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

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

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

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

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

相关推荐

嵌入式开发工程师

微信公众号