从零编写OV9650摄像头驱动:SCCB配置与DMA采集实战
简介面向ARM嵌入式平台的OV9650摄像头驱动与测试程序资源包参考现有代码移植适配可完整支持tiny210开发板也能经少量改动运行于2410、2440、6410、A8等ARM平台及不同版本Linux内核适合嵌入式驱动开发者、项目集成人员参考复用。包内含驱动核心ov965x.c、平台相关mach-mini210.c以及Kconfig/Makefile构建配置还提供基于OpenCV和Qt4.7编写的测试程序便于直接验证图像采集与预览效果另有PDF/docx形式的设计说明可帮助理解移植思路。资源共288个文件压缩包仅2.29MB其中源码、构建脚本与说明文档为使用重点其余多为网页脚本辅助内容。目前已有486人浏览学习。作者公开了花费两周完成的项目代码对学习摄像头驱动框架、Linux下V4L2编程以及ARM平台驱动适配均有实质参考价值。1. 为什么还要自己写 OV9650 摄像头驱动老传感器在 2410/2440/A8 上的现实问题OV9650 是一颗很老的 CMOS 图像传感器最大支持 SXGA1280x1024输出支持 YUV、RGB 和 RAW Bayer 三种数据格式到今天仍然大量出现在工业视觉采集板、老式医疗设备和 ARM9 教学平台上。标题里的「自己编写」说明你手里没有完整的 BSP 摄像头驱动或者厂商给的 demo 只是个黑匣子——寄存器配置不可见换分辨率要碰运气换一颗镜头、调一次曝光都要重新猜。这篇笔记适合手里有 2410、2440 或 A8 开发板想把 OV9650 驱动从零肝出来的工程师。你会需要同时搞定三件事SCCB 寄存器配置、按帧中断做 DMA 搬运以及一个能验证图像的测试程序。这三件事在 2410/2440 和 A8 上的做法差别不小尤其是 DMA 和缓存一致性下面会逐一讲到。2. 先把 OV9650 的时序和寄存器逻辑吃透SCCB 读写的三个关键差异2.1 OV9650 的关键引脚与上电流程OV9650 对外接口不算复杂SCCB 控制口SIOC/SIOD兼容 I2C 时序负责写寄存器数据口是 D0-D9 十条并行线配合 PCLK像素时钟、VSYNC帧同步、HREF行有效三个同步信号把图像数据送出来。很多新手上来就写初始化序列结果是图像出不来或者颜色一团糟问题往往不在寄存器表而在上电顺序——这其实就是一块典型的玄学区域。正确的上电流程是先把 PWDN 拉低、RESET 拉低然后给传感器提供稳定的时钟我一般用外部晶振 24MHz等电源和时钟稳定至少 50ms 之后再把 RESET 拉高再等一个帧周期让内部 PLL 锁住然后才开始通过 SCCB 写寄存器。如果你把 RESET 拉高的动作放在时钟之前传感器内部逻辑会处于不确定状态后面 SCCB 读写经常随机失败而且失败得很稳定——每次都在同一批寄存器上 NACK。另一个关键点是 PCLK 的输出频率。OV9650 内部有时钟分频系数默认配置下 PCLK 可能高达几十 MHz对 2410 这类 ARM9 来说如果 DMA 配置跟不上图像的右半部分会稳定地出现错位或残影。所以硬件设计时最好把 PCLK 引到一个可测的测试点软件调试时用示波器或逻辑分析仪确认 PCLK 实际频率而不是只看寄存器手册上的理论值。2.2 SCCB 读时序和 I2C 的差异写没问题读容易翻车OV9650 的控制总线叫 SCCB物理层和 I2C 几乎一样但读时序有个差异I2C 允许在一条消息里先写寄存器地址再切换为读而 SCCB 的读操作要求在写完寄存器地址之后发送一个 Stop 条件再以重复起始的方式发起读。很多平台的 I2C 控制器在做 read 时会自动生成 repeated START这也能工作但某些老版本内核的 i2c-algo-bit 实现不会自动做这个导致读出来的一律是 0xFF。我一般用 Linux 内核的 i2c_transfer 接口用两个 i2c_msg 来完成一次读这样驱动无需关心底层控制器是否自动生成 repeated STARTstatic int ov9650_sccb_read(struct ov9650_dev *dev, u8 reg, u8 *val) { struct i2c_msg msg[2]; u8 addr_buf reg; int ret; /* 第一段写寄存器地址不带 STOP 条件 */ msg[0].addr dev-client-addr; msg[0].flags 0; msg[0].len 1; msg[0].buf addr_buf; /* 第二段读一个字节控制器自动产生 repeated START */ msg[1].addr dev-client-addr; msg[1].flags I2C_M_RD; msg[1].len 1; msg[1].buf val; ret i2c_transfer(dev-client-adapter, msg, 2); if (ret ! 2) return -EIO; return 0; }逻辑说明两个 i2c_msg 组成一次完整传输第一段写寄存器地址第二段读数据由 I2C 控制器在两条消息之间自动插入 repeated START。这样写出来的 read 函数在内核 i2c 框架下最稳也方便移植到 2410、2440 各自的 I2C 适配器上。参数说明dev-client 来自 i2c_new_deviceaddr 是 OV9650 的 7 位地址 0x21因为芯片的 SCCB 地址是 0x42 写、0x43 读去掉最低位后就是 0x21。如果你是在 A8 平台上用平台设备的 I2C 控制器这段代码同样适用只要把 adapter 换成对应的 ic_bus。这里有一个我踩过的坑read 失败时不要轻易返回错误很多初始化序列里读寄存器只是为了校验建议失败时打印寄存器号然后继续往下写否则初始化会脆到一抖动就挂。2.3 2410/2440 与 A8 的采集通道差异DMA 怎么选三块处理器的差异主要在 DMA 通道和源地址表示方式。2410 和 2440 的 DMA 支持外设到内存传输源地址用外设基地址加一个固定的寄存器地址比如 CAMIF 的输入 FIFO传输单位可以配置为 32 位一次搬运 4 字节刚好和 OV9650 的 YUV422 数据宽度匹配。2440 相比 2410 多了一个 CAMIF 摄像头接口可以接 ITU-R BT.601/656 格式但 OV9650 的并口输出方式是老式的同步并行接口不满足 BT.656 的嵌入同步格式所以用 2440 时多半还是要绕过 CAMIF直接把 D0-D9 接到 GPIO 或外部总线用 GPIO 中断 DMA 搬运。A8 处理器比如 S5PV210 或 i.MX51的情况又不一样CPU 主频高但外设总线的行为差异很大DMA 控制器大多数支持 scatter-gather缓冲区不要求物理连续这对驱动设计是好事。但 A8 引入了一个 2410/2440 时代没这么强烈的问题——CPU cache 与 DMA 的一致性。如果 DMA 把图像数据写到了内存而 CPU 在读取时命中了 cache 里的旧数据画面就会随机出现上一帧的碎片。选 DMA 通道时我的经验是优先选内存到外设方向不涉及的那个通道并且把突发长度burst length设为 16 个 32 位字传输宽度设 32 位。突发长度太小DMA 会频繁被总线仲裁打断帧率高不起来突发长度太大在 A8 上容易触发总线错误系统直接 oops。这个参数值得花时间调一块板一个最优值别照抄。3. 驱动主体实现字符设备框架下的 SCCB 配置与帧中断3.1 先写字符设备驱动而不是直接上 V4L2很多教程一上来就让你按 V4L2 框架写摄像头驱动但 V4L2 要在 struct video_device、vb2_queue、v4l2_event 这些抽象层里绕一圈对 2410 这种老内核和只想抓帧做图像处理的场景反而把简单问题复杂化。我的做法是先写一个字符设备驱动注册成 /dev/ov9650提供 open、close、ioctl、mmap 四个操作。测试程序配合着写逻辑透明出问题好定位。等驱动稳定后如果产品需要接 GStreamer 或 OpenCV再封装一层 V4L2 也不迟。字符设备驱动的核心结构大概如下open 时做一次传感器初始化ioctl 里支持设置输出格式和启动/停止采集mmap 把内核的 DMA 缓冲区映射到用户空间。帧数据用两个缓冲区轮换中断来的时候通知 DMA 把当前帧搬到另一个缓冲区DMA 完成中断把 buffer 状态置为 ready用户程序通过 select/poll 收到可读事件。3.2 SCCB 读写与初始化序列一份能点亮传感器的寄存器表SCCB 写函数比读简单一条 i2c_msg 就能完成但有一个细节OV9650 的寄存器在 0x00-0x7F 之间是直接寻址0x80 之后有部分寄存器属于扩展页需要通过寄存器 0xFF 切换页。所以我初始化序列里第一件固定动作是往 0xFF 写 0x00确保回到默认页避免上一手配置残留把初始化带偏。static const struct ov9650_reg_init ov9650_sxga_uyvy[] { { 0xff, 0x00 }, /* 选择寄存器页 0 */ { 0x12, 0x80 }, /* PCLK 分频SXGA 分辨率 */ { 0x13, 0xe7 }, /* 自动曝光/自动白平衡开启 */ { 0x1e, 0x04 }, /* 水平窗口起始 */ { 0x3a, 0x04 }, /* 帧率 15fps SXGA */ { 0x40, 0xc1 }, /* RGB/YUV 输出格式选择 */ { 0x42, 0x80 }, /* UYVY 输出顺序Y0 U Y1 V */ { 0x11, 0x80 }, /* 输出窗口 1280x1024 */ };逻辑说明这是最小初始化子集真正完整序列一般有七八十个寄存器但点亮传感器只需要先保证时钟分频、输出格式、窗口大小三个组别正确。0x12 寄存器同时承担 reset 功能写 0x80 会让芯片执行一次软复位所以它必须出现在 0xff 之后、其他配置之前否则后面的配置会被复位清掉。参数说明0x40 寄存器决定格式0xC1 表示选择 RGB/YUV 输出模式0x42 寄存器控制 YUV 的字节顺序0x80 代表 UYVY0x3a 控制帧率分频SXGA 下最高大概 15fps想跑 30fps 就得把分辨率降到 VGA。我建议把初始化序列做成数组而不是散落的赋值函数这样后面接不同分辨率配置时只需要加一组表项驱动代码不用动。初始化完成之后建议读回几个关键寄存器校验比如读 0x0a、0x0bPID/VERSION确认传感器型号是 0x7F 和 0xA2。如果 PID 读不对再往后配寄存器都是白费这时候回去查上电时序和 SCCB 波形别跟寄存器表较劲。3.3 帧采集中断与双缓冲DMA 搬运的组织方式OV9650 的 VSYNC 是帧同步信号每输出一帧会产生一个低电平脉冲。驱动里把这个信号接到处理器的外部中断脚2410/2440 一般接 EINTA8 接 GPIO 中断中断触发方式用下降沿这样一帧开始和结束都能捕获到。中断处理里需要做的不是搬运数据而是启动 DMA把传感器的数据口数据灌进当前空闲缓冲区。static irqreturn_t ov9650_isr(int irq, void *dev_id) { struct ov9650_dev *dev (struct ov9650_dev *)dev_id; struct ov9650_buf *buf dev-bufs[dev-frame_in]; unsigned long flags; spin_lock_irqsave(dev-dma_lock, flags); if (buf-state BUF_IDLE) { /* 把 DMA 目标地址指向当前缓冲区搬运一帧 */ buf-state BUF_DMA_RUNNING; dma_start(dev-dma_chan, buf-dma_addr, buf-size); dev-frame_in (dev-frame_in 1) % OV9650_BUF_NUM; } spin_unlock_irqrestore(dev-dma_lock, flags); return IRQ_HANDLED; }逻辑说明VSYNC 中断里只负责切换 DMA 目标不处理图像数据这个设计能保证中断处理时间足够短。dma_start 启动一次外设到内存的搬运搬运完成后由 DMA 的中断回调把缓冲区状态改成 BUF_READY这时候用户态的 select 才能读到 POLLIN 事件。参数说明OV9650_BUF_NUM 我一般设 4不要只设 2因为 DMA 搬运一帧的时间接近一帧周期偶数缓冲容易在帧率波动时出现一帧被覆盖的情况。dma_addr 必须是用 dma_alloc_coherent 申请的物理地址2410/2440 上内核默认无 cache 问题但 A8 上必须保证这个内存区域是 uncached 或者按 DMA 层正确处理。DMA 搬运的字节数等于一帧大小SXGA 下 UYVY 格式是 1280×1024×2 字节约 2.5MB。这个尺寸对 2410 的内存带宽已经有点压力所以帧率上不去 15fps 时第一件事不是优化 DMA而是检查是否每一帧都真的搬完了、有没有中断丢失。4. 测试程序怎么写从 mmap 取帧到存出一张 BMP4.1 测试程序的总体流程与 ioctl 设计测试程序要解决的验证问题有三个传感器有没有出图、颜色对不对、帧率稳不稳。针对这三个问题我给 /dev/ov9650 设计了三个 ioctlOV9650_GET_FMT 查询当前格式、OV9650_SET_FMT 切换分辨率和输出格式、OV9650_STREAM_ON/OFF 控制采集开关。测试程序启动的流程是 open 设备先 GET_FMT 看默认配置再 SET_FMT 设成自己想要的 YUV422 UYVY SXGA然后 mmap 两块缓冲区最后 STREAM_ON 开始采集。这里有个经验ioctl 的序号定义不要图省事直接用随机数用内核的 _IOW/_IOR 宏按类型编码。因为测试程序和驱动是两个工程同一个 ioctl 号在两边不一致时表现出的现象是 ioctl 调用不报错但参数全部无效这类问题排查起来特别费时间。4.2 mmap 双缓冲取帧测试程序的核心代码驱动和测试程序之间用 mmap 共享缓冲区避免了每帧一次 read 拷贝。mmap 的 offset 参数我用来区分第几块缓冲区驱动里 remap_pfn_range 按 offset 计算对应的物理页。用户态拿到的是一段连续虚拟地址可以直接按 UYVY 格式解析。#include sys/mman.h #include fcntl.h #include linux/fb.h #define BUF_SIZE (1280 * 1024 * 2) int main(int argc, char **argv) { int fd open(/dev/ov9650, O_RDWR); unsigned char *buf0, *buf1; unsigned char *frame[2]; int idx 0; if (fd 0) { perror(open /dev/ov9650); return 1; } /* 两边缓冲区驱动保证 DMA 完成后才轮到用户读 */ buf0 mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); buf1 mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, BUF_SIZE); frame[0] buf0; frame[1] buf1; ioctl(fd, OV9650_STREAM_ON, NULL); for (int f 0; f 10; f) { struct pollfd pfd { .fd fd, .events POLLIN }; poll(pfd, 1, 3000); /* 先读用户态 index驱动在 DMA 完成后更新 */ idx ^ 1; save_as_bmp(capture.bmp, frame[idx], 1280, 1024); } ioctl(fd, OV9650_STREAM_OFF, NULL); munmap(buf0, BUF_SIZE); munmap(buf1, BUF_SIZE); close(fd); return 0; }逻辑说明测试程序在 poll 返回之后直接拿另一块缓冲区的数据这要求驱动必须保证用户上次读完的缓冲区不会被下一次 DMA 覆盖。双缓冲时驱动在 VSYNC 中断里交替使用缓冲区用户态的 idx 翻转必须和驱动保持同一个节拍稍有误差就会出现两帧重复或一帧撕裂。参数说明BUF_SIZE 要和驱动里的 DMA 缓冲区大小严格一致SXGA UYVY 就是 1280×1024×2。poll 超时设定为 3000ms正常情况下帧率 15fpspoll 最多等 70ms 就会返回等超过 3 秒直接判定采集失败这样测试程序能自己报错而不是卡在莫名其妙的地方。save_as_bmp 是像素格式转换函数下一节展开。4.3 UYVY 转 BMP像素格式转换的几个细节OV9650 输出 UYVY 时字节顺序是 U、Y0、V、Y1两个像素共享一对 UV。转 BMP 时要先解出每个像素的 Y、U、V再按标准 BT.601 公式算 RGB。这里最容易翻车的是缩放系数和截断方式很多代码直接写浮点公式效率低还容易在边界上溢出。static void save_as_bmp(const char *path, const unsigned char *uyvy, int w, int h) { static const int bmp_hdr[14] { 0x4d, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00 }; FILE *fp fopen(path, wb); int row_size (24 * w 31) / 32 * 4; int data_size row_size * h; unsigned char *rgb malloc(row_size); /* 写位图文件头和信息头BGR 顺序存储 */ /* ... 省略文件头填充 ... */ /* 从最后一行开始写BMP 的像素存储是自底向上 */ for (int y h - 1; y 0; y--) { const unsigned char *src uyvy y * w * 2; unsigned char *dst rgb; for (int x 0; x w; x 2) { int u src[0] - 128; int y0 src[1] - 16; int v src[2] - 128; int y1 src[3] - 16; /* BT.601 公式全部用整数运算 */ dst[0] clamp(298 * y0 516 * u 128); dst[1] clamp(298 * y0 - 100 * u - 208 * v 128); dst[2] clamp(298 * y0 409 * v 128); dst[3] clamp(298 * y1 516 * u 128); dst[4] clamp(298 * y1 - 100 * u - 208 * v 128); dst[5] clamp(298 * y1 409 * v 128); dst 6; src 4; } fwrite(rgb, 1, row_size, fp); } free(rgb); fclose(fp); }逻辑说明BMP 有 14 字节文件头加 40 字节信息头像素从图像最后一行开始写这是 BMP 格式与多数显示设备相反的存储方向最容易踩坑。转换公式用整数运算298、516、409 这些系数是 BT.601 的定点化近似右移 8 位等价于除以 256这样处理速度能支撑 SXGA 全尺寸转换。参数说明row_size 计算了 24 位色深下行字节数按 4 字节对齐公式是 (24×w31)/32×4如果不按这个对齐转换出来的 BMP 在 Windows 看图软件里会显示斜线或错位。clamp 函数要同时限制下界 0 和上界 255U/V 减 128 后超出边界时不做 clamp 的图像颜色会在高光区域明显失真。5. 移植到 2410/2440 与 A8 的四个血泪坑从花屏到缓存一致性5.1 图像花屏或撕裂先查 PCLK 分频再查 DMA 对齐现象图像能出来但右侧有规则的错位带或整帧随机撕裂像是把两帧图像水平切开了再拼起来。我第一次在 2440 上遇到这个问题时对着寄存器表调了两天最后是示波器量了 PCLK 发现频率已经超过 30MHz处理器那边根本来不及处理。原因OV9650 的 PCLK 分频系数没调对数据速率超过了 DMA 或 GPIO 采样能力或者 DMA 传输宽度和源数据宽度不匹配每行结束点对不齐。解决先用示波器确认 PCLK 实测频率把 0x12 寄存器的时钟分频位调大让 PCLK 降到 20MHz 以下然后把 DMA 传输宽度设为 32 位源地址递增方式设 4 字节递增保证每次突发传输都是整行长度的整数倍。如果 PCLK 已经很低还撕裂检查 VSYNC 和 HREF 是不是接到了处理器的正确引脚HREF 丢失会造成行同步错位但 VSYNC 还能正常触发画面就表现为固定位置的斜切。5.2 SCCB 写寄存器 NACK上电顺序比寄存器值更玄学现象初始化序列跑到第 20 个寄存器左右读写开始返回 NACK之后配置值写不进去图像不出来或全黑。原因OV9650 内部 PLL 还没锁定时SCCB 接口会间歇性不响应。很多时候不是寄存器表问题而是 RESET 拉高的时机早于时钟稳定或者电源和时钟之间的电源纹波太大。这类问题有个特点同一块板子上冷启动失败复位后再跑一遍又变成全通非常典型的时序竞争。解决在驱动 open 里加严格的时序屏障RESET 拉低后至少延时 10ms释放 RESET 后再等 50ms 才开始 SCCB 操作。另外在初始化函数开头加一个简单重试机制如果连续 3 次写同一个寄存器都 NACK就把整个上电流程重来一遍而不是继续往下写。这个重试机制在环境温度变化导致晶振起振变慢时能救回不少板子。5.3 帧率上不去、画面抖动VSYNC 中断触发沿现象帧率统计只有宣称值的一半或者画面每隔几秒抖一下CPU 占用率反而很高。原因中断触发方式配置成了上升沿而不是下降沿或者 GPIO 内部上没有使能下拉。OV9650 的 VSYNC 时序是帧消隐期拉低水平同步期间维持高电平如果配置成上升沿触发中断在每一帧会触发多次DMA 被反复启动缓冲区竞争激烈。4096 字节的 FIFO 还没填满DMA 就被下一次中断打断帧自然丢。解决把外部中断配置为下降沿触发并且把中断服务函数里的 DMA 启动逻辑加一个 state 判断只允许 BUF_IDLE 状态下的缓冲区被启动。这个 state 判断在 2410 上尤其重要因为 2410 的外部中断没有硬件消抖GPIO 毛刺会直接触发中断软件层不判断状态就会被毛刺带进错误路径。5.4 A8 平台上图像偶尔错位DMA 缓存一致性问题现象同一段测试程序在 2440 上跑 1000 帧不出错换到 A8 平台跑每隔几十帧就出一帧花屏花屏内容看起来像是上一帧的右上角补到当前帧的左下角。原因A8 处理器有较大的 L2 cacheDMA 把数据写进内存时如果那片区域在 CPU cache 中还有旧数据用户态读到的就是缓存里的残留。2440 的 cache 行为相对简单这个问题不明显A8 上就是概率性出现而且越是连续读同一块缓冲区命中概率越高。解决驱动里不要用 kmalloc 加 virt_to_phys 这种老套组合直接用 dma_alloc_coherent 申请 DMA 缓冲区这一接口保证分配的内存与 cache 操作是同步的。如果缓冲区和申请接口都已经正确还没好检查平台总线的 smp 相关配置某些 A8 平台需要把总线设为非一致性传输模式。还有一个临时排查技巧把花屏帧与前一帧做像素差如果碎块边界和 DMA 突发长度对齐基本就是缓存问题不用再查时序。6. 一个让我少走半年弯路的调试习惯上板前先做寄存器 dump6.1 寄存器 dump 工具怎么写我强烈建议在驱动里加一个 DEBUG ioctl把 OV9650 全部寄存器读出来打印到内核日志这也是我接手任何一颗新传感器时做的第一件事。原因很简单很多所谓「调好的初始化序列」在另一块板子上完全不是默认状态可能是上一个调试者遗留了半配置状态。寄存器 dump 能告诉你传感器现在到底在什么状态而不是猜。实现上也简单在 ioctl 里循环调用 ov9650_sccb_read把 0x00-0x80 的寄存器值打包到用户缓冲区测试程序用 hexdump 格式打印。这个工具看起来不起眼但在排查「为什么同一份配置两块板子效果不一样」这类问题上回报极高。6.2 能继续往下走的三个优化方向第一个方向是帧率优化把 SXGA 降到 VGA再调节 0x3a 寄存器把曝光和帧率解耦15fps 提到 30fps 基本没有成本但要注意 PCLK 分频也要同步调整否则输出带宽超限。第二个方向是自动曝光和自动白平衡的参数收敛OV9650 的 AEC/AGB 收敛算法对场景变化很敏感可以在驱动里暴露亮度阈值让应用层根据图像平均亮度动态修改目标值。第三个方向是往后兼容的封装把字符设备驱动加上 videobuf2 适配层往 V4L2 框架靠这样上层可以直接用 OpenCV 打开 /dev/video0而不用维护私有的测试程序。我自己早年因为贪快一直用私有接口后来接算法库时吃了不少后悔药所以现在哪怕是内部项目也会在驱动稳定后尽快补一层 V4L2 适配。最后说个习惯每次调完一块板子我会把最终能用的寄存器序列单独存成一份头文件并在文件头写上板名、晶振频率、PCLK 实测值和当时的环境光照条件。后来遇到同型号新板子时先按这份头文件初始化大多数时候一次过省下的时间远超当时写注释的几分钟。这也是这篇驱动笔记希望传达的核心——OV9650 这类老传感器没有高深的技术门槛真正值钱的是把每次踩坑后的稳定配置沉淀下来。希望帮到你。本文还有配套的精品资源点击获取