这个资料实际很早该写的,因为基本上所有其余的研究都绕不开内存。
不管是不是linux,先回归到计算机架构上,目前大部分架构都是冯诺依曼结构
,也就是指令
和数据
都是在同一个存储器上的。但是再往前走的话就要说一下图灵机
。
那不管是什么,这样一个存放数据的东西就是要说的内存
。
计算机中的所有程序的运行都是在内存中进行的,其中包含了一个程序所需要用到的指令
和数据
,内存
在CPU
空闲时会传输给CPU
完成处理
和运算
。更多的内容应该要学习下《计算机组成原理》
。
既然任何指令
和数据
都是在内存的,那么实际上是可以在运行过程控制到内存的数据。CPU
是用来运算
的,而内存
提供相关的资源,那CPU
是怎么获取到内存
上的资源的呢?
这部分就不提及总线
了,因为这部分实际上来说并不是要去控制的东西,着重学习寻址
。
首先就是如何去看待内存
,最简单来说就是把它视为一个存放信息的表格
,那既然是表格
就会有编号
,这样才能方便寻找,这样的编号
就是内存地址
,而CPU
的寻址
就很麻烦了,因为从早期到现在变了不少东西,最典型的就是物理地址
,虚拟地址
,线性地址
的引入。
物理地址
:内存
中每个内存单元
的编号,内存单元
可以理解为一个表格
的单个格子。虚拟地址
:程序产生的由段选择符
和段内偏移地址
组成的地址,简单来说就是偏移地址
(逻辑地址)线性地址
:在没有分页机制
的情况下,线性地址
就是物理地址
,不然就是物理
和虚拟
的中间层。
物理分页得线性,线性分段得虚拟
以前编程都是直接访问物理地址
进行编程,然后只要程序出错了,整个系统就崩溃了,或者只要一个程序写错了内存区域,可能另一个程序就崩溃了,这样的情况下,就提出了各种保护机制,其中包括一点就是程序使用虚拟地址访问内存,处理器负责虚拟地址到物理地址的映射
,这保证了程序
不会直接接触到物理内存
,有了一个缓冲可以作各种操作,比如寻址出错后挂起错误进程
。
那在这种情况下,cpu
是根据物理地址
拿数据,而进程
接触到的是虚拟地址
。
世事无绝对
本来说直接就整寻址
了,但是就以Linux
来说,就必然绕不过非对称访问机制
,也就是把一整个内存空间划分为用户空间
和内核空间
,这样设计主要还是安全性的考虑,两种空间相互独立,且权限并不相同。那就要先把内存布局给整一下,毕竟内存
硬件就那么一个,怎么使用就必然是一个问题。
先讲32位的Linux,其CPU
的寻址能力是4GB
,那么就4GB
的虚拟内存是怎么分配的呢(不是指物理内存大小,单单指的是cpu的处理能力上限)?这儿用的是3:1划分法
,也就是用户空间
用3GB,内核空间
用1GB
。但是先前说过了非对称访问机制
的问题,内核空间
的权利是远高于用户空间
的,这个权利表现在何处呢?那就是用户空间
的3GB
内存是实打实的3GB
,用户态程序
最多只能访问3GB
虚拟内存,就算计算机上的内存条是8GB
大小,用户空间
也只能用到其中的3GB
,即最大可用空间,至于不够用那就自己做内存数据交换吧。然而内核空间
虽然值有1GB
的大小却要求能够访问到所有物理内存。
内核需要具有对所有内存的寻址能力
接着说怎么划分呢?从内核空间
开始谈。
这玩意的划分是写在内核代码里的/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_NORMAL
和ZONE_DMA
之上又引入了高端内存(ZONE_HIGHMEM)
的概念,范围是0xF8000000 ~ 0xFFFFFFFF
,折算一下也就是128MB
,至于使用方法就是用时映射,用完释放
。
借一段地址空间,建立临时地址映射,用完后释放,达到这段地址空间可以循环使用,访问所有物理内存
也就是物理地址空间
布局如下:
但是呢,64位
则是没有这些东西的,因为64位
的寻址能力特别大,而关于内核空间
和用户空间
的划分直接看内核文档:
/x86/x86_64/mm.txt
0xffff800000000000
之后就是内核空间了
,大概128TB
,而且目前的x86_64
架构的CPU其实都遵循AMD
的Canonical Form
,即只有虚拟地址
的最低48
位才会在地址转换时被使用,且任何虚拟地址
的48-63
位必须与47
位的一致,一般用户模式地址下高16bit都是0x0
,而内核模式的地址则都是0xf
。
用户空间
的内存就比较奇特了,就是32位
下每个进程
都有一个3GB
的用户空间
,子进程
共享/继承与父进程
相同的线性地址
到物理地址
映射关系,但是并非共享父进程
的用户空间。
其中用户空间看到的那部分kernel space
是C运行库的内容,用户无法访问,否则会段错误。
这又是关于虚拟内存管理
的东西(来自百度):
当程序的存储空间要求大于实际的内存空间时,就使得程序难以运行。虚拟存储技术就是利用实际内存空间和相对大的多的外部储存器存储空间相结合构成一个远远大于实际内存空间的虚拟存储空间,程序就运行在这个虚拟存储空间中。能够实现虚拟存储的依据是程序的局部性原理,即程序在运行过程中经常体现出运行在某个局部范围之内的特点.在时间上,经常运行相同的指令段和数据(称为时间局部性),在空间上,经常运行与某一局部存储空间的指令和数据(称为空间局部性),有些程序段不能同时运行或根本得不到运行。
先前都说过了,为了安全性的问题,虽然数据是实际保存在物理地址
上的,但是进程
接触到的都是虚拟地址
,那自然得有一个虚实转换的过程。
然而对于内核空间
来说,逻辑地址
和物理地址
就是一个单纯的线性映射关系,而为什么不用分页管理主要有两个原因:
- 不用分页
- 不能分页
不用分页是因为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级页表
:
逻辑地址
--线性地址
--物理地址
段机制
页机制
#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情况下的图
Offset+段基址=线性地址
,然而Linux
其实基本就不用分段机制
,因为这个机制在Linux
上也只是为了兼容下IA-32
的硬件而已,但是呢IA-32
的设计上段机制
是不能够避免的,所以才会出现上面所说的段基址=0
的情况,至于为什么是四个段寄存器
这还是因为IA-32
规定代码段
和数据段
必须是分开创建的不同的段,然而又因为ring 0
和ring 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);
可以看下面的传参就明白了,其实内容就两个:
__USER_DS
数据段__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
表示无特权
换算二进制,这儿二进制就是段选择码
:
而TI
位就是选择的段描述符表
,大家都是0
因此都是GDT表
,也就是cpu_gdt_table
,然后这个cpu_gdt_table
的基址
在内存管理寄存器GDTR
中,段选择码
的高13位就是index
,也就是5
这个index
是cpu_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), \
} } }
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 Address
为00000000
,因此这儿就可以证明逻辑地址
=线性地址
。
已经获取到了线性地址
,那接下来就再去找对应的物理地址
。
这儿是一个页式
过程,过程比段式
更为繁琐。
首先从CR3寄存器
中获取到页目录(Page Directory)
的基地址,然后通过线性地址
中的Directory位
在页目录
中找到页表(Page Table)
的基地址,接着根据线性地址
中的Table位
在页表
中找到页面(Page)
基地址,最后这个基址
+ Offset位
= 物理地址
。
按照64位
的模型来(9,9,9,9,12):
首先就是关于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,因为这部分是页属性,并不需要。
用户态下的代码就借助study-linux-vm-64bit稍微修改一点用起来就行了。然后手动提取下物理地址的值就行,为什么不用代码全自动提取呢?因为这部分用户态不可做得用内核模块来处理,而且涉及到新建页关系,所以就没深入研究。
这儿需要注意的是每个数据项是8字节
,所以都要*8
。
例如第一级映射:
0x2002a000+0x0*8
最终这个地址中的值的`base address`就是页面表起始地址。
关于代码上有个坑点,就是C语言中地址是一个
unsigned
的类型,这儿需要注意,否则前面会被填充。等后面用空时候再重写一下这个工具的代码,让它能全自动输出映射地址还有值。
- Linux用户空间与内核空间(理解高端内存)
- Linux 内存
- 浅谈CPU寻址内存机制
- 详解:物理地址,虚拟地址,内存管理,逻辑地址之间的关系
- 虚拟地址、逻辑地址、线性地址、物理地址的区别
- Linux 从虚拟地址到物理地址
- 在64位的linux划分用户空间与内核空间大小
- 深入浅出内存管理-虚拟地址和物理地址转换
- LINUX程序(进程)在内存中的布局
- linux下逻辑地址-线性地址-物理地址转换
- Linux x86_64线性地址空间布局(Why Does X86_64 Not Have ZONE_HIGHMEM)
- 在 Linux x86-64 模式下分析内存映射流程
- 64位Linux下的地址映射
- 章节六 GDT全局描述表
- 特权指令
- Linux kernel学习-内存寻址
- IA-32 内存模型与地址映射
- Linux内存管理实践-虚拟地址转换物理地址
- linux内核中没有分页内存
- Linux内核中的内存都不分页(unpagable)
- 关于ioremap 和 phys_to_virt
- 操作系统——分页式内存管理
- Linux内存描述之高端内存--Linux内存管理(五)
- yangfurong/tool_phyaddr_access
- Linux用户程序如何访问物理内存
- linux-内存布局
- [内存管理]linux X86_64处理器的内存布局图
- Linux 4.x 内核空间 MODULE 虚拟内存地址