32 位 ABI(ilp32)整数宽度陷阱:64 位下金额 70 亿,32 位设备上报负数 —— long 只有 4 字节,而 -Wall -Wextra 对「变量运算溢出」一言不发
日期:2026-09-20 | 分支:
2026-09-20-0000| 第69题 关键词:-m32ilp32LP64sizeof(long)整数溢出 静默回绕-Woverflow-Wconversion-Wsign-conversion-WformatPRId64PRIu64int64_tuint64_tstd::max_elementambiguous 重载歧义-fsanitize=undefinedUBSan-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
三个让人误判的点:
- 没有任何运行时报错,退出码 0,日志里一行 error 都没有;
- 构建日志也是干净的——因为
WARN_FLAGS=-w把告警全静默了(当年为了「先能出包」); - 有人把
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) | 本题业务量级 |
|---|---|---|---|
| lp64 | x86_64 服务器 | 8 | total_price = 7.03e12,刚好装下 |
| ilp32 | i386 边缘设备 | 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
六、经验与避坑
- 跨 ABI 的铁律:只要量的值域可能超过 2^31,就绝不写
long/unsigned long/size_t。 一律int64_t/uint64_t。long的宽度是 ABI 决定的,不是语言决定的。 -Wall -Wextra抓不到「变量参与的整数溢出」,别被它骗了。 它只有-Woverflow(常量折叠)和-Wformat(格式串),这两个都只覆盖编译期可静态判定的情况。 真定位此类 bug 的正确姿势:-fsanitize=undefined。-ftrapv不可信,-m32 -O2下实测无效果(程序照常输出回绕值、退出码 0)。-Wconversion -Wsign-conversion也只是补充,抓不到变量乘法。- 「算式宽度」和「累加器宽度」是两个独立的 bug。
acc += a * b这种写法,只改acc的类型是无效的——a * b本身已经先回绕了。 先提升操作数,再进累加器。 - 「单位换算」的溢出量级往往比金额还大。
calls × 4096是 3.28e13,比calls × 999的 7.99e12 大 4 倍以上, 却因为「看起来只是个位移」而被忽略。 - 格式串宽度要用
<cinttypes>的PRId64/PRIu64,别写死%ld。%ld打long能逃过-Wformat(类型匹配了,只是范围不够), 所以要换的不是「让它报错」,而是「让它可移植」——PRId64会按 ABI 展开成ld或lld。 - 常量也要看宽度。
4294967296UL在 32 位unsigned long上等于 0。 这类常量通常是「模数 / 掩码 / 回绕基数」,一旦归零,整段还原逻辑静默失效,最难查。 写成4294967296ULL一劳永逸。 - CI「无警告即通过」+
-w是一对危险组合。 静默告警的首日能出包,但会把宽度 bug 直接带到生产。 恢复告警强度时不要只加-Wall,要按「能否拦住本次 bug」来验证它是否真有判别力。 - 修完必须做 32/64 双 ABI 对照。
只看单边绿了不算修好——
ops/verify.sh的 C1 断言就是专门防「改回 lp64 蒙混过关」的。 - 容器操作的两个小坑:
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。
