Skip to content
mirtotoPublic

About

Lightweight and with minimal dependencies implementation of snprintf() C function.

Topics

Resources

Stars

12 stars

Watchers

1 watching

Forks

Latest commit

 

History

123 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

snprintf()

Lightweight, dependency-light implementation of the C snprintf() family. It is intended for small embedded or minimal builds where a full libc formatter is not available and where avoiding math.h is desirable.

The project aims to stay portable and close to libc behavior for the supported conversions, while still keeping the implementation small and easy to embed.

Features

  • portable formatter for common printf-style conversions
  • formatter implementation has no math.h dependency by default
  • supports the standard integer, floating-point, string, character, and pointer cases used by this project
  • exact floating-point conversions: the digits are those of the binary value, rounded to nearest, ties to even, so the output is the one of libc (see Floating-point conversions)
  • default behavior is intentionally libc-like and permissive
  • optional strict validation mode for safety-oriented builds
  • tested against the C library with a differential fuzz test (see Testing)

Function prototype

int snprintf(char *string, size_t length, const char *format, ...);

Parameters

Parameter Description
string Output buffer. If NULL, nothing is written, length is ignored and the function calculates the output length.
length Size of the output buffer, including the terminating null byte: at most length - 1 characters are written. 0 only measures the output.
format Format string controlling the output.
... Variadic arguments consumed by the format string.

Return value

Value Meaning
>= 0 Number of characters of the whole output, not counting the terminating null byte, also when the output is truncated. An output longer than INT_MAX is reported as INT_MAX. With SNPRINTF_LEGACY_LENGTH it is the number of characters written, that is length - 1, for a truncated output.
-1 A strict-mode validation failure.

This is the same as the C99 snprintf() returns, so a caller can tell that the output was truncated and retry with a bigger buffer. To find out the size of a buffer up front, call the function with a NULL string.

Optional legacy return value

Before version 3.2 the result of a truncated output was the number of characters written, not the length of the whole output, and %n stored that number. Define SNPRINTF_LEGACY_LENGTH to keep it, for a project that depends on it:

make CFLAGS="-DUSE_SNPRINTF_PREFIX -DSNPRINTF_LEGACY_LENGTH -Wall -Wextra -g"

In that mode nothing more can be written once the buffer is full, so the rest of the format is not processed at all and a %n behind that point is never stored. Apart from the returned length and the %n, the two modes write the same text into the buffer and leave the same bytes past its end.

The strict mode is the exception, because it always processes the whole format to validate it: also with SNPRINTF_LEGACY_LENGTH a %n behind a full buffer is reached, and stores the number of characters written.

Usage

Copy src/snprintf.c and include/snprintf.h into your project and include snprintf.h.

#include <stdio.h>
#include "snprintf.h"

int main(void) {
  const char *hello = "Hello";
  const char *world = "World";
  char msg[128] = {0};

  snprintf(msg, sizeof(msg), "%s %s!", hello, world);
  printf("%s\n", msg);
  return 0;
}

There are two ways to build it.

Next to the C library (recommended). Define USE_SNPRINTF_PREFIX. The functions are then called my_snprintf() and my_vsnprintf(), and snprintf.h makes snprintf and vsnprintf macros for them, so the code above calls this implementation and still links with the C library:

cc -DUSE_SNPRINTF_PREFIX -Iinclude main.c src/snprintf.c

Instead of the C library. Without the prefix the functions are snprintf() and vsnprintf() themselves. Some toolchains turn on _FORTIFY_SOURCE by default, with -O1 and above (the GCC of Ubuntu does), and hardening build flags often add it. The C library then replaces every snprintf() call in a file that includes <stdio.h> with __snprintf_chk(), and this implementation is not called at all, without a warning. Build with -U_FORTIFY_SOURCE (or -D_FORTIFY_SOURCE=0) then:

cc -U_FORTIFY_SOURCE -Iinclude main.c src/snprintf.c

Compatibility model

The default implementation is intentionally permissive and mirrors libc fallback behavior for malformed format strings when possible. This makes it behave like a lightweight libc-compatible formatter instead of a strict custom validator.

In particular:

  • the parser is permissive rather than fully strict printf-validator behavior
  • malformed or unsupported specifiers often fall back in a libc-like way
  • the design favors compatibility over custom safety checks
  • mismatched argument types remain undefined behavior, matching standard printf semantics

This is a good default for portability and compatibility, but it is not a safety-oriented validation layer.

Behavioral guarantees

The implementation is intentionally designed around libc-like snprintf() semantics, with a few documented differences.

  • The return value is the length of the whole output, not only the number of bytes that fit in the buffer, unless SNPRINTF_LEGACY_LENGTH is defined.
  • A NULL output pointer with length == 0 is treated as a length-only probe and returns the number of characters that would be produced.
  • A NULL output pointer with a nonzero length is also treated as a measurement call, as the C library does; nothing is written.
  • %n stores the output count seen so far, matching the C99 contract. In legacy mode it stores the number of characters written into the buffer instead.
  • For integer and string conversions, a precision is a minimum width for integers and a maximum number of bytes for strings; it never truncates the value in the integer case.
  • The buffer is always NUL-terminated when a writable target is supplied, even when the output is truncated.
  • Width and alignment follow the usual printf rules; when the format requests more output than the buffer can hold, only the prefix is written and the rest is counted for the return value.

Unsupported and malformed specifiers

The parser is permissive by default and tries to remain compatible with libc-style formatter behavior instead of rejecting malformed input immediately.

This includes cases where the implementation treats unsupported or malformed fragments as text instead of failing:

  • %5k prints % and continues with text; the unsupported conversion is effectively ignored.
  • %La prints %a and then continues with the remaining format.
  • %1$d is not supported and is treated as %d with the next argument consumed by the unsupported placeholder.
  • A format fragment after a non-standard or unknown conversion is treated as ordinary text, so the rest of the format still proceeds.

This permissive behavior is deliberate. It keeps the formatter portable and libc-like for embedded use, but it is not a strict validator and should not be treated as a parsing-safety layer.

Pointer formatting and platform dependence

%p is inherently implementation-defined, and the exact textual form depends on the target platform and the C library. This implementation does not promise a single universal pointer representation across all toolchains.

In practice:

  • a NULL pointer prints (nil)
  • a non-NULL pointer prints hexadecimal digits, commonly with a 0x prefix
  • the presence or absence of the 0x prefix, the exact casing, and the width/precision behavior may vary by platform and libc behavior

The implementation tries to match common libc conventions for portable code, but callers should not treat %p as a stable cross-platform serialization format.

Differences to the C library

  • %g and %G with the # flag always print as many significant digits as the precision says, also when rounding makes the number a power of ten, as the C standard requires: %#g of 999999.5 is 1.00000e+06, where glibc prints 1.e+06.
  • %s of a NULL pointer prints (null), whole or cut by the precision, but a precision below its 6 characters prints nothing at all, which is what glibc does.
  • The sign of a NaN is not printed, so a negative one gives nan, where glibc gives -nan. An infinity keeps its sign.
  • The length modifier l is ignored by %s and %c: there are no wide characters. The strict mode rejects a length modifier on both of them, and on %s also the flags the standard does not define there, and returns -1.
  • The precision is limited, see Width and precision.
  • The integral part of a floating-point number is limited to SNPRINTF_FLOAT_INTEGRAL_DIGITS digits, 309 by default and enough for every double. A smaller value saves stack, and a number with a longer integral part is then printed as a row of nines, with the exponent of %e and %g taken from the biggest number that fits, see Floating-point conversions.
  • Not supported are the length modifier L (long double), the conversions a and A, the extensions of other C libraries like %' for grouping and %m for the strerror string, and numbered arguments like %1$d. Only the % is printed: everything from it to the unsupported character goes with it, and the rest of the format is text, so %5k prints %, %La prints %a and %1$d prints %d. No argument is used, so a conversion after it reads the argument the unsupported one would have had. In strict mode the function returns -1.

Optional strict mode

For stricter validation, compile with -DSNPRINTF_STRICT.

When enabled, malformed or unsupported format specifiers return -1 instead of falling back in permissive mode. That is a specifier whose parts are out of order or repeated (%1 2d, %5*d, %5.3.2d, %l5d), a length modifier that does not exist or does not suit the conversion (%lhd, %zzd, %hf, %lp), and what the C standard does not define for a conversion:

Conversion Allowed Refused
%% nothing between the two characters any flag, width, precision or length (%5%)
%c width and - +, space, #, 0, a precision (%#c)
%s width, - and a precision +, space, #, 0
%p width, - and a precision +, space, #, 0 (%+p)
%n a length modifier any flag, width or precision (%5n)
%u / %o / %x / %X width, -, #, 0 +, space (%+u, % u)
%d / %i all the flags —

The flags of the signed integer conversions are not checked. The strict mode refuses the + and space flags for the unsigned integer conversions, which never print a sign (%+u and % u fail).

For example, enable it for the project build with:

make CFLAGS="-DUSE_SNPRINTF_PREFIX -DSNPRINTF_STRICT -Wall -Wextra -g"

This preserves the default compatibility model while giving embedded or security-sensitive builds an explicit safety option.

Optional math library support

The floating-point conversion is self-contained and does not require math.h or libm. Define SNPRINTF_USE_MATH to use signbit() from math.h for the sign of a zero, on a target whose double is not IEEE 754 binary64, instead of the built-in helper. The modf() helper of the earlier versions is not used any more: the integral and the fractional parts of a number are split with integer arithmetic, which is exact. On toolchains where these functions are provided by a separate math library, link with -lm, which the Makefile of this project adds for that backend on its own:

make CFLAGS="-DUSE_SNPRINTF_PREFIX -DSNPRINTF_USE_MATH -Wall -Wextra -g"

Configuration macros

Macro Description
USE_SNPRINTF_PREFIX Name the functions my_snprintf() and my_vsnprintf() and define snprintf and vsnprintf as macros for them, to link with the C library. The tests need it. Without it a build with _FORTIFY_SOURCE calls __snprintf_chk() of the C library, not this implementation, see Usage.
SNPRINTF_STRICT Return -1 for a malformed or unsupported specifier.
SNPRINTF_LEGACY_LENGTH Return and store the number of characters written into the buffer instead of the length of the whole output, as before version 3.2.
SNPRINTF_USE_MATH Use signbit() of math.h for the sign of a zero, on a target whose double is not IEEE 754 binary64.
SNPRINTF_NO_LONGLONG Use long / unsigned long instead of long long / unsigned long long for the internal accumulator, so the code compiles on pre-C99 toolchains that lack long long. Floating-point conversions need a 53-bit mantissa and will not be exact on targets where unsigned long is narrower than 64 bits.
SNPRINTF_FLOAT_INTEGRAL_DIGITS Digits of the integral part of a double, 309 by default, which is enough for every double. A smaller number saves stack, but bigger numbers are then printed as a row of nines. Set it when compiling snprintf.c.
SNPRINTF_FLOAT_PRECISION Digits of the precision of %e, %E, %f, %F, %g and %G, 29 by default. The C standard asks for at least 999, so set it higher, e.g. to 999, to be conformant. The stack grows by about 3 bytes per digit for %e and %g, and by about 1 for %f. A bigger precision is lowered to this one, or fails with -1 in the strict mode. Set it when compiling snprintf.c.

Supported format specifiers

Types

Type Description
d / i signed decimal integer
u unsigned decimal integer
o unsigned octal integer
x / X unsigned hexadecimal integer
f / F decimal floating point
e / E scientific notation
g / G %e or %f style, by the exponent, not by the length
c character
s string, (null) for a NULL pointer
p pointer, (nil) for a NULL pointer
n length of the whole output so far, see Optional legacy return value
% percent sign

Length modifiers

Modifier Description
hh signed char / unsigned char
h short / unsigned short
l long / unsigned long
ll long long / unsigned long long
z size_t / signed size_t
t ptrdiff_t / unsigned ptrdiff_t
j intmax_t / uintmax_t

Flags

Flag Description
- left-justify
+ force a leading plus sign for positive signed numbers
# alternate form (0x, 0X, 0), keep the point and the trailing zeros of floating-point numbers
* width and/or precision supplied as an int argument
0 zero-pad numeric output, ignored with the - flag and with the precision of an integer
space prefix a blank for positive signed values, ignored with the + flag

Width and precision

Both are decimal numbers or a star. A negative width from a star is the - flag with the width made positive, a negative precision from a star is no precision, and a lone dot is the precision 0.

Conversion Precision
integers minimal number of digits, the digits and the sign are not cut
p minimal number of hex digits; a NULL pointer prints (nil), which a smaller precision does not cut
f / F digits after the point, 6 by default, SNPRINTF_FLOAT_PRECISION at most
e / E digits after the point, 6 by default, SNPRINTF_FLOAT_PRECISION at most
g / G significant digits, 6 by default, SNPRINTF_FLOAT_PRECISION at most
s maximal number of characters

A bigger precision of a finite floating-point conversion is lowered to SNPRINTF_FLOAT_PRECISION, which is 29 by default, or fails with -1 in the strict mode. An infinity and a not-a-number have no digits, so their precision changes nothing and is never lowered or refused. The C standard asks for at least 999 digits, so compile with -DSNPRINTF_FLOAT_PRECISION=999 to be conformant.

Floating-point conversions

The digits are those of the exact binary value of the double, rounded to nearest, ties to even, like the C library does: %.2f of 1.005 is 1.00 and %.0f of 2.5 is 2. Every finite double is supported, subnormals too, as long as the integral part has no more than SNPRINTF_FLOAT_INTEGRAL_DIGITS digits, which is enough for all of them by default.

Infinity is printed as inf and not a number as nan, in capitals for F, E and G, and the 0 flag is ignored for them. The sign of NaN is not printed. Negative zero is printed with a minus sign. The significand and the exponent of the double are taken from its bits and no arithmetic is done on the value, so the conversions also work with -ffast-math, -ffinite-math-only and -fno-signed-zeros: -ffast-math turns on the flush-to-zero and the denormals-are-zero modes of the FPU, which lose a subnormal number, and drops the comparisons that find an infinity or a not-a-number.

That needs a double of the IEEE 754 binary64 format, which every target with the 53-bit mantissa the floating-point conversions need has. On a target whose double is not of that format the value is normalized with arithmetic instead, and the conversions are then not exact with -ffast-math.

The conversion uses integer arithmetic only. Its cost is some stack: floating() needs about 600 bytes with the default 309 digits, and about 300 bytes with -DSNPRINTF_FLOAT_INTEGRAL_DIGITS=40. SNPRINTF_FLOAT_PRECISION adds about 3 bytes per digit on top of that for %e and %g, and about 1 for %f, so -DSNPRINTF_FLOAT_PRECISION=999 needs about 3.7 kB for %e and for %g alike: %g measures the exponent first, but that call has returned before the digits are printed. These numbers were measured with gcc, another compiler may need a little more or less.

Testing

The project includes a small MinUnit-based suite in src/tests-snprintf.c and include/tests-snprintf.h. It needs -DUSE_SNPRINTF_PREFIX, because it calls the functions of this project next to the ones of the C library.

#include "tests-snprintf.h"

int main(void) {
  return tests_snprintf();
}

The fuzz directory has a differential fuzz test: every random format, with random flags, width, precision, length modifiers and values, is run through the C library and through this implementation, and the output, the returned length, the length for a NULL buffer and the truncated output are compared. The formats it leaves out are the differences listed above, plus %n and a few cases the C standard does not define, all listed at the top of fuzz-snprintf.c.

make -C fuzz check                                   # 3 seeds, 200000 formats each, with sanitizers
make -C fuzz check DEFS=-DSNPRINTF_STRICT            # also with -DSNPRINTF_LEGACY_LENGTH
make -C fuzz check DEFS=-DSNPRINTF_USE_MATH           # a smaller -DSNPRINTF_FLOAT_INTEGRAL_DIGITS reports differences
make -C fuzz check SAN= CFLAGS="-O2 -m32"            # 32 bits, without sanitizers
make -C fuzz run SEED=1234 ITERATIONS=1000000        # reproduce a failure

The unit tests and the fuzz test run in CI for gcc and clang, in all four builds (the default one, SNPRINTF_STRICT, SNPRINTF_LEGACY_LENGTH and SNPRINTF_USE_MATH), and in 32 bits for -m32 and -m32 -msse2 -mfpmath=sse, see .github/workflows/ci.yml. A separate job builds the unit tests with -ffast-math and runs them, so that the claim about the fast math stays true.

Authors

About

Lightweight and with minimal dependencies implementation of snprintf() C function.

Topics

Resources

Stars

12 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages