[PATCH 5/7] stdio-common: Handle subnormal values in the printf format tests

Matt Turner mattst88@gmail.com
Mon Aug 24 22:16:10 GMT 2026


The reference implementation used to verify the a and A conversions
assumed the value it was given was normal, splitting it into a
significand of the full width and an exponent.  That is not true below
the smallest normal value, where the exponent can go no lower and the
significand loses bits from the top instead, which is what makes the
leading hexadecimal digit of a subnormal come out as zero.  Given a
subnormal it would have produced a normalized result such as 0x1p-1070
where we print 0x0.000000000001p-1022.

Telling the two apart needs the minimum exponent for the type, which was
not among the data the test program supplies, so add it as a MINEXP
definition reported in a record of its own next to the working
precision. Then stop normalizing once that exponent is reached, and pad
the digits produced on the left, as there are no longer enough of them
to fill the field on their own.  The remaining conversions are
unaffected: they work from the value itself and never needed it
decomposed.

How far the exponent has to be shifted to sit after the significand
depends on the working precision, so hold MINEXP as reported and combine
the two only once a value is due to be converted, rather than requiring
the records to arrive in a particular order.  Where no MINEXP record
arrives the type has no subnormals and no clamping is applied.

None of the values iterated over were subnormal, so this could not be
observed.  Add DBL_TRUE_MIN and LDBL_TRUE_MIN to cover it, which also
exercises the smallest exponent with the remaining conversions.  How
many bits the leading hexadecimal digit holds varies with the type, one
for a 53 bit significand and four for a 64 bit one, so both are needed:
the wider case lands on a different exponent than the minimum for the
type, with LDBL_TRUE_MIN coming out as 0x0.000000000000001p-16385 rather
than at the p-16382 that the leading digit of a normal value would sit
at.  One sign is enough for either, as nothing in the sign handling
depends on the value being subnormal, and the records these produce are
among the most expensive in the test suite.

Tested on x86_64-linux-gnu, where all 672 results pass.
---
 .../tst-printf-format-skeleton-double.c       |  5 +-
 .../tst-printf-format-skeleton-ldouble.c      |  5 +-
 stdio-common/tst-printf-format-skeleton.c     | 23 ++++++++-
 stdio-common/tst-printf-format.py             | 50 +++++++++++++------
 4 files changed, 62 insertions(+), 21 deletions(-)

diff --git ./stdio-common/tst-printf-format-skeleton-double.c ./stdio-common/tst-printf-format-skeleton-double.c
index 4dbf3fc1ed..ae3acb00f7 100644
--- ./stdio-common/tst-printf-format-skeleton-double.c
+++ ./stdio-common/tst-printf-format-skeleton-double.c
@@ -26,10 +26,11 @@
 #define REF_FMT ".35e"
 #define REF_VAL(v) (v)
 #define PREC DBL_MANT_DIG
+#define MINEXP DBL_MIN_EXP
 typedef double type_t;
 static const type_t vals[] =
-  { -HUGE_VAL, -DBL_MAX, -DBL_MIN, -0.0, -NAN, NAN, 0, DBL_MIN,
-    DBL_MAX, HUGE_VAL };
+  { -HUGE_VAL, -DBL_MAX, -DBL_MIN, -0.0, -NAN, NAN, 0, DBL_TRUE_MIN,
+    DBL_MIN, DBL_MAX, HUGE_VAL };
 static const char length[] = "";
 
 #include "tst-printf-format-skeleton.c"
diff --git ./stdio-common/tst-printf-format-skeleton-ldouble.c ./stdio-common/tst-printf-format-skeleton-ldouble.c
index d959f9dc2d..e323d621bb 100644
--- ./stdio-common/tst-printf-format-skeleton-ldouble.c
+++ ./stdio-common/tst-printf-format-skeleton-ldouble.c
@@ -27,10 +27,11 @@
 #define REF_FMT ".35Le"
 #define REF_VAL(v) (v)
 #define PREC LDBL_MANT_DIG
+#define MINEXP LDBL_MIN_EXP
 typedef long double type_t;
 static const type_t vals[] =
-  { -HUGE_VAL, -LDBL_MAX, -LDBL_MIN, -0.0, -NAN, NAN, 0, LDBL_MIN,
-    LDBL_MAX, HUGE_VAL };
+  { -HUGE_VAL, -LDBL_MAX, -LDBL_MIN, -0.0, -NAN, NAN, 0, LDBL_TRUE_MIN,
+    LDBL_MIN, LDBL_MAX, HUGE_VAL };
 static const char length[] = "L";
 
 #ifndef TIMEOUT
diff --git ./stdio-common/tst-printf-format-skeleton.c ./stdio-common/tst-printf-format-skeleton.c
index c880da55e2..6250792d44 100644
--- ./stdio-common/tst-printf-format-skeleton.c
+++ ./stdio-common/tst-printf-format-skeleton.c
@@ -38,6 +38,9 @@
 		the argument type handled; usually for floating-point
 		conversions only, but it may be required for 128-bit or
 		wider integer data types as well.
+   MINEXP	[optional] Minimum exponent integer constant.  Set to the
+		*_MIN_EXP value for the argument type handled, so that
+		subnormal values can be told apart from normal ones.
 
    Typedefs:
    type_t	Variadic function argument type.  Define to the promoted
@@ -79,6 +82,11 @@
 #ifndef PREC
 # define PREC 0
 #endif
+/* Set to the minimum exponent for the type handled; zero where there is
+   no such thing, in which case no value can be subnormal.  */
+#ifndef MINEXP
+# define MINEXP 0
+#endif
 
 /* The list of conversions permitted for the '#' flag, the '0' flag,
    and precision respectively.  */
@@ -294,8 +302,13 @@ do_printf_flags (char *fmt, size_t idx, const char *l, char c, type_t val)
 
    prec:<PREC>
 
-   is produced at the beginning.  Then for each VAL from VALS a block
-   of records is produced starting with:
+   is produced at the beginning, followed by this one if MINEXP is
+   nonzero:
+
+   minexp:<MINEXP>
+
+   Then for each VAL from VALS a block of records is produced starting
+   with:
 
    val:<VAL>
 
@@ -328,6 +341,12 @@ do_test (int argc, char *argv[])
       return EXIT_FAILURE;
     }
 
+  if (MINEXP != 0 && printf ("minexp:%i\n", MINEXP) < 0)
+    {
+      perror ("printf");
+      return EXIT_FAILURE;
+    }
+
   c = *argv[1];
   for (v = 0; v < array_length (vals); v++)
     {
diff --git ./stdio-common/tst-printf-format.py ./stdio-common/tst-printf-format.py
index dc4fbae1da..f1dd3504d1 100644
--- ./stdio-common/tst-printf-format.py
+++ ./stdio-common/tst-printf-format.py
@@ -286,18 +286,26 @@ def render_f(fr, prec, alt, strip):
     return whole
 
 
-def to_significand(fr, mant_bits):
-    """Split the positive Fraction FR into an integer significand of exactly
-    MANT_BITS bits and a power of two, returned as (significand, exponent).
+def to_significand(fr, mant_bits, min_exp):
+    """Split the positive Fraction FR into an integer significand and a
+    power of two, returned as (significand, exponent).
 
-    FR comes from a value of that many significand bits, so the split is
-    exact.  Subnormals are not among the values the generator uses and are
-    not handled here."""
+    The significand has exactly MANT_BITS bits for a normal value.  Below
+    the smallest normal the exponent cannot go any lower, so it stops at
+    MIN_EXP and the significand loses bits from the top instead, which is
+    what makes the leading hexadecimal digit of a subnormal come out zero.
+    MIN_EXP is None where the format has no subnormals.  FR comes from a
+    value of this format, so the split is exact either way."""
     num, den, exp = normalize(fr.numerator, fr.denominator, mant_bits)
+    if min_exp is not None and exp < min_exp:
+        # Subnormal: scale back up to the smallest exponent the format has,
+        # which drops the significand below its full width.
+        den <<= min_exp - exp
+        exp = min_exp
     return num // den, exp
 
 
-def render_a(fr, mant_bits, prec, alt, upper):
+def render_a(fr, mant_bits, min_exp, prec, alt, upper):
     """Render the non-negative Fraction FR in the style of 'a'.
 
     The number of bits the leading hexadecimal digit holds is whatever is
@@ -305,14 +313,15 @@ def render_a(fr, mant_bits, prec, alt, upper):
     digits, which is how the significand ends up written out without any
     shifting.  For a 53 bit significand that leaves one bit, so the leading
     digit is 1; for a 64 bit one it leaves four, so the leading digit runs
-    from 8 to f."""
+    from 8 to f.  A subnormal has no bit to put there and so leads with a
+    zero, its exponent being the smallest the format provides."""
     lead_bits = ((mant_bits - 1) % 4) + 1
     nfrac = (mant_bits - lead_bits) // 4
     if fr == 0:
         digits, exp = "0" * (1 + nfrac), 0
     else:
-        mant, exp = to_significand(fr, mant_bits)
-        digits = "%x" % mant
+        mant, exp = to_significand(fr, mant_bits, min_exp)
+        digits = ("%x" % mant).zfill(1 + nfrac)
         exp += 4 * nfrac
 
     if prec is None:
@@ -351,7 +360,7 @@ def render_e(fr, prec, alt, upper, strip):
                            "-" if exp < 0 else "+", abs(exp))
 
 
-def convert_float(value, spec, mant_bits, cache):
+def convert_float(value, spec, mant_bits, min_exp, cache):
     """Convert VALUE, an exact Fraction.  CACHE memoizes rendered digits for
     the value currently being converted."""
     conv = spec.conv
@@ -369,7 +378,8 @@ def convert_float(value, spec, mant_bits, cache):
     body = cache.get(key)
     if body is None:
         if conv in "aA":
-            body = render_a(fr, mant_bits, spec.prec, spec.alt, upper)
+            body = render_a(fr, mant_bits, min_exp, spec.prec, spec.alt,
+                            upper)
         elif conv in "gG":
             sig = 1 if prec == 0 else prec
             _, exp = decimal_digits(fr, sig) if fr != 0 else ("", 0)
@@ -395,11 +405,13 @@ class Block:
     """The state a run of records shares: the C type they exercise and the
     single value they all convert."""
 
-    __slots__ = ("mant_bits", "val_text", "value", "special",
-                 "neg_zero", "cache")
+    __slots__ = ("mant_bits", "type_min_exp", "min_exp", "val_text",
+                 "value", "special", "neg_zero", "cache")
 
     def __init__(self):
         self.mant_bits = 0
+        self.type_min_exp = None
+        self.min_exp = None
         self.set_value("")
 
     def set_value(self, text):
@@ -413,6 +425,12 @@ class Block:
     def interpret(self, conv):
         """Interpret the value text as the C type conversion CONV takes."""
         if conv in FLOAT_CONVS:
+            # *_MIN_EXP is the exponent of the smallest normal value with
+            # the radix point before the significand; shift it to sit after
+            # the significand, as to_significand expects.  Where it was not
+            # reported the type has no subnormals.
+            self.min_exp = (None if self.type_min_exp is None
+                            else self.type_min_exp - self.mant_bits)
             lowered = self.val_text.lower()
             if "inf" in lowered or "nan" in lowered:
                 self.special = "nan" if "nan" in lowered else "inf"
@@ -437,7 +455,7 @@ class Block:
             if self.special is not None:
                 return convert_special(self.special, self.neg_zero, spec)
             return convert_float(self.value, spec, self.mant_bits,
-                                 self.cache)
+                                 self.min_exp, self.cache)
         if conv in INT_CONVS:
             return convert_int(self.value, spec)
         if conv == "c":
@@ -484,6 +502,8 @@ def main():
 
         if raw.startswith("prec:"):
             block.mant_bits = int(raw[5:])
+        elif raw.startswith("minexp:"):
+            block.type_min_exp = int(raw[7:])
         elif raw.startswith("val:"):
             block.set_value(raw[4:])
         elif raw.startswith("%"):
-- 
2.54.0



More information about the Libc-alpha mailing list