经典三问
Q: C 语言结构体是什么?
A: 它是把多个有名称的成员组合成一个数据对象的类型,可以同时保存一组相关配置。
Q: 为什么库函数需要接收结构体?
A: 外设配置往往有多个彼此相关的字段,结构体能让调用接口保持稳定,也让参数含义更清楚。
Q: 结构体常见使用场景?
A: GPIO、定时器、串口等外设初始化,以及保存传感器数据、菜单项和控制器参数。
上一篇里,我把 GPIO_InitTypeDef 留在了这里:
c
GPIO_InitTypeDef gpio;
gpio.GPIO_Pin = GPIO_Pin_13;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_2MHz;
GPIO_Init(GPIOC, &gpio);
我第一次看到这几行,先冒出来的是一串问题:为什么会有这么多参数?每个参数到底管什么?它们应该放在哪里?为什么最后还要把 &gpio 交给另一个函数?
这次先不急着背 GPIO_InitTypeDef。我们退回普通 C,看看结构体到底在解决什么麻烦。
参数开始变多以后
假设要写一个函数,给一辆车设置速度、方向和刹车状态:
c
void Car_Setup(uint8_t speed,
uint8_t direction,
uint8_t brake);
参数少时还能看懂。调用时,三个数字分别对应速度、方向和刹车:
c
Car_Setup(100, 1, 0);
但如果后来又加上加速度限制、电机编号、是否允许反转,函数可能变成这样:
c
void Car_Setup(uint8_t speed,
uint8_t direction,
uint8_t brake,
uint8_t acceleration,
uint8_t motor_id,
uint8_t allow_reverse);
调用就会变成一串不容易读懂的数字:
c
Car_Setup(100, 1, 0, 50, 2, 1);
只看这一行,很难立刻确认 50 是加速度,还是电机编号;最后两个 1、2 交换位置,编译器也不会提醒你,因为它们的类型都是 uint8_t。参数越多,函数调用就越像一张没有列名的表。
可以把属于"车辆配置"的东西放在一起:
c
typedef struct
{
uint8_t speed;
uint8_t direction;
uint8_t brake;
} CarConfig;
使用时先得到一个配置对象:
c
CarConfig config;
config.speed = 40;
config.direction = 1;
config.brake = 0;
再把它交给处理配置的函数:
c
Car_ApplyConfig(&config);
这里的结构体没有变成什么高级对象。它就是几块挨在一起的数据,只是这些数据现在有了一个共同名字:CarConfig。函数看到这张配置单,知道速度、方向和刹车属于同一件事。
typedef struct 到底写了什么
c
typedef struct
{
uint8_t speed;
uint8_t direction;
uint8_t brake;
} CarConfig;
struct { ... } 描述了一种结构,花括号里是它的成员;最后的 CarConfig 给这种结构起了一个更好用的类型名;typedef 让以后声明变量时可以直接写:
c
CarConfig config;
不使用 typedef 时,声明变量会更长:
c
struct CarConfigTag
{
uint8_t speed;
uint8_t direction;
uint8_t brake;
};
struct CarConfigTag config;
STM32 标准库大量使用 typedef struct,只是因为外设配置成员很多,这样写起来短一些。看到一串很长的类型名时,先把它当成"某类配置对象",不必先把名字背下来。
回到 GPIO_InitTypeDef
c
GPIO_InitTypeDef gpio;
gpio.GPIO_Pin = GPIO_Pin_13;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_2MHz;
这三行不是随便挑了三个宏。它们分别回答三个问题:要安排哪根引脚?这根引脚作为输入、输出还是复用功能?输出时需要多快的边沿?
把 GPIO_Pin_13 换成 GPIO_Pin_12,你换的是哪根引脚;把 GPIO_Mode_Out_PP 换成输入模式,你是在换引脚的工作方式;把 GPIO_Speed_2MHz 换成 GPIO_Speed_50MHz,你只是在改变输出边沿能力。每一项都应该能在芯片手册或开发板接线中找到对应的东西。
为什么函数要拿到 &gpio
c
GPIO_Init(GPIOC, &gpio);
gpio 是一个结构体变量,里面放着刚刚填写好的配置。&gpio 取出这个变量的地址,传给 GPIO_Init()。函数拿着这个地址,就能读取 GPIO_Pin、GPIO_Mode 和 GPIO_Speed,再去修改 GPIOC 的配置寄存器。
拿地址并不意味着函数获得了什么神秘能力。函数仍然只是在读一段内存;只是它读完之后,调用了标准库里对应的寄存器操作,于是 PC13 的工作方式发生了变化。
点号和箭头其实在访问同一组成员
手里是结构体变量时,用点号:
c
gpio.GPIO_Pin = GPIO_Pin_13;
手里是指向结构体的指针时,用箭头:
c
GPIO_InitTypeDef *config;
config = &gpio;
config->GPIO_Pin = GPIO_Pin_13;
config->GPIO_Pin 和 gpio.GPIO_Pin 访问的是同一个成员。箭头不是 STM32 专用语法,只是"先沿着指针找到结构体,再访问它的成员"的缩写写法。
配置单要不要先恢复默认值
GPIO 的配置成员不算多,逐项填写通常已经够清楚。定时器、串口和 I2C 的配置更长,漏掉一个成员时,问题可能要到下载后才露出来。标准库常提供恢复默认配置的函数:
c
GPIO_InitTypeDef gpio;
GPIO_StructInit(&gpio);
gpio.GPIO_Pin = GPIO_Pin_13;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_2MHz;
GPIO_Init(GPIOC, &gpio);
我一般先把这张配置单擦干净,再填这次真正需要的内容。不是每个函数都必须先调用 StructInit,但当结构体成员很多、代码还在调整时,我更愿意先恢复默认值,少给旧数据留下机会。
到了定时器、串口和 I2C,你还会看到 TIM_TimeBaseInitTypeDef、USART_InitTypeDef 和 I2C_InitTypeDef。名字换了,动作没有换:声明一个配置对象,填写成员,把它的地址交给初始化函数。
结构体能帮助你把"这一组参数属于同一件事"写得清楚。真正需要花时间的,是弄明白每个成员改变以后,哪一根线、哪一个计数器或哪一段通信会跟着变化。
这些类型和函数就存在于右键快捷添加的那个标准库中。顺着调用链往里走,会看见标准外设库正是把配置对象翻译成 GPIO、定时器和串口寄存器操作的那一层。后面我将先深入标准库带大家看看里面是什么牛鬼蛇神,不必句句理解,只需要留下大概印象就行。