Linux内存管理之地址映射

写在前面:由于地址映射涉及到各种寄存器的设置访问,Linux对于不同体系结构处理器的地址映射采用不同的方法,例如对于i386及后来的32位的Intel的处理器在页式映射时采用的是2级页表映射,而对于IA64的处理器则采用3级分页。对于其他类型的处理器,例如MK68000等其他许多处理器,在地址映射时则忽略了段式映射,只是因为Intel的X86系列需要兼容早期的段式映射,才在后来的设计中即使用了段式映射,也采用了页式映射。以后关于Linux的笔记,除特别说明外,均是在i386体系结构之上,笔记中所有源码除特别说明外均摘自linux-2.4.0源码树。

现代操作系统在内存管理上均使用高效的页式管理,Linux也不例外。对于i386处理器则有些例外,为了兼容早期的处理器,Intel强制要求必须先经过段式映射。在地址映射时,虚拟地址被划分成固定的页面大小,由MMU将虚拟地址映射到实际的物理地址。在访问一个虚拟地址表示的内存空间中,CPU必须经过若干次的内存访问才能完成映射,具体访问次数为N+1(N为页表级数),同时还需要N次加法运算。

在Linux进行段式映射和页式映射之前,需要搞清楚X86系列的地址描述方式:

  • 逻辑地址:出现在机器指令中,用来制定操作数的地址。
  • 线性地址:逻辑地址经过分段单元处理后得到线性地址,这是一个32位的无符号整数,可用于定位4G个存储单元。
  • 物理地址:线性地址经过页表查找后得出物理地址,这个地址将被送到地址总线上指示所要访问的物理内存单元。
段式映射即为将逻辑地址与线性地址映射起来,而页式映射则为将线性地址和物理地址对应起来。
 
段式映射阶段:i386CPU选择代码段寄存器CS的当前值作为段描述符表中的下标,段式寄存器的第2位为0时使用GDT,为1时使用LDT。Intel设计为内核使用GDT,各个进程使用自己的LDT,寄存器的最低两位表示特权级别。在Linux内核中其实只使用GDT,在4个权限级别中只是用了0代表kernel级,3代表用户级。内核在创建一个新进程时都会先设定其段寄存器,对i386处理器中,段寄存器的设置代码位于include/asm-i386/processor.h
include/asm-i386/processor.h
 
#define start_thread(regs, new_eip, new_esp) do {       __asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0));    set_fs(USER_DS);                    regs->xds = __USER_DS;                   regs->xes = __USER_DS;                   regs->xss = __USER_DS;                   regs->xcs = __USER_CS;                   regs->eip = new_eip;                 regs->esp = new_esp;             } while (0)

从代码可以看出,Linux将i386处理器的DS,ES,SS寄存器均设置为USER_DS,这表示在Linux中对于进程的代码段,数据段和堆栈段是不区分的。__USER_CS和__USER_DS的设置位于include/asm-i386/segment.h

include/asm-i386/segment.h
 
#ifndef _ASM_SEGMENT_H
#define _ASM_SEGMENT_H
  
#define __KERNEL_CS 0x10
#define __KERNEL_DS 0x18
  
#define __USER_CS   0x23
#define __USER_DS   0x2B
  
#endif

由以上代码可以看出,CS寄存器中的内容是0x23,通过段寄存器各位的含义可知,CPU以4作为下标,从全局描述符表GDT中寻找段描述选项,GDT的内容在arch/i386/kernel/head.S中定义

arch/i386/kernel/head.S
 
ENTRY(gdt_table)
    .quad 0x0000000000000000    /* NULL descriptor */
    .quad 0x0000000000000000    /* not used */
    .quad 0x00cf9a000000ffff    /* 0x10 kernel 4GB code at 0x00000000 */
    .quad 0x00cf92000000ffff    /* 0x18 kernel 4GB data at 0x00000000 */
    .quad 0x00cffa000000ffff    /* 0x23 user   4GB code at 0x00000000 */
    .quad 0x00cff2000000ffff    /* 0x2b user   4GB data at 0x00000000 */
    .quad 0x0000000000000000    /* not used */
    .quad 0x0000000000000000    /* not used */
    /*
     * The APM segments have byte granularity and their bases
     * and limits are set at run time.
     */
    .quad 0x0040920000000000    /* 0x40 APM set up for bad BIOS‘s */
    .quad 0x00409a0000000000    /* 0x48 APM CS    code */
    .quad 0x00009a0000000000    /* 0x50 APM CS 16 code (16 bit) */
    .quad 0x0040920000000000    /* 0x58 APM DS    data */
    .fill NR_CPUS*4,8,0     /* space for TSS‘s and LDT‘s */
到此段式映射已经完成,实际上对于Linux的页式映射来说这一步完全没用但又不得不做。通过段映射,进程的逻辑地址已经映射到了线性地址,但实际上通过段描述符的意义来看,二者是相同的。
 
页式映射阶段:在页式存储中,每个进程都有其自身的PGD,指向PGD的指针存在进程的mm_struct中,每当进程运行的时候,内核需要设定好控制寄存器CR3,MMU是从CR3中取得页面目录指针的。我们知道CPU在执行程序时使用的是虚拟的地址,而MMU硬件在映射时使用的是实际的物理内存地址,其具体实现是由include/asm-i386/mmu_context.h中的函数实现
include/asm-i386/mmu_context.h
 
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk, unsigned cpu)
{
    if (prev != next) {
        /* stop flush ipis for the previous mm */
        clear_bit(cpu, &prev->cpu_vm_mask);
        /*
         * Re-load LDT if necessary
         */
        if (prev->context.segments != next->context.segments)
            load_LDT(next);
#ifdef CONFIG_SMP
        cpu_tlbstate[cpu].state = TLBSTATE_OK;
        cpu_tlbstate[cpu].active_mm = next;
#endif
        set_bit(cpu, &next->cpu_vm_mask);
        /* Re-load page tables */
        asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));
    }
#ifdef CONFIG_SMP
    else {
        cpu_tlbstate[cpu].state = TLBSTATE_OK;
        if(cpu_tlbstate[cpu].active_mm != next)
            BUG();
        if(!test_and_set_bit(cpu, &next->cpu_vm_mask)) {
            /* We were in lazy tlb mode and leave_mm disabled 
             * tlb flush IPI delivery. We must flush our tlb.
             */
            local_flush_tlb();
        }
    }
#endif
}
  
#define activate_mm(prev, next)     switch_mm((prev),(next),NULL,smp_processor_id())
  
#endif

重点关注其中的asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));它实现的功能即为将页目录指针读入CR3寄存器中。通过线性地址的最高10位可以从页面目录中知道具体的目录项,在找到进程的目录项之后,在目录项中,高20位指向页面表,在得到页面表之后,CPU再从线性地址的中间10位得到页表中的表项。在32位处理器上页表中的高20位指向物理内存的初始地址,在其后添加12个0,然后加上线性地址中的低12位(即为线性地址中的偏移量),这样就得到了一个具体的物理地址了。

在地址映射这个问题上,内核只提供页表,实际的转换是由硬件去完成的。那么内核如何生成这些页表呢?这就有两方面的内容,虚拟地址空间的管理和物理内存的管理。实际上只有用户态的地址映射才需要管理,内核态的地址映射是写死的即为[0xC000 0000] (3 GB)到[0xFFFF FFFF] (4 GB)。在这一部分中,内核要实现的一个重要功能就是通过高速缓存来提高查找速度。

参考资料:

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。