mbr的运行过程(一步步编写操作系统)
本节我们在之前mbr的基础上,做个稍微大一点的改进,经过这个改进后,我们的MBR可以读取硬盘。听上去这可是个大“手术”呢,我们要将之前学过的知识都用上啦。其实没那么大啦,就是加了个读写磁盘的函数而已,哈哈。怀着兴奋与忐忑的心情,咱们开始吧。
改造不是乱改的,在改之前要有个计划,对将来的程序布局要有个规划,心里有数才行。先说说目前的想法。
我们的MBR是受限于512字节大小的,在那么小的空间中,没法为内核准备好环境,更没法将内核成功加载到内存并运行。所以我们要在另一个程序中完成初始化环境及加载内核的任务,这个程序我们称之为loader,即加载器。Loader会在下一节中实现。问题来了,loader在哪里?如何跳过去执行?这就是新款MBR的使命,简而言之就是,负责从硬盘上把loader加载到内存,并将接力棒交给它。
由于MBR是占据了硬盘的第0扇区(以逻辑LBA方式,扇区从0开始编号,若是以物理CHS方式,扇区则从1开始编号),第1扇区是空闲的,可以用,但离得太近总感觉不如隔开一点心里踏实,所以把loader放到第2扇区。MBR从第2扇区中把它读出来。读出来放到哪里呢?原则上是找个空闲地方就行了,0x500~0x7BFF和0x7E00~9FBFF这两段内存区域都可以。
是这样的,容小弟分析一下:
首先,loader中要定义一些数据结构(如GDT全局描述符表,不懂没关系,以后会说),这些数据结构将来的内核还是要用的,所以loader加载到内存后不能被覆盖。
其次,随着咱们不断添加功能,内核必然是越来越大,其所在的内存地址也会向越来越高的地方发展,难免会超过可用区域的上限,咱们尽量把loader放在低处,多留出一些空间给内核。
所以,我将loader的加载地址选为0x900。为什么不是0x500,这个多省空间。还是预留出一定空间吧,彼此隔开远一点心里才踏实,不差这点空间了,哈哈,完全是个人偏好,大家随意啦。
按照上面所说的规划,下面代码就是改头换面的新款MBR。代码量增长到126行,下面给大家说说细节:
1 ;主引导程序 2 ;------------------------------------------------------------ 3 %include "boot.inc" 4 SECTION MBR vstart=0x7c00 5 mov ax,cs 6 mov ds,ax 7 mov es,ax 8 mov ss,ax 9 mov fs,ax 10 mov sp,0x7c00 11 mov ax,0xb800 12 mov gs,ax 13 14 ; 清屏 15 ;利用0x06号功能,上卷全部行,则可清屏。 16 ; ----------------------------------------------------------- 17 ;INT 0x10 功能号:0x06 功能描述:上卷窗口 18 ;------------------------------------------------------ 19 ;输入: 20 ;AH 功能号= 0x06 21 ;AL = 上卷的行数(如果为0,表示全部) 22 ;BH = 上卷行属性 23 ;(CL,CH) = 窗口左上角的(X,Y)位置 24 ;(DL,DH) = 窗口右下角的(X,Y)位置 25 ;无返回值: 26 mov ax, 0600h 27 mov bx, 0700h 28 mov cx, 0 ; 左上角: (0, 0) 29 mov dx, 184fh ; 右下角: (80,25), 30 ; 因为VGA文本模式中,一行只能容纳80个字符,共25行。 31 ; 下标从0开始,所以0x18=24,0x4f=79 32 int 10h ; int 10h 33 34 ; 输出字符串:MBR 35 mov byte [gs:0x00],1 36 mov byte [gs:0x01],0xA4 37 38 mov byte [gs:0x02], 39 mov byte [gs:0x03],0xA4 40 41 mov byte [gs:0x04],M 42 mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景色为红色 43 44 mov byte [gs:0x06],B 45 mov byte [gs:0x07],0xA4 46 47 mov byte [gs:0x08],R 48 mov byte [gs:0x09],0xA4 49 50 mov eax,LOADER_START_SECTOR ; 起始扇区lba地址 51 mov bx,LOADER_BASE_ADDR ; 写入的地址 52 mov cx,1 ; 待读入的扇区数 53 call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区) 54 55 jmp LOADER_BASE_ADDR 56 57 ;------------------------------------------------------------------------------- 58 ;功能:读取硬盘n个扇区 59 rd_disk_m_16: 60 ;------------------------------------------------------------------------------- 61 ; eax=LBA扇区号 62 ; bx=将数据写入的内存地址 63 ; cx=读入的扇区数 64 mov esi,eax ;备份eax 65 mov di,cx ;备份cx 66 ;读写硬盘: 67 ;第1步:设置要读取的扇区数 68 mov dx,0x1f2 69 mov al,cl 70 out dx,al ;读取的扇区数 71 72 mov eax,esi ;恢复ax 73 74 ;第2步:将LBA地址存入0x1f3 ~ 0x1f6 75 76 ;LBA地址7~0位写入端口0x1f3 77 mov dx,0x1f3 78 out dx,al 79 80 ;LBA地址15~8位写入端口0x1f4 81 mov cl,8 82 shr eax,cl 83 mov dx,0x1f4 84 out dx,al 85 86 ;LBA地址23~16位写入端口0x1f5 87 shr eax,cl 88 mov dx,0x1f5 89 out dx,al 90 91 shr eax,cl 92 and al,0x0f ;lba第24~27位 93 or al,0xe0 ; 设置7~4位为1110,表示lba模式 94 mov dx,0x1f6 95 out dx,al 96 97 ;第3步:向0x1f7端口写入读命令,0x20 98 mov dx,0x1f7 99 mov al,0x20 100 out dx,al 101 102 ;第4步:检测硬盘状态 103 .not_ready: 104 ;同一端口,写时表示写入命令字,读时表示读入硬盘状态 105 nop 106 in al,dx 107 and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输, ;第7位为1表示硬盘忙 108 cmp al,0x08 109 jnz .not_ready ;若未准备好,继续等。 110 111 ;第5步:从0x1f0端口读数据 112 mov ax, di 113 mov dx, 256 114 mul dx 115 mov cx, ax ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字, 116 ; 共需di*512/2次,所以di*256 117 mov dx, 0x1f0 118 .go_on_read: 119 in ax,dx 120 mov [bx],ax 121 add bx,2 122 loop .go_on_read 123 ret 124 125 times 510-($-$$) db 0 126 db 0x55,0xaa
程序最开始的%include "boot.inc",这个%include是nasm编译器中的预处理指令,意思是让编译器在编译之前把boot.inc文件包含了进来。任何编译器都应该有include之类的能够包含其它文件的预处理指令,不要认为底层的汇编语言就应该简陋到一穷二白,哈哈,这和语言是没关系的,是编译器为了开发人员方便管理代码,应该加的。boot.inc的内容很简单,目前就两句话,文件内容如下:
1 ;------------- loader和kernel ---------- 2 LOADER_BASE_ADDR equ 0x900 3 LOADER_START_SECTOR equ 0x2
boot.inc是我们的配置文件,我们目前关于加载器的配置信息就写在里面,今后还会在此添加更多的配置信息。大家看到的这两句也是预处理命令,是nasm提供的宏,和c语言中的宏是一回事。只不过nasm中的语法是:宏名 equ 值,而c语言中的宏是由#define指令来实现的。所以LOADER_BASE_ADDR和LOADER_START_SECTOR是两个宏名。
LOADER_BASE_ADDR是定义了loader在内存中的位置,MBR要把loader从硬盘读入后放到此处。如前所述,它的值是0x900,说明将来loader会在内存地址0x900处。
LOADER_START_SECTOR是定义了loader在硬盘上的逻辑扇区地址,即LBA地址。前面和大家交待过啦,它等于0x2,说明loader是放在了第2块扇区。
接下来的第4~48行和上一版本没区别,不用多说啦。
第50~52行是为函数rd_disk_m_16传递参数。在此说明一下,汇编语言中定义的函数(或者称为例程,proc),由于汇编语言能够直接操作寄存器,所以其传递参数可以用寄存器,也可以用栈。由于c语言中不能直接操作寄存器,所以咱们这里体验一回用寄存器来传递参数的函数是怎样实现的。另外再说明一下,用寄存器传参数,没有固定的形式,原则上用哪个寄存器都行,只要根据实际应用,别把还有用的寄存器值给覆盖就行,如果真需要用到某个正在使用中的寄存器,只要提前把该寄存器备份好就行了,如备份到其它寄存器或夺入栈中。此函数需要三个参数,我们选择用eax,bx,cx寄存器来传递参数。
在寄存器eax中的是待读入的扇区起始地址,赋值后eax为定义的宏LOADER_START_SECTOR,即0x2。
寄存器cx是读入的扇区数,cx其值为1。到底读入几个扇区,是由实际文件大小来决定的。由于将来会写一个简单的loader,其大小肯定不会超过512字节,所以此处读入的扇区数置为1即可。
数据从硬盘读进来后放在内存中哪里呢,这就要用寄存器bx来指定。在这里,bx寄存器值为LOADER_BASE_ADDR,即0x900。函数名rd_disk_m_16的意思是“在16位模式下读硬盘”。此函数是咱们本节的重点,大伙儿一定要拿下。
第64行的“mov esi,eax”是把eax中的值先备份到esi中。因为al在out指令中会被用到,这会影响到eax的低8位。
第65行是备份读取的扇区数到di寄存器,di寄存器是16位的,和cx大小一致。cx的值会在读取数据时用到,所以在此提前备份。
第67~70行,按照咱们操作硬盘的约定,先选定一个通道,再往sector count寄存器中写扇区数。往端口中写入数据是用out指令,注意out指令中dx寄存器是用来存储端口号。
咱们的虚拟硬盘属于ata0,是Primary通道,所以其sector count寄存器是由0x1f2端口来访问的。顺便再看第二行的ata0-master,path=”hd60M.img”,这说明hd60M.img是主盘。
第74~95行是将LBA地址写入三个LBA 寄存器和device寄存器的低4位。端口0x1f3是寄存器LBA low,端口0x1f4是寄存器LBA mid,端口0x1f5是寄存器LBA high。shr指令是逻辑右移指令,这里主要是通过此指令置换出地址的相应部分,写入相应的LBA寄存器。第93行的“or al,0xe0”,用了or“或”指令和0xe0做或运算,拼出device寄存器的值。高4位为e,即高4位的2进制表示为1110,其第5位和第7位固定为1,第6位为1表示启用LBA。大家可以参考注释。
第97~100行便是写入命令啦,因为我们这里是读操作,所以读扇区的命令是0x20。通过out指令写入command端口0x1f7后,硬盘就开始工作了。
第102~109行是检测status寄存器的BSY位。由于status寄存器依然是0x1f7端口,所以不需要再为dx重新赋值。105行的nop表示空操作,即什么了也不做,只是为了增加延迟,相当于sleep了一小下,目的是减少打扰硬盘的工作。对同一端口在读写两种操作时有不同的用途,在读硬盘时,此端口中的值是硬盘的工作状态。第106行是将Status寄存器的值读入到al寄存器,通过第107行的and“与”操作,保留第4位和第7位,第4位若为1,表示数据已经准备好,可以传输了。若第7位为1,表示硬盘现在正忙着。只要判断第4位是否为1就好了,用第108行的cmp指令和0x08做减法运算,判断第4位是否为1。cmp指令并不改变操作数的值,只是根据结果去设置标志位,从而咱们根据标志位反着去判断结果。cmp指令会影响的标志位有ZF,CF,PF等,这里咱们借助ZF位来判断cmp的结果。于是用第109行的jnz .not_ready来判断结果是否不等于0,即若等于0,则status寄存器的第4位为1,这表示只可以读数据了。若不等于0,说明status寄存器的第4位为0,表示硬盘正忙(此时status寄存器第7位肯定为1)。.not_ready是个标号,于是跳回去继续判断硬盘状态,直到硬盘把数据准备好才跳出这个循环。
第111行~122行是从硬盘取数据的过程。由于data寄存器是16位,即每次in操作只读入2字节,根据读入的数据总量(扇区数*512字节)来求得执行in指令的次数。这里的乘法是用mul指令,在实模式下,mul指令可以做8位乘法和16位乘法,格式是:mul 操作数。操作数可以是寄存器或内存。乘法运算至少要有两个数参与才行,这里的操作数只是一个乘数,被乘数隐含在al或ax寄存器中(mul指令被设计成这样的,由于历史原因产生很多奇怪的用法,习惯就好啦)。如果操作数是8位,被乘数就是al寄存器的值,乘积就是16位,位于ax寄存器。如果操作数是16位,被乘数就是ax寄存器的值,乘积就是32位,积的高16位在dx寄存器,积的低16位在ax寄存器。
虽然我们进行的是16位的乘法,其结果是32位,但由于我知道这两个乘数ax的值和dx的值都不大,ax的实际的值其实是1,乘出来的这个结果,其高位是0,所以在第115行的“mov cx, ax”我们只将这个结果的低16位移入cx做为循环读取的次数。此处用8位乘法不合适,因为256超过了8位寄存器表示的范围。在第118~122行通过循环来将数据写入bx寄存器指向的内存,每读入2个字节,bx所指的地址便 2。值得注意的是,由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。待写入的地址超过bx的范围时,从硬盘上读出的数据会把0x0000~0xffff的覆盖,所以此处加载的程序不能超过64k,即2的16次方等于65536。由于本mbr是用来加载loader的,所以loader.bin要小于64k才行。这一点大可以放心,我们最终的loader不超过2k,将来的内核也不会超过70k。
也许有同学会说,把bx改为ebx行吗?也不行,在实模式下,cpu依然会用16位偏移地址。这是实模式下访问内存的规定与缺陷,还记得那个“段基址 段内偏移地址”吗。段内偏移地址正因为是16位,只能访问64k的段空间,所以才将段基址乘以16来突破这64k,从而实现访问低调1M空间的。
第123行是返回指令ret,它是用来从函数中返回。如果我们没有定义函数,就不需要它了。函数和一般代码相比,就是在被调用时,cpu会将返回地址压到栈中,所以在函数体中,要用ret指令将栈中的返回地址重新加载到程序计数器中,如cs:ip,这样程序便恢复到之前的执行顺序了。
执行完第123行后,程序便 回到了第55行,这是个跳转的指令。个人觉得,jmp指令和call指令是必不可少的,jmp表示一去不回头,call表示去了还回来。各有各的用途。这里是MBR交出接力棒的一刻,采用jmp是唯一合适的选择。Jmp的操作数是LOADER_BASE_ADDR,即0x900,这是要跳到内核加载器的节奏。MBR到此结束了使命,顺序完成了第二棒的拼接。复习一下,第一棒是谁来着?是bios交给了MBR。
接下来的工作是编译,本次的编译较之前相比,多加了一个参数 -I。此参数的意思还是先见nasm帮助,nasm –h回车,找到-I的说明:
“-I<path> adds a pathname to the include file path”,
大概意思是添加一个包含文件的路径,其实就是添加个库目录。为了目录整洁一些,我在boot目录下建立了个子目录include,并把boot.inc放到了include目录下。所以nasm的编译参数是,在boot目录下输入:
nasm -I include/ -o mbr.bin mbr.S回车
接下来用dd命令将mbr.bin写入虚拟硬盘:dd if=./mbr.bin of=/此处替换成你的安装目录/bochs/hd60M.img bs=512 count=1 conv=notrunc 回车,下面是dd命令的三行输出:
记录了1 0 的读入
记录了1 0 的写出
512字节(512 B)已复制,0.0265972 秒,19.3 kB/秒
dd命令输出的第三行显示了实际写入硬盘的数据大小,是512字节。
现在还没有准备好loader,所以目前不宜执行。如果好奇心实在太大了,可以运行一下试试,反正只是虚拟机,对物理机不会有伤害,也许会cpu使用率过高。记得用ctrl c在bochs控制台中断运行就好了。
说了半天咱们还没有loader呢,若此时执行此MBR,cpu会直接跳到0x900的地方,非乱了不可,程序的运行不可预测。难为大家一直跟我在这假想这个虚幻的loader,下一节我们要实现个真的loader啦。
MBR大致就说到这,大家若是不理解,也不要糊弄自己,还是建议大家一行一行地看,直到弄清楚为止。代码写的不美,请大家多多包含。
【真累,再续】
,免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。