提问:一个源文件是如何变成可执行文件的?
在linux中,使用GCC来编译程序,我们逐步来分析:
- 预编译
gcc -E hello.c hello.i
- 编译
gcc -S hello.i -o hello.s
- 汇编
gcc -c hello.s -o hello.o
- 链接
gcc hello.o -o hello
- 运行
./hello
预编译
预编译阶段主要处理以"#"开头的预编译指令。
- 删除宏定义并作文本替换
- 递归展开头文件“#include”
- 处理#if、#ifdef、#elif、#else、#endif等
- 删除注释“//”和 “/* */”
- 添加行号和文件标识,以便编译器产生编译调试时的行号信息,以及产生错误或警号时能够显示行号
- 保留所有的#pragma指令
编译
- 词法分析
- 语法分析
- 语义分析
- 代码优化
- 生成汇编指令
汇编
将汇编指令转换成机器指令
链接
基础先知
指令和数据
局部变量属于指令,静态变量和全局变量属于数据。
int gdata1 = 10; //数据
int gdata2 = 0; //数据 强符号
int gdata3;//数据
static int gdata4 = 11; //数据
static int gdata5 = 0; //数据
static int gdata6; //数据
int main() //指令
{
int a = 12; //指令
int b = 0; //指令
int c;//指令
static int d = 13; //数据
static int e = 0; //数据
static int f; //数据
return 0; //指令
}
- 堆区空间,在堆还没有被申请的时候,为堆区预留的空间称作空洞;
- 如果程序中有使用到库函数,那么在堆区和栈区中间存放共享库;
- .text .data .bss在程序运行起来以后是固定不变的。程序运行起来的时候还没有堆,执行到malloc或者new的时候,才会给它分配堆,程序运行起来必需的内存是代码段、数据段还有栈
分析二进制可重定位目标文件 main.o 的组成
查看obj文件主要段:objdump -h main.o
其中,data段存放已初始化且初始值不为0的数据,上述代码中一共三个这种数据,占12个字节所以在.o文件中.data段的大小为0x0000000c;
从段的信息上来看,虚拟内存地址跟加载的内存地址都是0,所以一直到汇编阶段结束,都是不给符号分配内存地址的,是在链接时分配的,符号解析完成后,就分配内存地址
查看文件头 : readelf -h main.o
二进制可重定位目标文件以及可执行文件,文件的开始都是ELF Header这样一个文件头。文件头中包含了程序运行的平台、体系;程序的入口地址,这里显示的是0地址, 0地址是不可访问的,所以obj文件是不可能运行的;文件头ELF Header的大小为52个字节,转换成16进制是0x34;
.text段的段偏移是00000034,也就是对于文件组成来说,ELF Header之后就是.text段,text段的大小为0x1b,34+1b=4f,下一个段的偏移是50,因为这里的对齐方式是2的2次方,4f不能被4整除,所以这里补了一个字节
接下来就是数据段.data,大小为0x0c,下一个段的起始地址就是5c。
接下来是数据段.bss段,它的大小是0x14,20个字节,也就是有5个未初始化或初始值为0的数据在bss段,可是所写的代码中却有6个,这是为什么?
从段信息中可以看出.bss段的下一个段.comment段,它的文件偏移和.bss段一样都是0x5c,也就是说,data段下来根本就不是bss段而是.comment段,bss段不占文件的空间。
问题:既然 .o文件并没有存储bss段,那么程序运行时又如何得知存储在bss段上的那么未被初始化的或初始值为0的数据呢?
在obj文件的文件头中有一Start of section headers : 208个字节,十六进制是d0,表示段表(section table)在文件中的偏移量
查看obj文件所有的段:readelf -S main.o
obj 文件段信息(上图)的第一行There are 9 section headers, starting at offset 0xd0 ,读文件头就可以知道文件的段表的位置,段表中记录了当前obj文件里边都有哪些段,段的起始偏移量,以及段的大小。
段表中记录了所有段的详细信息,为什么叫bss段为better save space呢?因为它不需要存初始值,所有数据的初始值都是0,那操作系统又如何知道这些数据的存在呢,就是将它的信息,将来需要占多大的内存,都记录在段表中就可以了(.data段的数据每个初始值都不一样,不记录初始值是不行的,所以这些数据需要单独存储)。
最后一个段的偏移量加上段本身的大小就是整个obj文件的大小 348 + 4c结果是916个字节。使用 ll 查看mian.o的大小发现刚好是916个字节。
打印.o文件中各个段的内容:objdump -s main.o
问题:如果程序里有一指针指向一个常量字符串:char *p = “hello world”,那么这个常量字符串存在哪里?
存放在rodata只读数据段。通过查看段信息发现,数据段是可读可写,代码段是可读并且可执行。
强符号和弱符号
问题:.bss段,它的大小是0x14,20个字节,也就是有5个未初始化或初始值为0的数据在bss段,可是所写的代码中却有6个,这是为什么呢?哪个数据没有被存储呢?
首先看一个例子:
text1.c
int x;
void func()
{
x = 20;
//第一步:编译时 把 20 往x的内存上写 写4个字节
}
text2.c
#include<stdio.h>
short x = 10; //第三部:小端模式写值:14 00 00 00把20写到x中,1400给了x,0000给了y
short y = 10;
void func();
int main()
{
func();// //第二步:链接时选择强符号(short x) 把 20 往x的内存上写 写4个字节
printf("x = %d y=%d\n",x,y); //20,0
return 0;
}
编译时,各文件是分开编译的,此时text1.c里面的x是弱符号
func函数里x=20被编译成: mov dword ptr[x], 14h 往x的内存上写14h,写4字节。
在链接时,发现test2.c里面有一个同名的强符号x,在调用func函数的时候取了short x的地址。因为指令已经在编译阶段编译,所以,这个14h就写在了short x的地址上。但是short x只有两个字节,其他的部分就写在了内存紧挨着的short y的内存上,覆盖了y原有的数据。小端模式,14h在内存上的保存方式是:00010100 00000000 00000000 00000000。因此,x=20,y=0。
一个工程里的多个文件是分离式编译的,text1.c编译产生text1.obj,text2.c编译产生text2.obj,这个工程在cpp下是不能通过编译的,因为CPP不允许有同名的全局变量。在c语言中可以,因为c语言有强符号和弱符号的概念。简单来说,有初始化的都叫强符号,未初始化的都叫弱符号。
C语言的规则是:
- 不能出现多个同名的强符号
- 出现了同名的强符号和弱符号,选择强符号
- 出现同名的弱符号,选择内存占用量大的弱符号
在最初的 main.c 中有一 int gdata3是未初始化的全局变量,这是一个弱符号,在链接时,其他的obj文件中可能会有名为gdata3的强符号,或者内存占用量更大的弱符号,不一定会选择这个gdata3。static int gdata6由于加了static 只有本文件可见,所以不考虑。 (链接器在链接时,只对所有obj文件的global符号进行处理,local的符号不做任何处理。加static关键字的都是local的符号,链接器不做处理。)
符号表
符号表:数据是一定产生符号的,函数只产生一个符号,就是函数名,局部变量(指令)不产生符号,也就是是说指令只产生一个符号就是函数名。
查看符号表:objdump -t main.o
从符号表中可以看到gdata3在COMMON块,并没有在任何的段中,COMMON的意思就是表示它现在是一个未决定的符号,因为它现在是一个弱符号,链接时才等待选择。局部变量a,b,c是指令,不产生符号,所有的指令中只有函数名才产生符号。
函数符号生成规则:
- C : _函数名
- C++ :
- _cdecl调用约定:“?”+函数名+参数表的开始标识 “@@YA” + 函数返回类型代号+参数类型代号 +结束标识“@Z”或“Z”(无参数)。
- _stdcall调用约定:“?”+函数名+参数表的开始标识“@@YG”+函数返回类型代号+参数类型代号 +结束标识“@Z”或“Z”(无参数)。
- _fastcall调用约定:“?”+函数名+参数表的开始标识 “@@YI”+ 函数返回类型代号+参数类型代号 +结束标识“@Z”或“Z”(无参数)。
类型代号:
bool(_N)
int (H)
void(X)
char(D)
float(M)
double(N)
short(F)
普通指针(PA)
常指针(PB)
函数符号生成举例:
_cdecl: int Max(int a,int b) 则函数符号为 ?Max@@YAHHH@Z
链接过程分析
- 合并所有的段并调整段偏移和段长度,合并符号表
- 符号解析(决议) (把外部的符号处理掉,在符号引用的地方找到符号定义的地方)
- 分配地址空间和符号重定位(编译过程不给符号分配内存地址,链接结束后,每个符号都得到了自己的虚拟内存地址空间)
sum.c
int gdata10 = 13;
int sum(int a,int b)
{
return a+b;
}
main1.c
int gdata1 = 10;
int gdata2 = 0;
int gdata3;
static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;
int main()
{
extern gdata10;
int a = 12;
int b = 0;
int c = gdata10;
static int d = 13;
static int e = 0;
static int f;
sum(a,b);
return 0;
}
sum.o中主要的段信息:
sum.o的符号表:
main1.o的符号表:
查看汇编指令:objdump -d mian1.o
int c = gdata10;
19: a1 00 00 00 00 mov 0x0,%eax
这行汇编指令中显示的gdata10是0地址,0地址是不可访问的,说明在编译的过程中是不给符号分配内存地址的。
sum(a,b);
31: e8 fc ff ff ff call 32 <main+0x32>
这行汇编指令显示sum函数的地址为fffffffc(小端模式),这是一内核空间的地址(不可访问),sum函数不可能在这个地址,说明在编译的过程中是不给符号分配内存地址的(在链接进行符号解析时才分配内存地址)。
总结:在编译过程中,所有数据填的都是0地址,函数填的都是跟下一行指令地址的偏移量(-4)。函数的跳转地址是根据pc寄存器中的内容+偏移量的得到的(函数在符号表中存储的就是其偏移地址)
符号重定位
通俗的说,就是地址修正的过程。
比如:在代码中,有个全局变量var,它在目标文件A中,但是我们要在目标文件B里面访问这个全局变量,B中若有指令,movl $0x2a,var 此指令的功能是给这个var赋值0x2a。编译目标文件B,得到这条指令的机器码,C705 00 00 00 00 2a 00 00 00 (mov指令码)(目标地址)(源常量)由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以在编译器无法确定地址的情况下,将其目标地址置为0。假设A和B链接后,变量var的地址确定下来是0x1000,那么链接器将会把这个指令的目标地址部分修改为0x1000。
链接所有.o文件 ld -e main -o run *.o,得到可执行文件run
查看段信息 objdump -h run,可以看到 bss段大小为18,即24个字节,6个整型变量,也包括了gdata3。
查看符号表 objdump -t run :每个符号都有内存地址了。
每个符号都有了虚拟地址空间上的地址,这个时候 objdump -d run 查看汇编指令,发现所有数据符号都填的是绝对地址。 函数符号,因为要涉及指令跳转,所以存的都是偏移量。
0480c5: e8 0a 00 00 00 call 80480d4
80480ca: b8 00 00 00 00 mov $0x0,%eax
pc寄存器 中存的是下一行指令的地址也就是80480ca,这个地址加上偏移量0a000000等于080480d4。符号表中显示的sum函数的地址刚好就是080480d4。CPU在进行指令访问的时候,永远是从pc寄存器中取地址,当运行到当前指令的时候,pc寄存器里放的是下一行指令的地址,call指令涉及的是指令的跳转,意思也就是从call以后要调到其他的代码段去执行,而不是继续运行下一行。跳转的位置就是偏移量加下一行指令的地址。
run可执行文件的组成格式分析
首先是文件头ELF Header 用命令 readelf -h run来查看
这里的Entry point address已经不是0了而是0x8048094,这是main函数第一行指令的地址。
运行
- 创建虚拟地址空间到物理内存的映射(创建内核地址映射结构体),创建页目录和页表
- 加载指令和数据
- 把可执行文件的入口地址写到CPU的pc寄存器里面。
查看可执行文件的段信息 objdump -h run
问题1:text段的大小是4e, **Size of this header:52字节,也就是0x34,可是text段的偏移却是0x94,说明在ELF Header和text中间还有一块,那么这里存的又是什么呢?
文件头里有一个Start of program headers:52bytes,也就是0x34,所以通过偏移量得知在ELF Header和text中间的就是program header。
Number of program headers : 3
Size of program header : 32
program header的总大小是 3*32=96字节 (0x60),它的大小(0x60)加上偏移量52字节(0x34) = 0x94;刚好就是text的偏移量。
一共有3个 program header,用命令 readelf -l run 查看它的内容
有两个LOAD页,它的对齐方式是0x1000就是4k,也就是按页面对齐的。其中 00 是 .text,01是.data .bss 。这两个LOAD页就指示了操作系统的加载器应该把当前程序的哪些东西加载到内存中去,以及哪些东西(相同属性)在加载时组织在一个页面。
VP和PP
当程序运行的时候,磁盘上的可执行文件,它本身的存储结构还是按段存储的,只是有两个LOAD项,这两个LOAD项指明了在加载的时候哪些段应该组织在一个页面上。
下图中的DP1、DP2就是这两个LOAD项。
磁盘上的Disk page往内存上加载的时候是先加载到虚拟地址空间VP上,然后再从虚拟地址空间上映射到物理内存PP。
物理内存和虚拟内存的管理方式都是页面。可执行文件为什么要将段按页面组织?因为虚拟地址空间跟物理内存的基本单位都是页面,是为了方便映射。
- 使用mmap函数把磁盘上的页面映射到虚拟地址空间,也就是在虚拟地址空间上开辟内存的方式是使用mmap函数。
- 虚拟地址空间上的虚拟页是通过多级页表的方式映射到物理内存上的
来源:CSDN
作者:weixin_kab777
链接:https://blog.csdn.net/weixin_kab777/article/details/104393062