当前位置: 代码迷 >> 综合 >> 从零开始实现balloon操作系统(0x01) 先在bootsect.s里实现一个最小操作系统吧
  详细解决方案

从零开始实现balloon操作系统(0x01) 先在bootsect.s里实现一个最小操作系统吧

热度:13   发布时间:2024-02-22 16:45:44.0

在0x00文章中我们提到了引导扇区的一个重要的细节:主引导签名。

而在这篇文章中,我们先不要急着开始动手写,先了解一下计算机刚刚启动之后,我们能使用的内存空间,以及内存的分布情况。

刚刚启动的时候,计算机会进入实模式,在这个模式下,我们能够访问的内存空间只有1M大小(且只能执行16位程序),如果我们想实现32位系统,能够访问4GB内存的话,就必须进入保护模式(64位系统则要进一步进入长模式)。

实模式下,内存分布的情况如下:

  1. 0xffff0-0xfffff BIOS入口地址。CPU初始化之后,会设置CS:IP指向0xffff0,直接跳转到这个入口位置,并且执行储存在此处的指令0xea5be000f0,即jmp 0xf000:0xe05b,跳转到物理地址(0xf000<<4)+0xe05b=0xfe05b处,此处为自检程序。
  2. 0xf0000-0xfffef BIOS程序范围请注意! 0xffff0-0xfffff也属于BIOS程序范围。所以实际上BIOS程序范围在0xf0000-0xfffff。
  3. 0xc8000-0xeffff 映射硬件适配器的ROM
  4. 0xc0000-0xc7fff 显示适配器BIOS
  5. 0xb8000-0xbffff 文本模式显示适配器空间
  6. 0xb0000-0xb7fff 黑白显示适配器空间
  7. 0xa0000-0xaffff 彩色适配器空间。之后我们会通过设置vga 13h模式来使用这个空间,直接对这个空间进行8位写操作,相应的显示屏像素就会被修改,我们就是通过这个来实现最基本的显示的。当然上面两个显示适配器空间也是可以用来实现显示的,不过我没有深究,如果感兴趣的话可以了解。
  8. 0x9fc00-0x9ffff 扩展BIOS数据区
  9. 0x07e00-0x9fbff 可用区域(大概608kb)。进入bootloader后我们会把主引导程序(512字节)整体搬到0x90000处,再将之后编写的加载程序载入到0x90200处来把操作系统从存储设备取出来载入内存,操作系统主要部分也会被载入这部分空间(0x10000~0x8ffff)
  10. 0x07c00-0x07dff 引导程序。本文接下来讲述的bootsect.s编译出来的引导扇区将会被载入到这段空间运行。
  11. 0x00500-0x07bff 可用区域(约30kb)
  12. 0x00400-0x004ff BIOS数据区
  13. 0x00000-0x003ff 中断向量表。中断向量表是个非常重要的结构,这个部分存储的数据直接与int指令挂钩。表中存储了跳转的地址,刚刚进入系统的时候,我们需要BIOS提供的一些基础功能来帮助初始化系统和载入系统,这时候我们会用到int指令,例如int $0x10,这表示我们调用BIOS的video service,这时候CPU会从中断向量表中找0x10号中断,取出地址并跳转,转入BIOS提供的那段程序中执行,执行完毕后返回。这个向量表会在进入保护模式之后被我们的操作系统覆盖,但是之后我们仍然需要自己构造中断向量表。

下面开始动手写bootsect.s,如果初次接触AT&T汇编,也不用怕,下面的程序会逐条进行解释。

先是开头声明部分

.code16

这句的意思是告诉汇编器将这个代码编译成16位程序。后面在编写head.s时,会用到32位程序,那么开头就写.code32。

.global bootstart

定义了全局symbol,全局symbol是外部程序可见的,所以链接器在链接的时候如果在其他程序中发现了一个未定义symbol,但是查到在此处有同名的global的symbol,那么就会进行链接。

.global databeg,dataend,bssbeg,bssend,textbeg,textend
# 此处也是定义全局symbol

下面是分段部分

.text
textbeg:
.data
databeg:
.bss
bssbeg:

.text, .data, .bss分别表示代码段,数据段,未初始化数据段,三个段定义在同一个地址范围,意味着这个程序实际上是不分段的。

接下来是常量定义部分

.text # text段从这里开始.equ BOOTSEG, 0x07c0

.equ用于定义常量,这里定义BOOTSEG为0x07c0,后续我们会通过ljmp指令将CS寄存器设置为该值。有人会问为什么不是0x07c00,这个就得说到i386的寻址方式:

i386在实模式(16位)下,寻址是通过CS:IP来指向实际物理地址的,那么CS=0x07c0,IP=0x0000的时候,指向的实际物理地址就是:

(0x07c0<<4)+0x0000=0x07c00,就是主引导在一开始被载入的地址。

.equ INITSEG, 0x9000
# 这是我们后面要把这个主引导程序整体搬去的地方,CS:IP=0x90000.equ SETUPSEG,0x9020
# setup.s程序会被载入到CS:IP=0x90200处,
# 也就是主引导搬迁之后0x901ff的后面一个字节开始处.equ SYSSEG, 0x1000
# 操作系统主要程序被加载到0x10000处,当然
# 这个时候我们是没有写操作系统主程序的,这个只是提前写一下
# 毕竟我们要在写之前先规划好空间.equ SYSEND, 0x8000
# 这是我们操作系统程序装载的结尾地址

下面是bootsect.s的主体程序:

ljmp $BOOTSEG,$bootstart
# ljmp指令会同时对CS:IP赋值并且直接跳转到CS:IP指定的位置,
# 这里我们就直接跳转到了0x07c0:bootstart即0x07c00+bootstart位置,
# bootstart就是下面这个汇编代码的起始symbol,
# 执行该指令之后,bootloader正式进入bootstart开始执行指令

在接触主程序之前,先提一句,at&t风格的汇编,例如mov %ax,%bx,这个操作是从ax寄存器取出数据,存到bx寄存器中,也就是说前面的是源,后面的是目(目的/目的地),这和intel风格的汇编是反过来的。

bootstart:mov $BOOTSEG,%axmov %ax,%dsmov $INITSEG,%axmov %ax,%esxor %di,%dixor %si,%simov $0x100,%cxrepmovswljmp $INITSEG,$stackset

bootstart这段代码就已经开始有点整人了(对于零基础的人来说)。这段代码的目的是,将0x07c00~0x07dff处这512个字节,整体搬到0x90000处。那么这是怎么做到的呢?

首先看到我们对ds和es寄存器进行了赋值。ds和es不能直接通过mov $数字,%ds这种形式来直接赋值,只能间接通过其他寄存器来赋值,所以我们使用了ax寄存器。那么ds寄存器存入了0x07c0,es寄存器存入了0x9000。

接下来两个xor指令,是对di和si寄存器进行清零操作的,xor运算可以快速置0,如果这一点不太清楚,可以搜索异或真值表,不难发现异或只有在两个输入不相同的时候才会输出1,而这里xor %di,%di是寄存器对自身进行按位异或,那结果必然是0。

接着我们将0x100存入cx寄存器,0x100即256。那么为什么我们要给cx赋值256呢

下面就是重点了,rep指令是根据cx寄存器的值进行操作的,也就意味着cx是多少,rep指令以及后面这个指令(movsw)就执行多少次。那么movsw就要执行256次。movsw又是啥呢

这个指令是对数据进行“搬运”,它的执行是根据ds:dies:si来进行的,这个指令会从ds:di处取数据,存放到es:si处,并且在执行之后,自动对di和si加一。再加上movsw这个w后缀意味着一次搬运一个word,即2字节,所以循环256次之后,我们一共搬运了512个字节。

所以这下我们就清楚为什么前面要给ds和es赋值,并且置零di和si了,ds:di指向了0x07c00,es:si指向了0x90000,所以这段代码直接把0x07c00后面的512个字节整体移动到了0x90000处!

接着ljmp,跳转到了0x90000+stackset偏移量处,执行下面的这段代码。

stackset:mov %cs,%ax     # ax=INITSEGmov %ax,%ds     # ds=axmov %ax,%es     # es=axmov %ax,%ss     # ss=axmov $0xff00,%sp # sp=0xff00

刚刚跳转到0x9000:stackset处,不难得到CS的值是0x9000即INITSEG,IP的值是stackset的偏移量。这时候我们初始化所有的段寄存器ds,es,ss(也可以包括其他的段寄存器),让他们一起赋值为0x9000。并且设置sp(stack pointer)为0xff00,这时候栈的基址就被设置在了es:sp=0x9000:0xff00=0x9ff00处。

start:mov $0x03,%ah   # read cursor positionxor %bh,%bh     # set page 0int $0x10       # BIOS video servicemov $INITSEG,%axmov %ax,%es     # es:bp points to the stringmov $sysmsg,%bp # set string addressmov $0x1301,%ax # write string,move cursormov $0x0007,%bx # page 0,black background/white charactersmov $28,%cx     # length of stringint $0x10       # BIOS video service

这段代码提到了另外一个重点:BIOS中断调用。看到int $0x10了没,这就是前文中提到的中断调用,0x10就是中断号,在执行了这个指令之后,CPU会跳转到中断指定的位置执行对应的程序,然后返回到此处。由于我们现在还没有能够完全掌控各个设备,所以需要借助BIOS里面已经写好的程序,这时我们就是在调用BIOS内部给我们的程序。

int调用类似于call,但是它执行的是从中断向量表(前文提及到过)中对应中断号位置存储的函数地址,而不是非常直白的call,它有一个搜寻->跳转的过程。

那么既然调用的是已经写好的函数,那么函数必然会需要一些参数啊,BIOS中断调用使用的参数并不是像使用C调用函数的那种方法push到栈中的,而是由你自己设置一些需要用到的寄存器,来实现传参的。

比如第一个调用,0x10是BIOS显示服务,将ah(ax的高8位)设置为0x03,意思就是我们选择的是读取光标位置这个服务。然后我们将bh(bx高8位)设0,表示页号为0,然后调用0x10中断。0x10中断执行结束后,会反馈一些数据回来:
0x10中断0x03服务
这个反馈回来的ch cl dh dl值在接下来的0x10中断0x13号服务中会被部分使用到:
0x10中断0x13服务
那么根据这个传参要求,我们要设置es为0x9000,bp为字符串的起始地址,cx字符串长度,dh,dl起始行列(在0x03号服务中已经获得),al=1光标跟随移动,bl 0x07黑底白字(就是图中的属性),bh 0x00页号0。这就是这段代码的下半段所做的事情。sysmsg是字符串地址,在后面会提到,字符串长度28,调用一下0x10中断,此时屏幕上就会输出一行字:
成功了

die:hltjmp die # infinite loop

这是个死循环,我们的最简操作系统最后会执行到这里无限循环(操作系统本质上包含一个可以跳出的死循环)。hlt指令是在你的输入设备(键盘/鼠标)没有进行任何操作的时候,暂停CPU的运行,让CPU停下来“歇一歇”,不用一直执行jmp die操作。

sysmsg:.ascii "Starting Balloon System...".byte 13,10

这里就是存放sysmsg字符串的位置,.ascii后面可以写一串字符串,后面跟着的.byte 13,10表示13号和10号字符,13号字符即回车,光标返回最前面,10号字符即换行,光标移到下一行。

.=510
signature:.word 0xaa55

这里是程序的点睛之笔,如果缺少这一段,那么程序是无法被识别为主引导的。.=510意思就是一直跳到偏移量为510处,跳过的部分默认填0,这时候留下两个字节的位置,让我们来设置主引导签名0x55,0xaa,这里之所以写成0xaa55,是因为i386在取数的时候为小端序,低地址存放的是数的高位,所以在写入文件的时候,0x55会被放置到前面。

.text
textend:
.data
dataend:
.bss
bssend:

代码段结束。

要想让这段代码顺利跑起来,我们需要qemu-system-i386环境,并且需要一个链接脚本来保证链接器能生成正常的那512字节,最后还要将这512字节的bootsect文件装入一个虚拟软盘,这时候我们需要用到dd指令。链接脚本的代码如下:

OUTPUT_FORMAT(elf32-i386)
OUTPUT_ARCH(i386)SECTIONS {.text 0x0000:{*(.text)}/DISCARD/ : {}
}

开头两句表示输出格式为elf32-i386,架构为i386
下面是想要的链接结果,将.text段放置到0x0000起点,我们的bootsect.s本来就只有.text段,所以整个代码会直接从0x0000处开始,保证了进入引导的时候能正确执行。保存为文件名ld_boot.ld。

接下来写一下Makefile

All: Image
# 执行make All的时候,会直接生成Image文件.PHONY=clean run-qemu
# .PHONY表示伪目标,这里写的东西都不代表文件名,
# 我们用的clean和run-qemu都仅仅是操作bootsect:bootsect.s ld_boot.ld- @as --32 bootsect.s -o bootsect.o- @ld -T ld_boot.ld bootsect.o -o bootsect- @objcopy -O binary -j .text bootsect
# 冒号后面跟着的是生成bootsect所需的基础文件,
# 这里需要bootsect.s和ld_boot.ld
# 生成bootsect文件,第一句是用汇编器把.s文件汇编到.o文件
# 第二句是用链接脚本将.o文件转化到elf可执行文件
# 第三句是用objcopy提取bootsect中.text段的内容,再覆盖bootsect文件
# 这样bootsect就是标准的可以被识别和运行的引导程序了Image:bootsect- @dd if=bootsect of=Image bs=512 count=1- @echo "Image built done"
# dd可以制作一个镜像,if表示输入文件,of表示输出到文件,bs表示一块的大小
# bs=512表示一块有512字节大小,count=1表示生成1块clean:- @rm -f *.o bootsect Image
# 清理文件run-qemu:Image- @qemu-system-i386 -boot a -fda Image
# -fda表示把Image当做虚拟软盘载入qemu虚拟机

然后在控制台输入make run-qemu,就可以运行啦!最终结果就是输出一个字符串,然后进入die死循环,这就是最小的操作系统。

下一篇文章我们会对bootsect.s文件进行扩展,尝试通过0x13 BIOS存储器服务,来从软盘中读取我们需要的程序段。

  相关解决方案