简介:STM32F103与OV7725摄像头模块结合的图像采集与液晶显示例程,面向嵌入式开发者和电子爱好者,提供从摄像头初始化、图像数据捕获与传输、格式处理到屏幕显示的完整软件实现。资源共561个文件,以C语言源码和工程配置文件为主,同时包含编译生成的hex、axf、o等中间产物以及备份文件,压缩包整体约16.3MB,目录结构清晰,便于直接导入Keil工程学习或二次开发。已有59人学习浏览。例程中不仅配置了OV7725的工作模式、分辨率与帧率,还实现了LCD控制引脚初始化与像素映射等显示驱动逻辑,主循环负责协调图像采集、处理和刷新周期。附带的调试状态检查代码,有助于快速定位摄像头或显示模块的硬件问题,非常适合作为STM32图像应用开发的入门模板和项目起点。
1. OV7725 与 STM32F103:没有 DCMI 也能读摄像头的工程现实
第一次想在 F103 上挂 OV7725 的开发者,多半会被参考手册里的一行字劝退:STM32F103 没有 DCMI 数字摄像头接口。没有硬件接口不等于不能干,这套 BH-F103 例程就是把 OV7725 的 8 位并行数据线直接接到 GPIO,PCLK 触发 EXTI 中断,一字节一字节地把像素搬进内存,再用 FSMC 把 RGB565 数据刷到 TFT 液晶屏。工程里保留了 Keil MDK 的布局文件(.uvguix)和已编译的 OV7725.axf,说明发布者实际编译并跑通过,依赖的是标准外设库 V3.5,对刚接触图像采集的人来说是一个能完整复现的最小骨架。对想往老项目里塞拍照功能的工程师,也可以直接摘走 SCCB 驱动、PCLK 同步采样、FSMC 刷屏这三段链路。这篇就按这三条链路拆开讲。
2. SCCB 寄存器配置:把 OV7725 校准到 RGB565 输出
2.1 用 GPIO 模拟 SCCB,而不是硬件 I2C
OV7725 的配置通道叫 SCCB,两根线 SIO_C 和 SIO_D,时序和 I2C 大同小异,差别在停止条件和重复起始的相位上。F103 的硬件 I2C 在标准外设库时代口碑一般,errata 多,与其跟硬件外设较劲,不如直接在 GPIO 上用软件模拟,反正配置摄像头的速率也就是几百 kHz,CPU 完全扛得住。下面这段是例程里最核心的底层时序,SDA 必须配置为开漏并外接上拉,SCL 用推挽输出,这样才能在第 9 个时钟读出从机 ACK。
#define SCCB_GPIO GPIOB #define SCCB_SCL GPIO_Pin_10 #define SCCB_SDA GPIO_Pin_11 #define OV7725_WR 0x42 // 器件写地址(7 位地址 0x21 左移 1 位) #define OV7725_RD 0x43 static void sccb_delay(void) { delay_us(2); } static void sccb_start(void) { GPIO_SetBits(SCCB_GPIO, SCCB_SCL | SCCB_SDA); // 总线空闲 sccb_delay(); GPIO_ResetBits(SCCB_GPIO, SCCB_SDA); // SCL 高时 SDA 拉低,起始 sccb_delay(); GPIO_ResetBits(SCCB_GPIO, SCCB_SCL); } static void sccb_stop(void) { GPIO_ResetBits(SCCB_GPIO, SCCB_SDA); // SDA 先拉低 sccb_delay(); GPIO_SetBits(SCCB_GPIO, SCCB_SCL); // SCL 拉高 sccb_delay(); GPIO_SetBits(SCCB_GPIO, SCCB_SDA); // SCL 高时 SDA 拉高,停止 } static void sccb_tx_byte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) GPIO_SetBits(SCCB_GPIO, SCCB_SDA); else GPIO_ResetBits(SCCB_GPIO, SCCB_SDA); dat <<= 1; GPIO_SetBits(SCCB_GPIO, SCCB_SCL); // 数据在 SCL 高电平期间有效 sccb_delay(); GPIO_ResetBits(SCCB_GPIO, SCCB_SCL); sccb_delay(); } GPIO_SetBits(SCCB_GPIO, SCCB_SDA); // 释放 SDA,等从机 ACK GPIO_SetBits(SCCB_GPIO, SCCB_SCL); sccb_delay(); GPIO_ResetBits(SCCB_GPIO, SCCB_SCL); }这段代码里delay_us(2)对应 250kHz 左右的 SCCB 时钟,大部分模块在这个速率下都能稳定工作。如果线长超过 10cm,把延时提到 5us 更稳妥。sccb_tx_byte末尾的第九个时钟只负责读 ACK,不判断 ACK 值,因为后续写寄存器函数会忽略它,真正需要判断结果的是读 ID 环节。
2.2 读回传感器 ID,先验证物理链路
SCCB 的读操作比 I2C 多了一个重复起始过程:先发写地址和寄存器子地址,然后重新产生一次 START,再发读地址,把方向切过来。很多第一次调 OV7725 的人在这里卡住,直接把 I2C 的读时序套上去,结果 ID 永远读成 0xFF。正确写法如下。
uint8_t sccb_read_reg(uint8_t addr) { uint8_t dat = 0; sccb_start(); sccb_tx_byte(OV7725_WR); sccb_tx_byte(addr); // 写入寄存器子地址 sccb_start(); // 重复起始,切换为读方向 sccb_tx_byte(OV7725_RD); dat = sccb_rx_byte(); // 读 8 bit,主机发 NACK sccb_stop(); return dat; }sccb_rx_byte的实现与发送对称,每来一个 SCL 高电平把 SDA 电平移入变量即可,这里不贴完整代码。验证物理链路的方法是读 0x0A 和 0x0B 两个寄存器,正常应该分别返回 0x77 和 0x21,拼起来就是 PID=0x77、VER=0x21。如果读回 0xFF,优先检查 SDA 上拉电阻和电源;如果读回 0x00,重点查 SCL 是否虚焊。
2.3 初始化参数的取舍:关注格式,别烧脑抠每个寄存器
OV7725 的上电默认输出不一定是 RGB565,常见模块上电默认输出 YUV422,直接采数据会看到一片青绿色画面。初始化要做的事就是把输出格式切到 RGB565,并确认分辨率在 VGA 还是 QVGA。工程中常见的关键寄存器如下表。
| 寄存器 | 名称 | 配置/调试要点 |
|---|---|---|
| 0x0A / 0x0B | PID / VER | 只读,读回 0x77 / 0x21 |
| 0x12 | COM7 | 输出格式与分辨率,RGB 输出关键位 |
| 0x11 | CLKRC | PCLK 分频,F103 直读建议降频 |
| 0x1E | MVFP | 画面镜像/翻转,装反时调这里 |
| 0x27 ~ 0x2A | 窗口裁剪 | 输出 QVGA 时调整行场起始与尺寸 |
| 0x0D | COM4 | PCLK 分频及 PLL 配置,按模块手册改 |
下面是一段最精简的初始化函数。工程完整版里通常还有几百行寄存器配置,但核心思路就这几条。
uint8_t OV7725_Init(void) { uint16_t pid; pid = ((uint16_t)sccb_read_reg(0x0A) << 8) | sccb_read_reg(0x0B); if (pid != 0x7721) return 1; // SCCB 链路不通 sccb_write_reg(0x12, 0x80); // COM7:先软复位传感器寄存器 delay_ms(10); sccb_write_reg(0x12, 0x06); // COM7:VGA 分辨率,RGB 输出 sccb_write_reg(0x11, 0x00); // CLKRC:外部时钟直通 sccb_write_reg(0x1E, 0x00); // MVFP:不镜像不翻转 sccb_write_reg(0x15, 0x02); // COM10:PCLK 输出使能等,按实际模块调 return pid == 0x7721 ? 0 : 1; }0x12的 bit2 置 1 表示 RGB 输出,bit0 置 0 表示 RGB565 而不是 RGB555,这也是为什么常见值落在 0x06 附近。如果画面整体偏色严重,先怀疑这里;如果画面上下颠倒,就动 0x1E 的 bit7;左右镜像,动 0x1E 的 bit6。寄存器配置到这里已经够进下一步了,剩下那些曝光、增益、白平衡参数等图像稳定后再调。
3. PCLK 中断 + GPIO 直读:图像数据从摄像头到内存
3.1 三种读取路线的选型对照
OV7725 的像素数据是并行输出的,常见接法有三种,对应不同硬件投入和 CPU 开销。
| 方案 | 硬件改动 | CPU 占用 | 适用场景 |
|---|---|---|---|
| 带 AL422B FIFO 模块 | 模块自带 FIFO,F103 走 FSMC 读 | 低 | 产品化最稳,但模块贵 |
| PCLK 接 EXTI 中断直读 | 无需额外硬件 | 高 | 本套例程采用,教学清晰 |
| PCLK 接定时器触发 DMA | 接线不变,代码复杂 | 中 | 帧率要求高,需处理行场同步 |
F103 没有 DCMI 接口,所以例程走的是第二种路线:PCLK 每来一个上升沿就触发一次外部中断,在中断里把 GPIO 口上的 8 位数据取走。这个方案最直观,也最容易排查问题,代价是 CPU 在图像采集期间被中断占满,主循环里做不了重活。对这套“软件实验例程”来说,这个取舍是对的。
3.2 接线布局与帧缓冲定义
典型的 F103 接线方式是:OV7725 的 D0-D7 接 PC0-PC7,PCLK 接 PA8 并映射到 EXTI8,VSYNC 接 PA4 映射到 EXTI4,HREF 接 PA5 用普通 GPIO 查询。PC 口的优势是GPIO_ReadInputData(GPIOC)一次能读回全部 16 位,D0-D7 在低 8 位,取出低字节就是当前像素字节。
#define IMG_W 320 #define IMG_H 240 static uint16_t frame[IMG_H][IMG_W]; // QVGA RGB565 帧缓冲 static volatile uint16_t row, col; static volatile uint8_t pixel_low; // 凑像素时暂存第一个字节 static volatile uint8_t first_byte = 1; static volatile uint8_t frame_ready = 0;QVGA 一帧 320x240 像素、每像素 2 字节,共 153.6KB。F103 的 SRAM 通常只有 20KB 到 64KB,这张大数组已经占掉了可用内存的大半,实际工程里如果芯片是 64KB 版本还能放两帧做乒乓缓冲,20KB 版本只能压缩到 160x120 或者按行处理。
3.3 EXTI 中断里逐字节凑像素
OV7725 输出 RGB565 时,一个像素要分两个 PCLK 周期送出:第一个字节是低位部分,第二个字节是高位部分,合在一起才是 16 位 RGB565。代码里用first_byte做状态标记,配合 HREF 用查询方式判断当前是否在行有效区间。
void EXTI9_5_IRQHandler(void) { uint8_t d; if (EXTI_GetITStatus(EXTI_Line8) == RESET) return; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5)) { // HREF 为高,行有效 d = GPIO_ReadInputData(GPIOC) & 0xFF; // 取 D0-D7 if (first_byte) { pixel_low = d; // 先到的字节存起来 first_byte = 0; } else { uint16_t rgb = ((uint16_t)d << 8) | pixel_low; if (col < IMG_W) { frame[row][col] = rgb; col++; if (col == IMG_W) { col = 0; if (row < IMG_H) row++; } } first_byte = 1; } } EXTI_ClearITPendingBit(EXTI_Line8); }这里的高低字节顺序是按“先低位、后高位”写的,实际取决于模块 PCB 上 D0-D7 与 F103 的接法,如果画面出现每像素颜色错位、像噪点但轮廓清晰,把pixel_low和d的位置换一下即可。中断里加了边界保护,row到达IMG_H后不再写内存,防止数组越界导致硬件错误。
3.4 VSYNC 帧同步与 FreeRTOS 共享缓冲
光有 PCLK 中断还不够,必须在 VSYNC 到来时把行号和列号清零,否则上一帧和这一帧的数据会拼在一起。
void EXTI4_IRQHandler(void) // VSYNC 接 PA4 { if (EXTI_GetITStatus(EXTI_Line4) != RESET) { row = 0; col = 0; first_byte = 1; frame_ready = 0; EXTI_ClearITPendingBit(EXTI_Line4); } }frame_ready在 PCLK 中断里当row走完一整帧位置 1,主循环看到标志位后把帧数据交给 LCD,然后清标志。如果例程里要跑 FreeRTOS,这张frame数组会被中断写、任务读,必须加临界区保护或者改成双缓冲,否则 LCD 刷新到一半时摄像头又写入新数据,画面会出现撕裂。这也是移植 FreeRTOS 到 F103 图像采集场景时最常踩的坑。
4. FSMC 驱动 TFT-LCD 并口屏:像素流从数组到屏幕
4.1 一个地址对应一条控制线
图像数据进了内存只是第一步,把它显示出来用的是 FSMC。F103 的 FSMC 可以把外部并口设备映射到内部地址空间,对开发者的意义是:往某个地址写数据,硬件自动产生片选、读、写时序。TFT-LCD 通常挂在 Bank1 的 NE1 片选上,地址线中取一根(常见 A16)接 LCD 的 RS 引脚,RS 为 0 时写寄存器、RS 为 1 时写显存。
#define LCD_REG (*(volatile uint16_t *)0x60000000) // RS=0,命令/寄存器 #define LCD_RAM (*(volatile uint16_t *)0x60010000) // RS=1,显存数据这两个宏就是整个显示驱动的地基。0x60010000是在0x60000000基础上把 A16 置 1 得到的,如果板子把 RS 接到了 A15 或 A0,对应地址也要跟着改。用 FSMC 刷屏比 GPIO 模拟快一个数量级以上,因为每次写显存就是一条汇编 store 指令,CPU 不需要边翻转控制线边等延时。
4.2 FSMC 时序参数怎么配
FSMC 的时序参数以 HCLK 为基本单位,F103 主频 72MHz 时一个 HCLK 约 13.9ns。下面是一段适用于多数并口 TFT-LCD 的配置,SRAM 模式,16 位数据宽度。
static void FSMC_LCD_Init(void) { FSMC_NORSRAMInitTypeDef fsmc; FSMC_NORSRAMTimingInitTypeDef timing; timing.FSMC_AddressSetupTime = 1; // 地址建立时间,1 个 HCLK timing.FSMC_AddressHoldTime = 0; timing.FSMC_DataSetupTime = 3; // 数据建立时间,3 个 HCLK 约 42ns timing.FSMC_BusTurnAroundDuration = 0; timing.FSMC_CLKDivision = 0; timing.FSMC_DataLatency = 0; timing.FSMC_AccessMode = FSMC_AccessMode_A; fsmc.FSMC_Bank = FSMC_Bank1_NORSRAM1; fsmc.FSMC_MemoryType = FSMC_MemoryType_SRAM; fsmc.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b; fsmc.FSMC_WriteOperation = FSMC_WriteOperation_Enable; fsmc.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable; fsmc.FSMC_ReadTiming = &timing; fsmc.FSMC_WriteTiming = &timing; FSMC_NORSRAMInit(&fsmc); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE); }DataSetupTime是最关键的参数,它决定了一次访问周期里数据保持时间。多数 ILI9341、ILI9325 并口屏要求写信号低电平脉宽不小于 20ns 到 50ns,这里 3 个 HCLK 约 42ns 基本够用。如果屏上出现雪花点或者局部花屏,优先把DataSetupTime加到 8,代价是每像素写操作慢约 70ns,对 320x240 的刷新率影响不明显。
4.3 窗口化刷新与扫描方向
LCD 初始化完成后,刷一帧图像的正确姿势是先把显示窗口设到目标区域,然后连续向显存写数据。多数 F103 例程基于 ILI9341,指令是 0x2A 设置列范围、0x2B 设置行范围、0x2C 写显存。
static void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); // 准备写 GRAM } void LCD_ShowRGB565(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t *img) { uint32_t i; LCD_SetWindow(x0, y0, x0 + w - 1, y0 + h - 1); for (i = 0; i < (uint32_t)w * h; i++) { LCD_RAM = img[i]; // 连续写,GRAM 地址自动递增 } LCD_SetWindow(0, 0, 239, 319); // 恢复全屏窗口 }这段代码最后的恢复全屏窗口很重要,否则后续画点、画线函数会以为窗口还停在图像区域。像素方向问题则出在 ILI9341 的 MADCTL 寄存器,地址 0x36,其中 bit7 控制行方向、bit6 控制列方向、bit5 控制行列互换、bit3 控制 BGR 颜色顺序。摄像头一帧数据默认从左上角开始,如果画面上宽下窄或者左右颠倒,去改这个寄存器,不要对像素数组做矩阵转置,那会白白消耗 CPU。
| 0x36 位 | 功能 | 调试现象 |
|---|---|---|
| bit7 MY | 行方向 | 置 1 时上下翻转 |
| bit6 MX | 列方向 | 置 1 时左右翻转 |
| bit5 MV | 行列互换 | 屏幕旋转 90 度时用 |
| bit3 BGR | 通道交换 | 颜色偏红/偏蓝时切换 |
5. 帧错位、BMP 存图与几个现场坑
5.1 帧同步错行:用 HREF 上升沿校准行计数
VSYNC 中断清零行列号的做法在多数情况下够用,但 OV7725 每帧开头会有若干行消隐数据,如果 VSYNC 和 PCLK 的相位没对齐,画面上半部分会出现错行条纹。我一般会在 PCLK 中断里额外检测 HREF 的上升沿,一旦发现当前行的列号不为零就强制对齐。
uint8_t href_now = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5); uint8_t href_prev = 0; if (href_now && !href_prev && col != 0) { col = 0; // 行提前结束,下一行重新计数 if (row < IMG_H) row++; } href_prev = href_now;这段逻辑放在 PCLK 中断的最前面,代价是每次中断多执行几条比较指令,换来的是画面稳定。对 F103 直读摄像头来说,这种“丢尾巴”的容错比追求每一行像素数绝对精确更实际。
5.2 拍照存图:先把 RGB565 转成 BMP24
例程如果只显示不存图,价值少一半。工程目录里能看到 cc936.c、cc949.c、cc950.c、cc932.c 这几个文件,它们是 FATFS 文件系统的代码页支持,说明固件里预留了带中文长文件名的 SD 卡写文件功能。存图前要做一步格式转换:把 OV7725 的 RGB565 展开成 BMP 的 24 位 BGR 格式。
void RGB565_To_BMP24(uint16_t *rgb565, uint8_t *bmp, uint32_t n) { while (n--) { uint16_t p = *rgb565++; uint8_t r = (p >> 11) & 0x1F; uint8_t g = (p >> 5) & 0x3F; uint8_t b = p & 0x1F; r = (r << 3) | (r >> 2); // 5 位扩展到 8 位 g = (g << 2) | (g >> 4); // 6 位扩展到 8 位 b = (b << 3) | (b >> 2); *bmp++ = b; // BMP 文件按 BGR 存储 *bmp++ = g; *bmp++ = r; } }转换后的数据写给 FATFS 时,注意 BMP 文件的行序是从下往上,也就是图像最后一行要写在文件前面,需要把转换函数改成按行倒序处理,否则存出来的图片上下颠倒。这也是把 OV7725 接到 F103 上做拍照功能时最容易忽略的细节。
5.3 串口调试、下载失败与 ESD 经验
调试这套系统时,串口打印是唯一靠谱的观察窗口。注意 USART1 挂在 APB2 上,时钟 72MHz,USART3 挂在 APB1 上,时钟 36MHz,同样配置 115200 波特率,USART3 的分频误差更大,打印高频日志时容易丢字符。用 USART1 做日志输出会省很多事。下载失败先看 BOOT0 和 BOOT1 跳线,BOOT0 拉高时会进入系统存储器,DAP 连接再稳定也烧不进程序。
最后说一个现场经验:摄像头和 LCD 的 FPC 排线频繁插拔时,静电打坏 PCLK 引脚的概率远高于数据线。PCLK 上串 33Ω 电阻,并在模块端对地并一个 10pF 电容,能有效抑制尖峰;量产设计时在 PCLK 和 VSYNC 上加 TVS 管,比事后换片子便宜得多。花屏先排除 ESD 损伤,再怀疑时序,这个顺序能省掉一整天的排查时间。
本文还有配套的精品资源,点击获取