1. 项目概述为什么要在PIC单片机上死磕双精度除法如果你用PIC单片机做过稍微复杂一点的数据处理比如传感器校准、PID控制或者简单的财务计算大概率会遇到一个头疼的问题除不尽。我说的不是数学意义上的除不尽而是单片机硬件能力上的“除不尽”——当你需要处理超过8位或16位的数值或者需要高精度的小数结果时PIC自带的除法指令就显得捉襟见肘了。标准8位或16位PIC的除法指令通常只能处理有限位宽的整数运算对于32位甚至64位的“双精度”数据或者需要保留多位小数的定点数运算硬件直接支持不了。这时候你就得自己动手用汇编语言“造”一个除法器出来。这听起来有点“石器时代”的感觉都什么年代了还在用汇编做数学运算但对于资源极度受限的PIC单片机来说这恰恰是最高效、最可靠的选择。用C语言库函数它们往往很臃肿占用大量宝贵的程序存储器和RAM执行速度也慢。自己用汇编实现一套双精度除法算法不仅能精确控制代码大小和速度更能让你透彻理解计算机进行除法运算的底层原理这是提升嵌入式功力的绝佳路径。我这次要详细拆解的就是如何在PIC单片机的汇编环境下实现一套高效、可靠的双精度除法算法。这里的“双精度”不一定特指IEEE 754标准的双精度浮点数在嵌入式领域它更常指“双字长”或更高精度的定点数运算比如32位数除以16位数或者64位数除以32位数。我们将从最基础的算法原理开始一步步推导最后落实到每一行PIC汇编代码上过程中遇到的坑和优化技巧我也会毫无保留地分享出来。2. 核心算法原理恢复余数法与不恢复余数法加减交替法在硬件没有除法器的前提下我们实现除法的思路其实和小学生列竖式做除法一模一样试商、乘减、移位。在计算机中最经典的有两种算法恢复余数法Restoring Division Algorithm和不恢复余数法Non-restoring Division Algorithm后者也常被称为加减交替法。2.1 恢复余数法最直观的“笨”办法恢复余数法的逻辑非常直接模拟我们手算的过程对齐将被除数和除数对齐。试减从被除数的高位部分减去除数。判断余数如果余数 0说明减法成功该位商为1。如果余数 0说明减法失败不够减该位商为0并且需要把除数加回去恢复原来的余数。移位将余数左移一位相当于乘以2并从被除数中并入下一位。重复重复步骤2-4直到处理完所有位。这个算法的优点是思路清晰容易理解。但缺点也很明显当余数为负时需要多做一次加法操作来恢复效率较低。在PIC这种指令简单的单片机上多一条指令就意味着多几个时钟周期。注意在汇编层面“判断余数是否小于0”通常通过检查减法操作后的进位/借位标志位C或DC标志位具体取决于PIC系列来实现。如果发生借位则说明结果为负。2.2 不恢复余数法加减交替法更聪明的选择不恢复余数法是对恢复余数法的优化。它观察到当余数为负时恢复操作加回除数后下一步的左移操作相当于将余数除数乘以2即2*(RD)。而如果我们不恢复直接对负余数R左移然后加上除数得到2*R D。可以证明2*(RD) 2*R 2*D与2*R D在下一步的试商判断上是等价的只是比较的基准变了。这就引出了不恢复余数法的规则初始化余数寄存器R初始为0或部分被除数。循环每一位将余数R和被除数联合左移一位移出的被除数最高位进入R的最低位。根据上一轮的余数符号即商决定本次操作如果上一轮余数 0本轮执行R R - D试减。如果上一轮余数 0本轮执行R R D试加。判断本次操作后的R符号确定当前位商如果 R 0当前位商为1。如果 R 0当前位商为0。 注意商的值由本次操作后的R符号决定而下次的操作由本次的R符号决定。最终处理循环结束后如果最后的余数R为负数需要加回除数D进行一次恢复得到真正的正余数。这个方法妙就妙在它消除了大部分情况下的“恢复”步骤把一次“恢复左移减”的操作合并成了“左移加”或“左移减”平均效率更高。在PIC汇编实现中我们将采用这种方法。2.3 从算法到寄存器规划假设我们要实现一个32位被除数除以16位除数得到16位商和16位余数的运算即双字除以字。我们需要规划以下寄存器或内存单元被除数32位通常需要两个16位寄存器例如DividendH: DividendL。除数16位一个16位寄存器例如Divisor。余数16位一个寄存器例如Remainder。在运算开始时它存放被除数的高16位。商16位一个寄存器例如Quotient。在运算过程中它也可以巧妙地与被除数的低16位共用存储空间通过左移操作逐位产生商。运算的硬件模型可以想象为一个余数-商联合移位寄存器。每次循环将余数和被除数低16位最终变为商联合左移一位空出的最低位用于存放新产生的商。这样经过16次循环后被除数的低16位就被商完全替换而余数寄存器中存放的就是最终的余数。3. PIC汇编实现详解以16位除数为例我们以PIC18系列单片机指令集相对丰富为例实现一个32位无符号数除以16位无符号数的汇编子程序。目标是输入32位被除数、16位除数输出16位商和16位余数。PIC16系列原理类似但指令可能需要更多条组合。3.1 寄存器与变量定义首先在汇编器或头文件中定义变量。我们使用MPLAB X IDE常见的汇编语法。; 双精度除法子程序 - Div32by16 ; 输入 ; Dividend3:Dividend2:Dividend1:Dividend0 - 32位被除数 (Dividend3为最高字节) ; DivisorH:DivisorL - 16位除数 ; 输出 ; QuotientH:QuotientL - 16位商 ; RemainderH:RemainderL - 16位余数 ; 使用寄存器WREG, STATUS, 以及一些临时变量 ; 算法不恢复余数法加减交替法循环16次 CBLOCK 0x00 ; 在Access Bank分配变量 Dividend0, Dividend1, Dividend2, Dividend3 ; 32位被除数运算后低16位被商替换 DivisorL, DivisorH ; 16位除数 QuotientL, QuotientH ; 商 (实际上与Dividend0:1共用) RemainderL, RemainderH ; 余数 (实际上与Dividend2:3共用) Counter ; 循环计数器 SignFlag ; 符号标志位用于记录上一轮余数符号 (0为正或零1为负) ENDC注意为了节省内存我们让Quotient与Dividend的低16位共用空间Remainder与Dividend的高16位共用空间。运算结束后Dividend0:1就是商Dividend2:3就是余数。3.2 汇编子程序主体框架子程序开始前先保存现场如果必要初始化余数和符号标志。Div32by16: ; 可选保存重要寄存器 ; ... ; 初始化将32位被除数复制到工作区 ; 假设被除数已存放在 Dividend3:Dividend0 ; 初始化余数为0实际上第一步会将高16位移入 MOVLW 0x00 MOVWF RemainderH MOVWF RemainderL MOVWF SignFlag ; 初始符号标志为正 ; 设置循环次数为1616位商 MOVLW D‘16’ MOVWF Counter Div_Loop: ; 步骤1将余数和被除数联合左移一位 ; 即将 (RemainderH:RemainderL : Dividend3:Dividend2:Dividend1:Dividend0) 整体左移 ; 商位从被除数最高位移入余数最低位被除数空出的最低位用于存放新商。 ; 更高效的做法是直接操作Dividend和Remainder寄存器组。 ; 我们先左移被除数低16位将来是商其最高位进位需要移入余数。 ; 再左移余数其最高位丢弃最低位来自被除数左移的进位。 ; 首先左移被除数低字(Dividend1:Dividend0)最高位进入C标志 RLNCF Dividend0, F ; 左移不移入CPIC18是RLCF (带进位循环左移) RLCF Dividend0, F ; 更正使用RLCF将最高位移入C RLCF Dividend1, F ; Dividend1左移移入来自Dividend0的进位 ; 然后左移被除数高字(Dividend3:Dividend2)同时其最低位Dividend2的LSB需要接收来自Dividend1的进位吗 ; 不我们之前的理解需要调整。标准的做法是 ; 将余数R和被除数商Q联合左移 (R, Q) 1 ; 在代码中我们可以将Dividend3:Dividend2视为RDividend1:Dividend0视为Q。 ; 所以第一步是左移整个64位R:Q不对我们只有32位被除数放在Q的位置R初始为0。 ; 更清晰的实现使用RLCF指令链将四个字节作为一个整体左移。 ; 重新规划使用RLCF指令链式移位 ; 假设内存布局Dividend3(MSB), Dividend2, Dividend1, Dividend0(LSB) ; 我们要实现 (R_H:R_L : Q_H:Q_L) 左移其中R初始为0Q是32位被除数。 ; 但为了节省内存我们只用Dividend2:3作为RDividend0:1作为Q。 ; 所以初始状态R0, Q被除数。 ; 左移操作C - Q_H.7, Q_H - (Q_H1)|C_from_Q_L, ... 最后 R_L.0 - C_from_Q_H.7 ; 这很复杂。一个更简单直观的实现是 ; 方法使用软件循环移位将4字节被除数左移一位其最高位Dividend3.7移入C。 ; 然后将余数2字节左移一位其最低位RemainderL.0置为刚才的C。 ; 同时被除数左移后空出的最低位Dividend0.0用于存储新产生的商。 ; 由于篇幅和清晰度这里展示一个经过优化的、更标准的16位循环体伪代码思路实际汇编需要仔细处理进位 ; 1. 清除进位标志C为左移做准备但第一次左移时我们希望移入0不左移应该移入0 BCF STATUS, C ; 左移时最低位移入0。 ; 2. 左移被除数低16位商记录其移出的最高位到C ; 但PIC的RLCF是带进位循环左移所以我们需要用C来传递位。 ; 更佳实践将余数R和商Q看作一个整体进行左移。 ; 初始化R0, Q被除数。 ; for i1 to 16: ; (R, Q) (R, Q) 1 ; 整体左移C获取R的最高位Q的最低位移入0不对应该是Q的最高位移入R的最低位。 ; 实际是R (R 1) | (Q 15) ; Q Q 1 ; 在8位机上这需要多字节移位操作。 ; 鉴于直接写多字节移位容易出错下面给出一个概念清晰的步骤实际代码需要据此展开 ; 步骤A将余数R左移一位其最低位用于存放来自被除数高位的进位初始为0。 ; 步骤B将被除数作为商Q左移一位其最高位进入C其最低位空出用于存新商。 ; 步骤C将步骤B中C的值原被除数最高位移入余数R的最低位。 ; 步骤D根据上一轮的SignFlag决定对R进行加除数还是减除数。 ; 步骤E根据本次加减后的R符号检查C标志设置当前商位0或1到Q的最低位。 ; 步骤F更新SignFlag为本次R的符号。 ; 具体汇编实现较为冗长以下是高度简化的逻辑框架和关键片段由于完整的、精确处理所有进位标志的32位左移和16位加减循环汇编代码非常长超过百行我将核心循环的一个迭代步骤用伪代码和关键指令说明Div_Loop: ; *** 关键步骤1整体左移 (R, Q) *** ; 将R和Q作为一个48位整体左移R16位Q32位。Q的最高位Dividend3.7移入C。 ; 实现先左移Q4字节记录其移出的最高位在C中。 BCF STATUS, C ; 首次左移Q时最低位移入0所以先清C RLCF Dividend0, F ; 左移最低字节C-bit0, bit7-C RLCF Dividend1, F RLCF Dividend2, F RLCF Dividend3, F ; 现在C中保存了原被除数的最高位 ; 再将R左移一位并将刚才C中的值原Q最高位移入R的最低位。 ; 实现利用RLCF指令将C移入R的最低位。 RLCF RemainderL, F ; C(来自Q最高位) - RemainderL.0, RemainderL.7 - C RLCF RemainderH, F ; 上一级的C - RemainderH.0, RemainderH.7 - C (这个C可以丢弃或用于检测溢出) ; *** 关键步骤2根据上一轮符号进行加减操作 *** BTFSS SignFlag, 0 ; 测试上一轮SignFlag (0为正1为负) GOTO Do_Subtraction ; 上一轮余数为正本轮试减 GOTO Do_Addition ; 上一轮余数为负本轮试加 Do_Subtraction: ; 执行 R R - D MOVF DivisorL, W SUBWF RemainderL, F ; RemainderL - DivisorL - RemainderL MOVF DivisorH, W SUBWFB RemainderH, F ; RemainderH - DivisorH - Borrow - RemainderH ; 减法后检查借位标志C。在PIC中SUBF和SUBWFB指令后若发生借位则C0否则C1。 ; 我们需要根据C判断R是否变为负数即发生借位。 BTFSC STATUS, C ; 如果C1无借位结果非负 GOTO Result_Non_Negative GOTO Result_Negative Do_Addition: ; 执行 R R D MOVF DivisorL, W ADDWF RemainderL, F MOVF DivisorH, W ADDWFC RemainderH, F ; 带进位加 ; 加法后检查结果符号。对于无符号数加法后不会产生“负”的概念但我们需要判断R是否除数不在不恢复余数法中加法后直接判断最高位符号位。 ; 实际上我们把R视为有符号数二进制补码。加法后通过检查最高位RemainderH.7来判断正负。 BTFSS RemainderH, 7 ; 如果最高位为0为非负 GOTO Result_Non_Negative GOTO Result_Negative ; 最高位为1为负 Result_Non_Negative: ; 当前余数R 0当前商位应为1 ; 将商位1放入Q即被除数的最低位。由于我们之前左移了Q其最低位现在是0。 ; 将最低位置1。 BSF Dividend0, 0 ; 将商的最低位当前是Dividend0的bit0设为1 MOVLW 0 MOVWF SignFlag ; 设置符号标志为正用于下一轮 GOTO Loop_End Result_Negative: ; 当前余数R 0当前商位应为0 ; Q的最低位已经是0左移时移入的所以无需操作。 MOVLW 1 MOVWF SignFlag ; 设置符号标志为负用于下一轮 ; 注意商位保持为0 Loop_End: DECFSZ Counter, F GOTO Div_Loop ; *** 循环结束后的处理 *** ; 循环结束后如果最终的SignFlag为负即最后一次试商后R为负需要恢复余数R R D BTFSS SignFlag, 0 GOTO Division_Done ; 执行恢复操作 MOVF DivisorL, W ADDWF RemainderL, F MOVF DivisorH, W ADDWFC RemainderH, F Division_Done: ; 此时商在Dividend1:Dividend0中余数在RemainderH:RemainderL中 ; 将结果复制到输出变量如果变量不共用 MOVFF Dividend0, QuotientL MOVFF Dividend1, QuotientH ; Remainder已经在对应变量中 RETURN重要提示以上代码是高度简化的逻辑框架特别是左移操作和符号判断部分。在实际编写中需要极其小心地处理进位标志C、数字进位标志DC和零标志Z因为PIC的减法指令SUBWF, SUBWFB对标志位的影响与通常的“借位”概念是反的C0表示借位发生。此外判断有符号数的正负不能简单看最高位因为在加减过程中可能产生溢出更稳健的做法是判断减法后的借位标志对于减法或结合溢出标志对于有符号数但PIC无直接溢出标志。一个通用的技巧是在执行减法R-D后如果C1则RD无借位结果非负如果C0则RD结果为负。这适用于无符号数比较恰好符合我们不恢复余数法中对“余数正负”的判断我们将R和D都视为无符号数但用有符号的眼光看待R-D的结果。3.3 关键难点与调试技巧进位/借位标志的陷阱这是PIC汇编除法最大的坑。PIC的SUBWF指令执行的是(W) - (F)结果存入F。当发生借位时C标志位被清零C0否则置1C1。这与x86或ARM架构的思维习惯相反。务必在代码中写清楚注释并用测试用例验证如计算 1 - 2检查C是否为0。多字节移位操作将余数R和商Q作为整体左移需要精细的指令排序。上面的框架使用了RLCF链但必须确保在移位R之前Q移出的最高位已经保存在C中并且这个C不会被后续操作意外改变。通常需要在关键操作前用BCF/BSF明确管理C标志。符号标志的初始化与更新在不恢复余数法中SignFlag的初始值应为0正因为第一次操作是“试减”。每次循环结束后必须根据本次加减操作的结果来更新SignFlag用于下一次循环的操作选择。除数为零的处理一个健壮的除法子程序必须检查除数是否为零。如果为零应设置错误标志或返回一个最大值避免程序跑飞。可以在子程序开头加入检查MOVF DivisorH, W IORWF DivisorL, W ; 将高8位和低8位或操作 BTFSC STATUS, Z ; 如果结果为0 GOTO Division_By_Zero ; 跳转到错误处理测试用例的设计编写测试代码覆盖边界情况常规情况0x12345678 / 0x9ABC ?商为0的情况0x00001234 / 0xFFFF ?商为最大值的情况0xFFFFFFFF / 0x1 0xFFFFFFFF余数为0的情况0x12340000 / 0x1234 0x10000大数除小数0x87654321 / 0x1111 ?除数为10x12345678 / 0x1 0x12345678被除数为00x00000000 / 0x1234 0使用MPLAB X的软件模拟器如MPLAB SIM单步跟踪观察每一步寄存器、内存和标志位的变化与手工计算核对是调试此类底层算法最有效的方法。4. 性能优化与空间权衡上面给出的实现是清晰但非最优的。在实际项目中我们往往需要在代码大小ROM占用和执行速度时钟周期之间做权衡。循环展开如果代码空间充足可以将16次循环部分展开例如每次迭代处理2位或4位减少循环控制开销DECFSZ和GOTO指令。PIC的跳转指令耗时相对较多。使用查表法对于固定除数如果除数是常数例如在ADC采样值转换中除以一个固定的标定系数可以预先计算好倒数用乘法代替除法。或者对于特定范围的除数可以制作一个小的商值查找表结合线性插值速度极快。利用PIC18的硬件乘法器一些PIC18型号带有8x8硬件乘法器。对于32/16除法可以将其转换为“先估算商的倒数再用乘法迭代逼近”的算法如牛顿-拉弗森方法虽然算法复杂但在需要极高速度且除数变化不频繁的场景下可能更快。汇编与C的混合编程对于时间要求不苛刻的部分可以用C语言编写让编译器生成代码。对于最核心、调用最频繁的除法循环再用汇编精心优化。在MPLAB XC8编译器中可以使用#asm/#endasm指令嵌入汇编代码。寄存器分配优化尽量使用Access BankBank0的寄存器减少BANKSEL指令。将最频繁访问的变量如Counter, SignFlag分配到通用寄存器中。5. 常见问题与排查实录在实际将算法转化为PIC汇编并调试的过程中我踩过不少坑这里记录几个最典型的问题1除法结果完全错误商和余数都是随机值。排查首先检查除数是否为零。然后单步执行在第一次进入加减操作Do_Subtraction或Do_Addition前检查Remainder和Divisor的值是否正确以及SignFlag的初始值是否为0。最常见的原因是左移操作错误导致被除数和余数的位没有正确对齐。一个有效的调试方法是在循环开始和每次左移后将RemainderH:RemainderL和Dividend3:Dividend0的值通过串口打印出来如果条件允许或者写入特定的调试内存区域与手工计算的中间状态对比。问题2商正确但余数不对。排查几乎可以确定是循环结束后的恢复步骤有问题。检查SignFlag在循环结束后的值。如果最后一次试商后余数为负SignFlag1必须执行R R D。确认恢复加法执行了并且没有发生溢出对于无符号数加法溢出意味着余数大于除数这理论上不应该发生除非算法有误。另外检查在Do_Addition路径中符号判断逻辑是否正确是判断加法后的最高位还是判断加法是否产生进位对于无符号数加法结果永远不会“负”所以不恢复余数法中的“加操作后判断正负”实际上需要 reinterpret 为有符号数。更可靠的方法是统一用减法后的借位标志来判断而“加操作”路径是当上一轮为负时才进入的本轮加完后直接根据结果设置下一轮的符号逻辑上需要一致。问题3对于某些特定的被除数和除数组合结果偏差1。排查这是舍入误差或试商逻辑边界条件问题。在不恢复余数法中当余数恰好等于除数时理论上商位应该为1因为够减。检查你的“结果非负”判断条件是否是if R D而不是if R D。在汇编中这体现在减法指令后的借位标志判断上。确保你的条件分支BTFSC STATUS, C或BTFSS逻辑与“无借位即R D”严格对应。问题4代码在模拟器上运行正确但烧录到实物单片机后偶尔出错。排查中断干扰如果除法子程序执行时间较长且系统开启了中断可能在除法过程中被中断打断破坏了寄存器或标志位状态。解决方法在除法子程序的关键部分整个循环关闭全局中断BCF INTCON, GIE执行完毕后再开启。或者确保除法子程序使用的寄存器不会被中断服务程序破坏。看门狗定时器溢出如果看门狗定时器WDT使能且除法循环时间超过了看门狗超时周期会导致单片机复位。解决方法在长循环中加入CLRWDT指令喂狗或者在不要求高可靠性的场合禁用WDT。存储器访问冲突如果你使用的变量位于非Access Bank而代码中缺少必要的BANKSEL指令在实物上可能会访问到错误的存储区。确保所有对变量的访问都发生在正确的存储体Bank中。问题5如何扩展为有符号数除法思路先记录被除数和除数的符号将它们都转换为正数绝对值调用无符号除法子程序。然后根据“同号得正异号得负”的规则确定商的符号。余数的符号通常与被除数相同。在汇编实现中符号判断可以通过检查最高位对于16位检查DivisorH.7和Dividend3.7来完成转换正数可以用取补码对于负数执行COMF加INCF实现。这会使代码量几乎翻倍但逻辑是清晰的。6. 总结与进阶思考在PIC单片机上实现双精度除法就像在有限的土地上建造一座坚固的房子。汇编语言是你的砖瓦算法是你的蓝图。通过这个项目你收获的不仅仅是一个可用的除法函数更是对计算机算术逻辑单元ALU如何工作、编译器如何优化低级运算的深刻理解。对于更高级的应用你可以考虑扩展精度将上述32/16算法扩展到64/32甚至更高精度。原理完全一样只是寄存器更多循环次数更多移位和加减操作需要扩展到更多字节。定点数运算上述算法得到的是整数商和余数。如果你需要小数结果定点数可以在调用除法前将被除数左移N位相当于乘以2^N再除以除数得到的结果就是带有N位小数的定点数商。浮点数支持虽然PIC不适合做完整的IEEE 754浮点运算但可以实现简化版的浮点格式例如1位符号、8位指数、23位尾数。浮点除法的核心是尾数的除法可以用本文的定点除法和指数的减法再加上规格化、舍入等处理这是一个更大的挑战但也更能锻炼你的系统编程能力。最后我个人的体会是在嵌入式开发中“够用就好”是黄金法则。如果你的应用只是偶尔做一次除法且对时间不敏感直接用编译器提供的运行时库可能是最经济的选择。但当你需要榨干单片机的每一滴性能或者身处一个对代码体积和确定性要求极高的环境比如汽车电子、航空航天时自己动手优化核心算法这种掌控感是无与伦比的。这份汇编代码就是你对机器最直接的对话。