DoorDash Embedded Developer (Junior Level) Interview Preparation Guide
DoorDash's general engineering interview process typically includes a recruiter screening, technical phone screens, and onsite rounds. For an embedded developer role, expect rounds focused on embedded systems fundamentals, hands-on coding with C/C++, hardware-software integration concepts, real-time systems understanding, and behavioral fit. Junior-level interviews emphasize foundational competency, learning ability, and collaboration with hardware engineers rather than advanced architectural decisions.
Interview Rounds
Recruiter Screening
What to Expect
Initial conversation with a recruiter to assess background fit, role interest, and baseline qualifications. This is a 30-minute non-technical call focused on understanding your embedded systems experience, motivation for the role, familiarity with C/C++, and ability to commit to the interview process. Expect questions about your background in electronics, IoT projects, academic coursework, internships, or personal projects involving microcontrollers or firmware development. The recruiter will also discuss compensation, location, and timeline expectations.
Tips & Advice
Frame your experience around outcomes and learning. Don't just list languages or tools—explain what you built and what you learned from it. If you have internship or academic project experience with embedded systems, lead with concrete technical contributions. Show genuine enthusiasm for IoT and hardware-software integration. Be prepared to explain why DoorDash's domain (logistics, delivery) interests you and how embedded systems or IoT could be relevant. Ask thoughtful questions about the team, projects, and engineering culture. Research DoorDash's business and mention any awareness of their technical challenges.
Focus Topics
Projects and Technical Achievements
Prepare 2-3 embedded or IoT projects you've worked on. Be ready to discuss your specific technical contributions, challenges you solved, and what you learned.
Practice Interview
Study Questions
Motivation and Role Fit
Articulate why you're interested in embedded systems, IoT, and DoorDash specifically. Explain how this role aligns with your career goals.
Practice Interview
Study Questions
C and C++ Proficiency
Confidently discuss your comfort level with C and C++, including experience with pointers, memory management, bit manipulation, and embedded-specific coding patterns.
Practice Interview
Study Questions
Embedded Systems Background and Experience
Clearly articulate your hands-on experience with microcontrollers, firmware, IoT devices, or hardware projects. Include internships, academic coursework, personal projects, and contributions to embedded systems.
Practice Interview
Study Questions
Technical Phone Screen 1: Embedded C/C++ Fundamentals
What to Expect
45-60 minute technical phone interview focusing on embedded C/C++ coding fundamentals. You'll be asked to solve coding problems on a shared collaborative editor (e.g., CoderPad, HackerRank) involving low-level programming concepts such as bitwise operations, pointer manipulation, memory management, and embedded-specific data structures. Problems may include bit shifting, register masking, implementing simple state machines, or memory-efficient algorithms. Expect questions that probe both syntactical correctness and understanding of how code translates to hardware behavior.
Tips & Advice
Write clean, readable code even under time pressure. Embedded systems demand correctness and clarity because bugs can be difficult to debug on hardware. Walk through your thought process aloud so the interviewer understands your reasoning. For bitwise problems, explicitly explain the binary representation and show intermediate steps. Ask clarifying questions about memory constraints, performance requirements, or edge cases before diving into code. Test your solution mentally with edge cases (zero values, boundary conditions, overflow scenarios). If stuck, explain your approach verbally first before coding. Mention relevant embedded concepts like interrupt handling or peripheral interaction if applicable to the problem, but don't force them unnecessarily. Practice on platforms like LeetCode (filter for C/C++ bit manipulation and memory problems) or similar embedded-focused coding challenges.
Focus Topics
C/C++ Syntax and Standard Library Familiarity
Be fluent in C/C++ syntax, string manipulation, basic data structures (arrays, linked lists), and relevant standard library functions. Avoid over-reliance on high-level abstractions.
Practice Interview
Study Questions
Algorithm Efficiency for Constrained Environments
Solve coding problems while considering memory and processing constraints. Choose algorithms that minimize heap allocation, reduce CPU cycles, or fit within flash memory limits.
Practice Interview
Study Questions
Data Types and Memory Alignment
Understand fixed-width integer types (uint8_t, uint16_t, etc.), struct packing, and memory alignment. Know why these matter in embedded systems where memory is precious and peripheral registers are at fixed addresses.
Practice Interview
Study Questions
Embedded-Specific Coding Patterns
Know common embedded patterns such as state machines, interrupt handlers (conceptually), circular buffers, and bit-packed structures. Understand how these map to real hardware operations.
Practice Interview
Study Questions
Pointer Arithmetic and Memory Management
Understand pointer operations, address-of (&) and dereference (*) operators, pointer arithmetic, and how pointers interact with arrays and structs. Be comfortable with manual memory allocation/deallocation if required.
Practice Interview
Study Questions
Bitwise Operations and Bit Manipulation
Master bit shifting (<<, >>), bitwise AND (&), OR (|), XOR (^), and NOT (~) operators. Practice problems involving bit masks, setting/clearing/toggling bits, and bit extraction.
Practice Interview
Study Questions
Technical Phone Screen 2: Embedded Systems Concepts and Design
What to Expect
45-60 minute technical interview focusing on embedded systems fundamentals, hardware-software interaction, and real-world problem-solving. You may face scenario-based questions such as 'How would you interface with a temperature sensor via I2C?' or 'Explain how you'd implement a timer interrupt to toggle an LED every 100ms.' This round tests conceptual understanding of microcontroller architecture, peripheral interfaces (GPIO, UART, SPI, I2C), interrupt handling, and memory models. You won't write full code here; instead, explain your approach, hardware connections, register configurations, and timing considerations.
Tips & Advice
Draw diagrams when explaining hardware connections or state transitions—shared whiteboards help clarify your thinking. Structure answers by first understanding the problem (e.g., what sensor, what interface, what performance requirement), then outline the solution in layers: hardware connections, peripheral configuration, interrupt/polling strategy, and software implementation. Use correct terminology (e.g., 'I2C' not 'I squared C', 'GPIO' not 'pin'). Be honest about knowledge gaps but show problem-solving ability: 'I haven't worked with CAN bus, but based on my SPI experience, I'd approach it by...' Mention relevant embedded concepts proactively (clock configuration, pull-up resistors, timing margins). Practice with real microcontroller documentation or online tutorials to build intuition. Relate questions to projects you've built to provide concrete context.
Focus Topics
Real-Time Operating Systems (RTOS) Concepts
Understand basic RTOS concepts: tasks/threads, scheduling, synchronization primitives (mutexes, semaphores), and why RTOS is useful for managing multiple concurrent activities on resource-constrained hardware.
Practice Interview
Study Questions
Power Management and Optimization
Know basic power optimization techniques: sleep modes, clock gating, reducing interrupt frequency, and choosing appropriate peripherals. Understand trade-offs between power consumption and responsiveness.
Practice Interview
Study Questions
Memory Models and Addressing
Understand how embedded systems organize memory: separate instruction and data memory, memory-mapped I/O, flash vs. RAM, and address spaces. Know why these distinctions matter.
Practice Interview
Study Questions
Interrupt Handling and Timing
Understand interrupt concepts: what triggers an interrupt, interrupt handlers/ISRs, priority levels, and blocking behavior. Know the difference between interrupts and polling, and when each is appropriate.
Practice Interview
Study Questions
Peripheral Communication Interfaces (GPIO, UART, SPI, I2C)
Know how to interface with common peripherals: GPIO for digital input/output, UART for serial communication, SPI for high-speed synchronous communication, and I2C for multi-device low-speed buses. Understand timing, protocols, and typical use cases.
Practice Interview
Study Questions
Microcontroller Architecture Fundamentals
Understand basic microcontroller components: CPU, memory (flash, RAM, EEPROM), clock, interrupt controller, and peripheral subsystems. Know how registers control behavior and how reset/initialization works.
Practice Interview
Study Questions
Onsite Round 1: Advanced Embedded Coding Challenge
What to Expect
90-minute intensive coding session (often on a laptop with a compiler) to implement a non-trivial embedded software component. You might be asked to write firmware for a simple embedded system, such as implementing a device driver, a real-time state machine, or a communication protocol handler. For example: 'Implement a driver for an LED matrix controlled via a microcontroller' or 'Write code to handle multiple sensor inputs, perform simple data fusion, and trigger alarms.' You'll write production-quality C/C++ code, potentially with hardware simulation or provided stubs. Emphasis is on code correctness, efficiency, clarity, and evidence of embedded systems thinking (e.g., handling edge cases, respecting memory/timing constraints).
Tips & Advice
Before coding, clarify requirements with the interviewer: What are the constraints (memory, timing, power)? What edge cases matter? Are there specific hardware features to use? Outline your approach and get feedback before diving into code. Write modular, testable code even under time pressure—embedded developers value clarity. Use meaningful variable names and comments to explain non-obvious logic or hardware-specific details. Handle error cases gracefully (e.g., sensor read failures). If you run out of time, explain what remains and how you'd complete it. Test your code mentally with edge cases and boundary conditions. Ask for help if stuck: 'Can I ask about the expected behavior when the sensor fails?' Interviewers value problem-solving collaboration. Mention relevant optimizations (e.g., 'I used a state machine to minimize interrupt overhead') to demonstrate embedded thinking.
Focus Topics
Multi-Sensor Data Integration
Read from multiple sensors (e.g., temperature, pressure, motion), validate data, and perform simple fusion or filtering. Handle timing and synchronization correctly.
Practice Interview
Study Questions
Real-Time Event Processing and Interrupt Handling
Write code that responds to events (interrupts, sensor readings, timers) in real-time. Ensure minimal latency, proper prioritization, and no data loss. Use appropriate synchronization if needed.
Practice Interview
Study Questions
Performance and Memory Optimization
Write efficient code that meets timing and memory constraints. Consider algorithm complexity, minimize dynamic allocation, and optimize hot paths. Explain optimization trade-offs.
Practice Interview
Study Questions
Code Quality and Embedded Best Practices
Write defensive, maintainable code: proper error handling, avoiding magic numbers, clear variable names, comments explaining hardware-specific logic, and consideration of edge cases.
Practice Interview
Study Questions
State Machines for Embedded Systems
Implement finite state machines to model complex device behavior. Use state enums, transition logic, and action handling. State machines are everywhere in embedded systems.
Practice Interview
Study Questions
Embedded Device Driver Implementation
Design and implement a simple driver for a peripheral (e.g., LCD, sensor, button). Understand initialization, configuration, reading/writing data, and interrupt handling for the device.
Practice Interview
Study Questions
Onsite Round 2: System Design and Hardware-Software Integration
What to Expect
60-75 minute interview combining system design and behavioral questions. You'll be asked to design a complete embedded system for a realistic scenario, such as 'Design an IoT sensor node for environmental monitoring that sends data to a cloud service' or 'Design a firmware architecture for a smart delivery device that must reliably transmit location and status updates.' Focus on architecture, component selection, communication protocols, power management, scalability, and trade-offs. You'll also discuss how you'd collaborate with hardware engineers, troubleshoot hardware-software issues, and prioritize requirements under constraints. This round assesses system-level thinking, communication skills, and embedded problem-solving maturity.
Tips & Advice
Start by clarifying requirements: What is the system's purpose? What are the constraints (power, size, cost, reliability)? Who are the stakeholders? Draw block diagrams showing hardware components, communication buses, and software layers. Explain your choices: Why this microcontroller? Why SPI instead of I2C? How does power management work? Discuss trade-offs explicitly: 'Using WiFi gives faster uploads but drains battery faster, so I'd consider LTE for occasional long-range uploads and BLE for local connectivity.' Mention scalability considerations: How would the firmware scale to 1000 devices? How do you manage device firmware updates over the air? Show cross-functional thinking: How would you work with hardware engineers to define pin assignments, power budgets, and communication timing? Discuss real-world challenges: error handling, resilience, testing strategies. Relate your design to projects you've built to ground abstract concepts in experience.
Focus Topics
Reliability, Error Handling, and Fault Tolerance
Design systems that handle failures gracefully: sensor errors, communication timeouts, power glitches. Include watchdogs, error logging, and recovery strategies.
Practice Interview
Study Questions
Power Budget and Battery Life Optimization
Calculate power consumption, estimate battery life, and design strategies to extend it (duty cycling, sleep modes, efficient algorithms). Understand trade-offs with responsiveness.
Practice Interview
Study Questions
Hardware-Software Collaboration and Integration
Explain how you'd work with hardware engineers: defining pin assignments, timing requirements, electrical specifications, power budgets, and debugging hardware-software interactions.
Practice Interview
Study Questions
Firmware Update Strategy and Over-the-Air (OTA) Updates
Discuss approaches for deploying firmware updates to deployed devices: partitioning, rollback mechanisms, update verification, and minimizing downtime.
Practice Interview
Study Questions
End-to-End Embedded System Architecture
Design complete system: hardware selection (microcontroller, sensors, wireless modules), software layers (bootloader, OS, application), communication protocols, and data flow. Justify component choices.
Practice Interview
Study Questions
Wireless Communication Protocols (WiFi, BLE, LTE, LoRaWAN)
Understand when to use each protocol based on range, bandwidth, power, and latency requirements. Know trade-offs (WiFi: fast but power-hungry; LoRaWAN: power-efficient but low bandwidth).
Practice Interview
Study Questions
Onsite Round 3: Behavioral and Cross-Functional Collaboration
What to Expect
45-60 minute behavioral and team dynamics interview with an engineering manager or senior embedded engineer. You'll be asked about past experiences, teamwork, conflict resolution, and cultural fit. Expect questions like 'Tell me about a time you debugged a difficult hardware-software issue,' 'Describe a disagreement with a team member and how you resolved it,' or 'How do you approach learning a new embedded system or platform?' This round assesses communication skills, learning ability, resilience, and alignment with DoorDash's collaborative culture. For a junior-level candidate, interviewers focus on coachability, curiosity, and ability to work effectively with more senior engineers and hardware teams.
Tips & Advice
Prepare 3-4 concrete stories illustrating teamwork, problem-solving, and learning. Use the STAR method (Situation, Task, Action, Result) and quantify impact where possible. Choose stories that showcase collaboration with hardware engineers, peer learning, or overcoming technical challenges. Be honest about mistakes and what you learned: 'I initially tried to solve a timing issue in firmware when it was actually a hardware design issue. I collaborated with the hardware engineer to understand their constraints, and together we found a better solution.' Show curiosity and willingness to learn: 'I'd never used [platform/tool] before, so I studied the documentation and asked experienced team members for guidance.' Discuss how you approach debugging: methodically gather data, form hypotheses, test them, and communicate progress. Express genuine interest in DoorDash's mission and ask thoughtful questions about the team, embedded challenges, and career growth. Show awareness of embedded systems challenges in real-world products (reliability, power constraints, testing in the field). Demonstrate respect for both software and hardware expertise.
Focus Topics
Ownership and Initiative
Demonstrate taking responsibility for problems, following up on issues, and proactively improving processes. For junior level, show willingness to tackle assigned work and raise blockers early.
Practice Interview
Study Questions
Curiosity and Technical Growth Mindset
Express genuine interest in understanding systems deeply, asking 'why' questions, exploring new technologies, and continuous learning. Show examples of self-directed learning.
Practice Interview
Study Questions
Conflict Resolution and Communication
Share a story where you disagreed with a colleague (e.g., over design approach or priority) and how you resolved it. Emphasize listening, data-driven discussion, and finding common ground.
Practice Interview
Study Questions
Learning New Embedded Platforms and Technologies
Describe how you approach learning a new microcontroller, RTOS, wireless protocol, or development tool. Show resourcefulness, persistence, and willingness to explore documentation and ask questions.
Practice Interview
Study Questions
Collaboration with Hardware Engineers
Discuss experiences working with hardware teams. Show respect for hardware constraints, understanding of electrical principles, and ability to communicate across disciplines.
Practice Interview
Study Questions
Hardware-Software Debugging and Troubleshooting
Share experiences diagnosing difficult embedded bugs. Explain your systematic debugging approach: hypothesis formation, instrumentation, root cause analysis, and how you worked with hardware or software engineers.
Practice Interview
Study Questions
Frequently Asked Embedded Developer Interview Questions
Design a buddy allocator for a device with 128 KB of RAM that is safe from both threads and interrupts and uses no dynamic memory itself. How do you represent free blocks, split and coalesce, and protect the critical sections?
Sample Answer
Sizing first. A buddy allocator (every block has an order: its size is 2 to the power of its order bytes, so order 5 is 32 bytes and order 16 is 65,536 bytes; it splits a block into two equal halves, "buddies", and merges two free halves back) only deals in power-of-two block sizes. On a device with 128 KB of RAM in total, the allocator cannot own all of it: the stack, .data, .bss and the allocator's own bookkeeping have to fit too. The design below manages a 64 KB arena (order 16), the largest power of two that leaves the other 64 KB for everything else, with 32-byte minimum blocks (order 5). Doubling the arena to 128 KB would not leave room for the bookkeeping. If more is needed, a second 32 KB arena (order 15, with a 1,024-byte state array) could be managed too, but the code below keeps its arena, state and list heads as file-scope statics, so it manages exactly one arena; a second one needs those moved into a struct that every call receives.
Representing free blocks. Three pieces of state, all static arrays (nothing comes from malloc):
free_head[order]: for each order 5 to 16, the index of the first free block of that size, orNIL.- Free blocks store their own list links. A free block is unused memory, so its first four bytes hold
nextandprevas 16-bit indices (the block's offset divided by 32). A doubly linked list gives O(1) removal of a specific buddy during coalescing, which a singly linked list cannot. state[i]: one byte per 32-byte unit (2048 bytes for 64 KB). It is 0 for a unit inside a larger block, otherwise the block's order, with a flag bit saying free or allocated. This is what makesfree(p)need no size argument and no header in front of the block, and it letsfreecheck a buddy's state with one byte read.
Metadata cost, measured from the object file: 2048 bytes of state plus 34 bytes of list heads, about 3.2% of the 64 KB arena. A header-per-block design would pay on every block instead.
State byte examples. Suppose the arena is one free 64 KB block: state[0] is 0x8C (free flag 0x80 plus 12, which is order 16 - 5 + 1) and every other byte is 0. After one 32-byte allocation, state[0] is 0x01 (allocated, order 5), and the freed upper halves left behind are marked at units 1, 2, 4, 8, 16, 32, 64, 128, 256, 512 and 1024 with 0x81, 0x82, 0x83, up to 0x8B (free blocks of orders 5 to 15). A throwaway harness that includes buddy.c directly and prints the non-zero state bytes (it is not part of the test driver below) shows exactly these values right after buddy_init(), after that allocation and after the matching buddy_free: [0]=0x8C only, then [0]=0x01 with [1]=0x81, [2]=0x82, [4]=0x83, up to [1024]=0x8B, then [0]=0x8C only again. The test driver below checks behaviour and does not print them. A zero byte means "in the middle of a bigger block", so a pointer to the interior of a block is rejected. That only holds if a merge clears the state byte of the block being freed, not only that of the free buddy it absorbs (the code clears both; a stale byte left on the free buddy still has the free bit set, so a pointer to it is refused as a double free anyway): if the higher half were an allocated block being freed into a free lower buddy, its stale "allocated, order 5" byte would survive inside the merged block, and a later free of a pointer into the middle of that block would be taken for a real block and corrupt the free lists.
Why flipping one bit gives the buddy. Unit indices count 32-byte units. A block of order o covers 2^(o-5) units and starts at an index that is a multiple of 2^(o-5). Its buddy is the other half of the order o+1 block that contains it, and the two halves differ only in the bit worth 2^(o-5). Example at order 6 (64-byte blocks, 2 units each): the blocks start at indices 0, 2, 4, 6. Flipping bit 1 (the value 2) maps 4 to 6 and 6 to 4, so those are buddies (byte offsets 128 and 192), and 0 and 2 are buddies; index 2 and index 4 are neighbours but are not buddies, because merging them would not give an aligned 128-byte block. At order 5 the flip is bit 0: unit 5 and unit 4 are buddies. In the code that is idx ^ (1u << (o - MIN_ORDER)).
Trace of splitting down to 32 bytes and merging back. Start with one free 64 KB block at index 0. buddy_alloc(32) wants order 5, takes the order 16 block and halves it eleven times: the upper half of each step (indices 1024, 512, 256, ..., 2, 1) goes on the free list of orders 15, 14, ..., 5, and index 0 is returned. buddy_free then reads state[0] = 0x01, so order 5; the buddy at index 0 ^ 1 = 1 has state 0x81 (free, order 5), so they merge into an order 6 block at index 0; the buddy at 0 ^ 2 = 2 has state 0x82, merge again; and so on, eleven merges, until order 16, where the loop stops and the block goes back on the order 16 list (state[0] = 0x8C again, state[1] = 0).
Split and coalesce. On alloc(n): round n up to a power of two (at least 32), then search the lists from that order upward for the first non-empty one. Take its block and, while it is larger than needed, halve it: keep the lower half and push the upper half on the free list of the next lower order. On free(p): look up the block's order, then repeatedly compute the buddy's index by flipping one bit, idx ^ (1 << (order - 5)). If the buddy is a free block of the same order, remove it from its list, merge (the merged block starts at the lower index) and go up one order; otherwise stop and push the block. Both operations loop at most 11 times (orders 16 down to 5), so the work is bounded, which matters for interrupt latency.
Protecting critical sections. The free lists and state bytes are shared between threads and interrupt handlers, so every change to them must look atomic. A mutex cannot be taken inside an interrupt service routine (ISR, the handler the hardware runs when an interrupt fires), so the code masks interrupts around each allocator operation. On Cortex-M that means setting PRIMASK (a one-bit core register; while it is 1, all interrupts with configurable priority are held off; CMSIS describes __disable_irq as disabling IRQ interrupts by setting PRIMASK, and it needs privileged mode). The code first reads the old PRIMASK value and restores that value on exit, instead of unconditionally re-enabling: a call made from an ISR, or from a section that was already masked, then does not turn interrupts back on early. Interrupts stay masked for at most 11 split steps (in buddy_alloc) or 11 merge steps (in buddy_free), each of which is one list push or remove plus a state write. Counted from the arm-none-eabi-gcc 14.2.1 -mcpu=cortex-m3 -mthumb -Os listing (every instruction in an it block counted, whether or not its condition holds) and confirmed by tracing the worst case instruction by instruction in a QEMU Cortex-M3 model, the worst case is about 440 Thumb-2 instructions in buddy_alloc (11 empty-list checks of 6 instructions, one list_remove of 19, 11 splits of 29 instructions each, 19 of them in list_push) and about 525 in buddy_free (11 merge passes of about 43 instructions each, 19 of them in list_remove). That is an instruction count, not a time: loads, branches and flash wait states cost more than one cycle each, so the time has to be measured on the target. If a high-priority interrupt cannot tolerate even that, the alternative is to forbid allocation in ISRs entirely (they use a fixed-block pool and tasks use the buddy allocator under a mutex), and to measure the masked time on the target before deciding. buddy_free also rejects pointers outside the arena, misaligned pointers, interior pointers and double frees, because one bad free would otherwise corrupt a list silently.
The code. Run in a gcc:14 container (GCC 14.4, aarch64, with -O2 -Wall -Wextra -fsanitize=address,undefined). The interrupt masking is compiled in only for __arm__; the host build uses empty stubs, so this run tests the algorithm and not the masking.
#ifndef BUDDY_H
#define BUDDY_H
#include <stddef.h>
void buddy_init(void);
void *buddy_alloc(size_t size); /* NULL if no block is free */
void buddy_free(void *p); /* ignores NULL, rejects bad pointers */
size_t buddy_free_bytes(void); /* for tests and diagnostics */
size_t buddy_block_size(size_t request); /* size actually reserved */
#endif
#include <stdint.h>
#include "buddy.h"
#define MIN_ORDER 5u /* 32-byte smallest block */
#define MAX_ORDER 16u /* 64 KB: the whole arena */
#define ARENA_SIZE (1u << MAX_ORDER)
#define NUM_MIN (ARENA_SIZE >> MIN_ORDER) /* 2048 smallest blocks */
#define NIL 0xFFFFu
/* ---- critical section: interrupts masked, previous state restored ---- */
#if defined(__arm__)
static inline uint32_t cs_enter(void) {
uint32_t primask;
__asm volatile("mrs %0, primask\n\tcpsid i" : "=r"(primask) :: "memory");
return primask;
}
static inline void cs_exit(uint32_t primask) {
__asm volatile("msr primask, %0" :: "r"(primask) : "memory");
}
#else
static inline uint32_t cs_enter(void) { return 0; } /* host build: single thread */
static inline void cs_exit(uint32_t s) { (void)s; }
#endif
/* ---- storage: all static, nothing from malloc ---- */
static uint8_t arena[ARENA_SIZE] __attribute__((aligned(32)));
/* One byte per 32-byte unit. 0 = inside a larger block.
Otherwise (order - MIN_ORDER + 1), with 0x80 set when the block is free. */
#define FREE_BIT 0x80u
static uint8_t state[NUM_MIN];
/* A free block holds its own list links, as unit indices (16 bits each). */
typedef struct { uint16_t next, prev; } node_t;
static uint16_t free_head[MAX_ORDER + 1];
static node_t *node_at(uint16_t idx) { return (node_t *)&arena[(size_t)idx << MIN_ORDER]; }
static void list_push(unsigned order, uint16_t idx) {
node_t *n = node_at(idx);
n->prev = NIL;
n->next = free_head[order];
if (n->next != NIL) node_at(n->next)->prev = idx;
free_head[order] = idx;
state[idx] = (uint8_t)(FREE_BIT | (order - MIN_ORDER + 1u));
}
static void list_remove(unsigned order, uint16_t idx) {
node_t *n = node_at(idx);
if (n->prev != NIL) node_at(n->prev)->next = n->next;
else free_head[order] = n->next;
if (n->next != NIL) node_at(n->next)->prev = n->prev;
}
void buddy_init(void) {
for (unsigned i = 0; i < NUM_MIN; i++) state[i] = 0;
for (unsigned o = 0; o <= MAX_ORDER; o++) free_head[o] = NIL;
list_push(MAX_ORDER, 0); /* one free 64 KB block */
}
static unsigned order_for(size_t size) {
unsigned o = MIN_ORDER;
while (((size_t)1 << o) < size && o <= MAX_ORDER) o++;
return o; /* > MAX_ORDER means too big */
}
size_t buddy_block_size(size_t request) {
unsigned o = order_for(request);
return o > MAX_ORDER ? 0 : ((size_t)1 << o);
}
void *buddy_alloc(size_t size) {
unsigned want = order_for(size ? size : 1);
if (want > MAX_ORDER) return NULL;
uint32_t s = cs_enter();
unsigned o = want;
while (o <= MAX_ORDER && free_head[o] == NIL) o++; /* smallest free block that fits */
if (o > MAX_ORDER) { cs_exit(s); return NULL; }
uint16_t idx = free_head[o];
list_remove(o, idx);
while (o > want) { /* split: keep low half, free the high half */
o--;
list_push(o, (uint16_t)(idx + (1u << (o - MIN_ORDER))));
}
state[idx] = (uint8_t)(want - MIN_ORDER + 1u); /* allocated, not free */
cs_exit(s);
return &arena[(size_t)idx << MIN_ORDER];
}
void buddy_free(void *p) {
if (p == NULL) return;
uint8_t *b = (uint8_t *)p;
if (b < arena || b >= arena + ARENA_SIZE) return; /* not ours */
size_t off = (size_t)(b - arena);
if (off & ((1u << MIN_ORDER) - 1u)) return; /* not a block start */
uint16_t idx = (uint16_t)(off >> MIN_ORDER);
uint32_t s = cs_enter();
uint8_t st = state[idx];
if (st == 0 || (st & FREE_BIT)) { cs_exit(s); return; } /* interior pointer or double free */
unsigned o = (unsigned)(st & 0x7Fu) - 1u + MIN_ORDER;
while (o < MAX_ORDER) { /* coalesce while the buddy is a free block of this order */
uint16_t buddy = (uint16_t)(idx ^ (1u << (o - MIN_ORDER)));
if (state[buddy] != (uint8_t)(FREE_BIT | (o - MIN_ORDER + 1u))) break;
list_remove(o, buddy);
state[buddy] = 0; /* both halves become interior units of the merged block */
state[idx] = 0;
if (buddy < idx) idx = buddy;
o++;
}
list_push(o, idx);
cs_exit(s);
}
size_t buddy_free_bytes(void) {
size_t total = 0;
for (unsigned o = MIN_ORDER; o <= MAX_ORDER; o++)
for (uint16_t i = free_head[o]; i != NIL; i = node_at(i)->next) total += (size_t)1 << o;
return total;
}
In the test driver below, i * 2477 % n visits every index exactly once, because 2477 is odd and so shares no factor other than 1 with 2048 (they are coprime), so the frees happen in a scrambled but complete order. The driver takes every 32-byte block, frees them in a scrambled order, runs a seeded random alloc and free stress with a tag check on each block, tries bad frees, and finally tries three bad pointers on a live block that was formed by a merge (a pointer into its middle, a misaligned one, and an aligned one a full arena length away), checking that nothing was freed and the block's bytes are untouched:
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "buddy.h"
static uint32_t rng_state = 12345u;
static uint32_t rng(void) { rng_state = rng_state * 1664525u + 1013904223u; return rng_state >> 8; }
#define MAXLIVE 3000
static void *live[MAXLIVE];
static size_t live_size[MAXLIVE];
int main(void) {
buddy_init();
printf("free at start: %zu bytes\n", buddy_free_bytes());
printf("request 33 -> block %zu, request 32 -> block %zu, request 100 -> block %zu\n",
buddy_block_size(33), buddy_block_size(32), buddy_block_size(100));
/* 1. Take every 32-byte block, then free them in a scrambled order. */
static void *all[2048];
unsigned n = 0;
while (n < 2048 && (all[n] = buddy_alloc(32)) != NULL) n++;
printf("32-byte blocks handed out: %u, extra alloc returns %s\n", n, buddy_alloc(32) ? "pointer" : "NULL");
for (unsigned i = 0; i < n; i++) {
unsigned j = (unsigned)(i * 2477u) % n; /* 2477 is odd, so coprime with 2048 */
if (all[j]) { buddy_free(all[j]); all[j] = NULL; }
}
for (unsigned i = 0; i < n; i++) if (all[i]) buddy_free(all[i]);
size_t free_now = buddy_free_bytes();
void *whole = buddy_alloc(64 * 1024);
printf("after freeing all: free=%zu, one 64 KB alloc %s\n", free_now, whole ? "succeeds" : "FAILS");
buddy_free(whole);
/* 2. Random stress: fill each block with a tag, verify the tag before freeing. */
memset(live, 0, sizeof live);
unsigned fails = 0, corrupt = 0;
for (unsigned step = 0; step < 200000; step++) {
unsigned slot = rng() % MAXLIVE;
if (live[slot]) {
uint8_t tag = (uint8_t)(slot & 0xFF);
uint8_t *p = live[slot];
for (size_t k = 0; k < live_size[slot]; k++) if (p[k] != tag) { corrupt++; break; }
buddy_free(live[slot]);
live[slot] = NULL;
} else {
size_t sz = 1 + rng() % 2000;
live[slot] = buddy_alloc(sz);
if (!live[slot]) { fails++; continue; }
live_size[slot] = sz;
memset(live[slot], (int)(slot & 0xFF), sz);
}
}
for (unsigned i = 0; i < MAXLIVE; i++) buddy_free(live[i]);
printf("stress: corrupted blocks = %u, failed allocs (arena full) > 0: %s\n", corrupt, fails ? "yes" : "no");
printf("after stress and freeing everything: free=%zu\n", buddy_free_bytes());
void *w2 = buddy_alloc(64 * 1024);
printf("one 64 KB alloc %s\n", w2 ? "succeeds" : "FAILS");
buddy_free(w2);
/* 3. Bad frees are rejected. */
void *a = buddy_alloc(64);
buddy_free(a); buddy_free(a); /* double free: ignored */
int local; buddy_free(&local); /* not from the arena: ignored */
printf("free after double and foreign free: %zu\n", buddy_free_bytes());
/* 4. A pointer into the middle of a live block, or a misaligned one, is rejected, even after merges. */
void *x = buddy_alloc(32), *y = buddy_alloc(32); /* units 0 and 1 */
buddy_free(x); buddy_free(y); /* y merges into its lower buddy x */
uint8_t *blk = buddy_alloc(64); /* the merged block, at unit 0 */
memset(blk, 0xAB, 64);
size_t before = buddy_free_bytes();
buddy_free(blk + 32); /* interior pointer: must be ignored */
buddy_free(blk + 1); /* not a block start: must be ignored */
buddy_free((void *)((uintptr_t)blk + 65536u)); /* 32-byte aligned but one arena length away: must be ignored */
int intact = 1;
for (int k = 0; k < 64; k++) if (blk[k] != 0xAB) intact = 0;
printf("interior, misaligned and out-of-arena frees ignored: %s, block contents intact: %s\n",
before == buddy_free_bytes() ? "yes" : "NO", intact ? "yes" : "NO");
buddy_free(blk);
printf("free after those frees and the real free: %zu\n", buddy_free_bytes());
return 0;
}
Output:
free at start: 65536 bytes
request 33 -> block 64, request 32 -> block 32, request 100 -> block 128
32-byte blocks handed out: 2048, extra alloc returns NULL
after freeing all: free=65536, one 64 KB alloc succeeds
stress: corrupted blocks = 0, failed allocs (arena full) > 0: yes
after stress and freeing everything: free=65536
one 64 KB alloc succeeds
free after double and foreign free: 65536
interior, misaligned and out-of-arena frees ignored: yes, block contents intact: yes
free after those frees and the real free: 65536
The "one 64 KB alloc succeeds" lines show that coalescing restored the single large block both after the scrambled frees and after the stress run. The last block of the driver is what proves the pointer checks work, and each check was removed in turn to confirm it: without the st == 0 test the run prints block contents intact: NO; without the alignment test it prints ignored: NO and intact: NO; without the arena-range test the plain output is unchanged but -fsanitize=undefined reports an out-of-bounds index into state. The earlier double-free and stack-pointer tests alone would not notice any of these. A silent list corruption would not show in buddy_free_bytes, which is why the block's own bytes are tagged and re-read. The same file compiled for Cortex-M3 with arm-none-eabi-gcc 14.2.1 -mcpu=cortex-m3 -mthumb -Os -c has a 540-byte .text, 67,648 bytes of .bss (the 65,536-byte arena, 2,048 bytes of state, 34 bytes of list heads and 30 bytes of padding that keep the arena 32-byte aligned) and contains two cpsid i instructions, one in buddy_alloc and one in buddy_free. That build was only compiled, not run on a target. At -Os GCC also turns the clearing loop in buddy_init into a call to memset (the object file lists memset as undefined), so a freestanding link must provide memset or the loop has to be written so the compiler cannot make that call.
Costs of the design. Internal fragmentation (space wasted inside a block): a 33-byte request occupies 64 bytes, wasting 31 bytes (48% of the block), and a 100-byte request takes a 128-byte block. The loss can approach half of a block, so choose this allocator when request sizes are mostly powers of two or when you want fast, header-free, coalescing allocation with a worst-case bound, and use fixed-size pools for the hot small sizes.
Implement uint8_t crc8_update(uint8_t crc, uint8_t data) in C for the CRC-8 polynomial 0x07 (x^8 + x^2 + x + 1) using the bit-at-a-time algorithm (no lookup table). Then explain how to convert this to a table-driven implementation and discuss memory vs CPU trade-offs on small microcontrollers.
Sample Answer
Implementation (bit-at-a-time)
#include <stdint.h>
/* CRC-8 (poly 0x07) bit-at-a-time update */
uint8_t crc8_update(uint8_t crc, uint8_t data)
{
crc ^= data;
for (int i = 0; i < 8; ++i) {
/* if MSB set, shift left and xor poly; else just shift */
if (crc & 0x80) {
crc = (crc << 1) ^ 0x07;
} else {
crc <<= 1;
}
}
return crc;
}
Note: This treats CRC register MSB-first. If your protocol uses reflected input/output, reflect bits or process LSB-first and use poly 0x8C.
Converting to table-driven
- Precompute 256-entry table: for byte b, table[b] = crc8_update(0, b).
- Then update with: crc = table[crc ^ data];
- For multi-byte blocks, loop: crc = table[crc ^ buffer[i]];
Memory vs CPU trade-offs (small MCUs)
- Table-driven: fast (one XOR + table read), minimal CPU cycles per byte, but uses 256 bytes ROM (can be reduced to 16 entries with nibble table).
- Bit-at-a-time: tiny code/ROM, zero data table, deterministic small memory footprint, but ~8× slower and more branch/shift ops.
- Choose table if throughput/latency matters and flash/RAM allows. Use nibble table or compress table if memory constrained. For ultra-constrained or when code size matters, use bit method or optimize in assembly.
Write a C function that removes every node with a target value from a singly linked list and returns the new head. The implementation should free removed nodes correctly and handle deleting the first node without special-case bugs.
Sample Answer
Direct answer. Walk the list with a pointer to the link that leads to the current node (struct node **link, starting at &head). When the current node matches, unlink it with *link = dead->next; and free(dead) without advancing link. Otherwise advance with link = &(*link)->next. Because the cursor is the pointer that points at the node, deleting the first node is the same operation as deleting any other, so there is no head special case.
#include <stdio.h>
#include <stdlib.h>
struct node { int value; struct node *next; };
struct node *remove_all(struct node *head, int target) {
struct node **link = &head; /* address of the pointer that points at the current node */
while (*link) {
if ((*link)->value == target) {
struct node *dead = *link;
*link = dead->next; /* unlink: works for the head and for interior nodes */
free(dead);
} else {
link = &(*link)->next;
}
}
return head;
}
static struct node *build(const int *a, int n) {
struct node *head = NULL, **tail = &head;
for (int i = 0; i < n; i++) {
*tail = malloc(sizeof **tail);
(*tail)->value = a[i]; (*tail)->next = NULL;
tail = &(*tail)->next;
}
return head;
}
static void show(const struct node *h) {
printf("[");
for (; h; h = h->next) printf("%d%s", h->value, h->next ? " " : "");
printf("]\n");
}
static void destroy(struct node *h) { while (h) { struct node *n = h->next; free(h); h = n; } }
int main(void) {
int t1[] = {2, 2, 1, 2, 3, 2};
int t2[] = {5, 5, 5};
int t3[] = {1, 2, 3};
struct node *l;
l = build(t1, 6); show(l); l = remove_all(l, 2); show(l); destroy(l);
l = build(t2, 3); show(l); l = remove_all(l, 5); show(l); destroy(l);
l = build(t3, 3); show(l); l = remove_all(l, 9); show(l); destroy(l);
l = remove_all(NULL, 1); show(l);
return 0;
}
Built with gcc -g -O1 -Wall -Wextra -fsanitize=address,undefined s3.c -o s3 and run (AddressSanitizer would also report any leak or bad free at exit; there was none):
[2 2 1 2 3 2]
[1 3]
[5 5 5]
[]
[1 2 3]
[1 2 3]
[]
The cases are: head and tail both match with interior matches, every node matches (list becomes empty, function returns NULL), no node matches, and an empty list.
Why it works
linkalways holds the address of astruct node *variable: first the caller'shead, later thenextfield of the node before the current one.*linkis the current node.- Removal rewrites whatever pointer led to the node, so the predecessor (or
head) is updated for free. - After a removal, the node now in
*linkis unexamined, solinkmust not move. Advancing after a delete skips a node and misses the second 2 in2 2 .... - The function returns the (possibly changed) head. The caller must assign it:
l = remove_all(l, 2);. Ignoring the return value after the head was removed leaves the caller with a dangling pointer. - Time is O(n), extra space O(1).
Correct freeing
Each removed node is freed exactly once, after its next has been read (dead->next is read before free(dead)). The values need no cleanup here because a node is a plain int payload. If a node owned its own heap data (a char *name), free that before freeing the node, otherwise it leaks.
If the removed node may still be referenced elsewhere
Freeing is only safe when the list owns the node. If another structure (a cache, an iterator, another list) holds a pointer to it, free creates a dangling pointer. Pick one ownership rule and say it in the API:
- The list is the only owner. Document that nodes must not be retained; callers store the value or a key, not the node.
- Detach and hand back. The function unlinks the matching nodes and returns them (for instance as a second list) so the caller decides when to free. No
freeinside. - Reference counting. A
refsfield is incremented by each holder; removal drops the list's reference and the node is freed when the count reaches zero. This costs a field plus discipline (and atomic operations if threads share it).
Option 1 is the right default for a plain list because it is the simplest. Switch to 2 or 3 only when the requirements really say others hold the node. Whatever you pick, test it under AddressSanitizer: a retained pointer to a removed node is reported as heap-use-after-free at the first use.
Pitfalls
- Iterating with
for (n = head; n; n = n->next)and freeingninside: the loop readsn->nextafter the free. - Special-casing the head with a
while (head && head->value == target)pre-loop plus a second loop. It works but duplicates the unlink logic. - Forgetting that
headis passed by value: freeing the first node and not returning the new head leaves the caller's pointer stale.
What does an interrupt vector table contain on a microcontroller, and how do you relocate it when a bootloader hands over to an application? What goes wrong if it is misaligned or not set up correctly at startup?
Sample Answer
What the table contains. In plain terms, the vector table is the lookup list the core consults whenever something interrupts it: it finds the entry for that event and jumps to the address stored there. Relocating it means telling the core a new place to find the list. On a Cortex-M the vector table is an array of 32-bit words in memory: word 0 is the initial stack pointer, and every other word is the address of a handler. The position of a word is its exception number (Cortex-M3 Devices Generic User Guide, Arm DUI 0552A, Table 2-16 and Figure 2-2; that guide's table shows exception numbers 12 and 13 as reserved, and word 12 is the debug monitor vector on cores that implement the debug monitor exception):
| Word | Entry | Word | Entry |
|---|---|---|---|
| 0 | Initial main stack pointer (loaded on reset) | 11 | SVCall |
| 1 | Reset handler | 12 | Debug monitor |
| 2 | NMI (non-maskable interrupt) | 13 | Reserved |
| 3 | HardFault | 14 | PendSV |
| 4 to 6 | MemManage, BusFault, UsageFault | 15 | SysTick |
| 7 to 10 | Reserved | 16 and up | Device interrupts, IRQ0, IRQ1, ... |
Only the stack top and the reset handler are needed to start running, but give NMI and HardFault real handlers too, because they cannot be disabled; the other entries have to be filled in for every exception you enable (for example SVCall if the program uses the svc instruction). Each handler entry is an address with the lowest bit set, because the core only executes Thumb code (the compact 16/32-bit instruction encoding Cortex-M uses; the older 32-bit ARM encoding is not supported) and the manuals say so: the Generic User Guide (section 2.3.4) says "The least-significant bit of each vector must be 1", and the Cortex-M3 Technical Reference Manual (Arm DDI 0337H) says vector table entries are compatible with interworking, so bit 0 of the vector is loaded into the T bit on exception entry (1 means Thumb). Jumping to an address with bit 0 clear asks for the unsupported ARM state, which is a fault. Word 0 is used only at reset: it is the value the core loads into the main stack pointer then, and taking an exception later does not reload it. After a reset the core fetches word 0 and word 1 from address 0, which is why the first table sits at the start of the flash.
Relocating it: the VTOR register. The Vector Table Offset Register (VTOR, address 0xE000ED08) holds the table base. Its field TBLOFF occupies bits [31:7] and holds bits [29:7] of the table's offset from the bottom of the memory map, that is, the table address divided by 128, so the low 7 address bits are not stored. Bit 29 of that offset says whether the table is in the code region (0) or in SRAM (1) and is sometimes called TBLBASE (Generic User Guide, DUI 0552A, section 4.3.4, Table 4-16). After a reset the table is at address 0. Bits [6:0] are not part of the register, which is why a table address that is not a multiple of 128 cannot be stored exactly. The Generic User Guide (DUI 0552A, section 4.3.4) states the alignment rule as: "The minimum alignment is 32 words, enough for up to 16 interrupts. For more interrupts, adjust the alignment by rounding up to the next power of two. For example, if you require 21 interrupts, the alignment must be on a 64-word boundary because the required table size is 37 words, and the next power of two is 64." So the table size is 16 + N words for N device interrupts, rounded up to a power of two, and never below 32 words (128 bytes). A device with 48 interrupts needs 64 words (256 bytes); one with 60 needs 76, rounded to 128 words (512 bytes). Check your own core and chip manual: this is the Cortex-M3 text, and not every Cortex-M has the register.
Bootloader handover, in order.
- Mask interrupts (
cpsid i) and quiesce what the bootloader started (quiesce means bring it to a quiet, known state: stop SysTick, clear pending device interrupts), so nothing arrives while the table and stack are in transition. - Write the application's table address to VTOR.
- Load the main stack pointer from word 0 of the application's table (
msr msp). - Jump to the address in word 1, the application's reset handler.
- The application initialises itself and re-enables interrupts, because PRIMASK is still set after the jump (PRIMASK is the core's one-bit interrupt mask: while it is 1, all interrupts with configurable priority are held off;
cpsid isets it andcpsie iclears it).
Run: handover and three ways to break it. The program places a bootloader table at address 0 and the application's table at 0x10100 (aligned to 128 bytes), raises a device interrupt before and after the handover, and has two build variants that break the setup. It runs on a Cortex-M3 in QEMU (lm3s6965evb). QEMU shows the logic of the vector fetch; it does not show flash timing or a real bus fault (a bus fault is the core's report that a memory access, such as a fetch from an address with nothing behind it, failed). The 20-word table (16 + 4 device interrupts) is rounded up to 32 words, which is the 128-byte alignment the linker script uses.
#include "io.h"
extern uint32_t _estack;
static void boot_irq0(void) { puts_(" boot table: IRQ0 handler\n"); }
static void app_irq0(void) { puts_(" app table: IRQ0 handler\n"); }
static void fault(void) { puts_(" HardFault taken\n"); quit(); }
static void dflt(void) { puts_(" unexpected exception\n"); quit(); }
static void boot_reset(void);
static void app_reset(void);
#ifdef MISALIGNED
#define APP_BASE 0x10240u /* multiple of 64 bytes, not of 128 */
#define APP_SECTION ".bad_vectors"
#else
#define APP_BASE 0x10100u /* multiple of 128 bytes */
#define APP_SECTION ".app_vectors"
#endif
/* Table layout: word 0 initial SP, 1 reset, 2 NMI, 3 HardFault, 4..6 MemManage/BusFault/UsageFault,
7..10 reserved, 11 SVCall, 12 DebugMon, 13 reserved, 14 PendSV, 15 SysTick, 16.. device interrupts. */
__attribute__((section(".vectors"), used)) const void *const boot_vectors[20] = {
&_estack, boot_reset, dflt, fault, dflt, dflt, dflt, 0, 0, 0, 0, dflt, dflt, 0, dflt, dflt,
boot_irq0, dflt, dflt, dflt };
/* 20 words = 80 bytes; the alignment rule rounds the table up to 32 words = 128 bytes */
__attribute__((section(APP_SECTION), used)) const void *const app_vectors[20] = {
&_estack, app_reset, dflt, fault, dflt, dflt, dflt, 0, 0, 0, 0, dflt, dflt, 0, dflt, dflt,
app_irq0, dflt, dflt, dflt };
static void app_reset(void) {
puts_("app: running, VTOR="); puthex(VTOR); putc_('\n');
CPSIE(); /* the bootloader left PRIMASK set */
ISER0 = 1; ISPR0 = 1; DSB(); ISB(); /* raise device interrupt 0 */
puts_("app: done\n"); quit();
}
static void boot_reset(void) {
REG(0x4000C030u) = (1u << 0) | (1u << 8) | (1u << 9); /* UART0 enable */
puts_("bootloader: raising IRQ0 before handover\n");
ISER0 = 1; ISPR0 = 1; DSB(); ISB();
puts_("bootloader: handing over to table at "); puthex(APP_BASE); putc_('\n');
const uint32_t *t = (const uint32_t *)APP_BASE;
uint32_t entry = t[1];
#ifdef NO_THUMB_BIT
entry &= ~1u; /* a reset entry without the Thumb bit */
#endif
CPSID(); /* no interrupt while the table and stack change */
VTOR = APP_BASE;
puts_(" VTOR readback "); puthex(VTOR); putc_('\n');
__asm__ volatile("msr msp, %0" :: "r"(t[0]));
((void (*)(void))entry)(); /* jump to the application's reset handler */
}
vt.ld (places the tables at fixed flash addresses) and io.h (register and UART helpers) follow. In vt.ld, MEMORY declares the flash at address 0 (where the core fetches its first two words after reset) and the RAM at 0x20000000; the .vectors section, the bootloader's table, goes first in flash; each of the two application sections is pinned to an absolute address (.app_vectors 0x00010100, .bad_vectors 0x00010240) so the program knows where the tables are; and _estack is the end of RAM, 0x20010000, the value stored in word 0 of every table.
MEMORY { FLASH (rx) : ORIGIN = 0x0, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K }
SECTIONS {
.vectors : { KEEP(*(.vectors)) } > FLASH /* bootloader table at 0x0 */
.text : { *(.text*) *(.rodata*) } > FLASH
.app_vectors 0x00010100 : { KEEP(*(.app_vectors)) } > FLASH /* 128-byte aligned */
.bad_vectors 0x00010240 : { KEEP(*(.bad_vectors)) } > FLASH /* 64-byte aligned only */
_estack = ORIGIN(RAM) + LENGTH(RAM);
}
/* io.h: register helpers and UART0 output for QEMU lm3s6965evb (no vector table here). */
#include <stdint.h>
#define REG(a) (*(volatile uint32_t *)(a))
#define ISER0 REG(0xE000E100u)
#define ISPR0 REG(0xE000E200u)
#define VTOR REG(0xE000ED08u)
#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; }
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 quit(void) {
register uint32_t r0 __asm__("r0") = 0x18, r1 __asm__("r1") = 0x20026;
__asm__ volatile("bkpt 0xab" :: "r"(r0), "r"(r1)); for (;;) {}
}
In io.h, REG(a) turns an address into a volatile 32-bit register, ISER0 and ISPR0 are the NVIC's set-enable and set-pending registers for interrupts 0 to 31 (writing 1 to bit 0 enables, then pends, device interrupt 0 in software), VTOR is the register above, DSB/ISB are barrier instructions that make the preceding register writes complete before the next instruction, putc_ writes a byte to the UART0 data register that QEMU prints on the terminal, and quit makes a semihosting call (bkpt 0xab with operation 0x18) that ends the QEMU run.
Build and run, once per variant (Arm GNU Toolchain GCC 14.2.1, QEMU 10.0.13, gcc:14 container):
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O1 -Wall -Wextra -ffreestanding -nostdlib [-DMISALIGNED | -DNO_THUMB_BIT] -T vt.ld -o vt_handover.elf vt_handover.c -lgcc
timeout 20 qemu-system-arm -M lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel vt_handover.elf
Output of the three runs (no flag, -DMISALIGNED, -DNO_THUMB_BIT; deterministic at -O1; the lines after qemu: fatal are the core's register dump, whose leftover register values come from the -O1 build and differ at -O0, while R15=00000000, R14=fffffff1 and the 3 in the low bits of XPSR do not):
--- flags:
bootloader: raising IRQ0 before handover
boot table: IRQ0 handler
bootloader: handing over to table at 0x00010100
VTOR readback 0x00010100
app: running, VTOR=0x00010100
app table: IRQ0 handler
app: done
--- flags: -DMISALIGNED
bootloader: raising IRQ0 before handover
boot table: IRQ0 handler
bootloader: handing over to table at 0x00010240
VTOR readback 0x00010200
app: running, VTOR=0x00010200
qemu: fatal: Lockup: can't escalate 3 to HardFault (current priority -1)
R00=00000210 R01=4000c000 R02=0000000a R03=00000001
R04=e000e000 R05=4000c000 R06=00010000 R07=0000000a
R08=00010240 R09=000000e9 R10=00000000 R11=00000000
R12=00000000 R13=2000ffb8 R14=fffffff1 R15=00000000
XPSR=20000003 --C- A handler
FPSCR: 00000000
Aborted
--- flags: -DNO_THUMB_BIT
bootloader: raising IRQ0 before handover
boot table: IRQ0 handler
bootloader: handing over to table at 0x00010100
VTOR readback 0x00010100
HardFault taken
Reading it:
- Correct handover. The interrupt raised before the switch runs the bootloader's handler, the one raised after it runs the application's. This is also what a missing VTOR write looks like: the application's interrupts would keep going to the bootloader's table.
- Reading the
-DMISALIGNEDcrash text. "Lockup" is the core's state after a fault it cannot recover from: a fault raised while already running the HardFault handler, which has priority -1 and so cannot be escalated any further, stops the core. The sequence here: interrupt 0 is fetched from word 16 of the wrong base, which gives 0x20010000; jumping to that even address faults; the fault escalates to HardFault; the HardFault entry in the wrong table is word 3, at 0x1020c, where the image has no data (zero), so the handler address is 0, which faults again. A real device would lock up or reset in the same way, so this is a more severe outcome than an ordinary HardFault, which would run a handler. In the register dump, the useful fields areR15=00000000(the program counter, at the zero handler address),R14=fffffff1(the special return value the core loads on exception entry, here meaning "came from the main stack, return to handler mode") andXPSR=20000003, whose low bits, 3, are the active exception number: HardFault. The other registers (R01=4000c000,R02=0000000a,R08=00010240) are leftovers from the UART printing code and the bootloader's own variables, not part of the fault. - Misaligned table (
-DMISALIGNED). The table was placed at 0x10240, a multiple of 64 but not of 128. The write reads back as 0x10200: the low bits are dropped, so the core looks for the table 0x40 bytes before where it really is. Device interrupt 0 is fetched from word 16 of that wrong base, which is word 0 of the real table, the stack-top value 0x20010000, an even number that is not a valid handler address. The result in QEMU is a fault that cannot be handled and the machine stops with "Lockup". - Reset entry without the Thumb bit (
-DNO_THUMB_BIT). Jumping to an even address asks the core to enter ARM state, which Cortex-M does not support, and in the run the application's HardFault handler is entered ("HardFault taken"). The table must hold odd addresses; the linker and the compiler do this for you when the table is built from function names, so the bug appears when addresses are computed by hand.
Other ways it goes wrong. Interrupts enabled during the switch run a handler from whichever table is current at that moment. The first run's boot table: IRQ0 handler line is the same rule seen before the switch: the interrupt raised while the bootloader's table was current ran the bootloader's handler. A table missing an entry for an enabled interrupt leaves a zero or garbage word that faults when the interrupt fires. Forgetting to load the stack pointer leaves the application on the bootloader's stack, which works until the two overlap, so it is easy to miss.
What would flip the approach: if the application is linked to run at address 0 (the bootloader lives elsewhere and is remapped by the chip), no VTOR write is needed; if the chip keeps its table in RAM (TBLBASE, bit 29, set), copy the table there first and apply the same alignment rule.
In C, implement a single-producer single-consumer circular byte buffer for an embedded system where the producer runs in a UART RX interrupt and the consumer runs in main context. Constraints: buffer size is power-of-two, operations O(1), avoid disabling interrupts to pop, minimal memory. Provide API void cb_init(cb_t *cb, size_t size); bool cb_push_from_isr(cb_t *cb, uint8_t b); bool cb_pop(cb_t *cb, uint8_t *b); Describe synchronization assumptions for a 32-bit MCU.
Sample Answer
Approach — key ideas
- Use power-of-two size so index wrapping via mask: idx & (size-1).
- Single-producer ISR writes tail; single-consumer main reads head. Use volatile for head/tail.
- Producer-only updates tail, consumer-only updates head — on 32-bit MCU these updates are atomic (single aligned word). No interrupt disabling needed to pop.
Code
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
typedef struct {
uint8_t *buf;
size_t size; // power of two
size_t mask; // size - 1
volatile uint32_t head; // read by consumer
volatile uint32_t tail; // written by producer (ISR)
} cb_t;
void cb_init(cb_t *cb, size_t size, uint8_t *storage){
cb->buf = storage;
cb->size = size;
cb->mask = size - 1;
cb->head = cb->tail = 0;
}
bool cb_push_from_isr(cb_t *cb, uint8_t b){
uint32_t t = cb->tail;
uint32_t next = (t + 1);
if (next - cb->head > cb->size) return false; // full
cb->buf[t & cb->mask] = b;
cb->tail = next;
return true;
}
bool cb_pop(cb_t *cb, uint8_t *b){
uint32_t h = cb->head;
if (cb->tail == h) return false; // empty
*b = cb->buf[h & cb->mask];
cb->head = h + 1;
return true;
}
Explanation & complexity
- All ops O(1), minimal memory (no locks). Uses monotonic 32-bit counters to avoid ambiguity.
- On 32-bit MCU, aligned 32-bit volatile reads/writes are atomic; producer only writes tail, consumer only writes head so no races. The consumer reads tail (may be concurrently updated) — reading a 32-bit volatile gives consistent value.
- Edge cases: ensure size is power-of-two and storage is provided; handle counter wraparound naturally when using unsigned 32-bit counters (distance checks still valid).
Define graceful degradation in the context of an embedded IoT device. Provide three concrete examples of graceful-degradation strategies for a battery-powered sensor node that loses connectivity, including a prioritized list of subsystems to shut down and why.
Sample Answer
Definition (brief)
Graceful degradation for an embedded IoT device means the system intentionally reduces functionality in a controlled way when resources (connectivity, power, memory) fail, preserving essential behavior and state rather than collapsing unpredictably.
Three concrete strategies for a battery-powered sensor node that loses connectivity
- Local buffering with adaptive sampling
- Continue sensing but store data in flash (circular buffer). Reduce sample rate based on battery and buffer fill to extend life and avoid data loss. On reconnect, bulk upload.
- Event-driven reporting + thresholding
- Switch from continuous telemetry to reporting only on significant events (threshold crossings) to minimize power and memory use while preserving critical information.
- Duty-cycled heartbeat with watchdog-based safe mode
- Enter low-power sleep between periodic heartbeats; if reconnection fails for N attempts, enter safe mode that preserves state and exposes a minimal maintenance radio schedule.
Prioritized subsystems to shut down (highest → lowest priority)
- High-power radios (Wi‑Fi/Cellular) — biggest drain; suspend or lower TX power first.
- Noncritical sensors/peripherals (camera, high-rate ADCs) — stop expensive sampling.
- UI/indicator LEDs and actuators — save energy; report status on next heartbeat.
- Telemetry stack/features (encryption-heavy processing, complex logging) — reduce to essential metadata.
- Core sensing and minimal RTC/watchdog — keep these to ensure data integrity and timed wake-ups.
Rationale: prioritize energy and data integrity; keep minimal sensing and recovery ability while disabling highest drain and least critical features.
Formalize and verify a complex embedded state machine that controls safety-critical hardware. Describe steps to model the system, the properties you would prove (for example absence of deadlock, guaranteed response to commands), and practical model-checking or formal tools you would use (for example UML statecharts, NuSMV, TLA+). Also describe complementary testing you would perform on hardware.
Sample Answer
Problem framing & assumptions
I treat the controller as a finite-state machine (states, inputs, outputs, timeouts, interrupts). I clarify safety requirements (safe shutdown, no actuator overlap), timing bounds, and failure modes (sensor loss, CRC errors).
Modeling steps
- Create a precise UML statechart (orthogonal regions for independent subsystems) to capture hierarchy, entry/exit actions, guards, timeouts.
- Translate to a formal model (TLA+ for asynchronous/timed properties or NuSMV/SMV for boolean/state-based verification). Encode hardware faults as nondeterministic transitions.
- Add environment model (input rates, sensor noise, bus delays) and resource constraints (stack, timers).
Properties to prove
- Functional safety: invariant that actuators never commanded simultaneously when forbidden.
- Liveness/response: for each valid command, system reaches commanded state within bounded time (use TLA+ temporal properties like <><> or SMV CTL/ LTL with bounded fairness).
- Absence of deadlock: no reachable state with no outgoing transitions except defined halt.
- Fault containment: single-sensor-failure leads to safe degraded mode.
- Memory/time bounds: stack usage never exceeds limit; watchdog is regularly serviced.
Tools & practical approach
- Use TLA+ (Temporal logic) for complex liveness and fairness; model-check with TLC.
- Use NuSMV or SPIN for boolean/state model checking and CTL/LTL properties.
- Use CBMC or Frama-C to verify C firmware (asserts, absence of undefined behavior).
- Iterative: refine UML → formal model → counterexample-driven fixes.
Complementary hardware testing
- Hardware-in-the-loop (HIL) with fault injection (sensor dropout, bus errors).
- Stress tests: power cycles, EMI, timing jitter, watchdog trips.
- Code-in-the-loop (CIL) then run compiled firmware on target or cycle-accurate simulator; use coverage-guided tests.
- Runtime monitoring: assertions, end-to-end CRC, shadow state checks, watchdog telemetry.
This combination gives formal guarantees on the model plus empirical validation on the actual hardware and compiled firmware.
Implement bool add_will_overflow(int32_t a, int32_t b) in C++ that returns true if a + b would overflow a 32-bit signed integer. Do not use a 64-bit type. Include unit tests for edge cases such as INT_MAX + 0, INT_MAX + 1, and negative overflows, and explain your approach.
Sample Answer
Direct answer
Detecting whether a + b would overflow a 32-bit signed integer, without widening to 64 bits, means reasoning about the operation BEFORE it happens using only the bounds of int32_t itself: check whether b is positive and a is already close enough to the maximum that adding b would exceed it, and symmetrically for a negative b against the minimum.
Structured elaboration
Why you can't just compute a + b and check the result. Computing the sum first and then checking whether it looks wrong is undefined behavior for signed integer overflow in C++, meaning the compiler is permitted to assume overflow never happens and can optimize the check away entirely, silently producing incorrect results specifically in the case you were trying to detect. The check has to be done using only values that are guaranteed to be representable, before the actual addition occurs.
The two symmetric cases. If b is positive, overflow happens when a is already greater than INT32_MAX - b (equivalently, adding b would push past the maximum); this comparison, a > INT32_MAX - b, is always computable without overflow since INT32_MAX - b cannot itself overflow when b is positive. If b is negative, overflow (underflow past the minimum) happens when a is less than INT32_MIN - b; note INT32_MIN - b is safe to compute here specifically because b is negative, making this subtraction move away from, not toward, the boundary.
The zero and boundary cases. b == 0 never overflows regardless of a, and the two comparisons above naturally handle this correctly without a special case, since a > INT32_MAX - 0 is simply a > INT32_MAX, which is never true for a valid int32_t value of a.
Worked example
bool add_will_overflow(int32_t a, int32_t b) {
if (b > 0 && a > std::numeric_limits<int32_t>::max() - b) return true;
if (b < 0 && a < std::numeric_limits<int32_t>::min() - b) return true;
return false;
}
Executed and verified (g++, -Wall -Wextra): add_will_overflow(INT32_MAX, 0) is false (no overflow); add_will_overflow(INT32_MAX, 1) is true (the classic overflow case); add_will_overflow(INT32_MAX - 1, 1) is false (exactly at the boundary, still valid); add_will_overflow(INT32_MIN, -1) is true (the symmetric underflow case); add_will_overflow(INT32_MIN, 0) is false; add_will_overflow(INT32_MIN + 1, -1) is false (exactly at the boundary on the negative side); ordinary values like add_will_overflow(100, 200) and add_will_overflow(-100, -200) are both false; and add_will_overflow(INT32_MAX/2 + 1, INT32_MAX/2 + 1) is true, confirming the check also catches an overflow that occurs from two moderately-large positive values rather than only from a value already at the exact boundary.
Trade-offs and pitfalls
The single most common mistake is writing the intuitive-looking but broken version, int32_t sum = a + b; if (sum < a) return true; (checking whether the result "wrapped around" to something smaller than one of the inputs): this relies on signed overflow actually wrapping, which is undefined behavior in C++ and not guaranteed to behave that way at all, especially under compiler optimizations that are explicitly permitted to assume signed overflow never occurs and can eliminate the check entirely. A second, more subtle mistake is getting the comparison direction backwards for the negative-b case (checking a < INT32_MIN + b instead of a < INT32_MIN - b), which happens to work correctly by luck for some inputs and silently fails for others; testing both boundary directions explicitly, as in the worked example, is what catches this class of subtle sign error.
A telemetry device needs to run AES encryption but has limited RAM and no hardware crypto acceleration. How would you approach implementing it, and what would drive your choice given the memory budget, throughput needs, and the risk of cache-timing side channels on this MCU?
Sample Answer
Framing
With no hardware crypto acceleration, Advanced Encryption Standard (AES) has to run entirely in software, and on a memory-constrained microcontroller unit (MCU) there's a real trade-off between three things: RAM used by lookup tables, throughput, and resistance to cache-timing side-channel attacks (where an attacker infers secret data by measuring how long operations take, because table-lookup addresses depend on secret key/data bytes). The right answer depends on the threat model here, not just on which implementation is theoretically fastest.
The implementation options
- Classic T-table implementation: precomputes several kilobytes of lookup tables (the traditional AES T-table approach) that let each round execute as a handful of table lookups and XORs, which is fast. The cost: the table size (multiple kilobytes of flash/RAM), and because the table index depends on secret data, timing (or, on a device that has any cache, cache-access-pattern) varies with the key and plaintext, which is a real side-channel risk if an attacker can observe execution timing.
- Small S-box table implementation: uses just the 256-byte AES S-box lookup table instead of the larger precomputed T-tables, roughly an order of magnitude less table memory, at the cost of more per-round computation (the rest of the round transformation has to be computed rather than looked up in a combined table), so it's slower. It still has the same fundamental side-channel exposure as the T-table approach, since it's still a secret-dependent table lookup, just a smaller one.
- Bitsliced or constant-time implementation: avoids secret-dependent table lookups entirely by computing AES using fixed, data-independent instruction sequences (bit-level boolean operations instead of table indexing). This eliminates the timing side channel by construction, at the cost of more instructions per byte encrypted (slower throughput) than a table-based approach.
How I'd decide
The deciding factor is the threat model, specifically: can an attacker actually observe this device's timing? If this telemetry device is network-facing and an attacker could plausibly measure response timing remotely, or has physical access to probe it, I'd choose the constant-time implementation regardless of the RAM or throughput cost, since a working side channel defeats the encryption's purpose entirely no matter how fast or memory-efficient it is. If the device is genuinely isolated from a timing-observing adversary (for example, physically inaccessible, and encrypting a low-rate periodic payload rather than a high-throughput stream where the small-table approach's slower throughput wouldn't be a real bottleneck anyway), the smaller S-box table implementation is a reasonable, RAM-conscious choice.
Given this specific scenario
For a telemetry device sending periodic, low-data-rate payloads, throughput almost certainly isn't the bottleneck, so I'd default toward the smaller-footprint or constant-time option rather than the full T-table approach, and only reach for T-tables if profiling showed encryption was actually a measured bottleneck and the threat model ruled out a timing-observing adversary.
Your team is considering an outside component nobody here has used, the documentation is thin, and the decision gets made in about two weeks. How do you spend that time, and what would make you say no?
Sample Answer
Direct answer
I treat two weeks as a research spike with a decision at the end, not open-ended learning time. I spend it testing the vendor's own specific claims against a real slice of our workload in an isolated trial that can't touch production, and I decide in advance what result would make me say no, so the verdict isn't a last-minute gut call.
Structured elaboration
- Find the two or three claims that actually gate the decision. I don't try to become an expert in the whole component. I identify the handful of things that, if false, would kill the decision (does it handle our real data volume, is it compatible with what we already depend on, does its failure behavior make sense), and I aim the whole two weeks at testing those.
- Test the claims myself instead of trusting the documentation. Vendor docs and marketing describe the happy path. I build the smallest thing that proves or disproves the specific claim using our own representative data or traffic shape, not the vendor's demo dataset.
- Keep the trial isolated with a clear way back out. The evaluation runs in a sandbox or a feature-flagged path (gated behind a feature flag, a toggle that turns a new component on for only a slice of traffic, without needing a separate deploy to turn it back off) that can't reach real customer data, and I know before I start how quickly we could rip it back out if it doesn't work, so trying it never becomes a one-way door.
- Decide the "say no" triggers before I see the results, not after. Examples: it fails under our expected traffic at even a modest multiple, there's no realistic exit path if we need to remove it later, or its security posture doesn't clear a bar we've already set. Deciding this in advance keeps the deadline from quietly lowering the bar.
- Under a genuinely compressed timeline this same shape compresses further. If instead of two weeks I had days, for instance needing to understand and counter an unfamiliar type of threat quickly, I'd skip the exploratory tour entirely and go straight at the one or two claims that actually gate whether we're safe, using whatever cheap check answers that fastest.
- Write the finding down either way. A short adoption note (what I tested, what passed, what didn't, the verdict) means the next person evaluating something similar doesn't redo this from scratch.
- If we adopt it, the first real use is staged, not a big rollout. A small, reversible slice of production traffic with its own explicit checks, expanded only once that holds up.
Worked example
A team I was on had two weeks to decide whether to adopt a third-party message-queuing service for a path that mattered a lot, with thin documentation and nobody on the team who'd used it in production. Instead of reading everything, I picked out the two claims that actually mattered to us: that it could sustain our peak message rate, and that we could get our data back out cleanly if we ever needed to leave. I spent the first three days building a minimal proof of concept against a sandbox account, fed it a replay of a real day's traffic rather than a toy example, and it held up. I spent a day specifically testing the export path, since a dead end there was one of my pre-agreed reasons to say no, and it worked cleanly. With about five days left I wrote up a one-page recommendation with what I'd tested, what I hadn't had time to test, and the specific evidence behind each claim, and we adopted it behind a feature flag on a low-traffic queue first, with its own success checks, before moving anything critical onto it.
Trade-offs and pitfalls
The biggest trap is spending the whole window reading and exploring instead of testing the load-bearing claims, which leaves you with broad but shallow familiarity and no real evidence at decision time. The opposite trap, trusting the vendor's claims at face value because the deadline is tight, is worse: it just moves the real evaluation to production, after you've already committed. Testing directly against live systems instead of an isolated trial is the other classic mistake, since it turns an evaluation into an incident risk. And skipping the write-up because the deadline already felt tight just guarantees the next evaluator repeats your work.
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