跳到内容

第 3 卷 / 第 2 章 / 第 1 课

2.1 整数表示与溢出 ​

第 1 章说明了同一串 bit 可以有不同解释。本章先讨论固定宽度整数;下一篇再处理浮点表示与数值误差。

0xFFFFFFFF 是多少 ​

寄存器窗口只显示 FFFFFFFF,没有类型信息。若它是 32-bit 无符号整数,值为 4,294,967,295;若按常见的 32-bit 补码有符号整数解释,值为 -1。

位模式没有自带正负号。类型给出位宽和解释规则,语言又规定运算、转换与溢出的行为。看系统代码时,必须同时追踪这三层。

无符号整数按模运算 ​

n bit 有 2^n 种模式。无符号解释把它们映射到 0 至 2^n - 1。在 C 中,若运算的结果类型是有 n 个值位的无符号类型,加减乘按 2^n 取模。窄类型要先考虑整数提升;源变量是无符号类型,不保证中间表达式也按它的位宽回绕。

c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void) {
    uint8_t value = UINT8_MAX;

    printf("start = %" PRIu8 "\n", value);
    value = (uint8_t)(value + UINT8_C(1));
    printf("plus one = %" PRIu8 "\n", value);
    value = (uint8_t)(value - UINT8_C(1));
    printf("minus one = %" PRIu8 "\n", value);
    return 0;
}

输出是 255、0、255。表达式中的 uint8_t 会先发生整数提升,赋值转换回 uint8_t 时才得到模 256 的结果;显式转换把这个边界写得更清楚。

回绕可以服务于序列号和环形计数器,但“结果可预测”不代表“业务正确”。若长度计算从最大值绕回小值,后续分配的缓冲区可能小于实际写入量。把无符号类型用于尺寸时,仍要在算术前检查范围。

补码怎样给最高位负权重 ​

现代通用计算机使用补码。对 n-bit 位模式 x[n-1]...x[0],补码值是:

text
-x[n-1] * 2^(n-1) + Σ x[i] * 2^i

8-bit 示例:

位模式无符号解释补码解释
0000000000
01111111127127
10000000128-128
11111111255-1

因此 n-bit 补码范围是 -2^(n-1) 到 2^(n-1)-1。负数一侧多一个值,-128 没有对应的 8-bit 正数。

从位模式角度看,求反加一能得到相反数的编码;但对最小值执行有符号取负会超出范围。在补码 C 平台上,-INT_MIN 是未定义行为,不能因为硬件通常留下同一位模式就依赖它。

C23 要求标准有符号整数使用补码表示;阅读 C17 或更早标准约束下的高度可移植代码时,不应把所有实现细节都预设为补码。固定协议格式最好使用明确宽度的无符号类型,再显式解释。

有符号溢出不是回绕承诺 ​

这段 C 代码没有可移植的结果:

c
int value = INT_MAX;
value = value + 1;

有符号整数加、减、乘的数学结果若无法由完成整数提升和通常算术转换后的运算类型表示,会触发未定义行为。编译器可以据此假定合法程序不会走到溢出路径,因此“先计算,再看结果是否变小”不是有效检测方式:

c
bool add_overflowed(int value) {
    return value + 1 <= value; /* value == INT_MAX 时,求值本身已经越界 */
}

正确的手工检查要在运算之前完成:

c
#include <limits.h>
#include <stdbool.h>
#include <stddef.h>

static bool add_int_checked(int left, int right, int *result) {
    if (result == NULL) {
        return false;
    }
    if ((right > 0 && left > INT_MAX - right)
            || (right < 0 && left < INT_MIN - right)) {
        return false;
    }
    *result = left + right;
    return true;
}

INT_MIN - right 只在 right < 0 时求值,此时结果落在 [INT_MIN + 1, 0],因此减法不会先溢出。尤其当 right == INT_MIN 时结果为零;不能把表达式改成先求 -right,因为那个中间值可能不可表示。调用者可用返回值区分成功和失败。

支持 C23 的实现还可以使用 <stdckdint.h> 中的 ckd_add、ckd_sub 和 ckd_mul;编译器也常提供溢出检查内建函数。注意 ckd_* 返回真表示结果不可表示,与本页“真表示成功”的辅助函数相反。使用哪一种取决于工具链及标准库支持,不能只看编译命令是否接受 -std=c23。

边界不是只在加法里 ​

乘法 ​

判断 a * b 是否越界时,不能先做乘法。对非负尺寸,常见写法是:

c
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

static bool multiply_size_checked(size_t count, size_t width, size_t *result) {
    if (result == NULL) {
        return false;
    }
    if (width != 0 && count > SIZE_MAX / width) {
        return false;
    }
    *result = count * width;
    return true;
}

这个模式直接保护“元素个数 × 单个元素大小”的分配计算。

除法与取模 ​

对有符号整数,除以零始终错误。补码平台上还有一组容易漏掉的边界:INT_MIN / -1 的数学结果无法由 int 表示,在 C 中同样是未定义行为;INT_MIN % -1 也受商必须可表示的要求约束,不能因为数学余数为零就忽略这条边界。

解析与缩窄转换 ​

调用 strtol、strtoul 前把 errno 清零;返回后检查是否完全没读到数字(end == input)、是否有不允许的尾随内容,以及是否出现 ERANGE。这些函数允许前导空白与符号;strtoul 甚至接受负号,不能仅因返回无符号数就把输入视为非负。严格字段要先按协议语法检查。把较宽整数转换成较窄类型前,也要先检查目标范围。转换不是验证步骤。

当一个超出范围的整数转换为无符号类型时,C 规定按目标范围反复加减模数得到结果。例如常见 32-bit unsigned int 上,(unsigned int)-1 得到 UINT_MAX。这在抽象语义上是数值转换,不应一概描述成“重新解释同一段内存”。

超出范围的整数转换为有符号类型时,结果由实现定义或可能产生实现定义的信号;不要借它实现可移植回绕。

混合 signed 与 unsigned 会改变比较 ​

C 的 usual arithmetic conversions 可能先把有符号操作数转成无符号。例如 int 与同 rank 的 unsigned int 比较:

c
-1 < 0U

结果为假,因为 -1 转成了 unsigned int。不过精确规则取决于两种类型的 rank 和可表示范围,不能把这个单例推广成所有混合类型的口诀。

工程上更稳妥的做法是让同一表达式中的类型保持一致:

  • 数组索引和 sizeof 结果常用 size_t,倒序循环要避免无符号下溢;
  • 不要用 index >= 0 终止无符号倒序循环,它永远成立;
  • 比较前若要转换,先证明目标类型能表示该值;
  • 编译时开启 -Wall -Wextra -Wconversion 等警告,并逐条理解而不是机械屏蔽。

一个安全的无符号倒序形式是:

c
for (size_t index = length; index-- > 0;) {
    use(items[index]);
}

条件先比较旧值,再递减;进入循环体时 index 在 [0, length) 内。最终失败的条件仍会把零减到 SIZE_MAX,这是已定义的回绕;循环体不会再执行,且此处的 index 在循环外不可见。零长度时不会访问数组。若代码可读性优先,也可以使用正序、迭代器或显式分支。

Java 与 Python 的规则不同 ​

Java 的 byte、short、int、long 是固定宽度补码有符号整数。普通整型加减乘在提升后的 int 或 long 类型中保留低位;byte、short 运算通常先提升到 int,再缩窄需显式转换或复合赋值等语言规则。Math.addExact、multiplyExact 等接口在越界时抛出 ArithmeticException。Java 没有通用的无符号整数基本类型,但提供 Integer.compareUnsigned、toUnsignedLong 等操作;char 则是无符号 16-bit UTF-16 编码单元。

Python 的 int 在可用内存范围内自动扩展精度,通常不会发生固定宽度回绕。与二进制协议或 C 接口交互时,仍必须自行验证位宽;value.to_bytes(length, byteorder, signed=...) 会在装不下时抛出 OverflowError。

同一个算法跨语言移植时,先写下溢出语义。依赖 Java 回绕的哈希代码直接翻成 C 有符号运算,可能从确定行为变成未定义行为。

一组能运行的边界测试 ​

下面是独立的 integer_boundaries.c,已包含辅助函数,不要再粘贴前面的同名定义。把成功路径和失败路径都写进测试:

c
#include <assert.h>
#include <limits.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

static bool add_int_checked(int left, int right, int *result) {
    if (result == NULL) {
        return false;
    }
    if ((right > 0 && left > INT_MAX - right)
            || (right < 0 && left < INT_MIN - right)) {
        return false;
    }
    *result = left + right;
    return true;
}

static bool multiply_size_checked(size_t count, size_t width, size_t *result) {
    if (result == NULL) {
        return false;
    }
    if (width != 0 && count > SIZE_MAX / width) {
        return false;
    }
    *result = count * width;
    return true;
}

int main(void) {
    int integer_result = 0;
    size_t size_result = 0;

    assert(add_int_checked(20, 22, &integer_result) && integer_result == 42);
    assert(add_int_checked(INT_MAX, 0, &integer_result));
    assert(!add_int_checked(INT_MAX, 1, &integer_result));
    assert(!add_int_checked(INT_MIN, -1, &integer_result));
    assert(!add_int_checked(1, 2, NULL));

    assert(multiply_size_checked(6, 7, &size_result) && size_result == 42);
    assert(multiply_size_checked(SIZE_MAX, 1, &size_result));
    assert(!multiply_size_checked(SIZE_MAX, 2, &size_result));
    return 0;
}

用未开启 -fwrapv 等改变语义的普通 C17 模式编译:

bash
cc -std=c17 -Wall -Wextra -Werror integer_boundaries.c -o integer_boundaries
./integer_boundaries

没有输出且退出码为 0,表示断言全部通过。生产测试还应覆盖每个整数类型、不同符号组合,以及解析输入到目标类型的完整链路。

动手推导与验证 ​

  1. 分别按无符号和 8-bit 补码解释 10000000、11111111、01111111、11001010。
  2. 为 int 实现安全减法,覆盖 INT_MIN - 1 与 INT_MAX - (-1)。
  3. 解释为什么 abs(INT_MIN) 无法返回一个 int 范围内的正值。
  4. 写一个安全的 calloc 大小检查,并证明它不会在检查表达式中先溢出。
  5. 比较 C、Java 与 Python 执行“最大整数加一”时的语义。

从离散边界走向近似表示 ​

整数的每个位模式通常对应一个精确值,越界发生在范围之外。浮点数面对的是另一种取舍:用有限位覆盖巨大的数量级,因此相邻可表示值之间会出现间隔。下一篇从IEEE 754 与舍入误差继续。

Built with VitePress | Software Systems Atlas