1. 为什么计算机启动最开始的时候执行的是BIOS 代码而不是操作系统自身的代
码?
答:通常我们用 C 语言写的用户程序,必须在操作系统的平台上执行,即操作系统为应用程序创建进程并把应用程序的可执行代码加载到内存。计算机启动的时候,操作系统并没有在内存中,我们首先要把操作系统加载到内存,而这个工作最开始的部分,就是由bios 程序来实现的。所以计算机启动最开始执行的是bios 代码
2. 为什么BIOS只加载了一个扇区,后续扇区却是由bootsect代码加载?为什
么BIOS没有把所有需要加载的扇区都加载?
答:对BIOS 而言,“约定”在接到启动操作系统的命令后,“定位识别”只从启动扇区把代码加载到0x7c00 这个位置。后续扇区则由bootsect 代码加载,这些代码由编写系统的用户负责,与BIOS 无关。这样构建的好处是站在整个体系的高度,统一设计和统一安排,简单而有效。BIOS 和操作系统的开发都可以遵循这一约定,
灵活地进行各自的设计。例如,BIOS 可以不用知道内核镜像的大小以及其在软盘的分布等等信息,减轻了BIOS 程序的复杂度,降低了硬件上的开销。而操作系统的开发者也可以按照自己的意愿,内存的规划,等等都更为灵活。另外,如果要使用BIOS 进行加载,而且加载完成之后再执行,则需要很长的时间,因此Linux 采用的是边执行边加载的方法。
3. 为什么BIOS把bootsect加载到0x07c00,而不是0x00000?加载后又马上挪到
0x90000 处,是何道理?为什么不一次加载到位?
答:因为BIOS首先会把中断向量表加载到0x00000-0x003ff的1KB的内存空间,在加载bootsect时约定加载到0x07c00处,符合内存布局,如下。
加载之后挪到0x90000处的原因如下:首先内核会使用启动扇区中的一些数据,如第508、509 字节处的RO OT_DEV ;其次,依据系统对内存的规划,内核占用
0x0000开始的空间,因此0x7c00可能会被覆盖。因为加载到0x07c00是BIOS 约定好的,操作系统只能遵守这个约定。
4. bootsec、setup head程序之间是怎么衔接的?给出代码证据。
答:bootsect首先利用int 0x13中断分别加载setup程序及system模块,待bootsect 程序的任务完成之后,执行jmpi 0,SETUPSEG
由于bootsect 将setup 段加载到了SETUPSEG:0 的地方,在实模式下,该指令跳转到setup段的第一条指令。
setup执行了之后,内核被移到了0x00000处,系统进入了保护模式,并加载了中断描述符表和全局描述符表
lidt idt_48 lgdt gdt_48 在保护模式下,一个重要的特征就是根据GDT 决定后续执行哪里的程序。开启保护模式后,执行
jmpi 0, 8
根据保护模式的机制,该指令执行后跳转到以GDT 第2 项中的base_addr 为基地址,以0为偏移量的地方,其中base_addr为0。由于head放置在内核的头部,因此程序跳转到head中执行
5. setup程序里的cli是为了什么?
答: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. Linux是用C语言写的,为什么没有从main还是开始,而是先运行3个汇编程
序,道理何在?
答:通常用C 语言编写的程序都是用户应用程序,这类程序的执行必须在操作系统上执行,也就是说要由操作系统为应用程序创建进程,并把应用程序的可执行代码从硬盘加载到内存。
而在计算机刚刚加电时,内存中没有操作系统程序,只有BIOS 程序在运行,需要借助BIOS分别加载bootsec、setup及system模块,然后利用这3个程序来完成内存规划、建立IDT和GDT、设置分页机制等等,并实现从开机时的16位实模式到main 函数执行需要的32 位保护模式之间的转换。
当计算机处在32位的保护模式状态下时,调用main的条件才算准备完毕。
9. 为什么不用call,而是用ret “调用” main函数?画出调用路线图,给出代码证
据。
答:CALL 指令会将EIP 的值自动压栈,保护返回现场,然后执行被调函数,档执行到被调函数的ret指令时,自动出栈给EIP并还原现场,继续执行CALL 的下一行指令。在由head程序向main函数跳转时,是不需要main函数返回的;同时由于
main 函数已经是最底层的函数了,没有更底层的支撑函数支持其返回。所以要达到既调用main又不需返回,就不采用call而是选择了ret“调用”了。调用线路图见
P42 图1-46。
代码如下:
af:
pu^hl
pushl $0
pushl $0
pushl 5L6
pushl $_main jir.p sctiip_p3ging
(见P36最下面)
setup_pag ing
ret
10?保护模式的“保护”体现在哪里?
答:打开了保护模式后,CPU的寻址模式发生了变化,需要依赖于GDT去获取代码或数据段的基址。从GDT可以看出,保护模式除了段基址外,还有段限长,这样相当于增加了一个段位寄存器。既有效地防止了对代码或数据段的覆盖,又防止了代码段自身的访问超限,明显增强了保护作用。
同时,保护模式中特权级的引入对于操作系统内核提供了强有力的保护。In tel
从硬件上禁止低特权级代码段使用一些关键性指令,还提供了机会允许操作系统设计者通过一些特权级的设置,禁止用户进程使用cli、sti等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问GDT、LDT、TR,而GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址,而用户进程只能使用逻辑地址。
11. 特权级的目的和意义是什么?为什么特权级是基于段的?
答:特权级是操作系统为了更好地管理内存空间及其访问控制而设的,提高了系
统的安全性。
保护模式中特权级的引入对于操作系统内核提供了强有力的保护。In tel从
硬件上禁止低特权级代码段使用一些关键性指令,还提供了机会允许操作系统设计者通过一些特权级的设置,禁止用户进程使用cli、sti等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设
计成最低特权级。这样,操作系统可以访问GDT、LDT、TR,而GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址,而用户进程只能使用逻辑地址。
在操作系统设计中,一般一个段实现的功能相对完整,可以把代码放在一个段,数据放在一个段,并通过段选择符(包括CS、SS、DS、ES、FS和GS)
获取段的基址和特权级等信息。特权级基于段,这样当段选择子具有不匹配的特权级时,按照特权级规则判断是否可以访问。特权级基于段,是结合了程序的特点和硬件实现的一种考虑。
12. 在setup程序里曾经设置过一次gdt,为什么在head程序中将其废弃,又重新设
置了一个?为什么折腾两次,而不是一次搞好?
答:见P33点评。
13. 在head程序执行结束的时候,在idt的前面有184个字节的head程序的剩余代
码,剩余了什么?为什么要剩余?
答:在idt 前面有184 个字节的剩余代码,包含了after_page_tables、ignore_int 和setup_paging代码段,其中after_page_tables往栈中压入了些参数,ignore_int用做初始化中断时的中断处理函数,setup_paging 则是初始化分页。剩余的原因:after_page_tables中压入了一些参数,为内核进入main函数的跳转做准备。为了谨慎起见,设计者在栈中压入了L6 ,以使得系统可能出错时,返回到L6 处执行。
ignore_int 为中断处理函数,使用ignore_int 将idt 全部初始化,因此如果中断开启后,可能使用了未设置的中断向量,那么将默认跳转到ignore_int 处执行。这样做的好处是使得系统不会跳转到随机的地方执行错误的代码,所以ignore_int 不能被覆盖。
setup_paging 用于分页,在该函数中对0x0000 和0x5000 的进行了初始化操作。该代码需要“剩余”用于跳转到main,即执行” ret ”指令。
14. 进程0的task_struct在哪?具体内容是什么?给出代码证据。
答:进程0的task_struct是操作系统设计者事先写好的,位于内核数据区,存储在user_stack中。(因为在进程0未激活之前,使用的是boot阶段的user_stack ) static union task_union init_task={INIT_TASK};
具体内容如下:
包含了进程0 的进程状态、进程0 的LDT 、进程0 的TSS 等等。其中ldt 设置了代码段和堆栈段的基址和限长(640KB) ,而TSS 则保存了各种寄存器的值,包括各个段选择符。
代码如下:
INIT_TASK 的定义见P68。
15. 进程0创建进程1时,为进程1建立了自己的task_struc、内核栈,第一个页
表,分别位于物理内存16MB 的顶端倒数第一页、第二页。请问,这个了页究竟占用的是谁的线性地址空间,内核、进程0、进程1、还是没有占用任何线性地址空间(直接从物理地址分配)?说明理由并给出代码证据。
答:占用的是内核的线性地址空间。(先理解清楚,稍后补充)
16. 假设:经过一段时间的运行,操作系统中已经有5个进程在运行,且内核分
别为进程4、进程5分别创建了第一个页表,这两个页表在谁的线性地址空间?
用图表示这两个页表在线性地址空间和物理地址空间的映射关系。
答:在内核的线性地址空间。(图片自己画,参考如下图)
17. 进程0开始创建进程1,调用了fork(),跟踪代码时我们发现,fork代码执
行了两次,第一次,跳过init ()直接执行了for(;;) pause(),第二次执行fork 代码后,执行了init ()。奇怪的是,我们在代码中并没有看见向后的goto语句,也没有看到循环语句,是什么原因导致反复执行?请说明理由,并给出代码证据。
答:进程0 创建进程 1 采用了中断机制,在中断发生时由硬件将ss,esp,eflags,cs,eip 的值压入了内核栈,其中eip 的值指向了int 0x80 的下一条指令。在执行fork时,通过0x80号系统调用,内核执行copy_process函数,为进程1准备其管理结构(task_struct),设置进程1的线性地址空间及物理页面,其中设置了进程 1 的TSS 中eax 的值为0,状态为TASK_RUNNING ,以及利用中断压栈的寄存器值设置进程1 的ss,esp,eflags,cs,eip。
copy_process:
p->pid = last_pid;
p->tss.eip = eip; p->tss.eflags = eflags; p->tss.eax = 0;
p->tss.esp = esp; p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->state = TASK_RUNNING;
return last_pid;
函数copy_process的返回值是last_pid,即进程1的pid (pid不为0)。在fork返回到进程0 后,进程0 判断返回值非0,因此执行代码
for(;;) pause();
在sys_pause函数中,内核设置了进程0的状态为TASK_INTERRUPTIBLE,并进行进程调度。由于只有进程 1 处于就绪态,因此调度执行进程 1 的指令。由于进程1在TSS中设置了eip等寄存器的值,因此从int 0x80的下一条指令开始执行,且设定返回eax 的值作为fork 的返回值(值为0),因此进程 1 执行了init 的函数。导致反复执行,主要是利用了两个系统调用sys_fork 和sys_pause 对进程状态的设置,以及利用了进程调度机制。
18. copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long
s&查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。
答:在fork()中,当执行“ int $0x80”时产生一个软中断,该中断使CPU硬件自动将SS ESP、EFLAGS、CS、EIP这5个寄存器的数值按照这个顺序压入进程0 的内核栈。利用硬件进行压栈,可以确保eip 的值指向正确的指令,以使在中断返回后,程序能够继续执行。
19. 为什么static inline _syscall0(type,name中需要加上关键字inline ?
答:inline 一般是用于定义内联函数,内联函数结合了函数以及宏的优点,在定义时和函数一样,编译器会对其参数进行检查;在使用时和宏类似,内联函数的代码会被直接嵌入在它被调用的地方,这样省去了函数调用时的一些额外开销,比如保存和恢复函数返回地址等,可以加快速度。
20. 根据代码详细说明copy_process函数的所有参数是如何形成的?答:一般在应用程序中,一个函数的参数是由函数定义的,而在操作系统底层中,函数参数可以由函数定义以外的程序通过压栈的方式“做”出来。
copy_process函数的所有参数正是通过压栈形成的。代码见P83页、P85页、P86 页。
21. 根据代码详细分析,进程0如何根据调度第一次切换到进程1 的。
答:通过fork(),进程0创建进程1并将其状态设为TASK_RUNNING , fork() 函数执行完毕后返回,进入
for(;;) pause();
在sys_pause(中,将当前进程(进程0)的状态设置为TASK_INTERRUPTBLE,然后
执行schedule。遍历task[]数组,找到唯一的一个处于TASK_RUNNING的进程(进程1),然后切换到进程1执行,即switch_to(1)。
代码见P106
22. 内核的线性地址空间是如何分页的?画出从0x000000开始的7个页(包括页
目录表、页表所在页)的挂接关系图,就是页目录表的前四个页目录项、第一个个页表的前7个页表项指向什么位置?给出代码证据。
答:先把页目录表和 4 个页表放在物理内存的起始地址,从内存起始位置开始的5页空间内容全部清零(每页4KB )。然后设置页目录表的前4项,使之分别指向 4 个页表,将第4 个页表的最后一个页表项指向寻址范围的最后一个页面,将第 4 个页表的倒数第二个页表项指向寻址范围的倒数第二个页面,从高地址向低地址方向填写 4 个页面,依次指向内存从高地址向低地址方向的各个页面。
图见P39(注意要画出7个页,参考如下)
代码见P39 最下面
23. 用文字和图说明中断描述符表是如何初始化的,可以举例说明(比如:
set_trap_gate(0,÷_error)),并给出代码证据。
答:以set_trap_gate(0,÷_error)为例,其中,n 是0,gate_addr是&dt[0],也
就是idt的第一项中断描述符的地址;type是15,dpl (描述符特权级)是0; addr是中断服务程序divide_error(void)的入口地址。
见P54 图2-9 P53 代码
24. 进程0 fork进程1之前,为什么先要调用move_to_user_mode(?用的是什么方
法?解释其中的道理。
答:因为在Linux-0.11中,除进程0之外,所有进程都是由一个已有进程在用户态下完成创建的。但是此时进程0还处于内核态,因此要调用move_to_user_mode() 函数,模仿中断返回的方式,实现进程0的特权级从内核态转化为用户态。又因为在Linux-0.11中,转换特权级时采用中断和中断返回的方式,调用系统中断实
现从3到0的特权级转换,中断返回时转换为3特权级。因此,进程0从0特权级到3 特权级转换时采用的是模仿中断返回。
设计者首先手工写压栈代码模拟int仲断)压栈,当执行iret指令时,CPU 自动将这5个寄存器的值(SS,ESP,EFLAGS,CS,EIP按序恢复给CPU,CPU就会翻转到 3 特权级去执行代码。
25. 进程0创建进程1时调用copy_process函数,在其中直接、间接调用了两次
get_free_page函数,在物理内存中获得了两个页,分别用作什么?是怎么设置的?给出代码证据。
答:第一次调用get_free_page函数申请的空闲页面用于进程1的task_struct及
内核栈。首先将申请到的页面清0,然后复制进程0的task_struct,再针对进程1 作个性化设置,其中esp0 的设置,意味着设置该页末尾为进程 1 的堆栈的起始地址。代码见P90 及P92。
第二次调用get_free_page函数申请的空闲页面用于进程1的页表。在创建
进程1执行copy_process中,执行copy_mem(nr,p)时,内核为进程1拷贝了进程
0 的页表( 160 项),同时修改了页表项的属性为只读。代码见P98。
26. 在IA -32中,有大约20多个指令是只能在0特权级下使用,其他的指令,比如
cli,并没有这个约定。奇怪的是,在LinuxO.11中,在3特权级的进程代码并不能使用cli 指令,会报特权级错误,这是为什么?请解释并给出代码证据。
答:cli指令用于复位IF标志位,其执行与CPL(当前特权级)和EFLAGS[IOPL] 标志位有关。只有当CPL 小于或等于IOPL 时才可以执行该指令。如果在CPL 大于IOPL 的情况下执行,将会产生一个一般性保护异常,如下:set_trap_gate(13,
&general_protection);
由于在内核IOPL的初始值为0,且未经改变。进程0在move_to_user_mode 中,继承了内核的eflags,如下:
move_to_user_mode()
在进程0的TSS中,设置了eflags中的IOPL位为0,代码见P68,后续进程如果没有改动的话也是0,即IOPL=0。因此,通过设置IOPL,可以限制3特权级的进程代码使用cli 指令。
27. 根据代码详细分析操作系统是如何获得一个空闲页的。
答:代码见P90 get_free_page函数。
过程:
(1) 将EAX 设置为0,EDI 设置指向mem_map 的最后一项
(mem_map+PAGING_PAGES1),std设置扫描是从高地址向低地址。从
mem_map的最后一项反向扫描,找出引用次数为0(AL)的页,如果没有则退出;如果找到,则将找到的页设引用数为1;
(2) ECX 左移12位得到页的相对地址,加LOW_MEM 得到物理地址,将此页最后一个字节的地址赋值给EDI(LOW_MEM+4092 );
⑶stosl将EAX的值设置到ES:EDI所指内存,即反向清零1024*32bit,将此页清空;
(4)将页的地址(存放在EAX)返回。
28. 用户进程自己设计一套LDT 表,并与GDT 挂接,是否可行,为什么?答:不可行。GDT和LDT放在内核数据区,属于0特权级,3特权级的用户进程无权访问修改。此外,如果用户进程可以自己设计LDT 的话,表明用户进程可以访问其他进程的LDT,贝U会削弱进程之间的保护边界,容易引发问题。
29. 保护模式下,线性地址到物理地址的转化过程是什么?
答:保护模式下,线性地址到物理地址的转化是借助页目录表及页表完成的。其转化过程如图所示(见P97 图3-9)。Linux 0.11 中仅有一个页目录表,其地址存放在CR3寄存器中,通过线性地址中的“页目录项”数据及CR3寄存器就可以
找到页目录表中对应的页目录项,通过该页目录项可以找到对应的页表,结合线性地址中的“页表项” 数据就可以找到对应的页表项,通过该页表项可以找到对应的物理页面,最后通过线性地址中的“页内偏移”落实到实际的物理地址值。
30. 为什么get_free_page ()将新分配的页面清0?
答:Linux在回收页面时并没有将页面清0,只是将mem_map中与该页对应的位置0。在使用get_free_page申请页时,也是遍历mem_map寻找对应位为0的
页,但是该页可能存在垃圾数据,如果不清0 的话,若将该页用做页表,则可能导致错误的映射,引发错误,所以要将新分配的页面清0。
31. 内核和普通用户进程并不在一个线性地址空间内,为什么仍然能够访问普通用户
进程的页面?
答:虽然内核与普通进程并不在一个线性地址空间内,但是用户进程的页面最终要从物理内存上分配,而内核的分页机制即页目录表、页表等,正好管理着16M 物理内存,所以内核可以访问普通用户进程的页面。
32. 详细分析一个进程从创建、加载程序、执行、退出的全过程。
答:可以参考课本P273页,其中的核心部分课上都进行了介绍,包括fork()、copy_process() do_execve(及do_exit 等。
首先,shell调用fork开始创建进程,产生int 0x80软中断,最终映射到sys_fork(), 调用find_empty_process(),为str1申请可用的pid和task[64]空闲位置,接着调用
copy_process(为str1申请用来承载进程task_struct和内核栈的一个页面,shell 把自己的task_struct复制给str1进程,然后修改str1的task_struct的部分数据,包括时间片,TSS字段等。接着调用copy_mem()为进程分段(确定段基址和段限长等),然后调用copy_page_tables为str1进程另起一套页目录项和页表项,并指向shell的页面。还要解决文件继承的问题,然后将str1进程TSS和LDT
挂接在GDT 的指定位置,完成这些后,将str1 设为就绪态。接下来加载用户程序:首先要做一些检查工作,如可执行文件的数据长度和代码长度等;然后调用