资讯详情

资讯详情

汇编入门三讲:寻址方式、指令系统与程序结构

汇编语言学到第三讲的时候很多人会第一次感到“卡壳”。前两讲还在讲数制、编码、寄存器名称画个图就能明白到了第三讲屏幕上突然冒出一堆[BXSI10H]这样的写法第四讲又是一整页指令表第五讲直接要求你搭出一个能编译运行的完整程序框架。我当年就是在这个位置原地打转了两周书翻烂了也没搞懂为什么同样是取一个数非得搞出七种寻址方式。这篇内容就是围绕汇编语言课程里第三、四、五讲这条主线展开的。我把这三讲分别对应成三件事第三讲寻址方式解决“数据从哪儿来”第四讲指令系统解决“拿到数据怎么处理”第五讲程序结构解决“零散指令怎么组装成能跑的程序”。这三讲是汇编入门最密集的一段也是后面学中断、接口、反汇编分析的地基。不管你是刚上完前两讲的学生还是工作几年后想回头补一补底层知识、看懂反汇编窗口的开发者都可以顺着这条线走一遍。文中的代码和参数我都按 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×161234H。寄存器间接寻址偏移地址存在寄存器里例如MOV AX, [BX]。注意能用方括号间接寻址的寄存器只有四个BX、BP、SI、DI。AX、CX、DX 不行这是硬规定。寄存器相对寻址寄存器加一个 8 位或 16 位位移量例如MOV AX, [BX10H]也可以写成MOV AX, 10H[BX]两种写法等价。基址变址寻址一个基址寄存器加一个变址寄存器例如MOV AX, [BXSI]。基址只能是 BX 或 BP变址只能是 SI 或 DI不能 BXBP 这样配。相对基址变址寻址基址加变址再加位移例如MOV AX, [BXSI10H]。这是最完整的形式二维数组访问就靠它。我用一句大白话来概括这个演进立即数是“当场给”寄存器是“手边拿”直接寻址是“报门牌号”间接寻址是“问仓库管理员”相对寻址是“门牌号再加偏移”基址变址是“行号加列号”相对基址变址是“行加列再加偏移”。二维数组a[i][j]用 16 位寻址时如果每行 10 个字节那么BX放i×10、SI放j就是标准的[BXSI]模式这个对应关系一旦建立起来后面的数组访问代码几乎不用想就能写出来。2.2 物理地址的计算与段寄存器的隐含约定地址这块有个必须背下来的公式物理地址 段地址 × 16 偏移地址。段地址存在段寄存器里CS、DS、SS、ES偏移地址由寻址方式算出来。左移四位等价于十六进制数末尾补一个 0所以DS1000H、偏移1234H时物理地址就是11234H心算就能出结果。关键在于默认段寄存器是哪几个这是很多人容易混的地方寻址方式默认段寄存器使用 BX、SI、DI 的寻址DS使用 BP 的寻址SS串操作指令的源操作数DS串操作指令的目的操作数ES取指令CS堆栈操作 PUSH/POP/CALL/RETSS为什么用 BP 的时候默认是 SS因为 BP 设计出来就是给访问函数栈帧用的配合PUSH BP/MOV BP, SP那套标准开头局部变量都在[BP4]、[BP6]这些位置默认用 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, [BX10H] ; 8B 47 10 3字节相对寻址位移8位 MOV AX, [BXSI] ; 8B 00 2字节基址变址 MOV AX, [BXSI10H] ; 8B 40 10 3字节相对基址变址规律很清楚位移量占地方16 位位移要占两个字节如MOV AX, [BX1234H]是8B 87 34 128 位位移只占一个字节。所以在实际写代码时如果偏移量能塞进 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 ; 直接寻址AX1234H MOV AX, [BX] ; 间接寻址AX0010H MOV AX, [BXSI] ; 基址变址AX0020H MOV AX, [BXSI2] ; 相对基址变址AX0030H 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看数据段内存。这一步我建议慢一点做每步都对着预期值核对一遍[BXSI]时 BX 是 0ARR 的偏移、SI 是 2所以取到ARR2即第二个字 0020H。如果这里对不上说明 DS 没设对回到上一步查。第四步故意不写MOV DS, AX那一行再跑一次观察结果怎么变的。这种“破坏性实验”比看十遍书管用你会亲眼看到 DS 里残留的是调试器启动时的默认值所有内存读取都偏移到别的地方去了取出来的是垃圾数据。这个体验一旦有了以后写代码忘初始化 DS 的时候会有生理性的警觉。2.5 寻址环节的几个坑第一个坑是把OFFSET和方括号搞混。MOV BX, OFFSET ARR是把偏移地址放进 BXMOV 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 四根线。刚学的时候总觉得这规定莫名其妙其实站在硬件设计角度想就通了——地址计算是个加法器输入端只有这么多个多了布线成本上去了。第四个坑是两个变址寄存器不能同时用。[BXBP]和[SIDI]都不合法必须是“一个基址 一个变址”的组合。这个限制在 32 位以后就放开了[EAXEBX*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, [BXSI10H]会在程序运行到这条指令时真的做一次加法而MOV BX, OFFSET ARR在汇编阶段就把地址常量填进去了。LEA还有个不太正经但很常用的玩法LEA AX, [BXBX]等于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 算术指令与标志位的联动算术指令这一块真正的难点不是“怎么算”而是“算完之后标志位变成什么样”。六个状态标志是后面条件转移指令的唯一依据不搞清楚这个第四讲等于白学。先把六个状态标志的语义摆清楚标志名称什么时候置 1CF进位/借位最高位向更高位有进位或借位ZF零标志结果为 0SF符号标志结果的最高位为 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则是做与运算不保存结果专门用来测某一位是否为 1TEST AL, 01H后面跟JNZ就是判断最低位。MUL和IMUL有一点必须记住乘数的位数决定了结果放在哪。8 位乘法是AL × 操作数 → AX16 位乘法是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是逻辑移位空出来的位补 0SAL/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~39HA~F 是 41H~46H。判断方法是CMP AL, 9JBE就走加 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只需要 CX50。这个换算搞错会导致搬一半或者越界。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 位CF1。第二次ADC时1234H 0001H 1 1236HCF 被清零。最终结果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初始值是 0LOOP会循环 65536 次——这个坑极其经典CX0并不代表不循环。所以进入LOOP之前必须先判断CX是否为 0。LOOPE/LOOPNE是带零标志判断的循环配合CMP可以做“找到第一个匹配项就停”的查找。用LOOP有个限制循环体不能太长因为LOOP的跳转范围是 -128 到 127 字节。循环体一大就汇编报错“跳转超出范围”这时候得改成DEC CXJNZ的组合后者的跳转范围大得多。4.4 子程序、堆栈与参数传递子程序用CALL调用、RET返回。CALL做的事是把下一条指令的地址压栈然后跳到子程序入口RET则是从栈里弹出返回地址。理解了这个机制很多问题就清楚了为什么子程序里必须保证栈平衡压了多少就弹多少为什么PUSH和POP不配对会导致RET跳到错误位置。参数传递有三种常见方式寄存器传递最简单把参数放 AX、BX 里子程序直接用。缺点是参数一多就不够用而且寄存器是全局的子程序内部不小心改了会影响调用方。内存变量传递是把参数放在数据段的固定位置子程序按名字访问。好处是参数数量不受限坏处是子程序不能重入也不方便递归。堆栈传递最正规调用方把参数PUSH进去子程序用[BP4]、[BP6]访问。标准写法是MYPROC PROC PUSH BP MOV BP, SP ; 此时 [BP4] 是第一个参数[BP6] 是第二个 MOV AX, [BP4] ... 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、DIJump out of range条件转移目标超出 -128~127改用JMP 条件转移两段式Symbol not defined标号拼错或者END位置不对检查标号拼写和END后的入口名Unexpected end of fileENDS或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之后AL01H时按无符号看是小于按有符号看是大于方向完全相反。第三步查堆栈。如果程序跳飞了执行到不该执行的地方基本就是栈不平。检查PUSH和POP是不是配对子程序里有没有漏掉POPCALL之前栈顶有没有被意外改动。调试器里连续看几次 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在 CX0 时循环 65536 次。因为它是“先减再判断”0 减 1 变成 FFFFH判断不为 0继续循环。所以循环次数可能为 0 的场合必须在LOOP之前加一句JCXZ判断。坑四字符串指令的方向标志 DF。如果之前有代码把 DF 置 1 了用STD后面的REP MOVSB就会从高地址往低地址搬结果反了。标准的做法是在程序开头用CLD把 DF 清零养成这个习惯能省掉很多莫名其妙的调试时间。6. 三讲之后往哪走6.1 从 16 位到 32 位的映射关系把这三讲学完如果直接跳到 32 位汇编会有几个明显的“不对劲”寻址方式从“段偏移”变成了平坦模型段寄存器不再表示段基址而是变成了段选择子指向描述符表。大部分情况下你会看到所有段寄存器都指向同一个平坦段寻址范围一下扩展到 4GB。寻址组合也自由了[EAXEBX*48]这种带比例因子的写法正是为数组访问量身定做的。寄存器从 8 个通用寄存器扩展到 8 个 32 位寄存器名字前面加个 E。AX变成EAXBX变成EBX以此类推。新增了ESI、EDI这两个专用寄存器在 16 位里是SI、DI。指令方面新增了大量东西但核心那几十条没变。MOV、ADD、CMP、JMP、CALL、RET的语义完全一致只是操作数可以是 32 位。所以从 16 位过渡到 32 位真正需要重新学的是段机制、调用约定和系统接口指令本身算是平滑迁移。6.2 这三讲在真实场景里的价值很多人会问现在都写高级语言了学这个有什么用。我的实际体验是这三讲的价值主要体现在三个场景。看反汇编的时候。调试一个崩溃的 C 程序符号信息丢了只剩一堆汇编。这时候你能不能在三十秒内看出“这一段是在做字符串拷贝但循环计数用错了寄存器”完全取决于对寻址方式和指令的熟悉程度。我处理过一个越界写内存的问题就是靠反汇编里一个MOV [ESIECX*4], EAX的索引寄存器对不上号定位出来的。做性能敏感代码的时候。比如嵌入式环境下的位操作、图像处理里的像素遍历用汇编重写关键循环能带来实打实的提升。写这种代码必须清楚每条指令的长度和周期这正好是第三讲和第四讲的内容。理解上层语言机制的时候。函数调用怎么传参、局部变量放哪、结构体成员怎么偏移、虚函数表怎么寻址这些问题的答案全在这三讲里。搞明白了[BP4]是第一个参数、[BP-2]是第一个局部变量再去看编译器生成的栈帧布局就是看图说话。6.3 我个人的一点练习建议学完这三讲最好的巩固方式是写三个小东西一个十六进制到十进制的进制转换程序练除法、练取余、练字符输出、一个数组排序程序练寻址、练双重循环、练比较交换、一个字符串统计程序统计字母、数字、其他字符各多少个练字符判断和计数器。这三个东西加起来不到两百行但把三讲的知识点全覆盖了。写的时候刻意不用查资料卡住了再去翻翻完立刻回来重写。这种“卡住—翻书—重写”的循环比顺顺利利抄一遍代码的记忆强度高十倍。我自己的经验是同一个程序写到第三遍的时候那些原本需要想一下的指令组合会变成肌肉记忆看到需求就能直接落笔。提示每写完一个程序都强制自己用调试器单步走一遍完整流程边走边说出每一步寄存器和标志的变化。说不出来的地方就是没懂的地方回去补。最后分享一个小习惯我觉得挺有用准备一个本子专门记“为什么当时写错了”。比如“DIV前忘了清 DX导致异常跳飞”、“LOOP在 CX0 时循环 65536 次”、“有符号比较用了JA”。这些错误记录比任何教材都贴身因为每一条都是你自己踩出来的复习的时候看一眼就能唤起当时的场景。汇编这门东西坑就那么多踩完了就顺了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →