以下代码环境默认为 Linux Ubuntu 22.04.5 gcc C语言。
进程地址空间
Linux 进程地址空间分布
Linux 内核 2.6.32 版本 32 位平台,进程的地址空间大致分布图为:
在 (学习总结29)Linux 进程概念和进程状态 - 进程概念 中的父子进程写时拷贝演示中我们知道,进程的地址不是物理地址,而是虚拟地址。
这里来段代码测试一下具体的地址分布:
#include <stdio.h>
#include <stdlib.h>int g_num2;
int g_num1 = 100;int main(int argc, char* argv[], char* env[])
{const char* pStr = "Linux";printf("%p : 代码区\n", &main);printf("%p : 字符常量区\n", pStr);printf("%p : 初始化的全局区\n", &g_num1);printf("%p : 未初始化的全局区\n", &g_num2);static int num4; static int num3 = 0;printf("%p : 局部变量静态区\n", &num3);printf("%p : 局部变量静态区\n", &num4);char* ptr1 = (char*)malloc(sizeof(char) * 5); int* ptr2 = (int*)malloc(sizeof(int) * 5); printf("%p : 堆区\n", ptr1);printf("%p : 堆区\n", ptr2);int num1 = 0;int num2;int* ptr_int = NULL;printf("%p : 栈区\n", &num1);printf("%p : 栈区\n", &num2);printf("%p : 栈区\n", &ptr_int);for (int i = 0; i < argc; ++i){ printf("%p : 命令行参数区 argv[%d]\n", argv[i], i); } for (int i = 0; i < 5; ++i){ printf("%p : 环境变量区 env[%d]\n", env[i], i); } return 0;
}
事实上,我们从 C/C++ 语言所看到的地址,都是虚拟地址。物理地址用户看不到,由操作系统统一管理,而操作系统必须负责将 虚拟地址 转化成 物理地址。
虚拟地址空间和页表
进程创建时,会有一个虚拟地址空间和一套页表,而页表是用来做虚拟地址和物理地址映射的:
创建子进程与写时拷贝
使用 fork 函数创建子进程时,子进程会拷贝父进程的虚拟地址和页表,但为节约内存空间不会拷贝具体的数据,此时两者的虚拟地址就能通过一样的页表指向同一个资源。若子进程修改变量,操作系统会修改子进程的页表虚拟地址与物理地址的映射,将修改的变量数据放在另一个物理地址,正确映射关系:
这也是写时拷贝的基本原理。
权限访问
页表也不止映射功能,还可以查询操作具体数据的权限,若数据具有常量属性不可更改(如常量字符串),在访问时就会拒绝操作:
缺页中断
当内存空间不足,操作系统会将没有使用的进程代码和数据,临时的放入磁盘交换分区,对于新进程,不会将进程代码和数据加载到内存。此时页表对应的物理地址映射未知,并且访问资源时操作系统会检查是否加载到实际内存中。若操作的资源没有加载,操作系统会进行页表物理映射与加载资源:
Linux 虚拟内存管理
进程的虚拟地址空间和页表也会被操作系统管理起来,这就是所谓的虚拟内存管理。
mm_struct 内存描述符
描述 Linux 进程地址空间所有信息的结构体是 mm_struct(内存描述符)。每个进程只有一个 mm_struct 结构,在每个进程的 task_struct 结构中,有一个指向 mm_struct 的指针(Linux 内核 2.6.32):
可以说,mm_struct 结构是对整个用户空间的描述。每一个进程都会有自己独立的 mm_struct。
区域划分
mm_struct 还会对每个区域进行划分,只需要使用变量记录对应区域开始地址(start) 和结束地址(end) ,扩充或缩小区域使用加减即可:
vm_area_struct 独立虚拟内存区域
有些区域开辟空间并不是连续的。如堆空间每次申请并不是连续的,而是这里一堆那里一堆,如何记录呢?Linux 使用了 vm_area_struct 独立虚拟内存区域:
虚拟空间的组织方式有两种:
-
当虚拟区间较少时采取单链表,由 mmap 指针指向这个链表。
-
当虚拟区间较多时采取红黑树进行管理,由 mm_rb 指向这棵树。
Linux 内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(Virtual Memory Area),由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个 vm_area_struct 结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是 vm_area_struct 结构来连接各个 VMA,方便进程快速访问。
所以我们可以对之前的虚拟地址空间图再进行更细致的描述,如下图所示:
虚拟地址空间的作用
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
这种简单的内存分配策略问题很多:
-
安全风险:每个进程都可以访问任意的内存空间,意味着任意一个进程都能够去读写系统相关内存区域,中间没有检查管理机制。如果是一个木马病毒,那么它就能随意的修改内存空间,让设备直接瘫痪。
-
内存管理复杂化:程序员需要处理物理内存布局,在连续的地址空间中寻找并申请内存。
-
物理内存碎片:物理地址空间中的离散区域会出现碎片问题,降低了内存的利用率。
-
效率低下:直接使用物理内存,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存。但如果是物理地址的话,则要将整个进程一起拷走,这样在内存和磁盘之间拷贝时间太长,效率较低。
若有了虚拟地址空间和分页机制就能解决上面的问题:
-
安全维护:地址空间和页表是操作系统创建并维护的,使用内存时,也一定会在操作系统的监管之下来进行访问,保护了物理内存中的所有合法数据。
-
简化内存管理与高效:在 C/C++ 语言上 new,malloc 空间时,其实是在地址空间上申请的,物理内存操作系统可以选择不申请。而当程序员真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,来申请内存,构建页表映射关系(延迟分配),此过程全程由操作系统完成。
-
功能解耦合:因为有虚拟地址空间和页表的映射,物理内存中可以对未来的数据进行任意位置的加载,物理内存分配 和 进程管理 就可以做到模块分离,完成解耦合。
-
避免物理内存碎片:因为页表映射的存在,程序理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。
-
扩展可用内存:通过将不活跃的页面暂存至磁盘交换空间,虚拟地址空间可远大于物理内存,支持运行更大程序或多任务并行(时间换空间)。并且程序启动时可以仅加载必要部分,其余内容(如未执行代码)在访问时按需载入,节省物理内存。
进程控制
进程创建
fork 函数介绍
在 Linux 中,fork 函数是非常重要的函数,我们在 (学习总结29)Linux 进程概念和进程状态 曾具体使用过它,实践代码部分省略。
fork 函数作用是从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
进程调用 fork 函数,当执行到内核中的 fork 函数代码后,内核将会做:
当一个进程调用 fork 后,会出现两个二进制代码相同的进程。而且它们都运行到相同的代码命令。但每个进程都是独立操作它们自己的数据。
当父进程或子进程尝试修改内存数据时,触发缺页异常(上一部分讲述的进程地址空间的权限访问与写时拷贝),内核复制数据到其它地方并修改页表项为私有可写。
fork 之前父进程独立执行,之后父子两个执行流分别执行。注意 fork 之后谁先执行完全由调度器决定。
fork 函数返回值中,子进程返回 0,父进程返回的是子进程的 pid(pid > 0)。
fork 函数常规用法:
fork 调用失败的原因:
写时拷贝作用
我们在其它部分提到或讲解过写时拷贝,但它的具体作用是什么呢?
-
减少 fork 函数开销,加快进程创建速度。
-
延时申请思想,其避免立即复制全部内存,提升性能并提高整机内存的使用率(尤其适用于 fork 函数后使用 exec 函数的场景)
进程终止
进程终止的本质是释放系统资源,即释放进程申请的相关 内核数据结构
和 对应代码与数据
。
进程退出
进程退出的情况有 3 种:
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
进程常见正常退出方法:
-
从 main 函数返回
-
调用 exit 函数返回
-
调用 _exit 函数返回
正常退出时可以用 echo $?
命令查看进程退出码。
当然还有异常退出(以下只是一部分):
-
Ctrl + c 快捷键退出
-
除数为 0 错误
-
访问野指针
注意,异常退出时的退出码获取了没有意义。
退出码
退出码(退出状态)可以告诉我们进程执行的状态。在进程结束以后,我们可以知道此进程的执行是成功结束还是以错误结束的。其基本思想是,进程返回退出代码为 0 时表示执行成功,没有问题。
为 0 以外的任何代码都被视为不成功。
Linux shell 中的主要退出码:
-
退出码 1 为 “ 不被允许的操作 ” 。如在没有 sudo 情况使用需要 root 权限的命令,还有除数为 0 等操作也会返回。
-
C语言规定的退出码一共有 134 个(0 ~ 133)。
-
可以使用C语言的 strerror 函数来获取退出码对应的描述。
-
130 ( SIGINT 或 ^C )和 143 ( SIGTERM )等终止信号是非常典型的,它们属于 128 + n 信号,其中 n 代表终止码,使用后也能看到退出码。
我们可以使用 strerror 函数来查看具体的退出码:
#include <stdio.h>
#include <string.h>int main()
{for (int i = 0; i < 135; ++i){printf("str[%d] == %s\n", i, strerror(i)); }return 0;
}
_exit、exit 函数 与 缓冲区
_exit 函数是系统层面,而 exit 属于C语言标准库的,我们可以使用 man 手册查看:
exit 是 C语言 的库函数,内部会清理C语言开辟的缓冲区,最后才会调用 _exit。但在调用 _exit 之前,还做了其它工作:
-
执行用户通过 atexit 或 on_exit 定义的清理函数。
-
关闭所有打开的流,所有的缓存数据均被写入
-
调用 _exit
_exit 和 exit 对缓冲区执行的区别:
C语言打印时,并不是一个一个的打印在控制台上,而是遇到 \n
等规定的字符才会打印,在此之前字符将会暂时存储在C语言的缓冲区中。
如果没有 \n
但使用 exit 时,exit 会关闭缓冲区,将其中的字符打印出来。但若是调用 _exit ,_exit 会直接让进程退出,缓冲区直接杀掉,自然看不到打印的字符:
#include <stdio.h>
#include <stdlib.h>int main()
{printf("Hello Linux"); // 没有 \n 刷新缓冲区exit(0); // 使用 exit 会刷新缓冲区并打印其中的字符 return 0;
}
#include <stdio.h>
#include <unistd.h>int main()
{printf("Hello Linux"); _exit(0); // 使用 _exit 会杀掉缓冲区,不会打印其中的字符return 0;
}
status 的相关规定:
虽然 _exit 和 exit 中的 status 是 int,但是仅有低 8 位可以被父进程所用。所以 _exit(-1) 或 exit(-1) 时,在终端执行 echo $?
发现返回值是 255 :
#include <stdio.h>
#include <stdlib.h> int main()
{exit(-1);return 0;
}
return 退出
return 是一种更常见的退出进程方法。在 main 中执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值当做 exit 的参数。
进程等待
进程等待作用
所以出现了进程的僵尸状态概念,而僵尸进程是无法被消灭的,即便使用 kill -9 [进程PID]
具有最高的优先级和不可抗拒性的命令也无能为力,因为谁也没有办法杀死一个已经死去的进程。
我们在 (学习总结29)Linux 进程概念和进程状态 - 进程状态 中介绍过僵尸进程的危害。子进程退出,父进程如果不管不顾,会造成 " 僵尸进程 " 的问题,进而造成内存泄漏。
可以在 task_struct 中找到僵尸进程的退出信息:
进程等待的方法
我们可以使用 man 手册查看 wait 和 waitpid 函数:
wait 方法
返回值 pid_t :成功返回被等待进程 pid,失败返回 -1。
参数 wstatus:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。
wait 函数等待任意一个子进程,但它会让父进程进入阻塞状态,如果子进程一直没有结束,父进程就会一直等待:
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{pid_t pid = fork();if (pid < 0){ perror("fork:");return 0;} else if (pid == 0){ printf("child process: %d\n", getpid());sleep(2); // 子进程停止 2 秒返回} else{ pid_t ret = wait(NULL);if (ret == -1) { return 0;} else{ printf("get PID: %d\n", ret);} } return 0;
}
waitpid 方法
返回值 pid_t :
参数 wstatus:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。
waitpid 参数中的 pid 表示的是子进程的 PID ,具体填写的操作:
参数 options 等待方式,0 表示阻塞等待,其它等待方式:
WNOHANG:若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。若正常结束,则返回该子进程的 ID。
waitpid 功能相对于 wait 更多,特别是 WNOHANG 无阻塞调用,在下一部分我们再具体分析。
获取子进程 wstatus
wait 和 waitpid,都有一个 wstatus 参数,该参数是一个输出型参数,由操作系统填充。如果传递 NULL,表示不关心子进程的退出状态信息,反之操作系统会根据该参数,将子进程的退出信息反馈给父进程。
但是 wstatus 不能简单的当作整形来看待,要当作位图来看待,具体细节如下图(这里只分析 wstatus 低 16 比特位):
我们可以测试正常退出:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{pid_t pid = fork();if (pid < 0){ perror("fork:");return 0;} else if (pid == 0){ printf("child process: %d\n", getpid());sleep(5);exit(6);} else{ int status = 0;pid_t ret = waitpid(-1, &status, 0); if (ret == -1) { return 0;} else{ printf("子进程 PID: %d\n", ret);printf("子进程退出码: %d , 终止信号: %d\n", (status >> 8) & 0xFF, status & 0x7F);} } return 0;
}
这里再测试异常退出:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{pid_t pid = fork();if (pid < 0){ perror("fork:");return 0;} else if (pid == 0){ printf("child process: %d\n", getpid());int num = 5 / 0; // 除数为 0 异常 exit(6);} else{ int status = 0;pid_t ret = waitpid(-1, &status, 0); if (ret == -1) { return 0;} else{ printf("子进程 PID: %d\n", ret);printf("子进程退出码: %d , 终止信号: %d\n", (status >> 8) & 0xFF, status & 0x7F);} } return 0;
}
注意前面已经提醒过了,异常退出中的退出码是无意义的:
对于提取 wstatus 的操作也可以使用宏:
WIFEXITED(wstatus)
:若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(wstatus)
:若 WIFEXITED 为真,提取子进程退出码。(查看进程的退出码)
WIFSIGNALED(wstatus)
:检查子进程是否因信号而终止(非正常退出),为真说明子进程是被信号杀死的。
WTERMSIG(wstatus)
:如果 WIFSIGNALED 为真,提取导致子进程终止的信号编号(查看进程的终止信号码)
如果想看看有哪些信号,我们可以使用 kill -l
查看所有信号:
可以注意到刚刚的除以 0 操作为 8 号信号 SIGFPE
算术运算异常,另外 kill -9 [进程PID]
表示为杀掉进程的 9号信号 SIGKILL
,Ctrl + c 快捷键为 2号信号 SIGINT
。
阻塞与非阻塞等待
从之前文章的分析当中,阻塞等待就是将进程从调度队列转移到对应的等待队列,如果等待队列没有获取对应资源,就会一直等待。
而刚刚我们提到了 waitpid 的无阻塞 WNOHANG 模式。无阻塞最大的用途在于不用一直等待,可以在等待的时间内去做其它事情。
这意味着父进程可以在检查后去做自己的事情,大大提高处理任务的效率:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdbool.h>void test1()
{printf("执行打印任务\n");
}void test2()
{printf("执行计算任务\n");
}typedef void (*func)();int main()
{func task[2] = { &test1, &test2 };pid_t pid = fork();if (pid < 0){perror("fork:");return 0;}else if (pid == 0) {sleep(10);}else{int status = 0;int task_move = 0;int ret = 0;while (true){ret = waitpid(-1, &status, WNOHANG);if (ret != 0){break;} else{printf("未等待到子进程,执行其它任务\n");}task[task_move++ % 2]();sleep(2);}if (ret < 0){return 0;}printf("子进程 PID: %d\n", ret);printf("子进程退出码: %d , 终止信号: %d\n", (status >> 8) & 0xFF, status & 0x7F);}return 0;
}
进程程序替换
fork 函数调用完成之后,父子各自执行父进程代码的一部分,如果让子进程执行一个全新的程序可以用进程程序替换来完成这个功能。
程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。
替换原理
用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程需要调用一种 exec 函数才能执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序覆盖式的替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变:
替换函数
一般有六种以 exec 开头的函数,统称 exec 系列函数,除了 execvpe 函数其它属于 POSIX 标准函数,exec 系列函数的共同目标是用新进程替换当前进程,但它们在参数传递、环境变量处理和可执行文件查找方式上有所不同:
execl 函数使用
execl 函数如果调用成功则加载新的程序并从启动代码开始执行,不再返回。如果调用出错则返回 -1,则 execl 函数只有出错的返回值而没有成功的返回值。
第一个参数表示查找的可执行文件,第二个参数与后面的表示怎样执行它,而且参数可变,用 NULL 表示结束:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{pid_t id = fork();if (id < 0){ perror("fork:");return 1;} else if (id == 0){ printf("子进程 PID: %d\n", getpid());int ret = execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 以 NULL 结尾printf("子进程执行\n");if (ret == -1) { exit(1);} } else{ sleep(1); printf("父进程执行\n");pid_t child = wait(NULL);if (child > 0){ printf("子进程 PID: %d\n", child);} } return 0;
}
其它 5 个函数返回值同 execl 一样,但参数部分各有差异。
execv 函数使用
execv 函数:相对于 execl 函数,execv 中的 v
字符表示传的第二个与后面参数变为字符指针数组。
在 execl 函数的基础上只需改部分代码:
//......char* const args[] = { (char* const)"ls",(char* const)"-l",(char* const)"-a",NULL}; int ret = execv("/usr/bin/ls", args);//......
execlp 和 execvp 函数第一个参数在 PATH 环境变量中查找
execlp 函数 :在 execl 函数名上加了字符 p
,表示第一个参数会在 PATH 环境变量中查找。
在 execl 函数的基础上只需修改部分代码:
// ......int ret = execlp("ls", "ls", "-l", "-a", NULL);// ......
execvp 函数:在 execv 函数名上加了字符 p
,表示第一个参数会在 PATH 环境变量中查找。
在 execv 函数的基础上只需改部分代码:
// ......int ret = execvp("ls", args);// ......
execle 与 execvpe 函数增加环境变量参数
execle 函数:在 execl 函数名上加了 e
字符,表示后面增加环境变量参数。
execvpe 函数:在 execvp 函数上加了 e
字符,表示后面增加环境变量参数。
如果想要保留之前的环境参数,可以声明 extern char ** environ;
并在参数上传递:
其它部分代码省略:
// execle 部分 =========================
// .......extern char** environ;// ......int ret = execle("/usr/bin/ls", "ls", "-l", "-a", NULL, environ);// ......// execvpe 部分 =========================// ......extern char** environ;// ......char* const args[] = { (char* const)"ls",(char* const)"-l",(char* const)"-a",NULL}; //char* const env[] = {// (char* const)"my_env=12345"//};int ret = execvpe("ls", args, environ);// ......
exec 函数系列命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
-
l(list):表示参数采用列表
-
v(vector):表示参数用数组
-
p(path):有字符
p
表示自动搜索环境变量 PATH 里的可执行文件 -
e(env):表示更改或增加维护的环境变量
但要补充的是,只有 execve 是真正的系统调用,exec 函数中五个函数统一都会调用 Linux 系统提供的 execve 函数:
如果函数名没带 e
字符,最后调用 execve 将继承当前进程的环境变量。
所以 execve 在 man 手册第 2 节,其它函数在 man 手册第 3 节。
那 execvpe 函数呢?事实上 execvpe 函数并非 POSIX 标准函数,而是 GNU C 库(glibc)提供的扩展函数。它的存在取决于具体实现和系统环境。
我们可以用表格来具体对比它们:
函数名 | 参数格式 | 使用 PATH 查找 | 传递环境变量 | 标准 |
---|---|---|---|---|
execl | 列表 | 否 | 继承当前环境 | POSIX |
execv | 数组 | 否 | 继承当前环境 | POSIX |
execlp | 列表 | 是 | 继承当前环境 | POSIX |
execvp | 数组 | 是 | 继承当前环境 | POSIX |
execle | 列表 | 否 | 需显式传递 | POSIX |
execve | 数组 | 否 | 需显式传递 | POSIX |
execvpe | 数组 | 是 | 需显式传递 | GNU 扩展 |
exec 函数系列替换其它语言程序
exec 函数不仅可以替换C语言程序,还可以替换其它语言的程序!因为不管什么语言,如:C++、Java、Python 等等,最后程序运行都是用进程的方式。
这里我们编写一个 C++ 的程序来测试:
#include <iostream>
using namespace std;int main(int argc, char* argv[], char* env[])
{cout << "运行 C++ 程序\n"; for (int i = 0; i < argc; ++i){ cout << "参数[" << i << "] == " << argv[i] << endl;} for (int i = 0; i < 10; ++i){ cout << "环境变量[" << i << "] == " << env[i] << endl;} return 0;
}
C语言程序准备好调用 C++ 程序:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>extern char** environ;int main()
{ pid_t id = fork();if (id < 0){ perror("fork:");return 1;} else if (id == 0){ printf("子进程 PID: %d\n", getpid());int ret = execle("./testCpp", "testCpp", "-a", "-l", NULL, environ);printf("子进程执行\n");if (ret == -1) { exit(1);} } else{ sleep(1);printf("父进程执行\n");pid_t child = wait(NULL);if (child > 0){ printf("子进程 PID: %d\n", child);} } return 0;
}
结果: