《深入理解Linux内核(第三版)》,如此经典;以下简称《第三版》
不过这本书是基于 x86 硬件讲的;虽然和 ARM 移植的需求有些出入,但是目前没找到更适合上手的资料
针对 ARM 的 Linux 讲解,可以参考《奔跑吧,Linux 内核》系列的书籍
对应版本的内核源码的快照:
linux-2.6.11源码
基于源码的对应的节点,自己维护了一个阅读版的仓库。
在代码轻量的添加了一些笔记。该专栏中的代码行数均和该版本代码对应。
单独一个仓库的好处是代码下载比较快,坏处是没法跟踪每行代码最初是谁写的。
笔记(一),记录对《第三版》第二章“内存寻址”的学习
PS:
笔记写的再好,也取代不了书;如果想详细理解这书中内容,还是要抱着本子看的
笔记是对原书的扩展,把自己的理解备注到原文上,为以后的阅读和理解节省时间
而不是精简;把书的内容或者目录抄一遍到博客上,没太多意义
开篇讲内存是有意义的。
——第一感觉总是从 init 进程开始;但是,内存的操作,是进程开始的前置条件。
——可以通过简单的阅读 page.h 相关的代码,稍微的熟悉下内核的编码风格。
由虚拟地址(VA)到物理地址(PA)的寻址流程
几个概念:页框,页目录转换表,页目录,页表转换表,页表
- 页框可以理解为物理实体,4K大小;物理内存被划分为一个一个的页框
- 所有的页目录放到一个特定的页框(即“页目录转换表”)里
- 这个页框的物理地址是被内核知道的
- 并且在内核的整个生命周期中不会改变
- “页目录转换表”里有 1024 个页目录,每个页目录4Byte,索引一个“页表转换表”
- 理论上有 1024 个页框被作为“页表转换表”,可以放到内存的各个地方,但是要 4K 地址对齐
- 一个页表转换表内有 1024 个页表,每个页表4Byte,索引一个物理页框
然后说这两级转换表的指向的实现,是 field 字段和线性地址的联合运用:
- 两个转换表都有1024个元素,正好,各对应线性地址中的 10 个 bit
- 页目录和页表的 4 个 Byte 分为两段:present 字段和 field 字段
- present 字段在低 12bit,包含标志位,后续展开
- field 字段有 20 bit
- 首先,要在“页目录转换表”中找到对应的页目录
- “页目录转换表”的基地址是已知的
- 线性地址的 bit31:22 提供索引,找到目标页目录在“页目录转换表”中的位置
- 然后,基于页目录找到“页表转换表”的物理地址
- 页目录的 field,贡献“页表转换表”物理地址的高 20bit (bit31:12)
- “页表转换表” 4KB 对齐,也就是说它的物理地址的低 12bit 本来就是0
- 所以,这高 20bit 就是“页表转换表“的物理地址
- 线性地址的 bit21:12 提供索引,找到目标页表在“页表转换表”中的位置
- 页表的 field,贡献目标页框的物理地址的高 20bit (bit31:12);其实也就是目标页框的物理地址了
- 最后,找到目标存储空间的物理地址
- 目标页框的物理地址已经知道了
- 线性地址的 bit11:0 提供准确的偏移
- 页框物理地址加上偏移,就得到了要找的物理地址
相关宏
这一章,分解一些比较复杂的宏定义。
分页相关的头文件的位置:include->asm-xxx
- page.h:描述物理层面的页
- pgtable.h:描述物理层面的页表
现在基本确定了一件事情,一个页表项是由:页框地址(高20位,对应准确的物理地址)+若干标志位(利用低12位)
组成的。
present 相关的低 12 位的标志位。
这些标志是和硬件相关的,目前的理解是根据 x86 进行的,对于 AMR 会有若干不同。
#define _PAGE_PRESENT 0x001 // 标志该页(或页表)存在于主存中,为1的话
// 不为1却被访问,会产生缺页中断:把线性地址放到一个地方,并抛中断
#define _PAGE_RW 0x002 // 为1,表示该页可读可写;为0,标志该页只读
#define _PAGE_USER 0x004 // 若为1,用户可访问(内核当然更可以访问了);若为0,用户不可访问,只有内核可以访问
// 在 Linux 中,下面这两个标志位恒为 0,所以总是回写策略和启用高速缓存
#define _PAGE_PWT 0x008 // Page Write-Throuth,若为1,则要求硬件启用通写策略
#define _PAGE_PCD 0x010 // Page Cache Disable,若为1,则要求硬件关闭该页的高速缓存
#define _PAGE_ACCESSED 0x020 // 该页被分页单元访问过,就会置1;由操作系统按策略清零
#define _PAGE_DIRTY 0x040 // 只应用于页表项,对应的页框被写过后,就会置1
#define _PAGE_PSE 0x080 /* 4 MB (or 2MB) page, Pentium+, if present.. */
#define _PAGE_GLOBAL 0x100 /* Global TLB entry PPro+ */ // 置1可以保持该页一直存在于 TLB 中
#define _PAGE_UNUSED1 0x200 /* available for programmer */
#define _PAGE_UNUSED2 0x400
#define _PAGE_UNUSED3 0x800
关于通写(write-through)和回写(write-back):参考《第三版》P60
pmd_bad(x) 解析
#define pmd_bad(x) ((pmd_val(x) & (~PAGE_MASK & ~_PAGE_USER)) != _KERNPG_TABLE)
// 0x0FFF & 0xFFB = 0x0FFB
// x.pmd & 0x0FFB != 0x063
// 以下是相关引用
typedef struct { unsigned long pmd; } pmd_t;
#define pmd_val(x) ((x).pmd)
#define PAGE_SHIFT 12
#define PAGE_SIZE (1UL << PAGE_SHIFT) // 0x1000
#define PAGE_MASK (~(PAGE_SIZE-1)) // 0xF000
#define _PAGE_USER 0x004
#define _PAGE_PRESENT 0x001
#define _PAGE_RW 0x002
#define _PAGE_ACCESSED 0x020
#define _PAGE_DIRTY 0x040
#define _KERNPG_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED | _PAGE_DIRTY) // 0x063
pte_modify:把 pte 的标志按规则设置为 newprot
static inline pte_t pte_modify(pte_t pte, pgprot_t newprot)
{
pte.pte_low &= _PAGE_CHG_MASK; // 需要保留被访问标志和被写标志
pte.pte_low |= pgprot_val(newprot);
#ifdef CONFIG_X86_PAE
/*
* Chop off the NX bit (if present), and add the NX portion of
* the newprot (if present):
*/
pte.pte_high &= ~(1 << (_PAGE_BIT_NX - 32));
pte.pte_high |= (pgprot_val(newprot) >> 32) & \
(__supported_pte_mask >> 32);
#endif
return pte;
}
#define _PAGE_CHG_MASK (PTE_MASK | _PAGE_ACCESSED | _PAGE_DIRTY)
typedef struct { unsigned long pgprot; } pgprot_t; // 表示与一个单独表项相关的保护标志
#define pgprot_val(x) ((x).pgprot)
#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD-1))
// PTRS_PER_PGD - 1 = 1023 = 0x03FF
// 一个线性地址,右移掉对齐位后,再清理一下
// Page Global Directory,每个进程都有一个,是一个数组,有1024个元素,以上操作可以直接由线性地址计算出对应的下级目录项的索引
#define PGDIR_SHIFT 22
#define PTRS_PER_PGD 1024
由线性地址,得到目标页框在 pgd 中的索引
struct mm_struct {
...
pgd_t * pgd;
...
}; // from sched.h
#define pgd_offset(mm, address) ((mm)->pgd+pgd_index(address))
// 数组地址加数组索引,得到数组元素的地址;类型是 (pgd_t *)
// 这个宏起到了减少依赖的作用!!
// 如果上面是函数的话,mm 的结构体需要被包含到这个头文件里;但是现在只需要要求使用这个宏的文件包含结构体头文件就可以了
页描述符指针 + 标志位组,生成一个 pte
#define mk_pte(page, pgprot) pfn_pte(page_to_pfn(page), (pgprot))
struct page *mem_map;
// 这里使用了指针相减,只有在同一个数组中的两个元素指针相减,才是有意义的
// 得到的结果是被减数和减数之间差几个元素;特别的,如果减数是数组起始地址,那得到的是被减数在数组中的位置
#define page_to_pfn(page) ((unsigned long)((page) - mem_map))
// 得到了对应的页描述符在页描述符数组中的位置
// 页描述符和页表项(PTE)不是同一个概念,要到第八章才详细描述;总之,它是一个数据抽象,是一个结构体
// 页描述符数组是物理内存的一个线性压缩,压缩比正好是页的大小
// 所以得到页描述符,左移,就得到了页表项的 field 域(物理地址的高20位,如果页大小是12位的话)
// field 加上保护标志位组,就得到了一个完美的页表项啦
#define pfn_pte(pfn, prot) __pte(((pfn) << PAGE_SHIFT) | pgprot_val(prot))
一开始没有看明白,看完“内核页表”以后才大概了解了。
内核的 PA 在内存的开始,内核的 VA 在 0xC000_0000 (3G)的位置
首先要解决的疑惑是:dir 和 address,是什么?
// 目的是得到页表项的指针,这个页表项在页表中,页表是一个数组;返回的是页表数组的元素的指针
// 但是现在不确定的是,不知道得到的到底是 PA,还是 VA
// 按照理解,pmd_page_kernal 返回的必然是 VA;也就是说,可以认为得到了这个页表项的 VA 的结构体指针
#define pte_offset_kernel(dir, address) ((pte_t *) pmd_page_kernel(*(dir)) + pte_index(address))
// 内核,在物理内存的开始位置,在虚拟内存的 3G+ 的位置
#define __PAGE_OFFSET (0xC0000000UL)
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET)) // PA 到 VA
// 页中间目录里面放的是什么?是页中间目录表项,也就是某个页表的物理地址
// pmd_val(pmd) & PAGE_MASK),把地址的低 12 位置 0 了
// 再经过 va 处理,就得到了内核页表的虚拟地址。
#define pmd_page_kernel(pmd) ((unsigned long) __va(pmd_val(pmd) & PAGE_MASK))
// 现在的困惑是,内核页表放到哪里了?得到它的VA有什么用?
/*
* the pte page can be thought of an array like this: pte_t[PTRS_PER_PTE]
*
* this macro returns the index of the entry in the pte page which would
* control the given virtual address
*/
#define pte_index(address) (((address) >> PAGE_SHIFT) & (PTRS_PER_PTE - 1))
// 所以说,这和宏会返回一个数组的索引值
// 不管是 PA 还是 VA,这个 address 都能获得预期的 index
这个也超纲了,等回来看
#define pte_offset_map(dir, address) ((pte_t *)page_address(pmd_page(*(dir))) + pte_index(address))
#define pte_page(x) pfn_to_page(pte_pfn(x)) // 页表项对应的页描述符的地址
#define pfn_to_page(pfn) (mem_map + (pfn)) // 页描述符数组起始地址 + 索引
#define pte_pfn(x) ((unsigned long)(((x).pte_low >> PAGE_SHIFT))) // 得到页框的物理地址在页描述符数组中的索引
相关底层函数
这一节分析一些相关的、比较复杂的函数。
分页相关的 .c 文件在 arch -> xxx -> mm
- pgtable.c
// 貌似 mm 都没有用上
pgd_t *pgd_alloc(struct mm_struct *mm)
{
int i;
pgd_t *pgd = kmem_cache_alloc(pgd_cache, GFP_KERNEL); // 这个函数暂时不深究,应该是申请了一整个页全局目录的页框
if (PTRS_PER_PMD == 1 || !pgd)
return pgd;
for (i = 0; i < USER_PTRS_PER_PGD; ++i) {
// 同时,将页中间目录的空间也都申请出来了;这里和理解的不太一致,原则上,页中间目录的页框可以不用分配的啊
pmd_t *pmd = kmem_cache_alloc(pmd_cache, GFP_KERNEL);
if (!pmd)
goto out_oom;
// 原子的给一个 64 位的指针赋值
// 指针,是页全局目录里的表项的地址;值,没看明白,看起来是线性地址转成物理地址了,但是不应该啊
set_pgd(&pgd[i], __pgd(1 + __pa(pmd)));
}
return pgd;
out_oom:
for (i--; i >= 0; i--)
kmem_cache_free(pmd_cache, (void *)__va(pgd_val(pgd[i])-1));
kmem_cache_free(pgd_cache, pgd);
return NULL;
}
#define set_pgd(pgdptr, pgdval) set_pud((pud_t *)(pgdptr), (pud_t) { pgdval })
#define set_pud(pudptr,pudval) set_64bit((unsigned long long *)(pudptr),pud_val(pudval)) // 原子的给一个 64 位的指针赋值
看了下后面的几个函数,有点超前,先往后看吧。
相关上层函数
内核页表的初始化
首先是一个比较重要的变量:swapper_pg_dir
// swapper_pg_dir,这个全局变量,在汇编 head.S 中定义了
// 编译时便被分配了,这是一个真正的全局变量,生命周期与内核相等
ENTRY(swapper_pg_dir)
.fill 1024,4,0
当看明白上面这个变量的时候,突然有了一种豁然开朗的感觉。原来如此。但是现在还有一些细节是模糊的,还不能清晰表述。
内核页表的初始化分为两个阶段,首先是临时内核页表阶段,然后再进入最终内核页表阶段。
临时页表,线性地址0的起始和0xC000_0000的起始,都指向同一片物理内存。
当正式启用分页以后,也许目录中低地址的页表会被释放掉?
最终内核页表的初始化主要是这个函数,在 x86 上,大概分了几种情况:
- 内存小于 896M
- 内存小于 4G
- 内存大于 4G
主要都是在 init.c -> paging_init() 这个函数里实现的。
在这个函数里,会通过预定义(在配置环节)的宏定义、CPU的寄存器值等条件,完成不同情况的区分。
void __init paging_init(void) // line: 499
static void __init pagetable_init (void) // line:310
static void __init kernel_physical_mapping_init(pgd_t *pgd_base) // line: 143
static void __init page_table_range_init (unsigned long start, unsigned long end, pgd_t *pgd_base) // line: 103
代码要看,其中有若干 #ifdef 和 if 语句,完成了一套代码对各种情况的适配。
解析下 kernel_physical_mapping_init 这个函数
/*
* This maps the physical memory to kernel virtual address space, a total
* of max_low_pfn pages, by creating page tables starting from address
* PAGE_OFFSET.
*/ // pfn 应该是 page frame number
static void __init kernel_physical_mapping_init(pgd_t *pgd_base)
{
unsigned long pfn;
pgd_t *pgd;
pmd_t *pmd;
pte_t *pte;
int pgd_idx, pmd_idx, pte_ofs;
pgd_idx = pgd_index(PAGE_OFFSET); // 0xC000_0000 对应的页全局目录项在页全局目录中对应的位置
pgd = pgd_base + pgd_idx; // 页全局目录的一个结构体指针,指向的是页全局目录数组的某个元素
pfn = 0;
for (; pgd_idx < PTRS_PER_PGD; pgd++, pgd_idx++) { // 页全局目录中有 1024 个元素,遍历一遍
pmd = one_md_table_init(pgd); // 最后返回的,是一个 pmd_t *;但是在二级分页的情况下,pmd 就等于 pgd
if (pfn >= max_low_pfn)
continue;
for (pmd_idx = 0; pmd_idx < PTRS_PER_PMD && pfn < max_low_pfn; pmd++, pmd_idx++) { // 二级分页下,PTRS_PER_PMD 为1,所以这个循环体只会被执行一次
unsigned int address = pfn * PAGE_SIZE + PAGE_OFFSET; // 这个明显是 PA,和 pfn 相关的应该都是 PA
/* Map with big pages if possible, otherwise create normal page tables. */
if (cpu_has_pse) { // 如果支持大页表,也就是一页 4M;反正内核是需要连续空间,并且也不会被换出,所以使用大页表是合理的
unsigned int address2 = (pfn + PTRS_PER_PTE - 1) * PAGE_SIZE + PAGE_OFFSET + PAGE_SIZE-1;
// 不启用 PAE 的话,下面这两个 if 分支实际上是等价的
// set_pmd 执行的是一个把值赋给指针的操作
// 值=物理地址左移12位+标志位
// 指针=页中间目录(数组)中的某个元素指针,note that:二级分页的情况下,页中间目录项=页全局目录项
if (is_kernel_text(address) || is_kernel_text(address2))
set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE_EXEC));
else
set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE));
pfn += PTRS_PER_PTE;
} else { // 如果不支持大页表,页就是一页只能是 4k,那么需要把页中间目录中的每个页表项都初始化一遍,所以需要另一个循环
pte = one_page_table_init(pmd);
for (pte_ofs = 0; pte_ofs < PTRS_PER_PTE && pfn < max_low_pfn; pte++, pfn++, pte_ofs++) {
// set_pte 的操作和 set_pmd 类似
if (is_kernel_text(address))
set_pte(pte, pfn_pte(pfn, PAGE_KERNEL_EXEC));
else
set_pte(pte, pfn_pte(pfn, PAGE_KERNEL));
}
}
}
}
}