Linux从0到1——进程概念(下)
1. 进程优先级
1.1 基本概念
1. 优先级是什么?
- 本质是得到某种资源的先后顺序。
2. 为什么需要确定优先级?
- 本质上是因为计算机内的资源不足(软硬件资源)。
3. 怎么做?
- Linux中的优先级,其实就是PCB中的一个
int
字段,值越小,表示优先级越高。数值范围为60~99; - Linux中默认进程的优先级都是80。
4. 验证优先级的存在
-
先写一段代码:
-
编译并运行这段代码,同时执行命令
ps -al
:
-
可以看到进程有一个
PRI
属性,是英文Priority
的缩写,代表进程的优先级。 -
其中
UID
代表执行者的身份。
1.2 Linux支持动态优先级调整
1. PRI和NI:
PRI
就是进程的优先级,而NI
为nice
值,是进程优先级的修正数据;- 进程优先级的更新公式可以理解为:
PRI(new) = PRI(old) + nice
; - 注意,
PRI(old)
默认为80,是一个固定值; nice
最小是-20,超过-20统一按-20处理;nice
最大是19,超过19统一按19处理。
2. 为什么要把优先级限定在一定范围内?
- OS在调度时,需要较为均衡的让每一个进程都得到调度。如果某一个进程的优先级过低,就会长时间得不到CPU资源,不能被调度,这种现象称为——进程饥饿。
3. 动态调整优先级:
- 输入
top
指令,输入r
,输入待调整的进程pid
,最后输入nice
值(下面的例子还是用的1.1中的代码)。
- 上面的例子中,我们设置该进程
nice
值为10,它的PRI
就变成了90。 - 注意:
- 增大优先级(
nice
值为负),需要root
权限;减小优先级(nice
值为正),不需要root
权限。 - 一个普通用户,只有修改自己进程优先级的权限。
- 增大优先级(
1.3 其他概念
1. 其他概念:
- 竞争性:系统进程数目众多,而CPU资源只有少量,甚至只有1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级;
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰;
- 并行:多个进程分别在多个CPU下,同时运行,这称之为并行;
- 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
- 并发的直观感受就是,我们在使用电脑时,可以打开n多个软件,也就是启动n多个进程,这些进程可以同时运行。
2. 重点理解进程切换:
- 每一个进程,不是占有CPU就一直运行的,会每隔一段时间,自动从CPU剥离下来(这个时间间隔称为时间片);
- Linux内核是支持进程之间进行CPU资源抢占的,称为基于时间片的轮转式抢占内核。
- 简单理解一下:假如时间片是1ms,那么每个进程,运行1ms后就会被自动剥离下来。Linux还支持进程间CPU资源抢占,如果一个进程已经运行了0.5ms, 但是来了一个优先级更高的进程,那么这个已经运行了0.5ms的进程就会被强制剥离下来,即使时间还没到,以便让这个优先级更高的进程抢占CPU资源。
3. 认识寄存器:
- CPU中是有很多很多寄存器的。
- 上图的代码中,
a
是临时变量,临时变量出了作用域是会被销毁的,那么a
是如何通过return
返回给外部的呢?答案就是,a
这个临时变量的值,会先存到eax
寄存器中,然后通过这个寄存器返回给外部。 - 进程在运行时,它怎么知道当前运行到了哪一行?如何实现函数跳转?
- 在CPU内,有一个程序计数器
eip
,也叫pc
指针,它会记录程序运行到了哪里。
- 在CPU内,有一个程序计数器
- 如果我们有多个进程,各个进程在CPU寄存器中形成的临时数据都应该是不一样的。这一部分数据,就称为进程的硬件上下文。
- 寄存器 != 寄存器的内容。CPU寄存器硬件只有一套,如果此时有10个进程,那么10个进程的上下文数据就有十套。
- 一个进程在运行时,寄存器中会保存该进程的各种临时数据。
4. 进程切换是如何做到的?
- 实现进程切换,就必定要考虑几个问题。
- (1)一个进程可能运行到一半被剥离了,那当它再次被调度时,CPU怎么知道当前进程运行到了哪里?
- (2)进程在运行时,必定会产生很多的上下文数据,当这个进程再次被调度时,之前的上下文数据又在哪里?
- 之前我们知道了,一个进程在运行时,寄存器中会保存该进程的各种临时数据。牢记一点,保存的目的,是为了恢复。一个进程在切出时,会将寄存器中保存的临时数据放进自己的PCB中,在切入时,又会将PCB中保存的数据放回寄存器(这个问题其实很复杂,我们在这里只是暂时这样简单理解)。
- 注意:早期的内核是这样做的,但是如今的操作系统已经不这样做了,因为PCB越来越大了。
- 将CPU内的寄存器保存到进程PCB中,本质上就是将CPU寄存器的内容,保存到内存中。
进程切换时,寄存器中的内容不用清空,而是直接被覆盖。
2. Linux2.6内核进程调度队列(选学)
1. 实时操作系统、分时操作系统:
- 实时操作系统:严格遵循优先级高的先调度,所有优先级低的进程统统给优先级高的进程让路。
- 现在有一个车载系统,刹车这个进程的优先级肯定是最高的。如果此时采用分时操作系统,公平调度,车载系统说现在你还在放音乐呢,等这个进程结束了,再刹车,这就扯淡了。 所以车载系统肯定是采用实时操作系统,所有进程给刹车进程让路。
- 分时操作系统:不严格遵循优先级高的先调度。按时间片的方式分配资源。
Linux操作系统是实时兼分时的操作系统。
2. Linux2.6中的调度原理:
- Linux中的普通优先级只有
[66, 99]
。我们可以发现,Linux的调度队列有140个优先级,我们是做网络开发的,前[0, 99]
个优先级用不上,这里就不再介绍了。主要用到的就是后[100, 139]
优先级,其中queue[100]
就对应60优先级,queue[139]
就对应99优先级。 - 所谓的调度队列,其实就是
task_struct
的指针数组。从上向下遍历数组,只要出现不为空的位置,就执行对应位置的进程(可能有多个)。进程是以队列的方式,链入到调度队列对应优先级的位置中的。来一个进程,就在后面链接一个。
- 考虑一个问题,上图中,如果链入了一个优先级为99的进程,还没有被执行。此时再不断地向
queue
中链入优先级为80的进程,那么每次遍历queue
时,queue[120]
位置的值都不为空,优先执行优先级为80的进程。这样就会造成进程饥饿,即优先级为99的进程永远不会被调度,这就不符合公平的原则了。 - 为了解决这个问题,Linux2.6内核中,设计了两个
queue
,一个是活动队列active
,一个是过期队列expired
。看下图,此时活动队列中已有的进程如下,60优先级的有三个,80优先级的有1个,99优先级的也有一个。此时又想在queue
中链入新进程(一个60,两个80),就不会直接链入到活动队列中了,会先链入到过期队列中,等活动队列中的进程都调度完了(这里只是简单这样理解),再交换active
和expired
指针,调度刚才过期队列中的进程,体现了局部的优先级特性,也体现了公平性。
- 注意:交换发生时,活动队列中的进程,不一定是被彻底执行完了,但是
active
队列一定为空。比如此时有一个80优先级的进程进入了阻塞状态,它并没有执行完,但是需要从活动队列中剥离,放入阻塞队列中,等资源就绪时,再将这个进程放入过期队列,等待下一次调度。 - 确定优先级需要从上到下遍历数组,还是太麻烦了,Linux中采用位图
bitmap
来记录queue
的各个位置为不为空,为空是0,不为空是1,这样就可以8位8位或16位16位的检测了,大大提升了检测效率。 - 判断活动/过期队列是否为空,是根据
nr_active
来判断的。 - 这就是Linux2.6的大O(1)调度算法!
3. 命令行参数
3.1 认识命令行参数
1. main函数是可以传参的:
- 写一段代码。
- 其中
argc
和argv
这两个参数叫命令行参数。
- 其中
- 试着编译并运行一下上述代码:
- 可以看到,
main
函数接受了我们传进去的-q
,-a
,和-w
。 - 这个工作是由
shell
做的,它将我们输入的一串字符,以空格为分割符,进行分割。能分割出几个字符串,argc
就是几,argv
数组就存储这些分割好的子串。
- 可以看到,
2. 画图理解:
3. 命令行参数的意义:
- 命令行参数,可以支持各种指令级别的命令行选项的设置!
3.2 手动实现一个简单加/减/乘/除计算器
1. 代码:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
int main(int argc, char *argv[])
{
if (argc != 4)
{
printf("Use error: %s op[-add|sub|mul|div] d1 d2\n", argv[0]);
return 1;
}
// 将字符串转成int
int x = atoi(argv[2]);
int y = atoi(argv[3]);
int result = 0;
// 你的程序一定有4个命令行参数,第一个参数是程序名
if (strcmp(argv[1], "-add") == 0)
{
result = x + y;
printf("%d+%d=%d\n", x, y, result);
}
else if (strcmp(argv[1], "-sub") == 0)
{
result = x - y;
printf("%d-%d=%d\n", x, y, result);
}
else if (strcmp(argv[1], "-mul") == 0)
{
result = x * y;
printf("%d*%d=%d\n", x, y, result);
}
else if (strcmp(argv[1], "-div") == 0)
{
if (0 == y)
{
printf("error, div zero\n");
return 1;
}
result = x / y;
printf("%d/%d=%d\n", x, y, result);
}
else
{
printf("User error, you should use right op\n");
return 1;
}
return 0;
}
2. 使用示例:
3.3 模拟实现touch
1. 代码:
#include<stdio.h>
int main(int argc, char *argv[])
{
if (argc != 2)
{
printf("touch: missing file operator\n");
return 1;
}
FILE *fp = fopen(argv[1], "w");
if (fp != NULL) fclose(fp);
return 0;
}
2. 使用实例:
4. 环境变量
4.1 基本概念
1. 问题引入:
- 为什么我们在执行系统指令时,不用加路径,直接就能运行,而刚刚我们自己实现的
mytouch
却需要跟上路径? - 执行一个指令的前提,是必须找到这个可执行程序的位置。所以我们在执行
mytouch
时,需要跟上./
,告诉系统这个可执行程序在哪。那么系统指令可以不跟路径的原因,只能是它有自己默认的搜索路径。
2. 环境变量PATH:
- Linux中有很多环境变量,上面我们提到的默认搜索路径,就是环境变量
PATH
,我们可以通过指令echo $PATH
来打印这个环境的内容。
- 以
:
为分割符,有多个默认搜索路径。
- 以
3. 小实验:将mytouch添加进环境变量PATH
- 注意:不要直接写
PATH=/home/LHY/test/test_2024/test_6_24
,这样会将之前默认的路径全覆盖掉。 - 默认更改环境变量,只限于本次登录,重新登录,环境变量自动被恢复(为什么?之后再说)。
4. 概念:
- 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数;
- 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找;
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
4.2 常见的环境变量
1. 常见环境变量:
PATH
: 指定命令的搜索路径;HOME
: 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录);SHELL
: 当前Shell
,它的值通常是/bin/bash
;PWD
:当前路径。
2. 查看这些环境变量:
3. 思考:为什么用户在登录的时候,默认就是在自己的家目录下?
- 登录时,默认会进行如下操作。
- 输入用户名&&密码;
- 认证;
- 形成环境变量(不止一个,有
PATH
,PWD
,HOME
等); - 根据用户名,初始化
HOME
; - 执行指令,
cd $HOME
。
4. 使用指令env,查看所有环境变量:
- 其中有一个环境变量
USER
要注意,它记录的是你登录这个系统时,使用的身份。如果你是以某一个普通用户登录的,那么USER
就一直是这个普通用户名,就算你之后切换为root
身份,也只是权限变了,USER
不会改变。 - 使用
su -
可以切换登入身份为root
;使用su 普通用户名
可以切换登入身份为普通用户;但使用su/su root
只能提权,无法切换登入身份为root
。
4.3 通过代码获取环境变量
1. 通过getenv()获取环境变量:
- 查看手册。
- 参数是环境变量名,返回值是环境变量的值。
- 使用实例:设计一个只有登入用户是
root
才能使用的功能:
2. 通过main函数的第三个参数,获取环境变量:
- 系统在启动程序时,可以选择给进程(
main
)提供两张表:- 命令行参数表;
- 环境变量表。
#include<stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for (; env[i]; i++)
{
printf("%d: %s\n", i, env[i]);
}
return 0;
}
- 编译并运行:
[LHY@localhost test_6_27]$ ./mypro
0: XDG_SESSION_ID=3
1: HOSTNAME=localhost.localdomain
2: TERM=xterm
3: SHELL=/bin/bash
4: HISTSIZE=1000
5: OLDPWD=/home/LHY/test/test_2024
6: USER=LHY
7: JRE_HOME=/usr/local/soft/java/jdk1.8.0_40/jre
8: LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=01;05;37;41:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.axv=01;35:*.anx=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=01;36:*.au=01;36:*.flac=01;36:*.mid=01;36:*.midi=01;36:*.mka=01;36:*.mp3=01;36:*.mpc=01;36:*.ogg=01;36:*.ra=01;36:*.wav=01;36:*.axa=01;36:*.oga=01;36:*.spx=01;36:*.xspf=01;36:
9: LD_LIBRARY_PATH=:/root/.VimForCpp/vim/bundle/YCM.so/el7.x86_64:/home/LHY/.VimForCpp/vim/bundle/YCM.so/el7.x86_64
10: PATH=/usr/local/soft/java/jdk1.8.0_40/bin:/usr/local/soft/java/jdk1.8.0_40/jre/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin
11: MAIL=/var/spool/mail/root
12: PWD=/home/LHY/test/test_2024/test_6_27
13: JAVA_HOME=/usr/local/soft/java/jdk1.8.0_40
14: LANG=en_US.UTF-8
15: HISTCONTROL=ignoredups
16: HOME=/home/LHY
17: SHLVL=2
18: LOGNAME=LHY
19: CLASSPATH=/usr/local/soft/java/jdk1.8.0_40/lib:/usr/local/soft/java/jdk1.8.0_40/jre/lib:
20: LESSOPEN=||/usr/bin/lesspipe.sh %s
21: XDG_RUNTIME_DIR=/run/user/0
22: DISPLAY=localhost:10.0
23: _=./mypro
- 不难发现,这种方式得到的环境变量,和直接使用
env
指令得到的环境变量基本相同; env[]
数组的最后一个位置值为NULL
。
3. 通过environ指针获取环境变量:
- 系统中默认给我们提供了一个全局变量
char **environ
,它指向上面提过的环境变量表。
4.4 环境变量的全局性
1. 为什么每次用户登入时,环境变量会恢复默认值?
- 命令行启动的进程都是
shell/bash
的子进程,子进程的命令行参数和环境变量,是父进程bash
给我们传递的。之前讲过,直接更改PATH
是更改内存中环境变量的信息,实际上我们直接更改的就是bash
进程内部的环境变量信息(进程在被调度时要加载进内存)。每一次重新登入,都会给我们形成新的bash
解释器,并且新的bash
解释器会自动从配置文件中读取信息,进而形成自己的环境变量表信息。 - 这个配置文件,在用户家目录下,文件名为
.bash_profile
。
- 看一眼这个文件中的内容:
- 文件中的内容是一种脚本语言,我们暂时不关注。我们关心的结论就是,每一次用户登入时,
bash
进程都会读取用户家目录下.bash_profile
配置文件中的内容,为我们bash
进程形成一张环境变量表信息。
2. 验证上面的结论:
- 先导入两个环境变量,问:再次登入时,这两个环境变量还在不在?
- 不在,因为这个导入操作实际上是在内存中,也就是
bash
进程内部进行的,进程退出,变量就丢失了。 - 要想让自己设置的环境变量永久存在,可以直接修改
.bash_profile
配置文件。
- 再次登入时,
MYENV
自动导入环境变量。
3. 环境变量的全局性:
- 所有在命令行上启动的进程都是
bash
的子进程,子进程大多都是通过fork
创建的,和父进程共享代码和数据。之前说过,全局变量表可以通过environ
指针来获取,这个变量是bash
进程中定义的一个全局变量。所以所有bash
的子进程都可以直接拿到这个environ
指针。 - 同理,
bash
子进程,fork
出来的子进程,和bash
的子进程共享代码和数据,它也可以直接拿到environ
指针,由此可见,环境变量是具有全局性的。
4.5 本地变量VS环境变量
1. 本地变量和环境变量:
- 本地进程只在
bash
进程内部有效,不会被子进程继承下去; - 环境变量通过让所有子进程继承的方式,实现自身的全局性。
2. Linux命令的分类:
- 我们将
PATH
环境变量置空,ls\touch
这些命令都跑不起来了,这是正常现象,可是为什么echo
这个命令还可以跑?- 常规命令:
shell
通过fork
创建子进程,然后让子进程执行; - 内建命令:
shell
命令行中的一个函数。
- 常规命令:
ls\touch
就是常规命令,现在找不到目标可执行程序了,shell
就无法fork
子进程,自然就无法执行;echo
是内建命令,当然可以直接读取shell
内部定义的本地变量。
4.6 和环境变量相关的指令
echo
:显示某个环境变量值;export
:设置一个新的环境变量;env
:显示所有环境变量;unset
:清除环境变量;set
:显示本地定义的shell
变量和环境变量;
5. 程序地址空间
5.1 程序地址空间回顾
1. 回顾C语言中讲过的程序地址空间分布:
- 代码验证:
#include<stdio.h>
#include<stdlib.h>
int un_gval; // 未初始化的全局变量
int init_gval = 100; // 初始化的全局变量
int main()
{
printf("code addr: %p\n", main); // 代码区
const char *str = "hello Linux";
printf("read only char addr: %p\n", str); // 字符常量区
printf("init global value addr: %p\n", &init_gval);
printf("uninit global value addr: %p\n", &un_gval);
char *heap1 = (char*)malloc(100);
char *heap2 = (char*)malloc(100);
char *heap3 = (char*)malloc(100);
char *heap4 = (char*)malloc(100);
printf("heap1 addr: %p\n", heap1); // 堆区
printf("heap2 addr: %p\n", heap2); // 堆区
printf("heap3 addr: %p\n", heap3); // 堆区
printf("heap4 addr: %p\n", heap4); // 堆区
printf("stack1 addr: %p\n", &str); // 栈区
printf("stack2 addr: %p\n", &heap1); // 栈区
printf("stack3 addr: %p\n", &heap2); // 栈区
printf("stack4 addr: %p\n", &heap3); // 栈区
printf("stack5 addr: %p\n", &heap4); // 栈区
return 0;
}
2. 补充:存储命令行参数表和环境变量表的区域,在栈区之上:
- 代码验证:
#include<stdio.h>
#include<stdlib.h>
int un_gval; // 未初始化的全局变量
int init_gval = 100; // 初始化的全局变量
int main(int argc, char *argv[], char *env[])
{
printf("code addr: %p\n", main); // 代码区
const char *str = "hello Linux";
printf("read only char addr: %p\n", str); // 字符常量区
printf("init global value addr: %p\n", &init_gval);
printf("uninit global value addr: %p\n", &un_gval);
char *heap1 = (char*)malloc(100);
char *heap2 = (char*)malloc(100);
char *heap3 = (char*)malloc(100);
char *heap4 = (char*)malloc(100);
printf("heap1 addr: %p\n", heap1); // 堆区
printf("heap2 addr: %p\n", heap2); // 堆区
printf("heap3 addr: %p\n", heap3); // 堆区
printf("heap4 addr: %p\n", heap4); // 堆区
printf("stack1 addr: %p\n", &str); // 栈区
printf("stack2 addr: %p\n", &heap1); // 栈区
printf("stack3 addr: %p\n", &heap2); // 栈区
printf("stack4 addr: %p\n", &heap3); // 栈区
printf("stack5 addr: %p\n", &heap4); // 栈区
int i = 0;
for(; argv[i]; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for(i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
5.2 页表&虚拟地址空间
1. 问题引入:
- 写一段代码,子进程在五秒后改变全局变量
g_val
。
#include<stdio.h>
#include<unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while(1)
{
printf("child, Pid: %d, Ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
if (cnt == 0)
{
g_val = 200;
printf("child change g_val: 100->200\n");
}
cnt--;
}
}
else
{
// father
while(1)
{
printf("father, Pid: %d, Ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
- 怎么可能对同一个地址进行读取,却读出了不同的值?由此我们可以得出一个推论,我们在C/C++中看到的地址,一定不是物理地址。
- 上面我们所学的程序地址空间,也叫进程地址空间,它不是真实的物理地址,而是虚拟地址。
2. 虚拟地址&物理地址
- 每一个进程运行之后,都会有一个自己的进程地址空间。我们上面看到的
0x60105c
是一个虚拟地址,它通过页表映射到真实的物理地址上。 fork
之后,创建了子进程,它也有自己的进程地址空间,和自己的页表,并且都是拷贝父进程的。
- 当子进程或父进程对
g_val
进行修改时,会发生写时拷贝。这个写时拷贝是发生在物理内存中的,并且页表右侧物理地址改变,即改变映射关系,但左侧虚拟地址不变。
注意:
- 写时拷贝在物理内存中完成;
- 写时拷贝的操作由操作系统完成;
- 该操作不影响上层语言。
3. 页表(可以先看5.3的1、2)
-
页表中比较重要的字段有四个,前两个是虚拟地址和物理地址。后两个是访问权限字段,和表示是否给虚拟地址分配空间、虚拟地址是否有内容的字段。
-
访问权限字段:如果代码中,出现修改常量字符串的操作,会在页表处就进行拦截。
-
标记是否分配空间&是否有内容的字段:模拟一次内存分配,假如现在执行代码
int a = 1;
,编译器会先访问到a
的虚拟地址,但是此时a
还未定义,也就是该字段为00(假设00表示未分配,没有内容),操作系统就会先暂停对该虚拟地址的访问,随后进行物理内存的分配,初始化,然后再修改页表,将映射的物理地址和该标记字段修改一下——这个过程也叫缺页中断。
-
#include<stdio.h>
int main()
{
char *str = "hello Linux";
*str = 'H'; // 字符常量区不可被修改,为什么?
return 0;
}
- 磁盘中的可执行程序加载到内存中后,物理地址是无序的,数据放在内存中的什么位置都可以。因为大家的虚拟地址是统一的,而虚拟地址是有序的,虚拟地址到物理地址的映射是唯一的。
- 一个进程要想执行必须加载到内存中,CPU中有一个专门的寄存器CR3用来存储该进程对应的页表地址(这个地址是物理地址),
task_struct
中保存该寄存器的信息。 - 有些可执行程序可能非常大,而32位机器的实际物理内存只有4G,比较小,可执行程序无法一次性加载进内存。所以可执行程序可以分成一小段一小段,用局部加载的方式加载进内存。
5.3 深入理解地址空间
1. 地址空间的大小即概念:
- 计算机内各个设备之间,要通过线来连接。连接外设和内存的线,叫IO总线;连接CPU和内存的线,叫系统总线。
- 系统总线又分为地址总线和控制总线。在32位机器中,地址总线就有32根,一共可以表示232个地址,每个地址存储一个字节,所以32位地址总线可寻址的地址空间大小为4GB。
- 每一个进程都有自己对应的虚拟地址空间,他们对外宣称的大小都是4GB。但是每个进程所能利用的真实地址空间,大小肯定不是4GB,因为真实的物理地址空间一共就这么大(虚拟地址就是操作系统给进程画的一张饼)。
2. 如何管理地址空间?
- 每一个进程都有地址空间,系统中,一定要对地址空间做管理。
- 先描述,再组织。地址空间在操作系统中,就是一个内核数据结构对象,一个内核结构体。
- 在Linux中,这个结构体是(下面这个只是一个非常简单的示例,真实的
mm_struct
更加复杂):
struct mm_struct
{
...
long code_start; // 代码区起始位置
long code_end; // 代码区结束位置
long data_start;
long data_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
...
}
- PCB中会有一个
struct mm_struct *mm
指针,指向这个结构体对象。
3. 为什么要有地址空间?(先学习页表)
- 让进程以统一的视角看待内存。任意一个进程,可以通过地址空间+页表的方式,将乱序的内存数据变成有序,分门别类的规划好;
- 可以有效的进行进程访问内存的安全性检查;
- 将进程管理和内存管理进行解耦;
- 通过页表,让进程映射到不同的物理内存处,从而实现进程的独立性。
4. 子区域划分:
- 4G的虚拟空间中,用户能使用的只有3G,还有1G是内核空间,是留给操作系统的(之后细讲)。
- 在3G的用户空间中,可能会存在一些零碎的空间难以利用。比如在堆区开辟了一块区域,紧接着又开辟了一块,随后将最先开辟的那一块空间释放了,那么这一块空间就会零散的空出来。
- 为了更加有效的利用这些空间,
mm_struct
结构体中还定义了一个链表结构mmap
,其中每一个节点是一个vm_area_struct
结构体,描述一小块空间。 - 真实的进程地址空间是由内存描述符
mm_struct
,和线性空间vm_area_struct
组成的。