TRT
Sign in

Foundations · Module 2 · Lesson 4 of 10

Fixed-Width Types and Conversions

Eliminate cross-platform data model ambiguities using stdint.h, format fixed types with inttypes.h, and master casting semantics.

FreeAbout 18 minutes

By the end you can

  • Select exact-width, minimum-width, and pointer-sized integer types from stdint.h
  • Format portable integer types correctly using PRI macros from inttypes.h
  • Predict truncation and bit-pattern preservation during explicit downcasting
  • Store memory addresses safely within integer containers using intptr_t and uintptr_t

Builds on Primitive Types and Integer Encodings.

01 · A question first

Why does casting a pointer to long succeed without warnings on 64-bit Linux but truncate memory addresses on 64-bit Windows? And why does saving an int to a binary file on one machine cause a crash when parsed on another? Fixed-width types and casting rules solve these hardware portability traps.

Make a guess, then step through the trace below to check it.

02 · Watch it run

Truncation during downcasting and pointer storage in uintptr_tstep 1 of 4
uint32_t wide = 0x12345678;uint16_t narrow = (uint16_t)wide;uint64_t widened = (uint64_t)narrow;uintptr_t ptr_as_int = (uintptr_t)&narrow;
  1. register 0eax: 0x12345678(looked at in this step)
  2. register 1ax: unallocated
  3. register 2rax: unallocated
  4. register 3rdi: unallocated

32-bit unsigned integer wide holds four hexadecimal bytes 0x12345678 in register eax.

Machine work: movl $0x12345678, %eax

03 · The idea

Start with the problem

Early C programs relied on basic types such as short, int, and long. Because early software ran on specific minicomputers or single-architecture personal machines, developers assumed an int was always 16 bits or a long was always 32 bits.

As C expanded across architectures ranging from 8-bit microcontrollers to 64-bit supercomputers, this flexibility became a major source of bugs.

Consider a development team writing a network service. They define a binary protocol header on an x86-64 Linux server:

c
struct PacketHeader {
    unsigned int magic;
    unsigned long payload_length;
    unsigned short checksum;
};

On Linux (where the LP64 data model applies), unsigned int is 4 bytes, unsigned long is 8 bytes, and unsigned short is 2 bytes. The server sends this 14-byte payload header across the network.

A client running on 64-bit Windows (under the LLP64 data model) receives the packet and unpacks it using the identical header definition. On Windows, unsigned long is only 4 bytes wide. The client reads 4 bytes where the server wrote 8 bytes. Every subsequent field offset is shifted by 4 bytes. The checksum fails, payload lengths decode as garbage, and the client aborts with corrupted memory.

Now consider pointer manipulation. A programmer needs to tag pointer bits or log an address as an integer:

c
void log_address(void *resource_ptr) {
    unsigned long raw = (unsigned long)resource_ptr;
    printf("Resource address: 0x%lx\n", raw);
}

On 64-bit Linux, this code compiles and runs cleanly because pointers and unsigned long are both 64 bits wide.

On 64-bit Windows, compiling this exact file generates an error:

text
warning: cast to pointer from integer of different size [-Wpointer-to-int-cast]

On Windows, a pointer occupies 64 bits, but unsigned long is only 32 bits wide. The cast discards the upper 32 bits of the pointer. If the application attempts to convert that integer back into a pointer and dereference it, the CPU jumps to an invalid truncated memory address, crashing with an access violation.

How can developers write C programs that maintain deterministic bit widths across every platform? And how can data types be safely converted without unintentional truncation or undefined behavior?


How the idea works

To resolve hardware data model discrepancies, the C99 standard introduced two foundational header files: <stdint.h> and <inttypes.h>.

Instead of guessing how many bytes an int or long occupies on a given target, these headers define integer types with explicit, unambiguous widths and machine semantics.

text
stdint.h Type Categories:
├── Exact-Width Types: int8_t, int16_t, int32_t, int64_t (and uint*_t)
├── Minimum-Width Types: int_least8_t, int_least16_t, ...
├── Fastest Minimum-Width Types: int_fast8_t, int_fast16_t, ...
├── Pointer-Sized Types: intptr_t, uintptr_t
└── Maximum-Width Types: intmax_t, uintmax_t

1. Exact-Width Integer Types

Exact-width types guarantee that the integer occupies an exact number of bits with two's complement encoding and no padding bits:

  • int8_t / uint8_t: Exactly 8 bits (1 byte). Range: -128 to 127, or 0 to 255.
  • int16_t / uint16_t: Exactly 16 bits (2 bytes). Range: -32,768 to 32,767, or 0 to 65,535.
  • int32_t / uint32_t: Exactly 32 bits (4 bytes). Range: approx -2.14 billion to +2.14 billion, or 0 to 4.29 billion.
  • int64_t / uint64_t: Exactly 64 bits (8 bytes). Range: approx -9.22 quintillion to +9.22 quintillion, or 0 to 18.44 quintillion.

When designing network packets, file format headers, or memory-mapped hardware interfaces, exact-width types guarantee that the memory layout on disk or over the network matches the developer's specification regardless of whether the code compiles on x86, ARM, or MIPS.

2. Pointer-Capable Integers: intptr_t and uintptr_t

When an integer container must hold a memory address, developers should avoid standard primitive types like long or int. Instead, <stdint.h> provides:

  • intptr_t: A signed integer type guaranteed to match the width of a pointer on the target platform.
  • uintptr_t: An optional unsigned integer type that can hold an object pointer converted to an integer and back without loss.

On common 32-bit and 64-bit targets, uintptr_t is often provided with enough value bits for an object pointer. The type is optional, so code that must support every conforming implementation should check whether the implementation defines it.

In step 4 of the trace above, taking the address of narrow and storing it inside uintptr_t ptr_as_int executes without losing a single bit of the virtual address. Converting a pointer to uintptr_t and back to the original pointer type is guaranteed by the standard to preserve the exact address:

c
int value = 42;
void *original_ptr = &value;
uintptr_t raw = (uintptr_t)original_ptr;
 
/* Bit manipulations or alignments can be performed on raw */
 
void *restored_ptr = (void *)raw;
/* original_ptr == restored_ptr is guaranteed to be true */

3. Pointer Differences and Sizes: size_t and ptrdiff_t

Provided by <stddef.h>, these types reflect physical memory architecture:

  • size_t: An unsigned integer wide enough to hold the maximum possible size of any object in memory. It is the type returned by the sizeof operator and used for object and buffer sizes.
  • ptrdiff_t: A signed integer wide enough to represent the result of subtracting two pointers that point into the same array.

4. Portable Printing with <inttypes.h>

Because uint64_t might be defined as unsigned long on Linux but unsigned long long on Windows, writing printf("%lu", val) causes compiler warnings on Windows, while printf("%llu", val) causes warnings on Linux.

The <inttypes.h> header solves this by providing compile-time string literal macros:

c
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
 
uint32_t count = 42;
uint64_t total_bytes = 1048576;
 
printf("Count: %" PRIu32 ", Total: %" PRIu64 " bytes\n", count, total_bytes);

During preprocessing, the compiler concatenates adjacent string literals:

  • On Linux LP64, PRIu64 expands to "lu". The format string becomes "Count: %u, Total: %lu bytes\n".
  • On Windows LLP64, PRIu64 expands to "llu". The format string becomes "Count: %u, Total: %llu bytes\n".

The resulting code compiles cleanly without warnings across all compliant platforms.

Common format macros include:

TypeOutput Macro (Decimal)Hex MacroInput Macro (scanf)
int32_tPRId32PRIx32SCNd32
uint32_tPRIu32PRIx32SCNu32
int64_tPRId64PRIx64SCNd64
uint64_tPRIu64PRIx64SCNu64
uintptr_tPRIuPTRPRIxPTRSCNuPTR

5. Type Conversions: Widening, Narrowing, and Casting

Type conversions in C occur either implicitly (via compiler rules) or explicitly (via casting).

Widening (Promotion): Converting a narrower type to a wider type (e.g., uint16_t to uint64_t). As shown in step 3 of the trace, widening preserves value without data loss. Unsigned types are zero-extended; signed types are sign-extended.

Narrowing (Downcasting): Converting a wider type to a narrower type (e.g., uint32_t to uint16_t).

  • For unsigned targets, the C standard explicitly defines truncation: the value is computed modulo 2^N, where N is the bit-width of the target. Higher bits are discarded, preserving only the lowest N bits. In step 2 of the trace, 0x12345678 downcast to uint16_t yields 0x5678.
  • For signed targets, if the original value cannot be represented in the narrower signed type, the result is implementation-defined (or raises an implementation-defined signal), though modern two's complement CPUs discard the upper bits.

Explicit Casting: The cast operator (type)expression instructs the compiler to override default conversion rules and interpret the evaluated expression as the target type.


Why it is correct

Fixed-width types provide two essential structural properties: architectural determinism and representation safety.

Architectural Determinism

Hardware architectures implement memory bus widths differently. An x86-64 CPU addresses memory in 64-bit words, an embedded ARM Cortex-M0 addresses 32-bit words, and an 8-bit AVR microchip addresses 8-bit bytes.

When data structures must cross machine boundaries—such as writing to a storage drive, communicating across a network socket, or sharing an mmap memory segment—relying on standard int or long introduces non-deterministic layout shifts. Exact-width types force the compiler to allocate identical bit patterns regardless of the host register architecture.

Representation Safety for Pointers

Pointers are physical memory addresses. On modern systems, operating system paging tables map these addresses into 32-bit or 64-bit virtual memory spaces.

A standard int cannot safely hold an address because on 64-bit platforms, an address requires 8 bytes while an int remains 4 bytes. An unsigned long is not safe because Windows LLP64 restricts long to 4 bytes.

When an implementation provides uintptr_t, it supplies an integer type suitable for converting an object pointer to an integer and back. This does not make pointer serialization portable across processes or machines, and it does not make arbitrary pointer arithmetic valid.


What it costs

Using fixed-width types involves practical engineering trade-offs in instruction efficiency and hardware alignment:

Consideration32-bit CPU Architecture64-bit CPU ArchitecturePrimary Cost Factor
Native register width32 bits64 bitsOperations on native widths run fastest
Emulating 64-bit math (uint64_t)Split into two 32-bit registersSingle 64-bit instruction32-bit CPUs require multi-instruction sequences
Using narrow types (uint8_t, uint16_t)Requires register masking / sign extensionRequires zero-extension or maskingExtra instruction overhead for small math
Cache efficiencyHigh (4-byte pointers)Reduced (8-byte pointers consume twice the cache space)Pointers consume 50% more L1 cache on 64-bit

Instruction Overhead for Non-Native Widths

Processors operate most efficiently on their native register width. If you perform arithmetic on uint8_t inside a loop on an x86-64 machine:

  1. The hardware loads the 8-bit byte into a 32-bit or 64-bit register.
  2. It promotes the operand to int (the integer promotion rule).
  3. It performs 32-bit addition.
  4. If writing back to uint8_t, it truncates the result or masks the upper bits.

If extreme performance is required for local loop index variables, <stdint.h> provides int_fast8_t or int_fast16_t. These types instruct the compiler: "Use the fastest integer type on this machine that has at least the requested number of bits." On a 64-bit system, int_fast16_t might resolve to a native 64-bit integer to maximize ALU throughput.


Pitfalls and variations

Pitfall 1: Truncation Bugs in Downcasting

Downcasting a wider integer to a narrower integer discards high-order bits without warning:

c
uint32_t user_balance = 70000;
uint16_t truncated_balance = (uint16_t)user_balance;
 
printf("Stored balance: %u\n", truncated_balance);

Because 70,000 exceeds the 16-bit maximum (65,535), 70000 % 65536 = 4464. The user's stored balance becomes 4,464 without any compile error.

To avoid silent truncation, validate boundaries before casting:

c
#include <stdint.h>
#include <stdbool.h>
 
bool safe_cast_to_u16(uint32_t src, uint16_t *dest) {
    if (src > UINT16_MAX) {
        return false; /* Truncation prevented */
    }
    *dest = (uint16_t)src;
    return true;
}

Pitfall 2: Casting Pointers to Plain int

Attempting to store or manipulate pointers within an int remains a widespread security flaw:

c
int value = 256;
void *alloc_mem = &value;
/* DANGEROUS: Compiles on 32-bit, crashes on 64-bit */
int handle = (int)alloc_mem; 
 
/* Later: */
void *recovered_mem = (void *)(uintptr_t)handle;

On 64-bit architectures, this drops the upper 32 bits of the address. The recovered_mem pointer points to an unmapped, corrupted address, triggering immediate crashes upon dereferencing. Always use uintptr_t or intptr_t when converting pointer addresses to integers.

Pitfall 3: Missing Format Specifiers with Exact Types

Writing standard format specifiers with exact types causes warnings when compiled across different platforms:

c
uint64_t timer = 500000;
/* Generates warning on Windows or 32-bit systems: */
printf("Timer: %lu\n", timer); 
 
/* Portable and warning-free everywhere: */
printf("Timer: %" PRIu64 "\n", timer);

Always use the uppercase PRI* macros from <inttypes.h> when formatting <stdint.h> variables.


Where it is used

Exact-width types and conversion mechanics are foundational across modern systems software:

  • File Format Specifications (PNG, ELF, SQLite): The ELF binary format specification defines file header fields strictly using exact-width types (Elf64_Addr mapping to uint64_t, Elf64_Half mapping to uint16_t) so binary executables parse identically regardless of host tooling.
  • Network Protocol Stacks: The Linux networking stack (netfilter, TCP/IP packet parsers) defines IP packet structures using exact types like uint8_t ttl and uint16_t id to match RFC packet header specifications down to the exact byte.
  • Cryptographic Implementations: Ciphers such as AES and SHA-512 operate on strict 32-bit and 64-bit words. Any variation in integer width breaks hash computations. Cryptographic software relies entirely on uint32_t and uint64_t.

In the next lesson, we will examine how these data types interact in expressions: exploring operators, operator precedence, sequence points, and evaluation order.

04 · Prove it

Question 1 of 3 · answer from memory

Which optional type from `<stdint.h>` is intended to hold an object pointer converted to an integer and back without loss?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save