标准库包含大量我们不想自己编写的代码,包括printf、scanf、数学函数等等。因此,我们需要确保我们的操作系统可以链接到这个库,并且一切都“正常工作”。这篇文章将向你展示我如何将我们的操作系统链接到一个标准库newlib,以及在这样做的过程中遇到的考验和磨难。
程序库允许程序员在更高的层次上开始编写程序。如果有人还记得80年代,当个人电脑引导到一个基本的编辑器时,你基本上必须从头开始编写你的程序。
术语库只表示存储代码的某个地方。通常,此代码是编译和汇编的源代码的目标文件。共享对象也可以按需加载,但这需要一些来自动态链接器的额外支持。请看我关于动态链接的帖子:动态链接。
许多最有用的例程将只需编写一次,然后存储到共享对象(So)或存档(A)中。我们知道,库允许我们引入已经编写的代码,但库还有一个更根本的原因--使与操作系统的接口变得容易。
如果你读过我关于使用Rust的RISC-V操作系统的博客文章,你就会知道一个应用程序是如何从操作系统本身发出请求的。通常,这是通过系统调用完成的。这些系统调用被赋予一个编号。例如,在x86-64系统上的Linux中,系统调用#0是exit系统调用。然而,没有什么能真正说明我们必须使用这些数字。
如果我们看一看libgoss,这是一个为newlib编写的低级库,我们可以看到以下标准:
因此,正如您所看到的,低级库的工作是确保参数位于正确的位置,系统调用号位于正确的寄存器中,并且实际执行了系统调用。对于RISC-V,我们执行的最后一条指令是eCall,即“环境调用”。这条指令将使我们进入操作系统。操作系统将通过首先了解它正在处理系统调用来处理此问题。其次,它将查看系统调用号并将其路由到正确的例程。
我们可以通过一个通用函数(如printf())来了解这一点。此函数将首先使用应用程序形成一个完整的字符串。因此,类似printf(“Hello%s”,“Stephen”)的内容将导致该函数构建一个字符串,其内容为“Hello Stephen\0”。最后一步是printf()将其打印到标准输出文件句柄。除非有任何重定向,否则这通常是控制台。因此,库函数最终必须调用WRITE系统调用,首先列出文件描述符(Number),其次是指向字符串的指针,最后是要写入的字节数。
在系统调用结束时,控制权交回给用户应用程序。在这种情况下,printf()必须处理从WRITE系统调用接收到的任何返回值。如果我们执行系统调用跟踪或strace,我们可以看到WRITE确实是printf的最终目标。
在上面的输出中,我们可以看到printf()决定调用WRITE WITH WITE WITH FILE DESCRIPTOR 1(这是标准输出)和字符串缓冲区“Hello Stephen”(最后13个字符)。请注意,printf()必须能够找到空终止符(\0)来计算打印字符数。
当我们查看内核时,我们必须能够处理库如何进行系统调用。正如您所看到的,这些系统调用中的大多数都遵循UNIX SYSV约定,比如exit、read、write等等,其中所有内容都是文件描述符,包括网络套接字和实际文件。
下面的例子显示了我是如何实现开放系统调用的。您可以看到,由于应用程序使用的是虚拟内存,因此我需要找出它在物理内存中的位置,以便内核可以找到它。将两者混合或跨越河流从来都不是一个好主意。
在上面的代码中,我检查用户给出的路径(第一个参数)是否在虚拟文件系统中的某个位置。如果是,那么我们可以创建一个新的文件描述符,并将其链接到该文件。正如您可以看出的,这个系统调用绝不能完全处理所有不同类型的文件,包括节点(套接字、FIFO等)。
C++标准实际上包含有关标准库的信息。这包括复杂性保证和内存占用。
该库还最好了解底层架构的所有漏洞。例如,如果CPU支持AVX或SSE扩展,strcpy(字符串复制)就可以利用它们。
看看我在这里免费找到的C++2011标准,http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2011/n3242.pdf概述了需要支持才能被视为C++标准的不同标准函数。
这里只是第717页上容器库需求的一个例子(731是PDF页面)。
库的另一个有趣部分是,它们的某些部分可以帮助语言正常运行。这些库通常称为运行时。大多数语言现在都有运行库,运行库是在程序运行时执行的代码,而不是在编译或链接时执行的代码。
你们当中有多少人真的知道int main不是我们运行程序的真正切入点?相反,它属于一个名为_start的内存标签。该标签是ELF(可执行和可链接格式)入口点,操作系统将在该入口点设置您的应用程序开始运行。
_start最终将调用int main,但它必须设置一些内容,包括命令行参数,或者至少将它们放入int argc中,以及char*argv[]参数。
我们可以在crt0.S程序集文件中看到_start例程,它代表C运行时。0是第一个运行时,因为可以在不同的文件中添加更多的运行时,比如crt1.S等等。
您可以在上面的代码中看到,甚至在调用main之前,BSS(未初始化的全局变量)被清除为0,全局指针(GP)被设置,atexit函数(全局终止函数)被注册。最后,argc、argv和envp都找到了合适的位置。对于RISC-V,这是a0、a1和a2。
您可以看到,最后发生的事情是退出呼叫。这将获取Main的返回值,该返回值将位于寄存器a0中。尾部指令意味着它不会返回。Call和Tail都是RISC-V中的伪指令,可以在RISC-V规范中看到:
动态链接要求我们分析可执行和可链接格式(ELF),这比我们在Rust中使用的RISC-V操作系统更强大一些。动态链接器需要位于存储设备某处的可执行解释器。实际上,您可以查看可执行文件,以了解在调用这些动态链接的函数时它将请求哪个解释器。
您可以看到上面的可执行文件将使用/lib64/ld-linux-x86-64.so.2来运行我编译的这个简单的测试可执行文件。我们可以实际运行那个解释器,看看它说了什么:
正如您所看到的,INTERP是一个特定的程序头,我们的操作系统必须能够处理它并产生指向动态链接器的线程。然而,这远远超出了我们的定制操作系统所能处理的范围。所以现在,我们的操作系统需要将标准库静态链接到我们的程序中。这意味着程序需要的所有函数和例程都必须存储在可执行文件中。这大大增加了程序的大小,这取决于在可执行文件的生命周期中进行的库函数调用的次数。
我甚至还没有开始深入研究设计和创建一个图书馆。在我的软件工程课程中,我展示了库可以是将许多不同团队的代码聚合到单个项目中的一个很好的工具。请记住,我是用一个相当低层次的镜头来看这件事的。
大多数图书馆都有一个特定的体系结构和操作系统。这给定制操作系统带来了一些挑战。事实上,很多时候,我只是为了避免以后的痛苦而复制Linux的约定。从总体上看,这似乎没什么大不了的。然而,它迫使您以某种方式思考操作系统。
获得一个大的哲学-Linux在过去的几年里发生了变化,但它仍然有一个核心的哲学。从它衍生出来的许多操作系统,包括安卓,现在都被淘汰了,以换取新的、令人耳目一新的操作系统。事实上,谷歌已经启动了Fuchsia项目,该项目旨在构建一个模块化操作系统,以取代其移动设备中的Android。