文件读写
343.4.4 文件读写
/* app_drv/app/app.c:本节开头实际滚动查看并讲解的 main()。 */
int main(int argc,char **argv) // 应用入口;课程程序未使用命令行参数。
{ // main 函数体开始。
int fd_oled, fd_mpu; // 分别保存 OLED 和 MPU6050 的文件描述符。
int error; // 保存 read() 或 close() 的返回值。
unsigned char buf[200] = {0}; // 创建 200 字节并清零的 OLED 字符串缓冲区。
short resive_data[6]; // 保存六个 16 位传感器原始值;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){ // 负返回值表示 OLED 打开失败。
printf(" can't open /dev/oled \n\r"); // 输出课程原有错误提示。
return -1; // 立即结束程序。
} // OLED 打开失败分支结束。
fd_mpu = open("/dev/I2C1_mpu6050", O_RDWR); // 打开 MPU6050 字符设备节点。
if(fd_mpu < 0) // 检查第二次 open() 是否失败。
{ // MPU6050 打开失败分支开始。
printf("can't open /dev/mpu6050 \n\r"); // 此提示路径与实际打开的节点名不一致,原样保留。
return -1; // 直接退出;课程源码没有在此显式关闭 fd_oled。
} // MPU6050 打开失败分支结束。
while (1) { // 在当前线程中进入无限循环,持续采样和刷新显示。
error = read(fd_mpu,resive_data,12); // 从 MPU6050 设备请求 12 字节,即六个 short 原始值。
if(error < 0) // 课程代码只检查负值,没有检查短读或返回 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]); // 打印三轴加速度原始值。
printf("GX=%d, GY=%d, GZ=%d \n",(int)resive_data[3],(int)resive_data[4],(int)resive_data[5]); // 打印三轴陀螺仪原始值。
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); // 更新显示位置。
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); // 更新显示位置。
sprintf(buf, "GY:%d GZ:%d", resive_data[4], resive_data[5]); // 格式化 GY、GZ 值。
ioctl(fd_oled, OLED_DATA, buf); // 显示第三组数据。
sleep(2); // 暂停 2 秒后再进行姿态解算。
IMUupdate(resive_data[3],resive_data[4],resive_data[5], // 传入三轴陀螺仪原始值;函数调用在下一行继续。
resive_data[0],resive_data[1],resive_data[2]); // 再传入三轴加速度原始值并完成调用。
printf("Pitch=%f, Roll=%f, Yaw=%f\n", Pitch, Roll, Yaw); // 打印俯仰角、横滚角和偏航角。
ioctl(fd_oled, OLED_CLEAR, 0); // 再次清空 OLED,准备显示姿态角。
pos_temp.x = 0; // Pitch 文字横坐标设为 0。
pos_temp.y = 0; // Pitch 文字纵坐标设为 0。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 设置 Pitch 的显示位置。
sprintf(buf, "Pitch:%f", Pitch); // 把 Pitch 浮点值格式化到 buf。
ioctl(fd_oled, OLED_DATA, buf); // 显示 Pitch。
pos_temp.x = 0; // Roll 文字横坐标设为 0。
pos_temp.y = 3; // Roll 文字纵坐标设为 3。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 设置 Roll 的显示位置。
sprintf(buf, "Roll:%f", Roll); // 把 Roll 浮点值格式化到 buf。
ioctl(fd_oled, OLED_DATA, buf); // 显示 Roll。
pos_temp.x = 0; // Yaw 文字横坐标设为 0。
pos_temp.y = 6; // Yaw 文字纵坐标设为 6。
ioctl(fd_oled, OLED_OPS, &pos_temp); // 设置 Yaw 的显示位置。
sprintf(buf, "Yaw:%f", Yaw); // 把 Yaw 浮点值格式化到 buf。
ioctl(fd_oled, OLED_DATA, buf); // 显示 Yaw。
sleep(2); // 暂停 2 秒,然后回到 while 循环开头继续采样。
} // 无限循环体结束;当前源码没有 break,因此正常执行不会到达下面的关闭代码。
error = close(fd_oled); // 设计意图是关闭 OLED 描述符并保存返回值,但当前无限循环使此行不可达。
if(error < 0) // 检查关闭 OLED 是否失败。
{ // OLED 关闭失败分支开始。
printf("close file error! \n"); // 输出关闭失败提示。
} // OLED 关闭失败分支结束。
error = close(fd_mpu); // 设计意图是关闭 MPU6050 描述符并保存返回值,同样不可达。
if(error < 0) // 检查关闭 MPU6050 是否失败。
{ // MPU6050 关闭失败分支开始。
printf("close file error! \n"); // 输出关闭失败提示。
} // MPU6050 关闭失败分支结束。
return 0; // main 正常返回;当前控制流无法到达这里。
} // main 函数体结束。
/* 简单文件读写测试/test.c:本节在 Ubuntu 中从零编写并最终展示的完整源码。 */
#include <stdio.h> // 声明 printf()。
#include <sys/types.h> // 提供系统数据类型;本例没有直接使用其中的类型名。
#include <sys/stat.h> // 提供文件状态与权限相关定义;本例两参数 open() 没有直接用到它。
#include <fcntl.h> // 声明 open() 并定义 O_RDWR、O_CREAT 等打开标志。
#include <string.h> // 声明 memset()。
#include <unistd.h> // 声明 write()、lseek()、read() 和 close()。
int main() // 测试程序入口;课程源码使用空参数列表写法。
{ // main 函数体开始。
int fd; // 保存 open() 返回的文件描述符。
char buff[]="i am wfeng222\n"; // 创建含换行和末尾空字符的可修改字符数组。
fd = open("/home/wfeng/wf.txt",O_RDWR); // 以读写方式打开已存在的普通文件,不负责创建或清空它。
if(fd<0) // 负返回值表示 open() 失败。
{ // 打开失败分支开始。
printf("open failed!\n"); // 输出课程原有错误提示;没有打印 errno 的具体原因。
return fd; // 把负的 open() 返回值作为进程退出状态来源。
} // 打开失败分支结束。
write(fd,buff,sizeof(buff)); // 从当前偏移写入整个数组,连字符串末尾的空字符也写入文件。
lseek(fd,0,SEEK_SET); // 把此打开文件描述的读写偏移重新定位到文件开头。
memset(buff,'\0',sizeof(buff)); // 将 buff 的每个字节清零,清除写入前的旧字符串。
read(fd,buff,sizeof(buff)); // 从当前偏移最多读取 sizeof(buff) 字节到 buff;源码未检查返回值。
printf("buff is:%s\n",buff); // 按 C 字符串打印读取结果;文件内换行与格式串换行会连续出现。
close(fd); // 关闭文件描述符;源码未检查 close() 的返回值。
return 0; // 向操作系统报告程序正常结束。
} // main 函数体结束。
本节包含两部分:前约 4 分钟继续解读上一课的 OLED/MPU6050 应用,随后在 Ubuntu 用户 wfeng 的主目录中编写一个普通文件读写程序。课程最终源码均已在上方按实际内容抄录并逐行注释;没有把姿态解算函数体或其他未展示源码补进来。
一、文件读写
1.1 虚拟文件系统解析
上一课的核心仍然适用:应用使用 open() 得到文件描述符,再通过 read()、write()、ioctl()、close() 等接口访问已经打开的对象。Linux VFS 为不同文件系统和文件式对象提供统一的操作模型;内核用 struct file 表示打开文件,用文件操作表选择具体实现,参见 Linux VFS 官方文档。
但“加载驱动就自动形成一个文件”只是课堂中的入门化说法,不能当成普遍规则。对上一课的两个字符驱动,准确链路是:
设备树设备与 I²C 驱动匹配
↓
probe() 注册 cdev 和 file_operations
↓
device_create() 注册设备模型对象
↓
mdev 根据事件创建 /dev 设备节点
↓
应用通过节点打开字符设备
驱动代码本身没有被变成普通文件;/dev/oled 与 /dev/I2C1_mpu6050 是访问入口。本节后半段的 /home/wfeng/wf.txt 则是真正的普通文件。两者都能使用文件描述符接口,但后端与语义不同:
| 对象 | read/write/ioctl 的去向 | 数据含义 |
|---|---|---|
普通文件 wf.txt | VFS → 具体文件系统 | 文件中的字节和文件偏移 |
字符设备 /dev/I2C1_mpu6050 | VFS/字符设备层 → 驱动回调 | 驱动定义的六个传感器原始值 |
字符设备 /dev/oled | VFS/字符设备层 → 驱动回调 | 驱动定义的清屏、坐标与显示命令 |
ioctl 是 I/O control,用于发出对象或设备特有的控制请求,不宜理解成普通 write(),也不是“往下写命令”这一种固定动作。
1.2 文件读写应用案例
1.2.1 例题: 文件读写打开文件应用
应用代码的完整工作流
视频从 app.c 的 main() 开始滚动分析:
- 用
open("/dev/oled", O_RDWR)打开 OLED 节点。 - 用
open("/dev/I2C1_mpu6050", O_RDWR)打开 MPU6050 节点。 - 用
read(fd_mpu, resive_data, 12)读取六个 16 位原始量。 - 用
printf()输出原始加速度和角速度。 - 用
OLED_CLEAR清屏,再用OLED_OPS设置坐标、用OLED_DATA显示字符串。 - 调用
IMUupdate()计算Pitch、Roll、Yaw,清屏后显示三种姿态角。 - 每个显示阶段
sleep(2),然后无限循环重复。
这里有四处容易混淆:
O_RDWR表示程序请求以可读写方式打开;内核仍会根据文件权限、访问控制和驱动实现决定能否成功,它不是给文件“授予读写权限”。sprintf()生成的是显示字符串,不是给 OLED 的“指令”。真正的控制请求是ioctl()的命令号。while (1)是当前线程中的无限循环,不会因为存在循环就自动成为“独立线程”。- 课程源码没有
break,所以循环后的正常close()路径不可达。按Ctrl+C终止时由内核回收进程仍持有的描述符,并非控制流跳出循环后执行了这些语句。
1.2.2 例题: 文件操作接口应用
本节新实验的目的,是把同一套接口用于普通 TXT 文件,从而直观看见:
open() 打开路径并取得 fd
write() 从内存缓冲区向文件写字节
lseek() 改变下一次读写使用的偏移
read() 从文件向内存缓冲区读字节
close() 关闭 fd
文件描述符只在当前进程中有意义,是一个非负整数索引。open() 还会创建一个“打开文件描述”,其中保存文件状态标志和当前文件偏移;fd 是对它的引用。这个区别解释了本节最关键的“写后为什么直接读不到”问题。官方定义与返回规则见 open(2)。
1.2.3 例题: TXT文件操作
实验路径与最初代码
视频在 Ubuntu 终端中操作,不是在 ARM 开发板 shell 中操作。实际用户与路径是:
用户:wfeng
源码:/home/wfeng/test.c
数据文件:/home/wfeng/wf.txt
转录和原笔记里的“王峰.txt”“汪汪峰.txt”只是语音/OCR 近似,最终源码明确写的是 /home/wfeng/wf.txt。
课程先执行:
vim test.c
gcc test.c
未用 -o 指定文件名时,GCC 默认生成 a.out。转录中的 jcc、a.25t、1.25克 都分别是 gcc 与 a.out 的识别错误。
最初编译暴露了两类问题:变量名把 fd 误写为 fs,以及缺少函数声明和 O_RDWR 定义。课程逐步修正变量名并补齐头文件,最终源码如文首所示。
1.2.4 open函数接口手册
视频使用:
man open
更明确地指定系统调用手册第 2 节可以写:
man 2 open
手册显示的核心原型是:
#include <fcntl.h>
int open(const char *path, int flags, ...);
path是路径字符串,视频口述中的pass name应为 pathname。flags至少包含O_RDONLY、O_WRONLY、O_RDWR三种访问模式之一。- 只有加入
O_CREAT才能在路径不存在时请求创建文件;此时还必须传第三个mode参数。 - 成功返回非负 fd,失败返回
-1并设置errno。 - 在
man阅读器中按q退出;转录中的cure是字母q的识别错误。
最终代码各头文件的直接用途是:
| 头文件 | 本例使用的接口/定义 |
|---|---|
<stdio.h> | printf() |
<fcntl.h> | open()、O_RDWR |
<string.h> | memset() |
<unistd.h> | read()、write()、lseek()、close() |
<sys/types.h>、<sys/stat.h> | 课程从手册一并加入;当前两参数 open() 没有直接使用其中的类型或权限宏 |
“编译警告先不用管”不应作为常规做法。隐式函数声明意味着编译器没有看到正确原型,可能造成参数或返回值解释错误;现代 C 模式下还可能直接报错。应先补齐声明、类型和格式,再运行程序。
1.2.5 文件操作接口应用
第一次运行为什么 open failed!
最终调用是:
open("/home/wfeng/wf.txt", O_RDWR);
它没有 O_CREAT,所以目标必须已经存在。第一次运行时文件尚未创建,open() 返回 -1,程序打印:
open failed!
课程随后执行:
touch /home/wfeng/wf.txt
ls
./a.out
touch 在文件不存在时创建空文件;存在时通常只更新时间戳。再次运行时 open() 成功。
还应注意,代码也没有使用 O_TRUNC。若 wf.txt 原来比新字符串更长,从偏移 0 覆盖较短内容后,旧文件尾部仍可能保留。课程从新建空文件开始,所以没有遇到这个现象。O_CREAT、O_TRUNC 等标志的准确行为见 open(2)。
1.2.6 文件操作接口应用
write()、sizeof(buff) 与文件内容
课程先把字符串字面量和手写长度传给 write(),随后改成:
char buff[]="i am wfeng222\n";
write(fd,buff,sizeof(buff));
最终字符串中可见字符共有 14 字节:i am wfeng222 为 13 字节,再加换行 \n 为 1 字节;字符数组还自动包含一个末尾 \0,所以 sizeof(buff) 是 15。因此课程最终程序向文件写入 15 字节,连 C 字符串终止符也写进了普通文件。
原笔记称写字符串时“长度应包括 \0”,这不是一般规则。普通文本文件通常只写实际文本,常用长度是 strlen(buff);\0 是内存中 C 字符串的边界标记,通常不属于文本文件内容。本节之所以用 sizeof(buff) 仍能显示,是因为 cat 对空字节没有可见输出。
write() 成功时返回实际写入字节数,可能少于请求数;失败返回 -1。课程代码没有保存或检查返回值,只适合作为接口演示。对可定位的普通文件,写入从当前文件偏移开始,并把偏移增加实际写入量,参见 write(2)。
课程通过以下命令查看结果:
./a.out
cat /home/wfeng/wf.txt
1.2.7 文件操作接口应用
1.2.7.1 知识点1:read函数的使用
read(fd, buff, sizeof(buff)) 尝试从 fd 当前偏移读取最多 sizeof(buff) 字节到内存数组。其返回值才说明实际读取量:
| 返回值 | 含义 |
|---|---|
> 0 | 实际读取的字节数,可能小于请求数 |
0 | 已到文件末尾,没有读到字节 |
-1 | 失败,具体原因在 errno |
read() 不会自动在数据后追加 C 字符串终止符。若要用 %s 打印任意文本,稳妥模式是预留一字节,只读到“容量减 1”,成功后在 buff[n] 写 \0。课程示例依赖文件中已经被 write(sizeof(buff)) 写入的那个空字符,因此恰好能按字符串打印;其通用性有限。详细语义见 read(2)。
1.2.7.2 例题2: 文件写入与读取的测试
课程先单独启用 write(),编译运行后用 cat 看见 i am wfeng222,说明写入路径生效。接着在同一个 fd 上加入 read() 与 printf(),却只打印出空的 buff is:。
问题不在 read() 的基本参数,而在当前文件偏移:
open() 后偏移 = 0
↓
write(..., 15) 成功后偏移 = 15
↓
文件长度也约为 15,当前已在 EOF
↓
read() 从偏移 15 开始,返回 0
这也解释了为什么“先写再读”不能只看调用顺序;还必须知道二者是否共享同一个打开文件描述,以及当前偏移在哪里。
1.2.7.3 知识点2: 读取文件前的buffer处理
课程加入:
memset(buff,'\0',sizeof(buff));
memset() 把目标内存区域的前 n 个字节都设为同一个字节值;'\0' 的值是 0,所以这里将整个数组清零,需包含 <string.h>。参见 memset(3)。
清零有两个作用:避免失败或短读后继续显示旧字符串,并为未被覆盖的缓冲区尾部提供终止符。但它不会改变文件偏移,因此不能解决写后已在 EOF 的根因。此外,如果 read() 恰好覆盖缓冲区全部 15 字节,而输入本身没有空字符,printf("%s") 仍可能越界寻找终止符;所以仍应按返回值手动终止字符串。
1.2.7.4 知识点3: 编译过程中的警告处理
视频中的警告主要来自缺少正确函数声明。最终补入:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
分别解决 printf()、memset()、read/write/lseek/close 的声明问题。课程最后重新执行 gcc test.c,画面中不再出现警告或错误。
对警告的正确处理顺序是:读清警告位置和原因,修复后再运行。不能因为生成了 a.out 就推断调用一定正确。
1.2.7.5 例题3: 文件读写综合操作及问题排查
当程序输出只有:
buff is:
可按以下顺序排查:
- 检查
open()是否成功,以及路径是否正确。 - 保存并检查
write()的返回值,确认写入量。 - 确认当前文件偏移;本例写完后已经位于 EOF。
- 保存并检查
read()返回值;本例预期为 0。 - 只有
read()返回正值后,才按实际长度处理和打印缓冲区。
原笔记只说“涉及底层机制”,这里的具体机制就是:普通文件的 read() 与 write() 共用该打开文件描述中的当前偏移,操作成功后会推进它。
1.2.8 文件操作接口应用
1.2.8.1 例题1: 文件操作接口应用基础代码编写
视频先尝试一种直观修复:写完后 close(fd),再执行同一条 open()。新一次 open() 创建新的打开文件描述,初始文件偏移回到 0,所以随后 read() 能读到刚才写入的内容。
这种方法能解释现象,但必须再次检查新 open() 的返回值,而且会多做一次关闭和打开。本例保持文件打开并直接调用 lseek() 更清楚。
close(fd) 使这个整数描述符不再引用该文件,数字之后可以被进程复用;如果它是指向打开文件描述的最后一个 fd,相关资源才会释放。成功返回 0,失败返回 -1,参见 close(2)。
1.2.8.2 例题2: 文件指针移动与文件读写操作
课程最终采用:
lseek(fd,0,SEEK_SET);
三个参数分别是:
fd:要调整的已打开文件。0:目标偏移量。SEEK_SET:以文件开头为基准,把偏移直接设为 0。
所以它不是移动 C 标准库的 FILE * 指针,也不是屏幕上的真实鼠标光标;准确名称是调整 fd 所引用打开文件描述的文件偏移。官方定义见 lseek(2)。
写前 写后 lseek 后
offset=0 offset=15 offset=0
↓ ↓ EOF ↓
[ i am wfeng222\n\0 ] [ i am wfeng222\n\0 ] [ i am wfeng222\n\0 ]
并非所有 fd 都支持定位。管道、FIFO 和套接字没有普通文件这样的可寻址偏移,对它们调用 lseek() 通常以 ESPIPE 失败。驱动设备是否支持则取决于对象和驱动实现。
1.2.8.3 例题3: 文件操作中常见错误及修正
视频调试过程中出现的“多打一个 0”属于语法/实参错误,删除后 lseek(fd, 0, SEEK_SET) 才能编译。本例还留下若干值得主动检查的接口返回值:
| 调用 | 课程源码状态 | 应检查什么 |
|---|---|---|
open() | 已检查 < 0 | 失败时再打印 errno/perror() |
write() | 未检查 | 是否为 -1,是否写满预期字节 |
lseek() | 未检查 | 是否返回 (off_t)-1 |
read() | 未检查 | -1、0、正数以及短读 |
close() | 未检查 | 是否返回 -1;数据持久化另需按需求考虑 fsync() |
关闭 fd 是良好的资源管理习惯,但“必须 close() 数据才会写入文件”也不准确。write() 已把数据交给内核;close() 释放描述符,且成功关闭不等于数据已经物理落盘。需要持久化保证时应按场景使用同步接口,不能仅依赖 close()。
1.2.9 文件操作接口应用
最终实验流程可按视频还原为:
cd /home/wfeng
touch wf.txt
vim test.c
gcc test.c
./a.out
cat test.c
cat wf.txt
man 2 open
man 2 lseek
最终运行画面显示:
buff is:i am wfeng222
缓冲区字符串自身已有 \n,printf("buff is:%s\n", buff) 又追加一个换行,因此输出后会多换一行。文件中还包含不可见的 \0,仅用 cat 看不出来。
把整节课串起来,普通文件的状态变化是:
touch 创建空文件
↓
open(O_RDWR) → fd,初始 offset=0
↓
write(sizeof(buff)) → 写入 15 字节,offset=15
↓
lseek(fd, 0, SEEK_SET) → offset=0
↓
memset() → 只清空内存 buff,不影响文件
↓
read(sizeof(buff)) → 文件字节进入 buff,offset=15
↓
printf() 显示 buff
↓
close(fd) 释放描述符
课程源码完成了教学目标,但用于真实项目时还应修正三点:用实际文本长度避免把 \0 写进文本文件;逐项检查系统调用返回值并处理短读写;按 read() 返回字节数显式终止字符串。这些属于对课程代码行为的精确说明,文首仍完整保留了课程最终版本,没有擅自改写。