相信所有学习过「计算机组成原理与汇编语言」课程的人都受过8086的折磨:少得可怜的寄存器、远古开发环境、远古命令行调试器debug、复古的DOS操作系统,还有一点……实机不能运行。如果说前面几点还勉强可以忍受,那么最后一点实在是忍无可忍。8086已经是古玩了,Intel和微软的向前兼容也有限度,这导致了现在流行的64位操作系统上根本无法直接运行为8086编写的代码。于是为了继续教8086,现在国内大量学校使用某部分功能收费的IDE进行教学,写出来的东西也只能在Dosbox里面打转。但是,脱离实机的运行环境,学得再多也难对实践有太多助益。
好在从8086转到x64不困难。基础的指令基本相同,寄存器的名字也一脉相承,而且还干掉了分段和8086奇葩的20根地址线,使得编写程序更简单了(相当于以前的flat格式。而且转到x64还能带来生态上的改进:更先进易用的操作系统和更与时俱进的调试器,效率得以大幅提升。
之所以给新手教8086的指令集,一般都说是因为「8086简单」。然而和它的兄弟8088相比,8086还复杂了一些,和RISC架构的几个指令集相比,8086更是一点也不简单,甚至和x64相比,8086也只是显得简陋,基础的指令集并不显得更“简单”。并且在当下,8086已经严重过时,使得学习难度变得更高。
过去给新手教8086,或许只是因为这个架构具有里程碑意义,而且在容易找到可以运行的实机(得益于Intel和微软的垄断地位)。现在给新手教8086,也许只是因为懒于改革而已。实际上应该新手适合学x64(有实机)或者RISC-V(简单)。
第一步:寄存器和指令
寄存器
在基础的指令集上,实际上没有什么改变的;寄存器也只是改了个名、加了些新的。首先,在ax、bx、cx、dx、si、di这几个8086的通用寄存器前面加上r,就得到相应的64位寄存器,比如rax、rdi。原先的名字依然可以使用,不过得到的是这几个通用寄存器的低16位。把r换成e,就得到64位寄存器的低32位,比如eax,这是i386的遗产。ah、al这几个8位寄存器仍然可以使用,含义和以前相同。flags现在叫做rflags,它也是64位宽,不过高32位尚未使用,低32位和32位的eflags相同,低16位和之前的flags相同。
另外,x64引入了r8到r158个新通用寄存器,都是64位宽。后面加上d表示取其低32位,加上w代表取其低16位,加上b代表取其低8位(在intel格式中用l)。注意,不存在r8h这个寄存器,传统的高8位寄存器ah、bh、ch、dh不能和r8b这些寄存器同时出现在一条指令里。
d代表双字(doubleword,32位),w代表字(word,16位),b代表字节(byte)。后面还会提到q,代表肆字(quadword,64位)。「肆字」这个叫法是我自己编的,译法参照化学中的「碳碳叁键」。
ip现在叫做rip。
你会发现我没有提到ds、cs等段寄存器,没错,他们被干掉了。长模式下段寄存器无用。
此外还有80位宽的浮点数寄存器fpr0到fpr7,他们的低64位用于MMX寄存器,更具体的内容这里不赘述,因为编写简单的程序似乎不怎么会用到,具体可以查看Intel或AMD的文档。
指令
指令实际上没有什么不同。原先的指令被全数保留,即使有细微的差别,通过查文档也可以解决。
所有的指令后面加上q、d、w、b可以指明后面操作数的宽度。不过一般不需要手动加,汇编器会自动做。比如下面两条指令其实没有什么差别。
addq rax, 100
add rax, 100
乘法指令和除法指令也和以前几乎一样。
mul bx
mul rbx
第一条指令会把ax乘bx结果的高位存在dx中,低位存在ax中;类似地,第二条指令会把rax乘rbx的结果存在rdx:rax中。
另外还有一点值得注意,寻址的时候应该使用64位寄存器。比如
mov ax, word ptr [rsp + 8]
把rsp换成sp则是不对的。
第二步:操作系统和汇编器
由于笔者使用Linux,因此下面的内容只针对Linux操作系统。实际上Windows下也只是换汤不换药,改成Windows的系统调用即可。
汇编器我选择了GAS(GNU
Assembler),很多发行装好就自带binutils了,其中就包括了GAS。使用命令as就可以调用GAS。链接器自然也使用binutils的ld。GAS默认使用AT&T的汇编格式,立即数前加$,寄存器名字前加%,源操作数在前,目标操作数在后。不过我实在难以习惯,还是使用intel的格式。
调用约定
Linux继承了System V的调用约定(calling
conventions),rdi存放第一个参数,rsi存放第二个参数,rdx存放第三个参数,r10存放第四个参数,r8存放第五个参数,r9存放第六参数。第一个返回值存在rax中,第二个返回值存在rdx中。按照约定,被调用者保证调用后rbx、r12到r15、rbp和rsp
内容不变。
详见下图
系统调用
系统调用除了要按照上面的调用约定进行调用外,还需要往rax中放入系统调用号。下面的最常用的两个系统调用:
rax |
System Call | rdi |
rsi |
rdx |
|---|---|---|---|---|
| 0 | sys_read | unsigned int fd | char *buf | size_t count |
| 1 | sys_write | unsigned int fd | const char *buf | size_t count |
| 60 | sys_exit | int error_code |
read和write返回值都是实际读/写的字节数。exit结束程序,可以设定程序退出后返回的错误码,0表示正常。
准备好相关参数,就可以使用syscall指令直接进行系统调用。比如下面的代码,把rsi指向的内容输出到标准输出中。(UNIX中一切皆文件,stdin的文件描述符为0,stdout为1,stderr为2)
lea rsi, msg # msg为要输出的字符串的标号
mov rdi, 1
mov rax, 1
mov rdx, 5 # 输出五个字符
syscall # 系统调用
如果msg的位置指向字符串helloworld\0,那么调用后将输出hello。
类似地,下面的代码用于终止程序
mov rax, 60
mov rdi, 0
syscall
相当于DOS下8086的
mov ax, 4c00h
int 21h
GAS代码格式
MASM中里面需要用xxx segment和xxx ends来定义各种段,GAS中不需要。
.data后面的内容就是数据段的内容,.text后的内容就是代码段的内容。程序的入口为_start,为了让汇编器找到_start,需要将其设为外部文件可见的,使用.global _start即可。#之后的内容为单行注释。
于是一个典型的程序框架如下:
.intel_syntax noprefix # 启用intel格式
.data
# 数据段中的一些定义
.text
.global _start
_start:
# 一些代码
# 终止程序
mov rax, 60
mov rdi, 0
syscall
end
# end 之后的东西会被汇编器忽略
GAS中有一些常用的宏。
| 作用 | |
|---|---|
.ascii |
后跟字符串 |
.asciz |
后跟字符串,自动以0结尾 |
.byte |
后跟一个字节 |
.rept x和.endr |
把.rept和.endr之间的内容重复x遍 |
本文只是概述,更多内容可以参考GAS的文档。
至此,我们可以开始写第一个程序了。
第三步:Hello World!
这里直接给出程序。
.intel_syntax noprefix
.data
msg: .ascii "Hello World!\n"
.equ msg_len, .-msg
.text
.global _start
_start:
lea rsi, msg
mov rdi, 1
mov rax, 1
mov rdx, msg_len
syscall
mov rax, 60
mov rdi, 0
syscall
.end
第四行的.equ msglen, .-msg定义了一个新符号msglen,.-msg代表当前地址减去msg的偏移量,即为字符串的长度。
编写完成后命名为helloworld.s,然后调用下面的命令进行汇编。-g是为了便生成调试信息,可以去掉。
as -g helloworld.s -o helloworld.o
然后链接。
ld helloworld.o -o helloworld
此时应该会得到一个可执行文件helloworld,执行之,即可获得预期的结果:终端输出了Hello World!。
只写个Helloworld略有一点乏味,下面的程序读取用户输入的数字,转成二进制输出,把0输出为绿色。其中使用了颜色代码\033[0;32m(绿色)和\033[0m(默认颜色)。绿色代码后的字符全部显示为绿色,默认颜色同理。
.intel_syntax noprefix
.data
input_buffer:
.rept 40
.byte 0
.endr
output_buffer:
.rept 100
.byte 0
.endr
msg1: .asciz "input a num (hex): "
msg2: .asciz "binary: "
color_start: .asciz "\033[0;32m"
color_reset: .asciz "\033[0m"
lf: .ascii "\n"
.text
.global _start
# output LF
_newline:
mov rax, 1
mov rdi, 2
lea rsi, lf
mov rdx, 1
syscall
ret
# rdi - string to insert
# rsi - buffer begin
# rdx - pos
_insert_into_buffer:
xor rax, rax
insert_into_buffer_loop:
mov al, byte ptr [rdi]
inc rdi
cmp al, 0
jz insert_into_buffer_loop_end
mov byte ptr [rsi+rdx], al
inc rdx
jmp insert_into_buffer_loop
insert_into_buffer_loop_end:
ret
# output rbx in binary
_output_bin:
push r12
xor rcx, rcx
output_bin_loop:
inc rcx
mov r10, rbx
and r10, 1
add r10B, '0'
push r10
sar rbx, 1
cmp rbx, 0
jnz output_bin_loop
xor rdx, rdx
output_bin_loop2:
pop rax
mov byte ptr [rdx+output_buffer], al
cmp al, '1'
jz output_bin_loop2_even
mov r12b, byte ptr [rdx+output_buffer]
lea rdi, color_start
lea rsi, output_buffer
call _insert_into_buffer
mov byte ptr [rdx+output_buffer], r12b
inc rdx
lea rdi, color_reset
lea rsi, output_buffer
call _insert_into_buffer
jmp output_bin_loop2_odd
output_bin_loop2_even:
inc rdx
output_bin_loop2_odd:
loop output_bin_loop2
lea rsi, output_buffer
mov rax, 1
mov rdi, 1
syscall
pop r12
ret
# read line into input buffer
_read_into_buffer:
push r12
lea rsi, input_buffer
read_into_buffer_loop:
mov rax, 0
mov rdx, 1
syscall
mov r12b, byte ptr [rsi]
inc rsi
cmp r12b, '\n'
jnz read_into_buffer_loop
mov byte ptr [rsi], 0
mov rax, rsi
sub rax, offset input_buffer
dec rax
pop r12
ret
# parse input buffer, write result into rax
# rdi - start of string
# rdx - length
_parse_input:
mov rcx, rdx
xor rbx, rbx
xor rax, rax
parse_input_loop:
mov bl, byte ptr [rdi]
cmp bl, 'A'
jge parse_input_le
# less then 10
sub bl, '0'
jmp parse_input_le_end
parse_input_le:
sub bl, 'A'
add bl, 10
parse_input_le_end:
shl rax, 4
add rax, rbx
inc rdi
loop parse_input_loop
ret
# calc length, and print the string in rsi
_myputs:
mov r10, rsi
xor rdx, rdx
myputs_loop:
mov bl, byte ptr [rsi]
cmp bl, 0
jz myputs_loop_end
inc rdx
inc rsi
jmp myputs_loop
myputs_loop_end:
mov rax, 1
mov rdi, 1
mov rsi, r10
syscall
ret
_start:
read_input:
lea rsi, msg1
call _myputs
call _read_into_buffer
mov rdx, rax
lea rdi, input_buffer
call _parse_input
push rax
output_in_bin:
lea rsi, msg2
call _myputs
pop rbx
call _output_bin
call _newline
# exit
mov rax, 60
mov rdi, 0
syscall
.end
应有以下运行结果
第四步:用gdb调试
GDB(GNU Debugger)对应MASM的debug,由于提供了TUI界面,要比DOS下的debug好用不知道多少倍。
gdb -tui xxx
如是即可使用gdb的TUI界面。具体用法已经超出了本文的范畴,网上资料也不少,暂时先不填坑。这里就放一张图。