可执行文件(ELF)的装载与进程
程序员的自我修养
可执行文件的装载与进程
进程虚拟地址空间
-
什么是程序?什么是进程?
- 程序是一个静态的概念,它就是一些预先编译好的指令和数据的集合
- 进程是一个动态的概念.它是程序运行时的一个过程
- CPU比作是人, 程序比作是菜谱, 硬件等资源比作是菜,厨具之类的东西.
- 进程就是整个炒菜的过程 计算机安装程序的指示把输入数据加工成输出数据, 就好像厨师按照菜谱指导人把原料做成美味的菜一样
-
每个进程都有自己独立的虚拟地址空间, 进程只能使用操作系统分配的地址空间内的地址
- 如果访问未经允许的空间, 操作系统就会捕获到这些访问, 将进程强制结束, 比如Windows 进程因为非法操作需要关闭 , Linux 的Segment fault等
-
在Linux下
-
0XC0000000是操作系统和用户进程地址空间的划分线 ,系统用了1GB, 进程可用的3GB
-
那如果进程想跑一个3GB以上的怎么办?或者说程序使用的空间能不能大于3GB呢?
-
从虚存的角度来说是不行的
-
从实际的内存来说是可以的
- windows下有个叫PAE AWE的东西
- 可以从高于4GB的内存空间里申请ABCD等多块物理空间, 然后根据需要把某段虚存映射到这不同的ABCD块
-
-
-
装载的方式
-
最简单的办法就是把程序和数据全部存入内存中, 这就是静态装载
-
根据程序局部性原理, 还可以把程序最常用的部分驻留在内存中 ,不常用的放在 硬盘上 这就是动态装载
-
动态装载分成 覆盖装入overlay和页映射Paging
-
覆盖装入是上古时期的产物了,程序员在写代码的时候要手动替换模块, 而且要思考清楚 模块的依赖关系, 最后可以用树 这种数据结构来描述
- 子主题 1
-
页映射
-
可以说Modern OS都是采用这种方式
- 页就是由操作系统的存储管理器来做一个 装载管理器的工作,
- 由MMU来完成虚拟地址转换成物理地址的过程
- 把一般为4KB 也就是0x00001000大小段的页 读进内存
- 当然内存满了之后有替换算法, 比如FIFO,之类的
-
-
-
从操作系统的角度来看 可执行文件的装载
-
进程的建立
- 一个进程最关键的特点是 它拥有独立的虚拟地址空间
-
进程建立的三个步骤
-
1.创建一个独立的虚拟地址空间
- 实际上很简单, 就是操作系统给你分配了一个页目录(Page Directory)
-
2.读取可执行文件的头部 ,做好可执行文件ELF和虚拟地址空间的映射,
-
首先回忆一下缺页中断会发生什么
- 操作系统首先从空闲的物理内存中分配一个物理页, 然后我们就是要加载磁盘上的页到这个物理页上,最后设置好这个物理页的物理地址和虚拟地址的关系
- 那么问题来了, 我们怎么知道程序当前需要的页到底在什么位置呢? 这正是第2点 , 可执行文件和虚存映射要做的事情
-
实际上看图, 这种映射关系被保存在操作系统内部的一个数据结构 叫VMA(virtual Memory Area)
-
比如操作系统创建进程后 会在进程相应的数据结构里设置一个对应.text段的VMA, 这个VMA还会带有一些权限的限制, 比如只读, 后续我们还会进行合并
-
实际上操作系统发生段错误的时候, 通过查找这样的数据结构来定位页错误在可执行文件中的位置, 从而可以把正确的可执行文件的页加载进来
-
-
-
3.将CPU的指令寄存器 设置成 可执行文件的入口地址, 启动运行
-
这步其实最简单, 通过设置CPU的指令寄存器将CPU时间片交给进程,
-
在操作系统层面比较复杂
- 涉及到内核堆栈和用户堆栈的切换, CPU运行权限的切换
-
不过对程序来说,
- 不就是执行了一条跳转指令吗, 跳到ELF文件的入口地址
-
-
-
-
页错误
-
再重复一下刚才的过程, 就当是总结了吧
- 比如那个入口地址是0x08048000, 执行是发现页面0x08048000- 0x08049000是个空页面, 这时候触发缺页中断, CPU将控制权交给OS, OS查询那个VMA ,然后计算出对应ELF文件的偏移, 然后找一个空闲的物理地址, 建立好虚存和物理内存的映射关系(应该是由MMU)来完成的 ,最后回到进程刚才page fault的地方继续执行
-
-
进程虚存分布
-
刚才说的虚存和ELF文件的映射关系会产生碎片的问题, 而你站在操作系统的角度来看它其实并不关系这虚存对应的到底是.bss段还是.text段 ,操作系统只关心这些段的权限问题(read write exec)
-
所以把相同权限的section合并成一个虚存段segment是一个很自然的想法
- 子主题 1
-
这样做的好处是显著减少了页面内部碎片, 从而节省了内存空间
-
其实无非就是虚存的segment合并了 ELF的几个section罢了
-
一般ELF会分成两个段
- VMA0
- VMA1
-
-
-
-
堆和栈
-
首先在linux下可以 cat /proc/21963/map
-
这个可以看到究竟划分成了几个段
-
子主题 3
-
一般来说是5个
-
VMA0
-
VMA1
-
stack VMA
-
heap VMA
-
vdso
- 这个地址是属于大于0xC0000000的, 也就是属于内核的地址了
- 这个是进程可以用来访问内核, 做一些通信
-
-
-
进程除了那些segement之外还有自己的stack, 和Heap
-
每个线程都有属于自己的堆栈
-
比如这个进程的heap 140KB, stack 88KB
-
那如果是单线程的话
- 整个heap都是这个线程的
-
-
堆在linux下理论3GB, 实际大概可以2.9GB
-
windows
-
理论2G
- 实际大概1.5G
-
-
-
进程虚存空间分布
-
ELF文件链接视图和执行视图
- 操作系统并不关心可执行文件各个段的内容, 值只关心和装载相关的问题, 最主要是段的权限(可读, 可写 ,可执行)
- 子主题 2
-
进程栈初始化
- 进程刚启动的时候, 必须知道一些进程运行的环境, 最基本的就是环境变量和 进程的运行参数(argc, argv)
- 子主题 2
- 进程启动 以后, 程序的库部分会把堆栈里的初始化信息中的参数信息传给main函数, 也就是我们熟知的argc和argv
Linux内核装载ELF过程简介
-
首先在用户层面,bash进程会调用 fork系统调用创建一个新的进程, 然后新的进程调用execve()系统调用 执行指定的ELF文件, 原先的bash进程 返回继续等待过程启动的新进程结束, 然后继续等待用户输入命令
- execve()在unistd.h
-
minibash
-
在进入execve系统调用后, Linux内核开始进入真正的装载工作.
- 在内核中,execve系统调用相应的入口是sys_execve()
- 在进行一些参数的复制后, 调用do_execve()
- do_execve()会先查找被执行的文件, 如果找到了, 读前128个字节,
- 因为linux支持的可执行文件不止一种, a.out java等
- 我们通过魔数来判断究竟是哪种可执行文件
- 当do_execve()读取了128个byte后, 调用search_binary_handle()去搜索和匹配 合适的 可执行文件装载处理过程
- 比如ELF可执行文件对应的装载过程的函数 名叫 load_elf_binary
- a.out叫 load_aout_binary
- 脚本类叫 load_script_binary
-
load_elf_binary
-
1.检查文件有效性 比如魔数, segment数量
-
2.寻找.interp段, 设置动态链接器路径
-
3.根据ELF文件程序头表的描述 ,对ELF文件进行映射, 比如代码, 数据,只读数据
-
4.初始化ELF进程环境,
-
5.将系统调用的返回地址修改成ELF文件可执行文件的入口点
-
这个入口点对于静态链接的
- e_entry所指的地址
-
对于动态链接
- 入口是动态链接器
-
-
-
当load_elf_binary()执行完成后,系统调用的返回地址已经修改成被装载的ELF文件的入口地址了, sys_execve()系统调用()从内核态返回到用户态的时候, EIP寄存器直接跳转到了 ELF程序的入口地址
-
至此, 新的程序开始执行, ELF可执行文件装载完成
分支主题 2
分支主题 3
XMind: ZEN - Trial Version