「Linux 源码趣读」 一个新进程的诞生
Zane

[TOC]

前言

「Linux 0.11」内核于 1991 年 9 月 3 日发布,代码总量只有 10,000 行左右,但是麻雀虽小,五脏俱全。0.11版本的内核已经具备了基本的文件系统、进程管理等。

仅仅支持 x86 架构(Intel 80386 处理器),原因是因为 Linus Torvalds 使用的电脑为这台,因此他首先针对这一体系结构进行了开发。该版本的内核仅支持单个处理器,不支持对称多处理(SMP)。

需要较少的内存才能运行。通常,它需要至少2MB的物理内存,但可以在更小的系统上运行。这个版本不支持虚拟内存(交换空间),因此所有的内存管理都发生在物理内存中。

支持基本的文件系统,最初它使用的是Minix文件系统。硬盘大小通常非常有限,远远小于今天的标准。硬盘驱动程序和文件系统支持仅适用于有限的硬件。

有基本的输入和输出支持。它可以与键盘和显示器进行简单的交互,但不支持图形界面。它还可以与串口通信进行简单的终端交互。

Linux 0.11 最初不支持网络,因此没有网络堆栈。网络功能被后续版本添加到内核中。

简介

本篇以文档为主,记录我阅读闪客的「Linux源码趣读」过程中对每一回的笔记以及总结,所以有可能最后这篇文章会非常的冗余并且没有章法。

为了保证内容看起来不算太冗余,一部分一部分的记录。

正文

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

第21 回 第三部分全局概述

虽然只是做了系统第一个进程的创建,内核态用户态的转换、进程调度、系统调用等等,但是却是整个系统最关键的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
	move_to_user_mode();
if (!fork()) { /* we count on this going ok */
init();
}
/*
* NOTE!! For any other task 'pause()' would mean we have to get a
* signal to awaken, but task0 is the sole exception (see 'schedule()')
* as task 0 gets activated at every idle moment (when no other tasks
* can run). For task0 'pause()' just means we go check if some other
* task can run, and if not we return here.
*/
for(;;) pause();

move_to_user_mode

从内核态转变为用户态,除非发生中断之后的代码就会一直在用户态执行。

fork

创建一个进程,用户进程创建新的进程同样是调用这个函数。而之前执行的所有代码,也就是进程 0 ,再调用完 fork 之后现在就多出一个进程 1 。

在 18 回中,将 task 数组中添加一个进程管理结构并添加定时器及时钟中断时,就表明进程 0 已经正式在系统中跑起来。因为此刻时钟中断到来之后,就可以执行我们的进程调度程序了,进程调度程序才会去 task 数组中挑选合适的进程进行切换。

init

只有进程 1 会进入这个判断中,执行这行代码。而在 init 中,需要完成如加载根文件系统的任务,同时进程 1 会再创建新的进程 2,在进程 2 中加载与用户交互的 shell 程序,此时系统就正式成为一个可以交互的状态。

pause

进程 0 最终会走到 pause 函数,当没有任何可运行的进程时,系统会在此悬停。

第 22 回 从内核态切换到用户态

CPU 眼里什么是内核态什么是用户态

其实对 CPU 的而言没有所谓的双模式,这只是对操作系统或者说对程序员来说的。在机器的眼里只看段寄存器的最低两位,00就是内核态,11就是用户态。这是因为 Intel 但是将 CPU执行的指令权限设置了四个等级刚好使用两个比特来表示,而 Linux 只用了其中的最高权限和最低权限表示内核态和用户态。

而内核态和用户态的转换其实就是段寄存器最低两位的转换。

段寄存器不单单是指 cs 还包括其他段寄存器如 ds、ss

move_to_user_mode

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
#define move_to_user_mode() \
__asm__ ("movl %%eax,%%esp\n\t" \
"pushl $0x17\n\t" \
"pushl %%eax\n\t" \
"pushfl\n\t" \
"pushl $0x0f\n\t" \
"pushl $1f\n\t" \
"iret\n" \
"1:\tmovl $0x17,%%eax\n\t" \
"movw %%ax,%%ds\n\t" \
"movw %%ax,%%es\n\t" \
"movw %%ax,%%fs\n\t" \
"movw %%ax,%%gs" \
:::"ax")

#define move_to_user_mode() \
_asm { \
_asm mov eax,esp ; 将栈指针 esp 的值保存到寄存器 eax
_asm push 00000017h ; 将用户态的选择子(通常是 0x17)压栈
_asm push eax ; 将先前保存的栈指针值再次压栈
_asm pushfd ; 将标志寄存器的值压栈
_asm push 0000000fh ; 将用户态的代码段选择子(通常是 0x0f)压栈
_asm push offset l1 ; 将偏移地址为 l1 的标签地址压栈
_asm iretd ; 执行中断返回指令
_asm l1: mov eax,17h ; 设置用户态的代码段选择子(通常是 0x17)到寄存器 eax
_asm mov ds,ax ; 设置数据段寄存器 ds
_asm mov es,ax ; 设置附加段寄存器 es
_asm mov fs,ax ; 设置附加段寄存器 fs
_asm mov gs,ax ; 设置附加段寄存器 gs
}
// 上面是仓库中 Linux 0.11 的代码,而下面是书中讲解时的代码,好像是因为编译器导致的写法不同

上面的代码片段是 Linux 0.11 内核用于从内核态切换到用户态的宏。该宏通过使用汇编语言来执行任务门(Task Gate)的中断返回指令(iret)来实现。

  1. mov eax, esp: 将当前栈指针的值保存到寄存器 eax 中。
  2. push 00000017h: 压栈用户态的代码段选择子。
  3. push eax: 再次将先前保存的栈指针值压栈。
  4. pushfd: 压栈标志寄存器的值。
  5. push 0000000fh: 压栈用户态的数据段选择子。
  6. push offset l1: 压栈 l1 标签的地址,该地址将成为返回地址。
  7. iretd: 执行中断返回指令,弹出栈中保存的各项信息,包括标志寄存器、用户态代码段选择子和用户态数据段选择子,并跳转到 l1 标签处。
  8. l1: mov eax, 17h: 在 l1 标签处,将用户态的代码段选择子设置到寄存器 eax。
  9. mov ds, ax, mov es, ax, mov fs, ax, mov gs, ax: 设置数据段寄存器 ds、es、fs、gs,确保它们都指向用户态的数据段。

这段代码的目的是通过 iretd 指令实现从内核态切换到用户态,同时将相应的段寄存器设置为用户态的段选择子。需要注意的是,具体的段选择子值(0x17 和 0x0f)以及具体的用户态数据段设置需要根据实际情况而定。

因此,这段代码的作用是进行一系列的操作,最终使得 CPU 从内核态切换到用户态,并从用户态的指定入口点开始执行。这是通过使用 iret 指令和任务门来实现的。

其本质就是模拟中断时的压栈的操作,经过一系列操作寄存器,最终将寄存器中的所有后两位都转化成了 11。

反常识的一点,中断和中断返回应该配套使用,但也可以单独使用,像这一系列的压栈神操作其实就是模拟中断的到来。在执行 iretd 指令时,CPU会按顺序将刚刚压入栈中的数据分别弹出并赋值给SS、ESP、EFLAGS、CS、EIP这几个寄存器,感觉就像是正确返回了一样,让程序误认为是中断进来的。

而返回到哪里是在

再利用 CPU 特权级的性质,CPL(当前代码段特权级)必须等于 DPL(目标代码段特权级)这些都是段选择结构中的比特位,才能进行跳转代码的操作,也就限制了 CPU 只能执行同等级(低等级)的指令,从而实现了双模式。

而在内核态的代码可以访问任何特权级下的数据段,处于用户态的的代码只能访问用户态的数据。如果需要内核中的代码或者数据就得使用系统调用(中断)

除了改变了特权级还做了什么

CS寄存器在刚刚被赋值为 0000 0000 0000 1111 ,除了最后两位表示了特权级,低 3 位表明前面的描述符索引是从 GDT 还是 LDT中取,1 表示局部描述符(LDT)。

第 23 回 如果让你来设计进程调度

进程调度就是 CPU 去程序 1 处执行一段时间,再去程序 2 处执行一段时间。每次时钟中断时就中断 CPU,进行调度。

而进程调度时会从以下几个必要的信息进行考虑。

首先, task_struct 的结构如下。

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 的 tss_struct 结构体用于存储上下文切换时的必要信息。

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
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 寄存器

运行时间信息

剩余时间片表示该进程还需要占用 CPU 多久,每次时钟中断的到来,都会使得其减 1 ,如果减到 0 就进行切换进程的操作。结构体中的 counter 就是剩余时间片的字段。

优先级

优先级的考量其实就是上述时间片 counter 的初始化问题,需要一个值记录这个关系,这个值越大,剩余时间片的大小就应该越大,即优先级

进程状态

1
2
3
4
5
#define TASK_RUNNING		0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4

进程的状态很好理解,正在运行、停止、僵尸等等。

主要的目的就是给 OS 中断到来后判断该进程是否有执行下去的必要,比如某个进程正在等带硬盘I/O的读取操作,而在这期间该进程也不会往下执行,如果白白将 CPU 的周期分给该进程就会导致资源的利用率下降。

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

定时器时钟中断 do_timer

定时器每隔一段时间就像 CPU 发起一个中断信号,这个间隔时间设置为 10ms,也就是 100 Hz。

set_intr_gate(0x20, &timer_interrupt);设置了 0x20 中断号为时钟中断。

1
2
3
4
5
6
7
8
9
// kernel/system_call.s
_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...

其中 do_timer 的实现主要就是判断时间片是否还有,如果没有进进行进程调度。

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
// kernel/sched.c
void do_timer(long cpl)
{
extern int beepcount;
extern void sysbeepstop(void);

if (beepcount)
if (!--beepcount)
sysbeepstop();

if (cpl)
current->utime++;
else
current->stime++;

if (next_timer) {
next_timer->jiffies--;
while (next_timer && next_timer->jiffies <= 0) {
void (*fn)(void);

fn = next_timer->fn;
next_timer->fn = NULL;
next_timer = next_timer->next;
(fn)();
}
}
if (current_DOR & 0xf0)
do_floppy_timer();
if ((--current->counter)>0) return;
current->counter=0;
if (!cpl) return;
schedule();
}
  1. 计数器减少:
    • 如果 beepcount 大于 0,表明有蜂鸣计数,每次时钟中断 beepcount 减1,如果减到0,则停止蜂鸣。
    • 根据当前特权级 cpl 的不同,更新进程的用户态时间(utime)或内核态时间(stime)。
  2. 定时器处理:
    • 如果存在下一个定时器 next_timer,则将其剩余的时间减1。
    • 循环处理已到期的定时器,执行相应的处理函数。
    • 注意,这里的定时器是通过链表连接的,每个定时器节点包含了一个处理函数 fn 和剩余的时钟滴答数 jiffies
  3. 软盘定时器:
    • 如果 current_DOR & 0xf0 非零,表示软盘控制器有操作在进行,调用 do_floppy_timer 处理软盘定时器。
  4. 进程调度:
    • 每次时钟中断,当前进程的时间片 counter 减1。
    • 如果 counter 大于0,说明进程还有时间片剩余,直接返回。
    • 如果 counter 减到0,或者 cpl 不为0,即在内核态,调用 schedule 函数进行进程调度。

总体来说,这段代码实现了 Linux 0.11 内核中的时钟中断处理,包括蜂鸣处理、定时器处理、软盘定时器处理以及基本的进程调度。

而在这之后,会进行调度的函数 **schedule()**。

schedule 进程调度
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
void schedule(void)
{
int i,next,c;
struct task_struct ** p;

/* check alarm, wake up any interruptible tasks that have got a signal */

for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm && (*p)->alarm < jiffies) {
(*p)->signal |= (1<<(SIGALRM-1));
(*p)->alarm = 0;
}
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}

/* this is the scheduler proper: */

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);
}
  1. 检查定时器和信号:
    • 通过遍历任务数组,检查每个任务的定时器和信号状态:
      • 如果任务设置了 alarm 且当前时间(jiffies)已经超过了设定的 alarm 时间,将相应的 SIGALRM 信号置位,并将任务的 alarm 清零。
      • 对于可中断的任务,如果收到了未被屏蔽的信号,将任务的状态从 TASK_INTERRUPTIBLE 修改为 TASK_RUNNING
  2. 调度器主体:
    • 进入一个无限循环,该循环负责选择下一个要运行的任务。
    • 初始化 c 为-1,表示尚未找到可运行的任务,next 为0,表示下一个任务的索引。
    • 遍历所有任务,找到状态为 TASK_RUNNING 且剩余时间片(counter)最大的任务。将其索引保存在 next 中。
    • 如果找到了可运行的任务(c 不为0),跳出循环。否则,对所有任务的时间片进行调整。
  3. 时间片调整:
    • 如果所有 runnable 进程的时间片都为 0 ,遍历所有任务(不仅仅是 runnable 进程),将任务的时间片右移一位(相当于除以2),然后加上任务的优先级,对所有进程的 counter 进行重新赋值。
  4. 任务切换:
    • 调用 switch_to(next) 切换到下一个任务执行。

这段代码实现了一个简单的轮转调度算法。任务的时间片(counter)用于限制任务的执行时间,每次时钟中断,正在运行的任务的时间片减少,当时间片为0时,进行任务切换。可中断的任务在等待事件时会进入睡眠状态,等待条件满足后被唤醒。

switch_to 用于使 CPU 跳转当前正执行的进程(参考中)。不过,主要就是保存当前进程的上下文,恢复即将跳转进程的上下文,然后跳过去。

简单总结
  1. 原因是每个 10 ms 触发的一次定时器 timer。
  2. 滴答向 CPU 发送一个时钟中断信号。
  3. 根据中断信号 CPU 查找中断向量表,找到操作系统中实现的时钟中断处理函数 do_timer。
  4. do_timer 的作用就是对当前执行的任务的 counter 进行减 1 操作,如果仍然大于零则就此结束。
  5. 但是如果 counter 值等于 0 ,则进行进程调度 schedule。
  6. 进程调度函数,遍历所有RUNNABLE的进程,并从中找到 counter 最大的进程,当作 switch_to 的参数丢进去。
  7. switch_to 保存当前进程的上下文,恢复要跳转的进程的上下文,同时使 CPU 跳转到这个进程的偏移地址处。
  8. 接着该进程就会执行一段时间,直到下次时钟中断的到来。

第 25 回 通过 fork 看一次系统调用

在设置了用户态,模拟了一次系统调用,操作系统拥有了进程调度的能力,接下来的主函数代码只剩下了两行。

1
2
3
if (!fork()) {		/* we count on this going ok */
init();
}

_syscall0

首先,fork 也属于一次系统调用,被 Linux 封装成了内联汇编的形式,但是在这之前先了解一下 fork 作为一个系统调用是怎么被提供出 C 的接口的。

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

这是通过宏定义内联的 C 与汇编,将该宏定义展开后如下。

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

再将刚才的 fork 整理到该宏定义中。

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

可以看到,这个 fork 函数也没什么大不了,在一些代码编辑工具追不进去就是因为它被换上了宏定义的皮,在上段代码中,最关键的就是 int 80h 触发软中断,很早就说过 0x80 是 Linux 系统调用的中断号,而这就引出了系统调用是怎么产生的。

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

刚刚的那个值 __NR_FORK 便可以用上了,其实就是 eax 寄存器被赋值上了 2 ,对应的就是 sys_call_table 中的就是下标 2 的 sys_fork 的汇编实现(.s 文件中)。

1
2
3
4
5
6
7
8
9
10
11
12
13
.align 2
_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 表中,系统调用统一通过 0x80 中断进入,具体调用哪个函数,由赋值的 eax 传入,通过下标找到表中对应的函数实现。

多参数的系统调用

而在 unistd.h 头文件中,还定义了 **syscall0 ~ syscall3 ** 共 4 个宏:

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)

对应的就是不同系统调用的参数要求,以 execve 系统调用使用到的 syscall3 举例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}

参数a放在 ebx 寄存器中,参数 b 被放在 ecx 寄存器里,参数 c 被放在 edx 寄存器里。

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
.align 2
_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

在触发中断时,也属于特权级变化, 22 回中提到 CPU 会帮我们做一些压栈操作,且没有错误码情况的中断,便于回到原来的执行代码处,所以在这之前栈中已经被压入了 SS、ESP、EFLAGS、CS、EIP 这些寄存器的值,而且系统调用中又会压入如 ds、es、fs、edx、ecx、ebx、eax 这样中断程序在有相应的需要时,便可以从这里取走它想要的值。

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

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

到此为止,完成了一次完整的系统调用。

第 26 回 fork 函数中进程基本信息的复制

上一回从 fork 函数中,窥探了 Linux 是怎么进行系统调用,这一回看一看 fork 究竟是在干嘛。

_sys_fork

1
2
3
4
5
6
7
8
9
10
11
12
13
.align 2
_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
  1. call _find_empty_process:调用 _find_empty_process 函数,该函数的作用是找到一个空闲的进程槽位(task_struct 结构体)。_find_empty_process 返回进程槽位的编号,如果找不到空闲槽位,则返回负数。
  2. testl %eax, %eax:测试 %eax 寄存器的值,即 _find_empty_process 的返回值。如果返回值小于等于零(负数或零),则跳转到标签 1
  3. js 1f:如果 %eax 是负数,则跳转到标签 1,表示创建进程失败。
  4. push %gs:将 %gs 寄存器的值入栈,保存当前进程的 gs 寄存器值。
  5. pushl %esipushl %edipushl %ebppushl %eax:将 %esi%edi%ebp%eax 寄存器的值依次入栈,保存这些寄存器的值。
  6. call _copy_process:调用 _copy_process 函数,该函数的作用是复制当前进程的状态,创建一个新的进程。新进程的状态和代码空间都是从当前进程复制而来。
  7. addl $20, %esp:恢复栈的平衡,将之前入栈的 20 个字节弹出。
  8. 1::标签 1,表示创建进程失败的跳转目标。
  9. ret:返回指令,将控制权返回到调用 _sys_fork 的地方。

find_empty_process

先来看 find_empty_process

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long last_pid=0;

int find_empty_process(void)
{
int i;

repeat:
if ((++last _pid)<0) last_pid=1;
for(i=0 ; i<NR_TASKS ; i++)
if (task[i] && task[i]->pid == last_pid) goto repeat;
for(i=1 ; i<NR_TASKS ; i++)
if (!task[i])
return i;
return -EAGAIN;
}

这段代码就是将 last_pid 进行不断累加,从 1 开始逐个往上遍历找到 pid 可用的值,再找到一个空闲的位置返回。

copy_process

在找到了 last_pid 以及一个空闲位置的 task 下标,接下来就是进行 copy 操作。

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

很好奇参数都是从哪来的,但是仔细比对后发现,在中断到来发生特权级变化时,将最后面 5 个参数已经压入栈中,之后系统调用 _system_call 又会压入中间的 6 个寄存器的值,而刚刚 _sys_fork 时又压入了 5 个寄存器的值。

在给那些给 tss结构的赋值的代码以前,会使用 get_free_page 在内存末端申请一个空闲页面,遍历 mem_map[] 这个数组,找到值为 0 的项,就表示找到了空闲的一页,将其置 1 后返回该页得起始地址给到 p。

通过*p = *current对 p 进程进行赋值,使得进程 1 和进程 0 完全相同,但进程 1 同样有一些值是需要独自处理的。就引出了对 p 中很多成员进行赋值的操作。包括 state、pid、counter 这种进程的元信息,以及一部分 tss 中的各个寄存器的信息,即 上下文

1
2
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;

这两个寄存器的赋值比较特殊,ss0 和 esp0,表示 0 特权级,也就是内核态时的 ss:esp 的指向,其含义是将代码在内核太时使用的栈顶指针指向进程 task_struct 所在的 4KB 内存页的最顶端,而且之后的每个进程都是这样被设置的。

每个进程自己的内核态堆栈空间的栈顶地址在这个 4KB 内存的最顶端,向下发展。

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

上回讲到在 fork 中找到了一个空闲的进程信息位置,找到了空闲的一页,将进程 0 的元信息复制给了进程 1。在这之后有一个特殊操作:

1
2
3
4
5
6
7
8
9
10
11
12
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)
{
...
p->tss.ldt = _LDT(nr);
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
...
}

这一段的目的是设置了进程的局部描述符表(LDT),并且判断了如果上一个使用 FPU 的任务时当前任务,那么也将浮点寄存器的状态保存到进程的 TSS 中。(详细见参考)

在进行复制操作时,会对新的进程 1 的内存规划,引出 copy_mem() 函数,而在这种又实现了对 LDT 的赋值、页表的复制。

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_mem(int nr,struct task_struct * p)
{
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); // 获取 0x0f 0x17 段选择子的段限制
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
if (old_data_base != old_code_base)
panic("We don't support separate I&D");
if (data_limit < code_limit)
panic("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000;
p->start_code = new_code_base;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
printk("free_page_tables: from copy_mem\n");
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
return 0;
}

LDT 的赋值

给进程 0 设置的 LDT 的代码段和数据段,段基址都是 0,段限长是 640KB。而正在 fork 的进程 1,其代码段和数据段还没有设置。所以第一步,便是局部描述符表的赋值,给属于进程 1 的 LDT 赋值。

段限长取自进程 0,即640KB。

1
2
code_limit=get_limit(0x0f);
data_limit=get_limit(0x17); // 获取 0x0f 0x17 段选择子的段限制

段基址的设置不再单单是通过继承进程 0 的值,而是却决于当前是几号进程,也就是参数 nr 的值。

1
new_data_base = new_code_base = nr * 0x4000000;  // 64MB,暂时忽略 640KB 的段限长

接着将 LDT 基址设置进 LDT 结构。

1
2
3
p->start_code = new_code_base;					// 设置代码段基址
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);

至此,通过分段的方式,进程映射到了相互隔离的线性地址空间,但是内存管理不只有分段机制,还有分页机制,组成了段页式的管理方式。

页表的复制

1
2
3
4
5
6
// old = 0, new = 64M, limit = 640KB
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
printk("free_page_tables: from copy_mem\n");
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}

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

首先,明确页表复制的目的,就是在目前进程 0 的线性地址空间为 0 ~ 64MB,进程 1 的线性地址空间是 64M ~ 128M 的基础上,创造出进程 1 的页表,使得进程 0 和进程 1 最终都被映射到物理地址空间为 0 ~ 64MB 的地方。(具体原因见参考)

至于如何将不同地址通过页表映射到相同的物理地址上,根据分页机制,高 10 位表示页目录项,中 10 位表示页表项,低 12 位表示业内偏移,进程 0 和进程 1 根据此机制所找到真正物理地址所不同的地方在于,页表项数据,所以只需要保证页表项那 8B 的数据的相同,就可以将最终的物理地址映射到同样的区域。

第 29 回 写时复制就这几行代码

写时复制就是读写位的值不同,延时触发复制页表最终指向物理内存而完成的,结尾又介绍了写时复制的意义是什么,现在看一下 Linux 0.11 中写时复制的那几行代码究竟是怎么回事。

首先,还是前面提到过的,页表中有一位用来表示读\写权限,而 Linux 0.11 进行初始化时,将其置为 1 ,表示可读写。其余设置如下:

1
2
3
4
5
6
7
8
9
10
11
setup_paging:
...
movl $pg0+7,_pg_dir /* set present bit/user r/w */
movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
movl $pg2+7,_pg_dir+8 /* --------- " " --------- */
movl $pg3+7,_pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl
...

后三位是 7,用二进制表示就是 111,即初始设置的 4 个页目录表和 1024 个页表,都是:

存在(1),可读写(1),用户态(1)

写时复制的本质

在调用 fork 函数时,新进程和原进程会先共享一块内存区域,只有当其中一个进程进行写操作时,系统才回将其另分配内存页面。其原因是复制物理地址空间是相对比较费时的,所以先复制了页表,物理地址的内容先不复制(这需要前面提到的两个进程的不同线性地址空间映射到相同一块物理地址空间上)。

写时复制的代码

当其中一个进程有写操作的请求以后,就会触发写时复制这个逻辑,具体原因就是缺页中断

1
2
3
4
5
6
7
8
9
10
int copy_page_tables(...) {
...
// 源页表和新页表一样
this_page = *from_page_table;
...
// 源页表和新页表均置为只读
this_page &= ~2;
*from_page_table = this_page;
...
}

在复制页表时,会将原页表和新页表的读写位通通设置为只读,其目的就是当有写操作的时使得处理器触发缺页中断(0x14)。

1
2
3
4
5
6
7
8
9
// Linux 1.0 代码
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);
...
}

当进入到该函数体中,有两种情况, error_code 的第 0 位,也就是存在位为 0 时,会走 do_no_page 逻辑,其余情况,均走 do_wp_page 逻辑。

我们 fork 的时候只是将读写位变成了只读,存在位仍然是 1 没有动,所以会走 do_wp_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
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; // 和初始化页表时的置位相同,存在(1),可读写(1),用户态(1)
invalidate();
copy_page(old_page,new_page);
}

// 刷新页变换高速缓冲宏函数
#define invalidate() \
__asm__("movl %%eax,%%cr3"::"a" (0))

在这之后进程再写时就会只需要改页属性即可,不需要页面复制操作。

缺页中断的处理过程中,除了写时复制原理的 do_wp_page,还有个 do_no_page,是在页表项的存在位 P 为 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
__asm__ ("movl %%eax,%%esp\n\t" \
"pushl $0x17\n\t" \
"pushl %%eax\n\t" \
"pushfl\n\t" \
"pushl $0x0f\n\t" \
"pushl $1f\n\t" \
"iret\n" \
"1:\tmovl $0x17,%%eax\n\t" \
"movw %%ax,%%ds\n\t" \
"movw %%ax,%%es\n\t" \
"movw %%ax,%%fs\n\t" \
"movw %%ax,%%gs" \
:::"ax")

#define move_to_user_mode() \
_asm { \
_asm mov eax,esp ; 将栈指针 esp 的值保存到寄存器 eax
_asm push 00000017h ; 将用户态的选择子(通常是 0x17)压栈
_asm push eax ; 将先前保存的栈指针值再次压栈
_asm pushfd ; 将标志寄存器的值压栈
_asm push 0000000fh ; 将用户态的代码段选择子(通常是 0x0f)压栈
_asm push offset l1 ; 将偏移地址为 l1 的标签地址压栈
_asm iretd ; 执行中断返回指令
_asm l1: mov eax,17h ; 设置用户态的代码段选择子(通常是 0x17)到寄存器 eax
_asm mov ds,ax ; 设置数据段寄存器 ds
_asm mov es,ax ; 设置附加段寄存器 es
_asm mov fs,ax ; 设置附加段寄存器 fs
_asm mov gs,ax ; 设置附加段寄存器 gs
}

这两段代码的目标相同,都是用于在 x86 架构的汇编代码中实现从内核态切换到用户态的过程。它们的不同之处在于使用了不同的语法和寄存器命名约定。在不同的编译器和汇编器环境中,__asm___asm 的使用可能有一些差异。

在典型的 GCC 编译器环境中:

  1. __asm__ 是内联汇编的标准语法。在这个标准下,通常使用双下划线 __ 来标识关键字。
  2. _asm 不是 C 或 C++ 的标准语法。在某些特定的编译器环境中,可能是一种支持嵌入汇编的非标准语法。

所以,这两段代码在不同的编译器环境下可能会有差异。如果你使用的是 GCC 编译器,建议使用 __asm__,因为它是标准的内联汇编语法。

此外,代码中的寄存器名称也略有不同:

  • %%eax__asm__ 版本中用于表示寄存器 eax。
  • _asm 版本中直接使用 eax 表示寄存器 eax。

这种不同可能是由于不同的编译器和汇编器对于内联汇编语法和寄存器命名的规定而导致的。

switch_to

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* switch_to(n) should switch tasks to task nr n, first
* checking that n isn't the current task, in which case it does nothing.
* This also clears the TS-flag if the task we switched to has used
* tha math co-processor latest.
*/
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,_current\n\t" \
"ljmp *%0\n\t" \
"cmpl %%ecx,_last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}

  1. 宏定义:
    • 使用宏定义 switch_to(n),其中 n 是要切换到的任务号。
  2. 内联汇编:
    • 使用了内联汇编嵌入在 C 代码中,通过 __asm__ 关键字引入汇编代码。
  3. 任务切换前检查:
    • cmpl %%ecx, _current 指令比较当前任务号是否等于 n,如果相等(je 1f),则跳转到标号 1,不执行任务切换。
  4. 保存寄存器内容:
    • movw %%dx, %1 指令将 dx 寄存器的值保存到临时变量 __tmp.b 中。
  5. 任务切换:
    • xchgl %%ecx, _current 指令使用原子交换操作将当前任务号 _currentn 交换,确保任务切换是原子的。
    • ljmp *%0 指令实现了远跳转,跳转到新任务的 TSS(任务状态段)。
  6. 清除数学协处理器标志:
    • cmpl %%ecx, _last_task_used_math 指令比较当前任务号是否等于上一个使用数学协处理器的任务号。
    • 如果不相等(jne 1f),则跳转到标号 1,不执行清除数学协处理器标志的操作。
    • clts 指令用于清除 TS(Task Switch)标志,表示当前任务不再使用数学协处理器。
  7. 标号 1:
    • 标号 1: 是一个占位标号,用于处理任务切换前的跳转。

整体而言,这段代码实现了一个安全的任务切换机制,确保在任务切换时,不会中断正在执行的任务,并且在有需要的情况下清除了数学协处理器标志。

第 26 回 压入栈的寄存器做什么用?

这几个被压入栈的寄存器是为了保存当前进程的执行环境,以便在创建新进程后能够恢复。以下是这些寄存器的作用:

  1. %gs 寄存器:在多任务系统中,%gs 寄存器通常用于存储当前进程的线程局部存储(Thread Local Storage,TLS)。通过保存和恢复 %gs 寄存器的值,可以确保在切换进程时正确处理线程局部存储。
  2. %esi%edi%ebp 寄存器:这些通用寄存器用于保存当前进程在执行过程中的重要状态。具体来说:
    • %esi 寄存器通常用于存储源操作数的地址,它的值在函数调用之间需要保存和恢复。
    • %edi 寄存器通常用于存储目标操作数的地址,同样需要在函数调用之间保存和恢复。
    • %ebp 寄存器是基址指针,指向当前进程的栈帧。保存和恢复 %ebp 寄存器的值确保在进程切换时栈的正确使用。
  3. %eax 寄存器:这是一个通用寄存器,保存当前进程状态的返回值,通常是系统调用的返回值。在创建新进程后,它的值会被用于判断新进程的创建是否成功。

这些寄存器在创建新进程之前被保存,然后在创建新进程后通过出栈操作进行恢复,确保新进程从创建点开始执行时能够继续原来的状态。

浮点寄存器的保存

1
2
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));

这段代码主要用于处理浮点数协处理器(Floating Point Unit, FPU)状态的保存。让我们逐步讲解:

  1. 检查上一个使用 FPU 的任务是否是当前任务:

    1
    2
    cCopy code
    if (last_task_used_math == current)

    这里检查上一个使用 FPU 的任务是否就是当前任务。如果是,说明 FPU 的状态已经保存在当前任务的 tss.i387 结构体中。

  2. 禁用任务切换(Task Switching):

    1
    2
    cCopy code
    __asm__("clts ; fnsave %0"::"m" (p->tss.i387));
    • clts 指令用于清除任务切换标志位(TS)。该标志位用于禁止或允许任务切换时保存和加载 FPU 的状态。在这里,通过 clts 指令清除 TS 位,允许 FPU 状态的保存和加载。
    • fnsave %0 用于保存当前任务的 FPU 状态到内存中,操作数 %0 表示 p->tss.i387

这段代码的目的是确保在进行任务切换之前,当前任务的 FPU 状态已经保存。如果上一个使用 FPU 的任务是当前任务,就通过 fnsave 指令将 FPU 的状态保存到当前任务的 tss.i387 结构体中,从而确保在切换到新任务时,可以正确加载 FPU 的状态。

为什么 FPU 更适合进行浮点运算

硬件浮点单元(FPU)是专门设计用于高效执行浮点运算的硬件组件。它的设计和实现考虑了浮点数的特殊性质,以提高计算效率。以下是硬件 FPU 的一些关键原理和特点:

  1. 寄存器: FPU 包含一组专用的浮点寄存器,用于存储浮点数的值。这些寄存器通常比通用目的寄存器更宽,可以容纳更长的浮点数。
  2. 指令集: FPU 支持一组特殊的浮点指令,用于执行各种浮点运算,如加法、减法、乘法、除法等。这些指令可以一次性处理多个浮点数,提高并行性。
  3. 流水线: FPU 通常采用流水线架构,将浮点运算划分为多个阶段,并在不同的时钟周期内执行这些阶段。这允许同时执行多个浮点指令,提高整体吞吐量。
  4. 硬件协处理器: FPU 通常作为协处理器与主处理器配合工作,可以并行执行浮点和整数指令。这种协处理器设计允许浮点运算与整数运算并行进行,提高总体性能。
  5. 数据格式支持: FPU 支持不同的浮点数格式,如单精度浮点数(32 位)和双精度浮点数(64 位)。支持多种数据格式使得 FPU 能够适应不同的应用场景。
  6. 异常处理: FPU 能够检测并处理浮点运算中的异常,如除以零、溢出等。这有助于提高浮点运算的可靠性。
  7. 寄存器堆栈: FPU 通常包含一个寄存器堆栈,用于存储临时结果和中间值。这样的堆栈结构允许在多个浮点运算之间保存状态。

总的来说,硬件 FPU 的设计旨在提供高效的浮点运算能力,通过专用寄存器、指令集、流水线等技术来优化浮点计算的性能。这使得 FPU 在科学计算、图形处理和其他需要大量浮点运算的应用中发挥重要作用。

写时复制

写时复制(Copy-On-Write,简称COW)是一种延迟复制策略,用于优化对共享资源的并发访问。它的基本思想是,当多个进程共享同一块内存时,只有在某个进程尝试修改内存内容时,才会复制该内存块,确保修改只影响到进行了写操作的进程,而不影响其他进程。

具体来说,写时复制的过程如下:

  1. 共享阶段: 多个进程共享同一块内存,它们都指向相同的物理页面。
  2. 写操作尝试: 当有进程尝试对共享的内存进行写操作时,系统会检查这块内存是否已经被复制。
  3. 复制操作: 如果这块内存还没有被复制,系统会为进行写操作的进程分配一块新的物理页面,并将原来的内容复制到新的页面上。这样,进行写操作的进程拥有了独立的物理页面,而其他进程仍然共享原始的页面。
  4. 更新映射关系: 将进行写操作的进程的虚拟地址映射到新的物理页面上。

这样一来,写时复制使得多个进程可以共享同一块内存,而不需要在一开始就进行复制,从而提高了系统的性能和资源利用率。只有在写操作发生时才进行实际的复制,延迟了复制的时机。

写时复制通常用于处理 fork 操作。在 fork 中,子进程最初与父进程共享相同的地址空间,只有在其中一个进程尝试写入时,才会发生实际的复制。这样做的好处是避免了不必要的内存复制,提高了 fork 操作的效率。

为什么进程 1 和进程 0 最终要映射到同一片物理空间

在Linux 0.11中,进程0(idle进程)和进程1(init进程)的物理地址空间被映射到相同的物理内存区域,这是为了简化内核的设计和实现,同时也反映了一些早期Unix系统的设计理念。

在Linux 0.11的设计中,物理地址空间的映射主要通过页表来完成。进程0和进程1的页表中,对应于内核空间的部分被映射到相同的物理地址,这意味着它们共享相同的内核代码和数据。这样设计的好处包括:

  1. 共享内核代码和数据: 进程0和进程1可以共享内核的只读代码段和只读数据段,避免了重复占用内存空间。这对于嵌入式系统等资源受限环境下是一种有效的优化。
  2. 简化内核设计: 维护单一的内核空间映射可以简化内核代码的管理和维护。在早期的Unix系统中,这种设计有助于减小内核的体积和复杂性。
  3. 更容易理解和调试: 进程0和进程1在物理地址空间上的相似性使得调试和理解内核的运行状态更加方便。它们可以在相同的地址范围内执行,从而更容易进行调试和分析。

需要注意的是,这种设计并不是通用的,对于更为复杂和多用户的操作系统,通常会采用更灵活的内存管理策略,以支持不同进程的独立地址空间。在Linux等现代操作系统中,通常会采用虚拟内存机制,允许每个进程拥有独立的地址空间,从而提高安全性和隔离性。

很难的复制页表(copy_page_tables)函数

这段代码实现了将一个进程的页表复制到另一个进程的页表的功能。在Linux 0.11中,当创建一个新的进程时,需要将父进程的页表复制给子进程,以确保它们共享相同的内存映射关系。

以下是对代码的详细讲解:

  1. 地址对齐检查:

    1
    2
    cCopy codeif ((from&0x3fffff) || (to&0x3fffff))
    panic("copy_page_tables called with wrong alignment");

    这部分代码检查源地址 from 和目标地址 to 是否按照4MB边界对齐。在x86体系结构中,页表项的地址需要按照4MB对齐。

  2. 获取目录项地址:

    1
    2
    cCopy codefrom_dir = (unsigned long *) ((from>>20) & 0xffc);
    to_dir = (unsigned long *) ((to>>20) & 0xffc);

    计算源地址和目标地址对应的页目录项的地址。每个页目录项占用4字节,因此通过右移20位得到页目录项索引,然后再乘以4得到页目录项的地址。

  3. 计算需要复制的页表数量:

    1
    2
    cCopy code
    size = ((unsigned) (size+0x3fffff)) >> 22;

    这里 size 表示需要复制的页表的数量,通过将 size 加上0x3fffff,再右移22位,可以得到一个向上取整的除法结果。

  4. 循环复制页表:

    1
    2
    cCopy code
    for( ; size-->0 ; from_dir++,to_dir++) {

    使用循环依次处理每个页表。每次迭代,都会处理一个页表。from_dirto_dir 分别指向源进程和目标进程的页目录项。

  5. 检查目标页目录项是否已存在:

    1
    2
    cCopy codeif (1 & *to_dir)
    panic("copy_page_tables: already exist");

    如果目标页目录项已经存在,则表示出现了错误,因为这个函数用于创建新的页表。

  6. 创建新的页表:

    1
    2
    cCopy codeif (!(to_page_table = (unsigned long *) get_free_page()))
    return -1; /* Out of memory, see freeing */

    为目标进程分配一个新的页表,get_free_page 是获取一个空闲物理页面的函数。

  7. 更新目录项:

    1
    2
    cCopy code
    *to_dir = ((unsigned long) to_page_table) | 7;

    将目标页目录项设置为指向新创建的页表,并设置访问权限为7(表示用户态可读、写、执行)。

  8. 复制页表项:

    1
    2
    3
    cCopy codenr = (from==0)?0xA0:1024;
    for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
    // ...

    在内部的循环中,迭代处理每个页表项。这里 nr 表示每个页表的项数,根据是否是从物理地址0开始复制,选择了不同的项数。在循环中,将源页表项的内容复制到目标页表,并更新相应的引用计数。

  9. 更新源页表项:

    1
    2
    3
    4
    5
    6
    cCopy codeif (this_page > LOW_MEM) {
    *from_page_table = this_page;
    this_page -= LOW_MEM;
    this_page >>= 12;
    mem_map[this_page]++;
    }

    如果源页表项指向的页面位于低端内存之上,更新源页表项的内容,同时增加相应页面的引用计数。

  10. 刷新 TLB 缓存:

1
2
cCopy code
invalidate();

刷新 TLB 缓存,确保新的页表项立即生效。

  1. 返回:
1
2
cCopy code
return 0;

函数执行成功,返回0。

这段代码的主要作用是在新的进程地址空间中复制父进程的页表,以确保父子进程可以共享相同的物理页面。