程序地址空间(基于c++和linxu的一些个人笔记)
我们之前讲C语言的时候,老师给大家画过这样的空间布局图:
┌─────────────────┐ 高地址
│ 内核空间 1G │
├─────────────────┤
│ 栈 │ ↓ 向下增长
├─────────────────┤
│ ↓ │
│ │
│ ↑ │
├─────────────────┤
│ 共享库/mmap │
├─────────────────┤
│ 堆 │ ↑ 向上增长
├─────────────────┤
│ 未初始化数据BSS │
├─────────────────┤
│ 初始化数据段 │
├─────────────────┤
│ 代码段 │
└─────────────────┘ 低地址(用户空间 3G)
可是我们对他并不理解!可以先对其进行各区域验证:
为什么字符串常量是只读的
在 C/C++ 中,字符串字面量(如 "hello")存储在只读内存区域,尝试修改会导致段错误。
原因:
- 编译器优化:相同字符串只存一份,节省内存
- 安全考虑:防止意外修改导致程序崩溃
存储位置: 通常放在代码段(.text 或 .rodata),这些段是只读的
c
#include <stdio.h>
#include <string.h>
int main() {
// 下面是错误的
char *str1 = "hello";
str1[0] = 'J'; // 崩溃!字符串常量不可修改
printf("%s\n", str1);
// 方法:拷贝到可修改内存
char *str2 = strdup("hello");
str2[0] = 'J'; // 可以!
printf("%s\n", str2); // 输出 "Jello"
free(str2);
return 0;
}
C/C++ 变量存储位置表
| 内存区域 | 存储内容 | 示例 | 特点 |
|---|---|---|---|
| 内核空间 | 系统调用、内核数据 | - | 用户态不可直接访问 |
| 命令行/环境变量 | 程序参数、环境变量 | argc, argv, environ |
程序启动时传入 |
| 栈区 | 局部变量函数参数返回地址 | int x = 10; void func(int a) |
自动分配释放,向下增长(LIFO) |
| 共享库 | 动态链接库的代码和共享内存 | mmap(), .so文件 |
多进程可共享 |
| 堆区 | 动态分配的内存 | malloc(), new |
手动管理,向上增长,容易碎片化 |
| BSS段 | 未初始化全局变量和初始化为0的静态变量 | int global; static char buf[1024]; |
程序加载时自动清零 |
| 数据段 | 已初始化全局变量和静态变量(非零值) | int count = 10; static int x = 5; |
占用可执行文件空间 |
| 代码段 | 可执行代码和字符串常量 | int main() {...}, "MAX = 100" |
只读可执行 |
| 变量类型 | 声明方式 | 存储位置 | 生命周期 | 可见性 |
|---|---|---|---|---|
| 全局变量 | static int x = 10;(函数外) |
数据段(已初始化)或 BSS(未初始化) | 整个程序运行期间 | 当前文件内 |
| 静态局部变量 | static int y = 20;(函数内) |
数据段或 BSS | 整个程序运行期间 | 仅函数内 |
| 普通全局变量 | int z = 30;(函数外) |
数据段或 BSS | 整个程序运行期间 | 全局可见(可用extern跨文件访问) |
| 局部变量 | int a;(函数内) |
栈 | 函数调用期间 | 仅函数内 |
关键点:
- 静态变量(无论局部还是全局)都存储在相同的内存区域(数据段或BSS)
- 区别在于可见性(作用域),而不是存储位置
- 静态局部变量只在函数内可见,但生命周期是整个程序
那程序地址空间真的存在吗?
不是内存,应该叫做虚拟地址空间。其地址是虚拟地址,不是真正的物理地址。
怎么证明: 父子进程修改一个变量有写时拷贝,结果两个的数据内容不一样但是地址一样,如果这是物理内存的话,明显不符合。所以只能是虚拟的。但是它具体是什么都不知道,C/C++ 里用到的都是虚拟地址,不是实际的内存地址。
关键解释
虚拟地址空间: 每个进程都有自己的虚拟地址空间(如32位系统下的4GB空间)
这是操作系统提供的抽象:
- 进程"看到"的是连续的地址空间
- 实际上这些地址被映射到物理内存的不同位置,甚至可能部分在磁盘上(页面交换)
- 不是物理排布:物理内存不可能为每个进程都分配完整的地址空间
- 例如:多个4GB进程不可能同时完全装入2GB物理内存
操作系统使用分页/分段技术,按需将虚拟地址映射到物理内存。
概念 vs 语言: 地址空间是计算机科学的概念,不是编程语言特有的概念。它是操作系统和硬件协作提供的抽象层。
虚拟地址与进程地址空间
大富翁例子
虚拟地址空间相当于大饼:每个进程都以为自己有4GB的物理内存,每个进程都以为自己在独占。
那操作系统给进程画了大饼,也要把大饼管理起来,就又到了先描述、再组织:
- struct饼:时间,内容......;然后组织起来
所以为了能把虚拟地址空间管理起来,虚拟地址空间就是一个数据结构 ,在Linux里叫 struct mm_struct。
所以在我们创建进程的时候,要给进程画一个 mm_struct 这个虚拟地址(大饼)。task_struct 也是结构体变量,里面有指针指向对方。
虚拟地址空间怎么实现?
我们看起来有代码区,堆,栈......
它是一个从0到全的一共2的32次方个地址;同时它也划分了很多区域。
那这些区域要怎么理解?小女孩在桌子上画线本质就是区域划分,那怎么用计算机量化这个区域划分?
小女孩区域分化,用计算机量化一下!
c
struct Desktop {
int zs_start; // 张三区域开始
int zs_end; // 张三区域结束
int ls_start; // 李四区域开始
int ls_end; // 李四区域结束
};
struct Desktop area = {0, 49, 50, 99};
所以区域划分只需要定好区域的开始和结束就可以; 就上面的每一个刻度就是地址,比如0处,1处等等;虽然地址空间没说明地址,但是只要知道可以使用的区域就可以。
桌子就是地址空间,宽度是2的32次方,刻度是地址空间上的地址,一张桌子上的小朋友先不管,每一个小朋友都有自己的区域。所以结构体大饼要有什么属性?
本质:是一个数据结构!!
c
struct mm_struct {
long code_start; // 代码段开始
long code_end; // 代码段结束
long init_start; // 初始化数据开始
long init_end; // 初始化数据结束
long uninit_start; // 未初始化数据开始
long uninit_end; // 未初始化数据结束
long heap_start; // 堆开始
long heap_end; // 堆结束
long stack_start; // 栈开始
long stack_end; // 栈结束
// ......
pgd_t *pgd; // 页表根指针
struct vm_area_struct *mmap; // VMA链表
};
就是每一个区域的开始虚拟地址和结束虚拟地址;调整区域就是修改开始和结束。
所以地址空间本质上就是一个数据结构,PCB里有一个指针会指向自己进程的地址空间。
它这个空间是可变的,比如你写的代码占100字节,那它就开辟一百字节的代码区就可以,与此同时,物理空间也开辟同样大小的,然后通过页表映射;而怎么改变区域大小,就是调整区域划分。
mm_struct 的初始化
1. 开辟空间
2. 初始化的值从哪里来?
地址空间是个对象,但是对象要被初始化,那它怎么初始化?相当一部分就是程序加载到内存的时候,在加载的时候来的。
程序在磁盘上是 ELF 格式的可执行文件,里面已经记录了:
- 代码段多大、从哪开始
- 数据段多大、从哪开始
- 入口地址是什么
加载程序时,操作系统读取这些信息,填到 mm_struct 里。
一个进程一个虚拟地址空间,一个进程一套页表
虚拟地址空间对应的宽度是1字节;32位下有2的32次方个地址就是4GB,64位下有2的64次方个地址,宽度是1字节嘛,就这样乘。
用户空间3GB,然后内核空间1GB。
页表的作用
在这里说一个情况,你在程序里定义一个全局变量,然后在内存(物理地址)上就有了这个变量,在地址空间上也要对应有一个全局变量,这个全局变量的虚拟起始地址;与此同时,每个进程创建的时候要构建一个页表,一个进程一个地址空间,一个进程一个页表,页表的作用是:
页表
┌──────────┬──────────┐
│ 0x111111 │ 0x112233 │
├──────────┼──────────┤
│ ... │ ... │
└──────────┴──────────┘
它就长这样,然后左侧是变量的虚拟地址,右侧是变量的物理地址;当我们进程要访问虚拟地址,操作系统就查表来找到内存地址来访问,所以页表是做内存映射的。
我们在语言层用到的都是虚拟地址,我的代码里每一个变量都有地址,包括代码本身都有地址,都是虚拟地址,然后通过映射找到内存地址。
地址的理解
但是刚刚我们知道,程序地址空间的宽度是1字节,那四个字节代表是有四个地址,那么我们得到的是哪个地址,是四个地址里最小的地址;那一个地址只能访问一个字节,所以有了类型,所以有了起始地址加偏移量就可以访问到完整地址。
fork 与写时拷贝
子进程的很多东西都是包括 task_struct 拷贝父进程的,然后改一改;子进程也要有自己的虚拟地址空间;也有自己对应的页表,因为子进程有自己的代码;那子进程的页表和虚拟地址空间都拷贝父进程。
但是页表拷贝就是浅拷贝了,所以为什么打印的地址是一样的,而且为什么全局变量被父子共享;代码也共享了;
要是子进程要修改全局变量,操作系统就会在内存开辟一个新空间然后把变量复制过去,子进程的映射也改变到新创建的变量,就可以看起来是一个地址(虚拟地址)但是值不同,因为真实的物理地址不一样;这就是写时拷贝。
与此同时用户只能看到虚拟地址看不到物理地址。
那为什么要有虚拟地址空间?
1. 将地址从无序变有序
物理内存可能是碎片化的、散落各处的。虚拟地址空间让每个进程看到的是连续、统一的地址布局。
2. 权限保护
页表当中还有权限,如果你要对一个代码区写入但是没权限,去查页表,操作系统就不会让你完成,就对代码进行保护。即地址转化过程中,可以对你的地址和操作进行合法性判定。
几个问题:
a. 什么是野指针
c
char *str = "helloworld";
*str = 'H'; // 崩溃!
a. 野指针其实就是已经被释放的空间你去访问,然后查页表要去访问,结果操作系统为了保护代码,可能会直接把你的进程挂掉,导致程序崩溃;不一定程序奔溃,做个了解。
b. 这个叫字符串常量,是不可被修改的;那会发生什么,可以编译过,运行的时候崩掉了,字符串在字符常量区,它的权限是只读,没权限,所以页表转换失败。
3. 缺页中断(按需加载)
还没讲,大概意思就是物理空间如果需要的很大,可以先加载一点然后映射到页表,用的时候动态加载。
不需要一次性把整个程序加载到内存,用到哪页加载哪页,节省内存。
4. 让进程管理和内存管理进行一定程度的解耦合
进程只管虚拟地址,内存管理只管物理页的分配回收,两边通过页表连接,互不干扰。
澄清一些问题!!!
1. 我么可以不加载对应的代码和数据,只有 task_struct,mm_struct...,页表;
可以。创建进程时先建好"骨架",代码和数据可以按需加载(Demand Paging)。
2. 创建进程,先有 PCB 和 mm_struct...才会有代码;
对。顺序是:分配 task_struct → 分配 mm_struct → 建立页表 → 映射代码段/数据段 → 按需加载。
3. 如何理解进程挂起?
进程挂起就是先找到对应的进程,把页表标记为"不在内存",把物理页内容换出到磁盘(swap区)。这个时候虚拟地址还在,mm_struct 还在,只是物理页暂时不在内存里。恢复时再换回来。
堆区不止一个地址
堆区不止一个吧?不止一个虚拟地址吧?因为我需要不断地去 new 啊,然后它每次之间都是离散的呀?
没事的,mm_struct 里维护了一个 vm_area_struct 链表,有指向下一个节点的指针,然后分很多堆区也没事,一份堆区对应一个 vm_area_struct,vm_area_struct 里有这个堆区的开始和结束地址。
c
struct vm_area_struct {
unsigned long vm_start; // 区域起始地址
unsigned long vm_end; // 区域结束地址
struct vm_area_struct *vm_next; // 下一个VMA
unsigned long vm_flags; // 权限标志
// ...
};
就是一个地址区域相当于一个节点,整个是一个链表,然后节点里有指向下一个节点的指针。
mm_struct
└── mmap ──► [VMA: 代码段] ──► [VMA: 数据段] ──► [VMA: 堆] ──► [VMA: 栈] ──► NULL
总结
-
虚拟地址空间是操作系统给进程制造的"幻觉",让每个进程以为自己独占一大片连续内存。
-
内核用
mm_struct描述虚拟地址空间,记录各区域的起止地址。 -
用
vm_area_struct链表管理各个区域(代码段、数据段、堆、栈......)。 -
页表负责把虚拟地址翻译成物理地址,同时做权限检查。
-
这套机制实现了:
- 进程隔离(互不干扰)
- 简化编程(不用关心物理地址)
- 灵活分配(虚拟连续,物理可以不连续)
- 写时拷贝(fork 高效)
- 权限保护(防止非法访问)
- 按需加载(节省内存)