2023年国科大杨力祥《高级操作系统》期末思考题汇总

1.为什么开始启动计算机的时候,执行的是BIOS代码而不是操作系统自身的代码?

因为刚开始启动计算机的时候,计算机的内存还没有初始化。由于CPU只能执行内存中的代码,因此需要把操作系统从软盘或硬盘中加载到内存上。这就需要硬件主动加载BIOS程序,由BIOS准备好中断向量表和中断服务程序,接着通过中断将引导程序bootsect加载到内存里。再通过后续的一系列执行,操作系统的代码才能位于内存中,供CPU执行。

2.为什么BIOS只加载了一个扇区,后续扇区却是由bootsect代码加载?为什么BIOS没有直接把所有需要加载的扇区都加载?

因为操作系统和BIOS通常是由不同的团队进行开发的。为了能够协调工作,双方按照固定的约定进行代码的开发。对于BIOS来说,它接收到启动命令后就将启动扇区的代码加载至0x07c00(BOOTSEG)处,至于启动扇区里的内容是什么,BIOS一概不管。而后续的代码则由操作系统自己的bootsect代码进行加载,这些代码由编写操作系统的团队负责。这样构建可以让BIOS和操作系统的设计团队按照自己的意愿进行代码设计,使内存规划更加灵活。如果BIOS直接把所有需要加载的扇区一次性进行加载,可能会出现以下问题:①不同的操作系统代码长度不一样,由BIOS进行操作系统的加载可能会导致系统加载不完全②如果使用BIOS进行加载,等待系统加载完毕后再执行,则需要等待较长的时间,因此Linux采用的就是边加载边执行的方法

3.为什么BIOS把bootsect加载到0x07c00,而不是0x00000?加载后又马上挪到0x90000处,是何道理?为什么不一次加载到位?

有以下几个原因:

①BIOS在0x00000开始的位置构建起了中断向量表,暂时不能被覆盖,因此不能把bootsect加载到0x00000位置

②bootsect运行时产生的数据有些需要进行保存以供后续使用,而之后拷贝内核system时会将0x07c00处覆盖,造成数据的丢失,因此需要将该bootsect挪到0x90000处

③加载到0x07c00位置是历史约定,不是bootsect能够决定的,因此只能由bootsect在运行时把自己拷贝到0x90000处

4.bootsect、setup、head程序之间是怎么衔接的?给出代码证据。

①bootsect将setup加载到0x90200处

INITSEG  = 0x9000			! we move boot here - out of the way

	jmpi	go,INITSEG	! 修改 cs
go:	mov	ax,cs
	mov	ds,ax
	mov	es,ax

load_setup:
	mov	dx,#0x0000		! drive 0, head 0
	mov	cx,#0x0002		! sector 2, track 0
	mov	bx,#0x0200		! address = 512, in INITSEG
	mov	ax,#0x0200+SETUPLEN	! service 2, nr of sectors
	int	0x13			! read it
	jnc	ok_load_setup		! ok - continue
	mov	dx,#0x0000
	mov	ax,#0x0000		! reset the diskette
	int	0x13
	j	load_setup

②bootsect将system加载到0x10000处

SYSSIZE = 0x3000
SYSSEG   = 0x1000			! system loaded at 0x10000 (65536).
ENDSEG   = SYSSEG + SYSSIZE		! where to stop loading

	mov	ax,#SYSSEG
	mov	es,ax		! segment of 0x010000
	call	read_it

read_it:
	mov ax,es
	test ax,#0x0fff		! 64KB 对齐
die:	jne die			! es must be at 64kB boundary
	xor bx,bx		! bx is starting address within segment
rp_read:
	mov ax,es
	cmp ax,#ENDSEG		! have we loaded all yet?
	jb ok1_read
	ret
ok1_read:
	seg cs
	mov ax,sectors		! 每磁道扇区数
	sub ax,sread		! 当前磁道已读扇区数
	mov cx,ax
	shl cx,#9			! 计算一共有多少个字节 (*512) 以下是用来判断是否超过 64KB,真正有用的是 ax
	add cx,bx			! 段内当前偏移值
	jnc ok2_read
	je ok2_read			! 没有超过 64KB
	xor ax,ax
	sub ax,bx			! 计算此时最多能读入的字节数
	shr ax,#9
ok2_read:
	call read_track
	mov cx,ax			! 该次操作读取的扇区数
	add ax,sread		! 当前磁道已读扇区数
	seg cs
	cmp ax,sectors		
	jne ok3_read		! 如果当前磁道还有扇区未读,则跳转到 ok3_read
	mov ax,#1
	sub ax,head
	jne ok4_read		! 如果是 0 磁头,则去读 1 磁头面上的扇区数据
	inc track			! 否则去读下一磁道
ok4_read:
	mov head,ax
	xor ax,ax			! 清零当前已读扇区数
ok3_read:
	mov sread,ax
	shl cx,#9
	add bx,cx			! 调整当前段内数据开始的位置
	jnc rp_read
	mov ax,es
	add ax,#0x1000
	mov es,ax
	xor bx,bx
	jmp rp_read

read_track:
	push ax
	push bx
	push cx
	push dx
	mov dx,track	! 当前磁道号
	mov cx,sread	! 当前磁道已读扇区数
	inc cx			! 从下一扇区开始读
	mov ch,dl
	mov dx,head		! 当前磁头号
	mov dh,dl
	mov dl,#0
	and dx,#0x0100
	mov ah,#2
	int 0x13
	jc bad_rt
	pop dx
	pop cx
	pop bx
	pop ax
	ret
bad_rt:	mov ax,#0	! 执行驱动器复位操作
	mov dx,#0
	int 0x13
	pop dx
	pop cx
	pop bx
	pop ax
	jmp read_track

③bootsect跳转到setup处

SETUPSEG = 0x9020			! setup starts here
	jmpi	0,SETUPSEG		! 跳转到 setup.s

④setup将system移动到0x00000处

	mov	ax,#0x0000
	cld			! 'direction'=0, movs moves forward
do_move:
	mov	es,ax		! destination segment
	add	ax,#0x1000
	cmp	ax,#0x9000
	jz	end_move
	mov	ds,ax		! source segment
	sub	di,di
	sub	si,si
	mov 	cx,#0x8000
	rep
	movsw
	jmp	do_move

⑤setup加载GDT,令内核代码段基址指向第一条指令,即0x0处

! 内核代码段
	.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
	.word	0x0000		! base address=0
	.word	0x9A00		! code read/exec
	.word	0x00C0		! granularity=4096, 386

⑥setup进入保护模式,通过内核代码段选择子和偏移量跳转到head

jmpi	0,8		! jmp offset 0 of segment 8 (cs)		进入 head

5.setup程序的最后是jmpi 0,8 ,为什么这个8不能简单的当作阿拉伯数字8看待,究竟有什么内涵?

因为此时已经进入了保护模式,这里的0和8代表的是段内偏移量和段选择子。根据段选择子的规则,这里的8应该看做二进制的1000,其中最后两个字符“00”表示内核特权级,第二个字符“0”表示选择GDT表,第一个字符“1”表示所选择的表的第一项,即GDT表的第一项,由此来确定代码段的段基址和段限长,由此可以得到这段代码的意思是从0x00000000位置,偏移量为0处开始执行,即head的起始位置

6.保护模式在“保护”什么?它的“保护”体现在哪里?特权级的目的和意义是什么?分页有“保护”作用吗?为什么特权级是基于段的?

①打开了保护模式后,CPU的寻址模式发生了变化,需要依赖于GDT去获取代码或数据段的基址。从GDT可以看出,保护模式除了段基址外,还有段限长,这样相当于增加了一个段位寄存器。既有效地防止了对代码或数据段的覆盖,又防止了代码段自身的访问超限,明显增强了保护作用。
②体现:①在GDT、LDT及IDT中,均有自己界限特、权级等属性,这是对描述符所描述的对象的保护;②在不同特权级间访问时,系统会对CPL、RPL、DPL、IOPL 等进行检验,对不同层级的程序进行保护,同还限制某些特殊指令的使用,如 lgdt, lidt,cli等。
③特权级的目的和意义:①为了更好的管理资源并保护系统不受侵害,操作系统利用先机,以时间换取特权,先霸占所有特权;②依托CPU提供的保护模式,着眼于“段”,在所有的段选择符最后两位标示特权级,禁止用户执行cli、sti等对掌控局面至关重要的指令。③操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问 GDT、LDT、TR,而 GDT、LDT是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址,而用户进程只能使用逻辑地址。
④分页机制中PDE和PTE中的R/W和U/S等,提供了页级保护;分页机制将线性地址与物理地址加以映射,提供了对物理地址的保护;通过分页机制,每个进程都有自己的专属页表,有利于更安全、高效的使用内存,保护每个进程的地址空间。

⑤在操作系统设计中,一个段一般实现的功能相对完整,可以把代码放在一个段,数据放在一个段,并通过段选择符(包括CS、SS、DS、ES、Fs和GS)获取段的基址和特权级等信息。通过段,系统划分了内核代码段、内核数据段、用户代码段和用户数据段等不同的数据段,有些段是系统专享的,有些是和用户程序共享的,因此就有特权级的概念。特权级基于段,这样当段选择子具有不匹配的特权级时,按照特权级规则评判是否可以访问。特权级基于段,是结合了程序的特点和硬件实现的一种考虑。

7.在setup程序里曾经设置过gdt,为什么在head程序中将其废弃,又重新设置了一个?为什么设置两次,而不是一次搞好?

原来GDT所在的位置是设计代码时在setup.s里面设置的数据,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,将来GDT的内容肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存空间中唯一安全的地方就是现在head.s所在的位置了。
不能在执行setup程序时直接把GDT的内容复制到head.s所在位置:如果先复制GDT内容,后移动system模块,它就会被后者覆盖;如果先移动system模块,后复制GDT内容,它又会把head.s对应的程序覆盖,而这时head.s还没有执行。所以无论如何都要重新建立GDT。

8.内核的线性地址空间是如何分页的?画出从0x000000开始的7个页(包括页目录表、页表所在页)的挂接关系图,就是页目录表的前四个页目录项、第一个个页表的前7个页表项指向什么位置?给出代码证据。

①head.s在setup_paging开始创建分页机制。将页目录表和4个页表放到物理内存的起始位置,从内存起始位置开始的5个页空间内容全部清零(每页4kb),然后设置页目录表的前4项,使之分别指向4个页表。然后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。即将第4个页表的最后一项(pg3+4092指向的位置)指向寻址范围的最后一个页面。即从0xFFF000开始的4kb 大小的内存空间。将第4个页表的倒数第二个页表项(pg3-4+4092)指向倒数第二个页面,即0xFFF000-0x1000开始的4KB字节的内存空间,依此类推。

③Head.s中完成页表项与页面的挂接,是从高地址向低地址方向完成挂接的,16M内存全部完成挂接(页表从0开始,即页表0-页表3)

setup_paging: 
movl $1024*5,%ecx  /* 5 pages - pg_dir+4 page tables */ 
xorl %eax,%eax 
xorl %edi,%edi  /* pg_dir is at 0x000 */ 
cld;rep;stosl
 movl $pg0+7,pg_dir  /* set present bit/user r/w */  
movl $pg1+7,pg_dir+4  /*  --------- " " --------- */ 
movl $pg2+7,pg_dir+8  /*  --------- " " --------- */  
movl $pg3+7,pg_dir+12  /*  --------- " " --------- */ 
_pg_dir用于表示内核分页机制完成后的内核起始位置,也就是物理内存的起始位置0x000000,以上四句完成页目录表的前四项与页表12,3,4的挂接 
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
	jge 1b
	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

9.根据内核分页为线性地址恒等映射的要求,推导出四个页表的映射公式,写出页表的设置代码。

内核分页采用线性地址恒等映射。内核的段基址是0,代码段和数据段的段限长都是16 MB。每个页面大小为4 KB,每个页表可以管理1024个页面,每个页目录表可以管理1024个页表。既然确定了段限长是16 MB,这样就需要4个页目录项(attention:只用了四个页目录项管理4个页表)下辖4个页表,来管理这16 MB的内存

页表设置代码:(内核分页采用恒等映射模式,调用get_free_page( )函数后,获取的线性地址值直接就可以当物理地址来用)

//代码路径:boot/head.s:
…
setup_paging:
      movl $1024*5,%ecx            /* 5 pages - pg_dir + 4 page tables */
      xorl %eax,%eax
      xorl %edi,%edi                       /* pg_dir is at 0x000 */
      cld;rep;stosl
      movl $pg0 + 7,_pg_dir                /* set present bit/user r/w */
      movl $pg1 + 7,_pg_dir + 4    /*  --------- " " --------- */
      movl $pg2 + 7,_pg_dir + 8    /*  --------- " " --------- */
      movl $pg3 + 7,_pg_dir + 12  /*  --------- " " --------- */
      movl $pg3 + 4092,%edi
      movl $0xfff007,%eax          /*  16Mb -4096 + 7 (r/w user,p) */
      std
1: stosl                          /* fill pages backwards - more efficient :-) */
      subl $0x1000,%eax
      jge 1b
…

10.为什么不用call,而是用ret“调用”main函数?画出调用路线图,给出代码证据。

call指令会将EIP的值自动压栈,保护返回现场,然后执行被调用函数的程序,等执行到函数的ret指令时,自动出栈给EIP返回调用前的现场,而后继续执行call的下一条指令。然而由于操作系统是机器运行时逻辑上最底层的代码,因此如果使用call来调用操作系统的main函数,那么执行到ret时没有一个更底层的函数来接收操作系统的返回。而如果使用ret来实现调用main函数的操作则不需要再返回了。要想用ret来模拟call指令调用main函数,则需要手动编写压栈和跳转动作的代码。

调用路线如图:

代码如下:

after_page_tables:
	pushl $0		# These are the parameters to main :-)	envp
	pushl $0		# argv
	pushl $0		# argc
	pushl $L6		# return address for main, if it decides to.
	pushl $_main	# kernel 的 main 函数地址
	jmp setup_paging
L6:
	jmp L6			# main should never return here, but
				# just in case, we know what happens.

setup_paging:	// 内核分页,分完以后 线性地址 == 物理地址
	// ...
	ret			/* this also flushes prefetch-queue */		// 我们是操作系统的底层,所以要返回到 kernel 中

11、计算内核代码段、数据段的段基址、段限长、特权级。

在 Linux 0.11 中,内核代码段和数据段的段基址实际上是相同的,都是 0x00000000;代码段和数据段的段限长均设置为16 MB;特权级为0特权级

12、计算进程0的代码段、数据段的段基址、段限长、特权级。

在 Linux 0.11 中,进程0的代码段和数据段的段基址都是 0x00000000;代码段和数据段的段限长均设置为160*4KB=640KB;特权级为0特权级

13、fork进程1之前,为什么先调用move_to_user_mode()?用的是什么方法?解释其中的道理。

①因为在Linux-011中,规定除了进程0以外的所有进程,都必须在特权级为3即用户模式中开始执行。所以进程0 fork进程1之前,要调用move_to_user_mode()将0特权级翻转到3特权级。
②move_to_user_mode()使用的方法是模仿中断的硬件压栈,将ss、esp、eflags、cs、eip按顺序压栈,然后执行iret从内核模式返回,出栈恢复现场,从而翻转0特权级到3特权级。
③CPU响应中断的时候,根据DPL的设置,可以实现指定的特权级之间的翻转。所以模拟中断硬件压栈可以实现特权级的翻转。

14、根据什么判定move_to_user_mode()中iret之后的代码为进程0的代码。

iret 指令将CPU状态从内核模式切换到用户模式。iret之后的代码目的是为了设置用户模式下的各种寄存器。move_to_user_mode 是在系统初始化的最后阶段调用的,且此时系统中只有进程0存在,进程1还未创建。因此iret指令之后的代码仍然是属于进程0的。

15、进程0的task_struct在哪?具体内容是什么?给出代码证据。

①进程0的task_struct是操作系统设计者事先设计好的,位于内核数据区。
②进程0的task_struct 的具体内容包含了进程0的状态、信号、pid、alarm、ldt、tss等管理该进程所需的数据。
③代码如下:

// 进程0的task_struct
#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, \
		{} \
	}, \
}

16、在system.h里,有以下代码。读懂代码。这里中断门、陷阱门、系统调用都是通过_set_gate设置的,用的是同一个嵌入汇编代码,比较明显的差别是dpl一个是3,另外两个是0,这是为什么?说明理由。

#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)

dpl表示的是特权级,0和3分别表示0特权级(内核级)和3特权级(用户级),内核级高于用户级。中断和异常处理是由内核来完成的,Linux出于对内核的保护,不允许用户进程直接访问内核,因此需要设置为0特权级。但是有些情况下,用户进程又需要内核代码的支持,因此就需要系统调用,它是用户进程与内核打交道的接口,是由用户进程直接调用的。因此其在3特权级下。

17、分析get_free_page()函数的代码,叙述在主内存中获取一个空闲页的技术路线。

①将EAX 设置为0,EDI 设置指向mem_map 的最后一项(mem_map+PAGING_PAGES-1),std设置扫描是从高地址向低地址。从mem_map的最后一项反向扫描,找出引用次数为0(AL)的页,如果没有则退出;如果找到,则将找到的页设引用数为1;
② ECX左移12位得到页的相对地址,加LOW_MEM得到物理地址,将此页最后一个字节的地址赋值给EDI(LOW_MEM+4092);
③ stosl将EAX的值设置到ES:EDI所指内存,即反向清零1024*32bit,将此页清空;
④ 将页的地址(存放在EAX)返回。

18、copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long ss。查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。详细论证其他所有参数是如何传入的。

copy_process执行时因为进程调用了fork函数,会导致中断,中断使CPU硬件自动将SS、ESP、EFLAGS、CS、EIP这几个寄存器的值按照顺序压入 进程0内核栈,又因为函数专递参数是使用栈的,所以刚好可以做为copy_process的最后五项参数。

19、详细分析Linux操作系统如何设置保护模式的中断机制。

①中断描述符表 (IDT) 初始化:
在保护模式下,IDT用于存放中断处理程序的地址。每个中断或异常都有一个与之相关联的中断描述符。Linux在启动时设置这个IDT。

②初始化中断控制器 (PIC):
为了接收来自外部硬件的中断,Linux首先需要初始化可编程中断控制器 (PIC)。这是一个芯片,负责从外部硬件接收中断请求并将它们传递给CPU。

③设置中断处理程序:
Linux为每个可能的中断或异常设置了一个中断处理程序。这些处理程序在内核启动时初始化,并与特定的中断或异常号相关联。

④加载IDT寄存器:
使用lidt指令加载IDT的地址和大小。这告诉CPU在哪里可以找到中断描述符表。

⑤开启中断:
通过设置CPU的标志寄存器中的中断标志(IF)来启用中断。

20、分析Linux操作系统如何剥夺用户进程访问内核及其他进程的能力。

所有程序的设计都是基于段的。

①进程跨越到内核

用户进程代码段的特权级都是3,内核的特权级是0,Intel IA-32架构禁止代码跨越特权级长跳转,3特权级长跳转到0特权级是禁止的,0特权级长跳转到3特权级同样是禁止的。所以这样的非法长跳转指令会被CPU硬件有效阻拦,进程与内核的边界得到有效的保护。

②当一个进程的代码中有非法的跨进程跳转的指令时,比如,ljmp指令执行时,该指令后面的操作数是“段内偏移段选择子”。代码段的段选择子存储在CS里面。仔细考察一下,可以看出Linux 0.11中所有进程的CS的内容都是一样的,用二进制表示的形式都是0000000000001111。CPU硬件无法识别是哪一个进程的CS,也就无法选择段描述符,只能默认使用当前LDT中提供的段描述符,所以类似ljmp这样的段间跳转指令,无论后面操作数怎么写,都无法跨越当前进程的代码段,也就无法进行段间跳转,最终只能是执行到本段。

21、_system_call中有以下代码,分析后面这两行代码的意义。

    cmpl $nr_system_calls-1,%eax
    ja bad_sys_call

验证发起的系统调用编号是否在有效范围内,阻止非法的系统调用。

22、分析copy_page_tables()函数的代码,叙述父进程如何为子进程复制页表。

①要求源地址和目的地址必须按 4MB 对齐;
②计算源地址和目的地址所在的页目录表项的线性地址;
③通过所占的地址空间计算所用的页目录表项数;
④根据页目录表项的起始线性地址和页目录表项数遍历页目录表,对每一个源页目录表项,如果对应的页表存在,则进行以下操作:
4.1 从页目录表项中取出对应的页表的起始物理地址;
4.2 为目的页表分配一个空白页,并挂到目的页目录表项上,并将标志设置为用户级的、可读写、存在;
4.3 计算需要复制的页表项数,如果是内核空间(源地址为 0),则只复制前 160 项(内核空间只占低 640 KB),否则全部复制。
4.4 遍历页表,对于每一个源页表项,如果对应的页存在,则进行以下操作:
4.4.1 从源页表中复制每一项到目的页表,同时置为"只读",以便进行 COW;
4.4.2 如果页表项对应的页的地址在1MB 以上(非内核页面),则将源页表项置为只读,并在 mem_map 中将该页的引用计数加 1;
⑤重新加载 cr3,刷新 TLB。

23、进程0创建进程1时,为进程1建立了task_struct及内核栈,第一个页表,分别位于物理内存两个页。请问,这两个页的位置,究竟占用的是谁的线性地址空间,内核、进程0、进程1、还是没有占用任何线性地址空间?说明理由(可以图示)并给出代码证据。

均占用内核的线性地址空间,原因如下:

通过逆向扫描页表位图,并由第一空页的下标左移 12 位加 LOW_MEM 得到该页的物理地址,位于 16M 内存末端。 代码如下

//代码路径: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;
}

进程 0 和进程 1 的 LDT 的 LIMIT 属性将进程 0 和进程 1 的地址空间限定0~640KB, 所以进程 0、 进程 1 均无法访问到这两个页面, 故两页面占用内核的线性地址空间。进程 0 的局部描述符如下:

//代码路径:boot\head.s
.align 2
setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl
	movl $pg0+7,_pg_dir		/* set present bit/user r/w */
	movl $pg1+7,_pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,_pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,_pg_dir+12		/*  --------- " " --------- */
	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

上面的代码,指明了内核的线性地址空间为0x000000~Oxffffff(即前16M),且线性地址与物理地址呈现一一对应的关系。为进程1分配的这两个页,在16MB的顶端倒数第一页、第二页,因此占用内核的线性地址空间。
进程0的线性地址空间是内存前640KB,因为进程0的LDT中的limit 属性限制了进程0能够访问的地址空间。进程1拷贝了进程0的页表(160项),而这160个页表项即为内核第一个页表的前160项,指向的是物理内存前640KB,因此无法访问到16MB的顶端倒数的两个页。
进程0创建进程1的时候,先后通过get_free_page函数从物理地址中取出了两个页,但是并没有将这两个页的物理地址填入任何新的页表项中。此时只有内核的页表中包含了与这段物理地址对应的项,也就是说此时只有内核页表中有页表项指向这两个页的首地址,所以这两个页占用了内核线性空间。

24、假设:经过一段时间的运行,操作系统中已经有5个进程在运行,且内核为进程4、进程5分别创建了第一个页表,这两个页表在谁的线性地址空间?用图表示这两个页表在线性地址空间和物理地址空间的映射关系。

这两个页面均占用内核的线性地址空间。既然是内核线性地址空间,则与物理地址空间为一一对应关系。根据每一个进程占用16个页目录表项,则进程4占用从第65~81项的页目录表项。同理,进程5占用第81~96项的页目录表项。因为目前只分配了一个页面(用作进程的第一个页表),则分别只须要使用第一个页目录表项便可。映射关系如图:

25、有以下代码,代码中的"ljmp %0\n\t" 很奇怪,按理说jmp指令跳转到得位置应该是一条指令的地址,可是这行代码却跳到了"m" (*&__tmp.a),这明明是一个数据的地址,更奇怪的,这行代码竟然能正确执行。请论述其中的道理。

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
   "je 1f\n\t" \
   "movw %%dx,%1\n\t" \
   "xchgl %%ecx,_current\n\t" \
   "ljmp %0\n\t" \
   "cmpl %%ecx,_last_task_used_math\n\t" \
   "jne 1f\n\t" \
   "clts\n" \
   "1:" \
   ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
   "d" (_TSS(n)),"c" ((long) task[n])); \
}

tmp.a 为偏移量,tmp.b 为段选择子。在上述代码中,CPU通过 movw %%dx,%1\n\t 指令,将当前寄存器的值保存到当前进程的TSS中,再将目标进程 n的TSS和LDT恢复给CPU的各寄存器,ljmp 加上 TSS 描述符的选择子tmp.b和偏移量tmp.a 就实现了进程的跳转。

26、进程0开始创建进程1,调用fork(),跟踪代码时我们发现,fork代码执行了两次,第一次,执行fork代码后,跳过init()直接执行了for(;😉 pause(),第二次执行fork代码后,执行了init()。奇怪的是,我们在代码中并没有看到向转向fork的goto语句,也没有看到循环语句,是什么原因导致fork反复执行?请说明理由(可以图示),并给出代码证据。

fork 为 inline 函数,其中调用了 sys_call0,产生 0x80 中断,将 ss, esp, eflags, cs, eip 压栈,其中 eip 为 int 0x80 的下一句的地址。在 copy_process 中,内核将进程 0 的 tss 复制得到进程 1 的 tss,并将进程 1 的 tss.eax 设为 0,而进程 0 中的 eax 为 1。在进程调度时 tss 中的值被恢复至相应寄存器中,包括 eip, eax 等。所以中断返回后,进程 0 和进程 1 均会从 int 0x80 的下一句开始执行,即 fork 执行了两次。

由于 eax 代表返回值,所以进程 0 和进程 1 会得到不同的返回值,在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 对进程状态的设置,以及利用了进程调度机制。
代码如下:

//代码路径:init/main.c
void main(void)	{
	......
	move_to_user_mode();
	if (!fork()) {//fork的返回值为1,if(!1)为假		/* we count on this going ok */
		init();//不会执行这一行
	}
//代码路径:include/unistd.h
int fork(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
	: "=a" (__res) \ //__res的值就是eax,是copy_process()的返回值last_pid(1)
	: "0" (__NR_##name)); \
if (__res >= 0) \ //iret后,执行这一行!__res就是eax,值是1
	return (type) __res; \ //返回1!
errno = -__res; \
return -1; \
}
//代码路径: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)
{
	struct task_struct *p;
	int i;
	struct file *f;

	p = (struct task_struct *) get_free_page();
	if (!p)
		return -EAGAIN;
	task[nr] = p;
	*p = *current;	/* NOTE! this doesn't copy the supervisor stack */
	p->state = TASK_UNINTERRUPTIBLE;
	p->pid = last_pid;
	p->father = current->pid;
	p->counter = p->priority;
	p->signal = 0;
	p->alarm = 0;
	p->leader = 0;		/* process leadership doesn't inherit */
	p->utime = p->stime = 0;
	p->cutime = p->cstime = 0;
	p->start_time = jiffies;
	p->tss.back_link = 0;
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;
	p->tss.eip = eip;
	p->tss.eflags = eflags;
	p->tss.eax = 0;
	p->tss.ecx = ecx;
	p->tss.edx = edx;
	p->tss.ebx = ebx;
	p->tss.esp = esp;
	p->tss.ebp = ebp;
	p->tss.esi = esi;
	p->tss.edi = edi;
	p->tss.es = es & 0xffff;
	p->tss.cs = cs & 0xffff;
	p->tss.ss = ss & 0xffff;
	p->tss.ds = ds & 0xffff;
	p->tss.fs = fs & 0xffff;
	p->tss.gs = gs & 0xffff;
	p->tss.ldt = _LDT(nr);
	p->tss.trace_bitmap = 0x80000000;
	if (last_task_used_math == current)
		__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
	if (copy_mem(nr,p)) {
		task[nr] = NULL;
		free_page((long) p);
		return -EAGAIN;
	}
	for (i=0; i<NR_OPEN;i++)
		if (f=p->filp[i])
			f->f_count++;
	if (current->pwd)
		current->pwd->i_count++;
	if (current->root)
		current->root->i_count++;
	if (current->executable)
		current->executable->i_count++;
	set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
	set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
	p->state = TASK_RUNNING;	/* do this last, just in case */
	return last_pid;
}

27、详细分析进程调度的全过程。考虑所有可能(signal、alarm除外)

①进程中有就绪进程,且时间片没有用完。

正常情况下,schedule()函数首先扫描任务数组。通过比较每个就绪(TASK_RUNNING)任务的运行时间递减滴答计数counter 的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,最后调用switch_to()执行实际的进程切换操作

②进程中有就绪进程,但所有就绪进程时间片都用完(c=0)

如果此时所有处于TASK_RUNNING 状态进程的时间片都已经用完,系统就会根据每个进程的优先权值priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值counter。计算的公式是:

counter = counter + priority/2

然后 schdeule()函数重新扫描任务数组中所有处于TASK_RUNNING 状态,重复上述过程,直到选择出一个进程为止。最后调用switch_to()执行实际的进程切换操作。

③所有进程都不是就绪的c=-1

此时代码中的c=-1,next=0,跳出循环后,执行switch_to(0),切换到进程0执行,因此所有进程都不是就绪的时候进程0执行。

28、分析panic函数的源代码,根据你学过的操作系统知识,完整、准确的判断panic函数所起的作用。假如操作系统设计为支持内核进程(始终运行在0特权级的进程),你将如何改进panic函数?

panic函数如下:

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

①panic()函数是当系统发现无法继续运行下去的故障时将调用它,会导致程序终止,然后由系统显示错误号。如果出现错误的函数不是进程0,那么就要进行数据同步,把缓冲区中的数据尽量同步到硬盘上。遵循了Linux尽量简明的原则。

②改进panic函数:将死循环for(;;)改进为跳转到内核进程(始终运行在0特权级的进程),让内核继续执行。

29、getblk函数中,申请空闲缓冲块的标准就是b_count为0,而申请到之后,为什么在wait_on_buffer(bh)后又执行if(bh->b_count)来判断b_count是否为0?

wait_on_buffer(bh)内包含睡眠函数,虽然此时已经找到比较合适的空闲缓冲块,但是可能在睡眠阶段该缓冲区被其他任务所占用,因此必须重新搜索,判断是否被修改,修改则写盘等待解锁。判断若被占用则重新repeat,继续执行if(bh->b_count)

30、b_dirt已经被置为1的缓冲块,同步前能够被进程继续读、写?给出代码证据。

同步前可以被进程读写,但不能挪为它用(即关联其它物理块)。b_dirt是针对硬盘方向的,进程与缓冲块方向由b_uptodate标识。只要b_uptodate为1,缓冲块就能被进程读写。读操作不会改变缓冲块中数据的内容,写操作后,改变了缓冲区内容,需要将b_dirt置1。由于此前缓冲块中的数据已经用硬盘数据块更新了,所以后续同步过程中缓冲块没有写入新数据的部分和原来硬盘对应的部分相同,所有的数据都是进程希望同步到硬盘数据块上的,不会把垃圾数据同步到硬盘数据库上去,所以b_uptodate仍为1。
所以,b_dirt为1,进程仍能对缓冲区进行读写。

代码证据如下:

①读写文件均与b_dirt无关

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

31、wait_on_buffer函数中为什么不用if()而是用while()?

答案1

被调度回来 b_lock 可能还没清零;
有可能被其他进程加了 b_lock。
答案2

因为可能存在一种情况是,很多进程都在等待一个缓冲块。在缓冲块同步完毕,唤醒各等待进程到轮转到某一进程的过程中,很有可能此时的缓冲块又被其它进程所占用,并被加上了锁。此时如果用if(),则此进程会从之前被挂起的地方继续执行,不会再判断是否缓冲块已被占用而直接使用,就会出现错误;而如果用while(),则此进程会再次确认缓冲块是否已被占用,在确认未被占用后,才会使用,这样就不会发生之前那样的错误

32、分析ll_rw_block(READ,bh)读硬盘块数据到缓冲区的整个流程(包括借助中断形成的类递归),叙述这些代码实现的功能。

33、分析包括安装根文件系统、安装文件系统、打开文件、读文件在内的文件操作。

34、在创建进程、从硬盘加载程序、执行这个程序的过程中,sys_fork、do_execve、do_no_page分别起了什么作用?

sys_fork 用于进程的创建,do_execve 用于加载并执行新的程序,而 do_no_page 用于处理程序执行过程中的缺页异常,确保所需的内存页面被正确加载。

  • 24
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值