printf背後的故事
提及編程語言,C語言你們再熟悉不過。提及最簡單的代碼,Helloworld更是衆所周知。一條簡單的printf語句即可以完成這個簡單的功能,但是printf背後到底作了什麼事情呢?可能不少人未曾在乎,也或許你比我還要好奇!那咱們就聊聊printf背後的故事。linux
1、printf的代碼在哪裏?編程
顯然,Helloworld的源代碼須要通過編譯器編譯,操做系統的加載才能正確執行。而編譯器包含預編譯、編譯、彙編和連接四個步驟。sass
#include<stdio.h>koa
int main()編程語言
{ide
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
2、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!
咱們發現Hello World!打印位置的上一行代碼的執行位置爲0xb7fff419。咱們查看此處的反彙編代碼。
(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.
咱們驚奇的發現,地址0xb7fff419正是指向sysenter指令的位置!這裏即是系統調用的入口。若是想了解這裏爲何不是int 0x80指令,請參考文章《Linux 2.6 對新型 CPU 快速系統調用的支持》。或者參考Linus在郵件列表裏的文章《Intel P6 vs P7 system call performance》。
系統調用的位置已是printf函數調用的末端了,咱們只須要按照函數調用關係便能獲得printf的調用軌跡了。
(gdb)backtrace
#0 0xb7fff424 in __kernel_vsyscall ()
#1 0x080588b2 in __write_nocancel ()
#2 0x0806fa11 in _IO_new_file_write ()
#3 0x0806f8ed in new_do_write ()
#4 0x080708dd in _IO_new_do_write ()
#5 0x08070aa5 in _IO_new_file_overflow ()
#6 0x08049a37 in puts ()
#7 0x08048eb9 in main () at main.c:4
咱們發現系統調用前執行的函數是__write_nocancel,它執行了系統調用__write!
3、printf源碼閱讀
雖然咱們找到了Hello World的printf調用軌跡,可是仍然沒法看到函數的源碼。跟蹤反彙編代碼不是個好主意,最好的方式是直接閱讀glibc的源代碼!咱們能夠從官網下載最新的glibc源代碼(glibc-2.18)進行閱讀分析,或者直接訪問在線源碼分析網站LXR。而後按照調用軌跡的的逆序查找函數的調用點。
1.puts 調用 _IO_new_file_xsputn
具體的符號轉化關係爲:_IO_puts => _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_new_file_xsputn => _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_new_do_write
具體的符號轉化關係爲:_IO_new_file_overflow =>_IO_do_write =>_IO_new_do_write
$cat ./libio/fileops.c
int
_IO_new_file_overflow (f, ch)
_IO_FILE *f;
int ch;
{
...
if (INTUSE(_IO_do_write) (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base) == EOF)
return EOF;
return (unsigned char) ch;
}
4. _IO_new_do_write 調用 new_do_write
具體的符號轉化關係爲:_IO_new_do_write => new_do_write
$cat ./libio/fileops.c
int
_IO_new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
return (to_do == 0
|| (_IO_size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
5. new_do_write調用 _IO_new_file_write
具體的符號轉化關係爲:new_do_write =>_IO_SYSWRITE => __write() => write() => _IO_new_file_write
$cat ./libio/fileops.c
_IO_size_t
new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
...
count = _IO_SYSWRITE (fp, data, to_do);
if (fp->_cur_column && count)
fp->_cur_column = INTUSE(_IO_adjust_column) (fp->_cur_column - 1, data, count) + 1;
...
}
6. _IO_new_file_write調用 write_nocancel
具體的符號轉化關係爲:_IO_new_file_write=>write_not_cancel => write_nocancel
$cat ./libio/fileops.c
_IO_ssize_t
_IO_new_file_write (f, data, n)
_IO_FILE *f;
const void *data;
_IO_ssize_t n;
{
_IO_ssize_t to_do = n;
while (to_do > 0)
{
_IO_ssize_t count = (__builtin_expect (f->_flags2& _IO_FLAGS2_NOTCANCEL, 0)? write_not_cancel (f->_fileno, data, to_do): write (f->_fileno, data, to_do));
...
}
7. write_nocancel 調用 linux-gate.so::__kernel_vsyscall
具體的符號轉化關係爲:write_nocancel => INLINE_SYSCALL => INTERNAL_SYSCALL =>__kernel_vsyscall
注意 linux-gate.so在磁盤上並不存在,它是內核鏡像中的特定頁,由內核編譯生成。關於它的更多信息,能夠參考文章《linux-gate.so技術細節》和《What is linux-gate.so.1?》。
4、總結
本文從printf(「Hello World !\n」)談起,按照編譯器工做的流程發掘了printf函數的代碼位置。而後使用gdb反向跟蹤的方式找到了printf函數的調用軌跡,最後藉助於glibc源代碼描述了printf函數的詳細調用過程。相信這些內容會給你們帶來一個更加清晰的printf函數,透過對printf函數的實現機制的解析,更能夠加深對計算機工做機制的理解。但願本文對你有所幫助。