Hello World 背后的真实故事

* 原作者:Antônio Augusto M. Fröhlich
* 原文链接http://www.lisha.ufsc.br/~guto/teaching/os/exercise/hello.html

* 译者:杨文博 <http://blog.solrex.org>
* 译文链接http://share.solrex.org/os/hello_cn.html
* 最后更新时间: 2008 年 2 月 28 日

我们计算机科学专业的大多数学生至少都接触过一回著名的 "Hello World" 程序。相比一个典型的应用程序——几乎总是有一个带网络连接的图形用户界面,"Hello World" 程序看起来只是一段很简单无趣的代码。不过,许多计算机科学专业的学生其实并不了解它背后的真实故事。这个练习的目的就是利用对 "Hello World" 的生存周期的分析来帮助你揭开它神秘的面纱。

源代码

让我们先看一下 Hello World 的源代码:

1. #include <stdio.h>
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 输出 "Hello 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 stripped

给出的信息告诉我们 hello.o 是个可重定位的目标文件(relocatable),为 IA-32(Intel Architecture 32) 平台编译(在这个练习中我使用了一台标准 PC),保存为 ELF(Executable 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 的地址被设置为 00000000,符号 puts 未定义。此外,重定位表(relocation table)告诉我们怎么样去在 .text 段中去重定位对其它段内容的引用。第一个可重定位的符号对应于 .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 行)。因为我们使用 printf 来打印一个字符串,并且我们要求我们优秀的编译器对生成的代码进行优化(-Os),编译器用(应该更快的) puts 调用来取代 printf 调用。不幸的是,我们后面将会看到我们的 libc 库的实现会使这种优化变得没什么用。

那么这段汇编代码会生成什么代码呢?没什么意外之处:使用标志字符串地址的标号 .LCO 作为参数的一个对 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 系统调用来复制自己,然后刚生成的子进程发起 execve 系统调用来装载和执行要运行的程序组成的。无论何时你在 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 0x80480e0
There are 3 program headers, starting at offset 52

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x55dac 0x55dac R E 0x1000
LOAD 0x055dc0 0x0809edc0 0x0809edc0 0x01df4 0x03240 RW 0x1000
NOTE 0x000094 0x08048094 0x08048094 0x00020 0x00020 R 0x4

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 的整体结构。第一个程序头对应于进程的代码段,它将从文件偏移 0x000000 处被装载到映射到进程地址空间的 0x08048000 地址的物理内存中(虚拟内存机制)。代码段共有 0x55dac 字节大小而且必须按页对齐(0x1000, page-aligned)。这个段将包含我们前面讨论过的 ELF 文件中的 .text 段和 .rodata 段的内容,再加上在连接过程中生成的附加的段。正如我们预期,它被标志为:只读(R)和可执行(X),不过禁止写(W)。

第二个程序头对应于进程的数据段。装载这个段到内存的方式和上面所提到的一样。不过,需要注意的是,这个段占用的文件大小是 0x01df4 字节,而在内存中它占用了 0x03240 字节。这个差异主要归功于 .bss 段,它在内存中只需要被赋 0,所以不用在文件中出现(译者注:文件中只需要知道它的起始地址和大小即可)。进程的数据段仍然需要按页对齐(0x1000, 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 + heap),第四个区域在 ELF 文件中没有对应的内容,是程序栈。更多和正在运行的 hello 进程有关的信息可以用 GNU 程序:time, ps 和 /proc/pid/stat 得到。

程序终止

当 "Hello World" 程序运行到 main 函数中的 return 语句时,它向我们在段连接部分讨论过的外壳函数传入了一个参数。这些函数中的某一个发起 exit 系统调用。这个 exit 系统调用将返回值转交给被 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, <unfinished ...>
[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 语言的模运算符 ("%") 在某个平台上可能无法映射到一条汇编指令。可能用一个函数调用实现比让编译器为其生成内嵌代码更受欢迎(特别是对一些内存受限的计算机来说,比如微控制器)。很多其它的基本运算,包括除法、乘法、字符串处理(比如 memory copy)一般都会在这类函数库中实现。

《Hello World 背后的真实故事》上有3条评论

  1. 兄弟(暂且这样称呼 )真的很厉害,能在南京大学上学也不简单.真是羡慕你啊..... :cry:

  2. 呵呵,今天无意间碰到了你的blog,进来看了一下,才明白你是做什么的!第一次看到有人详细地分解hello world程序!难得啊,印象中浮躁的中国人向来不会做这些太底层的过程分解和研究的!但是你应该是个例外了!看起来你对嵌入式系统,OS方面很有研究啊!以后我会常来这里看看你的blog,向你学习新知识!

回复 PigOnInternet 取消回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注