Hello World 背后的真实故事
(至少是大部分故事)
* 原作者:Ant?nio Augusto M. Fr?hlich
* 原文链接: http://www.lisha.ufsc.br/~guto/teaching/os/exercise/hello.html
* 译者:杨文博
* 译文链接: http://share.solrex.cn/os/hello_cn.html
* 最后更新时间: 2008 年 2 月 28 日
我们计算机科学专业的大多数学生至少都接触过一回著名的 “Hello World” 程序。相比
一个典型的应用程序——几乎总是有一个带网络连接的图形用户界面,”Hello World”
程序看起来只是一段很简单无趣的代码。不过,许多计算机科学专业的学生其实并不了解
它背后的真实故事。这个练习的目的就是利用对 “Hello World” 的生存周期的分析来帮
助你揭开它神秘的面纱。
源代码
让我们先看一下 Hello World 的源代码:
1. #include
2. int main(void)
3. {
4. printf(”Hello World!/n”);
5. return 0;
6.
7. }
第 1 行指示编译器去包含调用 C 语言库(libc)函数 printf 所需要的头文件声明。
第 3 行声明了 main 函数,看起来好像是我们程序的入口点(在后面我们将看到,其实它
不是)。它被声明为一个不带参数(我们这里不准备理会命令行参数)且会返回一个整型
值给它的父进程(在我们的例子里是 shell)的函数。顺便说一下,shell 在调用程序时
对其返回值有个约定:子进程在结束时必须返回一个 8 比特数来代表它的状态:0 代表正
常结束,0~128 中间的数代表进程检测到的异常终止,大于 128 的数值代表由信号引起的
终止。
从第 4 行到第 8 行构成了 main 函数的实现,即调用 C 语言库函数 printf 输出 “He
llo World!/n” 字符串,在结束时返回 0 给它的父进程。
简单,非常简单!
编译
现在让我们看看 “Hello World” 的编译过程。在下面的讨论中,我们将使用非常流行的
GNU 编译器(gcc)和它的二进制辅助工具(binutils)。我们可以使用下面命令来编译
我们的程序:
# gcc -Os -c hello.c
这样就生成了目标文件 hello.o,来看一下它的属性:
# file hello.o
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripp
ed
给出的信息告诉我们 hello.o 是个可重定位的目标文件(relocatable),为 IA-32(Int
el Architecture 32) 平台编译(在这个练习中我使用了一台标准 PC),保存为 ELF(Ex
ecutable and Linking Format) 文件格式,并且包含着符号表(not stripped)。
顺便:
# objdump -hrt hello.o
hello.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000011 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000048 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000048 2**2
ALLOC
3 .rodata.str1.1 0000000d 00000000 00000000 00000048 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000033 00000000 00000000 00000055 2**0
CONTENTS, READONLY
SYMBOL TABLE:
00000000 l df *ABS* 00000000 hello.c
00000000 l d .text 00000000
00000000 l d .data 00000000
00000000 l d .bss 00000000
00000000 l d .rodata.str1.1 00000000
00000000 l d .comment 00000000
00000000 g F .text 00000011 main
00000000 *UND* 00000000 puts
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
00000004 R_386_32 .rodata.str1.1
00000009 R_386_PC32 puts
这告诉我们 hello.o 有 5 个段:
(译者注:在下面的解释中读者要分清什么是 ELF 文件中的段(section)和进程中的段
(segment)。比如 .text 是 ELF 文件中的段名,当程序被加载到内存中之后,.text 段
构成了程序的可执行代码段。其实有时候在中文环境下也称 .text 段为代码段,要根据上
下文分清它代表的意思。)
1. .text: 这是 “Hello World” 编译生成的可执行代码,也就是说这个程序对应的 IA
-32 指令序列。.text 段将被加载程序用来初始化进程的代码段。
2. .data:”Hello World” 的程序里既没有初始化的全局变量也没有初始化的静态局部
变量,所以这个段是空的。否则,这个段应该包含变量的初始值,运行前被装载到进程的
数据段。
3. .bss: “Hello World” 也没有任何未初始化的全局或者局部变量,所以这个段也是
空的。否则,这个段指示的是,在进程的数据段中除了上文的 .data 段内容,还有多少字
节应该被分配并赋 0。
4. .rodata: 这个段包含着被标记为只读 “Hello World!/n” 字符串。很多操作系统并
不支持进程(运行的程序)有只读数据段,所以 .rodata 段的内容既可以被装载到进程的
代码段(因为它是只读的),也可以被装载到进程的数据段(因为它是数据)。因为编译
器并不知道你的操作系统所使用的策略,所以它额外生成了一个 ELF 文件段。
5. .comment:这个段包含着 33 字节的注释。因为我们在代码中没有写任何注释,所以我
们无法追溯它的来源。不过我们将很快在下面看到它是怎么来的。
它也给我们展示了一个符号表(symbol table),其中符号 main 的地址被设置为 00000
000,符号 puts 未定义。此外,重定位表(relocation table)告诉我们怎么样去在 .t
ext 段中去重定位对其它段内容的引用。第一个可重定位的符号对应于 .rodata 中的 “
Hello World!/n” 字符串,第二个可重定位符号 puts,代表了使用 printf 所产生的对
一个 libc 库函数的调用。为了更好的理解 hello.o 的内容,让我们来看看它的汇编代码
:
1. # gcc -Os -S hello.c -o -
2. .file “hello.c”
3. .section .rodata.str1.1,”aMS”,@progbits,1
4. .LC0:
5. .string “Hello World!”
6. .text
7. .align 2
8. .globl main
9. .type main,@function
10. main:
11. pushl %ebp
12. movl %esp, %ebp
13. pushl $.LC0
14. call puts
15. xorl %eax, %eax
16. leave
17. ret
18. .Lfe1:
19. .size n,.Lfe1-n
20. .ident “GCC: (GNU) 3.2 20020903 (Red Hat Linux 8.0 3.2-7)”
从汇编代码中我们可以清楚的看到 ELF 段标记是怎么来的。比如,.text 段是 32 位对齐
的(第 7 行)。它也揭示了 .comment 段是从哪儿来的(第 20 行)。因为我们使用 pr
intf 来打印一个字符串,并且我们要求我们优秀的编译器对生成的代码进行优化(-Os)
,编译器用(应该更快的) puts 调用来取代 printf 调用。不幸的是,我们后面将会看
到我们的 libc 库的实现会使这种优化变得没什么用。
那么这段汇编代码会生成什么代码呢?没什么意外之处:使用标志字符串地址的标号 .LC
O 作为参数的一个对 puts 库函数的简单调用。
连接
下面让我们看一下 hello.o 转化为可执行文件的过程。可能会有人觉得用下面的命令就可
以了:
# ld -o hello hello.o -lc
ld: warning: cannot find entry symbol _start; defaulting to 08048184
不过,那个警告是什么意思?尝试运行一下!
是的,hello 程序不工作。让我们回到那个警告:它告诉我们连接器(ld)不能找到我们
程序的入口点 _start。不过 main 难道不是入口点吗?简短的来说,从程序员的角度来看
main 可能是一个 C 程序的入口点。但实际上,在调用 main 之前,一个进程已经执行了
一大堆代码来“为可执行程序清理房间”。我们通常情况下从编译器或者操作系统提供者
那里得到这些外壳程序(surrounding code,译者注:比如 CRT)。
下面让我们试试这个命令:
# ld -static -o hello -L`gcc -print-file-name=` /usr/lib/crt1.o /usr/lib/crti.
o hello.o /usr/lib/crtn.o -lc -lgcc
现在我们可以得到一个真正的可执行文件了。使用静态连接(static linking)有两个原
因:一,在这里我不想深入去讨论动态连接库(dynamic libraries)是怎么工作的;二,
我想让你看看在我们库(libc 和 libgcc)的实现中,有多少不必要的代码将被添加到 “
Hello World” 程序中。试一下这个命令:
# find hello.c hello.o hello -printf “%f/t%s/n”
hello.c 84
hello.o 788
hello 445506
你也可以尝试 “nm hello” 和 “objdump -d hello” 命令来得到什么东西被连接到了
可执行文件中。
想了解动态连接的更多内容,请参考 Program Library HOWTO
装载和运行
在一个遵循 POSIX(Portable Operating System Interface) 标准的操作系统(OS)上,
装载一个程序是由父进程发起 fork 系统调用来复制自己,然后刚生成的子进程发起 exe
cve 系统调用来装载和执行要运行的程序组成的。无论何时你在 shell 中敲入一个外部命
令,这个过程都会被实施。你可以使用 truss 或者 trace 命令来验证一下:
# strace -i hello > /dev/null
[????????] execve(”./hello”, [”hello”], [/* 46 vars */]) = 0
…
[08053d44] write(1, “Hello World!/n”, 13) = 13
…
[0804e7ad] _exit(0) = ?
除了 execve 系统调用,上面的输出展示了打印函数 puts 中的 write 系统调用,和用
main 的返回值(0)作为参数的 exit 系统调用。
为了解 execve 实施的装载过程背后的细节,让我们看一下我们的 ELF 可执行文件:
# readelf -l hello
Elf file type is EXEC (Executable file)
Entry point 0×80480e0
There are 3 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0×000000 0×08048000 0×08048000 0×55dac 0×55dac R E 0×1000
LOAD 0×055dc0 0×0809edc0 0×0809edc0 0×01df4 0×03240 RW 0×1000
NOTE 0×000094 0×08048094 0×08048094 0×00020 0×00020 R 0×4
Section to Segment mapping:
Segment Sections…
00 .init .text .fini .rodata __libc_atexit __libc_subfreeres .note.ABI-tag
01 .data .eh_frame .got .bss
02 .note.ABI-tag
输出显示了 hello 的整体结构。第一个程序头对应于进程的代码段,它将从文件偏移 0×
000000 处被装载到映射到进程地址空间的 0×08048000 地址的物理内存中(虚拟内存机
制)。代码段共有 0×55dac 字节大小而且必须按页对齐(0×1000, page-aligned)。这
个段将包含我们前面讨论过的 ELF 文件中的 .text 段和 .rodata 段的内容,再加上在连
接过程中生成的附加的段。正如我们预期,它被标志为:只读(R)和可执行(X),不过禁止
写(W)。
第二个程序头对应于进程的数据段。装载这个段到内存的方式和上面所提到的一样。不过
,需要注意的是,这个段占用的文件大小是 0×01df4 字节,而在内存中它占用了 0×03
240 字节。这个差异主要归功于 .bss 段,它在内存中只需要被赋 0,所以不用在文件中
出现(译者注:文件中只需要知道它的起始地址和大小即可)。进程的数据段仍然需要按
页对齐(0×1000, page-aligned)并且将包含 .data 和 .bss 段。它将被标识为可读写
(RW)。第三个程序头是连接阶段产生的,和这里的讨论没有什么关系。
如果你有一个 proc 文件系统,当你得到 “Hello World” 时停止进程(提示: gdb,译
者注:用 gdb 设置断点),你可以用下面的命令检查一下是不是如上所说:
# cat /proc/`ps -C hello -o pid=`/maps
08048000-0809e000 r-xp 00000000 03:06 479202 …/hello
0809e000-080a1000 rw-p 00055000 03:06 479202 …/hello
080a1000-080a3000 rwxp 00000000 00:00 0
bffff000-c0000000 rwxp 00000000 00:00 0
第一个映射的区域是这个进程的代码段,第二个和第三个构成了数据段(data + bss + he
ap),第四个区域在 ELF 文件中没有对应的内容,是程序栈。更多和正在运行的 hello 进
程有关的信息可以用 GNU 程序:time, ps 和 /proc/pid/stat 得到。
程序终止
当 “Hello World” 程序运行到 main 函数中的 return 语句时,它向我们在段连接部分
讨论过的外壳函数传入了一个参数。这些函数中的某一个发起 exit 系统调用。这个 exi
t 系统调用将返回值转交给被 wait 系统调用阻塞的父进程。此外,它还要对终止的进程
进行清理,将其占用的资源还给操作系统。用下面命令我们可以追踪到部分过程:
# strace -e trace=process -f sh -c “hello; echo $?” > /dev/null
execve(”/bin/sh”, [”sh”, “-c”, “hello; echo 0″], [/* 46 vars */]) = 0
fork() = 8321
[pid 8320] wait4(-1,
[pid 8321] execve(”./hello”, [”hello”], [/* 46 vars */]) = 0
[pid 8321] _exit(0) = ?
<… wait4 resumed> [WIFEXITED(s) && WEXITSTATUS(s) == 0], 0, NULL) = 8321
— SIGCHLD (Child exited) —
wait4(-1, 0xbffff06c, WNOHANG, NULL) = -1 ECHILD (No child processes)
_exit(0)
结束
这个练习的目的是让计算机专业的新生注意这样一个事实:一个 Java Applet 的运行并不
是像魔法一样(无中生有的),即使在最简单的程序背后也有很多系统软件的支撑。如果
您觉得这篇文章有用并且想提供建议来改进它,请发电子邮件给我。
常见问题
这一节是为了回答学生们的常见问题。
* 什么是 “libgcc”? 为什么它在连接的时候被包含进来?
编译器内部的函数库,比如 libgcc,是用来实现目标平台没有直接实现的语言元素。举个
例子,C 语言的模运算符 (”%”) 在某个平台上可能无法映射到一条汇编指令。可能用一
个函数调用实现比让编译器为其生成内嵌代码更受欢迎(特别是对一些内存受限的计算机
来说,比如微控制器)。很多其它的基本运算,包括除法、乘法、字符串处理(比如 mem
ory copy)一般都会在这类函数库中实现。