前言
📫作者简介:小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫
🏆InfoQ签约博主、CSDN专家博主/Java领域优质创作者、阿里云专家/签约博主、华为云专家、51CTO专家🏆
🔥如果此文还不错的话,还请👍关注 、点赞 、收藏三连支持👍一下博主~
本文导读
了解过ELF文件内容,我们知道程序由各种段组成,仅仅了解程序的组成还不够,本讲深入计算机程序(包含C/C++、Java、Python等等)所有语言的执行原理,同时了解在ELF文件中将内存布局地址,CPU是如何执行指令的,C语言中方法的执行过程的内核调用。
一、程序虚拟地址空间布局
在介绍ELF文件内容时,我们知道程由各种段组成,同时在LF件中将内存布地址都已经描述完成。程序读取到内存中后,根据 ELF的描述,决定是否执行动态链接。最后生成的程序布局图如图所示。
寻址空间为4GB的内存模型图,这里的地址空间是虚拟地址空间,底层的线性地址分段和物理地址分页,上层是无感知的。可以看到,每个程序的虚拟地址空间最高 1GB 处都是操作系统的内核映射,这是因为不管程序如何映射,都需要一段虚拟地址空间用于映射内核,这样我们才能通过系统调用访问内核。
整个程序包含如下部分
1、text segment 程序代码段
2、data segment 数据段
3、BSS segment 未初始化的数据段
4、 heap 堆区。由低地址往高地址扩张
5、memory mapping region 其实也属于堆区,只不过这一部分可以通过 mmap 来产生映射
6、stack程序运行时需要的栈内存,由高地址往低地址扩张
由于内部数据和函数,均在两个连接库中使用绝对地址所以我们将关注点放在全局数据和函数上。由于代码段 .text ,加载到内存中,OS不允许修改代码段的内容,他只读(保护程序)而对于数据段而言,非 .rodata,其他数据是可读可写的,所以维护表,来存储自己的程序的虚拟地址值。
二、CPU执行指令原理
我们了解到一个程序通过gcc编译,经过预处理里、编译、链接等步骤,最终生成了 ELF类型的二进制文件。通过反汇编,我们了解到这些文件其实就是之前学过的汇编语言 mov、sub 等,然后结合操作一系列的寄存器完成了整个执行过程。
本节我们就来探究以下两个问题:
CPU是如何执行指令的?C语言中方法的执行过程?
这里我们通过gdb来调试,并观察整个执行过程。读者需要寄存器。首先通过 gcc -g demo.c 编译源文件,生成 a.out ELF可执行文件。然后通过gdb的断点机制,对main函数打上断点,gdb a.out 。
程序成功地停止在断点处,观察此时的寄存器状态
#include<stdio.h>
int sum(int a, int b) {
int c = a + b;
return c;
}
int main() {
int i = sum(3,3);
printf("%d",i);
return 1;
}
重点观察 RIP 和 CS 寄存器,此时的 RIP为0x40054f,CS为0x33。注意,OS 位数为64位,所以这里以R开头。前面介绍的16位为IP,32位为EIP,其他寄存器也是如此。
RIP为0x400537,CS为0x33,RSI和RDI为3,即我们传入的参数。接下来继续执行,此时,RIP为 0x400542,CS为0x33,RSI和RII为3,rax保存了返回值6,EFLAGS变为了 0x206。其实,就是增加了一个PF(奇偶校验位)。可以看到,RIP 的计数和编译后的 ELF 文件地址一样。这就意味着,编译时就确定了虚拟地址的信息,这也正是虚拟地址→线性地址→物理地址映射的魅力。每个程序都认为自己占有整个内存地址,事实上底层由 OS结合硬件来进行段表、页表映射。
从上面的分析过程,我们可以得出以下几点信息。
1、CPU 通过 RIP 来获取指令的地址。
2、C语言程序通过寄存器来传递参数。当然也可以通过栈,如参数太多、寄存器放不下等情况。
3、虚拟地址在 ELF 文件中就已经确定。
4、调用方法的过程中,CS 代表代码段寄存器。启动保护模式后,CS 为段选择子 0x33,可以确定main 函数和sum函数处于同一个段,且变为二进制为110011。段选择子后面的两位代表着4个特权级--0、3。其中,0 代表 OS 特权级,3 用于用户程序,这里正好 11(二进制)为3。同时,第3位表明段信息是 LDT 和 GDT,这里为 0,表明在 GDT 中。其余高13位用于在段描述符表中,作为索引查询段基址。
5、执行方法后,发生了改变,从原来的IF 增加到了 PF、IF。
通过这些观察和结论,我们可以总结CPU如何执行程序的:
首先通过 CS 和 IP 寄存器定位到需要执行的指令,然后执行指令,接着根据执行的结果设置 EFLAGS 寄存器,最后在调用方法时通过寄存器或者栈来传递参数,并且在ELF文件生成时就已经确定了程序的虚拟地址。