Linux操作系统之进程(五):初识地址空间
目录
前言:
观察现象:
虚拟内存与页表映射机制
各种申请开始了
把它映射到操作系统
更深入理解
mm_struct结构体
页表与写时拷贝
R/W/X(可读、可写、可执行)
isExist :
补充问题:
1、变量名、地址和解引用的本质
2、重新理解进程和进程的独立性
前言:
在上一篇文章命令行参数与环境变量中,我们深入探讨了 命令行参数与环境变量 的作用和机制,了解到它们是进程启动时传递信息的重要手段,也是用户与程序交互的基础方式。然而,这些信息最终都要“落地”到内存中,供进程访问与使用。那么问题来了:
程序中的所有数据、代码、参数,它们在内存中是如何组织和存放的?
为什么同一个变量在父子进程中地址看似相同,却可能指向不同的内存?
操作系统又是如何保障不同进程间互不干扰、独立运行的?
为了解答这些问题,我们就必须进一步深入理解进程的地址空间结构。本篇文章将承接之前的内容,从命令行参数、环境变量所依赖的内存布局出发,正式走进虚拟地址空间的世界。
观察现象:
我们现在有以下代码:
process.cpp:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include <sys/types.h>int gval=100;int main()
{printf("我是一个进程,我的pid是:%d ,我的ppid是:%d\n",getpid(),getppid());pid_t id=fork();if(id==0){while(1){printf("我是一个子进程,我的pid是:%d ,我的ppid是:%d,gval:%d,&gval:%p\n",getpid(),getppid(),gval,&gval);sleep(1);}}else{while(1){printf("我是一个父进程,我的pid是:%d ,我的ppid是:%d,gval:%d,&gval:%p\n",getpid(),getppid(),gval,&gval);gval++;sleep(1);}}return 0;
}
Makefile:
# 定义编译器和编译选项
CXX = g++
CXXFLAGS = -Wall -std=c++11# 定义目标文件和可执行文件名
TARGET = process
SRC = process.cpp# 默认目标
all: $(TARGET)# 直接生成可执行文件(不生成.o文件)
$(TARGET): $(SRC)$(CXX) $(CXXFLAGS) -o $@ $<# 清理生成的文件
clean:rm -f $(TARGET)# 运行程序
run: $(TARGET)./$(TARGET).PHONY: all clean run
当你看过我之前的博客,你应该就知道,我的process.cpp的代码主要是定义了一个全局变量gval,随后用fork创建了一个子进程,让这个子进程不断循环并打印全局变量和它的地址,而父进程也在循环打印全局变量的值与地址,但是每次循环后会使gval++
运行代码,我们可以看到以下结果:
我们可以观察到一个现象,二者的gval地址明明都一样,但是为什么,二者的gval的数据却不相同呢?
变量内容不⼀样,所以父子进程输出的变量绝对不是同⼀个变量但地址值是⼀样的,说明,该地址绝对不是物理地址!在Linux地址下,这种地址叫做虚拟地址我们在⽤C/C++语言所看到的地址,全部都是虚拟地址!而物理地址,用户层面是⼀概看不到的,由OS统⼀管理
虚拟内存与页表映射机制
之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?
为了让大家更好地理解什么是虚拟地址空间,我们先来举个轻松的例子:
有一个大富翁,他的家产据说有十亿。但他有一个奇怪的爱好,就是悄悄地生了很多私生子——甲、乙、丙、丁、戊……这些私生子互相之间并不认识,也不知道彼此的存在。
大富翁跟每个私生子都说了一样的话:
“孩子,我老了,这十亿的家产将来都是你的。你想要用钱时,尽管来找我。”
每个私生子听了当然很感动,心想:“太好了!我爸这么有钱,我想干啥干啥。”
各种申请开始了
一天,孩子们陆续来找大富翁要钱:
-
甲说:“我想买辆车,要几十万。”
→ 大富翁一听,这不多,给了。 -
乙说:“我想每个月领几万生活费。”
→ 大富翁觉得合理,也同意了。 -
丙说:“我要你全部的十亿,现在给我!”
→ 大富翁拒绝了,说:“你现在根本用不到那么多,等你用到的时候再说。” -
丁说:“我要一百亿!”
→ 大富翁皱眉:“孩子,你要的比我全部家产还多,不可能。” 然后果断拒绝了他的请求。
把它映射到操作系统
你可能已经猜到了,这个故事,其实就是我们操作系统分配内存的一个拟人化比喻:
现实 | 比喻 | 实际含义 |
---|---|---|
操作系统 | 大富翁 | 掌控全部物理内存 |
进程 | 私生子 | 互不干扰,独立运行 |
虚拟地址空间 | “大饼”或“十亿遗产” | 每个进程看到的是完整的地址空间 |
实际分配内存 | 实际发钱 | 只有用到时,系统才分配 |
访问非法地址 | 要了一百亿 | 超出限制,系统直接拒绝或终止程序 |
写时拷贝 | 多个子进程共享一个饼 | 初期共享,写时才独立分配 |
更深入理解
操作系统会在每个进程启动时,给它一个完整的虚拟地址空间,比如在 32 位系统中,就是最多 4GB。虽然每个进程都以为自己独占这 4GB,但实际上这些只是虚拟的,系统并不会一开始就真的分配这么多内存。操作系统给每个进程画了一个大饼,叫做虚拟地址空间,让每个进程都认为自己独占系统物理内存大小,进程彼此之间互不知道,不关心对方的存在,从而实现一定程度上的隔离
当进程真正使用某段地址时,才会触发所谓的缺页中断(我们后后面才会讲到,这里只需要知道有这个东西),此时操作系统才会从物理内存里“划出一块”来分配给它,就像大富翁看到孩子真的需要,才掏钱一样。
如果你申请的内存过多(比如你在代码里无限分配内存),操作系统就会拒绝你,就像大富翁拒绝了一百亿的请求,有可能还直接把你“扫地出门”(kill 掉进程)。
操作系统就像一个深藏不露的大富翁,它告诉每个进程:“我全部的内存都是你的”,给你画了一张大饼(虚拟地址空间)。但它并不会真的一开始就把所有内存给你,而是根据你的申请,按需分配。这样既能给每个进程看似无限的自由,又能合理利用有限的资源。
这个机制背后,正是现代操作系统中最核心的内存管理技术之一:虚拟内存与页表映射机制。
mm_struct结构体
说到这里,聪明的你可能会问:
“操作系统是怎么知道每个进程要了哪些钱(内存),用了多少,还剩多少的?”
还是那句话:“先描述,再组织”。
所谓的进程虚拟地址空间本质上是一个内核数据结构对象 mm_struct。
我们之前已经知道了PCB(task_struct
)的存在,而 mm_struct
是被 task_struct
持有的一个子结构体,用来专门描述进程的虚拟地址空间布局。
:
struct task_struct
{...struct mm_struct *mm; // 当前进程的内存描述符(普通进程)struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。...
};
在 Linux 内核中,每个进程的虚拟地址空间都是由一个 mm_struct
结构来描述和管理的,它就像是大富翁的账本,详细记录着每个私生子(进程)“以为自己拥有的所有钱”的分布和使用情况。
mm_struct
是什么?
它是内核用来描述进程内存布局的核心结构体,记录了整个进程的代码段、数据段、堆、栈、共享内存等在虚拟地址空间中的分布情况。简化理解如下:
struct mm_struct
{unsigned long start_code; // 代码段起始地址unsigned long end_code; // 代码段结束地址unsigned long start_data; // 数据段起始地址unsigned long end_data; // 数据段结束地址unsigned long start_brk; // 堆起始地址unsigned long brk; // 当前堆顶地址unsigned long start_stack; // 栈起始地址(高地址)struct vm_area_struct *mmap; // 指向一系列虚拟内存区域(VMA)pgd_t *pgd; // 页目录表指针...
};
每当进程申请内存(比如通过 malloc
或 brk()
),这些信息就会动态地记录在 mm_struct
中。
为什么地址可以用 unsigned long
表示?
地址,本质上就是个数字
从硬件角度看,地址就是内存单元的编号。比如第一个字节是地址 0,第二个是地址 1……这和你在超市货架上按编号排商品一样。
-
所以地址 = 数字。
-
而程序中最自然的数字类型就是整数。
-
而
unsigned long
是其中最适合用于表示地址的类型之一。
我们已经知道,每个进程有一张“虚拟地址空间饼图”,那么这张图是怎么划分的呢?
以32位操作系统为例:
而我们通过这些数字,就可以进行区域划分,类似于不同的定义域:[0,10],[10,100],[100,1000],我们就通过数字把0-1000划分为了三个区域。
为什么我们需要 mm_struct
?
内核管理每个进程的虚拟地址空间时的依据;
支撑页面调度、换页、缺页中断等虚拟内存机制;
是调试、性能分析、内存泄漏排查的关键结构之一。
就像每个私生子都拿着一个“梦想的遗产饼图”,mm_struct
就是这张饼图在内核中的真实体现。
有了 mm_struct
,操作系统就能井井有条地管理每个进程的“虚拟家产”,不仅知道你想要多少、用过多少、还剩多少,也知道你有没有越界、违规使用别人的空间。
而mm_struct是一个结构体变量,那么他一定会被初始化。那么,他的初始化的数据有事从哪些地方来得呢?
答案是:
-
从可执行文件中解析的信息(如代码段、数据段的地址和权限)
-
由操作系统动态管理的部分(如堆、栈、内存映射等)
同时,跟画大饼类似的时,在mm_struct初始化时,部分区域的真实分配情况完全取决于你的代码是否显式或隐式地使用了堆内存分配函数。
如果你的程序从未使用过堆的空间,那么这个进程就根本不会申请实际的物理内存:
如
#include <unistd.h>
int main()
{sleep(1000); // 保持进程运行,方便观察return 0;
}
这种类似于写实拷贝的机制有多疯狂,他甚至,就算你使用了malloc,new等函数,它也只会调整 brk指针(记录堆顶的虚拟地址)来扩大或收缩堆的可用空间。注意,这个是数值上的调整,但实际还是没有分配哦!只有当你真正用到了,才会真的申请,就跟期末的你一样,不到期末不学习啊
页表与写时拷贝
加载进程时,除了PCB,虚拟地址、物理地址也会被加载。此时会有一个叫做页表的东西,负责从虚拟地址到物理地址的映射。
例如:
虚拟地址 物理地址
0x1111 0x1234
我们用例代码的gval是一个全局变量, 存放在数据段中(.data)
当 fork()
被调用时,操作系统会:
-
复制一份父进程的页表给子进程(注意:只是页表的复制,不是内存的深拷贝)。
-
父子进程的
task_struct
(PCB)不同,但mm_struct
中的页表内容初始是一样的。 -
因为页表记录的是“虚拟地址 → 物理页”的映射关系,所以:
-
父子进程的
gval
虚拟地址是一样的; -
一开始它们映射到同一个物理页(即物理地址一模一样都是0x1234);
-
所以
&gval
打印出来的虚拟地址一样,内容(值)也一样。
-
但是,当我们的父进程想要对数据进行修改,就会触发写时拷贝机制,操作系统说:你先别急,让我先拷贝一份数据。
随后操作系统会在物理内存中随便找个地方拷贝一份数据,随后把父进程的物理地址修改(如果是子进程想要修改数据就把子进程的物理地址修改):
此时,父子进程的页表就会变为:
父进程:
虚拟地址 物理地址
0x1111 0x4321
子进程:
虚拟地址 物理地址
0x1111 0x1234
而以上过程,就被称为操作系统的写时拷贝机制。
这个过程在上层看来,二者%p打印的地址相同(因为虚拟地址不变,但物理地址是不同的哦~)
这也是为什么fork两个返回值的原因。不同子进程时在查时用的是不同的,属于他自己的页表进行映射的,页表不一样,映射到了不同的物理内存,所以同一个变量内容不一样。
实际上,页表并不只是简单的虚拟地址与物理地址的映射,他还有很多小标记位来记录很多重要的指标:
在 32 位系统中,每个页表项(PTE)是一个 32 位(4 字节) 的数据。操作系统会把它划分成两部分:
|----------- 高位 -----------|---- 低位 ----|
| 物理页框号(PFN) | 标志位 |
| 20 bit | 12 bit |
-
高 20 位:记录物理页框号(Physical Frame Number),指向一个物理页的起始地址(对齐 4KB);
-
低 12 位:用来记录各种控制标志位(flags)。
在C语言中,我们知道有这个现象:
#include<stdio.h>int main()
{char *str="hello world";*str='H';return 0;
}
这个代码在编译时是不会报错的,但是运行时就会出错。
我们根据以往的知识可以判断出str变量在栈区,“hello world”在常量区,在我们对常量区进行修改时,由于常量区只读,所以导致出错。而这个区域的权限(读写),在页表里也会有所记录 。
R/W/X(可读、可写、可执行)
-
如果某页被标记为“不可写”,而你尝试写入,就会触发页错误(page fault);
-
某些平台支持不可执行(No-Execute)页面,防止栈上代码被执行,是防止缓冲区溢出的重要安全手段。
知道rwx标记位的存在,我们也能解释好上述写时拷贝的过程,完整的过程应该是:
在父进程执行到 gval++
时:
-
它尝试写入这个页(包含
gval
的数据段)。 -
操作系统发现这个物理页是 共享页(只读权限 + 写时拷贝标记)。
-
触发缺页异常,内核执行 Copy-On-Write 操作:
-
给父进程重新分配一个新的物理页;
-
把原物理页的内容复制到新的物理页;
-
更新父进程的页表,将这个虚拟地址映射到新的物理页;
-
子进程页表不变,仍然映射到旧物理页;
-
父进程可以写,子进程仍然看到旧值。
-
结果:
-
子进程继续打印
gval = 100
; -
父进程每秒
gval++
,自己看到的值递增; -
&gval
的虚拟地址在两个进程中始终相同,但它们背后的物理地址已经不同了!
isExist
:
isexist作用:判断是否在内存空间中存在。映射的对应数据是否在内存中呢?如果在内存中,我们就直接转物理地址访问了。如果不在,(要么就是没有被加载,要么就是之前被切换出去了),现在要访问了,他会把唤入数据,把页表重新填充。
举个例子,假设你有一个4GB的文件,系统或者程序不会把这4GB一次性全部读进内存,这样很浪费资源也不现实。
-
你只访问了文件的前1GB数据,系统就只把对应的页加载进内存。
-
文件剩下的3GB对应的页还没有加载(对应
isExist == 0
)。 -
当你访问这3GB中某部分数据时,才会触发页缺失,系统才会把那部分数据从磁盘调入内存。
-
如果内存不足,系统还会将之前不常用的页换出(swap out)到磁盘,以腾出空间加载新的页。
这就是典型的虚拟内存分页机制
-
文件不是一次性加载,而是按需加载(demand paging)。
-
系统通过页表和页框管理哪些页在内存,哪些在磁盘。
-
isExist
可以看作一个标志,告诉操作系统“这页现在是否在物理内存中”。
即:
这个标记位
-
如果该位为 0:表示该页当前不在内存中,可能还在磁盘上(比如 swap 或尚未加载的 ELF 段);
-
此时访问这个页就会触发缺页中断,由操作系统来加载数据并更新页表;
-
这是实现按需加载、换页机制和大程序运行的基础。
而页表+虚拟地址空间,其实本质的作用就是为了保护内存。这也解释了野指针的危害,为什么野指针会导致程序崩溃呢?
因为野指针实际上指向的是虚拟地址,在使用的时候,可能会出现指向的那个地址权限不对,或者根本不存在对应映射,所以可能会杀掉进程 ,导致程序崩溃。
补充问题:
1、变量名、地址和解引用的本质
在 C 语言等高级语言中,变量名只是一个标签,本质就是内存地址的抽象符号。
-
编译后,变量名就没了,CPU 只认识“地址”;
-
所以:每个变量名最终会被映射成某个内存地址(虚拟地址)。
举个例子:
int a = 10;
这在底层大致对应的是:
mov [0x0804a020], 10 ; 将10存入地址为0x0804a020的内存中
所以:
-
a
→ 被编译为地址0x0804a020
-
&a
→ 取出这个地址本身(就是0x0804a020
) -
*(&a)
→ 解引用地址,回到值10
C语言中,直接使用变量名与解引用变量不一样:访问内存,解引用
更准确地说:
-
访问变量名
a
:其实是访问地址[a]
,也就是隐式的解引用。 -
显式写成
*(&a)
:等价于a
-
a
和&a
是两个不同的东西,一个是值,一个是地址。
2、重新理解进程和进程的独立性
进程的独立性是因为内核数据结构(内核数据结构各自一份)和代码与数据独立,所以才有所谓的进程的独立性
什么是进程的“独立性”?
准确的说:
-
每个进程有 自己的一份虚拟地址空间(即
mm_struct
) -
每个进程有 自己的一套运行上下文、调度信息、文件描述符表等(即
task_struct
) -
每个进程之间的代码和数据 互不影响、互不可见
总结:
进程地址空间的管理依赖于虚拟内存和页表映射,mm_struct
结构体是内核中重要的抽象,支持动态内存申请和保护。写时拷贝优化了进程创建的内存使用。访问权限与 isExist
机制确保内存访问安全和效率。理解这些概念与机制,可以帮助我们更深入把握操作系统内存管理的核心。
如果大家有不懂的问题,或者我有所说错的地方,欢迎大家评论区指正交流!!!
希望本文对你有所帮助!!