Chapter 06 · ARM 嵌入式 C 开发

I²C 与 SPI 总线

掌握 I2C 开漏时序与 SPI 四种模式的硬件原理,驱动 SSD1306 OLED 显示屏和 W25Q128 Flash 存储器。

课程进度60%

关键概念

I2C 协议基础
Inter-Integrated Circuit,两线制总线:SCL(时钟)+ SDA(数据),均为开漏输出,需要上拉电阻(通常 4.7kΩ)。支持多主多从,每个从设备有唯一 7 位地址(0x00-0x7F)。速率:标准模式 100kHz,快速模式 400kHz,高速模式 3.4MHz。
I2C 开漏总线原理
SDA 和 SCL 线上所有设备都只能拉低,高电平靠上拉电阻实现。任何设备可以在任意时刻将总线拉低(线与逻辑)。当主机发送数据后,若从机不回应 ACK(拉低 SDA),主机读到高电平(NACK),得知通信失败。
I2C ACK/NACK
每传输 8 bit 数据后,接收方在第 9 个时钟拉低 SDA 表示 ACK(确认),不拉低表示 NACK(否认/无应答)。NACK 可能意味着:设备地址错误、设备忙、数据不被接受、或传输结束。
SPI 四线制
Serial Peripheral Interface,四线:SCLK(时钟)、MOSI(主发从收)、MISO(主收从发)、CS(片选,低有效)。全双工,同时收发。速度快(可达数十 MHz),无应答机制,适合高速短距离通信(Flash、显示屏、ADC)。
SPI 极性与相位(CPOL/CPHA)
CPOL(时钟极性):0=空闲低,1=空闲高。CPHA(时钟相位):0=第一个边沿采样,1=第二个边沿采样。四种组合形成 Mode 0-3。Mode 0(CPOL=0,CPHA=0)最常用。使用前必须查阅从设备数据手册确认 SPI 模式。
SSD1306 OLED
常见 0.96 寸单色 OLED 显示屏控制芯片,支持 I2C(地址 0x3C 或 0x3D)和 SPI 接口。分辨率 128×64 像素。通过发送命令字节配置显示参数,发送数据字节填充显存(GDDRAM)。驱动时需先发送初始化命令序列。
W25Q128 Flash
华邦 128Mbit(16MB)SPI NOR Flash。页大小 256 字节,扇区 4KB,块 64KB。写操作必须先擦除(置 FF),再写入(只能将 1 写成 0)。SPI Mode 0 或 Mode 3,最高 104MHz。常用作固件存储、配置数据存储。

I2C 时序图解

I2C 完整写时序(主机写数据到从机寄存器) ════════════════════════════════════════════════════════════════ SCL ─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌── │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ SDA ─┐└─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └ │ ← 7位地址(MSB先) → │R/W│ A │←── 数据8位 ──→│ A │ ↑ ↑ ↑ START ACK ACK/NACK (SCL高时SDA下降) (从机拉低) (STOP或继续) START 条件: SCL 高时,SDA 产生下降沿 STOP 条件: SCL 高时,SDA 产生上升沿 数据有效: SCL 高期间 SDA 必须稳定(上升/下降沿只在 SCL 低时) 典型读流程(读 SSD1306 状态): START → [0x3C + W=0] → ACK → [寄存器地址] → ACK → ReSTART → [0x3C + R=1] → ACK → [数据] → NACK → STOP

I2C 驱动 SSD1306 OLED

#include "stm32f4xx_hal.h"
#include "string.h"

#define SSD1306_ADDR    0x3C   /* 7位地址,SA0接GND时为0x3C */
#define SSD1306_CMD     0x00   /* 控制字节:命令 */
#define SSD1306_DATA    0x40   /* 控制字节:数据 */
#define SSD1306_W  128
#define SSD1306_H   64

extern I2C_HandleTypeDef hi2c1;

static uint8_t oled_buf[SSD1306_W * SSD1306_H / 8];  /* 1024 字节显存缓冲 */

/* 发送单个命令 */
static void SSD1306_SendCmd(uint8_t cmd) {
  uint8_t buf[2] = {SSD1306_CMD, cmd};
  HAL_I2C_Master_Transmit(&hi2c1, SSD1306_ADDR << 1, buf, 2, 10);
}

/* 初始化序列(参考 SSD1306 数据手册)*/
void SSD1306_Init(void) {
  static const uint8_t init_cmds[] = {
    0xAE,          /* Display OFF */
    0xD5, 0x80,   /* 时钟分频 */
    0xA8, 0x3F,   /* MUX 比率 = 64 */
    0xD3, 0x00,   /* 显示偏移 = 0 */
    0x40,          /* 起始行 0 */
    0x8D, 0x14,   /* 电荷泵使能 */
    0x20, 0x00,   /* 水平寻址模式 */
    0xA1,          /* SEG 映射翻转 */
    0xC8,          /* COM 扫描反向 */
    0xDA, 0x12,   /* COM 引脚配置 */
    0x81, 0xCF,   /* 对比度 */
    0xD9, 0xF1,   /* 预充电周期 */
    0xDB, 0x40,   /* VCOMH 电平 */
    0xA4,          /* 正常显示(非全亮)*/
    0xA6,          /* 正相显示 */
    0xAF           /* Display ON */
  };
  for (uint8_t i = 0; i < sizeof(init_cmds); i++)
    SSD1306_SendCmd(init_cmds[i]);
  memset(oled_buf, 0, sizeof(oled_buf));
}

/* 刷新全屏(将 oled_buf 写入 GDDRAM)*/
void SSD1306_Refresh(void) {
  /* 设置列地址范围 0-127,页地址范围 0-7 */
  SSD1306_SendCmd(0x21); SSD1306_SendCmd(0); SSD1306_SendCmd(127);
  SSD1306_SendCmd(0x22); SSD1306_SendCmd(0); SSD1306_SendCmd(7);

  uint8_t ctrl = SSD1306_DATA;
  /* 先发控制字节(0x40),再发 1024 字节显存数据 */
  HAL_I2C_Mem_Write(&hi2c1, SSD1306_ADDR << 1,
                    SSD1306_DATA, 1,
                    oled_buf, sizeof(oled_buf), 100);
}

/* 画像素点(x=0-127,y=0-63,color=0/1)*/
void SSD1306_DrawPixel(uint8_t x, uint8_t y, uint8_t color) {
  if (x >= SSD1306_W || y >= SSD1306_H) return;
  if (color)  oled_buf[x + (y / 8) * SSD1306_W] |=  (1 << (y % 8));
  else        oled_buf[x + (y / 8) * SSD1306_W] &= ~(1 << (y % 8));
}

SPI 模式图解

SPI 四种模式(CPOL / CPHA) Mode 0: CPOL=0(空闲低), CPHA=0(第1沿采样) SCLK: ─┐ ┌─┐ ┌─┐ ┌─┐ ┌─ └─┘ └─┘ └─┘ └─┘ MOSI: ──X───────X───────X── (上升沿前稳定,上升沿采样) ↑ 采样 ↑ 采样 Mode 1: CPOL=0(空闲低), CPHA=1(第2沿采样) SCLK: ─┐ ┌─┐ ┌─┐ ┌─┐ ┌─ └─┘ └─┘ └─┘ └─┘ MOSI: ─X───────X───────X─── (下降沿采样) ↑ 采样 ↑ 采样 Mode 3: CPOL=1(空闲高), CPHA=1(第2沿采样) ← W25Q128 使用此模式 SCLK: ─┐ ┌─┐ ┌─┐ ┌─┐ ┌─ → 实际上空闲是高,脉冲向下 └─┘ └─┘ └─┘ └─┘ 常用器件 SPI 模式: SSD1306: Mode 0 或 Mode 3(查具体批次手册) W25Q128: Mode 0 或 Mode 3 MPU6050: I2C(不是SPI) ADS1115: I2C

SPI 读写 W25Q128 Flash

#define W25Q_CS_LOW()   HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET)
#define W25Q_CS_HIGH()  HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)

#define W25Q_CMD_READ_ID    0x9F
#define W25Q_CMD_WRITE_EN   0x06
#define W25Q_CMD_READ_DATA  0x03
#define W25Q_CMD_PAGE_PROG  0x02
#define W25Q_CMD_SECTOR_ERS 0x20
#define W25Q_CMD_READ_SR    0x05

extern SPI_HandleTypeDef hspi1;

/* 发送单字节并接收响应 */
static uint8_t SPI_TxRx(uint8_t tx) {
  uint8_t rx;
  HAL_SPI_TransmitReceive(&hspi1, &tx, &rx, 1, 10);
  return rx;
}

/* 等待 Flash 内部操作完成(读状态寄存器 BUSY 位)*/
static void W25Q_WaitBusy(void) {
  uint8_t sr;
  do {
    W25Q_CS_LOW();
    SPI_TxRx(W25Q_CMD_READ_SR);
    sr = SPI_TxRx(0xFF);
    W25Q_CS_HIGH();
  } while (sr & 0x01);  /* bit0 = BUSY */
}

/* 读取 JEDEC ID(设备识别)*/
uint32_t W25Q_ReadID(void) {
  uint32_t id = 0;
  W25Q_CS_LOW();
  SPI_TxRx(W25Q_CMD_READ_ID);
  id |= (uint32_t)SPI_TxRx(0xFF) << 16;  /* MF ID */
  id |= (uint32_t)SPI_TxRx(0xFF) << 8;   /* Memory Type */
  id |= (uint32_t)SPI_TxRx(0xFF);        /* Capacity */
  W25Q_CS_HIGH();
  return id;  /* W25Q128 = 0xEF4018 */
}

/* 读取数据(任意地址,任意长度)*/
void W25Q_Read(uint32_t addr, uint8_t *buf, uint32_t len) {
  W25Q_CS_LOW();
  SPI_TxRx(W25Q_CMD_READ_DATA);
  SPI_TxRx((addr >> 16) & 0xFF);
  SPI_TxRx((addr >> 8)  & 0xFF);
  SPI_TxRx( addr         & 0xFF);
  HAL_SPI_Receive(&hspi1, buf, len, 1000);
  W25Q_CS_HIGH();
}

/* 写入一页(最多256字节,必须先扇区擦除!)*/
void W25Q_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) {
  W25Q_CS_LOW(); SPI_TxRx(W25Q_CMD_WRITE_EN); W25Q_CS_HIGH();

  W25Q_CS_LOW();
  SPI_TxRx(W25Q_CMD_PAGE_PROG);
  SPI_TxRx((addr >> 16) & 0xFF);
  SPI_TxRx((addr >> 8)  & 0xFF);
  SPI_TxRx( addr         & 0xFF);
  HAL_SPI_Transmit(&hspi1, data, len, 1000);
  W25Q_CS_HIGH();
  W25Q_WaitBusy();
}

/* 4KB 扇区擦除(整扇区变为 0xFF)*/
void W25Q_SectorErase(uint32_t sector_addr) {
  W25Q_CS_LOW(); SPI_TxRx(W25Q_CMD_WRITE_EN); W25Q_CS_HIGH();
  W25Q_CS_LOW();
  SPI_TxRx(W25Q_CMD_SECTOR_ERS);
  SPI_TxRx((sector_addr >> 16) & 0xFF);
  SPI_TxRx((sector_addr >> 8)  & 0xFF);
  SPI_TxRx( sector_addr         & 0xFF);
  W25Q_CS_HIGH();
  W25Q_WaitBusy();  /* 扇区擦除约需 45-400ms */
}
Flash 写操作注意事项

NOR Flash 的写操作只能将 bit 从 1 改为 0。若目标地址已有数据(非全 FF),必须先执行扇区擦除(将整个 4KB 变为 0xFF),再写入新数据。写入前必须先发送 Write Enable 命令(0x06),每次页写后写使能自动清除。频繁擦写会磨损 Flash,W25Q128 擦写寿命约 10 万次。

I2C 上拉电阻选择

上拉电阻值影响 I2C 速度和功耗。电阻太大(>10kΩ):上升沿太慢,高速模式无法工作。电阻太小(<1kΩ):静态电流大,驱动能力要求高。经验值:100kHz 用 10kΩ,400kHz 用 4.7kΩ,Fast-Mode Plus(1MHz)用 1kΩ。总线上接多个设备时,上拉电阻并联,等效值变小,可能需要调整。

I2C 总线仲裁与多主机

I2C 支持多主机(Multi-Master),多个 Master 可以共享同一总线。当两个 Master 同时发起传输时,总线仲裁机制确保只有一个 Master 继续,另一个退让:

仲裁原理(Wire-AND 逻辑)
I2C 总线是开漏(Open-Drain)结构,0 可以强制覆盖 1(线与逻辑)。当两个 Master 同时发数据时,谁先发 0 谁赢得总线(发 1 的 Master 检测到总线是 0,知道自己"输了",立即停止并转为 Slave)。地址较小(更多 0)的设备通常优先获得总线。
时钟延展(Clock Stretching)
Slave 设备可以通过将 SCL 拉低来暂停通信,等自己处理完数据再释放 SCL。这是 Slave 控制总线速度的机制。某些 Master(如 STM32 硬件 I2C 在特定条件下)对时钟延展的支持有限制,需查阅 Reference Manual。

SPI DMA 高速读取 W25Q Flash

在需要高速读取大块 Flash 数据时(如加载图片、音频),使用 SPI DMA 可以让 CPU 同时做其他工作:

/* W25Q128 SPI DMA 读取页数据(256字节/页)*/
/* 全双工:SPI 同时发送读命令+地址 + 接收数据 */
void W25Q_ReadPage_DMA(uint32_t addr, uint8_t *rx_buf) {
  static uint8_t tx_buf[260];       /* 4字节命令+地址 + 256字节哑数据 */
  static uint8_t rx_raw[260];       /* 接收缓冲(前4字节是命令字节,丢弃)*/

  tx_buf[0] = 0x03;                  /* READ 命令 */
  tx_buf[1] = (addr >> 16) & 0xFF;
  tx_buf[2] = (addr >> 8)  & 0xFF;
  tx_buf[3] =  addr         & 0xFF;
  memset(&tx_buf[4], 0xFF, 256);    /* 哑数据(MOSI 发 FF 让 Flash 输出数据)*/

  W25Q_CS_LOW();
  /* 同时启动 TX 和 RX DMA,SPI 全双工 */
  HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buf, rx_raw, 260);
  /* 传输完成后在 HAL_SPI_TxRxCpltCallback 中处理 */
}

void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
  if (hspi->Instance == SPI1) {
    W25Q_CS_HIGH();
    /* rx_raw[0..3] 是命令字节(忽略),rx_raw[4..259] 是读到的 256 字节数据 */
    /* 将有效数据复制到用户缓冲(或直接用偏移量访问)*/
  }
}
本章小结

I2C 是多主机半双工串行总线,7/10 位地址,标准速率 100kHz,快速 400kHz。总线为开漏结构,需要外部上拉电阻(100kHz 用 10kΩ,400kHz 用 4.7kΩ)。每次传输包含 START + 设备地址 + 寄存器地址 + 数据 + STOP。SPI 是全双工同步串行总线,无统一协议,设备特定协议由数据手册定义。SPI 传输速度远高于 I2C(可达几十 MHz),结合 DMA 可以高效读写外部 Flash。Flash 写操作只能 1→0,覆盖时必须先执行 4KB 扇区擦除(耗时 45~400ms)。I2C HAL 函数需注意:地址参数是 7位地址左移1位(不含 R/W 位)。