驱动开发基础:驱动框架介绍、PCI设备枚举与配置、中断处理机制、DMA传输原理

各位同学,今天我们来啃一块硬骨头——驱动开发的基础。说实话,很多做应用层开发的兄弟一听到「驱动」两个字就头大,觉得那是内核开发者的专利。其实不然,驱动说白了就是让操作系统认识你的硬件,并告诉它怎么用。我在QNX上折腾驱动也有十来年了,踩过的坑比走过的路还多。今天就把这些经验掰开揉碎了讲给你听。

一、QNX驱动框架介绍

QNX的驱动模型和Linux不太一样。它采用的是资源管理器(Resource Manager)架构。什么意思呢?就是每个驱动都是一个独立的进程,通过消息传递和内核通信。你想想看,这样做的好处是什么?

  • 隔离性:驱动挂了,不会把整个系统拖死。我遇到过好几次,驱动崩了,重启一下驱动进程就行,系统纹丝不动。
  • 可调试性:驱动进程可以像普通程序一样gdb调试。这在Linux上简直是奢望。
  • 热插拔:驱动可以动态加载卸载,不用重启系统。

驱动的基本结构是这样的:

int main(int argc, char **argv) {
    // 1. 初始化资源管理器
    dispatch_t *dpp = dispatch_create();
    
    // 2. 注册设备路径
    resmgr_attr_t resmgr_attr;
    memset(&resmgr_attr, 0, sizeof(resmgr_attr));
    
    // 3. 绑定IO函数
    resmgr_connect_funcs_t connect_funcs;
    resmgr_io_funcs_t io_funcs;
    memset(&connect_funcs, 0, sizeof(connect_funcs));
    memset(&io_funcs, 0, sizeof(io_funcs));
    
    io_funcs.read = my_read;
    io_funcs.write = my_write;
    io_funcs.devctl = my_devctl;
    
    // 4. 注册设备
    resmgr_attach(dpp, &resmgr_attr, "/dev/mydevice",
                  _FTYPE_ANY, 0, &connect_funcs, &io_funcs, NULL);
    
    // 5. 进入消息循环
    dispatch_block(dpp);
    return 0;
}

嗯,这里要注意:dispatch_create() 必须在最前面调用,否则后面所有注册都会失败。我曾经在这个坑里爬了整整一个下午。

二、PCI设备枚举与配置

PCI设备枚举,说白了就是让系统找到你的硬件。QNX提供了 pci_* 系列API,用起来还算顺手。

我个人习惯的枚举流程是这样的:

  1. 调用 pci_attach() 初始化PCI子系统
  2. 遍历总线、设备、功能号
  3. 读取Vendor ID和Device ID,匹配你的设备
  4. 读取BAR空间地址,映射到虚拟地址空间
  5. 配置中断和DMA

来看一个实际的枚举代码:

int find_pci_device(uint16_t vendor_id, uint16_t device_id) {
    pci_attach(0);  // 初始化PCI子系统
    
    for (int bus = 0; bus < 256; bus++) {
        for (int dev = 0; dev < 32; dev++) {
            for (int func = 0; func < 8; func++) {
                uint16_t vid, did;
                pci_read_config16(bus, dev, func, PCI_VENDOR_ID, &vid);
                pci_read_config16(bus, dev, func, PCI_DEVICE_ID, &did);
                
                if (vid == vendor_id && did == device_id) {
                    printf("找到设备: bus=%d, dev=%d, func=%d\n", 
                           bus, dev, func);
                    return (bus << 16) | (dev << 8) | func;
                }
            }
        }
    }
    return -1;  // 没找到
}
小技巧:PCI配置空间的前64字节是标准化的,但后面的256字节是设备相关的。读取BAR地址时,记得先写全1再读回来,这样才能得到BAR空间的大小。

配置BAR空间时,我最常用的做法是:

uint64_t bar_addr;
uint32_t bar_size;

// 读取BAR0
pci_read_config32(bus, dev, func, PCI_BAR0, &bar_addr);
// 计算BAR大小
pci_write_config32(bus, dev, func, PCI_BAR0, 0xFFFFFFFF);
pci_read_config32(bus, dev, func, PCI_BAR0, &bar_size);
bar_size = ~(bar_size & 0xFFFFFFF0) + 1;
// 恢复原始值
pci_write_config32(bus, dev, func, PCI_BAR0, bar_addr);

// 映射到虚拟地址
uintptr_t va = mmap_device_io(bar_size, bar_addr);
if (va == MAP_DEVICE_FAILED) {
    perror("mmap_device_io failed");
    return -1;
}
警告mmap_device_io()mmap_device_memory() 是有区别的。前者用于IO空间,后者用于内存空间。搞混了会导致系统崩溃,我亲眼见过同事因为这个把开发板搞死机了。

三、中断处理机制

中断处理是驱动开发中最容易出问题的环节。QNX的中断处理流程是这样的:

  1. 注册中断处理函数
  2. 中断到来时,内核调用处理函数
  3. 处理函数在中断上下文中运行,不能阻塞
  4. 如果需要做耗时操作,用 InterruptAttachEvent() 配合事件通知

来看一个典型的中断注册代码:

int irq = pci_get_irq(bus, dev, func);  // 获取中断号

// 注册中断处理函数
int ret = InterruptAttach(irq, my_isr, NULL, 0, 0);
if (ret != 0) {
    perror("InterruptAttach failed");
    return -1;
}

// 中断处理函数
const struct sigevent *my_isr(void *arg, int id) {
    // 读取中断状态寄存器
    uint32_t status = in32(INT_STATUS_REG);
    
    // 清除中断
    out32(INT_CLEAR_REG, status);
    
    // 返回事件通知
    return NULL;  // 或者返回事件对象
}

为什么会这样设计?因为中断处理函数必须快进快出。我见过有人在中断里做内存分配,结果系统直接死锁。记住:中断里只做最必要的事

如果你需要做复杂处理,可以用 InterruptAttachEvent()

struct sigevent event;
SIGEV_PULSE_INIT(&event, connect_id, priority, code, value);

InterruptAttachEvent(irq, &event, 0);

// 在消息循环中接收脉冲
while (1) {
    struct _pulse pulse;
    MsgReceivePulse(chid, &pulse, sizeof(pulse), NULL);
    // 处理中断事件
}
核心要点:中断处理函数不能调用任何可能阻塞的函数,比如 printf()malloc()mutex_lock() 等。我曾经在调试时加了个 printf(),结果系统直接卡死,查了三天才发现。

四、DMA传输原理

DMA(直接内存访问)是高性能驱动的必备技能。说白了,就是让硬件自己搬数据,CPU在旁边喝茶。

QNX的DMA编程主要涉及这几个步骤:

  1. 分配DMA缓冲区(物理连续)
  2. 设置DMA描述符
  3. 启动DMA传输
  4. 等待传输完成(中断或轮询)

分配DMA缓冲区时,一定要用 mmap_device_memory() 或者 posix_memalign() 配合 mlock()

// 分配DMA缓冲区
void *dma_buf = mmap_device_memory(NULL, BUF_SIZE, 
                                   PROT_READ | PROT_WRITE, 0,
                                   phys_addr);
if (dma_buf == MAP_DEVICE_FAILED) {
    perror("mmap_device_memory failed");
    return -1;
}

// 或者用posix_memalign
void *buf;
posix_memalign(&buf, 4096, BUF_SIZE);  // 4K对齐
mlock(buf, BUF_SIZE);  // 锁定物理内存

设置DMA描述符时,要注意字节对齐地址边界。很多DMA控制器要求源地址和目的地址按4字节或8字节对齐:

typedef struct {
    uint32_t src_addr;   // 源地址
    uint32_t dst_addr;   // 目的地址
    uint32_t length;     // 传输长度
    uint32_t control;    // 控制位
} dma_descriptor_t;

dma_descriptor_t desc;
desc.src_addr = (uint32_t)src_phys;
desc.dst_addr = (uint32_t)dst_phys;
desc.length = transfer_size;
desc.control = DMA_CTRL_IE | DMA_CTRL_GO;  // 使能中断,启动传输
避坑指南:我曾经在DMA传输完成后忘记清除中断标志,结果中断一直触发,CPU占用率飙到100%。检查了三天才发现是中断服务函数里少写了一行清除代码。所以,中断服务函数的第一件事就是清除中断标志

启动DMA传输后,可以通过中断或轮询来等待完成:

// 中断方式
// 在中断处理函数中检查DMA完成标志

// 轮询方式
while (in32(DMA_STATUS_REG) & DMA_BUSY) {
    // 等待,但不要空转
    nanospin_ns(100);  // 100纳秒的延迟
}

嗯,这里要提醒一下:轮询方式虽然简单,但会占用CPU。如果传输频繁,建议用中断方式。我在一个视频采集驱动里用过轮询,结果CPU占用率直接飙到80%,后来改成中断方式,降到了5%以下。

总结

驱动开发其实没那么神秘。你只要掌握了资源管理器框架、PCI枚举、中断处理和DMA传输这四板斧,大部分设备驱动都能搞定。记住:

  • 驱动是进程,不是内核模块
  • PCI枚举要遍历所有总线设备功能
  • 中断处理要快,别在里面干重活
  • DMA缓冲区要物理连续,注意对齐

下一章我们会讲具体的调试工具使用,到时候我会带大家实战分析一个真实的驱动问题。今天就到这里,有问题随时问我。