0x01 概述
在上一章中已经将环境搭建好了,接下来就开始进行操作系统编写的工作了。
编写操作系统的第一步当然是编写一个boot程序来将操作系统内核加载到系统中。
在BIOS刚将CPU初始化时,是没有操作系统带来的各种好处的。这时候没有文件系统、没有内存管理、没有任务调度,有的只是一个处于实模式的CPU,能做的就是使用这个处于实模式的CPU想办法讲操作系统内核加载到内存并执行。
早期的引导模式为Boot引导Loader,再由Loader加载操作系统,这是受当时的磁盘所限。现在一般用的都是以bootloader的方式直接加载操作系统。现在手机,甚至一般的单片机用的一般都是bootloader。但是为了从简到繁,还是要先学习boot + loader的方式来引导操作系统。
注:内容参考了《一个64位操作系统的设计与实现》,有兴趣的读者可以购买这本书阅读。
0x02 设计
这一章的目的是实现一个简单的boot程序,在屏幕上打印一个提示正在引导的字符串。
将使用Intel风格的汇编语言来实现,用NASM编译器编译,并将其写入到之前生成的boot.img中,使用bochs来模拟执行。
0x03 实现
首先是两个伪指令。
org 0x7c00
BaseOfStack equ 0x7c00
org指明程序的入口在0x7c00处,因为BIOS在初始化完CPU后会将CS:IP设置为0000:7c00,也就是从内存的0x7c00处开始执行。指定org 0x7c00之后,程序将会被装载到内存0x7c00处,否则将默认装载到0x0000处。至于地址长度问题,由于现在处于实模式,寻址是16位寻址,所以地址最高到0xffff。
BaseOfStack伪指令在后面用来初始化sp栈寄存器,让栈从0x7c00开始增长。由于栈是从高地址向低地址增长的,所以在正常情况下不用担心栈将代码覆盖的问题(在操作系统ring0层操作栈都得小心翼翼的,更何况在实模式下)。
之后对寄存器进行初始化。
; init registers
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, BaseOfStack
这里的目的很简单,用cs段寄存器的值来初始化ds,es及ss,然后用0x7c00来初始化sp。
接下来就开始打印字符串了。
从上一章尝试运行bochs的时候能看出来,在bochs将控制权交到0x7c00之前,已经打印了很多信息了,屏幕上很乱。因此,在打印新的字符串之前,需要先将屏幕清空。清空屏幕的代码如下。
; clear screen
; AH = 06h roll pages
; AL = page num (0 to clear screen)
; BH = color attributes
; CL = left row, CH = left column
; DL = right row, DH = right column
mov ax, 0600h
mov bx, 0700h
mov cx, 0
mov dx, 184Fh
int 10h
这里用到的是0x10号中断,也就是int 10h。在没有操作系统的系统调用带来的好处时,各种功能的实现都需要用到中断,之后也会大量使用中断。各中断的用法可以查阅处理器对应的文档。
0x10号中断用AH来标记功能。这里用到的0x06是滚动屏幕的功能,同时也能用它来清除屏幕。AL传的值是滚动的页数,传0的话就能清除屏幕。BH传的是颜色信息。CL和DL制定行数,CH和DH制定列数。其实这CX和DX分别标记了左上角和右下角的点,以它们的连线为对角线的话就形成了一个矩形。
这里直接传0x0600给ax,这样AL=0x00,AH=0x06。传0x0700给bx,BL=0x00,BH=0x07,然后给cx传值0x0000,代表左上角坐标为(00,00),给DX传值0x184F,代表右下角坐标为(4F,18)。
传完参数后使用int 10h来触发中断。
接着,继续使用0x10号中断来设置光标。
; set focus
; AH = 02h set focus
; DL = row
; DH = column
; BH = page num
mov ax, 0200h
mov bx, 0000h
mov dx, 0000h
int 10h
这里的AH为0x02,说明功能位设置光标。传的值及作用在注释里都提到了。
接下来就是打印字符串了。打印字符串用到的也是0x10号中断,传值情况如下。
; display boot string (int 10h)
; AH = 13h display a string
; AL = 01h display mode
; CX = StringLen
; DH = row, DL = column
; ES:BP = String adress
; BH = page num
; BL = text attributes
AH传的功能号为0x13,意思是打印字符串。AL传的是显示模式,模式分为cx指定单位是byte还是word、显示完之后光标显示在前面还是在后面。具体可以查阅文档。这里用到的0x01意思是cx传的单位是byte,打印完字符串后光标在字符串末尾。CX传的是字符串长度。DX制定行列号。BP传要打印的字符串地址。BH指定打印的页码,BL制定打印文字的属性。
由于要显示字符串的地方不少,我实现了一个简单地在制定行列数上打印白色字符串的函数,方便后续字符串的打印。
; Print a string on screen
; Parms:
; Stack: StringAddress, StringLength, ColRow
; Return:
; No return
Func_PrintString:
这里函数的实现模仿了C的实现,但是用的是stdcall,原因是预防调用时忘了清栈。函数名为Func_PrintSting(这里命名模仿了《一个64位操作系统的设计与实现》,我感觉这种命名方式非常好,用Func_前缀标记函数,用Label_前缀标记标签,这样就不会搞混),参数用栈传递,从栈底到栈顶分别为字符串地址、字符串长度及行列数。没有返回值。
下面将分段说明它的实现。
; construct stack frame
push bp
mov bp, sp
; StringAddress = [bp + 4]
; StringLength = [bp + 6]
; ColRow = [bp + 8]
这里先形成一个栈帧(Stack Frame),方便后续对参数寻址。形成栈帧后,三个参数的地址如后面的注释所示(与32位不同的,一次入栈是2个Byes,所以一次加2)。
然后保护一下要用到的寄存器。
; protect registers
push ax
push bx
push cx
保护完寄存器后开始触发中断打印字符串。
; protect BP
push bp
; print string
mov ax, 1301h
mov bx, 000fh
mov cx, [bp + 6]
mov dx, [bp + 8]
mov bp, [bp + 4]
int 10h
; recover bp
pop bp
这里有两个地方要注意的,第一个是要注意对bp进行保护,因为中断用到了bp传值,会破坏bp原有的值。第二个是注意bp要最后赋值,因为要靠bp来对参数进行寻址。
按照上面的功能号为0x13的int 10h的说明传好值,然后开始进行int 10h中断调用。返回之后pop bp将bp恢复。
接着是恢复被保护的寄存器。
; recover registers
pop cx
pop bx
pop ax
最后关闭栈帧并返回。
; close stack frame
mov sp, bp
pop bp
; return
ret 6h
这里的返回用的是ret 6h,意思是返回时讲sp降低6个bytes来平衡栈,就不需要调用者来清栈了。这是stdcall的调用约定。如果使用c call来实现,直接使用ret,则需要调用者使用add sp, 6h来清栈。
到这里,Func_PrintString就实现完了。
实现完打印字符串的函数后,就能很方便地打印字符串了。打印字符串实现如下。
; print boot message
push 0000h
push 16
push StartBootMessage
call Func_PrintString
这样调用Func_PrintString后,将在00行00列打印StartBootMessage处的16个字节的字符串。
打印完字符串后,开始循环等待。
; loop wait
jmp $
$表示的是当前地址,jmp $作用是一直往当前地址跳,也就是进入了死循环。这样会一直消耗CPU周期,但是不继续往下执行。
最后,是几个伪指令,用来定义消息字符串和填充空白,并且定义boot的签名。
; message string
StartBootMessage: db "Start Booting..."
; padding zero and set flag
times 510 - ($ - $$) db 0
dw 0xaa55
Boot扇区在第0扇区,大小为512个Bytes。BIOS在识别Boot扇区时回去识别第511和512个Byte是不是0x55和0xaa,如果是这两个值就认定Boot扇区是有效的。所以这里需要讲最后两个Bytes填充为制定的Signature。
times伪指令用来填充0,至于填充多少个0,由当前地址来决定。$ - $$表示当前地址与当前首地址的差值。$位当前地址,$$为块的首地址。用510 - ($ - $$)就能得到需要填充的bytes数,填充完成后下一个byte就是第511个byte了。然后用dw 0xaa55来写入签名。由于用的是Little-endian,所以高地位必须对应,0x55 0xaa就是0xaa55了。
到这里,一个简单的boot示例程序就编写完成了。
0x04 编译执行
为了方便之后对源代码和构建完的文件进行管理,源代码统一放到工程目录的"src"目录下,编译后的文件统一放到"build"目录下。这里将上面编写的代码保存到"src/bootloader/boot.asm"中,将生成的目标文件定为"build/boot.bin"。
并且,为了方便后续的编译,可以在工程根目录下新建一个makefile,内容如下。
build/boot.bin : src/bootloader/boot.asm
nasm src/bootloader/boot.asm -o build/boot.bin
clean:
rm build/*.bin
这样,每次增加需要编译的源文件和目标文件都直接添加到makefile中就能很方便地编译了。在根目录下直接执行make就能完成编译。
编译完成后,在build目录下会生成一个boot.bin。接下来要做的就是将其写入到之前生成的boot.img镜像中。这里也通过编写一个脚本来实现,避免每次繁琐地使用dd来写入。
下面是writeimage.sh脚本的实现。
#/bin/sh
dd if=build/boot.bin of=build/boot.img bs=512 count=1 conv=notrunc
这样,每次编译完之后,如果想将内容写入镜像中,只需要执行writeimage.sh就行。
接下来就开始编译、写入和执行了。分别执行make、writeimage.sh就能完成编译和写入。然后用Bochs来完成模拟(记得给writeimage.sh加执行权)。
make
./writeimage.sh
这样就完成了编译和写入。效果如下。
编译和写入
然后使用bochs模拟。
bochs -f bochsrc
bochsrc的配置在上一章已经完成了。
模拟效果如下。
Bochs模拟执行
能看到达到了预期效果。之前Bochs输出的内容已经被清空,并且打印了引导提示字符串。
到这里就完成了一个Boot示例程序的实现、编译和测试。
0x05 总结
这里的Boot程序无实际用途,仅仅用来熟悉Boot程序的编写和实模式下程序的编写。后续将会实现加载Loader的部分以及使用Loader加载内核。
项目已经托管到Github。链接:
Github-OperatingSystem
网友评论