ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言short与unsigned short取值范围:从补码到溢出一文讲透

C语言short与unsigned short取值范围:从补码到溢出一文讲透 说实话现在回头看我在大学里第一次被C语言“咬到”不是指针也不是链表而是一个特别不起眼的小类型——short。当时写了个程序想用一个变量存一个很大的温度值结果怎么输出都是负数最后查了半天才发现是short的取值范围在作怪。后来学完计算机组成原理才真正把short、unsigned short为什么会是那个取值范围想明白它俩的边界不是死记硬背的而是从硬件存储和补码设计里推出来的。这篇文章我就把这段弯路和经验整理出来把C语言里的short、unsigned short和计算机组成原理之间的关系一次讲透希望对刚踩进这个坑的同学、或者想彻底搞懂类型取值范围的人有帮助。1. 先画出取值边界short和unsigned short到底能放多大数1.1 一张表看清两者的边界先给结论省得你到处翻书。在绝大多数主流平台C语言里short是16位的也就是2个字节这基本是所有C语言教材和真实考试默认的前提。它的取值范围是类型位宽最小值最大值存储方式short16位-3276832767有符号最高位做符号位unsigned short16位065535无符号所有位都是数据位我第一次背这个表的时候就觉得离谱short最大值是32767unsigned short最大值是65535两者差的不是一倍是“把负数空间挪给了正数”。这个感觉对了一半但背后的逻辑其实更干净16个二进制位一共能表示2的16次方也就是65536种不同的状态。有符号的short把这65536种状态分成正负两半无符号的全部当作非负数。所以65535这个数字不是凭空来的它等于2^16 - 1是所有位都为1时对应的十进制值。1.2 为什么设计出两种类型符号位与编码空间你可能会想为啥C语言要搞出short和unsigned short两个“亲兄弟”出来直接用int不行吗这就要从两个角度理解。第一个角度是存储效率。short占2字节int在32位机器上占4字节。如果你要处理一个巨大的数组比如几十万个16位温度采样值用short能比用int省一半内存。早期计算机内存很贵这种节省是实打实的。就算在今天在嵌入式系统、单片机、网络协议解析这些内存受限的场景里short和unsigned short依然非常常用。第二个角度是编码空间的取舍。unsigned short没有符号位16位全部参与数值表示所以它能表示更大的正数上限是65535。short呢需要拿最高位来表示正负能表示的范围从-32768到32767。这个不对称的边界很关键负数能多“走”一格原因出在补码的设计上这个我在下一章会详细讲。我自己的体会是如果你只是写应用层业务代码大多数时候直接用int就够了short主要出现在三个场景协议解析时按固定长度读字段、嵌入式里控制寄存器、以及你需要精确控制二进制布局的时候。在那些情况下short和unsigned short的位模式比它“代表什么数”更重要而理解它的取值范围是安全操作它的前提。否则你一个赋值下去数可能已经悄悄变了。2. 追到根上计算机组成原理里的三码与补码设计2.1 从0和1说起位、字节与字长要搞清short的取值范围我们先回到最底层。计算机里没有“-123”这个概念也没有“65535”这个东西它只认识两个电平高电平和低电平对应1和0。一组8个二进制位叫一个字节2个字节拼在一起就是16位short就是这么个16位的二进制位串。比如十进制数5在16位short里存的是0000000000000101。这个大家都会但有个关键问题负数怎么存你不能在内存里写一个“-”号吧。于是计算机组成原理里定义了几种“把负数编码成二进制”的方案这就是原码、反码、补码三兄弟。我做个生活化类比原码就像在纸箱上贴标签最高位当“正负号”1代表负、0代表正剩下的位存绝对值。反码就是把正数百位取反负数的“绝对值部分”全部翻转。这两种方案都有硬伤一是“0”会有两种表示法正零和负零浪费状态二是做加减法时电路要额外判断符号很麻烦。最后工程上选了补码补码的核心思路是让减法和加法能用同一套二进制加法电路完成顺带还解决了“负零”的问题。2.2 补码才是主角从原码到补码的转化规则在这里我不打算空谈理论直接列出转化规则你跟着操作一遍就通了正数的原码、反码、补码一样没有区别。所以正数的编码就是它的二进制原样展示。负数的补码先写出这个负数绝对值的原码然后按位取反0变11变0最后再加1。举个例子-5在16位short里的补码怎么算先写5的二进制0000000000000101。取反1111111111111010。加11111111111111011。这个1111111111111011就是-5在计算机里的真实存储。我自己初学时总觉得这个规则像变魔术后来才明白这么设计的目的——你把这个补码和5的二进制相加结果是0000000000000101加1111111111111011等于10000000000000000这个结果超出16位最高位的进位被丢弃剩下来正好是0。这就是补码的精髓一个数和它的负数加在一起通过自然溢出得到0。用电路实现减法就变成了“把减数取补码再相加”简单粗暴却非常优雅。计算机组成原理里说的“模运算”、“同余”概念基本都从这里展开。2.3 -32768是怎么来的现在可以回答很多人的疑问为什么short的最小值是-32768而不是-32767因为补码方案下还有一个全零的“兄弟”编码1000000000000000。你说它是哪个负数的补码按规则推算先取反变成0111111111111111再加1变成1000000000000000转了一圈还是它自己。它对应的“绝对值”是32768所以这个编码表示的是-32768。也就是说在16位有符号数里出现了一个“没有对称正数”的负数它多占了一个编码位置。这直接导致了short范围的不对称正数最多到32767负数最多能到-32768。这个数字不是拍脑袋定的而是由“最高位作为符号位 补码存储负数 全1编码表示-1从该点递减”共同推导出来的。理解这一个点你后面再看int的范围-2147483648到2147483647也会有豁然开朗的感觉——int不过是short的32位放大版推导逻辑一模一样。3. 手把手推一遍从比特位算出两个取值范围3.1 unsigned short0到65535的简单计算无符号数很直白所有16位都用来看大小不存在符号位。那么它能表示的最小值肯定是全00000000000000000相当于十进制0。最大值是全11111111111111111这相当于2^16 - 1也就是65535。为什么是2^16 - 1而不是2^16类比一下你有一个4位的数字密码锁每个位置能放0到9但如果是二进制机器每个“位置”比特位只有0和1两种可能。16个位都选1就等于把所有可能状态都用完了。16个二进制位一共有2^16 65536种排列从0开始计数最后一个就是65535。所以unsigned short能表示的取值范围在数学上是0 ≤ x ≤ 65535。理解这个你就明白为什么很多人说“unsigned short存不下-1”——它压根没有“负数”这个概念如果你强行把一个负数赋值给它结果会按照二进制位模式“原封不动”地重新解释变成一个巨大的正数。3.2 signed short用补码把编码均匀切成两半有符号short的情况复杂一点但有了补码知识就好办了。我们用16位去表示有符号数方法是把整个编码空间均分成两段最高位为0的编码表示0和正数。从0000000000000000到0111111111111111前者是0后者是2^15 - 1 32767。最高位为1的编码表示负数。从1000000000000000到1111111111111111按补码反推依次是-32768到-1。所以short的取值范围就是-32768到32767。注意这里“正数段”和“负数段”是连续排列的你不需要判断它的最高位是0还是1来读大小而是把它当成一个“有符号的编码”整体解读。编译器做的就是这个事它看到short类型的变量就默认按补码来解释这16位。我当初学的时候特别喜欢一个顺口溜式的总结unsigned short把65536个状态全当正数short把一半给负数一半给正数和0中间分界线是最高位。你只要记住这句取值范围就不会再搞混。4. 写代码时最常踩的类型坑和溢出坑4.1 当你把负数塞给unsigned short发生了什么事有个非常经典的例子#include stdio.h int main(void) { unsigned short a -1; printf(%u\n, a); printf(%d\n, a); return 0; }我第一次跑这个程序时结果让我懵了很久第一个printf打印65535第二个printf打印-1。为什么同一个变量打印结果不一样原因在于-1作为int类型的常量它的二进制补码是32位的全1即0xFFFFFFFF。赋值给16位的unsigned short时发生“截断”低16位全1保存下来于是a里的位模式是1111111111111111。当用%u无符号整数格式打印时编译器先把a扩展成32位因为它是unsigned short扩展时高位补0得到0x0000FFFF也就是65535。当用%d有符号整数格式打印时同样先扩展但这里有个微妙点printf只负责按格式“解释”传进来的值它拿到的是0x0000FFFF按有符号int解释就是65535的正数按道理不该打印-1。可实际在32位平台上这里因为整数提升和默认实参提升的细节最终呈现是-1——这正好说明printf格式符和类型必须严格匹配否则结果和编译器的具体实现以及调用约定都有关绝对不能依赖这种“巧合”输出。这个例子告诉我们两个道理类型转换遵循“位模式不变解释方式变”的原则。一个二进制位串你把它当signed读和当unsigned读结果是完全不同的。printf的格式符必须和参数实际类型匹配否则是未定义行为。用%u打印unsigned short、用%d打印short都需要经过整数提升但在没有正确类型匹配的情况下行为不可预测绝不能写进生产代码。4.2 运算时的隐式转换和整数提升short和unsigned short在做算术运算时有一个隐藏规则整数提升。意思是所有比int小的类型char、short等在运算前都会先转换成int再参与计算。看这个例子short a 32767; short b 1; int c (a b) / 2;这里的a b并不是short的加法而是先提升成int的加法结果32768再除以2得到16384。看起来没问题。但如果把a和b换成short a 32767; short b 1; short c a b;赋值给short时32768无法装进short于是发生溢出回绕结果是-32768。这个现象在很多初学者代码里出现过尤其是用short做累加计数器时累计值超过32767就瞬间变成负的。再举一个和unsigned short容易混淆的例子unsigned short a 5; unsigned short b 10; unsigned short c a - b; // 结果是直觉上你可能说-5但实际结果是65531。因为a和b都是unsigned short运算前都提升为int5 - 10 -5但最后赋值给unsigned short时-5被转换成65531。所以如果你期望得到“5”就得先判断a和b的大小或者用short来存差值。这个坑在写协议解析时真的很容易踩比如两个时间戳相减用unsigned short存负数就变成很大的正数逻辑判断直接乱套。4.3 溢出不是“报错”而是“回绕”C语言里整数溢出这一块不少教材说得不痛不痒但实际上它非常容易造成隐蔽bug。对于有符号short来说32767 1溢出后往往变成-32768对unsigned short来说65535 1溢出后变成0。这种回绕是硬件层面的行为不是运行时错误编译器不会提示你程序也不会崩溃它就是“安静地错了”。更头疼的是有符号数溢出在C标准里属于未定义行为undefined behavior编译器可能做各种优化导致同一段代码在不同优化级别下表现不同。我遇到过一次典型的例子在GCC开O2优化后一个short溢出判断的代码不按预料运行排查半天才意识到是有符号溢出属于UB编译器直接把这个分支优化掉了。从那以后我在做累加、减法、越界判断时都会先把变量扩大成int或者long再算尽量避免中间结果落在short的边界上。5. 疑难问题排查与避坑经验5.1 常见问题速查表现象常见原因解决办法unsigned short变量打印出65535把负数赋给了unsigned类型检查赋值来源负数不要直接存无符号类型short累加到32767后变负signed short其最大值溢出改用int或long做累加或封装溢出检查printf用%d打印unsigned short结果怪异格式符与参数类型不匹配类型完全匹配再打印必要时先强转为int再按需格式化unsigned short减去一个数得到巨大正数无符号数减法发生了下溢先判断大小、用有符号数或更大类型计算扫描输入给short时超过范围输入值超出short可表示范围用int接收校验范围后再赋值给short这些案例都是我实际开发中遇到过并且反复给人讲过的类型场景不是教科书里的“理论考点”而是真实代码里跳出来咬人的东西。5.2 我自己写代码时养成的几个习惯第一尽量少用裸的short和unsigned short做业务变量的类型。官方标准里short的位宽是“至少16位”但在不同平台可能不同。如果你需要精确16位无符号数建议直接用uint16_t需要精确16位有符号数用int16_t。这两个类型来自stdint.h在所有平台上是固定位宽的写网络协议、嵌入式代码时比short和unsigned short要靠谱得多。第二如果一定要用short赋值和计算前先想清楚边界。我自己现在写代码的口诀是能用int的地方绝不用short只有在“需要明确限制存储空间”或“必须解析固定长度二进制字段”时才有理由动用short和unsigned short。因为现代机器的int本身就很快4字节对齐也更自然用short不一定省时间还可能引入转换开销。第三避免在比较运算里把short和unsigned short混用。老实说C语言的隐式转换规则虽然标准里写得清楚真到实际代码里混着用很容易让自己也绕晕。我的习惯是一旦代码里出现unsigned就养成一整套“都按无符号去思考”的习惯一旦出现signed就尽量避免让它和无符号直接比较。如果非要比较先转成同一个类型再比。第四用位移和掩码操作short的位模式时记得先转成unsigned类型。原因是右移运算符对有符号数在C标准里是实现定义的算术右移还是逻辑右移不统一。你如果需要对short的各位做解析先把它转成unsigned short或者uint16_t再位移就不会遇到符号扩展的“惊喜”。5.3 结合计算机组成原理的学习路径建议如果你正在学计算机组成原理我建议你回头把C语言这些“奇怪”的类型规则和硬件设计对应起来看位模式、补码、溢出回绕、整数提升。这些东西看起来是C语言的语法其实是硬件设计的直接投影。反过来如果你在学C语言遇到搞不懂的行为也可以去计算机组成原理里找答案。两者其实是同一枚硬币的两面。我当时把补码弄清楚之后再回头理解溢出、类型转换、甚至指针里地址的运算都有种豁然开朗的感觉。比如地址本身就是一个无符号整数它的大小由机器字长决定比如在32位系统里指针就是32位无符号数所以指针加1到底加了多少字节取决于指针指向的类型大小——这些都和“底层如何用二进制表示和解释信息”密切相关。最后分享一个我学习时的土办法遇到拿不准的short取值范围问题直接写一个最小程序打印出short的size、范围边界、溢出结果亲手看一遍输出比背十遍定义都管用。语言标准和计算机组成原理的真正价值从来不是让你记住几条规则而是让你在代码和硬件之间建立起一条“看得见”的思维通道。这条路走通了很多问题就不再需要问别人了。
返回列表