计算机体系结构中的核心问题之一就是如何有效的进行内存寻址。因为所有运算的前提都是要先从内存中取地数据。所以内存寻址技术在一定程度上代表了计算机技术。
1. 图灵机和冯诺依曼体系
图灵机:图灵机是一种通用自动的机器模型,通过二段无线延申的纸带作为存储装置,输入输出和状态转移函数是机器的三要素,这三要素组合变形可成为一切机器的原型,可解决一切图灵机能解决的问题。
冯诺依曼体系结构:图灵机的实现,基于图灵机的数据连续存储和选择读取思想,是目前我们使用的几乎所有机器运行背后的灵魂。
2. 内存寻址概述
以IntelX86结构为例,因为这是我们最为熟悉的结构之一。
2.1 x86内存寻址的不同时期
- 石器时期:8位
- 青铜时期:16位
- 白银时期:24位
- 黄金时期:32和64位
2.2 石器时期-8位寻址
在微处理器的是历史上,第一款微处理芯片是4004,由Intel推出的4位机器,之后又推出了一款8位微处理芯片8008。
这个时期没有段的概念,访问内存需要通过绝对地址。程序中的地址必须要进行硬编码,给出具体的物理地址,而且难以重定位。
2.3 青铜时期-16位寻址(段)
8086处理器的时代,引入了段的概念。8086的目标是寻址空间达到了1M,但数据总线只有16位,因此需要分为数个64k的段来进行管理。
段描述了一块有限的内存区域,区域的起始位置存在专门的寄存器(段寄存器)中。
把16位的段地址,左移4位后再与16位的偏移量相加,获得一个20位的内存地址。
实模式:从16位内存地址到20位实际地址的转换(映射)。
2.4 白银时期-24位寻址(保护模式)
80286的的地址总线增加到了24位,引入了一个全新的理念(保护模式)
保护模式:访问内存不能直接从段寄存器中获得段的起始地址,而是需要进行额外的转换和检查。
保护模式有很多沿用至今的机制: 内存保护, 分页系统, 虚拟内存等. 大部分现今基于x86的操作系统都是在保护模式下运行的.
为了让文章不显的臃肿, 保护模式的相关概念就不过多介绍了, 感兴趣的可以自行搜索, 这里贴几个相关链接:
- 保护模式汇编系列: https://www.0xffffff.org/2013/10/22/21-x86-asm-1/
- 《i386体系结构》上:http://kerneltravel.net/blog/2020/i386_1/
2.5 黄金时期-32/64位寻址
以32位CPU80386为例。
Intel选择在段寄存器的基础上构置保护模式,保留段寄存器依然是16位。在保护模式下,段范围不受限于64K,可以达到4GB。
把386以后的处理器称为X86, 这个时候, 保护模式才算是真正的体现出了强大的作用.
3. 分段机制和分页机制
分段和分页这个计算机科班出身的应该都在操作系统课程上学过相关的理论知识, 这里就不多bb了. 如果忘了也没关系, 贴一个链接, 第二章<linux运行的硬件基础>介绍的非常详细, 简单复习一下应该能回忆起主要内容:
- http://www.kerneltravel.net/book/
简单来说, i386之后的设备, 有三种不同的地址做区分
- 虚地址(逻辑地址): 机器语言指令用这种地址指定一个操作数的地址或一条指令的地址. 通过分段结构, 将程序分成若干段, 每个虚地址都由一个段和偏移量组成
- 线性地址: 在32位机器上, 线性地址是一个32位的无符号整数, 可以表达4GB的地址, 通常用16进制表示线性地址0x00000000~0xffffffff.
- 实地址(物理地址): 内存单元的实际地址, 用于芯片内存单元寻址. 32位机器的物理地址由32位无符号整数表示.
为了更直观的了解分段机制和分页机制, 我们从一个简单的"Hello World"程序说起
#include<stdio.h>
int main(){
printf("Hello World!\n");
return 0;
}
通过编译, 汇编, 链接, 装在和执行, 最后反汇编
gcc -S helloworld.c -o helloworld.s // 编译: 编译成汇编文件
gcc -c helloworld.s -o helloworld.o // 汇编: 汇编成二进制文件
gcc helloworld.c -o helloworld.out // 链接: 将调用的库进行链接, 输出可执行文件
./helloworld.out // 装载到内存并执行
objdump -d helloworld.out // 反汇编
有三个问题:
- 链接以后形成的地址是虚地址还是实地址? 虚地址
- 装入程序把可执行代码装入到虚拟内存还是物理内存? 虚拟内存
- cpu访问的是虚地址还是物理地址? 虚地址
下面和下下面将在理论和实践中进行回答.
编译之后形成的虚地址,就是cpu要访问的地址
cpu把虚地址送入MMU(内存管理单元), MMU把虚地址转成物理地址送给存储器
MMU分为两个阶段:
- 通过分段机制, 虚拟地址转换为线性地址
- 通过分页机制, 线性地址转为物理地址
4. Linux中的虚拟地址转化为物理地址
4.1 理论部分
Linux主要采用分页机制来实现虚拟存储管理. Linux分段机制使所有的进程都使用相同的段寄存器, 所有的进程使用同样的线性地址空间
过程可以用一张图来概述: 这张图中很清晰的描述了虚地址是如何转为实地址的, 先通过分段机制转为线性地址, 再通过分页机制转为物理地址.(补充: 在Linux中,段的基地址都为0, 所有程序共享同样的线性空间, 虚地址和线性地址在数值上就相同了)
在3.10.0版本的内核中, centos7采用了4级分页模式, 在/arch/x86/include/asm/pgtable_types.h文件中可以看到, 共pte,pmd,pud和pgd四部分组成.
分别为
- pgd(page global directory): 总目录
- pud(page upper directory): 三级页面
- pmd(page middle directory): 中间目录
- pte(page table entry): 页表
另外注意: 由于64位处理器硬件的限制, 它的地址线只有48条, 因此线性地址实际使用的地址只有48位
页面的大小是4k, 每一个页表项的大小是8bit, 整个页表可以映射的空间是256TB(已经很大了)
另外(Linux4.15内核版本之后, 新增了一个p4d页目录在pgd和pud之间. 这是因为Intel芯片中的mmu提供了5级页表的映射)
4.2 代码实践
寻页机制的代码实践, 需要用到内核提供的一些函数, 因此需要通过编写内核模块的方式实现.
实现思路就是模拟MMU的寻页过程:
- 首先在内核中申请一个页面, 利用__get_free_page()函数.
- 利用内核提供的函数, 一级级的查询各级页目录
- 各级目录组合找到对应的物理地址
注意!!! 新手在服务器或者物理机上直接操作的话一定一定一定要谨慎!!!避免给内核写崩之后重启丢失数据(强烈推荐在虚拟机上搞, 我写崩了好几次内核了, 都需要重启系统, 在虚拟机上试崩了好几次了, 不过有部分原因是我虚拟机内存开小了然后内存越界出错了)
#include<linux/init.h>
#include<linux/module.h>
#include<linux/mm.h> // 内存映射
#include<linux/mm_types.h>
#include<linux/sched.h>
// #include<linux/export.h>
#include<asm/pgtable.h> // 多级页表项
/*
在内核中先申请一个页面,
利用内核提供的函数,
利用寻页步骤一步步查询各级页目录,
最终找到所对应的物理地址.
等价于手动模拟MMU单元的寻页过程
*/
static unsigned long cr0, cr3;
static unsigned long vaddr = 0;
/* get_pgtable_macro():
打印页机制中的一些重要参数, 例如:
CR3寄存器的值, 通过read_cr3_pa函数获取
*/
static void get_pgtable_macro(void){
cr0 = read_cr0();
// cr3 = read_cr3_pa();
cr3 = read_cr3();
// _SHIFT的宏是指示线性地址中 相应字段所能映射区域大小的对数
// PAGE_SHIFT指page offset字段所能映射区域大小的对数(映射的是一个页面的大小)
// 一个页面大小是4k(1<<12)
printk("cr0 = 0x%lx, cr3 = 0x%lx\n", cr0, cr3);
printk("PGDIR_SHIFT = %d\n", PGDIR_SHIFT);
// printk("P4D_SHIFT = %d\n", P4D_SHIFT);
printk("PUD_SHIFT = %d\n", PUD_SHIFT);
printk("PMD_SHIFT = %d\n", PMD_SHIFT);
printk("PAGE_SHIFT = %d\n", PAGE_SHIFT);
//PTRS_PER_x 这些宏是用来指示相应页目录表中项的个数
printk("PTRS_PER_PGD = %d\n", PTRS_PER_PGD);
// printk("PTRS_PER_P4D = %d\n", PTRS_PER_P4D);
printk("PTRS_PER_PUD = %d\n", PTRS_PER_PUD);
printk("PTRS_PER_PMD = %d\n", PTRS_PER_PMD);
printk("PTRS_PER_PTE = %d\n", PTRS_PER_PTE);
// page_mask 页内偏移掩码, 屏蔽page offset字段
// 为了方便寻页时进行位运算
printk("PAGE_MASK = 0x%lx\n", PAGE_MASK);
}
static unsigned long vaddr2paddr(unsigned long vaddr){
pgd_t *pgd;
// p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
unsigned long paddr = 0;
unsigned long page_addr = 0;
unsigned long page_offset = 0;
pgd = pgd_offset(current->mm, vaddr);
printk("pdg_val = 0x%lx, pgd_index = %lu\n", pgd_val(*pgd), pgd_index(vaddr));
if (pgd_none(*pgd)){
printk("not mapped in pgd\n");
return -1;
}
// p4d = p4d_offset(pgd, vaddr);
// printk("p4d_val = 0x%lx, p4d_index = %lu\n", p4d_val(*p4d), p4d_index(vaddr));
// if (p4d_none(*p4d)){
// printk("not mapped in p4d\n");
// return -1;
// }
// pud = pud_offset(p4d, vaddr);
pud = pud_offset(pgd, vaddr);
printk("pud_val = 0x%lx, pud_index = %lu\n", pud_val(*pud), pud_index(vaddr));
if (pud_none(*pud)){
printk("not mapped in pud\n");
return -1;
}
pmd = pmd_offset(pud, vaddr);
printk("pmd_val = 0x%lx, pmd_index = %lu\n", pmd_val(*pmd), pmd_index(vaddr));
if (pmd_none(*pmd)){
printk("not mapped in pmd\n");
return -1;
}
pte = pte_offset_kernel(pmd, vaddr);
printk("pte_val = 0x%lx, pte_index = %lu\n", pte_val(*pte), pte_index(vaddr));
if (pte_none(*pte)){
printk("not mapped in pte\n");
return -1;
}
page_addr = native_pte_val(*pte) & PAGE_MASK;
page_offset = vaddr & ~PAGE_MASK;
paddr = page_addr | page_offset;
printk("page_addr = %lx, page_offset = %lx\n", page_addr, page_offset);
printk("vaddr = %lx, paddr = %lx\n", vaddr, paddr);
return paddr;
}
static int __init v2p_init(void){
unsigned long vaddr = 0;
printk("vaddr to paddr module is running...\n");
get_pgtable_macro();
printk("\n");
// vaddr = __get_free_page(GFP_KERNEL);
vaddr = __get_free_page(___GFP_HIGHMEM);
if (vaddr == 0){
printk("__get_free_page failed..\n");
return 0;
}
sprintf((char*)vaddr, "hello world from kernel\n");
printk("get_page_vaddr = 0x%lx\n", vaddr);
vaddr2paddr(vaddr);
return 0;
}
static void __exit v2p_exit(void){
printk("vaddr to paddr module is leaving..\n");
free_page(vaddr);
}
module_init(v2p_init);
module_exit(v2p_exit);
MODULE_LICENSE("GPL");
Makefile文件内容
obj-m:= paging_lowmem.o
PWD:= $(shell pwd)
LINUX_KERNEL_PATH := /usr/src/kernels/$(shell uname -r)
all:
make -C $(LINUX_KERNEL_PATH) M=$(PWD) modules
clean:
@rm -rf *.o *.mod.c *.mod.o *.ko *.order *.symvers .*.cmd .tmp_versions
然后就是内核编程的老操作了:
- 通过
make
命令进行编译,生成paging_lowmem.ko文件 - 之后通过
insmod paging_lowmem.ko
命令装载 - 通过
lsmod
命令查看已装在的模块列表,通过dmesg
命令查看printk输出的日志 - 最后通过
remod paging_lowmem
命令卸载
每个人的输出结果可能不同, 这里给出我的输出结果提供一下参考, 顺便方便下面的细节介绍给出示例:
[17024.193831] vaddr to paddr module is running...
[17024.193879] cr0 = 0x80050033, cr3 = 0x3a1a4000
[17024.193880] PGDIR_SHIFT = 39
[17024.193881] PUD_SHIFT = 30
[17024.193882] PMD_SHIFT = 21
[17024.193883] PAGE_SHIFT = 12
[17024.193884] PTRS_PER_PGD = 512
[17024.193885] PTRS_PER_PUD = 512
[17024.193886] PTRS_PER_PMD = 512
[17024.193887] PTRS_PER_PTE = 512
[17024.193887] PAGE_MASK = 0xfffffffffffff000
[17024.193891] get_page_vaddr = 0xffff8caaf497b000
[17024.193893] pdg_val = 0x26aa6067, pgd_index = 281
[17024.193894] pud_val = 0x26aa7067, pud_index = 171
[17024.193895] pmd_val = 0x3498b063, pmd_index = 420
[17024.193896] pte_val = 0x800000003497b063, pte_index = 379
[17024.193897] page_addr = 800000003497b000, page_offset = 0
[17024.193898] vaddr = ffff8caaf497b000, paddr = 800000003497b000
[17038.643975] vaddr to paddr module is leaving..
4.3 细节介绍
很多函数都是第一次使用,在这里简单介绍一下:
注意内核版本!
- 一些库函数, 内核模块编程的<linux/init.h>, <linux/module.h>和<linux/export.h>, 内存映射的<linux/mm.h>, <linux/mm_types.h>和<asm/pgtable.h>, 最后是用于进程管理的<linux/sched.h>. 这些库的功能上网就能轻松查到, 点进去也能看到源码, 也可以看看源码中的注释. 就不多介绍了.
- 一些寄存器: read_cr0()来源于嵌入汇编指令的native_read_cr0(void)函数,该函数被定义在/usr/src/kernels/{version}/arch/x86/include/asm/special_insns.h中,细节实现如下所示:
cr0和cr3属于控制寄存器: cr0用来描述处理器的操作模式和状态控制; cr3用来描述当前进程的页目录表物理内存基地址.extern unsigned long __force_order; static inline unsigned long native_read_cr0(void){ unsigned long val; asm volatile("mov %%cr0,%0\n\t" : "=r" (val), "=m" (__force_order)); return val; }
内核在创建一个进程的时候就会给它分配一个页全局目录, 在进程描述符task_struct结构体中有一个指向mm_struct结构的指针mm, 这个mm_struct结构就是用来描述进程的虚拟地址空间的. mm_struct结构中有一个变量pgd就是用来保存该进程的页全局目录(物理)地址的.
在进程切换的时候, 操作系统通过访问task_struct结构, 再访问mm_struct结构,最终找到pgd字段,取得新进程的页全局目录的地址, 填充到cr3寄存器中, 就完成了页表的切换. - 一些宏:
- XX_SHIFT等宏是指线性地址中相应字段能映射区域大小的对数, (例: 其中PAGE_SHIFT指page offset字段所映射区域大小的对数. 一个页面大小是4k, 就是1<<12, 因此PAGE_SHIFT的值为12), 这里隔9位是一个页面, 符合我们前面的分页理论.
- PTRS_PER_XX等宏是指相应页目录表中项的个数, (例PMD中, 21-39共9位, (1<<9)=512, 因此值是512)
- PAGR_MASK宏是指页内偏移掩码, 用来屏蔽page offset字段的(后12位的页内4K空间)
- 一些寻址函数:
- xxx_offset()这些函数是通过上级页表来寻找下级页表, 通过与上级页表和宏进行位运算得到的. 需要留意的是currect->mm这个参数, 其实就是获取当前进程的mm_struct.
- xxx_index()这些函数描述的是该地址是该页表的第几项
- 从cr3中获得pgd, 然后获得pud, pmd最终获得pte, 获得这些页表的线性地址之后, 通过位运算获得物理地址了
- 先将pte和PAGE_MASK进行&运算, 获得其高48位, 得到了页框的物理地址page_addr
- 取出页偏移量: 将PAGE_MASK按位取反(获得低12位) 后与vaddr进行&操作
- 将页框物理地址和页内偏移量通过 | 拼接起来, 得到物理地址paddr
- 输出案例分析
- 申请到的虚拟地址(线性地址)是get_page_vaddr = 0xffff8caaf497b000
- 将其转成二进制, 然后将对应的索引字段转成十进制和十六进制查看. 这里由于其每个页目录项有8B,因此需要左偏4位才能得到该索引在物理地址中的偏移量.
字段 二进制 十进制 十六进制 vaddr 1111 1111 1111 1111 1000 1100 1010 1010 1111 0100 1001 0111 1011 0000 0000 0000 / 0xffff8caaf497b000 PGD 1000 1100 1 281*8B 8c0 PUD 010 1010 11 171*8B 558 PMD 11 0100 100 420*8B d20 PTE 1 0111 1011 379*8B bd8 - 依次查找了这个虚拟地址的pgd, pud, pmd和pte和对应的页内偏移:
- 获取pgd页表项的物理地址: 从cr3中获取基地址0x3a1a4000, 也就是pgd的起始地址然后加上PGD的偏移量8c0组成一个新的物理地址: 0x3a1a48c0, 这个物理地址就是pgd页表中项的物理地址(这个项当然就是pgd页表中的项了, 内容就是下一级页表(pud)的物理地址)
- 获取pud页表项的物理地址: 查找pgd物理地址中的内容, 0x0000 0000 26aa 6067, 这个数据就是下级页表(pud)的物理地址.同理加上偏移量0x558组成一个一个新的物理地址0x26aa6558就是pud页表中项的地址了, 其中包含的内容就是下一级页表(pmd)的物理地址
- 获取pmd页表项的物理地址: 查找pud页表项中的数据: 0x26aa 7067, 加上pmd的偏移量0xd20组成一个新的物理地址: 0x26aa 7d20, 这个物理地址就是pmd页表项的物理地址了, 其中保存的是下一级pte的物理地址.
- 获取pte的的物理地址: 基地址0x3498b063+偏移0xbd8组成的0x3498bbd8就是pte的物理地址了.其中内容就是页的实际物理地址0x800000003497b063. 一页是4k, 所以该页的实际物理地址是0x800000003497b000.
- 需要留意的是, 物理地址的后三位是无效的, 因为需要与页内偏移做拼接, 这后三位一般是0x067或0x063, 是用来描述相应的页目录项或者页表的属性. 在做地址加减的时候置0即可.
- 还可以留意到,其实这个实际物理地址和虚拟地址在低位很相似, 尝试了几次, 发现虚拟地址都是物理地址左偏一个0xffff000000000000, 其实也很容易理解, 毕竟前几位不存在麻. 中间的几位就是中间的各级页表的页内偏移.
- Linux中提供了多种访问物理地址的方法, 包括mmap()物理内存映射, ioremap()+iounmap()物理到虚拟地址的映射, 或者通过
dd if=/dev/mem
的方式, 都可以通过物理地址进行访问. 感兴趣的可以自行查看.
参考资料
- 学堂在线-Linux内核分析与应用:https://next.xuetangx.com/course/XIYOU08091001441/14767915
- 《i386体系结构》上和下:http://kerneltravel.net/blog/2020/i386_1/
- 深入分析LINUX内核源码:http://kerneltravel.net/book/
- chatgpt