Google Embedded Developer (Entry Level) - Complete Interview Preparation Guide
Google's Embedded Software Engineer interview process for entry-level candidates focuses on practical embedded systems knowledge rather than pure algorithmic problem-solving. The process typically includes an initial recruiter screening call, followed by one technical phone screen, and then 4-5 onsite technical interviews. Interviewers evaluate C programming proficiency, embedded systems fundamentals, bit manipulation skills, hardware-software interaction understanding, and problem-solving ability. Unlike generic software engineering interviews, embedded roles at Google emphasize hands-on practical knowledge and real-world embedded systems concepts.
Interview Rounds
Recruiter Screening
What to Expect
Initial recruiter call to assess background, motivation, and basic qualifications. The recruiter will discuss your resume, embedded systems experience, C programming background, and interest in working on hardware-level software at Google. This is also an opportunity to ask questions about the role, team, and interview process. No technical coding is expected in this round.
Tips & Advice
Be clear about your embedded systems experience - even personal projects count. Highlight any C programming, microcontroller work, or IoT projects. Show genuine interest in low-level systems and hardware interaction. Have 2-3 specific questions ready about the embedded systems team, projects, or technology stack. Keep explanations concise and focused on relevant embedded experience.
Focus Topics
Motivation for Embedded Systems
Explain why you're interested in embedded development specifically, not just general software engineering
Practice Interview
Study Questions
Professional Background and Embedded Experience
Discuss your educational background, any embedded systems coursework, internships, or personal projects involving microcontrollers, firmware, or hardware-software integration
Practice Interview
Study Questions
C Programming Experience
Articulate your proficiency level with C, projects using C, and understanding of low-level C concepts like pointers, memory management, and bitwise operations
Practice Interview
Study Questions
Technical Phone Screen
What to Expect
A 60-minute technical interview conducted via video call. You will be asked to solve 1-2 embedded systems programming problems using C. Problems focus on practical embedded concepts rather than complex data structures or algorithms. Expect questions involving bit manipulation, array operations, string manipulation, low-level memory operations, or simple hardware interaction scenarios. You may be asked to write code on a shared online editor. The interviewer will assess your C programming ability, problem-solving approach, and embedded systems thinking.
Tips & Advice
Write clean, compilable C code - syntax errors matter in embedded roles. Think out loud while solving problems. Ask clarifying questions about requirements before coding. Start with a simple solution and optimize if needed. Be comfortable discussing bit manipulation and low-level operations. Explain your choice of data types (int, uint8_t, uint32_t, etc.) - this matters for embedded development. Practice writing code without an IDE. Verify your code logic by tracing through examples.
Focus Topics
Problem-Solving and Communication
Thinking aloud, asking clarifying questions, discussing tradeoffs, explaining your approach before coding, testing with examples
Practice Interview
Study Questions
Low-Level Memory Concepts
Understanding memory layout, pointer dereferencing, pointer arithmetic, casting pointers, working with memory directly, endianness, and memory-mapped registers
Practice Interview
Study Questions
Array and String Operations in C
Working with character arrays, string functions, array indexing, pointer arithmetic for arrays, common array algorithms like searching and sorting
Practice Interview
Study Questions
C Fundamentals and Syntax
Strong command of C syntax, data types (including fixed-width types like uint8_t), pointers, memory allocation, and array indexing. Write syntactically correct, compilable code.
Practice Interview
Study Questions
Bit Manipulation Operations
Bitwise AND, OR, XOR, NOT operations; bit shifting; setting, clearing, and toggling individual bits; working with bit masks; understanding endianness basics
Practice Interview
Study Questions
Onsite Interview - Embedded Systems Fundamentals
What to Expect
First onsite technical interview (45-60 minutes) focused on embedded systems core concepts. You may be asked about microcontroller architecture, memory organization, peripheral interfaces, interrupt handling basics, real-time concepts, or practical hardware-software interaction scenarios. Some questions may involve writing C code to interact with simulated hardware or solving embedded-specific problems. The interviewer is evaluating your understanding of how software runs on constrained hardware.
Tips & Advice
Study basic microcontroller concepts: memory types (RAM, ROM, flash), bus architectures, GPIO, interrupts, and timers. Be able to explain how software interacts with hardware registers. Understand the role of bootloaders and firmware. If the interview includes practical scenarios like 'write code to toggle an LED' or 'implement a simple debouncing routine', break it down logically. Discuss memory constraints - embedded developers must think about RAM and flash usage. Be prepared to explain terms like 'real-time', 'latency', and 'deterministic behavior'.
Focus Topics
Real-Time and Deterministic Behavior
What makes code real-time, timing predictability, avoiding non-deterministic operations, understanding latency and jitter, and optimization for performance constraints
Practice Interview
Study Questions
Interrupt Handling Concepts
What are interrupts, how they work at a basic level, interrupt service routines (ISRs), interrupt priorities, interrupt context, volatile keyword usage, and atomic operations
Practice Interview
Study Questions
Peripheral Interface Basics
General I/O (GPIO), serial communication basics (UART, I2C, SPI protocols at conceptual level), timers, analog-to-digital conversion (ADC) concepts
Practice Interview
Study Questions
Memory Management in Embedded Systems
RAM vs ROM/Flash, memory-mapped I/O, address spaces, working within limited memory constraints, static vs dynamic allocation trade-offs in embedded context
Practice Interview
Study Questions
Microcontroller Architecture Basics
General understanding of microcontroller components: CPU, RAM, ROM/Flash memory, I/O ports, clock generation, memory addressing, and how they interact
Practice Interview
Study Questions
Onsite Interview - C Programming and Data Structures
What to Expect
Second onsite technical interview (45-60 minutes) focused on C programming proficiency and basic data structures in an embedded context. You will write C code to solve embedded-relevant problems. Expect questions about working with structs, bit fields, arrays, linked lists (sometimes), or implementing simple hardware-related functionality. Problems are more practical and grounded in embedded scenarios compared to generic coding interviews. The interviewer evaluates code correctness, efficiency, and your understanding of embedded C idioms.
Tips & Advice
Write production-quality C code - consider error handling and robustness. Be comfortable with structs and bit fields for hardware register definitions. Understand when to use different data types (uint8_t vs uint32_t, volatile keyword). Practice implementing common embedded patterns like circular buffers, state machines, or simple device drivers. Think about memory efficiency - avoid unnecessary allocations. Test your code mentally with edge cases. Explain your design choices, especially around memory and performance trade-offs. Avoid complex algorithms; focus on correct, clean embedded C patterns.
Focus Topics
Code Optimization for Embedded Systems
Writing code that minimizes memory usage, reduces CPU cycles, considering code size and execution speed, optimization trade-offs in embedded contexts
Practice Interview
Study Questions
Pointers and Dynamic Memory in Embedded Context
Pointer operations, dynamic allocation pros and cons in embedded systems, static allocation patterns, avoiding memory leaks in resource-constrained environments
Practice Interview
Study Questions
Embedded C Patterns and Best Practices
Volatile keyword, casting, working with hardware registers, state machines in C, writing efficient and safe embedded code, avoiding common pitfalls
Practice Interview
Study Questions
Implementing Basic Data Structures
Arrays, circular buffers, simple linked lists (sometimes used), queues for embedded contexts, choosing appropriate data structures for constrained environments
Practice Interview
Study Questions
Structs and Bit Fields in Embedded C
Using structs for data organization, bit fields for hardware register definition, packing and alignment considerations, practical applications in hardware interaction
Practice Interview
Study Questions
Onsite Interview - Hardware-Software Interaction and Drivers
What to Expect
Third onsite technical interview (45-60 minutes) focused on practical hardware-software integration and device driver concepts. You may be asked questions about how to interface with specific hardware peripherals, implement simple driver-like functionality, configure registers for hardware control, or solve real-world IoT/hardware scenarios. If you mentioned any specific IP (like a microcontroller, FPGA, or sensor protocol) in your resume, questions may target that technology. The interviewer assesses your ability to bridge hardware and software, understand hardware datasheets, and write code that controls physical devices.
Tips & Advice
Study common peripheral interfaces if mentioned in your background (I2C, SPI, UART, GPIO). Understand the relationship between hardware datasheets and code - be ready to interpret register definitions. If you listed any hardware experience on your resume, be prepared to discuss it deeply: know the chip, its peripherals, and how you accessed them from code. Practice explaining how you would write code to control a simple peripheral. Understand timing and synchronization issues in hardware communication. Be comfortable with concepts like clock speeds, baud rates, pin configurations, and interrupt-driven peripheral access. If you have personal embedded projects, use them as examples.
Focus Topics
Problem-Solving with Hardware Constraints
Thinking through hardware limitations, debugging hardware-software interaction issues, understanding timing constraints, working with datasheets, making trade-off decisions
Practice Interview
Study Questions
Simple Driver Implementation Concepts
How device drivers abstract hardware, writing initialization code, handling hardware events, managing hardware resources, sensor data acquisition, practical driver patterns
Practice Interview
Study Questions
GPIO and Peripheral Control
Configuring GPIO pins for input and output, reading button states, controlling LEDs, debouncing, interrupt-driven GPIO, pull-up and pull-down resistors concept
Practice Interview
Study Questions
Common Communication Protocols (I2C, SPI, UART basics)
Conceptual understanding of I2C, SPI, and UART protocols; pin configurations; data format; timing requirements; implementing or troubleshooting communication with peripherals
Practice Interview
Study Questions
Register Access and Hardware Control
Reading and writing hardware registers, bit manipulation for register control, understanding register maps, using volatile pointers for hardware access, memory-mapped I/O
Practice Interview
Study Questions
Onsite Interview - Behavioral and Google Culture Fit
What to Expect
Final onsite interview (45-60 minutes) focused on behavioral assessment and cultural fit with Google. The interviewer will ask about your past experiences, how you handle challenges, teamwork, communication, and alignment with Google's values. Questions typically follow a 'Tell me about a time when...' format. While not technical coding, you should be prepared to discuss technical projects and challenges from your background in clear, structured narratives. This round evaluates communication skills, problem-solving approach in real situations, learning ability, and collaboration.
Tips & Advice
Use the STAR method (Situation, Task, Action, Result) to structure your answers. Prepare 5-7 concrete examples from your academic projects, internships, or personal work that demonstrate: taking ownership, handling setbacks, learning from failure, collaborating with others, and solving complex problems. Be specific with technical details - don't be vague. Emphasize your eagerness to learn and grow, critical for entry-level roles. Show genuine interest in Google's mission and embedded systems work. Ask thoughtful questions about the team, projects, and learning opportunities. Be authentic and honest - Google values genuine team members. Discuss challenges you've faced in embedded systems work and how you overcame them.
Focus Topics
Passion for Embedded Systems and Hardware
Why you chose embedded development, projects you're proud of, your understanding of IoT/hardware domain, future interests in the field
Practice Interview
Study Questions
Ownership and Responsibility
Projects you owned end-to-end, taking initiative, meeting deadlines, driving completion despite challenges, standing by your work quality
Practice Interview
Study Questions
Problem-Solving and Debugging
Share examples of tough technical problems you've solved, bugs you've debugged, your debugging methodology, and lessons learned from failures
Practice Interview
Study Questions
Collaboration and Communication
Examples of working with team members or peers, communicating technical concepts clearly, accepting feedback, helping others, and cross-functional collaboration
Practice Interview
Study Questions
Learning Ability and Growth Mindset
Describe experiences where you learned new technologies or concepts, how you approach learning embedded systems, projects where you pushed your boundaries
Practice Interview
Study Questions
Frequently Asked Embedded Developer Interview Questions
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.
How do you decide you know a new tool well enough to stop studying it and start shipping with it? Tell me about a time you made that call and what you were weighing.
Sample Answer
Direct answer
I treat this as a trade-off, not a knowledge threshold: I ship once I understand the parts that are actually load-bearing for correctness and for whoever maintains this afterward, I explicitly flag whatever I still don't understand at that point rather than hiding it, and I shape the first version to limit how much damage an unknown could cause.
Structured elaboration
- The real question isn't "do I know enough" in the abstract. It's whether I know enough of the parts that matter for this specific decision. I weigh the cost of continuing to study against the cost of the unknown parts causing wrong behavior, against how easily the team that inherits this, including future me, will be able to reason about it later.
- Separate load-bearing unknowns from cosmetic ones. A load-bearing unknown would silently break correctness or be expensive to unwind later; a cosmetic one is something like unfamiliar style conventions or a minor part of the interface I could look up when I need it. Only the first kind should actually block shipping.
- Flag what's still unknown, don't hide it. If something genuinely isn't understood yet at ship time, I say so directly: a comment in the code, a note in the review, or a follow-up item, so it's a visible, tracked risk instead of a silent one that surprises someone later.
- Shape the ship to limit exposure. Smaller surface area, behind a flag (a toggle that turns the new code path on for only a slice of users, so it's cheap to switch back off), easy to reverse, reviewed by someone who does know the tool well: all of these reduce how much damage an unknown can do if I turn out to be wrong about it.
Worked example
Picking up a new library for managing application state under a real deadline, I got comfortable enough with the common patterns within a couple of days but hadn't dug into how it handled a specific edge case around concurrent updates. I decided that edge case was load-bearing, since getting it wrong could cause silent data corruption, so I spent an extra half-day specifically verifying that one behavior with a small isolated test, while deciding I didn't need to fully understand the library's less-common configuration options, since those were cosmetic and easy to look up later if we ever needed them. I shipped behind a flag on a low-traffic part of the product first, and in the code review I explicitly flagged that I hadn't yet tested how the library behaved under our heaviest load, since I hadn't had time to simulate that realistically, and the team agreed that was an acceptable known gap to track rather than block on, given the limited blast radius of where it first shipped.
Trade-offs and pitfalls
The clearest failure on one side is perfectionism: waiting until you feel fully confident before shipping anything, which in practice means never shipping, since real fluency usually only comes from using something for real. The failure on the other side is shipping recklessly without distinguishing which unknowns actually matter, or worse, not flagging them at all, so the team inherits invisible risk they didn't agree to take on. The trade-off only works if the parts you decide are safe to ship with gaps genuinely are cosmetic, and you're honest with yourself, and with reviewers, about which unknowns you're actually still carrying.
Your team is integrating interrupts with FreeRTOS or Zephyr. What does it mean for an API to be ISR-safe, how does an ISR hand work to a task, and how do ISR priorities relate to the kernel's own critical sections? What must an ISR never call?
Sample Answer
What "ISR-safe" means. An RTOS call is ISR-safe (interrupt service routine safe) when it (1) never blocks, because an ISR is not a task and has no task to put to sleep; (2) protects the kernel's data by masking interrupts briefly instead of relying on a task context; and (3) reports whether it made a higher-priority task ready, so the ISR can ask for a task switch on its way out. FreeRTOS spells this with separate ...FromISR functions that take a pxHigherPriorityTaskWoken flag, followed by portYIELD_FROM_ISR(flag). Zephyr is a second RTOS with its own names for the same ideas, so the rest of this answer uses FreeRTOS names first and gives the Zephyr counterpart in brackets or in one closing sentence. Zephyr documents each function as usable from an ISR or not: "A semaphore may be given by a thread or an ISR", an ISR may take one only without waiting (K_NO_WAIT), and an ISR can call k_work_submit to offload work to the system workqueue (a workqueue is a thread that runs submitted work items one after another; K_NO_WAIT means "do not wait, fail at once if it would block").
How an ISR hands work to a task. The ISR does the minimum the hardware demands (read the data register, clear the status flag), then signals a task with one of three mechanisms, and the task does the slow work:
| Mechanism (FreeRTOS call) | What it carries | Cost and behaviour |
|---|---|---|
Queue (xQueueSendFromISR) | a copy of a message | ordered and bounded; can fail when full, so the ISR must check the result and count drops |
Task notification (vTaskNotifyGiveFromISR) | a counter or a flag for one task | cheapest; no payload; if the receiver clears the count when it takes (pdTRUE), several gives collapse into one wake-up |
Pended function (xTimerPendFunctionCallFromISR) | a function pointer and two words | runs later in the timer service task (the kernel's own background task that executes software-timer callbacks and these deferred calls), so no extra task is needed, but it shares that task and its command queue with every software timer; xTimerStartFromISR arms a software timer through the same task |
I would commit to this: notification when the event means "go look at the buffer the ISR filled" (UART bytes in a ring, a finished DMA transfer); a queue when each event has its own payload that must not be lost; a pended function for rare housekeeping that does not deserve a task. In Zephyr the equivalents are k_sem_give, a message queue put with K_NO_WAIT, and k_work_submit.
How ISR priorities relate to the kernel's critical sections. On Cortex-M, a lower priority number means more urgent, and an exception preempts only one of strictly higher urgency. FreeRTOS on the Cortex-M3 port enters a critical section by raising BASEPRI (the register that masks every interrupt whose priority number is at or above its value) to configMAX_SYSCALL_INTERRUPT_PRIORITY rather than by disabling all interrupts (the port's portmacro.h does exactly this). Priorities here are numbers, and a lower number is more urgent, so "above the ceiling" below always means more urgent than the ceiling, which is a numerically smaller value. That splits interrupts in two:
- Interrupts whose priority number is equal to or larger than the ceiling's number (equal or less urgent) are masked during critical sections and may call
...FromISRfunctions. A comment in the port'sport.csays ISR safe FreeRTOS API functions "must only be called from interrupts that have been assigned a priority at or below configMAX_SYSCALL_INTERRUPT_PRIORITY", which in numbers means a value equal to or larger than the ceiling. - Interrupts whose priority number is smaller than the ceiling's number (more urgent, so "above the ceiling") are never masked by the kernel, so they keep their low latency, but they must not call any FreeRTOS function at all. Zephyr's zero-latency interrupts are the same idea: they "should treat all kernel APIs as undefined behavior".
The port also puts PendSV (where task switches happen) and SysTick (the tick) at the lowest priority, so a task switch never happens in the middle of another ISR.
Proof, run in QEMU. The program below is FreeRTOS V11.1.0 (commit dbf70559b27d39c1fdb68dfb9a32140b6a6777a0) on a Cortex-M3 (lm3s6965evb). IRQ0 is the ISR-safe hand-off. IRQ1 is configured at 0x20 (above the ceiling 0x40), IRQ2 and IRQ3 at 0x60. The software-pend register in the NVIC stands in for a peripheral. FreeRTOSConfig.h. The part to read is the priority block. The NVIC stores each priority in an 8-bit field but this part implements only the top 3 bits (configPRIO_BITS 3), so a priority level n is written as n << (8 - 3), that is n << 5: level 7 (the lowest) becomes 0xE0 (configKERNEL_INTERRUPT_PRIORITY, used by PendSV and SysTick) and the ceiling level 2 becomes 0x40 (configMAX_SYSCALL_INTERRUPT_PRIORITY, the value loaded into BASEPRI during critical sections). Levels 1 and 3 are therefore 0x20 and 0x60, the values used in the test. The other settings size the heap and stacks and turn on the features the demo uses.
#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H
#include <stdint.h>
extern void app_assert_failed(const char *file, int line);
#define configUSE_PREEMPTION 1
#define configUSE_IDLE_HOOK 0
#define configUSE_TICK_HOOK 0
#define configCPU_CLOCK_HZ ( 12000000UL )
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
#define configMAX_PRIORITIES ( 5 )
#define configMINIMAL_STACK_SIZE ( ( unsigned short ) 128 )
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 24 * 1024 ) )
#define configMAX_TASK_NAME_LEN ( 8 )
#define configUSE_16_BIT_TICKS 0
#define configUSE_MUTEXES 0
#define configUSE_TASK_NOTIFICATIONS 1
#define configUSE_TIMERS 1
#define configTIMER_TASK_PRIORITY ( 3 )
#define configTIMER_QUEUE_LENGTH 8
#define configTIMER_TASK_STACK_DEPTH ( 128 )
#define configSUPPORT_DYNAMIC_ALLOCATION 1
#define configSUPPORT_STATIC_ALLOCATION 0
#define INCLUDE_vTaskDelay 1
#define INCLUDE_xTimerPendFunctionCall 1
/* Cortex-M3 priority bits: 3 implemented bits on this part (see the probe in the answer). */
#define configPRIO_BITS 3
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 7
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 2
#define configKERNEL_INTERRUPT_PRIORITY ( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << ( 8 - configPRIO_BITS ) )
#define configMAX_SYSCALL_INTERRUPT_PRIORITY ( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << ( 8 - configPRIO_BITS ) )
#define configASSERT( x ) if( ( x ) == 0 ) app_assert_failed( __FILE__, __LINE__ )
#define vPortSVCHandler SVC_Handler
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler
#endif
rtos_isr.c. The register macros at its top (REG, UART0_*, NVIC_ISER0, NVIC_ISPR0, NVIC_IPR) are defined in the file itself: NVIC_ISER0 enables interrupts 0 to 31, writing a 1 to a bit of NVIC_ISPR0 pends that interrupt in software (standing in for a peripheral raising it), and NVIC_IPR(n) is the priority byte of interrupt n. At the bottom, [2 ... 10] = Default_Handler is a GNU C range designator that fills table slots 2 to 10 with the same parking handler; slots 16 to 19 are the four device interrupts IRQ0 to IRQ3.
#include <stdint.h>
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "timers.h"
#define REG(a) (*(volatile uint32_t *)(a))
#define UART0_DR REG(0x4000C000u)
#define UART0_FR REG(0x4000C018u)
#define UART0_CTL REG(0x4000C030u)
#define NVIC_ISER0 REG(0xE000E100u)
#define NVIC_ISPR0 REG(0xE000E200u)
#define NVIC_IPR(n) (*(volatile uint8_t *)(0xE000E400u + (n)))
static void put(char c) { while (UART0_FR & (1u << 5)) { } UART0_DR = (uint8_t)c; }
static void puts_(const char *s) { while (*s) put(*s++); }
static void hex8(uint32_t v) { puts_("0x"); for (int i = 4; i >= 0; i -= 4) put("0123456789abcdef"[(v >> i) & 15]); }
static void dec(uint32_t v) { char b[11]; int n = 0; do { b[n++] = (char)('0' + v % 10u); v /= 10u; } while (v); while (n) put(b[--n]); }
void app_assert_failed(const char *file, int line)
{
(void)file;
__asm volatile("cpsid i");
puts_("ASSERT in kernel port check, line "); dec((uint32_t)line); puts_("\n");
for (;;) { }
}
/* ---------- event log: appended only inside critical sections or by ISRs ---------- */
static volatile char logbuf[40];
static volatile uint32_t logn;
static void log_ch(char c) { logbuf[logn++] = c; }
static void task_log(char c) { taskENTER_CRITICAL(); log_ch(c); taskEXIT_CRITICAL(); }
static QueueHandle_t q;
static TaskHandle_t notify_task;
#ifndef ISR_PRIO
#define ISR_PRIO 0x60u /* 3 << 5: numerically above the kernel ceiling 0x40, so API-safe */
#endif
static void deferred_fn(void *p1, uint32_t p2) { (void)p1; (void)p2; log_ch('T'); }
/* IRQ0: the ISR-safe path. The ISR only logs, posts, and asks for a switch. */
void IRQ0_Handler(void)
{
BaseType_t woken = pdFALSE;
static uint32_t n;
uint32_t v = ++n;
log_ch('I');
xQueueSendFromISR(q, &v, &woken);
vTaskNotifyGiveFromISR(notify_task, &woken);
xTimerPendFunctionCallFromISR(deferred_fn, 0, v, &woken);
#ifndef NO_YIELD
portYIELD_FROM_ISR(woken);
#endif
}
/* IRQ1 is above the ceiling (0x20): never touches the kernel. IRQ2 and IRQ3 are at 0x60. */
static volatile uint32_t fast_count, slow2_count, slow3_count;
static volatile uint32_t seen_in_cs_fast, seen_in_cs_slow, seen_after_cs_slow, basepri_in_cs;
static volatile uint32_t nest_a, nest_b, nest_c;
void IRQ1_Handler(void) { fast_count++; }
void IRQ3_Handler(void) { slow3_count++; }
void IRQ2_Handler(void)
{
slow2_count++;
nest_a = fast_count; NVIC_ISPR0 = 1u << 1; __asm volatile("dsb\n isb"); /* pend the 0x20 interrupt */
nest_b = fast_count; NVIC_ISPR0 = 1u << 3; __asm volatile("dsb\n isb"); /* pend the equal-priority one */
nest_c = slow3_count;
}
static void consumer(void *arg)
{
(void)arg; uint32_t v;
for (;;) { if (xQueueReceive(q, &v, portMAX_DELAY) == pdTRUE) task_log('Q'); }
}
static void notified(void *arg)
{
(void)arg;
for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); task_log('N'); }
}
static void producer(void *arg)
{
(void)arg;
/* Part 1: what a kernel critical section masks. */
taskENTER_CRITICAL();
{ uint32_t b; __asm volatile("mrs %0, basepri" : "=r"(b)); basepri_in_cs = b; }
NVIC_ISPR0 = (1u << 1) | (1u << 2); /* pend IRQ1 (0x20) and IRQ2 (0x60) */
__asm volatile("dsb\n isb");
seen_in_cs_fast = fast_count;
seen_in_cs_slow = slow2_count;
taskEXIT_CRITICAL();
__asm volatile("dsb\n isb");
seen_after_cs_slow = slow2_count;
/* Part 2: the ISR-safe hand-off, three times. */
for (int i = 0; i < 3; i++) {
task_log('P');
NVIC_ISPR0 = 1u << 0;
__asm volatile("dsb\n isb");
}
vTaskDelay(20);
puts_("log: ");
for (uint32_t i = 0; i < logn; i++) put(logbuf[i]);
puts_("\nbasepri_in_critical_section="); hex8(basepri_in_cs);
puts_("\nIRQ1 (0x20) ran inside the section: "); dec(seen_in_cs_fast);
puts_("\nIRQ2 (0x60) ran inside the section: "); dec(seen_in_cs_slow);
puts_("\nIRQ2 (0x60) ran right after it ends: "); dec(seen_after_cs_slow);
puts_("\nfrom inside IRQ2: IRQ1 ran at once: "); dec(nest_b - nest_a);
puts_(", IRQ3 (same priority) ran at once: "); dec(nest_c); puts_("\n");
for (;;) { vTaskDelay(1000); }
}
int main(void)
{
UART0_CTL = 0x301u;
NVIC_IPR(0) = 0xFFu; puts_("IPR readback of 0xff: "); hex8(NVIC_IPR(0)); puts_("\n");
NVIC_IPR(0) = ISR_PRIO; NVIC_IPR(1) = 0x20u; NVIC_IPR(2) = 0x60u; NVIC_IPR(3) = 0x60u;
NVIC_ISER0 = 0xFu;
q = xQueueCreate(8, sizeof(uint32_t));
xTaskCreate(consumer, "cons", 256, 0, 2, 0);
xTaskCreate(notified, "notif", 256, 0, 2, ¬ify_task);
xTaskCreate(producer, "prod", 512, 0, 1, 0);
vTaskStartScheduler();
for (;;) { }
}
extern uint32_t _sbss, _ebss, _sdata, _edata, _sidata, _estack;
void Reset_Handler(void)
{
for (uint32_t *s = &_sidata, *d = &_sdata; d < &_edata;) *d++ = *s++;
for (uint32_t *p = &_sbss; p < &_ebss; p++) *p = 0;
main();
for (;;) { }
}
void Default_Handler(void) { for (;;) { } }
extern void SVC_Handler(void), PendSV_Handler(void), SysTick_Handler(void);
__attribute__((section(".vectors"), used))
void (*const vectors[20])(void) = {
[0] = (void (*)(void))&_estack, [1] = Reset_Handler, [2 ... 10] = Default_Handler,
[11] = SVC_Handler, [12 ... 13] = Default_Handler, [14] = PendSV_Handler, [15] = SysTick_Handler,
[16] = IRQ0_Handler, [17] = IRQ1_Handler, [18] = IRQ2_Handler, [19] = IRQ3_Handler,
};
link.ld:
MEMORY { FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K }
ENTRY(Reset_Handler)
SECTIONS {
.text : { KEEP(*(.vectors)) *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH
.ARM.exidx : { *(.ARM.exidx*) } > FLASH
_sidata = LOADADDR(.data);
.data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH
.bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM);
}
Build and run in a gcc:14 container with apt-get install -y qemu-system-arm gcc-arm-none-eabi libnewlib-arm-none-eabi git (arm-none-eabi-gcc 14.2.1, qemu-system-arm 10.0):
git clone -q --depth 1 --branch V11.1.0 https://github.com/FreeRTOS/FreeRTOS-Kernel.git kernel
K=kernel
SRC="rtos_isr.c $K/tasks.c $K/queue.c $K/list.c $K/timers.c $K/portable/GCC/ARM_CM3/port.c $K/portable/MemMang/heap_4.c"
F="-mcpu=cortex-m3 -mthumb -O1 -g -Wall -I. -I$K/include -I$K/portable/GCC/ARM_CM3 -nostartfiles --specs=nosys.specs -T link.ld"
arm-none-eabi-gcc $F $SRC -o r.elf
timeout 6 qemu-system-arm -M lm3s6965evb -nographic -kernel r.elf -serial stdio -monitor none
Output of that build:
IPR readback of 0xff: 0xe0
log: PITQNPITQNPITQN
basepri_in_critical_section=0x40
IRQ1 (0x20) ran inside the section: 1
IRQ2 (0x60) ran inside the section: 0
IRQ2 (0x60) ran right after it ends: 1
from inside IRQ2: IRQ1 ran at once: 1, IRQ3 (same priority) ran at once: 0
What it shows. Writing 0xff to a priority register and reading it back gives 0xe0: three implemented bits, which is why the config sets configPRIO_BITS 3 (always read the implemented bits from the part). In the log each round is P (producer pends the interrupt), I (ISR), T (the pended function, which runs in the timer service task at priority 3), Q and N (the queue consumer and the notified task, both at priority 2): with portYIELD_FROM_ISR the woken tasks all ran before the producer (priority 1) continued. Inside the critical section BASEPRI was 0x40, the 0x20 interrupt still ran (count 1) and the 0x60 interrupt was held (count 0) until the section ended. From inside the 0x60 handler, the 0x20 interrupt preempted at once and the equal-priority 0x60 one did not.
Rebuilding with -DNO_YIELD (the portYIELD_FROM_ISR line removed) gave, in one sample run, the log PIPIPITTTQQQN (the exact interleaving depends on where the 1 kHz tick falls relative to the three rounds, so it can differ between runs): the woken tasks waited until the producer blocked, so P and I ran three times first. It is also the notification collapse: three gives, one N, because ulTaskNotifyTake(pdTRUE, ...) clears the count (the FreeRTOS header: with pdFALSE the notification value acts like a counting semaphore).
Rebuilding with -DISR_PRIO=0x20u puts IRQ0 above the ceiling while it still calls xQueueSendFromISR. The run prints ASSERT in kernel port check, line 805 (the line number is where the check sits in the kernel's port.c; the program's own app_assert_failed prints it): that is configASSERT( ucCurrentPriority >= ucMaxSysCallPriority ) in the port's vPortValidateInterruptPriority, active because configASSERT is defined. Without the assert the same mistake would let the urgent interrupt run kernel code in the middle of a critical section, a bug that shows up only when the timing lines up. Define configASSERT in every development build.
QEMU shows the logic and the ordering; it says nothing about how long entry, masking or switching take on a real part.
What an ISR must never call.
- Anything that blocks or waits: a queue receive or send with a non-zero timeout,
vTaskDelay,k_sleep, a semaphore take with a timeout. - Mutexes. FreeRTOS's
semphr.hstates "Mutex type semaphores cannot be used from within interrupt service routines"; a mutex has an owning task, which an ISR is not. - The task-context variants of a function when a
FromISRone exists (they assume a current task and use task-level critical sections). - Memory allocation (
malloc,pvPortMalloc): it takes a lock or walks a free list for an unbounded time, and the interrupted code may be inside the allocator. - Any RTOS call at all from an interrupt whose priority is above the ceiling.
printfand other library calls that lock or are slow, and long loops.
What would flip the design. If the event must be handled in a few microseconds, do it in an above-ceiling interrupt that touches only its own registers and a lock-free ring, and notify a task from a second, lower-priority interrupt. If latency does not matter, run the whole handler as a task and keep the ISR to "clear the flag and notify".
Write a short handoff note to whoever is picking up your work next (for example an on-call shift or an unfinished task). Cover the current state, what you have already tried, and what they should watch for.
Sample Answer
Direct answer
Cover the current state, what has already been tried (including what didn't work), and what to watch for next, so whoever picks this up doesn't waste time repeating steps you've already ruled out.
Structured elaboration
- Current state: what's actually happening right now, in concrete terms, not just a label. "Service is degraded" is weaker than "response times are 3x normal but the service is still serving requests."
- What's been tried, including attempts that didn't work. This is often the most valuable part of a handoff, since it prevents the next person from re-trying something you've already ruled out.
- What to watch for: the specific signal that would indicate the situation is getting better, getting worse, or that a particular hypothesis is confirmed or ruled out.
- Anything time-sensitive: a deadline, an escalation that's already in motion, or a promise already made to someone waiting on an update.
- Keep it scannable. A handoff note that's read under time pressure needs to be skimmable in under a minute, not a full narrative.
Worked example
"Current state: checkout latency is elevated (roughly 2x baseline) but not failing outright. Tried: restarted the payment service (no change), checked for a recent deploy (none in the last 24 hours, ruling that out). Not yet tried: checking the database connection pool, which is my next suspicion since the timing correlates with a traffic spike. Watch for: if latency crosses 3x baseline, that's the threshold where we'd start failing requests, escalate immediately if you see that."
This tells the next person exactly what's confirmed, what's ruled out, what's still suspected, and the specific threshold that changes the urgency, without requiring them to re-derive any of it.
Trade-offs and pitfalls
- Omitting what didn't work is the most common gap; a handoff that only says what you tried, without saying it didn't help, can lead the next person to redundantly retry it.
- A handoff written too tersely to be useful ("still broken, working on it") forces the next person to start from scratch; a handoff written as a full narrative takes too long to read under time pressure. The right length states facts plainly without either extreme.
- If you genuinely don't have a next hypothesis, say so honestly rather than implying more progress than you've made; "no clear lead yet, still gathering information" is a legitimate and useful handoff.
Implement uint32_t next_with_same_ones(uint32_t x) that returns the next larger integer > x with the same number of set bits. Example: next_with_same_ones(0b00110) => 0b01001. Your implementation should use efficient O(1) bit operations and handle edge cases (return 0 if no higher number exists).
Sample Answer
Approach (brief)
Use the standard bit-twiddling trick: find the rightmost non-trailing zero, flip it, clear bits to its right, then insert (count_ones-1) ones at the least-significant positions. This is O(1) with few bit ops.
C implementation
#include <stdint.h>
// Return next higher uint32 with same popcount, or 0 if none exists.
uint32_t next_with_same_ones(uint32_t x) {
if (x == 0) return 0;
uint32_t smallest = x & -x; // rightmost 1
uint32_t ripple = x + smallest; // flip rightmost non-trailing zero
if (ripple == 0) return 0; // overflow -> no larger number
uint32_t ones = x ^ ripple; // bits that changed
ones = (ones >> 2) / smallest; // shift the ones to the rightmost positions
return ripple | ones;
}
Why it works
- smallest = isolate lowest 1.
- ripple adds smallest giving the next carry to flip the zero above the trailing ones.
- ones computes the trailing ones that need to be packed at LSB side.
- Division by smallest and shift align the ones.
Complexity & edge cases
- Time: O(1) constant bit operations. Space: O(1).
- Returns 0 on overflow (no higher with same popcount) and for input 0.
Notes
- Works on two's-complement; well-suited for embedded C.
Compare and contrast C11 atomic_thread_fence (memory_order_acq_rel or seq_cst), Linux smp_mb()/wmb()/rmb(), and architecture-specific barriers like ARM DMB/DSB. For an embedded device driver that must notify hardware via a doorbell MMIO write after preparing DMA descriptors in memory, which of the above is appropriate and why? Provide mapping from high-level primitives to the barrier you would use.
Sample Answer
Quick framing / goal
You must ensure CPU stores of DMA descriptors in memory are visible to the device before the doorbell MMIO write that notifies the device. That requires: (1) ordering of CPU stores (write descriptors) with respect to the MMIO write, and (2) making MMIO write reach device (posted writes / caches).
Compare primitives
- C11 atomic_thread_fence(memory_order_acq_rel / seq_cst)
- Language-level fence that orders C11 atomic operations. On real hardware it maps to architecture fences or compiler barriers; it prevents compiler reordering of atomic accesses and can emit CPU fences if required.
- Use when your descriptor stores are atomics or you want portable C-level ordering.
- Linux smp_mb()/wmb()/rmb()
- Kernel-level full/half memory barriers that guarantee ordering on SMP systems and map to proper CPU instructions (and often to I/O barriers when needed in drivers).
- Designed for ordering normal memory operations; smp_wmb() orders stores before subsequent stores; smp_mb() is full fence.
- Architecture-specific barriers (ARM DMB/DSB)
- DMB (Data Memory Barrier) orders memory accesses seen by other agents; DSB (Data Synchronization Barrier) is stronger — waits for completion of memory transactions.
- On ARM for device activation you often need DMB + an appropriate device-write ordering. For posted MMIO, DSB may be required to ensure completion.
Which to use for DMA doorbell (embedded driver)
- Use the kernel/OS primitives that map correctly and include I/O ordering: in Linux kernel driver use smp_wmb() before writel() to doorbell, or smp_mb() if unsure. If writing baremetal or in userspace on ARM, use:
- store descriptors (regular stores)
- DMB ish — use DMB SY to ensure memory stores are visible to device
- then perform the MMIO write; on ARM when MMIO is posted, follow with DSB SY if you must wait for completion before proceeding or returning (or use read-back / posting-complete).
- If using C11 atomics, make the descriptor writes atomic and call atomic_thread_fence(memory_order_release) before the MMIO write; on many platforms this maps to DMB-ish fences, but you must ensure the MMIO write itself is executed as an ordered device access (use volatile MMIO access or platform I/O routines).
Mapping summary
- Linux kernel driver:
- descriptor stores -> smp_wmb()
- doorbell MMIO write -> writel() (I/O write)
- if confirmation needed -> readl() or smp_mb()/mb() as appropriate
- ARM baremetal:
- descriptor stores -> normal stores
- ensure visibility -> DMB SY (or DMB ST for store ordering)
- doorbell write -> STR to device register (strongly-ordered or device-mapped)
- ensure completion -> DSB SY or a read-back of doorbell register
- Portable C11:
- descriptor stores as atomic stores with memory_order_relaxed
- release fence -> atomic_thread_fence(memory_order_release) before doorbell
- doorbell write as volatile MMIO (ensure compiler emits store)
Practical note
Prefer kernel/OS primitives (smp_wmb/smp_mb) or architecture barriers (DMB/DSB) over relying solely on C11 fences for MMIO ordering; also use posted-write flush (read-back or DSB) when the device requires guaranteed visibility.
Given three candidate wireless technologies for an IoT sensor (BLE, Wi-Fi, LoRaWAN), evaluate each for a battery-powered device that transmits a 30-byte payload every 10 minutes and must operate for 2 years. Discuss power, range, latency, network topology, and typical industry use cases.
Sample Answer
Answer (Embedded Developer perspective)
Summary recommendation
- LoRaWAN is the most realistic to meet 2-year battery life with wide range; BLE is good for short-range, low-latency, and gateway-carried devices; Wi‑Fi is unlikely to meet 2-year battery life unless using rare low-power variants and very large batteries.
Power
- BLE (BLE 5.0, peripheral sending 30 B every 10 min): very low TX energy (tens–hundreds µJ per packet). With aggressive sleep and radio off, a small Li-ion coin cell or AAA can last years if connection stays minimal. Typical MCU+BLE estimate: ~50–200 µA average.
- LoRaWAN (SF7–SF12 trade-off): single TX might be several mJ–100s mJ depending on spreading factor; average current often <100 µA for low duty. Practically can achieve 2+ years on a 2400 mAh Li battery for 30 B/10min.
- Wi‑Fi (802.11n): high TX power and long association overhead; average currents in mA–100s mA when active — impractical for 2-year with small batteries.
Range
- BLE: ~10–40 m (indoors), up to 100 m line-of-sight with BLE Long Range.
- LoRaWAN: kilometers (rural tens of km; urban ~1–5 km).
- Wi‑Fi: ~30–100 m typical; depends on environment.
Latency
- BLE: low (ms) for connected; suitable for near-real-time.
- LoRaWAN: higher and variable (seconds to minutes) due to duty cycle, ADR and class (A/C).
- Wi‑Fi: low (ms), suitable for real-time.
Network topology
- BLE: star/mesh (with Bluetooth Mesh) — often gateway or phone-centric.
- LoRaWAN: star-of-stars (end nodes → gateways → network server).
- Wi‑Fi: star (AP-centric) with local LAN/internet.
Industry use cases
- BLE: wearable sensors, asset tags, phone-proxied sensors, firmware updates via phone.
- LoRaWAN: remote environmental sensors, agriculture, smart meters requiring years of life and long range.
- Wi‑Fi: high-throughput sensors, on-prem devices with mains power (cameras, firmware-heavy devices).
Concrete consideration for this device
- If you need 2-year battery, multi-km range, and small payloads every 10 min → choose LoRaWAN (optimize SF and ADR).
- If device is near a phone/gateway and needs low latency/config via mobile → BLE.
- Avoid Wi‑Fi unless mains power or very large battery is acceptable.
What is worst-case execution time, and why does a hard real-time design need it rather than an average? Describe how it is obtained in practice and where each approach falls short.
Sample Answer
What it is. The worst-case execution time (WCET) of a piece of code is an upper bound on how long it takes on a given processor and configuration, running without interruption, over every possible input and every possible hardware state (cache contents, pipeline state, and so on). Its relatives are the average-case time (ACET) and the best-case time (BCET). A WCET figure is only useful if it is safe (never below the true worst case) and reasonably tight (not far above it).
Why a hard real-time design needs it instead of the average. Schedulability analysis (proving that every task meets its deadline, for example with response-time analysis) takes each task's computation time C as the most it can ever consume. A hard real-time system fails on the one slow run, and the average is dominated by the typical runs that hide it. The program below shows both effects. Run in a python:3.12-slim container (CPython 3.12) with a fixed random seed, it prints:
import random
from math import ceil
def insertion_sort_comparisons(a):
"""Count key comparisons: the work grows with how out of order the input is."""
a = list(a)
count = 0
for i in range(1, len(a)):
key, j = a[i], i - 1
while j >= 0:
count += 1
if a[j] > key:
a[j + 1] = a[j]
j -= 1
else:
break
a[j + 1] = key
return count
N = 32
rng = random.Random(2024)
samples = [insertion_sort_comparisons(rng.sample(range(1000), N)) for _ in range(10_000)]
worst = insertion_sort_comparisons(range(N, 0, -1)) # reverse-sorted input
print(f"n={N}: mean {sum(samples) / len(samples):.0f}, largest of 10,000 random inputs {max(samples)}, "
f"true worst case (reverse sorted) {worst} = n(n-1)/2 = {N * (N - 1) // 2}")
print(f"largest observed is {max(samples) / worst:.0%} of the true worst case")
def response_times(tasks):
"""tasks: (name, C, T) in rate-monotonic order; deadline = period. Returns {name: R or None}."""
out = {}
for i, (name, C, T) in enumerate(tasks):
R = C
while True:
nxt = C + sum(ceil(R / Tj) * Cj for _, Cj, Tj in tasks[:i])
if nxt == R or nxt > T:
R = nxt
break
R = nxt
out[name] = R
return out
avg = [("A", 2, 10), ("B", 5, 20), ("C", 10, 50)]
wcet = [("A", 3, 10), ("B", 9, 20), ("C", 12, 50)]
for label, ts in (("average-case C", avg), ("worst-case C", wcet)):
u = sum(C / T for _, C, T in ts)
rt = response_times(ts)
verdict = ", ".join((f"R_{n}={r} (D={T}) ok" if r <= T else f"R_{n} exceeds D={T} MISS") for (n, _, T), r in zip(ts, rt.values()))
print(f"{label:15} U={u:.2f} {verdict}")
n=32: mean 276, largest of 10,000 random inputs 389, true worst case (reverse sorted) 496 = n(n-1)/2 = 496
largest observed is 78% of the true worst case
average-case C U=0.65 R_A=2 (D=10) ok, R_B=7 (D=20) ok, R_C=19 (D=50) ok
worst-case C U=0.99 R_A=3 (D=10) ok, R_B=15 (D=20) ok, R_C exceeds D=50 MISS
- The insertion sort counts comparisons, a deterministic stand-in for time. Random inputs average 276 comparisons and never exceeded 389 in 10,000 tries, but the true worst case is 496, reached by reverse-sorted input that random tests almost never produce.
- The response-time function finds each task's worst-case response time R (release to completion) as the smallest R with R = C + the sum over higher-priority tasks of ceil(R / T_j) x C_j. For task C with worst-case values (C = 12; A is 3 every 10, B is 9 every 20): R = 12; then 12 + ceil(12/10) x 3 + ceil(12/20) x 9 = 12 + 6 + 9 = 27; then 12 + 9 + 18 = 39; then 12 + 12 + 18 = 42; then 12 + 15 + 27 = 54. That is above the 50 ms deadline, so the iteration stops there and reports MISS. With the average values the same iteration settles at 19, inside 50.
- With average-case C values the three-task set looks comfortable (U = 0.65, every deadline met). With worst-case C values U = 0.99 and the lowest-priority task misses its 50 ms deadline. A design validated on averages would ship a system that is unschedulable on its worst inputs.
How a WCET is obtained, and where each way falls short (survey: Wilhelm et al., ACM Transactions on Embedded Computing Systems, 2008).
- Static analysis. A tool reads the compiled binary, builds its control-flow graph (the possible paths through the code), bounds every loop (from annotations or inference), uses a model of the processor's pipeline, caches and memory timing (the timing model) to put a time bound on each basic block (straight-line code), and finds the longest feasible path, commonly by integer linear programming (ILP: choose how many times each block runs so that the total time is largest, subject to constraints taken from the code's structure and the annotations). A tiny illustrative example: an entry block of 5 cycles, a loop whose header costs 4 cycles, a branch inside the loop that takes either a 6-cycle block or an 11-cycle block, and an exit block of 3 cycles, with the loop bounded to 10 iterations. The tool maximises 5 + 4 x (header runs) + 6 x (short block runs) + 11 x (long block runs) + 3, subject to header runs at most 10 and short plus long equal to header runs. The maximum takes the long block every time: 5 + 40 + 110 + 3 = 158 cycles. If a reviewed flow fact says the long block (an error path) runs at most twice per call, the bound drops to 5 + 40 + 2 x 11 + 8 x 6 + 3 = 118 cycles. A flow fact is exactly this kind of statement about the program that the tool cannot derive on its own, so a wrong one silently makes the bound unsafe. AbsInt's aiT is an example of a commercial tool. It falls short in four ways: it needs an accurate and complete processor model, which is hard for complex or poorly documented cores; it needs loop bounds and flow facts, and a wrong annotation makes the result silently unsafe; it is pessimistic wherever it cannot tell which cache state applies; and it must be rerun when the compiler, flags or hardware change.
- Measurement. Run the code on the real hardware and record the longest time seen (the high-water mark, meaning the largest value observed so far). The simplest start is a GPIO pin set high at the start of the code and low at the end, read on a logic analyser. On Arm Cortex-M the next step is the cycle counter in the DWT unit (the data watchpoint and trace unit; the counter is an optional feature, and the DWT_CTRL.NOCYCCNT bit reports when it is absent). Trace is for deeper investigation: ITM (instrumentation trace, written by software) and ETM (embedded trace, a hardware record of the instructions executed). It falls short because it only covers the inputs and hardware states you exercised: the worst path or the worst cache state may never occur in testing, as the sort shows. The result is a lower bound on the true WCET, not an upper bound, and instrumentation itself disturbs the timing.
- Hybrid. Measure short code segments on the real hardware and combine them with the program's structure, taking the longest path through the graph. It falls short because each segment's measured worst case still depends on the hardware state on entry and on the tests having reached its slow state, and adding segment maxima assumes the segments are independent, which cache and pipeline state break.
What to do in practice.
- Fix the exact binary, compiler flags, clock and memory configuration first: a WCET belongs to code plus configuration.
- On a core without caches or with simple pipelines, a static or hybrid bound is tractable. On a Cortex-A class core (caches, branch prediction, out-of-order execution, where the core may run later instructions before earlier ones whose inputs are not ready, a memory bus shared with other cores) a sound bound is much harder, so reduce the variability: pin tasks to cores, partition or lock caches, or disable features for the critical code.
- Make the code analysable: bounded loops, no recursion, no dynamic allocation in the critical path.
- For hard real-time use a static or hybrid bound, with measurement as a cross-check (the measured maximum must never exceed the bound). For soft real-time a measured high-water mark plus a margin, backed by a run-time execution-time monitor, is a reasonable engineering trade.
Given struct S { char a; uint32_t b; char c; }; on a 32-bit Cortex-M, what is sizeof(S)? How would you shrink it, what does __attribute__((packed)) cost you on that core, and when would you pack a struct deliberately to overlay it on a received byte stream?
Sample Answer
sizeof is 12. Alignment is the rule that a value of size N sits at an address that is a multiple of N, which hardware prefers or sometimes requires: reading a 4-byte value from an address that is not a multiple of 4 is an unaligned access, which some cores perform slowly or cannot perform at all. Each member must sit at an address that is a multiple of its alignment (a 32-bit integer on Cortex-M, under the Arm ABI, has 4-byte alignment; the ABI, or application binary interface, is the set of rules compilers and linkers follow so that separately built code agrees on sizes, alignment and calling conventions), and the struct's total size is rounded up to a multiple of its strictest member alignment so that an array of them keeps every b aligned:
| member | offset | size | note |
|---|---|---|---|
a | 0 | 1 | |
| (padding) | 1 to 3 | 3 | so b starts at a multiple of 4 |
b | 4 | 4 | |
c | 8 | 1 | |
| (tail padding) | 9 to 11 | 3 | so the next array element's b is aligned |
That is 6 bytes of data in 12 bytes. The program below states every size and offset as a compile-time _Static_assert:
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
struct S { char a; uint32_t b; char c; };
struct S_sorted { uint32_t b; char a; char c; };
struct T { char a; short b; char c; };
struct __attribute__((aligned(8))) S_align8 { char a; uint32_t b; char c; };
struct __attribute__((packed)) S_packed { char a; uint32_t b; char c; };
struct __attribute__((packed)) Hdr { uint8_t type; uint16_t len; uint32_t seq; };
_Static_assert(sizeof(struct S) == 12, "S is 12");
_Static_assert(offsetof(struct S, b) == 4 && offsetof(struct S, c) == 8, "S offsets");
_Static_assert(sizeof(struct S_sorted) == 8, "sorted is 8");
_Static_assert(sizeof(struct S_packed) == 6, "packed is 6");
_Static_assert(sizeof(struct T) == 6, "T is 6");
_Static_assert(sizeof(struct S_align8) == 16, "S_align8 is 16");
_Static_assert(sizeof(struct Hdr) == 7 && offsetof(struct Hdr, seq) == 3, "Hdr");
#ifndef NO_MAIN
#include <string.h>
int main(void)
{
printf("sizeof S=%zu (b@%zu c@%zu), sorted=%zu, packed=%zu, T=%zu, S_align8=%zu\n",
sizeof(struct S), offsetof(struct S, b), offsetof(struct S, c),
sizeof(struct S_sorted), sizeof(struct S_packed), sizeof(struct T), sizeof(struct S_align8));
uint8_t rx[16] = {0};
rx[1] = 7; rx[2] = 0x34; rx[3] = 0x12; rx[4]=1; rx[5]=0; rx[6]=0; rx[7]=0; /* frame starts at odd offset 1 */
const struct Hdr *h = (const struct Hdr *)(rx + 1);
struct Hdr copy; memcpy(©, rx + 1, sizeof copy);
printf("overlay: type=%u len=0x%X seq=%u; memcpy: type=%u len=0x%X seq=%u\n",
h->type, h->len, h->seq, copy.type, copy.len, copy.seq);
return 0;
}
#endif
With -DNO_MAIN (which drops the printf driver), it compiles cleanly for -mcpu=cortex-m4 -mthumb -O2 -Wall -Wextra and for -mcpu=cortex-m0 -mthumb -O2 -Wall -Wextra with arm-none-eabi-gcc 14.2.1 (the asserts hold on both, so the 12, 8, 6, 6 and 16 hold for those cores), and with gcc 14.4 on aarch64 Linux using -O2 -Wall -Wextra -fsanitize=address,undefined it prints:
sizeof S=12 (b@4 c@8), sorted=8, packed=6, T=6, S_align8=16
overlay: type=7 len=0x1234 seq=1; memcpy: type=7 len=0x1234 seq=1
Shrinking it, without packing.
- Reorder members, largest alignment first:
{ uint32_t b; char a; char c; }is 8 bytes (b at 0, a at 4, c at 5, two bytes of tail padding). That costs nothing at run time. - Use the narrowest types that hold the range (a
uint8_tflag instead of auint32_t), and group same-size members. A three-field struct with ashortin the middle,{ char a; short b; char c; }, is 6 bytes (one byte of padding beforeb, one afterc). - Raising alignment goes the other way:
__attribute__((aligned(8)))on the struct makessizeof16 here. You do that for DMA (hardware that copies memory without the CPU and may require buffers aligned to its transfer size) or cache-line (the fixed-size chunk a data cache loads at once) reasons, not to save space.
What __attribute__((packed)) costs on Cortex-M. It removes the padding (sizeof 6 for S), which means b sits at offset 1, not on a 4-byte boundary. Reading b then depends on whether the core can do an unaligned access. For a first answer the key fact is: M0-class cores cannot be relied on to load a word from an unaligned address, while M3, M4 and M7 can for ordinary loads, and that is why the compiler's output differs between them below. The detail behind it: GCC's documentation for -munaligned-access says it is disabled by default for ARMv6-M (the Cortex-M0 and M0+ family; ARMv6-M, ARMv7-M and ARMv8-M Baseline are the Arm architecture profiles the cores implement) and for ARMv8-M Baseline, and enabled for the other ARM architectures (including the ARMv7-M Cortex-M3, M4 and M7), and that when it is disabled, data in packed structures is accessed one byte at a time. The compiler output agrees. For a 32-bit member at offset 3 of a packed header (struct Hdr { uint8_t type; uint16_t len; uint32_t seq; }, 7 bytes), the functions in this file, compiled with arm-none-eabi-gcc 14.2.1 for the two cores, give the following for seq_overlay:
#include <stdint.h>
#include <string.h>
struct __attribute__((packed)) Hdr { uint8_t type; uint16_t len; uint32_t seq; };
uint32_t seq_overlay(const uint8_t *p) { return ((const struct Hdr *)p)->seq; }
uint32_t seq_shifts(const uint8_t *p)
{
return (uint32_t)p[3] | (uint32_t)p[4] << 8 | (uint32_t)p[5] << 16 | (uint32_t)p[6] << 24;
}
; -mcpu=cortex-m4 -mthumb -O2
seq_overlay:
ldr r0, [r0, #3] @ unaligned
bx lr
; -mcpu=cortex-m0 -mthumb -O2
seq_overlay:
ldrb r2, [r0, #4]
ldrb r3, [r0, #3]
lsls r2, r2, #8
orrs r2, r3
ldrb r3, [r0, #5]
ldrb r0, [r0, #6]
lsls r3, r3, #16
orrs r3, r2
lsls r0, r0, #24
orrs r0, r3
bx lr
On the M4 it is one load instruction. On the M0 it is four byte loads plus shifts and ORs, ten instructions instead of one (not counting the return), with the matching cost in code size and speed. Reading the M0 code (r0 is the pointer p):
ldrb r3, [r0, #3]loads byte 3, the least significant byte ofseq;ldrb r2, [r0, #4]loads byte 4.lsls r2, r2, #8shifts byte 4 up by 8 bits, andorrs r2, r3merges it with byte 3, sor2now holds bytes 4 and 3 as a 16-bit value.ldrb r3, [r0, #5]loads byte 5 andlsls r3, r3, #16moves it to bits 16 to 23;orrs r3, r2merges the pieces so far.ldrb r0, [r0, #6]loads byte 6,lsls r0, r0, #24moves it to the top byte, and the finalorrs r0, r3completes the 32-bit result, which is returned inr0.
That is exactly the seq_shifts expression written out as instructions. The practical rules:
- Access packed members through the struct, so the compiler knows they are packed. A plain
uint32_t *pointing at a packed member loses that information (GCC warns with-Waddress-of-packed-member) and may be compiled as a normal aligned load. - Packing hurts on every access, so pack only the structs that need it, not the whole program.
When to pack deliberately: matching a fixed external layout. Two real cases: a descriptor in memory (for example a DMA descriptor, or a record in a file or flash image) whose layout is defined by an external specification, and a received byte stream with a defined format (a protocol header). For those you want a struct whose members land exactly where the specification says, with no padding. A memory-mapped peripheral register block is usually not a case for packed: its registers already sit at naturally aligned offsets, so there is no padding to remove, and, as the GCC documentation quoted above says, on a core without unaligned access words in packed structures are accessed a byte at a time, which is the wrong access width for a register that must be read or written as a whole word. In the program above, struct Hdr is type at 0, len at 1, seq at 3, size 7, asserted at compile time, and a frame that starts at an odd offset in the receive buffer is read through it without trouble on the host.
"Overlaying" means pointing a struct at the received bytes so that its members read the fields in place, without copying. Overlaying a packed struct on a byte buffer has three catches:
- Alignment of the buffer. Casting
rx + 1to a struct pointer is only safe because the packed type tells the compiler the address may be unaligned. - Byte order. The overlay gives you the fields in the CPU's byte order. Cortex-M parts are typically run little-endian, and many wire formats are big-endian (network order: most significant byte first), so every multi-byte field still needs a swap.
- Layout rules that are not portable. Packing does not fix bitfield ordering (which end of the byte the compiler places the first bitfield member is up to the compiler), and padding assumptions break if the struct changes.
So the safest default for incoming data is decode, do not overlay: copy into a local struct with memcpy, as copy does in the program (identical result in the output), or assemble each field from bytes with shifts and ORs, which is what seq_shifts does in the file above and which compiles to the same single load on the M4 and to the byte loads on the M0. Keep the packed overlay for fixed-layout blocks where you have checked the cost, and always back it with _Static_assert(sizeof(...)) and offsetof checks. Note that packed only removes the alignment problem: reading a uint8_t array through a struct-typed lvalue is also outside the C aliasing rules (an object's declared type decides which lvalue types may access it), which is a second reason the memcpy decode is the default and the overlay is a deliberate, checked exception.
A worked case of the safe path, for a frame that arrives big-endian at an odd offset in the buffer (type 7, length 0x1234, sequence number 1):
#include <stdint.h>
#include <stdio.h>
#include <string.h>
struct __attribute__((packed)) Hdr { uint8_t type; uint16_t len; uint32_t seq; };
static uint16_t be16(const uint8_t *p) { return (uint16_t)(p[0] << 8 | p[1]); }
static uint32_t be32(const uint8_t *p)
{
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3];
}
int main(void)
{
/* a big-endian frame at odd offset 1: type=7, len=0x1234, seq=1 */
uint8_t rx[16] = { 0, 7, 0x12, 0x34, 0x00, 0x00, 0x00, 0x01 };
struct Hdr raw; /* step 1: copy the bytes, no cast */
memcpy(&raw, rx + 1, sizeof raw);
printf("memcpy only : len=0x%04X seq=0x%08X (wrong on a little-endian CPU)\n", raw.len, raw.seq);
struct Hdr h = { raw.type, be16(rx + 2), be32(rx + 4) }; /* step 2: decode each field from its bytes */
printf("decoded : type=%u len=0x%04X seq=0x%08X\n", h.type, h.len, h.seq);
return 0;
}
Compiled with gcc 14.4 (aarch64 Linux) using -O2 -Wall -Wextra -fsanitize=address,undefined, it prints:
memcpy only : len=0x3412 seq=0x01000000 (wrong on a little-endian CPU)
decoded : type=7 len=0x1234 seq=0x00000001
The memcpy makes the access safe on any alignment, but it keeps the wire's byte order, so a little-endian CPU sees 0x3412. The be16 and be32 helpers build each field from its bytes in the order the wire format defines, which is correct on any CPU and any alignment and compiles to plain byte loads and shifts. Undefined behaviour (UB, a case where the C standard places no requirements on the program, so the compiler may assume it never happens) is the reason not to simply cast rx + 1 to an unpacked struct pointer: a misaligned pointer of that type is UB even on a core that tolerates the load.
You need to design string key normalization and hashing for a backend user directory to ensure logically identical user-supplied strings compare equal. Discuss Unicode normalization (NFC vs NFD), case folding, trimming and canonicalization steps, and how to produce stable hashed keys for DB indexes. Also discuss security and collision considerations.
Sample Answer
Direct answer
Canonicalize before you hash: run every incoming string through the same fixed pipeline (Unicode normalization, then case folding, then trimming) so that logically-identical strings always produce byte-identical input to the hash function, then hash the canonical form with a cryptographic-strength function (SHA-256 or similar) to get a fixed-size, stable index key. The canonicalization step is what makes "logically identical" strings compare equal; the hash choice is what makes accidental collisions between genuinely different strings astronomically unlikely rather than a realistic operational risk.
Structured elaboration
Why canonicalization has to come first, in a fixed order. Two user-supplied strings can be logically the same identity (the same email address, the same username) while differing in raw bytes: different capitalization, incidental leading or trailing whitespace, or different Unicode encodings of the same visible character. If canonicalization doesn't happen before hashing, two representations of the same logical value hash to two different keys and the DB index silently treats one user as two.
NFC versus NFD, and why NFC is the more common default. Unicode allows the same visible character to be represented as one composed codepoint (NFC: Normalization Form C, "Canonical Composition") or as a base character plus separate combining marks (NFD: Normalization Form D, "Canonical Decomposition"). Either form works correctly as a canonicalization target as long as it is applied consistently to every string before comparison or hashing, since the goal is only that all logically-equal strings land on the same representation. NFC is chosen here specifically because it is the more compact form and the one most web platforms, databases, and the Unicode Consortium's own guidance treat as the default for stored text, so choosing it avoids surprising a downstream system that also normalizes to NFC independently.
Case folding versus lowercasing. Case folding is used instead of a plain lowercase conversion because case folding is defined specifically for caseless matching and correctly handles cases a naive lowercase does not (the German sharp s, ß, case-folds to ss, matching how it is treated as equivalent to ss in caseless comparisons, whereas simple lowercasing leaves it unchanged).
Trimming. Leading and trailing whitespace differences (a stray space from a copy-pasted email address, for instance) should not create a distinct identity. Trimming needs to operate on Unicode-aware whitespace, not just the ASCII space character, since a user could paste text containing a non-breaking space or other Unicode space character that a naive ASCII-only trim would miss.
Producing a stable hashed key. Once the string is canonicalized, hashing it with a cryptographic hash function (rather than a fast, non-cryptographic hash meant for in-memory hash tables) is the right choice specifically because the resulting key is going into a durable DB index: a cryptographic hash's output is effectively uniformly distributed over its output space, so two different canonical strings colliding to the same key by accident has probability governed by the birthday bound on the hash's output size, not by any structural weakness an attacker could exploit to deliberately engineer a collision. A domain-specific fixed prefix (not a secret, unlike a password salt) can be mixed in before hashing purely to avoid accidental key collisions with some other unrelated use of the same hash function elsewhere in the system; it does not need rotation.
Security and collision considerations, scoped to this problem. The relevant question here is "how likely is it that two different logical identities accidentally hash to the same key," which for a well-distributed cryptographic hash with a 256-bit output is not a practical concern at any realistic directory size. What this design does NOT need to get into is how the hash function itself resolves collisions internally, bucket layout, or table resizing: those are hash-table implementation concerns, not string-key-design concerns, and they belong to a different discussion about the data structure storing these keys rather than the process of producing the keys themselves.
Worked example
Full runnable code with pinned test cases:
import unicodedata
import hashlib
def canonical_key(raw):
"""Canonicalize a user-supplied string into a stable DB lookup key.
Steps: NFC normalize -> casefold -> strip leading/trailing whitespace.
Order matters: normalize before casefolding so composed/decomposed
variants of the same character land on the same casefolded form."""
return unicodedata.normalize("NFC", raw).casefold().strip()
def hashed_key(raw, salt=b"interviewstack-demo-salt-v1"):
"""Stable hashed index key: canonicalize first, then hash with a
cryptographic-strength function (SHA-256) so accidental collisions are
only as likely as the function's own security bound, not an accident of
a weak custom hash. The salt is a fixed domain-separation prefix (not a
secret, and not rotated like a password salt would be)."""
canon = canonical_key(raw)
return hashlib.sha256(salt + canon.encode("utf-8")).hexdigest()
if __name__ == "__main__":
variants = [
"Alice.Smith@Example.com",
" alice.smith@example.com ",
"alice.smith@example.com",
]
for v in variants:
print(f"{v!r:35} -> canonical={canonical_key(v)!r:28} hashed_key={hashed_key(v)}")
# Built from explicit codepoint escapes so composed/decomposed forms are
# unambiguous regardless of source-file encoding.
precomposed_name = "Jos" + "é" # J o s e-acute (1 codepoint)
decomposed_name = "Jos" + "e" + "́" # J o s e + combining acute (2 codepoints)
print()
print("precomposed raw:", repr(precomposed_name), "len", len(precomposed_name))
print("decomposed raw: ", repr(decomposed_name), "len", len(decomposed_name))
print("precomposed canonical:", canonical_key(precomposed_name))
print("decomposed canonical: ", canonical_key(decomposed_name))
print("canonical keys equal? ", canonical_key(precomposed_name) == canonical_key(decomposed_name))
print("hashed keys equal? ", hashed_key(precomposed_name) == hashed_key(decomposed_name))
Output (actual run):
'Alice.Smith@Example.com' -> canonical='alice.smith@example.com' hashed_key=681f25f0265fa3187a5bd4c9e8c2d2af8bcf8126195ddf8ad7e7fb4c49435c1a
' alice.smith@example.com ' -> canonical='alice.smith@example.com' hashed_key=681f25f0265fa3187a5bd4c9e8c2d2af8bcf8126195ddf8ad7e7fb4c49435c1a
'alice.smith@example.com' -> canonical='alice.smith@example.com' hashed_key=681f25f0265fa3187a5bd4c9e8c2d2af8bcf8126195ddf8ad7e7fb4c49435c1a
precomposed raw: 'José' len 4
decomposed raw: 'José' len 5
precomposed canonical: josé
decomposed canonical: josé
canonical keys equal? True
hashed keys equal? True
All three raw variants of the same email address (different capitalization, extra whitespace) collapse to the identical canonical form and the identical SHA-256 hashed key. The precomposed and decomposed forms of the same name also collapse to the same canonical key and the same hash, which is exactly the property the normalization step exists to guarantee.
Trade-offs and pitfalls
- Hashing the raw string directly (skipping canonicalization) is the most common wrong turn: it looks correct on ASCII-only test data and then silently creates duplicate "different" identities in production the moment a real user's name or email contains an accented character, mixed case, or incidental whitespace.
- Choosing a fast, non-cryptographic hash (something optimized for in-memory hash table buckets) instead of a cryptographic one is a reasonable choice for a purely in-memory structure, but is the wrong choice for a durable, security-relevant index key, since non-cryptographic hashes are explicitly designed to be fast and are not designed to resist deliberately engineered collisions.
- Normalizing to NFD instead of NFC (or mixing the two inconsistently across different code paths) is a real, subtle bug source: consistency of the chosen form matters far more than which specific form is chosen, and inconsistency between, say, a signup path and a login path is exactly how two supposedly-canonical keys for the same identity end up different.
- Case folding can change the number of characters in a string, so any length-based validation (a minimum-username-length check, for example) needs to run on the canonical form, not the raw input, if the check is meant to reflect what will actually be stored and compared.
- Locale-dependent case folding (Turkish dotless/dotted i is the classic example) is a real further wrinkle beyond the scope of a general-purpose canonicalization scheme; if the directory needs to support such locales correctly, a locale-aware folding step would need to be chosen deliberately rather than assumed, which is worth naming as a known limitation rather than silently ignoring.
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