linux0.11学习之启动主线要点(一)
引言
操作系统对于每位信息从业人员的重要性是不言而喻的,其中linux0.11对每个学习电子信息的人更显得致关重要,本文主要对linux0.11操作系统的关键运行逻辑做要点梳理,并对难理解的代码做一定阐释,其他细节之处有所忽略请大家参考更多资料。
bootsect.s加载
开机启动,移动自身
因为bios一般是直接放在bios的Rom中,计算机硬件开机启动的第一件事情就会去加载0x07c00 处的bios内容,当加载完成bootsect的相关内容后, 那么开机第一件事情要做什么呢?就是将自己移动到0x90000,刚加载的时候x86的cpu都默认采用16位模式且linux0.11默认的寻址范围是1M, 整个机器寻址范围为0x00000-0xfffff,完整的寻址地址 =段基地址<<4+偏移地址,其代码定义的地址如下:
SETUPLEN = 4 ! nr of setup-sectors
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = 0x9000 ! we move boot here - out of the way
SETUPSEG = 0x9020 ! setup starts here
SYSSEG = 0x1000 ! system loaded at 0x10000 (65536).
从上面可以出实际的地址和基地址,偏移地址之间的关系。此外,因为操作位数是16位且搬运256次,故而总共搬运512个字节,依照此种逻辑,bootsect将 自己搬运到0x90000处的代码如下:
_start:mov ax,#BOOTSEGmov ds,axmov ax,#INITSEGmov es,axmov cx,#256sub si,sisub di,direpmovw
图解也一并给出:
setup代码读取并载入
当上述代码执行完毕后,就开始要读取setup的相关的操作,其中的关键语句如下:
jmpi go,INITSEG
go: mov ax,cs mov ds,axmov es,ax
go语句所在之处就是被搬运到0x90000处的代码,jmpi 到0x90000 处开始执行加载setup的相关逻辑。
load_setup:mov dx,#0x0000 ! drive 0, head 0mov cx,#0x0002 ! sector 2, track 0mov bx,#0x0200 ! address = 512, in INITSEGmov ax,#0x0200+SETUPLEN ! service 2, nr of sectorsint 0x13 ! read itjnc ok_load_setup ! ok - continuemov dx,#0x0000mov ax,#0x0000 ! reset the disketteint 0x13j load_setup
上述代码开始加载磁盘中setup代码,从磁盘的第二个扇区加载。此处的SETUPLEN为4,故总共加载4个扇区数量,值得一提的是此处采用0x13中断加载磁盘上的setup代码,这也是非常科学的设计思路,同步读取磁盘耗时多,采用中断可大幅度提升系统运行效率,此外系统还采用0x10 显示中断来满足相关的字符串显示需求,下面为bootsect 整体运行完毕后的一个图解:
加载系统代码
setup代码加载完成后,陆续加载system代码到内存的0x10000处:需要连续加载240个扇区:
! ok, we've written the message, now
! we want to load the system (at 0x10000)mov ax,#SYSSEGmov es,ax ! segment of 0x010000call read_itcall kill_motor
read_it 函数中有比较详细的加载过程,此处不过多赘述,棋加载如下图所示意:
当加载system需要加载到0x10000的地址,此类做法是可避免bios 在16位时所建立的一些bios栈和中断向量表数据遭到覆盖而影系统接下来的运行。
setup代码执行
当bootsect万成自身移动,加载了系统的setup和system相关代码后,就开始跳转到setup代码执行,下面一句16位汇编指令是致关重要的。
jmpi 0,SETUPSEG
通过上述指令可直接将cpu从bootsect执行跳转到setup执行,setup主要任务有三件:
- 1、第一件要务是需要读取各个硬件的参数,如内存大小,显卡参数,其他硬盘和光驱等设备参数。
- 2、移动system模块到0x00000处。
- 3、从16进位入到保护模式(从16位向32位转变)
参数获取
参数获取主要是通过bios中断来获取,如0x10 为视频中断,用于在启动时打印信息。而0x15 为查询扩展内存大小、获取移动磁盘参数或者其他功能,需要根据子中断号来决定,当AH=0x88时,为获取内存参数,当AH=0f时,为获取移动盘参数,0x10也是一样的道理,具体可参考相关文献及数据手册,此处不过多赘述。
搬运system
首先可以思考一下为什么要搬运system代码,当移动代码的过程中的要点是什么?个人理解有如下三类起因:
- 1、代码紧凑,回收内存空间,在那个硬件资源稀缺的时代,这是非常有价值的做法。
- 2、逻辑严密,让各类有关内核的重要数据都放在内存最低位,更有利于软硬深度结合。
- 3、为从实际模式到保护模式做充足的准备。
知晓搬运system代码至0x00000的重要性,那就开始整个搬运工程的重难点阐述,首先在搬运system代码的过程中重点要注意的是需要关闭cpu中断,因为此时此刻系统并不具备处理其他中断的能力,下面是搬运代码的部分片段。
! now we want to move to protected mode ...cli ! no interrupts allowed !! first we move the system to it's rightful placemov ax,#0x0000cld ! 'direction'=0, movs moves forward
do_move:mov es,ax ! destination segmentadd ax,#0x1000cmp ax,#0x9000jz end_movemov ds,ax ! source segmentsub di,disub si,simov cx,#0x8000repmovswjmp do_move
上述代码的简易图解如下:
do_move函数都是比较重要且通俗易懂,此处就不做其他说明。经过上面的代码的执行,系统成功的将system.s移动到0x00000处,下面是整个引导过程中内存内容全局移动图解,图片是来自于猿友资料,此处引用一下,非常感谢。
从实模式向保护模式转变
8259中断控制芯片重新映射
当转变为保护模式时,由于0x00-0x1f的中断地址被intel 设置成不可屏蔽中断,故需要重新设置映射8259的中断向量地址,在实模式下0x00-0x1f号中断将被映射到0x20-0x3f中,保证相关中断能正常执行。
打开A20,跳转到system
打开A20地址线,系统的理论寻址范围从1M变成4G,虽然此时系统实际能操作的内存只有16M左右,但系统是完全具备4G的内存寻址能力。当转变过程执行完成后就开始使用系统临时构建的gdt,下面就开始从setup跳转到system所在的head.s去执行:
! Well, now's the time to actually move into protected mode. To make
! things as simple as possible, we do no register set-up or anything,
! we let the gnu-compiled 32-bit programs do that. We just jump to
! absolute address 0x00000, in 32-bit protected mode.mov ax,#0x0001 ! protected mode (PE) bitlmsw ax ! This is it!jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
jmpi 0, 8 将直接转到gdt表中,将gdt表中的第一项内容按规定的位数载入到相关寄存器,此处从第0项开始,其数据项如下图所示:
因为是word 占两个字节,且每项数据占64bit(8byte)所以jmpi 0, 8 将跳转到上图所示的第一项,其每个位数显示的内容如下:
代码段地址为0x00000000,即system的地址,如此设置正好跳转到system的首行代码,上述是通过GDT表来实现跳转,GDT表的作用是通过GDT固定的64位数据来实现相关寄存器赋值。
这里面值得一提的是关于jmpi 指令中的8的理解:
理解一
8要把他理解成二进制1000,其中00表示特权级(00表示内核级,11表示用户级别),第三位表示是使用GDT还是LDT(0表示GDT,1表示LDT), 第四位(0表示采用GDT或者LDT表的第0项,1表示采用第一项),同理如果第五不为0,那么应当和第四项结合使用(10表示GDT或者LDT表的第2项),这个是可以依次类推的。(参考杨力祥老师书)
理解二
不用上述那么麻烦,因为GDT表的数据站8位,jmpi 0, 8 直接跳到第一项数据所在的位置,同理,jmpi 0, 16 则直接跳转到临时GDT表的第二项位置。(参考李老师的教程)
理解一可能更为详细一些,上图所属的GDT数据项位第1项数据,表示的是将所有寄存器的内容设置成宇system 代码段相关,这样就可以实现代码段访问了,下面开始介绍head.s 中的相关代码。
system模块执行
system代码是由head.s和部分system模块中的c和汇编代码共同混合编译而来。而head.s的相关代码被编译放在了0地址处,总共大小为:25k+184b,而上文说了,jmpi 指令将跳到system的0地址处执行,即从head.s的第一行代码执行,但是为什么head.s是第一段呢?这个是和makefile编译链接树有密切关系,有兴趣可以参考哈工大李老师的操作系统教程,此处不过多赘述。
初始化新建的I/GDT表
前面使用了gdt表,为什么需要从新建立并初始化GDT?目前重新建立GDT是为了让保护模式下的GDT实际运转有效,而在setup中手动代码建立的GDT表是临时需要的,其段限长为0x7ff4KB=8MB,其主要目的是为了过渡到system代码里面来,并且实现保护模式,新建立的GDT表的段限长度为0xfff4KB=16MB。下面是系统开始建立gdt,ldt的相关代码:
startup_32:movl $0x10,%eaxmov %ax,%dsmov %ax,%esmov %ax,%fsmov %ax,%gslss stack_start,%espcall setup_idtcall setup_gdt
...
setup_gdt:lgdt gdt_descrret
gdt_descr:.word 256*8-1 # so does gdt (not that that's any.long gdt # magic number, but it works for me :^).align 8
...
gdt: .quad 0x0000000000000000 /* NULL descriptor */.quad 0x00c09a0000000fff /* 16Mb */.quad 0x00c0920000000fff /* 16Mb */.quad 0x0000000000000000 /* TEMPORARY - don't use */.fill 252,8,0 /* space for LDT's and TSS's etc */
通过设置ds,es,fs,gs 为0x10,ds为数据段寄存器,目前数据段寄存器已经指向0x10,按照本人的理解其效果是类似于 jmpi 0, 16,然后根据GDT表跳转到GDT所指向的数据段所在的地址,而后开始初始化新的gdt表,总共256项目,前4项是固定数据,后252项暂时用64位的0来填充。
idt也是类似的操作模式,具体请参考相关书籍,此处不再深究。
打开A20并设置CR0
cr0是一个非常重要的寄存器,cr0又称为数学协助处理器, 其结构和关键位如下所示。
通过以下代码可以快速设置cr0寄存器:
xorl %eax,%eax
1: incl %eax # check that A20 really IS enabledmovl %eax,0x000000 # loop forever if it isn'tcmpl %eax,0x100000je 1b
/** NOTE! 486 should set bit 16, to check for write-protect in supervisor* mode. Then it would be unnecessary with the "verify_area()"-calls.* 486 users probably want to set the NE (#5) bit also, so as to use* int 16 for math errors.*/movl %cr0,%eax # check math chipandl $0x80000011,%eax # Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */orl $2,%eax # set MPmovl %eax,%cr0call check_x87
如果没有开启保护模式,则CPU 的地址线是 20 位,最大寻址空间是 1MB (0xFFFFF)。当地址超过 1MB 时,会回绕到起始位置。例如:
- 访问 0x100000 会回绕到 0x000000。
- 访问 0x100001 会回绕到 0x000001。
可根据地址是否生效来判断到底有没有打开A20,如果打开了,就不会有上述的绕到起始位置,这样做的目的就是强制打开A20,当A20 设置完毕后就可以考虑设置PG,PE等状态来设置启动分页并和保护标志位。
设置分页并跳转到main
main相关参数压栈
下面是为开始跳转到main做最后的准备,这里比较有意思的是该段汇编代代码。
after_page_tables:pushl $0 # These are the parameters to main :-)pushl $0pushl $0pushl $L6 # return address for main, if it decides to.pushl $mainjmp setup_paging
L6:jmp L6 # main should never return here, but# just in case, we know what happens.
为什么说有意思呢,首先这段代码连续压了三次0,然后压了$L6,最后压了main的入口地址,这样设计必然是为了某个函数返回后自动跳到main的入口去,一般情况下main函数式不返回任何数,而如果当某些情况下返回,那就直接返回到L6了,而L6是一个无限循环,这样就保证系统可控,而压入栈的其他三个零则表示main的三个参数:
pushl $0 # envp (环境变量指针)
pushl $0 # argv (命令行参数数组指针)
pushl $0 # argc (参数计数)
分页设置
在上述代码手动设置完main函数的栈之后,系统开始执行分页初始化,其代码如下:
.align 2
setup_paging:movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */xorl %eax,%eaxxorl %edi,%edi /* pg_dir is at 0x000 */cld;rep;stoslmovl $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,%edimovl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */std
1: stosl /* fill pages backwards - more efficient :-) */subl $0x1000,%eaxjge 1bxorl %eax,%eax /* pg_dir is at 0x0000 */movl %eax,%cr3 /* cr3 - page directory start */movl %cr0,%eaxorl $0x80000000,%eaxmovl %eax,%cr0 /* set paging (PG) bit */ret /* this also flushes prefetch-queue */
页目录与页表项清零并设置属性
1、 此处略说,首先初始化页表和页目录,页目录有1024项,其他的4张页表也有1024项,总共有10245项,赋值给ecx,这样就确定了清除页帧的大小,直接循环向前清零直到10245个项全部完成了;
2、 后面就是设置每个页表的属性参数,7应该看成0111,其中:
- Bit 0 § - Present: 1,表示页表存在于内存中。
- Bit 1 (R/W) - Read/Write: 1,表示可读可写。
- Bit 2 (U/S) - User/Supervisor: 1,表示用户模式可访问。
下面是页目录的简易图解:
当页表目录中将页表有关的的属性参数设置成功后,就开始设置页表项,总共初始化的页地址范围是0x01000~0x4000,需要全部清零,执行完成下面的代码后,整个页表项的结果就如下所示:
3、设置CR0的页保护位的同时将页目录的地址赋给页指向寄存器CR3,如此页的初始化工作就算基本完毕了。
跳转main
上文已经讲述了在after_page_tables执行时候首先压入了5个参数,其对应关系如下所示:
ESP --> _main 的地址
ESP+4 --> L6 的地址
ESP+8 --> 0 (argc)
ESP+12 --> 0 (argv)
ESP+16 --> 0 (envp)
返栈跳转
当setup_paging代码段执行完成ret后, 执行过程回到了压参序列之后,此时,ESP 正指向我们之前压入的 _main 的地址, _main 的地址被ret指令弹出,所以 CPU 跳转到了 _main 函数,同时,ESP 现在指向了下一个值,即我们之前压入的 L6 的地址。
参数获取
main 函数访问参数:当 main 函数的代码试图访问它的第一个参数 argc 时,编译器生成的代码会去访问 [ebp+8] 的位置。而那里正好是我们之前压入的 0(模拟的 argc),同理,访问 argv 会去 [ebp+12],访问 envp 会去 [ebp+16],这样我们手动压入栈的所有参数都被正确的应用了,至此操作系统就成功的跳转到init模块中的main函数了。
总结
本章内容小猿为大家通俗易懂的阐述了linux0.11启动的整个过程,对中间的重难点内容进行了重点说明,下章节将继续深入学习main后面的系统进程建立和切换的相关要点,也是以简单明了的方式向大家阐述相关内容。
参考资料
哈工大 李治军老师 操作系统
国科大 杨力祥老师 linux内核设计的艺术 第2版
其他网络资料不再给出连接。