4、STM32基础外设驱动:GPIO、USART、I2C、SPI接口编程实战
好,咱们进入正题。这一章是嵌入式开发的「基本功」。说白了,你玩转 STM32,其实就是玩转它身上的这些外设接口。GPIO、USART、I2C、SPI,这四个家伙,几乎覆盖了 90% 的传感器和通信模块。
我记得刚入行那会儿,带我的老工程师跟我说过一句话:「小伙子,别急着跑操作系统,先把这四个接口焊死在脑子里。」嗯,现在轮到我跟你说了。
4.1 GPIO:最基础,也最容易翻车
GPIO 就是通用输入输出口。你可以把它想象成芯片的「手脚」——要么往外推电平(输出),要么感知外部电平(输入)。
但这里有个坑。很多人以为 GPIO 就是「置 1 拉高,置 0 拉低」。其实远不止这么简单。STM32 的 GPIO 有 8 种工作模式,我挑几个最常用的说说:
- 推挽输出:最常用。能输出高电平,也能输出低电平。驱动 LED、继电器(加三极管)都用它。
- 开漏输出:只能输出低电平,高电平要靠外部上拉电阻。I2C 总线就是这种模式,我后面会讲。
- 浮空输入:电平不确定,一般不用。除非你外部有很强的驱动源。
- 上拉/下拉输入:内部有弱上拉或弱下拉电阻。检测按键时特别有用。
重要经验:我在项目中遇到过,一个同事把按键检测引脚设成了浮空输入,结果按键没按下时,电平乱跳,程序一直在误触发。后来改成上拉输入,问题立刻解决。你想想看,一个电阻的事,能省多少调试时间。
代码其实很简单。以 STM32F1 系列为例,初始化一个 GPIO 输出:
// 使能 GPIOB 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
// 定义初始化结构体
GPIO_InitTypeDef GPIO_InitStructure;
// 配置 PB0 为推挽输出,50MHz
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
// 拉高电平
GPIO_SetBits(GPIOB, GPIO_Pin_0);
// 拉低电平
GPIO_ResetBits(GPIOB, GPIO_Pin_0);
小技巧:我个人习惯用位带操作来点灯,速度快,代码也简洁。比如 PBout(0) = 1; 直接操作位带别名区,比调用库函数快好几个时钟周期。
4.2 USART:与外界对话的嘴巴
USART 就是串口。调试信息打印、跟电脑通信、跟 GPS 模块、蓝牙模块通信,全靠它。
为什么叫「异步」?因为它没有时钟线。收发双方得约定好相同的波特率,比如 115200。你想想看,两个人打电话,如果一个人说得快,一个人听得慢,那肯定乱套。
配置 USART 的步骤,我总结为三步走:
- 配置 GPIO 复用功能:TX 脚设为推挽复用输出,RX 脚设为浮空输入。
- 配置 USART 参数:波特率、数据位、停止位、校验位。
- 使能 USART 和中断(如果需要)。
来个标准配置代码:
// 使能 USART1 和 GPIOA 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
// 配置 TX (PA9) 为推挽复用输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置 RX (PA10) 为浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置 USART1
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 115200;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &USART_InitStructure);
// 使能 USART1
USART_Cmd(USART1, ENABLE);
避坑指南:我曾经在调试一个 LoRa 模块时,串口死活收不到数据。查了半天,发现是 RX 引脚配置成了推挽输出。嗯,从那以后,我每次配串口都会默念一遍:「TX 复用推挽,RX 浮空输入。」
4.3 I2C:两根线走天下
I2C 只有两根线:SCL(时钟)和 SDA(数据)。但这两根线能挂载几十个设备,每个设备有唯一的地址。温度传感器、EEPROM、OLED 屏幕,很多都用 I2C。
I2C 的难点在于时序。启动信号、停止信号、应答信号,一个都不能错。不过 STM32 有硬件 I2C 外设,我们直接调用库函数就行。
我个人建议:能用硬件 I2C 就别用软件模拟。虽然软件模拟更灵活,但硬件 I2C 有中断和 DMA 支持,不占用 CPU。在冷链追踪这种需要长时间记录数据的场景里,CPU 资源很宝贵。
读一个 I2C 温度传感器的典型流程:
// 发送设备地址 + 写位
I2C_GenerateSTART(I2C1, ENABLE);
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
I2C_Send7bitAddress(I2C1, 0x48 << 1, I2C_Direction_Transmitter);
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
// 发送寄存器地址
I2C_SendData(I2C1, 0x00);
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
// 重新启动,发送读命令
I2C_GenerateSTART(I2C1, ENABLE);
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
I2C_Send7bitAddress(I2C1, 0x48 << 1, I2C_Direction_Receiver);
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));
// 读取数据
while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED));
uint8_t temp = I2C_ReceiveData(I2C1);
// 发送停止信号
I2C_GenerateSTOP(I2C1, ENABLE);
关键点:I2C 的地址是 7 位的,但发送时要左移一位,最低位表示读写方向(0 写,1 读)。这个细节,我见过好几个人在这里栽跟头。
4.4 SPI:速度至上
SPI 比 I2C 快得多。它有四根线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。
SPI 没有地址的概念,靠片选线来选择从设备。你要跟哪个设备通信,就把它的 CS 拉低,其他设备的 CS 拉高。
在冷链追踪系统里,SPI 通常用来接 LoRa 模块(比如 SX1278)或者 SD 卡。因为这些设备需要高速传输数据。
配置 SPI 的要点:
- 时钟极性 (CPOL):空闲时 SCK 是高电平还是低电平。
- 时钟相位 (CPHA):数据在 SCK 的第一个边沿采样,还是第二个边沿采样。
- 数据帧格式:MSB 在前还是 LSB 在前。大部分设备是 MSB 在前。
来个 SPI 发送接收的例子:
// 使能 SPI1 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
// 配置 SPI1
SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // 空闲时低电平
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // 第一个边沿采样
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件控制片选
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_Init(SPI1, &SPI_InitStructure);
// 使能 SPI1
SPI_Cmd(SPI1, ENABLE);
// 发送一个字节并接收一个字节
uint8_t SPI_ReadWriteByte(uint8_t data) {
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI1, data);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
return SPI_I2S_ReceiveData(SPI1);
}
个人经验:SPI 的时钟频率不要一上来就设到最高。我遇到过一块 LoRa 模块,标称支持 10MHz,结果在 5MHz 下工作正常,到 8MHz 就开始丢包。后来降回 5MHz,稳如老狗。所以,从低速开始调试,再慢慢往上提,这是最稳妥的做法。
4.5 四种接口对比与选型建议
最后,我整理了一张表,方便你快速对比和选型:
| 接口 | 线数 | 速度 | 通信方式 | 典型应用 |
|---|---|---|---|---|
| GPIO | 1 根(每路) | 取决于软件 | 并行/位控制 | 按键、LED、继电器 |
| USART | 2 根(TX/RX) | 最高几 Mbps | 异步串行 | 调试口、GPS、蓝牙 |
| I2C | 2 根(SCL/SDA) | 最高 400kHz(标准) | 同步串行,多从机 | 温度传感器、EEPROM |
| SPI | 4 根(SCK/MOSI/MISO/CS) | 最高几十 MHz | 同步串行,全双工 | LoRa 模块、SD 卡、显示屏 |
选型其实很简单:
- 需要跟电脑通信?用 USART。
- 需要挂多个低速传感器?用 I2C。
- 需要高速传输大量数据?用 SPI。
- 只是控制个灯或读个按键?GPIO 就够了。
嗯,这一章的内容就到这里。四种接口,每一种都是你工具箱里的利器。下一章,我们会把这些接口真正用起来,驱动 LoRa 模块和温度传感器。到时候,你就能感受到「纸上得来终觉浅,绝知此事要躬行」的真谛了。