朕喜技术(ZhenxiJS)
2026.09.20
1 分钟阅读

32 位 ABI(ilp32)整数宽度陷阱:64 位下金额 70 亿,32 位设备上报负数 —— long 只有 4 字节,而 -Wall -Wextra 对「变量运算溢出」一言不发

日期:2026-09-20 | 分支:2026-09-20-0000 | 第69题 关键词:-m32 ilp32 LP64 sizeof(long) 整数溢出 静默回绕 -Woverflow-Wconversion -Wsign-conversion -Wformat PRId64 PRIu64 int64_t uint64_tstd::max_element ambiguous 重载歧义 -fsanitize=undefined UBSan -ftrapv 无效 multilib 交叉编译 金融金额精度 32 位设备固件 gcc-14-multilib

一、问题背景

「极光计费」是一套按调用次数计费的结算服务。服务在 x86_64 服务器(lp64:指针 8 字节、long 8 字节)上开发,一切正常。 后来给一批边缘设备供货,固件是 32 位 i386(ilp32:指针 4 字节、long 4 字节),设备本地要跑同一套 src/。

现象:64 位服务器上正确的 70 亿微元金额,在 32 位设备上变成了负数。

32 位设备(错误)        64 位服务器(正确)
calls            = -589934592      |  calls            = 8000000000
total_price      = -934137856      |  total_price      = 7032960000000
flow_price       = 452480          |  flow_price       = 8750000000
counter_total    = 3705032704      |  counter_total    = 8000000000
bytes_total      = 1694498816      |  bytes_total      = 32768000000000
formatted_amount = -934137856      |  formatted_amount = 7992000000000
formatted_u64    = 1889785610      |  formatted_u64    = 11805916207174113034

三个让人误判的点:

  1. 没有任何运行时报错,退出码 0,日志里一行 error 都没有;
  2. 构建日志也是干净的——因为 WARN_FLAGS=-w 把告警全静默了(当年为了「先能出包」);
  3. 有人把 WARN_FLAGS 改成 -Wall -Wextra 重编,依然一条告警都没有, 于是结论被误判成「代码没问题,是 32 位平台的锅」。

第 3 点就是本题最反直觉、也最值得记住的地方。

二、复现步骤

环境:gcc:14 + g++-14-multilib + libc6-dev-i386,x86_64 宿主,目标 ABI = i386/ilp32。

cd scenario/2026-09-20-0000
docker compose up -d --build
docker compose exec -T app sh ops/verify.sh

问题态输出 PASS=3 FAIL=9,产物确实是 32 位:

$ file build/calc
build/calc: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked,
interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, not stripped

最小复现:-Wall -Wextra 对变量运算溢出沉默

// width-probe-32bit.cpp
long big = 3000000000;
std::printf("variable_overflow = %ld\n", big * 2);        // 期望 6e9,变量运算
std::printf("constant_overflow = %ld\n", (long)3 * 1000000000);  // 常量进表达式
G32=/usr/lib/gcc/x86_64-linux-gnu/14/32
g++ -m32 -B/usr/lib32 -B$G32 -L/usr/lib32 -L$G32 -O2 -std=gnu++17 -Wall -Wextra \
    width-probe-32bit.cpp -o /tmp/probe32 && /tmp/probe32
warning: integer overflow in expression of type 'long int' results in '-1294967296'
         [-Woverflow]        ← 只报了「常量进表达式」那条
sizeof(long)=4
variable_overflow = 1705032704   ← big * 2,期望 6000000000,零告警!
constant_overflow = -1294967296

各种诊断手段的实测对照

手段对 calls * 999(变量乘法溢出)
-Wall -Wextra沉默
-Wall -Wextra -Wconversion -Wsign-conversion依然沉默
-ftrapv沉默(-m32 -O2 下实测无效果,退出码仍 0)
-fsanitize=undefined(UBSan)抓到,运行期一次报出全部 4 处

UBSan 的实际输出:

billing.cpp:8:8: runtime error: signed integer overflow: -589934592 * 999 cannot be represented in type 'long int'
billing.cpp:17:8: runtime error: signed integer overflow: -589934592 * 4096 cannot be represented in type 'long int'
usage_stats.cpp:11:42: runtime error: signed integer overflow: -589934592 * 4096 cannot be represented in type 'long int'
report.cpp:40:49: runtime error: signed integer overflow: -589934592 * 999 cannot be represented in type 'long int'

三、根本原因

long 的宽度是 ABI 决定的,不是 C++ 语言决定的。

ABI平台sizeof(long)本题业务量级
lp64x86_64 服务器8total_price = 7.03e12,刚好装下
ilp32i386 边缘设备4装不下 → int64→int32 静默回绕

代码里把「long 就是 64 位」当成了平台无关的事实(注释还留着 TODO: 这里先用 long,等下个版本再统一成 int64_t,放了三年)。 共六处问题,归为四类:

① 类型别名(billing.hpp)

using amount_t  = long;          // ilp32 下 4 字节
using counter_t = unsigned long; // ilp32 下 4 字节

② 算式宽度(billing.cpp)

long base  = calls * UNIT_PRICE_MICRO;  // 8e9 * 999 = 7.99e12  ⇒ 回绕
long bytes = calls * BYTES_PER_CALL;    // 8e9 * 4096 = 3.28e13 ⇒ 回绕(量级大 4 倍,更易漏)

③ 累加器宽度(usage_stats.cpp)

bytes_ += static_cast<counter_t>(calls * bytes_per_call);  // 括号内先回绕,累加器再宽也救不回

④ 格式串宽度(serialize.cpp)

std::snprintf(buf, sizeof(buf), "%ld", v);   // ilp32 下 %ld 只认 4 字节 ⇒ 只打低 32 位
std::snprintf(buf, sizeof(buf), "%lu", static_cast<unsigned long>(v));

注意 %ld 打 long 逃过了 -Wformat——类型声明上确实匹配,编译器只能对「格式串与类型不匹配」报警, 对「类型装了不该装的量」无能为力。

⑤ 重载歧义(selector.cpp)

unsigned long pick_biggest_batch(const std::vector<unsigned int>& batches) {
  return *std::max_element(batches.begin(), batches.end());
}

ilp32 下 unsigned int 与 unsigned long 等宽、rank 相同, 返回类型转换与模板实参推导两条路「一样好」→ 二义性。 业务上真正错的是「返回值宽度」:picked_batch 丢了回绕圈数,给出 8100 而不是 12884909988。

⑥ 初始化常量与解析(report.cpp)

long calls = argc > 1 ? atol(argv[1]) : (long)0x1DCD65000L;  // 32 位下直接回绕;atol 返回 long 同样会先回绕
const unsigned long kBatchModulus = 4294967296UL;            // → 0!唯一的 -Woverflow 来源

4294967296UL 在 32 位 unsigned long 上变成 0,导致「回绕圈数还原」整段静默失效——症状最隐蔽的一处。

四、解决办法

只改 src/ + conf/build.env 的 WARN_FLAGS;ABI_PROFILE 保持 ilp32 不动。

1. 类型别名一律换成定宽类型

using amount_t  = int64_t;    // 原 long
using counter_t = uint64_t;   // 原 unsigned long

int64_t / uint64_t 由 <cstdint> 保证在所有 ABI 下宽度一致;long、size_t 都不保证。

2. 乘法前把操作数提升到 64 位

amount_t total_price(counter_t calls) {
  const amount_t c = static_cast<amount_t>(calls);
  amount_t base = c * UNIT_PRICE_MICRO;          // 64 位乘法
  if (calls >= static_cast<counter_t>(DISCOUNT_MIN_CALLS))
    base = base * DISCOUNT_NUM / DISCOUNT_DEN;   // 先乘后除 6.2e14,同样要 64 位
  return base;
}

amount_t total_flow_price(counter_t calls) {
  const amount_t bytes = static_cast<amount_t>(calls) * BYTES_PER_CALL;
  const amount_t mb = bytes / (1024 * 1024);
  return mb * FLOW_PRICE_MICRO_PER_MB;
}

3. 算式与累加器必须同时修

void UsageStats::accumulate(counter_t calls, amount_t bytes_per_call) {
  counter_ += calls;
  bytes_ += static_cast<counter_t>(static_cast<amount_t>(calls) * bytes_per_call);
}

bool UsageStats::over_capacity(counter_t quota_mb) const {
  const counter_t quota_bytes = quota_mb * 1024ULL * 1024ULL;  // 原 quota*1024*1024 也会回绕
  return bytes_ > quota_bytes;
}

这是最容易踩的坑:只改 bytes_ 的类型没用,括号里的乘法已经先回绕了。

4. 格式串用 PRId64 / PRIu64

#include <cinttypes>

std::snprintf(buf, sizeof(buf), "%" PRId64, v);   // lp64→"ld";ilp32→"lld"
std::snprintf(buf, sizeof(buf), "%" PRIu64, v);   // lp64→"lu";ilp32→"llu"

为什么这样就对了:<cinttypes> 的格式宏会按目标 ABI 展开。 一份源码,两种 ABI 都零告警、都正确。这才是可移植写法,写死 %ld 不是。

5. 重载歧义消解

// 方案 A:把入参和返回值统一成 uint64_t(推荐,顺带修掉返回值宽度 bug)
uint64_t pick_biggest_batch(const std::vector<uint64_t>& batches) {
  if (batches.empty()) return 0;
  return *std::max_element(batches.begin(), batches.end());
}

// 方案 B:若入参类型不能改,在调用点显式转换
return static_cast<uint64_t>(*std::max_element(batches.begin(), batches.end()));

6. 初始化常量与解析

int64_t   calls_signed = argc > 1 ? strtoll(argv[1], nullptr, 10)
                                  : static_cast<int64_t>(0x1DCD65000LL);
counter_t calls = static_cast<counter_t>(calls_signed);

const uint64_t kBatchWraps   = 3;
const uint64_t kBatchModulus = 4294967296ULL;   // 原 ...UL 在 32 位下 = 0

用 strtoll 而不是 atol:atol 返回 long,32 位下解析 8000000000 会先回绕。

7. 恢复告警强度(关键的一步,别只加 -Wall)

-WARN_FLAGS=-w
+WARN_FLAGS=-Wall -Wextra -Wconversion -Wsign-conversion

⚠️ 实测:-Wall -Wextra 对本场景的「变量运算溢出」完全沉默; 即便叠上 -Wconversion -Wsign-conversion,也只抓得到常量写死的那种。 想真正定位这类 bug,必须上 -fsanitize=undefined(运行期); -ftrapv 在 -m32 -O2 下实测无效,别写进排查清单。

五、验证

docker compose exec -T app sh ops/verify.sh

修复后 PASS=12 FAIL=0:

# 告警开关: WARN_FLAGS=-Wall -Wextra -Wconversion -Wsign-conversion
# 实测产物 ABI: ELF 32-bit LSB executable, Intel i386 ... not stripped
================ 实测输出 ================
platform_ptr_size = 4          ← 仍是 32 位 ABI(没作弊改 lp64)
sizeof_long = 4
calls = 8000000000
total_price = 7032960000000
flow_price = 8750000000
picked_batch = 12884909988
counter_total = 8000000000
bytes_total = 32768000000000
formatted_amount = 7992000000000
formatted_u64 = 11805916207174113034
==========================================
PASS - A1 目标 ABI 下构建成功
PASS - A2 实际产物是 32 位(i386)可执行文件
PASS - B1 calls 未截断(=8000000000)
PASS - B2 total_price 正确(=7032960000000 微元)
PASS - B3 flow_price 正确(=8750000000 微元)
PASS - B4 计数器未回绕(=8000000000)
PASS - B5 字节数台账正确(=32768000000000)
PASS - B6 上报报文未截断(=11805916207174113034)
PASS - C1 本机 64 位(lp64)结果仍然正确(未破坏 64 位路径)
PASS - D1 目标 ABI 下宽度/格式告警已清零(-Wall -Wextra -Wconversion)
PASS - D3 告警开关已恢复到有判别力的档位
PASS - D4 picked_batch 正确(=12884909988,且源码可编译)
================================
PASS=12 FAIL=0

交叉验证:32 位与 64 位业务指标逐项相同

docker compose exec -T app bash -c 'diff \
  <(grep -v -E "^(platform_ptr_size|sizeof_long)" build/calc.out) \
  <(grep -v -E "^(platform_ptr_size|sizeof_long)" build/calc64.out) \
  && echo "业务指标逐项相同 ✓"'
业务指标逐项相同 ✓

只有 platform_ptr_size / sizeof_long 两行不同——这是应该不同的,因为题目要求必须停留在 32 位 ABI。

构建日志无告警、warn32.log 归零

[build] ABI_PROFILE=ilp32  OBJINFO=x86_64-linux-gnu
[build] CXXFLAGS = -m32 -B/usr/lib32 ... -O2 -std=gnu++17 -Wall -Wextra -Wconversion -Wsign-conversion
[build] -> /scenario/build/calc
$ wc -c build/warn32.log
0 build/warn32.log

六、经验与避坑

  1. 跨 ABI 的铁律:只要量的值域可能超过 2^31,就绝不写 long / unsigned long / size_t。 一律 int64_t / uint64_t。long 的宽度是 ABI 决定的,不是语言决定的。
  2. -Wall -Wextra 抓不到「变量参与的整数溢出」,别被它骗了。 它只有 -Woverflow(常量折叠)和 -Wformat(格式串),这两个都只覆盖编译期可静态判定的情况。 真定位此类 bug 的正确姿势:-fsanitize=undefined。
  3. -ftrapv 不可信,-m32 -O2 下实测无效果(程序照常输出回绕值、退出码 0)。 -Wconversion -Wsign-conversion 也只是补充,抓不到变量乘法。
  4. 「算式宽度」和「累加器宽度」是两个独立的 bug。acc += a * b 这种写法,只改 acc 的类型是无效的——a * b 本身已经先回绕了。 先提升操作数,再进累加器。
  5. 「单位换算」的溢出量级往往比金额还大。calls × 4096 是 3.28e13,比 calls × 999 的 7.99e12 大 4 倍以上, 却因为「看起来只是个位移」而被忽略。
  6. 格式串宽度要用 <cinttypes> 的 PRId64 / PRIu64,别写死 %ld。%ld 打 long 能逃过 -Wformat(类型匹配了,只是范围不够), 所以要换的不是「让它报错」,而是「让它可移植」——PRId64 会按 ABI 展开成 ld 或 lld。
  7. 常量也要看宽度。 4294967296UL 在 32 位 unsigned long 上等于 0。 这类常量通常是「模数 / 掩码 / 回绕基数」,一旦归零,整段还原逻辑静默失效,最难查。 写成 4294967296ULL 一劳永逸。
  8. CI「无警告即通过」+ -w 是一对危险组合。 静默告警的首日能出包,但会把宽度 bug 直接带到生产。 恢复告警强度时不要只加 -Wall,要按「能否拦住本次 bug」来验证它是否真有判别力。
  9. 修完必须做 32/64 双 ABI 对照。 只看单边绿了不算修好——ops/verify.sh 的 C1 断言就是专门防「改回 lp64 蒙混过关」的。
  10. 容器操作的两个小坑:docker compose exec 的默认 shell 是 dash,不支持 <(...) 进程替换(需显式 bash -c);且容器内 /tmp 与宿主机 /tmp 不共享 (要拷文件进容器用 docker cp <dir>/. <容器名>:/path)。

一键复现

cd scenario/2026-09-20-0000
docker compose up -d --build
docker compose exec -T app sh ops/verify.sh     # 修好后 PASS=12 FAIL=0
docker compose down

完整操作过程见分支内 scenario/2026-09-20-0000/SOLUTION.md, 问题态/修复后判定书见 baseline/evidence-problem-state.txt 与 baseline/evidence-fixed-state.txt。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-20-0000-32abi-long4

分享这篇文章