简介:面向嵌入式开发者与电子类毕设学生,STM32智能门锁硬件端工程实现了密码与指纹解锁、虚位密码、超时锁定以及通过Wi-Fi/MQTT接入阿里云平台的完整逻辑。资源包共135个文件,以75个.h头文件和46个.c源文件为核心,覆盖HAL库外设驱动、FatFs文件系统、中文字库、SPI/UART/SD卡等模块,另有5个json配置文件、少量txt说明、png电路或界面截图及md文档,压缩包整体约4.49MB。当前已有325人浏览学习。工程将密码与指纹特征加密存储于外部Flash中,并支持门锁状态上报、开门通知、钥匙修改通知和门未锁好提醒等事件推送,便于理解嵌入式端如何与阿里云物联网平台完成双向交互。需要留意的是,压缩包仅包含门锁硬件端程序,安卓应用与云端配置不在其内;对正在准备毕业设计或开发实际门锁产品的开发者,可直接复用其屏幕交互、错误提示与自动锁屏机制,有效缩短固件开发与联调周期。
1. 把“智能门禁”拆成程序,才看得见 stm32 真正要做的事
门禁硬件的单价,现在已经被各种模块厂商压得很低,刷卡头、矩阵键盘、继电器输出焊在一起不过十几根飞线。真正让“基于 stm32 的智能门禁系统程序”从实验室里的“能开门”变成现场挂机半年不出错的,从来不是硬件选型,而是程序里对输入可靠性、掉电行为和异常恢复的处理。矩阵键盘按一次进来两个数字、RC522 偶尔读卡超时、继电器吸合的一瞬间单片机复位,这三类问题消耗的调试时间,比开门逻辑本身多得多。
这套程序的本质,是用状态机把输入验证、锁控输出、异常恢复串成一个闭环,用统一的定时节拍驱动扫描和去抖,再用片内 Flash 把密码、白名单和开锁日志留住。读卡、按键、门磁这些输入可以被任意顺序打断,但程序行为必须始终聚焦在“空闲、校验、开锁、报警”这几个状态上。做基于 stm32 的毕业设计,或者在一个 stm32 项目里新开一个门禁分支,都值得先把这套骨架搭正确,再往上面加功能。
下面按我平时落地的顺序展开:先定引脚和状态机,再写双因子鉴权链,随后处理掉电保存与锁控驱动,最后用三件工具把程序验证到可以交付的状态。
2. 先定输入输出再写状态机:stm32智能门禁的系统骨架
2.1 引脚分配与最小电路:哪些脚不能省
我一般先用 CubeMX 把时钟、GPIO 和外设排好,再动飞线。控制器用 STM32F103C8T6 就足够,64KB Flash 放几百条白名单和一大段日志绰绰有余。RC522 走 SPI1,占用 PA5/PA6/PA7,片选和复位单独分 PA4、PA3,不与其它外设共享;矩阵键盘行列各四个,占 PB0 到 PB7;继电器用 PB8 输出,门磁接 PB9;UART1 的 PA9/PA10 留给上位机管理。这个分配不是唯一解,但有几个引脚一定要慎重:PA13/PA14 是 SWD 调试口,PB3/PB4 默认复用 JTAG,把它们当普通 GPIO 用不是不行,代价是下载调试变慢,严重时烧录器直接连接失败。
外部晶振按 8MHz 配两个 22pF 负载电容,有条件的用负载电容公式 CL=(C1×C2)/(C1+C2)+Cstray 估算,Cstray 按 3 到 5pF 算,保证等效负载落在晶振手册范围内。这个参数偏了,初期仪表看不出问题,跑几个月后 RTC 时间戳和定时器节拍会慢慢漂。另一个隐藏点:用 CubeMX 后期把芯片型号从 C8T6 换到 RBT6 很方便,但迁移后必须重新核对所有引脚的复用功能,不能只改型号就编译下载。
| 功能块 | 接口 | 推荐引脚 | 关键说明 |
|---|---|---|---|
| RC522 | SPI1 + CS/RST | PA5/PA6/PA7、PA4/PA3 | SPI 时钟降到 1Mbps 左右更稳 |
| 矩阵键盘 | 4 行输出 / 4 列输入 | PB0~PB3、PB4~PB7 | 行推挽、列上拉,禁止列下拉 |
| 继电器开锁 | GPIO 推挽输出 | PB8 | 经 ULN2003 或三极管驱动,线圈并联续流二极管 |
| 门磁检测 | GPIO 上拉输入 | PB9 | 门体开合检测,配合软件滤波 |
| 调试与管理口 | UART1 | PA9/PA10 | 日志输出、白名单管理、协议回放 |
2.2 用状态机代替散排逻辑,运行时序才能一屏讲清
门禁的输入种类并不多:键盘按下、刷卡、门磁动作、超时,但如果把这些事件用 if 散落在各自的回调里,验证顺序一乱,程序行为立刻不可预测。我通常把系统限制成四个状态:空闲、校验、开锁、报警,每个状态只响应自己该响应的事件,非法事件直接丢弃。状态机不仅让代码可读,更重要的是让“锁定时拒绝一切输入”这种安全策略有了明确的落地位置。
调度不建议上 RTOS,门禁任务的实时性要求只有毫秒级,超级循环加定时器节拍足够,还少一层任务切换的心智负担。主循环按 10ms 周期调用一次状态机,SysTick 用 HAL_GetTick() 提供时间基准,需要精确时序的蜂鸣器鸣叫再用 TIM 定时器单独控制。核心状态机如下:
typedef enum { ST_IDLE, ST_VERIFY, ST_UNLOCK, ST_ALARM } door_state_t; static door_state_t g_state = ST_IDLE; static uint32_t g_enter_tick = 0; static uint8_t g_fail_count = 0; void door_fsm_run(void) { switch (g_state) { case ST_IDLE: // 只有空闲态才接受新的鉴权输入 if (key_event_pending() || rfid_event_pending()) { g_state = ST_VERIFY; g_enter_tick = HAL_GetTick(); set_panel_led(LED_VERIFY, ON); } break; case ST_VERIFY: if (auth_result == AUTH_PASS) { g_fail_count = 0; g_state = ST_UNLOCK; g_enter_tick = HAL_GetTick(); set_relay(RELAY_ON); } else if (auth_result == AUTH_FAIL && ++g_fail_count >= 3) { // 连续失败 3 次,进入报警锁定 g_state = ST_ALARM; g_enter_tick = HAL_GetTick(); } else if (HAL_GetTick() - g_enter_tick > VERIFY_TIMEOUT_MS) { g_state = ST_IDLE; set_panel_led(LED_VERIFY, OFF); } break; case ST_UNLOCK: // 开锁持续时间到后自动回锁 if (HAL_GetTick() - g_enter_tick > UNLOCK_KEEP_MS) { set_relay(RELAY_OFF); g_state = ST_IDLE; } break; case ST_ALARM: // 锁定 30 秒,期间键盘和刷卡一律不收 if (HAL_GetTick() - g_enter_tick > LOCK_DURATION_MS) { g_fail_count = 0; g_state = ST_IDLE; } break; } }这段代码的关键在于状态迁移记录 g_enter_tick,所有超时判定都基于它与当前 HAL_GetTick 的差值,不需要为每个状态单独开定时器。VERIFY_TIMEOUT_MS 通常取 5000,UNLOCK_KEEP_MS 取 3000 到 5000,LOCK_DURATION_MS 取 30000,这些宏集中放在配置头文件里,现场交付时只需要调这一处。注意 door_fsm_run 只能放在主循环轮询,不能进中断;中断回调只负责置事件标志,鉴权和状态迁移全部留在主循环完成,避免 SPI 读卡和 Flash 写入在中断上下文里卡死。
3. 刷卡与密码并存的双因子鉴权:stm32门禁程序的完整鉴权链
3.1 矩阵键盘扫键与按键去抖:先解决“按一次进来两字”
矩阵键盘电路设计本身不复杂,四行四列的机械开关本质是一堆抖动源。没有软硬件去抖时,一次按键经常被采样成多个脉冲,轻则密码输入重复,重则直接触发重试锁定。硬件上可以给每个按键并联 104 电容,但更可靠的是软件处理:扫描到低电平后不立即确认,间隔 10ms 连续扫描两次,两次结果一致才上报按键。门禁键盘不追求游戏手柄的微秒级响应,20ms 的处理延迟完全无感。
扫描代码用行推挽、列上拉的标准接法:
const uint8_t row_pin[4] = { GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3 }; const uint16_t col_mask = GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; uint8_t key_scan(void) { for (uint8_t r = 0; r < 4; r++) { // 先把四行全部拉高,再把当前行拉低 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, row_pin[r], GPIO_PIN_RESET); // 读取列口,取反后获得按下位 uint16_t raw = (~HAL_GPIO_ReadPin(GPIOB, col_mask)) & col_mask; if (raw) { for (uint8_t c = 0; c < 4; c++) { if (raw & (1U << (4 + c))) { return r * 4 + c; // 返回 0~15 的键值 } } } } return 0xFF; // 无按键 }这里先置高所有行线,再把当前扫描行拉低,目的是避免上一行扫描残留的电平影响本次判断。列口配置为上拉输入,无键按下时读到全高,取反后为 0;有键闭合时对应列被拉低,取反后对应位置 1。raw 与 col_mask 做位与,排除掉行口的干扰。实际工程中不把去抖逻辑揉进 key_scan,而是在主循环里每 10ms 调用一次,连续两次返回相同键值才向状态机投递按键事件,返回值 0xFF 直接过滤。
3.2 RC522 过 SPI 读卡号:先验证版本号,再谈寻卡
RC522 和 stm32 的通信,在 HAL 库环境下我习惯拆成三步:第一步只验证总线,第二步再寻卡,第三步才是读 UID 和鉴权。网上不少例程把三步全包在一个大函数里,一旦读不到卡,根本分不清是 SPI 没通还是天线场没建立。验证方式很直接:读取 RC522 的版本寄存器,地址 0x37,读回常见值是 0x92 或 0x91。如果读回 0xFF 或 0x00,先查 CS 片选是否悬浮、SPI 极性和相位是否配置为模式 0,而不是急着改寻卡重试次数。
CubeMX 里配置 SPI1 时,分频系数往大了调,实践下来 1Mbps 左右的时钟在飞线环境下最稳,RC522 模块规格虽然支持更高速度,但杜邦线和模块插座带来的寄生电容会把波形边沿拖垮。防碰撞和选卡流程走完后,拿到 4 字节 UID,白名单匹配用 memcmp;这里有个细节:memcmp 的第三个参数必须固定传 4,不能依赖外部传入的长度字段,防止恶意构造的卡号长度绕过比较。每步流程的状态码通过 UART 打印,现场调试时串口能看到“寻卡成功、防碰撞成功、选卡成功”到哪一步断掉。
| RC522 状态 | 表示含义 | 处理建议 |
|---|---|---|
| 0x26 应答成功 | 检测到卡片进入射频场 | 继续防碰撞流程 |
| 防碰撞成功 | 已取得 4 字节 UID | 进入选卡和验证 |
| 超时错误 | 卡片未在场或天线未启 | 间隔 100ms 重新检测 |
| 位冲突错误 | 多张卡同时靠近 | 延时几毫秒后重试 |
3.3 鉴权结果处理:三次失败进报警,防止暴力枚举
鉴权链最容易犯的设计错误是“密码对就开门,密码错就提示”,缺少失败后的抑制策略。6 位数字密码空间只有一百万种,交给暴力脚本按每秒十次的节奏试,一天之内必然被跑穿,门禁程序必须内置失败计数和锁定窗口。上面的状态机里已经预留了 g_fail_count,鉴权失败一次就加一,连续达到三次直接进入 ST_ALARM 状态,这个状态下矩阵键盘和 RC522 全部停止响应,持续 30 秒。只有管理员串口指令能提前解除锁定,这条口令在程序里单独写死,不能与用户密码共用一套存储结构。
不管是键盘输入的密码还是刷卡得到的 UID,最后都收敛成同一个鉴权接口,例如 auth_check_uid(uint8_t *uid, uint8_t len) 和 auth_check_password(uint8_t *buf, uint8_t len)。两个函数内部都对长度做上限校验,密码长度不足四位或者超过八位直接返回失败,不进入比较逻辑。卡片鉴权还有一个常见坑:多张卡放到读卡器上时,RC522 会返回位冲突,程序容易误判成非法卡,正确做法是原卡保持不动、延迟重试,而不是立刻累加失败次数。
4. 掉电保存与锁控输出:stm32门禁里最容易翻车的三个细节
4.1 密码、白名单、事件日志写进片内 Flash
授权数据必须掉电保持。外部 AT24C02 是一种选择,但 F103 片内 Flash 在典型门禁项目中往往还空着一大半,直接用 Flash 能省一路 I2C,也少一个器件失效点。F103 的 Flash 是页擦除架构,64KB 型号共 64 页、每页 1KB,写入粒度是半字或字。存密码和白名单前先规划布局,我的习惯是:密码哈希单独占一页,白名单从下一页开始顺序存放,事件日志再往后放并做环形覆盖。任何整页擦除动作都要先把这页内容读进 RAM 缓冲区,构造好新数据再擦除回写,否则中途掉电整页变 0xFF。
这里给出一个带备份思想的存储函数示例:
#define PIN_PAGE_0 0x0800C000 // 密码区主备份页 #define PIN_PAGE_1 0x0800C400 // 密码区副备份页 void flash_store_pin(uint32_t *hash, uint32_t len) { uint32_t page_err = 0; FLASH_EraseInitTypeDef erase_cfg; HAL_FLASH_Unlock(); __disable_irq(); // 写 Flash 期间关中断 for (int copy = 0; copy < 2; copy++) { uint32_t dest = (copy == 0) ? PIN_PAGE_0 : PIN_PAGE_1; erase_cfg.TypeErase = FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress = dest; erase_cfg.NbPages = 1; HAL_FLASHEx_Erase(&erase_cfg, &page_err); for (uint32_t i = 0; i < len; i += 4) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, dest + i, hash[i / 4]); } } __enable_irq(); HAL_FLASH_Lock(); }这段代码把同一份哈希写入两个物理页,启动时分别读出并比对 CRC,一页损坏就用另一页恢复。HAL_FLASH_Unlock 必须在擦写前调用,HAL_FLASH_Lock 在结束后调用;写 Flash 期间关闭中断,是因为擦写操作需要占用指令总线,中断服务程序里若有 Flash 访问会产生总线忙等待。密码不能明文存储,至少做一次 FNV 或 CRC 混淆,工程上可以升级为带盐的摘要,但毕业设计阶段先保证“不是明文落盘”。
| 存储方案 | 擦写方式 | 寿命与代价 | 适用场景 |
|---|---|---|---|
| 片内 Flash | 页擦除、字写入 | 约 1 万次擦写,零成本 | 白名单变化不频繁的普通门禁 |
| AT24C02 | 字节写、页写 | 约 100 万次,需 I2C | 日志频繁追加、需要高耐用 |
| W25Q64 外挂 SPI Flash | 扇区擦除 | 约 10 万次,成本低 | 需要存大量开门记录 |
4.2 继电器驱动与续流二极管:一类典型的“吸合即复位”
继电器吸合瞬间把 stm32 复位,是门禁项目里最经典的玄学问题,根因几乎都出在驱动电路上。GPIO 直接驱动继电器线圈,电流不够是小事,更大的问题是线圈断开瞬间会产生反向感应电动势,电压尖峰可以直接打穿 GPIO 或让电源跌落。常规做法是 GPIO 先接三极管或 ULN2003 放大电流,继电器线圈两端反向并联一只 1N4148 或 1N4007 续流二极管,让反电动势在二极管和线圈的环路里消耗掉。电磁锁的功率更大,最好连光耦一起上,PC817 输入端接 GPIO,输出端单独供电给继电器。
这里有一个接线细节:续流二极管方向不能接反,阴极接继电器线圈正极、阳极接负极,接反就变成短路路径,继电器一上电就烧驱动管。锁控输出最好能有独立的 5V 电源轨,不要从 stm32 的 3.3V LDO 后面取,瞬时电流会拉低 MCU 供电电压。现场如果已经出现吸合复位的现象,先用示波器抓继电器供电引脚在动作瞬间的跌落波形,再检查续流管方向,不要一上来就怀疑程序跑飞去加大喂狗频率。
4.3 低功耗唤醒:STOP 模式、门磁中断与电池电压检测
电池供电门禁要把待机电流压下来,F103 最常用的是 STOP 模式,典型待机电流几十微安级别。进入 STOP 前把不用的外设时钟关闭,把矩阵键盘的列线和门磁接成 EXTI 外部中断,任意一路信号变化都可以唤醒。唤醒后先重新初始化时钟树,再恢复外设,注意 F103 从 STOP 退出后默认运行在 HSI 上,如果主频是 72MHz,必须重新配置 PLL,否则定时器节拍和串口波特率全部错位,表现为“唤醒后第一次按键失灵或者串口乱码”。
低电量告警可以顺带用 ADC 做:电阻分压后采样电池电压,主循环里每 10ms 判断一次,低于阈值时只允许开门三次并点亮欠压指示灯。ADC 这里不需要开 DMA,门禁不采集连续波形,单次转换足够,省掉 DMA 配置带来的外设关联复杂度。待机电流测试要断开调试器,因为 st-link 的参考电压会通过 SWD 接口反向供电,测出来的数据比真实值虚高不少,这是低功耗验证里最容易出现的假数据来源。
5. 三件工具把 stm32 门禁程序验证到可以交付
5.1 逻辑分析仪抓 SPI 时序,定位读卡异常
RC522 的调试别靠猜。逻辑分析仪四个通道分别接 CS、SCK、MOSI、MISO,触发沿设置在 CS 下降沿,先看读版本号的波形:主机发出命令后,MISO 上必须回来一段数据。采样率不用太高,10M 左右足够覆盖 1Mbps 的 SPI 时钟。如果 MISO 一直是高电平,问题多半在片选配置或者 MISO 引脚复用;如果波形断断续续,检查杜邦线长度和 SPI 分频系数。键盘扫描同样能用逻辑分析仪验证:行线拉低、对应列线被拉低的过程,直接证明键值计算没有接反行列。
5.2 串口回放协议帧,把状态迁移变成可重复测试
把按键、刷卡、门磁统一封装成串口协议帧,等于给状态机装了一台信号发生器。常见做法是定义$KEY,05*C7、$RFID,1234ABCD*5A这样的短帧,帧尾固定一个校验和。主循环里解析到合法帧后,直接调用对应的输入处理函数,不经过真实的 GPIO 扫描。测试时用支持循环发送的串口工具,预置二十条正常帧加两三条非法帧循环发送,同时开启板子的状态迁移日志,观察 IDLE 到 VERIFY 到 UNLOCK 的轨迹是否符合预期。非法帧必须包含长度错误和校验错误两类,确认这些帧被丢弃且不触发失败计数,才能证明协议层能挡住构造数据。
5.3 打开 IWDG 做压测,让复位问题无处遁形
看门狗不是用来兜住 Bug 的,但门禁设备挂墙后没人天天去断电重启,IWDG 作为最后一道防线仍然必要。在 CubeMX 里开启 IWDG,溢出时间设 1 秒,主循环末尾喂狗;喂狗代码必须放在状态机函数之后,因为状态机在 ST_ALARM 锁定期间也要正常返回,否则锁定 30 秒内看门狗会误复位整个系统。压测方法是对继电器连续开关 100 次,同时统计 stm32 的复位次数,如果喂狗间隔正常但仍然出现复位,优先怀疑电源跌落而不是程序死循环。IWDG 一旦使能就只能复位关闭,所以它不能替代驱动电路整改,只用来保证异常发生后系统自动恢复到空闲状态。
本文还有配套的精品资源,点击获取