我花了两个周末,用 STM32 和一块串口指纹模块,攒出了一台能录入指纹、打卡签到、自动记录考勤时间的考勤机。今天这篇文章就把从原理到实现的过程完整记录下来,包括硬件选型、指纹模块通信、软件状态机设计,以及我在调试中踩过的几个比较典型的坑。如果你也想做一台自己的指纹考勤机,或者单纯想练一下嵌入式从零到一的综合能力,这篇应该能给你省下不少时间。做这种项目有一个很大的好处:它不是单纯的跑马灯或者点个屏,而是把传感器采集、串口协议、RTC 时钟、数据存储、状态机逻辑全部串在了一起,麻雀虽小五脏俱全。而且完成后放在工作室门口实测打卡,那种成就感跟用开发板点灯完全不是一个量级。下面我按自己实际做项目的顺序来写,从需求拆解开始,到最后的稳定性和扩展性经验,尽量把每个环节背后的理由也讲清楚。1. 从零搭一台考勤机:需求、选型与系统总览考勤机这种东西,市售产品很多,几十块钱到几百块钱都有,但大多数人买回来都会遇到几个问题:数据导不出来、只支持固定几个人、不能改班次规则、UI 老旧。自己动手做的核心动机,不是省钱,而是完全掌控功能——比如我希望打卡后能立刻显示工号和打卡时间,能方便地导出 CSV 到电脑,还能把指纹库容量做到一个指纹模块本身能容纳的上限。1.1 考勤机到底需要哪些核心功能先别急着焊板子,把需求清单列出来,这是整个项目最重要的一步。我的目标功能是:录入新指纹:给一个工号,按三次手指,生成指纹模板。打卡识别:手指按上去,识别出是哪个人,记录当前时间。时间记录:必须有可靠的实时时钟,断电后时间不能丢。数据存储:考勤记录要存到文件里,能拔出来直接当电子表格用。简单交互:OLED 显示当前时间、提示信息,两个按键控制录入/删除等操作。这些功能并不复杂,但每一项背后都牵扯到具体的硬件和协议。比如"时间不能丢"就要求外挂 RTC 芯片和备份电池;"数据能导出"最方便的做法是 SD 卡加文件系统,而不是单片机内部 Flash。1.2 技术选型:为什么是 STM32 和串口指纹模块主控我选的是 STM32F103C8T6,原因很直白:便宜、资料多、性能足够。72MHz 主频跑一个考勤机富余得很,外设接口丰富——USART 可以跟指纹模块通信,SPI 接 SD 卡,I2C 接 RTC,GPIO 接按键和蜂鸣器,基本上一个芯片全包了。有人会问用 Arduino 不更简单?Arduino 确实开发快,但 STM32 的控制粒度、中断响应、低功耗管理都要强一截,而且 CubeMX 生成初始化代码后,实际写的业务逻辑也不算多。指纹模块我选了市面上最常见的 AS608 型光学指纹模块。这类模块内置了 DSP 芯片,自己完成指纹图像的采集、特征提取和匹配计算,对外只提供一串串口指令。这大大降低了开发门槛——我们不需要懂什么脊线、谷线、FAR/FRR 这些底层的图像处理算法,只需要按照模块协议发指令包,接收返回结果就行。其他部件也顺便列一下:0.96 寸 OLED 屏(I2C 接口),DS3231 RTC 模块(带电池座),SPI 接口的 Micro SD 卡模块,两个按键加一个蜂鸣器。整套下来成本不超过一百块,比自己买个成品还便宜,还能享受定制乐趣。2. 指纹模块通信与 RTC 时间基准:让硬件先"说上话"选好了件,下一步不是直接接三根线,而是先把两块核心硬件的工作原理搞清楚:指纹模块怎么返回识别结果,RTC 怎么提供准确时间。这两块决定了整个软件架构。2.1 指纹识别的本质:模块替你做完了重活指纹识别的底层原理说复杂很复杂,说简单也简单。手指按在光学传感器上,表面凹凸不平的脊线谷线会形成明暗条纹,传感器把这些条纹拍成图像,然后算法从图像里提取出特征点——就是纹路的分岔点、端点这些东西。每次录入指纹时会生成一个若干个特征点的模板,之后识别就是拿新采集到的特征和库里所有模板比对,算相似度,超过阈值就认为匹配成功。AS608 模块之所以好用,是因为它把上述过程全部封装了。模块内部有一个小指纹库,最多能存 200 到 1000 枚指纹(具体看模块容量)。我们发给它的指令不外乎这几类:采集图像、生成特征、存储模板、搜索匹配、删除模板。模块执行完返回一个确认码,我们只需要解析确认码就行。拿"搜索匹配"来举例:模块先把手指图像生成特征放入缓冲区,然后执行搜索指令,指定从哪个编号搜到哪个编号,找到后返回匹配到的编号和分数。这样主控端的逻辑就变得很单纯——发指令,收结果,处理结果。2.2 串口指令包的构成和校验这类模块的串口协议一般是自定义帧格式。以 AS608 为例,每个指令包由以下几个部分组成:帧头固定为 0xEF01 两个字节。芯片地址默认 4 字节,通常全 FF。包标识字节:01 表示指令包,07 表示应答包。包长度:包括包标识、指令码、参数和校验和在内的字节总数。指令码和参数。校验和:从包标识开始到内容最后一个字节的累加和。这里最容易出错的就是校验和。我第一次直接拿上位机软件发指令没问题,换成单片机写就总是返回 FF(无应答),后来发现是校验和算重了,把帧头也加进去了。正确做法是只累加帧头以后的内容。下面是我自己封装的指令包发送函数骨架,思路其实比细节更重要:// 指令包发送函数简化版 // pkg: 指向除帧头(0xEF01)之外的数据起始位置 void sendPacket(uint8_t *body, uint8_t len) { uint8_t packet[len + 7]; packet[0] = 0xEF; packet[1] = 0x01; packet[2] = 0xFF; packet[3] = 0xFF; packet[4] = 0xFF; packet[5] = 0xFF; packet[6] = 0x01; // 包标识:指令包 memcpy(packet + 7, body, len); uint16_t sum = 0; for (uint8_t i = 6; i < len + 7; i++) // 从包标识开始累加 sum += packet[i]; packet[len + 7] = (sum >> 8) & 0xFF; packet[len + 8] = sum & 0xFF; HAL_UART_Transmit(&huart2, packet, len + 9, 1000); }你在写自己的代码时,具体的指令码和包结构要对着模块手册核对,我这个只是示意结构。重点是要理解"校验和从哪里开始算到啊"这个细节,掉坑率极高。2.3 RTC 模块:考勤数据的"时间戳来源"考勤记录如果没有准确时间,那整个项目就没有意义。STM32 内部虽然有一个 RTC,但掉电后需要备用电源维持,而常见的 F103 芯片内部 RTC 精度一般,月误差可能以分钟计。我选了 DS3231 外部 RTC,它内置温度补偿晶振,年误差可以做到几分钟内,而且自带电池座,断电之后靠一颗 CR2032 就能继续走时。DS3231 是 I2C 接口,跟 STM32 连接只需要两根数据线加上地。CubeMX 里把 I2C1 使能,然后在初始化时把当前时间写进去,之后随时用读取函数拿时分秒。你需要注意一点:第一次烧录程序时设置时间,以后如果电池有电,重启后不需要重复设置;我是在程序里读了一个"时间已设置标志"存在 RTC 芯片内部另一个寄存器里,避免每次开机都重置时间。代码上,时间和日期的读取可以封装成两个结构体,写入记录时直接塞进格式化字符串,后面会用到。3. 搭建硬件平台:引脚、电源与电平匹配的关键细节原理图看懂了,面包板上手时会发现一堆问题:接线松了、电平不对、供电不够、串口发乱码。这个阶段我花了大概一个晚上,虽然没什么高深技术,但细节决定成败。3.1 硬件清单与引脚分配表我把自己的引脚分配贴出来,做成表格,方便照着抄:外设引脚说明指纹模块 TXPA3USART2_RX,接 STM32 的 RX 引脚,注意交叉指纹模块 RXPA2USART2_TX,接 STM32 的 TX 引脚OLED SDAPB7I2C1_SDAOLED SCLPB6I2C1_SCLDS3231 SDAPB7同一 I2C 总线,不同地址DS3231 SCLPB6同一 I2C 总线SD 卡 CSPA4SPI1_NSSSD 卡 SCKPA5SPI1_SCKSD 卡 MISOPA6SPI1_MISOSD 卡 MOSIPA7SPI1_MOSI按键 S1PB0下拉输入,单击进入菜单按键 S2PB1下拉输入,确认/删除蜂鸣器PB10推挽输出,驱动三极管OLED 和 DS3231 挂同一条 I2C 总线没问题,它们的设备地址不同,不会冲突。这两个模块都是 3.3V 和 5V 兼容的,我统一用了 3.3V 供电,省去电平转换。3.2 电源与串口电平匹配这是最容易翻车的地方。STM32F103 的供电和引脚电平是 3.3V,但很多指纹模块虽然逻辑电平也是 3.3V,却要求 5V 供电才能让光学传感器和 DSP 正常工作。所以我的用法是:指纹模块接 5V 电源,但它的 TX 引脚输出的是 3.3V 逻辑电平(很多模块内部带了电平转换,可以兼容 3.3V MCU),这时就可以直连 STM32 的 RX 引脚。如果你买的模块明确写着 TTL 电平 5V,那就不能直连了,否则一插上去单片机引脚就可能烧掉。稳妥的办法是加一个电平转换小板,或者在 RX 线上串 1k 电阻分压。我建议买模块时就看清楚参数,优先选"5V 供电,3.3V 通信"的版本,省事很多。另外指纹模块的峰值电流不小,尤其按下手指采集图像时,如果整块板都用电脑 USB 口供电,可能出现电流不够导致模块重启或者录取失败。我后来给系统加了一个 5V 2A 的独立电源,才彻底解决了"按下手指单片机重启"的诡异问题。这个小现象值得记下来:任何瞬时功耗大的外设,都要预留电源余量。3.3 通电测试:先让每个模块单独站起来我建议你不要一上来就把所有模块接好然后整体跑,否则出问题都不知道在哪。我的顺序是:先只接 STM32 最小系统,烧一个点灯程序,确认主控工作正常。接 OLED,显示一个字,确认 I2C 通不通。接指纹模块,用串口调试助手连模块,发一个查询系统参数指令,确认模块应答正常。接 DS3231,读取时间,确认数据能读出来。最后接 SD 卡,挂载文件系统,创建文件成功后再整合。每加一个模块就测一项,这样就算出问题,也能锁定在新增的环节上。不要嫌麻烦,这比整体调试快太多了。4. 软件架构与实现:状态机、指纹指令和考勤记录落盘硬件跑通后,剩下的工作就是写软件把各个模块串起来。这是最体现"从原理到实现"的部分,也是整个项目里代码量最大的地方。我没有把它写成一个大 while 循环里堆 if,而是用一个简单状态机来组织,这样后续加功能、修 Bug 都清晰得多。4.1 时钟、显示和按键的基本驱动项目在 STM32CubeIDE 里搭建,用 CubeMX 生成基础工程。我同时开了两个串口:USART1 接调试终端,USART2 接指纹模块。调试串口打印日志非常有用,比如每次串口收到原始数据时打印十六进制内容,可以快速判断协议解析哪里出了问题。这里分享一个习惯:任何串口接收都定义成"环形缓冲 + 空闲中断"的结构,不要在中断里做耗时的协议解析,只把字节放进缓冲,主循环去读。OLED 驱动的代码网上有很多成熟库,我直接选用现有驱动只改了 I2C 底层。显示逻辑也简单:主界面显示当前日期时间加一个"请按手指"提示;打卡成功后显示"工号 010 打卡成功"加时间;录入时显示"请放置手指(1/3)"这样的步骤。这里不细写显示驱动代码,重点说业务逻辑。每次从 RTC 读取时间时注意:DS3231 返回的是 BCD 码,需要转换。比如寄存器里读到 0x09,其实是 9 点。很多新手不看手册直接拿来用,结果显示乱码。4.2 指纹录入和搜索的流程设计录入指纹是考勤机的基础功能。我的录入流程如下:按键进入录入模式,输入一个指纹 ID(直接用整数作为工号)。模块执行"采集图像"指令。采集成功后再执行"生成特征"指令。重复采集两次,模块内部会合并生成一个更稳定的模板。执行"存储模板"指令,把这个 ID 存到模块指定编号。这里有个细节:指纹模板不是一次按下就存,一般要按三次,每次生成一个特征,模块会把多次特征组合以后存储,这样同一个手指再用时识别率更高。如果手指太干或者太湿,模块返回"质量低"错误,屏幕上提示用户重新按压。我把这个提示写得很友好:"换个姿势按,别太快"。搜索识别流程更简单:手指按下后,模块先采集图像生成特征,然后让模块在指纹库 0 到 N 范围内搜索,返回找到的编号和相似度。主控拿到编号后把它当成工号,再配上当前时间写入考勤记录。代码骨架大致如下:uint8_t enrollFinger(uint16_t userID) { // 三次采集 for (uint8_t i = 0; i < 3; i++) { delay(1200); uint8_t r = ps_getImage(); // 采集图像 if (r != 0) continue; r = ps_genChar(0x01); // 生成特征缓冲1 if (r != 0) continue; } // 合并特征 2/3,生成模板 // 存储模板,指定ID uint8_t r = ps_store(0x06, userID, true); return r; }这里用的ps_getImage、ps_genChar、ps_store等函数名是我从底层驱动封装出来的别名,真实对应模块手册里的指令码。封装这类函数时,要把返回的确认码统一处理成枚举值,比如 0 表示成功,其他错误码分别映射到"撮失败""图像质量低""库满"等,这样上层的 UI 提示就非常直观。4.3 考勤记录存储:选 SD 卡还是按内部 Flash这是我在项目里思考了很久的一个点。考勤记录本质是一堆"时间 + 工号"的数据,存到哪儿直接决定了后期导出的便利性。三个方案摆出来对比:存储方案优点缺点STM32 内部 Flash无需额外硬件,代码简单容量有限,频繁擦写伤寿命外部 SPI Flash W25Q32成本低,擦写速度快,适合存原始记录导出数据需要额外读逻辑,不方便电脑直接使用SD 卡 + FATFS容量几乎无限,拔卡直读 CSV调试麻烦,初始化可能踩坑,文件系统占用代码空间最终还是选了 SD 卡。因为我就想在电脑上直接用 Excel 打开考勤表,不想再做一套上位机导出工具。STM32F103 的 SPI 接口跑 SD 卡最高也就是 18MHz 左右,写一条十几字节的记录耗时在几十毫秒量级,对打卡这种低频操作完全能接受。FatFs 文件系统的移植不难,CubeMX 里把 SD 卡的 SPI 模式启用,然后在中间层实现disk_initialize、disk_read、disk_write几个函数即可。新版本 CubeMX 甚至可以直接生成 FatFs 的移植代码,省了很多事。写入记录的代码我这样写:FRESULT saveAttendance(uint16_t userID, RTC_DateTime_t dt) { FIL f; char line[64]; if (f_open(&f, "record.csv", FA_OPEN_APPEND) != FR_OK) { // 如果文件不存在,先创建并写表头 f_open(&f, "record.csv", FA_CREATE_NEW | FA_WRITE); f_printf(&f, "time,user_id\r\n"); f_close(&f); f_open(&f, "record.csv", FA_OPEN_APPEND); } f_printf(&f, "%04d-%02d-%02d %02d:%02d:%02d,%d\r\n", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, userID); f_close(&f); return FR_OK; }注意f_printf是 FatFs 的可选函数,需要在配置里把FF_FS_SUSTAINABLE之类的宏打开(实际上要开FF_USE_STRFUNC),否则不编译进去。打开以后,格式化字符串就要用 ASCII 模式,注意中文要转成 UTF-8 或者 GBK 编码,不然在电脑上看可能是乱码。我为了避免麻烦,CSV 全部用 ASCII,时间数字和逗号分隔,不存在中文,Excel 打开一点问题没有。4.4 主循环状态机:考勤机的"大脑"怎么运转整个程序的主循环不是从上到下一遍一遍跑,而是用一个枚举状态变量控制在几个状态之间跳转。我的简化状态定义如下:typedef enum { STATE_IDLE, // 空闲,等待操作 STATE_MENU, // 菜单(录入/清除/设置时间) STATE_ENROLL, // 录入指纹 STATE_DELETE, // 删除指纹 STATE_VERIFY, // 验证打卡 } AppState_t;主循环的逻辑可以概括为:扫描按键,根据当前状态调用对应的处理函数。比如空闲状态下如果检测到有手指按下(通过指纹模块的"检测到手指"指令或者直接尝试采集),就切换到STATE_VERIFY,执行搜索;搜索结果如果成功,蜂鸣器响一声,保存记录,回到空闲。状态机里最容易忽略的是"超时返回空闲"机制。比如用户按了录入键,但半天不按手指,程序不能永远卡在录入状态。我在每个需要用户操作的状态里加了一个超时计数器,比如 30 秒无操作自动回到STATE_IDLE,并放弃当前未完成的录入。另一个要点是"事件触发"而非"轮询扫描"的原则问题。对按键,我是这样处理的:在主循环里每 20 毫秒扫描一次按键引脚,检测到下降沿后做软件消抖(连续读两个周期电平一致),然后产生一个按键事件,这个事件只在状态机的适当分支里消费。如果某些动作耗时较长(比如 SD 卡写操作),不要放在中断或事件回调里同步执行,否则手指按下去后要卡几十毫秒才显示结果,体感很差。我推荐的做法是把要写入的数据放进一个队列,主循环空闲时消费队列,这样打卡显示几乎零延迟。整体主循环的伪代码如下:while (1) { handle_button_event(); // 处理按键消抖和事件分发 handle_timeout(); // 状态超时检查 handle_finger_logic(); // 状态机指纹操作 handle_display(); // 根据状态更新 OLED process_log_queue(); // 消费考勤记录写入 SD 卡 }写完之后整个项目的结构非常清楚:按键事件产生、状态机响应、业务逻辑执行、数据落盘、结果反馈。后续想加一个"打卡语音播报",只要在状态机出入口加调用即可。5. 实测与掉坑记录:稳定性问题和优化思路硬件软件都跑通,不等于稳定。我连续测试了三天,把日常使用中遇到的坑一个个记录下来,这里挑几个影响最大的展开说。5.1 指纹模块"识别不出"和"乱识别"的排查第一天测试时,自己录的指纹经常认不出来,偶尔还出现认错到别人的情况。我先是怀疑模块坏了,后来发现是指纹录入的质量太差。原因是录入阶段我没有强制采集三次指纹,偶尔按到第二次就存储了。指纹按压位置、力度不一样,特征模板覆盖不全,识别时找不到足够匹配的特征点。后来改成三次采集、合并模板,拒真率明显下降。还有一次奇怪的现象:录入时明明是一个手指,同一个手再按却返回另一个 ID 的匹配。排查后发现是录入时 ID 分配错误,我在ps_store传的参数把"指纹编号"和"用户 ID"混用了。每枚指纹在模块里有一个存储位置(0~N),而用户 ID 是我自定义的工号。我原来设想工号就是指纹编号,一个 ID 只能录一根指头。如果一个人需要录双手,就会冲突。这算不上硬件 Bug,但提醒我设计时要区分"存储槽位"和"业务工号"。后来我做了个改进:在 MCU 里维护一张映射表,一人分配多个槽位,映射到同一个用户 ID。也就是录左手录到槽位 5,录右手录到槽位 6,两个槽位都对应工号 010。这样就能多人多指了。虽然代码多了一百多行,但实用性好很多,也把 D 个模块自带的槽位用活了。5.2 严重影响稳定性的 SD 卡写入问题前面说到打卡后要写入 CSV,我用的是同步写。第一次测试时发现,每写一条记录,系统大概要卡 200 毫秒,如果刚好这时候有下一个手指按下来,指纹模块的采集就会超时,导致打卡失败。这很致命。排查后发现两个原因:一是 SD 卡在 SPI 模式下初始化后没有把时钟降下来,我用的是 18MHz,虽然理论上可行,但实际卡与卡兼容性差,有的卡就是会超时;二是写文件后调用f_sync强制回写,盘中做了一次长期的物理写操作。解决办法有三个:初始化时用低速(400kHz),挂载成功后切到 12MHz,读写时如果出错自动降频重试。打卡时先放进内存队列,主循环空闲时批量写入,写之前把状态机保持在空闲态。如果对可靠性要求高,可以在写入前检查 SD 卡是否插好,拔卡时直接把记录暂存在外部 Flash,等下次插卡再补写。我最终采用了"队列 + 批量刷写"的方式。屏幕上提示"打卡成功"是即时的,实际上数据还在内存队列里,200 毫秒后悄悄落到 SD 卡。如果拔卡了,就弹个警告音提示数据可能丢失。这个方案既保证体验,又避免频繁摩擦 SD 卡。5.3 时间丢失和电池耗尽因为用了 DS3231,断电倒是不掉时间,但如果纽扣电池没电,上电后时间会恢复到底部默认值,考勤记录就会出现一堆奇怪的时间。我在程序里加了一个"时间合法性检查":开机时读出的年份小于某个值就认为 RTC 没有有效备份,OLED 上就会闪烁"请设置时间",并要求按键设置。这算是一个典型的"用户引导兜底"逻辑。另外 DS3231 内部还有一个温度寄存器,可以读出来显示在待机界面上。这个功能很受同事欢迎,反正都是顺手。5.4 防"指纹套"等作弊场景的扩展思考做考勤机,很多人会问:能不能防别人拿一个指纹套替你打卡?坦率讲,纯光学指纹模块很容易被硅胶指套骗过,因为模块只采集二维图像,不做活体检测。几百块的模块都这样,除非上电容指纹带心率检测的型号,但成本就上去了。我的做法是在产品层面弥补:旁边加一个人体红外感应模块,打卡时红外必须先感应到人体,才允许指纹模块采集。这样就算有人拿指纹套去按,红外没检测到热释电信号,直接提示"未感应到活体"。这个方案成本低很多,防住了常见的物理替代作弊(当然如果对方把指纹套贴在手指上一起按,红外也能感应到人,那就没法了)。这个扩展再次说明做自己的项目的优势:想要什么功能,顺手就加,不用求厂商开放接口。5.5 从原型到成品化的几点经验原型在面包板上能跑,但要放到门口长期用,有几点必须改进。第一,把所有松动的地方换成杜邦线再加扎线带,或者干脆打一块 PCB,我用了一个周末画板发出去打样,回来焊好后稳定性提升明显。第二,供电要稳压,指纹模块瞬间电流大,用 AMS1117 线性稳压可能扛不住压降,最好用 DC-DC 降压或者 USB 5V 直供。第三,外壳要注意防静电和防潮,指纹识别窗口如果不干净,识别率会明显下降,定期用酒精擦拭最好。还有一点经验,程序里所有"魔法数字"都定义为宏或者枚举,比如超时时间、蜂鸣器音调、按键防抖次数。因为你这个项目做完以后多半会想改,如果没有集中定义,改一个超时结果要翻好几个文件才能找全。自己做考勤机到后来,最大的收获不是那台机器,而是对整个系统的理解。单看每个模块的示例都觉得简单,但把它们组合在一起的时候,问题到处都是:供电、时序、协议解析、文件系统、状态管理。这些问题每一个都不可怕,可怕的是没有条理地乱调。如果你也想动手做,我的建议是先按最简方案跑通 "打卡 + 存记录" 这个主链路,再慢慢加花哨功能。主链路通了,这台考勤机就真的能用了;后面加的菜单、多人多指、活体检测、温度显示,都是锦上添花。最后分享一个小技巧:把调试串口的日志级别分成"错误/警告/信息"三档,发布版本里关掉信息档,保留错误档,以后出问题能快速定位,又不至于被日志淹没。
我花了两个周末,用 STM32 和一块串口指纹模块,攒出了一台能录入指纹、打卡签到、自动记录考勤时间的考勤机。今天这篇文章就把从原理到实现的过程完整记录下来,包括硬件选型、指纹模块通信、软件状态机设计,以及我在调试中踩过的几个比较典型的坑。如果你也想做一台自己的指纹考勤机,或者单纯想练一下嵌入式从零到一的综合能力,这篇应该能给你省下不少时间。做这种项目有一个很大的好处:它不是单纯的跑马灯或者点个屏,而是把传感器采集、串口协议、RTC 时钟、数据存储、状态机逻辑全部串在了一起,麻雀虽小五脏俱全。而且完成后放在工作室门口实测打卡,那种成就感跟用开发板点灯完全不是一个量级。下面我按自己实际做项目的顺序来写,从需求拆解开始,到最后的稳定性和扩展性经验,尽量把每个环节背后的理由也讲清楚。1. 从零搭一台考勤机:需求、选型与系统总览考勤机这种东西,市售产品很多,几十块钱到几百块钱都有,但大多数人买回来都会遇到几个问题:数据导不出来、只支持固定几个人、不能改班次规则、UI 老旧。自己动手做的核心动机,不是省钱,而是完全掌控功能——比如我希望打卡后能立刻显示工号和打卡时间,能方便地导出 CSV 到电脑,还能把指纹库容量做到一个指纹模块本身能容纳的上限。1.1 考勤机到底需要哪些核心功能先别急着焊板子,把需求清单列出来,这是整个项目最重要的一步。我的目标功能是:录入新指纹:给一个工号,按三次手指,生成指纹模板。打卡识别:手指按上去,识别出是哪个人,记录当前时间。时间记录:必须有可靠的实时时钟,断电后时间不能丢。数据存储:考勤记录要存到文件里,能拔出来直接当电子表格用。简单交互:OLED 显示当前时间、提示信息,两个按键控制录入/删除等操作。这些功能并不复杂,但每一项背后都牵扯到具体的硬件和协议。比如"时间不能丢"就要求外挂 RTC 芯片和备份电池;"数据能导出"最方便的做法是 SD 卡加文件系统,而不是单片机内部 Flash。1.2 技术选型:为什么是 STM32 和串口指纹模块主控我选的是 STM32F103C8T6,原因很直白:便宜、资料多、性能足够。72MHz 主频跑一个考勤机富余得很,外设接口丰富——USART 可以跟指纹模块通信,SPI 接 SD 卡,I2C 接 RTC,GPIO 接按键和蜂鸣器,基本上一个芯片全包了。有人会问用 Arduino 不更简单?Arduino 确实开发快,但 STM32 的控制粒度、中断响应、低功耗管理都要强一截,而且 CubeMX 生成初始化代码后,实际写的业务逻辑也不算多。指纹模块我选了市面上最常见的 AS608 型光学指纹模块。这类模块内置了 DSP 芯片,自己完成指纹图像的采集、特征提取和匹配计算,对外只提供一串串口指令。这大大降低了开发门槛——我们不需要懂什么脊线、谷线、FAR/FRR 这些底层的图像处理算法,只需要按照模块协议发指令包,接收返回结果就行。其他部件也顺便列一下:0.96 寸 OLED 屏(I2C 接口),DS3231 RTC 模块(带电池座),SPI 接口的 Micro SD 卡模块,两个按键加一个蜂鸣器。整套下来成本不超过一百块,比自己买个成品还便宜,还能享受定制乐趣。2. 指纹模块通信与 RTC 时间基准:让硬件先"说上话"选好了件,下一步不是直接接三根线,而是先把两块核心硬件的工作原理搞清楚:指纹模块怎么返回识别结果,RTC 怎么提供准确时间。这两块决定了整个软件架构。2.1 指纹识别的本质:模块替你做完了重活指纹识别的底层原理说复杂很复杂,说简单也简单。手指按在光学传感器上,表面凹凸不平的脊线谷线会形成明暗条纹,传感器把这些条纹拍成图像,然后算法从图像里提取出特征点——就是纹路的分岔点、端点这些东西。每次录入指纹时会生成一个若干个特征点的模板,之后识别就是拿新采集到的特征和库里所有模板比对,算相似度,超过阈值就认为匹配成功。AS608 模块之所以好用,是因为它把上述过程全部封装了。模块内部有一个小指纹库,最多能存 200 到 1000 枚指纹(具体看模块容量)。我们发给它的指令不外乎这几类:采集图像、生成特征、存储模板、搜索匹配、删除模板。模块执行完返回一个确认码,我们只需要解析确认码就行。拿"搜索匹配"来举例:模块先把手指图像生成特征放入缓冲区,然后执行搜索指令,指定从哪个编号搜到哪个编号,找到后返回匹配到的编号和分数。这样主控端的逻辑就变得很单纯——发指令,收结果,处理结果。2.2 串口指令包的构成和校验这类模块的串口协议一般是自定义帧格式。以 AS608 为例,每个指令包由以下几个部分组成:帧头固定为 0xEF01 两个字节。芯片地址默认 4 字节,通常全 FF。包标识字节:01 表示指令包,07 表示应答包。包长度:包括包标识、指令码、参数和校验和在内的字节总数。指令码和参数。校验和:从包标识开始到内容最后一个字节的累加和。这里最容易出错的就是校验和。我第一次直接拿上位机软件发指令没问题,换成单片机写就总是返回 FF(无应答),后来发现是校验和算重了,把帧头也加进去了。正确做法是只累加帧头以后的内容。下面是我自己封装的指令包发送函数骨架,思路其实比细节更重要:// 指令包发送函数简化版 // pkg: 指向除帧头(0xEF01)之外的数据起始位置 void sendPacket(uint8_t *body, uint8_t len) { uint8_t packet[len + 7]; packet[0] = 0xEF; packet[1] = 0x01; packet[2] = 0xFF; packet[3] = 0xFF; packet[4] = 0xFF; packet[5] = 0xFF; packet[6] = 0x01; // 包标识:指令包 memcpy(packet + 7, body, len); uint16_t sum = 0; for (uint8_t i = 6; i < len + 7; i++) // 从包标识开始累加 sum += packet[i]; packet[len + 7] = (sum >> 8) & 0xFF; packet[len + 8] = sum & 0xFF; HAL_UART_Transmit(&huart2, packet, len + 9, 1000); }你在写自己的代码时,具体的指令码和包结构要对着模块手册核对,我这个只是示意结构。重点是要理解"校验和从哪里开始算到啊"这个细节,掉坑率极高。2.3 RTC 模块:考勤数据的"时间戳来源"考勤记录如果没有准确时间,那整个项目就没有意义。STM32 内部虽然有一个 RTC,但掉电后需要备用电源维持,而常见的 F103 芯片内部 RTC 精度一般,月误差可能以分钟计。我选了 DS3231 外部 RTC,它内置温度补偿晶振,年误差可以做到几分钟内,而且自带电池座,断电之后靠一颗 CR2032 就能继续走时。DS3231 是 I2C 接口,跟 STM32 连接只需要两根数据线加上地。CubeMX 里把 I2C1 使能,然后在初始化时把当前时间写进去,之后随时用读取函数拿时分秒。你需要注意一点:第一次烧录程序时设置时间,以后如果电池有电,重启后不需要重复设置;我是在程序里读了一个"时间已设置标志"存在 RTC 芯片内部另一个寄存器里,避免每次开机都重置时间。代码上,时间和日期的读取可以封装成两个结构体,写入记录时直接塞进格式化字符串,后面会用到。3. 搭建硬件平台:引脚、电源与电平匹配的关键细节原理图看懂了,面包板上手时会发现一堆问题:接线松了、电平不对、供电不够、串口发乱码。这个阶段我花了大概一个晚上,虽然没什么高深技术,但细节决定成败。3.1 硬件清单与引脚分配表我把自己的引脚分配贴出来,做成表格,方便照着抄:外设引脚说明指纹模块 TXPA3USART2_RX,接 STM32 的 RX 引脚,注意交叉指纹模块 RXPA2USART2_TX,接 STM32 的 TX 引脚OLED SDAPB7I2C1_SDAOLED SCLPB6I2C1_SCLDS3231 SDAPB7同一 I2C 总线,不同地址DS3231 SCLPB6同一 I2C 总线SD 卡 CSPA4SPI1_NSSSD 卡 SCKPA5SPI1_SCKSD 卡 MISOPA6SPI1_MISOSD 卡 MOSIPA7SPI1_MOSI按键 S1PB0下拉输入,单击进入菜单按键 S2PB1下拉输入,确认/删除蜂鸣器PB10推挽输出,驱动三极管OLED 和 DS3231 挂同一条 I2C 总线没问题,它们的设备地址不同,不会冲突。这两个模块都是 3.3V 和 5V 兼容的,我统一用了 3.3V 供电,省去电平转换。3.2 电源与串口电平匹配这是最容易翻车的地方。STM32F103 的供电和引脚电平是 3.3V,但很多指纹模块虽然逻辑电平也是 3.3V,却要求 5V 供电才能让光学传感器和 DSP 正常工作。所以我的用法是:指纹模块接 5V 电源,但它的 TX 引脚输出的是 3.3V 逻辑电平(很多模块内部带了电平转换,可以兼容 3.3V MCU),这时就可以直连 STM32 的 RX 引脚。如果你买的模块明确写着 TTL 电平 5V,那就不能直连了,否则一插上去单片机引脚就可能烧掉。稳妥的办法是加一个电平转换小板,或者在 RX 线上串 1k 电阻分压。我建议买模块时就看清楚参数,优先选"5V 供电,3.3V 通信"的版本,省事很多。另外指纹模块的峰值电流不小,尤其按下手指采集图像时,如果整块板都用电脑 USB 口供电,可能出现电流不够导致模块重启或者录取失败。我后来给系统加了一个 5V 2A 的独立电源,才彻底解决了"按下手指单片机重启"的诡异问题。这个小现象值得记下来:任何瞬时功耗大的外设,都要预留电源余量。3.3 通电测试:先让每个模块单独站起来我建议你不要一上来就把所有模块接好然后整体跑,否则出问题都不知道在哪。我的顺序是:先只接 STM32 最小系统,烧一个点灯程序,确认主控工作正常。接 OLED,显示一个字,确认 I2C 通不通。接指纹模块,用串口调试助手连模块,发一个查询系统参数指令,确认模块应答正常。接 DS3231,读取时间,确认数据能读出来。最后接 SD 卡,挂载文件系统,创建文件成功后再整合。每加一个模块就测一项,这样就算出问题,也能锁定在新增的环节上。不要嫌麻烦,这比整体调试快太多了。4. 软件架构与实现:状态机、指纹指令和考勤记录落盘硬件跑通后,剩下的工作就是写软件把各个模块串起来。这是最体现"从原理到实现"的部分,也是整个项目里代码量最大的地方。我没有把它写成一个大 while 循环里堆 if,而是用一个简单状态机来组织,这样后续加功能、修 Bug 都清晰得多。4.1 时钟、显示和按键的基本驱动项目在 STM32CubeIDE 里搭建,用 CubeMX 生成基础工程。我同时开了两个串口:USART1 接调试终端,USART2 接指纹模块。调试串口打印日志非常有用,比如每次串口收到原始数据时打印十六进制内容,可以快速判断协议解析哪里出了问题。这里分享一个习惯:任何串口接收都定义成"环形缓冲 + 空闲中断"的结构,不要在中断里做耗时的协议解析,只把字节放进缓冲,主循环去读。OLED 驱动的代码网上有很多成熟库,我直接选用现有驱动只改了 I2C 底层。显示逻辑也简单:主界面显示当前日期时间加一个"请按手指"提示;打卡成功后显示"工号 010 打卡成功"加时间;录入时显示"请放置手指(1/3)"这样的步骤。这里不细写显示驱动代码,重点说业务逻辑。每次从 RTC 读取时间时注意:DS3231 返回的是 BCD 码,需要转换。比如寄存器里读到 0x09,其实是 9 点。很多新手不看手册直接拿来用,结果显示乱码。4.2 指纹录入和搜索的流程设计录入指纹是考勤机的基础功能。我的录入流程如下:按键进入录入模式,输入一个指纹 ID(直接用整数作为工号)。模块执行"采集图像"指令。采集成功后再执行"生成特征"指令。重复采集两次,模块内部会合并生成一个更稳定的模板。执行"存储模板"指令,把这个 ID 存到模块指定编号。这里有个细节:指纹模板不是一次按下就存,一般要按三次,每次生成一个特征,模块会把多次特征组合以后存储,这样同一个手指再用时识别率更高。如果手指太干或者太湿,模块返回"质量低"错误,屏幕上提示用户重新按压。我把这个提示写得很友好:"换个姿势按,别太快"。搜索识别流程更简单:手指按下后,模块先采集图像生成特征,然后让模块在指纹库 0 到 N 范围内搜索,返回找到的编号和相似度。主控拿到编号后把它当成工号,再配上当前时间写入考勤记录。代码骨架大致如下:uint8_t enrollFinger(uint16_t userID) { // 三次采集 for (uint8_t i = 0; i < 3; i++) { delay(1200); uint8_t r = ps_getImage(); // 采集图像 if (r != 0) continue; r = ps_genChar(0x01); // 生成特征缓冲1 if (r != 0) continue; } // 合并特征 2/3,生成模板 // 存储模板,指定ID uint8_t r = ps_store(0x06, userID, true); return r; }这里用的ps_getImage、ps_genChar、ps_store等函数名是我从底层驱动封装出来的别名,真实对应模块手册里的指令码。封装这类函数时,要把返回的确认码统一处理成枚举值,比如 0 表示成功,其他错误码分别映射到"撮失败""图像质量低""库满"等,这样上层的 UI 提示就非常直观。4.3 考勤记录存储:选 SD 卡还是按内部 Flash这是我在项目里思考了很久的一个点。考勤记录本质是一堆"时间 + 工号"的数据,存到哪儿直接决定了后期导出的便利性。三个方案摆出来对比:存储方案优点缺点STM32 内部 Flash无需额外硬件,代码简单容量有限,频繁擦写伤寿命外部 SPI Flash W25Q32成本低,擦写速度快,适合存原始记录导出数据需要额外读逻辑,不方便电脑直接使用SD 卡 + FATFS容量几乎无限,拔卡直读 CSV调试麻烦,初始化可能踩坑,文件系统占用代码空间最终还是选了 SD 卡。因为我就想在电脑上直接用 Excel 打开考勤表,不想再做一套上位机导出工具。STM32F103 的 SPI 接口跑 SD 卡最高也就是 18MHz 左右,写一条十几字节的记录耗时在几十毫秒量级,对打卡这种低频操作完全能接受。FatFs 文件系统的移植不难,CubeMX 里把 SD 卡的 SPI 模式启用,然后在中间层实现disk_initialize、disk_read、disk_write几个函数即可。新版本 CubeMX 甚至可以直接生成 FatFs 的移植代码,省了很多事。写入记录的代码我这样写:FRESULT saveAttendance(uint16_t userID, RTC_DateTime_t dt) { FIL f; char line[64]; if (f_open(&f, "record.csv", FA_OPEN_APPEND) != FR_OK) { // 如果文件不存在,先创建并写表头 f_open(&f, "record.csv", FA_CREATE_NEW | FA_WRITE); f_printf(&f, "time,user_id\r\n"); f_close(&f); f_open(&f, "record.csv", FA_OPEN_APPEND); } f_printf(&f, "%04d-%02d-%02d %02d:%02d:%02d,%d\r\n", dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, userID); f_close(&f); return FR_OK; }注意f_printf是 FatFs 的可选函数,需要在配置里把FF_FS_SUSTAINABLE之类的宏打开(实际上要开FF_USE_STRFUNC),否则不编译进去。打开以后,格式化字符串就要用 ASCII 模式,注意中文要转成 UTF-8 或者 GBK 编码,不然在电脑上看可能是乱码。我为了避免麻烦,CSV 全部用 ASCII,时间数字和逗号分隔,不存在中文,Excel 打开一点问题没有。4.4 主循环状态机:考勤机的"大脑"怎么运转整个程序的主循环不是从上到下一遍一遍跑,而是用一个枚举状态变量控制在几个状态之间跳转。我的简化状态定义如下:typedef enum { STATE_IDLE, // 空闲,等待操作 STATE_MENU, // 菜单(录入/清除/设置时间) STATE_ENROLL, // 录入指纹 STATE_DELETE, // 删除指纹 STATE_VERIFY, // 验证打卡 } AppState_t;主循环的逻辑可以概括为:扫描按键,根据当前状态调用对应的处理函数。比如空闲状态下如果检测到有手指按下(通过指纹模块的"检测到手指"指令或者直接尝试采集),就切换到STATE_VERIFY,执行搜索;搜索结果如果成功,蜂鸣器响一声,保存记录,回到空闲。状态机里最容易忽略的是"超时返回空闲"机制。比如用户按了录入键,但半天不按手指,程序不能永远卡在录入状态。我在每个需要用户操作的状态里加了一个超时计数器,比如 30 秒无操作自动回到STATE_IDLE,并放弃当前未完成的录入。另一个要点是"事件触发"而非"轮询扫描"的原则问题。对按键,我是这样处理的:在主循环里每 20 毫秒扫描一次按键引脚,检测到下降沿后做软件消抖(连续读两个周期电平一致),然后产生一个按键事件,这个事件只在状态机的适当分支里消费。如果某些动作耗时较长(比如 SD 卡写操作),不要放在中断或事件回调里同步执行,否则手指按下去后要卡几十毫秒才显示结果,体感很差。我推荐的做法是把要写入的数据放进一个队列,主循环空闲时消费队列,这样打卡显示几乎零延迟。整体主循环的伪代码如下:while (1) { handle_button_event(); // 处理按键消抖和事件分发 handle_timeout(); // 状态超时检查 handle_finger_logic(); // 状态机指纹操作 handle_display(); // 根据状态更新 OLED process_log_queue(); // 消费考勤记录写入 SD 卡 }写完之后整个项目的结构非常清楚:按键事件产生、状态机响应、业务逻辑执行、数据落盘、结果反馈。后续想加一个"打卡语音播报",只要在状态机出入口加调用即可。5. 实测与掉坑记录:稳定性问题和优化思路硬件软件都跑通,不等于稳定。我连续测试了三天,把日常使用中遇到的坑一个个记录下来,这里挑几个影响最大的展开说。5.1 指纹模块"识别不出"和"乱识别"的排查第一天测试时,自己录的指纹经常认不出来,偶尔还出现认错到别人的情况。我先是怀疑模块坏了,后来发现是指纹录入的质量太差。原因是录入阶段我没有强制采集三次指纹,偶尔按到第二次就存储了。指纹按压位置、力度不一样,特征模板覆盖不全,识别时找不到足够匹配的特征点。后来改成三次采集、合并模板,拒真率明显下降。还有一次奇怪的现象:录入时明明是一个手指,同一个手再按却返回另一个 ID 的匹配。排查后发现是录入时 ID 分配错误,我在ps_store传的参数把"指纹编号"和"用户 ID"混用了。每枚指纹在模块里有一个存储位置(0~N),而用户 ID 是我自定义的工号。我原来设想工号就是指纹编号,一个 ID 只能录一根指头。如果一个人需要录双手,就会冲突。这算不上硬件 Bug,但提醒我设计时要区分"存储槽位"和"业务工号"。后来我做了个改进:在 MCU 里维护一张映射表,一人分配多个槽位,映射到同一个用户 ID。也就是录左手录到槽位 5,录右手录到槽位 6,两个槽位都对应工号 010。这样就能多人多指了。虽然代码多了一百多行,但实用性好很多,也把 D 个模块自带的槽位用活了。5.2 严重影响稳定性的 SD 卡写入问题前面说到打卡后要写入 CSV,我用的是同步写。第一次测试时发现,每写一条记录,系统大概要卡 200 毫秒,如果刚好这时候有下一个手指按下来,指纹模块的采集就会超时,导致打卡失败。这很致命。排查后发现两个原因:一是 SD 卡在 SPI 模式下初始化后没有把时钟降下来,我用的是 18MHz,虽然理论上可行,但实际卡与卡兼容性差,有的卡就是会超时;二是写文件后调用f_sync强制回写,盘中做了一次长期的物理写操作。解决办法有三个:初始化时用低速(400kHz),挂载成功后切到 12MHz,读写时如果出错自动降频重试。打卡时先放进内存队列,主循环空闲时批量写入,写之前把状态机保持在空闲态。如果对可靠性要求高,可以在写入前检查 SD 卡是否插好,拔卡时直接把记录暂存在外部 Flash,等下次插卡再补写。我最终采用了"队列 + 批量刷写"的方式。屏幕上提示"打卡成功"是即时的,实际上数据还在内存队列里,200 毫秒后悄悄落到 SD 卡。如果拔卡了,就弹个警告音提示数据可能丢失。这个方案既保证体验,又避免频繁摩擦 SD 卡。5.3 时间丢失和电池耗尽因为用了 DS3231,断电倒是不掉时间,但如果纽扣电池没电,上电后时间会恢复到底部默认值,考勤记录就会出现一堆奇怪的时间。我在程序里加了一个"时间合法性检查":开机时读出的年份小于某个值就认为 RTC 没有有效备份,OLED 上就会闪烁"请设置时间",并要求按键设置。这算是一个典型的"用户引导兜底"逻辑。另外 DS3231 内部还有一个温度寄存器,可以读出来显示在待机界面上。这个功能很受同事欢迎,反正都是顺手。5.4 防"指纹套"等作弊场景的扩展思考做考勤机,很多人会问:能不能防别人拿一个指纹套替你打卡?坦率讲,纯光学指纹模块很容易被硅胶指套骗过,因为模块只采集二维图像,不做活体检测。几百块的模块都这样,除非上电容指纹带心率检测的型号,但成本就上去了。我的做法是在产品层面弥补:旁边加一个人体红外感应模块,打卡时红外必须先感应到人体,才允许指纹模块采集。这样就算有人拿指纹套去按,红外没检测到热释电信号,直接提示"未感应到活体"。这个方案成本低很多,防住了常见的物理替代作弊(当然如果对方把指纹套贴在手指上一起按,红外也能感应到人,那就没法了)。这个扩展再次说明做自己的项目的优势:想要什么功能,顺手就加,不用求厂商开放接口。5.5 从原型到成品化的几点经验原型在面包板上能跑,但要放到门口长期用,有几点必须改进。第一,把所有松动的地方换成杜邦线再加扎线带,或者干脆打一块 PCB,我用了一个周末画板发出去打样,回来焊好后稳定性提升明显。第二,供电要稳压,指纹模块瞬间电流大,用 AMS1117 线性稳压可能扛不住压降,最好用 DC-DC 降压或者 USB 5V 直供。第三,外壳要注意防静电和防潮,指纹识别窗口如果不干净,识别率会明显下降,定期用酒精擦拭最好。还有一点经验,程序里所有"魔法数字"都定义为宏或者枚举,比如超时时间、蜂鸣器音调、按键防抖次数。因为你这个项目做完以后多半会想改,如果没有集中定义,改一个超时结果要翻好几个文件才能找全。自己做考勤机到后来,最大的收获不是那台机器,而是对整个系统的理解。单看每个模块的示例都觉得简单,但把它们组合在一起的时候,问题到处都是:供电、时序、协议解析、文件系统、状态管理。这些问题每一个都不可怕,可怕的是没有条理地乱调。如果你也想动手做,我的建议是先按最简方案跑通 "打卡 + 存记录" 这个主链路,再慢慢加花哨功能。主链路通了,这台考勤机就真的能用了;后面加的菜单、多人多指、活体检测、温度显示,都是锦上添花。最后分享一个小技巧:把调试串口的日志级别分成"错误/警告/信息"三档,发布版本里关掉信息档,保留错误档,以后出问题能快速定位,又不至于被日志淹没。