TRT
Sign in

Foundations · Module 3 · Lesson 2 of 9

Pointer Arithmetic and Type Stepping

Understand how compilers scale pointer increments by type size, compute element distances with ptrdiff_t, and safely traverse memory buffers.

FreeAbout 18 minutes

By the end you can

  • Calculate target memory addresses when adding or subtracting integers from typed pointers
  • Compute distance between pointers using the ptrdiff_t type
  • Recognize the bounds of legal pointer arithmetic defined by the one-past-the-end rule
  • Traverse raw byte streams safely by casting to uint8_t pointers

Builds on Virtual Memory Layout and Pointer Addressing.

01 · A question first

If an integer occupies four bytes, why does adding 1 to an int pointer change its hexadecimal value by 4 instead of 1? And why does subtracting two pointers yield the number of elements rather than the number of bytes between them?

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

02 · Watch it run

Pointer scaling during increment across typed array slotsstep 1 of 4
int arr[3] = {10, 20, 30};int *p = &arr[0];p = p + 1;ptrdiff_t diff = p - &arr[0];
  1. slot 00x7ffd00: 10(looked at in this step)
  2. slot 10x7ffd04: 20(looked at in this step)
  3. slot 20x7ffd08: 30(looked at in this step)
  4. slot 3p: unallocated
  5. slot 4diff: unset

Stack allocates 12 bytes for 3-element int array at base address 0x7ffd00 (slots at 0x7ffd00, 0x7ffd04, 0x7ffd08).

Machine work: movl 10, -12(%rbp); movl20, -8(%rbp); movl $30, -4(%rbp)

03 · The idea

Start with the problem

To anyone accustomed to normal arithmetic, pointer arithmetic looks deeply counterintuitive. If a variable contains the number 1,000 and you add 1 to it, you expect to get 1,001.

In C, adding 1 to a pointer almost never increments the underlying address by a single byte.

Consider an engineer writing a network parser who wants to inspect the second 32-bit integer in an incoming byte buffer:

c
#include <stdio.h>
#include <stdint.h>
 
int main(void) {
    uint32_t values[4] = {0x11111111, 0x22222222, 0x33333333, 0x44444444};
    uint32_t *ptr = values;
 
    printf("Starting address: %p\n", (void *)ptr);
    ptr = ptr + 1;
    printf("After ptr + 1:    %p\n", (void *)ptr);
 
    return 0;
}

When compiled and executed on an x86-64 machine, the output is not sequential in single bytes:

text
Starting address: 0x7ffd5ce8a040
After ptr + 1:    0x7ffd5ce8a044

The address advanced by 4 bytes, jumping from 0x...40 directly to 0x...44.

Now consider pointer subtraction. A developer wants to measure the distance between two elements in an array:

c
#include <stddef.h>
#include <stdio.h>
 
double samples[8];
double *start = &samples[0];
double *end = &samples[4];
 
ptrdiff_t span = end - start;
printf("Span: %td\n", span);

On a platform where sizeof(double) == 8, the physical distance between index 0 and index 4 is 32 bytes: 4 elements, each 8 bytes wide. However, pointer subtraction does not return a byte count. It returns the number of array elements between the two pointers, so the program prints:

text
Span: 4

Why did adding 1 advance the address by 4? Why did subtracting two pointers that sit 32 bytes apart yield 4 instead of 32? And what happens when a programmer attempts to perform arithmetic on a generic void * pointer?

To write bug-free low-level code, you must understand pointer scaling, the distinction between byte distance and element distance, and the strict rules governing pointer boundaries in the C standard.


How the idea works

Pointer arithmetic is not raw numeric arithmetic. In C, pointer arithmetic is element-relative arithmetic.

When you perform addition or subtraction on a pointer, the compiler calculates offsets in units of elements, not individual memory bytes. The compiler transparently scales every integer offset by the size of the underlying data type.

text
Pointer Arithmetic Formula:
Target Address = Base Address + (Index Offset * sizeof(*ptr))

1. Pointer Scaling Mechanics

The type signature of a pointer informs the compiler of the stride width:

  • If p is a char *: sizeof(*p) is 1. Evaluating p + 1 advances the memory address by 1 byte.
  • If p is a uint16_t *: sizeof(*p) is 2. Evaluating p + 1 advances the memory address by 2 bytes.
  • If p is an int * or uint32_t *: sizeof(*p) is 4. Evaluating p + 1 advances the memory address by 4 bytes.
  • If p is a double * or uint64_t *: sizeof(*p) is 8. Evaluating p + 1 advances the memory address by 8 bytes.
  • If p is a pointer to a struct of size 128 bytes: evaluating p + 1 advances the address by 128 bytes.

In the trace above:

  • Step 1 allocates an array of three 4-byte integers starting at address 0x7ffd00.
  • Step 2 points p directly at arr[0] (0x7ffd00).
  • Step 3 executes p = p + 1;. The compiler recognizes that p is an int *. Instead of emitting an instruction to add 1 to the register, it emits addq $4, %rax. The pointer now targets 0x7ffd04—the exact starting byte of arr[1].

This behavior ensures that *(p + i) always dereferences the i-th element of the contiguous buffer, regardless of how many bytes each individual element consumes.

2. Pointer Subtraction and ptrdiff_t

When two pointers target elements of the same array (or one position past the end), subtracting them yields the number of elements between them, not the raw byte count:

text
Element Distance = (Pointer 2 Address - Pointer 1 Address) / sizeof(*ptr)

Because the result can be negative (if ptr2 precedes ptr1 in memory), the result of pointer subtraction cannot be represented by an unsigned type like size_t.

The C standard header <stddef.h> defines the dedicated type ptrdiff_t specifically for this purpose:

  • It is a signed integer type.
  • On 32-bit platforms, it is typically a 32-bit signed integer (int32_t).
  • On 64-bit platforms, it is an 8-byte signed integer (int64_t).

In step 4 of the trace, subtracting &arr[0] (0x7ffd00) from p (0x7ffd04) computes (0x7ffd04 - 0x7ffd00) / 4 = 4 / 4 = 1. The variable diff receives the value 1.

To print a ptrdiff_t value portably with printf, use the %td format specifier:

c
#include <stddef.h>
#include <stdio.h>
 
ptrdiff_t elements = ptr_b - ptr_a;
printf("Distance in elements: %td\n", elements);

3. Pointer Comparison

Pointers can be compared using relational and equality operators (==, !=, <, <=, >, >=):

  • Equality comparisons (==, !=) determine whether two pointers reference the identical memory address or are both NULL. This is legal between any two compatible pointers anywhere in memory.
  • Relational comparisons (<, <=, >, >=) determine relative memory ordering. Relational comparisons are only defined if both pointers target elements within the same array object or one past the end. Comparing pointers pointing to completely unrelated variables or separate dynamic allocations is undefined behavior.
c
int buffer[10];
int *cursor = &buffer[0];
int *limit = &buffer[10]; /* One-past-the-end address */
 
while (cursor < limit) {
    *cursor = 0; /* Clear element */
    cursor++;    /* Advance by 4 bytes */
}

4. The One-Past-The-End Rule

The C standard permits pointer arithmetic to produce an address pointing to:

  1. Any element within the array: &arr[0] through &arr[N - 1].
  2. Exactly one element past the end of the array: &arr[N].

The one-past-the-end pointer is essential for standard iteration loops (acting as an exclusive upper bound, similar to end() iterators in higher-level languages).

However, dereferencing the one-past-the-end pointer is undefined behavior. You may calculate its address and compare against it, but you must never read from or write to *limit.

Computing an address that steps two or more elements past the end (e.g., &arr[N + 2]), or calculating an address before the start of the array (arr - 1), is immediate undefined behavior even if the pointer is never dereferenced.


Why it is correct

Pointer scaling is not merely a syntactic convenience; it mirrors the physical address generation units present in modern CPU architectures.

Hardware Address Generation Units (AGU)

Microprocessors retrieve values from memory using indexed addressing modes. An x86-64 CPU instruction can compute a memory address, scale an offset, and load a value in a single clock cycle:

assembly
movl (%rdi, %rcx, 4), %eax

This single instruction computes Memory[RDI + (RCX * 4)]:

  • RDI holds the base pointer to the array.
  • RCX holds the index offset.
  • 4 is the hardware scale factor (supported hardware scale factors on x86 are 1, 2, 4, and 8).

Because CPU hardware directly integrates stride multipliers into memory load circuits, pointer scaling in C maps directly to native machine capabilities.

Polymorphic Traversal Semantics

If C required manual byte calculations, every loop traversing an array of structs would be cluttered with size multiplications:

c
/* Hypothetical C without pointer scaling: */
struct Record *r = base;
r = (struct Record *)((char *)r + (i * sizeof(struct Record)));

By embedding type width into the pointer type system, the language ensures that r++ retains a universal meaning: "move to the next logical entity."


What it costs

Understanding the instruction-level mechanics of pointer arithmetic exposes its performance characteristics:

Stride WidthHardware ImplementationInstruction Latency
Power-of-two (1, 2, 4, 8 bytes)Native AGU scaled addressing / bitwise shift (shl)1 CPU cycle
Arbitrary struct size (e.g., 24 bytes)Integer multiplication (imul) or shift-and-add3–4 CPU cycles
Pointer subtraction (p2 - p1)Subtraction + integer division or arithmetic shift (sar)1–3 CPU cycles

Power-of-Two Strides vs. Non-Power-of-Two Strides

When an array holds elements whose sizes are powers of two (such as int at 4 bytes, or double at 8 bytes), advancing an index in a loop is fast: the CPU converts multiplication by 4 or 8 into a single left-shift instruction (shl).

If a struct has a size that is not a power of two (such as 28 bytes):

  1. The compiler cannot use standard hardware scale factors in the mov instruction.
  2. It must emit an explicit multiply instruction (imul %rdx, $28) or a sequence of shifts and additions ((x << 4) + (x << 3) + (x << 2)).

For high-throughput game engines and database scan loops, aligning structs to 32 or 64 bytes often improves iteration speed by simplifying address calculation arithmetic.


Pitfalls and variations

Pitfall 1: Attempting Arithmetic on void *

A void * pointer represents a raw, untyped memory address. Because void is an incomplete type that has no size (sizeof(void) is undefined), standard C strictly forbids pointer arithmetic on void *:

c
unsigned char buffer[1024];
void *raw = buffer;
/* ISO C ERROR: cannot perform arithmetic on pointer to void */
void *next = raw + 16;

(Note: GCC and Clang provide a non-standard compiler extension that treats sizeof(void) as 1, allowing buffer + 16 to compile. However, this fails when compiling under strict standard conformance flags like -pedantic or on MSVC).

To navigate raw byte streams portably, always cast to an explicit 1-byte pointer type such as uint8_t * or char *:

c
unsigned char buffer[1024];
void *raw = buffer;
/* Portable and standards-compliant: */
uint8_t *byte_ptr = (uint8_t *)raw;
uint8_t *next = byte_ptr + 16;

Pitfall 2: Operator Precedence Between * and ++

Combining the dereference operator (*) with increment operators (++) is common, but precedence traps lead to subtle data corruption:

c
int arr[] = {10, 20, 30};
int *p = arr;
 
int a = *p++;   /* Expression 1 */
int b = (*p)++; /* Expression 2 */
int c = *++p;   /* Expression 3 */
  • *p++: The postfix ++ has higher precedence than *. It increments the pointer p, but evaluates to the old pointer address. Result: dereferences arr[0] (evaluates to 10), then advances p to point to arr[1].
  • (*p)++: The parentheses force dereferencing first. It reads the value stored at p (20), increments the integer stored in memory to 21, and leaves p pointing at the same address.
  • *++p: The prefix ++ advances p first, and then dereferences the new address. Result: p advances to arr[2] and yields 30.

Pitfall 3: Subtracting Pointers from Different Allocations

Calculating the distance between two pointers is only defined if both pointers target the same array object:

c
int block_a[10];
int block_b[10];
 
/* UNDEFINED BEHAVIOR: pointers belong to different objects */
ptrdiff_t distance = &block_b[2] - &block_a[1];

The compiler is allowed to assume that block_a and block_b do not alias and have no defined relationship in memory layout. The optimization passes can optimize away checks or produce unexpected arithmetic results.


Where it is used

Pointer arithmetic is the backbone of high-performance systems engineering:

  • Fast Memory Block Operations (memcpy, memmove): Optimized C standard library routines implement memcpy by casting pointers to uint64_t * or SIMD vector types (__m256i *) and advancing by 8 or 32 bytes per iteration, finishing remaining unaligned bytes using uint8_t * arithmetic.
  • Kernel Page Frame Databases: Operating systems track physical memory frames using an array of struct page objects. Given a physical memory address, the Linux kernel determines the corresponding page descriptor using pointer arithmetic:
    c
    struct page *page = mem_map + (pfn - ARCH_PFN_OFFSET);
  • Ring Buffers and Network Sockets: Packet ring buffers track consumer and producer offsets using pointers that advance sequentially through pinned memory rings, wrapping around to the base address upon reaching the capacity limit.

In the next lesson, we will see how generic pointers operate when type information is stripped away: exploring generic void * pointers, type-erased memory functions, and null pointer mechanics.

04 · Prove it

Question 1 of 3 · answer from memory

Given int *ptr = 0x2000; on a system where sizeof(int) == 4, what is the numerical address produced by evaluating ptr + 3?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save