上次对 malloc 进行了源码分析,然后加深了我对 堆利用的理解,让我觉得读源码确实有很大的好处。所以决定在对 IO 这一块的源码进行分析一下,由于 IO 相关的源码太多了,我只对其中的典型进行分析,然后相关的类似函数就不进行过多分析。虽然才开始习惯要去读源码分析漏洞,但是向前走吧,加油吧。
基本结构
stat
stat
结构体是用来保存文件或者文件夹信息的结构体
1 | struct stat |
_IO_FILE_plus
1 | struct _IO_FILE_plus |
每个文件结构其实是由上述 结构体描述,其中第一个变量是真实的 _IO_FILE 结构体,第二个变量 vtable 指向了 _IO_jump_t 的虚表,该虚表记录了FILE 能够执行的函数指针,在 libc2.23 32版本中的 vtable
的偏移 0x94
,64位下的偏移为 0xd8
。
每个 _IO_FILE_plus
会组成一个 FILE 链表,链表的头部 是 _IO_list_all
全局变量,通过这个全局变量可以遍历所有的 FILE
结构体,在每个程序启动的时候, 会自动创建 stdin
, stdout
, stderr
三个文件流,也就是 _IO_list_all
指向由 这三个 文件流构成的链表。
_IO_FILE
1 | struct _IO_FILE { |
所有的 FILE
结构体都是如上图所示,结构体指明了 IO 读取、输出的 开始、结束地址和指针,同时指定了输入输出缓冲区的 开始结束地址。其中有一个 chian
指针指明了 FILE链表的下一个 FILE 结构体。
_IO_jump_t
1 | struct _IO_jump_t |
_IO_jump_t
保存了一些函数指针,在一系列标准 IO 函数中会调用这些函数指针。
重要函数
fopen
1 | FILE *fopen(char *filename, *type); |
- filename:目标文件的路径
- type:打开方式的类型
- 返回值:返回一个文件指针
Tips:
如果想要对自己写得源代码编译生成的二进制文件,在调试时加入符号信息,可以使用如下命令:
1 | gcc test.c -g |
fopen
函数首先会调用 _IO_new_fopen
函数,内部会调用 __fopen_internal
函数
1 | _IO_FILE * |
在 __fopen_internal
函数中,总共主要执行 4步:
1 | _IO_FILE * |
malloc
分配 locked_FILE
结构体
首先需要 FILE 结构体分配内存,调用 malloc
函数分配 locked_FILE
结构,其中包含了 _IO_FILE_plus
结构。此外还有 _IO_wide_data
表示宽字节处理,结构内容和 _IO_FIE
类似,分配的结果如下:
调用 _IO_no_init
对结构体进行全 null 初始化
1 | void |
首先调用了_IO_old_init
函数,即将结构体中的成员变量设置为默认值。并且将_wide_data
结构体中的成员变量也设置为默认值。
调用 _IO_file_init
函数对结构体进行初始化
随后就要调用 _IO_file_init
函数对 FILE结构进行更深入的初始化。其实 本质上是调用了 _IO_new_file_init
函数。
1 | void |
在 _IO_link_in
函数中,
1 | void |
首先是判断该结构体是否已经加入到链表中。接着将该结构体的_chain
指针指向_IO_list_all
指针当前指向的结构体,也就是下一个结构体,更新IO_list_all
可以看到当前创建的结构体已经加入 _IO_list_all
列表,并且位于 _IO_list_all
链表头部,其下一个 结构体是 _IO_2_1_stderr_
。整个FILE链表现在如下:
1 | IO_list_all->new_f->_IO_2_1_stderr->_IO_2_1_stdout_->_IO_2_1_stdin_ |
调用 _IO_file_fopen
执行系统调用打开文件
本质上调用 _IO_new_file_fopen
函数,会检查文件是否打开,根据传入的mode
设置 FILE
结构的mode
,最后调用 open
函数实现打开文件,更新 FILE
结构体。
1 | _IO_FILE * |
总结:
- 使用
malloc
分配FILE
结构 - 设置 FILE 结构的
vtable
- 初始化分配的
FILE
结构 - 将初始化的
FILE
结构链入FILE
结构链表中 - 调用系统调用打开文件
fread
1 | size_t fread ( void *buffer, size_t size, size_t count, FILE *stream) ; |
- buffer 存放读取数据的缓冲区。
- size:指定每个记录的长度。
- count: 指定记录的个数。
- stream:目标文件流。
- 返回值:返回读取到数据缓冲区中的记录个数
fread
函数主要调用 _IO_fread
函数,里面主要先计算了 用户读取的size大小,检查 FILE 结构体是否存在,然后获得 FILE锁,最后调用了 _IO_sgetn
函数去读取文件。
1 | _IO_size_t |
_IO_sgetn
函数主要调用 _IO_XSGETN
函数,_IO_XSGETN
调用的是虚函数表中的__xsgetn
,默认调用的是IO_file_xsgetn
函数
1 | _IO_size_t |
_IO_file_xsgetn
函数 首先判断FILE 结构体输入输出缓冲区是否存在,如果不存在则调用 _IO_doallocbuf
去分配缓冲区。然后才会去读取数据到缓冲区中。
1 | _IO_size_t |
创建输入输出缓冲区
在 _IO_doallocbuf
函数中,会判断 FILE 结构体,如果flag
不是IO_UNBUFFERED
且model>0
,则调用_IO_DOALLOCATE
,该函数调用的是虚函数表中的__doallocate
函数,默认调用的_IO_file_doallocate
函数。而 _IO_setb
是设置 FILE 结构体输入输出指针的地址。
1 | void |
在 _IO_file_doallocate
函数中首先会调用 _IO_SYSSTAT
获取文件信息,并保存在 stat
结构体,_IO_SYSSTAT
调用的是虚函数表中的__stat
函数,获取文件信息,修改相应的需要申请的size
。然后会调用 _IO_setb
去设置IO_buf_base
和IO_buf_end
,也就是设置结构体中输入输出缓冲区的位置。执行完毕之后函数返回,接着alloc
相关函数返回,这样输入输出缓冲区就初始化完毕了。
1 | int |
获取的 st 文件信息如下:
执行完 _IO_setb
函数后,可以看到已经分配了 输入输出缓冲区,并且更新了 FILE 结构体中 _IO_buf_base
和 _IO_buf_end
此时就完成了 输入输出缓冲区的创建,随后就进入数读取的过程。
读取文件数据
读取文件分为两步:
- 输入缓冲区存在,且剩余输入缓冲区数据大小大于用户请求的数据大小,直接拷贝数据到用户指定的buf。
- 如果剩余缓冲区数据大小小于用户请求的数据大小,则先将buf填满,再调用系统调用
_IO_SYSREAD
读取文件数据。
第一步的过程如下:
1 | //得到输入缓冲区还剩余的数据大小 |
第二步系统调用:
先是将剩余缓冲区数据全部拷贝的buf 中
1 | if (have > 0) |
若当前的输入输出缓冲区中不存在可读数据就需要系统调用从文件中读取数据了。
- 用户需要读取的数据大于
blksize
即一个block
的大小,则调用IO_SYSREAD
将最大的block
数的数据直接读取到s
中。 - 否则调用
IO_UNDERFLOW
从文件中读取最大一个block
的数据。
1 | //输入缓冲区存在且用户需要读取的数据小于输入缓冲区的大小 |
_underflow
按照执行流程,此时是第一次读取数据,也就是_IO_read_ptr
与_IO_read_end
等指针均为空值,输入缓冲区中不存在待读数据,且我们读取的数据的大小小于一个block size
,因此会进入到__underflow
函数中。
1 | int |
首先会对FILE
结构体的model
进行检查,保证其处于get
模式。如果fp->_IO_read_ptr < fp->_IO_read_end
成立则表示还存在未被读取的数据,直接返回,否则就调用_IO_UNDERFLOW
读取文件中的数据。该函数调用的是虚函数表中的__underflow
函数,默认调用的是IO_file_underflow
函数.
_IO_UNDERFLOW
1 | int |
函数的执行流程如下
- 首先是检查文件是否允许读,若不允许则直接返回
EOF
- 检查输入输出缓冲区是否被初始化,如果未被初始化则初始化输入输出缓冲区(分配
block size
大小的内存空间) - 输出
stdout
中未被输出的内容,即调用_IO_OVERFLOW
表示的虚函数 - 调用
_IO_SYSREAD
读取文件数据,函数返回读取的文件的字节数 - 设置读取的末尾指针。
_IO_SYSREAD
1 | _IO_ssize_t |
函数在进行一定的检查之后直接调用系统调用read
读取文件内容到buf
缓冲区中。在执行完毕_IO_UNDERFLOW
之后,FILE
结构体中的读位置指针已经指向了输入输出缓冲区。
最终可以看到 输入输出缓冲中 已经有了数据:
总结:
- 若输入输出缓冲区未初始化则初始化输入输出缓冲区
- 若用户需要读取的数据的大小小于当前的输入输出缓冲区的可读取数据的大小,则直接
memcpy
将数据拷贝到返回值中 - 否则先将当前输入输出缓冲区的可读数据拷贝到返回值中。若剩余需要读取的数据的大小小于
block size
则调用__underflow
读取最大一个block size
的数据保存在缓冲区中,返回第2
步。也就是直接memcpy
- 若剩余需要读取的数据大于一个
block size
则调用IO_SYSREAD
将最大block
数的数据直接读取到s
中,返回第2
步,也就是下一步通过__underflow
读取剩余的数据。
fwrite
1 | size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream); |
- buffer: 是一个指针,对 fwrite 来说,是要写入数据的地址;
- size: 要写入内容的单字节数;
- count: 要进行写入 size 字节的数据项的个数;
- stream: 目标文件指针;
- 返回值:实际写入的数据项个数 count。
fwrite
本质上调用的是 _IO_fwrite
,_IO_fwrite
函数刚开始是获取用户请求的size大小,然后获取文件锁,最后是调用 _IO_sputn
,而 _IO_sputn
默认调用的是 _IO_new_file_xsputn
函数。
1 | _IO_size_t |
_IO_new_file_xputn
计算输出缓冲区可用空间
通过 缓冲区结束地址 和 输出地址指针,计算缓冲区中可用的空间,保存到 count
变量里。
1 | _IO_size_t |
写入数据
输入缓冲区空间存在
1 | _IO_size_t |
to_do
表示剩余需要写入的数据的大小,若当前的缓冲区中存在剩余,且满足用户的需求则使用 memcpy
将数据拷贝到输出缓冲区中。如果此时的 to_do
仍存在数值的话,表示输出缓冲区的空间不足,存在剩余的数据。
为建立输出缓冲区或空间不足
1 | _IO_size_t |
如果仍然剩余需要被写入的数据,则表示当前输出缓冲区已经用尽,或者未建立输出缓冲区。此时调用 _IO_OVERFLOW
所表示的虚函数进行输出缓冲区的初始化,或者刷新输出缓冲区。当程序时第一次读数据,由于未建立输出缓冲区,所以首先会调用该函数。
IO_OVERFLOW
该函数调用的是虚函数表中的 __overflow
,默认会调用 _IO_new_file_overflow
函数,该函数作用是刷新输出缓冲区。
1 | int |
首先判断了当前的 FILE
结构体指向的文件是否具有可写权限,然后若当前的输入输出缓冲区未被初始化,则调用 IO_doallocbuf
初始化输入输出缓冲区。在初始化缓冲区之后对 FILE
结构体中的读指针进行了赋值。
1 |
|
接着对当前的写指针进行了赋值,主要是将_IO_write_ptr
赋值为了_IO_read_ptr
。在执行完毕之后调用了_IO_do_write
来进行写入操作。
_IO_do_write
函数调用的 _IO_new_do_write
函数,若需要写入的字节数为 0
则直接返回,否则调用 new_do_write
函数,由于初次使用,写指针刚被赋值,此时的 to_do
值为0,因此会直接返回 0
。
如果此前存在输入输出缓冲区或者处于写模式,且存在未被刷新的输出缓冲区数据,那么就会调用 new_do_write
函数,将当前为被写入的数据写入到文件中,即刷新输出缓冲区。
分块数据写入
1 | _IO_size_t |
如果需要写入的数据是大块的数据(block_size>128
)且待写入数据 to_do
大于一个 block
,那么首先将 do_write
赋值为整块的数据,调用 new_do_write
函数进行文件数据写入,我们知道 new_do_write
函数返回值是写入的文件的字节数,如果 count<do_write
则表示写入出错,直接返回。
new_do_write
1 | static |
_IO_SYSWRITE
首先函数对写指针进行一个判断,防止读写冲突,接着调用 _IO_SYSWRITE
进行写入操作,该函数调用的是虚函数表的 __write
函数,默认调用的是 IO_new_file_write
函数,也是最终执行系统调用的地方:
1 | _IO_ssize_t |
最终调用系统调用数据写入到文件中,更新文件的偏移位置。
将整个 block
的数据写入完成之后,就剩下小块的数据,此时调用 _IO_default_xsputn
函数进行写入。
_IO_default_xsputn
1 | _IO_size_t |
先判断了输出缓冲区是否存在剩余空间,如果不存在则调用 _IO_OVERFLOW
刷新输出缓冲区。
若缓冲区存在剩余空间:
- 若写入的数据大于
20
字节,则依次赋值 - 否则通过
memcpy
拷贝数据
完成之后若 more
即数据仍然存在剩余则表示输出缓冲区已经用尽则调用 IO_OVERFLOW
刷新输出缓冲区,到此用户的数据已经全部写入到文件中。
总结
- 首先计算当前的输出缓冲区存在的剩余空间的大小,若存在空间剩余则将数据拷贝到输出缓冲区中
- 若此时仍然存在剩余的数据,则表示的是输出缓冲区未建立或者是空间用尽,则此时调用
IO_OVERFLOW
函数进行输出缓冲区的建立或刷新 - 输出缓冲区准备完毕后,首先判断写入的数据是否大于整块的数据,若大于则调用
IO_do_write
函数进行整块数据写入 - 调用
_IO_default_xsputn
函数将剩余部分写入文件
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/10/01/glibc-IO源码分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!