4、字符设备驱动实战:编写第一个字符设备驱动

好,咱们直接进入正题。字符设备驱动,说白了就是NuttX里最简单、最基础的一类驱动。你想想看,串口、GPIO、LED,这些基本都是字符设备。我刚开始接触NuttX时,第一个练手的驱动就是字符设备。今天我就带你走一遍完整的流程——从注册到回调,从内核到用户空间。

4.1 驱动框架的核心:file_operations结构体

在NuttX里,字符设备驱动的灵魂是什么?就是file_operations这个结构体。它定义了驱动和用户程序之间的接口。说白了,用户程序调用的open()read()write(),最终都会落到你注册的回调函数上。

来看一个最简的结构体定义:

#include <nuttx/fs/fs.h>

static int mydrv_open(FAR struct file *filep);
static int mydrv_close(FAR struct file *filep);
static ssize_t mydrv_read(FAR struct file *filep, FAR char *buffer,
                          size_t buflen);
static ssize_t mydrv_write(FAR struct file *filep, FAR const char *buffer,
                           size_t buflen);
static int mydrv_ioctl(FAR struct file *filep, int cmd, unsigned long arg);

static const struct file_operations g_mydrv_fops = {
    .open  = mydrv_open,
    .close = mydrv_close,
    .read  = mydrv_read,
    .write = mydrv_write,
    .ioctl = mydrv_ioctl,
};

嗯,这里要注意:file_operations里的每个成员都是函数指针。你不需要全部实现,但openclose我建议至少给个空函数。为什么?因为NuttX的VFS层在打开设备节点时会检查这些指针是否为NULL,如果为NULL可能会出问题。我在项目中就踩过这个坑——只实现了readwrite,结果open没写,用户程序一打开设备就崩了。

4.2 注册驱动:register_driver()

结构体定义好了,接下来要把它注册到系统中。NuttX提供了register_driver()这个API:

#include <nuttx/fs/fs.h>

int register_driver(FAR const char *path,
                    FAR const struct file_operations *fops,
                    mode_t mode, FAR void *priv);

参数说明:

参数 说明
path 设备节点路径,比如"/dev/mydrv"
fops 你定义的file_operations结构体指针
mode 访问权限,通常用0666
priv 私有数据指针,可以传你的设备结构体

我个人习惯在驱动初始化函数里调用它:

int mydrv_register(void)
{
    int ret;
    ret = register_driver("/dev/mydrv", &g_mydrv_fops, 0666, NULL);
    if (ret < 0) {
        ferr("ERROR: failed to register driver: %d\n", ret);
        return ret;
    }
    finfo("mydrv registered at /dev/mydrv\n");
    return OK;
}

这里有个小细节:register_driver()的返回值,负数是错误码,0是成功。我刚开始写的时候老忘了检查返回值,结果设备没注册成功,用户程序打开文件时返回-ENOENT,排查了半天才发现是注册失败了。

4.3 实现回调函数:open/read/write/ioctl

好,核心部分来了。我们逐个实现回调函数。

4.3.1 open回调

open回调在用户程序调用open("/dev/mydrv", O_RDWR)时触发。你可以在这里做初始化工作,比如申请资源、启动硬件等。

static int mydrv_open(FAR struct file *filep)
{
    FAR struct mydrv_dev_s *priv;

    /* 从filep中获取私有数据 */
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    if (priv == NULL) {
        /* 第一次打开,分配设备结构体 */
        priv = (FAR struct mydrv_dev_s *)
               kmm_zalloc(sizeof(struct mydrv_dev_s));
        if (priv == NULL) {
            return -ENOMEM;
        }
        filep->f_priv = priv;
    }

    /* 增加引用计数 */
    priv->refs++;
    finfo("mydrv opened, refs=%d\n", priv->refs);
    return OK;
}

你想想看,为什么要把私有数据挂在filep->f_priv上?因为后续的readwriteioctl回调都能拿到这个filep指针,这样就能访问你的设备状态了。这是NuttX驱动设计的一个惯用模式。

4.3.2 read回调

read回调负责从设备读取数据到用户缓冲区:

static ssize_t mydrv_read(FAR struct file *filep, FAR char *buffer,
                          size_t buflen)
{
    FAR struct mydrv_dev_s *priv;
    ssize_t nread = 0;

    priv = (FAR struct mydrv_dev_s *)filep->f_priv;

    /* 检查缓冲区是否可写 */
    if (!buffer) {
        return -EINVAL;
    }

    /* 从设备读取数据到用户缓冲区 */
    /* 这里假设设备有一个内部缓冲区 */
    while (nread < buflen && priv->rd_pos < priv->data_len) {
        buffer[nread] = priv->data[priv->rd_pos++];
        nread++;
    }

    return nread;
}

注意看,这里有个关键点:buffer是用户空间的地址,你不能直接往里面写数据吗?嗯,在NuttX的某些配置下(比如CONFIG_BUILD_PROTECTED),用户空间和内核空间是隔离的。但好消息是,NuttX的VFS层已经帮你处理了地址转换,你直接读写就行。不过,如果你在中断上下文或者任务间通信时,就要小心了——我建议用copy_to_user()这类API来保证安全。

4.3.3 write回调

write回调正好相反,把用户数据写入设备:

static ssize_t mydrv_write(FAR struct file *filep, FAR const char *buffer,
                           size_t buflen)
{
    FAR struct mydrv_dev_s *priv;
    ssize_t nwritten = 0;

    priv = (FAR struct mydrv_dev_s *)filep->f_priv;

    if (!buffer) {
        return -EINVAL;
    }

    /* 从用户缓冲区拷贝数据到设备 */
    while (nwritten < buflen && priv->wr_pos < MAX_DATA_SIZE) {
        priv->data[priv->wr_pos++] = buffer[nwritten];
        nwritten++;
    }

    return nwritten;
}

4.3.4 ioctl回调

ioctl是控制命令的入口,灵活性最大。你可以用它来设置设备参数、启动停止操作等:

static int mydrv_ioctl(FAR struct file *filep, int cmd, unsigned long arg)
{
    FAR struct mydrv_dev_s *priv;
    int ret = OK;

    priv = (FAR struct mydrv_dev_s *)filep->f_priv;

    switch (cmd) {
    case MYDRV_IOCTL_RESET:
        /* 重置设备 */
        priv->rd_pos = 0;
        priv->wr_pos = 0;
        priv->data_len = 0;
        finfo("mydrv reset\n");
        break;

    case MYDRV_IOCTL_GET_STATUS:
        /* 获取设备状态 */
        if (arg) {
            *(FAR int *)arg = priv->status;
        }
        break;

    default:
        ret = -ENOTTY;  /* 不支持的ioctl命令 */
        break;
    }

    return ret;
}

这里有个经验:ioctl命令号的定义要规范。我一般用_IOC()宏来生成,避免和其他驱动冲突。不过对于简单的驱动,直接用枚举值也行。

4.4 设备节点创建与用户空间交互

驱动注册好了,回调也实现了,怎么让用户程序用起来?很简单,用户程序通过标准的文件操作接口来访问:

/* 用户空间代码示例 */
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>

int fd;
char buf[64];
int status;

fd = open("/dev/mydrv", O_RDWR);
if (fd < 0) {
    printf("Failed to open device\n");
    return -1;
}

/* 写入数据 */
write(fd, "Hello NuttX", 11);

/* 读取数据 */
read(fd, buf, sizeof(buf));

/* 发送ioctl命令 */
ioctl(fd, MYDRV_IOCTL_GET_STATUS, &status);

close(fd);

你发现没有?用户程序根本不知道底层是驱动在干活。它只看到/dev/mydrv这个文件,然后调openreadwrite。这就是NuttX VFS的威力——一切皆文件。

小提示:设备节点路径通常放在/dev/目录下。如果你的驱动是板级特有的,建议在板级初始化代码中调用注册函数。我习惯在board_initialize()里加一行mydrv_register()

4.5 调试技巧与常见错误

写驱动难免遇到问题。我把自己踩过的坑和调试经验分享给你。

4.5.1 调试技巧

  • 善用finfoferr:NuttX提供了分级日志宏。finfo打印调试信息,ferr打印错误。我一般在每个回调函数入口加一句finfo,这样能清楚看到调用流程。
  • 检查返回值:每个系统调用都有返回值。用户程序里一定要检查openreadwrite的返回值。驱动里也要检查register_driver的返回值。
  • ls /dev确认节点存在:在NuttX的shell里执行ls /dev,看看你的设备节点有没有出现。如果没有,说明注册没成功。
  • echocat快速测试:在shell里执行echo "test" > /dev/mydrvcat /dev/mydrv,可以快速验证读写功能。

4.5.2 常见错误

错误现象 可能原因 解决方法
open返回-ENOENT 设备节点未注册 检查register_driver是否调用成功
read返回0 设备缓冲区为空或读位置已到末尾 检查数据是否已写入,检查读写位置指针
write返回0 设备缓冲区已满 检查写位置是否超出缓冲区大小
ioctl返回-ENOTTY 不支持的ioctl命令 检查命令号是否匹配,switch-case是否覆盖
系统崩溃或死机 回调函数中访问了NULL指针 检查filep->f_priv是否为NULL
我曾经踩过的坑:有一次我写了一个驱动,open里分配了内存,但close里忘了释放。结果每次打开设备都分配新内存,内存越用越多,最后系统OOM挂了。所以记住:open里分配的资源,一定要在close里释放。这是驱动开发的基本素养。

4.6 完整驱动示例

最后,给你一个完整的驱动骨架,可以直接拿去用:

#include <nuttx/config.h>
#include <nuttx/fs/fs.h>
#include <debug.h>
#include <errno.h>

#define MAX_DATA_SIZE 256

struct mydrv_dev_s {
    uint8_t data[MAX_DATA_SIZE];
    size_t data_len;
    size_t rd_pos;
    size_t wr_pos;
    int status;
    int refs;
};

static int mydrv_open(FAR struct file *filep)
{
    FAR struct mydrv_dev_s *priv;
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    if (priv == NULL) {
        priv = kmm_zalloc(sizeof(struct mydrv_dev_s));
        if (priv == NULL) return -ENOMEM;
        filep->f_priv = priv;
    }
    priv->refs++;
    return OK;
}

static int mydrv_close(FAR struct file *filep)
{
    FAR struct mydrv_dev_s *priv;
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    if (priv == NULL) return OK;

    priv->refs--;
    if (priv->refs == 0) {
        kmm_free(priv);
        filep->f_priv = NULL;
    }
    return OK;
}

static ssize_t mydrv_read(FAR struct file *filep, FAR char *buffer,
                          size_t buflen)
{
    FAR struct mydrv_dev_s *priv;
    ssize_t nread = 0;
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    while (nread < buflen && priv->rd_pos < priv->data_len) {
        buffer[nread] = priv->data[priv->rd_pos++];
        nread++;
    }
    return nread;
}

static ssize_t mydrv_write(FAR struct file *filep, FAR const char *buffer,
                           size_t buflen)
{
    FAR struct mydrv_dev_s *priv;
    ssize_t nwritten = 0;
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    while (nwritten < buflen && priv->wr_pos < MAX_DATA_SIZE) {
        priv->data[priv->wr_pos++] = buffer[nwritten];
        nwritten++;
    }
    priv->data_len = priv->wr_pos;
    return nwritten;
}

static int mydrv_ioctl(FAR struct file *filep, int cmd, unsigned long arg)
{
    FAR struct mydrv_dev_s *priv;
    priv = (FAR struct mydrv_dev_s *)filep->f_priv;
    switch (cmd) {
    case 0x0001: /* 自定义命令:重置 */
        priv->rd_pos = 0;
        priv->wr_pos = 0;
        priv->data_len = 0;
        break;
    default:
        return -ENOTTY;
    }
    return OK;
}

static const struct file_operations g_mydrv_fops = {
    .open  = mydrv_open,
    .close = mydrv_close,
    .read  = mydrv_read,
    .write = mydrv_write,
    .ioctl = mydrv_ioctl,
};

int mydrv_register(void)
{
    return register_driver("/dev/mydrv", &g_mydrv_fops, 0666, NULL);
}

好了,这就是第一个字符设备驱动的完整实现。你把它编译进NuttX,然后在shell里echo "hello" > /dev/mydrv,再cat /dev/mydrv,就能看到效果了。下一章我们聊聊更复杂的驱动——块设备驱动,到时候会有更多有意思的东西。