Linux内核分析——可执行程序的装载
马悦+原创作品转载请注明出处+《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
第七周 可执行程序的装载
一、 预处理、编译、链接和目标文件的格式
(一)可执行程序是怎么得来的
1、linux系统中,可执行程序一般要经过预处理、编译、汇编、链接、执行等步骤。
2、c代码,经过预处理,变成汇编代码;经过汇编器,变成目标代码;连接成可执行文件;加载到内核中执行。
3、可执行文件的创建——预处理、编译和链接
cd Code
vi hello.c
gcc -E -o hello.cpp hello.c -m32 //预处理
vi hello.cpp
gcc -x cpp-output -S -o hello.s hello.cpp -m32 //翻译成汇编文件
vi hello.s
gcc -x assembler -c hello.s -o hello.o -m32 // 翻译成可重定位目标文件
vi hello.o
gcc -o hello hello.o -m32 //链接成可执行目标文件
vi hello
gcc -o hello.static hello.o -m32 -static //静态编译
ls -l
-rwxrwxr-x 1 shiyanlou shiyanlou 7292 3\u6708 23 09:39 hello
-rw-rw-r-- 1 shiyanlou shiyanlou 64 3\u6708 23 09:30 hello.c
-rw-rw-r-- 1 shiyanlou shiyanlou 17302 3\u6708 23 09:35 hello.cpp
-rw-rw-r-- 1 shiyanlou shiyanlou 1020 3\u6708 23 09:38 hello.o
-rw-rw-r-- 1 shiyanlou shiyanlou 470 3\u6708 23 09:35 hello.s
-rwxrwxr-x 1 shiyanlou shiyanlou 733254 3\u6708 23 09:41 hello.static
4、gcc的一些参数:
-c 只编译不链接,生成目标文件.o
-S 只编译不汇编,生成汇编代码
-E 只进行预编译,不做其他处理
-g 在可执行程序中包含标准调试信息
(二)目标文件的格式ELF
1、.o文件,可执行文件,都是目标文件,一般使用相同的文件格式。
2、常用文件格式:
(1)a.out
(2)COFF
(3)PE – WINDOWS系统上
(4)ELF – LINUX系统上
3、ABI:应用程序二进制接口
4、ELF文件格式中有三种主要的文件格式:
(1)可重定位文件
主要是.o文件,保存有代码和适当数据,用来和其他的object文件一起来创建一个可执行文件或者共享文件
(2)可执行文件
保存着一个用来执行的程序,该文件指出exec(BA_OS)如何创建程序进程映象。
(3)共享目标文件
保存代码和合适的数据,用来和链接器链接:
1)链接编辑器,静态链接,和其他的可重定位、共享目标文件创建其他的目标文件
2)动态链接器,连喝一个可执行文件和其他的共享目标文件来创建一个进程映像
5、可执行程序加载的主要工作:当创建或者增加一个进程映像的时候,系统在理论上将拷贝一个文件的段到一个虚拟的内存段。
(三)静态链接的ELF可执行文件和进程的地址空间
1、32位x86进程地址空间共4G,内核空间是1G。
2、如何加载到内存:默认从0x8048000开始加载,然后头部需要占用一定空间,程序的实际入口可以在0x8048100等地方,即可执行文件加载到内存中开始执行的第一行代码的入口处。
3、一般静态链接会把所有代码放在一个代码段,动态链接会有多个代码段。
二、 可执行程序、共享库和动态链接
(一)装载可执行程序之前的工作
1、命令行参数和shell环境,一般我们执行一个程序的Shell环境,我们的实验直接使用execve系统调用。
$ ls -l /usr/bin 列出/usr/bin下的目录信息
Shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,
——例如,int main(int argc, char *argv[])
——又如,int main(int argc, char *argv[], char *envp[])
Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
——int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
——库函数exec*都是execve的封装例程
2、一个简单的例子:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char * argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid<0)
{
/* error occurred */
fprintf(stderr,"Fork Failed!");
exit(-1);
}
else if (pid==0)
{
/* child process */
execlp("/bin/ls","ls",NULL);
}
else
{
/* parent process */
/* parent will wait for the child to complete*/
wait(NULL);
printf("Child Complete!");
exit(0);
}
}
3、命令行参数和环境变量是如何进入新程序的堆栈的:
(1)实际上是把命令行参数和环境变量通过系统调用传递到内核处理函数,然后内核处理函数在创建新的用户态堆栈时都拷贝进去,来初始化新的可执行程序的堆栈。
即:shell->execve->sys_execve,然后在初始化新程序堆栈时拷贝进去。
(2)Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数,先函数调用参数传递,再系统调用参数传递。
(二)装载时动态链接和运行时动态链接应用举例
1、动态链接分为可执行程序装载时动态链接和运行时动态链接,如下代码演示了这两种动态链接。
准备.so文件
共享库的例子:
shlibexample.h //定义了一个函数原型
shlibexample.c
#include <stdio.h>
#include "shlibexample.h"
int SharedLibApi()
{
printf("This is a shared libary!\n");
return SUCCESS;
}
编译成libshlibexample.so文件,命令如下:
$ gcc -shared shlibexample.c -o libshlibexample.so -m32
动态加载的过程是一样的,只是名字不同:
dllibexample.h
dllibexample.c
编译成libdllibexample.so文件
$ gcc -shared dllibexample.c -o libdllibexample.so -m32
2、分别以共享库和动态加载共享库的方式使用libshlibexample.so文件和libdllibexample.so文件
/* main.c */
#include <stdio.h>
#include "shlibexample.h" // 共享库
#include <dlfcn.h> // 动态加载
int main()
{
printf("This is a Main program!\n");
/* 调用共享库函数 Use Shared Lib */
printf("Calling SharedLibApi() function of libshlibexample.so!\n");
SharedLibApi();
/* 调用动态装载库 Use Dynamical Loading Lib */
void * handle = dlopen("libdllibexample.so",RTLD_NOW);
if(handle == NULL)
{
printf("Open Lib libdllibexample.so Error:%s\n",dlerror());
return FAILURE;
}
int (*func)(void); // 声明了一个函数指针
char * error;
func = dlsym(handle,"DynamicalLoadingLibApi"); // 找到这个函数指针
if((error = dlerror()) != NULL)
{
printf("DynamicalLoadingLibApi not found:%s\n",error);
return FAILURE;
}
printf("Calling DynamicalLoadingLibApi() function of libdllibexample.so!\n");
func(); // 使用
dlclose(handle);
return SUCCESS;
}
编译main,注意这里只提供shlibexample的-L(库对应的接口头文件所在目录)和-l(库名,如libshlibexample.so去掉lib和.so的部分),并没有提供dllibexample的相关信息,只是指明了-ldl
$ gcc main.c -o main -L/path/to/your/dir -lshlibexample -ldl -m32
$ export LD_LIBRARY_PATH=$PWD #将当前目录加入默认路径,否则main找不到依赖的库文件,当然也可以将库文件copy到默认路径下。
$ ./main
This is a Main program!
Calling SharedLibApi() function of libshlibexample.so!
This is a shared libary!
Calling DynamicalLoadingLibApi() function of libdllibexample.so!
This is a Dynamical Loading libary!
3、两种方式:
(1)在程序执行过程中由程序自身装载共享库
(2)在装载可执行程序时完成动态链接过程
三、 可执行程序的装载
(一)可执行程序的装载相关关键问题分析
1、可执行程序的装载其实还是系统调用,execve,比较特殊的系统调用.
2、陷入到内核态,在内核态中加载,把当前进程的可执行程序覆盖掉;execve返回时,就是新的可执行程序了。
3、sys_execve内核处理过程:
sys_execve内部会解析可执行文件格式:
- do_execve -> do_execve_common -> exec_binprm
- search_binary_handler符合寻找文件格式对应的解析模块,如下:
- 1369 list_for_each_entry(fmt, &formats, lh) {
- 1370 if (!try_module_get(fmt->module))
- 1371 continue;
- 1372 read_unlock(&binfmt_lock);
- 1373 bprm->recursion_depth++;
- 1374 retval = fmt->load_binary(bprm); //用来解析elf文件的执行到位置。
- 1375 read_lock(&binfmt_lock);
根据文件头部信息寻找对应的文件格式处理模块
- 对于ELF格式的可执行文件fmt->load_binary(bprm);执行的应该是load_elf_binary,其内部是和ELF文件格式解析的部分,需要和ELF文件格式标准结合起来阅读。
- Linux内核是如何支持多种不同的可执行文件格式的?
实现技巧:
- /* 全局变量elf_format,把函数指针load_elf_binary**赋值**给了.load_binary */
- 82 static struct linux_binfmt elf_format = {
- 83 .module = THIS_MODULE,
- 84 .load_binary = load_elf_binary,
- 85 .load_shlib = load_elf_library,
- 86 .core_dump = elf_core_dump,
- 87 .min_coredump = ELF_EXEC_PAGESIZE,
- 88 };
- /* 把变量elf_format**注册**进了format链表里,就可以在链表里对应elf模式中找到对应模块 */
- 2198 static int __init init_elf_binfmt(void)
- 2199 {
- 2200 register_binfmt(&elf_format);
- 2201 return 0;
- 2202 }
elf_format 和 init_elf_binfmt,这里就相当于观察者模式中的观察者,elf_format是观察者,1369开始的那段代码是被观察者,当elf文件出现的时候,就会自动执行load_elf_binary。
4、在load_elf_binary中调用了start_thread这个函数:
198 start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)/* pt_regs 是内核堆栈栈底的函数,*/
199 {
200 set_user_gs(regs, 0);
201 regs->fs = 0;
202 regs->ds = __USER_DS;
203 regs->es = __USER_DS;
204 regs->ss = __USER_DS;
205 regs->cs = __USER_CS;
206 regs->ip = new_ip; //起点位置
207 regs->sp = new_sp;
208 regs->flags = X86_EFLAGS_IF;
209 /*
210 * force it to the iret return path by making it look as if there was
211 * some work pending.
212 */
213 set_thread_flag(TIF_NOTIFY_RESUME);
214 }
215 EXPORT_SYMBOL_GPL(start_thread);
可执行文件开始执行的起点:通过修改内核堆栈中eip的值作为新程序的起点。
以上适用于静态库。
(二)sys_execve的内部处理过程
1、
1604 SYSCALL_DEFINE3(execve,
1605 const char __user *, filename,
1606 const char __user *const __user *, argv,
1607 const char __user *const __user *, envp)
1608 {
1609 return do_execve(getname(filename), argv, envp);
1610 }
1611 #ifdef CONFIG_COMPAT
1612 COMPAT_SYSCALL_DEFINE3(execve, const char __user *, filename,
1613 const compat_uptr_t __user *, argv,
1614 const compat_uptr_t __user *, envp)
1615 {
1616 return compat_do_execve(getname(filename), argv, envp);
1617 }
1618 #endif
sys_execve函数中返回了一个do_execve:
1549 int do_execve(struct filename *filename,
1550 const char __user *const __user *__argv,
1551 const char __user *const __user *__envp)
1552 {
1553 struct user_arg_ptr argv = { .ptr.native = __argv };
1554 struct user_arg_ptr envp = { .ptr.native = __envp };
1555 return do_execve_common(filename, argv, envp);
1556 }
最后一句中do_execve_common把文件名,参数和环境转换了一下。
该函数do_execve_common打开如下:
1474 file = do_open_exec(filename);
打开了一个要加载的可执行文件,然后会加载一下它的头部,建立一个结构体,把命令行参数和环境变量拷贝到结构体中;
1513 retval = exec_binprm(bprm);
对这个可执行文件的处理过程。
打开exec_binprm这个函数,可以找到一句重要代码:
1416 ret = search_binary_handler(bprm);
寻找这个我们打开的可执行文件的处理函数。
打开search_binary_handler,找到list_for_each_entry如下:
1369 list_for_each_entry(fmt, &formats, lh) {
1370 if (!try_module_get(fmt->module))
1371 continue;
1372 read_unlock(&binfmt_lock);
1373 bprm->recursion_depth++;
1374 retval = fmt->load_binary(bprm);
1375 read_lock(&binfmt_lock);
1376 put_binfmt(fmt);
1377 bprm->recursion_depth--;
1378 if (retval < 0 && !bprm->mm) {
1379 /* we got to flush_old_exec() and failed after it */
1380 read_unlock(&binfmt_lock);
1381 force_sigsegv(SIGSEGV, current);
1382 return retval;
1383 }
1384 if (retval != -ENOEXEC || !bprm->file) {
1385 read_unlock(&binfmt_lock);
1386 return retval;
1387 }
1388 }
在这个循环里寻找能够解析这个当前可执行文件的代码模块。
retval = fmt->load_binary(bprm); // 这一句中的load_binary,加载处理函数。这一句是函数指针,实际上是调用的load_elf_binary。
2、load_elf_binary的函数中涉及到很多文件解析的内容。核心工作是把文件映射到进程的空间中。
3、ELF可执行文件会被默认映射到0x8048000这个地址。
4、需要动态链接的可执行文件先加载链接器ld
5、动态链接库的执行过程:
887 if (elf_interpreter) {
888 unsigned long interp_map_addr = 0;
889
890 elf_entry = load_elf_interp(&loc->interp_elf_ex,
891 interpreter,
892 &interp_map_addr,
893 load_bias);
需要加载连接器
6、静态链接的执行过程:
912 else {
913 elf_entry = loc->elf_ex.e_entry;
直接把elf文件的entry地址赋给elf_entry。但是在start_thread中是直接用的elf_entry:
start_thread(regs,elf_entry, bprm->p);
7、如果是一个静态连接的文件,elf_entry就是指的main函数开始的位置。如果是一个需要依赖动态链接库的文件,elf_entry指向的是动态链接器的起点,将cpu控制权交给ld来加载依赖库并完成动态链接。
8、对于静态链接的文件,elf_entry是新程序执行的起点。
(三)使用gdb跟踪sys_execve内核函数的处理过程
1、搭建环境:
rm menu -rf
git clone https://github.com/megnning/menu.git
cd menu
ls
mv test_exec.c test.c
vi test.c // 可以看到增加了一个exec的程序,只比fork程序多了一个execlp
vi Makefile // 查看Makefile的更改,加入了hello
make rootfs
2、使用gdb跟踪
Qemu -kernel ../linux-3.18.6/arch/x86/boot/bzImage -initrd ../rootfs.img -s -S
gdb
file ../linux-3.18.6/vmlinux
target remote:1234
b sys_execve //可以先停在sys_execve然后再设置其他断点
b load_elf_binary
b start_thread
通过po new_ip和新窗口的 readelf -h helloc查看入口地址
hello的入口地址和new_ip的值都是0x8048d0a
new_ip是返回到用户态的第一条指令的地址:
(四)浅析动态链接的可执行程序的装载
1、动态链接的过程内核做了什么:ELF文件格式需要依赖动态链接库。动态链接库也会依赖别的动态链接库。
2、可执行文件依赖的动态链接库(共享库)是由谁负责加载以及如何递归加载的?
(1)关注ELF文件中.interp和.dynamic
(2)动态链接器ld负责解析,加载,解析,装载和链接后ld再将CPU的控制权交给可执行程序(头部规定的起点位置)。
3、动态链接库的装载过程其实是一个图的广度遍历
4、两种加载的方法:
(1)静态库:直接执行可执行程序的入口
(2)动态库:由ld来动态链接这个程序,然后再把控制权移交给可执行程序的入口。
5、动态链接的过程不是由内核完成,是由动态链接器完成的。
四、总结
1、Linux下有三种目标文件格式,它们是:共享目标文件格式、可执行文件格式、可重定位文件格式。
2、运行时动态装载链接至少需要用到哪些函数:dlopen、dlsym
3、一般系统调用库函数API的参数传递过程,比如execve系统调用,先进行函数调用参数传递,然后系统调用参数传递,最后又进行函数调用参数传递。
4、Linux下可以使用readelf命令查看分析ELF格式文件。
5、动态连接有两种形式:可执行程序装载时动态连接和运行时动态链接。
6、execve执行静态链接程序时,通过修改内核堆栈中保存的eip的值作为新进程的起点。