Linux下的进程地址空间

在介绍C++的内存控制时, 我用了这样一张图来大致表述一个程序的程序地址空间, 并且也提到过这块空间占用的是内存, 并且通过下面的一段代码大致分析了, 各区域存储的变量类型:
如果不了解C++内存管理, 可以去看一下博主介绍C++内存管理的博客:
[C++] 超详细分析 C++内存分布、管理(new - delete) ~ C 和 C++ 内存管理关系 ~ 内存泄漏 ~_c++ 嵌套delete
虽然了解了各种数据在此空间中大致所存储的位置, 但是其实并不理解这块空间:
- 这块空间表示实际的物理地址空间吗?
- CPU是如何从这块空间中获取数据并处理的呢?
- ……
对于这块空间, 其实还有许多的问题没有理解, 这篇文章就是介绍Liunx中关于这快空间的相关介绍分析
不过针对Linux, 需要在将上图再细微修改一下:

这张图可以大致用来表示 Linux下进程地址空间区域分布
验证各种类数据存储区域
Linux系统中, 程序加载为进程之后, 进程中的数据会按照上图所表示的各数据区域在内存中存储
可以使用以下的代码来简单的验证一下:
#include
#include
int global_Var;
int init_global_Var = 1;
int main() {
static int static_Var = 1;
char* stack_data1 = (char*)malloc(100);
char* stack_data2 = (char*)malloc(100);
char* stack_data3 = (char*)malloc(100);
char* stack_data4 = (char*)malloc(100);
printf("main addr:: %p\n", main);
printf("global_Var addr:: %p\n", &global_Var);
printf("init_global_Var addr:: %p\n", &init_global_Var);
printf("static_Var addr:: %p\n", &static_Var);
printf("stack_data1 addr:: %p\n", &stack_data1);
printf("stack_data2 addr:: %p\n", &stack_data2);
printf("stack_data3 addr:: %p\n", &stack_data3);
printf("stack_data4 addr:: %p\n", &stack_data4);
printf("heap_data1 addr:: %p\n", stack_data1);
printf("heap_data2 addr:: %p\n", stack_data2);
printf("heap_data3 addr:: %p\n", stack_data3);
printf("heap_data4 addr:: %p\n", stack_data4);
return 0;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
运行此代码程序可以看到:

-
首先输出 main函数地址:
是输出的所有地址中最小的, 也就是最低的
但也不是整个进程的首地址, 这可以大致说明
进程的代码地址应该是在其他所有数据之前的
-
其次是 未初始化的全局变量、初始化的全局变量 和 初始化的函数内部定义的静态变量:
首先是未初始化、初始化的全局变量:可以看到, 未初始化的全局变量的地址是在已经初始化的全局变量上面的, 也就对应了图中细分的静态区区域:
全局变量相对来讲:未初始化数据在高地址, 初始化数据在低地址
而且可以看到, 经过初始化的在main函数体内部定义的static变量的地址 位于两个全局变量之间, 其实这就说明,
被static修饰的变量 其实实际上就是一个全局变量, 只有在进程结束后才会被释放的
-
定义在栈上的数据:
, 按照定义的顺序, 最先定义的数据的地址空间最大最高, 之后定义的
按照定义顺序逐渐减小
, 这表明在栈上定义数据 是由高到低占用空间的, 即在栈上定义数据占用空间是向下增长的
-
定义在堆上的数据:
, 按照定义的顺序,
其占用空间的方向 与栈刚好相反
.在堆区定义数据 是由低到高占用空间的, 即在堆区定义数据占用空间是向上增长的
-
栈 和 堆区数据的地址, 存在非常大的断层:
, 这也说明 堆和栈之间是存在着非常大的一块地址空间的:
如何感知到进程确实存在进程地址空间
上面举例介绍、也验证了, 进程的各种种类数据确实是按照 一定的区域分布的, 但是也没有办法说明进程有一块自己的空间来存储数据
那么, 如何说明进程确实存在一块空间呢?
还是使用一段代码:
#include
#include
int global_Var = 100;
int main() {
pid_t id = fork();
if(id == 0) {
int cnt = 5;
while(1) {
if(cnt > 0) {
prinf("我是子进程, global_Var= %d, addr=%p, 还有 %ds 修改global_Var\n", global_Var, &global_Var, cnt);
cnt--;
if(cnt == 0) {
global_Var = 200;
printf("我是子进程, 我已修改global_Var\n");
}
}
else
printf("我是子进程, global_Var= %d, addr=%p\n", global_Var, &global_Var);
sleep(1);
}
}
else {
while(1) {
printf("我是父进程, global_Var= %d, addr=%p\n\n", global_Var, &global_Var);
sleep(2);
}
}
return 0;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
运行上述代码, 你会发现一个令人震惊的现象:
前5s, 父子进程global_Var的地址相同, 值也相同; 但是5s之后, 发现 父子进程的global_Val地址相同, 但是值却不同了
为什么会出现这种现象?以往的认知中, 同一个地址存储的内容应该是相同的, 但这为什么不同了?
虽然现在不太知道原因, 但是一定能推断出一个结论, 就是 这两个进程使用的地址, 一定不是内存的物理地址, 即 C/C++中的地址一定不是内存的物理地址.
因为, 如果使用的物理地址根本不可能存在同一个地址却拥有两个不同的值的这种情况
那么C/C++使用的地址是什么地址呢?C/C++使用的地址空间其实是虚拟地址空间, 而并不是实际的内存物理地址空间
虚拟(进程)地址空间
在Linux系统中, 每一个进程被加载内存中时, 操作系统都会为其创建一个虚拟地址空间(也叫进程地址空间)
, 这个虚拟地址空间并不是内存的物理空间但是存在一定的映射关系, 且虚拟地址是被task_struct描述的
. 也就是说, 进程的task_struct、虚拟地址与物理地址存在类似此图示一样的关系:

task_struct描述着进程的所有属性, 也描述着进程的虚拟地址空间, 而虚拟地址空间与内存实际的物理地址只存在相互映射的关系
内存中加载的进程的代码和数据, 会通过一个叫页表的东西映射到虚拟地址空间中:

每个进程都会有一个虚拟地址空间, 这个虚拟地址空间也是被操作系统管理着的, 即 操作系统也会对虚拟地址空间生成一个类似PCB的数据结构, 在Linux中叫 struct mm_struct{}
:
此结构中也描述着有关进程地址空间的属性, 它描述着进程地址空间中各个区域之间的范围:栈区、堆区、静态区、代码段……各区域的地址范围
, 实际上 struct mm_struct{}
就是用来维护进程地址空间的
父子进程代码继承关系
Linux中, 调用fork()系统调用创建的子进程的代码是继承自其父进程的. 当时只是简单的提了一句:可以看作父进程fork()之后的代码与子进程是共享的
但是并不知道实际情况到底是如何的, 那么就还以上面父子进程的程序代码为例, 介绍一下父子进程代码继承的实际关系:
首先, 操作系统会针对父进程生成一个属于父进程的进程地址空间, 当子进程被创建出来之后, 操作系统也会针对子进程生成一个属于子进程的进程地址空间:
而所谓的共享代码, 实际的意思其实是代码的物理地址共享
即 父进程地址空间中的代码、数据地址通过页表映射到物理地址中其相对应的指定地址, 而子进程地址空间中的代码、数据地址
通过页表映射到与父进程相同的物理地址
:

在父子进程都未修改数据时, 父子进程的数据的物理地址也是共享的, 也就是说在父子进程都未修改数据的时候, 虽然父子进程都有属于自己的进程地址空间, 但内存中实际只加载了一份代码与数据
当子进程继续执行代码, 修改了0x60104C地址
所存储的值时, 操作系统就会在在物理空间中申请一个新的地址供子进程存储数据使用
, 同时修改子进程页表内容:

这种在数据做修改时, 才实际操作物理地址拷贝一份空间的方法叫
写时拷贝
这就是父子进程共享代码的实际情况
程序是如何加载为进程的
我们知道, 当源代码被编译器编译链接之后, 会生成可执行程序. 运行这个可执行程序, 然后操作系统生成PCB 与 程序的代码、数据一起加载到内存中, 创建了一个进程地址空间, 此时就说一个进程被创建了.
但这只是一个流程, 那么实际上操作系统是根据什么将程序的数据加载到内存中的呢?
在回答这个问题之前, 先分析这两个问题:
-
程序在没有运行、没有被加载到内存中的时候, 程序内部是否存在地址?
一定是存在的. 程序是源文件被编译器编译链接而生成的, 编译的过程暂且不讲. 而链接, 其实就是将程序内各种数据、函数的地址与库中的地址链接起来, 才能成为可执行程序的. 所以 程序中原本就是存在地址的.
-
程序在没有运行、没有被加载到内存中的时候, 程序内部是否存在类似进程地址空间里设置的区域?
程序内, 其实也是存在区域的. 在Linux系统中, 可以很简单的观察到:
readelf 指令可以用来查看文件的某些信息
那么程序内部的这些地址、区域有什么作用呢?
程序没有被加载到内存中时, 程序内部的地址和区域是按照一定的相对关系划分的. 就像上图那样, 当前区域编号是什么, 此区域的名字是什么是存储关于什么内容的, 类型是什么, 区域地址是什么, 与首地址相比偏移量是多少, 区域大小是多少
知道了这些数据, 就可以知道当前的区域的的大小, 存储的内容, 区域的地址等信息:

此图就为readelf打印出的信息所列的表格, 虽然不够详细但是已经可以说明了此程序内数据的区域、地址信息等
其实将readelf打印出的信息, 排版整理之后可以得到程序本身的一个地址区域表, 就像一个地址空间一样, 包含程序数据分布的各种信息
这张记录了程序本身数据分布地址的表格, 是从0000地址开始的. 其实编译器在编译链接源文件时, 会默认认为程序就是按照0000~FFFF(全0~全F)
编址的
当程序运行时, 操作系统会根据程序本身的这个地址区域表 将程序的数据加载到内存中.
在操作系统根据程序的地址区域表, 将程序的数据加载到内存的这个过程中, 程序会认为其加载的这块内存也是从全0开始~全F结束的:

此时就操作系统根据程序中的数据地址和实际内存地址计算出了相应的虚拟地址
(只是可能, 具体算法要看操作系统), 直到程序中所有数据全部加载到内存中, 操作系统创建进程地址空间, 同时根据虚拟地址和实际地址创建页表:

进程地址空间与也变创建完毕之后, CPU向内存访问数据的时候遇到的地址就是经过计算的虚拟地址
, 如果修改了数据, 进程地址空间就会通过页表找到实际的内存物理地址将内存物理地址中的数据进行修改, 完成一系列的数据访问.
程序本身拥有的其数据即代码的相对分布地址, 在程序加载到内存中变为进程的过程中起到了非常至关重要的作用
所以,
虚拟地址空间不仅仅操作空间会考虑, 其实编译器也是需要考虑的, 因为编译器需要创建的是程序自身的程序地址空间
为什么要存在进程地址空间
我们知道了C/C++中使用的地址、存储变量的地址并不是实际的内存物理地址, 知道了操作系统会针对每一个进程维护一个进程地址空间, 也知道了虚拟地址与实际的内存物理地址存在映射关系. 而这种种现象, 好像透露出一件事情:操作系统在限制进程直接访问物理地址
首先要了解一个关于硬件的常识:硬件本身是不会限制软件访问的!硬件的地址只能被动的被读取和写入
也就是说, 如果操作系统不对进程访问物理地址进行限制, 那么进程很有可能访问到其他进程已经占用的地址, 或者访问到硬件本身数据的地址
, 这是非常可怕的!一个不小心硬件就出问题了.
也就是说, 为了系统的安全以及硬件的安全, 所以才会存在进程地址空间
而且, 进程地址空间对每个进程来讲其实是非常有好处的. 存在进程地址空间, 也就将各个进程之间隔离开了, 各个进程之间无法互相访问, 互相影响
。 当进程之间不主动互相影响, 也不会互相影响的时候, 程序设计时就不需要考虑更多的东西, 可以使各种程序以一种统一的视角认识内存, 可以方便编译运行加载
。也保证了进程间的安全
就像上面父子进程的例子, 如果子进程修改global_Var时其实修改的是物理地址的数据, 那么势必会影响到父进程的数据, 显然这对两个进程来说是不合理的, 也是很危险的
还有就是, 操作系统针对每个进程都会创建一个虽然内容不同但是结构相同的进程地址空间, 也就是说对操作系统来说 每个进程的数据、代码包括各种属性虽然内容不同, 但是其结构是相同的(即操作系统针对每个进程维护的task_struct(PCB) 和 mm_struct(进程地址空间))
. 每个程序的结构都相同, 这对操作系统来说是非常方便管理的.
总的来讲, 进程地址空间的存在大致会带来三个方面的好处:
- 保护硬件, 可以为系统及硬件提供更安全的进程服务
- 保护进程, 以及方便编译器编译及操作系统加载
- 方便操作系统管理、维护每个进程
近期,DeepSeek的火爆引发了对AI编程领域的讨论。在此背景下,我阅读了一篇题为《低代码已死?试看国产AI编程工具Trae如何破局》的微信公众号推文,我曾在就职的公司落地低代码平台,并出版了《低代码平台开发与实践:React》。在 AI 编程的热潮下,本文是我对低代码的思考。
低代码平台的核心是高效开发,其次是低门槛,通过简化开发流程和抽象技术细节来实现高效开发和低门槛,具体的措施是:
- 可视化构建:将开发过程从手动编码升维为配置与组合
- 组件化复用:将复杂系统拆解为可复用的单元,组件化依赖的封装性和接口标准化
- 自动化部署:致力于“减少人工干预”,其本质是“代码到生产环境的自动化迁移”
国内低代码平台(包括商业服务商和开源项目)在宣传中更侧重可视化构建和组件化复用,而对自动化部署的强调相对较弱。这一现象背后有多层原因:
- 可视化能力、组件库和行业模板是吸引非开发者用户的核心。投入大量资源优化拖拽体验、丰富组件生态,可以快速占领市场。
- 自动化部署的技术复杂性高。
- 国内大部分低代码用户是业务部门(如HR、财务),而非技术团队。相比自动化部署,他们更关心自己能否动手开发,而非自动化部署。
对低代码可视化构建的一个常见误区是过度强调拖拽功能。但实际上,拖拽只是低代码平台的一种输入方式,当前正向更自然、更灵活的方向演进。目前,低代码平台的输入方式有:
- 拖拽式交互
- 代码嵌入
- 声明式配置
当前正在演进,未来可能出现的方式:
- 自然语言描述
- 语音与手势
- 脑机接口
技术革新会使交互方式发生剧烈变化,但组件化复用和自动化部署作为低代码平台的核心支柱,其内核逻辑和技术价值并未改变。原因如下:
- 组件化复用的本质是将复杂系统拆解为可复用的单元,组件化依赖的封装性和接口标准化,未发生根本变化,因此交互方式的革新不会动摇其根基。
- 自动化部署始终致力于减少人工干预,其本质是代码到生产环境的自动化迁移。CI/CD流水线、基础设施即代码等核心理念未变,因此交互方式的变化不会颠覆其逻辑。
立足于当下,AI 对低代码的影响主要是交互方式,具体上来说是扩充交互方式。开发过低代码的人,在 AI 编程的热潮下,可能会问 AI 编程是否会替代低代码,我觉得不会,原因如下:
-
用户群体不同:AI 编程对用户的编程能力要求更高,低代码的用户主要是产品经理和业务人员,AI编程用户主要是程序员和技术极客。
-
能力范围不同:低代码平台涵盖开发→测试→部署→监控的全流程,内置版本控制和一键回滚,而 AI 编程主要聚焦于代码生成环节。
-
业务分级处理能力:AI 编程通常由个人独立完成任务,而低代码支持分工协作,有助于节省高级人才的时间。简单来说,AI 编程和传统编程一样,都是一个人从头到尾做一道菜,而低代码像流水线分工,不同的人负责不同的步骤。
-
统一性优势:程序员普遍面临一个难题:接手别人的代码就像拆盲盒。你打开别人写的代码文件:
- 左边是密密麻麻的代码结构
- 右边是他的代码风格。
因此程序员不愿意接手别人的代码。而低代码平台就像给代码装上了说明书:
- 代码结构可视化展示
- 平台强制规定所有组件的拼接方式,避免风格混乱。
目前,多数低代码平台依赖预制组件来加速开发,这容易导致应用同质化,这种模板化的解决方案容易使产品缺乏差异化,如果生成式 AI 可以动态生成更符合具体业务需求的组件,从而提高个性化和灵活性,那么这会为低代码平台带来质的飞跃。
评论记录:
回复评论: