随着集成技术越来越精细,内存存储量从字节,千字节,兆字节,GB,…容量越来越大。如何有效地管理内存是一门艺术。在80X86体系中通过分段部件和分页部件提供内存管理的支持,由此从换分出实地址模式,保护模式。实地址模式下一般是裸机程序,Linux启动起始内核代码载入内存运行并没有内存管理机制,因此内核是以裸机程序运行,接着通过创建自举分配器创建一个零时的内存分配器来支持系统进一步构建内存管理子系统,待内存管理子系统构建完成以后便抛弃这个自举分配器,同时内核由实地址模式进入到保护模式。保护模式下的内存又分为分段管理,分页管理。分页管理是分段管理的进化版。在硬件上对分段管理有段寄存器来支持,分页管理有MMU支持。
一.分段管理
1.16位CPU
Intel 8086是16位CPU,它只有16位寄存器、16位数据总线和20位地址总线,它只能运行在实模式,16位的CPU如何访问20位的地址范围(2的20次方=1 048 576=1M)的内存空间。为了能够访问到整个地址空间,在CPU里添加了4个段寄存器,寄存器位数为16位,分别为CS(代码段寄存器)DS(数据段寄存器) SS(堆栈段寄存器)ES(扩展段寄存器)。所以段寄存器就是为了解决CPU位数和地址总线不同的问题而诞生的。在实模式下通过 物理地址=段值*16+偏移,乘以16(2的4次方)意味着左移四位,加上段寄存器本身的16位,凑成20位,段值和偏移都是16位的 具有1MB(2^16 * 2^4 + offset)的寻址能力。
2.32位的CPU
Intel的CPU发展到80386时,CPU变成了32位,地址总线变成32位,寻址空间达到了2的32次方=4GB。然而寄存器大小为了兼容之前体系下的版本,寄存器依旧是16比特位宽,这下寻址能力不足了。于是新增了GDTR(全局的段的描述附表寄存器),LDTR(局部的描述附表寄存器)两个32位的寄存器
当x86 CPU 工作在保护模式时,可以使用全部32根地址线访问4GB的内存,因为80386的所有通用寄存器都是32位的,所以用任何一个通用寄存器来间接寻址,不用分段就可以访问4G空间中任意的内存地址。但是一个地址空间是否可以被写入,可以被多少优先级的代码写入,是不是允许执行等等涉及保护的问题就出来了。要解决这些问题,必须对一个地址空间定义一些安全上的属性。段寄存器这时就派上了用场。但是设计属性和保护模式下段的参数,要表示的信息太多了,要用64位长的数据才能表示。
我们把着64位的属性数据叫做段描述符,它包含3个变量:段物理基地址、段界限、段属性 80386的段寄存器是16位的,无法放下保护模式下64位的段描述符。把所有段的段描述符顺序存放在内存中的指定位置,组成一个段描述符表(Descriptor Table);而段寄存器中的16位用来做索引信息,这时,段寄存器中的信息不再是段地址了,而是段选择子(Selector)。可以通过它在段描述符表中“选择”一个项得到段的全部信息。
段寄存器存放着段选择子,段选择子是段的一个16位标识符。它并不直接指向段,而是指向段选择符表中定义段的段描述符。它有三个字段内容:请求特权级RPL(Request Privilege Level)、表指示标志TI(Table Index)、索引值(Index),描述符索引数量为2的13次方=8192,12个表项被系统预留了。用户只能用8080个表项,TI代表着从GDTR还是LDTR索引,RPL代表内核态。
段描述符是GDT和LDT表中的一个数据结构项,用来向处理器提供一个有关段的位置和大小信息以及访问控制的状态信息。包含三个主要字段:段基地址、段限长、和段属性。段描述符通常由编译器、连接器、加载器或者操作系统来创建
段限长为16+4=20位,段限长由G位控制,G=0代表单位1B,段长1M,G=1代表单位4KB,段长4KB*1M(2的20次方)=4G,刚好一个物理内存大小。段基地址长16+8+8=32位,与地址总线长度等。
保护模式下分段映射:GDT [DS>>3].BaseAddr + IP(逻辑地址32位) = 线性地址,GDT[DS >>3]这个是段选择符高13位(15-3)保存着所以向左移动3个位对齐。
段描述符表: 是段描述符的一个数组。
LDT可以看做是GDT中的一个段,不过这个段里的内容是一张段描述符表,通过TI来标识到哪张表选择描述符。于是寻址过程变成了:
1、段寄存器中存放段选择子Selector
2、GDTR/LDTR中存放着GDT/LDT段描述符表的基地址
3、由段寄存器中的段选择符的TI位决定到GDT/LDT表寻找描述符
4、通过选择子根据GDT/LDT中的基地址,就能找到对应的段描述符 具体来说是:选择子*8 (段描述符64位=8字节)
5、段描述符中有段的物理基地址,就得到段在内存中的基地址
6、加上偏移量(IP),就找到在这个段中存放的数据的线性地址(只有分段机制情况下,就是真实物理地址)。
在系统运行多个任务的时候,多个任务共享的段存放由GDT表中的段描述符标识,不共享的段由LDT表中的段描述符标识。在切换任务时只需重新装在LDTR。
3.GDT初始化
linux-5.4.80/arch/x86/boot/header.S
start_of_setup:
......
# Jump to C code (should not return)
calll main
linux-5.4.80/arch/x86/boot/main.c
void main(void)
{
......
//进入保护模式
go_to_protected_mode();
}
linux-5.4.80/arch/x86/boot/pm.c
void go_to_protected_mode(void)
{
//离开实地址模式,禁用中断
realmode_switch_hook();
//使能A20
if (enable_a20()) {
puts("A20 gate not responding, unable to boot...\n");
die();
}
//重置协处理器
reset_coprocessor();
/* Mask all interrupts in the PIC */
mask_all_interrupts();
//设置IDT,GDT
setup_idt();
setup_gdt();
//跳转到code32_start指定的入口即startup_32或startup_64
protected_mode_jump(boot_params.hdr.code32_start,
(u32)&boot_params + (ds() << 4));
}
linux-5.4.80/arch/x86/boot/pm.c
设置GDT
static void setup_gdt(void)
{
//初始化GDT,设置CS,DS,TSS描述符表项
static const u64 boot_gdt[] __attribute__((aligned(16))) = {
/* CS: code, read/execute, 4 GB, base 0 */
[GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
/* DS: data, read/write, 4 GB, base 0 */
[GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
/* TSS: 32-bit tss, 104 bytes, base 4096 */
/* We only have a TSS here to keep Intel VT happy;
we don't actually use it for anything. */
[GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
};
static struct gdt_ptr gdt;
gdt.len = sizeof(boot_gdt)-1;
gdt.ptr = (u32)&boot_gdt + (ds() << 4);
asm volatile("lgdtl %0" : : "m" (gdt));
}
//gdt指针指向gdt表
struct gdt_ptr {
u16 len;
u32 ptr;
} __attribute__((packed));
linux-5.4.80/arch/x86/include/asm/segment.h
构造GDT/LDT段描述符表项
#define GDT_ENTRY(flags, base, limit) \
((((base) & _AC(0xff000000,ULL)) << (56-24)) | \
(((flags) & _AC(0x0000f0ff,ULL)) << 40) | \
(((limit) & _AC(0x000f0000,ULL)) << (48-16)) | \
(((base) & _AC(0x00ffffff,ULL)) << 16) | \
(((limit) & _AC(0x0000ffff,ULL))))
linux-5.4.80/arch/x86/kernel/head_32.S
protected_mode_jump跳转到此处
__HEAD
ENTRY(startup_32)
movl pa(initial_stack),%ecx
/* test KEEP_SEGMENTS flag to see if the bootloader is asking
us to not reload segments */
testb $KEEP_SEGMENTS, BP_loadflags(%esi)
jnz 2f
//读取GDTR寄存器
lgdt pa(boot_gdt_descr)
movl $(__BOOT_DS),%eax
movl %eax,%ds
movl %eax,%es
movl %eax,%fs
movl %eax,%gs
movl %eax,%ss
......
//调用i386_start_kernel
call *(initial_code)
......
ENTRY(initial_code)
.long i386_start_kernel
.data
.globl boot_gdt_descr
ALIGN
# early boot GDT descriptor (must use 1:1 address mapping)
.word 0 # 32 bit align gdt_desc.address
boot_gdt_descr:
.word __BOOT_DS+7
.long boot_gdt - __PAGE_OFFSET
# boot GDT descriptor (later on used by CPU#0):
.word 0 # 32 bit align gdt_desc.address
ENTRY(early_gdt_descr)
.word GDT_ENTRIES*8-1
.long gdt_page /* Overwritten for secondary CPUs */
.align L1_CACHE_BYTES
//段限长4GB
ENTRY(boot_gdt)
.fill GDT_ENTRY_BOOT_CS,8,0
.quad 0x00cf9a000000ffff /* kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* kernel 4GB data at 0x00000000 */
linux-5.4.80/arch/x86/kernel/head32.c
asmlinkage __visible void __init i386_start_kernel(void)
{
/* Make sure IDT is set up before any exception happens */
idt_setup_early_handler();
......
start_kernel(); //开始内核其他项的启动
}
linux-5.4.80/arch/x86/include/asm/desc_defs.h
Linux通过desc_struct来表示GDT段描述符表项
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));
linux-5.4.80/arch/x86/include/asm/desc.h
每个处理器都对应一个gdt
struct gdt_page {
struct desc_struct gdt[GDT_ENTRIES];
} __attribute__((aligned(PAGE_SIZE)));
DECLARE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page);
linux-5.4.80/arch/x86/kernel/cpu/common.c
DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
[GDT_ENTRY_KERNEL32_CS] = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER32_CS] = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
#else
......
[GDT_ENTRY_ESPFIX_SS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
[GDT_ENTRY_PERCPU] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
GDT_STACK_CANARY_INIT
#endif
} };
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
linux-5.4.80/arch/x86/include/asm/desc_defs.h
使用GDT_ENTRY_INIT宏来填充表项
#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, \
}
4.任务切换
linux-5.4.80/include/linux/mm_types.h
通过mm_context_t来关联任务
struct mm_struct {
......
mm_context_t context;
......
}
linux-5.4.80/arch/x86/include/asm/mmu.h
typedef struct {
......
struct ldt_struct *ldt;
......
} mm_context_t;
linux-5.4.80/arch/x86/include/asm/mmu_context.h
struct ldt_struct {
struct desc_struct *entries; //段表项结构
unsigned int nr_entries;
int slot;
};
linux-5.4.80/arch/x86/include/asm/mmu_context.h
切换任务时进行ldt切换
static inline void switch_ldt(struct mm_struct *prev, struct mm_struct *next)
{
#ifdef CONFIG_MODIFY_LDT_SYSCALL
if (unlikely((unsigned long)prev->context.ldt |
(unsigned long)next->context.ldt))
load_mm_ldt(next);
#endif
}
linux-5.4.80/arch/x86/include/asm/mmu_context.h
static inline void load_mm_ldt(struct mm_struct *mm)
{
#ifdef CONFIG_MODIFY_LDT_SYSCALL
struct ldt_struct *ldt;
ldt = READ_ONCE(mm->context.ldt);
if (unlikely(ldt)) {
set_ldt(ldt->entries, ldt->nr_entries);
} else {
clear_LDT();
}
#else
clear_LDT();
#endif
}
二.分页管理
内存分段管理,可以以段为单位有效利用内存,缺点是无法利用碎片,必须搬移内存,造成性能损失。Linux通过分页管理来有效利用内存碎片,通过CR0寄存器的PE位来开启分页管理。如果不开启分页管理,那么上述分段产生的线性地址就是物理内存地址,如果开启了分页管理,那么这个32位线性地址经过页表转换以后得到物理地址。
1.页表和分页
以32位CPU来看,32位CPU可寻址物理内存为4G。那么如何通过页目录和页表组织4G的寻址空间转换,首先通过分段产生的线性地址是32位的,这32位线性地址划分为3段,[31:22]这10位为页目录(PGD)项索引,[21:12]这10位为页表(PTE)项索引,[11:0]这12位为页内偏移(OFFSET)。由此PGD目录项总共1024项=2的10次方,PTE表项总共1024项=2的10次方,内存页长度为4K=2的12次方。所以1024 x 1024 x 4K = 4G。32位的线性地址通过分页打散为4K的内存页。
由上述线性地址由页目录页索引,表索引,页偏移组成。每个进程都有各自的页表,CR3寄存器存放当前进程的页目录地址。那么Linux中目录和页表,特别是目录项和表项如何组织的呢。
页目录和页表项是32位的,由于内存以4K为一页的,所以页表项[12:0]被用作表项属性标记用,如P位表示该表项的有效性。R/W用于分页级读写保护。
最后,32位CPU,逻辑地址,线性地址,物理地址的转换如上图示。
2.32位分页
linux-5.4.80/arch/x86/include/asm/pgtable-2level_types.h
32位CPU两级分页:[31:22] - [21:12] - [11:0]
#ifndef __ASSEMBLY__
#include <linux/types.h>
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long p4dval_t;
typedef unsigned long pgdval_t;
typedef unsigned long pgprotval_t;
typedef union {
pteval_t pte;
pteval_t pte_low;
} pte_t;
#endif /* !__ASSEMBLY__ */
#define SHARED_KERNEL_PMD 0 //两级分页时PMD目录项为0
#define PGDIR_SHIFT 22 //页目录偏移
#define PTRS_PER_PGD 1024 //页目录项1024项
#define PTRS_PER_PTE 1024 //页表项1024项
#define PGD_KERNEL_START (CONFIG_PAGE_OFFSET >> PGDIR_SHIFT) //页目录位置[32:22]移动 32 >> 22
#endif /* _ASM_X86_PGTABLE_2LEVEL_DEFS_H */
linux-5.4.80/arch/x86/include/asm/pgtable-3level_types.h
32位CPU三级分页: [31:30] - [39:21] - [20:12] - [11:0]
#ifndef __ASSEMBLY__
#include <linux/types.h>
typedef u64 pteval_t;
typedef u64 pmdval_t;
typedef u64 pudval_t;
typedef u64 p4dval_t;
typedef u64 pgdval_t;
typedef u64 pgprotval_t;
typedef union {
struct {
unsigned long pte_low, pte_high;
};
pteval_t pte;
} pte_t;
#endif /* !__ASSEMBLY__ */
#ifdef CONFIG_PARAVIRT_XXL
#define SHARED_KERNEL_PMD ((!static_cpu_has(X86_FEATURE_PTI) && \
(pv_info.shared_kernel_pmd)))
#else
#define SHARED_KERNEL_PMD (!static_cpu_has(X86_FEATURE_PTI))
#endif
#define PGDIR_SHIFT 30 //页目录偏移
#define PTRS_PER_PGD 4 //页目录4项
#define PMD_SHIFT 21 //中间级页目录偏移
#define PTRS_PER_PMD 512 //中间级页目录512项
#define PTRS_PER_PTE 512 //页表512项
#define MAX_POSSIBLE_PHYSMEM_BITS 36
#define PGD_KERNEL_START (CONFIG_PAGE_OFFSET >> PGDIR_SHIFT)
#endif /* _ASM_X86_PGTABLE_3LEVEL_DEFS_H */
3.64位分页
linux-5.4.80/arch/x86/include/asm/pgtable_64_types.h
四级页表: [48:39] - [38:30] - [29:21] - [20:12] - [11:0]
寻址范围: 0x000000000000-0xFFFFFFFFFFFF,256TB
五级页表: [55-48] - [47:39] - [38:30] - [29:21] - [20:12] - [11:0]
寻址范围0x00000000000000-0xFFFFFFFFFFFFFF,128PB
#ifndef __ASSEMBLY__
#include <linux/types.h>
#include <asm/kaslr.h>
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long p4dval_t;
typedef unsigned long pgdval_t;
typedef unsigned long pgprotval_t;
typedef struct { pteval_t pte; } pte_t;
extern unsigned int pgdir_shift;
extern unsigned int ptrs_per_p4d;
#endif /* !__ASSEMBLY__ */
#define SHARED_KERNEL_PMD 0
//五级页表
#ifdef CONFIG_X86_5LEVEL
#define PGDIR_SHIFT pgdir_shift //偏移
#define PTRS_PER_PGD 512 //PGD表512项
//五级页表下的四级页表
#define P4D_SHIFT 39 //P4D偏移
#define MAX_PTRS_PER_P4D 512 //P4D表512项
#define PTRS_PER_P4D ptrs_per_p4d
#define P4D_SIZE (_AC(1, UL) << P4D_SHIFT)
#define P4D_MASK (~(P4D_SIZE - 1))
#define MAX_POSSIBLE_PHYSMEM_BITS 52
#else /* CONFIG_X86_5LEVEL */
//四级页表
#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512
#define MAX_PTRS_PER_P4D 1
#endif /* CONFIG_X86_5LEVEL */
//三级页表
#define PUD_SHIFT 30
#define PTRS_PER_PUD 512
//二级页表
#define PMD_SHIFT 21
#define PTRS_PER_PMD 512
//一级页表
#define PTRS_PER_PTE 512
#define PMD_SIZE (_AC(1, UL) << PMD_SHIFT)
#define PMD_MASK (~(PMD_SIZE - 1))
#define PUD_SIZE (_AC(1, UL) << PUD_SHIFT)
#define PUD_MASK (~(PUD_SIZE - 1))
#define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE - 1))
......
#endif /* _ASM_X86_PGTABLE_64_DEFS_H */
Linux内核通过分段分页机制来划分内存,将程序的逻辑地址转换为线性地址,线性地址为多任务模型提供了统一的内存模型,再将线性地址转换为物理地址,使得较小的物理内存也可以拥有较大的线性地址空间。这样上层应用就可以不用考虑多样化的复杂的内存管理问题,同样这个模型也使得Linux具有极好的兼容性,可以适应多样化的硬件。