Linux下Hello World是如何输出的
2023-05-23 12:23:38

引子

最近开了一门名为“计算机系统”的课,以卡内基梅隆大学的《深入理解计算机系统》为教材,讲解偏向底层的一些知识:浮点数具体的表示方法、预处理编译汇编链接具体发生什么……这门课的大作业要求我们跟随“预处理-编译-汇编-链接-进程管理”的路线,写一下“Linux下Hello World是如何输出的”。

然而在写大作业的时候,我还没有复习,上课也没有认真听,东拼西凑写出来了一篇文章。事到如今,差不多把教材里的知识点都掌握了,那就认真写一遍吧。

上课挺没意思的。说实话。

0. 源程序

源程序

1
2
3
4
5
6
#include <stdio.h>

int main(int argc, char **argv) {
printf("Hello world!\n");
return 0;
}

1. 预处理

概念

预处理阶段,编译器主要对宏定义进行一些处理。比如,引入一些头文件、定义一些宏变量等。

后缀为.c的源程序文件,通过预处理,可以生成后缀名为.i的预处理文件。

命令

1
gcc -E hello.c -o hello.i

解释

.i文件部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# 1 "/usr/include/bits/types/__mbstate_t.h" 1 3 4
# 13 "/usr/include/bits/types/__mbstate_t.h" 3 4
typedef struct
{
int __count;
union
{
unsigned int __wch;
char __wchb[4];
} __value;
} __mbstate_t;
# 6 "/usr/include/bits/types/__fpos_t.h" 2 3 4

...

extern size_t fwrite (const void *__restrict __ptr, size_t __size,
size_t __n, FILE *__restrict __s);
# 702 "/usr/include/stdio.h" 3 4
extern size_t fread_unlocked (void *__restrict __ptr, size_t __size,
size_t __n, FILE *__restrict __stream) ;
extern size_t fwrite_unlocked (const void *__restrict __ptr, size_t __size,
size_t __n, FILE *__restrict __stream);

extern int fseek (FILE *__stream, long int __off, int __whence);

extern long int ftell (FILE *__stream) ;

extern void rewind (FILE *__stream);
# 736 "/usr/include/stdio.h" 3 4
extern int fseeko (FILE *__stream, __off_t __off, int __whence);




extern __off_t ftello (FILE *__stream) ;
# 760 "/usr/include/stdio.h" 3 4
extern int fgetpos (FILE *__restrict __stream, fpos_t *__restrict __pos);




extern int fsetpos (FILE *__stream, const fpos_t *__pos);
# 786 "/usr/include/stdio.h" 3 4
extern void clearerr (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));

extern int feof (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;

extern int ferror (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;



extern void clearerr_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
extern int feof_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern int ferror_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;







extern void perror (const char *__s);




extern int fileno (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;




extern int fileno_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
# 823 "/usr/include/stdio.h" 3 4
extern int pclose (FILE *__stream);





extern FILE *popen (const char *__command, const char *__modes)
__attribute__ ((__malloc__)) __attribute__ ((__malloc__ (pclose, 1))) ;






extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__))
__attribute__ ((__access__ (__write_only__, 1)));
# 867 "/usr/include/stdio.h" 3 4
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));



extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;


extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 885 "/usr/include/stdio.h" 3 4
extern int __uflow (FILE *);
extern int __overflow (FILE *, int);
# 909 "/usr/include/stdio.h" 3 4

# 2 "hello.c" 2


# 3 "hello.c"
int main(int argc, char **argv) {
printf("Hello world!\n");
return 0;
}

如您所见,预处理后的文件引入了许多结构体、许多函数。我们编写的主函数,则位于这个文件的末尾。

此外,有很多,类似于这样的语句:

1
# 885 "/usr/include/stdio.h" 3 4

这些语句标记了源文件和代码行来自于哪个头文件、哪一行。您可以参考gcc的官方文档获取更多信息。

2. 编译

概念

将程序语言转换为汇编语言。

通过编译,.i文件可以生成.s文件。

命令

1
2
gcc -S hello.i -o hello.s
gcc -S hello.c

这两种生成hello.s的方法是等价的,最后的结果没有任何差异。您可以通过diff命令来检查。

解释

hello.s文件输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
	.file	"hello.c"
.text
.section .rodata
.LC0:
.string "Hello world!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 13.1.1 20230429"
.section .note.GNU-stack,"",@progbits

该文件涉及到的知识点很多。

  • .file,表明了该.s文件的源程序文件名。
  • .text,表明代码节的开启,每个节的作用,在2.5节将进行解释。
  • .section .rodata,代表定义一个只读的数据节。
  • .LC0,也就是local constant 0,局部常量。
  • .string,声明了一个字符串。
  • .globl main,声明了main是一个全局符号。
  • .type A, B,声明了AB类型。
  • .LFB0 .LFE0,全称分别为Local Function BeginsLocal Function Ends,代表局部函数的开始和结束。
  • .size,表明main符号的尺寸。
  • .ident,表明编译器版本。
  • .section .note.GNU-stack,"",@progbits,意思是这个segment会被标记为 “非可执行栈”,引用自简书
  • cfi,即Call Frame Information,以之开头的一些指令和栈相关。

2.5 汇编与内存相关知识

我们现在所使用的是AT&T汇编指令系统。

该指令系统的部分特点是:

  • 立即数,也就是汇编指令中的数字,前面需要加上美元符号。比如$1$5等。

  • 寄存器,需要在前面加上百分号。比如%rbp,%rax等。如果寄存器加上括号,比如(%rax)代表间接寻址,也就是先从rax里取一个地址,在去内存中根据该地址寻找数据。

  • 指令后需要加一个标记,比如pushqmovq最后的q,和movl最后的l。标记有四种b,w,l,q,分别代表操作的数据长度为1,2,4,8字节。

  • 向函数中传递参数,前六个参数分别存入rdirsirdxrcxr8r9寄存器中,其他参数入栈。返回值存入rax寄存器中。

当程序运行时,内存布局如下:

  • 栈:存储局部变量、函数参数等。
  • 堆:存储动态分配的内容。
  • 数据段:存储全局变量、static变量、字符串。
  • 代码段:存储只读、可执行的机器指令。

当程序编译时,各内容布局如下:

  • .text节:存储代码。
  • .rodata节:存储只读数据(printf的格式串等)。
  • .data节:存储数据和已经初始化的全局与静态变量。
  • .bss节:存储为初始化的全局变量。
  • .symtab节:存储符号表。

3. 汇编

概念

将汇编语言转换为机器语言。

通过汇编,.s文件会生成.o文件。

命令

1
gcc -c hello.s -o hello.o

解释

最终生成一个二进制文件,而这个文件直接通过cat输出,没办法被阅读。需要反汇编,命令如下:

1
objdump -D hello.o

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

hello.o: file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 48 89 75 f0 mov %rsi,-0x10(%rbp)
f: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 16 <main+0x16>
16: 48 89 c7 mov %rax,%rdi
19: e8 00 00 00 00 call 1e <main+0x1e>
1e: b8 00 00 00 00 mov $0x0,%eax
23: c9 leave
24: c3 ret

Disassembly of section .rodata:

0000000000000000 <.rodata>:
0: 48 rex.W
1: 65 6c gs insb (%dx),%es:(%rdi)
3: 6c insb (%dx),%es:(%rdi)
4: 6f outsl %ds:(%rsi),(%dx)
5: 20 77 6f and %dh,0x6f(%rdi)
8: 72 6c jb 76 <main+0x76>
a: 64 21 00 and %eax,%fs:(%rax)

Disassembly of section .comment:

0000000000000000 <.comment>:
0: 00 47 43 add %al,0x43(%rdi)
3: 43 3a 20 rex.XB cmp (%r8),%spl
6: 28 47 4e sub %al,0x4e(%rdi)
9: 55 push %rbp
a: 29 20 sub %esp,(%rax)
c: 31 33 xor %esi,(%rbx)
e: 2e 31 2e cs xor %ebp,(%rsi)
11: 31 20 xor %esp,(%rax)
13: 32 30 xor (%rax),%dh
15: 32 33 xor (%rbx),%dh
17: 30 34 32 xor %dh,(%rdx,%rsi,1)
1a: 39 00 cmp %eax,(%rax)

Disassembly of section .note.gnu.property:

0000000000000000 <.note.gnu.property>:
0: 04 00 add $0x0,%al
2: 00 00 add %al,(%rax)
4: 20 00 and %al,(%rax)
6: 00 00 add %al,(%rax)
8: 05 00 00 00 47 add $0x47000000,%eax
d: 4e 55 rex.WRX push %rbp
f: 00 02 add %al,(%rdx)
11: 00 01 add %al,(%rcx)
13: c0 04 00 00 rolb $0x0,(%rax,%rax,1)
...
1f: 00 01 add %al,(%rcx)
21: 00 01 add %al,(%rcx)
23: c0 04 00 00 rolb $0x0,(%rax,%rax,1)
27: 00 01 add %al,(%rcx)
29: 00 00 add %al,(%rax)
2b: 00 00 add %al,(%rax)
2d: 00 00 add %al,(%rax)
...

Disassembly of section .eh_frame:

0000000000000000 <.eh_frame>:
0: 14 00 adc $0x0,%al
2: 00 00 add %al,(%rax)
4: 00 00 add %al,(%rax)
6: 00 00 add %al,(%rax)
8: 01 7a 52 add %edi,0x52(%rdx)
b: 00 01 add %al,(%rcx)
d: 78 10 js 1f <.eh_frame+0x1f>
f: 01 1b add %ebx,(%rbx)
11: 0c 07 or $0x7,%al
13: 08 90 01 00 00 1c or %dl,0x1c000001(%rax)
19: 00 00 add %al,(%rax)
1b: 00 1c 00 add %bl,(%rax,%rax,1)
1e: 00 00 add %al,(%rax)
20: 00 00 add %al,(%rax)
22: 00 00 add %al,(%rax)
24: 25 00 00 00 00 and $0x0,%eax
29: 41 0e rex.B (bad)
2b: 10 86 02 43 0d 06 adc %al,0x60d4302(%rsi)
31: 60 (bad)
32: 0c 07 or $0x7,%al
34: 08 00 or %al,(%rax)
...

第一行说明了该文件为ELF文件,ELF即Executable and Linkable Format,可执行可链接文件格式。和上一步输出得到的文件相比,本文件有如下几个特点:

  1. call指令后面是具体的地址,而非抽象的文件名。
  2. 开始使用十六进制操作数。
  3. 出现了很多节,起始地址都为0。

4. 链接

概念

一个程序可能有很多个模块,比如假设一个程序由三个源文件组成,其中第一个文件中的很多变量可能在第二个文件中定义。和#include引入头文件不同,链接一般是将多个.o文件拼凑起一个完整的程序。

可以在编译时链接,也可以在运行时通过dlopen()等函数动态链接一些其他文件,在Windows下,动态链接的是动态链接表.dll文件,在Linux下,动态链接的是共享库文件.so

链接分两个步骤:符号解析和重定位,前者解释所有的符号,将每一个符号与一个解释关联起来,所谓符号,要么是函数名,要么是变量;后者则给这些符号一个安身之所,定位到一个绝对内存位置。

命令

通过链接器ld链接即可。本例可以通过:

1
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/crt1.o /usr/lib/crti.o hello.o /usr/lib/libc.so /usr/lib/crtn.o

进行编译时链接。

解释

通过反编译,链接后的文件部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
0000000000400458 <.gnu.version_r>:
400458: 01 00 add %eax,(%rax)
40045a: 02 00 add (%rax),%al
40045c: 18 00 sbb %al,(%rax)
40045e: 00 00 add %al,(%rax)
400460: 10 00 adc %al,(%rax)
400462: 00 00 add %al,(%rax)
400464: 00 00 add %al,(%rax)
400466: 00 00 add %al,(%rax)
400468: 75 1a jne 400484 <_init-0xb7c>
40046a: 69 09 00 00 03 00 imul $0x30000,(%rcx),%ecx
400470: 22 00 and (%rax),%al
400472: 00 00 add %al,(%rax)
400474: 10 00 adc %al,(%rax)
400476: 00 00 add %al,(%rax)
400478: b4 91 mov $0x91,%ah
40047a: 96 xchg %eax,%esi
40047b: 06 (bad)
40047c: 00 00 add %al,(%rax)
40047e: 02 00 add (%rax),%al
400480: 2e 00 00 cs add %al,(%rax)
400483: 00 00 add %al,(%rax)
400485: 00 00 add %al,(%rax)
...

Disassembly of section .rela.dyn:

0000000000400488 <.rela.dyn>:
400488: d8 3f fdivrs (%rdi)
40048a: 40 00 00 rex add %al,(%rax)
40048d: 00 00 add %al,(%rax)
40048f: 00 06 add %al,(%rsi)
400491: 00 00 add %al,(%rax)
400493: 00 01 add %al,(%rcx)
...
40049d: 00 00 add %al,(%rax)
40049f: 00 e0 add %ah,%al
4004a1: 3f (bad)
4004a2: 40 00 00 rex add %al,(%rax)
4004a5: 00 00 add %al,(%rax)
4004a7: 00 06 add %al,(%rsi)
4004a9: 00 00 add %al,(%rax)
4004ab: 00 03 add %al,(%rbx)

最大的变化是,文件变长了,除此之外也有一个显著的新特点:每一个节的开始位置不再是0,而是一个具体的地址。

5. 进程管理、信号与内存管理

进程管理与信号

当我们在Shell中运行程序时,会经历几个步骤:

  1. 读取命令。
  2. 判断命令的准确性。
  3. 使用fork()创建子进程,在子进程中调用execve()启动程序。
  4. Shell等待子进程完成。

其中,用户可以给进程发出一些“信号”,比如按下C-z键,就会向进程输出SIGSTOP信号,按下C-c键,就会向进程输出SIGINT信号。当接收到这些信号时,程序可能有如下几种反应:

  • 忽略。
  • 调用相应的信号处理函数处理信号。
  • 执行默认的信号处理方式。

内存管理

一定程度上,进程之间彼此独立。在创建子进程后,在父进程中修改全局变量,子进程中的对应变量不会被更改。二者的内存区域指向不同的物理空间。

若想要在进程中通信,可以通过管道进行数据传输。

6. 结论

源程序首先经过预处理,处理掉一些宏定义,经过编译,将之转换为汇编语言文件,经过汇编,将之转换为二进制文件,最后经过链接,定义符号,将程序各段重定位到绝对内存地址。

之后,通过Shell,读取命令,创建子进程,输出。