简介:面向STM32单片机学习者与嵌入式入门开发者的AS608指纹识别实践工程,围绕STM32C8T6主控与AS608指纹模块的串口通信、指令协议和二次开发展开,适合课程设计、毕业设计及小型生物识别项目参考。压缩包共211个文件,约7.04MB,以C源文件与头文件、Keil工程配置、编译中间产物及HEX烧录文件为主,可呈现从驱动源码、工程组织到构建输出的完整结构。已有1914人学习,说明该案例在同类实践中具有一定参考面。读者可据此了解AS608模块的电源、串口收发、触摸感应与状态引脚等硬件连接思路,结合芯片手册核对指纹采集、比对等控制指令,并借助现成工程快速搭建验证环境,减少底层配置与联调时的重复摸索。
1. 从一根四芯排线说起:STM32C8T6 配 AS608 指纹识别到底能做到什么程度
我见过太多这样的场景:模块到手,四根杜邦线一插,串口助手敲一条EF 01 FF FF FF FF 01 00 03 0F 00 09,屏幕跳回00,心里 "成了"。然后关掉串口助手,第二天要把它做成门禁,发现指纹存哪、ID 怎么管、按键怎么响应、断电能记住吗,全是空白。STM32C8T6 加 AS608 这套组合真正的价值不在那一行握手指令,而在于它能把 "采集—建模板—入库—比对—删除" 整条链路放在一颗成本极低的 MCU 上跑完,还留得下 OLED、按键、继电器这些外设的位置。
这份工程源码做的就是这样一件事:把 AS608 的串口协议层剥出来,套一层指纹业务状态机,再接一个不阻塞的菜单界面。适合已经会点灯、会调串口、手上正好有块 F103 最小系统板的人,拿去做宿舍门禁、储物柜、考勤打卡这类能现场演示的小系统。它不复杂,但该踩的坑一个不少。
2. 硬件连法与工程骨架:先把接线表和目录树定死
动手写代码之前,我一般会把接线和目录结构先画在纸上。原因很实际:AS608 这类模块,硬件接错和软件写错的现象高度相似——都是没回包。先把物理层钉死,后面排查才有对照。这一章把引脚分配、供电、工程分层、库选型四件事一次讲清楚。
2.1 引脚分配:AS608 为什么挂在 USART2 上
AS608 是标准 UART 接口,默认波特率 57600,8 位数据位、1 位停止位、无校验。C8T6 有 USART1、USART2、USART3 三个可用串口,工程里选 USART2,理由很朴素:USART1 的 PA9/PA10 通常留着做调试打印或者配合串口下载,如果指纹模块也占这里,调试信息就和模块数据抢同一个通道,日志和协议帧混在一起,抓包时非常难受。
接线表如下,注意 TX/RX 必须交叉:
| AS608 引脚 | STM32C8T6 引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 模块工作电压 3.3V,取指纹瞬间电流会冲到 60mA 以上 |
| GND | GND | 必须共地,且尽量短 |
| TXD | PA3(USART2_RX) | 模块发,MCU 收 |
| RXD | PA2(USART2_TX) | MCU 发,模块收 |
| TOUCH | PC13(可选) | 手指触摸感应输出,可做低功耗唤醒 |
| VA | 悬空或接 LED | 手指按下指示 |
供电这一条值得单拎出来说。最小系统板上的 3.3V 大多来自 AMS1117,标称能出 800mA,但板上还有 OLED、蜂鸣器一起抢,指纹模块取图瞬间的浪涌很容易把电压拉到 3.0V 以下,模块直接复位、回包变成乱码。血泪经验是:AS608 单独走一路 LDO,或者至少在 VCC 和 GND 之间并一颗 100μF 电解加 0.1μF 瓷片,别省这两个元件。
HAL 库下 USART2 的初始化大概长这样,参数没什么花活,重点是波特率和模块出厂值对齐:
static void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 57600; /* AS608 出厂默认,改过要同步 */ huart2.Init.WordLength = UART_WORDLENGTH_8B; /* 8 位数据位 */ huart2.Init.StopBits = UART_STOPBITS_1; /* 1 位停止位 */ huart2.Init.Parity = UART_PARITY_NONE; /* 无校验,校验靠协议层自己做 */ huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart2) != HAL_OK) { Error_Handler(); } /* 开启接收中断,一个字节一个字节地喂给状态机 */ __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE); }这里没有用HAL_UART_Receive_IT配缓冲区,而是直接开 RXNE 中断,原因是 AS608 的回包长度不固定,短则 12 字节,长则带模板数据可以到上百字节,用固定长度的HAL_UART_Receive_IT收会一直等到凑满或者超时,和状态机的节奏对不上。开 RXNE 后每来一个字节进一次中断,由状态机决定这一字节属于哪个字段,什么时候算一帧结束。
2.2 目录结构:把协议层从业务层里剥出来
工程目录如果一开始不分层,写到第三周基本就烂了。这套代码按四层组织:
firmware/ ├── Core/ /* CubeMX 生成的启动、时钟、中断入口 */ │ ├── Src/main.c │ ├── Src/stm32f1xx_it.c │ └── Inc/main.h ├── Bsp/ /* 板级驱动,只跟硬件打交道 */ │ ├── bsp_uart.c/.h /* USART2 收发封装 */ │ ├── bsp_oled.c/.h /* SSD1306 I2C 驱动 */ │ ├── bsp_key.c/.h /* 按键扫描与消抖 */ │ └── bsp_flash.c/.h /* 内部 Flash 读写,存映射表 */ ├── Protocol/ /* 纯 C,不依赖 HAL,可拿到 PC 上单测 */ │ ├── as608_packet.c/.h /* 组包、校验和、收包状态机 */ │ └── as608_cmd.c/.h /* 各条指令的语义封装 */ └── App/ /* 业务逻辑 */ ├── app_fingerprint.c/.h /* 录入、搜索、删除状态机 */ ├── app_menu.c/.h /* 菜单与界面刷新 */ └── app_main.c/.h /* 调度入口,主循环里的节拍 */关键在Protocol/这一层。它只依赖两个回调:一个发字节,一个取系统毫秒数。这样在 PC 上挂个假的串口回放器,就能把组包、解析、错误码映射全测一遍,不用每次都烧板子。我见过很多同类工程把协议代码直接写在main.c的while(1)里,改一个字节长度要重新编译下载三十秒,调试效率差一个数量级。
2.3 库选型:标准库还是 HAL,别在这上面纠结太久
这套工程的底层用的是 HAL,配 CubeMX 生成时钟和引脚,好处是 USART、I2C、TIM 的初始化不用手抄寄存器,改引脚时点两下重生成。缺点是 HAL 的 UART 中断回调层层封装,一个字节进来要跑几十行才到你的HAL_UART_RxCpltCallback,115200 波特率下勉强够用,57600 完全没压力。
有人偏爱标准库(SPL),代码小、直给、查寄存器方便,尤其是老项目迁移过来时更顺手。我的看法是:这套东西的数据量很小,一秒钟最多几百字节,两种库都跑得动,选你手上现成工程模板的那一种就行。真正影响成败的是协议层写得对不对、状态机有没有卡死,而不是HAL_UART_Transmit比USART_SendData多花了几微秒。
唯一需要注意的是 HAL 的HAL_Delay在中断里不能用,指纹录入流程里有等待用户按压的环节,必须用基于HAL_GetTick()的非阻塞计时,不能图省事直接HAL_Delay(1000)卡在那。这一点在第 4 章的状态机会具体展开。
3. 协议层拆解:一条指令从组包到回包的完整链路
AS608 的通信协议不复杂,但没有文档在手边时,光靠猜很难把校验和与包长度对上。这一章按 "帧结构 → 组包 → 收包 → 语义解析" 的顺序拆,每一段都能直接抄进工程。
3.1 帧结构:包长度到底算哪几个字节
一条完整的指令包由六部分组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| 包头 | 2 字节 | 固定0xEF 0x01 |
| 芯片地址 | 4 字节 | 默认0xFF FF FF FF,多模块级联时才改 |
| 包标识 | 1 字节 | 0x01指令包,0x02数据包,0x07应答包,0x08结束包 |
| 包长度 | 2 字节 | 高字节在前,值 = 指令/数据长度 + 3 |
| 指令/数据 | N 字节 | 指令码在前,参数在后 |
| 校验和 | 2 字节 | 高字节恒为 0,低字节是累加和 |
最容易搞错的是包长度。它不是整帧长度,也不是参数长度,而是 "指令码 1 字节 + 参数 N 字节 + 校验和 2 字节"。以校验密码指令EF 01 FF FF FF FF 01 00 07 13 00 00 00 00 00 1B为例,参数是 4 字节密码,指令码 1 字节,加校验和 2 字节,包长度 = 4 + 1 + 2 = 7,正好对上前面的00 07。
校验和的范围是从包标识开始,一直累加到数据最后一个字节,取低 16 位。注意是从包标识算起,包头和芯片地址不参与。这一点写错的话,模块会回0x01(收包错误),现象是 "指令发出去了,模块也说收到了,但就是不执行"。
/* 计算校验和:从包标识开始,到数据末尾结束 */ static uint16_t as608_checksum(const uint8_t *buf, uint16_t len) { uint16_t sum = 0; for (uint16_t i = 0; i < len; i++) { sum += buf[i]; } return sum; }传入的buf指向包标识(也就是帧的第 7 个字节),len是包标识 + 包长度 + 指令 + 参数的总字节数。返回值的低 16 位直接拆成高低两字节填进帧尾。写的时候别图省事只发 1 字节校验和,AS608 认的是 2 字节,少一字节整帧就废了。
3.2 组包与发送:一个能复用的通用函数
有了帧结构,组包就是按顺序往缓冲区里填。我一般写一个通用函数,指令码和参数分开传,上层调用时不用关心包头地址这些固定字段:
/* 通用指令发送 * cmd : 指令码,例如 0x01 GenImg、0x04 Search * param : 参数缓冲区,无参数时传 NULL * plen : 参数长度 * 返回 : 0 成功,负数失败 */ int as608_send_cmd(uint8_t cmd, const uint8_t *param, uint16_t plen) { uint8_t frame[128]; uint16_t idx = 0; uint16_t pkt_len; uint16_t sum; if (plen > sizeof(frame) - 14) { /* 14 = 包头2+地址4+标识1+长度2+指令1+校验2+余量2 */ return -1; } frame[idx++] = 0xEF; /* 包头 */ frame[idx++] = 0x01; frame[idx++] = 0xFF; /* 芯片地址,默认全 F */ frame[idx++] = 0xFF; frame[idx++] = 0xFF; frame[idx++] = 0xFF; frame[idx++] = 0x01; /* 包标识:指令包 */ pkt_len = plen + 3; /* 指令1 + 参数N + 校验2 */ frame[idx++] = (uint8_t)(pkt_len >> 8); frame[idx++] = (uint8_t)(pkt_len & 0xFF); frame[idx++] = cmd; for (uint16_t i = 0; i < plen; i++) { frame[idx++] = param[i]; } sum = as608_checksum(&frame[6], idx - 6); /* 从包标识开始算 */ frame[idx++] = (uint8_t)(sum >> 8); frame[idx++] = (uint8_t)(sum & 0xFF); return bsp_uart_send(frame, idx); /* 底层阻塞发送,超时 100ms */ }几个参数的作用要说清楚:frame开 128 字节是够用的,AS608 最长的下行指令是下载模板,参数可以到 128 字节以上,那种场景要单独开大缓冲;plen + 3里的 3 是固定开销;idx - 6是因为校验从下标 6(包标识位置)开始,包头 2 字节加地址 4 字节共 6 字节不参与。
底层发送用阻塞式HAL_UART_Transmit,超时给 100ms。有人担心阻塞影响响应,实际上一条指令最多几十字节,57600 波特率下传输时间不到 10ms,完全在可接受范围。真正要避免的是在中断里调这个函数。
3.3 收包状态机:为什么不能一行行等
新手最常写的收包是这样的:发完指令,HAL_UART_Receive等 12 个字节,然后解析。单条指令测试时没问题,一旦进入业务循环就翻车——因为回包长度不固定,搜索成功时是 16 字节,失败时是 12 字节,用固定长度收要么多等要么少收,超时时间还得拍脑袋定。
正确做法是字节级状态机。每来一个字节推进一次状态,收到完整帧就置标志:
typedef enum { RX_HEAD1 = 0, /* 等 0xEF */ RX_HEAD2, /* 等 0x01 */ RX_ADDR, /* 收 4 字节地址 */ RX_PID, /* 收包标识 */ RX_LEN_H, /* 包长度高字节 */ RX_LEN_L, /* 包长度低字节 */ RX_BODY, /* 收包长度个字节(含校验和) */ RX_DONE } rx_state_t; static rx_state_t s_state = RX_HEAD1; static uint8_t s_frame[128]; static uint16_t s_idx = 0; static uint16_t s_need = 0; /* 在 USART2 的 RXNE 中断里逐字节调用 */ void as608_rx_byte(uint8_t byte) { switch (s_state) { case RX_HEAD1: if (byte == 0xEF) { s_frame[0] = byte; s_idx = 1; s_state = RX_HEAD2; } break; case RX_HEAD2: if (byte == 0x01) { s_frame[1] = byte; s_idx = 2; s_state = RX_ADDR; } else { s_state = RX_HEAD1; } /* 头不对,重来 */ break; case RX_ADDR: s_frame[s_idx++] = byte; if (s_idx == 6) { s_state = RX_PID; } break; case RX_PID: s_frame[s_idx++] = byte; s_state = RX_LEN_H; break; case RX_LEN_H: s_frame[s_idx++] = byte; s_need = (uint16_t)byte << 8; s_state = RX_LEN_L; break; case RX_LEN_L: s_frame[s_idx++] = byte; s_need |= byte; /* 包长度:含校验和 */ if (s_need == 0 || s_need > sizeof(s_frame) - s_idx) { s_state = RX_HEAD1; /* 长度异常,丢弃 */ } else { s_state = RX_BODY; } break; case RX_BODY: s_frame[s_idx++] = byte; if (s_idx >= 9 + s_need) { /* 9 = 包头2+地址4+标识1+长度2 */ s_state = RX_DONE; } break; default: break; } }状态机里有个细节:s_need是包长度字段的值,它已经包含了校验和 2 字节,所以收满9 + s_need字节就算一帧结束,不用再额外加 2。这个偏移量算错的话,要么少收两字节导致校验失败,要么多等两字节卡死。
RX_DONE之后不要在里面直接调业务函数,中断里干活越少越好。我的做法是把帧拷进一个全局缓冲,置s_frame_ready = 1,主循环里轮询这个标志,取走数据后清标志。这样业务逻辑全在非中断上下文跑,避免重入问题。
3.4 应答解析:把确认码翻译成人能看懂的话
应答包的包标识是0x07,第一个数据字节是确认码,后面跟指令相关的返回数据。确认码一共十几种,全都背下来没必要,但常见的必须认识,否则调试时只能对着0x0A发呆。
| 确认码 | 含义 | 常见触发场景 |
|---|---|---|
| 0x00 | 成功 | 正常 |
| 0x01 | 收包错误 | 校验和算错、包长度不对 |
| 0x02 | 传感器无手指 | 录入时没按上去 |
| 0x03 | 录入图像失败 | 手指太干、太湿、偏移太多 |
| 0x06 | 图像太乱 | 指纹头有污渍 |
| 0x07 | 特征点太少 | 小指、儿童手指常见 |
| 0x08 | 指纹不匹配 | 1:1 比对失败 |
| 0x09 | 没搜索到 | 1:N 搜索库里没有 |
| 0x0A | 特征合并失败 | 两次按压不是同一手指 |
| 0x0B | 访问库地址越界 | ID 超出模块容量 |
| 0x10 | 删除模板失败 | ID 不存在 |
| 0x13 | 密码错误 | 校验密码指令对不上 |
| 0x1F | 指纹库满 | 模块容量用尽 |
解析函数只做两件事:校验和验证、把确认码和数据拆出来给上层。校验失败直接返回错误,别硬着头皮往下走,否则拿到的是上一帧的残留数据。
typedef struct { uint8_t confirm; /* 确认码 */ uint8_t data[64]; /* 返回数据,长度不定 */ uint16_t data_len; } as608_resp_t; int as608_parse_resp(const uint8_t *frame, uint16_t len, as608_resp_t *out) { uint16_t pkt_len, sum_calc, sum_recv; if (len < 12 || frame[0] != 0xEF || frame[1] != 0x01 || frame[6] != 0x07) { return -1; } pkt_len = ((uint16_t)frame[7] << 8) | frame[8]; if (9 + pkt_len != len) { return -2; /* 长度对不上 */ } sum_calc = as608_checksum(&frame[6], len - 8); /* 到校验和之前 */ sum_recv = ((uint16_t)frame[len - 2] << 8) | frame[len - 1]; if ((sum_calc & 0xFFFF) != sum_recv) { return -3; /* 校验失败,丢帧 */ } out->confirm = frame[9]; out->data_len = pkt_len - 3; /* 去掉确认码和校验和 */ if (out->data_len > 0) { memcpy(out->data, &frame[10], out->data_len); } return 0; }注意data_len的计算:包长度包含确认码 1 字节 + 数据 N 字节 + 校验和 2 字节,所以数据长度是pkt_len - 3。搜索成功时数据是 4 字节(页 ID 2 字节 + 匹配得分 2 字节),data_len正好等于 4。
4. 业务层落地:录入、搜索、删除三条主线的状态机
协议通只是及格线,真正决定这个项目能不能用的是业务层。指纹录入要等用户按两次,搜索要处理 "没找到" 这种正常失败,删模板要防止删掉不存在的 ID 把整个库搞乱。这一章把三条主线的状态机拆开写。
4.1 指纹录入:从第一次按压到模板入库
AS608 的录入流程是固定的五步指令序列:
0x01 GenImg采集图像0x02 Img2Tz生成特征,缓冲区号 10x01 GenImg再次采集0x02 Img2Tz生成特征,缓冲区号 20x05 RegModel合并两个缓冲区0x06 Store存入指定 ID
这里面第 1、3 步会返回0x02(无手指),这是正常现象,不是错误。很多新手看到0x02就当失败退出,结果永远录不进去。
状态机这样写:
typedef enum { ENROLL_IDLE = 0, ENROLL_WAIT_FIRST, /* 等第一次按压 */ ENROLL_WAIT_SECOND, /* 等第二次按压 */ ENROLL_STORE, ENROLL_OK, ENROLL_FAIL } enroll_state_t; static enroll_state_t s_enroll = ENROLL_IDLE; static uint16_t s_enroll_id; static uint32_t s_tick; /* 主循环里每 10ms 调一次 */ void app_enroll_poll(void) { if (s_enroll == ENROLL_IDLE) return; switch (s_enroll) { case ENROLL_WAIT_FIRST: if (as608_gen_img() == 0) { /* 采到图了 */ if (as608_img2tz(1) == 0) { s_enroll = ENROLL_WAIT_SECOND; oled_show("请再按一次"); } } else if (HAL_GetTick() - s_tick > 10000) { s_enroll = ENROLL_FAIL; /* 10 秒没按,超时 */ } break; case ENROLL_WAIT_SECOND: if (as608_gen_img() == 0) { if (as608_img2tz(2) == 0 && as608_reg_model() == 0) { s_enroll = ENROLL_STORE; } else { s_enroll = ENROLL_FAIL; /* 两次不是同一手指 */ } } else if (HAL_GetTick() - s_tick > 10000) { s_enroll = ENROLL_FAIL; } break; case ENROLL_STORE: if (as608_store(1, s_enroll_id) == 0) { s_enroll = ENROLL_OK; bsp_flash_bind(s_enroll_id, s_current_user); /* 绑定业务 ID */ } else { s_enroll = ENROLL_FAIL; } break; default: break; } }参数说明:img2tz的入参是缓冲区号,只能是 1 或 2;reg_model不带参数,内部固定合并 1、2 两个缓冲区;store的入参是缓冲区号和模板 ID,ID 范围取决于模块容量(常见 300 或 1000,读系统参数能拿到)。
这里的关键是超时用HAL_GetTick()差值判断,而不是HAL_Delay。HAL_Delay会把整个主循环卡住 10 秒,界面不刷新、按键没响应,用户以为死机了。用状态机 + 时间戳,等待期间 OLED 可以正常显示倒计时,体验完全不同。
另外注意RegModel成功但Store失败的情况。多半是 ID 超出容量或者 Flash 写保护,先把 ID 校验做在状态机外面,别等存的时候才发现。
4.2 1:N 搜索与 1:1 比对的差别
这两个功能经常被混为一谈。1:1 比对是 "你说你是谁,我验证一下",需要先输入 ID,模块把该 ID 的模板载入缓冲区,和你按的手指比;1:N 搜索是 "我不管你是谁,库里挨个比一遍,告诉我匹配到哪个 ID"。
门禁场景用搜索更顺手——用户不用输 ID,按一下就开门。考勤场景常用比对,因为要绑定工号。两条指令的参数不一样:
/* 1:N 搜索:在 [start_id, start_id + page_num) 范围里找 */ int as608_search(uint16_t start_id, uint16_t page_num, uint16_t *out_id, uint16_t *out_score) { uint8_t param[5]; param[0] = 0x01; /* 缓冲区号 */ param[1] = (uint8_t)(start_id >> 8); param[2] = (uint8_t)(start_id & 0xFF); param[3] = (uint8_t)(page_num >> 8); param[4] = (uint8_t)(page_num & 0xFF); as608_send_cmd(0x04, param, 5); /* 应答:确认码 + 页ID(2) + 匹配得分(2) */ if (wait_resp(1000) != 0) return -1; if (g_resp.confirm != 0x00) return g_resp.confirm; *out_id = ((uint16_t)g_resp.data[0] << 8) | g_resp.data[1]; *out_score = ((uint16_t)g_resp.data[2] << 8) | g_resp.data[3]; return 0; }page_num一般填模块容量,比如 300,表示从start_id开始搜 300 个位置。如果只关心前 50 个用户,填 50 能明显快一些——AS608 的搜索耗时和范围成正比,全库搜索在 300 容量下大概 300ms 上下,范围缩小到 50 可以压到 100ms 以内。
out_score是匹配得分,值越小越像。工程里可以设个阈值,比如得分大于 100 就不开门,防止误识。这个阈值没有统一标准,得拿实际手指试,我一般先按 80 起步,误识多了再往下调。
1:1 比对用0x03 Match,参数只有缓冲区号,先把模板load_char进缓冲区,再采图、生成特征、比对。三步不能少,少一步就返回0x08。
4.3 OLED 菜单与按键:怎么让界面不卡死
界面这块最容易写成阻塞式:按下按键,delay(200)消抖,刷新屏幕,delay(100)等待,主循环一圈下来 300ms 过去了,指纹搜索的响应全被拖慢。
正确做法是把 "消抖" 和 "刷新" 都做成定时任务。按键用 10ms 节拍扫描,连续两次读到相同电平才认;OLED 只在内容变化时刷新,不是每圈都刷。I2C 刷一屏 128x64 大概 20ms,每圈都刷会把主循环周期撑到 20ms 以上。
/* 10ms 节拍里调用 */ void bsp_key_scan(void) { static uint8_t last = 0xFF; uint8_t now = 0; if (HAL_GPIO_ReadPin(KEY_UP_PORT, KEY_UP_PIN) == GPIO_PIN_RESET) now |= 0x01; if (HAL_GPIO_ReadPin(KEY_DOWN_PORT, KEY_DOWN_PIN) == GPIO_PIN_RESET) now |= 0x02; if (HAL_GPIO_ReadPin(KEY_OK_PORT, KEY_OK_PIN) == GPIO_PIN_RESET) now |= 0x04; g_key_pressed = (uint8_t)(last & ~now); /* 下降沿:这次按下、上次没按 */ last = now; }last & ~now得到的是 "上一次是高、这一次是低" 的位,也就是按下瞬间。松手检测同理用~last & now。这套写法不需要任何delay,节拍由定时器中断提供,主循环只管读g_key_pressed。
菜单状态用一个枚举加一个索引就够了,别上什么状态机框架:
typedef enum { MENU_MAIN = 0, MENU_ENROLL, MENU_DELETE, MENU_VERIFY } menu_page_t;MENU_MAIN下上下键切选项,OK 键进子页;子页里 OK 键确认、长按返回。长按判断用按下时间戳,超过 800ms 就算长按。这套逻辑写下来不到 100 行,比引入任何 UI 库都直接。
4.4 指纹 ID 与业务数据的映射:用最后一页 Flash 存表
模块里存的是模板,业务上还要知道 "ID 3 是张三,ID 7 是李四"。C8T6 有 64KB Flash,最后 1KB(0x0800FC00)拿出来做参数区刚好。
存的结构体别搞复杂,一个 ID 加一个 4 字节用户编号就够:
typedef struct { uint16_t fp_id; /* 指纹模块里的模板 ID */ uint32_t user_no; /* 业务系统里的用户编号 */ uint8_t used; /* 0xFF 表示空位 */ } fp_bind_t; #define BIND_MAX 32 #define BIND_ADDR 0x0800FC00UL写入前先擦除整页(1KB),再把整个数组写回去。别用 "只改一条" 的思路做增量写,Flash 只能整页擦,增量写会让逻辑变复杂还容易写坏。32 条记录一共 224 字节,一次擦写不到 100ms,用户感知不到。
读的时候做个简单校验:如果整页全是0xFF,说明没初始化过,直接用默认值;如果used字段既不是 0 也不是 0xFF,说明写坏了,整体重置。这种 "宁可丢数据也别读脏数据" 的策略,在现场比什么容错都管用。
5. 避坑排查:AS608 调试中最容易翻车的五个地方
调试这类模块,现象往往高度相似,原因却差很远。这一章按 "现象 → 原因 → 解决" 整理五条,都是我实际撞过或者帮人排查过的。
5.1 上电完全不回包,串口助手一片空白
现象:接好线,串口助手发握手指令,一个字节都不回。换模块、换线、换板子都不行。
原因:按概率排,第一位是 TX/RX 没交叉。模块的 TXD 要接 MCU 的 RX,很多人按 "TX 接 TX" 的习惯直连,数据根本进不去。第二位是波特率,模块被上一任主人改过,不是 57600。第三位是供电,3.3V 拉不起来,模块反复复位。
解决:先用 USB-TTL 直接接模块,串口助手 57600 发握手,确认模块本身是活的;再接回 MCU,用示波器或者逻辑分析仪看 PA2 上有没有波形。没有波形是 MCU 发送端问题,有波形但模块不回是接线或波特率问题。供电用万用表量模块 VCC 对 GND,取图瞬间不能低于 3.1V。
5.2 确认码 0x0A,两次录入总是合并失败
现象:第一次按压返回 0x00,第二次也返回 0x00,到RegModel就变成 0x0A。
原因:两次按压被识别成不同手指。常见于按压姿势变化太大、手指太干、指纹头有油污。还有一种隐蔽情况:两次Img2Tz用的缓冲区号写成一样的了,都是 1,模块合并时发现缓冲区 2 是空的。
解决:先检查Img2Tz的入参,第一次必须是 1,第二次必须是 2。确认无误后,让用户两次按压保持同一姿势,指肚覆盖整个采集窗。指纹头用无水酒精棉片擦一遍,油污对成像质量影响很大。如果用户手指特别干,让他对着手指哈口气再按,成功率能上去一大截。
5.3 单条指令测试正常,连续操作用着用着就丢包
现象:单独发搜索指令没问题,接上 OLED 刷新和按键扫描后,每隔十几次就有一帧解析失败。
原因:中断里干活太多。OLED 走 I2C,如果用了 HAL 的阻塞式发送,一次刷屏 20ms 关中断或者占着总线,这期间 USART2 收到的字节就丢了。AS608 不会重发,丢一个字节整帧就废。
解决:把 OLED 刷新拆成按页刷,一次只刷 8 行(1 页),主循环轮着来,单次占用压到 2ms 以内。或者用硬件 I2C 的 DMA 模式,CPU 完全不参与。另外确认 USART2 中断优先级高于 I2C,且 I2C 里不要做长延时。
5.4 断电重启后指纹 ID 全乱,用户对不上号
现象:录入时显示 "张三 绑定 ID 5",断电重开,ID 5 变成了别人。
原因:绑定表存在 RAM 里,没落 Flash;或者落了 Flash 但读的时候没校验,读到脏数据当成有效记录。
解决:绑定表必须落 Flash,且写入时整页擦除后整体重写。读的时候加校验:used字段只认 0 和 0xFF,fp_id必须在模块容量范围内,user_no不能是 0。任何一条不满足就整表重置,宁可让用户重新绑,也别让系统带着错数据跑。
5.5 模块单独测试正常,接上电磁锁就频繁复位
现象:裸板跑得好好的,接上 12V 电磁锁和继电器,指纹模块隔几分钟复位一次,MCU 也偶尔重启。
原因:电磁锁断电瞬间的反向电动势通过继电器线圈串回 3.3V 轨,电源上出现几百毫伏的尖峰。MCU 的 Brown-out 检测一触发就复位。
解决:继电器线圈两端并续流二极管(1N4148 或 1N4007),触点和线圈之间加光耦隔离。电源侧,12V 和 3.3V 共地但走线分开,3.3V 入口加 TVS 和磁珠。这些是硬件基本功,但真到了现场,十个故障里有三个是电源引起的。
6. 二次开发:把指纹能力抽象成一个可替换的模块
这套代码最值得改的地方,是把指纹功能收敛到一个接口上,而不是散落在main.c各处。我的做法是定义一个操作表:
typedef struct { int (*init)(void); int (*enroll)(uint16_t id); int (*search)(uint16_t *id, uint16_t *score); int (*verify)(uint16_t id, uint16_t *score); int (*del)(uint16_t id); } fp_ops_t; extern const fp_ops_t fp_as608; /* 当前实现 */上层业务只认fp_ops_t,调fp_as608.enroll(5)就行。以后换成别的指纹方案,或者做成双模块冗余,只需要再实现一份fp_ops_t替换指针,业务代码一行不改。这个抽象看着简单,但能省掉后面大量的条件编译。
验证方法上,我习惯先在 PC 上跑协议层单测:把as608_packet.c单独拎出来,喂几组手工构造的帧,看解析结果对不对,尤其是校验和错、长度错、确认码非零这三种情况。再在板子上加一个 "回放模式",把录好的应答帧从数组里依次吐给状态机,不接模块也能把业务流程走完。这一步做完,现场调试的时间能砍掉一半。
| 改造点 | 改动量 | 收益 |
|---|---|---|
抽象fp_ops_t接口 | 约 80 行 | 换模块不动业务代码 |
| 增加搜索得分阈值 | 约 10 行 | 降低误识率 |
| Flash 绑定表加 CRC | 约 30 行 | 断电后数据可信 |
| 回放模式 | 约 60 行 | 无硬件也能测业务 |
从那次现场连着复位的经历之后,我每次做带继电器的项目,都会先把续流二极管和电源走线确认一遍再上电,不管多简单的板子。硬件问题最费时间的地方在于它会伪装成软件 bug,让你在代码里找一整天。希望帮到你。
本文还有配套的精品资源,点击获取