embassy-boot-ch32
embassy-boot support for WCH CH32
microcontrollers driven by
ch32-hal: a NorFlash adapter, a
linker-script driven partition map, and a pair of bootloader / application
examples that build for 20 parts across 5 chip families. Tested on real hardware
on the CH32V305RBT6.
The examples default to the
nanoCH32V305 board: its status LED is
on PA3, active low, and both consoles live on USART1 (PA9 TX /
PA10 RX) at 115200 8N1. Both are one-line changes in src/main.rs for
another board.
The crate provides the glue that embassy-boot needs on top of ch32-hal:
CoarseFlash— wraps the 256 byte page flash driver so embassy-boot sees a coarser (and much cheaper) erase granularity,- partition accessors generated from the linker script symbols
(
__bootloader_active_start,__bootloader_dfu_start,__bootloader_state_start), BootLoader::load()— jumps from the bootloader into the active partition,system_reset()— a software reset through the QingKe PFIC.
Supported chips
One cargo feature per part (the lower case part number, same spelling as in
ch32-hal); build.rs of each example resolves it to a partition map and to
the target the part needs.
| family | core | target | parts | partition map |
|---|---|---|---|---|
| CH32V203 | QingKe V4B, no FPU | riscv32imc-unknown-none-elf |
C8T6, C8U6, F8P6, F8U6, G8R6, K8T6 | flash64k-ram20k.x |
| CH32V208 | QingKe V4C + Bluetooth, no FPU | riscv32imc-unknown-none-elf |
CBU6, GBU6, RBT6, WBU6 | flash128k-ram64k.x |
| CH32V303 | QingKe V4F | riscv32imfc-unknown-none-elf |
CBT6, RBT6 (128 KiB), RCT6, VCT6 (256 KiB) | flash128k-ram32k.x, flash256k-ram64k.x |
| CH32V305 | QingKe V4F, USB OTG HS | riscv32imfc-unknown-none-elf |
FBP6, GBU6, RBT6 | flash128k-ram32k.x |
| CH32V307 | QingKe V4F, Ethernet | riscv32imfc-unknown-none-elf |
RCT6, VCT6, WCU6 | flash256k-ram64k.x |
The canonical list lives in examples/chips.rs, which both
build.rs files include!, so the two binaries can never disagree about the
geometry. tools/build_matrix.sh --list prints it.
Not (yet) supported
A runtime bootloader needs a working flash driver, and ch32-hal only has one
for the flash IP version v3 — the CH32V2 and CH32V3 lines. For every other
version ch32-hal selects a stub (src/flash/mod.rs picks v3.rs under
cfg(flash_v3) and other.rs, where every operation is unimplemented!(),
otherwise):
| family | flash IP version |
state |
|---|---|---|
| CH32V103 | v1 |
no driver upstream |
| CH32V003, CH641 | v0 |
no driver upstream (16 KiB flash / 2 KiB RAM, too small anyway) |
| CH32X033, CH32X035 | x0 |
no driver upstream |
| CH32L103 | l1 |
no driver upstream |
Two further exclusions:
- CH32V203RBT6 is left out even though its flash IP is
v3. It is the only CH32V203 part with a 32-bit general purpose timer (TIM5, aGPTM32inch32-metapac), andch32-haldoes not compile for such a part outside CH32V208/CH32L1:TimerBits::Bits32is#[cfg(any(ch32l1, ch32v208))]while theforeach_interrupt!arm fortimer, GPTM32, UPuses it unconditionally. - 32 KiB flash parts (CH32V203C6T6/F6P6/G6U6/K6T6 and friends) are too small for a 16 KiB bootloader plus two images plus a state partition.
Layout
.
├── src/lib.rs the adapter crate
├── build.rs rejects `log` and `defmt` at the same time
├── partition-map/
│ ├── flash64k-ram20k.x one map per (flash, RAM) geometry
│ ├── flash128k-ram32k.x
│ ├── flash128k-ram64k.x
│ └── flash256k-ram64k.x
├── examples/
│ ├── chips.rs chip -> map -> target table, included by both build.rs
│ ├── bootloader/ blocking bootloader with a serial console
│ └── application/ embassy application with mark_booted()/mark_dfu()
├── tools/
│ ├── serial_update.py host side of the serial update protocol
│ ├── check_size.sh partition fit + entry point guard
│ ├── check_partitions.py validates the partition maps statically
│ └── build_matrix.sh builds (and checks) every supported chip
└── .github/workflows/ci.yml GitHub Actions CI
Both examples are standalone cargo workspaces (they build for a different target
and use a different memory.x than the crate). Neither carries a memory.x in
the repository: build.rs generates one that INCLUDEs the partition map
selected by the chip feature and aliases FLASH to BOOTLOADER (bootloader) or
ACTIVE (application), so the two binaries can never disagree about where the
partitions are.
Partition maps
Maps are named after the nominal application flash and the SRAM they are written
for, because several parts share one geometry. All sizes are multiples of the
8 KiB erase granularity embassy-boot sees (see below), and the DFU partition is
always at least one 8 KiB block larger than ACTIVE, as the swap requires a
spare block.
| map | flash | RAM | BOOTLOADER |
ACTIVE |
DFU |
BOOTLOADER_STATE |
|---|---|---|---|---|---|---|
flash64k-ram20k.x |
64 KiB | 20 KiB | 16 KiB 0x0800_0000 |
16 KiB 0x0800_4000 |
24 KiB 0x0800_8000 |
8 KiB 0x0800_E000 |
flash128k-ram32k.x |
128 KiB | 32 KiB | 16 KiB 0x0800_0000 |
48 KiB 0x0800_4000 |
56 KiB 0x0801_0000 |
8 KiB 0x0801_E000 |
flash128k-ram64k.x |
128 KiB | 64 KiB | 16 KiB 0x0800_0000 |
48 KiB 0x0800_4000 |
56 KiB 0x0801_0000 |
8 KiB 0x0801_E000 |
flash256k-ram64k.x |
256 KiB | 64 KiB | 16 KiB 0x0800_0000 |
104 KiB 0x0800_4000 |
120 KiB 0x0801_E000 |
16 KiB 0x0803_C000 |
tools/check_partitions.py validates every map in partition-map/ (contiguity,
page alignment, the embassy-boot state capacity inequality, total size, RAM size
taken from the file name) and runs in CI.
Why CoarseFlash and why an 8 KiB state partition
ch32-hal exposes the internal flash with WRITE_SIZE = ERASE_SIZE = 256
bytes. embassy-boot's assert_partitions requires
2 + 4 * (active_size / PAGE_SIZE) <= state_capacity / STATE::WRITE_SIZE
With a 256 byte page size and a 48 KiB active partition that would need
2 + 4 * 192 = 770 slots of 256 bytes, i.e. ~193 KiB of state on a 128 KiB
chip. Raising the erase granularity that embassy-boot sees to 8 KiB brings the
requirement down to 2 + 4 * 6 = 26 slots, i.e. 6.5 KiB, which fits into a
single 8 KiB block.
CoarseFlash<Flash, 8192> implements NorFlash with ERASE_SIZE = 8192 by
erasing 32 hardware pages per erase() call. The cost is that a swap erases
whole 8 KiB blocks instead of only the 256 byte pages it overwrites; the benefit
is that the swap can be interrupted by a power loss at any time and still be
resumed or reverted.
Other constraints that shaped the table above (all checked at compile time by
assert_partitions, and statically by tools/check_partitions.py):
active_size % 8192 == 0,dfu_size % 8192 == 0,dfu_size - active_size >= 8192(the swap needs one spare block),- the bootloader's copy buffer (1024 bytes) must divide the page size and be a
multiple of
WRITE_SIZE.
Building
Prerequisites: a Rust nightly with rust-src (both examples pin it in
rust-toolchain.toml; build-std is used because
riscv32imfc-unknown-none-elf is a JSON target spec), and optionally WCH's
wlink command line tool (shipped with MounRiver Studio) for flashing.
# the adapter crate (host target, only checks that it compiles)
# the bootloader and the application for the default chip (CH32V305RBT6)
&&
&&
For another part, pick its feature and the target it needs (the table above,
or tools/build_matrix.sh --list):
build.rs refuses to continue if the feature and the target disagree, so a
--target mistake is a short error message instead of a link failure. The
output binaries are called bootloader and application.
To build (and size check) everything:
tools/check_size.sh checks that a binary still fits its partition and that
its entry point is where the partition map expects it. Both the limit and the
expected entry point are derived from the __bootloader_active_start /
__bootloader_active_end symbols in the ELF itself, so the numbers are never
duplicated outside the map:
)
)
--role application uses the ACTIVE partition instead. The script uses the
llvm-tools of the active toolchain (rustup component add llvm-tools) and can
be pointed at another tool with $LLVM_SIZE, $LLVM_NM, … If the bootloader
does not fit anymore, either shrink it or move ACTIVE/DFU up in the
partition map it uses.
Flashing
The first time, flash both binaries: the bootloader at 0x0800_0000 and the
application at 0x0800_4000 (where it is linked). A blank state partition reads
as 0xFF, which embassy-boot treats as State::Boot, so the bootloader will
simply boot whatever is in ACTIVE.
# with wlink (the `cargo run` runner does the same thing)
A nanoCH32V305 can also be programmed over its USB1 port without a debugger,
with wchisp: hold BOOT, press and release
RST, release BOOT, then
wchisp understands ELF and programs each segment at its linked address, so the
application lands in ACTIVE without an intermediate .bin.
# raw binary, if your tool wants a .bin
# (`llvm-objcopy`, or `rust-objcopy` after `rustup component add llvm-tools`)
From then on the application can be updated over the serial console without a debugger.
Serial console
USART1, 115200 8N1, PA9 = TX, PA10 = RX (3.3 V levels).
Within 3 seconds after a reset any key press holds the board in the bootloader; otherwise the active partition is booted immediately.
b / Enter boot the active partition
i print the partition map and the bootloader state
d / u start an update session
? / h help
Update protocol
An update session speaks a trivial line + raw data protocol:
- the host sends
f <hex length>followed by CR or LF, e.g.f a800, - the host sends exactly that many raw bytes of the application image,
- the bootloader acknowledges every 256 byte chunk after it has been written
to flash with a single
.(with aN / M bytesprogress line every 16 KiB), and the host must wait for that acknowledgment before sending the next chunk: the USART has no FIFO and flash programming takes milliseconds, so a host that just streams will overrun the receiver, - after the last chunk the image is marked updated, the session ends, the
bootloader swaps
DFUintoACTIVEand boots it.
The tail of the last chunk is padded with 0xFF, which is what erased flash
reads as.
The image is a raw binary, not an ELF: the bootloader copies the bytes
straight into the DFU partition, which is linked to run at 0x0800_4000.
A host script (Python + pyserial) is
provided:
It resets the board over RTS (wire RTS to RESET, otherwise reset it by hand),
holds the bootloader during the grace window, and then stops and waits for the
. acknowledgment after every 256 byte chunk.
Rollback
The application is only trusted once it calls
mark_booted(). Until then the state
partition keeps the Swap magic and the bootloader reverts the image on the next
reset, so a bad image cannot brick the board:
mark_updated()writes theSwapmagic,- the bootloader swaps and boots the new image, state stays
Swap, - the new application runs its self-check and calls
mark_booted(), - if it resets or hangs before that, step 2 is undone and the previous image boots again.
An application can also ask to stay in the bootloader by calling mark_dfu()
and resetting (d in the example application). mark_dfu() does not touch the
active image; it only makes the bootloader wait for a new one.
Caveats
- Only the CH32V2/V3 flash controller is implemented upstream. Everything
above the line "Not (yet) supported" is a limitation of
ch32-hal, not of this crate: the adapter itself is chip agnostic. ch32-metapacover-reports flash size. It reportsFLASH_SIZEas the size of the largest member of the family plus its extraUSR_2region (480 KiB for a 128 KiB CH32V305RBT6), and the flash driver bounds its checks with that constant. Nothing will stop a partition map that runs past the physical flash; keeping the map inside the part's nominal flash is our responsibility, which is whattools/check_partitions.pychecks.- Option bytes. This example assumes the factory configuration: the chip
boots through the on-chip monitor ROM into the user flash at
0x0800_0000. If you have used WCHISPTool orwlinkto change the boot configuration (boot from RAM, read protection, etc.), restore the defaults before testing, or the reset vector will not land in the bootloader. ch32-halneeds itsembassyfeature to build today: its USB modules callembassy_timeunconditionally. The bootloader example is plain blocking code and does not use an executor or a time driver, but it still has to enable the feature.- No
defmtfor now.ch32-halpinsdefmt0.3 whileembassy-bootusesdefmt1.x, so thedefmtfeature of this crate cannot be combined withch32-hal/defmt. The examples therefore uselog. loganddefmtare exclusive. Cargo cannot express that in[features], sobuild.rsturns the combination into a build error (cargo::error=, Rust 1.84+) instead of letting it fail deep insideembassy-boot.SDIPrintcan hang without a debugger.ch32_hal::debug::SDIPrintspins on a busy flag that only a connected WCH-Link clears, so all normal output in both examples goes to USART1.- The bootloader's panic handler deliberately does not print the panic message
or location: formatting a
PanicInfopulls incore::fmt's debug and unicode tables, which cost ~12 KiB here. Rebuild with a debug profile if you need the details.
Porting to another chip
If the part has the v3 flash IP, adding it is three steps:
- pick the geometry: reuse a map in
partition-map/, or add one for the new(flash, RAM)pair and make suretools/check_partitions.pyis happy with it, - add a
Chip { part, map, target }entry toexamples/chips.rsand ach32XXXX = ["ch32-hal/ch32XXXX"]feature to both examples'Cargo.toml, - adjust the LED pin and the UART instance/pins in the examples if the board differs from a nanoCH32V305.
tools/build_matrix.sh --chip <part> then builds and size checks both binaries,
and CI picks the part up automatically (its matrix is generated from
examples/chips.rs).
For a part with a different flash controller the real work is upstream: a driver
in ch32-hal for that flash IP version.
Continuous integration
.github/workflows/ci.yml runs on pushes to main,
on pull requests and manually:
| job | toolchains | what it does |
|---|---|---|
crate |
stable, nightly | cargo fmt --check, cargo clippy --all-targets -D warnings, the same for --features log and --features defmt separately, cargo doc with RUSTDOCFLAGS=-D warnings, python3 tools/check_partitions.py |
chips |
— | turns examples/chips.rs into the chip matrix for the next job |
firmware (one job per part) |
nightly | tools/build_matrix.sh --chip <part>: builds the bootloader and the application for the target that part needs, checks both with tools/check_size.sh, uploads dist/<part>/*.bin as firmware-<part> |
examples-lint |
nightly | cargo fmt --check and cargo clippy --release -D warnings for both examples (default part) |
A few things worth knowing:
--all-featuresis never used, becauseloganddefmtare mutually exclusive: the two back ends are separate permutations. One step of thecratejob does runcargo check --all-featuresand requires it to fail with the exclusion message frombuild.rs, so the guard cannot rot away.- Everything runs with
--locked. The examples depend onch32-halfrom git, so the committedCargo.lockis what pins the exact upstream revision; update it on purpose withcargo update -p ch32-haland commit the new lock. - No partition sizes or entry points are duplicated in the workflow: the limits
come from the ELF symbols and the chip list from
examples/chips.rs. - The
firmwarejobs cache only the downloaded registry and git checkouts (cache-targets: false): every part is a different feature set, so cachingtarget/would store 20 near-identical trees. - The nightly leg exists to catch new lints early, which also means it can go
red on clippy churn that is not a real problem; pin a known-good nightly in a
rust-toolchain.tomlif that bothers you.
The badges above (and the workflow itself) start working with the first push to GitHub. The docs.rs badge turns green once docs.rs has built the published version.
License
MIT OR Apache-2.0.