Linux应用开发之文件与IO流

与大多数操作系统一样,Linux为程序运行提供了大量的服务,包括打开文件、读文件、启动一个新程序、分配存储区以及获得当前时间等,这些服务被称为系统调用接口(system call interface)。另外,glibc库还提供了大量广泛用于C程序的通用函数(格式化输出变量的值,比较两个字符串等)。
基于Linux系统的程序设计接口(系统调用接口和C库提供的很多函数),实现业务控制逻辑,我们谓之为Linux应用开发。本篇文章主要针对Linux应用开发中遇到的与文件和I/O相关的接口和知识点,进行讲解,帮助读者初体会一切皆文件的Linux基本哲学。讲解延续一贯风格,会先抛出问题或者是例子供读者思考,再进行分析。

首先,说明一下几个标准的含义,方便读者后续的归类理解。

  1. ISO C:意图是提供C程序的可移植性,使得它能够适合于大量不同的操作系统。该标准不仅定义了C程序设计语言的语法和语义,还定义了标准库

  1. POSIX(Portable Operating System Interface): 指的是可移植操作系统接口。该标准的目的是提升应用程序在各种UNIX系统环境之间的可移植性。它定义了“符合POSIX”的操作系统必须提供的各种服务。

  1. SUS(Single Unix Specification):是 POSIX 标准的一个超集,他定义了一些附加接口扩展了 POSIX 规范提供的功能。

上述三个标准只是定义了接口的规范,而具体的实现由厂商来完成,Linux就是这种标准的一个实现,目前同属于UNIX标准的有一下几个操作系统:

  • SVR4(UNIX System V Release 4)

  • 4.4 BSD(Berkeley Software Distribution)

  • FreeBSD

  • Linux

  • Mac OS X

  • Solaris


系统基础知识

UNIX系统

UNIX内核的接口称之为系统调用。公用函数库构建在系统调用接口之上。应用程序既可以使用公用函数库,也可以使用系统调用。UNIX shell 是一个特殊的应用程序,它为其他应用程序提供了一个接口。每个进程都有一个工作目录,有时称他为当前工作目录。所有的相对路径名都是从工作目录开始解释。文件系统根的名字"/"是一个特殊的绝对路径名。它不包含任何其他的字符。

Linux系统提供了强大的帮助说明文档,比如查看UNIX系统ls命令的 man帮助手册:man 1 ls或者man -s1 lsls表示待查找的shell command。1表示第一部分。由于很多命令的说明文档过于庞大,因此 UNIX将说明文档划分成九个部分。常用的段有:

  1. 可执行程序或者shell command的说明

  1. 系统调用的说明

  1. 库函数的说明

  1. 特殊文件(通常位于/dev/)的说明

  1. 系统管理员命令的说明(通常只有root用户可用)

我们可以将 LINUX 操作系统中的 man手册用中文的man手册替代。方法为(Ubuntu操作系统下):

sudo apt-get install manpages-zh
sudo vi /etc/manpath.config

最后将 man的配置文件/etc/manpath.config中所有的/usr/share/man替换为/usr/share/man/zh_CN即可


错误管理

当 UNIX 系统函数出错时,通常会返回一个负值,同时整型变量 erron 通常被设置为具有特定信息的值。

#include
char *strerror(int errnum);
#include
void perror(const char*msg);

上边两个函数用于处理错误信息。常用的是perror(),下面是函数使用代码和结果截图:

#include
#include
#include
void test_perror(int new_errno,const char*msg)
{
    M_TRACE("---------  Begin test_perror(%d,\"%s\")  ---------\n",new_errno,msg);
    int old_errno=errno;  //保存旧值
    errno=new_errno;  // 赋新值
    printf("perror(\"%s\") of errno=%d\n",msg,errno);
    perror(msg);
    errno=old_errno; // 还原旧值
    M_TRACE("---------  End test_perror(%d,\"%s\")  ---------\n\n",new_errno,msg);
}
Linux应用开发之文件与IO流_第1张图片

系统限制

UNIX 系统定义两种类型的限制,一种是编译时限制,如short、int 最大值是多少,还有一种是运行时限制,如文件名最长多少个字符。常编译时限制可以在头文件中定义;运行时限制则要求进程调用一个函数获得限制值。POSIX 与ISO C限制定义了很多如整型大小以及涉及操作系统实现限制的常量。这些常量大多数在中。运行时限制可通过下面三个函数给定。

#include
long sysconf(int name); 
long pathconf(const char*pathname,int name);
long fpathconf(int fd,int name); //fd 为文件描述符

文件与目录

文件描述符 :一个非负整数,范围是0~OPEN_MAX-1。内核用它来标识进程正在访问的文件。当进程创建时,默认为它打开了3个文件描述符,它们都链接向终端:

  • 0: 标准输入

  • 1: 标准输出

  • 2: 标准错误输出

通常我们应该使用STDIN_FILENO,STDOUT_FILENO和 STDERR_FILENO来替代这三个幻数(幻数:就是具体的数,反映不出数字所代表的意义。),从而提高可读性。这三个常量位于中。

文件IO操作

#include
#include
int open(const char* path,int oflag,.../*mode_t mode*/);
int creat(const char*path,mode_t mode);
int close(int fd);
off_t lseek(int fd, off_t offset,int whence);
ssize_t read(int fd,void *buf,size_t nbytes);
ssize_t write(int fd,const void *buf,size_t nbytes);

上述六个函数是我们常用的文件IO操作,具体我就不一一说明了,我们使用上面函数来创建一个有趣的文件。下面的代码会创建一个size很大,但是占用空间很小的文件:

#include
#include
#include
#include
#include
#include
 
int main(int argc, char* argv[]) {
  int fd;
  if(argc < 2) {
    fprintf(stderr,"Usage %s \n", argv[0]);
    exit(1);
  }
 
  fd = open(argv[1],O_WRONLY | O_CREAT | O_TRUNC, 0777);
  if(fd < 0) {
    perror("fopen()");
  }
 
  lseek(fd, 5ll*1024ll*1024ll*1024ll-1ll, SEEK_SET);
  write(fd," ",1);
  close(fd);
  exit(0);
}

原子操作与描述符管理

内核使用三种数据结构描述打开文件。它们之间的关系决定了一个进程与另一个进程在打开的文件之间的相互影响。现在假设进程 A 打开文件 file1,返回文件描述符 3;进程 B 也打开文件 file2,返回文件描述符 2:

Linux应用开发之文件与IO流_第2张图片

进程 B要往一个文件追加写数据,使用 lseek 定位到文件当前的尾端,则进程 B 对应的文件表项的当前文件偏移量设置为 i 结点中的当前长度。若此时进程B被打断,进程A通过 O_APPEND选项打开文件,然后直接write,若CPU控制权再回到进程B时,此时再继续往刚才lseek到的位置写,就会出现问题。使用下面两个原子定位读和原子定位写接口就能将lseek和read/write绑定为原子操作,则不会出现上述的问题。

#include
ssize_t pread(int fd,void*buf,size_t nbytes,off_t offset);
ssize_t pwrite(int fd,const void*buf,size_t nbytes,off_t offset);

复制一个现有的文件描述符有dup()和dup2()两个接口,dup2()用把新文件描述符关闭再打开做成了原子操作,防止close()之后有别的线程捕获文件关闭信号之后修改文件描述符而导致一些同步的问题。下面是dup2使用的一个例子:

#include 
#include 
#include 
#include
#include
#include 
#define FNAME "/tmp/out"

int main() {
  int fd;

  fd = open(FNAME, O_WRONLY | O_CREAT | O_TRUNC, 0777);
  if(fd < 0) {
    perror("open()");
    exit(1);
  }
  
  // 如果没有使用文件描述符号就会出错,不原子
  // close(1);
  // dup(fd);

  dup2(fd,1);
  
  if(fd != 1) {
      clse(1);
  }

  puts("hello");
  exit(0);
}

UNIX操作系统在内核中设有缓冲区,大多数磁盘 I/O 都通过缓冲区进行。当我们想文件写入数据时,内核通常都首先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式称为延迟写。有很多手动将延迟写的数据库写入磁盘的接口如sync()。下面这个向文件里输出时间的例子使用了标准IO库里的fflush()函数来冲洗了一个流。

#include 
#include 
#include 
#include 
#include 

#define FILE_NAME "/tmp/out"
#define BUF_SIZE 1024

int main(int argc, char* argv[]) {
  FILE* fp;
  fp = fopen(FILE_NAME, "a+");
  if (NULL == fp) {
    perror("fopen()");
    exit(1);
  }

  char buf[BUF_SIZE];
  int count = 0;
  while (NULL != fgets(buf, BUF_SIZE, fp)) {
    ++count;
  }

  time_t stamp;
  struct tm* tm;
  while (1) {
    time(&stamp);
    tm = localtime(&stamp);
    fprintf(fp, "%-4d%d-%d-%d %d:%d:%d\n", count++, tm->tm_year + 1900,
            tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec);
    //文件流采用全缓冲模式,手动刷新一下比较好。
    fflush(fp);
    sleep(1);
  }
  fclose(fp);
  exit(0);
}

fcntl函数用于改变已经打开的文件的属性,项目中也会用到,具体的使用说明可以使用man手册看一下。/dev/fd目录下是名为0、1、2等的文件。打开文件/dev/fd/n等效于复制描述符(假定描述符n是打开的)

Linux应用开发之文件与IO流_第3张图片

文件与目录

文件与目录这部分的接口繁杂,UNIX文件系统、硬链接、软链接、及其删除与重命名操作;stat 查询文件结构和权限相关;创建不具有不同访问权限的文件,修改文件访问权限和文件所属用户,文件长度以及文件的时间属性等;还有很多诸如opendir()的目录操作接口。

这些不同类型的接口往往都有shell命令帮助我们在终端对文件和目录进行操作,下面的例子是手写了一个du命令的实现,通过这个例子希望读者能够理解Linux环境的文件与目录操作。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define PATHSIZE 4096

// 判断目录是否指向自己或上级目录
static int path_nolopp(const char *path) {  
  char *pos = strrchr(path, '/');

  if (!pos) {
    exit(1);
  }

  if (0 == strcmp(pos + 1, ".") || 0 == strcmp(pos + 1, "..")) {
    return 0;
  } else {
    return 1;
  }
}

// 主函数返回目录或文件的大小单位为KB
static long long mydu(char *path) {
  int sum = 0;
  static struct stat statres;

  if (lstat(path, &statres) < 0) {
    perror("lstat()");
    exit(1);
  }

  //非目录文件
  if (!S_ISDIR(statres.st_mode)) {
    return statres.st_blocks / 2;
  }

  //目录文件
  glob_t globres;  // glob_t是typedef 定义的别名
  static char nextPath[PATHSIZE];
  
  sum = statres.st_blocks / 2;  //加上目录文件本身
  strncpy(nextPath, path, PATHSIZE);
  strncat(nextPath, "/*", PATHSIZE-1);
  glob(nextPath, 0, NULL, &globres);
  strncpy(nextPath, path, PATHSIZE);
  strncat(nextPath, "/.*", PATHSIZE-1);
  glob(nextPath, GLOB_APPEND, NULL, &globres);
  for (int i = 0; i < globres.gl_pathc; ++i) {
    if (path_nolopp(globres.gl_pathv[i])) {
      sum += mydu(globres.gl_pathv[i]);
    }
  }

  globfree(&globres);
  return sum;
}

int main(int argc, char *argv[]) {
  if (argc < 2) {
    fprintf(stderr, "Usege %s \n", argv[0]);
    exit(1);
  }
  mydu(argv[1]);
  printf("%d\n", mydu(argv[1]));
  exit(0);
}

标准&高级I/O库

标准IO库的函数很多都是以 f开头,如fopen、fclose。标准IO库与文件IO区别如下:

  • 标准IO库处理很多细节,如缓冲区分片、以优化的块长度执行IO等。

  • 文件IO函数都是围绕文件描述符进行。首先打开一个文件,返回一个文件描述符;后续的文件IO操作都使用该文件描述符。

  • 标准IO库是围绕流进行的。当用标准IO库打开或者创建一个文件时,就有一个内建的流与之相关联。

标准I/O库

当使用fopen函数打开一个流时,它返回一个执行FILE对象的指针。该对象通常是一个结构,包含了标准IO库为管理该流所需要的所有信息,包括:

  • 用于实际IO的文件描述符

  • 指向用于该流缓冲区的指针

  • 该流缓冲区的长度

  • 当前在缓冲区中的字符数

  • 出错标志

应用程序没必要检验FILE对象,只需要将FILE指针作为参数传递给每个标准IO函数。

Linux应用开发之文件与IO流_第4张图片

对于ASCII字符集,一个字符用一个字节表示;对于国际字符集,一个字符可以用多个字节表示。这就导致了流的宽度可能不一样。fwide函数能够修改流的宽度,是宽(多字节)定向还是字节定向。

操作系统对每个进程与定义了3个流,并且这3个流可以自动地被进程使用,他们都是定义在中:

  • 标准输入:预定义的文件指针为stdin,它内部的文件描述符就是STDIN_FILENO

  • 标准输出:预定义的文件指针为stdout,它内部的文件描述符就是STDOUT_FILENO

  • 标准错误:预定义的文件指针为stderr,它内部的文件描述符就是STDERR_FILENO

标准IO库提供缓冲的目的是:尽量减少使用readwrite调用的次数。标准IO库对每个IO流自动地进行缓冲管理,从而避免了程序员需要手动管理这一点带来的麻烦。

标准IO库提供了三种类型的缓冲:

  1. 全缓冲:此时在标准IO缓冲区被填满后,标准IO库才进行实际的IO操作。

  1. 行缓冲:此时当输入和输出中遇到换行符时,标准IO库执行实际的IO操作。

  1. 不带缓冲:标准IO库不对字符进行缓冲存储。此时任何IO都立即执行实际的IO操作。

可以通过setbuf()/setvbuf()函数来设置流的缓冲类型。缓冲这块尤其针对行缓冲以及标准流有一些很细的问题,这里就不详述了,我们常用的还是上面文件IO操作中的向文件里输出时间那个例子一样,使用fflush函数来将缓冲中的数据冲洗到磁盘上。

标准1/O库提供了很多操作文件流的接口,包括打开关闭流;读写流;格式化输入输出;以及像fmemopen()等其他接口。我们还是通过一个实际的例子,来体会这些接口的使用。

下面这个mycopy.c使用getc()与putc()接口实现了一个复制文件的功能:

#include 
#include 
#include 
#include 

int main(int argc, char* argv[]) {
  FILE *fps, *fpd;
  // 出错时返回负值不要用char。
  int ch;

  if(argc < 3) {
    fprintf(stderr,"Usage:%s  \n",argv[0]);
    exit(1);
  }

  fps = fopen(argv[1], "r");
  if (NULL == fps) {
    perror("fopen(fps)");
    exit(1);
  }

  fpd = fopen(argv[2], "w");
  if (NULL == fpd) {
    // 不过关闭会导致内存泄漏。
    fclose(fps);
    perror("fopen(fpd)");
    exit(1);
  }

  while (1) {
    ch = fgetc(fps);
    if (EOF == ch) break;
    fputc(ch, fpd);
  }

  puts("well down!");
  fclose(fpd);
  fclose(fps);
  exit(0);
}

我们编写下面的脚本来编译这个文件,并使用它实现对first_paper.zip压缩包的拷贝,并在控制台打印程序所消耗的时钟时间,用户CPU时间以及系统CPU时间:

#!/bin/bash
BUFSIZE=128
let MAXBUF=2**$1
let i=1
while [ $BUFSIZE -le  $MAXBUF ] 
do
    echo
    echo "$i" 
    let i+=1
    echo "$BUFSIZE"
    gcc mycopy.c -DBUFSIZE=$BUFSIZE
    time ./a.out ~/Documents/first_paper.zip ~/Download
    rm -f ~/Downloads/first_paper.zip 
    let BUFSIZE*=2
done

高级IO

Linux系统提供了很多高级IO接口来帮助我们实现对“文件"(这里的文件可能代表着一个诸如串口的设备)的不同操作需求。我们可以使用fcntl将文件设置为非阻塞访问,可以避免盲等设备的回复。我们也可以使用记录锁阻止其他进程修改同一个文件区。

我们嵌入式开发常见的需求是IO多路转换,即能监视多个fd,一旦某fd就绪(读或写就绪),能够通知程序进行相应读写操作。涉及的接口有select()、poll()以及epoll()。

我在实际的项目中用的就是epoll来监控的多路串口,因为select,poll需自己主动不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但它是设备就绪时,调用回调函数,把就绪fd放入就绪链表,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但select和poll在“醒着”时要遍历整个fd集合,而epoll在“醒着”的时候只需判断就绪链表是否为空,节省大量CPU时间,这就是回调机制带来的性能提升。

下面是一个使用epoll()接口来完成我们嵌入式应用开发常见的"中继"需求,即从一个串口接收到的数据,转发到另一个串口。实际的项目应用过程中还可能还涉及到协议的拆包和封包等工作,下面的例子省去了这一部分,只实现了"透传"的功能。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define TTY1 "/dev/tty11"
#define TTY2 "/dev/tty12"
#define BUFFSIZE 1024

enum { STATE_R = 1, STATE_W, STATE_AUTO, STATE_Ex, STATE_T };

struct fsm_st {
  int state;
  int sfd;
  int dfd;
  char buf[BUFFSIZE];
  int len;
  int pos;
  char* err;
};

static int max(int a, int b) { return a > b ? a : b; }

static void fsm_driver(struct fsm_st* fsm) {
  switch (fsm->state) {
    case STATE_R: {
      fsm->len = read(fsm->sfd, fsm->buf, BUFFSIZE);
      if (0 == fsm->len) {
        fsm->state = STATE_T;
      } else if (fsm->len < 0) {
        if (errno == EAGAIN) {
          fsm->state = STATE_R;
        } else {
          fsm->err = "read()";
          fsm->state = STATE_Ex;
        }
      } else {
        fsm->pos = 0;
        fsm->state = STATE_W;
      }
      break;
    }

    case STATE_W: {
      int ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
      if (ret < 0) {
        if (errno == EAGAIN) {
          fsm->state = STATE_W;
        } else {
          fsm->err = "write()";
          fsm->state = STATE_Ex;
        }
      } else {
        fsm->pos += ret;
        fsm->len -= ret;
        if (0 == fsm->len) {
          fsm->state = STATE_R;
        } else {
          fsm->state = STATE_W;
        }
      }
      break;
    }

    case STATE_Ex: {
      fprintf(stderr, fsm->err);
      fsm->state = STATE_T;
      break;
    }

    case STATE_T: {
      // do something!
      break;
    }

    default: {
      abort();
      break;
    }
  }
}

static void relay(int fd1, int fd2) {
  struct fsm_st fsm12, fsm21;
  struct epoll_event ev;

  //全部转化为非阻塞状态
  int fd1_save = fcntl(fd1, F_GETFL);
  fcntl(fd1, F_SETFL, fd1_save | O_NONBLOCK);
  int fd2_save = fcntl(fd2, F_GETFL);
  fcntl(fd2, F_SETFL, fd2_save | O_NONBLOCK);

  fsm12.state = STATE_R;
  fsm12.sfd = fd1;
  fsm12.dfd = fd2;

  fsm21.state = STATE_R;
  fsm21.sfd = fd2;
  fsm21.dfd = fd1;

  int epfd = epoll_create(10);
  if (epfd < 0) {
    perror("epoll_create()");
    exit(1);
  }
  ev.events = 0;
  ev.data.fd = fd1;
  epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);
  ev.events = 0;
  ev.data.fd = fd2;
  epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);

  while (fsm12.state != STATE_T || fsm21.state != STATE_T) {
    // 布置监视任务
    ev.data.fd = fd1;
    ev.events = 0;
    if (STATE_R == fsm12.state) {
      ev.events |= EPOLLIN;
    }
    if (STATE_W == fsm21.state) {
      ev.events |= EPOLLOUT;
    }
    epoll_ctl(epfd, EPOLL_CTL_MOD, fd1, &ev);

    ev.data.fd = fd2;
    ev.events = 0;
    if (STATE_R == fsm21.state) {
      ev.events |= EPOLLIN;
    }
    if (STATE_W == fsm12.state) {
      ev.events |= EPOLLOUT;
    }
    epoll_ctl(epfd, EPOLL_CTL_MOD, fd2, &ev);

    // 监视
    if (fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) {
      while (epoll_wait(epfd, &ev, 1, -1) < 0) {
        if (EINTR == errno) {
          continue;
        }
        perror("epoll()");
        exit(1);
      }
    }

    // 查看监视结果
    if (ev.data.fd == fd1 && ev.events & EPOLLIN ||
        ev.data.fd == fd2 && ev.events & EPOLLOUT || fsm12.state > STATE_AUTO) {
      fsm_driver(&fsm12);
    }
    if (ev.data.fd == fd2 && ev.events & EPOLLIN ||
        ev.data.fd == fd1 && ev.events & EPOLLOUT || fsm21.state > STATE_AUTO) {
      fsm_driver(&fsm21);
    }
  }

  fcntl(fd1, F_SETFL, fd1_save);
  fcntl(fd2, F_SETFL, fd2_save);

  close(epfd);
}

int main() {
  int fd1, fd2;
  fd1 = open(TTY1, O_RDWR);
  if (fd1 < 0) {
    perror("open()");
    exit(1);
  }
  write(fd1, "TTY1\n", 5);

  fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
  if (fd1 < 0) {
    perror("open()");
    exit(1);
  }
  write(fd2, "TTY2\n", 5);

  relay(fd1, fd2);

  close(fd1);
  close(fd2);
  exit(0);
}

上述的接口本质上还是同步I/O,POSIX异步IO接口为不同类型的文件进行异步IO提供了一套一致的方法。Linux还提供了readv()与writev()用于在一次函数调用中读、写多个非连续缓冲区。我们嵌入式常用的存储映射IO接口mmap(),下面这个例子就是用这个接口,实现了父子进程之间的通信。父子进程在本篇博文有点超纲,下一篇您可以看到相关的介绍,目前您可以理解成两个独立的任务。

#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MEMSIZE 1024
int main() {
  char *ptr = mmap(NULL, MEMSIZE, PROT_READ | PROT_WRITE,
                   MAP_SHARED | MAP_ANONYMOUS, -1, 0);
  if (MAP_FAILED == ptr) {
    perror("mmap()");
    exit(1);
  }

  pid_t pid = fork();
  if (pid < 0) {
    perror("fork");
    munmap(ptr, MEMSIZE);
    exit(1);
  }
  if (0 == pid) {
    strcpy(ptr, "hello!");
    munmap(ptr, MEMSIZE);
    exit(0);
  } else {
    wait(NULL);
    puts(ptr);
    munmap(ptr, MEMSIZE);
    exit(0);
  }
}

十六宿舍 原创作品,转载必须标注原文链接。
©2023 Yang Li. All rights reserved.
欢迎关注 『十六宿舍』 ,大家喜欢的话,给个 ,更多关于嵌入式相关技术的内容持续更新中。

你可能感兴趣的:(嵌入式开发Linux专题,unix,linux应用开发,apue)