
简介这是一份精炼的BASMBorland汇编语言知识归类文档面向深入使用Delphi、希望在Pascal代码中嵌入或理解汇编语言的开发者。内容聚焦BASM的核心语法与语义涵盖表达式类别寄存器、立即数、内存引用、类型与大小TYPE关键字、数据定义、复杂结构体访问、常量特殊性以及类型强制转换等主题并用mov、imul、OFFSET、DB/DW/DD/DQ等典型指令举例说明适合作为Delphi源代码分析或性能优化时的快速参考。资源为单个PDF约30KB便于离线阅读与检索。目前已有130人学习浏览。文档尤其突出了BASM与标准汇编的差异比如无法独立编译、不支持数组下标、数据只能定义在代码段等并给出可运行示例与编译技巧可帮助读者少走弯路快速掌握在Delphi工程中编写和调试内嵌汇编的方法。1. BASM 与 MASM 的本质差异内嵌汇编的边界在哪里Delphi 开发者在分析 RTL 源码或排查性能热点时迟早会在 System.pas 里撞见asm ... end代码块这就是 BASMBorland 汇编语言。它和 MASM 最大的区别在于BASM 无法独立编译出 .obj但语法又足够完整可以写出完全由汇编语句构成、由 Delphi 编译器直接编译的例程。这个「不能独立编译、却能完整表达」的特性决定了它的使用场景——不是替代汇编器而是在 Object Pascal 类型系统内直接操作寄存器、内存寻址和调用约定。对软件开发从业者来说读这份资料的价值在于看懂 RTL 里JMP _RunError、GetEIP这类写法在做什么需要精确控制参数传递和栈平衡时能写出编译器不插冗余指令的代码。下面按表达式类别、数据访问、调用约定、流程控制四层展开示例均来自原文档可直接在 Delphi 工程中验证。2. 表达式类别与寻址语义立即数、寄存器、内存引用2.1 三类表达式与编译期约束BASM 汇编语句由一条指令和零至三个表达式构成。指令是操作码表达式是操作数编译器的硬性要求是每一个表达式都必须在编译期计算出准确的值或者寻址地址否则语句直接编译失败。这和某些内联汇编实现「编译期宽松、运行期碰运气」的风格不同BASM 的表达式没有动态求值的余地。asm movsb // 单指令语句零个表达式 jmp Here // 一个表达式标识符标号 add eax, 1 // 两个表达式寄存器、立即数 imul edx, [ebx].RandSeed, 08088405H // 三个表达式寄存器、内存引用、立即数 end;movsb是串操作指令源地址和目的地址隐含在 DS:ESI、ES:EDI 中不需要显式表达式所以能作为零表达式语句存在。jmp Here的Here必须在编译期解析为当前代码段内的偏移编译器据此计算跳转距离。imul的三个表达式同时覆盖寄存器、内存引用和立即数三类说明一条指令内可以混合不同类别的表达式编译器为每个操作数生成对应的 ModR/M 编码。按计算结果表达式类别分为三类寄存器、立即数和内存引用存储器。寄存器是 CPU 通用寄存器立即数编码进指令本身内存引用通过寻址模式指向某个地址。与内存引用相关的寻址模式遵循 x86 的标准规则基址加索引乘比例加位移。后面 2.2 和 2.3 的示例会反复用到一个事实——同一个标识符放在方括号内外语义完全不同。2.2 变量寻址的三条指令var I: Integer; asm mov eax, I // I 的值送入 eax mov eax, [I] // 同上访问变量 I 所在内存 mov eax, OFFSET I // I 的地址送入 eax相当于 eax I end;对 Delphi 变量I来说mov eax, I和mov eax, [I]是等价的——变量本身就是内存位置Delphi 编译器为这两种写法生成同样的机器码。OFFSET I则不同它计算变量在数据段中的地址等价于 Object Pascal 里的I。这里的关键是区分「值」和「地址」两个层次前两条把 I 当前的内容放进 EAX第三条把 I 所在的内存地址放进 EAX。要遍历一个数组通常先把 OFFSET 取到的基地址放进 EBX再用[ebx esi]做增量访问既避免反复计算地址也便于配合 3.2 的地址运算访问模式。2.3 常量的立即数陷阱const C 3124; asm mov eax, C // eax 3124立即数编码 mov eax, [C] // 访问内存地址 3124非法地址访问 end;常量C在 BASM 中总是作为数值直接编码mov eax, C把 3124 作为立即数送入 EAX这符合直觉。但mov eax, [C]使用内存引用语义——方括号表示「访问该地址处的内容」所以它会尝试读取地址 3124 处的 4 字节。这个地址既不在程序的数据段也不在代码段运行时必然触发访问违规。另一个推论是对常量使用OFFSET没有意义因为常量不是内存对象。文档还特别指出字符串常量也一样——长度不超过 4 字节的字符串常量可以整体送入寄存器const Str2 ab; asm mov eax, Str2 // eax $00006162低位两个字节是 a、b左侧补 0 end;字符串ab被当成 32 位立即数$00006162处理字节序是反的。调试时如果发现某个寄存器里的字符串看起来高低字节颠倒多半就是这里提到的立即数编码规则在起作用。2.4 TYPE 关键字表达式类型是字节长度在 BASM 里表达式的类型是一个长度值——表达式值占用空间的字节数等价于 Delphi 的SizeOf()。关键字TYPE用来返回这个长度type TArr array [0..10] of char; // SizeOf(TArr) 11 asm mov eax, TYPE Arr[2] // 返回 11整个数组的长度 end;TYPE Arr[2]能编译通过但它返回的是整个数组的 11 字节而不是单个元素的 1 字节。这解释了为什么mov al, Arr[2]无法编译——目标寄存器 AL 只有 1 字节源操作数的类型长度是 11放进 8 位寄存器在类型层面就不可能。BASM 的类型长度概念只服务于「这个操作数能不能放进目标寄存器」的判断不参与高级语言层面的数组下标重载。下表汇总了本章的寻址语义表达式写法类别语义常见误用mov eax, I内存引用变量取变量 I 的值与取地址混淆mov eax, [I]内存引用同上与立即数混淆mov eax, OFFSET I立即数地址取变量 I 的地址误以为取内容mov eax, C立即数常量值编码进指令对常量加 OFFSETmov eax, [C]内存引用访问地址 C 处内容运行期非法访问mov eax, TYPE Arr[2]立即数返回数组整体长度误认为返回元素长度3. 数据定义与类型强制转换记录、数组与代码段数据3.1 记录字段访问的三种写法BASM 可以直接访问 Delphi 定义的记录、数组、对象等复杂数据结构这是它对传统 ASM 语法最重要的扩展。看一个记录访问的完整例子type TRec record rI: Integer; rS: String; end; var R: TRec; asm mov eax, R.rI // 域 rI 的值送入 eax mov eax, [TRec.rI R] // 同上字段偏移常量加变量 mov eax, [Offset R TRec.rI] // 同上显式地址运算 end;三种写法语义完全一致。R.rI是 Delphi 风格的点号访问编译器根据 TRec 的布局算出 rI 相对 R 基址的偏移[TRec.rI R]更接近汇编本来的样子这里的TRec.rI是记录字段偏移常量[Offset R TRec.rI]显式写出地址运算。我一般推荐日常使用第一种可读性最好后两种适合在宏、代码生成器或需要极简指令数的场景里用。3.2 字符串与数组的地址运算访问BASM 不支持数组下标语法访问字符串和数组元素必须用地址运算var S: String 1234567; asm mov ebx, S dec ebx // 越过长度字节指向字符数据起点 mov esi, 4 mov al, BYTE [ebx esi] // 取 S[4] 的字符值送入 al mov al, BYTE [ebx 4] // 立即数偏移写法同上 mov eax, [ebx 4] // 取 S[4]..S[7] 四字节eax $37363534 end;BYTE [ebx esi]和BYTE [ebx 4]得到同一个字符区别只是偏移量用寄存器还是立即数。第三行用 DWORD 宽度读取 4 个连续字符$37363534对应7654字节序是反的。数组访问同理先取基地址再叠加下标乘以元素大小var A: array [0..10] of char abcdefghij#0; asm mov ebx, OFFSET A mov eax, [ebx 4] // A[4]..A[7] 四字节eax $68676665 end;这里ebx是数组基地址[ebx 4]等价于从A[4]起读 4 字节。注意如果写成mov eax, TYPE A[4]返回的永远是整个数组长度 11而不是元素偏移对应的字节数这正是 2.4 里类型长度语义的体现。提示上述字符串示例按文档中的经典字符串布局处理长度字节。实际工程里不同 Delphi 版本的 AnsiString 头结构不同长度字段的真实偏移以当前 RTL 的字符串头结构定义为准直接dec ebx的做法只适合短字符串或你自己掌控布局的数据。3.3 类型强制转换的两种语法BASM 支持两种类型强制转换语法效果完全一致type TCode record I: Integer; S: String; end; var aRec: TCode; aInt: Integer; asm mov eax, aInt.TCode.I // 表达式.类型 格式 mov eax, integer(aRec) // 类型(表达式) 格式 end;和 Delphi 的强制转换不同BASM 的转换不做长度校验——TCode长度是 8Integer长度是 4双向转换都能编译通过这在 Object Pascal 里是行不通的。编译器只是把那个内存地址上的数据按目标类型重新解释。这意味着你可以把一个记录的第一个字段当成Integer读出来前提是你清楚记录的内存布局。这个特性在做协议解析、读二进制文件头时很好用但也要慎用因为编译器不会帮你兜底。3.4 代码段数据定义DB/DD/DW/DQ 与标号技巧BASM 支持在代码块里定义数据定义的数据总是落在代码段——这也是对 Delphi 无法在代码段定义数据的一个弥补。支持的伪指令有四个伪指令长度典型用途DB1 字节字符、布尔标志、小整数DW2 字节WORD 数据、近程偏移量DD4 字节整数、DWORD 数据、地址DQ8 字节Int64、双精度浮点表示和 ASM 不同BASM 不允许为这些数据命名asm DB 0FFH // 定义一个字节合法 // aVar DB 0FFH // 在 ASM 中可用BASM 编译失败 end;命名问题的解法是用标号当变量名。因为数据混在指令里必须先jmp跳过数据区再用标号引用数据所在地址type TCode packed record I: Integer; S1: array [1..26] of char; end; function ReadCode: Integer; asm jmp CodeRec: DD 12344213 DB ABCDEFGHIJKLMJNOQRSTUVWXYZ : mov eax, CodeRec.TCode.I end;jmp 跳过数据CodeRec标号记录数据区起始地址指向真正的代码。mov eax, CodeRec.TCode.I把数据区第一个字段按Integer类型读出。这里的packed record用来精确控制布局防止编译器插入对齐填充。如果要在运行期写这些数据需要先用操作系统 API 把代码页改成可写——Windows 下常见做法是VirtualProtect否则代码段默认只读写操作会触发访问违规这一点在 6.3 还会展开。4. 寄存器使用规则与五种调用约定4.1 寄存器保存义务与例程入口状态BASM 代码块执行期间必须保存 EDI、ESI、ESP、EBP、EBX 的值可以任意使用 EAX、ECX、EDX。这条规则的依据是BASM 代码块嵌在 Delphi 例程里外层 Object Pascal 代码假设这些寄存器在例程调用之间保持不变。凡是你在汇编代码里改动了需要保存的寄存器退出前必须恢复原值。寄存器保存义务说明EDI / ESI必须保留改之前先压栈退出前恢复ESP / EBP必须保留EBP 指向当前堆栈帧ESP 指向栈顶EBX必须保留常被用作基址寄存器最容易漏恢复EAX / ECX / EDX可自由使用编译器视为易失寄存器一个常见的正确写法是asm push ebx mov ebx, OFFSET A // ... 用 ebx 做基址访问 pop ebx end;BASM 代码块开始时EBP 指向当前堆栈ESP 指向栈顶SS 存放堆栈段段地址DS 存放数据段段地址CS 存放代码段段地址。通常情况下段地址寄存器满足 SSESDS。这些信息在手动解析栈上参数时很有用——弄清楚了 EBP 是帧基址[EBP8]这类访问你就能自己推算。返回值方面函数总是以 EAX32 位、AX16 位或 AL8 位作为返回寄存器。很多新手写的汇编例程在 Delphi 里偶发崩溃排查第一步就是检查 ESI、EDI、EBX 是否被改过。4.2 入口参数的四类形态与传值规则Delphi 例程入口参数有四类procedure TestProc(I: Integer); // 值参数 procedure TestProc(var I: Integer); // 变量参数 procedure TestProc(const I: Integer); // 常数参数 procedure TestProc(out I: Integer); // 输出参数规则很清晰值参数和常数参数使用相同的传值规则但值参数传入的是值的备份变量参数和输出参数总是传入值的地址。直接修改变量参数和输出参数指向的内存地址上的值调用者能感知到。对于值参数必要情况下编译器会生成一段代码创建值参数的备份并用备份的地址替换入口参数地址——这是完全汇编例程与内嵌汇编例程的关键差异之一6.1 会再提。具体传值规则是数据长度小于等于 4 字节存在例外如 Int64时直接传值否则传值的内存地址。这意味着一个Integer参数在 EAX 里而一个String参数在 EDX 里放的是字符串指针的地址。常数参数按传值处理但在完全汇编例程中行为会和值参数有微妙差别因为值参数的备份机制被绕过了。4.3 五种调用约定对比在不违背上述寄存器使用规则和参数传递规则的前提下Delphi 支持 5 种调用约定调用约定传参顺序清除参数责任寄存器传参实现目的register由左至右例程自身是提高效率Delphi 默认pascal由左至右例程自身否兼容旧有过程cdecl由右至左调用者否与 C/C 模块交互stdcall由右至左例程自身否Windows APIsafecall由右至左例程自身否COM 双接口与异常处理register 是唯一使用寄存器传参的约定前三个整数或地址参数按 EAX、EDX、ECX 顺序放置多余参数走栈。pascal 约定已经很少用主要用于兼容老代码。cdecl 是唯一由调用者清栈的约定所以在 BASM 里用 cdecl 调用 C 库函数后要自己add esp, N清理栈。stdcall 是 Windows API 的标准约定调用后由 API 内部清理。safecall 面向 COM在双重接口和异常处理上由编译器做包装。类设计里公开声明的方法强制使用 register这也是 Delphi 组件体系性能好的一个底层原因。5. 例程调用、API 互操作与流程控制5.1 register 约定调用 Delphi 函数按 register 约定调用一个普通 Delphi 函数是 BASM 最常用的实战场景。看完整例子function DelphiFunc(I: Integer; var S1, S2: String): Integer; begin if I Length(S1) then SetLength(S1, I); S1 : S1 S2; Result : Length(S1); end; var GS: String 12345678; procedure RegisterCall; var LS: String; Len: Integer; begin LS : This is a test!; // 以下汇编等价于 Len : DelphiFunc(8, LS, GS); asm mov eax, 8 lea edx, LS // 局部变量必须用 lea 载入地址 mov ecx, OFFSET GS // 全局变量 是复写标识符避开段寄存器保留字 call DelphiFunc mov Len, eax end; writeln(LS); // This is 12345678 writeln(Len); // 16 end;按 register 约定第一个参数进 EAX第二个进 EDX第三个进 ECX从左边开始排。8 是立即数直接movLS 是局部变量在栈上必须用lea取地址——这是最容易写错的地方写成mov edx, LS会把字符串指针头几个字节当成值送进 EDX而不是地址。GS 是全局变量且名字和段寄存器 GS 撞了所以加前缀。call DelphiFunc结束后返回值在 EAX再mov到局部变量 Len。输出验证了拼接结果LS 变成 This is 12345678总长 16。参数位置传递通道BASM 常见写法第 1 个整数/地址参数EAX立即数直接 mov第 2 个EDX局部变量用 lea第 3 个ECX全局变量用 OFFSET保留字加 第 4 个起栈由右至左压栈这段代码还有一个变体三个参数以内走寄存器超过三个的部分按参数声明的从右到左压栈。我在实际项目里会把多参数调用写成先压栈、再填寄存器、最后 call 的顺序这样阅读时参数位置一目了然。5.2 stdcall 约定调用 Windows API调用 Windows API 时用的是 stdcall 约定传参顺序从右到左栈由 API 自身清理function GetFileSize(Handle: Integer; x: Integer): Integer; stdcall; external kernel32.dll name GetFileSize; function stdcallDemo: Integer; var FH: THandle; begin FH : FileOpen(C:\boot.ini, fmOpenRead); asm push 0 // 第二个参数 nil先入栈 push FH // 然后第一个参数 call GetFileSize mov Result, eax end; FileClose(FH); end;stdcall 约定从右往左传参所以先压第二个参数 nil再压第一个参数 FH。按约定例程自身清理栈BASM 里不需要任何add esp。返回值在 EAXResult是 BASM 内建的标识符指向当前函数返回值的存储位置把 EAX 写过去等价于Result : ...。这里 GetFileSize 声明为 stdcall 并由 BASM 调用完全绕过了 Delphi 的类型检查——声明里两个参数都是 Integer调用时压入什么就是什么所以第二个参数压 0 表示 nil 也合法。如果换成 cdecl 调用比如直接调 C 动态库调用结束后要补一条add esp, 8因为清栈责任在调用者。这是三种常用约定里唯一需要手动平衡栈的漏掉这一条栈就慢慢长毛而且崩溃点往往不在 call 附近排查成本很高。5.3 跳转指令的自适应宽度与条件跳转反转BASM 会尽量把跳转压缩成短程跳转2 字节距离不够就用近程跳转3 字节只有两者都不可能时才用远程跳转56 字节。对于远程条件跳转比如JC FarJumpBASM 会自动转换成下面这种形式JNC ShortJump JMP FarJump ShortJump:这样既保留了原来的跳转语义又让大多数情况走短跳转代价是条件取反。反向阅读编译器生成的汇编时看到JNC后面紧跟一个JMP就知道原来的源码里很可能是一个远程JC。这个优化细节在对比手写汇编和编译器输出时非常有用。5.4 错误处理中的 JMP 模式BASM 可以用跳转指令把流程指向当前单元的任何例程这让错误控制更简洁高效。System.pas 中调用纯虚方法时的处理是典型例子NoAbstErrProc: MOV EAX, 210 JMP _RunError这里用 JMP 而不是 CALL区别在于JMP 让目标例程的 RET 替代当前例程的 RET——错误处理执行完后出错点后续指令不会再执行。如果换成 CALL返回后还会回到出错调用点继续执行逻辑就错了。这个模式用在错误、异常、退出路径上比 CALL 加条件判断更紧凑也是理解 RTL 源码里错误链路的钥匙。5.5 EIP 修补技巧让 JMP 等效 CALL有些场景想用 JMP 跳到目标例程又希望目标例程返回时回到 JMP 的下一行而不是回到 JMP 指令本身。文档给了一段 12 字节的机器码技巧DB $E8, $0, $0, $0, $0, $8F, $04, $24, $83, $04, $24, $0C jmp procDB 里的字节展开成三条真实指令call GetEIP // $E800000000把 GetEIP 标号的地址压栈 GetEIP: pop [esp] // $8F0424弹出栈顶 EIP 值再写回栈顶 add [esp], 12 // $8304240C栈顶保存的返回地址加 12 jmp proc // 无条件远程跳转5 字节 ReturnHere:执行逻辑是call GetEIP把返回地址即 GetEIP 标号的位置压栈pop [esp]把这个值读出来又写回同一个栈位置——寄存器没有变化但这条指令长度是 3 字节add [esp], 12把栈顶的返回地址往后推 12 字节。三段指令长度 3 4 5 12正好跳过jmp proc的 5 字节和后续对齐所以被跳转的 proc 执行 RET 时弹出的返回地址指向ReturnHere效果等价于call proc。这个技巧的价值在于proc 内部无论走多少分支最终 RET 都会回到 JMP 的下一条指令。适合把一段公共收尾代码插到另一个例程尾部又不需要维护额外的调用链。现场使用的时候建议把 12 这个数字和三条指令的长度对应关系写在注释里防止改动指令后忘记同步偏移量。6. 完全汇编例程、内嵌汇编例程与代码段数据写入6.1 完全汇编例程没有值参数副本BASM 在例程中使用时分成两种形态完全汇编例程是用asm关键字替换了例程的begin让例程完全由汇编代码实现内嵌汇编例程则是在begin..end中间任意位置放一段asm..end。完全汇编例程没有begin所以 Delphi 不会形成值参数的复制——这意味着在完全汇编例程里值参数和常数参数的处理完全一致。function SumPair(A, B: Integer): Integer; asm mov eax, A add eax, B end;这个函数没有局部变量、没有栈上传参编译器不会为它生成任何堆栈结构。如果你在参数声明里用了较大的记录类型完全汇编例程读到的就是调用者原始的数据而不是像普通例程那样先复制一份再操作——这对性能是好事但如果你期望「修改值参数不影响调用者」完全汇编例程不会给你这个保证。6.2 编译器何时不生成堆栈结构编译器自动处理例程的堆栈结构有一个例外如果完全汇编例程不是嵌套子例程也没有入口参数或参数只占用寄存器和局部变量编译器不会为该例程产生堆栈结构也就是说不会在栈上分配空间。验证这个方法最直接在 IDE 里打开 CPU 窗口单步进入完全汇编例程看第一条指令。没有push ebp; mov ebp, esp这两条 prologue就说明这个例程没有堆栈帧。排查栈溢出问题时这个判据比盲猜快得多——你只要确认目标例程是否真的在栈上占空间。6.3 代码段数据写入的权限处理3.4 里用 DB/DD 定义的数据位于代码段代码段的页属性默认是只读执行。要运行期修改这些数据需要先调用操作系统 API 把所在页设为可写。Windows 下的常见做法是VirtualProtect把包含该标号的页改成PAGE_EXECUTE_READWRITE写入完成后恢复原保护属性。反复修改会触发页表重映射性能代价不小适合只做一次初始化的场景。配合 CPU 窗口做验证是最快的排错路径在数据定义处加断点用内存 dump 检查 DB 区块的字节内容是否和预期一致再运行到mov eax, CodeRec.TCode.I之后比对 EAX 和 DD 定义的值。如果读出的值不对优先怀疑标号定位错误或记录字段偏移算错——packed record会消除对齐填充但字段顺序仍要严格按声明排列任何一处偏移偏差都会让读出的值面目全非。本文还有配套的精品资源点击获取