X86_Linux_0.11--进程1的创建与执行
现在系统中已经存在一个进程0了,进程0要做的第一件事的就是作为父进程调用fork函数创建第一个子进程—进程1。以后,所有进程都是基于父子进程创建机制由父进程创建而来。
进程1的创建
进程0触发0x80中断,进入fork函数
除了进程0是由内核直接创建之外,Linux操作系统的所有进程都是由父进程调用fork函数创建的。流程是这样的:
进程调用fork函数—>fork函数触发int 0x80
中断—>从IDT中取出int 0x80
中断服务程序入口地址—>跳转到中断服务程序system_call
运行—>system_call
调用sys_fork
创建子进程—>返回
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 代码路径:init/main.c:
…
static inline _syscall0(int, fork) // 对应fork()函数
static inline _syscall0(int, pause)
static inline _syscall1(int, setup, void *, BIOS)
…
void main(void) {
sti();
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();
}
// 代码路径:include/unistd.h:
…
#define __NR_setup 0 /* used only by init, to get system going */
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
…
#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; \
}
…
volatile void _exit(int status);
int fcntl(int fildes, int cmd, ...);
int fork(void);
int getpid(void);
int getuid(void);
int geteuid(void);
…
//代码路径:include/linux/sys.h:
extern int sys_setup();
extern int sys_exit();
extern int sys_fork(); // 对应system_call.s中的_sys_fork,汇编中对应C语言的函
// 数名在前面多加一个下划线"_",如C语言的sys_fork对应汇编的
// 就是_sys_fork
extern int sys_read();
extern int sys_write();
extern int sys_open();
…
fn_ptr sys_call_table[] = {sys_setup, sys_exit, sys_fork, sys_read, // sys_fork对应_sys_call_table的第三项
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,}
…
// syscall0展开后,看上去像下面的样子:
int fork(void) // 参看2.5节、2.9节、2.14节有关嵌入汇编的代码注释
{
long __res;
__asm__ volatile("int $0x80" // int 0x80是所有系统调用函数的总入口,fork()是其中
// 之一,参看2.9节的讲解及代码注释
: "=a"(__res) // 第一个冒号后是输出部分,将_res赋给eax
: "0"(__NR_fork)); // 第二个冒号后是输入部分,"0":同上寄存器,即eax,
// __NR_ fork就是2,将2给eax
if (__res >= 0) // int 0x80中断返回后,将执行这一句
return (int)__res;
errno = -__res;
return -1;
}
// 重要:别忘了int 0x80导致CPU硬件自动将ss、esp、eflags、cs、eip的值压栈!
// 解及代码解释
上面的代码配合注释很好懂,在调用int 0x80
的之前,讲寄存器eax
设置成了__NR_fork
实际上这就是fork()
函数在sys_call_table[]
中的偏移,也就是决定int 0x80
中断对应的中断服务程序具体调用哪个函数来处理系统调用请求。
调用int 0x80
之后,就产生了一个软中断,它会使CPU从3特权级进入0特权级,同时它会把SS
, ESP
, EFLAGS
, CS
, EIP
这五个寄存器压入内核栈(内核栈在哪?其实当前进程的TSS上保存了这一信息)。当然了,作为熟悉计算机体系结构的你应该知道执行中断的时候,保存的CS:EIP
其实保存的是调用者的下一条指令的地址,这是硬件规定的(RISC-V等架构没有这一要求,由软件保存)。
现在进入3特权级了,此时CPU就是由内核接管的了,首先会根据IDT指向的中断服务程序地址跳转到system_call
这个函数运行,也就是运行下面的代码:
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
//代码路径:kernel/system_call.s:
…
_system_call: # int 0x80——系统调用的总入口
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds #下面6个push都是为了copy_process()的参数,请记住
#压栈的顺序,别忘了前面的int 0x80还压了5个寄存器的值进栈
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) # eax是2,可以看成call (_sys_call_table + 2×4)就是
# _sys_fork的入口
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
…
sys_fork: #sys_fork函数的入口
…
system_call
继续将DS
, ES
, FS
, EDX
, ECX
, EBX
压栈(这些都是为了后面调用copy_process
函数中初始化进程1的TSS做准备)。随后,根据EAX
中的偏移值,查询sys_call_table[]
得到本次需要执行的系统调用是sys_fork
,理所当然的,下面会跳转到sys_fork
函数去执行。
汇编语言的函数名、变量名对应C语言中同样的函数名,在前面加一个
_
,例如汇编使用_sys_fork()
调用C语言函数sys_fork
。
call _sys_call_table(, %eax, 4)
这其实就是跳转到_sys_call_table
的起始地址加上EAX
* 4这个地址,也就是对应的函数地址在这个系统调用表里面的偏移。
当然了,call
这个指令你知道的,它是函数调用指令,会将CS:EIP
压栈,以便后续返回继续执行。
分析这段代码最后两行的含义
1
2
3
_system_call:
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
答案:
eax
的值是系统调用的编号,是被调用的系统调用在sys_call_table[]
内的偏移值,第一行代码是比较系统调用编号的最大合法值和当前传入的系统调用编号进行比较,第二行的ja
是条件跳转指令,跳转到bad_sys_call
的条件是eax
的值大于等于$nr_system_call-1
。总结:这两行将代码用于判断传入的系统调用编号是否合法。
sys_fork
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//代码路径:kernel/system_call.s:
…
_system_call:
…
_sys_fork:
call _find_empty_process #调用find_empty_process()
testl %eax,%eax #如果返回的是-EAGAIN(11),说明已有64个进程在运行
js 1f
push %gs #5个push也作为copy_process()的参数初始
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process #调用copy_process()
在task[64]中为进程1申请一个空闲位置并获取进程号
现在开始执行sys_fork
函数了。
之前在sched_init()
中,已经将task[64]
中除了第0项之外的所有项清零了,task[64]
保存着系统中进程task_struct
结构的指针,是系统管理进程的关键数据之一,因此进程1需要在task[64]
中找到一个空表项,存储自身的task_struct
指针。
find_empty_process
这个函数的任务正是寻找一个空个task[64]
表项:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//代码路径:kernel/fork.c:
…
long last_pid=0;
…
intlt@span i=1> find_empty_process(void)lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> lt@span i=1> //为新创建的进程找到一个空闲的位置,NR_TASKS是64
{
int i;
repeat:
if ((++last_pid)<0) last_pid=1; //如果++后last_pid溢出,则置1
for(i=0;i<NR_TASKS;i++) //现在,+ + 后last_pid为1。找到有效的last_pid
if (task[i] && task[i]->pid== last_pid) goto repeat;
for(i=1;i<NR_TASKS;i++) //返回第一个空闲的i
if (!task[i])
return i;
return -EAGAIN; // EAGAIN是11
}
last_pid
是一个全局变量,它存放系统自开机以来累计的进程数,也将此变量用作新建进程的进程号(pid),这段代码非常好懂,读者完全能看明白,不解释了。总之,此函数会返回一个空闲的task[64]
表项的索引,用作新进程的pid,如果task[64]
已满,则函数会返回一个约定值表示task[64]
已满。
现在,进程1在task[64]
中找到了空位:task[1]
,进程1相当于拿到了身份证号。
接下来,fork
函数继续压栈,为调用copy_process
函数准备参数,这些数据也是进程1的TSS中数据的来源。压栈结束后,就要调用copy_process
了。
执行copy_process
函数
copy_process
的流程是这样的:
- 为进程1创建
task_struct
,将进程0的task_struct
的内容复制给进程1 - 为进程1的
task_struct
、TSS
做个性化设置 - 为进程1创建第一个页表,将进程0的页表项内容赋给这个页表
- 进程1共享进程0的文件
- 设置进程1的GDT项
- 将进程1设置为就绪态,使其能够参与进程间的轮转
现在开始执行这一流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 代码路径:kernel/fork.c:
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)
// 注意:这些参数是int 0x80、system_call、sys_fork多次累积压栈的结果,顺序是完全一致的
{
struct task_struct *p;
int i;
struct file *f;
// 在16 MB内存的最高端获取一页,强制类型转换的潜台词是将这个页当task_union用,参看2.9节
p = (struct task_struct *)get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p; // 此时的nr就是1,潜台词是将这个页当task_union用,参看2.9节
…
}
本系列前面的文章已经非常简要的介绍过函数调用栈了。你会发现copy_process
有非常多的参数,这些参数正是之前fork
函数和system_call
函数压栈的数据(除了最后五个)。
copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long ss。查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。详细论证其他所有参数是如何传入的。
答案:因为这五个参数是进程0运行在3特权级时,调用系统调用,将CPU从3特权级运行转换到0特权级运行,CPU所自动压栈的(当然,发生特权级转换的中断会保存这五个,不发生特权级转换的中断只用保存三个),因此天然在栈中,不需要软件压栈。这些参数中,最后五个寄存器是发生特权级反转的中断CPU自动压栈的,
ebx
~ds
是函数system_call
压栈的,nr
~gs
是sys_fork
函数压栈的,而none
其实是函数sys_fork
的返回地址,是system_call
使用call
指令调用sys_fork
函数所自动压栈的,因为在copy_process
中用不到,索引标记为none
。
首先,调用get_free_page()
函数获得一个空闲页,将申请到的页面清零,用于进程1的task_struct
和stack
,其实就是之前提到的task_union
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 代码路径:mm/memory.c:
unsigned long get_free_page(void) // 遍历mem map[],找到主内存中(从高地址开始)第一个空闲页面
{ // 参看前面的嵌入汇编的代码注释
register unsigned long __res asm("ax");
__asm__("std;repne;scasb\n\t" // 反向扫描串(mem map[]),al(0)与di不等则
// 重复(找引用对数为0的项)
"jne 1f\n\t" // 找不到空闲页,跳转到1
"movb $1,1(%%edi)\n\t" // 将1赋给edi + 1的位置,在mem map[]中,
// 将找到0的项的引用计数置为1
"sall $12,%%ecx\n\t" // ecx算数左移12位,页的相对地址
"addl %2,%%ecx\n\t" // LOW MEN + ecx,页的物理地址
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t" // 将edx + 4 KB的有效地址赋给edi
"rep;stosl\n\t" // 将eax(即"0"(0))赋给edi指向的地址,目的是页面清零
"movl %%edx,%%eax\n"
"1:"
: "=a"(__res)
: "0"(0), "i"(LOW_MEM), "c"(PAGING_PAGES),
"D"(mem_map + PAGING_PAGES - 1) // edx,mem map[]的最后一个元素
: "di", "cx", "dx"); // 第三个冒号后是程序中改变过的量
return __res;
}
get_free_pages
函数的算法就是从mem_map
的最后开始向前遍历,找到一个空闲页,将其清零并返回该物理页的起始地址。看懂这个代码需要较深的X86汇编功底了,其实这段代码也完全可以用C来写,Linus这么写可能是节省内存考虑吧。看不懂也没关系,只需要知道这段代码是干什么的就行了。
分析
get_free_page()
函数的代码,叙述在主内存中获取一个空闲页的技术路线
答案:
get_free_page()
函数遍历mem_map[],找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其置为1。ecx左移12位加LOW_MEM得到该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。
得到一个空闲页之后,copy_process
将其强制转换为task_struct
结构赋值给p指针,随后再将task[64]
中的对应项指向这个task_struct
结构,得到的这个物理页实际上是被当作之前提到的task_union
结构,页的起始地址是task_struct
, 之后是task_stack
,栈的方向是从高地址向低地址的,因此是从页末尾向页起始地址增长的。
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
// 代码路径:kernel/fork.c:
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)
{
...
if (!p)
return -EAGAIN;
task[nr] = p; // 此时的nr就是1
/* current指向当前进程的task_struct的指针,当前进程是进程0。下面这行的意思:将父进程的
task_struct赋给子进程。这是父子进程创建机制的重要体现。这行代码执行后,父子进程的task_struct将完
全一样*/
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
/*重要!!注意指针类型,只复制task_struct,并未将4 KB都复制,即进程0的内核栈并未复制*/
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; // 开始设置子进程的TSS
...
}
接着,将当前的current赋值给p, 也就是将当前进程(进程0)的task_struct
赋值给进程1的task_struct
。之后要做一些针对进程1的个性化设置(毕竟不能完全照搬进程0啊),设置进程1的状态、pid、父进程、优先级等等内容。当然,有一个很重要的步骤就是设置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
31
// 代码路径:kernel/fork.c:
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->start_time= jiffies;
p->tss.back_link = 0; // 开始设置子进程的TSS p->tss.esp0= PAGE_SIZE + (long) p; //esp0是内核栈指针,参看上面的注释及2.9.1节p->tss.ss0= 0x10; //0x10就是10000,0特权级,GDT,数据段
p->tss.eip = eip; // 重要!就是参数的EIP,是int 0x80压栈的,指向的是:if(__res >= 0)
p->tss.eflags = eflags;
p->tss.eax = 0; // 重要!决定main()函数中if (!fork())后面的分支走向
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); // 挂接子进程的LDT
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__(„clts;fnsave %0"::"m" (p->tss.i387));
...
}
这段代码就是设置进程1的TSS的代码,它的数据来源就是之前压栈的数据,这里有个很重要的地方就是将eax
设置为0,eax
就是函数的返回值,放在这里,其实就是fork()
的返回值,创建完子进程之后,代码就依靠fork()
的返回值来判断是不是子进程。
这里CS:EIP
当然也是之前硬件自动压栈的值,它指向原进程0调用fork
的下一条指令,因此子进程运行后,就从这里开始执行。
设置进程1的分页管理
接下来要做的就是设置进程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
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
69
70
71
72
73
74
75
76
77
78
// 代码路径:kernel/fork.c:
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)
{
...
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++) // 下面将父进程相关文件属性的引用计数加1,表明父子进程共享文件
...
}
// 代码路径:include/linux/sched.h:
...
#define _set_base(addr, base) \ //用base设置addr,参看2.9节段描述符图及代码注释
__asm__("movw %%dx,%0\n\t"
"rorl $16,%%edx\n\t"
"movb %%dl,%1\n\t"
"movb %%dh,%2" ::"m"(*((addr) + 2)),
"m"(*((addr) + 4)),
"m"(*((addr) + 7)),
"d"(base)
: "dx")
...
#define set_base(ldt, base) _set_base(((char *)&(ldt)), base)
...
#define _get_base(addr) ({\ //获取addr段基址,参看_set_base,参看2.9节段描述
//符图及代码注释
unsigned long __base;
__asm__("movb %3,%%dh\n\t"
"movb %2,%%dl\n\t"
"shll $16,%%edx\n\t"
"movw %1,%%dx"
: "=d"(__base)
: "m"(*((addr) + 2)),
"m"(*((addr) + 4)),
"m"(*((addr) + 7)));
__base;
})
#define get_base(ldt) _get_base(((char *)&(ldt)))
#define get_limit(segment) ({ \
unsigned long __limit; \
__asm__("lsll %1,%0\n\tincl %0":"=r" (__limit):"r" (segment)); \
//取segment的段限长,给__limit
__limit;
})
//代码路径:kernel/fork.c:
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); //0x0f即1111:代码段、LDT、3特权级
data_limit = get_limit(0x17); // 0x17即10111:数据段、LDT、3特权级
// 获取父进程(现在是进程0)的代码段、数据段基址
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; // 现在nr是1,0x4000000是64 MB
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))
{
free_page_tables(new_data_base, data_limit);
return -ENOMEM;
}
return 0;
}
copy_process
调用copy_mem
函数,设置进程1的代码段、数据段的段限长、段基址等信息,也就是设置新进程的LDT。这些信息主要是赋值的父进程(进程0),新进程的基地址就是进程号nr * 64MB,Linus设计Linux0.11最多就支持64的进程,因此64个进程*64MB刚好填满了32位可寻址的最大范围4GB。
这种分段机制在现在看来就是糟粕,纯纯是屎,如果物理内存足够大,这分段就是分个寂寞,现代操作系统几乎不使用分段机制而是几乎完全依赖分页机制。
在copy_mem
中还会调用copy_page_tables
函数来实现页表的复制。首先,为新的页表申请一个空闲页面作为页表,把进程0中第一个页表的前160项(640KB,进程0最大管理这么多区域)复制到此页表中,此时进程0和进程1都展示指向了相同的页面,意味着进程1也可以操作进程0的页面。之后对进程1的页目录表进程设置。最后重置CR3,使得页变换高速缓存刷新。此时,进程1的页表和页目录表就设置完毕了。
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
// 代码路径:mm/memory.c:
#define invalidate() \
__asm__("movl %%eax,%%cr3" ::"a"(0)) // 重置CR3为0
…
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;
if ((from & 0x3fffff) || (to & 0x3fffff))
panic("copy_page_tables called with wrong alignment");
from_dir = (unsigned long *)((from >> 20) & 0xffc); /* _pg_dir= 0 */
to_dir = (unsigned long *)((to >> 20) & 0xffc); // 这里相当于右移22为再左移两位。
size = ((unsigned)(size + 0x3fffff)) >> 22; // >> 22是4 MB数
for (; size-- > 0; from_dir++, to_dir++)
{
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
continue;
//*from_dir是页目录项中的地址,0xfffff000&是将低12位清零,高20位是页表的地址
from_page_table = (unsigned long *)(0xfffff000 & *from_dir);
if (!(to_page_table = (unsigned long *)get_free_page()))
return -1; /* Out of memory, see freeing */
*to_dir = ((unsigned long)to_page_table) | 7; // 7即111,参看1.3.5节的注释
nr = (from == 0) ? 0xA0 : 1024; // 0xA0即160,复制页表的项数,
for (; nr-- > 0; from_page_table++, to_page_table++)
{ // 复制父进程页表
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2; // 设置页表项属性,2是010,~2是101,代表用户、只读、存在
*to_page_table = this_page;
if (this_page > LOW_MEM)
{ // 1 MB以内的内核区不参与用户分页管理
*from_page_table = this_page;
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++; // 增加引用计数,参看mem_init
}
}
}
invalidate(); // 用重置CR3为0,刷新"页变换高速缓存"
return 0;
}
这段代码配合注释不难看懂,请不要陷入代码细节,你只是在学操作系统原理。
分析
copy_page_tables()
函数的代码,叙述父进程如何为子进程复制页表
答案:
copy_page_tables
使用两层循环,第一层遍历父进程的页目录表,每次为子进程申请一个空闲页作页表,第二层遍历页目录表对应的页表项,将其复制到之前为子进程申请的新页表(如果父进程是进程0,只复制160个页表项,否则复制整个页表),将子进程的页表挂接到页目录表中,让子进程和父进程指向相同的页面。然后重置CR3来刷新页面变换高速缓存。
让进程1共享进程0的文件
复制完页表后,要让进程1共享进程0的文件,当然了进程0没有文件。实际上,表述为让子进程共享父进程的文件,更好。
现在返回copy_process
函数中,子进程完全继承了父进程的task_struct
,包括文件,因此这里需要将父进程相关文件属性的引用计数加1,表明父子进程共享文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 代码路径:kernel/fork.c:
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)
{
…
return -EAGAIN;
}
for (i = 0; i < NR_OPEN; i++) // 下面将父进程相关文件属性的引用计数加1,表明父子进程共享文件
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)); // 设置GDT中与子进
// 程相关的项,参看sched.c
…
}
设置进程1在GDT中的表项
该复制的已经复制了,该设置的页表也设置了,剩下就是设置GDT表中,进程1相关的信息了,包括设置TSS和LDT。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 代码路径:kernel/fork.c:
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)
{
…
current->executable->i_count++;
set_tss_desc(gdt + (nr << 1) + FIRST_TSS_ENTRY, &(p->tss)); // 设置GDT中与子进
// 程相关的项,参看sched.c
set_ldt_desc(gdt + (nr << 1) + FIRST_LDT_ENTRY, &(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */ // 设置子进程为就绪态
…
}
这段代码调用的函数与之前设置进程0的TSS和LDT时的一致,没啥可介绍的。
让进程1处于就绪态
现在将进程1的状态调整为就绪态,表示进程1已经是一个完整的进程1,可以接受调度。
1
2
3
4
5
6
7
8
9
10
// 代码路径:kernel/fork.c:
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->state = TASK_RUNNING; /* do this last, just in case */ // 设置子进程为就绪态
return last_pid;
}
当然了,要返回last_pid
,表示创建的子进程的进程号,实际上它存储在eax
中。
创建完进程1后,copy_process
函数就执行完毕了,返回sys_fork()
函数中的call _copy_process()
的下一行执行,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//代码路径:kernel/system_call.s:
…
_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 //copy_process返回至此,esp+=20就是esp清20字节的栈,也就是清前面压的gs、esi、
1: ret //edi、ebp、eax,注意:内核栈里还有数据。返回_system_call中的pushl %eax执行
…
首先,将栈指针前移20个字节,也就是清理掉之前压栈的五个寄存器,然后返回到system_call
函数中。
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
//代码路径:kernel/system_call.s:
_system_call:
...
call _sys_call_table(,%eax,4)
pushl %eax #sys_fork返回到此执行,eax是copy_process()的返回值last_pid
movl _current,%eax #当前进程是进程0
cmpl $0,state(%eax) # state
jne reschedule#如果进程0不是就绪态,则进程调度
cmpl $0,counter(%eax) # counter
je reschedule #如果进程0没有时间片,则进程调度
ret_from_sys_call:
movl _current,%eax # task[0] cannot have signals
cmpl _task,%eax
je 3f #如果当前进程是进程0,跳到下面的3:处执行。当前进程是进程0!
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 #如果是进程0,则直接跳到这个地方执行,将7个寄存器的值出栈给CPU
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret #CPU硬件将int 0x80的中断时压的ss、esp、eflags、cs、eip的值
#出栈给CPU对应寄存器,
... # CS:EIP指向fork()中int 0x80的下一行if(__res >=0)处执行
先检查当前的进程是不是进程0,注意:pushl %eax
这行代码,将返回的进程1的进程号压栈,之后到_ret_from_sys_call:处执行。由于这里是进程0,因此跳到label3
运行。实际上就是将七个寄存器的值弹栈,相当于恢复现场了,然后使用iret
返回。
当然,iret
会将中断时硬件压栈的五个寄存器弹出,CS:EIP
指向fork
函数中int 0x80
的下一条指令。
1
2
3
4
5
6
7
8
9
10
11
12
// 代码路径:include/unistd.h:
int fork(void)
{
long __res;
__asm__ volatile("int $0x80"
: "=a"(__res) //__res的值就是eax,是copy_process()的返回值last_pid(1)
: "0"(__NR_fork));
if (__res >= 0) // iret后,执行这一行!__res就是eax,值是1
return (int)__res; // 返回1!
errno = -__res;
return -1;
}
这里也是做了一个合法性检查,然后将__res
返回,__res
实际上就是eax
,也就是子进程的进程号。然后回到main()
中继续执行。
1
2
3
4
5
6
7
8
9
10
11
12
//代码路径:init/main.c:
…
void main(void)
{
sti();
move_to_user_mode();
if (!fork()) {//fork的返回值为1,if(!1)为假/* we count on this going ok */
init(); //不会执行这一行
}
…
for(;;) pause(); //执行这一行!
}
之后就回到main()
了,这里如果fork
返回的是0,也就是如果是子进程,那么会执行init
这个函数,当然了当前这里其实是父进程,也就是进程0,因此会执行pause()
函数。
内核做第一次进程调度
当前执行的还是进程0的代码,但是现在要准备切换到进程1去执行了。
在Linux0.11中,有两种情况会发生进程切换:
当前运行的进程的时间片用完了。每个进程的
task_struct
中都有一个时间片变量,它记录着当前进程剩余的时间片,每个时钟中断这个时间片都会被减去一,当时间片被减为零的时候,意味着进程的时间片用完了,就需要发生进程切换,调用系统中其它的处于就绪状态的进程运行。当前进程停止运行,一般是主动停止运行。当当前进程需要等待外设或者其它程序的运行结果等情况的时候,当前进程就不能再运行了,因为它不具备继续运行的条件了,因此即使此时时间片还没有用完,它也要停止运行,调用其它进程来运行,这也是一种节约资源的非常好的方法。
但是进程0比较特殊,进程0切换到进程1既有第二种情况,又有点怠速进程的意思。这在后面会讲到。
因此,进程0会执行for (;;) pause()
这行代码,最终会执行到schedule()
函数,来切换进程1。
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// 代码路径:init/main.c:
…
static inline _syscall0(int, fork)
static inline _syscall0(int, pause)
…
void main(void)
{
…
move_to_user_mode();
if (!fork())
{ /* we count on this going ok */
init();
}
for (;;)
pause();
}
// 代码路径:kernel/sched.c:
int sys_pause(void)
{
// 将进程0设置为可中断等待状态,如果产生某种中断,或其他进程给这个进程发送特定信号…才有可能将
// 这个进程的状态改为就绪态
current->state = TASK_INTERRUPTIBLE;
schedule();
return 0;
}
// 代码路径:kernel/sched.c:
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)); // 设置SIGALRM
(*p)->alarm = 0; // alarm清零
}
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) // 找出就绪态中
// counter最大的进程
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; // 即counter= counter /2 + priority
}
switch_to(next);
}
// 代码路径:include/sched.h:
…
// FIRST_TSS_ENTRY<<3是100000,((unsigned long) n)<<4,对进程1是10000
// _TSS(1)就是110000,最后2位特权级,左第3位GDT,110是6即GDT中tss0的下标
#define _TSS(n) ((((unsigned long)n) << 4) + (FIRST_TSS_ENTRY << 3))
…
#define switch_to(n) {\
// __tmp用来构造ljmp的操作数。该操作数由4字节偏移和2字节选择符组成。当选择符
// 是TSS选择符时,指令忽略4字节偏移。
// __tmp.a存放的是偏移,__tmp.b的低2字节存放TSS选择符。高两字节为0。
// ljmp跳转到TSS段选择符会造成任务切换到TSS选择符对应的进程。
// ljmp指令格式是 ljmp 16位段选择符:32位偏移,但如果操作数在内存中,顺序正好相反。
// %0 内存地址 __tmp.a的地址,用来放偏移
// %1 内存地址 __tmp.b的地址,用来放TSS选择符
// %2 edx 任务号为n的TSS选择符
// %3 ecx task[n]
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \ // 如果要切换的任务是当前任务
"je 1f\n\t" \ // 直接退出
"movw %%dx,%1\n\t" \ // 把TSS选择符放入__tmp.b中
"xchgl %%ecx,current\n\t" \ // 让current指向新进程的task_struct
"ljmp *%0\n\t" \ // 任务切换在这里发生,CPU会搞定一切
"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])); \
}
pause
函数与fork
函数一样,都会执行到syscall0
,主动触发int 0x80
中断,通过在内核中system_call.s
中的call _sys_call_table (,%eax, 4)
,进入真正的系统调用处理函数sys_pause
中执行。因此实际上pause
也是一个系统调用。
与sys_fork()
不同的是。sys_pause
函数是用C语言编写的。
进入sys_pause()
函数之后,首先会将当前进程的状态设置为可中断等待状态,然后调用schedule()
函数进行进程切换。schedule()
函数首先会分析当前有没有必要进程进程切换,如果判断为有必要,就执行进程切换,否则直接返回。
schedule
函数首先第一次遍历task[64]
这个结构,遍历所有已经存在的进程,针对他们的“报警定时值”以及“信号位图signal”进行处理(不必深究)。在当前的情况下,你可以无视这几行代码,它们不产生任何效果。
然后,schedule()
函数会第二次遍历所有进程,比较进程的状态和时间片,找出处于就绪状态且counter
值最大的进程(counter
值在fork子进程的时候会被赋值为这个进程的优先级)。现在只有进程0和进程1,进程0处于可中断等待状态,因此只会寻找到进程1,因此,会执行switch_to(next)
函数切换到进程1去执行。
switch_to
这边的汇编比较难懂,最关键的就在于ljmp %0
,这个%0
实际上就是__tmp
,它实际上保存的是任务1的TSS的段描述符,因此这条指令会触发硬件任务切换机制,硬件会自动将当前进程的状态保存到当前进程的TSS,然后从目标TSS中取出进程的状态恢复运行。
#define _TSS(n) ((((unsigned long)n) << 4) + (FIRST_TSS_ENTRY << 3))
这里其实是推算进程N的TSS段描述符,首先你需要知道段描述符长什么样子。我们先假设进程0的TSS段描述符就在GDT的第0项,((unsigned long)n) << 4)
实际上是先左移三位,把数字n移动到段选择子的“描述符索引”的位置,然后在左移一位,实际上是做了乘2的操作,因为进程n的TSS和进程n+1的TSS之间还隔了一个进程n的LDT段描述符,因此这里乘2的操作表示跳过LDT得到正确的TSS的相对索引,比如进程0的TSS索引在GDT的0号位置,进程1的TSS就应该在GDT的2号位置,进程2的TSS索引在GDT的4号位置。但由于实际上进程0的索引不在GDT的第0项而在第4项,因此((unsigned long)n) << 4)
这个相对TSS0索引的索引还需要加上一个偏移值,也就是TSS0的偏移值,也就是FIRST_TSS_ENTRY << 3
,而段选择子的低三位刚好填充为0。
1
2
"1:" ::"m"(*&__tmp.a),
"m"(*&__tmp.b), \ //.a对应EIP(忽略),.b对应CS
这里的*实际上与C语言的*不同,这里的*表示的是&__tmp.a这个地方有目标操作数。
补充:
1
2
3
按AS手册,ljmp指令存在两种形式,即:
一、直接操作数跳转,此时操作数即为目标逻辑地址(选择子,偏移),即形如:ljmp $seg_selector, $offset的方式;
二、使用内存操作数,这时候,AS手册规定,内存操作数必须用“*”作前缀,即形如:ljmp *mem48,其中内存位置mem48处存放目标逻辑地址: 高16bit存放的是seg_selector,低32bit存放的是offset。注意:这条指令里的“*”只是表示间接跳转的意思,与C语言里的“*”作用完全不同。
代码中的”ljmp %0\n\t” 很奇怪,按理说jmp指令跳转到得位置应该是一条指令的地址,可是这行代码却跳到了”m” (*&__tmp.a),这明明是一个数据的地址,更奇怪的,这行代码竟然能正确执行。请论述其中的道理。
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
#define switch_to(n) {\
// __tmp用来构造ljmp的操作数。该操作数由4字节偏移和2字节选择符组成。当选择符
// 是TSS选择符时,指令忽略4字节偏移。
// __tmp.a存放的是偏移,__tmp.b的低2字节存放TSS选择符。高两字节为0。
// ljmp跳转到TSS段选择符会造成任务切换到TSS选择符对应的进程。
// ljmp指令格式是 ljmp 16位段选择符:32位偏移,但如果操作数在内存中,顺序正好相反。
// %0 内存地址 __tmp.a的地址,用来放偏移
// %1 内存地址 __tmp.b的地址,用来放TSS选择符
// %2 edx 任务号为n的TSS选择符
// %3 ecx task[n]
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \ // 如果要切换的任务是当前任务
"je 1f\n\t" \ // 直接退出
"movw %%dx,%1\n\t" \ // 把TSS选择符放入__tmp.b中
"xchgl %%ecx,current\n\t" \ // 让current指向新进程的task_struct
"ljmp *%0\n\t" \ // 任务切换在这里发生,CPU会搞定一切
"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])); \
}
答案:这其实是X86的任务门机制,a对应EIP,b对应CS,ljmp后面跟随一个TSS的段描述符,这触发X86的任务门机制,通过任务门机制(未实际使用),更准确地说当ljmp后面的段描述是TSS段描述符的时候,这实际上是触发了X86的任务切换机制,CPU会自动将当前进程的上下文状态保存到当前任务的TSS中,从目标TSS中取出目标进程的上下文(包括LDT的代码段、数据段描述符)并恢复,以此实现任务切换。
轮转到进程1执行
现在轮到进程1运行了。
前面fork进程1的时候我们讲到,进程1继承了进程0的TSS,因此第一次轮转到进程1运行的时候,进程1会从进程0执行fork调用的后一条指令开始执行。
1
2
3
4
5
6
7
8
//代码路径:init/main.c:
void main(void)
{
…
if (!fork()) { //!0为真,
init(); //这次要执行这一行!代码跨度比较大,请参看3.1.3节
}
}
但对进程1的个性化设置中,将进程1的返回值(eax
)设置为了0,那其实是个伏笔,为的就是这一刻,现在通过fork
函数的返回值(0),就可以区分谁是进程1(子进程)了,因此,if (!fork())
对进程1来说会被判定为真,那么进程1就会进入init
函数执行了。
1
2
3
4
5
6
7
//代码路径:init/main.c:
void init(void)
{
…
setup((void *) &drive_info);
…
}
init
函数首先会调用setup()
函数,它与之前的fork
、pause
类似,也是一个系统调用,但是它不是通过_syscall0()
而是通过_syscall1()
实现的,当然流程还是一样的,先使用int 0x80
触发系统调用中断。然后在system_call
中压栈,然后通过_sys_call_table
找到对应的系统调用函数(sys_setup
)去执行。
由于作者本人对文件并不熟悉,因此下面的内容可能并不专业,更多是照搬原文源代码
进程1为安装硬盘文件系统做准备
sys_setup
的大部分代码,技术路线长、代码多、难度大,但是目的单纯:为安装硬盘文件系统做准备。
大概有三个步骤:
- 根据机器系统数据设置硬盘参数
- 读取硬盘引导块
- 从引导块中获取信息
进程1设置硬盘的hd_info
根据机器系统数据中的drive_info
,如硬盘的柱面数、磁头数、扇区数来设置内核的hd_info
,这些数据是之前setup
程序留下的。
代码是这样的,但是没有深究的必要。
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
69
70
71
72
73
74
75
76
77
// 代码路径:kernel/blk_dev/hd.c:
…
struct hd_i_struct
{
// 磁头数、每磁道扇区数、柱面数、写预补偿柱面、磁头着陆区、控制字节
// 这都是外设硬件相关的东西,太古老了
int head, sect, cyl, wpcom, lzone, ctl;
};
…
struct hd_i_struct hd_info[] = { {0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0} };
…
static struct hd_struct
{
long start_sect; // 起始扇区号
long nr_sects; // 总扇区数
} hd[5 * MAX_HD] = {
{0, 0},
};
…
/* This may be used only once, enforced by 'static int callable' */
int
sys_setup(void *BIOS) // 对比调用可以看出BIOS就是drive_info,参看2.1节
{
static int callable = 1;
int i, drive;
unsigned char cmos_disks;
struct partition *p;
struct buffer_head *bh;
if (!callable) // 控制只调用一次
return -1;
callable = 0;
#ifndef HD_TYPE
for (drive = 0; drive < 2; drive++)
{ // 读取drive_info设置hd_info
hd_info[drive].cyl = *(unsigned short *)BIOS; // 柱面数
hd_info[drive].head = *(unsigned char *)(2 + BIOS); // 磁头数
hd_info[drive].wpcom = *(unsigned short *)(5 + BIOS);
hd_info[drive].ctl = *(unsigned char *)(8 + BIOS);
hd_info[drive].lzone = *(unsigned short *)(12 + BIOS);
hd_info[drive].sect = *(unsigned char *)(14 + BIOS); // 每磁道扇区数
BIOS += 16;
}
if (hd_info[1].cyl) // 判断有几个硬盘
NR_HD = 2;
else
NR_HD = 1;
#endif
// 一个物理硬盘最多可以分4个逻辑盘,0是物理盘,1~4是逻辑盘,共5个,第1个物理盘是0*5,第2个物理盘是1*5
for (i = 0; i < NR_HD; i++)
{
hd[i * 5].start_sect = 0;
hd[i * 5].nr_sects = hd_info[i].head *
hd_info[i].sect * hd_info[i].cyl;
}
if ((cmos_disks = CMOS_READ(0x12)) & 0xf0)
if (cmos_disks & 0x0f)
NR_HD = 2;
else
NR_HD = 1;
else
NR_HD = 0;
for (i = NR_HD; i < 2; i++)
{
hd[i * 5].start_sect = 0;
hd[i * 5].nr_sects = 0;
}
// 第1个物理盘设备号是0x300,第2个是0x305,读每个物理硬盘的0号块,即引导块,有分区信息
for (drive = 0; drive < NR_HD; drive++)
{
if (!(bh = bread(0x300 + drive * 5, 0)))
{ //
printk("Unable to read partition table of drive %d\n\r",
drive);
panic("");
}
…
}
这里大概看看知道是做什么的就行了。主要是最后几行,遍历硬盘,使用bread
函数读取硬盘设备号为0x300 + drive * 5
的第0块,也就是引导块(一块硬盘就一个一个引导块)。等读取成功后,会进一步解析引导块中的分区表信息,如果读取失败,会触发panic
。
注意,这段代码上电之后只会执行一次。
读取硬盘的引导块到缓冲区
那个时代的Linux0.11中,硬盘最基础的信息就是分区表,其它的信息都可以从这个信息引导出来,这个信息所在的块就是引导块。一块硬盘只有一个一个引导块,就是硬盘的0号逻辑块。引导块有两个扇区,真正有用的是第一个扇区。杨立祥老师的书中假设计算机只有一块硬盘。
下面具体介绍如何把硬盘的引导块读入缓冲区,以便后续程序解读引导块中的内容。印度引导块这个操作是由bread()
函数做的,bread
即block read
读一个块。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 代码路径:fs/buffer.c:
struct buffer_head *bread(int dev, int block) // 读指定dev、block,第一块硬盘的dev是0x300,block是0
{
struct buffer_head *bh;
if (!(bh = getblk(dev, block))) // 在缓冲区得到与dev、block相符合或空闲的缓冲块
panic("bread: getblk returned NULL\n"); // 现在第一次使用缓冲区,不可能没有空闲块
if (bh->b_uptodate) // 现在是第一次使用,找到的肯定是未被使用过的
return bh;
ll_rw_block(READ, bh); // 调用低级读写函数 ll_rw_block,从设备上读取块数据到缓冲区。
wait_on_buffer(bh); // 该函数会阻塞当前进程,直到缓冲块的 I/O 操作完成。
if (bh->b_uptodate) // 如果为真,表示磁盘数据已经成功加载到缓冲区,返回 bh。
return bh;
brelse(bh); // 调用 brelse 将该缓冲块标记为空闲,供其他操作使用。
return NULL;
}
进入bread
函数执行后,首先调用getblk
函数,在缓冲区中申请一个空闲的缓冲块。在getblk
函数中,先调用get_hash_table()
函数查找哈希表,检索此前是否有程序把现在要读的硬盘逻辑块(相同的设备号和块号)已经读到了缓冲区, 如果已经读到了缓冲区,那就不用再从硬盘上读取了,直接用现成的的就可以,否则就要分配一个空闲缓冲块。
如果发现缓冲块已经包含有效数据,那么直接返回,无需进行磁盘读写。否则调用ll_rw_block
函数将磁盘数据读取到缓冲区,这个函数实际上是向设备发出IO请求,要求设备将指定块的数据加载到缓冲区。由于内存和磁盘需要交互,耗时很长,因此这里使用wait_on_buffer
函数来等待IO操作完成,这个函数会使当前进程主动放弃对CPU的占有,也就是阻塞当前进程,直到缓存块的IO操作完成。
完成IO操作之后,再次检查b_uptodate
标志,如果为真表示磁盘数据确实已经成功加载到缓冲区,返回bh。否则释放缓冲块并返回NULL。
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
// 代码路径:fs/buffer.c:
…
//在缓冲区得到与dev、block相符合或空闲的缓冲块。dev:0x300、block:0
struct buffer_head * getblk(int dev, int block)
{
struct buffer_head *tmp, *bh;
repeat:
if (bh = get_hash_table(dev, block))
return bh;
tmp = free_list;
do
{
if (tmp->b_count)
continue;
if (!bh || BADNESS(tmp) < BADNESS(bh))
{
bh = tmp;
if (!BADNESS(tmp))
break;
}
…
}
//代码路径:fs/buffer.c:
…
//查找哈希表,确定缓冲区中是否有指定dev、block的缓冲块。dev:0x300、block:0
struct buffer_head * get_hash_table(int dev, int block)
{
struct buffer_head * bh;
for (;;) {
if (!(bh=find_buffer(dev,block)))
return NULL; //现在是第一次使用,肯定没有已经读到缓冲区的块
bh->b_count++;
wait_on_buffer(bh);
if (bh->b_dev== dev && bh->b_blocknr== block)
return bh;
bh->b_count--;
}
}
//代码路径:fs/buffer.c:
…
//NR_HASH是307,对于dev:0x300、block:0而言,_hashfn(dev,block)的值是154
#define _hashfn(dev,block) (((unsigned)(dev^block))%NR_HASH)
#define hash(dev,block) hash_table[_hashfn(dev,block)]
…
//在缓冲区查找指定dev、block的缓冲块
static struct buffer_head * find_buffer(int dev, int block)
{
struct buffer_head * tmp;
for(tmp= hash(dev,block);tmp!= NULL;tmp= tmp->b_next)//现在tmp->b_next为NULL
if (tmp->b_dev==dev && tmp->b_blocknr==block)
return tmp;
return NULL;
}
这里的函数一个调一个,麻烦的很,解释起来十分费力。
getblk
函数的核心目的是获取一个缓冲块,它会优先从缓冲区中查找是否已经存在这样的缓冲块,这里是调用get_hash_table
函数实现的。如果已经存在了这样的缓冲块就直接返回它即可。如果不存在这样的缓冲块,就从缓冲区空闲列表中分配一个空闲块并返回,具体来说,就是遍历free_list
链表,选择一个合适的缓冲块并返回。(目前不要陷入代码细节)
get_hash_table
函数用于在缓冲区的哈希表中寻找指定的设备号和块号的缓冲块。其中,它调用find_buffer
函数来在hash_table
中寻找目标快,这个函数深究也是没什么意义。这里是第一次寻找,第一次请求一个缓存块,因此返回的肯定是NULL。返回到get_hash_table
函数中,由于返回的是NULL所以get_hash_table
也会直接返回NULL,这里如果寻找到了已经存在的缓存块,那么会将该缓冲块的引用次数加一,防止其它进程释放掉该缓存块。如果此时缓冲块正在进行IO,那么需要等待IO完成,之后再次验证得到的缓冲块是不是目标缓冲块,是的话就返回。
这里当然get_hash_table
返回给getblk
函数的是NULL,因此接下来getblk
需要在空闲表中申请一个新的空闲缓冲块。当前,所有的空闲缓冲块都是存储在空闲链表中,因此接下来的任务就是在空闲表中申请新的缓冲块。
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
// 代码路径:fs/buffer.c:
// dirt表示缓冲块被修改,需要写回,lock表示缓冲块正在进行IO操作
#define BADNESS(bh) (((bh)->b_dirt << 1) + (bh)->b_lock) // 现在b_dirt、b_lock是0,
// BADNESS(bh)就是00
struct buffer_head *getblk(int dev, int block)
{
struct buffer_head *tmp, *bh;
repeat:
if (bh = get_hash_table(dev, block))
return bh;
tmp = free_list;
do
{
if (tmp->b_count) // tmp-> b_count现在为0
continue;
if (!bh || BADNESS(tmp) < BADNESS(bh)) // bh现在为0
bh = tmp;
if (!BADNESS(tmp)) // 现在BADNESS(tmp)是00,取得空闲的缓冲块!
break;
/* and repeat until we find something good */
} while ((tmp = tmp->b_next_free) != free_list);
if (!bh)
{ // 现在不会出现没有获得空闲缓冲块的情况
sleep_on(&buffer_wait);
goto repeat;
}
wait_on_buffer(bh); // 缓冲块没有加锁
if (bh->b_count) // 现在还没有使用缓冲块
goto repeat;
while (bh->b_dirt)
{ // 缓冲块的内容没有被修改
sync_dev(bh->b_dev);
wait_on_buffer(bh);
if (bh->b_count)
goto repeat;
}
if (find_buffer(dev, block)) // 现在虽然获得了空闲缓冲块,但并没有挂接到hash表中
goto repeat;
bh->b_count = 1; // 占用
bh->b_dirt = 0;
bh->b_uptodate = 0;
remove_from_queues(bh);
bh->b_dev = dev;
bh->b_blocknr = block;
insert_into_queues(bh);
return bh;
}
这里有个#define BADNESS(bh) (((bh)->b_dirt << 1) + (bh)->b_lock)
,其中dirt
表示的是这个缓冲块被修改了,需要执行写回操作,lock
表示的是缓冲块正在进行IO操作。因此BADNESS
表示的就是“坏”程度,如果dirt
和lock
都是0那么坏程度就是0,表示最适合被分配,而这个值越大就表示越不应该被分配。
这里我们已经从get_hash_table
返回了,下面执行的就是分配一个空闲的缓冲块。
首先遍历空闲链表,b_count
表示的是缓冲块被使用的次数,如果不为0表示缓冲块正在使用,直接跳过,直到找到一个未被使用的缓冲块,然后如果是第一次找到,直接将其赋值给bh
,否则就和bh
比较其“坏程度”, 选择“坏程度”最低的缓冲块。当然,如果找到了一个坏程度为0的缓冲块就直接返回表示找到了,否则选择的就是坏程度最低的缓冲块。
如果实在没有空闲块,那么就进入休眠等待空闲的缓冲块,这里使用sleep_on
函数主动进入休眠状态,唤醒后重试整个流程。
wait_on_buffer
是在等待缓冲块解锁(结束IO操作),这是保险起见,当然了当前根本就没有缓冲块处于加锁状态。
然后再次查看缓冲块的b_count
状态,如果缓冲块在等待期间被其它进程占用,需要重新查找一个空闲缓冲块。
之后需要处理脏标志位,如果此时脏标志位b_dirt
为1,表示缓冲块被修改待写回,那么主动调用sync_dev
操作将设备上的脏缓冲块同步到磁盘,在这期间使用wait_on_buffer
等待同步完成,然后再次检查b_count
位是否为0,如果不是就返回。
然后还需要再次调用find_buffer
函数来查看是否已经存在一个相同设备号,如果存在的话也是重新查找(返回)。
然后,就可以占用这个缓冲块了,将b_count
置一,表示当前进程占用此缓冲块,然后将其从空闲链表中移除,设置其设备号和块号,将其插入到哈希表中然后返回这个缓冲块。之后这个getblk
函数的任务就完成了,返回到bread
函数中继续执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//代码路径:fs/buffer.c:
struct buffer_head * bread(int dev,int block)
{
struct buffer_head * bh;
if (!(bh=getblk(dev,block)))
panic("bread: getblk returned NULL\n");
if (bh->b_uptodate) //新申请的缓冲区肯定没有更新过
return bh;
ll_rw_block(READ,bh);
wait_on_buffer(bh);
if (bh->b_uptodate)
return bh;
brelse(bh);
return NULL;
}
返回bread
函数之后就进入ll_rw_block
函数,它的功能是将指定的磁盘数据读到缓冲区,实际上是将高层对设备的读写操作请求传递到底层设备驱动程序。
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
//代码路径:kernel/blk_dev/ll_rw_block.c:
void ll_rw_block(int rw, struct buffer_head * bh)
{
unsigned int major;
if((major=MAJOR(bh->b_dev))>=NR_BLK_DEV|| //NR_BLK_DEV是7,主设备号0-6,>=7意味着不存在
!(blk_dev[major].request_fn)) {
printk("Trying to read nonexistent block-device\n\r");
return;
}
make_request(major,rw,bh);
}
// 代码路径:kernel/blk_dev/ll_rw_block.c:
static inline void lock_buffer(struct buffer_head *bh)
{
cli();
while (bh->b_lock) // 现在还没加锁,不会sleep
sleep_on(&bh->b_wait);
bh->b_lock = 1; // 加锁
sti();
}
…
static void make_request(int major, int rw, struct buffer_head *bh) //
{
struct request *req;
int rw_ahead;
/* WRITEA/READA is special case - it is not really needed, so if the */
/* buffer is locked, we just forget about it, else it's a normal read */
if (rw_ahead = (rw == READA || rw == WRITEA))
{
if (bh->b_lock) // 现在还没有加锁
return;
if (rw == READA) // 放弃预读写,改为普通读写
rw = READ;
else
rw = WRITE;
}
if (rw != READ && rw != WRITE)
panic("Bad block dev command, must be R/W/RA/WA");
lock_buffer(bh); // 加锁
// 要求:写请求时缓冲块未被修改,读请求时缓冲块已经是最新状态
if ((rw == WRITE && !bh->b_dirt) || (rw == READ && bh->b_uptodate))
{ // 现在还没有使用
unlock_buffer(bh);
return;
}
repeat:
/* we don't allow the write-requests to fill up the queue completely:
* we want some room for reads: they take precedence. The last third
* of the requests are only for reads.
*/
if (rw == READ) // 读从尾端开始,写从2/3处开始
req = request + NR_REQUEST;
else
req = request + ((NR_REQUEST * 2) / 3);
/* find an empty request */
while (--req >= request) // 从后向前搜索空闲请求项,在blk_dev_init中,dev初始化为-1,即空闲
if (req->dev < 0) // 找到空闲请求项
break;
/* if none found, sleep on new requests: check for rw_ahead */
if (req < request)
{
if (rw_ahead)
{
unlock_buffer(bh);
return;
}
sleep_on(&wait_for_request);
goto repeat;
}
/* fill up the request-info, and add it to the queue */
req->dev = bh->b_dev; // 设置请求项
req->cmd = rw;
req->errors = 0;
req->sector = bh->b_blocknr << 1;
req->nr_sectors = 2;
req->buffer = bh->b_data;
req->waiting = NULL;
req->bh = bh;
req->next = NULL;
add_request(major + blk_dev, req);
}
ll_rw_block
函数首先判断缓冲块对应的设备是否存在或者这个设备的请求项函数是否挂接正常,如果存在且正常,说明可以操作这个缓冲块,就调用make_request
函数,准备将缓冲块与请求项建立关系。
具体来说,MAJOR(bh->b_dev)
用于提取块设备的主设备号,检查主设备号是否超出范NR_BLK_DEV
或该主设备号没有请求处理函数, 如果设备不存在或未初始化,打印警告信息并退出。否则调用make_request
函数封装并加入请求项。
下面解析make_request
函数。
进入make_request
之后,首先将这个缓冲块加锁,表示的是缓冲块正在进行IO操作,保证这个期间其它进程不能操作此缓冲块。
之后,在请求项结构中,获取一个空闲请求项,准备与这个缓冲块相挂接。如果是读请求,则从整个请求项结构的最末端开始寻找空闲请求项,如果是写请求则从整个结构的2/3处申请空闲请求项。这是因为用户更希望读取的数据能够更快地显现出来,因此给读操作更大的空间。这个时候是请求项第一次被使用,而且是读请求,因此在请求项结构的最末端找一个空闲的请求项,之后将缓冲块与请求项正式挂接,并对这个请求项各个成员进行初始化。
调用add_request
函数,向请求项队列中加载该请求项。进入add_request
之后,首先对当前硬盘的工作情况进行分析,然后设置该请求项为当前请求项,并调用硬盘请求项处理函数(dev->request_fn)()
,也就是do_hd_request
函数(之前设置过),去给硬盘发送读盘命令。
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
// 代码路径:kernel/blk_dev/ll_rw_block.c :
static void add_request(struct blk_dev_struct *dev,
struct request *req)
{
struct request *tmp;
req->next = NULL;
cli();
if (req->bh)
req->bh->b_dirt = 0;
if (!(tmp = dev->current_request))
{
dev->current_request = req;
sti();
(dev->request_fn)(); // do_hd_request()
return;
}
for (; tmp->next; tmp = tmp->next) // 电梯算法的作用是让磁盘磁头的移动距离最小
if ((IN_ORDER(tmp, req) ||
!IN_ORDER(tmp, tmp->next)) &&
IN_ORDER(req, tmp->next))
break;
req->next = tmp->next; // 挂接请求项队列
tmp->next = req;
sti();
}
读取硬盘
现在需要进入do_hd_request
函数去执行,为读硬盘做最后的准备工作。
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 代码路径:kernel/blk_dev/hd.c:
void do_hd_request(void)
{
int i, r;
unsigned int block, dev;
unsigned int sec, head, cyl;
unsigned int nsect;
INIT_REQUEST;
dev = MINOR(CURRENT->dev);
block = CURRENT->sector;
if (dev >= 5 * NR_HD || block + 2 > hd[dev].nr_sects)
{
end_request(0);
goto repeat;
}
block += hd[dev].start_sect;
dev /= 5;
__asm__("divl %4" : "=a"(block), "=d"(sec) : "0"(block), "1"(0),
"r"(hd_info[dev].sect));
__asm__("divl %4" : "=a"(cyl), "=d"(head) : "0"(block), "1"(0),
"r"(hd_info[dev].head));
sec++;
nsect = CURRENT->nr_sectors;
if (reset)
{
reset = 0; // 置位,防止多次执行if (reset)
recalibrate = 1; // 置位,确保执行下面的if(recalibrate)
reset_hd(CURRENT_DEV); // 将通过调用hd_out向硬盘发送WIN_SPECIFY命令,建立硬盘
// 读盘必要的参数
return;
}
if (recalibrate)
{
recalibrate = 0; // 置位,防止多次执行if (recalibrate)
hd_out(dev, hd_info[CURRENT_DEV].sect, 0, 0, 0,
WIN_RESTORE, &recal_intr); // 将向硬盘发送WIN_RESTORE命令,将磁头移动到
// 0柱面,以便从硬盘上读取数据
return;
}
if (CURRENT->cmd == WRITE)
{
hd_out(dev, nsect, sec, head, cyl, WIN_WRITE, &write_intr);
for (i = 0; i < 3000 && !(r = inb_p(HD_STATUS) & DRQ_STAT); i++)
/* nothing */;
if (!r)
{
bad_rw_intr();
goto repeat;
}
port_write(HD_DATA, CURRENT->buffer, 256);
}
else if (CURRENT->cmd == READ)
{
hd_out(dev, nsect, sec, head, cyl, WIN_READ, &read_intr); // 注意这两个参数
}
else
panic("unknown hd-command");
}
// 代码路径:kernel/blk_dev/hd.c:
static void hd_out(unsigned int drive, unsigned int nsect, unsigned int sect,
unsigned int head, unsigned int cyl, unsigned int cmd,
void (*intr_addr)(void)) // 对比调用的传参WIN_READ,& read_intr
{
register int port asm("dx");
if (drive > 1 || head > 15)
panic("Trying to write bad sector");
if (!controller_ready())
panic("HD controller not ready");
do_hd = intr_addr; // 根据调用的实参决定是read_intr还是write_intr,现在是read_intr
outb_p(hd_info[drive].ctl, HD_CMD);
port = HD_DATA;
outb_p(hd_info[drive].wpcom >> 2, ++port);
outb_p(nsect, ++port);
outb_p(sect, ++port);
outb_p(cyl, ++port);
outb_p(cyl >> 8, ++port);
outb_p(0xA0 | (drive << 4) | head, ++port);
outb(cmd, ++port);
}
// 代码路径:kernel/system_call.s:
_hd_interrupt:
...
1 : jmp 1f
1 : xorl %edx, %edx
xchgl _do_hd, %edx
testl %edx, %edx
jne 1f movl $_unexpected_hd_interrupt, %edx
...
这段代码大多数设备相关,细节太多不看也罢,只关心核心的处理流程即可。
先通过对当前请求项数据成员的分析,解析出需要操作的磁头、扇区、柱面、操作多少个扇区……之后,建立硬盘读盘必要的参数,将磁头移动到0柱面.之后,针对命令的性质(读/写)给硬盘发送操作命令。现在是读操作(读硬盘的引导块),所以接下来要调用hd_out
()函数来下达最后的硬盘操作指令。注意看最后两个实参,WIN_READ
表示接下来要进行读操作,read_intr()
是读盘操作对应的中断服务程序,所以要提取它的函数地址,准备挂接。请注意,这是通过hd_out()
函数实现的,读盘请求就挂接read_intr()
;如果是写盘,那就不是read_intr()
,而是write_intr()
了。
其中,do_hd = intr_addr
这一行是把读盘服务程序与硬盘中断操作程序相挂接,这里面的do_hd
是system_call.s
中_hd_interrupt
下面xchgl _do_hd,%edx
这一行所描述的内容。现在要做读盘操作,所以挂接的就是实参read_intr
,如果是写盘,挂接的就应该是write_intr()
函数。
注意,中断处理程序并不能接收参数,因此read_intr
是通过全局变量CURRENT
来获取硬盘数据需要写入的位置的。
下达读盘命令!硬盘开始将引导块中的数据不断读入它的缓存中,同时,程序也返回了,将会沿着前面调用的反方向,即hd_out()
函数、do_hd_request()
函数、add_request()
函数、make_request()
函数、ll_rw_block()
函数,一直返回bread()
函数中。
现在,硬盘正在继续读引导块。如果程序继续执行,则需要对引导块中的数据进行操作。但这些数据还没有从硬盘中读完,所以调用wait_on_buffer()
函数,挂起等待!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//代码路径:fs/buffer.c:
struct buffer_head *bread(int dev,int block)
{
struct buffer_head * bh;
if (!(bh=getblk(dev,block)))
panic("bread: getblk returned NULL\n");
if (bh->b_uptodate)
return bh;
ll_rw_block(READ,bh);
wait_on_buffer(bh); // 将等待缓冲块解锁的进程挂起
if (bh->b_uptodate)
return bh;
brelse(bh);
return NULL;
}
进入wait_on_buffer()
函数后,判断刚才申请到的缓冲块是否被加锁。现在,缓冲块确实加锁了,调用sleep_on()
函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//代码路径:fs/buffer.c:
static inline void wait_on_buffer(struct buffer_head * bh)
{
cli();
while (bh->b_lock) //前面已经加锁
sleep_on(&bh->b_wait);
sti();
}
//代码路径:kernel/sched.c:
void sleep_on(struct task_struct **p)
{
struct task_struct *tmp;
if (!p)
return;
if (current== &(init_task.task))
panic("task[0] trying to sleep");
tmp= *p;
*p= current;
current->state= TASK_UNINTERRUPTIBLE;
schedule();
if (tmp)
tmp->state=0;
}
进入sleep_on()
函数后,将进程1设置为不可中断等待状态,进程1挂起;然后调用schedule()
函数,准备进程切换。
等待硬盘数据时,切换到进程0去执行
调用schedule
函数之后,由于系统内仅有进程0和进程1,因此会由进程1切换到进程0运行。
当然了,这里有点不同,实际上进程0又承担了Linux0.11系统“idletask”的角色,当系统内其它进程都处于非就绪态的时候,就调度进程0来运行,当然了进程0运行会一直执行pause
程序。
这个时候的CPU还没有低功耗指令,进程0的运行不会降低功耗。
进程0执行过程发生硬盘中断
循环执行了一段时间后,硬盘在某一时刻把一个扇区的数据读出来了,产生硬盘中断。CPU接到中断指令后,终止正在执行的程序,转而去执行中断服务程序。
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
//代码路径:kernel/system_call.s:
…
_hd_interrupt:
pushl %eax //保存CPU的状态
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
movl $0x17,%eax
mov %ax,%fs
movb $0x20,%al
outb %al,$0xA0
jmp 1f
1: jmp 1f
1: xorl %edx,%edx
xchgl _do_hd,%edx
testl %edx,%edx
jne 1f
movl $_unexpected_hd_interrupt,%edx
1: outb %al,$0x20
call *%edx
…
这里执行调用_do_hd
处的读盘中断服务程序,实际上就是read_intr
函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//代码路径:kernel/blk_dev/hd.c:
static void read_intr(void)
{
if (win_result()) {
bad_rw_intr();
do_hd_request();
return;
}
port_read(HD_DATA,CURRENT->buffer,256);
CURRENT->errors= 0;
CURRENT->buffer += 512;
CURRENT->sector++;
if (--CURRENT->nr_sectors) {
do_hd= &read_intr;
return;
}
nd_request(1);
do_hd_request();
}
read_intr()
函数会将已经读到硬盘缓存中的数据复制到刚才被锁定的那个缓冲块中(注意:锁定是阻止进程方面的操作,而不是阻止外设方面的操作),这时1个扇区256字(512字节)的数据读入前面申请到的缓冲块。
但是,引导块的数据是1024字节,请求项要求的也是1024字节,现在仅读出了一半,硬盘会继续读盘。与此同时,在得知请求项对应的缓冲块数据没有读完的情况下,内核将再次把read_intr()
绑定在硬盘中断服务程序上,以待下次使用,之后中断服务程序返回。
进程1仍处在被挂起状态,pause()
、sys_pause()
、schedule()
、switch_to(0)
循环从刚才硬盘中断打断的地方继续循环,硬盘继续读盘。
又过了一段时间后,硬盘剩下的那一半数据也读完了,硬盘产生中断,读盘中断服务程序再次响应这个中断,进入read_intr( )函数后,仍然会判断请求项对应的缓冲块的数据是否读完了,对应代码如下:
1
2
3
4
5
6
7
8
9
//代码路径:kernel/blk_dev/hd.c:
static void read_intr(void)
{
…
if (--CURRENT->nr_sectors)
…
end_request(1);
…
}
这次已经将请求项要求的数据量全部读完了,经检验确认完成后,不执行if里面的内容了,跳到end_request()
函数去执行。
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
//代码路径:kernel/blk_dev/blk.h:
extern inline void end_request(int uptodate)
{
DEVICE_OFF(CURRENT->dev);
if (CURRENT->bh) {
CURRENT->bh->b_uptodate= uptodate; // uptodate是参数,为1
unlock_buffer(CURRENT->bh);
}
if (!uptodate) {
printk(DEVICE_NAME " I/O error\n\r");
printk("dev %04x, block %d\n\r",CURRENT->dev,
CURRENT->bh->b_blocknr);
}
wake_up(&CURRENT->waiting);
wake_up(&wait_for_request);
CURRENT->dev= -1;
CURRENT= CURRENT->next;
}
//代码路径:kernel/blk_dev/blk.h:
extern inline void unlock_buffer(struct buffer_head * bh)
{
if (!bh->b_lock)
printk(DEVICE_NAME ": free buffer being unlocked\n");
bh->b_lock=0;
wake_up(&bh->b_wait);
}
//代码路径:kernel/sched.c:
void wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=0; //设置为就绪态
*p=NULL;
}
}
进入end_request()
后,由于此时缓冲块的内容已经全部读进来了,将这个缓冲块的更新标志b_uptodate
置1,说明它可用了。之后,调用unlock_buffer()
函数为缓冲块解锁。在unlock_buffer()
函数中调用wake_up()
函数,将等待这个缓冲块解锁的进程(进程1)唤醒(设置为就绪态),并对刚刚使用过的请求项进行处理,如将它对应的请求项设置为空闲。
硬盘中断处理结束,也就是载入硬盘引导块的工作结束后,计算机在pause()
、sys_pause()
、schedule()
、switch_to(0)
循环中继续执行,当再次执行到schedule
函数时,由于此时进程1处于就绪态,因此会调度进程1进行运行。
切换回进程1后,进程1会从switch_to
函数的ljmp
指令的下一条指令开始执行,所有的进程在恢复执行后都是从这里开始执行的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//代码路径:fs/buffer.c:
struct buffer_head * bread(int dev,int block)
{
struct buffer_head * bh;
if (!(bh=getblk(dev,block)))
panic("bread: getblk returned NULL\n");
if (bh->b_uptodate)
return bh;
ll_rw_block(READ,bh);
wait_on_buffer(bh);
if (bh->b_uptodate)
return bh;
brelse(bh);
return NULL;
}
接着就会回到bread
函数继续执行,判断b_uptodate
是否为1,如果是直接返回,否则释放掉获取的缓冲块。这里显然是1,因此会返回到sys_setup
函数执行。
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
// 代码路径:kernel/blk_dev/hd.c:
int sys_setup(void *BIOS)
{
…
for (drive = 0; drive < NR_HD; drive++)
{
if (!(bh = bread(0x300 + drive * 5, 0)))
{
printk("Unable to read partition table of drive %d\n\r",
drive);
panic("");
}
if (bh->b_data[510] != 0x55 || (unsigned char) // 我们假设引导块的数据没问题
bh->b_data[511] != 0xAA)
{
printk("Bad partition table on drive %d\n\r", drive);
panic("");
}
p = 0x1BE + (void *)bh->b_data; // 根据引导块中的分区信息设置hd[]
for (i = 1; i < 5; i++, p++)
{
hd[i + 5 * drive].start_sect = p->start_sect;
hd[i + 5 * drive].nr_sects = p->nr_sects;
}
brelse(bh); // 释放缓冲块(引用计数减1)
}
if (NR_HD)
printk("Partition table%s ok.\n\r", (NR_HD > 1) ? "s" : "");
…
}
回到sys_setup函数继续执行,处理硬盘引导块载入缓冲区后的事务。缓冲块里面装载着硬盘的引导块的内容,先来判断硬盘信息有效标志’55AA’。如果第一个扇区的最后2字节不是’55AA’,就说明这个扇区中的数据是无效的(我们假设引导块的数据没有问题)。
读引导块的缓冲块已经完成使命,调用brelse()
函数释放,以便以后继续程序使用。
进程1格式化虚拟盘并更换根设备为虚拟盘
之前设置了虚拟盘空间并初始化。那时的虚拟盘只是一块“白盘”,尚未经过类似“格式化”的处理,还不能当做一个块设备使用。格式化所用的信息就在boot操作系统的软盘上。之前讲解过,第一个扇区是bootsect,后面4个扇区是setup,接下来的240个扇区是包含head的system模块,一共有245个扇区。“格式化”虚拟盘的信息从256扇区开始。
接下来,调用rd_load
函数,用软盘上256以后的扇区中的信息“格式化”虚拟盘,使之成为一个块设备。
1
2
3
4
5
6
7
8
9
10
//代码路径:kernel/blk_dev/hd.c:
int sys_setup(void * BIOS)
{
…
if (NR_HD)
printk("Partition table%s ok.\n\r",(NR_HD>1)?"s":"");
rd_load();
mount_root();
return (0);
}
进入rd_load()
函数后,调用breada()
函数从软盘预读一些数据块,也就是“格式化”虚拟盘需要的引导块、超级块。
breada()
和bread()
函数类似,不同点在于可以把一些连续的数据块都读进来,一共三块,分别是257、256和258,其中引导块在256(尽管引导块并未实际使用)、超级块在257中。从软盘上读取数据块与bread
读硬盘上的数据块原理基本一致。
之后,分析超级块信息,包括判断文件系统是不是minix文件系统、接下来要载入的根文件系统的数据块数会不会比整个虚拟盘区都大……这些条件都通过,才能继续加载根文件系统。分析完毕,释放缓冲块。
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
// 代码路径:kernel/blk_dev/ramdisk.c:
void rd_load(void)
{
struct buffer_head *bh;
struct super_block s;
int block = 256; /* Start at block 256 */
int I = 1;
int nblocks;
char *cp; /* Move pointer */
if (!rd_length)
return;
printk("Ram disk: %d bytes, starting at 0x%x\n", rd_length,
(int)rd_start);
if (MAJOR(ROOT_DEV) != 2) // 如果根设备不是软盘
return;
bh = breada(ROOT_DEV, block + 1, block, block + 2, -1);
if (!bh)
{
printk("Disk error while looking for ramdisk!\n");
return;
}
*((struct d_super_block *)&s) = *((struct d_super_block *)bh->b_data);
brelse(bh);
if (s.s_magic != SUPER_MAGIC) // 如果不等,说明不是minix文件系统
/* No ram disk image present, assume normal floppy boot */
return;
nblocks = s.s_nzones << s.s_log_zone_size; // 算出虚拟盘的块数
if (nblocks > (rd_length >> BLOCK_SIZE_BITS))
{
printk("Ram disk image too big! (%d blocks, %d avail)\n",
nblocks, rd_length >> BLOCK_SIZE_BITS);
return;
}
printk("Loading %d bytes into ram disk... 0000k",
nblocks << BLOCK_SIZE_BITS);
…
}
接下来调用breada()
函数,把与文件系统相关的内容,从软盘上拷贝到虚拟盘中,然后及时释放缓冲块,最终完成“格式化”这个过程。
复制结束后,将虚拟盘设置为根设备。
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
// 代码路径:kernel/blk_dev/ramdisk.c:
void rd_load(void)
{
…
printk("Loading %d bytes into ram disk... 0000k",
nblocks << BLOCK_SIZE_BITS);
cp = rd_start;
while (nblocks)
{ // 将软盘上准备格式化用的根文件系统复制到虚拟盘上
if (nblocks > 2)
bh = breada(ROOT_DEV, block, block + 1, block + 2, -1);
else
bh = bread(ROOT_DEV, block);
if (!bh)
{
printk("I/O error on block %d, aborting load\n",
block);
return;
}
(void)memcpy(cp, bh->b_data, BLOCK_SIZE);
brelse(bh);
printk("\010\010\010\010\010%4dk", i);
cp += BLOCK_SIZE;
block++;
nblocks--;
i++;
}
printk("\010\010\010\010\010done \n");
ROOT_DEV = 0x0101; // 设置虚拟盘为根设备
}
这里只需要知道,把软盘的文件系统拷贝到了虚拟盘,然后把虚拟盘设置为了根文件系统。
进程1在根设备上加载根文件系统
操作系统中加载根文件系统涉及文件、文件系统、根文件系统、加载文件系统、加载根文件系统这几个概念。为了更容易理解,这里我们只讨论块设备,也就是软盘、硬盘、虚拟盘。
操作系统中的文件系统可以大致分成两部分;一部分在操作系统内核中,另一部分在硬盘、软盘、虚拟盘中。文件系统是用来管理文件的。
文件系统用i节点来管理文件,一个i节点管理一个文件,i节点和文件一一对应。文件的路径在操作系统中由目录文件中的目录项管理,一个目录项对应一级路径,目录文件也是文件,也由i节点管理。一个文件挂在一个目录文件的目录项上,这个目录文件根据实际路径的不同,又可能挂在另一个目录文件的目录项上。一个目录文件有多个目录项,可以形成不同的路径。参考现代Linux的目录结构,虽然文字比较抽象,但是实际很容易理解。
另外,一个文件系统必须挂接在另一个文件系统上,按照这个设计,一定存在一个只被其他文件系统挂接的文件系统,这个文件系统就叫根文件系统,根文件系统所在的设备就叫根设备。
别的文件系统可以挂在根文件系统上,根文件系统挂在哪呢?挂在super_block[]
上。
Linux 0.11操作系统中只有一个super_block[]
(这个数组大小是8),每个数组元素是一个超级块,一个超级块管理一个逻辑设备,也就是说操作系统最多只能管理8个逻辑设备,其中只有一个根设备。加载根文件系统最重要的标志就是把根文件系统的根i节点挂在super_block[]
中根设备对应的超级块上。
可以说,加载根文件系统有三个主要步骤:
复制根设备的超级块到
super_block[]
中,将根设备中的根i节点挂在super_block[]
中对应根设备的超级块上。将驻留缓冲区中16个缓冲块的根设备逻辑块位图、i节点位图分别挂接在
super_block[]
中根设备超级块的s_zmap[]
、s_imap[8
]`上。将当前进程的pwd、root指针指向根设备的根i节点。
进程1通过调用mount_root()
函数实现在根设备虚拟盘上加载根文件系统。执行代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 代码路径:kernel/blk_dev/hd.c:
int sys_setup(void *BIOS)
{
…
brelse(bh);
}
…
if (NR_HD)
printk("Partition table%s ok.\n\r", (NR_HD > 1) ? "s" : "");
rd_load();
mount_root(); // 加载根文件系统
return (0);
}
复制根设备的超级块到super_block
中
进入mount_root()
函数后,初始化内存中的超级块super_block[8]
,将每一项所对应的设备号加锁标志和等待它解锁的进程全部设置为0。
系统只要想和任何一个设备以文件的形式进行数据交互,就要将这个设备的超级块存储在super_block[8]
中,这样可以通过super_block[8]
获取这个设备中文件系统的最基本信息,根设备中的超级块也不例外。
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
// 代码路径:fs/super.c:
void mount_root(void)
{
int i, free;
struct super_block *p;
struct m_inode *mi;
if (32 != sizeof(struct d_inode))
panic("bad i-node size");
for (i = 0; i < NR_FILE; i++) // 初始化file_table[64],为后续程序做准备
file_table[i].f_count = 0;
if (MAJOR(ROOT_DEV) == 2)
{ // 2代表软盘,此时根设备是虚拟盘,是1。反之,没有虚拟盘,
// 则加载软盘的根文件系统
printk("Insert root floppy and press ENTER");
wait_for_keypress();
}
// 初始化super_block[8]
for (p = &super_block[0]; p < &super_block[NR_SUPER]; p++)
{
p->s_dev = 0;
p->s_lock = 0;
p->s_wait = NULL;
}
if (!(p = read_super(ROOT_DEV)))
panic("Unable to mount root");
…
}
前面的rd_load()
函数已经“格式化”好虚拟盘,并设置为根设备。接下来调用read_super()
函数,从虚拟盘中读取根设备的超级块,复制到super_block[8]
中
1
2
3
4
5
6
7
8
//代码路径:fs/super.c:
void mount_root(void)
{
…
if (!(p=read_super(ROOT_DEV)))
panic("Unable to mount root");
…
}
在read_super()
函数中,先检测这个超级块是不是已经被读进super_block[8]
中了。如果已经被读进来了,则直接使用,不需要再加载一次了。这与之前将的bread
中先通过哈希表来检测缓冲块是否已经存在的道理是一样的。
1
2
3
4
5
6
7
8
9
10
11
12
13
//代码路径:fs/super.c:
static struct super_block * read_super(int dev)
{
struct super_block * s;
struct buffer_head * bh;
int i,block;
if (!dev)
return NULL;
check_disk_change(dev); //检查是否换过盘,并做相应处理
if (s= get_super(dev))
return s;
…
}
因为此前没有加载过根文件系统,所以要在super_block[8]
中申请一项。此时找到的是super_block[8]
结构中的第一项。然后进行初始化并加锁,准备把根设备的超级块读出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//代码路径:fs/super.c:
static struct super_block * read_super(int dev)
{
…
for (s= 0 + super_block ;; s++) {
if (s >= NR_SUPER + super_block) // NR_SUPER是8
return NULL;
if (!s->s_dev)
break;
}
s->s_dev= dev;
s->s_isup= NULL;
s->s_imount= NULL;
s->s_time= 0;
s->s_rd_only= 0;
s->s_dirt= 0;
lock_super(s); //锁定超级块
…
}
调用bread()
函数,把根文件系统的超级块从虚拟盘上读进缓冲区,并从缓冲区复制到super_block[8]
的第一项。如果给硬盘发送操作命令,则调用do_hd_request()函数,而此时操作的是虚拟盘,所以要调用do_rd_request()
函数。值得注意的是,虚拟盘虽然被视为外设,但它毕竟是内存里面一段空间,并不是实际的外设,所以,调用do_rd_request()
函数从虚拟盘上读取超级块,不会发生类似硬盘中断的情况。超级块复制进缓冲块以后,将缓冲块中的超级块数据复制到super_block[8]
的第一项。从现在起,虚拟盘这个根设备就由super_block[8]
的第一项来管理,之后调用brelse()
函数释放这个缓冲块。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 代码路径:fs/super.c:
static struct super_block *read_super(int dev)
{
…
if (!(bh = bread(dev, 1))) // 块号 1 是 Minix 文件系统中超级块的默认位置
{ // 读根设备的超级块到缓冲区
s->s_dev = 0;
free_super(s); // 释放超级块
return NULL;
}
*((struct d_super_block *)s) = // 将缓冲区中的超级块复制到
*((struct d_super_block *)bh->b_data); // super_block[8]第一项
brelse(bh); // 释放缓冲块
if (s->s_magic != SUPER_MAGIC)
{ // 判断超级块的魔数(SUPER_MAGIC)是否正确
s->s_dev = 0;
free_super(s); // 释放超级块
return NULL;
}
…
}
需要注意的是:struct d_super_block
是磁盘上超级块的格式; struct super_block
是内存中的超级块格式。因为之前是直接读的软盘,因此强制转换一下。
接着,初始化super_block[8]
中的虚拟盘超级块中的i节点位图s_imap
、逻辑块位图s_zmap
,并把虚拟盘上i节点位图、逻辑块位图所占用的所有逻辑块读到缓冲区,将这些缓冲块分别挂接到s_imap[8]
和s_zmap[8]
上。由于对它们的操作会比较频繁,所以这些占用的缓冲块并不被释放,它们将常驻在缓冲区内。
s_imap
是i节点位图的缓冲区数组,每个元素指向一个缓冲区,存储虚拟盘中i节点位图的数据。
s_zmap
是逻辑块位图的缓冲区数组,存储虚拟盘中逻辑块位图的数据
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
// 代码路径:fs/super.c:
static struct super_block *read_super(int dev)
{
…
for (i = 0; i < I_MAP_SLOTS; i++) // 初始化s_imap[8]、s_zmap[8]
s->s_imap[i] = NULL;
for (i = 0; i < Z_MAP_SLOTS; i++)
s->s_zmap[i] = NULL;
block = 2; // 虚拟盘的第一块是超级块,第二块开始是i节点位图和逻辑块位图
for (i = 0; i < s->s_imap_blocks; i++) // 把虚拟盘上i节点位图所占用的所有逻辑块
if (s->s_imap[i] = bread(dev, block)) // 读到缓冲区,分别挂接到s_imap[8]上
block++;
else
break;
for (i = 0; i < s->s_zmap_blocks; i++) // 把虚拟盘上逻辑块位图所占用的所有逻辑块
if (s->s_zmap[i] = bread(dev, block)) // 读到缓冲区,分别挂接到s_zmap[8]上
block++;
else
break;
// 校验读进来的块数
if (block != 2 + s->s_imap_blocks + s->s_zmap_blocks)
{ // 如果i节点位图、逻辑块位
for (i = 0; i < I_MAP_SLOTS; i++) // 图所占用的块数不对,说明操作系统有问题,则应释放前
brelse(s->s_imap[i]); // 面获得的缓冲块及超级块
for (i = 0; i < Z_MAP_SLOTS; i++)
brelse(s->s_zmap[i]);
s->s_dev = 0;
free_super(s);
return NULL;
}
s->s_imap[0]->b_data[0] |= 1; // 牺牲一个i节点,以防止查找算法返回0
s->s_zmap[0]->b_data[0] |= 1; //与0号i节点混淆
free_super(s);
return s;
}
将根设备中的根i节点挂在super_block[8]中根设备超级块上
回到mount_root()
函数中,调用iget()
函数,从虚拟盘上读取根i节点。根i节点的意义在于,通过它可以到文件系统中任何指定的i节点,也就是能找到任何指定的文件。
1
2
3
4
5
6
7
8
9
10
//代码路径:fs/super.c:
void mount_root(void)
{
…
if (!(p=read_super(ROOT_DEV)))
panic("Unable to mount root");
if (!(mi=iget(ROOT_DEV,ROOT_INO)))
panic("Unable to read root i-node");
…
}
进入iget()
函数后,操作系统从i节点表inode_table[32]
中申请一个空闲的i节点位置(inode_table[32]
是操作系统用来控制同时打开不同文件的最大数)。此时应该是首个i节点。对这个i节点进行初始化设置,其中包括该i节点对应的设备号、该i节点的节点号。
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
// 代码路径:fs/inode.c:
struct m_inode *iget(int dev, int nr)
{
struct m_inode *inode, *empty;
if (!dev)
panic("iget with dev==0");
empty = get_empty_inode(); // 从inode_table[32]中申请一个空闲的i节点
inode = inode_table;
while (inode < NR_INODE + inode_table)
{ // 查找与参数相同的inode
if (inode->i_dev != dev || inode->i_num != nr)
{
inode++;
continue;
}
wait_on_inode(inode); // 等待解锁
if (inode->i_dev != dev || inode->i_num != nr)
{ // 如等待期间发生
inode = inode_table; // 变化,继续查找
continue;
}
inode->i_count++;
if (inode->i_mount)
{
int i;
for (i = 0; i < NR_SUPER; i++) // 如是mount点,则查找对应的超级块
if (super_block[i].s_imount == inode)
break;
if (i >= NR_SUPER)
{
printk("Mounted inode hasn't got sb\n");
if (empty)
iput(empty);
return inode;
}
iput(inode);
dev = super_block[i].s_dev; // 从超级块中获取设备号
nr = ROOT_INO; // ROOT_INO为1,根i节点号
inode = inode_table;
continue;
}
if (empty)
iput(empty);
return inode;
}
if (!empty)
return (NULL);
inode = empty;
inode->i_dev = dev; // 初始化
inode->i_num = nr;
read_inode(inode); // 从虚拟盘上读出根i节点
return inode;
}
在read_inode()
函数中,先给inode_table[32]
中的这个i节点加锁。在解锁之前,这个i节点就不会被别的程序占用。之后,通过该i节点所在的超级块,间接地计算出i节点所在的逻辑块号,并将i节点所在的逻辑块整体读出,从中提取这个i节点的信息,载入刚才加锁的i节点位置上。最后,释放缓冲块并将锁定的i节点解锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 代码路径:fs/inode.c:
static void read_inode(struct m_inode *inode)
{
…
lock_inode(inode); // 锁定inode
if (!(sb = get_super(inode->i_dev))) // 获得inode所在设备的超级块
…
block = 2 + sb->s_imap_blocks + sb->s_zmap_blocks +
(inode->i_num - 1) / INODES_PER_BLOCK;
if (!(bh = bread(inode->i_dev, block))) // 读inode所在逻辑块进缓冲块
panic("unable to read i-node block");
*(struct d_inode *)inode = // 整体复制
((struct d_inode *)bh->b_data)
[(inode->i_num - 1) % INODES_PER_BLOCK];
brelse(bh); // 释放缓冲块
unlock_inode(inode); // 解锁
}
回到iget()
函数,将inode
指针返回给mount_root()
函数,并赋给mi
指针。
下面是加载根文件系统的标志性动作:
将inode_table[32]
中代表虚拟盘根i节点的项挂接到super_block[8]
中代表根设备虚拟盘的项中的s_isup
、s_imount
指针上。这样,操作系统在根设备上可以通过这里建立的关系,一步步地把文件找到。
将根文件系统与进程1关联
对进程1的tast_struct
中与文件系统i节点有关的字段进行设置,将根i节点与当前进程(现在就是进程1)关联起来。
1
2
3
4
5
6
7
8
9
10
11
12
// 代码路径:fs/super.c:
void mount_root(void)
{
…
if (!(mi = iget(ROOT_DEV, ROOT_INO))) // 根设备的根i节点
panic("Unable to read root i-node");
mi->i_count += 3; /* NOTE! it is logically used 4 times, not 1 */
p->s_isup = p->s_imount = mi; // 标志性的一步!
current->pwd = mi; // 当前进程(进程1)掌控根文件系统的根i节点,
current->root = mi; // 父子进程创建机制将这个特性遗传给子进程
…
}
得到了根文件系统的超级块,就可以根据超级块中“逻辑块位图”里记载的信息,计算出虚拟盘上数据块的占用与空闲情况,并将此信息记录在之前提到的驻留在缓冲区中“装载逻辑块位图信息的缓冲块”中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 代码路径:fs/super.c:
void mount_root(void)
{
…
free = 0;
i = p->s_nzones;
while (--i >= 0) // 计算虚拟盘中空闲逻辑块的总数
if (!set_bit(i & 8191, p->s_zmap[i >> 13]->b_data))
free++;
printk("%d/%d free blocks\n\r", free, p->s_nzones);
free = 0;
i = p->s_ninodes + 1;
while (--i >= 0) // 计算虚拟盘中空闲的i节点的总数
if (!set_bit(i & 8191, p->s_imap[i >> 13]->b_data))
free++;
printk("%d/%d free inodes\n\r", free, p->s_ninodes);
}
到此为止,sys_setup()
函数就全都执行完毕了。因为这个函数也是由于产生软中断才被调用的,所以返回system_call
中执行,之后会执行ret_from_sys_call
。这时候的当前进程是进程1,所以下面将调用do_signal()
函数(只要当前进程不是进程0,就要执行到这里),对当前进程的信号位图进行检测,执行代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//代码路径:kernel/system_call.s:
…
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 #调用do_signal()
…
现在,当前进程(进程1)并没有接收到信号,调用do_signal()
函数并没有实际的意义。至此,sys_setup()
的系统调用结束,进程1将返回3.3节中讲到的代码的调用点,准备下面代码的执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
//代码路径:init/main.c:
void init(void)
{
…
int pid,i;
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
printf("%d buffers= %d bytes buffer space\n\r",NR_BUFFERS,
NR_BUFFERS*BLOCK_SIZE);
…
}
至此,进程0创建进程1,进程1为安装硬盘文件系统做准备、“格式化”虚拟盘并用虚拟盘取代软盘为根设备、在虚拟盘上加载根文件系统的内容讲解完毕。
详细分析Linux操作系统如何设置保护模式的中断机制。
答案:首先setup程序废除了BIOS建立的16位的中断机制,并对配置8259A,重新设置了中断响应序列。然后,head在保护模式下根据自身携带的信息建立了IDT,将其中断处理程序都指向了
ignore_int
,并将IDTR指向IDT基地址。最后,内核重新设置了各个IDT的中断服务程序并打开了中断响应。
分析Linux操作系统如何剥夺用户进程访问内核及其他进程的能力。
答案:1. 基于段页式内存管理的进程隔离机制,Linux通过分段、分页机制使得每个进程都运行在独立的虚拟内存空间中,进程无法直接访问其它进程、内核的内存区域。2. 基于特权级机制的用户态与内核态隔离,进程默认运行在用户态无法使用特权级指令,只能通过系统调用访问处于特权级的内核,而系统调用会严格检查权限和参数。
进程0创建进程1时,为进程1建立了task_struct及内核栈,第一个页表,分别位于物理内存两个页。请问,这两个页的位置,究竟占用的是谁的线性地址空间,内核、进程0、进程1、还是没有占用任何线性地址空间?说明理由(可以图示)并给出代码证据。
答案:占用的都是内核的线性地址空间。因为创建新进程时调用
fork
系统调用会陷入内核态,此时使用的代码段、数据段是内核的代码段和数据段,当申请新的页面时,遍历用于管理内存空间的mem_map
也存在与内核空间中,它是内核管理整个物理地址的映射表,管理的也是内核的16MB线性地址(内核线性地址等于物理地址),因此每一次申请新的页也都是从16MB向低地址寻址空闲页的。
经过一段时间的运行,操作系统中已经有5个进程在运行,且内核为进程4、进程5分别创建了第一个页表,这两个页表在谁的线性地址空间?用图表示这两个页表在线性地址空间和物理地址空间的映射关系。
答案:都在内核的线性地址空间,每个进程占64MB的线性地址空间,每个页目录表可映射4MB的线性地址空间,因此每个进程会占用16个页目录表,进程4的页目录表在4*16=64开始16个页目录表项,而进程5则占用80开始的16个表项,因为只分配了一个页表,因此只有其对应的第一个页目录表项指向了当前页表。内核的线性地址和物理地址是等价的。
进程0开始创建进程1,调用fork(),跟踪代码时我们发现,fork代码执行了两次,第一次,执行fork代码后,跳过init()直接执行了for(;;) pause(),第二次执行fork代码后,执行了init()。奇怪的是,我们在代码中并没有看到向转向fork的goto语句,也没有看到循环语句,是什么原因导致fork反复执行?请说明理由(可以图示),并给出代码证据。
答案:这是因此进程0调用
fork
创建了子进程进程1,进程1继承了进程0的代码段、数据段、上下文等信息,因此进程0执行完会从fork
返回,其返回值为进程1的进程号,因此不会执行init
函数,而进程1的上下文中的eax
被特殊设计为0,即其第一次恢复运行后,从fork
函数int 0x80
的下一条指令开始执行,而此fork
的返回值为0,表示其子进程的身份,因此会被条件判断为真,去执行init
函数。因为父进程子进程都从fork
返回,看起来像执行了两次fork
函数。
详细分析进程调度的全过程。考虑所有可能(signal、alarm除外)
答案:进程调度的过程主要在
schedule
函数中进行,第一次遍历task[64]
结构,对其中有效任务的signal/alarm进行分析设置,这里不考虑。第二次遍历task[64]
寻找其中处于就绪态且counter
值最大的进程作为下一个调度运行的进程,执行switch_to
函数切换到此进程,如果存在就绪进程但时间片为0,则对每个进程使用其priority
重新设置时间片,然后再次寻找处于就绪态且counter
最大的进程作为下一个进程执行调度。如果此时系统内没有处于就绪态的进程,那就强行将进程0指定为下一个进程使用switch_to
调度运行。switch_to
里面使用了一个ljmp
长跳转后接一个TSS的地址,触发X86的任务切换机制,根据目标TSS恢复目标进程的上下文,实现任务切换。
分析panic函数的源代码,根据你学过的操作系统知识,完整、准确的判断panic函数所起的作用。假如操作系统设计为支持内核进程(始终运行在0特权级的进程),你将如何改进panic函数?
答案:作用:系统遇到重大故障无法继续运行时调用它,显示错误信息,它会导致程序终止。如果出现错误的进程不是进程0,那么就进行数据同步,把缓冲区的数据尽量同步到硬盘上。改进:将panic函数中的死循环改为跳转到内核进程(始终运行在特权级0的进程),让内核继续运行。
getblk函数中,申请空闲缓冲块的标准就是b_count为0,而申请到之后,为什么在wait_on_buffer(bh)后又执行if(bh->b_count)来判断b_count是否为0?
答案:因为
wait_on_buffer
函数可能会导致当前进程睡眠等待缓冲块解锁,这期间其它进程可能会占用此缓冲块,因此需要再次判断,如果此缓冲块被其他进程占用则需要重新寻找一个空闲缓冲块。
b_dirt已经被置为1的缓冲块,同步前能够被进程继续读、写?给出代码证据。
答案:同步前可以被进程继续读写,但不能挪为他用。
b_dirt
是针对硬盘方向的,当进程对缓冲块进行写操作后b_dirt
就会被置1表示其内容被更改等待同步,而缓冲块与硬盘的同步是异步的,不会阻止缓冲块的继续使用,对缓冲块写数据后继续对其读写数据也不会造成同步问题,只要最后写回就行了。而b_uptodate
才是针对缓冲块和进程方向的,只要b_uptodate
置一,就表示它可以被进程读写。
函数
wait_on_buffer
是通过缓冲块的b_lock
来判断是否需要阻塞进程的,而不是b_dirt
。
wait_on_buffer函数中为什么不用if()而是用while()?
答案:可能有多个进程等待同一个缓冲块,当前等待缓冲块的过程中会调度其它进程运行,缓冲块同步完成后其他进程也可能会占用此缓冲块,如果使用if,那么可能会导致一个缓冲块被多个进程占用,发生错误,而使用while重复确认缓冲块的可使用性则可以避免这个问题。
分析ll_rw_block(READ,bh)读硬盘块数据到缓冲区的整个流程(包括借助中断形成的类递归),叙述这些代码实现的功能。
答案:
ll_rw_block
的功能是将指定的磁盘上数据读到缓冲区,实际上是将高层对设备的读写操作请求传递到底层设备的驱动程序,读磁盘是异步的,CPU不会在此等待。ll_rw_block
函数首先判断缓冲块对应的设备是否存在或者找个设备的请求项函数是否正常挂接,如果正常,则调用make_request
函数,将缓冲块与请求项建立关系。make_request
函数会获取一个空闲请求项,将其与缓冲块挂接,然后调用add_request
函数将请求项加入请求项队列,将其设置为当前请求项,然后调用硬盘请求项处理函数,也就是do_hd_request
函数给硬盘发送读写命令,这个函数会根据读/写命令来将具体的读盘处理函数或者写盘处理函数与do_hd挂接,这里是读盘操作因此挂接do_hd
,然后下达读盘命令,此时系统会调用其它进程运行避免系统阻塞在此。当读完一个扇区内容的时候,会触发中断进入read_intr
函数,read_intr
函数会判断是否已经读完指定内容,如果没有读完会下达继续读的命令,然后系统继续调用系统进程运行,如此反复直到读操作完成。
分析包括安装根文件系统、安装文件系统、打开文件、读文件在内的文件操作
安装根文件系统:1. 复制根设备的超级块到
super_block[8]
,将根设备的根i节点挂接在super_block[8]
中对应根设备的超级块上。2. 将驻留缓冲区中16个缓冲块的根设备逻辑块位图、i节点位图分别挂接在super_block[]中根设备超级块的s_zmap[]、 s_imap[8]`上。3. 将当前进程的pwd、root指针指向根设备的根i节点。
安装文件系统:1. 将硬盘上的超级块读取出来,并载入系统中的super_block[8]中。2. 将虚拟盘上指定的i节点读出,并将此i节点加载到系统中的inode_table[32]中。3. 将硬盘上的超级块挂接到inode_table[32]中指定的i节点上。
打开文件:1. 将用户进程
task_struct
中的*filp[20]与内核中的file_table[64]挂接。 2. 以用户给定的路径名为线索找到指定文件的i节点。 3. 将这个i节点在file_table[64]中对应的项上登记。
读文件: 根据之前打开文件返回的文件句柄调用read函数。1. 确定数据块在外设中的位置(inode节点信息) 2. 使用bread将数据库读入缓冲区 3. 将缓冲区中的数据复制到进程空间。
在创建进程、从硬盘加载程序、执行这个程序的过程中,sys_fork、do_execve、do_no_page分别起了什么作用?
答案:
do_fork
用于创建一个新的进程(子进程)。do_execve
用于加载并执行新的程序,它将原先作为子进程的的新进程的页表,上下文等信息设置为新的内容。由于系统每次只分配一个页,因此缺页时触发缺页异常,do_no_page
用于处理这种异常,为进程配分一个新页,确保所需的内存页面被正确加载。