4、数据结构优化:结构体对齐与填充、位域的使用、联合体(union)节省内存

说到内存优化,很多人第一反应是「少定义几个变量」。但真正做过嵌入式界面移植的老手都知道——数据结构的布局,才是内存占用的隐形杀手。同样的几个成员,排列顺序不同,占的空间可能差出一倍。

我刚开始做界面移植时,就吃过这个亏。一个结构体里放了几个标志位和计数器,心想也就十几个字节,结果一算总内存,愣是多占了40多个字节。后来一查,全是结构体对齐在作祟。

这一节,我们就来聊聊怎么从数据结构层面「挤」出内存。核心就三招:结构体对齐与填充、位域、联合体。

4.1 结构体对齐:编译器偷偷塞的「填充字节」

先看一个例子。假设我们定义这样一个结构体:

typedef struct {
    char    a;      // 1字节
    int     b;      // 4字节
    char    c;      // 1字节
} WidgetInfo;

你觉得它占多少字节?1 + 4 + 1 = 6?不对。在32位系统上,它实际占 12字节。为什么?因为编译器为了保证 int 成员 b 的地址是4字节对齐,会在 a 后面塞3个填充字节,在 c 后面再塞3个填充字节。

这就是结构体对齐的规则:每个成员的起始地址必须是其自身大小的整数倍,整个结构体的大小必须是最大成员大小的整数倍。

我个人习惯,在定义结构体时,会按成员大小从大到小排列。把大的放前面,小的放后面,能最大程度减少填充。上面的例子改成这样:

typedef struct {
    int     b;      // 4字节
    char    a;      // 1字节
    char    c;      // 1字节
} WidgetInfo;       // 现在只占8字节

你看,只是调了个顺序,就从12字节降到了8字节。省了33%。

我的经验:在界面移植中,结构体往往用来描述控件属性、坐标、颜色值等。把这些结构体成员按「4字节→2字节→1字节」的顺序排列,能省下不少填充开销。我曾在一次移植中,仅靠调整结构体成员顺序,就省了200多字节的RAM。

4.2 使用 #pragma pack 手动控制对齐

有时候,结构体成员顺序没法调(比如要和硬件寄存器一一对应),或者你希望更紧凑。这时可以用 #pragma pack 指令。

#pragma pack(1)     // 按1字节对齐,取消填充
typedef struct {
    char    a;
    int     b;
    char    c;
} PackedWidgetInfo; // 现在占6字节
#pragma pack()      // 恢复默认对齐

但这里有个坑——性能换空间。取消对齐后,CPU 访问未对齐的 int 成员 b 时,可能需要两次内存访问,而不是一次。在界面刷新频繁的场景下,这个代价可能不小。

我曾经踩过的坑:在一个触摸屏界面项目中,我为了省内存,把所有结构体都 pack(1) 了。结果界面滑动时明显卡顿。后来发现是未对齐访问导致 CPU 频繁进入异常处理。最后只在那些「不常访问但数量多」的结构体上用了 pack(1),比如存储控件坐标的数组。

4.3 位域:把多个标志位塞进一个字节

界面移植中,经常需要记录控件的各种状态:是否可见、是否选中、是否启用、是否聚焦……每个状态只需要1位。如果用 char 或 int 来存,太浪费了。

位域就是干这个的。它允许你把多个标志位「挤」在一个字节或几个字节里。

typedef struct {
    unsigned int visible   : 1;   // 1位
    unsigned int selected  : 1;   // 1位
    unsigned int enabled   : 1;   // 1位
    unsigned int focused   : 1;   // 1位
    unsigned int reserved  : 4;   // 剩余4位,备用
} WidgetFlags;                   // 总共只占4字节(一个unsigned int)

如果不使用位域,4个标志位需要4个 char,占4字节。用了位域,还是4字节,但留出了扩展空间。如果标志位更多,比如有8个,位域仍然只占4字节,而普通方式要占8字节。

我个人习惯,在界面控件结构体中,把所有布尔类型的属性都做成位域。比如:

typedef struct {
    int16_t x, y;               // 坐标
    int16_t width, height;      // 尺寸
    uint32_t color;             // 颜色
    uint16_t flags;             // 位域标志,占2字节
} Widget;

然后在 flags 里用位域定义各种状态。这样既清晰又省空间。

关键点:位域的大小不能超过其基础类型。比如 unsigned int 的位域最多32位。如果标志位超过32个,可以拆成多个位域成员,或者用多个 uint32_t。

4.4 联合体(union):同一块内存,多种解读方式

联合体是我在界面移植中用的最多的技巧之一。它的核心思想是:同一块内存,在不同场景下被当作不同类型来用。

举个例子。界面中经常需要处理颜色值。有时你需要把它当作一个32位的整型(方便传参),有时需要单独操作 R、G、B、A 分量(方便修改)。用联合体就能完美解决:

typedef union {
    uint32_t value;             // 当作一个32位整型
    struct {
        uint8_t b;              // 蓝色分量
        uint8_t g;              // 绿色分量
        uint8_t r;              // 红色分量
        uint8_t a;              // 透明度分量
    } components;
} Color;

这样,color.value 和 color.components.r 访问的是同一块4字节内存。你既可以整体赋值,也可以单独修改某个分量。不浪费一个字节。

另一个常见场景是协议解析。界面和底层驱动之间经常通过消息队列传递数据。一条消息可能是按键事件、触摸坐标、刷新指令……每种消息的数据格式不同。用联合体可以这样设计:

typedef struct {
    uint8_t type;               // 消息类型
    union {
        struct {
            uint16_t key_code;
            uint8_t  press;
        } key_event;
        struct {
            uint16_t x;
            uint16_t y;
        } touch_event;
        struct {
            uint8_t  widget_id;
            uint8_t  refresh_type;
        } refresh_cmd;
    } data;
} Message;                      // 整个消息只占 1 + 4 = 5字节

如果不使用联合体,每种消息的数据都单独定义,这个结构体可能要占10字节以上。

我的经验:联合体特别适合用在「多选一」的场景。比如控件的属性值,可能是整数、浮点数、字符串指针。用联合体定义属性值,比用 void* 更安全,也更省内存。我在一个菜单系统中,用联合体把每个菜单项的「值」从8字节降到了4字节。

4.5 三种技巧的对比与选择

说了这么多,到底什么时候用哪种?我整理了一个对比表,方便你参考:

技巧 适用场景 节省效果 注意事项
结构体对齐优化 结构体成员较多,且大小不一 10%~50% 调整成员顺序即可,无副作用
#pragma pack 需要极致紧凑,或与硬件寄存器对齐 20%~60% 可能降低访问速度,谨慎使用
位域 多个布尔标志位或小范围整数 50%~75% 位域操作比普通变量慢,注意位序
联合体 同一内存有多种解读方式 30%~50% 只能同时使用一个成员,注意类型安全

4.6 综合案例:优化一个控件结构体

最后,我们看一个实际案例。假设你有一个按钮控件结构体,最初是这样定义的:

typedef struct {
    int16_t x;              // 2字节
    int16_t y;              // 2字节
    int16_t width;          // 2字节
    int16_t height;         // 2字节
    uint32_t color;         // 4字节
    uint8_t visible;        // 1字节
    uint8_t selected;       // 1字节
    uint8_t enabled;        // 1字节
    uint8_t focused;        // 1字节
    char* label;            // 4字节(指针)
} Button;                   // 理论大小:20字节,实际因对齐可能更大

我们来优化它:

  1. 调整成员顺序:把4字节的放前面,2字节的放中间,1字节的放后面。
  2. 位域化标志位:把 visible、selected、enabled、focused 合并成一个 uint8_t 位域。
  3. 联合体处理颜色:如果颜色不需要同时使用整体值和分量值,可以用联合体。

优化后的版本:

typedef struct {
    char*   label;          // 4字节
    uint32_t color;         // 4字节
    int16_t x;              // 2字节
    int16_t y;              // 2字节
    int16_t width;          // 2字节
    int16_t height;         // 2字节
    uint8_t flags;          // 1字节(位域:visible:1, selected:1, enabled:1, focused:1, reserved:4)
} Button;                   // 实际大小:17字节

从20字节降到17字节,省了15%。如果按钮数量多(比如有100个),就是300字节的差距。在资源紧张的嵌入式系统里,这300字节可能就决定了你的界面能不能跑起来。

总结一下:数据结构优化是界面移植中「性价比」最高的内存优化手段。不需要改算法,不需要换硬件,只需要调整一下结构体的定义方式,就能省下可观的内存。我建议你在每次移植前,先把所有结构体过一遍,按本节的方法优化一遍,往往会有意外收获。
数据结构优化三招核心逻辑 数据结构优化 结构体对齐与填充 位域的使用 联合体(union) 调整成员顺序 #pragma pack 标志位压缩 小范围整数 颜色值复用 消息解析 核心原则 1. 结构体成员按大小降序排列,减少填充 2. 多个布尔标志位用位域压缩到同一字节 3. 同一内存多种用途时用联合体复用