Real-Time Systems, RTOS Scheduling & WCET Questions
Building embedded and control software with timing guarantees: hard, firm and soft real-time and why fast is not the same as predictable, RTOS task scheduling (fixed-priority, rate-monotonic, earliest-deadline-first, cooperative vs preemptive, lightweight cooperative schedulers on small microcontrollers), priorities and preemption, RTOS task states and lifecycle, bounding priority-inversion blocking with inheritance and ceiling protocols in RTOS designs, schedulability and response-time analysis including blocking terms, worst-case execution time estimation (static, measurement-based, cache, pipeline and DMA bus effects), RTOS internals such as the tick, tickless idle, software timers and context switching, serving aperiodic and best-effort work beside hard periodic tasks, multicore and mixed-criticality scheduling, bounded-wait lock APIs, choosing an RTOS, and diagnosing deadline misses and jitter from timing traces. Excludes interrupt mechanism and ISR design, general-purpose OS (desktop and server) CPU scheduling, priority inversion as a generic locking hazard, general synchronization primitives and lock-free data structures, firmware architecture and power optimization, which are covered elsewhere.
A single-core control application with 1 ms hard tasks and best-effort tasks must move to a four-core SoC. How do you assign work to cores, what new timing risks appear, and how do you check the 1 ms deadlines still hold?
Sample Answer
Recommendation. Do not spread everything across four cores for balance. Pin the hard 1 ms and 5 ms tasks to two cores with the load split evenly, put diagnostics and logging on a third core, keep the fourth for communications and the other device interrupts that no hard task depends on, and prove the deadlines with response-time analysis per core using execution times measured with the other cores busy. The loop that matters most is the measurement under load, because that is what the single-core design never had to face.
Terms. A core runs one task at a time; response-time analysis (RTA) computes a task's worst-case completion time R from its execution time, blocking time and the interference of higher-priority tasks on the same core, and the task is safe if R is at most its deadline. WCET is a task's worst-case execution time. Partitioned means each task is pinned to one core. Rate-monotonic order gives the shorter period the higher priority. A cache is a small fast memory in front of main memory; the cores on an SoC (system on chip) usually share the last-level cache (the biggest cache, closest to main memory), the bus and the memory controller.
Step 1: assign work to cores, with numbers. The example below is an automotive-controller shape. Two 1 ms control loops (300 us and 250 us of CPU each), two 5 ms sensing loops (1100 us and 900 us), a diagnostics task (3 ms every 20 ms) and a logger (8 ms every 50 ms). The two control loops use a calibration table that diagnostics also reads, and the control loops, which the partition below puts on different cores, read it while diagnostics runs on a third. Assume each critical section on the table lasts at most 20 us, that a task waiting for the lock spins (busy-waits) in first-come order, and that the holder cannot be preempted while it holds the lock (the multiprocessor stack resource policy, MSRP, is the standard form of this scheme). A request then queues behind at most one holder per other core that uses the lock, so a control loop's worst remote wait is 2 x 20 = 40 us (both figures assumed). The program charges this as spin time B on the waiting task itself: the spinning is time the core spends on that task and cannot use for anything else, so it lengthens the task's own response time and also counts in the interference that task causes to every lower-priority task on its core (a blocking term added only to the task's own response would miss that second effect). On a single core nobody spins on a remote core, so the single-core lines leave the term out. If a holder could be preempted while it held the lock, the wait would grow by that preemption, which is why the section is made non-preemptible. The program checks the single-core situation, assigns the hard tasks to two cores in decreasing-utilisation order choosing the least loaded core that still passes (worst-fit: the task goes where there is the most room left, so every hard core keeps margin), and reports each core's response times and the largest uniform execution-time inflation it survives (the 40 us spin time is held fixed while the execution times are scaled). The figures are illustrative inputs; the method is what carries over.
from math import ceil
# (name, execution in microseconds, period = deadline in microseconds, hard?, remote blocking in microseconds)
TASKS = [
("ctrl_a", 300, 1000, True, 40), # 1 ms loops; 40 us = remote wait on the table: 2 other cores x 20 us
("ctrl_b", 250, 1000, True, 40), # (spin lock, non-preemptible holders; assumed figures)
("sense_c", 1100, 5000, True, 0), # 5 ms loops
("sense_d", 900, 5000, True, 0),
("diag", 3000, 20000, False, 0), # best effort
("logger", 8000, 50000, False, 0),
]
HARD_CORES = [0, 1] # cores 2 and 3 take best-effort work and spare capacity
def response_times(core_tasks, inflate=1.0):
"""core_tasks in rate-monotonic order. Returns list of (name, R, T).
A task's cost on its core is C x inflate + B: the spin time B is time the core spends on that task,
so it counts against the task itself AND against every lower-priority task on the same core."""
out = []
for i, (n, c, t, _, b) in enumerate(core_tasks):
c_i = c * inflate + b
r = c_i
while r <= t:
nxt = c_i + sum(ceil(r / tj) * (cj * inflate + bj) for _, cj, tj, _, bj in core_tasks[:i])
if abs(nxt - r) < 1e-9:
break
r = nxt
out.append((n, r, t))
return out
def fits(core_tasks, inflate=1.0):
return all(r <= t for _, r, t in response_times(core_tasks, inflate))
def partition():
cores = {k: [] for k in HARD_CORES}
hard = sorted([x for x in TASKS if x[3]], key=lambda x: -x[1] / x[2]) # decreasing utilisation order
for task in hard:
# worst-fit: among the cores where the task still passes, take the least loaded one,
# so every hard core keeps as much margin as possible
candidates = [k for k in HARD_CORES if fits(sorted(cores[k] + [task], key=lambda x: x[2]))]
if not candidates:
raise SystemExit("no core fits " + task[0])
k = min(candidates, key=lambda c: sum(x[1] / x[2] for x in cores[c]))
cores[k] = sorted(cores[k] + [task], key=lambda x: x[2])
return cores
def breakeven(core_tasks):
"""Raise every execution time by 1% at a time until some deadline would be missed."""
pct = 100
while fits(core_tasks, (pct + 1) / 100):
pct += 1
return pct / 100
def show_iterations(core_tasks, name, inflate):
"""Print R = (C + B) + sum(ceil(R / Tj) x (Cj + Bj)) step by step for one task (C, Cj scaled by inflate)."""
i = [n for n, *_ in core_tasks].index(name)
_, c, t, _, b = core_tasks[i]
c_i = c * inflate
hp = core_tasks[:i]
c_i += b
r = c_i
steps = [r]
while r <= t:
nxt = c_i + sum(ceil(r / tj) * (cj * inflate + bj) for _, cj, tj, _, bj in hp)
steps.append(nxt)
if abs(nxt - r) < 1e-6:
break
r = nxt
print(f" {name} at x{inflate}: R = " + " -> ".join(f"{x:.0f}" for x in steps) + f" (deadline {t})")
def no_spin(ts):
"""On one core nobody spins on a remote core: drop the spin term."""
return [(n, c, t, h, 0) for n, c, t, h, _ in ts]
u = sum(c / t for _, c, t, _, _ in TASKS)
print(f"Today on one core: utilisation {u:.3f} (all six tasks)")
single = no_spin(sorted(TASKS, key=lambda x: x[2]))
print(" response times on one core:", [(n, round(r), t) for n, r, t in response_times(single)])
hard_only = no_spin(sorted([x for x in TASKS if x[3]], key=lambda x: x[2]))
print(" hard tasks alone on one core:", [(n, round(r), t) for n, r, t in response_times(hard_only)],
f"tolerated inflation x{breakeven(hard_only):.2f}")
print()
cores = partition()
for k, ts in cores.items():
print(f"core {k}: " + ", ".join(f"{n}" for n, *_ in ts) + f" utilisation {sum(c / t for _, c, t, _, _ in ts):.3f}")
for n, r, t in response_times(ts):
print(f" {n:8s} R = {r:7.1f} us deadline {t} us slack {t - r:7.1f} us")
print(f" largest execution-time inflation tolerated: x{breakeven(ts):.2f}")
if k == 0:
# R = C + ceil(R / T_ctrl_a) x (C_ctrl_a + B_ctrl_a), iterated from R = C
show_iterations(ts, "sense_d", 1.0)
show_iterations(ts, "sense_d", 2.00)
show_iterations(ts, "sense_d", 2.01)
print("core 2: diag, logger (best effort) utilisation %.3f" % (3000 / 20000 + 8000 / 50000))
print("core 3: spare (interrupts, communications)")
Run in a python:3.12-slim container (Python 3.12.15); it is deterministic and prints:
Today on one core: utilisation 1.260 (all six tasks)
response times on one core: [('ctrl_a', 300, 1000), ('ctrl_b', 550, 1000), ('sense_c', 2750, 5000), ('sense_d', 4750, 5000), ('diag', 22000, 20000), ('logger', 58650, 50000)]
hard tasks alone on one core: [('ctrl_a', 300, 1000), ('ctrl_b', 550, 1000), ('sense_c', 2750, 5000), ('sense_d', 4750, 5000)] tolerated inflation x1.05
core 0: ctrl_a, sense_d utilisation 0.480
ctrl_a R = 340.0 us deadline 1000 us slack 660.0 us
sense_d R = 1580.0 us deadline 5000 us slack 3420.0 us
largest execution-time inflation tolerated: x2.00
sense_d at x1.0: R = 900 -> 1240 -> 1580 -> 1580 (deadline 5000)
sense_d at x2.0: R = 1800 -> 3080 -> 4360 -> 5000 -> 5000 (deadline 5000)
sense_d at x2.01: R = 1809 -> 3095 -> 4381 -> 5024 (deadline 5000)
core 1: ctrl_b, sense_c utilisation 0.470
ctrl_b R = 290.0 us deadline 1000 us slack 710.0 us
sense_c R = 1680.0 us deadline 5000 us slack 3320.0 us
largest execution-time inflation tolerated: x2.04
core 2: diag, logger (best effort) utilisation 0.310
core 3: spare (interrupts, communications)
Reading it.
- The arithmetic behind a number such as core 0's
sense_dR = 1580 us: sense_d needs C = 900 us and the only higher-priority task on that core is ctrl_a, which costs the core 300 us of work plus 40 us of spinning every 1000 us, so R = 900 + ceil(R / 1000) x (300 + 40), iterated from R = 900: 900 -> 1240 -> 1580 -> 1580. The program prints this line. - The inflation figure is found by multiplying every execution time by a factor f and raising f in 1% steps until some deadline is missed. At f = 2.00 sense_d iterates 1800 -> 3080 -> 4360 -> 5000 -> 5000 us, exactly on its 5000 us deadline (R equal to the deadline still meets it); at f = 2.01 it reaches 5024 us, past the deadline, so the largest tolerated factor is 2.00 (both iterations are printed).
- The single-core system is overloaded: utilisation 1.26, and the diagnostics task already has R = 22000 us against its 20000 us period. That is the reason for the move.
- The hard tasks alone would fit on one core (R = 4750 us for the slowest against 5000 us), but they tolerate only 5% execution-time inflation. Any interference from other cores would break that, so the program spreads them: each hard core ends at 47-48% utilisation (before spin time) and tolerates about 2 times inflation (x2.00 and x2.04) before any deadline is missed. Spare margin on the hard cores is what buys tolerance to the new interference.
- Best-effort work stays off the hard cores (core 2 at 31%), so a logging burst can never queue ahead of a control loop.
Step 2: the new timing risks.
- Shared memory and cache interference. Cores share the last cache level, the bus and the memory controller, so a task's execution time now depends on what the other cores do. This is the risk the single-core design did not have.
- A lock shared across cores. The calibration table is touched by hard tasks on two cores and diagnostics on a third, so a uniprocessor priority ceiling no longer bounds the wait: a request can queue behind one holder on each other core. The 40 us in the program is the extra time the hard task is assumed to spend waiting per access (two other cores, 20 us each), and it is time that core cannot give to its other tasks. The cleanest fix is to remove the sharing: publish the table to the control cores by copy at a safe point: keep two copies, let the writer fill the inactive one and then bump a version number with a release store; the reader reads the version (acquire) before and after copying and, if it changed, discards that copy and keeps its last good one, so it never acts on a half-written table.
- Interrupts: each device interrupt is handled on one core and takes time from that core's tasks. Route the control-loop timer and sensor interrupts to the core running their tasks and put that handler time in the core's analysis; the other interrupts, communications among them, can stay on the fourth core.
- Cross-core wake-ups cost an inter-processor interrupt (a signal one core sends to another to make it run its scheduler), and data written on one core and read on another moves through the coherency hardware (the logic that keeps each core's cached copies of the same memory consistent): put both in the budget of any hard task that depends on them.
- A 1 ms loop on one core and a 1 ms loop on another are only aligned if they run from the same time base.
Step 3: check that the 1 ms deadlines hold.
- Run RTA per core with the measured execution times, plus the blocking and interrupt terms. The tolerated-inflation lines above say how wrong the measured times may be before something breaks: here, about a factor of 2.
- Measure under load. Run each hard task alone and again while the other cores run a memory-heavy stress load (and while diagnostics and the logger run their worst case), and take the ratio of the worst observed times as the interference factor f. Accept the design only if f stays below the tolerated inflation with margin chosen in advance, for example at most 1.5 against the 2.00 above. Measurement-based times are lower bounds on the true worst case, so the margin is needed.
- Trace the whole system on the target: a GPIO toggle at the start and end of each hard task seen on a logic analyser, a deadline-miss counter in every hard task, and an overnight soak with the stress load on.
- Formal checking is possible. UPPAAL is a tool for modelling and verifying real-time systems as networks of timed automata (automata with clocks). Abstract each task as an automaton and the lock or bus arbitration as another, then ask the tool whether any deadline-miss state is reachable. For example, one automaton per task with a clock that measures time since release and a
Missedlocation entered if the clock passes the deadline before the task completes, a lock automaton with free and held states, and the queryA[] not ctrl_a.Missed(in every reachable state, ctrl_a has not missed). Many teams rely on response-time analysis plus measurement and use a model checker only for the lock or arbitration protocol where the stakes justify it. The limit is state-space explosion: the number of states grows multiplicatively with each task, core and clock, so the model must abstract away data and caches and cover the protocol and scheduling logic, not the whole system. It complements the measurement; it does not replace it.
What would change the plan. If the hard set cannot be split with enough margin, look at the memory interference first (cache partitioning where the hardware supports it, or moving the memory-heavy best-effort work to run only when hard tasks are idle) before adding cores or changing priorities.
Walk through the life of an RTOS task from creation to being scheduled. What states can it be in, what does the kernel keep for each task, and what moves it between states?
Sample Answer
Setting. On a microcontroller without an MMU (memory management unit) and with tens of kilobytes of RAM, a task is a function that runs forever in its own loop with its own stack. The RTOS (real-time operating system) kernel gives each such function the illusion of owning the CPU. Two terms used throughout: an ISR (interrupt service routine) is a handler function that the hardware runs when an interrupt fires, and the tick is the kernel's periodic timer interrupt (for example every 1 ms) that advances its clock, expires delays and gives the kernel a regular chance to reschedule. The examples below use FreeRTOS, whose source documents the details quoted.
1. Creation. A call such as xTaskCreate takes the task function, a name, a stack size and a priority. The kernel allocates a TCB (task control block, the kernel's record of one task) and a stack from the heap, or uses buffers you supply for static allocation (no heap). It then fills the new stack so it looks as if the task had already been interrupted: the initial register values, with the program counter pointing at the task function. Starting a task and resuming a preempted one then use the same code path. The new task is put in the Ready state, in the ready list for its priority.
2. What the kernel keeps per task (the TCB). In FreeRTOS's tasks.c the TCB's first member is the saved top-of-stack pointer, documented as pointing at the last item placed on the task's stack. The rest includes the priority (0 is the lowest), the start of the stack (for overflow checks), a name used for debugging, a state list item, and an event list item. The state list item is what puts the task on exactly one kernel list at a time, and which list it sits on denotes its state: in tasks.c these are the per-priority array of ready lists (pxReadyTasksLists), the delayed lists (pxDelayedTaskList and an overflow twin used when the tick counter wraps), xSuspendedTaskList and xTasksWaitingTermination. The event list item lets the task also sit on the wait list of a queue or semaphore, so a blocked task with a timeout is on a delayed list (through its state item) and on the queue's wait list (through its event item) at once. One wrinkle in "the list denotes the state": a task that blocks with no timeout (portMAX_DELAY, when INCLUDE_vTaskSuspend is 1) has its state item put on xSuspendedTaskList so that no timer event can wake it, yet it is still reported as Blocked because it also sits on an event list; a genuinely Suspended task is on no event list.
3. States. FreeRTOS documents these (the eTaskState values are Running, Ready, Blocked, Suspended and Deleted):
| State | Meaning | What the kernel does with it |
|---|---|---|
| Running | Executing on the CPU (only one per core) | Scheduler keeps it until a switch |
| Ready | Able to run, waiting for the CPU | In the ready list for its priority |
| Blocked | Waiting for a time delay or an event (queue data, semaphore, notification), optionally with a timeout | In a delayed list or an event wait list |
| Suspended | Held out by an explicit suspend call, not scheduled | In the suspended list until resumed |
| Deleted | Deleted, TCB and stack not yet freed (done later by the idle task) | In the waiting-termination list |
4. What moves a task between states
- Ready to Running: the scheduler picks the highest-priority Ready task. With equal priorities, FreeRTOS time-slices among them each tick when preemption and
configUSE_TIME_SLICINGare both enabled (time slicing is on by default; time slicing means: the tasks take turns, each running for one tick period before the next one in the same priority's ready list gets the CPU). - Running to Ready: preemption (a higher-priority task became Ready, or the tick triggers a time slice) or the task yields.
- Running to Blocked: the task calls a blocking API:
vTaskDelay, waiting on a queue, semaphore or mutex, or a notification. - Blocked to Ready: the event arrives (an ISR gives a semaphore, another task sends to the queue) or the timeout/delay expires on a tick. If the woken task has higher priority than the running one, a switch follows.
- Any state to Suspended and back:
vTaskSuspendandvTaskResume. - Running to Deleted: the task deletes itself; the idle task frees its memory later, which is why the idle task must get CPU time.
5. How it ties to scheduling. At each decision point (tick, wake-up, yield, block) the kernel chooses the highest-priority task in the Ready lists and switches to it. The tick is a periodic timer interrupt that advances the kernel's clock, expires delays and triggers time slicing. A task that never blocks and has the top priority starves everything below it (starvation: the lower-priority tasks stay Ready forever and never get the CPU), so tasks should block on events rather than poll.
6. Concrete example. A sensor task blocks on a queue. A UART interrupt receives a byte and sends it to the queue from the ISR. That moves the sensor task from Blocked to Ready; if it outranks the interrupted task, a switch follows on interrupt exit. In FreeRTOS the ISR uses the FromISR call (xQueueSendFromISR), which reports through its pxHigherPriorityTaskWoken argument that a higher-priority task was woken, and the ISR passes that flag to portYIELD_FROM_ISR to request the switch; without the request the woken task may wait for the next scheduling event. The task processes the byte, loops, and calls the queue receive again, which moves it back to Blocked and lets the lower-priority logging task run.
The lists the sensor task sits on during that example:
| Moment | Task state | List it is on |
|---|---|---|
| Created, before the scheduler picks it | Ready | pxReadyTasksLists[its priority] |
| Running, calls queue receive on an empty queue | Blocked | the queue's receive wait list (event item), and a delayed list too if it gave a finite timeout (state item); with an infinite wait the state item is on the suspended list instead |
| UART ISR sends a byte | Ready | moved back to pxReadyTasksLists[its priority] |
| Scheduler picks it | Running | still the ready list entry for its priority, plus the kernel's current-task pointer |
Your team has to pick an RTOS for a new product and the shortlist is FreeRTOS, Zephyr, VxWorks and QNX. What would you compare, and which would you lean towards for a small battery-powered sensor versus a certified safety product?
Sample Answer
What to compare
An RTOS (real-time operating system) is a small kernel whose scheduler guarantees that the highest-priority ready task runs, so that timing can be analysed. Compare the four on the axes that actually decide a product, in this order:
- Certification evidence you can buy or must produce. If a regulator or customer needs a safety certificate (IEC 61508 for industrial, ISO 26262 for road vehicles, DO-178C for airborne software), the question is whether the vendor already holds certificates and a pack of design artefacts for the exact version you will ship. Producing that evidence yourself for an uncertified kernel is a large body of work (requirements, design artefacts, verification), which is why the licence fee is rarely the deciding cost; price both routes for your own standard and team before relying on that.
- Footprint and power. Flash and RAM the kernel needs, and whether the scheduler can stop its periodic tick interrupt while idle so the MCU can sleep.
- Hardware and process model. Does it run on your core, is there memory-protection (MPU, a lightweight region-based protector, or MMU, full address translation) support, and can an application fault be contained?
- Licence and cost. Open source with no fee versus a commercial licence.
- Ecosystem and talent. Drivers, networking and cloud stacks, vendor SDK support, and how many engineers you can hire who know it.
The four, as documented
| FreeRTOS | Zephyr | VxWorks | QNX | |
|---|---|---|---|---|
| Licence | MIT for the kernel | Apache 2.0 for most code | Commercial (Wind River) | Commercial |
| Shape | Minimal scheduler, queues, timers; you assemble the rest | RTOS plus build system, device tree, drivers, networking, Bluetooth, one project | Full-featured RTOS, optional memory-protected processes | Microkernel RTOS (drivers and services run as user-space processes) |
| Small-MCU fit | Very good | Documented to run on under 8 KB flash and 5 KB RAM with the bare minimum of subsystems; boards in the supported list range from a few KB of RAM to hundreds of KB, so check the specific board | Typically used on larger processors | Typically used on larger processors |
| Safety evidence | A separate product, SAFERTOS, is pre-certified to IEC 61508 SIL 3 and ISO 26262 ASIL D; it is a different code base, not the FreeRTOS kernel | IEC 61508 certification is a stated goal of the project's safety committee, for a limited source scope; not a certificate to rely on today | VxWorks Cert Edition: certification evidence for DO-178C DAL A, plus IEC 61508 SIL 3 and ISO 26262 ASIL D with TUV SUD certificates | QNX OS for Safety: ISO 26262 ASIL D and IEC 61508 SIL 3, certified by TUV Rheinland |
(SIL is the Safety Integrity Level, ASIL the automotive equivalent, DAL the avionics Design Assurance Level; the letters or numbers rise with the consequence of failure.) Sources: the FreeRTOS kernel LICENSE file on GitHub, the Zephyr introduction and safety overview pages, Wind River's VxWorks Cert Edition overview, QNX's QOS 8.0 announcement, and the SAFERTOS pages of FreeRTOS.org and WITTENSTEIN. Re-check each against the version you would ship, because certificates are issued per version and configuration.
Small battery-powered sensor: lean FreeRTOS, with Zephyr as the alternative
The constraints are flash, RAM and microamps, not certification. Pick FreeRTOS when the MCU is small and you want a kernel you can read end to end, with the vendor's SDK already providing FreeRTOS ports and drivers (most MCU vendors ship one). It documents tickless idle (configUSE_TICKLESS_IDLE): the periodic tick interrupt is stopped while no task can run, so the chip stays asleep until an interrupt or the next timeout, which matters because waking every millisecond can cost more energy than the sleep saves. Pick Zephyr instead when the sensor needs Bluetooth Low Energy, a CoAP/LwM2M or MQTT stack, many sensor drivers or a board family you want portability across, since its device tree and integrated subsystems replace weeks of glue code. The cost is a steeper learning curve and a larger minimum build. VxWorks and QNX are typically used on larger processors and carry a licence cost a sensor does not need.
What would flip it: a vendor SDK that only supports one of the two, a power budget measured on your actual board (build the idle loop in both and measure sleep current), or a team already fluent in one.
Certified safety product: lean VxWorks Cert Edition or QNX OS for Safety, or SAFERTOS on a small MCU
Here the question is whose evidence you inherit. Choose by the standard and the processor:
- Safety MCU running a small control loop (a drive, a valve controller): SAFERTOS, because the FreeRTOS-style API keeps porting cost low while the certified code base and its Design Assurance Pack supply the artefacts. Plain FreeRTOS and Zephyr give you no certificate to inherit, so you would be certifying the kernel yourself.
- Avionics (DO-178C): VxWorks Cert Edition, which lists DAL A evidence.
- Automotive or industrial on an application-class processor with a memory-management unit: QNX OS for Safety or VxWorks Cert Edition. A microkernel keeps drivers outside the kernel, so a faulting driver can be restarted without taking down the safety-critical processes, and that isolation argument is something the certifier can follow.
Two cautions. A certified RTOS does not certify your product: you still owe the application's requirements, tests, and a worst-case timing analysis for your tasks on your hardware. And certificates cover a specific release, so do not choose on the product name, choose on which release and configuration your evidence covers.
The decision is therefore: sensor means FreeRTOS (or Zephyr when the connectivity stack is the real work), certified product means whichever vendor already holds the certificate for your standard and processor, and the licence fee is often small next to the effort of certifying a kernel yourself, which you should estimate for your own case.
Your RTOS sampling task misses its deadline about once an hour and its jitter is visible, yet CPU utilisation is under 40%. How do you find the cause?
Sample Answer
Start from the arithmetic: 40% utilisation does not protect a deadline. Utilisation is an average over time. A deadline is missed by one bad stretch, and a rare stretch of 5 ms during which the sampling task cannot run is invisible in an average. The program below takes a plausible set (sampler 5 ms period and deadline with 1 ms of work, control 10 ms with 1.5 ms, logger 100 ms with 3 ms), computes response times, and then adds one rare delay inside the sampler's path as a blocking term B. The response time R of a task is the longest time from its release to its completion; response-time analysis finds it as the smallest R satisfying R = C + B + the sum over higher-priority tasks j of ceil(R / T_j) * C_j, where C is the task's own work, T_j and C_j are a higher-priority task's period and work, and B (blocking) is the longest time a lower-priority task or a disabled-interrupt stretch can keep this task from running. The task meets its deadline if R is at most the deadline. The delay durations are illustrative. Run in a python:3.12-slim container (CPython 3.12), it prints:
from math import ceil
# (name, period = deadline, C) in microseconds, rate-monotonic priorities (shortest period first)
tasks = [("sampler", 5_000, 1_000), ("control", 10_000, 1_500), ("logger", 100_000, 3_000)]
print(f"utilisation = {sum(C / T for _, T, C in tasks):.1%}")
def response(i, blocking=0):
_, T, C = tasks[i]
R = C + blocking
while True:
nxt = C + blocking + sum(ceil(R / Tj) * Cj for _, Tj, Cj in tasks[:i])
if nxt == R or nxt > T:
return nxt
R = nxt
print("\nSlack per task with no blocking (deadline - response time):")
for i, (n, T, C) in enumerate(tasks):
R = response(i)
print(f" {n:8} R={R:6} us deadline={T:6} us slack={T - R:6} us")
print("\nOne rare delay inside the sampler's path, counted as blocking B (illustrative durations):")
events = [("mutex held across a 200 us SPI burst", 200),
("critical section around a log ring-buffer swap", 900),
("mutex held across a 6 ms flash page erase", 6_000)]
for label, b in events:
R = response(0, b)
print(f" B={b:5} us R_sampler={R:5} us {'ok' if R <= 5_000 else 'MISSES'} ({label})")
utilisation = 38.0%
Slack per task with no blocking (deadline - response time):
sampler R= 1000 us deadline= 5000 us slack= 4000 us
control R= 2500 us deadline= 10000 us slack= 7500 us
logger R= 6500 us deadline=100000 us slack= 93500 us
One rare delay inside the sampler's path, counted as blocking B (illustrative durations):
B= 200 us R_sampler= 1200 us ok (mutex held across a 200 us SPI burst)
B= 900 us R_sampler= 1900 us ok (critical section around a log ring-buffer swap)
B= 6000 us R_sampler= 7000 us MISSES (mutex held across a 6 ms flash page erase)
At 38% utilisation the sampler has 4 ms of slack. Being the highest-priority task it has no higher-priority interference, so R = C + B: with B = 200 us, R = 1,000 + 200 = 1,200 us; with B = 6,000 us, R = 1,000 + 6,000 = 7,000 us, over the 5,000 us deadline. Any single stretch longer than 4 ms in which it cannot run (a mutex held across a 6 ms flash erase, an interrupts-off region, a long burst of interrupt handlers) causes a miss on the occasions when it happens, which fits "about once an hour". The ordered checks below find which stretch it is.
1. Make the miss observable and cheap to catch. Detect lateness in code. FreeRTOS's xTaskDelayUntil returns pdFALSE when the next wake time is not in the future (it has already been reached or passed, so the call did not block), which is a direct flag that the previous job overran its period; on a miss, count it, raise a spare GPIO pin and freeze the trace buffer from step 2. The first measurement is a GPIO toggle at the start and end of each sampling job, watched on a logic analyser (it costs one store and no RTOS support). Set the analyser to trigger on the miss pin with a pre-trigger buffer (it records continuously and keeps the samples from before the trigger as well as after it), so the hourly event is captured when it happens instead of being watched for.
In the code below, extern declares a function that is defined in another file, and volatile tells the compiler that missed_count can be read or changed outside the normal flow of the code (for example by a debugger), so every access really reaches memory instead of being optimised away.
#include "FreeRTOS.h"
#include "task.h"
extern void MISS_PIN_HIGH(void); /* board code: drives a GPIO the logic analyser triggers on */
extern void trace_freeze(void); /* application code: stops writing the context-switch ring buffer */
volatile unsigned long missed_count;
void vSamplerTask(void *pvParameters)
{
(void)pvParameters;
TickType_t last_wake = xTaskGetTickCount();
for (;;) {
BaseType_t on_time = xTaskDelayUntil(&last_wake, pdMS_TO_TICKS(5));
if (on_time == pdFALSE) { /* the 5 ms boundary was already reached or passed: this job starts late */
missed_count++;
MISS_PIN_HIGH();
trace_freeze();
}
/* ... sample, filter, publish ... */
}
}
Compiled only, not run: this file compiles cleanly with arm-none-eabi-gcc 14.2.1 (-mcpu=cortex-m3 -mthumb -O1 -Wall -Wextra -Werror) against the FreeRTOS kernel V11.1.0+ headers and the ARM_CM3 port, with INCLUDE_xTaskDelayUntil set to 1 in FreeRTOSConfig.h (its default is 0, and the function is absent without it); the two extern functions are board and application code.
2. Record what the CPU did in the window before the miss. Hook the scheduler: FreeRTOS provides the traceTASK_SWITCHED_IN and traceTASK_SWITCHED_OUT macros (hooks you define, which the kernel runs each time a task starts or stops running), which can write a timestamp (the DWT cycle counter on Cortex-M, where present) and the task number into a RAM ring buffer. Freeze it on the first miss and read it with the debugger. A run-time statistics table (switched on by the configGENERATE_RUN_TIME_STATS setting, which makes the kernel total the CPU time each task has used) helps for averages but not for a single event, so use the ring buffer.
3. Classify the cause from the frozen trace. Each class has a different signature:
- A higher-priority task or interrupt ran long. The buffer shows another task (or a burst of interrupts) occupying the window. Check that task's worst-case time against its budget.
- The sampler was blocked on something. It switched out in the middle of a job and the mutex or queue owner is a lower-priority task. This is the blocking term B; look for a lock held across a slow operation (flash erase, SPI transfer,
printf). - Nothing ran: a gap with no context switches. Interrupts were disabled or the scheduler was suspended (a long
taskENTER_CRITICALsection, the FreeRTOS call that opens a critical section in which task switches and many interrupts are held off untiltaskEXIT_CRITICAL; or a driver that masks interrupts, a flash operation that stalls instruction fetch on a part that runs code from the flash being written). Find it by recording the longest interrupts-off time with the cycle counter, or by toggling a pin in the suspect sections. - The job itself ran slow. The sampler's own start-to-end pulse is long though nothing else ran: cache or wait-state effects, DMA contention for the bus, or a rare slow path.
- Timekeeping, not load. Using
vTaskDelaywherexTaskDelayUntilis meant accumulates the job's own run time into the period, and the tick quantises wake-ups (a task can only wake on a tick boundary, so its start time is rounded to a multiple of the tick period, for example 1 ms); both show as jitter. - Coincidence. "Once an hour" points to a periodic job whose period is one hour or a multiple of other periods: a log rotation, a time sync, a radio reconnect. Log timestamps of the misses and look for a matching event.
4. Fix the cause, then prove the fix. Shorten the stretch (erase flash a page at a time and yield between pages, release the mutex before the slow call, replace interrupt masking with a narrower section), or remove the sharing (give the slow job its own buffer), or raise the sampler above the offender after checking the effect on the others. Then re-run the response-time analysis with the measured worst B and confirm B stays under the 4 ms slack, and force the rare event on the bench (trigger the hourly job every second for several hours) and show the miss counter stays at zero. Leave the miss counter and pin in production builds, since they cost almost nothing.
Steps 1 to 3 are cheap and decisive. Stack overflow and heap corruption are worth a later look (uxTaskGetStackHighWaterMark, configCHECK_FOR_STACK_OVERFLOW), but a once-an-hour miss with a clean trace window points at timing, not corruption.
Describe what a context switch is in an RTOS running on an embedded CPU. List the CPU and memory state that must be saved and restored, discuss stack implications for tasks and ISRs, and estimate the performance costs and latency sources. Suggest two methods to reduce context-switch overhead on a resource-constrained system.
Sample Answer
What a context switch is. A context switch stops running one task and resumes another. The kernel saves everything that defines where the first task was (its context) and loads the second task's saved context so it continues exactly where it left off. On a microcontroller with no MMU (memory management unit, so no per-task address space), the context is only the CPU registers and the stack pointer; there are no page tables to swap.
State to save and restore
- General registers (on Cortex-M: R0-R12), the stack pointer, the link register (LR, the return address register), the program counter (PC) and the program status register (xPSR, with flags and the current exception number).
- Floating-point registers (the 32 single-precision registers and the FPSCR status register) if the task uses the FPU. This is a real cost on parts with one, so the saved context is bigger for FPU tasks.
- Per-task kernel data in the TCB (task control block, the kernel's record for a task): saved stack pointer, priority, state, and any per-task settings such as an MPU (memory protection unit) region table if the port switches one.
- Not saved: the task's own stack contents (they stay in RAM; only the pointer moves) and global data.
Cortex-M path, step by step
- An event (SysTick tick interrupt, or an ISR that released a semaphore, or a task calling a blocking API) makes the kernel decide a different task should run.
- The kernel requests the switch by setting the PendSV (pendable service call) exception pending, by writing the PENDSVSET bit in the interrupt control register. PendSV is an exception the software can trigger on demand, and it stays pending until the CPU is free to take it. FreeRTOS's Cortex-M3 port does exactly this, and it sets PendSV to the lowest interrupt priority, so the switch runs only after every other interrupt has finished. The reason for deferring: suppose the switch ran inside SysTick (the periodic timer exception that drives the kernel tick) or inside another ISR (interrupt service routine) that had interrupted a second, lower-priority ISR. The task stacks would be swapped while that second ISR was still unfinished underneath, and the CPU would be asked to return to a task with an interrupt handler still active, which Cortex-M does not allow. With PendSV at the lowest priority, all ISRs have returned by the time it runs, so the only thing underneath it is a task.
- On entering any exception the CPU hardware pushes eight words onto the active stack: R0-R3, R12, LR, PC and xPSR. Take a task running in thread mode on the process stack pointer (PSP) with PSP at 0x20000400 (an illustrative address) when PendSV is taken. Inside the handler PSP reads 0x200003E0, and LR holds the special EXC_RETURN value 0xFFFFFFFD (return to thread mode, use PSP). This assumes the stack pointer was 8-byte aligned and no floating-point context is active; with an unaligned stack the core can add a padding word, and an active FPU context makes the frame larger. Reading it: 0x400 - 0x3e0 = 0x20 = 32 bytes, and 32 bytes / 4 bytes per word = 8 words, the eight registers R0-R3, R12, LR, PC and xPSR. The stack grows downward, so pushing lowers the pointer. EXC_RETURN is not an address: when a handler returns by loading it into the PC (here,
bx lr), the CPU recognises the value, treats it as an instruction to leave exception handling, and its low bits select where to return and which stack (0xFFFFFFFD means thread mode, process stack, no floating-point frame). The frame size is a property of the architecture, so this shows the frame layout, not cost. - The PendSV handler, written in assembly, pushes the registers the hardware did not save (R4-R11) onto the task's own stack with
stmdb r0!, {r4-r11}(FreeRTOS's port does this), stores the resulting stack pointer in the outgoing task's TCB, calls the scheduler to pick the next task, loads that task's saved stack pointer, pops R4-R11 withldmia, writes it to PSP, and returns with the EXC_RETURN value so the hardware pops the other eight registers. The new task resumes mid-instruction as if nothing happened. Reading the two instructions:stmdb r0!, {r4-r11}means store multiple registers, decrement before: take the address in r0 (the task's stack pointer), step it down by 4 bytes per register in the list, write R4 to R11 into that block, and keep the lowered address in r0 (the!).ldmia r0!, {r4-r11}is the mirror: load multiple, increment after, reading the block back into R4-R11 and moving r0 up past it. So the incoming task's stack holds, from the lowest address up, R4-R11 (saved by software) and then R0-R3, R12, LR, PC, xPSR (saved by hardware). - The port briefly raises BASEPRI around the scheduler call so interrupts that use kernel APIs cannot change ready lists while the next task is being chosen. BASEPRI is a Cortex-M register that masks (blocks) every interrupt whose priority number is at or above the value written to it, leaving more urgent interrupts running; writing 0 turns the mask off.
Stack implications. On Cortex-M there are two stack pointers: tasks run on their own stacks through PSP (the process stack pointer), while exceptions run on MSP (the main stack pointer). Two consequences follow. First, each task stack must hold the task's deepest call chain plus one full saved context: the 32-byte hardware frame, which an interrupt that arrives while the task runs pushes on the interrupted stack (PSP), plus the 32 bytes of R4-R11 that the PendSV handler adds if that interrupt leads to a switch (more with FPU registers). The hardware frame is the first half of that saved context, so it is not counted twice. Second, the ISR's own locals and any nested interrupts' frames go on MSP, so MSP must be sized for the worst-case interrupt nesting depth and is shared by all interrupts. Overflow of a task stack silently corrupts its neighbour in RAM, so enable the kernel's stack-overflow check or place an MPU guard region (a small address range just below the stack that the memory protection unit marks as no-access, so an overflow causes an immediate fault instead of silent corruption).
Cost and latency sources
- Hardware exception entry and exit (stacking and unstacking the 8-word frame; more if the FPU context is stacked).
- Saving and restoring R4-R11 (and FPU registers): proportional to register count.
- The scheduler decision: constant for a bitmap of ready priorities, linear if the ready list is searched. Its cost is what matters for determinism.
- Critical sections: while the kernel masks interrupts, a switch or a higher-priority interrupt waits, which adds to latency.
- Memory and pipeline effects: flash wait states when fetching the handler, cache and branch predictor state on cores that have them, and the cache lines the next task needs, which are cold after a switch. These make the cost vary from switch to switch.
A way to size it without a cycle figure: one switch moves 8 words by hardware stacking and 8 more by stmdb, then the same 16 words back by unstacking and ldmia, so 32 word transfers plus the scheduler decision; each transfer costs at least a cycle and more with flash or RAM wait states, and an FPU task adds the floating-point registers on top. That count of 32 words is a lower-bound anchor for comparing designs, not a time. No fixed time is portable: it depends on core, clock, flash wait states, memory placement and FPU use. To bound it, toggle a GPIO at the start and end of the handler and capture it with a logic analyser (or read the cycle counter) over a long run, check the maximum, and add margin. For analysis, put the worst observed or computed switch cost into the response-time equation as extra execution time per switch.
Two ways to reduce overhead
- Switch less often. Lower the tick rate or use tickless idle (the kernel stops the periodic tick while every task is blocked and wakes at the next timeout instead), replace polling tasks with event-driven blocking, and use lightweight signalling (a task notification rather than a queue), so a switch happens only when the result is needed. Combine tiny tasks that always run back to back into one.
- Make each switch cheaper. Avoid FPU use in tasks that do not need it (so the FPU context is not stacked for them), keep the scheduler O(1), keep the ISR-to-task hand-off short (the ISR gives a semaphore or notification and returns), and place the handler and kernel data in zero-wait-state memory.
For flight-control style determinism, add a fixed upper bound: a cyclic executive (a fixed timetable of function calls repeated in a loop, with no preemption) or time-triggered switching (switches only at pre-planned clock instants), so every switch happens at a scheduled instant.
Unlock Full Question Bank
Get access to all 32 Real-Time Systems, RTOS Scheduling & WCET interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.