Primitive Types
We have 4 primitive data types: char, int, float, double. But we will take int
as reference. Rules are same for others.
27, 28, 29, 30 August 2025
Theoretical View
We can classify memory allocation in terms of function scope and file scope.
#include <stdio.h>
// FILE SCOPE
int another_function(){
// FUNCTION SCOPE
return something
}
int main(void){
// FUNCTION SCOPE
}
In both of these scopes, we can have:
Declaration only.
int num; char gen;
Declaration + Initialization.
int num = 45; char gen = 'M';
Outside Function Declarations
#include <stdio.h>
int BASE = 16; // extern, by default
auto int BASE = 16; // Not allowed
static int BASE = 16; // Makes the variable File Scoped.
extern int BASE = 16; // Raises a warning as there is no need to explicitly say 'extern'
int main(void);
Inside Function Declarations
// main.c
#include <stdio.h>
int main(void){
int BASE = 16; // auto, by default
auto int BASE = 16;
static int BASE = 16; // Lifetime is changed to "until the program exist"
// But the scope is still block-level
extern int BASE = 16; // Raises error; A block scope declaration can't be made extern
// But we can refer to an already existing variable with external linkage/global visibility
extern int global_BASE; // refers to the declaration in another.c
}
// another.c
int global_BASE = 16; // extern, by default
What is the use of `static` in block scope?
#include <stdio.h>
int main(void){
static int BASE = 16;
}
This increases the lifetime of a variable but it remains block scoped. It makes the variable a “private global”.
Take this:
#include <stdio.h>
int sq(int n, int flag){
static int ncalls = 0;
if (flag != 1){
ncalls++ ;
return n*n;
}
if (flag == 1){
return ncalls;
}
return -1;
}
int main(){
printf("%d\n", sq(2, 0));
printf("%d\n", sq(3, 0));
printf("%d\n", sq(4, 0));
printf("%d\n", sq(5, 0));
printf("%d\n", sq(1, 1));
return 0;
}
Here ncalls
is a static variable, so, its state is retained in every function call, instead of being allocated every single time, which is why the last printf
prints 4.
Wait, the lifetime is increased but the scope remains the same, how does that work?
- This is the most complicated case and we are going to talk about it soon.
Practical View
Let’s do some experiments to understand storage classes, the concept of scope and linkage.
We will use this command to compile our source to assembly.
gcc ./main.c -S -O0 -fno-asynchronous-unwind-tables -fno-dwarf2-cfi-asm -masm=intel
This ensures that we see intel syntax, no optimization and no cfi*
directives, just pure assembly. You can use godbolt.org
as well.
Experiment 1: Function scope and default storage class
#include <stdio.h>
int main(void){
int a;
}
Expectation: An integer is sized 4-bytes but that makes rsp
misaligned, so we are expecting the compiler to reserve 16 bytes on stack.
Reality: Function prologue and epilogue. No allocation on stack.
Change: Maybe the previous program was too short. So, we have added a printf.
#include <stdio.h>
int main(void){
int a;
printf("Hi!\n");
}
Expectation: Same.
Reality: No allocation on stack.
Change: Lets use this declaration somewhere. Lets take user input.
#include <stdio.h>
int main(void){
int a;
printf("Enter a: ");
scanf("%d", &a);
}
Expectation: Same.
There you go. We have it.
main:
push rbp
mov rbp, rsp
sub rsp, 16 ; <- Here
lea rax, .LC0[rip]
mov rdi, rax
mov eax, 0
call printf@PLT
lea rax, -4[rbp]
mov rsi, rax
lea rax, .LC1[rip]
mov rdi, rax
mov eax, 0
call __isoc99_scanf@PLT
mov eax, 0
leave
ret
Change: What if we “declare + initialize”, instead of user input?
#include <stdio.h>
int main(void){
int a = 45;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], 45
mov eax, 0
pop rbp
ret
Here, we are not subtracting to reserve space. Instead, we are using rbp
as a reference point (for the current stack frame) and subtracting 4 bytes from there. Then we are storing 45 at the 4th block (byte).
The stack is not misaligned because we are moving rbp
relative, not rsp
relative. rsp
is still 16-bytes aligned.
- Compiler optimization, you know!
Doesn’t this behavior makes it hard for accessing the value? Let’s see.
#include <stdio.h>
int main(void){
int a = 45;
printf("%d\n", a);
}
Expectation: Extra work to access a
due to this “so called optimization”.
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR -4[rbp], 45
mov eax, DWORD PTR -4[rbp]
mov esi, eax
lea rax, .LC0[rip]
mov rdi, rax
mov eax, 0
call printf@PLT
mov eax, 0
leave
ret
Result: Now it is using subtraction.
Outcomes Of E-1
- Any allocation in block scope goes on stack by default.
- In case of uninitialized declarations, if the declaration is not used later in the program, the compiler has no incentive to reserve space for it.
rsp
is subtracted 16-bytes aligned to reserve space.rbp
is used as a stable pointer to reference allocations inside a stack frame.
Experiment 2: Outside function scope and default storage class
#include <stdio.h>
int BASE;
int main(void){}
Expectation: Since it is uninitialized, it should be zero-initialized at runtime and declared in .bss
.
Reality: Indeed.
.text
.globl BASE
.bss
.align 4
.type BASE, @object
.size BASE, 4
PI:
.zero 4
We can use readelf to check its linkage as we are not using .c multiple files so there is no other way to verify if it is “globally” available or not.
$ readelf ./out --symbols | grep BASE
31: 0000000000004014 4 OBJECT GLOBAL DEFAULT 25 BASE
- Verified.
Change: Initialize it.
#include <stdio.h>
int BASE = 16;
int main(void){}
Expectation: Now the declaration should be in .data
.
Reality: Indeed.
.text
.globl BASE
.data
.align 4
.type BASE, @object
.size BASE, 4
PI:
.long 16
Outcomes Of E-2
A global declaration has external linkage and always exist in memory, unlike block scope declarations which require usage.
Experiment 3: Outside function scope and `static` class
#include <stdio.h>
static int BASE;
int main(void){}
Expectation: Internal linkage and a declaration in .bss
.
Reality: Indeed.
.text
.local BASE
.comm BASE,4,4
You might find it different from previous ones. There is no PI:
as a label. And, as of now (29 August 2025), I have no answer for that.
#include <stdio.h>
static int BASE = 16;
int main(void){}
Expectation: In .bss
.
Reality: Indeed.
.data
.align 4
.type BASE, @object
.size BASE, 4
PI:
.long 16
Experiment 4: Function scope + `static` :: The Final Boss
#include <stdio.h>
int main(void){
static int num;
}
Expectation: In .bss
.
Reality: Indeed.
.local num.0
.comm num.0,4,4
#include <stdio.h>
int main(void){
static int num = 45;
}
Expectation: In .data
.
Reality: Indeed.
.data
.align 4
.type num.0, @object
.size num.0, 4
num.0:
.long 45
The question is, how the lifetime is increased but the scope remains block level? This is the only edge case here.
- The answer is, there is no such “program lifespan but block scope” thing. It’s an abstraction.
- The only true scopes are program level scope, file level scope and block level scope.
- Just as “no CGI” is just “very good CGI”, this is also a perfectly crafted illusion. And the best part is, we can break that illusion ourselves.
Let’s try to find the answer.
To understand this, we have to understand how scopes are enforced.
The way .local
and .global
directives work is that they affect the symbol’s “linker visibility” attribute. .global
makes the symbol visible to the linker while .local
doesn’t.
- You don’t want a block scope symbol and a file scope symbol to be available outside of the current translation unit, which is why “no linkage” and “internal linkage” both uses
LOCAL
as visibility. - If you were given an unstripped binary (because stripped ones don’t have
.symtab
), you can’t tell if aLOCAL
symbol is a file scope static or a block scope static.
Making a block scope declaration static is a rule enforced at compilation level.
- First, the code undergoes lexical analysis. Then abstract syntax tree formation. Next comes semantic analysis, where the magic happens.
- An internal symbol table is created using the AST, and that table enforces this rule. It sees that this variable requires allocation in static storage but the scope has to be kept block. So, it doesn’t explicitly allows any code that refers to that declaration because it is not available outside.
- But once you’re done with compilation and reach assembly, there is no such thing. And we are going to prove this point.
There are 2 ways in which we can prove out point and we are going to explore both. Although it’s not necessary as the approach remains the same, but there is a very-very small difference which we must be aware of.
Method 1
Take this example we have used before:
#include <stdio.h>
int sq(int n, int flag){
static int ncalls = 0;
if (flag != 1){
ncalls++ ;
printf("sq(%d) = %d\n", n, n*n);
return ncalls;
}
if (flag == 1){
return ncalls;
}
return -1
}
int main(){
sq(2, 0);
sq(3, 0);
sq(4, 0);
sq(5, 0);
printf("Number of calls made to square function: %d\n", sq(1, 1));
return 0;
}
This is the assembly.
.text
.globl sq
.type sq, @function
sq:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi ; arg1 saved on stack
mov DWORD PTR -8[rbp], esi ; arg2 saved on stack
; flag != 1 check
cmp DWORD PTR -8[rbp], 1
je .L2
; continue downwards if not 1
; increment ncalls
mov eax, DWORD PTR ncalls.0[rip]
add eax, 1
mov DWORD PTR ncalls.0[rip], eax
; setup return value
mov eax, DWORD PTR -4[rbp]
imul eax, eax
jmp .L3 ; jump to return Label
; (flag == 1) Label
.L2:
cmp DWORD PTR -8[rbp], 1
jne .L4
mov eax, DWORD PTR ncalls.0[rip]
jmp .L3
; setup return value == -1
.L4:
mov eax, -1
; return
.L3:
pop rbp
ret
; %d string for printfs
.section .rodata
.LC0:
.string "%d\n"
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
mov esi, 0
mov edi, 2
call sq ; sq(2, 0)
mov esi, eax ; arg2 for printf
lea rax, .LC0[rip]
mov rdi, rax ; arg1 for printf
mov eax, 0
call printf@PLT ; printf for sq(2, 0)
mov esi, 0
mov edi, 3
call sq ; sq(3, 0)
mov esi, eax ; arg2 for printf
lea rax, .LC0[rip]
mov rdi, rax ; arg1 for printf
mov eax, 0
call printf@PLT ; printf for sq(3, 0)
mov esi, 0
mov edi, 4
call sq ; sq(4, 0)
mov esi, eax ; arg2 for printf
lea rax, .LC0[rip]
mov rdi, rax ; arg1 for printf
mov eax, 0
call printf@PLT ; printf for sq(4, 0)
mov esi, 0
mov edi, 5
call sq ; sq(5, 0)
mov esi, eax ; arg2 for printf
lea rax, .LC0[rip]
mov rdi, rax ; arg1 for printf
mov eax, 0
call printf@PLT ; printf for sq(4, 0)
mov esi, 1
mov edi, 1
call sq ; sq(1, 1)
mov esi, eax ; arg2 for printf
lea rax, .LC0[rip]
mov rdi, rax ; arg1 for printf
mov eax, 0
call printf@PLT ; printf for sq(1, 1)
mov eax, 0
pop rbp
ret
; ncalls declaration
.local ncalls.0
.comm ncalls.0,4,4
I have added comments to understand the flow better.
- Every
printf
is taking 2 arguments. First one is the %d string and the second one is the actual integer to be printed. - The second argument is the return value from the
sq
function call, viaeax
.
If you are using VS Code, you can select all the mov esi, eax
lines and replace them with mov, esi, DWORD PTR ncalls.0[rip]
to see a magic. The assembly is perfectly assembled and linked. And the output is completely transformed. But we shouldn’t be “allowed” to access ncalls
, right?
gcc main.s -o out
./out
1
2
3
4
4
Voila. This proves our point that lifetime + block scope is just a rule, not a hardly imposed impossibility, which is why it can be bypassed at assembly level.
Those who may ask that the compiler is using stack to save the arguments temporarily but never reserving space for them, the answer is that sq
is a leaf function for which there exist 128 bytes of red zone just below the rsp
, for temporary use without reserving space.
- We can use
-mno-red-zone
to remove red zone support and there would be stack allocation again.
Method 2
Via hidden symbols
IDE like VS Code have language server protocol, which is basically a real time parser of the source code and verifies it against the language rules. If there are anomalies, it flags them. There is nothing magical.
Conclusion
Even a variable declaration is not that straightforward.
This completes primitive data allocation. Next is complex data allocation.
It is overwhelming and I won’t deny that. Take your time and enjoy.