3、前端采集卡驱动开发:Linux内核驱动框架、DMA传输、中断处理、环形缓冲区
好,咱们进入第三章。这一章我估计是很多人觉得最「硬核」的部分——驱动开发。说实话,我刚入行那会儿也觉得驱动这东西离我挺远,直到第一次在彩超项目上被采集卡折腾得死去活来……嗯,从那以后我就明白了,不懂驱动,你前端采集做得再好也是白搭。
咱们彩超的前端采集卡,说白了就是一块高速数据搬运工。它要把探头传回来的模拟信号转成数字,然后一股脑塞给CPU。但CPU哪有那么闲?所以这里就需要驱动来协调。今天我就把Linux内核驱动里最核心的四个东西掰开揉碎讲给你听:驱动框架、DMA传输、中断处理、环形缓冲区。
3.1 Linux内核驱动框架:你的采集卡怎么被系统认出来?
先说说驱动框架。你想想看,一块PCIe采集卡插到主板上,系统怎么知道它是个啥?靠的就是驱动。
我个人习惯把驱动框架理解成「身份证+说明书」。身份证就是厂商ID和设备ID,说明书就是驱动里注册的那些回调函数。Linux内核里,我们通常用struct pci_driver这个结构体来注册一个PCIe设备驱动。
static struct pci_driver us_capture_driver = {
.name = "us_capture",
.id_table = us_capture_pci_ids,
.probe = us_capture_probe,
.remove = us_capture_remove,
};
static const struct pci_device_id us_capture_pci_ids[] = {
{ PCI_DEVICE(0x10EE, 0x8134) }, // 厂商ID 0x10EE,设备ID 0x8134
{ 0, }
};
module_pci_driver(us_capture_driver);
这里有个坑,我必须要说。我曾经在项目里遇到过厂商ID写错一位,结果驱动死活加载不上,查了两天才发现是0x10EE写成了0x10EF。你想想看,就一个十六进制位,折腾两天。所以写ID表的时候,一定要对着硬件手册反复核对。
probe函数是驱动的入口。当内核发现匹配的设备时,就会调用它。在这里我们要做几件事:
- 启用PCIe设备(
pci_enable_device) - 申请内存资源(
pci_request_regions) - 设置DMA掩码(
dma_set_mask) - 初始化硬件寄存器
- 注册中断处理函数
3.2 DMA传输:让数据自己飞一会儿
接下来是DMA。说白了,DMA就是让硬件自己把数据搬到内存里,不用CPU一根一根地搬。彩超的数据量有多大?我算给你看:一个通道采样率40MHz,16位精度,128通道,那就是40M×2×128≈10GB/s。这数据量,CPU要是亲自搬,其他事就别干了。
DMA传输的核心是描述符链。每个描述符里记录了:数据要搬到哪(目的地址)、搬多少(长度)、搬完了通知谁(中断标志)。多个描述符串起来,就形成了描述符链。
struct dma_descriptor {
u32 src_addr; // 源地址(采集卡上的FIFO地址)
u32 dst_addr; // 目的地址(系统内存地址)
u32 length; // 传输长度(字节)
u32 control; // 控制字(中断使能、链结束标志等)
u32 next; // 下一个描述符的地址
};
这里有个关键点:一致性DMA vs 流式DMA。我刚开始做的时候也搞混过。
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 一致性DMA | CPU和DMA看到的内存内容始终一致,不需要手动同步 | 控制信息、小数据量、频繁访问 |
| 流式DMA | 需要调用dma_sync_single_for_cpu/device来同步 | 大数据量、一次性传输、视频帧 |
彩超这种大数据量场景,我建议用流式DMA。为什么呢?因为一致性DMA为了保证缓存一致性,会禁用CPU的缓存优化,性能反而下降。流式DMA虽然需要手动同步,但数据量大时吞吐更高。
3.3 中断处理:别让CPU累死
DMA搬完数据,总得有人知道吧?这就是中断的作用。采集卡完成一帧DMA传输后,会向CPU发一个中断信号。CPU收到信号后,暂停手头的事,去处理新来的数据。
Linux里中断处理分上半部和下半部。上半部(request_irq注册的处理函数)要快,只做最必要的事:确认中断来源、清除中断标志、唤醒下半部。下半部(比如tasklet或workqueue)才做真正的数据处理。
static irqreturn_t us_capture_irq_handler(int irq, void *dev_id)
{
struct us_capture_dev *dev = (struct us_capture_dev *)dev_id;
// 1. 读取中断状态寄存器,确认中断来源
u32 status = ioread32(dev->regs + INT_STATUS);
if (!(status & INT_DMA_DONE))
return IRQ_NONE;
// 2. 清除中断标志
iowrite32(INT_DMA_DONE, dev->regs + INT_CLEAR);
// 3. 调度下半部
tasklet_schedule(&dev->dma_tasklet);
return IRQ_HANDLED;
}
为什么非要分上下半部?因为中断上下文里不能睡眠,不能拿锁,能干的事太少了。你把数据解析、帧同步这些耗时操作放在上半部,系统分分钟卡死给你看。
我记得有一次,一个同事把内存分配(kmalloc)放在了中断上半部里。平时跑得好好的,一遇到大数据量就死机。查了半天,发现是kmalloc在中断上下文里可能会睡眠,导致内核崩溃。嗯,从那以后我定了个规矩:中断上半部里只做寄存器操作,其他一律扔到下半部。
3.4 环形缓冲区:生产者与消费者的默契
最后说说环形缓冲区。DMA把数据搬到内存,中断通知CPU去处理,但CPU处理需要时间。如果DMA一直在写,CPU一直在读,怎么保证数据不丢、不覆盖?环形缓冲区就是干这个的。
环形缓冲区说白了就是一个固定大小的数组,加上两个指针:写指针(生产者)和读指针(消费者)。DMA写完一个缓冲区,写指针前进一位;CPU处理完一个缓冲区,读指针前进一位。当两个指针相遇时,要么是缓冲区满了(生产者等消费者),要么是缓冲区空了(消费者等生产者)。
struct ring_buffer {
void *buffers[NUM_BUFS]; // 缓冲区数组
u32 write_idx; // 写指针
u32 read_idx; // 读指针
spinlock_t lock; // 自旋锁,保护指针操作
};
这里有个设计要点:缓冲区个数选多少合适?我个人的经验是,彩超场景下至少4个。为什么呢?
- DMA正在写的那个缓冲区(当前生产者)
- CPU正在处理的那个缓冲区(当前消费者)
- 至少2个备用缓冲区,防止DMA连续写入时CPU来不及处理
我曾经在项目里只用了2个缓冲区,结果发现CPU处理一帧需要3ms,而DMA每2ms就写完一帧。你想想看,CPU还没处理完,DMA又来写,直接把上一帧覆盖了。画面就是一顿一顿的,像幻灯片一样。后来改成8个缓冲区,问题就解决了。
3.5 把它们串起来:一个完整的采集流程
好了,四个核心概念都讲完了。咱们把它们串起来,看看一个完整的采集流程是什么样的:
- 初始化阶段: 驱动加载,probe函数执行,分配DMA缓冲区,初始化环形缓冲区,注册中断处理函数。
- 启动采集: 用户空间程序通过ioctl通知驱动开始采集。驱动配置采集卡寄存器,启动DMA传输。
- 数据传输: DMA从采集卡FIFO读取数据,写入系统内存中的缓冲区。写完后触发中断。
- 中断处理: 上半部确认中断、清除标志、调度下半部。下半部将缓冲区指针放入环形缓冲区,唤醒等待的用户空间进程。
- 用户读取: 用户空间程序通过read或mmap读取环形缓冲区中的数据,进行后续的图像处理。
- 循环往复: 用户处理完一帧后,将缓冲区归还给驱动,驱动将其重新加入DMA描述符链,继续下一轮采集。
这个流程看起来简单,但每个环节都有坑。我建议你在开发时,先在每个关键节点加打印或计数,比如:DMA传输了多少帧、中断触发了多少次、环形缓冲区有没有溢出。这些调试信息能帮你快速定位问题。
好了,这一章的内容就到这儿。驱动开发这东西,光看理论是不够的,你得动手写、动手调。下次遇到采集卡驱动的问题,想想今天讲的这四个东西:框架让系统认识你的卡,DMA让数据自己跑,中断让CPU知道活干完了,环形缓冲区让生产和消费和谐共处。把这四个搞明白了,彩超前端采集的驱动部分,你就拿下了。