Foundations · Module 2 · Lesson 3 of 10
Primitive Types and Integer Encodings
Inspect binary representations of fundamental types, two's complement signed integer mechanics, and sign extension rules in memory.
By the end you can
- Calculate two's complement binary representations for signed integers
- Differentiate between machine word sizes across LP64 and LLP64 data models
- Predict promotion behaviors when mixing signed and unsigned operands
- Diagnose the distinction between defined unsigned modular arithmetic and undefined signed overflow
Builds on Anatomy of a C Program and Formatted I/O.
01 · A question first
Why does evaluating -5 < sizeof(int) return false on every modern machine? And why does incrementing 2147483647 as a signed integer trigger undefined behavior, while incrementing the same value as an unsigned integer reliably wraps to zero? The answers reside in the physical bit encodings and conversion rules of C.
Make a guess, then step through the trace below to check it.
02 · Watch it run
int8_t pos = 5;int8_t neg = -pos;uint8_t u = 255;u = u + 1;- byte 0pos: 0x05 (00000101)(looked at in this step)
- byte 1neg: unallocated
- byte 2u: unallocated
- byte 3result: none
Variable pos holds +5, represented as 00000101 in binary (0x05 hex).
Machine work: movb $5, -1(%rbp)
03 · The idea
Start with the problem
Modern programming languages often treat numbers as abstract mathematical entities that scale to any magnitude. In C, integer objects have implementation-defined widths and representations, and values are stored in hardware registers and memory with finite capacity. When a programmer forgets that numbers have boundaries, silent bugs occur.
Consider this loop written to validate incoming network packets against a maximum size:
#include <stdio.h>
int main(void) {
int packet_offset = -1;
if (packet_offset < sizeof(int)) {
printf("Offset is within safety bounds.\n");
} else {
printf("SECURITY ALERT: Offset out of bounds!\n");
}
return 0;
}Mathematically, -1 is less than 4. A developer reading this code expects "Offset is within safety bounds." to print every time.
When compiled and executed on modern x86-64 or ARM64 systems, the program prints:
SECURITY ALERT: Offset out of bounds!The machine concluded that -1 is greater than 4.
Now consider an everyday loop intended to print numbers down to zero:
for (unsigned int i = 5; i >= 0; i--) {
printf("%u ", i);
}This loop does not stop at zero. It counts down: 5 4 3 2 1 0, and then prints 4294967295 4294967294... forever in an infinite loop that maxes out a CPU core.
Why did -1 suddenly become greater than 4? Why does an unsigned variable below zero turn into four billion?
To write correct systems code, you must look below the variable names and understand how the CPU physically stores integers in silicon, how two's complement representation calculates negative values, and what the compiler does during implicit type conversions.
How the idea works
Every primitive integer variable in C is defined by its range, representation, and signedness. The width and representation are chosen by the implementation within the limits required by the standard; they are not all identical across targets.
Type Categories in C:
├── char (at least 8 bits)
├── short (at least 16 bits)
├── int (at least 16 bits, typically 32 bits)
├── long (at least 32 bits, 32 or 64 bits depending on OS)
└── long long (at least 64 bits)1. Unsigned Binary Representation
An unsigned integer uses pure positional binary notation. In an N-bit unsigned integer, each bit represents a power of two from 2^0 up to 2^(N-1).
An 8-bit unsigned integer (unsigned char) represents values from 0 to 2^8 - 1 (255):
Bit Position: 7 6 5 4 3 2 1 0
Weight: 128 64 32 16 8 4 2 1
Value (0x9B): 1 0 0 1 1 0 1 1
Calculation: 128 + 0 + 0 + 16 + 8 + 0 + 2 + 1 = 155When an unsigned calculation exceeds its maximum representable value (2^N - 1), the hardware drops the extra carry bit. In the trace above, step 3 begins with u = 255 (11111111). Step 4 adds 1: the sum is 256 (1 00000000 in 9 bits). Because u is an 8-bit container, the hardware discards the 9th bit, leaving 00000000. The value wraps back to 0 cleanly according to modular arithmetic rules.
2. Two's Complement Signed Representation
Two's complement is the dominant representation on current general-purpose systems and is required by C23, but older C standards permitted other signed representations. When this lesson discusses bit patterns such as 0xFB for -5, it is describing an 8-bit two's-complement target, not a universal rule for every C implementation.
A signed integer must represent both positive and negative values. Historically, computer architectures experimented with sign-magnitude and one's complement encodings. Two's complement is dominant on current general-purpose hardware, but portable lessons must distinguish that common target from guarantees made by the selected C language version.
In an N-bit two's complement integer, the most significant bit (MSB) does not represent +2^(N-1). Instead, it acts as a negative weight: -2^(N-1).
For an 8-bit signed integer (int8_t or signed char):
- Bit 7 has a mathematical weight of -128.
- Bits 0 through 6 retain their positive weights (+1, +2, through +64).
Bit Position: 7 6 5 4 3 2 1 0
Weight: -128 64 32 16 8 4 2 1
Value (0xFB): 1 1 1 1 1 0 1 1
Calculation: -128 + 64 + 32 + 16 + 8 + 0 + 2 + 1 = -5This explains step 2 of the trace above. To find the two's complement representation of -pos (where pos = 5), the CPU takes 00000101, inverts every bit (one's complement) to yield 11111010, and adds 1. The result is 11111011 (0xFB in hex), which decodes as -128 + 123 = -5.
The dynamic ranges for two's complement types of width N are asymmetrical:
Range = [-2^(N-1), 2^(N-1) - 1]For an 8-bit signed integer:
- Minimum: -128 (
10000000) - Maximum: +127 (
01111111)
The absolute value of the minimum cannot be represented as a positive number in the same width because zero occupies one of the positive bit patterns (00000000).
3. Sign Extension vs. Zero Extension
When a smaller integer type is converted to a wider integer type (such as casting an 8-bit char to a 32-bit int), the CPU must fill the upper 24 bits:
- Zero Extension: Used for unsigned types. The upper bits are filled with zeros.
(uint8_t) 0xFB(251) becomes0x000000FB(251). - Sign Extension: Used for signed types. The CPU copies the value of the most significant bit (the sign bit) across all new upper bits to preserve the negative value.
(int8_t) 0xFB(-5) becomes0xFFFFFFFB(-5).
If sign extension were omitted, an 8-bit -5 (11111011) padded with zeros would become 0x000000FB—turning a negative number into positive 251.
4. Integer Promotion and Usual Arithmetic Conversions
This brings us back to the unexpected -1 < sizeof(int) comparison. C enforces a strict set of type conversion rules whenever an operator receives operands of different types:
- Integer Promotion: Any integer type smaller than
int(such ascharorshort) is promoted tointbefore any operation occurs. - Usual Arithmetic Conversions: When two operands have different types, the compiler converts one operand to match the type of the other according to a fixed conversion hierarchy:
- If an unsigned type has an integer conversion rank equal to or greater than the signed type, the signed operand is converted to the unsigned type.
In the expression -1 < sizeof(int):
- The
sizeofoperator returns a value of typesize_t. - By standard definition,
size_tis an unsigned integer (typicallyuint64_ton 64-bit platforms). - The left-hand operand
-1is a signedint. - Because
size_tis unsigned and wider thanint, the compiler converts-1tosize_t.
In 64-bit binary, signed -1 is represented as 0xFFFFFFFFFFFFFFFF. When interpreted as an unsigned size_t, that bit pattern equals 2^64 - 1, which is 18446744073709551615.
The CPU executes a comparison between 18446744073709551615 and 4. Because eighteen quintillion is greater than four, the condition evaluates to false.
Why it is correct
Understanding why two's complement won out over competing encodings explains how computer hardware is designed.
Single Adder Circuitry in Silicon
In earlier systems using sign-magnitude representation (where bit 7 was merely a sign flag), adding a positive number to a negative number required separate hardware: a subtraction unit, logic to check which number had greater absolute value, and a sign assignment step.
In two's complement, addition and subtraction use the exact same digital logic circuits:
Calculation: 5 + (-5)
00000101 (+5)
+ 11111011 (-5)
──────────
1 00000000 (sum = 0, discarded 9th bit)The arithmetic logic unit (ALU) does not need separate instructions for signed and unsigned addition. An x86 CPU executes the exact same add instruction for both signed int and unsigned int. The silicon performs identical binary addition; only the programmer's interpretation of the result changes.
Elimination of Negative Zero
In both sign-magnitude and one's complement representations, there are two distinct bit patterns for zero: positive zero (+0) and negative zero (-0).
Sign-magnitude and one's-complement representations have two zero encodings. Two's complement has one zero representation and, for an 8-bit two's-complement type, one extra negative value (-128).
What it costs
Primitive types have concrete physical costs in memory footprints, cache efficiency, and execution cycles:
| Data Type | Typical Width (32-bit x86) | Typical Width (64-bit Linux / LP64) | Typical Width (64-bit Windows / LLP64) | Value Range (Signed) |
|---|---|---|---|---|
char | 1 byte (8 bits) | 1 byte (8 bits) | 1 byte (8 bits) | -128 to +127 |
short | 2 bytes (16 bits) | 2 bytes (16 bits) | 2 bytes (16 bits) | -32,768 to +32,767 |
int | 4 bytes (32 bits) | 4 bytes (32 bits) | 4 bytes (32 bits) | Approximately -2.14 billion to +2.14 billion |
long | 4 bytes (32 bits) | 8 bytes (64 bits) | 4 bytes (32 bits) | Model dependent |
long long | 8 bytes (64 bits) | 8 bytes (64 bits) | 8 bytes (64 bits) | Approximately -9.22 quintillion to +9.22 quintillion |
size_t | 4 bytes (32 bits) | 8 bytes (64 bits) | 8 bytes (64 bits) | 0 to 2^N - 1 |
The LP64 vs. LLP64 Data Model Split
Notice the critical divergence in the table above for the long type.
- LP64 (Linux, macOS, BSD, iOS, Android): Pointers and
longare 64 bits wide. Anintis 32 bits wide. - LLP64 (64-bit Microsoft Windows): Pointers and
long longare 64 bits wide, butlongremains 32 bits wide.
This architectural difference creates severe cross-platform portability traps. Code written on Linux that assumes a long can hold a pointer address will truncate memory addresses when compiled on 64-bit Windows, causing crashes.
Memory Alignment and Cache Lines
Choosing integer sizes affects more than individual variable allocations:
- Storing 1,000,000 values as 64-bit
long longtypes consumes 8 MB of memory. - Storing the same values as 16-bit
shorttypes consumes only 2 MB.
Systems with caches transfer memory in cache-line-sized units, but line size and performance vary by target. Smaller objects can improve locality and fit more entries in a fixed cache, but the effect should be measured for the actual workload.
Pitfalls and variations
Pitfall 1: Signed Overflow Is Undefined Behavior (UB)
A common mistake is assuming that signed integers wrap around predictably like unsigned integers:
/* DANGEROUS CODE */
int check_overflow(int x) {
if (x + 1 > x) {
return 1; /* No overflow */
}
return 0; /* Overflowed */
}In the C standard (ISO C11 / C17 / C23), unsigned integer overflow wraps modulo 2^N by definition. However, signed integer overflow is Undefined Behavior (UB).
Because signed overflow is undefined, modern optimizing compilers (such as GCC and Clang at -O2 or -O3) assume that signed overflow never happens. The compiler reasons: "Mathematically, x + 1 is always greater than x. Therefore, x + 1 > x is always true."
The compiler eliminates the comparison entirely and compiles the function down to an unconditional return 1;. The overflow check is wiped out of the binary, leaving the application vulnerable to buffer overflows or arithmetic bypasses.
To check for signed overflow safely without triggering UB, check bounds before performing the operation:
#include <limits.h>
int safe_add(int a, int b) {
if (a > 0 && b > INT_MAX - a) {
return -1; /* Overflow would occur */
}
if (a < 0 && b < INT_MIN - a) {
return -1; /* Underflow would occur */
}
return a + b;
}Pitfall 2: The char Signedness Ambiguity
The C standard does not specify whether plain char is signed or unsigned by default. It is implementation-defined:
- On x86-64 Linux,
charis signed by default (-128 to +127). - On ARM and PowerPC architectures,
charis frequently unsigned by default (0 to 255).
char c = -1;
if (c == -1) {
/* Evaluates to true on x86-64 */
/* Evaluates to FALSE on ARM64! */
}On ARM, char c = -1 assigns the byte 0xFF. When promoted to int, it zero-extends to 255, which does not equal -1. Always declare variables as explicit signed char or unsigned char when using them for arithmetic or byte buffers rather than ASCII text.
Pitfall 3: Accidental Infinite Loops with Unsigned Iterators
As shown in the opening section, using unsigned loop variables with decrement operators leads to infinite loops:
/* BUG: Infinite loop */
for (unsigned int i = 10; i >= 0; i--) {
/* When i reaches 0, the next step i-- wraps to 4294967295 */
/* Since 4294967295 >= 0 is true, the loop never terminates */
}To iterate backward safely with unsigned types:
for (size_t i = 10; i > 0; i--) {
size_t index = i - 1;
/* Use index safely here from 9 down to 0 */
}Where it is used
Direct understanding of binary encodings and integer representations is required across production engineering:
- Network Protocols (TCP/IP): Network headers (IPv4, TCP, UDP) pack flags and lengths into specific bit-fields and bytes. Serializing and deserializing packet headers requires exact knowledge of byte representations and sign-extension behavior.
- Cryptography Algorithms: Modern ciphers (such as AES, SHA-256, and ChaCha20) rely heavily on 32-bit and 64-bit modular arithmetic and bitwise rotations. They strictly use unsigned integers (
uint32_t,uint64_t) because defined modular wrap-around is mandatory for cipher mathematics. - Audio and Signal Processing: Digital audio samples are commonly stored as 16-bit signed two's complement PCM values. Processing audio waveforms requires direct manipulation of negative and positive amplitudes across hardware sample buffers.
In the next lesson, we will see how modern systems eliminate data-model ambiguities across platforms by adopting the fixed-width integer standards of <stdint.h> and mastering explicit type conversion casts.
04 · Prove it
Question 1 of 3 · answer from memory
What is the two's complement binary representation of the decimal value -1 stored in an 8-bit signed char?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem