Firmware Developer Interview Questions
Firmware Developer Interview Questions and Answers
This page is for engineers preparing for firmware developer interviews: bare-metal and RTOS work on microcontrollers, where you own everything from the reset vector to the update path. The reference platform throughout is Arm Cortex-M, because most interviewers use it, but the ideas carry over to RISC-V, AVR, MSP430 and vendor DSPs, and good answers say where a part behaves differently.
Firmware interviews usually combine a C coding round with bit manipulation, buffers and state machines; a deep technical round on interrupts, memory, peripherals and RTOS behaviour; a debugging or bring-up discussion, often around a real fault you caused and fixed; and for senior roles a design round on boot, update and security. The material here follows that order: questions grouped by topic with the answers a strong candidate gives, ten tested coding exercises from easy to hard, five open-ended scenarios with model walk-throughs, then a mock interview plan, a cheat sheet, red flags and FAQ.
If you are deciding between related roles, compare the Embedded Systems Engineer page, which is broader and includes embedded Linux, and the Device Driver Developer page, which focuses on operating system kernels. Brush up the language itself on the C Roadmap and Modern C++ Roadmap roadmaps.
- Questions
- 61 with model answers
- Coding
- 10 exercises with solutions
- Scenarios
- 5 design and debugging rounds
How to use this page
- Answer each question aloud before reading the model answer, in under two minutes, then compare structure as well as content: direct answer first, then reasoning, then the pitfall.
- Write every coding exercise by hand in a plain editor, compile it with warnings on, and write your own small test harness before looking at the solution.
- For each scenario, sketch a block diagram and a list of failure modes on paper, then read the walk-through and note what you missed.
- Keep the datasheet and reference manual of one microcontroller family you know open while you study, and check every register claim against it.
- Run the mock interview plan at least twice with a friend or a timer, and practise talking through code with the coding practice and AI code review round tools.
C for firmware: the language as the hardware sees it
Every firmware loop starts here: interviewers check that you know what the compiler is allowed to do with your code and how that meets real registers.
What does volatile actually guarantee, and what does it not guarantee?Junior
volatile tells the compiler that every read and write of the object is an observable side effect. It must perform each access, in program order relative to other volatile accesses, and it may not cache the value in a register or delete an access that looks redundant.
It does not make anything atomic. A volatile uint32_t counter incremented in main and in an ISR is still a read-modify-write that can be torn. And it does not order the hardware: on a Cortex-M7 with a write buffer and caches you still need __DSB() or __DMB(), or cache maintenance, before a DMA engine or another bus master sees the data.
Likely follow-up. How would you share a multi-byte timestamp between an ISR and main safely?
Write a macro or inline function to set, clear and toggle bit n of a register, and explain the pitfalls.Junior
I prefer static inline functions over macros because they type-check and evaluate arguments once:
static inline void bit_set(volatile uint32_t *r, unsigned n) { *r |= (1u << n); }
static inline void bit_clear(volatile uint32_t *r, unsigned n) { *r &= ~(1u << n); }
static inline void bit_toggle(volatile uint32_t *r, unsigned n) { *r ^= (1u << n); }The pitfalls are the interesting part. Write 1u, not 1: 1 << 31 shifts into the sign bit of an int, which is undefined behaviour. Shifting by 32 or more is also undefined. Each function is a read-modify-write, so if an ISR touches the same register between the read and the write its change is lost; for GPIO I use the set/reset register (BSRR on STM32) which is write-only and atomic. Some registers have write-one-to-clear status bits, and a read-modify-write on them clears flags you never meant to touch.
Likely follow-up. Which registers in a peripheral you know are write-one-to-clear?
Explain integer promotion with an example that causes a real firmware bug.Mid
Before arithmetic, C converts any type narrower than int to int (or unsigned int if int cannot hold every value). On a 32-bit MCU that means uint8_t and uint16_t become signed int in nearly every expression.
uint8_t cmd = 0x0F;
if (~cmd == 0xF0) { /* never true */ }~cmd is computed on int, so it is 0xFFFFFFF0, not 0xF0. The fix is (uint8_t)~cmd == 0xF0. A second classic is uint16_t a = 0xFFFF; uint32_t x = a * a;: both operands promote to signed int and the product overflows int, which is undefined. Cast one operand to uint32_t first. MISRA C exists partly because of these: its essential type model flags mixing narrow unsigned types with signed int, and I keep -Wconversion on to catch them.
When would you use a packed struct to describe a protocol frame or register block, and what can go wrong?Mid
For register blocks I use a plain struct of volatile uint32_t fields with explicit reserved padding and a static assert on the offsets, because registers are naturally aligned and packing is not needed. For protocol frames I avoid overlaying packed structs on byte buffers and serialise field by field instead.
The problems with __attribute__((packed)) are concrete. Cortex-M0 and M0+ fault on any unaligned halfword or word access. Cortex-M3, M4 and M7 tolerate unaligned single loads and stores to normal memory, but LDM, STM, LDRD and STRD still fault, and unaligned access to device memory is never allowed. Explicit shifts and masks are portable and testable on the host.
Likely follow-up. How do you write a little-endian uint32 into a byte buffer without any undefined behaviour?
Where do these variables live: a global initialised int, a static zero-initialised array, a const table, a local variable and a string literal?Junior
On a typical GCC Cortex-M build: an initialised global goes in .data, which lives in RAM at run time but whose initial values are stored in flash and copied by the startup code. A zero-initialised or uninitialised static array goes in .bss, which is RAM that startup code fills with zeros; it costs no flash. A const table at file scope goes in .rodata in flash and is read in place, which is how you keep large lookup tables out of RAM. A local variable lives on the current stack, or only in a register if the optimiser can manage it. A string literal is in .rodata.
The traps: a const local array inside a function may still be built on the stack each call unless it is also static. I check placement with the map file or arm-none-eabi-size and nm rather than guessing.
Likely follow-up. How would you place a buffer in a specific RAM bank, for example DTCM?
Should firmware use dynamic memory allocation?Mid
In most safety-relevant or long-running firmware I avoid malloc after initialisation. The reasons are fragmentation over weeks of uptime, non-deterministic timing, and the fact that an allocation failure has no good recovery path on a device with no user.
What I use instead: static allocation sized at compile time, fixed-block memory pools where object counts vary (constant-time allocate and free, no fragmentation because every block is the same size), and RTOS objects created with the static APIs, such as xTaskCreateStatic in FreeRTOS. The trade-off is that you must size for the worst case up front, so RAM usage is higher than average need, but it is predictable and testable.
What is MISRA C, and how do you deal with a rule that the code genuinely needs to break?Senior
MISRA C is a set of guidelines for using C in critical systems. It restricts the language to a subset with defined, analysable behaviour. Rules are classed as mandatory, required or advisory, and compliance is checked mostly by static analysis.
When code must break a required rule, for example casting an integer to a pointer to reach a peripheral register, the answer is a documented deviation, not a silent suppression. The deviation records the rule, the reason, why it is safe here and who approved it. Repeated patterns, such as register access, get one project-wide deviation and are confined to a small hardware access layer so the rest of the code stays compliant.
Likely follow-up. Which MISRA rules do you find most often catch real bugs?
Startup code, linker scripts and the memory map
Interviewers ask what happens between power-on and main because it shows whether you can bring up a new board and debug a part that never reaches your code.
Walk me through what a Cortex-M does from reset to the first line of main.Junior
On reset the core reads two words from the vector table, which is at address 0 or wherever the boot pins or option bytes alias it. Word 0 is the initial main stack pointer, loaded into MSP. Word 1 is the address of the reset handler, with bit 0 set to mark Thumb code, and the core starts executing there in Thread mode, privileged, using MSP.
The reset handler is ordinary code, usually in a startup file. It typically calls SystemInit to set up clocks, flash wait states and sometimes the FPU access bits, then copies the .data section from its load address in flash to its run address in RAM, zeroes .bss, runs C++ constructors and init arrays through __libc_init_array, and calls main.
Likely follow-up. What happens if you forget to enable the FPU and the startup code uses a float instruction?
Explain the difference between the load address and the run address of a section, and how the startup code uses it.Mid
Every output section has a virtual memory address, where code expects to find it while running, and a load memory address, where the bytes are stored in the image. For code and constants they are the same: flash. For .data they differ: it must run in RAM because it is writable, but its initial values must survive power-off, so they are stored in flash right after the code.
In a GNU linker script this is > RAM AT > FLASH, and the script exports symbols for the startup code:
.data : {
_sdata = .; *(.data*) _edata = .;
} > RAM AT > FLASH
_sidata = LOADADDR(.data);The reset handler copies from _sidata to _sdata until _edata.
How do you size the stack, and how do you detect a stack overflow on a part with no operating system?Mid
I start with static analysis: GCC can emit per-function stack usage with -fstack-usage, and combining that with the call graph gives a worst case for each thread of execution, plus the interrupt nesting depth multiplied by the largest ISR frame. Then I measure: fill the stack with a known pattern such as 0xDEADBEEF at start-up, run the worst-case test cases, and read the high-water mark. I add a margin, typically 25 percent or more.
For detection at run time I use the hardware where it exists. ARMv8-M cores have MSPLIM and PSPLIM stack limit registers that fault on overflow. On ARMv7-M I set an MPU region at the bottom of the stack as no-access, so an overflow triggers a MemManage fault instead of silently corrupting .bss. An RTOS adds a canary check at context switch.
Likely follow-up. Why might a stack overflow show up as a hard fault with STKERR set?
What is the vector table offset register, and when do you change it?Mid
VTOR, in the System Control Block, holds the base address of the vector table the core uses for exceptions. It exists on Cortex-M3, M4, M7, M33 and others, and is optional on M0+.
I change it in two cases. First, when a bootloader hands over to an application stored at a different address: the application, or the bootloader just before the jump, writes the application vector table address into VTOR so interrupts go to the application handlers. Second, when I copy the table into RAM so handlers can be installed at run time, or to gain speed on a part with slow flash. On a Cortex-M0, which has no VTOR, the usual trick is to copy the application vector table to the start of SRAM and remap SRAM to address 0 with a system configuration register.
Write the steps a bootloader takes to jump to an application.Mid
The order matters, because leftover state from the bootloader is the usual cause of an application that crashes only when started from the bootloader.
void jump_to_app(uint32_t base) {
uint32_t sp = *(const uint32_t *)base;
uint32_t pc = *(const uint32_t *)(base + 4u);
__disable_irq();
SysTick->CTRL = 0; /* stop the tick */
for (int i = 0; i < 8; i++) { /* disable and clear all IRQs */
NVIC->ICER[i] = 0xFFFFFFFFu;
NVIC->ICPR[i] = 0xFFFFFFFFu;
}
SCB->VTOR = base;
__DSB(); __ISB();
__set_MSP(sp);
((void (*)(void))pc)(); /* never returns */
}Before this, I validate the image: sane stack pointer inside RAM, reset vector inside the application region with the Thumb bit set, and a CRC or signature. I also return peripherals and clocks the bootloader used to reset state, or document what state the application inherits. The application must re-enable interrupts itself, since PRIMASK stays set across the jump.
Likely follow-up. Why can the jump work from a debugger but fail from a cold boot?
A board boots from the debugger but not from power-on. What do you check?Senior
This is a classic, and I work through the differences between the two cases. The debugger usually loads the image, sets PC and SP from the ELF entry point and may leave clocks running, so anything the hardware reset path does differently is suspect.
First, boot configuration: boot pins or option bytes may select system memory or SRAM instead of user flash. Second, the vector table: confirm the first word is a valid RAM address and the second a Thumb address, by reading flash back rather than trusting the ELF. Third, power and clocks: a crystal that starts slowly when cold, a PLL that never locks, or a brown-out reset level set too high for the supply ramp. Fourth, code that depends on uninitialised RAM, which the debugger session may have left with friendly values. I add an early GPIO toggle in the reset handler, then scope it, to see how far boot gets.
Likely follow-up. How would you prove whether the reset handler is reached at all?
Interrupts, the NVIC and concurrency
Most field bugs in firmware are races between an interrupt and the code it interrupts, so this section gets the hardest follow-up questions.
What happens in hardware when an interrupt fires on a Cortex-M3 or M4?Junior
The NVIC compares the pending interrupt priority with the current execution priority. If it is higher, the core finishes or abandons the current instruction (multi-cycle loads and stores can be restarted or continued), then pushes eight words onto the current stack: R0 to R3, R12, LR, the return address and xPSR. If the FPU context is active, it also reserves space for S0 to S15 and FPSCR, filled lazily only if the handler uses the FPU. At the same time it fetches the handler address from the vector table.
It then switches to Handler mode, uses MSP, and loads LR with an EXC_RETURN value such as 0xFFFFFFFD, which encodes which stack and mode to return to. Latency is around a dozen cycles with zero-wait-state memory.
Likely follow-up. What is late arrival, and how does it change which handler runs first?
Explain NVIC priority levels, priority grouping and preemption.Mid
Each interrupt has a priority value, and a lower number is more urgent. Only the top bits of the 8-bit field are implemented: with four bits, as on many STM32 parts, valid values are 0x00, 0x10 and so on up to 0xF0, giving 16 levels.
Priority grouping, set in the AIRCR PRIGROUP field, splits those bits into a preemption priority and a sub-priority. Only the preemption part decides whether one handler can interrupt another. The sub-priority only decides which of two pending interrupts with equal preemption priority is taken first. My rule of thumb in design is to give the shortest-deadline and shortest-running handlers the highest priority, keep handlers that call RTOS APIs at or below the RTOS limit, and document the whole table in one header, because a priority change in one driver can silently break another.
How do you protect data shared between an ISR and the main loop?Junior
First I try to design the sharing away: a single-producer, single-consumer ring buffer where the ISR only writes the head and main only writes the tail needs no lock, provided index updates are single aligned word stores and the data is written before the index is published.
When I need a real critical section, for example to update a multi-field struct or a 64-bit counter, I mask interrupts for the shortest possible time and restore the previous state rather than blindly re-enabling:
uint32_t primask = __get_PRIMASK();
__disable_irq();
snapshot = shared; /* copy, do not process, inside */
__set_PRIMASK(primask);On ARMv7-M and later I prefer raising BASEPRI so only the interrupts that touch the data are masked and a high-priority motor control ISR keeps running. In both cases the shared object is volatile or accessed through atomics, so the compiler does not cache it.
Likely follow-up. Why restore PRIMASK rather than call __enable_irq?
Your ISR clears its flag as the last statement, and occasionally runs twice for one event. Why?Mid
This is the write-buffer race. On a Cortex-M3, M4 or M7 the store that clears the peripheral flag goes through the bus and possibly a write buffer, and the peripheral may sit behind a slower bus bridge. The handler returns before the flag is actually cleared, the NVIC still sees the interrupt line asserted, and the handler is entered again with nothing to do.
The fixes are to clear the flag early in the handler rather than last, so the write completes while the rest of the handler runs, or to read the register back after clearing, or to issue a __DSB() before returning, which waits until the write has completed. Reading back is often the safest because it also forces the write through any bridge.
What is the difference between PRIMASK, FAULTMASK and BASEPRI?Mid
All three are special registers that raise the current execution priority to mask exceptions. PRIMASK, set with CPSID i, masks every exception with configurable priority, leaving only NMI and HardFault. FAULTMASK, set with CPSID f, also masks HardFault and is mainly used by fault handlers that need to run as if they were at HardFault priority; it clears automatically on exception return. BASEPRI, available from ARMv7-M, masks only exceptions with priority numerically greater than or equal to its value, so you can block the interrupts that share data with you while letting more urgent ones run.
RTOS kernels on ARMv7-M use BASEPRI for their critical sections. In FreeRTOS that threshold is configMAX_SYSCALL_INTERRUPT_PRIORITY: any ISR that calls a FromISR API must have that priority or a lower urgency, and interrupts above it are never masked by the kernel, so they get minimal latency but cannot use kernel services.
How do you make a read-modify-write atomic without disabling interrupts?Senior
On ARMv7-M and ARMv8-M mainline cores I use the exclusive access instructions, normally through C11 atomics or compiler built-ins rather than hand-written assembly. LDREX loads a value and marks the address in the local monitor. STREX stores only if nothing has cleared the monitor since, and returns a status. An exception entry or return clears the monitor, so if an interrupt arrives between them the store fails and the loop retries.
do {
old = __LDREXW(&counter);
} while (__STREXW(old + 1u, &counter) != 0u);This keeps interrupt latency unaffected. Limits worth naming: ARMv6-M cores such as the M0 and M0+ have no exclusives, so atomics there compile to interrupt masking or library calls. Exclusives only cover one word at a time.
Peripherals and register-level drivers
Expect to explain the wire-level behaviour of the buses you list on your CV, and how your driver copes when the other side misbehaves.
Compare UART, SPI and I2C. When would you choose each?Junior
UART is asynchronous and point-to-point: two data lines, no clock, both ends agree on a baud rate and frame format such as 8N1. Clock mismatch beyond a few percent causes framing errors.
SPI is synchronous, full duplex and driven by one master clock, with a chip select per device. It is fast, often tens of MHz, and has no addressing or acknowledgement overhead, so it suits displays, external flash, high-rate ADCs and radios. The cost is a chip-select pin per device and no built-in error detection.
I2C uses two open-drain lines with pull-ups and shares them between many devices by 7-bit address. It is slower, commonly 100 kHz or 400 kHz, and has acknowledgements, clock stretching and repeated starts. I use it for sensors, EEPROMs and power management ICs where pin count matters more than speed.
An I2C bus is stuck with SDA held low after a reset. What happened and how do you recover?Mid
Most likely the microcontroller reset in the middle of a read transaction. The slave was driving SDA low to send a zero bit or an acknowledgement and is still waiting for clock pulses to finish its byte. The master, freshly reset, sees SDA low, believes the bus is busy and cannot generate a START.
The standard recovery is to take SCL as a GPIO, open-drain, and clock it manually up to nine times, checking SDA after each pulse. Once the slave has shifted out its remaining bits it releases SDA. Then I generate a STOP condition by bringing SDA from low to high while SCL is high, and hand the pins back to the I2C peripheral, resetting the peripheral itself through its RCC reset bit because many I2C blocks latch a busy flag. I run this recovery at every boot and after any timeout.
Likely follow-up. How do you choose pull-up resistor values for a given bus capacitance and speed?
Explain the four SPI modes and how you would find the right one for a new device.Junior
The mode is the combination of clock polarity and clock phase. CPOL sets the idle level of the clock: 0 for idle low, 1 for idle high. CPHA sets which edge samples the data: 0 samples on the first edge after chip select is asserted, 1 samples on the second edge. That gives mode 0 (CPOL 0, CPHA 0), mode 1, mode 2 and mode 3.
To pick the mode I read the timing diagram in the device datasheet, not the summary table, because vendors name things differently. I check the idle clock level and the edge where data must be stable. Then I confirm on a logic analyser with protocol decoding that the first byte read back, usually a WHO_AM_I or JEDEC ID register, has the expected value.
How does CAN arbitration work, and what are the error states?Mid
CAN is a multi-master bus where a dominant bit, logic 0, overrides a recessive bit, logic 1. Every node transmits the identifier bit by bit and reads the bus back. If a node sends recessive but reads dominant, it has lost arbitration and stops transmitting without corrupting the frame, so the lower identifier, which has more leading dominant bits, wins. That makes the identifier also the priority. Standard frames use 11-bit identifiers, extended frames 29 bits.
Each node keeps a transmit error counter and a receive error counter. In error-active state it signals errors with a dominant error flag. If either counter exceeds 127 it becomes error-passive and signals errors only with recessive bits, so it cannot disturb the bus. If the transmit counter exceeds 255 the node goes bus-off and stops transmitting. The firmware must decide how to recover from bus-off: automatic recovery after 128 occurrences of 11 recessive bits, or a controlled restart with logging, which is safer in a vehicle.
Likely follow-up. What sets the sample point, and why does it matter on a long bus?
How do you configure an ADC reading so that the value is accurate, not just repeatable?Mid
Accuracy starts with the reference. The result is a ratio of the input to VREF, so a noisy or drifting reference becomes a noisy reading. I use the internal reference measurement channel or an external reference, and calibrate with the factory calibration values when the vendor provides them.
Next, sample time. The ADC samples onto a small capacitor through the source impedance; if the sampling window is too short for that RC to settle, readings are low and depend on the previous channel. So I set the sample time from the source impedance in the datasheet formula, or buffer a high-impedance source with an op-amp or a capacitor at the pin. Averaging four samples gains about one bit of resolution if the noise is random.
Describe how you would write a DMA-based UART receive driver for messages of unknown length.Senior
I run DMA in circular mode into a ring buffer, so reception never stops and there is no gap between transfers. The DMA only writes; the firmware tracks its own read index. The remaining-count register of the DMA channel tells me where the write position is.
To know when to process, I combine three events. The UART idle-line interrupt fires when the line stays idle for one frame after activity, which marks the end of a burst. The DMA half-transfer and transfer-complete interrupts fire at the midpoint and end of the buffer, so a long burst cannot overwrite unread data before I see it. In each handler I compute the new write position from the remaining count, hand the span between the old and new positions to the parser, handling the wrap, and update the read index. On a Cortex-M7 with data cache, the buffer goes in a non-cacheable MPU region or I invalidate the affected cache lines before reading, with the buffer aligned to 32-byte lines.
Likely follow-up. What breaks if you clean the cache instead of invalidating it after a DMA receive?
When would you use the vendor HAL and when would you write register-level code?Mid
I use the vendor HAL or low-level library for bring-up and for peripherals with complex, rarely touched initialisation, such as clock trees, USB and Ethernet, because the vendor has encoded the errata workarounds and sequencing rules.
I write register-level code for hot paths and timing-critical drivers: an ISR that must run in a few hundred nanoseconds, a motor PWM update, a bit-exact protocol. HAL functions often add callbacks, locks and generic state machines that cost cycles and code size, and some hide behaviour I need to control, such as which flags are cleared.
Whichever I choose, the rest of the application talks to a thin interface I define, such as uart_write or a board support layer, not to the HAL directly. That keeps the application portable across parts and lets me replace the implementation with a fake for host tests.
How do you generate a precise PWM and capture an input frequency with timers?Junior
For PWM, a timer counts from 0 up to the auto-reload value, which sets the period, and a compare channel sets the output high or low when the counter matches the compare value, which sets the duty cycle. With a timer clock of 72 MHz, a prescaler of 0 and an auto-reload of 3599, the period is 3600 ticks, which is 20 kHz, and a compare of 900 gives 25 percent duty. Resolution is the number of ticks per period, so a higher PWM frequency means fewer duty steps. I enable the preload on the compare register so updates take effect at the next period boundary and never produce a glitch.
For frequency measurement, an input capture channel latches the counter value on each rising edge. The difference between two captures, with unsigned subtraction so wrap is handled, gives the period in ticks. For low frequencies I count overflows between edges or use a slower prescaler.
RTOS, scheduling and firmware architecture
Interviewers want to hear when an RTOS earns its cost, how you avoid the classic scheduling bugs, and how you structure firmware so it stays testable.
When would you use a super-loop and when an RTOS?Junior
A super-loop, often with interrupts doing the urgent work and main polling flags or an event queue, is right when the system has a few activities with loose timing, small RAM, and a need for simple, fully deterministic behaviour.
An RTOS earns its place when there are several activities with different deadlines that would otherwise have to be chopped into state machines by hand, when you need blocking I/O with timeouts, or when a networking stack, file system or USB stack expects threads. The scheduler lets a high-priority task meet its deadline regardless of how long the low-priority work takes. The costs are RAM for each task stack, context-switch overhead, and a new class of bugs: priority inversion, deadlock and stack overflow per task. A middle path that I often recommend is a run-to-completion event-driven design with one stack, where each component is a state machine processing events from a queue.
What is priority inversion, and how do RTOS kernels deal with it?Mid
Priority inversion happens when a high-priority task waits for a resource held by a low-priority task, and a medium-priority task that does not need the resource preempts the low one. The high-priority task is now effectively running at the lowest priority, for an unbounded time.
The usual fix is priority inheritance: while a task holds a mutex that a higher-priority task is waiting for, the kernel raises the holder to the waiter priority, so the medium task cannot preempt it. FreeRTOS mutexes and Zephyr mutexes implement inheritance; binary semaphores do not, which is one reason not to use a semaphore as a lock. The stronger alternative is the priority ceiling protocol, where a mutex has a fixed ceiling priority and the holder runs at it immediately, which also prevents some deadlocks.
Likely follow-up. Why can priority inheritance alone still leave you with a deadlock?
What is the difference between a mutex, a binary semaphore and a counting semaphore?Junior
A mutex is for mutual exclusion. It has an owner: only the task that took it may give it back, it can implement priority inheritance, and in some kernels it can be recursive. It must never be taken from an interrupt, because an ISR cannot block and cannot be an owner.
A binary semaphore is for signalling. It has no owner, so one context gives and another takes. The typical use is an ISR giving it to wake a task that processes the event. Using it as a lock works mechanically but loses priority inheritance and lets any task release it by mistake.
A counting semaphore holds a count, so it can track several events that have not yet been handled or a pool of identical resources, such as free DMA descriptors.
How does a Cortex-M RTOS perform a context switch?Senior
On Cortex-M the kernel uses PendSV, an exception set to the lowest priority, so the switch happens only when no other handler is active. The tick interrupt or a kernel call decides a switch is needed and sets the PendSV pending bit. When all other handlers have finished, PendSV runs.
On entry the hardware has already pushed R0 to R3, R12, LR, PC and xPSR onto the task process stack, PSP. The handler pushes the remaining callee-saved registers R4 to R11, plus S16 to S31 if the task used the FPU, which it can tell from bit 4 of EXC_RETURN. It saves the resulting PSP into the current task control block, picks the next task, loads that task PSP, pops its R4 to R11 and returns with the matching EXC_RETURN. The hardware unstacks the rest and the new task continues.
How do you size an RTOS task stack and detect when it is too small?Mid
For each task I take the static worst case from the compiler stack-usage output along that task call graph, add the hardware exception frame (32 bytes, or 104 bytes when the FPU context is stacked), and add library calls that are often surprisingly deep, such as printf-style formatting, which can need a kilobyte or more. Then I add a margin.
At run time, FreeRTOS and Zephyr both fill stacks with a known pattern, and I can query the high-water mark, in FreeRTOS with uxTaskGetStackHighWaterMark. I log those values in a diagnostics command and in test runs that exercise every error path. Better still is hardware: an MPU guard region below each task stack, which Zephyr can configure, or PSPLIM on ARMv8-M, both of which fault on the exact instruction that overflows. That turns a random corruption into a clean, diagnosable fault.
Explain tickless idle and what it changes for timing code.Mid
In a normal RTOS the tick timer interrupts at a fixed rate, often 1 kHz, even when every task is blocked. That wakes the core a thousand times a second and wastes energy. Tickless idle stops the periodic tick when the idle task runs: the kernel finds the next timeout, programs a low-power timer to wake the core then, and enters a sleep mode. When any interrupt or that timer wakes the core, the kernel reads how much time actually passed and advances the tick count in one step.
For application code this mostly works without change, but there are consequences. The low-power timer often has a coarser resolution, so very short delays may round up. Wake-up from deep sleep adds latency because clocks and the PLL must restart.
How do you structure firmware so that most of it can be tested on a PC?Senior
I separate the code into layers with dependencies pointing one way. At the bottom is a thin hardware access layer that touches registers and nothing else. Above it are drivers with a small interface, such as spi_transfer or flash_write. Above those are device logic, protocols and application state machines, which never include a vendor header and talk to drivers only through those interfaces.
Everything above the bottom layer then compiles for the host. In tests I link fakes or mocks of the driver interfaces: a fake flash in a RAM array with real NOR erase semantics, a fake clock I can advance, a fake UART that records bytes. Time is injected, never read from a global tick, so timeout logic is testable in microseconds. The hardware access layer stays small enough to review line by line and is covered by hardware-in-the-loop tests.
Likely follow-up. Where do you draw the line between a fake and a mock?
Bootloaders, OTA updates, secure boot and flash
A device that cannot be updated safely is a recall waiting to happen, so senior rounds spend a lot of time on update paths, power loss and the chain of trust.
What are the main OTA update layouts, and what are their trade-offs?Mid
The first is A/B, or dual-bank: two full application slots. The new image is written into the inactive slot while the old one keeps running, then the bootloader switches to it. It costs twice the application flash, and on parts with true dual-bank flash you can even keep executing from one bank while erasing the other.
The second is primary plus staging with swap: the application always runs from a fixed primary slot, the update is downloaded into a secondary slot, and the bootloader swaps the two sector by sector using a scratch area, recording progress so a power cut can resume. Its advantage is that the application is linked for one fixed address. The costs are the time a swap takes and the extra flash wear.
The third is overwrite-only, where the bootloader copies the staged image over the primary with no copy of the old one. It is simple and small, but a bad image cannot be rolled back, so it relies on the bootloader itself being able to receive a new image.
How do you make sure a device survives power loss at any moment during an update?Senior
I design it as a sequence of steps where each step is either complete or invisible, and the bootloader can always tell which. The running image is never modified until a complete, verified replacement exists. The new image is written to the inactive slot, then verified by hash or signature from flash, not from the download buffer. Only then is a single small piece of state changed to say that slot is pending, and that write is itself power-safe: a record with a sequence number and a CRC, written to a fresh location, so a torn write is detected and ignored.
If a swap is involved, the bootloader writes a progress record after each sector so it can resume after a power cut, and it never erases a sector whose content does not exist in a second place. I test this by cutting power with a relay controlled by the test rig at random points, thousands of times, and checking the device always boots into one of the two images.
Likely follow-up. How does the bootloader decide whether to keep the new image after the first boot?
What does secure boot mean on a microcontroller, and what does the chain of trust look like?Senior
Secure boot means the device only runs code that the manufacturer signed. The chain starts with something immutable: boot ROM code or a first-stage bootloader in write-protected flash, plus a public key, or the hash of one, stored in one-time-programmable memory or locked flash. That root verifies the signature of the next stage before jumping to it, and each stage verifies the next.
Verification means hashing the image, usually SHA-256, and checking an ECDSA P-256 or Ed25519 signature over the hash and a signed header that includes the version and size. To prevent attackers from installing an old signed image with a known flaw, the header carries a security version that is compared with a monotonic counter in OTP or fuses. Secure boot is worthless if an attacker can simply attach a debugger and rewrite flash, so production parts also lock the debug port and enable readout protection.
Why can you not simply write one byte of internal flash the way you write RAM?Junior
NOR flash can only change bits from 1 to 0 by programming. Changing any bit back to 1 needs an erase, and erase works only on a whole sector or page, from a few hundred bytes to 128 kilobytes depending on the part. So updating one byte in place means reading the whole sector, erasing it and programming it all back, which is slow (milliseconds to seconds), wears the flash, and loses everything in the sector if power fails in the middle.
There are further constraints. While a bank is being programmed or erased, reads from it stall, so code running from that bank freezes, including interrupt handlers. And each sector has a limited number of erase cycles, often around ten thousand for internal MCU flash, so frequent updates need wear levelling.
How would you store settings that change often in internal flash without wearing it out?Mid
I use a log-structured store instead of a fixed location. Each update appends a new record, key plus value plus a check field, to the next free location in an active sector, and a read scans for the newest valid record of that key. Erases happen only when the active sector is full: the newest value of every key is copied into a second, freshly erased sector, the new sector is marked active with a higher sequence number written last, and the old one is erased later. That spreads wear across both sectors and turns one erase per write into one erase per hundreds of writes.
Power safety comes from ordering. A record is written data first and its commit marker last, so a torn record is detected and skipped, and the sector header is the single switch-over point during compaction. If the write rate is too high I batch updates in RAM or move to FRAM or a real EEPROM.
How do you protect firmware and secrets in the field from someone with physical access?Senior
I start by deciding what actually needs protecting, because physical attackers with enough time and equipment will win against most microcontrollers. Typical assets are device identity keys, the firmware itself as intellectual property and the integrity of the boot chain.
The baseline is to disable or lock the debug port in production, using the readout protection level or the debug authentication feature of the part, and to understand exactly what each level permits, since some levels can be reversed with a mass erase and some are permanent. Secrets go in protected storage: a secure element, the TrustZone secure side, or key storage that is not readable by the CPU at all, and per-device keys are used so that extracting one device does not compromise the fleet. Secure boot stops modified images, and encrypted update packages protect the firmware in transit.
What goes into a firmware image header, and why each field?Mid
A typical header has a magic number so the bootloader can tell a header from erased flash or random data; a header version so the format can evolve; the image size and load address, so the bootloader knows what to hash and where it should run; a semantic version for the product and a separate security counter for anti-rollback; a hash of the image and a signature over the header and hash; and flags such as whether the image is encrypted or compressed.
Two design points matter. Every field that affects a decision must be covered by the signature, otherwise an attacker can change the size or the security counter. And the header is parsed defensively: the size is checked against the slot size before hashing, so a corrupted length cannot read past the end of flash, and unknown header versions are rejected rather than guessed.
Low power, watchdogs and fault handling
These questions test whether your devices last years on a battery and recover on their own when something goes wrong in the field.
Describe the low-power modes of a typical Cortex-M microcontroller and how you choose between them.Junior
At the core level there are two: sleep, entered with WFI or WFE, which stops the CPU clock while peripherals keep running, and deep sleep, selected by setting SLEEPDEEP in the System Control Register before WFI, where the vendor turns off most clocks. Vendors then define their own modes on top. On STM32 parts, for example, Stop keeps RAM and registers but halts most clocks and the main regulator, and Standby or Shutdown power down almost everything, so the part effectively resets on wake, keeping only backup registers and a few wake pins.
I choose by the wake sources and wake-up time I need. If the device sleeps for minutes between measurements, Standby with an RTC alarm is far better, as long as the reboot cost and lost RAM are acceptable.
Your battery device draws 400 microamps in sleep instead of the 5 the datasheet promises. How do you find the leak?Mid
I measure first with a tool that can see the profile, such as a power analyser or a source meter, not a multimeter alone, because sleep current with short wake-up spikes averages misleadingly. Then I divide and conquer. I check whether the MCU actually enters the expected mode: a debugger connection often keeps clocks running, so I measure with the probe disconnected, and I check that no pending interrupt or SysTick prevents sleep.
Then the usual suspects. GPIO configuration: floating inputs draw current as they hover around the switching threshold, so unused pins go to analog mode, and outputs must not drive against pull-ups or into unpowered peripherals. External parts: a sensor not put in its own sleep mode, a regulator with high quiescent current, an LED, pull-ups on an I2C bus held low.
Likely follow-up. How would you estimate the battery life once sleep current is fixed?
How should a watchdog be serviced in a system with several tasks?Mid
Not from a timer interrupt and not from the idle task alone, because both keep running when the application has deadlocked, so the watchdog never fires when it should. The aim is that the watchdog is fed only when every important activity has proved it is making progress.
The pattern I use is a supervisor. Each monitored task sets its own bit in a health word, or increments its own check-in counter, at a point in its loop that is only reached when it has done real work. A supervisor, usually a low-priority task, checks periodically that every expected bit is set within its own allowed period, clears them, and only then kicks the hardware watchdog. If a check fails, the supervisor logs which task failed into a reset-surviving RAM area before letting the watchdog bite.
A HardFault happens in the field once a week. How do you get enough information to fix it?Mid
I make the device record its own crash, because I will never catch it with a debugger. The HardFault handler, written in a small assembly wrapper, checks bit 2 of EXC_RETURN to find whether the fault frame is on MSP or PSP and passes that pointer to a C function. The C function copies the stacked PC, LR, xPSR and registers, plus CFSR, HFSR, MMFAR and BFAR, the current task name if an RTOS is running, and a little of the stack, into a no-init RAM region or a reserved flash area, then resets.
On the next boot, the firmware reports that record through its normal telemetry. With the stacked PC and the exact firmware build, I find the faulting instruction with addr2line or the map file, and the LR tells me the caller. CFSR says what kind of fault it was: precise bus error with a valid BFAR gives the exact bad address, an undefined instruction or INVSTATE points to a corrupted function pointer, and a stacking error points to stack overflow.
Likely follow-up. What does an IMPRECISERR bus fault tell you, and how do you make it precise for debugging?
How do you configure clocks safely on a part with a PLL?Junior
The order matters because the core is running from the clocks you are changing. I start from the internal oscillator, enable the external crystal if one is used and wait for its ready flag with a timeout, then configure the PLL multiplier and dividers while it is off, enable it and wait for lock. Before switching the system clock to the faster source, I raise the flash wait states and, where needed, the voltage scaling level, since flash cannot be read at the new speed with the old settings. I also set the bus prescalers so each bus stays within its maximum frequency. Then I switch the clock source and confirm it with the status bits.
For safety I enable the clock security system if the part has one, which switches back to the internal oscillator if the crystal fails, and I handle the crystal timeout with a fallback rather than looping forever.
What is brown-out detection, and why does it matter to firmware?Mid
A brown-out is a supply voltage that sags below the level at which the chip is guaranteed to work but not low enough for a clean power-on reset. In that region the CPU can execute instructions wrongly, read flash incorrectly or write garbage, which is the most dangerous state for a device because it can corrupt stored data. The brown-out detector, or BOR, holds the part in reset while the supply is below a configured threshold.
Firmware matters here in three ways. I set the threshold, often in option bytes, high enough that flash programming and the clock speed in use are within specification, which can mean a higher threshold than the default. A programmable voltage detector that interrupts just above the reset threshold gives a little time to finish or abandon a flash write and save state. And I design every flash write to be power-safe anyway, because the detector reduces risk but cannot guarantee that a write completes.
How do you use the MPU in a bare-metal or RTOS system?Senior
The memory protection unit lets me assign access permissions and memory attributes to a small number of regions, typically 8 or 16. I use it for fault containment rather than full process isolation. Common settings: make the first few hundred bytes of the address space no-access so null pointer dereferences fault instead of reading flash; mark RAM as execute-never so a corrupted function pointer into data faults immediately; put a small no-access guard region below each stack; and make flash read-only for unprivileged code.
On a Cortex-M7 the MPU also sets the cache policy, so DMA buffers go in a region marked non-cacheable or shareable. With an RTOS that supports it, such as Zephyr user mode or the FreeRTOS MPU port, unprivileged tasks get access only to their own stack and the data they need.
Debugging, bring-up and testing
Panels want proof that you can bring up a board you have never seen and that your firmware is tested before it ships, not after it fails.
What is the difference between JTAG and SWD, and what can a debug probe do on a Cortex-M?Junior
JTAG is the older IEEE standard interface using four or five signals: TCK, TMS, TDI, TDO and optionally TRST. It supports daisy-chaining several devices and boundary scan for board testing. SWD is the Arm two-wire alternative, SWDIO and SWCLK, which gives the same debug access to a Cortex-M with fewer pins. SWO, an optional third pin, carries trace output.
Through the debug access port, a probe can halt and step the core, set hardware breakpoints (a small number, often 4 to 8) and data watchpoints, read and write memory and registers while the core is running, and program flash. The ITM sends printf-style messages and timestamps over SWO with little overhead, and the DWT gives the cycle counter and watchpoints that can trigger on a write to a specific address, which is how I find who corrupts a variable.
How do you bring up a brand new board for the first time?Mid
I go from power outwards and change one thing at a time. Before power, I read the schematic for the supply tree, reset circuit, boot pins, crystal and debug connector, and check the board visually. Then I power it from a current-limited bench supply and check each rail with a meter and the current against expectations, since a short shows up immediately.
Next, the debug connection: if the probe cannot see the core, I check reset, the debug pins, supply to the MCU and the boot mode before suspecting the chip. Then minimal firmware running from the internal oscillator: toggle a GPIO and check it on the scope, which proves the core runs and gives a timing reference. Then the crystal and PLL, verified by outputting the clock on an MCO pin or measuring a timer-generated square wave. Then a UART console, because everything after that is easier with logs. Then each peripheral and external device, starting with something with an ID register I can read back.
Likely follow-up. The probe cannot connect at all. What is your checklist?
How do you debug a timing problem that disappears when you add a breakpoint or printf?Mid
That is a classic Heisenbug, and it tells me the problem is probably a race or a timing margin, because the observation changes the timing. So I switch to observation methods with very low cost. Toggling GPIO pins at points of interest and watching them on a logic analyser together with the bus signals costs one store instruction each. The DWT cycle counter lets me record timestamps into a RAM array and dump it afterwards. ITM over SWO or a RAM-based logging scheme such as Segger RTT gives text output for a few cycles per message, compared with milliseconds for a blocking UART printf.
I also use data watchpoints, which do not alter timing until they trigger, to catch the code that writes a corrupted variable. Then I form a hypothesis about the race, such as a missing critical section or an unprotected read-modify-write, and confirm it by making the window bigger on purpose, for example adding a delay inside the suspect region.
How do you unit test a peripheral driver on the host?Mid
I make the register block injectable. Instead of the driver using a fixed address macro, it receives a pointer to a register struct, or the base address comes from one place that the test build overrides. In the test, that pointer points at a plain struct in RAM. The test sets up register values, calls the driver function and then asserts on what the driver wrote: the right bits in the control register, the right order of writes.
Registers with side effects, such as a status flag that clears on read or a data register that pops a FIFO, need a little more: I wrap register access in tiny inline functions so a test can substitute a fake that models the behaviour, or I use a mock framework such as CMock with Unity to set expectations on each access. I focus tests on the logic that actually breaks: timeouts, error flags, retry paths, buffer wrap, and what happens when the peripheral reports an error halfway through a transfer.
What does a good hardware-in-the-loop test setup for firmware look like?Senior
A HIL rig runs the real firmware on the real target hardware, with its inputs and outputs connected to test equipment controlled by a host PC. Typical parts are a debug probe for flashing and reading state, a programmable power supply or relay to control power and cut it at chosen moments, a measurement instrument for current, and simulated sensors or buses: a USB-to-CAN adapter, DACs that drive analogue inputs, GPIO lines to press buttons and loopbacks on serial ports.
Tests are written in a scripting language on the host and run in CI on every merge, using the same binary that would ship. What I want them to cover: flashing and boot, each external interface end to end, the update path including power cuts during download and swap, watchdog and fault recovery, low-power current within limits, and long soak tests overnight.
How do you reduce firmware code size when the image no longer fits?Mid
I measure before changing anything. The map file and a size tool sorted by symbol show where the bytes are, and the answer is often a surprise: the C library printf and floating-point support, a big lookup table, or a vendor HAL function that pulls in every feature of a peripheral.
Then the main levers, roughly in order of payoff. Build options: -Os or -Oz, link-time optimisation, and -ffunction-sections -fdata-sections with --gc-sections so unused functions are dropped. Library choices: newlib-nano or a small printf implementation, no floating-point formatting unless needed, and checking that a stray double operation has not dragged in the soft-float library. Code choices: replacing generic HAL calls with direct register code for hot drivers, moving constant tables to compact formats, and removing debug strings from release builds or replacing them with numeric log IDs decoded on the host.
What do you look for on a scope or logic analyser when a serial link is unreliable?Junior
With a logic analyser I first decode the protocol to see whether the bytes are wrong, missing or just late. Missing bytes on UART usually point to firmware overruns, not the wire. Wrong bytes with framing errors point to a baud rate mismatch, so I measure the actual bit width of a known character and compare it with the expected value; a few percent off means a clock configuration error or an inaccurate internal oscillator.
For electrical problems I switch to the scope, because a logic analyser hides analog detail. On I2C I look at rise times, which show whether the pull-ups are too weak for the bus capacitance, and at whether low levels go close enough to ground. I check that signals are referenced to the same ground and that the probe ground lead is short, since a long lead itself creates ringing.
Fixed-point maths, data integrity and protocols
Firmware turns raw counts into physical values and bytes into messages, so interviewers check your numerical care and your framing and integrity choices.
What is Q15 or Q16.16 fixed-point, and when do you use it instead of floating point?Junior
Fixed-point stores a fractional number as an integer with an implied binary point. Q15 uses a signed 16-bit integer where the value is the integer divided by 32768, so it covers -1 to just under 1 with a step of about 0.00003. Addition is plain integer addition; multiplication needs a wider intermediate and a shift back, for Q15 a 32-bit product shifted right by 15.
I use it on cores without a floating-point unit, such as Cortex-M0+ parts, where software floating point costs tens to hundreds of cycles per operation, and in DSP code where the core has saturating and SIMD instructions for Q15 and Q31, as on the M4 and M33 with the DSP extension. On a Cortex-M4F or M7 with an FPU, single-precision float is often as fast and much less error-prone, so I use float there unless I need double, which is not supported in hardware on the M4F, or exact reproducibility.
Convert a 12-bit ADC reading to millivolts and to a temperature without floating point.Mid
With a 3300 mV reference, millivolts are raw * 3300 / 4095 or, as many people prefer, / 4096 depending on the converter transfer function in the datasheet. I multiply first and divide second to keep precision, and check the product fits: 4095 times 3300 is about 13.5 million, comfortably within 32 bits.
For temperature from a linear sensor, say 500 mV at 0 degrees and 10 mV per degree, I work in tenths of a degree to keep one decimal. Each millivolt is then exactly a tenth of a degree, so temp_d10 = mv - 500. For a slope that is not a round number, such as 19.5 mV per degree with 400 mV at zero, I scale with integers: temp_d10 = (mv - 400) * 100 / 195. For a non-linear sensor such as an NTC thermistor I use a lookup table of raw ADC values at fixed temperatures, generated offline from the sensor equation, and linearly interpolate between entries with integer arithmetic. That is fast, uses a few hundred bytes of flash, and the accuracy is set by the table spacing.
What is saturation arithmetic, and why does control and DSP firmware use it?Mid
Saturation clamps a result to the largest or smallest representable value instead of wrapping around. In normal two complement arithmetic, adding 1 to 32767 in 16 bits wraps to -32768, so a control output that should be at full positive suddenly becomes full negative. For a motor, an audio sample or a heater that is a violent, dangerous error. Saturation turns the same overflow into a small, bounded error: the output sticks at its limit.
Cortex-M4, M7 and M33 cores with the DSP extension have saturating instructions such as QADD, QSUB and SSAT, which CMSIS exposes as intrinsics, so it costs nothing extra. In portable C I compute in a wider type and clamp. In a PID controller I also apply anti-windup: when the output is saturated I stop integrating, or clamp the integrator, otherwise the integral term keeps growing while the actuator is at its limit and causes a large overshoot once the error reverses.
Checksum, CRC or cryptographic hash: how do you choose for a given job?Mid
A simple additive checksum is cheap but weak: it misses reordered bytes and many multi-bit errors. I use it only where an existing protocol requires it. A CRC is designed for random transmission and storage errors: a well-chosen CRC-16 or CRC-32 detects all single-bit and double-bit errors, all odd numbers of bit errors for many polynomials, and all burst errors up to the CRC width, within a defined message length. It is fast with a table or a hardware CRC unit, so it is the default for frames on UART, SPI and radio links, and for records in flash.
A CRC protects against accidents, not attackers. Anyone can modify data and recompute the CRC. For firmware images and anything where tampering matters, I use a cryptographic hash, typically SHA-256, combined with a signature or a message authentication code such as HMAC or AES-CMAC with a device key, so the receiver can tell the data came from someone holding the key.
How do you handle endianness when firmware exchanges data with other systems?Junior
Endianness is the order in which a multi-byte value is stored in memory. Cortex-M parts are almost always configured little-endian, so the least significant byte is at the lowest address. Many network protocols and some sensors use big-endian, which is also called network byte order.
I never rely on casting a byte pointer to a wider type, because that ties the code to the host byte order and can create unaligned accesses. Instead I write small helper functions that build the value with shifts:
static inline uint32_t get_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];
}This works on any CPU regardless of byte order and alignment, and compilers recognise the pattern and emit a single load plus a REV byte-reverse instruction where it is legal.
Why does timer code often compare ticks with (int32_t)(now - deadline) >= 0?Mid
Because tick counters wrap. A 32-bit millisecond counter wraps after about 49.7 days, which is exactly the kind of bug that passes every lab test and fails in the field. The naive check now >= deadline is wrong whenever the deadline was computed just before a wrap and now has wrapped past zero: now is a small number and the comparison says the deadline has not been reached for another 49 days.
Unsigned subtraction in C is defined to wrap modulo 2 to the power 32, so now - deadline gives the true distance between the two ticks whenever they are less than half the range apart. Casting that difference to a signed 32-bit integer and testing the sign tells me whether now is before or after the deadline. The conversion of an out-of-range unsigned value to a signed type is implementation-defined rather than undefined, and every compiler used for Cortex-M gives the two complement result, but some teams prefer to compare the unsigned difference with half the range to stay fully portable.
Likely follow-up. How would you test this logic without waiting 49 days?
Coding exercises
Set a timer for 30 minutes per exercise. Write the code in an editor, compile it, and test the edge cases before you open the solution.
- Read-modify-write a register bit fieldEasy · C
Write
reg_write_field(reg, pos, width, value)that sets a bit field of a 32-bit memory-mapped register without disturbing the other bits, andreg_read_fieldto read one back. Reject invalid positions and widths, and reject a value that does not fit in the field rather than silently truncating it. The register is volatile, and the function should perform exactly one read and one write.Hints
- Build the mask from the width first, and handle width 32 separately because shifting by 32 is undefined.
- Check whether the shifted value has any bits outside the mask.
Solution and explanation
#include <stdint.h> /* Mask of 'width' ones starting at bit 'pos'. width is 1..32. */ static inline uint32_t field_mask(unsigned pos, unsigned width) { uint32_t ones = (width >= 32u) ? 0xFFFFFFFFu : ((1u << width) - 1u); return ones << pos; } /* Read-modify-write one bit field of a memory-mapped register. Returns 0 on success, -1 if the arguments are invalid. */ int reg_write_field(volatile uint32_t *reg, unsigned pos, unsigned width, uint32_t value) { if (width == 0u || width > 32u || pos > 31u || pos + width > 32u) return -1; uint32_t mask = field_mask(pos, width); if ((value << pos) & ~mask) /* value does not fit in the field */ return -1; if (width == 32u) { /* whole register: plain write */ *reg = value; return 0; } uint32_t v = *reg; /* exactly one read ... */ v = (v & ~mask) | ((value << pos) & mask); *reg = v; /* ... and exactly one write */ return 0; } uint32_t reg_read_field(const volatile uint32_t *reg, unsigned pos, unsigned width) { return (*reg & field_mask(pos, width)) >> pos; }The mask is width ones shifted to the field position. Width 32 is a special case because
1u << 32is undefined behaviour in C, and interviewers often check that you know it. The function validates arguments first, then checks that the value fits; silently truncating a too-large value is a classic source of misconfigured peripherals.The body reads the register once into a local, modifies the local and writes it once. That matters because each access to a volatile object is observable: writing
*reg &= ~mask; *reg |= value;creates an intermediate state in the hardware where the field is zero, which for a clock divider or a mode field can briefly put the peripheral into a wrong mode. Follow-ups to expect: this is still not atomic against an interrupt that writes the same register, so either mask interrupts around it or use hardware set and clear registers; and some registers contain write-one-to-clear flags, where any read-modify-write is dangerous. - Debounce a push buttonEasy · C
A push button is sampled every millisecond from a timer tick. The contact bounces for up to about 10 ms on press and release. Write a debouncer that produces a clean state and a single press event per physical press. It must use no delays, no dynamic memory and constant time per tick, and it should work for several buttons by having one state struct each.
Hints
- Think of a counter that integrates the raw input and saturates at both ends.
- Change the output state only at the ends of the counter range, which gives hysteresis.
Solution and explanation
#include <stdint.h> #include <stdbool.h> #define DEBOUNCE_MS 20u /* input must be stable this long */ typedef struct { uint8_t count; /* integrator, 0..DEBOUNCE_MS */ bool state; /* debounced output */ bool pressed_evt; /* set on a clean released->pressed edge */ } debounce_t; /* Call every 1 ms from a timer tick with the raw pin level (true = pressed). Integrator: count up while pressed, down while released, and only change state at the end stops. */ void debounce_tick(debounce_t *d, bool raw) { if (raw) { if (d->count < DEBOUNCE_MS) d->count++; } else if (d->count > 0u) { d->count--; } if (d->count == DEBOUNCE_MS && !d->state) { d->state = true; d->pressed_evt = true; } else if (d->count == 0u && d->state) { d->state = false; } } /* Consume the press event; returns true once per press. */ bool debounce_take_press(debounce_t *d) { bool e = d->pressed_evt; d->pressed_evt = false; return e; }This is an integrator debouncer. The counter moves up by one for each pressed sample and down by one for each released sample, saturating at 0 and at the threshold. The output changes to pressed only when the counter reaches the top and to released only when it reaches the bottom, so bounces in the middle move the counter back and forth without changing the state. That built-in hysteresis is what makes it reject noise.
The press event is a latched flag set on the clean released-to-pressed edge and consumed by the application, so a slow main loop never misses a press and never sees two. Memory is two bytes and a flag per button, and each tick is constant time. Interviewers sometimes ask about using an edge interrupt instead; the answer is that edge interrupts on a bouncing contact fire many times, so you either disable the interrupt and confirm with a timer, or just sample as here.
- CRC-16/CCITT for frame checkingEasy · C
Implement CRC-16/CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR) for a byte buffer. The function must support streaming, so a frame can be checked in several chunks as it arrives. Then add a table-driven version and say when you would choose each. The check value for the ASCII string "123456789" is 0x29B1.
Hints
- Feed each byte into the top of the 16-bit register, then shift eight times.
- The table entry for a byte is the CRC of that single byte with an initial value of zero.
Solution and explanation
#include <stdint.h> #include <stddef.h> /* CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final XOR. Check value for "123456789" is 0x29B1. */ uint16_t crc16_ccitt(uint16_t crc, const uint8_t *data, size_t len) { while (len--) { crc ^= (uint16_t)((unsigned)*data++ << 8); for (int bit = 0; bit < 8; bit++) { if (crc & 0x8000u) crc = (uint16_t)(((unsigned)crc << 1) ^ 0x1021u); else crc = (uint16_t)((unsigned)crc << 1); } } return crc; } /* Table-driven variant: 512 bytes of flash, one lookup per byte. */ static uint16_t crc_table[256]; void crc16_table_init(void) { for (unsigned i = 0; i < 256u; i++) { uint8_t b = (uint8_t)i; crc_table[i] = crc16_ccitt(0, &b, 1); } } uint16_t crc16_ccitt_fast(uint16_t crc, const uint8_t *data, size_t len) { while (len--) crc = (uint16_t)(((unsigned)crc << 8) ^ crc_table[(uint8_t)((crc >> 8) ^ *data++)]); return crc; }The bitwise version XORs each byte into the high byte of the CRC register, then for each of eight bits shifts left and XORs the polynomial whenever a 1 falls out of the top. Streaming works because the running CRC is the only state: pass the previous return value as the starting
crc.The table version precomputes the effect of all eight shifts for each possible top byte, so each input byte costs one lookup, one shift and two XORs instead of a loop of eight. It costs 512 bytes, which can live in flash if generated offline as a const array. I choose bitwise when flash is very tight or throughput is low, the table for bulk data, and the hardware CRC unit when the part has one and it supports the right polynomial. Interviewers listen for knowing that "CRC-16" is not one algorithm: initial value, reflection and final XOR all vary, so the check value for "123456789" is how you confirm you match the other end.
- Lock-free UART receive ring bufferMedium · C
Write a single-producer, single-consumer byte ring buffer for UART reception. The receive interrupt calls
rb_put; the main loop callsrb_get. Neither side may disable interrupts or block. When the buffer is full, the ISR drops the byte and counts an overrun. Use the whole buffer capacity, not capacity minus one, and explain why your code is safe on a Cortex-M.Hints
- Use free-running 32-bit head and tail counters and mask them only when indexing.
- The producer must make the byte visible before it publishes the new head.
Solution and explanation
#include <stdint.h> #include <stdbool.h> #include <stdatomic.h> #define RB_SIZE 256u /* must be a power of two */ _Static_assert((RB_SIZE & (RB_SIZE - 1u)) == 0u, "RB_SIZE power of two"); typedef struct { uint8_t buf[RB_SIZE]; _Atomic uint32_t head; /* written only by producer (ISR) */ _Atomic uint32_t tail; /* written only by consumer (main) */ uint32_t overruns; /* producer-only statistic */ } ringbuf_t; /* Producer side: called from the UART RX interrupt. Never blocks. */ bool rb_put(ringbuf_t *rb, uint8_t byte) { uint32_t head = atomic_load_explicit(&rb->head, memory_order_relaxed); uint32_t tail = atomic_load_explicit(&rb->tail, memory_order_acquire); if (head - tail == RB_SIZE) { /* full: drop and count */ rb->overruns++; return false; } rb->buf[head & (RB_SIZE - 1u)] = byte; /* release: the byte is visible before the new head */ atomic_store_explicit(&rb->head, head + 1u, memory_order_release); return true; } /* Consumer side: called from the main loop or a task. */ bool rb_get(ringbuf_t *rb, uint8_t *out) { uint32_t tail = atomic_load_explicit(&rb->tail, memory_order_relaxed); uint32_t head = atomic_load_explicit(&rb->head, memory_order_acquire); if (head == tail) return false; /* empty */ *out = rb->buf[tail & (RB_SIZE - 1u)]; atomic_store_explicit(&rb->tail, tail + 1u, memory_order_release); return true; } uint32_t rb_count(ringbuf_t *rb) { return atomic_load_explicit(&rb->head, memory_order_acquire) - atomic_load_explicit(&rb->tail, memory_order_acquire); }Head and tail are free-running counters, and the slot index is the counter masked by size minus one, which is why the size must be a power of two. The number of bytes stored is
head - tailin unsigned arithmetic, which stays correct across 32-bit wrap, and that lets the buffer use every slot: full is a difference equal to the size, empty is a difference of zero, with no wasted slot.Safety comes from ownership: only the ISR writes head and only main writes tail, so there is no read-modify-write race on either. The ordering is the subtle part. The producer stores the byte, then publishes head with release ordering; the consumer reads head with acquire ordering, then reads the byte. On a single-core Cortex-M the hardware already keeps program order for normal memory, so the important effect is on the compiler, which otherwise may reorder the plain buffer store after the index store. C11 atomics express this portably and compile to plain loads and stores, plus DMB barriers where needed. Overruns are counted, not hidden, so you can see the buffer is too small or the consumer too slow.
- Q15 arithmetic and a fixed-point low-pass filterMedium · C
On a Cortex-M0+ with no FPU, implement saturating Q15 addition and rounded Q15 multiplication, then an exponential moving average filter
y += alpha * (x - y)in fixed point. The filter must reach the input value exactly for a constant input even when alpha is very small, and must not overflow for any Q15 input, including a step from -1 to just under 1.Hints
- The only Q15 product that overflows is -1 multiplied by -1.
- Keep the filter state with more fractional bits than the output, or small steps round to zero and the output sticks.
Solution and explanation
#include <stdint.h> typedef int16_t q15_t; /* value = raw / 32768, range [-1, 1) */ static inline q15_t sat_q15(int32_t x) { if (x > INT16_MAX) return INT16_MAX; if (x < INT16_MIN) return INT16_MIN; return (q15_t)x; } /* Saturating Q15 add. */ q15_t q15_add(q15_t a, q15_t b) { return sat_q15((int32_t)a + (int32_t)b); } /* Q15 x Q15 -> Q15 with round-to-nearest. Only -1 * -1 overflows. */ q15_t q15_mul(q15_t a, q15_t b) { int32_t p = (int32_t)a * (int32_t)b; /* Q30 */ p += 1 << 14; /* round half up */ return sat_q15(p >> 15); /* arithmetic shift on gcc/clang */ } /* First-order IIR low-pass (exponential moving average): y += alpha * (x - y). The state is kept in Q31-ish 32 bits so small steps are not lost to truncation when alpha is tiny. */ typedef struct { int32_t acc; /* y in Q15 << 16 */ q15_t alpha; /* 0 < alpha < 1 in Q15 */ } ema_t; q15_t ema_step(ema_t *f, q15_t x) { int32_t y = f->acc >> 16; /* current output, Q15 */ int32_t err = (int32_t)x - y; /* fits in 17 bits */ /* err * alpha is Q30; * 2 gives the Q15 << 16 scale of acc. Do it in 64 bits: with alpha near 1 the step exceeds int32, but the new value lies between y and x, so it fits again. */ int64_t next = (int64_t)f->acc + (int64_t)err * f->alpha * 2; f->acc = (int32_t)next; return sat_q15(f->acc >> 16); }Addition widens to 32 bits and clamps, so overflow sticks at the limit instead of wrapping to the opposite sign. Multiplication forms the 32-bit Q30 product, adds half of the last bit for round-to-nearest, shifts right by 15 and saturates; the only overflowing case is -1 times -1, which would give +1, one step outside the range.
The filter is where candidates usually go wrong. If the state is kept in Q15, then with a small alpha the term
alpha * (x - y)rounds to zero once the error is small, and the output stops short of the input forever. Keeping the state with 16 extra fractional bits removes that dead band. The errorx - yneeds 17 bits, and the product with alpha times two can exceed 32 bits when alpha is close to 1, so the update is done in 64 bits. The result fits back in 32 bits because the new value always lies between the old output and the input. - Software timers with wrap-safe deadlinesMedium · C
Implement software timers driven by a 32-bit millisecond tick. Users can start one-shot or periodic timers, stop them, and restart them from inside a callback.
timer_poll(now)is called from the main loop and must fire every expired timer in deadline order. Periodic timers must not drift, and everything must keep working when the tick wraps after about 49.7 days. No dynamic memory: the caller owns each timer struct.Hints
- Keep an intrusive linked list sorted by deadline, so polling only looks at the head.
- Compare ticks with a signed difference, never with a plain greater-than.
Solution and explanation
#include <stdint.h> #include <stddef.h> #include <stdbool.h> typedef void (*timer_cb)(void *arg); typedef struct soft_timer { struct soft_timer *next; uint32_t deadline; /* absolute tick */ uint32_t period; /* 0 = one-shot */ timer_cb cb; void *arg; bool active; } soft_timer_t; static soft_timer_t *active_list; /* sorted by deadline, soonest first */ /* True if tick a is at or after tick b, correct across 32-bit wrap as long as the two are less than 2^31 ticks apart. */ static inline bool tick_reached(uint32_t a, uint32_t b) { return (int32_t)(a - b) >= 0; } static void insert_sorted(soft_timer_t *t) { soft_timer_t **pp = &active_list; while (*pp && tick_reached(t->deadline, (*pp)->deadline)) pp = &(*pp)->next; /* equal deadlines keep FIFO order */ t->next = *pp; *pp = t; t->active = true; } void timer_stop(soft_timer_t *t) { for (soft_timer_t **pp = &active_list; *pp; pp = &(*pp)->next) { if (*pp == t) { *pp = t->next; break; } } t->active = false; } void timer_start(soft_timer_t *t, uint32_t now, uint32_t delay, uint32_t period, timer_cb cb, void *arg) { if (t->active) timer_stop(t); t->deadline = now + delay; t->period = period; t->cb = cb; t->arg = arg; insert_sorted(t); } /* Call from the main loop (not the ISR) with the current tick. */ void timer_poll(uint32_t now) { while (active_list && tick_reached(now, active_list->deadline)) { soft_timer_t *t = active_list; active_list = t->next; t->active = false; if (t->period) { /* re-arm from the old deadline: no drift */ t->deadline += t->period; insert_sorted(t); } t->cb(t->arg); /* callback may stop or restart timers */ } }The timers form an intrusive singly linked list sorted by deadline, so
timer_pollonly examines the head and stops at the first unexpired timer. Insertion walks the list with a pointer to the previous next field, which avoids special cases for the head and keeps equal deadlines in FIFO order. Insertion is linear in the number of active timers, which is fine for the tens of timers typical on an MCU; a timer wheel or heap is the answer if the interviewer asks for thousands.All comparisons use
(int32_t)(a - b) >= 0, which is correct across wrap as long as deadlines are within about 24.8 days of now. Periodic timers re-arm from their previous deadline rather than from the current tick, so a late poll does not accumulate drift, and a very late poll fires the missed periods in a burst, which the interviewer may want you to discuss: some systems prefer to skip missed periods instead. The timer is removed from the list before its callback runs, so callbacks can stop or restart any timer, including themselves, without corrupting the list. - COBS framing for a serial linkMedium · C
Implement Consistent Overhead Byte Stuffing encode and decode so that frames on a UART can be delimited with a 0x00 byte. Encoding must never produce a zero byte, the output size must be bounded, and the decoder must reject malformed frames safely, without reading or writing out of bounds, because it receives bytes from the outside world.
Hints
- Each block starts with a code byte that says how far it is to the next zero, up to 254 data bytes.
- A code of 0xFF means a full block with no implied zero after it.
Solution and explanation
#include <stdint.h> #include <stddef.h> /* COBS: removes every 0x00 from a packet so 0x00 can mark frame ends. Encoded size is at most len + len/254 + 1. Returns bytes written. */ size_t cobs_encode(const uint8_t *in, size_t len, uint8_t *out) { size_t code_pos = 0, o = 1; uint8_t code = 1; for (size_t i = 0; i < len; i++) { if (in[i] == 0) { out[code_pos] = code; /* close the current block */ code_pos = o++; code = 1; } else { out[o++] = in[i]; if (++code == 0xFF) { /* 254 data bytes: full block */ out[code_pos] = code; code_pos = o++; code = 1; } } } out[code_pos] = code; return o; /* caller appends the 0x00 */ } /* Decode one frame (without its 0x00 delimiter). Returns decoded length, or 0 with *ok = 0 if the frame is malformed. */ size_t cobs_decode(const uint8_t *in, size_t len, uint8_t *out, int *ok) { size_t i = 0, o = 0; *ok = 0; while (i < len) { uint8_t code = in[i++]; if (code == 0 || i + code - 1u > len) return 0; /* zero inside frame or overrun */ for (uint8_t k = 1; k < code; k++) { if (in[i] == 0) return 0; out[o++] = in[i++]; } if (code != 0xFF && i < len) /* implicit zero between blocks */ out[o++] = 0; } *ok = 1; return o; }COBS splits the data at each zero. Each block becomes a code byte equal to one plus the number of non-zero bytes that follow, and the zero itself is implied at the end of the block. A block can hold at most 254 data bytes; a code of 0xFF means a full block with no implied zero, so long runs without zeros still encode. The overhead is at most one byte per 254, plus one, which bounds the output buffer size at compile time, unlike SLIP-style escaping, which can double the size in the worst case.
The encoder keeps the position of the current code byte, fills in data, and writes the code when it meets a zero, reaches 254 bytes or ends. The decoder is the security-relevant half: it checks that a code is non-zero, that the block it announces fits within the received length, and that no zero appears inside a block, and it returns failure for anything else. It inserts the implied zero only between blocks, never after the last one. The test harness round-trips random data with many zeros and every length around the 254 boundary, which is where hand-written COBS code usually fails, and feeds malformed frames to the decoder.
- Decode a HardFault into a readable reportMedium · C
Your HardFault handler has already captured the stacked exception frame, EXC_RETURN and the fault status registers (CFSR, HFSR, MMFAR, BFAR) into a struct. Write the function that turns them into a short human-readable report for the crash log: the PC, LR and stack in use, the fault causes, and the faulting address only when it is valid. It must never overflow the output buffer.
Hints
- CFSR combines three registers: MemManage in bits 0–7, BusFault in 8–15 and UsageFault in 16–31.
- MMFAR and BFAR only hold a meaningful address when MMARVALID (bit 7) or BFARVALID (bit 15) is set.
Solution and explanation
#include <stdint.h> #include <stdio.h> #include <string.h> typedef struct { /* captured by the HardFault handler */ uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr; /* hardware-stacked frame */ uint32_t exc_return, cfsr, hfsr, mmfar, bfar; } fault_info_t; static const struct { uint32_t bit; const char *name; } cfsr_bits[] = { {1u << 0, "IACCVIOL: MPU fault on instruction fetch"}, {1u << 1, "DACCVIOL: MPU fault on data access"}, {1u << 3, "MUNSTKERR: MPU fault while unstacking"}, {1u << 4, "MSTKERR: MPU fault while stacking (stack overflow?)"}, {1u << 8, "IBUSERR: bus fault on instruction fetch"}, {1u << 9, "PRECISERR: precise data bus fault"}, {1u << 10, "IMPRECISERR: imprecise data bus fault (PC is not exact)"}, {1u << 11, "UNSTKERR: bus fault while unstacking"}, {1u << 12, "STKERR: bus fault while stacking (stack overflow?)"}, {1u << 16, "UNDEFINSTR: undefined instruction"}, {1u << 17, "INVSTATE: Thumb bit clear (bad function pointer?)"}, {1u << 18, "INVPC: bad EXC_RETURN value"}, {1u << 19, "NOCP: coprocessor/FPU used while disabled"}, {1u << 24, "UNALIGNED: unaligned access trapped"}, {1u << 25, "DIVBYZERO: integer divide by zero trapped"}, }; /* Writes a human-readable report into buf. Returns length written. */ size_t fault_decode(const fault_info_t *f, char *buf, size_t n) { size_t len = 0; if (n == 0u) return 0; buf[0] = '\0'; #define OUT(...) do { if (len + 1u < n) { \ int w_ = snprintf(buf + len, n - len, __VA_ARGS__); \ if (w_ > 0) len += (size_t)w_; \ if (len >= n) len = n - 1u; } } while (0) /* truncated */ OUT("PC=0x%08lX LR=0x%08lX xPSR=0x%08lX stack=%s\n", (unsigned long)f->pc, (unsigned long)f->lr, (unsigned long)f->xpsr, (f->exc_return & 4u) ? "PSP" : "MSP"); if (f->hfsr & (1u << 30)) OUT("HFSR.FORCED: escalated from a configurable fault\n"); if (f->hfsr & (1u << 1)) OUT("HFSR.VECTTBL: bus fault reading the vector table\n"); for (size_t i = 0; i < sizeof cfsr_bits / sizeof cfsr_bits[0]; i++) if (f->cfsr & cfsr_bits[i].bit) OUT("%s\n", cfsr_bits[i].name); if ((f->cfsr & (1u << 7)) != 0u) /* MMARVALID */ OUT("MMFAR=0x%08lX (faulting address)\n", (unsigned long)f->mmfar); if ((f->cfsr & (1u << 15)) != 0u) /* BFARVALID */ OUT("BFAR=0x%08lX (faulting address)\n", (unsigned long)f->bfar); if ((f->xpsr & (1u << 24)) == 0u) /* T bit */ OUT("xPSR.T clear: branched to an even address\n"); #undef OUT return len; }The decoder is table-driven, so adding a bit is a one-line change and the logic is easy to review. It prints the stacked PC and LR first, because with the matching ELF they locate the faulting instruction and its caller, and it uses bit 2 of EXC_RETURN to say whether the fault happened on the main stack or a task stack. HFSR.FORCED means a configurable fault escalated because its own handler was disabled or could not run, so the real cause is in CFSR. The fault address registers are printed only when their valid bits are set; otherwise they hold stale values that send you looking in the wrong place.
Buffer safety is handled by the output macro: it never writes when the buffer is full and clamps the length after a truncated
snprintf, so the result is always terminated and the return value equals the string length. In a real handler I would avoidsnprintfand just store the raw words in no-init RAM, decoding them after reset or on the host, because the fault may have been caused by a corrupted heap or stack. The first-stage handler is a few lines of assembly:HardFault_Handler: tst lr, #4 ite eq mrseq r0, msp mrsne r0, psp b hardfault_capture @ r0 = stacked frame - A/B slot selection with trial boot and rollbackHard · C
Write the decision logic of a bootloader with two application slots. Each slot has a header (magic, version, size, CRC-32) and a state in persistent metadata: empty, pending, confirmed or bad. A newly installed image is pending with a number of trial boots. The application confirms itself after a successful self-test. Choose which slot to boot, roll back after the trials run out, refuse corrupted images and refuse any image below a minimum security version. Return -1 if nothing is bootable.
Hints
- Handle a pending image first; count the trial before jumping, not after.
- Re-validate the confirmed image on every boot, since flash can be corrupted after it was confirmed.
Solution and explanation
#include <stdint.h> #include <stddef.h> #include <stdbool.h> #define IMG_MAGIC 0x464D5721u typedef enum { ST_EMPTY, ST_PENDING, ST_CONFIRMED, ST_BAD } slot_state_t; typedef struct { uint32_t magic, version, size, crc; } img_hdr_t; typedef struct { const img_hdr_t *hdr; /* header at the start of the slot */ const uint8_t *body; /* image body that follows it */ slot_state_t state; /* from the boot metadata sector */ uint8_t tries_left; } slot_t; /* Hook that writes the metadata sector; must complete before we jump. */ void meta_persist(const slot_t slots[2]); static uint32_t crc32(const uint8_t *p, size_t n) { uint32_t c = 0xFFFFFFFFu; while (n--) { c ^= *p++; for (int k = 0; k < 8; k++) c = (c >> 1) ^ (0xEDB88320u & (0u - (c & 1u))); } return ~c; } static bool image_valid(const slot_t *s, uint32_t min_version, size_t cap) { const img_hdr_t *h = s->hdr; return h->magic == IMG_MAGIC && h->size <= cap && h->version >= min_version && /* anti-rollback counter */ crc32(s->body, h->size) == h->crc; } /* Returns the slot to boot, or -1 to stay in the bootloader (DFU mode). */ int boot_select(slot_t s[2], uint32_t min_version, size_t cap) { for (int i = 0; i < 2; i++) { /* 1. trial of a new image */ if (s[i].state != ST_PENDING) continue; if (s[i].tries_left == 0 || !image_valid(&s[i], min_version, cap)) { s[i].state = ST_BAD; /* roll back */ meta_persist(s); continue; } s[i].tries_left--; /* count the attempt first */ meta_persist(s); return i; } int best = -1; /* 2. newest good image */ for (int i = 0; i < 2; i++) { if (s[i].state == ST_CONFIRMED && image_valid(&s[i], min_version, cap) && (best < 0 || s[i].hdr->version > s[best].hdr->version)) best = i; } return best; } /* Called by the application once its self-test passes. */ void boot_confirm(slot_t s[2], int running) { if (s[running].state == ST_PENDING) { s[running].state = ST_CONFIRMED; meta_persist(s); } }The logic has two phases. A pending image gets priority, because it is the update being tried. Its trial counter is decremented and the metadata persisted before the jump, so if the new image hangs or crashes before confirming, the watchdog reset brings us back here with one fewer try, and after the last one the image is marked bad and we fall through. A pending image that fails validation is marked bad at once.
The second phase boots the newest confirmed image that still validates. Validation checks the magic, that the size fits the slot before hashing, so a corrupted length cannot read off the end of flash, the anti-rollback version, and the CRC. Re-checking on every boot catches flash corruption after confirmation and falls back to the other slot.
What a strong answer adds: in production the CRC is replaced by a signature check over the header and image; the metadata write must itself be power-safe, using a sequence-numbered, checksummed record; and the minimum version should be raised only after an image is confirmed, otherwise a failed update could leave no bootable image. The application must also confirm only after it has proved the things that matter, such as connecting to the update server, so a broken update can still be replaced.
- Power-safe key-value store on NOR flashHard · C
Implement a small settings store on two flash sectors of 256 bytes each. Flash has NOR semantics: programming can only clear bits and erase sets a whole sector to 0xFF. Provide
kv_mount,kv_getandkv_setfor 16-bit keys and 32-bit values. Writes must spread wear, and a power cut at any byte of any write or erase must leave every key with either its old or its new value. The board layer providesflash_programandflash_erase.Hints
- Append records; never rewrite one. The newest valid record for a key wins.
- Write each record data first and a commit field last, and switch sectors with a header written after the copy is complete.
- Protect the sector header against a torn erase by storing a value together with its complement.
Solution and explanation
#include <stdint.h> #include <stdbool.h> #include <string.h> #define SECT_SIZE 256u #define HDR_SIZE 8u /* seq, ~seq: a torn erase fails the check */ #define NREC ((SECT_SIZE - HDR_SIZE) / 8u) typedef struct { uint16_t key, commit; uint32_t value; } rec_t; /* 8 bytes */ /* Board layer: NOR semantics, program can only clear bits, erase sets 0xFF. */ extern uint8_t flash[2][SECT_SIZE]; void flash_program(unsigned sect, unsigned off, const void *src, unsigned len); void flash_erase(unsigned sect); static unsigned active, next_rec; static uint32_t seq_of(unsigned s) /* 0 means "not a valid sector" */ { uint32_t h[2]; memcpy(h, flash[s], sizeof h); return (h[0] != 0xFFFFFFFFu && h[0] == ~h[1]) ? h[0] : 0u; } static rec_t rec(unsigned s, unsigned i) { rec_t r; memcpy(&r, flash[s] + HDR_SIZE + i * 8u, sizeof r); return r; } static bool rec_ok(rec_t r) { return r.key != 0xFFFFu && r.commit == 0u; } static void write_hdr(unsigned s, uint32_t seq) { uint32_t h[2] = { seq, ~seq }; flash_program(s, 0, h, HDR_SIZE); } void kv_mount(void) { if (seq_of(0) == 0u && seq_of(1) == 0u) { /* blank or both torn */ flash_erase(0); write_hdr(0, 1); } active = (seq_of(1) > seq_of(0)) ? 1u : 0u; next_rec = 0; for (unsigned i = 0; i < NREC; i++) if (rec(active, i).key != 0xFFFFu) next_rec = i + 1u; /* skip torn records too */ } bool kv_get(uint16_t key, uint32_t *out) { bool found = false; for (unsigned i = 0; i < next_rec; i++) { rec_t r = rec(active, i); if (rec_ok(r) && r.key == key) { *out = r.value; found = true; } /* newest wins */ } return found; } static void append(unsigned s, unsigned i, uint16_t key, uint32_t value) { rec_t r = { key, 0xFFFFu, value }; uint16_t done = 0; flash_program(s, HDR_SIZE + i * 8u, &r, sizeof r); /* data first */ flash_program(s, HDR_SIZE + i * 8u + 2u, &done, 2); /* commit last */ } bool kv_set(uint16_t key, uint32_t value) { if (key == 0xFFFFu) return false; /* reserved: erased */ if (next_rec == NREC) { /* compact into the other sector */ unsigned dst = active ^ 1u, n = 0; flash_erase(dst); for (unsigned i = 0; i < NREC; i++) { rec_t r = rec(active, i); bool newest = rec_ok(r); for (unsigned j = i + 1u; newest && j < NREC; j++) if (rec_ok(rec(active, j)) && rec(active, j).key == r.key) newest = false; if (newest) append(dst, n++, r.key, r.value); } write_hdr(dst, seq_of(active) + 1u); /* header last: the switch-over */ active = dst; next_rec = n; if (n == NREC) return false; /* too many live keys */ } append(active, next_rec++, key, value); return true; }The store is a log. Each
kv_setappends an 8-byte record to the active sector: the key and value are programmed first with the commit halfword still erased, then the commit halfword is programmed to zero. A record whose commit is still 0xFFFF was torn by a power cut and is ignored, and mount skips past it so the next write uses a fresh location rather than programming over partly written bits. Reads scan the sector and keep the last committed match.When the sector fills, compaction erases the other sector, copies the newest record of each live key into it, and only then writes the new header with a sequence number one higher. The header is the single commit point for the switch: if power fails before it is complete, mount still selects the old sector, which is untouched. Storing the sequence with its complement means a header damaged by an interrupted erase, where bits may be in any state, fails the check instead of looking like a valid newer sector.
The harness injects a power cut at every possible byte position thousands of times and checks every key afterwards. Things to mention: real flash programs in units such as 64 bits with ECC, which changes the record layout, and the erase count per sector should be tracked to estimate lifetime.
Design and debugging scenarios
Talk through each one out loud for 15–20 minutes, then compare your answer with the model walk-through.
- Design the firmware update path for a battery-powered sensorSystem design
A fleet of 50,000 battery-powered environmental sensors uses a Cortex-M4 with 1 MB of internal flash and 256 kB of RAM, plus a low-power radio with a link of a few kilobytes per second. Devices are installed in hard-to-reach places for ten years. Design the over-the-air update system on the device: memory layout, download, verification, switch-over and recovery.
Model walk-through
Requirements first. The device must never become unrecoverable, updates must not drain the battery noticeably, only signed firmware may run, old vulnerable images must not be installable, and a failed update must roll back without anyone touching the device.
Memory layout. With 1 MB I use A/B slots: a small immutable bootloader of about 32 kB, two application slots of around 448 kB each, and two small sectors for boot metadata and settings. A/B gives instant rollback and means the running image is never touched during download.
Download. The image is transferred in chunks with a chunk index, so a download can resume after the radio drops or the device sleeps; progress is stored in flash. Each chunk is written straight to the inactive slot. To protect the battery, downloads run only when the battery voltage is above a threshold, are spread over several wake windows, and the server staggers rollout across the fleet.
Verification and switch-over. After the last chunk, the device hashes the whole slot with SHA-256 reading back from flash, and the bootloader verifies an ECDSA signature over the header and hash with a public key in protected flash. The header includes a security counter checked against a monotonic counter. Only then is the metadata record for that slot set to pending with three trial boots, using a power-safe, sequence-numbered record.
Trial and confirmation. The bootloader decrements the trial count before jumping, and the watchdog is running. The new application confirms itself only after it has initialised its sensors and completed a round-trip with the server, which proves it can receive the next update. If trials run out, the bootloader marks the image bad and boots the old one, which then reports the failure.
Failure modes and tests. Power cut at every stage, corrupted chunk, wrong-board image, downgrade attempt, image that crashes at start-up, image that runs but cannot connect, flash wear from repeated failed downloads. I would test each on a HIL rig with a power relay and a scripted server, and do a staged rollout to a small percentage of the fleet first, watching telemetry for confirmation rates before widening.
- Intermittent HardFault after adding a new featureDebugging
After a feature that adds BLE notifications was merged, a product that ran for months without issues now hits a HardFault roughly once a day in the test lab. It never happens with the debugger attached. The fault log shows a stacked PC inside
memcpy, CFSR shows PRECISERR with BFARVALID, and BFAR is an address just past the end of RAM. How do you proceed?Model walk-through
Read the evidence. A precise bus fault with a valid BFAR means a specific load or store tried to access an address that does not exist, and the address is just past the end of RAM. The faulting instruction is inside
memcpy, so the caller passed a bad pointer or a bad length. Since memcpy is a leaf, the stacked LR points to the caller, which I find with addr2line on the exact ELF of that build.Form hypotheses. There are three likely ones. First, a length that underflowed: an unsigned subtraction such as
received - header_lenwhere received is occasionally shorter than the header, producing a huge length that runs off the end of RAM. Second, a buffer pointer computed from corrupted data, for example a ring buffer index that is torn because the new BLE callback writes it from an interrupt while main reads it. Third, a stack overflow in the task that runs the new feature, corrupting a neighbouring structure that holds the pointer.Narrow it down. I extend the fault capture to save R0 to R3 from the stacked frame, which for memcpy are the destination, source and length. If the length is enormous, hypothesis one is confirmed. I check the stack high-water mark of each task, since the feature added work to some task, and enable the MPU guard regions or the kernel overflow check. I review every shared variable the new code touches for missing critical sections and check its interrupt priority against the RTOS limit, because a BLE stack callback calling a non-FromISR API from an interrupt above the syscall priority corrupts kernel lists.
Reproduce faster. I increase the notification rate and add random delays in the suspect paths on a test build to make the race window bigger, and run a fuzzer on the host build of the message parser with short and malformed packets.
Fix and prove. The fix depends on the cause: validate lengths before subtraction, protect the shared index, or resize the stack. Then I add a regression test, such as a host test with a short packet or a stress test on the HIL rig, and run it for many times the original failure interval.
- Review a UART interrupt handlerCode review
A colleague submits this receive interrupt for review. What problems do you raise, and how would you rewrite it?
volatile char line[64]; volatile int len; volatile int line_ready; void USART2_IRQHandler(void) { char c = USART2->DR; if (c == '\n') { line[len] = 0; line_ready = 1; printf("got line %s\n", line); len = 0; } else { line[len++] = c; } }Model walk-through
Memory safety. There is no bounds check: a line of 64 characters or more without a newline writes past the end of the array, and with the terminator added at
line[len]even 64 characters is one too many. Input comes from outside the device, so this is a real vulnerability, not a theoretical one. Overlong lines need to be discarded or truncated with an error count.Blocking work in the ISR.
printfinside a handler is a serious problem. It is slow, often takes milliseconds over a blocking UART, so further characters overrun; it may not be re-entrant; and if it is retargeted to the same UART or uses a lock, it can deadlock. Logging belongs in the main loop.Race with the consumer. When a line is ready, the ISR resets
lenand keeps writing into the same buffer while main may still be reading the previous line, so main sees a mix of two lines. A single buffer with a flag cannot work unless reception stops. I would use a byte ring buffer filled by the ISR and parse lines in main, or double-buffer lines and swap them.Error handling. The handler does not check overrun, framing or noise flags. On many STM32 parts an overrun leaves the interrupt asserted until the flag is cleared in the right sequence, which can lock the system in the handler.
Types and style.
lenas signed int invites negative indexing; use an unsigned size type.charmay be signed, so bytes above 0x7F sign-extend. The data register access should use the field width the reference manual defines. Volatile on the whole buffer is a symptom of the design, not a fix.Rewrite. The handler reads the status register, handles and clears error flags with a counter for each, reads the data register, and pushes the byte into a single-producer ring buffer, counting overruns if it is full. Main pulls bytes, assembles lines in its own buffer with a length limit, and processes complete lines. The handler is then a few dozen cycles, cannot overflow, and the line logic is testable on the host.
- Bring up a new board where the MCU will not run your codeDebugging
The first prototype of a new board has arrived. The debug probe connects and can program flash, but after a reset the code never reaches main: the LED you toggle first never changes. With the debugger you can halt the core, and it is sitting in a HardFault handler or at a strange address. Describe your approach.
Model walk-through
Start with what the core sees. I halt and read PC, SP, LR and xPSR, and dump the first words of flash at the boot address. Word 0 must be a plausible stack pointer within RAM, and word 1 the reset handler address with bit 0 set. A mismatch points to a wrong linker script for this part, an image linked for an offset meant for a bootloader that is not present, or wrong boot pins selecting another memory. I check the boot configuration on the schematic and the option bytes.
Step through the reset handler. I set a breakpoint on the reset handler and step. If it faults immediately, I read CFSR. A fault while writing .data or zeroing .bss often means the linker script declares more RAM than this part variant has, which is common when a board uses a smaller memory option of the same family. A fault on the first floating-point instruction means the FPU was not enabled in SystemInit before use. A hang in SystemInit usually means waiting for an external crystal or PLL that never becomes ready.
Check clocks and power. If it hangs waiting for the crystal, I scope the oscillator pins with a low-capacitance probe, check the load capacitor values against the crystal specification and the layout, and try running from the internal oscillator to separate the problem. I also check the core supply and decoupling, and the VCAP pins on parts with an internal regulator, which need a specific capacitor; a missing one causes erratic behaviour that looks like a software bug.
Reduce to a minimum. I build the smallest possible image: vector table, reset handler that toggles the LED pin with direct register writes, no clock changes, no C library. If that runs, I add the original start-up steps back one at a time. If the LED still does not toggle, I check the pin on the schematic and the port clock enable.
- Choose between bare metal, FreeRTOS and Zephyr for a new productResearch
Your team is starting a new connected product on a Cortex-M33 with TrustZone, BLE, a sensor hub and OTA updates. The previous product was a bare-metal super-loop. The lead asks you to recommend a software foundation and justify it in a short investigation. How do you approach this and what would you recommend?
Model walk-through
Frame the decision. I write down the requirements that actually differentiate the options: the BLE stack and how it expects to be scheduled, the update and secure boot path, TrustZone partitioning, low-power targets, certification needs, team experience, licensing, and long-term maintenance over the product life. Then I define how I will evaluate each option against them in a fixed time, for example two weeks.
Bare metal. It gives full control, the smallest footprint and the team knows it. But a vendor BLE stack usually comes with its own scheduler or assumes an RTOS, OTA with a secure bootloader must be built and maintained in-house, and several concurrent activities with different deadlines would mean hand-written state machines for everything. I would only keep it if the vendor SDK supplies most of the stack in bare-metal form.
FreeRTOS. A small, well-understood kernel with a permissive licence and wide vendor support. It is a scheduler, not a platform: drivers, the BLE integration, the update agent and the bootloader come from the vendor SDK or from us. That suits a team that wants a small, transparent core and is happy to own integration. TrustZone is supported with secure and non-secure ports.
Zephyr. A full platform: kernel, devicetree-based drivers, a BLE host and controller, MCUboot integration for signed updates, power management and Trusted Firmware-M for the secure side. That covers much of the list, but the build system, Kconfig and devicetree have a learning curve, the footprint is larger, and upgrades between releases need planning.
Evaluate with a prototype. I build the same spike on the two strongest candidates: BLE connection, a sensor read on a timer, deep sleep between events, and a signed OTA with rollback.
Recommendation. For this product, with BLE, OTA, TrustZone and a team ready to invest, I would expect Zephyr to win on total effort because so much of the security and update path already exists and is maintained upstream. FreeRTOS is the right choice if the vendor SDK is built around it and the team prefers to keep the platform small. I present the measurements, the risks such as learning curve and footprint, and a mitigation plan such as training and pinning a long-term support release.
Run a mock interview
Ask a friend to play the interviewer, or record yourself. Pick questions and an exercise you have not opened yet.
| Minutes | Stage | What to do |
|---|---|---|
| 0–5 | Introduction and project deep dive | Describe one firmware project in two minutes: the MCU, what you owned, one hard bug and how you proved the fix. Expect the interviewer to pick one detail and go deeper. |
| 5–15 | C and hardware fundamentals | Answer rapid questions on volatile, integer promotion, memory sections, bit manipulation and the startup sequence. Aim for two-sentence answers with one pitfall each. |
| 15–35 | Coding exercise | Solve one medium exercise from this page, such as the ring buffer or the software timers, in a plain editor. State the constraints and interface first, write the code, then walk through a test case including wrap-around and the full or empty edge. |
| 35–45 | Interrupts and RTOS | Explain priority inversion, a safe ISR-to-task hand-off and how you size stacks. Draw the priority table for a system with a motor control loop, a UART and a BLE stack. |
| 45–55 | Scenario | Take one scenario, such as the OTA design or the intermittent HardFault, and talk it through with the structure: requirements or evidence, approach, failure modes, how you would test it. |
| 55–60 | Your questions and self-review | Ask about the hardware, toolchain and test rigs the team uses. Afterwards, write down the questions where you hesitated and study those sections again. |
Cheat sheet
Facts worth having at your fingertips on the day.
- Vector table
- Word 0 is the initial MSP, word 1 the reset handler address with bit 0 set for Thumb state.
- Exception numbers
- Reset 1, NMI 2, HardFault 3, MemManage 4, BusFault 5, UsageFault 6, SVCall 11, PendSV 14, SysTick 15, external IRQs from 16.
- Exception frame
- Hardware stacks R0–R3, R12, LR, PC and xPSR: 8 words, or 26 words with the FPU context.
- EXC_RETURN
- 0xFFFFFFF1 returns to Handler mode, 0xFFFFFFF9 to Thread mode on MSP, 0xFFFFFFFD to Thread mode on PSP; bit 4 clear means an FPU frame.
- NVIC priority
- Lower number means more urgent; only the top implemented bits of the 8-bit field count.
- BASEPRI
- Masks interrupts with priority value at or above the setting; not available on ARMv6-M (M0 and M0+), which only have PRIMASK.
- Fault status registers
- CFSR at 0xE000ED28, HFSR at 0xE000ED2C, MMFAR at 0xE000ED34, BFAR at 0xE000ED38.
- CFSR layout
- MemManage status in bits 0–7, BusFault in bits 8–15, UsageFault in bits 16–31.
- Startup order
- Set clocks and FPU, copy .data from flash, zero .bss, run constructors, call main.
- .data and .bss
- .data costs flash and RAM, .bss costs only RAM, const data costs only flash.
- Tick wrap
- A 32-bit 1 ms counter wraps after about 49.7 days; compare with (int32_t)(now - deadline) >= 0.
- SPI modes
- Mode 0: CPOL 0, CPHA 0; mode 1: 0, 1; mode 2: 1, 0; mode 3: 1, 1.
- I2C recovery
- Clock SCL up to nine times until SDA is released, then send a STOP.
- CAN error states
- Error-passive above 127 on either counter, bus-off above 255 on the transmit counter.
- CAN arbitration
- Dominant 0 beats recessive 1, so the lowest identifier wins without corrupting the frame.
- Flash
- Programming clears bits, erase sets a whole sector to 1s; endurance is often around 10,000 cycles for internal flash.
- Q15
- Signed 16-bit, value = raw / 32768; multiply into 32 bits and shift right by 15.
- Oversampling
- Four times oversampling and averaging gains about one bit of resolution if the noise is random.
- CRC-16/CCITT-FALSE
- Polynomial 0x1021, initial value 0xFFFF; check value for "123456789" is 0x29B1.
- CRC-32
- Reflected polynomial 0xEDB88320, initial value and final XOR 0xFFFFFFFF; check value 0xCBF43926.
- Cache and DMA
- Clean the cache before a DMA transmit, invalidate after a DMA receive; align buffers to 32-byte lines on M7.
- Mutex versus semaphore
- A mutex has an owner and priority inheritance; a semaphore is for signalling and can be given from an ISR.
- FreeRTOS ISR rule
- Only FromISR APIs in handlers, and only from interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY urgency.
- Low power
- WFI with SLEEPDEEP clear is sleep, with SLEEPDEEP set is the vendor deep sleep; unused pins go to analog mode.
- Watchdog
- Feed only from a supervisor that has confirmed every monitored task made progress.
- Secure boot
- Immutable root, public key hash in OTP, signature over header and image, monotonic security counter, debug port locked.
Red flags that sink candidates
- Saying volatile makes a variable thread-safe or atomic. Explain that it only forces the compiler to perform each access, and name the critical section or atomic you would add.
- Calling printf, malloc or a blocking API inside an interrupt handler in a coding answer. Keep handlers to reading data, clearing flags and signalling, and say so before the interviewer asks.
- Designing an update path with no rollback or no answer to "what if power fails now". Walk through each step and state what the bootloader sees if power is cut there.
- Feeding the watchdog from a timer interrupt. Describe a supervisor that checks every task before the kick.
- Debugging by guesswork: changing code until the symptom moves. Describe the evidence you would collect first, such as fault registers, a scope trace or a minimal reproduction.
- Comparing tick counters with a plain greater-than, or ignoring wrap in ring buffer indices. Use unsigned differences and say how long the interval may be.
- Claiming experience with a bus or RTOS but being unable to describe its failure modes, such as a stuck I2C bus, CAN bus-off or priority inversion. Only list what you can defend in depth.
- Ignoring the hardware: never mentioning the reference manual, errata, the scope or the schematic. Firmware developers are expected to read all four and to talk to the hardware team in its own terms.
- Writing code with no tests and no plan to test it. Mention how you would test the function on the host and what you would check on the target.
FAQ
What do firmware developer interviews usually include?
Most loops have a C coding round, a technical deep dive on embedded fundamentals, and a discussion of past projects and debugging. Coding tasks are usually small and practical: bit manipulation, a ring buffer, a state machine, a parser or a driver skeleton, judged on correctness with edge cases rather than on algorithm tricks. Senior loops add a design round, often about boot and update paths, power or an RTOS architecture, and some teams include a practical exercise with real hardware or a schematic review.
How much C++ do I need for a firmware role?
C is the core requirement almost everywhere, and you should be fluent in it including undefined behaviour and the integer rules. Many teams now write firmware in a restricted subset of C++: classes, templates, constexpr, references and RAII, usually without exceptions, RTTI or dynamic allocation after start-up. If the job description mentions C++, expect questions on what each feature costs in code size and RAM, and why exceptions are normally disabled.
Do I need my own development board to prepare?
It helps a great deal. A low-cost Cortex-M board with an on-board debug probe lets you practise the things interviewers ask about: writing a linker script and startup code, reading fault registers after a deliberate crash, measuring interrupt latency with a GPIO and a cheap logic analyser, and putting the part into deep sleep and measuring the current. Being able to say "I tried this and here is what I saw" makes answers far more convincing than theory alone.
How is a firmware developer interview different from an embedded systems engineer interview?
A firmware interview stays close to the microcontroller: bare-metal start-up, interrupts, peripherals, RTOS behaviour, bootloaders, power and fault handling. An embedded systems engineer interview is often broader and may include embedded Linux, device trees, Yocto or Buildroot, user-space and kernel interfaces, and system-level design across several processors. There is overlap in C, debugging and hardware literacy, so preparing well for one helps with the other, but adjust the depth to the job description.
What should I talk about when asked for my hardest bug?
Choose a bug that crossed the boundary between hardware and software, such as a race with an interrupt, a DMA and cache problem, a power-related reset or a timing margin. Describe the symptom, the evidence you gathered and the tools you used, the hypotheses you ruled out, the root cause, the fix and how you proved it, and what you changed so it would not happen again. Interviewers listen for method and ownership more than for the bug being exotic.