Embedded C Interview Questions for Experienced Engineers: Advanced Scenarios

Embedded C interviews for experienced engineers stop asking what a keyword means and start asking what you would do when something breaks on real hardware. This guide collects 19 embedded C interview questions for experienced engineers, built around the scenarios that tend to come up in firmware roles: interrupts and shared data, stack and heap behaviour, undefined behaviour, peripheral registers, fault debugging and reliable design. The host-runnable examples were compiled with GCC (-std=c11 -Wall -Wextra -pedantic); the one Arm-specific example is labelled illustrative. These are representative practice questions, not a claim about what any particular company asks.

Topics Covered

  1. Interrupts and Concurrency (Q1–Q4)
  2. Memory: Stack, Heap and Flash (Q5–Q8)
  3. Data Representation and Undefined Behaviour (Q9–Q12)
  4. Hardware Access and Debugging (Q13–Q16)
  5. Design and Reliability (Q17–Q19)
Interrupts and Concurrency
Q1A 64-bit tick counter is updated in an ISR and read in main. Why is volatile not enough, and how do you read it safely?

volatile only tells the compiler that every access must really happen and must not be cached in a register. It says nothing about atomicity. On a 32-bit core a 64-bit value takes two loads, so an interrupt can arrive between them and you read a high word from one tick and a low word from another. The same applies to counter++ on a plain 32-bit variable: it is a load, an add and a store, and an ISR that modifies the variable in between loses an update.

You have three options: briefly disable interrupts around the access, use a hardware or C11 atomic if the target supports it for that width, or design the data so a lock-free read is possible. The snippet below uses the third option: the reader re-reads the high word and retries if it changed.

#include <stdint.h>
#include <stdio.h>

/* A 64-bit tick counter on a 32-bit MCU, updated by a timer ISR in two halves. */
static volatile uint32_t tick_hi, tick_lo;

void timer_isr(void)
{
    uint32_t lo = tick_lo + 1u;
    if (lo == 0u) {
        tick_hi++;
    }
    tick_lo = lo;
}

/* Thread-context reader: retry if the ISR rolled the high word mid-read. */
uint64_t ticks_read(void)
{
    uint32_t hi1, lo, hi2;
    do {
        hi1 = tick_hi;
        lo  = tick_lo;
        hi2 = tick_hi;
    } while (hi1 != hi2);
    return ((uint64_t)hi1 << 32) | lo;
}

int main(void)
{
    tick_hi = 0u; tick_lo = 0xFFFFFFFEu;
    timer_isr(); timer_isr();           /* simulate two ticks across the rollover */
    printf("%llx\n", (unsigned long long)ticks_read());   /* 100000000 */
    return 0;
}

This works because the ISR runs to completion relative to the reader; the reader can be preempted, but never sees a half-finished ISR update. If the ISR could itself be preempted by a higher-priority writer, you would need a different scheme.

💡 Interviewers like the follow-up “what is the cost of disabling interrupts?”: it adds worst-case interrupt latency equal to the length of the critical section, so keep it to a few instructions.
Q2How would you pass bytes from an ISR to the main loop without disabling interrupts?

A single-producer, single-consumer ring buffer. The ISR owns head and only writes it; the consumer owns tail and only writes it. Because each index has exactly one writer, no lock is needed, but you do need the right ordering: the producer must store the data before it publishes the new head, and the consumer must read the data after it sees the new head. C11 <stdatomic.h> release/acquire ordering expresses exactly that, and using free-running indices with a power-of-two size makes full/empty detection simple (head - tail == SIZE means full).

#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

#define RB_SIZE 16u                      /* must be a power of two */

typedef struct {
    uint8_t buf[RB_SIZE];
    _Atomic uint32_t head;               /* written only by the producer */
    _Atomic uint32_t tail;               /* written only by the consumer */
} rb_t;

bool rb_put(rb_t *r, uint8_t b)          /* producer (e.g. UART RX ISR) */
{
    uint32_t h = atomic_load_explicit(&r->head, memory_order_relaxed);
    uint32_t t = atomic_load_explicit(&r->tail, memory_order_acquire);
    if (h - t == RB_SIZE) {
        return false;                    /* full */
    }
    r->buf[h & (RB_SIZE - 1u)] = b;
    atomic_store_explicit(&r->head, h + 1u, memory_order_release);
    return true;
}

bool rb_get(rb_t *r, uint8_t *out)       /* consumer (main loop / task) */
{
    uint32_t t = atomic_load_explicit(&r->tail, memory_order_relaxed);
    uint32_t h = atomic_load_explicit(&r->head, memory_order_acquire);
    if (h == t) {
        return false;                    /* empty */
    }
    *out = r->buf[t & (RB_SIZE - 1u)];
    atomic_store_explicit(&r->tail, t + 1u, memory_order_release);
    return true;
}

int main(void)
{
    static rb_t rb;
    uint8_t v;
    for (uint8_t i = 0; i < 20; i++) { (void)rb_put(&rb, i); }  /* last 4 rejected */
    unsigned n = 0;
    while (rb_get(&rb, &v)) { n++; }
    printf("%u\n", n);                   /* 16 */
    return 0;
}

Be ready to say when this breaks: with two producers (for example two ISRs at different priorities writing the same buffer) the single-writer assumption is gone and you need a critical section or a different structure. On a single-core Cortex-M without caches, a compiler barrier is often what the ordering reduces to; atomics keep the code correct and portable.

💡 State the invariant out loud: “each index has one writer.” That sentence is what the interviewer is listening for.
Q3What belongs in an ISR, and what should be deferred?

Do the minimum that is time-critical: read or acknowledge the peripheral, clear the interrupt source, capture a timestamp or a data word, and signal the rest of the system. Push everything else to a lower priority context: parsing, logging, filesystem or flash writes, anything that blocks, and anything that calls non-reentrant library code such as printf or malloc.

  • Bare-metal: set a flag or enqueue into a ring buffer, and handle it in the main loop.
  • RTOS: use only the ISR-safe API variants (in FreeRTOS, the ...FromISR() functions) and request a context switch on exit if a higher-priority task was woken.
  • Latency budget: the worst-case latency of an interrupt is the longest critical section plus any higher-priority ISRs plus its own entry cost; long handlers hurt every lower-priority interrupt.
💡 A good answer also mentions clearing the peripheral flag before returning, or the interrupt immediately fires again.
Q4Your RTOS system occasionally misses a deadline for a high-priority task. How do you check for priority inversion?

Priority inversion happens when a high-priority task waits for a resource held by a low-priority task, and a medium-priority task keeps preempting the low-priority one, so the high-priority task is delayed by work it does not depend on. Look for shared resources protected by a lock, then check which primitive guards them: in FreeRTOS, mutexes implement priority inheritance, but binary semaphores do not, so a binary semaphore used as a lock does not protect you. The other common fixes are to shorten the critical section, use a priority-ceiling protocol where the RTOS supports it, or restructure so the resource is owned by a single task that others talk to through a queue.

To confirm, use an RTOS trace tool or toggle GPIO pins on task switches and watch the timeline on a logic analyser. Also rule out simpler causes: a long interrupts-disabled section, or a deadlock between two mutexes taken in opposite orders (fix with a global lock order).

💡 Do not claim a mutex “prevents” priority inversion in general; it depends on the RTOS implementing inheritance or ceiling.
Memory: Stack, Heap and Flash
Q5How do you find out how much stack a task or the main thread really uses?

Static analysis gives you a bound; measurement tells you what actually happened. The classic runtime technique is stack painting: fill the whole stack region with a known pattern at startup, run the system through its worst cases, then scan from the far end of the stack to see how much of the pattern survived. FreeRTOS exposes the same idea per task through uxTaskGetStackHighWaterMark().

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define STACK_WORDS 256u
#define PAINT       0xDEADBEEFu

static uint32_t stack_mem[STACK_WORDS];  /* on a real part: the linker's stack region */

void stack_paint(void)
{
    for (size_t i = 0; i < STACK_WORDS; i++) {
        stack_mem[i] = PAINT;
    }
}

/* The stack grows down, so untouched paint is found at the low addresses. */
size_t stack_high_water_bytes(void)
{
    size_t i = 0;
    while (i < STACK_WORDS && stack_mem[i] == PAINT) {
        i++;
    }
    return (STACK_WORDS - i) * sizeof stack_mem[0];
}

int main(void)
{
    stack_paint();
    for (size_t i = STACK_WORDS - 40u; i < STACK_WORDS; i++) {
        stack_mem[i] = 0u;               /* pretend the code used 40 words */
    }
    printf("%zu\n", stack_high_water_bytes());   /* 160 */
    return 0;
}

On the static side, GCC's -fstack-usage writes per-function stack sizes, and call-graph tools can combine them. Recursion, function pointers and variable-length arrays defeat static bounds, which is one reason many coding standards forbid them in firmware. Remember to budget for the interrupt frames that are pushed onto whichever stack is active, not just the code you wrote.

💡 A stack overflow often shows up as a corrupted neighbouring variable, not a crash. If the stack sits next to .bss, check what lies just below it.
Q6Why do many firmware projects forbid malloc, and what do you use instead?

The problems are not the function itself but its behaviour over months of uptime: fragmentation can make an allocation fail even though enough total memory is free, execution time is not deterministic, failure must be handled everywhere, and most implementations are not safe to call from an ISR without extra locking. Safety-oriented guidelines such as MISRA C restrict dynamic allocation for these reasons.

The usual replacement is static allocation for long-lived objects and fixed-size block pools for objects with a changing lifetime. A pool allocates and frees in constant time, cannot fragment, and exhausts predictably.

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#define BLK_SIZE  32u
#define BLK_COUNT 8u

typedef union blk {
    union blk *next;                     /* while free */
    uint8_t    data[BLK_SIZE];           /* while allocated */
} blk_t;

static blk_t  pool[BLK_COUNT];
static blk_t *free_list;

void pool_init(void)
{
    free_list = NULL;
    for (size_t i = 0; i < BLK_COUNT; i++) {
        pool[i].next = free_list;
        free_list = &pool[i];
    }
}

void *pool_alloc(void)                   /* O(1), no fragmentation */
{
    blk_t *b = free_list;                /* guard with a critical section if ISRs also allocate */
    if (b != NULL) {
        free_list = b->next;
    }
    return b;
}

void pool_free(void *p)
{
    blk_t *b = p;
    b->next = free_list;
    free_list = b;
}

int main(void)
{
    pool_init();
    unsigned n = 0;
    void *p;
    while ((p = pool_alloc()) != NULL) { n++; }
    printf("%u blocks, then NULL: exhaustion is deterministic\n", n);
    return 0;
}
💡 If you do allow heap use, say when: allocate during initialisation only, and never free.
Q7What decides whether a const table ends up in flash or RAM?

On a typical Arm Cortex-M toolchain, a file-scope or static object that is const and has a constant initialiser is placed in a read-only section (.rodata) and lives only in flash; the CPU reads it in place. The same table without const goes into .data: the initial values are stored in flash and copied into RAM by the startup code, so it costs both. That is why an unmarked 2 KB lookup table can silently eat 2 KB of scarce RAM.

Two caveats an experienced candidate should know. First, a const local array is usually built on the stack each call unless it is also static. Second, on Harvard-style architectures with separate address spaces (AVR, for example), flash data needs special access macros, which is toolchain specific. You can confirm placement by reading the linker map file or running size on the object file.

💡 “const means read-only to the compiler” and “const means stored in flash” are different statements; know which one you are making.
Q8A DMA transfer works on one MCU but returns stale data on a Cortex-M7 board. What is going on?

The Cortex-M7 has optional data caches, and DMA masters read and write memory directly, bypassing the CPU cache. If the CPU wrote a buffer that is still dirty in the cache, the DMA sends the old memory contents; if DMA writes new data to memory while the cache holds a stale copy of the same lines, the CPU keeps reading the stale copy.

  • Before a DMA transmit: clean (write back) the cache lines covering the buffer.
  • After a DMA receive: invalidate those lines before the CPU reads the buffer.
  • Alignment: align and size the buffer to the cache line (32 bytes on Cortex-M7); otherwise an invalidate can throw away neighbouring variables that share the line.
  • Alternatives: place DMA buffers in a non-cacheable MPU region, or use memory the vendor documents as non-cached.

CMSIS provides SCB_CleanDCache_by_Addr() and SCB_InvalidateDCache_by_Addr() for the cache operations. The symptom “works on M4, fails on M7 with the same code” is a strong hint for this answer.

💡 Also mention the compiler: the buffer should be accessed through a pointer the compiler cannot assume is unchanged (volatile or after a barrier).
Data Representation and Undefined Behaviour
Q9How do you parse a packet header from a byte stream safely and portably?

Do not cast the receive buffer to a struct pointer. Three things go wrong: the struct has padding that the wire format does not, the buffer may not be aligned for the struct's members (an unaligned access can fault on some cores and is undefined behaviour in C regardless), and the byte order of the wire may differ from the CPU. Assemble each field from bytes with explicit shifts. The result is correct on any compiler and endianness, and modern compilers turn it into a single load where the hardware allows.

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

typedef struct {
    uint8_t  type;
    uint16_t len;
    uint32_t seq;
} hdr_t;                                 /* in memory: padded */

/* Wire format: type(1) len(2, little-endian) seq(4, little-endian) = 7 bytes */
#define HDR_WIRE_SIZE 7u

bool hdr_parse(const uint8_t *p, size_t n, hdr_t *h)
{
    if (n < HDR_WIRE_SIZE) {
        return false;
    }
    h->type = p[0];
    h->len  = (uint16_t)((uint16_t)p[1] | ((uint16_t)p[2] << 8));
    h->seq  = (uint32_t)p[3] | ((uint32_t)p[4] << 8) |
              ((uint32_t)p[5] << 16) | ((uint32_t)p[6] << 24);
    return true;
}

int main(void)
{
    const uint8_t wire[] = { 0x02, 0x10, 0x00, 0x78, 0x56, 0x34, 0x12 };
    hdr_t h;
    if (hdr_parse(wire, sizeof wire, &h)) {
        printf("type=%u len=%u seq=0x%08X sizeof(hdr_t)=%zu\n",
               h.type, h.len, (unsigned)h.seq, sizeof h);
    }
    return 0;
}

Note the check on the length before reading, the explicit casts before the shifts (see Q11), and that sizeof(hdr_t) (8 here) differs from the 7-byte wire size. __attribute__((packed)) removes padding but not the alignment or endianness problems, and it can make member access slower or unsafe on strict-alignment targets.

💡 Validate lengths coming from the packet itself against the buffer size before using them, since they are attacker- or noise-controlled.
Q10Is it legal to read a float's bits through a uint32_t *? What are the safe alternatives?

No. Accessing an object through a pointer to an incompatible type violates the strict aliasing rule and is undefined behaviour; the optimiser is allowed to assume the two pointers do not alias and can reorder or drop the accesses, so it may appear to work at -O0 and break at -O2. The defined options are to copy with memcpy or to read through a union (permitted in C, though not in C++). Compilers recognise the memcpy idiom and emit a single move, so there is no cost.

#include <stdint.h>
#include <string.h>
#include <stdio.h>

uint32_t float_bits(float f)
{
    uint32_t u;
    _Static_assert(sizeof u == sizeof f, "size mismatch");
    memcpy(&u, &f, sizeof u);            /* defined behaviour; compiles to a register move */
    return u;
}

int main(void)
{
    printf("0x%08X\n", (unsigned)float_bits(1.0f));   /* 0x3F800000 on IEEE-754 targets */
    return 0;
}

Accessing memory through char-type pointers is the standard exception, which is why byte-wise copies are always allowed.

💡 “It works on my compiler” is not an answer to an undefined-behaviour question. Say what the standard guarantees.
Q11What does this print, and why? uint8_t a = 0x0F; if (~a == 0xF0) ...

It does not take the branch. Operands narrower than int are promoted to int before the operator is applied, so ~a is ~0x0000000F, which is 0xFFFFFFF0 on a 32-bit-int target, not 0xF0. The comparison is then false. Cast the result back to the narrow type ((uint8_t)~a) before comparing.

The same promotion causes a worse bug with shifts: uint8_t b = 0x80; b << 24 promotes b to signed int, and shifting 0x80 left by 24 places a bit into the sign bit, which the standard leaves undefined. Cast to an unsigned type of the final width first. GCC with -Wall -Wextra flags the comparison case, as the compile of this snippet does.

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint8_t a = 0x0Fu;

    if (~a == 0xF0u) { puts("never printed: ~a is a 32-bit int"); }
    if ((uint8_t)~a == 0xF0u) { puts("cast back to 8 bits: equal"); }

    uint8_t b = 0x80u;
    /* uint32_t bad = b << 24;   b is promoted to signed int; 0x80 << 24 does not fit -> UB */
    uint32_t ok = (uint32_t)b << 24;
    printf("0x%08X\n", (unsigned)ok);
    return 0;
}
💡 If asked to explain it, say “integer promotion” and “usual arithmetic conversions” by name.
Q12How do you write a timeout check that survives the 32-bit tick counter wrapping around?

Never compare absolute times with now >= deadline; that fails the moment either value wraps. Compare the difference, using unsigned subtraction (which is well defined modulo 232) and interpret it as signed. It stays correct as long as the interval you measure is less than half the counter range.

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

/* True once 'now' has reached 'deadline', correct across 32-bit wraparound
   as long as the two are less than 2^31 ticks apart. */
static inline bool time_reached(uint32_t now, uint32_t deadline)
{
    return (int32_t)(now - deadline) >= 0;
}

int main(void)
{
    uint32_t deadline = 0xFFFFFFF0u + 0x20u;          /* wraps to 0x10 */
    printf("%d %d\n", time_reached(0xFFFFFFF8u, deadline),   /* 0 */
                      time_reached(0x00000011u, deadline));  /* 1 */
    /* The naive 'now >= deadline' gets both cases wrong. */
    return 0;
}

Converting an out-of-range unsigned value to int32_t is implementation-defined rather than undefined in C, and every mainstream embedded compiler defines it as two's-complement wrap, so the idiom is widely used. A 1 kHz tick wraps a 32-bit counter in about 49.7 days, so this is a real bug in long-running devices, not a theoretical one.

💡 Offer to test it by starting the counter close to 0xFFFFFFFF in a unit test; a bug that needs 49 days of uptime is cheap to reproduce that way.
Hardware Access and Debugging
Q13Why can STATUS |= FLAG be a bug on a peripheral register?

Many status registers are write-1-to-clear (W1C): writing a 1 to a bit clears it, writing 0 leaves it alone. The expression STATUS |= FLAG reads the whole register, sets one bit in the copy, and writes the copy back. Every other flag that happened to be pending at the read is written back as 1, which clears it, and you have lost an event. For W1C bits, write only the bit you mean to clear: STATUS = FLAG. Always check the reference manual for the access type of each bit (read-only, read/write, W1C, clear-on-read).

#include <stdint.h>
#include <stdio.h>

typedef struct {
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
} uart_regs_t;

#define UART_CTRL_EN    (1u << 0)
#define UART_STATUS_OVR (1u << 2)        /* write 1 to clear */
#define UART_STATUS_FE  (1u << 3)        /* write 1 to clear */

static uart_regs_t fake_uart;            /* stands in for a memory-mapped peripheral */
#define UART (&fake_uart)

void uart_enable(void)
{
    UART->CTRL |= UART_CTRL_EN;          /* read-modify-write is fine for a plain control bit */
}

void uart_clear_framing_error(void)
{
    UART->STATUS = UART_STATUS_FE;       /* one write clears only FE */
    /* UART->STATUS |= UART_STATUS_FE;      would write back every flag that is
                                            currently 1 and clear them all */
}

int main(void)
{
    uart_enable();
    uart_clear_framing_error();
    printf("CTRL=0x%X\n", (unsigned)UART->CTRL);
    return 0;
}

Related points worth mentioning: registers are accessed through volatile-qualified types so the compiler issues every access; bit-fields are a poor choice for registers because their layout and allocation order are implementation-defined; and some peripherals require a read-back or a barrier after a write before the effect is guaranteed.

💡 This question is a quick filter for people who have brought up a peripheral on real hardware versus people who have only read example code.
Q14The device hard-faults in the field. How do you find the faulting instruction?

On Cortex-M the hardware pushes a stack frame on exception entry: R0–R3, R12, LR, the return address (PC) and xPSR. The PC in that frame is usually the instruction that faulted or is very close to it. A HardFault handler that selects the right stack pointer (MSP or PSP, using bit 2 of the EXC_RETURN value in LR) and passes the frame to a C function lets you read those values in a debugger or log them to non-volatile memory.

#include <stdint.h>

/* Illustrative Cortex-M3/M4 code (needs an Arm toolchain; not host-compiled). */
void hardfault_c(uint32_t *frame)
{
    /* Hardware stacked: R0, R1, R2, R3, R12, LR, PC, xPSR */
    volatile uint32_t pc   = frame[6];   /* instruction that faulted (or the one after) */
    volatile uint32_t lr   = frame[5];
    volatile uint32_t cfsr = *(volatile uint32_t *)0xE000ED28u;  /* configurable fault status */
    volatile uint32_t bfar = *(volatile uint32_t *)0xE000ED38u;  /* valid only if BFARVALID is set */
    (void)pc; (void)lr; (void)cfsr; (void)bfar;
    for (;;) { }                          /* park here and inspect in the debugger */
}

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

Then decode the Configurable Fault Status Register: bus faults, memory-management faults and usage faults (undefined instruction, unaligned access with trapping enabled, divide by zero when enabled) each have their own bits, and BFAR/MMFAR hold the faulting address when their valid bits are set. Look the PC up in the map file or with addr2line. Typical root causes: a null or dangling pointer, stack overflow, a misaligned access, or calling a function through a corrupted function pointer.

💡 Fault registers are in the Arm architecture reference manuals; cite the manual for your core rather than relying on memory.
Q15A bug disappears when you compile with -O0 or add a printf. Where do you look?

This pattern (a “Heisenbug”) usually points at one of a short list of causes, which you can check in order:

  • Missing volatile or atomicity on data shared with an ISR or another task, so the optimiser keeps a stale value in a register (Q1, Q2).
  • Undefined behaviour such as strict-aliasing violations, signed overflow or an uninitialised variable, where the optimiser's assumptions differ from yours (Q10, Q11).
  • Timing: the extra code changes the interleaving of ISRs and tasks, hiding a race.
  • Memory corruption: stack or buffer overflows land on different data when the layout shifts.

Practical steps: build with -Wall -Wextra and treat warnings seriously, run a static analyser, try the same code under UBSan/ASan on the host if the logic can be run there, and bisect optimisation flags or single functions with __attribute__((optimize)) only as a diagnostic, not as a fix.

💡 Saying “I would never ship by lowering the optimisation level” signals the right instinct.
Q16What is the difference between reentrant and thread-safe, and which matters in an ISR?

A reentrant function can be interrupted in the middle of its execution and called again (from an ISR, or another task) before the first call finishes, and both calls behave correctly. It touches only its arguments and its own stack, or protects shared state in a way that is safe against being re-entered. A thread-safe function is correct when called from several threads, usually by using locks. A function that takes a mutex is thread-safe but not safe to call from an ISR, because an ISR cannot block, and if the interrupted task already holds the mutex the system deadlocks.

#include <stddef.h>
#include <stdio.h>

/* Not reentrant: every caller shares one static buffer. */
const char *fmt_bad(int v)
{
    static char buf[16];
    snprintf(buf, sizeof buf, "%d", v);
    return buf;
}

/* Reentrant: the caller owns the storage. */
const char *fmt_ok(int v, char *buf, size_t n)
{
    snprintf(buf, n, "%d", v);
    return buf;
}

int main(void)
{
    const char *a = fmt_bad(1);
    const char *b = fmt_bad(2);
    printf("%s %s\n", a, b);             /* "2 2": the first result was overwritten */

    char x[16], y[16];
    printf("%s %s\n", fmt_ok(1, x, sizeof x), fmt_ok(2, y, sizeof y));   /* "1 2" */
    return 0;
}

Standard library functions that return pointers to internal static storage (strtok, and in some implementations localtime) are classic non-reentrant examples; strtok_r (POSIX) and caller-provided buffers are the usual fixes. Check your C library's documentation for the exact guarantees of each function you call from interrupt context.

💡 Reentrancy is the property you need for ISRs; thread-safety through blocking locks is not enough.
Design and Reliability
Q17How do you structure firmware with many modes and events without a tangle of nested if/switch?

Use an explicit state machine. A table-driven version stores, for every (state, event) pair, the next state and the action to run. The behaviour of the whole system then reads as data, adding a state or event is a visible change to the table, and the table lives in flash because it is const. Missing entries are zero-initialised, so a null action is something you can check or assert on; the snippet fills every cell instead, which is safer.

#include <stdio.h>

typedef enum { ST_IDLE, ST_ARMED, ST_FIRING, ST_COUNT } state_t;
typedef enum { EV_BUTTON, EV_TIMEOUT, EV_COUNT } event_t;

static void act_none(void) { }
static void act_arm(void)  { puts("arm"); }
static void act_fire(void) { puts("fire"); }
static void act_safe(void) { puts("safe"); }

typedef struct {
    state_t next;
    void  (*action)(void);
} transition_t;

static const transition_t table[ST_COUNT][EV_COUNT] = {
    /*               EV_BUTTON                EV_TIMEOUT             */
    [ST_IDLE]   = { { ST_ARMED,  act_arm  }, { ST_IDLE, act_none } },
    [ST_ARMED]  = { { ST_FIRING, act_fire }, { ST_IDLE, act_safe } },
    [ST_FIRING] = { { ST_FIRING, act_none }, { ST_IDLE, act_safe } },
};

static state_t state = ST_IDLE;

void dispatch(event_t ev)
{
    const transition_t *t = &table[state][ev];
    t->action();
    state = t->next;
}

int main(void)
{
    dispatch(EV_BUTTON);   /* arm  */
    dispatch(EV_BUTTON);   /* fire */
    dispatch(EV_TIMEOUT);  /* safe */
    return 0;
}

Where actions can be long-running, keep the dispatcher non-blocking, put events in a queue so ISRs only post events, and run the state machine from one context. That removes most locking problems by design.

💡 Mention how you would test it: feed an event sequence and assert on the resulting state and actions, entirely on the host.
Q18How do you design a firmware update that survives power loss halfway through?

The core rule is that the device must always have a bootable image. A common approach is a small, rarely-updated bootloader plus two application slots (A/B), or one slot plus a staging area, with these elements:

  • Write the new image to the inactive slot, never over the running one.
  • Verify before switching: check length and a CRC or hash of the whole image, and a signature if the product needs authenticity.
  • Switch atomically: update a small piece of metadata (a version or “active slot” flag, written in a way that is valid or clearly invalid after a power cut) only after verification.
  • Confirm after boot: mark the new image as good only when it has run successfully; if it never confirms, the bootloader falls back to the previous slot.
  • Handle flash realities: sectors must be erased before programming, erase and program take time and have write-endurance limits.
💡 Ask or state your assumptions: how much flash, is there a secure boot requirement, and who can reach the device physically.
Q19How should a watchdog be used so that it actually protects the system?

The mistake is to kick the watchdog from a timer interrupt or from a low-priority idle loop: the timer keeps firing even if the application has hung, so the watchdog never trips. The kick should be conditional on proof of progress. In a multi-task system, each critical task sets a flag or increments a counter when it completes a cycle, and one supervisor kicks the hardware only when all flags have been set within the window, then clears them. Consider a windowed watchdog if your part has one, so a runaway loop that kicks too often is also caught.

Pair it with reset-cause logging: read the reset-reason register at boot, store it, and report it, because “it rebooted” without a cause is not debuggable. Also decide what the system does after a watchdog reset: re-initialise cleanly, and escalate (safe state) if resets repeat.

💡 A watchdog is a last resort, not a substitute for finding the hang.

Practise these under interview conditions

Work through embedded C, RTOS and Linux questions with guided feedback in the interview prep bootcamp.

Explore the Interview Prep Bootcamp →

Frequently Asked Questions

Frequently Asked Questions

Q: Is this guide meant for freshers?
No. It targets engineers with roughly five or more years of experience who are asked scenario and debugging questions. For fundamentals, start with the 50 must-know embedded C interview questions.

Q: Do I need to memorise the code in these answers?
No. Understand the invariant behind each one (single writer, W1C semantics, unsigned difference) so you can reproduce the idea on a whiteboard in your own style.

Q: Were the code examples tested?
The host-runnable examples were compiled with GCC using -std=c11 -Wall -Wextra -pedantic and run. The Cortex-M hard fault handler is marked illustrative because it needs an Arm toolchain.

Q: What should I read next?
Pointers come up in almost every round; see the C pointers interview questions, and for a broad warm-up the top 40 embedded systems interview questions.