汇编语言学到第三讲的时候,很多人会第一次感到“卡壳”。前两讲还在讲数制、编码、寄存器名称,画个图就能明白;到了第三讲,屏幕上突然冒出一堆[BX+SI+10H]这样的写法,第四讲又是一整页指令表,第五讲直接要求你搭出一个能编译运行的完整程序框架。我当年就是在这个位置原地打转了两周,书翻烂了也没搞懂为什么同样是取一个数,非得搞出七种寻址方式。
这篇内容就是围绕汇编语言课程里第三、四、五讲这条主线展开的。我把这三讲分别对应成三件事:第三讲寻址方式解决“数据从哪儿来”,第四讲指令系统解决“拿到数据怎么处理”,第五讲程序结构解决“零散指令怎么组装成能跑的程序”。这三讲是汇编入门最密集的一段,也是后面学中断、接口、反汇编分析的地基。不管你是刚上完前两讲的学生,还是工作几年后想回头补一补底层知识、看懂反汇编窗口的开发者,都可以顺着这条线走一遍。文中的代码和参数我都按 16 位实模式环境验证过,工具用经典的宏汇编器和调试器即可复现,不需要额外硬件。
1. 三讲的整体设计与内容编排思路
1.1 为什么是“寻址—指令—结构”这条主线
我见过不少教材把顺序排成“指令系统—寻址方式—程序结构”,也有排成“寻址方式—程序结构—指令系统”的。从教学效果看,“寻址—指令—结构”这个顺序是最顺的,因为它对应着一个初学者真实的认知过程:先知道东西放在哪,再知道能对它做什么,最后知道怎么把一系列动作串起来。
举个生活里的类比。你要整理一个仓库。第一步得先弄清货架编号规则,A 区第 3 排第 5 层是什么位置,这就是寻址方式;第二步得知道有哪些工具,叉车能搬箱子、扫码枪能录入、计算器能算库存,这就是指令系统;第三步才是制定一整套作业流程,先扫码再上架最后记账,这就是程序结构。你要是跳过第一步直接背指令表,就会陷入“每个字都认识,连起来不知道在干嘛”的状态——MOV AX, [BX]和MOV AX, [1234H]背得滚瓜烂熟,但一问你这两条指令执行后 AX 里的值从哪个物理单元来、为什么一个要加段前缀一个不用,立刻哑火。
所以我个人的建议很明确:第三讲不要急着背指令,把七种寻址方式的“地址怎么算出来”这件事吃透,宁可多花两天;第四讲开始堆指令量,但每条指令都要绑定一个具体的寻址场景去记;第五讲才动真格的写完整程序。这个节奏比平均分配时间有效得多。
1.2 位宽与工具链的选型考量
这里有个绕不开的岔路口:入门学 16 位实模式,还是直接上 32 位保护模式?
我的答案是入门必须走 16 位实模式,但要在第五讲结束时把映射关系交代清楚。理由很实在:16 位实模式的寻址模型简单到可以手算——段地址左移四位加偏移,一个乘法就出物理地址,没有任何权限检查、没有描述符表、没有分页。你能把全部注意力放在“指令在干什么”上。而 32 位保护模式一上来就要面对平坦内存模型、段选择子、GDT,这些概念会把你真正该学的东西冲散。
工具链方面,16 位环境用宏汇编器加调试器就够了。宏汇编器负责把.ASM翻译成.OBJ再连接成.EXE,调试器负责单步、看寄存器、看内存。如果不想折腾连接过程,用模拟器软件更省事,它把编辑、汇编、运行、看寄存器面板集成在一个窗口里,适合第三、四讲做指令验证。等到第五讲要写完整程序、要调试堆栈的时候,切换回真实的汇编器加调试器,因为模拟器对INT 21H的行为模拟得不完全,有些堆栈相关的现象看不出来。
提示:不要在第三讲就折腾 64 位汇编。64 位下寻址方式变成了 RIP 相对寻址为主,寄存器数量翻倍,很多指令的默认操作数长度也变了,此时学寻址会学成另一套东西。
1.3 三讲各自要达成的“三个能”
我给自己定的验收标准是这样的,你可以拿去当自测清单:
| 讲次 | 核心内容 | 达标标准 |
|---|---|---|
| 第三讲 | 寻址方式 | 给出任意一条带方括号的指令,能写出它的段寄存器、偏移表达式、物理地址 |
| 第四讲 | 指令系统 | 能手写多字节加减、乘除、移位,且能预判每条指令执行后六个状态标志的值 |
| 第五讲 | 程序结构 | 能不看资料独立写出带数据段、堆栈段、代码段的完整程序,并用子程序实现功能模块 |
这三个标准里,最容易自欺欺人的是第四讲的“预判标志位”。很多人写代码时根本不管标志位,写完跑一遍看结果对不对就完事,结果一遇到JC、JS、JO这种条件转移就完全靠猜。这是后面所有问题的总根源,我在第五节会专门讲。
2. 第三讲:寻址方式,把“数据从哪来”这件事彻底问清楚
2.1 七种寻址方式逐条拆解
寻址方式的本质是回答一个问题:这条指令的操作数在哪里?是在指令自己身上(立即数),还是在某个寄存器里,还是在内存在某个地址上?如果在内存在,这个地址怎么算出来?
按“操作数位置”和“地址计算复杂度”两个维度,8086 的七种寻址方式可以这样排:
立即寻址:操作数直接写在指令里,例如MOV AX, 1234H。数据跟着指令一起待在代码段,取指令的时候顺带就取到了。特点是快,但没有灵活性,值写死了。
寄存器寻址:操作数在通用寄存器里,例如MOV AX, BX。不访问内存,速度最快,指令也最短。
直接寻址:方括号里直接给偏移地址,例如MOV AX, [1234H]。默认段寄存器是 DS,所以物理地址是 DS×16+1234H。
寄存器间接寻址:偏移地址存在寄存器里,例如MOV AX, [BX]。注意能用方括号间接寻址的寄存器只有四个:BX、BP、SI、DI。AX、CX、DX 不行,这是硬规定。
寄存器相对寻址:寄存器加一个 8 位或 16 位位移量,例如MOV AX, [BX+10H],也可以写成MOV AX, 10H[BX],两种写法等价。
基址变址寻址:一个基址寄存器加一个变址寄存器,例如MOV AX, [BX+SI]。基址只能是 BX 或 BP,变址只能是 SI 或 DI,不能 BX+BP 这样配。
相对基址变址寻址:基址加变址再加位移,例如MOV AX, [BX+SI+10H]。这是最完整的形式,二维数组访问就靠它。
我用一句大白话来概括这个演进:立即数是“当场给”,寄存器是“手边拿”,直接寻址是“报门牌号”,间接寻址是“问仓库管理员”,相对寻址是“门牌号再加偏移”,基址变址是“行号加列号”,相对基址变址是“行加列再加偏移”。二维数组a[i][j]用 16 位寻址时,如果每行 10 个字节,那么BX放i×10、SI放j,就是标准的[BX+SI]模式,这个对应关系一旦建立起来,后面的数组访问代码几乎不用想就能写出来。
2.2 物理地址的计算与段寄存器的隐含约定
地址这块有个必须背下来的公式:物理地址 = 段地址 × 16 + 偏移地址。段地址存在段寄存器里(CS、DS、SS、ES),偏移地址由寻址方式算出来。左移四位等价于十六进制数末尾补一个 0,所以DS=1000H、偏移1234H时物理地址就是11234H,心算就能出结果。
关键在于默认段寄存器是哪几个,这是很多人容易混的地方:
| 寻址方式 | 默认段寄存器 |
|---|---|
| 使用 BX、SI、DI 的寻址 | DS |
| 使用 BP 的寻址 | SS |
| 串操作指令的源操作数 | DS |
| 串操作指令的目的操作数 | ES |
| 取指令 | CS |
| 堆栈操作 PUSH/POP/CALL/RET | SS |
为什么用 BP 的时候默认是 SS?因为 BP 设计出来就是给访问函数栈帧用的,配合PUSH BP/MOV BP, SP那套标准开头,局部变量都在[BP+4]、[BP+6]这些位置,默认用 SS 能省掉一个段前缀。这个设计意图想清楚了,就不会再去纠结“为什么我访问栈里的数据不用写 SS:”。
如果需要换段,就在指令前加段超越前缀,例如MOV AX, ES:[BX]。段超越前缀会占一个字节,指令变长。至于DS的初始化,那是第五讲程序框架里的事——代码段里的段地址无法直接MOV DS, 立即数,必须先MOV AX, 数据段名再MOV DS, AX,这两步少一步,你后面所有内存访问都会跑到错误的段里,这是新手最常见的运行结果全错的原因。
2.3 寻址方式对指令长度与执行效率的影响
同样是取一个数到 AX,不同寻址方式编出来的机器码长度差得不少。我把几条典型指令的机器码拆给你看:
MOV AX, 1234H ; B8 34 12 3字节,立即寻址 MOV AX, BX ; 89 D8 2字节,寄存器寻址 MOV AX, [1234H] ; A1 34 12 3字节,直接寻址 MOV AX, [BX] ; 8B 07 2字节,寄存器间接 MOV AX, [BX+10H] ; 8B 47 10 3字节,相对寻址(位移8位) MOV AX, [BX+SI] ; 8B 00 2字节,基址变址 MOV AX, [BX+SI+10H] ; 8B 40 10 3字节,相对基址变址规律很清楚:位移量占地方,16 位位移要占两个字节(如MOV AX, [BX+1234H]是8B 87 34 12),8 位位移只占一个字节。所以在实际写代码时,如果偏移量能塞进 8 位以内(-128 到 +127),汇编器会自动选短格式;超过的话就得多花一个字节。这个细节在写紧凑代码或者做代码长度统计时有意义,日常练习倒不用刻意优化。
执行效率上,寄存器寻址完全不访问内存,EU直接干活;内存寻址要走总线,虽然 8086 的BIU会预取指令,但取数据还是要等。所以能放寄存器里反复用的值,就别每次都从内存捞——这个原则放到今天的编译器优化里依然成立,编译器把循环变量提升到寄存器,本质上就是同一件事。
2.4 实操:把七种寻址方式一个个验证出来
光看不行,得动手验证。我推荐的做法是准备一段极短的代码,用调试器逐条单步,每次单步后看寄存器和对应内存的变化。
第一步,准备好数据段并初始化:
DATA SEGMENT VAL1 DW 1234H VAL2 DW 5678H ARR DW 10H, 20H, 30H, 40H DATA ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA START: MOV AX, DATA MOV DS, AX ; 关键一步,不写则后续全部读错段 MOV BX, OFFSET ARR MOV SI, 2 MOV AX, VAL1 ; 直接寻址,AX=1234H MOV AX, [BX] ; 间接寻址,AX=0010H MOV AX, [BX+SI] ; 基址变址,AX=0020H MOV AX, [BX+SI+2] ; 相对基址变址,AX=0030H MOV AH, 4CH INT 21H CODE ENDS END START第二步,进调试器后先用反汇编命令U看一眼代码,确认汇编器把你的写法翻译成了什么。你会看到MOV AX, VAL1被翻译成带方括号的直接寻址形式,这个“符号名等价于偏移地址”的认识很关键——VAL1不是值 1234H,它是偏移地址,MOV AX, VAL1之所以能取到值,是因为汇编器把它当成了MOV AX, [VAL1]。
第三步,逐步T单步,每一步之后用R看 AX、BX、SI 的值,用D DS:0看数据段内存。这一步我建议慢一点做,每步都对着预期值核对一遍:[BX+SI]时 BX 是 0(ARR 的偏移)、SI 是 2,所以取到ARR+2即第二个字 0020H。如果这里对不上,说明 DS 没设对,回到上一步查。
第四步,故意不写MOV DS, AX那一行,再跑一次,观察结果怎么变的。这种“破坏性实验”比看十遍书管用,你会亲眼看到 DS 里残留的是调试器启动时的默认值,所有内存读取都偏移到别的地方去了,取出来的是垃圾数据。这个体验一旦有了,以后写代码忘初始化 DS 的时候会有生理性的警觉。
2.5 寻址环节的几个坑
第一个坑是把OFFSET和方括号搞混。MOV BX, OFFSET ARR是把偏移地址放进 BX,MOV BX, ARR是把 ARR 这个位置的内容放进 BX。前者是取地址,后者是取值。C 语言里&arr和arr的区别,在这里体现得一模一样。
第二个坑是BP 当基址时的默认段。用MOV AX, [BP]的时候默认段是 SS 不是 DS,如果 BP 里放的是数据段的偏移,取出来的就是栈里的垃圾。这种情况要么改段前缀DS:[BP],要么干脆别用 BP 当数据段的基址。
第三个坑是方括号里不能用 AX、CX、DX。MOV AX, [DX]会直接汇编报错,因为 8086 的地址生成电路只接了 BX、BP、SI、DI 四根线。刚学的时候总觉得这规定莫名其妙,其实站在硬件设计角度想就通了——地址计算是个加法器,输入端只有这么多个,多了布线成本上去了。
第四个坑是两个变址寄存器不能同时用。[BX+BP]和[SI+DI]都不合法,必须是“一个基址 + 一个变址”的组合。这个限制在 32 位以后就放开了,[EAX+EBX*4]这种随便配,所以从 16 位过渡到 32 位时这一点会觉得特别解脱。
3. 第四讲:指令系统,从“能搬”到“会算”
3.1 数据传送指令族,别小看“搬”这件事
传送类指令看着最简单,实际上问题最多。核心是MOV,但MOV有两条铁律:源和目的不能同时是内存(MOV [BX], [SI]不合法,必须借道寄存器),源和目的不能同时是段寄存器(MOV DS, ES不合法)。
XCHG是交换,XCHG AX, BX直接互换,省去三次 MOV。它还常被用来做“只用寄存器交换内存”的技巧。
LEA和OFFSET的区别要专门说一下。两者都能取有效地址,但LEA是运行时计算,OFFSET是汇编时确定。所以LEA BX, [BX+SI+10H]会在程序运行到这条指令时真的做一次加法,而MOV BX, OFFSET ARR在汇编阶段就把地址常量填进去了。LEA还有个不太正经但很常用的玩法:LEA AX, [BX+BX]等于AX = BX*2,用地址计算单元来做乘法,速度快于MUL,这种技巧在早期的优化代码里很常见。
PUSH和POP是堆栈操作,记住两条:栈向低地址方向生长(PUSH 时 SP 减 2),操作数必须是 16 位。PUSH AL不合法,要PUSH AX。PUSH和POP一定要配对,不然栈就歪了,RET的时候会跳到不知道哪里去。
XLAT是查表指令,用BX指向表首、AL作索引,执行后AL变成表里的值。它专门用于码表转换,比如把 0~15 转成十六进制字符'0'~'F',一行XLAT顶一个循环,效率很高。用的时候注意AL里的索引从 0 开始,表要放在 DS 段内。
3.2 算术指令与标志位的联动
算术指令这一块,真正的难点不是“怎么算”,而是“算完之后标志位变成什么样”。六个状态标志是后面条件转移指令的唯一依据,不搞清楚这个,第四讲等于白学。
先把六个状态标志的语义摆清楚:
| 标志 | 名称 | 什么时候置 1 |
|---|---|---|
| CF | 进位/借位 | 最高位向更高位有进位或借位 |
| ZF | 零标志 | 结果为 0 |
| SF | 符号标志 | 结果的最高位为 1(负数) |
| OF | 溢出标志 | 有符号数运算超出表示范围 |
| PF | 奇偶标志 | 结果低 8 位中 1 的个数为偶数 |
| AF | 辅助进位 | 低 4 位向高 4 位有进位或借位 |
ADD和ADC的区别就在进位:ADC会把上一条指令的 CF 加进去。这就是多字节加法的实现方式——低位用 ADD,高位用 ADC。写 32 位加法(用两个 16 位模拟)时:
; 计算 (DX:AX) = (DX:AX) + (CX:BX) ADD AX, BX ; 低位相加,可能产生进位 ADC DX, CX ; 高位相加,并把低位进位加进来SUB和SBB同理,SBB减时会把 CF 减掉,多字节减法靠它。借位的方向要特别注意:SBB是“减数 + CF”,不是“减 CF”,写成SBB AX, BX表示AX = AX - BX - CF。
CMP是做减法但不保存结果,只改标志位。这条指令是分支判断的绝对主力,CMP AX, BX之后紧跟JA/JB/JE之类。TEST则是做与运算不保存结果,专门用来测某一位是否为 1,TEST AL, 01H后面跟JNZ就是判断最低位。
MUL和IMUL有一点必须记住:乘数的位数决定了结果放在哪。8 位乘法是AL × 操作数 → AX;16 位乘法是AX × 操作数 → DX:AX。也就是说如果两个 16 位数相乘,结果可能用到 32 位,必须用 DX:AX 接。这是很多人第一次写乘法时结果“莫名奇妙被截断”的原因。
DIV和IDIV的规则是:被除数位数必须是除数的两倍。8 位除法是AX ÷ 操作数 → AL 商、AH 余数;16 位除法是DX:AX ÷ 操作数 → AX 商、DX 余数。所以做 16 位除法之前,一定要把 DX 清零(XOR DX, DX或MOV DX, 0),否则 DX 里的残留值会让被除数变得极大,触发除法溢出中断,程序直接崩。这个坑我踩过不止一次,明明只想除两个 16 位数,结果程序运行到某一行就莫名其妙跳走了。
3.3 逻辑运算与移位指令的实用技巧
AND常用来清位,AND AL, 0F0H把低 4 位清零。OR用来置位,OR AL, 01H把最低位置 1。XOR用来翻转位,XOR AL, 0FFH相当于取反。XOR AX, AX是把 AX 清零的经典写法,比MOV AX, 0短一个字节,而且还顺带把 CF 和 OF 清零了。
移位指令分两组:SHL/SHR是逻辑移位,空出来的位补 0;SAL/SAR是算术移位,其中SAR右移时补符号位。左移在数值上等价于乘 2 的幂,右移等价于除 2 的幂(向下取整)。SHL AX, 1比MUL快得多,这种优化思路在资源受限的场合很实用。
移位位数超过 1 时必须用CL指定,例如SHL AX, CL。而且只有移 1 位时OF才有意义,移多位时OF的值是未定义的,别指望它。
ROL/ROR是循环移位,移出去的位补回另一端。RCL/RCR是带进位的循环移位,把 CF 当作中间那一环参与循环,多字节数的整体左移右移就靠它。
一个很实用的小技巧:十六进制数字转 ASCII 字符。0~9 的 ASCII 是 30H~39H,A~F 是 41H~46H。判断方法是CMP AL, 9,JBE就走加 30H 的路径,否则加 37H。这套逻辑在写十六进制输出程序时几乎必用,我建议直接背下来。
3.4 串操作指令,批量处理的捷径
串操作指令是MOVS、LODS、STOS、CMPS、SCAS五条,配合重复前缀REP、REPE、REPNE使用,可以实现内存块的批量搬运和比较。
核心机制是:源地址用 DS:SI,目的地址用 ES:DI,方向由 DF 决定,计数用 CX。REP MOVSB就是“把 DS:SI 开始的内容搬到 ES:DI,搬 CX 次,每次 SI、DI 自增或自减 1”。
用之前必须做三件事:初始化 SI、初始化 DI、初始化 CX,另外必须保证 ES 已经被正确赋值。ES 不能通过MOV ES, 立即数赋值,得借道寄存器,这一点和 DS 一样。
注意:
REP MOVSB里的MOVSB是字节操作,MOVSW是字操作。用字操作时 CX 是“字数”不是“字节数”,搬运 100 个字节用MOVSW只需要 CX=50。这个换算搞错会导致搬一半或者越界。
CMPSB配合REPE可以做字符串比较,SCASB配合REPNE可以做字符查找。这几条指令用熟了,写字符串处理的代码量能砍掉一大半。
3.5 实操:一个多字节加法与输出的小程序
光说不够,来一个完整的验证。目标是把两个 32 位数相加(用 DX:AX 和 CX:BX 分别表示),结果再转成十六进制输出。
; 高位在 DX:CX,低位在 AX:BX,实际用的是两组16位累加 MOV AX, 5678H MOV DX, 1234H MOV BX, 9ABCH MOV CX, 0001H ADD AX, BX ; 低位相加 ADC DX, CX ; 高位加上进位 ; 此时 DX:AX 就是结果逐条单步执行,每次都看 CF 的变化。第一次ADD后,5678H + 9ABCH = F134H,超过 16 位,CF=1。第二次ADC时1234H + 0001H + 1 = 1236H,CF 被清零。最终结果1236F134H,可以用计算器验证一下,完全对得上。
这种“每执行一步就核对标志位和结果”的训练方式,比写十个程序都管用。我建议把加、减、乘、除各做一遍,尤其是MUL和DIV的边界情况,比如乘出 32 位结果、除数为 0、商超过范围触发溢出中断,这些异常场景实际遇到过一遍,以后代码里自然会有防范意识。
4. 第五讲:程序结构,把散装指令组装成能跑的程序
4.1 段定义与标准程序框架
第五讲的核心转变是:从“一条条独立指令”变成“一个有结构的程序”。汇编程序的骨架由段构成,一个完整的可执行程序至少要有代码段,通常还有数据段和堆栈段。
标准的 16 位程序模板长这样:
DATA SEGMENT MSG DB 'HELLO, ASSEMBLY!', 0DH, 0AH, '$' DATA ENDS STACK SEGMENT STACK DB 200 DUP(0) STACK ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK START: MOV AX, DATA MOV DS, AX MOV AX, STACK MOV SS, AX MOV SP, 200 LEA DX, MSG MOV AH, 09H INT 21H MOV AH, 4CH INT 21H CODE ENDS END START这个模板里有几个点必须理解到位,而不是照抄:
ASSUME只是告诉汇编器“假设某个段寄存器指向某个段”,它是一条伪指令,不产生任何机器码,也不会真的去修改段寄存器的值。真正赋值还是靠MOV DS, AX那几行。很多人在ASSUME上纠结半天,以为写了就自动生效,结果程序一跑就出错。
END START里的START是程序入口标号。连接器根据这个标号设置可执行文件的入口地址,也就是 CS:IP 的初始值。如果写成END(不带标号),连接器会警告“没有入口点”,程序跑起来是从代码段开头开始执行,如果开头恰好是数据或者子程序,就会执行一堆莫名其妙的东西。
堆栈段必须自己初始化 SS 和 SP,除非用了STACK参数让系统自动处理。有些教材说写了STACK SEGMENT STACK就不用管了,这个说法不完全对——它能保证 SS 被正确赋值,但 SP 的初始值不一定是你期望的,稳妥做法还是显式设置。
4.2 数据定义伪指令与内存布局
数据定义伪指令是DB、DW、DD,分别定义字节、字、双字。?表示保留空间不初始化,DUP表示重复,$表示当前地址计数器。
$的用法特别值得说。MSG DB 'HELLO', '$'之后,MSG_LEN EQU $ - MSG就能自动算出字符串长度,不用手数。这个技巧在写循环处理字符串时非常方便,改字符串内容不用改长度常量,避免了两处不一致的 bug。
还有个细节是内存中的字节序。DW 1234H在内存里是34 12,低字节在前。这是小端序,调试器里看内存时如果按字节看,会觉得数值“反了”,其实没错。第一次在调试器里看到这个现象时我愣了半天,以为哪里出问题了。
数据段里不同类型的数据要留意对齐。DB定义之后紧跟DW,字的起始地址可能是奇数,某些操作(比如字符串指令按字操作)会有性能影响。日常练习不用刻意对齐,但知道这回事,以后看编译器的输出时不会觉得人家莫名其妙插了一堆填充字节。
4.3 分支与循环的三种写法
汇编里没有if和for,只有条件转移和无条件转移。写分支的核心思路是**“先算标志再跳,或者跳过去再补做”**。
第一种写法是标准的双分支:
CMP AL, 'a' JB NOT_LOWER ; 小于 'a' 不是小写 CMP AL, 'z' JA NOT_LOWER ; 大于 'z' 不是小写 SUB AL, 20H ; 是小写,转成大写 NOT_LOWER:第二种写法是用跳转表做多路分支,适合分支数很多的情况。把各分支的入口地址放进一个表,用索引去查,JMP到对应位置。这种技巧在写状态机、菜单驱动时很好用,比一长串CMP + JE清爽得多。
第三种写法是用标志位控制的循环,适合循环次数未知、由条件决定什么时候停的情况。
循环方面,LOOP指令是最省事的:CX减 1,不为 0 就跳转。注意它先减后判断,所以如果CX初始值是 0,LOOP会循环 65536 次——这个坑极其经典,CX=0并不代表不循环。所以进入LOOP之前必须先判断CX是否为 0。
LOOPE/LOOPNE是带零标志判断的循环,配合CMP可以做“找到第一个匹配项就停”的查找。
用LOOP有个限制:循环体不能太长,因为LOOP的跳转范围是 -128 到 +127 字节。循环体一大就汇编报错“跳转超出范围”,这时候得改成DEC CX+JNZ的组合,后者的跳转范围大得多。
4.4 子程序、堆栈与参数传递
子程序用CALL调用、RET返回。CALL做的事是把下一条指令的地址压栈,然后跳到子程序入口;RET则是从栈里弹出返回地址。理解了这个机制,很多问题就清楚了:为什么子程序里必须保证栈平衡(压了多少就弹多少),为什么PUSH和POP不配对会导致RET跳到错误位置。
参数传递有三种常见方式:
寄存器传递最简单,把参数放 AX、BX 里,子程序直接用。缺点是参数一多就不够用,而且寄存器是全局的,子程序内部不小心改了会影响调用方。
内存变量传递是把参数放在数据段的固定位置,子程序按名字访问。好处是参数数量不受限,坏处是子程序不能重入,也不方便递归。
堆栈传递最正规,调用方把参数PUSH进去,子程序用[BP+4]、[BP+6]访问。标准写法是:
MYPROC PROC PUSH BP MOV BP, SP ; 此时 [BP+4] 是第一个参数,[BP+6] 是第二个 MOV AX, [BP+4] ... POP BP RET MYPROC ENDP这个PUSH BP / MOV BP, SP的开头是固定套路,作用是保住调用方的 BP,同时把当前栈顶记下来当基准,这样参数偏移量就固定了。最后POP BP恢复。如果你用堆栈传参,这个开头不能省,省了 BP 就被覆盖了。
返回值一般放 AX,多字节结果放 DX:AX。
4.5 完整实战:带子程序的字符串大小写转换
来一个综合的例子,把前面所有东西串起来:把数据段里的一串字符做大小写转换(小写转大写),用子程序实现,主程序负责调用和输出。
DATA SEGMENT BUF DB 'Hello, Assembly World!', 0 LEN EQU $ - BUF - 1 DATA ENDS STACK SEGMENT STACK DB 200 DUP(0) STACK ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK START: MOV AX, DATA MOV DS, AX LEA SI, BUF MOV CX, LEN CALL_TOUPPER: CALL TOUPPER LEA DX, BUF MOV AH, 09H INT 21H MOV AH, 4CH INT 21H ; 子程序:把 DS:SI 开始的 CX 个字符中的小写转成大写 TOUPPER PROC PUSH AX PUSH SI PUSH CX NEXT_CHAR: MOV AL, [SI] CMP AL, 'a' JB SKIP CMP AL, 'z' JA SKIP SUB AL, 20H MOV [SI], AL SKIP: INC SI LOOP NEXT_CHAR POP CX POP SI POP AX RET TOUPPER ENDP CODE ENDS END START这段代码里有几个细节值得停下来想一下。子程序开头PUSH AX / PUSH SI / PUSH CX是保护现场,因为子程序内部要用到这三个寄存器,用完必须还原,否则调用方原来 CX 里的循环次数就被破坏了。结尾POP的顺序必须和PUSH相反,后进先出,这个规则违反了栈就乱了。
LEN EQU $ - BUF - 1里的减 1 是因为末尾有个0作为结束符,不算在要转换的字符里。这种边界处理是写汇编的日常,差一个数就会多处理一个字节,可能把后面的数据段内容改了。
写完这段代码,我建议用调试器单步走一遍:看CALL之后栈里多了什么,进子程序后 BP、SP 怎么变,LOOP执行到CX为 0 时怎么退出,RET之后 IP 回到哪里。这一遍走下来,第五讲的内容基本上就吃透了。
5. 常见问题与排查技巧实录
5.1 汇编阶段的报错速查
汇编器报错通常比较直白,但有几个高频错误容易看错行号:
| 报错信息关键词 | 常见原因 | 处理办法 |
|---|---|---|
| Operand types must match | 操作数位宽不一致,如MOV AL, BX | 改成同为 8 位或同为 16 位 |
| Illegal use of register | 方括号里用了 AX、CX、DX | 换成 BX、BP、SI、DI |
| Jump out of range | 条件转移目标超出 -128~+127 | 改用JMP + 条件转移两段式 |
| Symbol not defined | 标号拼错,或者END位置不对 | 检查标号拼写和END后的入口名 |
| Unexpected end of file | ENDS或ENDP少写了 | 逐个段检查配对 |
“Jump out of range”这个错误特别值得说。条件转移指令(JE、JNE、JA之类)都是短跳转,只有 8 位位移,范围就是 ±127 字节。循环体一长,或者中间夹了太多指令,就会超范围。解决办法是把它改成先跳到中间位置:
JNE SKIP_LONG ; 改成跳到一个远一点的位置 JMP FAR_TARGET SKIP_LONG:或者直接用JMP配合条件判断的反逻辑。这个技巧在实际写大循环时几乎一定会用到。
5.2 运行阶段“结果不对”的排查顺序
程序能汇编能连接,但运行结果不对,这是最耗时间的。我的排查顺序是固定的三步,每次都按这个顺序走,很少绕远路:
第一步,查段寄存器。看 DS、ES、SS 的值是不是预期的。90% 的“数据读错了”问题都在这里。DS 没初始化、ES 忘了赋值、SS 和 SP 没设置对,这三件事占据了运行错误的大头。调试器里R一下就能看到,五秒钟的事。
第二步,查标志位和条件转移。如果程序逻辑走错了分支,就在CMP之后、跳转之前下断点,看标志位到底是什么。我遇到过太多次“我以为CMP之后 CF 是 1”,结果实际是 0,导致跳转方向整个反了。根源往往是把有符号和无符号的跳转指令用混了:有符号数比大小要用JG/JL,无符号数要用JA/JB。CMP AL, 80H之后,AL=01H时按无符号看是小于,按有符号看是大于,方向完全相反。
第三步,查堆栈。如果程序跳飞了(执行到不该执行的地方),基本就是栈不平。检查PUSH和POP是不是配对,子程序里有没有漏掉POP,CALL之前栈顶有没有被意外改动。调试器里连续看几次 SP 的值,进出子程序前后应该回到同一个数,对不上就是有问题。
5.3 调试器的使用技巧
几个提高效率的习惯,都是被坑出来的:
善用P命令跳过中断。T是单步,但遇到INT 21H的时候,T会钻进系统中断的处理代码里,几千行汇编看得人崩溃。用P直接把整个中断执行完,一步到位。
用G加断点地址来跑到指定位置。不用一步步挪,G 0010直接跑到偏移 0010H 处停下,中间跳过一堆无关代码。
用D命令分段看内存。D DS:0看数据段开头,D SS:0看栈,发现数据被莫名改写时,对比修改前后两块内存,能快速定位是哪个写操作越界了。
打断点前先U反汇编看一眼。有时候你写的是MOV AX, [BX],汇编器却因为ASSUME的关系翻译成了带段前缀的形式,机器码多一个字节,地址全偏了。先看完反汇编再下断点,能避免断在错误的位置上。
5.4 几个反直觉的坑
坑一:MOV不能直接赋值段寄存器。MOV DS, 1000H不合法,必须MOV AX, 1000H再MOV DS, AX。这不是汇编器的怪癖,是 CPU 指令集就这么设计的。
坑二:DIV之前忘了清 DX。前面提过一次,这里再强调一遍,因为它的表现形式特别迷惑——程序不是报错,是直接跳到某个奇怪的地方,或者死循环。原因是被除数变成了DX:AX拼起来的巨大数值,商超出范围触发了除法异常。养成习惯:做 16 位除法之前,先XOR DX, DX。
坑三:LOOP在 CX=0 时循环 65536 次。因为它是“先减再判断”,0 减 1 变成 FFFFH,判断不为 0,继续循环。所以循环次数可能为 0 的场合,必须在LOOP之前加一句JCXZ判断。
坑四:字符串指令的方向标志 DF。如果之前有代码把 DF 置 1 了(用STD),后面的REP MOVSB就会从高地址往低地址搬,结果反了。标准的做法是在程序开头用CLD把 DF 清零,养成这个习惯能省掉很多莫名其妙的调试时间。
6. 三讲之后往哪走
6.1 从 16 位到 32 位的映射关系
把这三讲学完,如果直接跳到 32 位汇编,会有几个明显的“不对劲”:
寻址方式从“段+偏移”变成了平坦模型,段寄存器不再表示段基址,而是变成了段选择子,指向描述符表。大部分情况下你会看到所有段寄存器都指向同一个平坦段,寻址范围一下扩展到 4GB。寻址组合也自由了,[EAX+EBX*4+8]这种带比例因子的写法,正是为数组访问量身定做的。
寄存器从 8 个通用寄存器扩展到 8 个 32 位寄存器,名字前面加个 E。AX变成EAX,BX变成EBX,以此类推。新增了ESI、EDI这两个专用寄存器(在 16 位里是SI、DI)。
指令方面新增了大量东西,但核心那几十条没变。MOV、ADD、CMP、JMP、CALL、RET的语义完全一致,只是操作数可以是 32 位。所以从 16 位过渡到 32 位,真正需要重新学的是段机制、调用约定和系统接口,指令本身算是平滑迁移。
6.2 这三讲在真实场景里的价值
很多人会问,现在都写高级语言了,学这个有什么用。我的实际体验是,这三讲的价值主要体现在三个场景。
看反汇编的时候。调试一个崩溃的 C 程序,符号信息丢了,只剩一堆汇编。这时候你能不能在三十秒内看出“这一段是在做字符串拷贝,但循环计数用错了寄存器”,完全取决于对寻址方式和指令的熟悉程度。我处理过一个越界写内存的问题,就是靠反汇编里一个MOV [ESI+ECX*4], EAX的索引寄存器对不上号定位出来的。
做性能敏感代码的时候。比如嵌入式环境下的位操作、图像处理里的像素遍历,用汇编重写关键循环能带来实打实的提升。写这种代码必须清楚每条指令的长度和周期,这正好是第三讲和第四讲的内容。
理解上层语言机制的时候。函数调用怎么传参、局部变量放哪、结构体成员怎么偏移、虚函数表怎么寻址,这些问题的答案全在这三讲里。搞明白了[BP+4]是第一个参数、[BP-2]是第一个局部变量,再去看编译器生成的栈帧布局,就是看图说话。
6.3 我个人的一点练习建议
学完这三讲,最好的巩固方式是写三个小东西:一个十六进制到十进制的进制转换程序(练除法、练取余、练字符输出)、一个数组排序程序(练寻址、练双重循环、练比较交换)、一个字符串统计程序(统计字母、数字、其他字符各多少个,练字符判断和计数器)。这三个东西加起来不到两百行,但把三讲的知识点全覆盖了。
写的时候刻意不用查资料,卡住了再去翻,翻完立刻回来重写。这种“卡住—翻书—重写”的循环,比顺顺利利抄一遍代码的记忆强度高十倍。我自己的经验是,同一个程序写到第三遍的时候,那些原本需要想一下的指令组合会变成肌肉记忆,看到需求就能直接落笔。
提示:每写完一个程序,都强制自己用调试器单步走一遍完整流程,边走边说出每一步寄存器和标志的变化。说不出来的地方就是没懂的地方,回去补。
最后分享一个小习惯,我觉得挺有用:准备一个本子,专门记“为什么当时写错了”。比如“DIV前忘了清 DX,导致异常跳飞”、“LOOP在 CX=0 时循环 65536 次”、“有符号比较用了JA”。这些错误记录比任何教材都贴身,因为每一条都是你自己踩出来的,复习的时候看一眼就能唤起当时的场景。汇编这门东西,坑就那么多,踩完了就顺了。