Linux中有哪几类设备
为什么网络设备不在 /dev 下?
Linux 里常说"一切皆文件":字符设备、块设备都躺在 /dev 下,open 一下,read/write 就能操作。但是 ls /dev,永远找不到 eth0。网络设备为什么是这句口号的例外?对于这个,我们需要了解/dev 下的节点到底是什么。
/dev 节点是个什么东西?
它本质上是一个映射钩子:文件名 → major/minor 号(主次设备号) → 驱动的 file_operations。
假设我使用open("/dev/ttyS0"),内核靠它身上记的 major 号(主次设备号)找到对应的驱动,之后你在这个 fd 上的 read() / write(),就被转发进驱动里写好的函数。
所以:一个设备能不能放进 /dev,取决于它的操作能不能被塞进 read/write/ioctl 这套文件接口里。字符设备和块设备能,网络设备不能。原因有两个:
原因一:read/write 表达不了"发给谁"
我向 /dev/ttyS0 写数据,字节直接就从这根线出去了——对端是谁是物理决定的,串口线另一头接的就是那一个东西,没有"选择目的地"这回事。
但发网络数据时,你必须指明对方是谁:IP、端口;内核还要查路由表决定走哪块网卡、要不要 ARP。假设我向192.168.x.x:x发送数据,但是根本没有地方放"发到 192.168.1.5 的 x 端口"这个信息。网络需要的是 sendto / recvfrom(带地址)、connect / bind / listen / accept 这一整套更丰富的动作,文件接口的 read/write 装不下。
原因二:一块网卡同时承载着无数条互不相干的"流"
串口是一条流——你 open 它,拿到的就是"那一条"字节流,一个 read 游标读到底。但 eth0 这一个设备,同一时刻可能在跑成千上万条 TCP 连接,还混着 UDP、ICMP,来自不同本地端口、发往不同远端。假设真有个 /dev/eth0 让你 read(),它该返回哪条连接、哪个协议的字节?文件抽象只有一个读写游标,而网卡是把海量逻辑流复用在一起的,这两者天然对不上。
应用程序要的从来不是"网卡这个接口"本身,而是"把这段数据发到那台主机"。这件事是socket + 协议栈干的。网卡驱动待在协议栈下面,职责窄得多:协议栈把一个封装好的数据包(sk_buff)丢给它,它负责放上网线;网线上来一个包,它收下来交给协议栈往上送。驱动自己甚至不知道 TCP 连接的存在。所以暴露一个 /dev/eth0,等于把应用根本不想直接操作的那一层硬塞给它,没有意义,所以/dev下没有网络设备。
硬中断 = 触发源是外部硬件设备——设备有急事时(异步地)拉高中断线、CPU每条指令都会短暂的检测一下,然后被强行打断,提醒它停下手头的事去处理。
软中断 = 触发源是程序自己——程序(同步地)主动执行一条特定指令(svc/syscall),发起一次受控的、不可分割的委托:把控制权交给内核,请内核用内核权限代办自己做不了的事,办完再交还。
字符设备流程
用户态和内核态的边界
用户态和内核态之间有一道边界。我应用里调的 open/read/write,和驱动里写的 my_open/my_read/my_write,是墙两边的两套不同函数。应用碰不到我的函数,全靠内核居中转发。而数据要过墙,只能走 copy_to_user / copy_from_user 这两座桥——buf 这类用户态地址,内核里绝不能直接解引用。
open后的创建的内容
每次 open,内核都新建一张 struct file(下文叫它"便签")。它身上挂着三样关键东西:f_op 指向 file_operations 表(转发就靠它)、private_data 存这次会话的上下文、f_pos 记读写到哪了。便签通过open创建,通过 read/write使用,通过 release销毁。
第一步:注册 —— 让内核找得到你
insmod 加载模块,内核调用入口函数,入口函数里执行 cdev_add,把"设备号 → cdev → fops"这条映射登记进内核的字符设备表。
这里的 fops,是源码里编译期就写死的一张表:
static const struct file_operations my_fops = {
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
注册只做一次,模块在期间一直有效。它是后面 open 能找到你的前提——没注册,open 去查表会查不到,直接报错返回,你的 read/write 根本没机会被调到。
小结:注册 = 在内核表里留下"设备号 → 我的函数表"这一行。
第二步:open —— 接线 + 开场准备
应用 open("/dev/mychar") 进内核后,内核依次做这几件事:
1. 解析路径,从设备节点拿到设备号;
2. 新建一张便签 struct file;
3. 拿设备号去注册表查到 cdev,把 fops 表的地址赋给便签的 f_op(挂指针,不拷内容)——线在这一步接通;
4. 回调 my_open,做这次会话的准备;
5. my_open 返回成功,内核才生成 fd 返回给应用(若返回负值,则打开失败,fd 不发出去)。
查表、接线(第 1~3 步)是内核自动做的;my_open(第 4 步)才是我们需要写的代码,它做的是这次打开的会话准备——典型就是给 private_data 挂上下文。
static int my_open(struct inode *inode, struct file *filp)
{
filp->private_data = ...; // 挂上这次会话要用的上下文
return 0; // 返回 0 表示打开成功
}
小结:接线靠内核(它查的就是注册表那一行),my_open 只管会话准备。
第三步:read / write —— 顺线传数据,跨墙是核心
read 和 write 是同一套机制的镜像:read 把数据从设备搬给应用,write 把应用的数据搬进设备。
以 read 为例,完整路径是:应用 read(fd, buf, n) → 撞墙进内核 → 拿 fd 找到那张便签 → 顺着便签的 f_op->read 调到 my_read。一个最小 my_read 长这样:
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_dev *dev = filp->private_data; // 取回会话上下文
if (*f_pos >= dev->size) // 到末尾了
return 0; // → 返回 0 表示 EOF
if (*f_pos + count > dev->size) // 防越界
count = dev->size - *f_pos;
if (copy_to_user(buf, dev->data + *f_pos, count)) // 跨墙搬到用户 buf
return -EFAULT;
*f_pos += count; // 推进读位置
return count; // 返回实际读了多少
}
为什么不能直接 memcpy 到 buf?因为 buf 是用户态地址:一来内核当前上下文里它不保证指向你以为的东西,直接解引用会崩;二来这是安全边界,用户可能传一个非法地址,内核不能完全照写。所以必须走 copy_to_user 这座会做地址校验的桥。read 的返回值有三种含义:正数 = 读了 N 字节(可以短读)、0 = EOF、负数 = 错误码。还有 f_pos ,它是 cat 能一段段读到底、读完看到 EOF 停下的原因,漏了应用会死循环。
write 是它的镜像,桥换成 copy_from_user(目的地永远是第一个参数)。但 write 有三个 read 没有的坑:短写必须如实返回(只写进 30 字节就返回 30,应用据此补写剩下的)、copy_from_user 也可能部分失败、write 常带副作用(拷完数据后往往还要真正去操作硬件)。
顺带,为什么 read 一定取 .read、不会取错成 .write?因为在系统调用层,read 和 write 就是两个不同的入口(sys_read / sys_write),各自只取 fops 表里对应的那一栏,从一开始就分岔了,根本不存在"进去再判断"。
小结:内核负责转发 + 校验地址,我们负责备好数据、用 copy_to_user / copy_from_user 搬过墙、并说明搬了多少。
第四步:release —— 收尾,但不是每次 close 都调
应用 close(fd) 时,内核给那张便签的引用计数 -1。注意:一个打开的文件可能被多个 fd 共享(fork、dup 都会),所以只有计数减到 0、最后一个 fd 也关掉时,内核才调用 my_release。
static int my_release(struct inode *inode, struct file *filp)
{
struct my_dev *dev = filp->private_data;
// 释放 open 里申请的资源、做收尾
return 0;
}
release 是最不能省的一步:漏了清理会累积成资源泄漏,设备被反复开关成千上万次后,内核内存被一点点吃光——这类 bug 平时不显形,跑久了才爆,极难定位。
小结:my_open 准备的,my_release 反着收拾干净;且引用归零才调一次。
最后:注销
rmmod 触发出口函数,里面 cdev_del 把注册条目从表里删除,并逆序清理之前申请的资源。
总结
insmod 注册(往表里登记"设备号 → fops")→ 应用 open,内核查表接线、调 my_open 备好会话 → 应用 read/write,内核顺 f_op 调到你的函数,靠 copy_*_user 把数据搬过墙 → 应用 close,引用归零时内核调 my_release 收尾 → rmmod 注销。
中间反复出现的 f_op、private_data、f_pos,都是那张便签上的字段。