嵌入式开发看不懂C语言结构体?

小结:

C语言的结构体更像是没有方法的纯数据集合,简洁高效
结构体是C语言的灵魂之一,尤其在嵌入式开发中,用的好能让代码清晰高效,用不好可能就是一堆坑等着你去填。
从基本声明到内存对齐,再到位域和灵活数组,结构体的每个特性都有它的用武之地,但也藏着不少细节需要注意。写代码时要善用工具检查内存布局,调试时多留个心眼

结构体的基本操作:声明与使用

结构体用 struct 关键字定义,后面跟上结构体名字和成员列表。
例:

struct sensor_data{
   char id;
   int value;
   double timestamp;
   char name[10];
};

这个 sensor_data 结构体就像一个传感器数据的模板,包含一个字符型的传感器ID、整数型测量值、双精度浮点型时间戳,以及一个能存9个字符加结束符的名称数组。
定义变量:

struct sensor_data {
    char id;
    int value;
    double timestamp;
    char name[10];
};

通过点号操作符直接访问成员,简单直观。

如果用指针操作呢?

那得用箭头操作符:

struct sensor_data *sp = &s1;
sp->id = 'B';
sp->value = 200;

note: 点号(.)和箭头(->)易混淆,使用错误,编译器会报错
结构体指针用得更多,因为动态内存分配和函数传参经常需要指针操作。

初始化

声明结构体变量后不初始化,成员的值就是随机的,像是堆栈里没清扫的垃圾数据。
开发中,这种未初始化问题可能是导致死机或数据异常的罪魁祸首
推荐:C99的指定初始化

struct sensor_data s1 = {
    .id = 'A',
    .value = 100,
    .timestamp = 1234.5678,
    .name = "TempSensor"
};

c99标准中引入指定初始化语法,这种写法明确指定每个成员的值,哪怕结构体定义改了也不容易出错。

动态分配:堆上的结构体

结构体不光可以在栈上定义,还能用 malloc 在堆上动态分配;
适合需要运行时决定大小的场景。

struct sensor_data *sp = malloc(sizeof(struct sensor_data));
if (sp == NULL) {
    // 内存分配失败,赶紧处理
    printf("内存告急,分配失败!\n");
    return -1;
}
memset(sp, 0, sizeof(struct sensor_data)); // 清零,防止随机值
sp->id = 'C';
sp->value = 300;
// 用完别忘了释放
free(sp);

note:
一是分配后一定要检查指针是否为空,内存不足在嵌入式设备上很常见;
二是分配的内存是未初始化的,最好用 memset 清零;
三是用完必须free,不然内存泄漏导致设备卡死

嵌套与自引用

结构体可以嵌套,像是俄罗斯套娃。
定义一个2D坐标点和一个精灵结构体:

typedef struct {
    float x;
    float y;
} Point2D;

typedef struct {
    char *name;
    Point2D pos;
    uint8_t *image;
} Sprite;

结构体可以嵌套,像是俄罗斯套娃

Sprite s = {
    .name = "Enemy",
    .pos = {.x = 1.0f, .y = 2.0f},
    .image = some_data
};

但如果想让结构体自己包含自己
比如实现一个二叉树,如果不用指针的方式,会报错,导致无限递归,内存占用
正确用法

struct tree {
    struct tree *left;
    struct tree *right;
    int data;
};

自引用结构体在开发中很常见,比如实现链表或树形数据结构。
不过要注意,指针操作容易引发野指针或悬空指针问题,调试时得格外小心。

内存对齐

结构体在内存中的布局可不是简单地把成员挨个摆放。
C标准规定,成员按声明顺序排列,但编译器可能会在成员之间或末尾插入填充字节,这就是内存对齐。
内存对齐的目的:处理器访问对齐的内存地址更快,有些架构不对齐直接崩
举两个例子对比分析一下:

struct example {
    char a;    // 1字节 + 3字节 = 4字节
    int b;     // 4字节 
    double c;  // 8字节 = 8字节
				//共16字节
};
struct example {
	int b;     // 4字节 + 4字节 = 8字节
	double c;  // 8字节 + 0字节 = 8字节
	char a;    // 1字节 + 8字节 = 8字节
					//共24字节
};

验证技巧:计算出的结构体的大小应该是结构体类型中字节宽度最大的成员的整数倍!!!!

什么是结构体填充?

为什么要关心这些填充字节?简单来说,它们会影响以下场景:

  1. 内存比较:直接用memcmp比较两个结构体时,填充字节的值可能导致结果不一致。
  2. 数据序列化:结构体写入Flash或通过网络传输时,填充字节可能导致数据格式不兼容。
  3. 安全问题:填充字节可能残留栈上之前的数据(比如密钥),不小心泄漏出去就是大麻烦。
  4. 性能开销:如果用__attribute__((packed))强制去掉填充,可能会引入额外的字节操作开销。

反过来,如果结构体只是内部使用,逐个成员访问,填充字节通常不会惹麻烦。
但在实际开发中,结构体经常要跨模块、跨设备甚至跨语言传递,填充问题就变得不容忽视。

填充字节的初始化

C11标准明确说了,填充字节的值是未定义的,具体取决于存储类型:

  • 静态存储(static或全局变量):填充字节会被初始化为0。
  • 自动存储(局部变量):填充字节的值完全不可预测。

导致一个问题: 同样一个结构体初始化代码,在不同编译器、不同优化级别下,填充字节的表现可能天差地别。

struct foo {
    uint32_t i; // 4字节
    uint8_t b;  // 1字节
};

来看几种常见的初始化方式:
方式1:老老实实用memset清零

struct foo a;
memset(&a, 0, sizeof(a));

这是最可靠的方法,直接把整个结构体内存清零,包括填充字节。
方式2:逐个成员显式初始化

struct foo a = {
    .i = 0,
    .b = 0
};

这种方式看着清晰,但填充字节的处理就看编译器心情了。
比如Clang 13中,O0和O2优化下填充字节可能是0,但O1和Os下就可能是未定义值。GCC 11更直接,填充字节几乎总是未定义。
方式3:用{0}初始化

struct foo a = {0};

这是C99引入的便捷写法,第一个成员初始化为0,其他成员按静态存储规则初始化(整数为0,指针为NULL等)。这方式比逐成员初始化靠谱。
方式4:GCC扩展的{}初始化

struct foo a = {};

这是GCC和Clang都支持的非标准扩展,效果类似{0}。

结果
在Ubuntu 21.10,GCC 11.2和Clang 13.0环境下实验,填充字节的表现确实五花八门。memset是唯一100%可靠的,其他方式在某些优化级别下都会翻车。

实战经验

招数1:能不依赖填充就不依赖
招数2:memset
招数3:attribute((packed))要谨慎
招数4:善用工具和编译器选项

紧凑结构体:省空间的代价

如果非要让结构体不加填充(比如映射硬件寄存器或文件格式),可以用GCC/Clang的 attribute((packed))


struct example {
    char a;
    int b;
    double c;
} __attribute__((packed));

这时候结构体大小就是13字节,没有填充。
紧凑结构体会导致未对齐访问,性能下降不说,在某些架构上可能直接崩溃。
嵌入式开发中,除非明确需要(比如操作外设寄存器),尽量别用 packed,否则填坑的成本可不低。

位域:精打细算的存储

在资源紧张的嵌入式系统里,位域是个省空间的好工具。
比如要存8个标志位,直接用8个 bool 太浪费,每个 bool 占1字节。位域可以这样写:

struct flags {
    unsignedint f1 : 1;
    unsignedint f2 : 1;
    unsignedint f3 : 1;
    unsignedint f4 : 1;
    unsignedint f5 : 1;
    unsignedint f6 : 1;
    unsignedint f7 : 1;
    unsignedint f8 : 1;
	//这里每个标志只占1位,8个标志正好1字节,完美!
};

struct term_char {
    unsigned int ch : 7;      // 7位ASCII
    unsigned int fg : 11;     // 前景色
    unsigned int bg : 11;     // 背景色
    unsigned int bold : 1;    // 加粗
    unsigned int italic : 1;  // 斜体
};
//位域还能干更多,比如模拟老式终端字符:

但位域也有坑:不同编译器的内存布局可能不一致,移植性差;而且不能取位域成员的地址。所以用位域时,最好用调试工具确认布局,少走弯路。

灵活数组:动态大小的尾巴

C99引入了灵活数组成员,允许结构体最后一个成员是未指定大小的数组。

struct packet {
    int len;
    char data[];
};

分配时可以根据需要决定数组大小:

int size = 50;
struct packet *p = malloc(sizeof(struct packet) + size * sizeof(char));
p->len = size;

在处理变长数据(比如网络包或日志)时特别好用。
灵活数组必须是最后一个成员,且分配时得算上数组的额外空间。

posted @ 2025-05-17 19:46  Liu余庆  阅读(86)  评论(0)    收藏  举报