简介:这份资源面向嵌入式开发初学者与计算机视觉入门者,提供一套在STM32平台上从图像采集到人脸识别的完整工程实践,帮助读者理解微控制器如何驱动OV5640摄像头并运行轻量级人脸检测算法。压缩包共174个文件,以72个C源文件与72个头文件为主体,构成摄像头驱动、图像处理与识别算法的核心代码,另含14张png图片、6个txt说明及uvprojx、uvoptx等Keil5工程配置,整体约1.03MB,可直接导入Keil uVision5编译调试。资源已有8608人学习下载,说明其在STM32视觉入门方向具有较高的参考价值。工程围绕Cortex-M4内核展开,涉及图像预处理、Haar特征级联分类器检测与特征提取等环节,并针对单片机内存与算力受限做了算法裁剪与优化,读者可据此掌握从OV5640数据采集到识别输出的完整链路,理解资源受限场景下的工程取舍与调试思路。
1. 一块单片机扛起人脸识别,这事到底靠不靠谱
去年帮一个做门禁改造的朋友看方案,对方开口就要“STM32 上跑人脸识别,成本压到两百以内”。我当时第一反应是摇头——人脸检测动辄几十兆的模型,一颗几十 KB RAM 的 MCU 怎么吃得下。后来把需求拆开才明白,他要的不是通用人脸识别,而是“固定几个人、固定场景、能开锁就行”的轻量方案。这正是基于 STM32 的人脸识别项目的典型落地场景:用单片机做前端采集与逻辑控制,把真正吃算力的识别环节交给专用模块或上位机,整套系统跑在嵌入式环境里,成本低、体积小、能离线。
这类项目适合谁?一是做毕业设计、课程设计的学生,需要一套从摄像头采集到识别结果输出的完整链路;二是做门禁、考勤、智能柜锁的嵌入式开发者,想找一个能直接改的底座。它不适合追求高精度大库容量的商用场景,但对“几十人到几百人、室内光照可控”的需求,单片机方案完全够用。下面按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份资源拆开讲。
2. 系统架构与硬件选型:从摄像头到开锁信号的一条链路
2.1 整体分层:MCU 只做它擅长的事
先把架构说清楚,不然拿到源码容易迷路。这类项目通常分三层:感知层、处理层、执行层。感知层是摄像头(常见 OV7670、OV2640 这类并口或 DVP 接口的模组),负责出图;处理层是 STM32 主控,负责图像采集时序、缓存管理、与识别模块通信、结果判定;执行层是继电器、电磁锁、蜂鸣器、OLED 屏这些外设,负责把“识别通过”变成“门开了”。
关键设计取舍在于:人脸识别的算法到底放哪。纯 STM32 跑完整识别(检测+特征提取+比对)在 F103 这类芯片上基本不现实,常见做法有两种。第一种是“MCU + 专用识别模组”,模组内部自带人脸算法,STM32 只通过 UART 发指令、收结果,这是最稳、最容易复现的路子。第二种是“MCU 采集 + 上位机/边缘板识别”,STM32 把图像通过串口或 USB 传给 PC 或树莓派,识别结果再回传。项目正文没写具体走哪条,但从“基于 STM32 的人脸识别”这个定位看,第一种更符合单片机项目的形态,下面按这个主线讲,第二种在改造章节里补。
提示:如果你手上的源码里出现了大量浮点卷积运算却跑在 F103 上,先别急着编译,大概率是算法被裁剪过或者根本跑不动,先确认识别到底在哪一端完成。
2.2 硬件清单与引脚分配
一份能跑起来的方案,硬件得先对齐。下面这张表是我按这类项目最常见的配置整理的,具体引脚以你源码里的gpio.c或main.h为准,别照抄。
| 模块 | 常见型号 | 接口 | 作用 |
|---|---|---|---|
| 主控 | STM32F103C8T6 / F407 | — | 采集控制与逻辑调度 |
| 摄像头 | OV7670 / OV2640 | DVP 并口 + SCCB | 图像采集 |
| 识别模组 | 串口人脸模组 | UART | 人脸检测与比对 |
| 显示 | 0.96 寸 OLED | I2C | 状态与结果提示 |
| 执行 | 继电器 + 电磁锁 | GPIO | 开锁动作 |
| 交互 | 按键 / 蜂鸣器 | GPIO | 录入触发与提示音 |
接线时最容易翻车的是摄像头的数据线和时钟线。DVP 接口的 PCLK、VSYNC、HSYNC 三根同步信号必须接到支持外部中断或定时器捕获的引脚上,数据线 D0-D7 要连续映射到同一个 GPIO 端口,否则采集时序对不上,出图全是雪花。我一般会先把引脚表在纸上画一遍,确认没有和调试串口、SWD 下载口冲突,再动手焊。
2.3 软件分层与目录结构
源码目录通常长这样,不同工程模板略有差异,但分层逻辑一致:
project/ ├── Core/ │ ├── Inc/ # 头文件:外设、协议、业务声明 │ └── Src/ │ ├── main.c # 主循环与初始化调度 │ ├── camera.c # 摄像头初始化与帧采集 │ ├── face_uart.c # 与识别模组的串口协议 │ ├── oled.c # 显示驱动 │ └── lock.c # 开锁逻辑与状态机 ├── Drivers/ # HAL 库或标准外设库 └── Middlewares/ # 环形缓冲、协议解析等这个结构的好处是业务和驱动分离:camera.c只管出图,face_uart.c只管收发协议帧,main.c里用一个状态机把它们串起来。二次开发时你改业务逻辑基本只动main.c和lock.c,不用碰底层驱动。如果拿到的工程把所有东西塞在一个main.c里,建议先花半天拆开,后面改起来会省很多事。
3. 核心代码拆解:采集、通信、判定三段式
3.1 摄像头帧采集:DVP 时序与双缓冲
图像采集是整个系统的地基,采不稳后面全白搭。OV 系列模组通过 DVP 并口输出,每来一个 PCLK 上升沿输出一个字节,VSYNC 表示一帧开始,HSYNC 表示一行开始。STM32 常见做法是用 DCMI 外设(F4 系列有)配合 DMA 搬运,F103 没有 DCMI 就得用定时器 + 外部中断手动拼帧,代码量差很多。下面给一段基于 DCMI + DMA 的采集初始化骨架:
/* camera.c —— DCMI + DMA 双缓冲采集骨架 */ static uint8_t frame_buf[2][FRAME_SIZE]; /* 双缓冲,避免采集与处理抢同一块内存 */ void camera_init(void) { /* 1. 配置 DCMI:8 位并行、硬件同步、连续模式 */ hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_LOW; hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_LOW; hdcmi.Init.CaptureRate = DCMI_CR_ALL_FRAME; hdcmi.Init.ExtendedDataMode = DCMI_EXTEND_DATA_8B; HAL_DCMI_Init(&hdcmi); /* 2. 启动 DMA 双缓冲,一帧填满自动切到另一块 */ HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_CONTINUOUS, (uint32_t)frame_buf[0], FRAME_SIZE / 4); } /* DMA 半满/全满回调:半满表示 buf[0] 可用,全满表示 buf[1] 可用 */ void HAL_DCMI_FrameEventCallback(DCMI_HandleTypeDef *hdcmi) { frame_ready_flag ^= 1; /* 翻转当前可读缓冲索引 */ }这段代码的关键在双缓冲。单缓冲的问题是:DMA 正在往 buf 里写的时候,CPU 去读就会读到半帧脏数据,表现出来就是识别时好时坏,玄学得很。双缓冲让“写”和“读”各用一块,靠frame_ready_flag切换,是嵌入式图像采集的标准解法。FRAME_SIZE要按你的分辨率算,比如 QQVGA(160×120)灰度图就是 19200 字节,RGB565 则翻倍,F103 的 20KB RAM 基本只够灰度小图,这也是为什么很多方案把识别外置。
参数上,CaptureRate选ALL_FRAME表示不丢帧;如果主循环处理慢,可以改成隔帧采集降负载。ExtendedDataMode必须和模组输出位宽一致,OV7670 配 8 位就填8B,填错会得到错位的图像。
3.2 串口协议解析:和识别模组对话
识别模组一般走 UART,帧格式各家不同,但套路相似:帧头 + 长度 + 命令 + 数据 + 校验。下面是一段带状态机的解析代码,比“收到就HAL_UART_Receive阻塞读”稳得多:
/* face_uart.c —— 环形缓冲 + 状态机解析识别模组回包 */ typedef enum { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_SUM } parse_state_t; static parse_state_t state = WAIT_HEAD1; static uint8_t data_len, cmd, data_idx; static uint8_t payload[32]; void face_parse_byte(uint8_t byte) { switch (state) { case WAIT_HEAD1: if (byte == 0xEF) state = WAIT_HEAD2; /* 帧头第一字节 */ break; case WAIT_HEAD2: state = (byte == 0x01) ? WAIT_LEN : WAIT_HEAD1; break; case WAIT_LEN: data_len = byte; state = WAIT_CMD; break; case WAIT_CMD: cmd = byte; data_idx = 0; state = data_len ? WAIT_DATA : WAIT_SUM; break; case WAIT_DATA: payload[data_idx++] = byte; if (data_idx >= data_len) state = WAIT_SUM; break; case WAIT_SUM: if (byte == calc_checksum(cmd, payload, data_len)) face_on_frame(cmd, payload, data_len); /* 校验通过才分发业务 */ state = WAIT_HEAD1; break; } }为什么不用阻塞读?因为主循环还要刷 OLED、扫按键、控锁,阻塞在串口上整个系统就卡住了。状态机逐字节喂入,来一个处理一个,配合 UART 接收中断或 DMA 空闲中断,CPU 占用极低。calc_checksum按模组手册实现,常见是“累加和取低字节”或“异或校验”,填错会导致所有回包被丢弃,现象是模组明明有返回但程序毫无反应,这个坑后面还会提。
face_on_frame是业务分发点,收到“比对成功”命令就置一个标志位,主循环看到标志再执行开锁,别在中断里直接驱动继电器,动作时序容易乱。
3.3 识别判定与开锁状态机
业务层用一个简单状态机把“待机 → 采集中 → 识别中 → 判定 → 开锁/拒绝”串起来,比一堆if嵌套清晰:
/* main.c —— 主循环状态机 */ typedef enum { S_IDLE, S_CAPTURE, S_RECOG, S_GRANT, S_DENY } sys_state_t; void app_loop(void) { switch (sys_state) { case S_IDLE: if (key_pressed()) sys_state = S_CAPTURE; /* 按键触发一次识别 */ break; case S_CAPTURE: if (frame_ready_flag != last_flag) { /* 新帧到达 */ face_send_image(frame_buf[frame_ready_flag]); sys_state = S_RECOG; } break; case S_RECOG: if (recog_result == RESULT_MATCH) sys_state = S_GRANT; else if (recog_result == RESULT_TIMEOUT) sys_state = S_DENY; break; case S_GRANT: lock_open(3000); /* 开锁 3 秒后自动回锁 */ oled_show("PASS"); sys_state = S_IDLE; break; case S_DENY: beep(2); oled_show("DENY"); sys_state = S_IDLE; break; } }lock_open(3000)的参数是开锁保持时长,单位毫秒,按门锁机械响应调,太短锁舌没弹到位,太长有安全隐患。recog_result由串口解析层写入,注意加volatile修饰,否则编译器优化后主循环可能读不到更新。整个状态机没有阻塞延时,响应快,也方便后面加“连续失败锁定”之类的策略。
4. 跑起来:编译、烧录与联调步骤
4.1 环境准备与工程导入
这类工程绝大多数是 Keil MDK 或 STM32CubeIDE 工程。以 Keil 为例,先装好对应芯片的 Device Pack,再装 ST-Link 或 DAP-Link 驱动。打开工程后第一件事不是编译,是检查三处配置:芯片型号是否和你手上的板子一致、晶振频率是否匹配(8MHz 还是 25MHz)、调试器选的是 SWD 还是 JTAG。这三处错一个,轻则跑飞,重则连不上。
编译前把Options for Target里的 C/C++ 宏定义看一眼,常见有USE_HAL_DRIVER、STM32F103xB这类,芯片型号宏填错会导致寄存器地址全错。编译通过后先别急着接摄像头,烧一个点灯程序确认下载链路通,这是最基本的排障隔离。
4.2 分阶段联调顺序
血泪经验:不要一次性把所有外设接上再上电,出了问题根本不知道是哪一环。按下面顺序逐段验证,每段通了再进下一段。
第一步,只接主控和下载器,烧录点灯或串口打印,确认最小系统活着。第二步,接 OLED,跑显示驱动,能看到字说明 I2C 通了。第三步,接摄像头,写个测试函数把采集到的灰度值打到串口,看数据是否有变化——对着镜头晃手,数值应该跟着变,不变就是时序或接线问题。第四步,接识别模组,用串口助手手动发指令,确认模组本身能返回结果,再让 MCU 去发。第五步,接继电器和锁,先用 LED 代替锁验证开锁信号,最后换真锁。
# 用串口助手手动验证识别模组(以 Linux 下 minicom 为例) minicom -D /dev/ttyUSB0 -b 115200 # 发送模组手册里的“检测人脸”指令帧,观察回包 # 回包正常再让 MCU 复现同样的帧这个顺序的价值在于:任何一步失败,问题范围都被压缩到刚接入的那一个模块,不用在整条链路里大海捞针。
4.3 关键参数怎么调
跑通之后要调几个参数才谈得上好用。识别距离靠摄像头焦距和模组灵敏度配合,室内门禁一般 30-80cm;补光很关键,OV 系列在弱光下噪点爆炸,加一颗白光 LED 常亮或红外补光能显著提升成功率;比对阈值在模组指令里设,调高更严(误识少、拒识多),调低反之,门禁场景我一般偏严一点,宁可多刷一次也别误开。
注意:调阈值时用真实用户多测几轮,别只拿一张标准正脸照调,侧脸、戴眼镜、不同光照都要覆盖,否则上线第一天就翻车。
5. 避坑与排查:五个真实踩过的坑
5.1 图像全是雪花或条纹
现象:OLED 能显示,但采集到的图全是噪点,识别永远失败。原因多半是 DVP 数据线和同步线接错端口,或者 PCLK 极性配反。解决:先用逻辑分析仪抓 VSYNC 和 PCLK,确认一帧内 PCLK 个数等于分辨率像素数;再核对HSPolarity/VSPolarity与模组手册是否一致。没有逻辑分析仪就用“降低分辨率 + 慢速时钟”先跑通,再逐步提速。
5.2 串口收到回包但程序不响应
现象:串口助手上能看到模组正常返回,MCU 却像没收到。原因通常是校验算法写错,或者帧头字节对不上(有的模组是0xEF 0x01,有的是0xAA 0x55)。解决:把解析状态机里每个状态加串口打印,看卡在哪一步;再拿手册逐字节比对校验公式。这个坑我踩过两次,都是想当然按“累加和”写,实际是异或。
5.3 识别时好时坏,重启又正常
现象:刚上电能用,跑一会儿就失灵,重启恢复。原因多为单缓冲采集导致读到半帧脏数据,或者栈溢出——图像缓冲开在栈上,F103 那点 RAM 根本不够。解决:图像缓冲改成全局静态数组或放到指定段,采集改双缓冲;用__heapstats或看 map 文件确认 RAM 占用没超。
5.4 开锁动作抖动或误触发
现象:没人操作锁自己弹开,或者开锁瞬间系统复位。原因:继电器线圈反向电动势干扰电源,或者开锁逻辑放在了中断里。解决:继电器并联续流二极管,电源加去耦电容;开锁动作移到主循环状态机执行,中断里只置标志。
5.5 换了块板子就跑不起来
现象:同一份代码在原板子正常,换板子后各种异常。原因:晶振频率、启动文件、芯片型号宏不一致,或者新板子的 BOOT 引脚电平不对。解决:对照原理图逐项核对时钟树配置,确认SystemClock_Config里的 PLL 参数匹配实际晶振;BOOT0 接地确保从 Flash 启动。
6. 二次开发:把“能识别”改成“能落地”
拿到这套代码,真正值钱的不是识别本身,是围绕它的业务扩展。我一般从三个方向改。第一,加人脸录入流程:按键长按进入录入模式,MCU 连续采几帧发给模组建库,OLED 提示“请正对镜头”,录入成功给个蜂鸣确认。这块要处理超时和重试,别让用户干等。第二,加记录与追溯:识别结果带时间戳存到外部 Flash 或通过串口上报,门禁场景这是刚需,出问题能查。第三,加失败策略:连续 N 次失败锁定 30 秒,防止暴力试脸,状态机里加个计数器就行。
如果想把识别从模组挪到本地,路线是换 F4/H7 系列带 DCMI 和足够 RAM 的芯片,跑轻量人脸检测网络(比如裁剪后的 MobileNet 类模型),用 CMSIS-NN 加速。这条路工作量大,但省掉模组成本,适合批量产品。验证方法很直接:固定测试集跑一遍,统计通过率和误识率,和模组方案对比,数据说话。
| 改造方向 | 改动文件 | 难度 | 适用场景 |
|---|---|---|---|
| 人脸录入流程 | main.c、face_uart.c | 低 | 门禁、考勤 |
| 记录存储上报 | 新增 log.c | 中 | 需要追溯的项目 |
| 本地算法替换 | camera.c、新增 nn 推理 | 高 | 批量降本产品 |
有个习惯我一直保持:每次改完业务逻辑,先不接真锁,用 LED 和串口打印把整条状态流转跑十遍,确认没有卡死和误触发,再上硬件。早年图省事直接接锁调试,结果一个状态机死循环把锁烧了,从那以后我每次改完状态机都强制走一遍“空跑验证”。希望帮到你。
本文还有配套的精品资源,点击获取