System Calls

来自osdev
Zhang3讨论 | 贡献2022年1月24日 (一) 09:57的版本 (创建页面,内容为“系统调用用于从用户环境调用内核服务。 目标是能够以相关的特权从用户模式切换到内核模式。 提供的系统调用取决于你的 内核 的性质。 == 进行系统调用的可能方法 == === 中断 === 实现系统调用最常见的方法是使用软件 interrupt。 它可能是实现系统调用的最可移植的方式。 Linux传统上在x86上为此使用中断0x80。 其他系统可能具有固定的…”)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳到导航 跳到搜索

系统调用用于从用户环境调用内核服务。 目标是能够以相关的特权从用户模式切换到内核模式。 提供的系统调用取决于你的 内核 的性质。

进行系统调用的可能方法

中断

实现系统调用最常见的方法是使用软件 interrupt。 它可能是实现系统调用的最可移植的方式。 Linux传统上在x86上为此使用中断0x80。 其他系统可能具有固定的系统调用向量 (例如PowerPC或Microblaze)。

要做到这一点,你将不得不在汇编中创建你的中断处理程序。 这是因为你的系统调用ABI可能与编译器支持的正常ABI不对应。 因此有必要从中间做一下翻译。

例如,在i386上,linux内核按 eax,ebx,ecx,edx,esi,edi和ebp 的顺序获取其参数。 但是,ABI将所有参数以相反的顺序放置在堆栈上。 Linux继续在堆栈上构造 pt_regs 结构,并将指向它的指针传递给C函数以处理调用本身。 这可以简化为这样的东西:

Int128Handler:
    ; already on stack: ss, sp, flags, cs, ip.
    ; need to push ax, gs, fs, es, ds, -ENOSYS, bp, di, si, dx, cx, and bx
    push eax
    push dword gs
    push dword fs
    push dword es
    push dword ds
    push dword -ENOSYS
    push ebp
    push edi
    push esi
    push edx
    push ecx
    push ebx
    push esp
    call do_syscall_in_C
    add esp, 4
    pop ebx
    pop ecx
    [...]
    pop es
    pop fs
    pop gs
    add esp, 4
    iretd

许多保护模式操作系统使用EAX来保存函数代码。 DOS使用AX寄存器存储功能代码-服务的AH和服务的功能的AL,或者如果没有服务的功能的AH。 例如,假设你有read() 和write()。 从中断0A9h (任意选择,可能是错误的) 中,代码为1的read() 和2的write()。 你可以写

 IntA9Handler:
     CMP AH, 1
     JNE .write
     CALL _read
     JMP .done
 .write:
     CMP AH, 2
     JNE .badcode
     CALL _write
     JMP .done
 .badcode:
     MOV EAX, 0FFFFFFFFh
 .done:
     IRETD

但是,如果所有函数代码都是小的连续数字,则更好的选择可能是函数表,例如:

dispatch_syscall:
    cmp eax, NR_syscalls
    ja .badcode
    jmp [syscall_table+4*eax]
.badcode:
    mov eax, -ENOSYS
    ret

请注意,这假定syscall表为空。 如果表中有孔,则用指向返回错误代码的函数的指针填充它!

Sysenter/Sysexit (Intel)

正文: Sysenter

在Intel CPU上,从Pentium II开始,出现了新的指令对sysenter/sysexit。 通过限制更改模式的开销,它可以更快地从用户模式切换到内核模式。 sysenter入口点将已经设置了内核堆栈。 但是,sysenter绝对不保存状态,因此用户堆栈指针和返回地址都必须是众所周知的值,或者必须由导致sysenter的用户空间代码保存。 此外,除了无条件清除中断和VM标志之外,sysenter不会修改任何标志。

AMD已经创建了类似的指令对: Syscall/Sysret。 然而,这些指令的行为与英特尔的不同。 syscall入口点仍将加载用户空间堆栈,并且必须保存它并加载内核堆栈。 唯一合理的方法是通过CPU本地变量: 通过 swapgs 指令,可以加载CPU-local指针,在其后面可以保存用户堆栈指针,然后用内核值覆盖堆栈指针 (也可以保存在CPU-local变量之间)。在32位模式下,你会得到一个鸡和蛋的场景: 你不能保存任何寄存器到堆栈,因为有问题的堆栈是用户堆栈,因此不被信任或修改,并且在SMP系统中,你不能使用任何全局状态。 你需要保存几乎所有的寄存器,所以你不能修改它们。 因此,可能避免在32位模式下的syscall。

在64位模式下,可以通过SFMASK MSR修改标志寄存器。 原始RFLAGS值将保存在r11中。

请注意,尽管这些指令确实成对出现,但实际上没有必要将这些指令保持成对。 使用正确构造的堆栈框架,以 syscall 开始的系统调用可以以 iret 结束。

内核可以指定哪些寄存器被保留,哪些寄存器在SYSENTER或SYSCALL上丢失 (64位模式下的r11除外,它总是丢失) 作为其syscall ABI的一部分。 然后,它不需要保存所有寄存器,而只需要保存指定为保留的寄存器。 最常见的是使用的C调用约定。 通过使用调用SYSENTER或SYSCALL的微小汇编器存根,C编译器将保护调用者保存的寄存器。 然后,SYSENTER或SYSCALL的内核入口点可以是另一个小汇编程序存根,避免在为syscall调用C函数之前更改任何被调用者保存的寄存器。 这样,只需要保存用户空间堆栈指针 (和64位模式下的r11),因为其他所有内容要么由C编译器保留,要么被允许销毁。

请注意,出于安全原因,内核应将所有未在SYSENTER或SYSCALL上保留的寄存器归零,因此不会意外地将信息从内核泄漏到用户空间。

陷阱

一些操作系统通过以确定的方式触发CPU 陷阱 来实现系统调用,这样他们就可以将其识别为系统调用。 Solaris、L4以及其他一些硬件都采用了此解决方案。

例如,L4在x86上使用 “LOCK NOP” 指令。由于不允许对 “NOP” 指令执行锁定,因此触发了陷阱。 这种方法的问题在于,不能保证 “锁定NOP” 在未来的x86 CPU上具有相同的行为。 他们可能应该使用 “UD2” 指令,因为它是为此目的定义的。

呼叫门 (英特尔)

80386系列处理器提供各种呼叫门作为 GDT 的一部分。 调用门是一个远指针,可以调用类似于调用普通函数。 很少有操作系统使用呼叫门。

要在32位模式下使用呼叫门,必须在GDT中添加一个条目。 假设标准的两层体系结构 (内核为环0,用户为环3,环1和环2未使用),则该段的DPL需要为3,描述符的前两个字节和后两个字节 (即限制,标志,和高基字段) 需要设置为指向处理程序函数的指针,第二个两个字节 (低基字段) 需要设置为内核代码段选择器,中基字段可以包含一个参数计数 (从用户到内核堆栈最多可以复制31个DWORDs),并且访问字节必须这样设置: Pr必须为1,Privl必须为3,GDT 页声明的位必须为1 (在AMD文档中称为S) 实际上必须为0,并且低于该值1100导致段被视为32位呼叫门。

要使用门,用户空间代码必须使用远呼叫指令。 偏移量将被忽略。 假设门是GDT中的第一个条目,则必须请求段0x0b (偏移量8和RPL 3):

呼叫远0x0b:0

在64位模式下,描述符大小加倍,处理程序的高一半地址直接位于上述描述符的其余部分之后。 此外,参数计数必须为零,并且第二描述符的第二个DWORD必须全部为零。 否则,不会有任何变化。

传递参数

寄存器

将参数传递给系统调用处理程序的最简单方法是寄存器。 BIOS 以这种方式进行争论。

优点:

  • 非常快

缺点:

  • 限于可用寄存器的数量
  • 如果调用者在系统调用后需要其旧值,则必须保存/还原已使用的寄存器
  • 不安全 (如果调用者传递的参数比被调用者假定的更多/更少)

堆栈

也可以通过 堆栈 传递参数。

优点:

  • 嵌套的系统调用是可能的
  • 在C中实现系统调用处理程序很容易,因为C也使用堆栈将参数传递给函数
  • 不限

缺点:

  • 不安全 (如果调用者传递的参数比被调用者假定的更多/更少)

内存

传递参数的最后一种常见方法是将它们存储在内存中。 在进行系统调用之前,调用者必须在系统调用处理程序的寄存器中存储指向参数位置的指针。 (假设此位置不固定)

优点:

  • 不限
  • 安全

缺点:

  • 仍然需要一个寄存器
  • 如果不复制参数,嵌套系统调用是不可能的
  • 不安全 (如果调用者传递的参数比被调用者假定的更多/更少)

安保/安全影响

由于内核以比调用它的用户模式代码更高的特权运行,因此必须检查所有内容。 这不仅是担心恶意程序的偏执狂,而且还可以保护你的内核免受损坏的应用程序的侵害。 因此,有必要检查所有参数是否在范围内,以及所有指针是否为实际的用户土地指针。 内核可以在任何地方写入,但是你不希望特制的 read() 系统调用用零覆盖某些进程的凭据 (从而赋予它root访问权限)。

至于确保指针在范围内,检查它们是否指向用户或内核内存可能很难有效地完成,除非你正在编写 更高的一半内核。 为了检查所有用户空间访问是否有效,你可以使用 虚拟内存管理器 查看请求的字节是否映射,或者你可以访问它们并处理生成的页面错误。 Linux从版本2.6开始切换到后者。

在用户环境一侧

虽然开发人员可以手动触发系统调用,但提供一个库来封装这种调用可能是一个好主意。 因此,你将能够在不影响用户应用程序的情况下切换系统调用技术。

另一种方法是在内存中的某个地方有一个存根,内核放在那里,然后一旦你的寄存器被设置,调用该存根为你做实际的系统调用。 然后,你可以在加载时而不是编译时交换方法。

请注意,无论你提供什么库,都不能假定用户使用该存根调用系统。 如果有一半的机会,他们可以并且将直接致电系统。

策略结论

系统调用策略取决于平台。 你可能希望根据架构使用不同的策略,甚至根据硬件性能使用切换策略。 在 微内核 上,你可能还需要比在 单片内核 上更多的参数复制。

另见

线程

外部链接