The whole course has been looking in the same direction: a server with permanent power, plenty of memory, a disk that can be expanded and a network that is almost always there. And the previous lesson took that model to its extreme, where machines are plentiful, created by API and thrown away when they get in the way.

Now we are going to look in the opposite direction, towards two families of operating systems that share the same theoretical root — processes, scheduling, memory, IPC, protection — but work under opposite constraints. The phone from which a user queries Meteora's API runs a Linux kernel, but with a battery that runs down, with no swap space, with an intermittent network and with applications of unknown origin that must not be able to touch each other: that set of constraints has forced a reinvention of the IPC, the memory management and the permission model from Modules 3 and 5. And Meteora's weather stations — the embedded devices we decided back in 01-03 would run an RTOS — have 64 KB of RAM, run for months on a battery, cannot request another unit when they run short of memory, and if the sensor's sampling arrives 10 milliseconds late, the reading is wrong, not "slow to arrive".

In this lesson you are going to understand what Android adds on top of Linux and why, how a mobile application is isolated and why the system can kill it, what "real time" really means — which is not speed — how you prove mathematically that a set of tasks will meet its deadlines, and why a spacecraft on Mars was nearly lost to a synchronization problem you already know. We will finish by writing the firmware for a Meteora station on FreeRTOS, with its tasks, its justified priorities and its schedulability analysis with concrete numbers.

Contents

  1. What Android adds on top of the Linux kernel
  2. Binder: why UNIX IPC was not enough
  3. Zygote, ART and the startup trick
  4. Per-application isolation: one UID per app
  5. The lifecycle and why the system can kill an app
  6. Power management: wake locks, Doze and big.LITTLE
  7. iOS compared
  8. What the designer of the API the app consumes must know
  9. Real time: the correct definition
  10. Hard and soft, with consequences
  11. The periodic task model
  12. Rate Monotonic and the Liu and Layland bound
  13. EDF and exact response-time analysis
  14. Priority inversion and the Mars Pathfinder
  15. Interrupt latency, jitter and determinism
  16. Real RTOSes and Linux with PREEMPT_RT
  17. Peculiarities of embedded systems
  18. Case study: the firmware of a Meteora station
  19. Wrapping up Module 6

What Android adds on top of the Linux kernel

Android is Linux: the same modular monolithic kernel from 01-05, the same processes from 02-01, the same namespaces and cgroups from 06-02. But above the kernel almost nothing is what you know: there is no glibc (it uses Bionic, which is smaller), there is no systemd (it uses its own init with .rc files), there is no X11 or Wayland, and user space is designed around premises that do not exist on a server.

Mobile constraint Design consequence
Limited battery The whole system optimizes for energy before sustained performance
No swap space When RAM runs short you have to kill processes, not page to disk
Applications of unknown origin Strong per-application isolation, not per user
An always-fluid interface The UI task must have almost real-time priority
An intermittent, expensive network Batching of work and tolerance of disconnection
Very heterogeneous hardware An abstraction layer (HAL) between the framework and the drivers

Android's additions on top of the kernel are, in essence, five: Binder, its own IPC implemented as a kernel driver; zygote, the process that preloads the runtime environment so apps start instantly; ART, the applications' execution engine; the HAL, the abstraction layer that decouples the Android framework from the manufacturer's drivers and that since Project Treble lives in separate processes with versioned interfaces, which allows Android to be updated without the manufacturer redoing the drivers; and a per-application isolation model built on the UIDs from 05-02, but used in a way nobody in UNIX had foreseen.

Binder: why UNIX IPC was not enough

In 03-03 we saw the classic IPC catalog: pipes, FIFOs, message queues, shared memory, sockets. Android has all of that available and still built another mechanism. The interesting question is why.

The answer is that in Android almost everything is a call between processes: asking for the location, checking the battery, drawing on the screen, requesting a permission. The real pattern is not "send a byte stream", but invoke a method on an object living in another process and wait for the result. And that pattern demands things classic IPC does not provide:

Need What classic IPC offers What Binder offers
Synchronous call with a result You have to build it by hand over two channels Native: it is its model
Knowing who is calling, reliably PIDs can be reused; the UID has to be passed along The kernel injects the caller's UID and PID
Reference counting on remote objects Does not exist Built in: the object dies when nobody uses it
Data copies 2 copies (sender → kernel → receiver) 1 copy, with mapped memory
Resource bounding Each mechanism its own A thread pool per process, with control
Death of the peer Detected late and badly Explicit death notification

The decisive row for security is the second. When meteo-app asks for the location, the system service needs to know with certainty who is asking in order to check whether they have the permission. If the identifier were sent by the caller itself, it would lie. Binder solves this in the kernel: the driver adds the sender's real UID and PID to every transaction, and the receiver queries them with Binder.getCallingUid(). It is the same principle that made journald valuable in 05-04 — the metadata is added by someone who cannot lie — applied to IPC.

The other important row is the one about copies. Binder maps an area of the receiving process's memory and writes into it directly from the kernel, so a transaction costs a single copy instead of two. With tens of thousands of transactions per second on an active phone, that half matters in energy and in latency.

The price is a limited transaction size (on the order of 1 MB shared per process), so Binder is not for moving large data: for that you pass a shared-memory file descriptor, which Binder does know how to transfer between processes just as SCM_RIGHTS did over UNIX sockets in 03-03.

Zygote, ART and the startup trick

Every Android application runs in its own process, with its own instance of the runtime. Starting that runtime from scratch — loading the framework classes, initializing the resources — would cost hundreds of milliseconds and several megabytes per application. With forty applications, it would be unworkable.

The solution is zygote, and it is a beautiful application of something you already know. When the system boots, init launches one zygote process, which preloads the framework classes and the common resources — thousands of classes and tens of megabytes already initialized — and waits on a socket. When the user opens an application, the activity manager asks zygote to do a fork(): the child inherits everything preloaded, only has to load the app's own code, and then calls setuid() with the application's UID to drop privileges, exactly the pattern from 05-02.

The key is in the fork() and in the copy-on-write from 02-01 and 02-04: the child does not copy the framework's hundreds of megabytes, it shares the same physical pages with zygote and with all its siblings. Only when a process writes to a page is a private copy made. And since the preloaded classes are read-only in practice, they are almost never written.

The result is twofold and spectacular: an app starts in tens of milliseconds instead of hundreds, and there is an enormous memory saving, because forty applications physically share the same framework. Notice that it is the same idea as KSM in 06-01 and as overlayfs layers in 06-02: share what is identical and copy only on write. The trick shows up in all three worlds.

As for ART (Android Runtime), what is relevant for this course is its evolution, which illustrates a systems decision: it went from interpreting (slow but with no installation cost) to compiling everything at install time (fast but with extremely long installs and a lot of space) and finally to a hybrid scheme — it interprets at first, profiles what is actually used, and compiles in the background when the device is charging and idle. That last condition is pure mobile design: the expensive work is done when energy is free.

Per-application isolation: one UID per app

Here Android does something that completely reinterprets the model from 05-02. On a server, the UID identifies a person, and several programs belonging to the same person share a UID and can touch each other's files. On a phone there is only one person, so that model isolates nothing.

Android reuses the mechanism with a different meaning: every installed application gets its own UID, in the range 10000 and up.

adb shell ps -A -o USER,PID,NAME | head
# USER   PID  NAME
# root     1  init
# system 1842 system_server
# u0_a132 4021 com.meteora.app
# u0_a145 4188 com.other.app

adb shell ls -la /data/data/com.meteora.app
# drwx------ 4 u0_a132 u0_a132 ... .     ← mode 700, owned by the app's UID

What it proves. u0_a132 is user 0, application 132: a real system UID (10132). The private data directory is in mode 700 and belongs to that UID. With that, the nine permission bits from 04-06 — the oldest mechanism in the course — isolate applications from each other with nothing else needed. It is a brilliant reuse of an existing mechanism.

Complete isolation, however, has four overlapping layers, and all of them will sound familiar:

Layer Mechanism From which lesson
Identity One UID per application, data in mode 700 04-06, 05-02
MAC SELinux in enforcing mode for the whole system 05-01
Syscall confinement A seccomp filter for applications 05-01
User permissions Explicit grant by the user at run time No UNIX equivalent

The fourth layer is the one with no precedent. Runtime permissions split access in two: normal permissions (Internet, vibration) are granted at install time, and dangerous ones (location, camera, microphone, contacts) are granted by the user, at the moment of use, and can be revoked. They can also be granted in a bounded way: "only while I am using the app", "just this once", "approximate rather than exact location".

Compare that with the model from 05-02: on a server, a program inherits all the permissions of the user who runs it. If you run a downloaded script, that script can read everything you can read. On Android, an application has by default zero access to anything interesting, and the user grants capabilities one by one. It is the least privilege of 05-01 taken to a model where software is not trusted by default — and, honestly, it is a better model than the desktop's and the server's, where we still run programs with all our privileges.

The lifecycle and why the system can kill an app

This is the difference that is hardest for someone coming from the server world: on Android, the system can kill your process at any moment and it is not a failure, it is normal operation.

The reason is the most important row of the opening table: there is no swap space. In 02-04 we saw that when memory runs short, Linux pages to disk and only invokes the OOM killer as a last desperate resort. On a phone, with flash memory of limited lifetime and no swap partition, that route does not exist. The only way to reclaim memory is to free entire processes.

So Android turned the last resort into ordinary policy. The system classifies each process according to what the user would perceive if it disappeared:

Category What it is Is it killed?
Foreground What the user is using right now Almost never
Visible Visible but not in focus Rarely
Service Background work (download, music) If needed
Cached Not doing anything any more, kept in case you come back Constantly
Empty Just the process, with no active components The first to fall

And the difference from the OOM killer in 02-04 is qualitative:

OOM killer (02-04) LMKD (Android)
When it acts When there is no memory left Earlier, on rising pressure (PSI)
Criterion The oom_score heuristic: mostly size Importance to the user
Perception An unexpected disaster Normal and expected behavior
Effect Work is lost The app must have saved its state

LMKD (Low Memory Killer Daemon) lives in user space and uses the kernel's memory pressure indicators (PSI) to act before the system degrades: as soon as pressure rises, it starts killing cached processes, from the least important upwards.

The consequence for the developer is a hard rule: state that has not been saved is lost. That is why an activity's lifecycle has callbacks such as onPause() and onStop(), with an explicit promise: when onStop() is called, the system guarantees that you have had a chance to save; from then on it can kill you with no further warning. A well-written app persists its state on every transition and restores it on return, so that the user does not perceive that the process died and was born again.

Power management: wake locks, Doze and big.LITTLE

On a server, energy is not a resource to manage: you plug it in and that is that. On a phone it is the main scarce resource, and the operating system manages it with the same seriousness with which it manages the CPU.

The starting principle is that the device's natural state is asleep. The CPU, the screen, the radio and the sensors are off or in low power, and they only wake for an event. The question the system asks is not "how do I share out the CPU" but "how do I get back to sleep as soon as possible".

  • Wake locks. A component that needs to keep the CPU awake acquires a wake lock, and while any one is active the system does not enter deep suspend. It is necessary and it is the number one cause of battery-draining applications: one acquired and not released — through an error branch that does not go through the release() — keeps the phone awake all night. The correct pattern is to acquire with a maximum time and release in a finally block, with the discipline of a mutex from 03-04.
  • Doze. When the device has been still for a while, with the screen off and not charging, the system batches all deferred work: it suspends network access, postpones alarms and syncs and ignores wake locks, and every so often it opens a maintenance window where everything pending runs at once. It is the same amortization logic as virtio's virtqueues in 06-01 and NAPI in 02-07: one interrupt per batch instead of one per event, because waking the radio has a fixed energy cost and doing it once for twenty things costs twenty times less.
  • Background restrictions. An app that is not in the foreground cannot start services at will. Deferred work is declared to a system scheduler with constraints — "when there is WiFi and the device is charging" — and the system decides when to run it, batching it with everyone else's.

And at the lowest level, the scheduler from 02-02 becomes energy-aware. Mobile processors are big.LITTLE: large, fast cores that consume a lot alongside small, efficient ones. The scheduler no longer only decides when each task runs, but on which kind of core, estimating its load and its latency demands: the UI task goes to a big core because the user perceives every millisecond, and a background sync goes to a small core because nobody is watching it. It is a dimension a server's CFS did not have, because there all the cores are the same.

iOS compared

Aspect Android iOS
Kernel Linux (modular monolithic) XNU: hybrid, with Mach and BSD (01-05)
Model Open, many manufacturers Closed, hardware and software from the same vendor
App installation Official store and external sources Only the official store (with regulatory exceptions)
Isolation One UID per app + SELinux + seccomp A mandatory per-app sandbox, signed and with entitlements
Language and execution Java/Kotlin on ART Swift/Objective-C compiled to native
Memory management Garbage collector + LMKD Reference counting (ARC) + jetsam
Background Services and constrained work Very restricted: specific modes and short windows
Updates Depend on the manufacturer (better with Treble) Direct from the vendor, to the whole fleet

The two differences with the most technical consequences are memory management and background execution. ART uses garbage collection, convenient for the programmer but with pauses and energy consumption, while iOS uses automatic reference counting decided at compile time, which gives tighter and more predictable usage in exchange for the programmer breaking reference cycles by hand — which is why iOS has historically worked well with less RAM. And in the background iOS is much more restrictive: specific modes, short windows and merciless killing (jetsam) when the memory budget is exceeded, which gives better battery life and worse flexibility, and explains why many functions an app performs on Android have to be performed by the server on iOS, with push notifications.

What the designer of the API the app consumes must know

This section is the bridge between the two halves of the course, because whoever writes meteo-api on meteo-01 sees none of the above and is nevertheless affected by all of it. Three constraints of the mobile client must show up in the server's design:

1. Batch requests, because the radio is the expense. Turning on the mobile radio to transmit costs a fixed amount of energy — and, on top of that, the radio stays in a high-consumption state for a few seconds after finishing, "just in case". Ten separate 2 KB requests can cost far more than one of 20 KB. The API must allow asking for several things at once:

GET /v1/readings?stations=12,17,23&from=2026-08-31T00:00&fields=temp,hum

Instead of three requests (one per station) returning every field, a single request with field selection. The design rule is: one screen, one request. If the app needs three calls to paint a view, the API is badly designed for mobile.

2. Tolerate disconnection, because it is the normal state. A phone loses the network in a lift, switches from WiFi to mobile in the middle of a request and loses coverage in a tunnel. The server must help:

Idempotent operations with an idempotency key, so that retrying after a timeout does not duplicate anything — without this, the client's retry, which is inevitable, creates duplicate data; incremental synchronization (?since_version=8421) instead of downloading everything again, because resuming must be cheap; entity tags and 304 responses, so that revalidating costs bytes and not kilobytes; and distinguishable errors, because the client needs to know whether to retry and with what delay, and a Retry-After saves a huge amount of battery compared with a loop of immediate retries.

3. Watch the response size, because it is paid for three times: in the user's data allowance, in radio time (energy) and in parsing memory, on a device that may be about to be killed by LMKD. Returning the whole 17.3 MB daily file to a phone is a mistake in three dimensions at once. The right approach is to paginate by default, always compress, offer aggregates rather than raw data — the phone wants the hourly average, not the 720,000 readings — and allow field selection.

And a warning that closes the circle with the previous lesson: the phone cannot retry indefinitely, so the server's elasticity does not save everything. If meteo-api takes 8 seconds to respond because it is scaling from cold, the app has already timed out and has spent radio time for nothing.

Real time: the correct definition

We change worlds. And the first thing is to dismantle the misunderstanding everybody carries into this topic:

A real-time system is not a fast system. It is a predictable system.

The property that defines real time is that the response is produced within a guaranteed deadline, always, even in the worst case. A system that responds in 1 microsecond 99.99% of the time and in 50 milliseconds the remaining 0.01% is not real-time. A system that always responds in 8 milliseconds, not one more, with a 10-millisecond deadline, is, even though it is a thousand times slower than the first.

From that follows a surprising consequence: in real time, a result's correctness includes the instant at which it is produced. A correct result delivered late is an incorrect result. And another practical consequence: real-time systems sacrifice average performance in exchange for predictability. Caches and predictions are disabled, data structures with variable cost are avoided, dynamic memory allocation is forbidden... anything that introduces variability, even if it were faster on average.

Applied to Meteora: if the station has to sample the sensor every 100 ms to compute correct averages, what matters is not that the sampling takes 2 microseconds, but that it happens always within its window. A sampling that sometimes lands at 130 ms shifts the average and corrupts the reading, even with the CPU at 3%.

Hard and soft, with consequences

We pick up and extend the classification from 01-03:

Hard real time Soft real time
Missing a deadline System failure Degraded quality
Guarantee Proven mathematically before running Statistical: "99% under 20 ms"
Sizing For the absolute worst case For the typical case with a margin
Cost High: over-provisioned hardware, analysis, certification Moderate
Examples Airbag, flight control, ABS brakes, pacemaker, reactor control Video, audio, video games, telephony, user interface

The difference is clearest in the consequences of failure. An airbag (hard) must deploy between 15 and 30 ms after the collision: at 50 ms the occupant has already hit and the deployment causes injuries instead of preventing them, so there is no "a bit late", there is correct or lethal. A video player (soft) that drops a frame shows a stutter, annoying and not catastrophic, and the correct response is to discard it and carry on. Automatic braking (hard) has a deadline derived from speed and distance, and missing it is a crash. And the weather station is soft, to be honest: a sampling 30 ms late slightly degrades the hourly average and nobody dies, but if samplings are systematically late or the transmission is lost through blocking, the data is corrupted and Meteora serves false information. It is soft with demanding requirements, the most common category in industry.

There is a third useful category, firm real time: missing the deadline breaks nothing, but the result is of no use whatsoever — a trajectory prediction that arrives after the decision had to be made. The result is discarded and you carry on.

The periodic task model

To be able to prove that a system will meet its deadlines you need a model. The classic one describes each task τᵢ with three numbers:

Symbol Name What it is
Tᵢ Period How often the task is activated
Cᵢ Worst-case execution time (WCET) The most one execution can take
Dᵢ Deadline When it must have finished, counting from its activation

The usual case — and what we will assume — is Dᵢ = Tᵢ: the task must finish before it is activated again.

A task's utilization is Uᵢ = Cᵢ / Tᵢ, and the system's is U = Σ Cᵢ/Tᵢ. A task that takes 12 ms and runs every 100 ms uses 12% of the CPU.

The hard number is the WCET, and it deserves a serious warning. It is not "what I measured running it a thousand times", but the absolute upper bound, and determining it is surprisingly difficult because modern hardware conspires against predictability: caches make the same function take 10 times longer if the data is not inside (the 200-300 cycles of a RAM access from 06-01), a branch misprediction costs tens of cycles, out-of-order execution and prefetching make timing depend on recent history, and other cores interfere in the shared cache and on the memory bus.

That is why hard real-time systems often use deliberately simple and predictable hardware — microcontrollers with no cache, or with dedicated scratchpad memory — and why there are static analysis tools that compute WCET bounds over the binary. In a soft system it is enough to measure the maximum observed and add a generous margin (30% to 100%), which is what we will do with the station.

Rate Monotonic and the Liu and Layland bound

Rate Monotonic (RM) is a fixed priority assignment algorithm with a one-line rule:

The shorter the period, the higher the priority.

And its virtue, proven by Liu and Layland in 1973, is that it is optimal among fixed-priority algorithms: if any fixed-priority scheme can schedule a set of tasks, RM can too.

Its schedulability test is a sufficient condition:

U = Σ (Cᵢ / Tᵢ) ≤ n · (2^(1/n) − 1)

where n is the number of tasks. The bound is worth:

n Bound n Bound
1 100.0% 5 74.3%
2 82.8% 10 71.8%
3 78.0% 69.3% (ln 2)
4 75.7%

Two extremely important readings of that table. If total utilization is below the bound, the system will meet all its deadlines, guaranteed, and nothing else needs to be checked. If it is above, the test says nothing: the deadlines may well be met anyway, because it is a sufficient and not a necessary condition, and you have to resort to exact analysis.

And a design conclusion to internalize: with many tasks, RM only guarantees 69.3% of the CPU. That remaining 30% is not waste: it is the price of the guarantee. Anyone sizing a hard real-time system at 95% utilization has not understood the problem.

EDF and exact response-time analysis

EDF (Earliest Deadline First) uses dynamic priorities: at each instant it runs the task whose absolute deadline is nearest. Its schedulability test is astonishingly simple:

U = Σ (Cᵢ / Tᵢ) ≤ 1

And here it is a necessary and sufficient condition: if it fits in the CPU, EDF will schedule it. It is optimal among all single-processor algorithms.

Aspect Rate Monotonic EDF
Priorities Fixed, computed before running Dynamic, recomputed at run time
Guaranteed utilization 69.3% - 100% depending on n 100%
Run-time cost Minimal Higher: you have to sort by deadline
Predictability under overload Good: the lowest-priority tasks fail first Bad: an unpredictable domino effect
Implementation Trivial: static priorities Requires scheduler support
Industry use Dominant Growing (SCHED_DEADLINE on Linux)

The overload row explains why RM still dominates in critical systems despite being worse in utilization. If the system becomes overloaded — an unforeseen interrupt, a badly estimated WCET — with RM you know exactly who fails: the lowest-priority tasks, that is, the ones with the longest periods, and you can design so that they are the least important. With EDF, a task that overruns its deadline makes another overrun its own, and that one another: the failure propagates in a way that is hard to predict. In a critical system, knowing who is going to fail is worth more than squeezing out the last 25% of CPU.

The exact analysis: response times

When the RM bound is not enough, response-time analysis is used, computing each task's real worst-case response time:

Rᵢ = Cᵢ + Σ_{j ∈ hp(i)} ⌈Rᵢ / Tⱼ⌉ · Cⱼ

It reads like this: a task's worst response time is its own computation plus all the interference from higher-priority tasks (hp(i)), where each one interferes as many times as it is activated during that interval — hence the ceiling ⌈ ⌉. Since Rᵢ appears on both sides, it is solved by iterating: you start with R = Cᵢ and recompute until the value stops changing. The system is schedulable if Rᵢ ≤ Dᵢ for all tasks. We will apply it with real numbers in the case study.

Priority inversion and the Mars Pathfinder

On 4 July 1997 the Mars Pathfinder probe landed successfully on Mars. Days later it started resetting itself, losing scientific data on every reset. The fault was a synchronization problem you already know from Module 3, and its outcome is one of the best stories in systems engineering.

Priority inversion happens like this, with three tasks:

  1. A low-priority task (B) acquires a mutex on a shared resource.
  2. A high-priority task (A) is activated, tries to acquire the same mutex and blocks. So far this is correct and expected.
  3. A medium-priority task (M) appears, one that does not use the mutex. Since it has higher priority than B, it preempts B.
  4. Result: B makes no progress, so it does not release the mutex, so A cannot continue. M, of medium priority, is indirectly blocking A, of high priority, for a time that can be arbitrarily long.

On the Pathfinder the cast was exactly that: an information bus management task (high priority, with a deadline), a meteorological data task (low priority, ASI/MET) that shared a mutex on the bus with it, and a communications task (medium priority, long-running). When the sequence occurred, the bus task missed its deadline, the watchdog detected that the system had not done its job in time and reset the spacecraft, which is exactly what a watchdog must do.

Note the coincidence, which is too good to pass over: the low-priority task that caused it all was the meteorological data one. The Meteora station we will design at the end has the same task structure.

The two classic solutions:

Protocol How it works Advantage Drawback
Priority inheritance When A blocks on a mutex held by B, B temporarily inherits A's priority until it releases it Simple, activated only when needed Does not prevent deadlocks; blocking can be chained
Priority ceiling Each mutex is assigned the highest priority of the tasks that use it; whoever acquires it is raised to that ceiling immediately Bounds blocking to one single critical section and prevents deadlocks The ceilings have to be computed before running

With priority inheritance, at step 3 B is already running at high priority, so M does not preempt it: B finishes, releases the mutex and A continues. The blocking is bounded to the duration of B's critical section.

And the best part of the story: VxWorks already had priority inheritance; it was disabled on that mutex for performance. The JPL team reproduced the fault in the lab and sent a one-parameter change to Mars to enable it. The spacecraft kept working.

Three lessons that hold for any system, not only spacecraft: the synchronization from 03-04 is not an implementation detail, it is part of the timing analysis, and a misused mutex turns a proven system into one that fails; safety mechanisms disabled "for performance" end up costing dearly; and the system worked as it should, because the watchdog detected the overrun and reset, without which the spacecraft would have hung on Mars with no possible diagnosis.

Interrupt latency, jitter and determinism

In 02-07 we looked at interrupts from a performance perspective. In real time, what matters is something else: how long it takes, in the worst case, from the physical event happening until the code that must respond runs.

That interrupt latency breaks down like this:

Component What it is How it is reduced
Hardware latency Detection and signaling in the controller Little room
Sections with interrupts disabled The kernel was in a critical region and does not respond A preemptible kernel; short critical sections
Context saving Saving registers and jumping to the routine Hardware; sometimes optimized
Running the routine Servicing the interrupt itself An extremely short ISR; the work goes to a task
Context switch to the task Waking and scheduling the waiting task A preemptible scheduler, correct priorities

Jitter is the variation of that latency between activations. And here is the key point for understanding real time: jitter usually matters more than average latency. A system with a constant 500 µs latency is perfectly usable — you just budget for those 500 µs; a system with a 50 µs average but 5 ms spikes is useless for control, because you have to size for the worst case and that worst case is 5 ms.

That is why real-time techniques all go in the same direction — reduce variability — and cheerfully sacrifice average performance: minimal interrupt routines that only signal a task, extremely short critical sections, a ban on allocating memory dynamically at run time (malloc has variable cost and can fragment), data structures with bounded cost, and sometimes caches disabled or locked for the critical paths.

Real RTOSes and Linux with PREEMPT_RT

System Type Footprint License Typical use
FreeRTOS Hard, very lightweight 6-12 KB MIT Microcontrollers, IoT, sensors
Zephyr Hard, modular 8-100 KB Apache 2.0 Connected IoT, wearables
QNX Hard, microkernel (01-05) ~1 MB Commercial Automotive, medical, industrial
VxWorks Hard, certifiable ~1 MB Commercial Aerospace, defense, rail
Linux + PREEMPT_RT Soft/firm, latencies of tens of µs ~50 MB+ GPL Robotics, audio, industrial control

It is worth pausing on the two extremes. FreeRTOS is not an operating system in the sense of this course: it is a scheduling library that is linked into your program, with no processes, no memory protection and no file system; there are tasks sharing the same address space, a preemptive fixed-priority scheduler, queues, semaphores and mutexes with priority inheritance. It fits in 10 KB because it does almost nothing else, and that is why it works with 64 KB of RAM. Linux with PREEMPT_RT is the other extreme: a complete system in which almost the entire kernel has been made preemptible, turning spinlocks into mutexes that can sleep, moving interrupt routines into prioritized kernel threads — so that an interrupt does not indefinitely delay a higher-priority task — and adding priority inheritance to the kernel's mutexes. The patch was merged into the mainline in 2024, after more than twenty years.

With PREEMPT_RT, worst-case latencies on the order of tens of microseconds are achievable, versus the milliseconds of an ordinary Linux. Combined with SCHED_DEADLINE — the policy we already mentioned in 02-02, which implements EDF with a budget: you declare period, deadline and execution time, and the kernel rejects the task if it does not fit — soft and firm real time are well covered.

When is Linux enough and when do you need an RTOS? Linux with PREEMPT_RT is enough when deadlines are in the hundreds of microseconds or milliseconds, when you need networking, a file system or a screen, and when the system is soft or firm: robotics, professional audio, mid-range industrial control. You need an RTOS when deadlines are in microseconds, when memory is measured in kilobytes, when consumption must be minimal or when the system has to be certified (DO-178C in avionics, ISO 26262 in automotive, IEC 62304 in medical): certifying a complete Linux is economically unviable, certifying 10 KB of FreeRTOS is feasible.

Peculiarities of embedded systems

Four realities that change the way you program:

  • Scarce, static memory. 64 KB of RAM and 512 KB of flash is normal, and dynamic allocation after startup is forbidden: buffers, queues and stacks are reserved statically with computed sizes. It is not only about space, it is about predictability, because malloc has variable cost and can fragment until it fails after weeks of operation, which is the worst possible moment.
  • No MMU on the smallest ones, and direct boot. Low-end microcontrollers have no memory management unit, so everything from Module 2 about virtual memory does not exist: physical addresses, no separation between tasks, and a corrupted pointer can write into another task's stack. Many carry a simpler MPU, which defines a few regions with permissions and detects out-of-region accesses without translating addresses. And there is no BIOS and no boot loader: on reset, the processor reads the stack address and the reset vector from a fixed position in flash and jumps into the code, running the application within tens of milliseconds.
  • Watchdog. An independent hardware timer that resets the system if the software does not refresh it in time: the last safety net against an infinite loop, a block on a mutex or memory corruption, and exactly what saved the Pathfinder from hanging. Its golden rule is violated constantly: it must be refreshed only if all the critical tasks are alive, never from a blind timer, because then it only protects against the CPU stopping altogether, which is the least likely failure.

Case study: the firmware of a Meteora station

We close the module by designing the firmware of a weather station. Hardware: a Cortex-M4 microcontroller at 48 MHz, 64 KB of RAM, 512 KB of flash, sensors over I²C, an NB-IoT radio over UART, a lithium battery. System: FreeRTOS.

The tasks and their requirements

Task Period T WCET C Deadline D Utilization Why that period
sampling_task 100 ms 12 ms 100 ms 12.0% 10 Hz for reliable hourly averages
watchdog_task 250 ms 2 ms 250 ms 0.8% It must refresh before the 1 s timeout
averaging_task 1,000 ms 180 ms 1,000 ms 18.0% Consolidates 10 samples per second
send_task 5,000 ms 2,400 ms 5,000 ms 48.0% The radio is expensive: batch 5 s of data
U = 78.8%

Priority assignment with Rate Monotonic

Shorter period, higher priority. In FreeRTOS, a higher number means higher priority:

Task T Priority Justification
sampling_task 100 ms 4 (highest) Shortest period; a late sampling corrupts the reading
watchdog_task 250 ms 3 It must run even if the transmission drags on
averaging_task 1,000 ms 2 Local computation, with no external deadline
send_task 5,000 ms 1 (lowest) The longest and the most tolerant: if it is late, it retries

And notice the valuable RM property we mentioned: if the system becomes overloaded, the first to fail is send_task, which is precisely the one that can retry without losing anything, because the data is in the buffer. The task whose failure would be irreparable — the sampling — is the most protected. Here RM's priority assignment coincides with functional importance, and when that happens, the design is good.

Schedulability test

Step 1: the Liu and Layland bound. With n = 4, the bound is 4 · (2^(1/4) − 1) = 4 · 0.1892 = 0.757, that is 75.7%.

Our utilization is 78.8%, which exceeds the bound. The test is inconclusive: we cannot claim it is schedulable, but nor can we claim it is not. We have to do the exact analysis.

Step 2: response-time analysis. We apply Rᵢ = Cᵢ + Σ_{j∈hp(i)} ⌈Rᵢ/Tⱼ⌉·Cⱼ from highest to lowest priority.

  • sampling_task (highest priority, no interference): R = 12 ms ≤ 100 ms
  • watchdog_task: interfered with by sampling. R⁰ = 2R¹ = 2 + ⌈2/100⌉·12 = 2 + 12 = 14R² = 2 + ⌈14/100⌉·12 = 14. It converges. R = 14 ms ≤ 250 ms
  • averaging_task: interfered with by sampling and watchdog. R⁰ = 180R¹ = 180 + ⌈180/100⌉·12 + ⌈180/250⌉·2 = 180 + 24 + 2 = 206 R² = 180 + ⌈206/100⌉·12 + ⌈206/250⌉·2 = 180 + 36 + 2 = 218 R³ = 180 + ⌈218/100⌉·12 + ⌈218/250⌉·2 = 218. It converges. R = 218 ms ≤ 1,000 ms
  • send_task (lowest priority, interfered with by all three): R⁰ = 2,400 R¹ = 2,400 + ⌈2400/100⌉·12 + ⌈2400/250⌉·2 + ⌈2400/1000⌉·180 = 2,400 + 288 + 20 + 540 = 3,248 R² = 2,400 + ⌈3248/100⌉·12 + ⌈3248/250⌉·2 + ⌈3248/1000⌉·180 = 2,400 + 396 + 26 + 720 = 3,542 R³ = 2,400 + ⌈3542/100⌉·12 + ⌈3542/250⌉·2 + ⌈3542/1000⌉·180 = 2,400 + 432 + 30 + 720 = 3,582 R⁴ = 2,400 + 432 + 30 + 720 = 3,582. It converges. R = 3,582 ms ≤ 5,000 ms

Conclusion: the system is schedulable with Rate Monotonic, even though the Liu and Layland bound did not guarantee it. The tightest task's margin is 5,000 − 3,582 = 1,418 ms, 28% of slack against its deadline. It is a first-rate practical lesson: the bound is sufficient but not necessary, and discarding a design just because it exceeds the bound is a common mistake.

(With EDF, U = 78.8% ≤ 100% would have been enough as a proof, with no further computation. The price, as we saw, is unpredictable behavior under overload.)

The code

/* ---------- Shared resources, all STATIC ---------- */
#define N_SAMPLES  50             /* 5 s at 10 Hz */

typedef struct {                  /* 24 bytes, like meteo-01's Reading */
    uint32_t station_id;
    uint32_t timestamp;
    float    temperature, humidity, pressure;
} Reading;

static Reading           buffer[N_SAMPLES];    /* reserved at compile time */
static StaticSemaphore_t buffer_mem;
static SemaphoreHandle_t buffer_mutex;
static EventGroupHandle_t alive;               /* "I am alive" signals */
#define ALIVE_SAMPLING (1<<0)
#define ALIVE_SEND     (1<<1)

/* ---------- Task 1: sampling. Priority 4, T=100 ms ---------- */
void sampling_task(void *p) {
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        Reading r;
        r.timestamp   = rtc_now();
        r.temperature = i2c_read_temp();     /* bounded reads:              */
        r.humidity    = i2c_read_hum();      /* I2C with timeout, never an  */
        r.pressure    = i2c_read_pres();     /* indefinite block            */

        /* VERY short critical section: only copy, never compute inside */
        if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(5)) == pdTRUE) {
            buffer_insert(&r);
            xSemaphoreGive(buffer_mutex);
        } else {
            mutex_failure_count++;            /* logged, not blocked on */
        }

        xEventGroupSetBits(alive, ALIVE_SAMPLING);
        vTaskDelayUntil(&last, pdMS_TO_TICKS(100));    /* EXACT period */
    }
}

/* ---------- Task 2: watchdog. Priority 3, T=250 ms ---------- */
void watchdog_task(void *p) {
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        /* Refresh only if ALL the critical tasks have signaled */
        EventBits_t b = xEventGroupClearBits(alive, ALIVE_SAMPLING|ALIVE_SEND);
        if (b & ALIVE_SAMPLING) {
            iwdg_refresh();                   /* otherwise the hardware resets */
        }
        vTaskDelayUntil(&last, pdMS_TO_TICKS(250));
    }
}

/* ---------- Task 3: averaging. Priority 2, T=1000 ms ---------- */
void averaging_task(void *p) {
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        Reading copy[10];
        if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(20)) == pdTRUE) {
            buffer_copy_last(copy, 10);        /* copy inside */
            xSemaphoreGive(buffer_mutex);
            compute_averages(copy, 10);        /* compute OUTSIDE the mutex */
        }
        vTaskDelayUntil(&last, pdMS_TO_TICKS(1000));
    }
}

/* ---------- Task 4: send. Priority 1, T=5000 ms ---------- */
void send_task(void *p) {
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        Reading batch[N_SAMPLES];
        size_t n = 0;
        if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
            n = buffer_extract_all(batch);
            xSemaphoreGive(buffer_mutex);
        }
        if (n > 0 && !nbiot_send(batch, n * sizeof(Reading))) {
            buffer_return(batch, n);           /* failure: retried later */
        }
        xEventGroupSetBits(alive, ALIVE_SEND);
        vTaskDelayUntil(&last, pdMS_TO_TICKS(5000));
    }
}

/* ---------- Startup ---------- */
int main(void) {
    hw_init();
    buffer_mutex = xSemaphoreCreateMutexStatic(&buffer_mem);  /* with inheritance */
    alive        = xEventGroupCreate();

    xTaskCreate(sampling_task,  "sampling",  256, NULL, 4, NULL);
    xTaskCreate(watchdog_task,  "watchdog",  128, NULL, 3, NULL);
    xTaskCreate(averaging_task, "averaging", 384, NULL, 2, NULL);
    xTaskCreate(send_task,      "send",      512, NULL, 1, NULL);

    iwdg_init(1000);              /* hardware watchdog: 1 s */
    vTaskStartScheduler();        /* does not return */
    for (;;);
}

The seven decisions that make this work:

  1. vTaskDelayUntil and not vTaskDelay. vTaskDelay(100) waits 100 ms from now, so the real period becomes 100 ms plus whatever the iteration took, and the error accumulates: within an hour the station would have lost tens of samples. vTaskDelayUntil wakes at absolute instants and keeps the period exact, which is precisely what real time demands.
  2. Everything static. buffer, the mutex's memory and the stacks are reserved at compile time. No malloc after startup: no fragmentation, no variable cost, no failures three weeks in.
  3. A mutex with priority inheritance. FreeRTOS implements it in xSemaphoreCreateMutex (not in binary semaphores). It is exactly the Pathfinder fix: without it, send_task holding the mutex could be preempted by averaging_task and block the sampling.
  4. Extremely short critical sections. Inside the mutex only copying happens; the computations and the transmission — the expensive parts — happen outside. This bounds the blocking the highest-priority task can suffer, which is what enters the timing analysis.
  5. Timeouts everywhere. Every xSemaphoreTake and every I²C operation has a maximum wait. In real time, indefinite blocking is forbidden: it is better to lose a sample and log it than to hang a task.
  6. The watchdog really checks. It refreshes only if the sampling task has signaled that it is alive during the interval. A watchdog refreshed from a blind timer only detects that the CPU has stopped, which is the least likely failure of all.
  7. The priorities coincide with the importance. By RM's construction, the first task to miss a deadline would be the sending one, which is the only one that can retry without losing data because the buffer keeps it.

A margin to be respected

With U = 78.8% there is 21% of CPU left free, and it is not spare: it absorbs the interrupts (which are not in the model and steal time from every task), the WCET estimation errors and future growth. If tomorrow a wind sensor is added with T = 100 ms and C = 8 ms, utilization rises to 86.8% and the whole analysis has to be redone, not assumed to fit. That, at bottom, is the habit that defines real-time engineering: before adding a task, you prove that it still meets its deadlines.

Common Mistakes and Tips

Believing real time means fast. It is conceptual mistake number one. It means predictable: a slow, constant system is real-time; a blazingly fast one with occasional spikes is not.

Sizing with the average case. In real time only the worst case counts. A WCET measured in benign tests with no margin is a false guarantee.

Misreading the Liu and Layland bound. Exceeding it does not mean the system is unschedulable: the test is sufficient, not necessary. Discarding a valid design over that is as common as doing the exact analysis and discovering, as here, that there is 28% of slack.

Using vTaskDelay in a periodic task, or doing work inside the interrupt routine. The first shifts the period and accumulates error: always vTaskDelayUntil or its equivalent. The second destroys the whole system's predictability, because all the latency depends on ISRs being minimal: signal a task and get out.

Disabling priority inheritance "for performance". It is literally the Mars Pathfinder fault: the saving is a few cycles and the cost is a system that reboots itself with nobody knowing why.

On mobile: assuming your process will stay alive. With no swap, the system kills processes as normal operation. State not saved in onStop() is lost.

On mobile: not releasing a wake lock on the error path. It is the most frequent cause of battery-draining applications. Acquire with a maximum time and release in finally.

Designing the API as if the client were a server. A phone pays for every request in battery, data and memory. One screen, one request; aggregates instead of raw data; pagination by default; and idempotent operations, because retrying is inevitable.

Tip: measure the jitter, not the average. In any system with timing requirements, the distribution matters more than the average. A latency histogram tells the truth; an average almost never does.

Tip: write the schedulability analysis down and keep it with the code. It must be updated every time a task is added or a period changes. Real-time firmware without that document is firmware nobody can safely modify.

Exercises

Exercise 1: redesigning the station's task set

Meteora wants to add two functions to the station: wind_task (anemometer, T = 50 ms, C = 6 ms) and diagnostic_task (self-test and battery status, T = 10,000 ms, C = 400 ms), keeping the four existing tasks.

(a) Assign priorities with Rate Monotonic for the six tasks and justify the ordering. (b) Compute total utilization and compare it with the Liu and Layland bound for n = 6. (c) Do the exact response-time analysis for send_task and for diagnostic_task, and say whether the system is schedulable. (d) If it were not, propose three different design changes that would fix it without changing the hardware, stating what is lost with each one.

Exercise 2: diagnosing a priority inversion

A Meteora station reboots at random, between two and six times a day, with no time-of-day pattern. The log before the reboot always shows that the last operation was a radio transmission. It is known that: send_task (priority 1) takes the buffer mutex before transmitting and holds it for the whole transmission, which lasts up to 2,400 ms; averaging_task (priority 2) does not use the mutex and takes 180 ms; sampling_task (priority 4) needs the mutex to insert each reading, with a 5 ms timeout; and the watchdog is set to 1 s.

(a) Explain step by step the sequence that causes the reboot, identifying each task's role. (b) Why is it random rather than happening every time? (c) Propose three independent fixes, say which is best and why. (d) Would priority inheritance have been enough to solve it completely? Reason it carefully.

Exercise 3: designing Meteora's API for mobile consumption

The Meteora app shows a screen with: the current status of the user's 3 favorite stations, the temperature trend over the last 24 hours for each, and a warning if any station has not reported for more than an hour. The current implementation makes 7 requests and downloads 2.4 MB.

(a) Redesign the API to reduce that, stating the number of requests and an estimate of the size, with the justification for each decision. (b) Explain which mechanisms you would add to tolerate disconnection and why each one. (c) The app also submits manual corrections of erroneous readings: design that endpoint so that a retry after a timeout does not duplicate the correction, and explain the mechanism. (d) Relate each decision to the mobile constraint that motivates it (radio, battery, memory or intermittent network).

Solutions

Solution 1

(a) RM priorities (shorter period → higher priority):

Task T (ms) C (ms) Priority U
wind_task 50 6 6 12.0%
sampling_task 100 12 5 12.0%
watchdog_task 250 2 4 0.8%
averaging_task 1,000 180 3 18.0%
send_task 5,000 2,400 2 48.0%
diagnostic_task 10,000 400 1 4.0%

The anemometer becomes the highest priority because it has the shortest period. Note that this is consistent with the function: measuring wind at 20 Hz demands strict regularity. And the diagnostic ends up last, which is also correct: it is the only thing that can be delayed without consequences.

(b) Utilization and bound. U = 0.12 + 0.12 + 0.008 + 0.18 + 0.48 + 0.04 = 0.94894.8%. Bound for n = 6: 6·(2^(1/6) − 1) = 6·0.1225 = 0.73573.5%. U far exceeds the bound: inconclusive, and this time it looks decidedly bad.

(c) Exact analysis.

send_task (priority 2), interfered with by wind, sampling, watchdog and averaging: R⁰ = 2,400R¹ = 2,400 + ⌈2400/50⌉·6 + ⌈2400/100⌉·12 + ⌈2400/250⌉·2 + ⌈2400/1000⌉·180 = 2,400 + 288 + 288 + 20 + 540 = 3,536R² = 2,400 + 426 + 432 + 30 + 720 = 4,008R³ = 2,400 + 486 + 492 + 34 + 900 = 4,312R⁴ = 2,400 + 522 + 528 + 36 + 900 = 4,386R⁵ = 2,400 + 528 + 528 + 36 + 900 = 4,392R⁶ = 4,392. It converges: R = 4,392 ms ≤ 5,000 ms, with a slack of only 608 ms (12%).

diagnostic_task (lowest priority, interfered with by all five): R⁰ = 400R¹ = 400 + 48 + 48 + 4 + 180 + 2,400 = 3,080R² = 400 + 372 + 372 + 26 + 720 + 2,400 = 4,290R³ = 400 + 516 + 516 + 36 + 900 + 2,400 = 4,768R⁴ = 400 + 576 + 576 + 40 + 900 + 2,400 = 4,892R⁵ = 400 + 588 + 588 + 40 + 900 + 2,400 = 4,916R⁶ = 4,916. It converges: R = 4,916 ms ≤ 10,000 ms

The system is schedulable, but with an uncomfortable margin: send_task has only 12% of slack, and the 5.2% of free CPU is not enough to absorb interrupts, WCET errors or extensions. Technically it passes; as engineering, it is not acceptable.

(d) Three design changes:

  1. Lengthen the send period to 10 s (send's U drops from 48% to 24%; total U to 70.8%, even below the bound). Freshness is lost: the data reaches the server up to 10 s later. For weather data that is irrelevant, and it also saves battery by waking the radio half as often: it is the best option, and it is an example of the fact that the most expensive timing requirement is often not justified.
  2. Split the transmission into fragments of 300 ms with yield points between them, so that the task's C drops even though the whole operation takes the same time. A great deal of slack is gained for the lower-priority tasks; simplicity is lost and the partial-send state has to be managed, with the risk of duplicating or losing data if it is interrupted midway.
  3. Lower the anemometer's sampling to 100 ms (wind's U from 12% to 6%; total 88.8%). It is the change with the least benefit and with a real loss of data quality — wind is the most variable of the four quantities — so it would only be justified if 10 Hz is verified to be enough.

A legitimate fourth option: move the diagnostic to an external event rather than a periodic one — when the server asks for it, or once a day at a quiet moment — which removes 4 points of utilization and all its interference without losing functionality.

Solution 2

(a) The sequence. It is a textbook priority inversion, with the same shape as the Pathfinder's:

send_task (priority 1) takes the buffer mutex and starts transmitting, holding it for up to 2,400 ms, which is enormously long. At 100 ms, sampling_task (priority 4) tries to take it, cannot and exhausts its 5 ms wait: it loses the reading. Then averaging_task (priority 2), which does not use the mutex and has higher priority than send, preempts it for 180 ms; while it is preempted, send makes no progress and does not release the mutex, so sampling keeps failing on every activation. Since sampling does not complete its work, it does not set its ALIVE_SAMPLING bit, watchdog_task checks and correctly does not refresh, and when the 1 s timer expires the hardware resets the station.

Each task's role: send is the low-priority one holding the resource, averaging is the medium-priority one that preempts without using the resource — the ingredient that turns ordinary blocking into inversion — sampling is the high-priority victim, and the watchdog is the detector that makes the failure visible.

(b) Why it is random. Three coincidences are needed at once: that the transmission is long (which depends on NB-IoT coverage, and that varies), that averaging_task activates inside that window, and that the sum of preemptions keeps sampling unable to set its bit for a full second or more. With good coverage the transmission lasts 400 ms and there is not enough time; with bad coverage it drags out and coincides. That is why it happens between two and six times a day and with no time-of-day pattern: it depends on radio propagation, not on the clock.

(c) Three independent fixes:

  1. Do not hold the mutex during the transmission. Take the mutex, copy the batch to a local buffer, release it immediately, and transmit without the mutex. The critical section goes from 2,400 ms to under 1 ms.
  2. Enable priority inheritance on the mutex (use xSemaphoreCreateMutex and not a binary semaphore). That way, when sampling blocks, send inherits priority 4 and averaging can no longer preempt it.
  3. Use a lock-free buffer — a single-producer, single-consumer ring with atomic indexes — removing the mutex entirely between sampling and sending.

The best is the first, and for a fundamental reason: it attacks the root cause, which is an extremely long critical section. It is the principle from 03-04 taken to its conclusion: inside the mutex, only what is strictly necessary. It is also a small change, it does not depend on RTOS features and it makes the system correct even if inheritance were disabled. The third is elegant and the fastest, but it is the easiest to implement badly.

(d) Would priority inheritance have been enough? It reduces the problem drastically, but does not solve it completely. With inheritance, averaging stops preempting send, so the inversion proper disappears and the blocking is bounded to the duration of the critical section. But that critical section is still 2,400 ms, so sampling_task would still be unable to insert for up to 2.4 seconds: it would still lose ~24 consecutive readings and, with the watchdog at 1 s, it would still reboot.

The conclusion is important and qualifies the Pathfinder lesson: priority inheritance bounds the blocking, it does not eliminate it. If the critical section is longer than the high-priority task's deadline, no amount of inheritance saves the design. The structural fix is always to shorten the critical section.

Solution 3

(a) Redesign. From 7 requests and 2.4 MB to 1 request and around 15-25 KB compressed:

GET /v1/dashboard?stations=12,17,23&history=24h&resolution=15m
                  &fields=temp,hum&format=aggregate
Accept-Encoding: gzip
If-None-Match: "w/dashboard-12,17,23-8421"

Decisions and justification:

A single composite endpoint returns current status, historical series and warnings for the three stations: the "one screen, one request" rule, which eliminates 6 radio wake-ups, the dominant energy expense. Aggregates rather than raw data are sent — 24 h at 15-minute resolution is 96 points per station, 288 in total, versus 720,000 readings; the client paints a 300-pixel curve, and sending it more resolution than pixels is pure waste — with field selection (no pressure and no metadata the screen does not show) and mandatory compression, because JSON of numeric series compresses 5:1 or better. And the warnings are computed on the server: having the client decide that a station "has not reported for an hour" would force it to download timestamps for everything.

(b) Tolerating disconnection:

ETag + 304 Not Modified, so that a refresh with no changes costs hundreds of bytes instead of 20 KB; incremental synchronization (?since_version=8421), so that after a tunnel the app asks only for what is new; Retry-After, so that the server states when to retry instead of leaving the app in a loop with the radio on; distinguishable errors, 4xx (do not retry) versus 5xx and 429 (retry with exponential backoff), because a client that retries a 400 burns energy forever for nothing; and a local cache with a freshness marker, so that the app is useful with no network by showing the previous data with an "updated X ago".

(c) Idempotent endpoint for corrections:

POST /v1/readings/corrections
Idempotency-Key: 7f3c1e9a-2b44-4c8d-9f01-6ab2e5d31c07
Content-Type: application/json

{"station_id": 17, "timestamp": 1756598400, "temperature": 21.4,
 "reason": "miscalibrated sensor"}

Mechanism. The client generates a UUID per correction intent — not per attempt — and repeats it on every retry of that same correction. The server keeps a table of processed keys with their response and a TTL (24-48 h):

  1. A request arrives with a key. If the key does not exist, the correction is applied, key → response is stored in the same transaction and 201 is returned.
  2. If the key already exists, nothing is applied and the stored response is returned, with 200.
  3. If the key exists but is in flight, 409 is returned so the client retries shortly.

Storing the key and applying the correction in the same transaction is what makes the mechanism correct: if they were done separately, a failure between the two would leave the door open to a duplicate. The case this solves is exactly the one that happens daily on a phone: the server processes the correction, the response is lost as the user enters a tunnel, and the client retries believing it failed.

(d) Decision ↔ constraint mapping:

Decision Mobile constraint
One request instead of seven Radio and battery: every radio wake-up has a fixed cost, plus seconds of tail in high consumption
Aggregates and field selection Memory (the process may be killed by LMKD) and the user's data allowance
Compression Radio (less transmission time) and data
ETag / 304 Intermittent network and battery: revalidating costs almost nothing
Incremental synchronization Intermittent network: resuming must be cheap
Retry-After and distinguishable errors Battery: it avoids retry loops with the radio on
Idempotency Intermittent network: retrying is inevitable, so it must be safe
Local cache with freshness Intermittent network: the app must be useful with no coverage

Conclusion

This lesson closes Module 6, which has had a single thread: isolation, seen at four successive scales.

In 06-01 we saw the strongest isolation: duplicating the entire machine. The Popek and Goldberg criteria and their theorem, x86's failure with its 17 sensitive, unprivileged instructions and the three answers — binary translation, paravirtualization and hardware assistance with root mode, the VMCS and the VM exit as the unit of cost; KVM turning Linux into a hypervisor, where a VM is a process and a vCPU is a thread; and the virtualization of Module 2's three resources: vCPUs with oversubscription and top's st, memory with EPT/NPT, ballooning and KSM, and I/O with the ladder emulation → virtio → SR-IOV.

In 06-02 we saw the lightest isolation: duplicating nothing and limiting what a process sees and consumes. The eight namespaces — with user as the decisive security improvement and chroot as the one that was never security — cgroups v2, whose text files in /sys/fs/cgroup govern CPU, memory, I/O and PIDs, the indispensable security on top (capabilities, seccomp, MAC), and overlayfs with its copy-up explaining why a hundred containers fit where one used to.

In 06-03 we saw what is left of the operating system when the machine stops being an object: cattle rather than pets, cloud-init, the metadata service and its risk, immutable infrastructure, minimal systems, storage arranged by layers of latency and cost, and orchestration as the cluster's operating system, where requests and limits are the previous lesson's cgroups and the cluster scheduler is the one from 02-02 one level up.

And in this last lesson we have seen the two worlds where the constraints are the opposite. On mobile, how the absence of swap turns the OOM killer into ordinary policy (LMKD), how Binder solved what UNIX IPC did not provide — identity injected by the kernel, a single copy, reference counting — how zygote applies fork and copy-on-write to start apps instantly, how one UID per application reuses the oldest mechanism in the course to isolate untrusted software, and how energy becomes the resource that governs the design, with wake locks, Doze and big.LITTLE-aware scheduling. On real time, that the property is not speed but predictability; the periodic task model and the hard problem of the WCET; Rate Monotonic with the Liu and Layland bound — sufficient but not necessary, as our station showed with U = 78.8% against a 75.7% bound and yet schedulable with 1,418 ms of slack; EDF, optimal in utilization but unpredictable under overload; the priority inversion of the Mars Pathfinder with its meteorological data task, and the qualified lesson that inheritance bounds the blocking but only shortening the critical section eliminates it; and the station's complete firmware, with its justified priorities, its numerical analysis and its seven design decisions.

With that, the conceptual half of the course closes. You know what an operating system is (Module 1), how it shares out resources (2), how it coordinates simultaneous execution (3), how it organizes persistence (4), how it protects (5) and how it isolates (6).

What is missing is the part that separates someone who understands a system from someone who can fix it at three in the morning. Because when meteo-01 is responding slowly and you do not know why, none of the abstractions you have learned is of any use if you do not know which command to run first, how to read its output and how to get from a vague symptom — "the website is slow" — to a concrete cause — "the aggregator is doing random 4 KB reads and has saturated the RAID's queue". That is no longer theory: it is method, practice and a handful of tools you have to know by heart.

That is Module 7: Administration and Troubleshooting in Practice, and it starts by going down to the interface everything else passes through: The Command Line as the System Interface.

Operating Systems Fundamentals

Module 1: Introduction to Operating Systems

Module 2: Resource Management

Module 3: Concurrency

Module 4: File Structures

Module 5: System Protection and Security

Module 6: Virtualization and Containers

Module 7: Administration and Troubleshooting in Practice

© Copyright 2026. All rights reserved