进程虚拟地址空间区域划分
我们在启动一个进程的时候, 操作系统会进行资源分配. 接下来以 C++ 作为载体稍微对这块内容进行说明罢.
环境说明
编译环境说明 ——
Environment: Ubuntu 26.04 LTS / aarch64 / Ubuntu clang version 21.1.8 (6ubuntu1) / C++23
Build: clang++ -std=c++23 -O0 -g -Wall -Wextra引入
众所周知, 一个程序它的不同“内容”会被分在分配的内存空间中的不同部分, 下面简单地对其进行说明 ——
#include <iostream>
#include <unistd.h>
int globalValue = 10;
void foo() { }
int main()
{
int localValue = 20;
int* dynamicValue = new int(30);
std::cout << "PID : " << getpid() << '\n';
std::cout << "code : "
<< reinterpret_cast<void*>(&foo)
<< '\n';
std::cout << "global : "
<< static_cast<void*>(&globalValue)
<< '\n';
std::cout << "local : "
<< static_cast<void*>(&localValue)
<< '\n';
std::cout << "heap : "
<< static_cast<void*>(dynamicValue)
<< '\n';
std::cin.get();
delete dynamicValue;
}运行结果 ——
PID : 26876
code : 0xb24103720b68
global : 0xb24103740070
local : 0xffffe3129cd8
heap : 0xb24135241020我们可以看出一个特点, code 和 global 存储的位置比较近, 然后 local 在一个区域, heap 又是另一块区域.
text 和 data
我们的 foo 会被翻译成机器指令, 一般是可执行但不可写(如果一块既可以执行又可以外部写入的话, 想必十分甚至九分的不安全), 而 globalValue 存储的区域应当是可读可写的. 我们会发现此处系统需要的权限已经出现了分歧. 不妨在程序持续运行的时候, 进行一个验证, 查看下 virtual memory mapping
cat /proc/<PID>/maps我们可以对照一下, 得到 code 和 global 所出的区域分别是
b24103720000-b24103721000 r-xp 00000000 ...
b24103740000-b24103741000 rw-p 00010000 ...额外先说一下权限
r = readable
w = writable
x = executable
p = private综上所述, 我们可以得出程序内存中进行区域划分的一个意义 —— 表达不同内容的访问权限.
实际上这两个位置就是常说的 .data 数据段 和 .text 代码段. ——
简单来说, .text 通常用于保存机器码, 而 .data 通常保存已经经过初始化, 可读可写的静态数据.(不过类似 const int a = 10; 这种可能会被存储到 .rodata 之类的地方, 后续有机会拓展)
用汇编可能会更容易识别.
clang++ -std=c++23 -O0 -fno-pie -S main.cpp -o main.s然后查看生成的汇编文件
.text
.globl _Z3foov
.p2align 2
.type _Z3foov,@function
_Z3foov:
ret说明 foo 方法在 .text
.type globalValue,@object
.data
.globl globalValue
.p2align 2, 0x0
globalValue:
.word 10
.size globalValue, 4说明 globalValue 在 .data 区域, 并且被初始化成 10.
stack
int localValue = 20; 此变量被我们声明在一个函数体内, 我们都知道它会在进入函数体内的时候被创建, 然后在函数退出的时候自动销毁. 不过此处我们需要知道, 变量的释放确乎是与其在函数体内的声明顺序有关(也是先声明的后被释放), 不过对象析构和栈空间的回收是两件事情. 需要分开看.
它位于的位置也非常容易证明 ——
cat /proc/26876/maps | grep stack得到
ffffe310b000-ffffe312c000 rw-p 00000000 00:00 0 [stack]结合上文, 我们可以判断 localValue 变量存储的位置落于 stack 区.
我们查看汇编, 看看它的具体行为是什么 ——
在进入 main 的时候
sub sp, sp, #80
stp x29, x30, [sp, #64]
add x29, sp, #64先借助 sub 来给当前 main 预留 80 字节的空间作为栈帧,
- 在
sp + 64写入x29的值, 存储进入 main 之前调用者的 Frame Pointer, 也就是记录 “从何而来” —— 后续在 frame chain / stack unwinding 的时候就很方便. - 在
sp + 64 + 8写入x30的值, 作为返回地址, Link Register
这也比较巧妙 —— 借助 80 = 64 + 16 = 64 + 8 + 8 所以实际给局部变量等的空间是 64 字节, 而上头为什么是 sp + 64 也非常好理解了, 这样让旧的 x29 和 x30 的值刚好存在高地址, 靠近原先 sp 的位置, 这样之后的变量也非常好的往低地址来进行存储
接着借助 add x29, sp, #64, 让 x29 成为当前 main 函数的 Frame Pointer ——
而且有意思的, 我们会发现是使用 sub 做减法操作来指向存储空间
mov w8, #20
stur w8, [x29, #-8]此处可以看到, 在当前的区域中, 我们先把 20 存储 w8, 接着再把寄存器 w8 里的 32 位值,存到内存地址 x29 - 8 的位置, 翻译成“人话” —— 在当前的栈帧中, 把相对 frame pointer 向低地址偏移 8 个字节的位置下写入 w8 的数据. 所以可以看出, 实际上局部变量只是栈区中的一个 offset 而已, 非常的高效.
然后释放的时候也非常方便快速 —— 我们直接移动栈顶指针
add sp, sp, #80让这一块不再属于当前栈帧即可, 并不需要真的抹除什么数据, 这也是我们常说栈区开辟/释放非常高效的底层原因.
heap
上文说到栈区域中的变量和当前栈帧的生命周期绑定, 所以我们常说的堆区是否可以看成是 —— 存储无法和某一次函数调用绑定生命周期的变量的地方呢? (感觉可以是一种理解, 不过对于大型对象等的旧理解也要保留)
在 int* dynamicValue = new int(30); 中, 我们需要注意两个地方 —— 一个是 dynamicValue 本身是一个栈区变量, 生命周期和当前的栈帧绑定. 但是后面的 new int 是一块动态申请的空间, 生命周期不和当前栈帧绑定, 需要手动调用 delete 进行释放.
我们继续查看一下汇编 ——
先进行空间的申请
mov x0, #4
bl _Znwm这样, x0 就类似于一个指向 heap 区域上某块内存的地址.
然后进行数据的写入
mov w8, #30
str w8, [x0]最后再把这个地址返回给栈帧上的局部变量
stur x0, [x29, #-16]那么此时其实我们可以再得出另一个结论 —— 进程虚拟地址空间区域划分可以用于不同的生命周期管理
综上
其实各个段自身的行为都非常的有趣, 比如栈区从高地址向低地址, 函数调用是怎么实现的? 接着为什么堆区是从低地址到高地址写数据? 这是谁规定的? 这是一定的吗? 空间还会被划分成其他块吗? 等等, 害很多可以深究的地方, 都埋上钩子.
此篇只是空间区域划分的基础引入 —— 对于不同语义的数据, 它的访问权限 / 生命周期不同, 则可能需要不同的存储和管理方式, 因此会在进程中虚拟地址空间的不同区域进行划分.
(以及上述很多行为仅仅是因为没有经过编译器优化等的, 所以在实际执行的时候可能还不太一样)