
说句实在话入行嵌入式头两年我最不在意的就是C语言里的算术运算符。加减乘除谁不会直到有一次我写的电池电量百分比显示在屏幕上跳来跳去排查了整整两天最后发现根因竟然是unsigned char参与运算时触发了整型提升中间结果被截断——那一刻我才意识到越是基础的符号在单片机这种资源受限的环境里越是暗藏玄机。这篇文章我就从嵌入式实战的角度把算术运算符和算术表达式彻底拆开揉碎讲一遍。不会只讲语法更多是讲在 MCU 上做寄存器操作、传感器采样、滤波算法时这些运算符是怎么工作的、会在哪些地方坑你、以及怎么用才能既高效又安全。想扎实修炼C语言功底的嵌入式初学者或者被一些诡异Bug折磨过的老手都可以看看。1. 单片机里算术运算符的“真实身价”一条指令背后的性能账单很多嵌入式开发者是从 PC 编程转过来的习惯了“加减乘除都差不多快”的思维。但在单片机世界里这五种算术运算符从来都不是平起平坐的。加法和减法便宜乘法看平台除法则是真正的“奢侈品”。理解这一点你写出来的代码才能在低主频、小内存的设备上跑得稳。1.1 加减法MCU原生的“廉价操作”在 ARM Cortex-M 系列上整数加法和减法通常对应一条指令。比如a b大概率会被编译成ADD r0, r1, r2一个周期搞定。减法也一样本质上是在做补码加法。但如果你用的是 8051 或者某些低端 8 位 MCU情况就完全不同了——这些芯片的 ALU 一次只能处理 8 位数据而 C 语言中int类型是 16 位的所以一个 16 位加法会被拆成两条指令先加低字节再加高字节同时处理进位标志。我早年做 8 位 MCU 项目时写过一个循环等待函数void delay_loop(uint16_t count) { while (count 0) { count--; } }当时没太在意后来用示波器量时序才发现这个循环里count--和count 0的每次判断在 8051 上要消耗好几条指令。如果业务逻辑里大量使用 16 位变量做简单计数CPU 开销是肉眼可见的。所以核心经验就是在 8 位 MCU 上能用uint8_t就用uint8_t没必要用 16 位去“显得正规”。但在 32 位 MCU 上比如 STM32反而建议尽量用 32 位变量因为一次能处理 32 位用 8 位反而要做额外的掩码和符号扩展。1.2 乘法硬件加速与编译器拆招Cortex-M3/M4/M7 都带硬件乘法器一条MUL指令就能算出两个 32 位整数的低 32 位乘积一个周期完成。但要注意如果两个 32 位数相乘结果要完整保存到 64 位就不能只用MUL了需要UMULL或SMULL这两个指令会多花一些周期但依然很快。真正的问题是 8 位 MCU 或者早期的 ARM7 平台。8051 的乘法指令只能做 8 位乘 8 位得到一个 16 位结果。两个 16 位数相乘编译器会展开成一组移位和加法操作比如把x * y分解成y的每一位去乘以x再移位累加。这在汇编层面是几十条指令的活儿。所以无硬件乘法器的平台上我通常会替编译器着想用移位和加法代替一些常见乘法// 原来total x * 10; // 优化编译器通常能自己做但如果平台太老建议手写 total (x 3) (x 1); // x * 8 x * 2 x * 10这样写会牺牲一些可读性但效率提升是实打实的。我的习惯是加上注释说明这是在为没有硬件乘法器的 MCU 做优化后面维护的人就不会一脸懵。1.3 除法嵌入式算术里的“奢侈品”说到除法就更要精打细算了。Cortex-M3/M4 虽然提供了SDIV/UDIV指令但它们不是单周期的通常要 2 到 12 个周期具体看操作数。而 Cortex-M0/M0 压根没有除法指令任何整数除法都会调用编译器内置的软件库函数比如__aeabi_uidiv这个函数内部是做辗转相除的可能要几百个周期。举个真实场景一个采样率为 1kHz 的传感器数据处理任务MCU 主频 48MHz平均每个主循环只剩 48000 个周期。如果每个采样点做一次sum / 10的软件除法耗时几百周期占比还不算致命。但如果你一段代码里有多个除法又或者在中断里做除法那 CPU 占用率会瞬间拉高实时性就没法保证了。除法的优化手段很明确。如果除数是 2 的幂用右移uint32_t avg sum 3; // sum / 8但要注意右移和除法只对无符号数完全等价。有符号负数右移是算术右移结果向负无穷取整和 C 语言里整数除法向零取整不一样。比如-7 1在多数平台上是-4而-7 / 2是-3。取模运算也有类似套路。当模数是 2 的幂时a % 8等价于a 7。环形缓冲区索引就常用这个技巧#define BUF_SIZE 16 uint8_t rd_idx 0; uint8_t wr_idx 0; // 写入后更新写索引 wr_idx (wr_idx 1) (BUF_SIZE - 1);这里BUF_SIZE必须是 2 的幂 (BUF_SIZE - 1)比% BUF_SIZE快很多。前提是索引是无符号整数且能保证不会因为取模而出现负数。2. 整型提升和隐式转换算术表达式中最隐蔽的“刺客”如果说性能是明面上的账那整型提升和隐式类型转换就是暗地里的刺客。这两个机制是 C 标准规定的但很多嵌入式工程师写代码时根本没意识到它们的存在于是 Bug 就像雨后春笋一样冒出来。2.1 为什么 unsigned char 一参与运算就“变了人”C 标准规定char和short类型在参与算术运算之前会先被提升为int。如果int能表示原类型的所有值就提升为int如果不行比如某些 16 位 int 平台上unsigned int和int范围一样大会提升为unsigned int。看这段代码uint8_t a 200; uint8_t b 100; int sum a b;你可能会以为a b在uint8_t域里运算得到300然后被截断成44再赋给sum。但实际上a和b在相加之前就已经被提升为int所以sum正确得到300。这个中间过程是很多问题的根源因为一旦你把它赋回一个窄类型uint8_t c a b; // c 被截断为 44300的二进制是0b100101100取低 8 位就是0b00101100十进制 44。这种截断往往不是程序本意尤其在处理通信协议里的校验和、传感器原始采样值时特别容易埋雷。我在调试一个蓝牙数据包校验和算法时就遇到过这类问题。累加器是uint8_t我一直以为它会自动回绕所以没在意。后来换了一个平台某些编译器优化选项下累加过程变成了int运算最后赋回uint8_t时行为虽然一致但中间有一次判断“累计值是否等于预期值”的代码用到了已经截断的uint8_t变量和另一个int常量比较结果就出现了诡异的偶发不一致。从那以后我写累加代码时都会显式地做类型转换并保证中间变量宽度足够uint16_t sum 0; for (int i 0; i len; i) { sum data[i]; // 用16位甚至32位累加最后再按需截断 } uint8_t checksum (uint8_t)sum;2.2 有符号与无符号混用的经典翻车现场再看一个经典场景。int和unsigned int混用时会发生“通常算术转换”当两个操作数有相同的转换等级时有符号类型会被转换成无符号类型。这句话本身不难理解但后果很反直觉int a -1; unsigned int b 1; if (a b) { // 这个分支居然执行了 }-1转换成unsigned int后变成0xFFFFFFFF也就是 4294967295自然大于 1。这种 Bug 在嵌入式里非常隐蔽因为a b一行代码从字面上看怎么都不该为真但实际运行时它就是真的。我见过一个实际案例某个温度采集模块传感器返回int16_t temperature而阈值配置存在unsigned int变量里。程序判断if (temperature threshold)来触发散热风扇结果零下温度时temperature转成无符号变成一个超大值风扇一直误开。排查过程很费劲因为打印日志时你看到temperature确实是 -5但比较结果却走了“大于”分支。有符号和无符号混用还有一个高频翻车点——倒计数循环unsigned int i; for (i 5; i 0; i--) { // ... }这个循环永远不会结束因为i是无符号数i 0恒为真。i变成 0 后再减 1会回绕成 4294967295。我的建议很朴素在嵌入式代码规范里明确“禁止有符号与无符号直接比较”涉及协议长度、传感器原始值、时间戳计算时统一使用无符号类型而真正需要表示负数的场景比如温度、误差值就用有符号类型但绝不要和长度、索引这类无符号量混用。时间差值计算这种场景用无符号回绕反而是一项技巧比如uint32_t elapsed now - last;如果now回绕了只要差值不超过 2^32结果依然是正确的时间间隔。但判断if (elapsed timeout)时要注意timeout也要是无符号避免混用。2.3 截断与回绕单片机里“降级”的数据无符号整数溢出后的行为是确定的按模回绕。比如uint8_t x 255; x;之后x变成 0。而有符号整数溢出在 C 语言里是未定义行为编译器可以假设它不会发生从而做出一些你完全想不到的优化。举个实际例子很多人喜欢用有符号变量保存传感器数据int16_t temp 32767; temp temp 1; // 有符号溢出未定义行为在某些优化级别下编译器可能直接把这段代码优化掉或者产生一个你无法预测的结果。虽然大多数 MCU 的底层硬件会按照补码回绕成 -32768但依赖这种未定义行为去写逻辑跟玩火差不多。ADC 采样的截断问题更常见。比如一个 12 位 ADC采样值存到uint16_t没问题但如果你手一滑存到了uint8_tuint16_t adc_val 1023; uint8_t low_val adc_val / 4;1023 / 4 255.75整数除法结果是 255赋给uint8_t还是 255。看着没问题但如果adc_val 10241024 / 4 256赋给uint8_t就变成 0。这在显示和判断逻辑上会突然跳变。我给自己定了一条规矩任何中间运算结果类型宽度至少比输入数据的最宽类型再大一级。两个uint16_t相加就用uint32_t接收两个uint16_t相乘直接用uint32_t甚至uint64_t。虽然浪费一点内存但能避开大量难以排查的截断问题。3. 寄存器与内存操作中的算术表达式从宏定义到指针偏移嵌入式开发里算术表达式最经典的应用场景就是访问外设寄存器。你会看到大量类似于*(volatile uint32_t *)(BASE_ADDR OFFSET)的代码。这里面既有加法也有指针运算还夹杂着强制类型转换每一步都有讲究。3.1 寄存器地址计算算术表达式写在外设宏定义里给外设寄存器定义地址一般有两种风格。一种是在宏里直接算好#define RCC_BASE 0x40021000 #define RCC_CR (*(volatile uint32_t *)(RCC_BASE 0x00)) #define RCC_CFGR (*(volatile uint32_t *)(RCC_BASE 0x04))另一种是用结构体指针typedef struct { volatile uint32_t CR; volatile uint32_t CFGR; ... } RCC_TypeDef; #define RCC ((RCC_TypeDef *)RCC_BASE)第二种写法本质上也是在做算术。RCC-CR就等价于*(volatile uint32_t*)((uint32_t)RCC_BASE offsetof(RCC_TypeDef, CR))而offsetof是由结构体成员排列决定的通常是成员之前的字节累加和。这也是为什么寄存器结构体通常被要求严格按 4 字节对齐、成员顺序和手册保持一致。你还要特别小心指针算术的步长。比如uint32_t *reg_ptr (uint32_t *)0x40021000; uint32_t *reg2 reg_ptr 5; // 指向 0x40021014而不是 0x40021005p 1在 C 语言里不是单纯地址加一而是加sizeof(*p)个字节。所以当你想用指针偏移访问寄存器时脑子里要清楚指针的类型。这种算术表达式写起来简单但读代码的人一旦把地址算错后面全盘皆输。实际项目里我更推荐结构体指针风格因为编译器会帮你处理偏移量不容易手算出错但在调试的时候你要能自己算出某个寄存器的绝对地址这就要用到“基地址 偏移量”的算术直觉了。3.2 volatile 变量参与算术表达式的“双层性格”volatile关键字告诉编译器该变量每次访问都必须从内存重新读取不能优化到寄存器里缓存。这在访问寄存器时是必须的但你把它和算术表达式放一起时很容易踩坑。看这个判断if ((READ_REG(FLAG) 0x10) (READ_REG(FLAG) 0x20)) { // 如果寄存器在两次读取之间变化两个条件看到的可能不是同一次状态 }单独看语法没错语义也不算太离谱。但在硬件层面标志寄存器里的位可能随时被外设硬件改变两次读取可能拿到不同状态。如果这是一个实时性要求高的逻辑这样的判断结果会非常不可靠。更隐蔽的是算术表达式中的重复读取uint32_t raw *reg *reg; // 可能被编译器优化成两次独立读取如果*reg是一个递增的计数器寄存器它两次读到的值可能不同导致raw的结果不是你期望的“两倍当前值”。我的经验是任何参与计算的寄存器值先拷贝到普通局部变量再用这个局部变量做运算。uint32_t r *reg; uint32_t raw r r;这样既保证了读取一致性也方便调试时在断点处观察局部变量的值。3.3 复合赋值运算符在寄存器位操作中的正确用法复合赋值运算符、-、*、/、|、、^看起来只是简写但有一个重要区别左操作数的求值只进行一次。比如a[i] b中i只会执行一次等价于a[i] a[i] b; i。这在语义上和展开写法一致但如果你自己写a[i] a[i] b行为就完全乱了。寄存器操作里最常用的是|和GPIOA-ODR | (1 5); // 置位第5位 GPIOA-ODR ~(1 5); // 清位第5位这种读改写操作在单线程上下文里是安全的。但要注意如果在中断服务函数和主循环里同时对同一个寄存器位做操作中间的“读-改-写”过程就可能被插队导致寄存器回退更新。举例主循环里REG | BIT0;ISR 里REG | BIT1;理论上两个位都应该被置位但如果主循环刚读完寄存器旧值BIT1 还没被置位ISR 插入并写入了 BIT1随后主循环写回它读到的旧值加 BIT0就把 BIT1 的写入覆盖掉了。这种问题在裸机并发环境中很经典。解决方法是使用原子操作比如在写之前临时关中断或者使用硬件支持的原子位操作指令例如 STM32 的BSRR/BRR寄存器专门用于原子置位和清零。4. 采样滤波与标定计算中算术表达式的实战设计传感器数据处理是算术表达式的重头戏。ADC 采集到的原始值如何滤波如何映射成物理量如何在无 FPU 的 MCU 上用整数模拟小数运算每一步都值得认真设计。4.1 滑动平均滤波器里的整数除法取舍滑动平均是嵌入式里最常用的滤波手段。它本质上就是维护一个窗口每次用新采样值替换最旧采样值然后求平均。一个高效实现#define WINDOW_SIZE 8 static uint8_t samples[WINDOW_SIZE]; static uint8_t idx 0; static uint32_t sum 0; // 每次有新样本时调用 void filter_add(uint8_t new_sample) { sum - samples[idx]; sum new_sample; samples[idx] new_sample; idx (idx 1) (WINDOW_SIZE - 1); } uint8_t filter_avg(void) { return (uint8_t)(sum 3); // WINDOW_SIZE 8右移3位相当于除8 }这里有两个关键点。第一sum用uint32_t因为多个uint8_t累加可能超过 255如果sum定义为uint8_t加两个值就溢出了。第二窗口长度选择 8、16、32 这类 2 的幂是为了用移位代替除法。我之前做一个空气质量监测项目采样率 100Hz窗口长度用 10没注意就写了avg sum / 10;。程序在 STM32F103 上跑虽然没有明显卡顿但我后来数了数周期发现这个除法占了滤波函数将近一半的执行时间。改成窗口长度 16 以后右移三位既省时间又让代码看起来更简洁。如果窗口长度确实不能改成 2 的幂也可以用“近似除法”但那是另一个大话题了能避开就避开。4.2 传感器量程映射先乘还是先除差之毫厘谬以千里传感器的原始 ADC 值和物理量之间通常是一次线性关系。比如风速传感器输出 0~4095 对应 0~30 m/s转换公式是uint32_t speed (uint32_t)adc_val * 30u / 4095u;为什么中间要转成uint32_t因为adc_val * 30的最大中间值是4095 * 30 122850而如果adc_val是uint16_t16 位 MCU 上int也是 16 位最大只能到 65535直接溢出。就算在 32 位 MCU 上uint16_t参与运算会先提升成int32 位 int 装 122850 没问题但你要是把adc_val声明成uint16_t且编译器按 16 位 int 处理的老平台就爆了。反过来如果先除再乘uint32_t speed (adc_val / 4095) * 30;那就直接变成 0因为整数除法adc_val 4095时结果是 0。很多新手会在这里栽跟头他们以为整数除法有小数部分实际上没有。还有一个细节* 30 / 4095在数学上等价于乘以约0.007326。除法很贵的话可以用乘法加移位近似。例如4095 ≈ 65536 / 16 4096那么uint32_t speed (uint32_t)adc_val * 30u * 16u 16;这样speed adc_val * 480 / 65536等价于除以约 136.5和除以 4095 相差约 0.05%如果精度要求不高完全可行。这个方案把除法优化成了一次乘法和右移在无硬件除法器的 MCU 上很划算。做量程映射时我建议先算一遍所有中间量的最大可能值再决定用多宽的整数类型。这个习惯能挡住大量“看起来对实际溢出”的 Bug。4.3 定点小数运算没有 FPU 时怎么用好算术运算符很多嵌入式主控没有 FPU直接写float变量编译器会调用软件浮点库。一个float加法可能需要几百个周期如果在中断里做实时性直接崩。解决方案是用定点数。定点数的核心思想是用一个整数表示小数。假设约定 Q12 格式也就是把 1.0 表示为 4096那么 0.5 表示为 20481.5 表示为 6144。#define Q 12 #define FLOAT_TO_Q(x) ((int32_t)((x) * (1 Q))) // 0.6 转成 Q12 int32_t k FLOAT_TO_Q(0.6); int32_t input 1000; // 输入量假设已经放大到整数 int32_t result (input * k) Q; // 结果 input * 0.6这里要注意input * k可能溢出所以input和k都要用足够宽的整数。通常我会用int32_t做定点运算并估算最大中间值。比如input最大 10000k最大约4096*2 8192乘积约 81920000还在int32_t范围内安全。定点除法也有对应做法计算a / b的定点结果应该先做a_Q a * Q再除以b这样结果就是 Q 格式。int32_t a_Q 5000 Q; // 分子先放大 int32_t ratio_Q a_Q / b; // 得到一个 Q 格式的结果用算术运算符做定点数计算本质上就是把“实数运算”转换成“整数乘除”但你必须时刻追踪每一个变量的 Q 值。我写的每段定点代码都会在头部注释清楚// 所有 k_xxx 变量均为 Q12 格式范围定义如下...没有 FPU 不代表不能用小数关键是选对表达方式。这也是算术运算符在嵌入式里最值得钻研的一个方向。5. 求值顺序、副作用与优化器之间的“三角关系”算术表达式不只是“怎么算”的问题还牵扯“啥时候算”和“算几次”。C 标准里有很多求值顺序和副作用的规定一旦踩中未定义行为编译器优化会把你带入一个完全不可预测的世界。5.1 未定义行为嵌入式开发中比 Bug 更可怕的东西未定义行为意味着 C 标准完全不保证代码的行为。编译器可以做它想做的任何事情。经典的未定义表达式包括int i 1; i i i; // 不要这么写这种代码在任何正规项目里都该直接被毙掉。但嵌入式里更常见的是函数实参求值顺序的问题uint32_t result func(read_reg(REG1), read_reg(REG2));C 标准规定函数实参的求值顺序是“未指定”的。有的编译器从左到右有的从右到左。如果read_reg(REG1)和read_reg(REG2)有副作用比如都修改同一个全局状态那么结果可能会不同。更糟糕的是寄存器读取本身如果有硬件副作用——有些寄存器读一下就会清中断标志——顺序就至关重要。我的原则是任何带副作用的操作不要塞进一个表达式。拆开uint32_t r1 read_reg(REG1); uint32_t r2 read_reg(REG2); uint32_t result func(r1, r2);这样顺序明确可读性也好。5.2 编译器优化会对算术表达式做什么GCC 在-O2级别会做大量算术变换。常量折叠是最基础的a b * 2;可能被优化成a b b;或a b 1;。还有结合律重排(x * 2) / 2会被直接优化成x这在数学上没问题但在浮点数场景下就不严谨了。更隐蔽的问题是编译器利用有符号溢出是未定义行为这一点进行优化。看这段int a; if (a 1 a) { // 逻辑上恒成立编译器可能直接移除这个判断 }因为在标准 C 下a 1不可能溢出所以a 1 a恒为真。但如果是在嵌入式硬件上a是某个寄存器镜像真实世界里它确实会溢出那这段代码就不能依赖编译器“帮你推理”。还有一点volatile变量可以阻止编译器重排和缓存访问但不能阻止所有优化。如果你的代码里有多个volatile读取和算术运算混在一起最好把必要的数据先读到普通局部变量中把“软件世界”和“硬件世界”隔离开这样编译器才能放手优化。5.3 用反汇编和静态分析排查算术问题当我怀疑一个算术表达式在目标平台上的行为不对最快的办法不是对着屏幕冥想而是直接看反汇编。在 Keil 工程里可以勾选生成.lst文件GCC 工具链可以用arm-none-eabi-objdump -d firmware.elf firmware_disasm.txt然后定位到出问题的函数查看对应的指令序列。比如你想确认sum / 8到底有没有被优化成移位看反汇编一眼便知。如果看到__aeabi_uidiv调用就知道除法没被优化掉。静态分析工具也是好帮手。给编译命令加上arm-none-eabi-gcc -Wall -Wextra -Wconversion -Wsign-compare ...-Wconversion会对隐式类型转换和可能的截断给出警告-Wsign-compare会提示有符号和无符号比较。Cppcheck 和 Clang-Tidy 也内置了不少算术相关的检查项。虽然警告不是错误但我对待警告的态度是除非你能说出每条警告不会引发问题的具体原因否则必须消除它。说一个我自己的排查经验曾经调试一个电机调速系统速度值偶尔会跳变。我打印了所有中间变量看着都很正常后来把volatile中断里修改的全局变量在表达式里多次使用这个问题找出来后一切豁然开朗。很多时候算术 Bug 的表象是“数值不对”根子却在“读取时机不对”。看反汇编和加编译警告是快速定位这种问题的最短路径。6. 嵌入式笔试与项目中的算术运算符高频雷区不夸张地说我在面试嵌入式工程师时基本都会问几道关于算术表达式细节的题。这些题看起来简单但能滤掉一大部分对 C 语言底层机制理解不深的人。这一节我把高频雷区整理成面试题模式再补几个真实项目中的故障案例。6.1 四道经典算术题看看你第几题会翻车第一题unsigned char a 250; int b 10; a b; printf(%d\n, a);问结果是什么答案是 4。因为a b展开为a (unsigned char)(a b)a b在int域里是 260转回unsigned char时取低 8 位260 0xFF 4。很多人会脱口而出 260说明还没意识到整型提升和回绕。第二题char c 128; printf(%d\n, c);如果char是有符号类型结果是 -128因为 128 的二进制是0b10000000在有符号char中被解析为 -128。如果平台把char定义为无符号结果才是 128。嵌入式里char的有无符号特性是平台相关的这个细节最容易引发移植 Bug。第三题int a 5, b 2; double d a / b; printf(%f, d);结果是 2.000000不是 2.5。因为a / b是整数除法先算出 2然后才转成double。想要 2.5必须写成(double)a / b。第四题uint16_t temp 0xFFFF; temp;结果是 0因为无符号溢出回绕。但如果temp是int16_t0xFFFF是 -1temp后变 0这还算“合理”而如果temp是int16_t且从 32767 加到 32768就是未定义行为你无法预测结果。这四道题很多人会在第一题和第三题上翻车。它们背后对应的就是整型提升、截断、整数除法语义和有符号溢出这些嵌入式开发中天天会碰到的概念。6.2 真实项目中因算术表达式引发的三个故障案例第一个案例是 BMS 电池管理系统的 SOC 百分比显示。产品反馈说电量在 80% 到 100% 之间疯狂跳变。检查代码发现uint8_t soc (voltage * 100) / 4095;voltage是uint16_t在 8 位 MCU 上voltage * 100会在int16位域里溢出导致大电压时soc计算错误。修复方案是改成uint8_t soc ((uint32_t)voltage * 100) / 4095;强制提升到 32 位再运算问题立刻消失。第二个案例是步进电机速度控制。代码里写的是int16_t speed 300 * 400 / 60;在某个 16 位int平台上300 * 400已经溢出实际值可能变成一个负数再除 60 也是错的。虽然这类常量表达式在 32 位平台没问题但代码跨平台移植时就炸了。建议常量表达式都显式加类型后缀或者直接算好int16_t speed 2000; // 300 * 400 / 60 2000第三个案例是 Modbus 协议解析。报文里有一个长度字段len类型是uint8_t代码里用buf[len - 1]取最后一个字节。当len 0时len - 1会先被提升为int得到 -1然后隐式转换回uint8_t成为 255最终访问buf[255]越界读到了别的内存区域。修复方式是先判断len 0再访问if (len 0) { // 处理非法长度 } else { last_byte buf[len - 1]; }这三个案例的共同点在于光看代码逻辑似乎没问题但一旦把类型宽度、整型提升、隐式转换这些因素放进去就处处是坑。6.3 团队代码评审中我必查的算术表达式清单带过几个项目之后我沉淀了一份代码评审检查清单专门用来审算术表达式相关代码。每次评审我都挨个过是否出现了有符号和无符号的隐式比较或赋值。有符号负数是否参与除法或取模结果是否符合 C99 的向零取整规则。中间运算结果的最大值是否可能超出目标类型范围。先算一遍最大中间值再定类型。是否把char/short和int混用是否依赖了整型提升的中间行为。除法的除数是否可能为 0有没有提前防御。是否用移位代替乘除但面对有符号负数时是否考虑了右移的语义差异。同一个volatile变量是否在一个表达式里被多次读取。是否依赖了函数实参求值顺序或者在同一表达式内对同一变量多次修改。表达式结果的类型是否会因为平台int位宽不同而改变。是否在代码注释里写清楚了定点数的 Q 值、类型宽度和预期的溢出行为。这份清单看起来啰嗦但它真能挡掉很多线上 Bug。我宁可评审时多花十分钟逐项核对也不愿意产品量产后去异地出差定位问题。最后分享一个小习惯我写涉及算术表达式的函数时会在草稿纸上推演一遍“最坏情况”。比如两个uint16_t相加最坏结果是多少一个uint16_t乘以常数再除以另一个常数中间会不会溢出。这个习惯帮我避开了至少两位数以上的隐藏 Bug。算术运算符虽然基础但真正把它吃透你写的嵌入式代码会稳定一大截。