4、数据对齐与填充:结构体对齐、packed属性、对齐宏的使用
好,咱们来聊聊数据对齐。这个话题,说大不大,说小不小。但你要是没处理好,程序跑起来要么慢得像蜗牛,要么直接崩给你看。我在嵌入式这行摸爬滚打这么多年,因为对齐问题栽过的跟头,两只手都数不过来。
说白了,数据对齐就是CPU读取数据时的一种「强迫症」。它喜欢从特定地址开始读数据,比如4字节的int,它希望地址能被4整除。如果没对齐,轻则多读几次,重则直接硬件异常。你想想看,这多耽误事。
4.1 结构体对齐的底层逻辑
先看个例子。你定义一个结构体:
struct example {
char a; // 1字节
int b; // 4字节
char c; // 1字节
};
你觉得它占多少字节?1+4+1=6?错。在我的ARM Cortex-M4上,它占12字节。为什么?因为编译器偷偷给你塞了「填充字节」。
对齐规则其实不复杂,就两条:
- 每个成员自己的对齐值:比如int是4,short是2,char是1。
- 结构体的整体对齐值:取所有成员中最大的那个对齐值。
编译器会保证每个成员的起始地址,都能被它自己的对齐值整除。不够?那就填充。我习惯把这个过程叫做「对齐到自然边界」。
拿上面那个结构体举例:
- a占地址0,没问题。
- b需要地址能被4整除,但地址1不行。所以编译器在a后面塞了3个字节,b从地址4开始。
- c占地址8,没问题。
- 结构体整体要对齐到4(最大成员是int),所以末尾再补3个字节,总大小变成12。
内存布局示意:
地址: 0 1 2 3 4 5 6 7 8 9 10 11
内容: a pad pad pad b b b b c pad pad pad
嗯,这里要注意:不同编译器、不同架构,对齐规则可能略有差异。但基本思想是一样的——用空间换时间。
4.2 手动优化:重排成员顺序
既然填充是编译器干的,那我们能不能让它少填点?当然可以。把大对齐值的成员往前放,小对齐值的往后放。
看这个优化版本:
struct example_optimized {
int b; // 4字节,放前面
char a; // 1字节
char c; // 1字节
};
现在b从地址0开始,a在地址4,c在地址5。整体对齐到4,末尾补2个字节,总共8字节。比之前的12字节省了33%。
我在项目中遇到过类似情况,一个包含几十个成员的结构体,通过重排顺序,从128字节压缩到96字节。在内存只有几十KB的MCU上,这省出来的空间能多放一个不小的缓冲区。
小技巧:写结构体时,养成习惯把指针、int、long这些大尺寸成员写在前面,char、short写在后面。这能帮你省不少内存。
4.3 packed属性:强制紧凑布局
有时候,我们就是不想让编译器填充。比如你要把一个结构体直接写入EEPROM,或者通过串口发送给另一个设备。这时候填充字节就是灾难——发过去的数据对不上。
GCC和ARMCC都提供了__attribute__((packed)),告诉编译器:别给我填充,所有成员紧挨着放。
struct __attribute__((packed)) packed_example {
char a;
int b;
char c;
};
这个结构体的大小就是1+4+1=6字节。成员a在地址0,b在地址1,c在地址5。注意,b的地址1不能被4整除,这就是未对齐访问。
警告:packed结构体中的成员可能未对齐。某些CPU(比如ARMv7-M之前的Cortex-M3/M4)支持未对齐访问,但性能会下降。而有些CPU(比如某些Cortex-M0)直接不支持,访问未对齐地址会触发HardFault。我曾经在一个Cortex-M0项目里用了packed,结果程序跑着跑着就死机,查了两天才发现是这个问题。
所以我的建议是:
- 只在需要精确控制内存布局时用packed,比如通信协议、存储结构。
- 如果用了packed,访问成员时尽量用memcpy,或者确保CPU支持未对齐访问。
- 不要在整个结构体上滥用packed,能局部packed就局部packed。
4.4 对齐宏:让代码更可移植
不同编译器、不同平台,对齐相关的语法不一样。为了让代码能跨平台编译,我习惯用宏来封装。
下面是我个人常用的几个对齐宏:
// 对齐到n字节边界(n必须是2的幂)
#define ALIGN_UP(x, n) (((x) + (n) - 1) & ~((n) - 1))
#define ALIGN_DOWN(x, n) ((x) & ~((n) - 1))
// 检查地址是否对齐
#define IS_ALIGNED(x, n) (((x) & ((n) - 1)) == 0)
// 获取结构体成员的对齐值(GCC扩展)
#define ALIGN_OF(type, member) __alignof__(((type*)0)->member)
这些宏有什么用?举个例子。你要分配一块内存,存放一个结构体数组,但要求每个结构体都对齐到4字节:
#define STRUCT_SIZE (sizeof(struct my_struct))
#define ALIGNED_SIZE ALIGN_UP(STRUCT_SIZE, 4)
uint8_t buffer[ALIGNED_SIZE * 10];
struct my_struct *p = (struct my_struct *)buffer;
这样不管编译器怎么对齐,你的buffer都能保证每个结构体实例的起始地址是4的倍数。
再比如,你要判断一个指针是否对齐:
void *ptr = get_some_buffer();
if (!IS_ALIGNED((uintptr_t)ptr, 4)) {
// 处理未对齐的情况
// 我一般会在这里打一条日志,或者做一次memcpy
}
这些宏看起来简单,但用好了能省很多事。我在做跨平台驱动时,经常用它们来统一不同架构的对齐行为。
4.5 实战中的避坑指南
最后,分享几个我踩过的坑:
- 结构体拷贝别用memcmp:填充字节的内容是未定义的,两个逻辑相同的结构体,memcmp可能返回不相等。我曾经因为这个bug,排查了整整一个下午。
- 小心位域的对齐:位域的对齐规则更复杂,不同编译器差异很大。能用普通成员就别用位域,除非你非常清楚后果。
- DMA缓冲区必须对齐:很多DMA控制器要求缓冲区地址对齐到4字节甚至16字节。不满足的话,DMA传输会直接失败。
- 强制类型转换要谨慎:把一个char*强制转成int*,然后解引用,很可能触发未对齐访问。我习惯用memcpy来安全地读取未对齐数据。
核心原则:在嵌入式C中,对齐不是「要不要做」的问题,而是「怎么做才正确」的问题。理解对齐规则,善用packed和对齐宏,能让你的代码既高效又可移植。
好了,关于数据对齐与填充,就聊这么多。记住,对齐问题平时不显山不露水,但一旦出问题,往往就是硬故障。多花点心思在这上面,绝对值得。