尧图精选

STM32+RT-Thread的WiFi门禁考勤系统:从源码到Mesh组网全解析

🕒 发布时间:2026/9/12 10:49:31 📁 来源:尧图网络
简介基于C语言实现的WiFi门禁考勤系统以STM32F103ZET6为主控芯片运行国产RT-Thread实时操作系统并配套完整源码与项目文档适合毕业设计、课程设计及嵌入式项目二次开发。系统支持刷卡和指纹识别验证身份通过ESP8266 Mesh组网与服务器通信服务器端可增删改人员信息、读取考勤记录并导出Excel表格从硬件驱动到应用层均已实现。资源包内含2000个文件约38.6MB其中以888个C语言源文件与807个头文件为核心另含188个网页界面、20个Python工具脚本、13个Markdown说明文档及部分PDF/Word资料覆盖RT-Thread v2.1.0 beta移植、STemWin图形界面适配、触摸屏/液晶/SD卡/SRAM/RC522/DS1307/蜂鸣器驱动、人员与考勤数据库设计以及ESP8266 HSPI接口通信协议等具体实现。源码经过严格测试目录结构清晰便于按模块查阅和功能扩展并可直接在现有框架上延伸二次开发。当前已有97人学习对需要快速搭建完整门禁考勤系统的开发者和学生具有较高参考价值。1. STM32F103ZET6跑RT-Thread的WIFI门禁考勤能从源码包里拆出多少东西一套能在大四答辩现场直接演示的WIFI门禁考勤系统往往不是只写一个“刷卡亮灯”的裸机程序。这个工程把RT-Thread v2.1.0、STemWin5.28、sqlite3、ESP8266 Mesh、RC522刷卡、指纹验证和服务器端Excel导出全部塞进STM32F103ZET6源码目录里光GUI演示和字库文件就占了大半。它适合两种人一是需要毕业设计/课程设计完整交付物的学生二是想评估“这颗Cortex-M3到底能不能同时扛GUI、数据库和网络协议”的嵌入式工程师。下文基于源码包的文件布局从架构拆解到数据落库再到Mesh通信和服务器端实现逐层还原这套系统的可复现路径。2. 源码包拆解RT-Thread v2.1.0、STemWin5.28与C语言文件地图拿到源码包第一眼容易被人名命名的demo文件绕晕。GUIDEMO_ZoomAndRotate.c、GUIDEMO_IconView.c、GUIDEMO_WashingMachine.c、GUIDEMO_Treeview.c并不是无线相关的代码而是STemWin自带的控件演示例子真正和门禁考勤强相关的是RT-Thread内核、sqlite3.c、ESP8266 HSPI驱动和两块点阵字库文件。2.1 从文件列表看懂功能单元解压源码后建议先做一步按“OS / GUI / 数据 / 通信 / 驱动”五个抽屉归类过一遍每个文件到底负责什么。下面这张表是我自己会列出来的对照关系。文件/目录归属实际作用RT-Thread 内核源码OS任务调度、信号量、消息队列、内存管理sqlite3.c数据嵌入式关系数据库存储人员和考勤记录GUIDEMO_*.cGUISTemWin控件演示程序可裁剪或改造成主界面hz12font.c / hz16font.cGUI12/16点阵中文字库供STemWin显示cc936.c / cc949.c / cc950.cGUI编码转换表GBK/韩文/繁体与Unicode互转RC522驱动、DS1307驱动、SD卡驱动外设读卡、时间戳、数据库落盘ESP8266 HSPI驱动及协议通信与ESP8266 Mesh节点交换数据归类之后能明显看出工程边界GUI层、应用层、数据库层、通信层是解耦的。如果只需要考勤逻辑GUIDEMO系列文件可以全部不参与编译如果只想做GUI展示又可以把sqlite3.c和ESP8266驱动摘出去。这也是为什么该源码包适合当课程设计来改裁剪路径非常清晰。2.2 RT-Thread启动路径与线程划分RT-Thread v2.1.0启动时内核自动创建init线程并调用init_thread_entry。我一般会在这个入口里把“GUI刷新”和“考勤采集”拆成两个独立线程避免一次刷卡导致界面卡顿。#include rtthread.h extern void gui_entry(void *param); extern void attendance_entry(void *param); /* RT-Thread 启动后自动进入该函数 */ void init_thread_entry(void *parameter) { rt_thread_t gui_thread; rt_thread_t att_thread; /* 优先级9栈512字节时间片10 */ gui_thread rt_thread_create(gui, gui_entry, RT_NULL, 512, 9, 10); if (gui_thread ! RT_NULL) rt_thread_startup(gui_thread); /* 考勤线程优先级更高栈配到2048字节 */ att_thread rt_thread_create(att, attendance_entry, RT_NULL, 2048, 3, 10); if (att_thread ! RT_NULL) rt_thread_startup(att_thread); }rt_thread_create第3个参数是线程入口参数这里传RT_NULL第4个参数是线程栈大小单位字节第5个参数是优先级数值越小优先级越高第6个是时间片只在同优先级就绪时起作用。考勤线程优先级高于GUI线程目的是让刷卡中断释放信号量后考勤线程能抢占GUI线程完成写库而不是等界面重绘完才记卡号。2.3 STemWin5.28与中文字库的配合STemWin5.28移植到RT-Thread上比较隐蔽的坑是中文显示。STemWin内部字符串默认按Unicode处理而源码包里的姓名、部门信息从数据库读出来通常是GBK编码不做转换就会在液晶屏上显示成乱码。cc936.c在这里就是桥接层把GBK字符转成STemWin能识别的Unicode码位。char disp_buf[64]; void gui_show_employee_name(const char *gbk_name) { strncpy(disp_buf, gbk_name, sizeof(disp_buf) - 1); disp_buf[sizeof(disp_buf) - 1] \0; /* 通过cc936.c把GBK转成STemWin内部编码 */ GUI_UC_SetEncodeGBK(); GUI_SetFont(GUI_FontHZ16); GUI_DispStringAt(disp_buf, 10, 30); }GUI_UC_SetEncodeGBK设置当前字符串编码方式GUI_FontHZ16对应hz16font.c提供的16点阵中文字体。如果换用12点阵则用GUI_FontHZ12。想省Flash资源的话可以在GUI初始化阶段只加载常用字模但源码包里的做法是全量字库直接编译简单但占Flash。3. 数据落地方案sqlite3在STM32上的裁剪与考勤表设计刷卡考勤系统的核心不是门锁而是数据。ZET6内部只有64KB RAM即使外扩SRAM也不会像PC那样随便挥霍内存。sqlite3.c单文件能编译进C工程但不做裁剪直接跑大概率会在首次建表时崩溃。所以要先解决内存预算问题再设计表结构。3.1 sqlite3的编译裁剪和RAM预算在C语言工程里sqlite3的内存主要消耗在页缓存和预编译语句上。我一般会在编译宏里做如下收敛宏开关推荐配置原因SQLITE_THREADSAFE0全系统只有一个线程写库关闭线程安全减少锁开销SQLITE_TEMP_STORE2临时表和临时索引优先放内存SQLITE_DEFAULT_CACHE_SIZE8页缓存8页每页1KB占用约8KB RAMSQLITE_DEFAULT_PAGE_SIZE1024512或1024字节更适合SD卡扇区SQLITE_OMIT_LOAD_EXTENSION1MCU上不需要动态加载扩展SQLITE_OMIT_TRACE1去掉调试跟踪接口省代码不省RAM对应的编译宏可以放在rtconfig.h或编译器命令行里#define SQLITE_THREADSAFE 0 #define SQLITE_TEMP_STORE 2 #define SQLITE_DEFAULT_CACHE_SIZE 8 #define SQLITE_DEFAULT_PAGE_SIZE 1024 #define SQLITE_OMIT_LOAD_EXTENSION 1 #define SQLITE_OMIT_TRACE 1这套配置下sqlite3运行期最少需要20KB左右的堆空间。如果RT-Thread的heap已经分配在外部SRAM上sqlite3的malloc会自动从外部SRAM取内存如果heap只在片内64KB里挤就会出现建表失败。启动后第一件事是打印sqlite3_errmsg(db)多半能看到out of memory这时候优先调大堆而不是怀疑SQL写错。3.2 人员表与考勤表结构人员信息和考勤记录会频繁关联查询建议按下面两张表建模CREATE TABLE IF NOT EXISTS employee ( emp_id INTEGER PRIMARY KEY AUTOINCREMENT, card_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, department TEXT DEFAULT 未分配, enable INTEGER DEFAULT 1 ); CREATE TABLE IF NOT EXISTS attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, emp_id INTEGER NOT NULL, record_time TEXT NOT NULL, verify_mode INTEGER DEFAULT 0, FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ); CREATE INDEX IF NOT EXISTS idx_attendance_time ON attendance(record_time);card_no用TEXT而不是INTEGER是因为RC522读出的卡序列号是8字节十六进制比如“A1B2C3D4”直接转整数可能溢出并丢失前导0。attendance表里不冗余姓名只存emp_id这样管理员改姓名时历史考勤记录不用跟着改。record_time用ISO格式的字符串“YYYY-MM-DD HH:MM:SS”在按时间范围查询时字典序就是时间序排序不需要再调用时间函数。3.3 刷卡写库的事务处理每次刷卡都执行一条INSERT裸写会频繁提交事务SD卡写入会很慢。更好的模式是先prepare再bind沿用同一个语句对象只替换参数值static sqlite3 *db; static sqlite3_stmt *stmt; int attendance_record_write(int emp_id, const char *time_buf, int verify_mode) { int rc; /* stmt在初始化时已prepare这里不复用sqlite3_exec */ sqlite3_reset(stmt); sqlite3_clear_bindings(stmt); sqlite3_bind_int(stmt, 1, emp_id); sqlite3_bind_text(stmt, 2, time_buf, -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 3, verify_mode); rc sqlite3_step(stmt); if (rc SQLITE_DONE) return 0; return -1; }sqlite3_bind_text第4个参数传-1表示让sqlite3通过strlen自行计算字符串长度第5个参数SQLITE_TRANSIENT告诉sqlite3把字符串拷贝一份防止time_buf指向的栈空间被覆盖。批量补卡时需要把整个刷卡流程包在一个事务里sqlite3_exec(db, BEGIN IMMEDIATE;, NULL, NULL, NULL); attendance_record_write(1001, 2025-06-01 08:30:12, 0); attendance_record_write(1002, 2025-06-01 08:31:45, 1); sqlite3_exec(db, COMMIT;, NULL, NULL, NULL);注意打开数据库后要执行一次PRAGMA foreign_keysON;否则表定义里的FOREIGN KEY只是摆设删除employee表记录时attendance里不会自动做任何处理。4. C语言实现ESP8266 Mesh组网与HSPI通信协议设计门禁考勤系统的网络层不是单片机和服务器直连而是多台设备通过ESP8266组成Mesh网络再统一汇到服务器。STM32与ESP8266之间没有用串口AT指令而是直接驱动HSPI接口。原因很简单Mesh节点在转发数据时9600波特率的串口会把几十条考勤记录堵死SPI接口能提供更高的传输带宽。4.1 HSPI接口初始化的关键参数如果按STM32F103ZET6的SPI1默认引脚来接线初始化大致是这样void esp8266_hspi_init(void) { GPIO_InitTypeDef gpio_init; SPI_InitTypeDef spi_init; RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* SPI1默认引脚PA5-SCK, PA6-MISO, PA7-MOSI, PA4做软件CS */ gpio_init.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio_init.GPIO_Mode GPIO_Mode_AF_PP; gpio_init.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio_init); spi_init.SPI_Direction SPI_Direction_2Lines_FullDuplex; spi_init.SPI_Mode SPI_Mode_Master; spi_init.SPI_DataSize SPI_DataSize_8b; spi_init.SPI_CPOL SPI_CPOL_Low; spi_init.SPI_CPHA SPI_CPHA_1Edge; spi_init.SPI_NSS SPI_NSS_Soft; spi_init.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_16; SPI_Init(SPI1, spi_init); SPI_Cmd(SPI1, ENABLE); }CPOL和CPHA必须和ESP8266从机端一致通常ESP8266 HSPI是从机SCK空闲低电平、第一个边沿采样也就是SPI_Mode 0。波特率预分频先压到PCLK/16不要一开始就追求速度ESP8266的SPI从机在Mesh转发状态下的时序容差有限跑太快会丢字节。4.2 通信帧格式设计HSPI只负责字节搬运真正的可靠传输要自己定帧协议。源码包里提到的“简单通信协议”按我见过的主流做法至少要有同步头、设备ID、命令字、长度、CRC偏移字段长度(字节)说明0帧头2固定0xAA55用于同步2设备ID2门禁节点地址4命令字10x01人员下发0x04考勤上传0x05时间同步5数据长度1后续数据区字节数6数据区N内容与命令相关6NCRC162从帧头到数据区末尾的校验封帧函数可以写得非常紧凑uint16_t frame_build(uint8_t *out, uint16_t dev_id, uint8_t cmd, const uint8_t *data, uint8_t len) { uint8_t *p out; uint16_t crc; *p 0xAA; *p 0x55; *p dev_id 8; *p dev_id 0xFF; *p cmd; *p len; if (len 0 data ! NULL) { memcpy(p, data, len); p len; } crc crc16(out, p - out); *p crc 8; *p crc 0xFF; return p - out; }CRC16实现建议查表不要在接收中断里逐位异或否则SPI中断会一直被打断。接收侧只做一字节状态机解析收到0xAA后再等0x55之后按长度字段累计收包最后校验CRC。CRC不过直接丢弃整帧不能让脏数据进入数据库。4.3 考勤上报与ACK确认设备端考勤记录先存在本地SD卡上的sqlite3数据库里定期合并成一条上报负载发给Mesh节点。服务器处理成功后必须返回ACK设备收到ACK才删除本地的对应记录否则留着下次重传。int attendance_upload(void) { uint8_t frame[64]; uint8_t buf[32]; uint16_t frame_len; buf[0] emp_id 8; buf[1] emp_id 0xFF; snprintf((char *)buf 2, sizeof(buf) - 2, %s, record_time); frame_len frame_build(frame, device_id, 0x04, buf, 2 strlen(record_time)); esp8266_hspi_send(frame, frame_len); if (esp8266_hspi_wait_ack(5000) 0) { attendance_record_delete(); return 0; } return -1; }wait_ack超时判断非常关键。Mesh网络在节点漂移时会出现一次转发延迟几秒的情况5秒超时是我调完这套链路后觉得比较稳的折中值。超时后不要立即重发应该回到主循环等待下一次调度避免Mesh拥塞雪崩。5. 服务器端管理人员维护、考勤接收与Excel导出设备端只是采集和数据缓冲真正给管理员使用的是服务器端。源码包主体是C语言但服务器端不一定继续用C常见做法是直接写Python脚本能快速把ESP8266上报的HTTP请求解析成结构化数据再落库并导出Excel。5.1 设备上报接收接口服务器端用Flask搭一个最简接口接收设备POST上来的JSON考勤批from flask import Flask, request, jsonify import sqlite3 app Flask(__name__) app.route(/attendance/upload, methods[POST]) def upload(): data request.get_json(forceTrue) # 请求体: {device_id: 3, records: [{emp_id: 4, time: 2025-06-01 08:30:12}]} conn sqlite3.connect(server.db) for rec in data[records]: conn.execute( INSERT INTO attendance (emp_id, record_time, verify_mode) VALUES (?, ?, 0), (rec[emp_id], rec[time]) ) conn.commit() conn.close() return jsonify({code: 0, message: ACK}) if __name__ __main__: app.run(host0.0.0.0, port8080)forceTrue让Flask忽略Content-Type直接解析JSON文本ESP8266端不用特意设置application/json头。实际生产环境必须加上设备ID白名单否则任何人都能伪造请求往考勤表里灌数据。上报接口返回ACK后设备端才会删除本地记录所以这段代码必须在数据库事务成功提交后再返回。5.2 人员信息增删改查接口服务器端常见的接口设计如下接口方法作用/employee/addPOST新增人员卡号、姓名、部门/employee/deletePOST软删除人员保留历史考勤/employee/updatePOST修改姓名、卡号或部门/employee/listGET分页查询人员删除人员时直接执行DELETE会把employee表记录抹掉但attendance表里存着emp_id的考勤记录会变成孤儿数据。我建议采用软删除只把enable置0app.route(/employee/delete, methods[POST]) def delete_employee(): emp_id request.get_json(forceTrue).get(emp_id) conn sqlite3.connect(server.db) conn.execute( UPDATE employee SET enable0 WHERE emp_id?, (emp_id,) ) conn.commit() conn.close() return jsonify({code: 0})门禁设备端每收到一次人员表变更最好强制同步一次enable字段这样离职人员的卡虽然还在手上但刷卡时会被设备端直接拒绝。5.3 考勤统计与Excel导出管理员最关心的不是原始记录而是“谁几点来了、迟到多少、早退几次”。导出Excel时建议让SQL来完成关联和筛选openpyxl只负责写xlsxfrom openpyxl import Workbook def export_excel(start_date, end_date, file_path): conn sqlite3.connect(server.db) rows conn.execute( SELECT e.name, e.department, a.record_time FROM attendance a JOIN employee e ON a.emp_id e.emp_id WHERE a.record_time BETWEEN ? AND ? ORDER BY a.record_time, (start_date, end_date) ).fetchall() wb Workbook() ws wb.active ws.append([姓名, 部门, 考勤时间]) for row in rows: ws.append(list(row)) wb.save(file_path) conn.close()这里让JOIN在数据库端执行比在Python里逐条查姓名快得多。如果需要统计迟到可以在查询里加CASE WHEN time(record_time) 08:30:00 THEN 迟到 ELSE 正常 END字段Excel导出后直接用筛选功能看。6. 上线前必须处理的三个细节看门狗、掉电保护与日期校准源码能跑通是一回事设备连续运行一周不出问题又是另一回事。最后这三个细节决定了这套WIFI门禁考勤系统能不能从“演示”变成“可维护”。6.1 RT-Thread空闲钩子喂IWDG边STemWin边等数据库读写时主循环很容易被GUI函数卡住喂狗代码放主循环并不可靠。把IWDG刷新挂到RT-Thread空闲线程钩子上是最稳的做法#include stm32f10x_iwdg.h #include rtthread.h static void idle_hook(void) { IWDG_ReloadCounter(); } void iwdg_start(void) { IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); IWDG_SetReload(4095); IWDG_ReloadCounter(); IWDG_Enable(); rt_thread_idle_sethook(idle_hook); }只要RT-Thread调度器还在运行空闲钩子就会周期性喂狗当某个线程死锁导致空闲线程不再被调度看门狗才会复位。这比在主循环里喂狗更能暴露“线程卡死”的问题。6.2 sqlite3掉电保护门禁设备突然断电时SQLite可能出现日记文件残留。最直接的保护是让每次事务都同步到SD卡并显式开启FULL同步模式PRAGMA synchronous FULL; BEGIN IMMEDIATE; INSERT INTO attendance (emp_id, record_time, verify_mode) VALUES (1001, 2025-06-01 08:30:12, 0); COMMIT;BEGIN IMMEDIATE在事务开始时就取得写锁能避免设备端考勤线程和网络线程同时写库时出现SQLITE_BUSY。由于SD卡写全同步会明显变慢不建议一个INSERT单独开事务而是合并成每隔10条再整体提交。6.3 用ESP8266校准DS1307时间考勤记录的时间戳来自DS1307但DS1307晶振温漂明显运行一个月可能偏差数分钟。设备每次与服务器通信后可以用服务器返回的NTP时间刷新RTCstatic void rtc_sync_from_server(const char *server_time) { struct tm t; /* server_time 格式: 2025-06-01 08:30:12 */ sscanf(server_time, %d-%d-%d %d:%d:%d, t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec); t.tm_year - 1900; t.tm_mon - 1; time_t ts mktime(t); ds1307_set_unix_time(ts); }tm_year字段保存的是“年份-1900”所以直接从四位年份字符串读进来后要减1900tm_mon范围是0到11必须减1。这两个修正漏掉任何一个DS1307里的时间都会在月初和跨年时错得离谱。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →