虚存管理:inux进程可以划分为多个不同的内存区域:代码段、数据段、BSS段、堆、栈。Linux内核把这些区域抽象成vm_area_struct
的对象进行管理。
前置知识
区分逻辑地址、线性地址、物理地址
分段和分页都是由CPU提供的。
虚拟地址包括了逻辑地址和线性地址,程序员用的其实是逻辑地址,分页机制可以绕开,单分段机制绕不开
1 | int test; |
疑问:
既然指针变量存的是偏移量,那为什么写程序的时候不用考虑段寄存器?
Linux和windows使用的是平坦内存模型,x64直接忽略了段描述符中的段基址和段界限,cpu直接支持flat模式。(fs,gs例外)
分段和分页都是由CPU提供的,intel开发手册,查看卷3,第三章保护模式下的内存管理。
- gdtr寄存器的特殊之处:不能使用mov指令访问gdtr寄存器,必须使用sgdt(访问)和lgdt(写入load)指令访问,这两个指令是特权指令,ring0才能访问。
逻辑地址就是应用程序员能看到的地址,就是机器语言中引用一个操作数或者是指定的地址
逻辑地址又叫虚拟地址
逻辑地址:段选择符+段内偏移量
CPU中有一个MMU,MMU中有一个分段单元的逻辑电路把逻辑地址转换成线性地址。
Linux有限度的使用了分段机制,有限制就是说所有的段基址都为0,所以在linux系统下,逻辑地址(虚拟地址)就等于线性地址
- 段基址就是根据段选择符查到的段描述符里的base字段
- 所有进程使用了相同的段寄存器,也就是不同的进程共享了同样的一组线性地址。
- 这样设计Linux可以移植到大多数处理器平台,比如一些不支持分段的体系结构。
内核的虚拟地址到物理地址只差了一个偏移,而用户空间的虚拟地址到物理地址则用了多久页表进行映射
为什么要分段?比如8086,8088,16位的CPU要访问20位的内存总线
为什么分页?比如要支持虚拟内存,内存不够要换页
内存管理
内核使用node、zone、page三级结构描述物理内存
页
页是内核管理内存的基本单位。MMU内存管理单元以页位单位管理系统中的页表。
1 | struct page { |
页表转换
谁执行page walk?CPU
页表由谁创建?操作系统,但是CPU规定了页表长什么样,即页表规范
谁会设置或修改页表?CPU会,OS也会
Intel CPU提供了多种页表结构:
二级页表:32位非PAE模式
三级页表:PAE模式
四级页表:IA-32e,即64位模式
32位页表
重点还是64位下的:
其实只有48位,前面16位是符号位扩展。
用户空间是0000,内核空间是FFFF。
page map level-4 table在linux里叫pgd
page-directory pointer table在linux里叫pud
page directory在linux里叫pmd
如果PS = 1,后面就是offset,为0表示还有下一级页表
P位代表是否存在(被换到磁盘),RW位就是读写权限
区
内核的虚拟地址空间分为三个类型的区,这三个区又线性映射到物理内存上。
- ZONE_DMA:这里的页只能执行DMA操作(0~16MB)
- ZONE_NORMAL:正常映射的页(16~896MB)
- ZONE_HIGHEM:高端内存(动态映射)
什么是高端内存
内核态下虚拟地址和物理地址是一个线性关系,就是所谓的内核线性地址空间,两者存在一个固定的offset,物理地址 = 逻辑地址 – 0xC0000000
x86-32位系统下,linux按照3:1来划分虚拟内存,3GB是用户空间,1GB是内核空间。
也就是说内核只能用1GB的地址空间来映射物理地址空间,如果内存大于1G的情况下,线性地址就不够用了。因此内核引入了一个高端内存的概念,小于896M的
叫低端内存,剩下的128M的线性空间用来灵活映射大于896M的物理地址空间,这128M就是我们说的高端内存区。
对于64位系统,地址空间足够用,就不用高端内存区了。
比如ARM-64的内核地址空间布局:
页管理
struct page * alloc_pages(gfp_t gfp_mask,unsigned order)
分配$2^(order)$个连续的物理页,并返回指向第一个页的指针。
void * page_address(struct page *page)
返回给定物理页的逻辑地址
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
也是分配物理页,当时返回的是分配的第一个页的逻辑地址。
还有获取填充为0的页,释放页等等
kmalloc()、kfree()和vmalloc()
这两个是对应的,用于获得以字节为单位的一块内核内存,释放由kmalloc分配出来的内存块。
kmaclloc确保分配出来的内存页在物理地址上是连续的,虚拟地址自然也是连续的。
分配的内存在虚拟地址上是连续的,而在物理内存上不一定连续,也是用户空间分配函数的工作方式。
他分配非连续的物理内存块,再修改页表,把内存映射到逻辑地址空间的连续区域中。
大多数情况下,只有硬件设备才需要连续的物理内存,因为硬件设备位于MMU之外,尽管如此,内核还是尽量使用kmalloc,因为这样可以提高性能,vmalloc需要对获得的页建立页表项一个个映射,还可能会导致TBL抖动。
slab分配器
slab描述符
1 | struct slab { |
高速缓存描述符
1 | struct kmem_cache_s { |
虚存管理
进程的地址空间
内存描述符
内存描述符mm_struct是进程描述符中的一个字段,包含了进程地址空间有光的全部信息
1 | struct mm_struct { |
mm_struct是否共享决定了是进程还是线程。
mm_struct是对整个用户空间的描述
虚拟内存区域VMA
VMA
一个进程有多个不同的段,段用vm_area_struct
进行描述,通过mmap创建。另外,堆和栈也是有自己的vma的。
使用pmap
或cat /proc/pid/maps
看到的就是进程的不同的区。如果有动态链接的情况,进程的地址空间也会包含链接库的代码段、数据段、bss段。
1 | struct vm_area_struct { |
ELF和Linux进程虚拟地址空间的映射关系
ELF文件中有一个程序头,程序头描述了ELF文件该被如何转载到进程的虚拟地址空间。
ELF引入了一个叫Segment的概念,一个Segment包含一个或者多个属性类似的Section,比如.init
和.text
是两个属性类似的Section,这两个段被看作是一个Segment,在装载的时候对应了一个VMA,这样减少了页面的内部碎片。
Segment是从装载的角度对Section进行了划分,链接的时候,属性类似的段放在一个空间,这个属性是说权限,比如可读可执行。
在ELF文件中,描述Section的叫做段表,表示Segment的叫程序头,程序头才描述了ELF文件该被操作系统如何映射到进程的虚拟地址空间。
使用readelf -l
读取程序头,比如:
不一定是每个Segment都要被映射到虚拟地址空间的,类型位LOAD的才需要,别的是装载时起辅助作用。
实际上程序头里写的各段转载虚拟地址并不一定跟linux装载后完全对应,linux装载时有一些特殊的处理,另外对于PIE地址无关编译的情况,程序头里代码段虚拟地址总是从0开始,实际情况下是随机的,各段共用了一个随机偏移量。
参考
linux2.6.11源码
《Linux内核设计与实现》第十二章、十五章
《程序员的自我修养》
《深入理解LINUX内核》第二章