原码、反码、补码与IEEE 754浮点数:从机器表示到Verilog串口发送
N年前我第一次在调试器里看到“-2”被显示成FFFFFFFE说实话当场懵了我明明写的是负二怎么读出来是一个八位的大正数后来我翻书才知道这压根不是数据坏了而是机器根本没按十进制那套思路来存数字。补码、反码、浮点数这些计算机组成原理里的基础概念平时看着像理论课真到自己写驱动、做串口协议、调FPGA的时候每一处都在踩它的坑。这篇内容我打算把整数和浮点数的机器表示一次性讲透原码、反码、补码到底解决了什么问题IEEE 754浮点数又是怎么把科学计数法塞进32位或者64位里的最后再用一个Verilog串口发送整数、浮点数的实操例子演示这些编码在硬件里到底是怎么流动的。适合刚接触计算机组成原理的人也适合被0.10.2 ! 0.3折磨过、想搞清楚底层原因的开发者。1. 从二进制到机器表示先把地基打牢1.1 为什么计算机是非要用二进制不可机器表示的核心其实只有一句话计算机里所有的数据落到寄存器、内存、总线、磁盘上统统都是二进制的0和1。为什么不用十进制最直接的原因是物理实现太方便了。一个晶体管的导通和截止、一个电平的高和低、一个电压的有和无天生就是两种状态。你非要造出一种能稳定表示十种状态的器件成本和可靠性都扛不住尤其电路噪声一多高和低都分不清十种状态基本就是灾难。二进制的另一个好处是逻辑运算和算术运算可以完美对齐。与、或、非这些布尔逻辑本质就是0和1之间的运算CPU里的加法器、比较器、移位器全部建立在二进制逻辑门之上。如果把十进制搬进去光是借位、进位规则就够硬件设计师喝一壶的。这里有个很基础的术语理解之后后面都顺了一个二进制位叫bit8个bit组成一个byteCPU一次能处理的bit数叫字长。32位系统就是寄存器宽度32bit64位就是64bit。而我们常说的“数据的机器表示”本质上就是在有限的bit宽度里约定一套规则把现实世界的正数、负数、小数、字符、地址统统编码成0和1的组合。宽度是天花板规则决定你在这块天花板下能表达什么。1.2 无符号整数好办有符号整数才是起跑线如果只表示非负整数机器表示非常简单直接按二进制写就行。比如8位无符号数00000001就是111111111就是255。这种编码叫无符号整数对应硬件里的unsignedC语言里的uint8_t、uint32_t。但现实世界有负数。于是计算机工程师面临一个最原始的问题有没有一种编码让正数、负数都能直接用二进制表示而且加减法还能共用一套硬件最朴素的直觉是在最高位加一个符号位0表示正1表示负剩下的位照常表示绝对值。这就是原码。但这条路走到一半就会发现符号位和数值位没法一起参与运算加法器根本不知道在算的是正数还是负数还得先判断符号再决定是加还是减硬件复杂度直接失控。所以真正的起点不是原码而是“怎么让符号位也自然地参与二进制加法”这正是补码登场的根本原因。不过先别急我们得把三兄弟一个个看过来才知道补码到底好在哪。2. 原码、反码、补码整数机器表示的“三兄弟”2.1 原码最直观但也最不好用以8位为例原码的规则是最高位是符号位0正1负剩余7位是绝对值。于是100000001-110000001500000101-510000101看是很好看了但一运算就出问题。比如你想算(-1) 1按原码直接把两个数丢进加法器10000001 00000001 ---------- 10000010结果是10000010按原码解读是-2。这显然不对。原码之所以不能直接用二进制加法是因为符号位出现在了最高位但低七位的“绝对值”又没有按二进制补数的逻辑组织整个编码在加法器眼里是错乱的。原码还有一个很丑的问题0是00000000-0是10000000。一个0有两种表示不但浪费编码空间你拿if (a b)判断相等还得特判。所有做符号运算的硬件看到这个都会头大。所以原码基本上只适合人眼阅读不适合机器运算。它存在的最大价值是让我们理解的符号位是怎么牺牲掉一个位的有效数据范围的8位原码能表示的范围是-127到127不是-128到127。2.2 反码从原码到补码的过渡产物反码的规则是正数和原码相同负数是“符号位不变其余位按位取反”。继续用-1举例1原码00000001-1原码10000001-1反码11111110反码确实比原码进了一步因为它把二进制取反和数值的相反数联系起来了两个数相加的时候大部分对位上已经能看出一点规律。但反码还是有毛病。它依然存在0和-00 00000000 -0 11111111而且反码做加法有著名的“循环进位”问题。比如-1的反码11111110加上1的反码00000001结果是11111111也就是-0虽然结果是0但还得把符号位判一次。用反码实现加减法电路要做额外的“回带进位”修正不别扭但不够干净。反码更像一块跳板。它的取反规则保留下来了被补码继承了过去但它自己的“零表示不唯一”和“回带修正”两个毛病注定了它只能活在教科书里。2.3 补码让减法在硬件里消失的魔法补码才是现代CPU真正采用的方案。它的规则一句话正数的补码和原码相同负数的补码等于“原码取反后加1”也就是反码加1。1补码00000001-1补码11111111-5补码11111011补码最巧妙的地方在于它把减法统一成了加法。因为a - b永远可以写成a (-b)而-b在补码里是一个和b加起来正好等于2^n模的数。这就是“模运算”的思想。用一个生活化类比钟表只有12个小时盘面上12其实就是0。如果现在是3点你想拨到1点可以顺时针走10个小时也可以逆时针走2个小时。3点往前走10格和往后走2格结果是一样的都是1点。在这里“-2”和“10”在模12下是等价的。我们的8位二进制加法器就是一个模256的钟所有运算自动丢掉超过255的进位。于是-1不再被编码成10000001而是编码成255因为1 255 256 0。你只是把255写成补码11111111加法器自然就能算出正确结果。检验一下前面的例子(-1) 1 11111111 00000001 ---------- 1 00000000最高位进位被8位寄存器丢掉留下00000000这就是0完全正确。再看5 - 3直接变成5 (-3)-3补码是1111110100000101 11111101 ---------- 1 00000010丢掉进位得到00000010也就是2和直觉一致。补码的厉害之处就在这里硬件只需要一个加法器减法、正负混合运算全都能算不用再单独造一堆判断逻辑。补码还顺带消灭了-0的问题。仔细看8位补码里0只有00000000而10000000这个状态空出来了于是它被规定为-128。补码的表示范围因此变成-128到127比原码和反码多出一个最小值。这在硬件上极其舒服范围边界对齐的是2的幂判断溢出也方便。这里有一个所有开发者都必须记住的规则补码扩展位宽时正数补0负数补1。比如-1从8位11111111扩展成16位是11111111 11111111直接在高位补符号位数值大小保持不变。你要是看到一个int8_t的负数和int16_t的负数拼接却没做符号扩展十有八九会得到一个大正数这就是开头那个FFFFFFFE问题的一半原因。我整理了一张8位数据常用的对照表建议收藏十进制原码反码补码0000000000000000000000000-01000000011111111不可用1000000010000000100000001-1100000011111111011111111127011111110111111101111111-127111111111000000010000001-128不可用不可用1000000032位整数就更贴近日常int32_t的范围是-2147483648到2147483647最小值0x80000000最大值0x7FFFFFFF。我调试串口解析数据时看到0xFFFFFFFF第一反应就是-1看到0x80000000就是那个最小的负数出错的概率瞬间低不少。2.4 补码运算的溢出判断既然补码直接把加法器包揽到底溢出就成了绕不开的话题。在硬件里进位是指“最高位产生了一个向外的进位”比如127101111111 00000001 ---------- 10000000没有向外进位但结果10000000是-128显然错了这叫溢出。溢出本质是运算结果超过了当前位宽能表示的范围而不是看有没有进位。判断补码加法溢出要记住一条口诀只有两个同号数相加结果异号才是溢出正加正得到负或者负加负得到正必溢出。正负相加结果绝对值不会超过原来两个数的范围边界永远不会溢出。在Verilog里如果你用行为级加法溢出就是最高位和次高位进位不一致。99%的初学者把进位标志当成溢出标志这里先排掉一个雷。3. 浮点数把科学计数法塞进32位和64位3.1 定点数为什么不够用整数搞定了小数怎么办最原始的想法是“定点数”约定一个位宽前面放整数部分后面放小数部分小数点位置固定。比如8位里高4位是整数、低4位是小数那0010.1000就是2.5。定点数在嵌入式底层控制里很常见因为速度快、无舍入歧义。但它的范围是硬伤。位宽就那么多整数占多了小数精度差小数占多了整数范围窄。你想表示595603.124567这种数字定点数会非常痛苦要么爆整数位要么小数部分全丢。这时候科学计数法给了思路任意一个数都可以写成尾数 × 2^指数。不需要固定小数点的位置小数点跟着指数变化这就是浮点数的核心思路——用一小部分位去记录指数让小数点在很大范围内“浮”起来。3.2 IEEE 754符号位、指数位、尾数位的排布现在全世界的计算机几乎都在用IEEE 754标准定义浮点数。为了在固定位宽里既能表示大数又能表示小数标准把32位单精度浮点数拆成三块区域单精度位数作用符号位S10正1负指数位E8偏移后的指数尾数位M23规格化后的数字有效位双精度浮点数则用64位1位符号11位指数52位尾数。IEEE 754规定一个规格化浮点数的真实形式是(-1)^S × 1.M × 2^(E - 偏移量)这里的关键是隐藏位因为是规格化表示尾数的整数部分永远是1于是这个1不需要存直接省下来所以23位尾数实际能存24位精度。这个细节是很多人的盲点。你看到一个单精度尾数全为0不能简单理解成数值是0还要看指数位。指数需要能表示负数。标准没有用补码而是用一个偏移量单精度偏移127双精度偏移1023。实际存进指数位的值是真实指数 偏移量。全0和全1两个指数状态被单独拿出来定义特殊值指数位全0表示0或者非规格化数尾数隐藏位当作0用来表示极小的数指数位全1表示无穷大或NaN。什么叫做“非规格化数”规格化数的尾数范围是[1, 2)最小规格化正数就是1.0 × 2^-126大概1.18e-38。比它还小的数只能用指数全0、尾数不为0的非规格化数来表示尾数隐藏位变成0数字可以一路小到1.4e-45。硬件在做这类数的时候速度会明显变慢嵌入式算力紧张时这是真实存在的坑。3.3 手把手转换85.25 转成单精度把十进制小数转成IEEE 754真正常用的场景是调试串口或者写通信协议。我拿85.25走一遍完整流程看完你就能自己手算不用再被在线转换器牵着走。第一步整数和小数分别转二进制。85转二进制是10101010.25转二进制是乘2取整0.25 × 2 0.5取00.5 × 2 1.0取1所以是.01。合起来85.25 1010101.01。第二步规格化把小数点移到第一个1后面1010101.01 1.01010101 × 2^6。真实指数是6。第三步算尾数。去掉隐藏的1只保留01010101后面补0凑满23位01010101000000000000000。第四步指数位存的是6 127 133转二进制是10000101。第五步符号位是0。组合0 10000101 01010101000000000000000分组成十六进制就是0x42AA8000。也就是说你在串口助手或者在线转换器里看到42 AA 80 00它代表的就是85.25前提是字节序正常。反向操作也完全对称把一个十六进制浮点数0x3DCCCCCD拆开符号位0指数位01111011十进制123真实指数是123 - 127 -4尾数10011001100110011001101还原成二进制小数就是约1.10011001100110011001101 × 2^-4算出来是≈0.10000000149。这已经揭穿了“在线转换器算错”的错觉它没算错它只是把单精度能表达的那个数如实展示出来而这个数和十进制0.1本来就不是完全相等的。3.4 双精度只是把位数翻倍逻辑不变双精度浮点数的转换流程和单精度完全一样只是指数偏移量变成1023尾数变成52位。比如0.1双精度的十六进制位模式是0x3FB999999999999A换算出来约是0.1000000000000000055511151231257827。所以在不需要超低内存和超高速的通用计算里优先用双精度因为精度高很多。但双精度绝不是“精确小数”的代名词。它和单精度一样本质都是二进制近似。你在金融、账务、测量计算里遇到“精确到分”这种需求直接上浮点数就是在埋雷后面我会专门讲怎么绕开。3.5 浮点运算的经典坑0.1 0.2 为什么不等于 0.30.1在二进制里是一个无限循环小数就像十进制里1/3是0.3333...一样永远写不完。单精度取23位尾数时就已经被截断双精度取52位还是截断。于是0.1 0.2 0.30000000000000004在大多数语言里打印出来都是这样。这不是计算机坏了而是二进制浮点表示和我们习惯的十进制无法精确对齐。对应的浮点乘法也一样两个近似数相乘误差会叠加。如果你做大量乘加运算误差会越滚越大这也是为什么硬件数字信号处理里很多人宁可用定点数或者专门做误差分析。比较浮点数的正确姿势是看绝对差是否小于一个阈值或者把数值缩放成整数再比较永远不要直接写if (a b)。4. 实操用Verilog把整数和浮点数通过串口发出去4.1 硬件眼中的机器表示寄存器里没有符号只有位前面讲的所有编码在Verilog里会体现得非常明显。你写reg [7:0] data它就是8个触发器你往里面赋-1综合工具也不会“聪明”到帮你存绝对值它存的纯粹是8hFF。硬件看不到正负号只看到位模式。这就是为什么做FPGA串口通信时聊“机器表示”不是装知识而是救命技能。你从内存里读一个int32_t负数看到0xFFFFFFFE你要是不知道这是补码就会把它当成一个42亿多的正数发出去整个协议直接错乱。4.2 写一个最简UART发送模块先写一个只发送8位数据的串口发送模块这是所有上层协议的基础。我用的方式很简单状态机从空闲切到起始位然后依次发送8个数据位最后发送停止位。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115_200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg txd, output reg tx_busy ); localparam BIT_WIDTH CLK_FREQ / BAUD_RATE; // 434 reg [8:0] clk_cnt; reg [3:0] bit_cnt; reg [1:0] state; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; always (posedge clk or negedge rst_n) begin if (~rst_n) begin txd 1b1; state IDLE; tx_busy 1b0; clk_cnt 0; bit_cnt 0; end else begin case (state) IDLE: begin txd 1b1; tx_busy 1b0; if (tx_start) begin tx_busy 1b1; clk_cnt 0; bit_cnt 0; state START; end end START: begin txd 1b0; if (clk_cnt BIT_WIDTH - 1) begin clk_cnt 0; state DATA; end else begin clk_cnt clk_cnt 1b1; end end DATA: begin txd tx_data[bit_cnt]; if (clk_cnt BIT_WIDTH - 1) begin clk_cnt 0; if (bit_cnt 4d7) begin bit_cnt 0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin clk_cnt clk_cnt 1b1; end end STOP: begin txd 1b1; if (clk_cnt BIT_WIDTH - 1) begin clk_cnt 0; state IDLE; tx_busy 1b0; end else begin clk_cnt clk_cnt 1b1; end end default: state IDLE; endcase end end endmodule这里BIT_WIDTH的计算是50MHz / 115200 434所以每个bit需要维持434个时钟周期。UART空闲时txd保持高电平起始位拉低8个数据位从最低位开始依次输出最后停止位拉高。这个模块不管上层发的是什么它只忠实发送一个8位字节。要想发送32位整数或32位浮点数需要在外层做字节拆分四个字节一个接一个喂给它。4.3 发送32位整数补码本身就是字节流假设我们要发一个32位有符号整数value它在Verilog里就是一个reg [31:0]位向量。负数不需要你手动算补码因为-1这个值在寄存器里就已经是32hFFFFFFFF了。你要做的只是把位向量按字节切开。发送顺序要提前定。串口协议通常会约定小端或者大端。小端就是低字节先发大端是高字节先发。PCIe、串口助手、各种通信中间件对这个很敏感我建议在模块里用一个byte_idx计数器从0到3然后按协议选择always (*) begin case (byte_idx) 2d0: byte_data value[7:0]; 2d1: byte_data value[15:8]; 2d2: byte_data value[23:16]; 2d3: byte_data value[31:24]; default: byte_data 8h00; endcase end这段是小端顺序先发低位字节。如果是0x12345678先发0x78再发0x56、0x34、最后0x12。接收端按同样顺序拼回去0x12345678 305419896你如果直接用移位再赋值很容易把位宽搞错。我自己踩过的坑是32位负数直接截取低8位得到0xF8传给uart_tx后一切正常但PC端按有符号数解出来却是-8如果你本意是发一个大数字就必须保证高位字节不是被符号扩展出来的多余字节。4.4 发送32位浮点数把IEEE 754编码拆开再发浮点数本质上也是32位二进制只是它的位模式遵循IEEE 754。因此发送一个浮点数和发送一个整数在硬件层面没有任何区别。你不需要在Verilog里做“实数转浮点”需要做的只是拿到浮点数的位模式然后按字节拆。综合代码里浮点数的位模式通常来自浮点运算IP核的输出或者在仿真里用$bitstoreal、$realtobits这类系统函数把实际值变成位向量。我这里用一个测试场景我想发85.25它的单精度编码是0x42AA8000。我不需要在代码里做乘法直接把32h42AA8000赋值给一个reg [31:0] float_bits然后按字节发reg [31:0] float_bits; always (*) begin case (byte_idx) 2d0: byte_data float_bits[7:0]; 2d1: byte_data float_bits[15:8]; 2d2: byte_data float_bits[23:16]; 2d3: byte_data float_bits[31:24]; endcase end同样是小端。最后串口助手里收到的十六进制就是00 80 AA 42注意这是字节倒序后的样子你在PC端把00 80 AA 42按小端拼成42 AA 80 00再交给在线转换器就能解出85.25。仿真时我习惯先用系统函数把浮点值转成位向量reg [31:0] tb_float_bits; initial begin tb_float_bits $realtobits(85.25); #100 $display(%h, tb_float_bits); // 42AA8000 end这个系统函数主要用于仿真验证不代表它一定能综合。实际项目里单精度浮点数的位宽就是32位你只要保证浮点运算模块输出的位序和你的字节拆分顺序一致就行别的什么都不用改。4.5 串口浮点发送的几个高频调试点一开始最容易卡住的现象是PC收到的字节全对但组合出来的浮点数却差得离谱。这时先不要怀疑算法按顺序排查字节序是否一致。发送方是小端接收方假定大端拼出来的位模式就会完全反转。数据位顺序。UART本身是LSB first如果接收端误配成MSB first也会错乱。浮点是否被意外截位。比如你从32位变量里直接取了16位尾数被砍数值就会变成另一个近似数。有没有在浮点数的传输过程中把它当成“十进制字符串”处理。很多协议为了省事直接把数字转成ASCII字符串发这时机器表示和数值本身已经完全脱钩再谈十六进制就没有意义了。我另一个建议是用在线十六进制浮点转换器当调试基准。先在软件里算好“十进制→十六进制位模式”再对着硬件出来的字节看两头一对照问题基本缩小到了时钟域或者字节序层面。这个方法在调试真实项目的时候救过我很多次比瞎猜快得多。5. 常见问题与排查技巧实录5.1 补码溢出和上溢、下溢怎么判断运算场景例子结果判定正 正127 110000000 (-128)溢出结果符号位为1负 负-128 (-1)01111111 (127)溢出结果符号位为0正 负127 (-1)01111110 (126)正常永远不会溢出如果是32位运算规则完全一样只是边界从127和-128变成0x7FFFFFFF和0x80000000。补码运算的溢出和“有没有进位”是两个完全不同的概念判断溢出时只盯着最高位的进位移位是常见的误区。5.2 浮点数比较三种安全姿势浮点数不要用直接判定相等这是老生常谈但真正落地时很多人又忘了。我常用的方法有三个第一种是绝对误差比较适合数值量级已知的场景if (fabs(a - b) 1e-6) { ... }第二种是相对误差比较适合跨度很大的场景比如算法计算结果可能从万分之一到几万if (fabs(a - b) 1e-6 * fmax(fabs(a), fabs(b))) { ... }第三种是把浮点数按需求缩放到整数再比较。比如金额按分存成整数运算用64位整数就完全绕开了浮点误差。这也是不少嵌入式系统在成本限制下使用定点数的核心原因。5.3 在线十六进制浮点转换器怎么用才靠谱这类工具本质就是把一个十六进制数拆成符号位、指数位、尾数位再按IEEE 754公式还原成十进制。它能用但有前提你得先明确是单精度还是双精度以及字节序。我见过很多人拿一个双精度的8字节数填进单精度输入框然后质疑“工具算错了”。工具没错错在选择就不对。如果条件允许最好自己手动还原一遍验证。重点看三个部分指数偏移量是127还是1023隐藏位要不要补上以及尾数截断后多出来的那一点点误差是不是符合预期。能把这三关都过了你对浮点数的理解就比大多数人都扎实。5.4 高精度整数和浮点数场景怎么办回到文章开头的场景高精度数值计算即使不用浮点也能靠整数和数据格式设计解决。比如一个64位有符号整数的范围是[-9223372036854775808, 9223372036854775807]很多计数类应用根本不会触顶。真正需要超高精度的时候市面上常见做法有两种。一种是使用固定精度的十进制定点数底层用整数存储十进制定标到小数点后N位加减乘全部在整数域完成。这种方法快、可控、误差可预测金融计费系统大量使用。另一种是使用支持高精度浮点数的科学计算环境内部会自动切分指数和尾数的存储。但这种高精度不是白来的计算速度和内存占用都会显著上升工程上要基于实际需求选型不能为了“看起来高级”一刀切。我自己做协议和数据采集时绝大多数情况靠32位整型和单精度浮点就够用只有在做滤波算法、积分累加时才升级到64位或者定点实现。不要迷信某一种表示能解决所有问题机器表示的意义就是让你在有限的位宽里做权衡选最合适的形态。最后说一个我踩过很多次之后总结出来的习惯调试数据链路时不要一上来就盯十进制数字先把数据当成十六进制位模式看一遍。整数、浮点数、字符、地址在链路里归根结底都是一串bit只要你拿到的是机器表示就按位模式去核对。懂得补码和IEEE 754的排布规则后看到一条十六进制报文心里就自然有了“这可能是-1、0.85还是A”的预判很多莫名奇妙的兼容性问题其实早就写在那些位里了。