通信协议(UART / SPI / I2C)¶
嵌入式系统中,MCU 很少独立工作,几乎总是需要与传感器、模块、PC 或其他 MCU 通信。UART、SPI、I2C 是三种最常用的串行通信协议,掌握它们就能与绝大多数外设模块对话。
零、通信基础概念¶
在学习具体协议之前,先理解几个关键概念:
| 概念 | 含义 | 举例 |
|---|---|---|
| 串行 vs 并行 | 数据逐位发送 vs 多位同时发送 | UART 是串行;内存总线是并行 |
| 同步 vs 异步 | 有/无独立时钟线 | SPI/I2C 是同步(有 CLK);UART 是异步 |
| 全双工 vs 半双工 | 能否同时收发 | SPI 全双工;I2C 半双工 |
| 主从模式 | 谁发起通信 | SPI/I2C 有主机从机之分 |
三种协议对比¶
| 特性 | UART | SPI | I2C |
|---|---|---|---|
| 信号线数量 | 2(TX、RX) | 4+(SCLK、MOSI、MISO、CS) | 2(SCL、SDA) |
| 同步方式 | 异步 | 同步 | 同步 |
| 双工方式 | 全双工 | 全双工 | 半双工 |
| 速度 | 低(~几 Mbps) | 高(~几十 Mbps) | 中(100k/400k/3.4Mbps) |
| 设备数量 | 点对点(一对一) | 一主多从(每从需一根 CS) | 一主多从(地址区分) |
| 典型应用 | 串口调试、GPS、蓝牙模块 | Flash、LCD 屏、SD 卡 | 传感器(MPU6050)、EEPROM |
一、UART 串口通信¶
什么是 UART?¶
UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)是最简单、最常用的通信接口。STM32 中称为 USART(多了一个 S = Synchronous,支持同步模式,但通常用异步模式)。
串口调试
串口是嵌入式开发中最重要的调试工具之一。通过 USB 转 TTL 模块将 STM32 的串口连接到 PC,就可以用串口助手收发数据,打印调试信息。
数据帧格式¶
UART 的每一帧数据格式如下:
空闲(高电平)
│ 起始位 │ 数据位 │校验位│ 停止位 │
↓ ↓ ↓ ↓ ↓
─────┐ ┌─┬─┬─┬─┬─┬─┬─┬─┐ ┌─┐ ┌───┐
高 │ │D0│D1│D2│D3│D4│D5│D6│D7│ │ P│ │ 1 │ ─── 高
└────┘ └─┴─┴─┴─┴─┴─┴─┘ └─┘ └───┘
起始位=0 LSB ────→ MSB 可选 1或2位
| 字段 | 说明 |
|---|---|
| 起始位 | 1 位低电平,通知接收方数据来了 |
| 数据位 | 8 位(或 9 位),LSB 先发 |
| 校验位 | 可选:无校验 / 奇校验 / 偶校验 |
| 停止位 | 1 位或 2 位高电平,标志一帧结束 |
波特率¶
波特率 = 每秒传输的 bit 数(bps)。收发双方必须约定相同的波特率。
常用波特率:9600、115200、460800
异步通信的关键
UART 没有时钟线,收发双方各自产生时钟。如果波特率不匹配,数据就会乱码。实际中允许的误差通常 < 2%。
CubeMX 配置串口¶
- Connectivity → USART1
- Mode: Asynchronous
- Baud Rate: 115200
- Word Length: 8 Bits
- Parity: None
- Stop Bits: 1
- PA9(TX)和 PA10(RX)会自动配置
- 如需中断接收:在 NVIC Settings 中勾选 USART1 global interrupt
- 生成代码
代码示例:串口发送数据¶
CubeMX 自动生成 MX_USART1_UART_Init() 和 UART_HandleTypeDef huart1。
/* main.c */
#include <stdio.h>
#include <string.h>
/* USER CODE BEGIN 2 */
char msg[] = "Hello, STM32!\r\n";
HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);
/* USER CODE END 2 */
/* USER CODE BEGIN WHILE */
while (1)
{
char buf[] = "Running...\r\n";
HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), HAL_MAX_DELAY);
HAL_Delay(1000);
/* USER CODE END WHILE */
}
printf 重定向到串口¶
在 main.c 中重定向 printf(需要在 Keil 中勾选 Use MicroLIB):
/* USER CODE BEGIN 0 */
#include <stdio.h>
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
/* USER CODE END 0 */
串口中断接收¶
/* main.c */
uint8_t rx_data; // 单字节接收缓冲区
/* USER CODE BEGIN 2 */
HAL_UART_Receive_IT(&huart1, &rx_data, 1); // 启动中断接收(1字节)
/* USER CODE END 2 */
/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 回显:收到什么就发回什么
HAL_UART_Transmit(&huart1, &rx_data, 1, HAL_MAX_DELAY);
// 重新启动接收(HAL 中断接收是单次的)
HAL_UART_Receive_IT(&huart1, &rx_data, 1);
}
}
/* USER CODE END 4 */
HAL 中断接收是单次触发
HAL_UART_Receive_IT() 完成指定字节数的接收后就停止了。必须在回调函数中重新调用才能继续接收。如果忘记重新调用,就只能收到一次数据。
二、SPI 通信¶
什么是 SPI?¶
SPI(Serial Peripheral Interface,串行外设接口)是一种高速、全双工、同步的通信协议。
信号线¶
| 信号线 | 全称 | 方向 | 功能 |
|---|---|---|---|
| SCLK | Serial Clock | 主→从 | 时钟信号,由主机产生 |
| MOSI | Master Out Slave In | 主→从 | 主机发送数据 |
| MISO | Master In Slave Out | 从→主 | 从机返回数据 |
| CS/NSS | Chip Select | 主→从 | 片选,低电平有效,选中从机 |
主机 (STM32) 从机 (Flash/传感器)
┌────────────┐ ┌────────────┐
│ SCLK ──┼──────────→┼── SCLK │
│ MOSI ──┼──────────→┼── MOSI │
│ MISO ←─┼───────────┼── MISO │
│ CS ──┼──────────→┼── CS │
└────────────┘ └────────────┘
SPI 四种工作模式(CPOL & CPHA)¶
SPI 有两个可配置参数:
- CPOL(Clock Polarity):空闲时时钟电平。0 = 低电平,1 = 高电平
- CPHA(Clock Phase):数据采样时机。0 = 第一个边沿,1 = 第二个边沿
| 模式 | CPOL | CPHA | 空闲时 CLK | 采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿采样 |
| Mode 1 | 0 | 1 | 低 | 下降沿采样 |
| Mode 2 | 1 | 0 | 高 | 下降沿采样 |
| Mode 3 | 1 | 1 | 高 | 上升沿采样 |
最常用的模式
Mode 0 和 Mode 3 最常用。大多数 SPI 器件(如 W25Q Flash、OLED 屏)默认使用 Mode 0 或 Mode 3。查阅从机数据手册即可确定。
SPI 数据传输过程¶
SPI 使用移位寄存器实现数据交换——主机和从机各有一个 8 位移位寄存器,在每个时钟周期交换 1 位数据:
8 个时钟周期后,主机和从机各自收到了对方的 1 字节数据。SPI 的发送和接收是同时进行的。
CubeMX 配置 SPI¶
- Connectivity → SPI1
- Mode: Full-Duplex Master
- Prescaler: 8(72MHz / 8 = 9MHz)
- CPOL: Low
- CPHA: 1 Edge(即 Mode 0)
- Data Size: 8 Bits
- First Bit: MSB
- NSS Signal: Disable(CS 用软件 GPIO 控制)
- PA5(SCK)、PA6(MISO)、PA7(MOSI)自动配置
- PA4 手动设置为 GPIO_Output(用作 CS 片选)
- 生成代码
SPI 代码示例¶
/* main.c */
// CS 控制宏
#define CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET)
#define CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)
// SPI 收发一个字节(全双工,发送和接收同时进行)
uint8_t SPI_TransferByte(uint8_t txData)
{
uint8_t rxData;
HAL_SPI_TransmitReceive(&hspi1, &txData, &rxData, 1, HAL_MAX_DELAY);
return rxData;
}
// 使用示例:读取 Flash(W25Qxx)的 JEDEC ID
void Flash_ReadID(void)
{
CS_LOW(); // 选中从机
SPI_TransferByte(0x9F); // 发送读 ID 命令
uint8_t mfr = SPI_TransferByte(0xFF); // 读制造商 ID
uint8_t id1 = SPI_TransferByte(0xFF); // 读设备 ID 高字节
uint8_t id2 = SPI_TransferByte(0xFF); // 读设备 ID 低字节
CS_HIGH(); // 释放从机
}
HAL SPI 的几种传输函数
| 函数 | 用途 |
|---|---|
HAL_SPI_Transmit() |
只发送 |
HAL_SPI_Receive() |
只接收 |
HAL_SPI_TransmitReceive() |
全双工收发 |
HAL_SPI_Transmit_IT() |
中断方式发送 |
HAL_SPI_Transmit_DMA() |
DMA 方式发送 |
三、I2C 通信¶
什么是 I2C?¶
I2C(Inter-Integrated Circuit,集成电路互联)是一种只需 2 根线 就能连接多个设备的同步半双工通信协议。
| 信号线 | 功能 |
|---|---|
| SCL | 时钟线,主机产生 |
| SDA | 数据线,双向传输 |
为什么只需 2 根线?
I2C 使用开漏输出 + 外部上拉电阻,所有设备共享 SDA 和 SCL 总线。通过地址区分不同从机(每个从机有唯一的 7 位地址)。
I2C 通信时序¶
一次完整的 I2C 通信包含以下步骤:
起始位 地址 + 读写位 应答 数据字节 应答 停止位
SCL: ────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌────
└──┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ │
SDA: ──┐ A6 A5 A4 A3 A2 A1 A0 R/W ACK D7 D6 ... D0 ACK ┌──┘
└─────────────────────────────────────────────────────────────────┘
| 时序 | 条件 | 含义 |
|---|---|---|
| 起始位(S) | SCL 高电平时,SDA 下降沿 | 通信开始 |
| 停止位(P) | SCL 高电平时,SDA 上升沿 | 通信结束 |
| 应答(ACK) | 接收方拉低 SDA | 收到数据,继续 |
| 非应答(NACK) | 接收方保持 SDA 高 | 通信结束或出错 |
I2C 地址¶
每个 I2C 从机有一个 7 位地址(也有 10 位地址模式,较少用)。
发送地址时的第 8 位(最低位)表示读写方向:
- 地址 + 0 = 写操作(主机向从机发数据)
- 地址 + 1 = 读操作(主机从从机读数据)
MPU6050 的地址
MPU6050 的 7 位地址是 0x68(AD0 接地时)或 0x69(AD0 接高)。
- 写操作发送:
0x68 << 1 | 0 = 0xD0 - 读操作发送:
0x68 << 1 | 1 = 0xD1
CubeMX 配置 I2C¶
- Connectivity → I2C1
- I2C Speed Mode: Standard Mode(100kHz)或 Fast Mode(400kHz)
- PB6(SCL)和 PB7(SDA)自动配置
- 生成代码
HAL 硬件 I2C vs 软件 I2C
STM32F1 的硬件 I2C 曾经有一些已知 bug(官方勘误表中列出),但 HAL 库已经做了很多兼容处理。对于大多数应用,直接使用 HAL 硬件 I2C 即可,简单且稳定。如果遇到极端情况也可以用软件模拟。
HAL I2C 代码示例:读写 MPU6050¶
/* main.c */
#define MPU6050_ADDR (0x68 << 1) // HAL 需要 8 位地址(左移 1 位)
// 向 MPU6050 寄存器写一个字节
HAL_StatusTypeDef MPU6050_WriteReg(uint8_t reg, uint8_t data)
{
return HAL_I2C_Mem_Write(&hi2c1, MPU6050_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, &data, 1, HAL_MAX_DELAY);
}
// 从 MPU6050 寄存器读一个字节
uint8_t MPU6050_ReadReg(uint8_t reg)
{
uint8_t data;
HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, &data, 1, HAL_MAX_DELAY);
return data;
}
// 连续读取多个字节(如读取 6 字节加速度数据)
void MPU6050_ReadAccel(int16_t *ax, int16_t *ay, int16_t *az)
{
uint8_t buf[6];
HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, 0x3B,
I2C_MEMADD_SIZE_8BIT, buf, 6, HAL_MAX_DELAY);
*ax = (int16_t)(buf[0] << 8 | buf[1]);
*ay = (int16_t)(buf[2] << 8 | buf[3]);
*az = (int16_t)(buf[4] << 8 | buf[5]);
}
// 初始化 MPU6050
void MPU6050_Init(void)
{
MPU6050_WriteReg(0x6B, 0x00); // 解除休眠
MPU6050_WriteReg(0x1C, 0x00); // 加速度量程 ±2g
MPU6050_WriteReg(0x1B, 0x00); // 陀螺仪量程 ±250°/s
}
HAL I2C 函数说明
| 函数 | 说明 |
|---|---|
HAL_I2C_Mem_Write() |
向从机指定寄存器写数据(最常用) |
HAL_I2C_Mem_Read() |
从从机指定寄存器读数据(最常用) |
HAL_I2C_Master_Transmit() |
向从机发送数据(不指定寄存器) |
HAL_I2C_Master_Receive() |
从从机接收数据(不指定寄存器) |
HAL_I2C_IsDeviceReady() |
检测从机是否在线 |
I2C 设备扫描¶
调试时可以用这段代码扫描总线上所有设备:
/* USER CODE BEGIN 2 */
printf("Scanning I2C bus...\r\n");
for (uint8_t addr = 1; addr < 128; addr++)
{
if (HAL_I2C_IsDeviceReady(&hi2c1, addr << 1, 3, 10) == HAL_OK)
{
printf("Found device at 0x%02X\r\n", addr);
}
}
printf("Scan done.\r\n");
/* USER CODE END 2 */
四、三种协议选型指南¶
| 场景 | 推荐协议 | 原因 |
|---|---|---|
| PC 调试打印 | UART | 简单直接,串口助手即可查看 |
| 高速读写 Flash/SD 卡 | SPI | 速度快,全双工 |
| 连接多个传感器(IMU、温湿度) | I2C | 只需 2 根线,节省引脚 |
| 显示屏(OLED、TFT) | SPI(首选)或 I2C | SPI 刷屏速度快 |
| 两个 MCU 之间通信 | UART 或 SPI | 根据速度需求选择 |
| 引脚极度紧张 | I2C | 占用引脚最少 |
五、常见问题¶
UART 接收到乱码?
- 检查波特率:双方必须完全一致
- 检查时钟配置:CubeMX 时钟树配置不对会导致实际波特率偏差
- 检查接线:TX 接 RX,RX 接 TX(交叉连接)
- 检查电平:STM32 是 3.3V TTL 电平,不能直接接 RS232(±12V)
SPI 读到的全是 0xFF 或 0x00?
- 检查接线:MOSI/MISO 是否接反
- 检查 CS 引脚:是否正确拉低(HAL 不自动管理软件 CS)
- 检查 SPI 模式:CPOL 和 CPHA 是否和从机匹配
- 检查速度:Prescaler 是否让速率超过从机支持的最大频率
I2C 设备扫描不到?
- 检查上拉电阻:I2C 总线必须有上拉电阻(通常 4.7kΩ)
- 检查地址:HAL 使用 8 位地址(7 位地址左移 1 位),确认是否正确
- 检查接线:SDA 和 SCL 是否接反
- 检查供电:从机是否正常上电
HAL_UART_Transmit 和 HAL_UART_Transmit_IT 的区别?
HAL_UART_Transmit():阻塞发送,函数返回时数据已发送完毕HAL_UART_Transmit_IT():非阻塞发送,数据在后台通过中断发送,完成后触发HAL_UART_TxCpltCallback()HAL_UART_Transmit_DMA():DMA 方式发送,CPU 几乎不参与