C Pointer Arithmetic: Navigating Memory Buffers Like a Pro

C Pointer Arithmetic: Navigating Memory Buffers Like a Pro
Table of Contents
- The Scaling Secret: Type-Aware Arithmetic
- Pointer Increment and Decrement
- Pointer Addition and Subtraction
- Array Subscripts Are Pointer Arithmetic
- Traversing Buffers: The Pointer Walk Pattern
- Pointer Comparison and Bounds Checking
- void* Arithmetic: The Exception
- The restrict Keyword: Aliasing Hints for Optimization
- Real-World Applications: Image Processing and Protocol Parsing
- Common Mistakes and Undefined Behavior
- Frequently Asked Questions
- Key Takeaway
The Scaling Secret: Type-Aware Arithmetic
When you perform arithmetic on a pointer, C automatically scales the offset by the size of the pointed-to type:
This "scaled arithmetic" is the core mechanism behind array indexing. When you write array[i], the CPU doesn't magically know where element i is — the compiler computes base_address + i * sizeof(element_type).
#include <stdio.h>
#include <stdint.h>
int main(void) {
int32_t data[5] = {10, 20, 30, 40, 50};
int32_t *ptr = data; // Points to data[0]
printf("ptr points to: %p, value: %d\n", (void*)ptr, *ptr); // 10
printf("ptr+1 points to: %p, value: %d\n", (void*)(ptr+1), *(ptr+1)); // 20
printf("ptr+2 points to: %p, value: %d\n", (void*)(ptr+2), *(ptr+2)); // 30
// The address difference between elements is exactly sizeof(int32_t) = 4 bytes
printf("Bytes between [0] and [1]: %td\n", (char*)(ptr+1) - (char*)ptr); // 4
return 0;
}Pointer Increment and Decrement
The ++ and -- operators move a pointer forward or backward by exactly one element:
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint8_t bytes[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
uint8_t *p = bytes;
printf("*p++: %02X\n", *p++); // 0x01 (reads then increments)
printf("*p: %02X\n", *p); // 0x02 (now pointing at element [1])
printf("*++p: %02X\n", *++p); // 0x03 (increments then reads)
// Post-decrement to go back
p--;
printf("After p--: %02X\n", *p); // 0x02
return 0;
}Operator precedence matters:
*p++=*(p++)— reads current value, then advances pointer (post-increment)*++p=*(++p)— advances pointer first, then reads (pre-increment)(*p)++— increments the value at the address, not the pointer itself
Pointer Addition and Subtraction
You can add or subtract an integer to/from a pointer to jump by multiple elements at once:
#include <stdio.h>
int main(void) {
char str[] = "Hello, World!";
char *start = str;
char *world_ptr = str + 7; // Points to 'W' — advanced 7 elements (bytes)
printf("Full string: %s\n", start); // Hello, World!
printf("From 'World': %s\n", world_ptr); // World!
// Pointer subtraction: gives the number of ELEMENTS between two pointers
ptrdiff_t distance = world_ptr - start;
printf("Distance: %td elements\n", distance); // 7
return 0;
}ptrdiff_t (from <stddef.h>) is the signed integer type for pointer differences — use it instead of int or long to ensure portability.
Array Subscripts Are Pointer Arithmetic
This is one of C's most important equivalences: array[i] is identically the same operation as *(array + i). The subscript notation is purely syntactic sugar:
#include <stdio.h>
int main(void) {
int data[5] = {10, 20, 30, 40, 50};
// These four expressions are identical in machine code:
printf("%d\n", data[2]); // Standard subscript
printf("%d\n", *(data + 2)); // Pointer arithmetic
printf("%d\n", 2[data]); // Legal but bizarre: 2 + data
int *ptr = data;
printf("%d\n", ptr[2]); // Subscript on a pointer variable
return 0;
}The equivalence a[b] == *(a+b) == *(b+a) == b[a] is not a quirk — it is how the C standard defines array subscripting. This also means you can use subscript notation on any pointer, not just arrays declared with [].
Traversing Buffers: The Pointer Walk Pattern
Professional C code uses a "pointer walk" instead of index loops for maximum clarity and often better compiler optimization:
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
// Sum all elements — index-based
int64_t sum_indexed(const int32_t *arr, size_t count) {
int64_t total = 0;
for (size_t i = 0; i < count; i++) {
total += arr[i];
}
return total;
}
// Sum all elements — pointer walk (often faster with -O2)
int64_t sum_pointer_walk(const int32_t *start, const int32_t *end) {
int64_t total = 0;
for (const int32_t *p = start; p < end; p++) {
total += *p;
}
return total;
}
int main(void) {
int32_t data[1000];
for (int i = 0; i < 1000; i++) data[i] = i;
printf("Index sum: %lld\n", sum_indexed(data, 1000));
printf("Walk sum: %lld\n", sum_pointer_walk(data, data + 1000));
return 0;
}The data + 1000 expression computes a "one-past-the-end" pointer — a pointer that is valid to compute but must never be dereferenced. This pattern comes directly from C++'s begin()/end() convention.
Pointer Comparison and Bounds Checking
Pointers to elements within the same array can be compared with <, >, <=, >=:
#include <stdio.h>
void process_range(int *start, int *end) {
// Ensure start is before end
if (start >= end) {
printf("Empty or invalid range\n");
return;
}
for (int *p = start; p < end; p++) {
process_element(p);
}
}[!WARNING] Comparing pointers from different arrays or allocations with
</>is undefined behavior. Only the==and!=operators are valid for comparing pointers from different objects. This is because different allocations may be at arbitrary virtual addresses.
void* Arithmetic: The Exception
A void* pointer has no type, so the compiler does not know the element size — pointer arithmetic on a bare void* is not allowed in standard C:
void wrong_example(void *ptr) {
ptr++; // ERROR: arithmetic on void* — illegal in C (allowed as extension in GCC)
ptr += 1; // ERROR: same issue
}
void correct_example(void *ptr) {
// Cast to char* first — char is always 1 byte, so this is byte-level arithmetic
char *byte_ptr = (char*)ptr;
byte_ptr += 16; // Move 16 bytes forward — valid
}When you need byte-level pointer arithmetic (as in memory allocator implementations or binary protocol parsing), cast to char* or uint8_t* first.
The restrict Keyword: Aliasing Hints for Optimization
When two pointers could potentially point to overlapping memory regions, the compiler must be conservative about reordering memory accesses. The restrict keyword tells the compiler that no other pointer will access the same memory during the function's lifetime:
#include <stddef.h>
// WITHOUT restrict: compiler can't assume src and dest don't overlap
void copy_slow(int *dest, const int *src, size_t n) {
for (size_t i = 0; i < n; i++) {
dest[i] = src[i]; // Each write might affect a future read
}
}
// WITH restrict: compiler knows they don't overlap — can vectorize/parallelize
void copy_fast(int * restrict dest, const int * restrict src, size_t n) {
for (size_t i = 0; i < n; i++) {
dest[i] = src[i]; // Safe to use SIMD, loop unrolling
}
}memcpy from <string.h> uses restrict internally — that is one reason it is so fast. memmove does not use restrict (it handles overlapping regions correctly but is slightly slower).
Real-World Applications: Image Processing and Protocol Parsing
Pointer arithmetic is at the core of high-performance data processing:
Image Processing (Pixel Traversal)
#include <stdint.h>
typedef struct { uint8_t r, g, b, a; } Pixel;
// Invert all pixels in an RGBA image
void invert_image(Pixel *pixels, size_t count) {
Pixel *end = pixels + count;
for (Pixel *p = pixels; p < end; p++) {
p->r = 255 - p->r;
p->g = 255 - p->g;
p->b = 255 - p->b;
// Alpha unchanged
}
}Binary Protocol Parsing
#include <stdint.h>
#include <string.h>
typedef struct {
uint16_t message_id;
uint32_t payload_length;
uint8_t flags;
} __attribute__((packed)) PacketHeader;
void parse_packet(const uint8_t *data, size_t len) {
if (len < sizeof(PacketHeader)) return;
const PacketHeader *hdr = (const PacketHeader*)data;
const uint8_t *payload = data + sizeof(PacketHeader);
printf("Message ID: %u, Payload: %u bytes\n",
hdr->message_id, hdr->payload_length);
}Common Mistakes and Undefined Behavior
// MISTAKE 1: Off-by-one — accessing one past the last valid element
int arr[5] = {1,2,3,4,5};
int *p = arr + 5;
printf("%d\n", *p); // UB! arr+5 is valid to compute, NOT to dereference
// MISTAKE 2: Pointer from one object compared with < to pointer from another
int a = 1, b = 2;
int *pa = &a, *pb = &b;
if (pa < pb) { ... } // UB! Different objects, unrelated addresses
// MISTAKE 3: Mixing signed and unsigned arithmetic
int arr2[10];
int offset = -1;
int *p2 = arr2 + offset; // Legal — moves backwards in memory
printf("%d\n", *p2); // UB! Points before array
// MISTAKE 4: Arithmetic on NULL
int *null_ptr = NULL;
null_ptr++; // UB! Any arithmetic on NULL is undefinedFrequently Asked Questions
Can I subtract two pointers from different arrays? You can subtract them without undefined behavior (UB) — the result is an integer — but the numeric value is meaningless. Only pointer subtraction between pointers within the same array (or one-past-the-end) is defined and meaningful.
Why does GCC allow void* arithmetic as an extension?
GCC (and Clang in GCC-compatibility mode) treat sizeof(void) as 1, allowing pointer arithmetic on void* as a non-standard extension. This is useful for byte-level operations but is not portable to MSVC or Clang in strict mode. Always cast to char* for portable byte arithmetic.
Is pointer arithmetic faster than index-based access?
They compile to identical machine code with -O1 and above. The compiler's optimizer converts both forms to the same multiply-and-add address calculation. The real performance advantage of pointer walks is that they sometimes help the compiler's alias analysis — when using restrict, pointer walks communicate aliasing information more naturally.
What is the maximum valid pointer offset?
You can advance a pointer up to n elements past the start of an n-element array (one-past-the-end). Advancing further is undefined behavior. The one-past-the-end pointer is valid to compute and compare against, but never to dereference.
Key Takeaway
Pointer arithmetic is the reason C is the undisputed king of buffer processing. By understanding that ptr + n moves n * sizeof(*ptr) bytes forward, you can write tight loops that the compiler can vectorize with SIMD instructions — processing 8, 16, or 32 elements per CPU cycle.
This is why C-based image codecs, network protocol stacks, and database storage engines all rely heavily on pointer walks rather than higher-level abstractions. It is the most direct expression of "data exists in memory; traverse it efficiently."
Read next: Structs, Unions & Data Alignment: Structural Precision →
Part of the C Mastery Course — 30 modules from C basics to expert-level systems engineering.
