Bare-metal MCUBoot Port on Renode

Setting up a bare-metal port of MCUBoot on an nrf52 and simulating in Renode

The code for this project is available here.

Why use Renode?

  • Faster Iterations: It allows faster testing without the delays of physical hardware, especially useful when tweaking bootloader and firmware behavior.
  • Flash Wear Prevention: Running tests in Renode prevents flash from being worn out due to frequent writes (especially useful while you’re still debugging).
  • Continuous Integration (CI): Test your actual build in CI setups.
  • Development Without Hardware: Enables software developers to work on embedded projects without needing the actual hardware.

Setup

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 & Building

Configuration and building is done from the root directory:

cmake --build  -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake ./
cmake --build ./

Building the application image

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:

  • nrf52_application.axf: Uses the post update memory layout so can be used for debugging the application.
  • nrf52_application-no-header.bin: The bare image binary.
  • nrf52_application.bin: MCUBoot format image.

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 Specifics

Writing to flash

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.

Renode Script

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

Running

Starting Renode

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.

Debugging

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.

Interacting with the serial port

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

Emulator Results

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

Alternatives

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.

End

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?

Resources & References

MCUBoot

DFU & OTA

Renode