#Linux 内核分析学习笔记 -- 第七章 可执行程序工作原理
学习目标: 了解一个可执行程序是如何作为一个进程工作的.
ELF 文件
目标文件: 是指由汇编产生的 (*.o) 文件和可执行文件. 即 可执行或可连接的文件. 目标文件是已经适应某一种 CPU 体系结构上的二进制指令.
目标文件的格式可以分为:
a.out
COFF
PE(Windows)和 ELF(Linux)
ELF 就是可执行和可连接的格式, 是一个目标文件的标准格式. ELF 是一种对象文件格式, 用于定义不同类型的对象文件中都有什么内容, 以什么样的格式存放这些内容.
ELF 文件的三种类型:
可重定位文件: 属于中间文件, 需要继续处理. 由编译器和汇编器创建. 一个源代码会生成一个可重定位文件. 用来和其他目标文件一起来创建一个可执行文件, 静态库文件或者共享目标文件
可重定位文件后缀为. o , 最后所有. o 文件会链接为一个文件.
可执行文件: 由多个可重定位文件结合生成, 完成了所有重定位工作和符号解析的文件. 文件中保存着一个用来执行的程序.
共享目标文件: 共享库, 是指被可执行文件或其他库文件使用的目标文件. 其后缀为. so
ELF 文件的功能:
ELF 文件参与程序的连接 (建立一个程序) 和程序的执行(运行一个程序), 所以可以从不同的角度来看待 elf 格式的文件:
如果用于编译和链接(可重定位文件), 则编译器和链接器将把 elf 文件看作是节头表描述的节的集合, 程序头表可选.
如果用于加载执行(可执行文件), 则加载器则将把 elf 文件看作是程序头表描述的段的集合, 一个段可能包含多个节, 节头表可选.
如果是共享文件, 则两者都含有.
ELF 格式
ELF 文件由 4 部分组成, 分别是 ELF 头 (ELF header), 程序头表(Program header table), 节(Section) 和节头表(Section header table).
ELF Header 之后可能会有一个程序头部表(Program Header Table), 如果存在的话, 告诉系统如何创建进程映像. 用来构造进程映像的目标文件必须具有程序头部表, 可重定位文件不需要这个表.
节区头部表 (Section Heade Table) 包含了描述文件节区的信息, 每个节区在表中都有一项, 每一项给出诸如节区名称, 节区大小这类信息. 用于链接的目标文件必须包含节区头部表, 其他目标文件可以有, 也可以没有这个表.
另外, Sections 是文件节区, 它包含不同的节区, 且节区没有规定的顺序.
ELF Header
ELF Header 结构体定义:
- #define EI_NIDENT 16
- typedef struct {
- unsigned char e_ident[EI_NIDENT];
- Elf32_Half e_type;
- Elf32_Half e_machine;
- Elf32_Word e_version;
- Elf32_Addr e_entry;
- Elf32_Off e_phoff;
- Elf32_Off e_shoff;
- Elf32_Word e_flags;
- Elf32_Half e_ehsize;
- Elf32_Half e_phentsize;
- Elf32_Half e_phnum;
- Elf32_Half e_shentsize;
- Elf32_Half e_shnum;
- Elf32_Half e_shstrndx;
- } Elf32_Ehdr;
其中 e_ident 定义:
- e_ident[] Identification Indexes
- Name Value Purpose
- ==== ===== =======
- EI_MAG0 0 File identification
- EI_MAG1 1 File identification
- EI_MAG2 2 File identification
- EI_MAG3 3 File identification
- EI_CLASS 4 File class
- EI_DATA 5 Data encoding
- EI_VERSION 6 File version
- EI_PAD 7 Start of padding bytes
- EI_NIDENT 16 Size of e_ident[ ]
其中结构体 e_ident[EI_NIDENT]前 4 个字节叫做一个魔术数(magic number), 用来确定该文件是否为 ELF 的目标文件, 所有 ELF 文件的魔数是相同的. 其中 EI_VERSIONELF 头的版本号, 目前只能设置为'1'.
对于 ELF Header 的部分结构体成员:
e_machine 该成员变量指出了运行该程序需要的体系结构.
e_version 这个成员确定 object 文件的版本.
e_entry 程序入口虚地址.
e_phoff 文件头偏移, 表明文件头紧接在 elf head 后面.
e_shoff 节头表文件偏移;
e_flags 处理器相关的标志
e_ehsize 该成员保存着 ELF 头大小(以字节计数).
e_phentsize 该成员保存着在文件的程序头表 (program header table) 中一个入口的大小(以字节计数). 所有的入口都是同样的大小.
e_phnum 该成员保存着在程序头表中入口的个数.
e_shentsize 该成员保存着 section 头的大小(以字节计数).
e_shnum 该成员保存着在 section header table 中的入口数目.
e_shstrndx 该成员保存着跟 section 名字字符表相关入口的 section 头表 (section header table) 索引.
其中, 节头表定义了整个 ELF 文件的组成, 段只是对节的重新组合, 将多个节区描述为一段连续区域, 对应到一段连续的内存地址中.
Section Header
节区头是节区的索引, 程序执行时先通过 ELF Header 找到 Section Header, 再通过这一索引找到对应的节区.
- typedef struct {
- Elf32_Word sh_name;
- Elf32_Word sh_type;
- Elf32_Word sh_flags;
- Elf32_Addr sh_addr;
- Elf32_Off sh_offset;
- Elf32_Word sh_size;
- Elf32_Word sh_link;
- Elf32_Word sh_info;
- Elf32_Word sh_addralign;
- Elf32_Word sh_entsize;
- } Elf32_Shdr;
sh_name 节名, 是在字符串中的索引
sh_type 节类型
sh_addr 该节对应的虚拟地址
sh_offset 该节在文件中的位置
sh_size 该节的大小
sh_link 与该节连接的其他节
sh_addralign 对齐方式
Program Header
段头表是和创建进程相关的, 描述了连续的几个节在文件中的位置, 大小以及它被放入内存后的位置和大小, 告诉系统如何创建进程
- /* Program Header */
- typedef struct {
- Elf32_Word p_type;
- Elf32_Off p_offset;
- Elf32_Addr p_vaddr;
- Elf32_Addr p_paddr;
- Elf32_Word p_filesz;
- Elf32_Word p_memsz;
- Elf32_Word p_flags;
- Elf32_Word p_align;
- } Elf32_Phdr;
p_type 当前描述的段类型
p_offset 段在文件中的偏移
p_vaddr 段在内存中的虚拟地址
p_paddr 在物理内存定位相关的系统中, 此项为物理地址保留
p_filesz 段在文件中的长度
p_memsz 段在内存中的长度
p_align 确定段在文件及内存中如何对齐
程序编译
程序从源代码到可执行文件经过以下步骤:
预处理, 编译, 汇编, 链接.
预处理
gcc -E hello.c -o hello.i
预处理的主要工作是
删除所有的注释
删除所有 #define, 进行替换
处理所有预编译指令
处理 #include 指令, 将被包含的文件插入预编译指令的位置
添加行号和文件名标识
预处理完的文件仍然是文本文件, 可以用任意文本编辑器查看.
编译
gcc -S hello.i -o hello.s -m32
编译首先会检查代码的规范性, 语法错误等
汇编结束的文件是二进制文件, 可以用任意编辑器查看
汇编
gcc -c hello.s -o hello.o -m32
汇编结束后的文件已经是 ELF 格式的文件了. 至少包含三个节区. text .data .bss
.text 代码段, 通常用来存放程序执行代码的内存区域.
.data 数据段, 通常用来存放程序中已经初始化的全局变量的一块内存区域, 属于静态内存分配.
.bss 通常用来存放程序中未初始化的变量的内存区域, 不占用文件空间.
链接
gcc hello.o -o hello -m32 -static
主要工作将有关的目标文件彼此相连, 使得所有目标文件能够成为一个能够被操作系统装入执行的统一整体. 将各种代码和数据部分收集起来并组合成一个单一文件的过程, 这个文件可以被加载或复制到内存中并执行.
链接与库
从过程上讲, 链接分为
符号解析
重定位
链接的时机不同, 可以分为
静态链接
动态链接
对于链接过程, 都是采用两步链接的方法
空间与地址分配
扫描所有的输入目标文件, 并且获得它们的各个段的长度, 属性和位置, 并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来, 统一放到一个全局符号表中.
这一步中, 链接器将能够获得所有输入目标文件的段长度, 并且将它们合并, 计算出输出文件中各个段合并后的长度和位置, 并建立映射关系.
符号解析与重定位
使用上面第一步中收集的所有信息, 读取输入文件中段的数据, 重定位信息 (有一个重定位表 Relocation Table), 并且进行符号解析与重定位, 调整代码中的地址(外部符号) 等.
符号与符号解析
在链接中, 我们将函数和变量统称为符号, 函数名或变量名就是符号名, 函数或变量的地址就是符号值.
每一个目标文件都有一个符号表, 符号有以下几种:
定义在本目标文件的全局符号, 可被其他目标文件引用
如: 全局变量, 全局函数
在本目标文件中引用的全局符号, 却没有定义在本目标文件 -- 外部符号(External Symbol)
如: extern 变量, printf 等库函数, 其他目标文件中定义的函数
段名, 这种符号由编译器产生, 其值为该段的起始地址
如: 目标文件的. text,.data 等
局部符号, 内部可见
符号表
符号表是用来供编译器用于保存有关源程序构造的各种信息的数据结构, 这些信息在编译器的分析阶段被逐步收集并放入符号表, 在综合阶段用于生成目标文件.
符号表的功能是找未知函数在其他库文件中的代码段的具体位置.
查看方法: objdump -t xxx.o 或 readlef -s xxx.o
Ndx 该符号对应区节的编号
其中, 可以看到, 在链接前 main 函数没有地址, 而在连接后, main 函数分配了内存地址. 其他属性未改变, 因为 main 函数本身就在 hello.o 文件中.
由此可见符号表中的 Ndx 字段会显示函数表示符号在段在表中的下标, 如果是未定义的函数, 显示 UND; 未初始化的全局变量则显示 COMMON
重定位
重定位就是把程序的逻辑地址空间变换成内存中的实际物理地址空间的过程, 也就是说在装入时对目标程序中指令和数据的修改过程. 它是实现多道程序在内存中同时运行的基础.
上图可以看到在 0x11 处有一个地址, 需要被替换为 puts 将来的内存地址
通过反汇编后可以看到, call 指令之后的 fc ff ff ff 在链接之后, 就会被替换为 puts 在链接后的地址.
由此可见符号表记录了目标文件中所有全局函数及其地址; 重定位表中记录了所有调用这些函数的代码位置
静态链接与动态链接
静态链接
链接器将函数的代码从其所在地 (目标文件或静态链接库中) 拷贝到最终的可执行程序中. 这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中. 静态链接库实际上是一个目标文件的集合, 其中的每个文件含有库中的一个或者一组相关函数的代码.
为创建可执行文件, 链接器必须要完成的主要任务:
符号解析: 把目标文件中符号的定义和引用联系起来;
重定位: 把符号定义和内存地址对应起来, 然后修改所有对符号的引用.
动态链接
在编译时不直接复制可执行代码, 通过记录一系列的参数和符号, 在程序运行或者加载时将这些信息传递给操作系统.
操作系统将需要的动态库加载到内存中, 程序在运行到指定代码时, 去共享执行内存中已经加载的动态库去执行代码.
动态链接分为
装载时动态链接
只需要在代码中调用对应的库函数, 在编译时, 将动态库的头文件路径标明
运行时动态链接
运行时动态链接的本质就是程序员自己控制整个过程.
程序装载
执行环境上下文
在 Shell 中输入 ls -l/usr/bin 实际上相当于执行了可执行程序 ls, 后面带了两个参数.
shell 本身不限制参数的个数, 命令行参数受限于命令自身.
shell 程序的工作方式: fork 出一个子进程, 在子进程中调用 execlp 来加载可执行程序.
如果仅仅加载一个静态链接可执行程序, 只需要传递一些命令行参数和环境变量就可以正常工作. 但是动态链接程序从内核态返回时, 首先会执行. interp 节区所指向的动态链接器.
fork 和 execve 内核处理过程
execve 执行概述
系统调用 sys_execve()被用来执行一个可执行文件, 整体调用关系为:
sys_execve -> do_execve() -> do_execve_common() -> exec_binprm() -> search_binary_handler() -> load_elf_binary() -> start_thread()
系统调用内核处理过程
该系统调用通过宏定义在获得可执行文件的文件名后, 直接调用 do_execve 并传递参数.
调用 do_execve 只是对参数进行了类型转换, 并传递给 do_execve_commom
首先创建了一个结构体, 将环境变量和命令行参数复制到结构体中, 在 exec_binprm 是准备交给真正的可执行文件加载器.
调用函数 search_binary_handler(bprm)根据文件的头部, 寻找可执行文件的处理函数.
在 search_binary_handler(bprm)中调用了指针 load_binary 实际上对应的是 load_elf_binary
load_elf_binary 用来装载可执行文件, 根据静态链接和动态链接的不同, 设置不同的 elf_entry
调用了 start_thread 函数, 来创建新的进程堆栈, 更重要的是修改了中断现场中保存的 EIP 寄存器.
静态链接: elf_entry 指向可执行文件的头部, 是新程序执行的起点.
动态链接: elf_entry 指向 ld(动态连接器)的起点 load_elf_interp
最后就是 start_thread 在这个设置 new_ip 即对应的 elf_entry 等该进程返回用户态时, 转而执行 elf_entry 指向的代码.
execve 和 fork 的区别
简单的来说, 就是 execve 是变身, fork 是分身
利用 gdb 跟踪调试过程
- cd LinuxKernel
- rm menu -rf
- Git clone http://github.com/mengning/menu.git
- cd menu
- mv test_exec.c test.c
- make rootfs
重新编译后, 使用 qemu 命令冻结系统执行, 进行调试
qemu -kernel Linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S
水平分割一个窗口, 启动 gdb 加载内核, 连接到 target 1234
- gdb
- (gdb) file Linux-3.18.6/vmlinux
- (gdb) target remote:1234
添加断点 sys_execve 和 load_elf_binary 和 start_thread
- b sys_execve
- b load_elf_binary
- b start_thread
停在了第一个断点 sys_execve 处
进入第二个断点
进入第 3 个断点, 即 start_thread 处, 继续执行可以看到修改了 eip 的值
来源: http://www.bubuko.com/infodetail-2870346.html