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

news2024/11/17 11:41:07

从零编写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_identMagic: 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_shoffStart of section headers: 11128 (bytes into file)
段表在文件中的偏移
e_flags标志: 0x0
ELF 标志位,用来识别一些 ELF 文件平台相关的属性。
e_ehsizeSize of this header: 52 (bytes)
ELF 文件头大小
e_phentsizeSize of program headers: 32 (bytes)
程序头描述符的大小
e_phnumNumber of program headers: 4
程序头描述符数量
e_shentsizeSize of section headers: 40 (bytes)
段表描述符的大小
e_shnumSize of section headers: 14 (bytes)
段表描述符数量
e_shstrndxSection 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节介绍。

下面来看看运行结果。

11.2运行结果

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

程序头信息

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

这个 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 函数的地址。

程序内存划分

第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]。

argv和envp

// 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。

11.3运行结果

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 都正确打印了数值。

11.4运行结果

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

qemu汇编出错

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

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 自动补全,问题一大堆啊。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/354591.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SpringCloud(PS)远程调用--Feign

远程调用RestTemplate远程调用RestTemplate方式调用存在的问题Http客户端Feign实现步骤自定义配置Feign优化Feign性能优化——连接池配置最佳实践RestTemplate远程调用 Bean // LoadBalancedpublic RestTemplate restTemplate(){return new RestTemplate();}Autowiredprivat…

linux基本功系列之fdisk命令实战

文章目录前言一. fdisk命令介绍二. 语法格式及常用选项三. 参考案例3.1 列出每个分区的大小3.2 分区操作3.2.1 添加硬盘3.2.2 开启虚拟机并分区3.3.3 分区完成后进行格式化挂载四 . 设置分区自动挂载前言 大家好&#xff0c;又见面了&#xff0c;我是沐风晓月&#xff0c;本文…

Elasticsearch7学习笔记(尚硅谷)

文章目录一、ElasticSearch概述1、ElasticSearch是什么2、全文搜索引擎3、ElasticSearch 和 Solr3.1 概述3.2 比较总结二、Elasticsearch入门1、Elasticsearch安装1.1 下载使用1.2 数据格式2、索引操作3、文档操作&#xff08;了解&#xff09;3.1 创建文档3.2 文档查询3.3 文档…

外贸谷歌优化,外贸google SEO优化费用是多少?

本文主要分享关于做外贸网站的谷歌seo成本到底需要投入多少这一件事。 本文由光算创作&#xff0c;有可能会被剽窃和修改&#xff0c;我们佛系对待这种行为吧。 那么外贸google SEO优化费用是多少&#xff1f; 答案是&#xff1a;2w~25w。 好&#xff0c;看到这个答案先别激…

0.1opencv库VS环境配置

opencv环境配置 感谢大家学习这门教程。本系列文章首发于公众号【周旋机器视觉】。 这个这门课程的第一篇文章&#xff0c;主要是opencv环境配置。 本教程的环境为 Visual Studio 2019CMake 3.22.3opencv 4.6.0windows 10 1、opencv的源码下载与安装 直接访问opencv官网&…

Docker入门教程

文章目录一、Docker概述1. 什么是容器技术&#xff1f;2. 什么是Docker3. 为什么要使用Docker4. Docker和虚拟机的对比5. Docker相关概念6. DockerHub7. Docker架构二、安装Docker1. 安装Docker2. 配置阿里云镜像加速三、Docker常用命令1. 帮助命令2. 镜像操作命令3. 容器操作命…

mysql 8.0 忘记root密码-linux

vim /etc/my.cnf 在[mysqld]最后加上如下语句&#xff1a;skip-grant-tables 并保存退出有的配置是分开的&#xff0c;/etc/my.cnf.d/mysql-server.cnf重启mysql服务 : service mysqld restart免密码登陆: mysql -u root -ppassword校验直接回车select host, user, authenticat…

C#:Krypton控件使用方法详解(第七讲) ——kryptonHeader

今天介绍的Krypton控件中的kryptonHeader&#xff0c;下面开始介绍这个控件的属性&#xff1a;控件的样子如上图所示&#xff0c;从上面控件外观来看&#xff0c;这个控件有三部分组成。第一部分是前面的图片&#xff0c;第二部分是kryptonHeader1文本&#xff0c;第三部分是控…

前端学习第一阶段-第7章 品优购电商项目

7-1 品优购项目介绍及准备工作 01-品优购项目导读 02-网站制作流程 03-品优购项目规划 04-品优购项目搭建 05-品优购项目-样式的模块化开发 06-品优购项目-favicon图标制作 07-品优购项目-TDK三大标签SEO优化 7-2 首页Header区域实现 08-品优购首页-快捷导航shortcut结构搭建 0…

MySQL(三):切分,主从复制,读写分离

文章目录一、切分水平切分垂直切分水平切分策略二、主从复制三、读写分离一、切分 水平切分 水平切分又称为sharding,它是将同一个表中的记录拆分到多个结构相同的表中。当一个表的数据不断增多的时候&#xff0c;sharding是必然的选择&#xff0c;它可以将数据分布到集群的不…

jmap监控工具

在一个JVM进程中会存在有多个对象实例,如果要想获取所有对象的信息,就可以通过JDK提供的jmap工具完成,另外使用该工具还可以直接获取到指定进程的堆内存使用信息,开发者可以通过jmap --help 命令查看该命令相关的参数。 1、查看JVM进程中的对象信息 导致JVM性能问题的核心…

力扣sql简单篇练习(十九)

力扣sql简单篇练习(十九) 1 查询结果的质量和占比 1.1 题目内容 1.1.1 基本题目信息 1.1.2 示例输入输出 1.2 示例sql语句 # 用count是不会统计为null的数据的 SELECT query_name,ROUND(AVG(rating/position),2) quality,ROUND(count(IF(rating<3,rating,null))/count(r…

九龙证券|欧美充电桩市场快速增长 国内“桩企”出海需求旺盛

海外充电桩毛利率水平高、需求增加速&#xff0c;“桩企”出海继续。 在阿里世界站上接连三年火爆的新能源出口行业&#xff0c;本年坚持高增加下&#xff0c;涌现出新商机——新能源车充电桩。阿里世界站最新跨境指数显现&#xff0c;曩昔一年新能源车充电桩的海外商机快速增加…

SpringBoot11:分布式Dubbo、Zookeeper

什么是分布式系统&#xff1f; 建立在网络之上的软件系统&#xff0c;是若干个独立的计算机的集合&#xff0c;但是对用户来说&#xff0c;就像单个系统一样。可以利用更多的机器&#xff0c;处理更多的数据 注意&#xff1a;只有当单个节点不够用的时候&#xff0c;才需要考…

杭州电子科技大学2023年MBA招生考试成绩查询和复查申请的通知

根据往年的情况&#xff0c;2023杭州电子大学MBA考试初试成绩可能将于2月21日公布&#xff0c;最早于20号出来&#xff0c;为了广大考生可以及时查询到自己的分数&#xff0c;杭州达立易考教育为大家汇总了信息。根据教育部和浙江省教育考试院关于硕士研究生招生考试工作的统一…

volatile 关键字的使用

写博客的目的第一是做笔记&#xff0c;第二是在错误的基础上不断刷新认知&#xff0c;这两天会写三篇关于嵌入式容易混淆的知识点&#xff0c;有错误欢迎拍砖&#xff01; 1、volatile关键字的使用 关于volatile 关键字&#xff0c;如果你的理解仅仅是讲“是从内存直接取数据…

CoreDNS

目录 文章目录目录本节实战前言1、环境变量2、DNS1.DNS 解析过程2.根域名服务器3.顶级域名服务器4.权威性域名服务器5.dig 域名3、CoreDNS1.CoreDNS 扩展配置&#xff08;1&#xff09;开开启日志服务&#xff08;2&#xff09;特定域名使用自定义 DNS 服务器&#xff08;3&…

K8s集群部署(二进制安装部署详细手册)

一、简介 K8s部署主要有两种方式&#xff1a;1、Kubeadm Kubeadm是一个K8s部署工具&#xff0c;提供kubeadm init和kubeadm join&#xff0c;用于快速部署Kubernetes集群。 2、二进制 从github下载发行版的二进制包&#xff0c;手动部署每个组件&#xff0c;组成Kubernetes集群…

SSM项目搭建保姆级教程

文章目录1、什么是SSM框架1.1、持久层1.2、业务层1.3、表现层1.4、View层1.5、SpringMVC执行流程1.6、MyBatis2、SSM实战搭建2.1、创建工程2.2、添加依赖2.3、配置spring.xml文件2.4、配置web.xml文件2.5、log4j.properties2.6、准备表2.7、实体类2.8、mapper2.9、service2.10、…

GuLi商城-SpringCloud-OpenFeign测试远程调用

1. Feign 简介 Feign 是一个声明式的 HTTP 客户端&#xff0c;它的目的就是让远程调用更加简单。Feign 提供了HTTP请 求的模板&#xff0c;通过编写简单的接口和插入注解&#xff0c;就可以定义好 HTTP 请求的参数、格式、地址等信 息。Feign 整合了 Ribbon&#xff08;负载…