3. 数据类型优化:整型与浮点型的选择、位域与联合体的妙用、枚举替代宏定义

内存优化这件事,说白了就是跟每一个字节较劲。触摸屏的显存、帧缓冲、触控数据缓冲区,哪个不是寸土寸金?我早年做一款低端工控屏时,就因为多用了几个浮点变量,导致整屏刷新卡了半秒。从那以后,我对数据类型的选择就格外敏感。

这一章咱们就聊聊数据类型的“瘦身术”。你想想看,同样的数据,用不同的类型存,占的内存能差出好几倍。选对了,系统跑得飞快;选错了,内存爆了都不知道怎么死的。

核心原则:能用整型就别用浮点,能用8位就别用32位,能用位域就别用结构体,能用枚举就别用宏。

3.1 整型与浮点型:选错了就是灾难

先问个问题:触摸屏上报的坐标值,范围是0到4095,你会用什么类型存?

我见过有人直接用 float,理由是“万一以后要算平均值呢”。嗯,这个“万一”代价可不小——一个 float 占4字节,而 uint16_t 只要2字节。如果缓冲区里有100个坐标点,这就差了200字节。对于只有几十KB RAM的MCU来说,这可不是小数目。

数据类型 字节数 取值范围 适用场景
uint8_t 1 0 ~ 255 小范围计数、状态标志、小尺寸坐标
uint16_t 2 0 ~ 65535 触摸坐标、ADC采样值、短数据包长度
uint32_t 4 0 ~ 4.29e9 时间戳、大缓冲区索引、长计数器
float 4 ±1.18e-38 ~ ±3.4e38 需要小数运算且精度要求不高
double 8 ±2.23e-308 ~ ±1.8e308 高精度科学计算(嵌入式慎用)

我个人习惯是:能用整型绝对不用浮点。触摸屏的坐标、压力值、手势参数,绝大多数都是整数。就算要算平均值,也可以用定点数模拟——比如把数值放大100倍存成整型,最后再除回来。这样既省内存,又避免了浮点运算的CPU开销。

小技巧:如果实在要用浮点,考虑用 float 代替 double。在ARM Cortex-M4以上的芯片里,float 有硬件加速,而 double 全靠软件模拟,又慢又占内存。

3.2 位域与联合体:把字节掰开用

你有没有遇到过这种情况:一个结构体里全是1位或2位的标志位,结果每个成员都占了一个完整的字节?

我曾经接手过一个触摸屏驱动,里面有个结构体存了8个布尔标志。每个标志用 uint8_t 定义,8个标志就占了8字节。其实它们完全可以挤在一个字节里——用位域就能搞定。

// 优化前:8字节
struct touch_flags {
    uint8_t touch_down;
    uint8_t multi_touch;
    uint8_t gesture_valid;
    uint8_t palm_reject;
    uint8_t hover_mode;
    uint8_t pen_active;
    uint8_t eraser_mode;
    uint8_t reserved;
};

// 优化后:1字节
struct touch_flags {
    uint8_t touch_down   : 1;
    uint8_t multi_touch  : 1;
    uint8_t gesture_valid: 1;
    uint8_t palm_reject  : 1;
    uint8_t hover_mode   : 1;
    uint8_t pen_active   : 1;
    uint8_t eraser_mode  : 1;
    uint8_t reserved     : 1;
};

看到了吗?从8字节降到1字节,省了87.5%。而且代码可读性一点没降,该有的成员名都在。

联合体就更妙了。它能让不同的数据类型共享同一块内存。比如触摸屏上报的原始数据包,有时是坐标对,有时是手势编码,但不会同时出现。用联合体就能让它们共用缓冲区。

union touch_data {
    struct {
        uint16_t x;
        uint16_t y;
        uint16_t pressure;
    } point;
    struct {
        uint8_t gesture_id;
        uint8_t param1;
        uint8_t param2;
    } gesture;
    uint8_t raw[6];  // 原始字节流
};

这个联合体大小只有6字节。如果不用联合体,两个结构体各自定义,就得占4+3=7字节(还要考虑对齐填充)。虽然只省了1字节,但在大量数据包缓冲时,积少成多就很可观了。

注意:位域的布局是编译器相关的。不同编译器可能把位域从低位到高位或从高位到低位排列。跨平台时一定要确认。我曾经在ARM和RISC-V之间移植代码,就因为位域顺序不同,调试了整整一天。

3.3 枚举替代宏定义:让代码更安全、更省心

很多老工程师喜欢用宏定义来写状态和常量:

#define TOUCH_STATE_IDLE     0
#define TOUCH_STATE_DOWN     1
#define TOUCH_STATE_MOVE     2
#define TOUCH_STATE_UP       3

这样写有什么问题?宏定义只是简单的文本替换,编译器不会做类型检查。你一不小心把 TOUCH_STATE_DOWN 赋值给一个颜色变量,编译器都不会报错。

我建议用枚举替代:

typedef enum {
    TOUCH_STATE_IDLE = 0,
    TOUCH_STATE_DOWN,
    TOUCH_STATE_MOVE,
    TOUCH_STATE_UP
} touch_state_t;

枚举的好处很明显:

  • 类型安全:编译器会检查赋值是否匹配
  • 调试友好:调试器能直接显示枚举名,而不是数字
  • 内存可控:默认是 int 大小,但可以用编译器扩展指定更小的类型
  • 自动编号:不用手动维护数字,增删状态不会乱

而且枚举还能省内存。如果你用宏定义,通常得配一个 uint8_t 变量来存状态。但枚举变量默认是 int(4字节),反而更费内存。怎么办?

在GCC和ARMCC里,可以用 __attribute__((packed)) 或 -fshort-enums 让枚举使用最小类型:

typedef enum __attribute__((packed)) {
    TOUCH_STATE_IDLE = 0,
    TOUCH_STATE_DOWN,
    TOUCH_STATE_MOVE,
    TOUCH_STATE_UP
} touch_state_t;  // 实际只占1字节

这样枚举变量就只占1字节了,跟 uint8_t 一样省,还多了类型安全。

经验之谈:我在一个项目中把200多个宏定义全部改成了枚举,编译后RAM占用减少了约300字节。虽然不多,但代码的可维护性提升了一大截。后来新同事接手,看枚举名就知道每个状态的含义,再也不用翻头文件找宏定义了。

3.4 知识体系总览

下面这张图总结了本章的核心逻辑。你可以看到,从数据类型的选型到具体的内存布局技巧,每一步都在跟字节较劲。

数据类型优化知识体系 数据类型优化 整型 vs 浮点型 位域与联合体 枚举替代宏定义 uint8/16/32 float/double 位域压缩 联合体共享 类型安全 内存可控 核心目标:在不损失功能的前提下, 把每个字节的利用率压到极致

3.5 实战建议与避坑指南

说了这么多,最后给几条实在的建议:

  1. 先分析数据范围,再选类型。触摸坐标0~4095,用 uint16_t 就够了。别上来就 int 或 float。
  2. 位域虽好,别滥用。如果位域跨了字节边界,编译器可能会生成低效的读写代码。我一般只在标志位和状态字段上用位域。
  3. 联合体要注意字节序。触摸屏的原始数据包可能是小端序,但你的MCU可能是大端序。用联合体解析时一定要确认。
  4. 枚举用 packed 属性。如果不指定,枚举变量默认是 int 大小,反而比宏定义更费内存。
  5. 定期做内存审计。我习惯在编译后查看map文件,检查每个全局变量和结构体的大小。发现异常大的变量,就回头看看能不能优化。

我曾经踩过的坑:有一次我用联合体解析触摸屏的I2C数据包,结构体里有个位域,联合体里有个 uint8_t 数组。结果因为位域的排列顺序跟数据包的比特顺序不一致,解析出来的坐标全是乱的。后来我加了一个 static_assert 来验证联合体的大小和布局,才彻底解决了这个问题。