Skip to content

x86 Assemble

约 4202 个字 70 行代码 预计阅读时间 15 分钟

Note

实验考试允许去Doxbox861里面查指令,只要不上网都不算违规

环境的配置

16 位汇编编译

  • 用 Editplus 写hello.asm并保存到D:\MASM
  • 编译:masm name.asm;
  • 链接:link name.obj
  • 执行:name.exe

16位汇编调试

td name * F4 excute to current position * F8 step over * F9 excute * ctrl + g 查看地址内容 * ctrl + o 回到当前停止位置 * ctrl + alt + x 退出

变量

变量大小

汇编定义变量时是变量名在前,类型在后 * db==char * b==byte * dw==short int * w==word * dd=float * d==double word * dq==double

寻址方式

8086有20位地址总线,可以传送20位地址,达到1MB的寻址能力。8086CPU内部提供两个16位地址,一个称为段地址,一个称为偏移地址。物理地址=段地址*16+偏移地址

段地址

通常使用seg 获取,seg hello表示获取hello字符串的段地址

偏移地址

通常使用offset获取,offset hello表示获取hello字符串的偏移地址

使用方式

段地址:[偏移地址],段地址指定了内存中一个段的起始位置,偏移地址指定了相对段首的偏移量,注意段地址不允许使用常量,只能用寄存器。段地址起始地址的末位一定为0,一个段的最大长度是10000h,即64k,1k=1024 byte。之所以会有这种情况,是因为最开始的寄存器是16位的,装不下20位的物理地址

data段后面并不一定是紧跟code段,因为每个段的长度必须是10h的倍数,不然就要补0

td里面一行的字节数是不固定的,有可能是1个、2个或者更多字节

语法规则

  • 一条汇编指令只能涉及一个内存变量,令一个必须是寄存器或者常数
  • X86是小端地址(small-endian):即先存放低八位,再存放高八位
  • masm是默认16位的,如果要使用32位寄存器,需要使用.386表示接下来要使用32位寄存器,并且要指定use 16来确保使用16位偏移地址
  • 汇编语言遵从等宽原则,即两个操作数的宽度必须保持一致,但有些时候会发生一些特殊情况,这种情况下需要手动进行强制转换:
    • byte ptr 表示宽度为一字节
    • word ptr 表示宽度为两字节
    • dword ptr 表示宽度为四字节
  • 汇编语言常数是没有宽度的,自然也不可能去改变宽度
  • 如果一条指令有两个操作数,一定是从右到左
  • 伪指令仅在代码编写阶段起到辅助作用,代码编译后并不会生成对应的机器码
  • 段假设是把段地址与相应寄存器关联起来,编译以后会消失
  • 关键字end表示程序的结束,关键字main表示程序的第一条指令,但是汇编语言的main并不是一定要在最前面,因为它只是起到一个开头的作用,甚至汇编语言可以没有main
  • 远指针,这是一个过时的概念,由于16位操作系统访问内存能力有限,索引引入32位的远指针,可以同时保存段地址和偏移地址,近指针就是偏移地址
  • 标签名不能是关键字,比如loop

寄存器与指令

寄存器

通用寄存器

包括ax,bx,cx,dx,主要用于做一些计算,它们可以被分为高低八位,比如ah和al。它们都有相应的32位扩展,即eax,ebx,ecx,edx,它们的低16位就是ax,bx,cx,dx,高16位没有专属的名称。

段寄存器

  • cs 代码段寄存器
  • ds 数据段寄存器
  • ss 堆栈段寄存器
  • es 额外的段寄存器

偏移寄存器

  • bx
  • bp
  • si
  • di

其它寄存器

  • ip 指令寄存器
  • fl 标志寄存器:FL一共16位,但是只使用其中9位,这9位包含6个状态标志和3个控制标志,剩下7个位都是锁死的保留位,除了最右边一号是1,其余都是0。注意FL寄存器不能被直接引用,所以只能pushf。
  • CF:进位标志,它总是等于移出的那一位
    • jc/jnc 有/无进位则跳
    • adc 带进位加
    • clc/stc CF=0/1
  • ZF:零标志
    • jz/jnz 等于/不等于 0 则跳
    • cmp ax, ax会影响ZF标志状态,因为隐含做了减法
  • SF:符号标志,其实就是运算结果的最高位,只对有符号数有意义
  • OF:溢出标志,无论是有符号数还是无符号数,都有可能溢出
  • PF:奇偶校验标志,只要低8位中1的个数是奇数时,PF=0
    • jp/jnp 低8位中1的个数为偶/奇跳转
    • 这种校验有缺陷,比如同时反转两位
  • AF:辅助进位标志,(从0开始计数)<从第三位向第四位产生进位或者错位
  • DF:方向标志,字符串复制的方向
    • 若源首地址<目标地址,赋值按反方向,反之,按正方向
    • cld/std DF=0/1
  • IF:中断标志,用于禁止允许硬件中断,IF=0时禁止,=1时允许
    • 键盘中断:用户敲击键盘,PCU会插入并执行int 9h
    • 时钟中断:根据CPU时钟周期插入
  • TF:陷阱标志,TF=1时,CPU进入单步运行模式,会在每条指令后面插入int 1h,int 1h可以人为去修改,可以借此实现调试器。函数名称是个地址,同时也是函数的入口,n函数的函数指针一定保存在0000:n*4处
  • sp 堆栈指针,由系统赋值,指向堆栈段的末尾

指令

算数运算

加减

  • add 和 sub,这个非常简单
  • inc是++,inc速度更快且长度更短,注意inc不影响FC标志位,但是其他位和加法指令一样
  • adc带进位加,可以用于解决一位溢出和大数的加法
  • sbb表示带借位减
  • dec表示自减,和adc一样不影响CF
  • neg表示求相反数,neg!=not,

乘除

汇编的mul 和 sub 与常规语言的乘除有很大不同

无符号数乘法

  • 8位乘法:被乘数一定是AL,乘积一定是AX
  • 16位乘法:被乘数一定是AX,乘积一定是DX:AX
  • 32位乘法:被乘数一定是EAX,乘积一定是EDX:EAX

符号数乘法

第二行的第三个操作数必须是常量,这个会溢出

imul eax, ebx
imul eax, ebx, 3

符号数除法

  • 8位除法:ax/divisor=AL...AH
  • 16位除法:dx:ax/divisor=ax...dx
  • 32位除法:edx:eax/divisor=eax...edx

符号数除法

除以0

操作系统会拦截,但是可以自定义输出,只要去修改int 00h 中断函数指针。操作系统会先压栈,保存当前状态。可以通过特殊的技巧去修改返回地址,其实计原里面讲的修改CS,但是要注意x86指令是不等长的,这里div指令刚好是2byte,直接跳过错误指令到next段。

push offset divid
push cs
pushf

逻辑运算

and or not xor shl shr sal sar rol ror

栈操作

push pop

注意这两条指令不支持 1 byte的操作 堆栈段的名称可以随便取,但是关键字不允许改变,而且堆栈段只能定义一个。因为栈是从上往下增长的,所以push的时候sp会自动-2,并把所在位置赋值为push值,pop的时候把所在位置的值弹出再+2

通用数据传送指令

xchg 交换两个寄存器(变量)的值

换码指令

xlat执行前必须让ds:bx指向表,al必须赋值为数组下标

地址传送指令

lea

取变量的偏移地址,可以用于计算一些特别的四则运算,比如三个数相加,eax乘以5这种

mov eax, 3
lea eax, [eax+eax*4]

les

取出远指针,高16位段地址给es,低16位给目标寄存器,可以用于取出远指针。一般不用lds,因为ds一般都指向data段。

标志寄存器传送指令

  1. lahf 把FL低8位赋值给AH
  2. sahf 把AH赋值给FL
  3. pushf 把FL压入堆栈
  4. popf 从堆栈弹出一个字给FL

转换指令

符号扩充

  1. cbw 把AL的值符号扩充到AX中
  2. cwd 把AX中的值符号扩充到DX:AX中
  3. cdq 把EAX中的值符号扩充到EDX:EAX

它们是为了有符号除法服务

零扩充

movzx,可以随意指定原寄存器和目标寄存器

跳转指令

Note

jmp指令使用相对寻址,而不是绝对地址,如果使用绝对地址会导致代码无法移动,虽然如果是写C的话一般是不会自己去移动代码的,操作系统重此类操作比较常见

跳转指令其实有两套

跟大小比较有关

jx与jxe除了第一个ja都写出来了,其它都只留一个 |指令|含义|条件| |:---:|:---:|:---:| |ja|jump if above|CF == 0 \&\& ZF == 0| |jae|jump if above or equal|CF == 0| |jb|jump if below|CF == 1| |je|jump if equal|ZF==1| |jg|jump if greater|SF == 0 && ZF == 0| |jge|jump if equal or greater|SF == ZF|

和标志有关

其实就是和每个符号位相关,所以只象征性地列两个 |指令|含义|标志| |:---:|:---:|:---:| |jc|jump if carry|CF == 1| |jz|jump if zero|ZF == 1| |jcxz|jump if cx is zero|CX == 0|

无条件跳转

指令是jmp,但是分为短跳、近跳、远跳

jmp short dest

短跳只能跳8位,其实距离是很近的,很容易超出去然后就会报out of range的错误,机器码一定是EB开头

jmp near ptr dest

近跳可以跳16位,但是后两个byte是小段规则,近跳一定是E9开头

jmp far ptr dest

远跳可以跳32位,最后4byte是一个远指针,远跳一定是EA开头

小数版本

运算符

8086 CPU没有专门的浮点寄存器, Intel 公司后来设计了8087(fpu)增添浮点寄存器。fpu里面有八个寄存器,分别叫st(0),st(1),...,st(7),为了与数组区分没有使用圆括号,使用了方括号,每个寄存器的宽度均为80位,即long double类型。 * fadd fsub fmul fdiv * fld 把小数类型的变量载入小数寄存器 * fild 把整数类型转化成小数载入小数寄存器 * fst 把小数寄存器st(0)保存到变量中 * fstp 把小数寄存器st(0)保存到变量中并弹出st(0)

IEEE浮点数标准

S Exponent Fraction
  1. 32位单精度浮点: 1 8 23
  2. 64位双精度浮点:1 11 52

字符串操作

字符串复制指令

Note

rep作为前缀不能单独使用,但是movsb可以单独使用,即做一次复制,并且仍然会对ds,si做调整,方便下次复制

  • movsb: 以字节为单位复制字符串,将ds:sicx bytes 复制到es:di。复制方向取决于df,df=0是正向复制,即从低地址到高地址
  • movsw: 以字节为单位复制字符串
  • movsd: 以双字为单位复制字符串,对于当前长度不是四的倍数的情况只需要先复制四的倍数个区块再用movsb
    push ecx
    rep movsd
    pop ecx
    and ecx, 3
    rep movsb
    

字符串比较指令

Warning

不能够使用cx=0来判断是否全等,因为有可能最后一个字符不同;实际上应该用zf=0,这个说明最后一次比较是相等,前面的比较也都是相等

  • cmpsb: 按字节比较
  • repe cmpsb:若本次相等则比较下一个
  • repne cmpsb:若本次不相等则比较下一个

字符串扫描

  • scasb
  • repne scasb
  • repe scasb
    计算字符串的长度
    mov ax, 1000h
    mov es, ax
    mov di, 2000h ;es:di表示目标串
    mov cx, 0FFFFh ;最多查找0FFFFh次
    mov al, 0 ;AL=待查找的字符
    cld
    repne scasb
    not cx ;相当于 FFFF-cx
    dec cx ;要去掉一个0,不能把'\0'算进去
    
跳过前导无效字符
mov al, '#'
cld
repe scasb
dec di
inc cx

内存块的重复填充

rep stosb/sw/sd,将一段内存全部置为一个值

获取一个单位长度

lodsb/w/d

函数

定义

可以直接用一个标签定义,也可以用函数名+proc定义(这是一种比较正式的写法)

call指令会先压栈,call与jmp最大的区别也在此,因为call还需要返回,再跳转,比如假设call f,会进行如下操作

push offset next
jmp f
cpu在返回时进行如下操作,把call下方的指令压入堆栈
pop ip

参数传递

  1. 寄存器传递:因为汇编的寄存器相当于C语言的全局变量
  2. 变量传递:x86里面db,dw定义的变量也是全局的,但是无法支持多线程
  3. 堆栈传递:push一次栈针自动减2,对于没有用的参数,可以直接强制把sp+2,避免回收垃圾数据。push一次可以传入多个参数

Note

用堆栈传递函数的参数主要有三种规范: 1. __cdecl:参数从右到左插入堆栈,参数的清理有调用者负责 2. __pascal:参数从左到右插入堆栈,参数的清理由被调用者负责 3. __stdcall:参数从右到左的顺序插入堆栈,参数的清理由被调用者清理

f:
  push bp
  mov bp, sp
  mov ax, [bp+4]
  add ax, ax
  pop bp
  ret

递归

参考MIPS中学的递归,不应该被改变的量一定要压栈。因为x86中sp并不能直接使用,所以其实是压bp。 寄存器全是全局寄存器

code segment
assume cs:code
f porc near
  push bp
  mov bp, sp
  mov ax, [bp+4]
  cmp ax, 1
  je done
  dec ax
  push ax
  call f
there:
  add sp, 2
  add ax, [bp+4]
done:
  pop bp
  ret
f endp

main:
  mov ax, 3
  push ax, 4Ch
  int 21h
code ends
end main

显存

文本模式

计算机启动时,显卡的文本模式(B800:0000)与显卡相应的内存地址映射起来。文本模式只能显示字符,每个字符占两个字节,第一个字节是字符的ASCELL码,第二个字节是字符的颜色与背景颜色,高四位是背景色,第四位是前景色。显存的地址计算公式为偏移地址=(y*80+x)*2

图形模式

图形模式下,能够显示像素点而非字符,图形模式下,显存地址计算公式为偏移地址=y*320+x。切换到图形模式可以通过BIOS中断int 10h实现,指定相应功能号即可完成切换。

杂项

特殊符号

  • $可以用来表示当前行的末尾或者字符串的结尾
  • 0DH(Carriage Return),表示回车,光标回到行首
  • 0AH(Line Feed),表示换行,在DOS操作系统中,回车和换行是分开的,Windows中会被合并

系统调用

调用操作系统函数,比如int 21h中断

调试器

调试器本身就是一个程序,会用到被调试程序的堆栈段,从而导致堆栈被部分覆盖,这是正常现象。堆栈段初始值不重要,因为始终是先push再pop,不会影响到被调试程序的有效数据

初始化

程序开始时,dos把exe读入内存,然后会对一下寄存器做初始化 1. ss:sp ss=stk,sp=len(stack) 2. cs:ip cs=code,ip=offset main 3. ds=psp segment address 4. es=psp segment address

psp 是操作系统分配的100h连续空间,而且一定位于程序段首,里面存放的是一些和当前程序运行有关的进程信息,并没有自动把ds,es赋值成data的段地址,会因为考虑到了程序员主动访问psp的可能

关于如何计算psp的段地址:

mov ax, ds
sub ds, 10h
mov ds, ax

Warning

100h 和 10h 的差距是10h倍,即十进制的16,不是10倍

关于内存

DOS系统是单任务的系统,所以一个程序运行时其余都是空闲的,从0000:0000到9000:FFFF的内存都是可用的,但是这之后的内存就会被用于映射硬件,所以常规编程不允许使用

;显卡地址
A000:0000~A000:FFFF
B000:0000~B000:FFFF
C000:0000~C000:FFFF
;ROM
D000:0000~F000:FFFF

命令行参数

就是命令行 + 参数

空格是命令行的一部分,但是回车(0Dh)不是,对应C语言里的argc,argv

BCD码

mov al, 29h
add al, 02h
daa ; if AF=1 or AL&0Fh>9
;AL=AL+6

时钟

70h及71h端口与coms内部的时钟有关,期中cmos的4、2、0中分别保存了当前的时分秒,并且格式均为BCD码

端口

CPU 不能直接控制I/O设备,它必须向I/O设备发送信号才能把控制信号输出到I/O设别。

特殊的键盘位是无法通过getchar读取的,这种情况下要使用更底层的指令

;高,mos中断调用
mov ah, 1
int 21h
;中 biod(basic input/outpust system)中断调用
;可以读取方向键,功能键等,但是不能读取单独的ctrl
mov ah, 0
int 16h
;低,端口操作,可以读取各个键的编码
in al ,60h

中断调用

中断调用int n的目标地址是一个32位远指针,这个远指针被称作int n的中断向量并且保存在0000:n*4处 [0000:0000,0000:03FFh]这个区间被称作中断向量表,中断向量表中一共放了从int 00h到int 0FFh共100h个中断向量

混合语言编程

Note

C语言的任何函数名,经过编译都会多出一个_

这个应该是没有办法在现如今的环境去实操的,要在虚拟机下的TC或者VC中操作

TC

TC 里面需要加asm关键字,而且不能在IDE里面运行,得用命令行 汇编语言的类型是未知的,所以需要指定宽度 TC中的代码段并不是code segment( 而是_TEXT

VC

使用__asm{}包围汇编代码块,如果要定义纯汇编函数要使用naked避免编译器重复添加函数框架