可执行文件是怎么来的?(以C语言为例)
C代码(.c) - 经过编译器预处理,编译成汇编代码(.asm) - 汇编器,生成目标代码(.o) - 链接器,链接成可执行文件(.out) - OS将可执行文件加载到内存里执行
From C to running program
可执行文件的创建
#include <stdio.h>
int main()
{
printf("hello world!\n");
}
1. 预处理
gcc -E -o hello.cpp hello.c -m32 预处理(文本文件)
预处理负责把include的文件包含进来及宏替换等工作
2. 编译
gcc -x cpp-output -S -o hello.s hello.cpp -m32 编译成汇编代码(文本文件)
3. 汇编
gcc -x assembler -c hello.s -o hello.o -m32 汇编成目标代码(ELF格式,二进制文件,有一些机器指令,只是还不能运行)
4. 链接
gcc -o hello hello.o -m32 链接成可执行文件(ELF格式,二进制文件)
在hello可执行文件里面使用了共享库,会调用printf,libc库里的函数
gcc -o hello.static hello.o -m32 -static 静态链接
把执行所需要依赖的东西都放在程序内部
hello 只有7k,hello.static却有7百k,因为它把需要C库里边的东西也放到可执行文件里面来
可执行文件怎样变成一个运行的进程的?
要弄清楚这个问题,需要先弄清楚,可执行文件的内部是怎样的?是怎样描述可执行文件的?
常见的文件格式
目标文件的格式ELF
EXECUTABLE AND LINKABLE FORMAT 可执行的和可链接的格式(是文件格式的标准)
.o文件 和 可执行文件,都是目标文件,一般使用相同的文件格式
ABI和目标文件格式是什么关系
目标文件也叫做ABI,应用程序二进制接口。实际上在目标文件里面,它已经是二进制兼容的格式。而什么叫二进制兼容呢?所谓的二进制兼容,就是指这个目标文件已经是适应某一种CPU体系结构上的二进制的指令,比如在32位x86环境下编译出来的目标文件,链接成ARM上的可执行文件,那肯定是不可以的
ELF文件里面三种目标文件
一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其它的object文件一起来创建一个可执行文件或者是一个共享文件(主要是.o文件)
一个可执行(executable)文件保存着一个用来执行的程序,该文件指出了exec(BA_OS)如何来创建程序进程映象(操作系统怎么样把可执行文件加载起来并且从哪里开始执行)
一个共享object文件保存着代码和合适的数据,用来被下面两个链接器链接:(主要是.so文件)
第一个是链接编辑器(静态链接)【请参看ld(SD_CMD)】,可以和其它的可重定位和共享object文件来创建其它的object
第二个是动态链接器,联合一个可执行文件和其它的共享object文件来创建一个进程映象
ELF的目标文件格式
Object文件参与程序的链接(创建一个程序)和程序的执行(运行一个程序)
一个ELF头在文件的开始,保存了路线图(road map),描述了该文件的组织情况
程序头表(Program header table)告诉系统如何来创建一个进程的内存映像
Section头表(Section header table)包含了描述文件Sections的信息。每个Section在这个表中有一个入口,每个入口给出了该Section的名字,大小等信息
链接视图,有很多Section,执行视图,有很多段(Segment)
大多数文件格式也都是这种模式,在头记录了一些元数据,用readelf命令来详细查看ELF文件头
可执行程序加载的主要工作
当创建或增加一个进程映象的时候,系统在理论上将拷贝一个文件的段到一个虚拟的内存段
可执行文件的格式和进程的地址空间有一个映射关系
可执行文件有一个头部,里面有一些关键信息,Entry point Address,入口地址,即程序的起点,0x8048300,后面有一些代码,数据
对于进程来讲,进程有一个进程地址空间,而对于32位x86体系结构来讲,进程有4G的进程地址空间(逻辑地址),3G以上的地址空间只能在内核态下访问,在用户态的时候,只能访问0到3G的地址空间。
ELF可执行文件加载到内存的位置 与 ELF可执行文件加载到内存中开始执行的第一行代码
ELF可执行文件默认加载到内存0x8048000这个位置,从这个位置开始加载。前面加载ELF可执行文件的头部信息,但因不同文件大小不同,程序的实际入口为:0x8048x00,图例为0x8048300,也就是说这个位置是程序的实际入口地址,即刚加载过可执行文件的进程(一个进程加载了新的可执行文件之后,开始执行的入口点),就是从这个地方开始执行
简略地来看,图例里的文件是ELF的静态链接文件。静态链接的时候,会将所有代码放在一个代码段,把所有的链接都链接好了,所以从0x8048300开始一行行代码执行,压栈出栈,把整个程序执行完
而实际上如果需要用到共享库,需要动态链接的话,会有多个代码段,情况会更复杂(暂不研究)
装载可执行程序之前的工作最主要的是两大部分:
1. 可执行程序的文件格式
2. 可执行程序的执行环境
一般是通过shell程序启动一个可执行程序,shell程序具体做了什么?而当启动加载一个可执行程序的时候,也就是发起一个系统调用execve,shell环境准备了哪些执行的上下文环境(用户态的执行环境)
再看看execve系统调用怎么样把一个可执行文件在内核态里面装载起来,装载起来后又返回到用户态(内核态的执行环境)
可执行程序的执行环境(Shell命令行、main函数的参数与execve的参数)
$ ls -l /usr/bin 列出/usr/bin下的目录信息,Shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身
例如,int main(int argc, char *argv[]) -- 愿意接收命令行参数
又如,int main(int argc, char *argv[], char *envp[]) -- 愿意接收shell的环境变量,前两个参数由用户输入命令的时候设定-l /usr/bin,后一个是shell环境,shell程序自动加上
Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
库函数exec*都是execve的封装例程
命令行参数和环境变量是如何保存和传递的?
先函数调用参数传递,再系统调用参数传递
Shell程序 -> execve -> sys_execve,然后在初始化新程序堆栈时拷贝进去
命令行参数和环境串都放在用户态堆栈中
当fork一个子进程的时候,复制父进程,调用execve系统调用的时候,要加载的可执行程序把原来的进程的环境覆盖掉了,覆盖掉之后它的用户态堆栈也被清空了,因为它是个新的程序要执行,那么argv和envp是如何进入新程序的用户态堆栈的?即命令行参数和环境变量是如何进入新程序的堆栈的?
在创建一个新的用户态堆栈的时候,实际上是把命令行参数的内容和环境变量的内容通过指针的方式传递到execve系统调用的内核处理函数,然后内核处理函数在创建可执行程序新的用户态堆栈的时候,会把参数拷贝到用户态堆栈里,初始化新的可执行程序的上下文环境。所以,新的程序能从main函数开始,把对应的参数接收过来,然后执行。但原先在调用execve时,参数只是压在了shell程序当前进程的堆栈上,而这个堆栈在加载完新的可执行程序之后,已经被清空了,内核又创建了一个新进程的用户态堆栈
如果仅仅只是加载一个静态链接的可执行程序的话,只需要传递一些命令行参数,一些环境变量,可执行程序就可以正常地工作。但是对于绝大多数的可执行程序来讲,还有一些对动态链接库的依赖,这个比较复杂。装载时动态链接和运行时动态链接应用举例
动态链接分为可执行程序装载时动态链接和运行时动态链接,如下代码演示了这两种动态链接。
1. 准备.so文件(动态链接文件,windows下是dll)
编译成libshlibexample.so文件
$ gcc -shared shlibexample.c -o libshlibexample.so -m32
编译成libdllibexample.so文件
$ gcc -shared dllibexample.c -o libdllibexample.so -m32
2. 以共享库和动态加载共享库的方式使用libshlibexample.so文件和libdllibexample.so文件
编译main,注意这里只提供shlibexample的-L(库对应的接口头文件所在目录)和-l(库名,如libshlibexample.so去掉lib和.so的部分),并没有提供dllibexample的相关信息,只是指明了-ldl
可执行程序的装载相关关键问题分析(execve和fork都是特殊一点的系统调用)
传统的系统调用都是陷入到内核态,然后再返回到用户态,继续执行系统调用下面的指令
fork系统调用进入到内核态,两次返回,在父进程中,返回到父进程原来的位置继续向下执行,这个和传统的系统调用是一样的。在子进程中,构造了它的堆栈环境,子进程返回到特定的点,是从ret_from_fork开始执行然后返回到用户态,对于子进程来讲比较特殊
execve系统调用,当前的可执行程序在执行,执行到execve系统调用时候,陷入到内核态,在内核里面,用execve加载的可执行文件,把当前进程的可执行程序给覆盖掉了,当execve系统调用返回的时候,已经不是返回到原来的可执行程序了,是新的可执行程序的执行起点,也就是main函数大致的位置,那么main函数的执行环境,也就需要我们来构建好加载的新的可执行程序的执行环境
sys_execve内核处理过程
当execve系统调用陷入到内核里的时候,system_call,调用了sys_execve(),sys_execve内部会解析可执行文件格式,后面的调用顺序:
do_execve -> do_execve_common -> exec_binprm
search_binary_handler根据文件头部信息寻找对应的文件格式处理模块,如下:
根据我们给出的文件名,加载了文件的头部,判断文件是什么格式,在列表中寻找能够解释ELF格式的内核模块
对于ELF格式的可执行文件fmt->load_binary(bprm);执行的应该是load_elf_binary其内部是和ELF文件格式解析的部分需要和ELF文件格式标准结合起来阅读
Linux内核是如何支持多种不同的可执行文件格式的?
当ELF文件格式出现的时候,观察者就能自动执行load_elf_binary,但实际上是在retval = fmt->load_binary(bprm)执行,这个地方实际上是一种多态的机制,本质上是一种观察者模式
在load_elf_binary里有一个很关键的地方,start_thread
看一下start_thread /linux-3.18.6/fs/binfmt_elf.c#82
start_thread这个函数有一个pt_regs,一个new_ip,一个new_sp
pt_regs实际上就是内核堆栈的栈底的那部分,发生系统调用int 0x80的时候,把eflags、sp、ip都压入到栈。那么新进程执行的时候,需要把它的起点位置给它替换掉
new_ip是怎么来的呢?
看一下load_elf_binary /linux-3.18.6/fs/binfmt_elf.c#571
975 start_thread(regs, elf_entry, bprm->p);
elf_entry,对于一个静态链接的可执行文件,就是可执行文件里的Entry point address,可执行文件头部定义的起点
在一个新的可执行程序返回到用户态之前,需要修改int 0x80压入内核堆栈的EIP,用新的可执行程序的起点来修改,但是对于动态链接的过程又更复杂一些,先理解静态链接的过程,大致是这样
看一下系统调用sys_execve内核处理过程
看一下sys_execve /linux-3.18.6/fs/exec.c#1604
看一下do_execve /linux-3.18.6/fs/exec.c#do_execve
看一下do_execve_common /linux-3.18.6/fs/exec.c#do_execve_common
打开要加载的可执行文件,然后加载文件头部
1474 file = do_open_exec(filename);
创建结构体,bprm
1481 bprm->file = file; 1482 bprm->filename = bprm->interp = filename->name;
把环境变量和参数都copy到结构体里面
1505 retval = copy_strings(bprm->envc, envp, bprm); 1509 retval = copy_strings(bprm->argc, argv, bprm);
对可执行文件的处理过程
1513 retval = exec_binprm(bprm);
看一下exec_binprm /linux-3.18.6/fs/exec.c#exec_binprm
寻找可执行文件的处理函数
1416 ret = search_binary_handler(bprm);
看一下search_binary_handler /linux-3.18.6/fs/exec.c#1352
寻找能够解释当前可执行文件的代码模块
看一下load_elf_binary /linux-3.18.6/fs/binfmt_elf.c#84
结构体变量,对load_binary做了赋值
前面说过,elf_format这个结构体变量要注册到链表里面去,这是一个发布订阅的架构模式,或者叫观察者模式,实际上是一个函数指针,在面向对象里,叫多态模式
在看load_elf_binary之前,看elf_format这个结构体变量是怎么进入到一个内核的处理模块里
看一下__init init_elf_binfmt /linux-3.18.6/fs/binfmt_elf.c#2198
注册结构体变量,把结构体变量注册到链表里面去,这样当出现一个ELF格式文件的时候,就到链表里面找专门处理ELF文件格式的模块
重点关注ELF文件格式的解释,看一下load_elf_binary /linux-3.18.6/fs/binfmt_elf.c#571
571static int load_elf_binary(struct linux_binprm *bprm)
(结合ELF文件格式)读取文件头部信息
626 retval = kernel_read(bprm->file, loc->elf_ex.e_phoff, 627 (char *)elf_phdata, size);
load_elf_binary主要的工作是把可执行文件映射到进程的地址空间,ELF可执行文件默认会被映射到0x8048000这个地址
816 error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt, 817 elf_prot, elf_flags, 0);
需要动态链接的可执行文件先加载连接器ld
所以后面在start_thread的时候就会有两种可能
975 start_thread(regs, elf_entry, bprm->p);
如果是一个静态链接文件的话,elf_entry就是指向Entry point address的位置0x8048x00
如果是一个需要依赖动态链接库的话,需要ld链接器,elf_entry就是指向动态链接器的起点
可以简单地理解,start_thread实际上就是把返回到用户态的位置从原来的int0x80的下一条指令变成了新加载的可执行文件的Entry point address的位置0x8048x00
浅析动态链接的可执行程序的装载
对于一般的可执行程序来讲,大多都是需要使用动态链接库,动态链接库最常见的就是libc,动态链接器ld它也是libc的一部分。那么在动态链接的过程,内核做了什么?
ELF格式里面要依赖其它的动态链接库,动态链接库某一个.so本身(它也是一个ELF格式的文件)还可能会依赖其它的动态链接库,因此实际上动态链接库的依赖关系会形成一个图。在解释每一个ELF格式文件的时候,看它依赖了哪些动态链接库,这样它就会加载
那么谁负责加载呢?
阅读内核代码的时候,可以看到,当这个文件需要用elf_interpreter的话,也就是说它需要依赖动态链接器来解释这个ELF文件,那么它就需要加载load_elf_interp,实际上是加载动态连接器ld,那么这时候Entry point address,也就是说在返回到用户态的时候,它返回的就不是这个可执行程序文件规定的起点,它返回的是动态连接器的程序入口,动态连接器负责解释当前的可执行文件,看它里面依赖哪些动态链接库,然后把那些动态链接库一个一个加载进来,加载进来之后再解释加载进来的动态链接库,看它这个动态链接库还依赖哪些文件,这样就有一个叫广度遍历的方法(即动态链接库的装载过程是一个图的遍历),把所有的动态链接库都装载起来,装载起来之后ld再负责把CPU的控制权移交给可执行程序头部规定的起点位置
那么从以上分析看出,动态链接的过程不是由内核来完成的,主要是由动态链接器来完成的,动态链接器是libc的一部分,是在用户态做的事情
Summary:
加载可执行程序两种方式
1. 静态链接,直接执行可执行程序的入口 Entry point address(0x8048x00)
2. 需要动态链接,由ld动态链接这个可执行程序
(完)