九加八等于十七这话连小学生都不会说错。可我第一次用汇编写十进制加法显示程序的时候屏幕上打出来的结果是 11。原因不复杂CPU 只会做二进制加法08H 加 09H 得到的是 11H这个数在二进制语境下确实是 17可它按十进制读出来就是 11。想让屏幕上出现 17中间必须人为插一步修正这一步就是汇编里常说的 BCD 码调整指令。这篇笔记把 BCD 码的两种存储形态、DAA、DAS、AAA、AAS、AAM、AAD 这六条调整指令的完整逻辑、多位十进制加法的进位链写法以及我在实际调试里反复踩过的坑从头到尾捋一遍。如果你正在学 8086 汇编、在啃《IBM-PC 汇编语言程序设计》那类教材或者做嵌入式仪表显示、需要精确十进制运算这篇内容可以直接当操作手册用哪怕你只是想弄明白为什么现代编译器几乎不用这些指令也能找到答案。1. 从一次加法算错说起BCD码到底解决什么问题1.1 为什么二进制加法会算错十进制很多人第一次接触 BCD 码都会犯迷糊明明用二进制存数为什么还要专门搞一套十进制编码答案藏在两个世界的缝隙里。CPU 的加法器是二进制加法器它只认 0 和 1做的是按位加加进位。08H 加 09H二进制是 0000 1000 加 0000 1001结果是 0001 0001也就是 11H。如果你把这个 11H 当成十六进制数看它是 17可如果你希望这个字节的每一个 4 位组分别表示一位十进制数字那它读出来就是11因为高 4 位的 1 表示十位是 1低 4 位的 1 表示个位是 1。问题就出在这里——十进制加法在个位满十时需要向十位进 1而二进制加法在低 4 位满十六时才向高 4 位进 1两种进位阈值差了 6这 6 就是所有调整指令存在的理由。我第一次真正理解这件事是在纸上把 09H 加 09H 一步步展开。二进制算出来是 0001 0010即 12H低 4 位的 2 是对的但高 4 位多出来一个 1这个 1 是二进制进位带来的它在十进制里没有意义因为 9 加 9 等于 18十位应该是 1个位应该是 8正确结果是 18H。对比之下差值正好是 6这就是 DAA 在低 4 位大于 9 时给 AL 加 6 的来源。理解了差 6这个核心六条调整指令就不再是六条需要死记硬背的规则而是一套围绕 6 做加减的补偿机制。1.2 压缩BCD与非压缩BCD的两套玩法BCD 码的存储方式有两种这个分类非常关键因为它直接决定你该用哪条调整指令。压缩 BCD也叫组合 BCD把一个字节拆成两半高 4 位存十位低 4 位存个位一个字节正好存两位十进制数。比如十进制 47压缩 BCD 就是 0100 0111写作 47H——注意它长得像十六进制的 47但含义完全不同。非压缩 BCD也叫分离 BCD一个字节只存一位十进制数字高 4 位通常填 0低 4 位放数字。十进制 47 用非压缩 BCD 表示就是两个字节04H 和 07H。前者省空间后者好处理两条路线在加减乘除上各配一套调整指令用错了就会得到莫名其妙的结果。压缩 BCD 的优势是存储密度高一个字节两位数字做多位十进制运算时非常紧凑早期的财务软件、电子秤、计价器大量使用这种方式。代价是每条字节里高低 4 位要分别判断调整逻辑稍微绕一点。非压缩 BCD 的优势是每一位单独占一个字节做加法时进位可以直接往高位字节传写循环很顺手缺点是一半的字节空间被浪费。我在做仪表数码管显示的项目里内部运算用压缩 BCD 省 RAM往数码管送数据前再转成非压缩 BCD 逐位输出两套形态混用是常态关键是每一步都得清楚自己手上这个字节是哪一种。1.3 非法编码那些没被定义的4位组合BCD 码的合法值只有 0000 到 1001 这十个1010 到 1111 这六个组合在 BCD 里是非法的。这个细节听起来像是考试题实际调试时却是最常见的坑。假如一次 DAA 之后 AL 的高 4 位变成了 1010说明你的调整逻辑没走对或者你根本不该用 DAA 而是该用 AAA。我遇到过一种情况把两个压缩 BCD 数相加结果里出现了 0FH 这种字节怎么看都不像十进制最后发现是加法之后忘了调整二进制进位把低 4 位推到了 15。非法编码还有一个隐蔽来源就是加减运算本身产生的中途状态。DAA 的内部逻辑是先判断低 4 位、加 6再判断高 4 位、加 60H两次判断之间有中间态中间态可能短暂落在非法区间里但最终结果一定回到合法区间。这意味着你不能在 DAA 之后、下一条指令之前去观察 AL看起来可能不对其实指令执行完就已经修正了。理解这点能省下很多是不是指令用错了的自我怀疑时间。判断一个字节是不是合法 BCD简单办法是看每个 4 位组是否都小于等于 9调试时我就是靠这个口算规则一眼扫出问题的。2. 六条调整指令的分工与共同套路2.1 为什么CPU不给你一条十进制加法指令有人会问既然十进制运算这么常见为什么不直接在硬件里做一个 BCD 加法器一条指令就搞定这背后是成本和收益的权衡。BCD 加法器需要在每个 4 位组里判断是否大于 9、是否要补 6还要处理跨组的进位传播电路面积比纯二进制加法器大不少。而 BCD 运算在通用计算里的占比很低绝大多数场景用二进制就够了硬件厂商自然不愿意为小众需求付出芯片面积和延迟的代价。早期确实有处理器直接支持十进制运算但那是个别设计x86 家族走的是二进制运算加软件调整这条路用几条单独的调整指令来补偿。这种设计还有个好处是灵活性。调整指令是独立的指令你可以控制什么时候调整、要不要调整甚至可以只调整低 4 位。代价是编译器必须显式生成这些指令代码体积和指令周期都会增加。所以后来的编译器基本放弃了 BCD改用整数放大法或者定点小数来模拟十进制。理解了这层历史你就明白这六条指令不是过时的鸡肋而是特定年代软硬件分工的一种妥协学它们更多是为了读懂老代码和维护老系统。2.2 加减乘除四类场景对应的六条指令六条调整指令按运算类型可以分成三组弄清楚分组关系后再记细节会轻松很多。运算类型BCD形态调整指令配合的运算指令加法压缩BCDDAAADD / ADC减法压缩BCDDASSUB / SBB加法非压缩BCDAAAADD / ADC减法非压缩BCDAASSUB / SBB乘法非压缩BCDAAMMUL除法非压缩BCDAADDIVAAD要先执行压成一张表之后规律就很明显压缩 BCD 只涉及加减用 DAA 和 DAS非压缩 BCD 加减乘除全都要用分别对应 AAA、AAS、AAM、AAD。为什么压缩 BCD 没有乘法除法调整指令因为压缩 BCD 的乘除结果位数会膨胀两位乘两位可能得到四位十进制一个字节根本装不下硬件没法用一条固定路径完成所以设计上就没提供只能拆成非压缩 BCD 逐位算或者转成二进制算完再转回来。这个设计取舍值得记住遇到需要乘除的 BCD 场景先把数据展开成非压缩形式是最省心的做法。2.3 隐含操作数AL与标志位AF/CF的联动关系六条调整指令有一个共同的套路它们都不带显式操作数默认操作 AL而且都要读和写标志位。这一点跟常见的 ADD、MOV 很不一样新手最容易在这里犯错以为能写 DAA BX 或者 DAA AL。实际上 DAA 后面的操作数写出来汇编器会直接报错因为它根本没有操作数字段。默认操作 AL 这个设定源于历史早期处理器指令编码空间紧张把最常用的累加器设为隐含操作数能省下不少位数。真正需要重点盯住的是 AF 和 CF 这两个标志位。AF 是辅助进位标志记录的是低 4 位向高 4 位的进位也就是 bit3 往 bit4 的进位它正是 BCD 调整要看的信号。CF 是进位标志在压缩 BCD 里代表十进制进位在非压缩 BCD 里功能相同但产生方式不同。DAA 和 DAS 会同时读写 AF 和 CFAAA 和 AAS 也会设置这两个标志。麻烦的地方在于你前一条 ADD 产生的 AF 和 CF 会被调整指令读取而调整指令自己又会重新写一遍所以如果中间夹了别的指令很可能把标志位冲掉。我的习惯是 ADD 之后紧跟着写调整指令中间绝不插入任何指令包括看似无害的 MOV。3. 逐条拆解DAA/DAS/AAA/AAS/AAM/AAD怎么用3.1 DAA与DAS压缩BCD加减的六与六十DAA 是十进制加法调整指令执行时机是 ADD 或 ADC 之后。它的内部逻辑分两步走第一步看低 4 位如果 AL 的低 4 位大于 9或者 AF 等于 1就给 AL 加 6并把 AF 置 1否则把 AF 清 0。第二步看高 4 位如果 AL 的高 4 位大于 9或者 CF 等于 1就给 AL 加 60H并把 CF 置 1否则把 CF 清 0。注意这两步是顺序执行的第二步判断的是第一步已经改过的 AL。所有加6还是加60H的疑问回到二进制进位阈值是16、十进制是10、差值是6这个原点就通了。DAS 是十进制减法调整逻辑对称低 4 位大于 9 或 AF 等于 1 就减 6高 4 位大于 9 或 CF 等于 1 就减 60H同样顺序执行。减法为什么也是减 6 而不是加 6因为借位方向反了二进制在多借了 6 之后需要减回来。我用纸笔推过一遍 04H 减 08H 的情况二进制算出来是 FCH低 4 位 C 大于 9减 6 得到 F6H高 4 位 F 大于 9减 60H 得到 96H。96H 加上一个 100 的借位结果就是 -4跟十进制 4 减 8 等于 -4 完全对上。这种纸面推导花不了几分钟但能把规则从记住变成理解。注意DAA 和 DAS 只对 AL 操作AX 的高位字节 AH 不受影响做多字节压缩 BCD 运算时要自己把 CF 传给下一个字节。3.2 AAA与AAS非压缩BCD加减与进位传递AAA 是 ASCII 加法调整处理非压缩 BCD 加法。规则是如果 AL 低 4 位大于 9 或者 AF 等于 1就给 AL 加 6、给 AH 加 1、置位 AF 和 CF否则清 AF 和 CF。最后无论哪种情况都把 AL 的高 4 位清零保证 AL 里只剩下一位十进制数字。这里 AH 加 1 值得单独说一句——这是 AAA 和 DAA 最大的区别。非压缩 BCD 一个字节存一位数字十进制进位必须送到高位字节去所以 AAA 顺手帮你把 AH 加了 1DAA 就没有这个功能因为压缩 BCD 的进位是字节之间的进位得靠 CF 手动传。AAS 是非压缩 BCD 减法调整逻辑对称低 4 位大于 9 或 AF 等于 1 时AL 减 6AH 减 1置位 AF 和 CF否则清标志最后同样把 AL 高 4 位清零。减法的方向要特别注意借位时 AH 是减 1 而不是加 1。我在写多字节非压缩 BCD 减法时一开始把 AH 写成了加 1结果低位借位时高位反而变大排查了半天才发现是把 AAS 的规则跟 AAA 记混了。这两条指令一个加 AH 一个减 AH是必须分清楚的地方。实操心得AAA 和 AAS 都会直接改写 AH如果你在循环里拿 AH 当临时变量用务必先把它保存到别的寄存器或者压栈否则循环两圈之后 AH 就被冲没了。3.3 AAM与AAD乘除调整里的隐藏除法AAM 是 ASCII 乘法调整默认形式是 AAM等价于 AAM 0AH。它的动作是把 AL 除以 10商放进 AH余数放回 AL。比如 AL 等于 0DH也就是 13执行 AAM 之后 AH 变成 1AL 变成 3正好把 13 拆成十位和个位。这条指令看起来是调整骨子里其实是一次除法运算所以它的执行周期在早期处理器上明显比别的调整指令长。AAM 最常见的用法不是做乘法而是把 0 到 99 之间的数拆成两位十进制数字再各加 30H 转成 ASCII 送去显示。AAD 是除法调整用法跟其他五条完全相反——它不是放在运算之后而是放在 DIV 之前。执行 AAD 时AL 被重新计算为 AH 乘以 10 再加 ALAH 清零。这一步是把非压缩 BCD 形式的被除数转换成纯二进制数然后再做 DIV得到的商和余数再各自变回 BCD。我刚开始学的时候总是记混方向后来用一句话锁死AAD 是进除法之前先把十进制数掰成二进制AAM 是乘法出来之后把二进制数掰成十进制。两个字母顺序不同执行位置和方向也正好相反。隐藏细节AAM 和 AAD 的立即数字节是可以改的操作码分别是 D4 和 D5后面跟的那个字节默认是 0A十进制 10改成 02 就变成了按二进制拆分或者按二进制合并。这个技巧在需要按其他进制拆分数字时很好用能省掉一段除法代码。不过要注意改成非 10 的基数之后指令的语义就跟 BCD 无关了只是借用了同一条机器码。4. 上机实操从单位加法器到多位BCD累加4.1 环境与代码框架为了把上面这些指令跑通我用 16 位实模式环境做验证工具链是常见的汇编器和调试器组合代码段用标准的段定义结构。开头先定义数据段放两组压缩 BCD 测试数据和一块输出缓冲区代码段里用 PROC 和 ENDP 划分多个子过程方便单独调试。之所以不直接在 64 位模式下测试是因为 DAA、DAS、AAA、AAS、AAM、AAD 这几条指令在 64 位模式下根本不能编码写进去会被判为非法指令这一点后面单独展开。DATA SEGMENT BCD_A DB 47H, 68H ; 压缩BCD代表 6847 BCD_B DB 25H, 39H ; 压缩BCD代表 3925 RESULT DB 2 DUP(0) ; 存放结果留进位空间 MSG DB RESULT: $ DATA ENDS STACK_SEG SEGMENT STACK DW 64 DUP(0) STACK_SEG ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK_SEG START: MOV AX, DATA MOV DS, AX ; ……后续运算代码 CODE ENDS END START框架搭好之后先别急着写多位运算从单字节开始验证。单字节压缩 BCD 加法是最小的可验证单元跑通了再往上叠循环调试成本会低很多。我见过不少人一上来就写四字节累加结果出错之后根本不知道是哪一层的进位链断了只能整段重写。4.2 单字节压缩BCD加法的完整验证单字节压缩 BCD 加法的核心就三条指令把第一个数放进 AL用 ADD 加上第二个数然后紧跟一条 DAA。写出来是这样MOV AL, 47H ADD AL, 25H ; AL 6CH DAA ; 调整后 AL 72HAF/CF 归零手动推一遍这个过程很有必要。47H 加 25H二进制逐位加得到 6CHDAA 第一步看低 4 位C 大于 9给 AL 加 66CH 加 6 得到 72HAF 置 1第二步看高 4 位7 不大于 9而且 CF 等于 0所以不再加 60HCF 清 0。最终 AL 等于 72H十进制 47 加 25 等于 72完全正确。再看一个产生进位的例子MOV AL, 99H ADD AL, 01H ; AL 9AHAF 1 DAA ; AL 00HCF 1表示十进制的 100这个例子是我觉得最能说明问题的一个。99H 加 01H 得到 9AH低 4 位 A 大于 9加 6 变成 A0HAF 置 1接着高 4 位 A 大于 9加 60HA0H 加 60H 得到 00H 并产生进位CF 置 1。结果是 AL 等于 00H、CF 等于 1合起来读作 100。如果没有 DAA直接拿 9AH 去显示你会得到一堆看不懂的字符。这里 CF 不能丢它代表百位多位运算里必须把它继续往上传。4.3 多字节BCD加法的进位链实现多个字节的压缩 BCD 加法关键是把每个字节的 CF 串成链。低字节算完产生的 CF 要参与高字节的加法所以高字节不能用 ADD得用 ADC。每一字节的流程固定为取数、带进位加、调整、存回用 LOOP 循环展开MOV SI, OFFSET BCD_A MOV DI, OFFSET BCD_B MOV BX, OFFSET RESULT MOV CX, 2 ; 两个字节代表四位十进制 CLC ; 最低字节之前清进位 NEXT_BYTE: MOV AL, [SI] ADC AL, [DI] ; 带进位加 DAA ; 十进制调整 MOV [BX], AL INC SI INC DI INC BX LOOP NEXT_BYTE ; 循环结束后的 CF 就是最高位的十进制进位这段代码有两个容易出错的地方。第一循环之前必须显式 CLC因为最低字节的加法不应该带上无关的进位如果你前面刚做过别的运算CF 里可能还残留着 1会让结果莫名多一。第二DAA 自己也会改写 CF这正是我们要的——它把本次字节的十进制进位重新算出来供下一轮 ADC 使用。整个循环的 CF 传递是自洽的不需要额外保存标志位。我用 BCD_A 等于 6847、BCD_B 等于 3925 这组数据验算过低字节 47H 加 25H 加上初始 CF 等于 0DAA 后得到 72HCF 等于 0高字节 68H 加 39H 加 0 得到 A1HDAA 后低 4 位 1 不用加 6高 4 位 A 大于 9 加 60H 得到 01H 并置 CF所以高字节结果是 01H、CF 等于 1。合并起来就是 10772跟十进制 6847 加 3925 等于 10772 完全一致。最终 CF 就是那个万位的 1如果要显示出来得在外面单独处理。4.4 非压缩BCD输出到屏幕运算结果要显示就得把它变成可打印的 ASCII。非压缩 BCD 到 ASCII 的转换非常直接每个字节低 4 位是数字 0 到 9加上 30H 就变成 0 到 9。压缩 BCD 则要先把高低 4 位拆开再分别加 30H。这里 AAM 是拆数字的利器MOV AL, 0DH ; 十进制的 13 AAM ; AH 01H十位AL 03H个位 ADD AX, 3030H ; AH 1AL 3 XCHG AH, AL ; 交换让内存顺序变成 1 在前 MOV WORD PTR [BUF], AX ; 小端存储BUF[0]1BUF[1]3XCHG 这一步初看多余其实很关键。x86 是小端存储AX 写进内存时低字节 AL 会落在低地址。AAM 之后 AH 是十位、AL 是个位如果直接存内存里先出现个位字符打印出来就是 31 而不是 13。交换 AH 和 AL 之后AL 变成十位字符、AH 变成个位字符写进内存的顺序就对了。这个坑我在第一次写数字显示时踩得很实在屏幕上的数字全是反的还以为是自己算错了运算部分。5. 踩坑记录与排查速查表5.1 典型现象与原因对照调试 BCD 相关的代码出问题的表现形式其实就那么几种我把自己遇到的整理成一张对照表出问题时先按这张表过一遍八成能定位到原因。现象可能原因排查动作结果显示 11 一类的错误值加完没做 DAA/AAA 调整检查运算指令后面是否紧跟调整指令结果比预期大 6调整指令执行了两次重复加 6顺着代码看有没有重复调整结果偏高或偏低 1循环开始前没有 CLC带入了残留进位在多字节运算前加一条 CLC高位字节莫名变化AAA/AAS 自动改了 AH检查 AH 是否另作他用数字显示顺序颠倒小端存储把高位字符写在后面用 XCHG 交换序汇编直接报错不通过在 64 位模式下写了这些指令改到 16 位或 32 位模式验证这张表是我自己攒的不代表官方文档但每一条都来自实际调试现场。尤其是最后一条很多人第一次在 64 位环境里写 DAA 会直接遇到无效指令的报错怀疑是汇编器版本问题其实是指令本身不被支持。把汇编参数切回 16 位模式问题立刻消失。5.2 我总结的几条实操心得第一条心得是标志位要用调试器实时看。AF 和 CF 的变化在源码里看不出来光靠推演很容易漏。我习惯在关键位置下断点一条指令一条指令地看 AL、AH、AF、CF 四个值只要这四个值对得上BCD 运算基本就不会错。调试器里单独看 AL 是不够的因为 CF 承载的高位信息经常被忽略。第二条是非压缩和压缩不要混用调整指令。这两种形态的调整指令长得像功能却不一样混用的后果是得到一个看起来合法但没有意义的字节。我的做法是在代码注释里明确标出每个变量是压缩 BCD还是非压缩 BCD写的时候照着标签选指令比记住六条规则要稳。第三条跟前面的循环有关在循环内部调用调整指令时要注意循环寄存器本身会不会被调整指令影响。调整指令只动 AL、AH 和标志位不动 CX、SI、DI所以循环控制是安全的。真正会被影响的是 AX如果你的高低字节都要用记得先把其中一个移到别的寄存器里不然 AAM 一执行AH 里的东西就没了。一个补充经验写 BCD 代码时把每一处调整指令都当成必须紧跟运算、中间不能插东西来处理养成习惯之后标志位被冲掉这类问题会少很多。6. 换个平台Go汇编与现代替代方案6.1 64位模式下这些指令为什么消失了很多人学完 8086 汇编转头去写 64 位程序会习惯性地想用 DAA结果发现根本编译不过甚至运行时报非法指令。这不是汇编器的问题而是这几条指令在 64 位模式下确实不被支持。DAA、DAS、AAA、AAS 的单字节操作码分别是 27H、2FH、37H、3FHAAM 和 AAD 是 D4 和 D5 带一个立即数这一片编码在 64 位模式下被判定为无效执行会触发异常。原因还是那个老问题用的人太少硬件厂商在精简指令集和编码空间时把它们砍掉了。这个变化对写代码的影响是现代平台上做十进制转换只能改用算法而不是指令。常见做法有两类一类是除法取余反复除以 10 拿各位数字另一类是查表加分组把数字按两位一组处理用预计算好的字符表直接映射。后一种效率高得多因为它把逐位除法换成了少量的大数除法和内存查表。6.2 用查表法和除十法做BCD转换Go 语言的汇编沿用 Plan 9 风格代码里写不了 DAA 这类指令做整数转十进制字符串时标准库用的就是前面说的查表加分组思路。它的做法是把数字按两位一组切开比如一个 uint64 先除以 100 拆成高低两段再对每一段继续处理个位和十位通过一张长度 100 的字符表直接查出来。这里两位一组的思路跟 BCD 的非压缩打包非常接近——都是用十进制分组来回避昂贵的逐位除法只是实现层面从硬件指令换成了软件算法。如果你需要在 Go 里手工做非压缩 BCD 到数值的转换可以照着 AAD 的逻辑写把每一位数字乘以 10 累加。反过来做数值到 BCD 的拆分就照着 AAM 的逻辑写不断取模 10 和整除 10。下面这段代码就是把 AAM 的逻辑用高级语言重写的样子// 把 0-99 的数值拆成两位 ASCII等价于 AAM 加上 3030H func toTwoDigits(n byte) (byte, byte) { hi : n / 10 lo : n % 10 return hi 0, lo 0 } // 把非压缩 BCD 转回数值等价于 AAD func fromBCD(hi, lo byte) byte { return hi*10 lo }写到这里你会发现汇编里那六条调整指令的精髓其实不是机器码本身而是十进制分组和差 6 补偿这两条思路。哪怕你现在完全用高级语言写业务代码碰上金额计算这类必须精确到分钱的场景理解 BCD 和定点十进制为什么比二进制浮点可靠依然能帮你少踩很多坑——比如 0.1 加 0.2 在二进制浮点里永远不精确等于 0.3而用十进制编码或整数分表示就完全没有这个烦恼。我在实际项目里处理金额时一直坚持用整数分存储本质上就是当年学 BCD 时留下的习惯。