第1章 概述
1.1 Hello简介
首先,通过编写高级语言如c语言等得到hello.c源程序文本文件,然后对其分别进行预处理、编译、形成hello.i和hello.s文件。接下来经过汇编程序处理,将其转化为hello.o可重定位目标程序二进制文件。最后将程序与函数库中需要使用的二进制文件进行链接,形成可执行目标程序ELF二进制文件。
P2P:在shell中输入./hello,shell调用fork函数生成一个子进程,为其分配相应的内存资源,包括cpu的使用权限和虚拟内存等。然后用execve在子进程的上下文中加载并运行可执行文件hello,这就是hello的“From Program to Process”过程。
020:子进程调用execve,映射虚拟内存并载入物理内存,进入程序入口处开始执行,同时,CPU为运行的hello分配时间片并执行逻辑控制流。最后当hello进程终止时,父进程shell将回收hello,内核删除相关数据结构,从系统中删掉其所有痕迹。这个过程叫做020。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:x64 cpu 2.90GHz 16.0GB RAM
软件环境:Windows 11 家庭中文版,Oracle VM VirtualBox,Ubuntu-20.04.4
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 作用 |
hello.i | hello.c预处理产物 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 经链接之后得到的可执行目标文件 |
elf.txt | hello.o的ELF格式 |
disass_hello.s | hello.o的反汇编代码 |
hello1.elf | hello的ELF格式 |
hello1_objdump.s | hello的反汇编代码 |
1.4 本章小结
本章通过对hello程序的分段分析,掌握了高级语言编写的源程序如何一步步地成为可以被加载到内存的可由系统执行的文件的。分为4个阶段,预处理(cpp),编译(ccl)、汇编(as)、链接(ld)。了解了P2P以及020的过程。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念: 预处理器ccp根据以字符#开头的命令,修改原始的c程序。比如hello.c第一行的头文件添加指令,就是告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本之中。结果就直接得到了另一个c程序,通常以i为扩展名。
作用:
预处理作用:
1.处理宏定义指令:用实际值替换宏定义的字符串。
2.文件包含(#include “文件名”):将头文件中的代码插入到新程序中。
3.条件编译:有些语句希望在条件满足时才编译,根据if后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
经过预处理后生成hello.i文件,使用vim hello.i命令查看hello.i文件内容,我们可以发现,生成的文件依然是文本文件,文件的内容增加,原程序的主函数没有改变,预处理对其中定义的宏进行了宏展开,头文件中的内容被包含到了该文件中,包括声明函数、定义结构体、定义变量、定义宏等,以及用#标明了程序涉及到的许多.h文件的所在目录。
2.4 本章小结
本章实际动手演示了进行预处理的操作和结果。直观地感受到了预处理的作用,加深了理解。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译器将文本文件hello.i翻译成文本文件hello.是,它包含一个汇编语言程序,该程序包含函数main的定义.编译器也称为编译程序,是指把用高级程序设计语言书写的源程序,翻译成等价的汇编语言格式目标程序的翻译程序。编译程序属于采用生成性实现途径实现的翻译程序。它以高级程序设计语言书写的源程序作为输入,而以汇编语言表示的目标程序作为输出。
作用:
把用高级语言编写的源程序翻译成汇编语言程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化等功能。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
在汇编语言中常量以立即数($+数字)表示.
例如本程序中的if语句
if (argc != 4)
中,常量4存储在.text中
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
cmpl $4, -20(%rbp)
je .L2
同理可得
for (i = 0; i < 8; i++)
{
printf("Hello %s %s\n", argv[1], argv[2]);
sleep(atoi(argv[3]));
}
中的数字也被存储在.text节中;
在下述函数中:
printf("用法: Hello 120L020504 王功轩!\n");
printf()、scanf()中的字符串则被存储在.rodata节中
-
-
-
- 变量
-
-
全局变量:
初始化的全局变量储存在.data节
局部变量:
局部变量存储在寄存器或栈中。如本程序中的局部变量i的定义
int i;
在汇编代码中
.L2:
movl $0, -4(%rbp)
jmp .L3
i被保存在栈当中、%rsp-4的地址上。
3.3.2算数操作
在本程序的循环操作中,使用了自加++操作符:
如for循环:
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
在每1次循环执行完成后,i的值会进行一次++操作,栈上存储变量i的值加1,其汇编指令为:
addl $1, -4(%rbp)
3.3.3关系操作
如本程序中的argc!=4 和 i<8
汇编代码为:
cmpl $4, -20(%rbp)
cmpl $7, -4(%rbp)
3.3.4 控制转移
例子同3.3.3if(argc!=4){
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
其中if语句中和for循环中的条件控制的汇编代码为:
cmpl $4, -20(%rbp)
jne .L2
jne用于判断cmpl产生的条件码,若两个操作数的值不相等则跳转到指定地址;
cmpl $7, -4(%rbp)
jle .L4
jle用于判断cmpl产生的条件码,若后一个操作数的值小于等于前一个则跳转到指定地址;
3.3.5数组/指针/结构操作
本程序中主函数main的参数中有指针数组char *argv[]
int main(int argc, char *argv[]) {…}
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串。
3.3.6函数操作
X86-64中的过程调用传递参数规则为:第1~6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,剩下的参数保存在栈当中。
main函数:
参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
printf函数:
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:if判断满足条件后调用,与for循环中被调用。
源代码1:
printf("用法: Hello 120L020504 王功轩!\n");
汇编代码1:
cmpl $4, -20(%rbp)
je .L2
leaq .LC0(%rip), %rdi
call puts@PLT
源代码2:
printf("Hello %s %s\n", argv[1], argv[2]);
汇编代码2:
.L4:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
exit函数:
参数传递:传入的参数为1,再执行退出命令
函数调用:if判断条件满足后被调用.
源代码:
exit(1);
汇编代码:
.LFB6:
movl $1, %edi
call exit@PLT
sleep函数:
参数传递:传入参数atoi(argv[3])
函数调用:在源程序的for循环中被调用
源代码:
sleep(atoi(argv[3]));
汇编代码:
.L4:
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
getchar函数:
函数调用:在main函数中被调用
源代码:
getchar();
汇编代码:
.L3:
call getchar@PLT
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
本章详细了解了编译的概念和作用,了解了c语言的各个数据类型以及操作被编译器翻译成什么样的汇编语言.编译程序所做的工作,就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码表示。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器as,将汇编语言(如hello.s)翻译成机器语言(hello.o)的过程称为汇编,同时这个机器语言文件也是二进制可重定位目标文件。
作用:将汇编语言翻译成机器指令并将其打包生成可重定位目标程序的格式,并将结果保存在目标文件.o中..o文件是一个二进制文件.
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
如图用文本编辑器打开二进制.o文件将看到一堆乱码.
4.3 可重定位目标elf格式
4.3.1命令:
:readelf -a hello.o > ./elf.txt
4.3.2 ELF头:
包含了系统信息,编码方式,ELF头大小,节的大小和数量等等一系列信息。Elf头内容如下图:
4.3.3节头目表:
描述了.o文件中出现的各个节的类型、位置、大小等信息。
4.3.4 重定位节
重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。通过此信息,可执行文件和共享目标文件可包含进程的程序映像的正确信息。重定位项即是这些数据。
下图中的重定位节描述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么样的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。
本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,slepsecs,sleep,getchar这些符号。
4.3.5符号表:
每一个.o文件都有一个符号表,用于存放.o中所定义和引用的全局符号信息(函数和全局变量的符号信息)。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o hello.o:
文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
17: 74 16 je 2f <main+0x2f>
19: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 20 <main+0x20>
1c: R_X86_64_PC32 .rodata-0x4
20: e8 00 00 00 00 callq 25 <main+0x25>
21: R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
2b: R_X86_64_PLT32 exit-0x4
2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
36: eb 48 jmp 80 <main+0x80>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 10 add $0x10,%rax
40: 48 8b 10 mov (%rax),%rdx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 08 add $0x8,%rax
4b: 48 8b 00 mov (%rax),%rax
4e: 48 89 c6 mov %rax,%rsi
51: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 58 <main+0x58>
54: R_X86_64_PC32 .rodata+0x22
58: b8 00 00 00 00 mov $0x0,%eax
5d: e8 00 00 00 00 callq 62 <main+0x62>
5e: R_X86_64_PLT32 printf-0x4
62: 48 8b 45 e0 mov -0x20(%rbp),%rax
66: 48 83 c0 18 add $0x18,%rax
6a: 48 8b 00 mov (%rax),%rax
6d: 48 89 c7 mov %rax,%rdi
70: e8 00 00 00 00 callq 75 <main+0x75>
71: R_X86_64_PLT32 atoi-0x4
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
78: R_X86_64_PLT32 sleep-0x4
7c: 83 45 fc 01 addl $0x1,-0x4(%rbp)
80: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
84: 7e b2 jle 38 <main+0x38>
86: e8 00 00 00 00 callq 8b <main+0x8b>
87: R_X86_64_PLT32 getchar-0x4
8b: b8 00 00 00 00 mov $0x0,%eax
90: c9 leaveq
91: c3 retq
分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析:
- 分支转移:在相应的跳转指令后面,hello.s中是.L2和.LC1等段名称,而反汇编代码中跳转指令之后是main函数的相对偏移的地址,也即间接地址。
- 数的表示:hello.s中的操作数是十进制,hello.o的反汇编代码中的操作数是十六进制。
- 函数调用:hello.s中,call指令使用的是函数名称,而反汇编代码中call指令使用的是main函数的相对偏移地址。这是因为函数只有在链接之后才能确定运行执行的实际地址,因此在重定位节中的.rela.text节中为其添加了重定位条目。
4.5 本章小结
本章对汇编的概念以及汇编的结果进行了详细的介绍.通过将.o文件转成elf.txt格式可以查看其详细信息,如elf头,节头目表,重定位节,符号表等.得到的hello.o文件为后续的链接做了准备.还对得到的.o文件进行反汇编与之前的.s文件对比,我加深了对汇编语言和机器语言互相转变的理解.
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种不同文件的代码和数据部分收集(符号解析和重定位)起来并组合成一个单一可执行文件的过程。
作用:使源程序以较少的空间利用未编入的常用函数文件(如printf.o)进行合并获得更多的功能并生成可以正常工作的可执行文件。
5.2 在Ubuntu下链接的命令
命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello1.elf
ELF文件头:
节头:
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 00000000004002e0 000002e0
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.propert NOTE 0000000000400300 00000300
0000000000000020 0000000000000000 A 0 0 8
[ 3] .note.ABI-tag NOTE 0000000000400320 00000320
0000000000000020 0000000000000000 A 0 0 4
[ 4] .hash HASH 0000000000400340 00000340
0000000000000038 0000000000000004 A 6 0 8
[ 5] .gnu.hash GNU_HASH 0000000000400378 00000378
000000000000001c 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 0000000000400398 00000398
00000000000000d8 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000400470 00000470
000000000000005c 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 00000000004004cc 000004cc
0000000000000012 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 00000000004004e0 000004e0
0000000000000020 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 0000000000400500 00000500
0000000000000030 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000400530 00000530
0000000000000090 0000000000000018 AI 6 21 8
[12] .init PROGBITS 0000000000401000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000401020 00001020
0000000000000070 0000000000000010 AX 0 0 16
[14] .plt.sec PROGBITS 0000000000401090 00001090
0000000000000060 0000000000000010 AX 0 0 16
[15] .text PROGBITS 00000000004010f0 000010f0
0000000000000145 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 0000000000401238 00001238
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000402000 00002000
000000000000003b 0000000000000000 A 0 0 8
[18] .eh_frame PROGBITS 0000000000402040 00002040
00000000000000fc 0000000000000000 A 0 0 8
[19] .dynamic DYNAMIC 0000000000403e50 00002e50
00000000000001a0 0000000000000010 WA 7 0 8
[20] .got PROGBITS 0000000000403ff0 00002ff0
0000000000000010 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000404000 00003000
0000000000000048 0000000000000008 WA 0 0 8
[22] .data PROGBITS 0000000000404048 00003048
0000000000000004 0000000000000000 WA 0 0 1
[23] .comment PROGBITS 0000000000000000 0000304c
000000000000002b 0000000000000001 MS 0 0 1
[24] .symtab SYMTAB 0000000000000000 00003078
00000000000004c8 0000000000000018 25 30 8
[25] .strtab STRTAB 0000000000000000 00003540
0000000000000158 0000000000000000 0 0 1
[26] .shstrtab STRTAB 0000000000000000 00003698
00000000000000e1 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
使用edb加载hello, 在Data Dump 窗口中可以查看加载到虚拟地址中的 hello 程序。查看 ELF 格式文件中的程序头,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。在下面可以看出,程序包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK,GNU_RELRO几个部分,如下图所示。
其中PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello_objdump.s
hello与hello.o的不同:
1.链接后合并了其他函数:
在hello中新加入了在hello.c里调用的库函数,如exit、printf、sleep、getchar等..init节定义了一个小函数_init,程序的初始化会调用它。在.text节中,多了一个函数 _start。加载程序时,加载器会跳转到_start的地址,它会调用main函数.
2.增添了节:
hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。
4.地址访问:
hello.o中的相对偏移地址变成了hello中的确定的虚拟内存地址,已经通过链接完成了重定位。而hello.o反汇编代码的虚拟地址均为0,未完成可重定位的过程,其地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
链接的过程:
- 符号解析: 程序中所有有定义和引用的符号(包括变量和函数),编译器会讲定义的符号存放在一个符号表中。这个符号表是一个结构数组,其中每个表项包含符号名、长度和位置等信息。而链接器的工作就是将每个符号的引用都与一个确定的符号定义链接起来
(2)重定位:将多个代码段与数据段分别合并为一个单独的代码段和数据段,计算每个定义符号在虚拟地址空间中的绝对地址,将可执行文件中的符号引用处的地址修改为重定位后的地址信息。
hello重定位的过程:
(1)首先合并相同节:函数<_start>就是系统代码段(.text)与hello.o中的.text节合并得到的最后的一个单独的代码段。
(2)重定位节中的符号引用,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
(3)重定位条目,当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子函数名和地址(后6位)
401000 <_init>
401020 <.plt>
401030 <puts@plt>
401040 <printf@plt>
401050 <getchar@plt>
401060 <atoi@plt>
401070 <exit@plt>
401080 <sleep@plt>
401090 <_start>
4010c0 <_dl_relocate_static_pie>
4010c1 <main>
401150 <__libc_csu_init>
4011b0 <__libc_csu_fini>
4011b4 <_fini>
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
图 12 edb执行init之前的地址
图 13 edb在执行init之后的地址
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。GNU编译系统采用延迟绑定技术来解决动态库函数模块调用的问题,它将过程地址的绑定推迟到了第一次调用该过程时。延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)实现。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。前者是数据段的一部分,后者是代码段的一部分。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。
5.8 本章小结
本章详细介绍了程序链接环节的过程,通过查看hello的虚拟地址以及将hello.o与hello的反汇编代码,加深了对链接的重定位功能的理解.
第6章 hello进程管理
6.1 进程的概念与作用
- 概念:进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位
作用:每次运行程序时,shell创建一新进程,在这个进程的上下文切换中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:解释命令,连接用户、操作系统以及内核。
处理流程:
1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(或首个)命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建新进程/子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait)等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
在终端输入./hello后,shell会读取输入的指令,并开始进行以下操作:首先判断输入指令是否是内置的,发现hello不是一个内置的shell指令,所以调用应用程序,找到当前所在目录下的可执行文件hello,准备执行;接着Shell会调用fork()函数为父进程创建一个新的子进程,子进程会得到与父进程(shell)的虚拟地址空间相同的数据结构的副本(包括代码和数据段,堆,共享库和用户栈)。父进程与子进程最大的不同在于他们PID不同。其实父进程与子进程分别是两个并发的进程,在子进程中程序运行的这个过程中,父进程在原位置等待着程序的运行完毕,这里可以看出二者在时间上是并发的。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
6.4 Hello的execve过程
execve函数在加载并运行可执行目标文件Hello,且带列表argv和环境变量列表envp。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。
只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序,这里与一次调用两次返回的fork不同。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(intargc , char **argv , char *envp);
结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello:
- 删除已存在的用户区域(自父进程独立)。
- 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
- 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
- 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
进程提供给应用程序以下两种抽象:
1) 一个独立的逻辑控制流,这使我们觉得这个进程正在独占使用处理器,且其通过OS内核的上下文切换机制提供。
2) 一个私有的地址空间,它使我们觉得程序正在独占使用CPU内存,由OS内核的虚拟内存机制提供。
下面阐述一些关键概念:
- 逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流。具体表现为进程是轮流使用处理器的,每个进程在一个处理器上执行一部分流后被抢占,然后轮到其他进程。
2.用户模式和内核模式:用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
3.上下文信息:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
4.时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
5.上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
6.并发:每个进程是个逻辑控制流,如果两个逻辑流在时间上有重叠,则称这两个进程是并发的(并发进程),否则他们是顺序的。
下面一hello程序为例分析:
命令:./hello 120L020504 王功轩 1
分析:在调用进程调用sleep之前,hello在当前的用户内核模式下继续运行,在内核中进程再次调用当前的sleep之后进程转入用户内核等待休眠模式,内核中所有正在处理休眠请求的应用程序主动请求释放当前正在发送处理sleep休眠请求的进程,将当前调用hello的进程自动加入正在执行等待的队列,移除或退出正在内核中执行的进程等待队列。之后设置程序计时器,休眠自己设置的时间,当计时器时间归0,发送一个中断信号。内核收到中断信号进行中断处理,hello被重新加入运行队列,等待执行。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
正常运行状态:
异常类型:
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
异常信号类型:
处理方式:
图 16 中断处理方式
图 17 陷阱处理方式
图 18 故障处理方式
图 19 终止处理方式
1.按下CTRL+Z:
进程收到 SIGSTP 信号, hello 进程挂起。用ps查看其进程PID,可以发现hello的PID是5542;再用jobs查看此时hello的后台 job号是1,调用 fg 1将其调回前台。
- 按下CTRL+C:
可见在按下ctrl+c之后进程已经彻底结束,ps查不到PID,jobs也查询不到
- 中途乱按:
只是将屏幕的输入缓存到缓冲区。乱码被认为是命令
- kill命令:终止进程
6.7本章小结
在本章中我了解了hello进程的执行过程。过程主要包括创建、加载和终止,以及一些常见的异常及信号,还由通过键盘输入发送信号。在学习过程中,我加深了对信号的认识,以及对进程和shell的理解。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址
逻辑地址(Logical Address)是指由程序hello产生的与段相关的偏移地址部分(hello.o)。
线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
虚拟地址
有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
物理地址
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
1.逻辑地址:段标识符+段内偏移量
段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面3位里,前一位是TI,为0是全局描述符表,为1是局部描述符表。后两位是cpu当前优先级。索引号,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。 这里面,我们只用关心Base字段,它描述了一个段的开始位置的线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理:页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
优势:1 .简化链接
- 简化加载
- 简化共享
缺点:1.需要相应的硬件支持,增加成本
- 增加系统开销,如缺页中断处理机制
- 请求调页的方法不当时会发生抖动现象
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
多级页表:
将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。
每次cpu产生一个虚拟地址,MMU就必须查阅一个PTE,在糟糕的情况下,由于一些不命中的情况发生,会造成大量次数的内存访存,导致大量的时间浪费,为了试图消除这样的开销,采取了一种TLB的机制。
TLB时MMU中一个关于TLB的缓存,称为翻译后备缓冲器。
下面时操作图:
四级页表下的VA到PA的转换:
解析VA,利用前m位vpn1找到一级页表位置,接着重复k次,在第k级页表获得了页表条目,将PPN与VPO组合即可获得PA
7.5 三级Cache支持下的物理内存访问
在CPU发送虚拟地址后,MMU按照上述操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)、CS(组号)、CO(偏移量)。根据CS寻找到正确的组,通过比较每一个cacheline标记位是否有效以及CT是否相等来判断是否命中。若命中则返回数据,若不命中,就依次去L2,L3,主存中去判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline(如果cache已满则要采用换入换出策略)。
7.6 hello进程fork时的内存映射
Shell通过调用fork函数可以让内核自动创建一个子进程,这个新的进程拥有新的数据结构,并且被内核分配了一个独立的pid。子进程有独立的虚拟内存空间,独立的逻辑控制流,以及当前父进程已经可以打开的各类文件信息和页表的原始数据和样本。
内核创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
1)在bash中的进程中执行了相应的execve调用:execve("hello",NULL,NULL);
2)execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
下面是加载并运行hello的几个步骤:
3)删除已存在的用户区域,可能会自动覆盖当前进程的所有虚拟地址和空间,删除当前进程虚拟地址的所有用户已存在的代码共享区域和数据结构等等。
4)映射私有区域,为新程序的代码、数据、.bss和栈区域创建新的区域结构。
5)映射共享区域,让hello与系统执行文件链接映射到共享区域。它首先映射到一个共享的区域,hello这个程序与当前共享的对象libc.so链接,它可能是首先动态通过链接映射到这个代码共享程序上下文中的。然后再通过映射链接到用户虚拟地址和部分空间区域中的另一个共享代码区域内。
6)设置程序计数器(PC)exceve做的最后一件事是设置当前进程的上下文中的程序计数器,是指指向代码区域的入口点。而下一次调度这个进程时,他将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:
- 处理器生成一个虚拟地址,并将它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE
- PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
上述内容在教材中有详细说明。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态储存分配管理使用动态内存分配器来进行,这样会比使用低级的mmap函数和munmap函数更加方便也更具有可移植性。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:
带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。
隐式空闲链表:在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
显示空间链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
显式空闲链表:在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
在本章中我加深了对逻辑地址、线性地址、虚拟地址、物理地址的理解,了解了段页式管理,以及三级cache的物理访存原理,缺页中断处理程序流程,深刻认识到了虚拟内存的优缺点,TLB机制带来的优势,shell中fork,execve函数的内存映射,缺页故障处理以及动态存储分配管理等概念。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作相对应文件的读和写来执行。
设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。
8.2 简述Unix IO接口及其函数
Unix IO接口:
打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
Unix IO函数:
1. open()函数
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(char* filename,int flags,mode_t mode)
参数:open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访 问这个文件,mode参数指定了新文件的访问权限位。
返回值:成功:返回文件描述符;失败:返回-1
2. close()函数
功能描述:用于关闭一个被打开的的文件
所需头文件: #include <unistd.h>
函数原型:int close(int fd)
参数:fd是需要关闭的文件的描述符,close 返回操作结果。
函数返回值:0成功,-1出错
3. read()函数
功能描述: 从文件读取数据。
所需头文件: #include <unistd.h>
函数原型:ssize_t read(int fd,void *buf,size_t n);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。n: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回所读取的字节数;0(读到EOF);-1(出错),否则返回值是实际传送的字节数量。
4. write()函数
功能描述: 向文件写入数据。
所需头文件: #include <unistd.h>
函数原型:ssize_t write(int fd, void *buf, size_t count);
write函数从内存位置buf复制至多count个字节到描述符为fd的当前文件位置。
返回值:写入文件的字节数(成功);-1(出错)
5. lseek()函数
功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include <unistd.h>,#include <sys/types.h>
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
返回值:成功:返回当前位移;失败:返回-1
8.3 printf的实现分析
printf函数部分代码如下:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
write函数代码::
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
syscall函数:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。
总体流程为:
vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 getchar的实现分析
当程序调用getchar时,程序就等着用户通过键盘输入,用户输入的字符被存放在键盘缓冲区中直到用户输入回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符显示到屏幕。若用户在输入回车符前输入了多个字符,则只输出第一个字符,其余字符会保存在输入缓冲区,等待后续getchar调用读取。后续调用getchar时不会等待用户按键,而是直接读取缓冲区中已经存在的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。在用户按键盘上面的按钮的时候,键盘接口获得一个键盘扫描码,此时同时产生一个中断的请求,系统调用键盘中断处理子程序,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
在本章中我加深了对linux系统的IO设备管理、unix IO接口及其函数的理解,对之前就很熟悉的printf函数和getchar函数有了更深的理解。
(第8章1分)
结论
Hello从出生起就经历了波澜壮阔的一生:
- hello.c,一个刚出生的源程序,在经历了预处理器的扩展插入代码后,成为了hello.i
- hello.i,一个经历了大风大浪之后得到扩展的源程序,在经编译器翻译后,被翻译成为hello.s,包含了一个汇编语言程序,
- hello.s,在接下来的汇编阶段中被翻译成机器语言指令,并被打包成可重定位可重定位目标程序,得到二进制文件hello.o
- hello.o经过链接阶段,生成了可执行目标文件hello
- bash进程调用fork函数生成子进程,并由execve函数在当前进程的上下文中加载运行新程序hello
- hello经历种种后我们会得到真正想要的PA物理地址
- hello运行过程中会调用相关函数,这些函数与linux的I/O设备模拟化相关
- 终止进程后hello会被shell父进程回收,内核会收回为其创建的所有信息
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
文件名 | 作用 |
hello.i | hello.c预处理产物 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 经链接之后得到的可执行目标文件 |
elf.txt | hello.o的ELF格式 |
disass_hello.s | hello.o的反汇编代码 |
hello1.elf | hello的ELF格式 |
hello1_objdump.s | hello的反汇编代码 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社
[8] 博客园 printf函数实现的深入剖析
[9] CSDN博客 Ubuntu系统预处理、编译、汇编、链接指令
[10] 博客园 从汇编层面看函数调用的实现原理
[11] CSDN博客 ELF可重定位目标文件格式
[12] 《步步惊芯——软核处理器内部设计分析》 TLB的作用及工作过程
[13] 博客园 [转]printf 函数实现的深入剖析