DoorDash Embedded Developer (Mid-Level) Interview Preparation Guide
DoorDash's engineering interview process typically consists of multiple rounds designed to assess technical depth, embedded systems knowledge, hardware-software integration capability, and cultural fit. For a mid-level embedded developer, expect a mix of coding assessments, embedded systems problem-solving, hardware interaction scenarios, and behavioral evaluation. The process emphasizes practical problem-solving, optimization thinking, and cross-functional collaboration.
Interview Rounds
Recruiter Screening
What to Expect
Initial conversation with recruiter to assess background, role fit, and motivation. Recruiter will discuss your embedded systems experience, proficiency with relevant programming languages, and interest in DoorDash's business and engineering culture. This round also covers logistical details and sets expectations for subsequent interviews.
Tips & Advice
Frame your experience around outcomes and impact rather than listing technologies. For example, explain how you optimized power consumption in a previous embedded project and the measurable results. Prepare 2-3 specific examples of embedded systems work you're proud of. Research DoorDash's delivery platform and mention genuine interest in how embedded systems might enhance their operations. Ask thoughtful questions about the team, tech stack, and growth opportunities.
Focus Topics
Motivation for DoorDash and Role Fit
Explain genuine interest in DoorDash's business model, delivery platform, and how embedded systems work interests you within their context
Practice Interview
Study Questions
Background and Experience Overview
Articulate your embedded systems background, key projects, and progression from junior to mid-level engineer
Practice Interview
Study Questions
Project Impact and Results
Provide 2-3 examples of past embedded projects with quantifiable outcomes (performance improvements, power savings, reduced latency)
Practice Interview
Study Questions
Technical Skills and Language Proficiency
Clearly communicate proficiency in C, C++, and other relevant languages; mention experience with microcontrollers, firmware, and real-time systems
Practice Interview
Study Questions
Technical Phone Screen
What to Expect
30-45 minute technical conversation with an engineer from the team. Expect practical embedded systems questions, live coding in C/C++, and discussion of how you approach hardware-software problems. The interviewer will assess your problem-solving methodology, understanding of embedded constraints, and ability to think through optimization trade-offs.
Tips & Advice
Think aloud as you solve problems; interviewers care about your reasoning process, not just the final answer. Be prepared to optimize code for memory and performance—discuss trade-offs explicitly (e.g., 'Using lookup tables saves CPU cycles but requires more ROM'). When discussing past projects, explain the hardware constraints you faced and how you addressed them. For unfamiliar hardware concepts, ask clarifying questions rather than guessing.
Focus Topics
Real-Time Operating Systems (RTOS) Concepts
Explain task scheduling, context switching, semaphores, mutexes, and synchronization; understand deterministic behavior requirements
Practice Interview
Study Questions
Hardware-Software Integration and Debugging
Describe experience with hardware debuggers, JTAG, oscilloscopes, and techniques for diagnosing hardware-software interaction issues
Practice Interview
Study Questions
Code Optimization for Embedded Constraints
Explain trade-offs between code size, execution speed, power consumption, and latency; discuss optimization techniques for resource-constrained environments
Practice Interview
Study Questions
Microcontroller Architecture Basics
Understand CPU registers, memory layout (RAM, ROM, Flash), interrupt handling, and hardware peripherals; know differences between common platforms (ARM, x86, RISC-V)
Practice Interview
Study Questions
C/C++ Fundamentals and Memory Management
Master pointer arithmetic, dynamic memory allocation, stack vs. heap trade-offs, and common embedded patterns; be comfortable with bit manipulation
Practice Interview
Study Questions
Embedded Systems Technical Interview - Part 1
What to Expect
Onsite or video technical interview (60 minutes) focused on embedded systems design and coding under realistic constraints. You will solve a medium-complexity embedded problem that involves hardware interaction, real-time behavior, or resource optimization. Expect detailed code review, discussion of alternative approaches, and questions about how your solution scales.
Tips & Advice
Write clean, commented code. Interviewers expect mid-level engineers to consider edge cases, error handling, and performance. When designing a solution, articulate constraints first (memory budget, latency requirements, power limits) before coding. Be prepared to estimate code size, execution time, and memory usage. Discuss trade-offs between different approaches—for example, interrupt-driven vs. polling. Ask clarifying questions if the problem statement is ambiguous.
Focus Topics
Error Handling and Robustness in Embedded Code
Implement watchdog timers, timeout mechanisms, and graceful degradation; handle hardware failures and sensor errors
Practice Interview
Study Questions
Communication Protocols (UART, SPI, I2C, CAN)
Understand protocol fundamentals; implement drivers for serial communication, SPI buses, I2C sensor interfaces, and automotive CAN (if relevant)
Practice Interview
Study Questions
Interrupt Handling and Critical Sections
Design interrupt service routines (ISRs) with minimal latency; understand race conditions, atomic operations, and when to disable interrupts
Practice Interview
Study Questions
Peripheral Control and Register Manipulation
Work with hardware registers directly; configure timers, ADC, UART, GPIO, and other peripherals; understand bit fields and memory-mapped I/O
Practice Interview
Study Questions
Firmware Architecture and State Machines
Design and implement finite state machines for embedded workflows; structure firmware with clear separation of concerns (initialization, main loop, interrupt handlers)
Practice Interview
Study Questions
Embedded Systems Design Interview
What to Expect
60-minute technical interview (onsite) assessing your ability to design an embedded system end-to-end. You will be given a real-world scenario (e.g., 'Design a real-time tracking device for delivery packages' or 'Build a power-efficient sensor node for IoT monitoring'). Expect questions about hardware selection, communication strategy, power budget, and software architecture. Interviewers evaluate systems thinking, trade-off reasoning, and how you balance competing constraints.
Tips & Advice
Start by clarifying requirements and constraints (power budget, latency, range, cost). Sketch a block diagram showing hardware components and communication flows. For a mid-level candidate, you should identify key design trade-offs (e.g., battery life vs. update frequency) and justify your choices with reasoning. Discuss how you'd test and validate the system. Be prepared to pivot if the interviewer introduces new constraints. Show familiarity with real components (microcontrollers, wireless modules, sensors) and explain why you chose them.
Focus Topics
Real-Time Constraints and Determinism
Understand hard vs. soft real-time requirements; design systems that meet timing deadlines; choose between preemptive and non-preemptive scheduling
Practice Interview
Study Questions
Wireless Communication Design (WiFi, BLE, LTE, LoRaWAN)
Evaluate trade-offs between range, bandwidth, power consumption, and cost for different wireless protocols; design communication layers
Practice Interview
Study Questions
Power Management and Battery Optimization
Estimate power consumption per component, design low-power modes, implement duty cycling, and optimize for battery life in wireless devices
Practice Interview
Study Questions
Embedded System Architecture and Block Diagrams
Design complete system architecture with processor selection, memory hierarchy, peripheral layout, and component integration strategy
Practice Interview
Study Questions
Trade-offs: Cost vs. Performance vs. Power vs. Reliability
Articulate reasoning for component selection and architecture choices; justify trade-offs between competing requirements
Practice Interview
Study Questions
Systems Integration and Problem-Solving Interview
What to Expect
60-minute technical interview exploring complex embedded challenges involving integration, debugging, and performance optimization. You may be asked to troubleshoot a failing system (e.g., 'This device works in lab but fails in the field—diagnose the issue'), optimize firmware for memory constraints, or design a reliable sensor fusion system. Interviewers assess your debugging methodology, systems thinking, and ability to work under ambiguity.
Tips & Advice
For troubleshooting scenarios, ask questions systematically to narrow down root cause (hardware vs. software, systematic vs. intermittent). For optimization challenges, think about measurement first—profile the system before optimizing. Discuss trade-offs between different solutions (e.g., firmware complexity vs. hardware cost). Show familiarity with debugging tools (JTAG, oscilloscope, logic analyzer, profilers). If you get stuck, explain your hypothesis and next debugging steps rather than guessing.
Focus Topics
Hardware-Software Interface Issues and Timing
Diagnose issues related to signal integrity, clock domains, setup/hold time violations, and metastability; understand timing constraints across hardware-software boundary
Practice Interview
Study Questions
Testing and Validation Strategies for Embedded Systems
Design unit tests, integration tests, and hardware-in-the-loop tests; use simulation and mocking; validate real-time behavior and edge cases
Practice Interview
Study Questions
Memory Optimization and Code Size Reduction
Identify memory bottlenecks; implement efficient data structures; use compiler optimizations; understand ROM/RAM trade-offs and linker scripts
Practice Interview
Study Questions
Debugging Embedded Systems and Root Cause Analysis
Systematically diagnose hardware-software failures; use profiling and measurement tools; distinguish between hardware issues, firmware bugs, and timing problems
Practice Interview
Study Questions
Concurrency and Race Conditions in Embedded Systems
Design safe concurrent firmware with multiple interrupt handlers; identify and prevent race conditions; use synchronization primitives correctly
Practice Interview
Study Questions
Behavioral and Culture Fit Interview
What to Expect
45-minute behavioral interview with a team member or manager assessing communication, collaboration, learning ability, and alignment with DoorDash values. Expect questions about past projects, handling disagreements, learning from failures, and how you approach ambiguity. Interviewers evaluate whether you can work cross-functionally (hardware engineers, firmware teams, logistics operations) and contribute to a fast-paced, execution-focused culture.
Tips & Advice
Use STAR method (Situation, Task, Action, Result) for behavioral questions. Emphasize outcomes and impact, not just technical details. For DoorDash, highlight examples where you balanced speed and quality, worked with hardware engineers, or navigated operational constraints. Show self-awareness by discussing mistakes you've made and how you learned. Ask thoughtful questions about the team's challenges and how they measure success. Demonstrate curiosity about DoorDash's logistics and delivery domain.
Focus Topics
Communication and Influence Within Technical Team
Share examples of presenting technical ideas, advocating for a design decision, or helping teammates solve problems
Practice Interview
Study Questions
Learning from Failures and Technical Debt
Reflect on a project that didn't go as planned; explain what you learned and how you'd approach it differently
Practice Interview
Study Questions
Cross-Functional Collaboration (Hardware and Firmware Teams)
Demonstrate ability to work effectively with hardware engineers, coordinate on interfaces, and communicate across disciplines
Practice Interview
Study Questions
Balancing Speed and Quality
Explain how you prioritize in time-constrained environments; discuss examples where you shipped fast vs. invested in robustness
Practice Interview
Study Questions
Handling Ambiguity and Operational Constraints
Describe situations where requirements were unclear; show how you clarified goals, tested assumptions, and iterated with stakeholders
Practice Interview
Study Questions
Frequently Asked Embedded Developer Interview Questions
You are the lead embedded developer responsible for migrating a legacy bare-metal firmware to an RTOS to support additional concurrency requirements. Describe a project plan with incremental migration steps, criteria for selecting modules to migrate first, verification and validation strategy (unit tests, integration tests, HIL), risk mitigation for timing regressions, how to involve hardware engineers, and how to train the team on RTOS best practices.
Sample Answer
Plan overview (incremental rollout)
I’d run a phased 6–9 month migration with iterative 2–4 week sprints: prototype RTOS on a non‑critical board → migrate lowest-risk modules → add concurrency primitives and IPC → migrate critical drivers and schedulable services → full integration and performance tuning → release. Each sprint delivers a runnable build that can be regression-tested on hardware.
Criteria for module order
- Start with low-risk, well‑encapsulated modules (logging, telemetry)
- Next: device drivers with deterministic timing but isolated state (I2C, SPI)
- Then: communication stacks and non‑hard‑real‑time tasks (CLI, telemetry upload)
- Last: hard real‑time control loops and safety-critical code (motor control, sensor fusion)
Rationale: minimize scope of timing regressions and build confidence.
Verification & validation strategy
- Unit tests: use C unit framework (Ceedling/CMock) for logic and API contracts.
- Integration tests: host-in-loop for driver+HAL combinations; emulator where available.
- HIL: automated test rigs exercising sensor stimuli and actuators; power, temperature, boundary inputs.
- Continuous Integration: each PR runs unit + static analysis + selected hardware smoke tests.
- Performance tests: trace scheduling with RTT/ITM or Tracealyzer to measure latencies and jitter.
Risk mitigation for timing regressions
- Characterize CPU load before/after; set utilization targets (e.g., <70%).
- Use priority ceiling/mutexes to avoid inversion; instrument and limit ISR runtime.
- Ramp concurrency gradually; keep critical loops single-threaded until proven.
- Maintain compile‑time config flags to build bare‑metal fallback for comparisons.
- Canary builds and staged field rollouts on a small fleet.
Involving hardware engineers
- Early joint spike to validate board bring-up and timers.
- Share timing budgets, ISR entry/exit hooks, and HIL scenarios.
- Co-design test fixtures and signal-level acceptance criteria.
- Weekly syncs and paired debugging sessions during integration sprints.
Training the team
- Hands‑on bootcamp: RTOS fundamentals, IPC patterns, priority and deadlock demos.
- Coding guidelines: minimal work in ISRs, stack sizing, watchdog usage.
- Pair programming, code reviews with checklists, and recorded walkthroughs of tricky fixes.
- Documented cookbook of patterns and anti‑patterns discovered during migration.
Outcome: controlled, test-driven migration that preserves real‑time guarantees while upskilling the team and minimizing field risk.
In C using the FreeRTOS API, write a minimal task-creation example: implement a task function that toggles an LED and sleeps for 1000 ms, and show how to create this task in main with a stack size, priority, and name. Assume functions LED_Toggle(void) and vTaskDelay(pdMS_TO_TICKS(ms)) are available and do not include board-specific headers. Show required FreeRTOS calls to create and start the scheduler.
Sample Answer
The task and main.
#include "FreeRTOS.h"
#include "task.h"
extern void LED_Toggle(void); /* provided by the board code */
static void vBlinkTask(void *pvParameters)
{
(void)pvParameters; /* unused */
for (;;) {
LED_Toggle();
vTaskDelay(pdMS_TO_TICKS(1000)); /* block this task for 1000 ms; the CPU runs other tasks or idles */
}
}
int main(void)
{
BaseType_t ok = xTaskCreate(vBlinkTask, /* task function */
"Blink", /* name, for debuggers and traces */
configMINIMAL_STACK_SIZE, /* stack depth in WORDS, not bytes */
NULL, /* parameter passed to the task */
tskIDLE_PRIORITY + 1, /* priority: higher number = higher priority */
NULL); /* optional handle out */
if (ok == pdPASS) {
vTaskStartScheduler(); /* does not return when it starts successfully */
}
for (;;) { } /* task creation failed (out of FreeRTOS heap), or the scheduler could not start */
}
This file compiled without warnings under arm-none-eabi-gcc 14.2.1 (-mcpu=cortex-m3 -mthumb -O1 -Wall -Wextra -Werror) against the FreeRTOS kernel (main branch, commit 8be86d4, ARM_CM3 port).
What each piece does.
vBlinkTaskis a normal C function that never returns: an RTOS task is a thread with its own stack, so it loops forever. It toggles the LED and callsvTaskDelay, which moves the task to the blocked state (not running and not ready: the scheduler skips it until its delay expires) for 1000 ms of ticks so the scheduler can run lower-priority tasks or the idle task (a task the kernel creates itself that runs when nothing else is ready). A task must never fall off the end of its function; if it has to finish, it callsvTaskDelete(NULL).pdMS_TO_TICKS(1000)converts milliseconds to ticks usingconfigTICK_RATE_HZ; the macro is integer arithmetic, so a time shorter than one tick converts to 0.xTaskCreate(function, name, stack depth, parameter, priority, handle)allocates the task's stack and control block from the FreeRTOS heap (a fixed-size memory pool the kernel manages, sized in the configuration file) and puts the task in the ready list. It returnspdPASSon success, or an error code (errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY) when the heap is too small, which is why the result is checked. The stack depth is in words, not bytes: the kernel header says so, so 128 words is 512 bytes on a 32-bit core.configMINIMAL_STACK_SIZEis the idle task's size and is only a starting point; measure real use withuxTaskGetStackHighWaterMark. The name is only for debuggers and traces.- Priority: a higher number is a higher priority, and
tskIDLE_PRIORITY(0) is the idle task's level. A lone task attskIDLE_PRIORITY + 1is valid and keeps the idle task able to run while the LED task sleeps. vTaskStartScheduler()creates the idle task, starts the tick and never returns on success. The kernel source says it is only reached again when the kernel could not start because there was not enough FreeRTOS heap for the idle task or timer task, which is what the trailingfor (;;)is for.- The build also needs
FreeRTOSConfig.h(withINCLUDE_vTaskDelayset to 1,INCLUDE_xTaskDelayUntilas well if you usexTaskDelayUntilas in the comparison below, and tick, heap and interrupt-priority settings, plus every other optionFreeRTOS.hinsists on being defined, for exampleconfigUSE_IDLE_HOOKandconfigUSE_TICK_HOOK, set to 0 here), the kernel sourcestasks.c,list.c,queue.c, a heap implementation such asheap_4.c, and the port files for your core.configSUPPORT_DYNAMIC_ALLOCATIONdefaults to 1; with it at 0 you usexTaskCreateStaticand supply the buffers yourself.
vTaskDelay does not make a fixed-period loop. The delay starts when the call is made, so the period is the delay plus the time the loop body took (and any preemption). The FreeRTOS header says vTaskDelay is not a good way of controlling the frequency of a periodic task and points to xTaskDelayUntil, which wakes at absolute tick times and does not accumulate the drift. The same task body, with 3 ticks of work added each iteration, was built both ways and run on QEMU's lm3s6965evb machine (a Cortex-M3 board; configTICK_RATE_HZ 1000, UART output). The task file used (LED_Toggle is an empty stub, and the UART helpers write to the QEMU board's serial port):
#include <stdint.h>
#include "FreeRTOS.h"
#include "task.h"
#define UART0_DR (*(volatile uint32_t *)0x4000C000u)
static void put(const char *s) { while (*s) UART0_DR = (uint8_t)*s++; }
static void putu(unsigned long v)
{
char b[12]; int n = 0;
do { b[n++] = (char)('0' + v % 10); v /= 10; } while (v);
while (n) UART0_DR = (uint8_t)b[--n];
}
/* prints "<label> toggle <n> at tick <t>" */
static void line(const char *label, unsigned long n, unsigned long t)
{
put(label); put(" toggle "); putu(n); put(" at tick "); putu(t); put("\n");
}
#ifdef USE_UNTIL
#define LABEL "xTaskDelayUntil"
#else
#define LABEL "vTaskDelay "
#endif
void LED_Toggle(void) { }
static void burn_ticks(TickType_t n)
{
TickType_t t0 = xTaskGetTickCount();
while (xTaskGetTickCount() - t0 < n) {}
}
static void vBlinkTask(void *pvParameters)
{
(void)pvParameters;
#ifdef USE_UNTIL
TickType_t last = xTaskGetTickCount();
#endif
for (int n = 0; n < 4; n++) {
LED_Toggle();
line(LABEL, (unsigned long)n, xTaskGetTickCount());
burn_ticks(3); /* 3 ticks of work per iteration */
#ifdef USE_UNTIL
(void)xTaskDelayUntil(&last, pdMS_TO_TICKS(1000));
#else
vTaskDelay(pdMS_TO_TICKS(1000));
#endif
}
put("done at tick "); putu(xTaskGetTickCount()); put("\n");
/* semihosting SYS_EXIT */
register uint32_t r0 __asm("r0") = 0x18, r1 __asm("r1") = 0x20026;
__asm volatile("bkpt 0xAB" :: "r"(r0), "r"(r1));
vTaskDelete(NULL);
}
USE_UNTIL selects the xTaskDelayUntil branch and LABEL is the text printed. This test main is appended to the same file (so it can call the file's static put) and replaces the one shown earlier; it adds two put calls that print the == build flags header and create ok:
int main(void)
{
#ifdef USE_UNTIL
put("== build flags: -DUSE_UNTIL\n");
#else
put("== build flags: none\n");
#endif
if (xTaskCreate(vBlinkTask, "Blink", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 1, NULL) == pdPASS) {
put("create ok\n");
vTaskStartScheduler();
}
for (;;) { }
}
The rest of the build is the usual bare-metal Cortex-M3 set: FreeRTOSConfig.h with INCLUDE_vTaskDelay, INCLUDE_vTaskDelete and INCLUDE_xTaskDelayUntil set to 1 (and xPortPendSVHandler, xPortSysTickHandler and vPortSVCHandler mapped to the vector-table names PendSV_Handler, SysTick_Handler and SVC_Handler), a start-up file with the vector table and a reset handler that copies .data and zeroes .bss, a linker script (flash at 0, RAM at 0x20000000) and, for memcpy and memset, newlib's libc. QEMU is started with -M lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel so that the semihosting exit works. After the fourth iteration the task prints the tick count once more (the done line) and exits through semihosting, a call that asks the emulator to stop. Compiled with and without -DUSE_UNTIL (arm-none-eabi-gcc 14.2.1, FreeRTOS commit 8be86d4) and run under QEMU, the two runs print:
== build flags: none
create ok
vTaskDelay toggle 0 at tick 0
vTaskDelay toggle 1 at tick 1003
vTaskDelay toggle 2 at tick 2006
vTaskDelay toggle 3 at tick 3009
done at tick 4012
== build flags: -DUSE_UNTIL
create ok
xTaskDelayUntil toggle 0 at tick 0
xTaskDelayUntil toggle 1 at tick 1000
xTaskDelayUntil toggle 2 at tick 2000
xTaskDelayUntil toggle 3 at tick 3000
done at tick 4000
With vTaskDelay each toggle lands 3 ticks later than the last (1003, 2006, 3009): the period is 1003 ticks, and the done line follows the same arithmetic: the fourth toggle at 3009, 3 ticks of work to 3012, then the 1000-tick delay to 4012. With xTaskDelayUntil the toggles land on 0, 1000, 2000, 3000, and done is at 4000 because the last wake time is exactly 3000 + 1000. Choose vTaskDelay for a relative pause (wait 1 s after finishing) and xTaskDelayUntil when the toggle rate matters. For a blinking LED the drift is harmless; for a sampling or control loop it is not.
Design a modular device driver architecture for a family of environmental sensors (temp/pressure/humidity) that can be hot-plugged, provide diagnostics, and expose a common API to applications. Include how drivers register capabilities, how to perform runtime discovery, how to support per-device calibration data, and how to run unit/integration tests without physical sensors.
Sample Answer
Clarify requirements & constraints
- Family: temp/pressure/humidity sensors, hot-pluggable (bus: I2C/SPI/UART/USB), limited MCU resources, must expose a uniform API to apps, support per-device calibration, diagnostics, and sensorless testing.
High-level architecture
- Kernel/RTOS driver manager + per-device modular sensor drivers + calibration store + diagnostics subsystem + app-facing HAL API.
- Drivers implemented as loadable modules (statically linked in constrained devices) with a well-defined registration interface.
Driver registration & capabilities
- Each driver implements a registration struct filled at init:
- device_id, bus_types[], capability_flags (TEMP/PRESS/HUMID), read()/config()/self_test(), calibration_schema_version.
- Driver manager calls register_sensor_driver(&driver_desc). Manager keeps registry keyed by bus+addr and capability mask.
Runtime discovery & hot-plug
- Bus watchers detect attach/detach events (I2C probe, USB enumeration). On attach:
- manager matches probe info to registered drivers, instantiates device context, loads calibration, publishes device descriptor to apps via event.
- On detach: mark device offline, queue graceful shutdown.
Calibration per-device
- Per-device calibration blob stored in non-volatile store (filesystem/partition or flash KV). Schema includes version, offsets/scales, timestamps, checksum.
- Driver applies calibration on read() path; calibration update API writes checksumed blob and triggers revalidate.
Diagnostics & self-test
- Drivers expose diag ops: built-in self-test, raw read, noise statistics, health counters (comm errors, CRCs).
- Diagnostics subsystem aggregates and exposes standardized status codes and logs over API and optional telemetry.
Testing without hardware
- Provide a Sensor Simulator module that implements same driver registration API and injects synthetic data, error modes, timing jitter.
- Unit tests: build driver logic linked against simulator and mocked bus layer (link-time fakes).
- Integration tests: run on hardware-in-loop using USB-to-I2C bridge or SoC test harness; CI runs simulator-based test vectors including calibration/diag.
- Use dependency injection: abstract bus ops via function table so tests swap real bus with simulator/mocks.
Trade-offs
- Static linking simpler on small MCUs; dynamic modules add flexibility but cost RAM/flash.
- Calibration on MCU keeps latency low; remote calibration offloads storage but needs secure transport.
This design yields modular, testable, hot-pluggable sensor support with clear diagnostics and per-device calibration.
An algorithm you rely on runs in O(n^2) time over a dataset that keeps growing, but your target device only has 32KB of RAM. How would you rework it to fit, and what would guide your choice of approach?
Sample Answer
Framing
This bundles two separate problems: an unbounded RAM ceiling (32 KB won't hold the whole growing dataset indefinitely) and an unbounded time cost (O(n^2) work growing with n). They need different fixes, but on a constrained device they interact, a fix for one alone can still fail on the other.
If the O(n^2) shape comes from all-pairs comparison
Naive duplicate detection or a naive nearest-neighbor scan comparing every element to every other element is the classic source of O(n^2). The standard time fix is a better algorithm for the same problem: sort once (O(n log n)) and compare only adjacent elements, or use a hash-based approach for exact matches (O(n) average), whichever matches what's actually being computed.
If holding the full dataset is what's actually blowing the 32 KB budget
Stop assuming full materialization is possible at all. Process in a streaming or windowed fashion, keeping only a bounded summary or a bounded window of recent elements (a fixed-size ring buffer) rather than the whole growing set. Some algorithms fundamentally need to see all pairs across the entire unbounded history, exact duplicate detection forever, for instance, and if that genuinely can't fit in 32 KB, the honest move is redefining the problem in terms of a bounded window ("duplicates within the last N items"), a real product trade-off to surface explicitly, not something to silently approximate.
Approximate structures as a middle ground
If exact correctness over the full growing set isn't strictly required, a fixed-memory probabilistic structure, a Bloom filter for approximate membership or duplicate checks, gives bounded RAM regardless of how large the dataset grows, at the cost of a known false-positive rate. This directly targets the "dataset keeps growing but RAM doesn't" mismatch in a way plain algorithm-swapping doesn't.
External storage as a last resort
If the device has flash beyond the 32 KB of RAM, spill the working set there and read back only what's needed for each step, a merge-sort-style external algorithm, trading I/O time (flash latency and write endurance) for RAM. Whether this is viable depends on flash wear characteristics and whether the access pattern is sequential enough to suit flash's block-oriented nature.
What guides the choice
The actual required correctness semantics, does the answer really need the full unbounded history, or is a recent window acceptable, how tolerant the application is of approximation (a monitoring or dedup use case may tolerate a Bloom filter's false positives, a billing or safety-critical one may not), and whether flash or external storage is even available to trade against RAM, on the smallest MCUs it may not be.
Compare inline functions and C preprocessor macros for small operations in embedded C. Discuss type checking, debugging experience, code-size and performance implications. When would you prefer an inline function over a macro and vice versa in a resource-constrained firmware project?
Sample Answer
Short answer. Prefer a static inline function for anything that computes a value from typed arguments, and keep macros for the things only the preprocessor can do. Both normally produce the same machine code once the optimiser runs; the difference is correctness and debuggability.
Type checking. A macro (a #define that the preprocessor, the text-substitution step that runs before the compiler proper, expands wherever it is used) is text substitution before the compiler sees types: #define MAX(a, b) ((a) > (b) ? (a) : (b)) happily compares an int32_t with a float or a signed with an unsigned value. An inline function has a signature, so arguments are converted and checked exactly once, and a wrong pointer type is a diagnostic (a compiler warning or error message).
Macro bugs that a function cannot have. Run in a gcc:14 container (GCC 14.4, -O2 -Wall -Wextra -fsanitize=address,undefined):
#include <stdio.h>
#include <stdint.h>
#define SQUARE(x) (x * x)
#define MAX(a, b) ((a) > (b) ? (a) : (b))
static inline int32_t square_i32(int32_t x) { return x * x; }
static inline int32_t max_i32(int32_t a, int32_t b) { return a > b ? a : b; }
static int next(void) { static int c = 0; return ++c; }
int main(void) {
int n = 3;
printf("SQUARE(n+1)=%d square_i32(n+1)=%d\n", SQUARE(n + 1), (int)square_i32(n + 1));
int a = MAX(next(), 0); /* next() runs twice when it is positive */
printf("MAX(next(),0)=%d, next() now returns %d\n", a, next());
static int d = 0;
int m = max_i32(++d, 0);
printf("max_i32(++d,0)=%d, d=%d\n", m, d);
return 0;
}
SQUARE(n+1)=7 square_i32(n+1)=16
MAX(next(),0)=2, next() now returns 3
max_i32(++d,0)=1, d=1
SQUARE(n + 1) expands to (n + 1 * n + 1) = 7, not 16: the missing parentheses around the parameter. MAX(next(), 0) evaluates next() twice, so a side effect (here a counter, in firmware a FIFO pop or a register read) happens twice and the result is 2 instead of 1; the function evaluates its argument once and gives 1. Fully parenthesising the macro fixes the first bug but not the second.
Debugging. A function has a symbol, a line, a breakpoint location and a stack frame; a macro expands into the caller, so a breakpoint lands on the use site and a watch cannot name it. GCC's -g3 (the highest debug-information level) records macro definitions so gdb can expand them, but stepping through macro-generated logic is still harder. An inline function not inlined (typically at -O0) is steppable; one that is inlined at -O2 loses its own frame but keeps line information.
Code size and speed. Neither is automatically smaller. A macro is always expanded in place, so repeated use of a large macro copies its body every time. An inline function is a request, not an order: the compiler decides, usually using -O2 or -Os heuristics, and may emit one shared out-of-line copy. For a tiny operation the result is the same instruction either way: compiling static inline uint32_t set_bits(uint32_t v, uint32_t m) { return v | m; } called with 0x80u gives, for Cortex-M4 at -O2 (arm-none-eabi-gcc 14.2.1), just orr r0, r0, #128 and bx lr, with no call. Read it as: orr ORs the constant 128 (0x80) into register r0, which holds the argument v and, by the Arm calling convention, also carries the return value, and bx lr returns to the caller. The whole function is those two instructions; the inline function's body has become one OR. When flash is tight, -Os and reading the map file decide, and __attribute__((always_inline)) or noinline override the compiler when you have measured. Mark header functions static inline so every translation unit (one .c file together with everything it #includes, compiled as a unit) gets a private copy and no multiple-definition error appears.
When a macro is still the right tool. The everyday cases come first, because every C project uses them: include guards (#ifndef wrappers that stop a header being pasted in twice) and conditional compilation (#if defined(BOARD_X) choosing code per board or build). Next are __FILE__/__LINE__ capture in assertion and logging macros, which only the preprocessor can do because a function would report its own line, not the caller's. The remaining jobs are rarer but have no function equivalent. This program shows four of them; it was built with gcc -std=c11 -O2 -Wall -Wextra -fsanitize=address,undefined in the same container:
#include <stdio.h>
#include <stdint.h>
/* X-macro: one list drives an enum and a name table. */
#define EVENT_LIST(X) \
X(EV_TX_DONE) \
X(EV_RX_BYTE) \
X(EV_ERROR)
#define AS_ENUM(name) name,
#define AS_NAME(name) #name,
enum event { EVENT_LIST(AS_ENUM) EV_COUNT };
static const char *const event_names[] = { EVENT_LIST(AS_NAME) };
/* Token pasting: REG_##name builds an identifier from a name. */
#define REG_CTRL 0x40000000u
#define REG_STATUS 0x40000004u
#define REG_ADDR(name) REG_##name
/* Stringising (#cond) and __LINE__: an assertion that reports where it failed. */
#define CHECK(cond) \
do { if (!(cond)) printf("check failed: %s at line %d\n", #cond, __LINE__); } while (0)
/* _Generic (C11): pick a function by argument type. */
#define PRINT_VAL(x) _Generic((x), int: print_int, double: print_double)(x)
static void print_int(int v) { printf("int %d\n", v); }
static void print_double(double v) { printf("double %.1f\n", v); }
int main(void) {
printf("%d events, event 1 is %s\n", EV_COUNT, event_names[EV_RX_BYTE]);
printf("REG_ADDR(STATUS) = 0x%08X\n", (unsigned)REG_ADDR(STATUS));
int fifo_len = 0;
CHECK(fifo_len > 0);
PRINT_VAL(3);
PRINT_VAL(2.5);
return 0;
}
3 events, event 1 is EV_RX_BYTE
REG_ADDR(STATUS) = 0x40000004
check failed: fifo_len > 0 at line 33
int 3
double 2.5
Reading it: the X-macro EVENT_LIST is a list of names that takes another macro X as a parameter; applying it with AS_ENUM yields EV_TX_DONE, EV_RX_BYTE, EV_ERROR, for the enum and applying it with AS_NAME yields the strings for the name table, so adding an event to the list updates both and they cannot drift apart (the same list can also generate a dispatch table of handlers). Token pasting (##) glues two tokens into one identifier, so REG_ADDR(STATUS) becomes REG_STATUS. Stringising (#) turns the argument's text into a string literal, which is how CHECK prints the failed condition fifo_len > 0 as text. _Generic selects an expression by the type of its argument, so one PRINT_VAL name calls print_int for an int and print_double for a double.
Two further rules sit in the same area. Constants in C that must be integer constant expressions (an expression the compiler can evaluate at build time, needed for file-scope array sizes, case labels and _Static_assert operands) cannot be a const int variable in any C standard, C23 included (C23 added constexpr objects for this purpose); a #define or an enum constant works in all of them. And generic code over several types in C before C11 _Generic could only be done with macros.
C++ in firmware. constexpr (a C++ keyword marking a function or variable the compiler can evaluate at build time) replaces both for compile-time computation: constexpr uint32_t bit(unsigned n) { return 1u << n; } is typed, evaluated at compile time when the arguments are constants, usable in array sizes and template arguments, and has an address and a type for the debugger. enum class (a scoped enumeration whose names do not leak into the surrounding scope and do not convert silently to integers) and constexpr constants replace object-like macros; templates (C++ code written once for any type and instantiated by the compiler for each type used) replace type-generic macros at the cost of more careful code-size review, because each type used can generate its own copy of the code, which the map file shows.
Recommendation. Default to static inline (or constexpr in C++) for expressions with arguments, parenthesise and single-evaluate when you must write a function-like macro, and reserve macros for stringising, pasting, X-macros and conditional compilation. Check the map file when code size matters, because the inlining decision, not the keyword, determines size.
You receive a prototype board and need to measure power consumption during idle, active computation, and wireless transmit bursts. Describe step-by-step how you would instrument the board and measure current/power using available lab tools (multimeter, oscilloscope with current probe or shunt, INA219 or Coulomb counter). Discuss accuracy, sampling rate, and common measurement pitfalls.
Sample Answer
Overview / goal
I would measure idle, active compute, and radio TX energy by inserting a known sensor in the supply path and capturing current vs. time so I can compute energy = ∫V_supply·I(t) dt for each mode.
Step-by-step instrumentation
- Inspect board power path and pick insertion point (battery/regulator output to board VIN).
- Low-resistance shunt method (preferred for accuracy and bandwidth): place a 10–100 mΩ four-terminal (Kelvin) shunt in series. Measure voltage across shunt with oscilloscope differential probe or scope channel plus differential amplifier. Use scope current probe if available for quick, non-invasive checks.
- Low-cost digital method: wire INA219 or Coulomb counter in I2C path—good for long-term average and energy but limited bandwidth (~10–100 Hz) so won’t capture short TX spikes. Use for long idle/average power.
- Configure measurement: set scope sample rate ≥10× highest event frequency. For wireless TX bursts (microsecond–millisecond), use 1–10 MS/s (prefer >20 MS/s if bursts <100 µs). For INA219, accept that fast peaks will be aliased/missed.
- Capture traces for each state: idle (static), run benchmark loop (active compute), trigger on radio TX (use antenna enable or GPIO to sync). Record supply voltage simultaneously. Compute energy per phase by integrating V*I over time windows.
Accuracy & sampling
- Use low shunt resistance to minimize burden voltage and disturbance to MCU/regulator. Measure shunt precisely with 4-wire.
- Oscilloscope: vertical resolution and probe noise set detection floor; use averaging to improve SNR for repeated events. Ensure sample rate and bandwidth > harmonic content of current pulse.
- INA219: accuracy ±0.5–1% for DC but sample rate limited; calibrate with known resistor or bench DMM.
Common pitfalls
- Burden voltage causing brown-out.
- Lead inductance and probe grounding loop creating ringing—use short ground spring or differential probe.
- Using multimeter for transient peaks — RMS/DC DMMs miss fast bursts.
- Ground loops when connecting scope ground to system ground—beware shorting isolated supplies.
- Temperature drift of shunt and INA219; warm-up and calibrate.
Practical tips
- Use GPIO toggles to mark start/stop of modes for trace alignment.
- Repeat measurements and report mean ± std; show per-burst energy and average power.
- For highest fidelity, combine INA219 for long-term trends and oscilloscope+shunt for transient profiling.
Describe an OTA firmware update architecture for a constrained microcontroller with 1 MB flash that must support rollback on failure. Specify partition layout, validation steps, atomic switch-over method, and how to handle interrupted updates due to power loss or connectivity drops.
Sample Answer
Partition layout (1 MB flash)
- Bootloader: 64 KB (0x0000–0x0FFFF) — robust, always resident, minimal features.
- Partition A (primary): 480 KB (0x10000–0x85FFF) — active image.
- Partition B (secondary): 480 KB (0x86000–0xEFFFF) — candidate image.
- Metadata sector: small reserved area inside bootloader region or last flash page for image headers, flags, and rolling counter.
Image header & metadata
- Each image contains: magic, version, length, CRC32, RSA/ECDSA signature, install-state flag (EMPTY/RECEIVING/VALID/ACTIVATING/OK).
- Metadata stores active-bank pointer, trial-boot counter, and monotonic boot validation counter.
Update & validation steps
- Begin: write B header with RECEIVING, expected length and seq info.
- Transfer chunks with sequence numbers; after each chunk write and verify CRC of chunk if possible.
- After full write, compute full-image CRC and verify signature using public key in bootloader.
- Set B header to VALID atomically (write flag with flash aligned write).
- Set bootloader active-bank pointer to B and ACTIVATING state; set trial counter = N (e.g., 3).
Atomic switch-over & rollback
- Bootloader reads active-bank pointer at reset; if ACTIVATING, it boots from B but considers it trial.
- On successful runtime self-tests (app reports "I am healthy" via watchdog or commit API), firmware calls bootloader API to mark image OK — bootloader flips ACTIVATING→OK and updates pointer persistently.
- If firmware fails to commit within N boots or watchdog resets occur, bootloader reverts pointer to A and marks B as INVALID (or ERASEABLE).
- Switching is atomic by using a single flash word for the active-bank pointer and state flags; ensure writes are aligned and use checksums for metadata.
Handling interrupted updates (power loss / connectivity drop)
- Use chunked, resumable transfer with sequence numbers and RECEIVING state so bootloader can resume: partial writes leave RECEIVING; on next update attempt, sender resumes from last committed chunk.
- Detect partial/ corrupt images via magic + CRC + signature; if INVALID or RECEIVING older than timeout, bootloader ignores B and continues with A.
- Never overwrite active partition in-place: always write to secondary partition to avoid bricking.
- Garbage collection: after successful activation, erase old inactive partition in background to prepare for next update.
- Use small atomic flags and wear-leveling considerations: minimize metadata writes; include monotonic counters to prevent rollback to older signed images.
Additional notes
- Keep bootloader minimal and stored in ROM-like region; include recovery mode (serial/DFU) reachable if both partitions invalid.
- Consider rollback window (trial boots) and secure boot signature verification to prevent downgrade attacks.
On a RISC-V microcontroller without a hardware divide instruction, propose an efficient software integer division routine optimized for small divisors (e.g., dividing by constants or by a small range of values). Discuss approaches like reciprocal multiplication, shift-subtract (long division), and precomputed tables; estimate cycle counts and trade-offs between worst-case and average-case performance.
Sample Answer
Approach summary
For small divisors on a RISC‑V core without DIV, use: (1) reciprocal multiplication for fixed/constant divisors, (2) shift‑subtract long division for variable small divisors, (3) precomputed tables for very small ranges. Choose by code size, worst‑case cycles, and precision needs.
1) Reciprocal multiplication (best for constant divisors)
- Precompute multiplier M = ceil(2^k / d) and shift s = k. Then
quotient = (uint32_t)(( (uint64_t) n * M ) >> s); - Works for unsigned and can be extended for signed with corrections.
- Example C (unsigned, 32-bit, divisor d known):
// Precomputed for d=10: M=0xCCCCCCCD, s=35 (example)
static const uint64_t M = 0xCCCCCCCDULL; // 2^k/d
static const int s = 35;
static inline uint32_t div_by_10(uint32_t n){
return (uint32_t)(((uint64_t)n * M) >> s);
}
- Cycle estimate: one 64-bit multiply (on RV32G usually a software sequence or mul instruction then shift) + shift = ~3–8 cycles on RV32M/mul support; if MUL absent, much slower.
- Trade-off: excellent average & worst-case; tiny code when divisor constant.
2) Shift‑subtract long division (variable small divisors)
- Classic restoring/non‑restoring algorithm: iterate up to W bits but can early-exit if divisor > remainder.
- For small divisors (d < 256) loop iterations ~log2(d) to align; average cycles often low.
- Pseudocode outline:
int div_var(uint32_t n, uint32_t d){
uint32_t q=0, r=0;
for(int i=31;i>=0;i--){
r = (r<<1) | ((n>>i)&1);
if(r >= d){ r -= d; q |= (1u<<i); }
}
return q;
}
- Cycle estimate: ~32 iterations, each ~6–10 cycles on RV32 -> ~200–320 cycles worst-case. For small d you can normalize and reduce iterations to ~8–12 -> ~50–120 cycles.
- Trade-off: works when divisor changes; larger worst-case than reciprocal method.
3) Precomputed tables (d in small range, e.g., 1..16)
- Store reciprocals or direct quotients for n ranges: for 8-bit n and small d, table lookup is O(1).
- Memory vs speed trade-off: extremely fast (1–3 cycles) but may consume significant ROM for larger domains.
Trade-offs & recommendations
- If divisor is constant: use reciprocal multiplication — minimal cycles, small code.
- If divisor variable but within small known range: use small lookup tables or several reciprocal paths.
- If no MUL instruction available: shift‑subtract may be better than expensive software 64-bit multiplies.
- Worst-case vs average: reciprocal gives tight worst-case; long division has predictable but higher worst-case; table lookup gives best avg/worst but uses memory.
Final practical note
Measure on target core: count cycles for MUL and shift sequences, verify off‑by‑one corrections (especially for signed), and prefer compiler intrinsics or hand‑tuned assembly for tight loops.
Legacy C code reads a float's bits by casting a float pointer to a uint32_t pointer. Why is that undefined behavior, and what are two correct ways to reinterpret the value in both directions?
Sample Answer
Direct answer. Casting a float* to a uint32_t* and dereferencing it violates C's strict aliasing rule: an object of one type accessed through a pointer to an unrelated type is undefined behavior, and an optimizing compiler is allowed to assume it never happens, which can produce a wrong answer, not just a theoretical violation. The two correct fixes are memcpy between the two types, and (in C specifically) a union with both members, both of which reinterpret the bits without the compiler ever assuming non-aliasing across the boundary.
Why it is undefined, precisely. The C standard restricts which pointer type may be used to access a given object's stored value (the "effective type" rule: every object has one type it was last written as, and only an lvalue, an expression that names a specific piece of storage, of a compatible type may read or write it); accessing a float object through a uint32_t lvalue is not one of the permitted exceptions (which cover things like unsigned char*). Because this is undefined rather than merely "implementation defined," the compiler's optimizer is entitled to assume a float* and a uint32_t* never alias (two pointers alias when they point at the same memory) the same memory, and to reorder, cache, or eliminate loads/stores on that assumption. This is exactly how a miscompile happens: the bug is not in some obscure corner case, it is in the normal operation of optimizations like load/store reordering and redundant-load elimination that strict-aliasing analysis enables.
A bit-level picture first. "Reinterpreting bits" means treating the same 4 bytes once as an IEEE-754 float (the standard binary format for floating-point numbers) and once as a plain 32-bit integer, with no conversion, just a different reading of identical bits. The float 1.0f is stored as the bit pattern 0x3f800000; that is not a coincidence to memorize, it is IEEE-754's encoding (1 sign bit, 8 exponent bits, 23 mantissa bits) producing that exact pattern for the value 1.0. float_to_bits(1.0f) below returns exactly that pattern, confirming the two views are of the same bytes.
Worked miscompile, built and run at two optimization levels. A function writes through an int* alias and a float* alias to the same object, then reads back through the int*:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
__attribute__((noinline))
int bad_alias(int *i, float *f) {
*i = 1;
*f = 2.0f; /* the optimizer assumes this cannot touch the int object *i points to */
return *i; /* so it may return the earlier value, 1, without reloading */
}
static uint32_t float_to_bits(float x) { uint32_t u; memcpy(&u, &x, sizeof u); return u; }
static float bits_to_float(uint32_t u) { float x; memcpy(&x, &u, sizeof x); return x; }
static uint32_t float_to_bits_union(float x) { union { float f; uint32_t u; } v = { .f = x }; return v.u; }
int main(void) {
union { int i; float f; } u; /* both pointers really alias these 4 bytes */
int r = bad_alias(&u.i, &u.f);
printf("bad_alias returned 0x%08x, the object holds 0x%08x\n", (unsigned)r, float_to_bits(u.f));
printf("%08x %g %08x\n", float_to_bits(1.0f), bits_to_float(0x40490fdb), float_to_bits_union(1.0f));
float x = 1.0f;
unsigned legacy = *(unsigned *)&x; /* the raw cast from the question */
printf("%08x\n", legacy);
return 0;
}
Built with gcc -Wall -Wextra (GCC 14.4, aarch64 Linux container), this prints at -O0:
bad_alias returned 0x40000000, the object holds 0x40000000
3f800000 3.14159 3f800000
3f800000
and at -O2 the first line becomes bad_alias returned 0x00000001, the object holds 0x40000000 (the other lines are unchanged), together with a compile-time warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing] on the *(unsigned *)&x line; adding -fno-strict-aliasing at -O2 restores 0x40000000.
Called on a union { int i; float f; } (so the two pointers really do alias the same four bytes, as they would through a raw type-punning cast): at -O0, bad_alias returns 0x40000000 (the bit pattern of 2.0f, the value that was actually last written), because with optimizations off the compiler reloads *i rather than reusing a cached value. At -O2, the exact same source and the exact same object returns 0x00000001, the stale value from before the float write, because the optimizer's strict-aliasing analysis lets it skip reloading *i after a write through a pointer it has proven (under the aliasing rule) cannot touch the same storage. Step by step: the compiler sees *i = 1 (writes 0x00000001 into the shared bytes), keeps that value 1 in a register as a cached copy of *i, sees *f = 2.0f but, because float* and int* are assumed not to alias, does not invalidate its cached *i, and then return *i hands back the cached register value 1 instead of re-reading memory, where the real bytes now hold 2.0f's pattern. The object genuinely holds 0x40000000 in both builds (confirmed by reading it back through a memcpy-based accessor), so the -O2 build's return value is simply wrong: a silent miscompile, not a crash, and reproducible deterministically at that optimization level with that compiler. Compiling with -fno-strict-aliasing restores the -O0 answer at -O2, confirming the aliasing assumption is exactly what changed the result; at -O2, -Wstrict-aliasing=2 (GCC 14) flags a raw type-punning cast like *(unsigned *)&x at compile time with "dereferencing type-punned pointer will break strict-aliasing rules"; the warning only works while -fstrict-aliasing is active (on by default from -O2), so at -O0 the same cast compiles with no warning at all and a green -O0 build proves nothing.
Fix 1: memcpy. memcpy(&dst, &src, sizeof dst) between the two types copies the bytes through unsigned char-equivalent access, which the standard explicitly permits regardless of the source and destination's declared types, and which optimizing compilers recognize and compile down to a plain register move or load/store when the size is a compile-time constant (no real function-call overhead in practice):
static uint32_t float_to_bits(float x) { uint32_t u; memcpy(&u, &x, sizeof u); return u; }
static float bits_to_float(uint32_t u) { float x; memcpy(&x, &u, sizeof x); return x; }
Running float_to_bits(1.0f) returns 0x3f800000 (the correct IEEE-754 bit pattern for 1.0, matching the bit picture above), and bits_to_float(0x40490fdb) returns 3.14159 (the correct round-trip of pi's bit pattern), matching bit-for-bit between -O0 and -O2 builds, because memcpy never exposes the aliasing hazard to the optimizer in the first place.
Fix 2: union (C only). C explicitly permits reading a union member other than the one last written ("type punning": reading the stored bytes through a different member's type than the one used to write them), as long as the access goes through the union type itself, not through a cast pointer: union { float f; uint32_t u; } v = { .f = x }; return v.u; is well-defined in C and gives the identical bit pattern as the memcpy version. This is a C-specific guarantee; in ISO C++ the same union access is formally undefined behavior (GCC supports it as a non-standard extension there), so the memcpy form, or std::bit_cast (C++20, a standard library function that performs exactly this bit reinterpretation; requires both types be the same size and trivially copyable, meaning the type can be copied by copying its raw bytes with no special constructor logic), is the portable choice in C++.
Trade-offs and pitfalls. Do not "fix" this by disabling -fstrict-aliasing project-wide: it silences this specific miscompile but gives up a real optimization across the entire codebase and hides every other latent aliasing bug instead of fixing them, which is a maintenance trap for the next person who re-enables it. Prefer memcpy over the union when you need C++ portability or simply want one idiom that works in both languages; prefer the union inside pure C code where its clarity at the call site is valuable and the standard guarantee is unambiguous. Either fix costs nothing at a reasonable optimization level once the compiler recognizes the pattern, so there is no real performance argument for keeping the raw cast.
An embedded system has a sensor ISR that must respond within 50 microseconds, a comms ISR with a 200 microsecond budget, and low-priority housekeeping interrupts, with nesting allowed. Lay out your priority assignment, masking approach and worst-case nesting depth, and say where long work goes.
Sample Answer
Design in one paragraph. Give each class its own preemption level (the part of an interrupt's priority that decides whether it can interrupt another handler; on Cortex-M a smaller number is more urgent), ordered by deadline: the sensor ISR (interrupt service routine) most urgent, the comms ISR next, all housekeeping interrupts at one shared, least-urgent level. Use BASEPRI (the base priority mask: it blocks interrupts at or below a chosen urgency and leaves more urgent ones running) for every critical section in thread code, set at the comms level so the sensor ISR is never masked. Keep every ISR to "clear the source, move the data, set a flag", and push long work into the main loop or a task. Worst-case nesting is then three exception levels deep.
Assumptions the numbers depend on (the question gives only the three deadlines). Cortex-M3 at 48 MHz with zero-wait-state memory, so one cycle is 1/48 microsecond (20.8 ns). The Cortex-M3 Technical Reference Manual (Arm DDI 0337G, section 5.5, "Pre-emption", Figure 5-2) shows a 12-cycle latency from asserting the interrupt to the first ISR instruction executing (the vector read and the load of the first instruction run in parallel with the register push). The manual's figure covers entry only, so the 12-cycle exit cost used below is a planning assumption, not a quoted figure. That is 0.25 us at 48 MHz. The other figures are planning assumptions to be replaced by measurements: the sensor ISR body takes 8 us and the sensor fires at most once per 250 us; the comms ISR body takes 60 us and fires at most once per 1 ms; a housekeeping ISR body takes at most 20 us, and all the housekeeping interrupts together fire at most once per 10 ms; thread code never holds BASEPRI longer than 5 us; the instruction in progress when an interrupt arrives can delay entry by up to 12 cycles (0.25 us); exiting an ISR costs 12 cycles. "Response" means from the line being asserted to the ISR having done its time-critical work. One sensor event, traced: the line is asserted at time 0; up to 0.25 us passes while the instruction in progress finishes; 0.25 us of entry latency follows; the 8 us body runs; the response is complete at 8.5 us, well inside the 50 us deadline.
Priority and masking plan.
| Interrupt | Preemption level | Value (3 implemented bits shown) | Masked by thread-code BASEPRI? | Long work goes to |
|---|---|---|---|---|
| Sensor, 50 us | 1 (most urgent) | 0x20 | No: BASEPRI is set to 0x60 | Nothing long: read, timestamp, set flag |
| Comms, 200 us | 2 | 0x60 | Yes | Packet processing in a task, data moved by DMA |
| Housekeeping (several) | 3, all the same level | 0xE0 | Yes | Flag or notification, work in the main loop |
Housekeeping interrupts share a level on purpose: interrupts in the same preemption group cannot preempt one another, so they add one level of nesting in total, not one each. PRIGROUP (the priority-grouping field that splits the priority byte into preemption and subpriority parts) is set so that all implemented bits are preemption bits (no subpriority). The comms and housekeeping levels in the table are examples of such preemption levels: level 2 and level 3 are different groups, so comms can preempt housekeeping. Shared data with the sensor ISR cannot be protected by BASEPRI (the sensor ISR runs through it), so use single-writer variables, retry reads or a PRIMASK section of a few instructions, and add that time to the sensor's budget.
Worst-case response, computed.
- Sensor. Nothing less urgent can delay it except the instruction in progress and its own entry: 0.25 (instruction in progress) + 0.25 (entry) + 8 (body) = 8.5 us, which is 408 cycles at 48 MHz. Budget 50 us, margin 41.5 us. It meets the budget as long as events are at least 8.5 us apart, because an ISR cannot preempt itself; at the assumed 250 us minimum spacing that holds.
- Comms. Waiting for a BASEPRI section (5 us), plus its own 0.25 + 0.25 + 60, plus every sensor ISR that lands inside its window, each costing entry + body + exit = 0.25 + 8 + 0.25 = 8.5 us. At most one sensor event fits in a window of about 74 us when events are 250 us apart: 0.25 + 0.25 + 60 + 5 + 8.5 = 74.0 us, or 3,552 cycles. Budget 200 us, margin 126 us. The count of sensor events is ceil(74 / 250) = 1, which confirms the assumption used in the sum. Housekeeping adds nothing here, because comms preempts it.
- Total interrupt load. Each term is body time divided by minimum period, using the assumed rates (sensor once per 250 us, comms once per 1000 us, housekeeping once per 10,000 us): 8/250 + 60/1000 + 20/10000 = 3.2% + 6.0% + 0.2% = 9.4% of the CPU. Counting entry and exit as well (0.5 us per interrupt) gives 8.5/250 + 60.5/1000 + 20.5/10000 = 3.4% + 6.05% + 0.205% = 9.655%, about 9.7%.
These are an interference sum with stated inputs, not a measurement. If the sensor could fire faster than every 250 us, the count in the comms line goes up and must be recomputed.
Worst-case nesting depth and stack. The deepest chain is housekeeping, preempted by comms, preempted by the sensor ISR: three exception levels, so at most three frames on the stack on top of whatever the interrupted thread code was using. The hardware pushes eight registers per exception (xPSR, PC, LR, r12, r3 to r0), 32 bytes (the Cortex-M3 Devices Generic User Guide, Arm DUI 0552A, section 2.3.7, describes the stack frame as eight data words), and with the STKALIGN bit set (a bit in the configuration and control register, CCR) the hardware inserts one extra 4-byte word when the interrupted code's stack pointer is only 4-byte aligned, so that the stack stays 8-byte aligned. The run below shows the frame size and the three levels directly. It runs on a Cortex-M3 in QEMU (lm3s6965evb); it checks the structure, not timing, and the support header mini.h and linker script mini.ld are listed after the answer text.
#include "mini.h"
/* Three nested exception levels: housekeeping (IRQ2) is preempted by comms (IRQ1), which is preempted by the sensor (IRQ0). */
static volatile uint32_t sp_main, sp_hk, sp_comms, sp_sensor;
static inline uint32_t get_sp(void) { uint32_t s; __asm__ volatile("mov %0, sp" : "=r"(s)); return s; }
static void pend(int irq) { ISPR0 = 1u << irq; DSB(); ISB(); }
void IRQ0_Handler(void) { sp_sensor = get_sp(); } /* sensor: most urgent */
void IRQ1_Handler(void) { sp_comms = get_sp(); pend(0); } /* comms raises the sensor line while it runs */
void IRQ2_Handler(void) { sp_hk = get_sp(); pend(1); } /* housekeeping raises the comms line while it runs */
int main(void) {
AIRCR = 0x05FA0000u | (4u << 8); /* all implemented bits are preemption bits */
IPR8(0) = 0x20; IPR8(1) = 0x60; IPR8(2) = 0xE0;
ISER0 = 7;
sp_main = get_sp();
pend(2);
puts_("SP in thread code "); puthex(sp_main); putc_('\n');
puts_("SP in housekeeping ISR "); puthex(sp_hk); puts_(" below thread by "); putdec(sp_main - sp_hk); putc_('\n');
puts_("SP in comms ISR "); puthex(sp_comms); puts_(" below previous by "); putdec(sp_hk - sp_comms); putc_('\n');
puts_("SP in sensor ISR "); puthex(sp_sensor); puts_(" below previous by "); putdec(sp_comms - sp_sensor); putc_('\n');
puts_("CCR STKALIGN="); putdec((REG(0xE000ED14u) >> 9) & 1); putc_('\n');
quit();
}
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O1 -Wall -Wextra -ffreestanding -nostdlib -T mini.ld -o nest_stack.elf nest_stack.c -lgcc
qemu-system-arm -M lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel nest_stack.elf
Output (Arm GNU Toolchain GCC 14.2.1, QEMU 10.0.13; deterministic):
SP in thread code 0x2000fff0
SP in housekeeping ISR 0x2000ffd0 below thread by 32
SP in comms ISR 0x2000ffb0 below previous by 32
SP in sensor ISR 0x2000ff90 below previous by 32
CCR STKALIGN=1
Each level is exactly 32 bytes below the previous one, because at -O1 (and -O2) these leaf handlers push nothing of their own. The figure holds only at that optimisation level: built with -O0 the same program prints 56 bytes per level, because the unoptimised handlers save r7 and lr and get_sp is a real call that pushes its own registers before it samples the stack pointer. STKALIGN=1 is consistent with that: the thread's stack pointer (0x2000fff0) was already a multiple of 8, so no padding word was inserted. The 4-byte alignment words in the budget below cover the case where an interrupted stack pointer is not a multiple of 8, which can happen at each of the three entries. Real handlers add saved registers and locals; assume 24 bytes for the sensor ISR, 64 for comms and 48 for housekeeping, to be replaced by the per-function figures from -fstack-usage (a GCC option that writes each function's stack use to a .su file) and a stack-fill measurement:
| Part | Bytes |
|---|---|
| Three hardware frames, 3 x 32 | 96 |
| Software pushes and locals, 24 + 64 + 48 | 136 |
| Possible alignment words, up to one 4-byte word per frame (a handler can be entered while its stack pointer is only 4-byte aligned, for example part-way through a prologue), 3 x 4 | 12 |
| Subtotal | 244 |
| With a 25% margin (244 x 1.25 = 305) | 305, rounded up to 312 (a multiple of 8 for stack alignment), then to a round 320 |
So reserve about 320 bytes of interrupt stack, plus the depth of the thread code if it shares the same stack. If an RTOS runs its tasks on a separate stack, the interrupt stack is only the 320 bytes. Adding a fourth preemption level costs one more frame plus its handler's usage.
Where the long work goes. Anything over a few microseconds leaves the ISR. The comms ISR validates the frame header and hands a buffer index to a task; for bursty network traffic let DMA (hardware that copies data without the CPU) fill a ring of packet buffers and interrupt once per packet or per half ring, so the ISR never copies payload. The housekeeping ISRs set a flag or send an RTOS notification and the work runs in the main loop.
How to validate it on the target (QEMU cannot show cycles). Toggle a GPIO pin at entry and exit of each ISR and capture it with a logic analyser while injecting worst-case sensor and comms traffic at once; or read the DWT cycle counter (CYCCNT, a free-running register in the Cortex-M3's Data Watchpoint and Trace unit that counts processor cycles) at entry and exit. Record the maximum over a long soak, not the average, compare it with the 50 us, 200 us and 1 ms bounds, and fill the interrupt stack with a known pattern and check how much of it was overwritten.
What would flip the design: if the sensor must also run kernel calls, it has to sit at or below the RTOS ceiling and be masked by kernel critical sections, which adds their length to its 8.5 us; if a measured comms body pushes its response estimate toward 200 us, split the body and defer the rest to a task.
Support files for the run above. mini.h provides the register addresses (IPR8(i) sets interrupt i's priority byte, ISER0 enables interrupts, ISPR0 marks one pending, AIRCR holds the PRIGROUP field), the UART print helpers, and the vector table, the list of handler addresses the core reads at start; handlers the program does not define are weak placeholders resolving to 0. mini.ld puts the vectors and code in flash at address 0 and data in RAM, and NOLOAD on .bss means the section takes RAM but stores nothing in the file.
/* mini.h: just enough start-up for a Cortex-M3 (QEMU lm3s6965evb). QEMU loads the ELF straight into RAM, so there is no .data copy from flash and no .bss clearing (RAM starts zeroed). A real board needs both. */
#include <stdint.h>
#pragma GCC diagnostic ignored "-Wunused-function"
#define REG(a) (*(volatile uint32_t *)(a))
#define IPR8(i) (*(volatile uint8_t *)(0xE000E400u + (i)))
#define ISER0 REG(0xE000E100u)
#define ISPR0 REG(0xE000E200u)
#define ICSR REG(0xE000ED04u)
#define VTOR REG(0xE000ED08u)
#define AIRCR REG(0xE000ED0Cu)
#define DSB() __asm__ volatile("dsb" ::: "memory")
#define ISB() __asm__ volatile("isb" ::: "memory")
#define CPSID() __asm__ volatile("cpsid i" ::: "memory")
#define CPSIE() __asm__ volatile("cpsie i" ::: "memory")
static void putc_(char c) { REG(0x4000C000u) = (uint32_t)c; } /* UART0 data register */
static void puts_(const char *s) { while (*s) putc_(*s++); }
static void puthex(uint32_t v) { puts_("0x"); for (int i = 28; i >= 0; i -= 4) putc_("0123456789abcdef"[(v >> i) & 15]); }
static void putdec(uint32_t v) { char b[11]; int n = 0; do b[n++] = '0' + v % 10; while (v /= 10); while (n) putc_(b[--n]); }
static void quit(void) { /* semihosting SYS_EXIT */
register uint32_t r0 __asm__("r0") = 0x18, r1 __asm__("r1") = 0x20026;
__asm__ volatile("bkpt 0xab" :: "r"(r0), "r"(r1)); for (;;) {}
}
int main(void);
extern uint32_t _estack;
static void Reset_Handler(void) { REG(0x4000C030u) = (1u << 0) | (1u << 8) | (1u << 9); /* UART0 CTL: enable, TX, RX */ main(); for (;;) {} }
/* Handlers a program does not define stay weak and resolve to 0 (never taken in these programs). */
#define WEAK(n) void n(void) __attribute__((weak))
WEAK(NMI_Handler); WEAK(HardFault_Handler); WEAK(SVC_Handler); WEAK(PendSV_Handler); WEAK(SysTick_Handler);
WEAK(IRQ0_Handler); WEAK(IRQ1_Handler); WEAK(IRQ2_Handler);
__attribute__((section(".vectors"), used)) static const void *const vectors[16 + 3] = {
&_estack, Reset_Handler, NMI_Handler, HardFault_Handler, 0, 0, 0, 0, 0, 0, 0, SVC_Handler, 0, 0, PendSV_Handler, SysTick_Handler,
IRQ0_Handler, IRQ1_Handler, IRQ2_Handler };
MEMORY { FLASH (rx) : ORIGIN = 0x0, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K }
SECTIONS {
.vectors : { KEEP(*(.vectors)) } > FLASH
.text : { *(.text*) *(.rodata*) } > FLASH
.data : { *(.data*) } > RAM
.bss (NOLOAD) : { *(.bss*) *(COMMON) } > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM);
}
Want to create your own tailored preparation guide using our deep research?
Get Started for FreeInterview-Ready Courses
Visual-first, interactive, structured learning paths
Browse Embedded Developer jobs
AI-enriched listings across hundreds of company career pages
Explore Jobs