你管这破玩意儿叫操作系统源码(七)

本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。

第二十五回 | 一个新进程的诞生(五) 通过fork看一次系统调用

一个新进程的诞生(五)通过 fork 看一次系统调用 (qq.com)

fork 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static _inline _syscall0(int,fork)

#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}

别急,我把它变成稍稍能看得懂的样子,就是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define _syscall0(type,name) \
type name(void) \
{ \
volatile long __res; \
_asm { \
_asm mov eax,__NR_##name \
_asm int 80h \
_asm mov __res,eax \
} \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}

所以,把宏定义都展开,其实就相当于定义了一个函数

1
2
3
4
5
6
7
8
9
10
11
12
int fork(void) {
volatile long __res;
_asm {
_asm mov eax,__NR_fork
_asm int 80h
_asm mov __res,eax
}
if (__res >= 0)
return (void) __res;
errno = -__res;
return -1;
}

关键指令就是一个 0x80 号软中断的触发,int 80h

其中还有一个 eax 寄存器里的参数是 **__NR_fork,这也是个宏定义,值是 2**。

OK,还记得 0x80 号中断的处理函数么?这个是我们在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 sched_init 里面设置的。

1
set_system_gate(0x80, &system_call);

看这个 system_call 的汇编代码,我们发现这么一行。

1
2
3
4
_system_call:
...
call [_sys_call_table + eax*4]
...

刚刚那个值就用上了,eax 寄存器里的值是 2,所以这个就是在这个 sys_call_table 表里找下标 2 位置处的函数,然后跳转过去。

那我们接着看 sys_call_table 是个啥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
};

看到没,就是各种函数指针组成的一个数组,说白了就是个系统调用函数表。

那下标 2 位置处是啥?从第零项开始数,第二项就是 sys_fork 函数!

至此,我们终于找到了 fork 函数,通过系统调用这个中断,最终走到内核层面的函数是什么,就是 sys_fork。

1
2
3
4
5
6
7
8
9
10
11
12
_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: ret

至于这个函数是什么,我们下一讲再说。

从这讲的探索我们也可以看出,操作系统通过系统调用,提供给用户态可用的功能,都暴露在 sys_call_table 里了。

系统调用统一通过 int 0x80 中断来进入,具体调用这个表里的哪个功能函数,就由 eax 寄存器传过来,这里的值是个数组索引的下标,通过这个下标就可以找到在 sys_call_table 这个数组里的具体函数。

同时也可以看出,用户进程调用内核的功能,可以直接通过写一句 int 0x80 汇编指令,并且给 eax 赋值,当然这样就比较麻烦。

所以也可以直接调用 fork 这样的包装好的方法,而这个方法里本质也是 int 0x80 以及 eax 赋值而已。

那我们再多说两句,刚刚定义 fork 的系统调用模板函数时,用的是 syscall0,其实这个表示参数个数为 0,也就是 sys_fork 函数并不需要任何参数。

所以其实,在 unistd.h 头文件里,还定义了 syscall0 ~ syscall3 一共四个宏。

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)

看都能看出来,其实 syscall1 就表示有一个参数syscall2 就表示有两个参数

那这些参数放在哪里了呢?总得有个约定的地方吧?

我们看一个今后要讲的重点函数,execve,是一个通常和 fork 在一起配合的变身函数,在之后的进程 1 创建进程 2 的过程中,就是这样玩的。

1
2
3
4
5
6
7
8
void init(void) {
...
if (!(pid=fork())) {
...
execve("/bin/sh",argv_rc,envp_rc);
...
}
}

当然我们的重点不是研究这个函数的作用,仅仅把它当做研究 syscall3 的一个例子,因为它的宏定义就是 syscall3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
execve("/bin/sh",argv_rc,envp_rc);

_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) { \
volatile long __res; \
_asm { \
_asm mov eax,__NR_##name \
_asm mov ebx,a \
_asm mov ecx,b \
_asm mov edx,c \
_asm int 80h \
_asm mov __res,eax\
} \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}

可以看出,参数 a 被放在了 ebx 寄存器,参数 b 被放在了 ecx 寄存器,参数 c 被放在了 edx 寄存器

第二十六回 | 一个新进程的诞生(六) fork中进程基本信息的复制

一个新进程的诞生(六)fork 中进程基本信息的复制 (qq.com)

sys_fork 函数干了啥。

还是个汇编代码,但我们要关注的地方不多。

1
2
3
4
5
6
7
8
9
10
11
12
_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: ret

先是 find_empty_process,就是找到空闲的进程槽位。

然后 copy_process,就是复制进程。

那妥了,这个方法的意思非常简单,因为存储进程的数据结构是一个 task[64] 数组,这个是在之前 第18回 | 大名鼎鼎的进程调度就是从这里开始的 sched_init 函数的时候设置的。

就是先在这个数组中找一个空闲的位置,准备存一个新的进程的结构 task_struct,这个结构之前在 一个新进程的诞生(三)如果让你来设计进程调度 也简单说过了。

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

通过 copy_process 这个名字我们知道,就是复制原来的进程,也就是当前进程。

当前只有一个进程,就是数组中位置 0 处的 init_task.init,也就是零号进程,那自然就复制它咯。

好了,以上只是我们的猜测,有了猜测再看代码会非常轻松,我们一个个函数看。

先来 find_empty_process

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;
}

一共三步,很简单。

第一步,判断 ++last_pid 是不是小于零了,小于零说明已经超过 long 的最大值了,重新赋值为 1,起到一个保护作用,这没什么好说的。

第二步,一个 for 循环,看看刚刚的 last_pid 在所有 task[] 数组中,是否已经被某进程占用了。如果被占用了,那就重复执行,再次加一,然后再次判断,直到找到一个 pid 号没有被任何进程用为止。

第三步,又是个 for 循环,刚刚已经找到一个可用的 pid 号了,那这一步就是再次遍历这个 task[] 试图找到一个空闲项,找到了就返回素组索引下标。

最终,这个方法就返回 task[] 数组的索引,表示找到了一个空闲项,之后就开始往这里塞一个新的进程吧。

来看 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
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
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;
int i;
struct file *f;


p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
p->tss.eip = eip;
p->tss.eflags = eflags;
p->tss.eax = 0;
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->tss.es = es & 0xffff;
p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->tss.gs = gs & 0xffff;
p->tss.ldt = _LDT(nr);
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
for (i=0; i<NR_OPEN;i++)
if (f=p->filp[i])
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
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; /* do this last, just in case */
return last_pid;
}

艾玛,这也太多了!

别急,大部分都是 tss 结构的复制,以及一些无关紧要的分支,看我简化下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int copy_process(int nr, ...) {
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;
}

这个函数本来就是 fork 的难点了,所以我们慢慢来。

首先 get_free_page 会在主内存末端申请一个空闲页面,就是遍历 mem_map[] 这个数组,找出值为零的项,就表示找到了空闲的一页内存。然后把该项置为 1,表示该页已经被使用。最后,算出这个页的内存起始地址,返回。

然后,拿到的这个内存起始地址,就给了 task_struct 结构的 p。

1
2
3
4
5
6
7
int copy_process(int nr, ...) {
struct task_struct p =
(struct task_struct *) get_free_page();
task[nr] = p;
*p = *current;
...
}

于是乎,一个进程结构 task_struct 就在内存中有了一块空间,但此时还没有赋值具体的字段。别急。

首先将这个 p 记录在进程管理结构 task[] 中。

然后下一句 p = current 很简单,就是把当前进程,也就是 0 号进程的 task_struct 的全部值都复制给即将创建的进程 p,目前它们两者就完全一样了。

嗯,这就附上值了,就完全复制之前的进程的 task_struct 而已,很粗暴。

最后的内存布局的效果就是这样。

然后,进程 1 和进程 0 目前是完全复制的关系,但有一些值是需要个性化处理的,下面的代码就是把这些不一样的值覆盖掉。

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;
...
}

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

第二十七回 | 一个新进程的诞生(七) 透过fork来看进程的内存规划

一个新进程的诞生(七)透过 fork 来看进程的内存规划 (qq.com)

copy_mem() function

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;
}

新进程 LDT 表项的赋值,以及页表的拷贝

LDT的赋值

那我们先看 LDT 表项的赋值,要说明白这个赋值的意义,得先回忆一下我们在 第九回 | Intel 内存管理两板斧:分段与分页 刚设置完页表时说过的问题。

程序员给出的逻辑地址最终转化为物理地址要经过这几步骤。

而我们已经开启了分页,那么分页机制的具体转化是这样的。

不考虑段限长的话,32 位的 CPU 线性地址空间应为 4G。现在只有四个页目录表,也就是将前 16M 的线性地址空间,与 16M 的物理地址空间一一对应起来了。

把这个图和全局描述符表 GDT 联系起来,这个线性地址空间,就是经过分段机制(段可能是 GDT 也可能是 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;
new_data_base = nr * 0x4000000;
...
}

这里的 0x4000000 等于 64M。

也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 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);
...
}

最终效果如图。

页表的复制

1
2
3
4
5
int copy_mem(int nr,struct task_struct * p) {
...
// old=0, new=64M, limit=640K
copy_page_tables(old_data_base,new_data_base,data_limit)
}

原来进程 0 有一个页目录表四个页表,将线性地址空间的 0-16M 原封不动映射到了物理地址空间的 0-16M。

那么新诞生的这个进程 2,也需要一套映射关系的页表,那我们看看这些页表是怎么建立的。

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]++;
}
}
}
invalidate();
return 0;
}

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

假设现在正在运行进程 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 号页表

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

我居然会认为权威书籍写错了...

由于理解起来非常简单,但代码中的计算就非常绕,所以我们就不细致分析代码了,只要理解其最终的作用就好。

OK,本章的内容就讲完了,再稍稍展开一个未来要说的东西。还记得页表的结构吧?

其中 RW 位表示读写状态,0 表示只读(或可执行),1表示可读写(或可执行)。当然,在内核态也就是 0 特权级时,这个标志位是没用的。

那我们看下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int copy_page_tables(unsigned long from,unsigned long to,long size) {
...
for( ; size-->0 ; from_dir++,to_dir++) {
...
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
...
this_page &= ~2;
...
if (this_page > LOW_MEM) {
*from_page_table = this_page;
...
}
}
}
...
}

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

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

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

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

第三部分一个新进程的诞生完结

一个新进程的诞生 完结撒花!!! (qq.com)

写时复制

写时复制就这么几行代码,麻烦你先看看再 BB 行吗? (qq.com)


你管这破玩意儿叫操作系统源码(七)
https://www.spacezxy.top/2023/04/06/OperatingSystem/Operating-system-source-code-7/
作者
Xavier ZXY
发布于
2023年4月6日
许可协议