• 正文
  • 相关推荐
申请入驻 产业图谱

采用STM32单片机+BE5100S人脸识别模块设计的人脸识别系统

09/09 08:00
260
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

人脸识别技术作为计算机视觉领域的重要分支,近年来已在门禁控制、身份验证、考勤管理及智能支付等场景中获得广泛应用。其基本原理涉及图像采集、人脸检测、特征提取与模式匹配等复杂算法链路,传统实现方案通常依赖高性能计算平台与深度学习推理框架,这使得众多嵌入式开发者对人脸识别功能的集成望而却步。

针对上述痛点,本文提出一种基于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小龙哥嵌入式技术资讯 ——

相关推荐

嵌入式开发工程师

微信公众号