/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
文章目录
-
- 1. C 变量与进程中的内存区域
-
- 1.1 存储期、使用方式与常见区域
- 1.2 观察变量对象与指针变量本身
- 1.3 实际布局不是固定地址表
- 2. fork 后地址相同而内容不同
- 3. 地址需要放在进程的映射关系中理解
-
- 3.1 程序看到的通常是虚拟地址
- 3.2 内核管理映射,硬件执行地址转换
- 4. 通过 /proc/PID/maps 对照内存区域
-
- 4.1 查看正在等待的示例
- 4.2 对照地址,而不是只看标签
- 4.3 mm_struct 与 VMA 的职责
- 5. fork 与写时拷贝
-
- 5.1 先保证逻辑独立,再优化物理复制
- 5.2 复制单位通常是页,不是单个变量
- 6. 地址空间带来的隔离与分配灵活性
- 7. 小结
在前面的内容中,子进程把 value 改成 20,父进程仍看到 10。这说明普通私有数据互不影响。但如果再把地址打印出来,就会出现一个值得认真解释的现象:两个进程中的数值地址可以相同,内容却不同。
这里并没有违背指针的规则。问题在于,我们平时用 %p 看到的地址,不是可以脱离进程直接定位一块物理内存的编号。
1. C 变量与进程中的内存区域
1.1 存储期、使用方式与常见区域
先接回熟悉的 C 知识:全局变量、静态局部变量具有静态存储期;普通局部变量常由调用栈承载;malloc 分配的对象具有动态分配的生存期。
在 Linux 上观察普通用户程序时,通常可以看到下面这些区域或映射:
| 代码相关映射 | 程序的机器指令 | 可执行文件通常对应多个权限不同的映射 |
| 只读数据相关映射 | 字符串字面量、某些常量等 | 不要通过指针修改字符串字面量 |
| 数据区 | 显式初始化的部分静态存储期对象 | 具体分类还受初始化内容和工具链影响 |
| BSS 相关区域 | 未显式初始化或零初始化的部分静态存储期对象 | 不等于磁盘文件中必须逐字节存满同样大小的零 |
| 堆与其他动态分配映射 | 分配器用于满足 malloc 等请求的内存 | 较大分配等可能来自独立映射,不一定都位于 [heap] |
| 栈 | 调用相关信息、常见自动变量等 | 优化后部分变量可能只在寄存器中,不能假定每个变量总有固定栈位置 |
| 共享库等映射 | C 库、动态链接器及其他映射内容 | 地址空间不止“代码、堆、栈”几块 |
1.2 观察变量对象与指针变量本身
编写并保存下面的完整程序为 address_regions.c:
#include <stdio.h>
#include <stdlib.h>
// 显式初始化静态全局变量 → 存放在 .data 数据区
static int global_init = 7;
// 未初始化静态全局变量 → 存放在 .bss 段,程序加载时自动置0
static int global_zero;
int main(void)
{
// 静态局部变量:静态存储期,只初始化一次,存放在 .data
static int static_local = 9;
// 普通局部自动变量:存储在栈上,函数调用时创建,函数退出销毁
int local = 11;
// text指针变量本身在栈;字符串字面量"linux"存放在只读数据段
const char *text = "linux";
// heap_value指针变量本身在栈;malloc分配的内存在堆(或mmap映射区)
int *heap_value = malloc(sizeof *heap_value);
if (heap_value == NULL)
{
perror("malloc");
return 1;
}
// 向堆内存写入数据
*heap_value = 13;
// 打印各个变量的虚拟地址,观察不同内存区域地址分布
printf("global_init = %p\\n", (void *)&global_init);
printf("global_zero = %p\\n", (void *)&global_zero);
printf("static_local = %p\\n", (void *)&static_local);
printf("local = %p\\n", (void *)&local);
printf("heap object = %p\\n", (void *)heap_value); // 堆内存地址
printf("heap pointer = %p\\n", (void *)&heap_value); // 存放heap_value这个指针变量本身,在栈
printf("literal = %p\\n", (void *)text); // 字符串字面量(只读段)地址
printf("text pointer = %p\\n", (void *)&text); // text指针变量本身,在栈
puts("Press Enter to free the allocation and exit.");
// 暂停程序
(void)getchar();
free(heap_value);
return 0;
}
gcc -std=c11 address_regions.c -o address_regions
./address_regions
运行示例:
地址分组观察:
global_init / global_zero / static_local 0x55e9a5976010、0x55e9a597601c、0x55e9a5976014 地址挨在一起,属于静态存储区(.data/.bss),不在栈上。
local / heap pointer / text pointer 0x7ffd1a5c2554、0x7ffd1a5c2560、0x7ffd1a5c2558 地址非常接近,都是栈上的局部变量。
- heap_value:栈上的指针变量,存着堆内存的起始地址
- text:栈上的指针变量,存着只读段字符串字面量的起始地址
heap object = 0x55e9c609c2a0 malloc分配出来的堆内存,独立区域。
literal = 0x55e9a5974008 字符串字面量"linux",放在只读数据段,地址和全局静态变量相近,但属于只读映射。
地址会随运行环境变化,这里重点是要区分两组容易混淆的对象:
- heap_value 保存动态分配对象的地址,&heap_value 是局部指针变量自身的地址。
- text 指向字符串字面量,&text 是局部指针变量自身的地址。
因此,“指针变量是局部变量”和“指针指向动态分配对象”可以同时成立,指针本身与被指对象不必在同类区域。
static_local 虽然写在函数内部,但具有静态存储期,不应仅凭书写位置把它划入栈中。
所有对象地址都显式转换为 void *,配合 %p 输出。
1.3 实际布局不是固定地址表
多次执行程序,部分地址通常会变化,这是地址空间布局随机化等机制的正常结果。
常见 64 位 Linux 上,栈通常向较低地址扩展;传统堆区域可能向较高地址扩展。但不能进一步推出“后声明的局部变量地址一定更小”或“后一次 malloc 的地址一定更大”。编译器布局、分配器复用和不同映射都可能改变观察结果。
2. fork 后地址相同而内容不同
下面用一个全局变量和一个动态分配对象,验证同一份程序创建出的父子进程。
保存为 fork_memory.c:
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
// 静态全局变量,存放在.data段,fork后父子进程各自拥有独立副本
static int number = 10;
int main()
{
int *heap_value = malloc(sizeof *heap_value);
if (heap_value == NULL) {
perror("malloc");
return 1;
}
*heap_value = 30;
// fork之前:父进程打印全局变量、堆变量的地址和值
printf("before: global@%p=%d heap@%p=%d\\n",
(void *)&number, number, (void *)heap_value, *heap_value);
pid_t child = fork();
if (child < 0) {
perror("fork");
free(heap_value);
return 1;
}
if (child == 0) {
// ========== 子进程执行分支 ==========
// 修改全局变量,触发写时拷贝,产生独立内存副本
number = 100;
// 修改堆上的数据,同样触发写时拷贝,子进程拥有独立堆副本
*heap_value = 300;
printf("child: global@%p=%d heap@%p=%d\\n",
(void *)&number, number, (void *)heap_value, *heap_value);
free(heap_value);
return 0;
}
// ========== 父进程执行分支 ==========
// waitpid阻塞等待子进程退出,回收子进程资源
pid_t result;
result = waitpid(child, NULL, 0);
// 子进程修改不会影响父进程的内存,父进程的值保持不变
printf("parent: global@%p=%d heap@%p=%d\\n",
(void *)&number, number, (void *)heap_value, *heap_value);
free(heap_value);
return 0;
}
gcc -std=c11 fork_memory.c -o fork_memory
./fork_memory
运行示例: 
| before | 地址 0x55d828daf010 | 10 | 地址 0x55d830bdf2a0 | 30 |
| child | 仍为 0x55d828daf010 | 100 | 仍为 0x55d830bdf2a0 | 300 |
| parent | 仍为 0x55d828daf010 | 10 | 仍为 0x55d830bdf2a0 | 30 |
子进程中的 free(heap_value) 也不会释放父进程自己的那份动态分配对象;父进程最后仍应释放自己的分配。两个进程各自释放各自的分配,不等于一个进程对同一对象重复 free。
3. 地址需要放在进程的映射关系中理解
3.1 程序看到的通常是虚拟地址
在这里讨论的普通 Linux 用户进程中,指针数值属于该进程的虚拟地址空间。使用它访问内存时,还需要通过该进程对应的映射关系定位实际物理内存。
因此,完整的理解不能只保留“地址数值 A”,还需要知道“哪个进程中的 A”。进程甲的 A 和进程乙的 A,可以映射到不同物理页。

一旦放回各自的映射关系,“相同地址、不同内容”就不矛盾了。
3.2 内核管理映射,硬件执行地址转换
内核负责建立和维护映射与访问权限。CPU 的内存管理单元(MMU)依据相关转换信息执行地址转换,页表等结构提供映射依据。
这不是“每访问一个变量,都先调用一次内核函数来翻译地址”。正常的地址访问由硬件机制处理;需要补充映射、处理写保护等情况时,才可能触发异常并进入内核处理。
页是理解这一机制的基本单位。普通页常见大小为 4 KiB,但具体以系统为准,还可能存在更大的页。可在服务器查询基本页大小:
getconf PAGESIZE

4. 通过 /proc/PID/maps 对照内存区域
4.1 查看正在等待的示例
在第一个 SSH 会话运行 ./address_regions,保持它等待回车;第二个会话使用:
pgrep -x address_regions
此时可得到这个正在运行进程的PID:
然后将打印的PID对应数字替换下方的12345:
pid=12345
cat /proc/"$pid"/maps
maps 的一行通常包含:
地址范围 权限 文件偏移 设备号 inode 路径或区域标记
地址范围采用左闭右开区间,权限中的常见字符是:
| r | 可读 |
| w | 可写 |
| x | 可执行 |
| p | 私有映射 |
| s | 共享映射 |
| – | 对应位置的权限未设置 |
这里的权限描述内存映射,不能与 ls -l 的文件权限字段混为一谈。
4.2 对照地址,而不是只看标签
把程序输出的地址放进 maps 的某个地址范围中,可以观察它落在哪段映射:
- 全局变量地址通常落在程序相关的可写区域。
- 局部变量与局部指针变量自身通常落在栈相关映射。
- 字符串字面量通常位于程序的只读映射。
- 带 x 的程序映射有助于识别代码相关区域。
同一个可执行文件出现多行正常,因为不同部分具有不同的权限和映射要求。
maps 展示的是虚拟地址范围和映射属性,但不能直接证明某两个虚拟页当前是否共享同一物理页。
4.3 mm_struct 与 VMA 的职责
从内核管理的角度,可以把普通用户进程的内存关系理解为:
任务管理信息 → 地址空间描述 → 多个虚拟内存区域及其映射
task_struct mm_struct VMA 等相关信息
mm_struct 描述地址空间相关信息;VMA,即虚拟内存区域,描述一段具有相应属性的虚拟地址范围。区域描述和页表分工不同:前者说明“这段地址是什么、允许做什么”,后者参与落实页级映射。
5. fork 与写时拷贝
5.1 先保证逻辑独立,再优化物理复制
fork 需要让父子进程都能使用创建时继承的内容,但很多子进程随后很快执行其他程序,或者只读取其中一部分数据。如果一开始就完整复制所有可复制物理内存,可能做了大量随后用不到的工作。
Linux 对普通私有内存通常使用写时拷贝,Copy-on-Write,COW。概念过程如下:

5.2 复制单位通常是页,不是单个变量
一个 int 可能只有几个字节,但写时拷贝通常按页处理。多个对象若位于同一页,对其中一个对象的写入可能使所在页需要独立;不能想成内核每次只复制那一个变量。
如果某页已经不再需要与其他使用者共享,写入时也未必必须再做一次物理复制。重要的是保证私有映射的行为,而非每次写入都机械执行相同动作。
6. 地址空间带来的隔离与分配灵活性
地址空间让每个进程使用自己的映射视图。一个进程中的地址,不能仅凭数值就拿去访问另一个进程的任意内存。错误指针访问可能导致该进程收到异常信号,而不是自然地指向旁边某个进程的变量。
这种隔离还依赖访问权限与内核保护,并不意味着所有越界写都会立刻被发现:若错误地址仍落在该进程可写的有效映射中,它可能悄悄破坏自身数据。虚拟内存不能替代 C 程序的边界检查。
映射也允许连续的虚拟地址对应不连续的物理页,减轻必须寻找大块连续物理内存的要求。某些内存内容可以按需建立或加载,不必把整个进程完整装入一段连续物理空间。
不过,“按需申请”并不等于 malloc 成功后就永远不会遇到内存压力,也不等于 malloc 每次都完全不占用物理内存。分配器复用、内核策略和实际访问都会影响结果。
回看开头,父子变量的地址相同,是创建后保留了相同数值的虚拟地址;数值不同,是私有地址空间的写入效果被隔离。地址数值、映射关系、物理页内容,应当分成三个层次理解。
映射和创建机制可进一步查阅 man 5 proc_pid_maps、man 2 fork、man 2 mmap等。
7. 小结
-
Linux 中每个进程拥有独立的虚拟地址空间,进程之间内存天然隔离,一个进程的虚拟地址数值无法直接访问其他进程内存。
-
虚拟地址空间划分代码段、只读数据段、.data、.bss、堆、栈、共享库映射等区域;指针变量本身与它指向的内容可处于不同内存区域。
-
fork创建子进程时不会立刻复制全部物理内存页,采用写时拷贝(COW)机制。
-
fork 初期父子进程共享同一份物理内存,页表标记为只读;任意进程执行写操作时,内核才复制对应内存页,父子各自持有独立副本。
-
父子进程打印出来的虚拟地址数值相同,但修改数据互不影响;虚拟地址相同不等于物理内存是同一块。
-
malloc成功仅代表分配到虚拟地址,不一定立刻占用物理内存;虚拟地址连续,对应的物理内存页可以不连续。



