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】进程地...   🤣 爆笑教程 👉 《看表情包学Linux》👈 猛...
育碧GDC2018程序化大世界... 1.传统手动绘制森林的问题 采用手动绘制的方法的话,每次迭代地形都要手动再绘制森林。这...
编译原理陈火旺版第三章课后题答... 下面答案仅供参考! 1.编写一个对于 Pascal 源程序的预处理程序。该程序的作用是...
MacBookPro M2芯片... MacBookPro M2芯片下如何搭建React-Native环境目录软件下载环境配置 目录 写在...
Android studio ... 解决 Android studio 出现“The emulator process for AVD ...
pyflink学习笔记(六):... 在pyflink学习笔记(一)中简单介绍了table-sql的窗口函数,下面简单介绍下...
创建deployment 创建deployment服务编排-DeploymentDeployment工作负载均衡器介绍Depl...
gma 1.1.4 (2023... 新增   1、地图工具    a. 增加【GetWorldDEMDataSet】。提供了一套 GEO...
AI专业教您保姆级在暗影精灵8... 目录 一、Stable Diffusion介绍    二、Stable Diffusion环境搭建 ...
vue笔记 第一个Vue应用 Document{{content}}{{...
Unity自带类 --- Ti... 1.在Unity中,自己写的类(脚本)的名字不能与Unit...
托福口语21天——day5 发... 目录 一、连读纠音 二、语料输入+造句输出 三、真题 一、连读纠音 英语中的连读方式有好几种...
五、排序与分页 一、排序 1、语法 ORDER BY 字段 ASC | DESC ASC(ascen...
Linux系统中如何安装软件 文章目录一、rpm包安装方式步骤:二、deb包安装方式步骤:三、tar....
开荒手册4——Related ... 0 写在前面 最早读文献的时候,每每看到related work部分都会选择性的忽略&...
实验01:吃鸡蛋问题 1.实验目的: 通过实验理解算法的概念、算法的表示、算法的时间复杂度和空间复杂度分析&...
8个免费图片/照片压缩工具帮您... 继续查看一些最好的图像压缩工具,以提升用户体验和存储空间以及网站使用支持。 无数图像压...
Spring Cloud Al... 前言 本文小新为大家带来 Sentinel控制台规则配置 相关知识,具体内容包括流控...
多项目同时进行,如何做好进度管... 多项目同时进行,如何做好进度管理? 大多数时候,面对项目进...
ATTCK红队评估实战靶场(二... 前言 第二个靶机来喽,地址:vulunstack 环境配置 大喊一声我...