ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32上跑二维码识别:OV5642+DCMI+DMA全链路实战

STM32上跑二维码识别:OV5642+DCMI+DMA全链路实战

简介:这份资源面向嵌入式开发初学者与参加电子设计竞赛的学生,提供基于STM32单片机与OV5642摄像头模块的二维码识别完整工程。包内共316个文件,以C源码与头文件为核心,包含67个.c与80个.h文件,另有编译中间产物如38个.o、38个.d及37个.crf文件,以及Keil工程配置、链接脚本、库文件与少量说明文档,压缩包约12.05MB,可直接导入开发环境编译调试。资源围绕二维码图像采集、二值化处理与解码识别展开,涉及字符集、校验功能及图形旋转变化等条码技术要点,帮助读者理解从摄像头取像到信息自动处理的完整链路。目前已有2445人学习下载,适合希望快速搭建二维码识别原型、研究图像识别算法在单片机端落地的开发者参考借鉴。

1. 需求先立住:为什么要在 STM32 上跑二维码识别

去年带一个嵌入式方向的课设小组,题目定的是“基于 STM32 的二维码识别系统”。组里第一反应是:二维码识别不是手机扫一下的事吗,为什么还要用单片机做?这正是这个项目最值得写的地方——它要解决的不是“能不能扫”,而是在没有操作系统、没有 OpenCV、主频只有几十到一百多兆、RAM 以 KB 计的 MCU 上,把图像采集、预处理、定位、解码整条链路跑通。适合做这个题的人很明确:嵌入式、电子信息、自动化方向的学生,或者想给门禁、仓储、产线做低成本离线扫码模块的从业者。OV5642 作为并口摄像头,配合 STM32 的 DCMI 接口,是这条链路上最典型的硬件组合,也是这套方案要讲清楚的核心。

2. 总体方案设计:从 OV5642 到二维码字符串的四段链路

2.1 硬件选型与接口分配

这套方案的核心器件就三块:STM32 主控、OV5642 摄像头、一块能显示中间结果的屏(TFT 或 OLED 都行)。OV5642 是 500 万像素的并口摄像头,输出格式支持 RGB565、YUV、JPEG,通过 8 位或 10 位并口把数据推给 STM32 的 DCMI(数字摄像头接口)。DCMI 的好处是硬件自动搬运,配合 DMA 双缓冲,CPU 不用一个像素一个像素去读,这是能在 MCU 上跑图像的前提。

选型上有几个硬约束必须提前算清楚。第一是分辨率:OV5642 全尺寸太大,二维码识别用 QVGA(320×240)甚至 QQVGA(160×120)就够,像素越少,后续二值化和定位的运算量越小。第二是内存:一帧 QVGA 的 RGB565 是 320×240×2 = 153600 字节,STM32F4 系列内部 SRAM 通常 192KB,勉强放一帧,但双缓冲就不够了,所以一般外挂一颗 SDRAM 或者用 FSMC 扩展。第三是主频:二值化、连通域标记、定位这些操作是纯 CPU 运算,主频低于 100MHz 会明显吃力,F4/F7/H7 是合理区间。

我一般会先画一张数据流图再动手:OV5642 → DCMI → DMA → 帧缓冲(SDRAM)→ 灰度化 → 二值化 → 定位 → 解码 → 串口/屏幕输出。每一段都有明确的输入输出和内存占用,先把这张图算明白,后面写代码才不会返工。

2.2 图像采集:DCMI + DMA 双缓冲配置

采集是整个链路的地基,配不好后面全是玄学问题。OV5642 通过 SCCB(类似 I2C)配置寄存器,设置输出格式、分辨率、帧率,然后 DCMI 开始接收。关键是用 DMA 双缓冲,让“采一帧”和“处理上一帧”能并行,否则每帧都要等采集完才能处理,帧率直接砍半。

// DCMI + DMA 双缓冲初始化(以 STM32F4 HAL 为例) void Camera_DMA_Init(void) { // DCMI 配置:8 位并口,硬件同步,RGB565 hdcmi.Init.SynchroMode = DCMI_SYNCHRO_HARDWARE; // 行场同步由硬件信号给出 hdcmi.Init.PCKPolarity = DCMI_PCKPOLARITY_RISING; // 像素时钟上升沿采样 hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_LOW; // 场同步低有效 hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_LOW; // 行同步低有效 hdcmi.Init.CaptureRate = DCMI_CR_ALL_FRAME; // 全帧捕获 hdcmi.Init.ExtendedDataMode = DCMI_EXTEND_DATA_8B; HAL_DCMI_Init(&hdcmi); // DMA2 流1,双缓冲模式,每帧 320*240 个半字 HAL_DMA_Start_IT(&hdma_dcmi, (uint32_t)&DCMI->DR, (uint32_t)frame_buffer0, FRAME_SIZE); // 第二块缓冲由 HAL_DCMI_Start_DMA 的第二个地址参数指定 HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_SNAPSHOT, (uint32_t)frame_buffer0, FRAME_SIZE); }

这段代码里几个参数值得说清楚。SynchroMode选硬件同步,是因为 OV5642 会输出 VSYNC/HSYNC/PCLK,硬件同步比软件轮询稳得多。PCKPolarity要和摄像头实际输出的时钟极性对上,对不上会出现整帧错位、图像撕裂,这是最常见的翻车点。CaptureRate用全帧捕获,配合 DMA 的FRAME_SIZE正好是一帧的像素数。双缓冲的意义在于:DMA 填 buffer0 的时候 CPU 处理 buffer1,填满后中断里切换,处理延迟被隐藏掉,实测帧率能比单缓冲高 40% 以上。

2.3 预处理与二值化:把彩色图变成黑白点阵

二维码识别不需要颜色,只需要黑白。所以第一步是灰度化,RGB565 转灰度用加权公式Gray = (R*77 + G*150 + B*29) >> 8,这个系数是常用近似,比等权平均更符合人眼感知,对后续阈值分割更友好。灰度化可以在采集后立刻做,也可以边采边算,我一般放在 DMA 半满/全满中断里,趁数据还在缓存里顺手转掉。

第二步是二值化。二维码是黑白模块,理论上用固定阈值就能分,但实际光照不均,固定阈值会翻车。常用的是局部自适应阈值,比如把图像分成若干块,每块单独算阈值。但 MCU 上做全图自适应太慢,工程里更常见的是“全局阈值 + 局部修正”:先统计整帧灰度直方图,取双峰之间的谷值作为全局阈值,再对过暗或过亮的区域做补偿。

// 灰度化 + 全局阈值二值化(逐像素,适合 QVGA) void Binarize_Frame(uint16_t *src, uint8_t *dst, int pixels) { // 第一遍:统计直方图,找全局阈值 uint32_t hist[256] = {0}; for (int i = 0; i < pixels; i++) { uint16_t p = src[i]; uint8_t r = (p >> 11) & 0x1F; uint8_t g = (p >> 5) & 0x3F; uint8_t b = p & 0x1F; // 扩展到 8 位再算灰度 uint8_t gray = (r * 77 + g * 150 + b * 29) >> 8; hist[gray]++; } // 取直方图谷值作为阈值(简化版 Otsu 思路) uint8_t threshold = Find_Valley(hist, 256); // 第二遍:按阈值二值化,黑=0,白=1 for (int i = 0; i < pixels; i++) { uint16_t p = src[i]; uint8_t r = (p >> 11) & 0x1F; uint8_t g = (p >> 5) & 0x3F; uint8_t b = p & 0x1F; uint8_t gray = (r * 77 + g * 150 + b * 29) >> 8; dst[i] = (gray < threshold) ? 0 : 1; } }

这里Find_Valley是简化版阈值选取,真实工程里如果光照稳定,直接给一个经验阈值(比如 128)也能跑,但换个环境就得重调。二值化后的dst是每像素 1 bit 还是 1 byte,取决于后续算法:如果做连通域标记,用 1 byte 方便索引;如果只做行列扫描定位,可以压成位图省内存。QVGA 下 1 byte/像素是 76800 字节,F4 的内部 RAM 放得下,但加上帧缓冲就紧张,所以二值化结果通常直接覆盖原缓冲或放到独立的小缓冲里。

2.4 定位与解码:从黑白点阵到字符串

二值化之后是定位。二维码有三个角上的“回”字形定位图案,这是它能在任意角度被识别的关键。MCU 上做定位,常用的是扫描线法:逐行扫描,找连续的黑白黑白的宽度比例,符合 1:1:3:1:1 的段落就认为是定位图案的一行。三个定位点找到后,用它们确定二维码的旋转角度和边界,再做透视校正,把倾斜的码摆正。

解码部分是最考验工程取舍的。完整的 QR 解码包括格式信息读取、版本确定、掩码去除、纠错译码(RS 纠错),全自己写工作量很大。实际项目里通常有两种做法:一是移植轻量级解码库,把核心解码函数抠出来适配 MCU;二是针对固定版本、固定内容的场景,写简化解码。我一般建议课设阶段先支持 Version 1~4、纠错等级 L 的码,把主流程跑通,再逐步扩展。

// 定位图案扫描(简化示意:逐行找 1:1:3:1:1 比例) int Find_Finder_Pattern(uint8_t *bin, int w, int h, Point *out) { for (int y = 0; y < h; y++) { int run[5] = {0}; // 连续黑白黑白黑的长度 int state = 0; for (int x = 0; x < w; x++) { uint8_t px = bin[y * w + x]; if (px == 0) { // 黑 if (state % 2 == 0) run[state]++; else { state++; run[state]++; } } else { // 白 if (state % 2 == 1) run[state]++; else { state++; run[state]++; } } if (state == 5) { // 检查比例是否接近 1:1:3:1:1 if (Check_Ratio(run)) { out->x = x - (run[0]+run[1]+run[2]+run[3]+run[4]); out->y = y; return 1; } // 滑动窗口,丢弃第一个 run,继续 for (int i = 0; i < 4; i++) run[i] = run[i+1]; run[4] = 0; state = 4; } } } return 0; }

Check_Ratio里做的是比例容差判断,因为实际图像有噪声,不可能严格 1:1:3:1:1,一般允许 ±50% 的偏差。找到三个定位点后,用它们算仿射变换矩阵,把码区域映射到标准正方形,再按模块网格采样,得到 0/1 矩阵,最后送解码。这一步的坑在于:如果定位点找错(比如把码里的数据模块误判成定位图案),后面全错,所以通常要求三个点满足几何关系(构成等腰直角三角形)才确认。

3. 关键模块实现:内存、帧率与解码的工程取舍

3.1 内存布局:把有限的 RAM 花在刀刃上

MCU 上做图像,内存永远是第一约束。以 QVGA RGB565 为例,一帧 153600 字节,双缓冲就是 307200 字节,F4 的 192KB 内部 RAM 根本不够。所以这套方案通常要外挂 SDRAM,通过 FMC/FSMC 映射到地址空间。但 SDRAM 访问速度比内部 SRAM 慢,DMA 直接写 SDRAM 可能带宽不够,常见做法是 DMA 先写内部 SRAM 的小缓冲,再搬运到 SDRAM,或者降低分辨率到 QQVGA。

我的经验是:如果只是做二维码识别,QQVGA(160×120)完全够用,一帧 38400 字节,双缓冲 76800 字节,F4 内部 RAM 能放下,省掉 SDRAM 这颗料,成本和布线都简单。代价是识别距离近,码要占满画面较大比例。课设阶段强烈建议从 QQVGA 起步,把链路跑通再考虑提分辨率。

3.2 帧率优化:哪些操作可以省,哪些不能省

帧率决定了扫码体验。整条链路里,采集是硬件搬运,基本不占 CPU;灰度化 + 二值化是逐像素,QVGA 下约 76800 次循环,F4 上大概几毫秒;定位扫描是逐行,最坏情况全图扫一遍,也是几毫秒;解码最耗时,RS 纠错在 MCU 上可能几十毫秒。所以瓶颈通常在解码,而不是图像处理。

优化手段有几个:一是降低分辨率,像素少一半,处理时间少一半;二是只处理感兴趣区域(ROI),如果码总在画面中央,就只扫中间那块;三是二值化和定位用查表代替实时计算;四是解码库选轻量的,别把桌面级库直接搬过来。我一般会先测每一段的耗时,用 GPIO 翻转 + 示波器看,或者用 DWT 周期计数器,找到真正的瓶颈再优化,而不是凭感觉。

3.3 解码适配:轻量库移植与固定版本简化

解码是这套方案里最“重”的部分。完整 QR 解码涉及 RS 纠错、掩码、格式信息,代码量不小。移植时要注意几点:一是库的内存分配要改成静态或预分配,MCU 上尽量不用 malloc;二是浮点运算尽量换成定点,F4 有 FPU 还好,F1 没有 FPU 会非常慢;三是把与平台相关的打印、文件操作去掉,只留核心解码。

如果场景固定(比如只识别自己生成的码),可以走简化路线:固定版本、固定纠错等级、固定掩码,省掉格式信息解析和部分纠错逻辑,代码量能砍一半以上。代价是通用性差,换个码可能就不认了。课设里我一般建议先做通用解码,跑通后再根据需求裁剪。

4. 避坑与常见问题:那些让项目卡住的细节

4.1 图像撕裂或错位

现象:屏幕显示的图像上下错位、颜色错乱,或者 DMA 传输一半就停了。原因通常是 DCMI 的同步极性配错,或者 DMA 缓冲大小和实际帧大小不一致。解决:先用固定测试图案(比如全红)验证 DCMI 配置,确认 VSYNC/HSYNC/PCK 极性;再检查FRAME_SIZE是否等于宽×高,双缓冲的两个地址是否都有效。我踩过一次坑,PCKPolarity设反了,图像整体偏移半个像素,二值化后定位图案全乱,查了两天才发现是极性。

4.2 二值化后码区域全黑或全白

现象:光照变化时,二维码区域二值化后变成一整块黑或一整块白,定位图案消失。原因是全局阈值不适应光照。解决:改用局部自适应阈值,或者加自动曝光控制(OV5642 支持 AEC/AGC,通过 SCCB 写寄存器开启)。如果不想动摄像头,就在二值化前做一次简单的背景估计,比如取图像四角的平均灰度作为参考,动态调整阈值。

4.3 定位图案误检

现象:程序把码里的数据模块误判成定位图案,导致解码失败或解出乱码。原因是比例判断太宽松,或者扫描线穿过了非定位区域。解决:加几何约束,要求找到的三个点构成近似等腰直角三角形,且边长比例合理;再加一次交叉验证,用找到的点反推码边界,检查边界内黑白模块比例是否符合二维码特征。

4.4 解码耗时过长导致帧率骤降

现象:采集帧率明明有 30fps,但识别结果几秒才出一次。原因是解码在中断里同步执行,阻塞了采集。解决:把解码放到主循环,采集用 DMA 中断只负责切换缓冲和置标志位;或者用 RTOS 分任务,采集、处理、解码各一个任务,用消息队列传递帧指针。注意帧缓冲要加引用计数或双缓冲轮换,避免解码时缓冲被覆盖。

4.5 内存溢出或 HardFault

现象:程序跑一段时间后死机,或者一进图像处理就 HardFault。原因通常是数组越界、栈溢出或 DMA 访问了非法地址。解决:把大数组定义成全局或静态,别放栈上;检查 DMA 目标地址是否在有效 RAM 区域;用__attribute__((aligned(4)))保证缓冲对齐,有些 DMA 要求 4 字节对齐。我一般会在链接脚本里把大缓冲单独放一个段,方便看内存占用。

5. 验收与答辩:怎么证明这套方案真的跑通了

验收这件事,最忌讳只说“能识别”。答辩老师或评审要的是可复现的证据链。我一般会准备三样东西:一份测试记录表、一段现场演示、一份关键代码走读。

测试记录表按场景分:正常光照、弱光、强光、倾斜 15°、倾斜 30°、部分遮挡。每个场景跑 20 次,记录成功次数和平均耗时。表格长这样:

测试场景测试次数成功次数成功率平均耗时(ms)
正常光照2020100%85
弱光201680%92
强光201890%88
倾斜15°201995%90
倾斜30°201575%95
部分遮挡201260%98

这张表能直接说明方案的边界:正常场景稳,极端场景有下降,但整体可用。答辩时老师问“弱光为什么掉”,你就能答“因为全局阈值在低对比度下分割不准,后续可以加 AEC 或局部阈值”,显得你清楚瓶颈在哪。

现场演示要准备“翻车预案”。比如提前把码打印在不同材质上(纸、屏幕、金属),演示时如果某个场景失败,立刻换一个已知能过的场景,别在一个点上死磕。我一般会准备一个“保底码”——尺寸大、光照好、正对摄像头,确保演示至少有一次成功。

代码走读环节,重点讲三处:DCMI + DMA 双缓冲的配置逻辑、二值化阈值的选择依据、定位图案的比例判断。这三处是方案的技术核心,讲清楚就能证明你不是调库调出来的。如果老师追问“为什么不用 OpenCV”,你就答“MCU 上没有操作系统和足够内存,OpenCV 跑不起来,这套是面向裸机或 RTOS 的轻量实现”,把约束讲明白。

从那以后我每次带图像类项目,都强制先做一张“耗时分布表”,把采集、预处理、定位、解码各占多少毫秒标出来,再动手优化。没有这张表,优化就是瞎猜。希望这套结构能帮你把课设或方案顺利交付。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进