关于嵌入式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 空间
  • 位置不同,对齐规则对它的影响也不同
    • 这正是我当初没意识到的地方

(二)单个标量:几乎省不出来

  • 我原以为随便定义个 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_t
    • uint8_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)
  • 但用 uint8_t 做同样的运算,链路会变长:
    • 读出 1 字节(LDRB)
    • 零扩展成 32 位
    • 在 32 位 ALU 里运算
    • 截断回 8 位
    • 写回 1 字节(STRB)
  • 多出来的零扩展与截断,就是纯粹的额外开销
    • 在密集计算的循环里,uint8_t 反而比 int 慢
    • 这些额外指令还会撑大 Flash 里的代码段(.text)
  • 更隐蔽的一层是整型提升
    • C 语言规定小于 int 的类型参与运算时,会先被提升成 int
    • 也就是说,编译器眼里 uint8_t 的运算本来就是在 32 位宽度下做的
    • 只是进出内存时多了一趟打包和解包

(二)搬运是例外

  • 不过 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 做算术,因为它到底有没有符号,是由编译器决定的

五、混用会带来混乱吗

  • 这是我顺着上面的结论往下想时冒出来的问题
    • 既然一部分地方用定宽类型、一部分地方用 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 位字长上做出的合理选择
posted @ 2026-09-22 00:36  临祁  阅读(17)  评论(0)    收藏  举报