48б. MCUboot on STM32: two slots, image swap and rollback
In section 48 the bootloader is our own: two areas, a header with CRC-32, the "on
trial" and "confirmed" flags. Section 48а added an ECDSA P-256 signature on
mbedTLS to it. Section 48.11 mentioned ready-made secure update libraries;
here is the most widespread of them, MCUboot. It lives in the Zephyr
v4.4.2 tree (section 34а) as a module of version 2.4.0, together with the signing utility
imgtool.py. This section covers: an F407 flash memory layout for MCUboot, building
the bootloader and the application with one command, a real signed image byte
by byte, the slot trailer with flags, image swap through scratch with a power
loss, and rollback, plus how all this differs from your own bootloader.
Sources: the MCUboot documentation docs/design.md (image format, slots, trailer,
swap types), the sources boot/bootutil/src/bootutil_public.c (the action
selection table) and boot/zephyr/CMakeLists.txt (the reserve at the end of the slot),
imgtool.py 2.4.0, the Zephyr header dfu/mcuboot.h, the datasheet DS8626
(sector erase and flash endurance of the F407). MCUboot and hello_world
were built for the stm32f4_disco board (STM32F407); the image
was parsed and verified with our own code; the action selection and the swap with power loss
were run in a model on the PC. Nothing was run on a board.
Terms used in the documentation
Term Meaning primary slot, secondary slot the main slot and the second slot scratch area an intermediate area for the swap image header the image header TLV (type-length-value) "type, length, value" records after the image image trailer the slot trailer: swap log and flags swap type: test, permanent, revert swap on trial, for good, back image ok, copy done "image confirmed", "copy finished" swap status the swap log sysbuild building several images with one command in Zephyr swap using scratch / move / offset swap through scratch / by moving / with an offset
48б.1 Flash memory layout: slots and scratch
MCUboot always starts the image from the primary slot (slot 0). A new image arrives in the secondary slot (slot 1); to start it, the bootloader swaps the contents of the slots. The previous image is not lost: it ends up in the secondary slot, and you can return to it. The application is built once, for the address of the primary slot, so the difficulty of 48.2 with images for different addresses does not arise here.
The F407 has sectors of different sizes: four of 16 KB, one of 64 KB, then 128 KB each.
Every MCUboot area must start and end on a sector boundary, and
scratch must hold the largest sector that takes part in the swap
(design.md). The layout is set by the devicetree. The stm32f4_disco board has no partitions,
nucleo_f429zi has them; the first megabyte of the F429 is organized the same way, and
the layout was taken from there (f407_parts.overlay):
boot_partition: partition@0 { /* секторы 0-1: загрузчик */
label = "mcuboot";
reg = <0x00000000 DT_SIZE_K(32)>;
read-only;
};
slot0_partition: partition@20000 { /* секторы 5-6: основной слот */
label = "image-0";
reg = <0x00020000 DT_SIZE_K(256)>;
};
slot1_partition: partition@60000 { /* секторы 7-8: второй слот */
label = "image-1";
reg = <0x00060000 DT_SIZE_K(256)>;
};
scratch_partition: partition@a0000 { /* сектор 9: промежуточный */
label = "image-scratch";
reg = <0x000a0000 DT_SIZE_K(128)>;
};
The bootloader got 32 KB, and it took almost all of it: 30308 bytes, 92.49 % (RAM: 18176 bytes). The hello_world application needs 17256 bytes of the 256 KB slot.
48б.2 Build: the bootloader and the application with one command
Zephyr builds both images at once; this is sysbuild. One command
(build48c.sh) produces MCUboot, hello_world and the signed image
zephyr.signed.bin:
west build -p always --sysbuild -b stm32f4_disco -d "$OUT/$v" zephyr/samples/hello_world -- \
-DSB_CONFIG_BOOTLOADER_MCUBOOT=y -D"SB_CONFIG_$mode=y" -DSB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y \
-DSB_CONFIG_BOOT_SIGNATURE_KEY_FILE="\"$HERE/test-ec256.pem\"" \
-Dmcuboot_EXTRA_DTC_OVERLAY_FILE="$HERE/f407_parts.overlay" \
-Dhello_world_EXTRA_DTC_OVERLAY_FILE="$HERE/f407_parts.overlay;$HERE/f407_app.overlay" \
Both images get the layout, and f407_app.overlay only the application:
it places the application code in slot0_partition. MCUboot itself takes its address from
its own app.overlay: boot_partition. The swap mode here is
through scratch (SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH); without this line
Zephyr 4.4.2 selects swap-using-offset. The signature is ECDSA P-256; in the
bootloader it is built on TinyCrypt (CONFIG_BOOT_ECDSA_TINYCRYPT=y), and
the bootloader verifies the primary slot on every start-up (CONFIG_BOOT_VALIDATE_SLOT0=y).
The key is our own test key: imgtool.py keygen -t ecdsa-p256. Without
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE sysbuild would sign the image with the key
root-rsa-2048.pem from the MCUboot repository; its private part is
publicly available, and a signature made with it means nothing.
Sysbuild signs by itself, with a call from the build:
imgtool.py sign --version 0.0.0+0 --header-size 0x200 --slot-size 262144 --align 1.
The build succeeded, but with five MCUboot warnings: it could not find out the sector size
of the slots. For the F4 flash memory, stm32f4.dtsi sets only
write-block-size = <1>, and no erase-block-size, because the sectors differ. The build took the number
of sectors per slot by default, 128, and the sector size as
1 byte, "this is probably wrong". For scratch this is harmless, for
swap-using-move it is not (48б.5).
48б.3 Header and TLV: a real image byte by byte
hello.bin is 17256 bytes, and the first 512 of them are zeros: the application is built with
the offset CONFIG_ROM_START_OFFSET=0x200, so room for the header is left
in advance. imgtool writes the header there, and the TLV records after the body.
Parsing by sign48c.py (fields follow the image_header structure from design.md):
| Field | Value |
|---|---|
ih_magic |
0x96f3b83d |
ih_load_addr |
0x0: executed in place, from the slot |
ih_hdr_size |
0x200 = 512 bytes |
ih_protect_tlv_size |
0: no protected TLVs |
ih_img_size |
0x4168 = 16744 bytes: the body without the header |
ih_flags |
0x0 |
ih_ver |
0.0.0+0 |
The header is 512 bytes, exactly as in 48.3, and for the same reason: the vector table
of the application starts right after it, at 0x08020200. After the body, from
offset 0x4368, comes the TLV area: magic 0x6907 and 151 bytes for three records:
| Type | What | Length |
|---|---|---|
| 0x10 SHA256 | hash of the header and the body | 32 bytes |
| 0x01 KEYHASH | SHA-256 of the public key (91 bytes of DER) | 32 bytes |
| 0x22 ECDSA_SIG | ECDSA P-256 signature in DER | 71 bytes |
Everything was cross-checked with our own code, not only with imgtool: the SHA-256 of the first 0x4368 bytes
matched record 0x10, the public key hash matched 0x01, and the signature was verified
with the cryptography library. A DER signature is not 64 bytes as in 48а: it is two
numbers of 32 bytes each with ASN.1 markup, and the length varies from signature to signature:
71 bytes for this image, 72 for the same body from the move build.
The public key hash in the image is needed by a bootloader that holds several keys: it
finds at once which one to verify with. Now the attempts to cheat, as in 48а.2,
checked with imgtool verify:
| What was done to the image | imgtool verify answer |
|---|---|
| nothing | Image was correctly validated |
| 1 bit of the body changed | Image has an invalid hash |
| version in the header changed | Image has an invalid hash |
| verified with someone else's key | No signature found for the given key |
The header is part of the hash, so the version cannot be replaced: the same rule as in 48а.1.
48б.4 The slot trailer and the decision at start-up
Everything MCUboot remembers between start-ups lives in the trailer at the end
of each slot. For this build (byte-wide writes, BOOT_MAX_ALIGN = 8,
128 sectors) the trailer takes 432 bytes = 0x1B0: a swap log of 128 × 3 bytes,
8 bytes each for the swap size, swap_info, copy_done and image_ok, and a magic of
16 bytes. The same figure 0x1b0 appears in the build output as the size of the update
trailer.
Flags are only ever written on top: an erased byte 0xFF means "no", 0x01 means "yes".
imgtool sign --pad pads the image to the slot size and puts the magic into the
last 16 bytes, the one design.md gives for BOOT_MAX_ALIGN = 8;
with --confirm it also sets image_ok:
imgtool sign --pad: код 0, файл 262144 байт, версия 1.2.3+4; последние 16 байт: 77 c2 95 f3 60 d2 ef 7f 35 52 50 0f 2c b6 79 80
image_ok (−24): 0xff, copy_done (−32): 0xff, swap_info (−40): 0xff
imgtool sign --pad --confirm: код 0, файл 262144 байт, версия 1.2.3+4; последние 16 байт: 77 c2 95 f3 60 d2 ef 7f 35 52 50 0f 2c b6 79 80
image_ok (−24): 0x01, copy_done (−32): 0xff, swap_info (−40): 0xff
At start-up MCUboot compares the trailers of the two slots with the table
boot_swap_tables in bootutil_public.c; the rows are checked in order,
and the first one that fits decides:
| Secondary slot trailer | Primary slot trailer | Action |
|---|---|---|
magic present, image_ok = 0xFF |
any | TEST: swap on trial |
magic present, image_ok = 0x01 |
any | PERM: swap for good |
| any | magic present, image_ok = 0xFF, copy_done = 0x01 |
REVERT: return the previous one |
| otherwise | NONE: start the primary slot |
The application controls this through Zephyr's dfu/mcuboot.h:
boot_request_upgrade(BOOT_UPGRADE_TEST) or BOOT_UPGRADE_PERMANENT
after receiving the image into the secondary slot; boot_write_img_confirmed() after
the new image has tested itself. The model swap48c.py takes the table straight from
the source and runs four cases:
| Case | Start-up 1 | Start-up 2 | Start-up 3 |
|---|---|---|---|
| trial without confirmation | TEST, v2 runs | REVERT, v1 runs | NONE, v1 runs |
| trial, v2 confirmed itself | TEST, v2 runs | NONE, v2 runs | |
for good at once (--confirm) |
PERM, v2 runs | NONE, v2 runs | |
| corrupted v2 signature | FAIL, v1 runs | NONE, v1 runs |
A rollback is a second swap, not an address switch: after REVERT the bootloader
itself sets image_ok for the old image (loader.c does this after REVERT and
PERM), and the third start-up no longer changes anything. An image with a bad signature does not
start at all: the action is FAIL, and the primary image is marked confirmed.
48б.5 Swap: scratch, move and offset
Through scratch every sector goes through three steps (design.md): the sector
of the primary slot is copied to scratch, the sector of the secondary slot to the primary, and scratch
to the secondary. After each step a mark goes into the trailer log; hence three bytes
per sector and 128 × 3 in the trailer. Power can be lost anywhere: the next
start-up sees an unfinished swap (swap_info is set, copy_done is not) and
continues from the marked step. The model cuts the power after every step
of the swap, for an image of one sector (3 steps) and of two (6 steps), and all 3 of 3
and 6 of 6 swaps reach the end with the same result as without a power loss.
The price is erasures. Per sector of the image, a swap costs one erasure each in scratch,
the primary and the secondary slot, plus one in the secondary slot when the image is received. On the F407
erasing a 128 KB sector takes, per DS8626, 1 s typical and 2 s maximum with
32-bit writes, 2 and 4 s with byte writes. Swapping one sector takes 3 erasures:
from 3 to 6 s at x32 and from 6 to 12 s at x8, not counting writes and trailers.
A trial with a rollback is two swaps. MCUboot's own stm32f4.dtsi records
the maximum erase time of a 128 KB sector: max-erase-time = <4000> ms.
A sector's endurance is 10 thousand cycles, and scratch
is erased on every swap: design.md advises estimating the number of updates
as the endurance divided by the ratio of the image size to the scratch size.
design.md itself warns that swap through scratch may eventually be removed from MCUboot.
Swap-using-move does without scratch: it moves the image of the primary slot up by
one sector and swaps the sectors in turn. Swap-using-offset is the opposite:
it keeps a spare sector in the secondary slot; in Zephyr 4.4.2 this is the default
mode, and design.md calls offset preferable to move. Both need
a spare sector, and the trailer is rounded up to a whole sector. With sectors of
128 KB this is expensive; the image limit by the design.md formulas for this layout
(swap48c.py):
| Mode | Image limit in a 256 KB slot |
|---|---|
| scratch | 261712 bytes: the slot minus the trailer |
| offset | 131072 bytes: two sectors minus the trailer rounded up to a sector |
| move | 0: (2 − 1) sectors minus the trailer rounded up to a sector |
So move fits nothing on this layout, and yet the move build
succeeded. It left 583 bytes at the end of the slot instead of 582 for scratch: a "spare sector"
of 1 byte was added to the trailer and the TLV estimate, the very one the
build could not find out (48б.2). With a real sector the reserve would be
262294 bytes, more than the slot. On chips with small pages move and offset
cost one page: on nucleo_f103rb the secondary slot is larger than the primary by exactly
1 KB.
48б.6 MCUboot versus your own bootloader
| Own bootloader (48, 48а) | MCUboot (this build) | |
|---|---|---|
| image verification | CRC-32 (48), ECDSA P-256 + SHA-256 on mbedTLS (48а) | SHA-256 + ECDSA P-256 on TinyCrypt, KEYHASH record |
| header | 32 bytes, padded to 512 | 32 bytes, padded to 512 (--header-size 0x200) |
| "trial" and "confirmed" flags | in the header, cells are written on top | in the slot trailer, image_ok and copy_done |
| where the image runs | in its own area, an image for each address (48.2) | always in the primary slot, one build |
| rollback | start the previous area | reverse swap (REVERT) |
| power loss during an update | copying "must be able to resume": you write it yourself | swap log in the trailer, resume from the step |
| protection against an old version | version comparison after the signature (48а.2) | in software only in overwrite mode (design.md); otherwise a hardware security counter (MCUBOOT_HW_ROLLBACK_PROT) |
| size | program with signature verification: 23028 bytes (48а.4) | the whole bootloader: 30308 bytes |
| image receive channel | your own | the application (Zephyr: dfu/mcuboot.h) or the MCUboot serial port |
Conclusions. MCUboot gives you the hardest parts ready-made: the swap log, rollback and verification that many people have already checked. The price is size (almost all of sectors 0–1 on the F407), swap time on large sectors, and someone else's rules: the header format, the trailer at the end of the slot, the layout in the devicetree. Your own bootloader is justified when you need your own format, your own area scheme (for example, two banks without a swap) or a tiny footprint.
48б.7 Pitfalls
The image is signed with the key from the MCUboot repository. Sysbuild without
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE takes root-rsa-2048.pem, a private
key known to everyone. Use your own key from the start, and keep it as in 48а.5.
The new image works for one start-up, then "rolls back". The application did not
call boot_write_img_confirmed(). This is not a fault but a trial: MCUboot performs
REVERT. Confirm after the self-test, not in the first line of main.
The build succeeded, but the update does not take. Move mode on sectors of 128 KB: the build does not know the sector size, and the reserve is computed for 1 byte (48б.5). On the F4 use scratch, or partitions that provide a spare sector.
The bootloader no longer fits after enabling options. 30308 bytes of 32 KB is almost the limit. Logging, the MCUboot serial port or another signature library will need a larger partition, up to the next sector boundary: 48 KB (0xC000) or 64 KB (0x10000).
The device "hangs" for a few seconds after an update. That is the swap: three erasures of 128 KB per image sector, up to 6 s at x32. The bootloader's watchdog timer must survive this.
The application is built for the wrong address. The code is not in slot0_partition:
MCUboot verifies the signature and jumps to the address from the vector table, where
there is no code. The application needs zephyr,code-partition = &slot0_partition, and
MCUboot boot_partition.
An old signed image is accepted. Software protection against an old version exists in MCUboot only in overwrite mode; in swap modes there is only the hardware security counter. Check the version yourself, as in 48а.2, or enable the counter.
48б.8 Summary
- F407 layout for MCUboot: a 32 KB bootloader (sectors 0–1), slots of 256 KB (5–6 and 7–8), a 128 KB scratch (9). MCUboot took 30308 bytes, 92.49 % of the partition.
- The image: a 512-byte header (magic 0x96f3b83d,
ih_img_size16744), the body, a 151-byte TLV: SHA-256, the key hash, a 71-byte ECDSA signature in DER. The header is part of the hash. - The slot trailer is 432 bytes: a 128 × 3 log, flags and magic. The action at start-up is selected by a table of three rows: TEST, PERM, REVERT, otherwise NONE.
- A trial without
boot_write_img_confirmed()rolls back on the next start-up; after REVERT the old image is marked confirmed. - Swap through scratch resumes from the mark after a power loss: in the model 3 of 3 and 6 of 6 power cuts. A 128 KB sector takes three erasures, 3–6 s at x32.
- On 128 KB sectors the image limit is: scratch 261712 bytes, offset 131072, move 0, although the move build succeeds.
Next: section 48в "Delta update: sending only the changes in the firmware" — instead of the whole image, only the difference goes over the radio: format, patching, power loss.