Pointer Arithmetic — One-Past-the-End Bug in Trading System
The while (cursor <= end) bug that crashed a $50,000 real-world trading system — get the pointer arithmetic rule and how AddressSanitizer catches it..
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Pointer arithmetic moves by element size, not bytes — the compiler scales n by sizeof(*ptr)
- array[i] is syntactic sugar for *(array + i) — identical after compilation
- Pointer subtraction gives the element count between two pointers; store in ptrdiff_t
- One-past-end address is valid to compute but never to dereference — loop with < not <=
- memcpy through uint8_t* to bypass strict aliasing when writing non-char data
Pointer arithmetic is the C/C++ language feature that lets you increment, decrement, or offset a pointer by an integer value, with the compiler automatically scaling the step by the size of the pointed-to type. It exists because the language was designed for systems programming where direct memory access and efficient iteration over arrays are critical — think network packet parsing, custom allocators, or real-time trading engines processing thousands of order book updates per microsecond.
When you write ptr + 1, the compiler doesn't add 1 byte; it adds sizeof(ptr) bytes, so a uint64_t advances by 8 bytes and a char* by 1. This implicit scaling is what makes pointer arithmetic both powerful and dangerous: a single off-by-one error in a trading system can corrupt adjacent memory, silently overwrite a price field, or cause a crash that loses millions.
The rules around legal pointer arithmetic are strict and non-negotiable. You can only form a pointer that points to an element within the same array object, or one past the end of that array — that's the "one-past-the-end" rule. Dereferencing that one-past-the-end pointer is undefined behavior, but comparing it or using it as a loop sentinel is legal.
Any other arithmetic — like forming a pointer two elements before the array start, or stepping past the end and then dereferencing — invokes undefined behavior that compilers exploit for optimization, often removing your safety checks silently. In practice, this bites trading systems hard: a custom memory buffer that allocates a fixed pool and uses pointer arithmetic to track the next free slot can easily compute an address one byte past the buffer's end, then write a trade message there, corrupting the adjacent allocation that holds the risk limits.
Real-world debugging of these bugs requires tools like AddressSanitizer (ASan) or UndefinedBehaviorSanitizer (UBSan), which instrument pointer operations at runtime to catch out-of-bounds accesses and invalid arithmetic. In a production trading system, you'd typically run ASan in CI and on a canary instance, because the cost of a missed one-past-the-end bug — a wrong fill price, a corrupted order ID, a segfault at 3 AM — far outweighs the 2x slowdown from sanitizers.
The standard library itself relies on pointer arithmetic internally: std::begin and std::end return pointers, std::copy uses pointer increments, and string functions like strlen walk a char* until they hit the null terminator. When you write your own buffer management for low-latency trading, you're essentially reimplementing that same pattern — but without the safety net of the standard library's decades of testing.
Imagine a hotel with numbered rooms. A pointer is like a keycard programmed to a specific room number. Pointer arithmetic is like telling a staff member 'go three rooms down from Room 101' — you don't say Room 104, you say 'plus three', and the building layout handles the rest. In C, the compiler is that building: it knows each room is a different size depending on what's stored there, so 'plus one' on an int pointer skips 4 bytes, not 1.
Most C bugs that end careers — buffer overflows, segfaults, corrupted data — trace back to one misunderstood concept: pointer arithmetic. It's not obscure wizardry reserved for OS kernels and embedded systems. It's the engine under every array you've ever used in C, every string you've ever printed, every memcpy that ever moved bytes across memory. If you've ever wondered why arrays and pointers feel interchangeable in C, pointer arithmetic is the answer.
The problem pointer arithmetic solves is elegant: how do you navigate raw memory without knowing the exact address of every element ahead of time? Arrays give you contiguous memory, but they're just a starting address. Pointer arithmetic lets you move through that memory systematically, letting the compiler handle the messy byte-offset calculations based on the data type's size. Without it, you'd be manually computing byte offsets for every element access — error-prone and unreadable.
By the end of this article you'll understand exactly why ptr + 1 doesn't mean 'add 1 byte', how to traverse arrays and strings without index variables, what makes pointer subtraction meaningful, and which patterns experienced C developers actually reach for in production code. You'll also know the two arithmetic operations that look valid but silently corrupt memory — and how to spot them before they bite you.
Pointer Arithmetic — The Address Math That Breaks Trading Systems
Pointer arithmetic is the operation of incrementing or decrementing a memory address by a multiple of the type's size. In C, ptr++ moves to the next element of the array, not the next byte. The compiler scales the offset by sizeof(T). This is the core mechanic: you're doing integer math on addresses, but the unit is the object size, not bytes. The result is a new pointer that either points to a valid element or one past the end of the array. That 'one past the end' address is legal to compute and compare, but dereferencing it is undefined behavior — and that's where production systems die. In practice, pointer arithmetic gives O(1) traversal of contiguous memory and is the foundation of array indexing (a[i] is syntactic sugar for *(a + i)). It's the only way to implement custom allocators, ring buffers, or zero-copy parsers. Trading systems rely on it for low-latency order book processing: iterating over a fixed-size array of price levels without bounds checks. The trade-off is that the compiler trusts you. One off-by-one error and you corrupt adjacent memory — no warning, no exception, just silent data corruption that surfaces hours later as a wrong trade.
&array[5] for a 5-element array is legal; dereferencing it is not. The C standard guarantees the address exists for comparison, not for access.i <= 100 instead of i < 100 caused a write to the one-past-the-end slot, which happened to be the first byte of the next order's timestamp. Symptom: random order rejections with 'timestamp in future' errors. Rule of thumb: always use < for loop bounds, never <=, when iterating over an array via pointer arithmetic.sizeof(T), not by byte — the compiler does the multiplication for you.Why the Compiler Scales Pointer Steps by the Type Size
Here's the core insight that unlocks everything: when you write ptr + 1, C doesn't add the integer 1 to the address. It adds 1 sizeof(ptr). That scaling is automatic, silent, and non-negotiable.
Why does this exist? Because memory is byte-addressable, but your data isn't byte-sized. An int on most modern systems occupies 4 bytes. If you have an array of ints starting at address 1000, the second element is at address 1004, the third at 1008. Writing ptr + 2 to mean 'skip 8 bytes' would force every programmer to manually multiply by sizeof(int) everywhere — a nightmare that would produce subtly broken code whenever you changed the data type.
C's designers baked the scaling in so that ptr + n always means 'the address of the nth element after ptr', regardless of whether ptr points to a char, an int, a double, or a 200-byte struct. This is also why ptr++ on a double* moves 8 bytes forward, not 1.
This design decision is what makes array[i] syntactic sugar. The compiler literally rewrites it as *(array + i) — pointer arithmetic followed by a dereference. They are exactly the same operation.
#include <stdio.h> int main(void) { int scores[5] = {10, 20, 30, 40, 50}; char letters[5] = {'A', 'B', 'C', 'D', 'E'}; double temps[5] = {36.6, 37.1, 38.0, 36.9, 37.5}; int *score_ptr = scores; /* points to scores[0] */ char *letter_ptr = letters; /* points to letters[0] */ double *temp_ptr = temps; /* points to temps[0] */ /* Adding 1 to each pointer — watch how far each jumps */ printf("=== Type sizes on this machine ===\n"); printf("sizeof(int) = %zu bytes\n", sizeof(int)); printf("sizeof(char) = %zu bytes\n", sizeof(char)); printf("sizeof(double) = %zu bytes\n", sizeof(double)); printf("\n=== Address jump when pointer is incremented by 1 ===\n"); printf("score_ptr before: %p\n", (void *)score_ptr); printf("score_ptr after: %p (jumped %zu bytes)\n", (void *)(score_ptr + 1), sizeof(int)); printf("letter_ptr before: %p\n", (void *)letter_ptr); printf("letter_ptr after: %p (jumped %zu bytes)\n", (void *)(letter_ptr + 1), sizeof(char)); printf("temp_ptr before: %p\n", (void *)temp_ptr); printf("temp_ptr after: %p (jumped %zu bytes)\n", (void *)(temp_ptr + 1), sizeof(double)); /* Prove array[i] == *(array + i) */ printf("\n=== array[2] vs *(array + 2) ===\n"); printf("scores[2] = %d\n", scores[2]); /* subscript form */ printf("*(scores + 2) = %d\n", *(scores + 2)); /* pointer form */ printf("*(score_ptr + 2) = %d\n", *(score_ptr + 2)); /* via pointer var */ return 0; }
array[i] compile to?', the answer is (array + i). They're not just equivalent — they're identical after the preprocessor. This is also why 3[scores] is valid C: it expands to (3 + scores), which is the same as *(scores + 3).ptr + n = address + n sizeof(ptr), not address + n.Traversing Arrays and Strings the Way the Standard Library Does It
Now that scaling makes sense, let's look at why experienced C programmers sometimes prefer pointer traversal over index-based loops — and when it actually matters.
The standard library functions like strlen, strcpy, and memcpy are all implemented internally using pointer arithmetic. There's no index counter ticking up — there's a pointer walking forward until a condition is met. This pattern is worth knowing not because it's faster on modern CPUs (it often isn't; compilers are clever), but because it teaches you to think in terms of memory, not arrays.
Pointer traversal also shines when you're working with a section of an array — a slice. Instead of passing both an array and an index, you pass a pointer to where you want to start. The function doesn't need to know the original array at all. This is exactly how C string functions handle substrings: strstr returns a pointer into the original string, not a copy.
The key habit: always keep a sentinel — either a count of elements or a terminator value like '\0' — so your pointer knows when to stop.
#include <stdio.h> /* Count characters in a string — reimplementing strlen with visible pointer steps */ size_t count_chars(const char *text) { const char *cursor = text; /* cursor walks forward; text stays at the start */ while (*cursor != '\0') { /* dereference: read the byte cursor is pointing at */ cursor++; /* move cursor one char (1 byte) forward */ } /* Subtracting two pointers gives element count between them, not byte count */ return (size_t)(cursor - text); } /* Sum a slice of an integer array — no index needed, just start + end pointers */ int sum_range(const int *start, const int *end) { int total = 0; /* 'end' is one-past-the-last valid element — a standard C idiom */ for (const int *ptr = start; ptr < end; ptr++) { total += *ptr; /* dereference ptr to read the current element */ } return total; } /* Find the first negative number in an array; return pointer to it or NULL */ int *find_first_negative(int *data, size_t count) { int *ptr = data; int *sentinel = data + count; /* one past the end — do NOT dereference this */ while (ptr < sentinel) { if (*ptr < 0) return ptr; /* return the actual address inside the array */ ptr++; } return NULL; /* no negative found */ } int main(void) { /* --- String traversal --- */ const char *message = "TheCodeForge"; printf("String: \"%s\"\n", message); printf("Length via count_chars: %zu\n", count_chars(message)); /* --- Slice summing --- */ int readings[8] = {12, 7, 34, 5, 89, 23, 61, 4}; /* Sum only elements at index 2, 3, 4 (readings[2] through readings[4]) */ int slice_sum = sum_range(readings + 2, readings + 5); printf("\nReadings array: {12, 7, 34, 5, 89, 23, 61, 4}\n"); printf("Sum of slice [2..4]: %d (expected: 34+5+89 = 128)\n", slice_sum); /* --- Finding negative values --- */ int temperatures[6] = {22, 18, -3, 25, -1, 30}; int *first_negative = find_first_negative(temperatures, 6); if (first_negative != NULL) { /* Pointer subtraction tells us which index it landed on */ ptrdiff_t index = first_negative - temperatures; printf("\nFirst negative: %d at index %td\n", *first_negative, index); } return 0; }
ptrdiff_t (from <stddef.h>) to store the result of pointer subtraction — never int. On 64-bit systems an array can be large enough that the difference between two pointers overflows a 32-bit int. ptrdiff_t is guaranteed to be large enough for any valid pointer difference on the platform.Legal vs. Illegal Pointer Arithmetic — The Rules That Prevent Chaos
Not all pointer arithmetic is created equal. C's standard is precise about what's defined behaviour and what silently explodes.
Legal operations: You can add or subtract an integer to/from a pointer. You can subtract two pointers that point into the same array (including the one-past-the-end position). You can compare two pointers from the same array with <, >, <=, >=. That's it.
Illegal operations: Adding two pointers together is a compile error — it has no geometric meaning. Subtracting pointers from different arrays is undefined behaviour — the result might 'work' on your machine today and crash on the build server tomorrow. Dereferencing the one-past-the-end pointer is undefined behaviour, even though computing its address is fine.
The 'one past the end' rule is subtle but critical. C explicitly allows you to form the address array + N (where N is the array length) as a sentinel for loops. This is valid address arithmetic. What's undefined is actually reading or writing through that address — *(array + N) — because that memory isn't yours.
Pointer comparison with == and != is safe between any two pointers, even from different objects, but < and > between pointers from different objects is undefined. This matters when you're implementing a memory allocator or anything that reasons about relative positions.
#include <stdio.h> #include <stddef.h> int main(void) { int buffer[5] = {100, 200, 300, 400, 500}; int *start = buffer; /* points to buffer[0] */ int *end = buffer + 5; /* one-past-end: valid ADDRESS, never dereference */ /* === LEGAL: integer + pointer === */ int *third_element = start + 2; /* buffer[2], address is buffer_base + 8 */ printf("Legal: start + 2 = %d\n", *third_element); /* === LEGAL: pointer - pointer (same array) === */ ptrdiff_t distance = end - start; /* gives 5 — element count, not byte count */ printf("Legal: end - start = %td elements\n", distance); /* === LEGAL: pointer comparison within same array === */ int *cursor = start; int count = 0; while (cursor < end) { /* cursor < end is legal — both point into buffer */ count++; cursor++; } printf("Legal: counted %d elements via pointer comparison\n", count); /* === LEGAL: one-past-end address computation (no dereference) === */ printf("Legal: end address computed = %p (not dereferenced)\n", (void *)end); /* === ILLEGAL (commented out to keep this code safe to run) === // 1. Adding two pointers — compile error: // int *bad = start + end; // error: invalid operands to binary + // 2. Subtracting pointers from DIFFERENT arrays — undefined behaviour: // int other[5] = {1,2,3,4,5}; // ptrdiff_t garbage = buffer - other; // UB: might crash, might return wrong value // 3. Dereferencing one-past-end — undefined behaviour: // int forbidden = *end; // UB: reading memory that isn't yours // 4. Pointer arithmetic past the bounds — undefined behaviour: // int *too_far = start + 10; // UB even before dereferencing on some platforms */ printf("\nAll legal operations completed safely.\n"); return 0; }
start + 6 on a 5-element array is UB even if you never read from it. Many developers assume you only get UB when you dereference — that's wrong, and it matters when compilers use UB for optimisation.Real-World Pattern: Using Pointer Arithmetic in a Custom Memory Buffer
Let's put it all together with a pattern you'll actually encounter: a simple write buffer that tracks its own current position using pointer arithmetic. This is the core idea behind arena allocators, packet serializers, and file format writers.
The idea is straightforward: you allocate a fixed block of memory, keep a write_ptr that starts at the beginning, and advance it each time you write data. Pointer arithmetic tells you how much space you've used (write_ptr - buffer_start) and how much you have left (buffer_end - write_ptr).
This pattern is used in real systems because it avoids repeated malloc calls and gives you cache-friendly contiguous storage. Game engines, network stacks, and embedded firmware all use variations of this. Understanding it requires being comfortable with pointer arithmetic: you need to write to the current position, advance past what you just wrote, and check bounds without ever losing track of where you started.
The code below shows a minimal version you can actually run — a byte buffer that serializes integers and strings one after another, with boundary checking at each step.
#include <stdio.h> #include <string.h> /* memcpy */ #include <stdint.h> /* uint8_t */ #include <stddef.h> /* ptrdiff_t, size_t */ #define BUFFER_CAPACITY 64 typedef struct { uint8_t storage[BUFFER_CAPACITY]; /* raw byte storage */ uint8_t *write_ptr; /* where the next byte will be written */ uint8_t *end_ptr; /* one-past-end sentinel for bounds checks */ } WriteBuffer; void buffer_init(WriteBuffer *buf) { buf->write_ptr = buf->storage; /* start at the beginning */ buf->end_ptr = buf->storage + BUFFER_CAPACITY;/* one past the last byte */ } /* Returns bytes used so far */ size_t buffer_used(const WriteBuffer *buf) { return (size_t)(buf->write_ptr - buf->storage); /* pointer subtraction = count */ } /* Returns bytes remaining */ size_t buffer_remaining(const WriteBuffer *buf) { return (size_t)(buf->end_ptr - buf->write_ptr); /* space left to write into */ } /* Write a 32-bit integer into the buffer (big-endian) */ int buffer_write_int32(WriteBuffer *buf, int32_t value) { if (buffer_remaining(buf) < sizeof(int32_t)) { return -1; /* not enough space */ } /* memcpy is the safe way to write non-char data through a byte pointer */ memcpy(buf->write_ptr, &value, sizeof(int32_t)); buf->write_ptr += sizeof(int32_t); /* advance past the 4 bytes we just wrote */ return 0; } /* Write a null-terminated string (without the null terminator) into the buffer */ int buffer_write_string(WriteBuffer *buf, const char *text) { size_t text_length = strlen(text); if (buffer_remaining(buf) < text_length) { return -1; /* not enough space */ } memcpy(buf->write_ptr, text, text_length); buf->write_ptr += text_length; /* advance past the string bytes we wrote */ return 0; } int main(void) { WriteBuffer packet; buffer_init(&packet); printf("Buffer capacity: %d bytes\n", BUFFER_CAPACITY); printf("Initial — used: %zu, remaining: %zu\n", buffer_used(&packet), buffer_remaining(&packet)); /* Write a 32-bit sensor ID */ int32_t sensor_id = 4029; buffer_write_int32(&packet, sensor_id); printf("After writing int32 (%d) — used: %zu, remaining: %zu\n", sensor_id, buffer_used(&packet), buffer_remaining(&packet)); /* Write a status string */ const char *status = "SENSOR_OK"; buffer_write_string(&packet, status); printf("After writing string (\"%s\") — used: %zu, remaining: %zu\n", status, buffer_used(&packet), buffer_remaining(&packet)); /* Write another int */ int32_t timestamp = 1718000000; buffer_write_int32(&packet, timestamp); printf("After writing timestamp (%d) — used: %zu, remaining: %zu\n", timestamp, buffer_used(&packet), buffer_remaining(&packet)); /* Show the raw bytes written */ printf("\nRaw bytes in buffer (%zu total):\n", buffer_used(&packet)); for (uint8_t *p = packet.storage; p < packet.write_ptr; p++) { /* Print hex for non-printable bytes, char for printable ones */ if (*p >= 32 && *p < 127) printf(" '%c'", *p); else printf(" %02X", *p); } printf("\n"); return 0; }
(int32_t )write_ptr = value; write_ptr += 4. This violates C's strict aliasing rules and can produce subtly wrong code at higher optimisation levels. Always use memcpy to write non-byte data through a uint8_t* — it's what the standard intends, and compilers optimise it away to a single register store anyway.Debugging Pointer Arithmetic: How Sanitizers Catch the Bugs You Miss
Pointer arithmetic bugs are notoriously hard to reproduce. A buffer overflow might only crash when the next heap chunk is allocated. An off-by-one might corrupt data silently for weeks before manifesting as a production outage.
The tools that find these bugs don't rely on luck. AddressSanitizer (ASan) instruments every memory access at compile time, checking each pointer operation against the known bounds of the allocation. It catches overflows, use-after-free, and garbage pointer dereferences. It's not a debugger — it's a runtime safety net that terminates the program with a precise report at the first violation.
UndefinedBehaviorSanitizer (UBSan) catches the subtler errors: pointer arithmetic past the allowed bound, misaligned pointer operations, and strict aliasing violations. Together, ASan and UBSan should be part of your standard debug build for any C project that uses pointer arithmetic.
Valgrind (Memcheck) is the heavy artillery for heap corruption. It intercepts every malloc and free, tracking the validity and origin of every byte. When pointer arithmetic causes you to read uninitialised memory, Valgrind tells you exactly where the value came from.
The critical habit: always run with these tools during development and in CI. A single pointer arithmetic mistake that survives code review will eventually cause a production incident. Sanitizers make that mistake visible in the first test run.
#!/bin/bash # Build with AddressSanitizer and UndefinedBehaviorSanitizer # compile clang -fsanitize=address,undefined -g -O1 -o program program.c # run — ASan checks every pointer operation ./program # If there's a bug, ASan will print a detailed report with: # - The exact line of the violation # - The allocation context # - The access direction (read/write) and offset # # Example ASan output for a one-past-end dereference: # ==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... # READ of size 4 at 0x... thread T0 # #0 in main at pointer_test.c:15 # #1 in ... libc_start_main ... # 0x... is located 0 bytes after 20-byte region [0x...,0x...) # allocated by thread T0 here: # #0 in malloc ... # #1 in main at pointer_test.c:10
-fsanitize=address,undefined to your debug build flags and run your test suite with it. A single pointer arithmetic bug that passes code review will eventually cost you a PagerDuty alert. ASan catches it on the first access, not after weeks of silent corruption.Pointer Subtraction: The Only Integer Math That Returns a Meaningful Count
Most devs think pointer arithmetic is just increment and decrement. The real power is pointer subtraction. When you subtract two pointers of the same type pointing into the same array, C returns the number of elements between them — not bytes. This is critical for buffer traversal, memcpy sizing, and diagnostic logging. The compiler divides the byte difference by the type size automatically. For trading systems processing 100k messages per second, manual offset calculations introduce off-by-one errors that corrupt entire packet buffers. Use pointer subtraction to compute index distances safely. Never cast to (int*) and subtract bytes yourself. The moment you start tracking byte offsets manually in a memory region, you introduce undefined behavior. Remember: ptrdiff_t is the signed integer type for pointer differences. It guarantees enough range even on 64-bit systems. Use it. Your future self debugging a production crash at 3 AM will thank you.
#include <stdio.h> #include <stddef.h> int main() { int arr[] = {10, 20, 30, 40, 50}; int *start = &arr[1]; int *end = &arr[4]; ptrdiff_t count = end - start; printf("Elements between arr[1] and arr[4]: %td\n", count); // Wrong way: byte difference printf("Byte difference (WRONG): %zu\n", (size_t)((char*)end - (char*)start)); return 0; }
Comparison of Pointers: The Undefined Behavior Trap in Non-Contiguous Memory
Pointer comparison sounds safe. It's not. Comparing pointers that don't point into the same array (or one past the end) is undefined behavior. The C standard says relational operators (<, >, <=, >=) are only defined for pointers within the same aggregate object or its terminating sentinel. The pattern that kills production systems: comparing pointers from two different malloc() calls. Even if the addresses happen to compare correctly today on x86, the compiler is free to optimize that comparison out entirely. On embedded systems, this is a classic source of phantom bugs. The only safe comparisons are equality (==, !=) across any valid pointers and relational comparisons within the same array. If you need to compare positions across buffers, store indices, not pointers. This is a zero-cost abstraction — integers don't trigger UB.
#include <stdio.h> #include <stdlib.h> int main() { int *a = malloc(sizeof(int) * 3); int *b = malloc(sizeof(int) * 3); // Undefined behavior: pointers from different allocations if (a < b) { printf("a is before b (UB!)\n"); } // Safe: restore structure by comparing indices int index_a = 0, index_b = 1; if (index_a < index_b) { printf("index_a < index_b (safe)\n"); } free(a); free(b); return 0; }
malloc() calls is UB. Clang and GCC assume this never happens and may optimize your comparison away entirely.The $50,000 Dereference: One Off-by-One Crashed a Trading System
while (cursor <= end) on a pointer into a packet buffer. The end pointer pointed to one-past-the-last valid byte. On the last iteration, cursor pointed to the one-past-the-end address, and the loop body dereferenced it. The read was from unmapped memory.cursor <= end to cursor < end. The one-past-end address is valid to compare against but never to read. The fix took 30 seconds to implement and test.- Never dereference the one-past-the-end pointer — it's guaranteed undefined behaviour even if the address appears valid.
- Always use
ptr < endnotptr <= endwhen walking a buffer with a sentinel pointer. - Invest in AddressSanitizer in your CI pipeline — it catches this exact bug on the first run.
<= instead of <. Verify that the end pointer is one past the last valid element, not pointing at it.(int32_t )(uint8_t_ptr) violates strict aliasing. Use memcpy instead.ptrdiff_t, not int.free() or realloc() — heap corruption.gcc -fsanitize=address -g -O1 -o app app.c./app 2>&1 | head -50gcc -Wall -Wextra -Wstrict-aliasing -g -O0 -o app app.cvalgrind --tool=memcheck --track-origins=yes ./appprintf("sizeof(struct) = %zu\n", sizeof(struct my_struct));printf("offsetof member2 = %zu\n", offsetof(struct my_struct, member2));offsetof, ensure manual calculation accounts for padding.| Aspect | Index-Based Access (array[i]) | Pointer Arithmetic (*ptr) |
|---|---|---|
| Readability | High — intent is obvious to all readers | Moderate — requires knowing the pattern |
| What compiler generates | Identical machine code after optimisation | Identical machine code after optimisation |
| Bounds checking | No automatic checking in C either way | No automatic checking in C either way |
| Slice / subrange passing | Must pass both array + start index | Pass a single pointer to the start element |
| String walking (no length) | Awkward — need a manual index counter | Natural — cursor++ until sentinel hit |
| Pointer subtraction (distance) | Not directly applicable | ptr_b - ptr_a gives element count between them |
| Risk of going out of bounds | Easy to catch via off-by-one in loop condition | Slightly harder — no obvious upper bound without sentinel |
| Used in standard library | Rarely in implementation | Universally — strlen, memcpy, strchr all use it |
| File | Command / Code | Purpose |
|---|---|---|
| pointer_scaling.c | int main(void) { | Why the Compiler Scales Pointer Steps by the Type Size |
| pointer_traversal.c | /* Count characters in a string — reimplementing strlen with visible pointer ste... | Traversing Arrays and Strings the Way the Standard Library D |
| pointer_arithmetic_rules.c | int main(void) { | Legal vs. Illegal Pointer Arithmetic |
| write_buffer.c | typedef struct { | Real-World Pattern |
| sanitize_pointer_arithmetic.sh | clang -fsanitize=address,undefined -g -O1 -o program program.c | Debugging Pointer Arithmetic |
| pointer_subtraction.c | int main() { | Pointer Subtraction |
| pointer_comparison_ub.c | int main() { | Comparison of Pointers |
Key takeaways
ptr + n always means 'skip n elements', not 'skip n bytes'array[i] and *(array + i) are 100% identical after compilation3[array] is valid (if terrible) C.ptrdiff_t, never int, to survive on 64-bit platforms with large arrays.for (ptr = start; ptr < end; ptr++) loop in the C standard library.Common mistakes to avoid
3 patternsTreating pointer arithmetic as byte arithmetic
ptr = (int)((char)ptr + 1) to advance by 'one'.ptr + 1 or ptr++ on a correctly typed pointer and the compiler multiplies by sizeof automatically. Only use explicit byte offsets when you genuinely need to work at the byte level, and use char or uint8_t for that.Dereferencing the one-past-the-end pointer
ptr < end_ptr, never ptr <= end_ptr. The one-past-end address is legal to compute and compare against, but the instant you write *end_ptr you have undefined behaviour regardless of what happens to be sitting at that address.Storing pointer subtraction results in an `int`
ptrdiff_t (from <stddef.h>) to store the result of subtracting two pointers. On 64-bit platforms, arrays can be large enough that the difference overflows a 32-bit int. ptrdiff_t is guaranteed by the standard to hold any valid pointer difference for the target platform.Interview Questions on This Topic
If `int *ptr` points to the first element of an `int` array, what is the numeric difference between `ptr+3` and `ptr` on a system where sizeof(int) is 4? And what does that tell you about how pointer arithmetic scales?
ptr+3 is ptr + 3 sizeof(int) = ptr + 12. This shows that ptr + n means 'skip n elements', not 'skip n bytes'. The compiler automatically multiplies the integer offset by the size of the pointed-to type.What is the difference between `ptr++` and `++ptr` when used in an expression like `*ptr++`? Walk me through exactly what gets read and what gets incremented, and in what order.
ptr++ is evaluated as (ptr++). In C, the postfix ++ operator has higher precedence than the dereference operator. So the expression works as: first, ptr++ is evaluated — it returns the current value of ptr (the address before increment), and then increments ptr to point to the next element. The dereference acts on the saved original pointer value, so we read the element at the original position. In other words, ptr++ reads the current element and then moves the pointer forward by one element. ++ptr in the same context would be (++ptr): first increment, then dereference the new address. This reads the next element.Why is subtracting two pointers that point into different arrays undefined behaviour in C, even if both pointers happen to be valid addresses? What could go wrong at the hardware or compiler optimisation level?
Frequently Asked Questions
Because C scales all pointer arithmetic by the size of the pointed-to type. If your pointer is an int* and sizeof(int) is 4, then ptr + 1 advances the address by 4 bytes so it points to the next integer. This design means you always think in elements, not bytes, which prevents an entire class of offset calculation bugs.
No — on any modern optimising compiler they produce identical machine code. array[i] is literally defined as *(array + i), so the compiler sees the same thing either way. Choose whichever form makes the code clearer for the reader. Reserve pointer-walking style for cases where you genuinely don't have an index, like traversing a null-terminated string.
Not in standard C. Because void has no type, the compiler can't compute sizeof(ptr) and therefore can't scale the arithmetic. GCC accepts void arithmetic as an extension (treating it like char), but this is non-standard and non-portable. Cast to the correct type — or to char/uint8_t if you genuinely want byte-level stepping — before performing arithmetic.
The compiler still scales by the full sizeof(struct), which includes padding. For example, if a struct has 4 bytes of data but 8 bytes of padding to align the next member, ptr + 1 will skip 8 bytes. This is correct: you want to point to the next entire struct. If you need to access individual members within a struct, use the offsetof macro or pointer-to-member syntax, not raw arithmetic on the struct pointer.
C++ does not allow pointer arithmetic on void without a cast, just like standard C. Some compilers allow it as an extension (e.g., GCC's -fpermissive), but this is non-portable and considered bad practice. In C++, you should use static_cast<char>(void_ptr) or reinterpret_cast<uint8_t*>(void_ptr) if you need byte-level stepping.
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
That's C Basics. Mark it forged?
6 min read · try the examples if you haven't