3、第一个字符设备驱动:编写最简单的char驱动、注册与注销设备、使用devctl接口

好,咱们今天来点真家伙。

前面两章聊了不少概念,什么内核态用户态、什么资源管理。说实话,光听理论容易犯困。我当年刚学QNX驱动时也是这样,看了三天文档,脑子里还是一团浆糊。直到亲手敲出第一个能跑起来的char驱动,才真正有了感觉。

所以这一章,咱们直接动手。写一个最简单的字符设备驱动。它不干别的,就是能被打开、关闭,并且能通过devctl接口接收几个命令。你想想看,这其实就是所有复杂驱动的骨架。

3.1 驱动骨架:一个最小的char驱动

一个QNX字符设备驱动,说白了就是一个共享库(.so),被io-pkt或者devc-xxx这类进程加载。但咱们自己写的时候,通常把它编译成一个独立的可执行文件,用devc-xxx的框架来跑。

我个人习惯用devc-xxx的模板,因为它把资源管理器那一套封装好了,我们只需要填几个回调函数。

先看代码,这是最精简的版本:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <devctl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/iofunc.h>
#include <sys/dispatch.h>

// 设备路径
#define DEVICE_NAME  "/dev/myfirstchar"

// 设备属性结构体
typedef struct {
    iofunc_attr_t attr;
    // 这里可以放你的私有数据
    int my_data;
} my_device_t;

// 打开设备时的回调
static int my_open(resmgr_context_t *ctp, io_open_t *msg,
                   RESMGR_OCB_T *ocb, iofunc_attr_t *attr) {
    // 这里可以做一些初始化工作
    printf("my_open: device opened\n");
    return iofunc_open_default(ctp, msg, ocb, attr);
}

// 关闭设备时的回调
static int my_close(resmgr_context_t *ctp, io_close_t *msg,
                    RESMGR_OCB_T *ocb, iofunc_attr_t *attr) {
    printf("my_close: device closed\n");
    return iofunc_close_default(ctp, msg, ocb, attr);
}

// devctl 回调——核心就在这里
static int my_devctl(resmgr_context_t *ctp, io_devctl_t *msg,
                     RESMGR_OCB_T *ocb, iofunc_attr_t *attr) {
    my_device_t *dev = (my_device_t *)attr;
    int status = EOK;

    // 获取命令码
    int cmd = msg->i.dcmd;

    switch (cmd) {
        case MY_CMD_HELLO:
            printf("devctl: Hello from kernel driver!\n");
            break;

        case MY_CMD_SET_DATA:
            // 从用户空间拷贝数据
            if (msg->i.nbytes >= sizeof(int)) {
                memcpy(&dev->my_data, &msg->i.data[0], sizeof(int));
                printf("devctl: set data to %d\n", dev->my_data);
            }
            break;

        case MY_CMD_GET_DATA:
            // 把数据拷贝回用户空间
            if (msg->i.nbytes >= sizeof(int)) {
                memcpy(&msg->o.data[0], &dev->my_data, sizeof(int));
                msg->o.nbytes = sizeof(int);
                printf("devctl: get data %d\n", dev->my_data);
            }
            break;

        default:
            status = ENOTTY;  // 不认识这个命令
            break;
    }

    // 设置返回值
    msg->o.ret_val = status;
    return _RESMGR_PTR(ctp, &msg->o, sizeof(msg->o));
}

int main(int argc, char **argv) {
    my_device_t *dev;
    dispatch_t *dpp;
    resmgr_attr_t resmgr_attr;
    resmgr_connect_funcs_t connect_funcs;
    resmgr_io_funcs_t io_funcs;
    iofunc_attr_t attr;
    int id;

    // 1. 初始化调度器
    dpp = dispatch_create();
    if (dpp == NULL) {
        perror("dispatch_create");
        return EXIT_FAILURE;
    }

    // 2. 初始化资源管理器属性
    memset(&resmgr_attr, 0, sizeof(resmgr_attr));
    resmgr_attr.nparts_max = 1;
    resmgr_attr.msg_max_size = 2048;

    // 3. 绑定回调函数
    iofunc_func_init(_RESMGR_CONNECT_NFUNCS, &connect_funcs,
                     _RESMGR_IO_NFUNCS, &io_funcs);
    connect_funcs.open = my_open;
    io_funcs.close = my_close;
    io_funcs.devctl = my_devctl;

    // 4. 分配设备结构体
    dev = (my_device_t *)malloc(sizeof(my_device_t));
    if (dev == NULL) {
        perror("malloc");
        return EXIT_FAILURE;
    }

    // 5. 初始化属性
    iofunc_attr_init(&dev->attr, S_IFCHR | 0666, 0, 0);
    dev->my_data = 0;

    // 6. 注册设备
    id = resmgr_attach(dpp, &resmgr_attr, DEVICE_NAME,
                       _FTYPE_ANY, 0,
                       &connect_funcs, &io_funcs,
                       &dev->attr);
    if (id == -1) {
        perror("resmgr_attach");
        return EXIT_FAILURE;
    }

    printf("Device %s registered successfully.\n", DEVICE_NAME);

    // 7. 进入事件循环
    dispatch_block(dpp);

    // 正常情况下不会走到这里
    resmgr_detach(dpp, id, 0);
    free(dev);
    return EXIT_SUCCESS;
}

嗯,代码有点长,但别怕。核心逻辑其实就三块:注册、回调、注销

3.2 注册与注销设备

注册设备,靠的是resmgr_attach()这个函数。它告诉系统:嘿,我要在/dev/myfirstchar这个路径上创建一个设备节点,谁来访问它,就调用我提供的回调函数。

参数里有个细节:S_IFCHR | 0666。这表示这是一个字符设备,权限是666,也就是所有人都能读写。我在项目中遇到过,有些新手图省事直接给777,结果被安全审计追着改。其实666就够了,除非你真的需要执行权限。

注销设备更简单:

resmgr_detach(dpp, id, 0);

这个函数会把设备节点从文件系统中移除。注意那个id,就是resmgr_attach返回的句柄,你得把它存好。我曾经犯过一个低级错误——把id声明成局部变量,结果在另一个函数里想注销设备时找不到了。嗯,后来我习惯把它放在全局结构体里。

注意: 注销设备前,要确保没有进程正在使用它。否则系统可能会崩溃。安全做法是:先检查引用计数,或者用iofunc_attr_t里的count字段判断。

3.3 devctl接口:驱动与应用的通信桥梁

驱动写好了,应用怎么跟它通信?

你可能会想到read/write。没错,那也是一种方式。但devctl更灵活。它本质上是一个控制通道,可以传命令、传数据、传结构体,什么都能干。

应用端的调用方式是这样的:

#include <devctl.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

// 自定义命令码
#define MY_CMD_HELLO    __DIOT(_DCMD_CHAR, 1, int)
#define MY_CMD_SET_DATA __DIOT(_DCMD_CHAR, 2, int)
#define MY_CMD_GET_DATA __DIOUT(_DCMD_CHAR, 3, int)

int main() {
    int fd = open("/dev/myfirstchar", O_RDWR);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 发送HELLO命令
    devctl(fd, MY_CMD_HELLO, NULL, 0, NULL);

    // 设置数据
    int value = 42;
    devctl(fd, MY_CMD_SET_DATA, &value, sizeof(value), NULL);

    // 获取数据
    int result = 0;
    devctl(fd, MY_CMD_GET_DATA, &result, sizeof(result), NULL);
    printf("Got data: %d\n", result);

    close(fd);
    return 0;
}

这里有个关键点:命令码的定义。你看__DIOT__DIOUT这些宏,它们不光是个数字,还编码了数据传输方向。

  • __DIOT:双向传输(Input and Output)
  • __DIOUT:从驱动输出到应用(Output)
  • __DIOR:从应用输入到驱动(Input)

为什么要这么麻烦?因为QNX的devctl机制会检查权限。如果你定义了一个只读命令,应用却想写数据,系统会直接返回错误。我在项目中遇到过,有人把所有命令都定义成__DIOT,结果调试时发现数据没写进去,查了半天才发现是方向搞反了。

小技巧: 调试devctl时,可以在驱动里加打印,把msg->i.dcmd打印出来。这样你就能看到应用到底发了什么命令过来。我经常用这招排查命令码不匹配的问题。

3.4 避坑指南

写第一个char驱动时,有几个坑你八成会踩到。我帮你列出来:

  1. 路径冲突:如果/dev/myfirstchar已经存在,resmgr_attach会失败。记得先检查或删除旧节点。
  2. 权限问题:驱动通常需要root权限才能注册设备。如果你用普通用户跑,会看到Permission denied
  3. 内存泄漏:在devctl回调里,如果你分配了内存,一定要在合适的时机释放。我见过有人每次devctlmalloc,但从不free,跑了一天系统就挂了。
  4. 数据拷贝:从用户空间拷贝数据时,一定要检查nbytes是否足够大。否则可能越界访问内核内存,导致系统panic。

核心要点回顾:

  • 一个char驱动 = 注册 + 回调 + 注销
  • resmgr_attach 注册设备,resmgr_detach 注销设备
  • devctl 是驱动与应用通信的主要接口,命令码要正确使用方向宏
  • 调试时多打印,排查问题快人一步

好了,这一章的内容就到这里。你把这个驱动编译出来跑一下,看看能不能打开设备、发送命令。下一章咱们会在这个基础上,加上read/write接口,让驱动真正能干点实事。

记住,写驱动就像搭积木,先搭好骨架,再慢慢填肉。别急,慢慢来。