Bootstrapping Freestanding, part 1
Kovacsics Robert
Recently I was building the platform support for a Cortex-R5 microcontroller. It is executing out of QSPI flash, with SRAM and tightly coupled memory: ATCM is typically for instructions, and BTCM is typically for data. The benefit of {A,B}TCM is much faster access, and it is present even when doing erases/writes to the flash. On the other hand, it is not accessible for other devices in the system (e.g. network DMA engines), unlike the SRAM.
So how do we get started? First, we need a toolchain, you may get one from your vendor, but in this case Nix to the rescue.
❯ nix shell nixpkgs\#pkgsCross.arm-embedded-nano.stdenv.cc
❯ arm-none-eabi-g++ --version
arm-none-eabi-g++ (GCC) 15.3.0
The benefits of using Nix are:
Having debug symbols and sources, though sources need to be fetched unpacked via
❯ nix build nixpkgs\#pkgsCross.arm-embedded-nano.stdenv.cc.libc.src ❯ realpath result /nix/store/...-newlib-4.6.0.20260123.tar.gz ❯ tar xzf result ❯ echo directory newlib-4.6.0.20260123/newlib/libc >> .gdbinitBeing able to change the flags for the toolchain, if needed
So let’s try to build something very simple, note the initial mode is ARM for this processor.
init.S.arm .globl _entry _entry: b _entry
To build, we can manually run
❯ arm-none-eabi-g++ init.S -c -o init.o
❯ arm-none-eabi-ld init.o -o init.elf
arm-none-eabi-ld.bfd: warning: cannot find entry symbol _start; defaulting to 00008000
❯ arm-none-eabi-objdump -dCS init.elf
init.elf: file format elf32-littlearm
Disassembly of section .text:
00008000 <_entry>:
8000: eafffffe b 8000 <_entry>
What happens if we use
arm-none-eabi-g++ init.S -o init.elf?We get a bunch of errors
❯ arm-none-eabi-g++ init.S -o init.elf arm-none-eabi-ld.bfd: .../lib/crt0.o: in function `_mainCRTStartup': .../libgloss/arm/crt0.S:546:(.text+0x118): undefined reference to `main' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-exit.o): in function `exit': .../newlib/libc/stdlib/exit.c:65:(.text+0x40): undefined reference to `_exit' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-closer.o): in function `_close_r': .../newlib/libc/reent/closer.c:47:(.text+0x28): undefined reference to `_close' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-lseekr.o): in function `_lseek_r': .../newlib/libc/reent/lseekr.c:49:(.text+0x30): undefined reference to `_lseek' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-readr.o): in function `_read_r': .../newlib/libc/reent/readr.c:49:(.text+0x30): undefined reference to `_read' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-writer.o): in function `_write_r': .../newlib/libc/reent/writer.c:49:(.text+0x30): undefined reference to `_write' arm-none-eabi-ld.bfd: .../lib/libc.a(libc_a-sbrkr.o): in function `_sbrk_r': .../newlib/libc/reent/sbrkr.c:51:(.text+0x28): undefined reference to `_sbrk' collect2: error: ld returned 1 exit statusWhat is the difference? Well, using G++ to compile and link is a convenience wrapper. To see, pass
--Map=%to the linker, i.e.❯ arm-none-eabi-g++ init.S -c -o init.o ❯ arm-none-eabi-ld init.o -o init.manual --Map=% ❯ arm-none-eabi-g++ init.S -o init.g++ -Wl,-Map=% ❯ diff -u init.manual.map init.g++.map --color -LOAD init.o +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0/lib/gcc/arm-none-eabi/15.3.0/crti.o +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0/lib/gcc/arm-none-eabi/15.3.0/crtbegin.o +LOAD /nix/store/...-newlib-arm-none-eabi-4.6.0.20260123/arm-none-eabi/lib/crt0.o +LOAD /tmp/ccYkk89B.o +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0-lib/arm-none-eabi/lib/libstdc++.a +LOAD /nix/store/...-newlib-arm-none-eabi-4.6.0.20260123/arm-none-eabi/lib/libm.a +START GROUP +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0/lib/gcc/arm-none-eabi/15.3.0/libgcc.a +LOAD /nix/store/...-newlib-arm-none-eabi-4.6.0.20260123/arm-none-eabi/lib/libc.a +END GROUP +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0/lib/gcc/arm-none-eabi/15.3.0/crtend.o +LOAD /nix/store/...-arm-none-eabi-gcc-15.3.0/lib/gcc/arm-none-eabi/15.3.0/crtn.oHere
/tmp/ccYkk89B.ois equivalent to our manualinit.o, so G++ adds a bunch of libraries for us. It is a convenience wrapper after all.To avoid this, we can pass
-nostdlibto the compiler.
Let’s first fix the warning about the entry symbol _start
link_script.ldENTRY(_entry)
❯ arm-none-eabi-ld init.o -o init.elf -T link_script.ld
This now builds, with the same assembly, but it is at the wrong place in memory, so let’s fix that next.
link_script.ldMEMORY { ATCM (rx) : ORIGIN = 0x00000000, LENGTH = 128K BTCM (rw) : ORIGIN = 0x00040000, LENGTH = 256K SRAM (rw) : ORIGIN = 0x01200000, LENGTH = 1M FLASH (rx) : ORIGIN = 0x1C000000, LENGTH = 32M } ENTRY(_entry) SECTIONS { .text : { *(.text) } >FLASH .rodata : { *(.rodata) } >FLASH .data : { *(.data) } >SRAM AT>FLASH }
This is about as simple as we can make the link script, it tells the linker to place code1 then read-only data in the flash, and to place read-write data in the flash, but link it as if it were in the SRAM.
Now we have something we can flash into the external flash, then attach GDB to the processor and see it loop forever, success!
My flash and GDB server commands
Yours will change depending on your tools, I have a JLink to flash the SPI flash, and a JLink attached to the JTAG of the Cortex-R5. Other tools e.g. openocd, ftditool are available and can run with a cheap FTDI adapter. But may require more setting up and tweaking, that is the hobbyist trade-off.
flash.jflashAppVersion = 81000 [SPI] CmdEnter4ByteMode = 0xB7 CmdEraseBulk = 0xC7 CmdEraseSector = 0xD8 CmdExit4ByteMode = 0xE9 CmdReadData = 0x03 CmdReadID = 0x9F CmdReadStatus = 0x70 CmdWriteDisable = 0x04 CmdWriteEnable = 0x06 CmdWritePage = 0x02 CmdWriteStatus = 0x01 Dedicated4BAddrMode = 0x01 NumAddrBytes = 0x04 SendInitCommands = 0x00 StatusBitPos = 0x07 StatusIsReadyBit = 0x01 StatusRegFormat = 0x00 UseQuadMode = 0x00 [PRODUCTION] EnableTargetPower = 1 TargetPowerDelay = 0x000000C8JFlashSPICL -USBJLINK1 -OpenPrj flash.jflash -Open init.elf 0x00000000 -AutoThis will flash the SPI flash using the JLink device with the nickname
JLINK1(use JLinkConfig to assign nicknames). Now we can start the GDB server in one terminal using:JLinkGDBServerCL -Select USB=JLINK2 -Device BCM56070And then connect to the GDB server via
gdb -ex 'tar ext :2331' init.elf
Moving to CMake
For convenience, let’s move to CMake so that we don’t have to manually type the build commands. I chose CMake, because it also provides a
compile_commands.jsonfile for clangd (so only useful when we get to C/C++), and in the long term might be better than a simple makefile.First, let’s create the main CMake file
CMakeLists.txtproject(bootstrap LANGUAGES CXX C ASM) add_executable(bootstrap entry.S)Now this would be enough for a host application, but because we are using a cross-toolchain, let’s add a toolchain CMake file too. The closest reference here is the online CMake documentation Cross Compiling using Renesas compilers
gcc-arm-none-eabi.cmakeset(CMAKE_SYSTEM_NAME Generic) set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g++) set(CMAKE_ASM_COMPILER arm-none-eabi-g++) set(CMAKE_EXECUTABLE_SUFFIX_C .elf) set(CMAKE_EXECUTABLE_SUFFIX_CXX .elf) set(CMAKE_EXECUTABLE_SUFFIX_ASM .elf) set(CMAKE_C_FLAGS "-mcpu=cortex-r5 -nostdlib") set(CMAKE_CXX_FLAGS "-mcpu=cortex-r5 -nostdlib") set(CMAKE_ASM_FLAGS "-mcpu=cortex-r5 -nostdlib") set(CMAKE_C_LINK_FLAGS -T${CMAKE_CURRENT_LIST_DIR}/link_script.ld) set(CMAKE_CXX_LINK_FLAGS -T${CMAKE_CURRENT_LIST_DIR}/link_script.ld) set(CMAKE_ASM_LINK_FLAGS -T${CMAKE_CURRENT_LIST_DIR}/link_script.ld) # To avoid test executable runs out of const section's size. set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)Now we could remember to do the CMake configuration as
cmake -DCMAKE_TOOLCHAIN_FILE=gcc-arm-none-eabi.cmake -B build -G Ninja .But instead we can create a CMakePresets.json file to do this for us.
CMakePresets.json{ "$schema": "https://cmake.org/cmake/help/latest/_downloads/3e2d73bff478d88a7de0de736ba5e361/schema.json", "version": 8, "configurePresets": [ { "name": "bootloader", "binaryDir": "${sourceDir}/build/", "generator": "Ninja Multi-Config", "toolchainFile": "${fileDir}/gcc-arm-none-eabi.cmake", "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": true, "CMAKE_COLOR_DIAGNOSTICS": true, "CMAKE_CONFIGURATION_TYPES": "Debug;Release;MinSizeRel" } } ], "buildPresets": [ { "name": "bootloader", "configurePreset": "bootloader", "targets": [ "bootloader" ] } ], "workflowPresets": [ { "name": "bootloader", "steps": [ { "type": "configure", "name": "bootloader" }, { "type": "build", "name": "bootloader" } ] } ] }
So we told the linker to output the read-write data into the flash, but any address pointing to it should point as if it were in the SRAM, so let’s relocate that. First, we need to grab the addresses from the linker.
link_script.ld@@ -12,5 +12,8 @@ SECTIONS { - .text : { *(.text) } >FLASH - .rodata : { *(.rodata) } >FLASH - .data : { *(.data) } >SRAM AT>FLASH + .text : { *(.text) } >FLASH + .rodata : { *(.rodata) } >FLASH + .data : ALIGN(8) { *(.data) } >SRAM AT>FLASH + __load_sram_begin__ = LOADADDR(.data); + __dest_sram_begin__ = ADDR(.data); + __dest_sram_end__ = ADDR(.data) + SIZEOF(.data); }
Then load from __load_sram_begin__ and store into __dest_sram_end__ until we
fill it. Note, because we ensured data is aligned to 8 bytes, we can use the
ldmia and stmia instructions.
init.S.arm .globl _entry _entry: ldr r8, =__load_sram_begin__ // r8 = input (u64*) ldr r9, =__dest_sram_begin__ // r9 = output (u64*) ldr r10, =__dest_sram_end__ // r10 = end 1: cmp r9, r10 // while (output < end) bge 2f // { ldmia r8!, {r0-r7} // *output++ = *input++ stmia r9!, {r0-r7} // b 1b // } 2: 1: b 1b
This is probably enough for part 1, stay tuned for actually starting C and C++ programs, and for improving the debugging experience.
For historical reasons code is called
textby the linker. ↩︎