系统程序员成长计划-像机器一样思考(三)
作者联系方式:李先静 <xianjimli at hotmail dot com>
系统程序员成长计划-像机器一样思考(三)
hello world的密秘
hello world是最经典的入门程序,该程序因Brian Kernighan 和Dennis Ritchie编写的《C语言程序设计》(The C Programming Language)而广泛流传。hello world同样也是深入研究计算机的极好题材,可以说我对计算机的理解,很大程度上归功于对hello world的研究。后来看了《深入理解计算机》和台湾著名黑客黄敬群老师的《深入淺出 Hello World》之后,对hello world又有了更深的认识。这里和大家分享一下hello world背后的密秘。
C语言的hello world如下:
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello World!/n");
return 0;
}
o 密秘一:main函数的原型
有些初学者是这样写HelloWorld的,编译也可以通过,运行也正常:
void main(void)
{
printf("Hello World!/n");
return;
}
如果用gcc来编译,你会发现,把main写成什么样子都行,只要函数名为main,编译都可以通过(可能有警告)。但下面两种写法才是比较正规的:
int main(int argc, char* argv[])
int main(int argc, char* argv[], char* env[])
argc是命令行参数的个数。
argv是命令行参数,以NULL结束。
env 是环境变量,以NULL结束。
下面的程序可以显示argv和env的内容:
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
int i = 0;
printf("Hello World!/n");
for(i = 0; argv[i] != NULL; i++)
{
printf("argv[%d]=%s/n", i, argv[i]);
}
for(i = 0; env[i] != NULL; i++)
{
printf("env[%d]=%s/n", i, env[i]);
}
return 0;
}
编译后运行它:
./helloworld arg1 arg2
屏幕打印:
Hello World!
argv[0]=./helloworld
argv[1]=arg1
argv[2]=arg2
env[0]=SSH_AGENT_PID=2609
env[1]=HOSTNAME=lixj.linux
env[2]=DESKTOP_STARTUP_ID=
env[3]=TERM=xterm
...
环境变量是从父进程继承过来的,通过setenv和getenv等函数可以存取环境变量,但对环境变量的修改只会影响当前进程及子进程,而不会影响父进程。
o 密秘二:main函数的返回值
正常情况下,我们调用一个函数之后,通过检查它的返回值来判断函数执行的结果。但main函数不是由程序员自己调用的,那么它的返回值会返回给谁呢?
答案是,main函数的返回值是返回给父进程的,父进程调用下列函数来获取子进程的退出码(即main函数的返回值):
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
在bash里,执行一个命令后(bash是父进程,命令是子进程),$?里存放的是这个命令的退出码,我们来测试一下:
int main(int argc, char* argv[])
{
printf("Hello World!/n");
return 100;
}
编译运行:
./helloworld_2;echo $?
屏幕打印:
Hello World!
100
100正是我们的返回值。这里对上面的程序做点修改,让它返回1000,看看它真的会返回1000吗?
int main(int argc, char* argv[])
{
printf("Hello World!/n");
return 1000;
}
编译运行:
./helloworld_2;echo $?
屏幕打印:
Hello World!
232
奇怪的是,这里打印的不是1000,而是232!不少朋友都碰到过类似的问题,我自己也遇到过。后来查资料才知道,main函数的返回值虽然是 int的,可以保存32位的整数,但实际上,系统只使用了一个字节来保存返回值,所以这是打印的是232(即1000 & 0xff)。
o 密秘三:被隐藏的细节
现在我们用strace来分析一下HelloWorld的执行过程:
strace ./helloworld_3
屏幕打印:
execve("./helloworld_3", ["./helloworld_3"], [/* 51 vars */]) = 0
brk(0) = 0x8eda000
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb8029000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("./tls/i686/sse2/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./tls/i686/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./tls/sse2/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./tls/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./i686/sse2/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./i686/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./sse2/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("./libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=123031, ...}) = 0
mmap2(NULL, 123031, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb800a000
close(3) = 0
open("/lib/libc.so.6", O_RDONLY) = 3
read(3, "/177ELF/1/1/1/0/0/0/0/0/0/0/0/0/3/0/3/0/1/0/0/0@/307o/0004/0/0/0"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0755, st_size=1758448, ...}) = 0
mmap2(0x6e6000, 1476176, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x6e6000
mmap2(0x849000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x163) = 0x849000
mmap2(0x84c000, 9808, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x84c000
close(3) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb8009000
set_thread_area({entry_number:-1 -> 6, base_addr:0xb80096c0, limit:1048575, seg_32bit:1, contents:0, read_exec_only:0, limit_in_pages:1, seg_not_present:0, useable:1}) = 0
mprotect(0x849000, 8192, PROT_READ) = 0
mprotect(0x6e2000, 4096, PROT_READ) = 0
munmap(0xb800a000, 123031) = 0
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb8028000
write(1, "Hello World!/n", 13Hello World!
) = 13
exit_group(0) = ?
这么一行简单的程序,居然做数十次系统调用。可见简单的程序并不简单,只是实现细节被操作系统和函数库封装起来了。
前面只是加载并执行helloworld程序,真正打印字符串的是write函数:它把字符串写入文件描述符为1的文件里。在C语言中:
文件描述符0 表示标准输入。
文件描述符1 表示标准输出。
文件描述符2 表示标准错误输出。
o 密秘四:printf不见了。
我们看下main函数的汇编代码(x86):
int main(int argc, char* argv[], char* env[])
{
80483b4: 8d 4c 24 04 lea 0x4(%esp),%ecx
80483b8: 83 e4 f0 and $0xfffffff0,%esp
80483bb: ff 71 fc pushl -0x4(%ecx)
80483be: 55 push %ebp
80483bf: 89 e5 mov %esp,%ebp
80483c1: 51 push %ecx
80483c2: 83 ec 04 sub $0x4,%esp
printf("Hello World!/n");
80483c5: c7 04 24 a4 84 04 08 movl $0x80484a4,(%esp)
80483cc: e8 1f ff ff ff call 80482f0 <puts@plt>
return 0;
80483d1: b8 00 00 00 00 mov $0x0,%eax
}
奇怪的是这里并没有调用printf,而且是调用的puts。第一次见到这个代码,我想printf可能只是个宏,最终由puts来实现打印功能。不过后来证实glibc里确实有printf函数,那为什么这里变成了puts呢?
我对代码做了修改,不包含任何头文件,自己声明printf的函数原型,这样确保没有宏在做怪:
int printf(const char *format, ...);
int main(int argc, char* argv[], char* env[])
{
printf("Hello World!/n");
return 0;
}
反汇编出来的代码没有任何变化,由此可见,不是宏在做怪,而是gcc做了手脚。原因可能是:printf要对格式字符进行分析,相对来说效率低下, 如果只有一个参数,printf的功能和puts一致,于是gcc就用puts代替了它。为了证实这个观点,对代码再做一点修改,使用格式字符串打印:
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
printf("%s%s", "Hello", " World!/n");
return 0;
}
这次汇编代码变成了:
int main(int argc, char* argv[], char* env[])
{
80483c4: 8d 4c 24 04 lea 0x4(%esp),%ecx
80483c8: 83 e4 f0 and $0xfffffff0,%esp
80483cb: ff 71 fc pushl -0x4(%ecx)
80483ce: 55 push %ebp
80483cf: 89 e5 mov %esp,%ebp
80483d1: 51 push %ecx
80483d2: 83 ec 14 sub $0x14,%esp
printf("%s%s", "Hello", " World!/n");
80483d5: c7 44 24 08 c4 84 04 movl $0x80484c4,0x8(%esp)
80483dc: 08
80483dd: c7 44 24 04 cd 84 04 movl $0x80484cd,0x4(%esp)
80483e4: 08
80483e5: c7 04 24 d3 84 04 08 movl $0x80484d3,(%esp)
80483ec: e8 03 ff ff ff call 80482f4 <printf@plt>
return 0;
80483f1: b8 00 00 00 00 mov $0x0,%eax
}
看来真的是gcc做了优化。
o 密秘五:链接了哪些共享库
用ldd查看helloworld链接的共享库。
屏幕打印:
linux-gate.so.1 => (0x002da000)
libc.so.6 => /lib/libc.so.6 (0x006e6000)
/lib/ld-linux.so.2 (0x006c6000)
libc.so.6是glibc,它实现了像printf这类标准C的函数。
ld-linux.so.2是elf可执行文件的解释器,Linux内核在执行ELF可执行文件时,其实是执行ld-linux.so.2,然后由 ld-linux.so.2去加载可执行文件及依赖的共享库。/lib/ld-linux.so.2是共享库,但它又是可执行的,运行它,屏幕会打印:
Usage: ld.so [OPTION]... EXECUTABLE-FILE [ARGS-FOR-PROGRAM...]
You have invoked `ld.so', the helper program for shared library executables.
This program usually lives in the file `/lib/ld.so', and special directives
in executable files using ELF shared libraries tell the system's program
loader to load the helper program from this file. This helper program loads
the shared libraries needed by the program executable, prepares the program
to run, and runs it. You may invoke this helper program directly from the
command line to load and run an ELF executable file; this is like executing
that file itself, but always uses this helper program from the file you
specified, instead of the helper program file specified in the executable
file you run. This is mostly of use for maintainers to test new versions
of this helper program; chances are you did not intend to run this program.
--list list all dependencies and how they are resolved
--verify verify that given object really is a dynamically linked
object we can handle
--library-path PATH use given PATH instead of content of the environment
variable LD_LIBRARY_PATH
--inhibit-rpath LIST ignore RUNPATH and RPATH information in object names
in LIST
从ld-linux.so.2 的参数来看,它可以直接执行ELF文件:
/lib/ld-linux.so.2 ./helloworld_5
屏幕打印:
Hello World!
这和直接执行./helloworld_5的效果一样。
linux-gate.so.1 则是有点奇怪了,它没有指向任何文件,而是指向一个地址0×002da000。这个文件又称为虚拟动态共享库(vdso),linux- gate.so.1文件是不存在的,Linux内核根据CPU类型,动态决定使用哪个共享库。它的功能主要是加速系统调用(syscall):
我们知道,用户空间代码(如应用程序)和内核代码是运行在不同级别的,用户空间代码的运行权限较小,内核代码的运行权限最高。由用户空间进入内核空间,需要跨越一个门(gate),这里linux-gate.so.1 的功能就是提供这样一个门(gate)。
系统调用(syscall)需要跨越用户空间与内核空间之间的门。在x86系列CPU上,Linux传统的做法是使用80中断(int 0×80)实现系统调用,不过它的执行效率较低。有的CPU提供了高效的sysenter指令,但不是所有CPU都支持,Linux通过VDSO来兼容这 两种系统调用。于是提供了两个共享库:
ls -l /lib/modules/$(uname -r)/vdso
-rwxr-xr-x 1 root root 1764 03-24 12:10 vdso32-int80.so
-rwxr-xr-x 1 root root 1784 03-24 12:10 vdso32-sysenter.so
我们看sysenter的实现方式:
objdump -S vdso32-sysenter.so
00000400 <__kernel_sigreturn>:
400: 58 pop %eax
401: b8 77 00 00 00 mov $0x77,%eax
406: cd 80 int $0x80
408: 90 nop
409: 8d 76 00 lea 0x0(%esi),%esi
0000040c <__kernel_rt_sigreturn>:
40c: b8 ad 00 00 00 mov $0xad,%eax
411: cd 80 int $0x80
413: 90 nop
00000414 <__kernel_vsyscall>:
414: 51 push %ecx
415: 52 push %edx
416: 55 push %ebp
417: 89 e5 mov %esp,%ebp
419: 0f 34 sysenter
41b: 90 nop
41c: 90 nop
41d: 90 nop
41e: 90 nop
41f: 90 nop
420: 90 nop
421: 90 nop
422: eb f3 jmp 417 <__kernel_vsyscall+0x3>
424: 5d pop %ebp
425: 5a pop %edx
426: 59 pop %ecx
427: c3 ret
里面实现了kernel_sigreturn、kernel_rt_sigreturn和kernel_vsyscall。
vdso32-int80.so也实现了这几个函数,只是方法不一样:
00000400 <__kernel_sigreturn>:
400: 58 pop %eax
401: b8 77 00 00 00 mov $0x77,%eax
406: cd 80 int $0x80
408: 90 nop
409: 8d 76 00 lea 0x0(%esi),%esi
0000040c <__kernel_rt_sigreturn>:
40c: b8 ad 00 00 00 mov $0xad,%eax
411: cd 80 int $0x80
413: 90 nop
00000414 <__kernel_vsyscall>:
414: cd 80 int $0x80
416: c3 ret
o 密秘六:调用共享库中的函数
puts是libc提供的函数,从反汇编代码中可以看到:
printf("Hello World!/n");
80483c5: c7 04 24 a4 84 04 08 movl $0x80484a4,(%esp)
80483cc: e8 1f ff ff ff call 80482f0 <puts@plt>
从前面的分析中,我们已经知道,gcc的优化把printf换成了puts。但是这里也并没有直接调用puts,而是调用的puts@plt,这是怎么回事呢?puts@plt显然是编译器加的一个中间函数,我们看一下这个函数对应的汇编代码:
080482f0 <puts@plt>:
80482f0: ff 25 1c 96 04 08 jmp *0x804961c
80482f6: 68 10 00 00 00 push $0x10
80482fb: e9 c0 ff ff ff jmp 80482c0 <_init+0x30>
现在我们用调试器来分析下:
gdb helloworld
(gdb) b main
Breakpoint 1 at 0x80483c5: file helloworld.c, line 5.
(gdb) r
Starting program: /home/work/mine/sysprog/think-in-compway/helloworld/helloworld
Breakpoint 1, main () at helloworld.c:5
5 printf("Hello World!/n");
Missing separate debuginfos, use: debuginfo-install glibc.i686
puts@plt先跳到*0×804961c,我们看看*0×804961c 里有什么?
(gdb) x 0x804961c
0x804961c <_GLOBAL_OFFSET_TABLE_+20>: 0x080482f6
*0×804961c 等于0×080482f6,这正是puts@plt中的第二行汇编代码的地址。也就是说puts@plt整个函数会顺序执行,直到跳转到0×80482c0.
再来看看0×80482c0处有什么,通过汇编可以看到:
080482c0 <__gmon_start__@plt-0x10>:
80482c0: ff 35 0c 96 04 08 pushl 0x804960c
80482c6: ff 25 10 96 04 08 jmp *0x8049610
又跳到了*0×8049610,转的弯真多,没关系,我们再看*0×8049610:
(gdb) x 0x8049610
0x8049610 <_GLOBAL_OFFSET_TABLE_+8>: 0x006da4d0
(gdb) x /wa 0x006da4d0
0x6da4d0 <_dl_runtime_resolve>: 0x8b525150
原来转来转去就是为了调用函数_dl_runtime_resolve, _dl_runtime_resolve的功能就是找到要调用函数(puts)的地址。
为什么不直接调用_dl_runtime_resolve,而要转这么多圈子呢?
先执行完puts:
(gdb) n
再回头来看看puts@plt的第一行代码:
80482f0: ff 25 1c 96 04 08 jmp *0x804961c
(gdb) x 0x804961c
0x804961c <_GLOBAL_OFFSET_TABLE_+20>: 0x745af0 <puts>
对比前面的代码:
(gdb) x 0x804961c
0x804961c <_GLOBAL_OFFSET_TABLE_+20>: 0x080482f6
也就是说第一次执行时,通过_dl_runtime_resolve解析到函数地址,并保存puts的地址到0×804962c里,以后执行时就直接调用了。
o 密秘七:函数的解析过程
LD_DEBUG是一个很有用的环境变量,通过它,我们可以对helloworld做更深入的分析,按下列方式运行helloworld:
LD_DEBUG=symbols ./helloworld
屏幕打印:
...
7264: symbol=malloc; lookup in file=./helloworld [0]
7264: symbol=malloc; lookup in file=/lib/libc.so.6 [0]
7264: symbol=calloc; lookup in file=./helloworld [0]
7264: symbol=calloc; lookup in file=/lib/libc.so.6 [0]
7264: symbol=realloc; lookup in file=./helloworld [0]
7264: symbol=realloc; lookup in file=/lib/libc.so.6 [0]
7264: symbol=free; lookup in file=./helloworld [0]
7264: symbol=free; lookup in file=/lib/libc.so.6 [0]
7264: symbol=puts; lookup in file=./helloworld [0]
7264: symbol=puts; lookup in file=/lib/libc.so.6 [0]
...
查找函数时,先在可执行文件中查找,然后依次到共享库中查找。使用共享库,可以节省空间,但调用共享库的函数需要额外的开销。我们用静态链接的方式链接helloworld:
gcc -g -static helloworld.c -o helloworld_static
这样运行速度可能会快些,但是可执行文件的大小增加了不少:
ls -l helloworld_static helloworld
屏幕打印:
-rwxrwxr-x 1 root root 6128 05-16 17:40 helloworld
-rwxrwxr-x 1 root root 562578 05-16 17:40 helloworld_static
共享库的好处其实取决于共享的次数,共享的次数越多,因共享而节省的空间越多。如果一个函数库只有一个程序使用它,把它编译成静态库将是更好的选择。
o 密秘八:托梁换柱
下面的helloworld会在屏幕上打印出什么内容?
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
printf("Hello World!/n");
return 0;
}
肯定是“Hello World!”,不是吗?下面我们来个托梁换柱:
preload.c
#include <stdio.h>
#include <ctype.h>
int puts(const char *s)
{
const char* p = s;
while(*p != '/0')
{
putc(toupper(*p), stdout);
p++;
}
return 0;
}
编译:
gcc -g -shared preload.c -o libpreload.so
按下列方式运行helloworld:
LD_PRELOAD=./libpreload.so ./helloworld
屏幕打印:
HELLO WORLD!
设置环境变量LD_PRELOAD之后,打印的内容变成大写了!原来,LD_PRELOAD指定的共享库被预先加载,如果出现重名的函数,预先加载的函数将会被调用。通过这种方法,我们可以在不需要修改源代码(有时候可能没有源代码)的情况下,来改变一个程序的行为。
o 密秘九:内存模型
对helloworld做点修改:
int main(int argc, char* argv[], char* env[])
{
printf("Hello World!/n");
getchar();
return 0;
}
这里调用了getchar ,让程序不会直接退出。利用程序等待输入的时间,我们看下它的内存布局(假设3458 是helloworld的进程ID):
cat /proc/3458/maps
0041f000-00420000 r-xp 0041f000 00:00 0 [vdso]
006c6000-006e2000 r-xp 00000000 08:01 765655 /lib/ld-2.8.so
006e2000-006e3000 r--p 0001c000 08:01 765655 /lib/ld-2.8.so
006e3000-006e4000 rw-p 0001d000 08:01 765655 /lib/ld-2.8.so
006e6000-00849000 r-xp 00000000 08:01 765657 /lib/libc-2.8.so
00849000-0084b000 r--p 00163000 08:01 765657 /lib/libc-2.8.so
0084b000-0084c000 rw-p 00165000 08:01 765657 /lib/libc-2.8.so
0084c000-0084f000 rw-p 0084c000 00:00 0
08048000-08049000 r-xp 00000000 08:05 2129380 /home/work/mine/sysprog/think-in-compway/helloworld/helloworld_9
08049000-0804a000 rw-p 00000000 08:05 2129380 /home/work/mine/sysprog/think-in-compway/helloworld/helloworld_9
b7f87000-b7f89000 rw-p b7f87000 00:00 0
b7fa6000-b7fa8000 rw-p b7fa6000 00:00 0
bfc93000-bfca8000 rw-p bffeb000 00:00 0 [stack]
从这里我们可以看出:
这里最低有效地址是0×0041f000,其下为保留区域。
可执行文件和共享库映射区,每个文件通常占1-3个区域,分别存放全局变量,常量和代码,它们有不同的属性。
这没有动态分配内存,所以没有堆内存。
栈是从上向下增长的, 这里栈底为0xbfca8000,我做了多次测试,发现栈底并不总是在这个位置。不过对于32位系统,我们可以确信的是栈底总是小于0xc0000000的。
o 密秘十:main函数不是第一个执行的函数
教科书告诉我们main函数是C语言程序的入口函数,实际上main并不是第一个被执行的函数。
我们对程序做点修改:
#include <stdio.h>
__attribute ((constructor)) void hello_init(void)
{
printf("%s/n", __func__);
return;
}
__attribute ((destructor)) void hello_fini(void)
{
printf("%s/n", __func__);
return;
}
int main(int argc, char* argv[], char* env[])
{
printf("Hello World!/n");
return 0;
}
编译并运行:
./helloworld_10
屏幕打印:
hello_init
Hello World!
hello_fini
在main函数之前执行了hello_init ,在main函数之后执行了hello_fini。在gdb里执行./helloworld_10,并在hello_init 和hello_fini设置断点,看是谁调用了它们:
(gdb) bt
#0 hello_init () at helloworld.10.c:5
#1 0x0804849d in __do_global_ctors_aux ()
#2 0x080482bc in _init ()
#3 0x08048439 in __libc_csu_init ()
#4 0x006fc571 in __libc_start_main () from /lib/libc.so.6
#5 0x08048321 in _start ()
(gdb) bt
#0 hello_fini () at helloworld.10.c:12
#1 0x0804836f in __do_global_dtors_aux ()
#2 0x080484c4 in _fini ()
#3 0x006d4f7b in _dl_fini () from /lib/ld-linux.so.2
#4 0x00713b39 in exit () from /lib/libc.so.6
#5 0x006fc5de in __libc_start_main () from /lib/libc.so.6
#6 0x08048321 in _start ()
其实_start才是程序的入口,它先构造进程中的全局对象,执行一些初始化函数,然后调用main函数,最后析构全局对象,执行一些退出函数。