轻松的开发一个操作系统(指导手册)
标签: 翻译家 编程 操作系统
chapter 1
前言
我们都使用过操作系统,又或者写过某个系统上运行着的程序;但操作系统到底是来做什么的?我所看到的工作多少是硬件完成的又有多少是软件完成的?电脑实际上是如何运作的?
我已故的老师,一位曾活跃在兰开斯特大学的Doug教授曾在我因陷入恼人的程序问题而苦不堪言时提醒过我,以前他还没能开始任何研究的时候,他用scratch写了自己的操作系统.所以看起来今天我们习以为常的神奇的机器实际上运行在所有的软件层下,他们互相联系互相依赖.
至此,专注于广泛使用的x86架构CPU,抛开所有电脑上的软件,跟随着Doug教授的步伐,逐步学习:
- 电脑是如何启动的
- 如何在不存在操作系统的情况下编写低级程序
- 如何设置CPU来使用它的拓展功能
- 如何用高级语言编写引导代码,这样就能真的开始以自己的系统为目标去编程
- 如何创造基础操作系统服务,比如设备驱动,文件系统,多任务处理
chapter 2
电脑架构和启动进程
2.1 启动进程
打起精神!准备离港了!
当我们重启电脑时,电脑一定会乖乖重启,最开始会以没有操作系统这一概念开始初始化.然而还是还是必须以某种形式-无论从什么东西里-从一些插在电脑上的永久存储设备中加载操作系统(闪盘,硬盘,或者U盘)
马上我们就会发现,你的电脑的预操作系统环境(pre-OS environment)提供不了丰富的服务:在这个预处理的阶段一个简单的文件系统会是很奢华的(比如从磁盘中读写逻辑文件),我们也不拥有这些功能.好在我们拥有基本输入输出软件(BIOS),一系列软件程序在电脑启动的一开始就会从芯片载入到内存并完成初始化.BIOS提供自动检测以及对你电脑的基本设备的基本控制,比如屏幕,键盘,硬盘.
在BIOS完成一些对你的硬件的低级别测试后--特别是你安装的内存是否正常的工作--它就会选择你的一个设备启动操作系统.但是,之前说过,BIOS不能简单的从磁盘中加载一个代表着操作系统的文件,因为BIOS没有文件系统的概念.BIOS一定要从磁盘中的特定物理位置中读取特定扇区的数据(通常大小是512字节),比如2柱面(Cylinder),3磁头(Head),5扇区(Sector)(详细的磁盘地址之后会讲解).
当然了,操作系统放在最容易被BIOS找到的地址,磁盘的第一个扇区(比如0柱面,0磁头,0扇区),这就是所谓的引导扇区.因为某些磁盘可能不包含操作系统(仅仅是作为存储设备),所以BIOS可以决定特定磁盘的引导扇区是可以执行的引导代码还是仅仅用于数据,这一点很重要.要注意CPU是不会区分数据和代码的,两者都可以被解释成CPU指令,而代码只是一些简单的指令被程序员编写成的有用的算法.
同样的,BIOS用了一种朴实无华的方式来检测引导扇区,通过查看目标引导扇区的最后2个字节是否是魔法数字0xaa55
来判断.于是BIOS会循环遍历每个存储设备(软盘,硬盘,CD),将引导扇区读取至内存,指引CPU去执行第一个发现最后两位是魔法数字的引导扇区.
这就会我们控制计算机的地方.
2.2 BIOS,启动块,魔法数字
e9 fd ff 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa
图2.1 一个机器码引导扇区,每一个字节由16进制表示
注意到,在图2.1中,有3个重要特性:
- 最开始的三个字节,用十六进制表示的
0xe9
,oxfd
和oxff
,实际上是机器码指令,由CPU制造商定义,该指令表示的是一个无限跳转 - 最后两个字节,
0x55
和0xaa
组成了魔法数字,它告诉了BIOS这确实是一个引导块,而不是恰好在硬盘引导扇区上的数据. - 文件由0填充("*"为简洁起见省略了的0),基本上是为了把BIOS的魔法数字挤到磁盘扇区的512个的字节的最后.
要注意一下字节顺序(endianness).你可能会觉得奇怪,为什么之前说的BIOS魔法数字是0xaa55
而图2.1中的最后两个字节是连续的0x55
以及0xaa
.这是因为x86架构以小端序来处理多字节值.这意味着较低的有效字节处理(表示)较高的有效字节,跟我们熟悉的数字系统相反—如果我们的系统也反过来的话我可能只有00005元钱在我的银行账户里了,那我就只能退休了,或许会捐几毛给曾经的百万富翁.
通过允许我们定义数据类型,编译器已经汇编器可以隐藏许多由字节顺序带来的问题,比如说,一个16进制的值会以正确的字节顺序被自动序列化为机器码.然而,特别是找bug的时候,了解具体的单个字节是如何存储在内存或者存储设备中是很有效的,所以字节顺序仍是非常重要的.
记住,是我们在为电脑编程,电脑仅仅是盲目的遵循着我们的命令,获取指令然后执行直到它关机;所以我们要确保它执行的是我们编写的代码而不是内存中某处的随机数据.在目前的底层(At this low level)上,我们对电脑有无穷大的权利以及职责,所以需要我们学习去控制它.
2.3 CPU仿真
有一种不用不停的重启你的电脑来测试底层程序的方法,就是使用CPU模拟器比如Bochs或者QEmu.与虚拟主机不同(比如VMware,VirtualBox),后者试图通过直接在CPU上运行客户指令来优化性能,从而优化托管操作系统的使用,模拟器包含了可表现的像指定CPU架构的程序,使用变量代表CPU寄存器,使用高级控制结构来模拟低级跳跃等等,所以它会慢一点但通常会更适合用来开发或者debug一个系统.
请注意,为了让模拟器跑起来,你需要以磁盘镜像文件的形式提供一些代码.一个镜像文件就是原始数据(换句话说机器代码和数据)否则就是写入到硬盘,软盘,CDROM等介质中.事实上,一些模拟器会成功的从下载到的或者安装光盘中的镜像文件启动一个真的操作系统--虽然虚拟主机更适合这种情况.
模拟器将低级显示设备指令转换为桌面窗口上的像素,所以你能确切的看到真实显示器上呈现的内容.
一般来说,对于本文中出现的练习,所有在模拟器中能正常运行的机器码都能在真正的CPU架构—显然会更快--上运行.
Chapter 3
引导扇区编程(16位实模式)
3.1 引导扇区回顾
就算有示例代码作为参照,你也会毫无疑问的在二进制编辑器里编写机器代码而挣扎.你必须记住,或者不停的翻阅,从繁多的机器代码中找到一个来使CPU执行某个功能.幸运的是,你不是一个人在作战,汇编器可以用来将人性化指令转换成特定CPU能读懂的机器代码.
本章中我们会探索更复杂的引导扇区程序来使我们更加熟悉汇编,以及将要运行我们程序的荒芜的预处理操作系统环境.
;
; 简单的无限循环引导扇区程序
;
loop : ; NASM的语法,不必太深究.
; 大致就是循环了510次,每次定义一个0
jmp loop ; 最后2个字节定义魔法数字0xaa55
; 这样CPU就知道了这段代码是引导扇区代码
;
;
times 510 -( $ - $$ ) db 0 ;
;
;
;
;
dw 0xaa55 ;
;
具体的执行方法参考此处:https://zhuanlan.zhihu.com/p/51725653
3.2 16位实模式(16-bit Real Mode)
CPU制造商必须不遗余力的保持他们的所有CPU(换句话说,它们的特定指令集)都能兼容早期的CPU,保证古老的软件,特别是古老的操作系统,能在他们的最新的CPU上正常运行.
由因特尔实现的可兼容CPU的解决方案是去模拟CPU家族中最老的成员:intel8086,支持16位指令,没有内存保护的概念.内存保护是现代操作系统能稳定运行的关键,因为它允许操作系统拒绝用户进程有意或无意的访问所谓的核心内存(系统在使用中的),如果让这样一条进程绕过安全机制可能会让整个系统崩塌.
所以,为了向后兼容,CPU以16位实模式启动是很重要的,启动后,现代操作系统会显式的切换到32位(或64位)保护模式,而老系统则会沉浸在浑然不知身处现代CPU的幸福中而保持16位实模式继续运行,之后我们会详解系统从16位实模式切换到32位保护模式这一重要步骤.
通常来说,我门口中的16位CPU指的是它一次最多只能处理最大16位的指令.比如,一个16位的CPU有特定的指令在一个机器周期中来执行相加2个16位的数,如果一个进程需要将2个32位数相加,那么使用16位加法的CPU将耗费2个周期.
因为所有操作系统的起点都是16位实模式,所以我们将从此开始探索,之后会延伸到32位保护模式以及了解它所带来的好处.
3.3 喂,有人在吗?
现在我们要来写一个简单的看上去是引导扇区程序的代码.它会在屏幕上打印一些数据,借此来学习CPU工作的基本原理,以及如何通过BIOS来掌控屏幕设备.
首先,想想我们要做的事.我们想在屏幕上打印一些文字但是又不知道如何与屏幕交流,因为这个世界上有这么多不同的显示器每个又有不同的接口.这就是我们需要BIOS的原因,因为BIOS在开机时就会做硬件检查,并且显然已经在屏幕上打印了许多检测结果.就是它了,可以帮我们.
所以,第二步,我们想让BIOS替我们打印一些文字,但是要如何让BIOS听话呢?能写php的代码echo一下吗—想得美.我们能确认的事,无论如何在内存的某处一定会存在BIOS的机器码可以在屏幕上打印文字.事实是虽然我们可能能在内存中找到BIOS的代码然后执行它,但这样做是得不偿失的,因为不同机器上的BIOS程序的不同而引发错误.
我们能用到的电脑的基本机制:中断.
3.3.1 中断
中断是一种允许CPU临时停下手中的任务转而执行优先级更高的指令,结束后再回到原来任务的机制.中断可由软件指令(比如 int 0x10
)或者高优先级的硬件动作(比如从网络中接受数据).
每种中断用中断向量中的唯一索引表示,中断向量是由BIOS从内存的开始处(换句话说,物理地址0x0
)进行初始设置,包含指向中断服务程序(interrupt service routines,ISRs)的地址指针.ISR只是一系列的机器指令,就像我们的引导扇区代码一样,用于处理一个特定的中断(比如从磁盘中或者网卡中读取新数据).
所以简而言之,BIOS在对电脑的中断向量中添加了一些自己的中断服务,比如中断0x10
会导致屏幕相关的中断服务被触发;中断0x13
会触发磁盘相关的IO中断服务.
However, it would be wasteful to allocate an interrupt per BIOS routine, so BIOS multiplexes the ISRs by what we could imagine as a big switch statement, based usually on the value set in one of the CPUs general purpose registers, ax, prior to raising the interrupt.(大意是BIOS执行中断的步骤,可以想象成一个大的switch
语句,通过ax
寄存器中的值来决定执行哪个中断)
3.3.2 CPU寄存器
就像我们在高级语言中使用变量一样,如果能在特定程序中存储临时数据那就好了.所有的x86CPU都有4个通用寄存器,ax
,bx
,cx
,dx
就是用来存储临时数据的.同时,这些寄存器每个都能存一个字
(2字节,16位)的数据,能被CPU以相对于从内存中拿数据而可忽视的延迟来读写.在汇编语言中,最常见的操作就是在寄存器中移动(更准确地说,复制)数据:
mov ax , 1234 ; 在ax中保存十进制数1234
mov cx , 0 x234 ; 在cx中保存16进制数
mov dx , ’t ’ ; 在dx中保存ASCII码't'
mov bx , ax ; 将ax中的数据复制到bx中,现在bx=1234
注意到目的地是mov
操作的第一个值而不是第二个,但并不是一定的,以编译器的语法为准.
有时候操作单个字节更加方便,所以可以独立的操作寄存器的高位以及低位:
mov ax , 0 ; ax -> 0x0000 , or in binary 0000000000000000
mov ah , 0 x56 ; ax -> 0 x5600
mov al , 0 x23 ; ax -> 0 x5623
mov ah , 0 x16 ; ax -> 0 x1623
3.3.3 一起来吧
回想起我们最初的目的吧,让BIOS为我们在屏幕上打印字符.我们可以通过设定ax为某个BIOS定义好的值来触发特定的BIOS程序来引发特定的中断.那个特定的程序就是BIOS电传打字机程序,能在屏幕上打印单个字符并且移动光标,为下个字符做准备.可以去查找一张展示所有中断的BIOS程序并且如何通过寄存器来使用的清单.现在我们需要的是0x10
中断,同时设置ah
为0x0e
(特指电传打字机模式),然后再ah
中设置需要打印的ASCII码.
;
; 通过BIOS程序打印字符的简单引导扇区代码
;
mov ah, 0x0e ;
mov al, 'H'
int 0x10
mov al, 'e'
int 0x10
mov al, 'l'
int 0x10
int 0x10 ; l还在al,记得吗?
mov al, 'o'
int 0x11
jmp $ ; 跳转当前地址=无限循环
; 填充0以及魔法数字
times 510 - ($-$$) db 0
dw 0xaa55
3.4 Hello,World!
3.4.1 内存,地址以及标签
之前我们看到了CPU是如何从内存中获取并执行指令,以及BIOS是如何将512字节的引导扇区加载到内存然后完成初始化,告诉CPU跳转到我们代码的开头,执行我们的第一个指令,然后下一个指令,如此反复.
所以我们的引导扇区的代码存在于内存的某个地方;哪里呢?我们可以把主要内存想象成一长串的字节序列,可以被一个地址单独访问(比如说通过索引),所以当我们要寻找内存中的第54个字节时,这个54就是对应的地址,用它的十六进制数来表示更为方便:0x36
所以我们引导扇区代码的开始,第一个机器码就在内存中的某处,是BIOS将它放在那的.你可能会觉得,BIOS把这段代码放在内存的开头 0x0
.但并不是这么简单,因为我们要知道,BIOS在载入我们的代码之前就已经在做初始化的工作了,事实上,它还一直在为硬件中断提供服务,比如时钟,驱动等.所以这些BIOS程序(比如 中断服务,屏幕打印等)在他们使用的时候也必须存在于内存中的某个地址,必须被保存起来(不能被覆盖).同样的,通过之前的学习,我们了解到(你有没有仔细去了解中断鸭)中断向量位于内存的开始处,如果BIOS将我们的代码加载在开始处(就会覆盖掉中断向量),我们的代码就翻身做了主人,一旦下一个中断触发时,电脑就很有可能崩溃重启:中断号与中断服务之间的映射实际上被切断了.
事实是,BIOS会将引导扇区加载在0x7c00
的地址上,一个绝对不会被重要程序占用的地址.下图给出了当引导扇区被加载时一台电脑典型的底层内存布局.所以虽然我们有能力指挥CPU将数据写进任意的内存地址,但这种肆意妄为可能会引起意外,因为一些内存是被其他程序使用的.比如时间中断或者驱动设备.
3.4.2 'X' 标记地址
现在我们来玩一个用来演示内存引用的叫做"找到那个字节"的游戏,会让你接触到汇编代码中标签的使用,以及了解BIOS在何处载入代码.我们会用汇编写一个保存一个字节数据,然后打印出来的程序.要做这个需要算出它的绝对内存地址,然后将数据放入al
寄存器中让BIOS打印,像上次那样的练习一样.
;
; 演示地址的简易代码
;
mov ah,0x0e;
; 第一次尝试
mov al , the_secret
int 0x10 ;
; 第二次尝试
mov al , [ the_secret ]
int 0x10 ;
; 第三次尝试
mov bx , the_secret
add bx , 0x7c00
mov al , [bx]
int 0x10 ;
; 第四次尝试
mov al , [0x7c1d]
int 0x10 ;
jmp $ ;
the_secret :
db "X "
; 填充
times 510 -( $ - $$ ) db 0
dw 0xaa55
首选,我们在程序中定义了一些数据,并且给了它一个标签(the_secret
).可以将标签放在程序的任何地方(本代码中则放在了填充前),它的作用是为获取特定的指令或者数据的偏移地址带来了方便.
b40e b01d cd10 a01d 00cd 10bb 1d00 81c3
007c 8a07 cd10 a01d 7ccd 10eb fe58 2000
*
0000 0000 0000 0000 0000 0000 0000 55aa
图3.5 之前程序的机器码
看一下图3.5的机器码,能看到我们的'X'用16进制的ASCII码表示为0x58
,偏移位置在第29个(0x1d
)个字节处,就在引导扇区填充0的代码之前.
可用sublime等能查看二进制文件的编辑器打开编译后的bin文件来查看机器码,原文中的机器码与本文中的并不相同,可能是编译器版本问题,本文使用的nasm为2.13.03版本.原文中的机器码为:
b4 0 e b0 1e cd 10 a0 1e 00 cd 10 bb 1e 00 81 c3 00 7 c 8a 07 cd 10 a0 1e 7c cd 10 e9 fd ff 58 00
'X'的偏移值是30,则代码也要改成[0x7c1e]
执行上面的代码,你会发现只有后面2次成功打印出了'X'.
第一次尝试的问题是代码试图直接将载入的偏移地址进行打印,而实际上我们想要打印的是在这个偏移地址上的字符而不是这个偏移地址本身.在下次尝试中,将地址放在方括号中才是我们想要CPU做的事-存储该地址对应的内容.
那么为什么第二次尝试也失败了呢?问题是,CPU讲偏移量理解成以代码开始的地方的偏移量,而不是相对于我们载入代码的偏移量,也就是说CPU会从中断向量的地方开始计算偏移量.在第三次尝试中,我们在the_secret
的偏移量上加上了之前BIOS载入我们引导扇区代码的地址0x7c00
,使用add
指令.也就是bx = bx + 0x7c00
,这样就能计算得到'X'的地址并且将其对应的内容保存在寄存器al
中,为BIOS打印而做准备,通过mov al,[bx]
指令
可能有点拗口,其实就是标签的偏移地址是以这段代码开始计算,而CPU理解的偏移地址是从
0x00
开始计算.偏移地址永远是个相对而不是绝对的值.
在第四次尝试中我们耍了个小聪明,直接计算出'X'在引导扇区代码中的位置.通过检查之前的机器码发现'X'在0x7c1d
的位置,于是直接将该地址的值读取出来.最后一个尝试告诉了我们标签的重要性,如果没有标签我们得从编译后的二进制机器码文件中去数出需要的偏移地址,并且在每次更新代码后都要重新数一次,因为偏移地址也会发生改变.
现在能确认BIOS确实是从0x7c00
处开始载入我们的引导扇区代码,也看到了汇编代码的标签是如何寻址的.
每次都要计算标签在内存中的偏移值是很不方便的,所以当你用了下面这条指令后汇编器将在汇编时根据你的设定修改标签偏移值的参考值.下面这条指令将明确的告诉CPU在何处载入代码.
[org 0x7c00]
CPU会默认在
0x00
处载入代码,标签偏移值也会以此为参考,指令org
将修改载入地址,同时也修改了标签的参考地址.
问题1
当在之前的引导扇区代码中加上org
指令后打印的结果如何?最好解释清楚原因.
当指定在
0x7c00
处载入代码后,CPU将不再0x00
处开始载入代码.标签偏移地址也从0x7c00
开始计算.所以执行add bx,0c7c00
的第二次尝试能成功的打印出,反之加上了地址的第三次则会打印失败.使用了绝对地址的第四次尝试也是成功的,当然前提是你得看看编译后的机器码文件'X'的位置变了没有.
3.4.3 定义字符串
假设你想在屏幕上的某处打印一条预定义的消息(比如"Booting OS");你讲如何在汇编程序中定义这条字符串呢?要知道电脑是没有字符串的概念的,对于电脑来说只是内存某处的一连串数据单位(比如字节,字符等)
汇编中我们可以这样定义字符串:
my_string:
db 'Booting OS'
我们已经见过db
了,它的含义是"声明一个字节数据(“declare byte(s) of data)",汇编器会将它声明的数据直接写入二进制输出文件中(进一步说,不会当做一条预处理指令来解释).因为我们将字符串放在引号中,所以汇编器知道它应该把每个字符转成ASCII字节代码.注意到,我们经常使用标签(比如my_string
)来标记数据的开始,否则没有好的办法在代码中找到之前定义的字符串.
有一件和字符串在哪里一样重要的被忽视的事情就是了解字符串的长度.因为是我们来编写处理字符串的所有代码,对于字符串长度通过一个一致的策略是很重要的.最方便的是定义字符串为空终结(null-terminating),意味着总是要定义字符串的最后一个字节为0
比如:
my_string :
db ’ Booting OS’,0
以后遍历字符串时,或许是打印字符串,能很容易的决定何时结束遍历.
3.4.4 栈的使用
在计算机底层的话题中,我们经常听到人们谈论栈,好像是很高深的东西一样.然而栈只是一个很简单的解决方案:CPU用来存储例程局部变量的寄存器是有限的,但是通常需要用到的临时存储量比拥有的多很多;现在,显然我们能使用主要内存,但是当读写时指定特定的内存地址是很麻烦的,特别是当我们不关心数据在何处存储,只关心能方便的回收时.同时,以后会看到栈对于函数调用中的传参是很有用的.
于是,CPU提供了2个指令:push
以及pop
允许我们分别从栈的顶部存储以及回收数据,而不用担心数据存储在哪.注意,我们不能在栈中push,pop单个字节数据,因为在16位实模式中,栈只能16位工作.
栈由2个特殊CPU寄存器实现,bp
以及sp
,她们分别维护着栈的底部(栈底)以及栈的头部(栈顶).因为栈的大小会随着我们插入数据而扩张,所以通常将栈底放在离内存中重要代码很远的地方(比如BIOS代码或者我们的代码)来避免栈太大而覆盖代码的风险.有一个很容易让人误会的事情就是栈其实是从基址指针向下增长的,所以当我们执行push
时,数据实际上存储在相对dp
更低的地址--而不是更高的地址—而sp
则是随着数据的大小而减小.
其实就是栈底是不变的,而栈顶会随着栈变大而变小,确实有点奇怪.
下图程序中,字符'A','B','C'在ASCII码中是用
0x21,0x22,0x23
来表示,对应二进制00100001
(一个字符占用8位),但是push
,pop
又要求16位数据,所以为了补充到16位,汇编器会在数据上附上0x00
同时注意汇编是在高位补上
0x00
,可以试着将程序修改成push 'AG'
,push 'BG'
然后修改bl
成bh
,来体验内存高低位的存储.
;
; 演示栈的简单程序
;
mov ah,0x0e
mov bp,0x8000 ; 将栈底设置在离BIOS远一点的地方
mov sp,bp ; 这样就不会复写我们的程序
push 'A' ; push一些数据.,准备被我们回收
push 'B' ; 注意它们是以16位数据的形式push的
push 'C' ; 所以会被汇编器补上0x00
;
pop bx ; 只能pop 16位数据,
mov al,bl ; 所以pop数据到bx 之后复制bl到al (8位数据)
int 0x10 ;
pop bx ;
mov al,bl
int 0x10 ;
mov al,[0x7ffe] ; 为了证明栈区是向下增长的,
; 在此地址获取数据: 0x8000 - 0x2 ( i.e. 16位 )
int 0x10 ;
jmp $ ;
;
times 510-($-$$) db 0
dw 0xaa55
图 3.6 通过push
以及pop
操作栈
问题2
图3.6的代码会按什么顺序打印字符?'C'的绝对内存地址会在哪?通过修改代码来证实你的猜想会很棒,但是要解释清楚为什么是这个答案.
3.4.5 控制指令
没有例如if..then..elseif..else,for
和while
这样的基本控制语句来编程是很蛋疼的,这些指令允许程序中分支的存在并且构成了例程的基础.
在编译后,这些高级控制指令减少了简单的跳转声明.实际上之前就已经见过循环的示例了:
some_label :
jmp some_label ; 跳转到标签的地址
或者可以这样来达到同样的效果:
jmp $ ; 跳转到当前指令
这个指令让我们能够无条件
跳转(一定会跳);实际上我们最需要的是在某种条件下的跳转(比如循环直到10次等)
条件跳转通过前置一个比较指令来实现,然后执行一个特定的跳转指令.
cmp ax , 4 ; 比较ax与4
je then_block ; 相等的话跳转到then_block标签
mov bx , 45 ; 否则执行此代码
jmp the_end ; 重点:跳过中间不需要的代码
; 这样就不会中间的代码了
then_block :
mov bx , 23
the_end :
在C或者Java中,实现代码类似:
if( ax == 4) {
bx = 23;
} else {
bx = 45;
}
从汇编示例中可以看到冥冥之中cmp
指令与je
指令执行的代码有某种联系.这是一个CPU特殊标志寄存器捕获cmp
指令结果的示例,这样后续的条件跳转指令就能决定是否要跳转到特殊地址了.
以下是可用跳转指令,基于cmp x,y
指令的结果:
je target ; jump if equal ( 相等时)
jne target ; jump if not equal ( 不相等时)
jl target ; jump if less than ( 当x<y时)
jle target ; jump if less than or equal ( 当x<=y时)
jg target ; jump if greater than ( 当x>y时)
jge target ; jump if greater than or equal ( 当x>=y时)
问题3
先用高级语言设计好条件跳转代码再转换成汇编指令是很好的办法.试一试将下方的伪汇编代码转换成完整的汇编代码,使用cmp
指令.测试不同的bx
值.用你自己的语言完整的注释你的代码.
mov bx , 30
if (bx <= 4) {
mov al , ’A ’
} else if (bx < 40) {
mov al , ’B ’
} else {
mov al , ’C ’
}
mov ah , 0 x0e ; int =10/ ah =0 x0e -> BIOS tele - type output
int 0 x10 ; print the character in al
jmp $
; Padding and magic number.
times 510 -( $ - $$ ) db 0
dw 0 xaa55
mov bx , 50 ;设置bx为50 cmp bx , 45 ;比较bx与45 jle jle_block ;小于等于,则跳转jle_block jl jl_block ;小于,则跳转jl_block mov al,'C' ;否则设置al为'C' jmp the_end ;不重复执行 jle_block: mov al,'A' jmp the_end jl_block: mov al,'B' jmp the_end the_end: mov ah , 0x0e int 0x10 jmp $ times 510 -( $ - $$ ) db 0 dw 0xaa55
要注意的是
jmp the_end
指令的用法,可以去掉这些指令来体验一下CPU运行代码的流程.
3.4.6 调用函数
在高级语言中,我们会将大需求拆分成一些本质上是通用程序的函数(打印信息,写入文件等)以方便之后的复用,通过传入不同的参数来改变输出.在CPU层级上一个方法无非就是跳转到一个存储了可用程序的地址执行完再跳转到之前跳转地址的位置之后继续执行代码.
我们能这样模拟方法:
...
...
mov al , ’H ’ ; 保存我们将要打印的字符
jmp my_print_function
return_to_here : ; 时间线在此,要跳回之前跳转的地方之后
...
...
my_print_function :
mov ah , 0 x0e ;
int 0 x10 ;
jmp return_to_here ;
首先,主要带我们使用了al
寄存器作为参数,提前设置好它的值以便调用.在高级语言中传参也是如此,调用方
与被调用方
一定要有某种对如何传参的约定.
不幸的是,这种方法的最大缺陷就是我们需要明白的告诉CPU执行玩函数的跳回的地址,这样就没办法在程序的任意地方调用这个函数—因为它总会回到一个相同的标签return_to_here
借用传参的想法,调用放可以明确的保存正确的返回地址(调用后要立即执行的地址),以便于被调用方能返回保存的地址.CPU会将当前的执行指令保存于特殊寄存器ip
(指令指针)中,不幸的是我们没法直接获取它.但是不用怕,CPU提供了一对指令,call
以及ret
能满足我们的需求:call
跟jmp
一样,但是在跳转前会将返回地址推入栈中;ret
则会在执行完函数后先pop
拿到返回地址再跳转,如下:
...
...
mov al , ’H ’ ;
call my_print_function
...
...
my_print_function :
mov ah , 0x0e
int 0x10
ret
我们的函数几乎是自包含(self-contained)的了,然而还是有个丑陋的问题需要考虑,之后的你会感谢我们提前考虑到了这个问题.当我们调用一个函数,比如打印函数时,通过汇编代码,函数的内部会改变一些寄存器的值来完成它的任务(事实上,由于寄存器的稀缺性,它一定会这样做),当我们程序返回之后就不那么安全了,比如,dx
的值被改变了,跟之前不一样了.
自包含是指在组件重用时不需要包含其他的可重用组件
这样做就很友好了:在函数之行前对于可能被函数改变的寄存器,先将他们推入栈中,返回之后再将他们拿出来(还原寄存器的值).因为一个函数可能用到许多通用寄存器,所以CPU实现了两个方便的指令:pusha
以及popa
(push all,pop all),这让入栈以及出栈所有寄存器的值变得很方便了,比如:
some_function :
pusha ; 所有寄存器值入栈
mov bx , 10
add bx , 20
mov ah , 0x0e
int 0x10
popa ; 还原所有寄存器值
ret
3.4.7 引入文件
你一定会想在多个项目中重复使用你的代码吧?nasm允许你引入拓展代码:
% include " my_print_function.asm " ; this will simply get replaced by
; the contents of the file
...
mov al , ’H ’ ; Store ’H’ in al so our function will print it.
call my_print_function
3.4.8 一起来吧
通过这几章的学习我们已经有足够的关于CPU以及汇编的只是来写一些比"Hello Wrold"更加复杂的引导扇区程序了
问题4
汇集你的智慧,来完成一个自包含的函数来打印空终结的字符串,可以这样被使用:
;
; 打印字符串的引导扇区
;
[ org 0x7c00 ] ; 告诉汇编器代码在此处被载入
mov bx,HELLO_MSG ; 使用bx作为函数的入参
call print_string ; 所以能指定字符串你的地址
mov bx , GOODBYE_MSG
call print_string
jmp $ ; 挂起
%include "print_string.asm"
; 一些数据
HELLO_MSG :
db 'Hello , World !',0 ; <-- 结尾的0告诉你的程序何时停止
GOODBYE_MSG :
db ' Goodbye ! ',0
times 510 -( $ - $$ ) db 0
dw 0xaa55
代码如下:
print_string: pusha start: mov al,[bx] cmp al,0 je done call print add bx,1 jmp start done: popa ret print: pusha mov ah, 0x0e int 0x10 popa ret
保证你掌握了学习的知识,小心对待寄存器哦,代码要附上你所理解的注释
bx
寄存器中存储的是字符串的地址,也就是头部地址.比如'H',然后根据每个字符占用1个字节,于是让bx+1来遍历字符串,当遍历到0时停止就好了
3.4.9 总结
似乎仍然没有进行的太远.没问题,也很正常,为我们的大业准备了原始环境.如果之前的内容你全都理解了,其实我们进行的很顺利.
3.5 护士姐姐,把我的听诊器拿来
目前为止我们已经能让电脑打印我们放在内存里的字符或字符串,之后我们会试着从磁盘中载入数据,如果确定了我们确实能从内存的任意地址载入数据,这会是极好的.要记住,我们没有丰富的开发界面,完整详细的调试工具来帮助我们检查代码,电脑能提供给我们最好的反馈就是当我们犯错时什么反应都没有,我们得靠自己!
现在来想想如何编写打印十六进制的例程—在这个底层又无情的世界中让人怜爱的例程.
想想要如何实现.高级语言中,我们可以像这样:print_hex(0x1fb6)
,就能打印0x1fb6
.之前的章节中有学到如何将寄存器的值作为参数在函数中使用,现在来使用dx
寄存器作为参数来保存我们想用print_hex来打印的值:
mov dx , 0 x1fb6 ; 在dx中保存值
call print_hex ; 调用方法
; 打印数据
print_hex :
...
...
ret