【Linux】基础IO

【Linux】基础IO

一、文件本质与IO核心认知

1.1 重新理解"文件"

核心定义
  • 狭义文件:磁盘等永久存储介质上的实体,本质是对外设的输入/输出操作(IO)。
  • 广义文件:Linux系统中"一切皆文件",键盘、显示器、网卡、进程等都被抽象为文件,可通过统一接口操作。
  • 本质构成:文件 = 属性(元数据,如权限、大小、创建时间) + 内容(实际存储的数据)。
初学者关键疑问
  1. 0KB空文件为什么占用磁盘空间?

    空文件虽无内容,但需存储元数据(如文件名、权限、inode编号等),这些信息占用磁盘inode节点空间,因此并非完全不占空间。

  2. 进程如何找到要操作的文件?

    进程启动时会记录当前工作目录(通过/proc/[PID]/cwd符号链接查看),若操作文件时不指定路径,系统会默认在当前工作目录中查找。

    示例验证:

    bash 复制代码
    # 查看进程当前工作目录
    ls -l /proc/[进程PID]/cwd
    # 查看进程对应的可执行文件路径
    ls -l /proc/[进程PID]/exe

1.2 文件操作的核心分类

所有文件操作本质可分为两类:

  • 内容操作:读写文件中的实际数据(如read/write)。
  • 属性操作:修改文件元数据(如chmod修改权限、chown修改所有者)。

1.3 系统视角:IO操作的底层逻辑

  • 文件的管理者是操作系统,而非应用程序或库函数。
  • 应用程序的IO操作(如C库fwrite)最终都会通过操作系统提供的系统调用接口 (如write)实现,库函数仅为封装层,方便开发者使用。

二、回顾C标准库IO接口

2.1 核心接口实战与常见坑

C语言提供了一套标准IO库函数(stdio.h),核心接口包括fopenfreadfwritefclose等,适合初学者入门,但需注意细节陷阱。

2.1.1 文件打开与路径问题
c 复制代码
#include <stdio.h>
int main() {
    // 以写模式打开文件,默认在进程当前工作目录创建
    FILE *fp = fopen("myfile", "w");
    if (!fp) {
        printf("fopen error!\n");
        return 1;
    }
    fclose(fp);
    return 0;
}
初学者疑问:如何确认文件创建路径?
  • 进程的当前工作目录由启动时的位置决定,而非可执行文件所在目录。
  • 可通过getcwd函数获取当前工作目录,或通过/proc/[PID]/cwd查看。
2.1.2 文件读写实战
写文件示例
c 复制代码
#include <stdio.h>
#include <string.h>
int main() {
    FILE *fp = fopen("myfile", "w");
    if (!fp) {
        printf("fopen error!\n");
        return 1;
    }
    const char *msg = "hello bit!\n";
    int count = 5;
    // 循环写入5次数据
    while (count--) {
        // 参数:数据地址、单次读写大小、次数、文件指针
        fwrite(msg, strlen(msg), 1, fp);
    }
    fclose(fp);
    return 0;
}
读文件示例(模拟简易cat命令)
c 复制代码
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[]) {
    if (argc != 2) {
        printf("用法:%s <文件名>\n", argv[0]);
        return 1;
    }
    FILE *fp = fopen(argv[1], "r");
    if (!fp) {
        printf("fopen error!\n");
        return 1;
    }
    char buf[1024];
    while (1) {
        // 读取数据到缓冲区,返回实际读取字节数
        size_t s = fread(buf, 1, sizeof(buf), fp);
        if (s > 0) {
            buf[s] = '\0';
            printf("%s", buf);
        }
        // 检测文件结束(feof),避免死循环
        if (feof(fp)) {
            break;
        }
    }
    fclose(fp);
    return 0;
}
关键坑点:feof的正确使用
  • 不可用fread返回0直接判断文件结束,因为fread返回0可能是读取失败(如权限问题)。
  • 需先用fread读取,再用feof判断是否为"正常文件结束",避免误判。

2.2 标准输入输出流:stdin/stdout/stderr

C语言默认打开3个标准流,类型均为FILE*

  • stdin:标准输入,对应键盘(文件描述符0)。
  • stdout:标准输出,对应显示器(文件描述符1)。
  • stderr:标准错误,对应显示器(文件描述符2)。
多方式输出到显示器示例
c 复制代码
#include <stdio.h>
#include <string.h>
int main() {
    const char *msg1 = "hello printf\n";
    const char *msg2 = "hello fwrite\n";
    const char *msg3 = "hello fprintf\n";
    
    printf("%s", msg1);                  // 标准输出宏
    fwrite(msg2, strlen(msg2), 1, stdout); // 二进制写
    fprintf(stdout, "%s", msg3);         // 格式化输出到stdout
    return 0;
}
初学者疑问:三者的区别是什么?
  • stdout是行缓冲,stderr是无缓冲(错误信息立即输出),stdin是行缓冲。
  • stdout输出可能被缓存,stderr输出直接刷新,适合打印紧急错误信息。

三、系统调用IO接口:底层操作的真相

C库IO函数是对系统调用的封装,若想深入理解IO机制,必须掌握操作系统提供的底层接口。

3.1 核心系统调用接口实战

系统调用IO接口包括openreadwritecloselseek等,需包含<fcntl.h><unistd.h>等头文件。

3.1.1 打开文件:open函数
c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 清除文件权限掩码(确保创建文件权限为0644)
    umask(0);
    // 打开文件:只写模式,文件不存在则创建,权限0644
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        // perror打印系统调用错误信息
        perror("open");
        return 1;
    }
    close(fd);
    return 0;
}
关键参数解析
  • pathname:文件路径(绝对路径或相对路径)。
  • flags:打开模式(必选其一:O_RDONLY只读、O_WRONLY只写、O_RDWR读写;可选:O_CREAT创建、O_APPEND追加、O_TRUNC清空)。
  • mode:文件权限(仅O_CREAT时有效,如0644表示所有者读写、组和其他只读)。
初学者疑问:为什么需要umask
  • umask是进程的权限掩码,默认值为0022(八进制),创建文件时实际权限 = mode & ~umask
  • 若不设置umask(0)0644 & ~0022 = 0644 - 0022 = 0622,最终权限会不符合预期。
3.1.2 读写文件:read/write函数
写文件示例
c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    umask(0);
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    const char *msg = "hello bit!\n";
    int len = strlen(msg);
    int count = 5;
    // 循环写入5次数据
    while (count--) {
        // 参数:文件描述符、数据地址、长度;返回实际写入字节数
        write(fd, msg, len);
    }
    close(fd);
    return 0;
}
读文件示例
c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 只读模式打开文件
    int fd = open("myfile", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    const char *msg = "hello bit!\n";
    int len = strlen(msg);
    char buf[1024];
    while (1) {
        // 读取数据到缓冲区,返回实际读取字节数
        ssize_t s = read(fd, buf, len);
        if (s > 0) {
            // 打印读取到的数据
            printf("%s", buf);
        } else {
            // 读取到0(文件结束)或-1(错误),退出循环
            break;
        }
    }
    close(fd);
    return 0;
}

3.2 系统调用与库函数的关系

  • 库函数(如fopenfwrite)是对系统调用(如openwrite)的封装,目的是简化开发(如提供缓冲区、格式化操作)。
  • 系统调用是操作系统暴露的底层接口,是IO操作的最终实现方式,所有语言的IO操作最终都依赖系统调用。

四、文件描述符(fd)

4.1 什么是文件描述符?

open函数的返回值就是文件描述符,本质是一个非负整数(小整数),是进程与打开文件之间的关联索引。

4.1.1 默认打开的文件描述符

Linux进程默认打开3个文件描述符:

  • 0:标准输入(stdin)→ 对应键盘。
  • 1:标准输出(stdout)→ 对应显示器。
  • 2:标准错误(stderr)→ 对应显示器。
验证示例
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    char buf[1024];
    // 从标准输入(fd=0)读取数据
    ssize_t s = read(0, buf, sizeof(buf));
    if (s > 0) {
        buf[s] = 0;
        // 写入标准输出(fd=1)和标准错误(fd=2)
        write(1, buf, strlen(buf));
        write(2, buf, strlen(buf));
    }
    return 0;
}

4.2 文件描述符的分配规则

核心规则:最小未使用原则

系统会从进程的文件描述符数组(fd_array)中,选择当前未使用的最小整数作为新的文件描述符。

实战验证
c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main() {
    // 关闭默认的fd=0(标准输入)
    close(0);
    // 打开新文件,新fd会是0(最小未使用)
    int fd = open("myfile", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("新文件描述符:%d\n", fd); // 输出:0
    close(fd);
    return 0;
}
初学者疑问:文件描述符的底层存储逻辑是什么?
  • 每个进程的task_struct(PCB)中包含一个files_struct指针,指向文件描述符表。
  • 文件描述符表的核心是fd_array数组,数组下标就是文件描述符,元素是指向内核file结构体的指针(file结构体存储文件元数据和操作方法)。

4.3 文件描述符与FILE结构体的关系

C库中的FILE结构体是对文件描述符的封装,内部包含:

  • _fileno:对应的文件描述符(核心成员)。
  • 缓冲区:用户级缓冲区(提升IO效率)。
  • 刷新模式、指针位置等控制信息。
结论:FILE*本质是对fd的封装,加上用户级缓冲区。

五、重定向

5.1 重定向的本质

通过修改文件描述符对应的file结构体指针,改变IO操作的目标设备/文件。例如:将标准输出(fd=1)从显示器重定向到文件。

基础重定向示例(关闭fd=1实现)
c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    // 关闭标准输出(fd=1)
    close(1);
    // 打开文件,新fd=1(最小未使用)
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    // printf默认写入stdout(fd=1),此时已重定向到文件
    printf("fd: %d\n", fd); // 内容写入myfile,而非显示器
    fflush(stdout); // 强制刷新缓冲区
    close(fd);
    return 0;
}
初学者疑问:为什么需要fflush
  • 重定向到文件后,stdout的缓冲区模式从"行缓冲"变为"全缓冲",数据需填满缓冲区才会刷新到文件。
  • fflush(stdout)可强制刷新缓冲区,确保数据立即写入文件。

5.2 高效重定向:dup2系统调用

dup2函数可直接复制文件描述符,实现重定向,无需手动关闭默认fd,更简洁高效。

函数原型
c 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);
  • 功能:将oldfd复制到newfd,若newfd已打开则先关闭,最终oldfdnewfd指向同一个文件。
实战示例(标准输出重定向到文件)
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

int main() {
    // 打开日志文件(创建+读写)
    int fd = open("./log.txt", O_CREAT | O_RDWR, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    // 将fd复制到1(标准输出),实现重定向
    dup2(fd, 1);
    // 后续printf输出都会写入log.txt
    while (1) {
        char buf[1024] = {0};
        ssize_t s = read(0, buf, sizeof(buf) - 1);
        if (s < 0) {
            perror("read");
            break;
        }
        printf("%s", buf);
        fflush(stdout);
    }
    close(fd);
    return 0;
}

5.3 增强版微型Shell:添加重定向功能

基于之前实现的微型Shell,新增>(输出重定向)、>>(追加重定向)、<(输入重定向)功能,核心步骤:

  1. 解析命令行中的重定向符号(>, >>, <)和目标文件名。
  2. 子进程中通过dup2完成重定向。
  3. 执行命令(程序替换不影响已完成的重定向)。
核心代码实现(关键部分)
c 复制代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <ctype.h>
using namespace std;

// 重定向类型枚举
#define NONE_REDIR 0
#define INPUT_REDIR 1   // < 输入重定向
#define OUTPUT_REDIR 2  // > 输出重定向
#define APPEND_REDIR 3  // >> 追加重定向

int g_redir = NONE_REDIR;  // 当前重定向类型
char *g_filename = nullptr; // 重定向目标文件名
char *g_argv[64];          // 命令参数数组
int g_argc = 0;            // 参数个数

// 去除字符串首尾空格
#define TRIM_SPACE(pos) do { \
    while (isspace(*pos)) pos++; \
} while (0)

// 解析重定向符号
void ParseRedir(char *command_buf, int len) {
    int end = len - 1;
    while (end >= 0) {
        if (command_buf[end] == '<') {
            // 输入重定向
            g_redir = INPUT_REDIR;
            command_buf[end] = '\0'; // 截断命令部分
            g_filename = &command_buf[end + 1];
            TRIM_SPACE(g_filename);
            break;
        } else if (command_buf[end] == '>') {
            if (command_buf[end - 1] == '>') {
                // 追加重定向
                g_redir = APPEND_REDIR;
                command_buf[end] = '\0';
                command_buf[end - 1] = '\0';
                g_filename = &command_buf[end + 1];
            } else {
                // 输出重定向
                g_redir = OUTPUT_REDIR;
                command_buf[end] = '\0';
                g_filename = &command_buf[end + 1];
            }
            TRIM_SPACE(g_filename);
            break;
        }
        end--;
    }
}

// 执行重定向(子进程中调用)
void DoRedir() {
    int fd = -1;
    switch (g_redir) {
        case INPUT_REDIR:
            // 打开输入文件(只读)
            fd = open(g_filename, O_RDONLY);
            if (fd < 0) exit(2);
            dup2(fd, 0); // 重定向标准输入(fd=0)
            break;
        case OUTPUT_REDIR:
            // 打开输出文件(创建+只写+清空)
            fd = open(g_filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
            if (fd < 0) exit(4);
            dup2(fd, 1); // 重定向标准输出(fd=1)
            break;
        case APPEND_REDIR:
            // 打开输出文件(创建+只写+追加)
            fd = open(g_filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
            if (fd < 0) exit(6);
            dup2(fd, 1); // 重定向标准输出(fd=1)
            break;
        default:
            return; // 无重定向
    }
    close(fd); // 重定向后关闭原fd
}

// 执行命令(含重定向)
bool ExecuteCommand() {
    pid_t pid = fork();
    if (pid < 0) return false;
    if (pid == 0) {
        DoRedir(); // 子进程中执行重定向
        // 程序替换(执行命令)
        execvpe(g_argv[0], g_argv, g_env);
        perror("exec failed");
        exit(7);
    } else {
        int status = 0;
        waitpid(pid, &status, 0); // 父进程等待
        // 更新退出码
        g_last_code = WIFEXITED(status) ? WEXITSTATUS(status) : 100;
    }
    return true;
}

// 完整Shell主循环(省略命令读取、解析等重复代码)
int main() {
    InitEnv(); // 初始化环境变量
    char command_buf[1024];
    while (true) {
        PrintPrompt(); // 打印提示符
        if (!GetCommandLine(command_buf, sizeof(command_buf))) continue;
        ResetCommand(); // 重置命令参数和重定向状态
        ParseRedir(command_buf, strlen(command_buf)); // 解析重定向
        ParseCommand(command_buf); // 解析命令参数
        if (CheckAndExecBuiltCommand()) continue; // 执行内建命令
        ExecuteCommand(); // 执行外部命令(含重定向)
    }
    return 0;
}
测试示例
bash 复制代码
# 输出重定向:ls -l 结果写入file.txt
[root@localhost myshell]# ls -l > file.txt

# 追加重定向:echo "hello" 追加到file.txt
[root@localhost myshell]# echo "hello" >> file.txt

# 输入重定向:cat 读取file.txt内容
[root@localhost myshell]# cat < file.txt

六、缓冲区

6.1 缓冲区的本质与作用

核心定义

缓冲区是内存中预留的一块存储空间,用于缓存输入/输出数据,减少系统调用次数和外设访问频率。

为什么需要缓冲区?
  • 系统调用(如read/write)会导致CPU从用户态切换到内核态,上下文切换开销大。
  • 外设(如磁盘、显示器)速度远低于CPU和内存,缓冲区可减少外设访问次数,提升整体效率。

6.2 缓冲区的三种类型

标准IO库(C库)提供三种缓冲方式:

  1. 全缓冲:填满缓冲区后才执行系统调用,常用于磁盘文件(默认缓冲区大小通常为4KB或8KB)。
  2. 行缓冲 :遇到换行符\n或缓冲区填满时执行系统调用,常用于终端(如stdout)。
  3. 无缓冲 :不使用缓冲区,直接执行系统调用,常用于标准错误(stderr),确保错误信息立即输出。
实战验证缓冲区类型
c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    // stdout:行缓冲,无换行符不刷新
    printf("hello stdout");
    // stderr:无缓冲,立即输出
    fprintf(stderr, "hello stderr\n");
    sleep(3); // 休眠期间观察输出
    return 0;
}

运行结果 :先输出hello stderr,休眠3秒后输出hello stdout(进程退出时刷新缓冲区)。

6.3 缓冲区的刷新时机

除了上述默认触发条件,以下情况会强制刷新缓冲区:

  1. 缓冲区填满时。
  2. 调用fflush函数强制刷新(如fflush(stdout))。
  3. 进程正常退出时(exitreturn)。
  4. 关闭文件时(fclose会自动刷新缓冲区)。
经典坑点:重定向后的缓冲区问题
c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("hello world"); // 重定向后为全缓冲,未填满不刷新
    close(fd); // 未刷新缓冲区,数据丢失
    return 0;
}

解决方法 :在close前调用fflush(stdout)强制刷新。

6.4 缓冲区的归属:用户级 vs 内核级

  • 用户级缓冲区 :由C标准库提供(如FILE结构体中的缓冲区),用于减少系统调用次数。
  • 内核级缓冲区:由操作系统提供,用于减少外设访问次数,用户无法直接操作。
验证:库函数与系统调用的缓冲区差异
c 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main() {
    const char *msg1 = "hello printf\n";
    const char *msg2 = "hello fwrite\n";
    const char *msg3 = "hello write\n";

    // 库函数:带用户级缓冲区
    printf("%s", msg1);
    fwrite(msg2, strlen(msg2), 1, stdout);
    // 系统调用:无用户级缓冲区
    write(1, msg3, strlen(msg3));

    fork(); // 创建子进程,触发写时拷贝
    return 0;
}

重定向到文件后的结果

  • printffwrite输出2次(缓冲区数据被写时拷贝)。
  • write输出1次(无用户级缓冲区,数据已直接写入内核)。

七、深入理解"一切皆文件"

7.1 核心原理:统一的文件抽象模型

Linux将所有设备和资源抽象为文件,通过以下机制实现统一操作:

  1. 内核file结构体 :每个打开的文件对应一个file结构体,存储文件元数据(f_inode)、操作方法(f_op)、当前位置(f_pos)等。
  2. file_operations结构体 :包含文件的操作函数指针(如readwriteopen),不同设备(磁盘、键盘、网卡)的file_operations实现不同,但接口统一。

7.2 本质:函数指针的多态性

  • 内核通过file->f_op指向对应设备的操作函数集合,调用read时实际执行的是设备驱动中的read函数。
  • 对开发者而言,无需关心设备差异,只需调用统一的read/write接口,实现"一次编码,多设备兼容"。

7.3 实战意义

例如,读取键盘输入和读取磁盘文件都可通过read函数实现:

  • 键盘:read(0, buf, sizeof(buf))
  • 磁盘文件:read(fd, buf, sizeof(buf))
  • 内核自动通过file_operations分发到对应设备的驱动函数。

八、总结与进阶方向

本文从文件本质出发,逐步深入Linux基础IO的核心机制,涵盖C库IO、系统调用IO、文件描述符、重定向及缓冲区原理,最终通过增强版微型Shell将知识点落地。

进阶学习方向

  1. 高级文件操作lseek(文件指针定位)、mmap(内存映射IO)、select/poll/epoll(IO多路复用)。
  2. 文件系统原理:inode、目录项、超级块、软链接与硬链接。
  3. 设备驱动开发 :基于file_operations实现简单字符设备驱动。
  4. 网络IO:Socket编程(本质是文件操作的延伸)、TCP/UDP协议实战。
相关推荐
A小辣椒1 天前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式