1. 为什么乘除法指令值得单独拎出来讲
很多人学汇编语言,加减法指令看一眼就过了,觉得逻辑简单、没什么好说的。但一到乘法和除法,问题就集中爆发了:为什么结果要分两个寄存器放?为什么有符号和无符号用的是完全不同的指令?除法的结果为什么有时候会直接让程序崩掉?这些问题在教材里往往一笔带过,但实际写代码的时候,每一个都能卡住你半天。
我自己刚开始接触汇编的时候,写了一个简单的整数运算程序,加法减法一路顺畅,到了乘法就发现结果对不上——明明两个不大的数相乘,取结果的时候只读了一个寄存器,高位全丢了。后来又碰到除法,程序跑着跑着直接触发异常终止,排查了半天才发现是除数为零没有提前判断。这些坑说到底,都是因为对乘除法指令的行为细节理解不到位。
这篇文章就是围绕汇编语言中的乘法和除法指令来展开的。我会从指令的基本行为讲起,把无符号和有符号的差异、操作数的组合方式、结果寄存器的分配逻辑、溢出和异常的触发条件,以及实际编码中怎么安全地使用这些指令,全部拆开来讲清楚。不管你是正在学汇编的学生,还是做底层开发需要偶尔手写汇编的工程师,这些内容都能直接用到。
文章会以 x86 架构下的经典指令集为主要参考,因为这是绝大多数汇编课程和实际项目中最常见的。同时我也会提到一些通用的设计思路,帮助你在其他架构上快速迁移理解。
2. 乘法指令的行为逻辑与寄存器分配
2.1 无符号乘法 MUL 的完整行为
x86 里无符号乘法用的是MUL指令。它的操作数只有一个,看起来很简单,但背后的行为需要仔细说。
MUL指令的操作数可以是寄存器或者内存单元,但不能是立即数。这一点和加法指令不同,很多人第一次写的时候会习惯性地写MUL AX, 5这种形式,结果汇编器直接报错。原因在于乘法指令的设计逻辑:它需要根据操作数的位宽来决定被乘数从哪里取、结果放到哪里。
具体规则是这样的:
| 操作数位宽 | 被乘数来源 | 结果低位 | 结果高位 |
|---|---|---|---|
| 8 位 | AL | AX | AH |
| 16 位 | AX | AX | DX |
| 32 位 | EAX | EAX | EDX |
| 64 位 | RAX | RAX | RDX |
这张表是理解乘法指令的钥匙。以 16 位乘法为例,当你执行MUL BX的时候,CPU 实际做的是把AX的值和BX的值相乘,得到一个 32 位的结果,低 16 位放在AX,高 16 位放在DX。
为什么要这样设计?因为两个 16 位数相乘,结果最大可以到 32 位。如果只用一个 16 位寄存器存结果,溢出就是必然的。CPU 设计者选择用两个寄存器拼成一个 32 位的结果空间,保证在操作数位宽范围内,乘法结果永远不会丢失。
这里有一个很关键的实操细节:结果的高位部分并不是没用的。如果你确定两个数相乘不会超过 16 位,那DX里就是 0,可以忽略。但如果你不能确定,就必须把DX:AX作为一个整体来处理。我见过不少人在做多字节乘法的时候只取了AX,结果高位数据丢失,算出来的数完全不对。
还有一个容易忽略的点:MUL指令执行后,CF 和 OF 标志位的含义。如果结果的高半部分不为零,CF 和 OF 都会被置 1,表示结果超出了低半部分的表示范围。其他标志位的状态在乘法之后是未定义的,不要依赖它们做判断。
2.2 有符号乘法 IMUL 的三种形态
有符号乘法用的是IMUL指令。它比MUL复杂得多,因为有不同的操作数形式。
第一种形态:单操作数。和MUL的用法完全对称,被乘数隐式地从 AL/AX/EAX/RAX 中取,结果也放在同样的位置。区别在于IMUL会把操作数当作有符号数来处理,结果的符号位扩展方式不同。
第二种形态:双操作数。格式是IMUL 目标, 源,意思是目标 = 目标 × 源。这种形式下结果被截断到目标寄存器的位宽,高位直接丢弃。CF 和 OF 会指示结果是否发生了有意义的溢出——如果高位截断掉的部分不是符号位的正确扩展,就置 1。
第三种形态:三操作数。格式是IMUL 目标, 源, 立即数,意思是目标 = 源 × 立即数。立即数可以是 8 位、16 位或 32 位,源可以是寄存器或内存。这种形式在计算数组偏移量的时候特别好用,比如IMUL EAX, EBX, 4就相当于把 EBX 乘以 4 放到 EAX。
三种形态的选择逻辑其实很清晰:如果你需要完整的双倍位宽结果,用单操作数形式;如果你确定结果不会溢出,用双操作数或三操作数形式更方便,因为不用去管 DX/EDX 那些寄存器。
2.3 乘法指令对标志位的影响
标志位这块值得单独说一下,因为它是很多 bug 的隐蔽来源。
对于MUL:CF 和 OF 表示结果高半部分是否非零。如果高半部分非零,两者都置 1;否则都清 0。SF、ZF、AF、PF 在乘法后是未定义的。
对于IMUL:CF 和 OF 表示结果是否被截断到目标位宽后丢失了有效信息。具体来说,如果结果的高半部分不是低半部分符号位的正确扩展,CF 和 OF 置 1。这个判断逻辑比MUL稍微绕一点,但本质是一样的——都是在告诉你"结果可能放不下"。
实际编码中,如果你用的是双操作数或三操作数的IMUL,而且确定不会溢出,那标志位可以不管。但如果你需要做溢出检测,就必须在乘法之后立刻检查 CF 或 OF,因为后续任何指令都可能改变它们。
3. 除法指令的陷阱与异常处理
3.1 DIV 指令的隐式操作数规则
无符号除法用DIV指令,操作数同样只能是寄存器或内存。它的行为和乘法是对称的,但方向反过来。
| 操作数位宽 | 被除数来源 | 商 | 余数 |
|---|---|---|---|
| 8 位 | AX | AL | AH |
| 16 位 | DX:AX | AX | DX |
| 32 位 | EDX:EAX | EAX | EDX |
| 64 位 | RDX:RAX | RAX | RDX |
以 16 位除法为例,执行DIV BX的时候,CPU 把DX:AX拼成的 32 位数除以BX,商放在AX,余数放在DX。
这里有一个非常关键的准备工作:在做除法之前,必须正确设置被除数的高半部分。如果你只是想做两个 16 位数的除法,被除数放在AX里,那在DIV之前必须把DX清零。否则DX里残留的垃圾数据会让被除数变成一个巨大的数,除出来的商很可能超出AX的表示范围,直接触发除法异常。
我当初踩这个坑的时候,程序直接崩了,没有任何有用的错误信息。后来用调试器一步步跟,才发现是DX没有清零。这个细节教材上通常会提,但真正写代码的时候特别容易忘。
3.2 除法溢出的触发条件与后果
除法指令有一个在加减乘里都不存在的特性:它可能因为商太大而触发异常。
具体来说,当商超出了目标寄存器的表示范围时,CPU 会产生一个除法错误异常(在 x86 上通常表现为中断 0)。这个异常如果不处理,操作系统会直接终止你的程序。
举个例子:16 位除法中,被除数是DX:AX,除数是BX。如果DX的值大于等于BX,那么商一定大于 0xFFFF,放不进AX,就会触发异常。这不是"结果不准确"的问题,而是程序直接崩溃。
所以在做除法之前,必须做两件事:
- 确保除数不为零。除数为零同样会触发除法异常。
- 确保商不会溢出。对于无符号除法,检查被除数的高半部分是否小于除数。对于有符号除法,检查条件稍微复杂一些,但核心思路是一样的。
提示:在实际编程中,如果除数是常量且确定不为零,可以省去除零检查。但如果除数是变量或来自外部输入,除零检查绝对不能省。
3.3 IDIV 有符号除法的符号扩展问题
有符号除法用IDIV,它的操作数规则和DIV完全一样,但多了一个符号扩展的要求。
对于无符号除法,被除数的高半部分清零就行。但对于有符号除法,被除数的高半部分必须是被除数低半部分的符号扩展。也就是说,如果被除数是负数,高半部分要全部填 1;如果是正数,高半部分要全部填 0。
x86 提供了几条专门的符号扩展指令来配合IDIV使用:
CBW:把 AL 的符号位扩展到 AH,形成 AX 中的 16 位有符号数。CWD:把 AX 的符号位扩展到 DX,形成 DX:AX 中的 32 位有符号数。CDQ:把 EAX 的符号位扩展到 EDX,形成 EDX:EAX 中的 64 位有符号数。CQO:把 RAX 的符号位扩展到 RDX,形成 RDX:RAX 中的 128 位有符号数。
这四条指令的命名规律是:第一个字母表示目标(C 代表 convert),后面的字母表示源和目标寄存器的组合。实际使用的时候,在做IDIV之前先执行对应的符号扩展指令,就能保证被除数的格式正确。
如果你忘了做符号扩展,比如被除数是负数但DX里是 0,那IDIV会把它当成一个很大的正数来处理,结果完全错误,而且很可能触发除法异常。
4. 从编码实战看乘除法的典型用法
4.1 多字节乘法的进位处理
单字节和单字乘法相对简单,但当你需要做多字节乘法的时候,进位处理就变成了核心问题。
假设你要计算两个 32 位数的乘积,结果需要 64 位。在 32 位 x86 上,你可以直接用MUL指令,结果自然放在EDX:EAX里。但如果你的操作数超过了 32 位,比如两个 64 位数相乘,就需要手动拆分计算。
拆分乘法的思路和竖式乘法是一样的:把每个操作数拆成高低两部分,分别相乘,然后按权重相加。具体来说,对于A = A_hi × 2^32 + A_lo和B = B_hi × 2^32 + B_lo,乘积可以展开为四项:
A_lo × B_lo:权重 2^0A_lo × B_hi:权重 2^32A_hi × B_lo:权重 2^32A_hi × B_hi:权重 2^64
每一项用 32 位乘法算出 64 位结果,然后按权重累加。累加的过程中要注意进位不能丢,特别是中间两项相加的时候可能产生向高位的进位。
这种手动拆分乘法的代码写起来比较繁琐,但逻辑是清晰的。实际项目中如果频繁需要大数乘法,通常会封装成函数或者直接用编译器生成。
4.2 除法在进制转换中的实际应用
除法指令最经典的应用场景之一就是进制转换。把一个整数转成十进制字符串输出,本质上就是不断除以 10 取余数。
以 16 位无符号数转十进制为例,基本流程是:
- 把要转换的数放在
AX,DX清零。 - 用
DIV除以 10。 - 商在
AX,余数在DX,把余数保存起来(它对应十进制的一位)。 - 如果商不为零,回到第 2 步继续。
- 把保存的余数逆序输出,就是十进制字符串。
这个流程看起来简单,但有一个容易忽略的细节:每次除法之前都要重新把DX清零。因为上一次除法的余数就放在DX里,如果不清理,下一次除法的被除数就错了。
我在写这个转换程序的时候,第一次就犯了这个错误——只在循环外面清了一次DX,循环里面没有清。结果前几位数字是对的,后面就开始出现莫名其妙的字符。排查的时候盯着代码看了很久,最后才意识到是DX的残留值在捣乱。
4.3 用移位替代乘除法的优化思路
在实际编码中,有一类优化非常常见:用移位指令替代乘除法。
原理很简单:左移一位相当于乘以 2,右移一位相当于除以 2。所以乘以 4 可以写成左移 2 位,除以 8 可以写成右移 3 位。移位指令的执行速度通常比乘除法快很多,在对性能敏感的场景下,这种替换能带来明显的提升。
但这里有几个坑需要注意:
第一,移位只适用于 2 的幂次。乘以 5 不能简单地用移位实现,需要拆成x × 4 + x或者x × 8 - x × 3之类的组合。拆分的方案不同,指令数和执行效率也不同。
第二,有符号数的右移和除法不等价。对于负数,算术右移是向下取整,而IDIV是向零取整。比如 -7 除以 2,IDIV得到 -3,而算术右移得到 -4。如果你用移位替代除法,必须确认这种取整差异不会影响你的业务逻辑。
第三,移位不会设置溢出标志。乘法指令会通过 CF/OF 告诉你结果是否溢出,但移位指令只是简单地丢弃移出的位。如果你依赖溢出检测来做边界判断,替换成移位之后这套机制就失效了。
注意:编译器在做优化的时候,会自动把常量乘法替换成移位和加法的组合。但如果你手写汇编,这个优化需要自己判断是否值得做。代码可读性和执行效率之间的权衡,要根据具体场景来定。
5. 那些只有踩过才知道的细节
5.1 操作数位宽不匹配的隐蔽错误
汇编器对操作数位宽的要求非常严格,但有些错误在汇编阶段不会报错,运行时才暴露出来。
比如MUL指令,如果你写MUL BL,那被乘数是AL,结果是AX。如果你写MUL BX,被乘数是AX,结果是DX:AX。这两种写法在汇编器看来都是合法的,但行为完全不同。如果你本来想用 8 位乘法,结果不小心写了 16 位的操作数,那被乘数就从AL变成了AX,结果也会写到DX:AX而不是AX。程序可能不会崩溃,但算出来的数完全不对。
避免这类错误的方法很简单:在写乘除法指令之前,先明确操作数的位宽,然后检查被乘数/被除数的来源和结果的去向是否匹配。这个检查只需要几秒钟,但能省下大量调试时间。
5.2 除法异常的实际排查过程
除法异常是汇编编程中最常见的运行时错误之一。它的表现通常是程序突然终止,或者进入一个异常处理流程。如果没有调试器,你只能看到程序崩了,完全不知道哪里出了问题。
我的排查流程一般是这样的:
第一步,确认异常类型。如果是除法错误,那问题一定出在DIV或IDIV指令上。在调试器里查看异常发生时的指令指针,就能定位到具体是哪一条除法指令。
第二步,检查除数。查看除数寄存器的值是否为零。如果是零,那问题很明确——需要加除零判断。
第三步,检查被除数的高半部分。如果除数不为零,那问题很可能出在商溢出。查看被除数的高半部分(DX或EDX)是否满足不溢出的条件。对于无符号除法,高半部分必须小于除数;对于有符号除法,高半部分必须是低半部分的符号扩展。
第四步,回溯被除数的设置过程。如果高半部分的值不对,往前找是哪条指令设置了它。常见的原因包括:忘了清零、符号扩展指令用错了、前面的运算留下了残留值。
这套流程看起来简单,但在实际调试中非常有效。关键是要养成习惯:每次写除法指令的时候,都下意识地检查除数和被除数的状态。
5.3 乘除法对程序性能的实际影响
在早期的 x86 处理器上,乘法和除法的执行周期远远多于加减法和移位。虽然现代处理器已经大幅优化了这些指令,但在某些场景下,乘除法仍然是性能瓶颈。
根据一般的经验数据,32 位整数乘法的延迟大约在 3 到 5 个时钟周期,除法则可能达到 20 到 30 个周期甚至更多。相比之下,加法和移位通常只需要 1 个周期。
这意味着在紧密循环中,如果能把除法替换成移位或者乘法,性能提升会非常明显。比如一个循环里每次迭代都要除以一个常量,把这个除法提到循环外面或者用乘法逆元替代,可能让循环速度提升好几倍。
当然,这种优化不是无条件的。如果除数是变量,或者代码本身不是性能瓶颈,那强行优化反而会增加代码复杂度和出错风险。我的原则是:先保证正确性,再考虑性能。只有在 profiling 确认除法是瓶颈之后,才去做替换优化。
5.4 不同架构下乘除法指令的差异
虽然这篇文章以 x86 为主,但了解一下其他架构的设计思路,对理解乘除法的本质很有帮助。
有些 RISC 架构(比如 MIPS)的乘法指令会把结果放在两个独立的专用寄存器HI和LO里,而不是复用通用寄存器。除法也是类似,商和余数分别放在LO和HI。这种设计的好处是不会占用通用寄存器,但取结果的时候需要用专门的指令(比如MFHI和MFLO)把值搬到通用寄存器里。
ARM 架构的乘法指令通常只保留结果的低半部分,如果需要完整结果,要用特殊的指令变体。除法在早期的 ARM 版本中甚至不是硬件指令,需要软件模拟。
这些差异说明了一个道理:乘除法的硬件实现方式取决于架构的设计取舍。理解了 x86 的设计逻辑,再去看其他架构,就能快速抓住核心差异,而不需要从头学起。
6. 写乘除法代码时我会遵守的几条规则
经过前面这些分析,我把实际编码中总结出来的规则整理一下,这些是我自己在写汇编时一直遵守的:
规则一:写乘法之前先问自己,结果会不会超过目标寄存器的位宽。如果会,用单操作数形式取完整结果;如果不会,用双操作数或三操作数形式更方便。
规则二:写除法之前先做两个检查——除数是否为零,商是否会溢出。这两个检查缺一不可。除零检查相对简单,商溢出检查需要根据操作数位宽来判断。
规则三:有符号除法之前,别忘了符号扩展。CBW、CWD、CDQ、CQO这四条指令要记牢,用的时候不能搞混。
规则四:循环里的除法要特别小心。每次迭代都要重新设置被除数的高半部分,不能只在循环外面设置一次。
规则五:能用移位就用移位,但要注意取整差异。对于 2 的幂次的乘除法,移位是更好的选择。但负数右移和除法不等价,这一点必须记住。
规则六:标志位在乘除法之后的状态要清楚。CF 和 OF 有明确的含义,其他标志位是未定义的。不要依赖未定义的标志位做判断。
这些规则看起来都是小细节,但每一条背后都有实际的踩坑经历。汇编编程就是这样,细节决定成败。一个寄存器没清零,一个符号位没扩展,程序就可能完全跑飞。把这些问题在编码阶段就规避掉,比事后调试要高效得多。
最后说一个我自己的习惯:在写任何乘除法指令的时候,我都会在注释里标明操作数的位宽、被乘数/被除数的来源、结果的去向,以及是否做了溢出检查。这个习惯看起来有点啰嗦,但在复查代码或者过一段时间再回来看的时候,这些注释能帮我快速回忆起当时的思路,避免因为遗忘细节而引入新的 bug。