What Does Clearing .bss Mean in Early Kernel Boot?
Somewhere in the first few hundred instructions of every kernel you’ll ever read, there’s a loop that writes zeros to a range of memory. The comment, if there is one, says:
; clear .bss
And that’s it. No explanation. No why. Just the assertion that this is a thing that must happen, and if you don’t do it your kernel will fail in strange and personal ways.
This note is the long answer to “why is that loop there?”
To really answer it, we have to back up further than .bss. We have to back up to the CPU, because the CPU is the actual hardware doing the work, and the CPU has no idea what a section is, what a symbol is, what a global variable is, or what zero-initialization means. The CPU has an instruction pointer, some registers, and a bus to memory. Everything else — sections, symbols, segments, linkers, loaders, the C language — is a fiction layered on top of that hardware to make it tractable to program.
.bss is one of those fictions. Clearing it is the moment that fiction has to become real.
1. Start with what the CPU actually sees
Forget C for a moment. Forget ELF. Forget the linker.
Here is what an x86-64 CPU does after reset, in caricature:
RIP = 0xfffffff0 ; instruction pointer points at reset vector
loop forever:
fetch instruction at RIP
decode
execute (which may change RIP, registers, or memory)
That’s the whole loop. The CPU does not know which bytes are “code” and which are “data”. It doesn’t know which bytes are “supposed to be zero”. It will happily execute any byte sequence as an instruction. It will happily read any address and get whatever happens to be there. There is no validation, no zero-fill, no concept of “uninitialized memory.” Memory is just a giant array of bytes hooked up to a bus, and the CPU asks for byte number N and gets back whatever number N currently contains.
Power on a fresh-from-factory machine and DRAM contains essentially random bits. Reset a machine that was just running Linux and DRAM still contains Linux. The CPU does not care. It will read those bits.
So when you ask “what is .bss?” — the honest first answer is: the CPU has no idea. .bss is not a hardware concept. It is something invented entirely on the software side to solve a software problem.
The interesting question is which software problem.
2. The software problem: where do C’s variables live?
C’s mental model of memory is roughly:
a flat array of bytes, addressable from 0 to some maximum,
in which variables, code, and dynamic allocations all reside
When you write:
int counter;
static char buffer[4096];
int initialized = 123;
void hello(void) { counter++; }
The C language has promises about each of these:
counterexists at some address for the entire program lifetime, and starts at zero.bufferexists at some address for the entire program lifetime, and every byte starts at zero.initializedexists at some address for the entire program lifetime, and starts holding the value 123.helloexists at some address for the entire program lifetime, and the bytes at that address decode to the right instructions to do what the function body says.
The C compiler’s job is to produce bytes that satisfy these promises. But the compiler is not the one putting the bytes into RAM. The compiler produces an object file, which is a description of bytes and where they belong. Something else — the linker, the loader, the bootloader, the kernel — has to actually place those bytes into the right addresses in the running machine.
So the compiler needs a way to describe the bytes. It needs a vocabulary for saying:
- “These bytes are the code for
hello. They should be at some address. They are read-only and executable.” - “These bytes are the value 123, the initial value of
initialized. They are read-write.” - “There need to be 4 bytes for
counter, and they should be zero. I’m not going to spell out the zero bytes; just reserve space.” - “There need to be 4096 bytes for
buffer, all zero. Same deal.”
That vocabulary is sections. A section is a labeled chunk of bytes (or a labeled chunk of “space that should be bytes”) that the compiler emits and the linker arranges.
The names are conventions:
.text— executable code.rodata— read-only data, like string literals andconstglobals.data— writable globals with non-zero initial values.bss— writable globals whose initial value is zero
The CPU still doesn’t know about these. They’re labels in the object file’s metadata. But they’re useful labels, because they let later tools make smart decisions: the linker uses them for layout, the loader uses them for permissions, the file format uses them for size optimization. And one of those optimizations is the entire reason .bss exists as a distinct section.
3. Why .bss is separate from .data
.bss is separate from .dataThis is the deep cut on the mental model. Both .data and .bss hold writable globals. Both end up in normal RAM, with normal read-write permissions, indistinguishable to the CPU once the program is running. So why are they two sections instead of one?
The answer is: .data has bytes the file must carry, .bss does not.
Consider:
int x = 0x12345678; // .data
static char heap[16 * 1024 * 1024]; // .bss
The initial value of x is 0x12345678. There is no way to derive that value other than to write it into the file. The compiler emits four bytes into the .data section: 78 56 34 12. They live in the executable file. The loader copies them from disk into RAM.
The initial value of heap is 16 mebibytes of zeros. The compiler could emit 16 MiB of zero bytes into the .data section, and the loader would dutifully copy them from disk to RAM. But that’s absurd: the bytes carry no information. They’re just zero. Writing them into the file makes the file 16 MiB larger for no reason.
So someone, decades ago, said: let’s have a second section for writable globals whose initial value is known to be zero. We don’t need to store its bytes in the file. We just need to record its size and address. At load time, we’ll zero out that region of memory.
That’s .bss. The name historically comes from the IBM 7090 assembler instruction “Block Started by Symbol”, which referred to uninitialized blocks. The name stuck, the meaning shifted, and now .bss means “section whose contents are zero, materialized at load time, not stored in the file.”
In ELF, this is a section with type SHT_NOBITS. The section header says “this section occupies N bytes starting at address A” but it has no file offset and no bytes in the file. It is pure metadata.
If you ever run size some_program you’ll see:
text data bss dec hex filename
1234 567 89012 90813 162bd some_program
The bss column is purely a promise about memory. It costs nothing on disk.
This is the first piece of the mental model: .bss is a contract that says “at runtime, somebody will arrange for N bytes of zeros to exist at address A.” The file doesn’t carry those bytes. Someone has to fulfill the contract.
In a hosted environment, the OS fulfills it. In a freestanding kernel, you fulfill it. That’s the entire mystery of “clearing .bss” — it’s the kernel fulfilling its own runtime contract because nobody else will.
4. The journey of int counter;
int counter;Let’s follow one variable all the way from source code to a zero in RAM, because this is where the mental model crystallizes.
Step 1 — Compiler sees source.
int counter;
This is a tentative definition in C terms. The compiler sees no initializer, treats the initial value as zero, and emits a symbol counter of size 4 bytes (assuming 32-bit int) into the .bss section of the object file:
object file: hello.o
.text: [bytes for any functions]
.rodata: [string literals etc]
.data: [bytes for x = 0x12345678]
.bss: [size=4, no bytes]
symbol table:
counter -> offset 0 of .bss, size 4
x -> offset 0 of .data, size 4
...
The object file does not yet know where counter will live in memory. It just knows that counter is in .bss, four bytes long, name counter.
Step 2 — Linker combines object files.
You compile a bunch of .c files into .o files, then link them. The linker reads every input object file, looks at each one’s sections, and concatenates same-named sections together (with alignment padding):
combined .bss:
hello.o's .bss (offset 0..4) counter
alloc.o's .bss (offset 8..16400) heap_pool
log.o's .bss (offset 16400..16432) log_buffer
...
Now the linker has one big .bss blob. It needs to assign it an actual address. This is where the linker script comes in.
Step 3 — Linker script picks the addresses.
For a user-space program on Linux, the linker uses a default script that places things at conventional addresses (.text at 0x400000 on x86-64, etc). For a kernel, you usually write your own:
SECTIONS
{
. = 0xffffffff80100000; /* start placing things at this virtual address */
.text : { *(.text*) }
.rodata : { *(.rodata*) }
.data : { *(.data*) }
.bss : {
__bss_start = .;
*(.bss*)
*(COMMON)
__bss_end = .;
}
}
The . is the “location counter” — the current address. After processing .text, .rodata, and .data, the location counter is at some address A. The linker assigns the .bss section to start at A, gives counter the address A, gives heap_pool the address A + 8, and so on, advancing . as it goes. By the end, . points just past the end of all the .bss symbols, and that value gets stored in the symbol __bss_end.
After linking, counter has a real, final address. The address is baked into every instruction that references it:
mov eax, [counter] ; assembled as: mov eax, [0xffffffff80123450]
The address is no longer abstract. It is the address that, at runtime, will be read from or written to. The CPU will literally generate that address on the address bus.
Step 4 — File is written.
The linker emits an ELF file. For .text, .rodata, .data, it writes the actual bytes into the file. For .bss, it writes nothing — just records in the program headers that “addresses A through B should exist in memory and be zero, but I’m not giving you any bytes for them.”
In ELF speak, the relevant program header (segment) will have p_filesz < p_memsz. The difference, p_memsz - p_filesz, is the amount the loader must zero-fill.
Step 5 — Something loads the file into RAM.
For a user-space program, this is the kernel’s ELF loader (fs/binfmt_elf.c if you’re poking around Linux). It mmaps the file into memory for the chunks that have file bytes, and for the trailing region where p_memsz > p_filesz, it allocates zero pages and maps them in.
For a kernel, this might be:
- A multiboot-compliant bootloader (GRUB) parsing the kernel’s ELF program headers and zero-filling the
.bsssegment, or - A simpler bootloader that loads the kernel as a flat blob and doesn’t zero anything, leaving the responsibility to early kernel code, or
- A firmware shim (UEFI) that hands control to the kernel after doing nothing to
.bss.
Step 6 — First read of counter.
Somewhere in kmain:
counter++;
This compiles to a load from address 0xffffffff80123450, an increment, and a store back. The load needs to return zero, because the C language says counter started at zero.
If .bss was zeroed (by the loader, or by the early boot code), the load returns 0. The increment makes it 1. The store writes 1 back. Everything works.
If .bss was not zeroed, the load returns whatever bits were at that address when the machine powered on. The increment adds 1 to that. The store writes the corrupted value back. Now counter is some random number. If anything else in the kernel checks if (counter == 0), the check fails for reasons that have no explanation in the source code.
That’s the whole story. Every int counter; in your kernel is a promise that the boot code has to fulfill.
5. Why are there sections at all, if the CPU doesn’t use them?
Take a step back. The CPU just wants an address. It will read or write any address with no concept of “section.” So why does any of this section infrastructure exist?
Three reasons, all on the software side:
Reason 1: Permissions.
Once you have a memory management unit (MMU), each page of memory can be marked read-only, read-write, executable, non-executable. Modern OSes use this aggressively: .text is read-only and executable, .rodata is read-only and non-executable, .data and .bss are read-write and non-executable. If the CPU tries to execute bytes in .data, it traps. If it tries to write to .rodata, it traps. This catches whole classes of bugs and entire classes of exploits.
But MMU permissions are per page, not per symbol. So the linker has to group symbols with the same desired permissions onto the same pages. Sections are how that grouping happens. The linker sorts symbols into sections by intended permission, the loader maps each section’s pages with the right permissions.
If there were no sections, the linker couldn’t tell the loader “make this range executable but not writable.” Everything would have to be either RWX (no protection at all) or you’d need per-symbol metadata, which would be enormous overhead.
Reason 2: File-size optimization.
Already covered: .bss exists separately from .data because zero bytes shouldn’t have to be stored on disk. This is also why some systems have things like .tdata and .tbss (for thread-local data — same split, same reason), or .note sections that carry metadata.
Reason 3: Tool ergonomics.
The linker needs to know what each chunk of bytes is for so it can:
- Resolve symbol references (jump to address X means “address of section Y plus offset Z”).
- Apply relocations correctly (a 32-bit PC-relative offset versus a 64-bit absolute address).
- Discard or merge sections (e.g.,
.gnu.linkoncestyle deduplication). - Order sections sensibly (hot code together, cold code together).
- Produce debugging info that maps source lines to addresses.
So sections are a vocabulary the compiler uses to describe the bytes it emits, the linker uses to combine and place them, the loader uses to load and protect them, and debuggers and profilers use to understand the resulting binary.
None of this is the CPU’s business. The CPU just wants an address. The sections are how the toolchain ends up at the right addresses.
This is why “sections” feel so mysterious when you first encounter them: they’re an entirely software-side abstraction with no hardware correlate, but they shape how every binary on the system is laid out, and getting them wrong breaks programs in ways that are very hard to debug because the CPU happily executes the wrong-shaped bytes.
6. Sections vs segments (the ELF deep cut)
A point that confuses everyone the first time: in ELF, there are two parallel descriptions of the same file.
-
Sections are the linker’s view. There are typically dozens:
.text,.rodata,.data,.bss,.eh_frame,.note.gnu.build-id,.debug_info, and so on. They are fine-grained, named, and described by the section header table. -
Segments (a.k.a. “program headers”) are the loader’s view. There are typically four or five: one for executable code, one for read-only data, one for read-write data, maybe a PHDR and an INTERP. They are coarse-grained, described by the program header table, and they’re what the loader actually maps.
The linker takes the fine-grained sections and groups them into segments based on permissions. All read-only-executable sections (.text, .init, .plt) go into the same LOAD segment with R+X permissions. All read-only sections (.rodata, .eh_frame) go into another LOAD segment with R only. All read-write sections (.data, .bss) go into a third LOAD segment with R+W.
The loader doesn’t care about individual sections. It walks the program headers and maps each segment. The mapping for the R+W segment has the crucial property:
p_filesz < p_memsz
The first p_filesz bytes come from the file (this is the .data content). The remaining p_memsz - p_filesz bytes are zero-filled (this is where .bss is materialized).
That’s the actual mechanism by which a normal user-space .bss becomes real memory: the loader does mmap with a portion file-backed and the rest anonymous (zero-filled). The kernel’s ELF loader handles this without you knowing.
Why does this matter for kernel boot? Because if you load the kernel via an ELF-aware bootloader (like GRUB with multiboot2 ELF support), the bootloader walks the program headers and does the zero-fill for you. Your .bss is already zero by the time kmain runs. You don’t need a manual loop.
But if your bootloader loads the kernel as a flat binary (just bytes pasted into RAM at some address), it doesn’t know about program headers. It doesn’t know p_filesz < p_memsz for some segment. It just pastes the file content. The .bss region after the loaded image is whatever DRAM happened to contain. You now have to clear it.
This is why the rule of thumb is: if your bootloader handed off a real ELF load, .bss may already be clear. If you loaded a flat binary, it isn’t. And since you don’t always control your bootloader’s behavior, kernels almost universally clear .bss themselves anyway, as a defensive measure. It’s cheap, and the cost of being wrong is catastrophic.
7. The linker script as the architectural drawing
The linker script is where the abstract section model meets the concrete memory layout. Worth understanding because it’s where you see the boundary between “C’s view of the world” and “RAM’s view of the world.”
A minimal kernel linker script for x86-64:
ENTRY(_start)
SECTIONS {
. = 0xffffffff80100000;
.text : ALIGN(4K) {
*(.text._start) /* put _start at the front */
*(.text*)
}
.rodata : ALIGN(4K) {
*(.rodata*)
}
.data : ALIGN(4K) {
*(.data*)
}
.bss : ALIGN(4K) {
__bss_start = .;
*(.bss*)
*(COMMON)
. = ALIGN(8);
__bss_end = .;
}
/DISCARD/ : {
*(.eh_frame)
*(.note*)
}
}
What is going on here:
ENTRY(_start)tells the linker which symbol is the program’s entry point.. = 0xffffffff80100000;sets the location counter to a virtual address up in the kernel half of the address space.- Each
SECTION_NAME : { ... }defines an output section. The*(.text*)pattern says “pull in every input section whose name starts with.text.” __bss_start = .;defines a symbol at the current location counter. It is not a variable; it is a name whose value is an address. The linker emits no bytes for it; it just records that the symbol__bss_startresolves to whatever value.had at that point.__bss_end = .;does the same after.bssis laid out.
Two things click into place when you understand this.
First, symbols and variables are different things. A C variable is an address that has storage. A linker symbol is an address that may or may not have storage. counter is a symbol whose address points to 4 bytes of writable storage. __bss_start is a symbol whose address is the start of .bss — there is no separate storage for __bss_start; the symbol’s value is the address.
This is why C code declares them as:
extern char __bss_start[];
extern char __bss_end[];
Not as extern char *__bss_start (which would be a pointer variable that itself has storage somewhere). The array notation says “this is a symbol whose address you can take.” Then __bss_start evaluates to the address itself, and you can write &__bss_start[0] or just __bss_start to get the start of the region.
Second, the linker script is what makes the C world and the hardware world agree on addresses. Without the linker script, the linker would have to guess. With it, you tell the linker: “I want .text at this address because that’s where the bootloader is going to drop me. I want .bss immediately after .data because contiguous layout makes the clear-loop easy. I want everything page-aligned because the MMU works on page granularity.”
The linker bakes those choices into the binary. Every mov [counter] instruction now contains the address counter would have if the binary were loaded as the script says. If you load it somewhere else, the addresses are wrong, and the CPU dutifully accesses the wrong memory.
(This is why position-independent code exists, but that’s a different essay.)
8. Where C’s promises meet hardware reality
The C standard talks about the “C abstract machine.” It is a model in which:
- Memory is a flat array of bytes.
- Objects with static storage duration and no explicit initializer are zero-initialized before program startup.
- Objects with static storage duration and an explicit initializer get that initializer before startup.
- Objects with automatic storage duration (locals) are uninitialized unless explicitly initialized.
That’s the contract. The C compiler, the C standard library, and the runtime collectively conspire to make the real hardware behave like that abstract machine.
In a hosted environment (your laptop), the conspiracy goes:
kernel ELF loader
-> mmaps file-backed segments at their target addresses
-> mmaps anonymous (zero-filled) pages where p_memsz > p_filesz
-> jumps to _start in crt1.o
crt1.o (statically linked into every program)
-> sets up the stack frame
-> calls __libc_start_main
__libc_start_main
-> runs constructors (.init_array)
-> calls main()
By the time main runs, the abstract machine’s contract is fully honored. Every static variable is at the value the C standard says it should be at.
In a freestanding kernel, the conspiracy is much shorter, and you are most of it:
firmware / bootloader
-> loads kernel image into RAM somehow
-> jumps to your entry point (typically still in assembly)
your assembly entry
-> sets up a stack
-> may set up paging
-> zeros .bss
-> calls kmain
kmain
-> the C abstract machine is now real, for your kernel
You are the loader. You are the runtime. You are the only one who is going to make int counter; actually start at zero. There is no other layer to defer to. If you don’t write that loop, the abstract machine’s contract is broken, and the C language is essentially lying to you about every global.
This is the mental shift the post is really about. In hosted environments, “the runtime” feels like a thing other than your code. In a freestanding environment, the runtime is your code, and clearing .bss is the moment you take on the loader’s job.
9. What’s actually in .bss’s memory before you clear it?
.bss’s memory before you clear it?Worth dwelling on, because “uninitialized” sounds vague. The bytes have some value. What value?
It depends on a few things:
Power-on: DRAM cells settle into mostly-zero, mostly-random, or mostly-one states depending on the manufacturing process and the specific chips. Some patterns are stable (a particular cell tends to come up as 1); others are noise. Don’t rely on any pattern.
Warm reboot: DRAM keeps its contents through a warm reset because it’s continuously refreshed. So a warm reboot starts with whatever the previous OS left in RAM. If you booted Linux, used it for a while, then rebooted into your kernel, your .bss region might contain pieces of Linux’s kernel data, page cache, your browser’s memory, anything. This is a famous source of “works the first boot, fails on reboot” bugs.
ECC RAM: If ECC scrubbing is active, bits may be in a “consistent” state but still unpredictable.
Hypervisor-provided VM: Most hypervisors zero new VM memory before handing it to the guest. So .bss may coincidentally be zero on the first boot of a fresh VM, masking the bug entirely. Then you boot on real hardware and the kernel falls over.
Bootloader scratch: If the bootloader used some region for its own data structures, then loaded your kernel into a region that overlaps or is adjacent to where it scratched, your .bss may contain bootloader leftovers. Sometimes these have recognizable patterns (0xDEADBEEF, 0x55AA, etc.) which is a useful debugging hint.
The general truth: .bss memory before clearing contains arbitrary bytes that are not under your control. Anything that depends on those bytes being zero is broken.
10. Anatomy of a .bss-failure bug
.bss-failure bugLet’s walk through how this goes wrong, because the failure modes are exactly the kind of thing that make you doubt your sanity.
struct allocator {
uintptr_t base;
uintptr_t end;
uintptr_t next;
};
static struct allocator early_heap;
void heap_init(uintptr_t base, size_t size) {
early_heap.base = base;
early_heap.end = base + size;
early_heap.next = base;
}
void *bump_alloc(size_t n) {
if (early_heap.next + n > early_heap.end) return NULL;
void *p = (void *)early_heap.next;
early_heap.next += n;
return p;
}
If .bss is cleared properly, early_heap starts as {0, 0, 0}. You call heap_init(0x100000, 0x10000), then bump_alloc(64) returns 0x100000, the next call returns 0x100040, and life is good.
If .bss is not cleared, early_heap starts as garbage. Suppose early_heap.next happens to be 0xfffffffffffffff8. You call heap_init, which sets all three fields explicitly. So now early_heap is in a good state. Looks fine.
Now consider a slightly different version:
static struct allocator early_heap;
static int initialized;
void heap_init(uintptr_t base, size_t size) {
if (initialized) return;
early_heap.base = base;
early_heap.end = base + size;
early_heap.next = base;
initialized = 1;
}
With .bss cleared: initialized starts at 0, heap_init runs, sets fields, sets initialized = 1. Subsequent calls are no-ops. Good.
Without .bss cleared: initialized is some random value, very likely nonzero. heap_init sees initialized != 0 and returns immediately. early_heap is left as garbage. The next call to bump_alloc reads garbage pointers, dereferences them, and the kernel triple-faults.
The bug is invisible at the call site. The bug is invisible at heap_init. The bug is upstream of all the C source code — it’s in the runtime contract, which was silently violated.
Variants of this bug:
- A global lock that starts “held” because its memory wasn’t zeroed.
- A linked list head pointing into invalid memory.
- A reference counter starting at some high value, so nothing ever gets freed.
- A function pointer table that gets called and jumps to garbage.
The unifying feature: the program is correct given C’s contract about static storage duration, but the contract was never fulfilled, so the program is wrong about memory it never touched.
11. Virtual addresses, physical addresses, and the higher-half twist
Now the MMU enters and the picture gets harder, because the addresses in your binary are no longer the same as the addresses on the CPU’s address bus.
In a “higher-half” kernel, the linker script puts your kernel at a high virtual address, say 0xffffffff80100000. This means every instruction that references counter or __bss_start uses an address in that high range. The linker baked it in.
But when the CPU first starts executing your kernel, the MMU is either off or in a state where high virtual addresses don’t yet translate. The bootloader probably loaded the kernel’s bytes into low physical addresses, somewhere like 0x100000 (1 MiB). The CPU’s instruction pointer is at the physical address of _start, executing those low-physical-address bytes.
At this moment, if you tried to clear .bss using the virtual address from the linker:
for (char *p = __bss_start; p < __bss_end; p++) *p = 0;
then __bss_start evaluates to something like 0xffffffff80123000, and writing to that address either faults (no mapping) or modifies whatever low-physical address happens to be mapped at the identity-mapped portion of memory. Bad either way.
The mental fix is that before paging is set up, you have to talk to memory in physical addresses, not virtual. You can do this several ways:
-
Set up paging first — build page tables that map the kernel’s virtual addresses to its physical load addresses, enable the MMU, then jump to a virtual address and start using virtual addresses. After that,
__bss_startevaluates to a real, mapped virtual address. -
Clear
.bssbefore paging, using physical addresses computed from the linker’s symbols. This requires some arithmetic: the linker can be told to expose__bss_start_physas a different symbol equal to the physical address, or your early code can subtract a known offset (KERNEL_VIRT_BASE - KERNEL_PHYS_BASE) from__bss_startto get the physical address. -
Have the bootloader clear it for you. If you used a multiboot ELF load, the bootloader walked the program headers and zeroed the
.bsssegment as part of loading. You don’t have to do it again.
Most kernels do option 1: set up paging early (often with a simple identity-mapped + higher-half mapping), then clear .bss using virtual addresses, because at that point the world looks the way the linker expected.
This is one of those situations where the linker confidently emits 0xffffffff80123000 as an address, and you, the human, have to ensure that the CPU’s view of memory matches the linker’s view before that address can be touched. The linker doesn’t enable the MMU. The CPU doesn’t know about the linker. The boot code is the diplomat.
12. COMMON symbols and tentative definitions (a small detour)
A pedantic but important detail. In C, this:
int x;
at file scope is not a definition. It is a tentative definition. The standard says that if no other translation unit provides a definition with an initializer, and no tentative definition is upgraded to a real one, the tentative definitions collectively become a definition of zero.
Old compilers and old linkers handled this with COMMON symbols. The compiler emitted x as a common symbol — meaning “I, this object file, claim this size for x, but I’m willing to be merged with other object files that claim a (possibly larger) size for x.” The linker, during link, would resolve all common symbols for the same name into one allocation.
Modern GCC defaults to -fno-common since GCC 10, which treats int x; as a regular .bss symbol. But linker scripts still typically include *(COMMON) inside .bss to catch any object files compiled with -fcommon, because forgetting that pattern means COMMON symbols silently get lost and you get unresolved references at link time.
For mental-model purposes: COMMON is “.bss with extra merging semantics.” It ends up in .bss at link time, so it gets zeroed at boot just like everything else in .bss. The clear loop catches both.
13. The clear loop, properly written
Now we can finally appreciate the loop itself:
extern char __bss_start[];
extern char __bss_end[];
void clear_bss(void) {
for (char *p = __bss_start; p < __bss_end; p++) {
*p = 0;
}
}
This works, but is byte-by-byte slow. In real kernels you’ll see something like:
void clear_bss(void) {
uint64_t *p = (uint64_t *)__bss_start;
uint64_t *end = (uint64_t *)__bss_end;
while (p < end) *p++ = 0;
}
…provided the linker script aligns __bss_start and __bss_end to 8 bytes. (Hence the . = ALIGN(8); line in the linker script above.) Or:
memset(__bss_start, 0, __bss_end - __bss_start);
…provided memset itself doesn’t depend on any .bss state. That last condition is non-obvious. If memset is implemented in a way that touches a global, you have a bootstrapping problem: the function you’re using to make globals usable depends on globals being usable. Typical kernels either implement clear_bss in pure assembly, or implement memset very conservatively, or simply use the inline while loop above to avoid the question.
A more interesting question is whether the clear loop can use the CPU’s vector instructions (SSE/AVX/NEON) for speed. The answer in early boot is usually no — you may not have enabled the FPU/SSE state yet, and trying to execute an SSE instruction before that traps with #UD. So the early clear_bss is almost always plain integer code.
It’s also worth noting that you call clear_bss before kmain. It’s typically the second or third thing your C-callable startup function does, after setting up the stack and before calling anything that might rely on globals.
14. Multi-core boot and .bss
.bssA subtlety that bites people. In an SMP system, only the bootstrap processor (BSP) executes the early boot path. The other cores (application processors, APs) are halted at reset. The BSP brings them up later, after the kernel is initialized.
This is good for .bss: only one CPU runs the clear loop, so there’s no race. By the time the APs come online, .bss is clean and the kernel is in a state where ordinary locking handles concurrent access. The cores don’t need to coordinate on zeroing.
But each AP has its own startup code, often a small trampoline blob copied somewhere in low memory. That trampoline has its own tiny .bss-like region (if any), and you may need to clear that before the AP can run C code. Most kernels keep the trampoline minimal and avoid globals entirely, sidestepping the problem.
The deeper point is that .bss is conceptually a global resource, shared across all cores once they’re running. Clearing it is a one-time act done by the BSP before anything else can see it. There’s no “per-core .bss clear.” (Per-CPU areas exist, but they’re a different mechanism — typically separately allocated and zeroed when each AP starts.)
15. PIC, PIE, and relocations
If you build the kernel as position-independent (PIC/PIE), the linker doesn’t bake in absolute addresses. Instead, every reference to counter or __bss_start is either PC-relative or goes through the Global Offset Table. The actual address is computed at load time based on where the loader places the image.
For .bss this changes nothing semantically: there’s still a region that must be zeroed, the symbols still point at it. But the addresses computed in the clear loop are now relative to the load address, so the loop works regardless of where the kernel was actually placed in physical memory.
Most kernels are not fully position-independent. They’re linked at a fixed virtual address and rely on the bootloader to place them at a corresponding physical address, then set up paging to map one to the other. Position independence introduces overhead and complexity that aren’t typically worth it for a monolithic kernel.
There’s a related concept called KASLR (kernel address space layout randomization) where the kernel is loaded at a random virtual address chosen at boot. This requires either full PIE or a small relocation step at boot that patches up absolute addresses. Either way, by the time .bss is cleared, the addresses in __bss_start and __bss_end have been resolved to their actual runtime values. The clear loop doesn’t know or care.
16. Why does this still bite people in 2026?
It would seem like, after sixty years of computing, .bss would be a fully tamed beast. And in user-space, it basically is — the OS loader handles it, you never think about it. The trouble shows up in three places:
Embedded systems. Microcontroller firmware is freestanding C running on bare metal. Every project has a startup file (often startup_stm32xxx.s or similar) that clears .bss. If the vendor’s startup file is buggy, or if you write your own and forget, you ship products that fail in confusing ways. The forums are full of “my STM32 project works in debug mode but not release mode” threads that turn out to be .bss.
Kernels and hypervisors. Same problem, more cores, more memory, more chances for things to go subtly wrong. Especially with secondary CPUs, secondary memory zones (like the kernel’s per-CPU areas), and dynamically loaded modules — each is its own “tiny program” with its own .bss-like contract.
Shellcode and exploit dev. When you write code that gets loaded as a flat blob into an arbitrary location and executed, you have neither a loader nor a runtime. If your shellcode uses globals, you have a .bss problem in miniature. This is one reason shellcode is usually written to avoid globals entirely.
JITs and dynamically generated code. When the runtime generates code on the fly and jumps to it, the code’s view of memory needs to match what the JIT laid out. JITs typically don’t have a .bss per se, but the equivalent — “this region must start zero” — comes up.
The unifying theme: anytime you’re writing the runtime instead of running on top of one, you own the contract. The C language assumes someone fulfilled it. If that someone is you, do the work.
17. The full model, restated
Walking up the stack of abstractions, one layer at a time:
-
The CPU sees a flat physical address space (modulo MMU) and an instruction pointer. It has no idea what a section is, what a symbol is, or what zero-initialization means. It just reads and writes addresses.
-
The C language sees a flat byte-addressable virtual memory and a contract: variables with static storage duration and no initializer start at zero. This contract is the C abstract machine’s promise to the programmer.
-
The compiler translates C source into bytes plus metadata. Variables with explicit non-zero initializers become bytes in
.dataplus a symbol table entry. Variables without initializers become a size in.bssplus a symbol table entry (no bytes). Functions become bytes in.textplus a symbol table entry. -
The linker combines object files. It concatenates same-named sections, picks final addresses based on the linker script, resolves all symbol references, and emits an executable. For
.bss, it records the size and address but emits no bytes. It also emits sentinels like__bss_startand__bss_endfor the runtime’s use. -
The file format (ELF) encodes the result. Sections are the linker’s view. Segments (program headers) are the loader’s view. A
LOADsegment withp_filesz < p_memszsays “loadp_fileszbytes from the file and zero-fillp_memsz - p_fileszmore.” -
The loader (in a hosted environment, the OS kernel’s ELF loader) walks the program headers, maps file-backed memory for
p_filesz, maps anonymous (zero-filled) memory forp_memsz - p_filesz, and jumps to the entry point. The C abstract machine’s contract is now satisfied for static storage. -
The runtime (
crt1.o,libc) sets up the stack frame, runs.init_array, callsmain. User code executes inside a world where the contract is fully real.
In a freestanding kernel, layers 6 and 7 collapse into your boot code. You are the loader. You are the runtime. You set up paging, you clear .bss, you call kmain. The line of assembly that says “set %rdi to __bss_start, set %rcx to __bss_end - __bss_start, rep stosb” is the moment you fulfill the contract on behalf of every int x; in your kernel.
That’s what clearing .bss is. Not a ritual, not a quirk, not historical baggage. It’s the single explicit step that makes the C language’s promises real on hardware that has no opinion about them. The CPU doesn’t care. The linker assumed you’d handle it. The bootloader may or may not have. The C code is going to depend on it. So you write the loop, and the abstraction stack stops bleeding.
18. The one-paragraph mental model
A computer is bytes in RAM and an instruction pointer. Sections are a fiction in the binary that the toolchain uses to group bytes by their intent. .bss is the section for writable globals that should start as zero, and the file deliberately doesn’t store those zero bytes — it just records a size. Someone has to put zeros in RAM at the right addresses before any code that depends on them runs. In hosted environments, the OS loader does it. In a kernel, your early boot code does it. The C language assumes those zeros are there and writes code that breaks subtly if they aren’t. Clearing .bss is the act of making the C abstract machine’s flat-zero-memory assumption true on a piece of silicon that doesn’t care about your assumptions.
That’s it. That’s the whole thing. The loop in your boot code isn’t magic. It’s just the bill coming due for using a language that assumes someone else handled it.
Comments