简介这份学习笔记围绕单片机与LED、按键Key、LCD三大外设的交互展开面向正在学习STM32、需要梳理底层驱动逻辑的嵌入式初学者与进阶者。内容从IO端口四种输出模式推挽、开漏、复用推挽、复用开漏与上拉电阻的钳位、驱动增强作用讲起延伸到LED状态数组与ODR、BSRR、BRR寄存器的位操作控制再到按键扫描、去抖动与状态机标志位设计最后覆盖LCD初始化、字符图形显示与屏幕滚动等驱动方法并配有可直接参考的代码片段。资源包为1个doc文档约2.97MB以图文笔记形式组织便于按模块查阅与复习。目前已有62人学习适合希望把零散知识点串成完整知识体系、并在实际项目中快速落地LED、按键与LCD控制的读者。1. 从一份“米醋”笔记说起LED、Key、LCD 三件套到底该怎么落地很多人第一次用 CubeMX 生成 STM32 工程编译一次过烧进去灯不亮、按键没反应、LCD 白屏然后开始怀疑人生。这份叫“米醋”的学习笔记恰好把 LED、Key、LCD 三个最基础也最容易翻车的模块串成了一条线。它不是什么大框架而是一份从 GPIO 输出模式、上拉电阻、开漏推挽到 LED 状态数组、按键状态机、LCD 驱动接口的实操记录。适合刚上手 HAL 库、被 ODR/BSRR/BRR 绕晕、按键一按就抖、LCD 时序调不通的嵌入式新手也适合想回头把底层寄存器逻辑捋顺的老手。笔记里那句“保存不是编译”看着像废话但真有人在这上面丢过一上午。2. GPIO 输出模式与上拉电阻先搞懂推挽、开漏和复用2.1 四种输出模式的本质区别STM32 的 GPIO 输出模式在 CubeMX 里就四个选项推挽输出、开漏输出、复用推挽、复用开漏。名字看着像绕口令其实核心就一句话——输出级那两个 MOS 管怎么配合。推挽输出上面 P-MOS 和下面 N-MOS 交替导通。输出高电平时 P-MOS 导通、N-MOS 截止引脚被拉到 VDD输出低电平时反过来。驱动能力强高低电平都硬适合直接驱动 LED、继电器这类负载。开漏输出上面的 P-MOS 永远断开只剩下面的 N-MOS 在工作。N-MOS 导通时引脚被拉到 GND输出低电平N-MOS 截止时引脚悬空相当于接了一个无穷大电阻电平不确定。想让开漏输出高电平必须在外部接一个上拉电阻把引脚拉到目标电源电压。复用推挽和复用开漏区别只在于引脚控制权交给片上外设I2C、SPI、USART、PWM而不是 GPIO 数据寄存器。I2C 必须用复用开漏因为 I2C 总线要求线与逻辑PWM 和串口通信用复用推挽因为需要强驱动和确定电平。注意开漏输出如果不接上拉电阻读引脚电平就是玄学万用表量出来可能是 1.8V、2.1V 这种中间值程序里读到的高低电平完全随机。2.2 上拉电阻到底在干什么笔记里有一句很关键的话“上拉电阻能将不确定的信号钳位在高电平并联降低阻抗使输出端获得更大的电压。”这句话拆开看涉及两个层面。第一个层面是电平确定。开漏输出 N-MOS 截止时引脚和外部上拉电阻、内部等效阻抗形成分压。内部阻抗接近无穷大上拉电阻阻值有限常见 4.7k、10k所以引脚电压被拉近上拉电源。如果上拉电源是 5VSTM32 的 3.3V 引脚就能输出接近 5V 的高电平实现电平转换。第二个层面是驱动能力。推挽输出时如果外部再并一个上拉电阻相当于内部 P-MOS 的导通电阻和外部上拉电阻并联总阻抗降低分压后输出高电平更接近 VDD带负载时压降更小。这就是笔记里说的“让该引脚输出高电平加一个上拉电阻变成 4.2V提高驱动能力”。实际选上拉电阻有个经验公式阻值不能太小否则低电平时灌电流太大超过引脚 20mA 上限也不能太大否则上升沿太慢高速通信比如 I2C 400kHz波形会变成圆弧。常见做法是 I2C 用 4.7k普通按键用 10kLED 限流用 1k 左右。2.3 线与逻辑与复用场景开漏输出还有一个隐藏技能线与。多个开漏引脚接在同一条线上只要有一个 N-MOS 导通整条线就被拉低。只有全部截止线才被上拉电阻拉高。I2C 的 SDA 和 SCL 就是典型线与场景所以必须用复用开漏。笔记里提到“复用的通讯频率很高每秒电平变换上万次这些模块直接控制它的引脚输出”。这意味着一旦把引脚配成复用模式GPIO 的 ODR 寄存器就管不了它了控制权在 I2C、SPI 这些外设手里。配错模式最直接的后果就是通信失败示波器上看波形要么没有要么畸变。2.4 CubeMX 配置与代码生成的关键步骤用 CubeMX 配 GPIO 时有几个参数必须一次配对不然后面改起来很麻烦。第一步在 Pinout 视图里点引脚选 GPIO_Output 或 GPIO_Input。LED 一般配 Output按键配 Input。第二步在 GPIO 选项卡里设具体参数参数LED 典型值按键典型值I2C 典型值GPIO output levelLow——GPIO modeOutput Push PullInput modeAlternate Function Open DrainGPIO Pull-up/Pull-downNo pullPull-upPull-upMaximum output speedLow/Medium—Very HighUser LabelLEDKEYI2C1_SDA第三步生成代码前先保存 .ioc 文件。笔记里那句“CubeMx 生成之前keil 要先保存保存不是编译”说的就是这件事——CubeMX 重新生成代码会覆盖用户代码如果 Keil 里没保存之前写的逻辑就丢了。我一般会在生成前手动备份 Core/Src 和 Core/Inc 两个目录。第四步生成代码后检查 gpio.c 里的初始化函数确认模式、上下拉、速度都符合预期。特别是开漏输出CubeMX 不会自动帮你加上拉电阻硬件上必须焊。3. LED 驱动与寄存器操作ODR、BSRR、BRR 到底怎么选3.1 LED 状态数组的设计思路笔记里定义了一个uint8_t ucLed[8]数组每个元素代表一个 LED 的状态0 灭 1 亮。这种设计的好处是逻辑层和硬件层解耦上层应用只改数组底层显示函数负责把数组映射到 GPIO 寄存器。led_disp函数的核心逻辑是先把 8 个 LED 状态拼成一个字节再取反写到 GPIOC 高字节。为什么取反因为硬件上 LED 通常是低电平点亮共阳极接法所以逻辑 1 对应引脚输出 0。void led_disp(uint8_t *ucLed) { uint8_t temp 0x00; static uint8_t temp_old 0xff; // 记录上一次状态避免重复刷新 for (int i 0; i 8; i) temp | (ucLed[i] (7 - i)); // 把数组拼成字节注意位序 if (temp ! temp_old) // 只有状态变化才写寄存器 { GPIOC-ODR 0x00ff; // 清高字节保留低字节 GPIOC-ODR | (~temp 8); // 取反后写到高字节 GPIOD-BSRR | 0x01 2; // PD2 置高 GPIOD-BRR | 0x01 2; // PD2 置低 temp_old temp; } }这段代码有两个坑。第一个坑是GPIOC-ODR 0x00ff这行会清掉 GPIOC 高 8 位但如果 GPIOC 低 8 位也在控制其他东西保留是对的如果低 8 位没用到直接写GPIOC-ODR (~temp 8)更干净。第二个坑是GPIOD-BSRR和GPIOD-BRR连续操作同一位实际效果是 PD2 先高后低如果后面没有延时示波器上就是一个极窄脉冲可能看不到。3.2 ODR、BSRR、BRR 的适用场景这三个寄存器都能控制引脚但用法和副作用完全不同。ODR 是输出数据寄存器读写都行。写 ODR 会同时影响 16 个引脚所以必须用和|来只改目标位。缺点是读-改-写不是原子操作如果中断里也在改同一个 ODR可能丢状态。BSRR 是位设置/复位寄存器写 1 有效写 0 无影响。低 16 位对应置高高 16 位对应置低。优点是原子操作不会影响其他引脚。缺点是只能写不能读。BRR 是位复位寄存器专门用来置低低 16 位有效。功能上等于 BSRR 的高 16 位但单独存在是为了代码可读性。我一般这样选初始化时用 ODR 整体设一次运行中单个引脚翻转用 BSRR需要同时置高和置低多个引脚时用 BSRR 一次写完避免中间状态。3.3 逻辑层与硬件层的分层笔记里的分层很清晰led_proc是逻辑层只调用led_displed_disp是硬件层直接操作寄存器。Led_app.c和Led_app.h提供对外接口bsp_system.h做统一包含。这种分层的好处是换一块板子只需要改led_disp里的寄存器操作上层逻辑一行不动。坏处是如果ucLed数组被多个任务同时修改需要加临界区保护。裸机环境下一般用__disable_irq()和__enable_irq()包一下。4. 按键状态机与去抖动从四行代码到结构体封装4.1 最简按键读取与边沿检测笔记里最开始给的key_read和key_proc是最简版本uint8_t key_read(void) { uint8_t temp 0; if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) GPIO_PIN_RESET) temp 1; if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) GPIO_PIN_RESET) temp 2; if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2) GPIO_PIN_RESET) temp 3; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) temp 4; return temp; } void key_proc(void) { key_val key_read(); key_down key_val (key_old ^ key_val); // 下降沿 key_up ~key_val (key_old ^ key_val); // 上升沿 key_old key_val; }key_down的位运算逻辑是key_old ^ key_val找出变化的位key_val 保留当前为 1 的位也就是按下事件。key_up反过来保留当前为 0 的位也就是释放事件。这个版本能用但有两个问题。第一没有去抖动按键机械触点闭合瞬间会产生几十微秒到几毫秒的抖动key_down可能连续触发多次。第二key_read里用了HAL_GPIO_ReadPin每次调用都要进函数、读寄存器四个按键轮询一遍开销不小。4.2 结构体封装的按键状态机笔记后半部分给了一个完整的button结构体和key_task状态机这是更工程化的做法。typedef struct { GPIO_TypeDef *gpiox; uint16_t pin; uint16_t ticks; // 计时变量用于长按和双击判断 uint8_t level; // 当前电平去抖动后的稳定值 uint8_t id; // 按键编号 uint8_t state; // 状态机0 等待按下1 按下2 抬起 uint8_t debouce_cnt; // 去抖动计数器 uint8_t repeat; // 重复按下次数用于双击三击 } button; button btns[4];key_task函数分两段。第一段是去抖动如果当前读到的电平和btn-level不同debouce_cnt累加连续 3 次不同才确认电平变化。第二段是状态机state 0等待按下。检测到level 0时重置ticksrepeat 1进入 state 1。state 1按键按下。如果level ! 0松开判断ticks是否大于等于 30300ms。大于等于 30 是长按执行长按逻辑后回 state 0小于 30 是短按进入 state 2。如果一直按着且ticks 30把repeat清零防止松开时误触发单击。state 2按键抬起。如果ticks 15150ms内没有再次按下根据repeat的值判断单击、双击、三击然后回 state 0。如果 150ms 内再次按下repeatticks清零回 state 1。这个状态机的关键参数是 30 和 15。30 个 tick 对应 300ms是长按阈值15 个 tick 对应 150ms是双击间隔窗口。key_state函数每 10ms 调用一次所以 tick 单位是 10ms。4.3 去抖动参数怎么调去抖动计数器阈值 3 对应 30ms这是经验值。按键手册上一般写抖动时间 5ms 到 20ms取 30ms 留了余量。如果按键质量差可以加到 550ms如果要求响应快可以降到 220ms。双击间隔 150ms 也是经验值。太快了用户来不及按第二下太慢了单击响应延迟明显。我一般用 200ms 到 300ms看具体场景。如果是游戏手柄150ms 合适如果是设置菜单300ms 更稳。注意key_task里btn-ticks的位置很关键。笔记里写的是“原本没放这”说明作者踩过坑。如果ticks放在 switch 外面且不加state 0判断state 0 时也会累加导致第一次按下时ticks不是从 0 开始。5. 避坑与排查LED、Key、LCD 调试中最容易翻车的五件事5.1 LED 不亮但引脚电压正常现象用万用表量 LED 引脚输出高电平 3.3V但 LED 不亮。原因LED 极性接反或者限流电阻太大。共阳极接法时引脚输出低电平才亮共阴极接法时输出高电平才亮。如果代码里按共阳极写硬件按共阴极接逻辑就反了。解决先确认硬件接法再改led_disp里的取反逻辑。如果不确定写一个闪烁测试把引脚轮流置高置低看 LED 有没有反应。5.2 按键按一次触发多次现象串口打印显示按一次键key_down触发了三四次。原因没有去抖动或者去抖动阈值太小。机械按键在闭合和断开瞬间会产生振铃电平在高低之间跳变多次。解决加去抖动计数器连续多次读到相同电平才确认。笔记里的做法是连续 3 次对应 30ms。如果还抖加到 5 次。另外按键引脚最好配内部上拉外部再并一个 100nF 电容到地硬件去抖。5.3 开漏输出读不到高电平现象I2C 引脚配了开漏输出但读回来永远是 0。原因外部没有接上拉电阻或者上拉电阻接到了错误的电源。开漏输出 N-MOS 截止时引脚悬空没有上拉电阻就没有高电平。解决确认硬件上有 4.7k 上拉电阻接到 3.3V 或 5V。如果用的是模块检查模块上有没有自带。另外CubeMX 里要把引脚配成“Alternate Function Open Drain”而不是“Output Open Drain”否则 I2C 外设控制不了引脚。5.4 LCD 白屏或花屏现象LCD 上电后白屏或者显示乱码。原因初始化时序不对或者数据线接错。LCD 控制器对初始化命令的顺序和延时很敏感少一条命令或者延时不够都会导致白屏。解决对照 LCD 手册逐条检查初始化命令和延时。数据线用万用表通断档逐一确认特别是 8 位并口模式D0 到 D7 接反一位就会花屏。如果用的是 SPI 接口检查时钟极性和相位。5.5 CubeMX 重新生成代码后用户代码丢失现象在 Keil 里写好的逻辑CubeMX 重新生成后不见了。原因CubeMX 只保留/* USER CODE BEGIN */和/* USER CODE END */之间的代码写在别处的代码会被覆盖。解决所有用户代码都写在 USER CODE 区块内。生成前先保存 Keil 工程生成后再编译。我一般会在生成前把 Core/Src 和 Core/Inc 复制一份到备份目录万一丢了还能找回来。6. 从状态机到多级菜单一个按键驱动 LCD 的进阶技巧按键状态机跑通之后下一步自然是把按键和 LCD 结合起来做一个多级菜单。这里分享一个我常用的技巧用状态机的repeat字段区分短按、长按、双击分别映射到“下一页”“进入”“返回”三个操作。具体做法是在key_task的 state 2 里根据repeat的值调用不同的回调函数// 在 key_task 的 state 2 分支里 if (btn-ticks 15) { btn-state 0; if (btn-repeat 1) menu_next(); // 单击下一页 else if (btn-repeat 2) menu_enter(); // 双击进入 else if (btn-repeat 3) menu_back(); // 三击返回 }长按逻辑放在 state 1 里ticks 30时触发menu_long_press()可以用来快速滚动或者保存参数。LCD 这边我一般会做一个lcd_draw_menu函数根据当前菜单索引和选中项重绘整个屏幕。为了避免闪烁只重绘变化的部分或者用双缓冲。如果 LCD 控制器支持局部刷新优先用局部刷新。验证方法很简单写一个测试菜单三个选项用单击切换选项、双击进入、长按返回。如果操作跟手、没有误触发说明状态机参数调对了。如果单击偶尔触发双击把双击间隔从 150ms 加到 250ms如果长按要按很久才触发把长按阈值从 300ms 降到 200ms。从那以后我每次调按键都会先写一个串口打印版本把key_val、key_down、key_up、repeat、ticks全部打出来确认状态跳转符合预期再接到 LCD 上。这个习惯帮我省了很多来回烧录的时间。希望帮到你。本文还有配套的精品资源点击获取