Global Data and Read-Only Memory
Type this right now
// save as globals.c — compile: gcc -o globals globals.c
#include <stdio.h>
#include <signal.h>
int initialized = 42; // .data
int uninitialized; // .bss
const int constant = 99; // .rodata
const char *greeting = "hello"; // pointer in .data, string in .rodata
int main() {
printf(".data (initialized): %p value=%d\n",
(void *)&initialized, initialized);
printf(".bss (uninitialized): %p value=%d\n",
(void *)&uninitialized, uninitialized);
printf(".rodata (constant): %p value=%d\n",
(void *)&constant, constant);
printf(".rodata (string): %p value=\"%s\"\n",
(void *)greeting, greeting);
return 0;
}
$ gcc -o globals globals.c && ./globals
.data (initialized): 0x55d3a7004010 value=42
.bss (uninitialized): 0x55d3a7004018 value=0
.rodata (constant): 0x55d3a7002004 value=99
.rodata (string): 0x55d3a7002008 value="hello"
Notice: .data and .bss addresses are close together (both writable data). .rodata addresses
are lower, near the code. The layout matches the diagram from Chapter 6.
.data: initialized globals
The .data section holds global and static variables with explicit non-zero initial values.
// C
int counter = 100; // .data — stored in the binary
static int module_state = 5; // .data — file-scoped, still in binary
#![allow(unused)] fn main() { // Rust static COUNTER: i32 = 100; // .data static MODULE_STATE: i32 = 5; // .data }
These values are baked into the ELF binary. When you hexdump the binary, you'll find the bytes
64 00 00 00 (100 in little-endian) sitting right there in the file. The OS loader copies them
into memory at process start.
ELF binary on disk:
┌────────────────────────────────────────────┐
│ ... ELF header ... │ .text │ .rodata │ .data: [64 00 00 00] [05 00 00 00] │
└────────────────────────────────────────────┘
^^^^^^^^^^^^^^^^^^
counter = 100, module_state = 5
literally stored in the file
Permissions: rw- — readable and writable, because your program modifies globals at runtime.
.bss: the zero optimization
// C
int zeroed_global; // .bss — implicitly zero in C
static int big_buffer[100000]; // .bss — 400,000 bytes of zeros
#![allow(unused)] fn main() { // Rust static ZEROED: i32 = 0; // .bss (compiler sees it's zero) static BIG_BUFFER: [i32; 100000] = [0; 100000]; // .bss }
Here's the key insight: the .bss section takes zero bytes on disk.
With .data, storing 400,000 bytes of zeros:
┌──────────────────────────────────────────────────┐
│ ELF header │ .text │ .data: [00 00 00 ... 400KB] │
└──────────────────────────────────────────────────┘
Binary size: ~400 KB larger
With .bss:
┌──────────────────────────────────────────┐
│ ELF header │ .text │ .bss header: "400000 bytes needed" │
└──────────────────────────────────────────┘
Binary size: just a few extra bytes for the header
The ELF file records "I need 400,000 bytes of BSS" but doesn't store the actual zeros. At load
time, the OS allocates the memory and zeroes it. This is why .bss exists as a separate section —
it's a size optimization that matters enormously for real programs.
💡 Fun Fact
The name "BSS" comes from an old IBM 704 assembler directive: "Block Started by Symbol." It's been used since the 1950s. Seventy years later, your Linux kernel still uses the same concept to avoid storing zeros on disk.
Verify it yourself:
$ readelf -S globals | grep -E "\.data|\.bss"
[24] .data PROGBITS 0000000000004000 003000 000010 00 WA 0 0 8
[25] .bss NOBITS 0000000000004010 003010 000004 00 WA 0 0 4
.data has type PROGBITS (actual bits in the program file). .bss has type NOBITS (nothing
on disk).
.rodata: string literals and constants
// C
const char *msg = "Hello, world!"; // the string is in .rodata
const int lookup[] = {1, 1, 2, 3, 5, 8, 13, 21}; // .rodata
#![allow(unused)] fn main() { // Rust let msg: &str = "Hello, world!"; // string literal → .rodata const LOOKUP: [i32; 8] = [1, 1, 2, 3, 5, 8, 13, 21]; // may be in .rodata }
The .rodata section holds data that should never be modified: string literals, constant arrays,
jump tables for switch statements. Its permissions are r-- — readable, not writable, not
executable.
This leads to one of C's most surprising crashes:
char *s = "hello"; // s points to .rodata
s[0] = 'H'; // SIGSEGV! Writing to read-only memory
Memory layout:
.rodata (r-- permissions):
┌─────────────────────────┐
│ 'h' 'e' 'l' 'l' 'o' \0 │ ← read-only page
└─────────────────────────┘
^
s points here
s[0] = 'H' → CPU tries to write to a read-only page
→ MMU raises a page fault
→ Kernel sends SIGSEGV
→ Program crashes
The string "hello" is a literal — it lives in .rodata, which is mapped read-only. The
pointer s is in .data (it's a writable global), but the string it points to is not writable.
Compare with:
char s[] = "hello"; // s is a char ARRAY on the stack — copy of the string
s[0] = 'H'; // Fine! The array is writable stack memory
🧠 What do you think happens?
In C, what's the difference between
char *s = "hello"andchar s[] = "hello"? The first is a pointer to read-only memory. The second is a stack array initialized with a copy of the string. One crashes when you modify it, the other works fine. The syntax looks almost identical, but the memory layout is completely different.
Rust: no surprises here
In Rust, string literals are &'static str — a reference to data in .rodata. The type system
makes the immutability obvious:
#![allow(unused)] fn main() { let s: &str = "hello"; // immutable reference to .rodata // s is &str — there's no way to get a &mut str from a string literal // The type TELLS you it's read-only }
You can't accidentally modify a string literal in Rust because &str is an immutable reference.
The compiler won't let you write through it. The C trap simply doesn't exist.
Rust's global variable story
Rust has several ways to declare global data, each with different properties:
#![allow(unused)] fn main() { // 1. const — compile-time constant, inlined at every use site const MAX_SIZE: usize = 1024; // Not a memory location — the value 1024 is copied wherever MAX_SIZE appears // 2. static — true global variable, has a fixed address in .data or .bss static COUNTER: i32 = 0; // Cannot be mutated without unsafe or interior mutability // 3. static mut — mutable global, requires unsafe to access static mut DANGER: i32 = 0; unsafe { DANGER += 1; } // You asked for it // 4. LazyLock — initialized on first access (like lazy_static!) use std::sync::LazyLock; static CONFIG: LazyLock<String> = LazyLock::new(|| { std::fs::read_to_string("/etc/myapp.conf").unwrap() }); // First access initializes it. Thread-safe. No unsafe. }
| Kind | Section | Mutable? | Thread-safe? | When initialized |
|---|---|---|---|---|
const | Inlined | No | N/A | Compile time |
static | .data/.bss | No | Yes | Load time |
static mut | .data/.bss | Yes (unsafe) | No | Load time |
LazyLock | .bss + heap | Via interior mutability | Yes | First access |
💡 Fun Fact
static mutis so dangerous that the Rust community is actively discussing deprecating it. Every access requiresunsafe, and it's a common source of data races. The recommended alternatives areAtomicI32,Mutex<T>, orLazyLock<T>— all of which provide safe mutation withoutunsafe.
Seeing sections with readelf
You can inspect exactly what sections your binary contains:
$ gcc -o globals globals.c
$ readelf -S globals
Section Headers:
[Nr] Name Type Address Off Size ES Flg
...
[16] .text PROGBITS 0000000000001060 001060 000185 00 AX
[18] .rodata PROGBITS 0000000000002000 002000 000040 00 A
[24] .data PROGBITS 0000000000004000 003000 000010 00 WA
[25] .bss NOBITS 0000000000004010 003010 000004 00 WA
The flags tell you everything:
A= allocated (loaded into memory)X= executableW= writable
.text: AX (allocated + executable) — code
.rodata: A (allocated, not writable, not executable) — read-only data
.data: WA (writable + allocated) — read-write globals
.bss: WA (writable + allocated) — but NOBITS on disk
🔧 Task
Write a C program with:
- An initialized global (
int x = 42;)- An uninitialized global (
int y;)- A large uninitialized array (
int big[100000];)- A string literal (
"hello")- A const array (
const int fib[] = {1,1,2,3,5,8};)Compile it:
gcc -o sections sections.cRun
readelf -S sectionsand find.data,.bss,.rodata. Note the sizes.Predict:
.bssshould be at least 400,000 bytes (100,000 ints * 4 bytes). Is it? Check that the binary file size did NOT grow by 400KB:ls -la sections.Try modifying the string literal at runtime:
char *s = "hello"; s[0] = 'H';Compile and run. Observe theSIGSEGV. Then try the same modification on achar[]array instead. It works. Explain why in terms of which section each lives in.Do the same in Rust. Use
static,const, and a string literal. Compile withrustc -o rust_sections your_file.rsand inspect withreadelf -S rust_sections.