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;
}
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 整体流程与测试思路
驱动写完了,怎么验证?我画了一张图,把数据流和接口调用关系理清楚。你照着这个思路去写测试程序,基本不会跑偏。
测试时,我建议你写一个简单的 C 程序:open 设备,ioctl 设置波特率为 115200,然后 write 一段字符串,再 read 回来,对比是否一致。如果一致,说明驱动基本没问题。
- 环形缓冲区管理是串口驱动的基石,头尾指针运算一定要用位与操作(& (size-1)),size 必须是 2 的幂。
- ioctl 里一定要做参数校验,否则用户传个非法值,驱动可能直接崩掉。
- 虚拟驱动先跑通,再上真硬件。这个习惯能帮你省下至少一半的调试时间。
嗯,这一章的内容就到这里。代码量不大,但每一行都是实际项目中会遇到的。你照着这个框架去写,遇到问题再回头看看数据流图,应该能很快定位到问题所在。