“Hello World程序会调用库函数,库函数又会调用什么呢?谁帮我们打印出来了:Hello World?”
01
提出问题
“系统调用”是现代大型操作系统的核心功能,那你知道什么是系统调用吗?它跟“函数调用”有什么区别呢?为什么有人说:普通程序员跟大神之间就差了一个:系统调用?如何在 2 分钟内,手写一个可以运行的:系统调用?
这里,我们将以Linux操作系统为例,用CPU 的视角,开启今天的“系统调用”之旅。
02
代码分析
其实,当你写第一个程序:hello world 的时候:
int main()
{
printf( "HiWorld\n" );
}
你已经在作“系统调用”了。虽然,hello world 是公认最简单的程序,但从 CPU 眼里,可一点都不简单!
因为,这个从天而降的 printf 函数,不仅仅帮你实现了:字符打印;还依次穿透了整个计算机系统的:应用层、操作系统层、驱动层 和 硬件层,如图所示。
显然,这都不是普通函数能做到的!真正的幕后英雄是:操作系统,是它调动了驱动和硬件,才完成了字符的显示。我们不过是通过 printf 函数,召唤操作系统完成特定的工作而已,而这个召唤过程,就是“系统调用”。
为了便于描述,我们把 printf 直接替換成:它必須要作的系统调用write,如图所示。
其中,参数1是标准输出设备(stdout)的文件描述符,它的值是:1;参数2是字符串“HiWorld\n”所在内存的首地址;参数3是字符串“HiWorld\n”的长度:8 个字节。
它所对应的CPU指令如图的左侧所示。好了,让我们化身成世界最慢的CPU,看看系统调用的具体过程。
假设:上边的内存颗粒,存放着:hello world 程序;下边的内存颗粒,存放着AMD64 的 Linux 操作系统。
先看第1条CPU指令,它把参数1的值:1,传递给寄存器 rdi;第2条CPU指令,把参数3的值:8,传递给寄存器 rdx,如图所示。
没想到吧?系统调用的参数传递方法,跟普通函数是完全一致的!有兴趣的同学,可以回看一下“CPU眼里的参数传递”。
随后的 3 条CPU指令,用来把字符串写入“堆栈”内存,并把内存地址传递给寄存器 rsi。假设这段内存,就是当前的堆栈(为了方便展示:“堆栈”的堆叠结构,下面是高端地址,上面是低端地址),如图所示。
每个内存块的字节长度为:8 个字节;红色水位线,表示“堆栈”栈顶的内存地址,也就是:CPU寄存器 rsp 的值,如图所示。
好了,先把字符串“\ndlroWiH”对应的 8 字节无符号数0x0a646c726f576948,写入到寄存器:rbx。每个字节,正好对应着1个字符的 ASCII 码,至于为什么倒着写?可以参看“CPU眼里的:大端、小端”。
随后的push指令,把 rbx 的值压入“堆栈”,栈顶也随之上升,这样,就把字符串写入到堆栈内存了;而这个字符串所在的内存地址,正好就是此时寄存器 rsp 的值:0x8000 0008。
最后,mov 指令把寄存器rsp的值,传递给:寄存器 rsi,这样,参数2(字符串“HiWorld\n”的内存首地址)的传递工作,也完成了。
好了,万事俱备,可以进行系统调用了,如图所示。
mov 指令,把 1 传递给寄存器 rax,表示我们要作:1号系统调用;随后的 syscall 指令,会产生CPU 异常,迫使 CPU 切换到操作系统内核,进行异常处理,如图所示。
操作系统,早就为所有的系统调用准备好了:一张表格,一行都对应着一个系统调用。因为,此时寄存器rax的值是1,所以操作系统会执行第1行的系统调用,调用所需的参数,分别在寄存器 rdi、rsi 和 rdx 里面。
剩下的事情,就由操作系统代劳了:把字符串输出到显示屏上。至此,整个系统调用结束!
但无论多么精妙的讲述,都无法替代一次上手操作,我们可以把代码粘贴到一台 64 位的 Ubuntu 电脑上,当然,也可以在虚拟机 或 Windows自带的Linux子系统(WSL)上操作:
先安装一下编译器 nasm,作一下编译,现在就可以运行了,如图所示。
不出所料,字符串被打印了出来。当然,我们还可以修改代码,打印一些有趣的东西。代码(syscall.s)已经上传至:GitHub(https://github.com/idea4good/AbuCoding)可以按照上面的步骤,亲手操作一下。
03
总结
1. “系统调用”跟“函数调用”一样,都可以通过寄存器来传递参数,但会用 syscall指令触发CPU异常,从而让操作系统,接管后面的功能实现。
2. 系统调用会引发:CPU状态切换,CPU在用户态准备参数,然后切换到内核态完成功能。
3. 系统调用能够有效的隔离应用程序和操作系统核心,提高整个系统的安全性。
4. 系统调用的实现,会因为CPU指令集的不同而不同。几乎所有重要的库函数,都需要通过系统调用来实现,看看图中的 Linux 的“系统调用”表,是不是觉得非常眼熟呢?
最后,不是所有的操作系统都支持系统调用,许多单片机操作系统,就不支持系统调用,程序员在调用操作系统的高级功能(例如:创建线程、sleep)时,大多是在作普通API函数的调用。
04
热点问题
Q1:实例程序,相当于一个脱离了C运行库的hello world程序,是这样吗?
A1:是的,因为脱离了第三方库的支持,这也可能是世界上最小、最简单的hello world程序。当然,printf这个库函数,并不仅仅是把字符串打印出来那么简单,其实它包含了更丰富的功能,例如:格式化字符串、不同进制(2进制、8进制、16进制)之间的数制转换等。
Q2:系统调用,存在的意义是什么?
A2:系统调用的一个重大意义是:隔离了用户空间和内核空间。以本章节的实例来说,在系统调用前,都是在用户内存空间,运行用户的代码;在syscall指令之后,CPU会跳转到内核内存空间,运行操作系统预设好的代码。
内核态的代码(例如:操作系统、驱动程序代码),一旦运行出错,往往就是系统崩溃或蓝屏,因此它们往往是经过了千锤百炼,基本确保了正确性、安全性的可靠代码。
相比之下,程序员编写的在用户态下运行的程序,出错的可能性更大,但由于这种良好的隔离,不会让用户态的错误影响到内核态。所以,即使用户的程序崩溃,也不会影响到整个计算机系统(例如:操作系统、驱动程序)的继续运行。
Q3:为什么把系统调用设计成这个样子?貌似不能随意改动,万一设计出来,不合理怎么办?
A3:形成一个固定的规则非常重要,这样才便于各种库函数的开发,如果系统调用的接口、编号不断变化,那对应的函数库(例如:C标准库、多线程库),也需要不断的调整!这非常不利于软件的标准化、工业化。
至于可能的设计缺陷,一方面要在开发的早期,及时发现、及时解决;另一方面可以由库函数来做适当修正,如你所见,系统调用的功能是非常基本的,更丰富的功能往往是由库函数来完善的。保持系统调用的简单性,本身就是良好的设计。如你所见,无论Windows还是Linux,它们的系统调用都非常相似,这也足以说明当前设计方法的可行性。
05
更多知识
如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》
本文暂时没有评论,来添加一个吧(●'◡'●)