Linux系统之:进程概念
本篇文章为Linux学习部分的进程概念部分学习分享,希望也能够为你带来些许的帮助!
文章有些长,但内容是真的干!
那咱们废话不多说,直接开始吧!
一、进程状态
1 进程属性与状态的本质
1.1 什么是进程状态?
进程状态就是进程控制块里的一个数字,它是定义在全局的一个宏,而且通过这个状态,就能确定进程下一步该干啥。常见的状态有运行、阻塞、挂起这些。
1.2 Linux 里进程链表的特殊玩法
在 Linux 里,进程链表的实现和咱们之前学的双链表不一样:PCB 里专门定义了一个 node
结构体,里面放着 next
和 prev
指针。所以进程之间的指针,指的是下一个进程的 node
变量,而不是像以前那样直接指向下一个结构体的开头。
1.3 怎么通过 node
拿到结构体首地址?还能访问其他属性吗?
这问题有点意思!咱们一步步说:
如果是数组里的元素,靠首地址和类型大小(比如 int 占 4 字节)就能定位,但这里是结构体里的 node
,咋整?
有个特巧妙的办法:
struct test *start = &c - &((struct test*)0->c);
解释一下:把数字 0 强转成 struct test*
类型,再访问里面的变量 c
,这就得到了 c
相对于结构体首地址的偏移量。用 c
的实际地址减去这个偏移量,不就是结构体的首地址了嘛!
首地址都有了,直接用结构体的变量名就能访问其他属性了啊!
最牛的是,这么搞出来的双链表,跟类型压根没关系!这意味着啥?一个结构体里只要多放几个 node
字段,就能同时属于好几个数据结构!这思路真的太有创新性了!
2 进程状态和调度那点事儿
把这思路用到进程上:所有进程在全局用双链表串起来,有了这招,就能在不破坏原有链表的情况下,把进程平滑地加入调度队列。那些还没进队列的,就是新建状态或者就绪状态~
要是有两个 CPU,那就有两套上面说的流程。比如 100 个进程在全局链表里,50 个链到第一个 CPU,剩下 50 个链到第二个,这不就实现进程并发运行了嘛!
3 阻塞状态:等资源时进程去哪了?
3.1 OS 是咋管理硬件的?
OS 管软件是 “先描述再组织”,管硬件也一样:把每个硬件都描述成结构体,改改数据就能区分是键盘、显示器还是网卡,再把这些结构体串成链表,统一管理。
3.2 就拿 scanf
来说,为啥没输入时命令行卡住了?
其实啊,这进程本来在 CPU 调度队列里跑着呢,执行到 scanf
时,得从键盘拿数据。可这时候键盘数据还没准备好,进程根本没法往下走。那咋办?
这时候 PCB 就从 CPU 运行队列断开,跑到键盘的资源等待队列里去(等着抢外设资源),这时候 PCB 的状态就是阻塞状态。
谁最清楚键盘数据好了没?当然是操作系统啊,它是管理者嘛!等检测到数据就绪,OS 就把等待队列里的 PCB 断开,再链回 CPU 运行队列,这样 scanf
后面的代码就能接着跑了。
所以说,运行和阻塞的本质就是:改改 task_struct
里的状态属性,再换个队列待着。
4 挂起状态:内存不够时的操作
还记得阻塞状态不?假设有个进程正在某个设备的等待队列里堵着,这时候内存不够了,而这进程又没法马上被调度,OS 就会把它的代码和数据换出(swap out
)到磁盘的 swap 分区,内存里只留个 PCB,这时候进程就是挂起状态。
等外设数据好了,OS 再把磁盘里的代码和数据换入(swap in
)到内存,恢复正常调度。
但挂起也分情况:
- 上面说的是阻塞挂起状态。
- 要是把阻塞进程换出去后内存还不够,OS 就会盯上 CPU 调度队列里还没跑的进程,把它们的代码和数据也换出去,这些进程就是就绪挂起状态。
5 Linux 里的进程状态细节
5.1 先看源码里的状态
从源码能看到 Linux 里有好几种状态,各对应一个数字,咱们重点说几个常见的。
5.2 浅度休眠(S+)就是阻塞状态
执行 scanf
没输入时,进程状态是 S+,这就是浅度休眠,对应咱们说的阻塞状态。
那为啥死循环打印时,进程也老显示阻塞状态?
其实啊,打印进程大部分时间都在显示器的等待队列里等着,谁让外设比 CPU 慢太多呢,想瞅见它在 R 状态(运行)的时候,太难了!
5.3 深度休眠(D)是啥?
浅度休眠的进程能响应外部事件,那深度休眠呢?
当进程在搞磁盘级的读写(比如往磁盘写 1GB 数据),得等磁盘给个信儿(成功还是失败)。这时候要是进程状态只是 S,内存不够时 OS 可能把它杀了,万一磁盘写失败了,找谁反馈去?数据不就丢了?
所以就有了 D 状态(disk sleeping,深度休眠):这状态下的进程,外界啥动静都不管,OS 也不能瞎干预(换出、杀掉都不行),只能等它自己醒。不过这过程一般很短,几百毫秒到几秒(看磁盘速度),I/O 一完成,自动转成 R 或 S 状态,内核才能正常管它。
(注意:内存不够时 swap out 的是进程自身的代码数据,写到 swap 区;而用户自己要写的信息,是写到常规存储区的,不是一回事儿!)
5.4 前台进程和后台进程的区别
状态后面带 +
,说明是前台进程,能用 ctrl+c
终止;没 +
就是后台进程,ctrl+c
不好使。
5.5 t 状态(tracing stop)是啥情况?
咱们用 gdb 调试代码时,一开始只有 gdb 进程,状态是 S+。打个断点再 r 运行,就会冒出个状态为 t 的子进程。
因为 gdb 运行代码本质是创建个子进程执行咱们的代码,这子进程被断点拦住了,就处于 t 状态(暂停状态)。
5.6 暂停状态和阻塞状态的区别
- 暂停状态:是外部干预的结果(比如用户调试),进程自己没说要暂停,是被强行按住了。
- 阻塞状态:是进程自己跑出来的结果,要的资源(比如输入、磁盘数据)没准备好,内核自动把它转成阻塞状态,等资源好了再说。
6 杀进程用 kill
命令
kill -l
:列出杀进程的所有选项。
kill -选项数字 进程号
:对指定进程执行相应操作(比如 kill -9 进程号,就是强制杀掉)。
二、进程状态与优先级调度
1. 进程结束的过程与僵尸状态
进程结束就是进程创建的反过程。进程生来就是为了完成任务,而任务完成得怎么样很关键 —— 比如main
函数最后的返回值,就是给父进程的 “成绩单”,0
通常表示任务圆满完成。
但进程结束时,不能一下子把所有资源都释放掉,为啥?
因为进程结束后得先进入Z(僵尸状态):这时候子进程的代码和数据会被释放,但 PCB 会保留下来,里面的int exit_code
字段会记着退出信息(比如退出码),等着父进程来读取。
可要是父进程一直不读、不回收这个子进程,僵尸进程的 PCB 就会一直占着地方。要是积累了 50 个、100 个,那可不就造成内存泄漏了嘛!所以解决办法只有一个:让父进程赶紧读取退出信息,完成回收。
父进程回收后,子进程会先过渡到 X 状态,然后彻底消失,啥痕迹都不留。
想自己观察这个状态?用一段代码试试:子进程 5 秒后结束,这时候父进程还没结束,你去看子进程状态,会发现是Z+
(前台僵尸状态)。
2. 父进程先没了:孤儿进程与领养机制
要是子进程还没结束,父进程先结束了,那子进程就成了孤儿进程,会被 1 号进程领养!
这时候肯定有几个问题冒出来:
2.1 1号进程是谁?
你top
一下就知道,1 号进程是systemd
(有些内核里叫initd
)。说白了,这进程就是操作系统的一部分。
2.2 为啥要被领养?
父进程没了,就没人管子进程了,那不就成僵尸进程浪费资源了?所以被领养这事儿,必须得有!
2.3 父进程去哪了?
这里的父进程,会被它自己的父进程(也就是bash
进程)自动回收掉!
3 进程优先级:资源分配的先后顺序
3.1 优先级的定义与和权限的区别
进程优先级,就是进程拿到系统资源的先后顺序。
这里得说说优先级和权限的区别:
- 权限:解决 “能不能” 拿到资源的问题。
- 优先级:前提是 “能拿到”,解决 “谁先谁后” 的问题。
为啥要有优先级?太简单了 —— 资源就那么点,进程却一大堆,不可能一下子全搞定,总得有个先来后到吧!
3.2 和优先级相关的几个概念
- UID:代表执行者的身份(也就是当前用户)。
- PRI:进程可被执行的优先级,数字越小,优先级越高。
- NI:进程的 nice 值(谦让值)。
PRI 很好理解,那 NI 是啥?
NI 就是用来调整优先级的,公式是PRI(new) = PRI(old) + NI
。要是 NI 是负数,PRI 就变小,优先级就变高。所以在 Linux 里,调优先级其实就是调 NI 值。
NI 的范围是-20~19
,一共 40 个级别。
3.3 怎么修改 NI 值?
用top
命令:先输入r
,再输入进程 PID,最后输入目标 nice 值就行(不过得是超级用户才有权限)。
举个例子:
初始情况下:
- 原来 PRI 是 80,把 NI 改成 17,PRI 就变成 97 了。
- 再把 NI 改成
-20
,PRI 就变成 60 了。
这说明 NI 是覆盖式修改的。而且PRI(old)
其实固定是 80—— 要是这值不固定,用户每次调优先级前还得先查当前 PRI,多麻烦!固定成 80,用户就不用操心了,效率也高。
3.4 为啥 NI 范围是-20~19
?
首先,Linux 里 PRI 范围是60~99
,也是 40 个级别,刚好对应。
其次,商用操作系统(也就是分时操作系统)得讲公平,就像抢票系统一样,得让大家都有机会。所以优先级变化必须在可控范围内。要是范围太大(比如1~1000
),那 PRI=1000 的进程可能永远排不上队,这不就造成 “进程饥饿” 了嘛!
至于为啥偏偏是-20~19
?这和具体的调度算法有关,以我现在的知识,还没法解释得更深……
4 进程的几个补充概念
4.1 竞争性
系统里进程太多,CPU 这类资源又少(甚至只有一个),所以进程之间天生就存在竞争。为了高效完成任务、让资源竞争更合理,才有了优先级这东西。
4.2 独立性
多进程运行时,得各自占着自己的资源,谁也别干扰谁。
/要是一个进程跑的是另一个操作系统的代码,那就是我们说的虚拟机啦/
4.3 并行
多个进程在多个 CPU 上,同时跑起来,这就叫并行。
4.4 并发
多个进程在一个 CPU 上,通过切换的方式,在一段时间内都能往前跑一跑,这就叫并发。
三、进程切换
1 进程切换的本质:寄存器的切换
先说结论:进程切换,说白了就是任务切换,反映到 CPU 上,其实就是寄存器的切换。
当一个进程的时间片用完了,就得从 CPU 上下来,把当前寄存器里的硬件上下文数据存到自己 PCB 的 TSS(任务状态段)里;等下次轮到它被调度时,再把这些数据从 TSS 里拿出来恢复到寄存器,接着上次的逻辑继续往下跑。
2 Linux 内核中的进程队列(参考 Linux 2.6 内核)
2.1 一个 CPU 对应一个 runqueue
每个 CPU 都有自己的 runqueue(运行队列),里面最核心的是queue[140]
这个调度队列:
- 0~99 号位置:咱们暂时不关心(后面说实时 OS 会提到)。
- 100~139 号位置:对应普通优先级,刚好 40 个位置 —— 这和前面说的 Linux 中 PRI 范围 60~99(也是 40 个级别)完美对上了!
这里有个历史原因:ps
、top
这些工具显示的 PRI,和内核里的优先级有个换算关系 ——PRI = 内核优先级 - 40
。也就是说,内核里queue[100~139]
对应的,就是工具上显示的 PRI 60~99。
2.2 同优先级进程的调度与位图优化
如果多个进程优先级相同,就会以双链表的形式串在queue
数组的同一个位置上。OS 调度时会从 100 往 139 遍历,同一位置上的进程就按链表顺序来(头部的先跑)。
但问题来了:要是所有进程都串在 139 号位置,OS 从 100 开始一个一个查,效率不就太低了?这可不符合 OS 追求高效的性子!
所以queue
上面还有个bitmap[5]
(位图)来解决这个问题:
为啥是 5?
bitmap
是 int 类型(4 字节 = 32 位),5 个 int 就是5×32=160
个比特位,刚好能覆盖queue[140]
(140 个队列),多一个浪费,少一个不够,就这么精准!它咋服务优先级队列的?举个例子:一个 8 比特位的位图
0000 0000
,每个比特位的位置对应queue
数组的队列号,比特位是 1 就表示这个队列里有进程(非空)。OS 只要从右向左遍历位图,就能快速找到有进程的队列,比遍历整个数组快多了!
3 活动队列与过期队列:轮流 “上岗”
仔细看 runqueue 的结构,会发现有两套一模一样的结构(咱们可以把蓝色或红色框里的三个变量打包成struct q
,然后在 runqueue 里定义struct q array[2]
,就能实现两个相同结构的数组)。
上面还有两个指针:active
和expired
:
active
指向活动队列:里面放的是时间片还没耗尽的进程,按优先级排好。expired
指向过期队列:里面放的是从活动队列过来的、时间片已经用完的进程。
所以调度过程中,活动队列的进程会越来越少,过期队列的会越来越多(要是中途改了 nice 值,本轮先正常调度,等时间片用完了再重新算 PRI,插到正确的位置)。等到活动队列空了(nr_active
变量变成 0),系统就会直接swap(&active, &expired)
—— 交换两个指针,过期队列变新的活动队列,原活动队列变新的过期队列,无缝衔接!
4 Linux 的 O (1) 调度算法:高效还不饿肚子
上面说的这套机制,就是 Linux 的 O (1) 调度算法,牛就牛在:
- 效率高:不管有多少进程,调度决策的时间都是固定的(O (1) 复杂度),靠位图快速定位非空队列。
- 不会造成进程饥饿:不管过期队列里有没有高优先级进程,都得先把活动队列的进程调度完,哪怕过期队列中有个优先级为1的进程,也得等活动队列里是优先级 139 的进程跑完时间片,才轮得到它被调度。
5 操作系统的两种类型
5.1 分时 OS
按时间片轮转运行,讲究个公平,比如咱们平时用的桌面系统、服务器系统大多是这样。
5.2 实时 OS
按优先级实时响应,高优先级任务能直接抢 CPU(Linux 也支持实时功能,对应queue[0~99]
的实时优先级)。
实时 OS 有啥用?举个例子:
一哥们儿开着自己的爱车、享受着柏林之声在德国不限速高速上飙到 200km/h,突然看到前方事故急踩刹车,但这时车载OS却说 “没看到这音乐搁这儿放着呢吗?刹车进程后边儿排队去奥”,
......粗口还没爆出来,兄弟先学会了飞翔
实时 OS 就能保证刹车这种高优先级任务立刻执行,救命的!
四、命令行参数
1 main 函数的参数: argc 和 argv
main 函数可以有参数,而且有固定的两个 ——argc
和argv
,完整形式是:
int main(int argc, char *argv[])
2 命令行参数的表现与作用
写一段遍历argv
数组的代码,编译后在命令行运行./a.out 参数1 参数2 ...
,会发现输入的内容会按空格分隔,依次存到argv
数组里,argc
则记录参数的总个数(包括程序名./a.out
)。
比如输入./a.out -a -b
,argc
就是 3,argv[0]
是./a.out
,argv[1]
是-a
,argv[2]
是-b
。
3 三个核心问题:为什么、谁做的、如何实现
3.1 为什么要设计命令行参数?
为了让程序更灵活!通过不同参数实现不同功能,就像我们用ls -a
和ls -l
会得到不同结果一样。
举个例子:写一段带参数校验的代码
编译后直接运行
./a.out
,系统会提示Usage: ./a.out 选项
—— 用户根据提示输入参数,程序就能按对应的逻辑执行
这就是命令行参数的价值。
3.2 是谁实现了命令行参数的传递?
是bash
(命令行解释器)!
在你输入./a.out 参数1 参数2
并回车时,bash
作为父进程,会先解析命令行中的参数,然后创建子进程(运行./a.out
),并把参数传递给子进程的main
函数。
原理是:子进程未修改的代码和数据与父进程共享,bash
会把参数个数argc
和参数数组argv
通过进程创建机制传给子进程,让main
函数能直接使用。
3.3 如何实现的?
这部分涉及操作系统创建进程的底层机制,目前知识储备还没法完全展开,简单说就是:bash
在调用exec
系列函数加载程序时,会将解析好的参数填入进程的用户栈中,main
函数从栈中读取这些参数,最终呈现为argc
和argv
。(后面会深入讲解)
4 命令行参数的总结
- 命令行参数至少有 1 个(
argv[0]
是程序名,如./a.out
)。 argc
是参数总个数(以空格分隔的子串数量)。argv
是字符串数组,按顺序存放参数,最后一个元素是NULL
(作为结束标志)。
五、环境变量
1 环境变量的本质:全局变量的一种
看到 “环境变量” 这词,配 Python 环境时肯定见过吧?
那到底啥是环境变量?
简单说,就是一套系统级的全局变量,每个变量都有特定用途,支撑着系统和程序的运行。
2 从 PATH 看环境变量的作用
2.1 PATH:程序查找的 “导航图”
Linux 里最常用的环境变量之一就是PATH
。
为啥在命令行输入a.out
会报错?因为 OS 执行命令前得先找到程序,而PATH
里就存着查找路径。
怎么证明?用echo $PATH
就能查看PATH
的内容 —— 一堆用冒号:
分隔的子路径。
bash
收到命令后,会逐个遍历这些子路径找程序,找到就执行,找不到就报错。
2.2 修改 PATH:让自己的程序随处可运行
要是把自己写的程序路径加到PATH
里,会咋样?
试一下:
export PATH=$PATH:/自己的程序路径
加完之后,不管在根目录还是家目录,输入程序名就能运行了
这就是为啥装 Python、Java 时要把路径加到
PATH
里:让解释器和可执行程序在任意目录都能被找到。
小实验:
把PATH
清空(PATH=""
),会发现所有命令都用不了了
但关掉终端重新登录,PATH
又恢复了
因为重新创建的
bash
进程会重新加载环境变量。
3 查看系统环境变量:env 命令
用env
命令能看到当前用户下的所有环境变量,挑几个常见的说说:
HOSTNAME
:当前主机的名称。TERM
:当前用的终端类型。HISTSIZE
:历史命令记录条数(默认存最近 1000 条)。OLDPWD
:上一次进入的目录路径 ——cd -
命令能在两个最近目录间切换,其实就是cd $OLDPWD
。SSH_TTY
:当前用户关联的终端文件是谁。USER
:当前访问 Linux 的用户。LS_COLORS
:ls
命令显示的颜色方案。PWD
:当前所在路径。LOGNAME
:当前登录的用户。
这些环境变量在登录系统时会被优先初始化,支撑用户的各种操作需求。
4 如何获取环境变量?三种方法
4.1 main 函数的第三个参数
之前说main
有两个参数(argc
、argv[]
),其实还有第三个:char* env[]
(三个参数都能省略)。它是个指针数组,每个指针指向 “K=V” 格式的字符串(就像 map 里的键值对),和argv[]
一样,最后以NULL
结尾。
写段代码遍历env[]
,就能打印出所有环境变量,亲测有效!
4.2 通过函数获取
用getenv("变量名")
函数也能获取环境变量,比如getenv("PATH")
就能拿到PATH
的值。
4.3 C 语言的全局二级指针
C 语言里有个全局变量extern char**environ
,它指向的就是char* env[]
,直接用这个指针也能访问所有环境变量。
5 进程如何获取环境变量?从父进程继承
咱们的进程自己不会凭空有环境变量,都是从父进程(通常是bash
)继承来的。因为环境变量表属于父进程的数据,子进程没修改的话会共享这份数据,自然就拿到了。
/* 偷偷说:bash
里有两张表 —— 命令行参数表(老变)和环境变量表(相对稳定) */
5.1 bash 的环境变量从哪来?
bash
的环境变量来自系统配置文件。登录账号时,bash
进程创建,会先申请一块内存,然后从配置文件里解析、读取环境变量,存到内存里。这就是为啥清空PATH
后,重登终端又恢复了 —— 新的bash
进程重新读了配置文件呗!
6 环境变量的特点与操作
-** 本地变量 :形如变量名=值
(比如a=123
),只在当前bash
进程里有效,子进程拿不到。
- 导出环境变量 :用export 变量名
,能把本地变量加到环境变量表里,子进程就能继承了。
- 删除环境变量 **:unset 变量名
,能删掉环境变量或本地变量。
环境变量的 “全局” 体现在:能被子进程、子进程的子进程…… 一代一代传下去,所有进程的环境变量源头基本都是bash
。
7 内建命令:不用创建子进程的特殊命令
先想个问题:定义一个本地变量a=100
,然后echo $a
能打印出来。但echo
是命令,按说要创建子进程,可子进程咋能拿到父进程的本地变量?Isn't that concerning?
其实,Linux 里大部分命令要创建子进程执行,但有些命令(比如echo
、cd
、export
)因为执行没风险,bash
会自己直接执行 —— 这就是内建命令,相当于bash
的内部函数,执行时不用创建子进程,自然能访问本地变量。
六、程序地址空间
1 C/C++ 内存空间布局验证:从地址看变量存储
先看个现象:写段代码打印不同变量的地址(全局变量、局部变量、字符串常量等),再给局部变量加static
修饰,编译运行后会发现 —— 加了static
的变量地址和全局变量格式一样了。
为啥?因为static
会让变量被编译器当作全局变量处理,存到全局数据区,这也是static
变量在函数调用结束后不销毁的原因 —— 它的存储区域和进程生命周期绑定,只要进程活着,全局数据区就一直在。
2 虚拟地址的发现:父子进程的 “地址之谜”
写一段父子进程代码:定义一个全局变量gval
,父进程循环打印gval
,子进程循环让gval
自增。
运行后会发现:
- 子进程的
gval
一直在涨,父进程的gval
却不变。 - 但两者打印的
gval
地址居然一模一样!
这咋回事?原来这个地址不是物理内存地址,而是虚拟地址—— 咱们平时在所有语言里打印的地址,全是虚拟地址,物理地址根本看不到!
3 虚拟地址与物理地址:映射靠页表,修改靠写时拷贝
3.1 页表:虚拟到物理的 “翻译官”
程序存放在磁盘里,运行时会加载到物理内存,OS 同时会给进程创建task_struct
(PCB)和一段虚拟地址空间(从 0000...0000 到 FFFF...FFFF)。虚拟地址空间里划分了代码段、数据段、堆、栈等区域,和物理内存通过页表(映射表)关联。
当访问虚拟地址时,OS 会通过页表把它翻译成物理地址,找到对应的物理内存 —— 这就是虚拟地址到物理地址的转换过程。
3.2 写时拷贝:父子进程的数据独立魔法
父子进程创建时,子进程会以父进程为模板,复制task_struct
、页表,虚拟地址空间完全相同,页表映射的物理内存也一样(代码和数据共享)。
但如果子进程修改gval
,OS 会触发写时拷贝:
- 在物理内存新开辟一块空间
- 把原数据拷贝过去。
- 更新子进程的页表,让虚拟地址映射到新物理地址。
这样,父子进程虚拟地址相同,但物理地址不同,数据各自独立 —— 这就是为啥子进程gval
变了,父进程不变。
3.3 再解释一个现象:fork 后 if 和 else 能同时执行?
fork()
创建子进程时,会给父进程返回子进程 PID,给子进程返回 0。
这本质是对
pid_t id
的写入操作,会触发写时拷贝,让父子进程的id
值不同。因此,if (id > 0)
(父进程)和else if (id == 0)
(子进程)能同时执行 —— 因为各自的id
值不同啦。
4 虚拟地址空间的本质:内核数据结构 mm_struct
虚拟地址空间不是真的 “空间”,而是 OS 用内核数据结构描述的范围 —— 核心是mm_struct
结构体(包含于task_struct中)。
4.1 从 “38 线” 理解空间划分
小时候同桌在课桌上画 38 线,本质是划分区域(各自的start
和end
)。虚拟地址空间也一样,mm_struct
里有一堆start
和end
变量,划定了代码段、数据段、堆、栈等区域的范围。
比如:
- 代码段:
start_code
到end_code
- 数据段:
start_data
到end_data
- 堆:
start_brk
到end_brk
这些范围在创建 PCB 时被初始化,就形成了我们看到的虚拟地址空间。
4.2 vm_area_struct(VMA):更细致的区域管理
mm_struct
划定了整体范围,但具体每个区域(比如堆里多次malloc
的内存)的描述,靠vm_area_struct
(简称 VMA)—— 以链表形式存在于mm_struct
中,每个 VMA 记录一块连续虚拟地址的start
、end
和权限(读 / 写 / 执行)。
比如堆的整体范围由mm_struct
的start_brk
和end_brk
划定,每次malloc
会在这个范围内用 VMA 分配一块小区域
当堆空间不够时,mm_struct
会扩大整体范围,再由 VMA 分配新区域。
VMA 的另一个关键作用是权限检查—— 比如代码段的 VMA 权限是 “只读 + 执行”,数据段是 “读 + 写”,字符串常量区是 “只读”,这些权限会同步到页表,确保内存访问安全。
5 32 位机器的地址空间划分
32 位机器的地址空间大小是2^32 = 4GB
,划分规则:
- 0~3GB:用户空间(程序员可直接用地址访问)
- 3~4GB:内核空间(必须通过系统调用访问)
这种划分让用户程序无法直接触碰内核内存,保证了系统安全。
6 进程独立性的再理解
进程 = 内核数据结构(task_struct
+mm_struct
+ 页表)+ 代码与数据。
父子进程各自有独立的task_struct
、mm_struct
和页表:
- 代码段是只读的,父子共享(页表映射到同一块物理内存)。
- 数据段默认共享,但只要一方修改,就触发写时拷贝,各自映射到不同物理内存。
因此,进程的独立性不仅是 “互不干扰”,更是通过虚拟地址空间和页表实现的 “逻辑独立”—— 哪怕虚拟地址相同,物理内存也能完全隔离。
7 常见问题的解释
7.1 全局变量为什么 “全局有效”?
全局变量和static
变量存放在数据段,虚拟地址空间只要进程活着就存在,它们的页表映射关系一直有效。因此,只要进程没结束,这些变量的数值就会被保留,看起来 “全局有效”。
7.2 字符串常量为什么是只读的?和const
有啥区别?
- 字符串常量(如
"hello world"
)和代码一起编译,存放在代码段,VMA 和页表给它的权限是 “只读”—— 如果尝试修改(比如*str = 'C'
),运行时会触发权限错误(段错误)。
const
是编译器级别的检查:修饰变量时,编译阶段如果有写入操作就报错(比如const char* str; *str = 'C'
会编译报错),但不影响运行时权限。
// 编译时报错(const检查)
const char* str1 = "hello";
*str1 = 'C'; // 编译通过,运行时报错(页表权限检查)
char* str2 = "hello";
*str2 = 'C';
8 为什么需要虚拟地址空间?
8.1 理由 1:保护物理内存安全
虚拟地址到物理地址的转换过程中,OS 会通过页表和 VMA 做权限审核(比如禁止写只读区域),相当于加了一层 “安全过滤”,避免物理内存被恶意或误操作修改,维护了进程独立性。
8.2 理由 2:让 “无序” 变 “有序”
物理内存的分配是无序的(程序加载到物理内存的位置随机),但虚拟地址空间强制按 “代码段→数据段→堆→栈” 的顺序划分。
通过页表映射,用户看到的永远是有序的地址,不用关心物理内存的实际位置 —— 这极大简化了程序员对内存的理解和使用。
8.3 理由 3:实现 “惰性加载” 和资源高效利用
创建进程时,代码和数据不会立即全部加载到物理内存,而是 “用多少加载多少”(惰性加载)。虚拟地址空间让 OS 可以先划定范围,等程序执行到某段代码 / 数据时,再通过页表映射加载到物理内存,避免内存浪费。
8.4 理由 4:解耦进程管理和内存管理
有了虚拟地址空间,进程管理(task_struct
、mm_struct
)和内存管理(物理内存分配)被分开:
- 进程管理只关心虚拟地址范围和权限。
- 内存管理只关心物理内存的分配和回收。
两者通过页表映射关联,互不干扰,让 OS 设计更灵活。
那么以上就是本次学习分享的所有内容了~
非常感谢你能够看到这里!
如果感觉本文对你有帮助的话还请给个三连 这将会给我莫大的鼓舞!
后续我依旧会继续更新Linux的学习分享~
就让我们 下次再见!