news 2026/9/25 4:48:26

OV9650摄像头驱动开发实战:从SCCB时序到CAMIF采集

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OV9650摄像头驱动开发实战:从SCCB时序到CAMIF采集

简介:一套OV9650摄像头驱动及测试程序源码,面向ARM平台嵌入式开发者,基于V4L2架构编写,参考他人代码移植而来,可运行于Tiny210开发板,也适用于2410、6410、A8等平台,仅需根据Linux内核版本做少量调整,硬件连接方式相同即可直接复用。压缩包共288个文件,体积2.29MB,主要包含C驱动源码、头文件、Makefile/Kconfig构建配置、测试程序相关脚本,另有一批HTML/CSS/JS、图片及PDF/DOCX文档,便于查看说明与工程文档,目录结构清晰。资源在CSDN已有485人学习,属于实际项目产出,作者花费两周完成并开源,适合学习V4L2驱动移植和摄像头应用调试的开发者参考。测试程序依赖OpenCV与Qt 4.7,可直接用于图像采集实验或二次开发。

1. 自己写的 OV9650 驱动到底难在哪

如果你正在一块 2410 或 2440 开发板上做摄像头采集,OV9650 驱动可能不是整块板子上最难的部分,但一定是最值得亲手写一遍的东西:网上能直接跑的 sensor 补丁往往和本机 CAMIF 版本对不上,V4L2 框架在内核 3.x、4.x 之间变化又大,A8 平台虽然外设更强,但传感器寄存器映射不会因此少一个。自己写驱动加测试程序,等于把 SCCB 时序、CAMIF 中断、DMA 缓冲、用户态 mmap 这条链完整过一遍,回头再去看任何标准驱动都会快很多。这篇文章适合正在调板子、评估 sensor、或者想拿 Linux 字符设备驱动框架练手的人。

2. 动手之前先拆清楚:SCCB、CAMIF、输出格式各管一段

OV9650 驱动不是“写一个初始化函数”就完事。它至少要拆成三段:SCCB 负责读写寄存器,CAMIF/ISP 负责把 sensor 的并行数据收进 DDR,字符设备驱动则负责把缓冲区送到应用层。三段里任何一段没接对,最后都是黑屏或者花屏。

2.1 SCCB 不是 I2C,但可以用 GPIO 模拟

OV9650 的寄存器接口叫 SCCB,两根线 SIO_C 和 SIO_D,时序和 I2C 很像:起始条件是 SIO_C 高电平时 SIO_D 产生下降沿,停止条件是 SIO_C 高电平时 SIO_D 产生上升沿。对大多数平台来说,直接复用 I2C 控制器也能写寄存器,但很多 2410/2440 底板上这两个引脚接在普通 GPIO 上,或者和触摸屏、EEPROM 的 I2C 总线共用,一旦总线上有设备拉死,就要拆设备排查。我一般会先用 GPIO 模拟,把时序主动权握在自己手里,转速也方便调。

#include <linux/gpio/consumer.h> #include <linux/delay.h> struct ov9650_dev { struct device *dev; struct gpio_desc *sio_c; struct gpio_desc *sio_d; unsigned int sccb_delay_ns; }; static void sccb_delay(struct ov9650_dev *ov) { ndelay(ov->sccb_delay_ns); } static void sccb_start(struct ov9650_dev *ov) { gpiod_set_value_cansleep(ov->sio_c, 1); gpiod_set_value_cansleep(ov->sio_d, 1); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_d, 0); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_c, 0); sccb_delay(ov); } static void sccb_stop(struct ov9650_dev *ov) { gpiod_set_value_cansleep(ov->sio_d, 0); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_c, 1); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_d, 1); sccb_delay(ov); } static int sccb_write_byte(struct ov9650_dev *ov, u8 val) { int i; int ack; for (i = 7; i >= 0; i--) { gpiod_set_value_cansleep(ov->sio_d, (val >> i) & 1); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_c, 1); sccb_delay(ov); gpiod_set_value_cansleep(ov->sio_c, 0); sccb_delay(ov); } /* 第 9 个时钟是应答位,此时 SIO_D 由 OV9650 控制 */ gpiod_direction_input(ov->sio_d); gpiod_set_value_cansleep(ov->sio_c, 1); sccb_delay(ov); ack = gpiod_get_value(ov->sio_d) ? -EIO : 0; gpiod_set_value_cansleep(ov->sio_c, 0); gpiod_direction_output(ov->sio_d, 1); sccb_delay(ov); return ack; } static int sccb_write_reg(struct ov9650_dev *ov, u8 reg, u8 val) { int ret; sccb_start(ov); ret = sccb_write_byte(ov, OV9650_SCCB_ADDR_WR); if (ret < 0) goto out; ret = sccb_write_byte(ov, reg); if (ret < 0) goto out; ret = sccb_write_byte(ov, val); out: sccb_stop(ov); return ret; }

这里OV9650_SCCB_ADDR_WR我一般定义为0x60,对应 7 位地址0x30。如果你的板卡原理图把 sensor 的 ID 引脚拉高或者拉低,实际地址会偏移,需要按照模组手册确认。sccb_delay_ns一开始不要追求速度快,先给到 1000 也就是 1us,读到 ID 后再慢慢往下压。GPIO 模拟 SCCB 的坑通常不是逻辑不对,而是初始时 SIO_D 没有设成开漏输出,导致应答位读不到低电平。

OV9650 读寄存器不能像 I2C 那样连续写完地址直接读,SCCB 的标准做法是先写寄存器地址,停止,再发读地址:

static u8 sccb_read_reg(struct ov9650_dev *ov, u8 reg) { u8 val = 0; sccb_start(ov); sccb_write_byte(ov, OV9650_SCCB_ADDR_WR); sccb_write_byte(ov, reg); sccb_stop(ov); sccb_start(ov); sccb_write_byte(ov, OV9650_SCCB_ADDR_RD); val = sccb_read_byte(ov, 1); sccb_stop(ov); return val; }

读字节的返回值要区分最后一次字节和中间字节:读最后一字节时可以发 NACK,告诉 sensor 不需要继续送了。多数驱动为了省事,读完一字节就停止,OV9650 也能接受,但遇到某些批次模组时会产生错位。

2.2 OV9650 输出什么格式,CAMIF 就按什么格式拆

OV9650 最大能输出 SXGA 分辨率,实际项目里用的最多的是 VGA 640x480。输出格式主要有 YUV 4:2:2、RGB565、Raw RGB、JPEG 几种。2410/2440 的 CAMIF 对 YUV 4:2:2 支持最顺,DMA 通道按 YUYV/YVYU 顺序收数据,DRAM 里就是连续两字节一个像素。如果第一次点屏,我强烈建议先把 sensor 配置成 YUV 4:2:2,不要上来就用 RGB565,因为 CAMIF 内部还可能涉及字节交换,RGB565 一旦字节序反了,图上颜色会非常诡异。

YUV 4:2:2 下,一行 640 像素,一帧数据量就是 640x480x2。用户态拿到的是Y0 U0 Y1 V0 Y2 U2 Y3 V3这样的连续流。想快速预览,可以用 ffplay 直接播放:

ffplay -f rawvideo -pixel_format yuyv422 -video_size 640x480 frame.yuv

先把这条命令跑通,再去做 RGB 转换。如果画面能看出轮廓但颜色不对,优先怀疑 UV 顺序;如果全是雪花,优先怀疑 PCLK 极性和 HREF 时序。

2.3 用字符设备驱动框架而不是 V4L2,不是炫技

自己写 OV9650 驱动,不是否定 V4L2。V4L2 是标准接口,后续要接 GStreamer、OpenCV 或视频编码器,最终还是要往 V4L2 上靠。但如果你手里是一块老内核的 2440 板,或者 A8 平台要快速验证 sensor 通路,V4L2 的videobuf、v4l2_subdev在不同内核版本间差异很大,调个框架半天就过去了。字符设备驱动框架轻得多:注册一个miscdevice,实现 open/release/ioctl/mmap/poll,数据通路自己控制。调试 sensor、抓原始寄存器、测帧率,都比在 V4L2 里翻日志快。

3. 把 SCCB 和寄存器初始化写进驱动

这一章是把上一章的思路落到代码上。字符设备驱动框架不需要很多函数,但每个函数的边界要清楚:open 只负责打开设备,read/mmap 负责把帧数据交给用户态,poll 让用户态能等到“新的帧到了”,ioctl 用来查格式、触发采集。

3.1 file_operations 里该实现哪几个接口

static const struct file_operations ov9650_fops = { .owner = THIS_MODULE, .open = ov9650_open, .release = ov9650_release, .read = ov9650_read, .mmap = ov9650_mmap, .poll = ov9650_poll, .unlocked_ioctl = ov9650_ioctl, };

open 里我不做重活,只把mutex_lock加上,防止两个进程同时打开设备。真正初始化 sensor 放在platform_probe里做,因为 open 可能被应用反复调用,每次都初始化一遍寄存器太慢。read 接口提供一种傻瓜式用法:用户态调用read(fd, buf, len),驱动阻塞到新帧到来后拷贝一帧。这种拷贝过程多一次内存搬运,但适合先把通路验证了。

poll 的实现核心是一个等待队列:

static unsigned int ov9650_poll(struct file *file, poll_table *wait) { struct ov9650_dev *ov = file->private_data; poll_wait(file, &ov->waitq, wait); if (atomic_read(&ov->frame_ready)) return POLLIN | POLLRDNORM; return 0; }

frame_ready必须在硬件中断里置位,而不是在用户态ioctl后再去读寄存器。摄像头帧中断是硬件产生的,中断里要做的事越少越好。

3.2 寄存器初始化序列:先验证 ID,再写参数表

OV9650 的寄存器有上百个,真正决定能不能出图的没有那么多。关键路径是:软复位、等待 sensor 稳定、读产品 ID、设置输出格式、设置窗口、设置 PCLK 分频,最后开自动曝光/白平衡。下面这段代码不追求把每个寄存器都列全,而是展示驱动里初始化函数该有的骨架:

#define OV9650_REG_PID 0x0a #define OV9650_EXPECT_PID 0x96 static int ov9650_init(struct ov9650_dev *ov) { u8 pid; int ret; int i; ret = sccb_write_reg(ov, 0x12, 0x80); /* 软复位 */ if (ret < 0) return -EIO; msleep(20); pid = sccb_read_reg(ov, OV9650_REG_PID); if (pid != OV9650_EXPECT_PID) { dev_err(ov->dev, "sensor id mismatch: 0x%02x\n", pid); return -ENODEV; } for (i = 0; i < ov->reg_num; i++) { ret = sccb_write_reg(ov, ov->regs[i].reg, ov->regs[i].val); if (ret < 0) { dev_err(ov->dev, "write reg 0x%02x failed\n", ov->regs[i].reg); return -EIO; } usleep_range(200, 300); } return 0; }

0x12是 COM7,0x80是软复位位。软复位后立刻读寄存器,很多批次会读到 0 或者旧值,所以msleep(20)不能省。ID 写入0x0A是产品 ID 寄存器,OV9650 正常返回0x96;记下这个值,后面排查 SCCB 电气问题会省很多时间。

参数表我习惯单独放在一个数组里,驱动逻辑不关心每个寄存器含义:

struct ov9650_reg { u8 reg; u8 val; }; static const struct ov9650_reg ov9650_vga_yuv422[] = { {0x12, 0x00}, /* 退出复位,选择 YUV 输出 */ {0x11, 0x40}, /* PCLK 分频,实际值由 XCLK 和目标帧率算 */ {0x0e, 0x61}, /* COM5,UV 顺序相关 */ {0x3a, 0x04}, /* 颜色矩阵,先按模组默认值 */ };

这块是 OV9650 驱动里最容易被网上资料误导的地方。不同模组厂给的初始化表可能都不一样,尤其在 2440 和 A8 上,XCLK 频率不同,PCLK 分频就不同;镜头不同,自动曝光目标值也不同。正确做法是根据你自己的模组手册或供应商驱动提取一张表,再通过测试程序一张一张验证。驱动框架只需要一个“按表写寄存器”的入口。

3.3 2410/2440 和 A8 的 CAMIF 配置差异

同样是 OV9650,2410/2440 和 A8 平台的接入方式有两点要额外注意:一个是时钟,一个是 DMA 地址。

2440 的 CAMIF 需要 HCLK 分频出 CAMCLK 给 sensor 做 XCLK,OV9650 的 XCLK 一般给 12MHz 到 24MHz。你需要在板级初始化里把摄像头时钟算对,而不是只在驱动里配分频。A8 平台如果用的是片上 camera controller,通常也要在设备树里把时钟和 GPIO 引线配好:

ov9650: ov9650@30 { compatible = "ovti,ov9650"; reg = <0x30>; pinctrl-names = "default"; pinctrl-0 = <&cam_pins>; clocks = <&clk_cam>; reset-gpios = <&gpio 0x10 GPIO_ACTIVE_LOW>; pwdn-gpios = <&gpio 0x11 GPIO_ACTIVE_HIGH>; status = "okay"; };

2440 年代的内核没有设备树,大多数用platform_device和板级注册表搞定;A8 上我更建议直接用设备树,把 CAMIF 中断、DMA 通道、时钟全部交给内核解析。你写的字符设备驱动代码在两个平台上是同一套,差异收敛在 DMA buffer 分配和 mmap 映射上。

4. 测试程序:从 /dev/ov9650 拿一帧 YUV422

驱动写完后,应用层测试程序要能回答三个问题:能不能出帧、格式对不对、帧率够不够。用 mmap 映射 DMA 缓冲区,比每次 read 拷贝更适合连续采集。

4.1 最小采集程序:mmap 加 poll

这个测试程序不依赖 V4L2,直接操作自定义字符设备:

#include <stdio.h> #include <fcntl.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <poll.h> #include <unistd.h> #include "ov9650_ioctl.h" int main(void) { int fd; int i; unsigned char *buf; struct ov9650_format fmt; struct pollfd pfd; FILE *out; fd = open("/dev/ov9650", O_RDWR); if (fd < 0) { perror("open"); return 1; } if (ioctl(fd, OV9650_IOC_G_FMT, &fmt) < 0) { perror("ioctl fmt"); return 1; } buf = mmap(NULL, fmt.width * fmt.height * 2, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (buf == MAP_FAILED) { perror("mmap"); return 1; } pfd.fd = fd; pfd.events = POLLIN; for (i = 0; i < 30; i++) { if (poll(&pfd, 1, 500) > 0) { ioctl(fd, OV9650_IOC_DQ_FRAME, &i); } } out = fopen("frame.yuv", "wb"); fwrite(buf, 1, fmt.width * fmt.height * 2, out); fclose(out); munmap(buf, fmt.width * fmt.height * 2); close(fd); return 0; }

这里OV9650_IOC_G_FMT只负责查询当前格式,驱动返回 640x480;OV9650_IOC_DQ_FRAME表示用户态要取最新的一帧,驱动在中断里已经把帧准备好,这个 ioctl 主要用来做缓冲切换。记住一点:mmap 映射的是内核 DMA buffer,不是普通用户态 malloc 内存,所以驱动在 mmap 回调里要用pgprot_noncached或者直接映射物理连续内存,否则 A8 的 cache 会把数据挡住。

4.2 帧率验证:不要只看打印

很多人在测试程序里 atodelay 一下,算一秒能读多少帧,结果是 30fps 还是 8fps 完全没准。更可靠的做法是记录每帧的单调时钟:

#include <time.h> static long long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000LL + ts.tv_nsec / 1000000; }

每 poll 到一帧就调用now_ms(),打印与上一帧的差值。正常 640x480 YUV422 在 24MHz 的 XCLK 下能跑到 30fps,如果间隔普遍超过 40ms,先查 PCLK 分频和 DMA burst 配置,不要怀疑 sensor 能力。

4.3 用原始帧检查像素格式是否被 CAMIF 拆错

把frame.yuv拖回 PC 用 ffplay 看过之后,如果画面方向、亮度和颜色都正常,说明 sensor 输出格式和 CAMIF 配置已经对齐。如果画面像万花筒一样,说明窗口时序不对;如果是绿紫色人脸,基本是 U/V 顺序反了。对 OV9650 来说,改 UV 顺序有两个入口:sensor 寄存器里的输出格式位,以及 CAMIF 里的字节交换位。我调试时会固定住 sensor 那侧,只改 CAMIF 的 byte swap,这样一旦画质正常,能确定是哪一个环节造成的。

5. OV9650 驱动从黑屏到花屏:五条避坑记录

驱动能出图不代表能交付,下面这几个问题我都踩过。每一条按现象、原因、解决来写,遇到相同症状可以直接对照。

5.1 读不到 sensor ID,返回 0x00 或 0xff

现象:ov9650_init里读0x0A,返回值不是0x96,日志里报sensor id mismatch。

原因:最常见是 sensor 没真正上电,或者 SCCB 总线上拉电阻缺失。GPIO 模拟读应答位时,SIO_D 切到输入后悬空,读到高电平,驱动把高电平当作 NACK,整个事务失败。还有可能是 SCCB 时钟太快,OV9650 跟不上。

解决:先用万用表量 SIO_D 上拉到 VDD 的电阻,确认 PWDN 和 RESET 引脚不是悬空;再把sccb_delay_ns改成 1000 以上,把 SCCB 时钟压到 100kHz 以下重试。如果还不行,用逻辑分析仪抓 SIO_C 和 SIO_D,重点看起始条件和 ACK 位位置,九成问题一眼就能看出来。

5.2 能读到 ID,但 CAMIF 一直收不到 VSYNC

现象:驱动初始化成功,内核没有报错,但 poll 始终超时,调试 GPIO 接的帧中断也一直没有触发。

原因:寄存器表里没有把 sensor 从测试模式或者待机模式切出来。很多网上抄的初始化表最后几行会把 sensor 设成输出彩条,或者让 PCLK 停在高电平,看起来像“初始化成功”,实际没进正常采集模式。

解决:用示波器或逻辑分析仪直接量 OV9650 的 VSYNC 和 HREF 引脚。如果 PCLK 有信号但 VSYNC 不动,问题在 sensor 窗口设置;如果 PCLK 都没有,问题在时钟或者 power down。不要一上来就怀疑 DMA 配置。

5.3 画面整体偏绿或偏紫,轮廓是清楚的

现象:能出帧,但人脸和桌面颜色完全不对,绿色和紫色占满画面。

原因:YUV422 的 U/V 顺序反了。OV9650 可以输出 YUYV,也可以输出 YVYU,CAMIF 的 DMA 通道对应关系如果和 sensor 不一致,颜色就会乱。

解决:先固定 sensor 寄存器里的 UV 输出顺序,然后改 CAMIF 的 byte swap 控制位。改完重新抓一帧,用 ffplay 看同一块颜色区域,正常情况下肤色能回来。如果换寄存器没用,检查是不是把 YUV422 数据当成了 RGB565 处理,两者每像素字节数一样但含义完全不同。

5.4 DMA 中断有,但 mmap 出来的内存一直是旧数据

现象:poll 每次都返回,用户态也调用了 DQ_FRAME,但读出来的 30 帧内容一模一样,或者前两帧会动,后面全卡住。

原因:DMA buffer 的 cache 一致性没有处理。DMA 把数据写进物理内存,CPU 如果读到 cache 里的旧数据,看到的就是“帧没更新”。在老内核 2440 上尤其常见,因为 CAMIF 的 DMA 地址是物理地址,而用户态 mmap 映射的是内核虚拟地址,中间缺一次 cache invalidate。

解决:不要在驱动里手工把 buffer 地址传到 DMA 后就不再管。用dma_alloc_coherent分配采集缓冲区,或者每次帧完成后对 buffer 做dma_map_single加dma_unmap_single。这个钱不能省。

5.5 连续采集几分钟后系统卡死,控制台报 paging request

现象:单帧采集没问题,循环跑一会儿后unable to handle kernel paging request或者 DMA 中断风暴。

原因:中断处理函数里做了不允许睡觉的事,比如msleep或者直接访问 SCCB;又或者 DMA buffer 被用户态越界写坏。OV9650 初始化写寄存器时常用usleep_range,如果这段代码被放到硬件中断上下文,系统挂掉只是时间问题。

解决:中断里只置frame_ready标志,唤醒等待队列,所有寄存器操作挪到 ioctl 或者工作队列里。DMA buffer 要把VMA的访问范围限制死,不让应用随便越界写。建议在中断处理函数里加一个自旋锁保护frame_ready,防止并发访问。

6. 从单帧采集到连续采集:中断、超时和缓存一致性

驱动过了“能出一帧”这个阶段,接下来的问题是连续采集会不会漏帧、会不会卡死。这里有一个我每次必做的改造:给等待帧加超时,同时把中断处理精简到最小。

static irqreturn_t ov9650_irq(int irq, void *data) { struct ov9650_dev *ov = data; atomic_set(&ov->frame_ready, 1); wake_up_interruptible(&ov->waitq); return IRQ_HANDLED; } static int ov9650_wait_frame(struct ov9650_dev *ov) { int ret; atomic_set(&ov->frame_ready, 0); ret = wait_event_interruptible_timeout(ov->waitq, atomic_read(&ov->frame_ready), 2 * HZ); if (ret == 0) { dev_warn(ov->dev, "capture timeout\n"); return -ETIMEDOUT; } return 0; }

wait_event_interruptible_timeout是连续采集的后悔药:DMA 或帧中断偶尔会丢一次,如果驱动傻等,应用线程就卡死了。超时返回后,驱动要主动做一次 CAMIF 软复位或者重新启动采集,把采集机拉回正轨。我现在的习惯是每 1000 帧打印一次超时计数,而不是每帧中断都打印,否则日志刷得比数据还多。DMA buffer 用dma_alloc_coherent分配,mmap 时保持 cache 一致性属性,这比在中断里手工 flush 可靠得多。最后一条提醒:不要在中断里调printk,也不要调sccb_read_reg。把中断处理压缩到“置位、唤醒、返回”,这是字符设备驱动框架下最不容易翻车的写法。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 4:46:29

从CLIP到LLava:多模态模型原理与复现指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 4:46:05

STM32调试防坑指南:电源、复位、时钟与外设实战避坑手册

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 4:44:58

PCIe协议入门:三层架构与数据流核心解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 4:44:55

Vitis 2024.2 Ubuntu 22.04.4 安装崩溃与硬件识别全链路修复指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华