目录
1. 命令行参数 int main(int argc, char* argv[])
1.1 命令行参数的使用
2. 环境变量
2.1 环境变量概念
2.2 常见环境变量
2.3 环境变量相关命令(增删查改,只对当前bash生效)
2.4 环境变量的组织方式、存放位置
2.5 通过代码获取环境变量(main函数第三个参数-环境变量表)
2.6 环境变量的全局属性、本地变量、内建命令
3. 程序地址空间->进程的虚拟地址空间
3.1 进程的虚拟地址空间 概念
3.2 虚拟内存机制证明
3.3 一个进程有一个虚拟地址空间+页表+写时拷贝
3.4 虚拟地址空间&物理地址空间-理解+区域划分(概括+举例) [重重重点]
3.5 进程运行时虚拟地址空间的开辟 及 与物理地址空间的映射
3.6 为什么要有虚拟地址空间?
3.7 澄清一些问题
4. 理解几个概念
1. mm_struct 是什么
2. 页表是什么
3. 虚拟地址怎么映射到物理地址
4. 为什么要用多级页表
5. 什么是缺页中断
6. 缺页不一定表示程序出错
7. 把几个概念串起来
8. 页表权限和缺页中断的联系
1. 命令行参数 int main(int argc, char* argv[])
1.1 命令行参数的使用
我们写的main函数也有参数,其中
argc:是输入的以空格为分隔符的字符串个数,系统识别后自动赋值。
argv:是以空格为分隔符的字符串数组。用户输入。
main函数的命令行参数,是实现程序不同子功能的方法!!指令选项的实现原理。
总结:进程拥有一张表,argv表,用来支持实现选项功能。
举例1:argv是字符指针数组,接收命令行参数
#include<stdio.h> int main(int argc, char* argv[]) { for(int i = 0; i < argc; i++) { printf("argv[%d]:%s\n",i,argv[i]); } return 0; }./code # argv[0]:./code ./code a b c d # ./code也算字符串 # argv[0]:./code # argv[1]:a # argv[2]:b # argv[3]:c # argv[4]:d像我们的ls,cp等命令行指令,也是通过这种方法实现的。
举例2:解释ls,cp等指令的参数功能实现
#include<stdio.h> #include<string.h> int main(int argc, char* argv[]) { if(argc != 2) { printf("Usage: %s [-a|-b|-c]\n",argv[0]); return 1; } const char *arg = argv[1]; if(strcmp(arg,"-a") == 0) printf("这是功能a\n"); else if(strcmp(arg,"-b") == 0) printf("这是功能b\n"); else if(strcmp(arg,"-c") == 0) printf("这是功能c\n"); else printf("Usage: %s [-a|-b|-c]\n",argv[0]); return 0; }./code # Usage: ./code [-a|-b|-c] ./code -a # 这是功能a ./code -b # 这是功能b ./code -c # 这是功能c ./code -d # Usage: ./code [-a|-b|-c]2. 环境变量
2.1 环境变量概念
引言:为什么系统的指令不需要带“./”,而我们自己的程序需要"./"才能执行?
答:系统中存在环境变量!要执行一个程序,必须先找到他。系统的PATH 环境变量来帮助系统找到二进制文件。(当然系统中还有其他各种环境变量)
环境变量(environment variables):一般是指在操作系统中用来指定操作系统运行环境的一些参数。(参数被bash使用,也就是间接被用户使用了)
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
举例:
问题1:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
2.2 常见环境变量
•HOSTNAME: 主机名
•SHELL: 当前Shell,它的值通常是/bin/bash。
•HISTSIZE: bash记录历史命令条数的最大值(history可以查看历史命令)
•SSH_CLIENT: 公网IP
•OLD_PWD: 前一个工作目录(cd -)
•SSH_TTY: 当前设备 /dev/pts/0
•USER: 当前用户
•PATH: 指定命令的搜索路径(比如我们写的程序执行要使用./code,而系统命令比如ls不需要“ls”,因为ls的路径在PATH的环境变量中)
•PWD: 当前工作目录
•HOME: 指定用户的主工作目录(用户家目录)(cd ~可以直接进入家目录)
• LOGNAME: 当前登录的用户(su不改变USER和LOGNAME,su -改变,即su -是退出重新登陆了)
2.3 环境变量相关命令(增删查改,只对当前bash生效)
每当我们打开一个终端(终端也是一个进程),终端会在系统中继承一份环境变量表。终端进程启动bash,bash在终端进程中继承了环境变量表,并且读取.bashrc和.bash_profile等配置文件中的内容,修改并整合刚刚从终端继承的环境变量表,从而形成自己最终要使用的“完整环境变量表”。而以下我们可以在bash终端中执行的对环境变量的操作,都是对bash环境变量表的修改。当新开一个bash进程时,bash还是重新继承环境变量表,所以我们在bash中对环境变量表的操作都是临时性的。
如果要永久性修改环境变量,需要修改./bashrc或./bash_profile文件
| 指令 | 功能 | 举例 |
| 查看环境变量 | ||
| env | 显示所有环境变量 | env |
| echo $NAME | 显示某个环境变量值 | echo $PATH |
| set | 显示本地定义的shell变量和环境变量 | |
| 新增环境变量给bash | ||
| export NAME=内容 | 设置一个新的环境变量,当前bash生效 | export MYENV1=111 env查看 |
| 删除环境变量 | ||
| unset NAME | 清除环境变量(当前bash生效) | unset MYENV1 env查看 |
| 修改环境变量 | ||
| NAME=内容 | 修改环境变量内容为指定内容 | |
| NAME=$NAME:内容 | 添加指定内容到环境变量中(当前bash生效) | 见下面代码 |
# 修改PATH环境变量为自己的路径 PATH=$PATH:/home/gyy/linux-system-programming/lesson15 echo $PATH # 给PATH环境变量添加自己的路径 PATH=$PATH:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/gyy/.local/bin:/home/gyy/bin:/home/gyy/linux-system-programming/lesson152.4 环境变量的组织方式、存放位置
1. bash为什么能根据环境变量执行指令?(PATH环境变量)
每当我们打开一个终端(终端也是一个进程),终端会在系统中继承一份环境变量表。终端进程启动bash,bash在终端进程中继承了环境变量表,并且读取.bashrc和.bash_profile等配置文件中的内容,修改并整合刚刚从终端继承的环境变量表,从而形成自己最终要使用的“完整环境变量表”。(环境表量表也是一个字符指针数组,存放每个环境变量的字符串)
指令执行流程举例:ls -a -l
1. 命令行参数表拆分命令
2. bash根据命令在环境变量表找到PATH,从PATH中获取ls程序的路径,执行ls -a -l指令。
2. 环境变量的存放位置、存放方式?
系统的相关配置文件:.bash_profile,.bashrc(在用户家目录里面的隐藏文件)
bash程序启动时继承终端环境变量表并整合.bash_profile,.bash_profile配置文件的内容形成完整的环境变量表。
bash中环境变量的存放方式,环境变量表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
3. 如果Linux系统有10个用户登录呢?
存在10个bash,每个bash都要从系统的相关配置文件中读取环境变量形成环境变量表和命令行参数表。
2.5 通过代码获取环境变量(main函数第三个参数-环境变量表)
1. main函数参数获取环境变量表
main函数的第三个参数env,env是获取的父进程的环境变量,当前程序的父进程就是bash
#include<stdio.h> #include<string.h> // main最多有几个参数?3个 // env参数是父进程传给我们的 int main(int argc, char *argv[], char *env[]) { (void)argc; (void)argv; // 打印环境变量表 for(int i = 0; env[i]; i++) { printf("env[%d]-> %s\n", i, env[i]); } return 0; }环境变量可以被子进程继承,只要是bash的子孙进程,都可以拿到bash的环境变量,即环境变量在系统中通常具有全局特性。
为什么要被子孙进程继承环境变量?子孙进程拿到bash的环境变量后可以结合环境变量做个性化操作,比如:定制一个只能让指定用户执行的程序。(getenv获取USER,指定的USER才能执行对应的程序)
2. getenv函数获取环境变量
getenv头文件<stdlib.h>
getenv("NAME"); //返回对应名称环境变量的内容字符串#include<stdio.h> #include<string.h> #include<stdlib.h> // main最多有几个参数?3个 // env参数是父进程传给我们的 int main(int argc, char *argv[], char *env[]) { (void)argc; (void)argv; (void)env; char *value = getenv("PATH"); if(value == NULL) return 1; printf("PATH->%s\n",value); return 0; }3. environ获取环境变量表
environ的头文件<unistd.h>,使用时需要声明全局变量。
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> extern char **environ; // main最多有几个参数?3个 // env参数是父进程传给我们的 int main(int argc, char *argv[]) { (void)argc; (void)argv; // 打印环境变量表 for(int i = 0; environ[i]; i++) { printf("environ[%d]-> %s\n", i, environ[i]); } return 0; }注意:推荐使用第二种方法,只获取某个环境变量的内容。
2.6 环境变量的全局属性、本地变量、内建命令
• 环境变量通常具有全局属性,可以被子进程继承下去
• 本地变量:不会被子进程继承,只能在bash内部被调用。
• 内建命令(built-in command):不需要创建子进程,而是让bash自己亲自执行。bash自己调用函数,或者系统调用。例如:export
举例:当使用以下命令之后修改当前bash的PATH环境变量时,还有一些指令可以使用,这些就是内建命令。
PATH=/home/gyy/linux-system-programming/lesson153. 程序地址空间->进程的虚拟地址空间
3.1 进程的虚拟地址空间 概念
我们之前所说的程序地址空间其实并不应该叫做程序地址空间,应该叫做进程地址空间 或 进程的虚拟地址空间,这是一个操作系统层级的概念,不是语言层的概念。之前讲程序地址空间只是为了好理解C语言中变量的存储位置,实际中不止我们的程序代码和变量存在这个位置,还包含了系统注入的信息(如环境变量、命令行参数)以及动态链接库等。
为什么又叫做虚拟地址空间呢?它之所以是‘虚拟’的,是因为操作系统通过虚拟内存机制,为每个进程提供了一个独立、统一的地址空间幻觉,并将其地址映射到物理内存甚至硬盘上,实现了内存管理和保护。
由下列代码可见,环境变量(environ)和命令行参数(argv, argc)就存放在栈区的最高地址区域。
注意:堆区地址由低地址向高地址增长,栈区由高地址向低地址增长
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_unval; int g_val = 100; int main(int argc, char* argv[], char* env[]) { const char* str = "helloworld"; printf("code addr: %p\n", main); printf("init global addr: %p\n", &g_val); printf("uninit global addr: %p\n", &g_unval); static int test = 10; char* heap_mem = (char*)malloc(10); char* heap_mem1 = (char*)malloc(10); char* heap_mem2 = (char*)malloc(10); char* heap_mem3 = (char*)malloc(10); printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1) printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1) printf("read only string addr: %p\n", str); for (int i = 0; i < argc; i++) { printf("argv[%d]: %p\n", i, argv[i]); } for (int i = 0; env[i]; i++) { printf("env[%d]: %p\n", i, env[i]); } return 0; }code addr: 0x40057d init global addr: 0x60103c uninit global addr: 0x601048 heap addr: 0x148a010 heap addr: 0x148a030 heap addr: 0x148a050 heap addr: 0x148a070 test static addr: 0x601040 stack addr: 0x7fffa66d3cb8 stack addr: 0x7fffa66d3cb0 stack addr: 0x7fffa66d3ca8 stack addr: 0x7fffa66d3ca0 read only string addr: 0x400820 argv[0]: 0x7fffa66d47e5 env[0]: 0x7fffa66d47ed env[1]: 0x7fffa66d4801 env[2]: 0x7fffa66d480f env[3]: 0x7fffa66d481a env[4]: 0x7fffa66d482a env[5]: 0x7fffa66d4838 env[6]: 0x7fffa66d485c env[7]: 0x7fffa66d486f env[8]: 0x7fffa66d4878 env[9]: 0x7fffa66d48bb env[10]: 0x7fffa66d4e57 env[11]: 0x7fffa66d4e70 env[12]: 0x7fffa66d4eca env[13]: 0x7fffa66d4efa env[14]: 0x7fffa66d4f0b env[15]: 0x7fffa66d4f22 env[16]: 0x7fffa66d4f2a env[17]: 0x7fffa66d4f39 env[18]: 0x7fffa66d4f45 env[19]: 0x7fffa66d4f7a env[20]: 0x7fffa66d4f9d env[21]: 0x7fffa66d4fbc env[22]: 0x7fffa66d4fc63.2 虚拟内存机制证明
• 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
• 但地址值是一样的,说明,该地址绝对不是物理地址!在Linux地址下,这种地址叫做虚拟地址。
• 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。OS必须负责将虚拟地址转化成物理地址。
#include <stdio.h> #include <unistd.h> int gval = 100; int main() { pid_t id = fork(); if(id == 0) { while(1) { printf("子: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid()); sleep(1); gval++; } } else { while(1) { printf("父: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid()); sleep(1); } } return 0; }子: gval: 100, &gval: 0x601054, pid: 9907, ppid: 9906 父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013 子: gval: 101, &gval: 0x601054, pid: 9907, ppid: 9906 父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013 子: gval: 102, &gval: 0x601054, pid: 9907, ppid: 9906 父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013 子: gval: 103, &gval: 0x601054, pid: 9907, ppid: 9906 父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 9013 子: gval: 104, &gval: 0x601054, pid: 9907, ppid: 9906 父: gval: 100, &gval: 0x601054, pid: 9906, ppid: 90133.3 一个进程有一个虚拟地址空间+页表+写时拷贝
虚拟地址空间总共4GB,[0,3]GB为用户空间,[3,4]GB为内核空间。
1. 代码中取地址如何找到完整变量?
虚拟地址空间的宽度是1字节,而例如一个整型g_val占四个字节,有四个虚拟地址,我们取地址时拿到的是起始虚拟地址(地址数值最小)的那个然后根据变量类型的偏移量就能找到完整的变量。
2. 页表概念
每个进程有一套“页表”,用来做虚拟地址空间和物理地址空间的映射关系,一个虚拟地址对应一个物理地址。
3. 写时拷贝
因为一个进程有一个虚拟地址空间,所以我们使用fork创建的子进程也有自己的虚拟地址空间。子进程的task_struct(PCB)、虚拟地址空间、页表都是拷贝的父进程的。所以子进程的初始化数据区也有全局变量g_val地址。因为子进程拷贝了父进程的页表,页表中存放的虚拟地址和物理地址空间的映射关系也被拷贝了(浅拷贝),所以子进程和父进程中g_val的虚拟地址和物理地址都相同。所以在2.2中父子进程打印的g_val的地址是相同的(父子进程中g_val的虚拟地址相同)!
因为浅拷贝,所以代码也是共享的!
因为我们的进程具有独立性,所以在子进程对g_val修改时,操作系统会进行判断,如果要修改g_val,则会给子进程的g_val重新分配物理地址空间并修改页表(深拷贝)。到此,父子进程中g_val的虚拟地址相同,而各自映射的物理地址不同,所以父子进程对g_val变量的修改互不影响!!这也就是写时拷贝(不修改则先浅拷贝,修改则再深拷贝)!
再回看第五篇博客的3.1.6,fork的两个问题,id和gval都有写时拷贝机制!!!
3.4 虚拟地址空间&物理地址空间-理解+区域划分(概括+举例) [重重重点]
物理内存中完全没有代码区、堆区、栈区这些概念,物理内存只会给每个进程进行简单的分块。代码区、堆区、栈区都是虚拟地址空间的概念!!具体每个虚拟地址空间的布局是由操作系统和编译器在编译器决定的。进程只是在运行时,在这个已经规划好的布局内,动态地使用堆和栈等区域。
1. 理解-虚拟地址空间&物理地址空间(大富翁)
一个富翁有10亿$,有四个私生子,富翁给每个私生子承诺会把10亿$遗产继承给他们(私生子互不认识),所以每个人都会认为自己能继承到10亿$的遗产(画大饼)!
富翁:操作系统;
10亿$遗产:物理内存;
私生子:不同的进程;
大饼:虚拟地址空间;
即:让每个进程都认为自己有4G的物理内存(以32位操作系统为例),或者每一个进程都认为自己独占物理内存。
怎么管理大饼?→怎么管理虚拟地址空间?
先描述,再组织!虚拟地址空间本质时一个数据结构:struct mm_struct{},每个进程的struct mm_struct{},包含各自虚拟地址空间的各种信息。
2. 虚拟地址空间的区域划分(38线)
小时候同桌a,b之间画38线,把课桌平分,将课桌的100cm每个厘米都标注出来(对桌子进行编址,其实桌子的划分就成了0-100的数字,所以可以用int整数来保存地址),a占0-50cm,b占50-100cm。即区域划分只需要确认区域的开始和结束即可!
桌子:地址空间
桌子的100cm:地址空间的2^32各个字节对应的地址
桌子上的刻度:地址空间上的地址
每个小朋友有各自对应的区域:正文代码区、初始化数据区、未初始化数据区、堆区等等。
根据每个区域的开始和结束地址划分地址空间!而struct mm_struct{}里面就保存了各个区域的起始和结束地址!(long code_start; long code_end; long init_start, init_end;……)
但是有一天a惹b生气了,b把a打了一顿,然后把38线调整,a:b=30:70。即为地址空间的区域调整!例:area.b_start -= 20; area.a_end -= 20;,a占0-30cm,b占30-100cm。
3.5 进程运行时虚拟地址空间的开辟 及 与物理地址空间的映射
流程:
1.【编译链接】规划蓝图:编译器与链接器在可执行文件中规定好虚拟地址空间的布局(如.text在0x08048000)。此时虚拟地址空间是“蓝图”。
2.【加载】创建虚拟空间并建立文件映射:执行程序时,OS为新进程创建mm_struct和页表。OS按“蓝图”在虚拟地址空间中划出区域(代码区、数据区等)并设置权限。OS将页表项设置为无效,并将其映射到磁盘上的可执行文件(而非物理内存)。至此,仍未分配任何物理内存。
3.【运行时】按需分配物理内存(由缺页中断驱动):
4.【动态管理】堆与栈的分配:malloc和函数调用最初也只在虚拟空间上操作(移动brk指针或扩展栈指针)。只有当真正读写这些新区域时,才会触发第3步的缺页中断流程,从而分配物理内存。对于堆,分配的是普通物理页;对于栈,可能映射到零页(Zero Page)。
虚拟地址空间<->物理地址空间 ,通过页表映射!(物理地址转化为虚拟地址),将虚拟地址空间提供给上层用户使用。我们的进程控制块 task_struct 中的 mm_struct 结构体,是用来管理进程虚拟地址空间的(包括虚拟地址空间中的代码段、数据段、堆、栈、共享库映射区等核心区域)!!!(第五章3.1进程的执行)(task_struct还有其他变量保存进程的其他信息)
虚拟地址空间是 mm_struct 结构体所管理和描述的那个“对象”
3.6 为什么要有虚拟地址空间?
用户访问的程序中的地址是连续的。而物理内存加载代码和数据的位置已经不重要了,无论代码和数据加载到物理内存的哪个位置,分散的还是聚集的,通过虚拟地址空间的映射,在我们用户看来,他们都是顺序存放的!
1.虚拟地址空间将地址从“无序”→“有序”。
2. 虚拟地址转换成物理地址。转换过程中,对地址和操作进行合法性、权限等判定,进而保护物理内存。(因为页表中不止存放了虚拟地址空间和物理地址空间的映射关系,还存放了物理地址空间权限的数据,用来保护物理内存)
a. 野指针:堆区没有分配内存,但是要访问野指针指向的地址,野指针指向的是虚拟内存地址,虚拟内存地址要映射到物理内存地址去访问,但由于没有开辟物理内存,所以就没有权限访问物理地址,进而保护物理内存。野指针访问失败,程序就可能崩溃。
b. char *str = "helloworld"; "str = 'H'; 修改常量字符串导致程序崩溃,原因?字符串常量存放在字符常量区(字符常量区在正文代码和初始化数据区之间,跟正文代码编在一块的),则字符常量区的页表权限是只读的,所以当我们对字符串常量进行修改的时候,程序会崩溃。(为什么在字符常量区写入会崩溃?查找页表的时候,权限拦截了!)(这是操作系统的概念,并不只针对某种编程语言。)
3. 让进程管理 和 内存管理,进行一定程度的解耦合。为了实现:安全性、可靠性、高效性和灵活性。(例如: 进程使用realloc时,只需要对mm_struct的对象修改,不需要对PCB修改,方便内存管理)
补:缺页中断,后面讲
3.7 澄清一些问题
1. 我们可以不加在代码和数据,只有tast_struct,mm_struct,页表
2. 创建进程,先有task_struct,mm_struct等,还是先加载 代码和数据?先有数据结构
3. 如何理解进程挂起?比如阻塞挂起:找到进程页表,将物理内存换出到磁盘,并将页表标记为无效。此时PCB和页表仍然存在。
4.虚拟内存管理:在虚拟地址空间中,堆区通常表现为一个连续的大区域(由一个主要的vm_area_struct描述)。malloc等内存分配器在这个连续的虚拟地址空间内进行管理。然而,支撑这个虚拟堆区的物理内存页框是不连续的、分散的。mm_struct通过管理一个由vm_area_struct组成的链表/红黑树,来管理整个虚拟地址空间中的所有区域(包括代码段、数据段、堆、栈以及每个通过mmap分配的独立内存块)
4. 理解几个概念
可以,这几个概念放在一起理解会更顺。
1.mm_struct是什么
mm_struct是 Linux 内核中用来描述一个进程虚拟地址空间的重要结构体。
大白话理解:
task_struct是进程的“总档案”,而mm_struct是其中专门负责记录“这个进程的内存怎么分布”的档案。
它里面会记录很多和内存有关的信息,比如:
代码段在哪里 数据段在哪里 堆在哪里 栈在哪里 页表在哪里 虚拟地址空间有哪些区域可以粗略理解成:
task_struct | ---> mm_struct | |-- 代码段 |-- 数据段 |-- 堆 |-- 栈 |-- 页表 |-- 内存映射区域所以一句话:
mm_struct就是 Linux 用来管理一个进程虚拟地址空间的核心结构。
2. 页表是什么
页表可以理解成:
虚拟地址到物理地址的“地址翻译表”。
程序使用的是虚拟地址,例如:
0x400000但内存条真正访问的是物理地址,因此 CPU 需要知道:
虚拟地址 ↓ 对应哪一块物理内存?这个映射关系就记录在页表中。
更准确地说,页表中的每一项叫:
PTE Page Table Entry 页表项一个页表项不仅保存:
这个虚拟页对应哪个物理页还会保存权限信息和状态信息。
可以理解成:
页表项 PTE │ ├── 地址信息 │ └── 对应哪个物理页 │ ├── 权限信息 │ ├── 能不能写 │ ├── 用户态能不能访问 │ └── 能不能执行 │ └── 状态信息 ├── 这一页是否在物理内存中 ├── 有没有被访问过 └── 有没有被修改过常见的信息例如:
| 信息 | 作用 |
|---|---|
Present | 这一页当前是否在物理内存中 |
Read/Write | 是否允许写 |
User/Supervisor | 用户态是否可以访问 |
NX | 是否禁止执行代码 |
Accessed | 这一页是否被访问过 |
Dirty | 这一页是否被修改过 |
比如一个代码页可能是:
可读:是 可写:否 可执行:是一个数据页可能是:
可读:是 可写:是 可执行:否所以可以记:
页表项 = 物理页地址 + 权限信息 + 状态信息。
3. 虚拟地址怎么映射到物理地址
现代操作系统不是一个字节一个字节地映射,而是按“页”进行管理。
常见页大小例如:
4KB假设程序访问:
虚拟地址:0x12345678CPU 会把它拆成:
虚拟页号 + 页内偏移然后:
虚拟地址 ↓ 找到属于哪个虚拟页 ↓ 查页表 ↓ 找到对应的物理页 ↓ 加上页内偏移 ↓ 得到最终物理地址例如:
虚拟地址 = 虚拟页 3 + 偏移 100页表告诉 CPU:
虚拟页 3 → 物理页 8最终访问:
物理页 8 + 偏移 100真正负责地址转换的是 CPU 中的:
MMU(Memory Management Unit,内存管理单元)
整体就是:
程序访问虚拟地址 ↓ MMU ↓ 查页表 ↓ 检查地址和权限 ↓ 找到对应物理页 ↓ 访问物理内存4. 为什么要用多级页表
如果所有虚拟地址都放在一张巨大的页表中,会非常占内存。
所以现代 Linux 一般采用多级页表。
可以理解成查目录:
一级目录 ↓ 二级目录 ↓ 三级目录 ↓ …… ↓ 找到最终页表项 PTE ↓ 找到物理页这样不用一次创建一张特别大的完整页表,可以节省大量内存。
5. 什么是缺页中断
如果程序访问一个虚拟地址,CPU 查页表时发现:
这个虚拟页当前没有有效的物理页映射CPU 就无法继续完成访问,于是会触发:
缺页异常 / 缺页中断(Page Fault)
大白话就是:
“程序要访问这块内存,但是现在页表处理不了,先交给操作系统看看怎么办。”
例如:
int* p = malloc(4096);malloc()之后,系统可能只是先给进程安排好了虚拟地址空间,并没有立刻分配真实物理页。
当真正执行:
*p = 10;可能出现:
访问虚拟地址 ↓ MMU 查页表 ↓ 发现没有对应物理页 ↓ 触发 Page Fault ↓ 进入内核 ↓ 内核判断地址是否合法 ↓ 合法 ↓ 分配物理页 ↓ 修改页表 ↓ 重新执行刚才的指令程序就可以继续运行。
6. 缺页不一定表示程序出错
缺页异常本身不一定是错误。
比如下面这些情况都可能正常触发 Page Fault:
malloc 后第一次真正访问 mmap 文件后第一次访问 写时拷贝 COW内核处理完以后,程序继续运行。
但如果访问的是非法地址:
int* p = NULL; *p = 10;流程可能变成:
触发 Page Fault ↓ 内核检查 ↓ 发现地址非法 ↓ 发送 SIGSEGV ↓ Segmentation fault7. 把几个概念串起来
可以直接记这一条:
task_struct ↓ mm_struct ↓ 描述整个进程的虚拟地址空间 ↓ 页表记录虚拟页 → 物理页的映射 并保存权限、状态等信息 ↓ MMU 根据页表进行地址转换和权限检查 ↓ 如果映射不存在或权限不允许 ↓ 可能触发 Page Fault ↓ 进入内核处理一句话总结:
mm_struct管整个进程的虚拟内存布局;页表负责记录虚拟页到物理页的映射,同时保存权限和状态信息;MMU 根据页表完成地址转换和权限检查;如果映射不存在或者访问不符合页表权限,就可能触发缺页异常,由内核进一步处理。
8. 页表权限和缺页中断的联系
有,而且联系非常直接。
可以这样理解:
CPU 查页表时,不仅看“这个虚拟页映射到哪个物理页”,还会顺便检查“你有没有权限这样访问”。如果权限不允许,也可能触发 Page Fault。
比如某个页表项规定:
可读:是 可写:否这时候程序去写:
*p = 10;CPU 会发现:
这个地址虽然有物理页映射 但是当前不允许写于是触发Page Fault,进入内核处理。
这正是写时拷贝 COW 能实现的关键之一。
父子进程刚fork()后,共享的数据页会暂时设成只读:
父进程 ─┐ ├──> 同一个物理页(只读) 子进程 ─┘如果子进程要写:
CPU检查页表权限 ↓ 发现不可写 ↓ 触发 Page Fault ↓ 内核发现这是 COW ↓ 复制一份物理页 ↓ 修改子进程页表 ↓ 把新页设成可写 ↓ 继续执行所以你可以记成:
缺页异常不只是“页不在内存”才会发生,访问权限不符合页表要求时也会触发。
一句话总结:
页表里的权限位是 CPU 判断访问是否合法的重要依据,权限不匹配时会触发 Page Fault,再由内核判断这是正常情况(如 COW)还是非法访问(如段错误)。