经典三问
Q: 输入到反馈链路是什么?
A: 它是"按键电平被读取、变量被修改、LED 和 OLED 显示结果"的完整路径。
Q: 为什么需要观察完整链路?
A: 只看最终灯光无法判断故障发生在输入、业务逻辑还是输出显示,分段反馈能缩小排查范围。
Q: 输入到反馈链路常见使用场景?
A: 调试菜单、传感器状态、运动模式切换和任何需要现场确认程序状态的功能。
按下 A,屏幕上的 VALUE 从 0 变成 1,最右边那颗 LED 亮起。再按一次,数字到 2,亮的换成中间那颗。连按到 5,屏幕写着 101,两端的 LED 亮着。
这时按键有没有被读到,程序里的变量变没变,变量有没有送到 GPIO,显示还能不能跟上,它们全摆在桌面上。
这干的事虽小:A 键让数值加一,B 键让数值减一;0 到 7 循环;三颗外接 LED 把这个数写成三位二进制;OLED 把按键电平、事件次数和当前状态同时摊开,但是后面电机、编码器、循迹模块和 PID 都会沿用这条路,只是变量不再这么简单。
先把桌面接成同一套
这篇沿用前面 OLED 的软件 I2C 接线:PB8 接 SCL,PB9 接 SDA。新增的按键和 LED 固定为下面这一套,之后菜单、按键事件和非阻塞实验也继续使用它。
| STM32 引脚 | 接到哪里 | 这根线在做什么 |
|---|---|---|
| PB11 | 按键 A,按键另一端接 GND | 读取"加一"操作 |
| PB1 | 按键 B,按键另一端接 GND | 读取"减一"操作 |
| PC13 | 外接 LED 0 正极 | 显示二进制 bit 0 |
| PC14 | 外接 LED 1 正极 | 显示二进制 bit 1 |
| PC15 | 外接 LED 2 正极 | 显示二进制 bit 2 |
| 三颗 LED 负极 | GND | 给 LED 一个共同参考 |
| PB8 / PB9 | OLED SCL / SDA | OLED 的软件 I2C |
按键初始化为内部上拉输入。松开时,PB11 和 PB1 被芯片内部拉成高电平;按下后接到 GND,读到低电平。Key_A_IsPressed() 已经把这层反转包起来了,所以业务代码看见的 1 就是"正在按",不需要每次再记住低电平才算按下。
三颗 LED 一开始按"高电平亮"连接。这个约定收在 led.c 的一行,其他代码不必到处跟着改:
c
#define LED_ACTIVE_LEVEL 1U
改成 0U 后,同一个二进制值仍会被送到 bit 0、bit 1、bit 2,只是 GPIO 的物理高低电平翻转,亮灭关系也会一起翻转。这个小开关很重要:应用层只关心"bit 1 要显示出来",接线的有效电平由驱动层消化。
三颗灯怎么写一个数
一颗 LED 只能亮或灭,刚好装下一位二进制。三颗灯就有三位:
| 十进制值 | OLED 上的 BITS | PC15 | PC14 | PC13 |
|---|---|---|---|---|
| 0 | 000 |
灭 | 灭 | 灭 |
| 1 | 001 |
灭 | 灭 | 亮 |
| 2 | 010 |
灭 | 亮 | 灭 |
| 3 | 011 |
灭 | 亮 | 亮 |
| 4 | 100 |
亮 | 灭 | 灭 |
| 5 | 101 |
亮 | 灭 | 亮 |
| 6 | 110 |
亮 | 亮 | 灭 |
| 7 | 111 |
亮 | 亮 | 亮 |
表格从左向右写的是高位到低位,所以 PC13 在最右边,它装的是 bit 0。代码也按这个方向拆数:
c
void LED_ShowBinary(uint8_t value)
{
LED_Write(GPIO_Pin_13, (uint8_t)((value >> 0) & 0x01U));
LED_Write(GPIO_Pin_14, (uint8_t)((value >> 1) & 0x01U));
LED_Write(GPIO_Pin_15, (uint8_t)((value >> 2) & 0x01U));
}
>> 1 的意思是把原数右移一格,让原来的 bit 1 走到最低位;& 0x01U 再把其余位抹掉。LED_Write() 收到的于是只会是 0 或 1。这里不需要直接去摸 GPIO 寄存器,标准库已经把"把某根引脚置高、置低"的细节装进 GPIO_SetBits() 和 GPIO_ResetBits()。
LED_Write() 本身只干一件事:按 LED_ACTIVE_LEVEL 决定这根引脚该置高还是置低。
c
void LED_Write(uint16_t pin, uint8_t level)
{
if (level == LED_ACTIVE_LEVEL)
{
GPIO_SetBits(GPIOC, pin);
}
else
{
GPIO_ResetBits(GPIOC, pin);
}
}
一次按下走过了哪些地方
先看整条链:
text
手指按下 A
-> PB11 从高变低
-> Key_A_DetectPressEdge() 给出一次事件
-> value 加一,A 的事件次数加一
-> LED_ShowBinary(value) 改三根 GPIO
-> OLED 显示 value、BITS、按键原始状态和次数
它没有神秘的跳跃。按键驱动负责读输入;main.c 决定这一次按键是什么意思;LED 和 OLED 驱动把结果送到外设。以后把 value 换成电机目标速度、循迹状态或 PID 参数,骨架还是这副骨架。
OLED 上的 A RAW、B RAW 是当前是否正按着,按住显示 1,松开回到 0。A EVT、B EVT 是从上电开始各自收到了多少次按下沿。把这两组数字分开摆,按键有问题时就能先分清楚:是输入电平没变,还是一次动作被当成了多次事件。
完整应用代码
key.c 和 led.c 已经分别封装好了输入与输出,这里只需要把应用层写清楚:
c
#include "stm32f10x.h"
#include "led.h"
#include "key.h"
#include "OLED.h"
static void OLED_ShowBits(uint8_t value)
{
OLED_ShowNum(36, 8, (uint32_t)((value >> 2) & 0x01U), 1, OLED_6X8);
OLED_ShowNum(42, 8, (uint32_t)((value >> 1) & 0x01U), 1, OLED_6X8);
OLED_ShowNum(48, 8, (uint32_t)(value & 0x01U), 1, OLED_6X8);
}
static void OLED_ShowPanel(uint8_t value, uint32_t key_a_count, uint32_t key_b_count)
{
OLED_ShowString(0, 0, "VALUE:", OLED_6X8);
OLED_ShowString(0, 8, "BITS:", OLED_6X8);
OLED_ShowString(0, 16, "A RAW:", OLED_6X8);
OLED_ShowString(0, 24, "B RAW:", OLED_6X8);
OLED_ShowString(64, 16, "A EVT:", OLED_6X8);
OLED_ShowString(64, 24, "B EVT:", OLED_6X8);
OLED_ShowNum(42, 0, value, 1, OLED_6X8);
OLED_ShowBits(value);
OLED_ShowNum(42, 16, Key_A_IsPressed(), 1, OLED_6X8);
OLED_ShowNum(42, 24, Key_B_IsPressed(), 1, OLED_6X8);
OLED_ShowNum(106, 16, key_a_count, 3, OLED_6X8);
OLED_ShowNum(106, 24, key_b_count, 3, OLED_6X8);
}
int main(void)
{
uint8_t value = 0;
uint8_t last_key_a_raw = 0xFFU;
uint8_t last_key_b_raw = 0xFFU;
uint32_t key_a_count = 0;
uint32_t key_b_count = 0;
LED_Init();
Key_Init();
OLED_Init();
OLED_Clear();
LED_ShowBinary(value);
OLED_ShowPanel(value, key_a_count, key_b_count);
OLED_Update();
while (1)
{
if (Key_A_DetectPressEdge())
{
value = (uint8_t)((value + 1U) & 0x07U);
key_a_count++;
LED_ShowBinary(value);
OLED_ShowNum(42, 0, value, 1, OLED_6X8);
OLED_ShowBits(value);
OLED_ShowNum(106, 16, key_a_count, 3, OLED_6X8);
OLED_Update();
}
if (Key_B_DetectPressEdge())
{
value = (uint8_t)((value + 7U) & 0x07U);
key_b_count++;
LED_ShowBinary(value);
OLED_ShowNum(42, 0, value, 1, OLED_6X8);
OLED_ShowBits(value);
OLED_ShowNum(106, 24, key_b_count, 3, OLED_6X8);
OLED_Update();
}
if (Key_A_IsPressed() != last_key_a_raw)
{
last_key_a_raw = Key_A_IsPressed();
OLED_ShowNum(42, 16, last_key_a_raw, 1, OLED_6X8);
OLED_Update();
}
if (Key_B_IsPressed() != last_key_b_raw)
{
last_key_b_raw = Key_B_IsPressed();
OLED_ShowNum(42, 24, last_key_b_raw, 1, OLED_6X8);
OLED_Update();
}
}
}
& 0x07U 只保留最低三位。A 键在 7 上再加 1,得到 8,低三位又是 000,所以回到 0。B 键不能直接写 value--,因为 uint8_t 减到 0 以下会绕到 255;先加 7,再保留三位,0 + 7 正好变成 7,5 + 7 变成 12,低三位是 4。
last_key_a_raw 和 last_key_b_raw 只是 OLED 的局部刷新记忆。它们起初故意写成 0xFFU,这和真实的 0、1 都不同,程序第一次进入循环就会把两个原始状态画出来。之后电平没有变化,屏幕不会被同一个数字反复覆盖。
别把这段"只在变化时刷新"的写法误会成按键消抖。它只节省了 OLED 的重复工作。机械按键抖动时,A EVT 仍可能一次跳好几格;等系统有了稳定的时间基准,才有条件把"按下多久""隔多久再确认"这类事认真处理。

先按这几个场景跑一遍
先连续按 A 五次。屏幕应到 VALUE: 5 和 BITS: 101,PC15、PC13 亮,PC14 灭。接着按 B 一次,状态退到 4,BITS 变 100。在 0 时按 B,观察它跳到 7;在 7 时按 A,观察它回到 0。



两个判断保留为两个独立的 if。如果同一轮扫描里 A、B 都产生了按下沿,两个动作都会执行。
故意让 OLED 停在昨天
把 A 键分支里的三行显示更新暂时注释掉,保留 LED_ShowBinary(value):
c
LED_ShowBinary(value);
/* OLED_ShowNum(42, 0, value, 1, OLED_6X8); */
/* OLED_ShowBits(value); */
/* OLED_ShowNum(106, 16, key_a_count, 3, OLED_6X8); */
烧录后按 A。三颗 LED 仍在按 0 到 7 变化,OLED 的 VALUE、BITS、A EVT 却停在第一次的数。此时 value 已经变了,GPIO 也已经跟着输出;漏掉的是把新状态画回屏幕的那几行。
把三行恢复,再烧录一次,面板重新跟上。这个故障很值得亲手做,因为后面会有更多"控制已经更新,显示还没更新"的场景。知道它们是不同工作,排查时就不会在 OLED 驱动里找一下午电机问题。(我之前就经常遇到有人测试陀螺仪模块的时候,要显示出角度,也就是浮点数,但是oled函数当时不支持浮点数,只支持整数,导致角度一直是显示0,以为是陀螺仪链路的问题,排查了半天......)