探究一下c++标准IO的底层实现
cpp加油站 357 0

说明一下,我用的是g++7.1.0编译器,标准库源代码也是这个版本的。

本篇文章讲解c++标准IO的底层实现结构,以及cin和cout的具体实现。

在看本文之前,建议先看一下之前的一篇文章,至少要知道标准IO里面各个类之间的关系:

c++标准输入输出流关系梳理

1. 标准IO的底层结构

通过通读c++标准IO的源代码,我总结出了它的底层实现结构,如图:

探究一下c++标准IO的底层实现

它分为三层结构:外部设备、缓冲区、程序,说明如下:

  • 外部设备是指键盘、屏幕、文件等物理或者逻辑设备;
  • 缓冲区是指在数据没有同步到外部设备之前,存放数据的一块内存;
  • 程序就是我们代码生成的进程了。

下面我们首先以输出一个字符为例来看一下它的实现过程,这个过程是由ostream::put函数完成,下面就探究一下put函数的具体实现。

1.1 先探探底层实现的底

小贴士:tcc是指template cc,cc是c++实现文件的后缀,加上t表示是模板的实现,所以tcc就是一个模板的实现文件,用于跟其他非模板的实现文件区分开来。

ostream.tcc中找到put函数的实现代码:

template<typename _CharT, typename _Traits>
    basic_ostream<_CharT, _Traits>&
    basic_ostream<_CharT, _Traits>::
    put(char_type __c)
    {
      sentry __cerb(*this);
      if (__cerb)
    {
      ios_base::iostate __err = ios_base::goodbit;
      __try
        {
          const int_type __put = this->rdbuf()->sputc(__c);
          if (traits_type::eq_int_type(__put, traits_type::eof()))
        __err |= ios_base::badbit;
        }
      __catch(__cxxabiv1::__forced_unwind&)
        {
          this->_M_setstate(ios_base::badbit);        
          __throw_exception_again;
        }
      __catch(...)
        { this->_M_setstate(ios_base::badbit); }
      if (__err)
        this->setstate(__err);
    }
      return *this;
    }

以输出一个字符为例,put函数是调用了缓冲区基类basic_streambufsputc成员函数,而sputc成员函数实现如下:

int_type
      sputc(char_type __c)
      {
    int_type __ret;
    //pptr返回一个指向缓冲区下一位置的指针,epptr返回一个指向缓冲区结束位置的指针
    if (__builtin_expect(this->pptr() < this->epptr(), true))
      {
        *this->pptr() = __c;
        //pbump是把缓冲区下一位置加1
        this->pbump(1);
        __ret = traits_type::to_int_type(__c);
      }
    else
        //overflow会进行缓冲区溢出处理
      __ret = this->overflow(traits_type::to_int_type(__c));
    return __ret;
      }

那么这样看来sputc函数的作用就很明显了,它有两个分支:

  • 一是当缓冲区当前位置还没有写满的时候,就直接把字符写到缓冲区;

  • 二是如果已经把当前缓冲区写满了,那么就要做缓冲区溢出处理。

对于这两种情况,很明显各个输出类的实现方式是不一样的,先抛开基本的ostream不说,我们先看一下ostringstreamofstream这两个类在实现时的异同。

对于第一点,ostringstreamofstream在实现上是一样的,都是把字符写入缓冲区并把位置向后移动一位,并没有特殊之处。

但对于第二点,ostringstream是调用的stringbufoverflow成员函数,它是在原来缓冲区用完的情况下,重新申请一块更大的临时缓冲区,然后把源缓冲区所有的数据复制过来,把当前要输出的数据加入到新的缓冲区,然后在用这个临时缓冲区与源缓冲区进行交换,这样才把一个字符写到了源缓冲区,同时也实现了缓冲区的扩容。

ofstream是调用的filebufoverflow成员函数,该函数会检测当前是否写到了缓冲区末尾,很显然对于第二点而言,既然缓冲区已经写满,那肯定是已经写到了末尾,此时会调用系统的write函数把当前缓冲区所有内容都刷新到文件中去,然后对缓冲区指针位置等进行重新初始化,注意filebuf并没有对缓冲区进行扩充。

小贴士:很显然,对于上面第二点,调用overflow函数,是使用了c++中多态,对于streambuf::overflow,它是一个虚函数,真正的实现是在stringbuf和filebuf里面。

到这里,put函数的具体实现我们就探究完了,大致上也探了探标准库底层实现的底子,但我们还是对于三层结构的实现不是那么清晰,下面就来具体的说一说。

1.2 详解标准IO底层结构
1.2.1 stringbuf的底层结构

对于istringstream、ostringstream、stringstream这三个类而言,他们都是基于stringbuf来实现缓冲区的,所以说白了他们的底层实现直接看stringbuf的底层实现就ok了,那么stringbuf是基于什么来实现缓冲区的呢。

先来看一张图,如下:

探究一下c++标准IO的底层实现

注意,这里箭头指示代表使用关系,并不是继承关系,所以我这里用了比较透明的线,后续同理。

那么现在就很明显了,stringbuf使用的是标准库中的string来作为缓冲区,如果说读取数据的话,很明显string的大小是不会变化的,但如果是写入string的话,在构造的时候也会调用string的构造,它一开始是一个空字符串,当开始写入第一个字符的时候,默认会给string对象申请一块大小为512个字节的动态内存,后续写入,就直接写入动态内存,当512个字节写完后,就会在当前内存大小基础上乘以2,然后申请一块新的内存,再把之前的数据全部复制到新的内存中来,再在新内存的后面写入要保存的字符。

那对于stringbuf的三层结构而言,它的缓冲区就是申请的内存,外部设备就是string,在逻辑上而言,他们是两层不同的皮,但实际上就实现来讲,我们对string申请的内存进行读写,其实就是对string进行读写,从这个角度而言,stringbuf可以说是三层结构,也可以说是两层结构,就看我们个人怎么理解了,这里不多做讨论。

1.2.2 filebuf的底层结构

同样的,对于fstream相关类而言,它的底层实现是基于filebuf的,filebuf又比stringbuf稍显复杂一些,先来看图:

探究一下c++标准IO的底层实现

filebuf在调用open函数的时候会new一块char类型的动态内存,大小为BUFSIZ,BUFSIZ是系统文件里面定义的一个专门用于缓冲区的默认size,filebuf写数据的时候,是先写到这一块动态内存中去,当写满以后,会把FILE*转换为文件描述符,然后利用write函数直接写到文件中去,再对缓冲区当前写位置进行初始化,读数据则会先把数据读到缓冲区,直到当前缓冲区全部读完,才会重新从文件再次读取,对于filebuf而言,它的缓冲区大小是固定的,不会进行扩充。

所以这里对于filebuf,缓冲区就是申请的这一块动态内存,外部设备就是文件了,filebuf不论是从逻辑上还是实现上看,它都是标准的三层结构。

1.2.3 iostream的底层实现

对于istream,ostream,iostream而言,他们的缓冲区使用的是streambuf,但streambuf的构造函数是保护类型的,所以它是没有办法直接生成一个对象的,也是可以理解的,因为streambuf既没有提供缓冲区,也没有提供一个外部设备,所以它本来也是不能直接使用的,它只是作为一个基类供stringbuffilebuf调用。

如果想使用istream,ostream,iostream,那么就需要给他们传入一个可用的缓冲区对象,例如filebuf对象,这样才是可用的,但这样还不如直接使用fstream,所以对于这三个基本模板类而言,既然不可直接使用,那就不存在两层结构还是三层结构了。

2. 标准IO全局变量cin、cout的实现

上一小节说了,iostream类是不可直接使用的,但是我们又知道cin是istream类型的,cout是ostream类型,而且实际上标准IO中还定义了另外两个ostream类型的cerr和clog,那么他们为什么又可以直接使用呢。

在iostream头文件中,定义了这样一个全局静态变量:

static ios_base::Init __ioinit;

ios_base::Init是一个类类型,定义在ios_base.h头文件中,它的构造函数实现如下:

  ios_base::Init::Init()
  {
    if (__gnu_cxx::__exchange_and_add_dispatch(&_S_refcount, 1) == 0)
      {
    // Standard streams default to synced with "C" operations.
    _S_synced_with_stdio = true;

    new (&buf_cout_sync) stdio_sync_filebuf<char>(stdout);
    new (&buf_cin_sync) stdio_sync_filebuf<char>(stdin);
    new (&buf_cerr_sync) stdio_sync_filebuf<char>(stderr);

    // The standard streams are constructed once only and never
    // destroyed.
    new (&cout) ostream(&buf_cout_sync);
    new (&cin) istream(&buf_cin_sync);
    new (&cerr) ostream(&buf_cerr_sync);
    new (&clog) ostream(&buf_cerr_sync);
    cin.tie(&cout);
    cerr.setf(ios_base::unitbuf);
    // _GLIBCXX_RESOLVE_LIB_DEFECTS
    // 455. cerr::tie() and wcerr::tie() are overspecified.
    cerr.tie(&cout);

#ifdef _GLIBCXX_USE_WCHAR_T
    new (&buf_wcout_sync) stdio_sync_filebuf<wchar_t>(stdout);
    new (&buf_wcin_sync) stdio_sync_filebuf<wchar_t>(stdin);
    new (&buf_wcerr_sync) stdio_sync_filebuf<wchar_t>(stderr);

    new (&wcout) wostream(&buf_wcout_sync);
    new (&wcin) wistream(&buf_wcin_sync);
    new (&wcerr) wostream(&buf_wcerr_sync);
    new (&wclog) wostream(&buf_wcerr_sync);
    wcin.tie(&wcout);
    wcerr.setf(ios_base::unitbuf);
    wcerr.tie(&wcout);
#endif

    // NB: Have to set refcount above one, so that standard
    // streams are not re-initialized with uses of ios_base::Init
    // besides <iostream> static object, ie just using <ios> with
    // ios_base::Init objects.
    __gnu_cxx::__atomic_add_dispatch(&_S_refcount, 1);
      }
  }

以cin为例,可以看到,实际上是在构造的时候传入了一个stdio_sync_filebuf类型的对象,那我们知道istream只接受streambuf类型的对象,所以可以猜测到stdio_sync_filebuf应该是继承于streambuf的,找到stdio_sync_filebuf.h头文件,看到stdio_sync_filebuf果然是继承于basic_streambuf的。

对于类stdio_sync_filebuf而言,它是不存在缓冲区的,只是它会根据传入的文件指针stdin、stdout、stderr来与外部设备键盘和屏幕扯上关系,所以对于cin而言,它是通过stdin直接从键盘进行读取,而cout则是通过stdout直接输出到屏幕。

所以从结构上而言,cin、cout、cerr、clog都是只有程序和外部设备两层结构,但还有一点疑惑,我们根据代码,实际上他们都是打开了文件,然后对文件进行了读写,那怎么会显示在外部设备上呢。

根据操作系统的不同,标准输入和输出也是实现不同的,这里我们以linux系统为例,来进行说明。

在linux中,有三个标准的输入和输出文件,分别是stdin,stdout,stderr,他们都在/dev目录下,由上一章可知,cout实际上打开了/dev/stdout这个文件,而/dev/stdout又是一个软链接,它链接的是/proc/self/fd/1这个文件,而/proc/self/fd/1又链接到了/dev/pts/0这个文件,/dev/pts/0这个文件实际上代表的是当前打开的终端,以当前终端为例,关系图如下:

探究一下c++标准IO的底层实现

这样看来,每个程序的输入输出,其实接收的都是当前终端的输入和输出,关于这一点,就写到这里,不再展开说明了。

评论区

索引目录