函数调用和堆栈

0 阅读24分钟

上一篇文章我们讲了CPU内部的简单架构,以及程序运行时的指令流和数据流。本篇我们主要聚焦在程序的内存分区、子程序的调用和CPU内部之间的交互。

1、内存布局

相信每个找工作的计算机同学都背过内存的几大分区,那为什么要分成这几个分区呢,我们的高级编程语言(C、C++、Java)等都是几个文件的代码最后编译成二进制,读完本篇内容你会得到答案的。 image-20260315214245010.png

整体来说编程语言只包括两部分:指令和数据。在高级语言中这两部分是放在一起的,高级语言在编译为二进制的过程中会先编译成汇编语言,而汇编语言中指令和数据则是分开的,前者对应图上的代码段,后者对应全局静态存储区、常量区。至于栈和堆,它们并不存在于编译后的二进制文件中,而是程序在操作系统中运行时动态生成的纯内存结构。为什么数据要分这么多模块呢,主要是为了数据安全,可以发现不同模块的数据权限是有着明显的不同的。

  • 常量区:这里的数据是写死的,只能访问无法修改
  • 全局静态存储区:这里的数据部分的改写是有条件的
  • 栈:根据当前过程动态维护的数据,一般是比较小的数据
  • 堆:这里的空间比较大,保存比较大的数据。

image-20260315214245010.png

2、高级语言编译成二进制的过程

我们的高级语言源码编译成二级制包括这几个过程: image-20260321193918457.png

在上面的过程中,编译阶段就是将我们的代码转化成根据内存布局的汇编语言的阶段,但是在编译和汇编阶段每个文件都是独立的还没有整合在一起,链接器(Linker)把多个 .o 文件合并后,并计算出里面全局\静态变量、常量、函数(代码段)的偏移量地址,产出的最终文件在 Linux 下统称为 ELF 可执行文件(Executable file) ,通常没有扩展名。下面以一个例子来进行说明:

C语言代码main.c

/* * ==========================================
 * 终极内存映射与预处理测试源文件 (main.c)
 * 目的:观察预处理、编译、汇编、链接的全阶段物理形态
 * ==========================================
 */#include <stdio.h>
#include <stdlib.h>
#include <string.h>// --- 宏定义观测区 ---
#define MAGIC_NUMBER 0x9999
#define CALC_OFFSET(x, y) ((x) << 2 | (y))  // 带参数的宏函数extern int external_symbol;
​
const int global_const = 0x11111111; // 这是一个只读全局变量
int global_init = 0x22222222;
​
// --- 空行观测区 ---
// 下面故意留出大量空行,观察预处理器如何处理它们
​
​
​
​
​
int global_uninit;
​
static const int static_global_const = 0x33333333;
static int static_global_init = 0x44444444;
static int static_global_uninit;
​
_Thread_local int thread_local_var = 0x55555555;
​
char *global_str_ptr = "Rodata_Global_String";
char global_str_arr[] = "Data_Global_String";
​
struct AlignedStruct {
    char a;
    int b;
    short c;
};
​
static inline int inline_compute(int x) {
    return x << 1;
}
​
void ultimate_memory_test(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7) {
    
    volatile int vol_var = 0x66666666;
    register int reg_var = 0x77777777;
​
    static int static_local_init = 0x88888888;
    static int static_local_uninit;
​
    // 观测点:宏常量将被直接文本替换
    const int local_const = MAGIC_NUMBER; 
    int local_init = 0xAAAAAAAA;
    int local_uninit;
​
    char *local_str_ptr = "Rodata_Local_String";
    char local_str_arr[] = "Stack_Local_String";
​
    struct AlignedStruct local_struct = {'A', 0xBBBBBBBB, 0xCCCC};
​
    int *heap_ptr = (int *)malloc(sizeof(int) * 10);
    
    void (*func_ptr)(int, int, int, int, int, int, int) = &ultimate_memory_test;
​
    if (heap_ptr) {
        // 观测点:宏函数将被强行展开
        heap_ptr[0] = global_init + static_local_init + CALC_OFFSET(local_struct.b, arg1);
        free(heap_ptr);
    }
​
    reg_var = inline_compute(arg1);
    vol_var = reg_var + local_const + arg7;
}
​
int main(int argc, char **argv) {
    ultimate_memory_test(1, 2, 3, 4, 5, 6, 7);
    return 0;
}

预处理代码:

aarch64-linux-gnu-gcc -E main.c -o main.i得到 main.i,全部文件过长,此处显示部分并解释

# 0 "main.c"
    #代表注释 0 代表行号 "main.c"代表原文件名,此处表示改行下面的内容是从main.c的0行开始的,0实际上代表还没进入任何文件
    除了上面的基本信息,在文件名后还会跟随:1 2 3 4(标志位 Flags),预处理器用数字来向编译器传递特殊状态。常见的数字标志有四个:
    1:表示**“我们刚刚进入了一个新文件”**(通常是因为遇到了 #include)。
    2:表示**“我们刚刚回到了上一个文件”**(说明刚才那个 #include 的文件已经读完了,退回来的)。
    3:表示这个文件是一个**“系统头文件”**(System Header)。这非常重要,它在暗示编译器:“这是 Linux 系统自带的底层基础代码,就算里面写得不怎么规范,你也给我闭嘴,不要报 Warning(警告)。”
    4:表示这段代码应该被隐式地当作包在 extern "C" 里面来处理。这是为了兼容 C++,防止 C++ 编译器把系统 C 库里的函数名给改写了(Name Mangling)。
​
# 0 "<built-in>" <built-in>(编译器内置宏)
# 0 "<command-line>" <command-line>(命令行参数宏,通过 -D 传进来的宏)
# 1 "/usr/include/stdc-predef.h" 系统要求隐式包含一个 C 标准预定义头文件。1 3 4 这里表示进入了一个新文件,且该文件是一个系统头文件,应该被隐式包含在里面作为extern "C" 里面来处理。
# 0 "<command-line>" 2   表示回到了上一个文件,"<command-line>" 这个虚拟环境
# 1 "main.c"   此处表示该行下面的内容是从main.c的1行开始的,有6行空行对应我们前面的6行注释
​
​
​
​
​
​
# 1 "/usr/include/stdio.h" 1 3 4 对应#include <stdio.h>,因此进入该文件
​
……
​
# 8 "main.c" 2  此处表示该行下面的内容是从main.c的8行开始的
# 1 "/usr/include/stdlib.h" 1 3 4 对应#include <stdlib.h>
……
# 9 "main.c" 2  此处表示该行下面的内容是从main.c的9行开始的
# 1 "/usr/include/string.h" 1 3 4 对应#include <string.h>
……
# 10 "main.c" 2
​
​
​
​
​
​
# 15 "main.c"
extern int external_symbol;
​
const int global_const = 0x11111111;
int global_init = 0x22222222;
# 27 "main.c"
int global_uninit;
​
static const int static_global_const = 0x33333333;
static int static_global_init = 0x44444444;
static int static_global_uninit;
​
_Thread_local int thread_local_var = 0x55555555;
​
char *global_str_ptr = "Rodata_Global_String";
char global_str_arr[] = "Data_Global_String";
​
struct AlignedStruct {
    char a;
    int b;
    short c;
};
​
static inline int inline_compute(int x) {
    return x << 1;
}
​
void ultimate_memory_test(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7) {
​
    volatile int vol_var = 0x66666666;
    register int reg_var = 0x77777777;
​
    static int static_local_init = 0x88888888;
    static int static_local_uninit;
​
​
    const int local_const = 0x9999;
    int local_init = 0xAAAAAAAA;
    int local_uninit;
​
    char *local_str_ptr = "Rodata_Local_String";
    char local_str_arr[] = "Stack_Local_String";
​
    struct AlignedStruct local_struct = {'A', 0xBBBBBBBB, 0xCCCC};
​
    int *heap_ptr = (int *)malloc(sizeof(int) * 10);
​
    void (*func_ptr)(int, int, int, int, int, int, int) = &ultimate_memory_test;
​
    if (heap_ptr) {
​
        heap_ptr[0] = global_init + static_local_init + ((local_struct.b) << 2 | (arg1));
        free(heap_ptr);
    }
​
    reg_var = inline_compute(arg1);
    vol_var = reg_var + local_const + arg7;
}
​
int main(int argc, char **argv) {
    ultimate_memory_test(1, 2, 3, 4, 5, 6, 7);
    return 0;
}
​

汇编代码:

aarch64-linux-gnu-gcc -O2 -E main.c -o main_arm.i得到main.s

在这里已经将原来的c代码的指令和数据整体,拆分成了数据和代码,且数据根据上面的内存区域进行了细分。

第一部分:全局与架构声明

.arch armv8-a        # 声明目标架构为 ARMv8-A,支持 64 位指令集。
    .file   "main.c"
    .text                # 代码段开始
    .align  2            # 内存对齐:2 的 2 次方,即 4 字节对齐(ARM 指令固定占 4 字节)。
    .p2align 4,,11       # 高级对齐优化:尝试按 16 字节对齐,但如果为了对齐需要填充超过 11 个字节的空指令(NOP),就放弃对齐,以节省空间。

第二部分:指令

函数1:ultimate_memory_test

.globl  ultimate_memory_test #声明 `ultimate_memory_test` 是一个全局符号(Global Symbol),允许其他 C 源码文件在链接时调用它。(即会加入到符号表里)
.type   ultimate_memory_test, @function #明确告诉链接器,这个符号是一个“函数”。
ultimate_memory_test:
.LFB52:
    .cfi_startproc
    stp x29, x30, [sp, -48]! # 核心动作:先将栈指针 sp 向下移动 48 字节(感叹号 ! 代表前缀更新),然后把旧的帧指针(x29)和链接寄存器/返回地址(x30)存入栈底。这就是 ARM 经典的压栈方式。
    mov x29, sp              # 设置当前函数的帧指针。
    stp x19, x20, [sp, 16]   # AAPCS64 规定 x19-x28 是被调用者保存寄存器(Callee-saved)。因为我们后面要用到 x19 和 x20,所以必须先备份到栈上。
    mov w1, 1717986918       # 准备 0x66666666
    mov w19, w0              # 把第 1 个参数 (arg1, 在 w0 里) 暂存到 w19
    mov w20, w6              # 把第 7 个参数 (arg7, 在 w6 里) 暂存到 w20。在 ARM 里,前 8 个参数都在寄存器里,不需要像 32 位 x86 架构那样去栈里捞!
    mov x0, 40               # 准备 malloc 的参数 40 字节
    str w1, [sp, 44]         # 把 0x66666666 存入栈偏移 44 的位置(这就是那个 volatile 变量 vol_var)
    
    bl  malloc               # Branch with Link:调用 malloc,并自动把下一条指令的地址存入 x30 (LR)。
    cbz x0, .L2              # Compare and Branch on Zero:如果 malloc 返回的指针 (x0) 是 NULL(0),直接跳转到 .L2。
    bl  free                 # 如果不是 NULL,直接调用 free。这里编译器很聪明,复用了 x0(malloc的返回值直接作为free的参数)。
.L2:
    add w19, w20, w19, lsl 1 # 绝妙的一步:w19 = w20(arg7) + (w19(arg1) << 1)。利用自带的逻辑左移(lsl),一条指令完成了乘 2 和加法。
    mov w0, 39321            # 把常量装入寄存器 w0。
    add w19, w19, w0         # w19 = w19 + 39321。完成最终计算。
    str w19, [sp, 44]        # 再次强制写入栈上的 volatile 变量。
    ldp x19, x20, [sp, 16]   # 恢复 x19 和 x20 的旧值。
    ldp x29, x30, [sp], 48   # 恢复帧指针和返回地址,同时把栈指针 sp 加 48(后缀更新,销毁栈帧)。
    ret                      # 跳转回 x30 记录的地址。

函数2:main

main:
.LFB53:
    stp x29, x30, [sp, -32]! # 开辟 32 字节栈空间,保存现场。
    mov w1, 1717986918
    mov x0, 40
    mov x29, sp
    str w1, [sp, 28]         # volatile 第一次赋值
    bl  malloc
    cbz x0, .L9
    bl  free
.L9:
    mov w1, 39330            # 编译器在编译期算出的最终结果 39330
    str w1, [sp, 28]         # volatile 第二次赋值
    mov w0, 0                # return 0
    ldp x29, x30, [sp], 32   # 恢复现场并销毁栈
    ret

第三部分:数据

.rodata:绝对只读

    .section    .rodata          # 声明:接下来的东西放进只读数据段
    .align  2                    # 内存对齐:2^2 = 4 字节对齐
    .type   global_const, %object
    .size   global_const, 4
global_const:
    .word   286331153            # 存放 0x11111111
  • 存在哪里: 编译成 ELF 文件后,这个 286331153 会直接占据磁盘文件里的 4 个字节。当 Linux 运行这个程序时,内核的 mmap 系统调用会把这块文件映射到内存里,并且把这一页(Page)的 MMU 权限设置为 R-X(可读不可写)
  • 怎么找地址: 这些数据的相对位置是固定的,和代码段紧挨着。

.data:跟着程序一起加载的“初始物资”

    .data                        # 声明:读写数据段
    .align  3                    # 2^3 = 8 字节对齐
    .type   global_str_arr, %object
    .size   global_str_arr, 19
global_str_arr:
    .string "Data_Global_String" # 字符串占据 19 个字节(包含结尾的 \0)
    .zero   1                    # 填充 1 个 0,为了满足上面要求的对齐
    .type   global_init, %object
    .size   global_init, 4
global_init:
    .word   572662306            # 存放 0x22222222
  • 存在哪里: 同样硬编码在 ELF 磁盘文件里。加载到内存后,OS 会给它分配 RW-(可读写) 的物理页面。你可以随便改,改的只是内存,不会修改磁盘上的源文件。

.bss:操作系统给的“空头支票”

    .bss                         # 声明:未初始化数据段
    .align  2
    .type   global_uninit, %object
    .size   global_uninit, 4
global_uninit:
    .zero   4                    # 填 4 个字节的 0
  • 存在哪里: 这是底层最精妙的设计。.bss 段在你的 ELF 硬盘文件里根本不占空间!文件头里只会记录一笔账:“我需要 4 个字节的 .bss”。当 Linux 加载程序时,会直接在物理内存里找一页全零的内存(Zero Page)映射给它。这极大地节省了磁盘空间。

.data.rel.local:动态链接的“待办事宜”

    .section    .data.rel.local,"aw"
    .align  3
    .type   global_str_ptr, %object
    .size   global_str_ptr, 8
global_str_ptr:
    .xword  .LC1                 # 存放 .LC1 ("Rodata_Global_String") 的地址
  • 存在哪里: 这是一个特殊的读写数据段。 因为现代操作系统都有 ASLR(地址空间布局随机化)。程序每次启动,加载到内存的基地址都在变。所以,编译器在编译时,根本不知道 .LC1 的绝对物理/虚拟地址是多少
  • 怎么解决: 编译器在这里填入 .LC1,实际上是在向 Linux 的动态链接器(ld-linux.so)发一个请求:“兄弟,等程序启动、你把内存分好之后,帮我算一下 .LC1 的真实地址,然后强行把这 8 个字节(.xword)改写成那个真实的绝对地址。” 这个过程叫重定位(Relocation)

.tdata:线程的“私有领地”

    .section    .tdata,"awT",@progbits
    .align  2
    .type   thread_local_var, %object
    .size   thread_local_var, 4
thread_local_var:
    .word   1431655765
  • 存在哪里: 它不会和普通的 .data 混在一起。每次你调用 pthread_create 创建一个新线程,内核(或者 glibc)都会在内存里单独切出一块新的区域(TLS块),把这个 0x55555555 拷贝进去。
  • 寻址原理: 在 ARM64 架构下,CPU 里有一个专门的系统寄存器叫 TPIDR_EL0,它永远指向当前运行线程的 TLS 块基地址。函数找这个数据时,直接去这个寄存器里拿地址。

汇编阶段(Assembly)—— 从助记符到机器码的蜕变

操作命令: aarch64-linux-gnu-as main.s -o main.o

如果说编译阶段是“翻译逻辑”,那么汇编阶段就是“机械铸造”。汇编器(Assembler)会将上一阶段生成的 .s 文件中人类还能勉强看懂的汇编指令(如 stp x29, x30, [sp, -48]!),逐条翻译成 CPU 真正能执行的 32 位二进制机器码(如 0xa9bdf7fd)。

最终,它会输出一个 .o 文件(Object File,目标文件)。这是一个可重定位的 ELF 格式文件(Relocatable ELF)

虽然它已经是纯正的二进制文件了,并且内部已经严格划分了 .text.data.bss 等物理段,但它此时绝对不能直接运行,因为里面存在大量的“空头支票”:

  1. 未知的函数地址: 比如你的代码里调用了 mallocfree,但汇编器在处理 main.c 时,根本不知道这俩函数在内存的哪个角落。于是,汇编器只能在调用指令的地方填入一个假地址(比如 0x00000000 占位),并在 .o 文件里悄悄建一张重定位表(Relocation Table) ,记下一笔账:“在代码段偏移量为 0x5C 的地方,未来需要填入 malloc 的真实地址”。
  2. 符号表(Symbol Table): .o 文件还会生成一张符号表,向外界宣告:“我这里面提供了一个叫 ultimate_memory_testmain 的函数,如果有别人想用,可以来找我”。

链接阶段(Linking)—— 终极缝合与地址绑定

操作命令: aarch64-linux-gnu-gcc main.o -o main (底层实际调用了 ld 链接器)

这是整个编译流程的最后一步,也是最复杂的一步。在我们日常的开发中,程序几乎不可能只靠一个 .c 文件搞定,你肯定包含了标准库(比如 <stdio.h> 里的 printf<stdlib.h> 里的 malloc),甚至还有其他的自定义源文件。

链接器(Linker)的任务,就是把各种 .o 文件以及依赖的静态库(.a)和动态库(.so)全部“缝合”成一个完整的可执行文件。主要分为两个核心动作:

1. 空间与地址分配(合并同类项) 链接器会将所有输入的 .o 文件“拆开”,把它们同名的段合并在一起。比如把 main.o.text 和辅助文件(如 C 语言启动代码 crt1.o)的 .text 拼成一个巨大的 .text 段;把所有的 .data 拼成一个巨大的 .data 段。 拼好之后,链接器会根据操作系统的规矩,为这些合并后的段分配最终的虚拟内存地址(或者为支持 ASLR 生成相对偏移基址)。

2. 符号解析与重定位(兑现空头支票) 这是链接器的灵魂操作。链接器会翻开各个 .o 文件里的“重定位表”和“符号表”:

  • 符号解析: 它发现 main.o 需要 malloc,于是它就去 C 标准库(比如 libc.so)里找,找到了之后把它们关联起来。
  • 地址重定位: 既然段的最终地址已经确定了,malloc 的相对位置也找到了,链接器就会回到刚才 main.o 代码段里那些填着 0x00000000 假地址的地方,强行把它们修改为跳转到 malloc 的正确偏移量指令。这就是所谓的“重定位”。

经历了这一步,所有的占位符都被真实的地址替换。最终产出的 main 文件,就是一个ELF 可执行文件(Executable ELF) 。它的文件头里清晰地记录着:代码段在哪、数据段在哪、程序的入口点(Entry Point,也就是你前文提到的 _start)在哪。

3、程序执行

3.1准备阶段

当你在终端敲下 ./main 并回车时,Shell 会调用 fork() 复制出一个子进程,然后在子进程中调用 execve() 系统调用。这个系统调用是 Linux 进程管理中最暴力的“拆迁与重建”大队长。在内核态,它最终会调起 load_elf_binary() 函数。

在这个过程中,内核完全不信任用户态,它会亲手在虚拟地址空间的高地址区,为你凭空捏造出一个初始栈帧(Initial Stack Frame)

内核布置这个栈的过程,就像是按图纸垒砖,从高地址向低地址逆向堆叠。具体步骤如下:

1. 确定栈底与 ASLR(随机化)

内核首先在用户空间最高地址(紧挨着内核空间下方)划出一片区域作为栈。但为了防止黑客通过固定地址进行缓冲区溢出攻击(比如 ROP 攻击),内核会注入一个随机偏移量(Random Offset) 。这就是 ASLR(地址空间布局随机化)在栈上的体现。栈底的绝对地址每次运行都不一样。

2. 压入“实体物资”(字符串数据区)

确定好位置后,内核开始把运行这个程序必须的环境变量和命令行参数的真实字符串内容按字节拷贝到栈的最高处:

  • 环境变量字符串: 比如 USER=user\0PATH=/usr/bin:...\0
  • 命令行参数字符串: 比如你的可执行文件名 ./main\0,以及后面的参数 1\0, 2\0 等。
3. 压入“辅助向量”(Auxiliary Vector)

在实体字符串的下方,内核会压入一个极其重要但常被忽略的数据结构:Auxiliary Vector (Auxv) 。 这是内核与动态链接器(如 ld-linux.so)或 C 运行库(glibc)沟通的“秘密通道”。里面包含了成对的键值:

  • 硬件特性(CPU 支持什么指令集)。
  • 系统页面大小(通常是 4096 字节)。
  • 程序的真正入口地址(Entry Point) 。 内核把这些底层信息打包放在栈上,方便后面的库函数直接读取。
4. 压入“指针目录”(envp 和 argv 指针数组)

刚才压入的字符串是一坨连续的内存,为了让 C 语言能方便地通过 char ** 去访问它们,内核要在栈上建立指针数组:

  • envp 数组: 压入一系列指针,分别指向上面的环境变量字符串。最后压入一个 NULL(0)作为数组结束标志。
  • argv 数组: 压入一系列指针,分别指向上面的命令行参数字符串。同样以 NULL 结尾。
5. 压入参数个数(argc)

在所有指针数组的下方(也就是更低的地址),内核压入一个整数 argc,表示命令行参数的数量。

6. 完成移交(寄存器归位)

当这套精密的结构在内存中布置完毕后,内核会做最后也是最关键的一步:

  • 把 CPU 的栈指针寄存器( ARM64 的 sp)直接强行指向刚刚压入的 argc 的地址。这就成了程序在用户态视角的 栈顶
  • 清理掉大部分通用寄存器的数据(防止泄露内核或旧进程信息)。
  • 将 CPU 的指令指针寄存器(pcrip设置为 ELF 文件头中记录的 程序入口地址

值得注意的是:起点并不是 main

完成移交后,内核通过系统调用返回(比如 eretiret 指令),CPU 切换回用户态,开始执行第一条指令。

但请注意,这个入口地址绝对不是你的 main 函数! 内核根本不知道什么是 main,它只认 ELF 头里的地址。在普通的 C/C++ 程序中,这个入口地址指向的是 C 标准库(glibc)提供的一段汇编代码,名字通常叫 _start

_startsp 寄存器指向的栈顶把 argcargv 捞出来,完成 C 库的初始化(比如初始化堆的内部结构、准备全局变量的构造函数等),最后才去调用你的 int main(int argc, char **argv)。通过这种方式,操作系统内核与高级语言的运行时库(Runtime)完成了天衣无缝的交接。

3.2 程序执行阶段

_start(具体通常是 glibc 中的 __libc_start_main 函数)完成底层 C 库的初始化,并毕恭毕敬地调用 main(argc, argv) 函数后,程序正式进入了我们编写的业务逻辑执行阶段。在这个阶段,所有的“静态设计”都将化为 CPU 中的指令流与数据流。

1. 建立业务栈帧(Function Prologue)

进入 main 函数的第一步,CPU 并不是马上执行你的业务逻辑,而是执行编译器悄悄插入的序言指令(Prologue)

  • 比如在 ARM64 架构下,会执行上面main函数下的 stp x29, x30, [sp, -32]! 的指令。
  • 这一步会在内核刚刚给你的栈顶(sp)基础上,继续向低地址开辟一段私有空间(栈帧),并把调用者(glibc)的栈底指针(x29)和返回地址(x30/LR)安全地压入栈中保存。至此,main 函数拥有了自己独立的局部变量活动区。
2. 指令获取与物理内存的“按需分配”(Demand Paging)

当 CPU 的 PC(程序计数器)指向 .text 代码段的第一条业务指令时,会发生一个极其关键的底层博弈:

  • 幻觉与现实: 之前的 execve 只是在操作系统的页表里建立了虚拟内存到 ELF 文件的映射(也就是画了张大饼),并没有真的把庞大的代码和数据全部加载到物理内存(RAM)中
  • 缺页中断(Page Fault): 当 CPU 内部的 MMU(内存管理单元)试图把虚拟地址转换成物理地址去取指令时,发现页表里的标志位是“无效(Invalid)”。此时 MMU 会立刻触发一个硬件级的异常——缺页中断,强行将控制权交还给 OS 内核。
  • 内核填坑: 内核接管后,确认你是合法访问,于是赶紧在物理内存中找一个空闲的 4KB 页框,把磁盘上 ELF 文件里对应的代码(或 .data 数据)按字节读进这个物理页,更新页表,最后让 CPU 重新执行刚才那条指令。
  • 整个程序运行过程中,这种“按需加载”在后台疯狂发生,它保证了即使是一个 1GB 大小的程序,也能在只有 100MB 物理内存的机器上跑起来。
3. 动态内存调度(堆的扩张与交互)

当程序执行到 malloc(C)或 new(C++)时,指令流会跳转进 C 标准库的内存分配器(如 ptmalloc 或 jemalloc)。

  • 如果你要的空间很小,C 库会直接从它预先向系统批发的一大块内存(Arena)里切一小块给你,这完全是在用户态完成的,非常快。
  • 如果你要的空间很大,或者 C 库的存货用光了,C 库就会通过 brk(向上移动堆顶指针)或 mmap(在文件映射区寻找空地)这两个系统调用(System Call) ,主动陷入内核态,请求操作系统给当前进程的虚拟地址空间再拨一块地。
4. 子程序调用的上下文切换

main 函数调用其他自定义函数时,会进行精密的参数传递与现场保护:

  • 传参: CPU 会将参数优先放入通用寄存器(如 ARM64 的 x0-x7)。如果参数过多,装不下的参数会被按照特定调用约定压入当前的栈中。
  • 跳转: 执行 bl(Branch with Link)或 call 指令,硬件自动将下一条将要执行的指令地址记录下来,然后跳转到子函数。子函数再次重复“建立栈帧 -> 执行逻辑 -> 销毁栈帧”的过程。这种栈帧的层层堆叠与销毁,就是函数能够无限制嵌套并在执行完后精准回到原处的根本原因。
5. 程序的优雅落幕(Epilogue 与 exit)

main 函数顺利走到 return 0; 时,故事并没有结束:

  • 销毁栈帧(Epilogue): CPU 执行 ldp x29, x30, [sp], 32 等指令,恢复 sp 和各种寄存器,利用 ret 指令跳回当初调用它的 __libc_start_main。你的返回值 0 会被放在特定的寄存器(如 w0eax)中带回。
  • 清理善后: glibc 重新接管控制权,开始执行一系列收尾工作:调用由 atexit() 注册的回调函数,调用 C++ 全局对象的析构函数,刷新并关闭所有打开的标准 I/O 流(比如把 printf 缓存里还没来得及打印的数据真正写进终端设备)。
  • 最终毁灭(exit_group): 所有的用户态清理完毕后,glibc 发起最后一个系统调用——exit_group()
  • 内核听到这个呼唤,会毫不留情地介入,回收该进程占用的所有物理内存页框,关闭所有文件描述符,销毁这本厚厚的页表字典。最终,这个曾经活跃的进程变成了一个僵尸进程(Zombie) ,静静地只保留一个退出状态码(0),等待它的父进程(Shell)通过 wait() 系统调用来读取遗言并彻底清理它的内核控制块(task_struct)。

至此,从一行行高级语言源码,到冰冷的二进制 ELF,再到一次完整的内存与 CPU 狂欢,彻底画上了句号。