_IO_FILE 相关源码阅读(一)——vtable 及其指向的函数:从 __finish 说起

Posted on Apr 10, 2021

__finish 虚函数主要处理的是对文件结构体的析构操作,同时会根据情况进行对文件进行 close 操作。通过本文的分析,应该可以基本理解 _IO_new_file_finish_IO_new_file_write__GI__IO_file_seek__GI__IO_file_close 等函数的实现。同时可以对面向语言中虚函数的实现有初步的了解,更重要的,可以明白面向对象是一种思维,而不是语法,使用面向过程式语言仍然可以实现面向对象的开发。

_IO_FILE_plus 的结构

我们都知道 glibc 中的每个文件都是由一个 _IO_FILE_plus 结构体来维护的,其定义为

struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};

其中的 vtable 虚表就是本文主要要分析的目标。由于虚表中的函数依赖于 file 成员变量,所以这里也放一下 FILE 结构体的定义(typedef struct _IO_FILE FILE;)。

struct _IO_FILE
{
  int _flags;                /* High-order word is _IO_MAGIC; rest is flags. */
  /* The following pointers correspond to the C++ streambuf protocol. */
  char *_IO_read_ptr;        /* Current read pointer */
  char *_IO_read_end;        /* End of get area. */
  char *_IO_read_base;        /* Start of putback+get area. */
  char *_IO_write_base;        /* Start of put area. */
  char *_IO_write_ptr;        /* Current put pointer. */
  char *_IO_write_end;        /* End of put area. */
  char *_IO_buf_base;        /* Start of reserve area. */
  char *_IO_buf_end;        /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */
  struct _IO_marker *_markers;
  struct _IO_FILE *_chain;
  int _fileno;
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];
  _IO_lock_t *_lock;
};

_fileno 是该文件在系统中的文件描述符的值。

注意变量 _old_offset,注释里也有提到,这曾经是变量 _offset,事实上,在之后的函数中也是使用的 _offset。此变量表示文件当前的读写位置偏移(与文件头的偏移)。

结构体中的变量 _flags 标志了文件所处的状态等,这些状态的宏定义如下

#define _IO_MAGIC         0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK    0xFFFF0000
#define _IO_USER_BUF          0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED        0x0002
#define _IO_NO_READS          0x0004 /* Reading not allowed.  */
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */
#define _IO_EOF_SEEN          0x0010
#define _IO_ERR_SEEN          0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close.  */
#define _IO_LINKED            0x0080 /* In the list of all open files.  */
#define _IO_IN_BACKUP         0x0100
#define _IO_LINE_BUF          0x0200
#define _IO_TIED_PUT_GET      0x0400 /* Put and get pointer move in unison.  */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING      0x1000
#define _IO_IS_FILEBUF        0x2000
                           /* 0x4000  No longer used, reserved for compat.  */
#define _IO_USER_LOCK         0x8000

_flags2 变量中也存储了一些标志

/* Bits for the _flags2 field.  */
#define _IO_FLAGS2_MMAP 1
#define _IO_FLAGS2_NOTCANCEL 2
#define _IO_FLAGS2_USER_WBUF 8
#define _IO_FLAGS2_NOCLOSE 32
#define _IO_FLAGS2_CLOEXEC 64
#define _IO_FLAGS2_NEED_LOCK 128

vtable 的定义

vtable 是一个类型为 struct _IO_jump_t 的结构体,这个结构体是这样定义的。这个结构体中存储了许多函数指针。其实这里是带有面向对象的思维的,vtable 实际上是一张虚表,通过这张虚表实现了虚函数的效果。

struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
};

函数指针的指向

这里仅讨论标准文件类型的函数实现,也就是以标准输入输出文件为代表的结构体的 vtable 中函数指针的指向,通过调试,可以获得他们的指向,整理后得到以下的对应关系

ptrfunction
__dummy0
__dummy20
__finish<_IO_new_file_finish>
__overflow<_IO_new_file_overflow>
__underflow<_IO_new_file_underflow>
__uflow<__GI__IO_default_uflow>
__pbackfail<__GI__IO_default_pbackfail>
__xsputn<_IO_new_file_xsputn>
__xsgetn<__GI__IO_file_xsgetn>
__seekoff<_IO_new_file_seekoff>
__seekpos<_IO_default_seekpos>
__setbuf<_IO_new_file_setbuf>
__sync<_IO_new_file_sync>
__doallocate<__GI__IO_file_doallocate>
__read<__GI__IO_file_read>
__write<_IO_new_file_write>
__seek<__GI__IO_file_seek>
__close<__GI__IO_file_close>
__stat<__GI__IO_file_stat>
__showmanyc<_IO_default_showmanyc>
__imbue<_IO_default_imbue>

一些宏的定义

一个比较典型的对 vtable 中的函数指针的调用是这样写的 _IO_OVERFLOW (fp, EOF),把宏展开来就是

((*(struct _IO_jump_t **) ((void *) &(*(__typeof__ (((struct _IO_FILE_plus){}).vtable) *)(((char *) ((fp))) + ((size_t)&(((struct _IO_FILE_plus*)0)->vtable)))) + (fp)->_vtable_offset))->__overflow) (fp, (-1))

这样就以 fpEOF 为参数调用了 fp 中的 JUMP_FIELD(_IO_overflow_t, __overflow); 这一个函数指针。说这个是指针略有些怪异,我们把它展开

宏的定义如下

#define JUMP_FIELD(TYPE, NAME) TYPE NAME

展开就是

_IO_overflow_t __overflow;

借用面向对象的思维,也可以称之为一个成员函数。

各个函数的实现

__finish

函数指针的定义为

typedef void (*_IO_finish_t) (FILE *, int);

相关的宏

#define _IO_FINISH(FP) JUMP1 (__finish, FP, 0)
#define _IO_WFINISH(FP) WJUMP1 (__finish, FP, 0)

glibc 对这个函数的解释为

/* The ‘finish’ function does any final cleaning up of an _IO_FILE object. It does not delete (free) it, but does everything else to finalize it. It matches the streambuf::~streambuf virtual destructor. */

也就是说这相当于一个 _IO_FILE 类的对象的析构函数。函数的实现比较简单。

void
_IO_new_file_finish (FILE *fp, int dummy)
{
  if (_IO_file_is_open (fp))
    {
      _IO_do_flush (fp);
      if (!(fp->_flags & _IO_DELETE_DONT_CLOSE))
        _IO_SYSCLOSE (fp);
    }
  _IO_default_finish (fp, 0);
}
libc_hidden_ver (_IO_new_file_finish, _IO_file_finish)

首先注意函数的第一个参数是 FILE * 类型的,也就是 _IO_FILE 类型,这是因为成员函数不需要对虚表进行访问等操作。

进入函数后,首先通过宏 _IO_file_is_open (fp) 判断文件是否打开,这个宏的定义为

#define _IO_file_is_open(__fp) ((__fp)->_fileno != -1)

也就是判断了结构体中的 _fileno 变量是否为 -1。若为 -1 则表明该文件已被关闭。

如果文件是打开的,首先通过 _IO_do_flush (fp) 来刷新该文件的缓冲区,宏的定义为

#define _IO_do_flush(_f) \
  ((_f)->_mode <= 0                                                              \
   ? _IO_do_write(_f, (_f)->_IO_write_base,                                      \
                  (_f)->_IO_write_ptr-(_f)->_IO_write_base)                      \
   : _IO_wdo_write(_f, (_f)->_wide_data->_IO_write_base,                         \
                   ((_f)->_wide_data->_IO_write_ptr                              \
                    - (_f)->_wide_data->_IO_write_base)))

这里进行的应该是检测是否需要输出宽字符。如果是输出跨字符则调用 _IO_wdo_write,实际调用的是 __GI__IO_wdo_write,定义在 glibc/libio/wfileops.c 中,实现比较复杂,这里按下不表。 否则调用 _IO_do_write 进行输出,实际调用的是定义在 glibc/libio/fileops.c 中的 _IO_new_do_write 函数,其实现如下

int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
  return (to_do == 0
          || (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)

传入的参数分别为文件对象自身(FILE *fp),输出起始地址(_IO_FILE 结构体成员指针变量 _IO_write_base),输出长度(_IO_write_ptr - _IO_write_base,这里也可以看出这两个指针分别代表了输出的结束和起始点)。

可见这里利用了短路求值原理,如果需要输出的字符数量为零,直接返回,否则调用 || 后面的 (size_t) new_do_write (fp, data, to_do),传入的参数是一样的。也就是说这个函数只是做了对是否输出做了一个简单的判断。

new_do_write 函数的实现如下

static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
  size_t count;
  if (fp->_flags & _IO_IS_APPENDING)
    /* On a system without a proper O_APPEND implementation,
       you would need to sys_seek(0, SEEK_END) here, but is
       not needed nor desirable for Unix- or Posix-like systems.
       Instead, just indicate that offset (before and after) is
       unpredictable. */
    fp->_offset = _IO_pos_BAD;
  else if (fp->_IO_read_end != fp->_IO_write_base)
    {
      off64_t new_pos
        = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
      if (new_pos == _IO_pos_BAD)
        return 0;
      fp->_offset = new_pos;
    }
  count = _IO_SYSWRITE (fp, data, to_do);
  if (fp->_cur_column && count)
    fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
  _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
  fp->_IO_write_end = (fp->_mode <= 0
                       && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
                       ? fp->_IO_buf_base : fp->_IO_buf_end);
  return count;
}

count 变量记录输出的字符个数。

进入流程后,首先检测 _flags_IO_IS_APPENDING 标志位,如果处于 APPENDING模式,则执行fp->_offset = _IO_pos_BAD;_IO_pos_BAD 实际是一个宏,其定义和解释如下:

/* _IO_pos_BAD is an off64_t value indicating error, unknown, or EOF.  */
#define _IO_pos_BAD ((off64_t) -1)

他表示出现了错误,或者未知情况,或读到了文件尾。在这种模式下,不会进行之后的 lseek,而是直接输出 [_IO_write_base,_IO_write_ptr] 中所有的数据。我们常常会在对 _IO_2_1_stdout_ 攻击的时候利用到这一点(由于这是一个 leak 的小技巧,并没有特别好的例子,就勉强以此题为例)。

如果不处于,则进入 else if 分支,这里判断的是指针 _IO_write_base_IO_read_end 是否指向同一地址,若非,则调用 _IO_SYSSEEK,该宏的定义如下

#define _IO_SYSSEEK(FP, OFFSET, MODE) JUMP2 (__seek, FP, OFFSET, MODE)

可见这里调用了另一个函数指针 __seek,所以下面先分析该函数实现


__seek

函数指针的定义为

typedef off64_t (*_IO_seek_t) (FILE *, off64_t, int);

相关的宏

#define _IO_SYSSEEK(FP, OFFSET, MODE) JUMP2 (__seek, FP, OFFSET, MODE)
#define _IO_WSYSSEEK(FP, OFFSET, MODE) WJUMP2 (__seek, FP, OFFSET, MODE)

glibc 的解释为

/* The ‘sysseek’ hook is used to re-position an external file. It generalizes the Unix lseek(2) function. It matches the streambuf::sys_seek virtual function, which is specific to this implementation. */

指向的函数为 __GI__IO_file_seek,内部的实现为

off64_t
_IO_file_seek (FILE *fp, off64_t offset, int dir)
{
  return __lseek64 (fp->_fileno, offset, dir);
}
libc_hidden_def (_IO_file_seek)

该函数定义在 glibc/libio/fileops.c 中,传入的参数分别为文件结构体指针,移动的偏移。这里调用了定义在 sysdeps/unix/sysv/linux/lseek64.c 中的 __lseek64

off64_t
__lseek64 (int fd, off64_t offset, int whence)
{
  loff_t res;
  int rc = INLINE_SYSCALL_CALL (_llseek, fd,
                (long) (((uint64_t) (offset)) >> 32),
                (long) offset, &res, whence);
  return rc ?: res;
}

在这个函数中执行了系统调用,通过 _llseek 移动了文件的读取位置。从这里可以看出 _IO_FILE结构体中的变量_fileno 储存的是文件描述符的值。

总结:__seek 函数指针直接返回内核函数 __lseek64,而后者是对系统调用 _llseek 的简单封装。通过调用 __seek 函数,可以实现改变文件的读写位置。


让我们回到 new_do_write。进过上面对 __seek 的分析,我们已经知道,_IO_SYSSEEK 将当前文件的读取位置转到了 _IO_write_base 处。这里有趣的一点是 glibc 假设了在输出时,原来的文件读写位置就是在 _IO_read_end 上。

接下来会进行执行如下流程:

if (new_pos == _IO_pos_BAD)
        return 0;
      fp->_offset = new_pos;
    }
  count = _IO_SYSWRITE (fp, data, to_do);

_IO_SYSSEEK 可能会返回 _IO_pos_BAD,代表写出的文件遇到异常,这里应该是到文件尾了,自然不需要再输出,直接返回 0,结束流程。

否则继续流程,首先设置 fp->_offset 为当前的文件读写位置,然后准备执行系统调用

#define _IO_SYSWRITE(FP, DATA, LEN) JUMP2 (__write, FP, DATA, LEN)

这里又调用了一个函数指针 __write,所以先分析该指针指向的函数

__write


函数指针的定义为

typedef ssize_t (*_IO_write_t) (FILE *, const void *, ssize_t);

相关的宏

#define _IO_SYSSTAT(FP, BUF) JUMP1 (__stat, FP, BUF)
#define _IO_WSYSSTAT(FP, BUF) WJUMP1 (__stat, FP, BUF)

glibc 的解释为

/* The ‘syswrite’ hook is used to write data from an existing buffer to an external file. It generalizes the Unix write(2) function. It matches the streambuf::sys_write virtual function, which is specific to this implementation. */

也就是说该函数将缓冲区中已存在的数据写到了外部文件中,该函数一般化了 Unix 的内核函数 write,也就是说这是一个对 write 的封装。

其内部实现如下。先解释一下参数,f 是文件结构体指针,data 指向(缓冲区中)要输出的数据,n 为要输出的长度。

ssize_t
_IO_new_file_write (FILE *f, const void *data, ssize_t n)
{
  ssize_t to_do = n;
  while (to_do > 0)
    {
      ssize_t count = (__builtin_expect (f->_flags2
                                         & _IO_FLAGS2_NOTCANCEL, 0)
                           ? __write_nocancel (f->_fileno, data, to_do)
                           : __write (f->_fileno, data, to_do));
      if (count < 0)
        {
          f->_flags |= _IO_ERR_SEEN;
          break;
        }
      to_do -= count;
      data = (void *) ((char *) data + count);
    }
  n -= to_do;
  if (f->_offset >= 0)
    f->_offset += n;
  return n;
}

解释一下 __builtin_expect:由gcc引入,其作用为允许程序员将最有可能执行的分支告诉编译器。指令的写法为:__builtin_expect(EXP, N),表示 EXP==N 的概率很大。有利于编译器的分支预测优化

进入流程后,开始尝试通过系统调用输出,通过 __builtin_expect (f->_flags2 & _IO_FLAGS2_NOTCANCEL, 0) 进行判断是否需要考虑 CANCEL,这是为多线程处理进行的特判,调用的两个函数的定义如下

ssize_t
__write_nocancel (int fd, const void *buf, size_t nbytes)
{
  return INLINE_SYSCALL_CALL (write, fd, buf, nbytes);
}
hidden_def (__write_nocancel)
/* Write NBYTES of BUF to FD.  Return the number written, or -1.  */
ssize_t
__libc_write (int fd, const void *buf, size_t nbytes)
{
  return SYSCALL_CANCEL (write, fd, buf, nbytes);
}
libc_hidden_def (__libc_write)
weak_alias (__libc_write, __write)

注意到这里的 __write__libc_write 的别名。两个函数对内核函数的简单封装,他们被定义在 glibc/sysdeps/unix/sysdep.h 中。

在这个 while 循环中一直使用系统调用进行输出,并用 count 变量维护结果,当 count < 0 时,代表出现了输出错误,此时进行置位 f->_flags |= _IO_ERR_SEEN;,并直接返回结束流程。

to_do 小于等于 0 时,结束循环,将 _offset 加上实际输出的长度(实际输出的长度是可能小于指定的长度 nbytes 的),返回输出的长度,结束流程。

_IO_new_file_write 的实现比较简单,是对系统函数的封装,同时对异常等情况进行了处理。


返回到 new_do_write 上,在完成输出后,需要对结构体中的一些状态变量进行更新。

if (fp->_cur_column && count)
    fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
  _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
  fp->_IO_write_end = (fp->_mode <= 0
                       && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
                       ? fp->_IO_buf_base : fp->_IO_buf_end);
  return count;
}

首先通过函数 _IO_adjust_column 进行列调整,实现如下

unsigned
_IO_adjust_column (unsigned start, const char *line, int count)
{
  const char *ptr = line + count;
  while (ptr > line)
    if (*--ptr == '\n')
      return line + count - ptr - 1;
  return start + count;
}
libc_hidden_def (_IO_adjust_column)

函数会对被输出的数据进行遍历,找到第一个换行符为止,返回换行符之后的字符总数;当然也可能没有输出换行符,那么就直接返回输出的字符数和原先所处在的列的和。从这样的实现可以看出,fp->_cur_column /* 1+column number of pbase(); 0 is unknown. */ 维护的是当前所处的列数加一,通过调用这个函数实现了对该变量的更新。

然后调用 _IO_setg 来对 read 相关指针进行更新。

#define _IO_setg(fp, eb, g, eg)  ((fp)->_IO_read_base = (eb),\
        (fp)->_IO_read_ptr = (g), (fp)->_IO_read_end = (eg))

也就是把三个 read 指针的缓冲区指针都赋为相关的 buf 指针。

之后还会对三个 write 指针进行更新。这些操作是刷新缓冲区,使读入和输出的开始地址都指向缓冲区的头部,根据不同的缓冲方式也会对 _IO_write_end 置不同的值,还是比较好理解的。

更新完后返回输出的字符数,结束流程。

总结 new_do_write 做的事:将缓冲区中需要输出的数据通过系统调用输出,然后更新列状态和 read write 相关的共计 6 个指针的指向。

之后一路返回,结束 _IO_do_flush (fp);。这个函数做的就是将缓冲区中的残留字符全部输出,同时更新相关状态。

又回到了 _IO_new_file_finish 上,接下来指向

      if (!(fp->_flags & _IO_DELETE_DONT_CLOSE))
        _IO_SYSCLOSE (fp);
    }
  _IO_default_finish (fp, 0);
}

如果该文件不是在删除时需要被关闭,则调用 _IO_SYSCLOSE (fp);,实际调用函数指针 __close


__close

int
_IO_file_close (FILE *fp)
{
  /* Cancelling close should be avoided if possible since it leaves an
     unrecoverable state behind.  */
  return __close_nocancel (fp->_fileno);
}
libc_hidden_def (_IO_file_close)
int
__close_nocancel (int fd)
{
  return INLINE_SYSCALL_CALL (close, fd);
}
libc_hidden_def (__close_nocancel)

实现比较简单,就是调用系统调用来关闭当前文件。


关闭文件后,调用 _IO_default_finish 做最后的去除操作

void
_IO_default_finish (FILE *fp, int dummy)
{
  struct _IO_marker *mark;
  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    {
      free (fp->_IO_buf_base);
      fp->_IO_buf_base = fp->_IO_buf_end = NULL;
    }
  for (mark = fp->_markers; mark != NULL; mark = mark->_next)
    mark->_sbuf = NULL;
  if (fp->_IO_save_base)
    {
      free (fp->_IO_save_base);
      fp->_IO_save_base = NULL;
    }
  _IO_un_link ((struct _IO_FILE_plus *) fp);
#ifdef _IO_MTSAFE_IO
  if (fp->_lock != NULL)
    _IO_lock_fini (*fp->_lock);
#endif
}
libc_hidden_def (_IO_default_finish)

这里主要做了 free 缓冲区和将该文件从文件链表上解链的操作。

至此 __finish 的调用已经分析完了,可见他主要做了将缓冲区刷新、释放缓冲区和将文件解链的操作。