Arbitrary bit-precise varying argument access, stores and loads

ISO/IEC JTC1/SC22/WG14 N3921 2026-07-12

Jakub Jelínek, jakub@redhat.com

Abstract
ChangeLog
    Revision 0
Introduction and Motivation
Proposal
Possible variants
Implementation experience
Wording
Acknowledgments

Abstract

This proposal offers type-generic macros for varying bit-precise integer type argument access whose width is determined at runtime, storing their values as array of integers or storing bit-precise integer value computed from array of integers.

ChangeLog

Revision 0 2026-07-12

— Initial release.

Introduction and Motivation

C23 introduced bit-precise integer types, however it lacks standard facilities for writing such values to output streams and reading such values from input streams. The N2858 paper proposes reasonable extensions for printf and scanf family of functions to support those. Users might also want to add similar support to their own libraries. However there is an implementability problem, while there are just a few standard integer types and optionally a few extra extended integer types, there can be thousands or millions of distinct bit-precise integer types.

In order to support following example


  _BitInt(856) x;
  printf("%wb175u",
         42636895407386080189113439121686496074204655714062974uwb);
  scanf("%wb856d", &x);

the C library needs to use separate va_arg macro invocation for each supported width of bit-precise integer types in the printf case or store separately each supported width of bit-precise integer types through the pointer obtained through va_arg macro. If an implementation supports the bare minimum of bit-precise integer types and BITINT_MAXWIDTH is very small, e.g. equal to LLONG_WIDTH, this can be easily implemented e.g. using



#include <stdarg.h>
#include <stddef.h>
#include <limits.h>

void
read_bitint_n(va_list *ap, size_t n, unsigned long *p, bool end, bool uns)
{
  unsigned long long r = 0;
  if (uns)
    switch (n)
      {
#define A(n) case n: r = va_arg(*ap, unsigned _BitInt(n)); break;
#define B(n) A(n##0) A(n##1) A(n##2) A(n##3) A(n##4) \
             A(n##5) A(n##6) A(n##7) A(n##8) A(n##9)
        A(1) A(2) A(3) A(4) A(5) A(6) A(7) A(8) A(9)
        B(1) B(2) B(3) B(4) B(5)
        A(60) A(61) A(62) A(63) A(64)
      }
  else
    switch (n)
      {
#undef A
#define A(n) case n: r = va_arg(*ap, _BitInt(n)); break;
        A(1) A(2) A(3) A(4) A(5) A(6) A(7) A(8) A(9)
        B(1) B(2) B(3) B(4) B(5)
        A(60) A(61) A(62) A(63) A(64)
      }
  r <<= (LLONG_WIDTH - n);
  if (uns)
    r >>= (LLONG_WIDTH - n);
  else
    r = ((long long) r) >> (LLONG_WIDTH - n);
  size_t w = (n + LONG_WIDTH - 1) / LONG_WIDTH;
  if (end)
    for (size_t i = 0; i < w; ++i)
      p[w - 1 - i] = r >> (i * LONG_WIDTH);
  else
    for (size_t i = 0; i < w; ++i)
      p[i] = r >> (i * LONG_WIDTH);
}

If BITINT_MAXWIDTH is large (GCC currently uses value 65535 for it on architectures which do support bit-precise integers, Clang on selected architectures uses 8388608), such an approach is not feasible, e.g. Godbolt example doesn't even compile in reasonable time, and even when commenting out 59000 of the 65535 cases it resulted in 1.3MiB of code when compiled by GCC. Furthermore, if the maximum bit-precise integer width is large, converting all bit-precise integer values to the largest one is inefficient, for the C library internals it is best to represent the arbitrary values as an array of limbs and either handle conversions from or to strings on it directly (for decimal I/O divide and modulo each limb by 10 e.g. using arithmetics on a type larger than the limb type), or use some Multiple Precision Arithmetics library under the hood.

The ABI for bit-precise integer types, their size, alignment, how they are passed as function arguments and returned from functions differs between different architectures (if it is already defined at all). In most cases however if the bit-precise width is smaller than certain small constants, their size, alignment and argument passing follows passing of selected standard integer types which has width larger or equal to the width of the bit-precise integer, and for larger width they are handled as usually as a structure containing single member with type array of some integer type. That integer type differs between different architectures, common width of the limb type are 64 bits, 128 bits or 32 bits. The knowledge of that is usually confined to compilers which support bit-precise integer types, the C library and especially user code doing similar format string handling as the C library ideally shouldn't hardcode such details and also details of the va_list type. So, this paper proposes macros which can be used both for the C library implementation and in other user code as well.

Hardcoding for the macros a particular user limb type (element of the array to be filled with bits of the read bit-precise integer value) is possible, but not very C library implementor or user-friendly. Mandating that the limb type matches the limb type in a particular bit-precise integer ABI would mean the library would need to implement the reading and writing of bit-precise integers several times, e.g. once for some 64-bit, once for some 32-bit and once for some 128-bit integer type if not more. Hardcoding some arbitrarily chosen standard integer type, e.g. unsigned long int, is possible, but would require extra memory and time to recode it into the form the library needs. Always using a very small limb type, such as unsigned char would mean too many limbs, usually the library will want as large limb type as possible provided that it can perform efficiently all operations it needs, or when using an underlying Multiple Precision Arithmetic library determined by the requirements of that library. Another needed choice is the ordering of the limbs within the array of limbs, whether the least significant limb comes first (little-endian) or last (big-endian). It is certainly possible to hardcode e.g. little-endian ordering of the limbs, however if an underlying library is involved and needs a different ordering the C library or user code would need to swap the limbs in a loop.

Yet another concern is when trying to build a C library which is supposed to support bit-precise integer I/O with a compiler which doesn't support them or doesn't support this proposal. One possibility is to pre-compile a simple function using these macros, for the va_arg_bitint case taking va_list * argument using a compiler which supports these into assembly and use that as fallback when compiler which builds the C library doesn't support it. In such a case, it is certainly useful when the library can choose any limb type it wants instead of requiring to match a particular ABI limb type.

For scanf there is another problem. It is undefined behavior to use va_arg (ap, _BitInt(64) *) when the next argument has _BitInt(65) * or _BitInt(927) * type. So, in order to implement bit-precise integer scanf support, one still needs pedantically a large switch with thousands or millions of similar pointer types, even when it will actually most likely work properly with just one va_arg invocation. While there probably are legacy reasons why C needs to allow different representation of e.g. function pointers and data pointers, bit-precise integer types are a recent addition to C and to the author's knowledge there are no ABIs which would pass pointers to bit-precise integers with different width differently. Thus this paper suggests at least requiring compatibility of pointer to bit-precise integer types through va_arg.

Proposal

Introduce 3 new type-generic macros,


#include <stdarg.h>
void va_arg_bitint(va_list ap, size_t n, type *limbptr, bool big_endian, bool uns);

and


#include <stdbit.h>
void stdc_load_bitint(void *bitintptr, size_t n, const type *limbptr, bool big_endian, bool uns);
void stdc_store_bitint(const void *bitintptr, size_t n, type *limbptr, bool big_endian, bool uns);

The va_arg_bitint macro executes


if (uns)
  stdc_store_bitint(&(unsigned _BitInt(n)){va_arg(ap, unsigned _BitInt(n))}, n, limbptr, big_endian, uns);
else
  stdc_store_bitint(&(_BitInt(n)){va_arg(ap, _BitInt(n))}, n, limbptr, big_endian, uns);

except that n (nor the other macro arguments) is not required to be a constant expression, while _BitInt(N) keyword requires a constant expression N, so it actually works as if a large switch based on n handling all supported widths of bit-precise integers.

type can be any standard unsigned integer type or extended unsigned integer type. Bit-precise integer types are intentionally excluded from this set, supporting splitting of bit-precise integers into array of _BitInt(17) or array of _BitInt(921) wouldn't be very useful and would significantly complicate the implementation of the macros.

If type type has width W, then the stdc_load_bitint and stdc_store_bitint macros assume limbptr points to an array of (n + W - 1) / W elements and bitintptr points to an object of type _BitInt(N) if !uns or unsigned _BitInt(N) otherwise where N is equal to n.

stdc_store_bitint stores, if n is greater or equal than W, the least significant W bits of the bit-precise integer value into limbptr[big_endian ? (n + W - 1) / W - 1 : 0], if n is greater or equal than 2 * W, the second least significant W bits of the bit-precise integer value into limbptr[big_endian ? (n + W - 1) / W - 2 : 1], etc., up to the most significant ((n - 1) % W) + 1 bits of the bit-precise integer value, which is stored into least significant bits of limbptr[big_endian ? 0 : (n + W - 1) / W - 1]. If n % W != 0 the remaining bits are cleared if uns or sign-extended otherwise.

stdc_load_bitint conversely sets the least significant W bits of the bit-precise integer object to limbptr[big_endian ? (n + W - 1) / W - 1 : 0], the second last significant W bits of the bit-precise integer object to limbptr[big_endian ? (n + W - 1) / W - 2 : 1], etc. and finally the most significant ((n - 1) % W) + 1 bits to limbptr[big_endian ? 0 : (n + W - 1) / W - 1].

It is not necessary to expose the size_t type name or the stdc_store_bitint macro when including just the <stdarg.h> header, the intent of the wording is that it acts as if the macro is invoked and for promotion of the va_arg_bitint argument to size_t it can use some internal type name instead.

Possible variants

The above macros have both endianity (big_endian) and signedness (uns) as bool macro arguments and allow both of them to be constant or non-constant. At least the endianity but the signedness as well could be if the committee prefers it required to be a constant expression, or remove the argument or arguments and move the endianity and/or the signedness into the name of the macro, similar how e.g. stdc_load8_leuN functions have those in the function name and not as arguments. That said, signedness in the actual implementation requires just one simple conditional (whether to zero-extend or sign-extend the bits on stdc_store_bitint when storing the most significant limb), or for stdc_load_bitint also to guard just a few instructions, and endianity also can guard just a small amount of code.

Implementation experience

The author implemented built-in functions which can be used for these macros in GCC, so far only on x86-64 and i686 architectures and with the built-in function for stdc_load_bitint unfinished yet, but the design is flexible enough that other architectures can be added easily and the unfinished built-in function can be completed.

Implementations which support large BITINT_MAXWIDTH need to support these likely as compiler intrinsics, implementations which only support the bare minimum set of bit-precise integer types could use e.g. for va_arg_bitint something similar to the above code.

Wording

The wording is relative to the latest Working Draft at time of publication, N3886.

Modify 7.16.1 General paragraph 4:

The type declared is

va_list

which is a complete object type suitable for holding information needed by the macros va_start, va_arg, va_arg_bitint, va_end, and va_copy to access the varying arguments. Objects of type va_list are generally referred to as ap in this subclause. If an ap object is passed as an argument to another function and that function invokes the va_arg or va_arg_bitint macros on ap, the representation of ap in the calling function is indeterminate and ap shall be passed to the va_end macro before being passed to any other va_... macros.263) Whether a byte copy of va_list can be used in place of the original is implementation-defined.

Modify 7.16.2.1 General paragraph 1:

The va_start and, va_arg and va_arg_bitint macros described in this subclause shall be implemented as macros, not functions. It is unspecified whether va_copy and va_end are macros or identifiers declared with external linkage. If a macro definition is suppressed to access an actual function, or a program defines an external identifier with the same name, the behavior is undefined. Each invocation of the va_start and va_copy macros shall be matched by a corresponding invocation of the va_end macro in the same function.

Modify 7.16.2.2 The va_arg macro paragraph 2:

The va_arg macro expands to an expression that has the specified type and the value of the next argument in the call. The parameter ap shall have been initialized by the va_start or va_copy macro (without an intervening invocation of the va_end macro for the same ap). Each invocation of the va_arg macro modifies ap so that the values of successive arguments are returned in turn. The behavior is undefined if there is no actual next argument. The parameter type shall be an object type name. If type is not compatible with the type of the actual next argument (as promoted according to the default argument promotions), the behavior is undefined, except for the following cases:
— both types are pointers to qualified or unqualified versions of compatible types;
— both types are pointers to qualified or unqualified versions of bit-precise integer types;
— one type is compatible with a signed integer type, the other type is compatible with the corresponding unsigned integer type, and the value is representable in both types;
— one type is pointer to qualified or unqualified void and the other is a pointer to a qualified or unqualified character type;
— or, the type of the next argument is nullptr_t and type is a pointer type that has the same representation and alignment requirements as a pointer to a character type.265)

Add a new subclause after 7.16.2.2 The va_arg macro:

7.16.2.3 The va_arg_bitint macro

Synopsis


#include <stdarg.h>
void va_arg_bitint(va_list ap, size_t n, generic_limb_type *limbptr, bool big_endian, bool uns);

Description

The type-generic macro va_arg_bitint reads a value of the next argument in the call as if va_arg(ap, _BitInt(N)) is invoked if uns argument is false or as if va_arg(ap, unsigned _BitInt(N)) otherwise where N is constant equal to n, stores that value into a compound literal of the same type and then writes that value into an array pointed to by limbptr as if the type-generic macro stdc_store_bitint has been invoked with the address of the compound literal as first argument and the n, limbptr, big_endian and uns arguments passed as remaining arguments to stdc_store_bitint.

Add two new subclauses after 7.18.22 Endian-Aware 8-Bit Store:

7.18.23 Bit-Precise Integer Load

Synopsis


#include <stdbit.h>
void stdc_load_bitint(void *bitintptr, size_t n, const generic_limb_type *limbptr, bool big_endian, bool uns);

Description

The type-generic function stdc_load_bitint reads a value from the array pointed to by limbptr and writes it into an object with bit-precise signed integer type with width equal to n pointed to by bitintptr when uns is false or into an object with bit-precise unsigned integer type with width equal to n pointed to by bitintptr otherwise, provided that the generic_limb_type type is a standard unsigned integer type or extended unsigned integer type. If n is smaller than 1 or larger than BITINT_MAXWIDTH, the behavior is undefined.

Let W be the width of the generic_limb_type type.
Let E be (n + W - 1) / W. Let the computed value avalue be:
index=0E - 1 bindex × 2W×index
where bindex is:
limbptr[index], if big_endian argument is false;
— otherwise, limbptr[E - index - 1], if big_endian argument is true.

Let value be avalue modulo 2n.
If uns argument is true, then value is stored into the object pointed to by bitintptr;
otherwise, if value is smaller than 2n-1, then value is stored into the object pointed to by bitintptr;
otherwise, value – 2n is stored into the object pointed to by bitintptr.

7.18.24 Bit-Precise Integer Store

Synopsis


#include <stdbit.h>
void stdc_store_bitint(const void *bitintptr, size_t n, generic_limb_type *limbptr, bool big_endian, bool uns);

Description

The type-generic function stdc_store_bitint reads a value from the object with bit-precise signed integer type with width equal to n pointed to by bitintptr when uns is false or a value from the object with bit-precise unsigned integer type with width equal to n pointed to by bitintptr otherwise and writes it into the array pointed to by limbptr, provided that the generic_limb_type type is a standard unsigned integer type or extended unsigned integer type. If n is smaller than 1 or larger than BITINT_MAXWIDTH, the behavior is undefined.

Let W be the width of the generic_limb_type type. Let value be the loaded bit-precise integer type value, if uns is true with bit-precise unsigned integer type with width equal to n, otherwise with bit-precise signed integer type with width equal to n.
Let index be an integer in a sequence that
— starts from 0 and increments by W in the range [0, n), if the big_endian argument is false;
— starts from (n - 1) / W * W and decrements by W in the range [0, n), if the big_endian argument is true.

Let ptr_index be an integer that starts from 0. For each index in the order of the above-specified sequence:
— Sets the W bits in limbptr[ptr_index] to (generic_limb_type) (value >> index).
— Increments ptr_index by 1.

Acknowledgments

Many thanks to Joseph S. Myers, Martin Uecker and Aaron Ballman.