内核探秘之指针存储之谜

  • 题注:本文以电子科技大学计算机学院李林老师授课内容为依据,记录本人学习的思路。

1 指针变量里存储的到底是什么

int test;
int* ptr = &test;

指针变量ptr 里存储的是什么?这不是废话吗,是地址。ptr中存储了test的地址,指针类型指明存放在该地址处的数据类型。
在汇编语言中,我们使用逻辑地址(段基址:偏移量)。
在8086CPU中,有20位地址总线,但CPU是16位的,它一次只能送出16位的地址。所以它在内部采用一种将两个16位地址合成一个20位物理地址的方式。这两个16位地址我们称为段地址偏移地址。地址加法器采用物理地址 = 段地址 x 16 +偏移地址的方式实现了1MB的寻址能力。

1.1 基本概念

逻辑地址:是指从应用程序角度看到的内存单元地址。例如,你在进行C语言指针编程中,能读取变量本身值(&操作),实际上这个值就是逻辑地址,它相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换)。应用程序员仅需和逻辑地址打交道,而分段和分页机制对你来说是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。

虚拟地址:是Windows程序运行在i386保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址。从虚拟地址到物理地址的运行时映射是由内存管理单元(MMU)的硬件设备来完成。

线性地址:是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。

物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。

虚拟内存:是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。一个非常恰当的比喻是:你不必非常长的轨道就能让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就能完成这个任务。采取的方法是把后面的铁轨即时铺到火车的前面,只要你的操作足够快并能满足需求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在Linux0.11内核中,给每个程序(进程)都划分了总容量为64MB的虚拟内存空间。因此程序的逻辑地址范围是0x0000000到0x4000000。有时我们也把逻辑地址称为虚拟地址。因为和虚拟内存空间的概念类似,逻辑地址也是和实际物理内存容量无关的。逻辑地址和物理地址的“差距”是0xC0000000,是由于虚拟地址->线性地址->物理地址映射正好差这个值。这个值是由操作系统指定的。逻辑地址(或称为虚拟地址)到线性地址是由CPU的段机制自动转换的。如果没有开启分页管理,则线性地址就是物理地址。如果开启了分页管理,那么系统程式需要参和线性地址到物理地址的转换过程。具体是通过设置页目录表和页表项进行的。

分段与分页见:分段与分页

在这里插入图片描述

1.2 指针变量存储的只是偏移量

分页机制可以绕开,但分段机制是绕不开的。程序直接使用的只能是逻辑地址,所以我们把段基址存放在寄存器中,ptr只能存放偏移量,所以指针变量中存储的只是偏移量
我们在写C程序时基本没有考虑过段寄存器、near/far jmp/call等等,但是程序好像依然能正确运行。由于程序直接使用的是逻辑地址,为什么操作系统中经常提到的是“进程线性地址空间”,而不是“进程逻辑地址空间”?要知道逻辑地址空间只有偏移量,而进程线性地址空间是在分段处理后的地址。

好像将分段机制绕开了?????怎么验证我们的猜想?去Inter体系结构中找依据,参见 Intel® 64 and IA-32 Architectures Software Developer’s Manual,P2874。下面简要介绍一下内存管理过程。
在这里插入图片描述
分段机制将处理器的线性地址空间分割成较小的被保护的叫做段的单元。段能用来存储程序的代码、数据和栈,或存储系统的数据结构。如果有多个进程同时在一个CPU上运行,分段机制使它们只能在自己的段中运行。逻辑地址包括偏移量和段选择两部分。逻辑地址空间的偏移量部分存储的是我们指针变量中的值,段选择部分对某个段来说是唯一的,GDTR寄存器中存储了GDT表(global descriptor table)的基地址和大小,利用GDT表的基地址和段选择这两部分对应了GDT表中的某项段描述符。段描述符存储了段的一些重要信息,如段的大小、存取权限、段类型和段基址。所以逻辑地址的偏移量部分加上段基址就对应了线性地址空间中的地址。
P2879页有 下图中Segment Selector的介绍,bit2指明是LDT还是GDT,bit1-0指请求特权级(RPL),代表选择子的特权级,共有4个特权级(0级、1级、2级、3级)。
GDT\LDT\GLDT见:GDT\LDT\GLDT
在这里插入图片描述
段描述符是一个存储了段信息的数据结构,见P2882,如下图所示
在这里插入图片描述

1.3 内核帮我们完成了

在写汇编时我们需要考虑段,需要使用CPU提供的地址转换机制完成各种段的设置,但在写C时不需考虑。操作系统是如何使用CPU提供的的分段管理机制呢?
分段管理机制不外乎涉及到GDTR寄存器、GDT表和段寄存器,读出这些信息,自然就知道操作系统是如何使用分段机制的。

怎么读取GDT、GDTR、段寄存器?
不能使用mov指令访问GDTR寄存器,必须使用特殊的sgdt和lgdt指令,sgdt读取GDTR,lgdt写入GDTR,这两个指令是特权指令,只能在ring 0级别读取。GDT表也是位于内核地址空间,用户态程序不能读取,因此,需要编写内核模块查看GDTR、GDT的内容。

  • 程序3.1步骤:
  • 编写用户态程序,获取其CS、DS、SS,以弄清使用的是GDT or LDT,以及index.
  • 进入内核,读取GDTR寄存器,获取GDT的基地址。
  • 打印出GDT表格的内容,并找出各个段寄存器对应的表项,即段描述符。
  • 分析段描述符内的基地址、段界限等信息。

在内核设备驱动函数中用内联汇编获取三个段寄存器内容和GDTR。
在这里插入图片描述
在main函数中,调用write函数打开设备时,内核驱动程序函数Driverwrite函数执行。
在这里插入图片描述
先编译并插入内核模块,在调试信息中看到内核模块已插入。
在这里插入图片描述
在这里插入图片描述
编译并运行main函数,看到打印出的三个段的信息,和dmesg中的调试信息
在这里插入图片描述
在这里插入图片描述
32位机用户态的CS段寄存器值为0x73,我们下面对CS段进行分析,所以在这里Segment Selector段寄存器指的是CS段寄存器
index指向表中第15项。注意Inter CPU为小端格式,高字节数据在高地址处,上图打印出来的数据是按地址顺序打印的,要注意转换。
在这里插入图片描述
在这里插入图片描述
根据段描述符数据结构的定义,CS段寄存器指定的段描述符如下,我们可以解析出如下信息。
在这里插入图片描述
在这里插入图片描述
我们可以知道段基址等于0!!!!!!!!!!段界限为0xFFFFFFFF!!!!!
卧槽!!!!!!!在32位的机器中,Linux将CS段范围设为了[0, 0xFFFFFFFF]。

因此全局只有一个段,等于没有分段。
所以在32位的Linux中,对于 ”段基址 + 偏移量 = 线性地址“ 这个公式由于段基址 = 0,所以偏移量 = 线性地址。
所以前面的ptr指针变量中既存储偏移量,也存储线性地址。

全局因为只有一个段,相当于没有段,感觉起来绕开了分段机制,因此即使没有意识到使用的是偏移量,程序也正常;经常使用线性地址空间,代替逻辑地址空间;通常编程时,不用关心段寄存器,因为只有一个段,都在一个段内,所以不用关心了,不用call或者jump了。

不仅Linux使用flat内存模型,windows也采用了,在x64中,忽略了段描述符中的段基址和段界限,即cpu直接支持flat模式,fs、gs例外。

2 内核管理GDT表

下面从内核源码的角度分析内核对GDT表的管理方法。
内核使用lgdt指令,把GDT表的基地址写入GDTR寄存器。我们在内核源码搜索网站bootlin中搜索出现了gdt的地方。
我的32位Ubuntu的内核版本为3.13.0,在/arch/x86/include/asm/desc.h文件中找到了对lgdt指令的调用,内核通过native_load_gdt函数把GDT表的基地址写到GDTR寄存器中去。
在这里插入图片描述
我们再看哪里调用了native_load_gdt函数,前面那个地方是定义。
在这里插入图片描述
先看desc.h文件,native_load_gdt函数用宏load_gdt代替,在/arch/x86/include/asm/desc.h文件中
在这里插入图片描述
我们看看宏CONFIG_PARAVIRT是否被定义,查看文件/boot/config-3.13.0-32-generic发现其中没有定义,所以定义了load_gdt宏。
在这里插入图片描述
再看文件/arch/x86/kernel/paravirt.c,发现其中定义了pv_cpu_ops结构,该结构中保存了很多函数指针,这些函数实现了CPU的一些指令操作。
在这里插入图片描述
在内核源码文件中搜索pv_cpu_ops.load_gdt出现的地方,在/arch/x86/include/asm/paravirt.h中,load_gdt函数调用了这个函数指针变量。
在这里插入图片描述
/arch/x86/kernel/cpu/common.c中调用了load_gdt函数,读取了GDTR,获取了GDT表的基地址。
在这里插入图片描述
查找get_cpu_gdt_table的定义,在/arch/x86/include/asm/desc.h中,
在这里插入图片描述
gdt_page定义在/arch/x86/include/asm/desc.h,**gdt_page在DEFINE_PER_CPU_PAGE_ALIGNED宏中被导出为全局结构体实例,**是一个全局变量,内核模块都可以访问。
在这里插入图片描述
在32位环境下,在/arch/x86/include/asm/segment.h在这里插入图片描述
desc_struct的定义在/arch/x86/include/asm/desc_defs.h中。
在这里插入图片描述
对比Intel手册,结构体desc_struct,就是用来描述GDT表项,即它就是段描述符,结构体desc_struct用来管理GDT表。GDT表中的一个表项就对应着这样的一个结构,在32位环境中gdt_page最多能够存储32个这样的表项。
/arch/x86/kernel/cpu/common.c中,DEFINE_PER_CPU_PAGE_ALIGNED宏
在这里插入图片描述
GDT_ENTRY_KERNEL_CS定义在/arch/x86/include/asm/segment.h中,我们看到默认的用户CS段表项是14,与之前我们读出的CS段寄存器相符。
在这里插入图片描述
/arch/x86/include/asm/desc_defs.h中有GDT_ENTRY_INIT的定义,我们看到通过调用宏GDT_ENTRY_INIT,得到了GDT_ENTRY_KERNEL_CS的值。
在这里插入图片描述

[GDT_ENTRY_DEFAULT_USER_CS]	= GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff)

GDT_ENTRY_INIT定义这么复杂,怎么办?
编写内核模块。

  • 程序3.2:
    在这里插入图片描述在这里插入图片描述
    可以看到,与前面输出的用户态CS段描述符相比,仅仅bit40不同,由Inter手册知,仅有A位不同。指被访问过,内核态的代码用于对其初始化,初始化的时候没有人访问过用户态CS段描述符。而我们在用户态进行读取的时候,段已经访问过了,CPU会把其置位1

最好不要用内核模块帮忙,最好用应用程序实现,main函数如下

  • 程序3.3:在这里插入图片描述
    我们看到结果是相同的。
    在这里插入图片描述

3 64位的变化

由于Windows、Linux等都采用了Flat Memory Model(平坦内存模型),所以Inter在在64位CPU中直接支持平坦内存模型,硬件忽略段描述符中的段基址和段界限(FS和GS段除外)。

  • 程序3.4:
    思路和程序3.1相同,不过64位的GDTR寄存器长10B,32位的是6B,因为地址多了4B。同时段描述符的支持个数从32降到了16。dmesg中调试信息如下:
    在这里插入图片描述
    /arch/x86/include/asm/segment.h中,有64位的GDT表项布局,因为64位操作系统要支持对32位程序的运行,所以其中包含了32位的一些描述符。
    在这里插入图片描述在这里插入图片描述
    下面这些值用于赋给段寄存器。*8是左移3位,+3是011,特权级别ring3,指用户态。
    在这里插入图片描述
    在这里插入图片描述

4 直接读取gdt_page来访问GDT表

我们知道在gdt_page的索引为4的gdt中存储的是用户态32位代码段描述符,它是被导出的全局结构体变量,我们可以通过内核模块来读取它的值。

  • 程序3.5
    在驱动函数中打印出该表项,并在main函数中打开设备写数据从而调用驱动函数。
    在这里插入图片描述
    我们发现main函数输出
    在这里插入图片描述
    dmesg输出
    在这里插入图片描述
    地址出现了9020?这个页面错误不能处理。这个地址很小,64位的内核态地址应该是FFFF开头,好像这个地址是用户态地址。我们接下来调试刚刚生成的内核模块。
  • 程序3.6
    Makefile发生变化在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值