本文以i386为例,内核代码版本:2.4.0
在操作系统中我们学过进程使用的虚拟地址,需要有一次地址映射才能转换为物理地址,这个转换过程是怎么样的呢?
1.页式管理结构
i386将虚拟地址分成了四段,分别代表PGD(页面目录),PMD(中间目录),PTE(页面表),offset(页内偏移),例如给一个32位的地址:
每个进程都有一个指向PGD表的指针,当需要将虚拟地址转换成物理地址时,先从cr3寄存器里边拿出来这个地址,也就得到了这个表,这个表有多少项呢?要根据虚拟地址的划分来算,如果虚拟地址划分中PGD是n位,那么PGD表就有2的n次方项,PMD,PTE同理。虚拟地址中PGD的值就是PGD表的下标,根据这个下标拿到一个指向PMD表的指针,然后根据虚拟地址中PMD的值拿到在PMD表中的下标,这个下标是指向一个PTE表的指针,根据此指针拿到PTE表,同理根据PTE值拿到一个地址,这个地址指向了实际的物理页面,此物理页面中偏移offset的地方就是虚拟地址对应的实际物理地址。
知道了整个过程,代码层面如何实现?本文讨论两层地址映射结构,也就是PMD部分在虚拟地址的划分中所占长度为0的情况。
几个重要的宏定义:
//include/asm-i386/pgtable-2level.h
#define PGDIR_SHIFT 22 //32位右移22位得到高10位,就是PGD表中的下标
#define PTRS_PER_PGD 1024 //PGD10位能找到1024个PGD表项
#define PMD_SHIFT 22 //PMD和PGD都是右移22位得到,PMD表只有一个项
#define PTRS_PER_PMD 1 //PMD表只有一项,
#define PTRS_PER_PTE 1024 //每个PTE(页表)有1024个条目,10位
32位地址意味着4G的虚拟地址空间,内核将这4G分位两段:0-3G为用户空间,3-4G位内核空间,但是物理内存总是从最低的0开始,所以对于内核来说地址映射是简单的线性映射,0xC0000000就是两者之间的偏移量。
//include/asm-i386/page.h
#define __PAGE_OFFSET (0xC0000000)
#define PAGE_OFFSET ((unsigned long)__PAGE_OFFSET)
//虚拟地址转物理地址
#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)
//物理地址转虚拟地址
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
在进程切换的时候,需要将cr3设置成指向新进程的页面目录表(PGD),而改目录的起始地址在内核代码中是虚拟地址,但是cr3需要的是物理地址,这个时候就要用到__pa()了,如下:
//include/asm-i386/mmu_context.h
asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));
2.段选择子
在我们学习汇编的时候,一般说的指令寻址都是cs:ip, cs寄存器指向了代码段,ip指向了代码段的偏移,通过这种方式来进行指令寻址,但是经常查coredump时候看到的ip(32位eip,64位rip)中都是一个完整的地址,根据这个地址能直接定位到coredump时候得指令,而cs寄存器是一个很小的固定值,这是为什么?为什么rip保存的不是一个偏移地址? 我们先来看看按照段式寻址(cs:ip)这种方式是如何做的吧。
图上是一个段选择子(cs、ds等寄存器里),高13位代表了一个索引。80386中有个GDTR(全局段描述寄存器),这个寄存器里保存了一个地址,地址指向了内存中的一个数组,用段选择子的高13位作为下标在这个数组中取出一个值,这个值就是相应段(代码段、数据段等等)的基地址,对于指令寻址来说,再加上ip寄存器里的值就能找到指令的位置。
那么问题来了,为何和现在看到的不一样?很多操作系统的课本中说的是操作系统使用的段页式虚拟内存管理,这简直就是胡扯。实际上是很久很久以前,是只有分段的,但是内存不够时候,需要将一部分物理空间换出到磁盘上时候,段式结构中需要将整个段(通常很大,我们的线上模块代码段是以G为单位的)换出,效率很低。在i386之前,分段已经存在和很久,为了兼容,i386一律是对程序中的地址先分段,再将段分页,linux内核只要服从intel的选择,但是它绕过了这种方式。具体的做法是:将从段选择子->全局描述符表中拿到的基地址都这是为0,这样每个段的起始地址都是0,对于cs:ip这种指令寻址方式来说,ip寄存器里存放的就是实际的虚拟地址,不用和cs寄存器一起运算了。
3. 重要的数据结构
//放在这里备用,后边好查
//include/asm-i386/page.h
typedef struct { unsigned long pte_low; } pte_t;
typedef struct { unsigned long pmd; } pmd_t;
typedef struct { unsigned long pgd; } pgd_t;
#define pte_val(x) ((x).pte_low)
#define PTE_MASK PAGE_MASK
typedef struct { unsigned long pgprot; } pgprot_t;
#define pmd_val(x) ((x).pmd)
#define pgd_val(x) ((x).pgd)
#define pgprot_val(x) ((x).pgprot)
#define __pte(x) ((pte_t) { (x) } )
#define __pmd(x) ((pmd_t) { (x) } )
#define __pgd(x) ((pgd_t) { (x) } )
#define __pgprot(x) ((pgprot_t) { (x) } )
pte(页表项)作为指针其实只需要其高20位,就能定位到一个物理页面(4K,12位,用虚拟地址中的offset来定位),所以pte_t的低12位用于保存指向的物理页面的状态信息和访问权限。
//include/asm-i386/page.h
typedef struct { unsigned long pgprot; } pgprot_t;
//include/asm-i386/pgtable.h
#define _PAGE_PRESENT 0x001 //是否在内存中(swap)
#define _PAGE_RW 0x002 //读写标志控制
#define _PAGE_USER 0x004
#define _PAGE_PWT 0x008
#define _PAGE_PCD 0x010
#define _PAGE_ACCESSED 0x020
#define _PAGE_DIRTY 0x040
#define _PAGE_PSE 0x080 /* 4 MB (or 2MB) page, Pentium+, if present.. */
#define _PAGE_GLOBAL 0x100 /* Global TLB entry PPro+ */
#define _PAGE_PROTNONE 0x080 /* If not present */
内核中有一个全局mem_map指针,指向一个page数组,每个page代表一个物理页面,page这个结构后边会讲到。
set_pte()是一个宏,用来把一个表项的值设置到一个页面表项中:
//include/asm-i386/pgtable-2level.h
#define set_pte(pteptr, pteval) (*(pteptr) = pteval)
映射过程中,mmu先检查P标志位(_PAGE_PRESENT),表示映射的页面是否在内存中,只有p位1的时候才会完成映射,否则就会产生缺页异常。
page是代表物理页面的一个结构
typedef struct page {
struct list_head list;
struct address_space *mapping;
// 页面内容来自文件时,index代表该页面在文件中的序号;
// 页面被换出时,代表页面的去向
unsigned long index;
struct page *next_hash;
atomic_t count;
unsigned long flags;
struct list_head lru;
unsigned long age;
wait_queue_head_t wait;
struct page **pprev_hash;
struct buffer_head * buffers;
void *virtual; /* non-NULL if kmapped */
struct zone_struct *zone;
} mem_map_t;
系统中的每个物理页面都有一个page(mem_map_t)结构,系统初始化时候根据物理内存建立起来一个page结构输入mem_map,作为物理页面的仓库,每个page都代表一个物理页面,每个物理页面的page结构在这个数组里的下标就是该物理页面的序号。物理页面通常分为ZONE_DMA(硬件设备要求物理页连续,同时地址不能太高)和ZONE_NORMAL两个管理区,根据配置,还有可能存在ZONE_HIGHMEM(对应虚拟地址中的高端内存高端内存),每个管理区有一个zone_struct数据结构,在zone_struct数据结构中有一组空闲区间(free_area_t)队列,为什么是一组?因为往往需要成块的连续分配物理页面,所以分为连续1个,2个,4个...2的n次方个连续物理页面,n最大为10,也就是最大可以分配到连续2的10次方(1024)个连续页面,代码定义:
//include/linux/mmzone.h
typedef struct free_area_struct {
//维持双向队列,嵌套在每个page中,把page串起来,物理页面的分配就是给这里串上物理页面
//后续会讲
struct list_head free_list;
unsigned int *map;
} free_area_t;
struct pglist_data;
typedef struct zone_struct {
spinlock_t lock;
//该分区在所有物理页面(mem_map)中起始页面编号
unsigned long offset;
unsigned long free_pages;
unsigned long inactive_clean_pages;
unsigned long inactive_dirty_pages;
unsigned long pages_min, pages_low, pages_high;
/*
* free areas of different sizes
*/
struct list_head inactive_clean_list;
free_area_t free_area[MAX_ORDER]; //空闲页面
/*
* rarely used fields:
*/
char *name;
unsigned long size;
/*
* Discontig memory support fields.
*/
struct pglist_data *zone_pgdat;
unsigned long zone_start_paddr;
unsigned long zone_start_mapnr;
struct page *zone_mem_map;
} zone_t;
由于NUMA架构的引入,需要在zone_struct上再包上一层pglist_data,之前的page结构数组也不再是全局的了,而是属于从属节点了。
NUMA架构
typedef struct pglist_data {
//最多三个,每zone都有个指针指向所属pglist_data,只属于本节点
zone_t node_zones[MAX_NR_ZONES];
//NR_GFPINDEX=256,这个代表了分配策略,内存分配时候先在本节点分配,也就是上边这个数组,分配不到了再从全局的空间分配,分配之后先指定分配策略,也就是数组下标,然后去相应的zone里分配
zonelist_t node_zonelists[NR_GFPINDEX];
struct page *node_mem_map; //当前node的物理页面
unsigned long *valid_addr_bitmap;
struct bootmem_data *bdata;
unsigned long node_start_paddr;
unsigned long node_start_mapnr;
unsigned long node_size;
int node_id;
struct pglist_data *node_next; //多个node的pglist_data形成一条链表
} pg_data_t;
//这个是全局的
typedef struct zonelist_struct {
zone_t * zones [MAX_NR_ZONES+1]; // NULL delimited
int gfp_mask;
} zonelist_t;
其中pglist_data一个node一个
虚拟内存数据结构之间关系:
task_strcut是每个进程的数据结构,每个结构上有个mm_struct指针,这就是虚拟内存相关数据结构的指针,这个结构上有个pgd,根据pgd能找到pmd表,根据虚拟地址里边的pmd和pte,offset字段可以定位到具体的物理内存页面中的偏移(offset,实际需要请求的地址),如果这个物理页面没有建立映射或者页面不在内存中(swap),就会产生缺页异常(注意:是异常不是中断)。这个结构体上还有一个mm指针,指向一个vm_area_struct,这是一个链表,通过vm_next指针把当前进程所有的vm_area_struct连接起来。
struct vm_area_struct {
struct mm_struct * vm_mm; /* VM area parameters */
unsigned long vm_start;
unsigned long vm_end;
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot;
unsigned long vm_flags;
/* AVL tree of VM areas per task, sorted by address */
short vm_avl_height;
struct vm_area_struct * vm_avl_left;
struct vm_area_struct * vm_avl_right;
/* For areas with an address space and backing store,
* one of the address_space->i_mmap{,shared} lists,
* for shm areas, the list of attaches, otherwise unused.
*/
struct vm_area_struct *vm_next_share;
struct vm_area_struct **vm_pprev_share;
struct vm_operations_struct * vm_ops;
unsigned long vm_pgoff; /* offset in PAGE_SIZE units, *not* PAGE_CACHE_SIZE */
struct file * vm_file;
unsigned long vm_raend;
void * vm_private_data; /* was vm_pte (shared mem) */
};
struct vm_operations_struct {
//区间打开
void (*open)(struct vm_area_struct * area);
//区间关闭
void (*close)(struct vm_area_struct * area);
//建立映射
struct page * (*nopage)(struct vm_area_struct * area, unsigned long address, int write_access);
};
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct vm_area_struct * mmap_avl; /* tree of VMAs */
struct vm_area_struct * mmap_cache; /* last find_vma result */
pgd_t * pgd; //页面目录表地址
//之所以用原子是因为fork时候子进程会复用父进程的空间
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
int map_count; /* number of VMAs */
struct semaphore mmap_sem;
spinlock_t page_table_lock;
struct list_head mmlist; /* List of all active mm's */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long rss, total_vm, locked_vm;
unsigned long def_flags;
unsigned long cpu_vm_mask;
unsigned long swap_cnt; /* number of pages to swap on next pass */
unsigned long swap_address;
/* Architecture-specific MM context */
mm_context_t context;
};