无法回避的字节对齐问题,从八个方向深入探讨(变量对齐,栈对齐,DMA对齐,结构体成员对齐
Chapter1 无法回避的字节对齐问题,从八个方向深入探讨(变量对齐,栈对齐,DMA对齐,结构体成员对齐
原文链接:https://blog.csdn.net/Simon223/article/details/121198442
【本文为安富莱电子原创】
本期的知识点要稍微烧点脑细胞,因为字节对齐问题涉及到的地方太多,且无法规避,必须硬着头皮上。
下面要说的每个技术点,其实都可以专门开一个帖子说,所以我们这里的讨论,争取言简意赅,并配上官方文档和实验数据,力求有理有据。如果讲解有误的地方,欢迎大家指正,我们主要讨论M0,M0+, M3,M4和M7内核。
一、引出问题:
字节对齐的含义:4字节对齐的含义就是变量地址对4求余数为0; 8字节对齐就是地址对8求余等于0,依次类推:
比如
uint32_t *p;
p=(uint32_t *)0x20000004; 这个地址是4字节对齐。
如果让p去访问0x20000001, 0x20000002,0x20000003这都是不对齐访问。
二、背景知识:
对于M3和M4而言,可以直接访问非对齐地址(注意芯片要在这个地址有对应的内存空间), 因为M3和M4是支持的,而M0/M0+/M1是不支持的,不支持内核芯片,只要非对齐访问就会触发硬件异常。
M7内核也支持非对齐访问,在M7的TRM中描述如下:
三、全局变量对齐问题:
基本上用户定义的变量是几个字节就是几字节对齐,这个比较好理解。
uint8_t定义变量地址要1字节对齐。
uint16_t定义变量地址要2字节对齐。
uint32_t定义变量地址要4字节对齐。
uint64_t定义变量地址要8字节对齐。
指针变量是4字节对齐。
四、结构体成员对齐问题:
首先明白一点,结构体里面的变量是什么类型,此变量的位置就是至少要几字节对齐,所以就存在结构体实际占用大小不是这些变量之和。
typedef struct
{
uint8_t a;
uint16_t b;
uint32_t c;
uint64_t d;
}info;
这种定义,info占用了16字节,a单字节对齐,b是两字节对齐,而c要是4字节对齐,从出现b定义完毕后空出来1个字节未被使用。d是8字节对齐,这样就是16字节。而我们切换下变量定义顺序:
typedef struct
{
uint16_t b;
uint32_t c;
uint64_t d;
uint8_t a;
}info;
这种定义就要占用24字节,b占用2字节对齐,c需要4字节对齐,这样就空出来2两个字节未使用,d占用8字节,最后一个a占用了8字节。
如果想定义几个变量就几个字节,变量前面加前缀__packed即可。
不管是上面那种定义方式,都是占用15个字节。
__packed typedef struct
{
uint8_t a; 1个
uint16_t b; 2个
uint32_t c; 4个
uint64_t d; 8个
}info;
五、局部变量对齐问题:
局部变量使用的是栈空间(除了静态局部变量和编译器优化不使用栈,直接用寄存器做变量空间),也就是大家使用在xxxx.S启动文件开辟的stack空间。
在M内核里面,局部变量的对齐问题如果研究起来是最烧脑的,这个涉及到AAPCS规约(Procedure Call Standard for the Arm Architecture, Arm架构的程序调用标准)。
六、硬件浮点对齐问题
如果使用的是带FPU硬件浮点单元的M内核芯片就要注意对齐访问了,访问单精度浮点数访问一定要4字节对齐,双精度要8字节对齐。
比如我们使用支持单精度浮点的M4内核芯片,测试代码如下:
MDK直接给你来个不对齐硬件异常:
Chapter2 stm32中字节对齐问题(__align(n),__packed用法)
ARM下的对齐处理
from DUI0067D_ADS1_2_CompLib
3.13 type qulifiers
有部分摘自ARM编译器文档对齐部分
对齐的使用:
1.__align(num)
这个用于修改最高级别对象的字节边界。在汇编中使用LDRD或者STRD时
就要用到此命令__align(8)进行修饰限制,来保证数据对象是相应对齐。
这个修饰对象的命令最大是8个字节限制,可以让2字节的对象进行4字节
对齐,但是不能让4字节的对象2字节对齐。
__align是存储类修改,他只修饰最高级类型对象,不能用于结构或者函数对象。
比如:__align(4) u8 mem1base[MEM1_MAX_SIZE];//保证分配的数组空间4字节对齐,同时保证数组首地址可被4整除
2.__packed
__packed是进行一字节对齐
1.不能对packed的对象进行对齐
2.所有对象的读写访问都进行非对齐访问
3.float及包含float的结构联合及未用__packed的对象将不能字节对齐
4.__packed对局部整形变量无影响
5.强制由unpacked对象向packed对象转化是未定义,整形指针可以合法定
义为packed。
__packed int* p; //__packed int 则没有意义
6.对齐或非对齐读写访问带来问题
__packed struct STRUCT_TEST
{
char a;
int b;
char c;
} ; //定义如下结构此时b的起始地址一定是不对齐的
//在栈中访问b可能有问题,因为栈上数据肯定是对齐访问[from CL]
//将下面变量定义成全局静态不在栈上
static char* p;
static struct STRUCT_TEST a;
void Main()
{
__packed int* q; //此时定义成__packed来修饰当前q指向为非对齐的数据地址下面的访问则可以
p = (char*)&a;
q = (int*)(p+1);
q = 0x87654321;
/
得到赋值的汇编指令很清楚
ldr r5,0x20001590 ; = #0x12345678
[0xe1a00005] mov r0,r5
[0xeb0000b0] bl __rt_uwrite4 //在此处调用一个写4byte的操作函数
[0xe5c10000] strb r0,[r1,#0] //函数进行4次strb操作然后返回保证了数据正确的访问
[0xe1a02420] mov r2,r0,lsr #8
[0xe5c12001] strb r2,[r1,#1]
[0xe1a02820] mov r2,r0,lsr #16
[0xe5c12002] strb r2,[r1,#2]
[0xe1a02c20] mov r2,r0,lsr #24
[0xe5c12003] strb r2,[r1,#3]
[0xe1a0f00e] mov pc,r14
*/
/*
如果q没有加__packed修饰则汇编出来指令是这样直接会导致奇地址处访问失败
[0xe59f2018] ldr r2,0x20001594 ; = #0x87654321
[0xe5812000] str r2,[r1,#0]
*/
//这样可以很清楚的看到非对齐访问是如何产生错误的
//以及如何消除非对齐访问带来问题
//也可以看到非对齐访问和对齐访问的指令差异导致效率问题
}
比如:
typedef __packed struct READ_Command
{
u_char code;
u_int addr;
u_char len;
} READ_Command;
与
typedef struct READ_Command
{
u_char code;
u_int addr;
u_char len;
} READ_Command;
的区别是什么啊?
回答:没有__packed的会出现字对齐等也就是,char型的有可能是占用4个字节的长度的内存空间有__packed 的就不会,就肯定是1个字节的内存空间,是gcc编译器的关键字。(不止vc下面32位的系统里面的内存数据的存取是32位的,处理的时候都是4个字节为单位,通常也就是int的长度。如果不定义压缩方式,也就是编译选项没有诸如#pragma pack(1)之类的,那么系统会进行4字节对齐)
注意:_packed只是某种编译器的格式压缩,有的是pack呢,对不同的CPU压缩的对齐方式也不一样,在使用了该关键以后在进行操作时需要格外小心。
声明结构类型时,可以包含一个保留字packed,用于实现压缩数据存储。
当一个记录类型在 {$A-} 状态下声明或者声明中包括了保留字 packed 时,记录中的字段不被调整,而替换为赋予连续的偏移量。这样一个压缩记录的总尺寸就是所有字段的尺寸的和。因为数据调整尺寸可能改变(如不同版本的编译器对同一种数据类型的调整值可能不同),所以当想要把记录写入磁盘时或者在内存中传递到另一模块而该模块由不同版本的编译器编译时,最好还是压缩所有的记录。(delphi borland 中也有该关键字)
3.在 Cotex-M3 programming manual 中有提到对齐问题
1.通常编译器在生成代码的时候都会进行结构体填充,保证(结构体内部成员)最高性能的对齐方式。
2.编译器自动分配出来结构体的内存(比如定义为全局变量或局部变量)肯定是对齐的。
3.查阅帮助文档的malloc部分,mdk的标准malloc申请的内存区时8字节对齐的。
4.若自定义的malloc函数本身没有对分配的内存实现4字节或以上的对齐操作,分配出来的不对齐的内存,编译器是不知道的,所以很可能会产生问题。
此时最好的解决方式在内存池数组前添加__align(4)关键字,只需保证自定义malloc分配出来的首地址是4字节对齐。
比如:__align(4) u8 mem1base[MEM1_MAX_SIZE];
相关更多stm32字节对齐问题的讨论,请参考正点原子相关帖子http://www.openedv.com/thread-7415-1-1.html。
其中问题的关键就在于正点原子自定义的mymalloc函数没有实现4字节对齐。
Chapter3 结构体内存对齐与位域性能优化–基于STM32的智能手表开发实战
原文链接:https://blog.csdn.net/weixin_45781584/article/details/145883549
一、问题引入:嵌入式开发中的内存困局
在开发基于STM32F4(Cortex-M4)+FreeRTOS的智能手表项目时,我遇到了一个典型的内存管理挑战:在实现多传感器数据融合模块时,原有数据结构导致SRAM消耗超出预期23%。
1.1 原始传感器数据结构
typedef struct {
uint32_t timestamp; // 4字节
uint8_t accel[3]; // 3字节
uint16_t pressure; // 2字节
float temperature; // 4字节
bool step_flag; // 1字节
bool fall_detect; // 1字节
} SensorData; // 预期15字节
通过sizeof(SensorData)实际测得24字节,内存浪费高达37.5%!在需要维护500组历史数据的环形缓冲区中,这种浪费直接导致16.5KB的额外内存开销。
1.2 实时性问题凸显
在FreeRTOS任务调度中,我们观察到以下异常:
数据采集任务(优先级20)周期从5ms抖动到7-9ms
数据处理任务出现偶发性的队列溢出
系统空闲内存降至警戒线以下
二、结构体内存对齐原理剖析
2.1 Cortex-M4内存访问特性
2.2 原始结构体内存布局
// 内存布局示意图
[timestamp(4)][accel[3]+padding(4)][pressure(2)+padding(2)][temperature(4)][step_flag(1)+fall_detect(1)+padding(2)]
2.3 优化策略对比
三、实战优化:双重优化方案实施
3.1 成员重排优化
typedef struct {
uint32_t timestamp; // 4字节(偏移0)
float temperature; // 4字节(偏移4)
uint16_t pressure; // 2字节(偏移8)
uint8_t accel[3]; // 3字节(偏移10)
bool step_flag; // 1字节(偏移13)
bool fall_detect; // 1字节(偏移14)
} __attribute__((packed)) SensorDataOpt1; // 实测15字节
通过GDB内存验证:
(gdb) p &sensor.timestamp // 0x20001f00
(gdb) p &sensor.temperature // 0x20001f04 (正确对齐)
(gdb) p &sensor.pressure // 0x20001f08 (2字节对齐)
3.2 位域深度优化
typedef struct {
uint32_t timestamp;
float temperature;
uint16_t pressure;
uint8_t accel[3];
union {
struct {
uint8_t step_flag : 1;
uint8_t fall_detect : 1;
uint8_t reserved : 6;
} bits;
uint8_t flags;
};
} SensorDataOpt2; // 实测14字节
四、性能对比测试
4.1 测试环境配置
主频:168MHz
测试方法:DWT周期计数器
测试样本:1000次连续访问
4.2 关键性能数据
4.3 位域访问性能劣化示例
// 测试代码片段
for(int i=0; i<1000; i++){
data.bits.step_flag = i%2; // 位域写操作
if(data.bits.fall_detect){ // 位域读操作
counter++;
}
}
反汇编对比:
; 普通bool访问
LDRB R0, [R1] ; 1周期
AND R0, R0, #0x1 ; 1周期
; 位域访问
LDRB R0, [R1] ; 1周期
UBFX R0, R0, #1, #1 ; 2周期
五、最佳实践总结
5.1 优化决策矩阵
5.2 关键代码模板
// 通用优化模板
typedef struct {
uint32_t timestamp; // 4字节(偏移0)
float temperature; // 4字节(偏移4)
uint16_t pressure; // 2字节(偏移8)
uint8_t accel[3]; // 3字节(偏移10)
union {
struct {
uint8_t step_flag : 1;
uint8_t fall_detect : 1;
uint8_t : 6; // 匿名位域填充
};
uint8_t flags;
}; // 1字节(偏移13)
} __attribute__((aligned(4))) SensorDataFinal; // 强制16字节对齐
六、深度优化技巧
6.1 混合位域优化
// 将多个布尔标志合并到位域
typedef struct {
uint32_t timestamp : 24; // 使用24位存储时间戳
uint8_t flags : 4; // 4个状态标志
// ...其他成员
} CompactData;
6.2 内存屏障应用
// 确保位域操作的原子性
#define CRITICAL_SECTION(code) \\\\
do { \\\\
UBaseType_t uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR(); \\\\
code \\\\
taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus); \\\\
} while(0)
void set_fall_detect(SensorData* data) {
CRITICAL_SECTION(
data->bits.fall_detect = 1;
);
}
七、实测效果
经过优化后:
传感器数据缓冲区内存消耗从12KB降至7KB
任务调度抖动从±2ms降低到±0.5ms
整体功耗降低18%(得益于内存减少带来的缓存效率提升)
结语
在STM32+FreeRTOS的嵌入式开发中,内存对齐与位域优化需要精细平衡空间效率与时间性能。通过本文的实测数据可以看出,合理的结构体设计可节省40%以上的内存空间,而位域操作带来的性能损耗在低频访问场景中可以忽略。
建议在关键路径上使用自然对齐结构,在配置参数等低频数据区域使用位域压缩, 同时结合FreeRTOS的内存管理特性进行全局优化。
Chapter4 stm32 通过eeprom保存配置参数
假设我用的是内部EEPROM,比如STM32L系列可能有。那得查一下对应型号的数据手册,看看EEPROM的地址范围。比如,起始地址可能是0x08080000,大小可能是几KB。然后需要用HAL库或者标准外设库来读写。或者如果是模拟EEPROM,可能需要用Flash的库函数,把某个Flash扇区作为EEPROM使用,但要注意Flash的写入次数比EEPROM少,而且需要先擦除整个页才能写入。
如果是外接EEPROM,比如通过I2C连接的,那需要配置I2C外设,初始化正确的时钟、引脚,设置地址等。比如使用24LC256,地址可能是0xA0(写)和0xA1(读)。然后通过I2C发送数据,需要处理起始条件、地址发送、数据写入等步骤。HAL库里有相关的函数,比如HAL_I2C_Mem_Write和HAL_I2C_Mem_Read,可以简化操作。
接下来,保存配置参数。我需要定义一个结构体来存储各种参数,比如设置值、校准值、用户配置等。这个结构体需要知道大小,以便确定要写入EEPROM的字节数。然后,每次启动时从EEPROM读取这个结构体,如果读取的数据有效(比如有特定的魔数或校验和),就使用;否则,使用默认值。
需要注意的是,EEPROM的写入次数有限,所以应该避免频繁写入。可能需要在RAM中保存当前配置,只在更改时写入EEPROM。或者使用磨损均衡技术,轮流使用不同的地址来延长寿命。但如果是简单的应用,可能直接覆盖同一地址也没问题,只要次数不太多。
对于写入操作,如果用的是内部EEPROM,可能需要解锁Flash,然后直接写入。比如STM32L4的HAL库有HAL_FLASHEx_DATAEEPROM_Write函数。而如果是外部EEPROM,则需要通过I2C发送写命令,注意页写入的限制,比如一页最多写多少字节,超过的话需要分多次写入。
还有,读写EEPROM的时候要考虑错误处理,比如写失败的情况,是否需要重试,或者如何通知用户。例如,检查HAL函数的返回值,如果返回HAL_OK则表示成功,否则处理错误。
另外,数据对齐的问题。某些EEPROM可能需要按字或页对齐,特别是内部EEPROM可能要求写入半字(16位)或字(32位)。结构体的定义可能需要使用特定的编译指令来保证对齐,比如__packed或者attribute((packed)),防止编译器填充字节导致结构体大小变化。
校验数据有效性方面,可以在结构体中包含版本号、CRC校验或者魔数(比如0xDEADBEEF),每次读取后检查这些值是否正确,如果不对则使用默认值并重新初始化EEPROM。
举个例子,假设我的配置结构体是这样的:
typedef struct {
uint32_t magic; // 魔数,比如0xDEADBEEF
uint16_t param1;
uint32_t param2;
uint8_t param3;
uint16_t crc; // 校验和
} ConfigStruct;
在保存时,计算结构体中除了crc之外所有数据的CRC,存入crc字段。读取时重新计算并对比,确保数据完整。
对于内部EEPROM的地址,假设起始地址是0x08080000,那么可以将结构体直接写入该地址。例如:
ConfigStruct config = {
.magic = 0xDEADBEEF,
.param1 = 100,
.param2 = 200,
.param3 = 5,
};
config.crc = calculate_crc(&config, sizeof(config) - sizeof(config.crc));
然后写入EEPROM:
HAL_FLASHEx_DATAEEPROM_Unlock();
HAL_FLASHEx_DATAEEPROM_Program(TYPEPROGRAMDATA_WORD, EEPROM_ADDR, config.magic);
…类似写入其他字段…
但可能更方便的是将整个结构体按字节写入,或者按正确的数据类型大小写入。因为DATAEEPROM的写入函数可能需要地址对齐和数据类型对齐。
如果是外部EEPROM,比如24C256,每个地址是16位,容量32KB,那么地址范围是0x0000到0x7FFF。写入时可能需要分页,比如每页64字节,超过的话要换页。
比如使用HAL_I2C_Mem_Write,指定设备地址、内存地址(两个字节)、数据指针和长度。例如:
HAL_I2C_Mem_Write(&hi2c1, 0xA0, eeprom_addr, I2C_MEMADD_SIZE_16BIT, (uint8_t*)&config, sizeof(config), HAL_MAX_DELAY);
读取时类似,使用HAL_I2C_Mem_Read。
但要注意,写入EEPROM需要一定时间,比如每次写入后要等待几毫秒,或者轮询设备是否应答。比如在发送写命令后,如果立即尝试再次写入,可能需要检查是否完成,比如发送起始条件后发送设备地址,直到设备应答为止。
总结步骤:
确定EEPROM类型(内部或外部)。
初始化对应的接口(如果是外部,初始化I2C/SPI)。
定义配置参数的结构体,包含校验信息。
上电时读取EEPROM中的数据,校验有效性。
无效则使用默认参数,并保存到EEPROM。
在应用运行时,当参数改变时,保存到EEPROM。
处理可能的写入错误,避免数据损坏。
可能遇到的问题:
结构体对齐导致的大小变化,使用#pragma pack或者__attribute__((packed))解决。
EEPROM的地址溢出,比如写入超过EEPROM容量。
I2C的地址设置错误,设备地址是否正确(比如24C02的地址引脚接线)。
写入延迟问题,外部EEPROM的写入周期需要等待,不能连续写入太快。
数据校验错误,CRC算法选择或计算错误。
在STM32中使用EEPROM保存配置参数可以分为以下步骤,具体根据使用的是内部EEPROM还是外部EEPROM芯片有所不同:
一、确定EEPROM类型
内部EEPROM
部分STM32型号(如STM32L系列)内置EEPROM,需查阅数据手册确认地址和大小(如STM32L476的EEPROM起始地址为 0x08080000,大小2 KB)。
若无内置EEPROM,可用Flash模拟(需注意Flash擦写次数较低)。
外部EEPROM
常见芯片如24Cxx系列(I2C接口)或25xx系列(SPI接口)。例如24C32(4 KB)的I2C地址为 0xA0(写)和 0xA1(读)。
二、硬件与驱动配置
内部EEPROM(以STM32L4为例)
外部EEPROM(以24C32为例,I2C接口)
三、配置参数存储设计
定义参数结构体
包含校验字段(如魔数、CRC):
#pragma pack(push, 1) // 取消结构体对齐
typedef struct {
uint32_t magic; // 魔数,例如0xDEADBEEF
uint16_t param1;
float param2;
uint8_t param3;
uint16_t crc; // 校验和
} ConfigStruct;
#pragma pack(pop)
计算CRC校验
使用简单校验或标准算法(如CRC16):
uint16_t calculate_crc(const uint8_t* data, size_t len) {
uint16_t crc = 0;
for (size_t i = 0; i < len; i++) crc ^= (data[i] << 8);
return crc;
}
四、读写流程
1. 读取配置
ConfigStruct config;
// 从EEPROM读取数据
HAL_I2C_Mem_Read(&hi2c1, 0xA1, 0x0000, I2C_MEMADD_SIZE_16BIT, (uint8_t*)&config, sizeof(config), 1000);
// 校验数据
if (config.magic != 0xDEADBEEF ||
config.crc != calculate_crc((uint8_t*)&config, sizeof(config) - sizeof(config.crc))) {
// 数据无效,加载默认值
load_default_config(&config);
save_config_to_eeprom(&config); // 保存默认值
}
2. 保存配置
void save_config_to_eeprom(ConfigStruct* config) {
config->crc = calculate_crc((uint8_t*)config, sizeof(*config) - sizeof(config->crc));
// 写入外部EEPROM
HAL_I2C_Mem_Write(&hi2c1, 0xA0, 0x0000, I2C_MEMADD_SIZE_16BIT, (uint8_t*)config, sizeof(*config), 1000);
// 若为内部EEPROM,逐字段写入:
// HAL_FLASHEx_DATAEEPROM_Program(TYPEPROGRAMDATA_WORD, EEPROM_ADDR, config->magic);
// ...
}
五、注意事项
写入寿命
内部EEPROM:约10万次。
外部EEPROM:约100万次。
避免频繁写入,可在RAM中缓存参数,仅在必要时保存。
写入延迟
外部EEPROM写入需等待几毫秒(如24Cxx系列的页写入周期为5 ms),可使用HAL_Delay()或轮询ACK。
地址对齐
内部EEPROM可能要求按字(32位)写入。
外部EEPROM需注意分页写入限制(如24C32每页32字节)。
错误处理
检查HAL函数返回值,实现重试机制:
HAL_StatusTypeDef status;
do {
status = HAL_I2C_Mem_Write(...);
if (status != HAL_OK) HAL_Delay(5);
} while (status != HAL_OK);
六、示例代码(外部EEPROM)
// 初始化I2C
void EEPROM_Init() {
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000;
HAL_I2C_Init(&hi2c1);
}
// 保存配置
void EEPROM_SaveConfig(ConfigStruct* config) {
config->magic = 0xDEADBEEF;
config->crc = calculate_crc((uint8_t*)config, sizeof(ConfigStruct) - sizeof(config->crc));
HAL_I2C_Mem_Write(&hi2c1, 0xA0, 0x0000, I2C_MEMADD_SIZE_16BIT, (uint8_t*)config, sizeof(ConfigStruct), 1000);
}
// 读取配置
void EEPROM_LoadConfig(ConfigStruct* config) {
HAL_I2C_Mem_Read(&hi2c1, 0xA1, 0x0000, I2C_MEMADD_SIZE_16BIT, (uint8_t*)config, sizeof(ConfigStruct), 1000);
if (config->magic != 0xDEADBEEF || config->crc != calculate_crc((uint8_t*)config, sizeof(ConfigStruct) - sizeof(config->crc))) {
LoadDefaultConfig(config);
EEPROM_SaveConfig(config);
}
}