大家好,我是DS小龙哥。
早上出门,你按一下指纹,门锁"咔哒"一声开了;到了公司,手指往打卡机上一放,考勤完成;打开保险柜,也是轻轻一按——指纹识别,早就不是什么黑科技,它就在我们每天的生活里。
很多同学跟我说:"龙哥,指纹模块听起来很高大上,是不是很难驱动?"其实真不难。指纹模块本身就是一个"黑盒",里面的采集、算法、存储都由模块自己完成,我们STM32要做的,就是通过串口给它发指令、收应答,跟我们平时驱动一个串口传感器没有本质区别。
今天这篇文章,我就带大家从零开始,用 STM32F103C8T6 驱动 海凌科 HLK-ZW101 半导体指纹识别模块,实现:指纹录入、指纹删除、指纹搜索开锁,外加 OLED 菜单显示、按键操作、蜂鸣器提示,做成一个完整可用的"指纹门禁锁"。
这篇文章也是一篇移植教程:很多同学手里有 AS608/AS606 光学指纹模块的旧代码,想换到 ZW101 上。ZW101 的通信协议和 AS608 是兼容的(都是 0xEF01 帧头那一套),但有几个坑不一样。我会把移植过程中踩到的两个"大坑"(手指检测不稳、搜索一直失败)作为本文精华,详细讲出来,保证大家看完能一次复刻成功。
老规矩,面向0基础,硬件连线全部表格化,代码注释写到每行,跟着做就行。
一、先看效果:我们最终要做成什么样
先给大家看成品,心里有个目标。整个系统由一块 STM32F103C8T6 最小系统板 + HLK-ZW101 指纹模块 + 0.96寸OLED屏 + 4个按键 + 蜂鸣器 + 指示灯组成。
上电后,系统会自动和指纹模块握手,握手成功 OLED 显示 ZW101 Init Success...,然后读取指纹库里已存指纹的个数和模块参数,通过串口打印到电脑。
图1 指纹门禁锁成品实物图
系统有两大功能模式:
1、待机识别模式(刷指纹开锁)
平时屏幕显示待机界面,系统每隔约500ms自动采一次图检测手指。你把已录入的手指按上去,模块自动完成"采图→生成特征→搜索指纹库",搜到了就蜂鸣器提示、OLED显示"开锁成功",同时可以驱动继电器/电磁锁开门;搜不到就提示失败。
2、管理员模式(录指纹/删指纹)
按一下 K1 键进入管理员验证,输入管理员密码(工程里默认是 1212),密码正确后进入管理菜单:
• 指纹录入:用 K3/K4 选择要存入的指纹ID(0~49),按 K2 确认,然后按屏幕提示"请按下指纹→请抬起手指→再按一次",两次采集合并成功后指纹就存进模块了;
• 指纹删除:同样选ID,按 K2 删除指定指纹,也支持一键清空整个指纹库。
图2 OLED菜单界面实拍
整个过程串口1(115200)会同步打印详细日志,方便调试:握手状态、每一步的确认码、匹配到的ID和分数、剩余指纹个数,全都能在串口助手里看到。
图3 串口调试日志截图
二、认识主角:HLK-ZW101指纹模块介绍
2.1 它是个什么东西
HLK-ZW101 是深圳海凌科电子(Hi-Link)推出的一款一体化半导体指纹处理模组。注意两个关键词:•
一体化:它把"主动式半导体指纹采集Sensor"和"指纹识别处理芯片"做在了一起,采集、算法、存储全在模块内部完成,对外只留一个串口;•
半导体(电容式):和老款 AS608 那种"光学拍照式"不同,ZW101 用的是电容式半导体传感器,靠手指脊线/谷线与传感器之间的电容差异来成像。它不需要光源、黑暗中也能工作、防伪能力更强、功耗更低,是现在智能门锁的主流方案。
模块内部用的是一颗高性能低功耗 RISC-V 内核算法芯片,跑360度自适应自学习算法——说白了就是你每次按指纹,它都会悄悄学习、越用越准。
2.2 主要特性参数
我把官方规格书和手册里的关键参数整理成表,大家心里有数:
| 参数项 | 指标 |
|---|---|
| 传感器类型 | 主动式半导体(电容式/射频采集),面阵式指纹传感器,表面有保护涂层 |
| 主控内核 | 高性能低功耗 RISC-V 内核,360°自适应自学习算法 |
| 通信接口 | UART(TTL 3.3V电平),默认波特率 57600,8数据位/1停止位/无校验 |
| 波特率范围 | 可用指令设置,9600 ~ 115200(默认系数6,即 6×9600=57600) |
| 供电电压 | 3.3V(模组支持3.3V供电,务必稳定) |
| 指纹容量 | 默认版本存储 50枚 指纹特征(ZW111等大容量版本为100枚) |
| 认假率 FAR | < 1/1,000,000(别人的手指误开你的锁,概率百万分之一以下) |
| 拒真率 FRR | < 3%(自己的手指偶尔识别不出的概率,多按几次即可) |
| 特征提取时间 | < 0.06 秒 |
| 单枚匹配时间 | < 0.8 秒(实际体验从按压到出结果基本1秒内) |
| 待机/休眠功耗 | 支持低功耗手指检测,休眠电流低至 8μA 级别 |
| ESD防护 | > 15KV(防静电,冬天摸它不容易坏) |
| 指示灯 | RGB 三色LED灯圈,可自定义颜色和闪烁方式 |
| 封装尺寸 | 芯片封装 Ø11.6mm×0.6mm,模组封装 Ø21mm×5.0mm,体积很小 |
| 上电信号 | 初始化成功后模组会主动通过串口发送1字节 0x55 作为握手信号 |
安全等级(分数等级)出厂默认为 Level2,共分 Level1~Level5 五级,等级越高要求越严格(越安全但越难识别),可以通过指令读写。
2.3 引脚定义
ZW101 模组用的是 1.0mm 连接器,引出的线在实际模块上一般带颜色,和我们工程里的接线一一对应。结合工程实际接线,引脚定义如下:
| 模块引脚/线色 | 名称 | 功能说明 |
|---|---|---|
| 红线 | VCC | 模块主电源,接 3.3V |
| 绿线 | VCC(触摸感应电源) | 触摸检测部分供电,也接 3.3V(本工程直接并到3.3V) |
| 黑线 | GND | 地,接 GND(必须和STM32共地) |
| 白线 | RX(UART_RD) | 模块串口接收,接 STM32 的 TX(PB10) |
| 黄线 | TX(UART_TD) | 模块串口发送,接 STM32 的 RX(PB11) |
| 蓝线 | TOUCH 触摸感应输出 | 手指触摸时输出高电平,接 PB9(本工程可不依赖它,详见后面讲解) |
⚠️ 串口接线记住"交叉接":STM32的TX接模块的RX,STM32的RX接模块的TX,千万别TX接TX,那样是收不到数据的。
三、ZW101 和 AS608/AS606 到底啥关系?能不能直接移植?
很多同学入门指纹识别,最早接触的是 AS608(也叫FPM10A) 这种光学指纹模块,网上资料、Arduino库一大堆。那 ZW101 和它是什么关系?代码能不能直接用?
结论先说:通信协议高度兼容,但有4个差异点要注意。
3.1 相同点:协议帧格式完全一样
ZW101 和 AS608 用的是同一套通信协议(业内常叫"EF01协议"):
• 帧头都是 0xEF01;• 设备地址默认都是 0xFFFFFFFF;
• 都是"命令包(标识01)/应答包(标识07)/结束包(标识08)"这套结构;
• 校验和算法完全一样(包标识开始所有字节累加);
• 核心业务指令码完全一样:采图0x01、生成特征0x02、比对0x03、搜索0x04、合并建模0x05、存储0x06、删除0x0C、清空0x0D、读参数0x0F、读指纹数0x1D。
**所以AS608的驱动代码,绝大部分可以直接搬过来用。**这也是为什么这套协议生态这么好——GitHub上有开源的 HLK-ZW 指纹库,明确写着"同时支持 AS608、R307 以及所有 EF-01 协议兼容的指纹模块"。
3.2 不同点:这4个地方移植时要改
我在 hlk_zw101.h 头文件里,专门把 ZW101 和 AS608 的区别注释出来了,一共4条:
/* 与 AS608 的主要区别:
* 1. 握手指令码为 0x35(AS608 无标准握手指令)
* 2. 读系统参数应答的字段排列顺序不同
* 3. 不支持 0x1B 高速搜索, 统一使用 0x04 搜索指令
* 4. 上电初始化成功后模组会主动发送 1 字节 0x55 握手信号
*/
逐条解释:
1. 握手指令码是 0x35。ZW101 有专门的握手指令 PS_HandShake,指令码 0x35(手册3.4.4节可查),发过去模块回确认码0x00就说明在线、正常工作。AS608没有这个标准握手指令(有些AS608例程用0x17或干脆不握手)。
2. 读系统参数(0x0F)应答字段排列顺序不同。AS608老例程里字段顺序和ZW101不一样,如果你直接套用AS608的解析下标,读出来的容量、等级会是乱的。ZW101的字段排列我在驱动里重新标注了(后面代码会讲)。
3. 不支持 0x1B 高速搜索。AS608有个高速搜索指令0x1B,ZW101不支持,统一用 0x04 普通搜索指令搜整个指纹库即可,速度也很快(<0.8s)。
4. 上电主动发 0x55。ZW101上电初始化成功后会主动发一个字节0x55,主机可以用它判断模块启动完成;如果没收到,手册建议M系列延时80ms、S系列延时50ms再操作。
3.3 为什么我选 ZW101
半导体电容式,比光学的更安全(假指纹难骗)、更省电(适合电池供电的门锁)、体积更小;•
价格便宜,十几块钱一个,性价比极高;•
自带RGB灯圈,交互体验好;•
协议通用,AS608的代码改改就能用,学习资料能直接复用。
四、硬件连接:一张接线表搞定
硬件部分我全部整理成表格,0基础同学照着接就行。整个工程用到的外设和引脚如下(全部以工程 main.c 里的实际接线为准)。
4.1 总接线表
| 外设 | 模块/外设引脚 | STM32F103引脚 | 说明 |
|---|---|---|---|
| ZW101指纹模块 | 白线 RX | PB10(USART3_TX) | STM32发→模块收 |
| 黄线 TX | PB11(USART3_RX) | 模块发→STM32收 | |
| 红线 VCC | 3.3V(独立供电) | 模块主电源 | |
| 绿线 VCC(触摸电源) | 3.3V | 触摸检测供电,和红线并一起接3.3V | |
| 黑线 GND | GND | 必须共地 | |
| 蓝线 TOUCH(触摸输出) | PB9 | 触摸高电平有效;本工程待机不靠它,可接可不接 | |
| 0.96寸OLED(IIC软件模拟) | VCC | 3.3V | 供电 |
| GND | GND | 地 | |
| SCL | PB0 | IIC时钟 | |
| SDA | PB1 | IIC数据 | |
| 4颗按键 | K1 | PB12 | 翻页/返回(管理员入口) |
| K2 | PB13 | 确定 | |
| K3 | PB14 | 数字1 / ID++ | |
| K4 | PB15 | 数字2 / ID-- | |
| 公共端 | GND | 按键另一端接地 | |
| 蜂鸣器 | OUT | PA15 | 提示音(有源蜂鸣器,高电平响) |
| VCC | 3.3V | ||
| GND | GND | ||
| 指示灯 | LED1 | PB3 | 系统运行状态灯 |
| LED2 | PB4 | 系统运行状态灯 | |
| LED3 | PC13 | 板载灯 | |
| 调试串口 | USB转TTL | USART1(PA9/PA10) | 115200,打印调试日志到电脑 |
注意:PA15、PB3、PB4 默认是 JTAG 调试脚。工程里调用了
JTAG_Set(SWD_ENABLE)关闭JTAG、只保留SWD下载,这样就能把这几个脚当普通IO用了。下载器用 ST-Link 的 SWD 模式(只接 SWCLK、SWDIO、GND、3.3V)即可,不要用JTAG模式。
4.2 ⚠️ 头号硬件注意事项:必须独立、稳定的3.3V供电
这是无数人栽跟头的地方,我提前重点讲:
ZW101 必须用 3.3V 供电,而且供电电流要够,强烈建议独立供电,不要图省事直接插在STM32最小系统板的3.3V引脚上。
原因:指纹模块在采集图像、点亮RGB灯、跑匹配算法的瞬间,电流会突然增大。STM32板子上那个3.3V(一般是AMS1117-3.3或USB转出来的)带载能力有限,一旦供电跟不上,模块电压被拉低,就会出现各种"玄学问题":
- • 明明手指按好了,却采集失败、反复提示无手指;• 录到一半模块重启、通信中断、串口乱码;• 搜索时好时坏,匹配分数异常低。
推荐做法:用一路独立的3.3V电源给指纹模块供电(比如外接一个3.3V稳压模块、或者用带独立LDO的电源),GND和STM32共地即可。万用表量一下模块VCC引脚,稳定在 3.3V ± 0.1V 才算合格。海凌科官方技术支持也明确建议"使用外部稳压电源或带LDO的开发板供电,避免供电能力不足"。
另外两个硬件小细节:
1. **新模块镜头上有一层保护膜,一定要撕掉!**不撕掉会严重影响成像,导致特征点太少、生不成特征(返回0x07)。
2. 手指要按在采集窗正中间、按实,别只搭个边;手指太干可以哈口气或搓一搓。
图5 硬件接线示意图
五、通信协议详解:帧格式 + 校验和手把手算
驱动指纹模块,本质就是"按格式组帧发出去→等模块应答→解析应答里的确认码"。所以协议一定要看懂。别担心,这套协议很规整。
5.1 数据包的三种类型
模块和主机之间收发的每一包数据,都必须加包头 0xEF01。按"包标识"字节区分类型:
| 包标识 | 类型 | 谁发给谁 |
|---|---|---|
| 0x01 | 命令包 | 主机(STM32)→ 模块,用来下指令 |
| 0x02 | 数据包(有后续包) | 双向,传输大块数据(如图像)时用 |
| 0x07 | 应答包 | 模块 → 主机,回应指令执行结果(我们最关心) |
| 0x08 | 结束包(最后一个数据包) | 双向,数据包的最后一包 |
我们做录指纹、搜指纹这些功能,收发的主要是 0x01命令包 和 0x07应答包。0x02/0x08数据包主要在上传指纹图像、模板时才用,本文工程用不到,了解即可。
5.2 命令包帧格式(我们要发送的)
| 名称 | 字节数 | 内容 | 说明 |
|---|---|---|---|
| 包头 | 2 | 0xEF 0x01 |
固定帧头,高字节在前 |
| 设备地址 | 4 | FF FF FF FF |
默认地址0xFFFFFFFF,高字节在前 |
| 包标识 | 1 | 0x01 |
命令包 |
| 包长度 | 2 | 如 0x00 0x03 |
长度=从"包长度"后一字节到校验和的总字节数(含指令、参数、校验和),高字节在前 |
| 指令码 | 1 | 如 0x01 |
具体指令(采图/搜索等) |
| 参数 | N | —— | 有些指令带参数(如缓冲区号、ID号) |
| 校验和 | 2 | 如 0x00 0x05 |
从"包标识"开始所有字节累加,取低16位,高字节在前 |
5.3 应答包帧格式(模块回给我们的)
| 名称 | 字节数 | 内容 | 说明 |
|---|---|---|---|
| 包头 | 2 | 0xEF 0x01 |
|
| 设备地址 | 4 | FF FF FF FF |
|
| 包标识 | 1 | 0x07 |
应答包 |
| 包长度 | 2 | 如 0x00 0x03 |
|
| 确认码 | 1 | data[9] |
0x00=成功,其它=各种错误,这是我们判断成败的关键 |
| 返回参数 | N | —— | 如搜索应答里紧跟"页码(2字节)+得分(2字节)" |
| 校验和 | 2 | sum |
注意应答包里确认码的位置:包头2 + 地址4 + 标识1 + 长度2 = 9个字节,所以确认码正好是 data[9](从0开始数)。这个下标在代码里反复用到,记住它。
5.4 校验和怎么算?手把手带你算一遍
校验和规则(手册原文):从"包标识"字节开始,到校验和之前的所有字节,全部累加,超出2字节(65535)的进位直接忽略,取低16位。
也就是:校验和 = 包标识 + 包长度高 + 包长度低 + 指令码 + 所有参数。
我们拿最简单的"采集图像 PS_GetImage(指令码0x01)"来算。它的完整命令包是:
EF 01 FF FF FF FF 01 00 03 01 00 05
- • 包标识 = 0x01• 包长度 = 0x0003,即高字节0x00、低字节0x03• 指令码 = 0x01• 无参数
累加:0x01 + 0x00 + 0x03 + 0x01 = 0x05,所以校验和 = 0x00 0x05。✅ 和手册给的算例完全一致。
再算一个"握手 PS_HandShake(指令码0x35)",命令包:
EF 01 FF FF FF FF 01 00 03 35 00 39
累加:0x01 + 0x00 + 0x03 + 0x35 = 0x39,校验和 = 0x00 0x39。✅ 同样和手册一致。
有参数的也一样,比如"读系统参数(0x0F)":0x01+0x00+0x03+0x0F = 0x13 → 校验和 0x0013;"读有效模板个数(0x1D)":0x01+0x00+0x03+0x1D = 0x21 → 0x0021。大家找到规律了吧?就是把包标识之后、校验和之前的字节,一股脑加起来。
5.5 确认码(返回码)速查表
模块应答里的确认码,是我们调试时的"诊断书"。工程里的 EnsureMessage() 函数把它们都翻译成了中文,这里列一份常用的:
| 确认码 | 含义 | 工程里常见场景 |
|---|---|---|
| 0x00 | 操作成功 / OK | 一切正常 |
| 0x01 | 数据包接收错误 | 帧格式/校验和错、波特率不对、接线虚 |
| 0x02 | 传感器上没有手指 | 采图时手指还没按上(这是正常状态,不是错误) |
| 0x03 | 录入指纹图像失败 | 成像失败 |
| 0x04 | 图像太干、太淡,生不成特征 | 手指太干 |
| 0x05 | 图像太湿、太糊,生不成特征 | 手指有水/汗 |
| 0x06 | 图像太乱,生不成特征 | 按歪了 |
| 0x07 | 图像正常但特征点太少(面积太小) | 没按实/保护膜没撕 |
| 0x08 | 指纹不匹配 | Match精确比对两枚特征不一致 |
| 0x09 | 没搜索到指纹 | Search搜库没搜到(这根手指没录过) |
| 0x0A | 特征合并失败 | RegModel合并失败:两次不是同一根手指,或图像质量差 |
| 0x0B | 访问指纹库地址序号超出范围 | ID超过容量 |
| 0x10 | 删除模板失败 | |
| 0x11 | 清空指纹库失败 | |
| 0x15 | 缓冲区无有效原始图 | 没采图就想生成特征 |
| 0x18 | 读写FLASH出错 | 存储异常 |
| 0x1F | 指纹库满 | 存满了(ZW101默认50枚) |
| 0xFF(工程自定义) | 通信超时,模块无应答 | 没接线/没供电/波特率错 |
这里先把 0x08、0x09、0x0A 三个码记住,后面"踩坑实录"会反复提到它们,它们是理解录入/识别逻辑的钥匙。
5.6 常用指令码速查表
| 指令码 | 函数名 | 功能 |
|---|---|---|
| 0x01 | PS_GetImage | 采集指纹图像(探测手指) |
| 0x02 | PS_GenChar | 由图像生成特征,存入Buffer1/Buffer2 |
| 0x03 | PS_Match | 精确比对Buffer1和Buffer2两枚特征 |
| 0x04 | PS_Search | 用缓冲区特征搜索指纹库,返回ID和分数 |
| 0x05 | PS_RegModel | 合并Buffer1、Buffer2特征,生成模板 |
| 0x06 | PS_StoreChar | 把模板存到指纹库指定ID位置 |
| 0x0C | PS_DeletChar | 删除指定ID开始的N个模板 |
| 0x0D | PS_Empty | 清空整个指纹库 |
| 0x0F | PS_ReadSysPara | 读模块基本参数 |
| 0x1D | PS_ValidTempleteNum | 读有效模板(已录指纹)个数 |
| 0x35 | PS_HandShake | 握手,检测模块是否在线 |
六、编程思路:串口怎么收发、帧怎么切、指令怎么封装
在贴代码之前,先把整体思路理清楚。看懂了框架,代码就是照着装。
6.1 整体框架
一句话概括:STM32通过USART3发指令帧 → 模块应答 → 串口中断把应答字节收进缓冲区 → 定时器判断一帧收完 → 在缓冲区里找应答包 → 读出确认码判断成败。
┌─────────────┐ 命令帧(0x01) ┌──────────────┐
│ STM32 │ ───────────────► │ HLK-ZW101 │
│ USART3 │ │ 指纹模块 │
│ PB10/PB11 │ ◄─────────────── │ (采集/算法/ │
└─────────────┘ 应答帧(0x07) │ 存储) │
│ └──────────────┘
│ 串口中断逐字节接收
▼
USART3_RX_BUFFER[] ──► 定时器3空闲20ms判定一帧结束
│
▼
strstr查找 "EF01+地址+07" 应答包 ──► data[9]确认码 ──► 成功/失败
6.2 为什么用"串口中断接收 + 定时器空闲成帧"
串口收发数据有两种写法:查询方式和中断方式。
• 查询方式(死等 while 等待收完)会把CPU卡死,而且应答包长度不固定,你不知道该等几个字节;
• 更好的办法是:串口每收到1个字节就进一次中断,把字节扔进缓冲区;再用定时器做"空闲检测"——如果连续20ms没有新字节到来,就认为这一帧收完了,置一个接收完成标志 USART3_RX_FLAG=1。
这是串口接收"不定长数据"的经典套路。指纹模块的应答包长度随指令变化(比如搜索应答比握手应答长),用空闲中断/超时成帧,不用提前知道长度,收到多少算多少,超时即整帧。
工程里用 USART3 中断 + 定时器3(TIMER3_Init(72, 20000),约20ms超时) 配合实现:
• USART3初始化波特率57600(USART3_Init(57600));• 每收到1字节,中断里往 USART3_RX_BUFFER 存,并喂一下定时器(重新计时);• 20ms没新字节,定时器判定一帧结束,置 USART3_RX_FLAG=1。
6.3 指令发送—应答—确认码 的封装套路
每个指令函数都是同一个"三板斧"模板,理解了一个就理解了全部:
第1步:组帧发送
SendHead() → 发 EF01
SendAddr() → 发 4字节地址
SendFlag(0x01) → 发命令包标识
SendLength() → 发包长度
发指令码 + 参数
SendCheck() → 发校验和(边发边累加算出来)
第2步:等待并定位应答包
data = JudgeStr(超时次数); // 在缓冲区里找 EF01+地址+07
第3步:解析确认码
ensure = data[9]; // data[9] 就是确认码
return ensure; // 0x00成功,其它失败
上层业务(录入、识别)只需要调这些函数,看返回的确认码就行,完全不用关心底层帧细节。这就是封装的好处。
6.4 两个关键的业务设计
后面踩坑会细讲,这里先点题:
1. 等待手指按下/松开:不能用固定延时去猜手指状态,要主动循环调 PS_GetImage(),靠返回码判断——返回 0x00 表示有手指且图像有效,返回 0x02 表示没手指。于是封装出 WaitFingerDown()(等按下)和 WaitFingerUp()(等抬起)两个函数。
2. 录入的"判官"是 RegModel,识别的"判官"是 Search:录入时不要拿 Match(0x03) 当卡点,真正判断两次采集是否合格的是 RegModel(0x05);识别时不要用 Compare/Match,要用 Search(0x04) 搜库返回ID。这是本文最核心的经验,第九章详讲。
七、核心代码逐段讲解
代码来自真实工程,注释都保留了中文。我按"头文件→底层收发→指令封装→录入/识别业务→主流程"的顺序讲。
7.1 驱动头文件 hlk_zw101.h:先看对外提供了哪些函数
头文件相当于"菜单",先看看这个驱动能干嘛:
#ifndef __HLK_ZW101_H
#define __HLK_ZW101_H
#include <stdio.h>
#include "stm32f10x.h"
/*============================================================
* HLK-ZW101 指纹模组驱动
* 通信方式 : UART 串口(工程中使用 USART3, PB10=TX -> 模块RX, PB11=RX <- 模块TX)
* 波特率 : 默认 57600, 8 数据位, 1 停止位, 无校验
* 帧格式 : 与 AS608 兼容
* 包头 0xEF01 + 设备地址(4字节) + 包标识(01命令/07应答/08数据包)
* + 包长度(2字节) + 指令/数据 + 校验和(2字节)
* 默认地址 : 0xFFFFFFFF
* 与 AS608 的主要区别:
* 1. 握手指令码为 0x35(AS608 无标准握手指令)
* 2. 读系统参数应答的字段排列顺序不同
* 3. 不支持 0x1B 高速搜索, 统一使用 0x04 搜索指令
* 4. 上电初始化成功后模组会主动发送 1 字节 0x55 握手信号
*============================================================*/
// 指纹模块触摸感应状态引脚: PB9, 触摸时输出高电平
#define PS_Sta PBin(9)
// 特征缓冲区编号(ZW101 支持多个, 注册/比对常用 1、2 号)
#define CharBuffer1 0x01
#define CharBuffer2 0x02
// 指纹库默认搜索范围
#define FP_LIB_MAX 300 // 搜索/录入的最大页码范围
#define FP_PAGE_MAX 49 // 本工程按键选号允许的最大 ID(0~49)
extern u32 ZW101Addr; // 模组设备地址, 默认 0xFFFFFFFF
// 搜索结果
typedef struct
{
u16 pageID; // 匹配到的指纹 ID(页码)
u16 mathscore; // 匹配分数
} SearchResult;
// 模组基本参数(对应 PS_ReadSysPara 读出的内容)
typedef struct
{
u16 EnrollTimes; // 注册次数
u16 TempSize; // 指纹模板大小
u16 PS_max; // 指纹库容量(可存指纹最大数量)
u16 PS_level; // 安全等级(分数等级)
u32 PS_addr; // 设备地址
u8 PS_size; // 通信数据包大小(0->32,1->64,2->128,3->256)
u16 PS_baud; // 波特率系数(实际波特率 = 系数 * 9600)
} SysPara;
// 初始化 PB9 为下拉输入, 读取手指触摸状态(触摸输出高电平)
void PS_StaGPIO_Init(void);
u8 PS_GetImage(void); // 录入指纹图像
u8 PS_GenChar(u8 BufferID); // 生成特征
u8 PS_Match(void); // 精确比对两枚特征
u8 PS_Search(u8 BufferID, u16 StartPage, u16 PageNum, SearchResult *p); // 搜索指纹
u8 PS_RegModel(void); // 合并特征生成模板
u8 PS_StoreChar(u8 BufferID, u16 PageID); // 存储模板
u8 PS_DeletChar(u16 PageID, u16 N); // 删除模板
u8 PS_Empty(void); // 清空指纹库
u8 PS_ReadSysPara(SysPara *p); // 读模组基本参数
u8 PS_ValidTempleteNum(u16 *ValidN); // 读有效模板个数
u8 PS_HandShake(u32 *PS_Addr); // 与 ZW101 模组握手(指令码 0x35)
const char *EnsureMessage(u8 ensure); // 确认码错误信息解析
#endif
大家看,函数名非常直观:GetImage采图、GenChar生成特征、Match比对、Search搜索、RegModel合并建模、StoreChar存储、DeletChar删除、Empty清空、ReadSysPara读参数、ValidTempleteNum读个数、HandShake握手。这就是操作指纹模块的"全套动作"。
7.2 底层发送:一个字节一个字节按帧格式发
u32 ZW101Addr = 0xFFFFFFFF; // 模组默认地址
/*--------------------- 串口3发送一个字节 ---------------------*/
static void MYUSART_SendData(u8 data)
{
while ((USART3->SR & 0X40) == 0); // 等待发送数据寄存器空(TC/TXE标志)
USART3->DR = data;
}
/*--------------------- 组包发送辅助函数 ---------------------*/
// 发送包头 0xEF01
static void SendHead(void)
{
MYUSART_SendData(0xEF);
MYUSART_SendData(0x01);
}
// 发送设备地址(4字节, 高字节在前)
static void SendAddr(void)
{
MYUSART_SendData(ZW101Addr >> 24);
MYUSART_SendData(ZW101Addr >> 16);
MYUSART_SendData(ZW101Addr >> 8);
MYUSART_SendData(ZW101Addr);
}
// 发送包标识(01=命令包)
static void SendFlag(u8 flag)
{
MYUSART_SendData(flag);
}
// 发送包长度(2字节, 高字节在前)
static void SendLength(u16 length)
{
MYUSART_SendData(length >> 8);
MYUSART_SendData(length);
}
// 发送校验和(2字节, 高字节在前)
static void SendCheck(u16 check)
{
MYUSART_SendData(check >> 8);
MYUSART_SendData(check);
}
这几个辅助函数把帧的每一段拆开来发,注意所有多字节字段都是"高字节在前"(大端序),比如地址0xFFFFFFFF就发 FF FF FF FF,长度0x0003就发 00 03。
7.3 接收应答:JudgeStr 在缓冲区里定位应答包
/*--------------------- 等待并定位应答包 ---------------------
* 功能: 发送指令后等待模组应答, 在接收缓冲中查找应答包
* 应答包包头为 0xEF 0x01 + 地址(4字节) + 0x07
* 参数: waittime —— 等待循环次数(每次约30ms)
* 返回: 应答包首地址; 超时返回 0
*------------------------------------------------------------*/
static u8 *JudgeStr(u16 waittime)
{
char *data;
u8 str[8];
str[0] = 0xef;
str[1] = 0x01;
str[2] = ZW101Addr >> 24;
str[3] = ZW101Addr >> 16;
str[4] = ZW101Addr >> 8;
str[5] = ZW101Addr;
str[6] = 0x07; // 应答包标识
str[7] = '';
USART3_RX_FLAG = 0; // 清空接收状态
USART3_RX_CNT = 0;
while (--waittime)
{
delay_ms(30);
if (USART3_RX_FLAG) // 定时器3空闲超时, 一帧接收完毕
{
USART3_RX_BUFFER[USART3_RX_CNT] = '';
USART3_RX_FLAG = 0;
USART3_RX_CNT = 0;
data = strstr((const char *)USART3_RX_BUFFER, (const char *)str);
if (data)
return (u8 *)data; // 找到应答包
}
}
return 0;
}
这里的技巧是用 C 库函数 strstr() 在接收缓冲区里搜索特征字符串 EF 01 FF FF FF FF 07——这正是应答包的开头7字节。搜到了就返回这一包的首地址,后面 data[9] 取确认码、data[10]往后取参数;搜不到、超时了就返回0,上层把它当成 0xff(通信超时)处理。
为什么要"搜索"而不是直接从头读?因为ZW101上电可能主动发0x55,缓冲区里可能有杂字节,用strstr定位最稳妥,能自动跳过无关字节。
7.4 指令封装:以采图、生成特征、搜索为例
先看最简单的采集图像 PS_GetImage(0x01),这就是"三板斧"的标准模板:
/*--------------------- 录入指纹图像 (指令 0x01) ---------------------*/
u8 PS_GetImage(void)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead(); // 包头 EF01
SendAddr(); // 地址 FFFFFFFF
SendFlag(0x01); // 包标识: 命令包
SendLength(0x03); // 包长度 = 3(指令码1 + 校验和2)
MYUSART_SendData(0x01); // 指令码 0x01
temp = 0x01 + 0x03 + 0x01; // 校验和 = 标识 + 长度 + 指令码
SendCheck(temp); // 发送校验和
data = JudgeStr(2000); // 等待应答(每次30ms, 2000次约60秒)
if (data)
ensure = data[9]; // data[9] = 确认码
else
ensure = 0xff; // 超时无应答
return ensure; // 0x00=采到手指, 0x02=无手指
}
生成特征 PS_GenChar(0x02),带1个参数"缓冲区号":
/*--------------------- 生成特征 (指令 0x02) ---------------------
* BufferID: 特征存放缓冲区编号(ZW101 注册时按次取 1,2,3...; 比对取 1)
*----------------------------------------------------------------*/
u8 PS_GenChar(u8 BufferID)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead();
SendAddr();
SendFlag(0x01);
SendLength(0x04); // 长度=4(指令1+参数1+校验2)
MYUSART_SendData(0x02); // 指令码 0x02
MYUSART_SendData(BufferID); // 参数: 缓冲区号(1或2)
temp = 0x01 + 0x04 + 0x02 + BufferID; // 校验和要把参数也加进去
SendCheck(temp);
data = JudgeStr(2000);
if (data)
ensure = data[9];
else
ensure = 0xff;
return ensure;
}
搜索指纹 PS_Search(0x04),这是识别开锁的核心,参数最多,应答里要取出ID和分数:
/*--------------------- 搜索指纹 (指令 0x04) ---------------------
* 以指定缓冲区特征搜索指纹库
* BufferID : 特征所在缓冲区(一般为 CharBuffer1)
* StartPage: 搜索起始页
* PageNum : 搜索页数
* 搜索成功返回 pageID 与 mathscore
* 注: ZW101 不支持 AS608 的 0x1B 高速搜索, 统一用本指令
*---------------------------------------------------------------*/
u8 PS_Search(u8 BufferID, u16 StartPage, u16 PageNum, SearchResult *p)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead();
SendAddr();
SendFlag(0x01);
SendLength(0x08); // 长度=8(指令1+缓冲1+起始页2+页数2+校验2)
MYUSART_SendData(0x04); // 指令码 0x04
MYUSART_SendData(BufferID); // 缓冲区号
MYUSART_SendData(StartPage >> 8); // 起始页 高字节
MYUSART_SendData(StartPage); // 起始页 低字节
MYUSART_SendData(PageNum >> 8); // 搜索页数 高字节
MYUSART_SendData(PageNum); // 搜索页数 低字节
// 校验和 = 标识 + 长度 + 指令码 + 缓冲号 + 起始页(2字节) + 页数(2字节)
temp = 0x01 + 0x08 + 0x04 + BufferID
+ (StartPage >> 8) + (u8)StartPage
+ (PageNum >> 8) + (u8)PageNum;
SendCheck(temp);
data = JudgeStr(2000);
if (data)
{
ensure = data[9]; // 确认码
p->pageID = (data[10] << 8) + data[11]; // 命中页码(ID)
p->mathscore = (data[12] << 8) + data[13]; // 匹配分数
}
else
ensure = 0xff;
return ensure; // 0x00=搜到, 0x09=没搜到
}
搜索成功的应答包结构是:...07(标识) 00 07(长度) 00(确认码) + PageID(2字节) + MatchScore(2字节) + 校验(2字节)。所以确认码在 data[9],页码在 data[10..11],分数在 data[12..13],都是高字节在前,用 (data[10]<<8)+data[11] 拼起来。
7.5 录入相关:合并建模、存储、握手
合并特征生成模板 PS_RegModel(0x05)——录入是否合格的"判官":
/*--------------------- 合并特征生成模板 (指令 0x05) ---------------------*/
u8 PS_RegModel(void)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead();
SendAddr();
SendFlag(0x01);
SendLength(0x03);
MYUSART_SendData(0x05); // 指令码 0x05: 把Buffer1、2特征合并
temp = 0x01 + 0x03 + 0x05;
SendCheck(temp);
data = JudgeStr(2000);
if (data)
ensure = data[9]; // 0x00=合并成功(两次同源且合格)
else // 0x0A=合并失败(不是同一手指/质量差)
ensure = 0xff;
return ensure;
}
存储模板 PS_StoreChar(0x06)——把合并好的模板存进指纹库指定ID:
/*--------------------- 存储模板 (指令 0x06) ---------------------
* 将 BufferID 缓冲区模板存入 PageID 号位置
* 官方例程注册成功后存储 Buffer1
*---------------------------------------------------------------*/
u8 PS_StoreChar(u8 BufferID, u16 PageID)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead();
SendAddr();
SendFlag(0x01);
SendLength(0x06); // 长度=6(指令1+缓冲1+页码2+校验2)
MYUSART_SendData(0x06); // 指令码 0x06
MYUSART_SendData(BufferID); // 缓冲区号(注册成功后存Buffer1)
MYUSART_SendData(PageID >> 8); // 存储位置ID 高字节
MYUSART_SendData(PageID); // 存储位置ID 低字节
temp = 0x01 + 0x06 + 0x06 + BufferID
+ (PageID >> 8) + (u8)PageID;
SendCheck(temp);
data = JudgeStr(2000);
if (data)
ensure = data[9];
else
ensure = 0xff;
return ensure;
}
握手 PS_HandShake(0x35)——ZW101特有,上电后先确认模块在线:
/*--------------------- 与 ZW101 握手 (指令 0x35) ---------------------
* 功能: 检测模组是否正常在线工作
* 说明: ZW101 握手指令码为 0x35(AS608 无此标准指令);
* 模组上电初始化成功后还会主动发送 1 字节 0x55。
* 本函数发送 0x35 握手指令, 收到确认码 0x00 即握手成功。
* 返回: 0=成功, 1=失败
*-------------------------------------------------------------------*/
u8 PS_HandShake(u32 *PS_Addr)
{
u16 temp;
u8 ensure;
u8 *data;
SendHead();
SendAddr();
SendFlag(0x01);
SendLength(0x03);
MYUSART_SendData(0x35); // ZW101 握手指令码
temp = 0x01 + 0x03 + 0x35;
SendCheck(temp);
data = JudgeStr(100); // 等待应答(每次30ms, 约3秒)
if (data)
{
ensure = data[9];
if (ensure == 0x00)
{
if (PS_Addr) // 可选: 顺便读回模块地址
*PS_Addr = ((u32)data[2] << 24) + ((u32)data[3] << 16)
+ ((u32)data[4] << 8) + data[5];
return 0; // 握手成功
}
}
return 1; // 握手失败
}
删除、清空、读参数、读个数也是同一个模板,指令码换成 0x0C / 0x0D / 0x0F / 0x1D 而已,这里不再逐个贴。特别说一下读系统参数 PS_ReadSysPara(0x0F),ZW101的字段排列和AS608不同,解析下标要按ZW101的来:
/*--------------------- 读模组基本参数 (指令 0x0F) ---------------------
* 【ZW101 字段排列(与 AS608 不同)】
* data[10..11] 已注册指纹个数
* data[12..13] 指纹模板大小
* data[14..15] 指纹库容量
* data[16..17] 分数等级(安全等级)
* data[18..21] 设备地址
* data[22..23] 数据包大小(0->32,1->64,2->128,3->256)
* data[24..25] 波特率系数(实际波特率 = 系数*9600)
*-------------------------------------------------------------------*/
u8 PS_ReadSysPara(SysPara *p)
{
// ...组帧发送 0x0F, 校验和 = 0x01+0x03+0x0F = 0x13...
data = JudgeStr(2000);
if (data)
{
ensure = data[9];
if (ensure == 0x00)
{
p->EnrollTimes = (data[10] << 8) + data[11]; // 已注册个数
p->PS_max = (data[14] << 8) + data[15]; // 指纹库容量
p->PS_level = (data[16] << 8) + data[17]; // 安全等级
p->PS_baud = (data[24] << 8) + data[25]; // 波特率系数
printf("rn指纹库容量: %d 安全等级: %d 波特率: %d",
p->PS_max, p->PS_level, p->PS_baud * 9600);
}
}
// ...
}
读出来波特率系数是6,6×9600=57600,和我们初始化一致,这也能反过来验证通信正常。
最后是把确认码翻译成中文的 EnsureMessage(),调试时全靠它看错误:
const char *EnsureMessage(u8 ensure)
{
const char *p;
switch (ensure)
{
case 0x00: p = "操作成功"; break;
case 0x01: p = "数据包接收错误"; break;
case 0x02: p = "传感器上没有手指"; break;
case 0x07: p = "指纹图像正常,但特征点太少(或面积太小)"; break;
case 0x08: p = "指纹不匹配"; break;
case 0x09: p = "没搜索到指纹"; break;
case 0x0a: p = "特征合并失败"; break;
case 0x0b: p = "访问指纹库时地址序号超出范围"; break;
case 0x1f: p = "指纹库满"; break;
case 0xff: p = "通信超时,模块无应答"; break;
// ...其余码见头文件/工程
default: p = "模块返回确认码有误"; break;
}
return p;
}
7.6 业务层①:等待手指按下/抬起(解决"检测不稳"的关键)
这是踩坑后改造出来的两个函数,思想很简单:别用固定延时瞎等,主动循环采图,看返回码说话。
//---------------- 等待手指按下 ----------------
// 循环采图直到 PS_GetImage 返回 0x00(传感器检测到手指)
// wait_s: 最长等待秒数; 返回 0=检测到手指, 1=超时
static u8 WaitFingerDown(u16 wait_s)
{
u16 trycnt = wait_s * 3; // 每 300ms 采一次图
u8 ensure;
while (trycnt--)
{
ensure = PS_GetImage();
if (ensure == 0x00) return 0; // 手指已按下, 图像有效
if (ensure != 0x02 && ensure != 0xff) // 0x02=无手指 0xff=超时, 不刷屏
printf("采图异常: %srn", EnsureMessage(ensure));
delay_ms(300);
}
return 1;
}
//---------------- 等待手指抬起 ----------------
// 循环采图直到 PS_GetImage 返回 0x02(传感器无手指)
// wait_s: 最长等待秒数; 返回 0=手指已抬起, 1=超时
static u8 WaitFingerUp(u16 wait_s)
{
u16 trycnt;
u8 ensure;
delay_ms(600); // 先等 0.6s, 避开刚采完图像的稳定期
trycnt = wait_s * 3;
while (trycnt--)
{
ensure = PS_GetImage();
if (ensure == 0x02) return 0; // 传感器无手指 = 手指已抬起
delay_ms(300);
}
return 1;
}
逻辑一目了然:
WaitFingerDown:反复发采图指令,返回0x00说明手指按上了、图像也有效,立刻往下走;返回0x02说明还没手指,继续等;每300ms试一次,等到超时为止。
• WaitFingerUp:反过来,返回0x02说明手指抬起来了。开头先延时600ms,避开刚采完图的稳定期,防止误判。
这样不管用户什么时候按、按多久,程序都能稳稳地捕捉到,再也不靠"猜"。
7.7 业务层②:录指纹 Add_FR(两次采集→合并→存储)
这是录入的完整主流程,也是踩坑②的结论体现——Match只作参考不阻断,真正判合格的是RegModel:
// 录指纹主流程
void Add_FR(int ID)
{
u8 ensure;
while (1)
{
/*---------------- 第一次采集 ----------------*/
printf("请按下手指.rn");
OLED_ShowChineseFont(...); // 屏幕显示"请按下指纹"
OLED_RefreshGRAM();
if (WaitFingerDown(20)) // 最长等待 20 秒
{
printf("超时未检测到手指---退出录入...rn");
return;
}
ensure = PS_GenChar(CharBuffer1); // 第一次图像生成特征 -> Buffer1
if (ensure != 0x00)
{
printf("第一次生成特征失败: %s, 请重新按手指rn", EnsureMessage(ensure));
delay_ms(1500);
continue;
}
printf("CharBuffer1 指纹 生成特征 正确.rn");
BEEP = 1; delay_ms(100); BEEP = 0; // 短响提示第一次采集成功
/*---------------- 等待抬起手指 ----------------*/
printf("请抬起手指...rn");
if (WaitFingerUp(10)) // 最长等待 10 秒
{
printf("手指一直未抬起, 本次录入作废, 请重新开始.rn");
delay_ms(1500);
continue;
}
/*---------------- 第二次采集 ----------------*/
printf("再按一次手指(同一根手指).rn");
OLED_ShowChineseFont(...); // 屏幕显示"再次按下指纹"
OLED_RefreshGRAM();
if (WaitFingerDown(20))
{
printf("超时未检测到手指---退出录入...rn");
return;
}
ensure = PS_GenChar(CharBuffer2); // 第二次图像生成特征 -> Buffer2
if (ensure != 0x00)
{
printf("第二次生成特征失败: %s, 请重新录入rn", EnsureMessage(ensure));
delay_ms(1500);
continue;
}
printf("CharBuffer2 指纹 生成特征 正确.rn");
BEEP = 1; delay_ms(100); BEEP = 0;
/*---------------- 精确比对两次特征(仅作参考, 不阻断流程) ----------------
* ZW101 官方注册流程不用 Match 做卡点: 两次按手指的角度、面积稍有差异,
* Match 精确比对就可能返回 0x08(不匹配), 但两次特征仍可正常融合。
* 真正的判官是下一步的 RegModel(0x05 合并):
* RegModel 返回 0x00 = 两次特征同源且质量合格, 可存储;
* RegModel 返回 0x0a = 两次特征差异过大/图像太差, 才需要重录。
*--------------------------------------------------------------*/
printf("对比两次指纹(参考).rn");
ensure = PS_Match();
if (ensure == 0x00)
printf("两次指纹精确匹配.rn");
else
printf("Match=%02X(不阻断流程), 继续合并, 以 RegModel 结果为准.rn", ensure);
/*---------------- 合并模板并存储(真正判定) ----------------*/
ensure = PS_RegModel(); // 0x05: 合并 Buffer1+Buffer2
if (ensure != 0x00)
{
printf("生成指纹模板失败(0x0a=合并失败): %srn", EnsureMessage(ensure));
printf("请重录: 同一根手指按在采集窗正中间、按实, 两次位置尽量一致;rn");
printf(" 手指别太干, 并确认模组独立稳定供电、镜头保护膜已撕。rn");
BEEP = 1; delay_ms(500); BEEP = 0;
delay_ms(1500);
continue; // 合并失败, 重来
}
printf("生成指纹模板成功(两次特征融合成功).rn");
ensure = PS_StoreChar(CharBuffer1, ID); // 0x06: 存到 ID 号位置
if (ensure == 0x00)
{
printf("添加指纹成功, ID=%d.rn", ID);
PS_ValidTempleteNum(&ValidN); // 读一下当前库存
printf("当前有效指纹个数:%drn", ValidN);
BEEP = 1; delay_ms(100); BEEP = 0; delay_ms(100); // 成功"嘀嘀"两声
BEEP = 1; delay_ms(100); BEEP = 0; delay_ms(100);
return;
}
printf("存储指纹失败: %srn", EnsureMessage(ensure));
delay_ms(1500);
}
}
整个录入链条是:等按下→采图→生成特征到Buf1→等抬起→等再按下→采图→生成特征到Buf2→(Match参考)→RegModel合并判定→StoreChar存库。任何一步失败都会打印中文原因并 continue 重来,体验很友好。
7.8 业务层③:刷指纹识别 press_FR(采图→生成特征→搜库)
待机时主循环约每500ms调一次,检测到手指就搜库。用了一个 armed 静态标志,保证一次按压只搜一次,手指抬起后才重新"布防",避免手指一直按着反复搜索刷屏:
// 函数功能: 搜索指纹(刷指纹开锁)
// 返回值: -1表示搜索失败 >=0 表示搜索到的指纹ID
int press_FR(void)
{
static u8 armed = 1; // 1=已布防, 可以检测新手指示
SearchResult seach;
u8 ensure;
ensure = PS_GetImage();
if (ensure == 0x00) // 检测到手指
{
if (!armed) return -1; // 本次按压已处理过, 等手指抬起
armed = 0; // 撤防
ensure = PS_GenChar(CharBuffer1);
if (ensure == 0x00) // 生成特征成功
{
// ZW101 不支持 0x1B 高速搜索, 使用 0x04 普通搜索, 搜索整个指纹库
ensure = PS_Search(CharBuffer1, 0, FP_LIB_MAX, &seach);
if (ensure == 0x00) // 搜索成功
{
printf("搜索指纹成功.rn");
printf("Match ID:%d Match score:%drn", seach.pageID, seach.mathscore);
TOUCH_ID = seach.pageID; // 保存指纹ID
return seach.pageID; // 返回ID(>=0), 主程序据此开锁
}
else
{
ShowErrMessage(ensure); // 0x09=没搜到等
}
}
else
{
ShowErrMessage(ensure);
}
delay_ms(1000); // 失败提示停留
}
else
{
if (ensure == 0x02)
armed = 1; // 传感器无手指=手指已抬起, 重新布防
else if (ensure != 0xff) // 通信超时不打印, 避免刷屏
printf("获取图像失败...%srn", EnsureMessage(ensure));
}
return -1;
}
识别链条是:采图(GetImage)→生成特征到Buf1(GenChar)→搜库(Search,从0页搜FP_LIB_MAX页)→命中返回ID和分数。注意这里识别用的是 Search(0x04),不是 Match/Compare——Search 才是"拿当前指纹去整个指纹库里1:N找人",Match 只是"1:1比对两枚特征",这是踩坑②的核心。
7.9 业务层④:删除/清空指纹
// 删除指纹
// num: 要删除的指纹ID; 传入 0xFF00 表示清空整个指纹库
void Del_FR(int num)
{
u8 ensure;
printf("显示删除指纹: %d.rn", num);
delay_ms(50);
if (num == 0xFF00)
ensure = PS_Empty(); // 0x0D: 清空指纹库
else
ensure = PS_DeletChar(num, 1); // 0x0C: 从 num 开始删 1 个
if (ensure == 0)
printf("删除指纹成功.rn");
else
ShowErrMessage(ensure);
delay_ms(1500);
PS_ValidTempleteNum(&ValidN); // 读剩余个数
printf("显示剩余指纹个数:%drn", ValidN);
}
PS_DeletChar(PageID, N) 支持从某个ID开始连续删N个,删一个就传N=1;想全部清空就发 PS_Empty()(0x0D)。
7.10 main.c 主流程:初始化握手→待机循环
主函数做的事:初始化外设→和模块握手(失败就一直重试)→读指纹个数和参数→进入主循环(检测按键进管理菜单 / 周期检测指纹开锁)。
int main()
{
JTAG_Set(SWD_ENABLE); // 关闭JTAG保留SWD, 释放PA15/PB3/PB4
u8 key = 0;
u32 time_cnt = 0;
int run_state = 0;
BEEP_Init(); // 蜂鸣器
LED_Init(); // LED
USART1_Init(115200); // 串口1: 打印调试日志
printf("666串口1初始化成功..rn");
BEEP = 1; LED1 = 0; LED2 = 0; LED3 = 0; delay_ms(500);
BEEP = 0; LED1 = 1; LED2 = 1; LED3 = 1; delay_ms(500);
KEY_Init(); // 按键
Oled_Init(); OLED_Clear(); // 0.96寸OLED
USART3_Init(57600); // 串口3: 接指纹模块(ZW101 默认57600)
TIMER3_Init(72, 20000); // 定时器3: 辅助串口3接收, 20ms空闲超时成帧
PS_StaGPIO_Init(); // 触摸状态脚 PB9
// 与 HLK-ZW101 握手(指令码0x35), 失败一直重试
while (PS_HandShake(&ZW101Addr))
{
OLED_Clear();
OLED_ShowString(0, 16 * 0, 16, "ZW101 Init Error...");
OLED_RefreshGRAM();
printf("连接ZW101指纹模块失败....rn");
delay_ms(1000);
}
printf("ZW101指纹模块连接成功.....rn");
OLED_Clear();
OLED_ShowString(0, 16 * 0, 16, "ZW101 Init Success...");
OLED_RefreshGRAM();
delay_ms(1000); delay_ms(1000); delay_ms(1000);
u8 ensure = PS_ValidTempleteNum(&ValidN); // 读库指纹个数
if (ensure != 0x00) printf("ensure:%d %srn", ensure, EnsureMessage(ensure));
ensure = PS_ReadSysPara(&ZW101Para); // 读模块参数(容量/等级/波特率)
if (ensure == 0x00)
printf("指纹最大容量:%d 安全等级:%drn", ZW101Para.PS_max, ZW101Para.PS_level);
else
printf("ensure:%d %srn", ensure, EnsureMessage(ensure));
while (1)
{
key = KEY_Scan(); // 按键检测
if (key) { beep_prompt(); printf("key=%drn", key); }
// K1: 进入管理员验证(密码1212), 通过后可录指纹/删指纹
if (key == 1)
{
OLED_Clear(); OLED_RefreshGRAM();
run_state = page5_pass_open(1); // 输管理员密码
if (run_state == 0)
{
page3_EnterFingerprint(); // 指纹录入页面
OLED_Clear(); OLED_RefreshGRAM();
page4_DeleteFingerprint(); // 指纹删除页面
OLED_Clear(); OLED_RefreshGRAM();
}
}
// 约每 500ms 一次
if (time_cnt > 50)
{
LED1 = !LED1; LED2 = !LED2; // 系统运行心跳灯
time_cnt = 0;
// 待机周期采图检测手指, 命中指纹则开锁
run_state = press_FR();
if (run_state >= 0)
{
close_lock(); // 解锁(可在此驱动继电器/电磁锁)
}
}
DelayMs(10);
time_cnt++;
}
}
说明:工程里
open_lock()/close_lock()是预留的锁控函数,你可以在里面拉高/拉低一个GPIO去驱动继电器、电磁锁或舵机。close_lock()在识别成功后被调用,表示"解锁"。蓝线触摸脚PB9工程里做了初始化,但待机采图轮询已经够用,所以主循环不依赖它(ZW101的触摸信号在模组进入休眠流程后才有效,本工程不休眠)。
八、完整录入/识别流程
8.1 录入流程(一枚指纹要采两次)
录入一枚指纹,标准流程是"两次采集、合并成模板、存库",步骤如下:
1. 握手:上电发0x35确认模块在线;
2. 读参数确认:发0x1D读库存、0x0F读参数,确认容量/等级正常;
3. 第一次按手指:WaitFingerDown 等到 GetImage 返回0x00 → GenChar(Buffer1) 生成特征存入特征缓冲区1,蜂鸣器"嘀"一声;
4. 松开手指:WaitFingerUp 等到 GetImage 返回0x02(无手指);
5. 第二次按同一根手指:WaitFingerDown → GenChar(Buffer2)生成特征存入缓冲区2;
6. (可选)Match参考:发0x03精确比对两枚特征,结果只打印不阻断;
7. 合并建模:发 RegModel(0x05),把Buf1、Buf2两枚特征融合成一枚完整模板。返回0x00=融合成功(说明是同一根手指且质量合格);返回0x0A=合并失败(两次不是同一手指或图像太差),需重录;
8. 存库:发 StoreChar(0x06),把模板存到指定ID;成功后蜂鸣器"嘀嘀"两声,并读回库存个数。
为什么要采两次?因为单次按手指接触面有限、角度随机,模块把两次(甚至多次)采集的特征融合成一枚更完整的模板,识别率更高、更抗角度变化。这也是为什么两次必须是同一根手指,否则 RegModel 会返回 0x0A。
8.2 识别流程(刷指纹开锁)
识别是"1:N搜库",流程如下:
1. 待机时主循环约每500ms调一次 press_FR();
2. GetImage(0x01) 采图:返回0x02说明没手指,静默返回;返回0x00说明有手指;
3. GenChar(Buffer1) 把当前图像生成特征;
4. Search(0x04) 用这枚特征从第0页开始搜索整个指纹库(FP_LIB_MAX页);
5. 返回0x00=搜到:应答里带命中的 pageID(指纹ID)和 mathscore(匹配分数),打印并返回ID,主程序执行开锁(蜂鸣器+OLED"开锁成功"+驱动锁);
6. 返回0x09=没搜到:说明这根手指没录过(或按得太差),提示失败;
7. 用 armed 标志保证一次按压只搜一次,等手指抬起(再次0x02)后才重新布防。
九、调试踩坑实录(重点!两个真实大坑)
这部分是全文最值钱的地方。从AS608移植到ZW101,我前后卡了两个坑,都是"代码看着没问题、功能就是不对"的那种。把现象、排查、根因、解法完整写出来,大家遇到能直接对号入座。
坑①:手指检测不稳——明明按了手指,模块时灵时不灵
【现象】
录指纹时,手指都按在采集窗上了,屏幕却一直停在"请按下指纹";有时候按一下有反应、有时候要按好几次才响应;偶尔录到一半又突然报无手指。体验非常差,根本没法用。
【最初的错误写法】
一开始图省事,用的是"固定延时轮询":发一次采图指令,然后 delay_ms(1000) 等一秒,再去读结果。问题就在这——
- • 延时太短:模块还没完成采集,你就去读,读到的是旧状态;• 延时太长:用户手指可能已经抬起了,你还在等,错过时机;• 固定延时本质是"猜",猜用户什么时候按、按多久,怎么猜都不稳。
再加上当时指纹模块是直接插在STM32板子的3.3V引脚上取电,供电电流不足,采集瞬间电压跌落,模块应答时有时无,雪上加霜。
【排查过程】
1. 先用串口助手/串口日志盯着 PS_GetImage() 的返回码看,发现没手指时稳定返回 0x02(传感器上没有手指),有手指且图像有效时返回 0x00。这两个码就是判断手指状态最可靠的依据,根本不用延时去猜。
2. 万用表量模块VCC电压,采集瞬间电压被拉低,确认供电不足。
3. 检查模块,发现新模块镜头上的保护膜没撕,成像受影响。
【解决办法】
• 软件上:废弃固定延时,改成主动等待的 WaitFingerDown() / WaitFingerUp() 两个函数(代码见7.6节)。循环调用 PS_GetImage(),用返回码判断:0x00=有手指(按下)、0x02=无手指(抬起/没按)。轮询间隔从原来的1s缩短到300ms,捕捉手指更灵敏;每次采集成功蜂鸣器短响一声,盲操作也有反馈。
• 硬件上:• ① 指纹模块改用独立、稳定的3.3V供电(别用STM32板载3.3V硬带),GND共地,万用表确认VCC稳定在3.3V±0.1V;
• ② 手指按在采集窗正中间、按实,不要只搭边;
• ③ 撕掉镜头保护膜;
• ④ 手指太干会返回0x04,哈口气或搓一搓再按。
改完之后,手指一按就有响应,百发百中。这个坑告诉我们:和硬件打交道,"主动查询状态"永远比"被动延时猜测"可靠;软件问题和硬件供电问题要一起查。
坑②:Search/Match 始终失败,指纹就是"匹配不上"
【现象】
指纹明明按流程录进去了(StoreChar也返回成功了),可一到识别环节,刷手指就是不开锁。日志里要么返回 0x08(指纹不匹配),要么搜索返回失败,怎么按都搜不到。一度怀疑模块坏了。
【排查过程与根因】
这个坑的根子在于没分清几个指令到底是干嘛的,想当然地用错了"判官"。我梳理一下三个容易混淆的指令:
| 指令 | 码 | 作用 | 用在哪 |
|---|---|---|---|
| PS_Match | 0x03 | 1:1精确比对 Buffer1和Buffer2里的两枚特征,只告诉你"这俩像不像" | 录入时两次采集之间做参考 |
| PS_RegModel | 0x05 | 把Buffer1、Buffer2两枚特征合并融合成一枚模板 | 录入时判断是否合格的判官 |
| PS_Search | 0x04 | 拿Buffer里的特征去整个指纹库做1:N搜索,返回命中的ID和分数 | 识别开锁的唯一正确指令 |
我先后犯了两个错误:
错误1:识别时用 Compare/Match(0x03)去"匹配指纹"。
Match只是比对缓冲区里那两枚特征,它根本不会去指纹库里找。你录好的指纹是存在库里的,光Match缓冲区里的东西,当然永远"匹配不上"。识别开锁要的是"这根手指是谁",必须用 Search(0x04) 搜库,让模块拿当前特征去和库里所有模板逐一比对,命中就返回ID和分数。
错误2:录入时把 Match(0x03) 当成合格判官,Match一返回0x08就判定录入失败、推倒重来。
两次按手指,角度、接触面积不可能完全一样。Match是"精确比对",稍有差异就可能返回0x08(不匹配),但这不代表两次不是同一根手指、也不代表不能融合。官方注册流程根本不拿Match卡流程。真正判断两次采集能不能合成一枚合格模板的,是 RegModel(0x05):
RegModel 返回 0x00 = 两枚特征融合成功,说明确实是同一根手指、图像质量合格,可以存库;
•RegModel 返回 0x0A = 特征合并失败,说明两次差异太大(不是同一根手指)或图像质量太差,这时候才需要重新录。
还有一个前提容易漏:必须两次采集、合并成一枚模板(Buf1+Buf2→RegModel),再 StoreChar 存进库,这枚指纹之后才能被 Search 搜到。 如果只采一次、或者没合并就存,库里的模板是不完整/无效的,搜索自然搜不到。
【解决办法】
• 录入流程固定为:第一次采图→GenChar(Buf1)→等抬起→第二次采图→GenChar(Buf2)→RegModel合并(以它的0x00/0x0A判定成败)→StoreChar存库;Match只打印结果做参考,不阻断流程;
• 识别流程固定为:采图→GenChar(Buf1)→Search(0x04)从0页搜整个库,用返回的确认码判断:0x00=搜到(带ID和分数),0x09=没搜到(这根手指没录过);
• 不要用AS608的 0x1B 高速搜索指令,ZW101不支持,统一用0x04;
• 录入时两次用同一根手指、按在正中间、位置尽量一致,能大幅降低 RegModel 返回0x0A的概率。
改完之后,录入的指纹一刷一个准,串口能稳定打印 Match ID:x Match score:xxx,分数越高越匹配。
一句话总结坑②:录入看 RegModel,识别用 Search,Match 只是参考。 记住这三句,指纹识别的逻辑就通了。
图8 调试踩坑前后串口日志对比
十、常见问题 FAQ
Q1:模块完全没应答,握手一直失败(OLED显示 Init Error)?
A:按这个顺序查:① 供电——是否3.3V、电流够不够、GND有没有共地(供电是头号原因);② 接线——TX/RX是否交叉接(STM32的PB10 TX接模块RX,PB11 RX接模块TX),杜邦线有没有松动虚接;③ 波特率——USART3必须是 57600、8N1;④ 模块是否上电正常(可观察模块灯,ZW101上电初始化成功会主动发0x55)。
Q2:串口打印乱码?
A:波特率不匹配。调试串口USART1是115200,指纹串口USART3是57600,别搞混;确认晶振是8MHz、系统时钟72MHz,延时和波特率才准。
Q3:录入时 RegModel 一直返回 0x0A(特征合并失败)?
A:两次不是同一根手指,或两次图像质量差/位置差太多。用同一根手指、按采集窗正中间、按实,两次位置尽量一致;手指别太干;确认保护膜已撕、供电稳定。
Q4:识别时一直返回 0x09(没搜索到指纹)?
A:这根手指没录成功/没存进库,或按得太差。先重新完整录一次(确认StoreChar返回0x00、ValidTempleteNum数量增加),识别时手指按正按实;也可能是录的时候没走RegModel合并、模板无效。
Q5:返回 0x08(指纹不匹配)要紧吗?
A:在录入环节Match返回0x08是正常的(两次角度有差异),不用管,以RegModel为准;在识别环节你根本不会看到0x08——识别用的是Search,失败返回的是0x09。如果你在识别时去调了Match,那就是用错指令了,见坑②。
Q6:返回 0x07(特征点太少)?
A:手指没按实、接触面积太小,或镜头保护膜没撕、镜头脏。把手指按满采集窗、清洁镜头。
Q7:能存多少枚指纹?存满了怎么办?
A:ZW101默认版本存 50枚(ID 0~49,工程里FP_PAGE_MAX=49)。存满返回0x1F(指纹库满),可以用PS_Empty清空,或删除不用的ID。大容量版本(如ZW111)支持100枚。
Q8:一定要接触摸感应脚(蓝线PB9)吗?
A:本工程不强制。ZW101的TOUCH触摸信号在模组进入休眠流程后才有效,本工程不休眠、待机直接周期采图检测手指,所以蓝线可接可不接。想做低功耗唤醒时再用它。
Q9:搜索分数(mathscore)多少算通过?
A:Search只要返回0x00就是达到安全等级阈值、通过了。安全等级默认Level2(共1~5级),等级越高阈值越严、越安全但越难识别,可通过写寄存器指令调整。
Q10:AS608的老代码能直接烧给ZW101用吗?
A:大部分能(协议兼容),但务必改4点:握手用0x35、读系统参数字段顺序按ZW101解析、不要用0x1B高速搜索(改用0x04)、注意上电0x55。详见第三章。
十一、应用场景与扩展玩法
学会驱动指纹模块后,能做的东西非常多:
指纹门锁/门禁:识别成功驱动继电器、电磁锁或舵机开锁,配OLED显示和蜂鸣器提示,就是本文成品;•
指纹考勤机:Search返回的ID对应用工号,加个RTC时钟,把"ID+时间"存下来就是打卡记录;•
指纹保险柜/储物箱:指纹开锁替代机械钥匙/密码,安全又方便;•
电子密码锁升级:在密码锁基础上加指纹,指纹+密码双重认证;•
电动车/摩托车防盗启动:指纹验证通过才允许点火;•
权限分级:利用指纹ID区分管理员/普通用户,不同ID开放不同功能;•
联网扩展:ZW101只有串口,你可以再加一颗蓝牙/WiFi模块(如HC-05、ESP8266),把开锁记录、报警信息推送到手机,或做远程临时授权;•
低功耗电池供电:利用ZW101的休眠(8μA级)+触摸唤醒脚,做成电池供电的无线门锁。
ZW101本身还支持RGB灯自定义颜色/闪烁、上传指纹图像、自动注册(PS_AutoEnroll一站式注册)等高级功能,手册里都有,入门后可以慢慢挖。
十二、资料与工程获取
本文涉及的完整工程(含 hlk_zw101.h / hlk_zw101.c 驱动、main.c、OLED/按键/蜂鸣器/串口/定时器驱动)、ZW101官方协议手册、录删识别完整源码,都已经打包整理好。
项目开发使用的全部软件工具已经上传到网盘:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink
743