动手写加载器(一)

前段时间脑子突然有个疑问,linux的可执行文件是怎么执行的呢?能不能根据最基本的原理写个文具在linux操作系统上动态执行可执行文件?如果自己在写操作系统(玩具级的),那是不是可以在不考虑执行效率的情况把自己写的加载器和链接器移植到项目上呢?所以就萌生了写一个简单的加载器的想法。本系列实现的加载器只是为了熟悉可执行文件(或动态库)的加载、执行的原理。Just for fun!

作为该系列的第一篇,我们从简单的入手,静态链接加载比动态链接的简单,所以开篇我们先讲解静态链接的加载。

ELF文件的结构如上图。我们如果要执行ELF文件,首先就需要把ELF文件解析出来。

1 解析ELF Header

ELF Header在文件的最开头,直接把文件内容读出来存放在内存中,根据ELF Header中e_phoff和e_shoff偏移,就可以找到Program header table和Section header table,所以我们定义个结构体保存相应的信息:

struct elf_info
{
    char* name; // 加载的文件名称
    Elf64_Ehdr* hdr; // Program header table的指针
    size_t len; // 加载的文件的大小
    Elf64_Phdr* phr; // Section header table的指针
};

读取ELF文件的内容,初始化上面的结构体:

    fd = open(file, O_RDONLY);
    if (fd == -1) {
        printf("Open %s failed.\n", file);
        goto error;
    }

    while (len < file_info.st_size) {
        read_byte = read(fd, file_ptr, file_info.st_size);
        if (read_byte == -1) {
            printf("Read %s failed.\n", file);
            goto error;
        }
        len += read_byte;
    }
    info.len = len;
    info.hdr = (Elf64_Ehdr*)file_ptr;
    info.name = file;

然后校验ELF Header,主要校验e_ident、e_type、e_version和e_machine字段,目的就是为了校验是不是可执行文件:

static bool verify_ehr_header(void)
{
    int ret;

    if (!info.hdr) {
        printf("Elf header is null.\n");
        return false;
    }

    ret = memcmp(info.hdr->e_ident, ELFMAG, SELFMAG);
    if (ret) {
        printf("Elf header magic num is invalid.\n");
        return false;
    }

    if ((info.hdr->e_type != ET_EXEC) && (info.hdr->e_type != ET_DYN)) {
        printf("Elf type(%d) is invalid.\n", ET_DYN);
        return false;
    }

    if (info.hdr->e_version != EV_CURRENT) {
        printf("Elf version is invalid.\n");
        return false;
    }

    if (info.hdr->e_machine != EM_X86_64) {
        printf("Elf machine arch is invalid.\n");
        return false;
    }
    return true;
}

2 读取可加载段并加载到内存

我们都知道可执行文件一般包含代码段、数据段、BSS段,程序要能够正常运行,就需要把这几个可加载段加载到内存中。可以通过readelf查询ELF的可加载段:

readelf -l test

Elf 文件类型为 EXEC (可执行文件)
Entry point 0x401b90
There are 10 program headers, starting at offset 64

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000518 0x0000000000000518  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x000000000009371d 0x000000000009371d  R E    0x1000
  LOAD           0x0000000000095000 0x0000000000495000 0x0000000000495000
                 0x000000000002666d 0x000000000002666d  R      0x1000
  LOAD           0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000005170 0x00000000000068c0  RW     0x1000
  NOTE           0x0000000000000270 0x0000000000400270 0x0000000000400270
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000290 0x0000000000400290 0x0000000000400290
                 0x0000000000000044 0x0000000000000044  R      0x4
  TLS            0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000000020 0x0000000000000060  R      0x8
  GNU_PROPERTY   0x0000000000000270 0x0000000000400270 0x0000000000400270
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000002f40 0x0000000000002f40  R      0x1

这里我们需要找到Program header table的位置,每个Program header table保存了每个段的信息。ELF Header中e_phoff表示Program header table在文件中的偏移,该偏移是从文件头开始计算的:

struct elf_info info; // info为全局变量
info.phr = (Elf64_Phdr*)((char *)info.hdr + info.hdr->e_phoff);

ELF Header中e_phnum表示Program header的个数,我们这里需要遍历所有的Program header,找到类型为PT_LOAD的段,并加载到内存中。

  1. 首先我们先在申请一块内存:
char* ptr = NULL;
ptr = mmap(NULL, mem_size,
    PROT_EXEC | PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (!ptr) {
    printf("Mmap segment to mem failed.\n");
    return;
}

这块内存用来加载ELF文件的可加载段,但是有个问题是:ELF文件可加载段的虚拟地址和实际分配的这块内存地址不一致。所以我们现在需要按照每个加载端内存虚拟地址的大小,顺序加载到内存中。

  1. 我们需要找到可加载段中最小的虚拟地址:
static void get_vaddr_range(size_t* min_addr, size_t* max_addr)
{
    size_t idx = 0;
    size_t min = UINTPTR_MAX, max = 0;
    info.phr = (Elf64_Phdr*)((char*)info.hdr + info.hdr->e_phoff);
    for (; idx < info.hdr->e_phnum; ++idx) {
        if (info.phr[idx].p_type == PT_LOAD) {
            min = min > info.phr[idx].p_vaddr ? info.phr[idx].p_vaddr : min;
            max = max > (info.phr[idx].p_vaddr + info.phr[idx].p_filesz)? max : info.phr[idx].p_vaddr + info.phr[idx].p_filesz;
        }
    }
    *min_addr = min;
    *max_addr = max;
}
  1. 计算实际地址与可加载段最小虚拟地址的偏移。因为我们实际加载的时候需要在每个虚拟地址上加上这个偏移,才能保证加载到我们实际分配的内存中:
    size_t min_va, max_va;
    get_vaddr_range(&min_va, &max_va);
    size_t mem_size = max_va - min_va ;

    bias_ptr = (char*)ptr;
    offset = ptr - (char*)min_va;
  1. 加载每个可加载段到内存中
    for (; idx < info.hdr->e_phnum; ++idx) {
        if (info.phr[idx].p_type != PT_LOAD) {
            continue;
        }
        printf("copy idx: %x, vaddr: %x, file size: %x, mapped addr: %x\n",
            idx, info.phr[idx].p_vaddr, info.phr[idx].p_filesz, (char*)bias_ptr + offset);
        // printf("Header num: %d\n", info.hdr->e_phnum);
        memcpy(offset + info.phr[idx].p_vaddr, ((char*)info.hdr + info.phr[idx].p_offset), info.phr[idx].p_filesz);
        bias_ptr += (info.phr[idx].p_filesz);
    }

3 找到执行入口,运行ELF

经过上面的步骤之后,ELF的可加载段就都加载内存中了。要运行程序,就需要先找到程序的执行入口,也就是main函数。查找main函数的过程涉及到symbol table和string table,需要两者配合。

typedef struct {
    Elf64_Word      st_name;        /* Symbol name (string tbl index) */
    unsigned char   st_info;        /* Symbol type and binding */
    unsigned char   st_other;       /* Symbol visibility */
    Elf64_Section   st_shndx;       /* Section index */
    Elf64_Addr      st_value;       /* Symbol value */
    Elf64_Xword     st_size;        /* Symbol size */
} Elf64_Sym;

symbot table中并没有直接保存main字符串,所有的字符串都保存在string table中。symbol table中保存的是和上面Elf64_Sym结构体一致的一个一个条目。每一个字符串都会对应一个条目。Elf64_Sym中st_name是该字符串在string table中偏移。根据这个偏移我们就可以知道这个字符串是什么。例如:

2025-12-27-10-46-09-image

假设上图中entry 5是main函数在symbol table中的位置。st_name表示的是string table中的偏移。string table中每个字符串都是以'\0'结尾的。

所以我们查找符号(symbol)的时候,我们就需要遍历symbol table中的每个entry,通过entry中的st_name字段找到这个entry对应的字符串。然后我们再根据这个字符串和我们想要找的symbol进行比较即可。

symbol的查找过程如下:

  1. 查找symbol section header
    /* 1. 找到symbol section header*/
    Elf64_Shdr* sec_tab_header, *sym_sec, *str_sec;
    int i;
    int j = 0;
    int left = 0, cnt = 0;
    int str_tab_size = 0;
    
    sec_tab_header = ((char*)info.hdr + info.hdr->e_shoff);
    for (i = 0; i < info.hdr->e_shnum; ++i) {
        if (sec_tab_header[i].sh_type == SHT_SYMTAB) {
            sym_sec = &sec_tab_header[i];
            break;
        }
    }
  1. 查找到string table header
    /* 2. 找到string table header*/
    for (i = 0; i < info.hdr->e_shnum; ++i) {
        if (sec_tab_header[i].sh_type == SHT_STRTAB) {
            str_sec = &sec_tab_header[i];
            break;
        }
    }
  1. 遍历symbol table,找到每个symbol table条目在string table的字符串,与待查找的字符串比较
    /* 通过st_name找到对应的字符串和查找的symbol比较 */
    Elf64_Sym* sym_entries = ((char*)info.hdr +  sym_sec->sh_offset);
    str_tab_size = str_sec->sh_size;
    char* str_tab = (char*)info.hdr + str_sec->sh_offset;

    while (left < (sym_sec->sh_size / sizeof(Elf64_Sym))) {
        if (!strcmp(str_tab + sym_entries[left].st_name, name)) {
            break;
        }
        left++;
    }

如果待查询的字符串和在string table中找到的字符串匹配,说明找到了对应的符号。Elf64_Sym结构体中的st_value保存的是符号在内存中的地址。

找到内存的偏移地址后,需要再加上之前加载可加载段时的偏移,才是最后该函数在内存中的地址。

    addr = look_up_symbol("main");
    int (*main)(int argc, char* argv[]);
    main = addr + info.load_offset;
    if (main) {
        main(0, NULL);
    }
posted @ 2025-12-27 17:46  cockpunctual  阅读(38)  评论(0)    收藏  举报