Embedded DSA Interview Questions: C Fundamentals, Pointers and Memory (Q1–Q35)

Embedded DSA Interview Questions: C Fundamentals, Pointers and Memory (Q1–Q35)

Part 1 of the seven-part Embedded Firmware DSA Interview Guide — 35 questions answered at full depth for embedded, firmware and senior technical roles.

These are the embedded C interview questions that come up first in almost every firmware screen: what the memory map actually looks like, what a pointer really costs, and where your variables genuinely live. Thirty-five of them, each answered at the depth a senior interviewer is listening for.


Embedded C interview questions in this part


Questions 1 to 35, explained fully. Every answer here is self contained. You should not need to open another page to understand it.


Before anything else: the memory map you must picture

Almost every answer below refers to this picture, so learn it first. When your firmware runs on a microcontroller, the compiler and linker have split your program into regions. Some live in flash, which is non volatile and read only at runtime. Some live in RAM, which is volatile and writable.

        FLASH (non volatile, survives power off)
        +--------------------------------------+  high address
        | .rodata   const data, string literals |
        +--------------------------------------+
        | .data init values (master copy)       |
        +--------------------------------------+
        | .text     your compiled instructions  |
        +--------------------------------------+
        | vector table (reset, faults, IRQs)    |  0x0000_0000 typically
        +--------------------------------------+

        RAM (volatile, empty at power on)
        +--------------------------------------+  high address
        | STACK   grows downward                |
        |   |                                   |
        |   v                                   |
        |                                       |
        |   ^                                   |
        |   |                                   |
        | HEAP    grows upward                  |
        +--------------------------------------+
        | .bss    globals that start at zero    |
        +--------------------------------------+
        | .data   globals with non zero init    |
        +--------------------------------------+  low address

Three facts to memorise:

  1. .text and .rodata stay in flash and are never copied. That is why a const array costs zero RAM.
  2. .data exists twice. The initial values sit in flash, and the startup code copies them into RAM before main runs. So a global int x = 5; costs 4 bytes of flash and 4 bytes of RAM.
  3. .bss costs zero flash. The startup code just writes zeros over that RAM range. So a global uint8_t buf[4096]; costs 4 KB of RAM and nothing in flash.

The code that does this copying and zeroing is the reset handler, and it runs before main. That is the single most useful thing to know when someone asks why a global has the wrong value at startup.


Section 1: C and C++ Fundamentals


Q1. Difference between C and C++?

Say this out loud C is a procedural language with no classes, no templates, no exceptions, and no automatic resource cleanup. C++ is a superset in spirit, adding object orientation, templates, RAII, function overloading, and stricter type checking. In firmware I use the parts of C++ that cost nothing at runtime, and I disable exceptions and RTTI.

The full explanation

Think of C as a very thin, honest layer over assembly. Almost every line of C maps to a small, predictable number of instructions. Nothing happens that you did not write.

C++ keeps all of that and adds a set of tools on top. The important thing for an embedded interview is knowing which of those tools are free and which are expensive.

Free, meaning they compile down to the same machine code you would have written in C by hand:

  • Classes with normal member functions. A member function is just a plain function that receives a hidden first argument, the this pointer. obj.set(5) becomes set(&obj, 5).
  • References. A reference is compiled as an address, exactly like a pointer, with the syntax cleaned up.
  • Templates. The compiler generates a separate copy of the code for each type you use. There is no runtime cost at all, only more flash if you instantiate many versions.
  • constexpr. Computed at compile time and baked into the binary as a constant.
  • RAII, meaning a destructor runs automatically when an object leaves scope. This becomes a plain function call, and often gets inlined away completely.
  • namespace, enum class, static_assert, nullptr. All compile time only.

Expensive, meaning they add hidden runtime cost:

  • Exceptions. Enabling them pulls in unwind tables and the unwinder library, often tens of kilobytes, and the throw path has no bounded execution time. Firmware builds normally use -fno-exceptions.
  • RTTI, which powers dynamic_cast and typeid. Adds type descriptor tables. Disabled with -fno-rtti.
  • Virtual functions. Every object of a class with virtual functions carries a hidden pointer to a table of function pointers, so an object grows by 4 bytes on a 32 bit target, and every virtual call becomes load the table pointer, load the function pointer, branch to it. The compiler usually cannot inline it. This is fine in a driver layer called at 100 Hz and a bad idea inside an interrupt at 100 kHz.
  • The standard library containers, std::string, std::vector, std::map. They allocate from the heap, which most firmware forbids.

There are also small differences that bite you when you compile C code as C++:

Thing C C++
void* to int* implicit, no cast needed needs an explicit cast
const int x = 5; at file scope external linkage internal linkage
struct Foo when using it often needs struct Foo f; unless typedef’d just Foo f;
character literal 'a' type int, size 4 type char, size 1
empty struct size is a GCC extension size is 1, never 0
function with no parameters void f() accepts any arguments void f() means exactly zero

Worked example

This compiles as C and fails as C++:

int *p = malloc(10 * sizeof(int));   /* fine in C, error in C++ */

In C++ you must write int *p = (int*)malloc(...) or better, use new or a static buffer.

Why it matters in firmware

If you say “C++ is too heavy for embedded” you sound out of date. The correct answer is that C++ is a set of features you opt into, and the zero cost subset gives you type safety and compile time checking for free. A typical embedded C++ build line looks like this:

arm-none-eabi-g++ -Os -fno-exceptions -fno-rtti -fno-threadsafe-statics \
                  -fno-use-cxa-atexit -ffunction-sections -fdata-sections \
                  -Wl,--gc-sections

-fno-threadsafe-statics removes the hidden lock that guards a function local static’s first initialization. --gc-sections throws away any function nobody called, which matters a lot with templates.

Mistakes people make

Saying C++ objects are slow because they are objects. Only virtual dispatch and heap allocation are slow. A class with plain methods and no virtuals produces identical machine code to a struct and free functions.


Q2. Stack vs heap memory?

Say this out loud Stack is a fixed size contiguous region per task, allocated by simply moving the stack pointer, freed automatically when a function returns, always constant time. Heap is a shared pool managed by an allocator, with variable timing, fragmentation risk, and the possibility of failure. In flight critical or long running firmware I allocate everything at init and never call malloc afterwards.

The full explanation

The stack. When a function runs, it needs somewhere to put its local variables and to remember where to return. That somewhere is the stack. There is a CPU register, the stack pointer or SP, that always points at the current top of the stack.

Allocating 40 bytes of locals is literally one instruction: sub sp, sp, #40. Freeing them is add sp, sp, #40. That is why stack allocation is described as free. There is no bookkeeping, no search, no list. It is a single arithmetic operation.

The rule that makes this work is last in first out. Function A calls B, B calls C. C must finish before B, and B before A. So memory is always released in exactly the reverse order it was taken. Nothing can be freed out of order, which means no gaps can ever form, which means no fragmentation is possible.

The cost of that simplicity is that the lifetime of a stack object is tied to the block it was declared in. The instant the function returns, that memory belongs to whatever gets called next.

The heap. Sometimes you do not know at compile time how much memory you need, or you need an object to outlive the function that created it. The heap is a large region that an allocator hands out in pieces on request.

The allocator keeps a list of free blocks. When you call malloc(100), it searches that list for a block big enough, splits it, marks the used part, and returns a pointer just past a small header it wrote in front of your data. When you call free, it reads that header to learn the size, and puts the block back on the free list, merging it with any neighbours that are also free.

That description already tells you the three problems:

  1. Timing is not constant. The search through the free list depends on how many blocks exist and how the memory happens to be laid out. Your function might take 2 microseconds today and 200 microseconds after eight hours of runtime. In a control loop with a hard deadline this is unacceptable.
  2. Fragmentation. Because blocks are freed in any order, the free space becomes chopped into small pieces. You can have 40 KB free in total and still fail to allocate 4 KB because no single free piece is that large. This is external fragmentation, and there is no cure other than not doing it.
  3. It can fail. malloc returns NULL. Half the code in the world does not check.

Worked example

Trace this by hand:

void child(void) {
    uint8_t local[64];        /* 64 bytes appear on the stack here */
    local[0] = 1;
}                             /* 64 bytes vanish here, automatically */

void parent(void) {
    uint32_t a = 10;          /* 4 bytes on the stack */
    child();                  /* stack temporarily grows by 64 + frame overhead */
    /* by this line, child's 64 bytes are gone and can be reused */
}

Now the heap version and the bug it invites:

uint8_t *make_buffer(void) {
    uint8_t *p = malloc(64);
    return p;                 /* the pointer survives the return, the data lives on */
}

versus the classic broken version:

uint8_t *make_buffer_broken(void) {
    uint8_t local[64];
    return local;             /* BUG: returns the address of memory that is now dead */
}

The second one often appears to work, because nothing has overwritten that stack region yet. It fails the moment another function is called and reuses the same stack space. This is one of the hardest classes of bug to find, and it is why the compiler warns about it.

Fragmentation, drawn

Suppose you have 12 units of heap and you allocate A of 4, B of 4, C of 4.

[ A A A A | B B B B | C C C C ]     0 free

Now free B.

[ A A A A | . . . . | C C C C ]     4 free, in one piece

Now free A.

[ . . . . . . . . . | C C C C ]     8 free, in one piece (they merged)

But suppose instead you free A and C, keeping B.

[ . . . . | B B B B | . . . . ]     8 free, but in two pieces of 4

A request for 6 units now fails, even though 8 units are free. That is fragmentation. In a device that must run for months without a reboot, this eventually kills you.

Why it matters in firmware

  • Stack sizes are fixed at build time or at task creation. In FreeRTOS you write xTaskCreate(task, "name", 256, NULL, 2, NULL) and that 256 is in words, so 1024 bytes. Get it wrong and the task silently corrupts its neighbour.
  • MISRA C and DO-178C effectively ban dynamic allocation after initialization, because you cannot prove absence of failure.
  • The standard alternative is a fixed block pool: carve a static array of equal sized blocks at init, hand them out and take them back in constant time. No fragmentation is possible because every block is interchangeable. See question 121 in the main guide.

Mistakes people make

Saying “the heap is slower”. The important word is not slow, it is non deterministic, and unbounded. A 200 microsecond malloc is not slow in absolute terms, it is fatal because you cannot predict it.


Q3. What happens during a function call?

Say this out loud The caller places arguments per the ABI, on ARM that is r0 to r3 for the first four integers and the stack for the rest, then executes bl, which stores the return address in the link register. The callee’s prologue saves LR and any callee saved registers it will use, then reserves stack space for locals. The return value comes back in r0. The epilogue restores registers and branches to the saved return address.

The full explanation

The CPU has a small number of registers. On ARM Cortex M there are 16, named r0 to r15, and three of them are special: r13 is SP, r14 is LR, r15 is PC. That leaves 13 for general work.

Because there are only 13, the caller and callee must agree on the rules for sharing them. That agreement is called the calling convention, and on ARM it is defined by AAPCS. Both sides were compiled separately, possibly by different compilers, so the rules must be fixed in advance.

The register split

Registers Name Rule
r0 to r3 argument and scratch The callee may destroy them freely. If the caller needs their values after the call, the caller must save them first. These are called caller saved.
r0, r1 return value r0 alone for up to 32 bits, r0 and r1 together for 64 bits.
r4 to r11 variables The callee must give them back unchanged. If the callee wants to use r5, it must push r5 first and pop it before returning. These are callee saved.
r12 IP, scratch Caller saved. Used by the linker for long branches.
r13 SP Stack pointer. Must be 8 byte aligned at any public interface.
r14 LR Link register, holds the return address.
r15 PC Program counter.

The step by step sequence

Take int result = add(10, 20);

  1. The caller loads 10 into r0 and 20 into r1.
  2. The caller executes bl add. This does two things at once: it copies the address of the next instruction into LR, then jumps to add.
  3. Inside add, the prologue runs. If add is a leaf function, meaning it calls nothing, and it needs no extra registers, the prologue is empty. Otherwise it pushes LR and the callee saved registers it plans to use, then subtracts from SP to make room for locals.
  4. The body runs.
  5. The return value is placed into r0.
  6. The epilogue undoes the prologue: add sp, sp, #N to release locals, then pop the saved registers.
  7. Return with bx lr, or more efficiently pop the saved LR straight into PC, which returns and restores in one instruction.

Worked example, real assembly

int add(int a, int b) {
    return a + b;
}

compiles at -O2 to exactly two instructions:

add:
    add  r0, r0, r1     @ a is already in r0, b in r1, result goes in r0
    bx   lr             @ jump back to the address in LR

No stack use at all. This is a leaf function.

Now a non leaf function that needs locals:

int outer(int x) {
    int scratch[4];
    scratch[0] = x;
    return add(scratch[0], helper(x));
}
outer:
    push {r4, lr}       @ save LR because we will call others and overwrite it,
                        @ save r4 because we want to use it and it is callee saved
    sub  sp, sp, #16    @ room for scratch[4]
    mov  r4, r0         @ keep x in a callee saved register, it survives the call
    ...
    bl   helper         @ overwrites r0 to r3 and LR
    ...
    add  sp, sp, #16    @ release locals
    pop  {r4, pc}       @ restore r4 and return in one instruction

Notice why x was moved to r4. It arrived in r0, but bl helper is allowed to destroy r0. Moving it to a callee saved register means helper is obliged to preserve it.

The stack frame, drawn

While outer is running, the stack looks like this. Remember the stack grows downward, toward lower addresses.

   higher addresses
   +---------------------+
   | caller's frame      |
   +---------------------+
   | saved LR            |   <-- pushed by the prologue
   +---------------------+
   | saved r4            |
   +---------------------+
   | scratch[3]          |
   | scratch[2]          |
   | scratch[1]          |
   | scratch[0]          |   <-- SP points here
   +---------------------+
   lower addresses            (the next call will build its frame below this)

Why it matters in firmware

  • This is the exact knowledge you need to read a fault. When a Cortex M takes a hard fault, the hardware automatically pushes eight registers onto the stack: r0, r1, r2, r3, r12, LR, PC, xPSR. The stacked PC is the instruction that faulted. If you can find the stack and read the sixth word, you know exactly where you crashed even with no debugger attached.
  • Interrupt handlers work through the same convention. That is why a C function can be used directly as an ISR on Cortex M: the hardware pushes the caller saved registers for you, so the compiler only needs to emit the normal prologue for callee saved ones.
  • Stack depth analysis for a safety build comes from adding up frame sizes along the deepest call chain. -fstack-usage makes the compiler emit the frame size of every function into a .su file.

Mistakes people make

Believing that arguments are always pushed on the stack. That was true on 32 bit x86 with the cdecl convention, which is where the textbook diagram comes from. On ARM, and on x86-64, the first several arguments go in registers and never touch memory.


Q4. What is memory alignment?

Say this out loud An object is naturally aligned when its address is a multiple of its size. Hardware often requires this. Cortex M0 faults on any unaligned word access. M3, M4, and M7 tolerate unaligned single word loads and stores but still fault on LDM, STM, LDRD, and on any access to Device memory, which includes peripheral registers.

The full explanation

Memory is not fetched one byte at a time. The bus moves data in fixed width chunks, typically 4 bytes on a 32 bit MCU. The memory system is wired so that a 4 byte read starting at address 0 is a single bus transaction.

Now ask for a 4 byte read starting at address 1. The bytes you want are spread across two chunks: bytes 1, 2, 3 of the first, and byte 0 of the second. The hardware must do two reads and stitch the result together, or it must simply refuse.

  • Simple cores refuse. They raise a fault.
  • Larger cores do the two reads for you, silently, at double the cost.

Each type therefore has an alignment requirement, which on a typical 32 bit ARM target is:

Type Size Alignment
char, uint8_t 1 1, any address works
short, uint16_t 2 2, address must be even
int, uint32_t, any pointer 4 4
long long, uint64_t, double 8 8
a struct varies equal to the largest alignment among its members

Worked example

uint8_t buffer[16];

uint32_t *p = (uint32_t *)&buffer[1];   /* address is odd */
uint32_t v = *p;                        /* Cortex M0: HardFault. M4: works, slower. */

The safe way to do this, which works everywhere and compiles to nothing extra when the compiler can prove alignment:

uint32_t v;
memcpy(&v, &buffer[1], sizeof(v));      /* always correct, any alignment */

memcpy with a compile time constant size is recognised by the compiler and turned into the best available instruction sequence. It is not a function call in the generated code.

Checking and requesting alignment in modern C:

#include <stdalign.h>

alignas(8)  uint8_t dma_buf[256];       /* force 8 byte alignment for a DMA buffer */

printf("%zu\n", alignof(uint32_t));     /* prints 4 */

An alignment test at runtime:

if (((uintptr_t)ptr & 3u) != 0) {
    /* not 4 byte aligned */
}

This works because a multiple of 4 always has its lowest two bits zero.

Why it matters in firmware

  • DMA controllers frequently require the source and destination to be aligned to the transfer width. A byte misaligned buffer produces silently corrupted transfers or a bus error.
  • Network and protocol buffers arrive as byte streams. Reading a 32 bit field out of a packet at an arbitrary offset is exactly the unaligned case. Always memcpy it out, never cast a pointer into the middle of a packet.
  • Peripheral registers are in Device memory, where unaligned access faults on every Cortex M without exception, even on M7.
  • Cache maintenance operations work on whole cache lines, usually 32 bytes, so a DMA buffer sharing a cache line with other data will be corrupted by an invalidate. That is why DMA buffers are aligned to the cache line size and padded to fill it.

Mistakes people make

Assuming that because the code works on their M4 dev board, it is correct. It will fault the day it is ported to an M0+ sensor node, and the fault will point at a completely innocent looking line.


Q5. Why does structure padding happen?

Say this out loud The compiler inserts unnamed filler bytes so every member lands on an address that satisfies its own alignment requirement, and adds trailing padding so the total size is a multiple of the struct’s alignment. The trailing padding exists so that in an array of the struct, every element stays aligned.

The full explanation

Once you understand Q4, padding follows automatically. The compiler places members in declaration order, and it is not allowed to reorder them in C. So when the next member needs an alignment the current offset does not satisfy, the compiler skips forward.

Worked example, traced byte by byte

struct Bad {
    char     a;    /* needs 1 */
    uint32_t b;    /* needs 4 */
    char     c;    /* needs 1 */
};

Lay it out:

offset 0 : a
offset 1 : padding   (b needs offset divisible by 4)
offset 2 : padding
offset 3 : padding
offset 4 : b byte 0
offset 5 : b byte 1
offset 6 : b byte 2
offset 7 : b byte 3
offset 8 : c
offset 9 : padding   (trailing, to round the size up to a multiple of 4)
offset 10: padding
offset 11: padding

sizeof(struct Bad) == 12, alignof == 4

Now reorder the members from widest to narrowest:

struct Good {
    uint32_t b;
    char     a;
    char     c;
};
offset 0..3 : b
offset 4    : a
offset 5    : c
offset 6..7 : padding (round up to multiple of 4)

sizeof(struct Good) == 8

Same data, 12 bytes versus 8 bytes. In an array of 1000 elements that is 4 KB saved, which on a device with 64 KB of RAM is a real number.

Why does the trailing padding exist?

Because of arrays. If struct Bad were 9 bytes, then in struct Bad arr[2], element 1 would start at offset 9, and its b member would sit at offset 13, which is not a multiple of 4. By rounding the size up to 12, the compiler guarantees that every element of every array is correctly aligned.

The rule is: sizeof is always a multiple of alignof.

Finding padding yourself

#include <stddef.h>
printf("a at %zu, b at %zu, c at %zu, total %zu\n",
       offsetof(struct Bad, a),
       offsetof(struct Bad, b),
       offsetof(struct Bad, c),
       sizeof(struct Bad));

GCC and Clang also have -Wpadded, which warns every time padding is inserted.

The packed attribute and why it is dangerous

struct __attribute__((packed)) Wire {
    uint8_t  type;
    uint32_t timestamp;
};      /* size 5, no padding at all */

This is tempting for protocol headers. Three problems:

  1. &w.timestamp is now a uint32_t* pointing at an odd offset. Passing that pointer to another function that dereferences it normally will fault on a strict core. The compiler knows the member is packed and generates byte access when you write w.timestamp, but it loses that knowledge once you take the address.
  2. The generated code is slower, four byte loads and shifts instead of one word load.
  3. It still does not make the struct a valid wire format, because endianness is not fixed by packing, and bitfield allocation order inside a unit is implementation defined.

The robust way to parse a wire format is explicit serialization:

static uint32_t be32(const uint8_t *p) {
    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
           ((uint32_t)p[2] << 8)  |  (uint32_t)p[3];
}

That is correct on every compiler, every core, and every endianness, and a good compiler recognises the pattern and turns it into a single load plus a byte reverse instruction.

Why it matters in firmware

Struct layout is the interface between your code and the outside world: flash storage records, EEPROM configuration blocks, radio packets, and shared memory between two processors. If you change the order of members in a config struct and forget the version field, every device in the field reads garbage after an update.

Mistakes people make

Writing a struct, casting a received byte buffer to it, and assuming it works because it worked once. It breaks across compilers, across cores, and across optimization levels.


Q6. Difference between struct and class in C++?

Say this out loud Only the default access level. struct members and base classes default to public, class defaults to private. Everything else is identical.

The full explanation

There is genuinely nothing else. A struct can have constructors, destructors, virtual functions, templates, private members, and inheritance. A class can be a plain bag of public data. The keyword you choose changes exactly two defaults.

struct S { int x; };        /* x is public */
class  C { int x; };        /* x is private */

struct D1 : Base { };       /* public inheritance */
class  D2 : Base { };       /* private inheritance */

The convention most teams follow is: use struct when the type is a passive collection of data with no invariant to protect, and class when the type has behaviour and internal state that must stay consistent.

Why it matters in firmware

The related and more useful concept is POD, plain old data, and its modern successors, trivially copyable and standard layout. A standard layout type has the same memory arrangement as the equivalent C struct, which is what lets you share a header file between a C driver and a C++ application layer, and what lets you memcpy it into a flash record. Adding a virtual function silently breaks this, because it inserts a vtable pointer at the start of the object.

static_assert(std::is_standard_layout<Config>::value, "Config must match the C layout");
static_assert(sizeof(Config) == 16, "Config size changed, bump the version field");

Those two lines in a header have saved more firmware than most code reviews.


Q7. What is pass by value and what does it cost?

Say this out loud The argument is copied into the parameter. The callee works on its own private copy, so nothing it does can affect the caller’s variable. Cost is proportional to the size of the object.

The full explanation

When you write void f(int x) and call f(a), the value inside a is duplicated. x is a completely separate variable that happens to start with the same value.

Worked example

void try_to_change(int x) {
    x = 99;                  /* changes the local copy only */
}

int main(void) {
    int a = 5;
    try_to_change(a);
    printf("%d\n", a);       /* prints 5, not 99 */
}

Picture it:

caller's memory        callee's memory
+-----------+          +-----------+
| a  =  5   |  copy    | x  =  5   |   then x becomes 99
+-----------+  ----->  +-----------+
                       (separate storage, discarded on return)

The array exception that everyone trips on

C has no way to pass an array by value. When you write an array as a parameter, the compiler silently converts it to a pointer.

void f(int arr[10]) {        /* this is actually int *arr */
    printf("%zu\n", sizeof(arr));   /* prints 4 on a 32 bit target, not 40 */
}

So arrays are always effectively passed by pointer, and you must pass the length separately. This is the source of a huge fraction of buffer overflows in C.

Why it matters in firmware

Passing a large struct by value silently copies it onto the stack. A 200 byte sensor calibration struct passed by value through four layers of call costs 800 bytes of stack, which on a 1 KB task stack is an overflow. Pass a const pointer instead. The rule of thumb is: anything larger than two or three machine words goes by pointer or const reference.


Q8. What is pass by pointer and what can the callee change?

Say this out loud The caller passes an address. The callee can read and write the caller’s object by dereferencing it. The pointer itself is still passed by value, meaning the callee can change what the pointer points at but cannot change the caller’s pointer variable unless you pass a pointer to pointer.

The full explanation

C has only pass by value. Pass by pointer is not a separate mechanism, it is pass by value where the value happens to be an address. Understanding that sentence resolves most confusion in this area.

Worked example

void really_change(int *p) {
    *p = 99;                 /* writes through the address, into the caller's variable */
}

int main(void) {
    int a = 5;
    really_change(&a);
    printf("%d\n", a);       /* prints 99 */
}
caller           callee
+---------+      +-----------+
| a = 5   |<-----| p = &a    |    *p = 99 reaches back into a
+---------+      +-----------+
  0x2000_0100      holds 0x2000_0100

The part that catches people: changing the pointer itself

void wont_work(int *p) {
    p = malloc(4);           /* changes the local copy of the pointer only */
}

void works(int **pp) {
    *pp = malloc(4);         /* changes the caller's pointer */
}

int main(void) {
    int *q = NULL;
    wont_work(q);            /* q is still NULL */
    works(&q);               /* q now points at the new block */
}

This is exactly why linked list functions take node_t **head. Inserting at the front must change the caller’s head pointer.

Why it matters in firmware

Output parameters are the standard C idiom for returning more than one thing, and the standard idiom for returning both a status and a value:

int sensor_read(sensor_t *dev, int32_t *out_value);   /* returns 0 on success */

Always validate the pointer at a public API boundary:

if (dev == NULL || out_value == NULL) return -EINVAL;

Q9. How does pass by reference differ from pass by pointer?

Say this out loud C++ only. A reference is an alias for an existing object. It cannot be null in well defined code, cannot be reseated after binding, and needs no dereference syntax. Under the hood the compiler passes an address, exactly like a pointer.

The full explanation

void change(int &r) {
    r = 99;                  /* no star needed, r *is* the caller's object */
}

int main() {
    int a = 5;
    change(a);               /* no ampersand needed at the call site either */
    /* a is now 99 */
}

Compare the three side by side:

Pointer Reference
Can be null yes no, not legally
Can be reassigned to another object yes no, bound once at creation
Syntax at call site f(&a) f(a)
Syntax inside *p = 5 r = 5
Arithmetic yes no
Machine code generated address in a register address in a register, identical

When to use which

Use a reference when the argument is mandatory and always valid. Use a pointer when “no object” is a meaningful state, because the pointer documents that in the type and forces the caller to think about it.

const reference is the important one

void process(const BigStruct &s);    /* no copy, cannot modify, cannot be null */

This is the default way to pass anything non trivial in C++. You get the performance of passing an address with the safety of a value.

The dangerous case

A reference to a temporary or to a destroyed object dangles just as badly as a pointer:

int &bad() {
    int local = 5;
    return local;            /* the object dies here, the reference is now garbage */
}

The reference syntax hides the danger, because at the call site it looks like a plain value.


Q10. Why use const?

Say this out loud It is a contract the compiler enforces. It documents intent, it catches accidental writes at compile time, it lets the linker place data in flash instead of RAM, and it enables optimizations that aliasing would otherwise block.

The full explanation

Reading const declarations. Read right to left from the variable name.

const char *p;          /* p is a pointer to a char that is const  */
char const *p;          /* identical to the above */
char * const p;         /* p is a const pointer to a char */
const char * const p;   /* const pointer to const char */

The practical test: which one can you write to?

const char *p = buf;
p = other;              /* legal, the pointer moves */
*p = 'x';               /* illegal, cannot write through it */

char * const q = buf;
q = other;              /* illegal, the pointer is fixed */
*q = 'x';               /* legal, the data is writable */

The flash saving. This is the concrete embedded payoff.

const uint16_t sine_table[256] = { 0, 402, 804, ... };

Because it is const, the linker puts it in .rodata, which stays in flash. It costs 512 bytes of flash and zero bytes of RAM.

Remove the const and the same table costs 512 bytes of flash for the initial values, 512 bytes of RAM to hold them, and startup time to copy them across. On a part with 20 KB of RAM this single keyword decides whether your project fits.

const with volatile. They are not opposites and they combine usefully.

const volatile uint32_t *status = (const volatile uint32_t *)0x40021000;

volatile says the value can change without your code changing it, so read it every time. const says your code must never write to it. Together they describe a read only hardware status register exactly.

Why it matters in firmware

  • const on a pointer parameter tells every caller that the function does not modify their buffer. That is documentation the compiler checks.
  • Casting away const and then writing is undefined behaviour, and on a microcontroller it means attempting to write to flash, which typically triggers a bus fault or silently does nothing depending on the flash controller.

Mistakes people make

Thinking const means the value never changes. It means this code may not change it. A const pointer into a buffer that a DMA engine is writing needs volatile too.


Q11. Static variable lifetime?

Say this out loud Whole program duration. A function local static keeps its value between calls, lives in .data or .bss rather than the stack, and its name is not visible outside the function. At file scope, static means internal linkage, so the symbol is private to that translation unit.

The full explanation

The keyword static in C does two completely different jobs depending on where you write it, which is the main reason it confuses people.

Job one: inside a function, it changes lifetime.

void counter(void) {
    static int count = 0;    /* initialized ONCE, before main, not on each call */
    count++;
    printf("%d\n", count);
}

Calling this three times prints 1, 2, 3. A normal local would print 1, 1, 1. The variable is not on the stack. It sits in .bss, because it initializes to zero, and it exists for the entire life of the program. Only its name is restricted to the function.

Job two: at file scope, it changes visibility.

static int private_state;      /* other .c files cannot link to this name */
static void helper(void) { }   /* internal function, not exported */

Lifetime is unchanged, since file scope variables already live forever. What changes is linkage: the symbol does not go into the global namespace, so another file can define its own helper without a duplicate symbol error. This is the C equivalent of private, and it also lets the compiler inline and optimize more aggressively, because it can see every use.

Why it matters in firmware

Reentrancy. A function with a static variable is not reentrant. If the same function can be called from a task and from an interrupt, the interrupt can land in the middle of the update and corrupt it.

int bad_parse(char c) {
    static int state = 0;    /* shared by every caller, including ISRs */
    ...
}

Two callers means one state machine shared between them, which is almost never what you wanted. The fix is to pass the state in:

int good_parse(parser_t *ctx, char c);   /* each caller owns its own context */

Initialization timing. static int x = compute(); is illegal in C, because the initializer of a static must be a compile time constant. In C++ it is legal, and the compiler inserts a hidden guard flag plus, by default, a lock so that the first call initializes it exactly once even with threads. That guard is why embedded builds pass -fno-threadsafe-statics.


Q12. Global variable lifetime?

Say this out loud Whole program. Initialized before main by the startup code, which copies .data from flash to RAM and zeroes .bss. Default linkage is external, so any file can reach it with extern.

The full explanation

Trace what happens between power on and your first line of main:

  1. The core resets, reads the initial stack pointer from the first word of the vector table, and the reset handler address from the second word.
  2. The reset handler runs. Usually written in assembly or in C, it does:
    • Copy the .data section from its load address in flash to its runtime address in RAM. This is a simple loop over symbols the linker script defines, typically _sidata, _sdata, _edata.
    • Zero the .bss section between _sbss and _ebss.
    • Optionally initialize the FPU, clocks, and external RAM.
    • In C++, walk the __init_array table and call every global constructor.
  3. It calls main.

That is the entire mystery of “how does my global get its value”.

Worked example of the cost difference

uint8_t  big_buffer[8192];        /* .bss  : 8 KB RAM, 0 flash */
uint8_t  table[4] = {1,2,3,4};    /* .data : 4 B RAM, 4 B flash + copy loop */
const uint8_t rom[4] = {1,2,3,4}; /* .rodata: 0 RAM, 4 B flash */

Checking this in practice:

arm-none-eabi-size -A firmware.elf

The C++ static initialization order fiasco

Within one translation unit, global constructors run in declaration order. Across translation units the order is unspecified. So if a global in a.cpp uses a global in b.cpp during its constructor, it may see an unconstructed object. The standard fix is the function local static, which is constructed on first use:

Logger &logger() {
    static Logger instance;   /* constructed the first time this is called */
    return instance;
}

Why it matters in firmware

  • A variable that must survive a soft reset has to be placed in a section the startup code does not touch, which means a custom linker section plus __attribute__((section(".noinit"))).
  • Globals shared with an ISR need volatile, and if they are wider than one word they need a critical section as well.
  • Every global is a hidden coupling between modules. static at file scope plus accessor functions is almost always the better structure.

Q13. Automatic variables?

Say this out loud Block scoped variables on the stack. They come into existence when the block is entered and are gone when it exits. Their initial contents are indeterminate unless you initialize them. Returning the address of one is undefined behaviour.

The full explanation

“Automatic” is the formal name for a normal local variable. The storage class keyword is auto, which nobody writes in C because it is the default, and which was repurposed entirely in C++11 to mean type deduction.

The uninitialized trap

void f(void) {
    int x;
    printf("%d\n", x);       /* whatever bytes were left there by a previous call */
}

This is not random. It is deterministic garbage: it is whatever the last function to use that stack slot left behind. That is why this class of bug is so cruel. It works perfectly in your test, and then a different call path leaves a different value there and the behaviour changes.

The dangling return trap

char *get_name(void) {
    char buf[32];
    strcpy(buf, "sensor1");
    return buf;              /* buf is dead the instant we return */
}

The caller often reads “sensor1” successfully, because nothing has overwritten the stack yet. Then you add a printf and it turns to garbage, because printf used the same stack region.

The three correct fixes:

/* 1. caller supplies the buffer */
void get_name(char *out, size_t n) { snprintf(out, n, "sensor1"); }

/* 2. return a pointer to something with static lifetime */
const char *get_name(void) { static const char name[] = "sensor1"; return name; }

/* 3. allocate, and document that the caller must free */
char *get_name(void) { char *p = malloc(32); strcpy(p, "sensor1"); return p; }

In firmware, option 1 is the standard choice.

Variable length arrays

void f(int n) {
    int arr[n];              /* legal C99, size chosen at runtime */
}

The size comes from a runtime value, and the stack has no way to refuse. If n is 100000 you silently blow past the end of the stack. MISRA C bans VLAs, and so does the Linux kernel. Never use them in firmware.


Q14. Register keyword?

Say this out loud A hint to keep the variable in a CPU register. The only effect the language actually guarantees is that you cannot take its address. Modern optimizers ignore the hint because their own register allocation is better, and C++17 removed the keyword.

The full explanation

In 1975 compilers were simple and the programmer often did know better. Today, register allocation is a graph colouring problem the compiler solves across the whole function, taking into account live ranges you cannot see. Writing register changes nothing at -O2.

The one remaining use is as a compile time assertion of intent:

register int i;
int *p = &i;                 /* compile error, which is the only guaranteed effect */

What people confuse it with

register is not related to hardware registers. Accessing a peripheral register has nothing to do with this keyword and everything to do with volatile and a fixed address:

#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)

There is also a GCC extension, explicit register variables, which is a different thing and is used in kernel and RTOS code to pin a value into a named register:

register uint32_t sp asm("sp");

Knowing that this exists, and that it is a compiler extension rather than standard C, is a good detail to mention.


Q15. Volatile keyword?

Say this out loud It tells the compiler this object can change outside the visible flow of control, so every read must be a real load, every write a real store, and volatile accesses must not be reordered relative to each other or removed. It is required for memory mapped registers, variables shared with an ISR, and spin wait flags. It is not atomic, and it is not a memory barrier.

The full explanation

The compiler’s normal job is to eliminate redundant memory traffic. If you read the same variable twice and nothing in between could have changed it, the compiler reads it once and keeps it in a register. That is correct for ordinary variables and catastrophic for hardware.

Worked example one: the infinite loop

uint32_t *status = (uint32_t *)0x40021000;

while ((*status & 0x1) == 0) {
    /* wait for the hardware ready bit */
}

The compiler reasons: status points at memory, nothing in the loop writes to it, therefore its value cannot change, therefore load it once. The generated code becomes:

    ldr  r0, [r1]          @ load once, before the loop
    tst  r0, #1
    beq  .                 @ branch to self, forever

Your board hangs. Add volatile:

volatile uint32_t *status = (volatile uint32_t *)0x40021000;

and now the load is inside the loop where you wrote it.

Worked example two: the ISR flag

volatile bool data_ready = false;      /* without volatile this loop never exits */

void USART1_IRQHandler(void) {
    data_ready = true;
}

void main_loop(void) {
    while (!data_ready) { }
    data_ready = false;
    process();
}

The compiler has no idea the ISR exists. From its point of view, nothing in main_loop writes data_ready, so the loop condition is invariant.

Worked example three: writes being merged

GPIOA_ODR = 0x01;
GPIOA_ODR = 0x00;
GPIOA_ODR = 0x01;

Without volatile, the compiler sees two dead stores and emits only the final one. That is fine for a variable and wrong for a pin you are bit banging.

What volatile does NOT do. This is the part interviewers probe.

  1. It is not atomic.
volatile uint32_t counter;
counter++;              /* three operations: load, add, store */

An interrupt landing between the load and the store loses an update. On a 32 bit core a plain aligned 32 bit load or store is atomic on its own, but read modify write never is. Use a critical section, or atomic_fetch_add, or a design with exactly one writer.

  1. It is not a memory barrier. It orders volatile accesses relative to each other, but it does nothing about ordinary accesses moving across them, and nothing about the CPU’s own store buffer or write reordering. For a peripheral write that must be visible before the next step, and on multi core or when DMA is involved, you need __DMB() or __DSB().

  2. It does not stop the cache. On a Cortex M7 with a data cache, a volatile read may still be served from cache while DMA has written new data to the physical RAM behind it. You must invalidate the cache line before reading a DMA destination and clean it after writing a DMA source.

  3. It does not make code thread safe. Two tasks incrementing the same volatile counter still race.

The complete rule for when you need it

Use volatile when the object can change for a reason the compiler cannot see:

  • memory mapped peripheral registers
  • variables written by an ISR and read by main, or the reverse
  • memory written by DMA
  • memory shared with another core

Do not use it as a substitute for synchronization, and do not sprinkle it on ordinary variables to “be safe”, because it disables real optimizations and costs performance for nothing.

Mistakes people make

Saying that volatile makes a variable thread safe or atomic. That single sentence ends a lot of interviews. The correct framing is: volatile is about visibility of the access, atomics and critical sections are about indivisibility of the operation, and barriers are about ordering. Three different problems, three different tools.


Section 2: Pointers and Memory


Q16. What is a pointer?

Say this out loud A variable whose value is a memory address, carrying a type so the compiler knows the size and alignment of the object at that address and how to scale arithmetic on it.

The full explanation

Every byte of memory has a number, its address. A pointer is just a variable that stores one of those numbers. On a 32 bit MCU a pointer is 4 bytes, so it can name any of 2^32 addresses.

The type matters for three reasons, and it is worth being explicit about all three because candidates usually only give the first:

  1. How many bytes to read when dereferenced. *(uint8_t*)p reads 1 byte, *(uint32_t*)p reads 4.
  2. How to interpret those bytes. The same 4 bytes read as int32_t and as float give completely different values.
  3. How far p + 1 moves. For a uint8_t* it moves 1 byte, for a uint32_t* it moves 4.

Worked example, with real addresses

int  x = 42;                 /* suppose x lives at 0x2000_0100 */
int *p = &x;                 /* p itself lives at 0x2000_0104 and contains 0x20000100 */

printf("%d\n",  x);          /* 42        the value */
printf("%p\n",  &x);         /* 0x20000100  the address of x */
printf("%p\n",  p);          /* 0x20000100  the value stored in p */
printf("%d\n",  *p);         /* 42        follow p, read what is there */
printf("%p\n",  &p);         /* 0x20000104  the address of p itself */
address       contents        name
0x2000_0100 | 42            |  x
0x2000_0104 | 0x2000_0100   |  p   (p points at x)

Two operators, and they are exact opposites:

  • & means “give me the address of this object”
  • * means “give me the object at this address”

So *&x is x.


Q17. Pointer arithmetic?

Say this out loud Adding an integer to a pointer advances it by that many elements, not bytes. p + n is p + n * sizeof(*p). It is only defined within a single array object and to one position past its end. Subtracting two pointers into the same array gives the element count between them.

The full explanation

This is the single most useful mechanical fact about pointers, and it is the reason arrays and pointers feel interchangeable in C.

uint8_t  *b;    b + 1   moves 1 byte
uint16_t *h;    h + 1   moves 2 bytes
uint32_t *w;    w + 1   moves 4 bytes
struct S *s;    s + 1   moves sizeof(struct S) bytes, padding included

Worked example

uint32_t arr[4] = {10, 20, 30, 40};   /* say arr starts at 0x2000_0000 */
uint32_t *p = arr;

p         -> 0x2000_0000, *p is 10
p + 1     -> 0x2000_0004, *(p+1) is 20
p + 3     -> 0x2000_000C, *(p+3) is 40
p + 4     -> 0x2000_0010, legal to compute, illegal to dereference
p + 5     -> undefined behaviour even to compute

And this is why arr[i] and *(arr + i) are the same thing. The subscript operator is literally defined as that. Which also explains the party trick that arr[2] and 2[arr] both compile, since addition commutes.

Pointer subtraction

uint32_t *start = &arr[0];
uint32_t *end   = &arr[4];
ptrdiff_t count = end - start;        /* 4, the element count, not 16 */

This is how you write strlen style loops:

size_t my_strlen(const char *s) {
    const char *p = s;
    while (*p) ++p;
    return (size_t)(p - s);
}

The rules that make it undefined

  • Arithmetic on a void* is not standard C. GCC allows it, treating the size as 1. Cast to char* or uint8_t* first if you want byte arithmetic.
  • Going more than one past the end is undefined even if you never dereference. So for (p = arr; p <= arr + 4; p++) is fine, but computing arr + 5 is not.
  • Comparing or subtracting pointers into two different objects is undefined. In practice on a flat address space it works, but the compiler is allowed to assume it never happens and can optimize on that basis.

Why it matters in firmware

Walking a byte buffer is the daily job:

uint8_t *p = frame;
uint8_t  type = *p++;
uint16_t len  = (uint16_t)p[0] | ((uint16_t)p[1] << 8);   p += 2;

Note the manual byte assembly rather than casting to a uint16_t*. That avoids both the alignment problem from Q4 and the strict aliasing problem from Q34.


Q18. Void pointer?

Say this out loud A pointer with no type information. It can hold the address of any object, but because the compiler does not know the size or layout of what it points at, you cannot dereference it and cannot do standard arithmetic on it without casting first.

The full explanation

void* is C’s escape hatch for writing code that works on any type. malloc returns one, memcpy takes two, and every generic container or callback API in C uses one.

void *p;
int   x = 5;
float f = 1.5f;

p = &x;                 /* fine, no cast needed to convert to void* */
p = &f;                 /* also fine */

/* *p;                     error: dereferencing void pointer */
int y = *(int *)p;      /* cast back to the real type first */

The context pointer pattern, which is what interviews actually want

Because a void* can carry anything, it is how C implements closures. You register a callback plus an opaque pointer, and the framework hands the pointer back to you untouched.

typedef void (*timer_cb_t)(void *ctx);

typedef struct {
    timer_cb_t cb;
    void      *ctx;      /* the framework never looks inside this */
    uint32_t   period_ms;
} timer_t;

/* the user's own state */
typedef struct { int count; uint32_t pin; } blink_state_t;

static void blink_handler(void *ctx) {
    blink_state_t *st = (blink_state_t *)ctx;   /* cast back to what I registered */
    st->count++;
    gpio_toggle(st->pin);
}

static blink_state_t led = { .count = 0, .pin = 13 };
timer_register(blink_handler, &led, 500);

The timer module has zero knowledge of blink_state_t. That is the whole point.

Why it matters in firmware

Every RTOS uses this. xTaskCreate(fn, name, stack, void *param, prio, handle) passes param straight to your task function. Queue APIs take void* for the item because they copy raw bytes and do not care what the bytes mean.

Mistakes people make

Casting void* to the wrong type and getting silent corruption, since the compiler cannot check you. Discipline is to cast back to exactly the type you registered, immediately, in the first line of the callback.


Q19. Function pointer?

Say this out loud A variable holding the address of executable code. It is how C implements callbacks, driver operation tables, vector tables, and state machine dispatch. Calling through one is an indirect branch, so it prevents inlining and costs a branch prediction miss.

The full explanation

Functions live at addresses too, in flash, in .text. A function pointer stores one.

The declaration syntax, decoded

int (*fp)(int, char);

Read it inside out: fp is a pointer, to a function, taking (int, char), returning int. The parentheses around *fp are mandatory. Without them, int *fp(int, char) declares a function returning int*, which is a completely different thing.

Always typedef it in real code:

typedef int (*handler_t)(int, char);
handler_t h;

Worked example, a driver operations table

This is the pattern that runs Linux, and it is polymorphism in plain C.

typedef struct {
    int  (*init)(void);
    int  (*write)(const uint8_t *buf, size_t len);
    int  (*read)(uint8_t *buf, size_t len);
    void (*deinit)(void);
} uart_ops_t;

/* one concrete implementation */
static int  uart1_init(void)  { /* ... */ return 0; }
static int  uart1_write(const uint8_t *b, size_t n) { /* ... */ return (int)n; }
static int  uart1_read(uint8_t *b, size_t n)  { /* ... */ return 0; }
static void uart1_deinit(void) { }

const uart_ops_t uart1_ops = {     /* const, so it lives in flash */
    .init   = uart1_init,
    .write  = uart1_write,
    .read   = uart1_read,
    .deinit = uart1_deinit,
};

/* generic code that works with any UART */
int send_hello(const uart_ops_t *ops) {
    ops->init();
    return ops->write((const uint8_t *)"hello", 5);
}

Swapping in a USB CDC or a Bluetooth SPP implementation means writing a second ops struct. Nothing above changes.

Worked example, a state machine dispatch table

typedef enum { ST_IDLE, ST_RX, ST_TX, ST_MAX } state_t;
typedef state_t (*state_fn_t)(uint8_t event);

static state_t on_idle(uint8_t e);
static state_t on_rx(uint8_t e);
static state_t on_tx(uint8_t e);

static const state_fn_t table[ST_MAX] = { on_idle, on_rx, on_tx };

state_t current = ST_IDLE;
void feed(uint8_t event) {
    current = table[current](event);     /* one indexed indirect call */
}

This replaces a nested switch with a constant time lookup, and it puts the table in flash.

Calling syntax

int (*fp)(int) = &square;    /* & is optional, a function name decays to its address */
fp = square;                 /* identical */

int r1 = (*fp)(5);           /* explicit dereference, old style */
int r2 = fp(5);              /* identical, and what everyone writes */

Why it matters in firmware

  • The Cortex M vector table is literally an array of function pointers at address 0. Element 0 is the initial stack pointer, element 1 is the reset handler, then the fault handlers, then one per peripheral interrupt.
  • On Cortex M in Thumb mode, function addresses have bit 0 set to indicate Thumb state. So a function pointer to a function at 0x08001000 actually holds 0x08001001. If you ever compute a jump target by hand, forgetting to set bit 0 causes an immediate usage fault.
  • Costs: an indirect call cannot be inlined, defeats the branch predictor on cores that have one, and prevents whole program optimization from proving what gets called. In a hot ISR, a switch statement may be faster despite looking uglier.
  • Safety: always check for NULL before calling through a pointer that can be registered at runtime.
if (ops != NULL && ops->write != NULL) ops->write(buf, len);

Q20. Pointer to pointer?

Say this out loud A pointer whose target is itself a pointer. The main uses are letting a function modify the caller’s pointer variable, and building arrays of pointers such as argv.

The full explanation

int    x  = 5;
int   *p  = &x;
int  **pp = &p;

*pp        /* is p,  a pointer to int */
**pp       /* is x,  5 */
0x2000_0100 | 5             |  x
0x2000_0104 | 0x2000_0100   |  p    points to x
0x2000_0108 | 0x2000_0104   |  pp   points to p

Use one: modifying the caller’s pointer

This is the linked list head problem, and it is worth writing out completely because it is asked constantly.

typedef struct node { int data; struct node *next; } node_t;

/* wrong: the caller's head never changes */
void push_wrong(node_t *head, int v) {
    node_t *n = malloc(sizeof *n);
    n->data = v;
    n->next = head;
    head = n;                 /* only the local copy moves */
}

/* right */
void push(node_t **head, int v) {
    node_t *n = malloc(sizeof *n);
    n->data = v;
    n->next = *head;
    *head = n;                /* writes through, so the caller sees it */
}

node_t *list = NULL;
push(&list, 10);
push(&list, 20);

Use two: the pointer to pointer walk, which removes every special case

This is the elegant version of list deletion. Instead of tracking a previous node, you track the pointer that needs updating.

void remove_value(node_t **head, int v) {
    node_t **pp = head;                 /* pp points at the pointer to the current node */
    while (*pp) {
        node_t *entry = *pp;
        if (entry->data == v) {
            *pp = entry->next;          /* works identically for the head and the middle */
            free(entry);
            return;
        }
        pp = &entry->next;              /* advance to point at the next node's next field */
    }
}

There is no if (node == head) branch anywhere. This is the version to write in an interview, and it is what Linus Torvalds was referring to when he talked about understanding pointers versus using them.

Use three: arrays of pointers

int main(int argc, char **argv);      /* argv[0] is a char*, argv[0][0] is a char */

const char *messages[] = { "OK", "TIMEOUT", "CRC ERROR" };   /* array of pointers */

The array holds 3 pointers, 12 bytes on a 32 bit target, and the strings themselves live separately in .rodata.


Q21. Wild pointer?

Say this out loud An uninitialized pointer. It contains whatever bytes were left in that stack slot, which is an arbitrary address. Dereferencing it reads or writes a random location, and the write case corrupts memory silently.

The full explanation

void bad(void) {
    int *p;                  /* p holds garbage, whatever was on the stack here */
    *p = 42;                 /* writes 42 to a random address */
}

Three possible outcomes, in increasing order of how much of your week it costs:

  1. The garbage address is unmapped, you get an immediate bus fault, and you find the bug in five minutes. This is the lucky case.
  2. The address lands in your own RAM, and you corrupt an unrelated variable. The symptom appears in a different module, hours later.
  3. The address lands in a peripheral register block, and you reconfigure a clock or a pin at random.

The fix, which is a habit not a technique

int *p = NULL;               /* now a dereference is at least deterministic */

Also enable and read the warnings, which catch most of these at compile time:

-Wall -Wextra -Werror -Wuninitialized -Wmaybe-uninitialized

Why it matters in firmware

On a microcontroller without an MMU, address 0 is not an unmapped page. It is usually the vector table in flash. So a NULL read returns the initial stack pointer value instead of faulting, and a NULL write may be silently discarded by the flash controller. You lose the free crash that desktop programmers rely on. The mitigation is to configure an MPU region during development that marks the low addresses as no access, so a NULL dereference produces a memory management fault you can trap.


Q22. Dangling pointer?

Say this out loud A pointer to memory that is no longer valid, either because it was freed or because the object’s scope ended. The memory usually still contains plausible looking data, so the failure appears far away from the cause.

The full explanation

Three ways to create one:

One, use after free.

uint8_t *p = malloc(64);
free(p);
p[0] = 1;                    /* p still holds the old address, but the block is gone */

After free, the allocator may have written its own free list bookkeeping into those bytes. Writing there corrupts the allocator, and the crash surfaces inside the next malloc, which is a completely innocent function.

Two, returning a local. Covered in Q13.

Three, a second pointer to a freed block.

uint8_t *a = malloc(64);
uint8_t *b = a;              /* two pointers, one block */
free(a);
a = NULL;                    /* good hygiene, but b is still dangling */
b[0] = 1;                    /* corruption */

This is why “set it to NULL after free” is necessary but not sufficient. The real fix is single ownership: exactly one pointer is responsible for the lifetime, and everyone else borrows it for a bounded time.

Defences

#define SAFE_FREE(p) do { free(p); (p) = NULL; } while (0)

In C++ the language solves this for you:

std::unique_ptr<Buffer> b = std::make_unique<Buffer>();   /* freed exactly once, automatically */

Debug builds can poison freed memory, filling it with a pattern such as 0xDEADBEEF, so a use after free produces obviously wrong data instead of plausible data. Many RTOS heaps have this as a build option and it is worth turning on.


Q23. Null pointer?

Say this out loud A pointer value guaranteed not to equal the address of any object, used to mean “points at nothing”. Dereferencing it is undefined behaviour. The constant is NULL in C and nullptr in C++.

The full explanation

NULL is a null pointer constant, conventionally written as ((void*)0) or 0. Note that the standard does not promise the bit pattern is all zeros, only that a comparison with 0 works. On every real embedded target the representation is zero, so memset of a struct full of pointers does produce nulls, but strictly that is a portability assumption.

The idioms

if (p == NULL)   /* explicit, clearest */
if (!p)          /* idiomatic, same meaning */
if (p)           /* p is not null */

Why the embedded case is special, and this is the part worth saying

On a desktop, address 0 is an unmapped page, so a null dereference is a segfault you cannot miss. On a Cortex M there is no MMU. Address 0 is the start of flash, where the vector table lives. So:

int *p = NULL;
int v = *p;                  /* reads the initial stack pointer value, no fault at all */
*p = 5;                      /* write to flash, usually ignored or a bus fault, depends */

You get a plausible number and no crash. The consequences show up much later.

Two practical defences:

  1. Configure an MPU region covering address 0 to some small size with no access permissions in debug builds. Any null dereference then raises a MemManage fault and you get the faulting PC immediately.
  2. Check pointers at every public API boundary rather than deep inside.
int drv_write(drv_t *dev, const uint8_t *data, size_t len) {
    if (dev == NULL || data == NULL) return -EINVAL;
    if (len == 0) return 0;
    ...
}

Q24. Difference between NULL and nullptr?

Say this out loud NULL is a macro that usually expands to the integer 0, so it participates in integer overload resolution and can select the wrong function. nullptr has its own type, std::nullptr_t, which converts to any pointer type and to no integer type, so it is unambiguous. Use nullptr in C++.

The full explanation

Worked example of the actual bug

void log(int code);
void log(const char *msg);

log(NULL);        /* calls log(int) if NULL is 0. Almost certainly not what you meant. */
log(nullptr);     /* calls log(const char*), unambiguously */

In C++, NULL cannot be defined as ((void*)0) because void* does not implicitly convert to other pointer types in C++. So it must be 0 or 0L, which makes it an integer literal that happens to be usable as a pointer. That is the root of the problem.

nullptr fixes it by being a distinct type:

int  i = nullptr;      /* error, good */
int *p = nullptr;      /* fine */
bool b = (p == nullptr);

It also matters for templates, where T = decltype(NULL) deduces int and breaks perfect forwarding.

In C, none of this applies. NULL is the correct spelling, and C23 added nullptr to C as well.


Q25. Double free problem?

Say this out loud Calling free twice on the same block corrupts the allocator’s internal free list. The failure usually surfaces later, inside an unrelated allocation, which makes it very hard to trace back.

The full explanation

To understand why it is so destructive, you need to know what the allocator stores. A typical malloc implementation puts a header immediately before the block it returns you:

   [ size | flags ]  [ your 64 bytes ......................... ]
   ^ header, 8 bytes  ^ the pointer malloc gave you

When the block is free, the allocator reuses the payload to store free list links:

   [ size | flags ]  [ next ptr | prev ptr | unused ........... ]

So the first free writes list pointers into the block. The second free reads that block, sees the pointers it wrote itself, and links the block into the list a second time. Now the free list contains a cycle or a duplicate entry. The next malloc walks that list and hands the same block to two different callers, or follows a corrupted pointer and writes wherever it lands.

Worked example of the aliasing route in

void process(uint8_t *buf) {
    ...
    free(buf);               /* this function takes ownership, apparently */
}

void caller(void) {
    uint8_t *b = malloc(64);
    process(b);
    free(b);                 /* double free: who owns it? */
}

The bug is not the second free, it is that ownership was never documented. That is why the fix is architectural.

Defences

  • One owner per allocation, stated in the header file comment.
  • SAFE_FREE macro that nulls the pointer, since free(NULL) is defined and does nothing.
  • In C++, unique_ptr makes ownership a compile time property.
  • Do not use the heap at all after init, and the entire class of bug disappears.

Q26. Memory leak?

Say this out loud An allocation that is never freed and whose last pointer has been lost, so the memory can never be recovered. In a long running device, even a small periodic leak is a guaranteed field failure.

The full explanation

How they happen

void handle(void) {
    uint8_t *buf = malloc(128);
    if (read_sensor(buf) != 0) {
        return;                      /* leak: early return skips the free */
    }
    process(buf);
    free(buf);
}

Error paths are where leaks live. Every early return, every break, every exception in C++.

The C idiom for fixing this is a single cleanup path:

int handle(void) {
    int rc = 0;
    uint8_t *buf = malloc(128);
    if (buf == NULL) return -ENOMEM;

    if (read_sensor(buf) != 0) { rc = -EIO; goto out; }
    if (process(buf)     != 0) { rc = -EINVAL; goto out; }

out:
    free(buf);
    return rc;
}

This is the one place where goto is not only acceptable but is the accepted convention, and it is what the Linux kernel does everywhere.

The other pointer overwrite route:

uint8_t *p = malloc(64);
p = malloc(128);             /* the first 64 bytes are now unreachable, forever */

Why it matters in firmware, with real numbers

A device with 32 KB of heap that leaks 16 bytes per received packet, at 10 packets per second, exhausts its heap in about 3.4 minutes. Leak 16 bytes per hour and it dies in about 80 days, which is exactly the kind of bug that passes every test and fails in the field.

How to detect it on a target with no valgrind

  • Log the heap free size periodically. In FreeRTOS, xPortGetFreeHeapSize() and xPortGetMinimumEverFreeHeapSize(). If the minimum ever free number keeps dropping over hours, you have a leak.
  • Wrap malloc and free with counters per subsystem in debug builds.
  • Run the same logic on a host with AddressSanitizer or valgrind, which is a strong argument for keeping business logic separate from hardware access.

Q27. Shallow copy?

Say this out loud Copying the members of a struct as they are, so any pointer member ends up pointing at the same underlying buffer in both objects. Now two objects share memory that only one of them should own.

The full explanation

typedef struct {
    char   *name;      /* pointer to a heap buffer */
    size_t  len;
} record_t;

record_t a;
a.name = malloc(32);
strcpy(a.name, "sensor");
a.len = 6;

record_t b = a;        /* shallow copy: memberwise, so b.name == a.name */
   a.name ---+
             +---> [ "sensor" ]  one buffer
   b.name ---+

Now three things can go wrong:

  1. Writing through b.name changes what a sees.
  2. Freeing a.name leaves b.name dangling.
  3. Freeing both is a double free.

This is what struct assignment does in C, and it is also what memcpy on a struct does. C gives you no way to hook it.

Where shallow is actually correct

If the struct contains no pointers, or the pointers are borrowed references with a clearly longer lifetime such as a pointer to a const table in flash, shallow copy is exactly right and costs nothing. Most firmware structs are in this category, which is why the problem is easy to forget about until it bites.


Q28. Deep copy?

Say this out loud Allocating new storage and copying the contents, so the two objects own independent memory. In C++ this is what the copy constructor and copy assignment operator are for, and if you need one of them you almost certainly need the destructor too.

The full explanation

The C version, written by hand:

int record_copy(record_t *dst, const record_t *src) {
    dst->name = malloc(src->len + 1);
    if (dst->name == NULL) return -ENOMEM;
    memcpy(dst->name, src->name, src->len + 1);
    dst->len = src->len;
    return 0;
}

The C++ version, with the rule of three:

class Record {
    char  *name_;
    size_t len_;
public:
    Record(const char *n) : len_(strlen(n)) {
        name_ = new char[len_ + 1];
        memcpy(name_, n, len_ + 1);
    }

    ~Record() { delete[] name_; }                       /* 1. destructor */

    Record(const Record &o) : len_(o.len_) {            /* 2. copy constructor */
        name_ = new char[len_ + 1];
        memcpy(name_, o.name_, len_ + 1);
    }

    Record &operator=(const Record &o) {                /* 3. copy assignment */
        if (this == &o) return *this;                   /* self assignment check */
        char *tmp = new char[o.len_ + 1];               /* allocate before releasing */
        memcpy(tmp, o.name_, o.len_ + 1);
        delete[] name_;
        name_ = tmp;
        len_ = o.len_;
        return *this;
    }
};

Two details interviewers look for: the self assignment check, and allocating the new buffer before freeing the old one, so that a failed allocation leaves the object unchanged rather than destroyed.

Rule of three, five, and zero

  • Three: if you write any of destructor, copy constructor, copy assignment, you need all three, because the compiler generated versions of the others will do shallow copies.
  • Five: C++11 adds move constructor and move assignment, which steal the pointer instead of copying, leaving the source null. Moving is O(1) where copying is O(n).
  • Zero: the best option. If every member manages its own resource, such as std::unique_ptr or a fixed array, you write none of the five and the compiler generated ones are correct.

Q29. Pointer to structure?

Say this out loud s->m is shorthand for (*s).m. The compiler turns it into a load or store at base plus a fixed offset computed at compile time. This is exactly the mechanism behind memory mapped peripheral access.

The full explanation

typedef struct { uint32_t id; uint32_t value; } sample_t;

sample_t  s = { 1, 100 };
sample_t *p = &s;

p->id            /* same as (*p).id */
(*p).id          /* same thing, uglier */

Compiled, p->value becomes ldr r0, [r1, #4], one instruction, because the offset of value is a compile time constant of 4.

The peripheral register pattern, which is the real reason this question is asked

Every vendor header does this:

typedef struct {
    volatile uint32_t MODER;     /* offset 0x00 */
    volatile uint32_t OTYPER;    /* offset 0x04 */
    volatile uint32_t OSPEEDR;   /* offset 0x08 */
    volatile uint32_t PUPDR;     /* offset 0x0C */
    volatile uint32_t IDR;       /* offset 0x10 */
    volatile uint32_t ODR;       /* offset 0x14 */
} GPIO_TypeDef;

#define GPIOA ((GPIO_TypeDef *)0x40020000UL)

GPIOA->ODR |= (1u << 5);         /* becomes a load, orr, store at 0x40020014 */

Everything you have learned so far shows up in these six lines:

  • volatile, because hardware changes these behind your back (Q15)
  • the struct member offsets must match the datasheet exactly, so padding must be zero, which is guaranteed here because every member is a uint32_t (Q5)
  • reserved gaps in the register map are represented by explicit dummy members, never left implicit
  • the whole thing is a pointer to a fixed address, so there is no storage cost at all

If a register map has a hole, the header declares it:

    volatile uint32_t CR1;
    uint32_t          RESERVED0[2];   /* 8 byte gap in the map */
    volatile uint32_t CR2;

Q30. Why use pointers?

Say this out loud To modify a caller’s data, to avoid copying large objects, to build data structures whose size is not known at compile time, to reach memory mapped hardware, to implement callbacks and polymorphism in C, and to walk buffers efficiently.

The full explanation, one reason at a time

  1. Output parameters. C returns one value. Pointers give you the rest. int read(dev_t*, int32_t *out).
  2. Avoiding copies. Passing a 512 byte struct by value copies 512 bytes onto the stack at every call. Passing const struct * copies 4 bytes.
  3. Dynamic structures. A linked list, tree, or queue is defined by nodes holding addresses of other nodes. Without pointers there is no way to express it.
  4. Hardware access. A peripheral register is just a fixed address you must read and write. A pointer is the only way C can name it.
  5. Polymorphism. Function pointers give you virtual dispatch without C++.
  6. Efficient iteration. while (*p) p++ is one instruction shorter per iteration than indexing, though modern compilers make the two identical.
  7. Zero copy data paths. In a network stack you pass a pointer to a packet buffer down through the layers rather than copying the payload at each one. On a device processing 1000 packets per second, that difference is the entire CPU budget.

Q31. Pointer vs reference?

Covered in detail in Q9. The summary table again for revision:

Pointer Reference
Nullable yes no
Reseatable yes no
Needs dereference yes, *p no
Has its own address yes, &p works no, &r gives the target’s address
Arithmetic yes no
Must be initialized no yes
Available in C yes no, C++ only
Generated code address in a register identical

The decision rule: if “nothing” is a valid value for this argument, use a pointer, because the type then documents that the caller must handle absence. Otherwise use a reference, because it removes an entire class of null checks.


Q32. Can a pointer point to a constant?

Say this out loud Yes, that is const int *p. It means you cannot write through this pointer. It does not mean the object itself is immutable, because it may still be reachable and writable through another non const path.

The full explanation

int x = 5;
const int *p = &x;

*p = 10;             /* error, cannot write through p */
x  = 10;             /* fine, x itself was never const */
printf("%d", *p);    /* prints 10, the value did change */

This is the key distinction. const on the pointer is a restriction on that access path, not a property of the object.

Now the genuinely const object:

const int y = 5;
int *q = (int *)&y;   /* casting away const, the compiler allows it with the cast */
*q = 10;              /* undefined behaviour */

On a microcontroller, y is in flash, so the write either faults or is silently dropped. On a desktop, the compiler may have already substituted the constant 5 everywhere it is used, so the read afterwards still prints 5 even though memory changed. Both are legitimate outcomes of undefined behaviour.

The API rule

size_t crc_compute(const uint8_t *data, size_t len);

The const here is a promise to the caller that their buffer is unchanged. It is checked by the compiler and it is the single most useful piece of self documentation in a C header.


Q33. Constant pointer?

Say this out loud int * const p fixes the pointer itself while leaving the pointed to data writable. Combined as const int * const p, both are fixed, which is the right declaration for a pointer to a read only register block.

The full explanation, all four combinations

int a = 1, b = 2;

int *p1 = &a;                  /* mutable pointer, mutable data */
p1 = &b;   /* ok */            *p1 = 5;   /* ok */

const int *p2 = &a;            /* mutable pointer, const data */
p2 = &b;   /* ok */            *p2 = 5;   /* ERROR */

int * const p3 = &a;           /* const pointer, mutable data */
p3 = &b;   /* ERROR */         *p3 = 5;   /* ok */

const int * const p4 = &a;     /* const pointer, const data */
p4 = &b;   /* ERROR */         *p4 = 5;   /* ERROR */

The rule that never fails: const applies to whatever is immediately to its left, unless there is nothing to its left, in which case it applies to what is on its right.

So in int * const p, the const has * to its left, so it makes the pointer const. In const int *p, the const has nothing to its left, so it applies to int, making the data const.

Firmware use

/* the peripheral base never moves, and this driver only reads status */
static const volatile uint32_t * const status_reg =
        (const volatile uint32_t *)0x40021000;

Three qualifiers, each doing a different job: const on the data means this code never writes the register, volatile means re read it every time because hardware changes it, and const on the pointer means the base address is fixed forever and lives in flash.


Q34. Pointer aliasing?

Say this out loud Two pointers that may refer to overlapping memory. The compiler must assume a write through one can change the value seen through the other, which forces it to reload from memory and blocks optimization. restrict promises there is no overlap and typically unlocks large speedups in copy and DSP loops.

The full explanation

Why the compiler is stuck

void scale(int *out, const int *in, int n, int factor) {
    for (int i = 0; i < n; i++) {
        out[i] = in[i] * factor;
    }
}

The compiler would like to keep factor in a register and vectorize the loop. But it cannot prove that out and in do not overlap. If the caller passed the same array for both, then writing out[0] changes in[0], and the loop must be executed exactly in the order written. So the compiler generates a conservative, slow loop that reloads on every iteration.

Now add the promise:

void scale(int * restrict out, const int * restrict in, int n, int factor) {

restrict says: for the lifetime of this pointer, the object it points at will only be accessed through this pointer. The compiler can now reorder, keep values in registers, unroll, and use SIMD. On a Cortex M4 with DSP instructions or an M7, this can be a two to four times difference on a tight loop.

Lying about it is undefined behaviour and produces code that is wrong only at higher optimization levels, which is the worst possible failure mode.

This is exactly why memcpy and memmove are two separate functions. memcpy declares both pointers restrict, so it may copy in any order and in any width. memmove does not, and must handle overlap by choosing a direction.

Strict aliasing, the related rule

C says you may only access an object through a pointer of a compatible type. So this is undefined:

float f = 1.0f;
uint32_t bits = *(uint32_t *)&f;      /* type punning, undefined behaviour */

The compiler is allowed to assume a float* and a uint32_t* never refer to the same memory, so it may reorder the write to f and the read of bits. At -O0 it works, at -O2 it may not.

The correct ways:

/* 1. memcpy, which the compiler recognises and compiles to zero instructions */
uint32_t bits;
memcpy(&bits, &f, sizeof bits);

/* 2. a union, which is explicitly allowed in C (not formally in C++, though it works) */
union { float f; uint32_t u; } cvt = { .f = 1.0f };
uint32_t bits = cvt.u;

char* and unsigned char* are exempt from the rule, which is why you may always inspect any object byte by byte through a uint8_t*. That is the escape hatch that makes serialization legal.

You can also disable the assumption globally with -fno-strict-aliasing, which the Linux kernel does, because too much existing code depends on punning.


Q35. How do you debug pointer corruption?

Say this out loud Fastest path is a data watchpoint on the corrupted address, which stops the core at the exact instruction that wrote it. If that is not available, I use canary values around suspect buffers, an MPU region to trap writes to a protected range, poison patterns on freed memory, and I decode the fault registers to recover the faulting PC and address.

The full explanation, in the order you should actually try them

1. Data watchpoint. This is the answer that shows experience.

Every Cortex M with a debug unit has a Data Watchpoint and Trace unit, giving you typically four comparators. In GDB:

(gdb) watch my_variable          # break when the value changes
(gdb) watch *(uint32_t*)0x20001234   # break when this address is written
(gdb) rwatch *(uint32_t*)0x20001234  # break on read

The core halts on the instruction after the write, so backtrace tells you exactly which code did it. This turns a week long bug into a ten minute bug, and it is the first thing to reach for whenever you know what got corrupted but not who did it.

2. Decode the fault.

When the core faults, hardware pushes eight registers onto the active stack:

   SP +0x00 : r0
   SP +0x04 : r1
   SP +0x08 : r2
   SP +0x0C : r3
   SP +0x10 : r12
   SP +0x14 : LR      (the return address of the function that faulted)
   SP +0x18 : PC      (the instruction that faulted)   <-- the one you want
   SP +0x1C : xPSR

A fault handler that captures this:

void HardFault_Handler(void) __attribute__((naked));
void HardFault_Handler(void) {
    __asm volatile (
        "tst lr, #4          \n"   /* which stack was in use? */
        "ite eq              \n"
        "mrseq r0, msp       \n"
        "mrsne r0, psp       \n"
        "b hard_fault_report \n"
    );
}

void hard_fault_report(uint32_t *frame) {
    uint32_t pc   = frame[6];
    uint32_t lr   = frame[5];
    uint32_t cfsr = *(volatile uint32_t *)0xE000ED28;  /* Configurable Fault Status */
    uint32_t bfar = *(volatile uint32_t *)0xE000ED38;  /* Bus Fault Address */
    uint32_t mmar = *(volatile uint32_t *)0xE000ED34;  /* MemManage Fault Address */
    log_fault(pc, lr, cfsr, bfar, mmar);
    for (;;) { }
}

CFSR tells you the category, and if the BFARVALID or MMARVALID bit is set, BFAR or MMFAR holds the exact address that was accessed illegally. Then arm-none-eabi-addr2line -e firmware.elf <pc> converts the PC into a file and line number.

3. Canaries around buffers.

typedef struct {
    uint32_t guard_lo;
    uint8_t  data[256];
    uint32_t guard_hi;
} guarded_buf_t;

#define GUARD 0xA5A5A5A5u

void check(const guarded_buf_t *b) {
    if (b->guard_lo != GUARD || b->guard_hi != GUARD) {
        fault("buffer overrun detected");
    }
}

The same idea applied to stacks is how FreeRTOS stack overflow checking works. Method 1 checks the stack pointer against the limit at each context switch, method 2 fills the stack with a known pattern at creation and verifies the last bytes still hold it. Turn on method 2 in development.

4. MPU regions.

Configure a region covering the structure you want to protect, mark it read only or no access, and any stray write raises a MemManage fault with the address in MMFAR. Also configure a no access region at address 0 to catch null dereferences, and a small no access guard band below each task stack so an overflow faults instead of silently eating the neighbour.

5. Poison freed memory.

Fill freed blocks with 0xDD and newly allocated blocks with 0xCD in debug builds. Then a use after free reads 0xDDDDDDDD, which is obviously wrong, instead of stale but plausible data. Many allocators including some FreeRTOS heap variants can do this with a build flag.

6. Run the logic on a host.

Separate hardware access behind an interface so the algorithmic parts of your firmware can be compiled and run on a PC. Then AddressSanitizer catches buffer overruns, use after free, and leaks with exact stack traces, and valgrind catches uninitialized reads. This is the single highest leverage change you can make to a firmware codebase’s debuggability.

7. Static analysis and compiler flags.

-Wall -Wextra -Werror -Wshadow -Wcast-align -Wconversion
-fstack-protector-strong -fsanitize=undefined     (host builds)

Plus cppcheck, clang-tidy, or a commercial MISRA checker in CI.

How to structure the spoken answer

Narrow the problem first: is the corruption at a fixed address or a moving one, is it deterministic or timing dependent, does it correlate with an interrupt or DMA activity. Fixed address and deterministic means a watchpoint solves it immediately. Timing dependent and correlated with an ISR points at a missing critical section rather than a pointer bug. Corruption that appears only when DMA runs points at a cache maintenance or alignment problem. Saying that out loud, before naming any tool, is what distinguishes a senior answer.


Quick revision sheet for questions 1 to 35

Concept The one sentence you must remember
Stack vs heap Stack is LIFO and cannot fragment, heap is any order and therefore can
.data vs .bss .data costs flash and RAM, .bss costs only RAM
const on a table Moves it from RAM to flash, often the cheapest memory saving available
Alignment Address must be a multiple of size, or the core faults or slows down
Padding Order members widest to narrowest and the padding mostly disappears
AAPCS r0 to r3 arguments and scratch, r4 to r11 must be preserved, r0 returns
static in a function Changes lifetime to forever, and breaks reentrancy
static at file scope Changes visibility to private, lifetime unchanged
volatile Visibility, not atomicity and not ordering
Atomicity Needs a critical section or an atomic, never volatile alone
Pointer arithmetic p + 1 moves sizeof(*p) bytes
node_t ** The way to modify the caller’s pointer, and the way to delete without a prev
void * Generic address plus a cast back, the basis of every C callback API
Function pointer Vector tables, driver ops structs, state machines
Dangling pointer The data still looks right, which is why it is hard
Double free Corrupts the allocator, crashes in the next unrelated malloc
Shallow copy Two owners for one buffer
restrict A promise of no overlap that unlocks vectorization
Strict aliasing Type pun with memcpy or a union, never with a pointer cast
Debugging corruption Data watchpoint first, then canaries, MPU, poison, host sanitizers


All seven parts of the guide

Start here: The Embedded Firmware DSA Interview Guide, all 248 questions indexed in one place. Architecture round: Firmware Architecture Interview Questions: 100 Q&A.


Work with us

Kalapi Infotech builds firmware for connected embedded products — architecture and BSP work, RTOS and bare-metal development, secure boot and OTA infrastructure. If you are hiring for these roles, or building a product that needs them, we would be glad to talk.

Get in touch with our firmware team →

Leave a Reply

Your email address will not be published. Required fields are marked *