Skip to content

Latest commit

 

History

History
315 lines (256 loc) · 22.4 KB

linux内核内存技术探秘.md

File metadata and controls

315 lines (256 loc) · 22.4 KB

这个资料实际很早该写的,因为基本上所有其余的研究都绕不开内存。

不管是不是linux,先回归到计算机架构上,目前大部分架构都是冯诺依曼结构,也就是指令数据都是在同一个存储器上的。但是再往前走的话就要说一下图灵机。 那不管是什么,这样一个存放数据的东西就是要说的内存

计算机中的所有程序的运行都是在内存中进行的,其中包含了一个程序所需要用到的指令数据内存CPU空闲时会传输给CPU完成处理运算。更多的内容应该要学习下《计算机组成原理》

Linux上的内存

既然任何指令数据都是在内存的,那么实际上是可以在运行过程控制到内存的数据。CPU是用来运算的,而内存提供相关的资源,那CPU是怎么获取到内存上的资源的呢? 这部分就不提及总线了,因为这部分实际上来说并不是要去控制的东西,着重学习寻址

地址寻址

首先就是如何去看待内存,最简单来说就是把它视为一个存放信息的表格,那既然是表格就会有编号,这样才能方便寻找,这样的编号就是内存地址,而CPU寻址就很麻烦了,因为从早期到现在变了不少东西,最典型的就是物理地址虚拟地址线性地址的引入。

  • 物理地址内存中每个内存单元的编号,内存单元可以理解为一个表格的单个格子。
  • 虚拟地址:程序产生的由段选择符段内偏移地址组成的地址,简单来说就是偏移地址(逻辑地址)
  • 线性地址:在没有分页机制的情况下,线性地址就是物理地址,不然就是物理虚拟的中间层。

物理分页得线性,线性分段得虚拟

以前编程都是直接访问物理地址进行编程,然后只要程序出错了,整个系统就崩溃了,或者只要一个程序写错了内存区域,可能另一个程序就崩溃了,这样的情况下,就提出了各种保护机制,其中包括一点就是程序使用虚拟地址访问内存,处理器负责虚拟地址到物理地址的映射,这保证了程序不会直接接触到物理内存,有了一个缓冲可以作各种操作,比如寻址出错后挂起错误进程

那在这种情况下,cpu是根据物理地址拿数据,而进程接触到的是虚拟地址

世事无绝对

内核态用户态

本来说直接就整寻址了,但是就以Linux来说,就必然绕不过非对称访问机制,也就是把一整个内存空间划分为用户空间内核空间,这样设计主要还是安全性的考虑,两种空间相互独立,且权限并不相同。那就要先把内存布局给整一下,毕竟内存硬件就那么一个,怎么使用就必然是一个问题。

先讲32位的Linux,其CPU的寻址能力是4GB,那么就4GB的虚拟内存是怎么分配的呢(不是指物理内存大小,单单指的是cpu的处理能力上限)?这儿用的是3:1划分法,也就是用户空间用3GB,内核空间1GB。但是先前说过了非对称访问机制的问题,内核空间的权利是远高于用户空间的,这个权利表现在何处呢?那就是用户空间3GB内存是实打实的3GB用户态程序最多只能访问3GB虚拟内存,就算计算机上的内存条是8GB大小,用户空间也只能用到其中的3GB,即最大可用空间,至于不够用那就自己做内存数据交换吧。然而内核空间虽然值有1GB的大小却要求能够访问到所有物理内存。

内核需要具有对所有内存的寻址能力

706e6d69-d7b2-4173-b5b2-4315628e0b19.png

接着说怎么划分呢?从内核空间开始谈。 这玩意的划分是写在内核代码里的/arch/x86/include/asm/page_32_types.h

/*
 * This handles the memory map.
 *
 * A __PAGE_OFFSET of 0xC0000000 means that the kernel has
 * a virtual address space of one gigabyte, which limits the
 * amount of physical memory you can use to about 950MB.
 *
 * If you want more physical memory than this then see the CONFIG_HIGHMEM4G
 * and CONFIG_HIGHMEM64G options in the kernel configuration.
 */
#define __PAGE_OFFSET _AC(CONFIG_PAGE_OFFSET, UL)

首先要明白程序接触到的都是逻辑地址,不管是内核空间还是用户空间,如上的话用户空间的起始地址是0x00000000内核空间的起始地址是0xc0000000,先前也说过逻辑地址实际上就是基于基地址偏移地址物理地址 = 逻辑地址 – 0xC0000000? 并非如此,如果单纯是地址映射的话,0xffffffff对应的物理地址就是上限了,无法访问上限后的内存,因此在这个需求下物理地址空间又做了划分,即ZONE_NORMALZONE_DMA之上又引入了高端内存(ZONE_HIGHMEM)的概念,范围是0xF8000000 ~ 0xFFFFFFFF,折算一下也就是128MB,至于使用方法就是用时映射,用完释放

借一段地址空间,建立临时地址映射,用完后释放,达到这段地址空间可以循环使用,访问所有物理内存

也就是物理地址空间布局如下:

2ddfee33-5fde-404c-af6e-de0783396e62.jpg

但是呢,64位则是没有这些东西的,因为64位的寻址能力特别大,而关于内核空间用户空间的划分直接看内核文档: /x86/x86_64/mm.txt

  • 0xffff800000000000之后就是内核空间了,大概128TB,而且目前的x86_64架构的CPU其实都遵循AMDCanonical Form,即只有虚拟地址的最低48位才会在地址转换时被使用,且任何虚拟地址48-63位必须与47位的一致,一般用户模式地址下高16bit都是0x0,而内核模式的地址则都是0xf

用户空间的内存就比较奇特了,就是32位下每个进程都有一个3GB用户空间子进程共享/继承与父进程相同的线性地址物理地址映射关系,但是并非共享父进程的用户空间。

e556e07f-c0d9-4ca5-8985-1fc8b012c4f4.jpg

其中用户空间看到的那部分kernel space是C运行库的内容,用户无法访问,否则会段错误。 这又是关于虚拟内存管理的东西(来自百度):

当程序的存储空间要求大于实际的内存空间时,就使得程序难以运行。虚拟存储技术就是利用实际内存空间和相对大的多的外部储存器存储空间相结合构成一个远远大于实际内存空间的虚拟存储空间,程序就运行在这个虚拟存储空间中。能够实现虚拟存储的依据是程序的局部性原理,即程序在运行过程中经常体现出运行在某个局部范围之内的特点.在时间上,经常运行相同的指令段和数据(称为时间局部性),在空间上,经常运行与某一局部存储空间的指令和数据(称为空间局部性),有些程序段不能同时运行或根本得不到运行。

地址转换(全部基于64位)

先前都说过了,为了安全性的问题,虽然数据是实际保存在物理地址上的,但是进程接触到的都是虚拟地址,那自然得有一个虚实转换的过程。

然而对于内核空间来说,逻辑地址物理地址就是一个单纯的线性映射关系,而为什么不用分页管理主要有两个原因:

  1. 不用分页
  2. 不能分页

不用分页是因为linux内核中并不需要分配大内存,功能上完全由用户程序来决定,因此内核本身不需要针对内存有太多的需求。而不能分页是因为内核内存是不可被交换到二级存储中的。先前研究启动机制的时候就说明过,linux本身可以看作是一个大进程,但是这个进程能够直接操作硬件,管理内存,例如中断实现上的指针啊,数据啊都是在内核内存中的,如果这部分内存交换到二级存储中的话,无疑是繁琐了中断处理的过程,倘若缺页中断的函数被交换到二级存储中,且内核发生缺页中断,那就没有相应的处理方式了。对于分页管理道理是相同的,如果分页函数被交换到二级存储中的话,内核本身就失去了分页机制。所以linux内核是没有分页内存的。

因此内核内存的转换也很有意思,当然在32位的情况下因为存在高端内存,那实际上来说是分为两种的转换方式的,一个是针对低端内存的直接映射关系,也被称为线性映射区域,另一种就是针对高端内存的映射关系,但是鉴于x86_64没有高端内存,就放在以后研究吧。

回到内核内存的映射,内核中直接映射物理地址的区域称为线性映射区域,这一区域的虚拟地址物理地址相差一个PAGE_OFFSET,这是kernel image起始虚拟地址,当然还需要加上kernel image起始物理地址,即PHYS_OFFSET

也就是PHYS_ADDR = VIRT_ADDR - PAGE_OFFSET + PHYS_OFFSET,这部分常驻内存中,然而在Linux 2.0后内核开始支持模块化,即只有在需要时才载入,这种内核模块机制让内核保持小体积,模块被加载到内核后,内核会为模块分配一块虚拟地址空间,在4.x下模块被放置到VMALLOC区域,所以模块申请的数据等虚拟地址都是在VMALLOC空间中,用MODULE_VADDR指定为MODULE虚拟内存区起始的地址,MODULE_END为终止地址

#define MODULES_VADDR (__START_KERNEL_map + KERNEL_IMAGE_SIZE)
/* The module sections ends with the start of the fixmap */
#define MODULES_END _AC(0xffffffffff000000, UL)

其中__START_KERNEL_map + KERNEL_IMAGE_SIZE的值是0xffffffffc0000000,而这一部分的物理地址虚拟地址的转换需要建立新的页表,推后再说。

而对于用户内存来说,地址转换要复杂得多,用到了4级页表

  • 逻辑地址 -- 线性地址 -- 物理地址
  1. 段机制
  2. 页机制

d7930a1b-8f4d-4c2f-bb1e-162cadcb61b6.png

#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)

首先是页机制,内核将内存分成了很多4kb大小的,这是标准页大小,以后内存管理关于内存的分配和回收的基本单位都是内存页,然后涉及到连续性问题又引入了一个伙伴算法,就是把页面添加到伙伴系统中适当的free_area链表里,然后释放时查看相邻内存块是否空闲,如果空闲就合并成更大的然后放到更高一阶的链表里,如此重复,这样就自然能获取连续的大内存块(页框块)。 但是一页这么大,然而内核一次要用到的内存可能远小于一页,是一个非常小的内存块,这些又会频繁的生成和销毁,为了满足内核对小内存的需求,就引入了一种slab分配器。这是基于伙伴系统基础上的一种内存分配方式,就是把空闲链表中的页撕碎成众多校内存块,用完后也不是直接释放而是放在存储池中,留着以后再用。

关于地址转换64位Linux下的地址映射这个文章讲的挺好的,处理器默认 CS, DS, ES, SS的段基址为 0,所以我们下面就不讨论逻辑地址到线性地址的转换了,因为基址为0,经过运算后线性地址和逻辑地址是一样的。

转化顺序: a) 找到vmalloc虚拟内存对应的页表,并寻找到对应的页表项。 b) 获取页表项对应的页面指针。 c) 通过页面得到对应的内核物理内存映射区域地址。

实际测试

通过一个程序,把一个数据的逻辑地址线性地址物理地址都输出出来吧。

逻辑地址其实很好说,如果不考虑之前说的段基址=0的情况,还是存在逻辑地址线性地址的转换过程的:

IA-32情况下的图

bb84bca4-cd9a-46c5-846a-7f2ca18d5af0.png

Offset+段基址=线性地址,然而Linux其实基本就不用分段机制,因为这个机制在Linux上也只是为了兼容下IA-32的硬件而已,但是呢IA-32的设计上段机制是不能够避免的,所以才会出现上面所说的段基址=0的情况,至于为什么是四个段寄存器这还是因为IA-32规定代码段数据段必须是分开创建的不同的段,然而又因为ring 0ring 3最终就导致了有四个段寄存=0。 这儿实际就按照实际去算一下Base Address便是,先看内核的代码:

static void
start_thread_common(struct pt_regs *regs, unsigned long new_ip,
      unsigned long new_sp,
      unsigned int _cs, unsigned int _ss, unsigned int _ds)
{
 WARN_ON_ONCE(regs != current_pt_regs());

 if (static_cpu_has(X86_BUG_NULL_SEG)) {
  /* Loading zero below won't clear the base. */
  loadsegment(fs, __USER_DS);
  load_gs_index(__USER_DS);
 }

 loadsegment(fs, 0);
 loadsegment(es, _ds);
 loadsegment(ds, _ds);
 load_gs_index(0);

 regs->ip = new_ip;
 regs->sp = new_sp;
 regs->cs = _cs;
 regs->ss = _ss;
 regs->flags = X86_EFLAGS_IF;
 force_iret();
}

void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
 start_thread_common(regs, new_ip, new_sp,
       __USER_CS, __USER_DS, 0);
}
EXPORT_SYMBOL_GPL(start_thread);

可以看下面的传参就明白了,其实内容就两个:

  1. __USER_DS 数据段
  2. __USER_CS 代码段

再去翻一翻定义:

#define GDT_ENTRY_DEFAULT_USER_DS 5
#define GDT_ENTRY_DEFAULT_USER_CS 6
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)

+3表示无特权

换算二进制,这儿二进制就是段选择码

8cfa5c43-9212-4628-81b4-2b0091d424a4.png

TI位就是选择的段描述符表,大家都是0因此都是GDT表,也就是cpu_gdt_table,然后这个cpu_gdt_table基址在内存管理寄存器GDTR中,段选择码的高13位就是index,也就是5这个indexcpu_gdt_table的下标,即该DS段描述符基址,不过这个基址在源码中是硬编码的:

 [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),

看函数:

#define GDT_ENTRY_INIT(flags, base, limit) { { { \
  .a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
  .b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
   ((limit) & 0xf0000) | ((base) & 0xff000000), \
 } } }

这儿直接就说明了,DS段的base值为0。  ede7e966-84d5-4e3c-91a5-1de3d03d5c7e.png

  • Base Address = Base 15:00 + Base 23:16 + Base 31:24

这个可以通过代码看出来:

/* 8 byte segment descriptor */
struct desc_struct {
	u16	limit0;
	u16	base0;
	u16	base1: 8, type: 4, s: 1, dpl: 2, p: 1;
	u16	limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
} __attribute__((packed));

#define GDT_ENTRY_INIT(flags, base, limit)			\
	{							\
		.limit0		= (u16) (limit),		\
		.limit1		= ((limit) >> 16) & 0x0F,	\
		.base0		= (u16) (base),			\
		.base1		= ((base) >> 16) & 0xFF,	\
		.base2		= ((base) >> 24) & 0xFF,	\
		.type		= (flags & 0x0f),		\
		.s		= (flags >> 4) & 0x01,		\
		.dpl		= (flags >> 5) & 0x03,		\
		.p		= (flags >> 7) & 0x01,		\
		.avl		= (flags >> 12) & 0x01,		\
		.l		= (flags >> 13) & 0x01,		\
		.d		= (flags >> 14) & 0x01,		\
		.g		= (flags >> 15) & 0x01,		\
	}

那就再从代码层反过来走一遍:

  • ds --> selector --> gdtr --> cpu_gdt_table --> user_ds --> base

然而问题是需要使用特权指令来保存寄存器信息,因此用户态下是没法编写的,只能用内核模块的方式解决这个问题。

准备下结构体: gdtr:

struct gdtr {
 long int limite;
 char base;
} __attribute__ ((packed));

cpu_gdt_table:

struct gdt_page {
 struct desc_struct gdt[GDT_ENTRIES];
} __attribute__((aligned(PAGE_SIZE)));

通过gdtr提取USER_DS的地址,最终通过拼接base获取到Base Address00000000,因此这儿就可以证明逻辑地址=线性地址

fe3284ea-f23c-4000-8125-677f3c25d5e0.png

已经获取到了线性地址,那接下来就再去找对应的物理地址。 这儿是一个页式过程,过程比段式更为繁琐。

3b7efa1f-df3a-4809-a7ab-25a2a3e3a195.png

首先从CR3寄存器中获取到页目录(Page Directory)的基地址,然后通过线性地址中的Directory位页目录中找到页表(Page Table)的基地址,接着根据线性地址中的Table位页表中找到页面(Page)基地址,最后这个基址 + Offset位 = 物理地址。 按照64位的模型来(9,9,9,9,12):

845f59e6-57c7-4c89-80fd-fca5ff06dccd.png

首先就是关于CR3的获取,内核中有这样的一串函数:

static inline void load_cr3(pgd_t *pgdir)
{
 write_cr3(__sme_pa(pgdir));
}

这个函数是加载页目录地址到cr3中的,去找pgd_t这个类型最后定位到的是typedef unsigned long pgdval_t; 关于获取就直接用api就行就不用写汇编了

unsigned long cr3;
cr3 = read_cr3_pa();

x86_64线性地址并不是64bit而是48bit,同时物理地址40bit。这厮因为CPU最高物理地址52bit,而实际支持的物理内存地址总线宽度是40bit

cr3之后获取的信息,如页面目录页表等信息都保存在物理内存中,这就需要到物理内存访问,然而虽然内核模块能够访问到物理地址,但是代码本身是无法访问,代码访问的都是逻辑地址,因此还需要作转换才能提取值,具体的可以看这个:Linux用户程序如何访问物理内存

而关于每一级获取到的物理映射地址,要注意一点的就是在4级转换过程中都要把低12bit置为0,因为这部分是页属性,并不需要。

10f86009-2cbe-498b-9ff1-71fb2f80a687.png

用户态下的代码就借助study-linux-vm-64bit稍微修改一点用起来就行了。然后手动提取下物理地址的值就行,为什么不用代码全自动提取呢?因为这部分用户态不可做得用内核模块来处理,而且涉及到新建页关系,所以就没深入研究。

200fa811-0c57-4f01-a6a8-480879384f00.png

这儿需要注意的是每个数据项是8字节,所以都要*8。 例如第一级映射:

0x2002a000+0x0*8
最终这个地址中的值的`base address`就是页面表起始地址。

关于代码上有个坑点,就是C语言中地址是一个unsigned的类型,这儿需要注意,否则前面会被填充。等后面用空时候再重写一下这个工具的代码,让它能全自动输出映射地址还有值。

参考资料