嵌入式里面互相嵌套的结构体

加更一节

昨天有人在上一篇下面留言:"这期有点没看懂。"

既然都打算做零基础教学了,那么我觉得还是有必要说清楚的(如果发现前面几篇进入主题太慢不符合心意还请见谅),上一篇上来就在讲"把参数打包成一个结构体",却漏掉了一个前提——结构体到底是个什么东西。 那就先把它说清楚。

结构体就是一个变量类型,和 intfloat 是同一级别的东西。 没有更高级,也没有更神秘。

在 STM32 上,int 占 4 个字节,float 占 4 个,char 占 1 个。它们之间的区别只是"这块内存怎么解释":同样 4 个字节,按 int 解释是一个整数,按 float 解释是一个小数。

struct 也一样——它也是一块内存,大小是成员加起来(再加一点填充,这一篇后面有一节专门讲这个)。区别只在于:int 那块里装一个整数,struct 那块里可以装好几个,而且类型可以不一样。

于是:

  1. struct 是一个类型;
  2. 一个类型的成员可以是任何类型(intfloatchar,当然也可以是另一个 struct);
  3. 所以 struct 里放 struct,就是"嵌套"——它不是新技巧,只是第 2 条的结果。

会写 int speed; 的人,其实已经会写结构体了。区别只在一次装几个。

不知道这个例子会不会比较抽象,这就像集合,我创造一个集合A中有{int1, int2, float1},我当然也可以创造集合B{A1, A2, double, char}。


有时候,结构体不只是把几个参数放在一起。一个结构体里可以放另一个结构体,另一个结构体里还可以继续放别的结构体。听起来像是在套盒子,但嵌入式程序确实经常这样组织数据。

标准外设库里的配置对象是一个很好的起点。我们已经见过:

GPIO_InitTypeDef gpio;

它把一组 GPIO 配置放在一起。假如现在要描述一辆小车,就不可能只需要一组 GPIO 参数。它还会有电机、按键、传感器和控制参数。把所有成员都堆进一个巨大的结构体,当然也能编译,但很快会变得难以阅读。

先把一件事拆成几件小事

先分别描述电机和按键:

typedef struct
{
    uint8_t speed;
    uint8_t direction;
} MotorConfig;

typedef struct
{
    uint8_t active_level;
    uint16_t debounce_ms;
} KeyConfig;

它们各自只关心自己的事情。电机不需要知道按键消抖多久,按键也不需要知道电机往哪个方向转。

接着,把它们放进小车配置:

typedef struct
{
    MotorConfig left_motor;
    MotorConfig right_motor;
    KeyConfig key_a;
    KeyConfig key_b;
} CarConfig;

这就是结构体嵌套。CarConfig 里面放了四个结构体对象,而不是四个零散数字。使用时,成员访问也会一层一层写出来:

CarConfig car;

car.left_motor.speed = 40;
car.left_motor.direction = 1;
car.key_a.active_level = 0;
car.key_a.debounce_ms = 20;

第一眼看上去多了几个点号,但它们其实在帮你标记数据的归属:这是小车的配置,这是左电机的配置,这是按键 A 的配置。

三层嵌套并不神秘

项目继续长大以后,还可以再加一层:

typedef struct
{
    uint16_t target_speed;
    uint16_t max_output;
} SpeedPidConfig;

typedef struct
{
    SpeedPidConfig left;
    SpeedPidConfig right;
} DriveControlConfig;

typedef struct
{
    MotorConfig motor;
    DriveControlConfig control;
} CarRuntimeConfig;

现在访问目标速度时,路径可能是:

CarRuntimeConfig car;

car.control.left.target_speed = 120;

这已经是三层关系:小车配置里有控制配置,控制配置里有左轮 PID 配置。层数多了以后,代码确实会变长,但每一层都有自己的边界。修改电机结构时,不必顺手检查按键;修改速度控制时,也不必把所有参数重新排列一遍。

嵌套不是越多越高级。层次应该跟着实际的归属关系走。如果只是为了少写几个变量名,把完全无关的东西硬塞进一棵树里,最后只会得到一棵谁也不想爬的树。

别把所有东西塞进一个“上帝结构体”

既然结构体可以一层层组合,有人会想到:那就把小车所有配置都放进一个大结构体里,所有模块都从这里取参数。比如:

typedef struct
{
    MotorConfig left_motor;
    MotorConfig right_motor;
    KeyConfig key_a;
    KeyConfig key_b;
    uint8_t led_enabled;
    uint8_t buzzer_enabled;
} SystemConfig;

项目刚开始时,这样做确实省事:所有配置集中在一个对象里,初始化时传一个参数,保存和读取也比较直接。问题是,这个对象会慢慢变成“什么都有、谁都能改”的上帝结构体。

轮子模块只需要轮子配置,却能看到车灯、喇叭和按键;车灯模块改一个成员,可能碰到整个系统配置;配置、运行状态和统计数据混在一起以后,出了问题,很难判断是哪一个模块动了它。

它还会影响编译器的增量编译机制。假设 SystemConfig 放在公共头文件里,而 main.c、轮子模块 motor.c、车灯模块 led.c、喇叭模块 buzzer.c 和按键模块 key.c 都包含这个头文件:

SystemConfig.h
    ↓
main.c、motor.c、led.c、buzzer.c、key.c 都包含它
    ↓
只修改一个配置成员
    ↓
多个源文件重新编译

增量编译机制本身没问题,真正麻烦的是公共头文件里的边界太大。项目小时,重新编译几秒钟不算什么;项目变大以后,一个小改动牵动一大片,等待和排查都会变慢。

标准库的 GPIO_InitTypeDef 就是一个反例提醒:它只描述 GPIO 初始化,不会顺手把定时器、串口和其他外设的配置也塞进来。小车项目也一样,轮子、车灯、喇叭、按键各自有自己的配置,需要组合时再由更高一层的对象组织起来。

并排放置也是一种组织方式

嵌套是“盒子里放盒子”,并行组合则是“几个盒子并排放”。例如一个传感器采样结果,可以这样写:

typedef struct
{
    int16_t x;
    int16_t y;
    int16_t z;
} AccelSample;

typedef struct
{
    int16_t x;
    int16_t y;
    int16_t z;
} GyroSample;

typedef struct
{
    AccelSample accel;
    GyroSample gyro;
    uint32_t timestamp_ms;
} ImuSample;

加速度和陀螺仪是两组并行的数据,时间戳是它们共同的采样信息。它们没有谁必须包含谁,所以把它们作为同一层的成员更自然。

标准库工程也常见类似写法:一个模块的配置对象、运行状态和统计信息分别定义,再组合成一个更大的状态对象。这样做的价值不在于代码看起来“像大工程”,而在于每个小结构体都能单独阅读、单独测试。

结构体真的会占内存

我看到一堆结构体时,先把它们当成“代码里的分类标签”。但声明一个结构体变量之后,内存里确实会留出空间:

typedef struct
{
    uint8_t  enabled;
    uint32_t total_count;
} Counter;

Counter counter;

可以用 sizeof 看它占了多少字节:

uint32_t size = sizeof(Counter);

直觉上,uint8_t 占 1 字节,uint32_t 占 4 字节,于是有人会猜 Counter 一共占 5 字节。但在很多编译器和处理器上,结果可能是 8 字节。

中间多出来的空间通常叫填充。编译器会让较大的成员落在更合适的地址上,这样处理器读写它们时更方便。于是结构体的大小不一定等于成员大小简单相加,还要考虑对齐。

可以把它想成停车位:一辆小车只需要 1 个位置,另一辆大车需要 4 个连续位置;为了让大车停在合适的格线上,停车场可能会留下几个空位。这些空位没有存业务数据,却确实占用了场地。

嵌套以后,空位也会跟着嵌套

外层结构体会把内层结构体当成一个整体来安排:

typedef struct
{
    uint8_t state;
    uint32_t value;
} SensorState;

typedef struct
{
    SensorState left;
    SensorState right;
    uint8_t valid;
} SensorGroup;

SensorGroup 的大小不仅取决于三个成员的业务含义,还取决于两个 SensorState 自己的大小、它们的对齐要求,以及外层最后是否需要补齐。结构体套得越深,越不能只凭眼睛估算内存。

这并不意味着每次定义结构体都要拿尺子量字节。日常工程先保持成员关系清楚,再在资源紧张、需要打包存储或通过通信发送整个对象时,用 sizeof 和编译器生成的布局信息确认实际结果。

为什么嵌入式开发者总在意这几个字节

电脑程序多占几十个字节,通常没有人专门讨论。单片机不一样:RAM 可能只有几十 KB,Flash 也有明确上限;结构体还可能被复制到栈上、保存到片内 Flash,或一次性发送给另一个设备。

一个结构体如果从 12 字节变成 16 字节,单看一次似乎没什么。可当它被 100 个传感器样本组成数组时,就多出了 400 字节;如果它被频繁复制,运行时搬运的数据也跟着增加;如果它被写入 Flash,存储格式还会影响掉电恢复和版本升级。

低成本、低功耗并不只是“买便宜的芯片”或“让电池撑久一点”。少占一点 RAM,可能就能换一颗更小的芯片;少搬一点数据,可能就能少做一些无意义的工作;少写几次 Flash,可能就能让存储器多用几年。结构体布局只是很小的一环,但它提醒我们:软件里的每个对象,最后都要在真实的内存里找位置。

什么时候该关心,什么时候不用紧张

如果结构体只保存三个 LED 状态,刚开始写代码时,你先把名字和层次写清楚。为了省几个字节,把结构体挤得太紧,反而更容易把问题藏起来。

下面这些场景,就值得认真看内存布局:

  • 大量结构体组成数组;
  • 结构体要保存到 Flash 或外部存储器;
  • 结构体要按字节发送或接收;
  • RAM 已经接近上限;
  • 低功耗场景需要减少搬运和唤醒时间。

这时不能只凭“成员顺序看起来合理”下结论。先用 sizeof 验证大小,再确认成员偏移和通信格式;如果要跨编译器、跨芯片保存数据,还要明确版本、字节顺序和兼容策略。

最后橘猫说

结构体嵌套的作用,是让复杂对象仍然有清楚的归属关系。嵌套结构体适合表达“里面还有一组完整配置”,并行结构体适合表达“几组数据属于同一个现场”;它们最终都会变成真实的内存布局。刚开始写时,我会先把层次看懂;工程变大后,再用 sizeof、对齐和存储格式去核对代价。

最后蜘蛛说

盒子套盒子没关系,别套到最后连自己放哪儿都找不到了。

posted @ 2026-09-16 10:17  Zw-awa  阅读(45)  评论(0)    收藏  举报