Quick Cap
A pointer is a variable that holds the address of another object in memory; an array is a contiguous block of elements whose name decays to a pointer in most expressions. Understanding the difference -- and the places where C blurs the line -- is the single most tested C skill in embedded interviews because pointers are how you access hardware registers, traverse buffers, and build callback tables.
Interviewers test whether you can trace pointer arithmetic by hand, predict sizeof results for arrays versus pointers, and spot bugs caused by array decay in function parameters.
Key Facts:
- Address-of (
&) gives a pointer; dereference (*) follows it -- these are inverse operations. - Pointer arithmetic scales by the pointed-to type:
ptr + 1on auint32_t*advances 4 bytes, not 1. - Array decay: an array name converts to a pointer to its first element in almost every context. The three exceptions are
sizeof,&array, and string literal initializers. sizeof(array)gives total bytes;sizeof(pointer)gives 4 or 8 -- this is the #1 interview trap.void*is the generic pointer: it can point to any type but cannot be dereferenced or used in arithmetic without a cast.- Dangling pointers (pointing to freed or out-of-scope memory) cause the hardest-to-find embedded bugs.
Deep Dive
At a Glance
| Concept | Detail |
|---|---|
| Pointer | Variable holding a memory address; typed so arithmetic scales correctly |
| Dereference | *ptr reads/writes the value at the stored address |
| Pointer arithmetic | ptr + n advances by n * sizeof(*ptr) bytes |
| Array | Contiguous block; name decays to &arr[0] in most contexts |
| Array decay | Loses size info; sizeof returns pointer size inside functions |
void* | Generic pointer; requires cast before dereference or arithmetic |
| Function pointer | Holds the address of a function; enables callbacks and dispatch tables |
| Null pointer | NULL / (void*)0; dereferencing is undefined behavior |
Pointer Basics: Address-of and Dereference
The & operator yields the address of a variable; the * operator follows that address to the stored value. These are inverse operations: *(&x) is always x.
uint16_t adc_raw = 1023;uint16_t *p = &adc_raw; /* p holds the address of adc_raw */uint16_t val = *p; /* val == 1023 — dereference reads value */*p = 512; /* adc_raw is now 512 — dereference writes */
In embedded systems the most important pointer declaration pattern is the volatile hardware register pointer:
/* volatile tells the compiler: never optimise away reads/writes */volatile uint32_t *const GPIOA_ODR = (volatile uint32_t *)0x40020014;*GPIOA_ODR |= (1U << 5); /* set bit 5 — turn on an LED */
Pointer Arithmetic: Why It Matters
Pointer arithmetic does not add raw byte counts — it adds multiples of the pointed-to type's size. This is what makes array traversal with pointers work correctly, but it is also one of the most common sources of off-by-N bugs.
uint8_t *bp = (uint8_t *)0x2000; /* byte pointer */uint16_t *hp = (uint16_t *)0x2000; /* halfword pointer */uint32_t *wp = (uint32_t *)0x2000; /* word pointer */bp++; /* 0x2001 — advances 1 byte */hp++; /* 0x2002 — advances 2 bytes */wp++; /* 0x2004 — advances 4 bytes */
This scaling is why you can iterate over an array with a pointer and ptr[i] is defined as *(ptr + i):
int32_t samples[4] = {10, 20, 30, 40};int32_t *p = samples; /* points to samples[0] *//* All three lines read samples[2] == 30 */int32_t a = samples[2];int32_t b = *(p + 2); /* pointer + offset */int32_t c = p[2]; /* equivalent syntax */
Where this matters in embedded work:
- Buffer traversal — walking a UART receive buffer byte by byte.
- Register banks — adjacent peripheral registers are spaced by a fixed offset; pointer arithmetic navigates between them.
- Casting between widths — reading a
uint32_tregister as fouruint8_tbytes requires switching to a byte pointer, or arithmetic will skip 3 bytes per increment.
If you cast a uint32_t* to uint8_t*, remember that ++ now advances 1 byte, not 4. Mixing pointer types without adjusting loop counts is the #1 cause of buffer over-reads in register-level code.
Arrays vs Pointers: The Critical Distinction
Arrays and pointers are related but not identical. The table below captures every difference that interviewers care about:
| Aspect | Array (int arr[8]) | Pointer (int *p) |
|---|---|---|
sizeof | Total bytes (e.g., 32) | Pointer size (4 or 8) |
| Assignment | Cannot assign: arr = other; is illegal | Can reassign: p = other; is legal |
| Decay | Decays to int* when passed to a function | Already a pointer — no decay |
| Storage | Allocates the element memory itself | Stores only an address (elements elsewhere) |
&arr | Type is int (*)[8] (pointer to array) | Type is int ** (pointer to pointer) |
| Initialization | Contents copied in; "hello" fills the array | Points to an existing string literal (read-only) |
The most dangerous confusion: treating a pointer parameter as if it still has array size information.
void print_size(int data[]) {/* data has decayed — sizeof gives pointer size, NOT array size */printf("%zu\n", sizeof(data)); /* prints 4 or 8 */}int main(void) {int buf[16];printf("%zu\n", sizeof(buf)); /* prints 64 (16 * 4) */print_size(buf); /* prints 4 or 8! */return 0;}
Always pass the element count alongside the pointer:
/* Correct: size travels with the pointer */void process(uint8_t *data, size_t len) {for (size_t i = 0; i < len; i++) {data[i] = calibrate(data[i]);}}
Pointer to Pointer
A pointer to pointer (int **pp) stores the address of a pointer variable. Two common embedded use cases:
- Modifying a caller's pointer — e.g., advancing a parse cursor through a command buffer:
/* parse_field advances *cursor past the parsed bytes */bool parse_field(const uint8_t **cursor, const uint8_t *end, uint16_t *out) {if (*cursor + 2 > end) return false;*out = (uint16_t)((*cursor)[0] << 8 | (*cursor)[1]);*cursor += 2; /* caller's pointer moves forward */return true;}
- Arrays of pointers — a table of strings or a dispatch table of function pointers is accessed through a double pointer when passed to a function.
Void Pointers: Generic Programming in C
void* is C's mechanism for type-erased pointers. You can assign any object pointer to a void* without a cast, but you cannot dereference or do arithmetic on a void* without casting it first.
/* Generic swap — works for any type by operating on raw bytes */void swap(void *a, void *b, size_t size) {uint8_t temp[size]; /* VLA — acceptable for small sizes */memcpy(temp, a, size);memcpy(a, b, size);memcpy(b, temp, size);}int x = 1, y = 2;swap(&x, &y, sizeof(int)); /* x == 2, y == 1 */
In embedded systems void* appears most often in:
- Callback context pointers — passing opaque user data to an ISR callback or RTOS task.
memcpy/memset— their signatures takevoid*so they work on any buffer.- Generic data structures — a ring buffer library that stores any element type.
Standard C does not allow arithmetic on void*. GCC provides a non-standard extension that treats void* arithmetic as byte arithmetic (sizeof(void) == 1). In portable code, cast to uint8_t* first.
Null Pointer Safety
Dereferencing NULL is undefined behavior. On Cortex-M it usually triggers a HardFault (address 0x00000000 is the initial stack pointer, not a valid data address), but on some architectures it silently reads whatever is at address zero.
Defensive patterns:
/* Guard every pointer before use */void uart_send(const uint8_t *buf, size_t len) {if (buf == NULL || len == 0) return;for (size_t i = 0; i < len; i++) {UART_TX_REG = buf[i];}}
In safety-critical code (MISRA C), every pointer must be checked against NULL before dereference, and functions must document whether NULL is a valid argument.
Dangling Pointers
A dangling pointer points to memory that has been freed or has gone out of scope. This is one of the hardest bugs to find because the memory may appear to work correctly until something else reuses it.
/* BUG: returning a pointer to a local variable */uint8_t *get_buffer(void) {uint8_t buf[64]; /* lives on the stack */fill_buffer(buf, 64);return buf; /* buf is destroyed when function returns */}/* The caller gets a pointer to memory that is now part ofthe stack — it will be silently overwritten by the nextfunction call. */
Prevention rules:
- Never return the address of a local variable.
- Set pointers to
NULLafterfree(). - Use
staticor caller-provided buffers instead of returning local arrays.
Function Pointers (Brief)
A function pointer stores the address of a function, enabling runtime dispatch. This is covered in depth in the function-pointers-callbacks topic; the essentials are:
/* typedef for readability */typedef void (*irq_handler_t)(void);/* Dispatch table — one handler per IRQ line */static irq_handler_t handlers[16] = {NULL};void register_handler(uint8_t irq, irq_handler_t fn) {if (irq < 16) handlers[irq] = fn;}void dispatch(uint8_t irq) {if (irq < 16 && handlers[irq] != NULL) {handlers[irq](); /* call through pointer */}}
Function pointers are the foundation of callback systems, state machines, and plugin architectures in embedded firmware.
Debugging Story: The Off-by-Four Sensor Crash
A team shipped a vibration-monitoring node that sampled an accelerometer into a 256-element int16_t buffer and sent the data over SPI to a host. After a firmware update, every fourth sample was corrupted. The code looked correct: a pointer walked the buffer and wrote each sample to the SPI data register.
The root cause: during a refactor, someone changed the buffer type from int16_t to int16_t* (a pointer to a separately allocated buffer) but left the loop using sizeof(buffer) / sizeof(buffer[0]) to compute the element count. Because buffer was now a pointer, sizeof(buffer) returned 4 (the pointer size) instead of 512 (256 elements times 2 bytes). The loop only processed 2 samples, and the rest of the buffer was sent uninitialized — corrupting the data stream.
The fix: pass the element count explicitly alongside the pointer, and add a _Static_assert to catch sizeof-on-pointer mistakes at compile time.
Lesson: Whenever you change a variable from an array declaration to a pointer, audit every sizeof that references it. Better yet, always pass sizes explicitly and avoid relying on sizeof for anything that might be a pointer.
What interviewers want to hear: You understand that pointer arithmetic scales by type size and can trace address calculations by hand. You know the exact difference between arrays and pointers -- especially the sizeof trap and the fact that arrays cannot be reassigned. You can explain array decay, list the three exceptions, and describe the correct way to pass array data to functions. You are aware of dangling pointer and null pointer hazards and apply defensive patterns. You use void* for generic code but know its limitations. You have debugged real pointer bugs and understand why they are hard to find.
Interview Focus
Classic Interview Questions
Q1: "What is a pointer, and how do address-of and dereference work?"
Model Answer Starter: "A pointer is a variable that stores the memory address of another object. The & operator gives you the address of a variable, and the * operator follows that address to read or write the stored value. They are inverses: *(&x) is always x. In embedded systems, the most important use of pointers is accessing memory-mapped hardware registers -- you cast a known peripheral address to a volatile pointer and dereference it to read or write the register..."
Q2: "What happens when you increment a uint32_t* pointer, and why does this matter for embedded work?"
Model Answer Starter: "Incrementing a uint32_t* advances the address by 4 bytes, not 1, because pointer arithmetic scales by sizeof(*ptr). This is critical in embedded systems for two reasons: first, it means iterating over an array of 32-bit registers with ptr++ naturally steps to the next register. Second, if you need byte-level access to the same memory, you must cast to uint8_t* first, otherwise each increment skips 3 bytes and you get corrupted reads..."
Q3: "Explain array decay. When does it happen and what are the exceptions?"
Model Answer Starter: "Array decay is the implicit conversion of an array name to a pointer to its first element. It happens in almost every context: function arguments, assignments, arithmetic expressions. The three exceptions where the array retains its full type are: sizeof(arr) returns total byte size, &arr yields a pointer-to-array type, and a string literal used to initialize a char array copies the characters rather than decaying. The practical consequence is that inside a function receiving an array parameter, sizeof returns the pointer size, not the array size, which is a classic source of buffer overflows..."
Q4: "How do you safely use void* in embedded C?"
Model Answer Starter: "A void* can hold any object pointer and is C's mechanism for generic programming -- memcpy, memset, callback context pointers, and generic data structures all rely on it. The rules are: you can assign any object pointer to void* without a cast, but you must cast to a concrete type before dereferencing or doing pointer arithmetic. In embedded callback systems, I use void* for the user-context parameter so the same callback signature works for any data type, then cast back inside the handler. The key safety practice is to document the expected concrete type and validate or assert it at runtime..."
Q5: "What is a dangling pointer, and how do you prevent one in firmware?"
Model Answer Starter: "A dangling pointer is one that refers to memory that has been freed or has gone out of scope -- the address is still stored in the pointer variable, but the memory it points to is no longer valid. The classic embedded case is returning the address of a local buffer from a function: the buffer lives on the stack and is destroyed on return, but the caller still holds the address. Prevention strategies are: never return addresses of locals, use static or caller-provided buffers, set pointers to NULL after free(), and in safety-critical code follow MISRA rules that require null-checking every pointer before dereference..."
Trap Alerts
- Don't say: "Arrays and pointers are the same thing" -- they differ in
sizeof, assignability, and type of&. - Don't forget: Pointer arithmetic scales by type size --
uint32_t* p; p++advances 4 bytes, not 1. - Don't ignore: Array decay in function parameters --
void f(int arr[16])is identical tovoid f(int *arr)and loses all size information.
Follow-up Questions
- "What is the type of
&arrwhenarrisint arr[8]?" - "Can you do pointer arithmetic on a
void*? Why or why not?" - "How would you implement a generic ring buffer using
void*andmemcpy?" - "What happens if you subtract two pointers that point into different arrays?"
- "Why must hardware register pointers be declared
volatile?"
Practice
❓ What does sizeof return for an array parameter inside a function?
❓ How many bytes does incrementing a uint16_t* pointer advance?
❓ Which of the following is true about void pointers in standard C?
❓ What is the key danger of returning a pointer to a local variable from a function?
❓ Given `int arr[10];` at file scope, what is the type of the expression `&arr`?
Real-World Tie-In
Automotive ECU Calibration Tables -- Engine control units store large lookup tables (fuel injection timing, ignition advance) as const arrays in flash. Calibration tools write updated tables by pointer arithmetic to flash offsets. A single off-by-one in the pointer stride can shift every table entry by one row, causing the engine to misfire at specific RPMs -- a bug that only manifests under load on a dynamometer.
IoT Sensor Firmware -- Battery-powered sensor nodes use void* context pointers in their RTOS task callbacks so a single generic "sample-and-send" function serves accelerometer, temperature, and humidity sensors. The context pointer carries a sensor-specific config struct, cast back inside the callback. A missing NULL check on the context pointer caused a field-deployed node to HardFault whenever a sensor was disabled but its task was still scheduled.
Medical Device Ring Buffer -- A patient-monitoring device used pointer arithmetic to implement a lock-free circular buffer between an ADC ISR (producer) and a display task (consumer). During code review for IEC 62304 certification, the team discovered that the head pointer was a uint16_t* but the buffer element size had been changed to uint32_t during a resolution upgrade, causing the pointer to skip every other sample. The fix was trivial (change the pointer type), but the bug had been silently halving the effective sample rate for months.