简介:这是一套面向嵌入式初学者与课程设计需求的STM32F1智能门锁完整项目资料,基于STM32F103C8T6主控,整合RFID读卡器、OLED屏幕、按键、蓝牙通信、蜂鸣器与舵机等模块,配套上位机与下位机双向交互方案,适合作为单片机课程设计、毕业设计或电子DIY练手项目。资源包共237个文件,约8.13MB,以C源码、头文件、编译中间文件及工程配置为主,另含hex烧录文件与Keil工程文件,覆盖从源码到可执行固件的完整链路。目前已有17081人学习下载,热度较高。项目功能划分清晰:密码模式下支持输入开锁、错误提示、连续三次输错触发蜂鸣器报警,并将密码保存至FLASH实现掉电不丢失;IC卡模式可读取身份信息并完成开锁或报警;上位机则负责密码设置与重置、错误报警弹窗以及IC卡用户信息的增删管理。整体方案兼顾硬件选型、软件逻辑与远程控制,便于读者快速理解智能门锁的完整实现思路并二次开发。
1. 从一块 F103 最小系统板到一把能刷卡的锁:这套资源到底值不值得拆
手头攒着几块 STM32F103C8T6 最小系统板,一直想找个能把 GPIO、SPI、UART、中断和 Flash 读写串起来的综合练手项目。翻了不少 stm32 课程资源,要么是点灯加串口打印的拼盘,要么是直接甩一个 HAL 库工程让你自己悟。这套基于 STM32F1 的智能门锁项目不太一样——它把 RC522 读卡、舵机开锁、矩阵键盘密码、OLED 状态显示、掉电存储和远程控制指令解析揉进了一个完整流程里,还配了全套视频教程。说白了,它解决的是“学完外设不知道怎么组合成一个能跑的产品原型”这个断层。适合已经能独立写 GPIO 和串口收发、想往综合项目迈一步的嵌入式新手,也适合需要快速搭一个门禁 Demo 去验证上位机协议的熟手。我花了一个周末把工程拆开跑通,下面把选型逻辑、接线参数、代码骨架和踩过的坑一次讲清。
2. 硬件选型与接线:为什么是 RC522 加舵机这套组合
2.1 主控与外设的匹配逻辑
STM32F103 系列跑 72MHz,片上 64KB Flash、20KB RAM,对于门锁这种状态机加少量数据存储的场景绰绰有余。选 F1 而不是 F4 的原因很直接:RC522 走 SPI 最高 10Mbps,F1 的 SPI1 完全喂得饱;舵机只需要一路 50Hz PWM,通用定时器随便出;矩阵键盘占 7 个 GPIO,OLED 走 I2C 占 2 个,剩下的引脚还能挂个蜂鸣器做按键反馈。整套 BOM 成本压得很低,适合批量做课程设计或小规模门禁节点。
常见做法是把 RC522 接 SPI1:SCK 在 PA5,MISO 在 PA6,MOSI 在 PA7,片选 NSS 用 PA4 软件控制,复位 RST 用 PB0。这里有个细节——很多教程把 NSS 接硬件 SPI 的 NSS 引脚,结果多机通信时片选时序对不上,读卡偶尔失败。我一般强制用软件片选,初始化时把 PA4 配成推挽输出,通信前拉低、通信后拉高,时序完全可控。
舵机信号线接 PA8,对应 TIM1_CH1,配置成 PWM 模式,周期 20ms,脉宽 0.5ms 到 2.5ms 对应 0 到 180 度。门锁场景只需要两个位置:关锁 0 度、开锁 90 度,所以实际占空比只在 5% 和 7.5% 之间切换。矩阵键盘用 4x4 薄膜键盘,行接 PB1 到 PB4,列接 PB5 到 PB8,全部配成上拉输入和推挽输出交替扫描。OLED 用 0.96 寸 I2C 屏,SCL 接 PB10,SDA 接 PB11,地址一般是 0x78 或 0x7A,取决于模块背面的电阻跳线。
2.2 供电与电平匹配的坑
RC522 模块标称 3.3V 供电,但市面上不少模块的 SPI 引脚没有电平转换,直接接 5V 主控会烧。F103 的 IO 是 3.3V 电平,和 RC522 直连没问题,但舵机必须单独供 5V,且电流要够——SG90 堵转电流能到 700mA,如果和主控共用一路 LDO,开锁瞬间主控会复位。血泪经验:舵机电源和主控电源分开走,共地即可。我在面包板上第一次跑的时候,舵机一转 STM32 就重启,查了半天以为是看门狗,最后发现是电源跌落。
接线确认后,先别急着写业务逻辑。用下面这段代码把 RC522 的版本号读出来,能返回 0x92 或 0x91 就说明 SPI 通了:
// rc522_check.c // 读取 RC522 的 VersionReg,验证 SPI 通信是否正常 #include "rc522.h" #include "spi.h" uint8_t RC522_Check(void) { uint8_t version; RC522_Init(); // 初始化 SPI 和 GPIO version = RC522_ReadReg(0x37); // VersionReg 地址固定为 0x37 if (version == 0x92 || version == 0x91) { return 1; // 通信正常 } return 0; // 通信失败,检查接线和片选 }逻辑说明:RC522_Init 里先拉低 RST 再拉高,给模块一个复位脉冲,然后配置 SPI 为模式 0、时钟分频到 4.5MHz 左右。ReadReg 的地址格式是 (reg << 1) | 0x80,读操作最高位置 1。参数说明:0x37 是 RC522 数据手册里固定的版本寄存器地址,返回值 0x92 对应 v2.0 芯片,0x91 对应 v1.0,两者都算正常。如果返回 0x00 或 0xFF,先查 NSS 有没有拉低、MISO 有没有接反。
3. 核心功能实现:刷卡、密码、存储与远程指令解析
3.1 RC522 寻卡与防冲突流程
RC522 读卡不是一步到位,标准流程是寻卡、防冲突、选卡、读写。寻卡用 PICC_REQALL 命令,返回卡的类型;防冲突用 PICC_ANTICOLL,拿到 4 字节 UID;选卡用 PICC_SELECTTAG,把 UID 发给卡确认。这三步走完才能读扇区数据。很多课程资源只贴寻卡代码,结果两张卡同时靠近就读不出来,就是因为没做防冲突。
// rc522_card.c // 完整读卡流程:寻卡 -> 防冲突 -> 选卡 -> 读 UID uint8_t RC522_ReadCard(uint8_t *uid) { uint8_t status; uint8_t buffer[5]; status = PcdRequest(PICC_REQALL, buffer); // 寻卡,buffer[0..1] 返回卡类型 if (status != MI_OK) return status; status = PcdAnticoll(buffer); // 防冲突,buffer[0..3] 返回 UID if (status != MI_OK) return status; memcpy(uid, buffer, 4); // 保存 4 字节 UID status = PcdSelect(uid); // 选卡,用 UID 锁定这张卡 return status; }逻辑说明:PcdRequest 内部会拉低 NSS、发送命令字、等待中断引脚响应,超时时间一般设 200ms。PcdAnticoll 返回的 UID 是低位在前,比较时要注意字节序。参数说明:PICC_REQALL 会寻所有卡,如果只想寻未进入休眠的卡可以用 PICC_REQIDL。选卡成功后,后续读写扇区就不会被其他卡干扰。
3.2 舵机开锁与状态机设计
门锁的状态不能乱。我一般用一个枚举把状态定死:LOCKED、UNLOCKING、UNLOCKED、LOCKING、ALARM。刷卡或密码正确后,状态从 LOCKED 跳到 UNLOCKING,舵机转到 90 度,延时 3 秒后自动跳 LOCKING 转回 0 度。如果连续 3 次密码错误,跳 ALARM,蜂鸣器响 5 秒并锁定键盘 30 秒。
// lock_fsm.c // 门锁状态机核心逻辑 typedef enum { LOCKED, UNLOCKING, UNLOCKED, LOCKING, ALARM } LockState; void Lock_Update(LockState *state, uint8_t event) { switch (*state) { case LOCKED: if (event == EVT_CARD_OK || event == EVT_PASS_OK) { Servo_SetAngle(90); // 开锁 *state = UNLOCKING; } else if (event == EVT_PASS_FAIL) { if (++fail_count >= 3) { Buzzer_On(); *state = ALARM; } } break; case UNLOCKING: if (delay_3s_done()) { // 非阻塞延时,用 systick 计数 Servo_SetAngle(0); // 关锁 *state = LOCKING; } break; case ALARM: if (alarm_5s_done()) { Buzzer_Off(); *state = LOCKED; fail_count = 0; } break; default: break; } }逻辑说明:状态机每 10ms 调一次,event 由按键扫描和读卡任务置位。非阻塞延时用 SysTick 的毫秒计数做差,不能用 delay_ms 死等,否则键盘和读卡全卡住。参数说明:fail_count 是全局变量,密码正确时清零;舵机角度 90 度对应开锁,实际机械结构可能需要微调到 85 或 95,看锁舌行程。
3.3 掉电存储与远程指令解析
UID 白名单和密码要存 Flash,F103 的 Flash 页大小 1KB,擦写寿命 1 万次左右。不能每次刷卡都写,我一般只在管理员模式下修改白名单时才擦写。存储结构用简单的 TLV 格式:先存一个 magic number 0x5A5A,再存密码 6 字节,再存白名单数量和白名单 UID 数组。上电时先读 magic,不对就加载默认密码。
远程控制这块,项目里留了 UART 接口,上位机发指令格式是CMD:UNLOCK\n或CMD:ADD:UID\n。解析时用环形缓冲区收字节,遇到换行符就取出一帧,用 strncmp 匹配命令头。注意别用 scanf 或 gets,会阻塞主循环。常见做法是串口中断收一个字节进 buffer,主循环里查有没有完整帧。
// remote_cmd.c // UART 环形缓冲区收帧与命令解析 #define RX_BUF_SIZE 64 static uint8_t rx_buf[RX_BUF_SIZE]; static uint16_t rx_head = 0, rx_tail = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { // 缓冲区未满 rx_buf[rx_head] = ch; rx_head = next; } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } void CMD_Process(void) { static char line[32]; static uint8_t idx = 0; while (rx_tail != rx_head) { char c = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; if (c == '\n') { line[idx] = '\0'; if (strncmp(line, "CMD:UNLOCK", 10) == 0) { Lock_Update(&lock_state, EVT_REMOTE_UNLOCK); } else if (strncmp(line, "CMD:ADD:", 8) == 0) { Add_UID_To_Flash((uint8_t *)line + 8); } idx = 0; } else if (idx < sizeof(line) - 1) { line[idx++] = c; } } }逻辑说明:中断里只做收字节和移动 head,不做解析,避免中断时间过长。主循环调 CMD_Process 逐字节取,遇到换行符才解析。参数说明:RX_BUF_SIZE 取 64 够用,如果指令带长 UID 可以加到 128。strncmp 的长度要精确匹配命令头,否则CMD:UNLOCKX也会被误判。
4. 避坑与排查:从读卡失败到舵机抖动的五个真实记录
4.1 现象:RC522 寻卡返回 MI_ERR,串口打印全是 0xFF
原因:SPI 模式配错。RC522 要求 SPI 模式 0(CPOL=0,CPHA=0),时钟空闲低电平、第一个边沿采样。很多教程用模式 3,读出来全是 0xFF。另外 NSS 如果接硬件片选,初始化顺序不对也会导致 MISO 一直被拉高。
解决:在 SPI_Init 里把 CPOL 和 CPHA 都设成 0,NSS 用软件模式。初始化时先拉高 NSS,再拉低 RST 延时 100ms,再拉高 RST 延时 100ms,最后发软复位命令 0x0F。用示波器看 SCK 和 MOSI,确认第一个字节 0x80 的波形正常。
4.2 现象:刷卡能读到 UID,但比对白名单永远不匹配
原因:UID 字节序搞反了。PcdAnticoll 返回的 buffer[0] 是 UID 最低位,但很多白名单存储时按人类阅读顺序写成了高位在前。比如卡上印的 UID 是A1 B2 C3 D4,实际读出来是D4 C3 B2 A1。
解决:统一在存储和比对时都用读出来的原始顺序,或者写一个字节翻转函数。我一般在上位机录入 UID 时就把顺序翻好,下位机不做转换,减少一处出错点。
4.3 现象:舵机转到一半突然回弹,门锁咔咔响
原因:PWM 占空比在状态切换时被其他定时器中断打断,或者舵机电源和主控共用导致电压跌落。另外舵机从 0 度到 90 度如果直接跳变,机械冲击大,齿轮容易扫齿。
解决:PWM 更新放在定时器更新中断里,保证占空比寄存器原子写入。舵机角度用渐变方式,每 10ms 加 5 度,分 18 步走到 90 度。电源单独供 5V,主控和舵机共地但不共电源正极。
4.4 现象:Flash 存了白名单,断电重启后数据丢了
原因:F103 的 Flash 写之前必须先擦除整页,擦除后是 0xFF。如果直接写数据没擦除,写进去的还是旧值。另外擦除时如果没关中断,中断向量表可能被误擦。
解决:写 Flash 前先解锁,擦除目标页,再按半字写入。擦除和写入期间关全局中断,写完再开。存储结构加 magic number 和 CRC 校验,上电读取时校验失败就加载默认值,避免读到半截数据。
4.5 现象:远程指令发过去没反应,但本地刷卡正常
原因:串口中断优先级低于读卡任务,或者环形缓冲区溢出。如果读卡任务里用了 delay_ms 死等,串口中断虽然能进,但主循环一直不调 CMD_Process,帧就堆在缓冲区里。
解决:读卡任务改成状态机轮询,不用死等。串口中断优先级设成比 SysTick 低但比读卡高,保证收字节及时。缓冲区满了就丢弃最旧数据,并在串口打印溢出警告,方便定位。
5. 进阶技巧:把门锁日志写进 Flash 并做循环覆盖
项目跑通之后,我习惯加一个日志功能,记录每次开锁的时间戳、方式和 UID。F103 没有 RTC 电池的话,时间戳可以用上电后的毫秒数代替,或者外挂一个 DS1302。日志区单独占一页 Flash,每条记录 16 字节:4 字节序号、4 字节时间、4 字节 UID、1 字节方式、3 字节保留。写满一页后从头覆盖,序号递增,这样永远保留最近 64 条记录。
// flash_log.c // 循环日志:写满一页后从头覆盖 #define LOG_PAGE_ADDR 0x0801FC00 // 最后一页,1KB #define LOG_ENTRY_SIZE 16 #define LOG_MAX_ENTRY (1024 / LOG_ENTRY_SIZE) void Log_Write(uint32_t uid, uint8_t method) { static uint32_t seq = 0; uint32_t addr; uint8_t entry[LOG_ENTRY_SIZE]; seq = Log_ReadLastSeq() + 1; // 读上一条序号加一 addr = LOG_PAGE_ADDR + (seq % LOG_MAX_ENTRY) * LOG_ENTRY_SIZE; if (seq % LOG_MAX_ENTRY == 0) { // 写满一轮,擦除整页 FLASH_Unlock(); FLASH_ErasePage(LOG_PAGE_ADDR); FLASH_Lock(); } *(uint32_t *)entry = seq; *(uint32_t *)(entry + 4) = Get_Tick_Ms(); *(uint32_t *)(entry + 8) = uid; entry[12] = method; entry[13] = entry[14] = entry[15] = 0; FLASH_Unlock(); for (int i = 0; i < LOG_ENTRY_SIZE; i += 2) { FLASH_ProgramHalfWord(addr + i, *(uint16_t *)(entry + i)); } FLASH_Lock(); }逻辑说明:seq 从 Flash 里读最后一条记录的序号,加一后对 LOG_MAX_ENTRY 取模得到写入偏移。写满一轮先擦整页再写,保证空间循环利用。参数说明:LOG_PAGE_ADDR 选最后一页是因为 F103C8T6 只有 64KB Flash,前 63 页留给代码,最后一页做日志。Get_Tick_Ms 返回上电后的毫秒数,精度够用。读日志时按 seq 从大到小遍历,遇到 0xFFFFFFFF 就停。
验证方法:连续刷卡 70 次,断电重启,用串口把日志全打出来,看序号是否连续、最早的记录是否被覆盖。如果发现某条记录 UID 是 0xFFFFFFFF,说明擦除后没写成功,检查 FLASH_ProgramHalfWord 的返回值。
从那以后我每次做带存储的项目,都强制先写一个读写测试函数,把整页擦掉、写满、读回、比对,确认无误再往上叠业务逻辑。这个习惯帮我省了至少三次“数据丢了但不知道哪一步丢”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取