Linux: vfork() 程序异常退出问题分析
admin
2024-05-19 20:54:34
0

1. 前言

限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。

2. 分析环境

本文基于 Linux 4.14 内核源码进行分析。测试环境为 Ubuntu 16.04.4 LTS 桌面系统,其搭配的内核版本为 4.15.0-142-generic ,虽然和用来分析的内核版本不完全相同,但不影响我们对问题的分析。

3. 问题场景

最近看到一篇博文: VFORK 挂掉的一个问题,描述了 vfork() 调用场景下的一个问题。博文作者对问题产生的原因从宏观上进行了分析。本篇试着从代码细节层面,对问题原因进行分析。
本篇对博文的测试代码稍作了一下修改,修改后的测试代码如下:

#include 
#include 
#include int glob = 7890;int main(void)
{pid_t pid;int var;var = 88;//if ((pid = fork()) < 0) { // okayif ((pid = vfork()) < 0) {printf("vfork error");exit(-1);} else if (pid == 0) { /* 子进程 */var++;return 0; // 在 vfork() 调用下,会导致程序崩溃//exit(0); // okay}printf("pid=%d, glob=%d, var=%d\n", getpid(), glob, var);return 0;
}

Ubuntu 16.04.4 LTS 桌面系统下编译运行上述代码,程序会异常退出,系统报告如下信息:

$ ./vfork_test 
pid=3485, glob=7890, var=4195584
vfork_test: cxa_atexit.c:100: __new_exitfn: Assertion `l != NULL' failed.
Aborted (core dumped)

我们看到,程序 abort 了。如果按如下两种方式之一修改前述代码:

. 将 vfork() 调用替换为 fork()
. 将子进程的代码 return 0; 替换为 exit(0)

程序将正常运行。为什么?接下来我们就试着分析下问题的原因。在正式分析开始之前,我们先来了解下 fork()vfork() 的功能和历史。

4. fork() 和 vfork() 的功能和历史

fork()vfork() 都可用来创建一个新的子进程。你一定会奇怪,既然功能相同,为什么还会有两个接口,只要一个不就好了吗?这是历史原因造成的。
fork() 先于 vfork() 出现,fork() 早期实现 ,在创建子进程时,会完整的复制父进程的数据,而非现在的 写时拷贝(COW: Copy-On-Write);而通常对于 fork() 的使用场景,是用它来创建一个子进程,然后调用 exec*() 来启动一个新的程序,这意味着子进程的数据是全新的,此时拷贝父进程的数据变得没有必要,另外拷贝父进程的数据,也对新程序的启动速度造成了影响。为此,引入了 vfork()
vfork()实现通过如下两点,解决上述 fork() 早期实现导致的上述问题:

1. vfork() 创建的子进程和父进程共享数据;
2. vfork() 保证子进程先于父进程运行,而父进程在子进程退出后继续运行。

由此可见,fork()vfork() 的不同,主要体现在 父子进程是否共享数据父子进程运行的先后顺序 上。
前述的点 1.,减少了不必要的数据拷贝,很大程度上加快了使用 exec*() 启动新程序的速度;而点 2. 保证子进程运行期间,父进程不参与系统资源竞争,进一步加速了子进程的启动和运行,顺带的系统中的其它进程也会受益。
而今的 fork() 实现,已然不再在子进程创建时就拷贝父进程数据,而是使用了 写时拷贝(COW: Copy-On-Write) 技术:仅在子进程对数据进行修改时,才建立自己独立的数据拷贝。当然,vfork() 对于优先运行子进程的场景,仍然存在微弱的性能优势,但除非对性能有极致要求,否则仍然不建议使用 vfork()
说完了 fork()vfork() 的功能和历史,我们开始正式进入对问题代码细节层面地分析。

5. 问题分析

虽然已经可以从博文 VFORK 挂掉的一个问题 得到一个本篇所分析问题的一个答案,但不了解事情的来龙去脉,给人的感觉仍是隔靴搔痒。让我们顺着程序的执行流程,从代码细节层面,试着分析问题所在。

5.1 程序的启动

程序的启动,从 bashfork() + exec*() 开始,我们的程序进程作为 bash 的子进程启动后,进入 c 运行时启动代码部分(glibc 源码):

/* 以 ARM32 平台启动代码为例 */
_start:.../* Pop argc off the stack and save a pointer to argv */pop { a2 }mov a3, sp/* Push stack limit */push { a3 }/* Push rtld_fini */push { a1 }/* Fetch address of __libc_csu_fini */ldr ip, =__libc_csu_fini/* Push __libc_csu_fini */push { ip }/* Set up the other arguments in registers */ldr a1, =main /* __libc_start_main() 的第1个参数: main() 的地址 */ldr a4, =__libc_csu_init/* __libc_start_main (main, argc, argv, init, fini, rtld_fini, stack_end) *//* Let the libc call main and exit with its return code.  */bl __libc_start_main /* 进入 __libc_start_main() */

代码流程进入 __libc_start_main() ,继续看它的执行流程:

__libc_start_main()...__libc_csu_init().../* 进入程序的 main() 函数 */result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);/* 子进程使用 return 退出,从 main() 退出到这里 */exit (result);

5.2 子进程的启动

上节程序流程进入到 main() 后,到 vfork() 调用处,将创建新的子进程,然后父子进程并立。我们先看子进程的建立启动流程:

sys_vfork()/* * sys_fork() 创建进程时仅传递了 SIGCHLD 标记,* 所以后续创建进程过程中,我们重点关注 CLONE_VFORK 和* CLONE_VM 标记,因为这体现了 fork() 和 vfork() 的差异* 所在。*/_do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0)...p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace, tls, NUMA_NO_NODE)...p = dup_task_struct(current, node); /* 新进程对象 */...p->vfork_done = NULL;...retval = copy_mm(clone_flags, p)struct mm_struct *mm, *oldmm;...oldmm = current->mm;.../* vfork(): 父子进程共享数据 */if (clone_flags & CLONE_VM) { mmget(oldmm); /* 增加父进程 mm_struct 对象引用计数:子进程也引用了它 */mm = oldmm;goto good_mm; /* 子进程共享父进程的 mm_struct */}.../* fork(): 子进程有自己独立的数据 */mm = dup_mm(tsk);if (!mm)goto fail_nomem;good_mm:tsk->mm = mm;tsk->active_mm = mm;return 0;.../** sigaltstack should be cleared when sharing the same VM*/if ((clone_flags & (CLONE_VM|CLONE_VFORK)) == CLONE_VM)sas_ss_reset(p);.../* 新进程创建的最后工作 */if (!IS_ERR(p)) {/** vfork() 用来同步父子进程: * 保证子进程先于父进程运行,父进程在子进程结束前不参与系统资源竞争。*/struct completion vfork;...if (clone_flags & CLONE_VFORK) {p->vfork_done = &vfork;init_completion(&vfork);get_task_struct(p);}wake_up_new_task(p); /* 唤醒新创建的子进程参与调度 */...if (clone_flags & CLONE_VFORK) {/* vfork():确保子进程先运行,父进程在子进程结束后继续运行 */if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);}}

从上面的代码分析,我们了解到:

1. fork() 的子进程通过独立的 mm_struct 来管理自己独立的数据;vfork() 通过和父进程共享 mm_struct 而共享数据。
2. vfork() 通过 task_struct::vfork_done 来同步父子进程:子进程先运行,父进程在子进程退出后继续运行。

我们重点关注 vfork() 调用下、父子进程共享 mm_struct 的情形,它意味着程序中的任意数据改变,会同时反映到父子进程。简单来说,就是父子进程使用相同的数据,其中包括 全局变量、堆、栈 等。了解到这一点,对我们后续地分析起着至关重要的作用。

5.3 子进程的退出

5.3.1 用 return 语句退出的情形

从章节 5.1 程序的启动__libc_start_main() 的流程分析看到,子进程将从 main() 回退到 __libc_start_main() ,然后调用 exit()

5.3.2 用 exit() 调用退出的情形

子进程从 main() 中直接调用 exit()

从上两小节不难看出,子进程退出的两种情形,仅仅在于 exit() 调用位置的不同:第1种情形 return 时是在 __libc_start_main() 调用 exit() ;第2种情形是在 main()中调用 exit() 。看起来似乎没什么差别,就这样第1种情形程序就崩溃?第2种情形程序正常运行?
起先看到博文 VFORK 挂掉的一个问题 时,我没有直接看完全篇,先是试图从如下程序崩溃的信息寻找答案:

$ ./vfork_test 
pid=3485, glob=7890, var=4195584
vfork_test: cxa_atexit.c:100: __new_exitfn: Assertion `l != NULL' failed.
Aborted (core dumped)

如果顺着去查 __new_exitfn() 函数去找问题,结果显然将是徒劳的。我一开始就是这么干的,结果走了弯路。其实变量 var 的输出值,已经给出了提示:明明赋值 88 ,结果输出 4195584
从前面我们知道,父子进程共享栈空间, 同时仅有一处父子进程共享代码语句 var = 88;var 赋值,var 这个栈变量的值发生了改变,那只能是调用栈的变化。函数的非静态变量从栈上分配空间:进入时分配,退出时释放。更重要的是,链接寄存器(如 ARM32 下的 LR 寄存器)的值也发生了变化,这将导致调用链破坏,也就是博文 VFORK 挂掉的一个问题 说的 堆栈跪了vfork() 子进程退出时使用 return ,就是前面分析的情形,破坏了父进程的当前栈空间,最终提示的出错位置在 __new_exitfn() 中也变得不靠谱。
子进程直接在 main() 中调用 exit() ,不会破坏父进程的栈空间,因为 exit() 是对 sys_exit_group() 系统调用的直接封装。系统调用的更多细节,可以参考博文 Linux系统调用实现简析 。

5.3.3 子进程退出的细节

从前面的分析知道,不管是 return 还是直接 exit() ,最终都会都调用了 exit() ,我们这里不讨论用户空间的细节,我们看一下子进程退出的内核空间的流程:

sys_exit_group()do_group_exit((error_code & 0xff) << 8).../* 给线程组中的其它进程发送 SIGKILL 信号 */sig->group_exit_code = exit_code;sig->flags = SIGNAL_GROUP_EXIT;zap_other_threads(current);/* 退出当前线程 */do_exit(exit_code)/* 我们只看 vfork 父子进程同步,以及 mm_struct 的相关部分 */...exit_mm()mm_release(current, mm)...if (tsk->vfork_done)complete_vfork_done(tsk)struct completion *vfork;task_lock(tsk);vfork = tsk->vfork_done;if (likely(vfork)) {tsk->vfork_done = NULL;/* 父进程还在 vfork() 里面等待,现在子进程退出了,唤醒父进程继续执行 */complete(vfork);}task_unlock(tsk);......

现在我们能够理解,为什么 vfork() 能够保证子进程先运行,子进程退出之前,父进程都不参与系统资源竞争,因为它一直在内核空间的 vfork() 调用中睡眠;子进程在 exit() 中发起唤醒动作,vfork() 终于得以继续运行。

6. 另辟蹊径

如果不了解内核代码细节,我们就没有办法解决这个问题了吗?当然不是,我们只需要阅读 vfork() 的文档,就能解决这个问题,vfork() 文档明确标明了需要避开的坑。这就是博文 VFORK 挂掉的一个问题 作者所讲的 RTFM: Read The Fucking Manual 大法。如果仅仅是为了解决这个问题,阅读 API 手册仍然是最省时、也最为推荐的方法。

7. 彩蛋

对于测试程序代码,假设我们只要求程序不崩溃,允许其它错误,应该怎样修改代码?
我们只需要将 main() 函数结尾处的 return 0; 修改为 exit(0) 。我们看一下修改代码后的程序输出:

$ ./vfork_test 
pid=5033, glob=7890, var=4195584

虽然 var 变量的输出值仍然错误,但程序不再崩溃了。原因很简单,既然是破坏了调用链导致的程序崩溃(具体是破坏了调用链上的链接寄存器,如 ARM32 架构的 LR 寄存器),那父进程就调用 exit() 直接退出,不再返回 __libc_start_main() ,也就用不上调用链了,和子进程使用 exit() 是一样的道理。

8. 参考资料

VFORK 挂掉的一个问题
man fork()
man vfork()

相关内容

热门资讯

linux入门---制作进度条 了解缓冲区 我们首先来看看下面的操作: 我们首先创建了一个文件并在这个文件里面添加了...
C++ 机房预约系统(六):学... 8、 学生模块 8.1 学生子菜单、登录和注销 实现步骤: 在Student.cpp的...
JAVA多线程知识整理 Java多线程基础 线程的创建和启动 继承Thread类来创建并启动 自定义Thread类的子类&#...
【洛谷 P1090】[NOIP... [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G ...
国民技术LPUART介绍 低功耗通用异步接收器(LPUART) 简介 低功耗通用异步收发器...
城乡供水一体化平台-助力乡村振... 城乡供水一体化管理系统建设方案 城乡供水一体化管理系统是运用云计算、大数据等信息化手段࿰...
程序的循环结构和random库...   第三个参数就是步长     引入文件时记得指明字符格式,否则读入不了 ...
中国版ChatGPT在哪些方面... 目录 一、中国巨大的市场需求 二、中国企业加速创新 三、中国的人工智能发展 四、企业愿景的推进 五、...
报名开启 | 共赴一场 Flu... 2023 年 1 月 25 日,Flutter Forward 大会在肯尼亚首都内罗毕...
汇编00-MASM 和 Vis... Qt源码解析 索引 汇编逆向--- MASM 和 Visual Studio入门 前提知识ÿ...
【简陋Web应用3】实现人脸比... 文章目录🍉 前情提要🌷 效果演示🥝 实现过程1. u...
前缀和与对数器与二分法 1. 前缀和 假设有一个数组,我们想大量频繁的去访问L到R这个区间的和,...
windows安装JDK步骤 一、 下载JDK安装包 下载地址:https://www.oracle.com/jav...
分治法实现合并排序(归并排序)... 🎊【数据结构与算法】专题正在持续更新中,各种数据结构的创建原理与运用✨...
在linux上安装配置node... 目录前言1,关于nodejs2,配置环境变量3,总结 前言...
Linux学习之端口、网络协议... 端口:设备与外界通讯交流的出口 网络协议:   网络协议是指计算机通信网...
Linux内核进程管理并发同步... 并发同步并发 是指在某一时间段内能够处理多个任务的能力,而 并行 是指同一时间能够处理...
opencv学习-HOG LO... 目录1. HOG(Histogram of Oriented Gradients,方向梯度直方图)1...
EEG微状态的功能意义 导读大脑的瞬时全局功能状态反映在其电场结构上。聚类分析方法一致地提取了四种头表面脑电场结构ÿ...
【Unity 手写PBR】Bu... 写在前面 前期积累: GAMES101作业7提高-实现微表面模型你需要了解的知识 【技...