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 !\n");

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!\n");

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的参数如果是以'\n'结束的纯字符串,printf会被优化为puts函数,而字符串的结尾'\n'符号被消除。除此之外,都会正常生成call printf指令。

如果我们仍希望通过printf调用"Hello World !\n"的话,只需要按照如下方式修改即可。不过这样做就不能在printf调用结束后立即看到打印字符串了,因为puts函数可以立即刷新输出缓冲区。我们仍然使用puts作为例子继续阐述。

.section   .rodata

.LC0:

.string    "hello world!\n"

...

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 !\n"的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 ('\n', _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在磁盘上并不存在,它是

printf code的更多相关文章

  1. 嵌入式C语言代码的调试技巧

    在项目开发的过程中,不可避免的会遇到调试代码的情况. 刚开始写代码时,我们想看具体执行到哪儿时,往往这么写: printf("***** Code is here! *****\n" ...

  2. 简体中国版文档的Markdown语法

    Markdown文件 注意︰这是简体中国版文档的Markdown语法.如果你正在寻找英语版文档.请参阅Markdown︰ Markdown: Syntax. Markdown: Syntax 概述 哲 ...

  3. Markdown语法 中文版

    文章翻译自Markdown创始人JOHN GRUBER的 个人博客, 英文原文请参见 Markdown Syntax; 本文地址: http://www.cnblogs.com/ayning/p/43 ...

  4. Java_动态编译总结

    不多说直接上代码: 动态编译的主类: package com.lkb.autoCode.util; import com.lkb.autoCode.constant.AutoCodeConstant; ...

  5. GDB配置与.gdbinit的编写

    GDB配置与.gdbinit的编写 当 GDB(即 GNU Project Debugger)启动时,它在当前用户的主目录中寻找一个名为 .gdbinit 的文件:如果该文件存在,则 GDB 就执行该 ...

  6. [No00005C]我也入住Markdown

    概览 宗旨 Markdown 的目标是实现「易读易写」. 可读性,无论如何,都是最重要的.一份使用 Markdown 格式撰写的文件应该可以直接以纯文本发布,并且看起来不会像是由许多标签或是格式指令所 ...

  7. Linux sysinfo获取系统相关信息

    Linux中,可以用sysinfo来获取系统相关信息. #include <stdio.h> #include <stdlib.h> #include <errno.h& ...

  8. Markdown 语法说明

    Markdown 语法说明 (简体中文版) / (点击查看快速入门) 概述 宗旨 Markdown 的目标是实现「易读易写」. 可读性,无论如何,都是最重要的.一份使用 Markdown 格式撰写的文 ...

  9. 第四章第四个例题(LRJ)

    半年了,最起码的编程能力也谈不上啊,思维神马就更不不敢说了. 互联网时代讲求效率,走得慢和不走没有区别了. The war is on. (buhuidetiduokanduodajibianyehu ...

随机推荐

  1. 用C#绘图实现动画出现卡屏(运行慢)问题的解决办法

    原文:用C#绘图实现动画出现卡屏(运行慢)问题的解决办法 正在用C#做一个小游戏,需要用到动画,上次解决的问题是闪烁问题,用双缓冲技术.以为不会有什么问题了.后来当把图片全部绘制上去的时候依然出现了卡 ...

  2. OWIN轻量型框架介绍

    OWIN轻量型框架介绍 阅读目录 引言 框架的特色 如何启动 各项功能 静态路由的3种写法 伪静态路由的支持 处理Form表单提交的文件 流式处理Post请求的数据 多种请求类型自动识别 响应处理 请 ...

  3. ArcGIS JavaScript API本地部署离线开发环境[转]

    原文地址:http://www.cnblogs.com/brawei/archive/2012/12/28/2837660.html 1 获取ArcGIS JavaScript API API的下载地 ...

  4. 第1章 单例模式(Single Pattern)

    原文 第1章 单例模式(Single Pattern) 单例模式就是保证在整个应用程序的生命周期中,在任何时刻,被指定的类只有一个实例,并为客户程序提供一个获取该实例的全局访问点. 一.常用模式: 1 ...

  5. 如何为ios酷我音乐盒下载导出的音乐文件(使用Java程序设计)

    这个工具已经准备第二版,读者了解编程软件,可以直接使用,请阅读和使用这个场地 http://blog.csdn.net/jzj1993/article/details/44459983 本文所涉及内容 ...

  6. BZOJ 1004 HNOI2008 Cards Burnside引理

    标题效果:特定n张卡m换人,编号寻求等价类 数据保证这m换人加上置换群置换后本身构成 BZOJ坑爹0.0 条件不那么重要出来尼玛怎么做 Burnside引理--昨晚为了做这题硬啃了一晚上白书0.0 都 ...

  7. 使用php-fpm状态页观察当前的php-fpm状态

    对于php-fpm的參数设置,非常多情况下有这种疑问,就是内置的几个參数比如pm.max_children,pm.start_servers等这几个參数究竟该设置最多为多少才合适.事实上这几个參数往往 ...

  8. Asp.Net+Easyui实现重大CRUD

    今天周四称,这应该给自己一个休息,好好休息休息,但无奈自己IT这是痴迷.甘心的想加加班把目标功能实现,功夫不负有心人.经过6个小时的鏖战,我最终成功了. 在此和大家分享下成果,希望大家给个赞. 我的目 ...

  9. C#-简单的定时器(C# ConsoleApp) ---ShinePans

    watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2hpbmVwYW4=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA ...

  10. 用HMM(隐马)图解三国杀的于吉“质疑”

    ·背景 最近乘闲暇之余初探了HMM(隐马尔科夫模型),觉得还有点意思,但是网上的教程都超级枯草,可读性很差,抄来抄去的,一堆公式仍在你面前,谁能搞的懂(但园内的两篇写的还算不错.真才实学).在熬制3天 ...