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 使用 #pragma pack 手动控制对齐
有时候,结构体成员顺序没法调(比如要和硬件寄存器一一对应),或者你希望更紧凑。这时可以用 #pragma pack 指令。
#pragma pack(1) // 按1字节对齐,取消填充
typedef struct {
char a;
int b;
char c;
} PackedWidgetInfo; // 现在占6字节
#pragma pack() // 恢复默认对齐
但这里有个坑——性能换空间。取消对齐后,CPU 访问未对齐的 int 成员 b 时,可能需要两次内存访问,而不是一次。在界面刷新频繁的场景下,这个代价可能不小。
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字节以上。
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字节,实际因对齐可能更大
我们来优化它:
- 调整成员顺序:把4字节的放前面,2字节的放中间,1字节的放后面。
- 位域化标志位:把 visible、selected、enabled、focused 合并成一个 uint8_t 位域。
- 联合体处理颜色:如果颜色不需要同时使用整体值和分量值,可以用联合体。
优化后的版本:
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字节可能就决定了你的界面能不能跑起来。