国科大操作系统高级教程思考题,参考书是《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代码执行过程中内存映像变化(这个图非常重要!!!!一定记住了)。
4.bootsect、setup、head程序之间是怎么衔接的?给出代码证据。
解释这个问题之前先搞明白bootsect、setup、head都干了啥(作为扩展总结一下,大致浏览即可,杨老师书上写的已经很详细了)
bootsect:
-
自我迁移:加电自检后,BIOS将引导扇区加载到内存0x7C00处。
bootsect.s
随后将自身代码从0x7C00移动到内存的0x90000处,为操作系统加载腾出空间。 -
加载
setup
模块:将磁盘上的setup
模块(由setup.s
编译而成)加载到内存中bootsect
后面的位置,即0x90200。 -
读取磁盘参数:利用BIOS中断0x13,读取磁盘参数表中当前启动引导盘的参数。
-
显示加载信息:在屏幕上显示“Loading system…”字符串,提供用户反馈,表明系统正在加载。
-
加载
system
模块:从磁盘上将system
模块加载到内存的0x10000处。这是内核映像的标准加载地址。 -
确定根文件系统设备号:
- 如果已指定设备号(
root_dev
不为0),则直接使用该设备号。 - 如果未指定,根据引导盘的每磁道扇区数确定盘的类型,并保存相应的设备号到
root_dev
(位于引导块的0x508地址处)。
- 如果已指定设备号(
-
执行
setup
程序:最后,执行跳转到setup
程序的开始处(0x90200),以继续操作系统的启动过程。
setup:
-
读取系统数据:利用ROM BIOS中断读取计算机的硬件和配置信息,并将这些信息保存到内存地址0x90000处,覆盖了原先的
bootsect
程序所在的位置。 -
移动
system
模块:将系统模块(内核映像)从0x10000-0x8ffff的位置整体移动到内存的0x00000处,这是为了准备将控制权交给操作系统。 -
加载IDTR和GDTR:设置中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR),为进入保护模式做准备。
-
开启A20地址线:为了访问超过1MB的内存,启用A20地址线是必要的步骤。
-
重新配置8259A中断控制器:将硬件中断号设置为0x20-0x2f,以避免与x86架构的软件中断号冲突。
-
进入32位保护模式:设置CPU的控制寄存器CR0,切换CPU到32位保护模式。
-
跳转到
head.s
:在准备完毕后,跳转到head.s
程序,这是内核执行的下一个阶段,head.s
在保护模式下运行。 -
设置临时的IDT和GDT:
setup.s
中还包括了设置中断描述符表(IDT)和全局描述符表(GDT)的代码,其中还包括了当前内核代码段的描述符和数据段的描述符,以确保在切换到32位模式后CPU能够正确解析内核的地址空间。
系统执行完setup
之后内存映像:
head:
-
设置数据段寄存器:加载各个数据段寄存器,为后续程序的运行做准备。
-
初始化中断描述符表(IDT):重新设置IDT,共256项,每项都指向一个处理中断的程序。
-
重新设置全局描述符表(GDT):根据保护模式的要求,配置全局描述符表。
-
检测A20地址线:通过比较物理地址0和1MB处的内容,确保A20地址线已经开启,这是访问高于1MB内存地址的前提条件。
-
检测数学协处理器:检测PC机是否含有数学协处理器,并相应设置控制寄存器CR0中的标志位。
-
设置分页机制:配置内存分页处理机制,将页目录表放在物理地址0开始处,并设置后续的页表,使其能够寻址最多16MB的内存。
-
覆盖自身:由于页目录表被放置在
head.s
所在的内存位置,因此head.s
程序会被覆盖。 -
跳转到
main()
函数:利用返回指令跳转到在/init/main.c
程序中定义的main()
函数,这标志着内核的高级初始化过程的开始。
系统执行完head
之后内存映像:
答:
① 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 [停止加载的段地址]
此时执行红线过程:
② 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.s
在setup_paging
开始创建分页机制。将页目录表和4个页表放到物理内存的起始位置,从内存起始位置开始的5个页空间内容全部清零(每页4KB),然后设置页目录表的前4项,使之分别指向4个页表。然后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。即将第4个页表的最后一项指向寻址范围的最后一个页面。即从0xFFF000
开始的4kb 大小的内存空间。将第4个页表的倒数第二个页表项指向倒数第二个页面,即0xFFF000-0x1000000
开始的4KB字节的内存空间,依此类推。
挂接关系图:
总体效果图
代码证据:注意,页目录表需指向所有页表;页表须要指向所有页;页目录表、页表自己也是页。
//代码路径: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进行解析,以页目录表、页表、页面的三级映射模式映射到物理地址。(注意:这里是为了实现支持多进程执行,不是内核分页的机制)
答:
内核分页采用线性地址恒等映射。内核的段基址是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的下一条指令。然而对操作系统的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“调用”了。
仿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函数调用关系图:
linux0.11线性地址空间使用分布图:
1、计算内核代码段、数据段的段基址、段限长、特权级。
在 Linux 0.11 中,内核代码段和数据段的段基址实际上是相同的,都是 0x00000000
;代码段和数据段的段限长设置为能够覆盖整个物理内存空间;特权级为0特权级
Linux 系统中虚拟地址空间分配图:
2、计算进程0的代码段、数据段的段基址、段限长、特权级。
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毫秒发生一次时钟中断。
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项的页目录表项。因为目前只分配了一个页面(用作进程的第一个页表),则分别只须要使用第一个页目录表项便可。映射关系如图:
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除外)
-
进程中有就绪进程,且时间片没有用完。
正常情况下,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执行。
9、分析panic函数的源代码,根据你学过的操作系统知识,完整、准确的判断panic函数所起的作用。假如操作系统设计为支持内核进程(始终运行在0特权级的进程),你将如何改进panic函数?
panic()函数是当系统发现无法继续运行下去的故障时将调用它,会导致程序终止,然后由系统显示错误号。如果出现错误的函数不是进程0,那么就要进行数据同步,把缓冲区中的数据尽量同步到硬盘上。遵循了Linux尽量简明的原则。
改进panic函数:将死循环for(;😉;改进为跳转到内核进程(始终运行在0特权级的进程),让内核继续执行。
//代码路径:kernel/panic.c
#include <linux/kernel.h>
#include <linux/sched.h>
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(;;);**
}