你管这叫操作系统源码(十三)

本文详细介绍了Linux内核中的execve函数如何加载并执行新的程序,从打开文件、读取文件头、解析exec结构、判断脚本文件到设置参数空间、调整内存布局。同时,探讨了当跳转到不存在的地址时触发的缺页中断处理流程,包括do_no_page函数如何加载所需数据到内存并建立页表映射。通过对这些底层机制的剖析,揭示了进程如何执行新程序的奥秘。
摘要由CSDN通过智能技术生成

扒开execve的皮

先打开 execve,看一下它的调用链:

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

// 调用方
execve("/bin/sh",argv_rc,envp_rc);

// 宏定义
_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

// 通过系统调用进入到这里
EIP = 0x1C
_sys_execve:
    lea EIP(%esp),%eax
    pushl %eax
    call _do_execve
    addl $4,%esp
    ret

// 最终执行的函数
int do_execve(
        unsigned long * eip,
        long tmp,
        char * filename,
        char ** argv,
        char ** envp) {
    ...
}

我们在系列之九中 通过 fork 看一次系统调用 已经详细分析了整个调用链中的栈以及参数传递的过程。所以这里我们就不再赘述,直接把这里的参数传过来的样子写出来。

eip 调用方触发系统调用时由 CPU 压入栈空间中的 eip 的指针 。

tmp 是一个无用的占位参数。

filename 是 “/bin/sh”

argv 是 { “/bin/sh”, NULL }

envp 是 { “HOME=/”, NULL }

好了,接下来我们看看整个 do_execve 函数,它非常非常长!我先把整个结构列出:

int do_execve(...) {
    // 检查文件类型和权限等
    ...
    // 读取文件的第一块数据到缓冲区
    ...
    // 如果是脚本文件,走这里
    if (脚本文件判断逻辑) {
        ...
    }
    // 如果是可执行文件,走这里
    // 一堆校验可执行文件是否能执行的判断
    ...
    // 进程管理结构的调整
    ...
    // 释放进程占有的页面
    ...
    // 调整线性地址空间、参数列表、堆栈地址等
    ...
    // 设置 eip 和 esp,这里是 execve 变身大法的关键!
    eip[0] = ex.a_entry;
    eip[3] = p;
    return 0;
    ...
}

整理起来的步骤就是:

  1. 检查文件类型和权限等

  2. 读取文件的第一块数据到缓冲区

  3. 脚本文件与可执行文件的判断

  4. 校验可执行文件是否能执行

  5. 进程管理结构的调整

  6. 释放进程占有的页面

  7. 调整线性地址空间、参数列表、堆栈地址等

  8. 设置 eip 和 esp,完成摇身一变

如果去掉一些逻辑校验和判断,那核心逻辑就是加载文件调整内存开始执行三个步骤,由于这些部分的内容已经非常复杂了,所以我们就去掉那些逻辑校验的部分,直接挑主干逻辑进行讲解,以便带大家认清 execve 的本质。

读取文件开头1KB的数据

先是根据文件名,找到并读取文件里的内容:

exec.c:

int do_execve(...) {
    ...
    // 根据文件名 /bin/sh 获取 inode
    struct m_inode * inode = namei(filename);
    // 根据 inode 读取文件第一块数据(1024KB)
    struct buffer_head * bh = bread(inode->i_dev,inode->i_zone[0]);
    ...
}

很简单,就是读取了文件(/bin/sh)第一个块,也就是 1KB 的数据,

在系列之十一中 加载根文件系统 里说过文件系统的结构,所以代码里 inode -> i_zone[0] 就刚好是文件开头的 1KB 数据。

ch17-4

OK,现在这 1KB 的数据,就已经在内存中了,但还没有解析。

解析这1KB的数据为exec结构

本质上就是按照指定的数据结构来解读罢了:

exec.c:

int do_execve(...) {
    ...
    struct exec ex = *((struct exec *) bh->b_data);
    ...
}

先从刚刚读取文件返回的缓冲头指针中取出数据部分 bh -> data,也就是文件前 1024 个字节,此时还是一段读不懂的二进制数据。 然后按照 exec 这个结构体对其进行解析,它便有了生命:

struct exec {
    // 魔数
    unsigned long a_magic;
    // 代码区长度
    unsigned a_text;
    // 数据区长度
    unsigned a_data;
    // 未初始化数据区长度
    unsigned a_bss;
    // 符号表长度
    unsigned a_syms;
    // 执行开始地址
    unsigned a_entry;
    // 代码重定位信息长度
    unsigned a_trsize;
    // 数据重定位信息长度
    unsigned a_drsize;
};

上面的代码就是 exec 结构体,这是 a.out 格式文件的头部结构,现在的 Linux 已经弃用了这种古老的格式,改用 ELF 格式了,但大体的思想是一致的。这个结构体里的字段表示什么,等我们用到了再说,你可以先通过我的注释自己体会下。

判断是脚本文件还是可执行文件

我们写一个 Linux 脚本文件的时候,通常可以看到前面有这么一坨东西:

#!/bin/sh
#!/usr/bin/python

你有没有想过为什么我们通常可以直接执行这样的文件?其实逻辑就在下面这个代码里:

exec.c:

int do_execve(...) {
    ...
    if ((bh->b_data[0] == '#') && (bh->b_data[1] == '!') {
        ...
    }
    brelse(bh);
    ...
}

可以看到,很简单粗暴地判断前面两个字符是不是 #!,如果是的话,就走脚本文件的执行逻辑。当然,我们现在的 /bin/sh 是个可执行的二进制文件,不符合这样的条件,所以这个 if 语句里面的内容我们也可以不看了,直接看外面,执行可执行二进制文件的逻辑。

第一步就是 brelse 释放这个缓冲块,因为已经把这个缓冲块内容解析成 exec 结构保存到我们程序的栈空间里了,那么这个缓冲块就可以释放,用于其他读取磁盘时的缓冲区。

准备参数空间

我们执行 /bin/sh 时,还给它传了 argc 和 envp 参数,就是通过下面这一系列代码来实现的:

exec.c

#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

int do_execve(...) {
    ...
    // p = 0x1FFFC = 128K - 4
    unsigned long p = PAGE_SIZE * MAX_ARG_PAGES - 4;
    ...
    // p = 0x1FFF5 = 128K - 4 - 7
    p = copy_strings(envc,envp,page,p,0);
    // p = 0x1FFED = 128K - 4 - 7 - 8
    p = copy_strings(argc,argv,page,p,0);
    ...
    // p = 0x3FFFFED = 64M - 4 - 7 - 8
    p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
    // p = 0x3FFFFD0
    p = (unsigned long) create_tables((char *)p,argc,envc);
    ...
    // 设置栈指针
    eip[3] = p;
}

准备参数空间的过程,同时也伴随着一个表示地址的 unsigned long p 的计算轨迹。有点难以理解,别急,我们一点点分析就会恍然大悟。

开头一行计算出的 p 值为

p = 4096 ∗ 32 − 4 = 0 x 20000 − 4 = 128 K − 4 p = 4096 * 32 - 4 = 0x20000 - 4 = 128K - 4 p=4096324=0x200004=128K4

为什么是这个数呢?整个这块讲完你就会知道,这表示参数表,每个进程的参数表大小为 128K,在每个进程地址空间的最末端

还记得之前的一张图么?

ch13-1

我们说过,每个进程通过不同的局部描述符在线性地址空间中瓜分出不同的空间,一个进程占 64M,我们单独把这部分表达出来:

ch18-1

参数表为 128K,就表示每个进程的线性地址空间的末端 128K,是为参数表保留的,目前这个 p 就指向了参数表的开始处(偏移 4 字节):

ch18-1a

接下来两个 copy_strings 就是往这个参数表里面存放信息,不过具体存放的只是字符串常量值的信息,随后他们将被引用,有点像 Java 里 class 文件的字符串常量池思想。

exec.c:

int do_execve(...) {
    ...
    // p = 0x1FFF5 = 128K - 4 - 7
    p = copy_strings(envc,envp,page,p,0);
    // p = 0x1FFED = 128K - 4 - 7 - 8
    p = copy_strings(argc,argv,page,p,0);
    ...
}

具体说来,envp表示字符串参数 "HOME=/"argv 表示字符串参数 "/bin/sh",两个 copy 就表示把这个字符串参数往参数表里存,相应地指针 p 也往下移动(共移动了 7 + 8 = 15 个字节),和压栈的效果是一样的。

ch18-1b
当然,这个只是示意图,实际上这些字符串都是紧挨着的,我们通过 debug 查看参数表位置处的内存便可以看到真正存放的方式:

ch18-2

可以看到,两个字符串乖乖地被安排在了参数表内存处,且参数与参数之间用 00 也就是 NULL 来分隔。

接下来是更新局部描述符

exec.c:

#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

int do_execve(...) {
    ...
    // p = 0x3FFFFED = 64M - 4 - 7 - 8
    p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
    ...
}

很简单,就是根据 ex.a_text 修改局部描述符中的代码段限长 code_limit,其他没动。ex 结构里的 a_text 是生成 /bin/sh 这个 a.out 格式的文件时,写在头部的值,用来表示代码段的长度。至于具体是怎么生成的,我们无需关心。

由于这个函数返回值是数据段限长,也就是 64M,所以最终的 p 值被调整为了以每个进程的线性地址空间视角下的地址偏移,大家可以仔细想想怎么算的。

ch18-3

接下来就是真正构造参数表的环节了:

exec.c:

#define PAGE_SIZE 4096
#define MAX_ARG_PAGES 32

int do_execve(...) {
    ...
    // p = 0x3FFFFD0
    p = (unsigned long) create_tables((char *)p,argc,envc);
    ...
}

刚刚仅仅是往参数表里面丢入了需要的字符串常量值信息,现在就需要真正把参数表构建起来。我们展开 create_tables

/*
 * create_tables() parses the env- and arg-strings in new user
 * memory and creates the pointer tables from them, and puts their
 * addresses on the "stack", returning the new stack pointer value.
 */
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);
    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 (argc-->0) {
        put_fs_long((unsigned long) p,argv++);
        while (get_fs_byte(p++)) /* nothing */ ;
    }
    put_fs_long(0,argv);
    while (envc-->0) {
        put_fs_long((unsigned long) p,envp++);
        while (get_fs_byte(p++)) /* nothing */ ;
    }
    put_fs_long(0,envp);
    return sp;
}

不难分析出就是把参数表空间变成了如下样子:

ch18-4

最后,将 sp 返回给 p,这个 p 将作为一个新的栈顶指针,给即将要完成替换的 /bin/sh 程序,也就是下面的代码:

int do_execve(...) {
    ...
    // 设置栈指针
    eip[3] = p;
}

为什么这样操作就可以达到更换栈顶指针的作用呢?那我们结合着更换代码指针 PC 来进行讲解。

设置eip和esp

下面这两行就是 execve 完成摇身一变的关键,解释了它为什么能做到变成一个新程序开始执行的关键密码:

int do_execve(unsigned long * eip, ...) {
    ...
    eip[0] = ex.a_entry;
    eip[3] = p; 
    ...
}

什么叫一个新程序开始执行呢?本质就是,代码指针 eip 和栈指针 esp 指向了一个新的地方

代码指针 eip 决定了 CPU 将执行哪一段指令,栈指针 esp 决定了 CPU 压栈操作的位置,以及读取栈空间数据的位置,在高级语言视角下就是局部变量以及函数调用链的栈帧。所以这两行代码:

第一行重新设置了代码指针 eip 的值,指向 /bin/sh 这个 a.out 格式文件的头结构 exec 中的 a_entry 字段,表示该程序的入口地址

第二行重新设置了栈指针 esp 的值,指向了我们经过一路计算得到的 p,也就是图中 sp 的值。将这个值作为新的栈顶十分合理。

ch18-4a

eip 和 esp 都设置好了,那么程序摇身一变的工作,自然就结束了,非常简单。至于为什么往 eip 的 0 和 3 索引位置处写入数据,就可以达到替换 eip 和 esp 的目的,那我们就得看看这个 eip 变量是怎么来的了。

计算机的世界没有魔法

还记得 execve 的调用链么?

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

// 调用方
execve("/bin/sh",argv_rc,envp_rc);

// 宏定义
_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

// 通过系统调用进入到这里
EIP = 0x1C
_sys_execve:
    lea EIP(%esp),%eax
    pushl %eax
    call _do_execve
    addl $4,%esp
    ret

// exec.c
int do_execve(unsigned long * eip, ...) {
    ...
    eip[0] = ex.a_entry;
    eip[3] = p; 
    ...
}

千万别忘了,我们这个 do_execve 函数,是通过一开始的 execve 函数触发了系统调用来到的这里。系统调用是一种中断,前面说过,中断时 CPU 会给栈空间里压入一定的信息,这部分信息是死的,查手册可以查得到:

ch14-4

然后,进入中断以后,通过系统调用查表进入到 _sys_execve 这里:

EIP = 0x1C
_sys_execve:
    lea EIP(%esp),%eax
    pushl %eax
    call _do_execve
    addl $4,%esp
    ret

看到没?在真正调用 do_execve 函数时,_sys_execve 这段代码偷偷地插入了一个小步骤,就是把当前栈顶指针 esp 偏移到 EIP 处的地址值给当做第一个参数 unsigned long * eip 传入进来了。

而偏移 EIP 处的位置,恰好就是中断时压入的 EIP 的值的位置,表示中断发生前的指令寄存器的值。所以 eip[0] 就表示栈空间里的 EIP 位置,eip[3] 就表示栈空间里的 ESP 位置。

ch14-4a

由于我们现在处于中断,所以中断返回后,也就是 do_execve 这个函数 return 之后,就会寻找中断返回前的这几个值(包括 eip 和 esp)进行恢复。这里有疑惑的同学,看下我之前写的 认真聊聊中断认认真真的聊聊"软"中断 这两篇文章,我认为把中断的原理彻底讲清楚了,不过其实就是读 CPU 手册罢了。

所以如果我们把这个栈空间里的 eip 和 esp 进行替换,换成执行 /bin/sh 所需要的 eip 和 esp,那么中断返回的**“恢复"工作,就犹如"跳转”**到一个新程序那里一样,其实是我们欺骗了 CPU,达到了 execve 这个函数的魔法效果。

小结

本篇讲解了我认为 Linux 0.11 里面最难理解的 execve 函数的核心逻辑,但其实整个顺下来你会发现,它所完成的事情也是由一个个非常简单的事情拼凑起来的。无非就是把参数表在一个空间里折腾来折腾去给构造好,然后把代码应该从哪里执行的 eip 和新的栈空间应该设置在哪里的 esp 给弄好,就完成了使命。

至于 /bin/sh 文件是怎么构造出来的,那就是 gcc 编译和链接那些事了,而且 Linux 0.11 所用的可执行文件格式 a.out,已经被现在的 ELF 格式所取代,那就更不在我们要研究的范畴,本系列还是要划分好边界问题的,不然就无休止了。

OK,至此,execve 函数就彻底结束了。

main.c:

void init(void) {
    ...
    if (!(pid=fork())) {
        close(0);
        open("/etc/rc",O_RDONLY,0);
        execve("/bin/sh",argv_rc,envp_rc);
        _exit(2);
    }
    ...
}

它的返回意味着接下来将执行 /bin/sh 这个可执行文件里的代码。不过有两个问题:

  1. /bin/sh 是躺在磁盘里的可执行文件,并不是 Linux 内核里的代码,所以其实那里面是什么,我们在 Linux 0.11 源码里是找不到的。也就是说,如果磁盘里并没有 /bin/sh 这个文件,Linux 0.11 是启动不起来的,在 open 的时候就会报错宕机了。

  2. 我们只将 /bin/sh 文件的头部加载到了内存,其他部分并没有任何代码完成加载这个操作,那接下来跳转到一个并没有加载 /bin/sh 代码的内存时,会发生什么呢?

带着这两个疑问,看后续的章节吧!

缺页中断

上节进程 2 通过 execve 函数,将自己摇身一变成为 /bin/sh 程序,也就是 shell 程序开始执行。那么此时进程 2 就是 shell 程序了。再进一步讲,相当于之前的进程 1 通过 fork + execve 这两个函数的组合,创建了一个新的进程去加载并执行了 shell 程序。

我们在 Linux 里执行一个程序,比如在命令行中 ./xxx,其内部实现逻辑都是 fork + execve 这个原理。当然,此时我们仅仅是通过 execve,使得下一条 CPU 指令将会执行到 /bin/sh 程序所在的内存起始位置处,也就是 /bin/sh 头部结构中 a_entry 所描述的地址。

但有个问题是,我们仅仅将 /bin/sh 文件的头部加载到了内存,其他部分并没有进行加载,那我们是怎么执行到的 /bin/sh 的程序指令呢?

ch18-5

跳转到一个不存在的地址会发生什么

/bin/sh 这个文件并不是 Linux 0.11 源码里的内容,Linux 0.11 只管按照 a.out 这种格式去解读它,跳转到 a.out 格式头部数据结构 exec.a_entry 所指向的内存地址去执行指令。所以这个 a_entry 的值是多少,就完全取决于硬盘中 /bin/sh 这个文件是怎么构造的了,我们简单点,就假设它为 0,这表示随后的 CPU 将跳转到 0 地址处进行执行。当然,这个 0 仅仅表示逻辑地址,既没有进行分段,也没有进行分页。

之前说过无数次了,Linux 0.11 的每个进程是通过不同的局部描述符在线性地址空间中瓜分出不同的空间,一个进程占 64M。

ch18-1

由于我们现在所处的代码是属于进程 2,所以逻辑地址 0 通过分段机制映射到线性地址空间,就是 0x8000000,表示 128M 位置处。好,128M 这个线性地址,随后将会通过分页机制的映射转化为物理地址,这才定位到最终的真实物理内存。

可是,128M 这个线性地址并没有页表映射它,也就是因为上面我们说的,我们除了 /bin/sh 文件的头部加载到了内存外,其他部分并没有进行加载操作。

再准确点说,是 0x8000000 这个线性地址的访问,遇到了页表项的存在位 P 等于 0 的情况。

一旦遇到了这种情况,CPU 会触发一个中断:页错误(Page-Fault),这在 Intel 手册 Volume-3 Chapter 4.7 章节里给出了这个信息:

页错误

当然,Page-Fault 在很多情况都会触发,具体是因为什么情况触发的,CPU 会帮我们保存在中断的出错码 Error Code 里,这在随后的 Figure 4-12 中给出了详细的出错码说明:

缺页中断异常码

这块之所以讲这么详细,因为我想让大家知道一切的原理都有最一手资料的来源,这些一手资料写的都非常详细和友好,大家完全不必道听途说,也不必毫无头绪地搜索网上的博客。当然,与本文相关的,就是这个存在位 P

当触发这个 Page-Fault 中断后,就会进入 Linux 0.11 源码中的 page_fault 方法,由于 Linux 0.11 的 page_fault 是汇编写的,很不直观,这里我选 Linux 1.0 的代码给大家看,逻辑是一样的:

void do_page_fault(..., unsigned long error_code) {
    ...   
    if (error_code & 1)
        do_wp_page(error_code, address, current, user_esp);
    else
        do_no_page(error_code, address, current, user_esp);
    ...
}

根据 error_code 的不同,有不同的逻辑。刚刚说了,这个中断是由于 0x8000000 这个线性地址的访问,遇到了页表项的存在位 P 等于 0 的情况,所以 error_code 的第 0 位就是 0,会走 do_no_page 逻辑

之前在讲系列之十 写时复制 的时候,讲了 do_wp_page,这是在 P=1 时的逻辑,文章的结尾我说过,后面会讲页表项的存在位 P 为 0 时触发的 do_no_page ,这不就来了么。

do_wp_page页写保护中断do_no_page缺页中断

好了,我们用了很大篇幅,说明白了跳转到一个 P=0 的地址会发生什么,接下来就是具体看 do_no_page 函数的逻辑咯。

缺页中断do_no_page

memory.c:

// address 缺页产生的线性地址 0x8000000
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;
    if (!(page = get_free_page()))
        oom();
/* remember that 1 block is used for header */
    block = 1 + tmp/BLOCK_SIZE;
    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();
}

我们仍然是去掉一些不重要的分支,假设跳转不会超过数据末端 end_data,也没有共享内存页面,申请空闲内存时也不会内存不足产生 oom 等,将程序简化如下:

void do_no_page(unsigned long address) {
    // 线性地址的页面地址 0x8000000
    address &= 0xfffff000;
    // 计算相对于进程基址的偏移 0
    unsigned long tmp = address - current->start_code;
    // 寻找空闲的一页内存
    unsigned long page = get_free_page();
    // 计算这个地址在文件中的哪个数据块 1
    int block = 1 + tmp/BLOCK_SIZE;
    // 一个数据块 1024 字节,所以一页内存需要读 4 个数据块
    int nr[4];
    for (int i=0 ; i<4 ; block++,i++)
        nr[i] = bmap(current->executable,block);
    bread_page(page,current->executable->i_dev,nr);
    ...
    // 完成页表的映射
    put_page(page,address);
}

首先,缺页产生的线性地址,之前假设过了,是 0x8000000,也就是进程 2 自己线性地址空间的起始处 128M 这个位置。由于我们的页表映射是以为单位的,所以首先计算出 address 所在的页,其实就是完成一次 4KB 的对齐

void do_no_page(unsigned long address) {
    // 线性地址的页面地址 0x8000000
    address &= 0xfffff000;
    ...
}

此时 address 对齐后仍然是 0x8000000。这个地址是整个线性地址空间的地址,但对于进程 2 自己来说,需要计算出相对于进程 2 的偏移地址,也就是去掉进程 2 的段基址部分:

void do_no_page(unsigned long address) {
    ...
    // 计算相对于进程基址的偏移 0
    unsigned long tmp = address - current->start_code;
    ...
}

这里的 current->start_code 就是进程 2 的段基址,也是 128M。所以偏移地址 tmp 计算后等于 0,这和之前假设的 a_entry = 0 是一致的。接下来很简单,就是寻找一个空闲页:

void do_no_page(unsigned long address) {
    ...
    // 寻找空闲的一页内存
    unsigned long page = get_free_page();
    ...
}

这个 get_free_page 是用汇编语言写的,其实就是去 mem_map[] 中寻找一个值为 0 的位置,这就表示找到了空闲内存。这部分忘记的,可以看一下系列之四中 主内存初始化 mem_init,之前苦苦建立的一些初始化的数据结构,就用上了:

ch11-1

找到一页物理内存后,当然是把硬盘中的数据加载进来,下面的代码就是完成这个工作:

void do_no_page(unsigned long address) {
    ...
    // 计算这个地址在文件中的哪个数据块 1
    int block = 1 + tmp/BLOCK_SIZE;
    // 一个数据块 1024 字节,所以一页内存需要读 4 个数据块
    int nr[4];
    for (int i=0 ; i<4 ; block++,i++)
        nr[i] = bmap(current->executable,block);
    bread_page(page,current->executable->i_dev,nr);
    ...
}

从硬盘的哪个位置开始读呢?首先 0 内存地址,应该就对应着这个文件 0 号数据块,当然由于 /bin/sh 这个 a.out 格式的文件使用了 1 个数据块作为头部 exec 结构,所以我们跳过头部,从文件 1 号数据块开始读。

**读多少块呢?**因为硬盘中的 1 个数据块为 1024 字节,而一页内存为 4096 字节,所以要读 4 块,这就是 nr[4] 的缘故。之后读取数据主要是两个函数,bmap 负责将相对于文件的数据块转换为相对于整个硬盘的数据块,比如这个文件的第 1 块数据,可能对应在整个硬盘的第 24 块的位置。

bread_page 就是连续读取 4 个数据块到 1 页内存的函数,这个函数原理就复杂了,之后第五部分会讲这块的内容,但站在用户层的效果很好理解,就是把硬盘数据复制到内存罢了。

OK,现在硬盘上所需要的内容已经被读入物理内存了。最后一步完成页表的映射

void do_no_page(unsigned long address) {
    ...
    // 完成页表的映射
    put_page(page,address);
}

这是因为我们此时仅仅是申请了物理内存页,并且把硬盘数据复制了进来,但我们并没有把这个物理内存页和线性地址空间的内存页进行映射,也就是没建立相关的页表

ch18-6

建立页表的映射,由于 Linux 0.11 使用的是二级页表,所以实际上就是写入页目录项页表项的过程,我把 put_page 函数简化了一下,只考虑页目录项还不存在的场景:

memory.c:

unsigned long put_page(unsigned long page,unsigned long address) {
    unsigned long tmp, *page_table;
    // 找到页目录项
    page_table = (unsigned long *) ((address>>20) & 0xffc);
    // 写入页目录项
    tmp = get_free_page();
    *page_table = tmp|7;
    // 写入页表项
    page_table = (unsigned long *) tmp;
    page_table[(address>>12) & 0x3ff] = page | 7;
    return page;
}

大家可以结合页目录表和页表的数据结构看一下,很简单,就是个计算过程。关于页目录表和页表这些分页相关的知识,可以回顾系列之三中的 Intel 内存管理:分段与分页,这里就不再赘述。

缺页中断返回

这就是整个缺页中断处理的过程,本质上就是加载硬盘对应位置的数据,然后建立页表的过程。再往上看,我们之前是在进程 2 里执行了 execve 函数将程序替换成 /bin/sh,也就是 shell 程序:

main.c:

void init(void) {
    ...
    if (!(pid=fork())) {
        close(0);
        open("/etc/rc",O_RDONLY,0);
        execve("/bin/sh",argv_rc,envp_rc);
        _exit(2);
    }
    ...
}

execve 函数返回后,CPU 就跳转到 /bin/sh 程序的第一行开始执行,但由于跳转到的线性地址不存在,所以引发了本节讲的缺页中断,把硬盘里 /bin/sh 所需要的内容加载到了内存,此时缺页中断返回。

返回后,CPU 会再次尝试跳转到 0x8000000 这个线性地址,此时由于缺页中断的处理结果,使得该线性地址已有对应的页表进行映射,所以顺利地映射到了物理地址,也就是 /bin/sh 的代码部分(从硬盘加载过来的),那接下来就终于可以执行 /bin/sh 程序,也就是 shell 程序了。那这个 shell 程序到底是啥呢?他的代码并不在 Linux 0.11 的源码里,所以我们的重点将不是分析它的源码,仅仅了解它的原理即可。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值