Embedded Systems Engineer Interview Questions
Embedded Systems Engineer Interview Questions and Answers
This page is for engineers preparing for embedded systems interviews: microcontroller firmware, RTOS applications, embedded Linux devices, or a mix. It assumes you already write C and know your way around a datasheet. The aim is to help you answer the technical rounds the way a strong hire does: precisely, with the reasoning behind the answer and the trade-off or pitfall that shows real experience.
Most embedded loops have a C or C++ coding round, often on a whiteboard or a shared editor without a compiler, a deep-dive on low-level fundamentals (memory, interrupts, concurrency, peripherals), a debugging or code review round, and for senior roles a system design round about a whole device. Interviewers care less about API names than about whether you can reason about what the hardware and compiler actually do.
The page is organised in the order the topics build on each other: the C language, embedded C++, interrupts and shared data, RTOS and real-time design, peripherals and buses, embedded Linux, power and safety, then debugging, testing and device design. After the questions come coding exercises with tested solutions, open-ended scenarios with model walk-throughs, a mock interview plan, a cheat sheet, red flags and an FAQ. The Embedded Systems Engineer role page covers the learning path and the interview loop; this page is the question bank that goes with it.
- Questions
- 60 with model answers
- Coding
- 9 exercises with solutions
- Scenarios
- 4 design and debugging rounds
How to use this page
Read each question and answer it out loud before opening the model answer. Embedded rounds are spoken, and explaining a race condition clearly is a skill in itself.
Write every coding exercise by hand or in a plain editor first, then compile it with
-Wall -Wextraand write a small test. Interviewers expect code that would compile, with fixed-width types and no undefined behaviour.For each section, pick one question and connect it to something you have built or debugged. Real stories carry the behavioural part of technical rounds.
Practise the scenarios against a timer, using the structure in the model answers: requirements, approach, trade-offs, failure modes, testing.
Run the mock interview at the end of your preparation, then review the cheat sheet and red flags the day before. For more timed problems, use coding practice and the AI code review round at AI code review round.
Embedded C: the language under the hardware
Interviewers open with C because almost every field bug in firmware traces back to storage, types, undefined behaviour or memory layout, and they want to hear that you know where the compiler will surprise you.
What does static mean in each place it can appear in C?Junior
static does different jobs depending on where it appears. At file scope it gives a function or variable internal linkage: the name is private to that translation unit. That is how a driver hides its internal state and helper functions and avoids symbol clashes at link time.
Inside a function it gives a local variable static storage duration. The variable lives for the whole program, is initialised once (to zero if there is no initialiser) and keeps its value between calls. It sits in .data or .bss, not on the stack, so it costs RAM permanently but never grows the stack.
The pitfall I mention is reentrancy. A function with a static local is not reentrant: if an ISR and a task, or two RTOS tasks, call it at the same time, they share one copy of that variable.
Likely follow-up. How would you make a function with internal state safe to call from two tasks?
Is volatile enough to make a variable safe to share between an interrupt and the main loop?Mid
No. volatile only tells the compiler that every access written in the source must really happen, and in program order relative to other volatile accesses, because something outside the program can see or change the value. It says nothing about atomicity, and nothing about the ordering of ordinary, non-volatile accesses.
Three things still go wrong. A volatile uint64_t on a 32-bit core is read in two halves and can tear. count++ on a volatile is still a load, an add and a store, so an interrupt between them loses an update. And the compiler may move ordinary writes, such as filling a buffer, past the volatile write that sets a ready flag.
For shared data I use C11 _Atomic with the right memory order, or a short critical section. With DMA or a second core I also need barriers or cache maintenance, which volatile never generates. volatile stays the right tool for memory-mapped registers.
Likely follow-up. On a single-core Cortex-M, when is a plain aligned 32-bit read already atomic?
Which kinds of undefined behaviour cause real firmware bugs, and how do you guard against them?Mid
The ones I have seen bite are signed overflow, bad shifts, out-of-bounds access, uninitialised locals and strict-aliasing violations. Undefined behaviour matters because the optimiser assumes it never happens, so the code can change meaning between -O0 and -O2.
1 << 31with a 32-bitintshifts into the sign bit, which is undefined in C. Write1u << 31. Shifting by the type width or more is also undefined.- An overflow check written as
if (x + 1 < x)on a signed type may be deleted entirely. - On many Cortex-M parts address 0 holds the vector table, so a null dereference silently reads flash instead of faulting.
My defences: unsigned types for bit work and counters, -Wall -Wextra -Wconversion treated as errors in CI, a static analyser, and building the hardware-independent modules on the host with the undefined behaviour and address sanitisers so the unit tests catch it.
Likely follow-up. Why might a null pointer dereference not fault on your microcontroller, and how could you make it fault?
Explain the integer promotion bug in this test: uint8_t a = 0xFF; if (~a == 0) { ... }Mid
The condition is never true. Before ~ is applied, a is promoted to int, so ~a is 0xFFFFFF00 on a 32-bit target, not zero. The fix is to bring the result back to the narrow type: (uint8_t)~a == 0.
Promotion causes a family of bugs. Two uint16_t values multiplied on a 32-bit target are promoted to signed int, so 0xFFFF * 0xFFFF overflows a signed int, which is undefined. Writing (uint32_t)a * b fixes it. On an 8-bit or 16-bit MCU, where int is 16 bits, uint16_t x = 40000; x * 2 behaves differently again, which bites when code is ported between cores.
Mixing signed and unsigned in a comparison converts the signed side to unsigned, so -1 < 1u is false. I turn on -Wsign-compare and -Wconversion and keep register and protocol arithmetic in explicitly sized unsigned types.
You receive four bytes from a sensor that hold an IEEE 754 float. How do you turn them into a float safely?Mid
Assemble the bytes into a uint32_t in the wire byte order with shifts, then memcpy that into a float. The compiler turns a fixed-size memcpy into a single register move, so it costs nothing.
The tempting version, *(float *)buf, has two problems. It breaks strict aliasing, because a float lvalue is reading an object whose type is a byte array, so the optimiser may reorder or drop accesses. And buf may not be 4-byte aligned, which faults on a Cortex-M0 and on any core when the compiler emits a multiple-load instruction.
In C, reading a different member of a union than the one last written is allowed and reinterprets the bytes. In C++ that is undefined; use std::bit_cast in C++20 or memcpy. Character types may alias anything, which is why byte buffers are safe to read. Some projects, the Linux kernel among them, build with -fno-strict-aliasing as a deliberate choice, but I would not rely on that in portable code.
Likely follow-up. Does the float format itself also need checking on an unusual target?
Where does each of these end up in memory: a global int x = 5, a global int y, a const lookup table, a local array, and a string literal?Junior
On a typical microcontroller toolchain:
int x = 5;goes in.data. It lives in RAM, but its initial value is stored in flash and the startup code copies it across beforemain.int y;, and any static explicitly initialised to zero, goes in.bss, which the startup code zeroes. It costs RAM but no flash.static const uint16_t table[] = {...};goes in.rodata, which is placed in flash and costs no RAM.- A non-static local array lives on the current stack.
- A string literal goes in
.rodata.
The trap is a large const table declared inside a function without static. The language then requires a fresh object per call, and a compiler may build it on the stack each time. I always check the map file to confirm big tables landed in flash, and I watch the .data size, because it costs both flash and RAM.
Likely follow-up. What exactly does the reset handler do before it calls main?
Why do many firmware teams forbid malloc, and what do they use instead?Junior
Four reasons. A general-purpose heap fragments over months of uptime, so an allocation can fail long after release even when total free memory looks fine. Allocation time is not bounded, which conflicts with real-time deadlines. Most heap implementations are not safe to call from an ISR, and need a lock to share between tasks. Finally, you cannot prove from the code that memory will never run out, and safety standards such as MISRA C ask you to avoid dynamic allocation for exactly that reason.
The alternatives are static allocation of everything sized at compile time, fixed-block pools for objects that come and go (O(1), no fragmentation, easy to monitor), RTOS objects created with the static-allocation APIs, and arenas that are reset as a whole at the end of a transaction.
Embedded C++ without surprises
More teams now write firmware in C++, and interviewers check that you can use its zero-cost features while knowing exactly which features cost flash, RAM or determinism.
Why are exceptions and RTTI often disabled in firmware, and what do you lose?Mid
Exceptions cost flash for unwind tables and the runtime support library, and on many toolchains throwing allocates the exception object from the heap. Throw time depends on how far the stack unwinds, so it is hard to bound. RTTI adds type information for every polymorphic class and enables dynamic_cast and typeid, which firmware rarely needs.
So teams build with -fno-exceptions -fno-rtti. The consequences: a failing new cannot throw, so either there is no heap or you use the nothrow form and check for null. Standard library functions that would throw call an abort handler instead, which you must define to put the device in a safe state. Errors travel as return codes, std::optional, or an expected-style type such as std::expected from C++23.
The trap is a third-party library or precompiled object built with exceptions enabled, which silently brings the unwinder back. I check the map file for unwind sections after integrating anything.
Likely follow-up. Would you ever keep exceptions on in an embedded product?
Which C++ features are effectively free on a microcontroller, and which carry a real cost?Junior
Free, or close to it after optimisation: classes with non-virtual member functions, references, namespaces, enum class, constexpr, static_assert, RAII destructors, std::array, std::span and std::string_view, lambdas, and std::atomic on types the core supports natively. Templates cost nothing at run time but cost flash for each instantiation.
Features with a cost: virtual functions add a vtable per class in flash, a pointer per object and an indirect call that blocks inlining, which is usually acceptable. Containers such as std::vector, std::string, std::map and often std::function allocate from the heap. iostreams pull in a very large amount of code. Exceptions and RTTI are discussed separately.
What is the difference between const, constexpr, consteval and constinit, and how would you build a lookup table at compile time?Mid
const means read-only after initialisation; the value may still be computed at run time. A constexpr variable must be initialised with a constant expression, and a constexpr function may run at compile time when its arguments are constants. consteval (C++20) forces compile-time evaluation. constinit (C++20) guarantees a static variable is initialised at compile time but leaves it writable.
constexpr std::array<uint8_t, 256> make_bitrev() {
std::array<uint8_t, 256> t{};
for (unsigned i = 0; i < 256; ++i) {
unsigned r = 0;
for (unsigned b = 0; b < 8; ++b)
if (i & (1u << b)) r |= 0x80u >> b;
t[i] = static_cast<uint8_t>(r);
}
return t;
}
constexpr auto kBitRev = make_bitrev();
static_assert(kBitRev[1] == 0x80);
The table lands in flash, there is no startup code to build it, and the static_assert documents and checks it at the same time.
How do you stop templates from bloating flash?Senior
Every distinct set of template arguments produces its own copy of the code. A queue template used with ten element types and five capacities can easily produce fifty near-identical functions.
The standard fix is a thin template over a type-erased core. The core is ordinary code that works on void *, an element size and a capacity passed at run time. The template only adds type-safe inline wrappers and the storage. Code that does not depend on the capacity should never be inside a class templated on it.
Other tools: explicit instantiation in one translation unit so headers do not re-instantiate everywhere, link-time optimisation, and identical code folding in the linker, which merges functions whose machine code is the same.
What runs before main in a C++ firmware image, and what goes wrong with global constructors?Mid
The reset handler sets up the stack, copies .data from flash to RAM, zeroes .bss, often calls a clock setup function, then runs the constructors listed in .init_array (with newlib this is done by __libc_init_array), and finally calls main.
Three problems follow. The order of constructors across translation units is unspecified, so a global that uses another global from a different file may see it unconstructed. A constructor that touches hardware runs before main has configured clocks or enabled the peripheral, so it faults or silently does nothing. And a custom startup file that forgets to walk .init_array means constructors never run at all.
My fixes: give classes constexpr constructors so globals are constant-initialised with no code, use constinit to make the compiler prove it, and keep hardware set-up in explicit init() calls from main. Function-local statics work too, but add guard variables, and with threads they call guard functions unless -fno-threadsafe-statics is used.
Write an RAII guard for a critical section on a Cortex-M and explain its subtleties.Mid
class IrqGuard {
public:
IrqGuard() : saved_(__get_PRIMASK()) { __disable_irq(); }
~IrqGuard() { __set_PRIMASK(saved_); }
IrqGuard(const IrqGuard&) = delete;
IrqGuard& operator=(const IrqGuard&) = delete;
private:
uint32_t saved_;
};
void push(Event e) {
IrqGuard lock; // released on every return path
queue_.push(e);
}
It saves and restores PRIMASK rather than blindly re-enabling interrupts, so it nests correctly: an inner guard inside an outer one does not re-enable interrupts early, and it is safe to use in code called with interrupts already off. Deleting copy stops two objects restoring the same state.
The classic bug is writing IrqGuard{}; without a name. That creates a temporary that is destroyed at the semicolon, so nothing is protected. Some static analysers catch it. I would also keep the guarded region short, and use a BASEPRI version when the highest-priority interrupts must keep running.
How can C++ make register access type-safe without costing anything at run time?Senior
Describe each register field as a type whose address, position and width are template parameters, so masks and shifts are computed at compile time and every access inlines to the same load and store a C macro would give.
template <uintptr_t Addr, unsigned Lsb, unsigned Width>
struct Field {
static constexpr uint32_t mask = ((1u << Width) - 1u) << Lsb;
static uint32_t read() {
return (*reinterpret_cast<volatile uint32_t*>(Addr) & mask) >> Lsb;
}
static void write(uint32_t v) {
auto r = reinterpret_cast<volatile uint32_t*>(Addr);
*r = (*r & ~mask) | ((v << Lsb) & mask);
}
};
using UartParity = Field<0x40011000u + 0x0C, 9, 2>;
Adding enum class values for the field contents means writing a baud setting into a parity field no longer compiles. The width must be less than 32 for this mask expression, which a static_assert can enforce.
The costs are in people, not cycles: code generators are needed to produce these types from the vendor's register description files, unoptimised debug builds get slower, and the team must be comfortable reading templates.
Interrupts, concurrency and shared data
Most intermittent firmware bugs are races between interrupt context and thread context, so interviewers dig into exception entry, latency, atomicity and barriers to see whether you can reason about them precisely.
Walk through what happens on a Cortex-M from the moment an interrupt is raised to the first line of the handler.Mid
The NVIC marks the interrupt pending. If its priority is higher than the current execution priority and it is not masked, the core takes it. Hardware pushes eight words onto the current stack: R0 to R3, R12, LR, the return address and xPSR, and more for the floating-point registers if the FPU context is active and lazy stacking is not deferring it. In parallel it fetches the handler address from the vector table, located through VTOR. LR is loaded with a special EXC_RETURN value, the core switches to Handler mode on the main stack, and the handler starts. On a Cortex-M3 or M4 with zero wait-state memory this takes about twelve cycles.
Because hardware saves the caller-saved registers, handlers are ordinary C functions. Two optimisations matter: tail-chaining, where a pending interrupt runs straight after the current one without unstacking and restacking, and late arrival, where a higher-priority interrupt that arrives during stacking takes over the vector fetch. The handler returns by loading EXC_RETURN into the PC.
Likely follow-up. What does EXC_RETURN tell you inside a fault handler?
What contributes to interrupt latency, and how do you measure the worst case?Mid
Latency is the time from the hardware event to the first useful instruction in the handler. Contributors: the fixed exception entry cycles, flash wait states or cache misses when fetching the vector and handler, a higher-or-equal priority handler already running, and, usually the largest, code that has disabled interrupts for a critical section. Bus contention with DMA and floating-point stacking add a little more.
To measure it, drive a GPIO from the event source if possible, toggle another GPIO at the first line of the handler, and capture both on an oscilloscope or logic analyser in persistence or histogram mode for a long run under realistic load. Inside the chip, the DWT cycle counter gives a timestamp you can record. The number that matters is the maximum, not the average.
To reduce it: shorten or narrow critical sections (BASEPRI instead of a global disable), raise the priority of the interrupt, and run the handler from RAM or tightly coupled memory if flash wait states dominate.
Main code does GPIOA->ODR |= LED_PIN while an interrupt changes another pin of the same port. What can go wrong?Mid
The read-modify-write in main is three steps: load ODR, OR in the bit, store ODR. If the interrupt fires after the load and changes its own pin, main then stores its stale copy and silently undoes the interrupt's change. It happens rarely, which is what makes it expensive to find.
The best fix is hardware support for atomic bit changes. Many GPIO blocks have set and reset registers, such as BSRR on STM32 parts, where writing a 1 changes only that pin. Cortex-M3 and M4 also offer bit-banding on some parts, which aliases each bit to its own word. Otherwise wrap the update in a short critical section, or use an LDREX and STREX retry loop.
A related bug: many status registers are write-one-to-clear. Writing SR |= FLAG reads all the set flags and writes them back as ones, clearing events you have not handled yet. For those registers you write only the bit you mean to clear: SR = FLAG.
A 64-bit uptime counter is incremented in a timer interrupt. How do you read it correctly from the main loop on a 32-bit MCU?Mid
A plain read is two 32-bit loads, so if the low word wraps between them you get a value that is off by 2^32. One option is a short critical section around the two loads. Another, which avoids masking interrupts, is to read the high word twice and retry if it changed:
static volatile uint32_t g_hi, g_lo; /* updated only by the ISR */
uint64_t uptime_read(void)
{
uint32_t hi1, lo, hi2;
do {
hi1 = g_hi;
lo = g_lo;
hi2 = g_hi;
} while (hi1 != hi2);
return ((uint64_t)hi1 << 32) | lo;
}
This works on a single core because the ISR runs to completion relative to main, so if it wrapped the low word during our reads, the high word differs and we retry. The same pattern works for a hardware timer with an overflow counter in software, with one extra check for a pending overflow flag. On a multi-core part you need a sequence counter with proper barriers instead.
How do interrupt priorities and priority grouping work on the NVIC, and what is the common RTOS trap?Mid
On Cortex-M a lower number means a higher priority. Each interrupt has an 8-bit priority field, but parts implement only the top few bits, often three or four, giving 8 or 16 levels. Priority grouping splits those bits into a pre-emption priority and a subpriority. Only the pre-emption priority decides whether one handler can interrupt another. The subpriority only decides which of two pending interrupts runs first.
The trap with an RTOS such as FreeRTOS: interrupts that call kernel functions must have a priority numerically equal to or greater than the kernel's configured maximum syscall priority, because the kernel masks only up to that level in its critical sections. Interrupts default to priority 0, the highest, so an ISR that calls a FromISR function without setting a priority corrupts kernel data at random. A configuration assert catches it if enabled.
Compare the ways of protecting data shared between interrupts and tasks.Senior
I choose the narrowest mechanism that still covers every context touching the data.
- Global disable (PRIMASK): simplest, works everywhere on a single core, but adds its duration to the latency of every interrupt.
- BASEPRI masking: masks only priorities at or below a threshold, so a motor-control interrupt above it keeps running. That interrupt then must not touch the protected data or call the kernel.
- Disabling one interrupt in the NVIC: fine-grained when only one ISR shares the data.
- Scheduler lock: stops task switches but not interrupts, for task-only data.
- Mutex: task-only, can block, and gives priority inheritance, so it suits longer operations.
- Lock-free structures: a single-producer, single-consumer ring buffer with atomics needs no lock at all.
In every case the protected region should be tens of cycles and must never block. On a multi-core part, disabling local interrupts protects nothing from the other core, so you need a spinlock or a hardware semaphore as well.
When do you need DMB, DSB or ISB on a Cortex-M, and what bug does a missing one cause?Senior
DMB orders memory accesses: those before it are observed before those after it. DSB goes further and waits until earlier accesses have completed. ISB flushes the pipeline so that later instructions are fetched with the effects of an earlier system change in force.
The classic bug: an ISR clears its peripheral's interrupt flag as its very last write and returns. The write is still travelling through a write buffer or a slower peripheral bus, so the NVIC still sees the request asserted and runs the handler a second time. Clearing the flag early in the handler, or reading the register back, or a DSB before return, fixes it.
Other places: a DSB before WFI so pending writes finish before sleep; a DSB then ISB after enabling the MPU, changing VTOR or switching stacks through CONTROL; and DMB or DSB between writing a DMA descriptor or buffer and starting the transfer. On a Cortex-M7 with caches, barriers are not enough for DMA: you also need cache clean and invalidate operations or a non-cacheable buffer.
What should an interrupt handler do, and how do you hand the rest of the work to a task?Junior
An ISR should do the minimum that cannot wait: acknowledge the source, grab data that would otherwise be overwritten (a received byte, a captured timer value), timestamp it if needed, and hand off. Everything else, such as parsing, logging and decisions, belongs in a task or the main loop.
Hand-off options, from lightest to heaviest: set an atomic flag the main loop polls; push the data into a lock-free ring buffer; give a semaphore or send a direct task notification using the ISR-safe API, then request a context switch on exit so a woken high-priority task runs immediately; or post to a queue. On embedded Linux the same idea appears as a threaded interrupt or a workqueue.
The reasons are short, predictable latency for other interrupts, no blocking calls in interrupt context, and code that can be unit-tested outside the ISR. I always add an overflow counter wherever the hand-off can fill up, so a drop is visible rather than silent.
RTOS scheduling and real-time design
Interviewers want proof that you can reason about worst-case timing, choose the right synchronisation primitive and size tasks, rather than only knowing the API names of one kernel.
How does a pre-emptive RTOS perform a context switch on a Cortex-M?Mid
The scheduler always runs the highest-priority ready task. When something makes a higher-priority task ready, such as a tick, an ISR giving a semaphore or a task posting to a queue, the kernel sets the PendSV exception pending. PendSV is configured as the lowest-priority exception, so it runs only after every other handler has finished. That guarantees a switch never happens in the middle of an ISR.
When the outgoing task was interrupted, hardware already pushed R0 to R3, R12, LR, PC and xPSR onto its process stack. The PendSV handler pushes the rest, R4 to R11 and the upper floating-point registers if that task used the FPU, saves the stack pointer into the task control block, picks the next task, loads its stack pointer, pops R4 to R11 and returns. The exception return unstacks the remaining registers, and the new task resumes as if it had never stopped.
Tasks of equal priority are rotated at each tick when time slicing is on.
Likely follow-up. Why do tasks run on the process stack while handlers use the main stack?
What is rate-monotonic scheduling and how do you check that a task set will meet its deadlines?Mid
Rate-monotonic assignment gives higher priority to tasks with shorter periods. For independent periodic tasks whose deadlines equal their periods, it is optimal among fixed-priority schemes: if any fixed priority order meets every deadline, this one does. When deadlines are shorter than periods, deadline-monotonic ordering takes its place.
The quick test is the Liu and Layland utilisation bound. The task set is schedulable if total utilisation, the sum of execution time divided by period, is at most n(2^(1/n) − 1), which is about 0.78 for three tasks and approaches 0.69 for many. It is sufficient but not necessary, so a set above the bound may still be fine.
The exact test is response-time analysis: for each task, iterate R = C + B + Σ ceil(R / Tj) × Cj, summed over the higher-priority tasks j, until R stops changing, then compare with the deadline. B is blocking from lower-priority tasks. I show this in the coding section. Earliest-deadline-first can use up to 100% utilisation, but its behaviour under overload is harder to predict, which is why fixed priority dominates in practice.
Compare priority inheritance with the priority ceiling protocol.Senior
Both bound priority inversion, but they act at different times. With priority inheritance, a low-priority task holding a mutex is raised to the priority of the highest task waiting for it, and only while that contention exists. It costs nothing when there is no contention and needs no configuration. But blocking can chain through several mutexes, and it does not prevent deadlock.
With the immediate ceiling protocol, every resource has a ceiling equal to the highest priority of any task that uses it. A task that takes the resource is raised to the ceiling at once. As a result a task can be blocked by at most one lower-priority critical section, and deadlock between resources is impossible. The price is that every user must be known at design time, and the boost happens even when nobody else wants the resource.
In practice: FreeRTOS mutexes use inheritance, OSEK and AUTOSAR use ceilings, and POSIX offers both through the mutex protocol attribute. For a safety-rated design where I must prove the worst-case blocking, I prefer ceilings.
Likely follow-up. How would you include blocking time in a response-time analysis?
When do you use 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, which lets the kernel apply priority inheritance and detect misuse. It cannot be used from an ISR because an ISR cannot block or own anything. Many kernels also offer a recursive mutex that the owner may take more than once.
A binary semaphore is for signalling. It has no owner: an ISR gives it when data arrives and a task takes it to wait. A counting semaphore counts events or free resources, such as free buffers in a pool or bytes queued for transmission.
The classic mistake is using a binary semaphore as a lock. It works in testing, but because there is no owner there is no priority inheritance, so the system becomes open to unbounded priority inversion. For simple ISR-to-task wake-ups, many kernels offer direct task notifications, which are faster and lighter than a semaphore.
How do you choose between a queue, event flags, a task notification and shared memory for inter-task communication?Mid
I start from what is being passed and who owns it.
- Message queue: copies fixed-size items, decouples producer and consumer, and blocks or fails when full. For large data, queue a pointer to a pool block and transfer ownership with it.
- Stream or message buffer: an efficient byte stream between one writer and one reader, such as UART data.
- Event flags: let a task wait for any or all of several conditions, such as "link up and configuration loaded".
- Direct task notification: the lightest way to wake one known task, often with a small value attached.
- Shared memory with a mutex, or double buffering: when only the latest state matters, such as the current setpoint, and queuing old values would be wrong.
For every channel I decide what happens when it is full (block with timeout, drop newest, overwrite oldest) and count drops. An undefined overflow policy is a common source of field bugs.
How can deadlock happen in an RTOS application and how do you prevent it?Mid
Deadlock needs four conditions at once: exclusive resources, tasks holding one resource while waiting for another, no forced release, and a circular wait. The typical case is task A taking the SPI mutex then the flash mutex, while task B takes them in the opposite order.
Prevention breaks one condition. The usual tool is a global lock order that everyone follows, written down and checked in review. Others: take all needed resources in one step, never hold a lock while calling into another module or waiting on a queue, and use the priority ceiling protocol, which rules deadlock out.
Detection matters too. A deadlocked device often looks alive because interrupts and the idle task still run. A task-level watchdog, where each critical task must check in within its period before the hardware watchdog is fed, turns a silent stall into a reset with a logged reason. Taking mutexes with a timeout and logging the failure gives you evidence instead of a frozen device.
How do you size task stacks and catch overflow before it reaches the field?Mid
I combine analysis with measurement. GCC's -fstack-usage gives the frame size of each function, and a call-graph tool adds them along the deepest path, although function pointers and recursion need manual review. Then I measure: the kernel fills each stack with a known pattern at creation, and after a stress test that exercises every path, the high-water mark shows how much was ever used.
On Cortex-M each task stack also has to hold the exception frame that hardware pushes when an interrupt arrives while that task runs: 32 bytes, or about 100 bytes with lazy FPU state. Nested interrupts then run on the main stack, which needs its own sizing for the worst nesting.
The usual stack wreckers are printf with floating-point formats, large local arrays and buffers, and deep library calls. I keep a margin agreed with the team, enable the kernel's overflow check during development, and where the hardware allows, use an MPU guard region or the ARMv8-M stack limit registers so an overflow faults at once.
How do you choose the RTOS tick rate, and what is tickless idle?Mid
The tick sets the resolution of timeouts and delays and the rate of time-slicing. A higher rate gives finer timeouts but costs CPU time and wakes the core more often, which hurts battery life. 1 kHz is a common default. Remember that a delay of one tick can end anywhere from almost zero to one full tick later, depending on when in the tick period it started, so short precise timing belongs to a hardware timer, not the tick.
Tickless idle stops the periodic tick when nothing is ready to run. The kernel works out when the next timeout expires, programs a low-power timer for that moment, puts the core into a sleep mode, and on wake corrects its tick count by the time that passed. Any other interrupt can end the sleep early, so the correction must handle that.
Peripherals and buses: UART, SPI, I2C, CAN and USB
Every embedded engineer brings up peripherals, so interviewers ask about the electrical and timing details that decide whether a bus works on the bench and keeps working in the field.
How much baud rate error can a UART link tolerate, and how can a perfectly configured MCU still be out of tolerance?Mid
A UART receiver resynchronises on the falling edge of each start bit and then samples each bit near its centre, often by oversampling at 16 times the baud rate and taking a majority vote. Timing error accumulates across the frame. For 8N1, the stop bit is sampled about 9.5 bit times after the start edge, and the sample must still fall inside that bit, so the combined mismatch between the two ends must stay below roughly 5% in theory. In practice I keep each end within about 2%, to leave room for edge distortion.
Two sources eat that budget. First, divider rounding: from a 16 MHz clock with 16 times oversampling, 115200 baud needs a divisor of 8.68, which rounds to 9 and gives 111111 baud, an error of about 3.5%. A fractional baud generator, 8 times oversampling or a different crystal fixes it. Second, an internal RC oscillator can drift by a percent or more over temperature, which is why production UARTs, and especially USB or CAN, usually run from a crystal.
What do CPOL and CPHA mean in SPI, and what does a wrong mode look like on real hardware?Junior
CPOL sets the idle level of the clock: 0 idles low, 1 idles high. CPHA sets which edge samples data: with CPHA 0, data is sampled on the first (leading) clock edge after chip select goes active, and with CPHA 1 on the second (trailing) edge. The four combinations are modes 0 to 3; mode 0 is the most common, and many devices support both 0 and 3.
A wrong mode rarely produces obvious garbage. Typical symptoms are values shifted by one bit, so readings look doubled or halved, a register that reads back correctly most of the time, or a link that works at 1 MHz but fails at 10 MHz because the sample point lands near a transition.
I check the timing diagram in the device datasheet, not just the mode number, because vendors draw them differently, then confirm with a logic analyser. While there I check bit order, whether the device needs chip select toggled between bytes or held across a transaction, and the minimum chip-select set-up time.
Likely follow-up. Why might SPI work on the bench and fail with a 30 cm ribbon cable?
How do you choose I2C pull-up resistors, and what are clock stretching and repeated start?Mid
I2C lines are open-drain, so the pull-ups alone produce the rising edges. Too large a resistor, combined with bus capacitance, gives slow rise times that break the timing: the specification allows up to 1000 ns rise time in standard mode and 300 ns in fast mode. Too small a resistor exceeds the 3 mA a device must be able to sink while holding the line below 0.4 V. So the minimum is (VDD − 0.4 V) / 3 mA, about 1 kilohm at 3.3 V, and the maximum comes from the rise-time limit and the measured bus capacitance. Values of 2.2k to 4.7k are typical, but I check the rise time on a scope.
Clock stretching is a slave holding SCL low to pause the master while it prepares data. Some master controllers handle it badly, which is worth checking early. A repeated start is a START issued without a STOP in between. A register read uses it: write the register address, issue a repeated start, then read, so no other master can take the bus in the middle. When reading, the master NACKs the last byte to tell the slave to stop sending.
Explain CAN error handling: error counters, error passive and bus-off. What changes with CAN FD?Mid
Every CAN node detects bit, stuff, CRC, form and acknowledgement errors. Bit stuffing inserts an opposite bit after five identical bits, which keeps receivers synchronised and makes a sixth identical bit a detectable error. A node that detects an error sends an error frame, and the frame is retransmitted automatically.
Each node keeps a transmit and a receive error counter. Errors raise them sharply and successful frames lower them slowly. Below 128 the node is error active and signals errors with dominant bits. At 128 or more it becomes error passive and signals with recessive bits so it cannot disrupt the bus. If the transmit counter exceeds 255, the node goes bus-off and stops participating. It may rejoin only after seeing 128 sequences of 11 recessive bits, and firmware should decide whether recovery is automatic and log every bus-off.
CAN FD allows payloads of up to 64 bytes and an optional faster bit rate for the data phase, with a stronger CRC. A classic controller that does not understand FD frames will flag them as errors, so FD and classic-only nodes cannot share a bus unless the classic nodes are FD-tolerant.
Likely follow-up. What would you expect to measure between CANH and CANL on an unpowered, correctly terminated bus?
How do you receive variable-length UART messages with DMA without an interrupt per byte?Mid
Run the DMA channel in circular mode into a ring buffer and let three events wake the software: the UART idle-line interrupt, which fires when the line has been quiet for one character time after a burst, and the DMA half-transfer and transfer-complete interrupts. On any of them, compute the current write position from the DMA's remaining-count register, process the bytes between the last read position and that position, handling the wrap, and store the new read position.
This handles any message length, keeps the CPU free during reception, and bounds latency to one idle time.
Things to get right: the buffer must hold all data that can arrive during the worst-case delay before processing; overrun and framing error flags must still be checked and cleared, or reception can stop; and on a core with a data cache, such as a Cortex-M7, the buffer must be in non-cacheable memory or invalidated, cache-line aligned, before the CPU reads it. Otherwise the CPU reads stale bytes from cache that look like corrupted data.
What does an embedded engineer need to know about USB to bring up a device?Mid
USB is host-driven: the device never transmits on its own initiative, it answers when the host polls it. Data flows through endpoints. Endpoint 0 is the mandatory control endpoint; others are IN or OUT, always named from the host's point of view. There are four transfer types: control for set-up and commands, bulk for large reliable data with no bandwidth guarantee, interrupt for small data polled at a bounded interval, and isochronous for streaming with reserved bandwidth but no retries.
Enumeration: the host resets the device, reads the device descriptor, assigns an address, reads the configuration, interface and endpoint descriptors, selects a configuration and loads a driver by class or by vendor and product ID. Using a standard class such as CDC ACM for a virtual serial port, HID or mass storage means no custom host driver.
How do you decide between polling, interrupts and DMA for a peripheral?Mid
I work out events per second and the cost per event. Polling is simplest and deterministic, and is the right choice when the wait is shorter than interrupt overhead. An SPI byte at 20 MHz takes 400 ns, which is less than entering and leaving an ISR on many parts, so polling the flag is faster. It is also fine in a super-loop for slow inputs.
Interrupts suit sporadic, low-rate events, such as a button or a received command, because the CPU does nothing until something happens. They become expensive at high rates: a UART at 1 Mbaud produces about 100,000 byte interrupts per second, which can consume a large share of a small core.
DMA suits bulk transfers: ADC sample streams, display updates, high-rate serial data. The CPU is free during the transfer, but there is set-up cost, a limited number of channels, request mappings to plan, and cache coherence to manage on cached cores. A common mix is DMA for data and an interrupt only at the end of a block.
How does a timer generate PWM, and what decides its resolution?Junior
The timer counter runs from 0 up to an auto-reload value and wraps. A compare register sets the duty cycle: the output is high while the counter is below the compare value and low after it, or the reverse. The PWM frequency is the timer clock divided by (prescaler + 1) × (auto-reload + 1), and the resolution is the number of counter steps per period.
So frequency and resolution trade against each other. With a 72 MHz timer clock and no prescaler, 20 kHz PWM gives an auto-reload of 3599, so 3600 steps, a little under 12 bits. Doubling the frequency halves the steps.
Details interviewers listen for: enable the preload (shadow) registers so a new duty value takes effect at the period boundary and never produces a glitch; use centre-aligned mode for motor control to reduce harmonics and give a natural point to trigger the ADC; and insert dead time on complementary outputs so both switches of a half-bridge are never on together. Input capture is the mirror image: the timer latches the counter on an edge, measuring periods without interrupt jitter.
Embedded Linux
Larger devices run Linux, and interviewers check that you understand the boot chain, the build system, the device tree and how user-space code reaches hardware, and whether you know when Linux is the wrong tool.
Describe the boot sequence of a typical embedded Linux board from power-on to your application.Mid
The SoC's boot ROM runs first. It reads boot-mode pins or fuses to choose a boot device and loads a small first-stage loader, often the U-Boot SPL, into on-chip SRAM, because DRAM is not working yet. That stage initialises the DRAM controller and loads the full bootloader, on many 64-bit Arm parts together with trusted firmware.
U-Boot then loads the kernel image, the device tree blob and optionally an initramfs into RAM, sets the kernel command line and jumps to the kernel. The kernel initialises its subsystems, uses the device tree to probe drivers, mounts the root file system named by the root= parameter, and starts PID 1, which is systemd, BusyBox init or a small custom init. Init starts services, and finally the application.
Likely follow-up. How would you reduce boot time to under two seconds?
Yocto or Buildroot: how do you choose?Mid
Buildroot is a set of makefiles with a Kconfig menu. It builds a toolchain, kernel, bootloader and root file system image quickly, is easy to understand completely, and suits a single product with a small team. It has no package manager by default, and some configuration changes need a clean rebuild.
Yocto, through BitBake recipes and layers, has a steeper learning curve and a longer first build, but it scales better: layers separate the silicon vendor's support, your board and your product; the shared-state cache makes rebuilds incremental; it generates an SDK for application developers, package feeds and licence manifests. Most silicon vendors ship their board support as Yocto layers, which often settles the question.
My rule: Buildroot for a small, stable product or a quick prototype, Yocto for a product family or a long maintenance life with several teams. With either, I pin every source revision so a build from two years ago can be reproduced exactly when a security fix is needed.
What is the device tree, and how does a driver find its hardware through it?Mid
The device tree describes hardware that cannot be discovered by probing: memory-mapped peripherals, their interrupts, clocks, GPIOs and the devices sitting on buses such as I2C and SPI. It lets one kernel binary support many boards. Source files (.dts and included .dtsi) are compiled with dtc into a blob that the bootloader passes to the kernel.
&i2c1 {
status = "okay";
temp-sensor@48 {
compatible = "ti,tmp102";
reg = <0x48>;
};
};
Each driver lists the compatible strings it supports. When the kernel finds a node whose string matches, it calls that driver's probe function, which reads properties such as reg, interrupts and GPIO references. Overlays modify the tree at boot for add-on boards.
The bug I see most often: a peripheral node in the SoC's .dtsi is status = "disabled" and the board file never sets it to "okay", so the driver never probes and nothing logs an error.
How do you talk to GPIO, I2C and SPI devices from user space, and when do you write a kernel driver instead?Mid
For GPIO, use the character device interface, /dev/gpiochipN, through libgpiod. The old sysfs GPIO interface is deprecated. For I2C, open /dev/i2c-N and use the I2C_RDWR ioctl for combined transactions. For SPI, the spidev driver gives /dev/spidevB.C. For a custom memory-mapped block, UIO maps its registers into the process and delivers interrupts through a blocking read(). Mapping /dev/mem works for quick experiments but is a security hole in a product.
User-space drivers are easy to develop and debug, and a crash does not take the kernel down. I write a kernel driver when the device should appear through a standard subsystem (IIO for sensors, input, hwmon, network) so existing tools work; when interrupt latency or DMA demands it; when several processes must share the device safely; or when it must take part in suspend and resume. That is the boundary with the Device Driver Developer role.
How do you make a Linux system behave predictably enough for a real-time control loop?Senior
Start with a kernel built with PREEMPT_RT, which makes most of the kernel pre-emptible and turns interrupt handlers into threads with priorities. Then the application has to cooperate:
- Run the control thread under
SCHED_FIFOat a priority above other work. - Call
mlockall(MCL_CURRENT | MCL_FUTURE)and pre-fault the stack and buffers so a page fault never happens in the loop. - Allocate everything before the loop starts and avoid blocking system calls and logging inside it.
- Sleep with
clock_nanosleeponCLOCK_MONOTONICwith an absolute deadline, so jitter does not accumulate. - Isolate a CPU for the thread, steer interrupt affinity away from it, and fix the CPU frequency and idle states.
Then measure the worst-case wake-up latency with a tool such as cyclictest while the system is under heavy CPU, memory, network and storage load, for hours rather than minutes. If the worst case does not fit the deadline, the tight loop belongs on a microcontroller or a real-time core beside Linux, with Linux handling the user interface and connectivity.
How do you design storage and updates so an embedded Linux device survives power loss at any moment?Senior
Assume power can vanish during any write. The root file system should be read-only, for example SquashFS, so it can never be corrupted. Writable data goes on a separate partition with a power-safe file system: ext4 with journalling or F2FS on eMMC, or UBIFS on top of UBI on raw NAND, where UBI also handles bad blocks and wear levelling. If software expects a writable root, an overlay file system puts a writable layer on top.
Updates use two root partitions, A and B. Write the new image to the inactive one, verify it, switch the boot target, and have the bootloader count boot attempts. If the new system does not confirm it is healthy within a few attempts, the bootloader falls back to the old one.
Files are replaced atomically: write a temporary file, fsync it, rename it over the old one, then fsync the directory. Logs go to RAM or are rate-limited, because flash wears out. And I test all of it with a rig that cuts power at random points thousands of times.
How does a platform driver get matched and probed, and what is deferred probing?Mid
A platform driver registers itself with a table of compatible strings. When the kernel creates a platform device from a device tree node whose compatible matches, the driver core calls the driver's probe function with that device.
Probe gathers resources: maps the registers with devm_platform_ioremap_resource, requests the interrupt, gets clocks, regulators and GPIOs, enables them and registers the device with its subsystem. The devm_ helpers tie each resource to the device's lifetime, so they are released automatically if probe fails half-way or the device is removed. That removes a whole class of error-path leaks.
If a dependency such as a clock controller or a GPIO expander has not probed yet, getting it returns -EPROBE_DEFER. The driver returns that code, and the core retries probing later, after other drivers have bound. A driver that is permanently deferred usually means a missing or disabled dependency in the device tree, and the kernel's list of deferred devices in debugfs shows which one.
Power, safety and reliability
Products run for years on batteries or in safety-relevant roles, so interviewers test low-power design, data integrity across power loss, fault handling and the safety mindset.
Describe the typical low-power modes of a microcontroller and what you lose in each.Mid
Names differ between vendors, but there are usually three tiers. In sleep, the CPU clock stops while peripherals keep running, any enabled interrupt wakes the core within a few cycles, and nothing is lost. In stop or deep sleep, most clocks stop, including the PLL, but RAM and registers keep their contents. Wake-up comes from external interrupts, the RTC or low-power peripherals, and takes microseconds, after which firmware usually has to restart the PLL and restore the system clock. In standby or shutdown, the core domain is powered off. RAM is lost apart from a small backup area, and wake-up looks like a reset with a flag telling you why.
The CPU enters these with WFI or WFE after the sleep configuration bits are set. Things that keep current high in practice: a peripheral clock left on, floating pins, an LED or pull-up in the wrong state, the debug interface keeping domains powered, and a 1 kHz tick waking the core a thousand times a second.
A sensor wakes every 60 seconds, works for 50 ms at 20 mA, transmits for 10 ms at 30 mA and sleeps at 5 µA. Estimate its life on a 1000 mAh battery.Mid
Compute the charge per cycle and divide by the period.
- Active: 20 mA × 0.05 s = 1.0 mAs.
- Radio: 30 mA × 0.01 s = 0.3 mAs.
- Sleep: 0.005 mA × 59.94 s ≈ 0.3 mAs.
Total is about 1.6 mAs per 60 s, an average of roughly 27 µA. 1000 mAh / 0.027 mA ≈ 37,000 hours, a little over four years, before derating.
The answer interviewers want continues: the active phase dominates, so the biggest gain is shortening it, for example by starting the sensor conversion earlier or using DMA and sleeping while waiting. Then derate. Real cells lose capacity to self-discharge, low temperature and high pulse currents, and the device stops at a cut-off voltage before the cell is empty, so I would plan with a substantial margin. Finally, I would measure the real profile with a current analyser that captures microamps and milliamps in one trace, because a multimeter averages away the spikes.
Likely follow-up. What changes if the radio sometimes needs three retries?
What is brown-out, and how do you protect flash writes from it?Mid
Brown-out is the supply sagging below the voltage at which the chip is guaranteed to work, without going to zero. During that window the CPU can execute instructions incorrectly, and a flash program or erase in progress can leave corrupted data. A slowly discharging battery or a sagging supply during a motor start produces exactly this.
The brown-out reset holds the MCU in reset below a threshold. I set it above the minimum operating voltage and above the voltage needed for reliable flash writes, rather than leaving it at the lowest setting. Many parts also have a programmable voltage detector that raises an interrupt earlier, giving time to finish or abort a write and enter a safe state.
In the design: check the supply before starting a long erase, size the hold-up capacitance so an in-progress page write can complete, and, most importantly, use a storage format that tolerates an interrupted write, with a CRC and sequence number per record, so the worst outcome is losing the newest update rather than all configuration.
Design a power-fail-safe scheme for storing configuration in internal flash.Senior
Never erase the only valid copy. I use two flash sectors, or a log of records across several, each record carrying a header: a magic value, a format version, a sequence number, the length and a CRC over everything.
To save, write the new record to the inactive sector, read it back and check the CRC. Only then is it considered valid, and it wins because its sequence number is higher. The old copy stays intact until the next save erases its sector. At boot, scan both, discard any with a bad magic or CRC, and choose the valid one with the higher sequence, comparing sequence numbers so that wrap-around is handled. If neither is valid, load safe defaults and record the event.
For wear, append records to a sector until it is full before erasing, rather than erasing on every save, and check the endurance figure in the datasheet against the expected save rate over the product's life. The version field allows migration when the configuration structure changes in a firmware update. The test is a rig that cuts power at random points during saves, thousands of times, then checks that a valid configuration always loads.
How do you detect stack overflow on a microcontroller?Mid
Layer several defences, because the silent version, where the stack grows into .bss and corrupts variables, is one of the hardest bugs to trace.
- Pattern and watermark: fill stacks with a known value at start-up and check how far it has been overwritten. Cheap, good for sizing, but detects overflow only after the fact.
- Canary check: an RTOS can check the last words of a task stack at every context switch and call a hook. It catches most overflows, but not one that jumps over the canary with a large local array.
- MPU guard region: a small no-access region at the stack limit makes the overflowing access fault at once, at the exact instruction.
- Stack limit registers: ARMv8-M cores have MSPLIM and PSPLIM, which fault when the stack pointer passes a limit.
On a bare-metal system, a placement trick helps: put the main stack at the bottom of RAM, growing down towards the start of RAM, so an overflow runs into unmapped memory and faults instead of corrupting data.
What does functional safety mean for an embedded engineer day to day?Senior
Functional safety means the system stays safe when something fails, including the software. It starts with a hazard analysis, which produces safety goals and an integrity level: SIL under IEC 61508, ASIL A to D under ISO 26262 for road vehicles, with IEC 62304 for medical software and DO-178C for airborne software. The level decides how much rigour the process demands.
Day to day that means:
- a defined safe state, such as motor off or valve closed, reachable from any fault;
- diagnostics: RAM and flash tests at start-up and periodically, clock monitoring, a windowed watchdog, and program-flow monitoring that checks tasks run in the expected order;
- plausibility checks on inputs: range, rate of change and agreement between redundant sensors;
- independence where required: a second channel or a separate monitor MCU;
- traceability from each requirement to code and tests, and structural coverage, up to MC/DC at the highest levels.
Even outside certified projects, I apply the safe-state and plausibility habits to anything that moves or heats.
Your device hits a HardFault. How do you find out what caused it?Mid
Write a fault handler that finds the stacked exception frame. A short assembly stub tests bit 2 of EXC_RETURN in LR to see whether the main or process stack was in use, and passes that stack pointer to a C function. From the frame, read the stacked PC, which points at or just after the faulting instruction, and the stacked LR, which shows the caller.
Then read the fault status registers. The configurable fault status register says which kind: a precise bus error with a valid bus fault address (a bad pointer), an imprecise bus error (a buffered write whose address is lost), an undefined instruction, an invalid state fault (often a call through a null or corrupt function pointer with the Thumb bit clear), an unaligned access or a divide by zero if trapping is enabled. The HardFault status register shows whether a configurable fault was escalated.
For an imprecise fault on a Cortex-M3 or M4, disabling the write buffer while debugging makes it precise. In the product, the handler saves the registers to a no-init RAM area and resets, and the next boot reports them, so field faults become a PC address I can resolve with addr2line.
Debugging, testing, tooling and device design
Senior rounds focus on how you find bugs, prove firmware works, control image size and timing, and make the early hardware and architecture decisions for a new product.
What can you do with SWD, JTAG and trace, and what should you be careful about when halting a running system?Junior
JTAG is the IEEE 1149.1 interface with four or five signals, boundary scan for board testing, and daisy-chaining of several chips. SWD is Arm's two-wire debug interface for Cortex-M, with the same debug features and fewer pins. Through either, a probe can halt the core, read and write memory and registers, flash the part and set breakpoints and watchpoints.
Hardware breakpoints are limited to a handful of comparators, which matters for code in flash. Data watchpoints from the DWT unit are the best tool for "who is corrupting this variable": the core halts on the write that does it. SWO adds a single trace pin that streams printf-style output through the ITM, interrupt events and periodic PC samples. A RAM ring buffer read by the probe in the background gives fast logging without a UART.
The caution: halting the core does not halt the outside world. A motor controller stopped at a breakpoint with PWM outputs active can destroy hardware. Configure timers to freeze or force outputs safe in debug halt, and prefer non-halting tools on live machinery.
What is hardware-in-the-loop testing, and what would you put in a HIL rig for a motor controller?Mid
HIL testing runs the real firmware on the real board while a rig simulates the environment around it and checks the outputs, under control of a test script on a host. It sits above host-based unit tests, which check logic, and pure simulation, which checks algorithms, and catches the problems those miss: timing, peripheral configuration and integration.
For a motor controller I would include: a programmable supply that can sag and cut out, simulated encoder or Hall signals, a load or a small motor on a dynamometer, a CAN interface to send commands and inject corrupt or missing frames, and measurement of the PWM outputs and phase currents. Fault injection is where the value lies: disconnect a sensor, short a phase input, stop the command stream, and check that the controller reaches its safe state within the required time.
The rig flashes every merged build and runs a suite in CI. The hard part is keeping tests deterministic and the rig maintained, so tests check behaviour with tolerances and log enough data to diagnose a failure without rerunning.
A bug disappears when you add a printf or build with -O0. What are the likely causes?Mid
A bug that moves when timing or code layout changes points to a short list:
- a missing
volatile, so at-O2the compiler keeps a polled flag in a register; - undefined behaviour that the optimiser exploits only at higher levels;
- an uninitialised local whose stack contents differ with each build;
- a race between an ISR and the main loop, where printf's delay changes the interleaving;
- stack overflow, since printf and optimisation change stack depth;
- a buffer overrun that hits a different neighbour when the layout changes;
- missing barriers or cache maintenance around DMA.
The approach is to stop disturbing the system. Keep the failing build and use tools that do not change timing: a DWT watchpoint on the corrupted variable, a trace buffer in RAM, GPIO toggles on a logic analyser. In parallel, turn up warnings, run a static analyser, build the logic on the host with sanitisers, and compare map files between the good and bad builds to see what moved.
What must a bootloader do before jumping to the application on a Cortex-M?Senior
First validate the application: check a header with magic number, length and version, verify a CRC or, better, a signature, and refuse to jump to an invalid image. Then leave the hardware as close to reset state as possible: disable and clear every interrupt the bootloader enabled, stop SysTick, de-initialise peripherals and DMA it used, and restore the default clock if the application expects it.
Then the jump itself: set VTOR to the application's vector table, load the main stack pointer from the first word of that table, and branch to the reset handler address in the second word. The application must be linked at its own flash offset, and its start-up code must not reset VTOR back to the start of flash.
The common bugs: an interrupt left enabled by the bootloader fires before the application has set up its handler; the bootloader jumps from inside an ISR, so the application starts in Handler mode; and a shared RAM area for boot flags is overwritten because the two images have different memory layouts. I reserve a small no-init section at a fixed address in both linker scripts for that hand-over.
What is in a linker script and a map file, and why should a firmware engineer read them?Mid
The linker script defines the memory regions, flash and RAM with their start addresses and sizes, and places each output section into them: the vector table at the start of flash, then .text and .rodata, .data in RAM with its load address in flash, then .bss, heap and stack. It also defines the symbols the start-up code uses, such as the start and end of .data and .bss and the initial stack pointer.
I edit it to put a function in RAM so it can run while flash is being erased, to create a no-init section that survives a soft reset for crash data, to place DMA buffers in a RAM bank the DMA can reach, or to align a buffer for the cache.
The map file is the linker's report: the address and size of every symbol, and which object file or library member pulled each one in. It is the first place to look when the image grows, for example when one call to printf with %f pulls in floating-point formatting code, and to confirm a buffer really landed in the intended region.
The image is 2 KB over the flash size the week before release. What do you do?Mid
Measure before changing anything. size gives section totals, and the map file or nm --size-sort lists the largest symbols. The answer is usually a few big items, not many small ones.
Typical wins, in the order I try them:
- Check the optimisation level and link-time optimisation, and that unused sections are garbage-collected (
-ffunction-sections,-fdata-sections,--gc-sections). - Remove floating-point support from
printfor use a smaller formatter. - Replace assert messages and file-name strings with numeric codes in release builds.
- Check for accidental library code: software division on a core without a divide instruction, C++ unwind tables, iostream.
- Shrink large tables with smaller types, or compute them instead.
- Disable vendor library modules that are linked in but unused.
What I would not do is remove safety checks or error handling to make it fit. If the margin is that thin, I also raise the risk with the team, because the next feature or security fix will not fit either.
How do you measure CPU load and the execution time of a function on a microcontroller?Mid
For individual code paths, the DWT cycle counter on Cortex-M3 and above is exact and cheap:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
uint32_t t0 = DWT->CYCCNT;
filter_step();
uint32_t cycles = DWT->CYCCNT - t0; /* unsigned subtraction survives wrap */
On some cores the DWT lock access register must be written before it will count. For ISR durations, a GPIO set at entry and cleared at exit, viewed on a scope with persistence, shows both the typical and worst case.
For overall load, RTOS run-time statistics give the share of time in each task, and load is the complement of time in the idle task. A trace tool that records every task switch and interrupt shows where the busy periods are.
How would you choose the microcontroller for a new product?Senior
I start from requirements and work through a short list:
- Peripherals: the number and type needed, such as CAN FD channels, high-speed USB, ADC resolution and sample rate, advanced timers for motor control, and whether the pin multiplexing lets them all coexist on one package.
- Compute and memory: an estimate of cycles for the heaviest workload, plus headroom for features, and enough flash for two images if updates must be fail-safe.
- Power: current in each sleep mode with the needed peripherals running, and wake-up time.
- Environment: temperature range and qualification, such as automotive grade, and EMC behaviour.
- Security: secure boot, key storage, crypto acceleration, debug port locking.
- Supply and lifecycle: second sources, longevity commitments and current lead times.
- Ecosystem: toolchain, RTOS support, the vendor's libraries and the team's experience.
I prefer a family with pin-compatible larger and smaller members, so a memory shortfall late in the project is a part change rather than a board redesign, and I prototype the riskiest peripheral on a development board before committing.
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 and write a register bit fieldEasy · C
Write helpers to read and update a bit field inside a 32-bit register value, given the field's lowest bit position and width. Then write a function that updates one field of a memory-mapped register with a single read and a single write. Widths from 1 to 32 must work. For example, setting a 3-bit field at bit 4 to 0 in
0xFFFFFFFFgives0xFFFFFF8F.Hints
- Build the mask from the width first, then shift it into place.
- Think about what 1u << 32 does before you write it.
Solution and explanation
#include <stdint.h> /* Field described by its least significant bit position and its width. */ static inline uint32_t field_mask(unsigned lsb, unsigned width) { uint32_t ones = (width >= 32u) ? 0xFFFFFFFFu : ((1u << width) - 1u); return ones << lsb; } static inline uint32_t field_get(uint32_t reg, unsigned lsb, unsigned width) { return (reg & field_mask(lsb, width)) >> lsb; } /* Returns the new register value; the caller writes it back once. */ static inline uint32_t field_set(uint32_t reg, unsigned lsb, unsigned width, uint32_t value) { uint32_t mask = field_mask(lsb, width); return (reg & ~mask) | ((value << lsb) & mask); } /* Read-modify-write on a memory-mapped register: one read, one write. */ static inline void reg_write_field(volatile uint32_t *reg, unsigned lsb, unsigned width, uint32_t value) { *reg = field_set(*reg, lsb, width, value); }The mask is built as width ones shifted to the field position. The width of 32 is a special case:
1u << 32is undefined behaviour in C, not zero, so the code returns all ones directly. That edge case is what the interviewer is waiting for.field_setclears the field with the inverted mask and ORs in the new value, masked so a value that is too wide cannot spill into neighbouring fields. Separating the pure functions from the register access makes them unit-testable on a PC, andreg_write_fielddoes exactly one volatile read and one volatile write, so the register sees no intermediate value.Things to mention: this read-modify-write is not atomic, so if an ISR touches the same register, wrap it in a critical section or use the peripheral's set and clear registers. Some registers have write-one-to-clear bits, where a read-modify-write clears status flags you did not intend to touch; for those, never use this helper. Everything is unsigned to avoid sign-extension and shift traps.
- CRC-16/CCITT-FALSE, bitwise and table-drivenMedium · C
Implement CRC-16/CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR) to protect frames on a serial link. The function must support streaming, so a frame can be processed in chunks. Then add a faster table-driven version. The check value for the ASCII string "123456789" is
0x29B1.Hints
- Process one byte at a time: XOR it into the top of the CRC, then shift eight times.
- A 256-entry table can be built from the bitwise version.
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 the ASCII string "123456789" is 0x29B1. */ uint16_t crc16_ccitt_update(uint16_t crc, const uint8_t *data, size_t len) { for (size_t i = 0; i < len; i++) { crc ^= (uint16_t)((uint16_t)data[i] << 8); for (int bit = 0; bit < 8; bit++) { if (crc & 0x8000u) crc = (uint16_t)((crc << 1) ^ 0x1021u); else crc = (uint16_t)(crc << 1); } } return crc; } /* Table version: 512 bytes of flash, one lookup per byte. */ static uint16_t crc_table[256]; void crc16_ccitt_init_table(void) { for (unsigned n = 0; n < 256; n++) { uint8_t b = (uint8_t)n; crc_table[n] = crc16_ccitt_update(0, &b, 1); } } uint16_t crc16_ccitt_fast(uint16_t crc, const uint8_t *data, size_t len) { while (len--) crc = (uint16_t)((crc << 8) ^ crc_table[(uint8_t)((crc >> 8) ^ *data++)]); return crc; }The bitwise version XORs each byte into the high byte of the CRC, then shifts left eight times, XORing in the polynomial whenever a 1 falls off the top. Passing the running CRC in and out makes it streamable: process a header now and the payload later, and the result is identical.
The table version precomputes the effect of eight shifts for each possible top byte, so each input byte costs one lookup, one shift and two XORs. That is several times faster for 512 bytes of table. On a small part, I would generate the table at build time and store it as
constso it lives in flash, or use a 16-entry nibble table as a middle ground.What interviewers check: you know the parameters that define a CRC variant (width, polynomial, initial value, reflection, final XOR) and that a mismatch in any one of them gives a different result; you verify with the standard check value; and you keep intermediate values in
uint16_twith explicit casts, since promotion tointotherwise leaves stray high bits. Many MCUs also have a CRC peripheral, which is worth mentioning. - Lock-free ring buffer between an ISR and a taskMedium · C
Write a byte ring buffer for a UART: the receive interrupt pushes bytes, a task pops them. There is exactly one producer and one consumer. It must not disable interrupts, must report full rather than overwrite, and must use the whole capacity, so a buffer of 64 holds 64 bytes. Use C11 atomics.
Hints
- Let head and tail count up forever and mask them only when indexing.
- Each index is written by exactly one side.
Solution and explanation
#include <stdatomic.h> #include <stdbool.h> #include <stdint.h> /* Single producer (e.g. UART RX ISR), single consumer (task). Capacity must be a power of two. Indices run freely and wrap at 2^32; head - tail is always the fill level. */ #define RB_SIZE 64u _Static_assert((RB_SIZE & (RB_SIZE - 1u)) == 0u, "RB_SIZE must be a power of two"); struct ringbuf { _Atomic uint32_t head; /* written only by the producer */ _Atomic uint32_t tail; /* written only by the consumer */ uint8_t data[RB_SIZE]; }; bool rb_push(struct ringbuf *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) return false; /* full: caller counts overruns */ rb->data[head & (RB_SIZE - 1u)] = byte; /* Release: the data write is visible before the new head. */ atomic_store_explicit(&rb->head, head + 1u, memory_order_release); return true; } bool rb_pop(struct ringbuf *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->data[tail & (RB_SIZE - 1u)]; atomic_store_explicit(&rb->tail, tail + 1u, memory_order_release); return true; } uint32_t rb_count(struct ringbuf *rb) { return atomic_load_explicit(&rb->head, memory_order_acquire) - atomic_load_explicit(&rb->tail, memory_order_acquire); }The head is written only by the producer and the tail only by the consumer, so no location has two writers and no lock is needed. Both indices count up without bound and wrap naturally at 2^32. Because the capacity is a power of two, it divides 2^32, so
head - tailin unsigned arithmetic is always the fill level, even across the wrap. That is how the buffer uses all 64 slots, where the classic "leave one slot empty" design wastes one.The memory orders carry the correctness. The producer writes the data byte and then publishes the new head with release ordering; the consumer reads the head with acquire ordering before reading the byte. That guarantees the consumer never sees the new head before the data. The same pairing in the other direction stops the producer overwriting a slot before the consumer has read it. On a single-core Cortex-M these mostly compile to plain loads and stores with barriers where needed.
Mention that a full buffer should increment an overrun counter, that this design is only correct for one producer and one consumer, and that indices must be 32-bit on a 32-bit core so each access is atomic.
- Fixed-block memory poolMedium · C
Implement a memory pool of 16 blocks of 48 bytes for message buffers, with allocate and free in constant time, no heap and no fragmentation. Blocks must be aligned for any type.
pool_freemust reject pointers that did not come from the pool. Also report how many blocks are free.Hints
- Store the free-list link inside the free blocks themselves.
- A union can give the block both its size and its alignment.
Solution and explanation
#include <stddef.h> #include <stdint.h> #include <stdalign.h> /* Fixed-block pool: O(1) alloc and free, no fragmentation, no heap. Free blocks are chained through their own first bytes. */ #define BLOCK_SIZE 48u #define BLOCK_COUNT 16u union block { union block *next; max_align_t align; /* every block suitably aligned */ unsigned char bytes[BLOCK_SIZE]; }; static union block pool[BLOCK_COUNT]; static union block *free_list; static size_t free_count; void pool_init(void) { free_list = NULL; for (size_t i = BLOCK_COUNT; i-- > 0; ) { pool[i].next = free_list; free_list = &pool[i]; } free_count = BLOCK_COUNT; } void *pool_alloc(void) { /* In firmware: disable interrupts or take a lock around this. */ union block *b = free_list; if (b != NULL) { free_list = b->next; free_count--; } return b; } int pool_free(void *p) { uintptr_t a = (uintptr_t)p, base = (uintptr_t)pool; if (p == NULL) return 0; if (a < base || a >= base + sizeof pool || (a - base) % sizeof(union block) != 0) return -1; /* not one of ours */ union block *b = p; b->next = free_list; free_list = b; free_count++; return 0; } size_t pool_available(void) { return free_count; }Free blocks form a singly linked list, and the link pointer is stored inside the free block itself, so the pool needs no extra memory per block. Allocation pops the head and free pushes onto it, both O(1). Because every block is the same size, the pool cannot fragment: any free block satisfies any request.
The union with
max_align_tmakes each block aligned for any type and makes its size a multiple of that alignment, so the array elements stay aligned too.pool_freechecks that the pointer lies inside the pool and on a block boundary, which catches the common bugs of freeing a pointer into the middle of a block or from another allocator.What interviewers want to hear next: in firmware, allocate and free must be protected by a short critical section if ISRs or several tasks use the pool; a double free is not detected here, and a debug build could add a per-block "in use" bitmap to catch it; the free count gives a low-water mark for sizing; and for several message sizes, use several pools with different block sizes rather than one general heap.
- Byte-stream frame parser state machineMedium · C
A device sends frames over UART: a start byte
0xA5, a length byte (0 to 32), the payload, and a checksum equal to the XOR of the length and payload bytes. Write a parser fed one byte at a time from the receive path that calls a callback for each valid frame, counts bad frames, recovers from noise and lost bytes, and never overruns its buffer.Hints
- Model it as explicit states: waiting for start, length, payload, checksum.
- Decide what happens when the length byte is invalid, and when bytes stop arriving mid-frame.
Solution and explanation
#include <stdint.h> #include <stddef.h> /* Frame: 0xA5 | len (0..MAX_PAYLOAD) | payload[len] | xor of len and payload */ #define SOF 0xA5u #define MAX_PAYLOAD 32u typedef void (*frame_cb)(const uint8_t *payload, uint8_t len); enum pstate { WAIT_SOF, WAIT_LEN, IN_PAYLOAD, WAIT_CHK }; struct parser { enum pstate st; uint8_t len, idx, chk; uint8_t buf[MAX_PAYLOAD]; frame_cb on_frame; uint32_t bad_frames; }; void parser_init(struct parser *p, frame_cb cb) { p->st = WAIT_SOF; p->on_frame = cb; p->bad_frames = 0; } /* Call from the inter-byte timeout so a lost byte cannot wedge the parser. */ void parser_reset(struct parser *p) { p->st = WAIT_SOF; } void parser_feed(struct parser *p, uint8_t b) { switch (p->st) { case WAIT_SOF: if (b == SOF) p->st = WAIT_LEN; break; case WAIT_LEN: if (b > MAX_PAYLOAD) { p->bad_frames++; p->st = (b == SOF) ? WAIT_LEN : WAIT_SOF; /* resynchronise */ break; } p->len = b; p->idx = 0; p->chk = b; p->st = (b == 0u) ? WAIT_CHK : IN_PAYLOAD; break; case IN_PAYLOAD: p->buf[p->idx++] = b; p->chk ^= b; if (p->idx == p->len) p->st = WAIT_CHK; break; case WAIT_CHK: if (b == p->chk) p->on_frame(p->buf, p->len); else p->bad_frames++; p->st = WAIT_SOF; break; } }An explicit state machine makes every case visible: each byte is handled according to the current state, with no hidden buffering. The length is validated before any payload byte is stored, so a corrupted length can never write past the end of the buffer, which is the most important property in an interview answer.
Recovery is designed in. Any error returns to waiting for a start byte, and if the invalid length byte is itself a start byte, the parser treats it as the start of a new frame, which helps resynchronise faster. A start byte inside a payload is fine because the length field, not the start byte, decides where the frame ends.
The remaining weakness is a lost byte in the middle of a frame: the parser would wait for bytes that belong to the next frame.
parser_resetis meant to be called from an inter-byte timeout, such as the UART idle-line interrupt, which bounds the damage to one frame. - Wrap-safe software timers on a 32-bit tickMedium · C
A SysTick interrupt increments a 32-bit millisecond counter, which wraps after about 49.7 days. Write a deadline comparison that works across the wrap, and a set of software timers (one-shot and periodic) polled from the main loop. Periodic timers must not drift when the loop is late.
Hints
- Subtract first, then interpret the difference as signed.
- Schedule the next period from the previous deadline, not from now.
Solution and explanation
#include <stdint.h> #include <stdbool.h> #include <stddef.h> /* 32-bit millisecond tick from a SysTick ISR. It wraps after about 49.7 days. */ extern volatile uint32_t g_ticks; /* True once 'now' has reached 'deadline', correct across wrap as long as the two are less than 2^31 ticks apart. */ static inline bool tick_reached(uint32_t now, uint32_t deadline) { return (int32_t)(now - deadline) >= 0; } struct soft_timer { uint32_t deadline; uint32_t period; /* 0 = one-shot */ bool active; void (*fn)(void *arg); void *arg; }; void timer_start(struct soft_timer *t, uint32_t delay, uint32_t period) { t->deadline = g_ticks + delay; /* unsigned addition wraps by design */ t->period = period; t->active = true; } /* Called from the main loop or a low-priority task, never from the ISR. */ void timers_poll(struct soft_timer *timers, size_t n) { uint32_t now = g_ticks; /* one 32-bit read: atomic on a 32-bit MCU */ for (size_t i = 0; i < n; i++) { struct soft_timer *t = &timers[i]; if (!t->active || !tick_reached(now, t->deadline)) continue; if (t->period != 0u) t->deadline += t->period; /* no drift: based on the old deadline */ else t->active = false; t->fn(t->arg); } }The comparison
(int32_t)(now - deadline) >= 0is the core of the exercise. Unsigned subtraction wraps modulo 2^32, so the difference is correct even when the counter has wrapped between the two values, and viewing it as signed tells you which one is ahead, as long as they are less than 2^31 ticks apart, about 24.8 days. The naivenow >= deadlinefails at the wrap, which is the classic bug that appears only after weeks of uptime.Periodic timers advance the deadline by the period from the previous deadline, so if the loop is late one time, the next expiry is not pushed back and there is no long-term drift. If the loop could fall more than a whole period behind, a real implementation would decide whether to catch up or skip missed periods.
The tick is read once per poll, which is atomic for an aligned 32-bit value on a 32-bit core, and callbacks run in the main loop, not in the ISR. For testing, interviewers like hearing that you start the tick counter just below the wrap point in a test build, so wrap bugs appear in seconds instead of weeks.
- Q15 fixed-point FIR filter with saturationMedium · C
An MCU without a floating-point unit must low-pass filter ADC samples. Implement Q15 multiply and add with saturation, and a FIR filter that keeps a circular history of the last N inputs, rounds correctly and saturates instead of wrapping on overflow. Show that a 4-tap moving average with coefficients of 0.25 gives a constant input back unchanged.
Hints
- A Q15 product is Q30; shift right by 15 to get back, after adding half a unit for rounding.
- What is -1.0 × -1.0 in Q15?
Solution and explanation
#include <stdint.h> #include <stddef.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; } /* Rounded multiply. -1 * -1 = +1 does not fit, so it saturates. */ static inline q15_t q15_mul(q15_t a, q15_t b) { int32_t p = (int32_t)a * b; /* Q30 */ return sat_q15((p + (1 << 14)) >> 15); } static inline q15_t q15_add(q15_t a, q15_t b) { return sat_q15((int32_t)a + b); } /* Direct-form FIR. hist is a circular buffer of the last ntaps inputs. The 64-bit accumulator cannot overflow for any ntaps < 2^33. */ struct fir_q15 { const q15_t *coef; q15_t *hist; size_t ntaps, pos; }; q15_t fir_q15_step(struct fir_q15 *f, q15_t x) { f->hist[f->pos] = x; int64_t acc = 0; size_t idx = f->pos; for (size_t k = 0; k < f->ntaps; k++) { acc += (int32_t)f->coef[k] * f->hist[idx]; /* h[k] * x[n-k] */ idx = (idx == 0) ? f->ntaps - 1 : idx - 1; } f->pos = (f->pos + 1 == f->ntaps) ? 0 : f->pos + 1; acc += 1 << 14; /* round to nearest */ acc >>= 15; /* Q30 back to Q15 */ if (acc > INT16_MAX) return INT16_MAX; if (acc < INT16_MIN) return INT16_MIN; return (q15_t)acc; }In Q15 a 16-bit value represents raw/32768, covering −1 to just under +1. Multiplying two Q15 numbers gives a Q30 result in 32 bits; adding
1 << 14before shifting right by 15 rounds to nearest instead of always rounding down, which avoids a small negative bias that accumulates in filters.Saturation matters because wrap-around in fixed point turns a large positive value into a large negative one, which in audio is a loud click and in control is a violent actuator jump. The one product that cannot fit is −1 × −1 = +1, which
q15_mulsaturates to the largest value.The FIR keeps a 64-bit accumulator in Q30 and only rounds and saturates once at the end, so intermediate sums can exceed the Q15 range without harm. On Cortex-M4 this maps to DSP multiply-accumulate instructions. The history is a circular buffer, so each new sample costs no copying.
- Fixed-capacity queue in C++ without heap or exceptionsHard · C++
Write a
FixedQueue<T, N>class template for firmware built with-fno-exceptions -fno-rttiand no heap. It stores up to N objects inside itself, constructs them in place on push (with arguments forwarded to the constructor), destroys them on pop and when the queue is destroyed, and reports full or empty through return values. T need not be default-constructible.Hints
- Raw, correctly aligned storage plus placement new.
- Every constructed object must be destroyed exactly once.
Solution and explanation
#include <cstddef> #include <new> #include <utility> #include <type_traits> // Fixed-capacity FIFO for builds with -fno-exceptions -fno-rtti and no heap. // Objects are constructed in place on push and destroyed on pop. template <typename T, std::size_t N> class FixedQueue { static_assert(N > 0, "capacity must be non-zero"); public: FixedQueue() = default; FixedQueue(const FixedQueue&) = delete; // keep the example small FixedQueue& operator=(const FixedQueue&) = delete; ~FixedQueue() { clear(); } template <typename... Args> [[nodiscard]] bool emplace(Args&&... args) { if (count_ == N) return false; // report, never throw ::new (static_cast<void*>(slot(tail_))) T(std::forward<Args>(args)...); tail_ = next(tail_); ++count_; return true; } // Moves the front element into out. Returns false when empty. [[nodiscard]] bool pop(T& out) { if (count_ == 0) return false; T* p = slot(head_); out = std::move(*p); p->~T(); head_ = next(head_); --count_; return true; } T* front() { return count_ ? slot(head_) : nullptr; } void clear() { while (count_) { slot(head_)->~T(); head_ = next(head_); --count_; } } std::size_t size() const { return count_; } static constexpr std::size_t capacity() { return N; } private: static constexpr std::size_t next(std::size_t i) { return i + 1 == N ? 0 : i + 1; } T* slot(std::size_t i) { return std::launder(reinterpret_cast<T*>(&storage_[i * sizeof(T)])); } alignas(T) unsigned char storage_[N * sizeof(T)]; std::size_t head_ = 0, tail_ = 0, count_ = 0; };The storage is a raw byte array with
alignas(T), so no T objects exist until they are pushed. That is why T does not need a default constructor and why an empty queue of large objects costs no construction time.emplaceforwards its arguments to placement new, building the object directly in its slot.Lifetime is handled explicitly:
popmoves the object out and then calls its destructor, andclear, used by the destructor, destroys whatever remains. Getting this exactly right is what the exercise tests; the harness counts live objects to prove nothing leaks or is destroyed twice.std::laundermakes accessing an object created by placement new through a pointer derived from the byte array well-defined under C++17 rules.Error reporting uses
[[nodiscard]] boolbecause exceptions are off, so ignoring a full queue becomes a compiler warning. Copying is deleted to keep the example short; a full version would implement it element by element. Mention that every distinct N creates a new instantiation, and that for ISR use you would combine this with a critical section or a single-producer, single-consumer design. - Response-time analysis for a fixed-priority task setHard · Python
Given periodic tasks in priority order, each with worst-case execution time C, period T, deadline D (no greater than T) and worst-case blocking B from lower-priority tasks, compute each task's worst-case response time under pre-emptive fixed-priority scheduling, or report that it misses its deadline. Also print the total utilisation and the rate-monotonic utilisation bound. Use only the standard library.
Hints
- The response time R appears on both sides of its own equation; iterate until it stops changing.
- Stop early once R exceeds the deadline.
Solution and explanation
import math def response_times(tasks): """Fixed-priority pre-emptive response-time analysis. tasks: list of (name, C, T, D, B) in priority order, highest first. C = worst-case execution time, T = period, D = relative deadline (D <= T), B = worst-case blocking from lower-priority tasks (for example the longest critical section under priority ceiling). Same time unit for all. Returns {name: response time, or None if it misses its deadline}. """ result = {} for i, (name, c, t, d, b) in enumerate(tasks): higher = tasks[:i] r = c + b while True: interference = sum(math.ceil(r / hp[2]) * hp[1] for hp in higher) r_next = c + b + interference if r_next > d: result[name] = None break if r_next == r: result[name] = r break r = r_next return result def liu_layland_bound(n): return n * (2 ** (1.0 / n) - 1) if __name__ == "__main__": # Rate-monotonic order: shorter period means higher priority. tasks = [ ("control", 1, 4, 4, 1), ("comms", 2, 6, 6, 1), ("logger", 3, 13, 13, 0), ] u = sum(c / t for _, c, t, _, _ in tasks) print("utilisation %.3f, RM bound %.3f" % (u, liu_layland_bound(len(tasks)))) for name, r in response_times(tasks).items(): print(name, "misses its deadline" if r is None else "R = %d" % r)For each task, the worst case occurs when every higher-priority task is released at the same moment as it (the critical instant). Within a window of length R, a higher-priority task with period Tj runs ceil(R / Tj) times, so the response time satisfies R = C + B + sum of ceil(R / Tj) × Cj. Starting from C + B and iterating, R never decreases; it either converges to a fixed point, the answer, or passes the deadline, a miss.
The example shows why the exact test matters. Utilisation is about 0.81, above the three-task bound of about 0.78, so the simple test cannot decide; response-time analysis shows all three tasks meet their deadlines with room to spare.
Points to raise: the C values must be measured or analysed worst cases, not averages; B comes from the longest critical section a lower-priority task holds on a shared resource, which priority ceiling or inheritance keeps to one section; and interrupt handlers should be modelled as the highest-priority tasks, because their load is real. Release jitter adds another term, which interviewers sometimes ask for.
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 architecture for a brushless motor controllerSystem design
You are the lead firmware engineer on a three-phase brushless motor controller for an industrial conveyor. It runs field-oriented control with a 20 kHz current loop on a Cortex-M4 with FPU, takes speed commands over CAN, reports status, must stop safely on any fault, and must be updatable in the field. Walk through your firmware architecture.
Model walk-through
Requirements first. I would confirm the timing budget (20 kHz gives 50 µs per current-loop cycle), the speed loop rate, the CAN message set and timeout, the fault list (over-current, over-voltage, under-voltage, over-temperature, encoder loss, command loss) with required reaction times, and whether any safety standard applies.
Timing structure. The current loop is not a task. The PWM timer, in centre-aligned mode, triggers the ADC at the middle of the period, when the phase currents are cleanest, and the ADC end-of-conversion interrupt runs the field-oriented control step at the highest priority: read currents, transform, run the PI controllers, update the compare registers through preload so the change applies at the next period. I would budget it at no more than half the period, measured with the cycle counter. The speed loop runs at around 1 kHz in a lower-priority interrupt or a high-priority task. CAN handling, status reporting, logging and the update service are RTOS tasks below that.
Data flow. The speed loop writes the current setpoint; the current loop reads it. A single 32-bit float written and read atomically is enough; for multi-field structures I would double-buffer with an index swap. Nothing above the current loop may disable interrupts for longer than a few microseconds, so critical sections use BASEPRI below the control interrupt, and the control interrupt never calls the kernel.
Safety. Over-current must not depend on software: the timer's break input, wired to a comparator, forces the outputs off in hardware within nanoseconds. Software checks the slower faults each cycle, with plausibility checks such as phase currents summing to near zero. Any fault goes to one safe-state function that disables PWM, latches the cause and reports it. A CAN command timeout ramps the motor down. A windowed watchdog is fed only when the control interrupt, the speed loop and the main tasks have all checked in.
Updates. A small bootloader with two application slots or a staging area, signature checking, and an update only accepted while the motor is stopped.
Testing. Unit tests of the control maths on the host, timing measurements under worst-case CAN load, and HIL fault injection: sensor disconnects, supply dips and command loss, checking reaction times.
- Partition a CAN-to-cloud gateway between Linux and a microcontrollerSystem design
A vehicle gateway must log several CAN buses with accurate timestamps, never miss frames at full bus load, run a cellular connection with TLS, buffer data when offline, accept remote configuration and software updates, and draw very little current when the vehicle is parked. Propose the hardware and software partitioning and explain the design.
Model walk-through
Requirements to pin down. Number of CAN buses and whether they use CAN FD, worst-case frame rate, timestamp accuracy, how long data must be buffered offline, parked current budget and wake-up sources, and the update policy for both processors.
Partition. I would use an application processor running embedded Linux for the cellular modem, TLS, storage, configuration and update logic, and a microcontroller for the CAN interfaces and power management. The reasons: Linux gives mature networking and security libraries, but CAN capture at full load with microsecond timestamps and a parked current in the milliamp range is easier to guarantee on an MCU that is always on while Linux is powered off.
MCU firmware. CAN receive interrupts timestamp each frame from a free-running hardware timer and place it into a large RAM ring buffer. A link task sends batches to Linux over SPI with a simple framed protocol, sequence numbers and a CRC, with Linux acknowledging batches so nothing is lost if Linux is busy. Filters configured from Linux reduce traffic. When the vehicle is parked, the MCU tells Linux to shut down, cuts its power rail, and watches the CAN buses and ignition line for wake-up activity.
Linux side. A capture service writes frames to a log on a power-safe file system on a separate data partition, rotating files and handling a full disk by dropping the oldest data according to policy. An upload service sends compressed batches when connected, retrying with back-off. The root file system is read-only, and updates use A/B partitions with rollback. The MCU firmware is updated from Linux through the MCU's bootloader, with its own signature check.
Failure modes. Linux crashes: the MCU keeps capturing into its buffer and resets Linux through a watchdog line. SPI link errors: retransmit by sequence number. Storage full: documented drop policy plus a counter. Power cut during writes: journalled file system and atomic file rotation.
Testing. A CAN load generator at 100% bus load checking frame counts end to end, power-cut testing, and long offline soak tests.
- Review a UART receive interrupt and its consumerCode review
A colleague asks for a review of this code, which collects newline-terminated commands. Identify the problems in order of severity and propose a better design.
static uint8_t rx_buf[64]; static uint8_t rx_len; static bool frame_ready; void USART1_IRQHandler(void) { uint8_t b = USART1->DR; rx_buf[rx_len++] = b; if (b == '\n') { frame_ready = true; printf("rx %u\n", rx_len); } } void poll_rx(void) { /* called from the main loop */ while (!frame_ready) { } process(rx_buf, rx_len); rx_len = 0; frame_ready = false; }Model walk-through
1. Buffer overflow. Nothing bounds
rx_len. A line longer than 64 bytes, or noise without a newline, writes pastrx_bufand corrupts whatever the linker placed next, and becauserx_lenis auint8_tit then wraps and keeps writing. This is the most serious bug: memory corruption from external input. Fix: check the length before storing and discard the line with an error count when it is too long.2. The flag is not volatile or atomic.
frame_readyis written in the ISR and read in a loop that does not otherwise touch memory, so at higher optimisation the compiler can read it once and loop forever. It needsvolatileat minimum, and correct ordering between the buffer writes and the flag, which an atomic store with release ordering gives.3. printf in the ISR. It is slow, usually not reentrant, may block on its output UART and uses a lot of stack. At high baud rates it causes overruns. Count events in the ISR and log from a task.
4. Shared buffer race. While
processreadsrx_buf, the ISR keeps receiving the next command into the same buffer, overwriting the data being parsed. Thenrx_len = 0in the main loop discards any bytes that arrived in the meantime, and since it is a read-modify-write race with the ISR's increment, it can also corrupt the length.5. Busy-wait.
poll_rxblocks the main loop until a command arrives, so nothing else runs. It should return immediately when there is nothing to do.6. No error handling. Overrun and framing errors are never checked, and on many UARTs an uncleared overrun stops reception.
Better design. The ISR only pushes bytes into a single-producer, single-consumer ring buffer and counts overflows and UART errors. The main loop or a task drains the ring buffer into its own line buffer, with a length check, and calls
processon each complete line. Ownership is now clear: the ISR owns the head, the consumer owns the tail and the line buffer, and no data is shared without a defined hand-over. At high rates, replace the per-byte interrupt with circular DMA and idle-line detection. Tests: a line of exactly 63, 64 and 65 bytes, bursts of back-to-back commands, and injected noise. - A HardFault appears only in the optimised release buildDebugging
After a feature merge, the release build (
-O2) hits a HardFault within a minute of start-up on the target. The debug build (-O0) runs for days without a problem. The new feature added a sensor driver and a data-logging task. Walk through how you find the cause.Model walk-through
Do not switch to the debug build. The fault depends on optimisation, so the evidence lives in the release build. I would build it with debug information (
-O2 -g), which does not change the generated code, so the debugger andaddr2linework.Read the fault. The fault handler gives the stacked PC and LR and the fault status registers. A precise bus fault with a valid fault address tells me the bad pointer value; an invalid-state fault suggests a call through a corrupt function pointer; an unaligned or undefined-instruction fault narrows it differently. An imprecise bus fault I make precise by disabling the write buffer for the investigation. Mapping the PC to source usually points at the failing line, even if it is not the root cause.
Hypotheses that fit "only at -O2, after a new driver and task":
- Stack overflow in the new logging task. Optimised code can use a different amount of stack, and formatting functions use a lot. Check the high-water marks and enable the kernel's overflow checking.
- A missing
volatileor barrier in the sensor driver, for example a status register polled through a non-volatile pointer, so a loop exits too early and the code reads a DMA buffer that is not filled. - Undefined behaviour the optimiser exploits: an uninitialised local, an out-of-bounds index, strict aliasing on a byte buffer cast to a struct.
- An unaligned access: at
-O2the compiler may combine two loads into a multiple-load instruction that faults on unaligned data, where-O0used separate loads.
Narrow it down. Compare map files to see what moved. Put a DWT watchpoint on any variable that appears corrupted to catch the writer. Disable the new task, then the driver, to bisect. Build the driver's logic on the host with sanitisers and run its tests. Read the warnings at the highest level the code supports.
Fix and protect. Fix the root cause rather than lowering optimisation for one file. Then add what would have caught it earlier: stack overflow checking and an MPU guard in development builds, CI that runs the release configuration on hardware, and a test that exercises the logging task at its worst-case message length.
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 summary | Describe one embedded project in two minutes: the hardware, your part, one hard problem and how you measured the result. Practise until it fits the time without notes. |
| 5–17 | Fundamentals rapid fire | Have a partner pick eight questions at random from the C, interrupts and RTOS sections. Answer each in under ninety seconds, leading with the direct answer. Note any you hesitated on. |
| 17–37 | Coding round | Pick one Medium exercise you have not looked at. Clarify constraints aloud, write it in a plain editor without compiling, then talk through edge cases and tests. Only afterwards compile with -Wall -Wextra and run your tests. |
| 37–50 | Design or debugging scenario | Take one scenario and talk through it using requirements, approach, trade-offs, failure modes and testing. Draw the timing or block diagram on paper. Have your partner interrupt with one changed requirement halfway. |
| 50–55 | Hardware debugging story | Tell the story of a bug you found with a scope, logic analyser or debugger: symptom, hypotheses, evidence, root cause, fix and what you changed so it cannot recur. |
| 55–60 | Your questions and review | Ask two questions about the team's hardware, toolchain and test practice. Then list what you would answer differently and revisit those sections the next day. |
Cheat sheet
Facts worth having at your fingertips on the day.
- volatile
- Forces every access in the source to happen; gives no atomicity and no ordering for ordinary memory.
- Shift safety
- Use unsigned operands; 1 << 31 on a 32-bit int and shifts of the full width or more are undefined.
- Integer promotion
- Types narrower than int are promoted to int before arithmetic, so ~ on a uint8_t gives an int.
- .data and .bss
- .data is initialised RAM copied from flash at start-up; .bss is zeroed RAM; const tables go in .rodata in flash.
- Cortex-M exception entry
- Hardware stacks R0–R3, R12, LR, PC and xPSR; about 12 cycles on M3/M4 with zero wait states.
- NVIC priority
- Lower number means higher priority; only the pre-emption part of the priority decides nesting.
- Write-one-to-clear
- Clear such flags with SR = FLAG, never SR |= FLAG, which clears other pending flags too.
- Barriers
- DMB orders accesses, DSB waits for completion, ISB refetches instructions after system changes.
- PendSV
- Lowest-priority exception used for context switches, so a switch never happens inside another ISR.
- Mutex vs semaphore
- Mutexes have an owner and priority inheritance; semaphores signal and may be given from an ISR.
- RM bound
- n(2^(1/n) − 1): about 0.83 for two tasks, 0.78 for three, tending to 0.69; sufficient, not necessary.
- Response time
- R = C + B + Σ ceil(R / Tj) × Cj over higher-priority tasks, iterated to a fixed point.
- Wrap-safe compare
- (int32_t)(now - deadline) >= 0 works across counter wrap if the gap is under 2^31 ticks.
- UART tolerance
- Total clock mismatch must stay well under about 5% for 8N1; aim for each end within about 2%.
- SPI modes
- CPOL is the idle clock level; CPHA 0 samples on the leading edge, CPHA 1 on the trailing edge.
- I2C pull-ups
- Minimum R is (VDD − 0.4 V) / 3 mA; maximum is set by bus capacitance and the rise-time limit.
- CAN error states
- Error passive at a counter of 128 or more; bus-off when the transmit counter exceeds 255.
- CAN termination
- 120 ohms at each end of the bus, about 60 ohms measured between CANH and CANL.
- USB transfer types
- Control, bulk, interrupt and isochronous; the host always initiates, directions are named from the host.
- PWM frequency
- Timer clock / ((prescaler + 1) × (auto-reload + 1)); resolution is the number of counts per period.
- Average current
- Sum of current × time for each phase, divided by the cycle period; active time usually dominates.
- Q15
- Value = raw / 32768; product of two Q15 numbers is Q30, so add 1 << 14 and shift right by 15.
- HardFault triage
- Stacked PC and LR, then CFSR, HFSR, and MMFAR or BFAR when their valid bits are set.
- Device tree
- Drivers bind through compatible strings; a node left with status disabled silently never probes.
- -EPROBE_DEFER
- Returned by probe when a dependency is not ready yet; the driver core retries later.
- Linux RT checklist
- PREEMPT_RT, SCHED_FIFO, mlockall, pre-allocation, absolute-time sleeps, isolated CPUs, measured worst case.
Red flags that sink candidates
Saying
volatilemakes shared data thread-safe. Explain that it prevents the compiler from removing accesses and that atomicity needs atomics or a critical section.Writing a busy-wait delay or
printfinside an interrupt handler. Keep ISRs short and hand work to a task or the main loop.Using plain
int, signed shifts and pointer casts onto byte buffers in coding rounds. Use fixed-width unsigned types, explicit byte assembly andmemcpy.Quoting average timings when asked about real-time behaviour. Talk about worst-case execution time, blocking and how you would measure the maximum.
Ignoring buffer bounds and full conditions. Every buffer, queue and pool needs a length check and a stated overflow policy with a counter.
Debugging by guessing and adding prints. Describe forming hypotheses from the symptom and collecting evidence with fault registers, watchpoints, a scope or a logic analyser.
Treating the hardware as someone else's problem. Strong candidates read schematics and datasheets, check signals on a scope and talk to hardware engineers as peers.
Proposing Linux, or bare metal, for every problem. Justify the platform from deadlines, power, connectivity and maintenance, and say what you would offload to a microcontroller.
Leaving out failure handling in design rounds. Cover power loss, brown-out, update failure, sensor faults and the safe state before the interviewer has to ask.
FAQ
What do embedded systems engineer interviews usually include?
Most loops have a screening call, a C or C++ coding round focused on bits, buffers, state machines and memory rather than complex algorithms, a fundamentals deep-dive on interrupts, concurrency, RTOS behaviour and peripherals, and a debugging or code review exercise. Senior loops add a system design round for a whole device and a discussion of past projects in technical depth. Some companies add a take-home task or a practical bring-up exercise on a development board.
How much data structures and algorithms practice do I need for embedded roles?
Less than for general software roles, but not none. Expect arrays, ring buffers, linked lists, bit manipulation, simple sorting and searching, state machines and fixed-point arithmetic, written carefully in C with attention to memory and edge cases. Large companies with a shared interview process may still ask a standard algorithm question, so practise a set of common problems in C as well, for example on coding practice.
Should I prepare C or C++ for an embedded interview?
Prepare C thoroughly in any case, because register access, startup code and most vendor libraries are C, and fundamentals questions assume it. If the job advert mentions C++, also prepare modern C++ for constrained targets: RAII, constexpr, templates and their cost, and working without exceptions, RTTI or a heap. The C Roadmap and Modern C++ Roadmap roadmaps cover the language foundations.
Do I need embedded Linux experience as well as microcontroller experience?
It depends on the product. Many embedded roles are purely microcontroller and RTOS work, while gateway, camera and industrial controller roles are mostly embedded Linux. Strong candidates understand both well enough to choose between them and to design the boundary when a product uses both. If your background is only microcontrollers, building a small Buildroot image, writing a device tree node and talking to a sensor from user space gives you real material to discuss. The Linux Roadmap roadmap covers the basics.
How do embedded systems engineer, firmware developer and device driver developer roles differ in interviews?
The titles overlap, and companies use them inconsistently. A firmware interview, as on the Firmware Developer page, tends to go deeper into microcontroller bring-up, bootloaders and low-level peripherals. A Device Driver Developer interview focuses on kernel internals, driver models and concurrency inside an operating system. Embedded systems engineer interviews are often broader, covering hardware interaction, real-time design and system architecture, sometimes including sensor and control topics shared with the Robotics Software Engineer role.