嵌入式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这个字符才被传到了内核缓冲区,再到磁盘内。

相关推荐
零涂毕业设计20 分钟前
毕业设计模块开发-OLED显示屏(IIC协议0.96寸)STM32 ESP32 FPGA Linux驱动免费分享
linux·stm32·单片机·嵌入式·esp32·fpga·oled
wdfk_prog4 小时前
嵌入式面试真题第 13 题:单硬件 Timer 下的高精度多路软件定时器架构设计
c语言·开发语言·面试·职场和发展·嵌入式
潘潘的嵌入式日记1 天前
嵌入式安全重构——新老两套同时跑
嵌入式·架构设计·验证·代码重构
Fu_Lin_1 天前
Qt 嵌入式从零基础到精通总纲
开发语言·qt·嵌入式·qt嵌入式
潘潘的嵌入式日记1 天前
一个宏在调试阶段挡住所有误操作
安全·嵌入式··调试·编译期·bring-up
K成长日志5 天前
BLE链路层空口包--LE Uncoded PHY
物联网·嵌入式·蓝牙·iot·ble·无线·通信
凉、介5 天前
Virtio(三):Virtqueue 建立与 Buffer 提交
笔记·学习·嵌入式·arm·虚拟化·virtio
dddwjzx6 天前
嵌入式Linux C应用编程——Framebuffer应用编程
linux·嵌入式
国产化创客6 天前
Kindle完整越狱改造:从闲置泡面盖到Linux开发与智能家居终端
linux·运维·物联网·嵌入式·智能家居·智能硬件
浅止菌6 天前
嵌入式Linux崩溃分析实战:没有JTAG,怎么"空手"定位死机问题?
嵌入式