This page is for anyone facing an embedded or firmware round, from a first job to a senior role. Most embedded interviews open with microcontroller basics and memory, then test interrupts, volatile and register-level C. After that come timers, the common serial buses and how to choose between them, and RTOS ideas like scheduling, semaphores and priority inversion. Senior rounds add watchdogs, bootloaders, power budgets and a real debugging story from the bench. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own boards and bugs.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Microcontroller: CPU, flash, RAM and peripherals like timers, UART and ADC on one chip; runs bare-metal code or an RTOS.
Microprocessor: a fast CPU that needs external RAM and storage, usually has an MMU and runs a full OS like Linux.
Choosing: real-time control, low power and low cost point to a microcontroller; screens, networking stacks and heavy software point to a processor.
"A microcontroller is a whole small computer on one chip. It has the CPU, flash for the program, some RAM, and peripherals like timers, UART, SPI and an ADC built in, so I can power it up and run my code straight from flash. It's cheap, boots in milliseconds and can sleep at microamps. A microprocessor is mostly just the CPU. It needs external DRAM and storage, it usually has a memory management unit, and it typically runs Linux or another full operating system. So for a thermostat, a motor controller or a sensor node, I'd pick a microcontroller because I need tight timing, long battery life and a low bill of materials. If the product needs a rich touchscreen, a full networking stack or lots of software, I'd go for an application processor and accept the extra power, cost and boot time."
Saying the only difference is clock speed, or not mentioning that a microcontroller has its memory and peripherals on the chip.
Flash: non-volatile; holds code, const data and the initial values of globals. Erased in pages or sectors with limited erase cycles.
SRAM: volatile and fast; holds globals at run time, the stack and any heap.
EEPROM: non-volatile and byte-writable; small settings and calibration. Many parts emulate it in flash.
"Flash is non-volatile, so it holds the program code and anything marked const, like a lookup table. It's also where the starting values of initialised globals are stored. SRAM is fast but loses everything at power-off, so at run time it holds the globals, the stack and the heap if there is one. EEPROM, when the chip has it, is small non-volatile memory I can write a byte at a time, which suits settings and calibration values. A lot of newer parts don't have real EEPROM and emulate it in a flash page instead, which means wear levelling and erasing whole pages. So in practice a global like int count = 5 lives in RAM, but its 5 is copied from flash at startup. A const table stays in flash and costs no RAM, at least on a Cortex-M; some 8-bit parts need a special keyword for that. And a local variable sits on the stack, or just in a register if the compiler can keep it there."
Not knowing that an initialised global costs flash as well as RAM, or thinking flash can be rewritten a byte at a time like RAM without an erase.
Order: power rails and current, then the debug connection, then clocks, then one simple output like an LED or UART.
Surprise: the thing that didn't work and how you narrowed it down.
Outcome: the fix, and what you fed back into the next board revision or your checklist.
"In my final-year project, and later at work, I've followed the same order. Before firmware, I power the board from a bench supply with a current limit and check each rail and the idle current. Then I check the debug probe connects over SWD, then I blink an LED and get a UART printing, running on the internal oscillator first. On one board at my last job everything worked on the internal clock but the chip hung when switching to the external crystal. I scoped the crystal and it wasn't oscillating reliably. The load capacitors on the schematic didn't match the crystal's datasheet. We fixed the values by hand on the prototypes, and I added a timeout in the clock setup so a dead crystal would fall back and report an error instead of hanging. That check is now in my bring-up list for every new board."
Flashing the full application on a brand-new board and debugging everything at once, with no step-by-step order.
Vector table: the core reads the initial stack pointer and the reset handler's address from the start of the vector table.
Startup code: the reset handler copies .data from flash to RAM, zeros .bss and usually sets up clocks.
Into C: it runs any library or C++ constructor setup, then calls main.
"On a Cortex-M, which is what I've used most, the core comes out of reset and reads the first two words of the vector table. The first is the initial stack pointer, the second is the address of the reset handler, and it jumps there. The reset handler is the startup code, often assembly or a small C file from the vendor. It copies the initial values of the .data section from flash into RAM, fills .bss with zeros so uninitialised globals really start at zero, and usually calls a system init function to set up clocks and sometimes the FPU. If it's C++, it also runs the global constructors. Only then does it call main. This matters when debugging: if a global has a garbage value at startup, or the board hangs before main, the problem is usually in the linker script or startup code, not in my application."
Saying the chip starts executing at main, or not knowing that initialised globals have to be copied from flash into RAM.
Mechanism: an independent counter that resets the chip unless the software refreshes it before it runs out.
Where to refresh: one place, only after every critical task has checked in during this period; never from a timer interrupt.
After a reset: read the reset-cause register and record which task went quiet.
"A watchdog is a counter, usually clocked from its own oscillator, that counts down and resets the microcontroller if the software doesn't refresh it in time. The whole point is recovery when the code hangs. The classic mistake is refreshing it in a periodic timer interrupt. The interrupt keeps firing even if every task is stuck, so the watchdog never bites. In a multitasking system I have each critical task set its own check-in bit each time round its loop. A single supervisor, often a low priority task, refreshes the watchdog only if all the bits are set, then clears them. If any task stops, no refresh happens and the chip resets. On boot I read the reset-cause register, and if it was the watchdog I log which task failed to check in, so the reset becomes evidence, not just a silent reboot."
Refreshing the watchdog from a timer ISR or at the top of main's loop without checking that tasks are alive.
Layout: a small bootloader that is rarely or never updated, plus two application slots or an app slot and a download slot.
Verify: write the new image to the inactive slot, check its hash, and check a signature so only your firmware runs.
Switch and confirm: mark the new image as on trial; it must confirm itself healthy, or the bootloader rolls back.
Power loss: every step leaves one valid image to boot from.
"I'd split flash into a small bootloader at the reset address and two application slots. The bootloader is kept tiny and ideally never updated, because it's the one thing that can't be recovered in the field. The running app downloads the new image into the inactive slot, so the working image is never touched. When the download finishes, the bootloader or the app checks a hash of the whole image and verifies a signature, so a corrupted or unofficial image is refused. Then it marks the new slot as pending and reboots. The bootloader boots it on trial. The new firmware has to confirm itself, say after it has connected to the server and passed self-tests. If it crashes or resets before confirming, the bootloader falls back to the old slot. Because state is only changed after writes are verified, a power cut at any point still leaves one bootable image."
Erasing the running image first and then writing the new one, or skipping integrity checks because the download used a reliable protocol.
Reframe: the watchdog isn't causing resets; it's catching a hang that would otherwise freeze the demo.
Find fast: read the reset cause and the stuck task; often it points straight to the bug.
Offer options: a clearly marked demo build if truly needed, never in shipped firmware, with a plan to fix the real cause.
"I'd first explain that the watchdog is doing its job. Something is hanging, and without the watchdog the unit would just freeze in front of the customer instead of recovering in a second or two. So disabling it might make the demo worse, not better. Then I'd spend the next hour finding out what's actually stuck. If we log the reset cause and which task missed its check-in, that often points straight at the bug, and it might be a quick fix. If we can't fix it by tomorrow, I'd offer options: a demo build with a longer timeout, or a script that avoids the feature that triggers it. If the manager still wants it off for the demo, I'd make a separate build that's clearly labelled, make sure it can never go to production, and put the real fix at the top of the list after the demo."
Disabling the watchdog in the main branch with no follow-up, or flatly refusing without offering any way through the demo.
Request: the peripheral sets a flag; the interrupt controller checks it is enabled and has high enough priority.
Entry: the CPU saves context, looks up the handler in the vector table and jumps to it.
Handler: the ISR does its short job and clears the peripheral's flag.
Return: context is restored and main continues exactly where it stopped.
"First the peripheral, say a UART that just received a byte, sets its interrupt flag. The interrupt controller checks that this interrupt is enabled and that its priority is higher than whatever is running now. If so, the CPU finishes or abandons the current instruction and saves the context. On a Cortex-M the hardware pushes eight registers onto the stack automatically: R0 to R3, R12, the link register, the program counter and the status register, plus the floating point registers if the FPU is in use. Then it fetches the handler address from the vector table and jumps to my ISR. Inside, I read the data and make sure the flag gets cleared, otherwise the interrupt fires again the moment I return. On return, the saved registers are popped and main carries on as if nothing happened. If another interrupt is pending at that point, the core can go straight into it without fully unstacking, which saves time."
Describing an interrupt as just calling a function, with no mention of saving context, priorities or clearing the source.
Short: grab the data, clear the flag, signal the main loop or a task, and leave.
Never inside: blocking waits, delays, printf, malloc, or any call that can sleep.
Shared data: anything shared with main is volatile and accessed safely; in an RTOS use only the ISR-safe API.
"My main rule is get in and get out. An ISR should read what the hardware has for it, clear the interrupt flag, maybe push a byte into a buffer or set a flag, and return. The real processing happens in the main loop or in a task that the ISR wakes up. Things I never put in an ISR are delay loops, waiting on a peripheral, printf, malloc, or anything that might block, because every microsecond I spend there delays other interrupts and can make the system miss events. printf is especially bad because it's slow and often not reentrant. Any variable shared with the main code is declared volatile and read in a way that can't be torn. And with an RTOS I only call the functions marked as safe from interrupts, never a normal blocking one."
Putting delays, printf or long processing in the ISR and saying it's fine because the interrupt is rare.
Polling: simple and predictable; fine when events are frequent, timing is loose or the CPU has nothing else to do.
Interrupts: best for rare or unpredictable events that need a fast response, and they let the CPU sleep.
DMA: for moving blocks of data, like ADC samples or a UART stream, without the CPU touching every byte.
"Polling means my code keeps checking a status flag. It's the simplest and it's fine for something like waiting a few microseconds for an SPI transfer to finish, or reading a slow sensor once a second in a simple loop. The cost is wasted CPU and power, and I can miss events if the loop gets busy. Interrupts suit events that are rare or unpredictable but need a quick reaction, like a button press or a received packet, and they let the CPU sleep in between, which is huge for battery life. But at very high rates the overhead of entering and leaving an ISR for every byte adds up. That's where DMA comes in. For a continuous ADC stream or a large UART or SPI transfer, I let DMA move the data into a buffer and only take an interrupt when half or all of the buffer is full."
Saying interrupts are always better than polling, or never having heard of DMA for bulk transfers.
Cause: a 64-bit read takes two 32-bit loads; the ISR can fire between them, so you get half old and half new.
volatile is not enough: it stops caching in registers but gives no atomicity.
Fixes: briefly mask interrupts around the read and restore the old state, re-read until two reads match, or use atomic types where the hardware supports them.
"On a 32-bit core, reading a 64-bit value takes two separate loads. If the interrupt fires between them and the ISR updates the timestamp, I end up with the low half from one value and the high half from another, which is a torn read. volatile doesn't help here. It only makes the compiler actually read memory every time, it says nothing about atomicity. The simplest fix is a tiny critical section: save the interrupt mask state, disable interrupts, copy the value into a local, then restore the previous state rather than blindly enabling, so it's safe if interrupts were already off. It's only a few instructions, so latency barely changes. Another option is reading the high word, the low word, then the high word again and retrying if it changed. The same issue hits read-modify-write, like count++ in main while the ISR also changes count."
#include <stdint.h>
/* CMSIS header for your part provides the intrinsics below */
static volatile uint64_t timestamp_us; /* written in the timer ISR */
uint64_t get_timestamp(void)
{
uint32_t primask = __get_PRIMASK(); /* remember current state */
__disable_irq();
uint64_t copy = timestamp_us; /* two loads, now uninterrupted */
__set_PRIMASK(primask); /* restore, don't just enable */
return copy;
}
Saying volatile makes the variable thread-safe or atomic, or fixing it by disabling interrupts for a long block of code.
Meaning: the value can change outside the code the compiler can see, so every read and write must really happen, in order.
Where: memory-mapped hardware registers, variables shared with an ISR, and memory another bus master like DMA can change.
Limits: it does not make access atomic and is not a lock or a memory barrier between cores.
"volatile tells the compiler that a variable can change, or that a write to it matters, in ways it can't see from the code. So it must not keep the value in a register, merge reads, or remove writes that look pointless. Without it, the optimiser can turn while (!ready) into an endless loop, because from its point of view nothing inside the loop changes ready. In firmware I need it in three main places. Hardware registers, because a status register changes on its own and a write to a data register has a side effect. Variables shared between an ISR and the main code. And buffers that DMA writes into. What volatile doesn't do is make access atomic or protect a read-modify-write. So for shared data I still need a critical section or atomic operations on top of it."
static volatile uint8_t rx_ready; /* set in the UART ISR */
void wait_for_byte(void)
{
while (!rx_ready) { } /* re-read each pass because it is volatile */
rx_ready = 0;
}
Saying volatile makes a variable thread-safe, or that it stops the value being cached by the CPU's data cache.
Mask and shift: build a mask for the field, clear it, then OR in the new value shifted into place.
Read once, write once: copy the register to a local, change it, write it back.
Hazards: an ISR touching the same register in between, write-one-to-clear bits, and registers whose read value differs from what was written.
"I define the field's position and a mask of four ones shifted up by four. Then I read the register once into a local variable, clear the field with AND NOT mask, OR in the new value shifted into place and masked so a too-big value can't spill into other bits, and write the result back once. The things that bite you are around read-modify-write. If an ISR changes another bit in the same register between my read and my write, my write puts back the old value and its change is lost, so either both sides use a critical section or I use the chip's separate set and clear registers if it has them. Also, some status registers are write-one-to-clear, so reading and writing back the whole register can clear flags I never meant to touch. For those I write only the bits I mean to clear."
#include <stdint.h>
#define MODE_POS 4u
#define MODE_MASK (0xFu << MODE_POS)
static inline void set_mode(volatile uint32_t *reg, uint32_t mode)
{
uint32_t v = *reg; /* read once */
v &= ~MODE_MASK; /* clear bits 4..7 */
v |= (mode << MODE_POS) & MODE_MASK; /* insert new value */
*reg = v; /* write once */
}
Writing the whole register with just the new value and wiping the other bits, or not seeing any risk when an ISR shares the register.
Two indexes: the ISR only moves head, the main loop only moves tail, so neither overwrites the other's work.
Full and empty: head equal to tail means empty; keeping one slot free tells full from empty.
Why it's safe: each index has one writer, updates are single aligned word writes, and the data is stored before head moves.
"I use a single-producer, single-consumer ring buffer. The ISR is the only code that writes head, and the main loop is the only code that writes tail. When a byte arrives, the ISR works out the next head, and if that would equal tail the buffer is full, so it drops the byte and I count that as an overrun. Otherwise it stores the byte and only then moves head. The main loop checks if tail equals head, which means empty, and if not it reads a byte and moves tail. I keep one slot empty so full and empty look different, and I make the size a power of two so wrapping is a cheap mask. Because each index has one writer and each update is a single aligned word store on a single-core chip, I don't need to disable interrupts. On a multi-core chip I'd add memory barriers."
#include <stdint.h>
#define RB_SIZE 256u /* must be a power of two */
static volatile uint8_t rb_buf[RB_SIZE];
static volatile uint32_t rb_head, rb_tail; /* ISR owns head, main owns tail */
void rb_put_from_isr(uint8_t byte) /* call from the UART RX ISR */
{
uint32_t next = (rb_head + 1u) & (RB_SIZE - 1u);
if (next == rb_tail) return; /* full: drop, count an overrun */
rb_buf[rb_head] = byte; /* store data first */
rb_head = next; /* then publish it */
}
int rb_get(uint8_t *out) /* call from the main loop */
{
if (rb_tail == rb_head) return 0; /* empty */
*out = rb_buf[rb_tail];
rb_tail = (rb_tail + 1u) & (RB_SIZE - 1u);
return 1;
}
Letting both sides write the same index, or updating head before the byte is stored so the reader can see a slot that isn't filled yet.
Prescaler: divide the timer clock down to a convenient tick, for example 1 MHz.
Reload: count that tick up to the period you want, here 1000 ticks for 1 ms.
Details: many timers load value minus one; confirm the real timer input clock and enable the update interrupt.
"The rate I get is the timer's input clock divided by the prescaler and then by the reload count. On a lot of parts, including the STM32 style timers I've used, both registers hold the value minus one. So with 48 MHz feeding the timer, I set the prescaler to 47, which gives a 1 MHz count, one tick per microsecond. Then I set the auto-reload to 999, so the counter overflows after 1000 ticks, once every millisecond. I enable the update interrupt and clear its flag in the handler. Two things I always check. First, the clock actually feeding the timer, because it often comes through a bus prescaler and isn't the core clock. Second, whether I even need a hardware timer, because on a Cortex-M the SysTick timer is made for exactly this: load it with the core clock divided by 1000, minus one."
Forgetting the minus-one convention, or assuming the timer always runs at the core clock without checking the clock tree.
Mechanism: the counter counts up to a period value; a compare register sets the point where the output pin flips.
Frequency: set by the timer clock, prescaler and period value.
Duty cycle: compare value over period; higher frequency leaves fewer counts, so coarser steps.
"A timer counts up from zero to a period value and then starts again. In PWM mode it also has a compare register. In the usual edge-aligned mode, at the start of each period the output pin goes high, and when the counter reaches the compare value it goes low. So the period value, together with the timer clock and prescaler, sets the PWM frequency, and the compare value sets the duty cycle, which is how much of each period the pin is high. If the period is 1000 counts and the compare is 250, the pin is high a quarter of the time. I use it for LED dimming, motor speed and servo control. The trade-off is resolution: if I push the frequency up, there are fewer counts per period, so I get fewer duty-cycle steps. For motors I'd also keep the frequency above the audible range so it doesn't whine."
Mixing up frequency and duty cycle, or thinking PWM is done by toggling a pin in a software loop.
Wrong way: comparing now against start plus timeout fails when the sum wraps.
Right way: subtract first; unsigned subtraction wraps too, so now minus start is the true elapsed time.
Limit: it works as long as the timeout is shorter than the counter's full range.
"The tempting version is to compute a deadline as start plus timeout and check if now is past it. That breaks near the wrap. If start is just below the maximum, the deadline wraps to a small number and the check passes instantly. With a 32-bit millisecond counter that happens after about 49.7 days, which is exactly the kind of bug that shows up only in the field. The fix is to always subtract: now minus start, both unsigned. Unsigned arithmetic in C is defined to wrap, so even when now has rolled over to a small value, the subtraction gives the correct elapsed time. Then I compare elapsed against the timeout. That's correct as long as the timeout is less than the counter's full range. With a 16-bit counter on a 32-bit core I'd cast the result back to 16 bits, because C promotes both to int before subtracting."
#include <stdint.h>
#include <stdbool.h>
extern volatile uint32_t g_ms_ticks; /* incremented by the 1 ms tick ISR */
bool timed_out(uint32_t start, uint32_t timeout_ms)
{
uint32_t elapsed = g_ms_ticks - start; /* correct across wraparound */
return elapsed >= timeout_ms;
}
/* Broken: if (g_ms_ticks >= start + timeout_ms) ... */
Using a deadline comparison, or suggesting a signed counter, since signed overflow is undefined behaviour in C.
UART: asynchronous, TX and RX, both sides agree on a baud rate; point-to-point.
SPI: clocked, full-duplex, fast; clock, two data lines and one chip select per device; no acknowledge.
I2C: two open-drain wires shared by many addressed devices; each byte acknowledged; slower.
Choosing: pin count, speed, number of devices and what the sensor supports.
"UART has no clock line. Each side just agrees on a baud rate, and each byte is framed with a start bit and a stop bit. It's point-to-point, so I use it for debug consoles, GPS modules or talking to another processor. SPI has a clock driven by the master, data lines in both directions and a chip select per device. It's full duplex and can run at many megahertz, so it suits displays, flash chips and fast ADCs, but each extra device costs a pin and there's no built-in acknowledge. I2C uses just two wires, SDA and SCL, shared by many devices, each with its own address, and every byte is acknowledged. It's slower, usually 100 or 400 kHz, but great for lots of slow sensors. So for a temperature sensor I'd happily share an I2C bus. For a high-rate IMU or flash I'd pick SPI."
Saying I2C is faster than SPI, or that UART needs a clock line.
Open drain: devices only pull lines low; resistors pull them high, so no two devices ever fight.
Write address: START, 7-bit address plus write bit, ACK, register number, ACK.
Read data: repeated START, address plus read bit, ACK, data bytes, master NACKs the last byte, then STOP.
"I2C lines are open drain. Every device can pull SDA or SCL low, but nobody drives them high. The pull-up resistors do that. It means two devices can never short against each other, and it's what makes clock stretching and multi-master arbitration possible. The resistor value is a trade-off: too big and the edges are slow because of bus capacitance, too small and you burn current and devices struggle to pull low. For a register read, the master sends a START, which is SDA falling while SCL is high. Then the 7-bit address with the write bit, and the sensor pulls SDA low on the ninth clock to acknowledge. Then the register number, acknowledged again. Then a repeated START, the address with the read bit, and the sensor sends data bytes. The master acknowledges each except the last, which it NACKs, and ends with a STOP."
Not knowing why pull-ups are needed, or describing I2C as push-pull with each device driving the line high and low.
Likely cause: the master and device disagree on SPI mode, so data is sampled on the wrong clock edge.
CPOL and CPHA: CPOL is the idle level of the clock; CPHA picks whether data is sampled on the leading or trailing edge of each clock pulse.
Check: read the mode from the datasheet timing diagram, confirm bit order and chip select timing, then look with a logic analyzer.
"Bytes that look shifted by one bit usually mean an SPI mode mismatch. SPI has four modes, set by two bits. CPOL is whether the clock idles low or high. CPHA is whether data is sampled on the leading edge of each clock pulse or on the trailing edge. Mode 0 idles low and samples on the rising edge. Mode 3 idles high and also samples on the rising edge, which is why many chips accept either. If my master samples on the other edge, it catches every bit just as it's changing, and I get data that's off by a bit or just noisy. So I'd check the device's datasheet timing diagram and set the matching mode. I'd also confirm the bit order, since most devices are MSB first, and that chip select stays low for the whole command. Then I'd put a logic analyzer on the four lines to prove it."
Guessing at wiring or a bad chip without mentioning clock polarity and phase.
Physical: a differential pair, CAN high and CAN low, with a 120 ohm terminator at each end of the bus.
Arbitration: 0 is dominant and overrides 1; a node that sends 1 but reads 0 stops; the lowest ID wins and its frame is not damaged.
Robustness: CRC, an acknowledge slot, error counters, and nodes that go bus-off instead of jamming the bus.
"CAN is a two-wire differential bus, which makes it resistant to the electrical noise you get from motors and ignition. There's a 120 ohm terminator at each end. Messages carry an identifier rather than a destination address, and any node can start sending when the bus is idle. If two start together, arbitration happens bit by bit on the identifier. A 0 is dominant and a 1 is recessive, so if one node sends a 1 but reads back a 0, it knows someone else has priority and stops sending. The node with the lowest ID wins and its frame goes through untouched, so no bandwidth is lost, and the loser retries later. On top of that, frames have a CRC, receivers acknowledge in a dedicated slot, and each node keeps error counters. A faulty node goes error passive and eventually bus-off, so it can't take the whole network down."
Saying the highest ID wins, or thinking a collision destroys both frames and both nodes back off like Ethernet.
Super loop: main loop plus ISRs; tiny, predictable and easy to reason about when there are a few jobs.
Signs you need an RTOS: several independent jobs with different deadlines, long blocking work like a network stack, loop timing getting hard to guarantee.
Costs: a stack per task, more RAM, new bug types like deadlocks and priority inversion.
"For a small device, like a sensor that wakes up, reads a value, sends it and sleeps, a super loop with interrupts is my first choice. It's small, there's no scheduler to reason about, and timing is easy to check. The trouble starts when the jobs multiply and have different deadlines. If one part of the loop occasionally takes 50 milliseconds, say writing to flash or handling a network stack, everything else waits, and I end up writing state machines everywhere to break work into pieces. That's when an RTOS pays off. I can give the motor control loop a high priority task, put communication in a lower one, and the scheduler makes sure the urgent work runs on time. But it isn't free. Each task needs its own stack, so RAM goes up, and I have to deal with shared resources, deadlocks and priority inversion."
Saying you'd always use an RTOS because it's more professional, or not knowing that each task needs its own stack.
Rule: the highest priority task that is ready always runs; when a higher one becomes ready, it preempts the current task.
States: running, ready, blocked on a delay, semaphore or queue, and suspended.
Equal priority: tasks at the same priority take turns on the tick when time slicing is enabled.
Consequence: a high priority task that never blocks starves every task below it, including the idle task.
"The rule is simple: the highest priority task that's ready is the one running. Every task is in one of a few states. Running is the one on the CPU. Ready means it could run, but something higher is running. Blocked means it's waiting for something, like a delay, a semaphore or data in a queue, and it uses no CPU while it waits. Suspended means it's been taken out of scheduling until something resumes it. When an interrupt gives a semaphore that a higher task is blocked on, that task becomes ready, and if the ISR asks for a switch on exit, it runs as soon as the interrupt returns rather than at the next tick. Tasks at the same priority take turns on each tick if time slicing is on. The practical lesson is that a high priority task must block regularly. If it spins in a busy loop, everything below it starves, including the idle task."
Saying every task gets an equal time slice whatever its priority, or that a blocked task still uses CPU time while it waits.
Binary semaphore: a signal with no owner; an ISR gives it to wake a task, for example when a DMA transfer finishes.
Counting semaphore: counts events or free units, like pulses that arrived before the task ran or free buffers in a pool.
Mutex: guards a shared resource such as an SPI bus; it has an owner, only the owner gives it back, and it usually brings priority inheritance.
Queue: carries the data itself, like sensor readings, from a producer task or ISR to a consumer task.
"I pick by what I need it to do. If I only need to signal, say an ISR telling a task that a DMA transfer finished, a binary semaphore does it, or a task notification, which is lighter. Nobody owns it, and the ISR gives it with the from-ISR call. If events can pile up before the task runs, or I'm handing out a pool of buffers, a counting semaphore keeps the count so nothing is lost. If two tasks share the SPI bus or a UART, I use a mutex. It has an owner, only the task that took it can give it back, and in most RTOSes it comes with priority inheritance, which a semaphore doesn't. And if I need to pass the data itself, like readings from a sensor task to a logging task, I use a queue. It copies each item in and blocks the reader until something arrives, which often removes the shared variable altogether."
Saying a mutex and a binary semaphore are the same thing, or using a mutex to signal from an ISR to a task.
Scenario: low task holds a mutex; high task blocks on it; a medium task preempts low, so high waits for medium.
Why it's bad: the high task's wait is no longer bounded by the short critical section.
Fixes: inheritance raises the holder to the waiter's priority; ceiling raises any holder to the highest priority of the tasks that use that lock; keep critical sections short.
"Say I have three tasks. The low priority one takes a mutex to use the SPI bus. Then the high priority task wakes up and wants the same bus, so it blocks on the mutex, which is expected and should be short. But now a medium priority task that doesn't need the bus becomes ready. It preempts the low task, because it's higher, and runs for as long as it likes. The low task can't finish and release the mutex, so the high task is effectively waiting on the medium one. That's priority inversion, and it can blow a deadline or trigger a watchdog reset. Priority inheritance fixes it by temporarily raising the mutex holder to the priority of the highest task waiting, so medium can't preempt it. Priority ceiling raises the holder, the moment it takes the lock, to the highest priority of any task that uses that lock. In FreeRTOS, mutexes have inheritance but plain binary semaphores don't."
Describing it as a high task simply waiting for a low one, with no medium task in the picture, or claiming any semaphore prevents it.
Deferred work: the ISR clears the hardware and signals a task, which does the processing at task level.
ISR-safe calls: give a semaphore, send to a queue or send a task notification with the from-ISR versions, then request a context switch if a higher task woke.
No mutex: an ISR can't block, and mutex ownership and priority inheritance only make sense for tasks.
"The pattern is deferred interrupt processing. The ISR does the bare minimum with the hardware, then signals a task that does the real work. In FreeRTOS I'd use a task notification or a semaphore give, and inside an ISR I must use the FromISR version. Those functions take a flag that gets set if the call woke a task with a higher priority than the one that was interrupted. At the end of the ISR I pass that flag to portYIELD_FROM_ISR, so the scheduler switches straight to that task when the interrupt returns instead of waiting for the next tick. A mutex is off limits because taking it may have to block, and an ISR can never block. A mutex also has an owner and priority inheritance, which only mean something for tasks. So if an ISR and a task share data, I use a short critical section or a queue instead."
#include "FreeRTOS.h"
#include "task.h"
static TaskHandle_t rx_task;
void DMA_IRQHandler(void)
{
BaseType_t woken = pdFALSE;
/* clear the DMA transfer-complete flag here */
vTaskNotifyGiveFromISR(rx_task, &woken);
portYIELD_FROM_ISR(woken);
}
void rx_task_fn(void *arg)
{
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* sleeps until notified */
/* process the received block */
}
}
Calling the normal blocking API from an ISR, or doing all the processing inside the ISR because the RTOS feels complicated.
Measure: profile current over a full wake-sleep cycle with a proper current measuring tool; find where the charge goes.
Sleep most of the time: deepest sleep mode that still wakes on the needed event; interrupts, not polling.
Cut the rest: clock off unused peripherals, finish work quickly then sleep, power down sensors and radio, set unused pins so they don't float.
"For a years-long battery life, the device has to be asleep almost all the time, so the average current is dominated by the sleep current and by how long and how often it wakes. I'd start by measuring a full cycle with a current profiler, because the surprise is usually somewhere unexpected, like a sensor left powered or a pull-up leaking. Then I'd make sure the chip uses the deepest sleep mode that can still wake on the RTC or the pin I need, with everything event-driven so nothing polls. When awake, I turn on only the peripherals I need, run fast, and go back to sleep, which usually beats running slowly. I'd power sensors through a load switch, batch radio transmissions since the radio is often the biggest cost, and set unused pins so they don't float, because floating inputs can draw current."
Jumping to lowering the clock speed without measuring, or ignoring the radio, sensors and pin states.
Gap: the target, the measured number and why it mattered to the product.
Finding it: the tool or measurement that pointed at the real cause.
Trade-off: what you changed, what it cost, and who agreed to it.
"At my last company a wireless sensor was meant to last two years but our bench measurements pointed to well under one. I set up a current profiler across a full cycle and saw two things. The sleep current was higher than the datasheet promised, and the radio was waking far more often than we thought. The sleep current turned out to be two unused pins left as floating inputs and a sensor we never powered down. Configuring the pins and switching the sensor through a load switch fixed most of it. The radio was sending every reading straight away. I proposed batching readings and sending them every few minutes, which meant the dashboard would show data a little later. I took that to the product owner, since it was a user-facing change. They agreed, and with both fixes the projection came back comfortably above target."
A fix made by guessing, or quietly changing user-visible behaviour without telling anyone.
Stack frame: bit 2 of the EXC_RETURN value in LR says which stack was active; read the stacked PC and LR from that frame.
Fault registers: on M3 and above, HFSR shows a fault that escalated and CFSR says what kind; MMFAR and BFAR give the address when their valid bits are set.
Map it back: look up the PC in the map file or disassembly; common causes are null or wild pointers, stack overflow, unaligned access and bad function pointers.
"First, I don't just set a breakpoint in the HardFault handler and stare at it. The hardware has already stacked eight registers, so I check bit 2 of the EXC_RETURN value in LR to see whether the main or process stack was in use, then read the stacked program counter and link register from that frame. The stacked PC points at, or very near, the faulting instruction, and LR usually shows who called that function. On an M3 or above I read the fault status registers next. The HardFault status register says if another fault escalated, and the configurable one says whether it was a bus fault, a memory management fault or a usage fault, like an undefined instruction or, if that trap is on, a divide by zero. The fault address registers help when they're marked valid. Then I look the PC up in the disassembly. One catch is an imprecise bus fault, where the PC is past the real culprit because the write was buffered."
Saying you'd add printf statements until the crash moves, with no mention of the stacked PC or the fault status registers.
Situation: the device and the symptom, and why the obvious guess was wrong.
Evidence: what you probed, what you saw on the lines, and how that split hardware from software.
Fix and lesson: what changed, and the check you now do by default.
"At my last company we had an I2C temperature sensor that returned garbage maybe once in a few hundred reads. The software team assumed a noisy board and the hardware team assumed a driver bug. I put a logic analyzer on SDA and SCL and triggered on a NACK. The captures showed that the bad reads always came right after a long flash erase, and the scope showed the rising edges were clean, which ruled out weak pull-ups. Looking closer, our driver had a timeout that assumed the sensor never stretched the clock, but the sensor did stretch it during its conversion, and when the flash erase delayed our ISR, the timeout fired mid-byte. We fixed the driver to honour clock stretching and moved the erase into smaller chunks. Since then I start with a capture of the bus before arguing about whose fault it is."
A story with no measurement, where the bug was fixed by trying random changes until it went away.
Explain: printf changed the timing, so the race is hidden, not fixed; it will come back.
Observe better: toggle a spare pin and watch it on a logic analyzer, log to a RAM buffer, or use trace output.
Fix the cause: find the shared data or ordering problem and protect it properly.
"I'd be kind about it, because it's a very natural thing to try, but I wouldn't let it ship. A printf in an ISR is slow and changes the timing of everything around it, so the bug disappearing tells us it's timing-related, which is useful, but not that it's fixed. It'll come back with a different compiler setting or a faster clock, probably in the field. It can also cause its own problems, since printf may not be reentrant and blocks other interrupts while it runs. I'd suggest we pull it out and observe without disturbing much: toggle a spare GPIO at the start and end of the ISR and at the main loop's critical points, and watch them together on a logic analyzer. Or write timestamps into a RAM buffer and dump it later. Once we can see the overlap, we fix the real race."
Agreeing to ship because the tests now pass, or blaming the compiler without investigating.
Get data: reset cause, uptime, a crash record saved to a no-init RAM area or flash, and firmware version from every unit.
Look for patterns: a regular interval suggests a counter overflow or leak; clustering by site suggests power or temperature.
Reproduce faster: start counters near their limits, run stress and power-dip tests, then fix and confirm with field data.
"First I'd stop guessing and get data from the devices. I'd make sure every unit records its reset cause from the reset-cause register, its uptime before the reset, and on a fault, the stacked PC and fault registers saved in a RAM area that isn't cleared at startup or in flash. That gets reported on the next boot. Then I'd look for patterns. If the uptime is always roughly the same, I think of a counter overflowing, a slow memory leak or heap fragmentation, or a stack creeping up. If it's brown-out resets clustered at certain sites, it's more likely power quality or a battery sagging in the cold. Once I have a theory, I try to reproduce it faster, for example by starting timers near their wrap value, or running a stress test with supply dips. And I'd ship the fix with the same logging, so the field data proves it worked."
Trying random fixes and shipping them to see if the resets stop, without adding any way to learn what happened.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.