尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BASM精要:从汇编语法到Delphi内联汇编实战

BASM精要:从汇编语法到Delphi内联汇编实战 简介《BASM(Borland汇编语言)精要》是一份面向 Delphi 开发者的汇编入门资料系统讲解 BASM 的基础概念、表达式类别与类型、数据定义与强制转换等核心内容。压缩包内只有 1 个 PDF 文件整体仅 30KB轻量易存可随时打开查阅。目前已有 130 人学习适合希望在开发中理解底层汇编的读者参考。PDF 内用短小代码示例贯穿全文涵盖单指令语句、寄存器/立即数/内存引用三类表达式以及记录、数组、常量的访问与地址运算特别说明 Delphi 中 BASM 内嵌于语言、无法独立编译为 OBJ 的特性能帮助开发者避开常见误区还涉及数据定义指令 DB/DW/DD/DQ 与代码段内数据命名技巧、常量访问限制等细节。对于想深入 Delphi 底层实现、优化关键路径代码的程序员这份精要能快速建立 BASM 整体认知是一份实用的随身速查手册。1. BASM 精要知道自己在排布哪一张汇编地图BASM 这个名字在旧版 Delphi 和 Turbo Pascal 用户里并不陌生它指 Borland 汇编语言严格说是 Turbo AssemblerTASM所采用的汇编语法体系也是 Delphi 内联汇编的书写基础。它常被拿来和 MASM、ATT 语法对比但一旦进入 Borland 工具链BASM 有自己的段装配习惯、过程声明方式和内联限制。一份以「精要[归类]」命名的资料做的其实是同一件事把几十条关键指令、伪指令、寻址方式和调用约定按使用频率重新排列方便查缺补漏。这篇博文不替代原资料而是顺着汇编工作实际用到的环节把 BASM 相对 MASM 的差异、过程模型、寻址组合、内联汇编写法以及调试技巧拆开讲。适合对 x86 有基本概念、想在 Delphi 或老 Borland 工具链里写底层代码的工程师。2. BASM 语法骨架从助记符到段定义BASM 的助记符体系和 MASM 高度同构指令写操作码 目的, 源十六进制数字以h结尾行注释用分号。这个观感对读过任何一本 x86 汇编书的人都不陌生但真正开始写 TASM 源码时会在三个地方卡顿数字字面量写法、段装配方式、数据量词选择。先把这三个地基钉住。2.1 指令格式和数字字面量的三个惯例MOV AX, 1234h ; 十六进制常量以 h 结尾 MOV BL, A ; 字符字面量用单引号 MOV DX, OFFSET msg ; OFFSET 取出标号所在段的偏移第一行的高位字母必须补前导零0ABCDh不能简写成ABCDh否则汇编器会把ABCDh当成标识符报Undefined symbol。字符常量与高级语言一致单引号内的多个字符在db下按顺序连续存放db OK, 0就是三个内存字节。OFFSET是编译期常量取段内位移LEA是运行时指令取某地址表达式的值。两者语义接近但用途不同把LEA当OFFSET用往往能跑却会引入多余执行。TASM 默认不区分标识符大小写mov ax,1与MOV AX,1等价。如果你同时维护从 C 头文件翻译过来的符号名可能需要OPTION CASEMAP:NONE保持大小写否则链接期符号对不上。我一般写助记符大写、操作数小写代码里一眼能分清指令与数据。2.2 段装配与数据定义量词BASM 的数据定义伪指令是db/dw/dd/dq/dt对应 1、2、4、8、10 字节。选择量词不只是看类型长度还要想清楚这条数据会不会被远指针引用、会不会参与 32 位算术这直接决定后续访问方式。伪指令单元大小典型用途db1 字节字符串、布尔标志、查表dw2 字节偏移量、计数器、16 位参数dd4 字节远指针、32 位整数、浮点尾数dq8 字节64 位整数、双精度浮点dt10 字节packed BCD、扩展精度浮点数组用DUP声明table dw 100 DUP(0)申请 100 个初值为 0 的字。DUP可以嵌套二维表常见写法是matrix db 4 DUP(16 DUP(?))?表示只分配空间不指定初值。段装配有完整定义和简化定义两种路线完整定义要手写SEGMENT/ENDS加ASSUME繁琐但可控日常练习用简化段更省事.MODEL SMALL .STACK 100h .DATA msg db BASM syntax ok, 0Dh, 0Ah, $ table dw 100 DUP(0) .CODE .STARTUP mov ah, 09h lea dx, msg int 21h mov ax, 4C00h int 21h END.MODEL SMALL声明小内存模型.DATA和.CODE让汇编器自动安排段顺序END前的.STARTUP负责生成标准入口。这段代码用 DOS 的int 21h打印消息后退出$是 09h 号功能的字符串结束符和 C 的\0作用不同。如果迁移到 Win32 环境则不再有int 21h$结束符也要换成db 0。2.3 指令大全式速查五组必背指令及边界比起背整本指令大全先把高频指令的使用边界搞清楚效率更高。下面这张表列的是我在 BASM 项目里最常用的五组指令和最容易出错的位置。分组代表指令使用边界数据传送MOV, PUSH, POP, LEAMOV不能两个操作数都在内存LEA不改状态标志算术ADD, SUB, IMUL, IDIV除法前记得清空高半寄存器串操作MOVSB, STOSB, SCASB, REP操作前检查 DF必要时CLD控制流JMP, Jcc, CALL, RET条件跳转默认短距目标超出范围会编译失败位操作TEST, AND, OR, XORTEST不写目标只影响标志一个典型误用是MOV想直接搬两个内存单元。x86 体系里内存到内存必须经过寄存器mov ax, [bx]再mov [di], ax所以表里特意标出这条边界。串操作则要记得方向标志REP MOVSB前忘记CLD数据会从高地址往低地址复制结果与预期完全相反这类错误排查成本极高写代码时就要固定检查顺序。3. BASM 过程模型参数、栈帧与调用约定背完指令表只是第一步BASM 里写出能和其他代码互相调用的过程需要同时掌握两套规则一套是过程本身的PROC/ENDP声明方式另一套是参数通过寄存器还是栈传递。后者决定函数签名怎么写也决定调用方和被调用方谁负责清理栈。3.1 用 PROC/ENDP 声明过程时的近远选择foo PROC NEAR mov ax, 1 ret foo ENDP bar PROC FAR mov ax, 2 ret bar ENDPPROC NEAR生成的ret只弹出偏移地址PROC FAR则弹出段基址和偏移所以远过程返回时必须用retfTASM 在FAR声明下会自动生成对应返回指令。近调用和远调用在call指令里也体现为E8和9A两种操作码混用时最容易出现栈不平衡。Borland 系高级语言里的普通函数默认都是 near只有跨段回调、TSR 程序才需要 far。PUBLIC foo单独写在过程外作用是让符号对链接器可见Delphi 或 C 代码引用汇编函数时导出名字的拼写要保持一致。3.2 register 约定BASM 最有辨识度的调用约定Borland register 约定和 C 世界完全不同前三个整数或指针参数依次放入 EAX、EDX、ECX剩余参数才压栈。它比 cdecl 和 stdcall 快省去内存读写因此被 Delphi 设为默认约定。add3 PROC NEAR ; EAX 参数1, EDX 参数2, ECX 参数3 add eax, edx add eax, ecx ret add3 ENDP这段函数不需要任何栈操作执行完 EAX 就是三数之和。调用方也要遵守同样规则mov eax, A; mov edx, B; mov ecx, C; call add3。多出来的参数才从右往左压栈一个 5 参数函数真正进入被调者时ECX 是参数3、栈顶往上是参数4、参数5。这个布局容易记混我一般会在源码头部画一张注释图比硬背可靠。约定参数存放位置栈清理责任典型用途registerEAX/EDX/ECX 加其余压栈被调者Delphi、Turbo Pascalcdecl全部压栈右→左调用者C/C 库stdcall全部压栈右→左被调者Win32 APIpascal全部压栈左→右被调者老式系统库pascal 和 cdecl 摆在一起最容易记cdecl 从右往左压、调用者还原pascal 从左往右压、被调者还原。ret n这条指令在 BASM 里承载“被调者清理”的职责stdcall 函数形参占 12 字节时会写ret 12。register 约定下清理责任也在被调者但多出的参数不多很少需要手写ret n。3.3 用 BP/EBP 搭栈帧参数和局部变量怎么稳定访问寄存器参数虽然快但函数体内一旦再调用别的函数EAX/EDX/ECX 就会被覆盖。要跨过程保留值就得在栈帧里找位置。标准 prologue 如下MyFunc PROC NEAR push bp ; 保存调用者 bp mov bp, sp ; bp 指向当前栈顶 sub sp, 16 ; 预留 16 字节局部变量区 ... mov sp, bp ; 撤销局部区 pop bp ret MyFunc ENDPpush bp/mov bp,sp之后[bp4]是返回地址[bp6]开始是入栈参数局部变量放在[bp-2]、[bp-4]等负偏移位置。局部区较大时sub sp之后所有push都会进入预留区域函数返回前必须mov sp,bp还原否则pop bp会读错栈顶。BASM 也提供ENTER/LEAVE生成样板但老 CPU 上这些指令偏慢实际项目里手工 prologue 更常见。32 位环境把bp换成ebp规则完全一致。寄存器参数没有固定的栈地址调试器里想查看入参只能回到调用点查看 EAX/EDX/ECX 的当时值这也是 register 约定下调试比 cdecl 费神的原因。4. 寻址方式与指令组合让一条 LEA 解决三种计算指令表背熟只是第一步BASM 写得好不好很大程度看寻址方式用得对不对。同样的逻辑既可以用三条mov/add/add实现也可能被一条lea代替后者代码短、执行路径少在热点函数里效果显著。4.1 x86 寻址方式速查写法含义适用场景[BX]寄存器间址遍历数组[BXSI]基址加变址二维数组、表查找[BXdisp]基址加常量结构体字段访问[EAXECX*4disp]比例变址32 位下数组下标计算[BPdisp]栈帧访问参数和局部变量16 位模式有严格分工只有 BX、BP 能做基址寄存器SI、DI 做变址寄存器[AXBX]这类写法直接非法必须先mov di, ax再把si换成di。32 位模式下 SIB 字节允许任意通用寄存器加比例变址自由度一下子大很多。从 16 位转 32 位的老手通常会先栽一次这个跟头原因是思维还停留在“哪个寄存器能做基址”上。4.2 LEA、ADD 与比例因子的组合LEA在 BASM 里常被当成“不用乘法就能做乘法”的工具它不访问内存只把有效地址计算结果写入目标寄存器; 计算 EAX 5 * ECX 7不执行乘法 lea eax, [ecx*4 ecx] ; EAX 5 * ECX add eax, 7 ; 计算 EDX 10 * EBX lea edx, [ebx*8 ebx] ; EDX 9 * EBX add edx, ebx ; EDX 10 * EBXSIB 比例因子只有 1、2、4、8所以 12 倍不能直接用一条 LEA 完成。遍历结构体数组时常见做法是拆成两次; EAX 是基址ECX 是下标每元素 12 字节 lea eax, [eax ecx*4] ; EAX 基址 4*N lea eax, [eax ecx*8] ; EAX 基址 4*N 8*N这个组合要求 ECX 没有在两条 LEA 之间被修改注释里写明数学关系能避免后续维护者误删一行。另一个容易忽略的差异是LEA不改状态标志如果后面紧跟jz跳转依据的还是 LEA 之前留下的 ZF。需要“计算并保留标志”的循环里LEA 反而制造麻烦此时用add更合适。4.3 短跳转、TEST 与串指令的配合条件跳转在 BASM 里默认落在短距离范围内目标超出 -128..127 时汇编器会报Jump out of range需要改逻辑或改用远程跳转。这类问题在较大函数里很常见拆过程比硬扩展跳转距离更划算。; 判断 AL 是否为十进制数字 0-9 sub al, 0 cmp al, 9 ja not_digit ; 是数字继续处理这里sub al, 0先做偏移ja用无符号大于判断。如果写成jg字符a减0后得到正数会被误判成大于 9。无符号领域用ja/jb有符号领域用jg/jl这是条件跳转选择的核心规则。串指令与 REP 配合也能写出极紧凑的循环; 将 EDI 指向的 1024 字节清零 cld mov al, 0 mov ecx, 1024 rep stosbcld保证 DF0stosb每次写入[edi]并让edi增 1。如果忘记cldDF1 时edi递减缓冲区尾巴被填、头部没填。32 位下方向标志依然存在这条经验不因位宽变化而失效。5. 在 Delphi 中写内联汇编BASM 的主战场对今天的读者来说BASM 最实用的入口不是写独立.asm文件而是在 Delphi 的asm...end块里直接嵌入汇编。内联汇编把经典 BASM 语法放进高级语言框架省掉段定义、符号导出这些繁琐环节同时仍然保留寄存器级控制能力。5.1 asm 块里哪些符号可见Delphi 内联汇编自动继承当前函数上下文函数参数、局部变量、方法里的Self都能在汇编中引用。前三个整数参数默认位于 EAX、EDX、ECX这与纯汇编的 register 约定一致。像高级语言那样直接写参数名编译器会安排存储位置保证语义正确但可能引入多余的栈上搬运直接操作寄存器能拿到最快路径。我在只有三个以内参数时通常直接使用 EAX/EDX/ECX参数超过三个才引用名字。EBX、ESI、EDI 在函数返回时必须保持原值这是编译器跨函数保存变量的基础修改前务必入栈保护。5.2 三个可抄的内联汇编函数第一个是字符串长度参数S: PAnsiChar在 EAXfunction AsmStrLen(const S: PAnsiChar): Integer; asm push edi mov edi, eax // EDI S xor eax, eax // AL 0搜索结束符 mov ecx, -1 // 最大计数 cld repne scasb // 逐字节比较直到遇到 0 not ecx dec ecx // 去掉结束符本身 mov eax, ecx pop edi end;repne scasb从[edi]开始逐字节与 AL 比较直到发现 0 或 ECX 减到 0。not ecx把剩余计数转成实际扫描字节数再dec去掉结束符。push edi/pop edi保护了必须保持的寄存器。第二个是三个整数取最大值function Max3(A, B, C: Integer): Integer; asm cmp eax, edx // A 与 B 比较 cmovl eax, edx // EAX max(A, B) cmp eax, ecx cmovl eax, ecx // 再与 C 比 end;cmovl在有符号比较为小于时执行移动适合 686 以上 CPU。若目标平台不确定可以改成cmp加jge分支代价是引入跳转预测风险。第三个是字节查找演示第三个参数在 CL 中的处理function FindByte(const Buf: PByte; Len: Integer; Target: Byte): Integer; asm push edi push esi mov esi, eax // ESI 保存 Buf mov edi, eax // EDI 指向当前扫描位置 mov al, cl // Target 参数在 ECX 的低 8 位 test edx, edx jz not_found // Len 0 直接返回 -1 mov ecx, edx // ECX 加载长度 cld repne scasb jnz not_found mov eax, edi sub eax, esi dec eax // SCASB 会多走一格 jmp done not_found: mov eax, -1 done: pop esi pop edi end;Target: Byte只占 ECX 低 8 位所以取cl就是对应字节。repne scasb找到目标时 EDI 指向其后一字节用edi - buf - 1还原下标Len为 0 时先跳走避免在未定义的 ZF 状态下做判断。两个被使用的寄存器都做了保存和恢复。5.3 内联汇编排错对照症状原因修复方向函数返回后跳飞某分支多 push 少 pop检查条件分支里栈操作数量调用者局部变量被改修改 EBX/ESI/EDI 未保存入口 push、出口 pop比较结果判断错标志位被中间指令覆盖用嵌套分支或改用 LEA64 位编译报错不支持 asm 块重写纯 Pascal 或抽外部汇编单元栈不平衡最常见的藏身处是条件分支一条路径多压了一个寄存器另一条没有。排错时在ret前设置断点观察 SP 与入口 BP 的差值比逐行读源码快得多。6. 调试 BASM 的三个检查点与工具习惯汇编调试主要靠看寄存器、内存和栈语法错误反而最少。下面三个检查点是我在写 TASM 和 Delphi 内联汇编时反复使用的。第一函数入口记录 SP/BP返回前验证栈平衡。进入函数执行push bp; mov bp,sp后当前 SP 就成为基准。单步到ret前停下对比 SP 是否回到基准值。Turbo Debugger 的寄存器窗口直接显示 SP用 dump 窗口查看SS:SP处的返回地址是否与call的下一条指令吻合能快速定位多压或多弹的问题。第二在调用点检查 EAX 和标志位。register 约定下整数返回值只认 EAX浮点返回值在 ST(0)。把断点设在外层函数的调用处函数返回后立即看 EAX能判断函数内最后一条写 EAX 的指令是否执行到。如果 EAX 是预期值但调用方拿到错值问题多半出现在调用约定或栈参数上。第三条件断点观察循环边界。调试器里把断点设在repne scasb之后的指令上条件写ecx0可以在缓冲区扫完但未找到目标时停下检查 EDI 是否越界。条件断点表达式尽量用寄存器值而不是标志位因为标志是动态变化的等到断点命中时可能已被后续指令覆盖。检查项常用方法最常见失误栈平衡对比入口 SP 与 ret 前 SP条件分支里多 push返回值断点停下看 EAX忘记写 mov eax, 结果串方向操作前看 DF忘记 cld 导致反向复制边界越界条件 ecx0 时看 EDI字符串长度计算偏差调试 BASM 时不要只盯着单条指令要用“从调用点观察约定”的视角看代码。很多返回值、寄存器保存和栈清理问题由两端源代码共同约束单独看函数内部往往觉得天衣无缝把断点设在调用位置前后各一处才能确认参数传没传对、返回地址是否准确。本文还有配套的精品资源点击获取
返回列表