# ELF 文件分类

Linux中,ELF文件全称为:Executable and Linkable Format,主要有三种形式,分别是:

  • 可执行文件
  • 动态库文件(共享文件 .so)
  • 目标文件(可重定位文件 .o)

写个脚本测试一下:

准备两个 C 程序:a.c 和 b.c,内容如下:

// a.c
#include <stdio.h> void hello(void); int main(void) {
hello();
return 0;
}
// b.c
#include <stdio.h> void hello(void) {
printf("hello a, b!\n");
}

接下来将b.c编译成动态链接库:

gcc -shared -o libb.so b.c -fPIC

a.c编译成可执行文件:

gcc a.c ./libb.so

得到 4 个文件:

a.c    a.out    b.c    libb.so

执行 ./a.out,可以输出:hello a, b!

为了测试,可以执行gcc -c a.c -o a.o,多编一个a.o,虽然用不到,权当对照。

此时可以用file命令查看文件信息:

file a.out
# 输出:a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked...
file a.o
# 输出:a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
file libb.so
# 输出:libb.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked...

可以看到,以上三个文件分属于不同的 ELF 种类。

# ELF 文件格式

ELF 文件的结构分为两个重要的部分:ELF头部分和ELF节表部分,其中节表部分被分成两种类型:节和程序段。ELF文件通过一个节表和程序头表指向这两个部分。具体结构如图:

即,同样的数据区域,既可以被视为节(sections),也可以被视为程序段(segments),其在不同的ELF文件中有所区分:

  • 可执行文件:加载器则将把elf文件看作是程序头表描述的段的集合,一个段可能包含多个节,节头部表可选;
  • 可重定位文件(.o):一般编译器和链接器将把elf文件看作是节头表描述的节的集合,程序头表可选;
  • 动态库文件(.so):一般两者都有,因为链接器在链接的时候需要节头部表来查看目标文件各个 section 的信息然后对各个目标文件进行链接;而加载器在加载可执行程序的时候需要程序头表 ,它需要根据这个表把相应的段加载到进程自己的的虚拟内存(虚拟地址空间)中。

可以通过readelf工具查看 ELF 文件的内容:

# 查看 ELF 文件头
readelf -h [elf_file]
# 查看 ELF 文件 sections
readelf -S [elf_file]
# 查看 ELF 文件 segments
readelf -l [elf_file]

这里仅做抛砖引玉,具体的 ELF 文件各个字段的解释,以及动态链接 ELF 文件如何寻址填充,生成可执行文件,可以移步这篇文章:https://cloud.tencent.com/developer/article/2058294

# ELF 文件的执行流程

当执行./a.out命令时,首先开始工作的,是Linux集成的Bash程序。Bash 进程会做两件事情:

  • 调用 fork() 系统调用,创建出一个新的进程,用来执行a.out任务;
  • 调用 execve() 系统调用,执行这个 ELF 可执行文件a.out

execve() 系统调用在内核源码fs/exec.c文件中被定义(kernel 版本 4.19):

SYSCALL_DEFINE3(execve,
const char __user *, filename, // ELF 文件名
const char __user *const __user *, argv, // ELF 文件执行参数
const char __user *const __user *, envp) // 环境参数
{
return do_execve(getname(filename), argv, envp);
}

execve() 系统调用接收三个参数:文件名、执行参数和环境参数,其调用链为:

// execve 系统调用:fs/exec.c
SYSCALL_DEFINE3(execve, ...)
|-> do_execve()
|-> do_execveat_common()
|-> __do_execve_file() // (A)
|-> prepare_binprm(bprm)
|-> kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos)
|-> exec_binprm(bprm) // (B)
|-> search_binary_handler(bprm)
|-> security_bprm_check(bprm) // (C) lsm hook (include/linux/lsm_hooks.h)
|-> list_for_each_entry(fmt, &formats, lh) {
fmt->load_binary(bprm) // (D) load_elf_binary
}

值得注意的:

在内核中,一个 ELF 可执行文件会被解析为一个brpm结构,结构体为linux_binprm,定义在include/linux/binfmts.h中,核心字段如下:

struct linux_binprm {
char buf[BINPRM_BUF_SIZE]; // 存储 ELF 文件头,大小 128 字节
struct mm_struct *mm;
unsigned long p; // mem top 指针
struct file * file; // ELF 可执行文件指针
int argc, envc; // argv、envp 参数数量
const char * filename; // ELF 可执行文件名
}

在步骤(A)中:留意两件事情

  • 调用prepare_binprm(bprm),后者执行kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos),将 ELF 文件的前BINPRM_BUF_SIZE大小(128字节)的内容填充到bprm->buf中;
  • 对传入的参数进行处理,即,为运行参数 argv和环境参数envp分配内存页面(函数copy_strings());

随后调用(B)。

步骤(B)会调用search_binary_handler(bprm)选择合适的可执行文件处理器后,最终会调用load_elf_binary()函数真正加载这个 ELF 文件(步骤(D))。

Linux 支持其他不同格式的可执行程序, elf就是其中常见的一种可执行文件格式。在这种方式下, Linux 能运行其他操作系统所编译的程序, 如 MS-DOS 程序, 活 BSD Unix 的 COFF 可执行格式。

这里选择的是 ELF 二进制文件处理器。

引自:https://zhuanlan.zhihu.com/p/287863861

不过在步骤(D)执行之前,会进行一个security_bprm_check(bprm)过程(步骤(C))。该过程是 LSM 框架预设的 hook 点,用于在真正加载 ELF 文件前执行自定义的 check 回调,来实现安全控制。

步骤(D)中调用的其实是fmt->load_binary(bprm),此乃linux_binfmt在初始化时,其成员函数指针内核预设的值,具体如下:

// 在文件 fs/binfmts.h 中
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_binfmt 初始化注册
static int __init init_elf_binfmt(void)
{
register_binfmt(&elf_format);
return 0;
}
// initcall
core_initcall(init_elf_binfmt);
# ELF文件的加载

在函数load_elf_binary()中,完成ELF文件的加载过程。

1)获取 ELF 头进行检查
/* Get the exec-header */
loc->elf_ex = *((struct elfhdr *)bprm->buf); /* First of all, some simple consistency checks */
if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out; if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
goto out;
if (!elf_check_arch(&loc->elf_ex))
goto out;
if (elf_check_fdpic(&loc->elf_ex))
goto out;

这一步骤,首先从 bprm->buf中读取 ELF 头(prepare_binprm(bprm)),并判断文件前SELFMAG个字节是否为ELFMAG

注:include/uapi/linux/elf.h

#define ELFMAG 		"\177ELF"
#define SEELFMAG 4

随后判断其文件类型是否为 “ET_EXEC” 和 “ET_DYN”,即,内核仅允许可执行ELF动态链接ELF的加载。

2)加载程序头表

这一过程是通过load_elf_phdrs()函数完成的。该函数主要作用是,调用kernel_read()读取 ELF 文件的 程序头表:

// in load_elf_binary
elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
if (!elf_phdata)
goto out; // in load_elf_phdrs 保留关键逻辑
static struct elf_phdr *load_elf_phdrs(struct elfhdr *elf_ex,
struct file *elf_file)
{
/* Sanity check the number of program headers... */
if (elf_ex->e_phnum < 1 ||
elf_ex->e_phnum > 65536U / sizeof(struct elf_phdr))
goto out; /* ...and their total size. */
size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
elf_phdata = kmalloc(size, GFP_KERNEL); /* Read in the program headers */
retval = kernel_read(elf_file, elf_phdata, size, &pos); return elf_phdata;
}

在这个函数中,有几个细节值得注意:

  • ELF 文件至少有一个程序段,才能被成功加载;
  • 所有段的大小不超过 65536U,即 64k
  • 最终 ELF 程序头表被保存在elf_phdata中。
3)处理动态链接的 ELF

如果当前加载的 ELF 文件是需要动态链接的,那么,程序最终会交给解释器执行,由解释器填充为链接库预留的程序段后,再真正交由程序执行。

因此,在这一步中,如果对 ELF 中定义的解释器段进行提取和解析,并加载到内存中。

需要动态链接的程序需要经由解释器来执行。例如上述的 a.out文件,其中动态链接了一个名为libb.so的共享库——具体而言,其代码中调用了libb.sohello()函数。

这部分的核心代码逻辑为:

// in load_elf_binary
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
if (elf_ppnt->p_type == PT_INTERP) {
// (A)
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
pos = elf_ppnt->p_offset;
retval = kernel_read(bprm->file, elf_interpreter,
elf_ppnt->p_filesz, &pos);
// (B)
interpreter = open_exec(elf_interpreter);
// (C)
pos = 0;
retval = kernel_read(interpreter, &loc->interp_elf_ex,
sizeof(loc->interp_elf_ex), &pos);
// ...
}
}

这一步骤,遍历 ELF 文件的所有程序段,寻找PT_INTERP程序段,如果找到了,则主要做三件事:

(A)

elf_interpreter代表 解释器文件名,它是硬编码到 ELF 文件PT_INTERP程序段中的。举例来看:

执行readelf -l a.out查看上述的a.out ELF 可执行文件,结果如下:

可以看到,其中INTERP程序段中,从0x000238开始,大小为0x00001C的内容填充了一段名为/lib64/ld-linux-x86-64.so.2的字符串,代表了 Linux 系统的解释器。

/lib64/ld-linux-x86-64.so.2也是一个.so文件,但它是静态链接的,其本身不依赖任何其他的共享对象也不能使用全局和静态变量。这是合理的,试想,如果解释器都是动态链接的话,那么由谁来完成它的动态链接呢?

这里的解释器在32为系统上的路径名为:/lib/ld-linux.so.2

因此,步骤(A)仅是把PT_INTERP程序段的内容读取出来,存放到elf_interpreter变量中。

(B)找到了elf_interpreter后,尝试打开它。

(C)读取解释器(/lib64/ld-linux-x86-64.so.2)的 ELF 文件头。

4)处理可执行栈

该步骤的逻辑:

// in load_elf_binary
for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++)
switch (elf_ppnt->p_type) {
case PT_GNU_STACK:
// ...
break;
// ...
}
}

gcc编译选项中,开始/关闭可执行栈的选项是 -z execstack/noexecstack,默认情况下gcc是关闭可执行栈的。在加载 ELF 文件时,会遍历所有的segment,找到PT_GNU_STACK,即栈段,检查flags。

具体可参考:https://mudongliang.github.io/2015/10/23/elf.html

5)解释器的检查工作

这一步骤主要检查刚刚打开的解释器的合法性,主要包括以下几个方面:

  • 是否是一个 ELF 解释器?
  • 架构信息是否合法?
  • 加载解释器程序头表
  • 执行前的最后校验(arch_check_elf(),此函数节点是执行前的最后确认,在此之前,exec系统调用仍然可以发挥一个 error code)
6)重建用户空间映射

这一步骤中,ELF 文件即将蜕变为一个真正的进程,首先为其重建用户空间:

// in load_elf_binary
/* Flush all traces of the currently running executable */
retval = flush_old_exec(bprm);
// ...
setup_new_exec(bprm);
install_exec_creds(bprm);
/* Do this so that we can load the interpreter, if need be. We will
change some of these later */
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP), executable_stack);
current->mm->start_stack = bprm->p;

在这一过程中,首先调用flush_old_exec释放当前进程的所有用户空间页面映射;紧接着,进行必要的setupinstall过程;最后,调用setup_arg_pages(),将前文(copy_strings())为argvenvp分配的页面重新映射回用户空间。

7)载入 LOAD 程序段

此为关键步骤,仍然是遍历所有的程序段,寻找PT_LOAD段,并将其载入到某个地址上(实际上是建立映射关系)。

// in load_elf_binary
for(i = 0, elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type != PT_LOAD)
continue;
if (elf_ppnt->p_flags & PF_R) elf_prot |= PROT_READ;
if (elf_ppnt->p_flags & PF_W) elf_prot |= PROT_WRITE;
if (elf_ppnt->p_flags & PF_X) elf_prot |= PROT_EXEC;
// ...
vaddr = elf_ppnt->p_vaddr;
// ...
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
}

这一过程中,首先定位PT_LOAD程序段,然后,保存 R/W/X 权限信息,最后进行地址映射。

8)定位程序的入口

进行到此步骤时,当前 ELF 可执行程序 和解释器均已加载完成,并且各类准备工作也已经执行完毕,接下来要做的,就是找到程序的入口。

// in load_elf_binary
if (elf_interpreter) {
unsigned long interp_map_addr = 0;
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias, interp_elf_phdata);
} else {
elf_entry = loc->elf_ex.e_entry;
}

很简单,若当前 ELF 依赖解释器,则入口地址设置为解释器的入口地址;否则设置为 ELF 本身的入口地址。

9)准备执行
  • 进程栈的设置(参数、环境变量...)
  • current->mm 的设置
  • ...
  • 调用start_thread(regs, elf_entry, bprm->p)开始执行
# ELF 文件的执行

load_elf_binary()函数最终调用start_thread(regs, elf_entry, bprm->p)启动执行流程。

对于x86架构而言,start_thread()定义在arch/x86/k ernel/process_64.c文件中:

void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
start_thread_common(regs, new_ip, new_sp,
__USER_CS, __USER_DS, 0);
}
EXPORT_SYMBOL_GPL(start_thread);

其中,new_ip 就是 ELF 文件的入口地址:elf_entry,后续指令将跳转此处开始执行。

【内核】ELF 文件执行流程的更多相关文章

  1. Yii2 源码分析 入口文件执行流程

    Yii2 源码分析  入口文件执行流程 1. 入口文件:web/index.php,第12行.(new yii\web\Application($config)->run()) 入口文件主要做4 ...

  2. 一篇看懂JVM底层详解,利用class反编译文件了解文件执行流程

    JVM之内存结构详解 JVM内存结构 java虚拟机在执行程序的过程中会将内存划分为不同的区域,具体如图1-1所示. 五个区域 JVM分为五个区域:堆.虚拟机栈.本地方法栈.方法区(元空间).程序计数 ...

  3. PHP7内核剖析之执行流程

    以fpm为例: 1.fpm启动时,会先执行 module_startup, 并随着fpm进程常驻 2.当一个请求到达之后,会执行 request_startup, 进行一些请求初始化工作,然后执行代码 ...

  4. 关于ELF文件和BIN文件

    ELF文件执行过程 ELF文件有操作系统的加载器loader执行,比如linux,windows,对于3803处理器是grmon的load命令. 加载器会读取ELF文件program header,比 ...

  5. PHP解释器引擎执行流程 - [ PHP内核学习 ]

    catalogue . SAPI接口 . PHP CLI模式解释执行脚本流程 . PHP Zend Complile/Execute函数接口化(Hook Call架构基础) 1. SAPI接口 PHP ...

  6. debian内核代码执行流程(三)

    接续<debian内核代码执行流程(二)>未完成部分 下面这行输出信息是启动udevd进程产生的输出信息: [ ]: starting version 175是udevd的版本号. 根据& ...

  7. debian内核代码执行流程(一)

    本文根据debian开机信息来查看内核源代码. 系统使用<debian下配置dynamic printk以及重新编译内核>中内核源码来查看执行流程. 使用dmesg命令,得到下面的开机信息 ...

  8. phpcms-v9 前台模板文件中{pc}标签的执行流程

    前台pc标签的使用:{pc:content 参数名="参数值" 参数名="参数值" 参数名="参数值"} 如: {pc:content ac ...

  9. Spring 文件上传MultipartFile 执行流程分析

    在了解Spring 文件上传执行流程之前,我们必须知道两点: 1.Spring 文件上传是基于common-fileUpload 组件的,所以,文件上传必须引入此包 2.Spring 文件上传需要在X ...

  10. debian内核代码执行流程(二)

    继续上一篇文章<debian内核代码执行流程(一)>未完成部分. acpi_bus_init调用acpi_initialize_objects,经过一系列复杂调用后输出下面信息: [ IN ...

随机推荐

  1. 《Python魔法大冒险》004 第一个魔法程序

    在图书馆的一个安静的角落,魔法师和小鱼坐在一张巨大的桌子前.桌子上摆放着那台神秘的笔记本电脑. 魔法师: 小鱼,你已经学会了如何安装魔法解释器和代码编辑器.是时候开始编写你的第一个Python魔法程序 ...

  2. nodejs实现的一个简单粗暴的洗牌算法

    据说名字长别人不一定看得到 之前用python,自带shuffle用的还是超爽的: 去年6月份自己动手用nodejs写一个21点扑克游戏的后台时,就需要一个洗牌算法,于是简单粗暴的实现了一个. 贴出来 ...

  3. 编译器优化记录(Mem2Reg+SSA Destruction)

    编译器优化记录(2) Mem2Reg+SSA Destruction 写的时候忽然想起来,这部分的内容恰好是在我十八岁生日的前一天完成的.算是自己给自己的一份成长的纪念吧. 0. 哪些东西可以Mem2 ...

  4. package.json指南

    一.属性 name 定义项目的名称,不能以"."和"_"开头,不能包含大写字母 version 定义项目的版本号,格式为:大版本号.次版本号.修订号 descr ...

  5. RCU的简单认识

    RCU RUC是什么? RCU(Read-Copy-Update)是一种用于并发编程的技术,旨在提供高效且无锁(lock-free)的读操作,同时保证数据一致性和并发性. 也就是说他并不需要锁的机制来 ...

  6. 2023年最新版Apollo保姆级使用手册(超级详尽版本)

    目录 Apollo操作说明 前言 Apollo环境部署 一.环境构建 二.官方地址 三.数据库脚本使用 四.配置Apollo文件 五.启动Apollo 六.访问Apollo Apollo产品使用 一. ...

  7. 9.2 运用API实现线程同步

    Windows 线程同步是指多个线程一同访问共享资源时,为了避免资源的并发访问导致数据的不一致或程序崩溃等问题,需要对线程的访问进行协同和控制,以保证程序的正确性和稳定性.Windows提供了多种线程 ...

  8. redis 源码分析:Jedis 哨兵模式连接原理

    1. 可以从单元测试开始入手 查看类JedisSentinelPool private static final String MASTER_NAME = "mymaster"; ...

  9. 分布式事务:XA和Seata的XA模式

    上一篇内容<从2PC和容错共识算法讨论zookeeper中的Create请求>介绍了保证分布式事务提交的两阶段提交协议,而XA是针对两阶段提交提出的接口实现标准,本文则对XA进行介绍. 1 ...

  10. 0 基础晋级 Serverless 高手课 — 初识 Serverless(下)

    冷启动 1. 流量预测 2. 提前启动 3. 实例复用 每个厂商规范不一致:,兼容,适配层:adapter: fs+oss 云厂商对比 产品维度 功能架构角度 个人博客官网 小程序           ...