
v45.05 鸿蒙内核源码分析(Fork) | 一次调用,两次返回 原创
孔子于乡党,恂恂如也,似不能言者。其在宗庙朝廷,便便言,唯谨尔。 《论语》:乡党篇
百篇博客系列篇.本篇为:
v45.xx 鸿蒙内核源码分析(Fork篇) | 一次调用,两次返回
进程管理相关篇为:
- v02.06 鸿蒙内核源码分析(进程管理) | 谁在管理内核资源
- v24.03 鸿蒙内核源码分析(进程概念) | 进程在管理哪些资源
- v45.05 鸿蒙内核源码分析(Fork) | 一次调用,两次返回
- v46.05 鸿蒙内核源码分析(特殊进程) | 老鼠生儿会打洞
- v47.02 鸿蒙内核源码分析(进程回收) | 临终前如何向老祖宗托孤
- v48.05 鸿蒙内核源码分析(信号生产) | 年过半百,依然活力十足
- v49.03 鸿蒙内核源码分析(信号消费) | 谁让CPU连续四次换栈运行
- v71.03 鸿蒙内核源码分析(Shell编辑) | 两个任务,三个阶段
- v72.01 鸿蒙内核源码分析(Shell解析) | 应用窥伺内核的窗口
笔者第一次看到fork时,说是一次调用,两次返回,当时就懵圈了,多新鲜,真的很难理解.因为这足以颠覆了以往对函数的认知, 函数调用还能这么玩,父进程调用一次,父子进程各返回一次.而且只能通过返回值来判断是哪个进程的返回.所以一直有几个问题缠绕在脑海中.
- fork是什么? 外部如何正确使用它.
- 为什么要用fork这种设计? fork的本质和好处是什么?
- 怎么做到的? 调用fork()使得父子进程各返回一次,怎么做到返回两次的,其中到底发生了什么?
- 为什么
pid = 0
代表了是子进程的返回? 为什么父进程不需要返回 0 ?
直到看了linux内核源码后才搞明白,但系列篇的定位是挖透鸿蒙的内核源码,所以本篇将深入fork函数,用鸿蒙内核源码去说明白这些问题.在看本篇之前建议要先看系列篇的其他篇幅.如(任务切换篇,寄存器篇,工作模式篇,系统调用篇 等),有了这些基础,会很好理解fork的实现过程.
fork是什么
先看一个网上经常拿来说fork的一个代码片段.
pid < 0
fork 失败pid == 0
fork成功,是子进程的返回pid > 0
fork成功,是父进程的返回fork
的返回值这样规定是有道理的。fork
在子进程中返回0,子进程仍可以调用getpid
函数得到自己的进程id,也可以调用getppid
函数得到父进程的id。在父进程中用getpid
可以得到自己的进程id,然而要想得到子进程的id,只有将fork
的返回值记录下来,别无它法。- 子进程并没有真正执行
fork()
,而是内核用了一个很巧妙的方法获得了返回值,并且将返回值硬生生的改写成了0,这是笔者认为fork
的实现最精彩的部分.
运行结果
这个程序的运行过程如下图所示。
解读
-
fork()
是一个系统调用,因此会切换到SVC模式运行.在SVC栈中父进程复制出一个子进程,父进程和子进程的PCB信息相同,用户态代码和数据也相同. -
从案例的执行上可以看出,fork 之后的代码父子进程都会执行,即代码段指向(PC寄存器)是一样的.实际上fork只被父进程调用了一次,子进程并没有执行
fork
函数,但是却获得了一个返回值,pid == 0
,这个非常重要.这是本篇说明的重点. -
从执行结果上看,父进程打印了三次(This is the parent),因为 n = 3. 子进程打印了六次(This is the child),因为 n = 6. 而子程序并没有执行以下代码:
子进程是从
pid = fork()
后开始执行的,按理它不会在新任务栈中出现这些变量,而实际上后面又能顺利的使用这些变量,说明父进程当前任务的用户态的数据也复制了一份给子进程的新任务栈中. -
被fork成功的子进程跑的首条代码指令是
pid = 0
,这里的0是返回值,存放在R0
寄存器中.说明父进程的任务上下文也进行了一次拷贝,父进程从内核态回到用户态时恢复的上下文和子进程的任务上下文是一样的,即 PC寄存器指向是一样的,如此才能确保在代码段相同的位置执行. -
执行
./a.out
后 第一条打印的是This is the child
说明fork()
中发生了一次调度,CPU切到了子进程的任务执行,sleep(1)
的本质在系列篇中多次说过是任务主动放弃CPU的使用权,将自己挂入任务等待链表,由此发生一次任务调度,CPU切到父进程执行,才有了打印第二条的This is the parent
,父进程的sleep(1)
又切到子进程如此往返,直到 n = 0, 结束父子进程. -
但这个例子和笔者的解读只解释了fork是什么的使用说明书,并猜测其中做了些什么,并没有说明为什么要这样做和代码是怎么实现的. 正式结合鸿蒙的源码说清楚为什么和怎么做这两个问题?
为什么是fork
fork函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回一次。从上图可以看出,一开始是一个控制流程,调用fork之后发生了分叉,变成两个控制流程,这也就是“fork”(分叉)这个名字的由来了。
系列篇已经写了40+多篇,已经很容易理解一个程序运行起来就需要各种资源(内存,文件,ipc,监控信息等等),资源就需要管理,进程就是管理资源的容器.这些资源相当于干活需要各种工具一样,干活的工具都差不多,实在没必再走流程一一申请,而且申请下来会发现和别人手里已有的工具都一样, 别人有直接拿过来使用它不香吗? 所以最简单的办法就是认个干爹,让干爹拷贝一份干活工具给你.这样只需要专心的干好活(任务)就行了. fork的本质就是copy,具体看代码.
fork怎么实现的?
解读
- 系统调用是通过
CLONE_SIGHAND
的方式创建子进程的.具体有哪些创建方式如下: 此处不展开细说,进程之间发送信号用于异步通讯,系列篇有专门的篇幅说信号(signal),请自行翻看. - 可以看出fork的主体函数是
OsCopyProcess
,先申请一个干净的PCB,相当于申请一个容器装资源. - 初始化这个容器
OsForkInitPCB
,OsInitPCB
先把容器打扫干净,虚拟空间,地址映射表(L1表),各种链表初始化好,为接下来的内容拷贝做好准备. OsCopyParent
把家族基因/关系传递给子进程,谁是你的老祖宗,你的七大姑八大姨是谁都得告诉你知道,这些都将挂到你已经初始化好的链表上.OsCopyTask
这个很重要,拷贝父进程当前执行的任务数据给子进程的新任务,系列篇中已经说过,真正让CPU干活的是任务(线程),所以子进程需要创建一个新任务LOS_TaskCreateOnly
来接受当前任务的数据,这个数据包括栈的数据,运行代码段指向,OsUserCloneParentStack
将用户态的上下文数据TaskContext
拷贝到子进程新任务的栈底位置, 也就是说新任务运行栈中此时只有上下文的数据.而且有最最最重要的一句代码context->R[0] = 0;
强制性的将未来恢复上下文R0
寄存器的数据改成了0, 这意味着调度算法切到子进程的任务后, 任务干的第一件事是恢复上下文,届时R0
寄存器的值变成0,而R0=0
意味着什么? 同时LR/SP
寄存器的值也和父进程的一样.这又意味着什么?- 系列篇寄存器篇中以说过返回值就是存在R0寄存器中,
A()->B()
,A拿B的返回值只认R0
的数据,读到什么就是什么返回值,而R0寄存器值等于0,等同于获得返回值为0, 而LR寄存器所指向的指令是pid=返回值
, sp寄存器记录了栈中的开始计算的位置,如此完全还原了父进程调用fork()
前的运行场景,唯一的区别是改变了R0
寄存器的值,所以才有了 由此确保了这是子进程的返回.这是fork()
最精彩的部分.一定要好好理解.OsCopyTask``OsUserCloneParentStack
的代码细节.会让你醍醐灌顶,永生难忘. - 父进程的返回是
processID = child->processID;
是子进程的ID,任何子进程的ID是不可能等于0的,成功了只能是大于0. 失败了就是负数return -ret;
OsCopyProcessResources
用于赋值各种资源,包括拷贝虚拟空间内存,拷贝打开的文件列表,IPC等等.OsChildSetProcessGroupAndSched
设置子进程组和调度的准备工作,加入调度队列,准备调度.LOS_MpSchedule
是个核间中断,给所有CPU发送调度信号,让所有CPU发生一次调度.由此父进程让出CPU使用权,因为子进程的调度优先级和父进程是平级,而同级情况下子进程的任务已经插到就绪队列的头部位置OS_PROCESS_PRI_QUEUE_ENQUEUE
排在了父进程任务的前面,所以在没有比他们更高优先级的进程和任务出现之前,下一次被调度到的任务就是子进程的任务.也就是在本篇开头看到的- 以上为fork在鸿蒙内核的整个实现过程,务必结合系列篇其他篇理解,一次理解透彻,终生不忘.
百万汉字注解.精读内核源码
百篇博客分析.深挖内核地基
给鸿蒙内核源码加注释过程中,整理出以下文章。内容立足源码,常以生活场景打比方尽可能多的将内核知识点置入某种场景,具有画面感,容易理解记忆。说别人能听得懂的话很重要! 百篇博客绝不是百度教条式的在说一堆诘屈聱牙的概念,那没什么意思。更希望让内核变得栩栩如生,倍感亲切.确实有难度,自不量力,但已经出发,回头已是不可能的了。 😛
与代码有bug需不断debug一样,文章和注解内容会存在不少错漏之处,请多包涵,但会反复修正,持续更新,.xx
代表修改的次数,精雕细琢,言简意赅,力求打造精品内容。
基础工具>> 双向链表 | 位图管理 | 用栈方式 | 定时器 | 原子操作 | 时间管理 |
加载运行>> ELF格式 | ELF解析 | 静态链接 | 重定位 | 进程映像 |
进程管理>> 进程管理 | 进程概念 | Fork | 特殊进程 | 进程回收 | 信号生产 | 信号消费 | Shell编辑 | Shell解析 |
编译构建>> 编译环境 | 编译过程 | 环境脚本 | 构建工具 | gn应用 | 忍者ninja |
进程通讯>> 自旋锁 | 互斥锁 | 进程通讯 | 信号量 | 事件控制 | 消息队列 |
内存管理>> 内存分配 | 内存管理 | 内存汇编 | 内存映射 | 内存规则 | 物理内存 |
前因后果>> 总目录 | 调度故事 | 内存主奴 | 源码注释 | 源码结构 | 静态站点 |
任务管理>> 时钟任务 | 任务调度 | 任务管理 | 调度队列 | 调度机制 | 线程概念 | 并发并行 | CPU | 系统调用 | 任务切换 |
文件系统>> 文件概念 | 文件系统 | 索引节点 | 挂载目录 | 根文件系统 | 字符设备 | VFS | 文件句柄 | 管道文件 |
硬件架构>> 汇编基础 | 汇编传参 | 工作模式 | 寄存器 | 异常接管 | 汇编汇总 | 中断切换 | 中断概念 | 中断管理 |
鸿蒙研究站 | 每天死磕一点点,原创不易,欢迎转载,但请注明出处。
