嵌入式Linux C应用编程入门——标准IO库

本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。

简介

标准 I/O 库则是标准 C 库中用于文件 I/O 操作(譬如读文件、写文件等)相关的一系列库函数的集合,通常标准 I/O 库函数相关的函数定义都在头文件<stdio.h>中,所以我们需要在程序源码中包含<stdio.h>头文件。标准I/O实际上是对文件I/O(open()、read()、write()、lseek()、close()等)进行了封装,在其中处理更多的细节,比如分配 stdio 缓冲区、以优化的块长度执行 I/O 等。标准I/O和文件IO的区别:

  • 虽然标准 I/O 和文件 I/O 都是 C 语言函数,但是标准 I/O 是标准 C 库函数,而文件 I/O 则是 Linux系统调用
  • 标准 I/O 是由文件 I/O 封装而来,标准 I/O 内部实际上是调用文件 I/O 来完成实际操作的
  • 标准 I/O 相比于文件 I/O 具有更好的可移植性
  • 标准 I/O 库在用户空间维护了自己的 stdio 缓冲区,所以标准 I/O 是带有缓存的,而文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上,标准 I/O 要优于文件 I/O

C标准库在不同操作系统上提供了一致的接口(API),但在各个操作系统内部,它们各自实现了不同的底层适配层(调用各自的系统调用)。对于应用程序来说,它只看到一致的接口,所以具备可移植性。

标准输入、标准输出和标准错误

在文件IO函数中,都是围绕文件描述符进行的,后续的操作也是基于文件描述符进行的。对于标准IO库函数来说,操作是围绕FILE指针进行的,当使用标准 I/O 库函数打开或创建一个文件时,会返回一个指向 FILE 类型对象的指针(FILE *),FILE 指针用于后续的标准 I/O 操作。

之前所提到的,进程启动之后都会默认打开标准输入、标准输出以及标准错误,得到三个文件描述符,即 0、1、2,其中 0 代表标准输入、1 代表标准输出、2 代表标准错误。0、1、2 这三个是文件描述符,只能用于文件 I/O库。在标准 I/O 中,自然是无法使用文件描述符来对文件进行 I/O 操作的,它们需要围绕 FILE 类型指针来进行,在 stdio.h 头文件中有相应的定义,其中struct _IO_FILE 结构体就是 FILE 结构体,使用了 typedef 进行了重命名,在标准 I/O 中,可以使用 stdin、stdout、stderr 来表示标准输入、标准输出和标准错误:

c 复制代码
/* Standard streams. */
extern struct _IO_FILE *stdin; /* Standard input stream. */
extern struct _IO_FILE *stdout; /* Standard output stream. */
extern struct _IO_FILE *stderr; /* Standard error output stream. */
/* C89/C99 say they're macros. Make them happy. */
#define stdin stdin
#define stdout stdout
#define stderr stderr

常用库函数介绍

打开文件 fopen()

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

FILE *fopen(const char *path, const char *mode);
  • path: 参数 path 指向文件路径,可以是绝对路径、也可以是相对路径。
  • mode: 参数 mode 指定了对该文件的读写权限,是一个字符串。
  • 返回值: 调用成功返回一个指向 FILE 类型对象的指针(FILE *),该指针与打开或创建的文件相关联,后续的标准 I/O 操作将围绕 FILE 指针进行。如果失败则返回 NULL,并设置 errno 以指示错误原因。
mode 说明 对应于 open() 函数的 flags 参数取值
r 以只读方式打开文件。 O_RDONLY
r+ 以可读、可写方式打开文件。 O_RDWR
w 以只写方式打开文件。如果文件存在,将文件长度截断为0;如果文件不存在则创建该文件。 `O_WRONLY
w+ 以可读、可写方式打开文件。如果文件存在,将文件长度截断为0;如果文件不存在则创建该文件。 `O_RDWR
a 以只写方式打开文件,以追加模式写入(在文件末尾写入)。如果文件不存在则创建该文件。 `O_WRONLY
a+ 以可读、可写方式打开文件,以追加模式写入(在文件末尾写入)。如果文件不存在则创建该文件。 `O_RDWR

补充说明 :使用 ww+aa+ 模式时,如果文件不存在则会自动创建,新建文件的默认权限为 S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH。即所有组可读可写。

关闭文件 fclose()

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

int fclose(FILE *stream);

读文件 fread()

与文件IO不同的是,可以指定读取多少个字节,而不是只能固定读取1 * 字节数。

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

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

ptrfread() 将读取到的数据存放在参数 ptr 指向的缓冲区中;

sizefread() 从文件读取 nmemb 个数据项,每一个数据项的大小为 size 个字节,所以总共读取的数据大小为 nmemb * size 个字节;

nmemb:参数 nmemb 指定了读取数据项的个数;

streamFILE 指针;

返回值 :调用成功时返回读取到的数据项的数目(数据项数目并不等于实际读取的字节数,除非参数 size 等于 1);如果发生错误或到达文件末尾,则 fread() 返回的值将小于参数 nmemb,那么到底发生了错误还是到达了文件末尾,fread() 不能区分文件结尾和错误,究竟是哪一种情况,此时可以使用 ferror()feof() 函数来判断。

写文件 fwrite()

ptr:将参数 ptr 指向的缓冲区中的数据写入到文件中;

size:参数 size 指定了每个数据项的字节大小,与 fread() 函数的 size 参数意义相同;

nmemb:参数 nmemb 指定了写入的数据项个数,与 fread() 函数的 nmemb 参数意义相同;

streamFILE 指针;

返回值 :调用成功时返回写入的数据项的数目(数据项数目并不等于实际写入的字节数,除非参数 size 等于 1);如果发生错误,则 fwrite() 返回的值将小于参数 nmemb(或者等于 0)。

示例代码

c 复制代码
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char buf[]= "Hello World!\n"; FILE* fp = NULL;
  /* 打开文件 */
  if(NULL ==(fp = fopen("./test_file","w"))){ 
    perror("fopen error");
    exit(-1);
  }

  printf("文件打开成功!\n");
  /* 写入数据 */
  if(sizeof(buf)>
  fwrite(buf,1,sizeof(buf),fp)){//条件判断中的函数调用也会执行
    printf("fwrite error\n");
    fclose(fp);
    exit(-1);
  }

  printf("数据写入成功!\n");
  /* 关闭文件 */
  fclose(fp);
  exit(0);
}
c 复制代码
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
  char buf[50]= {0};
  FILE *fp = NULL;
int size;
  /* 打开文件 */
  if(NULL ==(fp = fopen("./test_file","r"))){
    perror("fopen error");
    exit(-1);
  }

  printf("文件打开成功!\n");
  /* 读取数据 */
  if(12 >(size = fread(buf,1,12,fp))){
    if(ferror(fp)){ //使用 ferror 判断是否是发生错误
      printf("fread error\n");
      fclose(fp);
      exit(-1);
    }

    /* 如果未发生错误则意味着已经到达了文件末尾 */
  }

  printf("成功读取%d 个字节数据: %s\n",size,buf);
  /* 关闭文件 */
  fclose(fp);
  exit(0);
}

程序中调用了库函数 ferror()来判断是不是发生了错误,是用于判断到底是到达了末尾还是发生了错误的函数。

定位fseek

库函数 fseek() 的作用类似于系统调用 lseek(),用于设置文件读写位置偏移量。lseek() 用于文件 I/O,而库函数 fseek() 则用于标准 I/O。

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

int fseek(FILE *stream, long offset, int whence);

streamFILE 指针;

offset:与 lseek() 函数的 offset 参数意义相同;

whence:与 lseek() 函数的 whence 参数意义相同;

返回值 :成功返回 0;发生错误将返回 -1,并且会设置 errno 以指示错误原因;与 lseek() 函数的返回值意义不同。后者成功时会返回偏移值。

获取偏移量ftell

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

long ftell(FILE *stream);

参数 stream 指向对应的文件,函数调用成功将返回当前读写位置偏移量;调用失败将返回-1,并会设置 errno 以指示错误原因。

测试end of file标志 feof

当文件的读写位置移动到了文件末尾时,end-of-file 标志将会被设置。此时可以通过库函数 feof()来检查目前的偏移位置是否到达了文件末尾。

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

int feof(FILE *stream);

if (feof(file)) {
/* 到达文件末尾 */
}
else {
/* 未到达文件末尾 */
}

测试文件错误标志 ferror

库函数 ferror() 用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则调用 ferror() 函数将返回一个非零值,如果错误标志没有被设置,则返回 0

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

int ferror(FILE *stream);

if (ferror(file)) {
/* 发生错误 */
}
else {
/* 未发生错误 */
}

清除文件错误标志 clearerr

当调用 feof()ferror() 校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用 clearerr() 函数清除标志。

对于 end-of-file 标志,除了使用 clearerr() 显式清除之外,当调用 fseek() 成功时也会清除文件的 end-of-file 标志。

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

void clearerr(FILE *stream);

函数没有返回值,调用将总是会成功。

IO缓冲

之前的文章中提到了,系统调用 open 函数后,并不会直接从磁盘中取磁盘的文件,而是由内核申请一段内存作为缓冲区,以提高读写数据的效率,这段缓冲区就叫内核缓冲区。read()write() 系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区之间复制数据。

当调用 write()之后,内核稍后会将数据写入到磁盘设备中,具体是什么时间点写入到磁盘,是不确定的,由内核根据相应的存储算法自动判断。Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。

使用虚拟机时,其中的内存就被分为两个部分,一个是用户空间内存,一个是内核内存,系统运行时的具体内存分配情况由系统判断。

操控文件IO的内核缓冲区

某些应用程序在执行某些流程之前,必须要保证前置条件是否满足,比如 write 写入文件后,需要确认是否真的已经将数据写入到文件了。在实际中:当我们在 Ubuntu 系统下拷贝文件到 U 盘时,文件拷贝完成之后,通常在拔掉 U 盘之前,需要执行 sync 命令进行同步操作,这个同步操作其实就是将文件 I/O 内核缓冲区中的数据更新到 U 盘硬件设备,所以如果在没有执行 sync 命令时拔掉 U 盘,很可能就会导致拷贝到 U 盘中的文件遭到破坏。

fsync() 函数

系统调用 fsync() 将参数 fd 所指文件的内容数据和元数据 写入磁盘,只有在对磁盘设备的写入操作完成之后,fsync() 函数才会返回。

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

int fsync(int fd);

参数 fd 表示文件描述符,函数调用成功将返回 0,失败返回 -1 并设置 errno 以指示错误原因。

元数据并不是文件内容本身的数据,而是一些用于记录文件属性相关的数据信息,譬如文件大小、时间戳、权限等等信息,这里统称为文件的元数据,这些信息也是存储在磁盘设备中的。

fdatasync() 函数

系统调用 fdatasync()fsync() 类似,不同之处在于 fdatasync() 仅将参数 fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后,fdatasync() 函数才会返回。只有函数名字不一样,其它一致。

sync() 函数

系统调用 sync() 会将所有文件 I/O 内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件 I/O 内核缓冲区。调用sync()函数仅在所有数据已经写入到磁盘设备之后才会返回。

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

void sync(void);

关于内核缓冲的标志

  • 在调用 open() 函数时,指定 O_DSYNC 标志,其效果类似于在每个 write() 调用之后调用 fdatasync() 函数进行数据同步。
  • 在调用 open() 函数时,指定 O_SYNC 标志,使得每个 write() 调用都会自动将文件内容数据和元数据刷新到磁盘设备中,其效果类似于在每个 write() 调用之后调用 fsync() 函数进行数据同步。

在程序中频繁调用 fsync()fdatasync()sync()(或者调用 open() 时指定 O_DSYNCO_SYNC 标志)对性能的影响极大,会导致程序总是在等待同步,因此没有必要的情况下可以不用。

直接I/O

Linux 允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接 I/O。

有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写速率,那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。

除此之外,一般不使用直接I/O,因为系统会对内核缓冲区做许多优化,如果使用直接I/O会导致数据传输速率大大下降。

块对齐

直接 I/O 涉及到对磁盘设备的直接访问,所以需要块对齐,即:

  • 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
  • 写文件时,文件的位置偏移量必须是块大小的整数倍;
  • 写入到文件的数据大小必须是块大小的整数倍。

不同磁盘的块大小是可能不同的,可以通过 tune2fs -l /dev/sda1 | grep "Block size"命令查看根文件系统挂载磁盘的块大小。

stdio 缓冲

标准 I/O(fopenfreadfwritefclosefseek 等)是 C 语言标准库函数,而文件 I/O(openreadwritecloselseek 等)是系统调用。虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调用了 openfread 内部调用了 read 等),但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区,我们把这个缓冲区称为 stdio 缓冲区。

文件 I/O 内核缓冲,这是由内核 维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区。当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。

C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置。

setvbuf()函数

调用 setvbuf()库函数可以对文件的 stdio 缓冲区进行设置,譬如缓冲区的缓冲模式、缓冲区的大小、起始地址等。

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

int setvbuf(FILE *stream, char *buf, int mode, size_t size);

streamFILE 指针,用于指定对应的文件,每一个文件都可以设置它对应的 stdio 缓冲区。

buf:如果参数 buf 不为 NULL,那么 buf 指向 size 大小的内存区域将作为该文件的 stdio 缓冲区,因为 stdio 库会使用 buf 指向的缓冲区,所以应该以动态(分配在堆内存,譬如 malloc)或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。如果 buf 等于 NULL,那么 stdio 库会自动分配一块空间作为该文件的 stdio 缓冲区(除非参数 mode 配置为非缓冲模式)。

mode:参数 mode 用于指定缓冲区的缓冲类型,可取值如下:

  • _IONBF:不对 I/O 进行缓冲(无缓冲)。意味着每个标准 I/O 函数将立即调用 write() 或者 read(),并且忽略 bufsize 参数,可以分别指定两个参数为 NULL0。标准错误 stderr 默认属于这一种类型,从而保证错误信息能够立即输出。

标准错误 stderr也是属于标准I/O流(FILE ),也是一个文件,缓冲是标准 I/O 库(stdio)对所有流的管理机制。

  • _IOLBF:采用行缓冲 I/O。在这种情况下,当在输入或输出中遇到换行符 "\n" 时,标准 I/O 才会执行文件 I/O 操作。对于输出流,在输出一个换行符前将数据缓存(除非缓冲区已经被填满),当输出换行符时,再将这一行数据通过文件 I/O write() 函数刷入到内核缓冲区中;对于输入流,每次读取一行数据。对于终端设备默认采用的就是行缓冲模式,譬如标准输入和标准输出。 因此如果使用printf打印输出,在结尾没有使用换行符则会导致无法正常输出,数据一直停留在用户空间缓冲区,除非缓冲区已满。
  • _IOFBF:采用全缓冲 I/O。在这种情况下,在填满 stdio 缓冲区后才进行文件 I/O 操作(readwrite)。对于输出流,当 fwrite 写入文件的数据填满缓冲区时,才调用 write() 将 stdio 缓冲区中的数据刷入内核缓冲区;对于输入流,每次读取 stdio 缓冲区大小个字节数据。默认普通磁盘上的常规文件默认常用这种缓冲模式。

size:指定缓冲区的大小。

返回值 :成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。

需要注意的是,当 stdio 缓冲区中的数据被刷入到内核缓冲区或被读取之后,这些数据就不会存在于用户空间缓冲区中了,数据被刷入了内核缓冲区或被读走了。

setbuf()函数

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

void setbuf(FILE *stream, char *buf);

没有返回值,作用相当于等于 setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);,即如果buf为NULL则表示无缓冲,如果有值则分配BUFSIZ字节大小的缓冲区,这个缓冲区的大小可以在stdio.h的头文件中设置。

setbuffer()函数

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

void setbuffer(FILE *stream, char *buf, size_t size);

没有返回值,作用相当于等于 setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);允许调用者指定 buf 缓冲区的大小。

刷新stdio缓冲区

可以使用库函数 fflush() 来强制刷新(通过 write() 函数,将输出到 stdio 缓冲区中的数据写入到内核缓冲区)stdio 缓冲区,该函数会刷新指定文件的 stdio 输出缓冲区。

在一些其它的情况下,也会自动刷新 stdio 缓冲区,譬如当文件关闭时、程序退出时。如果使用 exit()return 或像上述示例代码一样不显式调用相关函数或执行 return 语句来结束程序,这些情况下程序终止时会自动刷新 stdio 缓冲区;如果使用 _exit_Exit() 终止程序则不会刷新。这和之前所提到的对应上了,因为使用 _exit_Exit() 终止程序会强制退出,而不会刷新缓存区。

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

int fflush(FILE *stream);

参数 stream 指定需要进行强制刷新的文件,如果该参数设置为 NULL,则表示刷新所有的 stdio 缓冲区。 函数调用成功返回 0,否则将返回 -1,并设置 errno 以指示错误原因。

文件描述符和FILE指针混用

在同一个文件上执行 I/O 操作时,还可以将文件 I/O与标准 I/O 混合使用,这个时候我们就需要将文件描述符和 FILE 指针对象之间进行转换,此时可以借助于库函数 fdopen()fileno() 来完成。

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

int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);

对于 fileno() 函数来说,根据传入的 FILE 指针得到整数文件描述符,通过返回值得到文件描述符,如果转换错误将返回 -1,并且会设置 errno 来指示错误原因。得到文件描述符之后,便可以使用诸如 read()write()lseek()fcntl() 等文件 I/O 方式操作文件。

fdopen() 函数与 fileno() 功能相反,给定一个文件描述符,得到该文件对应的 FILE 指针,之后便可以使用诸如 fread()fwrite() 等标准 I/O 方式操作文件了。参数 modefopen() 函数中的 mode 参数含义相同,若该参数与文件描述符 fd 的访问模式不一致,则会导致调用 fdopen() 失败。

为什么前者不需要传mode,而后者会需要?

我查了一些资料,得到以下的猜想:首先FILE指针指向的内存中本身就保存了文件描述符,使用这个函数可以直接提取出来,因此不需要其它操作。而文件标识符fd本身只是一个整型变量,这个标识符对应着一个文件表,这个文件表中确实存有open函数打开时的对应标志信息,只是fdopen函数没有使用fcntl(fd, F_GETFL)把这个标志调出来一键实现,至于为什么这个暂时没有查到资料。

当混合使用文件 I/O 和标准 I/O 时,需要特别注意缓冲的问题,文件 I/O 会直接将数据写入到内核缓冲 区进行高速缓存,而标准 I/O 则会将数据写入到 stdio 缓冲区,之后再调用 write()将 stdio 缓冲区中的数据写 入到内核缓冲区。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
  printf("print");
  write(STDOUT_FILENO,"write\n",6);
  exit(0);
}

使用printf这个C库函数后,因为默认是行缓冲,因此用户缓冲区中的数据始终没有被送到内核缓冲区中,一直留在用户缓冲区。而 write直接调用了系统调用函数,因此直接陷入内核态,将数据存到内核缓冲区,并写入了磁盘。运行 exit后,系统会先对各个缓冲区的数据进行传输,此时print这个字符才被传到了内核缓冲区,再到磁盘内。

相关推荐
pai同学1 小时前
ESP-IDF+vscode开发ESP32第十二讲——event
嵌入式
凉、介2 小时前
KVM + QEMU 虚拟化
笔记·学习·嵌入式·arm·qemu·虚拟化·kvm
dddwjzx17 小时前
嵌入式Linux C应用编程入门——文件IO进阶
嵌入式
2023自学中21 小时前
imx6ull 开发板, mame 模拟器,运行游戏 测试
linux·游戏·嵌入式·开发板
dddwjzx1 天前
嵌入式Linux C应用编程入门——文件IO
嵌入式
fzm52981 天前
车载ECU单元测试技术与应用研究
c语言·自动化测试·单元测试·嵌入式·白盒测试
用户120487221613 天前
Linux驱动编译与加载
linux·嵌入式
用户805533698033 天前
Input 子系统架构:Core、Handler、Driver 三层是怎么协作的
linux·嵌入式
用户805533698033 天前
RK-Forge外设系列开篇 - 把板子从「能启动」变成「能用」:Ethernet/SPI/MMC 三个纯接线外设
linux·github·嵌入式