Starnix 中的常见编码模式

本页面列出了特定于 Starnix 开发的常见编码模式和最佳实践。

Starnix 本质上是一个在用户空间中运行的内核,可在 Fuchsia 上运行 Linux 程序。这会给需要确保其代码生成 Linux 程序预期结果的开发者带来一些具体考虑因素。本页旨在阐明其中一些模式并提供最佳实践,涵盖测试、管理用户空间地址、处理错误消息等主题。

主题包括:

使用 Linux 二进制文件测试 Starnix

本部分介绍了测试 Starnix 功能的最佳实践,其中包括使用为 Linux 编译的二进制文件。

Starnix 的大部分测试覆盖率来自为 Linux 编译的用户空间二进制文件。Fuchsia 项目会在 Linux 和 Starnix 上运行这些二进制文件,以确保 Starnix 的行为与 Linux 一致。

用户空间单元测试用于验证 Linux UAPI,而这正是 Starnix 实现的。验证此级别的 Starnix 行为可让我们放心地重构 Starnix 的实现。

此外,Starnix 内核单元测试有助于验证系统的内部不变量。不过,如果您编写内核单元测试,请务必避免“更改检测器测试”。换句话说,即使实现更改在功能上是正确的,也要确保您编写的测试不会在实现更改时失败。

表示 Starnix 中的用户空间地址

本部分介绍了在 Starnix 的用户空间内表示和验证地址的最佳实践。

UserAddressUserRef 类型用于表示 Starnix 中位于“Linux 用户空间”(即受限地址空间)的地址。一旦确定 UserAddress 指向 T 类型的对象,就将其转换为 UserRef<T>。此方法可提供更多类型信息,从而更轻松地从用户空间读取和写入数据。

地址验证由 Fuchsia 的内存管理子系统执行。一般来说,内存管理器之外的代码不应在将地址传递给内存管理器之前执行任何检查来确定地址是否有效(例如,检查地址是否为非 null)。

良好

pub fn sys_something(current_task: &CurrentTask, user_events: UserRef<epoll_event>)
    -> Result <(), Errno> {
  let events = current_task.read_object(user_events)?;
  ...
}

pub fn sys_something(current_task: &CurrentTask, user_events: UserRef<epoll_event>)
    -> Result <(), Errno> {
  if user_events.addr().is_null() {
    return error!(EFAULT);
  }

  let events = current_task.read_object(user_events)?;
  ...
}

不过,此规则最常见的例外情况是,当提供的地址为 null 时,系统调用需要返回特定错误,例如:

pub fn sys_something(current_task: &CurrentTask, user_events: UserRef<epoll_event>)
    -> Result <(), Errno> {
  if user_events.addr().is_null() {
    // The memory manager would never return ENOSYS for a read_object at a null
    // address, so an explicit check is required.
    return error!(ENOSYS);
  }

  let events = current_task.read_object(user_events)?;
  ...
}

在 Starnix 中处理系统调用实参

本部分重点介绍围绕 SyscallArg 的最佳实践,SyscallArg 是所有 Starnix 系统调用实参的默认类型。

Starnix 系统调用实现的所有实参最初都是 SyscallArg。然后,使用 into() 特征将此类型转换为特定的系统调用实参类型。例如,在调度系统调用时,它将按如下方式调用:

match syscall_nr {
  __NR_execve => {
    sys_execve(arg0.into(), arg1.into(), arg3.into())
  }
}

这使得系统调用实现可以灵活地使用可转换为 SyscallArg 的任何类型。

良好

fn sys_execve(
    user_path: UserCString,
    user_argv: UserRef<UserCString>,
    user_environ: UserRef<UserCString>,
) -> Result<(), Errno> {
  ...
}

fn sys_execve(
    user_path: SyscallArg,
    user_argv: SyscallArg,
    user_environ: SyscallArg,
) -> Result<(), Errno> {
  ...
}

Starnix 中的安全错误处理

本部分讨论了在 Starnix 中使用 unwrap()expect() 方法的风险。

在 Starnix 中发生 panic 等同于在运行它的容器中发生内核 panic。这意味着,如果系统调用使用 unwrap()expect() 等 API,则不仅会导致引发错误的进程出现 panic,还会导致整个容器出现 panic。

良好

let value = option.ok_or_else(|| error!(EINVAL))?;

此示例是一种良好的实践,因为它使用 ok_or_else() 来处理错误情况。

let value = option.unwrap();

此示例是一种不好的做法,因为它可能会导致 panic 并使整个容器(而不仅仅是导致不变量被违反的进程)崩溃。

不过,如果错误确实无法恢复,则可以使用 unwrap()expect()。不过,其使用应包含一个上下文字符串,用于说明为什么内核 panic 是唯一选择。

Starnix 中的创建错误

本部分介绍了创建和翻译错误消息的最佳实践。

Starnix 使用名为 errno 的 Linux 错误代码封装容器类型。此类型非常有用,因为它可以捕获错误的来源位置,这在调试时很有帮助。创建新错误时,请使用 error!() 宏。

良好

if !name.entry.node.is_dir() {
  return error!(ENOTDIR, "Invalid path provided to sys_chroot");
}

此示例是一种良好的实践,因为 errno!() 宏提供了更多信息,有助于调试。

return error!(EINVAL);

此示例是一种不良做法,因为它返回的错误代码不包含任何上下文或错误信息。

此外,在将一个错误翻译成另一个错误时,使用 map_err()errno!() 宏可能很方便。

良好

let s = mm.read_c_string_to_vec(user_string, elem_limit).map_err(|e| {
  if e.code == ENAMETOOLONG {
    errno!(E2BIG)
  } else {
    e
  }
})?;

let s = match mm.read_c_string_to_vec(user_string, elem_limit) {
  Err(e) if e.code == ENAMETOOLONG => {
    errno!(E2BIG)
  },
  Err(e) => {
    e
  },
  ok => ok,
}?;

防止 Starnix 中的算术溢出

本部分强调了在处理来自用户空间的数值时使用经过检查的数学运算的重要性。

对于来自用户空间的数值,请始终使用经过检查的数学运算(例如 checked_add()checked_mul())。这样可以防止用户空间中的错误值导致内核中的算术运算溢出。

良好

pub fn sys_something(user_value: u32) -> Result<(), Errno> {
  let value = user_value.get()?;
  let result = value.checked_mul(2).ok_or_else(|| error!(EOVERFLOW))?;
  ...
}

此示例是一种良好的实践,因为代码使用 checked_mul() 对从用户空间检索到的值执行乘法运算。这样可确保,如果乘法运算发生溢出,系统会返回 EOVERFLOW 错误,而不是可能导致意外行为或崩溃。

pub fn sys_something(user_value: u32) -> Result<(), Errno> {
  let value = user_value.get()?;
  let result = value * 2; // Potential overflow here!
  ...
}

此示例是一种不良做法,因为代码直接将用户提供的值乘以 2,而未检查是否存在潜在的溢出。如果该值足够大,乘法运算可能会溢出,从而导致结果不正确或系统崩溃。

在 Starnix 中使用 FIDL 代理

本部分介绍了为什么 Starnix 在与 FIDL 协议交互时通常使用同步代理,这与某些 Fuchsia 组件不同。

由于 Starnix 的执行模型,它通常使用同步代理。具体而言,在处理 Linux 系统调用时,Starnix 代码会在调用 Linux 系统调用的用户程序的线程上运行。

由于该线程属于 Linux 程序,因此 Starnix 必须执行所请求的工作,然后将控制权返回给 Linux 程序。

此约束条件意味着,Starnix 正在执行的工作需要在返回控制权之前同步完成。

由于需要在返回之前完成工作,因此同步代理是最简单的解决方案。同步代理的性能也更高,因为它避免了切换到另一个线程并返回(使用异步代理需要一个单独的线程供异步执行器使用)。

在 Starnix 中生成线程

避免使用 std::thread::spawn

std::thread::spawn 会创建一个线程,该线程会成为调用进程的一部分。线程的生命周期将小于或等于调用进程的生命周期。

可能会出现哪些问题?

假设您在 Linux 进程的上下文中调用 std::thread::spawn。新生成的线程现在与相应 Linux 进程的生命周期相关联。当进程终止时,线程也会终止。如果线程在进程终止时恰好持有互斥锁(或类似资源),则该互斥锁将永远不会被释放,并且容器中的任何任务都将永远无法获取该互斥锁。

有何替代方案?

KernelThreads 的 spawn 方法通常是正确的选择。这些方法会创建成为主 Starnix 进程(有时称为原型进程)一部分的线程。主 Starnix 进程没有私有地址空间,并且会比容器中的所有 Linux 进程存活更长时间。

深入了解

如需详细了解 Starnix 的执行模型,请参阅: - 在 Fuchsia 中发出 Linux 系统调用 - RFC 0261:快速高效的用户空间内核模拟