实战:编写第一个UIO驱动

好,咱们直接动手。这一章我会带你从零搭建一个完整的UIO驱动——从设备树配置、内核模块代码,到用户态程序框架。说白了,就是让你亲眼看看用户态驱动到底怎么跑起来的。

我个人习惯把UIO驱动的开发分成三步走:设备树里先占个坑,内核模块负责把硬件暴露给用户态,最后用户态程序接管一切。每一步都不复杂,但细节容易翻车。我当年第一次写UIO驱动时,就因为在设备树里少配了一个中断属性,折腾了整整一个下午。

设备树配置:给硬件一个身份

设备树(Device Tree)是Linux内核用来描述硬件拓扑的机制。对于UIO来说,我们需要在设备树里声明一个设备节点,告诉内核:这块硬件归UIO管。

来看一个实际的例子。假设我们有一块简单的GPIO控制器,映射在0x43C00000地址上,占用4KB空间,使用中断号42。

// uio-gpio.dtsi
/ {
    uio_gpio: uio-gpio@43c00000 {
        compatible = "generic-uio";
        reg = <0x43c00000 0x1000>;
        interrupts = <0 42 4>;
        interrupt-parent = <&gic>;
    };
};

这里有几个关键点:

  • compatible:必须设为"generic-uio"。这是UIO框架的标准匹配字符串,内核看到它就知道要用UIO驱动来绑定。
  • reg:描述物理地址和大小。0x43C00000是起始地址,0x1000是4KB空间。
  • interrupts:中断描述。第一个0表示SPI中断类型,42是中断号,4是触发方式(高电平触发)。

注意:我曾经在项目里把interrupt-parent写成了中断控制器的别名,结果内核死活不认。后来发现必须用完整的引用路径,比如<&gic>。这个坑踩过一次就记住了。

如果你用的是设备树覆盖(Device Tree Overlay),写法也类似:

// uio-gpio-overlay.dts
/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target = <&amba>;
        __overlay__ {
            uio_gpio: uio-gpio@43c00000 {
                compatible = "generic-uio";
                reg = <0x43c00000 0x1000>;
                interrupts = <0 42 4>;
                interrupt-parent = <&gic>;
            };
        };
    };
};

内核模块代码:UIO驱动的骨架

设备树配好了,接下来写内核模块。UIO内核模块的核心工作就两件:注册UIO设备和实现中断处理。其他事情——比如内存映射、文件操作——UIO框架都帮你搞定了。

来看一个最小化的UIO驱动代码:

// uio_gpio.c
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/uio_driver.h>
#include <linux/interrupt.h>

static struct uio_info gpio_uio_info = {
    .name = "uio_gpio",
    .version = "1.0",
    .irq = UIO_IRQ_CUSTOM,  // 使用自定义中断处理
};

static irqreturn_t gpio_uio_handler(int irq, struct uio_info *dev_info)
{
    // 这里处理硬件中断
    // 实际项目中需要读取硬件状态寄存器确认中断源
    printk(KERN_INFO "UIO GPIO interrupt received\n");
    return IRQ_HANDLED;
}

static int gpio_uio_probe(struct platform_device *pdev)
{
    struct resource *res;
    int ret;

    // 获取设备树中的内存资源
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    if (!res) {
        dev_err(&pdev->dev, "No memory resource\n");
        return -ENODEV;
    }

    // 设置UIO内存映射信息
    gpio_uio_info.mem[0].addr = res->start;
    gpio_uio_info.mem[0].size = resource_size(res);
    gpio_uio_info.mem[0].memtype = UIO_MEM_PHYS;

    // 设置中断处理函数
    gpio_uio_info.handler = gpio_uio_handler;
    gpio_uio_info.irq = platform_get_irq(pdev, 0);

    // 注册UIO设备
    ret = uio_register_device(&pdev->dev, &gpio_uio_info);
    if (ret) {
        dev_err(&pdev->dev, "UIO registration failed\n");
        return ret;
    }

    platform_set_drvdata(pdev, &gpio_uio_info);
    dev_info(&pdev->dev, "UIO GPIO driver probed\n");
    return 0;
}

static int gpio_uio_remove(struct platform_device *pdev)
{
    struct uio_info *info = platform_get_drvdata(pdev);
    uio_unregister_device(info);
    dev_info(&pdev->dev, "UIO GPIO driver removed\n");
    return 0;
}

static const struct of_device_id gpio_uio_of_match[] = {
    { .compatible = "generic-uio" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, gpio_uio_of_match);

static struct platform_driver gpio_uio_driver = {
    .probe = gpio_uio_probe,
    .remove = gpio_uio_remove,
    .driver = {
        .name = "uio_gpio",
        .of_match_table = gpio_uio_of_match,
    },
};

module_platform_driver(gpio_uio_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("UIO GPIO Driver Example");

这段代码里,我特别想强调几个地方:

  • UIO_IRQ_CUSTOM:告诉UIO框架,中断处理由我们自己实现。如果你不需要中断,可以设为UIO_IRQ_NONE。
  • mem[0]:UIO支持最多5个内存区域。对于大多数简单设备,一个就够了。
  • platform_get_irq:从设备树中读取中断号。注意返回值检查,我见过有人忘了检查,结果中断号是负数还照用不误。

小技巧:调试UIO内核模块时,可以用modprobe uio_gpio加载,然后用ls /dev/uio*查看生成的设备节点。如果没看到,多半是设备树匹配出了问题。

用户态程序框架:接管硬件控制权

内核模块写好了,设备节点也生成了。现在轮到用户态程序登场。用户态程序要做的事情很直接:打开设备文件、映射内存、处理中断、操作硬件。

来看一个完整的用户态程序框架:

// uio_app.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <string.h>
#include <errno.h>

#define UIO_DEVICE "/dev/uio0"
#define MAP_SIZE   0x1000  // 4KB

int main(int argc, char *argv[])
{
    int fd;
    void *map_base;
    volatile uint32_t *reg_base;
    int irq_count;

    // 1. 打开UIO设备
    fd = open(UIO_DEVICE, O_RDWR);
    if (fd < 0) {
        fprintf(stderr, "Failed to open %s: %s\n", 
                UIO_DEVICE, strerror(errno));
        return -1;
    }

    // 2. 映射硬件寄存器到用户空间
    map_base = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE,
                    MAP_SHARED, fd, 0);
    if (map_base == MAP_FAILED) {
        fprintf(stderr, "mmap failed: %s\n", strerror(errno));
        close(fd);
        return -1;
    }

    reg_base = (volatile uint32_t *)map_base;
    printf("Hardware registers mapped at %p\n", map_base);

    // 3. 读取硬件寄存器(示例:读取ID寄存器)
    uint32_t id = reg_base[0];
    printf("Hardware ID: 0x%08x\n", id);

    // 4. 等待中断
    printf("Waiting for interrupt...\n");
    if (read(fd, &irq_count, sizeof(irq_count)) < 0) {
        fprintf(stderr, "read failed: %s\n", strerror(errno));
    } else {
        printf("Interrupt received! Count: %d\n", irq_count);
    }

    // 5. 清理
    munmap(map_base, MAP_SIZE);
    close(fd);
    return 0;
}

这个程序虽然简单,但包含了UIO用户态编程的所有核心步骤:

  1. 打开设备:/dev/uio0是第一个UIO设备,如果有多个,依次是uio1、uio2...
  2. 内存映射:mmap把物理地址映射到用户空间,之后就可以直接读写寄存器了。
  3. 中断处理:对UIO设备文件执行read操作,会阻塞等待中断。中断发生后,read返回中断次数。

核心要点:UIO的中断机制很巧妙。用户态程序通过read等待中断,内核在中断发生时唤醒用户态进程。这样用户态程序就能以事件驱动的方式响应硬件,而不是轮询。

编译与测试

最后,咱们看看怎么编译和测试这套东西。

内核模块的Makefile:

obj-m += uio_gpio.o

all:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

用户态程序的编译就简单了:

gcc -o uio_app uio_app.c

测试步骤:

  1. 加载内核模块:sudo insmod uio_gpio.ko
  2. 检查设备节点:ls -l /dev/uio0
  3. 运行用户态程序:sudo ./uio_app
  4. 触发硬件中断(比如按个按钮),观察程序输出

注意:访问UIO设备需要root权限,或者把用户加入uio组。我建议在开发阶段直接用sudo,但生产环境一定要做好权限控制。

好了,第一个UIO驱动就这么写完了。你想想看,整个过程其实就三步:设备树声明、内核模块注册、用户态程序映射。没有复杂的字符设备驱动开发,没有繁琐的ioctl接口设计。UIO把最麻烦的部分都封装好了,让我们能专注于硬件逻辑本身。

下一章,我们会深入UIO的中断处理机制,看看用户态程序如何高效响应硬件事件。到时候我会分享一个我在工业控制项目中遇到的中断丢失问题,以及怎么用UIO的特性来解决它。