mm 是 Linux 0.11 内存管理的模块,一共两个文件 memory.c 与 page.s。开篇先来“再续前缘”,继续探讨写时复制技术的后半部分。

写时复制之页错误

上一篇文章提到了,当父/子进程其中之一对只读的内存页面进行写操作时,会产生页错误的异常,该异常处理程序负责将共享的内存页面复制到新内存页中,并重新构建该页表项,使其指向新内存页并可写。实际上,页错误异常不仅由写保护引发,还有可能是缺页引起的。页错误异常就定义在 page.s 中,该文件也就只有 page_fault 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
.globl page_fault
/* 引发页错误的线性地址保存在控制寄存器 CR2 中 */
page_fault:
xchgl %eax,(%esp) /* 将出错码取到 eax 中 */
pushl %ecx
pushl %edx
push %ds
push %es
push %fs /* 保存现场 */
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
mov %dx,%fs /* 修改段寄存器,指向内核数据段 */
movl %cr2,%edx /* 将引起页错误的线性地址放到 edx 中 */
pushl %edx
pushl %eax /* 压参(页错误线性地址与错误码) */
testl $1,%eax /* 页存在 P 位如果不为 0,表明不是由缺页引起的异常 */
jne 1f /* 而是由写保护引发的异常,跳去调用 do_wp_page */
call do_no_page /* 如果是缺页引发的异常,则调用 do_no_page */
jmp 2f
1: call do_wp_page
2: addl $8,%esp /* 栈平衡 */
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax /* 还原现场 */
iret

先来看由写保护引起的异常处理函数 do_wp_page(之后涉及的函数都在 memory.c 中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Line 248
void do_wp_page(unsigned long error_code,unsigned long address)
{
#if 0
if (CODE_SPACE(address)) // 这段代码本意是如果线性地址位于进程代码空间中,
do_exit(SIGSEGV); // 则终止程序,但 #if 0 表示该段代码不起作用
#endif
un_wp_page((unsigned long *) // 实际通过 un_wp_page 实现,参数是线性地址 address
(((address>>10) & 0xffc) + (0xfffff000 & // 对应的页面在页表中的页表项指针
*((unsigned long *) ((address>>20) &0xffc)))));

}

// Line 222
void un_wp_page(unsigned long * table_entry)
{
unsigned long old_page,new_page;

old_page = 0xfffff000 & *table_entry; // 获取页面物理地址
// 判断该页面是否在主内存区(LOW_MEM 值为 1MB,1MB 以上为主内存区),并且没有被共享
if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) {
*table_entry |= 2; // 加上写属性
invalidate(); // 刷新 TLB
return;
}
// 否则在主内存区申请空闲页给执行写操作的进程单独使用
if (!(new_page=get_free_page())) // 如果没有空闲页则调用 oom 报错退出,oom 定义在 33 行
oom(); // out of memory
if (old_page >= LOW_MEM) // 如果页面物理在主内存区,且被共享了
mem_map[MAP_NR(old_page)]--; // 将映射的数量减 1
*table_entry = new_page | 7; // 设置新页面可读写、存在
invalidate(); // 刷新 TLB
copy_page(old_page,new_page); // 拷贝旧页面内容到新页面中
}

// Line 54
#define copy_page(from,to) \
__asm__("cld ; rep ; movsl"::"S" (from),"D" (to),"c" (1024))

于是,写时复制的全貌就展现完毕了。由缺页引发的页错误处理涉及到块设备的知识,之后再做记录。


mem_map数组

之前涉及内存管理的代码都或多或少地有 mem_map 数组的影子,这个字符数组就是 Linux 用于判断 1MB 以上物理内存使用情况的,每个字节描述一个物理页面的占用状态,该字节的数值表示该页面被占用的次数,0 代表该页面空闲,100 代表该页面已被完全占用,不能再被分配/共享。Linux 0.11 的物理内存区域划分如下:

mem_map


mm 模块中的几类函数

释放内存

接着来看 memory.c 中还剩下的一些函数,可根据功能分为几类,首先是释放内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Line 106
// 该函数释放从 from 开始,长度为 size 字节的线性地址空间
int free_page_tables(unsigned long from,unsigned long size)
{
unsigned long *pg_table;
unsigned long * dir, nr;

if (from & 0x3fffff) // 检查 from 是否是 4MB 对齐
panic("free_page_tables called with wrong alignment");
if (!from) // 如果 from 为 0,则不允许释放内核空间
panic("Trying to free up swapper memory space");
size = (size + 0x3fffff) >> 22; // 对 size 进行 4MB 舍入,右移 22 位求出涉及到的页目录项数
dir = (unsigned long *) ((from>>20) & 0xffc); // 计算起始的页目录项地址
for ( ; size-->0 ; dir++) { // 遍历涉及到的页目录项
if (!(1 & *dir)) // 如果该项不存在(P 位为 0),则跳过
continue;
pg_table = (unsigned long *) (0xfffff000 & *dir); // 页表基址
for (nr=0 ; nr<1024 ; nr++) { // 遍历所有页表项
if (1 & *pg_table) // 如果该页存在
free_page(0xfffff000 & *pg_table); // 释放该页所占用的空间
*pg_table = 0; // 页表项置空
pg_table++;
}
free_page(0xfffff000 & *dir); // 释放该页表所占用的空间
*dir = 0;
}
invalidate(); // 刷新 TLB
return 0;
}

// Line 90
// 释放一页空间实际上就是将 mem_map 数组对应项的映射数减 1
void free_page(unsigned long addr)
{
if (addr < LOW_MEM) return; // 不释放内核占用的空间
if (addr >= HIGH_MEMORY) // 对于超出内存大小的地址,直接死机
panic("trying to free nonexistent page");
addr -= LOW_MEM;
addr >>= 12; // 求出在 mem_map 数组中的索引
if (mem_map[addr]--) return; // 如果映射数不为 0,则减 1 后返回
mem_map[addr]=0; // 否则置 0 并死机
panic("trying to free free page");
}

获取空闲页面

第二类有关获取空闲页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Line 275
// 实际通过 get_free_page 与 put_page 实现
void get_empty_page(unsigned long address)
{
unsigned long tmp;

if (!(tmp=get_free_page()) || !put_page(tmp,address)) {
free_page(tmp);
oom();
}
}

// Line 63
// PAGING_PAGES 为页面总数,输入为 ax = 0,cx = PAGING_PAGES,
// edi = mem_map+PAGING_PAGES-1(即 mem_map 最后一项的地址)
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");
// std: 置方向位,从高到低
// repne: repeat if not equal,即下一条指令如果不能使 ZF 标志位为 0,则重复该指令,最多 ecx 次
// scasb: scas 用于比较字符串,加个 b 表示一次比较一字符,比较一次,di 自动递减(std 设置了方向)
// 每次操作比较 es:[di] 和 al 是否相等,这里从 mem_map 最后一项开始寻找映射数为 0 的项
//
__asm__("std ; repne ; scasb\n\t"
"jne 1f\n\t" // 如果遍历完 mem_map 还是没有找到空闲页面,则返回
"movb $1,1(%%edi)\n\t" // 到这里说明找到了空闲页,将 mem_map 对应项置 1
"sall $12,%%ecx\n\t" // 索引 * 4K 得到页面的相对起始地址
"addl %2,%%ecx\n\t" // 加上 LOW_MEM 得到页面实际物理起始地址
"movl %%ecx,%%edx\n\t" // 起始地址赋给 edi
"movl $1024,%%ecx\n\t" // 循环 1024 次
"leal 4092(%%edx),%%edi\n\t" // 当前页面末端 4 字节地址赋给 edi
"rep ; stosl\n\t" // stosl 将 eax 中的内容赋值给 es:[edi]
"movl %%edx,%%eax\n\t" // 返回值为页面物理起始地址
"1:"
"cld\n\t" // 复位方向位
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
);
return __res;
}

// Line 198
// 该函数将内存页面物理地址 page 映射到指定线性地址 address 处
unsigned long put_page(unsigned long page,unsigned long address)
{
unsigned long tmp, *page_table;
// page 应该在 LOW_MEM 与 HIGH_MEMORY 之间
if (page < LOW_MEM || page >= HIGH_MEMORY)
printk("Trying to put page %p at %p\n",page,address);
if (mem_map[(page-LOW_MEM)>>12] != 1) // 检查 page 是否是已经申请的页面
printk("mem_map disagrees with %p at %p\n",page,address);
page_table = (unsigned long *) ((address>>20) & 0xffc); // 线性地址对应页目录项指针
if ((*page_table)&1) // 如果页目录存在,直接获取页表地址
page_table = (unsigned long *) (0xfffff000 & *page_table);
else { // 否则申请一空内存页,存放页表
if (!(tmp=get_free_page()))
return 0;
*page_table = tmp|7; // 设置权限等
page_table = (unsigned long *) tmp; // 获取页表地址
}
page_table[(address>>12) & 0x3ff] = page | 7; // 修改线性地址对应的页表项,指向给定的物理地址
return page; // 无需刷新 TLB,直接返回
}

共享内存

第三类有关共享内存,share_page 函数仅被缺页处理函数 do_no_page 调用。这里引入一个新概念——页面逻辑地址,意为该页面地址是以进程的代码/数据起始地址算起的页面地址。以下是 do_no_page 部分代码:

1
2
3
4
5
6
7
8
// Line 373
// address 是产生页错误的线性地址
// current->start_code 是当前进程的线性地址空间基址(64MB 对齐)
// address - current->start_code 求出地址对应的页面逻辑地址
address &= 0xfffff000; // 取得线性地址所在的线性页面的地址
tmp = address - current->start_code;
if (share_page(tmp))
return;

share_page 的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// Line 345
// 寻找运行相同执行文件的进程,并尝试与之共享页面
// 参数 address 是页面逻辑地址
static int share_page(unsigned long address)
{
struct task_struct ** p;

if (!current->executable) // 如果当前进程没有执行文件,则返回
return 0;
// 如果当前进程正在执行的文件引用数小于 2,说明只有当前进程在运行该文件,直接返回
if (current->executable->i_count < 2)
return 0;
for (p = &LAST_TASK ; p > &FIRST_TASK ; --p) { // 遍历任务数组
if (!*p) // 跳过空任务项
continue;
if (current == *p) // 跳过当前进程
continue;
if ((*p)->executable != current->executable) // 跳过与当前进程执行的文件不同的进程
continue;
if (try_to_share(address,*p)) // 调用 try_to_share 尝试共享页面
return 1;
}
return 0;
}

// Line 293
// 尝试与目标任务 p 共享内存
// address 为产生页错误的线性地址所在的线性页面地址对应的页面逻辑地址(有点绕)
static int try_to_share(unsigned long address, struct task_struct * p)
{
unsigned long from;
unsigned long to;
unsigned long from_page;
unsigned long to_page;
unsigned long phys_addr;

from_page = to_page = ((address>>20) & 0xffc);
from_page += ((p->start_code>>20) & 0xffc); // 计算进程 p 的 address 对应的页目录项地址
to_page += ((current->start_code>>20) & 0xffc); // 计算当前进程的 address 对应的页目录项地址
from = *(unsigned long *) from_page; // 获得进程 p 页目录项
if (!(from & 1)) // 如果进程 p 该页表不存在,返回 0
return 0;
from &= 0xfffff000; // 否则取得页表基址
from_page = from + ((address>>10) & 0xffc); // 计算页表项地址
phys_addr = *(unsigned long *) from_page; // 获得页表项内容
if ((phys_addr & 0x41) != 0x01) // 0x41 对应 dirty 与 P 位,判断页面是否干净且存在
return 0;
phys_addr &= 0xfffff000; // 满足条件取得页面物理地址
if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM) // 判断物理地址是否越界
return 0;
to = *(unsigned long *) to_page; // 获得当前进程页目录项
if (!(to & 1)) // 如果当前进程该页表不存在
if (to = get_free_page()) // 则申请一页内存当页表
*(unsigned long *) to_page = to | 7;
else
oom();
to &= 0xfffff000; // 取得页表基址
to_page = to + ((address>>10) & 0xffc); // 计算页表项地址
if (1 & *(unsigned long *) to_page) // 如果当前进程的该页已有(我们本意是想共享内存),则死机
panic("try_to_share: to_page already exists");
*(unsigned long *) from_page &= ~2; // 取消页的写权限
*(unsigned long *) to_page = *(unsigned long *) from_page; // 建立映射
invalidate(); // 刷新 TLB
phys_addr -= LOW_MEM;
phys_addr >>= 12;
mem_map[phys_addr]++; // 物理地址在 mem_map 数组中对应项的映射数 + 1
return 1;
}

初始化函数

第四类是 main.c 中调用的 mem_init 初始化函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Line 400
void mem_init(long start_mem, long end_mem)
{
int i;

HIGH_MEMORY = end_mem; // 设置内存最高地址(16MB)
for (i=0 ; i<PAGING_PAGES ; i++) // 将 mem_map 所有项都赋值为 100,表示已占用
mem_map[i] = USED;
i = MAP_NR(start_mem); // 获取主内存开始地址对应的索引
end_mem -= start_mem; // 计算主内存区域大小
end_mem >>= 12; // 计算在 mem_map 数组中一共有多少项
while (end_mem-->0) // 将 mem_map 从后往前清零
mem_map[i++]=0;
}

其他

最后是一些杂项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Line 262 
// 写页面的验证,address 是页面的线性地址
void write_verify(unsigned long address)
{
unsigned long page;

if (!( (page = *((unsigned long *) ((address>>20) & 0xffc)) )&1)) // 页表不存在则返回
return;
page &= 0xfffff000; // 取得页表基址
page += ((address>>10) & 0xffc); // 计算页表项地址
if ((3 & *(unsigned long *) page) == 1) // 如果对应的页只读、存在
un_wp_page((unsigned long *) page); // 则执行复制页面、构建新映射的操作
return;
}

// Line 414
// 显示当前内存信息
void calc_mem(void)
{
int i,j,k,free=0;
long * pg_tbl;

for(i=0 ; i<PAGING_PAGES ; i++)
if (!mem_map[i]) free++; // 计算主内存区中有多少空闲页面并打印
printk("%d pages free (of %d)\n\r",free,PAGING_PAGES);
for(i=2 ; i<1024 ; i++) { // 遍历所有页目录项
if (1&pg_dir[i]) { // 如果对应页表存在
pg_tbl=(long *) (0xfffff000 & pg_dir[i]); // 获取页表基址
for(j=k=0 ; j<1024 ; j++) //遍历页表项
if (pg_tbl[j]&1) // 如果对应物理页存在,计数变量 k 加 1
k++;
printk("Pg-dir[%d] uses %d pages\n",i,k); // 打印页目录中有多少正在使用的页
}
}
}