4、摄像头驱动与数据采集:OV2640/OV7670驱动原理、DCMI接口配置、DMA传输,获取第一帧图像

好,咱们进入第四章。这一章可以说是整个课程的分水岭——前面讲理论,从这章开始,你就要真正跟硬件打交道了。

摄像头驱动,说白了就是让MCU和摄像头“说上话”。我刚开始做这个的时候,以为接上线就能出图像,结果折腾了两天才发现是初始化顺序搞反了。嗯,咱们今天就把这些坑一个个填平。

4.1 OV2640与OV7670:选谁?怎么驱动?

先说说这两款经典的摄像头模组。OV7670是30万像素的老将,OV2640是200万像素的升级版。我个人习惯,如果只是做简单的颜色识别、二维码扫描,OV7670完全够用,还便宜。但如果你要做人脸检测或者稍微复杂点的视觉任务,OV2640是底线。

它们的驱动原理其实是一样的,都是通过SCCB总线(跟I2C几乎一样)来配置内部寄存器。你想想看,摄像头就像一台相机,你要告诉它:输出多大分辨率、用什么格式、帧率多少。这些信息就写在寄存器里。

核心要点:驱动摄像头 = 通过SCCB写寄存器 + 通过DCMI读像素数据

我曾经在一个项目中,OV7670死活不出图像,后来发现是寄存器0x12(输出格式控制)没配置对。默认输出的是RGB565,而我的DCMI配置成了YUV。这种低级错误,排查起来真的很痛苦。

4.1.1 SCCB初始化流程

初始化流程其实不复杂,但顺序很重要。我总结了一个“三步走”策略:

  1. 复位摄像头:拉低RESET引脚至少10ms,再拉高。这一步是让摄像头回到已知状态。
  2. 配置时钟:OV7670需要24MHz的XCLK输入,OV2640可以到25MHz。注意,STM32的MCO引脚可以直接输出这个时钟。
  3. 写入寄存器序列:这是最关键的。每个摄像头都有官方推荐的初始化序列,几百个寄存器要按顺序写。

我的小技巧:不要自己手写寄存器配置。去官网下载初始化数组,直接复制到代码里。我见过太多人因为写错一个寄存器值,图像颜色全乱的。

下面是一个简化的OV7670初始化代码片段:

// SCCB写寄存器函数
uint8_t SCCB_WriteReg(uint8_t reg, uint8_t value) {
    // 启动I2C通信
    I2C_Start();
    // 发送设备地址(写)
    I2C_SendByte(0x42);
    // 等待应答
    if (!I2C_WaitAck()) return 1;
    // 发送寄存器地址
    I2C_SendByte(reg);
    I2C_WaitAck();
    // 发送数据
    I2C_SendByte(value);
    I2C_WaitAck();
    // 停止通信
    I2C_Stop();
    return 0;
}

// 初始化OV7670(QVGA格式,RGB565)
void OV7670_Init(void) {
    // 复位
    HAL_GPIO_WritePin(RESET_GPIO_Port, RESET_Pin, GPIO_PIN_RESET);
    HAL_Delay(10);
    HAL_GPIO_WritePin(RESET_GPIO_Port, RESET_Pin, GPIO_PIN_SET);
    HAL_Delay(10);
    
    // 写入初始化序列
    for (int i = 0; ov7670_init_regs[i][0] != 0xFF; i++) {
        SCCB_WriteReg(ov7670_init_regs[i][0], ov7670_init_regs[i][1]);
    }
}

4.2 DCMI接口配置:让数据流进来

摄像头配置好了,它就开始往外吐像素数据了。但MCU怎么接住这些数据?靠的就是DCMI——STM32的数字摄像头接口。

DCMI说白了就是一个并行数据接收器。它有几个关键信号:

信号名 作用 说明
DCMI_D[0:7] 数据线 8位并行数据,每个PCLK时钟传一个字节
DCMI_PCLK 像素时钟 由摄像头提供,每个上升沿采集数据
DCMI_HSYNC 行同步 高电平表示一行数据正在传输
DCMI_VSYNC 帧同步 高电平表示一帧数据开始/结束

配置DCMI的时候,有几点要特别注意:

  • 数据格式要匹配:摄像头输出RGB565,DCMI就要配置成RGB565模式。否则数据解析出来全是乱的。
  • 同步极性要正确:OV7670的VSYNC是高电平有效,但有些摄像头是低电平有效。配置反了,DCMI永远收不到帧开始信号。
  • 时钟边沿选择:我建议用PCLK的上升沿采集数据。下降沿采集容易受信号抖动影响。

注意:DCMI的时钟频率不能太高。STM32F4系列最高支持到54MHz,但实际使用中,超过30MHz就容易丢数据。我一般控制在20MHz左右,稳定第一。

4.3 DMA传输:别让CPU干苦力

好,现在DCMI能收到数据了。但问题来了——一帧QVGA(320x240)的RGB565图像,数据量是320×240×2 = 153,600字节。如果让CPU一个一个字节去读,那CPU啥也别干了,光搬数据就累死。

这时候DMA就该上场了。DMA说白了就是一个“数据搬运工”,它可以在不占用CPU的情况下,把DCMI收到的数据直接搬到内存里。

配置DMA时,我习惯用双缓冲模式。为什么?

你想想看,如果只有一个缓冲区,DMA正在往里面写数据,CPU想处理上一帧图像,就得等DMA写完。这一等,帧率就掉下来了。

双缓冲就不一样了:DMA往缓冲区A写第N帧,CPU同时处理缓冲区B里的第N-1帧。写完了,DMA自动切到缓冲区B,CPU切到缓冲区A。这样流水线作业,帧率能翻倍。

// DMA双缓冲配置示例
// 定义两个缓冲区
uint8_t frame_buffer_1[320 * 240 * 2];  // 缓冲区A
uint8_t frame_buffer_2[320 * 240 * 2];  // 缓冲区B

// 配置DMA
void DMA_Config(void) {
    // 使能DMA2时钟
    __HAL_RCC_DMA2_CLK_ENABLE();
    
    // 配置DMA流
    hdma_dcmi.Instance = DMA2_Stream1;
    hdma_dcmi.Init.Channel = DMA_CHANNEL_1;
    hdma_dcmi.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_dcmi.Init.PeriphInc = DMA_PINC_DISABLE;  // 外设地址不递增
    hdma_dcmi.Init.MemInc = DMA_MINC_ENABLE;       // 内存地址递增
    hdma_dcmi.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;
    hdma_dcmi.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;
    hdma_dcmi.Init.Mode = DMA_CIRCULAR;            // 循环模式
    hdma_dcmi.Init.Priority = DMA_PRIORITY_HIGH;
    
    HAL_DMA_Init(&hdma_dcmi);
    
    // 关联DMA到DCMI
    __HAL_LINKDMA(&hdma_dcmi, hdma_dcmi, hdma_dcmi);
    
    // 启动DMA双缓冲传输
    HAL_DMAEx_ChangeMemory(&hdma_dcmi, 0, (uint32_t)frame_buffer_1, DMA_MEMORY_0);
    HAL_DMAEx_ChangeMemory(&hdma_dcmi, 1, (uint32_t)frame_buffer_2, DMA_MEMORY_1);
}

4.4 获取第一帧图像:从零到一

好了,所有配置都完成了。现在我们来获取第一帧图像。这一步其实很简单,但第一次看到图像出现在屏幕上时,那种成就感真的很棒。

流程是这样的:

  1. 启动DCMI捕获
  2. 等待VSYNC中断(表示一帧开始)
  3. DMA自动把数据搬到缓冲区
  4. 等待传输完成中断(表示一帧结束)
  5. 处理缓冲区中的图像数据
// 启动捕获
void DCMI_StartCapture(void) {
    // 使能DCMI
    HAL_DCMI_Start_DMA(&hdcmi, DMA_MODE_CONTINUOUS, 
                       (uint32_t)frame_buffer_1, 
                       (320 * 240 * 2) / 4);  // 传输的字数
}

// 帧传输完成回调
void HAL_DCMI_FrameEventCallback(DCMI_HandleTypeDef *hdcmi) {
    // 切换缓冲区指针
    current_buffer = (current_buffer == frame_buffer_1) ? 
                     frame_buffer_2 : frame_buffer_1;
    
    // 处理图像(比如显示到LCD)
    ProcessImage(current_buffer);
}

避坑指南:我第一次调试时,图像一直出不来。后来发现是DMA传输的字数算错了。DCMI的DMA传输是以字(4字节)为单位的,所以传输字数 = 总字节数 / 4。对于QVGA RGB565,就是153600 / 4 = 38400个字。这个坑,我替你们踩过了。

4.5 实战经验总结

最后,分享几个我在项目中积累的经验:

  • 先测SCCB通信:不要一上来就搞DCMI。先用逻辑分析仪抓SCCB波形,确认能正确读写寄存器。这一步能排除一半的问题。
  • 用示波器看PCLK:如果DCMI收不到数据,先看PCLK有没有波形。没有波形,说明摄像头没工作,检查电源和复位。
  • 第一帧可能不完整:DMA启动后,第一帧数据可能只有半帧。我习惯丢弃第一帧,从第二帧开始处理。
  • 注意内存对齐:DMA缓冲区要4字节对齐。用__attribute__((aligned(4)))声明,否则DMA会报错。

嗯,这一章的内容就到这里。下一章我们会讲图像预处理——拿到原始图像数据后,怎么把它变成算法能用的格式。到时候见。