最近把吃灰的STM32F103C8T6最小系统板和AS608指纹模块翻出来重新调了一遍,指纹录入、识别、删除这一整套流程终于跑通,也顺手整理成了一套可以复用的代码。这套组合在国内做智能门锁、考勤机、保险柜相关课设和毕设的人里几乎是标配:主控便宜、模块也便宜,一个项目做下来,串口通信、协议解析、状态机设计这些基本功全都能练到。如果你正准备做类似项目,或者刚接触STM32想找一个“做完有成就感的实战”,这篇文章非常适合你。我会把方案选型、硬件接线、AS608的串口协议、完整代码流程和排障心得都摊开讲清楚,照着做基本能一次点亮。
1. 项目设计思路:为什么这套组合值得做
1.1 主控选型:不是越贵越好
先说说为什么选STM32F103C8T6。这颗芯片是Cortex-M3内核,最高主频72MHz,片上Flash 64KB、SRAM 20KB,价格在量产和零售端都压得极低,一块最小系统板十几块钱就能买到。对于指纹锁这类项目来说,算力需求其实不高,因为指纹图像处理和特征匹配算法全部由指纹模块内部完成,主控只需要“发指令、收结果、做交互”,所以这种级别的MCU完全够用。
还有一个很现实的原因:资料多、生态成熟。从寄存器标准库到HAL库,从Keil到IAR,网上随便一搜就是大把例程。真要遇到问题,你几乎不可能搜不到别人踩过的坑。另外,如果你在做量产项目,STM32F103C8T6的国产替代方案也非常成熟,GD32F103C8T6、HK32F103C8T6之类的芯片可以直接替换这颗料,串口、GPIO这部分完全兼容,工程上基本能做到换芯片不改代码、重新编译一次就过。当然国产替代芯片在USB、ADC等外设上有些细节差异,但本项目的USART应用不受影响。
1.2 AS608 指纹模块:把算法做成“黑盒”
AS608是一个光学指纹模块,内部集成了光路、传感器和一颗DSP指纹处理芯片。这颗DSP出厂就固化了完整的指纹算法,包括图像采集、特征提取、模板合成、1:N搜索比对,全部在模块内部完成。对于开发者来说,它就是一个“指纹黑盒”:你通过串口告诉它“采集指纹”“生成特征”“合成模板”“存储模板”,它就把活干完,再把结果通过串口回复给你。
这也是这套方案最大的价值点:你不用懂指纹识别算法,却能在一天之内做出一个能实际运行的门锁原型。如果你去啃算法,光是特征点提取和比对那一层就足够你研究大半年。AS608帮你把最难的算法层全部封装起来了,你要做的核心工作就是会写串口指令、会解析应答帧、会设计一套状态机。
目前市面上同类型的模块还有R307、ZW101等,不少模块的指令协议和AS608基本同源,都是那套0xEF01开头的帧格式。所以你把AS608调通了,换其他模块的迁移成本很小,顶多就是波特率和地址参数改一改。
1.3 系统工作流程怎么设计
整个项目的功能拆成三个模式:录入模式、识别模式、删除模式,用两个按键循环切换。录入模式是让用户按两次手指,模块采集两次图像、生成同一枚指纹的模板并存入指定ID;识别模式是按压一次手指,模块从指纹库中搜索匹配,返回匹配到的ID和得分;删除模式是按ID删除单枚指纹或者对整个指纹库执行清空。
显示交互方面,最简单的方案是一块OLED屏幕加一个蜂鸣器,OLED显示“请按手指”“录入成功”“验证通过”这些状态,蜂鸣器做成功/失败的提示音。如果你手头只有LED,也能凑合,但OLED会让调试效率高不少,串口打印当然也能做,不过做成产品原型还是有屏幕更直观。
状态机的核心思想是:主循环里用一个状态变量记录当前模式,按键触发模式切换;每个模式下调用对应的AS608指令序列,并把模块返回的错误码翻译成人话显示出来。这个结构很简单,但拓展性很好,后面要加FreeRTOS、加低功耗、加入网模块都不需要推翻重写。
2. AS608 串口协议与指令细节
2.1 串口参数与数据包格式
先立一个前提:AS608模块的UART默认参数,绝大多数是57600bps、8位数据、1位停止位、无校验。但我必须提醒一句,某些批次或者某些店铺定制的模块默认波特率是9600,而且厂家一般会把模块背面贴一张小标签写明波特率。我手上这块贴的是“57600”,如果你通信不上,第一反应就去看标签,而不是去怀疑代码。
模块与MCU之间通讯采用的是固定帧格式,每一帧都长这样:2字节包头 + 6字节地址 + 1字节包标识 + 2字节包长度 + N字节数据 + 2字节校验和。
包头固定是0xEF 0x01。地址默认是0xFF 0xFF 0xFF 0xFF 0xFF 0xFF,这是一级地址,绝大多数场景不需要改。包标识有几种:0x01是命令包,就是MCU发给模块的指令;0x02是数据包,0x08是结束包,用于上传下载指纹特征这种大数据传输的场景;0x07是应答包,是模块回复给MCU的结果。长度字段是高字节在前,它表示的是“包标识之后、校验和之前”那部分数据的字节数,也就是指令码加参数的总长度。
举个例子,你要发送“采集指纹”指令,也就是GenImg,指令码是0x01,没有参数。那么长度字段就是0x00 0x01。整帧就是:EF 01 FF FF FF FF FF FF FF 01 00 01 01 校验和高字节 校验和低字节。这个帧结构虽然简单,但每个字节都不能错,尤其是长度和校验和,错了模块直接不回包。
2.2 校验和怎么算
校验和模块官方定义是:从包标识到数据段最后一个字节的所有字节,累加后取低16位。注意,包头和地址不算,也就是从包标识那一个字节开始累加。
我直接用C代码写给你:
uint16_t checksum = 0; uint16_t packageLen = 1 + paramLen; // 指令码 + 参数长度 checksum += 0x01; // 包标识 checksum += (packageLen >> 8) & 0xFF; checksum += packageLen & 0xFF; checksum += cmd; // 指令码 for (uint16_t i = 0; i < paramLen; i++) { checksum += param[i]; }校验和的两字节也是高字节在前。很多人在这一步翻车,不是忘了把包标识算进去,就是忘了加长度字段。校验和不正确,模块收包后直接判为错误帧,要么不回包,要么回一个确认码0x01表示“收包出错”。
2.3 核心指令与错误码
AS608的常用指令其实就那几条,建议先把下面这些背下来:
| 指令码 | 名称 | 功能 | 参数说明 |
|---|---|---|---|
| 0x01 | GenImg | 采集指纹图像 | 无 |
| 0x02 | Img2Tz | 将图像生成特征存到Buffer | 1字节BufferID,取0x01或0x02 |
| 0x03 | RegModel | 合并Buffer1和Buffer2生成模板 | 无 |
| 0x04 | Store | 存储模板到Flash | 1字节BufferID + 2字节PageID |
| 0x05 | Search | 在指纹库中搜索匹配 | 1字节BufferID + 2字节起始页 + 2字节搜索数量 |
| 0x08 | DeleteChar | 删除模板 | 2字节起始页 + 2字节数量 |
| 0x09 | Empty | 清空整个指纹库 | 8字节确认口令 |
应答帧里,确认码通常是2个字节,0x00 0x00表示成功。完整的确认码含义如下表,排查问题时要学会对着这个表看:
| 确认码 | 含义 |
|---|---|
| 0x00 | 成功 |
| 0x01 | 数据包接收错误 |
| 0x02 | 传感器上没有手指 |
| 0x03 | 录入指纹图像失败 |
| 0x04 | 指纹太干或太淡 |
| 0x05 | 指纹太湿或太模糊 |
| 0x06 | 指纹图像过于杂乱 |
| 0x07 | 特征点太少 |
| 0x08 | 指纹不匹配 |
| 0x09 | 搜索不到指纹 |
| 0x0A | 特征合并失败 |
| 0x0B | 访问页号超范围 |
| 0x0D | 指纹库已满 |
| 0x0E | 写入Flash失败 |
有一点我想多说两句:不同批次的模块对个别错误码的定义会有细微差异,所以我建议你收到模块后先把厂家给的协议手册保存好,以手册为准。
3. 硬件接线与工程配置
3.1 接线表与供电注意
接线是所有工作的基础,AS608和STM32F103C8T6之间只需要四根线:
| AS608引脚 | 连接目标 | 说明 |
|---|---|---|
| VCC | 3.3V或5V(按模块丝印) | AS608常见为3.3V供电,个别版本自带稳压可接5V |
| GND | GND | 必须与STM32共地 |
| TXD | PA10(USART1_RX) | 模块发送,单片机接收 |
| RXD | PA9(USART1_TX) | 单片机发送,模块接收 |
串口连接必须交叉,这是个老生常谈但还是很多人犯的错误:模块的TX要接单片机的RX,模块的RX要接单片机的TX,我在调试时亲眼见过有人把两根线接成直连,结果收发都不通,还以为是波特率错了。
供电这一点值得单独强调。AS608工作时电流并不小,指纹头内部有LED光源和传感器,工作电流大致在60mA左右,瞬间峰值可能更高。如果你的STM32最小系统板是用USB口供电,再额外从板子的3.3V引脚引电给AS608,有时候会因为板上LDO电流不够、纹波太大导致指纹采集异常。比较稳妥的做法是:AS608的VCC独立走一组电源,比如主板5V经过单独的AMS1117-3.3转出来给它供电,或者直接用质量好一点的USB线给开发板供电,然后把模块VCC接在板子的5V引脚(前提是你的模块支持5V输入)。
3.2 工程环境与下载方式
开发环境用Keil MDK最多,IAR也行,HAL库和标准库都能完成这个项目。我这次用的是Keil MDK 5 + HAL库,原因是HAL库的串口收发封装比较完整,代码量少,适合快速验证。如果你之前跟的是标准库教程,也没问题,逻辑完全相同,只是函数名不一样。
下载调试推荐用DAP-Link,也就是网上很常见的“ARM DAPLink”下载器,SWD四根线就能搞定:SWDIO、SWCLK、GND、3V3。ST-Link也同样可以。如果你做的是量产门锁固件,还想保护一下Flash里的程序,可以在Keil里配置Option Bytes把读保护级别设到Level 1,这样别人通过SWD只能整体擦除,不能直接把固件读出来。STM32F103C8T6的读保护功能是硬件自带的,纯软件层面无法完全防止被读,这个小细节适合有产品化需求的场景,课设阶段就不用折腾了。
3.3 指纹模块的安装与稳定性细节
指纹头的排线是个隐蔽的坑。AS608模块的传感器和主控板之间通常有一根软排线,出厂时可能没插到底,或者运输过程中松了。如果你发现模块上电后串口能通但采集图像总是失败,先别急着改代码,把排线拔下来重新插一下,听到卡扣“咔哒”一声才算到位。
指纹识别对电源纹波和静电比较敏感,我自己的习惯是在模块VCC和GND之间就近焊一颗100uF电解电容,再并联一颗0.1uF陶瓷电容,实测下来图像采集成功率会高不少。模块的指纹窗口也要保持干净,手上沾了油污再按上去,生成的特征明显会差,指纹头表面脏了用软布蘸少量酒精轻轻擦,不要用硬物刮。
4. 核心代码实现:录入、识别、删除全流程
4.1 串口指令收发封装
代码层面的第一步是封装发送命令和接收应答的函数。发送函数的核心就是按帧格式组包,然后通过串口一次性发出去。我用HAL库做了这样一个封装:
void AS608_SendCmd(uint8_t cmd, uint8_t *param, uint16_t paramLen) { uint8_t buf[64]; uint16_t idx = 0; uint16_t packageLen = 1 + paramLen; uint16_t sum = 0; buf[idx++] = 0xEF; // 包头 buf[idx++] = 0x01; for (uint8_t i = 0; i < 6; i++) buf[idx++] = 0xFF; // 地址 buf[idx++] = 0x01; // 命令包标识 buf[idx++] = (packageLen >> 8) & 0xFF; buf[idx++] = packageLen & 0xFF; buf[idx++] = cmd; // 指令码 sum = 0x01; sum += (packageLen >> 8) & 0xFF; sum += packageLen & 0xFF; sum += cmd; for (uint16_t i = 0; i < paramLen; i++) { buf[idx++] = param[i]; sum += param[i]; } buf[idx++] = (sum >> 8) & 0xFF; buf[idx++] = sum & 0xFF; HAL_UART_Transmit(&huart1, buf, idx, 200); }接收应答的时候要注意,应答帧的长度不是固定的。比如“搜索指纹”成功时,应答帧除了2字节确认码还会带2字节页码、2字节得分,总数据长度是6字节,而普通指令的应答只有2字节确认码。所以接收函数必须先读固定长度的帧头,解析出长度字段,再根据长度收剩下的字节:
uint8_t AS608_WaitResponse(uint8_t *respData, uint16_t timeout) { uint8_t header[11]; uint8_t buf[64]; uint16_t dataLen, sum = 0; if (HAL_UART_Receive(&huart1, header, 11, timeout) != HAL_OK) return 0; // 简单校验:包头、包标识 if (header[0] != 0xEF || header[1] != 0x01) return 0; if (header[8] != 0x07) return 0; dataLen = (header[9] << 8) | header[10]; if (dataLen > 60) return 0; if (HAL_UART_Receive(&huart1, buf, dataLen + 2, timeout) != HAL_OK) return 0; // 累加校验,从包标识到数据段 sum = 0x07; sum += header[9]; sum += header[10]; for (uint16_t i = 0; i < dataLen + 2; i++) sum += buf[i]; // 这里的低16位如果为0,表示校验通过 // 把确认码和数据拷贝给上层使用 if (respData != NULL) { for (uint16_t i = 0; i < dataLen; i++) respData[i] = buf[i]; } return 1; }这里有个调试经验:HAL库的阻塞接收在模块一直没有响应时,会一直卡到timeout结束,所以timeout参数要根据场景设置合理值。采集指纹图像那一步,用户可以按压的时间本来就是一秒到几秒,timeout设个3000ms比较合理;而删除模板这种指令,模块很快就能回,timeout设500ms就够。
4.2 指纹录入前端控制逻辑
录入指纹的标准流程要按顺序走一遍:采集图像、生成特征存入Buffer1、提示用户再次按压、再采集图像、生成特征存入Buffer2、合成模板、存储模板。代码逻辑上建议每一步都检查确认码,并用返回值精确定位失败在哪一步。
uint8_t AS608_Enroll(uint16_t pageID) { uint8_t param[6]; uint8_t resp[8]; // 第1步:采集第一次指纹图像 if (!AS608_SendCmdAndWait(0x01, NULL, 0, resp, 3000)) return 1; if (resp[0] == 0x02) return 2; // 没有检测到手指 if (resp[0] != 0x00) return 3; // 采集失败 // 第2步:生成特征,存入Buffer1 param[0] = 0x01; if (!AS608_SendCmdAndWait(0x02, param, 1, resp, 1000)) return 4; if (resp[0] != 0x00) return 5; // 提示用户移开再按一次 AS608_ShowMessage("fingeroff"); HAL_Delay(300); // 第3步:采集第二次指纹图像 if (!AS608_SendCmdAndWait(0x01, NULL, 0, resp, 3000)) return 6; if (resp[0] == 0x02) return 7; if (resp[0] != 0x00) return 8; // 第4步:生成特征,存入Buffer2 param[0] = 0x02; if (!AS608_SendCmdAndWait(0x02, param, 1, resp, 1000)) return 9; if (resp[0] != 0x00) return 10; // 第5步:合并模板 if (!AS608_SendCmdAndWait(0x03, NULL, 0, resp, 1000)) return 11; if (resp[0] != 0x00) return 12; // 第6步:存储到指定ID param[0] = 0x01; param[1] = (pageID >> 8) & 0xFF; param[2] = pageID & 0xFF; if (!AS608_SendCmdAndWait(0x04, param, 3, resp, 1000)) return 13; if (resp[0] != 0x00) return 14; return 0; // 录入成功 }这里面我重点解释一下Buffer1和Buffer2的设计。模块内部有两个特征缓冲区,录入时必须两次采集同一个指纹,分别生成特征存进两个Buffer,然后再用RegModel指令把两份特征点融合成一份更完整的模板。为什么一定要这张做?因为单次按压采集到的特征点有限,指纹边缘、按压力度、角度都会影响特征点的完整性。两次取特征再合并,可以显著提高后续识别时的通过率。这也是为什么有些项目里“按压一次就录入”的做法是不规范的,录入模板的质量直接决定后面的识别率。
4.3 指纹识别与分数阈值
识别流程比录入简单,核心就三步:采集图像、生成特征到Buffer1、Search搜索整个指纹库。
uint8_t AS608_Identify(uint16_t *matchedID, uint16_t *score) { uint8_t param[5]; uint8_t resp[8]; if (!AS608_SendCmdAndWait(0x01, NULL, 0, resp, 3000)) return 1; if (resp[0] == 0x02) return 2; // 没手指 if (resp[0] != 0x00) return 3; param[0] = 0x01; if (!AS608_SendCmdAndWait(0x02, param, 1, resp, 1000)) return 4; if (resp[0] != 0x00) return 5; param[0] = 0x01; // 用Buffer1搜索 param[1] = 0x00; // 起始页高字节 param[2] = 0x00; // 起始页低字节 param[3] = 0x03; // 搜索数量高字节 param[4] = 0xFF; // 搜索数量低字节 if (!AS608_SendCmdAndWait(0x05, param, 5, resp, 1500)) return 6; if (resp[0] != 0x00) return 7; // 没匹配到 if (matchedID != NULL) *matchedID = (resp[2] << 8) | resp[3]; // 匹配上的页号 if (score != NULL) *score = (resp[4] << 8) | resp[5]; // 匹配得分 return 0; }Search指令的搜索数量我传的是0x03FF,覆盖范围大于模块常见容量300枚,所以能搜到全部指纹。AS608不同容量版本的差异主要在Flash大小和最大存储指纹数,你可以在模块手册里确认容量,然后把这个值改成实际容量值。
还有个细节是匹配分数。模块返回的score值反映匹配程度,分值越高说明越像。不同模块固件对分数口径可能不同,但通常80以上就比较可靠了。你要是发现识别误判太高,可以把阈值调高一些;反过来,识别太严导致老是拒识,就把阈值调低一点。这个值要作为宏定义放在程序开头,方便现场调。
4.4 删除模板与清空指纹库
删除单枚指纹的指令是DeleteChar,参数是起始页号和要删除的数量。比如你要删除ID为5的指纹:
uint8_t AS608_DeleteByID(uint16_t pageID) { uint8_t param[4]; param[0] = (pageID >> 8) & 0xFF; param[1] = pageID & 0xFF; param[2] = 0x00; // 删除数量高字节 param[3] = 0x01; // 删除1枚 return AS608_SendCmdAndWait(0x08, param, 4, resp, 1000); }清空整个指纹库是Empty指令,它需要带一个8字节的确认口令,防止软件误操作把所有指纹全删了。这个口令固定是0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x01,少一个字节或者多一个字节模块都不会执行。我建议在UI交互上加一个“长按3秒确认清空”的逻辑,避免误触发。
4.5 主状态机与按键交互
主程序用两个按键进入不同模式,参考下面的伪代码结构:
while (1) { switch (mode) { case MODE_IDLE: if (KEY1_PRESSED) mode = MODE_ENROLL; if (KEY2_PRESSED) mode = MODE_IDENTIFY; break; case MODE_ENROLL: // 简单引导流程:选择ID -> 录两次手指 break; case MODE_IDENTIFY: AS608_Identify(&id, &score); if (id有效 && score >= 80) OPEN_LOCK(); mode = MODE_IDLE; break; case MODE_DELETE: // 选择要删除的ID,执行DeleteByID break; } }这是一个最简单的裸机状态机。我再多说一句:如果后面按键逻辑变复杂,或者想加入超时自动回待机、低功耗休眠,建议把状态机抽成函数指针数组,甚至直接移植FreeRTOS,把每个模式做成独立任务。STM32F103C8T6的20KB SRAM跑一个精简版FreeRTOS完全没问题,串口、按键、指纹模块各占一个任务,代码会清爽很多。
5. 调试验证与常见问题实录
5.1 串口通信异常的排查方向
遇到指令发出去模块没反应,先按这个顺序查:
第一,波特率。看模块标签,如果贴的是9600就把初始化改成9600,别迷信“AS608默认57600”这句话。第二,TX/RX是否交叉。用万用表量一下模块TXD引脚有没有电平变化,发指令时用示波器或者逻辑分析仪抓一下波形最直观。第三,共地。模块和MCU不共地,串口电平没有参考基准,通信必然失败,这个问题在飞线连接时非常容易犯。第四,电源电流。如果模块指示灯一亮一灭、图像采集时模块重启,基本就是供电不足,换独立供电。
还有个常见问题是串口粘包。因为AS608应答帧是连续到达的,如果接收时没有按长度字段精确读取,或者上一帧数据没清空,下一帧就解析错位。我习惯每次发送指令前清空一下串口接收缓冲,或者用空闲中断+DMA接收,效果会稳定很多。
5.2 录入和识别失败怎么定位
录入失败时,首先要会看错误码。返回0x04说明手指太干,可以搓一搓手再试;返回0x05说明手指太湿,把手擦干;返回0x06是图像杂乱,往往是按压时手指滑动或者指纹头表面脏了;返回0x07是特征点太少,换一个纹理更清晰的手指,或者让手指按下去时稍微转动一下,让更多的脊线被采集到。录入多次失败还要确认指纹库是不是满了,如果模块容量300枚,存了300枚后再录入就会返回0x0D。
识别阶段最常见的问题是明明录入成功,识别时却匹配不上。这时候先别怀疑模块坏了,大概率是录入模板质量太差,建议删掉重新录,并且录入时让用户分别用“正中按压”“稍微偏左”“稍微偏右”按三次,模块会从三次图像中提取更丰富的特征点。对于老人和小孩的手指,指纹纹理浅,录一次两次往往不够,可以多录几次不同的按压角度。
5.3 识别稳定性优化心得
我实测下来,把模块的采集超时设置和用户交互节奏配合好,识别体验会明显提升。比如第一次GenImg失败后,如果是因为没有手指,UI要先提示“请按压手指”,然后延时再重试,不要一口气发几十条指令把串口刷爆。AS608模块内部处理图像也需要时间,指令之间保留至少几十毫秒的间隔,响应会更稳定。
分数阈值这块我再展开一点:在实际门锁项目里,安全性和便利性是矛盾的。阈值太高,指纹稍微出点汗就被拒;阈值太低,不同手指之间误识别率上升。项目调试阶段先用默认值跑一周,记录实际得分分布,再根据需求调整。比如家用场景,我一般建议阈值在50到80之间,既要防误开,也要保证老人小孩能被识别。
5.4 关于购买模块和固件版本的建议
AS608这个型号被做成了各种“定制版”,有的带蓝色背光,有的不带,有的内置了触摸感应输出引脚,有的没有。这些都不影响基本功能,但要注意别买到拆机件。拆机指纹头最典型的问题是图像传感器老化,录入时总报“图像杂乱”,换新模块立马正常。另外一个判断标准是看排线和焊点,全新件的排线插座干净,焊点光亮,拆机件往往有拆焊痕迹。
如果你手头的模块是ZW101或者其他同协议兼容模块,代码几乎不用改,顶多调整一下波特率和搜索数量。有些模块固件版本较老,对某些指令的应答慢一点,timeout时间要给足,不要一超时就判失败。
如果你决定把这个项目产品化,还有几个点值得加进去:指纹库剩余容量显示、多次识别失败后的锁定延时、掉电记忆模块地址、通过Boot引脚做固件升级。这些功能都不难,但能把一个“课设级别”的门锁变成“可用级别”的产品。
最后再说一个我自己的体会。整套系统调通之后,建议你故意把电源线接长一点、用快充充电器给系统供电、在指纹头旁边放一个电机启动的大负载,分别观察识别率的变化。你会直观地感受到电源质量对指纹识别的影响有多大。这个项目最值钱的部分不是那几行串口代码,而是你通过这些调试过程建立起来的“系统稳定性意识”。后面无论你是去做智能锁产品,还是转去做其他传感器采集类项目,这套排查思路都能直接迁移过去。这个简单的入门项目,可能改变你未来做嵌入式工程的很多习惯。