printf背后的故事
2014-01-14 21:54 by Florian, 41 阅读, 0 评论, 收藏, 编辑
printf背后的故事
说起编程语言,C语言大家再熟悉不过。说起最简单的代码,Helloworld更是众所周知。一条简单的printf语句便可以完成这个简单的功能,可是printf背后到底做了什么事情呢?可能很多人不曾在意,也或许你比我还要好奇!那我们就聊聊printf背后的故事。
一、printf的代码在哪里?
显然,Helloworld的源代码需要经过编译器编译,操作系统的加载才能正确执行。而编译器包含预编译、编译、汇编和链接四个步骤。
#include<stdio.h>
int main()
{
printf("Hello World ! ");
return 0;
}
首先,预编译器处理源代码中的宏,比如#include。预编译结束后,我们发现printf函数的声明。
$/usr/lib/gcc/i686-linux-gnu/4.7/cc1 -E -quiet main.c -o main.i
# 1 "main.c"
# 1 "<命令行>"
# 1 "main.c"
...
extern int printf (const char *__restrict __format, ...);
...
int main()
{
printf("Hello World! ");
return 0;
}
然后编译器将高级语言程序转化为汇编代码。
$/usr/lib/gcc/i686-linux-gnu/4.7/cc1 -fpreprocessed -quiet main.i -o main.s
.file "main.c"
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
subl $16, %esp
movl $.LC0, (%esp)
call puts
movl $0, %eax
leave
ret
.size main, .-main
...
我们发现printf函数调用被转化为call puts指令,而不是call printf指令,这好像有点出乎意料。不过不用担心,这是编译器对printf的一种优化。实践证明,对于printf的参数如果是以' '结束的纯字符串,printf会被优化为puts函数,而字符串的结尾' '符号被消除。除此之外,都会正常生成call printf指令。
如果我们仍希望通过printf调用"Hello World ! "的话,只需要按照如下方式修改即可。不过这样做就不能在printf调用结束后立即看到打印字符串了,因为puts函数可以立即刷新输出缓冲区。我们仍然使用puts作为例子继续阐述。
.section .rodata
.LC0:
.string "hello world! "
...
call printf
...
接下来,汇编器开始工作。将汇编文件转化为我们不能直接阅读的二进制格式——可重定位目标文件,这里我们需要gcc工具包的objdump命令查看它的二进制信息。可是我们发现call puts指令里保存了无效的符号地址。
$as -o main.o main.s
$objdump –d main.o
main.o: 文件格式 elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 e4 f0 and $0xfffffff0,%esp
6: 83 ec 10 sub $0x10,%esp
9: c7 04 24 00 00 00 00 movl $0x0,(%esp)
10: e8 fc ff ff ff call 11 <main+0x11>
15: b8 00 00 00 00 mov $0x0,%eax
1a: c9 leave
1b: c3 ret
而链接器最终会将puts的符号地址修正。由于链接方式分为静态链接和动态链接两种,虽然链接方式不同,但是不影响最终代码对库函数的调用。我们这里关注printf函数背后的原理,因此使用更易说明问题的静态链接的方式阐述。
$/usr/lib/gcc/i686-linux-gnu/4.7/collect2
-static -o main
/usr/lib/i386-linux-gnu/crt1.o
/usr/lib/i386-linux-gnu/crti.o
/usr/lib/gcc/i686-linux-gnu/4.7/crtbeginT.o
main.o
--start-group
/usr/lib/gcc/i686-linux-gnu/4.7/libgcc.a
/usr/lib/gcc/i686-linux-gnu/4.7/libgcc_eh.a
/usr/lib/i386-linux-gnu/libc.a
--end-group
/usr/lib/gcc/i686-linux-gnu/4.7/crtend.o
/usr/lib/i386-linux-gnu/crtn.o
$objdump –sd main
Disassembly of section .text:
...
08048ea4 <main>:
8048ea4: 55 push %ebp
8048ea5: 89 e5 mov %esp,%ebp
8048ea7: 83 e4 f0 and $0xfffffff0,%esp
8048eaa: 83 ec 10 sub $0x10,%esp
8048ead: c7 04 24 e8 86 0c 08 movl $0x80c86e8,(%esp)
8048eb4: e8 57 0a 00 00 call 8049910 <_IO_puts>
8048eb9: b8 00 00 00 00 mov $0x0,%eax
8048ebe: c9 leave
8048ebf: c3 ret
...
静态链接时,链接器将C语言的运行库(CRT)链接到可执行文件,其中crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o便是这五个核心的文件,它们按照上述命令显示的顺序分居在用户目标文件和库文件的两侧。由于我们使用了库函数puts,因此需要库文件libc.a,而libc.a与libgcc.a和libgcc_eh.a有相互依赖关系,因此需要使用—start-group和—end-group将它们包含起来。
链接后,call puts的地址被修正,但是反汇编显示的符号是_IO_puts而不是puts!难道我们找的文件不对吗?当然不是,我们使用readelf命令查看一下main的符号表。竟然发现puts和_IO_puts这两个符号的性质是等价的!objdump命令只是显示了全局的符号_IO_puts而已。
$readelf main –s
Symbol table '.symtab' contains 2307 entries:
Num: Value Size Type Bind Vis Ndx Name
...
1345: 08049910 352 FUNC WEAK DEFAULT 6 puts
...
1674: 08049910 352 FUNC GLOBAL DEFAULT 6 _IO_puts
...
那么puts函数的定义真的是在libc.a里吗?我们需要对此确认。我们将libc.a解压缩,然后全局符号_IO_puts所在的二进制文件,输出结果为ioputs.o。然后查看该文件的符号表。发现ioputs.o定义了puts和_IO_puts符号,因此可以确定ioputs.o就是puts函数的代码文件,且在库文件libc.a内。
$ar -x /usr/lib/i386-linux-gnu/libc.a
$grep -rin "_IO_puts" *.o
$readelf -s ioputs.o
Symbol table '.symtab' contains 20 entries:
Num: Value Size Type Bind Vis Ndx Name
...
11: 00000000 352 FUNC GLOBAL DEFAULT 1 _IO_puts
...
19: 00000000 352 FUNC WEAK DEFAULT 1 puts
二、printf的调用轨迹
我们知道对于"Hello World ! "的printf调用被转化为puts函数,并且我们找到了puts的实现代码是在库文件libc.a内的,并且知道它是以二进制的形式存储在文件ioputs.o内的,那么我们如何寻找printf函数的调用轨迹呢?换句话说,printf函数是如何一步步执行,最终使用Linux的int 0x80软中断进行系统调用陷入内核的呢?
如果让我们向终端输出一段字符串信息,我们一般会使用系统调用write()。那么打印Helloworld的printf最终是这样做的吗?我们借助于gdb来追踪这个过程,不过我们需要在编译源文件的时候添加-g选项,支持调试时使用符号表。
$/usr/lib/gcc/i686-linux-gnu/4.7/cc1 -fpreprocessed -quiet -g main.i -o main.s
然后使用gdb调试可执行文件。
$gdb ./main
(gdb)break main
(gdb)run
(gdb)stepi
在main函数内下断点,然后调试执行,接着不断的使用stepi指令执行代码,直到看到Hello World !输出为止。这也是为什么我们使用puts作为示例而不是使用printf的原因。
(gdb)
0xb7fff419 in __kernel_vsyscall ()
(gdb)
Hello World!
0xb7fff424 in __kernel_vsyscall ()
(gdb)
0xb7fff425 in __kernel_vsyscall ()
我们发现Hello World!打印位置的上一行代码的执行位置为0xb7fff419,我们重新调试 ,等执行到printf时,再在这里下新的断点,然后继续执行。
(gdb)run
(gdb)break *0xb7fff419
(gdb)continue
Breakpoint 2, 0xb7fff419 in __kernel_vsyscall ()
代码在__kernel_vsyscall函数内暂停。然后我们看这里的反汇编代码。
(gdb)disassemble
Dump of assembler code for function __kernel_vsyscall:
0xb7fff414 <+0>: push %ecx
0xb7fff415 <+1>: push %edx
0xb7fff416 <+2>: push %ebp
0xb7fff417 <+3>: mov %esp,%ebp
=> 0xb7fff419 <+5>: sysenter
0xb7fff41b <+7>: nop
0xb7fff41c <+8>: nop
0xb7fff41d <+9>: nop
0xb7fff41e <+10>: nop
0xb7fff41f <+11>: nop
0xb7fff420 <+12>: nop
0xb7fff421 <+13>: nop
0xb7fff422 <+14>: int $0x80
0xb7fff424 <+16>: pop %ebp
0xb7fff425 <+17>: pop %edx
0xb7fff426 <+18>: pop %ecx
0xb7fff427 <+19>: ret
End of assembler dump.
我们惊奇的发现,PC指针指向sysenter指令的位置!这里便是系统调用的入口。如果想了解这里为什么不是int 0x80指令,请参考文章《Linux 2.6 对新型 CPU 快速系统调用的支持》。或者参考Linus在邮件列表里的文章《Intel P6 vs P7 system call performance》。
系统调用的位置已经是printf函数调用的末端了,我们只需要按照函数调用关系便能得到printf的调用轨迹了。
(gdb)backtrace
#0 0xb7fff419 in __kernel_vsyscall ()
#1 0x08058784 in __fxstat64 ()
#2 0x0806fad2 in _IO_file_stat ()
#3 0x080abcd4 in _IO_file_doallocate ()
#4 0x0804a890 in _IO_doallocbuf ()
#5 0x08070b58 in _IO_new_file_overflow ()
#6 0x080705b0 in _IO_new_file_xsputn ()
#7 0x080499a5 in puts ()
#8 0x08048eb9 in main () at main.c:4
我们发现最终触发的系统调用是_fstat64,而不是我们猜测的write!
三、printf源码阅读
虽然我们找到了Hello World的printf调用轨迹,但是仍然无法看到函数的源码。跟踪反汇编代码不是个好主意,最好的方式是直接阅读glibc的源代码!我们可以从官网下载最新的glibc源代码(glibc-2.18)进行阅读分析,或者直接访问在线源码分析网站LXR。然后按照调用轨迹的的逆序查找函数的调用点。
1.puts 调用 _IO_new_file_xsputn
具体的符号转化关系为:_IO_sputn => _IO_XSPUTN => __xsputn => _IO_file_xsputn => _IO_new_file_xsputn
$cat ./libio/ioputs.c
int
_IO_puts (str)
const char *str;
{
int result = EOF;
_IO_size_t len = strlen (str);
_IO_acquire_lock (_IO_stdout);
if ((_IO_vtable_offset (_IO_stdout) != 0
|| _IO_fwide (_IO_stdout, -1) == -1)
&& _IO_sputn (_IO_stdout, str, len) == len
&& _IO_putc_unlocked (' ', _IO_stdout) != EOF)
result = MIN (INT_MAX, len + 1);
_IO_release_lock (_IO_stdout);
return result;
}
#ifdef weak_alias
weak_alias (_IO_puts, puts)
#endif
这里注意weak_alias宏的含义,即将puts绑定到符号_IO_puts,并且puts符号为weak类型的。这也就解释了puts符号被解析为_IO_puts的真正原因。
2._IO_new_file_xsputn 调用 _IO_new_file_overflow
具体的符号转化关系为:_IO_OVERFLOW => __overflow => _IO_new_file_overflow
$cat ./libio/fileops.c
_IO_size_t
_IO_new_file_xsputn (f, data, n)
_IO_FILE *f;
const void *data;
_IO_size_t n;
{
...
if (to_do + must_flush > 0)
{
_IO_size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
/* If nothing else has to be written or nothing has been written, we
must not signal the caller that the call was even partially
successful. */
return (to_do == 0 || to_do == n) ? EOF : n - to_do;
...
3._IO_new_file_overflow 调用 _IO_doallocbuf
4._IO_doallocbuf 调用_IO_file_doallocate
具体的符号转化关系为:_IO_doallocbuf => _IO_file_doallocate
$cat ./libio/fileops.c
int
_IO_new_file_overflow (f, ch)
_IO_FILE *f;
int ch;
{
...
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
{
/* Allocate a buffer if needed. */
if (f->_IO_write_base == NULL)
{
_IO_doallocbuf (f);
_IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
}
...
5.__IO_file_doallocate 调用 _IO_file_stat
具体的符号转化关系为:_IO_SYSSTAT => __stat => _IO_file_stat
$cat ./libio/filedoalloc.c
int
_IO_file_doallocate (fp)
_IO_FILE *fp;
{
...
size = _IO_BUFSIZ;
if (fp->_fileno >= 0 && __builtin_expect (_IO_SYSSTAT (fp, &st), 0) >= 0)
...
6.__IO_file_stat 调用 __fxstat64
具体的符号转化关系为:__fxstat64
$cat ./libio/filedoalloc.c
int
_IO_file_stat (fp, st)
_IO_FILE *fp;
void *st;
{
return __fxstat64 (_STAT_VER, fp->_fileno, (struct stat64 *) st);
}
7.fstat64 调用 linux-gate.so::__kernel_vsyscall
注意 linux-gate.so在磁盘上并不存在,它是