内存管理 · 2014-08-12 0

【Linux内存管理】初探保护模式(1)

既然都说是分析x86环境的linux系统内存管理,如果不分析一下x86那绕来绕去的内存映射机制,个人感觉等于什么都没分析。其实x86的内存映射机制,说复杂也不复杂,说简单也不简单,简单点说x86内存映射莫过于就两个映射:段式映射和页式映射。其中页式映射是基于段式映射的基础上而形成的,那就意味着可以是:纯段式映射和段页式映射。这些都是简单的,而且映射的结构图在各式各样介绍内存管理的书或者文章上比比皆是。但看点复杂的,GDTR存放的是虚拟地址还是线性地址呢?CR3存放的是线性地址还是物理地址?x86的各式寄存器分别在内存映射中起到了什么作用呢?这些都是值得探究的,如此一来可以更深入详细地理解x86体系结构。

直接把x86那几张内存映射机制结构图放上来没什么意思,还是循序渐进地结合linux内存初始化过程中的映射方式切换来分析这些映射吧。

机器通电启动的时候,CPU是处于实模式下的,这是很古老的模式,intel 8086就开始采用这种内存访问模式。不过那时候内存小,内存逐渐增大以及系统越来越负责,需要一个很好的内存保护方式,于是乎才有后来的保护模式。

那么实模式是如何地址翻译的呢?通过截取来自intel手册的实模式图来分析一下:

翻译公式:

(Offset<<4)+Base = Linear Address

由此可以看出来要访问某个物理地址,需要两个数据offset和base,其中base的数据就是实模式下段寄存器的值了(实模式下,段寄存器并不会当做描述符下标来使用),而offset则是需要访问的地址偏移。通过base左移4位加上offset的值则成为线性地址了,实模式下的线性地址直接映射物理地址,不做其他映射转换了。于是乎,实模式下表示逻辑地址的方式为“base:offset”。另外可以很明显地看到base和offset都为0xffff的时候,可以访问最大的地址值为0x10FFEF ,而实际上的实模式是仅能够访问到1M的内存空间而已,至于从0x100000到0x10ffef的内存空间实际上是0x0到0xffef,通过“wrapping”迂回回去了。也就是意味着你操作0x100000到0x10ffef的内存实际上是在操作0x0到0xffef低地址的内存。情况如下图:

这就是Intel 8086处理器所表现出来的内存访问方式了,也就是后来所谓的实模式了。Intel号称向下兼容,于是乎这种实模式的内存访问方式就完全被保留到了现在。

但是现在的计算机内存不可能仅有1M大小,截止目前,最新的电脑都基本上配备了2G及以上的内存了。而根据实模式的情况,是不可能访问超过1M以上的内存空间的,而现在面临着需要使用大内存的场景需要解决。即便不是现在,在Intel 80286的时候,已经不再是20根地址线了,而是升级为24根地址线了,访问内存达16M。所以这里面就有一个开关进行控制,这就是A20 Gate。这是指处理器上的A20线(即第21条地址线,地址线从0开始编号的),也是在80286设计时引入的。当A20 Gate开启时,则访问0x100000到0x10ffef的内存空间时是真正切切地访问了这块内存区域;当A20 Gate关闭时,则是仿8086的内存访问模式,访问的是0x0到0xffef的内存区域。

那看看linux内核开启A20的代码实现,实现开启功能的函数是enable_a20,具体代码:

【file:/arch/x86/boot/a20.c】
/*
 * Actual routine to enable A20; return 0 on ok, -1 on failure
 */

#define A20_ENABLE_LOOPS 255	/* Number of times to try */

int enable_a20(void)
{
       int loops = A20_ENABLE_LOOPS;
       int kbc_err;

       while (loops--) {
           /* First, check to see if A20 is already enabled
          (legacy free, etc.) */
           if (a20_test_short())
               return 0;
           
           /* Next, try the BIOS (INT 0x15, AX=0x2401) */
           enable_a20_bios();
           if (a20_test_short())
               return 0;
           
           /* Try enabling A20 through the keyboard controller */
           kbc_err = empty_8042();

           if (a20_test_short())
               return 0; /* BIOS worked, but with delayed reaction */
    
           if (!kbc_err) {
               enable_a20_kbc();
               if (a20_test_long())
                   return 0;
           }
           
           /* Finally, try enabling the "fast A20 gate" */
           enable_a20_fast();
           if (a20_test_long())
               return 0;
       }
       
       return -1;
}

 

实现一目了然,就一个while循环调用函数,循环调用里面的各个函数。如果开启A20成功了,则在循环体内返回0表示成功,否则直至循环结束返回-1并退出以表示失败。

接下来看看while循环体内的函数。首先是a20_test_short(),顾名思义,可以看出来它是用来检测的,继而从while循环内的第一个判断可以推断出它是检测A20是否开启的,如果开启的话,则直接返回0表示成功。

具体函数内的实现:

【file:/arch/x86/boot/a20.c】
static int a20_test_short(void)
{
    return a20_test(A20_TEST_SHORT);
}

 

而a20_test()的实现:

【file:/arch/x86/boot/a20.c】
#define A20_TEST_ADDR	(4*0x80)
#define A20_TEST_SHORT  32
#define A20_TEST_LONG	2097152	/* 2^21 */

static int a20_test(int loops)
{
    int ok = 0;
    int saved, ctr;

    set_fs(0x0000);
    set_gs(0xffff);

    saved = ctr = rdfs32(A20_TEST_ADDR);

    while (loops--) {
        wrfs32(++ctr, A20_TEST_ADDR);
        io_delay();	/* Serialize and make delay constant */
        ok = rdgs32(A20_TEST_ADDR+0x10) ^ ctr;
        if (ok)
            break;
    }

    wrfs32(saved, A20_TEST_ADDR);
    return ok;
}

 

在a20_test里面,可以看到set_fs(0x0000)和set_gs(0xffff)分别将fs和gs设置为0x0000和0xffff。接着rdfs32(A20_TEST_ADDR)则是把0x0000:(4*0x80)地址的数据读取出来,至于是什么,天知道,不过这不是重点。再接着while循环体内,wrfs32(++ctr, A20_TEST_ADDR)把读出来的数据自加后写回到0x0000:(4*0x80)。然后rdgs32(A20_TEST_ADDR+0x10) ^ ctr则是把0xffff:(4*0x80)+0x10的数据读出来与写入0x0000:(4*0x80)的数据做异或运算,再在if(ok)里面判断两者是否相等。如果相等,则表明两者数据一致,有可能wrfs32写入的数据就是rdgs32读出来的数据,也就有可能当前A20并没有开启。如果存在巧合呢?这就是while循环的由来,多试几次避免真的是巧合。最后wrfs32(saved, A20_TEST_ADDR)再把修改的数据改回去。毕竟不知道这个数据有什么用,怎么来的就怎么回。

回到enable_a20函数里面,根据注释和操作可以判断,开启A20 Gate的函数分别有:enable_a20_bios()、empty_8042()、enable_a20_kbc()和enable_a20_fast(),而且enable_a20_kbc()更是直接调用empty_8042(),由此判断开启A20的关键函数只有3个。此外也不难理解,同理e820内存探测一样,这3个函数应该是向前或者是对各种硬件设计做兼容而实现的。

首先看一下enable_a20_bios()的实现:

【file:/arch/x86/boot/a20.c】
static void enable_a20_bios(void)
{
    struct biosregs ireg;

    initregs(&ireg);
    ireg.ax = 0x2401;
    intcall(0x15, &ireg, NULL);
}

 

和e820内存探测很像的一个代码,这是通过调用BIOS的0x15中断尝试把A20开启。开启失败的话,将会调用empty_8042(),这是通过操作键盘控制器的状态寄存器尝试把A20开启,顺便提一下早期IBM为了解决80286兼容8086的内存访问模式,他们利用键盘控制其上空余的一些输出线来管理A20,这里应该就是针对这个情况尝试该方式开启A20,具体代码这里就不贴出来分析了。然后empty_8042()如果还失败的话,那么还有enable_a20_fast(),这个是通过操作主板控制寄存器来尝试开启,背后故事就略了,这里不是重点。

最后顺便记录一下detect_memory()在Linux系统中调用路径为:

main() #/arch/x86/boot/main.c

└-> go_to_protected_mode() #/arch/x86/boot/pm.c

└-> enable_a20() #/arch/x86/boot/a20.c

好了,截止现在打开A20 Gate,只是在实模式上使得处理器能够最大化访问0x10ffef的地址空间,而不是wrap回去访问低地址空间。但是要想访问0x10ffef以上的内存,则必须进入保护模式。