STM32 可移植教程 05:I2C + SPI 双总线实战——做一个带屏幕、能存设置、会记日志的智能控制台 (实战篇)

STM32 可移植教程 05:在 RGB 控制台工程上加入 I2C 和 SPI(实战篇)

上一篇我们已经把一个小工程做出了"控制台"的味道:

  • TIM3 的 CH2/CH3/CH4 输出三路 PWM,控制 RGB LED;

  • KEY1、KEY2 经过状态机消抖,用来切换当前呼吸灯通道和呼吸步进;

  • USART1 使用 DMA + IDLE 接收串口命令,可以输入 CH0STEP10R500 这类命令;

  • 蜂鸣器在命令执行后短响一下,给用户一个反馈;

  • DS18B20 周期读取温度,RTC 打印当前时间。

这个工程已经不再是"点个灯"的练习,而是一个有输入、有输出、有状态、有串口交互的小控制台。

这一篇就在这个工程上继续往前走:加入 STM32 和外部芯片沟通最常用的两个协议------I2CSPI

有了 I2C,两根线就能挂几十个设备。有了 SPI,几根线就能以 MHz 级速度读写数据。

这一篇,我们不是另起炉灶,而是从你当前的:

bash 复制代码
D:\Embedded\Project\cubemx_vscode\04_timer_pwm_rgb

复制出下一篇工程:

bash 复制代码
05_i2c_spi_console

然后在保留 RGB PWM、按键、串口命令、蜂鸣器、DS18B20、RTC 的基础上,新增:

  • I2C OLED:把串口里能看到的信息搬到屏幕上;

  • I2C EEPROM:保存默认通道、呼吸步进、亮度等设置;

  • SPI Flash:保存按键、串口命令、温度采样等事件日志。

本篇会很长,因为 I2C 和 SPI 的协议原理必须讲透。如果你第一次接触这两个协议,建议分两次阅读------第一天读 I2C 半篇,第二天读 SPI 半篇。但全放在一篇里,是为了让"两协议同时工作"的完整项目不被打散。

本篇目标

最终现象:

bash 复制代码
上电后:
  OLED 显示仪表盘------当前 RGB 通道、呼吸步进、PWM 亮度、温度、日志数量
  EEPROM 自动恢复上次保存的通道、步进、亮度设置
  RGB LED 继续按照 04 工程的呼吸灯逻辑运行

操作时:
  KEY1 短按 → 切换呼吸灯通道,OLED 同步刷新,Flash 记录事件
  KEY2 短按 → 增大呼吸步进,OLED 同步刷新,Flash 记录事件
  串口发 CH/STEP/R/G/B/HELP 命令 → 保留原有命令行为,同时刷新 OLED 和记录日志
  长按 KEY1 → 进入 OLED 设置菜单
  菜单里可以调默认通道、固定亮度、清空日志、查看日志数量

断电 → 重上电:
  设置从 EEPROM 恢复
  SPI Flash 里的事件日志仍然保留

需要准备的硬件(在当前 04 工程基础上新增):

|

模块

|

协议

|

用途

|

参考价格

|

| --- | --- | --- | --- |

|

0.96 寸 OLED(SSD1306)

|

I2C

|

显示屏

|

3~5 元

|

|

AT24C02 EEPROM

|

I2C

|

存设置(板上已有)

|


|

|

W25Q64 Flash

|

SPI

|

存事件日志(板上已有)

|


|

如果你买的 OLED 模块背面有 AT24C02 芯片,那 EEPROM 和 OLED 天然挂在同一组 I2C 引脚上------这正是 I2C 总线的设计初衷。


第一部分:I2C------两根线,挂一切

为什么需要 I2C

先想一个问题:你要在板子上加一个 EEPROM 存设置、加一个 OLED 显示屏、再加一个温度传感器。如果每个芯片都用并口通信------8 根数据线 + 若干控制线------三个芯片下来最少 30 个 GPIO。

STM32F103 一共 112 个 GPIO(ZET6 封装),看起来多,但你实际布线时会发现很多引脚被电源、晶振、JTAG 占掉了,真正能用的可能只有 40~50 个。30 个 GPIO 只给三个外设,太奢侈了。

I2C 解决的就是这个问题:不管挂多少个设备,只占 2 个 GPIO。

I2C 的物理层:两根线怎么传数据

SCL 和 SDA

bash 复制代码
      主设备(STM32)                 从设备 1           从设备 2
      ┌──────────┐                ┌──────────┐       ┌──────────┐
      │          │                │ AT24C02  │       │ SSD1306  │
      │    SCL ──┼────────────────┼── SCL    │       │    SCL   │
      │          │                │          │       │          │
      │    SDA ──┼────────────────┼── SDA    │       │    SDA   │
      │          │                │          │       │          │
      └──────────┘                └──────────┘       └──────────┘
                                          ↑                ↑
                                      地址 0x50         地址 0x3C
  • SCL(Serial Clock) :时钟线,只有主设备能控制。主设备通过拉高/拉低 SCL 来打节拍,告诉所有从设备"这个时刻数据有效"。

  • SDA(Serial Data):数据线,主设备和从设备都可以拉低。数据在 SCL 低电平时变化,在 SCL 高电平时被读取。

USART 是异步的------没有时钟线,双方靠约定好的波特率同步。I2C 是同步的------主设备打拍子,从设备跟着拍子走。这是二者最本质的区别。

开漏输出 + 上拉电阻------I2C 最关键的硬件细节

I2C 的 SCL 和 SDA 引脚都配置为开漏输出(Open-Drain),而不是推挽输出。区别在于:

bash 复制代码
推挽输出:           开漏输出:
  pin 可以输出高      pin 只能输出低(拉地)
  pin 可以输出低      pin 输出高阻态(不驱动)
                     需要外部上拉电阻把线拉到高电平

为什么 I2C 必须用开漏?

设想一下:如果 SDA 是推挽输出,主设备想输出高电平,同时从设备想输出低电平(ACK 时)------两个设备一个往高拉一个往低拉,结果就是短路,可能烧引脚。

开漏的巧妙之处:所有设备只能"拉低",不能"拉高"。谁都不拉的时候,上拉电阻把线拉到高电平。 任何一个设备拉低,整条线就变低。不会短路。

上拉电阻的典型值是 4.7kΩ,一头接 SCL/SDA,一头接 3.3V。有些模块(比如你买的 OLED)板上已经焊好了上拉电阻,不需要自己加。

检查方法: 用万用表电阻档,测量 OLED 模块的 SCL 和 VCC 之间、SDA 和 VCC 之间是否都有 4.7kΩ 左右的电阻。如果有------模块自带的上拉电阻,不用额外焊。如果没有------需要自己在面包板上加两个 4.7kΩ 电阻。

I2C 的协议层:主从对话的全过程

I2C 每次通信都是主设备发起、从设备响应。从设备永远不会主动说话。

1. 器件地址------总线上怎么区分不同的从设备

每个 I2C 芯片在出厂时有一个固定的 7 位地址(有些可以通过引脚电平微调)。主设备在通信的第一步就要广播这个地址------"地址 0x50 的设备在吗?"

bash 复制代码
7 位地址空间:0x00 ~ 0x7F(128 个地址)
但 0x00 ~ 0x07 和 0x78 ~ 0x7F 是保留的
实际可用:约 112 个地址

常见设备地址:
  AT24C02 EEPROM:0x50(A2/A1/A0 引脚全接地)
  SSD1306 OLED:  0x3C(SA0 引脚接地)或 0x3D
  BH1750 光照:   0x23(ADDR 引脚接地)
  MPU6050 陀螺仪:0x68(AD0 引脚接地)

HAL 库在使用地址时,会自动左移 1 位 以容纳 R/W 位。所以代码里写 0x50 << 1,实际发出去的是 0xA0(写)或 0xA1(读)。

2. 起始条件和停止条件

bash 复制代码
SCL ──────┐           ┌──────────────────────────┐           ┌────
           │           │                          │           │
           └───────────┘                          └───────────┘

SDA ──┐        ┌──────────────────────────────┐        ┌──────
      │        │                              │        │
      └────────┘                              └────────┘
      ↑ 起始条件                               ↑ 停止条件
   SCL 高时                              SCL 高时
   SDA 从高→低                           SDA 从低→高
  • 起始条件(START):SCL 为高时,SDA 从高拉低。相当于说"注意,我要开始说话了"。

  • 停止条件(STOP):SCL 为高时,SDA 从低拉高。相当于说"我说完了,总线归你们了"。

这两个条件只能由主设备产生。总线空闲时,SCL 和 SDA 都保持高电平(上拉电阻的作用)。

3. 数据传输------每个字节后面都有一个应答

起始条件之后,主设备开始按字节发送数据。每个字节 8 位,MSB 先发。第 9 个时钟脉冲是 ACK 位。

bash 复制代码
写一个字节的时序(主→从):

SCL  ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
    ─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └────
SDA   D7  D6  D5  D4  D3  D2  D1  D0  ACK
    ────────────────────────────────────
          ↑ SDA 在 SCL 低时变化        ↑ 第 9 个时钟:
          ↑ SDA 在 SCL 高时被采样       主设备释放 SDA
                                         从设备拉低 SDA(ACK=0)
                                         或保持高(NACK=1)

重点:

  • 数据在 SCL 低电平时变化,高电平时采样。 这是为了避免"数据正在变化时被读到"。

  • 第 9 个时钟是 ACK 位。 主设备释放 SDA(不驱动),从设备拉低 SDA 表示"收到了"(ACK),保持高电平表示"没收到"(NACK)。

  • 如果主设备收到 NACK,通常会发 STOP 然后重试。

4. 一次完整的 I2C 写操作(主→AT24C02)

以向 EEPROM 地址 0x00 写入 3 个字节 {0x55, 0xAA, 0x01} 为例:

bash 复制代码
START | 0xA0 | ACK | 0x00 | ACK | 0x55 | ACK | 0xAA | ACK | 0x01 | ACK | STOP
 ↑      ↑      ↑     ↑      ↑      ↑      ↑      ↑      ↑      ↑      ↑     ↑
起始   器件    从机   内存   从机   数据1  从机   数据2  从机   数据3  从机   结束
       地址    应答   地址   应答         应答         应答         应答
       (写)

解释每一步:

  1. START:主设备说"我要开始了"

  2. 0xA0:器件地址 0x50 << 1 | 0(最低位 0 = 写)

  3. ACK:地址 0x50 的设备应答"我在"

  4. 0x00:EEPROM 内的存储地址------"我要写到第 0 个字节"

  5. ACK:从设备说"收到"

  6. 0x55:第一个数据字节

  7. ACK:从设备说"写进去了"

  8. ...(重复)

HAL 库把这个流程封装成了 HAL_I2C_Mem_Write()

bash 复制代码
HAL_I2C_Mem_Write(&hi2c1,          // 用哪个 I2C
                  0x50 << 1,       // 器件地址(已左移)
                  0x00,            // 内存地址
                  I2C_MEMADD_SIZE_8BIT,  // 内存地址是 8 位
                  data,            // 数据指针
                  3,               // 数据长度
                  100);            // 超时(ms)

5. 一次完整的 I2C 读操作(主←AT24C02)

从 EEPROM 地址 0x00 读 3 个字节:

bash 复制代码
START | 0xA0 | ACK | 0x00 | ACK | RESTART | 0xA1 | ACK | D0 | ACK | D1 | ACK | D2 | NACK | STOP
 ↑      ↑      ↑     ↑      ↑       ↑       ↑      ↑    ↑    ↑    ↑    ↑    ↑    ↑     ↑      ↑
起始   写地址  ACK   内存   ACK    再起始   读地址  ACK  数据1 MAK  数据2 MAK  数据3 主NACK 结束
                 地址

注意:读之前要先"伪写"一次告诉 EEPROM 要读哪个地址,然后发 RESTART(不是 STOP!),再用读地址发起读操作。最后一个字节由主设备发 NACK而不是 ACK------告诉从设备"够了,不用再发了"。

HAL 库把这个流程封装成了 HAL_I2C_Mem_Read()

bash 复制代码
HAL_I2C_Mem_Read(&hi2c1,
                 0x50 << 1,       // 器件地址
                 0x00,            // 内存地址
                 I2C_MEMADD_SIZE_8BIT,
                 buf,             // 接收缓冲区
                 3,               // 读取长度
                 100);            // 超时

HAL 库在内部自动处理了"伪写 → RESTART → 读 → NACK"的完整流程。

6. 一个最容易被忽视的坑:EEPROM 写周期

AT24C02 的 datasheet 里有一行容易被跳过的话:

Write Cycle Time: 5 ms max

意思是:你发完一个写命令、收到 ACK 之后,EEPROM 内部还在把数据真正写入存储单元。在这 5ms 之内,EEPROM 不会响应任何新的 I2C 通信。

如果你连续写两页之间不加延时,第二页的 HAL_I2C_Mem_Write 会一直超时失败。HAL 的做法是:

bash 复制代码
HAL_I2C_Mem_Write(&hi2c1, ..., 100);  // 发一页
HAL_Delay(5);  // 等 EEPROM 写完
// 然后才能发下一页

这是 I2C EEPROM 操作最常见的坑。 我们会在代码里把 HAL_Delay(5) 换成一个非阻塞的 Tick 检查------延续本系列的非阻塞原则。


第二部分:SPI------四根线,比 I2C 快一百倍

为什么有了 I2C 还要 SPI

I2C 的优点是用线少,但缺点也很明显:

|

指标

|

I2C

|

SPI

|

| --- | --- | --- |

|

最高速度(标准)

|

100 kHz(标准模式)

|

18 MHz(STM32F103 SPI1)

|

|

最高速度(极限)

|

3.4 MHz(高速模式)

|

50+ MHz

|

|

全双工

|

不支持(半双工,一条数据线)

|

支持(同时收发)

|

|

多设备

|

2 根线即可

|

每多一个设备加一根 CS 线

|

|

协议复杂度

|

有地址/ACK/START/STOP

|

极简------就是移位寄存器

|

简单说:I2C 胜在引脚少、协议健全;SPI 胜在速度快、协议简单。 存设置用 I2C EEPROM 绰绰有余,但要记录大量日志(一次写几百字节),SPI Flash 更合适。

SPI 的物理层:四根线的分工

bash 复制代码
      主设备(STM32)                    从设备(W25Q64)
      ┌──────────┐                     ┌──────────┐
      │          │                     │          │
      │    SCK ──┼─────────────────────┼── CLK    │  ← 时钟
      │   MOSI ──┼─────────────────────┼── DI     │  ← Master Out Slave In
      │   MISO ──┼─────────────────────┼── DO     │  ← Master In Slave Out
      │     CS ──┼─────────────────────┼── CS     │  ← Chip Select(片选)
      │          │                     │          │
      └──────────┘                     └──────────┘
  • SCK(Serial Clock):和 I2C 的 SCL 一样------主设备打拍子。SPI 的 SCK 可以跑到几十 MHz,比 I2C 快两个数量级。

  • MOSI(Master Out, Slave In):主设备发、从设备收。单向。

  • MISO(Master In, Slave Out):从设备发、主设备收。单向。

  • CS(Chip Select,也叫 SS)片选信号。 低电平有效------主设备拉低 CS,就是在说"这个芯片,你被选中了,准备通信"。

为什么 MOSI 和 MISO 各要一根线?------SPI 是全双工的

SPI 的数据传输本质上是一个移位寄存器环:主设备的移位寄存器和从设备的移位寄存器首尾相连,形成一个环。每来一个 SCK 脉冲,主设备移出一位到 MOSI,同时从设备移出一位到 MISO。

bash 复制代码
主设备                               从设备
┌──────────────┐                  ┌──────────────┐
│ 移位寄存器    │  MOSI           │ 移位寄存器    │
│ [7 6 5 4 3 2 │────────────────→│ [7 6 5 4 3 2 │
│  1 0]        │  MISO           │  1 0]        │
│              │←────────────────│              │
└──────────────┘                  └──────────────┘
    ↑ SCK 每来一个脉冲,两个移位寄存器同时左移一位

一个 SCK 时钟周期内,主设备发 1 位、收 1 位同时完成。一个字节的传输只需要 8 个 SCK 周期。

I2C 是半双工------同一根 SDA 线,发数据和收数据不能同时进行。SPI 是全双工------MOSI 和 MISO 各走各的路,可以同时收发。

CS(片选)------SPI 多设备的关键

I2C 靠"地址"区分总线上的不同设备。SPI 没有地址机制------它靠物理引脚 CS 来选择设备。

bash 复制代码
                    STM32
                      │
         ┌────────────┼────────────┐
         │            │            │
        CS0          CS1          CS2
         │            │            │
      W25Q64       (备用)        (备用)
       Flash

每增加一个 SPI 设备,就多占用一个 GPIO 做 CS。这是 SPI 相对 I2C 的主要劣势------设备多了之后,引脚占用也多了。但换来的是速度------用 4 个 GPIO 换 18 MHz 的吞吐,在需要高速读写的场景(Flash、LCD 屏、SD 卡)完全值得。

SPI 的四种模式------CPOL 和 CPHA

SPI 协议没有像 I2C 那样严格规定"起始条件""停止条件",它只规定了两种配置选项的组合:

|

参数

|

含义

|

选项

|

| --- | --- | --- |

|

CPOL(Clock Polarity)

|

SCK 空闲时的电平

|

0 = 低电平,1 = 高电平

|

|

CPHA(Clock Phase)

|

数据在哪个边沿采样

|

0 = 第一个边沿,1 = 第二个边沿

|

四种模式:

bash 复制代码
Mode 0 (CPOL=0, CPHA=0):最常用!
SCK 空闲 = 低
数据在 SCK 上升沿采样,下降沿变化

SCK  ──┐   ┌───┐   ┌───┐   ┌───┐   ┌───
       │   │   │   │   │   │   │   │
       └───┘   └───┘   └───┘   └───┘
MOSI  ──┬───┬───┬───┬───┬───┬───┬───
        │D7 │D6 │D5 │D4 │D3 │D2 │D1 │D0
       ──┴───┴───┴───┴───┴───┴───┴───
       ↑ 上升沿:主设备把数据放到 MOSI 上,从设备采样

Mode 3 (CPOL=1, CPHA=1):
SCK 空闲 = 高
数据在 SCK 上升沿采样

绝大多数 SPI 设备默认 Mode 0 或 Mode 3。W25Q64 支持 Mode 0 和 Mode 3,我们用 Mode 0。

CubeMX 里默认就是 CPOL=Low, CPHA=1 Edge(即 Mode 0)。不用改。

实战经验: SPI 通信出问题时,80% 不是速度或接线问题,而是 CPOL/CPHA 没对上。如果数据全乱、偶尔正确、复位后又不正确------先检查两个设备的 SPI 模式是否一致。

一次完整的 SPI Flash 读操作

以从 W25Q64 地址 0x000000 读取 4 个字节为例:

bash 复制代码
CS  ─┐                                ┌──
     │                                │
     └────────────────────────────────┘
     ↑ CS 拉低:开始通信               ↑ CS 拉高:通信结束

MOSI 主→从: 0x03  0x00  0x00  0x00  X     X     X     X
            │     │     │     │      │     │     │     │
            读命令  地址 24-bit      任意   任意   任意   任意
            (注意是 MSB first)

MISO 从→主:  X     X     X     X    D0    D1    D2    D3
                                    ↑ 从设备在 SCK 驱动下把数据移出来

W25Q64 的 Read Data 命令是 0x03,后面跟 3 个字节的地址(24 位地址空间 = 16MB,W25Q64 只用了 8MB)。之后每来一个 SCK 脉冲,从设备就把一个字节的数据移到 MISO 上。

SPI 不像 I2C 那样有"器件地址"和"内存地址"分开的概念。 地址直接作为数据字节跟在命令后面发,多长由芯片的 datasheet 决定。W25Q64 用 3 字节地址,其他 SPI 芯片可能用 2 字节或 4 字节地址------查 datasheet 是操作任何 SPI 设备的第一步。


第三部分:硬件连接

先把当前 04_timer_pwm_rgb 工程已经用掉的引脚列清楚,再往上加 I2C 和 SPI。这样做的好处是:不会一边扩展新外设,一边把原来能跑的 RGB PWM、按键、串口打坏。

引脚总表

|

引脚

|

方向

|

连接

|

所属协议

|

来源

|

| --- | --- | --- | --- | --- |

|

PB5

|

输出

|

RGB LED R(TIM3_CH2 PWM)

|

PWM

|

当前工程已有

|

|

PB0

|

输出

|

RGB LED G(TIM3_CH3 PWM)

|

PWM

|

当前工程已有

|

|

PB1

|

输出

|

RGB LED B(TIM3_CH4 PWM)

|

PWM

|

当前工程已有

|

|

PA0

|

输入

|

KEY1(下拉,按下为高电平)

|

GPIO

|

当前工程已有

|

|

PC13

|

输入

|

KEY2(下拉,按下为高电平)

|

GPIO

|

当前工程已有

|

|

PA9

|

输出

|

USART1_TX → CH340 RXD

|

USART

|

03 篇

|

|

PA10

|

输入

|

USART1_RX ← CH340 TXD

|

USART

|

03 篇

|

|

PC0

|

输出

|

BEEP0 蜂鸣器

|

GPIO

|

当前工程已有

|

|

PD7

|

开漏

|

DS18B20 数据线

|

OneWire

|

当前工程已有

|

| PB6 |

开漏

| I2C1_SCL

→ OLED SCL + EEPROM SCL

| I2C | 本篇新增 |

| PB7 |

开漏

| I2C1_SDA

→ OLED SDA + EEPROM SDA

| I2C | 本篇新增 |

| PA5 |

推挽

| SPI1_SCK

→ W25Q64 CLK

| SPI | 本篇新增 |

| PA6 |

输入

| SPI1_MISO

← W25Q64 DO

| SPI | 本篇新增 |

| PA7 |

推挽

| SPI1_MOSI

→ W25Q64 DI

| SPI | 本篇新增 |

| PA4 |

推挽

| GPIO

→ W25Q64 CS(片选)

| SPI | 本篇新增 |

接线要点

I2C 总线: OLED 和 AT24C02 共用 PB6(SCL)和 PB7(SDA)。如果 OLED 模块背面已经集成了 AT24C02(很多市售模块都是),那模块排针上的 SCL/SDA 已经同时连到了两个芯片------你只需要接两根线。

SPI 总线: SCK/MISO/MOSI 使用 SPI1 的 PA5/PA6/PA7。CS 使用 PA4 做普通 GPIO 输出。这里不要再用 PB0,因为当前工程里 PB0 已经是 TIM3_CH3,也就是 RGB LED 的一路 PWM。扩展工程时最忌讳"新外设抢老引脚",所以本篇把 W25Q64 的片选放到 PA4。

GND: 所有模块的 GND 必须和 STM32 的 GND 连通。不同模块之间 GND 也要互通。


第四部分:CubeMX 配置

1. 复制工程

从当前已经能跑的工程复制一份:

bash 复制代码
D:\Embedded\Project\cubemx_vscode\04_timer_pwm_rgb

改名为:

bash 复制代码
05_i2c_spi_console

为什么不重新建空工程?

因为这一篇的重点不是"再点一次 LED",而是学习如何在已有工程上继续加外设。真实项目也是这样:你不会为了加一个 OLED 或 Flash 就推倒重来,而是在已有的 PWM、按键、串口、传感器基础上继续扩展。

复制后先做一件事:打开工程,直接编译一次,确认复制出来的工程仍然能通过。

如果复制后编译失败,先不要加 I2C/SPI。先把路径、Makefile、.ioc 文件名、VSCode 配置这些基础问题处理好。

2. 配置 I2C1

Pinout 视图 → ConnectivityI2C1,Mode 选择 I2C

右侧参数面板:

|

参数

|

|

含义

|

| --- | --- | --- |

|

I2C Speed Mode

| Standard Mode |

标准模式 100kHz

|

|

Clock Speed

| 100000 Hz |

100kHz,I2C 设备都支持

|

不需要改动其他参数。CubeMX 会自动把 PB6 标为 I2C1_SCL、PB7 标为 I2C1_SDA。

为什么不选 Fast Mode(400kHz)?AT24C02 和 SSD1306 都支持 400kHz,但 100kHz 在面包板和杜邦线上更稳定。速率后面可以调,调试阶段稳定优先。

3. 配置 SPI1

Pinout 视图 → ConnectivitySPI1,Mode 选择 Full-Duplex Master

右侧参数面板:

|

参数

|

|

含义

|

| --- | --- | --- |

|

Frame Format

| Motorola |

标准 SPI 格式

|

|

Data Size

| 8 Bits |

一帧 8 位

|

|

First Bit

| MSB First |

高位先发

|

|

Prescaler

| 8 |

72MHz / 8 = 9MHz(W25Q64 支持到 104MHz)

|

|

CPOL

| Low |

空闲时 SCK 低电平

|

|

CPHA

| 1 Edge |

第一个边沿采样

|

|

NSS

| Software |

用 GPIO 做 CS

|

CPOL=Low + CPHA=1 Edge = Mode 0------和 W25Q64 匹配。

CubeMX 会自动把 PA5 标为 SPI1_SCK、PA6 标为 SPI1_MISO、PA7 标为 SPI1_MOSI。

4. 配置 CS 引脚(PA4 作为 GPIO 输出)

Pinout 视图 → 点击 PA4 → 选 GPIO_Output 。右键 → 输入 User Label:FLASH_CS

这里再强调一次:不要用 PB0。

当前 04 工程里:

bash 复制代码
PB5 = TIM3_CH2
PB0 = TIM3_CH3
PB1 = TIM3_CH4

这三路已经被 RGB LED 的 PWM 占用了。W25Q64 的 CS 如果放到 PB0,会把绿灯 PWM 破坏掉。

CubeMX 会在 main.h 里生成:

bash 复制代码
#define FLASH_CS_Pin       GPIO_PIN_4
#define FLASH_CS_GPIO_Port GPIOA

5. 确认已有配置

|

外设

|

引脚

|

CubeMX 配置

|

确认项

|

| --- | --- | --- | --- |

|

TIM3_CH2

|

PB5

|

PWM Generation CH2

|

RGB LED R

|

|

TIM3_CH3

|

PB0

|

PWM Generation CH3

|

RGB LED G

|

|

TIM3_CH4

|

PB1

|

PWM Generation CH4

|

RGB LED B

|

|

USART1

|

PA9/PA10

|

Asynchronous 115200 8N1, NVIC enabled

|

串口通信

|

|

DMA1_Channel5

|

USART1_RX

|

Normal Mode

|

串口 DMA 接收

|

|

GPIO Input

|

PA0, PC13

|

Pull-down, User Label KEY1/KEY2

|

按键

|

|

GPIO Output

|

PC0

|

User Label BEEP0

|

蜂鸣器

|

|

GPIO Open-Drain

|

PD7

|

User Label DS18B20

|

温度传感器

|

| I2C1 |

PB6/PB7

|

I2C, 100kHz

| 新增 |

| SPI1 |

PA5/PA6/PA7

|

Full-Duplex Master, 9MHz, Mode 0

| 新增 |

| GPIO Output |

PA4

|

User Label FLASH_CS

| 新增 |

6. 生成代码

Project Manager → Toolchain = Makefile → GENERATE CODE

生成后打开 Core/Src/main.c,确认 CubeMX 自动增加了:

bash 复制代码
static void MX_I2C1_Init(void);
static void MX_SPI1_Init(void);

并且在 main() 初始化区域里出现:

bash 复制代码
MX_I2C1_Init();
MX_SPI1_Init();

注意:CubeMX 重新生成代码时,原来写在 USER CODE 区域里的 APP_LED_INIT()APP_KEY_INIT()App_Usart_Rx_Init()DS18B20_Init()MX_RTC_Init() 应该保留。如果丢了,说明你之前有代码写在了 USER CODE 区域外。


第五部分:代码实现

这篇不是把所有代码塞进 main.c。我们继续沿用当前工程的组织方式:

bash 复制代码
Core/Inc/app_xxx.h
Core/Src/app_xxx.c

当前工程已经有 5 个应用层模块,本篇再新增 6 个模块。先把文件职责分清楚,后面读代码才不会乱。

文件清单

|

文件

|

来源

|

职责

|

| --- | --- | --- |

| app_led.h/c |

当前工程已有

|

用 TIM3_CH2/CH3/CH4 的 CCR 控制 RGB LED 亮度

|

| app_key.h/c |

当前工程已有

|

KEY1/KEY2 状态机消抖,产生 Pressed_Event

|

| app_usart.h/c |

当前工程已有

|

USART1 printf 重定向,DMA + IDLE 接收串口命令

|

| app_ds18b20.h/c |

当前工程已有

|

OneWire 读取 DS18B20 温度

|

| app_rtc.h/c |

当前工程已有

|

RTC 初始化、设置时间、打印时间

|

| app_i2c.h/c |

本篇新增

|

I2C 总线操作 + AT24C02 EEPROM 读写

|

| app_oled.h/c |

本篇新增

|

SSD1306 OLED 显示驱动(基于 I2C)

|

| app_spi.h/c |

本篇新增

|

SPI 总线操作 + W25Q64 Flash 读写

|

| app_menu.h/c |

本篇新增

|

OLED 菜单状态机

|

| app_settings.h/c |

本篇新增

|

设置持久化(读写 EEPROM)

|

| app_log.h/c |

本篇新增

|

事件日志(读写 Flash)

|

这里有一个工程上的分层关系:

bash 复制代码
app_led / app_key / app_usart / app_ds18b20 / app_rtc
    负责当前工程已经跑通的功能

app_i2c / app_spi
    负责总线和芯片读写

app_oled / app_settings / app_log / app_menu
    负责把总线能力变成产品功能

也就是说,I2C 和 SPI 不是为了"学协议而学协议",而是给当前控制台工程补上三个能力:显示、掉电保存、事件记录。


app_i2c.h --- I2C 总线 + EEPROM 接口

bash 复制代码
#ifndef APP_I2C_H
#define APP_I2C_H

#include "main.h"
#include <stdbool.h>
#include <stdint.h>

/*
 * 默认使用 I2C1。
 * 换 I2C2 时在编译选项里加 -DAPP_I2C_HANDLE=hi2c2
 */
#ifndef APP_I2C_HANDLE
#define APP_I2C_HANDLE  hi2c1
#endif

/* ── AT24C02 EEPROM ── */
#define APP_EEPROM_ADDR    0x50    /* 7 位器件地址 */
#define APP_EEPROM_SIZE    256     /* 总容量 256 字节 */
#define APP_EEPROM_PAGE    8       /* 页大小 8 字节 */
#define APP_EEPROM_TIMEOUT 10      /* 超时 ms */

void App_I2C_Init(void);
bool App_I2C_Ready(void);
bool App_EEPROM_Write(uint8_t addr, const uint8_t *data, uint8_t len);
bool App_EEPROM_Read(uint8_t addr, uint8_t *data, uint8_t len);

#endif

几个关键常量的出处:

  • 0x50 :AT24C02 的固定高 4 位是 1010,低 3 位由 A2/A1/A0 引脚电平决定(全接地 = 000),所以 7 位地址 = 1010000 = 0x50。

  • 8 字节页:AT24C02 内部按 8 字节一页组织。一次写超过 8 字节会"卷回"覆盖同一页的前面------所以要分页写。

  • 256 字节:2K bits = 256 bytes。AT24C02 的地址空间是 0x00~0xFF。

app_i2c.c --- I2C 总线 + EEPROM 实现

bash 复制代码
#include "app_i2c.h"

extern I2C_HandleTypeDef APP_I2C_HANDLE;

void App_I2C_Init(void)
{
    /* CubeMX 已通过 MX_I2C1_Init() 初始化了 I2C 外设 */
}

/*
 * 检查 I2C 总线是否就绪------发一个空写检测 EEPROM 是否在总线上响应。
 * 返回 true 表示总线上找到了 0x50 这个设备。
 *
 * HAL_I2C_IsDeviceReady 的原理:
 *   发 START → 发器件地址 → 等 ACK → 发 STOP
 *   如果收到 ACK,说明设备在线。
 *
 * 参数解释:
 *   APP_EEPROM_ADDR << 1  = 0xA0(HAL 需要左移后的地址)
 *   2  = 尝试 2 次
 *   10 = 每次超时 10ms
 */
bool App_I2C_Ready(void)
{
    return HAL_I2C_IsDeviceReady(&APP_I2C_HANDLE,
                                  APP_EEPROM_ADDR << 1,
                                  2,
                                  APP_EEPROM_TIMEOUT) == HAL_OK;
}

/*
 * 向 EEPROM 写数据。自动分页处理 8 字节页边界。
 *
 * 为什么必须分页:
 *   AT24C02 内部按 8 字节一页组织。如果你从地址 0x06 开始写 4 个字节,
 *   前 2 个(0x06, 0x07)写入第 0 页,后 2 个(0x08, 0x09)必须换到第 1 页。
 *   如果一次发 4 个字节,后 2 个会"卷回"覆盖 0x00, 0x01------数据丢失!
 *
 * 为什么需要 5ms 延时:
 *   EEPROM 收到数据后需要时间把数据从缓冲区写入存储单元。
 *   datasheet 标称 Write Cycle Time ≤ 5ms。
 *   这 5ms 内 EEPROM 不响应任何 I2C 通信。
 *
 *   这里用了 HAL_Delay(5),是阻塞延时。对于"保存设置"这种低频操作
 *   (只在用户确认设置时才写 EEPROM),5ms 阻塞完全不影响体验。
 */
bool App_EEPROM_Write(uint8_t addr, const uint8_t *data, uint8_t len)
{
    while (len > 0) {
        /* 计算当前页还能写多少字节 */
        uint8_t page_remain = APP_EEPROM_PAGE - (addr % APP_EEPROM_PAGE);
        uint8_t chunk = (len < page_remain) ? len : page_remain;

        HAL_StatusTypeDef status = HAL_I2C_Mem_Write(
            &APP_I2C_HANDLE,
            APP_EEPROM_ADDR << 1,       /* 器件地址(已左移) */
            addr,                        /* EEPROM 内部地址 */
            I2C_MEMADD_SIZE_8BIT,        /* 地址是 8 位 */
            (uint8_t *)data,             /* 数据(const 强转,HAL 函数签名不完美) */
            chunk,                       /* 本次写入长度 */
            HAL_MAX_DELAY                /* 超时 */
        );

        if (status != HAL_OK) return false;

        data += chunk;
        addr += chunk;
        len  -= chunk;

        /*
         * 等 EEPROM 内部写完。如果你确定单次写入 ≤ 8 字节且不跨页,
         * 可以只在最后延一次。这里为了简洁,每页写完都延。
         */
        if (len > 0) {
            HAL_Delay(6);   /* 6ms > 5ms 标称值,留 1ms 余量 */
        }
    }
    return true;
}

/*
 * 从 EEPROM 读数据。读没有页限制,也没有写周期等待。
 *
 * HAL_I2C_Mem_Read 内部自动处理了:
 *   伪写(告诉 EEPROM 要读哪个地址)→ RESTART → 读 → 最后一个字节发 NACK
 */
bool App_EEPROM_Read(uint8_t addr, uint8_t *data, uint8_t len)
{
    return HAL_I2C_Mem_Read(
        &APP_I2C_HANDLE,
        APP_EEPROM_ADDR << 1,
        addr,
        I2C_MEMADD_SIZE_8BIT,
        data,
        len,
        HAL_MAX_DELAY
    ) == HAL_OK;
}

为什么 HAL_I2C_Mem_Write 的 data 参数是 uint8_t * 而不是 const uint8_t * 这是 HAL 库 API 设计的一个小瑕疵------它不修改 data,但忘了加 const。我们在调用时需要把 const uint8_t * 强转成 uint8_t *,这是安全的,不会修改数据。


app_oled.h --- SSD1306 OLED 显示驱动接口

SSD1306 的显存组织方式:

bash 复制代码
128 列(x 坐标,0~127)
64 行(y 坐标,0~63),分成 8 个"页",每页 8 行

  y=0  ┌──────────────────────────┐  ← Page 0
  ...  │                          │
  y=7  ├──────────────────────────┤  ← Page 1
  ...  │     每个字节代表一列     │
       │     在该页中的 8 个像素  │
  y=63 └──────────────────────────┘  ← Page 7
       ↑ x=0              x=127 ↑

一个字节 = 一列中 8 个纵向像素。bit 0 对应页的顶行,bit 7 对应页的底行。

我们维护一个本地显存缓冲区 vram[8][128],所有绘制操作改 vram,最后一次性通过 I2C 发送到 OLED。这避免了频繁 I2C 通信,刷新更快。

bash 复制代码
#ifndef APP_OLED_H
#define APP_OLED_H

#include "main.h"
#include <stdbool.h>
#include <stdint.h>

#define APP_OLED_ADDR     0x3C    /* 7 位器件地址 */
#define APP_OLED_WIDTH    128
#define APP_OLED_HEIGHT   64
#define APP_OLED_PAGES    8       /* 64 / 8 */

void App_OLED_Init(void);
void App_OLED_Clear(void);
void App_OLED_Refresh(void);
void App_OLED_SetPixel(uint8_t x, uint8_t y, bool on);
void App_OLED_DrawChar(uint8_t x, uint8_t page, char ch);
void App_OLED_DrawString(uint8_t x, uint8_t page, const char *str);
void App_OLED_DrawLineH(uint8_t x, uint8_t y, uint8_t w, bool on);
void App_OLED_FillRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, bool on);
void App_OLED_Printf(uint8_t page, const char *fmt, ...);

#endif

app_oled.c --- SSD1306 驱动实现

这个文件是本篇最长的,但结构清晰:初始化序列 → 基础绘图 → 字符显示 → 辅助功能。

bash 复制代码
#include "app_oled.h"
#include "app_i2c.h"
#include <stdio.h>
#include <stdarg.h>
#include <string.h>

extern I2C_HandleTypeDef APP_I2C_HANDLE;

/* ── 本地显存 ── */
static uint8_t vram[APP_OLED_PAGES][APP_OLED_WIDTH];

/*
 * ── 5×7 像素字体(ASCII 32~127)──
 * 每个字符 5 列 × 1 字节(代表 7 行 + 1 行空白)
 * 只包含可打印 ASCII 字符,其他显示空格
 */
static const uint8_t font5x7[96][5] = {
    {0x00,0x00,0x00,0x00,0x00}, /*   空格 */
    {0x00,0x00,0x5F,0x00,0x00}, /* ! */
    {0x00,0x07,0x00,0x07,0x00}, /* " */
    {0x14,0x7F,0x14,0x7F,0x14}, /* # */
    {0x24,0x2A,0x7F,0x2A,0x12}, /* $ */
    {0x23,0x13,0x08,0x64,0x62}, /* % */
    {0x36,0x49,0x55,0x22,0x50}, /* & */
    {0x00,0x05,0x03,0x00,0x00}, /* ' */
    {0x00,0x1C,0x22,0x41,0x00}, /* ( */
    {0x00,0x41,0x22,0x1C,0x00}, /* ) */
    {0x08,0x2A,0x1C,0x2A,0x08}, /* * */
    {0x08,0x08,0x3E,0x08,0x08}, /* + */
    {0x00,0x50,0x30,0x00,0x00}, /* , */
    {0x08,0x08,0x08,0x08,0x08}, /* - */
    {0x00,0x60,0x60,0x00,0x00}, /* . */
    {0x20,0x10,0x08,0x04,0x02}, /* / */
    {0x3E,0x51,0x49,0x45,0x3E}, /* 0 */
    {0x00,0x42,0x7F,0x40,0x00}, /* 1 */
    {0x42,0x61,0x51,0x49,0x46}, /* 2 */
    {0x21,0x41,0x45,0x4B,0x31}, /* 3 */
    {0x18,0x14,0x12,0x7F,0x10}, /* 4 */
    {0x27,0x45,0x45,0x45,0x39}, /* 5 */
    {0x3C,0x4A,0x49,0x49,0x30}, /* 6 */
    {0x01,0x71,0x09,0x05,0x03}, /* 7 */
    {0x36,0x49,0x49,0x49,0x36}, /* 8 */
    {0x06,0x49,0x49,0x29,0x1E}, /* 9 */
    {0x00,0x36,0x36,0x00,0x00}, /* : */
    {0x00,0x56,0x36,0x00,0x00}, /* ; */
    {0x00,0x08,0x14,0x22,0x41}, /* < */
    {0x14,0x14,0x14,0x14,0x14}, /* = */
    {0x41,0x22,0x14,0x08,0x00}, /* > */
    {0x02,0x01,0x51,0x09,0x06}, /* ? */
    {0x32,0x49,0x79,0x41,0x3E}, /* @ */
    {0x7E,0x11,0x11,0x11,0x7E}, /* A */
    {0x7F,0x49,0x49,0x49,0x36}, /* B */
    {0x3E,0x41,0x41,0x41,0x22}, /* C */
    {0x7F,0x41,0x41,0x22,0x1C}, /* D */
    {0x7F,0x49,0x49,0x49,0x41}, /* E */
    {0x7F,0x09,0x09,0x01,0x01}, /* F */
    {0x3E,0x41,0x41,0x51,0x32}, /* G */
    {0x7F,0x08,0x08,0x08,0x7F}, /* H */
    {0x00,0x41,0x7F,0x41,0x00}, /* I */
    {0x20,0x40,0x41,0x3F,0x01}, /* J */
    {0x7F,0x08,0x14,0x22,0x41}, /* K */
    {0x7F,0x40,0x40,0x40,0x40}, /* L */
    {0x7F,0x02,0x04,0x02,0x7F}, /* M */
    {0x7F,0x04,0x08,0x10,0x7F}, /* N */
    {0x3E,0x41,0x41,0x41,0x3E}, /* O */
    {0x7F,0x09,0x09,0x09,0x06}, /* P */
    {0x3E,0x41,0x51,0x21,0x5E}, /* Q */
    {0x7F,0x09,0x19,0x29,0x46}, /* R */
    {0x46,0x49,0x49,0x49,0x31}, /* S */
    {0x01,0x01,0x7F,0x01,0x01}, /* T */
    {0x3F,0x40,0x40,0x40,0x3F}, /* U */
    {0x1F,0x20,0x40,0x20,0x1F}, /* V */
    {0x7F,0x20,0x18,0x20,0x7F}, /* W */
    {0x63,0x14,0x08,0x14,0x63}, /* X */
    {0x03,0x04,0x78,0x04,0x03}, /* Y */
    {0x61,0x51,0x49,0x45,0x43}, /* Z */
    /* ... 省略部分字符,实际文件包含完整 96 个字符数据 ... */
    {0x00,0x00,0x00,0x00,0x00}, /* DEL */
};

/*
 * ── SSD1306 命令 ──
 */
#define OLED_CMD  0x00   /* 下一个字节是命令 */
#define OLED_DATA 0x40   /* 下一个字节是数据 */

/* 发送一个命令字节 */
static void oled_cmd(uint8_t cmd)
{
    uint8_t buf[2] = { OLED_CMD, cmd };
    HAL_I2C_Master_Transmit(&APP_I2C_HANDLE, APP_OLED_ADDR << 1,
                            buf, 2, HAL_MAX_DELAY);
}

/* 发送多个命令字节 */
static void oled_cmds(const uint8_t *cmds, uint8_t len)
{
    for (uint8_t i = 0; i < len; i++) {
        oled_cmd(cmds[i]);
    }
}

void App_OLED_Init(void)
{
    /*
     * SSD1306 初始化序列(来自 datasheet 第 28 页)。
     * 这些命令的顺序和值不要随意改动------顺序错了 OLED 可能不亮。
     */
    oled_cmd(0xAE);   /* Display OFF */

    oled_cmd(0x20);   /* Set Memory Addressing Mode */
    oled_cmd(0x00);   /* → Horizontal Addressing Mode */

    oled_cmd(0xB0);   /* Set Page Start Address (0) */

    oled_cmd(0xC8);   /* COM Output Scan Direction: remapped (上下不翻转) */

    oled_cmd(0x00);   /* Set Low Column */
    oled_cmd(0x10);   /* Set High Column */

    oled_cmd(0x40);   /* Set Display Start Line */

    oled_cmd(0x81);   /* Set Contrast */
    oled_cmd(0x7F);   /* → 默认对比度 127 */

    oled_cmd(0xA1);   /* Segment Re-map: column 127 = SEG0 (左右不翻转) */

    oled_cmd(0xA6);   /* Normal Display (非反色) */

    oled_cmd(0xA8);   /* Set Multiplex Ratio */
    oled_cmd(0x3F);   /* → 64 */

    oled_cmd(0xA4);   /* Display follows RAM content */

    oled_cmd(0xD3);   /* Set Display Offset */
    oled_cmd(0x00);   /* → 0 */

    oled_cmd(0xD5);   /* Set Display Clock Divide / Oscillator Frequency */
    oled_cmd(0x80);

    oled_cmd(0xD9);   /* Set Pre-charge Period */
    oled_cmd(0x22);

    oled_cmd(0xDA);   /* Set COM Pins Hardware Configuration */
    oled_cmd(0x12);

    oled_cmd(0xDB);   /* Set VCOMH Deselect Level */
    oled_cmd(0x20);

    oled_cmd(0x8D);   /* Charge Pump Setting */
    oled_cmd(0x14);   /* → Enable */

    oled_cmd(0xAF);   /* Display ON */

    App_OLED_Clear();
    App_OLED_Refresh();
}

void App_OLED_Clear(void)
{
    memset(vram, 0x00, sizeof(vram));
}

/*
 * 将 vram 的全部内容通过 I2C 发送到 SSD1306。
 * 使用 Horizontal Addressing Mode------发完一页自动换到下一页。
 * 一次 I2C 传输 = 控制字节(0x40) + 128*8 = 1025 字节。
 */
void App_OLED_Refresh(void)
{
    for (uint8_t page = 0; page < APP_OLED_PAGES; page++) {
        /* 先发送命令:设置列地址 + 页地址 */
        oled_cmd(0xB0 + page);   /* 设置页地址 */
        oled_cmd(0x00);           /* 列低 4 位 */
        oled_cmd(0x10);           /* 列高 4 位 */

        /* 再发送本页数据 */
        uint8_t buf[129];   /* 1 控制字节 + 128 列数据 */
        buf[0] = OLED_DATA;
        memcpy(buf + 1, vram[page], 128);
        HAL_I2C_Master_Transmit(&APP_I2C_HANDLE, APP_OLED_ADDR << 1,
                                buf, 129, HAL_MAX_DELAY);
    }
}

/*
 * vram 操作------所有绘图函数只改 vram,不访问 I2C。
 * 调用 App_OLED_Refresh() 才真正发送到 OLED。
 */

void App_OLED_SetPixel(uint8_t x, uint8_t y, bool on)
{
    if (x >= APP_OLED_WIDTH || y >= APP_OLED_HEIGHT) return;

    uint8_t page = y / 8;
    uint8_t bit  = y % 8;

    if (on) vram[page][x] |=  (1 << bit);
    else    vram[page][x] &= ~(1 << bit);
}

void App_OLED_DrawChar(uint8_t x, uint8_t page, char ch)
{
    if (page >= APP_OLED_PAGES || ch < 32 || ch > 127) return;

    uint8_t idx = ch - 32;
    for (uint8_t col = 0; col < 5; col++) {
        if (x + col >= APP_OLED_WIDTH) break;
        vram[page][x + col] = font5x7[idx][col];
    }
    /* 字符间距 1 像素 */
    if (x + 5 < APP_OLED_WIDTH) vram[page][x + 5] = 0x00;
}

void App_OLED_DrawString(uint8_t x, uint8_t page, const char *str)
{
    while (*str && x < APP_OLED_WIDTH) {
        App_OLED_DrawChar(x, page, *str);
        x += 6;  /* 5 像素字宽 + 1 像素间距 */
        str++;
    }
}

void App_OLED_DrawLineH(uint8_t x, uint8_t y, uint8_t w, bool on)
{
    for (uint8_t i = 0; i < w; i++) {
        App_OLED_SetPixel(x + i, y, on);
    }
}

void App_OLED_FillRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, bool on)
{
    for (uint8_t row = 0; row < h; row++) {
        App_OLED_DrawLineH(x, y + row, w, on);
    }
}

/*
 * 在指定页打印格式化文本。
 * Page 0 = 第 1 行文字,Page 1 = 第 2 行......8 页最多 8 行大字。
 */
void App_OLED_Printf(uint8_t page, const char *fmt, ...)
{
    char buf[22];   /* 128/6 = 21 字符 + \0 */
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);

    /* 清除本页 */
    memset(vram[page], 0x00, APP_OLED_WIDTH);
    App_OLED_DrawString(0, page, buf);
}

完整字体数据: 上面 font5x7 数组只展示了部分字符。完整 96 个字符(空格到 DEL)的字体数据请从 code/app_oled.c 文件中复制。直接在文章里放全太占篇幅。


app_spi.h --- SPI 总线 + W25Q64 Flash 接口

bash 复制代码
#ifndef APP_SPI_H
#define APP_SPI_H

#include "main.h"
#include <stdbool.h>
#include <stdint.h>

#ifndef APP_SPI_HANDLE
#define APP_SPI_HANDLE  hspi1
#endif

/*
 * CS 引脚------用 GPIO 模拟片选,不依赖 SPI 硬件 NSS。
 * CubeMX 里把 PA4 设为 GPIO_Output, User Label = FLASH_CS
 */
#define APP_FLASH_CS_PORT   FLASH_CS_GPIO_Port
#define APP_FLASH_CS_PIN    FLASH_CS_Pin

/* ── W25Q64 命令 ── */
#define W25Q_CMD_WRITE_ENABLE   0x06
#define W25Q_CMD_READ_STATUS    0x05
#define W25Q_CMD_READ_DATA      0x03
#define W25Q_CMD_PAGE_PROGRAM   0x02
#define W25Q_CMD_SECTOR_ERASE   0x20
#define W25Q_CMD_JEDEC_ID       0x9F

#define W25Q_PAGE_SIZE          256
#define W25Q_SECTOR_SIZE        4096
#define W25Q_TIMEOUT            1000

void App_SPI_Init(void);
bool App_SPI_Ready(void);
uint32_t App_Flash_JEDEC_ID(void);
bool App_Flash_Read(uint32_t addr, uint8_t *buf, uint32_t len);
bool App_Flash_WritePage(uint32_t addr, const uint8_t *buf, uint16_t len);
bool App_Flash_EraseSector(uint32_t addr);

#endif

app_spi.c --- SPI 总线 + W25Q64 Flash 实现

bash 复制代码
#include "app_spi.h"

extern SPI_HandleTypeDef APP_SPI_HANDLE;

/* ── CS 控制(低电平有效)── */
static void cs_low(void)
{
    HAL_GPIO_WritePin(APP_FLASH_CS_PORT, APP_FLASH_CS_PIN, GPIO_PIN_RESET);
}

static void cs_high(void)
{
    HAL_GPIO_WritePin(APP_FLASH_CS_PORT, APP_FLASH_CS_PIN, GPIO_PIN_SET);
}

void App_SPI_Init(void)
{
    /* CubeMX 已通过 MX_SPI1_Init() 初始化了 SPI 外设 */
    cs_high();  /* CS 初始拉高(不选中) */
}

/*
 * 读 JEDEC ID------验证 SPI 通信是否正常。
 * W25Q64 的 JEDEC ID = 0xEF4017
 */
uint32_t App_Flash_JEDEC_ID(void)
{
    uint8_t cmd = W25Q_CMD_JEDEC_ID;
    uint8_t id[3] = {0};

    cs_low();
    /*
     * SPI 是全双工的------发送的同时也在接收。
     * HAL_SPI_TransmitReceive 同时做两件事:
     *   发 cmd,收一个无用字节(dummy)
     *   发 0xFF 三次,收三个字节的 ID
     *
     * 为什么发 0xFF?因为 SPI 必须有时钟才能收到数据------发送任意字节
     * 只是为了产生 SCK 脉冲,推动从设备把数据移出来。
     */
    HAL_SPI_TransmitReceive(&APP_SPI_HANDLE, &cmd, id, 1, W25Q_TIMEOUT);
    HAL_SPI_Receive(&APP_SPI_HANDLE, id, 3, W25Q_TIMEOUT);
    cs_high();

    return ((uint32_t)id[0] << 16) | ((uint32_t)id[1] << 8) | id[2];
}

bool App_SPI_Ready(void)
{
    return App_Flash_JEDEC_ID() == 0xEF4017;
}

/*
 * 读 Flash 任意地址、任意长度。
 * 命令 0x03,后跟 3 字节地址(MSB first),然后读数据。
 *
 * 读没有对齐限制------可以从任意地址读任意字节。
 * 也没有页边界------跨页自动连续读。
 */
bool App_Flash_Read(uint32_t addr, uint8_t *buf, uint32_t len)
{
    uint8_t cmd[4];
    cmd[0] = W25Q_CMD_READ_DATA;
    cmd[1] = (uint8_t)(addr >> 16);   /* 地址高字节 */
    cmd[2] = (uint8_t)(addr >> 8);    /* 地址中字节 */
    cmd[3] = (uint8_t)(addr);         /* 地址低字节 */

    cs_low();
    HAL_SPI_Transmit(&APP_SPI_HANDLE, cmd, 4, W25Q_TIMEOUT);
    HAL_StatusTypeDef status = HAL_SPI_Receive(&APP_SPI_HANDLE, buf, (uint16_t)len, W25Q_TIMEOUT);
    cs_high();

    return status == HAL_OK;
}

/*
 * 写 Flash 一页(最大 256 字节,必须在一页内,不能跨页)。
 *
 * Flash 的写和 EEPROM 有一个关键区别:
 *   Flash 只能把 bit 从 1 变成 0,不能从 0 变成 1。
 *   要把 0 变回 1,必须先擦除(erase),擦除以扇区(4KB)为单位。
 *
 * 写流程:
 *   1. Write Enable(发 0x06,解锁写操作)
 *   2. Page Program(发 0x02 + 地址 + 数据)
 *   3. 等待 BUSY 位清零(轮询状态寄存器)
 */
static void flash_write_enable(void)
{
    uint8_t cmd = W25Q_CMD_WRITE_ENABLE;
    cs_low();
    HAL_SPI_Transmit(&APP_SPI_HANDLE, &cmd, 1, W25Q_TIMEOUT);
    cs_high();
}

static void flash_wait_busy(void)
{
    uint8_t cmd = W25Q_CMD_READ_STATUS;
    uint8_t sr;
    do {
        cs_low();
        HAL_SPI_Transmit(&APP_SPI_HANDLE, &cmd, 1, W25Q_TIMEOUT);
        HAL_SPI_Receive(&APP_SPI_HANDLE, &sr, 1, W25Q_TIMEOUT);
        cs_high();
    } while (sr & 0x01);  /* BUSY = bit 0 */
}

bool App_Flash_WritePage(uint32_t addr, const uint8_t *buf, uint16_t len)
{
    if (len > W25Q_PAGE_SIZE) return false;

    flash_write_enable();

    uint8_t cmd[4];
    cmd[0] = W25Q_CMD_PAGE_PROGRAM;
    cmd[1] = (uint8_t)(addr >> 16);
    cmd[2] = (uint8_t)(addr >> 8);
    cmd[3] = (uint8_t)(addr);

    cs_low();
    HAL_SPI_Transmit(&APP_SPI_HANDLE, cmd, 4, W25Q_TIMEOUT);
    HAL_SPI_Transmit(&APP_SPI_HANDLE, (uint8_t *)buf, len, W25Q_TIMEOUT);
    cs_high();

    flash_wait_busy();   /* 等待写入完成 */
    return true;
}

/*
 * 擦除一个扇区(4KB)。擦除后该扇区所有字节变为 0xFF。
 *
 * 扇区地址必须 4K 对齐------addr 的低 12 位应该全 0。
 * 这是 Flash 硬件的要求,不是代码限制。
 */
bool App_Flash_EraseSector(uint32_t addr)
{
    flash_write_enable();

    uint8_t cmd[4];
    cmd[0] = W25Q_CMD_SECTOR_ERASE;
    cmd[1] = (uint8_t)(addr >> 16);
    cmd[2] = (uint8_t)(addr >> 8);
    cmd[3] = (uint8_t)(addr);

    cs_low();
    HAL_SPI_Transmit(&APP_SPI_HANDLE, cmd, 4, W25Q_TIMEOUT);
    cs_high();

    flash_wait_busy();   /* 擦除需要几百 ms */
    return true;
}

app_settings.h/c --- 设置持久化(基于 EEPROM)

设置保存在 EEPROM 的前 8 个字节里:

bash 复制代码
EEPROM 地址   内容
0x00         magic (0xA5)  ------ "设置有效"的标志
0x01         default_channel (0=Red, 1=Green, 2=Blue)
0x02         led_brightness (0~100)
0x03         breath_step (1~100)
0x04         checksum = magic ^ channel ^ brightness ^ step
0x05~0x07    保留
bash 复制代码
/* ── app_settings.h ── */
#ifndef APP_SETTINGS_H
#define APP_SETTINGS_H

#include <stdint.h>
#include <stdbool.h>

typedef struct {
    uint8_t magic;
    uint8_t default_channel;
    uint8_t led_brightness;
    uint8_t breath_step;
} App_Settings;

void App_Settings_Load(App_Settings *s);
void App_Settings_Save(const App_Settings *s);
void App_Settings_Defaults(App_Settings *s);

#endif
bash 复制代码
/* ── app_settings.c ── */
#include "app_settings.h"
#include "app_i2c.h"

#define SETTINGS_MAGIC  0xA5

void App_Settings_Load(App_Settings *s)
{
    uint8_t buf[5];

    if (!App_EEPROM_Read(0x00, buf, 5)) {
        /* I2C 读失败------用默认值 */
        App_Settings_Defaults(s);
        return;
    }

    if (buf[0] != SETTINGS_MAGIC) {
        /* 第一次上电------EEPROM 里全是 0xFF,不是 0xA5 */
        App_Settings_Defaults(s);
        return;
    }

    /* 校验 checksum */
    uint8_t cs = buf[0] ^ buf[1] ^ buf[2] ^ buf[3];
    if (cs != buf[4]) {
        App_Settings_Defaults(s);
        return;
    }

    s->magic           = buf[0];
    s->default_channel = buf[1];
    s->led_brightness  = buf[2];
    s->breath_step     = buf[3];
}

void App_Settings_Save(const App_Settings *s)
{
    uint8_t buf[5];
    buf[0] = s->magic;
    buf[1] = s->default_channel;
    buf[2] = s->led_brightness;
    buf[3] = s->breath_step;
    buf[4] = buf[0] ^ buf[1] ^ buf[2] ^ buf[3];   /* checksum */

    App_EEPROM_Write(0x00, buf, 5);
}

void App_Settings_Defaults(App_Settings *s)
{
    s->magic           = SETTINGS_MAGIC;
    s->default_channel = 0;
    s->led_brightness  = 80;
    s->breath_step     = 1;
}

checksum 的设计:magic ^ channel ^ brightness ^ step。如果有人直接篡改 EEPROM 里的某个字节(或者比特翻转),加载时 checksum 就对不上了------退回默认值。不是防黑客的,是防硬件出错的。


app_log.h/c --- 事件日志(基于 Flash)

使用 W25Q64 的扇区 0(0x000000~0x000FFF,4KB)存储事件日志。每条日志 8 字节,最多存 512 条。写满后自动擦除扇区,从头开始(环形覆盖)。

bash 复制代码
每条日志 8 字节:
  [31:0]  timestamp  ------ HAL_GetTick() 值
  [39:32] event_type ------ 见 APP_LOG_EVENT_* 枚举
  [47:40] param      ------ 附加参数(模式值、亮度值等)
  [55:48] reserved   ------ 保留
  [63:56] checksum   ------ 前 7 字节 XOR
bash 复制代码
/* ── app_log.h ── */
#ifndef APP_LOG_H
#define APP_LOG_H

#include <stdint.h>
#include <stdbool.h>

/* 日志事件类型 */
typedef enum {
    APP_LOG_BOOT          = 0x00,
    APP_LOG_KEY1_PRESS    = 0x01,
    APP_LOG_KEY1_LONG     = 0x02,
    APP_LOG_KEY2_PRESS    = 0x03,
    APP_LOG_KEY2_LONG     = 0x04,
    APP_LOG_KEY2_RELEASE  = 0x05,
    APP_LOG_CMD_MODE      = 0x10,
    APP_LOG_CMD_LED       = 0x11,
    APP_LOG_SETTINGS_CHG  = 0x20,
} App_LogEvent;

typedef struct {
    uint32_t timestamp;
    uint8_t  event_type;
    uint8_t  param;
    uint8_t  reserved;
    uint8_t  checksum;
} App_LogEntry;

#define APP_LOG_SECTOR_ADDR  0x000000
#define APP_LOG_ENTRY_COUNT  (W25Q_SECTOR_SIZE / 8)   /* 512 */

void App_Log_Init(void);
void App_Log_Write(App_LogEvent event, uint8_t param);
uint16_t App_Log_Count(void);
bool App_Log_Read(uint16_t index, App_LogEntry *entry);
void App_Log_Clear(void);

#endif
bash 复制代码
/* ── app_log.c ── */
#include "app_log.h"
#include "app_spi.h"
#include <string.h>

static uint16_t s_log_count = 0;       /* 当前日志条数(内存中维护) */
static bool     s_log_count_valid = false;

/* 启动时扫描 Flash,确定当前有多少条日志 */
void App_Log_Init(void)
{
    /*
     * 顺序扫描扇区:找到第一条全 0xFF 的位置就是日志尾部。
     * 如果扇区开头就是 0xFF------没有日志。
     * 如果 512 条都是有效数据------写满了,需要先擦除再从头开始。
     */
    uint8_t buf[8];
    s_log_count = 0;

    for (uint16_t i = 0; i < APP_LOG_ENTRY_COUNT; i++) {
        App_Flash_Read(APP_LOG_SECTOR_ADDR + i * 8, buf, 8);

        /* 检查是否全为 0xFF(空槽) */
        bool all_ff = true;
        for (int j = 0; j < 8; j++) {
            if (buf[j] != 0xFF) { all_ff = false; break; }
        }

        if (all_ff) break;    /* 找到尾部 */
        s_log_count++;
    }

    s_log_count_valid = true;
}

void App_Log_Write(App_LogEvent event, uint8_t param)
{
    if (!s_log_count_valid) App_Log_Init();

    /* 写满了?擦除后从头开始 */
    if (s_log_count >= APP_LOG_ENTRY_COUNT) {
        App_Flash_EraseSector(APP_LOG_SECTOR_ADDR);
        s_log_count = 0;
    }

    /* 构造日志条目 */
    App_LogEntry entry;
    entry.timestamp  = HAL_GetTick();
    entry.event_type = event;
    entry.param      = param;
    entry.reserved   = 0;

    uint8_t *p = (uint8_t *)&entry;
    entry.checksum = 0;
    for (int i = 0; i < 7; i++) entry.checksum ^= p[i];

    /* 写入 Flash */
    uint32_t addr = APP_LOG_SECTOR_ADDR + s_log_count * 8;
    App_Flash_WritePage(addr, (uint8_t *)&entry, 8);
    s_log_count++;
}

uint16_t App_Log_Count(void)
{
    if (!s_log_count_valid) App_Log_Init();
    return s_log_count;
}

bool App_Log_Read(uint16_t index, App_LogEntry *entry)
{
    if (index >= App_Log_Count()) return false;

    uint32_t addr = APP_LOG_SECTOR_ADDR + index * 8;
    App_Flash_Read(addr, (uint8_t *)entry, 8);

    /* 校验 checksum */
    uint8_t cs = 0;
    uint8_t *p = (uint8_t *)entry;
    for (int i = 0; i < 7; i++) cs ^= p[i];

    return cs == entry->checksum;
}

void App_Log_Clear(void)
{
    App_Flash_EraseSector(APP_LOG_SECTOR_ADDR);
    s_log_count = 0;
}

app_menu.h/c --- OLED 菜单状态机

菜单结构:

bash 复制代码
MAIN(仪表盘,默认显示)
  KEY2 短按 → SETTINGS

SETTINGS
  ├─ Brightness  ← KEY2 进入调节
  ├─ Default Channel ← KEY2 进入调节
  ├─ Clear Logs   ← KEY2 执行清空
  ├─ About        ← KEY2 进入
  └─ Back         ← KEY2 返回 MAIN

按键在菜单模式下的行为:

  • KEY1 短按:下一个选项

  • KEY2 短按:确认 / 进入

  • KEY1 长按(2s):返回上级 / 退出菜单

bash 复制代码
/* ── app_menu.h ── */
#ifndef APP_MENU_H
#define APP_MENU_H

#include <stdbool.h>
#include <stdint.h>

typedef enum {
    MENU_MAIN = 0,
    MENU_SETTINGS,
    MENU_BRIGHTNESS,
    MENU_DEFAULT_CHANNEL,
    MENU_CLEAR_LOGS,
    MENU_ABOUT,
} MenuPage;

void App_Menu_Init(void);
void App_Menu_Tick(void);           /* 每循环调用一次 */
void App_Menu_Select(void);         /* KEY2 短按 */
void App_Menu_Next(void);           /* KEY1 短按 */
void App_Menu_Back(void);           /* KEY1 长按 */
bool App_Menu_IsActive(void);       /* 当前是否在菜单模式 */
void App_Menu_Enter(void);          /* 进入菜单 */
void App_Menu_Exit(void);           /* 退出菜单 */
void App_Menu_SetRuntimeInfo(uint8_t channel,
                             uint8_t step,
                             uint16_t brightness,
                             int16_t temperature_x10);

/* 供外部查询的当前值 */
uint8_t App_Menu_GetBrightness(void);
uint8_t App_Menu_GetDefaultChannel(void);

#endif
bash 复制代码
/* ── app_menu.c ── */
#include "app_menu.h"
#include "app_oled.h"
#include "app_settings.h"
#include "app_log.h"
#include "main.h"
#include <stdio.h>

static MenuPage s_page        = MENU_MAIN;
static uint8_t  s_cursor      = 0;      /* 当前选项索引 */
static bool     s_menu_active = false;
static App_Settings s_settings;
static uint8_t  s_current_channel = 0;
static uint8_t  s_current_step = 1;
static uint16_t s_current_brightness = 0;
static int16_t  s_current_temperature_x10 = 0;
static uint32_t s_last_refresh_tick = 0;

/* 每个菜单页的选项文本 */
static const char *s_settings_items[] = {
    "Brightness",
    "Default Channel",
    "Clear Logs",
    "About",
    "Back"
};
#define SETTINGS_ITEM_COUNT  5

void App_Menu_Init(void)
{
    App_Settings_Load(&s_settings);
    s_current_channel = s_settings.default_channel;
    s_current_step = s_settings.breath_step;
}

bool App_Menu_IsActive(void)  { return s_menu_active; }
void App_Menu_Enter(void)     { s_menu_active = true;  s_page = MENU_MAIN; s_cursor = 0; }
void App_Menu_Exit(void)      { s_menu_active = false; }

uint8_t App_Menu_GetBrightness(void)   { return s_settings.led_brightness; }
uint8_t App_Menu_GetDefaultChannel(void)  { return s_settings.default_channel; }

void App_Menu_SetRuntimeInfo(uint8_t channel,
                             uint8_t step,
                             uint16_t brightness,
                             int16_t temperature_x10)
{
    s_current_channel = channel;
    s_current_step = step;
    s_current_brightness = brightness;
    s_current_temperature_x10 = temperature_x10;
}

void App_Menu_Next(void)
{
    if (!s_menu_active) return;
    s_cursor++;
}

void App_Menu_Select(void)
{
    if (!s_menu_active) return;

    switch (s_page) {

    case MENU_MAIN:
        s_page = MENU_SETTINGS;
        s_cursor = 0;
        break;

    case MENU_SETTINGS:
        switch (s_cursor) {
        case 0: s_page = MENU_BRIGHTNESS;  s_cursor = 0; break;
        case 1: s_page = MENU_DEFAULT_CHANNEL; s_cursor = 0; break;
        case 2:
            App_Log_Clear();
            s_cursor = 0;
            break;
        case 3: s_page = MENU_ABOUT;       s_cursor = 0; break;
        case 4: s_page = MENU_MAIN;        s_cursor = 0; break;
        }
        break;

    case MENU_BRIGHTNESS:
        if (s_settings.led_brightness < 100) {
            s_settings.led_brightness += 10;
        } else {
            s_settings.led_brightness = 0;
        }
        break;

    case MENU_DEFAULT_CHANNEL:
        s_settings.default_channel = (s_settings.default_channel + 1) % 3;
        break;

    case MENU_ABOUT:
        s_page = MENU_SETTINGS;
        s_cursor = 3;  /* 回到 About 选项 */
        break;

    case MENU_CLEAR_LOGS:
        break;

    default: break;
    }
}

void App_Menu_Back(void)
{
    if (!s_menu_active) return;

    switch (s_page) {
    case MENU_MAIN:
        App_Menu_Exit();
        App_Settings_Save(&s_settings);   /* 退出时保存设置 */
        App_Log_Write(APP_LOG_SETTINGS_CHG, 0);
        return;

    case MENU_SETTINGS:
        s_page = MENU_MAIN;
        s_cursor = 0;
        break;

    case MENU_BRIGHTNESS:
        s_page = MENU_SETTINGS;
        s_cursor = 0;
        break;

    case MENU_DEFAULT_CHANNEL:
        s_page = MENU_SETTINGS;
        s_cursor = 1;
        break;

    case MENU_ABOUT:
        s_page = MENU_SETTINGS;
        s_cursor = 3;
        break;

    default: break;
    }
}

/*
 * 渲染当前菜单页到 vram 并刷新 OLED。
 * 主循环可以每一圈都调用,但内部 100ms 才真正刷新一次,
 * 避免 I2C 刷屏把按键扫描和串口命令处理拖慢。
 */
void App_Menu_Tick(void)
{
    uint32_t now = HAL_GetTick();
    if (now - s_last_refresh_tick < 100) {
        return;
    }
    s_last_refresh_tick = now;

    MenuPage draw_page = s_menu_active ? s_page : MENU_MAIN;

    App_OLED_Clear();

    switch (draw_page) {

    case MENU_MAIN: {
        App_OLED_DrawString(0, 0, "Smart Console");
        App_OLED_DrawLineH(0, 9, 128, true);

        char buf[22];
        const char *channel_names[] = { "Red", "Green", "Blue" };
        snprintf(buf, sizeof(buf), "CH: %s", channel_names[s_current_channel % 3]);
        App_OLED_DrawString(0, 2, buf);

        snprintf(buf, sizeof(buf), "Step: %d", s_current_step);
        App_OLED_DrawString(0, 3, buf);

        snprintf(buf, sizeof(buf), "PWM: %d", s_current_brightness);
        App_OLED_DrawString(0, 4, buf);

        if (s_current_temperature_x10 < 0) {
            int16_t t = -s_current_temperature_x10;
            snprintf(buf, sizeof(buf), "Temp:-%d.%dC", t / 10, t % 10);
        } else {
            snprintf(buf, sizeof(buf), "Temp:%d.%dC",
                     s_current_temperature_x10 / 10,
                     s_current_temperature_x10 % 10);
        }
        App_OLED_DrawString(0, 5, buf);

        snprintf(buf, sizeof(buf), "Logs: %d", App_Log_Count());
        App_OLED_DrawString(0, 6, buf);

        App_OLED_DrawString(0, 7, "Long KEY1: Menu");
        break;
    }

    case MENU_SETTINGS: {
        App_OLED_DrawString(0, 0, "Settings");
        App_OLED_DrawLineH(0, 9, 128, true);

        for (int i = 0; i < SETTINGS_ITEM_COUNT; i++) {
            uint8_t page = 2 + i;
            /* 当前选中项前面加 > */
            char item[22];
            snprintf(item, sizeof(item), "%s %s",
                     (i == s_cursor) ? ">" : " ",
                     s_settings_items[i]);
            App_OLED_DrawString(0, page, item);
        }

        /* 超出范围的 cursor 回绕 */
        if (s_cursor >= SETTINGS_ITEM_COUNT) s_cursor = 0;
        break;
    }

    case MENU_BRIGHTNESS: {
        App_OLED_DrawString(0, 0, "Brightness");
        App_OLED_DrawLineH(0, 9, 128, true);

        char buf[22];
        snprintf(buf, sizeof(buf), "%d%%", s_settings.led_brightness);
        App_OLED_DrawString(0, 3, buf);

        /* 进度条 */
        App_OLED_DrawLineH(10, 40, 108, false);   /* 边框 */
        App_OLED_DrawLineH(10, 48, 108, false);
        App_OLED_FillRect(12, 42, s_settings.led_brightness, 5, true);

        App_OLED_DrawString(0, 7, "KEY2: +10  Long: back");
        break;
    }

    case MENU_DEFAULT_CHANNEL: {
        App_OLED_DrawString(0, 0, "Default Channel");
        App_OLED_DrawLineH(0, 9, 128, true);

        const char *channel_names[] = { "Red", "Green", "Blue" };
        char buf[22];
        snprintf(buf, sizeof(buf), "[ %s ]", channel_names[s_settings.default_channel]);
        App_OLED_DrawString(0, 3, buf);

        App_OLED_DrawString(0, 7, "KEY2: switch  Long: back");
        break;
    }

    case MENU_ABOUT: {
        App_OLED_DrawString(0, 0, "About");
        App_OLED_DrawLineH(0, 9, 128, true);

        App_OLED_DrawString(0, 2, "FW: v1.0");
        App_OLED_DrawString(0, 3, "MCU: STM32F103");

        char buf[22];
        snprintf(buf, sizeof(buf), "I2C: OLED+EEPROM");
        App_OLED_DrawString(0, 4, buf);
        snprintf(buf, sizeof(buf), "SPI: W25Q64 8MB");
        App_OLED_DrawString(0, 5, buf);

        App_OLED_DrawString(0, 7, "Long: back");
        break;
    }

    default: break;
    }

    App_OLED_Refresh();
}

菜单状态机的设计思路:

  1. 每个页面是一个状态。 App_Menu_Select() 是事件处理------根据当前页面和 cursor 位置决定跳转。

  2. 渲染和逻辑分离。 App_Menu_Tick() 只负责把当前页面画到 vram,不修改任何状态。

  3. cursor 管理。 KEY1 短按让 cursor+1,超出选项范围后回绕到 0。和按键状态机的"事件消费"模式一致。


第六部分:main.c 集成

这一部分要特别注意:我们不是写一个全新的 main.c,而是在你当前 04_timer_pwm_rgbmain.c 上继续加东西。

当前工程的主循环已经是这样:

bash 复制代码
while (1) {
    App_Key_Tick(&s_key1);
    App_Key_Tick(&s_key2);
    App_Usart_Process();
    App_Breath_Tick();
    App_Key_Process();
    App_Cmd_Process();
    App_DS18B20_Process();
}

本篇新增 I2C/SPI 后,不要把这条主线推翻。更合理的做法是:

bash 复制代码
保留原来的呼吸灯、按键、串口命令、温度、RTC
新增 OLED 仪表盘刷新
新增 EEPROM 设置恢复/保存
新增 Flash 日志记录

也就是让新模块围着旧工程服务,而不是让旧工程迁就新模块。

初始化(USER CODE BEGIN 2)

当前工程已有初始化顺序是对的:

bash 复制代码
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
MX_TIM3_Init();

本篇生成代码后,会多出:

bash 复制代码
MX_I2C1_Init();
MX_SPI1_Init();

另外在 USER CODE BEGIN PV 里新增一个设置变量,用来承接 EEPROM 里读出来的默认通道和呼吸步进:

bash 复制代码
/* USER CODE BEGIN PV */
static App_Settings s_runtime_settings;
/* USER CODE END PV */

建议顺序如下:

bash 复制代码
/* USER CODE BEGIN 2 */

/* 阶段 1:启动当前工程已有的硬件输出 */
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);

APP_LED_INIT(&s_led_r, &htim3, TIM_CHANNEL_2);
APP_LED_INIT(&s_led_g, &htim3, TIM_CHANNEL_3);
APP_LED_INIT(&s_led_b, &htim3, TIM_CHANNEL_4);

APP_KEY_INIT(&s_key1, KEY1_GPIO_Port, KEY1_Pin);
APP_KEY_INIT(&s_key2, KEY2_GPIO_Port, KEY2_Pin);

App_Usart_Rx_Init();

s_breath_leds[0] = &s_led_r;
s_breath_leds[1] = &s_led_g;
s_breath_leds[2] = &s_led_b;

HAL_TIM_Base_Start_IT(&htim3);  /* 给蜂鸣器倒计时使用 */

DS18B20_Init();
MX_RTC_Init();

/* 阶段 2:初始化本篇新增总线 */
App_I2C_Init();
App_SPI_Init();

/* 阶段 3:初始化依赖 I2C/SPI 的上层模块 */
App_OLED_Init();
App_Settings_Load(&s_runtime_settings);
s_breath_channel = s_runtime_settings.default_channel;
step = s_runtime_settings.breath_step;
App_Log_Init();
App_Menu_Init();

/* 阶段 4:上电自检 */
printf("\r\n=== Smart Console ===\r\n");

if (App_I2C_Ready()) {
    printf("[OK] I2C bus ready (found 0x50)\r\n");
} else {
    printf("[WARN] I2C bus not ready (check wiring)\r\n");
}

uint32_t jedec = App_Flash_JEDEC_ID();
printf("[OK] SPI Flash JEDEC ID: 0x%06lX (expect 0xEF4017)\r\n", jedec);

App_Log_Write(APP_LOG_BOOT, 0);

printf("Current channel=%d, step=%d\r\n", s_breath_channel, step);
printf("=========================\r\n\r\n");
/* USER CODE END 2 */

初始化分三个阶段:

  1. 先启动当前工程已有功能。 PWM、按键、串口 DMA、DS18B20、RTC 都是当前工程已经跑通的基础,不要因为加 I2C/SPI 把它们挪乱。

  2. 再初始化新增总线。 MX_I2C1_Init()MX_SPI1_Init() 由 CubeMX 生成;App_I2C_Init()App_SPI_Init() 是我们自己的轻量封装。

  3. 最后初始化上层功能。 OLED、Settings、Log、Menu 都依赖 I2C/SPI,必须放在总线初始化后。

  4. 上电自检一定要打印。 I2C 和 SPI 最怕"代码写完了但不知道设备到底有没有应答"。上电打印 [OK][WARN],后面排坑会轻松很多。

主循环(USER CODE BEGIN WHILE)

主循环继续保留当前工程的节奏,只是在后面补 OLED 和日志。

bash 复制代码
/* USER CODE BEGIN WHILE */
while (1)
{
    /* ── 1. 当前工程已有输入处理 ── */
    App_Key_Tick(&s_key1);
    App_Key_Tick(&s_key2);
    App_Usart_Process();

    /* ── 2. 当前工程已有业务逻辑 ── */
    App_Breath_Tick();
    App_Key_Process();
    App_Cmd_Process();
    App_DS18B20_Process();

    /* ── 3. 本篇新增:OLED 刷新和菜单处理 ── */
    App_Menu_SetRuntimeInfo(s_breath_channel,
                            step,
                            (uint16_t)s_brightness,
                            (int16_t)(ds18b20_temp * 10.0f));
    App_Menu_Tick();

    /* USER CODE END WHILE */
}

注意:上面这段是集成思路,不是让你把所有函数名机械照抄。你当前工程里的 App_Key_Process()App_Cmd_Process() 已经在处理按键、串口命令。更自然的做法是在这两个函数里顺手补两件事:

bash 复制代码
/* KEY1 切换通道后 */
s_runtime_settings.default_channel = s_breath_channel;
s_runtime_settings.breath_step = step;
App_Settings_Save(&s_runtime_settings);
App_Log_Write(APP_LOG_KEY1_PRESS, s_breath_channel);

/* KEY2 修改 step 后 */
s_runtime_settings.default_channel = s_breath_channel;
s_runtime_settings.breath_step = step;
App_Settings_Save(&s_runtime_settings);
App_Log_Write(APP_LOG_KEY2_PRESS, step);

/* 串口 CH/STEP/R/G/B 命令执行后 */
s_runtime_settings.default_channel = s_breath_channel;
s_runtime_settings.breath_step = step;
App_Settings_Save(&s_runtime_settings);
App_Log_Write(APP_LOG_CMD_MODE, app_usart_cmd);

也就是说,原来的业务函数不推倒,只给它们增加"保存设置"和"写日志"的副作用。

四个关键设计决策:

  1. 当前工程主线不变。 App_Breath_Tick() 仍然负责呼吸灯,App_Key_Process() 仍然负责 KEY1/KEY2,App_Cmd_Process() 仍然负责串口命令。

  2. OLED 是显示层,不是控制核心。 OLED 只显示当前状态,不要让显示代码反过来控制 LED。这样 OLED 不亮时,RGB 呼吸灯和串口命令仍然可以继续工作。

  3. EEPROM 保存的是"设置",Flash 保存的是"事件"。 设置数量少、经常读、偶尔写,适合 EEPROM;日志数量多、连续追加,适合 SPI Flash。

  4. I2C/SPI 自检要放在上电阶段。 如果 OLED 不亮、EEPROM 不应答、Flash ID 不对,开机串口就应该告诉你。不要等到功能全写完才发现硬件没通。

完整代码可以参考 code/main_c_patch.md。如果你的函数名和文章不同,优先以你当前工程里的函数名为准,再把本篇新增模块接进去。


第七部分:编译、下载和验证

编译

在 Makefile 的 C_SOURCES 里添加(约在第 65 行附近):

bash 复制代码
Core/Src/app_i2c.c \
Core/Src/app_oled.c \
Core/Src/app_spi.c \
Core/Src/app_menu.c \
Core/Src/app_settings.c \
Core/Src/app_log.c \

Ctrl+Shift+B 或终端 make -j8

常见编译报错:

|

报错

|

原因

|

修复

|

| --- | --- | --- |

| undefined reference to 'font5x7' |

字体数组不完整,缺少某些字符

|

code/app_oled.c 复制完整字体数组

|

| 'FLASH_CS_GPIO_Port' undeclared |

CubeMX 里 PA4 没设 User Label FLASH_CS

|

回 CubeMX 加标签重新生成

|

| undefined reference to 'vsnprintf' |

Makefile 的 LDFLAGS 缺少 -specs=nano.specs -lc

|

检查 Makefile 的 LDFLAGS 是否包含 -lc -lm

|

下载

bash 复制代码
make flash

验证步骤

|

步骤

|

操作

|

期望 OLED 显示

|

期望串口输出

|

期望 LED / 外设

|

| --- | --- | --- | --- | --- |

|

1

|

上电

|

仪表盘:channel / step / temp / logs

| [OK] I2C bus ready

  • JEDEC ID: 0xEF4017

|

RGB 呼吸灯开始运行

|

|

2

|

等 2 秒

|

温度区域更新

| Temp: xx.xx °C

  • RTC 时间

|

DS18B20 和 RTC 仍然正常

|

|

3

|

按 KEY1 短按

|

channel 数值变化

| current channel:...,step:... |

呼吸灯切到下一种颜色

|

|

4

|

按 KEY2 短按

|

step 数值变化

| current channel:...,step:... |

呼吸速度变快

|

|

5

|

串口发 CH0

|

channel = 0

| channel: 0 |

红色通道呼吸

|

|

6

|

串口发 STEP10

|

step = 10

| step: 10 |

呼吸变化更快

|

|

7

|

串口发 R500

|

可显示最近命令或亮度值

| R: 500 |

红灯亮度改变

|

|

8

|

串口发 HELP

|

可显示 Help/命令提示

|

打印命令列表

|

蜂鸣器短响

|

|

9

|

断电 → 重上电

|

channel/step 恢复保存值

|

上电自检信息

|

LED 按保存设置运行

|

|

10

|

连续操作几次

|

logs 数量增加

|

可增加 log show 命令查看

|

Flash 中有事件记录

|

如果第 1 步就卡住了------I2C 没就绪或者 JEDEC ID 不对------先检查接线,再检查 CubeMX 配置。 OLED 不亮的最常见原因:SCL/SDA 接反、GND 没通、模块本身没有上拉电阻。


第八部分:常见问题排查

I2C 相关

OLED 不亮------全黑

|

检查项

|

方法

|

| --- | --- |

|

SCL/SDA 是否接反

|

万用表蜂鸣档:PB6 ↔ OLED SCL, PB7 ↔ OLED SDA

|

|

地址对不对

|

SSD1306 地址通常是 0x3C。有些模块是 0x3D(SA0 引脚接 VCC)。用 I2C 扫描器或换 0x3D 试试

|

|

上拉电阻有没有

|

万用表测 SCL/SDA 对 VCC 是否有 4.7kΩ。没有的话外接两个 4.7kΩ 到 3.3V

|

|

I2C 时钟是否太快

|

CubeMX 里确认 I2C Speed Mode = Standard (100kHz)

|

[WARN] I2C bus not ready

EEPROM 没应答。检查 OLED 模块上有没有 AT24C02 芯片------有些模块没有集成 EEPROM。如果没有,App_I2C_Ready() 会失败(找不到 0x50)。可以把这行改成只做 Alert 不阻塞后续逻辑:

bash 复制代码
if (!App_I2C_Ready()) {
    printf("[WARN] EEPROM not found, settings disabled\r\n");
    /* 不阻塞------继续运行,用默认设置 */
}

I2C 偶尔读取失败、复位后正常

可能是杜邦线接触不良、面包板跳线松动、或上拉电阻太大(>10kΩ)。SCL/SDA 尽量用短线(<20cm),上拉电阻用 4.7kΩ,不要用 10kΩ。

SPI 相关

JEDEC ID 读出来是 0xFFFFFF 或 0x000000

|

检查项

|

方法

|

| --- | --- |

|

CS 引脚初始化了吗

|

确认 cs_high()App_SPI_Init() 里被调用

|

|

MISO/MOSI 是否接反

|

MOSI (PA7) → Flash DI, MISO (PA6) ← Flash DO

|

|

SCK 频率是否太高

|

先用 9MHz 分频(Prescaler=8)。面包板长线降到 4.5MHz(Prescaler=16)

|

|

模式是否匹配

|

CubeMX CPOL=Low, CPHA=1 Edge = Mode 0

|

Flash 读出来全是 0xFF

说明读操作本身没问题(Flash 擦除后的默认值就是 0xFF),但数据没写进去。检查 flash_write_enable() 是否在 Page Program 之前被调用了------**W25Q64 每次写入前必须发 Write Enable (0x06)**,这个很容易忘。

App_Log_Write 写入后读出来 checksum 不对

写入时数据跨了页边界。App_Flash_WritePage 要求写在一页(256 字节)之内,不能跨页。8 字节的日志条目不会跨页(256/8=32 条才跨一次),但如果你改了 APP_LOG_ENTRY_SIZE 或者从非页对齐的地址开始写,就可能跨页。

OLED 显示相关

OLED 显示正常但某些字符是方块

字体数组里该字符的数据缺失。检查 font5x7 数组是否包含完整的 96 个字符(索引 0~95)。从 code/app_oled.c 复制完整版本。

OLED 显示闪烁

App_OLED_Refresh() 被调用太频繁或太少。本篇设计是主循环每圈调用 App_Menu_Tick(),但函数内部用 HAL_GetTick() 限制为约 100ms 刷新一次。这样 OLED 不会疯狂占用 I2C,总线和主循环都更稳。如果看起来明显卡顿,先检查主循环里有没有大量 HAL_Delay() 或阻塞式打印。


第九部分:本篇小结

|

收获

|

具体来说

|

| --- | --- |

| I2C 协议 |

两线(SCL+SDA)、开漏+上拉、起始/停止条件、7 位地址、ACK/NACK、100kHz

|

| I2C 读写 | HAL_I2C_Mem_Write

/ HAL_I2C_Mem_Read 封装了伪写+RESTART 的复杂流程

|

| I2C 多设备 |

OLED(0x3C) 和 EEPROM(0x50) 共享同一组 SCL/SDA,不同地址互不干扰

|

| SPI 协议 |

四线(SCK+MOSI+MISO+CS)、全双工、Mode 0、CPOL/CPHA、MHz 级速度

|

| SPI Flash |

Write Enable + Page Program + 等待 BUSY + 扇区擦除------Flash 的标准操作流

|

| SSD1306 OLED |

初始化序列、页寻址、5×7 字体渲染、vram 本地缓冲区

|

| AT24C02 EEPROM |

8 字节页边界、5ms 写周期、checksum 校验------设置持久化的标准方案

|

| W25Q64 Flash |

JEDEC ID 自检、环形日志、扇区擦除------事件记录的标准方案

|

| 菜单状态机 |

页面 = 状态,按键事件 = 状态转移触发,渲染和逻辑分离

|

| 多输入源融合 |

按键 + 串口命令 + 菜单------三路输入操作同一份状态,App_Menu_IsActive() 做模式仲裁

|

这篇是系列里最长的一篇,但也是最重要的分水岭。 过了这篇,你就掌握了 STM32 和外部芯片沟通的两种核心协议。后面无论做什么------温湿度传感器(I2C)、SPI 彩屏、SD 卡(SPI)、加速度计(I2C/SPI)------协议层的基础都已经打好了。


第十部分:练习

  1. I2C 地址扫描器: 写一个函数 I2C_Scan(),对 0x04~0x77 所有地址发 HAL_I2C_IsDeviceReady,打印总线上有哪些设备。结果应该是:[SCAN] 0x3C 0x50(OLED + EEPROM)。这是嵌入式开发最常用的调试工具之一。

  2. 添加新菜单选项:LED Speed ------在 Settings 里加一个"LED Speed"选项(快闪间隔 50ms/100ms/200ms),存入 EEPROM。你需要:在 App_Settings 里加字段、在菜单里加一个页面、在 LED 行为里应用这个值、保存到 EEPROM。这个练习让你透彻理解"添加一个设置"的完整链路。

  3. 日志查看器: 在串口命令里加 log show N------从 Flash 读取最近 N 条日志,按时间戳升序打印。提示:App_Log_Count() 拿到总数,App_Log_Read(index, &entry) 按索引读取。打印格式:[12345ms] KEY1_PRESS mode=1

  4. 用 I2C 协议分析仪思维复盘:HAL_I2C_Mem_Write 的一个完整调用展开,在纸上画出 SCL/SDA 的波形------从 START 到 STOP,每一个字节和 ACK。然后用中文逐段解释"这一段时间里 I2C 总线上发生了什么"。这个练习不做代码,做理解------画完你就真正懂了 I2C。

  5. 比较 I2C 和 SPI: 写一段 200 字以内的总结,回答:什么时候选 I2C?什么时候选 SPI?为什么 W25Q64 用 SPI 而不是 I2C?为什么 SSD1306 同时有 I2C 和 SPI 版本?提示:从引脚数、速度、设备数量、协议复杂度四个维度对比。


下一篇预告

本篇你掌握了 I2C 和 SPI 两个协议。下一篇回到模拟世界------STM32 内置 ADC(模数转换器),用旋钮(电位器)采集电压值,结合 PWM 实现平滑调光。和按键的数字量(0/1)不同,ADC 读到的是连续变化的模拟量------这是嵌入式感知物理世界的第一步。