Custom second-stage bootloader for the Xteink X3/X4 e-readers (ESP32-C3). Provides SD-card recovery as a standalone path that does not depend on any app, useful for devices whose vendor has burned DIS_USB_SERIAL_JTAG.
Hold the configured page button + POWER for 500 ms at boot: the
bootloader reads UPDATE.BIN from the FAT/exFAT root of an inserted SD card,
validates it, and flashes it to the inactive OTA slot. The strap button
is build-time configurable via CONFIG_CP_STRAP_BUTTON_PREV /
_NEXT (default: NEXT). Without the strap, behavior is identical to
the IDF stock bootloader.
If a PARTS.BIN is also present at the FAT/exFAT root, recovery installs
it to 0x8000 first (when it differs from the live PT). Pair it with a
matching UPDATE.BIN to recover devices whose third-party firmware
overwrote the partition layout.
X3 (left) and X4 (right) at the end of a dry-run recovery. The QR encodes CROSSBOOT/DRY-RUN — phones scan it as plain text.
▶ Watch a full recovery + boot
- Hardware-validated end-to-end on X3 + X4 (2026-05)
- 32 KB bootloader budget · 4 KB headroom · 240 B max stack frame
- CBMC bounded model check proves no UB on
cp_part_validate's bounds / uint32-wrap / range logic,cp_image_validate's entry guard, andcp_fat_mount's parse path - libFuzzer (ASAN+UBSAN+MSAN) on the same three parsers; zero crashes
- 4 static analyzers + 6 unit-test suites running clean (including real-SHA finale, fault-injection atomicity)
ESP-IDF 5.5.2. Recipes in Justfile.
just backup # save vendor bootloader (do this once, before anything)
just build-debug # debug: console + log lines, recovery defaults to dry-run
just build # production: silent, real flash writes
just flash-debug # flash debug
just flash-prod # flash production
just monitor # USJ monitor; `just watch` for aggressive reconnect
Building from source, running host-side tests, static analysis: see DEV_SETUP.md.
bootloader/bootloader_components/main/ our component
bootloader_start.c entry, strap gate, recovery dispatch
cp_strap.{c,h} POWER + ADC ladder strap detection
cp_recovery.{c,h} SD -> validate -> flash -> otadata
cp_image.{c,h} streaming ESP image validator
cp_sd.{c,h} register-level SD-SPI
cp_fat.{c,h} minimal read-only FAT16/32/exFAT
cp_ota.{c,h} inactive-slot pick + otadata commit
cp_part.{c,h} optional PARTS.BIN install at 0x8000
cp_epd.{c,h} SSD1677 (X4) + UC8xxx-class (X3)
cp_spi.{c,h} SPI2 primitives, shared by sd/epd
cp_i2c.{c,h} bit-bang I2C probe (X3 detection)
ld/bootloader_main_relocate.ld relocates libmain.a to iram_loader_seg
tests/ host-side fuzz, unit tests, CBMC, lint
shared/partitions.csv matches stock pio + crosspoint-reader; sourced from crosspoint
Things not obvious from reading the code.
CP_DEBUG_RECOVERY_DRY_RUN defaults to y in Kconfig. Production
sdkconfig.defaults has to explicitly disable it. Without that
override, fresh sdkconfig regeneration leaks the dry-run default into
prod and recovery never writes flash.
cp_recovery_run is noreturn. Every exit hits
bootloader_reset(). By the time recovery runs we've reconfigured
SPI2 (cp_sd_init and cp_epd_init both reset the peripheral); falling
through to bootloader_utility_load_boot_image from that state
hangs. Side effect: a strap-held boot with no UPDATE.BIN reset-loops
until the user releases POWER.
EPD polarity is per-device. X4 byte 0xFF = white pixel. X3
byte 0x00 = white pixel (SDK inverts before sending). cp_epd
exposes logical WHITE/BLACK and translates.
Prod isn't fully silent during a recovery. cp_recovery.c's
[cp] recovery: ... lines are unconditional esp_rom_printf (not
behind any CP_DEBUG_*), so they fire in every build — but only on a
recovery pass, which is strap-gated, so a normal boot stays silent.
Deliberate: recovery is the one flash-mutating op and should leave a
trace if a serial cable is attached. The [cp] gate: / [cp] strap:
lines, by contrast, are CP_DEBUG_STRAP-gated (debug builds only).
RVC compressed-jump errors are non-monotone. Don't try to shrink
your code to make R_RISCV_RVC_JUMP truncated to fit go away. Two
link-time fixes pin it open: -Wl,--no-relax in CMakeLists and
ld/bootloader_main_relocate.ld (INSERT BEFORE .iram.text) routing
libmain.a:cp_*.* into iram_loader_seg (28 KB) instead of the 10 KB
iram_seg. Both must stay.
I2C probe pins are dual-use on X4. GPIO20 is U0RXD on X4; GPIO0 is the battery voltage divider. The probe drives them LOW briefly during start/stop and releases via internal pullups; nothing ACKs on X4 so we fall through cleanly.
Strap intent re-check matters for deep-sleep wake. POWER is read
once before bootloader_init and again ~few hundred ms later. A
brief wake tap is released by the second read; a deliberate recovery
hold is still down. Without the re-check, every wake would burn
~1 sec on strap detection.
Bootloader budget is 32 KB. Region is fixed at 0x0000..0x8000.
~4 KB headroom on each build today.
Both X4 and X3 are hardware-validated end-to-end (X3 as of 2026-05):
the full recovery path runs on real X3 hardware — board detect
(cp_board_detect I2C probe), the GPIO13 SD power gate,
cp_epd_init_x3 + cp_epd_refresh_x3 (the progress bar and the
failure/QR screens both render correctly), cp_image_validate over a
~5.8 MB image, a real OTA-slot erase + write + cp_ota_commit, then a
clean reboot into the freshly-written slot.
The X3-specific code is just the display path plus board detect / the
GPIO13 SD-power gate — all three exercised above. Everything else
(cp_sd, cp_fat, cp_ota, cp_part, cp_image, cp_recovery's
orchestration) is device-agnostic and validated on X4. So cp_part_install
(the PARTS.BIN path) and the otadata round-trip aren't X3 risks —
cp_part_install just wasn't triggered in the X3 run (no PARTS.BIN
on the card; it's the same bootloader_flash_write code, to 0x8000
instead of an OTA slot), and the otadata toggle has gone slot 0 → 1
once but not round-tripped. Both are worth a confirming pass but
aren't X3-specific risks.
The X3 EPD controller is a UC8xxx-class part, not the SSD1677 the
X4 uses. What that means for cp_epd:
- BUSY is active-LOW (pin reads 0 while busy), the opposite of X4 —
cp_epd_wait_busykeys offcp_epd_device. - No sub-region addressing in the update path: every refresh
streams the whole 528×792 frame (~760 ms, ~84 ms of SPI for both
planes), so
cp_epdaccumulates a list of pending fill rects (≤512, enough for a v1 QR's 441 modules) and synthesizes the frame from white + those rects at refresh time. RED-RAM writes are no-ops since we always full-sync.
Init sequence, LUTs, and pixel polarity (byte 0x00 = white on X3)
were transcribed from the open-x4-sdk's EInkDisplay.cpp X3 branch.
crossboot is a recovery bootloader for devices the vendor has locked down via DIS_USB_SERIAL_JTAG. It assumes the user is the operator — your device, your firmware, your SD card. The job is "don't brick the device because of a mistake in the package or a power cut mid-flash," not "defend against an adversary who chose what's on the SD card."
Catches:
- A malformed
PARTS.BINthat would route the install at the bootloader region (0x0..0x8000), the PT region (0x8000..0x9000), or past flash.cp_part_validaterejects offsets outside[0x9000, flash_size), uint32-wrappingoffset + size, and any partition pair that overlaps. - A live PT that didn't come through
cp_part_validate(stock vendor table, or one flashed outside crossboot).cp_ota_pick_inactiveapplies the same range check to the slot it returns so a bad live PT can't redirect theUPDATE.BINwrite at the bootloader. - A malformed
UPDATE.BIN: bad magic, truncated, segment past EOF, wrong declared size, wrong XOR checksum, wrong SHA.cp_image_validatestreams the whole file once for validate, again for write; a bad image never reaches flash. - Power loss anywhere in the recovery pass — within
cp_part_install, between PT install and OTA write, mid-OTA-write, or duringcp_ota_commit. Auto-recovery on next boot if the PT was the casualty. See Safety for the ordering invariants. - A slow or hostile SD card. Every blocking op has a timeout; WDT is fed every 4 KB during write and every 32 KB during validate.
- Corrupt otadata. Both entries invalid → recovery triggers again on next boot rather than booting garbage.
Does not catch, by design:
- Rollback to older firmware. No version tracking, no anti-rollback eFuse use.
- Flash encryption / secure boot. Deliberately declined — both have permanent eFuse implications.
- An adversary with sustained physical access. ESP32-C3 has no hardware glitch detection; voltage-glitch attacks against the strap gate are out of scope for software.
- A malicious bootloader replacement via the app's bootloader-update
path. Once the app can write
0x0..0x8000, it's part of the trusted base; the app is responsible for validating any candidate bootloader before writing. - A physically failed flash chip. Take the backup before the first
flash;
just restoreis the way back.
One-shot bootloader: replaces the vendor's on first install, then every boot runs our code.
cp_ota_commitonly writes the non-active otadata sector. Power loss during commit leaves the device booting the old slot.cp_part_install(PARTS.BIN path) erases the new otadata sectors before publishing the new PT, so power loss never leaves the new layout reading stale entries from the previous tenant. A power loss during the PT-erase or PT-write step itself leaves the PT sector all-0xFF; the bootloader detects this on next boot and auto-routes to recovery without the strap (the device is already non-bootable, so installing a fresh PT is non-destructive vs the status quo). PT repair needsPARTS.BINon the SD; without it, the operator sees aNO-PARTSQR. The strap stays the gate forUPDATE.BINwrites. PT install + UPDATE.BIN write run in the same recovery pass; if the pass is interrupted between them, next boot's recovery picks up cleanly from the new (empty) otadata.- Every loop in
cp_*.cis bounded; every blocking SD command has a timeout;cp_recovery_runis noreturn so no exit falls through into a hang. WDT fed every 32 KB during validate, every 4 KB during write, every 250 ms during EPD waits. cp_image_validate,cp_fat, andcp_part_validateare libFuzzer- fuzzed (ASAN+UBSAN viajust fuzz, MSAN on Linux x86_64). CBMC bounded-model-check (just verify) proves no UB (bounds, pointer, signed/unsigned overflow, undefined shift) on three scopes: the bounds / wrap / range logic ofcp_part_validate(the function the threat model leans on for malicious PARTS.BIN rejection),cp_image_validate's entry guard, andcp_fat_mount's parse path. The parser bodies past those CBMC scopes are covered by libFuzzer (line coverage cp_image 93% / cp_part 55% / cp_fat 98%; cp_part_install drags cp_part's file figure, the parser itself is at 100%) and by directed unit tests (just test):cp_ota_pick_inactive's slot/seq math, pinned-case boundaries for the four parsers,cp_image_validate's SHA-256 finale against a real SHA reference (catches stub-masked bugs), and thecp_part_installordering /cp_ota_commitisolation invariants via a fault-injecting flash model. Recovery's pass-2 write re-computes SHA-256 over the bytes actually flashed and aborts beforecp_ota_commiton mismatch with pass 1 — closes the SD-card TOCTOU window.cp_image_validatehas a 16 MB hard cap and overflow-safe bounds.cp_part_validaterejects partitions outside[0x9000, flash_size)(catches uint32 wrap too) and tables where any two partitions overlap.cp_ota_pick_inactiveapplies the same range check to the slot it returns from the live PT — the live PT may not have come throughcp_part_validate(stock vendor table, manual flash). APARTS.BINthat flunks either would otherwise pointcp_part_install's erase or theUPDATE.BINwrite at our own bootloader image (0x0..0x8000), or alias one slot over another.-Werror -Wshadow -Wcast-align -Wpointer-arith -Wstrict-prototypesoncp_*.c. cppcheck and clang--analyzeclean.
This bootloader is built and flashed independently. The crosspoint-reader app side needs two changes to coexist with it:
-
Make
pio runflash crossboot, not the IDF stock bootloader. PlatformIO writes whatever bootloader its toolchain built at offset 0 every upload. To swap that for crossboot: dropcrossboot.bin(a copy ofbootloader/build/bootloader/ bootloader.bin, or a CI-fetched artifact) at the project root, then add apost:SCons hook that overwrites$BUILD_DIR/bootloader.binwith it before upload:# scripts/use_crossboot.py Import("env") import shutil from os.path import isfile, join CROSSBOOT = join(env.subst("$PROJECT_DIR"), "crossboot.bin") TARGET = join(env.subst("$BUILD_DIR"), "bootloader.bin") def _swap_in_crossboot(source, target, env): if not isfile(CROSSBOOT): raise FileNotFoundError(f"crossboot.bin missing at {CROSSBOOT}") shutil.copyfile(CROSSBOOT, TARGET) env.AddPostAction(TARGET, _swap_in_crossboot)
Wire it in
platformio.iniwithextra_scripts = post:scripts/use_crossboot.py(must bepost:so the framework's bootloader Command is registered when the hook attaches). Without this, everypio runreverts the device to the stock bootloader and crossboot is gone until youjust flash-prodagain. -
Add a "flash bootloader from file" path in the app. The app needs to take a file (downloaded over network, copied from SD, etc.) containing a crossboot bootloader binary and write it to offset
0x0..0x8000at runtime. This is the upgrade path for crossboot itself once installed; without it, the only way to update crossboot is to physically reflash via USJ. Validate the candidate (header magic, size <= 32 KB, sane shape) before erasing the existing bootloader; the write window is the only brick-able operation in normal app flow.
just restore flashes back the vendor backup if USJ enumerates. If
the device has DIS_USB_SERIAL_JTAG fused and the bootloader bricks
itself, there is no software recovery. Don't ship without
end-to-end testing.

