浅谈 ELF 格式解析
1. 前言
对 ELF 格式的理解,是成熟程序员的标志之一。
然而对于大多数想学习 ELF 格式的朋友来说,枯燥的书籍、文章根本读不下去,读下去了没有需求或项目实践也很快遗忘,只残留了字符串表、节区表之类的空洞概念。
本文从一个简单的 ELF 格式解析应用项目入手,带读者从实操角度掌握一个成熟程序员应该掌握的大多数有关 ELF 格式的知识。实现一个完整的 linker & loader 对理解 ELF 格式并无过多裨益,反而徒增旁枝末节(如果对完整的 linker & loader 感兴趣的话,可以阅读 TencentOS tiny 中的 elfloader 实现,代码紧凑、易读)。
本文适宜的读者对象:对 ELF 格式已经有所了解,但对于其中各个细节的理解尚做不到游刃有余,且希望能对 ELF 格式做进一步掌握的同学。本文不会啰里啰嗦的从 ELF 格式的细节着手,这方面推荐《程序员的自我修养》一书。
本文的翻译约定:section - "节区",segment - "段"。
2. 引子
2.1 什么是 ELF 格式
ELF 格式的本质就是一种描述可执行/可重定位文件信息的表的组织形式的约定。通过这张表,linker & loader 可以获取在装载、链接一个可执行文件时必要的信息。仅此而已。
2.2 项目需求
这个需求是我的好朋友提出的:希望通过在应用程序中添加特定的字段,这些字段中在编译期给应用程序添加一些类似校验的信息。这个应用程序在运行前需要先经过一个装载程序的校验,校验成功才继续运行此应用程序。
2.3 思路
这个需求包含了两个方面:
- 给应用程序(下文简称 "app")添加特定的字段
- 解析程序(下文简称 "parser")定位到 app 中的该字段,取出其中的内容并校验。
给 app 添加特定的字段又有两种思路:
- 定义一个变量,此变量位于某个特殊的节区(section)。
这个节区要视情况而定:
- 如果是在操作系统环境下开发,需要确保此节区的不会被 ld 自动 discard 掉(不在默认 linker script 的 discard 范围内)
- 如果是非操作系统环境下,一般 linker script 是由 app 开发者维护,此时需开发者自行确保 linker script 中不会 discard 掉此段。
定义一个变量,此变量有一个特殊的变量名。
此方案要注意变量名不要与其他变量产生冲突。
根据 app 中添加特定字段的手法不同,parser 的实现思路也对应两个不同的方向:
- parser 中定位到这个特殊节区之所在,然后从 app 中取出字段并校验。
- parser 中定位到 app 中这个特殊变量名的变量之所在,然后从 app 中取出字段并校验。
2.4 项目说明
本项目非常简单,这里直接贴出全部代码,读者想要验证的可自行把代码拷贝到磁盘上并按如下目录组织:
$ tree
.
|-- section
| |-- 1.sh
| |-- app.c
| |-- include
| | |-- __elf.h
| | |-- compiler.h
| | `-- version.h
| `-- parser.c
`-- symbol
|-- 1.sh
|-- app.c
|-- include
| |-- __elf.h
| |-- compiler.h
| `-- version.h
`-- parser.capp.c、compiler.h、__elf.h、1.sh,以及 parser.c 的部分函数在 方案1 和 方案2 中是通用的,为不影响行文的连贯性,这部分代码直接贴在第 6 章。
3. 方案1:节区方案
3.1 app 侧
对 app 里的校验字段,我们使用 version_descriptor 来定义。 这是 version.h:
// 这是向 app 中添加校验字段的数据结构struct version_descriptor { char str[16]; uint32_t num0; uint32_t num1;};
static const struct version_descriptor ____version \ __used __section(.__version) \ = { \ .str = __stringify(__str), \ .num0 = __num0, \ .num1 = __num1, \ }
除了定义了描述字段的结构体 struct version_descriptor,还提供了 DECLARE_VERSION 宏,用于编译期生成一个位于 .__version_section 节区的 struct version_descriptor 类型变量(变量名不重要)。
DECLARE_VERSION 宏的实现是 gcc 中比较基础的 section 属性应用,这里不赘述。
6.1 节 app.c 中定义了一个如下的 struct version_descriptor 变量:
.str = "version_x".num0 = 0xdead.num1 = 0xbeef3.2 parser 侧
1. 垫话
parser 侧要做的事情就是定位到 .__version_section 在 app 中的位置,然后读出来。
先说明一下 6.2 节 parser.c 中定义的几个函数的作用:
// 这里假定 app 程序的大小不会超过 10k 字节// 内存中定义一片 buffer 来存放 app 程序内容char __buffer[FILE_SIZE_MAX];
// 此函数的功能是,将 path 指定的文件内容,读到 __buffer 缓冲里。int read_file2buffer(char *path);
/* * 一个 fake 的 read 函数,作用是将 app 程序中 offset 偏移处,长 size 的内容, * 读到 buf 指定的地方。 */int fake_read(void *buf, int offset, int size);
/* 一个 fake 的 read 函数,作用是读取 app 程序中 offset 偏移处的内容到 buf 中 * 一直读到 '\0' */int fake_read_till_nil(char *buf, int offset);2. 总体流程
配合 图1 食用:
int main() { int rc; // 最终读出来的那个 version_descriptor 变量 struct version_descriptor __version;
// ELF 文件头 struct elfhdr __elfhdr; // .__version_section 节区的节区头 struct elf_shdr __version_section; // 字符串表的节区头 struct elf_shdr __shstr_tbl_section;
// 32 位架构 char *app_name = "./app_32"; // 64 位架构 char *app_name = "./app_64";
// app 文件读到 __buffer 中 rc = read_file2buffer(app_name); if (rc) exit(rc);
// 从 app 中读出其 ELF 文件头 rc = read_elf_header(&__elfhdr); if (rc) exit(rc);
// 读取字符串表节区头 rc = read_shstring_table(&__elfhdr, &__shstr_tbl_section); if (rc) exit(rc);
// 定位到 .__version 节区头 rc = read_section_by_name(".__version", sizeof(".__version"), &__elfhdr, &__shstr_tbl_section, &__version_section); if (rc) exit(rc);
// 以 .__version 节区在文件中的偏移,以及大小,读出其内容 fake_read(&__version, __version_section.sh_offset, __version_section.sh_size);
// 做后续的校验逻辑即可 printf("dumping the version:\n"); printf("%s\n", __version.str); printf("0x%x\n", __version.num0); printf("0x%x\n", __version.num1);
return 0;}2.1 节就说了,ELF 格式本质上就是一种表组织形式的约定。 根据 图1,我相信下面不贴代码你也能自己写出来上面每个关键函数的实现了。 此乃好图,值得收藏!

图1
3. 关键函数实现
有了 图1,直接贴代码加注释了:
int read_elf_header(struct elfhdr *elfhdr){ // elf 文件头就在 0 偏移处,直接读 sizeof(struct elfhdr) 长度即可。 fake_read(elfhdr, 0, sizeof(*elfhdr));
// 这里校验一下魔术字 if (strncmp(elfhdr->e_ident, ELFMAG, sizeof(ELFMAG) - 1) == 0) { printf("load elf header ok\n"); return 0; } return -1;}
int read_shstring_table(struct elfhdr *elfhdr, struct elf_shdr *shstr_tbl_section){ struct elf_shdr shdr; elf_addr_t shdr_offset; // section header offset Elf_Half shnum, shentsize;
Elf_Half shstrndx;
// 这是节区头表在文件中的偏移 shdr_offset = elfhdr->e_shoff; // 节区头表中节区头的数量 shnum = elfhdr->e_shnum; // 节区头个数 shentsize = elfhdr->e_shentsize;
// 此乃该 elf 所使用的字符串表节区头在节区表中的下标 shstrndx = elfhdr->e_shstrndx;
assert(shstrndx < shnum);
// 去节区头表对应下标处,读出字符串表的节区头 fake_read(&shdr, shdr_offset + shstrndx * shentsize, sizeof(shdr)); assert(shdr.sh_type == SHT_STRTAB);
memcpy(shstr_tbl_section, &shdr, sizeof(shdr));
return 0;}
// 定位到 .__version 节区在文件中的偏移是关键技术int read_section_by_name(char *section_name, int name_size, struct elfhdr *elfhdr, struct elf_shdr *shstr_tbl_section, struct elf_shdr *the_shdr){ char shdr_name[SHDR_NAME_MAX]; struct elf_shdr shdr; elf_addr_t shdr_offset; // section header offset Elf_Half shnum, shentsize; int i;
// 如上文所述 shdr_offset = elfhdr->e_shoff; shnum = elfhdr->e_shnum; shentsize = elfhdr->e_shentsize;
// 这里挨个遍历所有的节区头,找到名字叫 .__version 的那个节区头 for (i = 0; i < shnum; ++i) { // 把节区头读出来 fake_read(&shdr, shdr_offset, sizeof(shdr));
/* 节区头的 sh_name 域,存的是该节区头的名字,在字符串表中的偏移 * 而上一步中已经定位到了字符串表节区头,shstr_tbl_section->sh_offset 就是字符串表在文件中的偏移 * shstr_tbl_section->sh_offset + shdr.sh_name 就是该节区名的字符串 */ fake_read_till_nil(shdr_name, shstr_tbl_section->sh_offset + shdr.sh_name);
// 如果能匹配上,证明找到了 if (strncmp(shdr_name, section_name, name_size) == 0) { assert(shdr.sh_type == SHT_PROGBITS && shdr.sh_flags == SHF_ALLOC); memcpy(the_shdr, &shdr, sizeof(shdr)); return 0; }
shdr_offset += shentsize; }
return -1;}4. 方案2:符号方案
4.1 app 侧
app 侧改动不大,因为这个方案是基于符号查找的,而不是基于节区查找的,因此并不要求 __version 变量位于一个特殊的节区。 相对于方案1,去除掉了 __section(.__version) 限制:
// 这是向 app 中添加校验字段的数据结构struct version_descriptor { char str[16]; uint32_t num0; uint32_t num1;};
static const struct version_descriptor ____version \ __used \ = { \ .str = __stringify(__str), \ .num0 = __num0, \ .num1 = __num1, \ }
4.2 parser 侧
1. 垫话
方案2 的实现相对来说要复杂一些,此方案依赖于符号的查找,逻辑会更多一些。 但算法本质还是查 ELF 格式这张表。
2. 总体流程
配合 图2 食用:
int main() { int rc; // 最终读出来的那个 version_descriptor 变量 struct version_descriptor __version;
// ELF 文件头 struct elfhdr __elfhdr; // app 中的那个 ____version 变量所在的节区(一般来说就是 .data) struct elf_shdr __version_section; // 字符串表节区头 struct elf_shdr __str_tbl_section; // 符号表节区头 struct elf_shdr __sym_tbl_section;
char *app_name = "./app_32"; // app 符号表中描述 ____version 变量的符号,32位 arch struct elf32_sym ____version_sym; char *app_name = "./app_64"; // app 符号表中描述 ____version 变量的符号,64位 arch struct elf64_sym ____version_sym;
// app 文件读到 __buffer 中 rc = read_file2buffer(app_name); if (rc) exit(rc);
// 从 app 中读出其 ELF 文件头 rc = read_elf_header(&__elfhdr); if (rc) exit(rc);
// 读取符号表节区头 rc = read_section_by_type(SHT_SYMTAB, &__elfhdr, &__sym_tbl_section); if (rc) exit(rc);
// 读取字符串表节区头 rc = read_string_table(&__elfhdr, &__str_tbl_section, __sym_tbl_section.sh_link); if (rc) exit(rc);
// 读取 ____version 变量在符号表中的符号 rc = read_symbol_by_name("____version", &__sym_tbl_section, &__str_tbl_section, &____version_sym); if (rc) exit(rc);
// 读取 ____version 变量所在节区(如前所言,一般是 .data) rc = read_section_by_ndx(&__elfhdr, ____version_sym.st_shndx, &__version_section); if (rc) exit(rc);
// 计算出符号在文件中的偏移,读取之 fake_read(&__version, __version_section.sh_offset + ____version_sym.st_value - __version_section.sh_addr, ____version_sym.st_size);
printf("dumping the version:\n"); printf("%s\n", __version.str); printf("0x%x\n", __version.num0); printf("0x%x\n", __version.num1);
return 0;}方案2 要需要对符号表有一定的认识。 我们要找到这个符号在文件中的位置,就需要先到符号表中找到对应 ____version 变量的符号项。 找到符号项后,关键一步是怎么算出这个符号在文件中的偏移。 具体算法我们在下一小节中对着代码注释讲。

图2
3. 关键函数实现
/* 方案2 要借助符号表,所以先定位到符号表 * 这里封装了一个按照 type 类型来查找节区头的逻辑 * 上层调用此函数时,传入符号表的 type(SHT_SYMTAB) 就可以定位到符号表节区头 */int read_section_by_type(Elf_Word sh_type, struct elfhdr *elfhdr, struct elf_shdr *the_shdr){ struct elf_shdr shdr; elf_addr_t shdr_offset; // section header offset Elf_Half shnum, shentsize; int i;
shdr_offset = elfhdr->e_shoff; shnum = elfhdr->e_shnum; shentsize = elfhdr->e_shentsize;
// 遍历所有的节区头 for (i = 0; i < shnum; ++i) { fake_read(&shdr, shdr_offset, sizeof(shdr));
// type 能匹配上,则返回该节区头。 if (shdr.sh_type == sh_type) { memcpy(the_shdr, &shdr, sizeof(shdr)); return 0; }
shdr_offset += shentsize; }
return -1;}
/* 同按名称定位节区头类似,这里需要借助字符串表 * 符号表节区头中的 sh_link 域记录着该符号表所依赖的字符串表节区头下标 * 再获取到符号表节区头后,只是使用其 sh_link 即可获取到对应字符串表节区头 */int read_string_table(struct elfhdr *elfhdr, struct elf_shdr *str_tbl_section, Elf_Half ndx){ struct elf_shdr shdr; elf_addr_t shdr_offset; // section header offset Elf_Half shnum, shentsize;
shdr_offset = elfhdr->e_shoff; shnum = elfhdr->e_shnum; shentsize = elfhdr->e_shentsize;
assert(ndx < shnum);
// 读取特定下标的节区头 fake_read(&shdr, shdr_offset + ndx * shentsize, sizeof(shdr)); assert(shdr.sh_type == SHT_STRTAB);
memcpy(str_tbl_section, &shdr, sizeof(shdr));
return 0;}
// 此函数根据要查找的符号名称(这里就是 "____version"),找到其在符号表中对应的符号项int read_symbol_by_name(char *sym_name, struct elf_shdr *sym_tbl_section, struct elf_shdr *str_tbl_section, struct elf32_sym *the_sym)int read_symbol_by_name(char *sym_name, struct elf_shdr *sym_tbl_section, struct elf_shdr *str_tbl_section, struct elf64_sym *the_sym){ char __sym_name[SYM_NAME_MAX]; Elf_Word symtab_size, symtab_entsize; elf_addr_t symtab_offset;
struct elf32_sym sym; struct elf64_sym sym;
int i;
// 符号表节区头中存着符号表的信息 // 符号表在文件中的偏移 symtab_offset = sym_tbl_section->sh_offset; // 符号表的大小 symtab_size = sym_tbl_section->sh_size; // 一个符号项的大小 symtab_entsize = sym_tbl_section->sh_entsize;
// 遍历所有的符号 for (i = 0; i < symtab_size / symtab_entsize; ++i) { // 读出符号项 fake_read(&sym, symtab_offset, sizeof(sym)); if (sym.st_name) { // 在字符串表中查找该符号项对应符号的名字 fake_read_till_nil(__sym_name, str_tbl_section->sh_offset + sym.st_name);
// 如果名字能匹配上,证明找到了 if (strcmp(__sym_name, sym_name) == 0) { assert(ELF32_ST_TYPE(sym.st_info) == STT_OBJECT); assert(ELF64_ST_TYPE(sym.st_info) == STT_OBJECT); memcpy(the_sym, &sym, sizeof(sym)); return 0; } }
symtab_offset += symtab_entsize; }}
/* 一个符号根据其类型,会位于不同的节区中,比如变量会位于数据段(一般是 .data),函数会位于代码段(一般是 .text) * 上一个函数中找到的符号对应的符号项中,st_shndx 记录的是该符号所在节区的节区头表中的下标 */int read_section_by_ndx(struct elfhdr *elfhdr, Elf_Half ndx, struct elf_shdr *the_shdr){ struct elf_shdr shdr; elf_addr_t shdr_offset; // section header offset Elf_Half shnum, shentsize; int i;
shdr_offset = elfhdr->e_shoff; shnum = elfhdr->e_shnum; shentsize = elfhdr->e_shentsize;
assert(ndx < shnum);
// 直接出特定下标的该节区即可 fake_read(&shdr, shdr_offset + ndx * shentsize, sizeof(shdr)); assert(shdr.sh_type == SHT_PROGBITS);
memcpy(the_shdr, &shdr, sizeof(shdr));
return 0;}只剩下最后一跃,如何定位该符号在文件中的偏移? 因为符号项中并没有记录该变量在文件中的偏移信息。符号属于运行时的概念,不同于段、节区是加载、链接时的概念(严格意义上来说,段是加载时,节区是链接时),所以符号项中没必要记录其加载域的地址。
但是仍然有办法算出来:
- 节区的 sh_addr 是该节区将被加载到虚拟地址空间中的起始地址
- 符号的 st_value 是该符号将被加载到虚拟地址空间中的起始地址
- 那么 st_value - sh_addr 即是该符号相对其所在节区起始地址的偏移
- 而节区在文件中的偏移是 sh_offset
所以符号在文件中的偏移就是 sh_offset + (st_value - sh_addr)
代码:
fake_read(&__version, __version_section.sh_offset + ____version_sym.st_value - __version_section.sh_addr, ____version_sym.st_size);5. 写在后面
本文通过一个简单的小项目,帮对 ELF 格式只有零碎概念的同学将相关知识串起来。
其实本文还有很多细节问题没有展开,比如:
- 共享目标文件中还有一种叫“动态符号表(SHT_DYNSYM)”的符号表,它与本文中的符号表有何区别?
- 查找符号表时,为何不通过遍历节区头表,匹配 SHT_STRTAB 类型节区的方式来做?
- 段和节区有何区别?
- 方案2 中定位符号所在的节区,为何不通过匹配节区名 ".data" 来匹配?
- 方案2 中的符号名出现冲突怎么办?
诸如此类,凡此种种。 本文在这里就不做过多解答,意在起抛砖引玉之功效,旨在引导对 ELF 格式感兴趣的同学快速入门。
至于更多细节的学习,推荐《程序员的自我修炼》一书,结合 linker & loader 实现的源码阅读(推荐 TencentOS tiny 的 elfloader 组件)。
6. 项目代码文件
6.1 app.c
// use DECLARE_VERSION to add a version descriptor into our appDECLARE_VERSION("version_x", 0xdead, 0xbeef);
int main(void) { printf("hello world\n");}6.2 parser.c
/* the c library has its own elf.h, we define our elf.h here, * so rename it to __elf.h */
char __buffer[FILE_SIZE_MAX];
int read_file2buffer(char *path){ FILE *file = NULL; int size;
if (!(file = fopen(path, "r"))) { printf("open error\n"); goto errout; }
fseek(file, 0, SEEK_END); size = ftell(file); if (size > FILE_SIZE_MAX) { printf("file size exceed limit\n"); goto errout; }
fseek(file, 0, SEEK_SET); fread(__buffer, sizeof(char), size, file);
fclose(file); return 0;
errout: if (file) { fclose(file); }
return -1;}
// read from the `app` file to `buf` at offset of `offset`, with size of `size`int fake_read(void *buf, int offset, int size){ // you can replace this logic with a fread memcpy(buf, __buffer + offset, size);}
int fake_read_till_nil(char *buf, int offset){ int i;
for (i = offset; ; ++i) { buf[i - offset] = *((char *)__buffer + i);
if (buf[i - offset] == '\0') return 0; }
return -1;}
int read_elf_header(struct elfhdr *elfhdr){ fake_read(elfhdr, 0, sizeof(*elfhdr));
if (strncmp(elfhdr->e_ident, ELFMAG, sizeof(ELFMAG) - 1) == 0) { printf("load elf header ok\n"); return 0; } return -1;}6.3 compiler.h
typedef __signed__ char __s8;typedef unsigned char __u8;
typedef __signed__ short __s16;typedef unsigned short __u16;
typedef __signed__ int __s32;typedef unsigned int __u32;
__extension__ typedef __signed__ long long __s64;__extension__ typedef unsigned long long __u64;typedef __signed__ long long __s64;typedef unsigned long long __u64;
6.4 __elf.h
/* 32-bit ELF base types. */typedef __u32 Elf32_Addr;typedef __u16 Elf32_Half;typedef __u32 Elf32_Off;typedef __s32 Elf32_Sword;typedef __u32 Elf32_Word;
/* 64-bit ELF base types. */typedef __u64 Elf64_Addr;typedef __u16 Elf64_Half;typedef __s16 Elf64_SHalf;typedef __u64 Elf64_Off;typedef __s32 Elf64_Sword;typedef __u32 Elf64_Word;typedef __u64 Elf64_Xword;typedef __s64 Elf64_Sxword;
/* This info is needed when parsing the symbol table */
typedef struct elf32_sym { Elf32_Word st_name; Elf32_Addr st_value; Elf32_Word st_size; unsigned char st_info; unsigned char st_other; Elf32_Half st_shndx;} Elf32_Sym;
typedef struct elf64_sym { Elf64_Word st_name; /* Symbol name, index in string tbl */ unsigned char st_info; /* Type and binding attributes */ unsigned char st_other; /* No defined meaning, 0 */ Elf64_Half st_shndx; /* Associated section index */ Elf64_Addr st_value; /* Value of the symbol */ Elf64_Xword st_size; /* Associated symbol size */} Elf64_Sym;
typedef struct elf32_hdr { unsigned char e_ident[EI_NIDENT]; Elf32_Half e_type; Elf32_Half e_machine; Elf32_Word e_version; Elf32_Addr e_entry; /* Entry point */ 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;
typedef struct elf64_hdr { unsigned char e_ident[EI_NIDENT]; /* ELF "magic number" */ Elf64_Half e_type; Elf64_Half e_machine; Elf64_Word e_version; Elf64_Addr e_entry; /* Entry point virtual address */ Elf64_Off e_phoff; /* Program header table file offset */ Elf64_Off e_shoff; /* Section header table file offset */ Elf64_Word e_flags; Elf64_Half e_ehsize; Elf64_Half e_phentsize; Elf64_Half e_phnum; Elf64_Half e_shentsize; Elf64_Half e_shnum; Elf64_Half e_shstrndx;} Elf64_Ehdr;
typedef struct elf32_shdr { 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;
typedef struct elf64_shdr { Elf64_Word sh_name; /* Section name, index in string tbl */ Elf64_Word sh_type; /* Type of section */ Elf64_Xword sh_flags; /* Miscellaneous section attributes */ Elf64_Addr sh_addr; /* Section virtual addr at execution */ Elf64_Off sh_offset; /* Section file offset */ Elf64_Xword sh_size; /* Size of section in bytes */ Elf64_Word sh_link; /* Index of another section */ Elf64_Word sh_info; /* Additional section information */ Elf64_Xword sh_addralign; /* Section alignment */ Elf64_Xword sh_entsize; /* Entry size if section holds table */} Elf64_Shdr;
/* sh_type */
/* sh_flags */
// #define ELF_CLASS ELFCLASS64
6.5 1.sh
define ELFCLASS32 1define ELFCLASS64 2
# for x86_64gcc app.c -m64 -o app_64 -I ./includegcc parser.c -o parser_64 -I./include -DELF_CLASS=2./parser_64
# for x86 gcc app.c -m32 -o app_32 -I ./include gcc parser.c -o parser_32 -I./include -DELF_CLASS=1 ./parser_32 




