Chapter 10: C + startup.S + linker script¶
Chapter 9’s assembly model reaches the LED without a C runtime. Rewriting its loop in C ought to be a small change, but C arrives with expectations: a usable stack, initialized globals and zero-initialized static storage. The compiler generates code on that basis. It does not arrange those conditions for this flat, ROM-loaded image.
We will keep the same LED and supply the missing work before calling main(). The linker script describes where code and objects belong. startup.S uses that description to prepare memory. Seeing those two files agree is more useful than treating startup as a block to paste into every project.
10.1 What an initialized global needs¶
Consider a C file with three globals:
int x = 7; // initialized → .data
int y; // uninitialized → .bss
const int z = 42; // const + initialized → .rodata
On Linux, the loader establishes the ELF’s loadable memory segments and zero-filled storage before the C runtime calls main(). Here the Boot ROM loads our image, but it does not interpret the ELF or initialize C objects. We give it a flat binary wrapped in an IVT.
So we, ourselves, must:
Set an aligned stack pointer. A C function may use the stack even when its source has no obvious local array or function call.
Zero
.bss. Otherwiseyis whatever was in OCRAM when we arrived.Copy
.datafrom its load location to its run location when those differ. This is the LMA-versus-VMA distinction from Chapter 6.Branch to
main.
Optionally, also: set up exception vectors, configure caches, enable the FPU. We do these later as we need them.
These are promises made to C before it runs. Preparing data means putting the initial value at its runtime address. In our first layout it is already there, so a copy is not yet a relocation test.¶
10.2 A linker script worth keeping¶
Create ~/imx6ull/src/ch10-c-startup in your editor. Save the four complete listings in this chapter as link.ld, startup.S, main.c and Makefile. Copy Chapter 9’s wrap.py into this directory. In each new build terminal, run . ~/imx6ull/scripts/env.sh before entering the directory.
The Chapter 9 program needed only .text. Now we also describe initialized storage, zero-initialized storage and space for a stack. Save this as link.ld:
ENTRY(_vectors)
MEMORY
{
OCRAM (rwx) : ORIGIN = 0x00908000, LENGTH = 0x00018000 /* 96 KB */
}
SECTIONS
{
. = ORIGIN(OCRAM);
.text ALIGN(32) : {
KEEP(*(.vectors)) /* room for vector table later (Ch 15) */
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > OCRAM
/* Mark the end of .text -- LMA of .data begins here. */
_etext = .;
/*
* .data : runtime in OCRAM, image-time directly after .text.
* Because OCRAM and our image both live in the same region, LMA == VMA
* for this layout. The startup copy writes each word back to itself.
* Keep the source and destination explicit for a later split layout.
*/
.data ALIGN(4) : AT(_etext) {
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > OCRAM
_sidata = LOADADDR(.data);
.bss ALIGN(4) (NOLOAD) : {
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > OCRAM
_stack_top = ORIGIN(OCRAM) + LENGTH(OCRAM);
_stack_limit = _stack_top - 0x1000; /* reserve 4 KiB after ROM handoff */
ASSERT(_ebss <= _stack_limit, "Objects overlap the reserved stack")
ASSERT(_edata <= 0x00918000, "Loaded bytes overlap ROM-active RAM")
/DISCARD/ : { *(.note*) *(.comment) *(.ARM.exidx*) *(.ARM.extab*) }
}
Decoded line by line:
ENTRY(_vectors)records the ELF entry at our first instruction,b _start, at0x00908000. The wrapper uses the same address in the IVT._startitself follows the eight vector slots. The ROM reads the IVT, not this ELF field.MEMORY { OCRAM ... }: describes our one available region.ORIGINis where the wrapper places the first program byte.LENGTHcovers the remaining 96 KB up to the end of OCRAM at0x00920000.. = ORIGIN(OCRAM);: the location counter starts at the region’s base..textsection: gathers all.text*,.rodata*, plus aKEEP(*(.vectors))placeholder for a future vector table.KEEPtells the linker not to remove this as unused, even if no symbol references it. The final. = ALIGN(4);makes_etextword-aligned._etext = .;: captures the location counter. This is where.textends. It is also where the.dataload image will be placed (see next line)..data ALIGN(4) : AT(_etext):ALIGN(4)aligns the runtime address.AT(_etext)sets the load address. Notice their positions around the colon. GNUldrejects.data : ALIGN(4) AT(_etext)becauseAT(...)must come before a post-colonALIGN(...)._sidata = LOADADDR(.data): asks the linker for.data’s actual load address. Startup code uses this symbol as its copy source._sdata/_edata: boundary symbols our startup uses to know how much to copy..bss (NOLOAD)reserves zero-initialized storage without carrying its contents in our load image. Startup clears the range between_sbssand_ebss. We round both data-section ends to four bytes because our loops copy or clear whole words._stack_top/_stack_limitreserve 4 KiB for a descending stack. This is a budget, not an automatic stack-overflow guard. Loaded bytes must fit below0x00918000while ROM is active. Our own stack uses the upper OCRAM only after handoff, without returning to ROM services./DISCARD/: throws away ELF notes and attributes that have no place in a bare-metal binary.
Four layout decisions explain common startup failures:
Putting
ALIGNandATin the wrong order. Use.data ALIGN(4) : AT(_etext). Linker-script keywords have a fixed grammar, andldreports only a line number when this order is wrong.Forgetting
KEEParound the vector table. When you later link with-gc-sections, the linker removes the table because nothing in C references it.KEEPprevents this.Assuming the linker moves bytes.
AT(...)describes a load address. It does not perform the startup copy. Without an explicit LMA, GNU ld uses layout-dependent rules, not a universal promise that it equals the VMA.Assuming
.bssneeds stored zeros. Ordinary ELF.bssis normallyNOBITSeven withoutNOLOAD. Check its type and the raw binary size. The explicitNOLOADmakes our layout’s intention clear.
10.3 startup.S, the bridge from reset to main¶
.syntax unified
.cpu cortex-a7
.arm
.section .vectors, "ax"
.align 5 @ vector table must be 32-byte aligned
.global _vectors
_vectors:
b _start @ Reset, replaced in Ch 15
b . @ Undef
b . @ SVC
b . @ Prefetch abort
b . @ Data abort
b . @ Reserved
b . @ IRQ
b . @ FIQ
.section .text.startup, "ax"
.global _start
_start:
/* Direct entry from a fresh open-device ROM boot, not U-Boot.
Mask asynchronous interrupts and select our working mode. */
cpsid if, #0x13 @ mode = SVC, mask IRQ+FIQ
/* Use our aligned placeholder vectors instead of ROM handlers. */
mrc p15, 0, r0, c1, c0, 0
bic r0, r0, #(1 << 13) @ SCTLR.V = 0, use VBAR
mcr p15, 0, r0, c1, c0, 0
ldr r0, =_vectors
mcr p15, 0, r0, c12, c0, 0
isb
/* Stack: top of OCRAM, defined by the linker. */
ldr sp, =_stack_top
/* Zero .bss : for (p = &_sbss; p < &_ebss; p++) *p = 0; */
ldr r0, =_sbss
ldr r1, =_ebss
mov r2, #0
1: cmp r0, r1
strlo r2, [r0], #4
blo 1b
/* Copy .data from LMA to VMA. In our current layout they are equal,
so each word is copied onto itself. When .data moves to another
memory region, this same loop performs the required relocation. */
ldr r0, =_sidata @ source (LMA)
ldr r1, =_sdata @ destination (VMA)
ldr r2, =_edata
2: cmp r1, r2
ldrlo r3, [r0], #4
strlo r3, [r1], #4
blo 2b
/* main(void) takes no arguments. */
bl main
/* main() should never return. If it does, halt cleanly. */
hang:
wfi
b hang
A few notes on the assembly choices:
cpsid if, #0x13is acpsinstruction with the side effect of setting the mode bits to0b10011(SVC) and the I and F mask bits. One instruction. Three guarantees.strlo r2, [r0], #4stores a word, then advances the pointer by four bytes when the preceding unsigned comparison saysr0 < r1.lois the condition-code spelling for unsigned lower, not an AAPCS-specific flag.bl mainrecords a return address in LR and branches. Ifmainreturns,hangrepeatedly executeswfi. This supplies a defined place to stop, not a full power-management configuration..section .text.startupputs our startup code in a named subsection. The linker script’s*(.text*)matches.text.startupand pulls it in early. We could put it in plain.text, but the explicit name makes the startup code easier to find.The vector table contains a reset-slot branch followed by seven self-loops. We install its address in VBAR here, but do not enable IRQ/FIQ. Chapter 15 replaces the placeholders with useful handlers.
Do not read CPU reset defaults as ROM handoff defaults. RM 8.4.4 says the ROM enables the instruction cache during download and disables the data caches and MMU after authentication. This startup is for that direct ROM path. A jump from an existing bootloader may require cache cleanup, MMU changes and peripheral reinitialization that this listing does not supply. Chapter 17 handles our own memory attributes and caches.
10.4 main.c, the LED, again¶
#include <stdint.h>
#define REG(addr) (*(volatile uint32_t *)(addr))
#define CCM_CCGR1 0x020C406C
#define IOMUX_MUX 0x020E0068
#define IOMUX_PAD 0x020E02F4
#define GPIO1_DR 0x0209C000
#define GPIO1_GDIR 0x0209C004
#define LED_BIT (1u << 3)
static void delay(volatile uint32_t n)
{
while (n--) { asm volatile ("nop"); }
}
int main(void)
{
REG(CCM_CCGR1) |= (3u << 26); /* GPIO1 clock on */
REG(IOMUX_MUX) = 5; /* ALT5 = GPIO */
REG(IOMUX_PAD) = 0x17059; /* vendor LED pad setting */
REG(GPIO1_DR) |= LED_BIT; /* preload LED off */
REG(GPIO1_GDIR) |= LED_BIT; /* output */
for (;;) {
REG(GPIO1_DR) ^= LED_BIT;
delay(50000000u);
}
}
Three things that look small but matter:
volatileon the cast. Withoutvolatile, the optimizer is free to assumeREG(GPIO1_DR)reads always return the same value, and to elide the second read entirely in a tight loop. Withvolatile, the compiler emits a real load-store every time. Every MMIO access in this book isvolatile.The busy delay. The volatile counter and
asm volatile ("nop")keep observable work in the loop. Neither makes the duration precise. Anopis not a memory barrier. Chapter 16 replaces instruction counting with a timer.(3u << 26). Unsigned masks make bitwise intent explicit. This particular signed shift would also fit a 32-bitint, but higher-bit masks may not.
10.5 The Makefile¶
CROSS := arm-none-eabi-
CC := $(CROSS)gcc
LD := $(CROSS)ld
OC := $(CROSS)objcopy
SIZE := $(CROSS)size
ARCH := -mcpu=cortex-a7 -marm -mfloat-abi=soft -mgeneral-regs-only
CFLAGS := $(ARCH) \
-ffreestanding -fno-builtin -nostdlib \
-fno-common -fno-unwind-tables -fno-asynchronous-unwind-tables \
-MMD -MP -O2 -g -Wall -Wextra -Werror=implicit-function-declaration
LDFLAGS := $(ARCH) -T link.ld -nostdlib
OBJS := startup.o main.o
DEPS := $(OBJS:.o=.d)
all: led.bin
%.o: %.S Makefile
$(CC) $(CFLAGS) -c -o $@ $<
%.o: %.c Makefile
$(CC) $(CFLAGS) -c -o $@ $<
led.elf: $(OBJS) link.ld Makefile
$(CC) $(LDFLAGS) -o $@ $(OBJS) -lgcc
$(SIZE) $@
led.bin: led.elf
$(OC) -O binary $< $@
clean:
rm -f $(OBJS) $(DEPS) led.elf led.bin led.imx
-include $(DEPS)
.PHONY: all clean
A couple of flags worth highlighting:
-marm -mfloat-abi=soft -mgeneral-regs-onlykeeps these early integer-only C examples in ARM state and prevents GCC from using VFP/NEON registers before we enable that hardware. Use the same architecture/ABI options at link time. We explicitly add-lgccafter our objects for compiler helpers such as integer division. This is not libc.-MMD -MPandMakefileprerequisites rebuild objects when included headers or build flags change. Otherwise an old object can survive an apparently successfulmake.-fno-common: forces every uninitialized global into.bssinstead of “common” symbols. Without this, two files declaringint foo;would merge without a clear warning. That is convenient on hosted Linux and dangerous on bare-metal.-Werror=implicit-function-declaration: we never tolerate “I forgot to include the header.” It is one of the cheapest bugs to prevent.-O2 -gtogether, optimize but keep DWARF. The-gdoes not affect the binary. It only enlarges the ELF.
10.6 Building and running¶
$ make
$ arm-none-eabi-size led.elf
$ arm-none-eabi-readelf -h led.elf
$ arm-none-eabi-nm -n led.elf
$ wc -c led.bin
A few observations:
textgrows. The image now includes the vector slots, memory-initialization loops and compiled C. Read your own size output rather than expecting one fixed byte count.datais 0. No initialized globals in our C.bssis 0. No uninitialized globals.
The loops are present, but their ranges are empty. A worked variant uses an initialized object instead of the LED_BIT macro:
static volatile uint32_t led_mask = (1u << 3); /* stored in .data */
In that variant, led_mask replaces each use of LED_BIT. The volatile qualifier keeps GCC from replacing this demonstration object with a constant. The section-inspection commands are:
$ arm-none-eabi-size led.elf
$ arm-none-eabi-readelf -SW led.elf
$ arm-none-eabi-nm -n led.elf
The object occupies four bytes. Compare _sidata and _sdata: in this layout they are equal. The ROM has already placed the initial value at its runtime address. The loop copies it onto itself, so disabling the copy would not demonstrate a failure. A separate source and destination are needed to test relocation, as in the later DDR work.
Check _vectors and the ELF entry at 0x00908000. _start is after the vector slots. If GNU ld reports an RWX load-segment warning, it describes this small combined code/data layout. It is not a report that the MMU has installed page permissions.
Wrap into .imx with the same wrap.py from Chapter 9 and push:
$ python3 wrap.py
$ sudo "$IMX6ULL_HOME/build/mfgtools/uuu/uuu" led.imx
An LED blink from this image would show that startup reached compiled C. That observation alone does not test empty .bss or a relocated .data section. Section 10.10 distinguishes those memory-initialization guarantees.
10.7 Stepping through with objdump¶
It is worth reading the disassembled startup once. After make:
$ arm-none-eabi-objdump -d led.elf | head -80
Find _start. Follow the working-mode and vector setup, then the stack, clear, copy and call blocks:
The mode-setting
cpsidinstruction.The
ldr sp, =_stack_topliteral load.The
.bsszero loop.The
.datacopy loop.The
bl main.
The literal pool follows the function. You can see the resolved addresses there.
A different linker-script OCRAM origin changes every code address. For example, an origin of 0x00909000 would no longer agree with the wrapper’s default entry at 0x00908000. The image wrapper’s entry and code placement must change with the linker layout, not independently of it.
10.8 What if main() returns?¶
In startup.S, after bl main, we fall through to a hang loop. In normal bare-metal code, main() should never return. During development, it can happen by accident, for example from an unintended return or if (...) return;. The hang loop gives you a stable failure state instead of letting execution continue into unknown memory.
You can make the dependency explicit by giving main the __attribute__((noreturn)):
__attribute__((noreturn)) int main(void) { ... }
GCC then warns if main has a code path that returns. Optional but informative.
10.9 Why volatile, one more time¶
A common bug:
*(uint32_t *)CCM_CCGR1 |= (3u << 26);
Without volatile, the compiler is allowed to:
assume that
*(uint32_t *)CCM_CCGR1does not change between reads,merge consecutive accesses to the same address,
remove or combine accesses when ordinary-memory rules permit it.
In the LED program, these freedoms produce code that happens to work, because we touch each register exactly once. But the moment you write code like:
while ((REG(UART_STATUS) & TX_EMPTY) == 0) {}
…without volatile, the compiler treats UART_STATUS as constant inside the loop, reads it once before the loop, and spins forever. Most embedded engineers hit this bug once. Avoid it by reflex.
Use volatile access for MMIO, but keep reading the register definition. REG(x) |= mask is still a read-modify-write operation. It can be wrong for write-one-to-clear status bits or registers with read side effects. Volatile also does not supply atomicity, cache maintenance or CPU memory ordering. Those are separate decisions.
10.10 Key Notes¶
The linker describes placement and exports boundaries. Startup establishes the aligned stack and object contents before calling C. Neither the compiler nor the IVT supplies that runtime preparation.
.bssreserves storage without carrying initialization bytes in the flat image. A nonempty clear range is necessary to establish C’s zero-initialization guarantee, regardless of OCRAM’s previous contents.Equal
.dataLMA and VMA make the copy loop a self-copy. The initializedled_maskcase explains stored data but cannot demonstrate relocation or expose an omitted copy.In a deterministic clear case, a four-byte
.bsscounter containing0xA5A5A5A5becomes zero after startup clears its range. A naturally zero SRAM value would not distinguish a working clear from an omitted one.The reserved stack and loaded-byte limits protect different lifetimes: ROM-active loading and post-handoff execution. Link-time bounds do not provide a runtime stack-overflow guard.
volatilepreserves MMIO accesses at the compiler boundary. Register side effects, read-modify-write safety, CPU ordering and cache attributes remain separate responsibilities.
10.11 Pitfalls¶
bssnot zeroed. Symptom: nondeterministic startup behavior across resets. Cause: forgot the loop, or got the_sbss/_ebsssymbols wrong in the linker script..datanot copied in a split layout. When LMA differs from VMA, the initial values must reach the runtime destination before C reads them. The current equal-address layout cannot expose a missing copy.Stack not aligned at function entry. AAPCS requires SP to be 8-byte aligned at every public function entry.
_stack_top = ORIGIN + LENGTHaligns naturally as long as LENGTH is a multiple of 8. Change LENGTH to an odd value and expect crashes inside libgcc helpers.-fno-commonnot set. Twoint foo;declarations in two.cfiles merge into one symbol without a clear warning. Sometimes the result works, sometimes it corrupts memory. Always enable.Forgot
volatile. Discussed above.Linker script does not declare
.rodata. GCC may emit string literals into.rodata, which falls through to the next region. We folded.rodatainto.texthere. If you split them out, make sure both are placed in OCRAM.Misaligned section boundaries. Our word loops require aligned starts and rounded-up ends. An unrounded byte-sized object can otherwise make the final word store cross a section boundary. Do not rely on a particular alignment-fault configuration to catch it.
10.12 Going deeper¶
LLVM’s
lldmanual has a much shorter introduction to the same concepts, useful for the second-time reader.arm-none-eabi-gcc -E -P -x c /dev/null -include stdint.h: inspect the target’s integer typedefs. Do not put shell redirection brackets around the header name.The U-Boot source’s
arch/arm/lib/crt0.S, read it after this chapter. The patterns are the same.