人脸识别技术作为计算机视觉领域的重要分支,近年来已在门禁控制、身份验证、考勤管理及智能支付等场景中获得广泛应用。其基本原理涉及图像采集、人脸检测、特征提取与模式匹配等复杂算法链路,传统实现方案通常依赖高性能计算平台与深度学习推理框架,这使得众多嵌入式开发者对人脸识别功能的集成望而却步。
针对上述痛点,本文提出一种基于STM32微控制器与BE5100S专用人脸识别模组的轻量化解决方案。BE5100S模组内部集成了图像传感器、红外补光单元、神经网络加速器及非易失性存储,出厂即预置完整的人脸检测与识别固件,对外提供标准UART串口指令接口。
该架构将视觉计算任务完全剥离于主控MCU之外,开发者仅需通过串口发送符合协议规范的命令帧,即可调用模组的全部功能,包括人脸注册、1:N识别、指定ID删除及全库清空等操作,全程无需编写任何AI算法或训练神经网络模型。本文将从硬件连接、通信协议解析、驱动层实现及业务层封装四个维度,系统阐述该方案的全流程开发方法,并附完整工程源码,为嵌入式系统开发者提供一套可直接移植至实际项目的技术参考。
这篇教程,会带着大家从零开始:认识模块、接线、吃透通信协议、写驱动、做人脸录入、刷脸开锁、删除清空,最后上OLED中文菜单。代码全部来自实际工程,注释写得比代码还多,保证0基础的同学照着做就能复刻出来。
老规矩,先上目录,我们一步步来。
一、先看效果:这套系统能干啥
先给大家交个底,看完这篇教程、把工程跑起来之后,你手上会有这么一套东西:
一颗STM32F103C8T6最小系统板,外接一块BE5100S人脸识别模块、一块0.96寸OLED屏、四个按键、一个蜂鸣器和几颗LED。上电之后:
1. OLED中文菜单:屏幕显示"人脸识别 / 请正对镜头",系统待机,自动循环刷脸;
2. 人脸录入:管理员输入密码(默认1212)进入管理菜单,选择"人脸录入",按下确定,把脸正对镜头,距离40~60cm,模块会实时提示你"靠近一点""往下一点",录入成功后自动分配一个ID(从1开始);
3. 刷脸识别开锁:录过的人往镜头前一站,模块识别成功,OLED显示"开锁成功 + ID号",蜂鸣器长响一声(实际产品这里接继电器/舵机驱动门锁);
4. 删除/清空人脸:管理菜单里可以删除指定ID的人脸,也可以一键清空整个脸库;
5. 姿态实时引导:录入和识别过程中,模块会不停上报你的脸部位置状态,屏幕和串口同步提示。
图1 整机实物效果:STM32驱动BE5100S人脸识别开锁系统
图2 OLED中文菜单各界面显示效果
图3 串口1调试日志(录入/识别/姿态提示一目了然)
二、认识主角:BE5100S人脸识别模块
2.1 它到底是个什么东西
BE5100S(资料里也标注为BE5100SZ系列)是一颗一体化的人脸识别终端模块。我们拆开看它的"内心",配置相当豪华:
• 高速双核嵌入式处理器;• 内置神经网络加速器(NPU),专门跑人脸识别算法;
• 双目3D深度算法,双百万级高清图像传感器(CMOS像素大于1280×720);
• 双IR红外镜头(850nm波长),带红外补光,暗光环境也能识别,还有一定的防照片/视频攻击能力;
• 内置人脸数据库,最多存 100个用户,掉电不丢失;
• 对外就一个 UART串口,作为从设备,听主控(我们的STM32)发号施令。
说人话就是:它把"看脸→算特征→比对脸库→出结果"这一整套活全自己干了,我们单片机只负责"下命令"和"读结果"。这就跟我们以前玩指纹模块(如AS608)是一个思路——脏活累活模块干,单片机当指挥官。
图4 BE5100S人脸识别模块实物特写(双目摄像头+红外补光)
2.2 引脚定义(接线必看)
模块对外是一个 1.25mm间距的4P连接器,一共就4根线,定义如下(来自官方用户手册):
| 引脚号 | 名称 | 方向 | 功能说明 |
|---|---|---|---|
| 1 | VDD | 输入(In) | 电源正极,5V供电(手册标注5.0~12.0V均可) |
| 2 | TX | 输出(Out) | 模块串口数据发送脚,TTL 3.3V电平,接单片机RX |
| 3 | RX | 输入(In) | 模块串口数据接收脚,TTL 3.3V电平,接单片机TX |
| 4 | GND | — | 地线 |
就4根线:电源2根(VDD、GND),串口2根(TX、RX)。简单到不能再简单。
注意交叉接线:模块的TX要接单片机的RX,模块的RX要接单片机的TX,是"交叉"的,不是同名相连。这个龙哥后面接线表里还会再强调。
2.3 主要参数特性
从官方用户手册里,龙哥把关键参数给大家摘出来(做产品选型和调试都用得上):
| 参数项 | 指标 |
|---|---|
| CMOS传感器 | 像素大于 1280 × 720(双目) |
| IR红外镜头波长 | 850nm |
| 人脸存储容量 | 100个用户(ID从1开始编号) |
| 搜索(识别)时间 | 热启动 < 0.6s(10个ID时) |
| 识别距离 | 30cm ~ 80cm(实操建议40~60cm最佳) |
| 通过率 TAR | 98% @ FAR=0.001%(误识率极低) |
| 通信接口 | UART串口 |
| 波特率 | 115200(默认),8数据位,1停止位,无校验(8N1) |
| 串口信号电平 | 3.3V TTL |
| 供电电压 | 5.0 ~ 12.0 V |
| 最大工作电流 | < 500mA |
| 工作温度 | -20°C ~ 60°C |
| 存储温度 | -40°C ~ 85°C |
几个数字大家记一下:100张人脸、115200波特率、3.3V电平、5V供电、识别距离40~60cm。这几个是后面接线和编程天天要用到的。
另外手册里还有几条"血泪提醒",龙哥先给大家划重点,后面调试章节还会细说:
1. 模块不能倒置使用;2. 强日光直射下识别效果会下降,室外用要注意遮光;结构盖板对850nm红外光的透光率要≥85%;3. 70岁以上老人、双胞胎,识别体验可能下降(双胞胎特征太像,难免互相认);4. 结构安装要避免进水。
三、人脸模块 vs 指纹模块,到底怎么选
很多同学肯定玩过指纹模块(比如AS608),也做过指纹锁。那人脸识别和指纹识别,到底选哪个?龙哥给大家客观对比一下:
| 对比项 | 人脸识别(BE5100S) | 指纹识别(AS608类) |
|---|---|---|
| 使用方式 | 无接触,站过去看一眼就行 | 需要手指按压接触 |
| 卫生程度 | 好,不接触,公共场合友好 | 多人共用一个采集头,相对一般 |
| 操作便捷性 | 高,手拎东西、戴手套也能识别 | 手指脏、湿、脱皮、戴手套会受影响 |
| 成本 | 相对高一些(双目+算法+NPU) | 便宜,性价比高 |
| 识别速度 | 热启动<0.6s,无感通行 | 按压后约1秒内 |
| 暗光环境 | 红外补光,暗光可用 | 基本不受光线影响 |
| 强光环境 | 强日光下效果下降,需遮光 | 不受影响 |
| 适用场景 | 门禁、闸机、门锁、考勤、刷脸支付 | 门锁、保险柜、考勤、U盘加密 |
龙哥的建议:
• 做小区门禁、公司闸机、考勤机、公共储物柜这类人流量大、讲究通行效率和卫生的,优先人脸识别,无感通行体验最好;
• 做家用门锁、保险柜、私人抽屉这种对成本敏感、使用环境固定的,指纹依然很能打;
• 预算够、想做高端产品,完全可以人脸+指纹+密码三合一,BE5100S本身也支持管理员/普通用户分级。
技术上,这两类模块的编程思路几乎一模一样(都是串口发命令→收应答),你会了指纹模块,这篇人脸模块对你来说就是换个协议的事。
四、硬件连接:一张表接完所有线
本工程主控用的是 STM32F103C8T6(经典的C8T6最小系统板)。人脸识别模块走 USART3,调试打印走 USART1,OLED走软件IIC,四个独立按键、蜂鸣器、LED各占几个IO。
4.1 完整接线表
龙哥把main.c开头注释里的接线说明,整理成下面这张大表,照着接就行:
(1)BE5100S 人脸识别模块(USART3通信)
| STM32引脚 | 模块引脚 | 说明 |
|---|---|---|
| PB10(USART3_TX) | 模块 3脚 RX | 单片机发 → 模块收(注意交叉) |
| PB11(USART3_RX) | 模块 2脚 TX | 模块发 → 单片机收(注意交叉) |
| 5V(独立供电) | 模块 1脚 VDD | 5V电源,务必独立供电,见4.2 |
| GND | 模块 4脚 GND | 共地(必须共地!) |
(2)0.96寸 IIC OLED显示屏(中文菜单)
| STM32引脚 | OLED引脚 | 说明 |
|---|---|---|
| 3.3V | VCC | OLED供电3.3V |
| GND | GND | 地 |
| PB0 | SCL | IIC时钟线(软件模拟) |
| PB1 | SDA | IIC数据线(软件模拟) |
(3)4个独立按键(一端接IO,一端接GND)
| STM32引脚 | 按键 | 功能 |
|---|---|---|
| PB12 | K1 | 返回 / 翻页(长按进管理员菜单) |
| PB13 | K2 | 确定 |
| PB14 | K3 | 密码数字"1" / 菜单ID加 |
| PB15 | K4 | 密码数字"2" / 菜单ID减 |
| GND | 按键公共端 | 按键按下IO接地,低电平有效 |
(4)蜂鸣器(开锁/按键提示音)
| STM32引脚 | 蜂鸣器 | 说明 |
|---|---|---|
| PA15 | OUT(信号端) | 高电平响,低电平停 |
| 3.3V | VCC | 有源蜂鸣器供电 |
| GND | GND | 地 |
(5)LED指示灯
| STM32引脚 | LED | 说明 |
|---|---|---|
| PB3 | LED1 | 系统运行状态灯(主循环翻转闪烁) |
| PB4 | LED2 | 系统运行状态灯 |
| PC13 | LED3 | 板载指示灯 |
一个小坑提前说:PA15、PB3、PB4 这几个脚默认是 JTAG调试接口 的复用脚(JTDI、JTDO、JTRST)。如果直接当普通IO用会不工作。工程里在main开头调用了
JTAG_Set(SWD_ENABLE),把JTAG关闭、只保留SWD下载,这样这几个脚就能当普通IO用了。大家用ST-Link的SWD模式下载(只占PA13/PA14),完全不受影响。
图5 系统硬件接线图(按此接线即可复刻)
4.2 供电的坑(重点!)
这是新手最容易翻车的地方,龙哥必须单独拎出来说。
BE5100S模块工作时,双摄像头+红外补光灯+算法芯片一起跑,最大电流接近500mA。这个电流是什么概念?STM32开发板上那个板载5V(从ST-Link的USB口取电),往往只有几百mA的余量,而且电压可能被拉低。
如果你图省事,直接把模块VDD插到开发板的5V排针上,很可能出现这些"灵异现象":
- • 模块反复重启、指示灯乱闪;• 串口能收到数据但经常校验错误、命令发出去没应答;• 一到识别(开红外补光)瞬间就死机重启。
正确做法(官方手册也明确建议):
1. 模块用独立的5V电源供电——最好用一个输出能力2A以上的5V电源适配器(手机充电器就行),或者一个独立的5V开关电源;
2. 模块的GND和STM32的GND必须连在一起(共地),否则串口电平没有参考,通信必乱;
3. 不要指望ST-Link下载器供电,下载器口一般只有3V左右、电流也小,模块会处于异常状态。
记住一句话:信号线3.3V交叉接,电源线5V独立给,两地必须连一起。
4.3 上电时序注意事项
官方手册里还有一条容易被忽略的"上电时序"要求,龙哥给大家翻译成人话:
模块电源上电之前,要保证 UART_RX、UART_TX 这两个引脚是低电平;实际应用时,主控应该先给模块上电源,等200~250ms,再初始化串口,让串口引脚变成正常的UART通信电平。下电时,则先把串口脚拉低,再关模块电源。
为什么这么要求?因为模块上电自检瞬间,如果串口线上电平不对,可能误判成乱码命令,导致工作异常。
我们工程里的处理是:上电后OLED先显示"BE5100S Starting...",延时等待并调用 BE_WaitReady() 等模块自检就绪(约1~2秒),再开始正常收发,就是这个道理。
五、通信协议详解(本文核心)
硬件接好了,下面进入最硬核、也是最关键的部分——通信协议。把这一节吃透,你不仅能驱动BE5100S,以后遇到任何串口模块(指纹、RFID、WiFi等),都是同一个套路。
5.1 帧格式:一帧数据长什么样
BE5100S和单片机之间,不管是"命令""应答"还是"通知",用的都是同一种帧格式。一帧数据由5个部分组成:
| 字段 | 同步域SyncWord | 消息号MsgID | 数据长度Size | 数据域Data | 校验域Check |
|---|---|---|---|---|---|
| 字节数 | 2字节 | 1字节 | 2字节 | N字节 | 1字节 |
| 内容 | 0xEF 0xAA | 消息号 | 高字节在前(大端) | 具体数据 | XOR校验 |
我们一个一个看:
1. 同步域(帧头):固定 0xEF 0xAA 两个字节。作用是"对齐"——接收方在一串字节流里看到 EF AA,就知道"一帧从这里开始了"。
2. 消息号(MsgID):1字节,说明这帧是干嘛的。命令(如0x12识别、0x1D录入)、应答(0x00)、通知(0x01)靠它区分。
3. 数据长度(Size):2字节,表示后面"数据域"有多少个字节。注意是大端模式,高字节在前。比如数据域35字节,这里就是 00 23(0x0023=35)。
4. 数据域(Data):N字节,真正的"干货"——命令参数或者返回结果。没有数据时可以为空(Size=0)。
5. 校验域(Check)
- :1字节,用来判断这帧数据在传输过程中有没有出错。
整帧的总长度 = 2(帧头)+ 1(MsgID)+ 2(长度)+ N(数据)+ 1(校验)= N + 6 字节。
5.2 XOR异或校验怎么算(配算例)
这个模块的校验用的是异或校验(XOR),算法非常简单,龙哥给大家掰开揉碎讲:
从"消息号MsgID"那个字节开始,到"数据域最后一个字节"为止,把中间所有字节逐个做异或运算(⊕),最终得到的1字节结果,就是校验码。
注意:帧头 EF AA 不参与校验,参与校验的是 MsgID + Size(2字节) + Data 这一段。
异或运算规则就一句:相同为0,不同为1(0⊕0=0,1⊕1=0,0⊕1=1)。而且它有个很好的性质:一个数异或自己等于0,异或0等于自己。所以逐字节"滚"下去就行。
我们看手册里的真实例子——"终止执行"命令 MID_RESET(0x10),它没有数据域,发出去的完整帧是:
EF AA 10 00 00 10
帧头 MsgID 长度 校验
校验码 0x10 怎么来的?参与校验的是 MsgID(0x10) + 长度高(0x00) + 长度低(0x00):
0x10 ⊕ 0x00 ⊕ 0x00 = 0x10
就这么简单。
再看模块回的应答帧:
EF AA 00 00 02 10 00 12
帧头 MsgID 长度 数据 校验
数据域是 10 00(0x10=回显命令号,0x00=结果码成功)。校验码0x12这样算(MsgID + 长度2字节 + 数据2字节,共5个字节):
0x00 ⊕ 0x00 ⊕ 0x02 ⊕ 0x10 ⊕ 0x00
= 0x02 ⊕ 0x10
= 0x12
完全对上!大家自己拿笔算一遍,这个校验就彻底懂了。代码里实现起来就是一个for循环的事,后面7.2节看代码。
5.3 三种消息:命令包、应答包、通知包
MsgID把帧分成三大类,这个关系一定要理清楚:
| 消息类型 | MsgID | 方向 | 作用 |
|---|---|---|---|
| 命令消息 Command | 0x10~0x30等 | 单片机 → 模块 | 我们命令模块干活(录入/识别/删除…) |
| 应答消息 Reply | 0x00 | 模块 → 单片机 | 模块对某条命令的"回复",带结果码 |
| 通知消息 Note | 0x01 | 模块 → 单片机 | 模块主动上报状态(就绪/人脸姿态) |
(1)应答包(MsgID=0x00)的数据域结构:
数据域 = [mid] [result] [后续返回数据...]
回显 结果码
命令号
• 第1个字节 mid:回显你发的是哪条命令。比如你发0x1D录入,应答里这个字节就是0x1D。这样当多条命令交织时,你能知道这个应答是回给谁的。
• 第2个字节 result:结果码
,0x00表示成功,其他值表示各种失败(见5.5)。
• 第3个字节开始:成功时返回的具体内容(比如识别到的用户ID、用户名等)。
(2)通知包(MsgID=0x01)的数据域结构:
数据域 = [nid] [后续通知数据...]
通知号
• nid = 0x00:模块就绪通知。模块上电自检完成后,会主动发一帧 EF AA 01 00 01 00 00,意思是"我准备好了,可以发命令了"。
• nid = 0x01:人脸姿态通知。录入/识别过程中,模块会不停发这个,告诉你当前脸的位置状态(太靠上/太远/没检测到脸等),用来引导用户调整站位。姿态码是2字节、小端格式(低字节在前),后面还跟着人脸框坐标left/top/right/bottom和转角yaw/pitch等信息(本工程主要用姿态码)。
这里有个关键细节:录入/识别时,模块会"通知帧+应答帧"混着发过来——先哗啦啦发一串姿态通知,最后发一帧应答告诉你成没成。所以我们接收解析时,要能在缓冲区里逐字节扫描、把两种帧区分开处理。这正是后面
BE_WaitReply函数要干的活。
5.4 常用指令一览表
龙哥把本工程用到的、以及手册里常用的命令整理成表(命令号就是MsgID):
| 命令 | MsgID | 功能 | 数据域 |
|---|---|---|---|
| MID_RESET | 0x10 | 终止当前操作(录入/识别中途取消) | 无 |
| MID_GET_STATUS | 0x11 | 查询模块当前状态(空闲/忙) | 无 |
| MID_VERIFY | 0x12 | 人脸识别/搜索(刷脸开锁) | reserve(0x00) + 超时秒数 |
| MID_ENROLL | 0x13 | 5方向录入(需转脸,本工程不用) | admin+姓名32B+方向+超时 |
| MID_ENROLL_SINGLE | 0x1D | 单方向录入(正对一次即可,本工程用这个) | admin+姓名32B+方向+超时 |
| MID_DELUSER | 0x20 | 删除指定ID用户 | 用户ID(2字节大端) |
| MID_DELALL | 0x21 | 删除全部用户(清空脸库) | 无 |
| MID_GETUSERINFO | 0x22 | 查询指定用户信息 | 用户ID(2字节大端) |
| MID_FACERESET | 0x23 | 重置录入状态(取消已录方向) | 无 |
| MID_GET_ALL_USERID | 0x24 | 查询所有已注册用户ID列表 | 无 |
| MID_GET_VERSION | 0x30 | 查询固件版本号 | 无 |
我们这个工程,核心就用 0x1D(录入)、0x12(识别)、0x20(删一个)、0x21(全删)、0x24(查列表)这5条,外加0x11(就绪探测)。够做一把完整的人脸锁了。
5.5 result结果码表
应答包里的result字节,是判断命令成败的关键。常用结果码如下:
| 结果码 | 宏定义 | 含义 | 我们怎么处理 |
|---|---|---|---|
| 0x00 | BE_RES_OK | 成功 | 继续解析返回数据 |
| 0x08 | BE_RES_NOFACE | 识别失败/人脸不匹配(脸库里没这张脸,陌生人) | 提示"识别失败",正常现象 |
| 0x0A | BE_RES_EXIST | 该人脸已经录入过(重复录入) | 提示"已录入,请勿重复" |
| 0x0D | BE_RES_TIMEOUT | 超时(规定时间内没检测到人脸) | 提示超时,请重试 |
说明:0x08(陌生人)和0x0D(超时没人脸)在刷脸场景下是日常正常现象——没人站镜头前、或者路过个没录过的人,都会返回这两个码。所以代码里这两种情况不打印"错误",只当作一次普通的识别失败处理。
5.6 人脸姿态码表(8种状态)
录入和识别时,模块通过 nid=0x01 的通知包,实时上报一个姿态码status(2字节小端,实际值0~7)。这8种状态,就是引导用户"把脸摆到合适位置"的关键:
| 姿态码 | 含义 | OLED英文提示 | 中文提示(串口) | 用户该怎么做 |
|---|---|---|---|---|
| 0 | 人脸位置正常 | Face OK | 人脸位置正常 | 保持不动 |
| 1 | 未检测到人脸 | No Face | 没有检测到人脸 | 把脸凑到镜头前 |
| 2 | 脸太靠上(靠近画面上边沿) | Move Down | 脸部太靠上,请向下移动 | 人往下蹲/镜头往上 |
| 3 | 脸太靠下(靠近画面下边沿) | Move Up | 脸部太靠下,请向上移动 | 人站起来/镜头往下 |
| 4 | 脸太靠左 | Move Right | 脸部太靠左,请向右移动 | 人往右挪 |
| 5 | 脸太靠右 | Move Left | 脸部太靠右,请向左移动 | 人往左挪 |
| 6 | 离镜头太远 | Closer | 离镜头太远,请靠近(40~60cm) | 往前走,靠近 |
| 7 | 离镜头太近 | Farther | 离镜头太近,请后退一点 | 往后退 |
有了这8种提示,用户哪怕第一次用,看着屏幕"Closer""Move Down"也能自己调整到位,录入和识别的成功率大大提高。这也是这个模块体验好的地方——它不是冷冰冰地返回成功/失败,而是会"教你怎么站"。
图6 串口抓包:姿态通知包(0x01)与录入应答包(0x00)实测波形/字节
六、编程思路:整体框架怎么搭
在贴代码之前,龙哥先带大家把"骨架"想清楚。写串口模块驱动,最怕的就是一上来就闷头写,写到后面收发逻辑缠成一团乱麻。我们先理清三件事:怎么收、怎么发、怎么等结果。
6.1 串口接收为什么要用"空闲中断成帧"
先想一个问题:模块给我们回数据,是一个字节一个字节陆续过来的。我们怎么知道"这一帧收完了"?
有同学可能会说:帧头后面不是有"数据长度Size"字段吗?我读到Size不就知道这帧多长了?
理论上可以,但实战中有两个麻烦:
1. 模块会连发多帧——比如录入时,一串姿态通知帧后面才跟一帧应答帧,中间没有任何间隔标志;
2. 万一中途丢了一个字节、或者上电瞬间来了点乱码,你按Size去切帧,整个错位,后面全乱。
所以工程上更稳妥、更简单的办法是:用"总线空闲"来判断一帧结束。
道理很朴素:模块发一帧数据时,字节是连续"突突突"过来的,两个字节之间间隔极短(115200波特率下,一个字节约87微秒);而发完一帧之后,总线会安静下来。我们只要检测到"超过一段时间没有新字节到来",就认为"这一帧收完了"。
本工程的具体做法是两件武器配合:
• USART3接收中断:每收到1个字节,进一次中断,把这个字节存进接收缓冲区 USART3_RX_BUFFER,字节计数 USART3_RX_CNT 加1,同时喂一下定时器3(重新计时);
• TIM3定时器做空闲检测:定时器配置成 PSC=72,ARR=20000。STM32主频72MHz,72分频后定时器计数频率是1MHz,数20000个就是 20ms。每收到一个字节就把定时器清零重新数,如果连续20ms没有新字节,定时器溢出,置位帧完成标志 USART3_RX_FLAG。
这样,主程序只要看到 USART3_RX_FLAG==1,就知道"一帧完整数据躺在缓冲区里了",可以拿去解析。
为什么是20ms?因为帧内字节间隔不到0.1ms,而帧与帧之间、命令处理之间的间隙远大于20ms。20ms既能保证一帧内部不会被误切断,又能快速响应。这是个很经典的经验值。
这套"串口中断收字节 + 定时器空闲判帧"的组合,是单片机串口通信里的万能套路,大家一定要掌握,换任何模块都能用。
6.2 发送→等待应答→解析 的三段式框架
收发的整体逻辑,龙哥画成文字版的流程:
【发送端】
业务函数(录入/识别/删除)
│
▼
BE_SendCmd(命令号, 数据, 长度)
│ 内部: BE_FillPackage 组帧(帧头+MsgID+长度+数据)
│ BE_CheckXOR 算校验
▼
BE5100S_DataSend() 逐字节从USART3发出去
【接收端】
USART3中断收字节 → USART3_RX_BUFFER
│
▼
TIM3 20ms空闲 → USART3_RX_FLAG=1
│
▼
Wait_BE5100S_RunData() 发现标志→拷贝到BE5100S_buffer
│
▼
BE_FindFrame() 在缓冲区里扫描 EF AA 帧头→读长度→校验XOR
│
▼
BE_WaitReply(命令号, 超时)
├─ 是通知帧(0x01)? → 姿态就回调BE_OnFaceStatus刷新屏幕, 就绪就忽略
└─ 是应答帧(0x00)且回显命令号匹配? → 返回这帧, 业务函数解析result
一句话总结:发命令用"组帧",收结果用"找帧+校验",中间靠超时兜底。
6.3 就绪通知和姿态通知怎么处理
两种通知帧(MsgID=0x01)我们这么对待:
• 就绪通知(nid=0x00):上电后等它,收到说明模块自检完成、通信正常。但要考虑一种情况——如果模块比单片机先上电,就绪通知可能在单片机还没开始接收时就发完了,我们会"错过"。所以工程里做了
兜底:等3秒没等到就绪通知,就主动发一条"查询状态(0x11)"命令试探,有应答就说明模块活着,一样往下走。
没收到就绪通知不影响后面正常发命令。
姿态通知(nid=0x01):录入/识别期间会高频到来。我们不在业务函数里直接处理它,而是通过一个
回调函数 BE_OnFaceStatus(姿态码) 把它"甩"给main.c。main.c在回调里把姿态提示刷新到OLED(英文)和串口(中文)。这样驱动层和界面层就解耦了——驱动文件用 __attribute__((weak)) 弱定义占个位,main.c实现真正的回调,不实现也不报错。
这个"弱定义回调"的技巧,在分层设计里非常好用,大家可以留意。
七、核心代码逐段讲解
思路清楚了,下面上代码。所有代码都来自实际工程文件(BE5100S.h / BE5100S.c / main.c),龙哥挑核心的讲,注释都保留着。
7.1 驱动头文件:函数清单先混个脸熟
先看 BE5100S.h 里的宏定义和函数声明,把"弹药库"看一眼:
/*---------------- 帧同步头(固定2字节) --------*/
#define BE_SYNC0 0xEF // 同步头第1字节, 固定 0xEF
#define BE_SYNC1 0xAA // 同步头第2字节, 固定 0xAA
/*---------------- 消息类型(MsgID) ------------*/
#define BE_MSG_REPLY 0x00 // 应答消息: 模块对某条命令的回复
#define BE_MSG_NOTE 0x01 // 通知消息: 模块主动上报(就绪/人脸姿态)
/*---------------- 命令消息号 -----------------*/
#define MID_RESET 0x10 // 复位命令: 终止模块当前正在执行的操作
#define MID_GET_STATUS 0x11 // 查询模块当前状态
#define MID_VERIFY 0x12 // 人脸识别/搜索 (刷脸开锁用这条命令)
#define MID_ENROLL 0x13 // 人脸录入(五方向录入, 需按提示转脸)
#define MID_ENROLL_SINGLE 0x1D // 人脸录入(单方向录入, 正对镜头一次即可, 本工程用这个)
#define MID_DELUSER 0x20 // 删除指定ID的单个用户
#define MID_DELALL 0x21 // 删除全部用户(清空人脸库)
#define MID_GETUSERINFO 0x22 // 查询指定用户的信息
#define MID_FACERESET 0x23 // 复位录入状态
#define MID_GET_ALL_ID 0x24 // 查询所有已注册用户的ID列表
#define MID_GET_VERSION 0x30 // 查询模块固件版本号
/*---------------- 应答结果码 -----------------*/
#define BE_RES_OK 0x00 // 成功
#define BE_RES_NOFACE 0x08 // 识别失败/人脸不匹配(人脸库里没有这张脸)
#define BE_RES_EXIST 0x0A // 该人脸已经录入过(重复录入)
#define BE_RES_TIMEOUT 0x0D // 命令执行超时(规定时间内没检测到人脸)
/*---------------- 模块容量限制 ---------------*/
#define BE_USER_MAX 100 // 最多注册100个用户, 用户ID从1开始自动编号
业务层就5个API,main.c直接调:
int BE_EnrollSingle(char *name, u8 admin, u16 timeout_s); // 单方向录入, 成功返回新ID
int BE_Verify(u16 *user_id, char *name, u16 timeout_s); // 识别/刷脸, 0成功
int BE_DelUser(u16 user_id); // 删除指定ID
int BE_DelAll(void); // 清空全部
int BE_GetAllUserID(u8 *counts, u16 *idlist); // 查询ID列表
void BE_OnFaceStatus(u16 status); // 姿态回调(main.c实现)
7.2 XOR校验与组帧发送
先看最底层的校验函数,简单到出乎意料:
/*---------------- 计算XOR异或校验和 ------------------------------*
* 参数: buf-数据首地址, size-参与计算的字节数
* 返回: 所有字节逐位异或后的结果(1字节)
*----------------------------------------------------------------*/
u8 BE_CheckXOR(u8 *buf, u16 size)
{
u16 i;
u8 v = 0;
for (i = 0; i < size; i++)
v ^= buf[i]; // 逐字节异或, 初值0, 滚一遍结果就是校验和
return v;
}
就是5.2节讲的算法,一个for循环搞定。
再看组帧函数,按 EF AA | MsgID | 长度高 长度低 | 数据 | 校验 的顺序往数组里填:
/*---------------- 按协议组装一帧数据 -----------------------------*
* 输出帧结构: EF AA | MsgID | 长度高字节 长度低字节 | 数据区 | 校验和
*----------------------------------------------------------------*/
void BE_FillPackage(u8 msgID, u8 *pData, u16 dataSize, u8 *out)
{
u16 idx = 0;
out[idx++] = BE_SYNC0; // 帧头第1字节: 0xEF
out[idx++] = BE_SYNC1; // 帧头第2字节: 0xAA
out[idx++] = msgID; // 消息号
out[idx++] = (u8)(dataSize >> 8); // 数据长度高字节(大端: 高字节在前)
out[idx++] = (u8)(dataSize & 0xFF); // 数据长度低字节
if (dataSize && pData)
memcpy(&out[idx], pData, dataSize); // 拷贝数据区内容
idx += dataSize;
// 校验范围: 从MsgID到数据区最后一个字节, 对应 out[2] 开始, 共 3+dataSize 个字节
// (3 = MsgID 1字节 + 长度 2字节)
out[idx] = BE_CheckXOR(&out[2], (u16)(3 + dataSize));
}
注意校验从 out[2](也就是MsgID)开始算,长度是 3 + dataSize(MsgID 1字节 + 长度2字节 + 数据N字节),帧头EF AA不参与——和协议完全对应。
组好帧,发送函数把它从USART3发出去:
/*---------------- 通过串口3发送数据 ------------------------------*
* 原理: 逐字节写入串口3数据寄存器USART3->DR,
* 等待发送完成标志位(TXE)后再发下一个.
*----------------------------------------------------------------*/
void BE5100S_DataSend(u8 *data, u32 len)
{
u32 i;
for (i = 0; i < len; i++)
{
USART3->DR = *data++;
while (!(USART3->SR & 1 << 7)) { } // 等待发送数据寄存器空(TXE位), 空了才能写下一个字节
}
}
/*---------------- 组装命令帧并发送 -------------------------------*/
void BE_SendCmd(u8 msgID, u8 *pData, u16 dataSize)
{
u8 frame[300];
// 整帧总长度 = 帧头2 + 消息号1 + 长度2 + 数据N + 校验1
u16 total = (u16)(5 + dataSize + 1);
memset(frame, 0, sizeof(frame));
BE_FillPackage(msgID, pData, dataSize, frame); // 组装
BE5100S_DataSend(frame, total); // 通过串口3发出去
}
这里操作寄存器直接发,while(!(SR&TXE)) 保证上一个字节发完再发下一个,简单可靠。
7.3 接收成帧与BE_WaitReply应答解析
接收侧,先看"一帧收齐没"的判断。前面说了,USART3中断负责把字节存进 USART3_RX_BUFFER,TIM3 20ms空闲后置 USART3_RX_FLAG。驱动里这个函数负责把成帧数据搬过来:
/*---------------- 是否收到了一帧完整数据 --------------------------*
* 接收机制: 串口3中断每收到1个字节就存进 USART3_RX_BUFFER,
* 定时器3做空闲检测(20ms没新字节就认为一帧结束),
* 置位 USART3_RX_FLAG 标志.
* 返回值: 0=一帧数据已收齐并拷贝到BE5100S_buffer; -1=还没收到完整帧
*----------------------------------------------------------------*/
int Wait_BE5100S_RunData(void)
{
if (USART3_RX_FLAG) // 20ms空闲到, 一帧接收完成
{
memcpy(BE5100S_buffer, USART3_RX_BUFFER, USART3_RX_CNT); // 拷贝到本驱动缓存
BE5100S_cnt = (int)USART3_RX_CNT; // 记录本帧字节数
memset(USART3_RX_BUFFER, 0, sizeof(USART3_RX_BUFFER)); // 清空中断接收缓存
USART3_RX_CNT = 0; // 字节计数清零
USART3_RX_FLAG = 0; // 帧完成标志清零, 准备接收下一帧
return 0;
}
return -1;
}
拿到一帧数据后,关键是在缓冲区里定位出一帧合法的帧(因为可能粘了多帧)。BE_FindFrame 逐字节扫描 EF AA,读出长度,再校验XOR:
/*---------------- 在接收缓存里查找一帧合法数据 --------------------*
* 返回: 找到 -> 返回帧起始地址(EF AA的位置); 没找到 -> 返回0
* 判定条件: EF AA帧头 + 长度合理(<=250) + XOR校验通过.
* 说明: 模块偶尔会连发多帧(通知帧+应答帧), 所以要逐字节扫描定位.
*----------------------------------------------------------------*/
static u8 *BE_FindFrame(void)
{
int i;
u16 size;
for (i = 0; i + 5 <= BE5100S_cnt; i++)
{
// 找到帧头 EF AA
if (BE5100S_buffer[i] == BE_SYNC0 &&
BE5100S_buffer[i + 1] == BE_SYNC1)
{
// 读出数据区长度(大端: i+3高字节, i+4低字节)
size = ((u16)BE5100S_buffer[i + 3] << 8) | BE5100S_buffer[i + 4];
if (size > 250) { continue; } // 长度异常, 不是合法帧, 跳过
if (i + 5 + size >= sizeof(BE5100S_buffer)) { continue; } // 超出缓存范围
// 校验和验证: 从 MsgID(i+2) 开始, 共 3+size 个字节,
// 计算结果应等于帧尾校验字节(i+5+size位置)
if (BE_CheckXOR(&BE5100S_buffer[i + 2], (u16)(3 + size))
== BE5100S_buffer[i + 5 + size])
{
return &BE5100S_buffer[i]; // 校验通过, 返回帧首地址
}
}
}
return 0;
}
这段是整个驱动的"定海神针":哪怕缓冲区里有乱码、粘了好几帧,它都能把校验正确的那一帧揪出来。size>250 这种合法性判断,是防止把噪声误当帧头后越界。
接下来是最核心的 BE_WaitReply——发完命令后调它,它负责在超时时间内收帧、区分通知/应答、分发姿态、返回我们要的应答:
/*---------------- 等待某条命令的应答帧 ---------------------------*
* 处理逻辑:
* 1)收到通知帧(MsgID=0x01): 姿态通知(nid=0x01)->调BE_OnFaceStatus回调;
* 就绪通知(nid=0x00)->忽略.
* 2)收到应答帧(MsgID=0x00)且数据区第1字节==cmdID: 这就是要等的应答, 返回.
* 返回: 帧首地址指针(数据区从 ptr[5] 开始:
* ptr[5]=命令号, ptr[6]=结果码, ptr[7..]=返回内容); 超时返回0.
*----------------------------------------------------------------*/
u8 *BE_WaitReply(u8 cmdID, u32 wait_ms)
{
u8 *p;
while (wait_ms)
{
if (Wait_BE5100S_RunData() == 0) // 收到一帧
{
p = BE_FindFrame();
if (p)
{
u8 msgid = p[2]; // 消息号
u8 *data = &p[5]; // 数据区首地址
if (msgid == BE_MSG_NOTE) // ---------- 通知帧 ----------
{
u8 nid = data[0]; // 通知号
if (nid == 0x01) // 人脸姿态通知
{
// 姿态码是2字节小端格式(低字节在前): data[1]低, data[2]高
u16 status = (u16)data[1] | ((u16)data[2] << 8);
BE_OnFaceStatus(status); // 回调给main.c刷新OLED/串口提示
}
// nid=0x00 是"模块就绪"通知, 这里直接忽略
}
else if (msgid == BE_MSG_REPLY) // ---------- 应答帧 ----------
{
if (data[0] == cmdID) // 应答帧数据区第1字节=对应的命令号
return p; // 是我们要等的那条命令的应答, 返回
}
}
}
delay_ms(10); // 每10ms查一次
if (wait_ms >= 10) wait_ms -= 10; else wait_ms = 0;
}
return 0; // 超时
}
大家重点理解几个偏移:一帧里 p[0]p[1]是帧头,p[2]是MsgID,p[3]p[4]是长度,数据区从 p[5] 开始。所以应答帧里 p[5]是回显命令号、p[6]是结果码、p[7]起是返回内容——代码里写成 p[5+0]、p[5+1]、p[5+2],就是这个对应关系,一看就懂。
姿态码是小端(低字节在前),所以 status = data[1] | (data[2]<<8);而用户ID、长度这些是大端(高字节在前),所以 id = (p[5+2]<<8) | p[5+3]。这个大小端的区别千万别搞反,龙哥在注释里都标了。
7.4 等待模块就绪 BE_WaitReady
上电后等就绪,带兜底探测:
/*---------------- 上电后等待模块自检就绪 --------------------------*
* 模块上电自检完成后, 会主动发一帧就绪通知: EF AA 01 00 01 00 00
* 如果一直没等到(比如模块比单片机先上电, 通知错过了),
* 就主动发一条"查询状态"命令试探, 有应答说明模块活着.
* 返回: 0=就绪, -1=超时未就绪.
*----------------------------------------------------------------*/
int BE_WaitReady(u32 wait_ms)
{
u8 *p;
u32 t = wait_ms;
while (t)
{
if (Wait_BE5100S_RunData() == 0)
{
p = BE_FindFrame();
if (p)
{
// 就绪通知特征: 消息号=0x01(通知), 数据长度=1, 数据区[0]=0x00
if (p[2] == BE_MSG_NOTE && p[4] == 0x01 && p[5] == 0x00)
return 0;
}
}
delay_ms(10);
if (t >= 10) t -= 10; else t = 0;
}
// 兜底方案: 主动发"查询状态"命令(0x11), 有应答就说明模块正常
BE_SendCmd(MID_GET_STATUS, 0, 0);
p = BE_WaitReply(MID_GET_STATUS, 500);
if (p && p[5 + 1] == BE_RES_OK) // 应答数据区[1]=结果码, 0x00表示成功
return 0;
return -1;
}
就绪通知整帧是 EF AA 01 00 01 00 00,对应 p[2]=0x01(通知)、p[4]=0x01(数据长度低字节=1)、p[5]=0x00(nid=就绪)。判断条件就是这么来的。
7.5 人脸录入(0x1D单方向录入)
单方向录入命令的数据域共35字节:[0]管理员标志 + [1..32]用户名32字节 + [33]方向(固定0x01) + [34]超时秒数。模块自动分配ID,成功应答里带回新ID(大端2字节)。
/*---------------- 单方向录入人脸 (命令号 0x1D) --------------------*
* 命令数据区(共35字节):
* [0] 管理员标志: 0=普通用户, 1=管理员
* [1..32] 用户名: 32字节字符串
* [33] 人脸方向: 固定填0x01(单方向录入时该字段无效)
* [34] 采集超时: 单位秒
* 应答数据区: [0]=0x1D, [1]=结果码, [2..3]=分配的用户ID(大端)
* 返回: 成功->新用户ID(>=1), 失败->-1.
*----------------------------------------------------------------*/
int BE_EnrollSingle(char *name, u8 admin, u16 timeout_s)
{
u8 data[35];
u8 *p;
u16 user_id;
memset(data, 0, sizeof(data));
data[0] = admin ? 1 : 0; // 管理员标志
if (name)
strncpy((char *)&data[1], name, 31); // 用户名最多拷31个字符, 留1字节结束符
data[33] = 0x01; // 人脸方向字段(单方向录入时不用, 填0x01)
data[34] = (u8)(timeout_s & 0xFF); // 采集超时时间(秒)
printf("录入命令已发送(单方向录入), 超时时间%d秒, 请正对镜头...rn", timeout_s);
BE_SendCmd(MID_ENROLL_SINGLE, data, 35); // 发送录入命令
p = BE_WaitReply(MID_ENROLL_SINGLE, timeout_s * 1000 + 3000); // 等待应答(留3秒余量)
if (!p)
{
printf("录入失败: 等待模块应答超时(通信异常, 请检查接线/供电)rn");
return -1;
}
if (p[5 + 1] != BE_RES_OK) // 结果码不是0x00
{
printf("录入失败, 结果码=0x%02X (%s)rn",
p[5 + 1], BE_ResultStr(p[5 + 1]));
return -1;
}
user_id = ((u16)p[5 + 2] << 8) | p[5 + 3]; // 读出模块分配的用户ID(大端2字节)
printf("录入成功! 模块分配的新用户ID=%drn", user_id);
return (int)user_id;
}
注意等待时间给了 超时秒数×1000 + 3000 的余量——因为录入命令发出去、模块处理、再回帧,留点余量更稳妥。返回的新ID从 p[5+2]、p[5+3] 按大端拼出来。
7.6 人脸识别/刷脸(0x12)
识别命令数据域只有2字节:[0]保留(0x00) + [1]超时秒数。成功应答数据很丰富:[2..3]用户ID + [4..35]用户名32字节 + [36]管理员标志 + [37]开锁标志(0xC8表示通过)。
/*---------------- 人脸识别/搜索 (命令号 0x12, 刷脸开锁) -----------*
* 命令数据区(共2字节): [0]保留固定0x00, [1]识别超时(秒)
* 成功应答(0x00): [2..3]=用户ID(大端), [4..35]=用户名(32字节),
* [36]=管理员标志, [37]=开锁标志(0xC8表示通过)
* 失败: 0x08=人脸不匹配(陌生人), 0x0D=超时没检测到人脸
* 返回: 0=识别成功, -1=失败.
*----------------------------------------------------------------*/
int BE_Verify(u16 *user_id, char *name, u16 timeout_s)
{
u8 data[2];
u8 *p;
data[0] = 0x00; // 保留字节, 固定0x00
data[1] = (u8)(timeout_s & 0xFF); // 识别超时时间(秒), 常用5~10秒
BE_SendCmd(MID_VERIFY, data, 2); // 发送识别命令
p = BE_WaitReply(MID_VERIFY, timeout_s * 1000 + 2000); // 等待应答(留2秒余量)
if (!p)
{
printf("识别失败: 等待模块应答超时(通信异常, 请检查接线/供电)rn");
return -1;
}
if (p[5 + 1] != BE_RES_OK)
{
// 0x08(没这张脸)和0x0D(超时没人脸)是日常正常现象, 不打印错误;
// 其他异常结果码才打印出来方便排查
if (p[5 + 1] != BE_RES_NOFACE && p[5 + 1] != BE_RES_TIMEOUT)
printf("识别异常, 结果码=0x%02X (%s)rn", p[5 + 1], BE_ResultStr(p[5 + 1]));
else
printf("识别结果: %srn", BE_ResultStr(p[5 + 1]));
return -1;
}
// ---------------- 识别成功, 解析应答内容 ----------------
if (user_id)
*user_id = ((u16)p[5 + 2] << 8) | p[5 + 3]; // 用户ID(大端2字节)
if (name)
{
memcpy(name, &p[5 + 4], 32); // 用户名32字节
name[32] = ''; // 补字符串结束符
}
printf("识别成功! 用户ID=%d, 用户名=%s -> 执行开锁rn",
user_id ? *user_id : -1, name ? name : "");
return 0;
}
识别成功后,用户名从 p[5+4] 起拷32字节,记得手动补 '' 结束符,否则printf字符串会越界——这是C语言操作定长字符数组的经典细节。
7.7 删除指定ID(0x20)、清空全部(0x21)、查ID列表(0x24)
删除指定ID,数据域就2字节的用户ID(大端):
/*---------------- 删除指定用户 (命令号 0x20) ---------------------*
* 命令数据区(共2字节): 用户ID, 大端格式. 返回: 0成功/-1失败.
*----------------------------------------------------------------*/
int BE_DelUser(u16 user_id)
{
u8 data[2];
u8 *p;
data[0] = (u8)(user_id >> 8); // ID高字节
data[1] = (u8)(user_id & 0xFF); // ID低字节
printf("发送删除命令: 删除用户ID=%d ...rn", user_id);
BE_SendCmd(MID_DELUSER, data, 2);
p = BE_WaitReply(MID_DELUSER, 1000);
if (p && p[5 + 1] == BE_RES_OK)
{
printf("用户ID=%d 删除成功rn", user_id);
return 0;
}
printf("用户ID=%d 删除失败(超时或ID不存在)rn", user_id);
return -1;
}
清空全部,命令无数据域(清空操作耗时稍长,等2秒):
/*---------------- 删除全部用户 (命令号 0x21, 清空人脸库) ---------*
* 命令无数据区. 返回: 0=成功, -1=失败.
*----------------------------------------------------------------*/
int BE_DelAll(void)
{
u8 *p;
printf("发送删除命令: 清空全部人脸库 ...rn");
BE_SendCmd(MID_DELALL, 0, 0);
p = BE_WaitReply(MID_DELALL, 2000); // 清空操作耗时稍长, 等2秒
if (p && p[5 + 1] == BE_RES_OK)
{
printf("全部用户已清空rn");
return 0;
}
printf("清空人脸库失败(应答超时)rn");
return -1;
}
查询已注册ID列表,应答里 [2]=数量,后面每个ID占2字节大端:
/*---------------- 查询所有已注册用户ID (命令号 0x24) -------------*
* 应答数据区: [2]=已注册用户数量counts,
* [3..3+2*counts-1]=用户ID列表, 每个ID占2字节大端
* 返回: 0=查询成功, -1=失败.
*----------------------------------------------------------------*/
int BE_GetAllUserID(u8 *counts, u16 *idlist)
{
u8 *p;
u8 cnt;
int i;
BE_SendCmd(MID_GET_ALL_ID, 0, 0);
p = BE_WaitReply(MID_GET_ALL_ID, 1000);
if (!p || p[5 + 1] != BE_RES_OK) return -1;
cnt = p[5 + 2]; // 已注册用户数量
if (counts) *counts = cnt;
for (i = 0; i < cnt && i < BE_USER_MAX; i++)
{
if (idlist)
idlist[i] = ((u16)p[5 + 3 + i * 2] << 8) | p[5 + 4 + i * 2]; // 每个ID大端2字节
}
printf("当前人脸库已注册用户数量: %d 人rn", cnt);
return 0;
}
手册里这个应答的例子是 EF AA 00 00 09 24 00 03 00 01 00 02 00 03 2E,表示共3个用户、ID分别是1、2、3——和代码解析逻辑完全对应(p[5+2]=0x03数量,之后每2字节一个ID)。
最后,姿态码和结果码转文字的辅助函数,给OLED和串口显示用:
/*---------------- 人脸姿态码 -> 英文提示串(给OLED显示) -----------*
* 说明: 本工程OLED字库里只有英文/数字, 没有中文字库,
* 所以屏幕上的姿态提示只能用英文简写单词.
*----------------------------------------------------------------*/
const char *BE_FaceStatusStr(u16 status)
{
switch (status)
{
case 0: return "Face OK"; // 位置正常
case 1: return "No Face"; // 没检测到人脸
case 2: return "Move Down"; // 太靠上, 请向下移动
case 3: return "Move Up"; // 太靠下, 请向上移动
case 4: return "Move Right"; // 太靠左, 请向右移动
case 5: return "Move Left"; // 太靠右, 请向左移动
case 6: return "Closer"; // 太远了, 请靠近
case 7: return "Farther"; // 太近了, 请后退一点
default: return "...";
}
}
/*---------------- 应答结果码 -> 中文说明串(给串口调试打印) -------*/
const char *BE_ResultStr(u8 res)
{
switch (res)
{
case 0x00: return "成功";
case 0x08: return "识别失败/人脸不匹配(人脸库中没有这张脸)";
case 0x0A: return "该人脸已经录入过, 请勿重复录入";
case 0x0D: return "超时(规定时间内没有检测到人脸)";
default: return "未知错误";
}
}
(BE_FaceStatusStrCN 是对应的中文版,给电脑串口助手打印,内容和5.6节姿态表一致,这里就不重复贴了。)
7.8 姿态回调与main.c菜单主流程
驱动层通过弱定义回调把姿态"甩"给main.c。main.c里真正实现它,刷新OLED和串口:
/*============================================================
* 人脸姿态通知回调函数
* 录入/识别过程中, BE5100S.c 每收到一帧姿态通知, 就调用一次本函数.
* 显示: 串口1打印中文提示; OLED屏幕显示英文简写(屏上字库只有英文).
*===========================================================*/
void BE_OnFaceStatus(u16 status)
{
g_face_status = status;
// 串口打印中文姿态提示(GBK编码, 串口助手正常显示中文)
printf("[姿态] %srn", BE_FaceStatusStrCN(status));
// OLED第4行显示英文姿态提示
OLED_ShowString(0, 16 * 3, 16, " ");
OLED_ShowString(0, 16 * 3, 16, (char *)BE_FaceStatusStr(status));
OLED_RefreshGRAM();
}
录入的界面封装 Add_FACE(ID由模块自动分配,传进来的ID不用):
int Add_FACE(int ID)
{
int new_id;
(void)ID; // 模块自动分配ID, 外部传入的ID不使用
// ... 此处省略OLED显示"人脸录入/请正对镜头/距离40-60cm"的若干行 ...
// 调用单方向录入: 用户名"user", 普通用户(admin=0), 采集超时15秒
// 录入过程中模块会不断上报姿态, BE_OnFaceStatus回调会实时刷新提示
new_id = BE_EnrollSingle("user", 0, 15);
OLED_Clear();
if (new_id > 0)
{
// ... OLED显示"录入成功" + ID号 ...
sprintf(data_buff, "ID:%d", new_id);
OLED_ShowString(40, 16 * 2, 16, data_buff);
// 蜂鸣器"嘀-嘀-"两声提示录入成功
BEEP = 1; delay_ms(100); BEEP = 0; delay_ms(100);
BEEP = 1; delay_ms(100); BEEP = 0;
}
else
{
// ... OLED显示"录入失败/请重试", 蜂鸣器长响一声 ...
}
OLED_RefreshGRAM();
delay_ms(1500);
return new_id;
}
刷脸识别 press_FACE,带冷却机制防止同一张脸站镜头前连续触发开锁:
int press_FACE(void)
{
u16 user_id = 0;
char name[33];
// 冷却计数>0时直接跳过本次识别(主循环约500ms调一次, 自减1)
if (face_cooldown > 0) { face_cooldown--; return -1; }
// ... OLED第4行提示"识别中" ...
// 发识别命令, 超时5秒; 姿态提示由BE_OnFaceStatus回调刷新
if (BE_Verify(&user_id, name, 5) == 0)
{
face_cooldown = 6; // 成功后冷却约3秒(6次*500ms), 防止重复开锁
printf("人脸识别通过! 用户ID=%d, 执行开锁动作rn", user_id);
// ... OLED显示"开锁成功" + ID, 1秒后恢复待机界面 ...
return (int)user_id;
}
// 识别失败(没检测到人脸/陌生人/超时)
face_cooldown = 2; // 失败后冷却约1秒
// ... OLED第4行提示"识别失败" ...
return -1;
}
删除 Del_FACE:参数≤0走清空全部(0x21),≥1走删除指定ID(0x20):
int Del_FACE(int num)
{
int ret;
OLED_Clear();
if (num <= 0)
{
// num为0: 删除全部用户(清空人脸库, 命令号0x21)
ret = BE_DelAll();
// ... OLED显示"清空中"-> 成功"清空完成"/失败"删除失败" ...
}
else
{
// num>=1: 删除指定ID(命令号0x20)
ret = BE_DelUser((u16)num);
// ... OLED显示"删除ID:x"-> 成功"删除完成"/失败"删除失败" ...
}
BEEP = 1; delay_ms(100); BEEP = 0; // 蜂鸣器"嘀"一声提示操作完成
delay_ms(1500);
return ret;
}
最后看main主流程的骨架(初始化→等就绪→查人数→主循环里按键进菜单、定时刷脸):
int main()
{
JTAG_Set(SWD_ENABLE); // 关闭JTAG保留SWD, 释放PA15/PB3/PB4
BEEP_Init(); LED_Init();
USART1_Init(115200); // 串口1: 向电脑printf调试
KEY_Init();
Oled_Init(); OLED_Clear();
USART3_Init(115200); // 串口3: 接人脸模块(BE5100S默认115200)
TIMER3_Init(72, 20000); // 定时器3: 辅助串口3接收, 20ms空闲判一帧
OLED_ShowString(0, 0, 16, "BE5100S Starting...");
if (BE_WaitReady(3000) == 0) { /* OLED显示"就绪" */ }
else { /* 显示"未就绪", 但不影响继续执行 */ }
// 查询一次当前人脸库已注册人数
{ u8 cnt = 0; BE_GetAllUserID(&cnt, 0); }
// 进入待机界面: "人脸识别" + "请正对镜头"
// ...
while (1)
{
key = KEY_Scan();
if (key == 1) // K1: 进管理员验证
{
run_state = page5_pass_open(1); // 输入管理员密码"1212"
if (run_state == 0)
{
page3_EnterFingerprint(); // 人脸录入菜单
page4_DeleteFingerprint(); // 人脸删除菜单
}
}
if (time_cnt > 50) // 约500ms到
{
LED1 = !LED1; LED2 = !LED2; // 运行指示灯闪烁
time_cnt = 0;
run_state = press_FACE(); // 刷脸识别
if (run_state >= 0)
close_lock(); // 识别成功: 蜂鸣器长响(实际接继电器开锁)
}
DelayMs(10);
time_cnt++;
}
}
管理员密码在main.c里是 char Password[] = "1212";,想改密码改这里就行。整个菜单(密码页、录入页、删除页)都是OLED中文字模索引+按键扫描搭出来的,逻辑很直白,大家看工程源码即可。
八、完整工作流程:录入、识别、删除怎么走
代码讲完了,龙哥再带大家把三条主线的"操作流程"完整走一遍,把前面的知识点串起来。
8.1 人脸录入流程
管理员按K1 → 输入密码"1212"(K3=1/K4=2输入, K2确定) → 进入"人脸录入"页
→ 按K2确定开始
→ STM32发送 0x1D 单方向录入命令(数据域: 管理员标志+姓名+方向+超时15秒)
→ 模块开始采集, 期间持续上报姿态通知(nid=0x01):
No Face → 请把脸凑过来
Closer/Farther → 调整距离到40~60cm
Move Up/Down/Left/Right → 调整上下左右
Face OK → 位置正常, 保持
→ 采集成功, 模块回复应答(result=0x00, 带回自动分配的新ID, 从1开始)
→ OLED显示"录入成功 ID:x", 蜂鸣器嘀嘀两声
(异常: 结果码0x0A=这张脸已录过; 0x0D=15秒内没采集到, 超时请重试)
两个关键点:
1. ID是模块自动分配的,从1开始递增,我们不用也不能手动指定。这跟有些指纹模块要自己指定ID不一样,省事;
2. 全程有姿态引导,用户照着提示调整就行,体验很友好。
8.2 刷脸识别(开锁)流程
系统待机, 主循环每约500ms自动调一次 press_FACE()
→ 发送 0x12 识别命令(超时5秒)
→ 模块检测镜头前的人脸, 同样持续上报姿态引导
→ 比对人脸库:
命中(已注册) → 应答result=0x00, 带回 user_id + 用户名 + 开锁标志0xC8
→ OLED"开锁成功 ID:x", 蜂鸣器长响1秒
→ (实际产品在此驱动继电器/舵机开门)
→ 冷却约3秒, 防止同一张脸连续触发
未命中(陌生人) → result=0x08, OLED"识别失败"
5秒没人脸 → result=0x0D 超时, 安静等待下一轮
刷脸是后台自动循环跑的,人不需要按任何键,往镜头前一站就行——这就是"无感通行"。
8.3 删除/清空流程
管理员验证 → 进入"人脸删除"页
→ K3/K4 调整ID:
ID显示 1~99 → 按K2 = 删除指定ID(0x20命令)
ID调到 0 → 屏幕提示"清空全部", 按K2 = 清空整个脸库(0x21命令)
→ 模块应答result=0x00 → OLED"删除完成/清空完成", 蜂鸣器嘀一声
删除是物理生效、掉电保存的,删了就真没了,操作前想清楚。清空脸库尤其慎用。
另外上电时主程序还会用 0x24 查一次当前已注册人数,通过串口打印出来,方便你确认脸库状态。
图8 录入、识别、删除三大业务流程图
九、使用说明与调试注意事项
这一节全是龙哥踩过/帮网友排查过的坑,建议大家接线、调试前先读一遍。
1. 供电是第一位的(再说一遍)
• 模块必须 5V独立供电,用2A以上的适配器或手机充电器;
• 不要用ST-Link下载器供电(只有3V左右),也不要指望开发板板载5V直接带500mA;
• 模块GND和STM32的GND必须共地。
• 供电不足的典型症状:模块反复重启、应答时有时无、一识别就死机。
2. 串口接线要交叉、共地
• STM32的TX(PB10) → 模块RX(3脚);STM32的RX(PB11) ← 模块TX(2脚);
• 波特率 115200,8N1,电平 3.3V TTL(和STM32直连即可,不用电平转换);
• 没应答,90%是接线问题:TX/RX接反了、没共地、供电不对。
3. 上电时序
• 先给模块上5V,等约200ms12秒自检完成,再开始串口通信;• 工程里 BE_WaitReady(3000) 就是等这个。没收到就绪通知也别怕,兜底会发0x11探测,不影响使用。
4. 识别姿态与环境
• 最佳识别/录入距离 40~60cm(手册范围30~80cm),正对镜头;• 录入时跟着屏幕/串口的姿态提示调整,看到"Face OK/人脸位置正常"保持住;• 避免强日光直射镜头,室外注意遮光;红外补光在暗光下反而表现不错;• 模块不能倒置安装;结构盖板对850nm红外透光率要≥85%(普通深色亚克力可能挡红外,要选红外透传材料);• 摘口罩、摘墨镜,露出完整面部;70岁以上老人、双胞胎识别率可能下降。
5. 三个结果码怎么应对
• 0x0A(人脸已录入):这张脸已经在库里了,不用重复录。想重录就先删掉旧ID;•
0x0D(超时):规定时间内没检测到合格人脸。检查距离、光线、有没有正对镜头,重试;•
0x08(不匹配):陌生人或当前姿态没认出。注册用户可调整角度距离再试。
6. 中文显示与串口编码
-
-
- • OLED屏幕上的姿态提示用英文(Face OK/Closer等),是因为屏的字库只放了有限汉字;• 串口助手看中文提示,注意编码选
GBK
- ,否则中文可能乱码。
-
十、常见问题FAQ
Q1:发命令模块完全没反应、收不到任何应答?
A:按顺序排查:① 模块5V供电是否正常、电流够不够(换独立适配器);② GND有没有和STM32共地;③ TX/RX是不是接反了(务必交叉);④ 波特率是不是115200;⑤ 模块是不是刚上电还在自检,等1~2秒。可以先用USB转TTL+官方上位机FaceDemo.exe验证模块本身好坏,再接STM32。
Q2:串口能收到数据,但全是乱码/校验老失败?
A:多半是波特率不匹配(确认115200)、电平不对,或者供电不稳导致模块重启。也可能是共地没做好导致电平参考漂移。
Q3:录入/识别时一直提示"No Face/没有检测到人脸"?
A:① 距离太远或太偏,站到40~60cm正对镜头;② 光线太暗或强逆光,调整光照;③ 检查镜头保护膜撕掉没、镜头有没有被深色盖板挡住红外;④ 模块是否倒置安装。
Q4:识别率低、本人经常认不出(0x08)?
A:① 录入时就没录好——重新录入,录入时确保Face OK、光线均匀、表情自然;② 距离角度不对,按姿态提示调整;③ 强逆光/逆光环境换位置;④ 戴口罩帽子墨镜会影响,摘掉再试。
Q5:录入返回0x0A"已录入"是什么意思?
A:说明这张脸已经在人脸库里了,模块防重复录入。要么直接用,要么先删掉对应ID再重录。
Q6:OLED中文乱码或不显示汉字?
A:本工程OLED汉字是用取模软件(PCtoLCD2002)按16×16逐行式取模、放进oledfont.h的。要改显示文字,需重新取模并按main.c开头注释里的"中文字模索引表"对应好。姿态提示因为字数多,保留英文显示。
Q7:PA15/PB3/PB4接的蜂鸣器、LED不工作?
A:这几个脚默认是JTAG复用脚。main开头必须调用 JTAG_Set(SWD_ENABLE) 关闭JTAG(保留SWD下载)。用ST-Link的SWD模式(PA13/PA14)下载不受影响。
Q8:最多能录多少人?满了怎么办?
A:模块人脸库容量100人,ID从1自动编号。满了需要先删除不用的ID(0x20)或清空(0x21)再录。
Q9:录入中途想取消怎么办?
A:可以发 MID_RESET(0x10) 终止当前录入/识别操作,模块回到空闲状态。五方向录入(0x13)中途想撤销已录方向,用 MID_FACERESET(0x23)。
十一、应用场景与扩展方向
把这套驱动吃透,能玩的东西非常多。BE5100S本质是个"串口上的身份认证模块",凡是涉及授权、管理、开关的场景都能用它替代IC卡、密码、机械钥匙:
• 人脸门禁/闸机:小区单元门、公司大门、工地实名制通道,刷脸无感通行;•
人脸门锁/保险柜:家用防盗门、办公室柜锁,识别成功驱动继电器/舵机开锁;•
考勤机:刷脸打卡,结合RTC记录时间,配合0x24查询用户列表做排班;•
刷脸储物柜/快递柜:健身房、游泳馆、超市储物柜,刷脸存取,免带卡;•
会员/授权管理:私人会所、设备使用授权、养老/医保身份核验;•
电梯/楼宇分层授权:刷脸后只能按自家楼层。
几个可以继续扩展的方向:
1. 联网上报:加一颗ESP8266/ESP01S或4G模块(再来一个串口),把"谁、几点、识别成功/失败"上报到服务器/手机,做远程考勤、远程查看开锁记录;
2. 多人/分级管理:模块录入时支持管理员标志(admin),可以做"管理员才能进设置菜单、普通用户只能开锁"的权限体系;
3. 结合RTC做时段控制:比如只在工作时间允许识别开锁;
4. 多种认证组合:人脸+密码+刷卡三合一,安全性更高;
5. 替换交互:把OLED+按键换成触摸屏,或者接入语音播报模块(模块本身上位机方案支持语音提示,单片机端可外接语音芯片);
6. 防拆/防照片:双目3D+红外本身有一定活体能力,户外高安全场景可再配合活体检测策略。
可以看到,模块把最难的算法部分全包了,我们的工作主要是业务逻辑和交互——这正是单片机工程师最擅长、也最容易出成果的地方。
十二、资料与工程获取
本文涉及的完整内容包括:
- • BE5100S人脸识别模块(带4P杜邦线);• STM32F103C8T6完整工程源码(BE5100S.c / BE5100S.h 驱动 + main.c菜单主程序 + OLED/按键/串口/定时器底层),Keil5可直接编译烧录;• BE5100S官方《用户使用手册》和《通信协议手册》;• USB转TTL测试工具、FaceDemo上位机、CH340驱动、取模软件等。
项目开发使用的全部软件工具与资料包已经上传到网盘:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink
实操小建议:拿到模块后,先用USB转TTL + 官方FaceDemo上位机把模块跑通(录一张脸、识别一次),确认模块、供电、接线都OK,再往STM32上移植。这样能把"模块问题"和"自己代码问题"分开,调试效率高很多。
人脸识别听起来高大上,其实拆开看就是一颗"串口模块"。掌握了"帧头+长度+数据+校验"的协议解析套路,再加上"中断收字节+定时器空闲成帧"这个万能框架,别说BE5100S,以后遇到任何串口外设,你都能照着这个思路拿下。
动手做起来吧,做出一把自己的人脸锁,还是很有成就感的。
我是DS小龙哥,专注分享能落地的嵌入式实战项目,我们下篇文章再见!—— DS小龙哥嵌入式技术资讯 ——
260