从零编写linux0.11 - 第十一章 可执行文件

本文详细介绍了Linux系统中elf可执行文件的结构,包括文件头、程序头和代码数据等关键部分,并演示了如何使用execve系统调用来加载和运行elf文件。同时,文章阐述了参数和环境变量的传递过程,以及在缺页异常中加载代码和数据的机制。

从零编写linux0.11 - 第十一章 可执行文件

编程环境:Ubuntu 20.04、gcc-9.4.0

代码仓库:https://gitee.com/AprilSloan/linux0.11-project

linux0.11源码下载(不能直接编译,需进行修改)

本章目标

本章会加载并运行 elf 格式可执行文件,但是功能还不够完善,不支持动态编译,不能运行太大的文件。

1.elf 可执行文件介绍

本节的内容主要参考《程序员的自我修养》一书,该书包含了较为全面的可执行文件加载的知识。不仅介绍了 elf 文件结构,还讲解了程序装载和动态链接的内容。阅读完本书后,相信你会对程序运行有更深刻的理解。

首先,现在存在不同的可执行文件格式,linux0.11 原本采用的是 a.out 格式,但是这种格式已经被淘汰了。如今 linux 采用的是 elf 格式,windows 采用的是 PE 格式。因为 linux 和 windows 可执行文件的格式不同,所以 windows 的程序不能在 linux 上运行。

elf 文件主要有以下几个部分:文件头、节头、程序头、代码数据、重定位表、符号表、字符串表等。动态链接还有动态符号表和重定位表,但这里只会讲静态链接的相关知识。

我们的代码只会用到文件头,程序头和代码数据。文件头用来找到程序头,程序头用来找到代码数据。

为了直观地了解 elf 格式可执行文件,我们来查看这种文件的结构是怎样的。

代码仓库的 libc 目录下已经搭建好一个编译环境,main.c 的代码如下所示:

#include <stdio.h>

void start()
{
   
   
    __asm__("call main" :::);
}

int main(int argc, char *argv[])
{
   
   
    printf("Hello World!\n");
    printf("argc: %d\n", argc);
    printf("argv[0]: %s\n", argv[0]);
    while (1);
    return 0;
}

用 start 调用 main 函数是为了保证栈不出错,如果直接运行 main 函数的话,main 函数会修改栈内容,导致不能正确访问 argc 和argv。

打开终端,进入该目录,执行 make 指令,就会编译出一个名为 main 的可执行文件,执行下面的执行查看文件类型:

ai@ubuntu:~/Desktop/linux0.11-project/libc$ file main
main: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

这代表 main 是32位小端的 elf 可执行文件,适用于 Intel 80386 平台,版本为1,静态链接,未剔除符号表信息。这些都是 elf 文件的基本信息,这些信息保存在文件头里。运行下面的指令查看 elf 头信息。

ai@ubuntu:~/Desktop/linux0.11-project/libc$ readelf -h main
ELF 头:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF32
  数据:                              2 补码,小端序 (little endian)
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              EXEC (可执行文件)
  系统架构:                          Intel 80386
  版本:                              0x1
  入口点地址:               0x80000000
  程序头起点:          52 (bytes into file)
  Start of section headers:          11128 (bytes into file)
  标志:             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         4
  Size of section headers:           40 (bytes)
  Number of section headers:         14
  Section header string table index: 13

文件头的数据结构如下所示。

// elf.h
typedef struct elfhdr {
   
   
    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;

结构体成员的含义如下所示:

成员 readelf输出结果与含义
e_ident Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
类别: ELF32
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
e_type 类型: EXEC (可执行文件)
ELF文件类型
e_machine 系统架构: Intel 80386
ELF文件的CPU平台属性,相关常量以 EM_ 开头
e_version 版本: 0x1
ELF 版本号。一般为常数1
e_entry 入口点地址: 0x80000000
入口地址,规定 ELF 程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令,可重定位文件一般没有入口地址,则这个值为0
e_phoff 程序头起点: 52 (bytes into file)
程序头在文件中的偏移,也就是从文件的第52个字节开始是程序头
e_shoff Start of section headers: 11128 (bytes into file)
段表在文件中的偏移
e_flags 标志: 0x0
ELF 标志位,用来识别一些 ELF 文件平台相关的属性。
e_ehsize Size of this header: 52 (bytes)
ELF 文件头大小
e_phentsize Size of program headers: 32 (bytes)
程序头描述符的大小
e_phnum Number of program headers: 4
程序头描述符数量
e_shentsize Size of section headers: 40 (bytes)
段表描述符的大小
e_shnum Size of section headers: 14 (bytes)
段表描述符数量
e_shstrndx Section header string table index: 13
段表字符串表所在的段在段表中的下标。

e_ident 的前4个字符必须是 0x7f,0x45(E),0x4c(L),0x46(F)。不然这个文件就不是 elf 文件。

通过 e_phoff 找到程序头描述符的位置。

程序头的结构可以通过如下的命令看到:

ai@ubuntu:~/Desktop/linux0.11-project/libc$ readelf -l main

Elf 文件类型为 EXEC (可执行文件)
Entry point 0x80000000
There are 4 program headers, starting at offset 52

程序头:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x80000000 0x80000000 0x0144c 0x01868 RWE 0x1000
  NOTE           0x002420 0x80001420 0x80001420 0x0001c 0x0001c R   0x4
  GNU_PROPERTY   0x002420 0x80001420 0x80001420 0x0001c 0x0001c R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  段节...
   00     .text .text.__x86.get_pc_thunk.ax .text.__x86.get_pc_thunk.bx .text.__x86.get_pc_thunk.si .rodata .eh_frame .note.gnu.property .got.plt .bss 
   01     .note.gnu.property 
   02     .note.gnu.property 
   03

LOAD 段包含了代码和数据,我们需要将这个段加载到操作系统中。该段在文件中的偏移是 0x1000,文件长度为 0x144c,内存长度为 0x1868。为什么这两个长度不一样呢?在文件中并没有 bss 段的数据(bss 的数据全为0,只需要保存 bss 段的长度即可),当加载到内存时,需要向 bss 段填充0。也就是说,内存长度 - 文件长度 = bss 段长度。

程序头的定义如下:

// elf.h
typedef struct elf_phdr {
   
   
    Elf32_Word p_type;
    Elf32_Off  p_offset;
    Elf32_Addr p_vaddr;
    Elf32_Addr p_paddr;
    Elf32_Word p_filesz;
    Elf32_Word p_memsz;
    Elf32_Word p_flags;
    Elf32_Word p_align;
} Elf32_Phdr;

各成员的含义如下:

成员 含义
p_type “Segment” 的类型,基本上我们在这里只关注 LOAD 类型的程序头
p_offset “Segment” 在文件中的偏移
p_vaddr “Segment” 的第一个字节在进程虚拟地址空间的起始地址,整个程序头表中,所有 LOAD 类型的元素按照从小到大排列
p_paddr “Segment” 的物理装载地址
p_filesz “Segment” 在 elf 文件中所占空间的长度
p_memsz “Segment” 在进程虚拟地址空间中占用的长度
p_flags “Segment” 的权限属性,比如可读 “R”、可写 “W” 和可执行 “X”
p_align “Segment” 的对齐属性。实际对齐字节等于2的 p_align 次。比如 p_align 等于10,那么实际的对齐属性就是2的10次方,即1024字节

通过 p_offset 就能找到代码和数据的起始地址。

我的代码很简单,只需要了解这两个部分就可以开始写代码了,如果你想了解更多的知识,还是去看看《程序员的自我修养》吧。毕竟这只是一篇小小的博客,装不下书里太多的内容。

另外,linux 中也有运行 windows 可执行文件的方法,安装 wine 就可以执行部分 PE 格式的可执行文件。毕竟只要知道 PE 格式的结构,就能够对它进行解析。如果需要动态库(.dll)支持,那就没法加载了。

2.打印可执行文件信息

这节开始编写代码。运行可执行文件的系统调用是 execve。

# system_call.s
.align 4
sys_execve:
    lea EIP(%esp), %eax     # 保存栈中eip的地址
    pushl %eax
    call do_execve
    addl $4, %esp
    ret

在执行int 0x80进入内核后,会将 ss、esp、eflags、cs、eip 依次入栈。第4行代码想要保存栈中 eip 的地址。加载可执行文件后,需要设置新的栈,重新设置程序运行地址,就会修改 eip 和 esp 的值。第5行将地址入栈,作为 do_execve 函数的参数,方便对 eip 和 esp 进行修改。

可以看到 do_execve 有5个参数。eip 是在 sys_execve 中入栈的,filename、argv、envp 是在 system_call 中入栈的,这个 tmp 又是在什么入栈的?是在call *sys_call_table(, %eax, 4)指令执行后入栈的,call 命令会将 eip 入栈,ret 会将 eip 出栈。所以,tmp 的值是 call 的下一条指令。

// exec.c
int do_execve(unsigned long *eip, long tmp, char *filename, char **argv, char **envp)
{
   
   
    struct elfhdr elf_ex;
    struct m_inode *inode;
    struct buffer_head *bh;
    struct elf_phdr *elf_phdata;
    int i;
    int e_uid, e_gid;

    if ((0xffff & eip[1]) != 0x000f)
        panic("execve called from supervisor mode");

    inode = namei(filename);
    if (!inode)
        return -ENOENT;
    
    if (!S_ISREG(inode->i_mode))    // 必须是普通文件
        return -EACCES;
    
    i = inode->i_mode;
    e_uid = (i & S_ISUID) ? inode->i_uid : current->euid;
    e_gid = (i & S_ISGID) ? inode->i_gid : current->egid;
    if (current->euid == inode->i_uid)
        i >>= 6;
    else if (current->egid == inode->i_gid)
        i >>= 3;
    if (!(i & 1) && !((inode->i_mode & 0111) && suser()))   // 必须是可执行文件
        return -ENOEXEC;

    bh = bread(inode->i_dev, inode->i_zone[0]);
    if (!bh)
        return -EACCES;
    
    elf_ex = *((struct elfhdr *)bh->b_data);

    if (elf_ex.e_ident[0] != 0x7f ||
        strncmp((char *)&elf_ex.e_ident[1], "ELF",3) != 0)
        return -ENOEXEC;

    if(elf_ex.e_type != ET_EXEC || elf_ex.e_machine != EM_386)
        return -ENOEXEC;

    elf_phdata = (struct elf_phdr *)(bh->b_data + elf_ex.e_phoff);
    printk("Type:       0x%x\n", elf_phdata->p_type);
    printk("Offset:     0x%x\n", elf_phdata->p_offset);
    printk("VirtAddr:   0x%x\n", elf_phdata->p_vaddr);
    printk("PhysAddr:   0x%x\n", elf_phdata->p_paddr);
    printk("FileSiz:    0x%x\n", elf_phdata->p_filesz);
    printk("MemSiz:     0x%x\n", elf_phdata->p_memsz);
    printk("Flg:        0x%x\n", elf_phdata->p_flags);
    printk("Align:      0x%x\n", elf_phdata->p_align);

    current->euid = e_uid;
    current->egid = e_
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值