本次教程将使用赤菟开发板上的DVP接口和LCD模块,读取外接摄像头OV2640的视频流并通过LCD显示。
开发流程将会分为以下部分讲解:
- DVP接口的使用
- LCD的数据传输(DMA)
- 摄像头的寄存器配置
- 系统初始化流程
1 DVP接口的使用
开发板上的数字视频接口(Digital Video Port,DVP)位于赤菟开发板右侧TF卡插槽的下方,能够兼容OV2640、OV5640等多种使用DVP接口的摄像头,同时芯片也内置了DVP数据采集模块,用于视频或图像数据的接收。本教程将会以OV2640为例,但是内容同样适用于其他兼容的型号。
沁恒微电子及MRS IDE提供了DVP接口的API,因此直接调用相关函数与功能进行开发即可。沁恒同时也在CH32V307EVT中提供了DVP接口的使用例程,因此教程的部分代码也是基于DVP_TFTLCD这一例程修改而来的。
1.1 DVP接口定义
MCU和摄像头之间的线路连接如上图所示:PCLK(Pixel clock)是摄像头在传输数据时使用的时钟信号,每个时钟对应一个非压缩的像素数据;HSYNC(Horizonal synchronization)和VSYNC(Vertical synchronization)分别是数据输出时的行同步和场同步信号;DATA包含摄像头输出的数据,CH32V307芯片可以支持8/10/12bit位宽,不过开发板上只连接了低10位(D9-D0),因此12bit位宽不可用;XCLK(External clock)是摄像头的外部参考时钟,可以由微控制器提供或外部提供,开发板上使用了一个8MHz晶振作为摄像头的外部时钟。
最后的SDA/SCL是Serial Camera Control Bus(SCCB)接口,其本质上就是一个I2C 接口,用于配置摄像头的寄存器,例程中使用了GPIO模拟I2C通讯。
需要注意的是,DVP接口的数据位不止一种连接方式,实际使用时需要根据摄像头的数据输出格式进行相应的选择。下图包含了四种常见的连接方式,其中赤菟开发板使用的是左上角的标准10bit连接,能够支持摄像头进行原始10bit RAW输出、压缩RGB565/YUV422输出、以及JPEG格式输出。
由于开发板板载的LCD同样支持RGB565格式的数据,本例程将会使用RGB565格式作为摄像头的输出格式,从而省略数据转换和图像调整的过程。RGB565格式中每个像素的数据占2字节,通过摄像头上的Y9-Y2,分2个PCLK时钟周期输出。RGB565的数据格式如下图所示。
尽管有效数据位宽仅为8位,但是由于最高位占据的是Y9,因此MCU的DVP接口应当工作于10bit模式(D9-D0)而非8bit模式(D7-D0),在接收数据后再额外进行移位操作,将摄像头输出的D9-D2数据转换为LCD所需的D7-D0。
1.2 DVP接口的驱动代码
由于视频信号的数据量较大,因此直接使用GPIO来进行读取并不是一个合适的选择。CH32V307芯片提供了专门的硬件DVP接口,因此使用这一专用接口接收数据,并通过DMA(Direct Memory Access,直接存储器访问控制)总线将数据存入RAM会是一个更好的选择,这样视频数据在接收过程中完全不需要经过CPU的处理,能够有效避免大量的IO操作占用CPU的工作时间。
在使用DVP之前,首先需要打开DVP模块的时钟信号,并清除DVP配置寄存器CR0:
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DVP, ENABLE);
DVP->CR0 &= ~RB_DVP_MSK_DAT_MOD;
随后在配置寄存器CR0设置DVP的工作模式(10bit位宽)、同步信号极性(OV2640使用低有效的VSYNC信号),在配置寄存器CR0清除DVP缓存和标志位,然后设置DVP视频信号的长宽(需要注意的是,由于每个像素的数据占据2时钟周期,因此列数COL_NUM应当为实际分辨率的2倍,即在QVGA(320*240)模式下,ROW_NUM=240,COL_NUM=640)。
DVP->CR0 |= RB_DVP_D10_MOD | RB_DVP_V_POLAR;
DVP->CR1 &= ~((RB_DVP_ALL_CLR) | RB_DVP_RCV_CLR);
DVP->ROW_NUM = RGB565_ROW_NUM; // rows
DVP->COL_NUM = RGB565_COL_NUM; // cols
由于摄像头的视频比例是4:3(传感器分辨率1600x1200,经内置DSP缩放至320x240),而LCD的窗口比例是1:1(240x240),因此采集到的视频信号还需要经过裁切,否则显示画面会发生畸变或错位(通过摄像头自带的DSP进行非等比缩放会导致画面被横向压缩产生畸变,而直接将320宽的数据输入LCD则会导致显示错位)。DVP的裁切功能如下:
据此对DVP裁切位置的寄存器进行配置,并在CR1中启用裁切功能。其中RGB565_COL_NUM为完整视频信号的分辨率,而ROI_WIDTH则是裁切后的窗口大小,同样需要在实际分辨率的基础上乘以2。
DVP->HOFFCNT = (RGB565_COL_NUM - ROI_WIDTH) / 2;
DVP->VST = (RGB565_ROW_NUM - ROI_HEIGTH) / 2;
DVP->CAPCNT = ROI_WIDTH;
DVP->VLINE = ROI_HEIGTH;
DVP->CR1 |= RB_DVP_CROP;
接下来设置DVP帧捕获率,此处设为100%,即捕获收到的所有帧。如果LCD的刷新率跟不上摄像头刷新率,或需要对视频信号进行进一步处理的话,这里也可以将捕获率设为50%或25%,或者直接降低摄像头的数据速率(在后文摄像头寄存器配置部分说明),确保有足够的时间处理数据。
DVP->CR1 &= ~RB_DVP_FCRC;
DVP->CR1 |= DVP_RATE_100P; // 100%
然后配置DMA的目标地址,在DVP模块收到视频数据后,数据会通过DMA通道直接送往RAM当中的指定地址。此处需要设定两个DMA目标地址,两块区域轮流使用,每个区域对应视频信号中的一行,确保在任意时刻有且只有一个区域被写入,以便程序读取另一区域的数据。
DVP->DMA_BUF0 = RGB565_DVPDMAaddr0; // DMA addr0
DVP->DMA_BUF1 = RGB565_DVPDMAaddr1; // DMA addr1
DMA目标地址设置如下。需要注意的是,由于DVP工作在10bit模式下,因此每个时钟周期的10bit数据需要占据2字节,即每个像素的数据需要占用4字节,所以这里每个地址区间的长度需要在RGB565_COL_NUM=640的基础上再乘以2。
DVP->IER |= RB_DVP_IE_STP_FRM;
DVP->IER |= RB_DVP_IE_FIFO_OV;
DVP->IER |= RB_DVP_IE_FRM_DONE;
DVP->IER |= RB_DVP_IE_ROW_DONE;
DVP->IER |= RB_DVP_IE_STR_FRM;
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = DVP_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
最后通过CR0和CR1寄存器启用DVP模块即可:
DVP->CR1 |= RB_DVP_DMA_EN; // enable DMA
DVP->CR0 |= RB_DVP_ENABLE; // enable DVP
2 LCD的数据传输(DMA)
LCD的配置以及初始化流程可以参考《用赤菟玩贪吃蛇》或《碎屏模拟器》,因此不再赘述,只需按照流程将LCD进行初始化即可,不需要使用其中的绘图函数。
不过由于需要在外部调用LCD的数据端口地址,因此以下的两行宏定义需要剪切到LCD的.h文件当中
#define LCD_CMD ((u32)0x6001FFFF)
#define LCD_DATA ((u32)0x60020000)
由于视频信号的数据量大,因此LCD的显示信号同样可以通过DMA进行传输,避免大量的IO操作占用CPU时间。
开发板上的LCD模块通过FSMC(Flexible Static Memory Controller,可变静态存储控制器)接口进行连接,FSMC可将外设模拟为一个或数个内存地址,只需要对特定的内存地址写入数据,就能够将命令或数据发送至外部设备。因此,我们只需要通过DMA通道将数据写到外设地址中,就能够在无需CPU操作的情况下将视频数据发送至LCD进行显示。
此处DMA使用了DMA2的通道5,配置流程同样是从打开DMA的时钟信号,并清除配置寄存器开始:
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA2, ENABLE);
DMA_DeInit(DMA2_Channel5);
然后对DMA的参数寄存器进行配置:
DMA_InitTypeDef DMA_InitStructure = {0};
DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)LCD_DATA;
DMA_InitStructure.DMA_MemoryBaseAddr = ddr;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST;
DMA_InitStructure.DMA_BufferSize = LCD_W;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;
DMA_Init(DMA2_Channel5, &DMA_InitStructure);
其中,PeripheralBaseAddr为LCD的数据地址;MemoryBaseAddr为视频数据地址(即DVP接口的DMA目标地址,此处也可以不设置,因为后续在发送数据前还需要再次选择使用Buff0还是Buff1,数据长度BufferSize一项同理);DIR为数据传输方向,此处LCD作为外设,应将外设侧设置为数据的目标地址。
由于LCD的数据地址为固定地址,而视频数据的存储区域为一个区间,因此外设地址自增PeripheralInc应设置为Disable,而内存地址自增MemoryInc应设置为Enable,这样DMA就能够自动将一个区间内的视频数据依序发送至固定的LCD数据端口。
然后将每位数据的长度DataSize设置为half-word,即16bit,与LCD上RGB565格式中每像素占2字节对应。该选项会影响地址的自增量、数据对齐关系和数据长度BufferSize,因此这几项的设置必须对应。(例如在每行240像素、每像素2字节的情况下,Half-word对应BufferSize=240,而Byte就需要对应BufferSize=480,两种方式效果相同)
最后将DMA_Mode设为Normal模式,此时当DMA发送了BufferSize位的数据后会自动停止传输;由于源地址和目标地址都是内存地址,因此Memory2Memory选项需要设置为Enable。
接下来需要配置DVP中断的回调函数,在接收到行信号完成信号后设置DMA的源地址以及数据长度,并使能DMA通道触发数据传输。
在受到行信号完成中断后,首先清除中断信号:
if (DVP->IFR & RB_DVP_IF_ROW_DONE)
{
/* Write 0 clear 0 */
DVP->IFR &= ~RB_DVP_IF_ROW_DONE; // clear Interrupt
然后根据计数器选择Buff0和Buff1中的一个作为源地址MADDR,并设置单次传输的数据长度为LCD一行的分辨率。由于DataSize已经设为half-word,因此SetCurrDataCounter(即设置BufferSize)只需要填入实际的横向分辨率240即可。
if (addr_cnt % 2) // buf0 done
{
addr_cnt++;
// data shift
for (u16 i = 0; i < RGB565_COL_NUM * 2; ++i)
{
*(u8 *)(RGB565_DVPDMAaddr0 + i) = (u8)(*(u16 *)(RGB565_DVPDMAaddr0 + i * 2) >> 2);
}
// Send DVP data to LCD
DMA_Cmd(DMA2_Channel5, DISABLE);
DMA_SetCurrDataCounter(DMA2_Channel5, LCD_W);
DMA2_Channel5->MADDR = RGB565_DVPDMAaddr0;
DMA_Cmd(DMA2_Channel5, ENABLE);
}
由于摄像头输出的RGB565数据占据D9-D2,而LCD需要D7-D0的数据,因此在传输前需要对缓存中的数据统一向右移位2位。
注意在修改DMA配置之前,需要先停止DMA操作,防止上一行的数据还没有传输完成结果错位到下一行的情况。在配置修改结束后再启动DMA通道,将对应的数据发往LCD。
此外,在收到帧信号完成的中断时,应当清除行计数器,以免偶然错误被带到下一帧当中。
if (DVP->IFR & RB_DVP_IF_FRM_DONE)
{
DVP->IFR &= ~RB_DVP_IF_FRM_DONE; // clear Interrupt
addr_cnt = 0;
href_cnt = 0;
}
3 摄像头的寄存器配置
摄像头的寄存器配置项非常多,且很多项都被厂家设置为Reserved,不公开对应项的作用,因此教程的该部分就只挑几个常用的设置项简单说明一下。
首选是输出格式IMAGE_MODE的设置,该寄存器决定内置DSP输出到DVP端口的数据格式,本教程中使用了RGB565格式,因此该项应设为0x80。
另一项则是DSP缩放设置ZMOW/ZMOH,该项决定了DVP输出的分辨率,教程中使用了QVGA分辨率,因此这三项分别为0x50、0x3C、0x00。
还有一项则是Pixel Clock的分频参数DVP_SP,该项参数决定了DVP数据输出的速率。对于QVGA分辨率,PCLK分频可以设为4。
以上三项都是针对摄像头内置DSP的设置,因此在设置前需要先切换至DSP Bank,即将寄存器0xFF设置为0:
同时寄存器0x05也需要设置为0,以启用内置DSP功能。
完整的寄存器配置列表可以参考教程中的代码,或根据摄像头型号和应用需求自己寻找并进行搭配。
4 系统初始化
在main函数当中,首先我们需要初始化中断系统和串口调试,不再赘述。
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
Delay_Init();
USART_Printf_Init(115200);
printf("SystemClk:%d\r\n", SystemCoreClock);
然后需要对LCD进行外部重置(一般可跳过),并通过LCD_Init函数进行配置:
LCD_Reset_GPIO_Init();
GPIO_ResetBits(GPIOA, GPIO_Pin_15);
Delay_Ms(100);
GPIO_SetBits(GPIOA, GPIO_Pin_15);
LCD_Init();
然后在屏幕上显示一个等待提示,用于在摄像头完成启动前填充屏幕空间(使用了《碎屏模拟器》中的修改版LCD驱动):
LCD_SetBrightness(75);
LCD_SetColor(0x18E3, RED);
LCD_DrawRectangle(40, 80, 200, 160, TRUE);
LCD_ShowString(64, 104, 32, FALSE, "WAITING");
接下来通过SCCB端口完成摄像头的寄存器配置流程,包括基本参数配置OV2640_Init以及输出格式配置RGB565_Mode_Init,并在每项配置后添加一段延时,确保摄像头有足够时间使配置生效:
/* Camera init */
while (OV2640_Init())
{
printf("Camera Err\r\n");
Delay_Ms(1000);
}
printf("Camera Success\r\n");
Delay_Ms(1000);
/* Camera mode */
RGB565_Mode_Init();
printf("RGB565_MODE\r\n");
Delay_Ms(1000);
最后向LCD发送写缓存指令,将视频信号的显示区域设置为全屏,并配置好LCD的DMA通道,然后启动DVP端口。
/* Video data path */
LCD_AddressSetWrite(0, 0, LCD_W - 1, LCD_H - 1);
DMA_SRAMLCD_Init((u32)RGB565_DVPDMAaddr0); // DMA2
DVP_Init();
printf("DVP Enable\r\n");
接下来,所有的数据传输都会通过中断回调函数以及DMA通道进行处理,while循环中只要留空就可以了。
以上就是本次教程的全部内容了~