Linux操作系统问题
一、从开机加电到执行main函数之前的过程
1,为什么计算机启动最开始的时候执行的是BIOS代码而不是操作系统自身的代码?
计算机的运行是离不开程序的。然而,加电的一瞬间,计算机的内存中,准确地说是RAM中,空空如也,什么程序也没有。软盘里虽然有操作系统程序,但CPU的逻辑电路被设计为只能运行内存中的程序,没有能力直接从软盘运行操作系统。如果要运行软盘中的操作系统,必须将软盘中的操作系统程序加载到内存中。BIOS的作用就是把操作系统加载到内存中。
2,为什么BIOS只加载了一个区,后续扇区却是由bootsect代码加载?为什么BIOS没有把所有需要加载的扇区都加载?
对BIOS而言,“约定”在接到启动操作系统的命令后,“定位识别”只从启动扇区把代码加载到0x7c00这个位置。后续扇区则由bootsect代码加载,这些代码由编写系统的用户负责,与BIOS无关。这样构建的好处是站在整个体系的高度,统一设计和统一安排,简单而有效。BIOS和操作系统的开发都可以遵循这一约定,灵活地进行各自的设计。操作系统的开发也可以按照自己的意愿,内存的规划,等等都更为灵活
3,为什么BIOS把bootsect加载到0x07c00,而不是0x00000?加载后又马.上挪到0x90000处,是何道理?为什么不一次加载到位?
1)因为BIOS将从0x00000开始的1KB字节构建了了中断向量表,接着的256KB字节内存空间构建了BIOS数据区,所以不能把bootsect加载到0x00000. 0X07c00是BIOS设置的内存地址,不是bootsect能够决定的。
2)首先,在启动扇区中有一些数据,将会被内核利用到。
其次,依据系统对内存的规划,内核终会占用0x0000其实的空间,因此0x7c00可能会被覆盖。将该扇区挪到0x90000,在setup.s中,获取一些硬件数据保存在0x90000~0x901ff处,可以对一些后面内核将要利用的数据,集中保存和管理。
4, bootsect、setup、 head程序之 间是怎么衔接的?给出代码证据。
bootsect首先利用int0x13中断分别加载setup程序及system模块,待bootsect程序的任务完成之后,执行
jmpi 0,SETUPSEG
由于bootsect将setup段加载到了SETUPSEG:0的地方,在实模式下,该指令跳转到setup段的第一条指令。
setup执行了之后,将位于0x10000的内核程序复制到内存地址起始位置0x00000处,并加载了中断描述符表和全局描述符表。
lidt idt_48
lgdt gdt_48
系统进入了保护模式,在保护模式下,一个重要的特征就是根据GDT决定后续执行哪里的程序。开启保护模式后,执行下句从setup程序跳转到head程序
jmpi 0,8 // 8看成二进制的1000
根据保护模式的机制,该指令执行后跳转到以GDT第2项中的base_addr为基地址,以0为偏移量的地方,其中base_addr为0。由于head放置在内核的头部,因此程序跳转到head中执行。
5, setup程序里的cli是为了什么?
cli是关中断指令。因为此时需要由16位实模式向32位保护模式转变,即将进行实模式下的中断向量表和保护模式下中断描述符表的交接工作,在保护模式的中断机制尚未完成时不允许响应中断,以免发生未知的错误。&oq=cli是关中断指令。因为此时需要由16位实模式向32位保护模式转变,即将进行实模式下的中断向量表和保护模式下中断描述符表的交接工作,在保护模式的中断机制尚未完成时不允许响应中断,以免发生未知的错误。
6, setup程序的最后是jmpi 0,8为什么这个8不能简单的当作阿拉伯数字8看待?
这里8要看成二进制1000,最后两位00表示内核特权级,第三位0表示GDT表,第四位1表示根据GDT中的第2项来确定代码段的段基址和段限长等信息。这样,我们可以得到代码是从段基址0x00000000、偏移为0处开始执行的,即head的开始位置。注意到已经开启了保护模式的机制,这里的8是保护模式下的段选择符,而不能当成简单的阿拉伯数字8来看待。
7,打开A20和打开pe究竟是什么关系,保护模式不就是32位的吗?为什么还要打开A20?有必要吗?
有必要。A20是cpu的第21位地址线,A20未打开的时候,实模式下的最大寻址为1MB+64KB而第21根地址线被强制为0,所以相当于cpu“回滚”到内存地址起始处寻址。打开A20仅仅意味着CPU可以进行32位寻址,且最大寻址空间是4GB,而打开PE是使能保护模式。打开A20是打开PE的必要条件;而打开A20不一定非得打开PE。打开PE是说明系统处于保护模式下,如果不打开A20的话,可以访问的内存只能是奇数1M段,若要真正在保护模式下工作,必须打开A20,实现32位寻址。
8,在setup程序里曾经设置过一次gdt, 为什么在head程序中将其废弃,又重新设置了一 个?为什么折腾两次,而不是一 次搞好?
原来GDT所在的位置是设计代码时在setups里面设置的数据,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,将来GDT的内容肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存中唯一安全的地方就是现在heads所在的位置了。
不能在执行setup程序时直接把GDT的内容复制到heads所在的位置。因为如果先复制GDT的内容,后移动system模块,它就会被后者覆盖:如果先移动system模块,后复制GDT的内容,它又会把heads对应的程序覆盖,而这时heads还没有执行。所以,无论如何,都要重新建立GDT。
9, Linux是用C语言写的,为什么没有从main开始,而是先运行3个汇编程序,道理何在?
通常用C语言编写的程序都是用户应用程序,这类程序的执行必须在操作系统上执行,也就是说要由操作系统为应用程序创建进程,并把应用程序的可执行代码从硬盘加载到内存。
而在计算机刚刚加电时,内存中没有操作系统程序,只有BIOS程序在运行,需要借助BIOS分别加载bootsect、setup及system模块,然后利用这3个程序来完成内存规划、建立IDT和GDT、设置分页机制等等,并实现从开机时的16位实模式到main函数执行需要的32位保护模式之间的转换。
当计算机处在32位的保护模式状态下时,调用main的条件才算准备完毕。
10,为什么不用call, 而是用ret调用"main函数?画出调用路线图,给出代码证据。P42图
CALL指令会将EIP的值自动压栈,保护返回现场,然后执行被调函数,档执行到被调函数的ret指令时,自动出栈给EIP并还原现场,继续执行CALL的下一行指令。在由head程序向main函数跳转时,是不需要main函数返回的;同时由于main函数已经是最底层的函数了,没有更底层的支撑函数支持其返回。所以要达到既调用main又不需返回,就不采用call而是选择了ret“调用”了。
after page tables:
pushl $0
pushl SO
pushl $0
pushl $L6
pushl $_main
jmp setup_paging
setup_paging:
…
ret
下面两题参考IA-32-3中文版.pdf
11, 保护模式的“保护”体现在哪里?
打开了保护模式后,CPU的寻址模式发生了变化,需要依赖于GDT去获取代码或数据段的基址。从GDT可以看出,保护模式除了段基址外,还有段限长,这样相当于增加了一个段位寄存器。既有效地防止了对代码或数据段的覆盖,又防止了代码段自身的访问超限,明显增强了保护作用。
同时,保护模式中特权级的引入对于操作系统内核提供了强有力的保护。Intel从硬件上禁止低特权级代码段使用一些关键性指令,还提供了机会允许操作系统设计者通过一些特权级的设置,禁止用户进程使用cli、sti等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问GDT、LDT、TR,而GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址,而用户进程只能使用逻辑地址。
12, 特权级的目的和意义是什么?为什么特权级是基于段的?
特权级是操作系统为了更好地管理内存空间及其访问控制而设的,提高了系统的安全性。
保护模式中特权级的引入对于操作系统内核提供了强有力的保护。Intel从硬件上禁止低特权级代码段使用一些关键性指令,还提供了机会允许操作系统设计者通过一些特权级的设置,禁止用户进程使用cli、sti等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问GDT、LDT、TR,而GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址,而用户进程只能使用逻辑地址。
在操作系统设计中,一般一个段实现的功能相对完整,可以把代码放在一个段,数据放在一个段,并通过段选择符(包括CS、SS、DS、ES、FS和GS)获取段的基址和特权级等信息。特权级基于段,这样当段选择子具有不匹配的特权级时,按照特权级规则判断是否可以访问。特权级基于段,是结合了程序的特点和硬件实现的一种考虑。
二、设备环境初始化及**进程0
1、进程0的task_struct、内核栈、用户栈在哪?证明进程0的用户栈就是未**进程0时的0特权栈,即user_stack,而进程0的内核栈并不是user_stack,给出代码证据。P29 user_stack
进程0的task_struct是操作系统设计者事先写好的,位于内核数据区,存储在user_stack中。(因为在进程0未**之前,使用的是boot阶段的user_stack。)
static union task_union init_task={INIT_TASK,};//P65
...
struct task_struct * task[NR_TASKS] = {&(init_task.task), };
具体内容如下:包含进程0的进程状态、进程0的LDT、进程0的TSS等等。其中ldt设置了代码段和堆栈段的基址和限长(640KB),而TSS则保存了各种寄存器的值,包括各个段选择符。代码如下:P68
\linux0.11\include\linux\sched.h
#define INIT_TASK \
/* state etc */ { 0,15,15, \
/* signals */ 0,{{},},0, \
/* ec,brk... */ 0,0,0,0,0,0, \
/* pid etc.. */ 0,-1,0,0,0, \
/* uid etc */ 0,0,0,0,0,0, \
/* alarm */ 0,0,0,0,0,0, \
/* math */ 0, \
/* fs info */ -1,0022,NULL,NULL,NULL,0, \
/* filp */ {NULL,}, \
{ \
{0,0}, \
/* ldt */ {0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
/*tss*/ {0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
0,0,0,0,0,0,0,0, \
0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
_LDT(0),0x80000000, \
{} \
}, \
}
2、在system.h里
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))
#define set_intr_gate(n,addr) \
_set_gate(&idt[n],14,0,addr)
#define set_trap_gate(n,addr) \
_set_gate(&idt[n],15,0,addr)
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
这里中断门、陷阱门、系统调用都是通过_set_gate设置的,用的是同一个嵌入汇编代码,比较明显的差别是dpl一个是3,另外两个是0,这是为什么?说明理由。
dpl表示的是特权级,0和3分别表示0特权级和3特权级。异常处理是由内核来完成,Linux处于对内核的保护,不允许用户进程直接访问内核。但是有些情况下,用户进程又需要内核代码的支持,因此就需要系统调用,它是用户进程与内核打交道的接口,是由用户进程直接调用的,因此其在3特权级下。
3、进程0 fork进程1之前,为什么先要调用move_to_user_mode()?用的是什么方法?解释其中的道理。
因为在Linux-011中,除进程0之外,所有进程都是由一个已有进程在用户态下完成创+建的,但是此时进程0还处于内核态,因此要调用move_to_user_mode()函数,模仿终端返回的方式,实现进程0的特权级从内核态转化成用户态,又因为在Linux-0.11中转换特权级时采用中断和中断返回的方式,调用系统中断实现从3到0的特权级转换,中断返回时转换为3特权级。因此,进程0从0特权级到3特权级转换时采用的是模拟中断返回。
4、在IA-32中,有大约20多个指令是只能在0特权级下使用,其他的指令,比如cli,并没有这个约定。奇怪的是,在Linux0.11中,在3特权级的进程代码并不能使用cli指令,会报特权级错误,这是为什么?请解释并给出代码证据。
cli指令用于复位IF标志位,其执行与CPL(当前特权级)和EFLAGS[IOPL]标志位有关。只有当CPL小于或等于IOPL时才可以执行该指令。如果在CPL大于IOPL的情况下执行,将会产生一个一般性保护异常,如下:
set_trap_gate
由于在内核IOPL的初始值为0,且未经改变。进程0在move_to_user_mode中,继承了内核的eflags,如下:
move_to_user_mode()
...
"pushfl\n\t"
...
"iret\n"
在进程0的TSS中,设置了eflags中的IOPL位为0,代码见P68,后续进程如果没有改动的话也是0,即IOPL=0。因此,通过设置IOPL,可以限制3特权级的进程代码使用cli指令。
5、用户进程自己设计一套LDT表,并与GDT挂接,是否可行,为什么?
不可行。首先,用户进程不可以设置GDT 、 LDT ,因为 Linux0.11 将 GDT 、 LDT 这两个数据结构设置在内核数据区,是 0 特权级的,只有 0 特权级的额代码才能修改设置 GDT 、 LDT而且,用户也不可以在自己的数据段按照自己的意愿重新做一套 GDT 、 LDT ,如果仅仅是形式上做一套和 GDT 、 LDT一样的数据结构是可以的,但是真正起作用的 GDT 、 LDT 是CPU 硬件认定的,这两个数据结构的首地址必须挂载在 CPU 中的 GDTR 、 LDTR上,运行时 CPU 只认 GDTR 和 LDTR指向的数据结构,其他数据结构就算起名字叫 GDT 、 LDT.CPU 也一概不认;另外,用户进程也不能将自己制作的 GDT 、 LDT 挂接到 GDRT 、 LDRT上,因为对 GDTR 和 LDTR 的设置只能在 0 特权级别下执行 ,3特权级别下无法把这套结构挂接在 CR3 上。
6、分析初始化IDT、GDT、LDT的代码
IDT初始化:
//代码路径:kernel/traps.c
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);//除零错误
set_trap_gate(1,&debug);//单步调试
set_trap_gate(2,&nmi);//不可屏蔽中断
...
set_trap_gate(16,&coprocessor_error);//协处理器错误
for(i=17;i<48;i++)//都先挂接好,中断服务程序函数名初始化为保留
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13//允许IRQ2中断请求
outb_p(inb(0x21)&0xfb,0xA1);//允许IRQ2中断请求
set_trap_gate(39,¶llel_interrupt);//并口
}
//代码路径:include\asm\system.h:
#define _set_gate(gate_addr,type,dql,addr)
__asm__("movw %%dx,%%ax\n\t"\ //将edx的低字赋值给eax的低字
"movw %0,%%dx\n\t"\ //0对应第二个冒号后的第1行的"i"
"movl %%eax,%1\n\t"\ //1对应第二个冒号后的第2行的"o"
"movl %%edx,%2"\ //2对应第二个冒号后的第3行的"o"
:\) //这个冒号后面是输出,下面冒号后面是输入
: "i"((short)(0x8000 + (dql<<13) + (type<<8))),\ //立即数
"o" (*((char *) (gate_addr))),\ //中断描述符前4个字节的地址
"o" (*(4 + (char *) (gate_addr))),\ //中断描述符后4个字节的地址
"d" ((char *) (addr)),"a" (0x00080000)) //"d”对应edx. "a”对应eax
...
#define set_trap_gate(n,addr)\
_set_gate(&idt[n],15,0,addr)
可以看出,n是0;gate_addr是&idt[0],也就是idt的第一项中断描述符的地址;type是15;dp1(描述符特权级)是0;addr是中断服务程序divide_ error(void)的入口地址,如下所示。图P54
set_trap_gate (0,÷_ error)
set_trap-gate (n,addr)
_set_gate (&idt[n],15,O,addr)
_set_gate (gate_addr,type,dpl,addr)
"movw %%dx,%%ax\n\t"是把edx的低字赋值给eax的低字;edx是(char *) (addr),也就是÷ error;eax的值是0x00080000,这个数据在head.s中就提到过,,8应该看成1000,每一位都有意义,这样eax的值就是0x00080000 + ((char *) (addr)的低字),其中的0x0008是段选择符,含义与第1章中讲解过的"jmpi 0,8"中的8一致。
"movw %0,%%dx\n\t"是把(short)(0x8000+(dpl<< 13)+(type<< 8))赋值给dx。别忘了,edx是(char *)(addr),也就是÷_ error。
因为这部分数据是按位拼接的,必须计算精确,我们耐心详细计算一下:
0x8000就是二进制的 1000 0000 0000 0000;
dpl是00,dpl<< 13就是000 0000 0000 0000;
type是15,type<< 8就是1111 0000 0000;
加起来就是1000 1111 0000 0000,这就是dx的值。edx的计算结果就是(char*)(addr)
的高字即÷_ error的高字+1000 1111 0000 00000
"movl %%eax,%1\n\t"是把eax的值赋给*((char*)(gate_addr)),就是赋给idt[0]的前4字节。同理"movl %%edx,%2"是把edx的值赋给*(4+(char *)(gate_addr)),就是赋给idt[0]的前后4字节。8字节合起来就是完整的idt[0]。
IDT中的第一项除零错误中断描述符初始化完毕,其余异常处理服务程序的中断描述符初始化过程大同小异。后续介绍的所有中断服务程序与IDT的初始化基本上都是以这种方式进行的系统通过函数set_ system-gate将system_call与IDT相挂接,这样进程0就具备了处理系统调用的能力了。
LDT、GDT初始化:将进程0的task_struct中的LDT,TSS与GDT相挂接,并对GDT,task[64]以及与进程调度相关的寄存器进行初始化设置。代码见P65
GDT P32 P20
7、在sched_init(void)函数中有这样的代码:
for(i=1;i<NR_TASKS;i++) {
task[i] = NULL;
……
但并未涉及task[0],从后续代码能感觉到已经给了进程0,请给出代码证据。
\linux0.11\include\linux\sched.h
#define INIT_TASK \
/* state etc */ { 0,15,15, \
/* signals */ 0,{{},},0, \
/* ec,brk... */ 0,0,0,0,0,0, \
/* pid etc.. */ 0,-1,0,0,0, \
/* uid etc */ 0,0,0,0,0,0, \
/* alarm */ 0,0,0,0,0,0, \
/* math */ 0, \
/* fs info */ -1,0022,NULL,NULL,NULL,0, \
/* filp */ {NULL,}, \
{ \
{0,0}, \
/* ldt */ {0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
/*tss*/ {0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
0,0,0,0,0,0,0,0, \
0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
_LDT(0),0x80000000, \
{} \
}, \
}
进程0的task_ struct是由操作系统设计者事先写好的,就是sched.h中的INIT_TASK,并用INIT_ TASK的指针初始化task[64]
的0项。
set_tss_desc(gdt + FIRST TSS ENTRY,&(init_task.task.tss));
set ldt desc(gdt + FIRST LDT ENTRY,&(init task.task.ldt));
这两行代码的目的就是在GDT中初始化进程0所占的4,5两项,即初始化TSS0和LDT0。
ltr(0);//重要将TSS挂接到TR寄存器
lldt (0);//重要将LDT挂接到LDTR寄存器
初始化进程0相关的管理结构的最后一步是非常重要的一步,是将TR寄存器指向TSS0,LDTR寄存器指向LDT0,这样,CPU就能通过TR, LDTR寄存器找到进程0的TSS0,LDT0,也能找到一切和进程0相关的管理信息。
第三章 进程1的创建及执行
1、进程0 fork进程1之前,为什么先调用move_to_user_mode()?用的是什么方法?解释其中的道理。
因为在Linux-0.11中,除进程0之外,所有进程都是由一个已有进程在用户态下完成创建的。但是此时进程0还处于内核态,因此要调用move_to_user_mode()函数,用仿中断的方法将进程0的特权级从内核态转化为用户态,实现**进程0。又因为在Linux-0.11中,转换特权级时采用中断和中断返回的方式,调用系统中断实现从3到0的特权级转换,中断返回时转换为3特权级。因此,进程0从0特权级到3特权级转换时采用的是仿中断返回。
道理P80
2、为什么static inline _syscall0(type,name)中加上关键字inline?
因为_syscall0(int,fork)展开是一个真函数,普通真函数调用事需要将eip入栈,返回时需要讲eip出栈。inline是内联函数,它将标明为inline的函数代码放在符号表中,而此处的fork函数需要调用两次,加上inline后先进行词法分析、语法分析正确后就地展开函数,不需要有普通函数的call\ret等指令,也不需要保持栈的eip,效率很高。若不加上inline,第一次调用fork结束时将eip 出栈,第二次调用返回的eip出栈值将是一个错误值。
3、copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long ss。查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。
copy_process执行时因为进程调用了fork函数,fork是一个系统调用,会导致中断,int 0x80中断导致CPU硬件自动将SS、ESP、EFLAGS、CS、EIP的值按照顺序压入 进程0内核栈,又因为函数专递参数是使用栈的,所以刚好可以做为copy_process的最后五项参数。
4、打开保护模式、分页后,线性地址到物理地址是如何转换的?
在保护模式下,线性地址到物理地址的转化是通过内存分页管理机制实现的。其基本原理是将整个线性和物理内存区域划分为4K大小的内存页面,系统以页为单位进行分配和回收。每个线性地址为32位,MMU按照10-10-12的长度来识别线性地址的值。CR3中存储着页目录表的基址,线性地址的前十位表示也目录表中的页目录项,由此得到所在的页表地址。21~12位记录了页表中的页表项位置,由此得到页的位置,最后12位表示页内偏移。通过线性地址中提供的叶目录项数据就可以找到页目录表中对应的页目录项;通过页目录项找到对应的页表;之后,通过线性地址中提供的页表项数据,就可以在该页表中找到对应的页表项;通过页表项可以找到对应的物理页面;最后通过线性地址中的页内偏移落实到实际的物理地址的值。
5、分析get_free_page()函数的代码,叙述在主内存中获取一个空闲页的技术路线。P89
遍历mem_map[],找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其引用计数置为1。ecx左移12位加LOW_MEM得到该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。
\linux0.11\mm\memory.c
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");
__asm__("std ; repne ; scasb\n\t"
"jne 1f\n\t"
"movb $1,1(%%edi)\n\t"
"sall $12,%%ecx\n\t"
"addl %2,%%ecx\n\t"
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n"
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
return __res;
}
本函数从字节图末端开始向前扫描所有页面标志(页面总数为PAGING_PAGES),若有页面空闲(其内存映像字节为0)则返回页面地址。注意!本函数只是指出在主内存区的一页空闲页面,但并没有映射到某个进程的线性地址去。后面的put_page()函数就是用来作映射的。
6、分析copy_page_tables()函数的代码,叙述父进程如何为子进程复制页表。
进入copy_page_tables函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里的前160个页表项复制到这个页面中(1个页表项控制一个页面4KB内存空间,160个页表项可以控制640KB内存空间)。进程0和进程1的页表暂时度指向了相同的页面,意味着进程1也可以操作进程0的页面。之后对进程1的页目录表进行设置。最后,用重置CR3的方法刷新页面变换高速缓存。进程1的页表和页目录表设置完毕。进程1此时是一个空架子,还没有对应的程序,它的页表又是从进程0的页表复制过来的,它们管理的页面完全一致,也就是它暂时和进程0共享一套页面管理结构。
\linux0.11\mm\memory.c
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);
size = ((unsigned) (size+0x3fffff)) >> 22;
for( ; size-->0 ; from_dir++,to_dir++) {
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
continue;
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;
nr = (from==0)?0xA0:1024;
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2;
*to_page_table = this_page;
if (this_page > LOW_MEM) {
*from_page_table = this_page;
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++;
}
}
}
invalidate();
return 0;
}
7、进程0创建进程1时,为进程1建立了task_struct及内核栈,第一个页表,分别位于物理内存16MB顶端倒数第一页、第二页。请问,这两个页究竟占用的是谁的线性地址空间,内核、进程0、进程1、还是没有占用任何线性地址空间?说明理由(可以图示)并给出代码证据。
将为进程0建立task_struct、内核栈所用的页记为页面1,其页表记为页面2。其中页面1与页面2均占用内核的线性地址空间,原因如下:通过逆向扫描页表位图,并由第一空页的下标左移12位加LOW_MEM得到该页的物理地址,位于16M内存末端。代码如下(get_free_page)
\linux0.11\mm\memory.c
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");
__asm__("std ; repne ; scasb\n\t"
"jne 1f\n\t"
"movb $1,1(%%edi)\n\t"
"sall $12,%%ecx\n\t"
"addl %2,%%ecx\n\t"
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n"
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
return __res;
}
页面1和页面2占用物理页面的地址仅被挂接(填充)在内核的线性空间所对应的页表中。进程0和进程1的LDT的LIMIT属性将进程0和进程1的地址空间限定在0~640KB,所以进程0、进程1均无法访问到这两个页面,故两页面占用内核的线性地址空间。
进程0的局部描述符如下include/linux/sched.h/INIT_TASK
/*ldt*/
{0x9f,0xc0fa00}.\
{0x9f,0xc0f200},\
内核线性地址等于物理地址(0x00000~0xfffff),挂接操作的代码如下:
\linux0.11\boot\head.s
setup_paging:
//…
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
//…
8、根据代码详细分析,进程0如何根据调度第一次切换到进程1的。
- 1.进程0通过fork函数创建进程1,使其处在就绪态。
- 2.进程0调用pause函数。pause函数通过int 0x80中断,映射到sys_pause函数,将自身设为可中断等待状态,调用schedule函数。
- 3.schedule函数分析到当前有必要进行进程调度,第一次遍历进程,只要地址指针不为为空,就要针对处理。第二次遍历所有进程,比较进程的状态和时间片,找出处在就绪态且counter最大的进程,此时只有进程0和1,且进程0是可中断等待状态,只有进程1是就绪态,所以切换到进程1去执行。
9、switch_to(n)代码中的"ljmp %0\n\t" 很奇怪,按理说jmp指令跳转到得位置应该是一条指令的地址,可是这行代码却跳到了"m" (*&__tmp.a),这明明是一个数据的地址,更奇怪的,这行代码竟然能正确执行。请论述其中的道理。
ljmp %0\n\t通过任务门机制并未实际使用任务门,将CPU的各个寄存器值保存在进程0的TSS中,将进程1的TSS数据以LDT的代码段、数据段描述符数据恢复给CPU的各个寄存器,实现从0特权级的内核代码切换到3特权级的进程1代码执行。其中tss.eip也自然恢复给了CPU,此时EIP指向的就是fork中的if(__res >= 0)语句。
10、进程0开始创建进程1,调用fork(),跟踪代码时我们发现,fork代码执行了两次,第一次,执行fork代码后,跳过init()直接执行了for(;???? pause(),第二次执行fork代码后,执行了init()。奇怪的是,我们在代码中并没有看到向转向fork的goto语句,也没有看到循环语句,是什么原因导致fork反复执行?请说明理由(可以图示),并给出代码证据。
\linux0.11\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 = *current; /* NOTE! this doesn't copy the supervisor stack */
//…
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;
//…
return last_pid;
}
首先在copy_process()函数中,设置TSS“p->tss.eip = eip;”指向的是if (__res >= 0); 而“p->tss.eax = 0;”决定main()中if (!fork())后面的分支走向。
\linux0.11\kernel\system_call.s
_system_call:
//…
call _sys_call_table(,%eax,4)
pushl %eax
接着,copy_process()函数返回后,通过“pushl %eax”将函数返回值,也就是进程1的进程号压栈。
\linux0.11\init\main.c
#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; \
}
“: “=a” (__res) \”将eax的值赋值给__res,所以“if (__res >= 0) \”实际上是看此时的eax时多少,由上可知,eax=1。
\linux0.11\kernel\sched.c
void main(void) /* This really IS void, no error here. */
{ /* The startup routine assumes (well, ...) this */
//…
move_to_user_mode();
if (!fork()) { /* we count on this going ok */
init();
}
for(;;) pause();
}
回到if (!fork())处执行,!1为“假”,不会执行init(),直接执行“for(;; ) pause();”。
\linux0.11\kernel\sched.c
int sys_pause(void)
{
current->state = TASK_INTERRUPTIBLE;
schedule();
return 0;
}
\linux0.11\kernel\sched.c
void schedule(void)
{
//…
switch_to(next);
}
由pause()函数进入“schedule();”开始调度,然后通过“switch_to(next);”准备切换进程。
\linux0.11\fs\buffer.c
#define switch_to(n) {\
//…
"ljmp %0\n\t" \
//…
}
执行switch_to()函数中,当程序执行到“ljmp %0\n\t”这行时,ljmp通过CPU任务门机制自动将进程1的TSS值恢复给CPU,自然也将其中的tss.eip恢复给CPU,这时EIP指向fork的if(__res >= 0)这行。而此时的__res值就是进程1中TSS的eax的值,这个值在前面被写死为0,即“p->tss.eax = 0;”所以执行到“return (type)__res;”这行时,返回值为0。返回后,执行到if(!fork())这一行,!0为“真”,调用init()函数!
11、详细分析进程调度的全过程。考虑所有可能(signal、alarm除外)
首先在task数据(进程槽)中,从后往前进行遍历,寻找进程槽中,进程状态为“就緒态”且时间片最大的进程作为下一个要执行的进程。通过调用switch_to()函数跳转到指定进程。在此过程中,如果发现存在状态为“就緒态”的进程,但这些进程都没有时间片了,则会从后往前遍历进程槽为所有进程重新分配时间片(不仅仅是“就緒态”的进程)。然后再重新执行以上步骤,寻找进程槽中,进程状态为“就緒态”且时间片最大的进程作为下一个要执行的进程。如果在遍历的过程中,发现没有进程处于“就绪态”,则会调用switch_to()函数跳转到进程0。
在switch_to函数中,如果要跳转的目标进程就是当前进程,则不发生跳转。否则,保存当前进程信息,长跳转到目标进程。
12、为什么要设计缓冲区,有什么好处?
缓冲区是内存与外设(块设备,如硬盘等)进行数据交互的媒介。内存与外设最大的区别在于:外设(如硬盘)的作用仅仅就是对数据信息以逻辑块的形式进行断电保存,并不参与运算(因为CPU无法到硬盘上进行寻址);而内存除了需要对数据进行保存以外,还要通过与CPU和总线的配合,进行数据运算(有代码和数据之分);缓冲区则介于两者之间,有了缓冲区这个媒介以后,对外设而言,它仅需要考虑与缓冲区进行数据交互是否符合要求,而不需要考虑内存中内核、进程如何使用这些数据;对内存的内核、进程而言,它也仅需要考虑与缓冲区交互的条件是否成熟,而并不需要关心此时外设对缓冲区的交互情况。它们两者的组织、管理和协调将由操作系统统一操作,这样就大大降低了数据处理的维护成本。
缓冲区的好处主要有两点:
① 形成所有块设备数据的统一集散地,操作系统的设计更方便、更灵活; ②对块设备的文件操作运行效率更高。
13、操作系统如何利用buffer_head中的 b_data,b_blocknr,b_dev,b_uptodate,b_dirt,b_count,b_lock,b_wait管理缓冲块的?
- buffer_head负责进程与缓冲块的数据交互,让数据在缓冲区中停留的时间尽可能长。
- b_data是缓冲块的数据内容。
- b_dev和b_blocknr两个字段把缓冲块和硬盘数据块的关系绑定,同时根据b_count 决定是否废除旧缓冲块而新建缓冲块以保证数据在缓冲区停留时间尽量长。
- b_dev为设备标示, b_blo cknr 标示 block 块好。
- b_count用于记录缓冲块被多少个进程共享了。
- b_uptodate和b_dirt 用以保证缓冲块和数据块的正确性。b_uptodate 为 1 说明缓冲块的数据就是数据块中最新的,进程可以共享缓冲块中的数据。 b_dirt 为 1 时说明缓冲块数据已被进程修改,需要同步到硬盘上。b_lock为 1 时说明缓冲块与数据块在同步数据,此时内核会拦截进程对该缓冲块的操作,直到交互结束才置 0 。
- b_wait 用于记录因为 b_lock=1而挂起等待缓冲块的进程数。
14、操作系统如何处理多个进程等待同一个正在与硬盘交互的缓冲块?
操作系统使用了进程等待队列。例如如果为进程申请到的缓冲块中b_block字段被置为1,即便已经申请到了,该进程也需要挂起,直到该缓冲块被解锁后,才能访问。在缓冲块被加锁的过程中,而且无论有多少进程申请到了这个缓冲块,都不能立即操作该缓冲块,都要挂起,通过b_wait加入进程等待队列,sleep_on()函数就会调用schedule()函数去执行别的进程。当进程被唤醒而重新执行时就会执行后续的语句,把比它早进入等待队列的一个进程唤醒。
15、getblk函数中,申请空闲缓冲块的标准就是b_count为0,而申请到之后,为什么在wait_on_buffer(bh)后又执行if(bh->b_count)来判断b_count是否为0?
b_count用来标记“每个缓冲块有多少个进程在共享”。只有当b_count=0时,该缓冲块才能被再次分配。举个可能引发异常例子,每个缓冲块有一个进程等待队列,假设此时B、C两进程在队列中,当该缓冲块被解锁时,进程C被唤醒(它开始使用缓冲区之前需先唤醒进程B,使进程B从挂起进入就绪状态),将缓冲区加锁,一段时间后,进程C又被挂起,但此时缓冲区进程C仍在使用。这时候,进程B被调度,“if (bh->b_count)”该缓冲区任是加锁状态,进程B重新选择缓冲区…如果,不执行该判断将造成进程B操作一个被加锁的缓冲区,引发异常。
16、b_dirt已经被置为1的缓冲块,同步前能够被进程继续读、写?给出代码证据。
同步前能够被进程继续读、写,但不能挪为它用(即关联其它物理块)。b_dirt是针对硬盘方向的,进程与缓冲块方向由b_uptodate标识。只要缓冲块的b_dirt字段被设置为1,就是告诉内核,这个缓冲块中的内容已经被进程的方向数据改写了,最终需要同步到硬盘上。反之,如果为0,就不需要同步。
\linux0.11\fs\file_dev.c
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
//…
if (filp->f_flags & O_APPEND)
pos = inode->i_size;
else
pos = filp->f_pos;
while (i<count) {
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
if (!(bh=bread(inode->i_dev,block)))
break;
//…
}
int file_read(struct m_inode * inode, struct file * filp, char * buf, int count)
{
//…
if ((left=count)<=0)
return 0;
while (left) {
if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
if (!(bh=bread(inode->i_dev,nr)))
break;
}
//…
}
可见,读写文件均与b_dirt无关。
\linux0.11\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;
}
\linux0.11\fs\buffer.c
#define BADNESS(bh) (((bh)->b_dirt<<1)+(bh)->b_lock)
struct buffer_head * getblk(int dev,int block)
{
struct buffer_head * tmp, * bh;
repeat:
if (bh = get_hash_table(dev,block))
return bh;
//…
}
在获取缓冲块时,亦与b_dirt无任何关系。
17、分析panic函数的源代码,根据你学过的操作系统知识,完整、准确的判断panic函数所起的作用。假如操作系统设计为支持内核进程(始终运行在0特权级的进程),你将如何改进panic函数?
该函数用来显示内核中出现的重大错误信息,并运行文件系统同步函数,然后进入死循环——死机。如果当前进程是任务0的话,还说明时交换任务出错,并且还没有运行系统同步函数。关键字volatile用于告诉gcc该函数不会返回,死机。
\linux0.11\kernel\panic.c
volatile void panic(const char * s)
{
printk("Kernel panic: %s\n\r",s);
if (current == task[0])
printk("In swapper task - not syncing\n\r");
else
sys_sync();
for(;;);
}
18、详细分析进程调度的全过程。考虑所有可能(signal、alarm除外)P105
schedule()函数的主要过程为,首先依据task[64]这个结构,第一次遍历所有进程,只要地址指针不为空,就要针对它的signal、alarm分析,这里先不考虑。第二次遍历所有进程,比较进程的状态和时间片,找出处在就绪态且counter最大的进程。如果没有进程符合调度条件,就强制调度进程0.
\linux0.11\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));
(*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);
}
执行switch_to()函数中,ljmp %0\n\t通过任务门机制并未实际使用任务门,将CPU的各个寄存器值保存在进程0的TSS中,将进程1的TSS数据以LDT的代码段、数据段描述符数据恢复给CPU的各个寄存器,实现从0特权级的内核代码切换到3特权级的进程1代码执行。其中tss.eip也自然恢复给了CPU,此时EIP指向的就是fork中的if(__res >= 0)语句。
\linux0.11\fs\buffer.c
#define switch_to(n) {\
//…
"ljmp %0\n\t" \
//…
}
19、wait_on_buffer函数中为什么不用if()而是用while()?
\linux0.11\fs\buffer.c
static inline void wait_on_buffer(struct buffer_head * bh)
{
cli();
while (bh->b_lock)
sleep_on(&bh->b_wait);
sti();
}
从上述源码中可知,一旦缓冲块被加锁,当前请求进程必被挂起在该缓冲块等待队列中,直到在某一时间被重新唤醒。这时候,缓冲块肯定已经被解锁了,但是可能被队列中其他进程又把该缓冲块给占用了。这时候使用while则可以再次判断该缓冲块是否被加锁,如果是,则继续被挂起,循环往复。
20、add_request()函数中有下列代码
if (!(tmp = dev->current_request)) {
dev->current_request = req;
sti();
(dev->request_fn)();
return;
}
其中的
if (!(tmp = dev->current_request)) {
dev->current_request = req;
是什么意思?电梯算法完成后为什么没有执行do_hd_request函数?
查看指定设备是否有当前请求项,即查看设备是否忙。如果指定设备dev当前请求项(dev->current_request ==NULL)为空,则表示目前设备没有请求项,本次是第1个请求项,也是唯一的一个。因此可将块设备当前请求指针直接指向该请求项,并立即执行相应设备的请求函数。
21、getblk()函数中,两次调用wait_on_buffer()函数,两次的意思一样吗?
不一样。第一处,执行到这里,说明我们已经找到了一个比较适合的空闲缓冲块了,于是先等待该缓冲区解锁(如果已被上锁的话)。第二处“wait_on_buffer(bh);”是如果该缓冲区已被修改,则将数据写盘,并再次等待缓冲块解锁。
\linux0.11\fs\buffer.c
#define BADNESS(bh) (((bh)->b_dirt<<1)+(bh)->b_lock)
struct buffer_head * getblk(int dev,int block)
{
//…
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;
}
//…
}
22、getblk()函数中
do {
if (tmp->b_count)
continue;
if (!bh || BADNESS(tmp)<BADNESS(bh)) {
bh = tmp;
if (!BADNESS(tmp))
break;
}
/* and repeat until we find something good */
} while ((tmp = tmp->b_next_free) != free_list);
说明什么情况下执行continue、break。
tmp指向的是空闲链表的第一个空闲缓冲块头“tmp = free_list;”。如果该缓冲块正在被使用,引用计数“tmp->b_count”不等于0,则继续扫描下一项,也就是执行continue。接下来,如果缓冲头指针bh为空,或者tmp所指的缓冲头标志(修改、锁定)权重小于bh头标志的权重,则让bh指向tmp缓冲块头。如果该tmp缓冲块头表明缓冲块既没有修改也没有锁定标志位,则说明已为指定设备上的块取得对应的高速缓冲块,则退出循环,亦即执行break。
23、make_request()函数
if (req < request) {
if (rw_ahead) {
unlock_buffer(bh);
return;
}
sleep_on(&wait_for_request);
goto repeat;
其中的sleep_on(&wait_for_request)是谁在等?等什么?
make_request()函数主要功能为创建请求项并插入请求队列。根据具体读写操作,如果request[32]中没有一项是空闲的,则查看此次请求是不是提前读写,如果是则立即放弃此次请求操作。否则让本次请求先睡眠“sleep_on(&wait_for_request);”以等待request请求队列腾出空闲项,一段时间后再次搜索请求队列。
24、bread()函数代码中
if (bh->b_uptodate)
return bh;
ll_rw_block(READ,bh);
wait_on_buffer(bh);
if (bh->b_uptodate)
return bh;
为什么要做第二次if (bh->b_uptodate)判断?
在高速缓冲区中申请一块缓冲块,如果该缓冲块中数据是有效的(已更新的)可以直接使用,则返回。否则我们就调用底层块设备读写ll_rw_block()函数,产生读设备块请求。然后等待指定数据块被读入,并等待缓冲区解锁。在睡眠醒来之后,如果该缓冲区已更新,则返回缓冲区头指针,退出。否则表明读设备操作失败,于是释放该缓冲区,返回 NULL,退出。
上一篇: 记录自己第一次写的缓存
下一篇: 十一、编写高质量的代码—开源世界(笔记)