内核与应用程序的接口

应用程序与操作系统内核之间的交互接口通常被称为内核的 API(Application Programming Interface)。这一 API 是操作系统为用户空间的应用程序提供的一组服务,它定义了应用程序如何请求内核执行某些任务或获取系统资源。在实际操作中,最常见的这种交互方式是通过 系统调用(System Call)。虽然系统调用表面上与普通函数调用类似,但它本质上更为复杂,因为它不仅涉及到跨越不同权限的边界,还要保证整个系统的安全性和稳定性。

系统调用 是用户空间程序访问内核服务的唯一合法途径。当一个应用程序希望执行诸如文件读写、进程管理或内存分配等操作时,它必须通过系统调用来实现。这是因为用户空间的应用程序被限制在较低权限的用户模式(User Mode)中,无法直接访问硬件资源或修改系统的核心数据。为了确保对系统资源的访问是在受控环境下进行,操作系统通过系统调用将这些敏感操作委托给内核态(Kernel Mode)来完成。

在系统调用执行时,应用程序首先发出请求,处理器随后从用户模式切换到内核模式。此时,操作系统接管控制权,开始执行内核中的代码。内核会检查请求是否合法并根据内核 API 提供相应的服务,比如读取磁盘文件、分配内存、或与外部设备进行通信。完成操作后,处理器会再次切换回用户模式,将控制权交还给应用程序,并返回操作结果。这一过程确保了应用程序只能通过规范的接口访问硬件资源和系统数据,从而防止应用程序直接操作硬件或破坏系统的稳定性。

系统调用的设计目的不仅是为了实现应用程序和内核之间的功能交互,更重要的是为了提供一种安全性机制。通过将用户态与内核态分隔,操作系统可以有效地防止用户程序直接访问或篡改内核数据,避免意外或恶意操作导致系统崩溃。此外,系统调用的这种模式也为系统提供了更高的灵活性,操作系统可以对系统调用的实现进行优化或扩展,而无需改变应用程序的代码。

在本节的后续内容中,我们将深入探讨系统调用的具体实现方式,尤其是在现代操作系统中的工作原理。通过对系统调用的源代码和执行过程的详细分析,我们可以逐步理解操作系统如何为应用程序提供底层支持。同时,我们也会通过实际编程实践,展示如何编写和调用系统调用,进一步了解它们在操作系统中的关键作用。

系统调用的实际例子

在操作系统中,系统调用是用户空间程序与内核交互的关键机制。尽管系统调用在代码中看起来像普通的函数调用,但实际上它们触发了从用户模式到内核模式的切换,从而执行内核中的特定操作。

1. open 系统调用

#![allow(unused)]
fn main() {
// user/src/file.rs
pub fn open(path: &str, flags: OpenFlags) -> isize {
    sys_open(path, flags.bits)
}

// user/src/syscall.rs
pub fn sys_open(path: &str, flags: u32) -> isize {
    syscall(SYSCALL_OPEN, [path.as_ptr() as usize, flags as usize, 0])
}

// os/src/syscall/fs.rs
pub fn sys_open(path: *const u8, flags: u32) -> isize {...}
}

当应用程序需要打开一个文件时,调用 open 系统调用,并将文件名作为参数传递给系统。假设我们需要打开一个名为 “out” 的文件,同时希望对文件进行写操作,可以使用类似以下代码的方式进行调用:

#![allow(unused)]
fn main() {
let fd = open("out", OpenFlags::WRONLY);
}
  • "out" 是文件的路径。
  • OpenFlags::WRONLY 表示我们希望以只写模式打开文件。

rCore中,文件的打开过程也是通过系统调用与内核进行交互的,但由于 rCore 使用 Rust 编写,系统调用的接口和内部处理与传统的 C 语言操作系统(如 xv6)有所不同。

系统调用实现(sys_open

rCore 的用户态代码中,sys_open 函数用于封装用户程序与内核的交互。该函数负责向内核发起 open 系统调用,请求内核打开文件并返回文件描述符。

#![allow(unused)]
fn main() {
pub fn sys_open(path: &str, flags: u32) -> isize {
    syscall(SYSCALL_OPEN, [path.as_ptr() as usize, flags as usize, 0])
}
}
  • path:文件路径,通过传递字符串引用。
  • flags:打开文件的标志位,表示文件的操作模式,例如只读、只写等。
  • syscall:这是系统调用的通用接口,它负责通过 SYSCALL_OPEN 系统调用号与内核通信。syscall 函数将 pathflags 等参数转换为整数形式传递给内核。
  • path.as_ptr() as usize:将文件路径的指针转换为 usize 类型,以便传递给内核。

调用 syscall 后,系统会切换到内核模式,内核接收这些参数,并查找文件的磁盘位置,然后为文件分配一个文件描述符(File Descriptor, fd)。

用户态接口 (open 函数)

在用户程序中,open 函数进一步封装了 sys_open 系统调用,提供了更高层次的抽象,让用户更方便地使用 open 系统调用。

#![allow(unused)]
fn main() {
pub fn open(path: &str, flags: OpenFlags) -> isize {
    sys_open(path, flags.bits)
}
}
  • path:文件路径,表示需要打开的文件名。
  • flags:这是 OpenFlags 枚举类型,用来指定文件的打开模式,例如只读、只写等。通过 flags.bits 获取具体的标志位值。
  • sys_open:调用封装好的 sys_open,将文件路径和标志位传递给系统,实际执行系统调用并返回文件描述符。

内核的处理

sys_open 被调用时,rCore 内核会处理来自用户态的系统调用,执行文件的查找、权限检查等操作,并最终返回文件描述符。文件描述符是一个整数,用于标识已打开的文件,它为后续的文件操作(例如读写)提供了句柄,简化了文件的操作流程。

OpenFlags 枚举

rCore 中,文件操作模式是通过 OpenFlags 枚举类型来定义的。这些标志位与传统操作系统中的文件操作模式类似:

#![allow(unused)]
fn main() {
bitflags! {
    pub struct OpenFlags: u32 {
        const RDONLY = 0;
        const WRONLY = 1 << 0;
        const RDWR = 1 << 1;
        const CREATE = 1 << 9;
        const TRUNC = 1 << 10;
    }
}
}
  • RDONLY:只读模式。
  • WRONLY:只写模式。
  • RDWR:读写模式。
  • CREATE:如果文件不存在,则创建该文件。
  • TRUNC:如果文件存在,打开时清空文件内容。

2. write 系统调用

rCore 中,write 系统调用的实现与传统的 xv6 逻辑类似。它负责将数据从用户空间缓冲区写入到指定的文件中。在 Rust 中,由于其所有权模型和安全检查机制,这一过程变得更加复杂和安全。

#![allow(unused)]
fn main() {
// user/src/file.rs
pub fn write(fd: usize, buf: &[u8]) -> isize {
    sys_write(fd, buf)
}

// user/src/syscall.rs
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}

// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    let token = current_user_token();
    let process = current_process();
    let inner = process.inner_exclusive_access();
    if fd >= inner.fd_table.len() {
        return -1;
    }
    if let Some(file) = &inner.fd_table[fd] {
        if !file.writable() {
            return -1;
        }
        let file = file.clone();
        // release current task TCB manually to avoid multi-borrow
        drop(inner);
        file.write(UserBuffer::new(translated_byte_buffer(token, buf, len))) as isize
    } else {
        -1
    }
}
}

当用户想要向一个已打开的文件写入数据时,会调用 write 系统调用,并传递以下参数:

  • 文件描述符(fd),表示要写入的文件,由 open 返回。
  • 数据缓冲区(buffer),这是一个字节数组,包含要写入的数据。
  • 要写入的数据长度(len),指定需要写入的字节数。

rCore 中,用户态和内核态的 write 调用分别由 user/src/syscall.rsos/src/syscall/fs.rs 中的代码实现。

用户态的 write 实现

#![allow(unused)]
fn main() {
// user/src/syscall.rs
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
}
  • sys_write 函数

    • fd: usize:文件描述符,表示已经打开的文件。
    • buffer: &[u8]:这是一个字节数组切片,表示要写入的数据缓冲区。
    • 返回值类型是 isize,用于指示系统调用的结果,成功时返回写入的字节数,失败时返回负数(通常为 -1)。
  • syscall 函数

    • syscall 是一个通用的系统调用接口,负责将系统调用号和参数传递给内核,并等待内核返回结果。这里传递的参数包括:
      • SYSCALL_WRITE:表示 write 系统调用的系统调用号。
      • [fd, buffer.as_ptr() as usize, buffer.len()]:参数列表,其中:
        • buffer.as_ptr():获取缓冲区的指针,指向数据在内存中的起始地址。
        • buffer.len():获取缓冲区的长度,即要写入的字节数。

用户态通过 sys_write 调用,向内核发送系统调用请求,请求内核将 buffer 中的数据写入到文件。

内核态的 write 实现

#![allow(unused)]
fn main() {
// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    let token = current_user_token();
    let process = current_process();
    let inner = process.inner_exclusive_access();
    if fd >= inner.fd_table.len() {
        return -1;
    }
    if let Some(file) = &inner.fd_table[fd] {
        if !file.writable() {
            return -1;
        }
        let file = file.clone();
        // release current task TCB manually to avoid multi-borrow
        drop(inner);
        file.write(UserBuffer::new(translated_byte_buffer(token, buf, len))) as isize
    } else {
        -1
    }
}
}
内核态函数暂时不做要求
  • sys_write 函数
  • fd: usize:文件描述符,用来标识要写入的文件。
  • buf: *const u8:指向用户空间缓冲区的指针,表示要写入的数据。
  • len: usize:要写入的字节数。

步骤:

  1. 获取当前用户进程信息
  • current_user_token():获取当前用户进程的令牌(token),用于用户态与内核态之间的内存转换。
  • current_process():获取当前执行的进程信息。
  • process.inner_exclusive_access():通过独占访问锁(inner_exclusive_access)获取进程的内部信息(例如文件描述符表)。
  1. 检查文件描述符的有效性
  • if fd >= inner.fd_table.len() { return -1; }:检查传入的文件描述符 fd 是否超出当前进程的文件描述符表长度。如果文件描述符无效,则返回 -1。
  1. 检查文件是否可写
  • if !file.writable() { return -1; }:检查文件是否具有可写权限。如果文件不可写,也返回 -1。
  1. 克隆文件句柄并释放锁
  • let file = file.clone();:克隆文件句柄,避免出现多次借用问题。
  • drop(inner):手动释放对进程内核对象的独占访问锁,确保在后续操作中不会重复借用。
  1. 写入数据到文件
  • file.write(UserBuffer::new(translated_byte_buffer(token, buf, len)))
    • 首先,通过 translated_byte_buffer(token, buf, len) 将用户空间的缓冲区地址翻译为内核可访问的地址。
    • 然后,构造一个新的 UserBuffer,用于表示用户态的字节缓冲区。
    • 最后,调用 file.write() 函数,将 UserBuffer 中的数据写入文件。
  1. 返回写入字节数
  • file.write() 的返回值是写入的字节数,返回给用户态程序。如果写入失败,返回 -1。

用户态调用接口

#![allow(unused)]
fn main() {
// user/src/file.rs
pub fn write(fd: usize, buf: &[u8]) -> isize {
    sys_write(fd, buf)
}
}

这个函数封装了对 sys_write 的调用,进一步简化了用户程序的使用接口。用户只需传递文件描述符和缓冲区,调用 write 函数即可完成数据写入操作。

内核态与用户态和系统调用

在操作系统中,内核态用户态 是两种运行模式。它们提供了不同的权限级别,来保障系统安全性和稳定性。在讨论 系统调用 时,用户态和内核态有各自的作用和层次,这两者的协同工作是系统正常运行的基础。

内核态和用户态

  • 用户态(User Mode):运行普通应用程序的模式,权限有限,不能直接访问硬件或执行某些敏感操作(如 I/O 操作、内存管理等)。所有与硬件、资源管理相关的操作都必须通过系统调用请求内核来执行。
  • 内核态(Kernel Mode):运行操作系统内核和核心服务的模式,拥有最高权限,可以直接访问硬件资源,管理系统资源并执行重要的低级操作。系统调用会从用户态切换到内核态,让内核代替应用程序完成这些操作。

系统调用的概念

系统调用(System Call)是用户态程序与内核通信的机制。用户态程序需要通过系统调用来请求内核执行某些操作,例如文件读写、内存分配、进程管理等。

在用户态,系统调用看起来像是普通函数调用,但实际上,系统调用会触发 特权指令,将程序从用户态切换到内核态,内核负责处理请求后返回结果。

用户态和内核态在系统调用中的视角与作用

系统调用在不同的层次中有不同的表现,从用户态到内核态,每个层次都有其特定的工作。我们用一个 文件写操作(write 系统调用) 为例,展示其在不同层次的实现和视角。

(1) 用户态的视角:系统调用封装

在用户态,开发者看不到内核的细节,只能调用高层次的系统调用接口。以 rCore 用户态的 write 系统调用为例:

#![allow(unused)]
fn main() {
// 用户态:user/src/syscall.rs
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
}

用户态执行过程

  1. 封装系统调用sys_write 是一个封装好的系统调用函数。用户态程序调用 sys_write,传递文件描述符 fd 和写入数据 buffer
  2. 参数准备:将参数打包成标准的形式(如将缓冲区指针转换为整数,准备文件描述符和长度)。
  3. 发起系统调用syscall(SYSCALL_WRITE, ...) 触发系统调用,通过硬件机制(如中断陷入指令)将程序从用户态切换到内核态,并将请求传递给内核。

(2) 内核态的视角:处理系统调用

在内核态,操作系统接收到系统调用请求后,会处理这些用户态程序无法完成的任务。在 rCore 中,write 系统调用在内核的实现如下:

#![allow(unused)]
fn main() {
// 内核态:os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    let token = current_user_token();
    let process = current_process();
    let inner = process.inner_exclusive_access();
    
    // 检查文件描述符是否有效
    if fd >= inner.fd_table.len() {
        return -1;
    }

    // 查找文件并判断是否可写
    if let Some(file) = &inner.fd_table[fd] {
        if !file.writable() {
            return -1;
        }
        let file = file.clone();
        
        // 释放锁以防止重复借用
        drop(inner);
        
        // 将用户态缓冲区中的数据写入文件
        file.write(UserBuffer::new(translated_byte_buffer(token, buf, len))) as isize
    } else {
        -1
    }
}
}

内核态执行过程

  1. 参数接收:内核接收到用户态传递的参数,例如文件描述符 fd 和数据缓冲区指针 buf
  2. 用户态到内核态的缓冲区转换:由于用户态和内核态处于不同的地址空间,内核需要通过 translated_byte_buffer 函数将用户态缓冲区地址转换为内核态可访问的地址。
  3. 权限检查:内核检查文件描述符是否有效,并确保该文件具有写权限。如果检查失败,则返回错误码。
  4. 实际操作:内核调用内部的文件写入函数,将数据写入到对应的文件系统或设备中。
  5. 返回结果:操作完成后,内核返回写入的字节数或错误码,并切换回用户态。

(3) 硬件的视角:用户态到内核态的切换

用户态程序通过系统调用进入内核态的过程涉及硬件机制:

  1. 触发系统调用:系统调用通常是通过一条特殊的指令触发的,例如 syscall(在 x86-64 架构上)或 ecall(在 RISC-V 上)。这条指令会产生一个陷入(trap)或中断,导致处理器从用户态切换到内核态。

  2. 进入内核态:处理器根据陷入向量跳转到内核的系统调用处理函数。陷入后,处理器会切换到内核态运行,并保护当前的用户态上下文。

    RISC-V 架构中,处理系统调用或异常的机制使用 陷入向量。关键寄存器:

    • stvec(Supervisor Trap Vector Base Address Register):用于存储系统调用或异常处理函数的基地址。当发生系统调用或异常时,处理器会根据 stvec 的值跳转到对应的地址执行陷入处理函数。
    • 参见 lzzs xv6 notebook: usertrap
  3. 保存上下文:内核会保存当前的用户态寄存器状态,以便在系统调用结束后能恢复用户态程序的执行。

系统调用流程总结

从系统调用的全过程来看,用户态和内核态的工作是密切相关的。用户态和内核态的系统调用处理过程可以分为几个关键步骤:

  1. 用户态

    • 发起系统调用,提供需要的参数(如文件描述符、缓冲区、数据长度等)。
    • 用户态程序并不知道内核的具体实现细节,它只关心系统调用的接口和结果。
  2. 内核态

    • 接收并处理系统调用,进行一系列操作,如权限检查、资源管理、硬件访问等。
    • 内核负责实际的操作,如与文件系统交互、与设备交互等。
    • 返回处理结果(如写入的字节数或错误码)并切换回用户态。
  3. 硬件层

    • 实现用户态与内核态的隔离,通过硬件机制确保用户态程序不能直接访问内核权限级别的资源。
    • 提供特权指令或陷入机制,允许用户态程序通过系统调用进入内核。

示例流程:文件写操作的系统调用

举例来说,用户态程序调用 write(fd, buffer) 的全过程如下:

  1. 用户态

    • 程序调用 sys_write(fd, buffer),封装参数并通过 syscall函数使用 **ecall**指令触发系统调用。
  2. 陷入内核

    • ecall触发处理器切换到内核态,内核接管执行。
  3. 内核态

    • 内核的系统调用处理函数 sys_write(fd, buf, len) 接收参数。
    • 内核检查文件描述符的有效性、权限,确保写入操作合法。
    • 内核将数据从用户态缓冲区拷贝到内核缓冲区(必要时进行地址转换)。
    • 内核调用文件系统接口,将数据写入文件。
    • 完成写入操作后,返回结果(如写入的字节数),并切换回用户态。
  4. 用户态继续执行

    • 系统调用结束,程序继续执行,处理返回的结果。

总结

  • 用户态 的视角:系统调用像普通函数一样,但其实底层涉及内核操作。
  • 内核态 的视角:接收用户的系统调用请求,进行底层的资源管理和硬件交互操作。
  • 硬件层 提供用户态和内核态的隔离机制,并通过陷入机制实现状态切换。

这层次化的设计保证了系统的安全性和稳定性,同时也为程序提供了强大的操作能力。

系统调用与syscall() 函数

系统调用(System Call)是操作系统提供给用户态程序与内核进行交互的一种机制。用户态程序无法直接访问硬件资源或者执行某些敏感的操作(如进程管理、文件操作等),因此需要通过系统调用向内核发出请求,执行这些操作。在用户态程序中,系统调用的接口通常封装成类似函数的调用形式,但其本质是通过硬件机制将执行权限从用户态切换到内核态。

rCore 操作系统中,系统调用通过 syscall() 函数来封装,这个函数根据不同的系统调用 ID 和参数,执行相应的操作。

syscall() 函数

#![allow(unused)]
fn main() {
// user/src/syscall.rs
fn syscall(id: usize, args: [usize; 3]) -> isize {
    let mut ret: isize;
    unsafe {
        core::arch::asm!(
            "ecall",                              // 触发系统调用
            inlateout("x10") args[0] => ret,      // 参数 1 传递给 x10 寄存器,返回值也通过 x10 返回
            in("x11") args[1],                    // 参数 2 传递给 x11
            in("x12") args[2],                    // 参数 3 传递给 x12
            in("x17") id                          // 系统调用号传递给 x17
        );
    }
    ret
}
}

syscall() 是系统调用的核心,它封装了系统调用的执行流程。这个函数的执行步骤如下:

  1. 传递系统调用号:系统调用号(id)被传递给寄存器 x17,用于告诉内核应该执行哪种系统调用。

  2. 传递参数:系统调用的三个参数存储在 x10, x11, x12 寄存器中,这些寄存器会传递到内核中的系统调用处理程序。

  3. 触发系统调用:使用 ecall 指令触发系统调用,中断当前的用户态执行,进入内核态。

  4. 返回结果:系统调用的返回值通过 x10 寄存器返回给用户程序,存储在 ret 变量中。

    RISC-V 处理器有 32 个通用寄存器(x0x31),其中某些寄存器被预先规定用于特定的目的,比如存储函数参数、返回值、临时值和保存调用者的上下文等。

    RISC-V 架构中,函数调用(包括系统调用)的参数传递返回值处理是由 RISC-V ABI 和指令集规范定义的。具体来说,不论是普通函数还是系统调用,参数和返回值都通过指定的寄存器进行传递。这些寄存器的使用规则在 RISC-V Calling Convention 中有明确的定义。

典型系统调用的实现

1. sys_open() 系统调用

sys_open() 是用于打开文件的系统调用。在用户态,sys_open() 封装了文件打开操作,它将文件路径和打开模式传递给内核,通过 syscall() 来执行。

#![allow(unused)]
fn main() {
pub fn sys_open(path: &str, flags: u32) -> isize {
    syscall(SYSCALL_OPEN, [path.as_ptr() as usize, flags as usize, 0])
}
}
  • 系统调用号SYSCALL_OPEN 指定了这是一个文件打开操作。
  • 参数
    • path.as_ptr() as usize:文件路径的指针地址被传递给内核。
    • flags as usize:指定文件的打开模式(如只读、只写等)。

内核处理文件打开请求后,返回一个文件描述符,该描述符用于后续的文件操作(如读写)。

2. sys_write() 系统调用

sys_write() 是用于向文件或标准输出写入数据的系统调用。该系统调用将文件描述符、写入的缓冲区和缓冲区的长度作为参数传递给内核。

#![allow(unused)]
fn main() {
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
}
  • 系统调用号SYSCALL_WRITE 指定了这是一个写操作。
  • 参数
    • fd:文件描述符,指定要写入的文件或输出流。
    • buffer.as_ptr() as usize:数据缓冲区的指针。
    • buffer.len():要写入的数据长度。

内核会根据文件描述符,将缓冲区中的数据写入到指定的文件或设备中。

其他系统调用

sys_opensys_write 的系统调用模式适用于大多数其他系统调用,流程基本相同:

  1. 将系统调用号和参数传递给 syscall() 函数。
  2. 通过 ecall 触发内核执行操作。
  3. 获取结果并返回给用户态程序。

类似的系统调用有:

  • sys_read():读取文件内容。
  • sys_close():关闭打开的文件描述符。
  • sys_fork():创建一个新的进程。
  • sys_exit():退出当前进程。

每个系统调用根据不同的操作会传递不同的参数,但它们的工作机制都是类似的,通过 syscall() 函数向内核发起请求,执行系统级的操作。更多 rCore 系统调用参见user/src/syscall.rs

总的来说,系统调用是用户态程序与内核进行交互的桥梁。在 rCore 中,syscall() 函数封装了系统调用的执行流程,用户态程序通过调用相应的系统调用函数(如 sys_opensys_write 等),间接调用内核提供的服务。系统调用的机制保证了用户态和内核态的隔离,确保了系统的安全性和稳定性。

接下来的章节将开始深入探讨如何通过这些系统调用实现更复杂的系统操作,如进程管理、文件操作以及设备交互。