一个新进程的诞生[linux源码趣读]

GPT摘要

这篇文章主要讲解了以下内容: 1. 内核态切换到用户态:通过 move_to_user_mode 函数从内核态切换到用户态,利用中断返回机制模拟切换过程,确保代码在用户态下运行。 2. 进程创建(fork):通过 fork 系统调用创建新进程: - find_empty_process 找到空闲的进程槽位。 - copy_process 复制当前进程的上下文(task_struct)、内存管理信息(页表、段表)以及寄存器状态。 - 新进程与父进程共享相同的物理内存,但通过写时复制(Copy-On-Write)机制确保修改时分配新内存。 3. 进程调度机制: - 时间片轮转:每个进程有一个时间片计数器(counter),时钟中断(10ms一次)减少计数器,归零时触发调度。 - 优先级调度:进程的初始时间片由优先级(priority)决定,空闲时重新分配时间片。 - 任务状态:进程可以是运行(TASK_RUNNING)、不可中断(TASK_UNINTERRUPTIBLE)等状态,确保合理调度。 4. 内存管理: - 段式管理:每个进程通过 LDT(局部描述符表)映射到独立的线性地址空间。 - 页式管理:通过页表将线性地址映射到物理地址,fork 时复制父进程页表,但设置为只读以实现写时复制。 - 缺页中断:写操作触发缺页中断,处理程序 do_wp_page 分配新物理页并更新页表。 5. 系统调用与中断: - int 0x80:用户态通过软中断进入内核,由 sys_call_table 根据 eax 调用处理函数(如 sys_fork)。 - 中断上下文切换:保存寄存器状态并通过 TSS(任务状态段)恢复新进程的上下文。 总结:文章详细描述了进程创建(fork)的完整流程,包括权限切换、内存复制、调度策略以及写时复制的实现机制,展示了操作系统如何管理多进程的并发执行和内存隔离。

引入

第一部分和第二部分,为我们这个第三部分做了充足的铺垫工作。

第一部分 进入内核前的苦力活

第二部分 大战前期的初始化工作

到了第三部分,简单说就是从内核态切换到用户态,然后通过 fork 创建出一个新的进程,再之后老进程进入死循环。

1
2
3
4
5
6
7
8
9
10
11
12
void main(void) {
// 第二部分的内容,各种初始化工作
...
// 第三部分的内容,一个新进程的诞生
move_to_user_mode();
if (!fork()) {
// 新进程里干了啥,是第四部分的内容
init();
}
// 死循环,操作系统怠速状态
for(;;) pause();
}
  • move_to_user_mode: 转为用户态,之后需要通过中断转为内核态
  • fork:从进程0创建进程1
  • init: 进程1加载根文件系统任务,创建进程2,进程2加载shell
  • pause:暂停

从内核态切换到用户态

move_to_user_mode

特权级的实现

  • 首先从一个最大的视角来看,这一切都源于 CPU 的保护机制。CPU 为了配合操作系统完成保护机制这一特性,分别设计了分段保护机制分页保护机制
  • 当我们在 第七回 | 六行代码就进入了保护模式 将 cr0 寄存器的 PE 位开启时,就开启了保护模式,也即开启了分段保护机制

如何保护的?

cs代码段选择子的最后几位

image-20240418174750677

  • CPL代表当前的特权级别:11代表用户态
  • 代码的跳转执行jmp时,yyy : xxx,这里的 yyy 就是q请求跳转的段选择子
  • 在GDT中保存了yyy 的段描述符,其中定义了DPL指明了目标代码段特权级

image-20240418175345418

  • 在代码跳转时:CPL必须等于DPL
  • 在内存数据访问时:处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段
  • 发送中断时,指向新的CS IP,CS中的CPL通常是0

转化方式

中断和中断返回:int 0x80

image-20240418175639704

没有中断也可也返回?

中断前通常会保存当前的cs ip,并在return时pop,所以这里我们return前先push,假装发生了中断,push的cs ip就是等下想去的位置

image-20240418175822567

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void main(void) {
...
move_to_user_mode();
...
}

#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \ SS 赋值
_asm push eax \
_asm pushfd \
_asm push 0000000fh \ CS 赋值
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}
  • 注意SS、CS最后两位为1,代表切换到用户态
  • LDT拿转换关系:倒数第三位 TI 表示从 GDT 还是 LDT 中取,1 表示 LDT,也就是从局部描述符表中取。局部描述符具体是哪一个?也就是lldt 指向的是谁?

第18回 | 大名鼎鼎的进程调度就是从这里开始的 中,将 0 号 LDT 作为当前的 LDT 索引,记录在了 CPU 的 lldt 寄存器中。

1
2
3
4
5
6
7
#define lldt(n) __asm__("lldt %%ax"::"a" (_LDT(n)))

void sched_init(void) {
...
lldt(0);
...
}

总结

  • 特权的区别通过cs中的CPL和段描述符中的DPL定义,跳转时需要检查,使得用户下的代码和数据和内核下的区分开来
  • 什么时候特权会转变:中断以及中断返回时

如果让你来设计进程调度

1
2
3
4
5
6
7
8
void main(void) {
...
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}

上一节中,成功切换到了用户态,现在我们先不考虑fork,先讲讲如何实现进程调度?

如何实现cpu一下运行这个线程,一下另外一个?

由一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。

这个每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断

当前线程被换下去后,肯定需要保存当前任务的现场,比如它上一次执行到哪里了,要不 CPU 就算决定好了要跳转到你这个进程上运行,具体跳到哪一行运行,总得有个地方存吧?

1
2
3
struct task_struct {
?
}

上下文

每个程序最终的本质就是执行指令。这个过程会涉及寄存器内存外设端口

内存还有可能设计成相互错开的,互不干扰,比如进程 1 你就用 01K 的内存空间,进程 2 就用 1K2K 的内存空间,咱谁也别影响谁。

但寄存器只有那么多,如果当前线程切换掉了,切换前就要存储下来,否者就别别人删了

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
struct task_struct {
...
struct tss_struct tss;
}

struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};

cr3 : cr3 寄存器是指向页目录表首地址的,每一个进程不同,说明了线性地址到物理地址的映射关系不同。当前进程来了,就把当前进程的cr3设置上,代表当前线程在运行

操作系统来复制建立不同的页目录表并替换 cr3 寄存器即可,可以实现内存的不冲突或者共享

什么时候切换

每次时钟都切换?太频繁了

给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。

1
2
3
4
5
6
struct task_struct {
...
long counter;
...
struct tss_struct tss;
}

每次中断都–,并检查一下

1
2
3
4
5
6
7
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}

优先级

如何区分进程的优先级,其实就是counter的初始值,用一个priority保存

1
2
3
4
5
6
7
struct task_struct {
...
long counter;
long priority;
...
struct tss_struct tss;
}

进程状态

刚才的进程是都要运行的,有没有不在运行的情况?

一个进程读取磁盘,需要阻塞好久,这个时候调度cpu给他也没用,就需要主动放弃CPU执行权力,用state记录下进程的状态。

1
2
3
4
5
6
7
8
9
10
11
12
struct task_struct {
long state;
long counter;
long priority;
...
struct tss_struct tss;
}
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4

现在就可以完成简单的任务了:表示状态的 state,表示剩余时间片的 counter,表示优先级的 priority,和表示上下文信息的 tss

小结

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
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};

进程调度的开始,要从一次定时器滴答来触发,通过时钟中断处理函数走到进程调度函数,然后去进程的结构 task_struct 中取出所需的数据,进行策略计算,并挑选出下一个可以得到 CPU 运行的进程,跳转过去。

从一次定时器滴答来看进程调度

上回我们说了进程调度需要的数据结构,现在我们来看下具体的调度过程

还记得我们在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 sched_init 的时候,开启了定时器吧?这个定时器每隔一段时间就会向 CPU 发起一个中断信号。

image-20240418195753302

这个间隔时间被设置为 10 ms,也就是 100 Hz。

1
2
3
schedule.c

#define HZ 100

发起的中断叫时钟中断,其中断向量号被设置为了 0x20

同时我们在 sched_init 里设置的时钟中断和对应的中断处理函数吧?

1
2
3
4
5
6
7
8
9
10
11
12
13
schedule.c
set_intr_gate(0x20, &timer_interrupt);

system_call.s

_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...
1
2
3
4
5
6
7
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}

schedule

找出剩余时间片最大的线程(state=TASK_RUNNING ),如果都没有时间就counter = counter/2 + priority

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void schedule(void) {
int i, next, c;
struct task_struct ** p;
...
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}

switch_to

进程切换,其实就是切换上一章中我们定义的数据结构,我们需要保存当前的上下文到tss中,并将新的线程的上下文加载

ljmp :指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。

image-20240418200148268

通过 fork 看一次系统调用流程

回到我们的fork命令

1
2
3
4
5
6
7
8
void main(void) {
...
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}

伪代码:这里其实是触发了一次中断,具体处理函数由eax决定,并去sys_call_table(操作系统提供给用户全部的系统调用功能)找

1
2
3
4
5
6
7
8
9
10
11
12
int fork(void) {
volatile long __res;
_asm {
_asm mov eax,__NR_fork // 宏定义,值是 2
_asm int 80h
_asm mov __res,eax
}
if (__res >= 0)
return (void) __res;
errno = -__res;
return -1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
set_system_gate(0x80, &system_call);

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

_system_call:
...
call [_sys_call_table + eax*4]
...

image-20240418200635408

系统调用也可能有参数,刚刚产生哪个系统调用是通过eax传递,其他的参数可以通过ebx ecx edx 传递

1
2
3
4
#define _syscall0(type,name)
#define _syscall1(type,name,atype,a)
#define _syscall2(type,name,atype,a,btype,b)
#define _syscall3(type,name,atype,a,btype,b,ctype,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
44
45
46
47
48
49
_system_call:
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs
call _sys_call_table(,%eax,4)
pushl %eax
movl _current,%eax
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule
ret_from_sys_call:
movl _current,%eax # task[0] cannot have signals
cmpl _task,%eax
je 3f
cmpw $0x0f,CS(%esp) # was old code segment supervisor ?
jne 3f
cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
notl %ecx
andl %ebx,%ecx
bsfl %ecx,%ecx
je 3f
btrl %ecx,%ebx
movl %ebx,signal(%eax)
incl %ecx
pushl %ecx
call _do_signal
popl %eax
3: popl %eax
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret

因为 system_call 是通过 int 80h 这个软中断进来的,所以也属于中断的一种,具体说是属于特权级发生变化的,且没有错误码情况的中断,所以在这之前栈已经被压了 SS、ESP、EFLAGS、CS、EIP 这些值。

接下来 system_call 又压入了一些值,具体说来有 ds、es、fs、edx、ecx、ebx、eax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Stack layout in 'ret_from_system_call':
*
* 0(%esp) - %eax
* 4(%esp) - %ebx
* 8(%esp) - %ecx
* C(%esp) - %edx
* 10(%esp) - %fs
* 14(%esp) - %es
* 18(%esp) - %ds
* 1C(%esp) - %eip
* 20(%esp) - %cs
* 24(%esp) - %eflags
* 28(%esp) - %oldesp
* 2C(%esp) - %oldss
*/

所以之后,中断处理程序如果有需要的话,就可以从这里取出它想要的值,包括 CPU 压入的那五个值,或者 system_call 手动压入的 7 个值。

比如 sys_execve 这个中断处理函数,一开始就取走了位于栈顶 0x1C 位置处的 EIP 的值。

1
2
3
4
5
6
7
EIP = 0x1C
_sys_execve:
lea EIP(%esp),%eax
pushl %eax
call _do_execve
addl $4,%esp
ret

随后在 do_execve 函数中,又通过 C 语言函数调用的约定,取走了 filename,argv,envp 等参数。

1
2
3
4
5
6
7
8
int do_execve(
unsigned long * eip,
long tmp,
char * filename,
char ** argv,
char ** envp) {
...
}

fork 中进程基本信息的复制

回到fork函数,sys_fork 具体干了什么

1
2
3
4
5
6
7
8
9
10
11
_sys_fork:
call _find_empty_process
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
  1. find_empty_process,就是找到空闲的进程槽位。 task[64]
1
2
3
4
5
6
7
8
9
10
11
12
13
long last_pid = 0;

int find_empty_process(void) {
int i;
repeat:
if ((++last_pid)<0) last_pid=1;
for(i=0 ; i<64 ; i++)
if (task[i] && task[i]->pid == last_pid) goto repeat;
for(i=1 ; i<64; i++)
if (!task[i])
return i;
return -EAGAIN;
}
  1. copy_process,就是复制进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss){
struct task_struct p =
(struct task_struct *) get_free_page();
task[nr] = p;
*p = *current; // 复制当前当前进程 使得完全一样

p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->counter = p->priority;
..
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
...
copy_mem(nr,p);
...
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING;
return last_pid;
}
  • get_free_page : 遍历mem_map[],找到一个空闲的区域,空间给task_struct p

  • 将这个 p 记录在进程管理结构 task[nr] 中

  • 复制当前进程的task

    image-20240418202432064

设置独特值:一部分是 statepidcounter 这种进程的元信息,另一部分是 tss 里面保存的各种寄存器的信息,即上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int copy_process(int nr, ...) {
...
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->counter = p->priority;
..
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
...
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
...
}

ss0 和 esp0,这个表示 0 特权级也就是内核态时的 ss:esp 的指向。

根据代码我们得知,其含义是将代码在内核态时使用的堆栈栈顶指针指向进程 task_struct 所在的 4K 内存页的最顶端,而且之后的每个进程都是这样被设置的。

接下来将是进程页表和段表的复制,这将会决定进程之间的内存规划问题,很是精彩,也是 fork 真正的难点所在。

透过 fork 来看进程的内存规划

上一节中完成了task内存分配以及基本信息的复制,这里讲copy_mem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int copy_mem(int nr,struct task_struct * p) {
// 局部描述符表 LDT 赋值
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;
code_limit = get_limit(0x0f);
data_limit = get_limit(0x17);
new_code_base = nr * 0x4000000;
new_data_base = nr * 0x4000000;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
// 拷贝页表
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
copy_page_tables(old_data_base,new_data_base,data_limit);
return 0;
}

地址转换过程:需要进过分段和分页

image-20240418203259106

LDT

分段查看的就是当前进程的LDT,我们给进程 0 准备的 LDT 的代码段和数据段,段基址都是 0,段限长是 640K。给进程 1,也就是我们现在正在 fork 的这个进程,其代码段和数据段还没有设置。

所以第一步,局部描述符表 LDT 的赋值,就是给上图中那两个还未设置的代码段和数据段赋值。

其中段限长,就是取自进程 0 设置好的段限长,也就是 640K。

1
2
3
4
5
6
int copy_mem(int nr,struct task_struct * p) {
...
code_limit = get_limit(0x0f);
data_limit = get_limit(0x17);
...
}

段基址有点意思,是取决于当前是几号进程,也就是 nr 的值。

1
2
3
4
5
6
int copy_mem(int nr,struct task_struct * p) {
...
new_code_base = nr * 0x4000000; //(64M)
new_data_base = nr * 0x4000000;
...
}

也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 64M 的空间(暂不考虑段限长),且紧挨着。

接着就把 LDT 设置进了 LDT 表里。

1
2
3
4
5
6
int copy_mem(int nr,struct task_struct * p) {
...
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
...
}

image-20240418203254546

进程0的线性地址空间是064M,1 是64128M

经过以上的步骤,就通过分段的方式,将进程映射到了相互隔离的线性地址空间里,这就是段式管理。

页表的复制

页表项

image-20240418221102280

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
/*
* Well, here is one of the most complicated functions in mm. It
* copies a range of linerar addresses by copying only the pages.
* Let's hope this is bug-free, 'cause this one I don't want to debug :-)
*/
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
unsigned long * from_page_table;
unsigned long * to_page_table;
unsigned long this_page;
unsigned long * from_dir, * to_dir;
unsigned long nr;

from_dir = (unsigned long *) ((from>>20) & 0xffc);
to_dir = (unsigned long *) ((to>>20) & 0xffc);
size = ((unsigned) (size+0x3fffff)) >> 22;
for( ; size-->0 ; from_dir++,to_dir++) {
if (!(1 & *from_dir))
continue;
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
to_page_table = (unsigned long *) get_free_page() //页表也是申请的空间
*to_dir = ((unsigned long) to_page_table) | 7;
nr = (from==0)?0xA0:1024;
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2; // 只读
*to_page_table = this_page;
if (this_page > LOW_MEM) {
*from_page_table = this_page;
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++; // 计数++ 地址到idx需要右移12
}
}
}
invalidate(); // 刷新转换后援缓冲器(TLB)
return 0;
}

现在进程 0 的线性地址空间是 0 - 64M,进程 1 的线性地址空间是 64M - 128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0 最终被映射到的物理空间都是 0 - 64M,这样进程 1 才能顺利运行起来,不然就乱套了。

image-20240418205101695

假设现在正在运行进程 0,代码中给出一个虚拟地址 0x03,由于进程 0 的 LDT 中代码段基址是 0,所以线性地址也是 0x03,最终由进程 0 页表映射到物理地址 0x03 处。

假设现在正在运行进程 1,代码中给出一个虚拟地址 0x03,由于进程 1 的 LDT 中代码段基址是 64M,所以线性地址是 64M + 3,最终由进程 1 页表映射到物理地址也同样是 0x03 处。

即,进程 0 和进程 1 目前共同映射物理内存的前 640K 的空间。

至于如何将不同地址通过不同页表映射到相同物理地址空间,很简单,举个刚刚的例子。

刚刚的进程 1 的线性地址 64M + 0x03 用二进制表示是:

0000010000_0000000000_000000000011

刚刚的进程 0 的线性地址 0x03 用二进制表示是:

0000000000_0000000000_000000000011

根据分页机制的转化规则,前 10 位表示页目录项,中间 10 位表示页表项,后 12 位表页内偏移。

进程 1 要找的是页目录项 16 中的第 0 号页表

进程 0 要找的是页目录项 0 中的第 0 号页表

那只要让这俩最终找到的两个页表里的数据一模一样即可。*from_page_table = this_page;

~2 表示取反,2 用二进制表示是 10,取反就是 01,其目的是把 this_page 也就是当前的页表的 RW 位置零,也就是是把该页变成只读

而 *from_page_table = this_page 表示又把源页表也变成只读

也就是说,经过 fork创建出的新进程,其页表项都是只读的,而且导致源进程的页表项也变成了只读。

这个就是写时复制的基础,新老进程一开始共享同一个物理内存空间,如果只有读,那就相安无事,但如果任何一方有写操作,由于页面是只读的,将触发缺页中断,然后就会分配一块新的物理内存给产生写操作的那个进程,此时这一块内存就不再共享了。

image-20240418220124431

写时复制

缺页中断

包含缺页do_no_page,和只读页do_wp_page 两种情况

1
2
3
4
5
6
7
8
void do_page_fault(..., unsigned long error_code) {
...
if (error_code & 1)
do_wp_page(error_code, address, current, user_esp);
else
do_no_page(error_code, address, current, user_esp);
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void do_wp_page(unsigned long error_code,unsigned long address) {
// 后面这一大坨计算了 address 在页表项的指针
un_wp_page((unsigned long *)
(((address>>10) & 0xffc) + (0xfffff000 &
*((unsigned long *) ((address>>20) &0xffc)))));
}

void un_wp_page(unsigned long * table_entry) {
unsigned long old_page,new_page;
old_page = 0xfffff000 & *table_entry;
// 只被引用一次,说明没有被共享,那只改下读写属性就行了
if (mem_map[MAP_NR(old_page)]==1) {
*table_entry |= 2;
invalidate();
return;
}
// 被引用多次,就需要复制页表了
new_page=get_free_page();
mem_map[MAP_NR(old_page)]--;
*table_entry = new_page | 7; // 更新可读可写
invalidate();
copy_page(old_page,new_page);
}

image-20240418221905176

总结

image-20240418214828462

  • 第一,原封不动复制了一下 task_struct。

    image-20240418214853270

  • 第二,LDT 的复制和改造,使得进程 0 和进程 1 分别映射到了不同的线性地址空间。

    image-20240418214916017

  • 第三,页表的复制,使得进程 0 和进程 1 又从不同的线性地址空间,被映射到了相同的物理地址空间。

    image-20240418214928175

    最后,将新老进程的页表都变成只读状态,为后面写时复制缺页中断做准备。

image-20240418220017599