【操作系统】进程的地址空间:从指针、ELF 内存布局到 mmap
头像

🔥 星恒随风: 个人主页
❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》
✨ 数据即知识,压缩即智能

文章目录

声明

本文根据南京大学 JYY《操作系统》2026 春季课程 Lecture 5“程序和进程”整理,并参考 Operating Systems: Three Easy Pieces(OSTEP)第 4、5 章及 Linux 官方手册进行补充。各位感兴趣的话可以上官网看看

课程网站 https://jyywiki.cn


一、从上一讲开始:进程不只有代码,还有“内存现场”

上一讲介绍了 UNIX 进程管理的基本 API:

API 状态机视角
fork() 复制一个进程的状态
execve() 将当前进程重置为新程序的初始状态
waitpid() 等待子进程并取得状态变化结果
_exit() 销毁当前进程的执行状态

当我们说 fork 会复制进程状态时,究竟复制了什么?

除了寄存器、PID、打开的文件等信息,进程还拥有自己的内存:

  • 正在执行的机器指令;
  • 全局变量和静态变量;
  • 动态申请的堆内存;
  • 函数调用使用的栈;
  • 动态链接库;
  • 文件映射和匿名映射等。

课堂中的 CrazyOS 用一个数组表示进程的内存:

#define MEM_SIZE   (1 << 20)
#define MEM_OFFSET 0x80000000u

struct proc {
    struct CPUState cpu;
    unsigned char mem[MEM_SIZE];

    // pid、系统调用缓冲区等操作系统内部状态
};

这个模型很直观:每个进程都有一份 mem,CPU 的取指令、读数据和写数据都发生在这份内存中。

真实操作系统当然不会简单地为每个进程固定准备一个大数组,但这个模型已经把本讲的核心问题提了出来:

一个真实进程看到的内存是什么样的?其中每个字节从哪里来?操作系统又怎样增加、删除和保护这些内存?


二、什么是进程的地址空间?

1. 先用一句通俗的话理解

地址空间(Address Space) 是一个进程能够使用的全部虚拟地址,以及这些地址当前对应的内容和访问规则。

可以把它想成操作系统给进程发放的一本“虚拟内存通讯录”:

  • 通讯录中的编号就是虚拟地址;
  • 某些编号对应程序代码;
  • 某些编号对应全局变量、堆或栈;
  • 有些地址只允许读取;
  • 有些地址允许读写;
  • 大量地址暂时什么也不对应,不能访问。

进程只能按照这本通讯录访问内存。它看到的是虚拟地址,不需要知道数据最终位于哪一块物理内存。

2. 地址空间不等于物理内存

这是初学时最重要的区分:

虚拟地址空间 物理内存
每个进程看到的抽象 机器上真实安装的 RAM
地址由进程中的指针使用 地址由内存控制器等硬件使用
可以非常大且十分稀疏 容量受到硬件限制
不同进程拥有不同的映射关系 所有进程最终共享物理资源
一段虚拟地址不一定已经有物理页 真实保存当前驻留的数据

例如,两个进程里都可能存在数值为 0x400000 的指针,但它们通常不代表同一份数据。

原因是指针只有放在某个进程的地址空间中解释才有意义:

进程 A 的 0x400000 → 物理页 X
进程 B 的 0x400000 → 物理页 Y

因此,单独拿到一个指针数值,却不知道它属于哪个进程,往往没有完整意义。

3. 为什么操作系统要创造地址空间?

虚拟内存系统的主要目标概括为三个方向:

透明性

程序可以像独占内存一样编程,不需要手工避开其他进程正在使用的物理地址。

效率

地址转换不能让每次访存都变得非常缓慢,也不能为维护映射浪费过多内存。

保护

一个进程不能随意读取或修改另一个进程以及操作系统自身的内存。

这三个目标解释了为什么现代程序可以相对安心地运行:

  • 浏览器标签页崩溃通常不会直接改坏编辑器的变量;
  • 普通程序无法通过一个任意指针读取内核中的密码;
  • 多个进程可以同时使用相似的虚拟地址,而不发生冲突。

三、重新理解指针:它保存的到底是什么?

许多同学学 C 语言时,会把指针记成一句话:

指针是存放地址的变量。

这句话没有错,但到了操作系统课程,需要再补充两个关键词:

用户程序中的指针,通常保存的是当前进程地址空间中的虚拟地址

1. 指针如何产生内存访问?

假设:

int x = 10;
int *p = &x;

这里:

  • x 是一个 int 对象;
  • &x 取得 x 的虚拟地址;
  • p 保存这个虚拟地址;
  • *p 表示访问该地址处的 int

当 CPU 执行:

int y = *p;

底层会发生一次或多次 load。执行:

*p = 20;

则会发生 store。

但 CPU 不能只看地址,还需要知道:

  • 这段地址当前有没有映射;
  • 该映射是否允许读取或写入;
  • 虚拟地址应该翻译到哪个物理页;
  • 数据跨越了多少字节。

2. 内存中的字节本身没有 C 语言类型

假设某四个字节是:

01 00 00 00

它们本身不会在内存里贴着“这是 int”的标签。把它解释成什么,取决于:

  • CPU 执行了怎样的指令;
  • 指针类型告诉编译器怎样生成访问指令;
  • 访问宽度是多少;
  • 体系结构采用怎样的字节序。

例如:

int *pi = ...;
char *pc = ...;

即使 pipc 数值相同:

  • *pi 通常读取 sizeof(int) 个字节;
  • *pc 只读取一个字节。

因此,类型主要存在于编程语言、编译器和调试信息中;执行时,CPU 面对的是指令、地址和字节。

3. 为什么课堂示例要使用 volatile?

课堂中出现了类似代码:

volatile unsigned char *ptr = input();

unsigned char value = *ptr;  // 强制发生一次读取
*ptr = 1;                    // 强制发生一次写入

如果一次读取的结果没有被使用,编译器可能认为这次读取对程序可观察行为没有影响,从而将它优化掉。

volatile 告诉编译器:

这个位置的内容可能受到程序之外因素影响,每一次代码中写明的访问都应真正发生。

它常用于:

  • 访问内存映射设备寄存器;
  • 信号处理等少数特殊场景;
  • 需要观察真实内存访问的实验。

但要特别注意:

  • volatile 不会让无效地址变得有效;
  • volatile 不会增加访问权限;
  • volatile 不保证多个线程之间的原子性;
  • volatile 不能替代锁或 C/C++ 原子类型。

如果 ptr 指向未映射区域,访问仍可能触发 SIGSEGV


四、一个进程的地址空间里通常有什么?

教科书常把地址空间简化成“代码、堆、栈”三部分。真实 Linux 进程会复杂得多。

常见区域如下:

区域 典型内容 常见权限
代码区域 程序机器指令 r-x
只读数据 字符串常量、只读全局数据 r--
已初始化数据 有非零初始值的全局和静态变量 rw-
BSS 未显式初始化或零初始化的全局和静态变量 rw-
mallocnew 等动态分配对象 rw-
动态映射区 动态库、匿名映射、文件映射 取决于用途
局部变量、返回地址、部分函数参数 rw-
vDSO 等特殊区域 内核提供给用户空间的辅助代码或数据 由系统决定

1. 这张表不是固定地址图

不要机械地记忆“代码一定在最低地址、栈一定在最高地址”。具体布局会受到很多因素影响:

  • 体系结构是 x86-64、ARM64 还是其他架构;
  • 可执行文件是否为 PIE;
  • 是否启用 ASLR;
  • 链接器和动态加载器如何布局;

更准确的说法是:

地址空间由若干拥有不同来源和权限的映射区域组成,代码、数据、堆、栈只是其中最常见的类别。

2. 为什么同一个程序每次运行的地址可能不同?

现代系统通常启用 ASLR(Address Space Layout Randomization,地址空间布局随机化)

它会让栈、堆、共享库或 PIE 程序等区域的地址在不同运行中发生变化,从而增加攻击者预测关键地址的难度。

因此,同一个程序连续运行两次:

printf("%p\n", (void *)&local);

得到的地址可能不同。这不代表程序出错,而是系统的安全机制正在发挥作用。


五、动手观察:代码、数据、堆、栈和 mmap 在哪里?

下面的程序打印多个典型对象的虚拟地址:

#define _GNU_SOURCE

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>

static const int global_read_only = 11;
static int global_initialized = 22;
static int global_bss;

static void sample_function(void) {
}

int main(void) {
    int stack_variable = 33;

    int *heap_variable = malloc(sizeof(*heap_variable));
    if (heap_variable == NULL) {
        perror("malloc");
        return EXIT_FAILURE;
    }
    *heap_variable = 44;

    long page_size = sysconf(_SC_PAGESIZE);
    if (page_size == -1) {
        perror("sysconf");
        free(heap_variable);
        return EXIT_FAILURE;
    }

    void *mapping = mmap(NULL,
                         (size_t)page_size,
                         PROT_READ | PROT_WRITE,
                         MAP_PRIVATE | MAP_ANONYMOUS,
                         -1,
                         0);
    if (mapping == MAP_FAILED) {
        perror("mmap");
        free(heap_variable);
        return EXIT_FAILURE;
    }

    printf("pid              : %ld\n", (long)getpid());
    printf("code             : %p\n",
           (void *)(uintptr_t)&sample_function);
    printf("read-only global : %p\n",
           (const void *)&global_read_only);
    printf("initialized data : %p\n",
           (void *)&global_initialized);
    printf("bss              : %p\n",
           (void *)&global_bss);
    printf("heap             : %p\n",
           (void *)heap_variable);
    printf("anonymous mmap   : %p\n",
           mapping);
    printf("stack            : %p\n",
           (void *)&stack_variable);

    printf("\nRun this command in another terminal:\n");
    printf("cat /proc/%ld/maps\n", (long)getpid());
    printf("Press Enter to exit...\n");
    fflush(stdout);
    (void)getchar();

    if (munmap(mapping, (size_t)page_size) == -1) {
        perror("munmap");
    }
    free(heap_variable);
    return 0;
}

编译运行:

gcc -std=gnu11 -Wall -Wextra -O0 address_layout.c -o address_layout
./address_layout

程序暂停后,在另一个终端执行它打印出的命令:

cat /proc/进程PID/maps

或者:

pmap -X 进程PID

1. 实验时应该观察什么?

观察:

  • 代码、全局数据、堆和栈是否落在不同映射中;
  • 各个映射的权限是否不同;
  • 动态链接库出现在哪里;
  • 匿名 mmap 是否形成了新的区域;
  • 多运行几次后,哪些地址会因为 ASLR 发生变化。

2. 为什么函数地址的写法看起来有些奇怪?

标准 C 对函数指针与对象指针的转换限制较多。上述写法用于 Linux/GCC 环境下的观察实验,不应该被当成完全可移植的 ISO C 写法。

操作系统实验常常会接触平台相关行为。此时应明确区分:

  • C 语言标准保证的行为;
  • POSIX 接口;
  • Linux 特有接口;
  • 某个编译器和体系结构下的具体实现。

六、怎样阅读 /proc/[pid]/maps

Linux 的:

/proc/[pid]/maps

记录了进程当前的内存映射及其权限。一行典型内容类似:

55d8c1a90000-55d8c1a91000 r-xp 00001000 08:01 123456 /path/to/a.out

各字段含义如下:

字段 含义
55d8...-55d8... 这段虚拟地址的起止范围
r-xp 读取、写入、执行以及私有/共享属性
00001000 对应文件中的偏移
08:01 设备号
123456 inode
/path/to/a.out 映射来源;匿名映射可能没有路径

权限字段通常包含四个字符:

字符 含义
r 可读
w 可写
x 可执行
- 不具有对应权限
p private,私有映射
s shared,共享映射

常见名称还包括:

  • [heap]:传统堆区域;
  • [stack]:主线程栈;
  • [vdso]:内核映射给用户空间的辅助区域;
  • 共享库路径:动态加载的 libc、动态链接器等。

1. maps 与 smaps 有什么区别?

cat /proc/$PID/maps
cat /proc/$PID/smaps
cat /proc/$PID/smaps_rollup
  • maps 适合查看映射范围、权限和来源;
  • smaps 会为每个映射提供更详细的内存统计;
  • smaps_rollup 提供聚合统计,读取起来更方便。

访问其他进程的这些信息需要通过系统的权限检查,并不是任何进程都能随意读取。


七、execve 之后,地址空间是怎样建立起来的?

上一讲说:

execve 用新程序替换当前进程的程序映像。

本讲继续追问:替换后的每一个字节究竟从哪里来?

可以把整个过程分成四层。

1. 编译器生成不同用途的 section

C 源代码经过编译和汇编后,会产生不同类型的 section,例如:

section 典型内容
.text 机器指令
.rodata 字符串常量、只读数据
.data 有初始值的可写全局/静态数据
.bss 未初始化或零初始化的全局/静态数据

可以使用:

readelf -S ./a.out
objdump -h ./a.out

观察 section。

2. 链接器决定可执行文件布局

链接器将多个目标文件和库组合成 ELF 可执行文件,并安排符号、重定位、section 和程序头等信息。

这里要区分两个容易混淆的概念:

section segment
主要服务于链接与分析 主要服务于程序加载和运行
常用 readelf -S 查看 常用 readelf -l 查看
.text.data.bss PT_LOAD 等程序段
数量通常较多 装载时常把多个 section 组合到一个 segment

操作系统加载程序时,主要根据 program header 中的可装载 segment 建立内存映射,而不是简单地把每个 section 分别复制一次。

3. 加载器建立 PT_LOAD 对应的映射

执行:

readelf -l ./a.out

可以看到可执行文件的 program header。

典型 PT_LOAD 段会描述:

  • 文件中从哪里开始;
  • 映射到哪个虚拟地址;
  • 文件中有多少字节;
  • 内存中需要多大范围;
  • 需要 RWX 中哪些权限。

如果内存大小大于文件大小,多出来的部分通常需要清零,这正是 .bss 一类零初始化区域得以建立的基础。

因此,.bss 中的变量虽然在运行时占用内存,却不需要在可执行文件中保存一大串零。

4. ABI 规定进程入口时的初始状态

只加载代码和数据还不够。程序真正开始执行前,还需要准备:

  • 程序入口 PC;
  • 初始栈指针 SP;
  • argc
  • argv 指针数组及字符串;
  • envp 指针数组及环境变量字符串;
  • auxiliary vector(辅助向量,简称 auxv);
  • 体系结构和 ABI 规定的部分寄存器状态。

在 AMD64 System V ABI 中,初始栈包含参数数量、参数指针、环境变量指针和辅助向量等信息。auxv 可以向用户空间传递页大小、程序头位置、程序入口等运行时信息。

可以通过:

cat /proc/self/auxv | od -tx8
getconf PAGESIZE

做简单观察。

最终流程可以概括为:

C 源代码
→ 编译器产生 section
→ 链接器生成 ELF 和 program header
→ execve 根据可装载 segment 建立映射
→ 准备初始栈、PC 和 SP
→ 动态链接器完成必要工作
→ 从程序入口开始执行
→ C 运行库最终调用 main(argc, argv)

这就是“可执行文件描述状态机初始状态”的具体含义。


八、MMU:操作系统给进程戴上的“虚拟现实眼镜”

进程发出的地址是虚拟地址,但内存硬件最终需要物理地址。谁负责翻译?

答案是操作系统与硬件共同完成:

  • 操作系统建立并维护地址映射及权限;
  • MMU(Memory Management Unit)在 CPU 访存时执行地址转换;
  • TLB 缓存近期转换结果,减少查询开销;
  • 转换无法直接完成时,CPU 触发异常,由操作系统处理。

可以把它简化成下面的过程:

CPU 产生虚拟地址
→ MMU/TLB 查询地址转换
→ 检查读、写、执行权限
→ 得到物理地址并访问内存

如果转换不存在或权限不允许:

CPU 触发 page fault
→ 操作系统判断原因
→ 可以解决:建立映射/调入数据后继续
→ 无法解决:向进程发送 SIGSEGV 或 SIGBUS

1. 缺页异常不一定代表程序出错

这是一个非常重要的概念:

Page fault 是一种硬件异常和操作系统机制,不等于最终显示出来的 segmentation fault。

例如,进程第一次访问匿名 mmap 的某个页面时,系统可能才真正为它准备物理页。CPU 发现当前转换尚未建立,于是触发 page fault;内核建立映射后,程序继续执行,用户甚至感觉不到异常发生过。

只有当访问本身非法,例如:

  • 地址根本没有映射;
  • 向只读区域写入;
  • 从不可执行区域取指令;
  • 访问超出有效文件映射的范围;

内核无法为这次访问提供合理语义时,进程才可能收到 SIGSEGVSIGBUS

2. 地址空间为什么可以比物理内存大?

因为虚拟地址空间可以是稀疏的:

  • 大量虚拟地址根本没有映射;
  • 有些映射暂时没有物理页;
  • 文件内容可以按需调入;
  • 不活跃页面可以被换出;
  • 多个进程可以共享只读代码页或共享库页。

所以,看到一个进程拥有很大的虚拟地址范围,不代表它真的占用了同样大小的 RAM。


九、malloc 的内存究竟从哪里来?

初学 C 语言时,我们通过:

int *p = malloc(100 * sizeof(*p));

申请堆内存。但 malloc 并不是系统调用,它是 C 运行库提供的内存分配函数。

这里存在两层内存管理:

层次 管理者 典型接口
进程内部 malloc 分配器 mallocfree
进程地址空间 操作系统内核 brkmmapmunmap

可以把 malloc 分配器想成仓库管理员:

  1. 分配器先向操作系统申请较大的地址空间区域;
  2. 在进程内部记录哪些小块空闲、哪些已使用;
  3. malloc 从大区域中切出一小块交给程序;
  4. free 将小块归还给分配器;
  5. 分配器是否立即把内存还给内核,取决于实现和当前情况。

1. brk 和 sbrk

传统 UNIX 可以通过改变 program break 扩大或缩小数据段末端:

int brk(void *addr);
void *sbrk(intptr_t increment);

但它们已经属于遗留接口。应用程序不应直接使用 brk/sbrk 管理普通动态内存,而应该使用 malloc/free

2. malloc 与 mmap 的关系

常见的 malloc 实现可能:

  • 使用 brk 扩展传统堆;
  • 使用匿名 mmap 获得较大独立区域;
  • 在内部缓存已释放块;
  • 根据大小、碎片和实现策略选择不同机制。

因此,不要把“每次 malloc 都会调用一次 mmap”当成规律。很多小块分配只在用户空间完成,不需要每次进入内核。

OSTEP 特别强调:

malloc/free 是库函数,它们建立在更底层的操作系统内存接口之上。


十、mmap:向地址空间增加一段映射

mmap 是本讲最重要的地址空间管理接口:

#include <sys/mman.h>

void *mmap(void *addr,
           size_t length,
           int prot,
           int flags,
           int fd,
           off_t offset);

int munmap(void *addr, size_t length);

可以先把 mmap 理解为:

请操作系统在当前进程的地址空间中创建一段映射,并规定这段映射的来源、长度和权限。

1. 六个参数分别是什么?

参数 初学阶段的理解
addr 希望放在哪里;通常传 NULL 让内核选择
length 需要映射多少字节
prot 允许读取、写入还是执行
flags 私有/共享、匿名/其他行为
fd 文件映射时使用的文件描述符
offset 从文件的哪个偏移开始映射

成功时返回映射起始地址;失败时返回:

MAP_FAILED

而不是简单地保证返回 NULL

2. prot:控制读、写和执行

常见权限:

标志 含义
PROT_READ 可以读取
PROT_WRITE 可以写入
PROT_EXEC 可以执行
PROT_NONE 不允许访问

这些标志可以使用按位或组合:

PROT_READ | PROT_WRITE

3. MAP_PRIVATE 和 MAP_SHARED

标志 主要语义
MAP_PRIVATE 修改采用私有的写时复制视图,不直接作为共享修改传播
MAP_SHARED 修改可被映射同一对象的其他进程看到,文件映射还可能写回文件

二者回答的是“这段映射的修改怎样传播”,不要将 MAP_PRIVATE 误解为“其他任何进程绝对不可能映射同一个文件”。

4. 匿名映射与文件映射

匿名映射
mmap(NULL,
     length,
     PROT_READ | PROT_WRITE,
     MAP_PRIVATE | MAP_ANONYMOUS,
     -1,
     0);

它没有普通文件作为直接内容来源,常用于申请零初始化内存。

文件映射
mmap(NULL,
     length,
     PROT_READ,
     MAP_PRIVATE,
     fd,
     offset);

它把文件的一段内容映射到进程地址空间。程序可以像访问数组一样访问文件内容。

但“把文件搬进内存”只是方便理解的说法。系统通常不会在 mmap 返回时把整个文件一次性读入 RAM,而会按需调入页面。


十一、一个完整的 mmap 与 mprotect 实验

下面的程序创建一页匿名映射,写入字符串,再将它改成只读:

#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void) {
    long page_size = sysconf(_SC_PAGESIZE);
    if (page_size == -1) {
        perror("sysconf");
        return EXIT_FAILURE;
    }

    char *region = mmap(NULL,
                        (size_t)page_size,
                        PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS,
                        -1,
                        0);
    if (region == MAP_FAILED) {
        perror("mmap");
        return EXIT_FAILURE;
    }

    snprintf(region,
             (size_t)page_size,
             "hello from mmap, page size = %ld",
             page_size);
    printf("before mprotect: %s\n", region);

    if (mprotect(region,
                 (size_t)page_size,
                 PROT_READ) == -1) {
        perror("mprotect");
        munmap(region, (size_t)page_size);
        return EXIT_FAILURE;
    }

    printf("after mprotect : %s\n", region);

    /*
     * 取消下一行注释后,程序通常会因为向只读映射写入而收到 SIGSEGV。
     * region[0] = 'H';
     */

    if (munmap(region, (size_t)page_size) == -1) {
        perror("munmap");
        return EXIT_FAILURE;
    }

    return 0;
}

编译运行:

gcc -std=gnu11 -Wall -Wextra -O2 mmap_demo.c -o mmap_demo
./mmap_demo

这个实验涉及三个操作:

  1. mmap:增加一段可读写映射;
  2. mprotect:把映射权限改为只读;
  3. munmap:从地址空间删除映射。

1. 为什么修改权限需要按页进行?

现代虚拟内存通常以页为基本管理单位。常见页面大小是 4 KiB,但不能把 4 KiB 当成所有平台的固定真理。

程序可以通过:

sysconf(_SC_PAGESIZE);

查询系统页面大小。

mmap 返回的地址会按页对齐;mprotect 要求地址满足页面对齐规则。即使只想保护几个字节,底层权限仍然作用于包含它们的页面。

2. mmap 返回后,物理内存一定已经分配了吗?

不一定。

匿名 mmap 成功首先表示:

这一段虚拟地址范围已经按照指定规则加入进程地址空间。

当程序第一次真正读写某个页面时,才可能通过缺页异常建立实际物理页。这就是按需分配。

因此:

  • mmap 一个很大的范围可能很快;
  • 虚拟内存统计可能立刻增加;
  • 实际驻留内存 RSS 可能在逐页访问后才明显增长。

十二、为什么 mmap 如此重要?

课堂中提到“几乎所有和内存相关的功能,底层都能看到 mmap 的影子”。虽然具体实现不能一概而论,但 mmap 的确是现代 UNIX 系统非常核心的机制。

1. 大块内存分配

malloc 分配器可以通过匿名 mmap 向内核申请新的区域,再管理其中的小块。

2. 文件映射

映射大文件后,程序只访问其中一小部分,操作系统可以按需调入对应页面。

适合的场景包括:

  • 数据库和索引文件;
  • 大型模型权重;
  • 可执行文件和共享库加载;
  • 需要随机访问的大文件。

mmap 并不在所有文件 I/O 场景下都一定比 read/write 更快。访问模式、错误处理、文件变化、同步要求和平台差异都会影响选择。

3. 进程间共享内存

多个进程可以对同一个共享对象建立 MAP_SHARED 映射,从而访问共同的物理页面。

这也说明:

地址空间相互隔离,并不等于永远不能共享;共享必须由操作系统明确授权并建立映射。

共享内存只解决“看到同一份数据”,不自动解决并发同步。多个进程同时修改数据时,仍需要锁、信号量或原子操作等机制。

4. 设备映射

某些设备可以通过文件描述符映射到进程地址空间。程序读写特定地址时,实际上在与设备交互。

这与前面提到的 volatile 有了联系:设备寄存器可能随外部硬件变化,编译器不能随意省略访问。

5. JIT 动态生成代码

JIT 编译器需要:

  1. 获得一段可写内存;
  2. 写入生成的机器指令;
  3. 将权限调整为可执行;
  4. 跳转执行。

出于安全考虑,现代系统通常不鼓励内存同时拥有写和执行权限。常见原则是 W^X:Writable or Executable,但不要同时两者都是。

6. AddressSanitizer 的 shadow memory

AddressSanitizer 会为应用内存建立对应的 shadow memory,用于记录哪些字节可以访问。

在 64 位地址空间中,可以先保留很大的虚拟地址范围,再按需使用;这正体现了“大而稀疏的地址空间”带来的灵活性。


十三、地址空间隔离为什么不是绝对禁止访问?

正常情况下,一个进程不能直接解引用另一个进程的指针。因为那个指针属于另一个地址空间。

但调试器必须能够:

  • 暂停目标程序;
  • 查看和修改寄存器;
  • 读取变量;
  • 设置断点;
  • 单步执行。

因此,操作系统提供了受到权限检查的机制,例如:

  • ptrace
  • /proc/[pid]/maps/proc/[pid]/mem 等 procfs 接口;
  • process_vm_readvprocess_vm_writev
  • 共享内存。

gdbstrace 等工具正是建立在这些能力之上。

这里的关键不是“隔离被破坏了”,而是:

默认隔离,经过操作系统权限检查后进行有限、明确的访问。

1. 游戏修改器为什么能工作?

某些单机游戏会把生命值、经验值等状态直接保存在进程内存中。调试或内存扫描工具可以在获得权限后:

  1. 搜索当前值;
  2. 让游戏状态发生变化;
  3. 再次搜索变化后的值;
  4. 缩小候选地址范围;
  5. 观察或修改找到的状态。

课堂中的 Game Genie 更接近物理层的地址和值替换:当处理器从某个地址读到指定值时,硬件将它替换为另一个值。

这些例子真正想说明的是:

地址空间中的字节只有结合程序语义,才知道它们代表金币、经验、指令还是普通缓存。

仅仅能够读取内存,并不代表能够理解程序。复杂程序可能:

  • 把状态拆分保存;
  • 加密或编码数据;
  • 频繁更换对象地址;
  • 从服务器校验状态;
  • 在每次更新时创建新对象。

调试、逆向和修改第三方程序还涉及授权、软件协议和法律边界。相关机制应只用于自己的程序、课程实验或明确获得授权的目标。


十四、段错误到底意味着什么?

“Segmentation fault” 经常被简单理解成“指针错了”。从地址空间角度看,它更具体:

进程执行了一个操作系统无法按照当前映射和权限规则完成的内存访问。

常见原因:

1. 解引用空指针

int *p = NULL;
*p = 1;

低地址区域通常故意不建立可访问映射,使空指针错误尽早暴露。

2. 使用未初始化指针

char *dst;
strcpy(dst, "hello");

dst 中是未确定的地址,可能指向未映射区域,也可能更危险地碰巧指向可写区域。

3. 数组越界

int a[4];
a[1000000] = 1;

越界访问属于未定义行为。它可能立即触发异常,也可能破坏同一映射中的其他数据后继续运行。

4. 向只读区域写入

char *s = "hello";
s[0] = 'H';

字符串字面量通常位于只读映射中,修改它属于未定义行为,并常常触发 SIGSEGV

5. 释放后继续使用

int *p = malloc(sizeof(*p));
free(p);
*p = 1;

free 后,这块区域可能仍暂时处于可映射状态,所以错误未必立刻崩溃。这也是 use-after-free 难以排查的原因之一。

6. 为什么“能运行一次”不代表正确?

内存错误是否立即暴露,受很多因素影响:

  • 分配器当前布局;
  • 编译优化;
  • 输入数据;
  • 线程调度;
  • 越界位置是否跨越映射边界;
  • 被破坏的数据何时才会使用。

因此:

编译通过、运行一次没崩溃,都不能证明 C/C++ 内存访问正确。

常用检查工具包括:

gcc -fsanitize=address,undefined -g program.c -o program
./program

valgrind --leak-check=full ./program

AddressSanitizer 一般具有更好的速度和编译器集成;Valgrind 在无需重新编译或观察某些底层行为时仍然很有价值。


十五. 总结

这一讲可以整理成下面的过程:

  1. 进程不仅有寄存器和 PID,还有完整的内存状态;
  2. 地址空间是进程看到的虚拟内存世界;
  3. 指针保存当前进程中的虚拟地址;
  4. 代码、数据、堆、栈和动态库等都以映射形式存在;
  5. execve 根据 ELF program header 和 ABI 建立初始地址空间;
  6. MMU 根据操作系统维护的映射把虚拟地址翻译成物理地址;
  7. 地址无法直接转换时,CPU 触发 page fault;
  8. mmapmunmapmprotect 可以增加、删除或修改映射;
  9. malloc 在这些底层机制之上管理进程内部的小块内存;
  10. 调试器可以在权限允许时观察或修改其他进程的状态。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐