文件IO讲解

文件IO

本笔记为作者再学习嵌入式Linux的一些心得体会,如有不对的地方,请包含与谅解!我主要是采用香橙派5来作为我们学习嵌入式Linux的环境。

------------by wsoz

文件IO介绍

在 Linux 系统中,一切都是"文件":普通文件、驱动程序、网络通信等等。所有的操作,都是通过"文件 IO"来操作的。

Linux的文件

Linux 的文件既可以是真实保存到存储介质的文件也可以是自身内核提供的虚拟文件,还可以是设备节点。

文件IO方式

1. 系统调用IO(System Call IO)

也叫底层IO、POSIX IO,直接使用系统调用与内核交互

  • 核心函数openclosereadwritelseek
  • 返回值:文件描述符(fd,整数)
  • 缓冲:无用户空间缓冲(内核有缓冲)
  • 特点:直接、高效、可控性强

2. 标准IO(Standard IO)

C标准库提供的IO函数,在用户空间有缓冲区

  • 核心函数fopenfclosefreadfwritefseek
  • 返回值:FILE* 指针
  • 缓冲:用户空间缓冲区(全缓冲/行缓冲/无缓冲)
  • 特点:使用方便、跨平台、自动缓冲优化
  • 主要用于解决系统调用IO频繁在用户和内核态之间切换

使用场景选择

系统调用IO

  • 操作设备文件(如 /dev/ttyS0/dev/gpio
  • 需要精确控制读写时机
  • 非阻塞IO、异步IO
  • 嵌入式开发中最常用

标准IO

  • 普通文本文件读写
  • 日志文件
  • 配置文件解析
  • 跨平台应用

💡 嵌入式开发建议:优先掌握系统调用IO,因为驱动操作、GPIO、串口等都需要用到。标准IO用于日志和配置文件。

手册查看函数

对于操作时我们无法完全记住具体的操作函数,因此我们可以通过手册来查询具体的用法。

注意有可能手册比较旧的情况下,我们需要更新一下手册才可以查询到对应的信息:

bash 复制代码
sudo apt update
sudo apt install manpages manpages-dev man-db

简单操作:

bash 复制代码
#查看完整手册
man man

拿查询open函数举例

bash 复制代码
#查询需要对应具体的章节,我们使用该命令查询到的不是我们的open函数
man 1 open
#正确应该使用该命令
man 2 open

open函数

open函数主要用于对打开文件或者创建文件,下面讲解一下操作。

函数原型

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

参数说明

pathname:文件路径(绝对路径或相对路径)

flags:文件打开方式,必须包含以下三种之一:

必选标志 含义
O_RDONLY 只读方式打开
O_WRONLY 只写方式打开
O_RDWR 读写方式打开

可选标志 (通过 | 组合使用):

可选标志 含义
O_CREAT 文件不存在则创建(需要mode参数)
O_EXCL 与O_CREAT配合,文件存在则报错
O_TRUNC 文件存在则清空内容
O_APPEND 追加模式,写入从文件末尾开始
O_NONBLOCK 非阻塞模式(设备文件常用)
O_SYNC 同步写入,等待数据写入磁盘

mode :文件权限(仅在使用 O_CREAT 时需要)

umask指的是文件创建权限掩码

  • 当你创建新文件/目录时,从"初始权限"里减掉一部分权限

权限采用八进制表示,常用值:

权限值 含义
0644 所有者读写,其他人只读
0755 所有者读写执行,其他人读执行
0666 所有人读写
0777 所有人读写执行

⚠️ 实际权限 = mode & ~umask(系统会用umask掩码过滤)

返回值

  • 成功:返回文件描述符(非负整数,通常从3开始,0/1/2被stdin/stdout/stderr占用)
  • 失败:返回 -1,并设置 errno

常见错误码

errno 含义
EACCES 权限不足
EEXIST 文件已存在(O_CREAT + O_EXCL时)
ENOENT 文件或路径不存在
EISDIR 尝试写方式打开目录
EMFILE 进程打开文件数达到上限

使用要点

  1. flags组合 :必选标志只能选一个,可选标志可以用 | 组合

  2. 创建文件 :使用 O_CREAT 时必须提供 mode 参数

  3. 清空文件O_TRUNC 会清空已有内容,慎用

  4. 追加写入O_APPEND 保证多进程写入时不会覆盖

  5. 错误检查:必须检查返回值是否为 -1

  6. 关闭文件 :使用完毕后必须调用 close(fd) 释放资源

  7. 文件描述符:是一个整数,后续 read/write/close 都用这个值

典型使用场景

场景 flags组合
只读打开 O_RDONLY
只写打开(不存在则创建) `O_WRONLY
读写打开(不存在则创建) `O_RDWR
清空重写 `O_WRONLY
追加写入 `O_WRONLY
创建新文件(已存在则报错) `O_WRONLY
打开设备文件(非阻塞) `O_RDWR

💡 嵌入式开发提示 :操作设备文件(如 /dev/ttyS0)时,通常使用 O_RDWR | O_NOCTTY | O_NONBLOCK

简单示例

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        return 1; // Incorrect usage
    }

    const char *filename = argv[1];
    int fd = open(filename, O_RDONLY);		//fd是设备句柄
    if (fd == -1) 
    {
        // Handle error
        perror("open");
        return 1;
    }

    // File opened successfully, you can perform operations on the file here

    close(fd); // Don't forget to close the file descriptor
    return 0;
}

write函数

write函数用于向文件描述符写入数据。

函数原型

c 复制代码
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数说明

fd:文件描述符(由open返回)

buf:指向要写入数据的缓冲区

count:要写入的字节数

返回值

  • 成功:返回实际写入的字节数(可能小于count)
  • 失败:返回 -1,并设置 errno

⚠️ 重要:返回值可能小于count,需要循环写入直到全部完成

常见错误码

errno 含义
EBADF 文件描述符无效或未以写方式打开
EFAULT buf指针无效
EINTR 被信号中断
EIO 底层IO错误
ENOSPC 磁盘空间不足
EPIPE 管道/socket另一端已关闭
EAGAIN 非阻塞模式下暂时无法写入

使用要点

  1. 返回值检查:必须检查返回值,-1表示错误,其他值表示实际写入字节数

  2. 部分写入:返回值可能小于count,需要循环写入:

    • 普通文件通常一次写完
    • 管道、socket、串口可能需要多次写入
  3. 缓冲区有效性:buf指向的内存必须有效且可读

  4. 文件偏移:每次写入后,文件偏移自动向后移动

  5. O_APPEND模式:无论当前偏移在哪,都会追加到文件末尾

  6. 非阻塞模式:如果设置了O_NONBLOCK,写不进去会立即返回EAGAIN

  7. 原子性:对于普通文件,小于PIPE_BUF(通常4KB)的写入是原子的

典型使用场景

场景 说明
写入文本 write(fd, "hello\n", 6)
写入二进制数据 write(fd, &data, sizeof(data))
循环写入大数据 检查返回值,循环直到全部写完
串口发送数据 非阻塞模式,处理EAGAIN
日志写入 O_APPEND模式,多进程安全

完整写入的正确做法

由于write可能部分写入,完整写入需要循环:

c 复制代码
// 伪代码逻辑(不是完整代码)
已写入 = 0
while (已写入 < 总长度) {
    本次写入 = write(fd, buf + 已写入, 总长度 - 已写入)
    if (本次写入 == -1) {
        if (errno == EINTR) continue;  // 被信号中断,重试
        return -1;  // 真正的错误
    }
    已写入 += 本次写入
}

write vs printf/fprintf

对比项 write printf/fprintf
层次 系统调用 标准IO(有缓冲)
格式化 不支持 支持格式化输出
缓冲 无用户空间缓冲 有缓冲区
适用场景 二进制数据、设备文件 文本输出、日志
效率 每次都系统调用 减少系统调用次数

💡 嵌入式开发建议:操作设备文件(GPIO、串口、I2C)用write;输出日志用printf/fprintf更方便。

简单示例

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(int argc, char *argv[]) {

    const char *filename = argv[1];

    int fd = open(filename, O_RDWR|O_CREAT, 0777);
    if (fd == -1) 
    {
        // Handle error
        perror("open");
        return 1;
    }

    for(int i=0;i<argc-2;i++)
    {
        int size=write(fd, argv[i+2], strlen(argv[i+2]));
        if(size==-1)
        {
            perror("write");
            close(fd);
            return 1;
        }
        write(fd, "\n", 1); // Write a newline after each argument
    }

    close(fd); // Don't forget to close the file descriptor
    return 0;
}

read函数

read函数用于从文件描述符读取数据。

函数原型

c 复制代码
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

参数说明

fd:文件描述符(由open返回)

buf:指向接收数据的缓冲区

count:期望读取的字节数

返回值

  • 成功:返回实际读取的字节数(可能小于count)
  • 到达文件末尾:返回 0
  • 失败:返回 -1,并设置 errno

⚠️ 重要:返回值可能小于count,这不是错误!

常见错误码

errno 含义
EBADF 文件描述符无效或未以读方式打开
EFAULT buf指针无效
EINTR 被信号中断
EIO 底层IO错误
EISDIR 尝试读取目录
EAGAIN 非阻塞模式下暂时无数据可读

返回值含义详解

返回值 含义 处理方式
> 0 成功读取n字节 正常处理数据
0 到达文件末尾(EOF) 停止读取
-1errno == EINTR 被信号中断 重试读取
-1errno == EAGAIN 非阻塞模式暂无数据 稍后重试
-1 其他errno 真正的错误 错误处理

使用要点

  1. 返回值检查:必须检查返回值

    • 返回0表示EOF,不是错误
    • 返回值小于count是正常的,不代表出错
  2. 部分读取:实际读取可能少于count:

    • 文件剩余内容不足count
    • 管道/socket对端发送的数据少于count
    • 被信号中断
  3. 缓冲区大小:buf必须至少有count字节空间

  4. 文件偏移:每次读取后,文件偏移自动向后移动

  5. 非阻塞模式:如果设置了O_NONBLOCK,没数据会立即返回EAGAIN

  6. EOF判断:返回0表示到达文件末尾

  7. 字符串处理:读取文本时,read不会自动添加'\0',需要手动添加

典型使用场景

场景 说明
读取整个文件 循环读取直到返回0(EOF)
读取固定大小数据 循环读取直到累计达到目标大小
读取一行文本 逐字节读取直到遇到'\n'
串口接收数据 非阻塞模式,处理EAGAIN
读取二进制结构 read(fd, &data, sizeof(data))

read vs scanf/fread

对比项 read scanf/fread
层次 系统调用 标准IO(有缓冲)
格式化 不支持 scanf支持格式化
缓冲 无用户空间缓冲 有缓冲区
适用场景 二进制数据、设备文件 文本解析、结构化数据
效率 每次都系统调用 减少系统调用次数
字符串处理 不自动添加'\0' fgets自动添加'\0'

常见陷阱

  1. 忘记检查返回值0(EOF):导致死循环

  2. 认为返回值小于count就是错误:实际上这是正常的

  3. 忘记添加字符串结束符:读取文本后buf[n] = '\0'

  4. 缓冲区溢出:buf大小必须 >= count

  5. EINTR处理:被信号中断时应该重试,不应该当作错误

💡 嵌入式开发建议:操作设备文件(GPIO、串口、传感器)用read;解析配置文件用fgets/fscanf更方便。

简单示例

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(int argc, char *argv[]) {

    const char *filename = argv[1];

    int fd = open(filename, O_RDWR|O_CREAT, 0777);
    if (fd == -1) 
    {
        // Handle error
        perror("open");
        return 1;
    }

    for(int i=0;i<argc-2;i++)
    {
        int size=write(fd, argv[i+2], strlen(argv[i+2]));
        if(size==-1)
        {
            perror("write");
            close(fd);
            return 1;
        }
        write(fd, "\n", 1); // Write a newline after each argument
    }
    lseek(fd, 0, SEEK_SET);
    while(1)
    {
        char buffer[100];
        ssize_t bytesRead = read(fd, buffer, sizeof(buffer) - 1);
        if (bytesRead == -1)
        {
            perror("read");
            close(fd);
            return 1;
        }
        else if (bytesRead == 0)
        {
            // End of file reached
            break;
        }
        buffer[bytesRead] = '\0'; // Null-terminate the buffer
        printf("%s", buffer); // Print the contents read from the file
    }

    close(fd); // Don't forget to close the file descriptor
    return 0;
}

lseek函数

lseek函数用于移动文件描述符的读写位置(文件偏移)。

函数原型

c 复制代码
#include <sys/types.h>
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

参数说明

fd:文件描述符

offset:偏移量(字节数),可以是正数、负数或0

whence:偏移的起始位置,必须是以下三个之一:

whence 含义 说明
SEEK_SET 文件开头 新位置 = offset
SEEK_CUR 当前位置 新位置 = 当前位置 + offset
SEEK_END 文件末尾 新位置 = 文件大小 + offset

返回值

  • 成功:返回新的文件偏移(从文件开头算起的字节数)
  • 失败:返回 -1,并设置 errno

常见错误码

errno 含义
EBADF 文件描述符无效
EINVAL whence参数无效,或结果偏移为负数
ESPIPE fd关联的是管道、socket或FIFO(不可seek)
EOVERFLOW 结果偏移超出off_t表示范围

使用要点

  1. 返回值是绝对位置:返回的是从文件开头算起的字节数,不是相对偏移

  2. 可以超出文件末尾:lseek可以移动到文件末尾之后,写入会产生"文件空洞"

  3. 不适用于所有文件:管道、socket、FIFO不支持lseek

  4. 不会触发IO:lseek只是修改内核中的偏移值,不会读写数据

  5. 负数偏移:SEEK_CUR和SEEK_END可以用负数offset向前移动

  6. 获取文件大小lseek(fd, 0, SEEK_END) 返回文件大小

  7. 获取当前位置lseek(fd, 0, SEEK_CUR) 返回当前偏移

典型使用场景

场景 用法
回到文件开头 lseek(fd, 0, SEEK_SET)
跳到文件末尾 lseek(fd, 0, SEEK_END)
获取文件大小 size = lseek(fd, 0, SEEK_END)
获取当前位置 pos = lseek(fd, 0, SEEK_CUR)
向前跳过100字节 lseek(fd, 100, SEEK_CUR)
向后退10字节 lseek(fd, -10, SEEK_CUR)
从末尾向前10字节 lseek(fd, -10, SEEK_END)
创建文件空洞 lseek(fd, 1024, SEEK_END); write(fd, "x", 1)

文件空洞

当lseek超出文件末尾后写入,中间未写入的部分称为"文件空洞":

  • 空洞部分不占用磁盘空间(稀疏文件)
  • 读取空洞返回0('\0')
  • 用于数据库、虚拟磁盘等场景

与标准IO的对应

系统调用 标准IO 说明
lseek(fd, 0, SEEK_SET) fseek(fp, 0, SEEK_SET)rewind(fp) 回到开头
lseek(fd, 0, SEEK_END) fseek(fp, 0, SEEK_END) 跳到末尾
lseek(fd, 0, SEEK_CUR) ftell(fp) 获取当前位置

常见陷阱

  1. 管道/socket不能seek:会返回ESPIPE错误

  2. 忘记检查返回值:lseek失败返回-1,不检查会导致后续操作出错

  3. 混淆返回值含义:返回的是绝对位置,不是移动的距离

  4. 负数偏移越界lseek(fd, -100, SEEK_SET) 会失败(EINVAL)

  5. 文件空洞的误解 :空洞不占磁盘空间,但ls -l显示的文件大小包含空洞

💡 嵌入式开发建议:lseek常用于随机访问文件、获取文件大小、重复读取同一数据。对于顺序读写,不需要使用lseek。

系统调用内部流程

系统调用操作接口后,是怎么进入到内核操作底层的呢?下图为系统内部调用的示例:

调用流程详解

open 函数为例,从应用层到硬件的完整流程:

1. 应用层(用户空间)

应用程序调用C库函数 open(),此时程序运行在用户态

2. C库(glibc)

C库中的 open() 封装函数将参数准备好,然后通过 swi(软中断)/ svc 指令(ARM架构)触发从用户态到内核态的切换。

在x86架构上是 int 0x80syscall 指令,ARM上是 swi / svc 指令

3. 系统调用入口(内核空间)

CPU收到软中断后,切换到内核态,进入系统调用入口:

  • 内核通过系统调用号(每个系统调用有唯一编号)在**系统调用表(sys_call_table)**中查找对应的内核函数
  • 例如 open 对应的内核函数是 sys_open()

4. VFS(虚拟文件系统)

sys_open() 进入VFS层,VFS是内核中的抽象层:

  • 不关心底层是什么文件系统(ext4、FAT、NFS...)
  • 统一管理文件描述符、inode、dentry等数据结构
  • 根据文件路径找到对应的文件系统和驱动

5. 具体文件系统 / 设备驱动

VFS将操作分发到具体的实现:

  • 普通文件:调用具体文件系统(ext4、FAT等)的操作函数
  • 设备文件 :调用对应的设备驱动程序中注册的操作函数

6. 硬件操作

驱动程序最终操作硬件(磁盘、串口、GPIO等),完成实际的数据读写。

流程总结

复制代码
应用程序 open()
    ↓
C库封装(glibc)
    ↓  swi/svc 软中断(用户态 → 内核态)
系统调用入口 → sys_call_table 查表
    ↓
sys_open()(内核函数)
    ↓
VFS(虚拟文件系统)
    ↓
具体文件系统 / 设备驱动
    ↓
硬件(磁盘、串口、GPIO...)

关键概念

概念 说明
用户态/内核态 CPU的两种运行模式,内核态可以访问所有硬件资源
软中断(swi/svc) 用户态切换到内核态的触发机制
系统调用号 每个系统调用的唯一编号,内核通过它查表
sys_call_table 内核中的函数指针数组,索引就是系统调用号(类似于中断向量表)
VFS 虚拟文件系统,屏蔽底层差异,提供统一接口

💡 理解要点:应用程序不能直接操作硬件,必须通过系统调用"请求"内核代为操作。这就是为什么open/read/write叫"系统调用"------它们是用户程序进入内核的入口。

注意 :每个进程由内核维护,进程中对文件的操作都是通过**文件描述符(fd)**进行的。调用open时,内核会创建一个 struct file 结构体指向该文件(包含文件偏移、打开标志等参数),并返回一个文件描述符给用户空间。如果对同一个文件多次open,内核会分别创建独立的 struct file,返回不同的fd,二者的文件偏移和操作互不影响。

dup/dup2函数

该函数主要用于复制文件描述符,会分配一个新的文件描述符,但是内核不会新创建一个struct file 结构体,而是指向被复制文件描述符所指向的struct file 结构体。

⚠️ 与多次open的区别 :多次open会创建独立的 struct file,偏移互不影响;而dup复制的fd共享同一个 struct file,文件偏移是共享的。

函数原型

c 复制代码
#include <unistd.h>

int dup(int oldfd);	//oldfd:要复制的文件描述符
int dup2(int oldfd, int newfd);	//oldfd:要复制的文件描述符 
								//newfd:指定新的文件描述符编号。如果newfd已经打开,会先自动close再复制

返回值

  • 成功:dup返回系统分配的最小可用fd;dup2返回newfd
  • 失败:返回 -1,并设置 errno

常见错误码

errno 含义
EBADF oldfd无效,或newfd超出范围
EMFILE 进程打开文件数达到上限
EINTR 被信号中断

dup vs dup2 对比

对比项 dup dup2
新fd编号 系统自动分配(最小可用) 用户指定
目标fd已打开 不涉及 自动先close再复制
典型用途 保存/备份fd IO重定向

使用要点

  1. 共享struct file:dup出来的新fd和旧fd共享文件偏移、打开标志,一个write后另一个的偏移也会变

  2. 独立关闭:新旧fd需要分别close,关闭一个不影响另一个

  3. dup2的原子性:dup2的"close + 复制"是原子操作,比手动close再dup更安全

  4. dup2相同fd:如果oldfd == newfd,dup2什么都不做,直接返回newfd

  5. 引用计数 :内核对 struct file 有引用计数,所有指向它的fd都close后才真正释放

典型使用场景

场景 说明
重定向stdout到文件 dup2(fd, STDOUT_FILENO) 后printf输出到文件
重定向stderr到文件 dup2(fd, STDERR_FILENO) 后错误信息写入文件
备份stdout saved = dup(STDOUT_FILENO) 保存后可恢复
恢复stdout dup2(saved, STDOUT_FILENO) 恢复原来的stdout
管道通信 配合pipe()实现进程间通信

IO重定向原理

复制代码
// 重定向前:
fd 1 (stdout) → 终端
fd 3           → 文件test.txt

// 执行 dup2(3, 1) 后:
fd 1 (stdout) → 文件test.txt  (原来指向终端的连接被替换)
fd 3           → 文件test.txt  (不变)

// 此时 printf("hello") 会写入 test.txt 而不是终端

💡 嵌入式开发建议:dup2最常用于日志重定向,把程序的stdout/stderr重定向到日志文件,方便调试和记录。

**注意:**在进程(如通过 ps 查看)相关的文件描述符语境中,默认有三个标准文件描述符:0------标准输入(stdin),1------标准输出(stdout),2------标准错误(stderr)。

简单示例

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include  <stdio.h>
#include <unistd.h>

int main(int argc,char** argv)
{
    int fd;
    char buf[100];
    fd = open("test.txt",O_RDWR|O_CREAT,0777);
    if(fd == -1)
    {
        perror("open");
        return -1;
    }
    int temp_fd= dup(1);
    int fd2 = dup2(fd,1);
    printf("fd2 = %d\n",fd2);
    printf("hello world\n");
    fflush(stdout);                  // 关键:先把缓冲刷进文件
    dup2(temp_fd, 1);
    lseek(fd, 0, SEEK_SET);
    while(read(fd,buf,sizeof(buf)) > 0)
    {
        printf("%s",buf);
    }

    close(fd);
    return 0;
}
相关推荐
mounter6252 小时前
基于MLX设备的Devlink 工具全指南与核心架构演进
linux·运维·服务器·网络·架构·kernel
wefg12 小时前
【计算机网络】网络基础 - 1(网络协议/TCP/IP协议栈/局域网内外数据传输/数据封装、解包、分用)
linux·服务器·网络
xuanwojiuxin2 小时前
[linux] what‘s the kdump?
linux·运维·服务器
无盐海2 小时前
Linux vi 命令 Docker命令
linux·docker
如若1233 小时前
WSL2安装Ubuntu完整教程:自定义安装目录到D盘(--location一键搞定)
linux·运维·服务器·pytorch·python·ubuntu·计算机视觉
papaofdoudou5 小时前
QEMU和KVMTOOL在GPA(IOVA)和HVA映射方面的异同
linux·运维·服务器
艾莉丝努力练剑6 小时前
文件描述符fd:跨进程共享机制
java·linux·运维·服务器·开发语言·c++
原来是猿6 小时前
Linux-【文件系统下】
linux·运维·数据库
勇闯逆流河6 小时前
【Linux】linux进程概念(冯洛伊曼体系、操作系统、进程详解)
linux·运维·服务器