kodeOS internals
Boot model
How a Kode Dot picks the firmware to start after a reset: the partition table, otadata, the boot request, the mailbox and the bootloader override.
What it is
After every reset the Kode Dot has to decide which firmware to run: a kodeOS image, or an app. Three records in the flash decide, and each has exactly one owner.
| Record | Where | What it says | Owner |
|---|---|---|---|
otadata | partition otadata, 0x1B000 | Which kodeOS slot starts | AO_Boot. Only a kodeOS update writes it |
| The boot request | sector at offset 0x2000 of the apps region | Which installed app starts, and for how many resets | AO_Boot writes it; the bootloader override advances it |
| The mailbox | partition app_pending, 0x1A00000 | An image a computer left for kodeOS to adopt | AO_AppMgr reads it at boot |
An app never writes otadata. That is why any reset brings the board back to kodeOS: the next boot finds no request that still stands and falls to the slot otadata names. A sketch uploaded through the mailbox is the exception, and only for the resets listed under Specifications.
kodeOS keeps three slots for itself: factory, ota_0 and ota_1. factory is the rescue copy, written only over the cable. The other two take updates in turn. The apps region is not a slot, but it also holds firmware, and the bootloader starts an app from it in place, without copying it.
How it is connected
flowchart TD
rom["ROM bootloader"] --> bl["Bootloader override"]
bl --> q{"Boot request stands?<br/>NEW, ALWAYS or ARMED"}
q -- "yes" --> app["App in the<br/>apps region"]
q -- "no" --> od{"otadata names<br/>a slot?"}
od -- "yes" --> slot["ota_0 or ota_1"]
od -- "no" --> factory["factory"]
class rom chip
class app app
class slot,factory storeThe bootloader is the standard ESP-IDF one with four additions, in bootloader_components/main/bootloader_start.c. Three run at every boot:
- The IO_MUX clock goes back to the crystal.
- Every GPIO interrupt a previous firmware left armed is disarmed.
- The panel's reset line is pulsed low for 2 ms.
The fourth is the question in the diagram. If no request stands, the standard path runs.
The request changes before the jump
The bootloader moves NEW to TRIED and ALWAYS to ARMED with one 4-byte write, then jumps. A one-shot app that crashes or resets finds TRIED at the next boot, so the standard path starts kodeOS. The same order means a failed write blocks the jump: a request that cannot advance would otherwise jump on every reset.
An ARMED request is judged on every reset
It stands only if the reset cause is one a working app or its host produces (the list under Specifications) and the mailbox is empty. If it does not stand, the bootloader writes TRIED and starts kodeOS without verifying the app. The first boot of an ALWAYS request never consults the list, because kodeOS launches an app with a software reset and that reset must not cancel its own launch.
sequenceDiagram autonumber participant OS as kodeOS<br/>AO_Boot participant BL as Bootloader<br/>override participant App as App OS->>OS: write the request: NEW, or ALWAYS from the mailbox OS->>BL: reset, after the leave BL->>BL: check magic, CRC, bounds and esp_image_verify BL->>BL: NEW becomes TRIED, ALWAYS becomes ARMED BL->>App: jump in place App->>BL: any reset Note over BL: TRIED, so otadata names kodeOS BL->>OS: start kodeOS OS->>OS: req_tried() gives boot reason app, then req_clear()
AO_Boot decides what kodeOS does about it
Its behaviour depends on the firmware's role, a Kconfig choice (CONFIG_KODEOS_BOOT_ROLE_OS or _APP). In an app (the default) it claims otadata and the request sector and writes neither, so a firmware that crashes on its first line still returns to kodeOS. In kodeOS it runs three steps at every boot:
- If the running image is still
PENDING_VERIFY,kdrv_slot_confirm_self()clears the mark. This is mandatory: an unconfirmed image is rolled back at the next boot, andesp_ota_begin()refuses to open an update while the running slot is unconfirmed. - It calls
kdrv_apps_init()and thenkdrv_apps_req_tried(). ATRIEDrequest proves the bootloader jumped, so the reason isappand the request is cleared. Any other request still present is cleared with a warning, because the bootloader's retire write did not stick, and the reason iscold. - If kodeOS runs from
factorywhileotadatais not blank, a kodeOS update died before it confirmed. The reason isunexpectedand AO_Boot clearsotadata, so the board stays onfactory.
The reset reason of the chip, read after those steps, classes how the app before it ended: panic, watchdog, brownout, or clean for everything else. An unknown reset reason never reads as a crash.
A restart leaves in an order
Every deliberate restart (an app launch, a kodeOS update, kode_exit_to_os(), the bottom button held) goes through the same short leave in AO_Boot:
- AO_Net stops the Kode channel cleanly (at most 200 ms).
- AO_Radio hangs up Bluetooth and parks the ESP32-C5 so the Wi-Fi link survives the jump (at most 250 ms).
- The console drains for 150 ms.
- The chip resets.
Each step ends on its owner's answer or on its bound, so a silent neighbour never holds the jump. A second restart request during the leave is dropped.
The C5 across the jump
The C5 has its own reset line, so a P4 restart does not reset it. The firmware that leaves writes a handover note into a register of the low-power domain, only after the C5 confirmed it parked. The firmware that starts reads the note at its first link, takes the Wi-Fi association over and erases the note. After a panic, a watchdog or a power cut there is no note, and the C5 is reset to a fresh start.
Specifications
The partition table
The bootloader is at 0x2000 and the table at 0x8000 (CONFIG_PARTITION_TABLE_OFFSET, where the Arduino core also looks for it). The file is components/kbsp/partitions.csv. nvs starts at 0xB000, so the two sectors after the table stay free and a table that grows never moves it.
| Name | Type | SubType | Offset | Size | Holds |
|---|---|---|---|---|---|
nvs | data | nvs | 0xB000 | 0x10000 (64 KB) | Settings of kodeOS and the Arduino Preferences of a sketch |
otadata | data | ota | 0x1B000 | 0x2000 (8 KB) | The two entries that say which slot starts |
phy_init | data | phy | 0x1D000 | 0x1000 (4 KB) | Radio calibration |
factory | app | factory | 0x20000 | 0x400000 (4 MB) | The rescue copy of kodeOS |
ota_0 | app | ota_0 | 0x420000 | 0x400000 (4 MB) | An update slot of kodeOS |
ota_1 | app | ota_1 | 0x820000 | 0x400000 (4 MB) | An update slot of kodeOS |
coredump | data | coredump | 0xC20000 | 0x20000 (128 KB) | The last panic, in ELF format |
apps | data | 0x40 | 0xC40000 | 0xBC0000 (11.75 MB) | Directory, boot request and installed apps (internals/apps-region) |
storage | data | fat | 0x1800000 | 0x200000 (2 MB) | The internal FAT volume, wear-levelled |
app_pending | data | 0x41 | 0x1A00000 | 0x600000 (6 MB) | The mailbox. Never booted from |
From factory to the end of the flash each area starts where the one before ends, up to app_pending at 28 MB. The flash is 32 MB.
| Rule | Why |
|---|---|
apps starts at 0xC40000 and its base never moves | Installed apps keep their addresses across a table change |
app_pending is always at 0x1A00000 | Every host tool and every esptool line quotes the address |
Every image in apps starts on a 64 KB boundary | CONFIG_MMU_PAGE_SIZE_64KB: the MMU maps flash in pages of that size |
Code runs from apps across the 16 MB mark | CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH: the cache maps 32-bit addresses |
coredump sits below 16 MB | The panic handler writes it with the cache off |
| Each slot is 4 MB | A table cannot be changed over the air, so the slots have room to grow |
An image through the mailbox is at most 0x5FF000 bytes, and an OS image also fits its 4 MB slot | The last 4 KB sector of the mailbox holds the intent word |
factory is never an OTA destination | esp_ota_begin() refuses it, so nothing running on the board can overwrite the rescue copy |
The coredump= field of info says whether a dump waits. owner=kodeos means the dump's ELF SHA-256 matches the running image. owner=other is any other dump: a sketch's, another build's, or one this build cannot decode (the Arduino core's libraries checksum with CRC32 and kodeOS checks SHA-256 only, so it prints task=- pc=-). Read a dump with idf.py coredump-info.
The boot request
kapps_req_t is 20 bytes: magic ("KRUN", 0x4E55524B), offset, size, crc, then state. The CRC covers magic to size. state sits after it so the bootloader can advance it with a 4-byte write that only clears bits. The byte formats are on sdk/kdrv/apps.
| State | Word | Written by |
|---|---|---|
EMPTY | 0xFFFFFFFF | Erased flash |
NEW | 0xAAAAAAAA | AO_Boot: the launcher, app_run and install-and-launch. One run |
ALWAYS | 0x55555555 | AO_Boot, for mailbox adoption only. A sticky run, not started yet |
ARMED | 0x00005555 | The bootloader, on the first boot of an ALWAYS request |
TRIED | 0x00000000 | The bootloader, when it retires a request |
The bootloader reads the request and checks, in order, the magic, the CRC (esp_rom_crc32_le), a state of NEW, ALWAYS or ARMED, and the geometry (kapps_req_bounds_ok()). A request that fails the first three is ignored without a write. A request that fails the geometry check is ignored too and stays where it is, so AO_Boot clears it when kodeOS starts. Then one of five things happens:
| Request | What the bootloader does |
|---|---|
NEW | Verify the image, write TRIED, jump |
ALWAYS | Verify the image, write ARMED, jump. The reset cause is not read |
ARMED, and it stands | Verify the image, jump. Nothing is written, so the next reset asks again |
ARMED, and it does not stand | Write TRIED and start kodeOS. No image verification |
Any state, image fails esp_image_verify() | Write TRIED and start kodeOS |
If the write that advances the state fails, the bootloader does not jump. If the array of images it may load is full, it returns to the standard path. The standard loader falls back to the earlier images (ota_1, ota_0, factory) when the jump itself fails.
Resets that keep an ARMED request
The list is closed (reset_keeps_request() is a switch whose default answers no). The numbers are the ROM's soc_reset_reason_t, not the esp_reset_reason_t that kodeOS logs at boot. The request also needs an empty mailbox: the bootloader reads the first word of app_pending and counts a bare image (0xE9) or a .kfw container (0x3157464B) as full, and a failed read counts as full.
| Cause | Value | Who produces it |
|---|---|---|
RESET_REASON_CORE_USB_UART | 0x17 | A host opens the USB-Serial/JTAG port, or esptool's hard reset |
RESET_REASON_CORE_USB_JTAG | 0x16 | OpenOCD through the USB bridge |
RESET_REASON_CPU_JTAG | 0x18 | OpenOCD's reset halt |
RESET_REASON_CORE_SW | 0x03 | OpenOCD's first reset, and the ESP-IDF brownout interrupt, which ends in a software reset |
RESET_REASON_CPU_SW | 0x0C | esp_restart(), a panic's reboot, and every launch and home gesture of kodeOS |
RESET_REASON_CORE_DEEP_SLEEP | 0x05 | A deep-sleep wake |
Any other cause retires the request: power-on (0x01, which BOT and LEFT held together produce), every hardware watchdog, the hardware brownout reset 0x0F, the glitch detectors, a CPU lockup, and a number nobody named. A full mailbox retires it as well, so a second upload works without touching the board.
| What resets the board | One-shot request (NEW, then TRIED) | Mailbox request (ARMED) |
|---|---|---|
| A program opens the port | kodeOS | The same app |
| The debugger resets the chip | kodeOS | The same app |
kode_exit_to_os(), esp_restart(), a panic, BOT held | kodeOS | The same app |
| The ESP-IDF brownout interrupt | kodeOS | The same app |
| A deep-sleep wake | kodeOS | The same app |
| BOT and LEFT together (power-on reset) | kodeOS | kodeOS |
| A hardware watchdog | kodeOS | kodeOS |
| A hardware brownout reset | kodeOS | kodeOS |
| A new image waits in the mailbox | kodeOS | kodeOS |
kodeOS's own OFF state ends in deep sleep too, and its wake is the row of the deep-sleep wake with no request standing: a normal boot. Before AO_Log starts, kode_boot_gate() sends the board back to deep sleep when a button other than BOT woke it (sdk/kaos/lowpower).
AO_Boot
| Signal | What AO_Boot does |
|---|---|
BOOT_OS_SWITCH_SIG | Refuses when the inactive slot is absent or holds no valid image. Otherwise kdrv_otadata_select_slot() verifies the image and writes otadata, then the leave and a reset |
BOOT_APP_RUN_SIG | Writes a fresh request: NEW for KBOOT_RUN_ONCE, ALWAYS for KBOOT_RUN_STICKY. These are the only two states kodeOS writes. If the write fails it logs and does not reset |
BOOT_INFO_REQ_SIG | Publishes BOOT_STATE_SIG and logs the image in the inactive slot |
BOOT_EXIT_REQ_SIG | A plain reset after the leave. Nothing is written |
HOME_SIG and the hold | In an app with CONFIG_KODEOS_HOME_HOLD (default on), BOT held 600 ms publishes HOME_SIG and resets 200 ms later. The app may ask for more time with kode_home_defer(), up to 3000 ms from the hold, and say go with kode_home_now() |
boot_info answers one record: running=<slot> addr=0x... size_kb=<n> otadata=blank|slot reason=cold|app|unexpected app_end=none|clean|panic|watchdog|brownout. After a clean install, running=factory, otadata=blank and app_list answers nothing.
Flash addresses a host tool uses
A host tool contains these as constants and never computes them.
| Address | What | Used by |
|---|---|---|
0x0 | A merged image starts here | Full install |
0x2000 | bootloader.bin | Recovery |
0x8000 | partition-table.bin | Recovery |
0x1B000 | ota_data_initial.bin | Recovery |
0x20000 | kodeos.bin | Recovery |
0xC40000 | apps.bin, the apps region with the welcome app installed | Factory pack |
0x1800000 | storage.bin, the FAT with Kody's pack and the C5 image | Factory pack |
0xC20000 | The coredump partition | Crash read-back |
0x1A00000 | The mailbox | App push, OS update |
0x1FFF000 | The mailbox's last sector, the OS intent word KOTA | OS update through the mailbox |
Four ways to put firmware on the board use these addresses:
| Way | What it does |
|---|---|
| Full install | Erases the whole chip and writes one merged image at 0x0 |
| Recovery | Writes the same four regions with no erase. It brings back the bootloader and factory, blanks otadata, and leaves apps, storage and app_pending alone |
| Push an app | One write to the mailbox. At the next boot AO_AppMgr verifies it, installs it (evicting the oldest app if the region is full, replacing a same-named app) and launches it with a sticky request |
| An OS update through the mailbox | Writes the 4-byte word KOTA at 0x1FFF000 first, then the image. Without the word the image installs as an app |
Nothing infers the intent from the image's bytes, because the author of an image controls its name field. Every consume of the mailbox erases both sectors, so an old word never describes a later upload.
Gotchas
Example code
Push a sketch or an app through the mailbox and come back to kodeOS. Each step ends in something you can see.
- Write the image to the mailbox:
python -m esptool --chip esp32p4 -p <port> write-flash 0x1A00000 app.bin. The board resets, kodeOS adopts the image and the app starts. The glass shows the app. - Open a serial monitor on the port. The port opening resets the board, and the same app starts again, with its console answering.
- Hold BOT and LEFT together for about one second. The board resets as a power-on and the request is retired.
- kodeOS counts the boot as the return from an app, so Apps is on the glass with the app in focus, listed first (
internals/gui/launcher).
TBD: these steps have not been run on a Kode Dot from this page.
In the SDK
bootloader_components/main/bootloader_start.c: the bootloader override, about 300 lines.components/kaos/boot/src/boot_ao.c: AO_Boot, the owner ofotadataand the boot request.components/kbsp/partitions.csv: the partition table.