可执行文件格式(ELF格式)以及进程地址空间第二讲【Linux操作系统】
文章目录
- 可执行文件的格式
- 可执行文件中存储了什么
- 可执行文件中的虚拟地址以及加载
- 进程地址空间第二讲
- CPU如何执行进程的代码
- 再谈进程地址空间的区域划分
可执行文件的格式
源文件被编译器编译之后的可执行文件,并不是只有代码和数据,还有一定的格式,也就是ELF格式
ELF格式中
可执行文件的代码和数据的内容和属性
是存储在一个一个的节(section)中的
但是可执行文件被加载到内存的时候,以及被执行的时候,并不是
以节(section)为单位被加载和使用的
因为一个节(section)太小了,而且信息零散,以节为单位加载效率不够高
所以具有相同特征的多个节,就会被看做成一个段
(类似磁盘文件系统的扇区和数据段的关系)
①Header:存储整个可执行程序ELF的相关属性信息
②PHT:
是一个数组:
每一个元素都是一个:描述可执行程序的一个数据段的“结构体”对象
数据段“结构体”中比较重要的字段:
1.数据段在文件中的起始偏移量和终止偏移量,方便查找,方便操作系统从文件读取指定的区域(数据段)的代码和数据
2.数据段在进程地址空间的起始地址和终止地址用于初始化进程地址空间
3.数据段的rwx权限
③SHT:
是一个数组:
每一个元素都是一个:描述可执行程序的一个section(数据节)的“结构体”对象
section“结构体”中比较重要的字段:
1.节的开始偏移量以及节的大小
2.节的起始虚拟地址
如下图:
左图是可执行文件在磁盘中的样子
右图是可执行文件被加载到内存执行时的样子
不光是可执行程序,.o文件,动静态库在磁盘中存储的时候,也是按照ELF格式存储的
所以所谓的静态库的链接就是:
main函数的执行逻辑中使用到的函数和全局变量,把所有的参与链接的.o文件中包含这些东西的节(section),全部整合在一起变成可执行文件的ELF格式中的节
然后,具有相同特征的节会被放在一起“合并”成一个段
可执行文件中存储了什么
①环境变量区的数据只能在进程创建之后继承父进程的(或者读取配置文件
)
②共享区的数据是在可执行程序运行之后,存储动态映射(比如:映射动态库,映射共享内存等)而且根据运行情况,共享区是动态开辟,动态扩展的
③栈区和堆区是运行时动态开辟(申请物理内存)的,用于存储运行时产生的数据(变量等)的(所以地址不固定,做不到在可执行文件中存储它们的虚拟地址)[变量的初始值在代码区和代码在一起],也是在运行的过程中才动态创建
④剩下的数据区中的数据,代码区中的代码和其相关信息则是在变成进程之前就已经有了,并且还有它们对应的虚拟地址,都存储在可执行文件中
所以可执行文件的代码和数据,在被加载之前,每行代码,每个全局区的变量就已经有了虚拟地址了
可执行文件中的虚拟地址以及加载
可执行文件编址模式是平坦模式
本来逻辑地址=起始地址+偏移量
采用平坦模式之后,每个逻辑的地址的起始地址都是0
所以平坦模式下:逻辑地址=偏移量
逻辑地址(偏移量)的取值范围就是,内存的大小,也就是虚拟地址的取值范围从0开始给每行代码/数据进行编址
即:
我们可以简单地认为:可执行程序的逻辑地址=进程虚拟地址
每一个汇编指令(代码)都有自己的大小,并且CPU“知道”每一个汇编指令的大小
所以我们给代码编制的时候,只需要知道起始地址就可以了
因为:当前汇编指令地址+汇编本身指令的大小=下一条汇编指令的地址
所以
进程加载时,初始化进程地址空间的数据区和代码区的起始虚拟地址和具体的虚拟地址都是从可执行文件中读取的
栈区,堆区是运行时,操作系统动态使用虚拟地址
可执行程序的加载
如下图
每个可执行程序的虚拟地址加载到物理内存之后,都有自己对应的物理地址
就把起始的物理地址填充到页表中
进程地址空间第二讲
CPU如何执行进程的代码
可执行程序被加载到内存之后,CPU如何知道从可执行程序的那条代码开始执行呢?
其实ELF中的Header里面保存了,进程的起始函数(_start函数)的地址
CPU中pc寄存器中放的是虚拟地址
可执行程序的代码和数据加载到内存之后
每个汇编指令的虚拟地址也加载进去了
内存为了保存代码,就会为每行代码分配物理内存,所以每一个汇编指令也有了自己对应的物理地址
所以就可以在页表里建立每个汇编指令的虚拟地址和物理地址的映射关系
CPU如何执行进程的代码?
①进程起始汇编指令的虚拟地址(也就是函数_satrt的第一条汇编指令的地址
),在进程加载时就会被放进CPU中的pc寄存器中
②通过CR3寄存器里面保存的进程的页表的起始物理地址,找到页表
③通过MMU硬件在页表中,把PC寄存器中的虚拟地址转化成物理地址
也就获得了存储这条汇编指令的物理地址
④拿着汇编指令的物理地址找到物理内存中该地址中存储的具体的汇编指令是什么,最后把汇编指令读取到CPU中的EIP寄存器中
⑤PC寄存器把当前PC寄存器中存储虚拟地址+=EIP中存储的汇编指令的大小=下一条汇编指令的起始虚拟地址
(因为可执行文件的代码时连续存储的,并且平坦模式编址的,所以虚拟地址一定是连续的)
⑥CPU执行EIP寄存器中存储的汇编指令
如此循环,即可执行进程代码
当然说是这么说,CPU没那么傻
不会每次取一条汇编指令都去物理内存中拿,因为从物理内存中读取数据和CPU从自己的缓存中取数据慢的多
CPU会缓存汇编指令
如何缓存?
很简单,有一次拿着MMU转换出来物理地址找到一行汇编的时候,还会把这条汇编指令后面的一些汇编指令都读取进CPU的缓存
再谈进程地址空间的区域划分
进程地址空间中的代码区和数据区
的大小,在编译形成可执行文件的时候就已经固定了,在运行过程中大小也一定不会再改变了
之前我们说过,进程地址空间划分区域,只需要设置区域的开始地址和结束地址就行
所以进程地址空间给代码区和数据区划分虚拟地址区间就很简单,一对开始和结束地址就行
但是栈区,堆区,共享区,环境变量区的大小,都有可能在运行的过程中发生变化
那进程地址空间如何给它们动态地划分区域呢?
mm_struct里面有一个
struct vm_area_struct*
类型链表的头指针mmap
struct vm_area_struct里面的数据域中有
start和end这两个框定(划分)区域
所以分配虚拟地址空间的本质是
创建一个struct vm_area_struct类型的节点,链接到链表中,并把它的start和end初始化,在页表中建立与物理内存的映射
这样就分配了一块连续的虚拟地址空间,并给它映射了物理内存
所以当用户使用栈区/堆区等动态区域时
①进程地址空间先在struct vm_area_struct
链表里给栈区,堆区等区域都给了一个初始节点,节点里面存储了它们的start和end
并在页表中建立这块区域与物理内存的映射
②如果运行时,哪个区域的映射的物理内存不够用了或者要插入新区域[比如依赖库增加,堆区申请空间]就再创建一个节点,再申请一些物理内存
]
这样就可以动态地增加/释放虚拟内存对应的物理内存,进而提高物理内存的利用率
所以mm_struct
里面存储的各个区域start和end,只是对所有的虚拟内存宏观上的划分
,用于限定每个区域的最大范围(例如栈区最大几M,堆区最大几G等等)
而struct vm_area_struct链表
里面的节点则是微观上的划分
虽然他是微观的,但是他更重要,因为大部分节点标识的虚拟内存区域都映射了物理内存
可执行程序加载一个段基本就会给它一个struct vm_area_struct节点
所以可执行文件代码和数据,就可以以段为单位,分批加载了
因为可执行程序加载到内存之后,也不一定马上调用
所以甚至可以只加载可执行程序的入口的虚拟地址以及在页表中左侧存放section的起始虚拟地址,右侧存放section在中文件的起始偏移量
等到CPU调用该进程时,去页表里面找地址时,发现没有对应的物理地址,就触发缺页中断,此时操作系统在分批加载该可执行文件的代码和数据,并给它们分配物理地址
也就是懒加载
而静态库则不需要搞这么麻烦
静态库只需要在链接的时候,把包含可执行文件需要的方法的实现的section合并到可执行文件的section就可以了