Skip to content

Firmware Fundamentals interview questions

Writing C and Python that runs against real hardware.

Practice all 10, gradedOne at a time. Free, no account.

What interviewers probe

  • Volatile & Register Access
  • Bit Manipulation
  • Fixed Point & Numeric Care
  • Concurrency Hazards
  • Python for Test & Instrumentation

Builds on Interrupts & ISR Design.

10 questions, easiest first

  1. 1You write a firmware loop that busy-waits on a global flag set inside an interrupt handler, like `while (!data_ready) {}`, before moving on. Unoptimized, it works fine on the bench. The moment you enable -O2 optimization, the loop never exits, even though a debugger shows the flag has actually become 1 in memory. Walk me through why this happens and how you would fix it.intro
    Answer this one, graded
    Or just show what a strong answer covers

    The compiler doesn't know this flag can change from outside the code path it's analyzing, since the write happens in an ISR it treats as unrelated. At -O2 it sees no write to the flag inside the loop body, so it decides the value can't change during the loop and caches it in a register, spinning on that stale copy forever even though memory has actually changed. The fix is declaring the flag volatile, which forces every access to go through an actual memory load or store instead of a cached register value. I'd confirm the fix by looking at the disassembly to make sure the loop now issues a load instruction each iteration instead of reading once before the loop. I'd also keep in mind volatile only guarantees the read is fresh - it doesn't make multi-byte accesses atomic or add ordering guarantees with other variables, so if this were a 32-bit flag on an 8-bit core I'd need more care than just the keyword.

    • Diagnoses compiler caching — Explains that the optimizer sees no write to the flag inside the loop body and hoists/caches the read into a register, so the loop spins on a stale copy.
    • Names volatile as the fix — States that declaring the flag volatile forces the compiler to reload from memory on every check.
    • Notes limits of volatile — Mentions volatile does not guarantee atomicity for multi-byte types or provide memory barriers.
    • Verification step — Proposes checking the generated assembly to confirm the loop now reloads the variable each iteration rather than just trusting the fix.

    Where answers go wrong: Assuming it's a hardware or interrupt-priority problem rather than a compiler behavior problem. · Believing volatile fully solves concurrency/atomicity issues rather than just caching. · Fixing it by disabling optimization instead of understanding and fixing the root cause.

  2. 2A student writes firmware that polls a hardware status register to know when a peripheral is ready: `while (*STATUS_REG != READY) { }` followed by code that reads data out. On a debug build (no optimization) it works fine. The moment they turn on -O2 optimization, the board hangs forever at that loop even though the peripheral is clearly ready (an LED tied to the same signal turns on). What's happening, and what's the fix?intro

    STATUS_REG is a pointer to a fixed hardware address, e.g. `#define STATUS_REG ((uint8_t*)0x4000)`. The value at that address changes due to hardware activity, not due to anything the CPU writes.

    Answer this one, graded
    Or just show what a strong answer covers

    This is a missing-volatile bug. The compiler sees a loop that reads *STATUS_REG repeatedly but never writes it anywhere in the visible code, so under optimization it's free to assume the value doesn't change, load it into a CPU register once, and just spin on that cached copy forever — even though the actual memory-mapped register is changing due to hardware, not software. The fix is to declare the register as volatile, e.g. `volatile uint8_t* STATUS_REG`, which tells the compiler this memory can change outside the program's control and forces a fresh load from memory on every access. That's exactly why it worked in the unoptimized build — without optimization the compiler tends to re-read memory anyway, so the bug was latent until -O2 exposed it.

    • Diagnoses the actual cause — States that the compiler, seeing the loop never writes to *STATUS_REG, hoists the read out of the loop and caches the value in a register, so the loop condition never re-evaluates real hardware state under optimization.
    • Names volatile as the fix — Correctly proposes declaring STATUS_REG's pointed-to type as volatile (e.g. `volatile uint8_t* STATUS_REG`), not the pointer variable itself unless that also changes.
    • Explains why it worked without optimization — Notes unoptimized builds tend to re-read memory every time by default, masking the bug, which is why it 'worked' before -O2.
    • Avoids over-broad fixes — Does not propose disabling all optimization globally as the real fix, recognizing that's a workaround, not a correct fix.

    Where answers go wrong: Blaming the hardware peripheral itself for not asserting the ready signal. · Adding volatile to some unrelated variable instead of the register pointer/type. · Assuming the bug is caused by an interrupt not firing, when no interrupt is involved here. · Proposing to just turn off optimization for the whole project as the 'real' fix.

  3. 3A peripheral control register packs several settings into different bit ranges - say bits 3:5 select a clock divider, while other bits control unrelated features you must not disturb. Describe precisely how you'd change just the clock divider bits in C without touching anything else, and tell me one concrete way this read-modify-write sequence can silently go wrong on real hardware.easy
    Answer this one, graded
    Or just show what a strong answer covers

    I'd write something like reg = (reg & ~(0x7 << 3)) | ((new_div & 0x7) << 3) - clear the three target bits with an inverted mask, then OR in the new value after masking it so it can't spill into neighboring fields. Where this goes wrong: this is a read-modify-write, so if an interrupt or another piece of code touches the same register between my read and my write, whatever it changed gets silently clobbered because I'm writing back a stale copy of the whole register. It's worse if any bits in that register are write-1-to-clear status flags - a blind read-modify-write can accidentally clear a pending flag you never meant to touch. The mitigation is a short critical section around the read-modify-write, or better, using dedicated set/clear alias registers if the peripheral provides them so you never have to read the current value at all.

    • Correct bit manipulation — Gives an expression equivalent to reg = (reg & ~(mask << shift)) | ((value & mask) << shift), masking both the register and the new value.
    • Identifies RMW race — Explains that if an interrupt or other code modifies the same register between the read and the write, that change is silently overwritten.
    • Mentions write-1-to-clear risk — Notes that some peripheral registers have self-clearing or write-1-to-clear status bits that a naive read-modify-write can corrupt.
    • Proposes mitigation — Suggests a short critical section (disabling interrupts) or using dedicated set/clear alias registers where the peripheral supports them.

    Where answers go wrong: Forgetting to mask the new value before shifting it in, letting stray high bits leak into neighboring fields. · Assuming a single register write is inherently atomic with respect to interrupts. · Not knowing some registers have side effects on write (like clear-on-write status bits).

  4. 4You write a Python script that sends a query over a serial port to a bench multimeter and reads back a line like 'VOLT +1.234E+00\r\n', parses out the float, and logs it in a loop for an overnight test. A few times per thousand readings, the parse throws an exception or returns a garbage value. Before you patch it with a try/except, how do you actually find the root cause, and what's your leading suspicion going in?easy
    Answer this one, graded
    Or just show what a strong answer covers

    I wouldn't just wrap it in a try/except - I'd first log the raw bytes on every failure, not just the parsed value, so I can see exactly what came back when it broke. My leading suspicion is a timing or framing issue: either I'm reading before the full line including its terminator has arrived, so I get a partial response, or the previous response wasn't fully drained from the input buffer before I sent the next query, so my next read picks up leftover bytes concatenated with the new response. I'd check whether my read call reads until the terminator versus a fixed byte count, whether I flush the input buffer before each query, and whether my timeout has enough margin, since a failure rate of a few per thousand smells like an occasional slow response or scheduling hiccup rather than a logic error in the parser itself.

    • Captures raw evidence — Proposes logging the raw bytes/line on every parse failure instead of only handling the exception, to see the actual malformed data.
    • Considers read framing — Considers whether the read call waits for the full terminator/line rather than a fixed size, and whether timeouts are adequate.
    • Considers buffer desync — Considers that leftover bytes from a previous, incompletely-drained response could be read as part of the next query's response.
    • Avoids blind exception handling as the fix — Treats try/except as a last resort after root-causing, not the actual fix for an intermittent parsing bug.

    Where answers go wrong: Immediately wrapping the parser in try/except and moving on without investigating the actual bytes. · Assuming the multimeter itself is unreliable rather than considering the script's read/write timing. · Not distinguishing an exception (malformed data) from a garbage-but-valid-looking float (wrong data entirely).

  5. 5An 8-bit peripheral control register has bit 4 as an 'enable' bit; bits 0-2 hold a baud-rate selection and bit 3 holds a parity setting that were already configured correctly by earlier code. A teammate adds this line to turn the peripheral on: `CTRL_REG = (1 << 4);` After that line runs, the peripheral enables, but the baud rate and parity settings that were working a moment ago are now wrong. What went wrong, and what's the corrected line?easy

    CTRL_REG is a memory-mapped 8-bit register; bits 5-7 are reserved and should be left at their current value.

    Answer this one, graded
    Or just show what a strong answer covers

    The line `CTRL_REG = (1 << 4);` doesn't set bit 4 — it overwrites the entire register with the value 0b00010000, which zeroes out bits 0-3 (baud rate and parity) and bits 5-7. The fix is a read-modify-write using OR: `CTRL_REG |= (1 << 4);` which reads the current register value, ORs in only bit 4, and writes the result back, leaving every other bit exactly as it was. If this register can also be touched from an interrupt, I'd flag that `|=` is a read-then-write and not inherently atomic, so I'd want a short critical section around it to avoid a race with an ISR doing the same kind of update.

    • Identifies the overwrite — States that `CTRL_REG = (1 << 4)` sets the whole register to 0b00010000, clearing bits 0-3 and 5-7 rather than modifying just bit 4.
    • Gives the corrected line — Provides `CTRL_REG |= (1 << 4);` or an equivalent read-modify-write that only touches bit 4.
    • Explains OR vs assignment at the bit level — Articulates that bitwise OR preserves existing 1-bits in other positions while assignment does not.
    • Notes the risk if the register is also written elsewhere concurrently — Mentions that a plain read-modify-write like |= is not atomic and could itself race with an ISR touching the same register.

    Where answers go wrong: Using AND instead of OR to try to set the bit. · Forgetting the shift amount and writing `1` instead of `1 << 4`. · Assuming the peripheral hardware is at fault rather than the software write. · Suggesting the fix is to just re-set the baud rate and parity bits again after, instead of fixing the write pattern.

  6. 6On an 8-bit microcontroller, a timer interrupt increments a 16-bit millisecond counter every tick. In the main loop you read that same variable to time an event. Occasionally you get an elapsed time that's wildly wrong - one 16-bit read comes back nowhere near what it should be. What's actually happening at the instruction level, and how would you fix it?medium
    Answer this one, graded
    Or just show what a strong answer covers

    On an 8-bit core, reading a 16-bit variable takes two separate load instructions, one for the low byte and one for the high byte. If the timer ISR fires and increments the counter in between those two loads, main can end up combining the old low byte with a new high byte (or vice versa), producing a value that's nowhere near correct even though neither individual byte is corrupted. Declaring the counter volatile fixes stale-cache issues but doesn't make this two-instruction read atomic, so that alone won't solve it. The real fix is to briefly disable interrupts around just the read of that variable, or use a read-verify-reread pattern where you read the value twice and only accept it once two consecutive reads agree. The same hazard applies to any multi-byte or multi-step shared state touched from both an ISR and the main loop, not just this specific counter.

    • Explains the torn read — Describes that a 16-bit read on an 8-bit core takes two instructions, and if the ISR fires between them the result combines mismatched high and low bytes.
    • Distinguishes volatile from atomicity — States that volatile alone ensures a fresh memory access but does not make the multi-byte access atomic.
    • Proposes a concrete fix — Proposes disabling interrupts briefly around the read, or a read-twice-and-compare pattern, to guarantee a consistent value.
    • Generalizes the principle — Notes this applies to any multi-byte or multi-step shared state accessed from both ISR and main context, not just this counter.

    Where answers go wrong: Assuming volatile alone fixes the bug because it 'deals with concurrency'. · Blaming the timer hardware instead of the read sequence. · Proposing a fix that disables interrupts for the entire main loop instead of just the critical read.

  7. 7You're accumulating a running sum of 12-bit unsigned ADC samples (each 0-4095) to compute an average without using floats, and you've stored the running sum in a uint16_t. At what sample count does this first overflow? Then tell me what type and scaling you'd actually use so the fixed-point average stays as accurate as a float-based one.medium
    Answer this one, graded
    Or just show what a strong answer covers

    Worst case, every sample is the maximum 12-bit value, 4095. uint16_t maxes out at 65535, so I need the smallest n where 4095*n exceeds 65535. At 16 samples that's 65520, which still fits; at 17 samples it's 69615, which overflows. So overflow first happens at 17 samples, which is way below the 1000-sample target, so a uint16_t accumulator is unusable here. I'd switch to a uint32_t accumulator, which comfortably holds 4095*1000 which is about 4.1 million, nowhere near the 4.3 billion limit. For the average itself, I wouldn't just do sum/1000 and call it done - plain integer division truncates toward zero, so I'd add half the divisor before dividing (sum + 500)/1000 to get correctly rounded results, or keep extra precision by scaling the sum up by a fixed factor before dividing if I need fractional resolution beyond a plain integer average.

    • Correct overflow arithmetic — Computes that the worst case sum is 4095*n and correctly finds where that first exceeds 65535, landing on n=17.
    • Identifies the limiting constraint — States uint16_t's max value of 65535 as the constraint being violated.
    • Right-sizes the accumulator — Proposes a uint32_t (or otherwise sized) accumulator that comfortably holds the full worst-case sum for the intended sample count.
    • Handles rounding in fixed point — Describes computing the average with attention to rounding rather than plain truncating integer division, e.g. adding half the divisor before dividing, or retaining extra precision bits.

    Where answers go wrong: Forgetting the overflow depends on the maximum possible sample value (4095), not an average or typical value. · Choosing a wider accumulator type but not addressing truncation/rounding in the final division. · Confusing bit-width of the ADC (12-bit) with the bit-width needed for the sum accumulator.

  8. 8A 10-bit ADC produces raw codes 0–1023 for an input referenced to 3300 mV. Firmware converts a reading to millivolts with: `uint16_t raw = 800; uint16_t mv = (raw * 3300) / 1023;` on a target where the compiler's `int` type is 16 bits wide, as is common on small 8/16-bit microcontrollers. First: what millivolt value should this reading mathematically represent? Then explain why the actual value this line computes on that target could be wrong, and how you'd rewrite it to be safe.medium

    Assume raw and the literal 3300 are both treated as 16-bit values in the intermediate arithmetic before the division happens.

    Answer this one, graded
    Or just show what a strong answer covers

    Mathematically, (800*3300)/1023 is about 2581 mV. But on a target where int is 16 bits, the intermediate product 800*3300 = 2,640,000 blows past the 65535 ceiling of a 16-bit unsigned type, so it silently wraps around and the division operates on garbage — the computed mv won't be 2581, it'll be something essentially meaningless. The fix is to force the multiplication itself to happen in a wider type, e.g. `uint16_t mv = ((uint32_t)raw * 3300) / 1023;`, so the 2.64 million intermediate has room to exist before the division brings it back down into 16-bit range. I'd also note integer division truncates rather than rounds, so there's a small systematic bias, but that's a much smaller issue than the overflow.

    • Correct mathematical value — Computes (800*3300)/1023 ≈ 2581 mV as the intended, mathematically correct result.
    • Identifies the overflow — States that 800*3300 = 2,640,000 exceeds the 65535 max of a 16-bit unsigned (or ~32767 signed) intermediate, so the product wraps around before division, giving a wrong result on that target.
    • Proposes a correct fix — Suggests widening the intermediate, e.g. `(uint32_t)raw * 3300 / 1023`, or reordering to divide first only if precision loss is acceptable, explaining the tradeoff.
    • Notes truncation/rounding as a secondary concern — Mentions that integer division truncates rather than rounds, introducing a small bias, distinct from the overflow issue.

    Where answers go wrong: Only computing the mathematical answer and missing that the actual computed value would differ due to overflow on a 16-bit int platform. · Assuming int is always 32 bits and dismissing the overflow risk entirely without checking the target's actual int width. · Fixing it by dividing first (`raw / 1023 * 3300`) without noticing that discards nearly all precision since raw < 1023. · Casting the final result to uint32_t instead of casting before the multiply, which doesn't prevent the overflow.

  9. 9You have a handshake where an ISR sets a boolean `new_data_ready` flag when an event occurs, and the main loop does: `if (new_data_ready) { process(); new_data_ready = false; }`. Under sustained high interrupt rate, roughly once every few minutes a data sample is silently dropped - not corrupted, just missing, as if an event fired but was never processed. Walk me through the exact race that causes this, and how you'd redesign the handshake so no event is ever lost, even at high rates.hard
    Answer this one, graded
    Or just show what a strong answer covers

    The race isn't in reading the flag, it's in clearing it. Main sees new_data_ready true because event A happened, and starts processing. While process() is still running, event B occurs and the ISR sets new_data_ready = true again. Main finishes processing A, then unconditionally executes new_data_ready = false - which clobbers the true that event B just set, so event B's signal is gone even though the data or condition it represented was never handled. This isn't a volatile or memory-visibility problem; it's a genuine logical race in the check-process-clear sequence, and it gets more likely the faster events arrive relative to how long process() takes. The fix is to stop using a single boolean for what might be multiple pending events: use a counter, incremented in the ISR each time an event fires and decremented in main once per event actually processed, and loop processing while the counter is nonzero. Alternatively, move to a small ring buffer of samples so every event's data is captured individually rather than collapsed into one shared flag, with a short critical section only around the pointer/counter updates rather than around the whole processing call.

    • Diagnoses the overwrite race precisely — Explains that while main is inside process() after seeing the flag true, a new ISR event can set the flag true again, and main's subsequent unconditional clear overwrites that second signal, silently dropping it.
    • Distinguishes from a memory-visibility bug — States this is not fixed by volatile or by memory barriers alone since it is a logical sequencing race, not a stale-read problem.
    • Proposes a counter-based fix — Proposes replacing the boolean flag with an event counter incremented in the ISR and decremented once per processed event in main, so multiple pending events are not collapsed into one.
    • Proposes atomic/queue-based alternative — Alternatively or additionally proposes a short critical section around flag manipulation, or moving to a ring buffer/queue that captures every event's data rather than a single shared flag.

    Where answers go wrong: Assuming the fix is simply making the flag volatile, missing that it's a logical race, not a caching issue. · Only considering the race at the point of reading the flag, not at the point of clearing it after processing. · Proposing to just disable interrupts for the entire process() call, which risks missing or delaying other time-critical interrupts.

  10. 10A board has two interrupt handlers at different priority levels: a high-priority 1kHz timer ISR that updates a 3-field struct of 16-bit sensor axes (x, y, z), and a lower-priority UART ISR that can preempt the main loop but never the timer ISR. The main loop periodically copies the whole struct out to send over UART. Occasionally the transmitted x, y, z values look like they came from different, inconsistent moments in time even though there's no crash and no obvious data corruption otherwise. Walk through why this happens and how you'd fix it, given that disabling interrupts for the entire multi-field copy is not acceptable because it would blow the timer ISR's real-time budget.hard

    The struct is `struct { uint16_t x, y, z; } sensor;` updated field-by-field inside the timer ISR every 1ms, and read field-by-field in the main loop's copy routine.

    Answer this one, graded
    Or just show what a strong answer covers

    This is a torn read: the struct has three separately-updated 16-bit fields, and the timer ISR can preempt the main loop's copy routine between, say, reading x and reading y, so the copy ends up mixing values from two different 1ms updates — nothing crashes because each individual field access is fine, but the combination is inconsistent. The fix isn't to disable interrupts for the whole copy since that costs too much time relative to the 1ms timer budget; instead I'd shrink the critical section to just the few instructions needed to copy the three fields, disabling only the timer interrupt (since the UART interrupt can't touch this data) for that brief window, then re-enabling immediately. An alternative that avoids masking entirely is a double buffer: the timer ISR writes into an inactive buffer and atomically swaps a single pointer or index when done, so the main loop always reads a complete, self-consistent snapshot without ever needing a critical section on the copy itself. A sequence-counter approach — bump a counter before and after the ISR write, and have the reader retry if the counter changed or is odd — is another way to detect and avoid using a torn read without ever masking interrupts.

    • Diagnoses the torn read — Explains that because the struct has multiple independently-updated fields, the timer ISR can fire between the main loop's reads of x, y, and z, producing a mix of old and new values with no single consistent 'moment'.
    • Recognizes why disabling interrupts naively is a problem here — Acknowledges that masking interrupts for the entire copy would delay the 1kHz timer ISR long enough to matter, which is why a smarter approach is needed.
    • Proposes a viable fix — Suggests either a short, tightly-scoped critical section around just the raw copy (a few instructions), a double buffer with an atomically-swapped pointer/index the ISR writes into off-line, or a sequence-counter/seqlock pattern to detect and retry a torn read.
    • Specifies which interrupt must actually be masked — Notes that the critical section must specifically block the timer ISR (the higher-priority one), not just lower-priority interrupts, since only the timer ISR can corrupt this data.

    Where answers go wrong: Assuming each 16-bit field read/write is individually atomic means the whole struct copy is automatically safe, ignoring the multi-field torn read. · Proposing to just add volatile to the struct and stopping there, without addressing atomicity of the multi-field copy. · Suggesting a critical section but forgetting it must mask the specific higher-priority timer interrupt, not just disable the lower-priority UART interrupt. · Proposing to disable interrupts for the entire main loop permanently as a blanket fix, ignoring the stated real-time constraint.

Next topicCommunication Protocols

Interviewing for a specific role?

Paste the job description and we weight firmware fundamentals against everything else the role needs.