
写汇编学到第 7 篇差不多该碰上那些名字里带 A 的调整指令了。我最初看到 DAA、AAA、AAM 这几条指令的时候第一反应是这玩意儿是古董吧毕竟现代代码里几乎见不到。直到有一次要读主板 RTC 芯片的秒寄存器读出来一个 0x42我下意识按二进制转成 66屏幕上就多出了一个不存在的秒数。那一刻才明白BCD 码不是历史遗留物它是一类真实存在的数据格式而调整指令就是为这种格式服务的工具。这篇笔记就把 BCD 的两种打包方式、六条调整指令的内部逻辑、多字节 BCD 的加减法链条以及我在实测中踩到的几个坑一次性讲透。如果你正在学 8086 汇编、准备做课程设计里的十进制计算器或者只是好奇这些老指令到底怎么用下面的内容都能直接拿去跑。1. 十进制数为什么需要另一种二进制先说清楚动机不然调整指令看起来就像一堆莫名其妙的规定动作。计算机内部的整数是纯二进制的这没错但当数字要和人打交道的时候二进制和十进制之间就横着一道转换成本。比如 0.1 这个数用二进制浮点根本存不准0.1 0.2 在某些表示下不等于 0.3这不是 bug是进制本身的特性。金融、计量、仪表显示这类场景对十进制精确有硬需求于是就有了一个折中方案不用整个数去逼近而是把每一位十进制数字单独用 4 位二进制编码存起来这就是 BCDBinary-Coded Decimal。这个思路朴素得有点笨但它的好处非常实在每一位数字都是精确的不存在舍入显示的时候只要把每一位加上 0x30 就是 ASCII 码中间不需要任何除法取余和外部设备交换数据的时候只要双方约定好每个字节代表几位十进制数格式天然对齐。代价也很明显——一个字节 8 位纯二进制能表示 0 到 255 共 256 个值BCD 只能表示 100 个值剩下 156 个编码全是浪费的。但你换来了不用转换这个便利值不值就看场景。1.1 压缩 BCD 与非压缩 BCD同样是 4 位装法不一样BCD 有两种常见的打包方式这个区分是后面所有调整指令分家的根源必须一次分清。压缩 BCDpacked BCD一个字节装两位十进制数字高 4 位存十位低 4 位存个位。比如十进制 82压缩 BCD 就是0x82十进制 59 就是0x59。字节里的每一位都不浪费所以叫压缩。这种格式存储效率高缺点是每次只能处理两位数字而且加减法之后需要修正。非压缩 BCDunpacked BCD一个字节只存一位十进制数字放在低 4 位高 4 位一般是 0。十进制 82 在非压缩 BCD 里占两个字节0x08和0x02通常低位在前。如果高 4 位填的是0011那这个字节就退化成 ASCII 数字字符了比如8就是0x38、2就是0x32。这就是为什么这套指令的名字里都带 ASCII —— 它们最初就是为处理 ASCII 数字字符设计的。两种格式对应两套调整指令一张表先摆出来格式加法调整减法调整乘法调整除法前调整压缩 BCDDAADAS无无非压缩 BCD / ASCIIAAAAASAAMAAD压缩 BCD 只有加减两条指令乘除没有对应的调整指令这一点后面会解释原因。非压缩 BCD 则是四条全齐这也从侧面说明它更接近给人看的格式。1.2 从 RTC 芯片到金融数据BCD 至今没死透的地方说几个 BCD 真实存在的地方这样你学的时候心里有底。最常见的是实时时钟芯片。PC 主板上的 CMOS 时钟、嵌入式里常用的 DS1302/DS3231 这类 RTC 芯片秒、分、时、日、月、年这些寄存器基本都是 BCD 格式。你从端口读出来的秒如果是0x59那不是 89 秒而是 59 秒。不转换就显示屏幕上的时间会以一种很诡异的方式跳变——这也是我开头踩的坑。第二个地方是金融和主机系统的定点数。COBOL 里有个数据格式叫 COMP-3就是压缩 BCD一个字节存两位数字最后半个字节存符号。这类数据在银行批量处理、票务清算里跑了几十年现代系统读取这些老数据的时候还是要做 BCD 转换。第三个地方是数字电路和 FPGA。你在 Verilog 里写一个 BCD 计数器或者 BCD 加法器本质上就是在用硬件实现 DAA 的那套逻辑理解软件的调整指令对看懂硬件设计也有帮助。顺带说一句这也是为什么这些指令没有被彻底删掉——它们对应的数据格式还在只是处理它们的责任从 CPU 转移到了软件库和编译器运行时。现代编译器生成整数转字符串的代码时用的是除法取余或者乘倒数取整那一套不会去调用 DAA因为 DAA 在当代微架构上慢得离谱。2. DAA 与 DAS 的调整逻辑把加 6 修正说透调整指令的核心动作只有一个词修正。二进制加法器算出来的结果如果落在非法 BCD 区间某 4 位大于 9就得把它掰回合法值同时把进位正确地传给更高位。这个掰的具体手法是加 6 或者减 6为什么是 6从硬件角度一看就明白。2.1 先看硬件一个 4 位加法器怎么算出 BCD 结果假设你有一个标准 4 位二进制加法器输入是两个 BCD 数字比如 9 和 7。二进制加起来是 16输出 4 位是0进位是 1。但十进制上 9 7 16结果应该是进位 1、本位 6。问题出在哪二进制加法器逢 16 进位而十进制只允许逢 10 进位中间差了 6。所以修正方法就是把结果再加上 616 6 22低 4 位是 6进位仍然是 1正好就是十进制的 16。这就是加 6 修正的全部原理。减法的镜像逻辑同理二进制借位是借 16十进制只该借 10多借了 6所以减 6 修正。用 Verilog 描述这个逻辑只要三行// 4 位 BCD 加法器的一级超出 9 就加 6 wire [4:0] t a b cin; assign sum (t 5d9) ? (t 5d6) : t; assign cout (t 5d9) ? 1b1 : 1b0;软件里的 DAA 做的是一模一样的事情只不过它一次处理 8 位两位十进制数字所以要分两级判断先修低 4 位再修高 4 位。而且它没法像 Verilog 那样直接拿到原始和因为运算已经结束了只能从标志位和结果反推。这就是 DAA 依赖 AF 和 CF 的原因。2.2 DAA 的两级判断与 0x9F 这个界限把 DAA 的实际行为写出来是这样IF (AL 0x0F) 9 OR AF 1 THEN AL AL 6 AF 1 ELSE AF 0 END IF IF AL 0x9F OR CF 1 THEN AL AL 0x60 CF 1 ELSE CF 0 END IF拆开看第一级处理低位如果低 4 位大于 9说明这个半字节是非法 BCD加 6 修正如果 AF 是 1说明低 4 位的二进制加法产生了半进位也就是和超过了 15即使结果的低 4 位看着合法也必须加 6 把多进的那 16 折算成 10。这两个条件是或关系缺一不可。第二级处理高位注意判断条件写的是AL 0x9F用的是加 6 之后的 AL不是原始结果。这个顺序很关键很多人在纸上推演的时候把顺序搞反就会算错。为什么要用 0x9F 而不用 0x99因为第一级加 6 有可能把一个高 4 位正好是 9的值推到 0xA0 以上这时候必须再补一次 0x60。提示第一级的AF 0分支会把 AF 清零第二级不会碰 AF。如果你在 DAA 之后还需要原始的 AF得提前保存。2.3 三个加法用例的手工推演光看伪代码容易糊我拿三个例子把整个链条走一遍。这三个都在 8086 上实测过结果和推演完全一致。用例一59 28 870x59 0x28 0x81CF 0AF 1低 4 位 9 8 17 超过了 15产生半进位。 第一级低 4 位是 1不大于 9但 AF 1所以加 6 ——0x81 6 0x87AF 置 1。 第二级0x87不大于0x9FCF 也是 0不调整。 结果 AL 0x87CF 0十进制 87。正确。这个例子的价值在于它展示了只看低 4 位数值是不够的AF 才是真正决定要不要修的东西。用例二95 08 1030x95 0x08 0x9DCF 0AF 1。 第一级低 4 位是 D大于 9加 6 ——0x9D 6 0xA3AF 置 1。注意这里结果的高 4 位从 9 变成了 A。 第二级0xA3大于0x9F加0x60——0xA3 0x60 0x03CF 置 1。 结果 AL 0x03CF 1合起来是 103。正确。这个例子专门用来解释 0x9F 这个界限的来历用 0x99 判断在这里虽然也能过0xA3 0x99 同样成立但机制的描述就说不通了。用例三99 01 1000x99 0x01 0x9ACF 0AF 19 1 10 9触发半进位。 第一级低 4 位是 A大于 9加 6 ——0x9A 6 0xA0AF 置 1。 第二级0xA0大于0x9F加0x60——0xA0 0x60 0x00CF 置 1。 结果 AL 0x00CF 1合起来 100。正确。用例三是最漂亮的一个它同时触发了第一级低 4 位非法和第二级高位溢出把 DAA 的两级结构完整跑了一遍。99 加 1 进位到三位数这条路径如果你没手工推过调试的时候看到 AL 变成 0 会以为程序崩了。2.4 DAS 是镜像结构CF 是借位链的钥匙DAS 的伪代码和 DAA 几乎对称只是把加 6 换成减 6IF (AL 0x0F) 9 OR AF 1 THEN AL AL - 6 AF 1 ELSE AF 0 END IF IF AL 0x9F OR CF 1 THEN AL AL - 0x60 CF 1 ELSE CF 0 END IF同样拿实例验证。0x42 - 0x15二进制相减得0x2DCF 0AF 1低 4 位 2 减 5 要借位。第一级低 4 位是 D 大于 9减 6 得0x27第二级0x27不大于0x9F、CF 0不动。结果 27正确。再看需要借位的情况0x05 - 0x12。二进制相减得0xF3CF 1发生借位AF 1。第一级低 4 位是 3 不大于 9但 AF 1减 6 得0xED第二级0xED大于0x9F减0x60得0x8DCF 置 1。结果0x8D加上借位标志含义是87 - 100 -13而真实结果确实是 5 - 12 -13。这验证了 DAS 输出的是十的补码形式CF 1 表示要向更高位借 100。注意DAS 之后的 CF 不是结果是不是负数这么简单的语义而是需要从更高字节借一个十进制 100。多字节 BCD 减法链就是靠这个标志串起来的。3. 多字节压缩 BCD 加减法ADC DAA 的接力单字节只能表示两位十进制数一超过 99 就得扩展到多字节。这里有个设计上的巧合也可能不是巧合让多字节 BCD 加法写起来异常干净。3.1 为什么 DAA 设置的 CF 恰好能直接喂给下一条 ADC看这段循环; SI 指向被加数低字节DI 指向加数低字节CX 是字节数 CLC ; 最低字节没有进位输入 next: MOV AL, [SI] ADC AL, [DI] ; 用上一轮留下的 CF 作为进位输入 DAA ; 调整同时把十进制进位写回 CF MOV [SI], AL INC SI INC DI LOOP next关键在于DAA输出的 CF 语义如果这一字节的十进制运算产生了进位CF 1。而ADC需要的输入正好就是上一字节有没有进位。两者严丝合缝所以既不用额外的寄存器保存进位也不用做任何判断分支一条链直接串到底。我第一次看懂这个设计的时候确实觉得挺妙的。你可以对比一下纯二进制的多字节加法ADD之后ADC接力靠的也是 CF但那是因为二进制进位天然就是 CF。BCD 这里CF 经过了 DAA 的重新解释从二进制进位 16变成了十进制进位 100语义变了但管道接口没变。这就是为什么 DAA 必须保留并正确设置 CF——它不是顺手设的是整条链子的接口。3.2 一段能在 DOSBox 里跑的完整代码下面这段是完整可跑的 16 位 DOS 程序用 NASM 编译成 .COM 文件在 DOSBox 里直接执行就能看到结果。它做两件事算一个会进位的 BCD 加法然后把结果按十进制显示出来。; bcdemo.asm ; nasm -f bin bcdemo.asm -o bcdemo.com org 100h start: mov al, 0x99 mov bl, 0x01 add al, bl ; AL 0x9A, AF 1 daa ; AL 0x00, CF 1 → 结果 100 ; 保存 CF 表示的百位先显示它 mov cl, 0 adc cl, 0x30 ; CL 0 或 1 mov dl, cl mov ah, 02h int 21h ; 显示 AL 的高 4 位十位 mov bl, al shr al, 1 shr al, 1 shr al, 1 shr al, 1 or al, 0x30 mov dl, al mov ah, 02h int 21h ; 显示 AL 的低 4 位个位 mov al, bl and al, 0x0F or al, 0x30 mov dl, al mov ah, 02h int 21h mov ax, 4C00h int 21h运行结果是屏幕上打出100。这里有个小技巧值得记把 CF 加进CL用的是ADC CL, 0x30因为进位的值是 0 或 1加上0x30之后正好是字符0或1一步完成从标志位到可显示字符的转换。用 8086 做移位的时候要注意SHR AL, CL里的移位次数必须放 CL不能直接写立即数写四次SHR AL, 1虽然啰嗦但一定对。如果你想走另一条路用 AAM 来拆位也是可以的后面会讲。3.3 单步调试时该盯哪几个标志位调试这类程序光看寄存器值不够必须盯着标志位看。我一般会在 DOSBox 自带的调试器或者 emu8086 里单步走重点看三处第一ADD执行完之后AF 是不是符合预期。这一步是判断 DAA 会不会走低 4 位修正分支的唯一依据如果 AF 和你预期的不一样说明你对两个操作数的理解有偏差。第二DAA执行完之后CF 是不是符合预期。这是判断多字节链能不能接下去的关键。第三注意DAA执行完之后AL 的数值可能比原来小比如 0xA0 变成 0x00这是正常的不是溢出。还有一个容易忽略的点DAA之后OF 是未定义的。Intel 的手册里明确写着 DAA/DAS 对 OF 的影响是 undefined。所以千万别用JO或者JNO去判断 BCD 运算有没有溢出要判断溢出只能靠 CF或者提前做范围检查。4. AAA / AAS / AAM / AAD非压缩 BCD 工具箱非压缩 BCD 那边有四条指令名字里的 ASCII 暴露了它们的出身。这里有个常见误解很多人以为这四条是给 ASCII 码用的所以要先把字符的高 4 位清掉其实不用——它们本来就设计成可以直接吃0x30到0x39的原始字符。4.1 AAA 的名字由来7 5 的完整推演拿一个最简单的例子屏幕上输入两个字符7和5想算出它们的和并显示。在非压缩 BCD 的思路下你不需要把字符转成数值直接把它俩当数字加0x37 0x35 0x6CAF 0低 4 位 7 5 12没超过 15不产生半进位CF 0。现在执行 AAA。AAA 的实际行为是IF (AL 0x0F) 9 OR AF 1 THEN AL AL 6 AH AH 1 AF 1 CF 1 ELSE AF 0 CF 0 END IF AL AL 0x0F对这个例子低 4 位是 C大于 9所以AL 6 0x72AH 1假设 AH 初值为 0变成 1AF 1CF 1。最后AL 0x0F 0x02。结果AH 1AL 0x02合起来就是十进制的 12而 7 5 确实等于 12。整个过程没有任何显式的字符转数字步骤AAA 一条指令同时完成了数值化、十进制修正和进位处理。这就是它名字里带 ASCII 的原因——它是为 ASCII 数字加法量身定做的。再验证一个8 9。0x38 0x39 0x71AF 18 9 17 15。低 4 位是 1不大于 9但 AF 1所以AL 6 0x77AH 1然后AL 0x0F 0x07AH 1CF 1。结果 17。正确。AAS 是减法版本逻辑对称IF (AL 0x0F) 9 OR AF 1 THEN AL AL - 6 AH AH - 1 AF 1 CF 1 ELSE AF 0 CF 0 END IF AL AL 0x0F注意 AAS 的AH AH - 1这是借位的表达方式。坑点AAA 和 AAS 会无条件清零 AL 的高 4 位。这看起来是副作用其实是设计意图——把残留的0x30抹掉。但如果你在此之前往 AL 高 4 位塞了有用信息那就找不回来了。4.2 AAM 的真实身份乘完之后拆十位和个位非压缩 BCD 乘法在硬件上很简单两个 4 位以内的数字相乘结果最大是 9 × 9 81一个字节装得下而且是纯二进制结果。问题在于非压缩 BCD 要求每个字节只存一位十进制数字所以 81 必须拆成十位 8、个位 1两个字节。这个拆分动作就是 AAMAH AL / 10 AL AL MOD 10假设 AL 里是 81执行 AAM 之后 AH 8AL 1正好是两个非压缩 BCD 数字。因为 AAM 本质上就是除以 10 取商和余数有不少老代码把它当成一条免费的除法指令来用用AAM替代DIV来把一个字节拆成两个十进制位省掉XOR AH, AH、MOV BL, 10、DIV BL三条指令。这个用法在 8086 时代确实能省字节我当时看到也觉得挺机灵。但这里有两个代价必须知道。第一AAM 是隐含 AX 作为操作数的它会无条件覆盖 AH所以你的 AH 里要是有别的东西就没了。第二AAM 在后续处理器上是微码指令执行成本远高于普通的算术指令省代码不省时间。4.3 AAD 与除法前的合并以及 AH 清零这个必做动作除法的情况正好反过来。你要做非压缩 BCD 除法比如十位 3、个位 5想除以 2但除法指令只认二进制。所以必须先把两个数字合并成一个数35。AAD 就是干这个的AL AH * 10 AL AH 0执行完 AAD 之后 AL 35AH 清 0然后就可以放心DIVmov ah, 3 mov al, 5 aad ; AL 35, AH 0 mov bl, 2 div bl ; AL 17商, AH 1余数35 除以 2 得商 17 余 1完全正确。AAD 这个名字有点绕——它不是调整之后的结果而是除法之前的准备工作所以叫 ASCII Adjust before Division。名字里的 before 很关键容易记混。这里有个必须强调的坑AAD 之后 AH 是 0这不是副作用是它工作的一部分。但反过来如果你在做多字节非压缩 BCD 运算把 AH 当成了进位寄存器在跨字节传递那 AAD 会把它清掉。我在做四字节 BCD 加法循环的时候就栽过这个跟头——以为 AH 里的进位是跨轮保留的结果第二轮开始数据就全错了。顺带说一下多字节非压缩 BCD 加法的正确写法。每一轮处理一个字节进位的传递应该靠 CF而不是靠 AHmov cx, 4 clc ; 最低字节无进位 next: mov al, [si] adc al, [di] aaa ; CF 本轮十进制进位 mov [si], al xor ah, ah ; 清掉 AH避免累积污染 inc si inc di loop next最后那句xor ah, ah是我后来加上去的。因为 AAA 在触发调整时会给 AH 加 1如果不清零AH 会一轮一轮累积变成到目前为止一共进过几次位这种没用的东西下一轮再执行 AAA 的时候基准就不对了。4.4 立即数变体的野路子与风险AAM 和 AAD 的机器码是0xD4和0xD5后面各跟一个立即数字节。官方文档只说立即数应该是0x0A其他值行为未定义。但实际上绝大多数 x86 处理器会老老实实按那个立即数去做乘除运算。所以你可以这样写db 0xD4, 0x0B ; 相当于 AH AL / 11, AL AL % 11在 16 位代码里这确实能跑一些老技巧文档拿它当除以任意常数的捷径。我不建议在正经代码里用第一Intel 和 AMD 的手册都写了非 0x0A 的立即数行为未定义未来某个微架构上翻车的概率不为零第二同样的效果用乘法加移位能做出来而且更快第三可读性差别人读到db 0xD4, 0x0B会懵。如果你的目的只是除以 10直接写AAM就好别玩立即数。5. 64 位模式下这套指令为什么集体失效前面讲的都是 16 位 / 32 位环境。如果你在 64 位平台上试着写入 DAA 或 AAA会得到一个信号——非法操作码异常。这不是汇编器的问题是架构层面的。5.1 长模式下的非法指令异常x86-64 长模式下这六条指令全部被标记为无效DAA0x27、DAS0x2F、AAA0x37、AAS0x3F、AAM0xD4、AAD0xD5。执行它们会触发#UDUndefined Opcode异常操作系统收到之后一般直接终止进程。原因是这些指令在 64 位模式下腾出的操作码空间被分配给了别的用途加上现代编译器早就放弃了 BCD 硬件支持架构师认为没必要保留。这个事实带来的实际影响是什么如果你的课程实验或者老项目要在 64 位系统上跑而这部分代码原本用了 DAA那你就必须重写。不能指望反正在兼容模式——除非你真的编译成 32 位目标并跑在 32 位兼容模式下那是可以的。验证方法很简单用一段内联汇编写进去就崩/* 在 64 位 Linux 上编译运行会收到 SIGILL */ int main(void) { __asm__ volatile(movb $0x59, %al\n\t addb $0x28, %al\n\t .byte 0x27); /* DAA */ return 0; }编译成 64 位会直接崩改成-m32就正常了。这个对比我做过一次感受挺直观的。5.2 Go 汇编环境里怎么处理 BCD手写软件版 DAAGo 的 amd64 汇编Plan9 风格里当然也没有这些指令而且因为长模式的限制你连手写字节都跑不了只能软件模拟。好在 DAA 的逻辑本来就不复杂翻译成普通代码也就十来行// addBCD 把两个压缩 BCD 字节相加返回结果和十进制进位 func addBCD(a, b byte) (byte, bool) { lo : int(a0x0F) int(b0x0F) hi : int(a4) int(b4) carry : false if lo 9 { // 低 4 位非法加 6 修正 lo - 10 // 等价于 6 后取低 4 位 hi } if hi 9 { // 高 4 位非法加 6 修正 hi - 10 carry true } return byte(hi4) | byte(lo), carry }把lo - 10换成lo 6再取低 4 位效果完全一样我选前者是因为读起来更接近十进制的直觉。这段代码我拿几个边界值测过addBCD(0x99, 0x01)得到(0x00, true)addBCD(0x59, 0x28)得到(0x87, false)和硬件版 DAA 的结果完全一致。用软件版本的好处是不依赖 x86 架构ARM、RISC-V 上都能跑而且比硬件版快得多——这个后面说。5.3 性能账微码指令的周期代价这里要纠正一个直觉上的误解很多人以为硬件指令肯定比软件快。对 BCD 调整指令来说恰恰相反。在现代微架构上DAA、DAS、AAA、AAS、AAM、AAD 全部是微码指令一条要拆成几个到十几个微操作。相比之下ADD是一个微操作ADC也就一两个。差距大概是十倍这个量级。具体到每个型号的周期数我没有在这里给数字因为不同微架构差别不小而且我不能保证手册上的数字和你手上的机器一致。如果你真的关心可以自己用RDTSC把两种实现各跑一千万次比一比这个测量方法是最靠得住的。而软件版 DAA 的代码全是简单的加减移位分支预测好的话每个字节几个周期就能过完。所以在 64 位系统上做 BCD 处理用软件实现根本不是退而求其次而是更快、更可移植的正解。硬件 DAA 只在 8086 时代的晶体管预算下才有意义——那时候多一条指令换少几十条指令是划算的现在反过来了。我实际做过一次粗略对比对一兆字节的数据做 BCD 累加在 32 位模式下用ADC DAA循环比用等价的纯算术实现慢了不少。这让我彻底放弃了为了性能用硬件调整指令这个念头。6. BCD 与二进制互转几种比 AAM 更值得掌握的路子实际工程里调整指令用得最多的场合其实不是做加减法而是格式转换从设备读到的 BCD 要变成人能看的十进制或者把计算结果输出成 BCD。这部分我用得比加减法多得多值得单独展开。6.1 二进制转 BCD除法循环与 Double Dabble最直接的办法是反复除以 10 取余数。一个 16 位无符号数转成压缩 BCD 需要五位数循环五次; AX 里是待转换的值结果写进 5 字节缓冲区 ; 每次 DIV 10余数就是最低位十进制数字 mov cx, 5 mov bx, buf next: xor dx, dx mov si, 10 div si ; AX 商, DX 余数 mov [bx], dl ; 余数 0..9正好是压缩 BCD 的半字节 inc bx loop next ; 循环结束时 buf 里是逆序的需要翻转这个做法的缺点很明显DIV很慢而且每次只能拿到一位。如果你在做嵌入式开发代码空间和周期预算都紧那还有一条更优雅的路Double Dabble也叫移位加 3 算法。它的思路是把二进制数一位一位地左移进 BCD 缓冲区每次移位之前检查每个 BCD 半字节是不是大于等于 5是的话加 3。加 3 之后如果左移就不会溢出到非法区间。整个算法只用移位、比较和加法没有任何除法。为什么加 3 就能防溢出因为一个半字节左移等于乘 2如果它当前是 5 到 9左移之后会变成 10 到 18越过了 9。而先加 3 变成 8 到 12其中 10 到 12 仍然是越界的——不对这个解释不完整。准确的说法是加 3 之后即使左移引入进位结果仍然落在合法 BCD 范围和正确的十进制位上这是这个算法能被证明正确的原因。细节推导比较长你只需要记住每次左移前把 ≥5 的半字节加 3用几个数值手推一遍就能建立信心。Double Dabble 在硬件里特别常用因为硬件做除法很贵做移位和比较几乎不要钱。软件里如果你要转的位数不多用它也能省掉慢速的 DIV。6.2 BCD 转二进制AAD 循环与查表反方向最简单的情形是两位非压缩 BCD 转成一个字节直接用 AAD 一条指令搞定前面已经演示过。如果是多位就循环每读一个数字把累积值乘 10 再加进去。; SI 指向一串非压缩 BCD 数字高位数在前CX 是位数 xor bx, bx ; BX 作为累积值 next: mov ax, bx mov dx, 10 mul dx ; DX:AX BX * 10 mov bx, ax mov al, [si] xor ah, ah add bx, ax inc si loop next ; 循环结束 BX 就是二进制值这段代码里有个细节MUL会把结果放到 DX:AX 里如果数字很大要处理 DX。简单场景下直接取 AX 就够了超过 16 位的值得用 32 位累积。对于更快的场景还可以用查表法预先算好每个十进制位对应的权重1、10、100、1000……读一位就乘一次权重累加用IMUL或者移位加来做乘 10。查表法在位数固定的时候特别适合没有循环开销全部展开。6.3 x87 的 FBLD / FBSTP 与 18 位 BCD最后补充一个冷门但有用的东西x87 浮点单元里有两条专门处理 BCD 的指令。FBLD把 80 位打包 BCD10 个字节9 个字节存 18 位十进制数字最后 1 个字节放符号加载到浮点栈上FBSTP反过来把浮点值转成 80 位打包 BCD 存到内存。这两条指令在需要高精度十进制运算的老财务软件里出现过。它的价值在于 18 位十进制数字的精度远超 64 位整数的 19 位十进制上限——等等两者接近但 x87 的扩展精度格式能同时保持小数部分这才是关键。做十进制小数运算时用 BCD 转成 x87 扩展精度再转回来比用二进制浮点更能控制误差。不过FBSTP的输出格式比较特殊最后那个符号字节的编码方式和普通的补码不一样具体规则我这里不展开你在用之前务必查一遍手册确认。这类指令在 64 位模式下依然可用x87 指令没有被移除这一点和整数调整指令不同。7. 几个我踩过的坑和调试习惯把上面所有内容串起来最后集中说几条经验。这些都是我在实际操作中碰出来的不是手册上会写的。第一条是关于格式假设的。拿到一段二进制数据第一件事是确认它到底是二进制还是 BCD不要凭直觉。我开头那个 RTC 秒寄存器的坑就是这么来的。判断方法也不复杂如果一串字节的高 4 位全都落在 0 到 9 的范围内那它是 BCD 的可能性很大如果高 4 位经常出现 A 到 F那就是纯二进制。这个特征在十六进制 dump 里一眼就能看出来。第二条是关于调整指令的独立性。DAA 必须紧跟在 ADD 或 ADC 后面中间不要插入任何会改 AF 或 CF 的指令。我见过有人写成ADD AL, BL/MOV DL, AL/DAA然后疑惑结果为什么不对。MOV本身确实不改标志位所以这个例子恰好没事但只要你插进去的是INC、TEST、CMP这类指令AF 和 CF 就被破坏了。稳妥的做法是让调整指令和它服务的运算指令紧挨着。第三条是关于调试工具的选择。做 16 位 BCD 调试DOSBox 加上它的调试器是最省事的环境-f bin编出 .COM 文件直接挂上去单步走标志位看得清清楚楚。如果你在国内的教学环境里emu8086 也是常见选择它会把标志位和寄存器都显示在一个面板里。纯 64 位环境想验证 BCD 逻辑就老老实实用 C 或 Go 写软件版别想着绕过去执行硬件指令。第四条是关于性能判断。不要因为这是硬件指令就默认它快。我在前面已经说过微码的代价了这里再强调一次任何涉及 BCD 的循环如果长度是常数优先考虑软件展开如果是热路径先测再优化。最后一句关于学习的建议。这六条调整指令在现代代码里几乎绝迹但它们背后的格式转换 修正思路是通用的你今天写 JSON 解析、写协议编解码、写字符集转换做的都是同一类事——识别输入格式按规则转换处理好边界和进位。把 DAA 的两级判断推清楚把 AAA 的 AH 累加问题踩一遍你对边界处理这件事的敏感度会明显提升。这个收获比记住六条指令的机器码有用得多。