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驱动时,有几个坑你八成会踩到。我帮你列出来:
- 路径冲突:如果
/dev/myfirstchar已经存在,resmgr_attach会失败。记得先检查或删除旧节点。 - 权限问题:驱动通常需要root权限才能注册设备。如果你用普通用户跑,会看到
Permission denied。 - 内存泄漏:在
devctl回调里,如果你分配了内存,一定要在合适的时机释放。我见过有人每次devctl都malloc,但从不free,跑了一天系统就挂了。 - 数据拷贝:从用户空间拷贝数据时,一定要检查
nbytes是否足够大。否则可能越界访问内核内存,导致系统panic。
核心要点回顾:
- 一个char驱动 = 注册 + 回调 + 注销
resmgr_attach注册设备,resmgr_detach注销设备devctl是驱动与应用通信的主要接口,命令码要正确使用方向宏- 调试时多打印,排查问题快人一步
好了,这一章的内容就到这里。你把这个驱动编译出来跑一下,看看能不能打开设备、发送命令。下一章咱们会在这个基础上,加上read/write接口,让驱动真正能干点实事。
记住,写驱动就像搭积木,先搭好骨架,再慢慢填肉。别急,慢慢来。