Memory Regions - Static
#memory | #lowlevel | #cacheThink of static/global memory like a public notice board in an office. Unlike local (stack) variables, the information stays there throughout the program's execution. Static memory is used for storing global variables, static variables, and constant data. These variables are allocated when the program starts and remain in memory until the program ends.
Key Characteristics
- Exists for the entire program duration;
- Used for global variables, static variables, and constant data;
- This memory is often segmented further into specific sections (e.g., for data and constants);
Limitations
- Not as fast as cache or registers, but faster than heap access;
- Can possibly keep unused data in memory, allowing for more risks of memory leak;
- If multiple functions modify the same global variable, it can lead to unexpected bugs;
Example 1
// static.c
#include <stdio.h>
int x = 1; // goes in .data
static int y; // goes in .bss
const char* s = "Hello .rodata"; // s pointer in .data, string literal in .rodata
int main() {
const int z = 42; // stack (or register) dynamic memory
static int u; // goes in .bss
printf("x=%d, s=%s\n", x, s); // string literal goes in .rodata
}
How to prove?
Again, this is another very simple program example just to help us to explore at least tree different static memory segments, .bss
(Block Started by Symbol), .data
and .rodata
(read-only data).
Compile the program with the debug symbols -g
and no optimizations -O0
;
gcc -g -O0 static.c -o static
After the program is compiled, we can use the objdump -t
command because the output of objdump -t
shows the symbol table of our ELF (Executable and Linkable Format) binary. And, because we already know the sections we're interested in, we can also use the grep
command in order to filter those specifc sections.
objdump -t static | grep -E 'O \.data|O \.bss'
0000000000011020 l O .bss 0000000000000001 completed.0
0000000000011024 l O .bss 0000000000000004 y
0000000000011028 l O .bss 0000000000000004 u.0
0000000000011010 g O .data 0000000000000004 x
0000000000011008 g O .data 0000000000000000 .hidden __dso_handle
0000000000011018 g O .data 0000000000000008 s
0000000000011020 g O .data 0000000000000000 .hidden __TMC_END__
Now, let's break this output down and try to understand what is the meaning of each column and letter in this table.
Symbol Table
Column | Meaning |
---|---|
0000000000011028 |
Memory address where the symbol (u.0 ) will be located at runtime. |
l |
Binding: l = local symbol (not visible outside this object). |
(space) | Unused column (can be w for weak or g for global). |
O |
Type: O = Object (i.e., a variable, not a function). |
.bss |
Section where the symbol is located (.bss = uninitialized data). |
0000000000000004 |
Size in bytes (4 bytes for int number ). |
Other Common Symbol Types
Letter | Meaning |
---|---|
F |
Function symbol (e.g., main, printf) |
O |
Object (variable) |
d |
Section definition |
U |
Undefined (external symbol — declared but not defined in this binary) |
Static and global variables—or their pointers—are typically stored in the .bss
or .data
segments. However, some types, like char* s
pointing to string literals, are split across segments: the pointer itself might go in .data
, while the string is placed in the .rodata
(read-only data) segment. Since .bss
holds uninitialized data, its contents aren't stored in the binary file—they're simply reserved and zero-initialized at runtime.
On the other hand, the z
variable is not listed in the symbol table because it is a local variable with automatic storage duration. It's typically allocated on the stack (or even in a register, depending on compiler optimizations) and exists only within the scope and lifetime of the main
function. Unlike global or static variables, it does not occupy space in segments like .data
or .bss
.
In C, a const
qualifier simply means that a variable cannot be modified after initialization, but it doesn't change the variable's storage class or linkage. So, a const
local variable like z
is still stored on the stack—just like a regular variable. In contrast, languages like C++ treat const
(or constexpr
) variables as candidates for compile-time evaluation and placement in read-only sections (like .rodata
), especially when they have internal linkage at global scope.
This difference makes C's const
more of a compile-time promise than a directive for memory placement. It doesn't imply immutability at the binary level and doesn't guarantee storage in a dedicated read-only segment. We will prove this later when we inspect the memory addresses of the static variables in our program.
Common ELF Static Segments
Segment | Purpose | RAM Type | Lifetime | Example |
---|---|---|---|---|
.text |
Executable code | Static (read-only) | Entire program | main() , function bodies |
.data |
Initialized global/static vars | Static (read/write) | Entire program | int x = 5; |
.bss |
Uninitialized global/static vars | Static (zero-initialized) | Entire program | static int y; |
.rodata |
Read-only data | Static (read-only) | Entire program | String literals |
.init / .ctors / .dtors |
Initialization routines | Static | Startup/teardown | C++ constructors/destructors |
These sections are core to understanding how programs are laid out in memory—especially in C/C++ or any system-level programming.
.text
— Contains the executable instructions..data
— Stores global/static variables with initial values..bss
— Stores global/static variables without initial values..rodata
— Contains constants like string literals..init
— Contains functions that run beforemain()
.
Inspecting static variable values in ELF
Now that we already know how to check the symbol table, we can also use the readelf
command to check the actual values of these variables in memory.
The readelf
command is a powerful tool for examining the contents of ELF files, including the memory layout and values of static/global variables.
We can use the readelf -x .rodata
command to check the contents of the .rodata
section, which contains the string literals.
readelf -x .rodata static
Hex dump of section '.rodata':
0x000007f0 01000200 00000000 48656c6c 6f2c202e ........Hello, .
0x00000800 726f6461 74610000 783d2564 2c20733d rodata..x=%d, s=
0x00000810 25730a00 %s..
The output of the readelf
command shows the hexadecimal representation of the contents of the .rodata
section, which includes the string literal "Hello, .rodata"
and the format strings used in the printf
function.
Inspecting memory addresses of static variables
Now, let's check the memory addresses of the static variables in our program based on the memory addresses we've already gotten so far. Shall we?
For this, I'm gonna use the gdb because it's a powerful debugger that allows us to inspect the memory addresses of variables at runtime. And it will allow us to check not only the value of our static/global variables, but also the dynamic memory addresses of our const
going into the stack (or into the registers depending on the compiler optimizations).
Before we run and start debugging the program after doing gdb static
, let's first inspect the static/global variables we already know the memory addresses from the previous objdump
output. Let's start with the x
, y
and u
variables.
If you remember the objdump
output, we've gotten something like this 0000000000011010 g O .data 0000000000000004 x
. This means that the variable x
is located at the memory address 0x11010
in the .data
section, and it has a size of 4 bytes
. So, we can use the x/d
command in gdb to inspect the value of the variable x
at that memory address.
(gdb) x/d 0x11010
0x11010 <x>: 1
Similarly, we can inspect the y
and u
variables by using their respective memory addresses from the objdump
output. The variable y
is located at 0x11024
in the .bss
section, and the variable u
is located at 0x11028
, also in the .bss
section.
But when it comes to the const int z = 42;
variable, we need to remember that it's a local variable with automatic storage duration. This means that it's allocated on the stack (or possibly in a register, depending on compiler optimizations) and does not have a fixed memory address like global or static variables.
So, we cannot use the x/d
command to inspect its value directly by memory address. Instead, we need to run the program and pause it at the main
function using a breakpoint, so we can inspect the value of the z
variable in the current stack frame.
Let's go for it! Once inside the gdb prompt, we can set a breakpoint at the main
function by typing break main
. This will allow us to pause the execution of the program right at the beginning of the main
function. Then we can run the program by typing run
.
(gdb) run
Starting program: /app/static
warning: Error disabling address space randomization: Operation not permitted
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main () at static.c:9
9 const int z = 42; // stack (or register) dynamic memory
When the program hits the breakpoint, it will pause on line 9 where we have declared the const int z = 42;
variable. Now, if you inspect the locals by typing info locals
, you will see the values of the local variables currently in scope for this stack frame, including the z
variable.
(gdb) info locals
z = 0
u = 0
Interestingly, the output shows that the z
variable is currently set to 0
, which is expected because it has not been initialized yet. But from where did this 0
come from? Well, the z
variable is a local variable with automatic storage duration, which means it is allocated on the stack. It's a local variable that has not been assigned a value and zero is the default value for uninitialized integer variables in C.
The u
variable is also set to 0
, as it is a static variable that has not been assigned a value and zero is the default value for uninitialized integer variables in C. This is why we can see u
appearing in the locals list, even though it is declared as a static variable. Static variables are initialized to zero by default if not explicitly initialized and goes into the .bss
section, which is reserved for uninitialized data.
Key concepts
const
in C does not mean "compile-time constant";- In C (unlike C++),
const int z = 42;
still results in a runtime initialization; - It's treated like a local variable that's read-only after initialization, but it's still initialized at runtime;
- Stack frame is allocated before any code runs;
- When
main()
starts, the stack frame is created and space is reserved forz
; - But initialization (like
z = 42
) happens only after execution enters the function body;
Disassembly Overview
And if we want to check and confirm when the z
variable is actually initialized, we can do disas
to disassemble the code and see the instructions performed by the assembly code.
(gdb) disas
Dump of assembler code for function main:
0x0000aaaaba0a0794 <+0>: stp x29, x30, [sp, #-32]!
0x0000aaaaba0a0798 <+4>: mov x29, sp
=> 0x0000aaaaba0a079c <+8>: mov w0, #0x2a // #42
0x0000aaaaba0a07a0 <+12>: str w0, [sp, #28]
0x0000aaaaba0a07a4 <+16>: adrp x0, 0xaaaaba0b1000
0x0000aaaaba0a07a8 <+20>: add x0, x0, #0x10
0x0000aaaaba0a07ac <+24>: ldr w1, [x0]
0x0000aaaaba0a07b0 <+28>: adrp x0, 0xaaaaba0b1000
0x0000aaaaba0a07b4 <+32>: add x0, x0, #0x18
0x0000aaaaba0a07b8 <+36>: ldr x0, [x0]
0x0000aaaaba0a07bc <+40>: mov x2, x0
0x0000aaaaba0a07c0 <+44>: adrp x0, 0xaaaaba0a0000
0x0000aaaaba0a07c4 <+48>: add x0, x0, #0x808
0x0000aaaaba0a07c8 <+52>: bl 0xaaaaba0a0650 <printf@plt>
0x0000aaaaba0a07cc <+56>: mov w0, #0x0 // #0
0x0000aaaaba0a07d0 <+60>: ldp x29, x30, [sp], #32
0x0000aaaaba0a07d4 <+64>: ret
End of assembler dump.
Notice that I'm doing this disassemble from an Apple Silicon Mac M1, which uses the ARM64 (AArch64) architecture. The instructions may vary slightly on different architectures, but the general idea remains the same.
Let's break the disassemble down and walk through the relevant part of the disassembly with z
variable and its values in mind.
0x0000aaaaba0a0794 <+0>: stp x29, x30, [sp, #-32]!
0x0000aaaaba0a0798 <+4>: mov x29, sp
This is the function prologue, standard in ARM64. The stp x29, x30, [sp, #-32]!
allocates 32 bytes of stack space, stores the old frame pointer and return address. The mov x29, sp
sets up the new frame pointer. This is when stack space is allocated for local variables—including the const int z
.
0x0000aaaaba0a079c <+8>: mov w0, #0x2a // w0 = 42
0x0000aaaaba0a07a0 <+12>: str w0, [sp, #28] // store w0 to [sp + 28]
Here, at mov w0, #0x2a
, the constant 42
is loaded into register w0
. From this part str w0, [sp, #28]
, w0
is stored to memory at [sp + 28]
, which is where the compiler allocated space for z
. So z
lives at offset +28
from the current stack pointer. That's where its actual value (42) is stored.
Example 2
#include <stdio.h>
int accountBalance = 1000; // Global state — BAD practice!
int interestThreshold = 1000; // Not enforced correctly
void applyInterest() {
// Oops: forget to check threshold
accountBalance = accountBalance + (accountBalance * 0.05);
}
void deposit(int amount) {
if (amount > 0) {
accountBalance += amount;
}
}
void withdraw(int amount) {
if (amount > 0 && accountBalance >= amount) {
accountBalance -= amount;
} else {
printf("Insufficient balance or invalid amount\n");
}
}
int main() {
accountBalance = 950; // Below threshold
printf("Initial Balance: %d\n", accountBalance);
withdraw(100); // Brings balance to 850
printf("After withdrawal + interest: %d\n", accountBalance);
applyInterest(); // May apply interest below threshold
return 0;
}
// Output:
// Initial Balance: 950
// After withdrawal + interest: 892
// Oops, interest applied below threshold
This example above shows a common pitfal of using global variables. It demonstrates how global state can lead to unexpected behavior, especially when functions modify shared variables without proper checks. In this case, the applyInterest
function applies interest even when the balance is below the threshold, leading to incorrect results.
This is a classic example of why global state is often considered a bad practice in programming, as it can lead to bugs that are hard to track down when code base grows larger and more complex.
Now, in this updated example bellow, we will see how to use a struct with a function pointer to simulate a method call, which is a common pattern in C programming to achieve object-oriented-like behavior.
#include <stdio.h>
// Define the BankAccount struct with a function pointer
typedef struct BankAccount {
int balance;
int interestThreshold;
void (*applyInterest)(struct BankAccount*); // Function pointer "method"
} BankAccount;
// Function that applies interest if above threshold
void applyInterestImpl(BankAccount* account) {
if (account->balance >= account->interestThreshold) {
account->balance += account->balance * 0.05;
printf("Interest applied. New balance: %d\n", account->balance);
} else {
printf("Interest not applied: balance (%d) is below threshold (%d).\n",
account->balance, account->interestThreshold);
}
}
void deposit(BankAccount* account, int amount) {
if (amount > 0) {
account->balance += amount;
printf("Deposited %d. New balance: %d\n", amount, account->balance);
}
}
void withdraw(BankAccount* account, int amount) {
if (amount > 0 && account->balance >= amount) {
account->balance -= amount;
printf("Withdrew %d. New balance: %d\n", amount, account->balance);
} else {
printf("Insufficient balance or invalid amount.\n");
}
}
int main() {
// Initialize the account with a balance and interest threshold
BankAccount myAccount = {
.balance = 950,
.interestThreshold = 1000,
.applyInterest = applyInterestImpl
};
printf("Initial balance: %d\n", myAccount.balance);
deposit(&myAccount, 100); // Now balance is 1050
withdraw(&myAccount, 250); // After withdrawal and interest
myAccount.applyInterest(&myAccount); // Simulated method call
return 0;
}
// Output:
// Initial balance: 950
// Deposited 100. New balance: 1050
// Withdrew 250. New balance: 800
// Interest not applied: balance (800) is below threshold (1000).
Final thoughts
In C, you must explicitly pass the object (&myAccount
) since C has no concept of a this
pointer.
While C lacks built-in support for object-oriented programming, function pointers inside structs
let us simulate methods and encapsulate behavior along with data. This approach lets us keep logic close to the data it operates on, making the code easier to reason about—and helping avoid global variable misuse and policy violations like applying interest below a minimum balance.