相信所有学习过「计算机组成原理与汇编语言」课程的人都受过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界面。具体用法已经超出了本文的范畴,网上资料也不少,暂时先不填坑。这里就放一张图。