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

从零编写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 

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_gid;
    return 0;
}

eip[1] 是 cs,在用户态 cs 的值为 0xf,在内核态 cs 的值为 0x8。如果0xffff & eip[1]的结果为 0xf,说明是用户在调用程序。如果不是 0xf,那就可能是内核在调用该函数,明显有问题。

namei 的功能与 open_namei 相似,它会根据文件路径找到文件的 inode。如果没找到,说明文件路径有问题。

可执行文件是普通文件,如果你想执行一个目录或字符设备,那肯定是不能执行的。

用户必须对文件有执行权限。如果你使用chmod 666 main把可执行文件的执行权限清理掉,那肯定也是不能执行的。

第31-33行:使用 bread 将文件头和程序头的信息读取到内存中,它们都在可执行文件的第一个逻辑块中。

第37-39行:elf 文件的前4个字符必须是 0x7f 和 ELF。不然这就不是一个 elf 文件。

第41-42行:我们的代码只能在x86 CPU 平台上运行。ET_EXEC 代表这是可执行文件,ET_REL 代表是可重定位文件(一般为 .o),ET_DYN代表是共享目标文件(一般为 .so)。

第44-52行:找到 elf 文件的程序头,并将程序头信息打印出来。

// namei.c
struct m_inode *namei(const char *pathname)
{
    const char *basename;
    int inr, dev, namelen;
    struct m_inode *dir;
    struct buffer_head *bh;
    struct dir_entry *de;

    dir = dir_namei(pathname, &namelen, &basename);
    if (!dir) {
        return NULL;
    }
    if (!namelen) {
        return dir;
    }
    bh = find_entry(&dir, basename, namelen, &de);
    if (!bh) {
        iput(dir);
        return NULL;
    }
    inr = de->inode;
    dev = dir->i_dev;
    brelse(bh);
    iput(dir);
    dir = iget(dev, inr);
    if (dir) {
        dir->i_atime = CURRENT_TIME;
        dir->i_dirt = 1;
    }
    return dir;
}

dir_namei 会找到文件所在目录的 inode,find_entry 会在目录逻辑块中找到文件名,通过 de 返回文件 inode 号,iget 会读取文件的 inode,最后将文件的 inode 指针返回。

// main.c
static char *argv_rc[] = {"/bin/sh", NULL};
static char *envp_rc[] = {"HOME=/", NULL};

void init(void)
{
    setup();
    open("/dev/tty0", O_RDWR, 0);
    dup(0);
    dup(0);
    if (fork() == 0)
        execve("/usr/root/main", argv_rc, envp_rc);
    while (1);
}

我们使用操作系统难道就只是运行操作系统内部的代码吗?肯定不是,连个 shell 都没有,谁愿意用啊!所以就需要用 execve 函数运行自己的程序。但完整的 execve 函数会将当前进程的代码替换为可执行文件的代码,而 init 的代码还没运行完,自然不希望代码被替换掉,于是乎,就创建子进程,在子进程中执行 execve。

main 是通过 libc 目录中的代码编译得到的可执行文件,将文件放入 rootimage 的方法会在第4节介绍。

下面来看看运行结果。

从零编写linux0.11 - 第十一章 可执行文件_第1张图片

这个结果对不对呢?下面是 readelf 读取的程序头信息。

从零编写linux0.11 - 第十一章 可执行文件_第2张图片

可以看到,二者是一样的。

这个 0x80000000 的虚拟地址是怎么来的?这是由 libc 目录下的 linker.lds 文件指定的。这里将程序的入口设置为 start 函数,代码段的起始地址设置为 0x8000000。

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(start)
SECTIONS
{
    . = 0x80000000;
    .text :
    {
        _text = .;
        *(.text)
        _etext = .;
    }
    . = ALIGN(8);
    .data :
    {
        _data = .;
        *(.data)
        _edata = .;
    }
    .bss :
    {
        _bss = .;
        *(.bss)
        _ebss = .;
    }
    _end = .;
}

3.传递参数和环境变量

// main.c
static char *argv_rc[] = {"/bin/sh", NULL};
static char *envp_rc[] = {"HOME=/", NULL};

void init(void)
{
    setup();
    open("/dev/tty0", O_RDWR, 0);
    dup(0);
    dup(0);
    if (fork() == 0)
        execve("/usr/root/main", argv_rc, envp_rc);
    while (1);
}

argv_rc 和 envp_rc 必须以 NULL 结尾。argv_rc 是传入的参数,envp 是环境变量。调用 fork 后子进程也能访问到这两个变量,但是 execve 函数会清除页表,argv 和 envp 的内容就会被丢弃,那怎么传递这两个变量呢?

execve 不仅会清除页表,也会向进程添加页面。这一节我们会为进程添加页面作为栈,把变量写入栈中。

// 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, argc, envc;
    int e_uid, e_gid;
    unsigned long page[MAX_ARG_PAGES];                  // 页面地址
    unsigned long p = PAGE_SIZE * MAX_ARG_PAGES - 4;    // 栈地址
    unsigned long base;
    unsigned int elf_entry, elf_brk;
    unsigned int start_code, end_code, end_data;
    int retval;

    if ((0xffff & eip[1]) != 0x000f)
        panic("execve called from supervisor mode");
    for (i = 0; i < MAX_ARG_PAGES; i++)
        page[i] = 0;
    ...
    elf_phdata = (struct elf_phdr *)(bh->b_data + elf_ex.e_phoff);
    if (elf_phdata->p_type == PT_LOAD) {
        start_code = elf_phdata->p_vaddr;
        end_code = elf_phdata->p_vaddr + elf_phdata->p_filesz;
        end_data = end_code;
        elf_brk = elf_phdata->p_vaddr + elf_phdata->p_memsz;
    }
    elf_entry = (unsigned int)elf_ex.e_entry - ELF_START_MMAP;
    brelse(bh);
    
    argc = count(argv);
    envc = count(envp);
    p = copy_strings(argc, argv, page, p);
    p = copy_strings(envc, envp, page, p);
    if (!p) {
        retval = -ENOMEM;
        goto exec_error;
    }

    base = get_base(current->ldt[1]);   // 代码段基地址
    free_page_tables(base, get_limit(0x0f));
    free_page_tables(base, get_limit(0x17));
    p = (unsigned long)create_tables((char *)p, argc, envc);
    p += change_ldt(end_code - start_code, page) - MAX_ARG_PAGES * PAGE_SIZE;

    current->brk = elf_brk - ELF_START_MMAP + base;
    current->end_code = end_code - ELF_START_MMAP + base;
    current->end_data = end_data - ELF_START_MMAP + base;
    current->start_stack = p;
    current->euid = e_uid;
    current->egid = e_gid;

    eip[0] = elf_entry;	// eip
    eip[3] = p;			// esp
    return 0;
exec_error:
    iput(inode);
    for (i = 0; i < MAX_ARG_PAGES; i++)
        free_page(page[i]);
    return retval;
}

这一次添加了不少变量。page 数组用来保存栈页面的基地址,老实说一个页面都根本用不完,毕竟页面的大小为 4K,我们不会传这么多参数给程序。p 代表了栈地址,之后会把它赋值给 esp。

第22-29行:程序的第一个程序头必定是 LOAD 段,但程序可能不只有一个 LOAD 段,这里只是处理简单的情况,更复杂的情况以后再讨论。这几行代码会获取程序的相关信息。之前说过,p_vaddr 到 p_filesz 是代码段和数据段,p_filesz 到 p_memsz 之间是 bss 段,p_memsz 以上的空间是堆和栈,堆从 bss 段之后开始向上增长,栈从进程的数据段界限开始向下增长,如下图所示。elf_entry 代表了 main 函数的地址。

从零编写linux0.11 - 第十一章 可执行文件_第3张图片

第32-33行:计算参数和环境变量的个数。

第34-35行:申请页面用来保存 argv 和 envp 里的字符串,由于保存了字符串,栈指针也会发生改变。

第36-39行:由于 copy_strings 函数会申请页面,出现错误的情况下,需要将页面释放掉。

第42-43行:释放进程拥有的所有的页面,第41行释放进程代码段的页面,第42行释放进程数据段的页面。

第44行:设置进程的代码段和数据段的长度。将栈的页面添加到进程的地址空间中,这个过程会修改页目录项和页表项。

第45行:创建指向参数和环境变量的指针。copy_strings 只是将 “/bin/sh” 字符串写入页面中,我们还需要创建指向字符串的指针,以及指向指针的指针(argv 和 envp)。具体情况参见下面的图。

第54-55行:设置栈里的 eip 和 esp。退出系统调用时会将栈里的值加载到寄存器中。

第58-61行:如果发生错误,需要将 inode 和申请的页面释放掉,并返回错误号。

// exec.c
static int count(char **argv)
{
    int i = 0;
    char **tmp = argv;

    if (tmp)
        while (get_fs_long((unsigned long *)(tmp++)))
            i++;

    return i;
}

计算参数个数和环境变量个数的函数不难,因为 argv 和 envp 必须以 NULL 结尾,直接遍历就能得到数量。

// exec.c
static unsigned long copy_strings(int argc, char **argv, unsigned long *page, unsigned long p)
{
    char *tmp, *pag = NULL;
    int len, offset = 0;

    while (argc-- > 0) {
        // 获取字符串的首地址
        tmp = (char *)get_fs_long(((unsigned long *)argv) + argc);
        if (!tmp)
            panic("argc is wrong");
        // 计算字符串的长度
        len = 0;
        do {
            len++;
        } while (get_fs_byte(tmp++));
        if (p - len < 0) {
            return 0;
        }
        // 将字符串写入页面
        while (len) {
            --p;
            --tmp;
            --len;
            if (--offset < 0) {
                offset = p % PAGE_SIZE;
                pag = (char *)page[p / PAGE_SIZE];
                if (!pag) {
                    page[p / PAGE_SIZE] = get_free_page();
                    pag = (char *)page[p / PAGE_SIZE];
                    if (!pag)
                        return 0;
                }
            }
            *(pag + offset) = get_fs_byte(tmp);
        }
    }
    return p;
}

参数 page 数组用来保存栈的页面,参数 p 是新栈的栈指针。

这个函数主要有三步:获取字符串的首地址,计算字符串的长度,将字符串写入页面,直至把所有字符串都写入到页面中。我们为参数和环境变量预留了32个页面,即 128KB 空间,除非是故意,不然是不会将这32个页面用完,然后运行到第18行代码。如果没有足够的页面存放字符串,就申请空闲页面,并将地址存入 page 数组中。

我们的程序一般会将 main 函数定位为int main(int argc, char *argv[])的形式,从未访问到参数。argc 是参数个数,argv 是字符指针数组,通过它可以访问到一系列指向字符串的指针。我们需要将这两个值连同环境变量指针一起入栈。

// exec.c
static unsigned long *create_tables(char *p, int argc, int envc)
{
    unsigned long *argv, *envp;
    unsigned long *sp;

    sp = (unsigned long *)(0xfffffffc & (unsigned long)p);  // 4字节对齐
    sp -= envc + 1;
    envp = sp;
    sp -= argc + 1;
    argv = sp;
    put_fs_long((unsigned long)envp, --sp);
    put_fs_long((unsigned long)argv, --sp);
    put_fs_long((unsigned long)argc, --sp);
    while (envc-- > 0) {
        put_fs_long((unsigned long)p, envp++);
        while (get_fs_byte(p++)); // 找到下一个字符串的首地址
    }
    put_fs_long(0, envp);
    while (argc-- > 0) {
        put_fs_long((unsigned long)p, argv++);
        while (get_fs_byte(p++));
    }
    put_fs_long(0, argv);
    return sp;
}

得知了参数个数和环境变量个数之后,就能计算出 argc 和 envp 的值,它们都指向指针数组,数组里的指针再指向字符串的首地址,数组以0结尾。

第15-23行代码将指针从低地址到高地址依次存放,都以0结尾。最后的结果应该如下所示。0xbffffd0 是 argc,0xbffffd4 是 argv,0xbffffd8 是 envp,0xbffffdc 是 argv[0],0xbffffe0 是 argv[1],0xbffffe4 是 envp[0],0xbffffe8 是 envp[1]。

从零编写linux0.11 - 第十一章 可执行文件_第4张图片

// memory.c
unsigned long put_page(unsigned long page, unsigned long address)
{
    unsigned long tmp, *page_table;

    if (page < LOW_MEM || page >= HIGH_MEMORY)
        printk("Trying to put page %p at %p\n", page, address);
    if (mem_map[(page - LOW_MEM) >> 12] != 1)
        printk("mem_map disagrees with %p at %p\n", page, address);
    page_table = (unsigned long *)((address >> 20) & 0xffc);    // 页目录项
    if (*page_table & 1)    // 是否存在页表
        page_table = (unsigned long *)(0xfffff000 & *page_table);
    else {
        tmp = get_free_page();
        if (!tmp)
            return 0;
        *page_table = tmp | 7;  // 页表已存在,可读可写,用户可访问页表中的页
        page_table = (unsigned long *)tmp;
    }
    page_table[(address >> 12) & 0x3ff] = page | 7; // 页面已存在,可读可写,用户可访问该页
    return page;
}

put_page 会将页面映射到进程的地址空间中。page 是页面的物理地址,address 是要映射的虚拟地址。假如页面的物理地址是 0x100000,要映射的虚拟地址是 0x8000000,映射之后,我们就能通过访问 0x8000000 地址就能得到 0x100000 地址的数据。使用虚拟地址的主要原因是减少内存资源浪费,具体请看操作系统教科书。

第10行代码会找到页目录项,它记载了页表的地址和状态。页目录项的最低位是 P(Present),如果 P 为1说明页表存在,第12行得到页表的首地址。如果 P 为0说明页表不存在,我们需要先创建页表,将页表地址和状态保存到页目录项。

第20行会设置页表项的值,高20位是页面的物理地址,低12位是页面的属性。

因为 put_page 的页面之前没被使用过,不在高速缓冲中,所以不需要 invalidate 刷新高速缓冲。

// sched.h
#define _set_limit(addr, limit)         \
    __asm__("push %%edx\n\t"            \
            "movw %%dx,%0\n\t"          \
            "rorl $16,%%edx\n\t"        \
            "movb %1,%%dh\n\t"          \
            "andb $0xf0,%%dh\n\t"       \
            "orb %%dh,%%dl\n\t"         \
            "movb %%dl,%1\n\t"          \
            "pop %%edx"                 \
            :: "m"(*(addr)),            \
               "m"(*((addr) + 6)),      \
               "d"(limit))

#define set_limit(ldt, limit) _set_limit(((char *)&(ldt)), (limit - 1) >> 12)

// exec.c
static unsigned long change_ldt(unsigned long text_size, unsigned long *page)
{
    unsigned long code_limit, data_limit, data_base;
    int i;

    code_limit = text_size + PAGE_SIZE - 1;
    code_limit &= 0xFFFFF000;
    data_limit = 0x4000000;
    set_limit(current->ldt[1], code_limit);
    set_limit(current->ldt[2], data_limit);
    data_base = get_base(current->ldt[2]);
    data_base += data_limit;
    for (i = MAX_ARG_PAGES - 1; i >= 0; i--) {
        data_base -= PAGE_SIZE;
        if (page[i])
            put_page(page[i], data_base);
    }
    return data_limit;
}

这个函数会设置进程代码段和数据段的长度,并且将栈所在的页面映射到进程的地址空间中。_set_limit 需要参考 gdt 的结构进行理解。

其实 execve 函数的内容已经差不多了,最多再添加几行代码。不知道我这么说会不会让你觉得很奇怪。毕竟,我们还没读取代码段和数据段到内存中,页面映射也没有做,怎么就快完了?其实,加载代码段和数据段的代码并不在 execve 中。那么在哪里加载代码和数据呢?

让我们捋一下代码。首先在用户态调用 execve 函数,进入内核调用 sys_execve,再调用 do_execve,释放进程的所有页面,将栈的页面映射到进程的地址空间,设置 eip 和 esp ,退出系统调用。退出系统调用后会发生什么事情?进程没有 eip 地址所在的页面,于是触发 page fault。上次讲 page fault 还是在进程创建的时候。

# page.s
page_fault:
    xchgl %eax, (%esp)
    pushl %ecx
    pushl %edx
    push %ds
    push %es
    push %fs
    movl $0x10, %edx
    mov %dx, %ds
    mov %dx, %es
    mov %dx, %fs
    movl %cr2, %edx     # 获得触发异常的线性地址
    pushl %edx
    pushl %eax
    testl $1, %eax
    jne 1f
    call do_no_page
    je 2f
1:  call do_wp_page
2:  addl $8, %esp
    pop %fs
    pop %es
    pop %ds
    popl %edx
    popl %ecx
    popl %eax
    iret

第3行将出错原因保存在 eax 中,此时 eax 的值为 4,意思是用户态触发错误,读操作触发错误,由一个不存在的页触发错误。fork 后子进程写数据触发的 page fault,eax 的值是5。所以我们能够通过 eax 的最低位判断是哪种原因触发的错误。

// memory.c
void do_no_page(unsigned long error_code, unsigned long address)
{
    panic("execve incurs page fault!");
}

具体的处理将在下一节介绍,这一节就简单地打印一句话就好了。运行结果也确实触发了 page fault。

从零编写linux0.11 - 第十一章 可执行文件_第5张图片

4.加载代码和数据

我们想在缺页异常中读取可执行文件的代码和数据,不过在缺页异常中我们不知道文件路径或者其他的信息,怎么办呢?我们可以在 task_struct 结构体中添加一个成员,用于记录可执行文件的 inode。

// sched.h
struct task_struct {
    ...
    struct m_inode *pwd;    // 当前目录的inode
    struct m_inode *root;   // 根目录的inode
    struct m_inode *executable; // 可执行文件的inode
    unsigned long close_on_exec;    // 运行可执行文件时关闭文件句柄位图
    struct file *filp[NR_OPEN]; // 进程打开的文件
    struct desc_struct ldt[3];  // 任务局部描述符表。0-空,1-代码段,2-数据和堆栈段
    struct tss_struct tss;      // 进程的任务状态段信息
};

executable 就是新添加的成员,我们需要在 fork 和 exit 中对它进行处理,处理方法与 pwd 和 root 一样。

// exec.c
int do_execve(unsigned long *eip, long tmp, char *filename, char **argv, char **envp)
{
    ...
    if (current->executable)
        iput(current->executable);
    current->executable = inode;
    for (i = 0; i < 32; i++)
        current->sigaction[i].sa_handler = NULL;

    eip[0] = elf_entry; // eip
    eip[3] = p;         // esp
    return 0;
exec_error:
    iput(inode);
    for (i = 0; i < MAX_ARG_PAGES; i++)
        free_page(page[i]);
    return retval;
}

在 do_execve 中我们要设置 executable 的值,这样才能在缺页异常中使用,另外这里还初始化了信号处理函数。

// memory.c
void do_no_page(unsigned long error_code, unsigned long address)
{
    int nr[4];
    unsigned long tmp;
    unsigned long page;
    int block, i;

    address &= 0xfffff000;
    tmp = address - current->start_code;
    if (!current->executable || tmp >= current->end_data) {
        get_empty_page(address);
        return;
    }
    if (share_page(tmp))
        return;
    page = get_free_page();
    if (!page)
        oom();

    block = 4 + tmp / BLOCK_SIZE;   // 从第4个逻辑块开始才是LOAD段
    for (i = 0; i < 4; block++, i++)
        nr[i] = bmap(current->executable, block);
    bread_page(page, current->executable->i_dev, nr);
    i = tmp + 4096 - current->end_data;
    tmp = page + 4096;
    while (i-- > 0) {
        tmp--;
        *(char *)tmp = 0;
    }
    if (put_page(page, address))
        return;
    free_page(page);
    oom();
}

address 是出现缺页异常的虚拟地址,第9行代码得到所缺页面的首地址。tmp 代表页面相对代码段起始地址的偏移。

第11-14行:进程没有加载可执行文件,或者缺页的地址超出了界限,这些情况明显出现了问题,就只为其映射一个空闲页面。

第15行:判断可执行文件是否被其他进程加载到内存中,如果已经加载到内存中,只需要将页面映射到当前进程中就可以了。如果没有,就接着执行下面的代码。

第17行:申请一个空闲的页面,

第21行:之前的小节曾讲过,LOAD段在文件中的偏移是 0x1000,就是4个逻辑块的大小,所以我们需要跳过开始的4个逻辑块。

第22-23行:系统的1页是 4K,所以需要读取4个逻辑块,这里是在寻找逻辑块的块号。

第24行:将逻辑块读取到页面中。

第25-30行:将页面的剩余空间填充0。假如可执行文件的代码数据的总长度是4000,将这4000个字符读取到页面中,页面剩余的96个字符需要填充0。

第31行:将页面映射到进程的地址空间中,退出缺页异常后就能够正常运行了。

// memory.c
static int share_page(unsigned long address)
{
    struct task_struct **p;

    if (!current->executable)
        return 0;
    if (current->executable->i_count < 2)
        return 0;
    for (p = &LAST_TASK; p > &FIRST_TASK; --p) {
        if (!*p)
            continue;
        if (current == *p)
            continue;
        if ((*p)->executable != current->executable)
            continue;
        if (try_to_share(address, *p))
            return 1;
    }
    return 0;
}

首先检查是否加载了可执行文件。除了当前进程外,至少还得有一个进程加载了该可执行文件,这样才能共享可执行文件,所以 i_count 值至少为2。

第10-19行:检查是否有其他进程加载了这个可执行文件,如果有,就进入 try_to_share 函数。

// memory.c
static int try_to_share(unsigned long address, struct task_struct *p)
{
    unsigned long from;
    unsigned long to;
    unsigned long from_page;
    unsigned long to_page;
    unsigned long phys_addr;

    from_page = to_page = ((address >> 20) & 0xffc);
    from_page += ((p->start_code >> 20) & 0xffc);
    to_page += ((current->start_code >> 20) & 0xffc);

    from = *(unsigned long *)from_page;
    if (!(from & 1))
        return 0;
    from &= 0xfffff000;
    from_page = from + ((address >> 10) & 0xffc);
    phys_addr = *(unsigned long *)from_page;
    
    if ((phys_addr & 0x41) != 0x01)    // 页面是否被修改
        return 0;
    phys_addr &= 0xfffff000;
    if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM)
        return 0;
    to = *(unsigned long *)to_page;
    if (!(to & 1)) {    // 当前进程是否存在该页表
        to = get_free_page();
        if (to)
            *(unsigned long *)to_page = to | 7;
        else
            oom();
    }
    to &= 0xfffff000;
    to_page = to + ((address >> 10) & 0xffc);   // 找到页表项
    if (1 & *(unsigned long *)to_page)
        panic("try_to_share: to_page already exists");
    
    *(unsigned long *)from_page &= ~2;	// 只读
    *(unsigned long *)to_page = *(unsigned long *)from_page;    // 修改页表项
    invalidate();
    phys_addr -= LOW_MEM;
    phys_addr >>= 12;
    mem_map[phys_addr]++;   // 引用数加1
    return 1;
}

address = (当前进程所缺页面的虚拟地址 - 进程代码段起始地址),p 代表另一个加载了相同可执行文件的进程。我们得先检查另一个进程是否已经读取了这个页面的内容,如果没读取,就无法共享。

第10-12:计算两个进程的页目录项。找到页面对应的页表地址。

第14-16行:如果另一个进程还不存在这个页表,说明该进程还未读取该页面,就无法共享页面。

第17-19行:找到另一个进程的页表项。

第21-22行:页表项的第7位是脏位,如果页面数据被修改过,我们肯定不能给当前进程使用这个页面。

第26-33行:检查当前进程是否存在该页表,如果没有页表就创建一个,并赋予页表属性。

第34-37行:找到当前进程的页表项,如果该页表项已经映射了页面,那么肯定是出问题了。

第39-40行:将页面设置为只读,不然一个进程修改了数据,另一个进程访问这个数据得到的不是预期结果。不过这样会出问题吧,如果共享的是数据段,那么岂不是不让修改了。

第42-44行:更新页面的引用计数。

get_empty_page 函数很简单,我就不多讲了。

// buffer.c
#define COPYBLK(from, to)   \
    __asm__("cld\n\t"       \
            "rep\n\t"       \
            "movsl\n\t"     \
            :: "c"(BLOCK_SIZE / 4), \
            "S"(from), "D"(to));

void bread_page(unsigned long address, int dev, int b[4])
{
    struct buffer_head *bh[4];
    int i;

    for (i = 0; i < 4; i++)
        if (b[i]) {
            bh[i] = getblk(dev, b[i]);
            if (bh[i])
                if (!bh[i]->b_uptodate)
                    ll_rw_block(READ, bh[i]);
        }
        else
            bh[i] = NULL;
    for (i = 0; i < 4; i++, address += BLOCK_SIZE)
        if (bh[i]) {
            wait_on_buffer(bh[i]);
            if (bh[i]->b_uptodate)
                COPYBLK((unsigned long)bh[i]->b_data, address);
            brelse(bh[i]);
        }
}

bread_page 会读取4个逻辑块到文件缓冲区,再把数据从文件缓冲区转移到页面中。一个文件缓冲区的大小是1K,没办法做映射。

好了,终于说完了操作系统的所有代码,来看看要运行结果了。如下所示,可以看到无论是 argc 还是 argv 都正确打印了数值。

从零编写linux0.11 - 第十一章 可执行文件_第6张图片

等等,qemu 的汇编数据有点问题啊。这是缺页异常后回到用户态的情况,Assembly 标签下的数据有点问题,和 main 里的数据不匹配。我只能说,这是正常情况。可以看到下面打印的 0x8000000 地址的数据是正常的,继续执行程序也是没有问题的。

从零编写linux0.11 - 第十一章 可执行文件_第7张图片

下面是将可执行文件放入软盘的方法。

mkdir dir
sudo mount -t minix chapter_11/4th/rootimage dir
cp libc/main dir/usr/root/
sync
sudo umount dir
rmdir dir

这里假设是在工程的根目录下执行。将文件系统以 minix 文件系统格式挂载到 dir 目录,将可执行文件放入目录中,同步数据,然后解挂文件系统。

这样你就可以尝试运行自己的程序了。不过请注意,目前的 C 库几乎什么也没有,所以也不能完成稍难的任务。当然,你也可以尝试自己完成 C 库。

linux0.11 的内存模型只支持与地址无关的程序,在使用静态库的情况下,代码过多会被编译成与地址相关的程序,这种程序是无法在本系统中使用的。因为没有动态库的解析代码,所以没法使用动态库。就只能编点小程序自娱自乐。感觉这个功能好鸡肋呀。

本章小节

这章的代码参考了 linux0.11 和 linux1.2 的可执行文件相关的代码,一开始出现了不少 bug,比如一开始编译出与地址相关的程序,一直没发现问题在哪儿;又比如 qemu 上的可执行文件的汇编代码不对,我一直以为是没加载对。

现在在看 shell 的代码,一开始我准备是看 bash 源码的,但是 parse.y 我看不懂,又没有 lex 文件,直接放弃了。之后想编译一个32位的 bash 程序,但是最后编出来的程序是与地址相关的,搞不定啊!没办法,只能用 busybox 凑合了,不知道怎么在 busybox 上做 TAB 自动补全,问题一大堆啊。

你可能感兴趣的:(linux0.11,操作系统,linux,操作系统)