A firmware engineer interviewing for a battery-management-systems role at a mid-size EV supplier in late 2025 ran into one of the more common embedded systems interview questions early: why does a flag shared between an interrupt handler and the main loop need to be declared volatile? Fair enough, that's a phone-screen staple. The follow-up is the one that actually separates candidates: on an 8-bit MCU, does volatile alone make that access atomic? He got the first half right and stalled on the second. That two-question sequence is basically the entire embedded interview compressed into fifteen seconds. The syntax question is bait. The real question is whether you understand what's happening in the hardware underneath it.
Embedded roles keep getting harder to fill for reasons the job postings rarely say out loud. The BLS projects 7 percent employment growth for computer hardware engineers through 2034, with a May 2024 median wage of $155,020, while the language itself is quietly shrinking as a share of new developers. C sat at 20.3 percent usage among professional developers in the 2024 Stack Overflow Developer Survey, but only 38 percent among people learning to code right now, versus 39 percent for C++. That gap between "companies need firmware engineers" and "fewer people are learning C first" is a big part of why loops at Bosch, Texas Instruments, Nordic Semiconductor, Continental, and Aptiv lean so hard on fundamentals instead of trendy frameworks. There's no framework to lean on here. Here's an opinion that could be wrong: most candidates over-prepare on protocol trivia (I2C address bit counts, SPI mode numbers) and under-prepare on the two things that actually fail people, memory behavior they can't directly observe, and concurrency bugs that only show up under real timing, not in a simulator.
This page covers 47 embedded systems interview questions across four areas: C and the memory model (pointers, structs, volatile, stack versus heap), RTOS concepts and concurrency (priority inversion, semaphores versus mutexes, race conditions between an ISR and a task), the hardware protocols nearly every firmware round eventually touches (GPIO, I2C, SPI, UART, PWM, DMA), and the debugging and low-power questions that separate people who've shipped real hardware from people who've only run it in a simulator.
Easy questions
15Without volatile, the compiler is free to assume a variable never changes except through code it can see, so it may cache the value in a register once and never re-read it from memory, even though an interrupt handler is updating that memory location asynchronously. The main loop can end up spinning on a stale cached copy forever.
volatile uint8_t data_ready = 0;
void UART_RX_ISR(void) {
/*... read byte into buffer... */
data_ready = 1;
}
int main(void) {
while (1) {
if (data_ready) { /* without volatile, the compiler may
optimize this into an infinite loop */
data_ready = 0;
process_data();
}
}
}const int *p means the pointer can be reassigned to point somewhere else, but you can't change the value it points at through p. int * const p is the reverse: the pointer itself is fixed once set, but you can freely modify the value it points to. Read the declaration right to left from the variable name and it stops being confusing, p is a const pointer to int versus p is a pointer to a const int.
#define is a preprocessor text substitution, no type checking, no scope, but it costs zero memory and works anywhere, including as an array size on older compilers that reject a const int in that position. const gives you real type checking and can be scoped to a function or file, at the cost of possibly consuming a memory location depending on optimization level. enum is the right tool for a named set of related integer values, state machine states, error codes, it costs no memory and gets you a debugger that shows the name instead of a bare number.
At file scope, static gives a variable or function internal linkage, it can't be referenced from any other translation unit, which is how you keep a helper function or a module-private variable from polluting the global namespace across a large firmware codebase. Inside a function, static changes lifetime rather than linkage, the variable is initialized once and keeps its value between calls instead of being recreated on the stack every time, which is exactly how you'd implement a call counter or a state machine's current state without a global.
A process traditionally implies a protected, separate memory address space, enforced by an MMU. Most RTOS environments used in embedded work, FreeRTOS, Zephyr, ThreadX, don't give each task its own address space at all, tasks share one flat memory map and only get a separate stack. Some Cortex-M parts have an MPU that offers weaker, region-based protection, but it's the exception, not the default, and it's rarely configured on cost-sensitive designs. So on most embedded RTOS work, "task" is the accurate word, and "process isolation" mostly doesn't exist unless someone specifically built it in.
The table above covers the wiring, the part that actually matters is what each protocol trades away. I2C trades speed for pin count, two wires no matter how many devices sit on the bus. SPI trades pin count for speed, it's the fastest of the three but needs a dedicated chip-select line per device, so it doesn't scale cleanly past a handful of peripherals. UART trades everything for simplicity, no addressing, no shared bus at all, just two devices agreeing on a baud rate in advance since there's no clock line to synchronize against.
I2C outputs are open-drain, they can only pull the line low, never drive it high, so a pull-up resistor is what actually returns the line to a high state when nothing is pulling it down. Too high a resistance and bus capacitance slows the rising edge enough to limit your maximum clock speed, too low and you draw excessive current and make it harder for a weak driver to pull the line down at all. 4.7k ohm is a common default for 100 kHz standard mode on a lightly loaded bus, faster modes or longer bus runs generally need lower values.
UART is asynchronous, framing comes entirely from the bit pattern itself: the line idles high, a start bit pulls it low, then a fixed number of data bits follow, an optional parity bit, and one or more stop bits bring the line back high. Both ends have to agree on the baud rate in advance since there's no clock wire to synchronize against, and because timing is inferred rather than shared, drift beyond roughly two percent between the two sides' clocks can misalign bit sampling badly enough to corrupt the frame.
PWM is a fixed-frequency square wave where the fraction of each period spent high, the duty cycle, sets the average power delivered. A motor's inductance or the human eye's own averaging effectively smooths that switching waveform into what behaves like an analog value. Resolution comes from the timer counter width at a given PWM frequency, an 8-bit timer gives 256 discrete duty-cycle steps, so higher resolution means either a wider counter or a lower PWM frequency, and that trade-off is exactly what an interviewer wants you to name.
An oscilloscope shows the real analog signal, voltage levels, rise and fall time, glitches, ringing, which is what you need when you suspect the electrical signal itself is unhealthy. A logic analyzer only sees digital high or low, but across many channels at once, with built-in protocol decoders that translate the raw bits into actual bytes and ACK/NACK status for I2C or SPI. Most working sessions use both, the scope first to confirm the signal is electrically sane, the logic analyzer second to decode exactly what data was actually sent.
A blocking delay stalls the entire task or main loop for the duration, unacceptable on anything doing real work concurrently. A non-blocking, timestamp-based approach only accepts a new button state once enough time has passed since the last accepted transition, letting everything else keep running in the meantime.
uint32_t last_change_ms = 0;
#define DEBOUNCE_MS 25
void button_isr(void) {
uint32_t now = get_tick_ms();
if (now - last_change_ms > DEBOUNCE_MS) {
last_change_ms = now;
handle_button_press();
}
}Twenty-five milliseconds is a reasonable default for most mechanical switches, real bounce specs vary by switch, so check the datasheet if the button's behavior in the field is inconsistent with what worked on the bench.
.text holds your compiled machine instructions and any read-only constants. It lives in flash, and on most Cortex-M parts the CPU executes directly out of flash rather than copying it to RAM first, since flash is memory-mapped and reads at close to CPU speed with a few wait states.
.data holds initialized globals and statics, something like int retryCount = 5;. The value 5 has to be stored somewhere in the flash image, but the variable itself has to live in RAM because it's writable at runtime. That means the startup code, usually in the reset handler before main() runs, copies the initial values out of flash into the.data region in RAM..bss holds uninitialized or explicitly zero-initialized globals and statics. Since every value in.bss starts at zero, there's nothing worth storing in flash for it, so the linker just reserves the space and the startup code runs a loop that zeroes it out.
This is exactly why a hand-rolled startup file that skips the.bss zeroing loop causes bugs that only show up on real hardware and not in a simulator: simulators often zero RAM at reset anyway, so uninitialized globals happen to read as zero until someone runs the same image on silicon where RAM comes up with whatever pattern was left over from power-on.
A bootloader is a small, separate piece of firmware that runs first after reset, before your actual application. The reset vector points at the bootloader, not the app. Its job is usually to decide whether to enter firmware update mode or jump into the main application, and to do that safely even if the last update attempt was interrupted halfway through.
Once the bootloader decides the application image is valid, typically by checking a CRC or signature over the app region, it relocates the vector table offset register (VTOR on Cortex-M) to point at the application's own vector table, sets the stack pointer from the app's vector table, and jumps to the app's reset handler. From that point on, interrupts are serviced by the application's handlers, not the bootloader's.
The reason almost every shipped product has one is field updates. Without a bootloader, updating firmware means physically connecting a programmer to every unit. With one, you can update over UART, USB, CAN, or wirelessly. The tradeoff is that a broken bootloader, or a bug in the update logic itself, can brick a device permanently, which is why more careful designs use a dual-bank or A/B image layout with a known-good fallback rather than overwriting the only copy of the app in place.
Endianness is just the order in which a multi-byte value's individual bytes are stored or transmitted. Little-endian puts the least significant byte first, big-endian puts the most significant byte first. Most Cortex-M and x86 parts are little-endian by default, while a lot of network protocols were standardized around big-endian, often called network byte order.
The reason it matters is that a mismatch doesn't crash anything, it just silently produces the wrong number. If a sensor sends a 16-bit temperature reading as two bytes big-endian and your MCU reads them assuming little-endian, 0x01F4 (500) and 0xF401 (62465) look nothing alike, and the bug can sit unnoticed until someone happens to compare a logged value against a known-good reading.
The fix is to convert explicitly at the boundary rather than relying on a struct cast over raw bytes, since a cast assumes your compiler and target agree with whatever produced the data:
uint16_t be16_to_host(const uint8_t *buf) {
return ((uint16_t)buf[0] << 8) | buf[1];
}A CRC treats the message as one long binary number and divides it by a fixed polynomial, keeping the remainder as the check value. The receiver runs the same division and compares remainders. It sounds more complicated than adding up the bytes, but it's specifically good at catching the kinds of errors that actually happen on a wire: burst errors, dropped or duplicated bits, and reordered bytes.
A single parity bit only catches an odd number of bit flips in a byte, and a simple additive checksum can't tell the difference between two bytes swapping positions, since the sum comes out identical either way. CRC is sensitive to exactly where in the message the corruption happened, not just how many bits flipped, which is why it's the default choice for I2C-attached sensors with packet framing, CAN frames, and most serial protocols.
In practice, the gotcha isn't the math, it's agreement. Both ends need the same polynomial, bit width, initial value, and whether the input or output gets reflected. Get one of those wrong and you don't get an obvious failure, you get a CRC that happens to validate garbage some percentage of the time, which is a much worse bug to chase than an outright mismatch.
Medium questions
25A fixed-size array with head and tail indices, plus a full flag, since head equals tail is ambiguous between empty and full on its own. The ISR writes at head and advances it; the consumer reads at tail and advances it. Neither index needs a lock if it's a true single-producer single-consumer setup, only the ISR ever writes head and only the task ever writes tail.
#define RB_SIZE 64
typedef struct {
volatile uint8_t buf[RB_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
volatile uint8_t full;
} ring_buf_t;
void rb_init(ring_buf_t *rb) {
rb->head = 0;
rb->tail = 0;
rb->full = 0;
}
/* called from the UART RX ISR */
int rb_put(ring_buf_t *rb, uint8_t byte) {
if (rb->full) {
return -1; /* overrun: caller decides whether to drop or flag it */
}
rb->buf[rb->head] = byte;
rb->head = (rb->head + 1) % RB_SIZE;
rb->full = (rb->head == rb->tail);
return 0;
}
/* called from the consuming task */
int rb_get(ring_buf_t *rb, uint8_t *out) {
if (rb->head == rb->tail && !rb->full) {
return -1; /* empty */
}
*out = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % RB_SIZE;
rb->full = 0;
return 0;
}Interviewers almost always follow up with "what happens on overrun." The honest answer is a design decision, not a syntax one: drop the new byte, overwrite the oldest, or set a sticky error flag the application checks. Most production UART drivers pick "drop new, flag it," because silently overwriting unread data hides the real bug, which is that the consumer isn't draining fast enough.
Stack memory is allocated and freed automatically as functions call and return, fixed in size per task, and fast because it's just a pointer bump. Heap memory is allocated on request (malloc, or an RTOS equivalent) and lives until explicitly freed, which is exactly the problem on a device that might run for months without a reboot.
Fragmentation is the real reason malloc gets banned outright on a lot of firmware teams. There's no OS-level defragmentation on a bare-metal or lightly-RTOS'd system, so a device that allocates and frees variable-size blocks for weeks eventually can't satisfy a request even though the total free memory looks fine on paper, it's just scattered into pieces too small to be useful. Static allocation or a fixed-size memory pool sidesteps the whole problem, at the cost of having to know your worst-case memory needs up front.
The compiler inserts padding bytes so each member starts at an address matching its own alignment requirement, usually a multiple of its size, because many CPUs fault or slow down on unaligned access.
struct example {
char a; /* 1 byte */
int b; /* 4 bytes, needs 4-byte alignment */
char c; /* 1 byte */
};
/* sizeof(struct example) is commonly 12, not 6:
a (1) + 3 padding + b (4) + c (1) + 3 trailing padding */
struct reordered {
int b; /* 4 bytes */
char a; /* 1 byte */
char c; /* 1 byte */
};
/* sizeof(struct reordered) is commonly 8: b (4) + a (1) + c (1) + 2 padding */Reordering members from largest to smallest usually shrinks the struct. On a device pushing thousands of these structs over a wire or into flash, four bytes saved per instance genuinely matters at scale, and it's the kind of thing an interviewer expects you to notice without being told to look for it.
A compiler attribute or pragma (__attribute__((packed)) on GCC/Clang, #pragma pack elsewhere) tells the compiler to skip the alignment padding and lay members out contiguously. It's genuinely useful for matching an exact wire protocol or an on-flash record format byte for byte.
The cost is real, though. Some architectures, certain ARM cores among them, either fault or silently perform a much slower multi-instruction access when you read a packed multi-byte field that isn't naturally aligned. Packed structs also aren't portable across compilers without checking, the pragma syntax differs. Use it for wire formats you control precisely, not as a default habit.
Stack overflow risk that's hard to bound in advance. A recursive function's worst-case depth depends on its input, and on an MCU with a few kilobytes of total RAM shared between every task's stack, a global, or a heap, there's no room for "it'll probably be fine." Safety-critical coding standards like MISRA C explicitly restrict or forbid recursion for exactly this reason, static analysis tools can prove a bounded worst-case stack depth for iterative code far more reliably than for recursive code.
Cast the fixed address to a pointer of the right type and dereference it, almost always through volatile so the compiler doesn't optimize away what looks like a redundant read or write to code that has no idea the address is actually a hardware register, not RAM.
#define GPIO_DATA_REG (*(volatile uint32_t *)0x40020014)
/* set bit 5 without disturbing the other pins on this port */
GPIO_DATA_REG |= (1U << 5);
/* clear it */
GPIO_DATA_REG &= ~(1U << 5);Vendor HAL headers wrap this same pattern in named structs and macros, but the mechanism underneath every one of them is exactly this cast-and-dereference. Interviewers ask this specifically to check you know what's happening below the HAL, rather than only that you can call HAL_GPIO_WritePin.
A bit-field struct reads cleanly, but the C standard doesn't guarantee bit order or padding layout across compilers, which means a bit-field that maps correctly to a register on one toolchain can silently map wrong on another. Mask-and-shift macros are more verbose but their behavior is fully specified and identical everywhere.
#define SET_BIT(reg, bit) ((reg) |= (1U << (bit)))
#define CLEAR_BIT(reg, bit) ((reg) &= ~(1U << (bit)))
#define TOGGLE_BIT(reg, bit) ((reg) ^= (1U << (bit)))
#define READ_BIT(reg, bit) (((reg) >> (bit)) & 1U)Most production firmware codebases use the macro approach for register access specifically, and reserve bit-fields, if at all, for internal data structures that never need to match an exact hardware layout across different compilers.
When a high-priority task blocks waiting on a mutex, priority inheritance temporarily boosts whichever task currently holds that mutex up to the waiting task's priority, but only for the duration it holds the lock. That prevents anything of merely medium priority from preempting the holder, since the holder is now, for a moment, effectively high-priority too. The instant it releases the mutex, its priority drops back to normal. Most production RTOS mutex implementations (FreeRTOS included) support this as a configurable option rather than the unconditional default, which is exactly the kind of setting worth checking rather than assuming.
A mutex has ownership, whichever task locks it is the only one that can unlock it, and it typically supports priority inheritance for exactly the reason above. A semaphore has no ownership concept at all, any task, or an ISR, can give it, and a different task can take it. That makes semaphores the right tool for signaling and handoff (an ISR says "data's ready" by giving a semaphore that a task is waiting to take), and mutexes the right tool for mutual exclusion of a shared resource among tasks (protecting a shared I2C bus so two tasks don't interleave transactions on it).
An ISR runs outside the normal task scheduling context, it isn't a task the scheduler can suspend and resume later, so any function that might block waiting for something has no valid way to hand control back. Interrupts need to be short and fast regardless, blocking one for an unbounded wait would stall every lower-priority interrupt and task behind it.
/* FreeRTOS pattern: hand off from ISR to a waiting task without blocking */
void UART_RX_ISR(void) {
BaseType_t higher_priority_task_woken = pdFALSE;
xSemaphoreGiveFromISR(rxSemaphore, &higher_priority_task_woken);
portYIELD_FROM_ISR(higher_priority_task_woken);
}The FromISR-suffixed variants exist specifically so the ISR can signal a task without ever blocking itself, and the yield flag tells the scheduler whether it needs to immediately switch to a now-ready higher-priority task the moment the ISR returns.
Task A locks mutex 1 then tries to lock mutex 2. Task B locks mutex 2 then tries to lock mutex 1. Both now wait forever for a lock the other is holding. It's a lock-ordering violation, not a timing fluke, which is exactly why it can pass a hundred test runs and hang on the hundred-and-first.
The structural fix is a consistent global lock order, every task that needs both mutexes always acquires them in the same order, so the circular wait becomes impossible by construction. Where that's not practical, a timed lock attempt with backoff and retry avoids the permanent hang at the cost of some added complexity.
Preemptive scheduling gives every task its own stack and lets a higher-priority task interrupt a lower-priority one mid-execution, which is what makes real-time responsiveness possible, at the cost of RAM (one stack per task) and the concurrency bugs covered throughout this section. Cooperative scheduling has tasks voluntarily yield control, which is simpler and can share memory more efficiently, but one task that runs long or hangs blocks the entire system with nothing to preempt it. Cooperative schedulers still show up on genuinely tiny microcontrollers where RAM is the binding constraint, but most real-time embedded work today runs preemptive.
A watchdog is a hardware timer that resets the device if it isn't periodically reset, or fed, by software, the assumption being that a hung system stops feeding it in time. Feeding it from a single low-priority timer ISR is the naive approach, and it's a trap, the ISR can keep firing and feeding the watchdog even while a critical task above it is completely hung, giving false confidence that the system is healthy.
#define TASK_A_ALIVE (1U << 0)
#define TASK_B_ALIVE (1U << 1)
#define TASK_C_ALIVE (1U << 2)
#define ALL_ALIVE (TASK_A_ALIVE | TASK_B_ALIVE | TASK_C_ALIVE)
volatile uint8_t alive_flags = 0;
/* each critical task sets its own bit once per loop iteration */
void task_a_checkpoint(void) { alive_flags |= TASK_A_ALIVE; }
/* watchdog service task only feeds the hardware timer if everyone checked in */
void watchdog_service(void) {
if (alive_flags == ALL_ALIVE) {
feed_watchdog();
alive_flags = 0;
}
}Every critical task calling a checkpoint function once per loop iteration is what actually catches a hung task, the watchdog only gets fed when every bit is set, so one stuck task anywhere in the system stops the reset from being fed at all, exactly the behavior a naive single-source feed doesn't give you.
A reentrant function can safely be interrupted partway through execution and called again before the first call finishes, without corrupting shared state, because it doesn't rely on any mutable state beyond its own local variables and whatever the caller explicitly passed in. The classic non-reentrant example is the standard strtok, which keeps its position in a hidden static variable between calls, call it from an ISR while the main loop is mid-parse with it, and both callers silently corrupt each other's parsing state. strtok_r fixes this by making the caller pass that state explicitly instead of hiding it in a static.
A transaction starts with a START condition (SDA falls while SCL is high), followed by a 7-bit (or occasionally 10-bit) slave address plus a read/write bit. Every addressed slave that recognizes its own address pulls SDA low during the ninth clock pulse to ACK, if no slave responds, the line stays high and the master reads that as a NACK, meaning nothing on the bus claimed that address.
CPOL sets the clock's idle state, 0 means the clock idles low, 1 means it idles high. CPHA sets which clock edge data actually gets sampled on, the first transition or the second. Together they define four SPI modes. Get master and slave configured to different modes and the wiring looks completely fine on a scope, the clock and data lines are toggling exactly as expected, but every byte received is garbage, because the receiving side is sampling the data line at the wrong instant relative to when the sender changed it.
Speed versus pin count is the real trade. SPI runs into the tens of megahertz and is full-duplex, but every additional slave needs its own chip-select line, so a board with a dozen SPI sensors needs a dozen extra GPIO pins just for selection. I2C stays at two wires regardless of how many addressable devices you add, at the cost of running far slower and half-duplex. On a pin-starved microcontroller with several low-speed sensors, I2C usually wins by default. On anything needing to move real bandwidth, a display, external flash, a fast ADC, SPI is close to the only realistic option.
The two sides often aren't running the exact same baud rate at all, just a close approximation of it, because the baud generator divides a fixed clock and the division doesn't land on a round number.
/* baud rate divisor for a typical UART baud generator */
uint32_t divisor = (clock_hz + (baud_rate * 8)) / (baud_rate * 16);
uint32_t actual_baud = clock_hz / (16 * divisor);
/* if actual_baud isn't within about 2% of the requested baud_rate,
long frames will drift out of alignment over the byte */Compute the actual achieved baud rate from the divisor your clock and settings produce, and compare it against the target, rather than eyeballing "close enough." A mismatch that looks tiny on paper compounds across a full 10-bit frame and can land the last bit or two outside the sampling window.
An edge-triggered interrupt fires once on a transition, rising or falling. A level-triggered interrupt keeps firing continuously as long as the pin stays at that level. The bite comes when the ISR doesn't actually clear whatever condition is holding the level, some peripheral status flags need an explicit read or write to clear, and the interrupt fires again immediately, and again, starving every other task and interrupt on the system in what looks from the outside like a hard hang.
Push-pull actively drives the pin both high and low. Open-drain only actively pulls low and relies on a pull-up (external or internal) for the high state. The real reason to choose open-drain deliberately is a shared bus where multiple devices might drive the same line, I2C being the obvious example, but also things like a shared active-low reset line across several boards. With open-drain, if two devices disagree, one holding low and one wanting high, the line simply goes low, safely. With push-pull, two devices disagreeing means one is actively driving high while the other actively drives low into the same node, a real electrical short.
Direct Memory Access lets a dedicated controller move data between a peripheral and RAM without the CPU touching each byte individually, the CPU only gets interrupted once at completion, or at the halfway point if you're double-buffering. That matters most with high-frequency data sources, an ADC sampling at even a few hundred kilohertz would otherwise generate an interrupt per sample and leave the CPU with almost no time to do anything except service that one peripheral. DMA moves the bulk transfer to background hardware and frees the CPU for the actual computation the system exists to do.
A brown-out happens when supply voltage dips below a safe threshold, often triggered by a transient high-current load, a motor starting up, a relay energizing, sagging a shared power rail for a few milliseconds. Most MCUs expose a reset-cause register that records exactly which source triggered the last reset, power-on, brown-out, watchdog, external pin, or software. Reading and logging that register on every boot, ideally to non-volatile storage, is arguably the single highest-value three lines of firmware you can add for field debugging, since it turns "the device just resets sometimes" into "42 of the last 50 field resets were brown-outs, go look at the power supply."
Sleep mode stops just the CPU clock while peripherals and RAM stay fully powered, giving the fastest wake-up since almost nothing needs to restart. Stop mode shuts down most clocks but retains RAM, waking only on specific configured interrupt lines, and it's slower to wake because an oscillator may need to restart and stabilize. Standby (sometimes called shutdown) powers down nearly everything, including most RAM, wake behaves close to a full reset, and it draws the least current by far, which is why it's the mode used for long stretches between infrequent sensor readings on a battery-powered device.
/* Cortex-M: enter sleep and wait for the next interrupt to wake it */
__disable_irq();
if (!work_pending()) {
__WFI(); /* Wait For Interrupt: CPU clock stops here until an IRQ fires */
}
__enable_irq();Clock gating stops the clock signal feeding an unused block, the block stays powered but isn't switching, which saves dynamic (switching) power but does nothing for static leakage current, since the transistors are still energized. Power gating cuts the supply voltage to the block entirely, eliminating both dynamic and leakage power, at the cost of a real wake-up latency penalty, since the block's internal state is lost and has to be re-initialized on power-up. Clock gating suits something you'll re-enable in microseconds, power gating suits something staying off for seconds or longer where losing its state is an acceptable trade.
UART transmission is comparatively slow, microseconds to low milliseconds per byte depending on baud rate, and blocking inside an ISR for that long violates the basic rule that interrupt handlers need to stay short. A long enough ISR can cause missed interrupts elsewhere, trip a watchdog that expected to be fed on schedule, or introduce a priority-inversion-shaped problem of its own where a low-priority interrupt source ties up the CPU far longer than its priority should allow. If you genuinely need visibility from inside an ISR, write to a small in-memory trace buffer and flush it from task context afterward, or fall back to toggling a GPIO pin captured externally.
Hard questions
12Static allocation doesn't make leaks impossible, it just changes their shape. A fixed-size buffer pool where blocks get checked out but a bug means one code path never returns them is functionally a leak, the pool just slowly runs dry instead of the heap running dry. A queue that fills because a consumer task silently stopped draining it is the same failure mode with a different name.
Hardware resources leak the same way. A DMA channel or a peripheral handle that gets acquired on one code path and never released on an error path will eventually exhaust the fixed pool of channels the silicon actually has, and that failure often shows up hours or days after the triggering bug, which makes it genuinely hard to trace back.
No. Volatile only tells the compiler not to cache the value or reorder accesses to it, it says nothing about whether reading or writing that value is a single indivisible operation on the actual hardware. A 32-bit counter on an 8-bit AVR takes four separate load or store instructions, and an interrupt can fire between any two of them.
If an ISR increments that same counter, the main loop can read a torn value, half the old bytes and half the new ones, that never actually existed as a real count. Fixing it needs a brief critical section (disabling interrupts around the read or write) or a hardware atomic instruction where one exists, volatile alone doesn't cover it. This is the exact distinction that trips up candidates who memorized "volatile fixes shared variables" without understanding why.
Undefined behavior is anything the C standard explicitly doesn't define the outcome of, meaning the compiler is free to do anything at all, including something that happens to work today and breaks on the next compiler version or optimization level. Signed integer overflow is the classic one, incrementing an int past INT_MAX doesn't reliably wrap to a negative number the way it does on unsigned types, even though it "usually" does on most hardware.
The embedded-specific one is type punning through a union to reinterpret a float's bit pattern as an integer, which is technically undefined behavior in strict C even though it's extremely common in firmware for things like fixed-point math or protocol packing. Most compilers support it as a de facto extension and it works in practice, but "works in practice on this compiler" and "defined behavior" are not the same claim, and an interviewer who asks this is checking whether you know the difference.
Priority inversion happens when a low-priority task holds a resource a high-priority task needs, and a medium-priority task, which needs neither resource, keeps preempting the low-priority task and preventing it from ever finishing and releasing that resource. The high-priority task ends up effectively blocked by a task with lower priority than it, which is exactly backwards from what a priority scheduler is supposed to guarantee.
NASA's Mars Pathfinder rover hit this almost exactly in July 1997. A low-priority meteorological data task held a mutex that a high-priority bus-management task needed, and a medium-priority communications task kept preempting the low-priority one in between, long enough that a watchdog timer eventually reset the whole system. Engineers diagnosed it remotely and enabled the priority-inheritance option on that mutex, a feature the RTOS already supported but hadn't been turned on for that particular lock, which fixed the failure without uploading a single new line of application code. The formal fix, priority inheritance protocols, was described seven years earlier by Sha, Rajkumar, and Lehoczky in a 1990 IEEE Transactions on Computers paper.
If a low-priority task can block a high-priority task, and something in between can preempt the low-priority task indefinitely, you don't have a priority scheduler. You have a priority suggestion.
Even with volatile in place, a multi-step read-modify-write on a shared variable isn't atomic. If the main loop reads a counter, adds one, and writes it back, and the ISR increments that same counter in between the read and the write, the ISR's update gets silently overwritten when the main loop's stale write lands.
volatile uint32_t error_count = 0;
void log_error(void) {
uint32_t temp = error_count; /* interrupt could fire right here */
temp = temp + 1;
error_count = temp; /* ISR's increment gets lost */
}
/* fix: wrap the read-modify-write in a critical section */
void log_error_safe(void) {
__disable_irq();
error_count = error_count + 1;
__enable_irq();
}The fix is a short critical section around the whole operation, rather than only declaring the variable volatile. Keep it as brief as possible, disabling interrupts for a long stretch introduces its own latency and jitter problems elsewhere in the system.
Every RTOS task gets a fixed-size stack allocated up front, and if the actual call depth plus local variables exceeds that allocation, the stack pointer walks past its boundary into whatever memory sits next, often another task's stack or a global variable, corrupting it silently until something downstream fails in a way that looks completely unrelated.
Stack painting is the standard detection technique: fill each task's stack with a known pattern (0xA5 is common) before it starts running, then periodically check how much of that pattern remains untouched to estimate the high-water mark of actual usage. FreeRTOS exposes this directly through uxTaskGetStackHighWaterMark. Where the silicon has an MPU, placing a small guard region at the end of each stack turns an overflow into an immediate, debuggable fault instead of silent corruption discovered hours later.
I2C is open-drain, both masters can only pull the line low, never actively drive it high, so each master monitors the actual bus state while it transmits. If a master drives a bit high but reads back a low (because another master is simultaneously driving low), it recognizes it's lost arbitration and immediately backs off, letting the other master continue uninterrupted. Because this comparison happens bit by bit in real time rather than after the fact, the winning transaction never sees corrupted data, the losing master just silently retries later. Clock stretching, a slave holding SCL low to buy itself more processing time, uses this same open-drain wired-AND property.
A debugger changes timing. Halting the CPU at breakpoints, single-stepping, even the overhead of the debug interface itself can mask exactly the kind of race condition or marginal stack-overflow bug that only manifests under the original, tighter timing. That's the first hypothesis, not a hardware defect, a bug that the act of observing it makes disappear.
Test it by removing the observation method that's most likely disturbing timing. Swap breakpoints for toggling a spare GPIO pin at the point of interest and capturing it externally with a logic analyzer, which adds only a few instructions of overhead instead of halting the whole CPU. RTOS-aware trace tools built for exactly this problem (Percepio Tracealyzer, SEGGER SystemView) instrument with far less timing disturbance than a debugger stop or a blocking print.
The same problem as the debugger case above, in a different disguise. A print statement adds latency, can shift stack usage, and can change scheduling behavior enough to shift the timing window a race condition needed to actually occur. The bug didn't get fixed, the conditions for observing it did.
Toggle a spare GPIO pin instead and capture the timing externally with a logic analyzer, that costs a handful of instructions rather than the microseconds to milliseconds a blocking UART transmit takes. RTOS-aware trace tooling is the more thorough version of the same idea, instrumented specifically to disturb timing as little as possible while still recording what actually happened.
Start with the reset-cause register from earlier in this section, logged to non-volatile storage on every boot. That single data point often immediately narrows the field, brown-out versus watchdog versus an actual hard fault are three very different investigations. If it's a hard fault specifically, capture and persist the fault status registers (CFSR and HFSR on Cortex-M parts) along with the stack pointer at the moment of the fault, so the next boot can report exactly which instruction and which kind of fault caused it, rather than just "it crashed."
Add whatever basic health telemetry the device can send before a reset happens, uptime, free stack watermark, current task state, if connectivity allows it. A single unexplained field reset is a genuine mystery. A fault log correlated across twenty units in the field usually isn't, it's a pattern.
The peripheral asserts its interrupt line, which sets a pending bit in the NVIC. If that interrupt's priority is higher than whatever's currently running (and interrupts aren't globally masked), the core has to finish or abandon its current instruction, which on Cortex-M means letting a single-cycle instruction retire or, for a multi-cycle one like an LDM loading several registers, it can be abandoned and restarted later. That's already a source of jitter: interrupting a 12-register LDM costs more cycles than interrupting a single MOV.
Next the core automatically stacks eight registers, r0 to r3, r12, LR, the return address, and xPSR, onto the current stack before it ever reaches your handler. If the code was using the FPU and lazy stacking is enabled, the floating point context isn't saved yet, only space is reserved for it, and it only gets pushed the first time the ISR itself touches a floating point register. That's a real gotcha: an ISR that happens to do float math takes measurably longer on its first FPU instruction than one that doesn't touch the FPU at all, and teams that don't know lazy stacking exists burn a long time chasing "random" timing variance that's actually deterministic once you know to look for it.
After stacking, the core fetches the handler's address from the vector table, which lives in flash unless it's been relocated to RAM, and that fetch costs extra wait states if flash prefetch or cache isn't warmed up. If another interrupt of equal or higher priority is already pending by the time this one finishes, tail-chaining skips the unstack/restack pair between them and jumps straight to the next handler, which is faster than two separate exception entries but only if the priorities are ordered so tail-chaining actually applies.
In a real product, worst-case interrupt latency is bounded not by the hardware entry sequence, which is fixed and small, but by software: how long the longest critical section with interrupts masked runs, whether a higher-priority interrupt storm can starve a lower-priority one indefinitely, and whether someone put a blocking call or a printf inside an ISR that's supposed to be microseconds long. Measuring it means toggling a GPIO at ISR entry and watching it on a scope across the worst conditions you can trigger, not just reading a datasheet number for the hardware entry latency.
A lock-free single-producer single-consumer ring buffer, the same structure covered at the top of this page, needs no mutex at all as long as only the ISR ever writes the head index and only the task ever writes the tail index, each side only reads the other's index. If the RTOS provides a queue primitive, xQueueSendFromISR (FreeRTOS) achieves the same handoff with a bit more overhead but less code to get wrong. Either way, the ISR does the absolute minimum, copy the data, advance an index or push to a queue, and defers every bit of actual processing to the task.
Across embedded-focused mock interviews run through LastRoundAI over the past few months, the failure pattern isn't quite what most candidates expect going in. Syntax questions, the circular buffer, the bit-manipulation macros, get answered correctly nearly every time, people clearly drill these. Where sessions actually stall is the second-order question: why does this specific access need a critical section rather than only volatile, or what's actually happening on the wire when two I2C masters collide. The pattern holds whether someone's prepping for an automotive supplier, a chip vendor, or a small robotics startup. Questions that reward reasoning about hardware you can't directly see consistently beat the ones that just test recall.
Getting ready for the loop
A few things are worth doing on actual hardware before your next embedded round, rather than only on paper:
- Build a ring buffer from scratch and force a real UART overrun on it, so you've actually seen what happens when the consumer falls behind, rather than only reading about it.
- Deliberately introduce a race condition between an ISR and the main loop, watch it fail, then fix it with a critical section instead of being handed the fix.
- Read your MCU's reset-cause register after triggering a real brown-out, unplug it mid-write to flash and see what the register actually says.
- Wire up one I2C sensor and one SPI sensor to the same board, so "why would you pick SPI here" stops being an abstract question.
Two different tools solve two different problems here, and it's worth being precise about which is which. If a concept on this page, priority inheritance, bus arbitration, why a struct doesn't equal the sum of its members, doesn't fully land from reading it once, LastRoundAI's Concept Explainer breaks down the actual mechanism instead of restating the same definition you didn't understand the first time. If you're on a live technical screen and get handed a register-level bug you've never seen before, the AI Interview Copilot listens in and feeds structured guidance in under 200 milliseconds, across more than 50 languages including C and C++, so the response doesn't show up as an awkward pause on a screen share. It runs on the desktop app or in a browser tab, there's no native mobile app, so plan to be at a computer rather than dialing in from a phone.
The free plan includes 15 credits a month, which reset every month instead of banking up. Starter is $19 a month if that's not enough sessions to get through a full search. None of this replaces actually building something with an MCU on your desk, a two-dollar Arduino clone and a logic analyzer teaches more about interrupts than any amount of reading, but it closes the gap between knowing a definition and defending it under a follow-up that quietly changes one variable on you mid-sentence.
LastRoundAI runs a realistic mock interview and gives you real-time guidance on the exact questions above.
LastRound data
What we see on our side
Of 1,393 LastRound sessions configured between January 2025 and July 2026, only 8 enabled a coding round. For embedded roles, where a live pointer or ISR question is common, that gap between what people rehearse and what they get asked is unusually wide.
Frequently asked questions
What comes up most in embedded interviews?
Memory, concurrency and hardware interaction. Expect pointers, volatile, interrupt handling and questions about what happens when an ISR and main loop touch the same variable.
Is C still the dominant language?
For most embedded roles, yes, with C++ common in larger systems and Rust appearing at the edges. Depth in C remains the reliable preparation.
Do they ask about RTOS concepts?
Frequently. Task scheduling, priority inversion and mutex against semaphore are standard, usually framed as a debugging scenario rather than a definition.
How do I prepare for hardware questions without hardware?
Be specific about a real board you have used and what went wrong on it. A concrete debugging story with a logic analyser carries more weight than textbook recall.
How this list was built
Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.
What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.
If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

