CSAPP(lecture 3)

整数加法与溢出

在前面学习整数的二进制表示时,我们已经知道,无符号整数和补码整数都受到位宽的限制。

对于 www 位整数,无符号数的表示范围为 0,2w−10,2\^w-10,2w−1,补码整数的表示范围为 −2w−1,2w−1−1-2\^{w-1},2\^{w-1}-1−2w−1,2w−1−1。那么,当两个整数相加的结果超出表示范围时,计算机应该如何处理?

两个 www 位整数相加,数学上的精确结果最多需要 w+1w+1w+1 位表示,而固定宽度的整数加法只保留低 www 位,超出的最高进位被丢弃。这一过程在数学上等价于对 2w2^w2w 取模,也是整数加法发生回绕的根本原因。

例如,使用 4 位无符号整数计算 13+513+513+5:

text 复制代码
    1101    (13)
  + 0101    (5)
  -------
  1 0010    (18)
    0010    (2)

数学结果为 18,但只保留低 4 位后得到 0010,即 18 mod 16=218\bmod16=218mod16=2。这种运算结果超过最大值后重新从 0 开始的现象称为回绕(Wrap Around) 。

因此,www 位无符号整数加法可以表示为:

UAddw(u,v)=(u+v) mod 2w UAdd_w(u,v)=(u+v)\bmod 2^w UAddw(u,v)=(u+v)mod2w

这里需要注意,计算机并不是先计算出完整结果,再额外执行一次取模运算,而是固定宽度加法器只保留低 www 位,其结果天然符合模 2w2^w2w 的数学规律 。

由于两个 www 位无符号整数的最大值都是 2w−12^w-12w−1,因此它们相加的最大结果为:

(2w−1)+(2w−1)=2w+1−2 (2^w-1)+(2^w-1)=2^{w+1}-2 (2w−1)+(2w−1)=2w+1−2

这意味着两个 www 位无符号整数相加,其数学结果始终小于 2w+12^{w+1}2w+1,所以最多只会回绕一次。由此可以进一步得到:

UAddw(u,v)={u+v,u+v<2wu+v−2w,u+v≥2w UAdd_w(u,v)= \begin{cases} u+v, & u+v<2^w\\ u+v-2^w, & u+v\ge 2^w \end{cases} UAddw(u,v)={u+v,u+v−2w,u+v<2wu+v≥2w

如果把普通数学加法的结果看作一个随着操作数增大而不断上升的平面,那么固定宽度无符号加法的结果会在达到 2w2^w2w 时突然回到 0,随后继续增长。这说明无符号加法并不是无限增长的普通整数加法,而是在模 2w2^w2w 意义下进行的运算。

既然无符号整数的加法规则已经明确,那么有符号整数又该如何处理?特别是负数 参与运算时,是否需要专门设计另一套加法电路?

实际上,这正是补码表示的重要优势:补码加法(TAdd)与无符号加法(UAdd)具有完全相同的位级运算行为。 两者都按照普通二进制加法计算,并保留低 www 位,区别仅在于如何解释最终得到的位模式。

例如,使用 4 位整数计算 5+45+45+4:

text 复制代码
    0101    (5)
  + 0100    (4)
  -------
    1001

如果按照无符号整数解释,1001 表示 9;但按照补码解释,由于最高位具有负权重,其数值为:

−23+20=−7 -2^3+2^0=-7 −23+20=−7

因此,同样的二进制加法得到了相同的位模式,却因为解释方式不同,产生了不同的数值结果。

对于没有超出表示范围的运算,补码能够直接得到正确的数学结果。例如,4 位补码下计算 −5+3-5+3−5+3:

text 复制代码
    1011    (-5)
  + 0011    (+3)
  -------
    1110    (-2)

整个过程不需要单独判断正负号,也不需要对负数采用特殊的加法规则。这使得处理器能够使用同一套基本加法电路完成有符号和无符号整数的加法。

但如果数学结果超出了补码表示范围,最终位模式仍然按照补码解释,就可能出现两个正数相加得到负数,或者两个负数相加得到正数的现象。

对于 www 位补码整数,其表示范围为:

Tmin=−2w−1,Tmax=2w−1−1 Tmin=-2^{w-1},\qquad Tmax=2^{w-1}-1 Tmin=−2w−1,Tmax=2w−1−1

当数学结果超过 TmaxTmaxTmax 时,称为正溢出(Positive Overflow) ;当数学结果小于 TminTminTmin 时,称为负溢出(Negative Overflow)。

例如,4 位补码只能表示 −8,7-8,7−8,7,计算 7+57+57+5:

text 复制代码
    0111    (+7)
  + 0101    (+5)
  -------
    1100    (-4)

数学结果为 12,超过 Tmax=7Tmax=7Tmax=7,而 1100 按补码解释为 -4。由于保留低 4 位等价于模 16 运算,因此:

12−16=−4 12-16=-4 12−16=−4

同理,计算 −6+(−3)-6+(-3)−6+(−3):

text 复制代码
    1010    (-6)
  + 1101    (-3)
  -------
  1 0111
    0111    (+7)

数学结果为 -9,小于 Tmin=−8Tmin=-8Tmin=−8,最终的低 4 位为 0111,按补码解释为 +7:

−9+16=7 -9+16=7 −9+16=7

可以发现,补码溢出仍然遵循固定宽度模运算的规律,只不过需要将保留下来的二进制位重新解释为有符号整数。

设数学上的精确和为 s=x+ys=x+ys=x+y,则 www 位补码加法的结果可以统一表示为:

TAddw(x,y)={s−2w,s>Tmaxs+2w,s<Tmins,Tmin≤s≤Tmax TAdd_w(x,y)= \begin{cases} s-2^w, & s>Tmax\\ s+2^w, & s<Tmin\\ s, & Tmin\le s\le Tmax \end{cases} TAddw(x,y)=⎩ ⎨ ⎧s−2w,s+2w,s,s>Tmaxs<TminTmin≤s≤Tmax

由于两个 www 位补码整数相加的数学结果满足:

−2w≤x+y≤2w−2 -2^w\le x+y\le 2^w-2 −2w≤x+y≤2w−2

因此,结果最多只会发生一次回绕 。正溢出需要减去一次 2w2^w2w,负溢出则需要加上一次 2w2^w2w,使结果重新落入补码的表示范围。

由此还可以得到一个重要性质:两个同号整数相加才可能发生有符号溢出,而异号整数相加不会发生加法溢出。

原因在于,当 x>0x>0x>0、y<0y<0y<0 时:

y<x+y<x y<x+y<x y<x+y<x

其数学结果始终位于两个操作数之间,不可能超出原本的表示范围。两个操作数符号相同时,则可能出现以下情况:

  • 两个非负数相加,结果变为负数,说明发生正溢出。
  • 两个负数相加,结果变为非负数,说明发生负溢出。

需要注意,这些规律描述的是固定宽度补码加法模型,而实际 C/C++ 语言对有符号和无符号整数的溢出有不同规定。

对于无符号整数,C/C++ 明确规定采用模 2w2^w2w 运算,因此回绕是完全合法且可预测的行为:

c 复制代码
unsigned int x = UINT_MAX;
unsigned int y = x + 1;  // 结果保证为 0

但对于有符号整数,一旦数学结果超出类型的表示范围,就会产生未定义行为(Undefined Behavior):

c 复制代码
int x = INT_MAX;
int y = x + 1;  // 有符号整数溢出,未定义行为

即使底层处理器通常使用固定宽度补码运算,编译器也不保证程序一定得到回绕结果。因此,不能依赖溢出后的数值判断是否发生了溢出,而应该在执行运算之前检查边界。

例如,可以利用整数表示范围推导出一个不会发生溢出的检测函数:

c 复制代码
#include <limits.h>
#include <stdbool.h>

bool add_overflow(int x, int y)
{
    if (y > 0 && x > INT_MAX - y)
        return true;

    if (y < 0 && x < INT_MIN - y)
        return true;

    return false;
}

当 y>0y>0y>0 时,发生正溢出的条件为:

x+y>INT_MAX x+y>INT\_MAX x+y>INT_MAX

移项后得到:

x>INT_MAX−y x>INT\_MAX-y x>INT_MAX−y

因此,可以在执行加法之前,通过比较判断是否会超过上界。负溢出的检测同理。

整数溢出不仅影响数值计算,还可能引发实际的内存安全问题。例如,在 Linux 网络程序中,根据报文负载长度和协议头长度申请缓冲区:

c 复制代码
size_t total = payload_len + header_len;
void *buffer = malloc(total);

由于size_t是无符号整数,如果两者相加超过 SIZE_MAX,就会按照模运算发生回绕,导致 total 小于实际需要的大小。后续如果仍然按照原始长度写入数据,就可能越过缓冲区边界。

因此,计算前应该检查:

c 复制代码
if (payload_len > SIZE_MAX - header_len) {
    // 长度溢出
    return;
}

size_t total = payload_len + header_len;
void *buffer = malloc(total);

这也说明,整数加法的关键不仅在于理解二进制如何计算,更在于区分数学上的精确结果、固定位宽下的位级结果,以及 C/C++ 语言对溢出的具体规定。在网络协议解析、内存分配和底层系统开发中,这种区别直接关系到程序的正确性与安全性。

整数乘法与截断

前面已经知道,两个 www 位整数相加,数学结果最多需要 w+1w+1w+1 位,因此固定宽度加法会丢弃超出位宽的最高进位。那么对于乘法,结果需要多少位才能完整表示?

以无符号整数为例,两个 www 位整数的最大乘积为:

(2w−1)2=22w−2w+1+1 (2^w-1)^2=2^{2w}-2^{w+1}+1 (2w−1)2=22w−2w+1+1

因此,两个 www 位整数相乘,精确结果最多需要 2w2w2w 位才能表示。

例如,两个 323232 位整数相乘,数学结果最多需要 646464 位。如果仍然只保留低 323232 位,就会丢弃乘积的高 323232 位。

这与整数加法的处理方式完全一致:固定宽度整数乘法只保留低 www 位,数学上仍然等价于对 2w2^w2w 取模。

UMultw(u,v)=(u×v) mod 2w UMult_w(u,v)=(u\times v)\bmod 2^w UMultw(u,v)=(u×v)mod2w

例如,使用 444 位无符号整数计算 5×55\times55×5:

text 复制代码
  0101 × 0101 = 0001 1001
                └──┘ └──┘
                高4位 低4位

数学结果为 252525,完整二进制表示为 000110010001100100011001。丢弃高 444 位后,得到 100110011001,即:

25 mod 16=9 25\bmod16=9 25mod16=9

这里与加法存在一个区别:两个 www 位整数相加最多回绕一次,但乘法的结果可能远大于 2w2^w2w,因此不能简单地通过减去一次 2w2^w2w 得到结果,而是需要按照模 2w2^w2w 计算 。

那么,补码整数参与乘法时,是否也能直接使用无符号乘法的低位结果?

答案是可以的。有符号乘法和无符号乘法的完整乘积可能不同,但它们的低 www 位始终相同。

原因在于,同一组 www 位二进制,无符号解释与补码解释之间只可能相差 2w2^w2w。例如,444 位二进制 110111011101 和 111011101110:

二进制 无符号解释 补码解释
110111011101 131313 −3-3−3
111011101110 141414 −2-2−2

按照无符号整数计算:

13×14=182 13\times14=182 13×14=182

将其转换成二进制:

182=101101102 182=10110110_2 182=101101102

只保留低 444 位:

101101102⟶01102=6 10110110_2\longrightarrow0110_2=6 101101102⟶01102=6

而按照补码整数计算:

(−3)×(−2)=6 (-3)\times(-2)=6 (−3)×(−2)=6

最终同样得到 011001100110。因此,尽管两种解释对应的数学乘积分别为 182182182 和 666,其低 444 位却完全一致。

这个现象并不是巧合。对于任意 www 位位模式 XXX,设其无符号解释为 uuu、补码解释为 xxx,那么必然存在整数 kkk,使得:

u=x+k2w u=x+k2^w u=x+k2w

同理,对于另一个操作数:

v=y+m2w v=y+m2^w v=y+m2w

将两者相乘:

uv=(x+k2w)(y+m2w) uv=(x+k2^w)(y+m2^w) uv=(x+k2w)(y+m2w)

展开得到:

uv=xy+(xm+yk)2w+km22w uv=xy+(xm+yk)2^w+km2^{2w} uv=xy+(xm+yk)2w+km22w

可以发现,除 xyxyxy 外,其余项全部是 2w2^w2w 的整数倍,因此:

uv≡xy(mod2w) \boxed{uv\equiv xy\pmod{2^w}} uv≡xy(mod2w)

这就从数学上证明了:同样的两组 www 位二进制,无论按照无符号整数还是补码整数进行乘法,截断后的低 www 位必然相同。

但是,如果希望得到完整的 2w2w2w 位乘积,有符号与无符号乘法就不能简单地视为相同运算。因为负数需要进行符号扩展 ,而无符号整数需要进行零扩展,两者的乘积高位可能不同。

例如,仍然使用 444 位整数:

(−3)×(−2)=6 (-3)\times(-2)=6 (−3)×(−2)=6

其 888 位补码结果为:

000001102 00000110_2 000001102

但如果将原始位模式按无符号数解释:

13×14=182=101101102 13\times14=182=10110110_2 13×14=182=101101102

两者的低 444 位都是 011001100110,但高 444 位分别为 000000000000 和 101110111011。

因此,处理器可以复用乘法器的低位计算逻辑,但在需要完整乘积时,仍然需要根据操作数的有符号属性正确处理高位。

进一步来看,www 位补码整数的最小值为 −2w−1-2^{w-1}−2w−1,因此其最大正乘积实际上可能达到:

Tmin2=(−2w−1)2=22w−2 Tmin^2=(-2^{w-1})^2=2^{2w-2} Tmin2=(−2w−1)2=22w−2

这个数需要 2w2w2w 位有符号整数才能表示,因为 2w−12w-12w−1 位补码的最大值只有 22w−2−12^{2w-2}-122w−2−1。而除这一特殊最大正乘积外,其余两个 www 位补码整数的乘积都可以在 2w−12w-12w−1 位补码中表示。

这也说明,如果想完整保存两个整数的乘积,通常需要在计算之前扩大运算位宽,而不是等乘法完成后再转换结果类型。

例如:

c 复制代码
int x = 100000;
int y = 100000;

long long result = x * y;

这里虽然使用了 long long 保存结果,但表达式 x * y 的两个操作数都是 int,所以乘法仍然先按照 int 类型执行。数学结果为 101010^{10}1010,超出通常的 323232 位有符号整数范围,因此在这种平台上会发生未定义行为。

正确做法是提前转换操作数类型:

c 复制代码
long long result = (long long)x * y;

此时乘法按照longlonglong longlonglong类型进行,能够正确保存 101010^{10}1010。不过,如果扩大后的类型仍然不足以表示结果,依旧可能溢出;需要更大范围的整数运算时,就必须使用更宽的类型或任意精度整数库。

理解整数乘法的截断性质后,还可以进一步解释为什么编译器经常使用移位指令替代乘法 。

对于二进制整数,左移一位相当于将原来每一位的权重扩大 222 倍,因此左移 kkk 位在位级运算上等价于乘以 2k2^k2k:

x≪k≡x×2k(mod2w) x\ll k\equiv x\times2^k\pmod{2^w} x≪k≡x×2k(mod2w)

例如:

text 复制代码
00000101    (5)
   << 3
--------
00101000    (40)

因此,乘以 888 可以通过左移 333 位完成:

8x=x≪3 8x=x\ll3 8x=x≪3

更进一步,某些常数乘法可以分解为多个 222 的幂,再通过移位与加减法完成。例如:

24x=32x−8x 24x=32x-8x 24x=32x−8x

在固定宽度模运算模型下,对应:

c 复制代码
(x << 5) - (x << 3)

这种优化称为强度削减(Strength Reduction),即使用执行成本较低的操作替换成本较高的操作。

早期处理器的整数乘法通常比移位慢得多,因此这种优化十分常见。现代处理器的乘法指令已经明显加快,是否替换则取决于具体指令延迟、吞吐量和目标架构,编译器会根据成本模型自行选择。

不过,数学上的等价关系不代表所有 C/C++ 表达式都能无条件互换。对于无符号整数,只要移位次数合法,左移具有明确的模 2w2^w2w 语义;对于有符号整数,左移的具体规则还受到语言版本及可表示范围等因素影响,不能直接依赖可能溢出的表达式。

因此,在编写普通 C/C++ 程序时,通常应优先表达真实的数学意图,例如直接写常数乘法,让编译器决定是否使用移位优化;只有在处理位掩码、协议字段或明确需要模运算语义时,才有必要主动使用位运算。

整数除法,移位与无符号运算的陷阱

在二进制中,左移 kkk 位相当于将每一位的权重扩大 2k2^k2k 倍,因此可以用来实现乘以 2k2^k2k。反过来,右移 kkk 位则相当于将每一位的权重缩小 2k2^k2k 倍,似乎就能实现除法。

但整数除法存在一个额外的问题:当结果不是整数时,应该如何舍入?

对于无符号整数,答案比较简单。逻辑右移 会丢弃最低的 kkk 位,并在高位补零,因此相当于除以 2k2^k2k 后向下取整:

u≫k=⌊u2k⌋ \boxed{u\gg k=\left\lfloor\frac{u}{2^k}\right\rfloor} u≫k=⌊2ku⌋

例如,计算 13/413/413/4:

text 复制代码
13 = 00001101

逻辑右移 2 位:

00001101 >> 2 = 00000011

数学结果为 3.253.253.25,而右移得到 333,恰好是向下取整的结果。

从二进制权重来看,右移实际上是将二进制小数点向左移动 kkk 位,再丢弃小数部分。例如:

11012÷22=11.012 1101_2\div 2^2=11.01_2 11012÷22=11.012

保留整数部分,就得到:

112=3 11_2=3 112=3

因此,无符号整数除以 2k2^k2k 时,可以直接通过逻辑右移完成。对于非负整数,向下取整与 C/C++ 整数除法的向零截断恰好一致。

但如果操作数是负数,情况就不同了。因为负数的算术右移通常会在高位补充符号位,以保持数值的符号,这称为算术右移(Arithmetic Right Shift)。

在通常采用算术右移的补码机器上,负数右移 kkk 位相当于:

x≫k=⌊x2k⌋ x\gg k=\left\lfloor\frac{x}{2^k}\right\rfloor x≫k=⌊2kx⌋

例如:

−9÷4=−2.25 -9\div4=-2.25 −9÷4=−2.25

算术右移按照向下取整处理:

−9≫2=−3 -9\gg2=-3 −9≫2=−3

但 C/C++ 的有符号整数除法采用向零截断(Truncation Toward Zero):

−9/4=−2 -9/4=-2 −9/4=−2

因此:

−9≫2≠−9/4 \boxed{-9\gg2\ne-9/4} −9≫2=−9/4

这里的差异不是移位计算错误,而是两种运算的舍入方向不同:

  • 向下取整:选择不大于真实结果的最大整数,例如 −2.25→−3-2.25\rightarrow-3−2.25→−3。
  • 向零截断:直接舍弃小数部分,例如 −2.25→−2-2.25\rightarrow-2−2.25→−2。

对于非负数,这两种舍入方式结果相同;对于不能整除的负数,结果则相差 111。

需要注意,C++20 起,负数的有符号右移被规定为向负无穷方向取整;在较早的 C/C++ 标准中,负数右移结果依赖具体实现,不能无条件假定使用算术右移。

既然算术右移和整数除法的区别只在于舍入方向,那么能否在右移之前对负数进行适当调整,使其得到向零截断的结果?

答案是对负数加上偏置量(Bias)。

当 x<0x<0x<0 时,先加上 2k−12^k-12k−1,再执行算术右移:

trunc⁡(x2k)=⌊x+2k−12k⌋ \boxed{\operatorname{trunc}\left(\frac{x}{2^k}\right) =\left\lfloor\frac{x+2^k-1}{2^k}\right\rfloor} trunc(2kx)=⌊2kx+2k−1⌋

例如,计算 −9/4-9/4−9/4,其中 k=2k=2k=2,偏置量为:

22−1=3 2^2-1=3 22−1=3

因此:

(−9+3)≫2=−6≫2=−2 (-9+3)\gg2=-6\gg2=-2 (−9+3)≫2=−6≫2=−2

正好得到向零截断的结果。

为什么偏置量是 2k−12^k-12k−1,而不是直接加上 2k2^k2k?

设负数 xxx 可以表示为:

x=−q2k−r,0≤r<2k x=-q2^k-r,\qquad 0\le r<2^k x=−q2k−r,0≤r<2k

其中 qqq 和 rrr 均为非负整数。

当 r=0r=0r=0 时,说明能够整除,不需要额外调整;当 r>0r>0r>0 时,数学商为 −q−r/2k-q-r/2^k−q−r/2k,向零截断应该得到 −q-q−q,但直接向下取整会得到 −q−1-q-1−q−1。

加入偏置量后:

x+2k−1=−q2k+(2k−1−r) x+2^k-1=-q2^k+(2^k-1-r) x+2k−1=−q2k+(2k−1−r)

由于:

0≤2k−1−r<2k 0\le2^k-1-r<2^k 0≤2k−1−r<2k

右移 kkk 位后恰好得到 −q-q−q。

因此,偏置量不仅修正了负数的舍入方向,也不会改变原本可以整除时的结果。

实际编译器可以利用这一性质优化有符号整数除以 2k2^k2k 的运算。不过,这里的推导描述的是算术右移模型,不能直接忽略 C/C++ 对移位次数、语言版本以及特殊溢出情况的限制。

对于整数除法,另一个值得注意的边界情况是:

Tmin/(−1) Tmin/(-1) Tmin/(−1)

由于补码整数的负数范围比正数多一个值 ,−Tmin-Tmin−Tmin 无法在相同宽度的有符号类型中表示,因此这次除法同样会产生有符号整数溢出,在 C/C++ 中属于未定义行为。

至此,可以发现整数乘法与除法虽然都能利用移位优化,但两者并不完全对称:左移的核心问题是结果是否超出固定位宽,而右移除了位模式变化,还必须考虑整数除法的舍入方向。

在掌握这些基本运算规则后,还需要重新审视一个之前接触过的问题:有符号整数和无符号整数虽然可以共用位级运算,但混合使用时容易产生隐蔽错误。

例如:

c 复制代码
unsigned int i;

for (i = 10; i >= 0; i--) {
    // ...
}

这段代码看起来是从 101010 递减到 000,但实际上无法通过条件退出循环。

原因在于,iii 是无符号整数,其取值永远不小于 000。当 i=0i=0i=0 时,继续执行减一操作,并不会得到 −1-1−1,而是按照模 2w2^w2w 回绕到:

UMax=2w−1 UMax=2^w-1 UMax=2w−1

因此,循环条件 i≥0i\ge0i≥0 始终成立。

如果确实需要使用无符号下标倒序遍历,更直接的写法是:

c 复制代码
for (size_t i = cnt; i > 0; ) {
    --i;
    // 使用 a[i]
}

这里通过判断 i>0i>0i>0,保证递减不会越过 000;同时应确保 cntcntcnt 不超过数组实际长度。

无符号整数还存在另一个容易忽略的问题:有符号与无符号操作数混合运算时,表达式可能被转换为无符号类型。

例如:

c 复制代码
#define DELTA sizeof(int)

int i = 10;

if (i - DELTA >= 0) {
    // ...
}

由于 sizeofsizeofsizeof 返回的类型是无符号 整数类型size_t,因此表达式 i−DELTAi-DELTAi−DELTA 并不一定按照有符号整数运算。

在常见的 646464 位 Linux 环境中,size_t是 646464 位无符号类型,而 intintint 是 323232 位有符号类型。因此,iii 会转换为无符号类型参与减法。

当 iii 小于 DELTADELTADELTA 时,结果不是预期的负数,而是发生无符号回绕,得到一个很大的正数,使后面的比较仍然可能成立。

例如,假设 i=2i=2i=2、DELTA=4DELTA=4DELTA=4,在 646464 位无符号运算下:

2−4≡264−2(mod264) 2-4\equiv2^{64}-2\pmod{2^{64}} 2−4≡264−2(mod264)

结果显然大于 000。

因此,使用无符号类型并不意味着程序一定更安全。它适合表示非负范围内的长度、大小、位掩码,以及需要明确模运算语义的数据;但当程序涉及递减、减法、负数哨兵值或者混合类型比较时,必须特别注意转换规则。

对于数组下标和对象大小,通常使用 size_t;但即使使用 size_t,也仍然需要保证减法不会产生意外回绕。类型选择应当服从数据的实际含义,而不是简单地认为无符号类型一定优于有符号类型。

无符号整数必要性

前面已经知道,无符号整数采用模 2w2^w2w 运算,因此当一个无符号整数从 000 继续减去 111 时,并不会得到 −1-1−1,而是回绕到最大值 UMax=2w−1UMax=2^w-1UMax=2w−1。这使得使用无符号整数进行倒序遍历时,不能直接使用 i≥0i\ge0i≥0 作为循环条件,否则条件始终成立,最终可能访问数组边界之外的内存。

那么,是否意味着我们应该彻底避免使用无符号整数?

其实没有必要。既然无符号回绕是 C 语言标准明确保证的行为,我们不仅可以避免它带来的错误,还可以主动利用这种性质设计正确的循环终止条件。

例如,假设数组包含 cntcntcnt 个元素,需要从倒数第二个元素开始,将后一个元素累加到当前元素:

c 复制代码
unsigned i;

for (i = cnt - 2; i < cnt; i--)
    a[i] += a[i + 1];

这里假设 cntcntcnt 是同类型的无符号整数,并且满足 2≤cnt2\le cnt2≤cnt。

与之前使用 i≥0i\ge0i≥0 不同,这次循环条件变成了 i<cnti<cnti<cnt。

为什么一个不断减小的变量,反而能够利用小于关系结束循环?

假设 cnt=5cnt=5cnt=5,那么循环过程为:

text 复制代码
i = 3  →  3 < 5  →  执行
i = 2  →  2 < 5  →  执行
i = 1  →  1 < 5  →  执行
i = 0  →  0 < 5  →  执行

i = 0 - 1
      ↓
i = UMax
      ↓
UMax < 5 为 false,循环结束

也就是说,这次不再试图避免无符号回绕,而是利用回绕后的最大值让循环条件变为假。

不过,使用 unsigned int 仍然存在一个限制:在常见的 646464 位平台上,unsigned int 通常只有 323232 位,而数组长度可能使用 646464 位表示。因此,更适合使用专门表示对象大小和数组长度的无符号整数类型 size_t:

c 复制代码
size_t i;

for (i = cnt - 2; i < cnt; i--)
    a[i] += a[i + 1];

size_t 是 sizeof 运算符返回值的类型,能够表示实现所支持的对象大小。在常见的 646464 位 Linux 平台上,它通常占用 646464 位,但具体宽度由平台决定。

值得注意的是,size_t 并不能自动消除无符号整数的所有问题。如果 cnt<2cnt<2cnt<2,表达式 cnt−2cnt-2cnt−2 就可能发生无符号回绕;如果 cntcntcnt 是负数类型,参与无符号运算或比较时,还可能被转换为一个很大的正数。

因此,上面的循环必须保证 cntcntcnt 是有效的数组长度,并且至少为 222。

对于一般的倒序数组遍历,还可以采用更直观的写法:

c 复制代码
for (size_t i = cnt; i > 0; ) {
    --i;
    // 处理 a[i]
}

这种写法在递减之前检查 i>0i>0i>0,不需要依靠无符号回绕来退出循环,也能正确处理空数组。

由此可见,无符号整数本身并不危险,真正的问题在于把它当成普通有符号整数使用,却忽略了它的模运算性质和隐式类型转换规则。

既然无符号整数的回绕行为是确定的,那么在某些场景下,它反而比有符号整数更适合。

首先是模运算(Modular Arithmetic) 。当算法本身就需要在固定范围内循环计算时,无符号整数能够直接提供模 2w2^w2w 的运算语义。例如,多精度整数运算可以把一个大整数拆分成多个固定宽度的数据块,每个数据块保存部分二进制位,再通过加法、进位和借位完成完整运算。

对于 www 位的数据块,可以利用:

s=a+b+c s=a+b+c s=a+b+c

其中 ccc 表示来自低位数据块的进位。当前数据块保留:

r=s mod 2w r=s\bmod2^w r=smod2w

而超出的部分则作为进位传递给下一个数据块。

实际实现时,通常使用更宽的整数类型保存中间结果,或者通过比较运算检测进位。无符号整数的模运算性质使这种处理更加明确,不需要依赖有符号溢出的未定义行为。

另一个重要用途是位集合(Bit Set)。此时二进制位不再用于表示普通数值,而是表示一组独立的状态或集合元素。

例如,假设使用一个 888 位无符号整数表示设备支持的功能:

text 复制代码
0000 0101

其中每一位代表一个独立功能,值为 111 表示启用,值为 000 表示关闭。这样就可以通过位运算完成集合操作:

  • 按位与 &:求两个集合的交集。
  • 按位或 |:求两个集合的并集。
  • 按位异或 ^:求两个集合的对称差。
  • 按位取反 ~:对指定位宽内的集合求补集。

例如:

c 复制代码
unsigned int flags = 0x05;

// 开启第 3 位
flags |= (1U << 3);

// 清除第 0 位
flags &= ~(1U << 0);

// 检查第 2 位
if (flags & (1U << 2)) {
    // 对应功能已启用
}

这里使用无符号整数还有一个优势:无符号整数右移采用逻辑右移,高位始终补零,不需要考虑负数符号扩展。

因此,当我们希望将某个整数视为一组二进制位,而不是具有正负含义的数学整数时,无符号类型通常更加合适。

这也说明,选择有符号还是无符号整数,并不存在绝对统一的答案。普通数值计算如果可能涉及负数,使用有符号整数通常更加自然;对象大小、数组长度、位掩码以及需要明确模运算语义的数据,则适合使用无符号整数。

关键在于理解其运算规则,特别是无符号回绕、隐式类型转换以及不同整数类型之间的比较和算术运算。这些问题在正常数据范围内可能不容易暴露,但一旦遇到边界条件,就可能导致循环无法结束、数组越界或者错误的内存分配。

数据在内存中的表示

前面我们已经学习了整数的二进制表示和运算规则,但还有一个问题尚未解决:计算机虽然知道如何使用二进制表示一个整数,那么这些二进制位究竟是如何存储在内存中的?

首先需要明确,现代计算机通常采用按字节寻址(Byte Addressing),即内存中的每个字节都有一个独立地址,而不是每个二进制位都有独立地址。

因此,可以在逻辑上将内存理解为一个巨大的字节数组 ,其中每个元素占用 111 字节,地址相当于数组下标,指针则用于保存某个对象的内存地址。

text 复制代码
内存地址      存储内容
0x1000        1 Byte
0x1001        1 Byte
0x1002        1 Byte
0x1003        1 Byte
0x1004        1 Byte
...

当然,实际内存并不是一个简单的连续数组。操作系统和硬件通过虚拟内存机制,为每个进程提供独立的虚拟地址空间,并将虚拟地址映射到实际的物理内存。这样,程序就可以将内存视为连续的地址空间,而不必直接管理物理内存的位置。

通常情况下,每个进程只能访问自身被允许访问的虚拟地址,不能直接通过普通指针访问其他进程的私有内存。这也是后续学习进程隔离、页表和虚拟内存的基础。

不过,计算机处理的数据通常不止 111 字节。例如,一个 323232 位整数需要 444 字节,一个 646464 位整数需要 888 字节,因此多个连续的字节通常会被组合起来,作为一个完整的数据对象使用。

这里就涉及**机器字长(Word Size)**的概念。字长通常用于描述计算机体系结构中自然处理的数据宽度,并与寄存器、整数运算及地址表示能力密切相关。例如,我们通常将支持 646464 位通用整数运算、采用 646464 位指针的体系结构称为 646464 位体系结构。

但需要注意,机器字长并不意味着所有数据类型都具有相同的位宽 。例如,在常见的 646464 位 Linux 环境中:

数据类型 常见大小
char\mathrm{char}char 111 Byte
short\mathrm{short}short 222 Bytes
int\mathrm{int}int 444 Bytes
long\mathrm{long}long 888 Bytes
long long\mathrm{long\ long}long long 888 Bytes
float\mathrm{float}float 444 Bytes
double\mathrm{double}double 888 Bytes
指针 888 Bytes

这些大小取决于平台的数据模型和 ABI,并不是 C 语言对所有平台的统一规定。例如,646464 位 Windows 通常采用不同的数据模型,其中 long\mathrm{long}long 仍然是 444 字节。

对于一个长度为 nnn 字节的数据对象,如果其起始地址为 AAA,那么它通常占据从 AAA 到 A+n−1A+n-1A+n−1 的连续字节地址。因此,一个 444 字节对象的起始地址如果为 0x10000x10000x1000,就会占据 0x10000x10000x1000~0x10030x10030x1003;下一个紧邻的 444 字节对象可以从 0x10040x10040x1004 开始。

需要区分的是,连续对象是否紧邻还受到对齐和填充规则的影响,不能认为程序中定义的所有变量都会按照声明顺序紧密排列。

机器字长还会影响理论上的地址表示范围。假设地址宽度为 www 位,并且每个地址对应 111 字节,那么理论上最多可以表示 2w2^w2w 个不同的字节地址。

例如:

232 Bytes=4 GiB 2^{32}\ \text{Bytes}=4\ \text{GiB} 232 Bytes=4 GiB

264 Bytes=16 EiB 2^{64}\ \text{Bytes}=16\ \text{EiB} 264 Bytes=16 EiB

但指针宽度不等于实际可用的虚拟内存大小,更不等于物理内存容量 。处理器可能只实现部分地址位,操作系统也会限制进程实际可访问的地址范围。因此,646464 位指针只是地址表示能力的一部分,不能据此认为程序真的拥有 16 EiB16\ \text{EiB}16 EiB 的可用内存。

现在我们知道了一个整数会占据多个连续字节,但又出现了一个问题:这些字节应该按照什么顺序存放?

例如,假设一个 323232 位整数的数值为:

x=0x01234567 x=\texttt{0x01234567} x=0x01234567

它由 444 个字节组成:

01⏟最高有效字节234567⏟最低有效字节 \underbrace{\texttt{01}}{\text{最高有效字节}}\quad \texttt{23}\quad \texttt{45}\quad \underbrace{\texttt{67}}{\text{最低有效字节}} 最高有效字节 012345最低有效字节 67

如果变量 xxx 的起始地址为 0x1000x1000x100,那么这 444 个字节究竟应该怎样排列?

计算机体系结构中主要存在两种约定:

  • 大端序(Big Endian): 最高有效字节存放在最低内存地址。
  • 小端序(Little Endian): 最低有效字节存放在最低内存地址。

对于 0x01234567\texttt{0x01234567}0x01234567:

text 复制代码
内存地址:    0x100   0x101   0x102   0x103

大端存储:      01      23      45      67

小端存储:      67      45      23      01

这里的"高位"和"低位"指的是字节在整数数值中的权重,而"高地址"和"低地址"指的是内存位置,两者不是同一个概念。

大端和小端只改变多字节数据在内存中的排列方式,不会改变整数本身的数值,也不会颠倒每个字节内部的二进制位。

因此,虽然两台机器可能使用不同的字节序,但只要按照各自的体系结构规则读取完整整数,都可以正确得到 0x01234567\texttt{0x01234567}0x01234567。

为什么需要学习大小端?因为当数据只在同一台机器内部计算时,字节序通常由硬件和编译器处理;但当程序需要直接检查内存、交换二进制数据,或者进行网络通信时,字节序就可能影响数据的正确解析。

例如,x86x86x86 和常见的 x86-64x86\text{-}64x86-64 系统采用小端序,而许多 ARM 处理器具有可配置的字节序能力,常见的 ARM Linux 系统也通常采用小端序。

为了让不同体系结构的计算机能够正确交换数据,网络协议通常会明确规定多字节字段的编码顺序。例如,TCP/IP 协议中的多字节整数通常使用网络字节序(Network Byte Order),即大端序。

因此,在 Linux 网络编程中,经常会使用以下函数:

c 复制代码
uint16_t port = 8080;
uint16_t network_port = htons(port);

uint32_t value = 0x12345678;
uint32_t network_value = htonl(value);

其中,htons\mathrm{htons}htons 和 htonl\mathrm{htonl}htonl 分别用于将主机字节序的 161616 位、323232 位整数转换为网络字节序,对应的 ntohs\mathrm{ntohs}ntohs 和 ntohl\mathrm{ntohl}ntohl 则用于反向转换。

这些函数的意义并不是单纯地"交换字节",而是根据主机实际采用的字节序,保证数据符合网络协议规定的表示方式。

进一步来看,大小端差异只影响多字节数据的存储顺序,对于负数也同样成立。例如,假设 int\mathrm{int}int 为 323232 位,整数 −15213-15213−15213 的补码表示为:

0xFFFFC493 \texttt{0xFFFFC493} 0xFFFFC493

那么它在不同字节序中的内存布局为:

text 复制代码
内存地址从低到高 →

大端:FF FF C4 93

小端:93 C4 FF FF

注意,小端序并不会改变补码的计算规则,它只是改变构成这个整数的字节在内存中的排列顺序。

既然内存中存储的只是字节,那么我们能否绕过变量的数据类型,直接观察它的原始字节表示?

答案是可以的。C 语言允许通过字符类型访问对象的底层字节表示,因此可以将对象地址转换为 unsigned char∗\mathrm{unsigned\ char*}unsigned char∗,再逐字节读取。

例如:

c 复制代码
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>

void show_bytes(const unsigned char *start, size_t len)
{
    for (size_t i = 0; i < len; i++) {
        printf("%p: %02x\n",
               (const void *)(start + i),
               (unsigned int)start[i]);
    }
}

int main(void)
{
    uint32_t x = 0x01234567U;

    show_bytes((const unsigned char *)&x, sizeof(x));

    return 0;
}

这里有三个值得注意的细节。

首先,&x\&x&x 得到变量 xxx 的地址,而将其转换为 unsigned char∗\mathrm{unsigned\ char*}unsigned char∗ 后,就可以把对象视为一个连续的字节序列。

其次,start+i\mathrm{start+i}start+i 每次向后移动 111 字节。这是因为指针运算的移动距离由所指向的数据类型决定 。对于 unsigned char∗\mathrm{unsigned\ char*}unsigned char∗,增加 111 表示向后移动一个字符大小的存储单元;而对于 int∗\mathrm{int*}int∗,增加 111 则通常向后移动 444 字节。

最后,sizeof(x)\mathrm{sizeof(x)}sizeof(x) 返回变量 xxx 所占的字节数,因此程序可以根据对象大小逐字节输出其内存表示。

如果在常见的小端 x86-64x86\text{-}64x86-64 Linux 系统中运行,并且该变量的起始地址假设为 0x10000x10000x1000,那么输出的字节值依次为:

text 复制代码
0x1000: 67
0x1001: 45
0x1002: 23
0x1003: 01

实际地址通常不会是这里假设的 0x10000x10000x1000,因为变量地址由编译器、操作系统和运行环境决定。

这里也能进一步理解指针的本质:指针变量保存的是地址值,而不是被指向对象的内容。

例如:

c 复制代码
int x = 100;
int *p = &x;

变量 xxx 保存整数 100100100,而指针 ppp 保存的是 xxx 的地址。对于常见的 646464 位程序,指针本身通常占用 888 字节,但实际保存的地址值会随着程序运行环境而改变。

即使同一程序在同一台机器上运行两次,指针值也可能不同。这与操作系统的虚拟地址空间布局及地址空间布局随机化(ASLR)等机制有关。

因此,不能假定某个变量始终位于固定地址,更不能将当前进程中的普通指针值直接发送给另一个进程,并期望对方能够通过它访问同一个对象。

那么,既然多字节整数存在大小端差异,为什么同一个字符串在不同字节序的机器上通常具有相同的字节排列?

原因在于,C 语言中的普通字符串是由连续字符组成的字节序列。以 ASCII 编码的字符串为例,每个字符对应一个独立字节,不需要将多个字节组合起来解释成同一个整数。

例如:

c 复制代码
char s[] = "18213";

其内存内容为:

text 复制代码
字符:       '1'   '8'   '2'   '1'   '3'   '\0'

十六进制:    31    38    32    31    33    00

其中,0x31\texttt{0x31}0x31 表示字符 111 的 ASCII 编码,0x38\texttt{0x38}0x38 表示字符 888 的 ASCII 编码,而最后的 0x00\texttt{0x00}0x00 是字符串结束标记,即空字符。

因此:

strlen(s)=5 \mathrm{strlen(s)}=5 strlen(s)=5

sizeof(s)=6 \mathrm{sizeof(s)}=6 sizeof(s)=6

前者计算字符串结束标记之前的字符数量,后者计算整个字符数组占用的字节数,因此包含末尾的空字符。

字符串的字符顺序由数组下标决定,不会因为主机使用大端或小端,就自动颠倒字符排列顺序。对于 ASCII 字符串,在不同字节序的机器上,其字节序列通常完全一致。

但如果字符串采用 UTF-16、UTF-32 等多字节编码,其编码单元的存储顺序仍然可能涉及字节序;因此,"字符串不受大小端影响"主要适用于这里讨论的单字节 ASCII 字符序列。

由此可以将内存中的数据表示串联起来:内存按字节寻址,数据类型决定一个对象需要多少字节,字节序决定多字节数值的排列方式,而指针用于定位这些字节所在的地址。

理解这些底层规则后,就能够解释为什么同一个整数在不同机器上的内存布局可能不同、为什么网络协议需要统一字节序,以及为什么通过不同类型的指针访问相同地址,会产生不同的读取和移动行为。

这些知识也是后续理解 C/C++ 指针、结构体内存布局、网络报文解析和虚拟内存机制的基础。

整数表达式中的陷阱(练习题)

前面我们已经学习了整数的二进制表示、补码运算、位移以及有符号和无符号整数的转换规则。这些知识单独来看并不复杂,但在实际 C 语言表达式中,它们往往会同时出现,使一些看似符合数学规律的判断产生意想不到的结果。

理解这些问题的关键在于:C 语言的整数运算并不是无限精度的数学运算,表达式的结果同时受到数据类型、表示范围、隐式转换和运算符优先级的影响。

假设有以下变量:

c 复制代码
int x = foo();
int y = bar();

unsigned ux = x;
unsigned uy = y;

其中,xxx 和 yyy 是有符号整数,uxuxux 和 uyuyuy 是无符号整数。虽然 xxx 和 uxuxux 可以具有相同的二进制位模式,但由于解释方式不同,参与比较和运算时就可能产生不同结果。

首先考虑一个最基本的数学规律:如果 x<0x<0x<0,那么 2x<02x<02x<0 是否一定成立?

数学上显然成立,但在固定宽度补码模型中,乘以 222 相当于左移一位,超出位宽的高位会被丢弃。例如,在 444 位补码中:

text 复制代码
  1000    (-8)
<<   1
------
  0000    (0)

因此,负数乘以 222 后不一定仍然是负数。对于真正的 C 语言,如果有符号乘法的数学结果超出类型的表示范围,就属于未定义行为,而不是语言保证结果回绕。

同样的道理,也使得下面两个看似显然的表达式存在问题:

x×x≥0 x\times x\ge0 x×x≥0

x>0∧y>0  ⟹  x+y>0 x>0\land y>0\implies x+y>0 x>0∧y>0⟹x+y>0

第一个表达式在数学上必然成立,但如果 xxx 的绝对值过大,平方结果可能超出 int\mathrm{int}int 的表示范围。例如,在通常的 323232 位 int\mathrm{int}int 环境中:

c 复制代码
int x = 50000;
int result = x * x;

数学结果为:

500002=2500000000 50000^2=2500000000 500002=2500000000

但它已经超过:

INT_MAX=2147483647 INT\_MAX=2147483647 INT_MAX=2147483647

因此,这次乘法产生有符号溢出,属于未定义行为。

第二个表达式也存在类似问题。当两个正整数的和超过 TmaxTmaxTmax 时,固定宽度补码结果可能回绕为负数,而 C 语言中的实际加法则产生未定义行为。

这说明,即使一个表达式在数学上恒成立,也必须先确认对应的计算过程不会发生有符号溢出。

那么,取相反数是否也存在同样的问题?

数学上,如果:

x>y x>y x>y

那么两边乘以 −1-1−1,必然得到:

−x<−y -x<-y −x<−y

但补码整数存在一个特殊值:

Tmin=−2w−1 Tmin=-2^{w-1} Tmin=−2w−1

其相反数为:

−Tmin=2w−1 -Tmin=2^{w-1} −Tmin=2w−1

这个正数无法使用相同宽度的补码表示。在固定宽度补码回绕模型中,−Tmin-Tmin−Tmin 会得到与 TminTminTmin 相同的位模式。

例如,令 x=0x=0x=0、y=Tminy=Tminy=Tmin,原本满足:

0>Tmin 0>Tmin 0>Tmin

但在补码回绕模型中,取相反数后却得到:

0<Tmin 0<Tmin 0<Tmin

显然不成立。

类似地,下面两个表达式看起来只是方向相反:

x≥0  ⟹  −x≤0 x\ge0\implies -x\le0 x≥0⟹−x≤0

x≤0  ⟹  −x≥0 x\le0\implies -x\ge0 x≤0⟹−x≥0

但它们并不完全对称。第一个表达式始终成立,因为非负整数取相反数不会超过补码的负数表示范围;第二个表达式则需要排除 x=Tminx=Tminx=Tmin 的情况。

在实际 C 语言中,−INT_MIN-INT\_MIN−INT_MIN 同样属于有符号溢出,产生未定义行为。因此,对于补码整数,取相反数并不是对所有值都能安全执行的运算,TminTminTmin 是必须单独考虑的边界情况。

除了有符号溢出,无符号整数还会通过隐式类型转换改变比较结果。

例如:

ux≥0 ux\ge0 ux≥0

这个表达式永远成立,因为 uxuxux 是无符号整数,其表示范围本来就不包含负数。

但下面的表达式却永远不成立:

ux>−1 ux>-1 ux>−1

原因是比较的两个操作数类型不同:uxuxux 为 unsigned int\mathrm{unsigned\ int}unsigned int,而常量 −1-1−1 为 int\mathrm{int}int。

在这两种相同整数转换等级的类型进行比较时,−1-1−1 会转换为 unsigned int\mathrm{unsigned\ int}unsigned int。根据模运算规则:

−1 mod 2w=2w−1=UMax -1\bmod2^w=2^w-1=UMax −1mod2w=2w−1=UMax

因此,实际比较相当于:

ux>UMax ux>UMax ux>UMax

显然不可能成立。

这也是为什么在 C/C++ 中,不能只根据表达式表面上的数值判断大小,还必须考虑操作数经过通常算术转换后使用的实际类型。

类似的问题还会出现在位移与除法之间。对于无符号整数,逻辑右移 kkk 位等价于除以 2k2^k2k 后向下取整:

ux≫k=⌊ux2k⌋ ux\gg k=\left\lfloor\frac{ux}{2^k}\right\rfloor ux≫k=⌊2kux⌋

因此:

ux≫3=ux/8 ux\gg3=ux/8 ux≫3=ux/8

始终成立,因为无符号整数除法本身就是向下取整。

但有符号整数不同。在常见的算术右移实现中,负数右移相当于向负无穷方向取整,而 C 语言的整数除法采用向零截断。

例如:

−9/8=−1 -9/8=-1 −9/8=−1

但在算术右移模型中:

−9≫3=−2 -9\gg3=-2 −9≫3=−2

因此:

x≫3=x/8 x\gg3=x/8 x≫3=x/8

并非对所有有符号整数都成立。只有当 xxx 非负,或者负数恰好能够被 888 整除时,两者才一定得到相同结果。需要注意,负数右移在 C 语言中还涉及实现定义行为,不能脱离具体语言规则和编译环境讨论。

还有一个容易忽略的表达式:

(x∣−x)≫31=−1 (x\mathbin{|}-x)\gg31=-1 (x∣−x)≫31=−1

假设 int\mathrm{int}int 为 323232 位,并采用补码及算术右移。如果 x>0x>0x>0,那么 −x<0-x<0−x<0;如果 x<0x<0x<0,则 xxx 本身为负数。因此,对于非零且能够安全取相反数的 xxx,x∣−xx|-xx∣−x 的最高位必然为 111,算术右移 313131 位后便得到 −1-1−1。

但如果 x=0x=0x=0,就有:

(0∣0)≫31=0 (0|0)\gg31=0 (0∣0)≫31=0

因此,该表达式并不是恒等于 −1-1−1;而对于 x=Tminx=Tminx=Tmin,还必须考虑取相反数产生的未定义行为。

除了数据表示和溢出,运算符优先级也是整数表达式中非常隐蔽的错误来源。

例如:

c 复制代码
x & 7 == 7

直觉上,这似乎是在判断 xxx 的低 333 位是否全部为 111。但 C 语言中,相等运算符的优先级高于按位与运算符,因此实际解析为:

c 复制代码
x & (7 == 7)

由于 7==77==77==7 的结果为 111,整个表达式实际上等价于:

c 复制代码
x & 1

也就是说,它判断的只是 xxx 的最低位是否为 111。

如果希望判断低 333 位是否全部为 111,应该明确添加括号:

c 复制代码
(x & 7) == 7

例如:

text 复制代码
x = 13 = 1101

    1101
  & 0111
  ------
    0101

结果为 555,不等于 777,因此条件不成立。

这也影响了下面这个看似可以通过位移判断符号的表达式:

c 复制代码
x & 7 == 7  =>  (x << 30) < 0

由于左边实际只检查最低位,令 x=1x=1x=1 时,左边条件成立,但:

1≪30=230>0 1\ll30=2^{30}>0 1≪30=230>0

因此,右边条件不成立。

即使修正为:

c 复制代码
(x & 7) == 7

也不能直接认为右边的有符号左移始终合法,因为将某些有符号整数左移 303030 位可能超出表示范围,产生未定义行为。

类似的优先级陷阱还会出现在下面的表达式中:

c 复制代码
x & (x - 1) != 0

它实际被解析为:

c 复制代码
x & ((x - 1) != 0)

而不是:

c 复制代码
(x & (x - 1)) != 0

后者才是一个非常经典的位运算技巧。

对于一个非零无符号整数,执行 x−1x-1x−1 会将最低位的 111 变为 000,并将其右侧的所有 000 变为 111。

例如:

text 复制代码
x       = 00101000
x - 1   = 00100111
------------------
x & (x-1)= 00100000

可以发现,x&(x−1)x\&(x-1)x&(x−1) 的作用就是清除 xxx 最低位的那个 111。

因此,如果一个非零整数的二进制表示中只有一个 111,那么执行该运算后必然得到 000。这恰好对应 222 的整数次幂:

x=2k  ⟹  x&(x−1)=0 x=2^k\implies x\&(x-1)=0 x=2k⟹x&(x−1)=0

由此可以实现一个常见的位运算判断函数:

c 复制代码
#include <stdbool.h>

bool isPowerOfTwo(unsigned int x)
{
    return x != 0 && (x & (x - 1)) == 0;
}

这里必须排除 x=0x=0x=0,因为无符号整数从 000 减去 111 会回绕到 UMaxUMaxUMax,但:

0&UMax=0 0\&UMax=0 0&UMax=0

如果不检查 x≠0x\ne0x=0,就会错误地将 000 判断为 222 的整数次幂。

由此可以发现,看似简单的整数表达式,实际上涉及多个层次:数据类型决定数值范围,补码决定位模式的解释方式,溢出影响运算结果,隐式转换决定实际参与计算的类型,而运算符优先级最终决定表达式究竟执行什么操作。

在分析 C/C++ 整数代码时,只有将这些规则结合起来,才能正确判断表达式的含义,而不能直接套用普通数学中的运算直觉。

相关推荐
别动我齐刘海1 小时前
机器人 C++ / 系统联调补强2——异常处理、Protobuf
linux·c++·人工智能·学习·算法·机器学习·机器人
basketball6161 小时前
C++ 中字符串与常见类型的转换总结
开发语言·c++
等我调个Bug2 小时前
C++智能指针通俗讲解
开发语言·c++
kyle~4 小时前
时间同步 --- PTP 与 Scheduled Action Command
c++·数码相机·机器人·传感器
syagain_zsx6 小时前
WebSocket 教程:websocketpp 写 echo 服务端
linux·网络·c++·websocket·网络协议·工具库
大陈子编程6 小时前
C++ CSP-J 与 CSP-S 题单:洛谷 140 题知识点全覆盖
c++
张小姐的猫6 小时前
【MySQL】数据类型
android·linux·数据库·c++·mysql·adb·php
讳疾忌医丶7 小时前
编译器眼中的 C++ 循环:从指针别名、IEEE 754 结合律到 SIMD 语义证明
开发语言·c++