一切皆文件虚拟文件系统
342.4.3 一切皆文件与虚拟文件系统
/* app.c:本节从 main() 开始实际展示并讲解的应用代码片段;课程画面在循环中继续向下滚动,因此这里只收录当堂展示到的 OLED 数据写入部分。 */
int main(int argc,char **argv) // 应用程序入口;本节代码没有使用 argc 和 argv。
{ // main 函数体开始。
int fd_oled, fd_mpu; // 分别保存 OLED 和 MPU6050 设备的文件描述符。
int error; // 保存 read() 的返回值,供后续判断读取是否失败。
unsigned char buf[200] = {0}; // 准备 200 字节并清零,用于拼接要显示到 OLED 的字符串。
short resive_data[6]; // 保存 6 个 16 位测量值;课程源码将 receive 误拼成了 resive,原样保留。
struct pos pos_temp = { // 定义 OLED 显示坐标,并开始使用指定初始化器赋初值。
.x = 0, // 将横坐标初始化为 0。
.y = 0, // 将纵坐标初始化为 0。
}; // 坐标结构体初始化结束。
fd_oled = open("/dev/oled",O_RDWR); /* 打开字符设备/dev/oled */ // 以可读写方式打开 OLED 字符设备节点,成功时得到非负文件描述符。
if(fd_oled < 0){ // 返回负值表示打开失败。
printf(" can't open /dev/oled \n\r"); // 输出课程原有错误信息;这里没有打印 errno 的具体原因。
return -1; // OLED 无法打开时立即结束应用。
} // OLED 打开失败分支结束。
fd_mpu = open("/dev/I2C1_mpu6050", O_RDWR); // 打开名称大小写完全匹配的 MPU6050 字符设备节点。
if(fd_mpu < 0) // 检查第二次 open() 是否失败。
{ // MPU6050 打开失败分支开始。
printf("can't open /dev/mpu6050 \n\r"); // 此提示中的路径与实际打开的 /dev/I2C1_mpu6050 不一致,是课程源码原有问题。
return -1; // 直接退出;课程源码此处没有关闭此前已经打开的 fd_oled。
} // MPU6050 打开失败分支结束。
while (1) { // 进入无限循环,持续采样 MPU6050 并刷新 OLED。
error = read(fd_mpu,resive_data,12); // 请求读取 12 字节,即本平台上 6 个 2 字节 short 测量值。
if(error < 0) // 课程应用只把负返回值视为读取失败。
{ // 读取失败分支开始。
printf("read file error! \n"); // 在终端输出读取错误提示。
close(fd_mpu); // 关闭 MPU6050 文件描述符。
close(fd_oled); // 关闭 OLED 文件描述符。
return 0; // 结束程序;虽然发生错误,课程源码仍返回 0。
/*判断是否关闭成功*/ // 保留课程源码原注释;实际代码没有检查两个 close() 的返回值。
} // 读取失败分支结束。
/*打印数据*/ // 保留课程源码原注释:下面把传感器数据打印到终端。
printf("AX=%d, AY=%d, AZ=%d \n",(int)resive_data[0],(int)resive_data[1],(int)resive_data[2]); // 打印 X、Y、Z 三轴加速度原始值。
printf("GX=%d, GY=%d, GZ=%d \n",(int)resive_data[3],(int)resive_data[4],(int)resive_data[5]); // 打印 X、Y、Z 三轴陀螺仪原始值。
ioctl(fd_oled, OLED_CLEAR, 0); // 向 OLED 驱动发送清屏命令;第三个参数在该命令中不使用。
pos_temp.x = 0; // 第一行文字从横坐标 0 开始。
pos_temp.y = 0; // 第一行文字从纵坐标 0 开始。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 把显示坐标结构体地址传给驱动,设置随后绘制文字的位置。
sprintf(buf, "AX:%d AY:%d", resive_data[0], resive_data[1]); // 将 AX、AY 原始值格式化到字符串缓冲区。
ioctl(fd_oled, OLED_DATA, buf); // 把字符串缓冲区地址传给 OLED 驱动并显示第一行数据。
pos_temp.x = 0; // 第二行文字仍从横坐标 0 开始。
pos_temp.y = 3; // 把第二行文字的纵坐标设置为 3。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 更新 OLED 驱动保存的显示坐标。
sprintf(buf, "AZ:%d GX:%d", resive_data[2], resive_data[3]); // 将 AZ、GX 原始值格式化到字符串缓冲区。
ioctl(fd_oled, OLED_DATA, buf); // 显示第二行数据。
pos_temp.x = 0; // 第三行文字仍从横坐标 0 开始。
pos_temp.y = 6; // 把第三行文字的纵坐标设置为 6。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 再次更新 OLED 显示坐标。
sprintf(buf, "GY:%d GZ:%d", resive_data[4], resive_data[5]); // 将 GY、GZ 原始值格式化到字符串缓冲区。
ioctl(fd_oled, OLED_DATA, buf); // 显示第三行数据;本节视频展示的代码范围到这里。
本节承接上一课已经成功运行的 MPU6050 与 OLED 联调实验。重点不是继续编写姿态算法,而是从应用中的 open()、read()、ioctl() 出发,解释用户程序怎样通过文件描述符访问字符设备;随后在开发板上卸载两个驱动,观察 /dev 节点随驱动清理而消失,并用应用打开失败的结果验证整条链路。
上方代码按视频中实际展示的 main() 片段抄录,仅增加逐行注释。函数后半段仍属于同一个无限循环,但本节没有继续讲解,故不为凑成完整函数而补录。
一、设备树与内核模块编译
本节保留原笔记的一级结构,但画面中没有重新编译设备树或内核模块。开头只回顾上一课的设备树修改要求,随后立即进入内核日志与应用代码。
1.1 内核打印相关
1.1.1 上一课设备树要点回顾
视频开头再次强调两点:
- I²C 设备树
reg使用不含读写方向位的 7 位地址。MPU6050 为0x68,课程 OLED 从 8 位写地址0x78右移一位得到 7 位地址0x3c。 - DTS 的花括号、尖括号和分号必须完整;语法缺失会令 DTC 编译失败。
转录中的“设备数”和“FNC 地址”均为识别错误,正确说法分别是“设备树”和“I²C 地址”。这些内容已在上一课展开,本节不再修改 DTS。
1.1.2 dmesg 到底显示什么
课程先在开发板终端执行:
dmesg
dmesg 用来读取或控制内核环形缓冲区,默认显示其中现存的内核消息。它不只是“驱动加载记录”:内核启动、设备探测、错误、驱动里的 printk() 等信息都可能出现;缓冲区容量有限,较早记录也可能被覆盖。该定义可对照 util-linux 的 dmesg 手册。
画面中形如 [1089.969606] 的数字默认表示相对启动时刻的秒数,不是当天的时钟时间。它适合判断事件先后及间隔。
信息较多时,课程使用管道和 grep 过滤:
dmesg | grep 6050
这条命令分成三步理解:
dmesg把当前缓冲区内容写到标准输出。- 管道符
|把左侧标准输出连接到右侧标准输入。 grep 6050只保留包含文本6050的行。
视频过滤结果中能看到课程驱动的初始化、匹配、mpu6050_open 和 mpu6050_release。同时还出现过带 inv-mpu6050 前缀的早期信息;这说明文本过滤只能缩小范围,不能证明每一行都来自同一驱动。判断日志来源还要比较前缀、时间和当前操作。
若系统工具版本支持,可以实时只看随后出现的新日志:
dmesg -w | grep 6050
-w 会等待新消息。某些系统因 dmesg_restrict 权限设置而拒绝普通用户读取,此时应按系统权限策略处理,不能把 Permission denied 误判为驱动没有日志。
1.1.3 为什么按 Ctrl+C 后出现 release
视频运行 ./app 后,日志新增:
oled open
mpu6050_open
随后按 Ctrl+C,又看到:
mpu6050_release
精确过程是:终端向前台进程发送 SIGINT;该应用没有安装自定义信号处理器,所以按默认动作终止。进程退出时,内核关闭其尚未关闭的文件描述符;当该打开文件对象的最后一个引用被释放时,字符驱动的 .release 回调执行。课程代码中 MPU6050 的 release 打印日志,OLED 的 release 没有 printk(),因此画面只看到前者。
这里不能说 Ctrl+C 主动执行了源码中循环之后的 close():while (1) 下方的正常关闭语句在当前程序结构中不可达,信号终止时是内核负责清理进程持有的描述符。Linux 信号默认动作和进程退出时关闭描述符可分别参见 signal(7) 与 _exit(2)。
二、应用代码分析
2.1 项目代码分析
2.1.1 裸机直接调用与 Linux 用户态调用
课程先用单片机程序作对比:裸机应用通常可以从 main.c 直接调用 OLED.c,再由它调用 i2c.c 的底层函数,因为这些代码被链接进同一个固件地址空间。
Linux 用户程序运行在用户空间,不能直接调用内核驱动中的函数,也不能直接解引用驱动的内核地址。这里的接口链是:
用户程序 app
├─ open("/dev/oled", ...)
├─ open("/dev/I2C1_mpu6050", ...)
├─ read(fd_mpu, ...)
└─ ioctl(fd_oled, ...)
↓ 系统调用进入内核
VFS
↓ 按打开文件的操作表分派
字符设备驱动
↓
I²C 核心与控制器驱动
↓
OLED / MPU6050 硬件
因此,“调用驱动”在本例中的准确含义是:应用发起系统调用,内核按设备节点和驱动注册关系找到相应回调,并在内核态执行它。
2.1.2 open() 与文件描述符
应用按顺序打开:
fd_oled = open("/dev/oled", O_RDWR);
fd_mpu = open("/dev/I2C1_mpu6050", O_RDWR);
成功的 open() 返回一个小的非负整数。这个整数是当前进程文件描述符表的索引,后续系统调用用它引用已经打开的文件;它不是设备地址、I²C 地址或全系统唯一编号。详见 open(2)。
两处源码细节需要特别留意:
- 实际节点名是大小写敏感的
/dev/I2C1_mpu6050。原笔记中的 “iPhone cempu6050” 来自 OCR 错误。 - 第二次打开失败时,提示却写成
can't open /dev/mpu6050,与真实路径不一致;同时直接return -1,没有关闭已成功打开的fd_oled。进程退出后内核仍会回收它,但这不是良好的显式清理写法。本笔记只指出问题,不改写课程源码。
排错时最好打印真实路径和 errno,否则“节点不存在”“权限不足”“驱动拒绝打开”等不同原因会得到同一句提示。
2.1.3 read() 读取 12 字节
应用执行:
error = read(fd_mpu, resive_data, 12);
在本课程的 ARM Linux ABI 中,short 占 2 字节,因此 12 字节正好容纳 6 个值:
| 下标 | 数据 |
|---|---|
resive_data[0..2] | AX、AY、AZ 三轴加速度原始值 |
resive_data[3..5] | GX、GY、GZ 三轴陀螺仪原始值 |
read() 的标准语义是:成功返回实际读取的字节数,0 通常表示文件结束,失败返回 -1 并设置 errno,而且成功时也可能短读。参见 read(2)。课程应用只判断 error < 0,没有验证是否确实返回 12。
配套 MPU6050 驱动还有一个应明确标出的接口瑕疵:mpu6050_read() 成功执行 copy_to_user(buf, mpu6050_result, cnt) 后返回 0,没有返回已复制的字节数。这个应用恰好只把负值当错误,所以演示仍能输出数据,但 0 按通常 read() 约定会被应用理解成 EOF。驱动也直接信任用户传入的 cnt;课程应用固定传 12 才与内部 6 个 short 的缓冲区相符。生产代码应检查长度并在成功时返回实际字节数。
2.1.4 ioctl() 控制 OLED
本节主要指向这条清屏调用:
ioctl(fd_oled, OLED_CLEAR, 0);
随后画面还能看到 OLED_OPS 与 OLED_DATA:先传坐标结构体,再传字符串。三条命令在课程 OLED 驱动中的对应关系是:
| 应用命令 | 驱动处理 |
|---|---|
OLED_CLEAR | 调用 oled_clear() 清屏 |
OLED_OPS | copy_from_user() 取得 struct pos,保存绘制坐标 |
OLED_DATA | copy_from_user() 取得字符串,在保存的坐标显示 |
ioctl() 是针对文件描述符发出设备相关控制请求的系统调用;命令号及第三个参数的含义由对应设备接口定义,并非所有文件通用。参见 ioctl(2)。本例 OLED 驱动把 .unlocked_ioctl 指向 oled_ioctl,所以 VFS 最终会把这些命令交给该函数。
2.2 虚拟文件系统
2.2.1 VFS 的准确含义
VFS(Virtual File System,虚拟文件系统)是 Linux 内核中的统一文件访问抽象。它让用户程序使用相同的系统调用模型访问不同的具体文件系统和许多“文件式”内核对象。内核官方文档把打开文件表示为 struct file,把可对它执行的操作组织在 struct file_operations 中,其中就包括 read、write、unlocked_ioctl、open 和 release,参见 Linux VFS 文档。
“虚拟”不等于数据是假的,也不是内存中另有一个名为 VFS 的磁盘。它表示 VFS 提供统一抽象和分派层,隐藏 ext4、NFS、procfs、字符设备等后端实现差异。
2.2.2 “一切皆文件”怎样理解
这句话在本节应理解为:Linux 尽量用路径、文件描述符及 open/read/write/ioctl/close 等接口统一访问多种资源。常见例子包括普通文件、目录、字符设备、块设备、管道、套接字以及 /proc、/sys 暴露的内核视图。
它不是以下三种意思:
- 不是“所有硬件数据都存放在普通磁盘文件里”。
- 不是“驱动二进制代码被 VFS 转换成了一个文件”。
- 不是“内核所有子系统都完全由 VFS 实现”。例如套接字也使用文件描述符,但网络协议处理仍由网络栈完成;进程和内存信息可以通过 procfs/sysfs 暴露部分视图,不代表进程管理或内存管理本身就是文件系统。
2.2.3 本项目中 VFS 与驱动回调的对应
课程源码注册的关键操作如下:
| 用户态操作 | 内核中最终使用的课程回调 | 本例行为 |
|---|---|---|
open("/dev/oled", ...) | oled_fops.open = oled_open | 初始化 OLED、清屏并打印 oled open |
open("/dev/I2C1_mpu6050", ...) | mpu6050_chr_dev_fops.open = mpu6050_open | 初始化 MPU6050 并打印 mpu6050_open |
read(fd_mpu, ...) | .read = mpu6050_read | 读取六个原始量并复制到用户空间 |
ioctl(fd_oled, ...) | .unlocked_ioctl = oled_ioctl | 执行清屏、设置坐标或显示字符串 |
| 关闭最后一个 MPU6050 文件引用 | .release = mpu6050_release | 打印 mpu6050_release |
简化后的分派关系是:
路径 /dev/I2C1_mpu6050
↓ 解析设备特殊文件
字符设备号(主设备号 + 次设备号)
↓ 找到已注册 cdev
mpu6050_chr_dev_fops
↓ 按系统调用选择成员
open → mpu6050_open
read → mpu6050_read
close/最终释放 → mpu6050_release
/dev/I2C1_mpu6050 和 /dev/oled 是字符设备特殊文件节点。节点的关键作用是保存设备类型和设备号,使内核能把访问导向注册的字符设备;硬件寄存器内容并不存放在目录项里。ls -l 输出开头的 c 表示字符设备,普通文件则是 -。
三、实验操作
3.1 实验环境及文件说明
视频在开发板的 root shell 操作。实验开始时上一课已满足:
- 活动设备树包含 MPU6050 和 OLED 节点。
- 两个 I²C 驱动均已匹配并执行
probe()。 probe()已注册字符设备、创建 class/device。- 板端设备管理机制据此建立
/dev/I2C1_mpu6050与/dev/oled。 - 应用此前已运行成功,并在终端和 OLED 上显示数据。
dev 是 device 的缩写,但“/dev 包含板上所有设备”说得过满。它主要放设备节点,也可能包含 /dev/pts、/dev/shm 等挂载点和其他特殊入口;板上存在的硬件也不保证一定拥有独立 /dev 节点。
3.2 实验操作: 查找设备文件
视频实际执行:
cd /dev
ls
在较长的列表中确认两个准确名称:
I2C1_mpu6050
oled
更适合验证类型与设备号的命令是:
ls -l /dev/I2C1_mpu6050 /dev/oled
若输出首字符为 c,后面通常还能看到主、次设备号。Linux 官方的设备编号文档也将 /dev 节点与字符/块设备号对应起来,参见 Linux allocated devices。不要因它们能被 ls 看见就当作普通文本文件随意 cat;读写语义由各自驱动决定。
3.3 实验操作: 卸载mpu6050驱动
视频先停止应用,然后在 /dev 目录中执行:
rmmod mpu6050_drv.ko
ls
该 BusyBox 环境接受了带 .ko 的参数。日常更通用的模块名写法是:
rmmod mpu6050_drv
卸载时,I²C 核心解除设备与驱动的绑定,课程 mpu6050_remove() 依次调用:
device_destroy(class_mpu6050, mpu6050_devno);
class_destroy(class_mpu6050);
cdev_del(&mpu6050_chr_dev);
unregister_chrdev_region(mpu6050_devno, DEV_CNT);
因此 class/device 与字符设备注册被撤销,板端设备管理机制移除 /dev/I2C1_mpu6050。device_destroy() 的官方定义正是注销并清理由 device_create() 创建的设备,参见 Linux 设备驱动基础设施文档。
节点消失不是“VFS 自动把每个已卸载驱动都删除成文件”的普遍魔法,而是这份驱动的卸载链确实调用了对应清理接口,系统再同步处理设备节点。若模块仍被占用,rmmod 可能失败;应先检查命令返回值和 dmesg,不能只凭随后一次 ls 下结论。
3.4 实验操作: 卸载OLED驱动
视频继续执行:
rmmod oled_drv.ko
ls
通用写法同样是:
rmmod oled_drv
课程 oled_remove() 调用 device_destroy()、class_destroy()、cdev_del() 和 unregister_chrdev_region(),终端也输出 OLED remove/driver delete 相关日志。再次 ls 后,/dev/oled 消失。
原笔记中的 red杠JAVA、or ED 都是 OCR 错误,实际命令和设备名分别是 rmmod oled_drv.ko、oled。
3.5 例题1: 理解驱动与文件的关系
这个实验能证明的关系是:
驱动与设备树节点匹配
↓
probe() 注册字符设备并创建 device
↓
系统建立 /dev 设备节点
↓
应用 open() 节点,获得文件描述符
↓
read()/ioctl() 经 VFS 调用驱动回调
所以,更准确的表述是“驱动向内核注册文件操作,设备节点为用户态提供路径入口”,而不是“驱动代码在 VFS 中形成一个文件”。
视频随后曾在 /dev 目录误输入 ./ap3216c,终端返回 Permission denied。这只是一次目录/命令输入错误,不是本实验预期的应用路径,也不能据此判断 OLED 或 MPU6050 驱动状态。老师随即切换到应用目录继续验证。
3.6 例题2: 驱动未加载导致的错误
视频切换到应用所在目录并执行:
cd /oled_6050/
ls
./app
得到:
can't open /dev/oled
原因顺序很明确:应用首先执行 open("/dev/oled", O_RDWR);OLED 驱动已卸载,/dev/oled 又已随清理消失,所以第一次 open() 失败,程序立即返回,根本没有继续尝试打开 MPU6050。
上一课也出现过同一句报错,但原因是设备树尚未提供匹配对象,驱动模块虽加载却没有执行 probe(),所以也没有生成节点。这说明应用层的一句 can't open 不能唯一定位根因:
| 观察 | 可能原因 | 下一步 |
|---|---|---|
/dev/oled 不存在 | 模块未加载、设备树未生效、匹配失败、probe() 失败、设备已移除 | 查 lsmod、dmesg、活动设备树与驱动匹配表 |
节点存在但 open() 返回 EACCES | 权限或访问控制限制 | 查 ls -l、进程身份与安全策略 |
节点存在且权限允许,驱动 open 仍失败 | 驱动初始化或硬件通信错误 | 打印 errno,结合驱动日志追踪 |
| 应用提示路径与源码不一致 | 错误信息写死或节点名称拼错 | 以实际 open() 参数和 errno 为准 |
恢复本实验时,要保证正确 DTB 已启动,再重新加载两个模块并确认节点:
insmod mpu6050_drv.ko
insmod oled_drv.ko
ls -l /dev/I2C1_mpu6050 /dev/oled
./app
四、虚拟文件系统解析
课程结尾展示的示意图把多种具体文件系统、驱动和内核子系统都画在应用层之下。对初学者最重要的是区分以下几层:
| 层次 | 本项目实例 | 职责 |
|---|---|---|
| 用户空间接口 | open/read/ioctl/close | 用路径或文件描述符提出请求 |
| VFS | 路径解析、struct file、操作分派 | 提供统一抽象并选择具体实现 |
| 设备文件/具体文件系统 | /dev/oled、NFS 根文件系统、procfs/sysfs | 承载命名、对象与具体语义 |
| 字符设备层 | cdev、设备号、file_operations | 把文件操作连接到字符驱动 |
| 总线与设备驱动 | 课程 OLED/MPU6050 I²C 驱动 | 实现初始化、读写和控制 |
| 硬件 | I²C1、OLED、MPU6050 | 执行真实总线传输与测量/显示 |
还要分清三种常被混为一谈的机制:
- VFS:内核统一文件访问抽象和分派层。
- devtmpfs/mdev/udev 一类设备节点机制:根据内核设备模型事件,在
/dev创建或删除节点;课程 OLED 源码注释明确写的是由mdev自动创建/dev/oled。 - NFS 根文件系统:本开发环境实际挂载的远端根文件系统,决定目录和普通文件来自哪里;它不会代替字符驱动处理 OLED 或 MPU6050 的设备操作。
把本节完整因果链串起来:
设备树描述 I²C 设备
↓
I²C client 与驱动匹配,probe() 执行
↓
驱动注册 cdev/file_operations,并创建 class/device
↓
设备管理机制生成 /dev 节点
↓
应用 open(),VFS 建立打开文件对象并返回 fd
↓
read()/ioctl() 经 file_operations 进入驱动
↓
驱动通过 I²C 访问硬件
↓
rmmod 触发 remove() 清理,节点消失,后续 open() 失败
至此,“一切皆文件”在本项目里就有了具体含义:应用用统一的文件描述符接口访问设备,而 VFS、字符设备层和驱动共同把这些操作落实为真正的 I²C 通信。它是一套接口与抽象模型,不是把硬件或驱动源码变成普通文件。