从一个ELF程序的加载窥探操作系统内核-(2)

从一个ELF程序的加载窥探操作系统内核-(2)

操作系统加载一个ELF程序看似一个EASY的动作,其实下面隐藏了很多很多OS内核的关键实现,让我们一起来解密其中的流程

作者是一个micro kernel的开发者,在设计动态链接器的时候,在此留下一些笔记,重点参考了以下资料文献

  • 《程序员的自我修养》
  • 《深入理解计算机系统》
  • 《现代操作系统-原理与实现》
  • 《深入理解LINUX内核》
  • 《设计模式/JAVA》
linux下运行一个ELF程序都经历了什么

当我们在shell终端下执行一个ELF程序,首先操作系统会使用fork创建一个新的进程,然后再通过exec运行新的进程,这是fork+exec的典型应用。
当我们使用ctrl+shift+T打开一个新的shell终端这就是一个单独fork下的场景。
如果我们直接在shell终端使用exec执行一个程序,这个程序执行完成后,当前的shell终端会被关闭,这就是单独exec下的场景应用。

fork 还是 exec

同样是创建子进程,我们先理解下fork和exec的区别

  1. 从当前进程复制一份和父进程完全一样的新进程: 准确地说是复制了一份父进程作为新进程
    从系统调用的角度来说,和进程创建相关的系统调用只有fork(),进程在调用fork()创建它的时刻开始存活,fork()通过"复制"(Linux下所有进程都是"复制"出来的)一个现有进程来创建一个新的进程,调用fork()的进程称为父进程,新产生的进程称为子进程。在该调用结束时,在返回到这个相同位置上,父进程恢复执行,子进程开始执行。
    fork()系统调用从内核返回两次,一次返回到父进程、另一次返回到新产生的子进程
    1) 调用fork() 
    or
    2) 调用clone()
    /*
    就像一个细胞复制了一份和自己相同的新细胞,两个细胞同时运行
    */    
  1. 运行新代码的新进程创建: 在调用fork的基础上,继续调用exec(),读取并载入新进程代码并继续运行
    通常,创建新的进程都是为了立即执行新的、不同的代码,而接着调用exec这组函数就可以创建新的"地址空间",并把新的程序载入其中。在现代Linux内核中,fork()实际上是由clone()系统调用实现的
    1) fork()/clone() + exec()
    /*
    就像一个细胞复制了一份和自己相同的新细胞,并填充进了新的细胞核,两个细胞同时运行
    */
  1. 运行新进程: 直接将当前进程转变为一个包含不同代码的新进程
    1) exec()
    /*
    就像一个细胞使用新的蛋白质将自己的细胞核改变了,并继续运行
    */

那么调用这个fork函数时发生了什么呢?fork函数启动一个新的进程,前面我们说过,这个进程几乎是当前进程的一个拷贝:子进程和父进程使用相同的代码段;子进程复制父进程的堆栈段和数据段。这样,父进程的所有数据都可以留给子进程,但是,子进程一旦开始运行,虽然它继承了父进程的一切数据,但实际上数据却已经分开,相互之间不再有影响了,也就是说,它们之间不再共享任何数据了。它们再要交互信息时,只有通过进程间通信来实现,这将是我们下面的内容。既然它们如此相象,系统如何来区分它们呢?这是由函数的返回值来决定的。对于父进程, fork函数返回了子程序的进程号,而对于子程序,fork函数则返回零。在操作系统中,我们用ps函数就可以看到不同的进程号,对父进程而言,它的进程号是由比它更低层的系统调用赋予的,而对于子进程而言,它的进程号即是fork函数对父进程的返回值。

读者也许会问,如果一个大程序在运行中,它的数据段和堆栈都很大,一次fork就要复制一次,那么fork的系统开销不是很大吗?其实UNIX自有其解决的办法,大家知道,一般CPU都是以"页"为单位来分配内存空间的,每一个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在通常情况下是 4086字节大小,而无论是数据段还是堆栈段都是由许多"页"构成的,fork函数复制这两个段,只是"逻辑"上的,并非"物理"上的,也就是说,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的" 页"从物理上也分开。系统在空间上的开销就可以达到最小。

一个进程一旦调用exec类函数,它本身就"死亡"了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了。(不过exec类函数中有的还允许继承环境变量之类的信息。)
那么如果我的程序想启动另一程序的执行但自己仍想继续运行的话,怎么办呢?那就是结合fork与exec的使用。

fork时究竟发生了什么

fork会创建一个和当前进程一模一样的子进程,这个时候的子进程和父进程的区别仅仅在于PID(进程号)、PPID(父进程号)、和某些资源和统计量

如何创建一个一模一样的子进程?一个任务运行需要具备哪些资源?

一个程序最基本的资源组成由3部分组成

  • 代码段
  • 数据段
  • 堆栈

现在我们只需要拷贝一份父进程的这三部分给子进程就可以创建一个和父进程相同的进程了,也就是clone

这里的拷贝,其实并不是真正的拷贝,想像一下如果每创建一个子进程都去拷贝这些资源是不是很浪费内存空间呢?下面分析下这三个资源究竟该如何处理。

  • 代码段
    • 代码段是程序运行的指令,这一部分子进程和父进程是可以完全共享的,也就是我们只需要把父进程的代码段的页表映射关系拷贝到子进程就可以了,这里只是页表的拷贝而不是直接的代码段内存拷贝,当然子进程的页表也需要重新去申请内存
    • 页表拷贝后,子进程的页表也具备代码段的RO和可执行权限了
    • 进程的切换无非就是切换堆栈,上下文,跳转PC,所以要运行多个相同的资源,并不需要完全拷贝多个进程的所有资源
  • 数据段
    • 数据段存放进程的数据,对于这个段,我们在创建进程成功后,我们都希望两个进程的数据不要相互污染,所以子进程的数据段是需要重新分配物理空间的。
    • 重新分配物理地址空间后,那么父进程的数据段还需不需要拷贝到子进程呢?在fork中我们希望创建子进程后,数据段的内容是继承父进程的,所以要进行数据段的拷贝,典型的,在我们时候shell又打开一个新的shell,在新的shell里我们使用方向键上键依旧可以访问到父进程的历史列表,这就是一个很有用的功能。
    • 注意这里不光是要申请新的物理空间,还要进行新的页表映射,因为物理地址变化了,虚拟地址还是不变,所以不能拷贝父进程的页表映射,需要重新进行映射,这样我们就实现了数据段的隔离
    • 究竟什么时候去分配数据段真实物理地址空间?
    • 其实在fork时并没有第一时间去分配数据段的物理地址空间,内核会让子进程共享父进程的数据空间,也就是把父进程数据段的页表映射拷贝给子进程,但是注意:拷贝页表后,会把数据段的属性修改为只读的。为什么?修改为只读的后,返回用户态,用户如果要对数据段进行写的时候,就会发生缺页异常,在缺页异常里再去进行物理空间分配以及页表重映射
  • 堆栈
    • 堆栈保存的是进程的局部变量,上下文,参数,返回值
    • 对于fork,堆栈是需要立刻重新分配空间的,不能采取延时分配,因为父子进程谁先返回不确定,由调度器决定,如果父进程先返回,修改了堆栈的内容,此刻再产生缺页异常进行分配拷贝堆栈,此刻拷贝的堆栈就不是fork时刻的堆栈,这样子进程就无法恢复到当初fork时刻的上下文,所以此刻必须为子进程堆栈分配真实空间
    • 对于fork,父进程的堆栈的内容要拷贝到子进程吗?对于fork,是需要拷贝的,这才才能在fork返回时正确的恢复上下文
    • 对于exec,堆栈就可以采用延时分配COW,而且不用拷贝父进程堆栈的内容到子进程,因为exec,马上要执行新的代码,堆栈空间要干净的

总结一下,fork的流程

  1. 分配并初始化一个tcb资源
    1. 从父进程中复制:包括从父进程继承而来的特权和限制
      1. 父进程的上下文(在armv7-A中也就是R0~R15,CPSR)
      2. 父进程的mm信息(start_code/end_code,start_data/end_data,start_bss/end_bss,start_brk/brk,start_stack/end_stack)
    2. 清零
    3. 显示初始化
      1. 进程pid和ppid
  2. 为子进程创建地址空间
    1. 创建子进程页表(一级/二级)
    2. 拷贝父进程的页表到子进程(拷贝了父进程的所有映射区域)
    3. 代码段的地址空间在上一步通过拷贝页表完成
  3. 创建堆栈地址空间
    1. 立即分配堆栈的真实物理地址空间
    2. 拷贝父进程的堆栈到申请的物理地址空间
    3. 为子进程建立堆栈区域的页表映射(RW),由于之前拷贝了父进程的所有页表映射,这里实际上是进行了一次重映射
  4. 创建数据段的地址空间
    1. 把数据段重新映射为只读(RO),实现COW
  5. 设置子进程的返回值
    1. 在arm中通过设置R0寄存器,来设置子进程的返回值为0
  6. 设置父进程的返回值
    1. 在arm中通过设置R0寄存器,来设置父进程的返回值为子进程的pid

exec的流程

  1. 分配并初始化一个tcb资源
    1. 从父进程中复制:包括从父进程继承而来的特权和限制
      1. 因为马上要运行新的程序所以,无须复制上下文
      2. 因为马上要运行新的程序所以,无须复制mm信息
    2. 清零
    3. 显示初始化
      1. 进程pid和ppid
      2. 初始化新程序的mm信息(start_code/end_code,start_data/end_data,start_bss/end_bss,start_brk/brk,start_stack/end_stack)
      3. 设置新程序的上下文(在armv7-A中需要必须设置SP和PC,设置LR可以帮助进程退出时回收进程资源,设置R0寄存器可以设置exec时的返回值)
  2. 为子进程创建地址空间
    1. 创建子进程页表(一级/二级)
    2. 拷贝父进程的页表到子进程(拷贝了父进程的所有映射区域)
    3. 代码段的地址空间在上一步通过拷贝页表完成
  3. 创建堆栈地址空间
    1. 延迟分配堆栈的真实物理地址空间,由于之前拷贝了父进程的所有页表映射,这里实际上是对堆栈区域进行一次页表清空,来实现堆栈COW
  4. 创建数据段的地址空间
    1. 把数据段重新映射为只读(RO),实现COW
  5. 设置父进程的返回值
    1. 在arm中通过设置R0寄存器,来设置父进程的返回值为子进程的pid
vfork为什么会出现

在很久之前只有fork,并没有vfork,也没有COW,COW是需要硬件支持的。
人们在使用的时候一般就的先fork再exec,由于要执行新的程序,fork要拷贝父进程数据的动作就很浪费,而且很耗时(页表的建立和映射也是需要时间的),基于这个原因,当时就让父子进程共享数据区(数据段和堆栈),而且设定必须子进程先返回(如果父进程先返回,会破坏数据区导致子进程无法执行),而且必须子进程exit后才能回到父进程。
后来COW出现,fork后,数据区只有在修改的时候才会去创建空间,这个时候执行exec的时候就会更轻巧了,vfork就弃用了

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值