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" and char 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.
}
KindSectionMutable?Thread-safe?When initialized
constInlinedNoN/ACompile time
static.data/.bssNoYesLoad time
static mut.data/.bssYes (unsafe)NoLoad time
LazyLock.bss + heapVia interior mutabilityYesFirst access

💡 Fun Fact

static mut is so dangerous that the Rust community is actively discussing deprecating it. Every access requires unsafe, and it's a common source of data races. The recommended alternatives are AtomicI32, Mutex<T>, or LazyLock<T> — all of which provide safe mutation without unsafe.


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 = executable
  • W = 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

  1. 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};)
  2. Compile it: gcc -o sections sections.c

  3. Run readelf -S sections and find .data, .bss, .rodata. Note the sizes.

  4. Predict: .bss should 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.

  5. Try modifying the string literal at runtime: char *s = "hello"; s[0] = 'H'; Compile and run. Observe the SIGSEGV. Then try the same modification on a char[] array instead. It works. Explain why in terms of which section each lives in.

  6. Do the same in Rust. Use static, const, and a string literal. Compile with rustc -o rust_sections your_file.rs and inspect with readelf -S rust_sections.