本节将学习汇编函数和控制结构,其中穿插着字符串打印、数字转字符知识.
关键字: 控制结构; 函数调用; 字符串
目标: 学习使用汇编语言编写控制结构、函数
对于本节的控制结构和函数,读者应该在本系列博客的第一篇原文介绍+环境搭建中,提到的《汇编语言(第三版)》一书中学习到过,因此本节的目地也是复习相关知识,为我们能写出一个功能完备的boot sector做准备.
理论基础
字符串
在汇编中定义一个类似于C语言中的字符串,即一系列的字符和结尾的一个0,可以用如下方式定义
; db表示后面的字符串中每一个字符占一个字节(byte),类似的数据类型还有dw,dd等
db 'Hello, World', 0
注意单引号扩起来的字符串会被编译器逐一转换为ASCII码存储起来,而最后的0被存储为ASCII码的0.
控制结构
所谓控制结构,就是控制代码运行顺序的语句,C语言中的for、while、switch、break…都为控制结构语句.
汇编语言方面,本系列博客之前用到过jmp语句,后面跟上$表示死循环,当然jmp还有很多衍生语句,根据上一条语句的执行结果,进行不同的跳转处理,它们被称为条件跳转命令.
这里给出一个条件跳转的例子
cmp ax, 4 ; 对比ax是否等于4
je ax_is_four ; 如果相等,则跳转到一个代码位置
jmp else ; 如果不相等,则跳转到另一个代码位置
jmp endif ; 最后,进入正常执行流
ax_is_four:
...
jmp endif
else:
...
jmp endif
endif:
...
本节只使用一种条件跳转命令,即jle,表示如果上一条语句的结果为"<=",则跳转.如果需要其它跳转指令,可以到x86条件跳转指令表查询.
函数调用
汇编语言中的函数调用,其实只是跳转到一个标签(label),所谓标签,就是类似于上面例子中的“ax_if_four、else、endif”,用来标记代码位置.
比较特殊的是传参,汇编传参一般有2步:
- 将参数放入特定的寄存器,并编写文档告知调用者,应该将参数放入哪个(些)寄存器
- 稍稍多写一点代码,保证编写的函数能够允许调用者随意调用
进入函数后应该先保存寄存器环境,防止在函数中改写寄存器,返回后导致主环境中寄存器内容混乱,函数返回前将保存的寄存器恢复.
以上要求我们使用CPU提供的指令来实现.
将系统寄存器保存在栈中: pusha
将系统寄存器恢复到进入函数之前的状态: popa
返回到主函数: ret
现在我们来编写一个print函数,来实现打印单个字符的功能.
; =============================================
; 函数名: print
; 函数功能: 打印传入的字符
; 参数: AL,调用者应将需要打印的字符传入8bit寄存器AL中
; =============================================
print:
pusha
mov ah, 0x0E
int 0x10
popa
ret
包含外部文件
如果我们编写了一类函数,专门用来打印各种类型的数据,如字符串,字符…,这里假设我们写的这类函数的文件叫做print_class.asm.
通用的做法是在需要调用打印函数的源文件中包含print_class.asm.
在C语言中我们的做法为
#include "print_class.asm"
在汇编中我们的做法为
%include "print_class.asm"
注意!汇编中的%include语句的位置放置比较讲究,不同于C语言中只需要将#include放置在文件开头就万事大吉,汇编需要将%include放置在数据段/主程序的代码段之后,目地是不影响主程序的正常执行. 详细情况我在本节的最后会向大家介绍.
打印16进制数
如果直接将16进制数进行打印,那么就会出现打印错误的情况,因为打印函数默认获得的是ASCII码,而ASCII码和数字并不一一对应,因此我们需要提前将16进制数转化为对应的ASCII码.
将16进制数转化为对应的字符形式是有规律可循的,这里直接给出规律,读者可以自行参照ASCII码表进行验证.
情况一: 0~9的16进制数 + 0x30 = 对应ASCII码
情况二: A~F的16进制数 + 0x37 = 对应ASCII码
源码
boot_sect_print.asm
包含print和println两个函数,用以实现打印字符串和打印换行归位
; ===========================================================
; 函数名: print
; input: bx
; input的意义: 需要打印的字符串的首地址
; 效果:打印以bx为首地址的字符串
; ===========================================================
print:
pusha
; 以 while(string[i] != 0) { print string[i]; i++} 为指导思想
start:
; 确认是否为字符串结尾(值为0的字节)
mov al, [bx]
cmp al, 0
je done
; 没到结尾则打印字符, 并跳转到字符串的下一个字符
mov ah, 0x0E
int 0x10
inc bx ; bx = bx + 1, 也可以使用 add bx, 1
jmp start
done:
popa
ret
; ===========================================================
; 函数名: print_nl
; input: 没有输入
; 效果:换行并将光标置到第一列
; ===========================================================
print_nl:
pusha
mov ah, 0x0E
mov al, 0x0A ; 0x0A是换行键的ASCII码,但只会将光标置到下一行的当前列,而不是第一列
int 0x10
mov al, 0x0D ; 0x0D是归位键的ASCII码,作用为将光标回到第一列
int 0x10
popa
ret
boot_sect_print_hex.asm
包含print_hex函数,用于打印16进制数
; ===========================================================
; 函数名: print_hex
; input: dx
; input的意义: 需要打印的16bit的16进制数
; 效果:打印存储在dx中的16进制数
; ===========================================================
print_hex:
pusha
mov cx, 0
; 对于0~9的数字,转换为字符0~9,只需在数字上加0x30,再打印字符即可
; 对于A~F(10~15)的数字,转换为字符A~F,只需在数字上加0x37即可
hex_loop:
cmp cx, 4 ; 循环4次
je end
; 16进制数的每一位用4bit二进制表示,如0x1234可以用二进制表示为 0001 0010 0011 0100
; 因此我们的思路为,利用16bit寄存器AX,获取DX中每4个bit的二进制数,将其转换为对应字符的ASCII码
; 并按顺序放置在我们提前准备好的一个32bit的空间(HEX_OUT)里(严格讲db "0x0000", 0 共占用56bit)
mov ax, dx
and ax, 0x000F
add al, 0x30
cmp al, 0x39 ; 0x39是字符'9'的ASCII码
jle step2 ; 如果ASCII码<='9'的ASCII码
add al, 7 ; 如果是A~F,应该在加0x30的基础上再多加7
;-----将16进制的一位(0~9,A~F),转换为对应字符的ASCII码成功-----
step2:
mov bx, HEX_OUT + 5 ; 此时bx指向HEX_OUT的0x0000的最低位的0
; 利用cx定位0x0000中的右侧4个0, 循环中cx取值依次为0,1,2,3,对应bx指向0x0000右侧的第4、3、2、1个0
sub bx, cx
mov [bx], al
; 也可以使用ror dx, 4 意为: 0x1234 -> 0x4123 -> 0x3412 -> 0x2341 -> 0x1234
shr dx, 4 ; 0x1234 -> 0x0123 -> 0x0012 -> 0x0001 -> 0x0000
add cx, 1
jmp hex_loop
end:
mov bx, HEX_OUT
call print
popa
ret
HEX_OUT:
db '0x0000', 0
boot_sect_main.asm
主函数
[org 0x7C00] ; 变量寻址基址从0x7C00(boot sector被load的地址)开始
; 向寄存器传参数,并调用函数
mov bx, HELLO
call print
call print_nl
mov bx, GOODBYE
call print
call print_nl
mov dx, 0x12fe
call print_hex
call print_nl
; 无限循环
jmp $
%include "boot_sect_print.asm"
%include "boot_sect_print_hex.asm"
HELLO:
db 'Hello, World', 0
GOODBYE:
db 'Goodbye', 0
times 510 - ($ - $$) db 0
dw 0xAA55
编译并Boot
本系列博客自本节之后不再对编译并Boot作说明,如果没有特别提到,默认使用nasm编译,qemu启动仿真.
实验结果
番外
英文原文教程在主函数中对于%include的注释为:
它说要把%include语句放在无限循环之后,嘿,译者尝试将%include放在org 0x7c00后面,结果啥都没有输出,我就很奇怪了(其实是我蠢),于是乎上网搜索,发现在stackoverflow里有位兄弟跟我一样stupid.
贴上链接nasm-instruction-sequence,回答者很明显游刃有余,说明了原因.
针对该汇编程序来说,如果将%include放在无限循环之前,那么将会导致在执行主程序时先执行你的boot_sect_print.asm和boot_sect_print_hex.asm中的内容,可是都还没往函数里传参,AL中都是乱码,自然打印出来的东西乱七八糟了,所以你这个程序的%include只能往无限循环后面放.
一般的,必须将%include语句放在数据段/非主程序段,总而言之就是不能让它主动执行,要让它被动调用