NYC's Blog - C C语言,编程语言中的瑞士军刀。 2017-05-25T09:39:00+08:00 Typecho http://niyanchun.com/feed/atom/tag/c/ <![CDATA[Linux下的非阻塞IO库epoll]]> http://niyanchun.com/epoll-in-linux.html 2017-05-25T09:39:00+08:00 2017-05-25T09:39:00+08:00 NYC https://niyanchun.com 今天看到一篇文章,对于epoll讲解的非常生动清晰,转载收藏一下,原文请点击here

生活中的一个例子

假设你在大学中读书,要等待一个朋友来访,而这个朋友只知道你在A号楼,但是不知道你具体住在哪里,于是你们约好了在A号楼门口见面。如果你使用的阻塞IO模型来处理这个问题,那么你就只能一直守候在A号楼门口等待朋友的到来,在这段时间里你不能做别的事情,不难知道,这种方式的效率是低下的。

进一步解释select和epoll模型的差异。select版大妈做的是如下的事情:比如同学甲的朋友来了,select版大妈比较笨,她带着朋友挨个房间进行查询谁是同学甲,你等的朋友来了,于是在实际的代码中,select版大妈做的是以下的事情:

int n = select(&readset, NULL, NULL, 100); 
for (int i = 0; n > 0; ++i) {
     if (FD_ISSET(fdarray[i], &readset)) {
         do_something(fdarray[i]); --n; 
    } 
}

epoll版大妈就比较先进了,她记下了同学甲的信息,比如说他的房间号,那么等同学甲的朋友到来时,只需要告诉该朋友同学甲在哪个房间即可,不用自己亲自带着人满大楼的找人了,于是epoll版大妈做的事情可以用如下的代码表示:

n = epoll_wait(epfd, events, 20, 500);
     for(i=0;i<n;++i) { do_something(events[n]);
}

在epoll中,关键的数据结构epoll_event定义如下:

typedef union epoll_data {
     void *ptr;
     int fd; 
     __uint32_t u32;
     __uint64_t u64; 
}epoll_data_t; 

struct epoll_event { 
    __uint32_t events; /* Epoll events */ 
    epoll_data_t data;/* User data variable */
 };

可以看到,epoll_data是一个union结构体,它就是epoll版大妈用于保存同学信息的结构体,它可以保存很多类型的信息:fd,指针,等等。有了这个结构体,epoll大妈可以不用吹灰之力就可以定位到同学甲。

别小看了这些效率的提高,在一个大规模并发的服务器中,轮询IO是最耗时间的操作之一。再回到那个例子中,如果每到来一个朋友楼管大妈都要全楼的查询同学,那么处理的效率必然就低下了,过不久楼底就有不少的人了。

对比最早给出的阻塞IO的处理模型,可以看到采用了多路复用IO之后,程序可以自由的进行自己除了IO操作之外的工作,只有到IO状态发生变化的时候由多路复用IO进行通知,然后再采取相应的操作,而不用一直阻塞等待IO状态发生变化了。

从上面的分析也可以看出,epoll比select的提高实际上是一个用空间换时间思想的具体应用。

深入理解epoll的实现原理

开发高性能网络程序时,windows开发者们言必称iocp,linux开发者们则言必称epoll。大家都明白epoll是一种IO多路复用技术,可以非常高效的处理数以百万计的socket句柄,比起以前的select和poll效率高大发了。我们用起epoll来都感觉挺爽,确实快,那么,它到底为什么可以高速处理这么多并发连接呢?先简单回顾下如何使用C库封装的3个epoll系统调用吧。

int epoll_create(int size); 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

使用起来很清晰,首先要调用epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。epoll_ctl可以操作上面建立的epoll,例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。

从上面的调用方式就可以看到epoll比select/poll的优越之处:

因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

static int __init eventpoll_init(void) { 
    ... ... 
    /* Allocates slab cache used to allocate "struct epitem" items */ 
    epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct  epitem), 0, SLAB_HWCACHE_ALIGN| EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); 
    /* Allocates slab cache used to allocate "struct eppoll_entry" */ 
    pwq_cache = kmem_cache_create("eventpoll_pwq", sizeof(struct eppoll_entry), 0, EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); 
    ... ...

epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。

这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪
链表里了。

如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。

所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

扩展阅读

Linux提供了select、poll、epoll接口来实现IO复用,三者的原型如下所示,本文从参数、实现、性能等方面对三者进行对比。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

select、poll、epoll_wait参数及实现对比:

select的第一个参数nfds为fdset集合中最大描述符值加1,fdset是一个位数组,其大小限制为__FD_SETSIZE(1024),位数组的每一位代表其对应的描述符是否需要被检查。
select的第二三四个参数表示需要关注读、写、错误事件的文件描述符位数组,这些参数既是输入参数也是输出参数,可能会被内核修改用于标示哪些描述符上发生了关注的事件。
所以每次调用select前都需要重新初始化fdset。
timeout参数为超时时间,该结构会被内核修改,其值为超时剩余的时间。
select对应于内核中的sys_select调用,sys_select首先将第二三四个参数指向的fd_set拷贝到内核,然后对每个被SET的描述符调用进行poll,并记录在临时结果中(fdset),如果有事件发生,select会将临时结果写到用户空间并返回;当轮询一遍后没有任何事件发生时,如果指定了超时时间,则select会睡眠到超时,睡眠结束后再进行一次轮询,并将临时结果写到用户空间,然后返回。 select返回后,需要逐一检查关注的描述符是否被SET(事件是否发生)。

poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。
poll的实现机制与select类似,其对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll,相比处理fdset来说,poll效率更高。 poll返回后,需要对pollfd中的每个元素检查其revents值,来得指事件是否发生。

epoll通过epoll_create创建一个用于epoll轮询的描述符,通过epoll_ctl添加/修改/删除事件,通过epoll_wait检查事件,epoll_wait的第二个参数用于存放结果。 epoll与select、poll不同,首先,其不用每次调用都向内核拷贝事件描述信息,在第一次调用后,事件信息就会与对应的epoll描述符关联起来。另外epoll不是通过轮询,而是通过在等待的描述符上注册回调函数,当事件发生时,回调函数负责把发生的事件存储在就绪事件链表中,最后写到用户空间。

更多关于select的知识可参考我之前的博客《select(pselect)函数》。

]]>
<![CDATA[关于exit、_EXIT和_exit]]> http://niyanchun.com/exit-_exit-_exit.html 2016-06-30T22:00:00+08:00 2016-06-30T22:00:00+08:00 NYC https://niyanchun.com exit、_EXIT和_exit三个函数都是终止一个进程,但是三者却有一些差异。网上对于它们的介绍也是特别的多,这里我就不重复造轮子了,只是从我的角度和观点去归纳性的描述一些这三者的异同。

  1. exit和_EXIT都是标准C提供的库函数,定义在<stdlib.h>中;而_exit并不是标准C的库函数,它是Posix扩展定义的。
  2. 在类UNIX系统中,一般_exit是系统调用,而exit和_EXIT都是库函数。而且_exit和_EXIT功能一般是一样的。exit最终也会调用_exit。
  3. exit和_EXIT/_exit的区别在于调用exit退出程序的时候,程序不会直接退出,而是会做一些操作:(1)调用有atexit含住注册的终止处理程序(如果有的话)(2)关闭所有标准I/O流等。而后两者一般都会直接退出程序,不会去做上面的两个操作(是否关闭I/O流取决于实现)。
  4. 程序里面调用return返回相当于调用了exit。

在很多场景中,我们使用那一个函数退出程序没有太大区别。但是有些特殊场景,我们可能就得注意选择合适的函数。如果我们希望程序退出前可以执行终止处理程序,那肯定是需要调用exit或者return。但如果我们希望程序直接退出,不要再做任何多余的操作。这在错误处理程序里面比较常见,一般错误处理程序是整个程序的最后一道防线,不能再引发新的错误,我们希望程序可以立即退出。这种情况下,一般使用_exit和_EXIT.

有了这些概念我觉得我们基本上就知道如何去使用这几个函数了,如果你对细节感兴趣的话,可以去看一下man文档(man 3 _exit 或者man 3p _exit ,后者需要你装了POSIX的man文档),里面有更加详细的说明,或者去看一下具体的代码实现。

]]>
<![CDATA[C的字符串分割函数strtok]]> http://niyanchun.com/strtok-in-c.html 2016-06-19T11:16:00+08:00 2016-06-19T11:16:00+08:00 NYC https://niyanchun.com 对于一些高级语言而言,使用某个字符分割字符串是很基础的功能。而对于编程语言中的瑞士军刀C,该功能自然也是有的,我们使用标准C提供的strtok函数即可实现这个功能。这个函数在使用上面看上去有些怪,我们从一个例子开始吧。

一,strtok基本使用

#include <stdio.h>
#include <string.h>

int main()
{
    char *token;
    char line[] = "ab,c:d e fngh";
    char *delims = ",: tn";

    /* 第一次调用strtok时第一个参数为要处理的字符串 */
    token = strtok(line, delims);
    while (token != NULL)
    {
        printf ( "%sn", token );
        /* 后续调用传NULL */
        token = strtok(NULL, delims); 
    }

    return 0;
}

程序执行结果如下:

ab
c
d
e
f
gh

然后再看下strtok的函数原型:

char *strtok(char *str, const char *delim);

strtok的第一个参数毫无疑问就是我们想要处理的字符串,第二个参数是分割符。这里注意第二个参数是字符串,而不是字符。因为strtok支持使用多个分割字符分割字符串,甚至在每次调用strtok的时候可以重新指定分割符。

strtok的使用方法:strtok第一次调用的时候str参数传我们要处理的字符串,后续再调用的时候第一个参数传NULL。然后我们多次调用strtok,直到返回NULL则解析结束。每一次调用,返回分割得到的字符串,该字符串包含字符串结束符。

二,strtok实现细节及使用时的注意事项

我们看一下glic中strtok的实现:

#include <string.h>

static char *olds;

#undef strtok

#ifndef STRTOK
# define STRTOK strtok
#endif

/* Parse S into tokens separated by characters in DELIM.
   If S is NULL, the last string strtok() was called with is
   used.  For example:
    char s[] = "-abc-=-def";
    x = strtok(s, "-");     // x = "abc"
    x = strtok(NULL, "-=");     // x = "def"
    x = strtok(NULL, "=");      // x = NULL
        // s = "abc=-def"
*/
char *
STRTOK (char *s, const char *delim)
{
  char *token;

  if (s == NULL)
    s = olds;

  /* Scan leading delimiters.  */
  s += strspn (s, delim);
  if (*s == '')
    {
      olds = s;
      return NULL;
    }

  /* Find the end of the token.  */
  token = s;
  s = strpbrk (token, delim);
  if (s == NULL)
    /* This token finishes the string.  */
    olds = __rawmemchr (token, '');
  else
    {
      /* Terminate the token and make OLDS point past it.  */
      *s = '';
      olds = s + 1;
    }
  return token;
}

可以看到,其实strtok就是一个循环字符串解析的过程,解析过程这里就不详细分析了,有兴趣的可以自己研究一下这段代码。但从这个实现中我们可以得出一些使用strtok的注意点:

1, strtok会改变传给它的字符串(代码里面的s = ''),细心的人可能已经发现它的第一个参数不是const的。所以我们一定得保证传的字符串是可以更改的。最常见的错误是给strtok传一个char的字符串字面常量(这样可能会导致段错误):

char *str1 = "hello world";
char str2[] = "hello world";

注意,上面的str1就是字符串字面常量,这种定义方法定义的字符串是常量字符串,是const的,是不可以改变的。诸如str1[1] = 'c';的赋值操作都是不允许的。而str2的定义方法则只是一个普通的数组,是可以更改的。

2, 连续的分割符都会被跳过。这个是什么意思呢?举个例子:比如对于字符串"ab::cd:",我们使用分割符":"分割的时候,返回值是"ab"、"cd"、NULL,而不是"ab"、""(空字符串)、"cd"、NULL。

3, 如果目标字符串是空或者只包含分割符,那么我们第一次调用strtok的时候,就会返回NULL。

最后简单介绍以下上面glic实现strtok时使用的几个库函数:

#include <string.h>

size_t strspn(const char *s, const char *accept);

size_t strcspn(const char *s, const char *reject);

char *strpbrk(const char *s, const char *accept);

strspn()从参数s 字符串的开头计算连续的字符,而这些字符都完全是accept 所指字符串中的字符。简单的说,若strspn()返回的数值为n,则代表字符串s 开头连续有n 个字符都是属于字符串accept内的字符。

strcspn()从参数s 字符串的开头计算连续的字符, 而这些字符都完全不在参数reject 所指的字符串中. 简单地说, 若strcspn()返回的数值为n, 则代表字符串s 开头连续有n 个字符都不含字符串reject 内的字符.

strpbrk是在源字符串(s1)中找出最先含有搜索字符串(s2)中任一字符的位置并返回,若找不到则返回空指针。

]]>
<![CDATA[Lex和Yacc——Lex学习]]> http://niyanchun.com/lex-learning.html 2016-06-02T15:32:00+08:00 2016-06-02T15:32:00+08:00 NYC https://niyanchun.com 一,什么是Lex?

引用度娘上面的介绍:

Lex是LEXical compiler的缩写,是Unix环境下非常著名的工具,主要功能是生成一个词法分析器(scanner)的C源码,描述规则采用正则表达式(regular expression)。
OK,我们先来介绍Lex中的正则表达式。

二,Lex中的正则表达式

字符 含义
**.** 匹配任意字符,除了 n。一般作为最后一条翻译规则。
**-** 用来指定范围。例如:A-Z 指从 A 到 Z 之间的所有字符。
**[ ]** 一个字符集合。匹配括号内的 _任意_ 字符。如果第一个字符是 **^** 那么它表示否定模式。例如: [abC] 匹配 a, b, 和 C中的任何一个。
***** 匹配 _0个_或者多个上述的模式。
**+** 匹配 _1个_或者多个上述模式。
**?** 匹配 _0个或1个_上述模式。
**$** 作为模式的最后一个字符匹配一行的结尾。
**{ }** 指出一个模式可能出现的次数。 例如: A{1,3} 表示 A 可能出现1次或3次。
用来转义元字符。同样用来覆盖字符在此表中定义的特殊意义,只取字符的本意。
**^** 否定。
**|** 表达式间的逻辑或。
**"<一些符号>"** 字符的字面含义。如“a+b”,就表示a+b
**/** 向前匹配(或称为超前搜索)。比如R1/R2(R1和R2是正则式),若要匹配R1,则必须先看紧跟其后的超前搜索部分是否与R2匹配。举个例子:DO/{alnum}*={alnum}*,表示如果想匹配DO,则必须先在DO后面找到形式为{alnum}*={alnum}*的串,才能确定匹配DO。
**( )** 将一系列常规表达式分组。

基本上,Lex中的正则表达式和UNIX bash里面的正则表达式是一致的。我们看一些例子:

  • abc:匹配abc
  • [abc]:匹配a、b、c三者之一
  • [A-Z]:匹配A~Z的所有大写字母
  • [A-Z]:匹配A、-、Z三者之一
  • [-AZ]:和[A-Z]相同
  • 1:匹配除ab之外的任意字符
  • [a^b]:匹配a、^、b三者之一
  • [a|b]:匹配a、|、b三者之一
  • a|b:匹配a或者b
  • abc+:匹配abcc、abccc、abcccc、...
  • a(bc)+:匹配abcbc、abcbcbc、abcbcbcbc、...
  • ^abc$:匹配只有abc的行
  • [0-9]+:匹配整数。
  • -?[0-9]:匹配带符号的整数。
  • -?[0-9]*.[0-9]+:匹配带符号的小数。
    从上面的一些例子我们可以总结出一些注意点:

(1) []表示一个集合,匹配集合里面的某个元素;不加[]时则匹配一个串。
(2)“-”只有在方括号的中间时,才表示范围,否则只是普通字符。类似的还有“^”(只有在首部时,才表示否定)、“$”(只有在末尾时,才表示行尾)、“|”(只有在方括号外使用时才表示或).
(3)“*”、“+”、“?”只匹配之前离他们最近的那个元素,如果要匹配多个,就需要加括号。

PS:C语言中的一些转义字符也可以出现在正则式中,比如\t \n \b等。

三,Lex的基本原理和方法

Lex的基本工作原理为:由正则表达式生成NFA,将NFA变换成DFA,DFA经化简后,模拟生成词法分析器。

什么是NFA呢?NFA全称Nondeterministic Finite Automata,表示不确定有穷自动机,一般采用tompson构造来构建NFA。

一个不确定的有穷自动机T是一个五元组,M={K,∑,f,S,Z}

⒈K是一个有穷集他的每一个元素称作一个状态。
⒉∑是一个字母表,他的每一个元素称为一个输入符号。
⒊f是一个从Kx∑到K的子集映射即K∑*->2^K,其中2^K表示K的幂集。
⒋S包含于K集,是一个非空初态集合。
⒌Z包含于K是一个非空的终态集合。

DFA英文全称:Deterministic Finite Automaton

定义:一个确定有穷自动机(DFA)M是一个五元组:M=(K,Σ,f,S,Z)其中

① K是一个有穷集,它的每个元素称为一个状态;
② Σ是一个有穷字母表,它的每个元素称为一个输入符号,所以也称Σ为输入符号字母表;
③ f是转换函数,是K×Σ→K上的映射(且可以是部分函数),即,如 f(ki,a)=kj,(ki∈K,kj∈K)就意味着,当前状态为ki,输入符为a时,将转换为下一个状态kj,我们把kj称作ki的一个后继状态;
④ S ∈ K是唯一的一个初态;
⑤ Z⊂K是一个终态集,终态也称可接受状态或结束状态。

其中正则表达式由开发者使用Lex语言编写,其余部分由Lex翻译器(比如GUN下面的flex工具)完成。翻译器将Lex源程序翻译成一个名为lex.yy.c的C语言源文件(所以Lex是和C强相关的),此文件含有两部分内容:一部分是根据正则表达式所构造的DFA状态转移表,另一部分是用来驱动该表的总控程序yylex()。当主程序需要从输入流中识别一个记号时,只需要调用一次yylex()就可以了。为了使用Lex所生成的词法分析器,我们需要将lex.yy.c程序用C编译器进行编译,并将相关支持库函数连入目标代码。Lex的使用步骤如下图所示:

lex1

四,Lex的语法

Lex源程序必须按照Lex语言的规范来写,其核心是一组词法规则(正则表达式)。一般而言,一个Lex源程序分为三个部分,三部分之间用"%%"分隔:

第一部分:定义段

%%

第二部分:词法规则段

%%

第三部分:辅助函数段

当然,第一部分和第三部分使可以省略的,也就是说Lex程序段也可以精简成下面这样:

%%      /* 不能少 */

第二部分:词法规则段

一般以%开头的符号和关键字,或者是词法规则段的各个规则一般顶行来写,前面没有空格。这个很重要,如果不这样,往往会编译失败。Lex程序中支持C的/**/格式注释,但是写在第二部分(词法规则段)的注释的行首必须要有前导空格(即不能顶格写)。好的习惯是所有%和关键字都顶格写,所有注释都不要顶格写。

下面我们看各个段落如何写。

1. 第一部分定义段写法

定义段可以分为两部分:

第一部分以符号%{和%}包裹,里面以C语法写一些定义和声明:例如,头文件,宏定义,常数定义,全局变量以及外部变量定义,函数声明等。这一部分被Lex翻译器处理之后会全部拷贝到文件lex.yy.c中。注意,特殊括号%{和%}必须顶着行首写。例如:

%{
#include <stdio.h>

#define MAX_LINE 1024
extern int yylval;
%}

第二部分是一组正则定义和状态定义。正则定义是为了简化后面的词法规则而给部分正则式定义了名字。每条正则定义一定要顶格写。例如:

letter        [A-Za-z]
digit         [0-9]
id            [letter]({letter}|{digit})*

注意,上面正则定义中出现的小括号表示分组,而不是被匹配的字符。而大括号括起的部分表示正规定义名。

状态定义也叫环境定义,它定义了匹配正则式时所处的状态的名字。状态定义以%s开始,后跟所定义的状态的名字,注意%s也要顶格写。例如下面一行就定义了一个名为COMMENT的状态和一个名为BAD的状态,状态之间用空白隔开:

%s COMMENT BAD

2. 第二部分词法规则段的写法

词法规则段列出的是词法分析器需要匹配的正则式,以及匹配该正则式后需要进行的相关动作。例子如下:

while        { return (WHILE); }
do           { return (DO); }
{id}         { yylval = installID(); return (ID); }

每行都是一条规则,该规则的前一部分是正则式,需要顶行来写,后一部分是匹配该正则式后需要进行的动作,这个动作是用C语法来写的,被包裹在{}之内,被Lex翻译器翻译后会被直接拷贝进lex.yy.c。正则式和语义动作之间要有空白隔开。其中用{}扩住的正则式表示正则定义的名字。也可以若干个正则式匹配同一条语义动作,此时正则式之间要用|分隔。

3. 第三部分辅助函数的写法

辅助函数段用C语言语法来写,辅助函数一般是在词法规则段中用到的函数。这一部分一般会被直接拷贝到lex.yy.c中。比如:

main ()
{
    yylex();
}

4. Lex内置的变量及函数

  • yyin和yyout:这是Lex中已经定义的输入和输出文件指针。这两个变量指明了lex生成的词法分析器从哪里获取输入和输出到哪里。默认是标准输入(stdin)和标准输出(stdout)。
  • yytext:指向当前识别的词法单元(词文)的指针,即C里面的char*,一般可以直接用%s打印。
  • yyleng:当前词法单元的长度。
  • ECHO:Lex中预定义的宏,可以出现在(第二部分的)动作中,相当于fprintf(yyout, "%s", yytext),即输出当前匹配的词法单元。
  • yylex():词法分析器驱动程序,用Lex翻译器生成的lex.yy.c内必然包含这个函数。
  • yywrap():词法分析器遇到文件结尾时会调用yywrap()来决定下一步怎么做:若yywrap()返回0,则继续扫描,若返回1,则返回报告文件结尾的0标记。由于词法分析器总会调用yywrap,因此辅助寒素中最好提供yywrap,如果不提供,则在C编译器编译lex.yy.c时,需要链接相应的库,库中会给出标准的yywrap函数(标准函数返回1)。

5. 词法分析器的状态(环境)

词法分析器在匹配正则式时,可以在不同状态(或环境)下进行。我们可以规定在不同的状态下有不同的匹配方式。每个词法分析器都至少有一个状态,这个状态叫做初始状态,可以用INITIAL或0来表示,如果还需要使用其他状态,可以在定义段用%s来定义。使用状态时,可以用如下方式写词法规则:

<state1, state2> p0     { action0; }
<state1> p1             { action1; }

这两行词法规则表示:在状态state1和state2下,匹配正则式p0后执行动作action0,而只有在状态state1下,才可以在匹配正则式p1后执行动作action1。如果不指明状态,默认情况下处于初始状态INITIAL。

要想进入某个特定状态,可以在动作中写上这样一句:BEGIN state;执行这个动作后,就进入状态state。下面是一段处理C语言注释的例子,里面用到了状态的转换,在这个例子里,使用不同的状态,可以让词法分析器在处于注释中和处于注释外时使用不同的匹配规则:

...
%s c_comment
...

%%

<INITIAL>"/*"      { BEGIN c_comment; }
...
<c_comment>"*/"    { BEGIN 0; }
<c_comment>.       { ; }

6. Lex的匹配策略

  • 按最长匹配原则确定被选中的单词。
  • 如果一个字符串能被若干正则式匹配,则先匹配排在前面的正则式。

五,一个实例

Lex常常与语法分析器的生成工具yacc同时使用。此时,一般来说,语法分析器每次都调用一次yylex()获取一个记号。如果想自己写一个程序使用lex生成的词法分析器,则只需要在自己的程序中按需要调用yylex()函数即可。需要注意的是,yylex()函数调用结束后,输入缓冲区并不会被重置,而是仍然停留在刚才读到的地方。并且,词法分析器当前所处的状态(%s定义的那些状态)也不会改变。

exam1.l

/* 这是注释部分,与C中的/*...* /注释相同 */
/* 第一部分是定义、声明部分。这部分内容可以为空 */

%{

/* 写在 %{...%}这对特殊括号内的内容会被直接拷贝到C文件中。
 *
 * 这部分通常进行一些头文件声明,变量、常量的定义,用C语法。
 *
 * %{和%}两个符号都必须位于行首
 */

/* 下面定义了需要识别的记号名,如果和yacc联合使用,这些记号名应该在yacc中定义 */

#include <stdio.h>

#define LT          1
#define LE          2
#define GT          3
#define GE          4
#define EQ          5
#define NE          6

#define WHILE       18
#define DO          19
#define ID          20
#define NUMBER      21
#define RELOP       22

#define NEWLINE     23
#define ERRORCHAR   24

int yylval;
/* yylval是yacc中定义的变量,用来保存记号的属性值,默认是int类型。
 * 在用lex实现的词法分析器中可以使用这个变量将记号的属性传递给yacc
 * 实现的语法分析器。
 * 
 * 注意:该变量只有在联合使用lex和yacc编写词法和语法分析器时才可以
 * 在lex中使用,此时该变量不需要定义即可使用。单独使用lex时,编译器
 * 找不到这个变量。所以这里定义了该变量。
 */
int installID();
int installNum();

%}

 /*
 * 这里进行正则定义和状态定义。下面就是正则定义,注意,正则定义和状态
 * 定义都要顶格写。
 */

delim       [ t n]
ws          {delim}+
letter      [A-Za-z_]
digit       -?[0-9]
id          {letter}({letter}|{digit})*
number      {digit}+(.{digit}+)?(E[+-]?{digit}+)?

 /* %%作为lex文件三个部分的分隔符,必须位于行首。下面这个%%不能省略 */
%%

 /* 第二部分是翻译规则部分。
  * 写在这一部分的注释要有前导空格,否则lex编译出错。
  * 翻译规则形式是:正则式 {动作}
  * 其中,正则式要顶行写,动作以C语法写(动作会被拷贝到yylex()函数中),
  * 正则式和动作之间要用空白分隔。
  */

{ws}        { ; /* 此时词法分析器没有动作,也不返回,而是继续分析 */}

 /* 正则式部分用大括号扩住表示正则定义名,例如{ws}
  * 没有扩住的直接表示正则式本身。
  * 一些正则元字符没办法表示它本身,此时可以用转义字符或双引号
  * 括起来,例如"<"
  */
while       { return (WHILE); }
do          { return (DO); }
{id}        { yylval = installID(); return (ID); }
{number}    { yylval = installNum(); return (NUMBER); }
"<"          { yylval = LT; return (RELOP); }
"<="     { yylval = LE; return (RELOP); }
"="         { yylval = EQ; return (RELOP); }
"<>"      { yylval = NE; return (RELOP); }
">"          { yylval = GT; return (RELOP); }
">="     { yylval = GE; return (RELOP); }

.           { yylval = ERRORCHAR; return ERRORCHAR; }
    /* .匹配除换行符以外的任何字符,一般可作为最后一条翻译规则 */

%%
/* 第三部分是辅助函数部分,这部分内容个以及前面的%%都可以省略。
 * 辅助函数可以定义“动作”中使用的一些函数。这些函数使用C语言编写,
 * 并会被直接拷贝到lex.yy.c中。
 */
int installID()
{
    /* 把词法单元装入符号表并返回指针 */
    return ID;
}

int installNum()
{
    return NUMBER;
}

/* yywrap这个辅助函数是词法分析器遇到输入文件结尾时会调用的,用来
 * 决定下一步怎么做:若yywrap返回0,则继续扫描;返回1,则词法分析
 * 器返回报告文件已结束的0。
 * lex库中的标准yywrap程序就是返回1,你也可以定义自己的yywrap。
 */
int yywrap()
{
    return 1;
}

void writeout(int c)
{
    switch(c)
    {
        case ERRORCHAR: 
            fprintf(yyout, "(ERRORCHAR, "%s") ", yytext);
            break;
        case RELOP: 
            fprintf(yyout, "(RELOP, "%s") ", yytext);
            break;    
        case WHILE:
            fprintf(yyout, "(WHILE, "%s") ", yytext);
            break;
        case DO: 
            fprintf(yyout, "(DO, "%s") ", yytext);
            break;
        case NUMBER: 
            fprintf(yyout, "(NUM, "%s") ", yytext);
            break;
        case ID: 
            fprintf(yyout, "(ID, "%s") ", yytext);
            break;
        case NEWLINE: 
            fprintf(yyout, "n");
            break;

        default:
            break;
    }

    return;
}

/* 如果你的词法分析器并不是作为语法分析器的子程序,而是有自己的输入
 * 输出,你可以在这里定义你的词法分析器的main函数,main函数里可以调
 * 用yylex().
 */
int main(int argc, char **argv)
{
    int c, j = 0;
    if (argc >= 2)
    {
        if ((yyin = fopen(argv[1], "r")) == NULL)
        {
            printf("can not open file %sn", argv[1]);
            return 1;
        }

        if (argc >= 3)
        {
            yyout = fopen(argv[2], "w");
        }
    }

    while (c = yylex())
    {
        writeout(c);
        j++;

        if (j%5 == 0)
            writeout(NEWLINE);
    }

    if (argc >= 2)
    {
        fclose(yyin);
        if (argc >= 3)
            fclose(yyout);

    }

    return 0;
}

exam2.l:

%{

#include <stdio.h>
#define LT                  1
#define LE                  2
#define GT                  3
#define GE                  4
#define EQ                  5
#define NE                  6
#define LLK                 7
#define RLK                 8
#define LBK                 9
#define RBK                 10
#define IF                  11
#define ELSE                12
#define EQU                 13
#define SEM                 14

#define WHILE               18
#define DO                  19
#define ID                  20
#define NUMBER              21
#define RELOP               22

#define NEWLINE             23
#define ERRORCHAR           24
#define ADD                 25
#define  DEC                26
#define  MUL                27
#define  DIV                28

%}

delim       [ t n]
ws          {delim}+
letter      [A-Za-z]
digit       [0-9]
id          {letter}({letter}|{digit})*
number      {digit}+(.{digit}+)?(E[+-]?{digit}+)?

%s COMMENT
%s COMMENT2

%%

<INITIAL>"/*"           {BEGIN COMMENT;}
<COMMENT>"*/"           {BEGIN INITIAL;}
<COMMENT>.|n            {;}
<INITIAL>"//"           {BEGIN COMMENT2;}
<COMMENT2>n             {BEGIN INITIAL;}
<COMMENT2>.                 {;}

<INITIAL>{ws}           {;}
<INITIAL>while              {return (WHILE);}
<INITIAL>do                 {return (DO);}
<INITIAL>if                 {return (IF);}
<INITIAL>else           {return (ELSE);}
<INITIAL>{id}           {return (ID);}
<INITIAL>{number}       {return (NUMBER);}
<INITIAL>"<"             {return (RELOP);}
<INITIAL>"<="            {return (RELOP);}
<INITIAL>"="            {return (RELOP);}
<INITIAL>"!="           {return (RELOP);}
<INITIAL>">"             {return (RELOP);}
<INITIAL>">="            {return (RELOP);}
<INITIAL>"("            {return (RELOP);}
<INITIAL>")"            {return (RELOP);}
<INITIAL>"{"            {return (RELOP);}
<INITIAL>"}"            {return (RELOP);}
<INITIAL>"+"            {return (RELOP);}
<INITIAL>"-"            {return (RELOP);}
<INITIAL>"*"            {return (RELOP);}
<INITIAL>"/"            {return (RELOP);}
<INITIAL>";"            {return (RELOP);}

<INITIAL>.                  {return ERRORCHAR;}

%%

int yywrap()
{
    return 1;
}

void writeout(int c)
{
    switch(c)
    {
        case ERRORCHAR: 
            fprintf(yyout, "(ERRORCHAR, "%s") ", yytext);
            break;
        case RELOP: 
            fprintf(yyout, "(RELOP, "%s") ", yytext);
            break;    
        case WHILE: 
            fprintf(yyout, "(WHILE, "%s") ", yytext);
            break;
        case DO: 
            fprintf(yyout, "(DO, "%s") ", yytext);
            break;
        case IF: 
            fprintf(yyout, "(IF, "%s") ", yytext);
            break;
        case ELSE: 
            fprintf(yyout, "(ELSE, "%s") ", yytext);
            break;
        case NUMBER: fprintf(yyout, "(NUM, "%s") ", yytext);
            break;
        case ID: 
            fprintf(yyout, "(ID, "%s") ", yytext);
            break;
        case NEWLINE: 
            fprintf(yyout, "n");
            break;

        default:
            break;

    }
    return;
}

int main (int argc, char ** argv)
{
    int c,j=0;
    if (argc>=2)
    {
        if ((yyin = fopen(argv[1], "r")) == NULL)
        {
            printf("Can't open file %sn", argv[1]);
                return 1;
        }

        if (argc>=3)
        {
            yyout=fopen(argv[2], "w");
        }
    }

    while (c = yylex())
    {
        writeout(c);
        j++;

        if (j%5 == 0) 
            writeout(NEWLINE);
                            }
        if(argc>=2)
        {
            fclose(yyin);
            if (argc>=3) 
                fclose(yyout);
        }
        return 0;
}

使用lex/flex和gcc工具编译:

flex exam1.l   // 会生成lex.yy.c
gcc lex.yy.c -o exam1 -ll  // 可以去掉后面链接lex库的操作(-ll),因为我们自己定义了yywrap()函数

flex exam2.l
gcc lex.yy.c -o exam2

下面分别是两组测试数据:

test1.sample:

while a >= -1.2E-2
do b<=2

test2.sample:

while a >= -1.2E-2
do b <= 2
if else
+
-
*
/
;   //的发生过
/* 请注意:测试文件的格式必须符合要求,比如文件格式必须是UNIX格式 */

测试结果如下:

allan@NYC:~/test/lex_yacc$ ./exam1 < test1.sample 
(WHILE, "while") (ID, "a") (RELOP, ">=") (NUM, "-1.2E-2") (DO, "do") 
(ID, "b") (RELOP, "<=") (NUM, "2") allan@NYC:~/test/lex_yacc$ 
allan@NYC:~/test/lex_yacc$ 
allan@NYC:~/test/lex_yacc$ ./exam2 < test2.sample 
(WHILE, "while") (ID, "a") (RELOP, ">=") (RELOP, "-") (NUM, "1.2E-2") 
(DO, "do") (ID, "b") (RELOP, "<=") (NUM, "2") (IF, "if") 
(ELSE, "else") (RELOP, "+") (RELOP, "-") (RELOP, "*") (RELOP, "/") 

本文整理自网络。


  1. ab
]]>
<![CDATA[Linux进程间通信]]> http://niyanchun.com/linux-ipc.html 2016-05-29T17:12:00+08:00 2016-05-29T17:12:00+08:00 NYC https://niyanchun.com 我们知道多进程的程序会比多线程的程序在稳定性上面好一些,因为资源是隔离的。但这也是的多进程程序在相互通信方面比多线程复杂一些,但Linux系统还是提供了很多进程通信的方式,统称为IPC(InterProcess Communication).常见的IPC有管道、FIFO、消息队列、信号量、共享内存以及套接字等。

一,管道(PIPE)

管道(有时也称为无名管道,和后面要介绍的FIFO相对)是UNIX/LINUX系统IPC里面最简单的一个,但是它的使用却是非常广泛的。这个名字很形象,其实简单理解他就是一个可以流通数据的管道——数据从一端流入,从另一端流出。比如下图,当我们创建一个Pipe后,我们从fd[1]写数据到该管道,然后数据经pipe从fd[0]端流出。

pipe-1

因为很简单,所以管道有两种局限性:

  • 管道一般是半双工的(有的系统可能实现为全双工),即数据只能在一个方向上流动。
  • 它们只能在具有公共祖先的进程间使用。一般管道最经典的使用方式是一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可以利用该管道通信。
    我们解释一下上面的第二条:如下图,假设父进程打开了0和1号描述符,然后我们调用fork,我们知道子进程会复制父进程的文件描述符,并且父子进程共享同一个文件表项(见我之前的博客《Linux文件I/O》)。此时,父子进程的0号描述符都指向管道的读端,1号描述符都指向管道的写端。如果我们想构建从父进程到子进程的数据流向管道时,我们就可以关闭父进程的0号描述符,关闭子进程的1号描述符。

pipe-2

创建管道可以使用pipe函数:

#include <unistd.h>

int pipe(int pipefd[2]);

函数成功返回0,失败返回-1.如果调用成功,经由pipefd返回两个文件描述符:pipefd[0]是读端描述符,pipefd[1]是写端描述符。

下面我们看一个例子:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    int     pfds[2];
    char    buf[20];

    pipe(pfds);

    if (fork() > 0)
    {
        close(pfds[0]);  /* parent close read end */
        printf ( "parent: writing to the pipe ...n" );
        write(pfds[1], "test", 5);

        wait(NULL); /* wait child process exit in case of a zombie process */
        exit(0);
    }
    else
    {
        close(pfds[1]); /* child close write end */
        printf ( "child: reading from pipe ...n" );
        read(pfds[0], buf, 20);
        printf ( "child read message: %sn", buf );
        exit(0);
    }

    return 0;
}

这个例子很简单(仅作为示例,没有出错处理),父进程创建管道,然后fork出子进程。父进程关闭读端,子进程关闭写端。父进程通过管道向子进程发送数据。程序运行结果:

allan@NYC:~/test$ ./a.out 
parent: writing to the pipe ...
child: reading from pipe ...
child read message: test

最后,我们再看两个问题:

1.  如果管道的一端关闭后,会怎样?

  • 当读一个写端已经关闭的管道时,在所有数据都被读取后,read返回0,以指示到达了文件结束处。
  • 如果写一个读端已关闭的管道,则会产生SIGPIPE。

2.  管道的缓冲区多大?

管道是内核提供的一种IPC,所以它的缓冲也是内核确定的。常量PIPE_BUF规定了内核中管道的缓冲区大小。如果对管道调用write,而且要求写的字节数小于等于PIPE_BUF,则此操作不会与其它进程对同一管道(或后面要介绍FIFO)的write穿插进行。但是,若多个进程同时写一个管道/FIFO,而且进程要求写的字节数超过PIPE_BUF字节数时,则写操作数据就可能穿插进行。

二,FIFO

FIFO(First in, First out)又被称为命名管道。Pipe只能是具有血缘关系的进程才可以相互通信,但是FIFO却可以在不相关的进程间交换数据(因为FIFO是有"名字"的)。我们可以使用mkfifo或者mknod创建FIFO:

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
int mknod(const char *path, mode_t mode, dev_t dev);

一般mkfifo也是调用的mknod。两个函数都是成功返回0,失败返回-1。一般的I/O函数都可用于FIFO。

下面我们先看两个例子程序:speak.c和tick.c。speak.c会往FIFO里面写数据,tick.c会从FIFO读数据。

speak.c:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

#define FIFO_NAME "fifo_test"

int main(void)
{
    char    s[300];
    int     status, num, fd;

    status = mkfifo(FIFO_NAME, S_IFIFO | 0666);
    if (status != 0)
    {
        if (errno == EEXIST)
        {
            printf ( "fifo already exists.n" );
        }
        else
        {
            printf ( "mkfifo failed.n" );
            return EXIT_FAILURE;
        }
    }

    printf ( "waiting for readers ...n" );
    fd = open(FIFO_NAME, O_WRONLY);
    printf ( "got a reader -- type some stuffn" );

    while (fgets(s, 300, stdin), !feof(stdin))
    {
        if ((num = write(fd, s, strlen(s))) == -1)
            perror("write");
        else
            printf ( "speak: wrote %d bytesn", num);
    }

    return EXIT_SUCCESS;
}               /* ----------  end of function main  ---------- */

tick.c:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

#define FIFO_NAME "fifo_test"

int main(void)
{
    char    s[300];
    int     status, num, fd;

    status = mkfifo(FIFO_NAME, S_IFIFO | 0666);
    if (status < 0)
    {
        if (errno == EEXIST)
        {
            printf ( "fifo already existsn" );
        }
        else
        {
            printf ( "mkfifo failed, errno=%dn", errno );
            return EXIT_FAILURE;
        }
    }

    printf ( "waiting for writers ...n" );
    fd = open(FIFO_NAME, O_RDONLY);
    printf ( "got a writern" );

    do {
        if ((num = read(fd, s, 300)) == -1)
            perror("read");
        else
        {
            s[num] = '';
            printf ( "tick: read %d bytes: "%s"n", num, s );
        }

    }while (num > 0);

    return EXIT_SUCCESS;
}

我们编译之后运行时会发现:不管是先运行speak还是先运行tick,都会阻塞在open的地方,直到运行另外一个程序的时候阻塞的程序才会继续往下运行。原因是默认是以阻塞的方式打开FIFO的,在这种模式下,(1)以只读打开FIFO时进程会阻塞到有其他进程为写此FIFO而打开(2)以只写打开FIFO时,进程会阻塞到有其他进程为读此FIFO而打开。当然,我们可以在打开FIFO的时候指定为O_NOBLOCK,这样的话只读open立即返回。只写open的话如果没有进程为写而打开此FIFO,那么将出错返回-1,errno是ENXIO。

另外:

  1. 类似于Pipe,若用write写一个尚未读而打开的FIFO,则产生SIGPIPE。
  2. 若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIFO的读进程产生一个文件结束标志。
  3. 一般一个给定的FIFO有多个写进程是很常见的,这就意味着如果不希望多个进程所写的数据互相穿插,则需要考虑原子操作。和Pipe类似,厂里PIPE_BUF说明了可被原子的写到FIFO的最大数据量。

三,XSI IPC

1. XSI IPC共同点

XSI IPC是消息队列、信号量、共享内存的统称,因为他们有很多相似的地方。

每个内核中的IPC结构都用一个非负整数的标识符加以引用。例如,为了对一个消息队列发送或获取消息,只需要直到其队列标识符。与文件描述符不同,IPC标识符不是小的整数。当一个IPC结构被创建,以后又被删除时,与这种结构相关的标识符连续加1,直至达到一个整形数的最大正值,然后又转回0.不过标识符是IPC对象的内部名。为了使多个合作的进程能够在同一个IPC对象上会合,需要提供一个外部名方案。为此使用了键(key),每个IPC对象都与一个键相关联,于是键就用作该对象的外部名。我们可以使用ftok函数来获取一个key(key_t):

#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);

函数成功返回key,失败返回-1.

关于该函数有以下几个要点:

  • pathname必须是存在且可访问的文件;
  • proj_id是非0整数,ftok会使用它的低八位;
  • ftok的内部机制一般是获取给定的文件的stat结构,从该结构中获取部分st_dev和st_ino字段,然后再与proj_id组合起来。所以,对于相同的参数,ftok返回的key肯定是一样的。

另外,XSI IPC还为每一个IPC结构设置了一个ipc_perm结构,该结构规定了IPC对象的权限和所有者,它至少包括下列成员:

struct ipc_perm {
uid_t uid; /* owner’s effective user ID */
gid_t gid; /* owner’s effective group ID */
uid_t cuid; /* creator’s effective user ID */
gid_t cgid; /* creator’s effective group ID */
mode_t mode; /* access modes */
.
.
.
};

在创建IPC结构时,对所有字段赋初值,以后可以调用msgctl、semctl或shmctl修改这些字段,调用进程必须是IPC结构的创建者或者超级用户。

另外,系统对于每种XSI IPC都有资源限制(使用ipcs -l命令可以查看),后面会看到。当然我们可以更改这些限制,比如使用sysctl命令。

2. 消息队列

消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。每个消息队列都有一个msqid_ds结构与其相关联:

struct msqid_ds {
   struct ipc_perm msg_perm;     /* Ownership and permissions */
   time_t          msg_stime;    /* Time of last msgsnd(2) */
   time_t          msg_rtime;    /* Time of last msgrcv(2) */
   time_t          msg_ctime;    /* Time of last change */
   unsigned long   __msg_cbytes; /* Current number of bytes in
                                    queue (nonstandard) */
   msgqnum_t       msg_qnum;     /* Current number of messages
                                    in queue */
   msglen_t        msg_qbytes;   /* Maximum number of bytes
                                    allowed in queue */
   pid_t           msg_lspid;    /* PID of last msgsnd(2) */
   pid_t           msg_lrpid;    /* PID of last msgrcv(2) */
};

Linux内核对于消息队列的限制:

allan@NYC:~$ ipcs -ql

------ Messages Limits --------
系统最大队列数量 = 32000
max size of message (bytes) = 8192   # 可发送最长消息的字节数
default max size of queue (bytes) = 16384  # 一个特定队列的最大字节数(即队列中所有消息之和)

和消息队列相关的函数:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

(1)msgget:用于打开一个现存队列或者创建一个新队列

满足下列两个条件之一就会创建一个新的消息队列:

  • key是IPC_PRIVATE
  • flag为IPC_CREATE,且没有当前队列为key
    如果我们要创建一个新的IPC结构,而且要确保不是引用具有同一标识符的一个现行IPC结构,那就必须在flag中同时指定IPC_CREATE和IPC_EXCL位。这样做了以后,如果IPC结构已经存在就会造成出错,返回EEXIST。后面要介绍的semget、sheget和msgget类似。另外,msg_qnum、msg_lspid、msg_lrpid、msg_stime、msg_rtime都被设置为0;msg_ctime设置为当前时间;msg_qbytes设置为系统限制值。

函数执行成功,msgget返回非负队列ID,此后,该值可被用于其他三个消息队列函数。

(2)msgctl:对队列执行多种操作

cmd参数说明对由msqid指定的队列要执行的命令:

IPC_STAT  取此队列的msqid_ds结构,并将它存放在buf指向的结构中。

IPC_SET  按由buf指向的结构的值,设置与此队列相关结构中的下列四个字段:msg_perm.uid、msg_perm.gid、msg_perm.mode和msg_qbytes。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid;另一种是具有超级用户特权的进程。只有超级用户才能增加msg_qbytes的值。

IPC_RMID  从系统中删除该消息队列以及仍在队列中的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在他们下一次试图对此队列进行操作时,将出错返回EIDRM。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid;另一种是具有超级用户特权的进程。
这三个参数也适用于后面要介绍的信号量和共享内存。

(3)msgsnd:将数据放到消息队列中

消息队列的每个消息包含一个正长整型类型字段,一个非负长度以及实际数据字节(对应于长度)。消息总是放在队列尾端。msgp参数指向一个长整型数,它包含了正的整型消息类型,在其后紧跟着消息数据。若msgsz参数是0,则无消息数据。若发送的最长消息是512字节,则可定义下列结构:

struct mymesg
{
    long mtype;      /* positive message type */
    char mtext[512]; /* message data, of length nbytes */
};

必须要注意的是msgsz参数指的是消息体的长度,不包含正的整型消息类型。

参数msgflg的值可以指定为IPC_NOWAIT。这类似于文件I/O的非阻塞标志。若消息队列已满(或者是队列中的消息总数等于系统限制值,或队列中的字节总数等于系统限制值),则执行IPC_NOWAIT使得msgsnd立即出错返回EAGAIN。如果没有指定IPC_NOWAIT,则进程阻塞直到下述情况出现为止:有空间可以容纳要发送的消息;从系统中删除了此队列(msgsnd返回-1,且errno设置为EIDRM);或捕捉到一个信号,并从信号处理返回(msgsnd返回-1,errno设置为EINTR)。

当msgsnd成功返回,与消息队列相关的msqid_ds结构得到了更新,以标明发出该调用的进程ID(msg_lspid)、进行该调用的时间(msg_stime),并指示队列中增加了一条消息。

(4)msgrcv:从队列中取消息

参数msgp和msgsnd含义一样,有一个消息类型和一个存放实际消息的数据缓冲区组成。msgsz说明数据缓冲区的长度。若返回的消息大于msgsz,而且在msgflg中设置了MSG_NOERROR,则该消息被截断(在这种情况下,不通知我们消息截断了,消息的截去部分被丢弃)。如果没有设置这个标志,而消息又太长,则出错返回E2BIG(消息仍留在队列中)。

参数msgtyp使我们可以指定想要哪一种消息:

  • msgtyp == 0:返回队列中的第一个消息;
  • msgtyp >= 0:返回队列中消息类型为msgtyp的第一个消息;
  • msgtyp <= 0:返回队列中消息类型值小于或等于msgtyp绝对值的消息,如果这个消息有若干个,则取类型值最小的消息。
    参数msgflg如果为IPC_NOWAIT,则操作不阻塞,这使得如果没有指定类型的消息,则msgrcv返回-1,errno设置为ENOMSG。如果没有指定,则进程阻塞直到下列情况出现:有了指定类型的消息;从系统中删除了此队列(msgrcv返回-1,且errno设置为EIDRM);或捕捉到一个信号并从喜好处理程序返回(msgrcv返回-1,errno设置为EINTR)。

msgrcv成功执行时,内核更新与消息队列相关的msqid_ds结构。

最后我们看两个例子,send_msg.c创建队列,并往队列发送消息;recv_msg.c从队列读取消息:

send_msg.c:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct my_msgbuf
{
    long    mtype;
    char    mtext[200];
};

int main(void)
{
    struct my_msgbuf    buf;
    int                 msqid;
    key_t               key;

    if ((key = ftok("send_msg.c", 'B')) == -1)
    {
        perror("ftok");
        exit(1);
    }

    if ((msqid = msgget(key, 0644 | IPC_CREAT)) == -1)
    {
        perror("msgget");
        exit(1);
    }

    printf ( "Enter lines of text, ^D to quit:n" );

    buf.mtype = 1; /* we don't really care in this case */

    while (fgets(buf.mtext, sizeof buf.mtext, stdin) != NULL)
    {
        int len = strlen(buf.mtext);

        if (buf.mtext[len-1] == 'n')
            buf.mtext[len-1] = '';

        if (msgsnd(msqid, &buf, len+1, 0) == -1) /* +1 for '' */
            perror("msgsnd");
    }

    if (msgctl(msqid, IPC_RMID, NULL) == -1)
    {
        perror("msgctl");
        exit(1);
    }

    return 0;
}

recv_msg.c:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct my_msgbuf
{
    long    mtype;
    char    mtext[200];
};

int main(void)
{
    struct my_msgbuf    buf;
    int                 msqid;
    key_t               key;

    if ((key = ftok("send_msg.c", 'B')) == -1)
    {
        perror("ftok");
        exit(1);
    }

    if ((msqid = msgget(key, 0644)) == -1)
    {
        perror("msgget");
        exit(1);
    }

    printf ( "ready to receive message:n" );

    for (;;)
    {
        if (msgrcv(msqid, &buf, sizeof buf.mtext, 0, 0) == -1)
        {
            perror("msgrcv");
            exit(1);
        }
        printf ( ""%s"n", buf.mtext );
    }

    return 0;
}

3. 信号量

信号量不同于我们之前介绍的IPC机构,它是一个计数器,用于多进程对共享对象的访问。但是XSI的信号量集比较复杂,有三个特性造成了这种非必要的复杂性:

  • XSI提供的是信号量集,而不是单个信号量。我们只能创建信号量集,而不能创建单个信号量。当然如果我们创建信号量集时,指定集合中的信号量数目为0,就创建了一个信号量。
  • 创建信号量集(semget)和对其赋初值(semctl)是分开的。这是一个致命的弱点,因为不能原子的创建一个信号量集,并且对该集合中的信号量赋初值。
  • 即使没有进程正在使用各种形式的XSI IPC,他们仍然是存在的。有些程序在终止时并没有释放已经分配给它的信号量集,所以我们不得不为这种程序担心。后面要说明的undo功能就是假定要处理这种情况的。

内核为每个信号量集设置了一个semid_ds结构(定义在sys/sem.h中):

struct semid_ds {
   struct ipc_perm sem_perm;  /* Ownership and permissions */
   time_t          sem_otime; /* Last semop time */
   time_t          sem_ctime; /* Last change time */
   unsigned long   sem_nsems; /* No. of semaphores in set */
};

每个信号量由一个无名结构表示,它至少包含下列成员:

struct 
{
    unsigned short    semval;        /* semaphore value, always &gt;= 0 */
    pid_t            sempid;        /* pid for last operation */
    unsigned short    semncnt;    /* # processes awaiting semval &gt; curval */
    unsigned short    semzcnt;    /* # processes awaiting semval == 0 */
    .
    .
    .
};

和信号量相关的函数:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
成功返回信号量ID,失败返回-1.

int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
失败返回-1,成功返回值依赖于cmd。

int semop(int semid, struct sembuf *sops, size_t nsops);
成功返回0,失败返回-1.

(1)semget:创建信号量

semget和msgget类似,多了一个nsems参数。这个参数是要创建的信号量集中的信号量数。如果是创建新集合(一般在服务器进程中),则必须指定nsems。如果是引用一个现存的集合,则讲nsems指定为0.

(2)semclt:对信号量进行各种操作

这个函数的第四个参数是可选的,如果使用该参数,则其类型是semun,他是多个特定命令参数的联合(union)(不是指向联合的指针):

union semun {
   int              val;    /* Value for SETVAL */
   struct semid_ds *buf;    /* Buffer for IPC_STAT, IPC_SET */
   unsigned short  *array;  /* Array for GETALL, SETALL */
   struct seminfo  *__buf;  /* Buffer for IPC_INFO
                               (Linux-specific) */
};

cmd参数指定下列10种命令中的一种,在semid指定的信号量集合上执行此命令。其中5条命令是针对一个特定的信号量的,它们用semnum指定该信号量集合中的一个信号量成员。semnum值在0和nsems-1之间(包括0和nsems-1)。

IPC_STAT     对此集合取semid_ds结构,并存放在arg.buf指向的结构中。

IPC_SET      按由arg.buf指向结构中的值设置与此集合相关结构中的下列三个字段值:sem_perm.uid、sem_perm.gid和sem_perm.mode。此命令只能由下列两种进程执行:一种是其有效用户ID等于sem_perm.cuid或sem_perm.uid的进程;另                    一种是超级用户特权进程。

IPC_RMID   与之前消息队列那里介绍的一样。

GETVAL      返回成员semnum的semval值。

SETVAL      设置成员semnum的semval值。该值由arg.val指定。

GETPID       返回成员semnum的sempid值。

GETNCNT   返回成员semnum的semncnt值。

GETZCNT    返回成员semnum的semzcnt值。

GETALL      取该集合中所有信号量的值,并将它们存放在存放在由arg.array指向的数组中。

SETALL      按arg.array指向的数组中的值,设置该集合中所有信号量的值。
对于除GETALL以外的所有get命令,semctl函数都返回相应的值。其他命令返回值为0.

(3)semop:操作信号量

semop函数自动执行信号量集合上的操作数组,这是个原子操作。参数sops是个指针,它指向一个信号量操作数组,信号量操作由sembuf结构表示:

struct sembuf
{
   unsigned short sem_num;  /* member # in set (0, 1, ..., nsems-1)  */
   short          sem_op;   /* semaphore operations(negative, 0, or positive) */
   short          sem_flg;  /* operation flags: IPC_NOWAIT, SEM_UNDO */
};

参数nsops规定该数组中操作的数量(元素数)。

对集合中每个成员的操作由相应的sem_op值规定。此值可以是负值、0或正值(下面的讨论将提到信号量的undo标志。此标志对应于相应sem_flg成员的SEM_UNDO位。)。

  1. 最易于处理的情况是sem_op为正。这对应于进程释放占用的资源数。sem_op值加到信号量的值上。如果指定了undo标志,则也从该进程的此信号量调整值中减去sem_op。
  2. 若sem_op为负,则表示要获得由该信号量控制的资源。如若该信号量大于或等于sem_op的绝对值,则从信号量值中减去sem_op的绝对值。这保证信号量的结果值大于等于0.如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。如果信号值小于sem_op的绝对值(资源不能满足要求),则:

    1. 若指定了IPC_NOWAIT,则semop出错返回EAGAIN。
    2. 若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生:

      1. 此信号量变成大于或等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1,并且从信号量值中减去sem_op的绝对值。如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。
      2. 从系统中删除了此信号量。在此情况下,函数出错返回EIDRM。
      3. 进程捕捉到一个信号,并从信号处理程序返回。在此情况下,此信号量的senmcnt值减1,并且函数出错返回EINTR。
  3. 若sem_op为0,这表示调用进程希望等待到该信号量值变成0.如果信号量值当前是0,则此函数立即返回。否则:

    1. 若指定了IPC_NOWAIT,则出错返回EAGAIN。
    2. 若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生:

      1. 此信号量值变成0。此信号量的semncnt值减1。
      2. 从系统中删除了此信号量。在此情况下,函数出错返回EIDRM。
      3. 进程捕捉到一个信号,并从信号处理程序返回。在此情况下,此信号量的senmcnt值减1,并且函数出错返回EINTR。
        semop函数具有原子性,它或者执行数组中的所有操作,或者什么也不做。

exit时的信号量调整:正如前面提到的,如果进程终止时,它占用了经由信号量分配的资源,那么就会成为一个问题。无论何时,只要为信号量操作指定了SEM_UNDO标志,然后分配资源(sem_op值小于0),那么内核就会记住对于该特定信号量,分配给调用进程多少资源。当该进程终止时,不论自愿或者不自愿,内核都将检验该进程是否还有尚未处理的信号量调整值,如果有,则按调整值对相应量值进行处理。如果用带SETVAL或SETALL命令的semctl设置一信号量的值,则在所有进程中,对于该信号量的调整值都被设置成0.

最后我们看两个例子:sem_demo.c创建信号量,并模拟文件锁操作,sem_rm.c删除信号量:

sem_demo.c:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

#define MAX_RETRIES 10

union semun
{
    int             val;
    struct semid_ds *buf;
    ushort          *array;
};

int initsem(key_t key, int nsems)
{
    int             i;
    union semun     arg;
    struct semid_ds buf;
    struct sembuf   sb;
    int             semid;

    semid = semget(key, nsems, IPC_CREAT | IPC_EXCL | 0666);
    if (semid >= 0)
    {
        sb.sem_op = 1;
        sb.sem_flg = 0;
        arg.val = 1;

        printf ( "press returnn" ); 
        getchar();

        for (sb.sem_num = 0; sb.sem_num < nsems; sb.sem_num++)
        {
            /* do a semop() to "free" the semaphores. */
            /* this sets the sem_otime filed, as needed below. */
            if (semop(semid, &sb, 1) == -1)
            {
                int e = errno;
                semctl(semid, 0, IPC_RMID); /* clean up */
                errno = e;
                return -1; /* error, check errno */
            }
        }
    } 
    else if (errno == EEXIST)
    {
        int     ready = 0;

        semid = semget(key, nsems, 0); /* get the id */
        if (semid < 0)
            return semid;

        /* wait for other process to initialize the semaphore: */
        arg.buf = &buf;
        for (i = 0; i < MAX_RETRIES && !ready; i++)
        {
            semctl(semid, nsems-1, IPC_STAT, arg);
            if (arg.buf->sem_otime != 0)
            {
                ready = 1;
            }
            else
            {
                sleep(1);
            }
        }

        if (!ready)
        {
            errno = ETIME;
            return -1;
        }
    }
    else
    {
        return semid;
    }

    return semid;
}

int main(void)
{
    key_t   key;
    int     semid;
    struct sembuf sb;

    sb.sem_num = 0;
    sb.sem_op = -1;     /* set to allocate resource */
    sb.sem_flg = SEM_UNDO;

    if ((key = ftok("sem_demo.c", 'J')) == -1)
    {
        perror("ftok");
        exit(1);
    }

    /* grab the semaphore set created by seminit.c */
    if ((semid = initsem(key, 1)) == -1)
    {
        perror("initsem");
        exit(1);
    }

    printf ( "Press return to lock:" );
    getchar();
    printf ( "Trying to lock...n" );

    if (semop(semid, &sb, 1) == -1)
    {
        perror("semop");
        exit(1);
    }

    printf ( "Locked.n" );
    printf ( "Press return to unlock:" );
    getchar();

    sb.sem_op = 1;  /* free resource */
    if (semop(semid, &sb, 1) == -1)
    {
        perror("semop");
        exit(1);
    }

    printf ( "Unlockedn" );

    return 0;
}

sem_rm.c:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

union semun
{
    int val;
    struct semid_ds *buf;
    ushort  *array;
};

int main(void)
{
    key_t       key;
    int         semid;
    union semun arg;

    if ((key = ftok("sem_demo.c", 'J')) == -1)
    {
        perror("ftok");
        exit(1);
    }

    if ((semid = semget(key, 1, 0)) == -1)
    {
        perror("semget");
        exit(1);
    }

    /* remove it */
    if (semctl(semid, 0, IPC_RMID, arg) == -1)
    {
        perror("semctl");
        exit(1);
    }

    return 0;
}

4. 共享内存

共享内存允许多个进程共享一给定的存储区,因为不需要在进程之间复制,所以这是最快的一种IPC。使用共享内存需要注意的就是多进程之间对于共享内存去的并发访问。内核为每个共享存储段设置了一个shmid_ds结构:

struct shmid_ds {
   struct ipc_perm shm_perm;    /* Ownership and permissions */
   size_t          shm_segsz;   /* Size of segment (bytes) */
   time_t          shm_atime;   /* Last attach time */
   time_t          shm_dtime;   /* Last detach time */
   time_t          shm_ctime;   /* Last change time */
   pid_t           shm_cpid;    /* PID of creator */
   pid_t           shm_lpid;    /* PID of last shmat(2)/shmdt(2) */
   shmatt_t        shm_nattch;  /* No. of current attaches */   /* shmatt_t一般定义为无符号整形 */
   ...
};

和共享内存相关的函数:

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);
成功返回共享存储ID,失败返回-1.

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
成功返回0,失败返回-1.

void *shmat(int shmid, const void *shmaddr, int shmflg);
成功返回指向共享内存的指针,失败返回-1.

int shmdt(const void *shmaddr);
成功返回0,失败返回-1.

(1)shmget:创建/引用共享内存

和之前的msgget/semget类似,不过参数size指定要创建的共享内存大小。如果是引用一个现有的共享内存,则size置为0.

(2)shmctl:操作共享内存

使用与semctl类似,但是cmd仅支持IPC_STAT、IPC_SET、IPC_RMID。在有些系统上面还支持 SHM_LOCK(将共享内存段锁定在内存中,只有超级用户才能执行)和SHM_UNLOCK(解锁共享内存段,只有超级用户才可以执行)。

(3)shmat:将共享内存段链接到自己的地址空间中

将共享内存段链接到调用进程的哪个地址上面与addr参数以及在flag中是否指定SHM_RND位有关,但一般都将addr置为0,让内核自己选择地址。

如果在flag中指定了SHM_RDONLY位,则以只读方式连接此段。否则以读写方式连接此段。如果shmat执行成功,那么内核将使该共享内存段shmid_ds结构中的shm_nattch计数器加1.

(4)shmdt:脱接共享内存段

对共享内存操作结束时,调用shmdt可以脱接共享内存段。要注意的是,这并不从系统中删除共享内存。shmdt会使shmid_ds结构中的shm_nattch计数器减1。

下面看一个简单的例子shm_demo.c,该程序没有参数时,打印共享内存里面的内容,如果跟一个参数的话,将这个参数的值写到共享内存里面:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define SHM_SIZE    1024

int main(int argc, char *argv[])
{
    key_t   key;
    int     shmid;
    char    *data;

    if (argc > 2)
    {
        fprintf(stderr, "usage: shmdemo [data_to_write]n");
        exit(1);
    }

    if ((key = ftok("shm_demo.c", 'R')) == -1)
    {
        perror("ftok");
        exit(1);
    }

    /* connect to (and possibly create) the segment */
    if ((shmid = shmget(key, SHM_SIZE, 0644 | IPC_CREAT)) == -1)
    {
        perror("shmget");
        exit(1);
    }

    /* attach to the segment to get a pointer to it: */
    data = shmat(shmid, (void*)0, 0);
    if (data == (char*)(-1))
    {
        perror("shmat");
        exit(1);
    }

    /* read or modify the segment, based on the command line: */
    if (argc == 2)
    {
        printf ( "writing to segment:"%s"n", argv[1] );
        strncpy(data, argv[1], SHM_SIZE);
    }
    else
        printf ( "segment contains:"%s"n", data );

    /* detach from the segment */
    if (shmdt(data) == -1)
    {
        perror("shmdt");
        exit(1);
    }

    return 0;
}

这里总结了PIPE、FIFO、消息队列、信号量、共享内存五种IPC,但其实还有很多其他方式也可以用于进程间通信,比如文件锁、STREAMS(全双工的PIPE)、SOCKET等。后续再介绍。

总结/参考自:

]]>
<![CDATA[Linux进程控制之wait类函数]]> http://niyanchun.com/wait-in-linux.html 2016-05-09T23:10:00+08:00 2016-05-09T23:10:00+08:00 NYC https://niyanchun.com 我们已经知道,fork出来的子进程和父进程谁先运行是随机的,那我们如果控制呢?可以使用wait类函数。

#include <sys/wait.h>

pid_t wait(int *stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int options);

若成功则返回进程pid,0,若出错返回-1

进程调用上面的两个函数可能会:

  • 如果其所有子进程都还在运行,则阻塞。
  • 如果有一个子进程已经终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
  • 如果它没有任何子进程,则立即出错返回。

两个函数的区别如下:

  • 在一个子进程终止前,wait会使其调用者阻塞,而waitpid有一个选项,可使调用者不阻塞。
  • waitpid并不等待在其调用后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。

如果一个子进程已经终止,并且是一个僵尸进程(zombie:一个已经终止,但是其父进程尚未对其进行善后处理的进程),则wait立即返回并获取该子进程的状态,否则wait使其调用者阻塞直到一个子进程终止。如果调用者阻塞而且它有多个子进程,则在其任一个子进程终止时,wait就立即返回。因为wait返回终止子进程的PID,所以它总能了解到是哪个子进程终止了。

这两个函数的参数stat_loc是一个整形指针。如果不关心终止状态,则可以将该参数设置为NULL。如果stat_loc不是NULL,则终止进程的终止状态就存放在它所指向的单元内。那我们如果根据这个整形状态字获取进程的退出状态呢?POSIX.1在<sys/wait.h>中提供了几个互斥的宏来查看:

  • WIFEXITED(stat_loc):若为正常终止子进程返回的状态,则为真。对于这种情况可以执行WEXITSTATUS(stat_loc),取子进程传给exit/_exit/_Exit的低八位。
  • WIFSIGNALED(stat_loc):若为异常终止子进程返回的状态,则为真(接到一个不捕捉的信号),对于这种情况,可使用WTERMSIG(stat_loc)获取子进程终止的信号编号。另外,有些实现定义宏WCOREDUMP(stat_loc),若已产生core文件,则返回真。
  • WIFSTOPPED(stat_loc):若为当前暂停子进程返回的状态,则为真。对于这种情况,可使用WSTOPSIG(stat_loc)获取使子进程暂停的信号编号。
  • WIFCONTINUED(stat_loc):若在作业控制暂停后已经继续的子进程返回了状态,则为真。

可以看到,wait无法控制等待哪一子进程,但是waitpid却可以等待某一个特定的子进程。对于waitpid里面的pid参数有如下解释:

  • pid == -1:等待任一子进程,此时和wait等效。
  • pid >= 0:等待进程ID与pid相等的子进程。这是我们最常使用的情况。
  • pid == 0:等待其组ID等于调用进程组ID的任一子进程。
  • pid <= -1 :等待其组ID等于pid绝对值的任一子进程。

waitpid里面的options参数使我们可以更进一步的控制waitpid的操作。此参数可以为0,或者是下面的三个常量按位或的结果:

  • WCNTINUED:若实现支持作业控制,那么由pid指定的任一子进程在暂停后已经继续,但其状态尚未报告,则返回其状态(POSIX.1的XSI扩展)。
  • WNOHANG:若由pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时其返回值为0.
  • WUNTRACED:若某实现支持作业控制,而由pid指定的任一子进程已处于暂停状态,并且其状态自暂停以来还未报告过,则返回其状态WIFSTOPPED宏确定返回值是否对应于一个暂停子进程。

除了wait和waitpid外,还有一些不太常用的扩展函数:

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

pid_t wait3(int *status, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);

这些函数这里就不介绍了,有兴趣的可以查看man文档。

最后我们看个例子:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

void pr_exit(int status)
{
    if (WIFEXITED(status))
        printf ( "normal termination, exit status = %dn", WEXITSTATUS(status) );
    else if (WIFSIGNALED(status))
        printf ( "abnormal termination, signal number = %d(%s)n", WTERMSIG(status),strsignal(WTERMSIG(status)));
    else if (WIFSTOPPED(status))
        printf ( "child stopped, signal number = %d(%s)n", WSTOPSIG(status), strsignal(WSTOPSIG(status)) );
}

int main(void)
{
    pid_t       pid;
    int         status;

    if ((pid = fork()) < 0)
        exit(1);
    else if (pid == 0)
        exit(7);            /* child */

    if (wait(&status) != pid)
    {
        printf ( "wait errorn" );
        exit(2);
    }
    pr_exit(status);

    if ((pid = fork()) < 0)
        exit(3);
    else if (pid == 0)
        abort();        /* generate SIGABRT */

    if (wait(&status) != pid)
    {
        printf ( "wait errorn" );
        exit(2);
    }
    pr_exit(status);

    if ((pid = fork()) < 0)
        exit(4);
    else if (pid == 0)
        status /= 0;    /* generate SIGFPE */

    if (wait(&status) != pid)
    {
        printf ( "wait errorn" );
        exit(2);
    }
    pr_exit(status);

    exit(0);
}

程序执行结果:

allan@NYC:~/test$ ./wait_test1 
normal termination, exit status = 7
abnormal termination, signal number = 6(Aborted)
abnormal termination, signal number = 8(Floating point exception)
]]>
<![CDATA[Linux进程控制之exec族函数]]> http://niyanchun.com/exec-functions-in-linux.html 2016-05-08T15:07:00+08:00 2016-05-08T15:07:00+08:00 NYC https://niyanchun.com exec族函数是Linux进程控制原语中非常重要的一部分,往往和fork/vfork配合使用。

当一个进程调用一种exec函数时,该进程执行的程序完全替换未新程序,而新程序则从其main函数开始执行(原程序后续部分不会再执行了)。但是调用exec并不创建新进程(不同于fork和vfork),所以调用exec前后进程ID并未改变。exec只是用一个全新的程序替换了当前进程的正文、数据、堆和栈。

下面是exec族的函数:

#include <unistd.h>

extern char **environ;

int execl(const char *path, const char *arg, ...
               /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...
               /* (char  *) NULL */);
int execle(const char *path, const char *arg, ...
               /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execve(const char *path, char *const argv[],
                  char *const envp[]);
int execvp(const char *file, char *const argv[]);

函数失败返回-1,成功则不返回。

这个家族挺大的,挺难记忆的。我们可以稍微总结一下特征:

特点1. 以p结尾的两个函数(execlp,execvp)取文件名作为第一个参数,其它几个取路径名作为参数。这里的文件名可以带绝对路径,也可以不带。这样的话,程序将会取PATH环境变量里面找可执行文件。而其它几个不以p结尾只能使用绝对路径。

程序1:使用不以p结尾的,且不使用路径名作为参数:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>

int main()
{
    printf ( "begin ...n" );
    /* 使用不带p版本的 */
    if (execl("ls", "ls", "-rt", NULL) == -1)   
    {
        perror("execl error");
    }
    printf ( "end ...n" );

    return 0;
}

执行结果:

allan@NYC:~/test$ ./exec_test 
begin ...
execl error: No such file or directory
end ...

程序2:将程序1改为使用以p结尾的

#include <stdio.h>
#include <unistd.h>
#include <errno.h>

int main()
{
    printf ( "begin ...n" );
    if (execlp("ls", "ls", "-rt", NULL) == -1)
    {
        perror("execl error");
    }
    printf ( "end ...n" );

    return 0;
}

执行结果:

allan@NYC:~/test$ ./exec_test 
begin ...
apue.c  bug.c  test1  test1.out  test.dat  file.c  file  c-7.c  c-7  bug_exp  exec_test.c  exec_test

可以看到,程序2可以正常执行。要注意的是,后面的printf并没有执行到。因为exec替换了原有的进程映像。

所以,对于以p结尾的这两个exec函数,我们使用的时候,可以不用指定可执行文件(如果不是ld产生的二进制文件,exec就会尝试用/bin/sh取执行,即把它当做一个shell脚本取执行)的路径,让系统取PATH里面查找。当然也可以指定,但如果指定的话,就一定要指定对,因为系统只会去指定的路径查找,不会再去PATH指定的路径找了。举个例子,ls名字的完整路径是/bin/ls,那么我们可以写成execlp("ls", "ls", "-rt", NULL) ,或者execlp("/bin/ls", "ls", "-rt", NULL),但不能写成execlp("/usr/bin/ls", "ls", "-rt", NULL)。而对于不带p后缀的,第一个参数只能写成"/bin/ls"。即,带不带p决定exec族函数的第一个参数如何写——如果带p,可以不用写正确的绝对路径;如果不带,就必须写正确的绝对路径。

特点2. 参数中的l(list)和v(vector)决定其它参数如何传递(l和v是互斥的,只会包含其中之一)简单说就是,包含l的(execl,execlv,execlp),将要执行的可执行文件的参数直接写到exec函数参数里面;包含v的(execv,execve,execvp),必须先将要执行的可执行文件的参数写到一个字符串数组里面,然后把这个数组作为exec的参数。需要注意的是,包含l的,最后一个参数必须是(char *)0,当然,就是C里面的NULL。

程序2就是包含l的例子,我们举一个包含v的例子,程序3:包含v的

#include <stdio.h>
#include <unistd.h>
#include <errno.h>

char* argv[] = { "ls", "-rt" };

int main()
{
    printf ( "begin ...n" );
    if (execv("/bin/ls", argv) == -1)
    {
        perror("exec error");
    }
    printf ( "end ...n" );

    return 0;
}

执行结果:

allan@NYC:~/test$ ./exec_test 
begin ...
apue.c  bug.c  test1  test1.out  test.dat  file.c  file  c-7.c  c-7  bug_exp  exec_test.c  exec_test

特点3:以e结尾的exec函数(execle,execve)可以(选择性的)传递一个指针数组,该指针数组是一些环境变量的定义。其它的不以e结尾的则使用调用进程总的environ变量(一个系统预定义的全局变量)为新程序复制现有的环境。

下面我们举一个例子来看一下以e结尾exec函数和其它的区别:

程序4:echoall程序,打印命令行参数以及环境变量,在后面的程序中会使用

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    char            **ptr;
    extern  char    ** environ;

    for (int i = 0; i < argc; i++)
    {
        printf ( "argv[%d]: %sn", i, argv[i] );
    }

    for (ptr = environ; *ptr != 0; ptr++)
    {
        printf ( "%sn", *ptr );
    }

    exit(0);
}

程序5:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>

char    *env_init[] = { "USER=known", "PATH=/tmp", NULL };

int main()
{
    pid_t   pid;

    if ((pid = fork()) < 0)
    {
        printf ( "fork errorn" );
        exit(1);
    }
    else if (pid == 0)
    {
        if (execle("/home/allan/test/echoall", "echoall", "myarg1", "MY_ARG2", NULL, env_init) < 0)
        {
            perror ( "execle error" );
            exit(2);
        }
    }

    if (waitpid(pid, NULL, 0) < 0)
    {
        perror("wait error");
        exit(3);
    }

    if ((pid = fork()) < 0)
    {
        printf ( "fork error 2n" );
        exit(4);
    }
    else if (pid == 0)
    {
        if (execlp("/home/allan/test/echoall", "echoall", "only 1 arg", NULL) < 0)
        {
            perror("execlp error");
            exit(5);
        }
    }

    exit(0);
}

程序5运行结果:

allan@NYC:~/test$ ./exec_test 
argv[0]: echoall
argv[1]: myarg1
argv[2]: MY_ARG2
USER=known
PATH=/tmp
allan@NYC:~/test$ argv[0]: echoall
argv[1]: only 1 arg
XDG_VTNR=7
LC_PAPER=zh_CN.UTF-8
/* 中间省略 */
GTK_IM_MODULE=fcitx
LESSCLOSE=/usr/bin/lesspipe %s %s
LC_TIME=zh_CN.UTF-8
LC_NAME=zh_CN.UTF-8
XAUTHORITY=/home/allan/.Xauthority
_=./exec_test

可以看到,对于带e的,使用了我们指定的环境变量,不带e的继承了父进程的环境变量(即系统默认的环境变量)。再强调一点:对于带e的exec函数,我们也可以不传递最后一个envp这个参数,这个和传一个空的envp指针数组是一样的,只是编译的时候会报一个WARNING。当然,这样做的后果是我们新的进程映像就没有任何环境变量了。所以,如果不是要为新的进程指定某个特定的环境(变量),就不要使用带e的exec函数。

最后,我们再说两个知识点:

  1. 虽然exec族有很多函数,但具体的实现中往往只有execve是内核的系统调用,其它的都是库函数,它们最终都要调用execve系统调用。
  2. exec族函数中都有一个arg0参数,习惯上指定为要执行的文件名,但其实没有什么用。程序并不使用这个参数。
    看下面两个例子:

程序6:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    if (execlp("ls", "abc", NULL) < 0)
    {
        perror("execle error");
        exit(1);
    }

    exit(0);
}

程序7:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>

char *argv[] = { "abc" , NULL};

int main()
{
    if (execvp("ls", argv) < 0)
    {
        perror("execle error");
        exit(1);
    }

    exit(0);
}

上面程序6和程序7中的arg0参数我们都指定为了"abc",但它们都可以正常运行。当然,我们还是遵从习惯,指定为可执行文件名比较好。

至此,exec家族就算介绍完了。

]]>
<![CDATA[Linux进程控制之fork和vfork]]> http://niyanchun.com/fork-and-vfork.html 2016-05-07T11:17:00+08:00 2016-05-07T11:17:00+08:00 NYC https://niyanchun.com 一,fork和vfork
#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
pid_t vfork(void);

fork用于创建一个进程,该函数调用一次,返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值是新子进程的进程号。出错时,返回-1。子进程是父进程的一个副本——子进程获得父进程的数据空间、堆和栈的副本(不是共享存储空间),但是父子进程共享正文段,因为这一部分一般都是只读的。fork出来的子进程和父进程谁先执行是不确定的

vfork和fork一样,也用于创建一个新的进程。但是它和fork有两个区别:

  • vfork保证子进程先运行,在子进程调用exec或者exit之后父进程才会被调度运行。
  • vfork一般要和exec族函数配合使用。因为vfork创建的进程并不复制父进程的地址空间,它直接在父进程的进程空间里面运行,直到调用exit或者exec族函数。这也就是为什么vfork会保证子进程会先运行,因为二者共享一个进程空间,同时运行就会有问题。

下面我们分析几个有趣的程序:

例程1:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int glob = 6;
char    buf[] = "a write to stdoutn";

int main()
{
    int     var;
    pid_t   pid;

    var = 88;
    if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf) -1)
    {
        printf ( "write errorn" );
        return 1;
    }

    printf ( "before forkn" );

    if ((pid = fork()) < 0)
    {
        printf ( "fork errorn" );
        return 1;
    }
    else if (pid == 0)
    {
        glob++;
        var++;
    }
    else
    {
        sleep(2);  // 让子进程先运行,但这样并不能完全保证
    }

    printf ( "pid = %d, glob = %d, var = %dn", getpid(), glob, var);

    exit(0);
}

程序执行结果:

allan@NYC:~/test$ ./test1 
a write to stdout
before fork
pid = 16752, glob = 7, var = 89
pid = 16751, glob = 6, var = 88
allan@NYC:~/test$ ./test1 > test1.out
allan@NYC:~/test$ cat test1.out 
a write to stdout
before fork
pid = 16778, glob = 7, var = 89
before fork
pid = 16777, glob = 6, var = 88

从结果我们可以看出:

  • 子进程中堆glob和var的改变并未影响到父进程中glob和var的值,因为二者不在一个进程空间中。
  • 有趣的是同一个程序,两种运行方式得到的结果不一样,第二次的执行多输出了一个“before fork”。这里是和I/O函数有关系。write函数本身是不带缓冲区的(见之前《Linux文件I/O》),因为在fork之前调用write,所以其数据写到标准输出一次。但是,标准I/O库是带缓冲的。如果标准输出连到终端设备,则它是行缓冲的,否则它是全缓冲的。当以交互方式运行该程序时,只得到该printf输出的行一次,其原因是标准输出缓冲区由换行符冲洗。但是当标准输出重定向到一个文件时,去得到printf输出行两次。其原因是在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区也被复制到子进程中。于是,那时父子进程各自有了带该行内容的标准I/O缓冲区。在exit之前的第二个printf将其数据添加到现有的缓冲区中。当每个进程终止时,最终会冲洗其缓冲区中的副本。

例程2:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int     glob = 6;

int main(void)
{
    int     var;
    pid_t   pid;

    var = 88;
    printf ( "before vforkn" );
    if ((pid = vfork()) < 0)
    {
        printf ( "vfork errorn" );
        _exit(1);
    }
    else if (pid == 0)
    {
        glob++;
        var++;

        _exit(0);
    }

    /* parent continue here */
    printf ( "pid = %d, glob = %d, var = %dn", getpid(), glob, var );

    exit(0);
}

程序运行结果:

allan@NYC:~/test$ ./c-7 
before vfork
pid = 7589, glob = 7, var = 89

可以看到,使用vfork后,子进程里面对glob和var进行的加1操作,影响到了父进程里面glob和var的值(最后的打印是父进程打印的,子进程已经调用_exit退出了)。这就是之前说的,vfork出来的进程并不会复制父进程的进程空间,而是直接在父进程的进程空间去运行的。

例程3:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

static void f1(void), f2(void);

int main(void)
{
    f1();
    f2();
    _exit(0);
}

static void f1(void)
{
    pid_t   pid;

    if ((pid = vfork()) < 0)
    {
        printf ( "vfork errorn" );
        return;
    }
    /* child and parent both return */
}

static void f2(void)
{
    char    buf[1000];
    int     i;

    for (i = 0; i < sizeof(buf); i++)
        buf[i] = 0;
}

程序运行:

allan@NYC:~/test$ ./bug_exp 
段错误

可见这个程序是无法正常运行的。我们知道每个函数调用的栈帧是存储在栈里面的,当函数f1调用vfork时,父进程的栈指针指向f1函数的栈帧。然后vfork后,子进程先执行然后从f1返回,接着子进程调用f2,并且f2的栈帧覆盖了f1的栈帧,在f2中子进程将自动变量buf的值置为0,即将栈中的1000个字节的值都置为了0.从f2返回后子进程调用_exit,这时栈中main以下的内容已经被f2修改了。然后,父进程从vfork调用后继续,并从f1返回。返回信息虽然保存于栈中,但是多半可能已经被子进程修改了(这依赖于系统对于栈的实现,比如返回信息保存在栈中的具体位置,修改动态变量时覆盖了哪些信息等)。不过这些都是因为父进程和子进程运行在同一个进程空间导致的,虽然有先后顺序,但是还是很危险。所以,vfork一般都是和exec族函数配合使用的。

上面是一些进程变量在父子进程中的变化关系,下面我们看一下文件共享方面父子进程的关系。

二,父子进程的文件共享

关于Linux的文件共享之前已经在《Linux文件I/O》中介绍过。利用fork产生一个子进程的时候,父进程所有打开的文件描述符都会被复制到子进程中。父子进程的每个相同的文件描述符共享一个文件表项。假设几个进程具有三个不同的打开文件,它们是标准输入、标准输出、标准出错。从fork返回时,我们就有下图所示的结构:

1365952916_7361

这种共享文件的方式使父子进程堆同一文件使用了一个文件偏移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父子进程都向标准输出进行写操作。如果父进程的标准输出已重定向,那么子进程写到该标准输出时,他讲更新与父进程共享的该文件的偏移量。在我们所考虑的例子中,当父进程等待子进程的时候,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会添加在子进程所写的数据后。如果父子进程不共享同一文件偏移量,这种形式的交互就很难做到。

如果父子进程写到同一文件描述符,但又没有任何形式的同步(例如上面的使父进程等待子进程),那么它们的输出就会相互混合(假定所用的文件描述符是fork之前打开的)。所以,一般fork之后,处理文件描述符有两种常见的方式:

  • 父进程等待子进程完成。在这种情况下,父进程无需堆其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应更新。
  • 父子进程各执行不同的程序段。在这种情况下,在fork之后,父子进程各自关闭它们需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。
]]>
<![CDATA[C程序的存储布局]]> http://niyanchun.com/memory-layout-of-a-c.html 2016-04-24T13:01:00+08:00 2016-04-24T13:01:00+08:00 NYC https://niyanchun.com C程序一般由正文段初始化数据段未初始化数据段组成。

1 正文段

正文段(text segment)存储的是CPU执行的机器指令。一般正文段是共享的,这样的话对于那些频繁执行的程序(比如编辑器、shell等)只需要在内存中保存一份副本就可以了。另外,正文段一般都是只读的,防止程序由于意外修改了自身的指令。

2 初始化数据段

一般又称为数据段(data segment),这里存储的是程序中定义于函数体外且明确初始化的变量。我们常说的全局变量如果明确初始化了的话就存储在这里。比如:int maxcount = 99;.

3 未初始化数据段

一般又称为bss段(bss segment),bss是一个早期的汇编运算符“block started by symbol”。我们定义于函数体外的变量如果没有明确初始化的话就存储在这里。比如:int maxcount;.

程序开始执行前,对于bss段的数据,内核会将其初始化为0或者空指针(NULL)。这就是为什么全局变量可以不显式的初始化的原因。

4 栈

栈(Stack)我们应该都很熟悉,局部变量(又称自动变量)以及每次函数调用的相关信息都存储在这里。每次函数调用,函数内的局部变量、函数的返回地址、调用者的环境信息都存储在栈里面。而且,每次调用都是分配一个新的栈帧,所以递归调用时变量并不会冲突。

5 堆

堆(Heap)我们也比较熟悉。动态分配的内存都来自堆。堆一般位于未初始化数据段和栈之间。

下图是一个典型的C程序内存布局:

memory-layout-of-a-c

需要注意的是:(1)除了上述段,一个C程序还包含一些其它段,比如符号表段、包含调试信息的段、包含动态共享库链接表的段等。但这些部分在程序执行时并不会作为进程映像的一部分被加载。(2)我们磁盘上的C程序文件只包括正文段和数据段,不包含bss段,因为程序运行的时候,会将bss段中的内容置为0.

我们可以使用size命令来查看正文段、数据段、bss段的大小,单位是字节:

allan@NYC:~$ size /usr/bin/gcc
text data bss dec hex filename
900519 8032 9696 918247 e02e7 /usr/bin/gcc

dec和hex列分别以十进制和十六进制显示了三个段的总大小。

]]>
<![CDATA[Linux文件I/O]]> http://niyanchun.com/linux-io.html 2016-04-03T23:51:00+08:00 2016-04-03T23:51:00+08:00 NYC https://niyanchun.com 0 文件描述符

对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或者创建一个新文件时,内核项进程返回一个文件描述符。文件描述符的变化范围时0~OPEN_MAX

1 Linux文件I/O函数

Linux系统中的大多数文件I/O只需要用到5个函数:openreadwritelseekclose

int open(const char *path, int oflag, ...  );
int creat(const char *path, mode_t mode);
int close(int fildes);

ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
ssize_t read(int fildes, void *buf, size_t nbyte);

ssize_t pwrite(int fildes, const void *buf, size_t nbyte, off_t offset);
ssize_t write(int fildes, const void *buf, size_t nbyte);

off_t lseek(int fildes, off_t offset, int whence);

int dup(int oldfd);
int dup2(int oldfd, int newfd);

1.1 open函数

open函数的oflag参数表示以何种方式打开/创建文件,其可选值(可以多个进行或运算)如下:

  • O_RDONLY     只读打开
  • O_WRONLY    只写打开
  • O_RDWR        读写打开

上面这三个常量中必须指定一个且只能指定一个。下面的常量则是可选的:

  • O_APPEND     每次写都追加到文件尾端。
  • O_CREAT       若文件不存在,则创建。创建时必须指定第三个参数mode,即该新文件的访问权限。
  • O_EXCL          如果同时指定了O_CREAT,那么如果文件已经存在,则会报错。用此来测试文件是否存在。
  • O_TRUNC       如果文件存在,而且为只写或者读写打开,则将其长度截为0.
  • O_NOCTTY     如果path指定的时终端设备,则不将该终端设备分配作为此进程的控制终端。
  • O_NONBLOCK  如果path指的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞模式。

注意,由open返回的文件描述符(非负整数)一定是最小的未使用的描述符数值。

1.2 creat函数

creat函数等效于:open(path, O_WRONLY | O_CREAT | O_TRUNC, mod);

1.3 close函数

close关闭一个打开的文件,同时释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有打开的文件。很多程序利用这一功能而不是显式的用close关闭打开文件。

1.4 lseek函数

每个打开的文件都有一个与其关联的“当前文件偏移量”(current file offset)。它通常是一个非负整数,用以度量从文件开始处计算的字节数。通常,读、写操作都是从当前文件偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设为0.

但是,我们可以使用lseek显式的为一个打开的文件设置其偏移量。对参数offset的解释将与whence的值有关:

  • 若whence是SEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
  • 若whence是SEEK_CUR,则将该文件的偏移量设置为其当前值加offset,offset可为正或者负。
  • 若whence时SEEK_END,则将该文件的偏移量设置为当前文件长度加offset,offset可为正或负。

若lseek执行成功,则返回新的文件偏移量。所以,我们可以使用下列方式确定打开文件的当前偏移量:

offset_t    currpos;
currpos = lseek(fd, 0, SEEK_CUR);

这种方法也可以用来确定所涉及的文件是否可以设置偏移量。如果文件描述符引用的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE。

关于文件偏移量,还有几个点需要注意:

(1)偏移量类型off_t的数据类型也限制了一个文件的最大长度。一般是带符号数据类型,所以文件最大长度会减小一半。例如,若off_t是32位的整型,则文件最大长度是231-1个字节(2TB)。当然,文件的最大大小也受具体文件系统的限制。

(2)lseek仅将当前的文件偏移量记录在内核中(文件表项),它并不引起任何I/O操作。然后,该偏移量用于下一个读或写操作。

(3)文件偏移量可以大于当前文件的长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞。位于文件中但没写过的字节都为读成0.文件中的空洞并不要求在磁盘上面占用存储区。具体处理方式与文件系统实现有关。当定位到超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是对于原文件尾端和新开始写位置之间的部分则不需要分配磁盘块。

1.5 read/write函数

如果read成功,返回读到的字节数;如果到达文件结尾,返回0;若出错,返回-1.

如果write的返回值不等于nbytes,则表示出错。write出错的一个常见原因时盘已写满,或者超过了一个给定进程的文件长度限制。

1.6 dup/dup2函数

dup和dup2都用来复制一个现存的文件描述符。dup返回的新文件描述符一定是当前可用文件描述符的最小值。用dup2则可以用newfd参数指定新描述符的数值。如果newfd已经打开,则先将其关闭。如果newfd等于oldfd,则dup2返回newfd,而不是关闭它。

需要注意的是两个函数返回的新文件描述符与旧文件描述符共享同一个文件表项,所以他们共享同一文件状态标志(读、写、添加等)以及同一当前文件偏移量。

2 文件共享

UNIX系统支持在不同进程间共享打开的文件。内核使用了三种数据结构表示打开的文件,他们之间的关系决定了在文件共享方面一个进程对另外一个进程可能产生的影响。

(1)每个进程在进程表中都有一个记录项,记录项中包含有一个打开文件描述符表,可将其视为一个矢量,每个描述符占一项。与每个文件描述符相关联的是:

  • 文件描述符标志(close_on_exec)。
  • 指向文件表项的指针。

(2)内核为所有打开的文件维持一张文件表。每个文件表项包含:

  • 文件状态标志(读、写、添加、同步和非阻塞等)。
  • 当前文件偏移量。
  • 指向该文件v节点表项的指针。

(3)每个打开文件(或设备)都有一个v节点结构。v节点包含了文件类型和对此文件进行各种操作的函数指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。这些信息都是在打开文件时从磁盘上面读入内存的,所以所有关于文件的信息都是快速可供使用的。需要注意的是,创建v节点结构的目的是对一个计算机系统上的多个文件系统类型提供支持。Linux没有使用v节点,而是使用了通用i节点。但是这个i节点对应于此处的v节点,并不是文件系统里面的i节点的概念。

下图显示了一个进程的三张表之间的关系。该进程由两个不同的打开文件:一个文件打开为标准输入,另一个打开为标准输出。

下图显示了两个独立进程各自打开了同一文件,我们假定第一个进程在文件描述符3上打开该文件,而另一个进程则在文件描述符4上打开该文件。打开该文件的每个进程都得到一个文件表项,但对一个给定的文件只有一个v节点表项。每个进程都有自己的文件表项的理由时:这种安排使每个进程都有它自己对该文件的当前偏移量。

3999251

需要注意的是,可能有多个文件描述符指向同一个文件表项,比如像下面这样,fd1和fd2共享同一个文件表项,fd1、fd2、fd3共享一个v节点表:

6597955973028210657

dup/dup2返回的新描述符和原来的描述符共享同一个文件表项;fork后,父子进程对于每一个打开文件的描述符共享同一个文件表项。但是open函数每次都会打开一个新的文件表项。

另外还需要注意的是进程表项中的文件描述符标志和文件表项中的文件状态标志在作用域方面的区别,前者只用于一个进程的一个描述符,而后者则适用于指向该文件表项的任何进程中的所有描述符。

有了这些数据结构的概念后,我们来看一下上面介绍的几个I/O函数的具体操作:

  • 在完成每个write后,在文件表项中的当前文件偏移量即增加所写的字节数。如果这使得当前文件偏移量超过了当前文件长度,则在i节点表项中国的当前文件长度被设置为当前文件偏移量(也就是该文件加长了)。
  • 如果用O_APPEND标志打开了一个文件,则相应的标志也被设置到文件表项的文件状态标志中。每次对这种具有添加写标志的文件执行写操作时,在文件表项中的当前文件偏移量首先被设置为i节点表项中的当前文件长度。这就使得每次写的数据都添加到文件的当前尾端处。
  • 若一个文件用lseek定位都文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。
  • lseek函数只修改文件表项中的当前文件偏移量,没有进行任何I/O操作。

3. 缓冲相关概念

传统的UNIX实现在内核中设有缓冲区高速缓存或者页面高速缓存,大多数磁盘I/O都通过缓冲进行。当将数据写入文件时,内核通常是将该数据复制到其中一个缓冲区中,如果该缓冲区尚未写满,则并不将其排到输出队列,而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时,再将缓冲排入到输出队列,然后待其到达队首时,才进行实际的I/O操作。这种输出方式被称为延迟写。这种技术减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件与缓冲区高速缓冲中内容的一致性,UNIX系统提供了sync、fsync、fdatasync三个函数:

void sync(void);
int fsync(int fildes);
int fdatasync(int fildes);
  • sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘结束。通常称为update的系统守护进程会周期性的调用sync函数。这就保证了定期冲洗内核的块缓冲区。
  • fsync函数只对由文件描述符fildes指定的单一文件起作用,并且等待写磁盘操作结束后返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保将修改过的块立即写到磁盘上。
  • fdatasync函数类似于fsync,但他只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。
    一般,我们称UNIX的这些函数为不带缓冲的I/O(标准C里面的标准I/O为带缓冲的I/O),这里的术语不带缓冲指的是每个read和write都调用内核中的一个系统调用。而不是说读写文件时没有缓冲机制,正如上面所说,内核里面提供了缓冲机制。所以这里说的不带缓冲以及标准C里面的标准I/O带缓冲都指的是用户级别的缓冲,而不是内核级别的。

当然,UNIX提供的I/O相关的函数还有fcntl和ioctl函数,这里先不介绍了,具体用法可以查看man文档。

Linux I/O和标准I/O有很多不同的地方,前者很多都是系统调用,而后者基本都是用前者封装的库函数。一个是内核空间的,一个是用户空间的。标准C I/O参见我的另外一篇文章《ANSI C中的标准I/O》。

本文总结自APUE。

]]>