2019-2020-1 20199311《Linux内核原理与分析》第八周作业
- 问题描述
通过这一周的学习,我学习了linux操作系统可执行程序的工作原理,包括可执行文件的格式、编译、链接、装载等知识,下面将通过介绍理论知识,以及使用gdb跟踪分析一个execve系统调用内核处理函数sys_execve来深入理解这个过程。
2. 解决步骤
2.1 ELF文件简介
目标文件是指编译器生成的文件,“目标”是指目标平台,它决定了编译器使用的机器指令集。ELF即可执行的和可链接的格式,是一个目标文件格式的标准。ELF格式的文件用来存储Linux程序。ElF首部回描绘整个文件的组织结构,它还包括很多节(sections,是在ELF文件里用以装载内容数据的最小容器)。
ELF有3种格式
- 一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件 一起来创建一个可执行文件或者是一个共享文件。
- 一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
- 一个共享object文件保存着代码和合适的数据,用来被下面的两个链接器链接。第一个链接编辑器,可以和其他的可重定位和共享object文件来创建其他的object。第二个是动态链接器,联合一个可执行文件和其他的共享object文件来创建一个进程映象。
ELF文件参与程序的链接(建立一个程序)和程序的执行(运行一个程序)。
ElF文件的主体包括各种节,如代码节.text,还有描述这些节属性信息的(Program header table和Section header table),以及ELF文件的整体描述信息(ELF header)。
2.2程序编译
程序从源代码到可执行文件的步骤:预处理、编译、汇编、链接。
2.2.1 预处理
指令如下
gcc -E hello.c -o hello.i
预处理完成的具体工作如下
- 删除所有“#define”,展开所有的宏定义
- 处理所有的条件预编译指令
- 处理“#include”预编译指令,将被包含的文件插入该预编译指令的位置,这一过程是递归进行的。
- 添加行号和文件名标识
预处理完的文件仍然是文本文件。
2.2.2 编译
指令如下
gcc -S hello.i -o hello.s -m32
gcc首先检查代码的规范性、是否有语法错误等,检查无误后,gcc把代码翻译成汇编语言。编译完的文件仍然是文本文件。
2.2.3 汇编
指令如下
gcc -c hello.s -o hello.o.m32 -m32
-m32表示生成32位目标文件。
汇编后形成的.o格式的文件已经是ELF格式文件了。程序编译后生成的目标文件至少含3个节区(Section),分别为.text,.data和.bss。
- .bss段:BSS段用来存放程序中未初始化的全局变量的一块内存区域。
- .data段:数据段用来存放程序中已初始化的全局变量的一块内存区域。
- .text段:代码段通常是指用来存放程序执行代码的一块内存区域。
2.2.4 链接
指令如下
gcc hello.o.m32 -o hello.m32.static -m32 -static
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可以被加载(或复制)到内存中并执行。
2.3 链接与库
在可执行文件的生成过程中,比较复杂的部分是链接。链接从过程上分为符合解析和重定位两部分;从时机上分为静态链接和动态链接两种。
2.3.1 符号表
符号包括全局变量和全局函数。符号表是一种供编译器用于保存有关源程序构造的各种信息的数据结构。这些信息在编译器的分析阶段被逐步收集并放入符号表,它们在综合阶段用于生成目标代码。符号表的功能是找未知函数在其他库文件中的代码段的具体位置。如代码调用外部库提供的函数,在链接前,编译器把这种符号都记录下来,存储于符号表中。
2.3.2 重定位
1. 重定位
重定位是把程序的逻辑地址空间变换成内存中的实际物理地址空间的过程,也就是说在装入时对目标程序中指令和数据的修改过程。重定位分为两步
- 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节,将运行时存储地址赋给新的聚合节、输入模块定义的每个节,以及输入模块定义的每个符号。此时,程序的每个指令和全局变量都有唯一的运行时存储器地址。
- 重定位中的符号引用:链接器修改代码节和数据节对每个符号的引用,使得它们执向正确的运行地时的地址。链接器依赖于重定位条目的可重定位目标模块中的数据结构。
2.重定位表
可重定位表的每一条记录对应一个需要重定位的符号。汇编器可将重定位文件中每个包含需要重定位符号的段都建立一个新的重定位表。
总结一下,符号表记录了目标文件中所有全局函数及其地址;重定位表中记录了所有调用这些函数的代码位置。
2.3.3 静态链接和动态链接
1 .静态链接
在编译时直接将需要的执行代码复制到最终可执行的文件中,优点是代码的装载速度快,执行速度也比较快,对外部环境依赖度低。编译时会把所有需要的代码都链接进去,应用程序相对比较大。缺点是如果多个应用程序使用同一库函数,会被装载多次,浪费内存。
2. 动态链接
在编译时不直接复制可执行代码,而是通过一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统。操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库去执行代码,最终达到运行时链接的目的。优点是多个程序可以共享同一段代码,而不需要在磁盘上存储多个复制。缺点时在运行时加载,可能会影响程序的前期执行性能,而且对库的依赖度极高。
2.4 程序装载
2.4.1 执行环境上下文
以一个例子开始,如果在Shell中输入以下指令ls -l /usr/bin,实际上相当于执行了可执行程序ls,后面带两个参数-l和/usr/bin。Shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,也就是main函数愿意接收什么。典型的main函数可以写成如下几种:
int main()
int main(int argc,int *argv[])
int main(int argc,int *argv[],char *envp[])
用户输入命令时,会通过int argc和char *argv[]传进来。Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数。execve的函数原型如下:
int execve(const char *filename,char *const argv[],char *const envp[]);
filename为可执行文件的名字,argv是以NULL结尾的命令行参数数组,envp是以NULL结尾的环境变量数组。当fork一个子进程时,会生成子进程的进程控制块与堆栈,子进程控制块会复制父进程的大部分内容。堆栈则由execlp,再将程序跳转到main。这边是main函数的起点,在创建一个新的用户态堆栈时,实际上是把命令行参数内容和环境变量的内容通过指针的方式传到系统调用内核处理函数,再创建一个新的用户态堆栈时会把这些char *argv[]和char *envp[]复制到用户态堆栈中,来初始化这个新的可执行程序执行的上下文环境。所以新的程序可以从main函数开始把对应的参数接收过来,然后执行,但父进程在调用execve这个命令行时,只是压在了shell程序当前的堆栈上,堆栈在加载完新的可执行程序之后已经被清空了。所以内核帮我们创建了一个新的进程执行堆栈和新的进程用户态堆栈。
普通静态链接程序在完成以上操作后,堆栈上的返回地址会修改为程序入口点的地址。而动态链接的程序会首先执行.interp节区指向的动态链接器。动态链接器ld负责加载库并进行解析,装载所有需要的动态链接库,然后ld将CPU的控制权交给可执行程序。
2.5 使用gdb跟踪分析一个execve系统调用内核处理函数sys_execve
2.5.1 sys_execve
Linux提供了execl、execlp、execle、execv、execvp和execve等6个用以执行一个可执行文件的函数(统称exec函数,差异在于对命令行参数和环境变量参数的传递方式不同)。以上函数的本质都是调用实现系统调用sys_execve()来执行一个可执行文件。
整体的调用关系为sys_execve -> do_execve() -> do_execve_common() -> exec_binprm() -> search_binary_handler() -> load_elf_binary() -> start_thread()。
2.5.2 实验步骤
将menu目录删除,利用git命令克隆一个新的menu目录
用test_exec.c将test.c覆盖,然后重新编译rootfs。
可以看到test.c中增加了exec函数
使用help命令可以看到增加了exec指令
执行exec指令发现比fork指令增加了一行输出“hello,world!”。实际上是新加载了一个可执行程序来输出了一行语句。
返回LinuxKernel目录,shift+ctrl+o水平分割,执行如下命令
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S
启动gdb,准备进行单步调试
gdb
file linux-3.18.6/vmlinux
target remote:1234
设置如下断点:sys_execve,load_elf_binary,start_thread。(停在“sys_exec之后在设置其它断点)
进行调试,可以看到sys_execve函数
sys_execve调用do_execve函数
进行单步调试
load_elf_binary函数,根据静态、动态链接的不同设置不同的elf_entry,按照ELF文件布局加载到内存中,然后启动新的进程
start_thread结构体,进程切换到内核态前的堆栈位置和返回地址就存储在这个结构里。
可以看到“new_ip”是返回到用户态的第一条指令的地址
3. 总结
通过这一周学习,我初步了解linux中可执行程序的工作原理,对linux中程序的执行有了一个更深入的认识,接下来将通过进一步学习进程相关的知识来进一步深入学习linux内核的工作机制。