系统调用API、syscall指令和异常机制(中断)之间的关系

4
我正在尝试理解C语言系统调用API、syscall汇编指令以及上下文切换所使用的异常机制(中断)之间的关系。需要我自己去学习很多,所以请耐心等待。
我的理解是否正确:C语言系统调用由编译器实现为带有相应代码的syscall汇编指令,而这些指令又由操作系统作为异常机制(中断)来实现?
因此,在下面的C代码中调用write函数的方式是:
#include <unistd.h>

int main(void)
{
    write(2, "There was an error writing to standard out\n", 44);
    return 0;
}

被编译为一个syscall指令的汇编代码:

mov eax,4       ; system call number (sys_write)
syscall 

而指令则由操作系统作为异常机制(中断)实现?

1
嗯,确切的系统调用机制取决于操作系统和架构,但你并不完全走错了路线 :) - Martin James
write 是一个 C 函数调用。它不会直接编译成 syscall。这是在函数内部处理的。syscall 0x80 不是有效的,您将其与旧的 32 位调用内核的 int 0x80 混淆了。那是一个中断。syscall 不使用中断机制。 - Jester
@Jester,是的,谢谢,我搞混了。我更新了代码。但现在我更感兴趣的是整体的情况,而不是汇编语言的细节(如果这有意义的话)。是的,我的意思是函数write是用汇编中的syscall实现的。我想这就是你所说的。 - Max Koretskyi
@AngularInDepth.com:我认为您是在询问有关汇编语言的某些“特殊性”。 - Rudy Velthuis
4个回答

6

TL;DR

syscall指令本身就像是一个高级跳转,它是一种硬件支持的方式,可以有效且安全地从非特权用户空间跳转到内核。
syscall指令跳转到一个内核入口点来分派调用。

在x86_64之前,还使用了另外两种机制: int指令和sysenter指令。
它们具有不同的入口点(32位内核中仍然存在,以及可以运行32位用户空间程序的64位内核中也存在)。
前者使用了x86中断机制,并且可能会与异常分派混淆(异常也使用了中断机制)。
但是,异常是虚假事件,而int用于生成软件中断,同样是一种高级跳转。


C语言不涉及系统调用,它依赖C运行时来执行与未来程序环境的所有交互。
C运行时通过特定于环境的机制实现上述交互。可能会有各种软件抽象层,但最终会调用操作系统API。
术语API用于表示一个协议,严格地说,使用API并不需要调用内核代码(趋势是在用户空间实现非关键函数以限制可利用的代码),这里我们只对需要特权切换的API子集感兴趣。
在Linux下,内核提供了一组可从用户空间访问的服务入口,这些入口点被称为系统调用
在Windows下,内核服务(通过与Linux类似的机制访问)被认为是私有的,因为它们不需要跨版本稳定。
而是使用一组导出的DLL / EXE函数作为入口点(例如ntoskrnl.exe、hal.dll、kernel32.dll、user32.dll),这些函数再通过一个(私有的)系统调用来使用内核服务。
请注意,在Linux下,大多数系统调用都有一个POSIX包装器,因此可以使用这些包装器(普通的C函数)来调用系统调用。
底层ABI不同,错误报告也不同;包装器在两个世界之间进行转换。

C运行时调用操作系统API,在Linux中直接使用系统调用,因为它们是公共的(在跨版本上稳定),而对于Windows,像kernel32.dll这样的常规DLL被标记为依赖项并使用。

我们被迫到了这么一个地步,即用户模式程序,无论是作为C运行时的一部分(Linux)还是作为API DLL的一部分(Windows),都需要调用内核中的代码。

x86架构在历史上提供了不同的方法,例如 call gate
另一种方法是通过int指令完成,它有一些优点:

  • 这是BIOS和DOS在其时代所做的。在实模式下,使用int指令是合适的,因为向量号(例如21h)比远地址(例如0f000h:0fff0h)更容易记住。
  • 它保存标志位。
  • 设置中断服务程序(ISR)相对容易。

随着体系结构的现代化,这种机制的缺点变得很大:它很慢。在引入sysenter指令之前,没有更快的替代方案(调用门也同样慢)。

随着Pentium Pro/II的问世[1],一对新的指令sysentersysexit被引入以加快系统调用。
Linux从版本2.5开始使用它们{{link2:},在32位系统上至今仍在使用。
我不会解释整个sysenter指令机制和伴侣VDSO的必要性,只需要说它比int机制更快(我找不到Andy Glew的文章,他说sysenter在Pentium III上变得很慢,我不知道它现在的表现如何)。
随着x86-64的出现,AMD对sysenter的回应,即syscall/sysret对,开始成为从用户模式到内核模式切换的事实标准。
这是因为sysenter实际上非常快速和简单(它将riprflags复制到rcxr11中,分别屏蔽rflags并跳转到IA32_LSTAR中设置的地址)。

64位版本的Linux和Windows都使用syscall

总之,可以通过三种机制将控制权交给内核:

  • 软件中断。
    这是32位Linux(2.5之前)的int 80h和32位Windows的int 2eh
  • 通过sysenter
    32位Linux自2.5以来一直在使用。
  • 通过syscall
    64位版本的Linux和Windows都在使用。

这是一个很好的页面,可以让它变得更好。

C运行时通常是静态库,因此预编译,并使用上述三种方法之一。

syscall指令将控制权直接转移给内核入口点(参见entry_64.s)。
它只是执行此操作的指令,不是由操作系统实现的,而是由操作系统使用的。

术语异常在计算机科学中有多重含义,C++、Java和C#都有异常。
操作系统可以具有语言无关的异常捕获机制(在Windows下曾经称为SEH,现已被重写)。
CPU也有异常。
我认为我们谈论的是最后一种含义。

Exceptions被通过中断分发,它们是一种中断。
毋庸置疑,虽然异常是同步的(它们发生在特定的、可重复的点上),但它们是“不需要的”,也就是说,程序员倾向于避免它们,当它们发生时,要么是由于一个错误,一个未处理的边缘情况或者一个糟糕的情况。
因此,它们不用于将控制传递到内核(它们可以这样做)。
软件中断(也是同步的)被用来代替;机制几乎完全相同(异常可以在内核堆栈上推送状态码),但语义不同。
我们从未解引用过空指针,访问未映射的页面或类似的方式来调用系统调用,而是使用了int指令。

3
我的理解是,C语言系统调用由编译器实现为具有相应汇编代码的syscall。这个理解正确吗?
不正确。
C编译器处理系统调用的方式与处理任何其他函数调用的方式相同:
; write(2, "There was an error writing to standard out\n", 44);
mov    $44, %edx
lea    .LC0(%rip), %rsi  ; address of the string
mov    $2, %edi
call   write

在libc(您的系统C库)中实现这些功能可能会包含syscall指令,或者在您的系统架构上等效的指令。


感谢您的回复!已点赞。我在选择用词上有些不当,实际上我是指 write 函数本身是作为 syscall 实现的。无论如何,您证实了我的预期。 - Max Koretskyi

3

编辑

是的,C应用程序调用了一个位于C库解决方案中的C库函数,该函数在系统特定调用或一组调用中被隐藏起来,使用体系结构特定的方式到达操作系统,后者设置了异常/中断处理程序来处理这些系统调用。实际上不必是体系结构特定的,可以简单地跳转/调用到一个众所周知的地址,但由于现代对安全和保护模式的需求,一个简单的调用将没有这些额外的特性,尽管功能上是正确的。

库的实现方式是实现定义的。编译器如何将您的代码连接到该库运行时或链接时间,有多种组合方式可以发生,没有一种方式可或需要发生,因此也是实现定义的。只要它功能上是正确的,并且不干扰C标准,则可以工作。

像Windows、Linux和手机等其他操作系统一样,我们有强烈的愿望将应用程序与系统隔离开来,以便它们不能以各种方式造成损害,因此需要保护,需要一种体系结构特定的方式来使函数调用进入操作系统,这不是正常的调用,因为它会切换模式。如果体系结构有多种方法可以做到这一点,那么操作系统可以选择一种或多种方法作为其设计的一部分。

“软件中断”是一种常见的方法,与硬件中断一样,大多数解决方案包括一张处理程序地址表,通过扩展该表并将某些向量绑定到由软件创建的“中断”(触发特殊指令而不是信号改变输入状态)但执行相同的停止、保存一些状态、调用向量等操作。


如果你看LC-3,它被称为TRAP。在ARM中,指令曾经被称为SWI,但现在是SVC,同样的机器码和功能,这些助记符至少由gnu汇编器支持,我想其他的也一样。实现在不同的架构上可能不完全相同,但总体功能是相似的。有一些架构有特定的指令,可以将写入uart的指令映射到地址空间中,很久以前这种做法还有点意义,今天你会在一些不一定运行操作系统的CPU上看到这种情况。 - old_timer
感谢您详细的回答。我可以建议您以“底线是,是的,您似乎理解了这一点…”开头回答,并在此之后提供详细信息吗? - Max Koretskyi
人们会期望这个系统调用是用汇编语言编写的,并且以目标文件的形式存储,以便链接器稍后可以选择它,对吗? - Max Koretskyi
您本应该期望如此,但不幸的是由于各种原因,人们使用内联汇编而不是真正的汇编是很常见的。 - old_timer
英特尔并没有将软件中断定义为唯一的系统调用机制,这是Linux的选择。他们本可以选择带有“远程调用”到特定描述符的调用门,因为在386中,英特尔提供了许多特权级功能(有关选项的详细信息,请参见OsDev syscall/sysret 和 sysenter/sysexit instructions enabling)。有趣的是,在386上进入内核的最快方法是非法指令陷阱,并且一些操作系统将其用作其系统调用机制。 - Peter Cordes
有趣的事实:我并不是那个意思。我的意思是,在Linux之前很久,甚至在Windows之前,以及我们今天仍然看到的方式中,他们进入“系统”调用的路径是“软件中断”。但正如你所指出的,不是英特尔,而是IBM选择使用这些软件中断来进行系统调用,而不是选择其他解决方案,这极大地帮助了8086的未来。(Microsoft等)然后,随着每个新操作系统的创建或移植,每个当前和新成员都有机会进行更改,同时也有新的选择。 - old_timer

2
这不是对问题的直接回答,但可能会让你感兴趣(我没有足够的Karma来评论)- 它详细解释了所有用户空间执行(包括glibc以及它如何执行系统调用)的细节。

http://www.maizure.org/projects/printf/index.html

您可能对“第8步-最终字符串写入标准输出”特别感兴趣:

And what does __libc_write look like...?

000000000040f9c0 <__libc_write>:
  40f9c0:  83 3d c5 bb 2a 00 00   cmpl   $0x0,0x2abbc5(%rip)  # 6bb58c <__libc_multiple_threads>
  40f9c7:  75 14                  jne    40f9dd <__write_nocancel+0x14>

000000000040f9c9 <__write_nocancel>:
  40f9c9: b8 01 00 00 00          mov    $0x1,%eax
  40f9ce: 0f 05                   syscall 
  ...cut...

Write simply checks the threading state and, assuming all is well, moves the write syscall number (1) in to EAX and enters the kernel.

Some notes:

  • x86-64 Linux write syscall is 1, old x86 was 4
  • rdi refers to stdout
  • rsi points to the string
  • rdx is the string size count
请注意,这是作者的x86-64 Linux系统。
对于x86,这提供了一些帮助:

http://www.tldp.org/LDP/khg/HyperNews/get/syscall/syscall86.html

在Linux下,系统调用的执行是通过可屏蔽中断或异常类转移来调用的,由指令int 0x80引起。我们使用向量0x80将控制权传递给内核。这个中断向量在系统启动时初始化,以及其他重要的向量,如系统时钟向量。
但对于Linux内核的一般答案:
我的理解正确吗?C语言系统调用是由编译器实现为具有相应汇编代码的syscall,这些syscall又由操作系统作为异常机制(中断)实现?
是的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接