Google的新操作系统Fuchsia的内核设计

本文有 15492 字,大约需要 38 分钟可以读完, 创建于 2019-10-06

传说中的Google的下一代操作系统项目Fuchsia一直处于“犹抱琵琶半遮面”的半公开状态,只是它的代码早就开源出来一段时间了,多有人猜测它是不是下一代的大杀器,将来会通吃目前的Android和Chrome OS生态圈;然而官方的说法却一致是含糊不清。 由于最近的一些政治事件引起的余波,技术圈又有不少人讨论这个稍微有些神秘的操作系统来。

本文尝试就其目前官方公布的技术文档就其架构设计做一简单分析。

内核架构

Fuchsia其实是基于一个微内核风格的架构,内核项目的名字叫做Zircon。Fuchsia本身是程序员直接面对的操作系统环境, 它和Ziron的关系就像Ubuntu和Linux之间的关系一样类似。Zircon作为一个内核项目,它所提供的功能和传统的Linux、Windows、MacOS/Darwin的功能类似,都是实现对底层的硬件资源管理,同时提供系统调用接口给上层程序使用。

微内核和宏内核的争论已经是一个历久弥新的话题,从学术界到工业界双方各执一词,几十年来争吵不休,因为两者的优势和劣势都非常明显

  • 微内核由于选择将内核的组织用分布式的思路解耦开来,从设计上来说比较漂亮容易扩展但是最大的不足是性能太差
  • 宏内核则对应于软件架构上的大一统、集中式单体风格,所有的东西都放在内核中;从设计上来说非常丑陋不看维护成本高昂但是胜在性能很好

学术界一致在讨伐以Linux为代表的宏内核架构是设计落后的表现,却被Linus直接打脸说微内核的想法在现在的硬件条件下根本就是在浪费资源,想要高效地完成实际的复杂工作,必须要采用宏内核的思路; 之前几十年的计算机系统发展历史印证了Linus实用主义思想的胜利, 而普通的PC用户日常离不开的Windows系统,它的内核却干脆在多年的打磨和演化中,坚持用混合内核的思路来不断权衡两种设计的比重。

Zircon直接旗帜鲜明地宣称它是一个微内核架构,让人自然而然地好奇它怎么设计来避免传统的微内核架构需要重复拷贝数据性能低下的弊端。

Zircon顶层概念设计

传统的Unix/Linux内核设计秉承一切皆文件的设计理念(Eric S Raymond在Unix编程哲学一书中详细地阐述了这一理念并对这一简单设计推崇备至);Zircon完全没有采用这一理念,它另起炉灶用面型对象的思想来假设所有的内核所管理的东西都是对象;即一切接对象。 Small Talk的发明人Alan Kay的思想终于照耀进了最底层的操作系统设计领域而不再是中看不中用不能解决实际问题的花架子。

这些对象是通过句柄(handle)的方式在系统调用中直接暴露给用户使用的,对象本身在实现上均继承自一个名字为Dispatcher的抽象类;稍微翻阅它的代码,就可以找到其定义包含在/kernel/object/include/object/dispatcher.h中。

Dispatcher定义

Dispatcher的定义声明如下

class Dispatcher : private fbl::RefCountedUpgradeable<Dispatcher>,
                   private fbl::Recyclable<Dispatcher> {
public:
    using fbl::RefCountedUpgradeable<Dispatcher>::AddRef;
    using fbl::RefCountedUpgradeable<Dispatcher>::Release;
    using fbl::RefCountedUpgradeable<Dispatcher>::Adopt;
    using fbl::RefCountedUpgradeable<Dispatcher>::AddRefMaybeInDestructor;
    // Dispatchers are either Solo or Peered. They handle refcounting
    // and locking differently.
    virtual ~Dispatcher();

从类体系结构上看,它用私有继承的方式(C++中私有继承一般并不用来提供传统的接口继承而是仅仅复用代码)实现了基于引用计数的生存周期管理。

于此同时它还提供了基于引用计数的接口操作定义

void increment_handle_count() { handle_count_.fetch_add(1, ktl::memory_order_seq_cst); }
// Returns true exactly when the handle count goes to zero.
bool decrement_handle_count() {
    return handle_count_.fetch_sub(1, ktl::memory_order_seq_cst) == 1u;
}
uint32_t current_handle_count() const {
    return handle_count_.load(ktl::memory_order_seq_cst);
}

该接口上可以添加或者删除Observer

    virtual zx_status_t AddObserver(StateObserver* observer);
    bool RemoveObserver(StateObserver* observer);
    void Cancel(const Handle* handle);

最后一个cancel接口可以实现停止某个observer对handle状态的监控。

针对具体实现子类的接口

剩下的一些虚函数接口定义则是为了方便实现类似于模板方法这一设计模式而提供给具体的对象实现的

    virtual zx_obj_type_t get_type() const = 0;
    virtual zx_status_t user_signal_self(uint32_t clear_mask, uint32_t set_mask) = 0;
    virtual zx_status_t user_signal_peer(uint32_t clear_mask, uint32_t set_mask) = 0;
    virtual void on_zero_handles() {}
    //...

内核对象的ID

内核中的所有对象都有一个唯一的ID标识符,用koid来表示;它本身是一个64bit的整形无符号数字;并且该ID在系统运行期间从来不会被回收重用。 理论上来说,有边界的正整数总会有用完的一天,只是在务实主义者的严重,这个其实完全都不能算作一个问题,因为系统中出现重复的概率实在堪比行星撞地球了。

Handle

用户空间的代码如果想访问内核服务中的对象,必须通过handle句柄来完成。句柄的概念和Linux里面的文件描述符的概念比较类似。 设计思路上来说,它采用了将资源索引和具体资源实现隔离的方法;使得更灵活的系统演进和扩展变得更加容易。

Handle在用户空间的程序看来其实是一个32位的整数值,用zx_handle_t来定义。用户程序和内核打交道的时候,需要将一个事先从内核获取的handle对象(通过一系列创建资源的系统调用)作为参数传递进去;指明需要操作的对象。 这样系统调用的格式上大都携带一个所要操作的资源的标识,从而让内核直到作用在哪个内核对象上。

handle和object的对应和校验

显然,对于用户空间的进程来说,多个不同的进程可能需要访问同一个内核资源,此时它们需要有不同的handle而不能共享同一个handle; 任何时候用户通过系统调用传入一个handle的时候,内核都会做相应的校验

  • 合法性校验:对应的资源handle是否存在内核为用户进程创建的handle表里,即用户不能随意创建一个自定义的handle;这样可以有效避免意外的资源撞车;同时这也暗含了每个进程必然有一个类似于handle资源表的数据结构以供内核来查验
  • 类型校验:对应的资源的类型和系统调用所对应的类型是否一致
  • 安全校验:用户空间的程序是否有合理的权限来操作访问对应的内核资源;这个设计在当今的网络安全环境下尤其重要
handle和资源的单一绑定

一个内核对象资源可以绑定于多个handle,反过来对于某个具体的handle而言它却只能绑定于一个具体的资源。并且这个绑定被设计为要么绑定于内核资源对象,要么绑定于用户空间程序;同时只有绑定于用户空间的时候,它才是对用户程序而言可见的(采用上述的整数数值的方式)。

当这个handle被绑定于内核的时候,我们称它为处于迁移中(in-transit)状态。

用户空间的handle值

不同用户程序中的同一个handle值之间没有逻辑上的直接关系;它们可以指向不同类型的资源,或者对一个进程有意义的整数值对另外一个进程可能完全没有意义。 同时关于handle值目前有一些特殊的限定用法

  • 0被定义为非法的handle值,而其它的合法的handle必然是一个大于0的正整数。这是一个自然而然的设计,有利于实际的比较
  • 多个handel可能归属于同一个集合中,此时最低的两个比特位表示它属于哪个集合,并可以用ZX_HANDLE_FIXED_BITS_MASK来获取掩码
内核中的handle

内核态的handle实际上是一个C++对象,它从逻辑上包含三个逻辑部分

  • 对实际内核对象的引用
  • 被允许的操作权限定义
  • 实际被绑定的进程的信息 权限和进程绑定的语义意味着内核允许同一个用户进程来创建不同handle来访问同一个内核对象,只要它们的权限不同就可以。
Handle定义

内核中的类定义在kernel/object/include/object/handle.h文件中,它是一个标记为final的具体类; 类开头的注释说吗了它维护了进程和关联的内核对象的关联信息。

// A Handle is how a specific process refers to a specific Dispatcher.
class Handle final {
 public:
  // Returns the Dispatcher to which this instance points.
  const fbl::RefPtr<Dispatcher>& dispatcher() const { return dispatcher_; }

  // Returns the process that owns this instance. Used to guarantee
  // that one process may not access a handle owned by a different process.
  zx_koid_t process_id() const { return process_id_.load(ktl::memory_order_relaxed); }
   
  // Sets the value returned by process_id().
  void set_process_id(zx_koid_t pid);

为了避免不同的进程访问,类结构里面保存了它所关联的进程ID信息。

针对内核资源的引用计数方面,该类提供了丰富的读取和查询操作;并提供接口返回权限检查操作。

  // Returns the |rights| parameter that was provided when this instance
  // was created.
  uint32_t rights() const { return rights_; }

  // Returns true if this handle has all of the desired rights bits set.
  bool HasRights(zx_rights_t desired) const { return (rights_ & desired) == desired; }
  
  // Returns a value that can be decoded by Handle::FromU32() to derive a
  // pointer to this instance.  ProcessDispatcher will XOR this with its
  // |handle_rand_| to create the zx_handle_t value that user space sees.
  uint32_t base_value() const { return base_value_; }
  
  //...
  // Get the number of outstanding handles for a given dispatcher.
  static uint32_t Count(const fbl::RefPtr<const Dispatcher>&);
内核对象的封装

同样的头文件中还包含了一个管理内核对象生存周期的KernelHandle类。因为这里都是C++代码,我们可以借用C++语言的语义,从它的定义可以看出内核对象使用的是典型的移动语义,即不允许复制但是可以移动。 由于这是内核的代码,并且C++标准库中的智能指针被禁用了(参考这里的C++语言使用说明),所以该类的实现基本就是重复了C++标准库的做法

template <typename T>
class KernelHandle {
private:
  template <typename U>
  friend class KernelHandle;
  fbl::RefPtr<T> dispatcher_;

 public:
  KernelHandle() = default;

  explicit KernelHandle(fbl::RefPtr<T> dispatcher) : dispatcher_(ktl::move(dispatcher)) {}
  ~KernelHandle() { reset(); }
  
  // Movable but not copyable since we own the underlying Dispatcher.
  KernelHandle(const KernelHandle&) = delete;
  KernelHandle& operator=(const KernelHandle&) = delete;

这个类是一个针对抽象资源的封装,并且对用传入的内核对象引用来构造的方式,采用了直接的move操作来替换所有权。 同时拷贝和复制操作也被直接禁用。

对不同类型资源(模板参数类型不一样)的构造和赋值,也是直接采用move语义来操作实现中封装的内核对象。

  template <typename U>
  KernelHandle(KernelHandle<U>&& other) : dispatcher_(ktl::move(other.dispatcher_)) {}

  template <typename U>
  KernelHandle& operator=(KernelHandle<U>&& other) {
    reset(ktl::move(other.dispatcher_));
    return *this;
  }

reset操作和release实现基本就是照搬了uniq_ptr类型的唯一所有权的处理手法。

  void reset() { reset(fbl::RefPtr<T>()); }
  
  template <typename U>
  void reset(fbl::RefPtr<U> dispatcher) {
    if (dispatcher_) {
      dispatcher_->on_zero_handles();
    }
    dispatcher_ = ktl::move(dispatcher);
  }

  const fbl::RefPtr<T>& dispatcher() const { return dispatcher_; }
  fbl::RefPtr<T> release() { return ktl::move(dispatcher_); }

};
用户空间的创建、替换、更新和关闭

如果要访问内核资源,用户空间的进程需要先通过具体资源的系统调用来创建一个对应资源的handle;比如如下这些系统调用函数

  • zx_event_create创建一个事件类型的资源
  • zx_process_create创建一个进程资源,这里进程本身其实也是一类具体的资源;这里的概念和Linux里面的进程管理作为内核的基本构造的方法完全不同:Zircon是将进程也作为抽象对象的一种实现了。
  • zx_thread_create创建一个线程资源 这些函数在创建对应资源的同时,也返回了一个用户空间可用的指向对应资源的handle

Handle本身可以通过zx_handle_duplicate调用被复制,这种情况下新创建的handle指向和旧的handle完全相同的内核资源;一般情况下惯用的做法是通过该API来降低需要访问的资源权限

zx_handle_replace用来替换一个已有的handle,用户甚至可以在这个API中添加新的handle的权限。 zx_handle_close顾名思义完成关闭handle的目的。

用户空间和内核空间的迁移

如下的两组API用来完成handle在内核和用户空间的转换

  • zx_channel_write将对应的用户空间的handle转移到内核空间中,即放置于in-transist状态
  • zx_channel_read+zx_channel_call将处于内核中的handle提前出来并绑定到用户空间中

这两组API的功能实现了类似于传统Linux内核的上下文切换的功能。

基于引用技术的资源权限管理

内核中的资源如上面的Dispatcher类的接口声明,采用了基于引用计数的生命周期管理思路;任何时候有一个新的handle指向一个资源,其内部的引用计数就会增加;已有的handle被关闭的时候,引用计数会自减。 当引用减小为0的时候,对应的资源会被自动销毁或者处于终结状态等待延迟的垃圾回收机制来清理无人使用的资源。

如何运行用户代码:Job/Process/Thread

Zircon将应用程序的加载和执行放到了内核之外,由用户空间的工具和函数来提供;从内核提供的资源角度来看,由下面三种重要的资源

  • Job是最顶层的对象资源,它定义了运行期的种种限制,并且Job之间采用父子关系结构,并且在系统启动的时候有个顶层的根(RootJob),这一设计基本是复用了传统的Linx系统设计思路
  • 一个Job拥有一个或者多个Process资源对象,这里的Process类似于我们熟悉的进程
  • 一个Process拥有一个或者多个Thread资源对象,而Thread代表了CPU可以执行的最小单元如CPU、寄存器、堆栈等

这样的设计方式比传统的Unix基于Task的思路要方便不少,比如传统的用户进程组的概念被Job所替代,并且Thread和Process的关系也被显示地设计为包含关系,不管是使用的便利性还是安全性上都更为干净清晰

Job

Job主要的目的是用来做进程的分组控制,它可以在分组的层面上提供诸如系统调用权限、资源使用限额控制等。这方面明显可以看到目前的Linux上的容器技术设计的考虑。

所有的Job构成了一个单根的树形结构,除了根节点外每一个Job都可以由一个或者多个子Job。同时因为Process是Job的子节点,它可以在树结构中和Job处于平级结构。

Job对象中包含如下信息

  • 指向父对象的引用
  • 包含子Job的集合
  • 一组成员进程的集合
  • 一组控制策略(目前尚未实现)

目前由两个系统调用来完成对Job的控制,zx_job_create()用于创建Job,而zx_job_set_policy()用于设置控制策略。

进程Process

Process和传统的Linux进程的概念比起来没有太大的差别,都是用来表示可以被运行的一些程序指令的集合。 它被Job所控制,包含如下的资源

  • handles句柄对象
  • 虚拟内存区域对象
  • 线程对象

进程的创建和启动需要两个步骤来完成

  • zx_process_create负责创建但不执行
  • zx_process_start负责执行,它在创建之后只能被执行一次

一个进程里面可以加入多个线程,进程会一致执行直到下列一个条件满足

  • 最后一个包含的线程退出
  • 进程自己调用了zx_process_exit
  • 父Job终止了自己的执行
  • 父Job被销毁

线程Thread

线程是一种实际消耗CPU运行资源并被内核调度器所调度执行的一种资源,这一点和传统的Unix内核设计截然不同。它运行在一个特定的父进程对象中,使用进程所提供的内存、资源句柄等信息来完成IO处理和计算功能。

线程生存周期

线程随着zx_thread_create()调用而被创建,但是并不会在创建后就立即被执行。只有有人调用了zx_thread_start()或者调用了父进程zx_process_start()之后才会被执行(此时该线程是进程里面的第一个线程)。两种系统调用都包含了实际代码执行的入口信息。 该入口信息是一个内存地址资源,传递的时候也是以句柄的方式来传入的。

线程的退出条件包含以下几种情况之一

  • zx_thread_exit()调用用于自行退出,入口点函数的返回并不会自行退出线程
  • zx_vmar_unmap_handle_close_thread_exit()调用回收了对应的虚拟内存资源,此时调用方的堆栈信息也会被系统回收
  • zx_futex_wake_handle_close_thread_exit()调用
  • 父进程自身退出的时候,连带终止所有子线程
  • zx_task_kill终止了线程的句柄
  • 系统产生了异常行为,此时没有其它的处理程序可以顺利退出该线程

默认情况下,线程默认总是和父进程解绑(detach)的,这意味着在父进程退出的时候,无需调用专门的join()函数来做清理。

内核到用户空间的启动

基于微内核的架构的一个比较复杂的地方是如何启动第一个用户空间的进程。 一种流行的做法是在内核层面实现一个仅仅用于启动加载的“迷你版“的文件系统读写和程序加载功能,然后再内核完成自启动之后旧不再使用这些功能;而Ziron并未采用这一流行的设计方法。

Ziron使用的Bootloader会用称为ZBI的镜像文件格式来同时加载内核镜像和一个数据块。该镜像格式是一个内嵌了诸如硬件细节信息、内核启动时候的命令行参数、压缩的内存磁盘镜像文件等内容的简单容器格式

内存磁盘镜像文件使用LZ4格式压缩,解压后它的镜像格式是BOOTFS格式;该格式其实是一个简单的只读文件系统,内含了用户空间程序需要运行的环境、共享库和数据文件等,且每一项都包含了

  • 文件名
  • 文件大小
  • 文件在BOOTFS里面的相对偏移量

这些数值都需要保证和页面大小对齐,并且目前仅限于32位数字。 当内核完成自举之后,BOOTFS里面的这些文件随之变成一个挂载在/boot目录的文件系统。

但是内核本身并不包含任何解压和解析BOOTFS文件格式的代码,这些功能是通过第一个用户空间的称为userboot的进程来完成的。

userboot进程的加载

userboot进程是一个普通的用户空间进程,它可以使用内核定义的系统调用来完成具体的工作;它和其它用户空间进程的区别在于内核加载它的方式是特殊的。 在编译阶段,userboot被和内核镜像直接用简单的ELF文件格式打包在一起。 然而内核并不需要真的来解析ELF文件才能使用它,在编译期间诸如只读段的大小、可执行段的大小、入口函数的位置这些信息都被从文件中提取出来然后用常量定义的方式传递给内核代码。

随后在加载过程中,内核会发送初始的bootstrap消息给userboot,内核启动的命令行参数会被解析为一个一个的单词并以字符串环境变量的格式传递给userboot;这样所有它需要使用的资源句柄都会包含在该消息中。

解压BOOTFS和启动加载服务

随后基于内核映射系统调用的vDSO的工作方式,userboot可以在其后的内存位置上找到系统调用的代码入口,完成系统调用。 它需要做的第一件事就是通过传入为VMO类型的句柄来解压ZBI格式的BOOTFS镜像,使用自己包含的LZ4格式支持代码来完成解压处理,将解压之后的文件内容放入新的VMO对象中。

接下来它需要环境变量传入的userboot=<file>信息,来查找到对应的文件作为第一个真正的用户空间进程;如果用户没有指定该启动选项,则默认启动BOOTFS镜像中包含的bin/bootsvc程序。 因为usertoot实现了完整的ELF格式解析功能,它可以识别到具体的动态链接文件的属性信息,并在需要的时候调用额外的解析器来加载需要解释执行的文件。

随后userboot会将包含系统调用服务的vDSO加载到一个随机的内存地址(这样可以有效地防止黑客攻击内核),并用正常的基于Channel的方式启动新的进程,将这个vDSO的基地址和Channel资源的句柄传递给子进程。 在这个Channel上,userboot会将它从内核接收到的启动信息简单替换处理后发送个子进程。

加载服务传递

直接被userboot启动的是一个加载服务进程,它会接收到userboot传递来的vDSO和一个包含加载服务信息的通道的句柄。 正常情况下加载服务处理完成这些信息之后,应该尽快关闭该通道。 userboot程序会循环处理来自加载服务的请求,并且在收到加载对象的请求的时候,查询BOOTFS中的以lib/为前缀的文件并用VMO的方式将内容返回给加载服务;这样第一个真正的用户进程就可以访问放在BOOTFS中的动态链接库信息。 这些文件同时也会以文件的形态出现在/boot中。

作为一个单线程的应用,userboot在通道被对方关闭之后,就立马退出处理循环并不再需要做其它事情了。 除了特殊测试场景的设置它会等待启动的用户程序退出才自己退出外,正常情况下userboot就会自行退出,留下它所启动的子进程作为用户可以看见的第一个进程。

虚拟内存对象

IPC机制

Zircon提供了基于消息传递设计的IPC机制,这里面主要的IPC对象是Socket和Channel;两者都提供双向的消息交互,具有两个通信端点。 创建Socket或者Channel的时候,我们就会得到指向同一个IPC对象资源的两个句柄,每一个代表通信的一方向。

Socket

Socket提供了一种双向的基于数据流的IPC机制,它的主要机制是数据搬运和移动。 创建之后,连接socket的两端都可以通过zx_sockert_read/write执行读写操作;同时它支持类似于TCP协议的半关闭的概念,用户可以在zx_socket_shutdown里设置ZX_SOCKET_SHUTDOWN_READ/WRITE来关闭读或者写操作。

Socket本身分为两种类型

  • ZX_SOCKET_STREAM类型的socket在写入的时候会检查内部缓冲的大小,如果缓冲区未满则允许写入一部分或者全部数据,写操作会返回成功,并且调用者可以得到实际写入的字节数;如果缓冲已满,则返回ZX_ERR_SHOULD_WAIT,此时调用方需要用zx_object_wait_async或者zx_object_wait_one这样的异步调用来等待
  • ZX_SOCKET_DATAGRAM类型的socket在类似情况下不会部分写入,如果缓冲可用空间不足则会直接返回ZX_ERR_SHOULD_WAIT,如果写入成功返回值就会包含实际写入的数据大小。而该类型的socket在读取的时候会仅仅读取第一个可用的DATAGRAM。

Socket相关的系统调用还提供了类似于查询缓冲大小的API,用户需要在zx_object_get_info中指定对象类型为ZX_INFO_SOCKET,然后再返回的结构体中查看zx_info_socket_t。 这个获取内核对象信息的接口是一个C风格的泛型接口

#include <zircon/syscalls.h>

zx_status_t zx_object_get_info(zx_handle_t handle,
                               uint32_t topic,
                               void* buffer,
                               size_t buffer_size,
                               size_t* actual,
                               size_t* avail);

Channel

Channel对象是一种基于数据报的IPC对象;不同于socket的机制,IPC的两端通过Channel交换的是内核资源的句柄。 Channel最大不能超过ZX_CHANNEL_MAX_MSG_BYTES个字节,同时它还限制了最多可以关联ZX_CHANNEL_MAX_MSG_HANDLES个内核对象句柄。

当对应的内核资源的句柄被写入到一个Channel中去的时候,它再当前调用进程里面的句柄引用就会被删除;而当Channel的读取方获得该资源句柄的时候,它又被内核自动关联到接收进程的引用句柄列表中。 处于中间状态的内核对象会持续存在,除非有一些关闭进程或者异常处理的程序关闭了Channel的另外一端,这种情况下对应的资源句柄会被一同清理。

Channel和消息队列

Channel自身维护了一个有序的消息队列,其中的每一个消息里面包含数据和某些内核资源句柄。 对zx_channel_write的调用会将对应的消息放入Channel的消息队列中,而zx_channel_read则会将已经排队的消息出队。 某个线程可以用zx_object_wait_one系统调用显示地阻塞等待另外一方发送消息。

zx_channel_call调用则会完成如下的操作

  • 将消息写入队列
  • 等待对方的消息响应
  • 将收到的消息出队,此时收到的消息的前4个字节包含内核提供的transaction ID
通过Channel收发消息的操作原子化和一致性

通过一个Channel来发送消息总是经过如下的两步式操作

  • 原子化的方式将数据写入channel并将消息中包含的所有对象的句柄的所有权移动到Channel中去
  • 消息的读取方用用原子化方式将所有的资源句柄移入调用方的句柄表里面,如果有失败发生,所有权依然再Channel对象中,但是用户指定了ZX_CHANNEL_READ_MAY_DISCARD选项的除外

正式因为这个移动和所有权的设计,导致Channel对象不像其它内核对象一样可以被复制。Fuchsia设计的关于消息的顺序性保证异常严格: 当一个Channel的所有权正在被内核从一个进程传递到另外一个进程的过程中的时候,即使是来自于同一个进程的同时进行的消息写入操作也不会破坏消息之间的相对次序。

上述的顺序性保证是Channel独有的,其它的内核对象并没有该性质。

Signal,Port和Event

Signal和Event是用于实现进程间通知的一类内核对象。

Signal

Zircon内核对象通过Signal接口来通知应用程序一些简单的信息:一个signal仅仅能传输信息容量为一即一个比特位的内核发生的具体事件。有些signal是多个内核对象共享的,而另外一些则是因内核对象的不同而定。

用户空间的程序可以用如下的系统调用来捕捉内核对象的signal

  • zx_object_wait_one会以阻塞的方式等待给定内核对象上的指定信号集中的某一个发生,直到超时或者关注的信号被触发
  • zx_object_wait_many允许用户传入多个等待的内核对象以及它们上关联的信号集作为参数,并等到对应的信号通知,直到超时或者至少一个信号被触发
  • zx_object_wait_async是异步等待的系统调用,它通过一个额外的Port类对象来协助完成

最后一个异步的等待方式略微复杂,但是避免了同步等待阻塞应用程序的代价

zx_status_t zx_object_wait_async(zx_handle_t handle,
                                 zx_handle_t port,
                                 uint64_t key,
                                 zx_signals_t signals,
                                 uint32_t options);

port和异步等待

上面的系统调用通过signals参数指定对应handle所指向的内核对象上的哪些signal需要被关注,当对应的signal被触发的时候,一个新的包会被排队于Port对象上。

而Port的队列里会包含所有已经发生的信号而不仅仅限于zx_object_wait_async所指定的那些signal;一旦排队发生,异步等到的操作即告完成,即不会有更多的信号引起的包被排队于Port上。

Port对象的用户可以用zx_port_wait来获取这些得到的信号,也可以用zx_port_cancel系统调用终止整个异步等待操作。

Port的逻辑概念

Port其实是一个更加底层的消息信箱的概念。它允许线程等待通过包发送的各种各样的事件;这些事件不但包含显示地排队于其上的包,也适用于通过异步IPC机制发送过来的消息数据。它通过三个系统调用来提供服务

  • zx_port_create创建一个Port
  • zx_port_queue发送数据包到某个Port对象
  • zx_port_wait等待数据包

Port既可以接收来自用户空间的包,也可以处理来自内核的包(如上面所述的Signal);它还可以绑定于某种内核中断,此时内核中断的优先级比其它的非中断包的优先级要高因而会被优先处理。

如果一个线程需要处理大量的内核对象Handle上过来的信号,Port机制是一种效率更高的方案所以应该被优先考虑。

Event

Event是一种包含了多个活跃信号集的简单内核对象,它代表了用户可以触发的信号对象集。目前预留了ZX_USER_SIGNAL_0ZX_USER_SIGNAL_7总共8个用户可以设置、清理、等待的自定义信号。

用户需要适用zx_object_signal来触发自定义的信号。

Event Pair则是一对其中的信号会互相触发的Event对。用户可以适用zx_object_signal来向对方发送自定义的信号。 当Event Pair上的一端上的所有资源句柄都被关闭的时候,另外一端则会自动收到PEER_CLOSED信号。

虚拟内存对象VMO

虚拟内存对象表示一系列物理内存页对象,或者是需要按需创建、惰性按需填充的内存页的集合。 内核提供zx_vmar_map来允许用户将地址空间映射到进程里面;zx_vmar_unmap则执行相反的地址空间的卸映射。 用户程序也通过zx_vmar_protect来设置或者调整页面的控制权限。

同一个VMO对象可以被多个进程各自映射到他们的用户地址空间中。不同于Linux的内核空间和用户空间完全隔离的做法, Zircon用同样VMO对象来满足内核空间和用户空间的内存管理;它是一种通用的多进程之间共享内存的做法。

页面对齐和大小调整

实际VMO的大小会被内核自动调整为和页面大小对齐,用户可以适用zx_vmo_set_size来指定一个希望设置的大小,而zx_vmo_get_size则接收内核调整过后向上对齐的实际大小。

页面映射和分配

内存页面被相关的系统调用以按需使用的方式分配给VMO对象,比如

  • 系统调用zx_vmo_read/zx_vmo_write被触发的时候,或者
  • zx_vmar_map创建的VMO对象被写入数据的时候

内核也允许通过zx_vmo_op_range调用来分配或者释放对应的内存页,不过这些操作应该被当作是非常底层的系统调用来使用,一般可以应用于缓存或者锁算法;一般用户空间的代码还是不用为好。

libc库的支持

Fuchsia实现了一个部分遵循POSIX标准(子集)的C标准库;它最初是基于musl这个容器世界里非常流行的迷你C库,但是修改了很多自己的实现;但是整体的代码布局依然和musl保持一致。

由于系统设计的不同,Fuchsia的C库并不支持静态链接,所有的程序都必须使用动态链接,且libc.so本身就是一个动态链接器。 另外,它也不支持Linux上的IO操作的功能,相反它提供了一个针对fdio.so的弱符号封装。

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...