How the Linux Boot Sequence Works-Embedded Linux Course In Hyderabad

How the Linux Boot Sequence Works
Free embedded Linux course: ROM code, SPL, and every stage between power-on and a bootable kernel
Chapter 3 · Lecture 2
Beginner Friendly
Hands-On Demo Included

In the previous lecture of this free embedded systems course we established the two universal jobs of a
bootloader: bring up minimal hardware, then load and jump to the next stage. Now it’s time to see what that
actually looks like on real silicon. The Linux boot sequence on a modern SoC is rarely a single leap — it’s a
relay race of increasingly capable code, where each stage’s only purpose is to make the next stage possible. This
lecture walks through that relay from the very first ROM-resident instruction all the way to the point where a
full bootloader is finally running in DRAM.

linux boot sequence
ROM code
secondary program loader
free linux kernel development course
free embedded linux course
SoC boot stages

What You Will Learn

  • Why the simple “boot directly from flash” model stopped being enough for modern SoCs
  • What ROM code is, and why it’s the one piece of the linux boot sequence you can never change
  • How a size-constrained secondary program loader (SPL) fits into the picture
  • How each stage hands off to the next, and what “enough capability” means at each step
  • A hands-on demo: writing a size-budgeted loader stub and verifying it fits an SRAM-class footprint

Prerequisites

  • Lecture 1 of this chapter — “What Does a Bootloader Do”
  • A working cross toolchain from our free linux development course toolchain chapter
  • Basic familiarity with linker scripts is helpful but not required

The Simple Case: Booting Straight From Flash

Years ago, many embedded designs used NOR-type flash memory that could be mapped directly into the CPU’s address
space, just like RAM. In that world, the linux boot sequence could be almost embarrassingly simple: place the
bootloader image in flash so that it sits at the CPU’s reset vector, and the CPU could start executing bootloader
instructions immediately — no loading step required at all, because “in flash” and “executable” were the same
thing.

Direct-Execute Boot From Mapped Flash (Older Designs)
High address
+————————————–+
| Mapped Flash |
| …bootloader code… |
| [reset vector] –> jump instruction | <– CPU starts executing here directly
| …bootloader code continues… |
+————————————–+
| DRAM (not yet configured) |
+————————————–+
Low address 0x00000000

This “execute in place” approach is elegant, but it depends entirely on having a flash technology that supports
direct, byte-addressable execution. Once designs moved to NAND flash, eMMC, or SD-card style storage — none of
which can be executed in place — a single-stage boot was no longer possible, and the boot sequence had to become a
multi-phase process instead.

Phase 1: ROM Code

The very first code that runs on a modern SoC is called ROM code. It’s burned into the chip
during manufacturing, which means two important things: it’s fixed for the life of the chip, and it can’t assume
anything about hardware that lives outside the chip — including the exact DRAM chips the board designer chose.
Because of that restriction, ROM code only has access to a small amount of on-chip static RAM (SRAM), typically
somewhere between a few kilobytes and a few hundred kilobytes.

What ROM code can do is search a short, preprogrammed list of locations for a small next-stage image and
copy it into that SRAM. Depending on the SoC vendor, that search list commonly includes the first blocks of a NAND
or SPI flash device, the first sectors of an SD/eMMC device, or a specifically named file on an SD card’s first
partition. If none of those succeed, many ROM implementations fall back to reading a raw byte stream over a
peripheral like USB or UART — mainly intended for factory programming rather than everyday booting.

Phase 1 — ROM Code Loads a Small Image Into SRAM
+—————————————————+
| SoC |
| +———————————————–+ |
| | On-chip SRAM (few KiB – few hundred KiB) | |
| | [next-stage image loaded here] <——–+ | |
| +———————————————–+ | |
| | ROM code (fixed, runs first, searches storage)-+ |
| +———————————————–+ |
+—————————————————+
|
v searches, in order: SPI flash / NAND / MMC-eMMC-SD / fallback USB-UART

When the SRAM budget is too small to hold a full-featured bootloader, the image ROM code loads is an
intermediate program called the secondary program loader, or SPL. Once that image is in SRAM, ROM
code’s job is done — it simply jumps to the start of the newly loaded code.

Phase 2: The Secondary Program Loader (SPL)

The SPL exists for exactly one purpose relative to the linux boot sequence: bring up main memory (DRAM) and
whatever else is needed to load a larger, more capable next-stage image — sometimes called a third-stage program
loader, or TPL — into that freshly available DRAM. Because the SPL itself still has to fit inside the same
constrained SRAM, its feature set is deliberately minimal. It typically doesn’t support any interactive user
input, though it’s common for it to print short version or progress messages to a serial console so you can
observe boot progress even before a full bootloader is running.

Phase 2 — SPL Initializes DRAM and Loads the Next Stage
+—————————————————+ +————————————–+
| SoC (on-chip) | | DRAM (now configured by SPL) |
| +———————————————–+ | ==> | [next-stage / TPL loaded here] |
| | SPL (loaded by ROM code, now executing) | | | |
| | 1. init DRAM controller | | +————————————–+
| | 2. load next-stage image from storage | |
| | 3. jump into DRAM | |
| +———————————————–+ |
+—————————————————+

Whether the SPL is open source or closed depends heavily on the SoC vendor — it’s common to see vendor-supplied
binary blobs here, since bringing up an unpublished DRAM controller often requires register sequences the vendor
doesn’t want to document publicly. Regardless of its origin, once DRAM is live and the next-stage image is sitting
inside it, the SPL’s final act — like every stage before it — is a single jump into that freshly loaded code.

Why So Many Stages?

It’s tempting to ask why the linux boot sequence doesn’t just skip straight to a full bootloader. The answer is
capability versus space: each stage exists because it’s the smallest amount of code that can unlock the resources
the next, larger stage needs. ROM code can’t assume DRAM works, so it can only load something small enough for
SRAM. That small SPL can’t do everything a full bootloader does, so its only job is to make DRAM usable. This
“unlock the next tier of resources” pattern is the organizing principle behind the entire multi-stage design.

Stage Where It Runs Its One Job Typical Size Budget
ROM code On-chip, fixed at manufacture Load the next stage into SRAM N/A (masked in silicon)
SPL On-chip SRAM Bring up DRAM, load next stage into DRAM A few KiB to tens of KiB
Full bootloader / TPL DRAM Load the Linux kernel and hand off Hundreds of KiB to a few MiB

Hands-On: Fitting a Loader Stub Into an SRAM-Sized Budget

Let’s get a feel for why SPL code has to be so minimal by writing a tiny original C program, linking it against
a deliberately small memory region, and confirming it fits.

/* ep_spl_stub.c -- a minimal, original illustration of an SPL-style stub */
static volatile unsigned int *const EP_UART_STATUS = (unsigned int *)0x40000000;
static volatile unsigned int *const EP_UART_DATA   = (unsigned int *)0x40000004;

static void ep_putc(char c)
{
    while ((*EP_UART_STATUS & 0x1) == 0) {
        /* wait for transmit-ready, no library calls available yet */
    }
    *EP_UART_DATA = (unsigned int)c;
}

void ep_spl_main(void)
{
    const char msg[] = "EP-SPL: DRAM init would go here\n";
    for (int i = 0; msg[i] != '\0'; i++)
        ep_putc(msg[i]);

    for (;;) {
        /* stand-in for: init DRAM, load next stage, jump */
    }
}

Now give it a linker script that mimics a tight SRAM budget — here, just 8 KiB — so the build itself enforces
the same size discipline a real SPL has to live within:

/* ep_spl.ld -- pretend 8 KiB SRAM budget */
MEMORY
{
    SRAM (rwx) : ORIGIN = 0x00100000, LENGTH = 8K
}
SECTIONS
{
    .text : { *(.text*) } > SRAM
    .data : { *(.data*) } > SRAM
    .bss  : { *(.bss*)  } > SRAM
}
$ arm-none-eabi-gcc -c -ffreestanding -O2 ep_spl_stub.c -o ep_spl_stub.o
$ arm-none-eabi-ld -T ep_spl.ld ep_spl_stub.o -o ep_spl_stub.elf
$ arm-none-eabi-size ep_spl_stub.elf

Expected output:

   text    data     bss     dec     hex filename
    148       0       0     148      94 ep_spl_stub.elf

148 bytes comfortably inside an 8 KiB budget — but notice how quickly that changes if you add library calls like
printf or dynamic memory allocation; a real SPL has to be written with exactly this kind of size
awareness at every step, which is precisely why it only ever does the one job of getting DRAM ready.

Common Mistakes and Troubleshooting

Mistake Why It Happens Fix
Linking SPL-style code against libc without checking size Assuming normal C library functions are “free” Build freestanding, check size output, avoid printf/malloc in early stages
Expecting ROM code’s search order to be identical across vendors Treating one SoC’s boot media search list as universal Always check that specific SoC’s boot ROM documentation
Debugging “no boot” issues by looking at DRAM first Habit from application debugging Check serial console output from the earliest stage (ROM/SPL) first — DRAM may not even be the problem

Best Practices

  • Always check your serial console during boot — ROM code and SPL failures are often silent otherwise
  • Keep every early-stage binary building with -ffreestanding and check its size against the real SRAM budget
  • Know your SoC’s documented boot media search order before debugging a “won’t boot” issue

Summary and Key Takeaways

  • Direct-execute boot from mapped flash works only when the storage technology supports it — modern NAND/eMMC/SD designs can’t
  • ROM code is fixed at manufacture and can only load a small image into on-chip SRAM
  • The SPL’s one job is bringing up DRAM and loading the next, larger stage into it
  • Every stage in the linux boot sequence exists to unlock just enough resources for the next stage to run

Conclusion

What looks, from the outside, like “the board booted” is really a relay of small, purpose-built programs, each
one solving exactly the resource problem the next one has. ROM code gets a little SRAM working; the SPL turns that
into a working DRAM; and — as we’ll see in the next lecture of this free embedded Linux course — the full
bootloader that finally lands in DRAM is what takes on the much bigger job of finding, loading, and starting the
Linux kernel itself.

Frequently Asked Questions

What is ROM code in the Linux boot sequence?

It’s the first code that executes after reset, fixed permanently into the SoC at manufacture. Its only job is to
locate and load a small next-stage image into on-chip SRAM.

What is an SPL and why is it needed?

A secondary program loader is a small, size-constrained program loaded by ROM code. It’s needed when the on-chip
SRAM isn’t large enough to hold a full-featured bootloader, so its one job is bringing up DRAM and loading the next stage.

Can ROM code be updated or replaced?

No. It’s masked into the silicon during manufacturing and is fixed for the life of the chip.

Why can’t a full bootloader run directly from SRAM?

Full bootloaders are typically hundreds of KiB to a few MiB in size, far larger than the SRAM budget available on most SoCs, so they need DRAM to run in.

Is the SPL always open source?

Not necessarily — it varies by SoC vendor, and it’s common for parts of it to be a closed vendor-supplied binary, especially DRAM initialization code.

What happens if ROM code can’t find a valid image on any storage device?

Many implementations fall back to reading a raw byte stream over USB or UART, primarily intended for factory programming rather than normal boot.

 

Next: The Full Bootloader Takes Over

Continue this free embedded Linux course to see how the full bootloader, now running in DRAM, finds and
starts the Linux kernel itself.

Leave a Reply

Your email address will not be published. Required fields are marked *