原文链接:https://thecoder08.github.io/hello-world.html
未经许可,禁止转载!
在本文中,我们来深入磋商当代Hello World程序背后的抽象天下。
背景先容
本文紧张磋商用C措辞编写的Hello World程序。不考虑详细的编程措辞在Hello World正式运行之前阐明器/编译器/JIT等事情的话,C措辞便是高等措辞所能达到的最高层次了。
原来我写这篇文章的目的是让所有具备一些编程背景的人都能理解,但现在我认为具备一些C措辞或汇编措辞的知识会更有帮助。
Hello World代码
每个人都该当很熟习Hello World程序。学习Python时,你编写的第一个程序可能像下面这样:非常大略,便是在屏幕上输出文本“Hello World!”。
在本文中,我们来看一看用C措辞编写的Hello World程序。你能看懂下面的代码吗?
这个程序实行的操作与上述Python代码完备一样。但与Python不同,你不能直接调用阐明器运行这个程序。你必须先运行编译器,将这段代码转换成机器代码,然后才能在打算机的处理器上直接运行。所有当代大型程序都是这样编写的。
因此,我们必须运行以下命令:
这个命令可以将文件hello.c中的C代码转换成机器代码,并天生一个名为hello的程序。然后,我们就可以通过如下命令运行程序了:
结果是:
我们的程序
那么,我们的程序是如何输出这个文本的呢?首先,我们来看看我们的程序,看看里面究竟是什么。你不用担心看不懂,我会逐步阐明。重点是下面这几个字段:
这几个字段见告我们,这个程序是x86_64指令集架构上的ELF可实行文件。什么意思?
ELF可实行文件是Linux文件,相称于Windows下的.exe文件,便是一种打算机可以运行的程序。别的信息见告我们,这是一个在 64位 x86 处理器上运行的机器代码程序,64位 x86 处理器是自1981年以来IBM打算机一贯在利用的CPU架构。当然,当时还不是64位的,但当代处理器也可以运行为IBM PC编写的代码。这又是另一个话题了。
我们的程序文件包含的是机器代码,一种措辞,也是CPU能理解的唯一措辞。那么,CPU从何处开始运行代码呢?
此处的重点是:Entry point address,其值为 0x1060。这是一个十六进制数字,代表了程序加载到打算机内存后,程序中的一个位置。那么,这个位置上究竟有什么呢?
代码
这条命令完全的输出太长了,此处就不贴了。下面是截取的一部分,请把稳 1060: 开头的一行:
什么意思?冒号前面的数字是后面的字节的地址,也便是它们在文件中的位置。后面的数字是程序文件中的数据字节,此处表示机器代码。后面的文本是机器代码的反汇编。汇编措辞是人类可读的机器代码的表示。请把稳,即便左侧的字节不表示代码,反汇编器仍会考试测验对它们进行反汇编。由此会产生一些垃圾和毫无意义的汇编代码。
如上,我们找到了一些代码!
但不是我们编写的代码。这些代码是编译器(严格来说是链接器)自动添加到程序中的。实质上,这些代码会实行一些初始化,然后运行一个主要的指令:
这条指令见告打算机去实行其他地方的一些代码,此处即为地址0x2f53,当动态链接器加载我们的程序时,这个地址会被改为0x3fd8。关于这一点,此处不做详细磋商。
但无论你怎么努力探求,我们的文件中都找不到这两个地址。准确来说,0x3fd8在全局偏移表中,同样干系内容也超出了本文的范围,但此刻它是空的。这是由于这段代码不是在我们的程序中定义的,而是在其他地方。
C 库
那么究竟在哪里?我们的代码依赖的库有很多,上面只是个中一部分。我们可以看到下面这行:
main函数当然就在我们的程序中。再看看反汇编,你会看到:终于看到我们的代码了!
那它究竟干了什么呢?
设置了一个栈帧。
设置了我们的函数调用的参数。
调用了我们的Hello World函数。
清理了栈帧。
从函数中返回,退出代码为0。
这便是我们在源代码中看到的内容。但什么是栈帧呢?它是打算机内存的一部分,我们的程序用栈帧来存储局部变量,即在main函数内声明的变量。幸运的是,我们没有声明任何变量,以是不须要在意。重点是下面这部分:
详细操作为:
设置 Hello World 字符串的内存地址,将其作为函数调用的第一个参数(间接调用)。
调用 puts() 函数。
等等,puts()?我们调用的不是 printf() 吗?
没错。但是,编译器进行了一种优化。printf 函数很繁芜,由于它能够打印“格式化输出”,这意味着我们可以在输出中嵌入变量。这个函数卖力将它们转换为字符串并输出,但我们没有用到这些功能。因此,编译器将 printf() 更换为更为大略的 puts(),后者仅卖力打印一串未经格式化的文本。那么,我们的文本在哪里?
字符串
根据反汇编器的显示,我们的字符串位于地址 0x0eac,加载后会转换为地址 0x2004。那么,字符串里面是什么呢?
前面,我说过纵然不是代码,反汇编器也会考试测验进行反汇编,这便是一个很好的例子。忽略上面这些汇编措辞,由于它们毫无意义。我们来看看 0x2004,后面是一串十六进制字节 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00,翻译过来便是字符串“Hello World!”,末了是一个终止符。
但是我们的字符串中不是还包含一个换行符 \n 吗,不是该当被翻译为 ASCII 0x0a 吗?没错,但这也是编译器优化后的结果。puts() 函数会在字符串后面添加换行符,而 printf() 不会。因此,我们的换行符被移除了,这样输出就只包含一个换行符。
我们还看到了一个字节 0x00,又称作终止符。所有 C 字符串的末端都有这个字节。在 C 中,字符串不包含任何长度信息。因此,接管任何长度的字符串作为参数的函数会逐字节地对其进行操作,直到碰着终止符。如果内存中有多个字符串,并且它们之间没有终止符,那么 C 函数将一次性操作所有字符串。终极,函数将来到字符串末端,并开始读取不许可读取的内存,而你的程序将崩溃并显示“Segmentation Fault”缺点。
puts()
puts()的地址是0x1050。又一次调用标准库(严格来说是全局偏移表,但终极是标准库)。
此处,我们还是不想阅读标准库的反汇编代码,但幸运的是 Glibc(我们的 C 标准库)是开源的。我们能从中创造什么呢?
在标准库中,puts() 的别名为 _IO_puts。
可以看到,这个函数获取了字符串的长度,得到了输出流锁,进行了一些检讨,并调用了 _IO_sputn。然后,开释锁,并返回打印字符的数量。
我搜索了一下这个函数,但没有找到。很明显,它通过一个名为 _IO_file_jumps 的函数实行了一些操作,并调用了 _IO_new_file_xsputn。
好长的一段代码,我可不打算去剖析这段代码究竟在干什么。我知道利用 Glibc 来阐明这段代码会很麻烦。因此,此处我决定查看 musl libc,我知道它该当很小。
musl
在 musl 中,puts() 定义如下:
首先,获取输出流锁;然后,调用fputs;末了,开释输出流锁。
那么,fputs又是若何定义的呢?
获取字符串的长度,然后调用fwrite(),参数为输出流、字符串及其长度。
那么,fwrite()的定义又是什么呢?
获取另一个输出流锁,然后调用__fwritex(),然后开释输出流锁。
那么,__fwritex()的定义又是什么呢?
这段代码有点多,但紧张操作是利用输出流的FILE工具调用write()。我们的流被定义为标准输出(stdout),这又是在哪里定义的呢?
此处,write函数被定义为__stdout_write(),那么后者的定义又是什么呢?
针对我们的输出流实行了一次 TIOCGWINSZ ioctl,然后又调用了 __stdio_write(),那么后者的定义又是什么呢?
我们间隔终点已经很近了。这个函数实行了很多操作,调用了 syscall(),第一个参数为 SYS_writev。那么,syscall() 是如何定义的呢?
syscall()的第一个参数为系统调用编号,还接管数量可变的额外参数。va_arg()调用将这些参数读入变量a、b、c、d、e和f中。然后,我们利用这些参数调用__syscall(),并将结果放入__syscall_ret()。
不幸的是,我找不到__syscall()的定义。但我以为这是由于这部分属于平台范畴。Musl是一个多架构的C库,因此从这个深度开始运行的代码取决于我们利用的是什么架构。在深入研究之前,我看了一眼__syscall_ret():
检讨__syscall()的返回值是否有效,如果无效,则系统调用失落败,因此返回-1。
系统调用
我们的Hello World调用的末了几个阶段涉及到了系统调用。什么是系统调用?无论我们的C库有多大,都无法完成底层的一些事情。个中之一便是与硬件通信。这部分事情预留给了内核,是操作系统的一部分,卖力掌握并共享IO设备、内存和CPU的访问。在这个例子中,这部分事情由Linux内核卖力。在Windows中是ntoskrnl.exe,也便是任务管理器显示的System。
这意味着,向操作系统传达了后面的事情后,puts()调用就功成身退了。在这个例子中,我们哀求操作系统向输出流写入一些文本。写入流的事情是系统调用write完成的。Musl利用了一个类似的系统调用,叫做writev,它可以在数组中写入多个缓冲区。下面,我们来看看musl如何进行系统调用。
我们已经追踪到最底层了。在x86_64平台上,musl可以利用7个不同的函数进行系统调用。每个函数接管不同数量的参数。
每个函数都有一个__asm__指令,它可以将内联汇编代码嵌入到编译器的机器措辞输出中。我们在向操作系统发出系统调用时设置了一些CPU寄存器并实行了syscall指令。然后,掌握权转移到了内核,由后者读取我们的参数并实行系统调用。
内核
接下来,由Linux内核实行系统调用要求的操作。系统调用write见告内核写入文件系统中的一个已打开的文件,或者写入一个流,而此处我们的操作属于后者。
系统调用write有3个参数:文件描述符、写入的缓冲区以及写入的字节数。musl利用的系统调用write略有不同,但此处我们只谈论write。
那么,我们到底写入到哪里呢?
视详细情形而定。
在这个例子中,我在GNOME终端仿照器中运行了hello程序。这款仿照器是一个图形运用程序,对付内核来说,它是一个伪终端(pty)。以是,内核将我们的Hello World保存在缓冲区中,仿照器运行时,读取缓冲区,然后再显示。终于讲完了。
当然,全体旅程还没有完备结束。仿照器必须将文本渲染成一帧(可以利用GPU来渲染),将此帧发送到X做事器/合成器,然后由后者将其与其他正在运行的运用程序组合在一起(也利用GPU),例如我当前用来撰写这篇文章的文本编辑器,然后将其发送回内核,末了显示出来。
长呼一口气。我略过了很多不太主要的细节,而且你的环境可能完备不同。比如你选择远程登录,那么内核会将的文本发送到sshd,然后将通过互联网发送的数据包(加密)发送回内核。或者,你利用的是物理终端,连接到串口转USB适配器,那么内核必须将文本放入USB数据包,并将其发送到下一级。再或者,你利用了帧缓冲掌握台,这是在没有安装GUI的情形下与操作系统交互的默认办法。在这种情形下,内核必须将文本渲染成一帧,并将其输出到显示器。
重点在于,接下来的操作有很多可能性,而且详细的细节并不主要。由于你的Hello World只是一个别系调用,来自一个程序,此时此刻你的打算机上有数百万个别系调用和成千上万个程序在运行,而Hello World只是个中最微不足道的一个。
总结
现如今,硬件上的当代软件系统是如此错综繁芜,费尽心机完全地理解打算机上的一个小操作完备没故意义。显然,为理解释这个小程序所做的统统操作,我略过了很多内容。我没有提到所有边缘情形、附加信息以及打算机实行的其他任务。我也没有阐明内核是如何事情的。
问:那么,Hello World 程序究竟是如何事情的呢?
答:一言难尽……