experiments with isolation on blue pill — lopuh@void
$">
← cd ../log
these are notes i gathered while working on my project - aether
intro
i got an idea to build a semi-hypervisor, or rather, a supervisor, for a cortex-M3 system (i’ve chosen blue pill because it’s cheap and because i have tons of them). its CPU doesn’t have TrustZone, memory management unit or the capabilities needed to recreate a fully functioning hypervisor like there are for general-purpose operating systems. more precisely, i was curious how to provide isolation for such microcontrollers in the absence of all listed features.
naturally i stumbled into the MPU and the two execution modes (handler and thread) together with privileged and unprivileged execution, with privileged code reachable only through the SVC instruction (supervisor call instruction).
with those features equipped there is a visible architecture for isolation, along with a supervisor that hands out revocable access to hardware.
but first, why on earth would we need to isolate blue pill?
when firmware stops being a static thing, some trusted blob is needed to protect it. it happens if the application is flashed / updated after the board has shipped, or if it’s allowed to talk to peripherals that can brick or damage the device, or even if the application is expected to keep running reliably even when a bug corrupts its own state, and so on.
some stuff you need to know before touching MPU and SVC
cortex-M3’s MPU is a region-based protection unit, it supports up to 8 regions, each with a power-of-two size (from 32B to 4G), its own access permissions (no access / read-only / read-write, per privilege level), an execute-never (XN) bit, and memory type attributes. these regions can again be split into 8 sub-regions to carve holes out of a block. since there is no translation, every protected region has to be at a fixed physical address (the linker script has to reflect this layout too). if an application violates permissions, it results in a hard fault (or memmanage fault first, if enabled).
the processor supports privileged and unprivileged execution. thread mode can run at either level, while handler mode always executes privileged. it also provides two stack pointers (MSP and PSP), with thread mode selecting between them through the CONTROL register. code running unprivileged cannot touch the CONTROL register, cannot reconfigure the MPU, and cannot mask interrupts. the only way back into privileged code is an exception, either a fault or a supervisor call. this system call looks like this: SVC #n. the processor enters the SVC exception handler, the handler can recover the SVC immediate (#n) from the instruction that triggered the exception and dispatch the requested service.
put those together, and the mechanism resembles the isolation a “real” OS gives a userspace process.
the architecture
to go hardcore, i didn’t use CMSIS or HAL and went only with bare-metal, burying myself in manuals.
the implementation itself looks like a bunch of components sealed together. two main blocks - bootloader-supervisor and an application module which is flashed during runtime and embedded into the system.<br>each block has its own flash memory and RAM, protected by the MPU.<br>application talks with bootloader by sending calls.
bootloader-supervisor - the privileged half, it owns the vector table, MPU, watchdog, and all the hardware. it boots first, sets up isolation, and only then hands control to the application.
application module - flashed independently, at runtime, after the supervisor is already initialized. it runs unprivileged, in its own flash region, RAM region, and stack. it can only reach the peripherals the supervisor has explicitly granted. the application doesn’t get its own vector table (though, cortex m3 supports relocating the vector table through VTOR) — interrupts remain routed through the supervisor’s table. bootloader either handles it itself or forwards it to the application (as with calls).
that’s how the bootloader is started after the initialization:
void start_bootloader(bool is_after_reset)<br>ret res;<br>app_desc_t* desc = NULL;
while (!is_app_exists(&desc) && !fetch_app()) {<br>//... sleep
if (desc != NULL) {<br>if (is_after_reset) {<br>res = preinit_periph(desc->manifest);<br>//... reset
run_app(desc);
each app is “patched” with additional information, provided within an app description structure at a known offset; it includes a signature to identify the app, a pointer to the application’s entry point in memory, size, CRC, version, and the manifest. the manifest holds the list of peripherals and capabilities this application is allowed to use. patching happens at build time; afterward the description isn’t accessible, so the manifest can’t be changed at runtime.
typedef struct PACKED _app_desc {<br>u32 magic;<br>u32 entry;<br>u32 size;<br>app_manifest_t manifest;<br>u16 crc16;<br>u8 version;<br>} app_desc_t;
handing control to the application is the moment of dropping...