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 的步骤,我总结为三步走:

  1. 配置 GPIO 复用功能:TX 脚设为推挽复用输出,RX 脚设为浮空输入。
  2. 配置 USART 参数:波特率、数据位、停止位、校验位。
  3. 使能 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 模块和温度传感器。到时候,你就能感受到「纸上得来终觉浅,绝知此事要躬行」的真谛了。