资讯动态

计算机组成原理:补码、浮点数与溢出,数据表示与运算的核心逻辑

发布时间:2026/10/15 1:31:03 来源:尧图企业网站定制
1. 从数据在机器里长什么样说起很多人学计算机组成原理翻到数据的表示与运算这一章就开始犯困。原因很简单前面讲CPU、讲存储器好歹能对应到实物而这一章全是0和1、补码、浮点、溢出看起来像数学课而不是硬件课。但我带了这么多届学生、也做过不少底层调试的活儿可以很负责任地说——这一章才是整个计算机体系里最值钱的部分。你后面遇到的几乎所有诡异bug追到根上都是数据表示出了问题。举个最常见的场景两个正整数相加结果变成负数0.1加0.2不等于0.3一个看起来相等的浮点数比较却返回false大端小端传数据过去全乱套。这些问题的答案全在数据的表示与运算这一章里。它讲的是三件事数在机器里怎么存表示、存进去之后怎么算运算、算的时候会出什么岔子溢出与精度。搞懂这三件事你才算真正理解了计算机的数字观。这篇文章我打算按实际理解的顺序来拆而不是照本宣科按教材目录走。因为教材的编排逻辑是先定点后浮点、先原码后补码但人的理解逻辑往往是先问为什么要有补码再问浮点为什么不准。我会把这两条线揉在一起讲中间穿插大量我实际调试中踩过的坑和验证方法。不管你是正在啃这门课的学生还是工作几年后想回头补基础的开发者这篇都能让你把这块硬骨头啃下来。关键词就三个补码、浮点数、溢出。这三个词撑起了整章的核心其余的定点表示、移位运算、舍入规则都是围绕它们展开的枝叶。下面我们一层层剥。2. 定点数的表示为什么计算机偏爱补码2.1 原码、反码、补码的演进逻辑先说清楚一个前提计算机里做加减法最希望的是用同一套电路同时处理加法和减法。如果加法和减法要两套硬件成本翻倍不说控制逻辑也复杂。补码的出现本质上就是为了让减法变加法这件事在二进制里成立。我们一步步看。假设用8位表示一个整数。最直观的想法是原码最高位当符号位0正1负剩下7位表示绝对值。比如5是0000 0101-5是1000 0101。原码的问题有两个第一0有两种表示0000 0000和1000 0000给判断带来麻烦第二做加法时符号位不能直接参与运算得先判断符号、比较大小、决定用加法还是减法电路很啰嗦。于是有人提出反码正数不变负数是原码除符号位外按位取反。-5的反码是1111 1010。反码解决了部分运算问题但0依然有两种表示0000 0000和1111 1111而且运算时还要处理循环进位。补码才是终局方案。负数的补码等于反码加1-5的补码是1111 1011。它的精妙之处在于符号位可以直接参与运算减法可以统一成加法0只有一种表示。验证一下5 (-5)即0000 01011111 10111 0000 0000最高位溢出丢弃结果是0000 0000正好是0。这就是补码的魔力。提示理解补码不要死记取反加一要理解它的数学本质——在模2^n的意义下-x 等价于 2^n - x。8位时-5就是256-5251二进制正是1111 1011。这个模运算的视角能让你后面理解溢出时豁然开朗。2.2 补码的表示范围与那个多出来的负数8位补码能表示的范围是-128到127。注意负数比正数多一个。为什么因为0占用了0000 0000这一个编码而1000 0000这个编码在原码里是-0在补码体系里被废物利用成了-128。这不是巧合是补码设计的必然结果。这个细节在实际编程里非常关键。比如你写C语言char类型在多数平台是有符号的范围就是-128到127。如果你把一个127加1得到的是-128而不是128。这就是有符号溢出。很多新手写循环for(char i0; i200; i)结果死循环就是因为i永远到不了200加到127之后跳到-128继续。我实际调试过一个案例某嵌入式设备用8位有符号数记录温度偏移量量程设计是-128到127。测试时输入128设备直接报了个-128工程师以为是传感器坏了查了半天才发现是数据类型选错了。这种坑理解了补码范围就一眼能看穿。2.3 无符号数与有符号数的边界陷阱无符号数就简单了8位表示0到255全部位都是数值位。但有符号和无符号混用是C/C里最经典的坑之一。看这段代码unsigned int a 1; int b -1; if (a b) { printf(a b\n); } else { printf(a b\n); }直觉上1 -1应该输出a b。但实际运行会输出a b。原因是当有符号和无符号比较时有符号数会被隐式转换成无符号数-1变成1111 1111 ... 1111也就是4294967295远大于1。这个坑我在代码review里见过太多次尤其是涉及数组下标、长度判断的时候。注意写代码时尽量避免有符号和无符号混用。如果非要用显式做类型转换并且心里清楚转换后每个bit是什么。编译器一般会警告别把警告当耳旁风。3. 定点运算移位、加减与溢出的判定3.1 移位运算左移右移背后的算术含义移位看起来简单其实分好几种搞混了会出大问题。逻辑移位是不管符号位的左移右移都补0算术移位要保留符号位右移时补的是符号位正数补0负数补1。左移一位在数值上等于乘2前提是不溢出。比如0000 01015左移一位变0000 101010。右移一位等于除以2取整。但注意负数的算术右移是向下取整比如-5右移一位-5的补码是1111 1011算术右移补符号位1得到1111 1101这是-3。而-5除以2在数学上是-2.5向下取整是-3对上了。这里有个实际优化技巧乘除2的幂次用移位比用乘除指令快得多。编译器在优化时也会自动把x * 8变成x 3。但前提是x是无符号数或者你确定不会溢出。如果x是有符号负数右移的行为在不同编译器、不同标准下曾经有过分歧C20之前是实现定义所以涉及负数右移要格外小心。3.2 加减运算的溢出判定三种实用方法溢出是定点运算的核心考点也是实际bug的高发区。判定溢出有三种常用方法我逐个说并给出适用场景。方法一双符号位法。用两位表示符号正常是00正或11负。运算后如果变成01正溢出或10负溢出就说明溢出了。这个方法在硬件里很常用因为判断逻辑简单一个异或门就搞定。方法二进位判断法。看最高数值位的进位和符号位的进位是否一致。如果两者不同一个进一个不进就是溢出。这个方法在手工计算时最方便。方法三结果反推法。两个正数相加得到负数或者两个负数相加得到正数必然溢出。同号相加才可能溢出异号相加永远不会溢出。这个方法最直观写代码判断时最常用。我实际写底层代码时判断有符号加法溢出一般这么写int safe_add(int a, int b, int *result) { if ((b 0) (a INT_MAX - b)) return -1; // 正溢出 if ((b 0) (a INT_MIN - b)) return -1; // 负溢出 *result a b; return 0; }这个写法避免了直接相加后再判断因为相加本身可能已经溢出行为未定义而是提前用减法预判。这是工程上比较稳妥的做法。3.3 溢出在真实项目里的表现说个我印象深刻的案例。某数据处理程序需要累加一批传感器读数每个读数范围是0到1000用16位有符号数存范围-32768到32767。单个数没问题但累加几千个之后总和超过了32767直接翻成负数。程序没有做溢出检查结果输出的平均值是个负数下游的报警逻辑全乱了。这个bug的教训是选数据类型时一定要估算最大值。累加n个最大值为M的数结果最大是n*M要保证这个值在类型范围内。如果不够要么换更大的类型32位、64位要么用浮点要么做溢出检查。很多教科书只讲溢出的判定不讲怎么在设计阶段避免溢出这是不够的。提示涉及金额计算时永远不要用浮点用整数以分为单位配合足够大的类型。这是血泪教训浮点的舍入误差在金额累加时会累积成肉眼可见的偏差。4. 浮点数IEEE 754标准与精度丢失的根源4.1 浮点数的三部分结构浮点数解决的是定点数表示范围有限、无法表示小数的问题。IEEE 754标准是事实上的通用标准单精度float用32位双精度double用64位。以32位为例结构是1位符号位 8位阶码 23位尾数。符号位好理解0正1负。阶码用移码表示偏移量127这样能表示正负指数。尾数用隐含最高位1的方式即规格化后尾数总是1.xxxx的形式那个1不存省出一位精度。所以float的实际有效精度是24位二进制大约对应7位十进制有效数字。这里有个容易混淆的点阶码用移码尾数用原码。为什么阶码不用补码因为移码比较大小更方便全0最小、全1最大硬件比较电路简单。而尾数用原码是因为它只表示绝对值符号单独存不需要参与加减的符号处理。4.2 0.1 0.2 ≠ 0.3 的完整推导这是浮点数最著名的反直觉案例。为什么0.1加0.2不等于0.3因为0.1和0.2在二进制里都是无限循环小数无法用有限的23位尾数精确表示。0.1的二进制是0.000110011001100...1100循环0.2是0.00110011001100...也是1100循环。存进float时只能截取前23位后面被舍入。两个近似值相加误差累积结果自然不等于精确的0.3。用Python验证 0.1 0.2 0.30000000000000004这个0.00000000000000004就是舍入误差。注意这不是Python的bug是所有遵循IEEE 754的语言的共同行为。那怎么处理涉及精确比较时不要用要用一个极小的误差范围epsilondef float_equal(a, b, eps1e-9): return abs(a - b) eps涉及金额、计数等需要精确的场景用整数或Decimal库别用float。4.3 特殊值与舍入模式IEEE 754还定义了几个特殊值正负无穷Inf、非数NaN、非规格化数。除以0得到Inf0除以0或Inf减Inf得到NaN。NaN有个诡异特性它不等于任何值包括它自己。所以判断一个值是不是NaN不能用x NaN要用专门的函数如isnan()。舍入模式有四种就近舍入默认、向零舍入、向上舍入、向下舍入。默认的就近舍入还有个细节叫向偶数舍入round to even即恰好在中点时舍入到末位为偶数的方向。这个设计是为了减少统计上的偏差。理解这一点你才能解释为什么某些舍入结果和直觉不符。我实际做数值计算时遇到过因为舍入模式导致的累积误差。一批数据用不同顺序相加结果居然不一样就是因为每次舍入的误差方向不同。解决办法是用Kahan求和算法或者干脆用double甚至更高精度把误差压到可接受范围。5. 定点与浮点的选型什么时候用哪个5.1 从精度、范围、性能三个维度对比选定点还是浮点不是拍脑袋决定的要看三个维度精度要求、数值范围、性能约束。维度定点数浮点数精度绝对精度固定均匀分布相对精度固定越靠近0越密范围有限取决于位数极大阶码决定性能加减快乘除需额外处理有专用硬件FPU现代CPU上很快适用场景金额、计数、嵌入式科学计算、图形、机器学习关键区别在于精度的分布方式。定点数在整个范围内精度均匀比如用16位定点表示0到1每个间隔是1/65536。浮点数则是浮动的大数附近精度低小数附近精度高。这就是为什么浮点适合表示跨度极大的物理量从原子半径到宇宙尺度而定点适合表示范围固定、精度要求均匀的量比如金额。5.2 嵌入式场景下的定点实战在嵌入式领域很多低端MCU没有FPU浮点运算单元用浮点会非常慢甚至要软件模拟。这时候定点数是首选。常见做法是Q格式定点比如Q15表示1位符号15位小数范围-1到1精度1/32768。举个实际例子做电机控制需要计算PID。假设比例系数Kp0.5误差e范围-100到100。用Q15表示Kp就是0.5 * 32768 16384。计算Kp*e时先做整数乘法16384 * e再右移15位还原。整个过程全是整数运算速度飞快。注意定点乘法的中间结果会放大要确保中间变量用足够大的类型。两个Q15相乘结果是Q30需要32位甚至64位来存否则中间就溢出了。这是定点编程最容易翻车的地方。5.3 浮点的现代地位与陷阱现代CPU基本都带FPU浮点运算速度不再是问题。但浮点的陷阱依然存在精度丢失、比较陷阱、累积误差、特殊值传播。我见过一个机器学习项目训练时loss突然变成NaN查了半天发现是某一步除以了一个接近0的数产生了InfInf再参与运算变成NaN然后污染了整个网络。处理这类问题的经验是在关键计算步骤后加检查发现Inf或NaN立即报警或做保护。另外归一化、对数、除法这些操作要特别小心它们最容易产生极端值。工程上常用的技巧是给分母加一个极小的epsilon防止除零。6. 那些年我在数据表示上踩过的坑6.1 大小端跨平台传输的隐形杀手大小端Endianness是数据表示里最容易被忽视、又最容易出问题的点。大端是高位字节存在低地址小端是低位字节存在低地址。x86是小端很多网络协议和某些架构是大端。我做过一个跨平台的数据采集项目发送端是小端机器接收端是大端机器传一个32位整数过去接收端解析出来完全不对。比如0x12345678小端存储是78 56 34 12大端解析就变成了0x78563412。解决办法是统一用网络字节序大端传输收发两端都做转换htons/htonl系列函数。这个坑的隐蔽性在于单机测试永远发现不了一跨平台就炸。所以涉及网络传输、文件存储、跨架构通信时必须明确字节序并在协议文档里写清楚。6.2 类型转换的静默截断C语言里把int赋给char会发生截断只保留低8位。这个行为是静默的编译器可能只给个警告。我见过一个bug某配置项用int存范围是0到1000后来为了省内存改成char结果超过127的配置全部变成负数程序行为诡异。查了半天才发现是类型改小导致的截断。教训是改数据类型时一定要重新评估取值范围。缩小类型前先确认所有可能的取值都在新类型范围内。这个检查用静态分析工具能自动做但很多人不用。6.3 位运算的优先级陷阱位运算的优先级在C语言里是出了名的反直觉。的优先级低于所以if (a b 0)实际是if (a (b 0))完全不是你想的意思。正确写法是if ((a b) 0)。这个坑我踩过不止一次。后来养成的习惯是位运算表达式一律加括号不管优先级记不记得住加括号总没错。代码可读性也更好。7. 一套可复用的验证方法学完理论怎么验证自己真的懂了我总结了一套实操验证方法用Python或C都能做。第一步验证补码。写个函数输入一个负数输出它的补码二进制和理论值对比。再验证-128的补码是1000 0000以及它的相反数还是它自己这是补码的一个特殊性质。第二步验证溢出。用8位有符号数测试1271、-128-1、100100等边界情况观察结果是否符合溢出规律。第三步验证浮点精度。打印0.1、0.2、0.3的二进制表示观察尾数的舍入。再测试一系列浮点运算找出哪些结果和数学预期不符。第四步验证大小端。用union或指针把一个整数的每个字节打印出来确认当前机器的字节序。这套验证做完你对数据表示的理解就从知道变成了确信。我当年就是靠反复做这些验证才把这一章彻底吃透的。提示验证时用Python的struct模块和bin()函数很方便能直接看到内存里的字节和二进制。C语言用union和指针强转也能达到同样效果。两种都试试理解更深。8. 给不同基础读者的学习路径建议如果你是正在上课的学生我的建议是先把补码的模运算本质搞懂这是整章的钥匙。然后动手做上面那套验证别只看书。浮点部分重点理解为什么不准而不是死记IEEE 754的位分配。如果你是工作后想补基础的开发者建议从你实际遇到的bug反推。比如你遇到过浮点比较问题就顺着这个点深挖浮点的表示和舍入遇到过溢出就深挖补码和溢出判定。带着问题学效率比从头啃高得多。如果你是做嵌入式的定点数和位运算要重点掌握浮点能不用就不用。Q格式、移位优化、溢出检查这些是日常必备技能。如果你是做上层应用的浮点的精度陷阱和类型转换的坑要重点防定点数了解即可。金额用整数、比较用epsilon、跨平台注意字节序这三条记住就够用很久。这一章的内容看起来抽象但每一个知识点背后都有真实的工程场景。我常跟人说计算机组成原理不是理论课它是解释为什么你的代码会这样跑的课。数据的表示与运算这一章就是这门课里最接地气的一章。把它啃透你再看那些诡异的bug会有一种原来如此的通透感。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑