中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)

国科大操作系统高级教程思考题,参考书是《Linux内核设计的艺术-图解Linux操作系统架构设计与实现原理(第二版)》杨老师上课讲的特别棒,该文章供自己学习使用,有欠妥当的地方欢迎批评指正,参考往年学长学姐的文章,今年又有一些新题。

第一次思考题:

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

答:

计算机被设计为从内存中运行程序,无法直接从软盘或者硬盘中运行。最开始启动计算机的时候,计算机内存未初始化,没有任何程序。而因为CPU只能读取内存中的程序,所以必须将操作系统先加载进内存当中。需要使用BIOS。在加电后, BIOS 需要完成一些硬件检测工作,同时设置实模式下的中断向量表和服务程序,并将操作系统的引导扇区加载至 0x7C00 处,然后将跳转至 0x7C00运行操作系统自身的代码。BIOS程序存放在ROM中,ROM断电后也能保持信息,但一被烧就不能改变数据,适合存放BIOS这种不需要修改的例行工作。所以计算机启动最开始运行的是BIOS代码。

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

答:

BIOS和操作系统的开发通常是不同的团队,按固定的规则约定,可以进行灵活的各自设计相应的部分。BIOS接到启动操作系统命令后,只从启动扇区将代码加载至0x7c00(BOOTSEG)位置,而后续扇区由bootsect代码加载,这些代码由编写系统的用户负责,与之前BIOS无关。这样构建的好处是站在整个体系的高度,统一设计和统一安排,简单而有效。BIOS和操作系统的开发都可以遵循这一约定,灵活地进行各自的设计。例如,BIOS可以不用知道内核镜像的大小以及其在软盘的分布等等信息,减轻了BIOS程序的复杂度,降低了硬件上的开销。而操作系统的开发者也可以按照自己的意愿,内存的规划,等等都更为灵活。另外,如果要使用BIOS进行加载,而且加载完成之后再执行,则需要很长的时间,此外,对于不同的操作系统,其代码长度不一样,可能导致操作系统加载不完全。因此Linux采用的是边执行边加载的方法。

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

答:

加载0x07c00是BIOS提前约定设置的,不能加载到0x00000是因为从0x00000开始到0x003ff这1KB内存空间都是BIOS首先约定进行加载中断向量表的地方,不能进行覆盖。

(1)加载0x07c00是BIOS提前约定设置的,BIOS把bootsect加载到0x07c00而不是0x00000,是因为0x00000处存放着BIOS构建的1k大小的中断向量表和256B的BIOS数据区,这些数据还有用处,不能进行覆盖。

(2)加载后又挪到0x90000是因为,操作系统对内存的规划是在0x90000存放bootsect,然后bootsect执行结束之后,立即将系统机器数据存放在此处,这样就可以及时回收寿命结束的程序占据的内存空间。而且后续会把120K的系统模块存放到0x00000处,这会覆盖0x07c00处的代码和数据。

(3)不一次加载到位的原因是由于“两头约定”和“定位识别”,所以在开始时bootsect“被迫”加载到0X07c00位置。现在将自身移至0x90000处,说明操作系统开始根据自己的需要安排内存了。

下图为bootsect.s, setup.s, head.s代码执行过程中内存映像变化(这个图非常重要!!!!一定记住了)。

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第1张图片

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

解释这个问题之前先搞明白bootsect、setup、head都干了啥(作为扩展总结一下,大致浏览即可,杨老师书上写的已经很详细了)

bootsect:

  1. 自我迁移:加电自检后,BIOS将引导扇区加载到内存0x7C00处。bootsect.s 随后将自身代码从0x7C00移动到内存的0x90000处,为操作系统加载腾出空间。

  2. 加载 setup 模块:将磁盘上的 setup 模块(由 setup.s 编译而成)加载到内存中bootsect后面的位置,即0x90200。

  3. 读取磁盘参数:利用BIOS中断0x13,读取磁盘参数表中当前启动引导盘的参数。

  4. 显示加载信息:在屏幕上显示“Loading system…”字符串,提供用户反馈,表明系统正在加载。

  5. 加载 system 模块:从磁盘上将 system 模块加载到内存的0x10000处。这是内核映像的标准加载地址。

  6. 确定根文件系统设备号

    • 如果已指定设备号(root_dev不为0),则直接使用该设备号。
    • 如果未指定,根据引导盘的每磁道扇区数确定盘的类型,并保存相应的设备号到 root_dev(位于引导块的0x508地址处)。
  7. 执行 setup 程序:最后,执行跳转到 setup 程序的开始处(0x90200),以继续操作系统的启动过程。

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第2张图片

setup:

  1. 读取系统数据:利用ROM BIOS中断读取计算机的硬件和配置信息,并将这些信息保存到内存地址0x90000处,覆盖了原先的 bootsect 程序所在的位置。

  2. 移动 system 模块:将系统模块(内核映像)从0x10000-0x8ffff的位置整体移动到内存的0x00000处,这是为了准备将控制权交给操作系统。

  3. 加载IDTR和GDTR:设置中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR),为进入保护模式做准备。

  4. 开启A20地址线:为了访问超过1MB的内存,启用A20地址线是必要的步骤。

  5. 重新配置8259A中断控制器:将硬件中断号设置为0x20-0x2f,以避免与x86架构的软件中断号冲突。

  6. 进入32位保护模式:设置CPU的控制寄存器CR0,切换CPU到32位保护模式。

  7. 跳转到 head.s:在准备完毕后,跳转到 head.s 程序,这是内核执行的下一个阶段,head.s 在保护模式下运行。

  8. 设置临时的IDT和GDTsetup.s 中还包括了设置中断描述符表(IDT)和全局描述符表(GDT)的代码,其中还包括了当前内核代码段的描述符和数据段的描述符,以确保在切换到32位模式后CPU能够正确解析内核的地址空间。

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第3张图片

系统执行完setup之后内存映像:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第4张图片

head:

  1. 设置数据段寄存器:加载各个数据段寄存器,为后续程序的运行做准备。

  2. 初始化中断描述符表(IDT):重新设置IDT,共256项,每项都指向一个处理中断的程序。

  3. 重新设置全局描述符表(GDT):根据保护模式的要求,配置全局描述符表。

  4. 检测A20地址线:通过比较物理地址0和1MB处的内容,确保A20地址线已经开启,这是访问高于1MB内存地址的前提条件。

  5. 检测数学协处理器:检测PC机是否含有数学协处理器,并相应设置控制寄存器CR0中的标志位。

  6. 设置分页机制:配置内存分页处理机制,将页目录表放在物理地址0开始处,并设置后续的页表,使其能够寻址最多16MB的内存。

  7. 覆盖自身:由于页目录表被放置在head.s所在的内存位置,因此head.s程序会被覆盖。

  8. 跳转到main()函数:利用返回指令跳转到在/init/main.c程序中定义的main()函数,这标志着内核的高级初始化过程的开始。

系统执行完head之后内存映像:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第5张图片

答:

① bootsect→setup程序:jmpi 0,SETUPSEG;

bootsect首先利用int 0x13中断分别加载setup程序及system模块,待bootsect程序的任务完成之后,执行代码jmpi 0,SETUPSEG。由于 bootsect 将 setup 段加载到了 SETUPSEG:0 (0x90200)的地方,在实模式下,CS:IP指向setup程序的第一条指令,此时setup开始执行。

在bootsect程序中135-139行中有如下程序

//代码路径:boot\bootsect.s 135-139
! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:

	jmpi	0,SETUPSEG     !跳转到 0x9020:0000(setup.s 程序的开始处)!Tips:该程序到这已经完全结束了

而关于SETUPSEG的定义

//代码路径:boot\bootsect.s 34-39
SETUPLEN = 4					! nr of setup-sectors [setup 程序的扇区数(setup-sectors)值]
BOOTSEG  = 0x07c0			! original address of boot-sector[bootsect 的原始地址(是段地址,以下同)]
INITSEG  = 0x9000			! we move boot here - out of the way [将 bootsect 移到这里 -- 避开]
SETUPSEG = 0x9020			! setup starts here [setup 程序从这里开始]
SYSSEG   = 0x1000			! system loaded at 0x10000 (65536).[system 模块加载到 0x10000(64 kB)处]
ENDSEG   = SYSSEG + SYSSIZE		! where to stop loading [停止加载的段地址]

此时执行红线过程:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第6张图片

② setup→head程序:jmpi 0,8

执行setup后,内核被移到了0x00000处,系统进入了保护模式,执行jmpi 0,8

并加载了中断描述符表和全局描述符表lidt idt_48;1gdt gdt_48。在保护模式下,一个重要的特征就是根据GDT决定后续执行哪里的程序。该指令执行后跳转到以GDT第2项中的 base_addr 为基地址,以0为偏移量的位置,其中base_addr为0。由于head放置在内核的头部,因此程序跳转到head中执行。

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

此时为32位保护模式,“0”表示段内偏移,“8”表示段选择符。这里8要转化为二进制:1000,最后两位00表示内核特权级(若是11则表示用户),第三位0表示 GDT 表(若是1则表示LDT表),第四位1表示根据GDT中的第2项来确定代码段的段基址和段限长等信息。可以得到代码是从head 的开始位置,段基址 0x00000000、偏移为 0 处开始执行的,即head的开始位置。

关于jmpi 0,8的解释

jmpi    0,8  ! jmp offset 0 of segment 8 (cs) ! 跳转至cs段8,偏移0处。

此时我们已经将 system 模块移动到 0x00000开始的地方,所以这里的偏移地址是 0。这里的段值8是32位保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。 段选择符长度为16 位(2 字节);位 0-1 表示请求的特权级0-3,linux 操作系统只用到两级:0 级(系统级)和 3 级(用户级);位 2用于选择全局描述符表(0)还是局部描述符表(1);位 3-15 是描述符表项的索引,指出选择第几项描述符。所以段选择符8(0b0000,0000,0000,1000)表示请求特权级0、使用全局描述符表中的第1项,该项指出代码的基地址是0(参见 209行),因此这里的跳转指令就会去执行 system 中的代码。

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

(1) 保护模式在“保护”什么?它的“保护”体现在哪里?

保护操作系统的安全,不受到恶意攻击。保护进程地址空间。

“保护”体现在:打开保护模式后,CPU 的寻址模式发生了变化,基于 GDT 去获取代码或数据段的基址,相当于增加了一个段位寄存器。防止了对代码或数据段的覆盖以及代码段自身的访问超限,明显增强了保护作用。对描述符所描述的对象进行保护:在 GDT、 LDT 及 IDT 中,均有对应界限、特权级等,这是对描述符所描述的对象的保护;在不同特权级间访问时,系统会对 CPL、 RPL、 DPL、 IOPL 等进行检验,同时限制某些特殊指令如 lgdt, lidt,cli 等的使用;分页机制中 PDE 和 PTE 中的 R/W 和 U/S 等提供了页级保护,分页机制通过将线性地址与物理地址的映射,提供了对物理地址的保护。

(2)特权级的目的和意义是什么?

特权级机制目的是为了进行合理的管理资源,保护高特权级的段。其中操作系统的内核处于最高的特权级。

意义是进行了对系统的保护,对操作系统的“主奴机制”影响深远。Intel 从硬件上禁止低特权级代码段使用部分关键性指令,通过特权级的设置禁止用户进程使用 cli、 sti 等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问 GDT、 LDT、 TR,而 GDT、 LDT 是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址。而用户进程只能使用逻辑地址。总之,特权级的引入对操作系统内核进行保护。

(3)分页有“保护”作用吗?

分页机制有保护作用,使得用户进程不能直接访问内核地址,进程间也不能相互访问。用户进程只能使用逻辑地址,而逻辑地址通过内核转化为线性地址,根据内核提供的专门为进程设计的分页方案,由MMU非直接映射转化为实际物理地址形成保护。此外,通过分页机制,每个进程都有自己的专属页表,有利于更安全、高效的使用内存,保护每个进程的地址空间。

为什么特权级是基于段的?(超纲备用)

在操作系统设计中,一个段一般实现的功能相对完整,可以把代码放在一个段,数据放在一个段,并通过段选择符(包括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.ssetup_paging开始创建分页机制。将页目录表和4个页表放到物理内存的起始位置,从内存起始位置开始的5个页空间内容全部清零(每页4KB),然后设置页目录表的前4项,使之分别指向4个页表。然后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。即将第4个页表的最后一项指向寻址范围的最后一个页面。即从0xFFF000开始的4kb 大小的内存空间。将第4个页表的倒数第二个页表项指向倒数第二个页面,即0xFFF000-0x1000000开始的4KB字节的内存空间,依此类推。

挂接关系图:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第7张图片

总体效果图

代码证据:注意,页目录表需指向所有页表;页表须要指向所有页;页目录表、页表自己也是页。

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

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

线性地址最终要转换为物理地址。Linux 0.11在怠速前打开了PG,线性地址是通过页目录表——页表——页面三级映射模式,最终落实到物理地址的。在保护模式下,如果没有打开PG,线性地址恒等映射到物理地址;如果打开了PG,则线性地址需要通过MMU进行解析,以页目录表、页表、页面的三级映射模式映射到物理地址。(注意:这里是为了实现支持多进程执行,不是内核分页的机制)

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第8张图片

答:

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

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第9张图片

页表设置代码:(内核分页采用恒等映射模式,调用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的下一条指令。然而对操作系统的main 函数来说,如果用call 调用main函数,那么ret时返回给谁呢?在由head程序向main函数跳转时,是不需要main函数返回的;同时由于main函数已经是最底层的函数了,没有更底层的支撑函数支持其返回。用ret 实现的调用操作当然就不需要返回了,call做的压栈和跳转动作需要手工编写代码,模仿了call的全部动作,实现了调用setup_paging函数。压栈的EIP值不是调用setup_paging函数的下一行指令的地址,而是操作系统的main函数的执行入口地址,这样当setup_paging函数执行到ret时,从栈中将操作系统的main函数的执行入口地址_main自动出栈给EIP,EIP指向main函数的入口地址,实现了用返回指令调用main函数。所以要达到既调用main又不需返回,就不采用call而是选择了ret“调用”了。

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第10张图片

仿call示意图

//代码路径:boot\head.s
after_page_tables:
	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6		# return address for main, if it decides to.
	pushl $_main
	jmp setup_paging
L6:
	jmp L6			# main should never return here, but
				# just in case, we know what happens.

第二次思考题:

main函数调用关系图:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第11张图片

linux0.11线性地址空间使用分布图:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第12张图片

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

在 Linux 0.11 中,内核代码段和数据段的段基址实际上是相同的,都是 0x00000000;代码段和数据段的段限长设置为能够覆盖整个物理内存空间;特权级为0特权级

Linux 系统中虚拟地址空间分配图:

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第13张图片

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

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第14张图片

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

Linux规定,除了进程0外,所有进程都要由一个已有的进程在3特权级下创建,进程0此时处于0特权级。按照规定,在创建进程1之前要将进程0转变为3特权级。方法是调用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特权级去执行代码。

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

#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
	"pushl $0x17\n\t" \
	"pushl %%eax\n\t" \
	"pushfl\n\t" \
	"pushl $0x0f\n\t" \
	"pushl $1f\n\t" \
	"iret\n" \
	"1:\tmovl $0x17,%%eax\n\t" \
	"movw %%ax,%%ds\n\t" \
	"movw %%ax,%%es\n\t" \
	"movw %%ax,%%fs\n\t" \
	"movw %%ax,%%gs" \
	:::"ax")

iret 指令已经改变了 CPU 的特权级(0特权级->3特权级)。

iret之后的代码目的是为了设置用户模式下的各种段寄存器(如 DS、ES、FS、GS),这样做是因为当从内核模式切换到用户模式时,必须确保所有的段选择器都正确地设置为用户模式的段选择器。在 Linux 0.11 中,段选择器 0x17 代表用户模式数据段的选择器。

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

进程0的task_struct位于内核数据区,因为在进程0未激活之前,使用的是boot阶段的user_stack,因此存储在user_stack中。
具体内容:包含了进程 0 的进程状态、进程 0 的 LDT、进程 0 的 TSS 等等。其中 ldt 设置了代码段和堆栈段的基址和限长(640KB),而 TSS 则保存了各种寄存器的值,包括各个段选择符。

代码如下:

//进程0的task_struct的值
/*
 *  INIT_TASK is used to set up the first task table, touch at
 * your own risk!. Base=0, limit=0x9ffff (=640kB)
 */
#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, \
		{} \
	}, \
}

6、在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,这是为什么?说明理由。

set_trap_gate 和set_intr_gate的dpl是3,set_system_gate的dpl是0。dpl为0表示只能在内核态下允许,dpl为3表示系统调用可以由3特权级调用。
当用户程序产生系统调用软中断后, 系统都通过system_call总入口找到具体的系统调用函数。 set_system_gate设置系统调用,须将 DPL设置为 3,允许在用户特权级(3)的进程调用,否则会引发 General Protection 异常。set_trap_gate 及 set_intr_gate 设置陷阱和中断为内核使用,需禁止用户进程调用,所以 DPL为 0。

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

通过逆向扫描页表位图 mem_map,找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其置为1。ecx左移12位加LOW_MEM获得该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。代码如下:

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

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

copy_process执行是因为进程调用了fork函数创建进程,会执行“int 0x80”产生一个软中断,中断使CPU硬件自动将ss,esp,eflags,cs,eip这5个寄存器的值按顺序压入进程0内核栈,又因为函数传递参数是使用栈的,所以刚好可以作为copy_process的最后五个参数。

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

①对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

②对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

③对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第15张图片

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

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

①进程跨越到内核

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

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

11、

_system_call:
 cmpl $nr_system_calls-1,%eax
 ja bad_sys_call

分析后面两行代码的意义。

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

第三次思考题:

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

copy_process执行是因为进程调用了fork函数创建进程,会执行“int 0x80”产生一个软中断,中断使CPU硬件自动将ss,esp,eflags,cs,eip这5个寄存器的值按顺序压入进程0内核栈,又因为函数传递参数是使用栈的,所以刚好可以作为copy_process的最后五个参数。

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

通过逆向扫描页表位图 mem_map,找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其置为1。ecx左移12位加LOW_MEM获得该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。代码如下:

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

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

进入copy_page_tables函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里的前160个页表项复制到这个页面中(1个页表项控制一个页面4KB内存空间,160个页表项能够控制640KB内存空间)。进程0和进程1的页表暂时度指向了相同的页面,意味着进程1也能够操做进程0的页面。以后对进程1的页目录表进行设置。最后,用重置CR3的方法刷新页面变换高速缓存。进程1的页表和页目录表设置完毕。进程1此时是一个空架子,尚未对应的程序,它的页表又是从进程0的页表复制过来的,它们管理的页面彻底一致,也就是它暂时和进程0共享一套页面管理结构。

//代码路径:kernel/fork.c
int copy_mem(int nr,struct task_struct * p)
{
	......
	set_base(p->ldt[1],new_code_base);//设置子进程代码段基址
	set_base(p->ldt[2],new_data_base);//设置子进程数据段基址
	//为进程1创建第一个页表、复制进程0的页表,设置进程1的页目录项
	if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
			free_page_tables(new_data_base,data_limit);
			return -ENOMEM;
		}
		return 0;
}

//代码路径: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;
/*0x3fffff是4 MB,是一个页表的管辖范围,二进制是22个1,||的两边必须同为0,所以,from和to后22位必须都为0,即4 MB的整数倍,意思是一个页表对应4 MB连续的线性地址空间必须是从0x000000开始的4 MB的整数倍的线性地址,不能是任意地址开始的4 MB,才符合分页的要求*/
	if ((from&0x3fffff) || (to&0x3fffff))
		panic("copy_page_tables called with wrong alignment");
/*一个页目录项的管理范围是4 MB,一项是4字节,项的地址就是项数×4,也就是项管理的线性地址起始地址的M数,比如:0项的地址是0,管理范围是0~4 MB,1项的地址是4,管理范围是4~8 MB,2项的地址是8,管理范围是8~12MB……>>20就是地址的MB数,&0xffc就是&111111111100b,就是4 MB以下部分清零的地址的MB数,也就是页目录项的地址*/
	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;
}

4、进程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函数从物理地址中取出了两个页,但是并没有将这两个页的物理地址填入任何新的页表项中。此时只有内核的页表中包含了与这段物理地址对应的项,也就是说此时只有内核页表中有页表项指向这两个页的首地址,所以这两个页占用了内核线性空间。

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

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

中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)_第16张图片

6、

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

代码中的"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)语句。

其中a对应EIP,b对应CS,ljmp此时通过CPU中的电路进行硬件切换,进程由当前进程切换到进程n。CPU将当前寄存器的值保存到当前进程的TSS中,将进程n的TSS数据及LDT的代码段和数据段描述符恢复给CPU的各个寄存器,实现任务切换。

7、进程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;
}

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

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

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

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

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

    counter = counter + priority/2

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

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

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

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

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

//代码路径:kernel/panic.c
#include 
#include 
void sys_sync(void);	/* it's really int */
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(;;);**
}

你可能感兴趣的:(操作系统,linux)