ELF 执行流程
January 6, 2021 Linux 访问: 64 次
虚拟地址空间
在Linux
操作系统上,每个程序运行起来后,都将会拥有自己的独立的虚拟地址空间(这和Windows
上的进程是一样的),虚拟地址空间的大小是投CPU的位数决定的。一般来说,C语言指针大小的位数与虚拟空间的位数相同。
操作系统会提供一种机制,能够将不同进程的虚拟地址空间与不同内存的物理地址映射起来,这样所有的进程都不能直接访问物理地址,都只能访问自己的虚拟内存空间。
如图所示,无论是32位还是64位的elf,都为操作系统留了空间,
虚拟地址空间分布
Linux
将进程虚拟地址空间中的一个段叫做虚拟内存区域(VMA)。当进程被创建时,就会在进行相对应的数据结构中设置一个.text
段的VMA、.data
段的VMA,如下图所示:
链接视图、装载视图
从链接视图来看elf
,分为若干个节(section
),比如说.text
、.bss
等等,我们可以通过readelf -S [filename]
来查看
❯ readelf -S test
共有 31 个节头,从偏移量 0x19d8 开始:
节头:
[号] 名称 类型 地址 偏移量 大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400238 00000238 000000000000001c 0000000000000000 A 0 0 1
………………
[30] .strtab STRTAB 0000000000000000 000016b8 0000000000000210 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
从装载视图来看,elf
则分为若干个段(segment
),也就是程序头部分,我们可以通过readelf -l [filename]
来查看,站在操作系统的角度来看,它不关心各个节的内容,而是只关心相同权限的节,把他们合并成一起成为段,然后进行映射。它描述了elf
文件该如何被操作系统映射到进程的虚拟地址空间
❯ readelf -l test
Elf 文件类型为 EXEC (可执行文件)
入口点 0x400430
共有 9 个程序头,开始于偏移量 64
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 R E 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c R 1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000006fc 0x00000000000006fc R E 200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000228 0x0000000000000230 RW 200000
DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28 0x00000000000001d0 0x00000000000001d0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254 0x0000000000000044 0x0000000000000044 R 4
GNU_EH_FRAME 0x00000000000005d4 0x00000000004005d4 0x00000000004005d4 0x0000000000000034 0x0000000000000034 R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x00000000000001f0 0x00000000000001f0 R 1
Section to Segment mapping:
段节...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got
其中在程序头部分,只有type
为LOAD
类型的段需要被映射。
如图所示,elf
文件被划分成了三个部分,一部分是可读可执行的段被映射到了VMA 0
,另一部分是可读可写的段映射到了VMA 1
问题
0x01
LOAD
类型的segment
只有两个,但是在内存中的会出现三个segment
,为什么?
经过一番查阅后,我发现程序头的最后一项的地址也是
0x0000000000600e10
,大小是0x00000000000001f0
,权限是R
,刚好是0x0000000000600e10+0x00000000000001f0=0x601000
GNU_RELRO
是有关于RELRO
保护的RELRO:-z norelro / -z lazy / -z now
关闭 / 部分开启 / 完全开启
0x02
第二个LOAD
是从文件偏移0xe10处开始映射,然后映射到内存的0x600e10的位置,那么0x600000到0x600e10中间为什么有数据呢?
在进行内存映射的时候,映射是以页为单位进行映射的,考虑到对齐的原因,要把文件偏移0xe10所在的一页拷贝到0x600e10所在的那一页
静态链接、动态链接
静态链接是指在代码编译的时候,代码中用到so
库中的函数,把库函数的在so
中的代码抽取出来直接放入到我们编译后的文件中,在运行的时候就不用对so
文件调用啦
动态链接是指不用再编译链接的时候把库函数的代码装到程序里,当我们运行程序的时候,需要调用库里的某一个函数的时候,通过“延迟绑定”的方法来找到函数在so库里的真是地址,然后程序调到so库里进行执行。
分析elf执行过程
我们在命令行通过strace
执行程序,可以看到linux是通过调用execve
来执行程序的
graph TD
execve --> do_execve --> do_execveat_common-->__do_execve_file-->exec_binprm-->search_binary_handler-->load_elf_binary
Linux可执行文件类型的注册机制
linux支持不同格式的可执行程序,比如elf、ms-dos等等。原因是linux
内核对所支持的每种可执行程序类型都有一个struct linux_binfmt
的数据结构
/*~/MyFile/mac_file/linux-5.7/include/linux/binfmts.h:102*/
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
} __randomize_layout;
函数 | 描述 |
---|---|
load_binary | 通过读取存放在可执行文件中的信息为当前进程创建一个新的执行环境 |
load_shlib | 用于动态的把一个共享库文件捆绑到一个已经在运行的进程上 |
core_dump | 在名为core的文件中,存放当前进程的执行上下文 |
当我们执行一个可执行程序的时候,内核会对所有的linux_binfmt
进行遍历,调用load_binary
尝试进行加载,知道加载成功为止
elf对应的linux_binfmt
结构体如下所示
/*~/MyFile/mac_file/linux-5.7/fs/binfmt_elf.c:93*/
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
在ELF文件格式中,处理函数是load_elf_binary函数
检查文件头部
/* First of all, some simple consistency checks 头部检查 */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
goto out;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
goto out;
if (!elf_check_arch(elf_ex))
goto out;
if (elf_check_fdpic(elf_ex))
goto out;
if (!bprm->file->f_op->mmap)
goto out;
加载文件中的program headers
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
在load_elf_phdrs
函数中调用了elf_read
来对文件进行读取
寻找和处理解释器(段INTERP)
紧接着是一个for循环,遍历程序头,找到type为PT_INTERP
的segment
找到之后对p_filesz
字段做check
其次是申请空间,把这个字符串读入到内存里,
然后调用open_exec
来打开这个文件,调用elf_read
来读取header
信息
设置栈的执行权限
遍历所有的段,找到STACK,查看flags
并设置executable_stack
PT_LOPROC ... PT_HIPROC 范围的类型保留给处理器专用语义,调用架构相关函数处理(x86和ARM中均为空函数)
对解释器进行检查
检查文件格式、结构等
内存映射前的初始化
对处理器相关的进行检查操作,并且清除掉了父进程的所有相关代码,调用setup_new_exec
来创建一个新的线程,来进行后续的操作,然后对一些变量进行初始化
进行内存映射
首先遍历所有segment
,找出所有类型为LOAD
的segment
。
然后对地址和页面的信息进行检查
进而通过调用elf_map
来建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射
elf_map 函数内部
可以看到内部是调用了vm_mmap
来进行内存映射,第六个参数指明了文件的那个部分被映射
使用set_brk调整bss段的大小
必须在映射加载器之前为bss段申请空间,防止加载器占用bss段的空间
映射加载器
如果需要把加载器映射到内存中的话,就调用了load_elf_interp
来进行映射,最后把执行入口地址赋值成解释器的地址
如果不需要加载器的映射,那么会直接把执行入口地址赋值成elf的入口地址
加载器需不需要映射取决于该elf是不是静态链接文件
load_elf_interp
函数内部映射方式和映射elf文件到内存中是一样的,首先做一些一致性的检查,然后遍历解释器的程序头,把LOAD
类型的segment
用elf_map
来进行映射到内存中
做执行前的准备
调用create_elf_tables
函数填写目标文件的参数环境变量等必要信息,然后填充新进程内存描述符(mm_struct
)中的堆栈
调用start_thread开始执行elf文件
该函数会把rip
和rsp
改成新的地址,然后CPU就会进入到新的程序入口地址进行执行。