初识Linux 进程:进程创建、终止与进程地址空间
目录
0.写在前面
1.进程创建
fork():
exec():
2.进程地址空间
3.进程终止
正常终止(主动退出)
异常终止(被动终止)
0.写在前面
本文将对Linux环境下的进程:包括进程创建、终止与进程等待、替换进行讲解,作者使用XShell连接配置为CentOs 7.6的主机进行演示,希望能帮助你更好理解操作系统的运行原理!
1.进程创建
什么是进程创建?怎么来创建进程?和别的进程有什么关系?别着急,这些问题需要慢慢回答。首先是进程创建的概念:
进程创建的本质是为程序的运行构建独立的执行环境,包括:分配内存空间;创建进程控制块(PCB)......
重点在于进程如何创建!:Linux 中进程创建过两个关键系统调用完成:
fork():
先来看看man-pages关于fork的介绍:
fork()
的作用是创建父进程的副本(子进程)。内核会为子进程分配新的PCB (task_struct),并复制父进程的大部分资源(如内存页表、文件描述符、信号处理等),并且采用写时拷贝优化:
写时复制:子进程与父进程初始时共享同一块物理内存(仅复制页表,不复制代码和数据)。只有当父进程或子进程尝试修改内存时,才会为修改的部分分配新的物理内存并复制数据。这大幅减少了内存占用和创建时间。
再来看看返回值:
如果成功创建子进程会在父进程返回PID,子进程本身返回0,如果创建失败,父进程返回-1,子进程没有,errno出现( 父子进程/多个子进程的运行顺序由调度器决定,不同的平台下不同)
了解了返回值,我们可以根据pid_t类型的fork返回值区分出哪一个是父进程哪一个是子进程,但是既然说创建子进程会直接沿用父进程的代码和数据,并进行写时拷贝,这里能衍生出四个问题:
1. 为什么fork要给子进程返回0,给父进程返回子进程pid?
- 对父进程而言,一个进程可能有多个子进程,没有函数能直接获取所有子进程的PID,fork返回子进程PID便于父进程识别和管理子进程。
- 对子进程而言,它可通过getpid()获取自身PID,fork返回0使其能轻松与父进程区分(进程ID 0由系统特殊使用,子进程PID不可能为0)。2. fork干了什么?
fork创建一个新进程(子进程),子进程是父进程的副本:
- 共享代码段,也就是说代码是共享的,但子进程拥有自己的PCB,且操作系统会拷贝父数据中子进程要修改的部分,(代码一般是共享的,页表和虚拟地址空间同样写时拷贝)父子进程互不干扰。(这属于数据层面的写时拷贝)- 为什么?进程之间是独立的,数据可能被修改,父子进程不能共用一份数据,所以数据必须拷贝一份
- 两进程从fork返回处继续执行,子进程继承父进程打开的文件描述符等资源。3. fork是如何返回两次的?
fork执行时,系统复制父进程的堆栈段,父进程和子进程都停留在fork函数中等待返回。 - 父进程中,fork返回子进程的PID
- 子进程中,fork返回0
补充:父子进程共用代码段,return语句在父子进程中都会执行4. 一个变量为什么会有不同的内容?
fork后,子进程获得父进程数据空间的独立副本,两者数据空间互不共享。 本质上发生了写时拷贝,操作系统重新开辟空间给子进程进行修改。看完前面内容,你可能觉得这个问题有点多余,但如果你去揪出两个进程中返回值的地址,你会发现是相同的。这就关乎进程地址空间了。
exec():
由于这一类系统调用的特殊,作者放到了进程替换中详细介绍~
2.进程地址空间
通常在编程时,提到的栈区堆区常量区之类的,并不是物理地址!如果是物理地址,那么地址相同的变量不可能对应不同值!那么这里可以得出的是栈区堆区保存在虚拟地址空间中,先让我们了解一下虚拟地址空间的结构,这里可以通过一个简单的程序验证一下:
int g_val_1;
int g_val_2=100;void test1()
{printf("code addr: %p\n",test1);//验证字符串常量区const char* str="hello bit";printf("read only string addr: %p\n",str);//验证已初始化的全局变量printf("init global value addr: %p\n",&g_val_2);//验证未初始化的全局变量printf("uninit global value addr: %p\n",&g_val_1);//验证堆区的地址char* mem=(char*)malloc(100);printf("heap addr: %p\n",mem);printf("stack addr: %p\n",&str);printf("stack addr: %p\n",&mem);//验证栈区的地址int a;int b;int c;printf("stack addr: %p\n",&a);printf("stack addr: %p\n",&b);printf("stack addr: %p\n",&c);
}
通过简单的编译命令,我们执行可执行文件查看一下执行结果:
啊哈,这就验证了栈区向下增长,堆区向上增长的结论!直接给出虚拟地址空间的架构:
虚拟地址空间通过页表来对应真实的物理地址:
下面我们来一睹mm_struct的真容:mmstruct部分代码:
struct mm_struct
{ unsigned long total_vm, locked_vm, shared_vm, exec_vm;unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack; ......
}
存在虚拟地址空间的意义:
1.让进程以统一的视角看待内存,不用直接管理物理内存上的存储空间与地址;
2.增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,可以对寻址请求审查,拦截异常访问,保护物理内存;
3.因为有地址空间和页表的存在,将进程管理模块,和内存管理模块进行解耦合;
再来补充一下页表:
保存在哪里?页表的物理地址(其实是页表的真实地址)临时存储在页目录基址寄存器(如 x86 的 CR3)上面,当进程切换时会被当作上下文保存,页表记录虚拟地址对应的物理地址,有权限标志位参与权限管理,在每一个虚拟地址对应物理地址的同时加上读写权限,那么尝试写入常量区时会被页表发现,还有标志位来判定代码和数据是否以已经被加载到内存中,由于操作系统对大文件采用惰性加载的方式:推迟非必要资源的加载或初始化,直到实际需要时再执行,数据并不会全部加载到内存,此时有了虚拟地址但物理地址是空着的,标识为0,触发缺页中断,从磁盘中将数据加载到内存有了物理地址再补全映射关系。
3.进程终止
这里我们就把写出来的一个程序当作进程,进程终止的触发场景可分为正常终止和异常终止两类:
正常终止(主动退出)
进程因完成任务或主动请求结束,常见方式包括:
- main() 函数返回:程序执行到main()函数末尾,返回一个整数值(如return 0),等价于调用
exit(返回值);
- 调用exit()函数:标准库函数exit(返回码)会执行清理操作(如刷新 IO 缓冲区);
- 调用_exit()系统调用:直接终止进程(不执行清理,不刷新缓冲区),将退出状态传递给内核(exit()最终会调用_exit());
但是正常终止分为两种情况:1.代码执行完毕,结果正确;2.代码执行完毕,结果错误;
父进程会根据子进程main函数的退出码(return的返回值)来判断代码的运行情况:
bash就是父进程,我们的程序运行时就相当于子进程,那么程序中main函数的返回码对bash来说至关重要!
1.返回码为0,运行成功;
2.返回码不为0,不同的返回码对应不同的情况;
| | 让我们来看看C语言中不同的返回码都可以携带哪些信息:
int test2()
{int i=0;for(i=0;i<60;i++){printf("%s\n",strerror(i));//退出码和退出码的描述具有对应关系,再通过strerror来将错误打印出来。//在C语言中,有errno这样的全局变量来保存最近一次的错误码,//可以用perror/strerror来打印错误信息}return 2;//这里故意返回的2,来看看返回码的运用}
补充:如何来查看退出码呢?在命令行界面,用echo $?就可以查看上一个程序的退出码;在程序中可以使用wait/waitpid来获取子进程的退出码(由exit返回),作者放到进程等待讲解!
哈哈,return 2的作用在这里!
异常终止(被动终止)
进程因外部干预或内部错误被迫终止,常见场景包括:
- 收到终止信号:如用户通过
kill
-9 命令发送SIGKILL,
这里的kill命令本质上就是一种信号!