Systems Programming · C and the ABI

Integer Object Representation and Conversion at the Boundary

How signed and unsigned values are stored, what integer promotion silently does to an operand, and which conversions are defined, implementation-defined, or undefined.

A comparison that reads if (len - 1 >= 0) behaves differently depending on whether len is int or size_t, and the reason is not the comparison operator. It is the representation of the two types and the conversion the compiler applies before the operator runs. Integer types in C are defined by a bit-level model, and most of the surprising behaviour in arithmetic code (wraparound, sign extension, the negative value that becomes enormous) falls out of that model plus the promotion rules.

The model is worth stating precisely, because it determines what a compiler is allowed to assume and therefore what it is allowed to optimise away.

Value bits, sign bit, and what the standard leaves open

An integer type of width N bits is not necessarily N bits of value. The object representation of a signed integer consists of a sign bit, some number of value bits, and possibly padding bits whose values are unspecified. CHAR_BIT gives the number of bits in a byte and is 8 on every mainstream target, but the standard permits larger. sizeof measures bytes, so a 32-bit int is 4 bytes only where CHAR_BIT is 8.

For unsigned types the value bits directly encode the value in binary, with no sign bit and no padding in the value range; an unsigned type with N value bits represents 0 through 2^N - 1 exactly. For signed types, the historical standard permitted three encodings: two’s complement, ones’ complement, and sign-magnitude. The last two have a representation for negative zero and, in the case of ones’ complement, a distinct bit pattern for each magnitude. C23 requires two’s complement for signed integer types, which removes the negative-zero case and makes the bit pattern of a negative value the two’s complement of its magnitude. Code written against earlier standards cannot assume that, although in practice every target in current use has been two’s complement for decades.

The asymmetry between signed and unsigned ranges is the root of most conversion surprises. For a 32-bit type, signed covers -2^31 through 2^31 - 1, and unsigned covers 0 through 2^32 - 1. Both have 2^32 distinct values; the signed range is shifted down by one power of two. There is no representation for 2^31 in the signed type, which is why the most negative value has no positive counterpart and why negating it is undefined.

Promotion: the conversion nobody writes

Before an arithmetic operator is applied, operands of integer type narrower than int undergo integer promotion. If int can represent every value of the original type, the operand converts to int; otherwise it converts to unsigned int. On a target with 8-bit char and 32-bit int, this means char, short, and their unsigned variants all become int. An unsigned char holding 255 becomes the int value 255, not -1, because the value is preserved and int is wide enough.

Promotion is why the expression (unsigned char)200 + (unsigned char)100 yields 300 rather than 44. Both operands become int and the addition is signed, because int is wide enough to hold every value of unsigned char. On a target where int is 16 bits and unsigned short is also 16 bits, an operand of that type promotes to unsigned int instead, because int cannot hold values above 32767, and the addition then wraps modulo 65536 rather than being undefined. The same source therefore produces different values on different targets, with no diagnostic.

The usual arithmetic conversions then bring the two promoted operands to a common type. The rule that matters in practice: if the types have the same rank and one is unsigned, the result is unsigned; if the signed type has greater rank and can represent all values of the unsigned type, the unsigned operand converts to the signed type; otherwise both convert to the unsigned type of the signed operand’s rank. The consequence is that int compared with unsigned int converts the int to unsigned int. A -1 becomes UINT_MAX, and -1 < 1u is false.

That single rule explains the classic loop bug: a size_t counter counting down with i >= 0 never terminates, because the comparison is unsigned and the condition is always true. It also explains why subtracting from a size_t that is already zero produces a value near SIZE_MAX rather than a negative number.

#include <stddef.h>
#include <stdio.h>

int main(void)
{
    size_t n = 0;
    if (n - 1 > 0)                 /* unsigned wrap, always true here */
        puts("wrapped to a huge value");

    printf("%zu\n", (size_t)-1);   /* SIZE_MAX, not -1 */
    printf("%d\n",  -1 < 1u);      /* 0: -1 converted to unsigned */
    return 0;
}

Defined, implementation-defined, and undefined

Conversions between integer types fall into three categories, and distinguishing them is the practical skill.

A conversion to an unsigned type is fully defined: the value is reduced modulo 2^N, where N is the number of value bits. Converting -1 to unsigned int yields UINT_MAX; converting 300 to unsigned char yields 44. This is modular arithmetic, not saturation, and it is guaranteed by the standard rather than by the target.

A conversion to a signed type where the value does not fit is implementation-defined. The standard requires the implementation to document the result, and in practice two’s complement targets wrap. That documentation is why such conversions compile without a warning from many compilers at default settings; they are legal, merely unspecified in the source.

Arithmetic that overflows a signed type is undefined behaviour. This is a stronger statement than “wraps”. The compiler may assume no signed overflow occurs, and it uses that assumption for transformations: a loop condition like i + 1 > i can be treated as always true, and a check if (x + 1 < x) can be deleted entirely. The overflow itself may never be observable, because the optimiser removed the code that would have produced it. Unsigned arithmetic, by contrast, is defined to wrap, which is why hash functions and checksums are conventionally written over unsigned types.

Where a signed overflow check is genuinely needed, it must be written so that the check itself cannot overflow. Comparing against the type’s limits before the operation is the portable form. Compilers also provide builtins that perform the operation and report overflow without invoking undefined behaviour.

#include <limits.h>
#include <stdbool.h>

bool add_overflow(int a, int b, int *out)
{
    if ((b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b))
        return true;           /* would overflow; do not compute */
    *out = a + b;
    return false;
}

INT_MAX - b cannot overflow because b is positive, and INT_MIN - b cannot overflow because b is negative; both are representable. The guarded addition is the pattern; the direct a + b followed by a check is not, because by the time the check runs the undefined behaviour has already occurred.

Division follows its own rules. Integer division truncates toward zero, so -7 / 2 is -3 and -7 % 2 is -1. The one case with no defined result is INT_MIN / -1, whose mathematical result is not representable; the operation is undefined and traps on some architectures. A divisor of zero is likewise undefined for integers, where floating point would produce an infinity.

Sign extension, truncation, and the byte view

Widening a signed value to a wider signed type preserves the value by replicating the sign bit into the new high bits. Widening an unsigned value fills the new high bits with zeros. Narrowing discards the high bits and reinterprets what remains in the destination type, which for an unsigned destination is the modulo reduction and for a signed destination is implementation-defined.

The distinction is visible in the generated code and in any byte-level interface. A function declared to take int and called with a short will receive a value sign-extended to 32 bits, and the callee may assume the high bits are consistent with bit 31. A function declared to take unsigned int will receive the same bit pattern zero-extended, and a negative argument will arrive as a large positive value. Function prototypes without parameters (the old-style int f(); declaration) suppress the prototype information entirely, and the default argument promotions then apply: float becomes double, and narrow integers become int. Calling through such a declaration with a mismatched type is undefined behaviour, and the mechanism behind it is the same one covered in variadic calls and default argument promotions.

Endianness is a separate axis that becomes relevant as soon as bytes are read individually. A 32-bit value stored little-endian has its least significant byte at the lowest address; big-endian has the most significant byte there. Neither order is more correct, but a byte-level interface has to pick one, and the choice is part of the format rather than of the language. Mixing the two within one process is possible and routine; the error is in treating a buffer of bytes as a typed object without a defined conversion.

Type width is an interface decision

int is at least 16 bits, long at least 32, and long long at least 64. Those minimums are the only promises the standard makes, so any value that crosses a file, a socket, or a shared library boundary should use the fixed-width types from <stdint.h>, which exist only when the target provides an exact-width type. The optional types int_leastN_t and int_fastN_t trade exactness for availability and speed respectively, and intptr_t and uintptr_t are the only integer types guaranteed to hold a converted pointer.

Two more details complete the picture. The signedness of plain char is implementation-defined and differs between targets: it is signed on x86 Linux and unsigned on AArch64 Linux, which matters when a byte value above 127 is compared against zero. And enum has an implementation-defined underlying type, typically int but potentially smaller, so an enumeration used as a wire value needs a fixed-width companion type.

The practical rule that follows is to decide the width and signedness at the point where the value enters the program and convert once, explicitly, rather than letting promotion rules decide silently at each use. Explicit casts do not change the arithmetic, but they do move the decision into the source where it can be reviewed. The layout of the resulting objects in memory is a separate question, covered in struct padding and alignment, and the two interact whenever a struct is serialised.

References

  1. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
  2. ISO/IEC 9899:2024 — Programming languages — C — ISO/IEC
  3. GCC Manual — Integer Overflow Builtins — GNU Project
  4. SEI CERT C Coding Standard — INT32-C: Ensure that operations on signed integers do not result in overflow — Carnegie Mellon University Software Engineering Institute