Chasing the Ideal Bootloader: A Journey into Bootloader Development & PIC Architecture
We all know about application code, whether that’s a web app, a mobile app, or even the operating system itself. But before the OS even loads up, when you first turn on your phone or PC, there’s usually a splash screen with a brand logo, or maybe you’ve heard of the BIOS. Have you ever wondered if your blood pressure monitor at home has a similar thing? And wait, when you run an OS update on your laptop, what is actually running the update? It definitely isn’t the OS, because the OS is the thing being updated!
Whenever your device reboots to apply patches, you’re seeing a bootloader in action. Embedded devices, like that pressure monitor or even the washing machine at home, do the exact same thing. But how does that actually work under the hood? And more importantly, when you’re dealing with critical hardware like medical devices or remote sensors, how do we make sure a bad update doesn’t turn a thousand-dollar machine into a useless brick?
I wrote this post to be a starting point if you’re curious about firmware updates, but I’ve also packed in some technical details too. We’ll kick things off with the basics-what a bootloader actually is and why you’d even need a custom one. From there, we’ll get into some technical stuff: how the jump to the application works, how standard dual-bank updates keep things safe, and finally, my journey into Position-Independent Code (PIC) and how to make a tiny, resource-constrained device boot using it.
What is a Bootloader?
At its core, a bootloader is the very first piece of code that wakes up the moment you power on an embedded system. You can think of it like the BIOS or UEFI on your computer, just stripped way down for a microcontroller. Its main job is pretty straightforward: wake up the essential hardware, make sure everything looks good, and then hand it over to your main application.
But depending on what you’re building, that to-do list can grow pretty fast. For example, in our recent project, the bootloader had to initialize external flash memory, configure memory map modes so we could execute code directly from that flash (XIP), and handle Over-The-Air (OTA) updates. I know that sounds like a lot of gritty, low-level hardware stuff, but actually, modern bootloaders are mostly written in comfortable languages like C or C++.
Further Reading:
- [1] Embedded Tutorial Series – Bootloader Tutorials
- [2] STM32 OTA Firmware Update
Why Build a Custom Bootloader?
If you’ve ever tinkered with an ESP32, an Arduino, or really any standard microcontroller, you might be thinking, “Wait a minute, I can just upload my code and it works. I never had to write a bootloader!”
And you’d be right. Most popular maker platforms and System-on-Chips (SoCs) come pre-loaded with solid, vendor-supplied bootloaders that handle serial or Wi-Fi flashing effortlessly. In some cases, you can even just directly erase and write to the flash memory yourself. But when you move into commercial hardware, like building a complex medical device or a high-performance industrial system, those generic, manufacturer-provided bootloaders won’t cut it.
For instance, you might need to boot from a massive external flash because your application is simply too big to fit on the internal memory. You might need to implement custom encryption to protect your proprietary firmware. Or maybe you need an OTA protocol running over a dedicated bus like CAN, UART, or a custom RF link. Once you hit that level of specialized hardware and security, you probably will have to write your own custom bootloader tailored to your specific system architecture.
From Power-On to Bootloader
But how does the microcontroller actually know it needs to run your bootloader first instead of jumping straight into the main application? Well, this is generally hardwired at the hardware level. When an MCU powers up, it samples the physical electrical states of its BOOT pins (or checks some preconfigured fuses/settings). Based on what it reads from those pins and configurations, the memory controller maps a specific memory address to be the starting point. The Program Counter (PC) then begins executing instructions from that address-effectively waking up our custom bootloader.
Once it’s awake, the bootloader has to do its initial chores: bringing up the necessary clocks, configuring communication interfaces, and checking to see if a new firmware image needs to be downloaded and written to flash.
Making the Leap: The Jump Mechanism
When the bootloader finishes its duties, whether that’s a fresh OTA update or just a routine startup after a reset or a power cycle, it must transfer control to the main application. This “jump” isn’t a simple function call; it requires careful orchestration to ensure the application inherits a clean hardware state.

Before we jump into the code, there are a few key terms you should know.
- Vector Table: An array of addresses that point to interrupt and exception handlers. When you compile your application, this table is typically the very first part of the resulting .bin file, placed first by your linker script. The first two entries in this table are absolutely critical for the boot process.
- MSP (Main Stack Pointer): The first entry in the vector table. It tells the processor exactly where the application’s stack memory starts.
- Reset Handler: The second entry in the vector table. It holds the absolute physical memory address of the very first instruction the application is supposed to execute.
- Linked Address: When compiling code, the compiler needs to know where in memory this code is going to live so it can generate the right memory jumps and variable lookups. This base location (usually defined in your linker script’s FLASH ORIGIN) is called the Linked Address. If you compile your code to run from 0x90000000, the compiler bakes that exact address assumption directly into your final binary.
Further Reading (ARM Architecture):
- [3] Vector Table
- [4] Reset Handler
- [5] R13, Stack Pointer (SP)
- [10] Linker Scripts
Here is the standard flow:
- Peripheral De-Initialization: Before jumping, the bootloader must disable active peripherals (such as GPIOs, and interrupts) to ensure the main application starts with a clean hardware state, avoiding unexpected interrupt triggers.
- Stack and Vector Table Extraction: The bootloader locates the application’s binary in memory and reads the first 8 bytes to extract the MSP and Reset Handler address.
- The Final Branch: To execute the transfer, the bootloader loads the extracted MSP into the processor’s stack pointer register and branches to the Reset Handler address.
The Basic (Static) Jump
In standard environments, the application is always linked to a fixed physical address (e.g., 0x90000000), and the compiled .bin file is physically placed starting at that exact memory location. Because of this perfect match, the jump code is straightforward. It blindly trusts the addresses found in the vector table:
|
/* 1. Extract MSP and Reset Handler from the known static address */ /* 2. Setup the function pointer */ /* 3. Set the Main Stack Pointer and jump */ |

The Dynamic Jump
But what happens if the compiled firmware is not sitting at the physical memory address it was compiled for?
If this happens, the bootloader has to actively calculate the physical offset between where the application actually is and where it was originally compiled to be, and dynamically adjust the Reset Handler pointer on the fly.
(If it doesn’t make sense why such a mismatch can exist right now, don’t worry-it will be covered in a later section.)
Here is how that dynamic calculation looks in C:
|
/* boot_bank_offset: the base address delta between where the firmware physically sits and where it was linked. Determined from a metadata header or a compile-time constant (e.g., 0x0 for Bank 1, 0x200000 for Bank 2) */
/* Extract the raw values directly from the binary’s physical location */ /* Calculate the load address delta */ /* Patch the Reset Handler address to point to its real physical location*/
/* Setup the function pointer */ /* Set the Main Stack Pointer and jump to the dynamically calculated address */ |
Dual-Bank Architectures: Ensuring Reliability
Have you ever lost power or your internet connection right in the middle of an ESP32 firmware update? It may have been worrying, but when you restarted the device, it booted right back up with the old firmware as if nothing happened.
This exact mechanism is crucial. As we mentioned earlier, when you’re deploying critical systems like medical devices or remote sensors, you absolutely cannot risk “bricking” them with a corrupt update.
This requirement for failsafe redundancy leads us to Dual-Bank Architectures. The core concept is simple: you divide your available flash memory into two separate partitions (Bank 1 and Bank 2). When a new OTA update arrives, it is downloaded entirely into an inactive bank. The current, proven firmware remains completely untouched in the active bank. If the download fails midway, or the new firmware fails cryptographic validation, the bootloader simply ignores the corrupted firmware and boot from the older, valid firmware.
Let’s take a look into the dual bank approaches.
1. The “Two Binaries” Approach One software method involves compiling the application twice: once linked for Bank 1 and once for Bank 2. This allows the application to boot from either bank.
While effective, managing two identical-but-separate build targets complicates the continuous integration pipeline and OTA server logic (the server now has to know which binary the device needs).
2. The “Copy to Execute” Method To avoid managing multiple binaries, many systems use a staging approach. Bank 2 serves merely as a temporary download area. Once validated, the bootloader copies the new firmware bit-for-bit into Bank 1. The application always executes from the fixed Bank 1 address, and is always compiled (linked) to run from Bank 1.
While safe and reliable, the Copy-to-Execute method increases update time and consumes flash write-endurance cycles.

3. Execute from Either Bank (The Ideal Scenario) This approach eliminates the need for copying or managing multiple build targets. The bootloader simply verifies which bank holds the newest valid firmware (Bank 1 or Bank 2) and dynamically jumps to that bank’s starting address, executing the code directly in place.

Wait, isn’t this third option obviously the best? Why do the first two workarounds even exist? What exactly is the issue here?
What’s the catch ?
The answer lies in a fundamental limitation of how standard C code is compiled. By default, C compilers generate Position-Dependent Code. Because the compiler hardcodes exact physical memory addresses into the resulting binary, standard firmware is rigid. If a function is located at 0x08001000 in Bank 1, every call to that function hardcodes a jump specifically to 0x08001000.
If you physically place that exact same binary into Bank 2 (e.g., starting at 0x08040000), all those hardcoded jumps are suddenly wrong. The moment the code tries to call a function or fetch a global variable, it will jump into empty space-or worse, jump back into the old firmware residing in Bank 1, causing an immediate crash.

This memory mapping is why those first two workarounds exist! Because the Linked Address is permanently baked into the code during compilation, standard firmware can only run from that exact spot. So, your only options are to either compile two completely separate binaries (each with a different Linked Address pointing to Bank 1 or Bank 2), or keep a single binary but always copy it over to Bank 1 so it executes from the single Linked Address it was built for.
So, how do we solve this address mismatch to achieve that ideal “Execute from Either Bank” scenario?
If the processor happens to include a Memory Management Unit (MMU) or hardware-level bank swapping, this process is simplified. The hardware dynamically remaps the physical addresses of Bank 2 to look like the consistent virtual addresses of Bank 1.
However, in resource-constrained microcontrollers lacking these features, developers need a software solution. Is there a way to resolve these addresses at runtime instead of compile-time? Yes, and this is what bridges the gap to Position-Independent Code (PIC). Standard compilation doesn’t allow for simple runtime resolution like this out of the box; the application code must be specifically engineered and compiled to support it.
Position-Independent Code (PIC): A Dynamic Solution
Position-Independent Code is designed to execute correctly regardless of its absolute physical memory location. Instead of hardcoding absolute addresses, it utilizes relative addressing and a Global Offset Table (GOT), which is patched once during startup to resolve the correct physical addresses of global variables.
While PIC is the foundational technology behind shared libraries (.so files) in Operating Systems like Linux-where a dynamic loader handles the GOT patching-implementing it in a bare-metal microcontroller environment requires significant manual intervention.
Further Reading:
- [7] Position Independent Code (PIC) in shared libraries
- [8] Load-time relocation of shared libraries
How PIC Works: The Relocation Playbook
Transitioning from Position-Dependent Code to PIC in an embedded environment is a huge architectural shift. It transforms a static binary into a dynamically relocatable entity.
Remember that dynamic jump from earlier? Just as the bootloader offsets the Reset Handler to get the application started, PIC allows the rest of the application’s internal jumps and variables to be offset in a similar way using a Global Offset Table (GOT). This is what allows the application to run from any physical offset, regardless of its original linked address.
Crucially, this runtime relocation is handled entirely by the application’s own startup assembly. The bootloader’s job ends after it performs that initial jump to the offset Reset Handler.
Making the application code Position-Independent requires modifications across the compiler, the linker script, and the startup assembly.
Further Reading:
- [9] Portable PIC Bootloader and Firmware Walkthrough (Recommended to go through if you’re making your own PIC implementation.)
It is critical to note that all of the following PIC modifications are done in the application’s code, not the bootloader’s code.

1. The Compiler’s Role & The r9 Register: By using specific compiler flags (e.g., -fpic, -msingle-pic-base, -mpic-register=r9), the toolchain replaces absolute jump addresses with PC-Relative Addressing. Since the internal distances between functions within the binary remain constant, these jumps work from any flash bank.
For global variables in RAM, however, relative addressing doesn’t work. The compiler instead generates a Global Offset Table (GOT). It routes every global data fetch through this table. By convention on ARM architectures, we dedicate the r9 register to act as a universal base pointer. The compiler expects r9 to always point to the start of the GOT in RAM at all times.
2. Linker Script Modifications: The linker script must be modified to support this. We have to explicitly define the .got block in flash. More importantly, we have to carve out a dedicated section of RAM (e.g., ISR_RAM) to hold our relocated Vector Table. Why? Because the Vector Table contains absolute function pointers to interrupt handlers. If we don’t move it to RAM where we can modify it, hardware interrupts (like the SysTick) will jump to un-offset memory addresses and crash the system.
3. The Startup Assembly Patching Phase: The most critical work happens in the startup assembly (before main() is ever reached). Since the code doesn’t know its physical address at compile-time, it calculates a dynamic runtime offset (usually stored in r7) by comparing the current Program Counter (PC) to the originally linked address.
Once the offset is known, the assembly code must manually patch the environment:
- Patching the GOT: The original GOT is copied from Flash to RAM. A loop iterates through the table, injecting the r7 offset into each pointer so global variables resolve correctly. Immediately after, r9 is loaded with the RAM address of the new GOT.
- Patching the Vector Table: Similarly, the Vector Table is read from Flash, the r7 offset is added to every interrupt handler pointer, and the patched table is saved into the reserved ISR_RAM. Finally, the hardware’s Vector Table Offset Register (VTOR) is manually updated to point to this newly patched table in ISR_RAM.
- Data Initialization (_sidata): The standard routine that copies initialized variables into RAM must also be patched, as the source data in Flash is physically shifted by the r7 offset.
Here is a simplified example of what an assembly patch loop looks like for the GOT:
|
@ r7 holds our dynamic offset. r1 holds Flash GOT base, r2 holds RAM GOT base GOT_Loop: ldr r3, [r1], #4 @ Load pointer from Flash GOT add r3, r3, r7 @ Inject the physical runtime offset str r3, [r2], #4 @ Store patched pointer into RAM GOT cmp r1, r4 @ Have we reached the end of the table? blo GOT_Loop @ If lower (unsigned), loop again @ Critically: Tell the compiler where the new GOT is ldr r9, =__ram_global_offset_table_begin |
The Bare-Metal Reality: My Personal Experience
While PIC offers an elegant solution on paper, the practical challenges in bare-metal development can be formidable.
This project was actually my very first foray into bootloader development. I was working with an STM32H7 “Value Edition” MCU. Despite having an incredibly powerful Cortex-M7 core, it only has a tiny 128KB internal flash. Because our system ran a heavy UI application, we had to rely entirely on a massive external QSPI flash for memory.
Getting the basic bootloader to initialize the hardware and successfully jump to the external flash application was the first major victory. Next came implementing the OTA updates. While the STM32H7 does have a hardware bank-swapping feature, it only applies to the internal flash-meaning it was completely useless for our external QSPI setup. This made us implement the robust, albeit slower, “Copy to Execute” method. But you might be wondering, why didn’t we just use PIC?
I too wanted to eliminate that slow copy phase, especially since all those extra read/write cycles were eating into the flash memory’s limited lifespan. This desire led me to explore Position-Independent Code.
It was a fascinating journey. After rewriting linker scripts and wrestling with ARM assembly to patch the GOT, I actually achieved a massive win: I got a simple test application (with no UI and no RTOS) fully compiled and running flawlessly from any offset using PIC!
However, the “Bare-Metal Reality” quickly set in. When I tried to apply those exact same PIC mechanics to our actual, heavy UI-enabled production system, the architecture completely fell apart. The primary complication? External dependencies. Massive third-party UI libraries and vendor HALs are almost universally compiled using absolute addressing. Attempting to link these rigid, static binaries with my PIC application broke the relocation mechanisms entirely.
While PIC works flawlessly under the hood for shared libraries in an OS like Linux, or in tightly controlled, dependency-free bare-metal environments, we ultimately had to weigh its benefits against the practical integration challenges it introduced. In the end, we decided to stick with the straightforward, proven stability of the Copy-to-Execute method for our application.
Our Engineering Culture: Where R&D Meets Reality
What struck me most about this entire journey wasn’t any single technical breakthrough – it was the process itself.
I was a first-time bootloader developer, handed a genuinely complex problem on production hardware. There was no perfect tutorial to follow. I had to read ARM architecture specs, wrestle with linker scripts, write assembly I had never written before, and ultimately make a call on an architectural direction that would affect real devices in the field.
What made that possible was the environment. At Zone24x7, deep R&D isn’t a side project – it’s part of the job. The time I spent implementing PIC from scratch, and then making the honest call that it wasn’t ready for our production system, wasn’t considered wasted. It was considered engineering.
Whether the outcome is a polished new capability or a well-reasoned “not yet,” both are treated as valid results of rigorous thinking. And when things get genuinely hard – the kind of hard where you’re debugging a memory fault and you’re not sure if it’s the linker script, the GOT, or something else entirely – you’re not alone. There’s a team here that’s been in those trenches, and the door is genuinely open.
References
Bootloader Architecture & Tutorials
- Embedded Tutorial Series – Bootloader Tutorials. Embetronicx. https://embetronicx.com/bootloader-tutorials/
- Mongoose. (2023). STM32 OTA Firmware Update. https://mongoose.ws/articles/stm32-ota-firmware-update/
ARM Architecture Documentation
- ARM Developer. Vector Table – What is inside a program image. https://developer.arm.com/documentation/107565/0101/Use-case-examples/Generic-Information/What-is-inside-a-program-image-/Vector-table
- ARM Developer. Reset Handler – What is inside a program image. https://developer.arm.com/documentation/107565/0101/Use-case-examples/Generic-Information/What-is-inside-a-program-image-/Reset-Handler–
- ARM Developer. R13, Stack Pointer (SP). Cortex-M55 Registers. https://developer.arm.com/documentation/107656/0101/Registers/Registers-in-the-register-bank/R13–Stack-Pointer–SP-
- ARM Developer. Core Registers – Cortex-M7. https://developer.arm.com/documentation/dui0646/c/The-Cortex-M7-Processor/Programmers-model/Core-registers
Position-Independent Code (PIC)
- Bendersky, E. (2011, November 3). Position Independent Code (PIC) in shared libraries. https://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/
- Bendersky, E. (2011, August 25). Load-time relocation of shared libraries. https://eli.thegreenplace.net/2011/08/25/load-time-relocation-of-shared-libraries/
- Paalijarvi, J. (2022, January 16). Portable Position Independent Code (PIC) Bootloader and Firmware for ARM Cortex-M0 and Cortex-M4. Tech Blog. https://techblog.paalijarvi.fi/2022/01/16/portable-position-independent-code-pic-bootloader-and-firmware-for-arm-cortex-m0-and-cortex-m4/
Compiling & Linking
- OSDev Wiki. Linker Scripts. https://wiki.osdev.org/Linker_Scripts
- Bachana, A. Embedded Systems Intro: Compiling and Linking using Make. Medium. https://medium.com/@aareshbachana/embedded-systems-intro-compiling-and-linking-using-make-1340520df3ce