Linux_17进程控制
前提回顾:
页表可以将无序的物理地址映射为有序的;
通过进程地址空间,避免将内存直接暴漏给操作系统;
cr3寄存器存放的有当前运行进程的页表的物理地址;
一、查看命令行参数和环境变量的地址
因为命令行参数和环境变量都是字符串的形式,所以这里我们可以通过字符串直接打印地址;
通过验证最后可以知道:命令行参数和环境变量位于栈区的上方!
(子进程继承父进程的环境变量 --- 因为进程地址空间存放的有!)
代码是不可写入的(无论是父进程还是子进程,映射到代码区,页表对应的权限关系是只读的!)
操作系统怎么识别到父子共享的数据块,此时需要进行写时拷贝呢?
如果我们要对当前的父进程的数据进行写入的时候,无论数据是只读还是可写,系统都会将其改为只读权限!(子进程也是只读),此时如果想写入就会触发系统权限的问题(不做异常处理),此时操作系统会对要访问的区域进行检查,如果权限是可写的就会进行写时拷贝!
通过fork创建多个进程
下面我们通过循环创建多个线程:
父子进程(或兄弟进程)被创建出来后,谁先运行不可知!(由调度器决定!)
进程退出的场景都有哪些?
- 代码运行完毕,结果正确;
- 代码运行完毕,结果不正确;
- 代码异常终止;
进程中,一般谁会关心我们的运行情况呢?(--- 父进程!)
相比于正确的结果,我们一般关心的是为什么程序运行会出错?
在C语言中,可以用return的不同返回值数字,表征不同的出错原因(即退出码);
即:main函数的返回值得到本质表示的是:进程运行时是否是正确的结果,如果不是,可以用不同的数字来表示不同的出错原因!
使用 $? 可以获取上一个进程运行结束后的退出码(最近一个进程运行的退出码);
这里第一次执行查看的时./myproc的进程码,接下来几次查看的都是echo的进程码!
错误码0、1...都是给计算机查看的,人自己查看可以通过Linux提供的接口将其转化为字符串(需要包含string.h这个头文件);
这里当我们进行ls查看一个不存在的文件的时候,此时我们使用echo $? 打印的结果正好是错误代码中2!
我们也可以自定义错误码,而不是一定非要根据系统的体系走;
此时返回错误码对应的自定义的下标即可!
C语言有个errno全局变量!(用于保存最近一次执行的错误码!)
当我们调用全局函数失败的时候,此时errno会返回对应的错误码!
此时我们可以打印errno对应的错误代码,并将其返回给父进程,让父进程直到此时调用有问题;
代码异常终止,我们可以认为:代码可能没有跑完(如果程序在main返回之前异常终止,此时我们认为该错误码没有意义,不关心退出码!);
进程的控制是由信号控制的:
我们可以通过kill -l对对应的进程进行控制;
结论:进程出现异常,本质上就是进程收到了对应的信号!
这里exit()在任何地方被调用后,都表示调用该进程直接退出!(不会执行后面的打印语句)
此时如果我们进行return,函数会直接返回,然后在main里面继续打印printf的内容!
_exit()这是一个系统调用接口;exit是库函数!
exit()实际上是先将对应的缓存区和自定义的函数清理,然后再调用_exit() ;
此时我们提出一个问题:这个缓存区应该在内存的那个位置?
一定不在内核区,如果在内核区的话此时_exit()也会刷新缓存流!
二、进程等待
我们要通过等待进程,获取紫禁城的退出情况 --- 直到我们给子进程布置的任务完成的怎么样,可以关心也可以不关系(不关心就设置为NULL)
异常实质上就是信号!(运行中报错)
如果我们对应的wait在调用的时候,此时子进程一直在执行工作,没有退出;
那么此时父进程就会阻塞等待子进程!(阻塞状态不仅发生在等待硬件 - 键盘、也会发生在等待软件 - 进程)
输出型参数:在函数调用的时候,由函数内部修改其值并返回调用者的参数(例如malloc)
status就是一个输出型参数!
父进程要拿子进程的状态数据,为什么必须要用wait等系统调用呢?
进程具有独立性!如果我们全局变量,此时子进程将这个全局变量进行修改,无法传递给父进程!(必须得通过系统调用来实现!)
前8位表示的是终止信号,(例如我们勇敢什么特殊信号杀掉进程);
第八位为标志位,此时我们暂不关心;
8~15表示的是退出状态(即子进程的退出状态码);
这里父进程在对应的数据和代码里面,通过系统调用接口,查询子进程的退出信息(exit_code, exit_signal),其中这两个主要的信息包含在tast_struct里面!
因此waitpid函数的本质是:读取子进程的task_struct的内核数据结构对象;
等待子进程返回信息的时候,什么时候会等待失败呢?
当等待的子进程不是对应的子进程!此时返回-1;
- WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) --- 本质上检查的是信号位(也就是status & 0x7f --- > 取小的七位!);
- WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)也就是(status>>8)& 0xFF

waitpid中的option选项:
- 当此时为0的时候,即为阻塞等待方式;
- WNOHANG(wait no 夯住了) --- 非阻塞轮询!
阻塞等待方式:子进程此时一直为R状态运行,父进程需要等待,把父进程投递在等待队列中,
(我们可以认为子进程维护的PCB里面也维护了一个等待队列!--- 为等待该进程的使用的)
引入非阻塞轮询的概念:
阻塞等待的时候,此时父进程只能等待,而不能先进行一会自己的工作;
但是非阻塞轮询此时会一直询问子进程的状态,如果没准备好,此时父进程会先忙自己的工作,过段时间再进行询问;
对于非阻塞轮询有三种返回条件:
- ret_pid>0(也就是对应子进程的PID) | ret_pid <0,此时表示此时等待的条件已经就绪或者失败!
- ret_pid = 0 表示还没有继续,此时会继续等待;
为了实现非阻塞轮询,这里我们使用while循环来实现!
信号是没有0号信号的!这是因为0号代表进程正常执行,没有异常,即没有信号干扰!
waitpid
中的 options
参数用于控制该函数的行为模式,允许父进程以更灵活的方式等待子进程的状态变化。以下是 options
参数的详细解析:
主要选项及其作用
-
0
(默认阻塞模式)- 当
options
设置为0
时,waitpid
会以阻塞方式等待子进程结束或状态变化。父进程会暂停执行,直到满足等待条件(如子进程终止)后才继续运行。 - 适用场景:需要父进程必须等待子进程结束后才能继续执行的简单场景 。
- 当
-
WNOHANG
(非阻塞模式)- 若子进程尚未结束或状态未变化,
waitpid
会立即返回,而非阻塞父进程。此时返回值可能是:- 子进程 PID:子进程已结束,状态被成功收集。
- 0:子进程仍在运行,但未结束。
- 适用场景:父进程需要同时处理其他任务(如轮询多个子进程状态)时,避免阻塞以提高效率 。
- 若子进程尚未结束或状态未变化,
-
WUNTRACED
(报告停止状态)- 当子进程因信号(如
SIGSTOP
)被暂停(而非终止)时,waitpid
会返回其状态。 - 需配合宏
WIFSTOPPED(status)
和WSTOPSIG(status)
解析状态,获取导致暂停的信号(如SIGSTOP
) 。
- 当子进程因信号(如
-
WCONTINUED
(报告继续状态)- 当子进程因
SIGCONT
信号恢复执行时,waitpid
会返回其状态。需通过宏WIFCONTINUED(status)
检测此状态 。
- 当子进程因
三、进程替换
excl类函数的作用:执行一个文件;
标准写法要以NULL为结尾! (execl后面的代码不会执行!)
单进程的程序进行程序替换:
当我们名为mycommand的程序进行运行的时候,刚开始printf进行打印,执行到execl这一行命令的时候,此时会将ls的代码和数据移至内存当中(对原来的数据和代码进行覆盖)!此时整个页表左侧的结构和东西不变!(task_struct和进程地址空间和页表左侧)
当我们运行到程序替换所对应的程序的位置的时候:将新程序的数据替换老程序的数据;
页表对应的左侧都没有发生变化!此时是页表对应的右侧的物理内存发生了变化,将新的程序加载到内存当中并替换页表;
execl如果执行成功!此时后面的代码都不会被执行!哪怕此时后面存在exit(0);
但是如果execl执行失败,此时函数会返回1,然后继续执行后面的代码!(execl后面的代码和数据也算是老程序的代码和数据)
问题:子进程执行程序替换的时候,是否会影响父进程?
当子进程进行进程替换的时候,不会影响父进程!此时子进程会进行写时拷贝!
程序替换不会创建子进程!只进行程序的代码和数据的替换工作!
程序替换的现象:
- 程序替换之后,exec* 之后的代码不会执行,替换失败呢,才有可能执行后面的代码;
- exec* 函数只有失败才有返回值!!成功没有!
问题:当我们加载新的程序的时候,CPU如何知道新的程序的入口地址?
- Linux中的可执行程序是有格式的!(ELF),在可执行程序的表头中有可执行程序的入口地址;
- 新进程的表头可以被CPU读取,进行替换新的进程;
execl接口介绍
list指的是像链表一样一个节点一个节点的进行传递(参数为可变参数);
所有的exec* 系列的函数的第一个参数都是为了找到执行该程序的地址;
找到该程序后应该怎么办?
主要是确定如何执行该程序,该程序需不需要覆盖选项?(命令行中如何传,此时我们就如何传)
execlp接口介绍
带p指的是:PATH,也就是说execlp会在自己默认的PATH中进行查找!
虽然这里显示我们调用写了两个ls,但是实际上!第一个参数ls是为了确定在哪里找到整个程序(即我们需要执行谁)!第二个ls是为了确定我们需要怎么进行执行!
execv接口介绍
这里的v指的是数组!
以NULL作为结尾!
实际上就是将我们对应的可变参数列表替换为了字符串指针数组!
这里在使用的时候我们需要先定义一个数组!
char* const myargv[]
ls有main函数,那么ls的main函数有命令行参数吗?
有!这里ls的命令行参数是由myargv传入的!
execvp接口介绍
实际上就是vector + PATH!
此时调用更加简单!
execle接口介绍
这里的e指的是env:也就是我们自己维护的环境变量!
补充点:对于C++的程序,后缀吹了.cpp,还可以为.cc或.cxx;
如何通过一个Makefile形成两个或者多个可执行文件?
可以定义一个伪目标文件all;
此时形成这个伪目标文件需要先形成其他两个可执行程序!
当前我们有两个可执行程序!此时我们想要通过mycommand来调用otherExe!
我们可以通过下面的格式进行调用!
- 第一个参数表示的是执行程序的路径(相对路径);
- 第二个参数是我们需要执行什么程序;
- 第三个参数是附加的选项(这里为NULL!);
问题:C/C++语言程序是否可以调用其他语言或脚本的程序?
答案:可以!
脚本语言开头都以#!为开头!后面对应的是脚本语言的解释器!
假如说我们当前有如下所示的脚本文件:
./shell脚本不能执行!(得通过bash来执行!)
bash test.sh
此时如果我们想通过C/C++语言程序执行这个脚本,那我们应该怎么执行?
这里得第一个参数为bash解释器得地址!
第二个为需要执行的指令!(按照在命令行输入指令的形式)
如果当前我们有一个Python脚本也是对应的!
问题:无论是我们的可执行程序,还是脚本语言,为什么都能跨语言调用呢?
这是因为所有语言运行出来,本质上都是进程!
问题:我们的exec* 能调用系统指令,那么能不能执行我们自己的指令?
一个程序代码是否能通过exec*传递命令行参数,另一个可执行程序通过main中的命令行参数接受到可执行参数?
这里我们没有传递环境变量,但是子进程依然可以显示环境变量!
问题:环境变量是什么时候传递给子进程的?
结论:环境变量也是数据,创建子进程的时候,环境变量就已经被子进程继承下去了!(extern cahr** environ)
所以程序替换中,环境变量信息不会被替换!
问题:为什么程序被替换的时候,环境变量信息不回被替换?(deepseek)
1. 环境变量的存储位置与程序替换的覆盖范围
-
环境变量存储区域: 环境变量通常存储在进程地址空间的 栈区之上 或 独立的环境变量表 中,与程序的代码段(
.text
)、数据段(.data
)分离。程序替换仅覆盖代码段和数据段,而不会修改栈区和堆区等内存区域,因此环境变量所在区域未被触及。 -
程序替换的本质: 替换过程通过加载新程序的代码和数据到内存中,并更新页表映射,但不会修改进程的上下文信息(如环境变量、文件描述符等)。
2. 环境变量的继承机制
-
父子进程的继承性: 子进程通过
fork
创建时,会复制父进程的环境变量表。即使后续调用exec
替换程序,子进程仍保留父进程传递的环境变量,除非显式指定新的环境变量数组。 -
默认行为:
exec
系列函数(如execl
、execv
等)默认使用父进程的环境变量,仅当调用execle
或execve
时才会通过参数envp[]
覆盖式传递 新环境变量。例如:
char *envp[] = {"PATH=/custom/path", NULL};
execle("/bin/program", "program", NULL, envp); // 覆盖原有环境变量
3. 操作系统对进程上下文的管理
-
进程控制块(PCB)的独立性: 程序替换仅修改进程的代码和数据,而 PCB 中的优先级、环境变量指针等元数据保持不变。环境变量作为进程运行时的上下文信息,独立于代码逻辑。
-
性能优化: 环境变量的继承避免了重复加载和初始化,提升进程创建效率。若每次替换程序都重新加载环境变量,会增加系统开销。
问题:如果我们想给子进程传递环境变量,此时应该怎么进行传递呢?
- 新增环境变量;
第一种情况:我们可以在bash上面新增一个环境变量,此时 mycommand是bash的子进程,会继承该环境变量;otherExe是mycommand的子进程,会继承其的环境变量,最后得到新增的环境变量!
第二种情况:我们不想让bash有该环境变量!
需要在父进程中导入!
我们可以使用上面的系统调用接口新增环境变量!
第三种情况:通过exec* 系列带e的函数可以实现:
但是上面实际还是将父进程的环境变量传递过去,默认的也是将父进程的传过去!
- 彻底替换;
我们可以通过自定义环境变量,然后进行替换!
结论:这里execle采用的策略是覆盖而不是替换!
man的3号手册一般被称为库函数!(2号手册是系统调用!)
这里的execve实际上是系统调用接口!
因此实际上其他库函数最后都是调用execve这个系统调用函数!
再谈shell
shell被称为外壳程序,shell/bash也被称为一个进程,执行命令的时候,本质上是创建子进程!
当我们在bash上面执行命令的时候,此时左侧对应的用户名 + 主机名 + 路径等,我们可以通过环境变量来获取(系统调用也可以!)
自定义简单shell
补充点1:C语言中,相邻字符串具有自动连接的特点!
如下所示:
printf("This is a very long string "
"that spans multiple lines "
"in the source code.");
输出结果:This is a very long string that spans multiple lines in the source code.
补充点二:fgets函数的用法(deepseek)
在Linux系统中,fgets()
是C标准库中用于安全读取字符串的函数,尤其适合处理文本文件的逐行读取需求。以下是其核心特性、使用方法和注意事项的综合解析:
- 参数解析
str
:指向字符数组的指针,用于存储读取的字符串。n
:最大读取字符数(包括结尾的\0
),即最多读取n-1
个字符。stream
:输入流指针,可以是文件指针(如fopen()
返回的)或标准输入(stdin
) 。
返回值
- 成功时返回
str
指针; - 失败或到达文件末尾时返回
NULL
,需通过feof()
或ferror()
判断具体原因 。
assert在编译的时候起效果,运行的时候没有效果!
注意点一:
对有的编译器来说,有时候创建变量若没有使用,则此时会出现警告甚至是报错,因此此时我们可以采用下面这种方式:
通过(void)s 避免报错;
注意点二:
这里我们为了不想当输入完指令后,此时回车也会被记录到对应的指令中,因此此时我们可以进行下面的处理:
这里的strlen会带上\n,因此此时我们将对应的\n替换为\0,即可实现!
注意点三:
为实现不同的功能,这里我们需要对字符串进行分割,这里我们调用一个函数strtok;
strtok
是C标准库中用于分割字符串的核心函数,常用于解析以特定分隔符分隔的文本数据。
#include <string.h>
char *strtok(char *str, const char *delim);
- 参数:
str
:首次调用时传入待分割的字符串;后续调用需设为NULL
,以继续处理剩余部分。delim
:分隔符集合字符串,函数会将其中任意字符视为分隔符。
- 返回值:返回指向当前子串的指针,若无可分割内容则返回
NULL
。
该函数一次只会分割一次字串!
strtok的基本用法:
#include <stdio.h>
#include <string.h>
int main() {
char str[] = "apple,banana;cherry";
char *token = strtok(str, ",;"); // 分隔符可以是逗号或分号
while (token != NULL) {
printf("%s\n", token);
token = strtok(NULL, ",;"); // 后续调用传入NULL
}
return 0;
}
这里需要注意的是,后续用来分隔的时候,需要传入NULL;
输出如下所示:
apple
banana
cherry
当我们执行mkdir等的时候,都可以正常运行,但是执行cd .. 或者 cd /等却不能正常运行!
这是因为这些命令我们都是通过fork之后的子进程来运行的!但是子进程的运行结果不会影响父进程!
因此这些命令需要父进程自己来运行!这些命令也就成为内建命令!
在自定义的shell时,内建命令需要我们自己罗列起来;
引用新的系统调用接口:chdir
在Linux系统中,chdir
函数是用于更改进程当前工作目录的核心接口,属于C标准库(libc)的一部分。
函数原型:
#include <unistd.h>
int chdir(const char *path);
- 参数:
path
可以是绝对路径(如/home/user
)或相对路径(如../doc
) 。
- 返回值:成功返回
0
,失败返回-1
并设置errno
以指示具体错误 。
函数的使用场景:
#include <stdio.h>
#include <unistd.h>
int main() {
char buf[256];
getcwd(buf, sizeof(buf)); // 获取当前目录
printf("原目录: %s\n", buf);
if (chdir("/tmp") == 0) { // 切换目录到/tmp
getcwd(buf, sizeof(buf));
printf("新目录: %s\n", buf);
} else {
perror("chdir失败");
}
return 0;
}
输出结果如下所示:
原目录: /home/user
新目录: /tmp
引用函数调用接口:sprintf
sprintf
是 C 语言标准库中用于将格式化数据写入字符数组的核心函数,其作用是将变量、常量等数据按指定格式组合成一个字符串,并将结果存储到用户提供的缓冲区中。
函数原型与参数
int sprintf(char *str, const char *format, ...);
str
:指向目标字符数组的指针,用于存储结果字符串。需确保缓冲区足够大,否则可能引发溢出 。
format
:格式字符串,包含普通字符和格式说明符(如%d
、%s
)。格式说明符决定参数的类型和显示方式 。
...
:可变参数列表,参数数量与格式字符串中的%
标签数量一致 。
示例场景:
int num = 123;
char buffer[20];
sprintf(buffer, "%d", num); // 转换为 "123"
引用函数调用接口:getcwd
getcwd
是用于获取进程当前工作目录(Current Working Directory)的 C 标准库函数,其底层通过系统调用实现。
#include <unistd.h>
char *getcwd(char *buf, size_t size);
- 参数:
buf
:存储路径的缓冲区,若为NULL
且size=0
,函数自动分配内存(需手动free
释放)。size
:缓冲区大小,若路径长度超过size
,返回NULL
并设置errno=ERANGE
。
- 返回值:成功返回路径指针,失败返回
NULL
。
这里我们还需要处理export和echo打印环境变量!
这里export也需要是内建命令!如果不是内建命令,当我们到入环境变量时,通过fork子进程导入对应的环境变量,此时父进程无法显示!但是如果父进程执行该命令,此时由于继承环境变量,子进程也会有新的环境变量!
当我们们登录的时候,系统会帮我们启动一个shell进程,此时可以引出问题:shell本身的环境变量表是从哪里得来的?
命令行中所有执行的环境变量都是从bash得到的。
在当前用户的家目录下,有这个.bash_profile这个文件!
即当用户登陆的时候,shell会读取用户目录下的.bash_profile文件,里面保存了导入环境变量的方式!