0%

从.c文件到程序的执行,中间发生了什么?

引子

本篇文章是作者阅读《庖丁解牛Linux内核分析》一书中的第七章————“可执行程序的工作原理”的读书笔记,这篇文章要解决的两个问题是:

  • 一个.c源代码文件是如何编译成可执行文件的?
  • 当我们在shell中执行一个可执行程序时,发生了什么?

为了解决这两个问题,本文首先介绍了ELF文件格式,其次完整地概括了程序的编译过程,最后总结了可执行程序在Linux中的装载过程。

ELF文件格式

是什么

  • 目标文件是什么?目标是指目标平台,一个目标文件已经适应了某一种特定体系结构的CPU指令。
  • ELF是一种目标文件的格式
  • ELF文件有三种类型:
    • 可重定位目标文件:每一个源代码文件就会生成一个可重定位文件,就是我们经常看到的.o文件,目的是为了和其他的目标文件一起创建一个可执行文件/动态链接库文件。所有的.o文件最后会链接成为一个文件,就是Linux内核。
      • 静态链接库文件就是可重定位文件的打包,也是一种可重定位文件,一般以.a作为文件名后缀
    • 可执行文件:由多个可重定位文件结合生成,完成了所有重定位工作和符号解析的文件。(动态链接库符号是在运行时解析的)
    • 动态链接库文件:也就是共享目标文件,是一种已经经过链接处理可以直接加载运行的库文件。可以理解为”没有main的可执行文件”。只有一堆函数可以供其他程序调用。Linux中动态链接库文件的后缀为.so。(shared object)

作用

  • 对于可重定位文件来说,作用是为了给编译器和链接器提供信息,以便于编译和链接
  • 对于可执行文件来说,作用是为了给加载器提供信息,以便于加载执行

  • ELF文件格式:分为主体和描述信息,大致描述如下:

程序的编译过程

hello.c文件进行举例说明

预处理

1
gcc -E hello.c -o hello.i
  1. 删除所有的注释
  2. 删除所有的#define,展开宏定义
  3. 处理条件预编译指令
  4. 处理#include预编译指令,将被包含的文件插入进来,递归进行
  5. 添加行号和文件名标识
  6. 预处理过后的文件可以使用任意的文本编辑器打开查看

编译

1
gcc –S hello.i –o hello.s -m32
  1. 检查语法的规范性
  2. 把代码翻译成汇编语言
  3. 生成的目标文件依然可以用任意文本编辑器打开查看

以.开头的是伪指令

汇编

1
gcc –c hello.s –o hello.o.m32 -m32
  1. .o文件就是ELF格式文件
  2. 生成的目标文件至少有3个节区(.bss/.data/.text)
  3. 查看目标文件需要使用readelf指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ readelf -S helloworld.o
There are 14 section headers, starting at offset 0x310:
Section Headers:
[Nr] Name Type Address Offset
......
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000017 0000000000000000 AX 0 0 1
......
[ 3] .data PROGBITS 0000000000000000 00000057
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000057
0000000000000000 0000000000000000 WA 0 0 1
......
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)

解释一下上面的输出,以.text行为例:

  1. Type列代表该节存储的是代码
  2. Address全0,是因为生成的是可重定位目标文件,还不是可执行文件,所以没有设置对应的虚拟地址,在链接部分完成后会改变为虚拟地址
  3. off为在.o文件中的偏移
  4. key to flags是对标识的说明,A(alloc)代表需要加载到内存中,X标识表示对应的内存部分需要可执行权限

除了以上提到的三个节以外,还有一些常见节:

链接

1
gcc hello.o.m32 -o hello.m32.static -m32 -static
  1. 链接就是把各种代码和数据部分收集起来成为一个单一文件的过程,本质上是各个ELF文件中的节的拼接
  2. 这个单一文件可以被加载/复制到内存中执行
  3. 对应一个最简单的helloworld.c文件,就是把helloworld.o文件和libc库文件进行链接的过程
  4. .o到可执行文件的一个重要变化是,.text的Addr有了值。多出来的节是从外部库中添加过来的,编译器进行了整合,并安排了地址布局。
  5. 还有一个重要的变化是:多了一个段头表
  6. 可执行文件的加载执行,其实是操作系统按照段头的指示,将执行文件按照安排好的布局加载到内存,再跳转到其中的代码段。

程序的链接过程

程序的编译过程中,最复杂的部分是链接过程,程序的链接从过程上分类:

  1. 符号解析
  2. 重定位

从链接的时机分类:

  1. 动态链接
  2. 静态链接

符号解析

符号

什么是符号,符号包含全局变量和全局函数,在我们的例子中,printf就是一个符号。符号分为三种

  1. 本地模块定义,可以被其他模块饮用的全局符号
  2. 其他模块定义,被本地模块引用的外部符号
  3. 本地模块定义,只被本地模块引用的本地符号

符号表

符号表是编译器生成的,具体怎么来的按下不表,我们只需要知道其作用为:

找到未知函数在其他库文件中的代码段的具体位置,以helloworld为例,就是为了找到printf函数在libc库中的具体代码位置

查看符号表的方法:

1
readelf -s xxx.o

链接前后的符号表对比:

重定位

目的:重定位是一个把程序的逻辑地址空间变成实际内存的物理地址空间的过程,这里的物理地址空间也就是进程的虚拟地址空间,为了达成这个目标,需要对程序的指令和数据进行修改。

重定位的过程:

  1. 聚合:将所有的类型相同的节聚合成一个新的聚合节,并赋值新的地址,这个过程完成后,程序中的每个指令和全局变量都有唯一的运行时地址
  2. 修改:修改.text(代码段)和.data(数据段)中对每个符号的引用,使得指向正确的运行时地址。

重定位表:

  1. 重定位表中的每一条记录对应一个需要重定位的符号

  2. 每个包含需要重定位符号的段都有一个重定位表

  3. 查看重定位表:

    1
    readelf -r xxx.o

可重定位表和符号表的区别:

  1. 可重定位表指示了什么符号需要被替换
  2. 符号表指示了需要替换进去的符号的实际地址

动态链接

在链接的时候,记录一系列符号和参数,在程序运行或加载的时候把信息传递给操作系统,由操作系统负责把需要的动态库加载到内存,最终程序在执行的时候去共享内存中执行代码。

优点:

  1. 多个程序共享一段代码

缺点:

  1. 运行时加载,可能会影响程序前期执行性能
  2. 对库依赖比较高,在升级的时候容易出现版本兼容问题

分类:

  1. 装载时动态链接
  2. 执行时动态链接

静态链接

在链接阶段把需要的执行代码直接复制到最终可执行文件中。

优点:

  1. 装载速度快,执行速度快
  2. 对环境依赖低

缺点:

  1. 浪费内存
  2. 链接出来的程序比较大

程序的装载过程

不管是直接执行一个可执行文件./hello还是执行命令ls -l,我们大部分都是通过shell程序来启动一个可执行程序的,本节我们主要探讨一个问题:shell程序是怎么把一个进程启动起来的?

引子

我们执行ls -l./hello有什么区别?其实我们在shell中执行这两个命令时,总本质上来说都是没有区别的,一个是执行了ls程序,一个是执行了hello程序,只不过前者我们传递了参数,后者我们没有传递参数。最终程序都会从main()处开始执行。总的来说,一个程序有三种main函数的形式:

那从输入./hello到执行程序的main函数,这其中shell做了什么事呢?shell在其中做的事就是通过fork()系统调用新建一个进程,并且通过execve()系统调用帮我们启动程序和传递参数。

execve函数原型

1
int execve(const char *filename, char *const argv[],char *const envp[]);

filename:文件名

argv:命令行参数

envp:环境变量

执行环境上下文

那么,新的进程是怎么拿到shell传过来的命令行参数和环境变量参数的呢?

fork()系统调用会生成子进程的PCB和用户态堆栈,这些参数就是放在堆栈中的,如图所示,然后会跳转到main函数执行:

说明:

  1. 返回地址是在用户态堆栈的栈顶,当执行完以上操作后,就会从返回地址指向的指令开始执行
  2. 以上所说的过程是针对静态链接的程序而言的,动态链接的程序在完成用户态堆栈的建立后会做一些特殊处理

动态链接程序的特殊处理

ld:动态链接器

  1. 动态链接版本的可执行文件会比静态链接的多处.interp这个节点以及其他ld需要用到的节
  2. 动态链接版本的可执行文件从内核态返回时,首先执行的是.interp节区指向的动态链接器
  3. ld的责任就是解析依赖关系,并根据依赖关系装载所有需要的动态链接库,然后ld会把CPU的控制权交给可执行程序
  4. 从以上的过程我们可以发现,动态链接的过程主要是动态链接器在起作用而不是内核

查看一个动态链接的可执行程序的依赖关系:

1
2
3
4
5
6
7
8
$ ldd /bin/ls
linux-vdso.so.1 (0x00007fffc5373000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f15aee2a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f15aec38000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f15aeba8000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f15aeba2000)
/lib64/ld-linux-x86-64.so.2 (0x00007f15aee97000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f15aeb7f000)

fork和execve系统调用的内核处理过程

概述

execve系统调用的作用是:用加载的程序把当前正在执行的进程覆盖掉,当系统调用返回的时候也就返回到新的可执行程序的起点。

execve系统调用对应的内核处理函数为sys_execve(),对应的调用关系为:

1
2
3
4
5
6
7
sys_execve() 
-> do_execve() :读取文件头部判断可执行文件的类型
-> do_execve_common()
-> exec_binprm()
-> search_binary_handler() :搜索和匹配合适的可执行文件装载处理过程
-> load_elf_binary() :负责装载ELF文件
-> start_thread():创建新进程的堆栈,其中有pt_regs(内核堆栈)栈底指针,修改了EIP寄存器

对于返回到用户态后的起点位置,静态链接和动态链接是不一样的:

  • 静态链接:elf_entry指向可执行文件的头部,一般是main函数,是新程序执行的起点。
  • 动态链接:elf_entry指向ld(动态链接器)的起点load_elf_interp。

fork和execve的区别和联系

fork

因为正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后的下一条指令。fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点 ret_from_fork 通过内核构造的堆栈环境,它可以正常返回到用户态,所以它稍微特殊一点。

execve

同样, execve也比较特殊。当前的可执行程序在执行,执行到 execve时陷入内核态,在内核里面用execve加载的可执行文件把当前进程的可执行程序给覆盖掉了。当 execve的 系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。

start_thread()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
///src/arch/x86/kernel/process_32.c
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
set_user_gs(regs, 0);
regs->fs = 0;
regs->ds = __USER_DS;
regs->es = __USER_DS;
regs->ss = __USER_DS;
regs->cs = __USER_CS;
regs->ip = new_ip;
regs->sp = new_sp;
regs->flags = X86_EFLAGS_IF;
}

参考文献

庖丁解牛Linux内核分析