Embedded HandbookApps
All sections › VI. Practice
STM32F407 · level intermediate · 14 min read
Open in the interactive handbook

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)>;
        };

STM32F407 flash memory for MCUboot: a 32 KB bootloader in sectors 0–1, 256 KB slots in sectors 5–6 and 7–8, a 128 KB scratch in sector 9; three steps of swapping a sector through scratch; slot 0 from the inside: a 512-byte header, a 16744-byte body, a 151-byte TLV and a 432-byte trailer

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

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.

Open in the interactive handbook