核心内容摘要
openpilot个性化设置指南:打造专属驾驶体验
目录
进程创建
fork 函数
写时拷贝 (Copy-On-Write, COW)
为什么要写时拷贝
fork 失败的原因
进程终止
退出场景
获取返回值
退出码标准
exit 退出
exit 和 _exit 区别
进程等待
为什么要等待子进程结束
如何等待
进程程序替换
原理
返回值
execl 函数格式
在子进程中执行
exec 系列函数
简单 Shell 的实现
获取用户信息
输出提示符如 [userhost path]$
获取和解析命令
创建子进程执行外部命令
内建命令 (Built-in Command)
进程创建
fork函数分配资源为子进程分配task_struct、mm_struct等内核数据结构。
拷贝数据将父进程的 PCBtask_struct、页表等拷贝到子进程中。
加入调度将子进程添加到系统的进程列表中并加入到调度队列的expired组。
返回fork函数返回。
如果创建的子进程专门执行系统内核代码这就是内核级虚拟机如KVM的原理。
写时拷贝 (Copy-On-Write, COW)创建子进程时操作系统会将父、子进程页表的映射权限设置为只读。
当子进程或父进程需要修改一个共享的变量时虽然地址没问题但权限不符此时会触发写时拷贝。
操作系统会为修改者子进程重新开辟一块物理内存拷贝数据并修改其页表映射最终完成写入。
为什么要写时拷贝加快子进程创建速度无需立即拷贝全部数据创建瞬间非常快。
减少内存资源浪费由于子进程通常不会使用父进程的全部数据对于不修改的部分父子可以共享无需额外占用内存。
写时拷贝是一种精细化的内存管理策略。
fork失败的原因系统中进程总数过多。
用户的进程数超过了限制。
进程终止
退出场景正常结束结果正确返回0。
正常结束结果错误返回非0即退出码。
异常结束返回值无意义程序被信号终止。
获取返回值使用echo $?命令可以打印最近一个进程的退出码。
进程的退出码存储在task_struct结构体内。
父进程回收僵尸进程时可以从中获取退出码。
再次执行echo $?会显示0因为这时获取到的是echo命令本身的退出码。
退出码标准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没有意义。
因此异常退出时退出码无意义。
exit退出main函数的返回代表进程结束而普通函数的返回只代表该函数结束。
如果要在函数中就结束整个进程可以使用exit。
void fun() { exit(
; } int main() { fun(); return 1; }最后的退出码是0exit(
而不是1。
exit和_exit区别exit是 C 语言库函数_exit是操作系统提供的系统调用。
printf(打印); sleep(
; _exit(
; // 或 exit(
; return 0;两个函数对于缓冲区的刷新策略不同exit退出时会刷新fflush缓冲区将“打印”输出出来。
_exit则不会刷新缓冲区直接退出屏幕上可能看不到“打印”。
C 语言库的exit函数底层会调用_exit但在调用前会进行一些清理工作如刷新缓冲区。
结论输出缓冲区这里指stdout的缓冲区的管理位于 C 语言标准库层面而不是操作系统内核。
进程等待
为什么要等待子进程结束如果父进程不管子进程子进程结束会变成僵尸进程造成内存泄漏即使kill命令也无法杀死僵尸进程。
父进程需要知道子进程的执行结果是否成功、退出码等以便进行后续处理。
如何等待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
{ // 子进程 int cnt 5; while(cnt--) { printf(子进程pid%dppid%d\n,getpid(),getppid()); sleep(
; } exit(
; } // 父进程 sleep(
; int i wait(NULL); if(i
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 options0表示阻塞等待WNOHANG表示非阻塞轮询。
status参数的构成这是一个输出型参数获取到的status是一个整型通常 32 位其低 16 位有效。
低 16 位中高 8 位bit 15~8存储子进程的退出码正常终止时。
低 7 位bit 6~0存储导致子进程终止的信号编号异常终止时。
第 7 位bit 7是 core dump 标志位。
正常退出示例子进程exit(
status值为2561 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)
0xFF); }(*status) 0x7F取出低 7 位信号。
((*status)
0xFF取出高 8 位退出码。
其他常见退出信号野指针信号11(SIGSEGV)除零错误信号8(SIGFPE)使用宏简化判断WIFEXITED(status)判断子进程是否正常退出为真表示正常。
WEXITSTATUS(status)提取子进程的退出码仅在正常退出时有效。
非阻塞等待 (WNOHANG)将waitpid的第三个参数改为WNOHANG。
返回值成功等待到子进程结束返回子进程的PID。
子进程尚未结束返回0。
出错返回-1。
与阻塞等待的区别阻塞等待会一直等待直到子进程结束。
非阻塞等待会立即返回允许父进程在等待期间做其他事情轮询效率更高。
这个while循环就是非阻塞轮询。
int sta 0; int *status sta; while (
{ int i waitpid(id, status, WNOHANG); if (i
{ // 子进程已结束处理 status break; } else if (i
{ // 子进程未结束 printf(waiting...\n); sleep(
; } else { // 错误处理 break; } }
进程程序替换execl(/usr/bin/ls, ls, -l, -a, NULL);执行这条语句当前进程的代码就会被替换为ls -l -a的代码。
原理进程 内核数据结构 代码和数据。
程序替换就是将新的程序代码和数据加载到当前进程的地址空间中并调整页表映射但进程的 PID、内核数据结构等保持不变。
返回值替换失败返回-1程序继续执行当前代码。
替换成功不返回因为原代码已被覆盖新的程序从main函数开始执行。
因为代码会被替换所以返回值在替换成功的情况下没有意义。
execl函数格式int execl(const char* path, const char* arg, ...);path要执行的程序的路径包含程序名。
arg命令行参数列表...表示可变参数以NULL结尾。
在子进程中执行int id fork(); if (id
{ // 子进程 printf(子进程 pid: %d\n, getpid()); execl(./other, ./other, NULL); // 如果 execl 失败才会执行下面 perror(execl); exit(
; } else { // 父进程 waitpid(-1, NULL,
; }由子进程执行替换不会干扰父进程。
为什么不会干扰因为进程具有独立性。
数据和代码通过写时拷贝不会相互干扰。
注意这里说的“代码不干扰”是指替换后子进程的代码完全独立。
程序替换前后子进程的 PID 不变证明了没有新建进程只是替换了内容。
进程替换使得我们可以用 C 程序作为加载器来执行任何语言编写的程序。
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[] {newenv111, newenv2222, NULL}; execvpe(./other, argv, env);如何新增环境变量而不覆盖方法一使用putenv函数修改当前进程的环境变量然后使用execv它会自动传递当前的环境变量。
putenv(a
; execv(./other, argv);方法二先putenv然后获取当前环境变量数组environ再传给execvpe。
putenv(a
; extern char** environ; execvpe(./other, argv, environ);execve这是 Linux 操作系统直接提供的系统调用接口是exec函数簇的底层实现。
其他函数如execl,execvp等都是 C 库在此基础上进行的封装。
简单 Shell 的实现
获取用户信息从环境变量中获取用户名、主机名和当前路径。
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; }
输出提示符如[userhost 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数组中然后打印。
获取和解析命令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)
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数组。
创建子进程执行外部命令void Execute() { int id fork(); if (id
{ // 子进程 execvp(g_argv[0], g_argv); perror(execvp); // 如果替换失败 exit(
; } // 父进程等待 int rid waitpid(id, nullptr,
; }
内建命令 (Built-in Command)有些命令需要 Shell 自己执行而不是创建子进程例如cd命令改变当前 Shell 进程的工作目录。
void cd() { if (g_argc
{ // 只有 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 (
{ printcommandprompt(); char commandline[MAX_COMMANDSIZE]; if (!GetCommandLine(commandline, sizeof(commandline))) continue; if (!CommandSplice(commandline)) continue; if (builtin()) continue; // 是内建命令直接执行 Execute(); // 外部命令创建子进程执行 } return 0; }注意像$?这样的命令也需要内建实现因为$?需要 Shell 进程父进程获取上一个命令的退出状态这无法通过创建子进程来完成。
代码#includeiostream #includestdlib.h #includestring.h #includestdio.h #includesys/types.h #includesys/wait.h #includeunistd.h using namespace std; const int MAX_COMMANDSIAE1024; #define FORMAT [%s%s %s]# const int MAX_SIZE128; char* g_argv[MAX_SIZE]; int g_argc0; const char* get_user() { const char* usergetenv(USER); return user; } const char* get_hostname() { const char*hostnamegetenv(HOSTNAME); return hostname; } const char* get_pwd() { char cwd[100]; const char*pwdgetcwd(cwd,sizeof(cwd)); char p[100]; snprintf(p,sizeof(p),PWD%s,pwd); putenv(p); return pwd; } const char*get_home() { const char*homegetenv(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*commandfgets(c,size,stdin); if(commandnullptr)return 0; c[strlen(c)-1]0; if(strlen(c)