既然已经了解了进程的创建和关闭,下面就来了解进程的详细内容。
基本前提整理
我们首先明确一个基本前提:进程所看到的地址空间是一个抽象的虚拟地址空间。
对进程而言,内存只体现为一段逻辑地址的集合,而其具体映射到物理内存还是外存、如何进行地址转换与缓存加速,均由 MMU 与内核在后台完成,这些不属于本文讨论范围。
进程可以通过系统调用请求创建或扩展内存映射,但虚拟地址的具体布局(如位置、连续性与对齐)通常由内核和运行时系统共同决定,对应用程序而言大多是透明的。
本文将重点关注进程在用户态层面如何管理和使用其逻辑地址空间。
实验观察进程地址空间:全局变量和代码块
实验程序
char arr[10485760];
int main() {
asm volatile(
".rept 10485760"
"nop"
".endr"
);
}
asm
asm(或 __asm__)是 GCC / Clang 提供的内联汇编机制,用于在 C/C++ 代码中直接插入汇编指令。我们将通过asm往代码空间快速插入,来扩充使得代码空间到一定大小,可以在实验的地址空间中定位和验证。
volatile
volatile 用来告知编译器该内联汇编具有不可见但真实的副作用,禁止优化器将其删除或随意重排。它保证汇编指令在最终生成的代码中确实存在,但并不自动提供内存访问顺序的约束。防止编译器优化掉这段无意义扩充。
总结
该程序在全局作用域定义了一个大小为 10 MB 的字符数组 arr,由于未显式初始化,它在语义上属于 .bss 段的全局静态存储区。main 函数中通过 asm volatile 插入了一段内联汇编,利用汇编器伪指令 .rept 在编译阶段展开并生成约 10 MB 的连续 nop 指令,从而显著拉长程序的代码段;volatile 确保这段指令序列不会被编译器优化删除或重排,因此在最终生成的可执行文件中真实存在。
使用gdb 调用 info proc mappings 和 info registers
观察 execve 之后进程的内存布局与寄存器初始状态
查看地址空间
展现结果:
(gdb) info proc mappings
process 32914
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x555555554000 0x555555555000 0x1000 0x0 r--p /home/maiicy/Desktop/code/cpp-code/tmp-code/tmp
0x555555555000 0x555555f56000 0xa01000 0x1000 r-xp /home/maiicy/Desktop/code/cpp-code/tmp-code/tmp
0x555555f56000 0x555555f57000 0x1000 0xa02000 r--p /home/maiicy/Desktop/code/cpp-code/tmp-code/tmp
0x555555f57000 0x555555f58000 0x1000 0xa02000 r--p /home/maiicy/Desktop/code/cpp-code/tmp-code/tmp
0x555555f58000 0x555555f59000 0x1000 0xa03000 rw-p /home/maiicy/Desktop/code/cpp-code/tmp-code/tmp
0x555555f59000 0x555556959000 0xa00000 0x0 rw-p
0x7ffff7c00000 0x7ffff7c28000 0x28000 0x0 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7c28000 0x7ffff7dbd000 0x195000 0x28000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7dbd000 0x7ffff7e15000 0x58000 0x1bd000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e15000 0x7ffff7e16000 0x1000 0x215000 ---p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e16000 0x7ffff7e1a000 0x4000 0x215000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e1a000 0x7ffff7e1c000 0x2000 0x219000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e1c000 0x7ffff7e29000 0xd000 0x0 rw-p
0x7ffff7fa5000 0x7ffff7fa8000 0x3000 0x0 rw-p
0x7ffff7fbb000 0x7ffff7fbd000 0x2000 0x0 rw-p
0x7ffff7fbd000 0x7ffff7fbf000 0x2000 0x0 r--p [vvar]
0x7ffff7fbf000 0x7ffff7fc1000 0x2000 0x0 r--p [vvar_vclock]
0x7ffff7fc1000 0x7ffff7fc3000 0x2000 0x0 r-xp [vdso]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 0x0 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc5000 0x7ffff7fef000 0x2a000 0x2000 r-xp /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fef000 0x7ffff7ffa000 0xb000 0x2c000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 0x37000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x39000 rw-p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 rw-p [stack]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 --xp [vsyscall]
分析内容:
Linux 进程地址空间映射总结表 by AI(info proc mappings)
| 类别 | 地址区间示例 | 权限 | 映射对象 | 主要内容 | 说明 / 备注 |
|---|---|---|---|---|---|
| 程序 ELF 头 / 只读段 | 0x5555…4000–5000 |
r–p | 程序本身 | ELF Header、Program Header、.rodata |
加载器读取元信息 |
程序代码段 .text |
0x5555…5000–f56000 |
r-xp | 程序本身 | 指令代码(含你的 10MB NOP) | 核心执行区 |
| 只读数据 / RELRO | 0x5555…f56000–f58000 |
r–p | 程序本身 | .rodata、.eh_frame |
运行期不可修改 |
初始化数据段 .data |
0x5555…f58000–f59000 |
rw-p | 程序本身 | 初始化全局变量 | 可读写 |
未初始化数据 .bss |
0x5555…f59000–969000 |
rw-p | 匿名 | char arr[10MB] |
你定义的全局数组 |
| libc ELF 只读段 | 0x7fff…0000–28000 |
r–p | libc.so.6 | ELF 头、只读数据 | C 运行时 |
| libc 代码段 | 0x7fff…28000–dbd000 |
r-xp | libc.so.6 | printf、malloc 等 |
高频调用 |
| libc guard page | 0x7fff…15000–16000 |
—p | libc.so.6 | 越界保护页 | 防止非法访问 |
| libc 数据段 | 0x7fff…16000–29000 |
rw-p | libc.so.6 | libc 全局状态 | 内部使用 |
| 匿名映射区 | 0x7fff…fa5000 等 |
rw-p | anonymous | malloc / TLS / pthread | 运行时动态分配 |
| vvar | 0x7fff…fbd000–fbf000 |
r–p | [vvar] |
时间/CPU 数据 | 内核映射 |
| vvar_vclock | 0x7fff…fbf000–fc1000 |
r–p | [vvar_vclock] |
高精度时钟 | 无 syscall |
| vdso | 0x7fff…fc1000–fc3000 |
r-xp | [vdso] |
clock_gettime 等 |
用户态内核函数 |
| 动态链接器只读段 | 0x7fff…fc3000–fc5000 |
r–p | ld-linux | ELF/rodata | 程序启动 |
| 动态链接器代码段 | 0x7fff…fc5000–fef000 |
r-xp | ld-linux | 符号解析 | 程序加载 |
| 动态链接器数据段 | 0x7fff…ffd000–fff000 |
rw-p | ld-linux | 链接器状态 | 启动后常驻 |
| 用户栈 | 0x7fff…fde000–fffff000 |
rw-p | [stack] |
函数栈帧 | 向下增长 |
| vsyscall(历史) | 0xffff…600000 |
–xp | [vsyscall] |
旧系统调用 | 已废弃 |
可以看到我们定义的10MB大内容在两个地方:
- 第二行的程序代码段,就是我们使用asm扩充的代码段。
- 第六行的匿名段也就是运行起来后生成的10MB字符数组占用空间。
vvar
vvar 是内核将部分只读的时间与 CPU 状态数据以只读页的形式映射到用户态,使用户程序在多数情况下无需陷入内核即可读取高精度时间信息,从而显著减少系统调用开销。
例如传统模式下:
time(NULL)
→ syscall
→ 内核态
→ 读取时间
→ 返回用户态
- 需要 用户态 ↔ 内核态切换
- 在高频调用(日志、profiling、调度)中代价明显
vsdo
vDSO 是内核向用户态映射的一段只读、可执行的共享代码页。它以“看起来像一个共享库”的形式存在,但不在磁盘上,而是由内核在进程启动时直接映射到用户地址空间。
其核心目标是:让某些原本需要系统调用的内核服务,在用户态通过普通函数调用完成。
典型用途包括:
gettimeofdayclock_gettimetimegetcpu(部分架构)
以 clock_gettime(CLOCK_MONOTONIC) 为例:
clock_gettime()
→ 调用 vDSO 中的用户态函数
→ 读取 vvar 中的时间/CPU 数据
→ 必要时做轻量校正
→ 返回用户态结果
vDSO 与 vvar 的关系
可以将二者理解为 “数据 + 代码” 的配套设计:
| 组件 | 角色 |
|---|---|
| vvar | 提供只读的、由内核维护的时间与 CPU 状态数据页 |
| vDSO | 提供用户态可执行的“封装函数”,负责读取 vvar 并处理细节 |
也就是说:正常情况下用户程序不会、也不应当直接读取 vvar;而是通过 vDSO 提供的函数接口来间接使用其中的数据。
题外话:wsl上的 gdb 效果
(gdb) info proc mappings
process 3353
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x555555554000 0x555555555000 0x1000 0x0 r--p /mnt/e/code/linux-code/cpp-code/execve/mock
0x555555555000 0x555555f56000 0xa01000 0x1000 r-xp /mnt/e/code/linux-code/cpp-code/execve/mock
0x555555f56000 0x555555f57000 0x1000 0xa02000 r--p /mnt/e/code/linux-code/cpp-code/execve/mock
0x555555f57000 0x555555f58000 0x1000 0xa02000 r--p /mnt/e/code/linux-code/cpp-code/execve/mock
0x555555f58000 0x555555f59000 0x1000 0xa03000 rw-p /mnt/e/code/linux-code/cpp-code/execve/mock
0x555555f59000 0x555556959000 0xa00000 0x0 rw-p [heap]
0x7ffff7da0000 0x7ffff7da3000 0x3000 0x0 rw-p
0x7ffff7da3000 0x7ffff7dcb000 0x28000 0x0 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7dcb000 0x7ffff7f53000 0x188000 0x28000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f53000 0x7ffff7fa2000 0x4f000 0x1b0000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa2000 0x7ffff7fa6000 0x4000 0x1fe000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa6000 0x7ffff7fa8000 0x2000 0x202000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa8000 0x7ffff7fb5000 0xd000 0x0 rw-p
0x7ffff7fbd000 0x7ffff7fbf000 0x2000 0x0 rw-p
0x7ffff7fbf000 0x7ffff7fc3000 0x4000 0x0 r--p [vvar]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 0x0 r-xp [vdso]
0x7ffff7fc5000 0x7ffff7fc6000 0x1000 0x0 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc6000 0x7ffff7ff1000 0x2b000 0x1000 r-xp /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ff1000 0x7ffff7ffb000 0xa000 0x2c000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 0x36000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x38000 rw-p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffdd000 0x7ffffffff000 0x22000 0x0 rw-p [stack]
(gdb)
在 Ubuntu 真机系统中,进程地址空间包含 vvar_vclock 和 vsyscall 等与硬件时钟及历史系统调用优化相关的特殊映射;而在 WSL 环境中,由于其运行于虚拟化或兼容层之上,内核不直接控制底层硬件,因此不提供 vvar_vclock 和 vsyscall 映射,仅保留 vDSO 与基础 vvar 以保证用户态时间接口的语义正确性。
查看寄存器空间
(gdb) starti
Starting program: /mnt/e/code/linux-code/cpp-code/execve/mock_static
Program stopped.
0x0000000000401740 in _start ()
(gdb) info registers
rax 0x0 0
rbx 0x0 0
rcx 0x0 0
rdx 0x0 0
rsi 0x0 0
rdi 0x0 0
rbp 0x0 0x0
rsp 0x7fffffffda00 0x7fffffffda00
r8 0x0 0
r9 0x0 0
r10 0x0 0
r11 0x0 0
r12 0x0 0
r13 0x0 0
r14 0x0 0
r15 0x0 0
rip 0x401740 0x401740 <_start>
eflags 0x200 [ IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
fs_base 0x0 0
gs_base 0x0 0
同时我们可以使用readelf来获取ELF的文件头数据
$ readelf -h ./mock_static
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x401740
Start of program headers: 64 (bytes into file)
Start of section headers: 11269232 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 10
Size of section headers: 64 (bytes)
Number of section headers: 28
Section header string table index: 27
我们可以发现,rip寄存器(CPU 下一条将要执行的指令的虚拟地址)与elf header中的 Entry point address 值一致。
可以看到,elf头和其执行效果是密切相关的,实际上,一个elf是否能在系统上可以执行,也是有完整的一套标准的,也就是ABI,只要符合对应系统采用的ABI,这个系统就可以处理这个ELF。
ABI(Application Binary Interface)简介
ABI(Application Binary Interface,应用二进制接口)定义了已经编译好的二进制程序之间,以及程序与操作系统之间如何正确协作。
如果说 API 解决的是“源码层面如何调用”,那么 ABI 解决的就是:不同模块在二进制层面,如何在不重新编译的前提下正确运行。
在现代操作系统中,ABI 是进程能否成功启动、函数能否正确调用、库能否被安全复用的根本前提。
System V AMD64 ABI
Linux / BSD / macOS(x86-64)的事实标准
链接:https://refspecs.linuxfoundation.org/elf/x86_64-abi-0.99.pdf
进程的地址空间
刚才的演示只能从程序里确定的静态空间来分配地址,已经分配了众多的空间。
mmap —— 建立新的虚拟内存映射
函数原型
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
参数说明
addr:建议映射的起始虚拟地址,通常传NULL由内核自动选择合适地址。length:映射区域的长度(字节数),实际会按页大小对齐。prot: 映射区域的访问权限,如PROT_READ、PROT_WRITE、PROT_EXEC。flags: 映射类型和行为控制,如MAP_PRIVATE、MAP_SHARED、MAP_ANONYMOUS。fd:被映射文件的文件描述符;匿名映射时可传-1。offset:文件映射的起始偏移,必须是页大小的整数倍。
核心作用
mmap 在进程的虚拟地址空间中创建一段新的内存映射,用于将文件内容、设备或匿名内存按页映射到用户空间,从而支持程序加载、共享库映射、大块内存分配以及高效的文件 I/O。
munmap —— 解除已有的内存映射
函数原型
intmunmap(void *addr, size_t length);
参数说明
addr:要解除映射的内存区域起始地址,必须是此前mmap返回的地址或其页对齐地址。length:解除映射的长度(字节数),同样按页大小对齐。
核心作用
munmap 用于撤销进程地址空间中一段已存在的内存映射,使对应的虚拟地址区间不再有效,常用于释放通过 mmap 申请的内存或解除文件映射,在进程退出时也会由内核统一执行。
mprotect —— 修改内存访问权限
函数原型
intmprotect(void *addr, size_t length, int prot);
参数说明
addr:需要修改权限的内存区域起始地址,必须页对齐。length:需要修改权限的区域长度(字节数)。prot:新的访问权限组合,如PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE。
核心作用
mprotect 用于动态调整进程地址空间中某段已映射内存的访问权限,常用于实现代码段保护(只读可执行)、运行时内存安全控制,以及 JIT 场景下写权限与执行权限的切换。
查看进程的地址空间 pmap
pmap 是 Linux 提供的用户态工具,用于查看某个进程当前的虚拟地址空间映射情况。它以人类可读的形式展示进程中各个虚拟内存区域的起始地址、大小、权限及来源。
基本用法
pmap <pid>
或查看更详细信息:
pmap -x <pid>
实际输出例子
$ pmap 366
366: -bash
0000559b8ee48000 192K r---- bash
0000559b8ee78000 956K r-x-- bash
0000559b8ef67000 212K r---- bash
0000559b8ef9c000 16K r---- bash
0000559b8efa0000 36K rw--- bash
0000559b8efa9000 44K rw--- [ anon ]
0000559bb96ad000 1580K rw--- [ anon ]
00007f93f717c000 356K r---- LC_CTYPE
00007f93f71d5000 4K r---- LC_NUMERIC
00007f93f71d6000 4K r---- LC_TIME
00007f93f71d7000 4K r---- LC_COLLATE
00007f93f71d8000 4K r---- LC_MONETARY
00007f93f71d9000 4K r---- SYS_LC_MESSAGES
00007f93f71da000 4K r---- LC_PAPER
00007f93f71db000 4K r---- LC_NAME
00007f93f71dc000 4K r---- LC_ADDRESS
00007f93f71dd000 4K r---- LC_TELEPHONE
00007f93f71de000 4K r---- LC_MEASUREMENT
00007f93f71df000 2988K r---- locale-archive
00007f93f74ca000 12K rw--- [ anon ]
00007f93f74cd000 160K r---- libc.so.6
00007f93f74f5000 1568K r-x-- libc.so.6
00007f93f767d000 316K r---- libc.so.6
00007f93f76cc000 16K r---- libc.so.6
00007f93f76d0000 8K rw--- libc.so.6
00007f93f76d2000 52K rw--- [ anon ]
00007f93f76df000 56K r---- libtinfo.so.6.4
00007f93f76ed000 76K r-x-- libtinfo.so.6.4
00007f93f7700000 56K r---- libtinfo.so.6.4
00007f93f770e000 16K r---- libtinfo.so.6.4
00007f93f7712000 4K rw--- libtinfo.so.6.4
00007f93f7713000 28K r--s- gconv-modules.cache
00007f93f771a000 4K r---- LC_IDENTIFICATION
00007f93f771b000 8K rw--- [ anon ]
00007f93f771d000 4K r---- ld-linux-x86-64.so.2
00007f93f771e000 172K r-x-- ld-linux-x86-64.so.2
00007f93f7749000 40K r---- ld-linux-x86-64.so.2
00007f93f7753000 8K r---- ld-linux-x86-64.so.2
00007f93f7755000 8K rw--- ld-linux-x86-64.so.2
00007fff34836000 132K rw--- [ stack ]
00007fff34902000 16K r---- [ anon ]
00007fff34906000 8K r-x-- [ anon ]
total 9188K
探讨问题:pmap的运行原理
pmap 并不直接访问页表或内核内存管理结构,而是通过读取 /proc/<pid>/maps 及相关 procfs 文件,在用户态重建进程的虚拟地址空间视图。
通过strace可以一目了然
$ strace pmap 366 2>&1 | grep 'proc/366'
openat(AT_FDCWD, "/proc/366/status", O_RDONLY) = 3
newfstatat(AT_FDCWD, "/proc/366", {st_mode=S_IFDIR|0555, st_size=0, ...}, 0) = 0
openat(AT_FDCWD, "/proc/366/stat", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/366/cmdline", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/366/maps", O_RDONLY) = 3
其核心操作就是读取这些内容,然后格式化输出出来。只是对maps的高级可读封装。
/proc 介绍
/proc 是 Linux 提供的一种虚拟文件系统(procfs),用于将内核内部的运行时状态以“文件”的形式暴露给用户空间程序。它不是磁盘上的真实文件系统,而是内核在内存中动态生成的接口层。
其是内核与用户态之间的一种只读/可控读写的信息通道,用于查询和配置系统及进程的运行状态。
其中文件内容并不持久存在,每一次 read 都是内核即时生成的结果。
/proc/pid/ 该目录中包含进程的核心运行信息,例如:
| 文件 | 含义 |
|---|---|
maps |
进程的虚拟地址空间映射 |
smaps |
更详细的内存使用统计 |
stat |
进程状态与调度信息 |
status |
人类可读的进程摘要 |
cmdline |
启动命令行 |
fd/ |
该进程打开的文件描述符 |
实际上,ps、lsof等也是依赖这个目录提供的信息接口。
malloc 使用 mmap 行为
对于进程而言,动态内存需求是常态。对 C 程序来说,通常通过 malloc 获取新的内存空间。
实际上,malloc 会根据申请大小,选择通过 brk 扩展 heap,或直接通过 mmap 向内核申请新的虚拟地址区间。
如果每一次 malloc 都直接调用 mmap,就会频繁陷入内核态,带来明显的性能开销。
在 Unix 进程模型中,进程地址空间天然包含一个可向上增长的数据区(heap),用于支持高频、小规模的动态内存分配,解决了频繁切换的问题。
malloc的使用heap行为
以 glibc 的默认策略为例,当申请的内存 小于约 128KB 时,malloc 通常会将其分配在 heap 中。
heap 的行为类似于一个动态数组:当现有空间不足时,malloc 通过 brk 系统调用请求内核扩展 heap 的上界。
一旦 heap 扩展到足够大小,后续的小块内存分配与回收均可在用户态完成块级管理,无需再次进入内核态。
由于 heap 必须保持连续增长,当 heap 后方的虚拟地址空间已被 mmap 或其他映射占用时,brk 将无法继续扩展。
malloc 直接使用 mmap
对于 大于约 128KB 的内存申请,malloc 通常直接使用 mmap 单独映射一段虚拟地址空间。
这种做法可以避免 heap 过快增长和碎片化问题,同时也更便于大块内存的独立回收。
heap 无法精准 free
由于 heap 中的内存由用户态的 malloc 统一管理,内核只感知整段 heap 的边界而不关心其内部块的使用情况,因此无法在块级别进行精准回收;相对地,基于 mmap 的内存分配以独立映射区间存在,释放时可以通过 munmap 明确告知内核整段地址不再使用。正是为了避免频繁进入内核态,小规模、高频的内存分配选择在 heap 中完成,而大块内存则直接使用 mmap。
内存泄露
在程序开始运行时,内核会为进程建立并初始化其虚拟地址空间布局;而在程序结束时,进程所占用的所有虚拟内存映射(包括 heap、mmap 等)都会被内核统一回收。因此,从操作系统层面来看,进程结束后并不存在真正意义上的内存泄露。
通常所说的内存泄露,指的是进程运行期间的问题:程序在用户态分配了内存(如通过 malloc 或 mmap),但由于逻辑错误丢失了对这些内存的引用,导致已分配的内存无法再被程序复用,从而使 heap 持续增长或虚拟内存占用不断增加。
跟踪mmap创建的内存实验
示例程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <string.h>
#define PAGE_SIZE 4096
void wait_point(const char *msg) {
printf("\\n=== %s ===\\n", msg);
}
int main() {
wait_point("start program");
/* 1. 匿名 mmap(私有,可写) */
void *anon1 = mmap(NULL, PAGE_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
wait_point("after anonymous mmap");
/* 2. 再映射一块更大的匿名区 */
void *anon2 = mmap(NULL, PAGE_SIZE * 10,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
wait_point("after large anonymous mmap");
/* 3. 文件映射(只读) */
int fd = open("./empty.bin", O_RDONLY);
void *file_ro = mmap(NULL, PAGE_SIZE,
PROT_READ,
MAP_PRIVATE,
fd, 0);
wait_point("after file-backed mmap (RO)");
/* 4. 文件映射(可写,共享) */
int fd2 = open("./empty2.bin", O_RDWR);
void *file_rw = mmap(NULL, PAGE_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd2, 0);
wait_point("after file-backed mmap (RW, shared)");
/* 5. 解除一块映射 */
munmap(anon1, PAGE_SIZE);
wait_point("after munmap anon1");
close(fd);
close(fd2);
wait_point("end program");
return 0;
}
after large anonymous mmap(大匿名 mmap)
新增内容:
0x7ffff7d96000 - 0x7ffff7da3000 rw-p
after file-backed mmap (RO)
新增内容:
0x7ffff7fbb000 - 0x7ffff7fbc000 r--p empty.bin
after file-backed mmap (RW, shared)
新增内容:
0x7ffff7fba000 - 0x7ffff7fbb000 rw-s empty2.bin
入侵进程的地址空间
金手指
金手指作为早期游戏上的硬件外挂,允许让玩家拥有无限的血量,在游戏内达到“无敌”的层面。
使用方式通常是在主机与游戏卡带之间插入金手指硬件,并在启动游戏前或运行过程中输入对应的作弊码。作弊码配置完成后,金手指会在游戏运行时自动生效,无需修改游戏程序或重新加载数据。
原理
其作为插在主机 CPU 与游戏卡带之间的硬件中间层,通过直接监听并拦截地址总线与数据总线,在 CPU 访问特定内存地址或指令地址时,动态替换返回的数据或指令。它并不修改游戏卡带中的 ROM 或 RAM 芯片本身,而是在总线级别对内存访问结果进行实时篡改,使 CPU 在执行过程中获得被伪造的内存状态或指令流,从而改变游戏逻辑。
在缺乏虚拟内存与权限隔离的早期游戏主机环境中,这种总线级拦截可以对程序执行产生完全透明且即时的影响。
作弊码
所谓“作弊码”并非密码或魔法字符串,而是对目标地址、替换数据以及可选条件的编码表示。用户输入的字符序列在金手指内部被解码为一组地址匹配与数据替换规则,当 CPU 在运行过程中访问这些地址时,金手指根据规则判断是否命中,并在命中时返回预先设定的替换数据。
例如解码作弊码后可以得到以下类似的数据
struct cheat_rule {
uint16_t address; // 目标地址
uint8_t new_value; // 替换的数据
uint8_t compare; // 可选:原值匹配
};
然后根据定义的逻辑数据,将血量地址改为999999,这样的话,当游戏机想要读取血量地址的数据,就会被金手指意识到后拦截卡带中的原血量,返回999999。
入侵内存修改
由于是早期游戏,许多例如血量设计,都是静态的存储,血量数据的地址的固定的。现在的内存通常是类似于以下
player = new Player();
动态内存的方式创建的。因此就无法像作弊码一样输入固定的address了。
对于像Cheat Engine这些修改器他们的做法就是:
类似以下
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#define MAX_ADDRS 10000000 // 最多保存 1e7 个候选地址
#define LINE_LEN 512
typedef struct {
uintptr_t start;
uintptr_t end;
} region_t;
static uintptr_t *candidates;
static size_t cand_count = 0;
/* 解析 /proc/pid/maps,只保留可读的匿名/heap 区域 */
size_t load_regions(pid_t pid, region_t **out) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/maps", pid);
FILE *fp = fopen(path, "r");
if (!fp) {
perror("fopen maps");
exit(1);
}
size_t cap = 64, n = 0;
region_t *regs = malloc(cap * sizeof(region_t));
char line[LINE_LEN];
while (fgets(line, sizeof(line), fp)) {
uintptr_t start, end;
char perm[5], rest[256];
sscanf(line, "%lx-%lx %4s %*s %*s %*s %255[^\\n]",
&start, &end, perm, rest);
if (perm[0] != 'r')
continue;
/* 只扫 heap 或匿名映射,避免代码段/文件段 */
if (strstr(rest, "[heap]") || strlen(rest) == 0) {
if (n == cap) {
cap *= 2;
regs = realloc(regs, cap * sizeof(region_t));
}
regs[n++] = (region_t){ start, end };
}
}
fclose(fp);
*out = regs;
return n;
}
/* 初次扫描 */
void initial_scan(int memfd, region_t *regs, size_t nregs, int target) {
cand_count = 0;
for (size_t i = 0; i < nregs; i++) {
for (uintptr_t addr = regs[i].start;
addr + sizeof(int) <= regs[i].end;
addr += sizeof(int)) {
int v;
if (pread(memfd, &v, sizeof(v), addr) != sizeof(v))
continue;
if (v == target) {
candidates[cand_count++] = addr;
if (cand_count >= MAX_ADDRS) {
fprintf(stderr, "Too many candidates, abort.\\n");
exit(1);
}
}
}
}
}
/* 多次筛选 */
void filter_scan(int memfd, int target) {
size_t w = 0;
for (size_t i = 0; i < cand_count; i++) {
int v;
if (pread(memfd, &v, sizeof(v), candidates[i]) != sizeof(v))
continue;
if (v == target) {
candidates[w++] = candidates[i];
}
}
cand_count = w;
}
/* 批量写回 */
void patch_all(int memfd, int newval) {
for (size_t i = 0; i < cand_count; i++) {
pwrite(memfd, &newval, sizeof(newval), candidates[i]);
}
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <pid>\\n", argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
char mempath[64];
snprintf(mempath, sizeof(mempath), "/proc/%d/mem", pid);
int memfd = open(mempath, O_RDWR);
if (memfd < 0) {
perror("open mem");
return 1;
}
region_t *regions;
size_t nregs = load_regions(pid, ®ions);
candidates = malloc(MAX_ADDRS * sizeof(uintptr_t));
if (!candidates) {
perror("malloc");
return 1;
}
int v;
printf("Initial value: ");
scanf("%d", &v);
initial_scan(memfd, regions, nregs, v);
printf("Found %zu candidates\\n", cand_count);
while (cand_count > 1) {
printf("Next value (or -1 to stop): ");
scanf("%d", &v);
if (v == -1)
break;
filter_scan(memfd, v);
printf("Remaining %zu candidates\\n", cand_count);
}
if (cand_count == 0) {
printf("No candidates left.\\n");
return 0;
}
printf("New value to write: ");
scanf("%d", &v);
patch_all(memfd, v);
printf("Patched %zu addresses.\\n", cand_count);
return 0;
}
通过扫描游戏内的一个值例如金钱 10000 ,会有许多个10000的值的地址
然后花费金钱到9000,再扫描上述地址中谁变成9000
然后变化金钱,再次扫描,直到确定是哪些地址。
修改这些地址中的值,金钱也就会直接被变化。
修改内存的三种方法
通过 /proc/<pid>/mem 直接读写内存(最直观)
Linux 为每个进程提供了一个特殊文件 /proc/<pid>/mem,它是目标进程虚拟地址空间的线性视图。只要具备权限,用户程序就可以通过 pread / pwrite,在指定虚拟地址处直接读取或修改目标进程的内存内容。
使用 ptrace 控制并修改进程(最“调试器式”)
ptrace 是 Linux 提供的进程调试接口,允许一个进程附加(attach)到另一个进程,并完全控制其执行状态。调试进程可以:
- 暂停目标进程
- 读写其寄存器
- 读写其内存
- 单步执行
使用 process_vm_readv / writev(最“现代”、最高效)
这是 Linux 3.2 引入的系统调用,允许一个进程在获得权限的前提下,直接在内核中完成进程间内存拷贝,绕过 /proc 文件接口。
三种方式的横向对比
| 方式 | 控制粒度 | 性能 | 易理解 | 典型角色 |
|---|---|---|---|---|
/proc/pid/mem |
内存级 | 中 | 高 | CE、教学 |
ptrace |
执行 + 内存 | 低 | 中 | GDB |
process_vm_* |
内存级 | 高 | 低 | 专业工具 |