目录
一、进程创建
1. fork 函数
2. 写时拷贝 (Copy-On-Write, COW)
3. 为什么要写时拷贝?
4. fork 失败的原因
二、进程终止
1. 退出场景
2. 获取返回值
3. 退出码标准
4. exit 退出
5. exit 和 _exit 区别
三、进程等待
1. 为什么要等待子进程结束?
2. 如何等待?
四、进程程序替换
1. 原理
2. 返回值
3. execl 函数格式
4. 在子进程中执行
5. exec 系列函数
五、简单 Shell 的实现
1. 获取用户信息
2. 输出提示符(如 [user@host path]$)
3. 获取和解析命令
4. 创建子进程执行外部命令
5. 内建命令 (Built-in Command)
一、进程创建
1.fork函数
分配资源:为子进程分配
task_struct、mm_struct等内核数据结构。拷贝数据:将父进程的 PCB(
task_struct)、页表等拷贝到子进程中。加入调度:将子进程添加到系统的进程列表中,并加入到调度队列的
expired组。返回:
fork函数返回。
如果创建的子进程专门执行系统内核代码,这就是内核级虚拟机(如
KVM)的原理。
2. 写时拷贝 (Copy-On-Write, COW)
创建子进程时,操作系统会将父、子进程页表的映射权限设置为只读。
当子进程(或父进程)需要修改一个共享的变量时,虽然地址没问题,但权限不符,此时会触发写时拷贝。
操作系统会为修改者(子进程)重新开辟一块物理内存,拷贝数据,并修改其页表映射,最终完成写入。
3. 为什么要写时拷贝?
加快子进程创建速度:无需立即拷贝全部数据,创建瞬间非常快。
减少内存资源浪费:由于子进程通常不会使用父进程的全部数据,对于不修改的部分,父子可以共享,无需额外占用内存。
写时拷贝是一种精细化的内存管理策略。
4.fork失败的原因
系统中进程总数过多。
用户的进程数超过了限制。
二、进程终止
1. 退出场景
正常结束,结果正确:返回
0。正常结束,结果错误:返回非
0(即退出码)。异常结束:返回值无意义(程序被信号终止)。
2. 获取返回值
使用
echo $?命令可以打印最近一个进程的退出码。进程的退出码存储在
task_struct结构体内。父进程回收僵尸进程时,可以从中获取退出码。再次执行
echo $?会显示0,因为这时获取到的是echo命令本身的退出码。
3. 退出码标准
Linux 规定了一系列退出码,
strerror函数可以将整数错误码转换为对应的字符串描述。从
0到133,共有 134 个数字对应不同的描述字符串。示例:
FILE* f = fopen("a.txt", "r"); return errno;errno会返回失败操作的错误码。echo $?会输出2(表示文件不存在)。同样,
ls -l a.txt打开一个不存在的文件,也会返回2。
异常退出示例:
int p = 10; p /= 0; // 除零异常 return 87;程序异常终止,
echo $?可能会输出136(或其他值,代表信号SIGFPE),此时return 87没有意义。因此,异常退出时,退出码无意义。
4.exit退出
main函数的返回代表进程结束,而普通函数的返回只代表该函数结束。如果要在函数中就结束整个进程,可以使用
exit。void fun() { exit(0); } int main() { fun(); return 1; }最后的退出码是
0(exit(0)),而不是1。
5.exit和_exit区别
exit是 C 语言库函数,_exit是操作系统提供的系统调用。
printf("打印"); sleep(2); _exit(0); // 或 exit(0); return 0;两个函数对于缓冲区的刷新策略不同:
exit退出时会刷新(fflush)缓冲区,将“打印”输出出来。_exit则不会刷新缓冲区,直接退出,屏幕上可能看不到“打印”。
C 语言库的
exit函数底层会调用_exit,但在调用前会进行一些清理工作(如刷新缓冲区)。结论:输出缓冲区(这里指
stdout的缓冲区)的管理位于 C 语言标准库层面,而不是操作系统内核。
三、进程等待
1. 为什么要等待子进程结束?
如果父进程不管子进程,子进程结束会变成僵尸进程,造成内存泄漏,即使
kill命令也无法杀死僵尸进程。父进程需要知道子进程的执行结果(是否成功、退出码等),以便进行后续处理。
2. 如何等待?
wait函数等待任意一个子进程结束并回收。
父进程会阻塞,直到有子进程退出。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <string.h> #include <sys/wait.h> int main() { int id = fork(); if(id == 0) { // 子进程 int cnt = 5; while(cnt--) { printf("子进程,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(1); } exit(0); } // 父进程 sleep(10); int i = wait(NULL); if(i > 0) printf("回收子进程:%d\n", i); return 0; }waitpid函数pid_t waitpid(pid_t pid, int* status, int options)pid_t:成功时返回被等待子进程的 PID。pid_t pid:指定等待哪个子进程,-1表示等待任意子进程。int options:0表示阻塞等待;WNOHANG表示非阻塞轮询。
status参数的构成这是一个输出型参数,获取到的
status是一个整型(通常 32 位),其低 16 位有效。低 16 位中:
高 8 位(
bit 15~8):存储子进程的退出码(正常终止时)。低 7 位(
bit 6~0):存储导致子进程终止的信号编号(异常终止时)。第 7 位(
bit 7)是 core dump 标志位。
正常退出示例:子进程
exit(1),status值为256(1 << 8),右移 8 位后得到1。异常退出示例:用
kill -9杀死子进程,此时退出码部分为0,信号部分为9。可以用
kill -l命令查看信号编号与名称的对应关系。
通过位运算取出信息
if ((*status) & 0x7F) { // 低7位不为0,说明是信号终止 printf("回收子进程:%d,退出信号 %d\n", i, (*status) & 0x7F); } else { // 正常退出 printf("回收子进程:%d,退出值 %d\n", i, ((*status) >> 8) & 0xFF); }(*status) & 0x7F取出低 7 位(信号)。((*status) >> 8) & 0xFF取出高 8 位(退出码)。
其他常见退出信号
野指针:信号
11(SIGSEGV)除零错误:信号
8(SIGFPE)
使用宏简化判断
WIFEXITED(status):判断子进程是否正常退出(为真表示正常)。WEXITSTATUS(status):提取子进程的退出码(仅在正常退出时有效)。
非阻塞等待 (WNOHANG)
将
waitpid的第三个参数改为WNOHANG。返回值:
成功等待到子进程结束:返回子进程的
PID。子进程尚未结束:返回
0。出错:返回
-1。
与阻塞等待的区别:
阻塞等待会一直等待,直到子进程结束。
非阻塞等待会立即返回,允许父进程在等待期间做其他事情(轮询),效率更高。
这个
while循环就是非阻塞轮询。
int sta = 0; int *status = &sta; while (1) { int i = waitpid(id, status, WNOHANG); if (i > 0) { // 子进程已结束,处理 status break; } else if (i == 0) { // 子进程未结束 printf("waiting...\n"); sleep(1); } else { // 错误处理 break; } }
四、进程程序替换
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);执行这条语句,当前进程的代码就会被替换为
ls -l -a的代码。
1. 原理
进程 = 内核数据结构 + 代码和数据。
程序替换就是将新的程序代码和数据加载到当前进程的地址空间中,并调整页表映射,但进程的 PID、内核数据结构等保持不变。
2. 返回值
替换失败:返回
-1,程序继续执行当前代码。替换成功:不返回(因为原代码已被覆盖),新的程序从
main函数开始执行。因为代码会被替换,所以返回值在替换成功的情况下没有意义。
3.execl函数格式
int execl(const char* path, const char* arg, ...);path:要执行的程序的路径(包含程序名)。arg:命令行参数列表,...表示可变参数,以NULL结尾。
4. 在子进程中执行
int id = fork(); if (id == 0) { // 子进程 printf("子进程 pid: %d\n", getpid()); execl("./other", "./other", NULL); // 如果 execl 失败才会执行下面 perror("execl"); exit(1); } else { // 父进程 waitpid(-1, NULL, 0); }由子进程执行替换,不会干扰父进程。
为什么不会干扰?因为进程具有独立性。数据和代码(通过写时拷贝)不会相互干扰。注意,这里说的“代码不干扰”是指替换后子进程的代码完全独立。
程序替换前后子进程的 PID 不变,证明了没有新建进程,只是替换了内容。
进程替换使得我们可以用 C 程序作为加载器,来执行任何语言编写的程序。
5.exec系列函数
exec函数簇本质上是一个加载器,负责将磁盘上的可执行程序加载到内存并执行。
execlpp代表PATH环境变量。执行系统命令时,不需要写完整路径。
execlp("ls", "ls", "-l", "-a", NULL);注意:第一个参数
"ls"是去PATH中查找程序,第二个参数"ls"是传递给程序的argv[0]。
execvv代表vector,传递一个指针数组。
char* const argv[] = { "going", "a", "b", NULL }; execv("./other", argv);被替换的程序(
other)的main函数可以接收到这个参数数组。因此,子进程能够拿到父进程传递的命令参数,是
exec系列函数的功能。
int main(int argc, char* argv[]) { std::cout << "替换成功:" << getpid() << std::endl; for(int i = 0; i < argc; i++) { std::cout << i << ":" << argv[i] << std::endl; } return 0; }execvp是
v和p的组合,既支持PATH查找,又使用指针数组传参。
execvpe/execlee代表environment,可以传递一个新的环境变量数组,覆盖原有的环境变量。新进程将使用
env数组中的环境变量,原有的环境变量被覆盖。
char* const argv[] = {"going", "a", "b", NULL}; char* const env[] = {"newenv=111", "newenv2=222", NULL}; execvpe("./other", argv, env);如何新增环境变量而不覆盖?
方法一:使用
putenv函数修改当前进程的环境变量,然后使用execv(它会自动传递当前的环境变量)。putenv("a=222"); execv("./other", argv);方法二:先
putenv,然后获取当前环境变量数组environ,再传给execvpe。putenv("a=222"); extern char** environ; execvpe("./other", argv, environ);
execve这是 Linux 操作系统直接提供的系统调用接口,是
exec函数簇的底层实现。其他函数(如execl,execvp等)都是 C 库在此基础上进行的封装。
五、简单 Shell 的实现
1. 获取用户信息
从环境变量中获取用户名、主机名和当前路径。
const char* get_user() { const char* user = getenv("USER"); return user ? user : "unknown"; } const char* get_hostname() { const char* hostname = getenv("HOSTNAME"); return hostname ? hostname : "unknown"; } const char* get_pwd() { const char* pwd = getenv("PWD"); return pwd ? pwd : "unknown"; }2. 输出提示符(如[user@host path]$)
#define FORMAT "[%s@%s %s]# " #define MAX_COMMANDSIZE 256 void MakeCommandLine(char cmd_prompt[], int size) { snprintf(cmd_prompt, size, FORMAT, get_user(), get_hostname(), get_pwd()); } void printcommandprompt() { char prompt[MAX_COMMANDSIZE]; MakeCommandLine(prompt, sizeof(prompt)); printf("%s", prompt); fflush(stdout); // 立即刷新输出 }使用
snprintf将格式化的字符串写入prompt数组中,然后打印。
3. 获取和解析命令
bool GetCommandLine(char* c, int size) { char* command = fgets(c, size, stdin); if (command == nullptr) return false; c[strlen(c) - 1] = '\0'; // 去掉末尾的换行符 if (strlen(c) == 0) return false; return true; } #define SEP " " char* g_argv[64]; int g_argc = 0; bool CommandSplice(char* commandline) { g_argc = 0; g_argv[g_argc++] = strtok(commandline, SEP); while ((g_argv[g_argc++] = strtok(NULL, SEP))); g_argc--; return true; }fgets从标准输入读取一行命令(包括换行符)。strtok按空格分隔命令字符串,存入g_argv数组。
4. 创建子进程执行外部命令
void Execute() { int id = fork(); if (id == 0) { // 子进程 execvp(g_argv[0], g_argv); perror("execvp"); // 如果替换失败 exit(1); } // 父进程等待 int rid = waitpid(id, nullptr, 0); }5. 内建命令 (Built-in Command)
有些命令需要 Shell 自己执行,而不是创建子进程,例如
cd命令(改变当前 Shell 进程的工作目录)。
void cd() { if (g_argc == 1) { // 只有 cd,切换到 home 目录 const char* home = getenv("HOME"); chdir(home); } else { // cd path chdir(g_argv[1]); } // 更新 PWD 环境变量 char cwd[256]; getcwd(cwd, sizeof(cwd)); char pwd_env[300]; snprintf(pwd_env, sizeof(pwd_env), "PWD=%s", cwd); putenv(pwd_env); } bool builtin() { string cmd = g_argv[0]; if (cmd == "cd") { cd(); return true; } // 可以添加其他内建命令,如 exit, export 等 return false; }chdir系统调用改变当前进程的工作目录。putenv设置或修改环境变量PWD。在 Shell 主循环中,先判断是否为内建命令,如果是则直接执行,否则创建子进程执行。
int main() { while (1) { printcommandprompt(); char commandline[MAX_COMMANDSIZE]; if (!GetCommandLine(commandline, sizeof(commandline))) continue; if (!CommandSplice(commandline)) continue; if (builtin()) continue; // 是内建命令,直接执行 Execute(); // 外部命令,创建子进程执行 } return 0; }注意:像
$?这样的命令也需要内建实现,因为$?需要 Shell 进程(父进程)获取上一个命令的退出状态,这无法通过创建子进程来完成。
代码
#include<iostream> #include<stdlib.h> #include<string.h> #include<stdio.h> #include<sys/types.h> #include<sys/wait.h> #include<unistd.h> using namespace std; const int MAX_COMMANDSIAE=1024; #define FORMAT "[%s@%s %s]# " const int MAX_SIZE=128; char* g_argv[MAX_SIZE]; int g_argc=0; const char* get_user() { const char* user=getenv("USER"); return user; } const char* get_hostname() { const char*hostname=getenv("HOSTNAME"); return hostname; } const char* get_pwd() { char cwd[100]; const char*pwd=getcwd(cwd,sizeof(cwd)); char p[100]; snprintf(p,sizeof(p),"PWD=%s",pwd); putenv(p); return pwd; } const char*get_home() { const char*home=getenv("HOME"); return home; } void MakeCommandLine(char cmd_prompt[],int size) { snprintf(cmd_prompt,size,FORMAT,get_user(),get_hostname(),get_pwd()); } void printcommandprompt() { char prompt[MAX_COMMANDSIAE]; MakeCommandLine(prompt,sizeof(prompt)); printf("%s",prompt); fflush(stdout); } bool GetCommandLine(char*c,int size) { char*command=fgets(c,size,stdin); if(command==nullptr)return 0; c[strlen(c)-1]=0; if(strlen(c)==0)return 0; return 1; } bool CommandSplice(char*commandline) { #define SEP " " g_argc=0; g_argv[g_argc++]=strtok(commandline,SEP); while(g_argv[g_argc++]=strtok(nullptr,SEP)); g_argc--; return 1; } void Excute() { int id=fork(); if(id==0) { execvp(g_argv[0],g_argv); exit(0); } int rid=waitpid(id,nullptr,0); } void cd() { if(g_argc==1) { string home=get_pwd(); chdir(home.c_str()); } else { string where=g_argv[1]; chdir(where.c_str()); } } bool builtin() { string met=g_argv[0]; if(met=="cd") { cd(); return 1; } return 0; } int main() { while(1) { printcommandprompt(); char commandline[MAX_COMMANDSIAE]; if(!GetCommandLine(commandline,sizeof(commandline)))continue; CommandSplice(commandline); if(g_argc==0)continue; if(builtin())continue; Excute(); } return 0; }