ELF程序头表详解
程序头表(Program Header Table)是ELF文件中描述段(segments)如何映射到内存中的关键结构。它在程序加载和执行过程中起着至关重要的作用。
程序头表基本概念
程序头表由多个程序头(Program Header)组成,每个程序头描述一个段(segment)的信息。这些段告诉操作系统如何将程序加载到内存中执行。
程序头表的主要属性:
- 只在可执行文件和共享库中存在(目标文件没有)
- 由ELF头部中的e_phoff字段指定在文件中的偏移量
- 包含e_phnum个条目
- 每个条目大小为e_phentsize字节
程序头结构
每个程序头是一个Elf64_Phdr结构(32位是Elf32_Phdr),包含以下字段:
c
typedef struct {
Elf64_Word p_type; // 段类型
Elf64_Word p_flags; // 段标志
Elf64_Off p_offset; // 段在文件中的偏移
Elf64_Addr p_vaddr; // 段的虚拟地址
Elf64_Addr p_paddr; // 段的物理地址(通常与虚拟地址相同)
Elf64_Xword p_filesz; // 段在文件中的大小
Elf64_Xword p_memsz; // 段在内存中的大小
Elf64_Xword p_align; // 对齐方式
} Elf64_Phdr;
各字段详细说明:
-
p_type - 段类型
- PT_NULL:未使用的程序头表项
- PT_LOAD:可加载的段
- PT_DYNAMIC:动态链接信息
- PT_INTERP:指定要作为解释器调用的以空字符结尾的路径名的位置和大小
- PT_NOTE:指定辅助信息的位置和大小
- PT_SHLIB:保留类型,但语义未指定
- PT_PHDR:指定程序头表在文件中的位置和大小
- PT_TLS:线程局部存储模板
-
p_flags - 段标志
- PF_X:执行权限
- PF_W:写权限
- PF_R:读权限
-
p_offset - 段在文件中的偏移量,从文件头开始计算
-
p_vaddr - 段在内存中的虚拟地址
-
p_paddr - 段在内存中的物理地址(在系统中物理地址有意义时使用)
-
p_filesz - 段在文件中的字节数
-
p_memsz - 段在内存中的字节数
-
p_align - 段对齐要求
程序头表的作用
程序头表在程序加载过程中发挥关键作用:
-
描述段的属性:每个段在程序头表中都有一个对应的条目,描述了段的类型、文件偏移、虚拟地址、物理地址、文件大小、内存大小、对齐要求等。
-
指导加载过程:操作系统的加载器使用程序头表中的信息来决定如何将文件中的段加载到内存中。例如,它可以根据段的虚拟地址和文件偏移来计算段在内存中的位置。
常见段类型详解
PT_LOAD段
这是最重要的段类型,表示需要加载到内存中的段。一个可执行文件通常包含多个PT_LOAD段:
- 代码段:包含程序指令,通常具有读和执行权限(PF_R | PF_X)
- 数据段:包含已初始化的数据,通常具有读和写权限(PF_R | PF_W)
PT_DYNAMIC段
包含动态链接信息,用于动态加载共享库。它包含一系列动态条目,如:
- DT_NEEDED:需要的共享库
- DT_STRTAB:字符串表地址
- DT_SYMTAB:符号表地址
- DT_RELA:重定位表地址
PT_INTERP段
指定程序解释器的路径,对于动态链接的程序,通常是/lib64/ld-linux-x86-64.so.2。
PT_NOTE段
包含辅助信息,如程序的ABI版本信息。
PT_PHDR段
描述程序头表自身的大小和位置。
示例分析
使用readelf -l命令可以查看程序头表信息。例如:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000400040 0x0000000000400040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000278 0x0000000000400278 0x0000000000400278 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000988 0x000988 R E 0x200000
LOAD 0x000e30 0x0000000000600e30 0x0000000000600e30 0x000278 0x000288 RW 0x200000
DYNAMIC 0x000e40 0x0000000000600e40 0x0000000000600e40 0x0001f0 0x0001f0 RW 0x8
在这个例子中:
- 第一个LOAD段(代码段):从文件偏移0x0开始,加载到虚拟地址0x400000,大小为0x988字节,具有读和执行权限
- 第二个LOAD段(数据段):从文件偏移0xe30开始,加载到虚拟地址0x600e30,大小为0x278字节,具有读和写权限
toolCall::search_web::call_63c943f52898415cac0bbf7c::INIT
下面是一个使用C语言读取ELF程序头表的示例代码:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <elf.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/mman.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "用法: %s <elf文件>\n", argv[0]);
return 1;
}
// 打开文件
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("无法打开文件");
return 1;
}
// 获取文件大小
struct stat st;
if (fstat(fd, &st) < 0) {
perror("获取文件状态失败");
close(fd);
return 1;
}
// 将文件映射到内存
char *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (map == MAP_FAILED) {
perror("内存映射失败");
close(fd);
return 1;
}
// 检查是否为有效的ELF文件
Elf64_Ehdr *ehdr = (Elf64_Ehdr *)map;
if (memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0) {
fprintf(stderr, "不是有效的ELF文件\n");
munmap(map, st.st_size);
close(fd);
return 1;
}
// 检查是否为64位ELF文件
if (ehdr->e_ident[EI_CLASS] != ELFCLASS64) {
fprintf(stderr, "此程序仅支持64位ELF文件\n");
munmap(map, st.st_size);
close(fd);
return 1;
}
// 打印程序头表信息
printf("程序头表包含 %d 个条目:\n", ehdr->e_phnum);
printf("程序头表偏移: %ld\n", ehdr->e_phoff);
printf("每个程序头大小: %d 字节\n", ehdr->e_phentsize);
printf("\n");
// 获取程序头表的起始位置
Elf64_Phdr *phdr = (Elf64_Phdr *)(map + ehdr->e_phoff);
// 打印表头
printf("%-12s %-10s %-16s %-16s %-10s %-10s %-4s %-8s\n",
"类型", "偏移量", "虚拟地址", "物理地址", "文件大小", "内存大小", "标志", "对齐");
// 遍历并打印每个程序头
for (int i = 0; i < ehdr->e_phnum; i++) {
// 程序头类型字符串
char *type_str;
switch (phdr[i].p_type) {
case PT_NULL: type_str = "NULL"; break;
case PT_LOAD: type_str = "LOAD"; break;
case PT_DYNAMIC: type_str = "DYNAMIC"; break;
case PT_INTERP: type_str = "INTERP"; break;
case PT_NOTE: type_str = "NOTE"; break;
case PT_SHLIB: type_str = "SHLIB"; break;
case PT_PHDR: type_str = "PHDR"; break;
case PT_TLS: type_str = "TLS"; break;
case PT_GNU_EH_FRAME: type_str = "EH_FRAME"; break;
case PT_GNU_STACK: type_str = "STACK"; break;
case PT_GNU_RELRO: type_str = "RELRO"; break;
default: type_str = "UNKNOWN"; break;
}
// 标志字符串
char flags_str[4] = "---";
int f_idx = 0;
if (phdr[i].p_flags & PF_R) flags_str[f_idx++] = 'R';
if (phdr[i].p_flags & PF_W) flags_str[f_idx++] = 'W';
if (phdr[i].p_flags & PF_X) flags_str[f_idx++] = 'E';
printf("%-12s 0x%06lx 0x%016lx 0x%016lx 0x%06lx 0x%06lx %s 0x%lx\n",
type_str,
(unsigned long)phdr[i].p_offset,
(unsigned long)phdr[i].p_vaddr,
(unsigned long)phdr[i].p_paddr,
(unsigned long)phdr[i].p_filesz,
(unsigned long)phdr[i].p_memsz,
flags_str,
(unsigned long)phdr[i].p_align);
}
// 详细解释LOAD段
printf("\n详细段信息:\n");
for (int i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type == PT_LOAD) {
printf("LOAD段 %d:\n", i);
printf(" 文件偏移: 0x%08lx\n", (unsigned long)phdr[i].p_offset);
printf(" 虚拟地址: 0x%016lx\n", (unsigned long)phdr[i].p_vaddr);
printf(" 物理地址: 0x%016lx\n", (unsigned long)phdr[i].p_paddr);
printf(" 文件大小: 0x%08lx\n", (unsigned long)phdr[i].p_filesz);
printf(" 内存大小: 0x%08lx\n", (unsigned long)phdr[i].p_memsz);
printf(" 对齐方式: 0x%08lx\n", (unsigned long)phdr[i].p_align);
printf(" 段标志: ");
if (phdr[i].p_flags & PF_R) printf("读 ");
if (phdr[i].p_flags & PF_W) printf("写 ");
if (phdr[i].p_flags & PF_X) printf("执行 ");
printf("\n\n");
}
}
// 清理资源
munmap(map, st.st_size);
close(fd);
return 0;
}
这段代码完成了以下功能:
- 打开并验证ELF文件的有效性
- 使用内存映射技术将文件映射到内存中
- 解析ELF头部以获取程序头表的位置和大小信息
- 遍历程序头表中的每个程序头
- 打印每个程序头的详细信息,包括:
- 段类型(PT_LOAD、PT_DYNAMIC等)
- 文件偏移量
- 虚拟地址和物理地址
- 文件大小和内存大小
- 访问权限标志(读、写、执行)
- 对齐方式
- 特别详细解释LOAD段的信息
这个示例仅支持64位ELF文件,如果需要支持32位,需要相应地使用Elf32_Phdr结构
程序头表与节区头表的区别
-
用途不同:
- 程序头表用于执行视图,指导程序加载到内存
- 节区头表用于链接视图,指导链接器进行符号解析和重定位
-
存在范围不同:
- 程序头表只存在于可执行文件和共享库中
- 节区头表主要存在于目标文件和可执行文件中
-
粒度不同:
- 程序头表中的段是多个节的组合,粒度较粗
- 节区头表中的节是更细粒度的数据单元
总结
程序头表是ELF文件执行视图的核心组成部分,它描述了如何将文件中的段加载到内存中。通过程序头表,操作系统加载器能够正确地将程序映射到进程的地址空间中,为程序的执行做好准备。理解程序头表的结构和作用对于深入理解程序加载机制和系统底层工作原理非常重要。