C++Linux系统编程——文件IO

系统调用简介和实现

系统调用定义

系统调用,顾名思义,说的是操作系统提供给用户程序调用的一组“特殊”接口。用户程序可以通过这组“特殊”接口来获得操作系统内核提供的服务,比如用户可以通过文件系统相关的调用请求系统打开文件、关闭文件或读写文件,可以通过时钟相关的系统调用获得系统时间或设置定时器等。

从逻辑上来说,系统调用可被看成是一个内核与用户空间程序交互的接口——它好比一个中间人,把用户进程的请求传达给内核,待内核把请求处理完毕后再将处理结果送回给用户空间。

系统服务之所以需要通过系统调用来提供给用户空间的根本原因是为了对系统进行“保护”,因为我们知道 Linux 的运行空间分为内核空间与用户空间,它们各自运行在不同的级别中,逻辑上相互隔离。

所以用户进程在通常情况下不允许访问内核数据,也无法使用内核函数,它们只能在用户空间操作用户数据,调用用户空间函数。

比如我们熟悉的“hello world”程序(执行时)就是标准的用户空间进程,它使用的打印函数 printf 就属于用户空间函数,打印的字符“hello word”字符串也属于用户空间数据。

但是很多情况下,用户进程需要获得系统服务(调用系统程序),这时就必须利用系统提供给用户的“特殊接口”——系统调用了,它的特殊性主要在于规定了用户进程进入内核的具体位置。

系统调用实现

系统调用是属于操作系统内核的一部分的,必须以某种方式提供给进程让它们去调用。CPU 可以在不同的特权级别下运行,而相应的操作系统也有不同的运行级别,用户态和内核态。运行在内核态的进程可以毫无限制的访问各种资源,而在用户态下的用户进程的各种操作都有着限制,比如不能随意的访问内存、不能开闭中断以及切换运行的特权级别。显然,属于内核的系统调用一定是运行在内核态下,但是如何切换到内核态呢?

答案是软件中断。软件中断和我们常说的中断(硬件中断)不同之处在于,它是通过软件指令触发而并非外设引发的中断,也就是说,又是编程人员开发出的一种异常(该异常为正常的异常)。操作系统一般是通过软中断从用户态切换到内核态。

系统调用和库函数区别

Linux 下对文件操作有两种方式:系统调用(system call)库函数调用(Library functions)

库函数由两类函数组成:

1)不需要调用系统调用

不需要切换到内核空间即可完成函数全部功能,并且将结果反馈给应用程序,如strcpy、bzero 等字符串操作函数。

2)需要调用系统调用

需要切换到内核空间,这类函数通过封装系统调用去实现相应功能,如 printf、fread等。

 

系统调用是需要时间的,程序中频繁的使用系统调用会降低程序的运行效率。当运行内核代码时,CPU工作在内核态,在系统调用发生前需要保存用户态的栈和内存环境,然后转入内核态工作。系统调用结束后,又要切换回用户态。这种环境的切换会消耗掉许多时间 。

C库中IO函数工作流程

 

 

库函数访问文件的时候根据需要,设置不同类型的缓冲区,从而减少了直接调用 IO 系统调用的次数,提高了访问效率。

这个过程类似于快递员给某个区域(内核空间)送快递一样,快递员有两种方式送:

1)来一件快递就马上送到目的地,来一件送一件,这样导致来回走比较频繁(系统调用)

2)等快递攒着差不多后(缓冲区),才一次性送到目的地(库函数调用)

错误处理函数

errno 是记录系统的最后一次错误代码。代码是一个int型的值,在errno.h中定义。查看错误代码errno是调试程序的一个重要方法。

当Linux C api函数发生异常时,一般会将errno全局变量赋一个整数值,不同的值表示不同的含义,可以通过查看该值推测出错的原因。

测试程序:

​
#include <stdio.h>  //fopen
#include <errno.h>  //errno
#include <string.h> //strerror(errno)
​
int main()
{
    FILE *fp = fopen("xxxx", "r");
    if (NULL == fp)
    {
        printf("%d\n", errno);  //打印错误码
        printf("%s\n", strerror(errno)); //把errno的数字转换成相应的文字
        perror("fopen err");    //打印错误原因的字符串
    }
​
    return 0;
}

虚拟内存空间

每个进程都会分配虚拟地址空间,在32位机器上,该地址空间为4G

        

 

在进程里平时所说的指针变量,保存的就是虚拟地址。当应用程序使用虚拟地址访问内存时,处理器(CPU)会将其转化成物理地址(MMU)。

MMU:将虚拟的地址转化为物理地址。

这样做的好处在于:

  • 进程隔离,更好的保护系统安全运行

  • 屏蔽物理差异带来的麻烦,方便操作系统和编译器安排进程地址

文件描述符

在 Linux 的世界里,一切设备皆文件。我们可以系统调用中 I/O 的函数(I:input,输入;O:output,输出),对文件进行相应的操作( open()、close()、write() 、read() 等)。

打开现存文件或新建文件时,系统(内核)会返回一个文件描述符,文件描述符用来指定已打开的文件。这个文件描述符相当于这个已打开文件的标号,文件描述符是非负整数,是文件的标识,操作这个文件描述符相当于操作这个描述符所指定的文件。

程序运行起来后(每个进程)都有一张文件描述符的表,标准输入、标准输出、标准错误输出设备文件被打开,对应的文件描述符 0、1、2 记录在表中。程序运行起来后这三个文件描述符是默认打开的。

#define STDIN_FILENO  0 //标准输入的文件描述符
#define STDOUT_FILENO 1 //标准输出的文件描述符
#define STDERR_FILENO 2 //标准错误的文件描述符

在程序运行起来后打开其他文件时,系统会返回文件描述符表中最小可用的文件描述符,并将此文件描述符记录在表中。

 

最大打开的文件个数

Linux 中一个进程最多只能打开 NR_OPEN_DEFAULT (即1024)个文件,故当文件不再使用时应及时调用 close() 函数关闭文件。

  • 查看当前系统允许打开最大文件个数:

    cat /proc/sys/fs/file-max

  • 当前默认设置最大打开文件个数1024

    ulimit -a

  • 修改默认设置最大打开文件个数为4096

    ulimit -n 4096

常见文件IO函数

open函数

man open 2 指令打开源码,部分源码如下:

#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
    mode:这个参数,只有在文件不存在时有效,指新建文件时指定文件的权限
返回值:
    成功:成功返回打开的文件描述符
    失败:-1

flags详细说明

必选项:

取值含义
O_ACCMODE访问模式,一般用switch (flag & O_ACCMODE)可以拿到下面三种文件状态标志
O_RDONLY以只读的方式打开
O_WRONLY以只写的方式打开
O_RDWR以可读、可写的方式打开

可选项,和必选项按位或起来

取值含义
O_CREAT文件不存在则创建文件,使用此选项时需使用mode说明文件的权限
O_EXCL如果同时指定了O_CREAT,且文件已经存在,则出错
O_TRUNC如果文件存在,则清空文件内容
O_APPEND写文件时,数据添加到文件末尾
O_NONBLOCK对于设备文件, 以O_NONBLOCK方式打开可以做非阻塞I/O

mode补充说明

\1) 文件最终权限:mode & ~umask,其中mode为文件创建时指定,umask是进程的权限掩码,~umask为其补码,也就是进程的权限

例如

mode = 0777 (文件指定权限)
umask = 0022 (进程的umask掩码,补码为 7555)
~umask = 7555 (umask的补码)
mode & ~umask = 0777 & 7555 = 0222 (最终权限)

\2) shell进程的umask掩码可以用umask命令查看

  • umask:查看掩码(补码)

  • umask mode:设置掩码,mode为八进制数

  • umask -S:查看各组用户的默认操作权限

取值八进制含义
S_IRWXU00700文件所有者的读、写、可执行权限
S_IRUSR00400文件所有者的读权限
S_IWUSR00200文件所有者的写权限
S_IXUSR00100文件所有者的可执行权限
S_IRWXG00070文件所有者同组用户的读、写、可执行权限
S_IRGRP00040文件所有者同组用户的读权限
S_IWGRP00020文件所有者同组用户的写权限
S_IXGRP00010文件所有者同组用户的可执行权限
S_IRWXO00007其他组用户的读、写、可执行权限
S_IROTH00004其他组用户的读权限
S_IWOTH00002其他组用户的写权限
S_IXOTH00001其他组用户的可执行权限
#include <fcntl.h>
​
int main() {
//以写入的方式打开,并且文件所有者权限为写,其他的都是为读
    int fd = open("newfile.txt", O_WRONLY | O_CREAT, 0644);
    if (fd != -1) {
        // 文件打开成功,可以进行写入操作
        // ...
        close(fd); // 关闭文件
    } else {
        // 文件打开失败
        // 处理错误
    }
    return 0;
}
close函数
#include <unistd.h>
​
int close(int fd);
功能:
    关闭已打开的文件
参数:
    fd : 文件描述符,open()的返回值
返回值:
    成功:0
    失败: -1, 并设置errno

需要说明的是,当一个进程终止时,内核对该进程所有尚未关闭的文件描述符调用close关闭,所以即使用户程序不调用close,在终止时内核也会自动关闭它打开的所有文件。

但是对于一个长年累月运行的程序(比如网络服务器),打开的文件描述符一定要记得关闭,否则随着打开的文件越来越多,会占用大量文件描述符和系统资源。

write函数
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
功能:
    把指定数目的数据写到文件(fd)
参数:
    fd :  文件描述符
    buf : 数据首地址
    count : 写入数据的长度(字节)
返回值:
    成功:实际写入数据的字节个数
    失败: - 1

例如:

#include <fcntl.h>
#include <unistd.h>
​
int main() {
    int fd = open("myfile.txt", O_WRONLY | O_CREAT, 0644);
    if (fd != -1) {
        const char* data = "Hello, World!";
        ssize_t bytes_written = write(fd, data, strlen(data));
        if (bytes_written == -1) {
            // 写入失败
            // 处理错误
        } else {
            // 写入成功
            // 打印写入的字节数
            printf("写入了 %zd 字节\n", bytes_written);
        }
        close(fd); // 关闭文件
    } else {
        // 文件打开失败
        // 处理错误
    }
    return 0;
}
read函数
#include <unistd.h>
​
ssize_t read(int fd, void *buf, size_t count);
功能:
    把指定数目的数据读到内存(缓冲区)
参数:
    fd : 文件描述符
    buf : 内存首地址
    count : 读取的字节个数
返回值:
    成功:实际读取到的字节个数
    失败: - 1
阻塞与非阻塞的概念

读常规文件是不会阻塞的,不管读多少字节,read一定会在有限的时间内返回。

从终端设备或网络读则不一定,1.如果从终端输入的数据没有换行符,调用read读终端设备就会阻塞,2.如果网络上没有接收到数据包,调用read从网络读就会阻塞,至于会阻塞多长时间也是不确定的,如果一直没有数据到达就一直阻塞在那里。

1.例如,如果你从终端输入"Hello, World!"但没有按下回车键,那么调用read函数将会一直等待,直到按下回车键为止。

2.如果你的程序尝试从网络套接字读取数据,并且当前网络上没有数据包到达,read函数将会一直阻塞等待,直到有数据包到达为止。

同样,写常规文件是不会阻塞的,而向终端设备或网络写则不一定。

以非阻塞方式打开文件程序示例:

#include <unistd.h> //read
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h> //EAGAIN
​
int main()
{
    // /dev/tty --> 当前终端设备
    // 以不阻塞方式(O_NONBLOCK)打开终端设备
    int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
​
    char buf[10];
    int n;
    n = read(fd, buf, sizeof(buf));
    if (n < 0)
    {
        // 如果为非阻塞,但是没有数据可读,此时全局变量 errno 被设置为 EAGAIN
        if (errno != EAGAIN)
        {
            perror("read /dev/tty");
            return -1;
        }
        printf("没有数据\n");
    }
​
    return 0;
}
Iseek函数

lseek函数适用于底层文件描述符,而不适用于标准I/O库中FILE类型文件流(就像open对应fopen的关系,lseek对应fseek),功能为改变文件描述定位符

#include <sys/types.h>
#include <unistd.h>
​
off_t lseek(int fd, off_t offset, int whence);
功能:
    改变文件的偏移量
参数:
    fd:文件描述符
    offset:根据whence来移动的位移数(偏移量),可以是正数,也可以负数,如果正数,则相对于whence往右移动,如果是负数,则相对于whence往左移动。如果向前移动的字节数超过了文件开头则出错返回,如果向后移动的字节数超过了文件末尾,再次写入时将增大文件尺寸。
​
    whence:其取值如下:
        SEEK_SET:从文件开头移动offset个字节
        SEEK_CUR:从当前位置移动offset个字节
        SEEK_END:从文件末尾移动offset个字节
返回值:
    若lseek成功执行, 则返回新的偏移量
    如果失败, 返回-1

例如:

#include <fcntl.h>
#include <unistd.h>
​
int main() {
    int fd = open("myfile.txt", O_RDONLY);
    if (fd != -1) {
    //将fd其文件描述定位符移到14位之后,文件内容第一行为 This is line 1,
    //移动14之后即代表定位到第二行开头
        off_t offset = lseek(fd, 14, SEEK_SET);
        if (offset != -1) {
            char buffer[100];
            ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
            if (bytes_read != -1) {
                printf("第二行内容:%.*s\n", (int)bytes_read, buffer);
            } else {
                // 读取失败
                // 处理错误
            }
        } else {
            // 定位失败
            // 处理错误
        }
        close(fd);
    } else {
        // 文件打开失败
        // 处理错误
    }
    return 0;
}

所有打开的文件都有一个当前文件偏移量(current file offset),以下简称为 cfo。cfo 通常是一个非负整数,用于表明文件开始处到文件当前位置的字节数。

读写操作通常开始于 cfo,并且使 cfo 增大,增量为读写的字节数。文件被打开时,cfo 会被初始化为 0,除非使用了 O_APPEND

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值