中国科学院⼤学操作系统⾼级教程思考题
1.为什么计算机启动最开始的时候执⾏的是BIOS代码⽽不是操作系统⾃⾝的代码?
计算机启动的时候,内存未初始化,CPU不能直接从外设运⾏操作系统,所以必须将操作系统加载⾄内存中。⽽这个⼯作最开始的部分,BIOS需要完成⼀些检测⼯作,和设置实模式下的中断向量表和服务程序,并将操作系统的引导扇区加载值 0x7C00处,然后将跳转⾄0x7C00。这些就是由bios程序来实现的。所以计算机启动最开始执⾏的是bios代码。
2.为什么BIOS只加载了⼀个扇区,后续扇区却是由bootsect代码加载?为什么BIOS没有把所有需要加载的扇区都加载?
对BIOS⽽⾔,“约定”在接到启动操作系统的命令后,“定位识别”只从启动扇区把代码加载到0x7c00这个位置。后续扇区则由bootsect代码加载,这些代码由编写系统的⽤户负责,与BIOS⽆关。这样构建的好处是站在整个体系的⾼度,统⼀设计和统⼀安排,简单⽽有效。BIOS 和操作系统的开发都可以遵循这⼀约定,灵活地进⾏各⾃的设计。操作系统的开发也可以按照⾃⼰的意愿,内存的规划,等等都更为灵活。
3.为什么BIOS把bootsect加载到0x07c00,⽽不是0x00000?加载后⼜马上挪到0x90000处,是何道理?为什么不⼀次加载到位?
(1)BIOS把bootsect加载到0x07c00⽽不是0x00000,是因为0x00000处存放着BIOS构建的1k⼤⼩的中断向量表和256B的BIOS数据区。
(2)加载后⼜挪到0x90000是因为,操作系统对内存的规划是在0x90000存放bootsect,然后bootsect执⾏结束之后,⽴即将系统机器数据存放在此处,这样就可以及时回收寿命结束的程序占据的内存空间。⽽且后续会把120K的系统模块存放到
0x00000处,这会覆盖0x07c00处的代码和数据。
3)不⼀次加载到位的原因是由于“两头约定”和“定位识别”,所以在开始时bootsect“被迫”加载到0X07c00位置。现在将⾃⾝移⾄0x90000处,说明操作系统开始根据⾃⼰的需要安排内存了。
4.bootsect、setup、head程序之间是怎么衔接的?给出代码证据。
1)bootsect 跳转到setup :jmpi 0,SETUPSEG;
在实模式下,该指令直接给cs和ip赋值,cs:ip指向了0x90200也就是setup的第⼀条指令。
2)setup 跳转到head 程序:CPU ⼯作模式⾸先转变为保护模式然后执⾏ jmpi 0,8
保护模式下,通过全局描述符表进⾏段间的跳转,0 指的是段内偏移,8 是保护模式下的段选择⼦:01000,其中后两位表⽰内核特权级,第三位0代表GDT,1 则表⽰GDT 表中的第⼀项,即内核代码段,段基质为0x0000000,⽽head程序地址就在这⾥,意味着跳转到head执⾏。
5.setup程序⾥的cli是为了什么?
cli为关中断,以为着程序在接下来的执⾏过程中,⽆论是否发⽣中断,系统都不再对此中断进⾏响应。因为在setup中,需要将位于0x10000 的内核程序复制到0x00000 处,bios中断向量表覆盖掉了,若此时如果产⽣中断,这将破坏原有的中断机制会发⽣不可预知的错误,所以要禁⽰中断。
6.setup程序的最后是jmpi 0,8 为什么这个8不能简单的当作阿拉伯数字8看待?
这⾥8要看成⼆进制1000,最后两位00表⽰内核特权级,第三位0表⽰GDT表,第四位1
表⽰所选的表(在此就是GDT表)的1项来确定代码段的段基址和段限长等信息。这样,我们可以得到代码是从段基址
0x00000000、偏移为0处开始执⾏的,即head的开始位置。注意到已经开启了保护模式的机制,所以这⾥的8不能简单的当成阿拉伯数字8来看待。
7.打开A20和打开pe究竟是什么关系,保护模式不就是32位的吗?为什么还要打开A20?有必要吗?
1、打开A20仅仅意味着CPU可以进⾏32位寻址,且最⼤寻址空间是4GB。打开PE是进⼊保护模式。A20是cpu的第21位地址线,A20未打开的时候,实模式中cs:ip最⼤寻址为1MB 64KB,⽽第21根地址线被强制为0,所以相当于cpu“回滚”到内存地址起始处寻址。
当打开A20的时候,实模式下cpu可以寻址到1MB以上的⾼端内存区。A20未打开时,如果打开pe,则cpu进⼊保护模式,但是可以访问的内存只能是奇数1M段,即0-1M,2M-3M,4-5M等。A20被打开后,如果打开pe,则可以访问的内存是连续的。打开A20 是打开PE的必要条件;⽽打开A20不⼀定⾮得打开PE。
2、有必要。打开PE只是说明系统处于保护模式下,但若真正在保护模式下⼯作,必须打开A20,实现32位寻址。
8.Linux是⽤C语⾔写的,为什么没有从main函数开始,⽽是先运⾏3个汇编程序,道理何在?
main 函数运⾏在32 位的保护模式下,但系统启动时默认为16 位的实模式,开机时的16 位实模式与main 函数执⾏需要的32位保护模式之间有很⼤的差距,这个差距需要由 3 个汇编程序来填补。其中bootsect 负责加载,setup 与head 则负责获取硬件参数,准备idt,gdt,开启A20,PE,PG,废弃旧的16 位中断响应机制,建⽴新的32 为IDT,设置分页机制等。这些⼯作做完后,计算机处在了32位的保护模式状态了,调⽤main的条件就算准备完毕。
9.为什么不⽤call,⽽是⽤ret“调⽤”main函数?画出调⽤路线图,给出代码证据。
call指令会将EIP的值⾃动压栈,保护返回现场,然后执⾏被调函数的程序,等到执⾏被调函数的ret指令时,⾃动出栈给EIP并还原现场,继续执⾏call的下⼀条指令。然⽽对操作系统的main函数来说,如果⽤call调⽤main函数,那么ret时返回给谁呢?因为没有更底层的函数程序接收操作系统的返回。⽤ret实现的调⽤操作当然就不需要返回了,call做的压栈和跳转动作需要⼿⼯编写代码。
after_page_tables:
pushl $L6 # return address for main, if it decides to.
pushl $_main //将main的地址压⼊栈,即EIP
jmp setup_paging
setup_paging:
ret //弹出EIP,针对EIP指向的值继续执⾏,即main函数的⼊⼝地址。
P42页
10.保护模式的“保护”体现在哪⾥?
1)在GDT、LDT 及IDT 中,均有⾃⼰界限,特权级等属性,这是对描述符所描述的对象的保护
2)在不同特权级间访问时,系统会对CPL、RPL、DPL、IOPL 等进⾏检验,对不同层级的程序进⾏保护,同还限制某些特殊指令的使⽤,如 lgdt, lidt,cli 等
3)分页机制中PDE 和PTE 中的R/W 和U/S 等,提供了页级保护。分页机制将线性地址与物理地址加以映射,提供了对物理地址的保护。
11.特权级的⽬的和意义是什么?
答:特权级
⽬的:在于保护⾼特权级的段,其中操作系统的内核处于最⾼的特权级。
意义:保护模式中的特权级,对操作系统的“主奴机制”影响深远。Intel从硬件上禁⽌低特权级代码段使
⽤⼀些关键性指
令,Intel还提供了机会允许操作系统设计者通过⼀些特权级的设置,禁⽌⽤户进程使⽤cli、sti等对掌控局⾯⾄关重要的指令。有了这些基础,操作系统可以把内核设计成最⾼特权级,把⽤户进程设计成最低特权级。这样,操作系统可以访问GDT、LDT、TR,⽽GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换⽽成的,所以操作系统可以访问任何物理地址。⽽⽤户进程只能使⽤逻辑地址。总之,特权级的引⼊对于操作系统内核提供了强有⼒的保护。在操作系统设计中,⼀个段⼀般实现的功能相对完整,可以把代码放在⼀个段,数据放在⼀个段,并通过段选择符(包括CS、SS、DS、ES、FS和GS)获取段的基址和特权级等信息。特权级基于段,这样当段选择⼦具有不匹配的特权级时,按照特权级规则评判是否可以访问。
12.在setup程序⾥曾经设置过⼀次gdt,为什么在head程序中将其废弃,⼜重新设置了⼀个?为什么折腾两次,⽽不是⼀次搞好?·
答:由于setup开了保护模式,从setup跳转到head时需要GDT,所以setup中必须建⽴⼀个GDT。但其位置将来会在设计缓冲区时被覆盖。如果不改变位置,GDT的内容将来肯定会被缓冲区覆盖掉,从⽽影响系统的运⾏。⽽将来,整个内存中唯⼀安全的地⽅就是head 程序的位置。
其次,不能在执⾏setup时直接把GDT内容拷贝到head所在的位置。因为,如果先复制GDT 的内容,
后移动system模块,它就会被后者覆盖掉;如果先移动system模块,后复
制GDT的内容,它⼜会把head对应的程序覆盖掉,⽽这时head还没有执⾏。所以⽆论如何,都要重新建⽴GDT。
13.⽤户进程⾃⼰设计⼀套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上。
14.进程0的task_struct、内核栈、⽤户栈在哪?给出代码证据。
task_union是task_struct型变量task和⼀个⼤⼩为4k的char型数组stack的共⽤体。(1)进程0的task_struct在内核数据区的init_task共⽤体中:
sched.h中:
#define INIT_TASK \ 定义了⼀个名为INIT_TASK的符合task_struct结构的宏。
sched.c中:
static union task_union init_task = {INIT_TASK,};
struct task_struct * task[NR_TASKS] = {&(init_task.task), };
将之前定义的宏INIT_TASK放到了进程槽的0项中,说明进程0的task_struct在
内核数据区中的init_task共⽤体中。
(2)进程0的内核栈也在init_task共⽤体中。
sched.c中:
static union task_union init_task = {INIT_TASK,};
sched.h中:
/
*tss*/{0,PAGE_SIZE+(long)&init_task\
PAGE_SIZE+(long)&init_task在tss中对应esp0,指向的地⽅就是init_task的末尾的位置。(3)进程0⽤户栈在user_stack中sched.c中:
stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };
head.s中:
lss _stack_start,%esp
在head.s中⽤user_stack做内核的数据栈
system.h中:
#define move_to_user_mode() \
......
"movl %%esp,%%eax\n\t" \
......
"pushl %%eax\n\t" \
此时压栈的数据在ret之后就弹出到esp寄存器中,所以这⾥的esp指向的区域user_stack 就是以后进程0的⽤户栈。
15.进程0创建进程1时,为进程1建⽴了⾃⼰的task_struct、内核栈,第⼀个页表,分别位于物理内存16MB的顶端倒数第⼀页、第⼆页。请问,这个了页究竟占⽤的是谁的线性地址空间,内核、进程0、进程1、还是没有占⽤任何线性地址空间(直接从物理地址分配)?说明理由并给出代码证据。
答:这两个页占⽤的是内核的线性地址空间,依据在setup_paging(⽂件head.s)中,movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb -4096 + 7 (r/w user,p) */
std
1: stosl/* fill pages backwards -more efficient :-) */
c语言大学教程subl $0x1000,%eax
上⾯的代码,指明了内核的线性地址空间为0x000000 ~ 0xffffff(即前16M),且线性地址与物理地址呈现⼀⼀对应的关系。为进程1分配的这两个页,在16MB的顶端倒数第⼀页、第⼆页,因此占⽤内核的线性地址空间。
进程0的线性地址空间是内存前640KB,因为进程0的LDT中的limit属性限制了进程0 能够访问的地址空间。进程1拷贝了进程0的页表(160项),⽽这160个页表项即为内核第⼀个页表的前160项,指向的是物理内存前640KB,因此⽆法访问到16MB的顶端倒数的两个页。
进程0创建进程1的时候,先后通过get_free_page函数从物理地址中取出了两个页,但是并没有将这两个页的物理地址填⼊任何新的页表项中。
此时只有内核的页表中包含了与这段物理地址对应的项,也就是说此时只有内核页表中有页表项指向这两个页的⾸地址,所以这两个页占⽤了内核线性空间。
16.假设:经过⼀段时间的运⾏,操作系统中已经有5个进程在运⾏,且内核分别为进程4、进程5分别创建了第⼀个页表,这两个页表在谁的线性地址空间?⽤图表⽰这两个页表在线性地址空间和物理地址空间的映射关系。
这两个页⾯均占⽤内核的线性空间。
17.进程0开始创建进程1,调⽤了fork(),跟踪代码时我们发现,fork代码执⾏了两次,第⼀次,跳过init()直接执⾏了for(;;) pause(),第⼆次执⾏fork代码后,执⾏了init()。奇怪的是,我们在代码中并没有看见向后的goto语句,也没有看到循环语句,是什么原因导致反复执⾏?请说明理由,并给出代码证据。
进程0创建进程1时,⾸先在copy_process()函数中,对进程 1 做个性化调整设置,调整tss的数据。
Int copy_process(int nr, long ebp,…)
{
......
p->tss.eip = eip;
......
p->tss.eax = 0;
......
}
此时,赋给tss.eip的值即为int 0x80中断发⽣时压⼊栈的eip值,这个eip就指向发⽣中断的指令的下⼀⾏指令。
进程0,创建进程1结束之后从中断返回,返回值为1(进程1的进程号),所以跳过init(),直接执⾏了for(;;) pause()。
然后再执⾏到如下进程切换的代码:
#define switch_to()
……
"ljmp %0\n\t" \
程序在执⾏到“ljmp %0/n/t”这⼀⾏,ljmp 通过cpu 任务们机制⾃动将进程1 的tss 值恢复给cpu,⾃然也将其中tss.eip恢复给cpu,现在cpu指向fork的if(_res >=0)这⼀⾏。⽽此时的_res 值就是进程1中tss的eax 的值,这个值在前⾯被写死为0,
所以直⾏到return (type) _res 这⼀⾏返回值为0.
所以进程1执⾏到main 函数中if(!fork())这⼀⾏,!0 值为真,调⽤init()函数。
copy_process执⾏时因为进程调⽤了fork函数,会导致中断,中断使CPU硬件⾃动将SS、ESP、EFLAGS、CS、EIP这⼏个寄存器的值按照顺序压⼊进程0内核栈,⼜因为函数专递参数是使⽤栈的,所以刚好可以做为copy_process的最后五项参数。
19.为什么static inline _syscall0(type,name)中需要加上关键字inline?
因为_syscall0(int,fork)展开是⼀个真函数,普通真函数调⽤时需要将eip⼊栈,返回时需要将eip出栈。inline是内联函数,它将标明为inline的函数代码放在符号表中,⽽此处的fork 函数需要调⽤两次,加上inline后先进⾏词法分析、语法分析正确后就地展开函数,不需要有普通函数的call\ret等指令,也不需要保持栈的eip,效率很⾼。若不加上inline,第⼀次调⽤fork结束时将eip 出栈,第⼆次调⽤返回的eip出栈值将是⼀个错误值。
20.根据代码详细说明copy_process函数的所有参数是如何形成的?
long eip, long cs, long eflags, long esp, long ss;这五个参数是中断使CPU⾃动压栈的。
long ebx, long ecx, long edx, long fs, long es, long ds为__system_call压进栈的参数。
_system_call:
......
push %ds
push %es
push %fs
pushl %edx
pushl %ecx
pushl %ebx
long none 为__system_call 调⽤__sys_fork 压进栈的EIP值。
Int nr, long ebp, long edi, long esi, long gs,为__system_fork压进栈的值。
_sys_fork:
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
20.根据代码详细分析,进程0如何根据调度第⼀次切换到进程1的。
1.进程0通过fork函数创建进程1,使其处在就绪态。
2.进程0调⽤pause函数。pause函数通过int 0x80中断,映射到sys_pause函数,将⾃⾝设为可中断等待状态,调⽤schedule函数。
3.schedule函数分析到当前有必要进⾏进程调度,第⼀次遍历进程,只要地址指针不为为空,就要针对处理。第⼆次遍历所有

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。