字符设备(Character Device)本质上是 Linux 内核向用户态暴露的一类字节流访问接口。它不像块设备那样以 block 为单位随机寻址,而是强调顺序读写、控制命令、状态交互、事件通知。
典型字符设备包括:
UART 串口 /dev/ttyS0
GPIO /dev/gpiochip0
I2C 适配器 /dev/i2c-0
watchdog /dev/watchdog
自定义 FPGA / MCU 控制节点
其核心价值不是“存储”,而是将硬件能力抽象成文件语义。
一、字符设备的内核抽象本质
Linux 一切皆文件,字符设备最终都会映射到 /dev/xxx 节点。
用户态:
fd = open("/dev/mydev", O_RDWR);read(fd, buf, 128);write(fd, data, len);ioctl(fd, CMD, arg);
内核态最终对应的是:struct file_operations
VFS 在 sys_open -> do_filp_open -> vfs_open 路径中,依据 inode 中的 major/minor 找到目标 cdev,再绑定驱动提供的操作函数。字符设备的核心就是把用户系统调用路由到你的回调。(Linux Kernel Labs)
二、核心数据结构:cdev + file_operations
1)file_operations:驱动行为入口
最核心结构:
static const struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .release = my_release, .read = my_read, .write = my_write, .unlocked_ioctl = my_ioctl, .poll = my_poll, .mmap = my_mmap,};
它定义了字符设备所有“文件行为”。
常见映射:
这也是面试高频问题:
为什么驱动能像文件一样访问?
答案就是:VFS 把文件系统抽象层和设备驱动通过 file_operations 解耦了。
2)cdev:字符设备注册对象
struct cdev my_cdev;
初始化:
cdev_init(&my_cdev, &my_fops);my_cdev.owner = THIS_MODULE;
注册:cdev_add(&my_cdev, devno, 1);
cdev 本质是字符设备在内核中的“方法表绑定实体”。
VFS 根据设备号找到 inode->i_cdev,再拿到 fops。(Linux Kernel Labs)
三、设备号机制:major/minor
字符设备识别靠:dev_t devno;
拆分:
例如:alloc_chrdev_region(&devno, 0, 1, "mydev");
动态分配 major 更安全。
查看:
cat /proc/devicesls -l /dev/mydev
设备号本质是:VFS → 驱动实例的路由索引
例如一个驱动管理 8 个串口:
minor 常用来区分不同 channel、port、FPGA queue、DMA ring。
四、完整驱动初始化流程
这是最标准模板:
staticint __init my_init(void){ alloc_chrdev_region(&devno, 0, 1, "mydev"); cdev_init(&my_cdev, &my_fops); cdev_add(&my_cdev, devno, 1); cls = class_create("myclass"); device_create(cls, NULL, devno, NULL, "mydev"); return 0;}
卸载:
static void __exit my_exit(void){ device_destroy(cls, devno); class_destroy(cls); cdev_del(&my_cdev); unregister_chrdev_region(devno, 1);}
完整链路:
alloc_chrdev_region ↓cdev_add ↓class_create ↓device_create ↓udev 创建设备节点 ↓/dev/mydev
这里 device_create() 会挂接到 driver model 和 sysfs。(Linux内核文档)
五、read/write 的真实执行原理
1)从用户态到内核缓冲区
ssize_tmy_read(struct file *f, char __user *buf, size_t len, loff_t *off){ copy_to_user(buf, kbuf, len); return len;}
写:copy_from_user(kbuf, buf, len);
这里必须强调:不能直接解引用用户态指针
错误写法:buf[0] = kbuf[0];
必须通过:
copy_to_user()copy_from_user()
否则:
页错误不可恢复
非法地址 panic
SMAP/PAN 用户隔离异常
2)为什么 copy_to_user 可能睡眠
因为用户页可能未映射,需要缺页异常补页。
所以:持 spinlock 时不能调用 copy_to_user
这是驱动性能问题和死锁问题高发点。
六、ioctl:控制面的核心接口
很多硬件控制不是 read/write 能表达的。
例如:
设置波特率
配置 DMA ring
获取设备温度
FPGA 模式切换
驱动 debug 开关
这时使用:
longmy_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
例如:
#define MY_SET_MODE _IOW('M', 1, int)switch (cmd) {case MY_SET_MODE: copy_from_user(&mode, (int __user *)arg, sizeof(mode)); break;}
你的系统研发背景里,这块往往用于:
七、高性能字符驱动的关键设计
结合你常做的 ARM/x86 平台 bring-up,这部分最关键。
1)阻塞与非阻塞 I/O
驱动常用等待队列:wait_queue_head_t wq;
无数据:wait_event_interruptible(wq, data_ready);
有中断数据后:wake_up_interruptible(&wq);
这就是:
串口阻塞读
传感器事件
FPGA 中断完成
netlink-like control fd
核心机制。
2)poll/epoll 支持
高并发必须支持:
unsigned int my_poll(struct file *f, poll_table *wait)
注册等待:poll_wait(f, &wq, wait);
返回:return POLLIN | POLLRDNORM;
否则用户态无法高效 epoll。
很多低质量驱动只做 read/write,不做 poll,最终在业务层被迫轮询,CPU 飙高。
八、mmap 零拷贝:高性能驱动核心
对于 DMA、共享环形队列,常直接映射到用户态:
intmy_mmap(structfile *f, struct vm_area_struct *vma)
典型场景:
DMA buffer
FPGA BAR
huge ring queue
packet shared memory
video frame
映射后用户态直接访问:ptr = mmap(NULL, size, ...);
避免:设备 → 内核buffer → 用户buffer
改成:设备DMA → 用户共享区
这是高性能系统里非常核心的优化路径。
九、常见性能陷阱与坑点
1)open 中做重操作
错误:open() 里初始化硬件、复位 DMA、申请大内存
这会导致:
建议只做 lightweight refcount。
2)锁粒度错误
典型错误:
mutex_lock();copy_to_user();mutex_unlock();
如果用户页 fault,锁持有时间极长。
正确做法:
锁内取快照锁外 copy_to_user
3)设备卸载竞争
必须处理:fd 未关闭 模块已 rmmod
依赖:.owner = THIS_MODULE
增加 module refcount。
否则非常容易 UAF。
十、字符设备驱动本质
字符设备驱动的本质可以浓缩为一句话:
通过 cdev + file_operations,将硬件能力挂接到 VFS 文件语义,让用户态以 open/read/write/ioctl/poll/mmap 的统一接口访问设备。
它的难点是:
并发模型
中断与阻塞唤醒
零拷贝
DMA 一致性
生命周期管理
多实例 minor 设计