x86逻辑地址转物理地址实验

之前一篇文章关于往NetBSD添加系统调用,是为了做这个实验准备的。

想要研究x86架构下的内存管理,最重要的参考文档应该是英特尔官方关于内存管理方面的解释了。在 Intel? 64 and IA-32 Architectures Software Developer’s Manual Volume 3A: System Programming Guide, Part 1 文档的第三个章节,详细解释了整个翻译过程,其中涉及到的寄存器和各种标志位的作用。长是比较长,而且又是英语的,但是说得很清楚。

而实验则是验证自己理解是不是正确的一个很好的方式。理论看了半天,也知道怎么算,套公式好像也能算得出来,但是却不知道有没有算对。有真实的环境,一测试一验证,就知道对不对了。如果自己理解有误,可以及早发现和纠正。

关于逻辑地址转物理地址的文章,已经有很多地方都能找到,包括公式啥的。但是大多只有理论上的解释,最多再给你个习题啥的。真正要动手在系统上做实验,一个要获取CR3寄存器,一个要访问物理内存读取页表,这两个操作略麻烦,不知道是不是阻碍动手做实验的原因。总之现在CR3能获取到了,通过 /dev/mem 这个设备也可以访问物理内存,可以说万事俱备了。

分页机制很好地实现了进程间的内存空间隔离:不同进程之间正常情况下只能在属于自己的内存空间中操作。而且这种隔离并不仅仅是不让你访问属于其他进程的地址,而是根本看不见其他进程的内存:不同进程就像在不同次元中运行。举个例子,同样一个地址,在不同的进程中,可以看到不同的数据,这就是靠分页机制实现的:进程中操作的所有地址,都要先进过一个转换,转换成物理地址,才操作内存。而这种转换,对于不同的进程,是不同的。所以同一个地址,通过不同的转换方式,可以转到不同的物理地址上去,保存在内存中不同的地方。就好像它们在不同的平行世界上一样。

image
Intel那个文档里的图片对于整个地址的转译过程说得很清楚,配上图片就更加容易理解了。这张图片的左上角,“逻辑地址”,或者说Far Pointer,就是平时写C语言程序的时候会遇到的叫“指针”的东西。
这个指针要最终操作物理内存,先要经过“分段”机制的翻译,将指针所表示的地址加上一个其所属的段的“段基址”,变成“线性地址”。所谓“所属的段”,写过见过汇编语言的同学可能会见过类似  CS: DS: ES: 这样的前缀,其中CS DS ES这些是段寄存器,里面保存了段的编号。通过段的编号可以在段表中找到这个段的信息,进而获取到段的基址。但是现在的操作系统,比如Windows、Linux、*BSD啥的,大部分情况下就直接给你把基址定为0。这样确实是比较方便了,任何数加上0还是原来的数,于是逻辑地址等于线性地址。那么把逻辑地址转为物理地址的时候就不理这个步骤了,直接去处理分页机制就好了。
分页的话,CPU的某个寄存器保存了“页表”的地址,这个页表可能有好几级。线性地址的每个部分都保存了每一级需要的索引(说白了就是数组下标),然后最后一部分保存了偏移。这个在下面具体情况的时候再去理解会比较容易。

实验在NetBSD 6.1.4上完成。已经通过这一篇文章中介绍的方法添加了获取CR3寄存器值的系统调用,调用号是475。


========================================================
1、x86-32,未开启PAE的情况
========================================================

image
image
image

这两张图把整个过程说明得非常清楚,首先获取CR3寄存器的值,然后通过这个值找到Page Directory,再根据Page Directory里的标志位确定还要不要继续到Page Table里面去找:因为Page Directory里也可能是直接指向一个4MB的内存页,这根据Page Directory Entry里第7(最右为第0位)位(PS位,全称Page Size)是不是1来决定它指向的是一个Page Table还是直接表示了一个4MB的内存页。这些页目录、页表项的前20位(4MB内存页的时候是前10位)代表了基地址,就是页表具体存放在哪里或者这个页具体在哪里。然后最后一位(Present位)是1还是0代表了这个页面是否存在。有这样一种情况,内存页会不存在:操作系统不是有一个机制叫做虚拟内存吗?可以通过将暂时不用的内存页写入硬盘,腾出这部分空间给急需内存的程序使用,这些数据如果以后又要用了,就再从硬盘中读出来。那么如果一个页面正好被放入硬盘了,就有可能明明是一个可以访问的地址,但是这个最后一位却是0。关于这些位的详细解释,看Intel的文档就能很清楚了。

这里面,CR3和Page Directory Entry和Page Table Entry涉及到的“地址”全部都指的是物理地址。

然后通过 /dev/mem 来操作物理内存,就可以写一个程序将虚拟地址转为物理地址,然后验证是否正确。一个简单的方法,就是声明一个变量,然后把它的地址转成物理地址,通过写入 /dev/mem 来改变它的值,最后读取这个变量看看它的值有没有变。这个过程中有一个需要注意的地方,就是如果一个页目录项或者页表项的末位为0,就直接认为转换失败了。以及就算转出了物理地址,如果在转完之后访问之前正好遇上操作系统把这个内存页换到硬盘的交换分区去了,也是会出错的。所以这个仅仅是实验,真正实用要考虑的东西实在是非常多,不要贸然搞这样的方法。要让内核把这个分页换回来虽然也不难,比如访问一下这个变量之类……不过这样程序逻辑就变麻烦了,而且又反正是实验嘛,就随便来了(殴)。实际实验过程中,因为程序体积小占的内存少,又因为是虚拟机里面、系统根本没运行其他东西,所以没遇到这种情况。

于是有如下测试程序:

#include <unistd.h>
#include <cstdio>
#include <fcntl.h>
#include <stdint.h>
using namespace std;

struct PhyAddr {
    uint32_t m_phyaddr;
    PhyAddr(uint32_t addr = 0) : m_phyaddr(addr)
    {
    }
    PhyAddr& operator += (uint32_t offset)
    {
        m_phyaddr += offset;
        return *this;
    }
};

bool AccessPhyMem(PhyAddr addr, bool isRead, void* buf, int len)
{
    int fd = open("/dev/mem", O_RDWR);
    if (fd <= 0)
        return false;
    lseek(fd, addr.m_phyaddr, SEEK_SET);
    if (isRead)
        read(fd, buf, len);
    else
        write(fd, buf, len);
    close(fd);
    return true;
}

struct PageItemEntry {
    uint32_t m_entry;
    operator PhyAddr ()
    {
        PhyAddr r;
        r.m_phyaddr = m_entry & 0xfffff000;
        return r;
    }
    bool IsPresent()
    {
        return (m_entry & 1) == 1;
    }
};

struct PageDirEntry : public PageItemEntry {
    bool Is4MPage()
    {
        return (m_entry & 0x80) != 0;
    }
    operator PhyAddr ()
    {
        PhyAddr r = PageItemEntry::operator PhyAddr();
        if (Is4MPage())
            r.m_phyaddr &= 0xffc00000;
        return r;
    }
};

struct PageTblEntry : public PageItemEntry {
};

void SplitPtr(void* ptr, int* nDirIndex, int* nTblIndex, int* nOffset, int* nOffset2)
{
    *nDirIndex = (uint32_t)ptr >> 22;
    *nTblIndex = ((uint32_t)ptr >> 12) & 0x3ff;
    *nOffset = (uint32_t)ptr & 0xfff;
    *nOffset2 = (uint32_t)ptr & 0x003fffff;
}

int main()
{
    volatile int var = 0xfbfbfbfb;
    int val = 0x12349876;
    int nDirIndex, nTblIndex, nOffset, nOffset2;
    SplitPtr((void*)&var, &nDirIndex, &nTblIndex, &nOffset, &nOffset2);
    
    try {
        PhyAddr cr3 = syscall(475);
        PhyAddr varAddr;
        PageDirEntry pageDir[1024];
        AccessPhyMem(cr3, true, pageDir, sizeof(pageDir));
        if (!pageDir[nDirIndex].IsPresent())
            throw "Fail: Invalid page directory entry.";

        if (pageDir[nDirIndex].Is4MPage()) {
            varAddr = pageDir[nDirIndex];
            varAddr += nOffset2;
            AccessPhyMem(varAddr, false, &val, sizeof(val)); 
        } else {
            PageTblEntry pageTbl[1024];
            AccessPhyMem(pageDir[nDirIndex], true, pageTbl, sizeof(pageTbl));
            if (!pageTbl[nTblIndex].IsPresent())
                throw "Fail: Invalid page table entry.";
            varAddr = pageTbl[nTblIndex];
            varAddr += nOffset;
            AccessPhyMem(varAddr, false, &val, sizeof(val));
        }

        printf("%x\n", var);
    } catch(const char* msg) {
        fprintf(stderr, "%s\n", msg);
    }
    return 0;
}

简单说就是通过把var的地址转成物理地址,然后把val的值写入这个地址,最后输出var看看是否被改变了。

还是上面说的:就算都检查了 IsPresent() ,只要转成物理地址之后正好发生页面交换把var所在页面调入swap,这个写入仍然是有问题的。只是因为现在是实验所以才这么做。

顺便,如果提供的是另一个进程的CR3和另一个进程里的某个变量的地址,那么可以直接修改另一个进程里面的变量。注意volatile关键词,免得编译器生成的代码在你要输出的地方不重读变量。


========================================================
2、x86-32,开启PAE的情况
========================================================

到 /usr/src/sys/arch/i386/conf 里面找到 GENERIC 文件,把里面的 #options PAE 最前面的 # 去掉,然后config GENERIC之后编译安装新内核。这个内核就是启用PAE的内核了。接下去的实验就在这个内核上进行。

image
image
image

这个图上暂时能看出的是,一个指针本来是分成3部分或者2部分,现在变成4部分或者3部分了:多了一级。以及,页目录、页表的索引本来是10位,现在变成了9位(多的一级是2位),最后12位在4K内存页的时候是不变的,但是在2MB内存页的时候变成20位了(因为变成2MB了嘛……本来是4MB)。

但是一个更大的区别是,PAE模式下,页表项、页目录项之类的项都是64位的了。其实也就是原来32位前面多了32位高地址,再把原来1024个项变成512个项,其他其实还是一样。然后仔细看,多了好多Reserved的部分,原来32位的时候没有这些东西的。因为PAE实际上就支持到36位地址(注:Intel的说明文档中有这么一句 NOTES: 1. MAXPHYADDR is 36-bits on processors that do not support CPUID.80000008H leaf. On processors that do support CPUID.80000008H, MAXPHYADDR is implementation-specific and indicated by CPUID.80000008H:EAX[bits 7:0]. 但是我没看懂怎么样能让它突破36位。NetBSD下把那个Reversed的最高位拿来用了),所以36位往上的部分要mask掉(P.S. 我最初做实验的时候没有mask掉,结果出来的结果很奇怪,后来把它们dump出来一看才发现reserved那边不是0)。所以有了下面的实验代码:

#include <unistd.h>
#include <cstdio>
#include <fcntl.h>
#include <stdint.h>
using namespace std;

struct PhyAddr {
    uint64_t m_phyaddr;
    PhyAddr(uint64_t addr = 0) : m_phyaddr(addr)
    {
    }
    PhyAddr& operator += (uint64_t offset)
    {
        m_phyaddr += offset;
        return *this;
    }
};

bool AccessPhyMem(PhyAddr addr, bool isRead, void* buf, int len)
{
    int fd = open("/dev/mem", O_RDWR);
    if (fd <= 0)
        return false;
    lseek(fd, addr.m_phyaddr, SEEK_SET);
    if (isRead)
        read(fd, buf, len);
    else
        write(fd, buf, len);
    close(fd);
    return true;
}

struct PageItemEntry {
    uint64_t m_entry;
    operator PhyAddr ()
    {
        PhyAddr r;
        r.m_phyaddr = m_entry & 0xffffff000ull;
        return r;
    }
    bool IsPresent()
    {
        return (m_entry & 1) == 1;
    }
};

struct PageDirPtrEntry : public PageItemEntry {
};


struct PageDirEntry : public PageItemEntry {
    bool Is2MPage()
    {
        return (m_entry & 0x80) != 0;
    }
    operator PhyAddr ()
    {
        PhyAddr r = PageItemEntry::operator PhyAddr();
        if (Is2MPage())
            r.m_phyaddr &= 0xffff00000ull;
        return r;
    }
};

struct PageTblEntry : public PageItemEntry {
};

void SplitPtr(void* ptr, int* nDirPtrIndex, int* nDirIndex, int* nTblIndex, int* nOffset, int* nOffset2)
{
    *nDirPtrIndex = (uint32_t)ptr >> 30;
    *nDirIndex = ((uint32_t)ptr >> 21) & 0x1ff;
    *nTblIndex = ((uint32_t)ptr >> 12) & 0x1ff;
    *nOffset = (uint32_t)ptr & 0xfff;
    *nOffset2 = (uint32_t)ptr & 0x000fffff;
}

int main()
{
    volatile int var = 0xfbfbfbfb;
    int val = 0x12349876;
    int nDirPtrIndex, nDirIndex, nTblIndex, nOffset, nOffset2;
    SplitPtr((void*)&var, &nDirPtrIndex, &nDirIndex, &nTblIndex, &nOffset, &nOffset2);
    try {
        PhyAddr cr3 = syscall(475);
        PhyAddr varAddr;
        PageDirPtrEntry pageDirPtr[4];
        AccessPhyMem(cr3, true, pageDirPtr, sizeof(pageDirPtr));
        if (!pageDirPtr[nDirPtrIndex].IsPresent())
            throw "Fail: Invalid page directory pointer entry.";
        
        PageDirEntry pageDir[512];
        AccessPhyMem(pageDirPtr[nDirPtrIndex], true, pageDir, sizeof(pageDir));
        if (!pageDir[nDirIndex].IsPresent())
            throw "Fail: Invalid page directory entry.";

        if (pageDir[nDirIndex].Is2MPage()) {
            varAddr = pageDir[nDirIndex];
            varAddr += nOffset2;
            AccessPhyMem(varAddr, false, &val, sizeof(val)); 
        } else {
            PageTblEntry pageTbl[512];
            AccessPhyMem(pageDir[nDirIndex], true, pageTbl, sizeof(pageTbl));
            if (!pageTbl[nTblIndex].IsPresent())
                throw "Fail: Invalid page table entry.";
            varAddr = pageTbl[nTblIndex];
            varAddr += nOffset;
            AccessPhyMem(varAddr, false, &val, sizeof(val));
        }

        printf("%x\n", var);
    } catch(const char* msg) {
        fprintf(stderr, "%s\n", msg);
    }
    return 0;
}

效果和上面那个程序是一样的,就是在PAE下的转换。


========================================================
3、x86-64(AMD64,IA-32e)的情况
========================================================

 

在x86-64(以下简称64位)下,PAE强制开启,逻辑地址的长度扩充为48位,物理地址则可以使用52位(相比起来,32位下32位物理地址最大范围是4G,32位PAE下36位物理地址最大范围是64G,而52位地址则意味着范围到了4194304GB,也就是4096TB)。所以大内存的时候还是要靠64位。

在64位PAE下,页表比32位PAE多了一级,变成四级页表。新增加的一级命名为PML4(page map level 4,继Page Table,Page Directory,Page Directory Pointer之后终于直接简单粗暴用起数字来了)。但是换汤不换药,就是查找的时候多了一级而已。结构和原来基本一致,32位的那几个如果没写错,照着改改在64位下就能跑了。

image
image
image

因为换了64位系统,之前添加过的系统调用还要重新添加一次(好吧我换了一台虚拟机装了个AMD64的)。解包出源代码,然后到 /usr/src/sys/kern/syscalls.master 里面去加系统调用然后make init_sysent.c一下,再到 /usr/src/sys/arch/amd64/conf/files.amd64 添加一个源代码文件,并在源代码文件中写好实现。cr3还是在原来的地方:从lwp结构体获取到pcb,然后pcb_cr3成员就是。

#include <unistd.h>
#include <cstdio>
#include <fcntl.h>
#include <stdint.h>
using namespace std;

struct PhyAddr {
    uint64_t m_phyaddr;
    PhyAddr(uint64_t addr = 0) : m_phyaddr(addr)
    {
    }
    PhyAddr& operator += (uint64_t offset)
    {
        m_phyaddr += offset;
        return *this;
    }
};

bool AccessPhyMem(PhyAddr addr, bool isRead, void* buf, int len)
{
    int fd = open("/dev/mem", O_RDWR);
    if (fd <= 0)
        return false;
    lseek(fd, addr.m_phyaddr, SEEK_SET);
    if (isRead)
        read(fd, buf, len);
    else
        write(fd, buf, len);
    close(fd);
    return true;
}

struct PageItemEntry {
    uint64_t m_entry;
    operator PhyAddr ()
    {
        PhyAddr r;
        r.m_phyaddr = m_entry & 0xfffffffff000ull;
        return r;
    }
    bool IsPresent()
    {
        return (m_entry & 1) == 1;
    }
};

struct PML4Entry : public PageItemEntry {
};

struct PageDirPtrEntry : public PageItemEntry {
};


struct PageDirEntry : public PageItemEntry {
    bool Is2MPage()
    {
        return (m_entry & 0x80) != 0;
    }
    operator PhyAddr ()
    {
        PhyAddr r = PageItemEntry::operator PhyAddr();
        if (Is2MPage())
            r.m_phyaddr &= 0xfffffff00000ull;
        return r;
    }
};

struct PageTblEntry : public PageItemEntry {
};

void SplitPtr(void* ptr, int* nPML4Index, int* nDirPtrIndex, int* nDirIndex, int* nTblIndex, int* nOffset, int* nOffset2)
{
    *nPML4Index = ((uint64_t)ptr >> 39) & 0x1ff;
    *nDirPtrIndex = ((uint64_t)ptr >> 30) & 0x1ff;
    *nDirIndex = ((uint64_t)ptr >> 21) & 0x1ff;
    *nTblIndex = ((uint64_t)ptr >> 12) & 0x1ff;
    *nOffset = (uint64_t)ptr & 0xfff;
    *nOffset2 = (uint64_t)ptr & 0x000fffff;
}

int main()
{
    volatile int var = 0xfbfbfbfb;
    int val = 0x12349876;
    int nPML4Index, nDirPtrIndex, nDirIndex, nTblIndex, nOffset, nOffset2;
    SplitPtr((void*)&var, &nPML4Index, &nDirPtrIndex, &nDirIndex, &nTblIndex, &nOffset, &nOffset2);
    try {
        PhyAddr cr3 = syscall(475);
        PhyAddr varAddr;
        
        PML4Entry pageMapL4[512];
        AccessPhyMem(cr3, true, pageMapL4, sizeof(pageMapL4));
        if (!pageMapL4[nPML4Index].IsPresent())
            throw "Fail: Invalid PML4 entry.";
        
        PageDirPtrEntry pageDirPtr[512];
        AccessPhyMem(pageMapL4[nPML4Index], true, pageDirPtr, sizeof(pageDirPtr));
        if (!pageDirPtr[nDirPtrIndex].IsPresent())
            throw "Fail: Invalid page directory pointer entry.";
        
        PageDirEntry pageDir[512];
        AccessPhyMem(pageDirPtr[nDirPtrIndex], true, pageDir, sizeof(pageDir));
        if (!pageDir[nDirIndex].IsPresent())
            throw "Fail: Invalid page directory entry.";

        if (pageDir[nDirIndex].Is2MPage()) {
            varAddr = pageDir[nDirIndex];
            varAddr += nOffset2;
            AccessPhyMem(varAddr, false, &val, sizeof(val)); 
        } else {
            PageTblEntry pageTbl[512];
            AccessPhyMem(pageDir[nDirIndex], true, pageTbl, sizeof(pageTbl));
            if (!pageTbl[nTblIndex].IsPresent())
                throw "Fail: Invalid page table entry.";
            varAddr = pageTbl[nTblIndex];
            varAddr += nOffset;
            AccessPhyMem(varAddr, false, &val, sizeof(val));
        }

        printf("%x\n", var);
    } catch(const char* msg) {
        fprintf(stderr, "%s\n", msg);
    }
    return 0;
}

和32位PAE的情况基本一样,多了一级,Page Directory Pointer变成了512个元素,以及逻辑地址变成了48位。

==============================================
4、总结一下
==============================================

模式 逻辑 / 物理
地址长度
页表级数 每级项数 每项长度 页大小
x86-32 32/32位 2级 1024/1024 32位 4KB
1级 1024 4MB
x86-32 PAE 32/36位 3级 4/512/512 64位 4KB
2级 4/512 2MB
x86-64 48/52位 4级 512/512/512/512 64位 4KB
3级 512/512/512 2MB

发表评论