The code for this project is available here.
Note: Zephyr’s CMake or west manager will look after almost all of this for you.
I started with the Interrupt article here on porting MCUBoot to a bare-metal project, ported it to a CMake project and added the Renode and VSCode scripts, building on my previous post: Renode Docker Setup on Ubuntu 24.04.
I changed the Makefile with CMake, as it’s easier to manage. There’s now one main CMakeLists.txt and separate ones for the bootloader and the application.
Configuration and building is done from the root directory:
cmake --build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake ./
cmake --build ./
The bootloader build is just a regular project, but the application needs to be packaged in the way MCUBoot expects (see the MCUBoot docs: docs/design.md).
This results in 3x application images:
The nrf52_application.bin binary is packaged with the required metadata and is ready to load, thanks to the MCUBoot python tool.
If --pad
is not included, the magic number check will fail.
Adding --erased-val 0xff
pads with the correct “empty data” value, though any value will work.
Some people like to write test values like 0xDE
or similar so unintended clears and partial
writes can be detected in a hexdump.
# Custom command to create binary
add_custom_command(
OUTPUT ${BUILD_DIR}/${PROJECT_NAME}.bin
COMMAND ${CMAKE_OBJCOPY} -O binary ${BUILD_DIR}/${PROJECT_NAME}.elf
${BUILD_DIR}/${PROJECT_NAME}.bin
DEPENDS ${PROJECT_NAME}.elf)
# Custom command to sign MCUboot image
set(IMGTOOL_PY ${ROOT_DIR}/external/mcuboot/scripts/imgtool.py)
add_custom_command(
OUTPUT ${BUILD_DIR}/${PROJECT_NAME}-signed.bin
COMMAND
python3 ${IMGTOOL_PY} sign --header-size 0x200 --align 4 --slot-size 0x20000
-v 1.0.0 --pad-header --erased-val 0xff --pad
${BUILD_DIR}/${PROJECT_NAME}.bin ${BUILD_DIR}/${PROJECT_NAME}-signed.bin
DEPENDS ${BUILD_DIR}/${PROJECT_NAME}.bin)
This does not sign the image or encrypt it but those are both options and recommended as the image check is only done with the magic numbers in the trailer. MCUBoot lets you have multiple keys which you can use to do useful things like prevent production systems from running development builds while letting development systems run both.
Renode uses a .repl
file to define devices and specify the position and type of peripheral.
The following line places an NRF5280_UART peripheral at memory location 0x40002000.
uart0: UART.NRF52840_UART @ sysbus 0x40002000
easyDMA: true
-> nvic@2
The UART.
part specifies
the location of the peripheral in the repo.
Renode searches for the matching peripheral in renode/src/Infrastructure/src/Emulator/Peripherals/Peripherals/UART
.
If we look for in-application programming (IAP) or just flash, the closest we get is:
flash: Memory.MappedMemory @ sysbus 0x0
size: 0x100000
ram: Memory.MappedMemory @ { sysbus 0x800000; sysbus 0x20000000 }
size: 0x40000
NOTE: the two RAM locations exposes the same memory at different locations, as the MCU has ‘Data RAM’ and ‘Code RAM’ which point to the same memory.
The flash ROM is handled in the same way as the RAM so calls to the IAP peripheral will be ineffective but writes should work like RAM. This quick test confirms this:
uint32_t v = 0xdeadbeef;
uint32_t a = 0x28000;
uint32_t* flash_a = (uint32_t*)a;
flash_a[0] = v;
assert(flash_a[0] == v);
This means we need to define the peripheral. Fortunately there’s already one available here.
The Renode setup is shockingly straightforward.
If you’re researching the topic you’ll likely come across discussions on the loading order of elf files. In this setup, we create a binary of the application and load it into machine at the specified location so the order doesn’t matter. The location we load it into (0x8000/0x28000) must match the linker script location however.
MCUBoot finds the start of the image using APPLICATION_PRIMARY_START_ADDRESS
which is defined in common/src/mcuboot_port.c
, obviously these all have to agree.
:name: NRF52840 Application Loading
:description: Bootloader project demo on NRF52840.
using sysbus
$name?="NRF52840"
$bootloader_elf?=@bootloader/nrf52_bootloader.elf
$application_0_bin?=@application/nrf52_application-signed.bin
# Create Machine & Load config
mach create $name
include @https://raw.githubusercontent.com/snhobbs/renode-nrf52840_flash/refs/heads/main/nrf52840_nvcm.cs
machine LoadPlatformDescription @renode-platforms/boards/nrf52840dk_nrf52840.repl
machine LoadPlatformDescription @https://raw.githubusercontent.com/snhobbs/renode-nrf52840_flash/refs/heads/main/nrf52840_flash.repl
emulation CreateServerSocketTerminal 2345 "term"
connector Connect sysbus.uart0 term
# Enable GDB
machine StartGdbServer 3333
macro reset
"""
sysbus LoadELF $bootloader_elf
sysbus LoadBinary $application_0_bin 0x8000
"""
runMacro $reset
As in the previous blog post, the debug configuration is in the .vscode/*.json files
but we can also run docker and GDB directly. Call this from the maskset-examples/renode-bootloader/project
directory.
docker run -it --rm \
-e DISPLAY=$DISPLAY -e DOTNET_BUNDLE_EXTRACT_BASE_DIR=/home/user \
-p 2345:2345 -p 3456:3456 -p 3333:3333 -p 1234:1234 \
-v /tmp/.X11-unix:/tmp/.X11-unix -v $(pwd):/home/user \
--name renode renode-1.15.3 renode renode-config-application.resc
The emulator can be started from the Renode monitor view or with GDB (or GDB through VSCode). The monitor interface is at port 1234 and is available in the GUI terminal emulator.
To start GDB:
arm-none-eabi-gdb --eval-command="target remote localhost:3333" --se=bootloader/nrf52_bootloader.elf
Once GDB starts, send c
or continue
to begin execution:
(gdb) c
If you prefer to use the application elf then call with application/nrf52_application.elf
instead of the bootloader elf.
There are two debug targets in the VSCode setup: one for the application and one for the bootloader. Each target use the symbols for one of the images at a time. While you likely don’t need to debug both simultaneously, you can compile both images into a single image if needed.
If you want to debug the bootloader in the application environment then simply modify the GDB call in launch.json.
In VSCode, you can use the Serial Monitor
to a socket. However, I’ve encountered transmission reliability issues so I don’t recommend this approach.
Outside of VSCode, the most reliable way I’ve found is to setup a socat pty terminal that connects to the socket, which can then be accessed with any serial terminal. I’m using the pyserial terminal.
socat PTY,link=/tmp/ttyV0 TCP:127.0.0.1:2345 &
pyserial-miniterm /tmp/ttyV0 115200 --eol LF
Once you have the terminal running, you should see output like this:
___ ________ _ _ _ _
| \/ / __ \ | | | | | |
| . . | / \/ | | | |__ ___ ___ | |_
| |\/| | | | | | | '_ \ / _ \ / _ \| __|
| | | | \__/\ |_| | |_) | (_) | (_) | |_
\_| |_/\____/\___/|_.__/ \___/ \___/ \__|
==Starting Bootloader==
[INF] Primary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Secondary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Boot source: none
[INF] Swap type: test
[DBG] erasing trailer; fa_id=1
[DBG] flash_area_erase: Addr: 0x00027000 Length: 4096
[DBG] initializing status; fa_id=1
[DBG] writing swap_info; fa_id=1 off=0x1ffd8 (0x27fd8), swap_type=0x2 image_num=0x0
The swap types and magic number macros are defined in bootutil_public.h
:
#define BOOT_SWAP_TYPE_NONE 1
/**
* Swap to the secondary slot.
* Absent a confirm command, revert back on next boot.
*/
#define BOOT_SWAP_TYPE_TEST 2
/**
* Swap to the secondary slot,
* and permanently switch to booting its contents.
*/
#define BOOT_SWAP_TYPE_PERM 3
/** Swap back to alternate slot. A confirm changes this state to NONE. */
#define BOOT_SWAP_TYPE_REVERT 4
/** Swap failed because image to be run is not valid */
#define BOOT_SWAP_TYPE_FAIL 5
...
#define BOOT_MAGIC_GOOD 1
#define BOOT_MAGIC_BAD 2
#define BOOT_MAGIC_UNSET 3
#define BOOT_MAGIC_ANY 4 /* NOTE: control only, not dependent on sector */
#define BOOT_MAGIC_NOTGOOD 5 /* NOTE: control only, not dependent on sector */
#define BOOT_FLAG_SET 1
#define BOOT_FLAG_BAD 2
#define BOOT_FLAG_UNSET 3
#define BOOT_FLAG_ANY 4 /* NOTE: control only, not dependent on sector */
#define BOOT_EFLASH 1
#define BOOT_EFILE 2
#define BOOT_EBADIMAGE 3
#define BOOT_EBADVECT 4
#define BOOT_EBADSTATUS 5
#define BOOT_ENOMEM 6
#define BOOT_EBADARGS 7
#define BOOT_EBADVERSION 8
A custom MAGIC?
command was added to read the image states. This info is also printed by the bootloader at startup.
shell> MAGIC?
Slot 0: magic=0x01 swap_type=2 copy_done=1 image_ok=1
Slot 1: magic=0x03 swap_type=1 copy_done=3 image_ok=3
Note that swap_type and copy_done are each one of the BOOT_FLAG_* values.
If we write an image to slot 1 using GDB:
(gdb) restore application/nrf52_application-signed-1.bin binary 0x28000
After reading the magic numbers again we see that the value is now marked as valid.
shell> MAGIC?
Slot 0: magic=0x01 swap_type=2 copy_done=1 image_ok=1
Slot 1: magic=0x01 swap_type=1 copy_done=3 image_ok=3
Now when we swap the images:
shell> swap_images
Triggering Image Swap
___ ________ _ _ _ _
| \/ / __ \ | | | | |
| . . | / \/ | | | |__ ___ ___ | |_
| |\/| | | | | | | '_ \ / _ \ / _ \| __|
| | | | \__/\ |_| | |_) | (_) | (_) | |_
\_| |_/\____/\___/|_.__/ \___/ \___/ \__|
==Starting Bootloader==
[INF] Primary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Secondary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Boot source: none
[INF] Swap type: test
While the flash read and write works the image is reset after a reboot, clearing our flash. Turns out this happens because the macro named reset
is automatically called on MCU reboot, unintentionally reloading the binaries and erasing the flash state.
Renaming the macro name to ’load’ prevents the behavior and allows the flash state to persist across reboots, enabling proper images swapping. So our new Renode script is:
:name: NRF52840 Application Loading
:description: Bootloader project demo on NRF52840.
using sysbus
$name?="NRF52840"
$bootloader_elf?=@bootloader/nrf52_bootloader.elf
$application_0_bin?=@application/nrf52_application-signed.bin
$application_1_bin?=@application/nrf52_application-signed-1.bin
# Create Machine & Load config
mach create $name
include @https://raw.githubusercontent.com/snhobbs/renode-nrf52840_flash/refs/heads/main/nrf52840_nvcm.cs
machine LoadPlatformDescription @renode-platforms/boards/nrf52840dk_nrf52840.repl
machine LoadPlatformDescription @https://raw.githubusercontent.com/snhobbs/renode-nrf52840_flash/refs/heads/main/nrf52840_flash.repl
emulation CreateServerSocketTerminal 2345 "term"
connector Connect sysbus.uart0 term
# Enable GDB
machine StartGdbServer 3333
macro load
"""
sysbus LoadELF $bootloader_elf
sysbus LoadBinary $application_0_bin 0x8000
sysbus LoadBinary $application_1_bin 0x28000
"""
runMacro $load
On initial startup, the image in slot 1 is copied to the primary slot and booted:
___ ________ _ _ _ _
| \/ / __ \ | | | | | |
| . . | / \/ | | | |__ ___ ___ | |_
| |\/| | | | | | | '_ \ / _ \ / _ \| __|
| | | | \__/\ |_| | |_) | (_) | (_) | |_
\_| |_/\____/\___/|_.__/ \___/ \___/ \__|
==Starting Bootloader==
[INF] Primary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Secondary image: magic=good, swap_type=0x1, copy_done=0x3, image_ok=0x3
[INF] Boot source: none
[INF] Swap type: test
[DBG] erasing trailer; fa_id=1
[DBG] flash_area_erase: Addr: 0x00027000 Length: 4096
[DBG] initializing status; fa_id=1
[DBG] writing swap_info; fa_id=1 off=0x1ffd8 (0x27fd8), swap_type=0x2 image_num=0x0
[DBG] flash_area_write: Addr: 0x00027fd8 Length: 4
Checking the timestamp from the currently running image with *IDN?
:
shell> *IDN?
Maskset,UNDEFINED,1,0.1.0-11:22:57
shell> MAGIC?
Slot 0: magic=0x01 swap_type=2 copy_done=1 image_ok=1
Slot 1: magic=0x03 swap_type=1 copy_done=3 image_ok=3
Running swap_images
and checking again confirms the image swap occurred.
shell> *IDN?
Maskset,UNDEFINED,1,0.1.0-12:25:58
shell> MAGIC?
Slot 0: magic=0x01 swap_type=2 copy_done=1 image_ok=1
Slot 1: magic=0x03 swap_type=1 copy_done=3 image_ok=3
One popular alternative to MCUBoot is WolfBoot which is based in RFC901990199019901990199019901990199019, a good overview of secure bootloader architecture. Wolboot also has a Zephyr port.
U-Boot is commonly used in larger systems than those that use MCUBoot. See this NXP app note on loading Zephyr with U-Boot and the ARM Cortex-A documentation on bootloaders.
If you’re working with asymmetric processors such as a Cortex-A with a Cortex-M co-processor, check out this NXP app note.
For more advanced security and isolation, see TrustedFirmware.org’s Trusted Firmware M. TF-M builds on MCUBoot and adds layered isolation, secure services, and trusted execution environments. See the Zephyr TF-M documentation and Nordic’s TF-M examples.
That’s MCUBoot on Renode with an nrf52840. Note the entire of the nRF52 family should work with minor changes to flash and RAM size in the platform description.
A real-world implementation would need a mechanism to get a new image into flash in the first place. That will be in the next part of the series, likely using Zepyhr or FreeRTOS. MCUBoot has a serial recovery mode but that isn’t enabled here.
The Nordic Academy DFU chapter is a helpful resource for firmware updates and device bootloaders.
How do you handle device updates in your projects? Do you use a bootloader like MCUBoot or roll your own?