STM32 可移植教程 05:在 RGB 控制台工程上加入 I2C 和 SPI(实战篇)
上一篇我们已经把一个小工程做出了"控制台"的味道:
-
TIM3 的 CH2/CH3/CH4 输出三路 PWM,控制 RGB LED;
-
KEY1、KEY2 经过状态机消抖,用来切换当前呼吸灯通道和呼吸步进;
-
USART1 使用 DMA + IDLE 接收串口命令,可以输入
CH0、STEP10、R500这类命令; -
蜂鸣器在命令执行后短响一下,给用户一个反馈;
-
DS18B20 周期读取温度,RTC 打印当前时间。
这个工程已经不再是"点个灯"的练习,而是一个有输入、有输出、有状态、有串口交互的小控制台。
这一篇就在这个工程上继续往前走:加入 STM32 和外部芯片沟通最常用的两个协议------I2C 和 SPI。
有了 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 从机 结束
地址 应答 地址 应答 应答 应答 应答
(写)
解释每一步:
-
START:主设备说"我要开始了"
-
0xA0:器件地址 0x50 << 1 | 0(最低位 0 = 写)
-
ACK:地址 0x50 的设备应答"我在"
-
0x00:EEPROM 内的存储地址------"我要写到第 0 个字节"
-
ACK:从设备说"收到"
-
0x55:第一个数据字节
-
ACK:从设备说"写进去了"
-
...(重复)
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 视图 → Connectivity → I2C1,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 视图 → Connectivity → SPI1,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();
}
菜单状态机的设计思路:
-
每个页面是一个状态。
App_Menu_Select()是事件处理------根据当前页面和 cursor 位置决定跳转。 -
渲染和逻辑分离。
App_Menu_Tick()只负责把当前页面画到 vram,不修改任何状态。 -
cursor 管理。 KEY1 短按让 cursor+1,超出选项范围后回绕到 0。和按键状态机的"事件消费"模式一致。

第六部分:main.c 集成
这一部分要特别注意:我们不是写一个全新的 main.c,而是在你当前 04_timer_pwm_rgb 的 main.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 */
初始化分三个阶段:
-
先启动当前工程已有功能。 PWM、按键、串口 DMA、DS18B20、RTC 都是当前工程已经跑通的基础,不要因为加 I2C/SPI 把它们挪乱。
-
再初始化新增总线。
MX_I2C1_Init()和MX_SPI1_Init()由 CubeMX 生成;App_I2C_Init()和App_SPI_Init()是我们自己的轻量封装。 -
最后初始化上层功能。 OLED、Settings、Log、Menu 都依赖 I2C/SPI,必须放在总线初始化后。
-
上电自检一定要打印。 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);
也就是说,原来的业务函数不推倒,只给它们增加"保存设置"和"写日志"的副作用。
四个关键设计决策:
-
当前工程主线不变。
App_Breath_Tick()仍然负责呼吸灯,App_Key_Process()仍然负责 KEY1/KEY2,App_Cmd_Process()仍然负责串口命令。 -
OLED 是显示层,不是控制核心。 OLED 只显示当前状态,不要让显示代码反过来控制 LED。这样 OLED 不亮时,RGB 呼吸灯和串口命令仍然可以继续工作。
-
EEPROM 保存的是"设置",Flash 保存的是"事件"。 设置数量少、经常读、偶尔写,适合 EEPROM;日志数量多、连续追加,适合 SPI Flash。
-
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)------协议层的基础都已经打好了。
第十部分:练习
-
I2C 地址扫描器: 写一个函数
I2C_Scan(),对 0x04~0x77 所有地址发HAL_I2C_IsDeviceReady,打印总线上有哪些设备。结果应该是:[SCAN] 0x3C 0x50(OLED + EEPROM)。这是嵌入式开发最常用的调试工具之一。 -
添加新菜单选项:LED Speed ------在 Settings 里加一个"LED Speed"选项(快闪间隔 50ms/100ms/200ms),存入 EEPROM。你需要:在
App_Settings里加字段、在菜单里加一个页面、在 LED 行为里应用这个值、保存到 EEPROM。这个练习让你透彻理解"添加一个设置"的完整链路。 -
日志查看器: 在串口命令里加
log show N------从 Flash 读取最近 N 条日志,按时间戳升序打印。提示:App_Log_Count()拿到总数,App_Log_Read(index, &entry)按索引读取。打印格式:[12345ms] KEY1_PRESS mode=1。 -
用 I2C 协议分析仪思维复盘: 把
HAL_I2C_Mem_Write的一个完整调用展开,在纸上画出 SCL/SDA 的波形------从 START 到 STOP,每一个字节和 ACK。然后用中文逐段解释"这一段时间里 I2C 总线上发生了什么"。这个练习不做代码,做理解------画完你就真正懂了 I2C。 -
比较 I2C 和 SPI: 写一段 200 字以内的总结,回答:什么时候选 I2C?什么时候选 SPI?为什么 W25Q64 用 SPI 而不是 I2C?为什么 SSD1306 同时有 I2C 和 SPI 版本?提示:从引脚数、速度、设备数量、协议复杂度四个维度对比。
下一篇预告
本篇你掌握了 I2C 和 SPI 两个协议。下一篇回到模拟世界------STM32 内置 ADC(模数转换器),用旋钮(电位器)采集电压值,结合 PWM 实现平滑调光。和按键的数字量(0/1)不同,ADC 读到的是连续变化的模拟量------这是嵌入式感知物理世界的第一步。