本文分阶段介绍从shell 执行一条命令运行应用程序到应用程序main() 函数被调用过程中发生了什么。
0 起因
用ftrace 抓了一段程序的页面故障(page fault)的异常,其目的是为了查看一个简单程序哪里会产生页面故障(使用strace 也大致能查看到,但无法精确定位到那个语句产生了)。这个脚本大致如下:
cd /sys/kernel/debug/tracing echo 1 > events /exceptions/enable # 使能监控异常 echo 1 > events /syscalls/enable # 方便起见,同时监控系统调用 echo nop > current_tracer # 不记录函数调用 echo 8 > tracing_cpumask # 监控 isolated CPU2 echo > trace # 记录前先清空文件 echo 1 > tracing_on # 开始记录 sudo taskset -c 2 /path/to/a .out # run on CPU2 cat trace |
测试程序源码如下:
1 2 3 4 5 6 | #include <unistd.h> int main() { write(0, "hello\n" , 6); return 0; } |
trace 输出的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | # tracer: nop # # entries-in-buffer/entries-written: 34/34 #P:8 # # _-----=> irqs-off # / _----=> need-resched # | / _---=> hardirq/softirq # || / _--=> preempt-depth # ||| / delay a.out-12405 [003] ...1 89386.976089: sys_sched_setaffinity -> 0x0 a.out-12405 [003] ...1 89386.976091: sys_execve(filename: 7fffffffe7ef, argv: 7fffffffe5a0, envp: 7fffffffe5b0) a.out-12405 [003] d... 89386.976143: page_fault_kernel: address=0x555555558010 ip=__clear_user error_code=0x2 a.out-12405 [003] d... 89386.976149: page_fault_kernel: address=0x7ffff7ffdff8 ip=__clear_user error_code=0x2 a.out-12405 [003] ...1 89386.976154: sys_execve -> 0x0 a.out-12405 [003] d... 89386.991695: page_fault_user: address=0x7ffff7fd0100 ip=0x7ffff7fd0100 error_code=0x14 a.out-12405 [003] d... 89386.991697: page_fault_user: address=0x7ffff7ffc5e0 ip=0x7ffff7fd0e18 error_code=0x6 a.out-12405 [003] d... 89386.991698: page_fault_user: address=0x7ffff7fcfb00 ip=0x7ffff7fd1128 error_code=0x4 a.out-12405 [003] d... 89386.991699: page_fault_user: address=0x7ffff7feb700 ip=0x7ffff7feb700 error_code=0x14 a.out-12405 [003] d... 89386.991701: page_fault_user: address=0x7ffff7ffe138 ip=0x7ffff7feb737 error_code=0x6 a.out-12405 [003] d... 89386.991702: page_fault_user: address=0x7ffff7ff38b4 ip=0x7ffff7feb81a error_code=0x4 a.out-12405 [003] ...1 89386.991705: sys_brk(brk: 0) a.out-12405 [003] ...1 89386.991705: sys_brk -> 0x555555559000 a.out-12405 [003] ...1 89386.991707: sys_arch_prctl(option: 3001, arg2: 7fffffffe4c0) a.out-12405 [003] ...1 89386.991707: sys_arch_prctl -> 0xffffffffffffffea a.out-12405 [003] d... 89386.991707: page_fault_user: address=0x7ffff7ff1e20 ip=0x7ffff7ff1e20 error_code=0x14 a.out-12405 [003] ...1 89386.991711: sys_access(filename: 7ffff7ff79e0, mode: 4) a.out-12405 [003] ...1 89386.991712: sys_access -> 0xfffffffffffffffe a.out-12405 [003] d... 89386.991712: page_fault_user: address=0x7fffffffdd80 ip=0x7ffff7fde54f error_code=0x6 ...... 省略后续trace 记录 |
注意,address 是发生页面故障的内存地址,ip 是发生页面故障的指令地址,error_code 是发生页面故障的类型(究竟是内存不存在,还是权限,以及是读还是写等)ip 地址是运行时的,所以需要使用gdb 查看对应地址的指令。
那么从shell 执行该命令开始到main 函数运行,主要包括了1)shell 过程;2)动态连接器过程;3)用户程序过程。
shell 负责fork 出新的线程,即包裹有动态连接器代码的用户程序;
动态连接器过程,加载可执行文件依赖的共享对象文件,并进行符号重定位;
用户程序过程,其中包含了调用用户main 的过程。
注意:动态连接器过程和用户程序过程的开头都是_start,有些文章就把两者混淆了。
1 Shell 过程
解析命令行,比如找到命令的路径(ls -> /usr/bin/ls)等。
创建子进程fork(),新的子进程成为当前进程,并通过系统调用sys_execve,并将命令行中程序作为参数。
首先看trace 中前5条记录,即
1 2 3 4 5 | a.out-12405 [003] ...1 89386.976089: sys_sched_setaffinity -> 0x0 a.out-12405 [003] ...1 89386.976091: sys_execve(filename: 7fffffffe7ef, argv: 7fffffffe5a0, envp: 7fffffffe5b0) a.out-12405 [003] d... 89386.976143: page_fault_kernel: address=0x555555558010 ip=__clear_user error_code=0x2 a.out-12405 [003] d... 89386.976149: page_fault_kernel: address=0x7ffff7ffdff8 ip=__clear_user error_code=0x2 a.out-12405 [003] ...1 89386.976154: sys_execve -> 0x0 |
execv 相关函数位于 exec.c 文件中,其中 do_execveat()函数主要执行过程如下(基于5.4 内核)
- do_execveat_common(fd, filename, argv, envp, flags);
- exec.c 文件__do_execve_file() 函数retval = exec_binprm(bprm);
- exec.c 文件exec_binprm()函数ret = search_binary_handler(bprm);
- exec.c 文件search_binary_handler()函数 fmt->load_binary(bprm);
- binfmt_elf.c文件load_elf_binary()函数(解析elf header,分配地址空间,加载程序内容,建立页表,解析动态链接库,重定位,初始化程序状态,跳转至程序入口)
此处内容请看内核相关代码。
2.1 libc-start/_start
首先看trace 记录中第三条,也就是第一条在用户态发生的页面故障
1 | a.out-5309 [003] d... 30851.627208: page_fault_user: address=0x7ffff7fd0100 ip=0x7ffff7fd0100 error_code=0x14 |
其发生的指令地址和地址相同,说明这是一条指令页故障,使用gdb 查看对应地址(0x7ffff7fd0100)执行的指令:
1 2 3 4 5 6 | (gdb) x/5i 0x7ffff7fd0100 0x7ffff7fd0100 <_start>: mov %rsp,%rdi 0x7ffff7fd0103 <_start+3>: callq 0x7ffff7fd0df0 <_dl_start> 0x7ffff7fd0108 <_dl_start_user>: mov %rax,%r12 0x7ffff7fd010b <_dl_start_user+3>: mov 0x2c4e7(%rip),%eax # 0x7ffff7ffc5f8 <_dl_skip_args> 0x7ffff7fd0111 <_dl_start_user+9>: pop %rdx |
这就是execve() 返回用户态调用的动态链接器的执行位置,源码可参考glibc 的sysdeps/x86_64/dl-machine.h 文件
1 2 3 4 5 6 7 8 9 10 11 | #define RTLD_START asm ("\n\ .text\n\ .align 16\n\ .globl _start\n\ .globl _dl_start_user\n\ _start:\n\ movq %rsp, %rdi\n\ call _dl_start\n\ _dl_start_user:\n\ # Save the user entry point address in %r12.\n\ movq %rax, %r12\n\ |
需要将这里动态链接库的_start 和用户程序的_start 区分开来。
2.2 _dl_start
接着看下一处页面故障
1 | a.out-12405 [003] d... 89386.991697: page_fault_user: address=0x7ffff7ffc5e0 ip=0x7ffff7fd0e18 error_code=0x6 |
查看ip 地址执行的什么
1 2 3 4 5 6 | (gdb) x/5i 0x7ffff7fd0e18 0x7ffff7fd0e18 <_dl_start+40>: mov %rax,0x2b7c1(%rip) # 0x7ffff7ffc5e0 <start_time> 0x7ffff7fd0e1f <_dl_start+47>: mov 0x2c042(%rip),%rax # 0x7ffff7ffce68 0x7ffff7fd0e26 <_dl_start+54>: mov %rdx,%r12 0x7ffff7fd0e29 <_dl_start+57>: sub 0x2c1d0(%rip),%r12 # 0x7ffff7ffd000 0x7ffff7fd0e30 <_dl_start+64>: mov %rdx,0x2cbc1(%rip) # 0x7ffff7ffd9f8 <_rtld_global+2456> |
这里的页面故障是因为从动态链接库入口_start 处调用了_dl_start (位于elf/rtld.c) ,而_dl_start 中修改了start_time 对应地址的值引发了页面故障,(对应语句rtld_timer_start (&start_time);)。
下面不再详细追踪剩余trace 中的页面故障函数(及其对应地址的指令与函数,如_dl_sysdep_start 等)。
3.1 用户程序 _start
书接上回,先看看动态链接器的_start 最后跳转执行什么(这是在页面故障和系统调用的trace 中看不到的),我们在_start 指令所在地址打断点(地址为0x7ffff7fd0100):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | (gdb) x/100i 0x7ffff7fd0100 => 0x7ffff7fd0100 <_start>: mov %rsp,%rdi 0x7ffff7fd0103 <_start+3>: callq 0x7ffff7fd0df0 <_dl_start> 0x7ffff7fd0108 <_dl_start_user>: mov %rax,%r12 0x7ffff7fd010b <_dl_start_user+3>: mov 0x2c4e7(%rip),%eax # 0x7ffff7ffc5f8 <_dl_skip_args> 0x7ffff7fd0111 <_dl_start_user+9>: pop %rdx 0x7ffff7fd0112 <_dl_start_user+10>: lea (%rsp,%rax,8),%rsp 0x7ffff7fd0116 <_dl_start_user+14>: sub %eax,%edx 0x7ffff7fd0118 <_dl_start_user+16>: push %rdx 0x7ffff7fd0119 <_dl_start_user+17>: mov %rdx,%rsi 0x7ffff7fd011c <_dl_start_user+20>: mov %rsp,%r13 0x7ffff7fd011f <_dl_start_user+23>: and $0xfffffffffffffff0,%rsp 0x7ffff7fd0123 <_dl_start_user+27>: mov 0x2cf36(%rip),%rdi # 0x7ffff7ffd060 <_rtld_global> 0x7ffff7fd012a <_dl_start_user+34>: lea 0x10(%r13,%rdx,8),%rcx 0x7ffff7fd012f <_dl_start_user+39>: lea 0x8(%r13),%rdx 0x7ffff7fd0133 <_dl_start_user+43>: xor %ebp,%ebp 0x7ffff7fd0135 <_dl_start_user+45>: callq 0x7ffff7fe0c20 <_dl_init> 0x7ffff7fd013a <_dl_start_user+50>: lea 0x10c1f(%rip),%rdx # 0x7ffff7fe0d60 <_dl_fini> 0x7ffff7fd0141 <_dl_start_user+57>: mov %r13,%rsp 0x7ffff7fd0144 <_dl_start_user+60>: jmpq *%r12 ...... |
这段代码最后跳转到%r12 寄存器指向的位置(其实也就是用户程序对应的_start),看看是什么
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | (gdb) info registers ...... r12 0x555555555060 93824992235616 ...... (gdb) x/20i 0x555555555060 0x555555555060 <_start>: endbr64 0x555555555064 <_start+4>: xor %ebp,%ebp 0x555555555066 <_start+6>: mov %rdx,%r9 0x555555555069 <_start+9>: pop %rsi 0x55555555506a <_start+10>: mov %rsp,%rdx 0x55555555506d <_start+13>: and $0xfffffffffffffff0,%rsp 0x555555555071 <_start+17>: push %rax 0x555555555072 <_start+18>: push %rsp 0x555555555073 <_start+19>: lea 0x166(%rip),%r8 # 0x5555555551e0 <__libc_csu_fini> 0x55555555507a <_start+26>: lea 0xef(%rip),%rcx # 0x555555555170 <__libc_csu_init> 0x555555555081 <_start+33>: lea 0xc1(%rip),%rdi # 0x555555555149 0x555555555088 <_start+40>: callq *0x2f52(%rip) # 0x555555557fe0 0x55555555508e <_start+46>: hlt |
这里的_start 是用户程序的,从执行代码内容可以看出和动态链接库_start 不同,用户程序的_start 是GCC 编译器在生成可执行文件时添加进去的,位置在代码段.text 的开头,最先执行,可以使用objdump 查看其汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #objdump -d a.out 省略其他段 Disassembly of section .text: 0000000000001060 <_start>: 1060: f3 0f 1e fa endbr64 1064: 31 ed xor %ebp,%ebp 1066: 49 89 d1 mov %rdx,%r9 1069: 5e pop %rsi 106a: 48 89 e2 mov %rsp,%rdx 106d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 1071: 50 push %rax 1072: 54 push %rsp 1073: 4c 8d 05 66 01 00 00 lea 0x166(%rip),%r8 # 11e0 <__libc_csu_fini> 107a: 48 8d 0d ef 00 00 00 lea 0xef(%rip),%rcx # 1170 <__libc_csu_init> 1081: 48 8d 3d c1 00 00 00 lea 0xc1(%rip),%rdi # 1149 <main> 1088: ff 15 52 2f 00 00 callq *0x2f52(%rip) # 3fe0 <__libc_start_main@GLIBC_2.2.5> 108e: f4 hlt 108f: 90 nop 省略其他段 |
_start 的一个重要任务就是调用__libc_start_main,
__lib_start_main 也是定义在C 库(glibc),在我使用的glibc 2.31 版本中,其函数接受7 个参数的输入
1 2 3 4 5 6 7 8 | define LIBC_START_MAIN __libc_start_main STATIC int LIBC_START_MAIN ( int (*main) (int, char **, char ** MAIN_AUXVEC_DECL), int argc, char **argv, __typeof (main) init, void (*fini) (void), void (*rtld_fini) (void), void *stack_end) { |
上面用户程序的_start 函数的主要功能就是初始化调用__libc_start_main 函数的参数栈,在X86-64 架构中,前6个参数通过寄存器来传递,我们看看都是啥
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | (gdb) disassemble _start Dump of assembler code for function _start: 0x0000555555555060 <+0>: endbr64 0x0000555555555064 <+4>: xor %ebp,%ebp 0x0000555555555066 <+6>: mov %rdx,%r9 0x0000555555555069 <+9>: pop %rsi 0x000055555555506a <+10>: mov %rsp,%rdx 0x000055555555506d <+13>: and $0xfffffffffffffff0,%rsp 0x0000555555555071 <+17>: push %rax 0x0000555555555072 <+18>: push %rsp 0x0000555555555073 <+19>: lea 0x166(%rip),%r8 # 0x5555555551e0 <__libc_csu_fini> 0x000055555555507a <+26>: lea 0xef(%rip),%rcx # 0x555555555170 <__libc_csu_init> 0x0000555555555081 <+33>: lea 0xc1(%rip),%rdi # 0x555555555149 <main> => 0x0000555555555088 <+40>: callq *0x2f52(%rip) # 0x555555557fe0 0x000055555555508e <+46>: hlt End of assembler dump. (gdb) inf reg rdi 0x555555555149 93824992235849 # <main> 函数 rsi 0x1 1 # argc rdx 0x7fffffffe598 140737488348568 # **argv rcx 0x555555555170 93824992235888 # <__libc_csu_init> r8 0x5555555551e0 93824992236000 # <__libc_csu_finit> r9 0x7ffff7fe0d60 140737354009952 # <_dl_fini> // 省略其他寄存器的值 |
从寄存器保存的数值可以看出传递的参数信息。其中rdx 寄存器指向的是argv,保存了命令行参数
1 2 3 4 5 6 7 8 | (gdb) x/gx 0x7fffffffe598 0x7fffffffe598: 0x00007fffffffe7eb (gdb) x/20c 0x00007fffffffe7eb 0x7fffffffe7eb: 47 '/' 104 'h' 111 'o' 109 'm' 101 'e' 47 '/' 102 'f' 111 'o' 0x7fffffffe7f3: 111 'o' 108 'l' 47 '/' 116 't' 109 'm' 112 'p' 47 '/' 97 'a' 0x7fffffffe7fb: 46 '.' 111 'o' 117 'u' 116 't' (gdb) x/s *(char **) (0x7fffffffe598) 0x7fffffffe7eb: "/home/fool/tmp/a.out" |
__libc_start_main 主要流程包括:
- 调用 __libc_csu_fini 和 __libc_csu_init 等初始化
- 调用应用程序main
- 调用exit 退出程序。
至此结束。
无论是动态链接库的_start 还是__libc_start_main 都是非常复杂的过程,没办法一句话,一篇文章讲清楚,实属遗憾。
参考:
https://www.gnu.org/software/hurd/glibc/startup.html
https://stackoverflow.com/questions/62709030/what-is-libc-start-main-and-start
Linux x86 Program Start Up (dbp-consulting.com)
https://tldp.org/LDP/LG/issue84/hawk.html
https://www.cnblogs.com/jiqingwu/p/linux_binary_load_and_run.html