关于嵌入式MCU开发中,整型使用的一些思考
一、前言
- 刚开始接触嵌入式 MCU 开发的时候,我定义变量基本都用
int- 后来看到很多代码用的是
stdint.h里的uint8_t、uint16_t - 我当时的判断是:这不过是老式编程习惯
- 年代久远的代码这么写,后面的就跟着流传下来了
- 没多大用处,和
int没什么区别
- 后来看到很多代码用的是
- 后来做自己的个人项目时,我才觉得
uint8_t这些是真顺手- 占多少个 bit,看类型名就知道,取值范围也一目了然
- 有符号和无符号只差一个
u前缀,很好区分 - 看起来还比
int省内存得多
- 于是我越用越顺手,项目里几乎所有的整型变量都换成了
stdint.h里的类型 - 就这样,我形成了第一个片面认知:
uint8_t等类型节省内存占用 - 接着就一发不可收拾
bool还是太大,用uint8_t代替- 枚举的大小不确定,那就只用它来确定取值范围,实际类型用
typedef uint8_t顶上去
- 接触 FreeRTOS 之后,我又发现
uint8_t的读和写是原子性的,不用额外加保护 - 尤其是我在一处地方尝试使用原子特性代替互斥锁、队列、配置操作任务,发现实时性大大提高
- 我由此得出了第二个片面认知:
uint8_t操作效率高- 再往潜意识里滑一步,就变成了「
uint8_t、uint16_t这些类型哪怕做普通运算肯定也很快」 - 哪怕我后来已经知道该用 C 标准库的原子操作了,这个印象还是在
- 再往潜意识里滑一步,就变成了「
- 直到实习时开发 ESP32-C5,这个印象才被打破
- 业务多,内存和性能都紧得很
- 可我翻其他分支的代码,发现里面很多整型变量用的都是
int - 我当时很不理解
- 于是我去查了一圈资料,才发现错的是我
二、我的第一个偏见:uint8_t 更省内存
省不省内存,取决于这个变量放在哪里,而不是取决于类型名写着几个字节
(一)变量一般住在哪
- 在讨论省不省之前,得先知道变量平时待在哪些地方
| 存放位置 | 一般放什么 | 何时生灭 | 掉电后 |
|---|---|---|---|
| 栈 | 局部变量、函数参数、返回值、返回地址 | 随函数调用自动生灭 | 不保留 |
全局/静态区(.data / .bss) |
全局变量、static 变量 |
程序一启动就存在,直到结束 | 不保留 |
| 堆 | malloc / free 管的内存 |
手动申请、手动释放 | 不保留 |
| Flash | const 常量和代码本身 |
烧录时就固定 | 保留 |
- 前三个都在 SRAM 里
- 而 SRAM 恰恰是 MCU 上最紧的资源
- 所以平时说「省内存」,基本只谈 SRAM
- 顺带一提,全局区还分两块
- 初始值不是 0 的进
.data,初值要在 Flash 里存一份,启动时再拷进 SRAM - 没有初值或初值为 0 的进
.bss,启动时统一清零,不占 Flash 空间
- 初始值不是 0 的进
- 位置不同,对齐规则对它的影响也不同
- 这正是我当初没意识到的地方
(二)单个标量:几乎省不出来
- 我原以为随便定义个
uint8_t,就白赚了 3 个字节 - 先看栈
- 32 位平台的栈指针按 4 字节对齐
- 一个
uint8_t局部变量名义上 1 字节,进了栈帧照样占一个 4 字节槽位 - 所以在函数里纠结
uint8_t和int那一两个字节,基本是白纠结
- 再看全局区
- 单个
uint8_t全局变量在.bss里确实只占 1 字节 - 但如果它后面紧跟着一个
uint32_t,那 3 个字节的缝还是会被填上 - 和结构体填充是同一回事:省不省,取决于它旁边站着谁
- 单个
(三)数组与缓冲区:这里才真省
- 真正有大用处的地方是数组
uint8_t buf[1024]占 1 KB- 换成
int buf[1024]就是 4 KB - 在 SRAM 只有几百 KB 的 MCU 上,这 3 KB 是能救命的
- 而且它在栈上、在全局区一样省
- 我一开始把「栈上不省」当成了普遍规律,其实它只对单个标量成立
- 数组的对齐浪费只发生在整个栈帧的边界上,不会逐元素发生
- 所以一个局部数组,1024 字节就是实打实的 1024 字节
(四)结构体:省不省看成员怎么排
- 结构体是最容易出现内存浪费情况的
- 结构体成员要满足内存对齐,编译器会在成员之间插入填充字节(Padding)
- 举个例子:一个
uint8_t后面紧跟一个uint32_tuint8_t名义上占 1 字节- 但为了让后面的
uint32_t落在 4 字节边界上,编译器要填 3 个字节 - 结果这个
uint8_t实际吃掉了 4 字节,和直接用uint32_t没区别
- 成员顺序排得不好,占用甚至可能比全用
uint32_t还大 - 对策是把成员按从大到小排
- 让同宽度的类型集中在一起,填充自然就少了
- 这只影响内存布局,不影响代码逻辑,改起来没有代价
内存对齐:CPU 访问对齐的地址只需要一次总线操作,跨越边界则要拆成多次,所以编译器会主动牺牲一点空间换访问速度
(五)常量表:进 Flash 同样省 4 倍
- 同样的道理也适用于常量
const uint8_t table[]放在 Flash 里,比uint32_t版本省 4 倍- 纯
uint8_t数组在 Flash 里是紧凑摆放的,不存在对齐填充
uint8_t的省内存优势来自大块的、连续的数据,一旦落到单个变量上,对齐规则会把这点优势吃掉
三、我的第二个偏见:uint8_t 操作更快
在 32 位平台上,
int往往才是更快的那个,原子性和运算效率是两回事
(一)32 位平台的原生字长是 32 位
- 我原来想象的运算过程很朴素:读进来、算、写回去
- 用
int做运算,链路确实就是这样:- 从内存读出 4 字节(
LDR) - 在 32 位 ALU 里运算
- 写回 4 字节(
STR)
- 从内存读出 4 字节(
- 但用
uint8_t做同样的运算,链路会变长:- 读出 1 字节(
LDRB) - 零扩展成 32 位
- 在 32 位 ALU 里运算
- 截断回 8 位
- 写回 1 字节(
STRB)
- 读出 1 字节(
- 多出来的零扩展与截断,就是纯粹的额外开销
- 在密集计算的循环里,
uint8_t反而比int慢 - 这些额外指令还会撑大 Flash 里的代码段(
.text)
- 在密集计算的循环里,
- 更隐蔽的一层是整型提升
- C 语言规定小于
int的类型参与运算时,会先被提升成int - 也就是说,编译器眼里
uint8_t的运算本来就是在 32 位宽度下做的 - 只是进出内存时多了一趟打包和解包
- C 语言规定小于
(二)搬运是例外
- 不过
uint8_t也不是处处吃亏 - 如果只是存储与搬运(
memcpy、数组遍历、DMA),它并不慢LDRB/STRB与LDR/STR本身的耗时接近- 搬运同样大小的数据时,
uint8_t的总字节数只有 1/4,总线占用反而更少
- 所以「存」和「算」得分开看
- 省内存的优势在搬运上能兑现
- 在运算上兑换不了,甚至要倒贴
(三)原子性也不等于高效率
- 回到我当初那条推理链,它断在第二步
- 前半句对,后半句不成立
- 「不用加保护」说明的是这个操作不会被打断,省掉的是互斥的开销
- 它说明不了这个操作本身执行得有多快
- 在 Cortex-M、ESP32 这类 32 位平台上,硬件对 8 位和 32 位都提供了原生原子指令
- 原子
uint8_t与原子uint32_t的耗时基本相同,不存在谁快谁慢 - 选 8 位的收益依然是省内存,不是提速
- 原子
- 这里还得分清:硬件层面的原子,和 C 语言层面承诺的原子,不是一回事
- 硬件通常保证对齐的 8 位访问不被打断,但编译器并不知道你在依赖这一点
- 它可能把访问拆开,也可能调整顺序,你的假设就这么被打破了
- 所以该上原子变量就上原子变量,C11 起标准库有了
stdatomic.h,正是干这个的- 让编译器明确知道你依赖的是原子性,而不是靠「它够小」去赌一个语言层面并不保证的行为
小结:32 位平台上 CPU 最喜欢的字长就是 32 位,用
int运算反而最省事;uint8_t快的地方在搬运和存储,不在计算
四、那到底该怎么选
- 两个偏见都被推翻了,但是我冒出了一个新的困惑:那我到底该用哪个
- 答案其实被前面两节体现出来了:看这个变量是用来「传数据」还是用来「算东西」
- 两个维度交叉一下,就得到一张能直接对号入座的表
(一)按使用场景对号入座
| 使用场景 | 空间敏感度 | 时间敏感度 | 推荐做法 |
|---|---|---|---|
| 通信协议、硬件寄存器映射 | 极高,必须严格按字节对齐 | 低,允许一定延迟 | 严格用 uint8_t/uint16_t/uint32_t |
| 结构体、数组缓冲区 | 极高,要死抠 SRAM | 低,只需顺序读写 | 定宽类型 + 成员从大到小排序 |
| 局部变量、循环计数、密集计算 | 低,栈会自动对齐 | 极高,要最快 | 用 int 或 int32_t |
| 多任务共享的标志位 | 高,要省 SRAM | 中,要求原子性 | uint8_t,并确认架构支持无锁原子 |
| 布尔值与枚举的存储 | 取决于是否放进结构体 | 低 | 逻辑判断用 bool/enum;一旦要进结构体或对外通信,退化回 uint8_t |
(二)一句话准则
要传数据,用定宽类型卡死内存布局;要算东西,用
int换执行效率
- 更细一点的三条:
- 凡是要和外部对上号的(协议报文、寄存器、Flash 存储、结构体布局),一律用
stdint.h的定宽类型,绝不用short、long - 凡是只活在函数内部的(局部变量、循环计数、返回值),大胆用
int - 不要用
char做算术,因为它到底有没有符号,是由编译器决定的
- 凡是要和外部对上号的(协议报文、寄存器、Flash 存储、结构体布局),一律用
五、混用会带来混乱吗
- 这是我顺着上面的结论往下想时冒出来的问题
- 既然一部分地方用定宽类型、一部分地方用
int - 那代码里不就变成大杂烩了吗
- 既然一部分地方用定宽类型、一部分地方用
- 答案是:混用本身没问题,隐式转换才是问题
(一)混乱的根源:隐式转换
- C 语言有一条规则:参与算术运算时,比
int小的类型会先被自动提升为int - 这条规则让很多看起来理所当然的判断反过来
- 例子一:以为会溢出,其实不会
uint8_t a = 200; uint8_t b = 100; // 想当然以为 300 会回绕成 44,但 a 和 b 先被提升成 int 了,结果是 300 if (a + b > 255) { ... } - 例子二:以为 10 比 -20 大,其实反了
unsigned int x = 10; int y = -20; // 这里 y 被转成无符号参与比较,-20 变成一个巨大的正数,所以 x > y 不成立 if (x > y) { ... } - 这两个例子的共同点是:编译器偷偷做了你没写的转换,而转换的方向往往和你脑补的相反
(二)几点做法
- 按数据流向分层:定宽类型管「数据流」(通信、存储、硬件映射),
int管「逻辑控制」(计算、循环)- 这条边界一旦划清,混用就是有章法的,而不是随手的
- 显式强转:不要指望编译器替你决定,该转的地方自己写出来
uint8_t a = 100; int b = 200; a = (uint8_t)(a + b); // 写清楚,而不是 a = a + b;- 显式转换会让代码变啰嗦,到处都是括号
- 但每一个强转都是你主动签下的契约,不会在某次编译后悄悄改变行为
- 枚举要进结构体或写进协议报文时,用
typedef uint8_t把底层宽度锁死enum的大小是「实现定义」的,换个编译器或换个编译选项就可能变- 一旦它要对外,布局变一点就整个错位,这时候必须用
typedef uint8_t顶上去 - 这也正好收回前言里那两个动作
- 我当初把
bool和枚举的存储类型都换成了uint8_t,理由是「省内存」 bool通常本来就是 1 字节,换与不换都省不出什么- 枚举则要看用在哪:只做局部判断是白费,写进协议报文才算必须
- 我当初把这两件事当成同一件,其实它们的道理完全不同
- 我当初把
小结:混用不可避免,也不需要消灭,把它约束在一个可预测的范围内就够了
六、结语
- 回过头看,我踩的其实是同一个坑:把「类型名写着几个字节」直接等同于「它在机器上就是几个字节、几拍」
- 中间隔着两层东西
- 一层是编译器:它管对齐、管填充、管整型提升,比我更清楚 CPU 喜欢什么
- 一层是硬件:它有自己的字长和总线宽度,1 字节从来不是它最舒服的单位
- 所以
uint8_t这些类型真正的价值,不是「省」也不是「快」,而是确定性int是几字节,C 标准没规定,换个平台就可能变uint8_t是几字节,全世界都一样,协议和寄存器才敢照着它写
- 现在再看到别人在业务代码里用
int,我不会觉得奇怪了- 那不是老式习惯,那是站在 32 位字长上做出的合理选择

浙公网安备 33010602011771号