ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux父进程与子进程:从fork到僵尸进程的实战解析

嵌入式Linux父进程与子进程:从fork到僵尸进程的实战解析 最近在折腾飞凌嵌入式ElfBoard这块板子跑Linux调进程时碰到一堆和父进程、子进程相关的乱七八糟问题。嵌入式开发里进程这个概念太关键了尤其当你开始用fork创建子进程或者发现系统里多了个僵尸进程却不知道是谁搞出来的时候真的会被绕晕。这篇文章我就结合在ElfBoard上的实际调试经历把父进程和子进程这块彻底理一遍从原理到实操到排错一次讲透。1. 先理清父子进程的关系开发板上怎么看1.1 进程为什么会有爸爸和儿子进程不是一个孤立的概念。你写的每个程序跑到内存里一定是由某个地方拉起来的拉起它的那个进程就是父进程Parent Process被拉起来的这个就是子进程Child Process。这两个概念不是随便叫叫的系统内核在创建进程的时候真的会把父子关系记录在PCB进程控制块里通过PPID字段指向父进程的PID。刚开始在PC上玩Linux可能没太大感觉但在嵌入式板子上资源紧张、系统精简进程之间的关系一旦乱了排查起来要比服务器上麻烦得多。比如你在ElfBoard上把一个串口调试程序做成开机自启它是由哪个进程拉起来的这个“爸爸”的身份直接决定了程序死了之后由谁来收拾残局。如果父进程先挂了子进程就成了孤儿会被PID为1的init进程收养这就要涉及孤儿进程的底层逻辑了。每个进程都有唯一的标识符PID同时还有一个PPID。PID是在内核分配进程描述符时从pid_max范围内递增分配的PPID则是从父进程的task_struct里直接拷贝过来的。在/proc文件系统里每个进程都对应一个以PID命名的目录你看一眼/proc/1234/status里的PPid字段就知道它的爸爸是谁了。1.2 从命令行到进程树一条完整的血缘链你在ElfBoard上敲一个命令比如ls或者topshell一般就是bash或者busybox的ash会先fork一个子进程然后在这个子进程里exec加载ls的镜像。也就是说每次你运行命令命令行解释器都在扮演“父亲”的角色。而且这个关系是层层传递的init进程是系统启动后第一个用户态进程所有用户进程最终都是它的子孙整棵进程树根扎在init身上。用pstree可以直观看到这棵树的形状。在ElfBoard系统的终端里敲pstree会看到一坨嵌套的进程关系树的顶层是PID 1下面是各个服务。如果你写了个小程序叫test_process运行后再开一个软件终端敲ps就能看到bash作为父进程下面挂着test_process这个子进程的PPID正好指向bash的PID。这里有一个特别要提醒的点同一个父进程可以创建多个子进程但一个子进程同时只有一个父进程。父进程退出后子进程不会被一起杀掉而是被“过继”给最近的subreaper如果没有subreaper就给init。这是很多刚接触嵌入式Linux的人第一反应错的点以为父进程退了子进程也跟着退了实际上完全不是这样父子进程是独立调度的两个实体。1.3 在ElfBoard上快速定位进程的爸爸是谁实际操作中最常用的命令就是ps而且要用带ef参数的看。在ElfBoard上装的是busybox的话ps -ef可能不支持完整格式这时候可以试试ps -aux或者直接用cat /proc/进程号/status。比如你想查一个叫app_demo的进程是谁拉起的先用pidof或者ps找到它的PID然后执行cat /proc/PID/status在输出里找PPid这一行再拿这个PPid去ps里反查就能顺藤摸瓜找到它的父进程。我在板子上调试一个GUI程序时就发现它居然是被某个服务脚本拉起来的而不是我自己手动跑的那个进程这就解释了为什么程序退了又被自动拉起来因为服务脚本里有while循环加重启逻辑。ps -ef输出里的第一列UID、第二列PID、第三列PPID马上就能看出来进程的家谱。看惯了之后几乎不用进/proc一眼就能判断异常的进程是谁生成的。这个技能在嵌入式现场非常实用你不可能在客户的设备上装gdb慢慢调试但ps和/proc是肯定有的。2. fork才是子进程的真正来源2.1 为什么嵌入式开发绕不开forkLinux里创建子进程最根本的方式是fork系统调用内核复制一份当前进程的描述符和内存映射产生一个和父进程几乎一模一样的子进程。嵌入式开发里你躲不开fork原因很简单很多守护进程和服务框架就是用fork去派生工作进程的比如常见的热插拔管理、网络服务监听进程都是父进程监听端口每来一个连接就fork一个子进程去处理。还有一类情况是你要跑多个任务但不想用线程因为线程共享地址空间一个线程把内存写坏了整个进程都崩。用子进程隔离更安全子进程崩了父进程还能继续跑。这个思路在我们做嵌入式软件升级时特别常用升级程序跑在子进程里就算升级过程中段错误了主程序还活着能给你留一条恢复的后路。ElfBoard这块板子是跑Linux的用的Linux内核本身就定义死了进程一定是用fork或者它的变体clone创建的。你就算什么都不做开机后系统初始化时也是在内核里复刻出了一个又一个进程。理解了fork你才能真正理解Linux的进程模型而不是停留在“进程就是运行中的程序”这种表面认知上。2.2 fork返回值的玄机一个函数返回两个PIDfork的返回值设计是学习进程编程时最容易犯迷糊的地方。一个fork调用在主流程里看起来只调用了一次但调用返回后父子两个进程各得到一次返回值。父进程得到的返回值是子进程的PID子进程得到的返回值是0如果失败则返回-1。这个设计第一次接触确实很反直觉但它的用途非常清晰通过判断返回值同一个函数里就能让父子进程走不同的代码分支。返回值是全系统范围内唯一标识子进程的PID父进程后面要wait子进程全靠它返回值0则表示“当前在子进程里”子进程不知道自己的PID也没关系可以用getpid()现查。我在ElfBoard上写第一个fork测试的时候代码是这样#include stdio.h #include unistd.h #include sys/types.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork failed); return 1; } else if (pid 0) { printf(我是子进程, PID%d, 父进程PPID%d\n, getpid(), getppid()); } else { printf(我是父进程, PID%d, 子进程PID%d\n, getpid(), pid); } return 0; }编译后放到ElfBoard上运行你会第一次直观感受到明明是写在一个程序里的代码却有两个进程各执行了一遍而且printf打印出来的内容会有两个不同的PID。这里有坑printf是标准库带的缓冲IO在终端场景下通常是行缓冲遇到换行会flush但如果你的程序把输出重定向到文件或者用system()包了一层没加换行的时候缓冲区会被父子进程各拷一份结果一条字符串被打印两次。这是fork带来的经典双写问题后续我会专门再讲。2.3 说好的“复制”其实是写时复制fork的语义是复制父进程的整个地址空间但内核不可能在fork时真的把所有内存全部拷贝一份那样又慢又浪费。Linux用的是写时复制Copy-On-Write, COWfork时父子进程共享物理内存页页表都标记为只读谁先写入谁才触发缺页异常内核把这一页真正复制一份后再写入。这个机制对嵌入式系统意义重大。板子内存本身就紧张假设一个父进程占了80MB内存如果fork上来就完整复制瞬间要160MB很可能直接OOM。有了COWfork只是复制了页表和task_struct开销小得多。但要注意这也意味着你在子进程里大面积写内存的时候内存占用会真实涨起来在ElfBoard这种内存可能只有256MB的板子上写很多新数据时记得留意free的输出。还有一个嵌入式里常被忽略的点fork后子进程继承了很多父进程的资源比如打开的文件描述符、信号处理函数、环境变量。这些不是空的它们会被拷贝或者引用计数增加。如果父进程打开了串口设备、socket套接字fork出的子进程手里也有同一份引用那么在子进程里关掉描述符并不会真正释放资源因为父进程还握着。这一点在做串口通信程序时很致命父进程监听串口fork了子进程去处理数据子进程退出时把串口fd关了你以为关掉了其实父进程还占着导致设备被占用。3. 在ElfBoard上实操亲手创建一个子进程3.1 最小demo用fork创建子进程并观察运行顺序前面说的理论比较干还是上手见真章。我在ElfBoard上做了一组小实验Pi板子是通过串口终端连上去的Linux系统起来之后先写一个最简单的fork程序编译完扔进可执行文件里跑。代码很简单就是2.2节里那个demo。但这次我在fork前后加了几个计时的打印观察调度顺序。结果很有意思大部分时候父进程先打印子进程后打印但偶尔子进程会先跑。原因在于fork返回后两个进程都处于就绪态CPU调度器按优先级和时间片来调度谁先拿到CPU谁先执行并不保证父进程一定优先。这个“不确定顺序”的特性在编写依赖时序的程序时是最容易埋雷的两个进程如果同时在写同一个文件先后顺序乱结果就会错。我在板子上反复跑了20多次统计下来父进程先打印的次数略多但这不是规律只是宿主的调度器行为。如果你的程序逻辑强依赖父子进程执行的先后顺序需要显式加同步机制比如用wait让父进程等子进程退出或者用管道、信号做同步。绝对不要指望fork之后天然有个先后顺序。3.2 如何区分父子进程并各干各的活实际写业务代码时fork之后干的第一件事就是处理返回值然后各干各的。我经常在板子上写一种模式父进程负责监听外部事件比如等待按键中断或者网络连接子进程负责去做耗时操作比如写Flash或者处理图片数据。这里涉及一个选择用fork还是用线程。我的经验是如果任务之间要共享大量数据用线程加锁效率高如果任务要完全隔离想安全第一用子进程。在嵌入式里跑第三方解码库我宁可放子进程里一来库内部可能申请很多内存二来一旦库崩溃导致段错误子进程挂了父进程还能捕获SIGCHLD信号并重启它。各干各的活有一个要注意的细节父子进程的内存是独立的副本COW之后的父进程修改一个全局变量子进程完全看不到。很多新手从线程模型切换过来下意识以为父进程改了变量子进程能读到结果数据对不上。要在父子进程间传递数据得用进程间通信IPC最轻量的就是管道pipe。我在ElfBoard的串口通信程序里就是父进程读串口数据通过管道发给子进程子进程解析协议并转发数据单向流动清爽不打架。3.3 僵尸进程是怎么出现的如何用wait回收fork子进程之后子进程退出内核并不会马上把它完全清理掉。内核需要保留子进程的退出状态信息比如退出码和资源使用统计供它的父进程查询。这个“已经死了但还没被父进程收走尸体”的进程状态就是僵尸状态。进程信息里显示为Zzombie用ps能看到。我在ElfBoard上做了个实验子进程直接exit(42)父进程在fork之后用一个sleep(60)挂住这时候在另一个终端里ps aux就能看到一个PID很小的僵尸进程。父进程不调用wait或者waitpid僵尸就一直在那占着进程表项。僵尸进程不占CPU不占内存但它占用一个PID而系统PID总数有限pid_max默认值是32768僵尸多了PID被耗尽新进程就fork不出来了。正确做法是父进程调用waitpid。最简单实用的是阻塞等待int status; pid_t pid waitpid(child_pid, status, 0); if (WIFEXITED(status)) { printf(子进程退出码: %d\n, WEXITSTATUS(status)); }这样才能把子进程的残留信息回收掉。如果父进程不想阻塞等待可以在代码里给SIGCHLD信号挂一个处理函数或者用WNOHANG参数轮询。在ElfBoard上的服务程序里我用的就是非阻塞的waitpid搭配轮询不拖慢主流程。需要注意waitpid的pid参数传-1表示等待任意子进程传具体PID则精确等待某个子进程这在有多个子进程的场景里特别关键避免把别的子进程的退出状态误收了。4. 嵌入式多进程的经典坑孤儿态、僵尸态与退出码4.1 init收养孤儿进程为什么不会失控父进程先于子进程退出子进程就变成孤儿进程。孤儿进程不会永远没了依靠内核会自动让PID 1的init进程现在很多嵌入式系统里是busybox init成为它的新父进程。子进程结束时init会负责回收它的状态信息所以孤儿进程一般不会变成僵尸。但这个机制也有副作用我在板子上写串口程序时碰到过父进程意外退出子进程还在跑并且子进程里还在访问父进程留下来的管道或者socket。父进程的fd在子进程里仍然有效但另一端已经没人读取了子进程在写管道时会触发SIGPIPE信号默认动作是终止进程。这算好的如果子进程继续持有某个锁文件父进程退出了锁没释放下次启动新实例时就会发现资源被占。用flock锁的时候如果持锁进程的父进程退出但子进程还活着那锁依然被持有因为锁是跟着文件描述符走的子进程继承了它。所以设计嵌入式程序时最好明确如果你不希望某个任务随父进程的崩溃而终止那就让它成为孤儿交给init去管如果你希望它跟着父进程走要用进程组和信号来管理比如prctl(PR_SET_PDEATHSIG)设置父进程死亡时子进程收到某信号。这个接口在嵌入式Linux里很实用能实现“父进程挂了子进程也自杀”的效果避免一堆残留进程。4.2 僵尸进程的危害与三种清理方式僵尸进程不是bug它是正常的进程生命周期阶段但长期大量堆积就是问题了。在ElfBoard这种长期开机的设备上如果某个服务每次处理完任务fork出的子进程都成了僵尸几百上千次之后进程表就会被占满到时候ssh都连不进来ps命令都跑不动。三种清理方式我逐个试过。第一种用wait/waitpid在父进程里主动回收这个是最推荐的代码层面的根本解法。第二种如果父进程写了SIGCHLD信号处理函数并且在函数里调用waitpid(-1, status, WNOHANG)那么即使信号乱序到达也能把所有退出的子进程都清理掉。注意在信号处理函数里只能调用异步信号安全函数waitpid是安全的printf不安全。第三种实在找不到是谁的父进程就用kill把还在运行的父进程干掉系统在父进程退出后会重新挂接子进程到initinit会自动回收。这个方法简单粗暴但可能会影响其他功能。在嵌入式现场如果出现大量僵尸我一般先跑一个ps -eo ppid,pid,stat,comm查看僵尸的PPID然后定位到具体父进程查它为什么不回收而不是盲目kill。4.3 从d2l安装失败的报错看子进程退出码的意义这个话题不止一次被群里人问起。有人在装d2l深度学习动手学代码库时pip install d2l报错提示subprocess-exited-with-error。他不是嵌入式场景但这背后正是父子进程的概念pip是父进程为了执行setup.py里的构建逻辑会拉起一个子进程通过subprocess模块子进程退出时返回了一个非零的退出码pip就认为构建失败终止安装并报错。这个错误几乎和进程模型紧密相关。子进程退出码是0表示成功非0表示失败。你用os.system或者system()时返回值的低位是信号编号高位是退出码要取WEXITSTATUS才能拿到真正的退出码。Python的subprocess模块比较友好直接抛CalledProcessError里面就有returncode。排查这类问题的思路是先看父进程到底执行了什么命令pip的报错信息里通常会带出完整命令行然后在板子或者本地手动跑一遍这个命令看子进程直接失败的真正原因。我帮人排查过几次大部分情况是缺依赖库或者是编译环境中没有找到gcc还有的是下载依赖时的网络原因。本质上子进程退出码只是一个结果信号背后原因还需要看日志和stderr。在嵌入式板子上装Python包时要特别留意ARM架构下很多包没有预编译wheel需要现场编译这时如果板子上缺少编译工具链子进程就会以非零码退出看起来就像pip出bug了实际是工具链的问题。5. 真实场景里的“子进程”CEF多进程架构与系统稳定5.1 CEF为什么非要搞多进程还有个常见热词是“CEF用自己的子进程”。CEF是Chromium Embedded Framework很多嵌入式设备的HMI界面就是用CEF做的ElfBoard这类平台性能足够跑个CEF当UI层是很常见的方案。CEF默认是多进程架构一个Browser主进程父进程下面挂Renderer子进程、GPU进程、Utility进程等等。为什么非要搞多进程因为浏览器引擎的渲染和解析本身就容易出错如果全部塞进一个进程页面一崩整个应用直接退出在设备上就是屏幕黑掉或者界面消失非常致命。多进程之后一个渲染进程崩了Browser进程还在可以重新拉起新的Renderer代价只是那个页面重新加载。这对嵌入式设备很有价值毕竟设备通常7x24小时跑不能动不动就弹崩溃框或者重启。CEF的父进程就是Browser进程它负责创建和管理各个子进程。子进程不是同时在同一个任务里fork出来的而是按需创建有渲染任务才拉起Renderer一开始空页面的时候可能只有一个GPU进程。在ElfBoard上看到一堆cef的子进程机器负载还高时多半是Render进程一直在CPU跑要用top确认一下是哪个具体子进程在吃资源。5.2 子进程崩溃后的处理策略CEF有自己的崩溃恢复机制Browser进程收到子进程的退出状态后会尝试重建但也不是万能的。我在板子上调CEF应用时遇到渲染进程反复崩溃的情况直观表现是界面闪一下又恢复。这种行为背后是父进程在检测到子进程异常退出后根据配置决定要不要重启、要不要抛出异常事件。对我自己做嵌入式应用的建议是如果你自己用fork创建子进程来执行危险任务一定要设计好重启策略。我的做法是维护一个简单计数器子进程在短时间内连续崩溃超过一定次数比如5次就不再无限重启而是记录日志并进入降级模式避免变成一个死循环拉进程的“自杀式恢复”让系统卡死。这一点在资源有限的板子上特别重要无限重启一个内存占用大的子进程最终会触发OOM。另外子进程退出时如果有未写完的日志或者未落盘的数据父进程要负责做善后处理。比如子进程操作Flash中途退出可能会导致数据半写状态父进程收到SIGCHLD后要检查标志位决定是否回滚。这是我在做掉电安全相关的更新程序时踩过坑总结出来的fork子进程之前先规划好崩溃后由谁来做数据一致性的补偿动作。5.3 排查进程问题时的三板斧最后把我在ElfBoard上排查进程问题最常用的三板斧总结一下都是不用装额外工具就能用的。第一板斧是ps配合/proc。ps -eo pid,ppid,stat,comm看全局cat /proc/PID/status深入单个进程。你判断一个进程是不是僵尸看stat列的通配符最直观Z代表zombieR是runningS是sleepingD是uninterruptible sleep。第二板斧是top的CPU占用排序。嵌入式设备上出现卡顿经常是某个子进程在无限循环跑满CPUtop里按P键按CPU排序马上就能揪出来。如果是多核平台记得top里按1看每个CPU的负载分布有些进程会绑核运行看起来整体负载不高但某一核已经被占满了。第三板斧是strace。板子上如果空间允许busybox里没有strace可以自己交叉编译一个放进去。strace -p PID可以实时跟踪进程的系统调用能看到进程卡在哪个系统调用上。排查子进程假死特别有效可能它阻塞在一个read上或者等待网络socket的数据一跟踪就明白了。我遇到过子进程占着CPU但啥也不干的情况strace一看它在一个nanosleep循环里自旋等待完全是我自己逻辑写的有问题加了个mutex但忘了释放导致子进程一直在等锁。这些工具和方法都不依赖图形界面串口终端就能操作。嵌入式调试环境往往就是这么朴素但足够解决90%的进程问题。6. 进程编程里那些文档不会写的实战细节6.1 文件描述符继承是最容易被忽略的隐形炸弹fork之后子进程会继承父进程所有打开的文件描述符包括socket、串口fd、普通文件fd。继承意味着引用计数增加父进程关闭fd不会让底层文件对象释放因为它还被子进程引用着。我在ElfBoard上写一个网络服务程序时父进程bind了端口并listen接着fork了子进程。子进程退出前习惯性调用了close(sockfd)结果发现端口根本没有被释放还在被占用。后来才意识到父进程手里还保持着同一个socket fd的引用listen还在继续。正确做法是在fork之后父子进程各自关闭自己不需要的fd父进程保留listen fd子进程关闭listen fd再去做自己的事。嵌入式场景里还有一个点容易忽略串口fd被继承后两个进程同时去读同一个串口会竞争数据。如果你fork了一个子进程又没有明确谁负责读串口数据会被两个进程随机读取协议直接乱掉。我的建议是进程创建后第一时间明确fd归属不需要的fd在子进程入口处统一关闭这是一种代码习惯能省掉后面一大半莫名其妙的bug。6.2 内存占用与OOM风险的评估COW机制下fork本身不占多少物理内存但子进程一旦开始大量写操作内存占用会逐渐分离出来。在ElfBoard这种内存有限的板子上开启一个进程前先估算它的内存开销非常有必要。我的做法是在程序启动时记录/proc/meminfo里的MemAvailable一段时间后反复对比看内存是否持续下降。如果子进程里加载了比较大的静态数据或者图资文件父进程的页缓存可能不算进程占用但子进程写入了大量数据就会真实计入。使用malloc分配但不写时内存不会立刻全部分配只有写的时候才消耗物理页这个理解COW后就会很清楚。如果板子内存较小还有一个建议用vfork代替fork在某些极端情况下更快但vfork的语义是子进程先运行且共享父进程地址空间直到exec或exit使用要特别小心子进程里不能乱改父进程的变量。现在的Linux里vfork基本是fork的优化变体但我在嵌入式系统上依然会用vfork来做简单的任务拉起因为它避免了复制页表的过程适合那种fork后马上exec的场景。不过用vfork时一旦子进程有往stdout打印缓冲区的操作很容易出问题建议优先用标准fork更稳健。6.3 进程上限与PID耗尽问题嵌入式设备上pid_max默认值一般是32768也就是说系统同时最多只有32768个PID可用。频繁fork子进程并且不回收PID分配会逐渐攀升最终出新进程失败“Resource temporarily unavailable”。这个问题的典型场景就是前面说的僵尸进程积累。还有一种情况是程序里循环fork每次都等子进程退出但父进程忘了waitpid僵尸堆积。在长期运行的设备上这种bug可能要跑几个星期才暴露但在实验室里很难发现因为测试时间根本不够。我的建议是在代码里加入子进程数量的监控通过读取/proc/loadavg或者sysconf(_SC_CHILD_MAX)在逼近阈值时打日志。另外Linux里每个用户都有进程数限制通过ulimit -u查看。嵌入式系统中往往只用一个root用户root默认不受限制但容器或者安全配置可能会加上限制。如果你在板子上跑Docker或者使用systemd服务注意LimitNPROC的设置别让默认限制卡死你的服务。我在实际中碰到的比较隐蔽的问题是systemd服务长时间运行后重启服务时提示失败查日志发现是子进程未被正确回收导致服务启动时PID文件被锁。这个问题表面上是锁文件冲突底层就是僵尸进程没清理干净。遇到这类问题先清理系统里的僵尸进程再看看服务代码里的wait逻辑多半是父进程在初始化阶段没设置SIGCHLD的回收。7. 最后的一点实践经验父亲进程和子进程的概念在嵌入式Linux里可以说是基础中的基础但你真正把它用到极致需要一次次的实战踩坑。我在ElfBoard上从最开始的fork打印到后来写串口服务、CEF界面集成、远程升级程序几乎每个环节都会遇到父子进程的衍生问题说实在的Linux内核里这一套进程模型设计得已经很优雅了但用得好不好全看开发者的理解和习惯。如果让我给刚开始接触嵌入式Linux进程编程的朋友一个建议那就是动笔写fork之前先想清楚三个问题。第一子进程的目的是什么独立任务还是协同处理第二父进程怎么感知子进程的退出是用wait阻塞还是用SIGCHLD第三子进程出问题之后父进程要不要恢复它恢复的边界条件是什么。这三个问题想清楚了进程代码基本不会出大乱子。另外在嵌入式板子上一定要多看系统的原始信息不要只看自己程序的调试输出。ps、/proc、dmesg这些免费的观察窗口往往能一眼看出问题本质。比如子进程崩溃了dmesg里可能会有segment fault的提示ps里能看到退出状态这些比你在代码里printf一万句都管用。做嵌入式就是这么朴素有时候一个ps命令比上gdb调试半天还来得快。
返回列表