作业信息
作业课程 | 2020-2021-1Linux内核原理与分析 |
---|---|
作业要求 | 2020-2021-1Linux内核原理与分析第八周作业 |
作业目标 | 可执行程序工作原理 |
作业正文 | 2020-2021-1 20209322《Linux内核原理与分析》第八周作业 |
可执行程序工作原理
一、ELF目标文件格式
1.1ELF概述
“目标文件”,是指编译器生成的文件。“目标”指目标平台目标文件一般也叫作ABI(Application Binary Interface,应用程序二进制接口),目标文件和目标平台是二进制兼容的。二进制兼容即指该目标文件已经是适应某一种CPU体系结构上的二进制指令。
最古老的目标文件格式是a.out,后来发展成COFF格式,现在常用的格式有PE(Windows)和ELF(Linux)。
ELF(Executable and Linkable Format)即可执行的和可链接的格式,是一个目标文件格式的标准。
ELF格式里有3种不同类型的目标文件:
(1)可重定位文件:这种文件一般是中间文件,还需要继续处理。由编译器和汇编器创建,一个源代码文件会生成一个可重定位文件。用来和其他的目标文件一起来创建一个可执行文件、静态库文件或者共享目标文件(即动态库文件)。读者在编译Linux内核时可能会注意到,每个内核源代码.c文件都会生成一个同名的.o文件,该文件即为可重定位目标文件,最后所有的.o文件会链接为一个文件,即Linux内核。
(2)可执行文件:一般由多个可重定位文件结合生成,是完成了所有重定位工作和符号解析(除了运行时解析的共享库符号)的文件,文件中保存着一个用来执行的程序。
(3)共享目标文件:共享库,是指可以被可执行文件或其他库文件使用的目标文件。Linux下共享库后缀为.so的文件,so代表shared object。
ELF文件的作用是参与程序的链接(建立一个程序)和程序的执行(运行一个程序)。
1.2 ELF格式简介
ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且他们的位置也未必如图所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
创建如下图的hello20209322.c
1.2.1 ELF Header 结构
ELF Header 在文件最开始描述了该文件的组织情况。ELF文件头会指出可执行文件是32位还是64位的,e_ident数组的第五个字节是1表示是32位,2表示是64位。ELF Header的其他部分主要说明了其他文内容的位置、大小等信息。ELF Header 长度为64字节,在/usr/include/elf.h文件中。ELF表头会给出很多关于本ELF文件的属性信息,如前面提到过的3种ELF类型就是通过e_type来体现的。e_type值1、2、3、4分别代表可重定位目标文件、可执行文件、共享目标文件和核心文件。其中最重要是段头表(program header table)和节头表(Section header table)的位置。段头表存储于文件的e_phoff(ELF header的字段,下同)位置,有e_phnum项内容,每项大小为e_phentsize字节;节头表存储于e_shoff位置,有e_shnum项内容,每项大小为e_shentsize字节。节头表基本定义了整个ELF文件的组成,可以说是整个ELF就是由若干个节(Section)组成的。
1.2.2 Program Header table 结构
段头表(Program Header)表是和创建进程相关的,描述了连续的几个节在文件中的位置、大小以及它被放进内存后的位置和大小,告诉系统如何创建进程映像,可执行文件加载器就可以按这个说明将可执行文件搬到内存中。用来构造进程映像的目标文件必须具有段头表,可重定位文件不需要这个表。
8列分别是Type类型、Offset文件偏移、VirtAddr虚拟地址、PhysAddr物理地址、FileSiz 可执行文件中该区域的大小、MemSiz内存中该区域的大小、Flg属性标识和Align对齐方式。该表描述了将可执行文件中起始位置为Offset、大小为FileSiz的一段数据,加载到内存地址VirtAddr中。Type值为LOAD表示该段(Segment)需要加载到内存,Offset全0表示其内容为从可执行文件头开始共0xa065f(FileSiz)个字节,加载到虚拟地址0x08048000(VirtAddr)处,该段为可读(R)可执行(E)权限,4k(Align,0x1000)对齐。再往下看为节与段的映射关系说明(Section to Segment mapping:),00即第一行描述的段,一共包括了.note、.ABI-tag、.init、.text等多个节。
1.3 Section Header table 结构
节头表Section Header table 组成的表,包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件有没有这个表皆可。
6列分别是[Nr]索引、Name节名、Type类型、Addr虚拟地址、Off偏移和Size节大小。简单来说,该节描述了将可执行文件中起始位置为Off、大小为Size的一段数据加载到内存地址Addr。Type中的PROGBITS表示该节存储的是代码,Addr为080482d0是该部分将加载到内存中的虚拟地址,Off为节在可执行文件中的偏移,后半部分的Key to Flags是对Flg中标识的说明,如.text节Flg为AX,A(Alloc)表示需要加载到内存中,X(eXecute)表示对应内存需要可执行权限。
二、程序编译
程序从源代码到可执行文件的步骤:预处理、编译、汇编、链接。
2.1预处理
预处理时编译器完成的具体工作如下:
- 删除所有的注释“/”和“/**/”。
- 删除所有的“#define”,展开所有的宏定义。
- 处理所有的条件预编译指令。
- 处理“#include”预编译指令,将被包含的文件插入该预编译指令的位置,这一过程是递归进行的。
- 添加行号和文件名标识。
2.2编译
编译时,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码实际要做的工作。在检查无误后,gcc把代码翻译成汇编语言。
2.3汇编
汇编后形成的.o格式的文件已经是ELF格式文件了。程序编译后生成的目标文件至少含有3个节区(Section),分别为.text,.data和.bss。
- .bss段。BSS段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是BlockStarted by Symbol的简称。BSS段属于静态内存分配,该节区包含了在内存中的程序未初始化的数据。当程序开始运行时,系统将用0来初始化该区域。该节不占用文 件空间,该section type = SHT_NOBITS。
- .data段。数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
- .text段。代码段(code segment/text segment)通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码 段为可写,即允许修改程序。在代码段中,也可能包含一些只读的常数变量,例如字符串常量等。
其他常见节:
- .rodata:存放C中的字符串和#define定义的常量,该节包含了只读数据。
- .comment:该节包含了版本控制信息。
- .dynamic:该节包含了动态链接信息。
- .dynsym:该节包含了动态链接符号表。
- .init:该节包含了用于初始化进程的可执行代码、也就是说,当一个程序开始运行时,系统将会执行在该节中的代码,然后才会调用程序的入口点(对于C程序而言就是main)。
2.4链接
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被复制)到内存中并执行。
三、链接与库
在可执行文件的生成过程中,最为复杂的部分就是链接。链接从过程上讲分为符号解析和重定位两部分;根据链接时机的不同,又分为静态链接和动态链接两种。
先以hello.c为例简要说明符号、符号解析与重定位。
符号: 简化来说,hello.c中只有两个符号------main和printf。main的实现就在hello.c中,而printf的实现显然没有在 hello.c中,相应的hello.c编译为hello.o后,main这个符号是“有定义”的,printf这个符号则是“无定义”的。“有定义”的 意思就是函数对应的机器指令地址在当前文件中(有明确的地址)。
符号解析:编译器需要到其他的共享库中找到printf的“定义(机器指令片段)”,找到后把该片机器指令与hello.o拼接到一起,生成可执行文件hello。hello中printf就存在了(有定义即有了明确的地址),这就是符号解析。
重定位:在拼接所有目标文件的同时,编译器会确定各个函数加载到内存中的运行地址,然后反过来修改所有调用该函数的机器指令,使得该指令能跳转到正确的内存地址。这个过程就是重定位。
3.1符号与符号解析
符号包含全局变量和 全局函数。例如printf就是一个符号,hello程序需要在函数库中找到这个符号。
符号表(symbol table)是一种供编译器用于保存有关源程序构造的各种信息的数据结构,符号表的功能是找未知函数在其他库文件中的代码段的具体位置,还是以hello 为例,其调用的printf是外部库提供的函数。在链接前,编译器需要把类似于printf这种符号都记录下来,存储于符号表中。
符号表的查看方法为objdump -t xxx.o或readelf -s xxx.o。
3.2重定位
重定位是把程序的逻辑地址空间变换成内存中的实际物理地址空间的过程,也就是说在装入时对目标程序中指令和数据的修改过程。它是实现多道程序在内存中同时运行的基础。
简单总结一下,符号表记录了目标文件所有的全局函数及其地址;重定位表中记录了所有调用这些函数的代码位置。在链接时,这两大类数据都需要逐一修改为正确的值。
3.3静态链接与动态链接
静态链接:在编译链接时直接将需要的执行代码复制到最终可执行文件中,优点是代码的装载速度快,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对比较大。缺点是如果多个应用程序使用同一库函数,会被装载多次,浪费内存。
动态链接: 在编译时不直接复制可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统。操作系统负责将需要的动态库加载到内存中, 然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库去执行代码,最终达到运行时链接的目的。优点是多个程序可以共享同一段代码,而且不需要 再磁盘上存储多个复制。缺点是在运行时加载,可能会影响程序的前期执行性能,而且对使用的库依赖性较高,在升级时特别容易出现版本不兼容的问题。
四、程序装载
4.1概要
Shell 本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,也就是main函数愿意接收什么。Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数。
4.2 fork与execve内核处理过程
Linux提供了 execl、execlp、execle、execv、execvp、和execve 等6个用以执行一个可执行文件的函数(统称为exec函数,差异在于对命令行参数和环境变量参数的传递方式不同)。
整体的调用关系为sys_execve() -> do_execve() -> do_execve_common() -> exec_binprm() -> search_binary_handler() ->load_elf_binary() -> start_thread()。
4.3 execve与fork的区别与联系
fork两次返回,第一次返回到父进程继续向下执行,第二次是子进程返回到ret_from_fork后正常返回到用户态。execve 在执行时陷入内核态,用execve中加载的程序把当前正在执行的进程覆盖掉,当系统调用返回时也就返回到新的可执行程序起点。内核处理这个可执行程序的装载过程,实际上是执行程序装载的一个系统调用,和前面分析的fork及其他的系统调用的主要过程是一样的。但是 execve这个系统调用的内核处理过程和fork一样也是比较特殊的。因为正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后 的下一条指令。fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,这和其他的系统调用是一样 的。在子进程中fork也返回了一次,会返回到一个特定的点------ret_from_fork,通过内核构造的堆栈环境,它可以正常返回到用户态, 所以它稍微特殊一点。同样,execve 也比较特殊。当前的可执行程序正在执行,执行到execve 时陷入内核态,在内核里面用execve 加载的可执行文件把当前进程的可执行程序给覆盖掉。当execve 的系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve 返回的是新的可执行程序执行的起点,也就是main函数的大致位置。
五、实验-使用gdb跟踪分析一个execve系统调用内核处理函数
5.1、更新linux/kernel下的menu,并将test_exec变成test.c
使用help命令可以看到已经有exec命令了,执行exec命令发现比fork指令多输出一行“hello world!”。实际上时新加载了一个可执行程序来输出了一行语句。
5.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
设置断点sys_execve
执行exec指令,执行到断点
再设置断点
b load_elf_binary
b start_thread
继续执行
通过list 可以查看后续会调用start_thread函数
继续执行到start_thread断点
可以看到
查看new_ip的值,new_ip时返回到用户态的第一条指令的地址。
通过
readelf -h hello
查看hello的elf头部,查看定义的入口地址与new_ip所指向的地址时一致的
继续单步执行
六、总结
Linux 内核加载可执行程序进程,和古代庄生梦蝶的故事比较相似。如果把fork出来的shell程序的子进程比作庄子,它调用execve系统调用进入内核即 入睡了(Shell子进程本身停止执行)。进入内核的execve系统调用加载了一个新的可执行程序(比如前文中的hello程序),execve系统调用 return返回到用户态时发现自己已经不是原来的shell子进程,而是hello程序。如果hello程序内部也执行execve系统调用加载 shell程序,同样返回到用户态(醒来)发现自己是shell进程了。这两者总是相对的,你可以装载我,我可以装载你。但都是同一个进程,只是进程里的 可执行程序被替换掉了。