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

透过fork看进程的内存规划

上篇讲到fork函数的原理,即sys_fork函数所干的事情就是调用两个函数,分别是_find_empty_process_copy_process。上篇讲到copy_process()中的get_free_page(),本篇来讲剩余的部分copy_mem(nr,p),这将会决定进程之间的内存规划问题。

int copy_process(int nr, ...) {
    ...
    copy_mem(nr,p);
    ...
}

整个函数不长,我们还是试着先直译一下。

int copy_mem(int nr,struct task_struct * p) {
    // 局部描述符表 LDT 赋值
    unsigned long old_data_base,new_data_base,data_limit;
    unsigned long old_code_base,new_code_base,code_limit;
    code_limit = get_limit(0x0f);
    data_limit = get_limit(0x17);
    new_code_base = nr * 0x4000000;
    new_data_base = nr * 0x4000000;
    set_base(p->ldt[1],new_code_base);
    set_base(p->ldt[2],new_data_base);
    // 拷贝页表
    old_code_base = get_base(current->ldt[1]);
    old_data_base = get_base(current->ldt[2]);
    copy_page_tables(old_data_base,new_data_base,data_limit);
    return 0;
}

其实就是新进程 LDT 表项的赋值,以及页表的拷贝

LDT的赋值

先看 LDT 表项的赋值,要说明白这个赋值的意义,得先回忆一下我们在系列之三中 Intel 内存管理:分段与分页 刚设置完页表时说过的问题。

程序员给出的逻辑地址最终转化为物理地址要经过这几步骤。

ch08-1

而我们已经开启了分页,那么分页机制的具体转化是这样的。

ch08-2

因为有了页表的存在,所以多了线性地址空间的概念,即经过分段机制转化后,分页机制转化前的地址。

不考虑段限长的话,32 位的 CPU 线性地址空间应为 4G。现在只有四个页目录表,也就是将前 16M 的线性地址空间,与 16M 的物理地址空间一一对应起来了。

ch08-6

把这个图和全局描述符表 GDT 联系起来,这个线性地址空间,就是经过分段机制(段可能是 GDT 也可能是 LDT)后的地址,是这样对应的。

ch16-1

我们给进程 0 准备的 LDT 的代码段和数据段,段基址都是 0,段限长是 640K。给进程 1,也就是我们现在正在 fork 的这个进程,其代码段和数据段还没有设置。

所以第一步,局部描述符表 LDT 的赋值,就是给上图中那两个还未设置的代码段和数据段赋值。其中段限长,就是取自进程 0 设置好的段限长,也就是 640K。

int copy_mem(int nr,struct task_struct * p) {
    ...
    code_limit = get_limit(0x0f);
    data_limit = get_limit(0x17);
    ...
}

段基址有点意思,是取决于当前是几号进程,也就是 nr 的值。

int copy_mem(int nr,struct task_struct * p) {
    ...
    new_code_base = nr * 0x4000000;
    new_data_base = nr * 0x4000000;
    ...
}

这里的 0x4000000 等于 64M。也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 64M 的空间(暂不考虑段限长),且紧挨着。

接着就把 LDT 设置进了 LDT 表里。

int copy_mem(int nr,struct task_struct * p) {
    ...
    set_base(p->ldt[1],new_code_base);
    set_base(p->ldt[2],new_data_base);
    ...
}

最终效果如图:

ch16-2

经过以上的步骤,就通过分段的方式,将进程映射到了相互隔离的线性地址空间里,这就是段式管理。当然,Linux 0.11 不但是分段管理,也开启了分页管理,最终形成段页式的管理方式。这就涉及到下面要说的,页表的复制。

页表的复制

int copy_mem(int nr,struct task_struct * p) {
    ...
    // old=0, new=64M, limit=640K
    copy_page_tables(old_data_base,new_data_base,data_limit)
}

原来进程 0 有一个页目录表四个页表,将线性地址空间的 0-16M 原封不动映射到了物理地址空间的 0-16M(见上节图)。

那么新诞生的这个进程 2,也需要一套映射关系的页表,那我们看看这些页表是怎么建立的。

/*
 *  Well, here is one of the most complicated functions in mm. It
 * copies a range of linerar addresses by copying only the pages.
 * Let's hope this is bug-free, 'cause this one I don't want to debug :-)
 */
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
    unsigned long * from_page_table;
    unsigned long * to_page_table;
    unsigned long this_page;
    unsigned long * from_dir, * to_dir;
    unsigned long nr;

    from_dir = (unsigned long *) ((from>>20) & 0xffc);
    to_dir = (unsigned long *) ((to>>20) & 0xffc);
    size = ((unsigned) (size+0x3fffff)) >> 22;
    for( ; size-->0 ; from_dir++,to_dir++) {
        if (!(1 & *from_dir))
            continue;
        from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
        to_page_table = (unsigned long *) get_free_page()
        *to_dir = ((unsigned long) to_page_table) | 7;
        nr = (from==0)?0xA0:1024;
        for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
            this_page = *from_page_table;
            if (!(1 & this_page))
                continue;
            this_page &= ~2;
            *to_page_table = this_page;
            if (this_page > LOW_MEM) {
                *from_page_table = this_page;
                this_page -= LOW_MEM;
                this_page >>= 12;
                mem_map[this_page]++;
            }
        }
    }
    invalidate();
    return 0;
}

注释是 Linus 自己写的,他说:“这部分是内存管理中最复杂的代码,希望这段代码没有错误(bug-free),因为我实在不想调试它!”,可见这是一套让 Linus 都觉得烧脑的逻辑。虽说代码实现很复杂,但要完成的事情确实非常简单!

现在进程 0 的线性地址空间是 0 - 64M,进程 1 的线性地址空间是 64M - 128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0 最终被映射到的物理空间都是 0 - 64M,这样进程 1 才能顺利运行起来,不然就乱套了。

ch16-3

总之,最终的效果就是:

假设现在正在运行进程 0,代码中给出一个虚拟地址 0x03,由于进程 0 的 LDT 中代码段基址是 0,所以线性地址也是 0x03,最终由进程 0 页表映射到物理地址 0x03 处。

假设现在正在运行进程 1,代码中给出一个虚拟地址 0x03,由于进程 1 的 LDT 中代码段基址是 64M,所以线性地址是 64M + 3,最终由进程 1 页表映射到物理地址也同样是 0x03 处。

ch16-4

即,进程 0 和进程 1 目前共同映射物理内存的前 640K 的空间。

至于如何将不同地址通过不同页表映射到相同物理地址空间,很简单,举个刚刚的例子。

刚刚的进程 1 的线性地址 64M + 0x03 用二进制表示是:

0000010000_0000000000_000000000011

刚刚的进程 0 的线性地址 0x03 用二进制表示是:

0000000000_0000000000_000000000011

根据分页机制的转化规则,前 10 位表示页目录项,中间 10 位表示页表项,后 12 位表页内偏移。进程 1 要找的是页目录项 16 中的第 0 号页表,进程 0 要找的是页目录项 0 中的第 0 号页表,那只要让这俩最终找到的两个页表里的数据一模一样即可。

我居然会认为权威书籍写错了…

由于理解起来非常简单,但代码中的计算就非常绕,所以我们就不细致分析代码了,只要理解其最终的作用就好。

本节内容讲完了,再稍稍展开一个未来要说的东西。还记得页表的结构吧?

ch08-3

其中 RW 位表示读写状态,0 表示只读(或可执行),1表示可读写(或可执行)。当然,在内核态也就是 0 特权级时,这个标志位是没用的。

那我们看下面的代码:

int copy_page_tables(unsigned long from,unsigned long to,long size) {
    ...
    for( ; size-->0 ; from_dir++,to_dir++) {
        ...
        for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
            ...
            this_page &= ~2;
            ...
            if (this_page > LOW_MEM) {
                *from_page_table = this_page;
                ...
            }
        }
    }
    ...
}

~2 表示取反,2 用二进制表示是 10,取反就是 01,其目的是把 this_page 也就是当前的页表的 RW 位置零,也就是是把该页变成只读。而 *from_page_table = this_page 表示又把源页表也变成只读。也就是说,经过 fork 创建出的新进程,其页表项都是只读的,而且导致源进程的页表项也变成了只读。

这个就是写时复制的基础,新老进程一开始共享同一个物理内存空间,如果只有读,那就相安无事,但如果任何一方有写操作,由于页面是只读的,将触发缺页中断,然后就会分配一块新的物理内存给产生写操作的那个进程,此时这一块内存就不再共享了。这是后话了,这里先埋个伏笔。

小结

至此 fork 中的 copy_process 函数就全部被我们读完了,总共做了三件事,把整个进程的数据结构个性化地从进程 0 复制给了进程 1。

1.原封不动复制了一下 task_struct

ch13-2改

2. LDT的复制和改造

使进程0和1分别映射到了不同的线性地址空间

ch16-2

3. 页表的复制

使进程0和1从不同的线性地址空间,被映射到了相同的物理地址空间。

ch16-3改

4. 页表变只读

将新老进程的页表都变成只读状态,为后面写时复制的缺页中断做准备。

写时复制

上节最后提及Linux内核里的写时复制,本节从源码层面把写时复制的原理搞清楚。

储备知识

写时复制用到的这里的知识点只有其中一个位的值而已,但我把周边也给你讲讲。

32 位模式下,Intel 设计了页目录表页表两种结构,用来给程序员们提供分页机制。

在 Intel Volume-3 Chapter 4.3 Figure 4-4 中给出了页表和页目录表的数据结构,PDE 就是页目录表,PTE 就是页表。

页表和页目录表

大部分的操作系统使用的都是 4KB 的页框大小,Linux 0.11 也是,所以我们只看 4KB 页大小时的情况即可。

一个由程序员给出的逻辑地址,要先经过分段机制的转化变成线性地址,再经过分页机制的转化变成物理地址

Figure 4-2 给出了线性地址到物理地址,也就是分页机制的转化过程。(可以结合系列三中最后提供的“逻辑地址-线性地址-物理地址转换”图)

线性地址-物理地址转换

这里的 PDE 就是页目录表,PTE 就是页表,刚刚说过了。

在手册接下来的 Table 4-5 和 Table 4-6 中,详细解释了页目录表和页表数据结构各字段的含义。

Table 4-5 是页目录表。

页目录表

Table 4-6是页表:

页表

他们几乎都是一样的含义,我们就只看页表就好了,看一些比较重要的位。

31:12 表示页的起始物理地址,加上线性地址的后 12 位偏移地址,就构成了最终要访问的内存的物理地址,这个就不说了。

第 0 位是 P,表示 Present,存在位。

第 1 位是 RW,表示读写权限,0 表示只读,那么此时往这个页表示的内存范围内写数据,则不允许。

第 2 位是 US,表示用户态还是内核态,0 表示内核态,那么此时用户态的程序往这个内存范围内写数据,则不允许。

在 Linux 0.11 的 head.s 里,初次为页表设置的值如下:

setup_paging:
   ...
    movl $pg0+7,_pg_dir     /* set present bit/user r/w */
    movl $pg1+7,_pg_dir+4       /*  --------- " " --------- */
    movl $pg2+7,_pg_dir+8       /*  --------- " " --------- */
    movl $pg3+7,_pg_dir+12      /*  --------- " " --------- */
    movl $pg3+4092,%edi
    movl $0xfff007,%eax     /*  16Mb - 4096 + 7 (r/w user,p) */
    std
1:  stosl
    ...

后三位是 7,用二进制表示就是 111,即初始设置的 4 个页目录表和 1024 个页表,都是:

存在(1),可读写(1),用户态(1)

好了,储备知识就到这里。如果你前面没读懂,你只需要知道,页表当中有一位是表示读\写的,而 Linux 0.11 初始化时,把它设置为了 1,表示可读写。

本质

在调用 fork() 生成新进程时,新进程与原进程会共享同一内存区。只有当其中一个进程进行写操作时,系统才会为其另外分配内存页面。

之前在系列之六中进程调度初始化,给过一个 Linux 0.11 进程的内存规划图

ch13-1

不过我们考虑写时复制并不用这么复杂,去掉些细节就是。原来的进程通过自己的页表占用了一定范围的物理内存空间:

ch16-5

调用 fork 创建新进程时,原本页表和物理地址空间里的内容,都要进行复制,因为进程的内存空间是要隔离的嘛。

ch16-5a

但 fork 函数认为,复制物理地址空间里的内容,比较费时,所以姑且先只复制页表,物理地址空间的内容先不复制

ch16-5b

如果只有读操作,那就完全没有影响,复不复制物理地址空间里的内容就无所谓了,这就很赚。但如果有写操作,那就不得不把物理地址空间里的值复制一份,保证进程间的内存隔离。

ch16-5c

有写操作时,再复制物理内存,就叫写时复制

代码

有上述的现象,必然是在 fork 时,对页表做了手脚,这回知道为啥储备知识里讲页表结构了吧?

同时,只要有写操作,就会触发写时复制这个逻辑,这是咋做到的呢?答案是通过中断,具体是缺页中断

好的,首先来看 fork。fork 细节很多,具体可以看上节和系列之九中fork中进程基本信息的复制,这里只看其中关键的复制页表的代码

int copy_page_tables(...) {
    ...
    // 源页表和新页表一样
    this_page = *from_page_table;
    ...
    // 源页表和新页表均置为只读
    this_page &= ~2;
    *from_page_table = this_page;
    ...
}

还记得知识储备当中的页表结构吧,就是把 R/W 位置 0 了。用刚刚的 fork 图表示就是:

ch16-5b改

那么此时,再次对这块物理地址空间进行写操作时,就不允许了。但不允许并不是真的不允许,Intel 会触发一个缺页中断,具体是 0x14 号中断,中断处理程序里边怎么处理,那就由 Linux 源码自由发挥了。

Linux 0.11 的缺页中断处理函数的开头是用汇编写的,看着太闹心了,这里我选 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 的不同,有不同的逻辑。

那触发缺页中断的异常码都有哪些呢?在 Intel Volume-3 Chapter 4.7 Figure 4-12 中给出:

缺页中断异常码

可以看出,当 error_code 的第 0 位,也就是存在位为 0 时,会走 do_no_page 逻辑,其余情况,均走 do_wp_page 逻辑。

我们 fork 的时候只是将读写位变成了只读,存在位仍然是 1 没有动,所以会走 do_wp_page 逻辑。

void do_wp_page(unsigned long error_code,unsigned long address) {
    // 后面这一大坨计算了 address 在页表项的指针
    un_wp_page((unsigned long *)
        (((address>>10) & 0xffc) + (0xfffff000 &
        *((unsigned long *) ((address>>20) &0xffc)))));
}

void un_wp_page(unsigned long * table_entry) {
    unsigned long old_page,new_page;
    old_page = 0xfffff000 & *table_entry;
    // 只被引用一次,说明没有被共享,那只改下读写属性就行了
    if (mem_map[MAP_NR(old_page)]==1) {
        *table_entry |= 2;
        invalidate();
        return;
    }
    // 被引用多次,就需要复制页表了

    new_page=get_free_page();
    mem_map[MAP_NR(old_page)]--;
    *table_entry = new_page | 7;
    invalidate();
    copy_page(old_page,new_page);
}

// 刷新页变换高速缓冲宏函数
#define invalidate() \
__asm__("movl %%eax,%%cr3"::"a" (0))

我用图直接说明这段代码的细节。

刚刚 fork 完一个进程,是这个样子的对吧?

ch16-5b改

我们对着这个物理空间范围,写一个值,就会触发上述函数。

假如是进程 2 写的。显然此时这个物理空间被引用了大于 1 次,所以要复制页面。

new_page=get_free_page()

并且更改页面只读属性为可读写:

*table_entry = new_page | 7;

图示就是这样:

ch16-5b改a

是不是很简单。那此时如果进程 1 再写呢?那么引用次数就等于 1 了,只需要更改下页属性即可,不用进行页面复制操作。

if (mem_map[MAP_NR(old_page)]==1) ...

图示就是这样:

ch16-5b改b

就这么简单。是不是从细节上看,和你原来理解的写时复制,还有点不同。

缺页中断的处理过程中,除了写时复制原理的 do_wp_page,还有个 do_no_page,是在页表项的存在位 P 为 0 时触发的。

这个和进程按需加载内存有关,如果还没加载到内存,会通过这个函数将磁盘中的数据复制到内存来,这个有时间再给大家讲。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值