4. 串口驱动编写实战:基于虚拟串口驱动示例,实现 open/read/write/ioctl 接口

好,我们直接进入正题。上一章我们把串口协议和硬件寄存器梳理了一遍,这一章就来点实在的——动手写一个串口驱动。不过,咱们不拿真实硬件练手,那样调试起来太折腾。我习惯先用虚拟串口驱动把逻辑跑通,再移植到真芯片上。你想想看,如果虚拟环境里都调不通,上了真板子只会更头疼。

4.1 驱动框架与数据结构设计

写驱动之前,先把架子搭好。一个标准的字符设备驱动,核心就是 file_operations 结构体。我们得实现四个接口:open、read、write、ioctl。说白了,用户空间调什么,内核就响应什么。

我个人习惯先定义设备结构体,把所有状态都封装进去。这样代码清晰,也方便后期扩展。

#define VIRTUAL_UART_BUFFER_SIZE  1024
#define VIRTUAL_UART_DEVICE_NAME  "vserial"

struct virtual_uart_device {
    struct cdev cdev;               /* 字符设备结构 */
    struct mutex lock;              /* 互斥锁,防止并发 */
    unsigned char rx_buffer[VIRTUAL_UART_BUFFER_SIZE];  /* 接收缓冲区 */
    unsigned char tx_buffer[VIRTUAL_UART_BUFFER_SIZE];  /* 发送缓冲区 */
    int rx_head, rx_tail;           /* 环形缓冲区指针 */
    int tx_head, tx_tail;
    int baud_rate;                  /* 波特率,ioctl 会用到 */
    int data_bits;                  /* 数据位 */
    int stop_bits;                  /* 停止位 */
    char parity;                    /* 校验位:'N','E','O' */
    bool device_open;               /* 打开标志 */
};

这里用了环形缓冲区,为什么?我在项目中遇到过,如果直接用线性缓冲区,读写指针管理起来容易出 bug,尤其是中断和用户态并发访问时。环形缓冲区配合头尾指针,能省不少心。

4.2 open 接口实现

open 是最简单的,但也是最容易漏事的。你想想看,用户程序 open("/dev/vserial", O_RDWR) 时,驱动得做什么?

  • 检查设备是否已经被占用(防止重复打开)
  • 初始化缓冲区状态(清空旧数据)
  • 递增模块引用计数
static int virtual_uart_open(struct inode *inode, struct file *filp)
{
    struct virtual_uart_device *vuart = container_of(inode->i_cdev,
                                        struct virtual_uart_device, cdev);
    filp->private_data = vuart;

    mutex_lock(&vuart->lock);

    if (vuart->device_open) {
        mutex_unlock(&vuart->lock);
        return -EBUSY;  /* 设备忙,返回错误 */
    }

    vuart->device_open = true;
    vuart->rx_head = vuart->rx_tail = 0;
    vuart->tx_head = vuart->tx_tail = 0;

    mutex_unlock(&vuart->lock);

    printk(KERN_INFO "virtual_uart: device opened\n");
    return 0;
}
小技巧: 用 filp->private_data 保存设备结构体指针,这样 read/write 里直接取出来用,不用再满世界找 inode 了。

4.3 read 与 write 接口实现

这两个接口是重头戏。虚拟串口没有硬件中断,所以数据怎么来?我设计了一个简单的机制:write 把数据写入发送缓冲区,同时模拟数据“回环”到接收缓冲区。这样 read 就能读到刚才写的内容。说白了,就是自发自收,用来验证驱动逻辑。

static ssize_t virtual_uart_read(struct file *filp, char __user *buf,
                                 size_t count, loff_t *f_pos)
{
    struct virtual_uart_device *vuart = filp->private_data;
    int ret, available, bytes_to_read;

    mutex_lock(&vuart->lock);

    /* 计算接收缓冲区中可读数据量 */
    available = (vuart->rx_tail - vuart->rx_head) &
                (VIRTUAL_UART_BUFFER_SIZE - 1);
    if (available == 0) {
        mutex_unlock(&vuart->lock);
        return 0;  /* 无数据可读 */
    }

    bytes_to_read = min(count, (size_t)available);

    /* 从环形缓冲区拷贝到用户空间 */
    if (vuart->rx_head + bytes_to_read < VIRTUAL_UART_BUFFER_SIZE) {
        ret = copy_to_user(buf, &vuart->rx_buffer[vuart->rx_head],
                           bytes_to_read);
    } else {
        /* 处理环形缓冲区回绕 */
        int first_part = VIRTUAL_UART_BUFFER_SIZE - vuart->rx_head;
        ret = copy_to_user(buf, &vuart->rx_buffer[vuart->rx_head],
                           first_part);
        ret += copy_to_user(buf + first_part, vuart->rx_buffer,
                            bytes_to_read - first_part);
    }

    vuart->rx_head = (vuart->rx_head + bytes_to_read) &
                     (VIRTUAL_UART_BUFFER_SIZE - 1);

    mutex_unlock(&vuart->lock);
    return bytes_to_read;
}

write 接口类似,但多了一步:把写入的数据同时拷贝到接收缓冲区,模拟回环。

static ssize_t virtual_uart_write(struct file *filp, const char __user *buf,
                                  size_t count, loff_t *f_pos)
{
    struct virtual_uart_device *vuart = filp->private_data;
    int ret, space, bytes_to_write;

    mutex_lock(&vuart->lock);

    space = VIRTUAL_UART_BUFFER_SIZE - 1 -
            ((vuart->tx_tail - vuart->tx_head) &
             (VIRTUAL_UART_BUFFER_SIZE - 1));
    if (space == 0) {
        mutex_unlock(&vuart->lock);
        return -ENOSPC;  /* 缓冲区满 */
    }

    bytes_to_write = min(count, (size_t)space);

    /* 从用户空间拷贝到发送缓冲区 */
    if (vuart->tx_tail + bytes_to_write < VIRTUAL_UART_BUFFER_SIZE) {
        ret = copy_from_user(&vuart->tx_buffer[vuart->tx_tail],
                             buf, bytes_to_write);
    } else {
        int first_part = VIRTUAL_UART_BUFFER_SIZE - vuart->tx_tail;
        ret = copy_from_user(&vuart->tx_buffer[vuart->tx_tail],
                             buf, first_part);
        ret += copy_from_user(vuart->tx_buffer,
                              buf + first_part,
                              bytes_to_write - first_part);
    }

    vuart->tx_tail = (vuart->tx_tail + bytes_to_write) &
                     (VIRTUAL_UART_BUFFER_SIZE - 1);

    /* 回环:将发送数据同时写入接收缓冲区 */
    /* 这里简化处理,直接拷贝到 rx_buffer */
    memcpy(vuart->rx_buffer, vuart->tx_buffer, bytes_to_write);
    vuart->rx_tail = bytes_to_write;
    vuart->rx_head = 0;

    mutex_unlock(&vuart->lock);
    return bytes_to_write;
}
注意: 回环逻辑只是用来测试的。真实驱动里,write 应该把数据交给硬件发送 FIFO,read 则从接收 FIFO 取数据。千万不要把回环代码直接搬到产品里!

4.4 ioctl 接口实现

ioctl 是驱动和用户态沟通的“万能接口”。串口驱动里,最常用的就是设置波特率、数据位、校验位这些参数。我一般会定义几个命令宏,让用户程序通过 ioctl(fd, CMD, &arg) 来调用。

#define VIRTUAL_UART_SET_BAUD    _IOW('U', 1, int)
#define VIRTUAL_UART_GET_BAUD    _IOR('U', 2, int)
#define VIRTUAL_UART_SET_FORMAT  _IOW('U', 3, struct uart_format)

struct uart_format {
    int data_bits;
    int stop_bits;
    char parity;
};

实现部分,就是根据 cmd 做对应的操作。我曾经在调试一个工控设备时,发现用户程序设置的波特率和驱动实际配置的不一致,查了半天才发现是 ioctl 里没有做参数校验。从那以后,我每个 ioctl 分支都会加边界检查。

static long virtual_uart_ioctl(struct file *filp, unsigned int cmd,
                               unsigned long arg)
{
    struct virtual_uart_device *vuart = filp->private_data;
    int tmp;
    struct uart_format fmt;

    switch (cmd) {
    case VIRTUAL_UART_SET_BAUD:
        if (copy_from_user(&tmp, (int __user *)arg, sizeof(tmp)))
            return -EFAULT;
        if (tmp < 1200 || tmp > 115200)  /* 边界检查 */
            return -EINVAL;
        mutex_lock(&vuart->lock);
        vuart->baud_rate = tmp;
        mutex_unlock(&vuart->lock);
        printk(KERN_INFO "virtual_uart: baud set to %d\n", tmp);
        break;

    case VIRTUAL_UART_GET_BAUD:
        tmp = vuart->baud_rate;
        if (copy_to_user((int __user *)arg, &tmp, sizeof(tmp)))
            return -EFAULT;
        break;

    case VIRTUAL_UART_SET_FORMAT:
        if (copy_from_user(&fmt, (struct uart_format __user *)arg,
                           sizeof(fmt)))
            return -EFAULT;
        if (fmt.data_bits < 5 || fmt.data_bits > 8)
            return -EINVAL;
        if (fmt.stop_bits < 1 || fmt.stop_bits > 2)
            return -EINVAL;
        if (fmt.parity != 'N' && fmt.parity != 'E' && fmt.parity != 'O')
            return -EINVAL;
        mutex_lock(&vuart->lock);
        vuart->data_bits = fmt.data_bits;
        vuart->stop_bits = fmt.stop_bits;
        vuart->parity = fmt.parity;
        mutex_unlock(&vuart->lock);
        break;

    default:
        return -ENOTTY;  /* 不支持的命令 */
    }

    return 0;
}

4.5 驱动注册与 file_operations 绑定

最后,把上面实现的接口挂到 file_operations 上,然后在模块初始化时注册字符设备。

static const struct file_operations virtual_uart_fops = {
    .owner   = THIS_MODULE,
    .open    = virtual_uart_open,
    .read    = virtual_uart_read,
    .write   = virtual_uart_write,
    .unlocked_ioctl = virtual_uart_ioctl,
};

static int __init virtual_uart_init(void)
{
    /* 分配设备号、注册 cdev、创建设备节点 */
    /* 这里省略了标准模板代码,实际项目中按 Linux 字符设备驱动流程走 */
    printk(KERN_INFO "virtual_uart: driver loaded\n");
    return 0;
}

module_init(virtual_uart_init);
module_exit(virtual_uart_exit);

4.6 整体流程与测试思路

驱动写完了,怎么验证?我画了一张图,把数据流和接口调用关系理清楚。你照着这个思路去写测试程序,基本不会跑偏。

虚拟串口驱动数据流与接口调用关系 用户空间 应用程序 open / read / write / ioctl 内核空间 file_operations virtual_uart_fops 虚拟串口设备 环形缓冲区 系统调用 驱动操作 ioctl 配置参数 波特率 (1200 ~ 115200) 数据位 (5 ~ 8) 停止位 (1 ~ 2) 校验位 (N / E / O) ioctl 命令传递 回环测试机制 write 数据 → 发送缓冲区 同时拷贝到接收缓冲区 → read 返回 数据回环

测试时,我建议你写一个简单的 C 程序:open 设备,ioctl 设置波特率为 115200,然后 write 一段字符串,再 read 回来,对比是否一致。如果一致,说明驱动基本没问题。

核心要点:
  • 环形缓冲区管理是串口驱动的基石,头尾指针运算一定要用位与操作(& (size-1)),size 必须是 2 的幂。
  • ioctl 里一定要做参数校验,否则用户传个非法值,驱动可能直接崩掉。
  • 虚拟驱动先跑通,再上真硬件。这个习惯能帮你省下至少一半的调试时间。

嗯,这一章的内容就到这里。代码量不大,但每一行都是实际项目中会遇到的。你照着这个框架去写,遇到问题再回头看看数据流图,应该能很快定位到问题所在。