利用 STM32 DCMI 与 DMA 双缓冲实现无 CPU 干预的 OV2640 图像采集

引言

在嵌入式视觉系统中,图像采集的实时性往往决定整体性能。传统方式下,CPU 需要频繁响应中断并搬运数据,导致大量开销。STM32 的 DCMI(Digital Camera Interface)配合 DMA 的双缓冲模式,可让图像数据直接由外设搬运至内存,全程无需 CPU 干预,极大提升系统效率。本文将以 OV2640 摄像头为例,详细讲解实现方案。

原理概述

DCMI 接口

DCMI 是 STM32 系列(如 F4、F7、H7 等)提供的并行摄像头接口,支持 8/10/12/14 位数据宽度,可接收来自摄像头的同步信号(VSYNC、HSYNC、PIXCLK)和数据字节。它支持两种捕获模式:快照(单帧)和连续(视频流)。在连续模式下,DCMI 持续接收数据,并通过 DMA 将数据搬运至内存。

DMA 双缓冲

DMA 双缓冲(Double Buffer)模式允许 DMA 在两个内存缓冲区之间交替传输数据。当 DMA 正在填充缓冲区 A 时,CPU 可以处理缓冲区 B 中的数据;反之亦然。这样,CPU 无需等待 DMA 完成,也无需在每次传输后重新配置 DMA,实现真正的零干预。

系统架构

  • OV2640:输出 JPEG 或 RGB565 数据,通过 DCMI 接口连接。
  • DCMI:接收数据并触发 DMA 请求。
  • DMA:在双缓冲模式下,将数据从 DCMI 数据寄存器搬运至内存。
  • CPU:仅在 DMA 传输完成中断时切换缓冲区,并处理已填充的数据。

硬件连接

| OV2640 引脚 | STM32 引脚 | 说明 | |------------|------------|------| | D0-D7 | PD0-PD7 | 数据线 | | PCLK | PA6 | 像素时钟 | | HSYNC | PA4 | 行同步 | | VSYNC | PA5 | 帧同步 | | SCL/SDA | PB6/PB7 | I2C 控制 | | XCLK | PA8 | 主时钟(由定时器产生) |

注意:具体引脚因芯片型号而异,请参考数据手册。

配置步骤

1. 初始化时钟和 GPIO

首先,使能 DCMI、DMA、GPIO 和 I2C 时钟,并配置相关引脚为复用功能。

void GPIO_Init(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    __HAL_RCC_DCMI_CLK_ENABLE();
    __HAL_RCC_DMA2_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOD_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();

    // 数据线 PD0-PD7
    GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 |
                          GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF13_DCMI;
    HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);

    // 控制线 PA4(HSYNC), PA5(VSYNC), PA6(PCLK)
    GPIO_InitStruct.Pin = GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6;
    GPIO_InitStruct.Alternate = GPIO_AF13_DCMI;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // I2C 引脚 PB6/PB7
    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}

2. 配置 DCMI

设置 DCMI 为连续模式,8 位数据宽度,并启用硬件同步。

void DCMI_Init(void) {
    DCMI_HandleTypeDef hdcmi;

    hdcmi.Instance = DCMI;
    hdcmi.Init.SynchroMode = DCMI_SYNCHRO_HARDWARE;  // 硬件同步
    hdcmi.Init.PCKPolarity = DCMI_PCKPOLARITY_RISING; // 像素时钟上升沿采样
    hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_LOW;      // VSYNC 低有效
    hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_LOW;      // HSYNC 低有效
    hdcmi.Init.ExtendedDataMode = DCMI_EXTEND_DATA_8B;
    hdcmi.Init.CaptureMode = DCMI_MODE_CONTINUOUS;    // 连续模式
    hdcmi.Init.ByteSelectMode = DCMI_BSM_ALL;
    hdcmi.Init.LineSelectMode = DCMI_LSM_ALL;
    hdcmi.Init.HalfLine = DCMI_HALFLINE_DISABLE;

    HAL_DCMI_Init(&hdcmi);
}

3. 配置 DMA 双缓冲

使用 DMA2 Stream1(DCMI 专用),设置双缓冲模式,内存地址为两个缓冲区。

#define BUFFER_SIZE  (320 * 240 * 2)  // 假设 RGB565 格式,320x240

uint8_t bufferA[BUFFER_SIZE];
uint8_t bufferB[BUFFER_SIZE];

void DMA_Init(void) {
    DMA_HandleTypeDef hdma;

    hdma.Instance = DMA2_Stream1;
    hdma.Init.Channel = DMA_CHANNEL_1;
    hdma.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma.Init.MemInc = DMA_MINC_ENABLE;
    hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;
    hdma.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;
    hdma.Init.Mode = DMA_CIRCULAR;  // 循环模式,配合双缓冲
    hdma.Init.Priority = DMA_PRIORITY_HIGH;
    hdma.Init.FIFOMode = DMA_FIFOMODE_DISABLE;

    HAL_DMA_Init(&hdma);

    // 关联 DMA 到 DCMI
    __HAL_LINKDMA(&hdcmi, DMA_Handle, hdma);

    // 配置双缓冲
    HAL_DMAEx_MultiBufferStart(&hdma, (uint32_t)&DCMI->DR, (uint32_t)bufferA, (uint32_t)bufferB, BUFFER_SIZE);
}

4. 启动捕获

在配置完 OV2640 后,启动 DCMI 捕获。

HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_CONTINUOUS, (uint32_t)bufferA, BUFFER_SIZE);

注意:HAL_DCMI_Start_DMA 内部会启动 DMA,但双缓冲模式需要额外调用 HAL_DMAEx_MultiBufferStart 或使用 HAL 库的 HAL_DCMI_Start_DMA 配合 HAL_DMAEx_ChangeMemory 实现切换。推荐使用 HAL 库的 HAL_DCMI_Start_DMA,它内部支持双缓冲,但需在中断回调中切换内存。

完整代码示例

以下是一个简化的示例,展示了如何配置并使用双缓冲。

// 全局变量
DCMI_HandleTypeDef hdcmi;
DMA_HandleTypeDef hdma;
volatile uint8_t frame_ready = 0;
volatile uint8_t active_buffer = 0;

// 缓冲区定义
#define IMG_WIDTH  320
#define IMG_HEIGHT 240
#define IMG_SIZE   (IMG_WIDTH * IMG_HEIGHT * 2)  // RGB565

uint8_t bufferA[IMG_SIZE];
uint8_t bufferB[IMG_SIZE];

// 初始化函数
void DCMI_DMA_Init(void) {
    // 时钟使能
    __HAL_RCC_DCMI_CLK_ENABLE();
    __HAL_RCC_DMA2_CLK_ENABLE();

    // GPIO 配置(略,见上文)

    // DCMI 配置
    hdcmi.Instance = DCMI;
    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.ExtendedDataMode = DCMI_EXTEND_DATA_8B;
    hdcmi.Init.CaptureMode = DCMI_MODE_CONTINUOUS;
    hdcmi.Init.ByteSelectMode = DCMI_BSM_ALL;
    hdcmi.Init.LineSelectMode = DCMI_LSM_ALL;
    hdcmi.Init.HalfLine = DCMI_HALFLINE_DISABLE;
    HAL_DCMI_Init(&hdcmi);

    // DMA 配置
    hdma.Instance = DMA2_Stream1;
    hdma.Init.Channel = DMA_CHANNEL_1;
    hdma.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma.Init.MemInc = DMA_MINC_ENABLE;
    hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;
    hdma.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;
    hdma.Init.Mode = DMA_CIRCULAR;
    hdma.Init.Priority = DMA_PRIORITY_HIGH;
    HAL_DMA_Init(&hdma);

    // 连接 DMA 到 DCMI
    __HAL_LINKDMA(&hdcmi, DMA_Handle, hdma);

    // 启动双缓冲 DMA
    HAL_DMAEx_MultiBufferStart(&hdma, (uint32_t)&DCMI->DR, (uint32_t)bufferA, (uint32_t)bufferB, IMG_SIZE);

    // 启动 DCMI 捕获
    HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_CONTINUOUS, (uint32_t)bufferA, IMG_SIZE);

    // 使能 DMA 传输完成中断
    HAL_NVIC_EnableIRQ(DMA2_Stream1_IRQn);
    HAL_NVIC_SetPriority(DMA2_Stream1_IRQn, 0, 0);
}

// DMA 中断回调
void DMA2_Stream1_IRQHandler(void) {
    HAL_DMA_IRQHandler(&hdma);
}

void HAL_DMAEx_MultiBufferCpltCallback(DMA_HandleTypeDef *hdma) {
    // 当当前缓冲区填满时,切换缓冲区
    if (hdma->Instance == DMA2_Stream1) {
        // 切换内存地址
        if (active_buffer == 0) {
            HAL_DMAEx_ChangeMemory(hdma, (uint32_t)bufferB, MEMORY0);
            active_buffer = 1;
        } else {
            HAL_DMAEx_ChangeMemory(hdma, (uint32_t)bufferA, MEMORY0);
            active_buffer = 0;
        }
        frame_ready = 1;  // 通知主循环处理数据
    }
}

// 主循环
int main(void) {
    HAL_Init();
    SystemClock_Config();
    GPIO_Init();
    I2C_Init();
    OV2640_Init();  // 配置 OV2640 寄存器
    DCMI_DMA_Init();

    while (1) {
        if (frame_ready) {
            frame_ready = 0;
            // 处理当前缓冲区数据(例如图像处理、显示、传输)
            if (active_buffer == 1) {
                // bufferA 已填满,可处理
                process_image(bufferA);
            } else {
                // bufferB 已填满
                process_image(bufferB);
            }
        }
    }
}

注意事项

  • 缓冲区大小:必须与图像尺寸匹配,且建议按 4 字节对齐,以满足 DMA 要求。
  • 中断优先级:DMA 中断应设为较高优先级,避免数据覆盖。
  • 同步信号极性:务必根据 OV2640 数据手册配置 VSYNC/HSYNC 极性,否则采集图像会错乱。
  • DCMI 时钟:确保 PIXCLK 频率在 DCMI 允许范围内(通常不超过 25MHz)。
  • 双缓冲切换:在回调中切换内存时,要确保 DMA 已停止当前传输,否则可能出错。HAL 库的 HAL_DMAEx_ChangeMemory 会处理,但需注意时序。
  • 内存对齐:使用 __attribute__((aligned(4)))ALIGN_32BYTES 宏确保缓冲区对齐。
  • JPEG 格式:若 OV2640 输出 JPEG,缓冲区大小需根据最大帧大小预留,且 DCMI 需配置为可变长度模式(需额外处理)。

总结

通过 DCMI 与 DMA 双缓冲的配合,STM32 可以完全自主地接收摄像头数据,CPU 仅在缓冲区切换时被短暂唤醒,极大降低了负载。本文提供的代码框架可直接应用于实际项目,开发者可根据具体需求调整缓冲区大小、图像格式和处理逻辑。这种设计在机器视觉、智能监控等场景中具有显著优势。