简介:这份资源是面向嵌入式视觉与条码识别开发者的STM32H750端程序,解决的是在MCU侧通过OV5640摄像头采集640×480分辨率RGB图像并上传至上位机完成一维码、二维码解码的问题。适合已具备STM32基础、希望打通图像采集与上位机解码链路的工程师与高年级学生,可作为条码识别项目的参考实现。压缩包共257个文件,约16.77MB,以C源码与头文件为主体,包含39个c、67个h,并配有40个d依赖文件、40个o目标文件、40个su编译单元记录,以及mk、makefile、ld链接脚本、map映射文件、bin与elf可执行产物、ioc与mxproject工程配置等,完整保留了从源码到固件的构建痕迹。目前已有588人学习下载。读者可据此理解OV5640驱动、DCMI与DMA图像搬运、串口上传协议等关键环节,并配合上位机解码软件完成一维码与二维码识别验证,快速搭建可复现的条码识别实验环境。
1. 从一颗 H750 和一颗 OV5640 说起:条码识别为什么要自己搭
前阵子帮朋友调一套产线扫码工装,需求很朴素:固定工位、固定距离、扫一维码和二维码,识别结果通过串口吐给上位机。他一开始想用现成的模组,后来发现视野和补光都不好调,索性自己画了块板子,主控选了 STM32H750VBT6,摄像头用 OV5640,软件层面就是一套跑在裸机上的条码识别。这套组合在工业现场其实很常见,H750 主频能到 480MHz,带 DCMI 接口和硬件 JPEG 解码,OV5640 又是资料最全的 500 万像素 sensor 之一,两者凑一起做条码识别,成本和可控性都比买模组划算。
这篇文章就围绕 STM32H750VBT6 加 OV5640 做条码识别这条路线展开,把从硬件接线、DCMI 配置、图像预处理到条码解码的完整链路讲清楚。适合两类人看:一类是刚接触 STM32 图像采集、想找个具体项目练手的新手,另一类是在产线或自助设备上做扫码方案、需要评估自研可行性的工程师。我不会假设你手里有某个现成的工程包,所有步骤都按通用做法给,你照着搭就能跑起来。
2. 硬件链路怎么搭:DCMI、I2C 和时钟的三角关系
2.1 OV5640 与 H750 的引脚映射逻辑
OV5640 输出的是并行 DVP 信号,包含 PCLK、HREF、VSYNC 和 8 位数据线 D0-D7,另外还有 SCCB 控制接口,本质就是 I2C。H750 这边对应的是 DCMI 外设加一路 I2C。接线的时候最容易翻车的地方是数据线顺序,很多人以为 D0 必须接 DCMI_D0,其实 DCMI 支持数据线重映射,你接成 D7 到 DCMI_D0 也能通过配置纠正过来,但前提是你得在 DCMI 初始化里把 DataWidth 和实际接线对齐。
我一般会先把 OV5640 的引脚按功能分组:电源组(AVDD、DVDD、DOVDD)、时钟组(XVCLK、PCLK)、同步组(VSYNC、HREF)、数据组(D0-D7)、控制组(SIOC、SIOD、PWDN、RESET)。H750 这边 DCMI 的引脚是固定的几个 AF 口,查数据手册的复用表就能确定。XVCLK 需要外部提供 24MHz 时钟,可以用 H750 的 MCO 输出,也可以外挂晶振,我倾向用 MCO,省一个物料。
注意:OV5640 的 PWDN 和 RESET 不要悬空,很多模块默认拉低使能,但自己画板时如果悬空,上电时序会乱,表现为 I2C 读不到 ID。
2.2 DCMI 初始化:从寄存器到 HAL 配置
DCMI 的配置核心是几个参数:捕获模式、数据宽度、时钟极性、硬件同步。下面是一段基于 HAL 库的初始化代码,我把它拆成可读的片段。
// DCMI 句柄配置,重点在 hdcmi.Init 这几个字段 hdcmi.Instance = DCMI; hdcmi.Init.SynchroMode = DCMI_SYNCHRO_HARDWARE; // 硬件同步,用 VSYNC/HREF hdcmi.Init.PCKPolarity = DCMI_PCKPOLARITY_RISING; // PCLK 上升沿采样 hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_HIGH; // VSYNC 高电平有效 hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_HIGH; // HREF 高电平有效 hdcmi.Init.CaptureRate = DCMI_CR_ALL_FRAME; // 全帧捕获 hdcmi.Init.ExtendedDataMode = DCMI_EXTEND_DATA_8B; // 8 位数据 hdcmi.Init.JPEGMode = DCMI_JPEG_DISABLE; // 条码识别用 RGB 或 YUV,不开 JPEG if (HAL_DCMI_Init(&hdcmi) != HAL_OK) { Error_Handler(); }这段代码里最需要根据实际硬件调的是 PCKPolarity 和 VSPolarity。OV5640 默认输出 PCLK 上升沿有效,但如果你走线比较长、信号有振铃,可能得改成下降沿采样才稳。VSPolarity 取决于你用的是 VSYNC 高电平表示帧有效还是低电平,这个在 OV5640 的寄存器 0x4740 里可以配,两边必须一致,否则你会看到图像上下翻滚或者只采到半帧。
参数说明:CaptureRate 设成全帧,如果你只做条码识别,其实可以设成 DCMI_CR_1_LINE 配合行中断做逐行处理,省内存,但调试阶段建议先全帧,确认图像正常再优化。ExtendedDataMode 选 8B,因为 OV5640 的 DVP 就是 8 位输出,别选成 10B 或 12B,否则数据错位。
2.3 时钟树配置:480MHz 主频下怎么分频给 DCMI
H750 的 DCMI 时钟来自 AHB 总线,最高不能超过 100MHz 左右,具体看手册。我一般把系统时钟跑到 480MHz,AHB 分频到 240MHz,DCMI 再分频到 80MHz 左右。OV5640 在 640x480 分辨率下 PCLK 大概 24MHz 到 48MHz,DCMI 时钟要高于 PCLK 才能稳定采样,所以 80MHz 是够的。
配置的时候用 CubeMX 最省事,但你要知道它在做什么。下面是手动配置 DCMI 时钟的关键寄存器操作:
// 假设 AHB 已经 240MHz,DCMI 分频系数设为 3,得到 80MHz RCC->D2CCIP2R &= ~RCC_D2CCIP2R_DCMISEL; // 清除 DCMI 时钟源选择 RCC->D2CCIP2R |= RCC_D2CCIP2R_DCMISEL_0; // 选 PLL2 作为时钟源 // 然后在 DCMI 的 CR 寄存器里设置分频 DCMI->CR &= ~DCMI_CR_EDM_Msk; DCMI->CR |= (0x2 << DCMI_CR_EDM_Pos); // 这里只是示例,实际分频在 RCC 里更稳妥的做法是直接用 HAL_RCCEx_PeriphCLKConfig 配 PeriphClockSelection 为 RCC_PERIPHCLK_DCMI,指定 ClockSelection 和 Divider。分频系数别设太小,DCMI 时钟过高会导致采样窗口变窄,图像上出现随机噪点,这种噪点在条码识别里是致命的,因为二值化之后噪点会变成假条空。
3. 图像预处理:从 RAW 到能二值化的灰度图
3.1 OV5640 输出格式选择:RGB565 还是 YUV
OV5640 支持 RGB565、YUV422、JPEG 等输出格式。做条码识别,我建议用 YUV422,因为 Y 分量就是灰度,直接拿来做二值化,省掉 RGB 转灰度的计算。如果你用 RGB565,就得在 MCU 里做一次加权平均,H750 虽然有硬件加速,但没必要多这一步。
配置输出格式是通过 SCCB 写寄存器,OV5640 的寄存器 0x4300 控制输出顺序,0x501F 控制 ISP 输出格式。下面是一段写寄存器的封装:
// 通过 SCCB 写 OV5640 寄存器,reg 是 16 位地址,val 是 8 位值 uint8_t OV5640_WriteReg(uint16_t reg, uint8_t val) { uint8_t buf[3]; buf[0] = (reg >> 8) & 0xFF; buf[1] = reg & 0xFF; buf[2] = val; return HAL_I2C_Master_Transmit(&hi2c1, OV5640_ADDR, buf, 3, 100); } // 设置为 YUV422 输出 OV5640_WriteReg(0x4300, 0x30); // YUV422 OV5640_WriteReg(0x501F, 0x03); // ISP 输出 YUV422逻辑说明:0x4300 的 0x30 表示 YUYV 顺序,0x501F 的 0x03 是 ISP 控制寄存器,选 YUV422。写完这两个之后,DCMI 收到的数据就是 Y0 U0 Y1 V0 这样的排列,你只需要取每个像素对的第一个字节就是 Y 分量。
参数说明:如果你发现图像颜色不对但灰度正常,说明 YUV 顺序反了,把 0x4300 改成 0x32 试试。另外 OV5640 的自动白平衡和自动增益在条码场景下最好关掉,因为条码是黑白对比,自动增益会把对比度拉低,反而不好二值化。关 AWB 是写 0x3406 为 0x00,关 AGC 是写 0x3503 为 0x00。
3.2 灰度化与二值化:在 MCU 上怎么算得快
如果你已经用了 YUV422,灰度化这步就省了。接下来是二值化,最常用的是全局阈值,比如大津法。但在 H750 上跑大津法,一帧 640x480 要遍历两次直方图,耗时大概几毫秒,可以接受。下面是一个简化版的大津法实现:
// 输入:灰度图指针,宽高;输出:阈值 uint8_t OtsuThreshold(uint8_t *img, int w, int h) { int hist[256] = {0}; int total = w * h; for (int i = 0; i < total; i++) { hist[img[i]]++; } float sum = 0; for (int i = 0; i < 256; i++) { sum += i * hist[i]; } float sumB = 0, wB = 0, maxVar = 0; uint8_t threshold = 0; for (int i = 0; i < 256; i++) { wB += hist[i]; if (wB == 0) continue; float wF = total - wB; if (wF == 0) break; sumB += i * hist[i]; float mB = sumB / wB; float mF = (sum - sumB) / wF; float varBetween = wB * wF * (mB - mF) * (mB - mF); if (varBetween > maxVar) { maxVar = varBetween; threshold = i; } } return threshold; }这段代码的逻辑是遍历直方图,找类间方差最大的阈值。参数说明:img 是 Y 分量数组,w 和 h 是图像宽高。实际用的时候,我建议先对图像做一次 2x2 降采样,把 640x480 降到 320x240,这样直方图统计量少四分之三,阈值依然够用,因为条码的条空宽度通常大于 2 个像素。
二值化之后,条码区域是黑白分明的,但背景可能有反光或阴影。这时候可以加一个形态学开运算,去掉小的噪点。在 MCU 上做形态学,用 3x3 的核就够了,腐蚀一次再膨胀一次,代码不复杂,但要注意边界处理,别越界访问。
3.3 图像缓存怎么放:内部 SRAM 还是外部 SDRAM
H750VBT6 内部 SRAM 有 1MB 左右,但分散在几个域里,DCMI 能直接写入的是 AXI SRAM 和部分 D2 域 SRAM。一帧 640x480 的 YUV422 是 614KB,内部 SRAM 放不下两帧,只能放一帧。如果你要做双缓冲,就得外挂 SDRAM 或者用 JPEG 压缩。
我的做法是:调试阶段用单缓冲,DCMI 配置成快照模式,采完一帧就停,处理完再采下一帧。这样虽然帧率低,但逻辑简单,不容易出错。等识别率稳定了,再考虑外挂 SDRAM 做乒乓缓冲。H750 的 FMC 接口挂一片 16 位 SDRAM,成本增加不多,但能跑到 30fps 以上。
注意:DCMI 写入 SDRAM 时,要确保 SDRAM 的刷新周期和 DCMI 的突发传输匹配,否则会出现图像撕裂。常见做法是把 DCMI 的 DMA 突发长度设成 8 或 16,SDRAM 的 CAS 延迟设成 2 或 3,具体看你的 SDRAM 型号。
4. 条码解码:从二值图到字符串的最后一公里
4.1 一维码:扫描线找条空边界
一维码解码的核心是找条空宽度。我一般从图像中间取若干条水平扫描线,对每条线做游程编码,记录连续黑像素和白像素的长度。然后根据起始符和终止符判断条码类型,比如 EAN-13 的起始符是 101,Code128 的起始符是 211412。
下面是一个游程编码的片段:
// 对一行二值图像做游程编码,返回游程数组和长度 int RunLengthEncode(uint8_t *line, int width, int *runs, int maxRuns) { int count = 0; int runLen = 1; for (int i = 1; i < width; i++) { if (line[i] == line[i-1]) { runLen++; } else { if (count < maxRuns) runs[count++] = runLen; runLen = 1; } } if (count < maxRuns) runs[count++] = runLen; return count; }逻辑说明:line 是二值化后的一行,0 表示黑,1 表示白。runs 数组存的是每个连续段的长度。参数说明:maxRuns 要设得足够大,比如 200,因为一维码的条空数量可能上百。拿到 runs 之后,你要做归一化,因为条码的条空宽度是基准宽度的整数倍,通常用最小游程作为基准,然后看每个游程是基准的几倍,四舍五入到整数。
一维码解码的坑在于:如果扫描线没穿过条码中心,或者条码有倾斜,游程宽度会失真。解决办法是多取几条扫描线,取解码成功次数最多的结果。另外,条码两侧要有足够的空白区,如果图像裁剪太紧,起始符和终止符可能被切掉,导致解码失败。
4.2 二维码:定位图案与网格采样
二维码(比如 QR Code)的解码比一维码复杂,但步骤是固定的:找三个定位图案,确定版本和方向,然后按网格采样。在 MCU 上做完整的 QR 解码,代码量不小,我一般会移植一个轻量级的库,比如 quirc 或者自己写一个简化版。
定位图案的查找方法是:在二值图上做行扫描,找 1:1:3:1:1 的黑白比例。找到三个之后,用它们的位置关系确定二维码的四个角。下面是一个查找定位图案的伪代码:
// 在二值图中查找 QR 定位图案,返回候选点 int FindFinderPattern(uint8_t *img, int w, int h, Point *candidates, int maxCand) { int count = 0; for (int y = 0; y < h; y++) { int run[5] = {0}; int state = 0; for (int x = 0; x < w; x++) { if (img[y*w+x] == 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 int total = run[0]+run[1]+run[2]+run[3]+run[4]; if (abs(run[0]*7 - total) < total/2 && abs(run[1]*7 - total) < total/2 && abs(run[2]*3 - total*3/7) < total/2 && abs(run[3]*7 - total) < total/2 && abs(run[4]*7 - total) < total/2) { if (count < maxCand) { candidates[count].x = x - total; candidates[count].y = y; count++; } } // 滑动窗口,继续找 run[0] = run[2]; run[1] = run[3]; run[2] = run[4]; state = 3; } } } return count; }这段代码的逻辑是维护一个 5 段游程窗口,当窗口满 5 段时检查比例。参数说明:img 是二值图,0 为黑,1 为白。candidates 存候选定位图案的中心坐标。实际用的时候,你还需要在垂直方向再验证一次,避免把条码的某些图案误判成定位图案。
找到三个定位图案后,计算二维码的版本和模块大小,然后对每个模块中心采样,得到 0/1 矩阵。最后按 QR 的格式做纠错和解码。这部分如果自己写,工作量不小,建议先用现成库跑通,再根据需求裁剪。
4.3 解码结果输出:串口协议怎么定
识别出字符串之后,要通过串口发给上位机。协议我一般定得很简单:帧头 0xAA 0x55,然后跟一个字节的长度,再跟数据,最后跟一个字节的校验和。这样上位机解析起来不容易错。
// 发送条码结果,data 是字符串,len 是长度 void SendBarcodeResult(uint8_t *data, uint8_t len) { uint8_t header[2] = {0xAA, 0x55}; uint8_t checksum = 0; HAL_UART_Transmit(&huart1, header, 2, 100); HAL_UART_Transmit(&huart1, &len, 1, 100); for (int i = 0; i < len; i++) { checksum += data[i]; } HAL_UART_Transmit(&huart1, data, len, 100); HAL_UART_Transmit(&huart1, &checksum, 1, 100); }逻辑说明:先发帧头,再发长度,再发数据,最后发校验和。参数说明:len 是条码字符串的长度,一般不超过 64 字节。校验和用累加和,简单够用。如果你要传中文或者二进制数据,长度字段要改成两字节,并且数据里如果有 0xAA 0x55,要做转义,否则上位机会误判帧头。
5. 避坑与排查:那些让我熬夜的瞬间
5.1 图像全黑或全白:先查时钟和电源
现象:DCMI 采到的图像要么全黑,要么全白,偶尔有几行噪点。原因:OV5640 的 XVCLK 没起振,或者 PWDN 引脚电平不对。解决:用示波器量 XVCLK,应该是 24MHz 方波;量 PWDN,正常工作时应该是低电平。如果 XVCLK 没有,检查 MCO 配置,或者换外挂晶振。
5.2 图像有横条纹:PCLK 极性反了
现象:图像上有规律的横条纹,条纹间距和 PCLK 周期相关。原因:DCMI 的 PCKPolarity 和 OV5640 的输出极性不匹配。解决:把 DCMI_Init.PCKPolarity 从 RISING 改成 FALLING,或者反过来。这个坑很玄学,因为有时候条纹不明显,但二值化之后条码就解不出来了。
5.3 条码识别率低:补光和曝光时间没调好
现象:同一张条码,有时候能解,有时候解不出来。原因:OV5640 的自动曝光在条码场景下会频繁调整,导致图像亮度不稳定。解决:关掉自动曝光,手动设曝光时间。写寄存器 0x3503 为 0x00 关 AEC,然后写 0x3500、0x3501、0x3502 设曝光值。具体值要根据你的补光强度调,我一般从 0x1000 开始试。
5.4 解码结果乱码:串口波特率或校验和错了
现象:上位机收到的字符串是乱码,或者校验和不对。原因:串口波特率不匹配,或者校验和计算时把长度字节也算进去了。解决:确认两边波特率一致,校验和只累加数据部分,不包括帧头和长度。另外,如果数据里有 0x00,HAL_UART_Transmit 是能发的,但上位机如果按字符串处理会截断,所以协议里最好带长度。
5.5 帧率上不去:DCMI 和 SDRAM 抢总线
现象:外挂 SDRAM 之后,帧率反而比单缓冲还低。原因:DCMI 的 DMA 和 CPU 同时访问 SDRAM,总线仲裁开销大。解决:把 DCMI 的 DMA 优先级设高,或者用双缓冲加 DMA 传输完成中断,让 CPU 在另一块缓冲上处理。另外,SDRAM 的刷新周期别设太短,否则占用太多带宽。
6. 进阶技巧:把识别率从 90% 拉到 99%
前面讲的都是能跑通的基础链路,但实际产线上,条码可能有污损、倾斜、反光,识别率卡在 90% 上不去。我自己的经验是,最后这 10% 靠的是多帧融合和参数自适应。
多帧融合的做法是:连续采 3 帧,分别二值化并解码,取多数结果。如果三帧结果不一致,就取置信度最高的那个。置信度可以用解码时的纠错次数来衡量,纠错次数越少,置信度越高。这个逻辑在 MCU 上实现不难,就是多占点内存。
参数自适应是指根据图像质量动态调整二值化阈值和曝光。比如,如果当前帧的直方图双峰不明显,说明对比度低,就加大补光或者延长曝光。下面是一个简单的判断逻辑:
// 判断直方图是否双峰,返回 1 表示双峰明显 int IsBimodal(int *hist) { int peak1 = 0, peak2 = 0; int valley = 255; for (int i = 1; i < 255; i++) { if (hist[i] > hist[peak1]) peak1 = i; } for (int i = 1; i < 255; i++) { if (abs(i - peak1) > 30 && hist[i] > hist[peak2]) peak2 = i; } if (peak2 == 0) return 0; int minIdx = peak1 < peak2 ? peak1 : peak2; int maxIdx = peak1 > peak2 ? peak1 : peak2; for (int i = minIdx; i <= maxIdx; i++) { if (hist[i] < valley) valley = hist[i]; } // 如果谷底值小于两个峰值平均值的 1/3,认为双峰明显 return (valley < (hist[peak1] + hist[peak2]) / 6); }逻辑说明:先找两个峰值,再找它们之间的谷底,如果谷底足够低,说明黑白分明。参数说明:峰值间距阈值 30 是根据经验定的,你可以根据实际图像调整。如果返回 0,就触发补光增强或曝光调整。
还有一个技巧是 ROI 裁剪。如果条码在图像中的位置相对固定,你可以只处理中间一块区域,比如 320x240,这样处理速度快一倍,而且背景干扰少。ROI 的坐标可以通过上位机配置,存到 H750 的 Flash 里,掉电不丢。
最后说一个我踩过的坑:OV5640 的镜头焦距。很多模块默认是泛焦,近距离扫码时图像是糊的。你得手动拧镜头,边拧边看串口输出的识别率,找到最清晰的位置。这个调整很费时间,但调好之后,识别率会有明显提升。我一般会在镜头上点一滴 UV 胶固定,免得振动跑焦。
希望帮到你。
本文还有配套的精品资源,点击获取