QNX Neutrino 系统架构

目录

QNX Neutrino 系统架构(System Architecture)中文翻译

原文来源:QNX Neutrino RTOS 7.1 官方文档《System Architecture》
翻译范围:设计理念、微内核、进程间通信(IPC)、插桩微内核、多核处理、进程管理器、动态链接、资源管理器、文件系统、PPS 共 10 章及其全部子章节
图片保留原图(SVG 矢量图)


第一章 QNX Neutrino RTOS 的设计理念(The Philosophy of the QNX Neutrino RTOS)

QNX Neutrino RTOS 的首要目标是:以健壮、可伸缩的形式交付开放系统 POSIX API,使其适用于从资源受限的微型嵌入式系统,到高端分布式计算环境在内的各种系统。该操作系统支持多种处理器体系结构,包括 x86 和 ARM。

对于任务关键型(mission-critical)应用而言,健壮的架构同样至关重要,因此该操作系统对 MMU 硬件进行了灵活且充分的利用。

当然,仅仅设定这些目标并不能保证结果。我们邀请你通读本《系统架构》指南,以了解我们的实现方法,以及为实现这些目标而做出的设计权衡。当你读完本指南后,我们相信你会认同:QNX Neutrino 是同类产品中第一款真正同时实现开放系统标准、广泛可伸缩性与高可靠性的操作系统。

1.1 可嵌入的 POSIX 操作系统?

坊间流传着一种说法:如果你划开一个 POSIX 操作系统的表面,你会在底层发现 UNIX!因此,POSIX 操作系统体积过大,不适合嵌入式系统。

然而事实是,POSIX 并不是 UNIX。尽管 POSIX 标准植根于已有的 UNIX 实践,但 POSIX 工作组明确地从接口(而非实现)的角度来定义这些标准。

得益于标准中精确的规范,以及 POSIX 测试套件的可用性,非传统的操作系统架构无需采用传统的 UNIX 内核,也能提供 POSIX API。任意两个 POSIX 系统放在一起比较,它们在外观上会非常相似------它们拥有许多相同的函数、实用工具等。但在性能和可靠性方面,它们可能天差地别。正是架构造成了这种差异。

尽管 QNX Neutrino 的架构明显不是 UNIX 风格,但它实现了标准的 POSIX API。通过采用微内核架构,该操作系统以一种既能轻松缩减以适配实时嵌入式系统、又能按需逐步扩展的形式来交付这一 API。

1.2 产品伸缩性(Product scaling)

由于你可以简单地通过加入或省略提供特定功能的进程来伸缩微内核操作系统,因此一个微内核操作系统可以胜任比实时执行体(realtime executive)广泛得多的用途。

产品开发往往采取创建产品线(product line)的形式,后续型号提供越来越丰富的功能。使用微内核操作系统的开发人员无需被迫为产品的每个版本更换操作系统,而可以根据需要轻松地伸缩系统------通过添加文件系统、网络、图形用户界面及其他技术。

这种可伸缩方法带来的一些优势包括:

  • 应用代码可移植(可在产品线各成员之间移植)
  • 整个产品线使用共同的开发工具
  • 开发人员技能可迁移
  • 缩短产品上市时间

1.3 嵌入式系统为何选择 POSIX?

实时应用开发中一个常见的问题是:每个实时操作系统往往都自带专有的 API。在缺乏行业标准的情况下,竞争市场演化成这种状态并不罕见------对实时市场的调查一直显示,自研专有操作系统被大量使用。POSIX 则为统一这一市场提供了契机。

在众多的 POSIX 标准中,嵌入式系统开发人员最感兴趣的是:

  • 1003.1 ------ 定义了进程管理、设备 I/O、文件系统 I/O 以及基本 IPC 的 API。这涵盖了可称为 UNIX 操作系统基础功能的内容,是许多应用的有用标准。从 C 语言编程的角度看,它假定以 ANSI X3J11 C 为起点,然后在 ANSI C 所规定的范围之外,进一步详细规范了管理进程、文件和 tty 设备的各个方面。
  • 实时扩展(Realtime Extensions) ------ 定义了对基础 1003.1 标准的一组实时扩展。这些扩展包括:信号量、带优先级的进程调度、对信号的实时扩展、高分辨率定时器控制、增强的 IPC 原语、同步与异步 I/O,以及关于实时连续文件支持的建议。
  • 线程(Threads) ------ 进一步扩展 POSIX 环境,将同一地址空间内多个执行线程的创建与管理纳入其中。
  • 附加实时扩展(Additional Realtime Extensions) ------ 定义了对实时标准的进一步扩展。其中描述了诸如挂接中断处理程序之类的设施。
  • 应用环境配置文件(Application Environment Profiles) ------ 定义了 POSIX 环境的若干 AEP(实时 AEP、嵌入式系统 AEP 等),以适应不同的嵌入式能力集合。这些配置文件代表了带或不带文件系统及其他能力的嵌入式操作系统。

注:关于众多 POSIX 草案与标准的信息,请参阅 IEEE 网站 http://www.ieee.org/。

除了随大流采用行业标准的动机之外,将 POSIX 标准应用于嵌入式实时市场还有若干具体优势:

多操作系统来源

硬件制造商不愿选择单一来源的硬件组件,因为如果该来源停产,将带来风险。同理,制造商也不应因为其应用源代码无法移植到其他操作系统,而被绑定在单一来源的专有操作系统上。通过基于 POSIX 标准构建应用,开发人员可以使用来自多个供应商的操作系统。只要开发人员避免使用操作系统专有的扩展,应用源代码就能轻松地从一个平台移植到另一个平台、从一个操作系统移植到另一个操作系统。

开发人员的可迁移性

在嵌入式开发中使用通用 API,那么熟悉某一种实时操作系统的程序员,就能将其技能直接应用到涉及其他处理器和操作系统的其他项目中。此外,具有 UNIX 或 POSIX 经验的程序员也能轻松地从事嵌入式实时系统的工作,因为实时操作系统 API 中非实时的部分对他们而言是熟悉的领域。

开发环境

即使在交叉托管的开发环境中,API 与嵌入式系统上的也基本保持一致。无论具体的主机(Linux、Windows 等)或目标机(x86、ARM)是什么,程序员都无需担心平台相关的字节序、对齐或 I/O 问题。

1.4 嵌入式系统为何选择 QNX Neutrino?

操作系统的主要职责是管理计算机的资源。系统中的所有活动------调度应用程序、向磁盘写文件、跨网络发送数据等------都应尽可能无缝、透明地协同工作。

有些环境比其他环境要求更严格的资源管理与调度。例如,实时应用依赖操作系统来处理多个事件,并确保系统在可预测的时间限制内响应这些事件。操作系统的响应性越好,实时应用用来满足其截止时间(deadline)的时间就越充裕。

QNX Neutrino RTOS 是嵌入式实时应用的理想选择。它可以被缩减到非常小的体积,并提供多任务、线程、优先级驱动的抢占式调度以及快速的上下文切换------这些都是嵌入式实时系统的必备要素。此外,该操作系统通过 POSIX 标准 API 交付这些能力;无需为了获得小体积系统而放弃标准。

QNX Neutrino 还极为灵活。开发人员可以轻松地定制操作系统以满足其应用的需求。从带有少量小型模块的微内核裸机配置,到能够服务数百用户的完整全网络系统,你都可以自由地搭建系统,使其只使用处理手头工作所需的资源。

QNX Neutrino 通过两条基本原则实现了其独有的高效、模块化与简洁:

  • 微内核架构
  • 基于消息的进程间通信

1.5 微内核架构

流行词往往此起彼伏。供应商倾向于热情地将当下流行的词汇套用到自己的产品上,无论这些词是否真的贴切。

"微内核"(microkernel)一词已经变得流行起来。尽管许多新的操作系统据称是微内核(甚至纳内核 nanokernel),但如果没有清晰的定义,这个词可能意义不大。

让我们试着给这个词下个定义。微内核操作系统的结构是:一个极小的内核,提供由一组可选的协作进程所使用的最小服务;而这些进程反过来提供更高层的操作系统功能。微内核本身并不包含文件系统以及通常人们所期望操作系统具备的许多其他服务;这些服务由可选的进程来提供。

设计微内核操作系统的真正目标并不仅仅是让它变小。微内核操作系统体现的是交付操作系统功能方式上的一种根本性变革。**模块化才是关键,体积小只是附带的结果。**如果仅仅因为某个内核碰巧很小就称其为微内核,那就完全没抓住重点。

由于微内核提供的 IPC 服务被用来把操作系统本身粘合在一起,因此这些服务的性能与灵活性,决定了最终操作系统的性能。除了这些 IPC 服务之外,微内核在所提供服务以及实时性能方面,与实时执行体大致相当。

微内核与执行体的区别在于:如何利用 IPC 服务,通过额外的、提供服务的进程来扩展内核的功能。由于操作系统被实现为由微内核管理的一组协作进程,因此用户编写的进程既可以作为应用程序,也可以作为扩展底层操作系统功能的进程,服务于行业特定的应用。操作系统本身因此变得开放且易于扩展。此外,用户对操作系统的扩展不会影响核心操作系统的根本可靠性。

许多实现 POSIX 1003.1 标准的实时执行体面临的一个困难是:它们的运行时环境通常是单进程、多线程模型,线程之间没有内存保护。这样的环境只是 POSIX 所假定的多进程模型的一个子集;它无法支持 fork() 函数。相比之下,QNX Neutrino 充分利用 MMU,在受保护的环境中交付完整的 POSIX 进程模型。

如下面的图所示,真正的微内核提供完整的内存保护,不仅针对用户应用,也针对操作系统组件(设备驱动、文件系统等):

图 1 传统执行体不提供内存保护。

传统执行体

图 2 在宏内核(monolithic)操作系统中,系统进程没有保护。

宏内核

图 3 微内核提供完整的内存保护。

微内核

QNX 操作系统的第一版于 1981 年发布。随着每一次产品迭代,我们都把上一代产品的经验应用到最新版本上------它是我们迄今最强大、最具伸缩性的操作系统。我们相信,正是这种历经时间考验的经验,使得 QNX Neutrino RTOS 能够以它消耗的有限资源,交付它所实现的功能。

1.6 进程间通信

当多个线程并发运行时(正如在典型的实时多任务环境中那样),操作系统必须提供机制让它们能够相互通信。

进程间通信(IPC)是将应用设计为一组协作进程的关键,其中每个进程处理整体中一个定义明确的部分。

操作系统提供了一组简单而强大的 IPC 能力,极大地简化了由协作进程组成的应用的开发工作。更多信息请参阅《进程间通信(IPC)》一章。

1.7 内核的网络分布

在最简单的形式中,局域网络提供了一种在若干互连计算机之间共享文件和外围设备的机制。QNX Neutrino 远远超越了这个简单的概念,它将整个网络整合成单一的、同构的资源集合。

网络中的任何一台机器上的任何线程,都可以直接使用任何其他机器上的任何资源。从应用的角度看,本地资源与远程资源没有任何区别------应用无需内置任何特殊设施就能使用远程资源。

用户可以访问网络上任意位置的文件、使用任意外围设备,并在任意网络机器上运行应用(如果获得授权的话)。进程可以在整个网络中以前述相同的方式通信。同样,消息传递 IPC 成就了这种流畅、透明的网络能力。


第二章 QNX Neutrino 微内核(The QNX Neutrino Microkernel)

微内核实现了嵌入式实时系统所使用的核心 POSIX 功能,以及 QNX Neutrino 基础的消息传递服务。

未在 procnto 微内核中实现的 POSIX 功能(例如文件和设备 I/O)由可选的进程和共享库提供。

注 :要确定你系统上微内核的发布版本,请使用 uname -a 命令。更多信息请参阅《实用工具参考》(Utilities Reference)中对应的条目。

BlackBerry QNX 历代微内核在实现给定内核调用所需的代码量上一直在减少。内核代码最底层的对象定义变得越来越具体,从而实现了更大程度的代码复用(例如,将各种形式的 POSIX 信号、实时信号和 QNX Neutrino 脉冲折叠到公共的数据结构,以及操作这些结构的代码中)。

在最底层,微内核包含少量基础对象,以及操作这些对象的高度调优例程。整个操作系统就是构建在这个基础之上的。

图 1 微内核。

微内核组件图

一些开发人员曾假设:出于体积或性能的考虑,我们的微内核完全用汇编代码实现。事实上,我们的实现主要用 C 语言编写;体积与性能目标是通过不断精炼的算法和数据结构来实现的,而非通过汇编级的窥孔优化。

2.1 QNX Neutrino RTOS 的设计目标

从历史上看,BlackBerry QNX 操作系统所面临的应用压力来自计算领域的两个极端------从内存受限的嵌入式系统,一直到拥有数 GB 物理内存的高端 SMP(对称多处理)机器。

相应地,QNX Neutrino 的设计目标同时兼顾了这两组看似互斥的功能。追求这些目标,是为了让系统的适用范围远超其他操作系统实现所能达到的边界。

POSIX 实时与线程扩展

由于 QNX Neutrino RTOS 直接在微内核中实现了大部分实时与线程服务,因此即使没有额外的操作系统模块,这些服务也可用。

此外,POSIX 定义的一些配置文件建议这些服务在不一定需要进程模型的情况下也应存在。为适应这一点,操作系统直接支持线程,但依赖其进程管理器部分将此功能扩展到包含多个线程的进程。

请注意,许多实时执行体和内核只提供无内存保护的线程模型,完全没有进程模型和/或受保护的内存模型。没有进程模型,就无法实现完整的 POSIX 兼容。

2.2 系统服务

微内核提供内核调用来支持以下内容:

  • 线程
  • 消息传递
  • 信号
  • 时钟
  • 定时器
  • 中断处理程序
  • 信号量
  • 互斥锁(mutex)
  • 条件变量(condvar)
  • 屏障(barrier)

整个操作系统都构建在这些调用之上。操作系统是完全可抢占的,即使在进程之间传递消息时也是如此;它会在抢占前中断的位置恢复消息传递。

微内核的最小复杂度有助于为内核中最长的不可抢占代码路径设定一个上限,而较小的代码量又使解决复杂的多处理器问题变得可行。选择哪些服务纳入微内核,是基于其执行路径是否短小。需要大量工作的操作(例如进程加载)被分配给外部进程/线程,在那里,进入该线程上下文所付出的代价,与该线程内部所做的工作相比微不足道。

将这一规则严格地应用于内核与外部进程之间的功能划分,就打破了"微内核操作系统必然比宏内核操作系统产生更高运行时开销"的神话。在上下文切换之间所做的工作(消息传递中隐含的)超过了由简化内核所带来的极快的上下文切换时间。因此,执行上下文切换所花的时间,相对于为响应操作系统进程间消息传递所传达的请求而做的工作而言,已淹没在噪声之中。

下图展示了非 SMP 内核(x86 实现)的抢占细节:

图 1 QNX Neutrino 的抢占细节。

抢占细节

中断被禁用或抢占被推迟的时间间隔都非常短暂(通常在数百纳秒量级)。

2.3 线程与进程

在构建应用(无论是实时、嵌入式、图形还是其他类型)时,开发人员可能希望应用中的多个算法并发执行。这种并发性是通过 POSIX 线程模型实现的,该模型将进程定义为包含一个或多个执行线程。

线程可以被视为执行的最小单元,即微内核中的调度与执行单元。另一方面,进程可以被视为线程的容器,它定义了线程在其中执行的地址空间。一个进程总是至少包含一个线程。

取决于应用的性质,线程可能彼此独立地执行、算法之间无需通信(这种情况不太可能),也可能需要紧密耦合,进行高带宽通信和紧密同步。为协助实现这一点,QNX Neutrino RTOS 提供了许多 IPC 和同步服务。

以下 pthread_*(POSIX 线程)库调用不涉及任何微内核线程调用:

pthread_attr_destroy()、pthread_attr_getdetachstate()、pthread_attr_getinheritsched()、pthread_attr_getschedparam()、pthread_attr_getschedpolicy()、pthread_attr_getscope()、pthread_attr_getstackaddr()、pthread_attr_getstacksize()、pthread_attr_init()、pthread_attr_setdetachstate()、pthread_attr_setinheritsched()、pthread_attr_setschedparam()、pthread_attr_setschedpolicy()、pthread_attr_setscope()、pthread_attr_setstackaddr()、pthread_attr_setstacksize()、pthread_cleanup_pop()、pthread_cleanup_push()、pthread_equal()、pthread_getspecific()、pthread_setspecific()、pthread_key_create()、pthread_key_delete()、pthread_self()

下表列出了具有对应微内核线程调用的 POSIX 线程调用。pthread_调用可能在 C 库中实现了内核调用所不提供的更多功能。除非有特殊情况,请使用以下 pthread_ 调用而非内核调用:

POSIX 调用 微内核调用 描述
pthread_create() ThreadCreate() 创建一个新的执行线程
pthread_exit() ThreadDestroy() 销毁一个线程
pthread_detach() ThreadDetach() 分离线程,使其无需被 join
pthread_join() ThreadJoin() join 一个线程,等待其退出状态
pthread_cancel() ThreadCancel() 在下一个取消点取消一个线程
N/A ThreadCtl() 更改线程的 QNX Neutrino 专属线程特性
pthread_mutex_init() SyncTypeCreate() 创建互斥锁
pthread_mutex_destroy() SyncDestroy() 销毁互斥锁
pthread_mutex_lock() SyncMutexLock() 锁定互斥锁
pthread_mutex_trylock() SyncMutexLock() 条件性地锁定互斥锁
pthread_mutex_unlock() SyncMutexUnlock() 解锁互斥锁
pthread_cond_init() SyncTypeCreate() 创建条件变量
pthread_cond_destroy() SyncDestroy() 销毁条件变量
pthread_cond_wait() SyncCondvarWait() 等待条件变量
pthread_cond_signal() SyncCondvarSignal() 向条件变量发信号
pthread_cond_broadcast() SyncCondvarSignal() 广播条件变量
pthread_getschedparam() SchedGet() 获取线程的调度参数与策略
pthread_setschedparam()、pthread_setschedprio() SchedSet() 设置线程的调度参数与策略
pthread_sigmask() SignalProcmask() 检查或设置线程的信号掩码
pthread_kill() SignalKill() 向特定线程发送信号

操作系统可以被配置为提供线程与进程(按 POSIX 定义)的混合。每个进程与其他进程之间受 MMU 保护,每个进程可以包含一个或多个共享该进程地址空间的线程。

你选择的环境不仅影响应用的并发能力,也影响应用可能使用的 IPC 和同步服务。

注 :尽管通用的术语 IPC 指的是进程间的通信,但这里我们用它来描述 线程之间的通信,无论它们是在同一进程内还是在不同的进程中。

关于进程与线程的编程视角信息,请参阅《QNX Neutrino 快速入门》的《进程与线程》一章,以及《QNX Neutrino 程序员指南》的《编程概述》和《进程》两章。

2.4 线程调度

调度决策在何时、如何做出?

微内核在因内核调用、异常或硬件中断而被进入时,就会做出调度决策。每当任何线程的执行状态发生变化时,都会做出调度决策------无论这些线程驻留在哪些进程中。线程是在所有进程之间全局调度的。

通常,正在运行的线程会继续运行,但当该运行中的线程出现以下情况时,线程调度器会执行从一个线程到另一个线程的上下文切换:

  • 阻塞(block)
  • 被抢占(preempt)
  • 让出(yield)

线程何时阻塞?

当一个运行中的线程必须等待某个事件发生时(响应 IPC 请求、等待互斥锁等),它就会阻塞。被阻塞的线程从运行队列(running array)中移除,然后运行最高优先级的就绪线程。当被阻塞的线程随后解除阻塞时,它通常被放到该优先级就绪队列的末尾。

线程何时被抢占?

当一个更高优先级的线程被放入就绪队列时(由于其阻塞条件被解除,它变为 READY),运行中的线程就会被抢占。被抢占的线程被放到该优先级就绪队列的开头,更高优先级的线程开始运行。

线程何时让出?

一个运行中的线程自愿让出其处理器(例如通过 sched_yield()),并被放到该优先级就绪队列的末尾。然后最高优先级的线程开始运行(可能仍然是刚刚让出的那个线程)。

2.5 同步服务

QNX Neutrino RTOS 提供 POSIX 标准的线程级同步原语,其中一些甚至在不同进程的线程之间也很有用。

同步服务至少包括以下内容:

同步服务 进程间是否支持 是否跨 QNX Neutrino 局域网支持
互斥锁(Mutex) 是¹ 否
条件变量(Condvar) 是 否
屏障(Barrier) 是¹ 否
Sleepon 锁 否 否
读写锁(Reader/writer lock) 是¹ 否
信号量(Semaphore) 是 是(仅命名信号量)
发送/接收/应答(Send/Receive/Reply) 是 是
原子操作(Atomic operation) 是 否

¹ 在进程之间共享此类对象可能存在安全问题;请参阅本章后文的《在进程之间安全地共享互斥锁、屏障与读写锁》。

上述同步原语由内核直接实现,除了:

  • sleepon 锁和读写锁(它们由互斥锁和条件变量构建而成)
  • 原子操作(它们要么由处理器直接实现,要么在内核中仿真)

注 :你应该只在普通内存映射中分配互斥锁、条件变量、屏障、读写锁、信号量,以及你计划在其上使用原子操作的对象。在某些处理器上,如果对象被分配在未缓存的内存中,原子操作以及诸如 pthread_mutex_lock() 之类的调用将会引发故障。

2.6 时钟与定时器服务

时钟服务用于维护当天的时间(time of day),而内核定时器调用又利用它来实现间隔定时器。

注 :QNX Neutrino 系统的有效日期范围从 1970 年 1 月一直到至少 2038 年。 time_t 数据类型是无符号 32 位数字,它将许多应用的这一范围扩展到 2106 年。内核本身使用无符号 64 位数字来计算自 1970 年 1 月以来的纳秒数,因此可以处理到 2554 年的日期。如果你的系统必须在 2554 年之后运行,且系统无法在现场升级或修改,你就需要特别注意系统日期(如需帮助请联系我们)。

ClockTime() 内核调用允许你获取或设置由 ID(CLOCK_REALTIME)指定的系统时钟,该时钟维护系统时间。一旦设置,系统时间会根据系统时钟的分辨率递增若干纳秒。这个分辨率可以使用 ClockPeriod() 调用来查询或更改,它允许你将系统定时器设置为若干纳秒的倍数;操作系统内核会尽其所能,用可用的硬件来满足请求的精度。

由指定分辨率选定的间隔,总是根据底层硬件定时器的精度舍入为整数倍。当然,将它设置为极低的值可能会导致相当一部分 CPU 性能被消耗在服务定时器中断上。

在系统页(system page) 这个内存数据结构中,有一个 64 位字段(nsec),它保存自系统启动以来的纳秒数。该字段始终单调递增,并且绝不会因通过 ClockTime() 或 ClockAdjust() 设置当前时间而受到影响。你可以通过 SYSPAGE_ENTRY(qtime)->nsec 宏命令访问该字段。

ClockCycles() 函数返回一个自由运行的 64 位周期计数器的当前值。这在每个处理器上被实现为一种对短时间间隔进行计时的高性能机制。例如,在 Intel x86 处理器上,使用读取处理器时间戳计数器的操作码。其他 CPU 架构也有类似的指令。

在硬件上未实现此类指令的处理器上,内核会仿真一个。这将提供比提供该指令时更低的时间分辨率(在 IBM PC 兼容系统上为 838.095345 纳秒)。

在所有情况下,SYSPAGE_ENTRY(qtime)->cycles_per_sec 字段给出了一秒钟内 ClockCycles() 递增的次数。

注 :QNX Neutrino 要求 SMP 系统上所有处理器的 ClockCycles() 底层硬件保持同步。如果没有同步,你可能会遇到一些意外行为,例如时间漂移和定时器漂移。

ClockId() 函数返回一个特殊的时钟 ID,你可以用它来跟踪某个进程或线程所使用的 CPU 时间。更多信息请参阅《QNX Neutrino 程序员指南》中《理解微内核的时间概念》一章的《监控执行时间》。

微内核调用 POSIX 调用 描述
ClockTime() clock_gettime()、clock_settime() 获取或设置当天时间(使用范围从 1970 到 2554 年的 64 位纳秒值)
ClockAdjust() N/A 应用微小的时钟调整以同步时钟
ClockCycles() N/A 读取 64 位自由运行的高精度计数器
ClockPeriod() clock_getres() 获取或设置时钟的周期
ClockId() clock_getcpuclockid()、pthread_getcpuclockid() 获取进程或线程 CPU 时间时钟的时钟 ID

内核可以在无滴答(tickless) 模式下运行以降低功耗,但这个说法其实有点名不副实。系统仍然有时钟滴答,除非系统空闲,否则一切都正常运行。只有当系统完全空闲时,内核才会关闭时钟滴答,而实际上它所做的是放慢时钟,使下一个滴答中断恰好发生在下一个活动定时器即将触发之后,从而使定时器立即触发。要启用无滴答运行,请为 startup-* 代码指定 -Z 选项。

2.7 中断处理

无论我们多么希望如此,计算机并不是无限快的。在实时系统中,不必要地浪费 CPU 周期是绝对不允许的。同样至关重要的,是最大限度地缩短从外部事件发生到负责响应该事件的线程中代码实际执行之间的时间。这段时间被称为延迟(latency)。

我们最关心的两种延迟是中断延迟(interrupt latency)和调度延迟(scheduling latency)。

注:延迟时间可能因处理器速度和其他因素而有显著差异。更多信息请访问我们的网站(www.qnx.com)。


第三章 进程间通信 IPC(Interprocess Communication)

进程间通信(IPC)在微内核从嵌入式实时内核转变为完整 POSIX 操作系统的过程中扮演着基础性角色。随着各种提供服务的进程被添加到微内核之上,IPC 正是将这些组件粘合成一个紧密整体的粘合剂。

尽管消息传递是 QNX Neutrino RTOS 中 IPC 的主要形式,但也提供了其他几种形式。除非另有说明,这些其他形式的 IPC 都是构建在我们原生消息传递之上的。其策略是:创建一个简单、健壮的 IPC 服务,通过微内核中简化的代码路径来为性能调优;然后可以基于这些,实现功能更繁杂的 IPC 服务。

将高层 IPC 服务(例如构建在我们消息传递之上的管道和 FIFO)与其宏内核对应实现进行对比的基准测试显示,性能相当。

QNX Neutrino 至少提供以下形式的 IPC:

服务 实现于
消息传递(Message-passing) 内核
信号(Signals) 内核
POSIX 消息队列 外部进程
共享内存 进程管理器
管道(Pipes) 外部进程
FIFO 外部进程

设计人员可以根据带宽需求、对排队的需要、网络透明性等因素来选择这些服务。这种权衡可能很复杂,但灵活性是有用的。

在定义微内核的工程工作中,将消息传递作为基本 IPC 原语的聚焦是经过深思熟虑的。作为一种 IPC 形式,消息传递(如 MsgSend()、MsgReceive() 和 MsgReply() 中所实现的)是同步的 ,并且会拷贝数据。让我们更详细地探讨这两个特性。

3.1 同步消息传递

同步消息传递是 QNX Neutrino RTOS 中 IPC 的主要形式。

一个线程对另一个线程(可能在另一个进程内)执行 MsgSend(),将被阻塞,直到目标线程执行 MsgReceive()、处理消息并执行 MsgReply()。如果一个线程在没有待处理已发送消息的情况下执行 MsgReceive(),它将阻塞,直到另一个线程执行 MsgSend()。

在 QNX Neutrino 中,服务器线程通常循环运行,等待接收来自客户端线程的消息。如前所述,一个线程------无论是服务器还是客户端------如果能够使用 CPU,就处于 READY(就绪)状态。由于它自己和其他线程的优先级及调度策略,它可能实际上没有获得任何 CPU 时间,但它并没有被阻塞。

先来看客户端线程:

图 1 发送-接收-应答事务中客户端线程的状态变化。

客户端线程状态变化

  • 如果客户端线程调用 MsgSend(),而服务器线程尚未调用 MsgReceive(),则客户端线程变为 SEND 阻塞(SEND blocked)。一旦服务器线程调用 MsgReceive(),内核就将客户端线程的状态改为 REPLY 阻塞,这意味着服务器线程已收到消息,现在必须应答。当服务器线程调用 MsgReply() 时,客户端线程变为 READY。
  • 如果客户端线程调用 MsgSend(),而服务器线程已经阻塞在 MsgReceive() 上,则客户端线程立即变为 REPLY 阻塞,完全跳过 SEND 阻塞状态。
  • 如果服务器线程失败、退出或消失,客户端线程变为 READY,MsgSend() 指示出错。

接下来考虑服务器线程:

图 2 发送-接收-应答事务中服务器线程的状态变化。

服务器线程状态变化

  • 如果服务器线程调用 MsgReceive(),且没有其他线程向它发送消息,则服务器线程变为 RECEIVE 阻塞。当另一个线程向它发送时,服务器线程变为 READY。
  • 如果服务器线程调用 MsgReceive(),而另一个线程已经向它发送了消息,则 MsgReceive() 立即带着消息返回。在这种情况下,服务器线程不会阻塞。
  • 如果服务器线程调用 MsgReply(),它不会变为阻塞。

这种固有的阻塞同步了发送线程的执行,因为请求发送数据的动作本身也导致发送线程被阻塞,并让接收线程被调度执行。这一切发生,无需内核显式地确定接下来运行哪个线程(而大多数其他形式的 IPC 都需要这样做)。执行和数据直接从一个上下文转移到另一个上下文。

这些消息传递原语省略了数据排队能力,因为可以在需要时在接收线程内部实现排队。发送线程通常已经准备好等待响应;排队是不必要的开销和复杂性(也就是说,它拖慢了非排队的情况)。因此,发送线程无需单独、显式地调用阻塞来等待响应(而如果使用其他形式的 IPC,则必须这样做)。

虽然发送和接收操作是阻塞且同步的,但 MsgReply()(或 MsgError())不会阻塞。由于客户端已经阻塞在等待应答上,无需额外的同步,因此不需要阻塞式的 MsgReply()。这允许服务器应答客户端并继续处理,而内核和/或网络代码异步地将应答数据传递给发送线程,并将其标记为就绪执行。由于大多数服务器往往会做一些处理以准备接收下一个请求(此时它们再次阻塞),这种设计效果很好。

注:在网络中,应答可能不像本地消息传递那样立即完成。关于网络消息传递的更多信息,请参阅本书中关于 Qnet 网络的章节。

MsgReply() 与 MsgError()

MsgReply() 函数用于向客户端返回一个状态和零个或多个字节。另一方面,MsgError() 用于只向客户端返回一个状态。这两个函数都会将客户端从其 MsgSend() 中解除阻塞。

3.2 消息拷贝

由于我们的消息传递服务将消息直接从地址空间拷贝到另一个线程的地址空间,中间无需缓冲,因此消息传递性能接近底层硬件的内存带宽。

内核不对消息的内容附加任何特殊含义------消息中的数据只有在发送方和接收方共同定义时才有意义。然而,也提供了定义良好的消息类型,以便用户编写的进程或线程可以增强或替代系统提供的服务。

消息传递原语支持多段(multipart)传输,因此从一个线程地址空间传递到另一个线程的消息,无需预先存在于单个连续的缓冲区中。相反,发送线程和接收线程都可以指定一个向量表(vector table),指示发送和接收消息片段在内存中的位置。请注意,发送方和接收方各段的大小可以不同。

多段传输允许将头块与数据块分离的消息在发送时,无需进行消耗性能的数据拷贝来创建连续消息。此外,如果底层数据结构是环形缓冲区,指定一个三段消息将允许将头以及环形缓冲区中两个不相交的范围作为单个原子消息发送。这个概念在硬件上的等价物是分散/聚集(scatter/gather)DMA 设施。

图 1 多段传输。

多段传输

每个 IOV 最多可以有 524288 个段。各段大小之和不得超过 SSIZE_MAX。

多段传输也被文件系统广泛使用。在读取时,数据使用一个包含 n 个数据段的消息,直接从文件系统缓存拷贝到应用中。每个段指向缓存;这弥补了缓存块在内存中不连续的事实。

例如,缓存块大小为 512 字节时,读取 1454 字节可以用一个五段消息来满足:

图 2 读取 1454 字节的分散/聚集。

分散/聚集

由于消息数据是在地址空间之间显式拷贝的(而不是通过页表操作),消息可以轻松地分配在栈上,而不是从用于 MMU 页面翻转(page flipping)的特殊页对齐内存池中分配。因此,实现客户端与服务器进程之间 API 的许多库例程都可以平凡地表达,而无需复杂的 IPC 专用内存分配调用。

例如,客户端线程用来请求文件系统管理器代表其执行 lseek 的代码如下实现:

复制代码
#include <unistd.h>
#include <errno.h>
#include <sys/iomsg.h>

off64_t lseek64(int fd, off64_t offset, int whence) {
    io_lseek_t msg;
    off64_t off;

    msg.i.type = _IO_LSEEK;
    msg.i.combine_len = sizeof msg.i;
    msg.i.offset = offset;
    msg.i.whence = whence;
    msg.i.zero = 0;
    if(MsgSend(fd, &msg.i, sizeof msg.i, &off, sizeof off) == -1) {
        return -1;
    }
    return off;
}

off_t lseek(int fd, off_t offset, int whence) {
    return lseek64(fd, offset, whence);
}

这段代码本质上是在栈上构建一个消息结构,用各种常量以及来自调用线程的传入参数填充它,并将其发送给与 fd 相关联的文件系统管理器。应答指示操作的成功或失败。

注:由于传递的许多消息都很小,将这样的消息通过中间缓冲区拷贝两次,比操纵页表来进行直接的进程到进程拷贝更快,因此内核将其作为短消息的一种优化。

3.3 简单消息

对于简单的单段消息,操作系统提供的函数直接接受指向缓冲区的指针,而无需 IOV(输入/输出向量)。在这种情况下,段的数量被消息的大小直接替换。

以消息发送原语为例------它接受一个发送缓冲区和一个应答缓冲区------这就引入了四种变体:

函数 发送消息 应答消息
MsgSend() 简单 简单
MsgSendsv() 简单 IOV
MsgSendvs() IOV 简单
MsgSendv() IOV IOV

其他接受直接消息的消息传递原语,只是在其名称中去掉末尾的 v:

IOV 简单直接
MsgReceivev() MsgReceive()
MsgReceivePulsev() MsgReceivePulse()
MsgReplyv() MsgReply()
MsgReadv() MsgRead()
MsgWritev() MsgWrite()

3.4 通道与连接

在 QNX Neutrino RTOS 中,消息传递是针对通道(channel) 和**连接(connection)**的,而不是直接在线程与线程之间进行。一个希望接收消息的线程首先创建一个通道;另一个希望向该线程发送消息的线程,必须首先通过挂接到该通道来建立连接。

通道是消息内核调用所必需的,服务器用它来在上面执行 MsgReceive() 接收消息。连接由客户端线程创建,用于连接到服务器提供的通道。一旦建立了连接,客户端就可以通过它们执行 MsgSend() 发送消息。如果一个进程中的多个线程都挂接到同一个通道,那么这些连接为了效率都映射到同一个内核对象。

通道和连接在进程内由一个小的整数标识符命名。客户端连接直接映射到文件描述符(FD)。

从架构上讲,这是一个关键点。通过让客户端连接直接映射到 FD,我们又消除了一层转换。我们无需根据文件描述符来弄清把消息发送到哪里(例如通过 read(fd) 调用)。相反,我们可以简单地将消息直接发送到文件描述符(即连接 ID)。

函数 描述
ChannelCreate() 创建一个用于接收消息的通道
ChannelDestroy() 销毁一个通道
ConnectAttach() 创建一个用于发送消息的连接
ConnectDetach() 分离一个连接

图 1 连接优雅地映射到文件描述符。

连接映射到文件描述符

一个充当服务器的进程将实现一个事件循环来接收和处理消息,如下所示:

复制代码
chid = ChannelCreate(flags);
SETIOV(&iov, &msg, sizeof(msg));
for(;;) {
    rcv_id = MsgReceivev( chid, &iov, parts, &info );

    switch( msg.type ) {
        /* Perform message processing here */
    }

    MsgReplyv( rcv_id, &iov, rparts );
}

这个循环允许服务器线程从任何与该通道有连接的线程接收消息。

注 :服务器也可以使用 name_attach() 来创建通道并为其关联一个名称。发送方进程随后可以使用 name_open() 定位该名称并创建到它的连接。

通道有多个与之关联的消息列表:

  • Receive(接收):一个 LIFO 队列,存放等待消息的线程。
  • Send(发送):一个优先级 FIFO 队列,存放已发送但尚未被接收的消息的线程。
  • Reply(应答):一个无序列表,存放已发送、已被接收但尚未被应答的消息的线程。

当线程处于这些列表中的任何一个时,它就处于阻塞状态(即 RECEIVE、SEND 或 REPLY 阻塞)。多个线程和多个客户端可以在一个通道上等待。

3.5 脉冲(Pulses)

除了同步的发送/接收/应答服务之外,操作系统还支持固定大小的、非阻塞的消息。这些被称为脉冲(pulse),携带一个小的载荷(四个字节的数据加上一个单字节的代码)。

脉冲打包了一个相对较小的载荷------8 位的代码和 32 位的数据。脉冲通常被用作中断处理程序内部的通知机制。它们还允许服务器在不阻塞于客户端的情况下向客户端发信号。

图 1 脉冲打包一个小载荷。

脉冲

3.6 优先级继承与消息

QNX Neutrino 使用消息驱动的优先级继承来避免优先级反转(priority-inversion)问题。

服务器进程按优先级顺序接收消息和脉冲。服务器内接收消息的线程继承发送线程和脉冲的优先级。因此,向服务器请求工作的线程的相对优先级得以保留,服务器的工作将在适当的优先级上执行。

例如,假设系统包括以下内容:

  • 一个服务器线程,优先级 22
  • 一个客户端线程 T1,优先级 13
  • 一个客户端线程 T2,优先级 10

如果没有优先级继承,如果 T2 向服务器发送消息,实际上是以优先级 22 为它完成工作,因此 T2 的优先级被反转了。

实际发生的情况是:当服务器收到消息时,它的有效优先级变为最高优先级发送者的优先级(受下文所述限制)。在这种情况下,T2 的优先级低于服务器的优先级,所以服务器有效优先级的变化发生在服务器接收消息时。

接下来,假设 T1 在服务器仍处于优先级 10 时向它发送消息。由于 T1 的优先级高于服务器当前的优先级,服务器优先级的变化发生在 T1发送消息时。

这一变化发生在服务器接收消息之前,以避免另一种优先级反转情况。如果服务器的优先级保持在 10 不变,而另一个线程 T3 以优先级 11 开始运行,服务器就必须等到 T3 让它获得一些 CPU 时间,才能最终接收 T1 的消息。这样,T1 就会被优先级较低的线程 T3 延迟。

如果线程中最高的优先级是特权优先级,而服务器没有启用 PROCMGR_AID_PRIORITY 能力,那么服务器线程会被提升到最高的非特权优先级。更多信息请参阅《QNX Neutrino 微内核》一章的《调度优先级》,以及《C 库参考》中的 procmgr_ability()。

服务器线程的优先级在向客户端应答时不会恢复到之前的设置。新的优先级持续有效,直到发生以下情况之一:

  • 当一个更高优先级的线程发送另一条消息,或发送一个更高优先级的脉冲时,优先级被提升。
  • 当从较低优先级的线程接收消息,或接收较低优先级的脉冲时,优先级被降低。
  • 通过诸如 pthread_setschedparam() 或 pthread_setschedprio() 之类的函数调用显式设置优先级。

注 :你可以在调用 ChannelCreate() 时指定 _NTO_CHF_FIXED_PRIORITY 标志来关闭优先级继承。

3.7 消息传递 API

消息传递 API 由以下函数组成:

函数 描述
MsgSend() 发送一条消息并阻塞直到应答
MsgReceive() 等待一条消息
MsgReceivePulse() 等待一条微小的非阻塞消息(脉冲)
MsgReply() 应答一条消息
MsgError() 只以错误状态应答,不传输消息字节
MsgRead() 从已接收的消息中读取额外的数据
MsgWrite() 向应答消息写入额外的数据
MsgInfo() 获取已接收消息的信息
MsgSendPulse() 发送一条微小的非阻塞消息(脉冲)
MsgDeliverEvent() 向客户端投递一个事件
MsgKeyData() 为消息设置密钥以允许安全检查

关于消息的编程视角信息,请参阅《QNX Neutrino 快速入门》的《消息传递》一章。

3.8 使用发送/接收/应答的健壮实现

将 QNX Neutrino 应用架构为一组通过发送/接收/应答协作的线程和进程,会得到一个使用同步通知的系统。因此,IPC 发生在系统内指定的转换点,而不是异步地发生。

异步系统的一个重大问题是:事件通知需要运行信号处理程序。异步 IPC 可能使彻底测试系统的运行变得困难,也难以确保无论信号处理程序何时运行,处理都会按预期继续。应用常常试图通过依赖一个显式打开和关闭的窗口来避免这种情况,在该窗口期间信号被容忍。

有了围绕发送/接收/应答构建的同步、非排队系统架构,健壮的应用架构可以非常容易地实现和交付。

当从排队 IPC、共享内存和杂项同步原语的各种组合构建应用时,避免死锁是另一个难题。例如,假设线程 A 直到线程 B 释放互斥锁 2 才释放互斥锁 1。不幸的是,如果线程 B 处于直到线程 A 释放互斥锁 1 才释放互斥锁 2 的状态,就会形成僵局。为了确保系统运行时不会发生死锁,常常需要借助仿真工具。

发送/接收/应答 IPC 原语允许通过仅遵守这些简单规则来构建无死锁的系统:

  • 绝不要让两个线程相互发送。
  • 始终将你的线程排列成层级结构,发送向上(朝树的上层)进行。

第一条规则是对僵局情况的明显规避,但第二条规则需要进一步解释。假设协作的线程和进程组排列如下:

图 1 线程应始终向上层线程发送。

线程树

这里,层级结构中任何给定级别的线程从不相互发送,而是只向上发送。

这方面的一个例子可能是:一个客户端应用发送给数据库服务器进程,后者又发送给文件系统进程。由于发送线程阻塞并等待目标线程应答,且目标线程并未 SEND 阻塞在发送线程上,因此不会发生死锁。

但是,高层线程如何通知低层线程它已得到先前请求操作的结果呢?(假设低层线程上次发送时并不想等待应答结果。)

QNX Neutrino RTOS 通过 MsgDeliverEvent() 内核调用提供了一个非常灵活的架构,用于投递非阻塞事件。所有常见的异步服务都可以用它来实现。例如,poll() 调用的服务器端是一个 API,应用可以用它来让线程等待一组文件描述符上的 I/O 事件完成。除了需要一个异步通知机制作为从高层线程到低层线程通知的返回通道之外,我们还可以围绕它构建一个可靠的定时器、硬件中断和其他事件源的通知系统。

图 2 高层线程可以发送一个脉冲事件。

脉冲事件

一个相关的问题是:高层线程如何在不向低层线程发送(以免冒死锁风险)的情况下,请求低层线程工作。低层线程的存在只是为了充当高层线程的工作线程,按请求做事。低层线程会发送以报告工作,但高层线程当时不应答。它会推迟应答,直到高层线程有工作要做,然后它才应答(这是一个非阻塞操作),并带上描述工作的数据。实际上,是用应答来发起工作,而不是用发送,这就巧妙地绕过了规则 #1。

3.9 事件(Events)

QNX Neutrino 内核设计中的一个重要特性是事件处理子系统。POSIX 及其实时扩展定义了多种异步通知方法(例如,不排队、不传递数据的 UNIX 信号;可能排队并传递数据的 POSIX 实时信号等)。

内核还定义了额外的、QNX Neutrino 专属的通知技术,例如脉冲。实现所有这些事件机制可能会消耗大量的代码空间,所以我们的实现策略是在一个单一的、丰富的事件子系统之上构建所有这些通知方法。

这种方法的一个好处是:某一种通知技术独有的能力可以提供给其他技术。例如,应用可以将 POSIX 实时信号的排队服务应用于 UNIX 信号。这可以简化应用中信号处理程序的健壮实现。

执行线程遇到的事件可以来自以下三个来源中的任何一个:

  • 线程调用的 MsgDeliverEvent() 内核调用
  • 中断处理程序
  • 定时器的到期

事件本身可以是多种不同类型中的任何一种:QNX Neutrino 脉冲、中断、各种形式的信号,以及强制解除阻塞(unblock)事件。解除阻塞是一种手段,通过它线程可以从刻意阻塞的状态中释放出来,而实际上没有任何显式事件被投递。

鉴于事件类型的多样性,以及应用需要请求最适合其需求的异步通知技术的能力,要求服务器进程(前一节中的高层线程)携带支持所有这些选项的代码是很别扭的。

相反,客户端线程可以给服务器一个数据结构,或者说一个被称为 sigevent 的 cookie,让服务器保存到以后使用。当服务器需要通知客户端线程时,它调用 MsgDeliverEvent(),微内核就将 cookie 中编码的事件类型设置到客户端线程上。

图 1 客户端向服务器发送一个 sigevent。

sigevent

关于 sigevent 结构的详细信息,请参阅《C 库参考》中对应的条目;关于如何使用它的描述,请参阅《QNX Neutrino 快速入门》中《时钟、定时器与周期性唤醒》一章的《通知方案》。

在一个完全由可信程序组成的深度嵌入式系统中,事件上无需任何防护,但在更开放的系统里并非如此。sigevent 的一个问题是:服务器可以向客户端投递任何事件,即使客户端从未表示过接收事件的兴趣。

在 QNX Neutrino 7.0.1 或更高版本中,客户端可以注册事件以使其更安全,方法如下:

  • 客户端设置 sigevent 以指示它希望被通知的方式。
  • 如果客户端希望允许服务器更新与事件关联的值,它在事件中设置 SIGEV_FLAG_UPDATEABLE 标志。
  • 客户端通过调用 MsgRegisterEvent() 注册一个事件。该函数将给定的 sigevent 注册到内核,内核在内部存储该事件,并为该事件提供一个句柄。
  • 为了防止投递未注册的事件,客户端在调用 ConnectAttach() 连接服务器时设置 _NTO_COF_REG_EVENTS 标志。
  • 客户端将句柄(而不是实际事件)交给服务器。
  • 如果客户端在事件上设置了 SIGEV_FLAG_UPDATEABLE 标志,则允许服务器更新已注册事件中包含的值。
  • 当服务器用句柄调用 MsgDeliverEvent() 时,内核查找该句柄。如果句柄存在且客户端已允许服务器投递它,内核就向客户端投递相应的已注册事件。
  • 当客户端不再需要该安全事件时,它可以调用 MsgUnregisterEvent() 将其移除并注销句柄。

这样,客户端就可以确保它只获得自己想要的事件,并且没有人篡改过它们。示例程序请参阅《C 库参考》中 MsgRegisterEvent() 的条目。

在 QNX Neutrino 7.1 或更高版本中,默认只能使用已注册事件,但你可以为 procnto 使用 -U 选项来同时允许使用未注册事件。这个选项是一个临时变通方案,你可以使用它,直到你将程序转换为使用已注册事件;-U 选项将在未来的版本中移除。

你可以使用 secpolgenerate(通过文件 /dev/secpolgenerate/errors)或 secpolmonitor(当指定 -u 时)来检测进程何时使用了未注册事件。

3.10 信号(Signals)

操作系统支持 32 个标准 POSIX 信号(与 UNIX 相同)以及 POSIX 实时信号,两者都从内核实现的一组 64 个信号中编号,具有统一的功能。虽然 POSIX 标准定义实时信号与 UNIX 风格信号不同(实时信号可以包含四个字节的数据和一个字节代码,并且可以排队投递),但这一功能可以在逐个信号的基础上显式地选择或取消,从而使这种融合实现仍能符合标准。

顺便说一下,如果应用想要,UNIX 风格的信号也可以选择 POSIX 实时信号排队。QNX Neutrino RTOS 还通过允许信号针对特定线程(而不是简单地针对包含这些线程的进程)来扩展 POSIX 的信号投递机制。由于信号是异步事件,它们也用事件投递机制来实现。

微内核调用 POSIX 调用 描述
SignalKill() kill()、pthread_kill()、raise()、sigqueue() 在进程组、进程或线程上设置一个信号
SignalAction() sigaction() 定义收到信号时要采取的动作
SignalProcmask() sigprocmask()、pthread_sigmask() 更改线程的信号阻塞掩码
SignalSuspend() sigsuspend()、pause() 阻塞直到信号调用信号处理程序
SignalWaitinfo() sigwaitinfo() 等待信号并返回其信息

最初的 POSIX 规范只定义了进程上的信号操作。在多线程进程中,遵循以下规则:

  • 由 CPU 异常引起的信号(例如 SIGSEGV、SIGBUS)总是投递给引发该异常的线程。这些是同步信号,不能被线程的信号掩码阻塞。
  • 信号动作在进程级别维护。如果一个线程为某个信号指定了动作(例如忽略或捕获它),该操作影响进程内所有线程。
  • 信号掩码在线程级别维护。如果一个线程阻塞某个信号,阻塞只影响该线程。
  • 一个未被忽略的、针对线程的信号只投递给该线程。
  • 一个未被忽略的、针对进程的信号投递给第一个未阻塞该信号的线程。如果所有线程都阻塞了该信号,信号将在进程上排队,直到任何线程忽略或解除阻塞该信号。如果被忽略,进程上的信号将被移除。如果被解除阻塞,信号将从进程移到解除阻塞它的线程。

当信号针对一个拥有大量线程的进程时,必须扫描线程表,寻找一个未阻塞该信号的线程。大多数多线程进程的标准做法是:在所有线程中屏蔽该信号,只保留一个专门处理它的线程。为了提高进程信号投递的效率,内核会缓存最后一个接受信号的线程,并总是首先尝试向它投递信号。

图 1 信号投递。

信号投递

POSIX 标准包括排队实时信号的概念。QNX Neutrino RTOS 支持任何信号的可选排队,而不仅仅是实时信号。排队可以在进程内逐个信号地指定。每个信号可以有一个关联的 8 位代码和一个 32 位值。

这与前面描述的脉冲非常相似。内核利用这种相似性,使用公共代码来管理信号和脉冲。信号编号使用 _SIGMAX -- signo 映射到脉冲优先级。因此,信号按优先级顺序投递,较低 编号的信号具有较高优先级。这符合 POSIX 标准,该标准规定现有信号的优先级高于新的实时信号。

注:(QNX Neutrino 7.1 或更高版本)内核在进入和离开信号处理程序时保存和恢复 FPU 上下文,因此在信号处理程序中使用浮点运算是安全的。

3.11 POSIX 消息队列

POSIX 定义了一组非阻塞的消息传递设施,称为消息队列(message queue)。

与管道类似,消息队列是有名称的对象,与读者和写者一起运行。作为离散消息的优先级队列,消息队列比管道具有更多的结构,并为应用提供对通信的更多控制。

POSIX 消息队列为许多实时程序员提供了一个熟悉的接口,因为它们类似于许多实时执行体中的邮箱(mailbox)。

注 :要使用 POSIX 消息队列,消息队列服务器必须正在运行。QNX Neutrino 有两种消息队列实现:

  • 一种传统实现,使用 mqueue 资源管理器(见本书《资源管理器》一章)
  • 一种替代实现,使用 mq 服务器和内核空间中的队列

关于这些实现的更多信息,请参阅《实用工具参考》以及《POSIX 消息队列:两种实现》技术说明。

QNX Neutrino 原生消息与 POSIX 消息队列之间有一个根本区别:我们的消息是阻塞的------它们在发送消息的进程地址空间之间直接拷贝数据。另一方面,POSIX 消息队列实现的是存储转发(store-and-forward)设计,发送方无需阻塞,并且可以有多个未处理的消息排队。POSIX 消息队列独立于使用它们的进程而存在。你可以在这样的设计中使用消息队列:多个命名队列随着时间的推移被各种进程操作。

你应该用哪种?

消息队列由 POSIX 定义,更具可移植性,但原生消息有几个优势:

  • 更好的性能
  • 无界、非固定的消息长度
  • 优先级继承
  • 已知的发送者

消息队列至少在接口方面类似于文件。POSIX 定义了以下可用于管理消息队列的函数(更多信息见《C 库参考》):

函数 描述
mq_open() 打开一个消息队列
mq_close() 关闭一个消息队列
mq_unlink() 移除一个消息队列
mq_send() 向消息队列添加一条消息
mq_receive() 从消息队列接收一条消息
mq_notify() 告知调用进程消息队列上有可用消息
mq_setattr() 设置消息队列属性
mq_getattr() 获取消息队列属性

为了严格符合 POSIX,你应该创建以单个斜杠(/)开头且不包含其他斜杠的消息队列。但请注意,我们通过支持可能包含多个斜杠的路径名来扩展 POSIX 标准。例如,这允许一家公司将其所有消息队列放在其公司名称之下,并以更高的信心分发产品,确信队列名称不会与其他公司的队列名称冲突。

在 QNX Neutrino 中,创建的所有消息队列都出现在文件名空间中以下目录下:

  • 如果使用传统(mqueue)实现,则在 /dev/mqueue 下
  • 如果使用替代(mq)实现,则在 /dev/mq 下

例如,使用传统实现:

mq_open() 名称 消息队列路径名
/data /dev/mqueue/data
/acme/data /dev/mqueue/acme/data
/qnx/data /dev/mqueue/qnx/data

你可以使用 ls 命令显示系统中的所有消息队列,如下所示:

复制代码
ls -Rl /dev/mqueue

打印的大小是等待中的消息数量。

3.12 共享内存

共享内存提供了可用的最高带宽 IPC。

一旦创建了共享内存对象,能够访问该对象的进程就可以使用指针直接读写它。这意味着对共享内存的访问本身是未同步的。如果一个进程正在更新共享内存的某个区域,必须小心防止另一个进程读取或更新同一区域。即使在简单的读取情况下,另一个进程也可能得到处于变化之中、不一致的信息。

为了解决这些问题,共享内存常常与同步原语之一结合使用,以使进程之间的更新成为原子的。如果更新的粒度很小,那么同步原语本身就会限制使用共享内存固有的高带宽。因此,共享内存在用于整块更新大量数据时最为高效。

信号量和互斥锁都是适合与共享内存一起使用的同步原语。信号量是随 POSIX 实时标准引入的,用于进程间同步。互斥锁是随 POSIX 线程标准引入的,用于线程同步。互斥锁也可以用于不同进程中的线程之间。POSIX 认为这是一种可选能力;我们支持它。一般来说,互斥锁比信号量更高效。

3.13 类型化内存(Typed memory)

POSIX 类型化内存提供了一个接口,让你打开命名的内存对象并对其执行映射操作。

类型化内存在 BSP 或板级特定的地址布局与设备驱动或用户代码之间提供抽象时很有用。你可以为特定用途(例如图形)预先保留内存,并将该内存从程序通常从中分配的系统 RAM 中移除。

POSIX 规定类型化内存池(或对象)以特定于实现的方式创建和定义。在 QNX Neutrino 下,类型化内存对象是从系统页的 asinfo 部分中指定的内存区域定义的。因此,类型化内存对象直接映射到由 startup 定义的地址空间层次结构(asinfo 段)。类型化内存对象也继承 asinfo 中定义的属性,即内存段的物理地址(或边界)。

一般来说,asinfo 条目的命名和属性是任意的,完全由用户控制。然而,有一些强制性的条目:

  • memory:处理器的物理可寻址性,取决于架构。
  • ram:系统上的所有 RAM。这可能由多个条目组成。
  • sysram:系统 RAM,即交给操作系统管理的内存。这也可能由多个条目组成。操作系统使用这个池进行系统上所有通用内存分配,包括代码、数据、栈、堆、内核和进程管理器数据、共享内存对象以及内存映射文件。

你可以创建额外的条目,但只能在 startup 中使用 as_add() 函数(见《构建嵌入式系统》的《启动库》一章)。

注:类型化内存对象可以在 SYSRAM 之内,也可以在 SYSRAM 之外。在 SYSRAM 之内的对象用于将正常分配限制在正常池的一个子集内,而在 SYSRAM 之外的对象用于划出不可用于通用分配的区域。

类型化内存区域的名称直接源自 asinfo 段的名称。asinfo 部分本身描述了一个层次结构,因此类型化内存对象的命名是一个层次结构。它们的名称可以包含中间的斜杠(/)字符,这些字符被视为路径组件分隔符。

让我们看一个示例 asinfo 配置,你可以用 pidin syspage=asinfo 命令来显示:

复制代码
Header size=0x00000108, Total Size=0x00000cd0, #Cpu=2, Type=256
Section:asinfo offset:0x00000710 size:0x00000340 elsize:0x00000020
 0000) 0000000000000000-000000000000ffff o:ffff a:0000 p:100 c:0 n:/io
 0020) 0000000000000000-000fffffffffffff o:ffff a:0010 p:100 c:0 n:/memory
 0040) 0000000000000000-00000000ffffffff o:0020 a:0010 p:100 c:0 n:/memory/below4G
 0060) 0000000000000000-0000000000ffffff o:0020 a:0010 p:100 c:0 n:/memory/isa
 0080) 0000000006000000-00000000ffefffff o:0020 a:0013 p:100 c:0 n:/memory/device
 00a0) 00000000fff00000-00000000ffffffff o:0020 a:0005 p:100 c:0 n:/memory/rom
 00c0) 0000000000000000-000000000009f7ff o:0060 a:0017 p:100 c:0 n:/memory/isa/ram
 00e0) 0000000000100000-0000000000ffffff o:0060 a:0037 p:100 c:0 n:/memory/isa/ram
 0100) 0000000001000000-0000000005ffffff o:0040 a:0037 p:100 c:0 n:/memory/below4G/ram
 0120) 0000000006000000-000000003fedffff o:0080 a:0017 p:100 c:0 n:/memory/device/ram
 0140) 000000003ff00000-000000003fffffff o:0080 a:0017 p:100 c:0 n:/memory/device/ram
 0160) 00000000000f6a00-00000000000f6a23 o:0020 a:0007 p:100 c:0 n:/memory/acpi_rsdp
 0180) 00000000fee00000-00000000fee003ef o:0020 a:0003 p:100 c:0 n:/memory/lapic
 01a0) 000000000142c038-0000000001c9bb7b o:0020 a:0005 p:100 c:0 n:/memory/imagefs
 01c0) 0000000001400f30-000000000142c037 o:0020 a:0007 p:100 c:0 n:/memory/startup
 01e0) 000000000142c038-0000000001c9bb7b o:0020 a:0007 p:100 c:0 n:/memory/bootram
 0200) 0000000000000000-00000000ffffffff o:ffff a:0010 p:100 c:0 n:/virtual
 0220) ffff800000001000-ffff8000000f25d0 o:0200 a:0000 p:100 c:0 n:/virtual/vboot
 0240) 0000000000001000-000000000009efff o:00c0 a:0007 p:100 c:0 n:/memory/isa/ram/sysram
 0260) 0000000000106000-0000000000108fff o:00e0 a:0007 p:100 c:0 n:/memory/isa/ram/sysram
 0280) 000000000010f000-00000000004f6fff o:00e0 a:0007 p:100 c:0 n:/memory/isa/ram/sysram
 02a0) 00000000004f8000-0000000000ffffff o:00e0 a:0027 p:100 c:0 n:/memory/isa/ram/sysram
 02c0) 0000000001000000-000000000142bfff o:0100 a:0007 p:100 c:0 n:/memory/below4G/ram/sysram
 02e0) 0000000001c9c000-0000000005ffffff o:0100 a:0027 p:100 c:0 n:/memory/below4G/ram/sysram
 0300) 0000000006000000-000000003f2d4fff o:0120 a:0007 p:100 c:0 n:/memory/device/ram/sysram
 0320) 000000003ff00000-000000003fffffff o:0140 a:0007 p:100 c:0 n:/memory/device/ram/sysram

关于上述信息的详细说明,请参阅《构建嵌入式系统》的《系统页》一章中关于 asinfo 的部分。

你使用 posix_typed_mem_open() 打开类型化内存对象,指定对象名称、访问模式以及指示对象被映射时行为的标志。你传给这个函数的名称遵循上述命名约定。如果名称以斜杠(/)开头,则进行精确匹配。POSIX 允许实现定义名称不以斜杠开头时发生的情况;在 QNX Neutrino 中,对指定的路径名组件进行尾匹配(tail match)。

你还可以在段之间使用 & 和 | 来分别指定交集和并集。你可以指定任意数量的它们;它们从左到右求值,但不支持括号。

以下是使用上述示例配置时,posix_typed_mem_open() 如何解析名称的一些例子:

这个名称 解析为
/memory /memory
isa 失败,因为 isa 不是层次结构中的叶子
/memory/rom /memory/rom
/sysram 失败,因为 sysram 不在层次结构的顶部
sysram 所有 sysram 段,包括四个 /memory/isa/ram/sysram、两个 /memory/device/below4G/sysram 和两个 /memory/device/ram/sysram
below4G&sysram below4G 的所有物理地址范围与 sysram 的所有物理地址范围的交集。例如,如果你有两个内存区域,一个是 /ram/below4G,地址从 0 到 4 GB;另一个是 /ram/sysram,地址从 1 GB 到 6 GB,那么 below4G&sysram 给出 1 GB 到 4 GB 范围内的页

指定并集或交集可能比指定完整名称更有用。

作为对 POSIX 的扩展,类型化内存名称层次结构通过进程管理器命名空间在 /dev/tymem 下导出。应用可以列出这个层次结构,并查看系统页中的 asinfo 条目来获取类型化内存的信息。

注 :你不能通过命名空间接口打开类型化内存(正如你对共享内存对象所做的那样),因为 posix_typed_mem_open() 需要一个额外的 tflag 参数,而你在调用 open() 时无法提供该参数。

如果成功,posix_typed_mem_open() 返回类型化内存对象的文件描述符。你用这个文件描述符做什么,取决于你想采用哪种方法:

  • POSIX 方法,使用 mmap() 从类型化内存对象建立内存映射
  • QNX Neutrino 方法,使用 shm_open() 和 shm_ctl()。这不可移植,但更高效、更安全。

从类型化内存文件描述符映射的内存是被隐式锁定的。

POSIX 还定义了 posix_typed_mem_get_info(),你可以用它来获取类型化内存对象的信息。关于使用类型化内存的更多信息,请参阅《QNX Neutrino 程序员指南》中《使用内存》一章的《类型化内存》。

3.14 管道与 FIFO

管道和 FIFO 都是连接进程的队列形式。

注 :要在 QNX Neutrino RTOS 中使用管道或 FIFO,管道资源管理器( pipe)必须正在运行。

管道(Pipes)

管道是一个未命名的文件,充当两个或多个协作进程之间的 I/O 通道:一个进程写入管道,另一个进程从管道读取。

管道管理器负责缓冲数据。缓冲区大小在 文件中定义为 PIPE_BUF。管道一旦两端都关闭就被移除。函数 pathconf() 返回该限制的值。

管道通常用于两个进程希望并行运行、数据以单一方向从一个进程流向另一个进程的场合。(如果需要双向通信,应改用消息。)

管道的一个典型应用是将一个程序的输出连接到另一个程序的输入。这种连接通常由 shell 完成。例如:

复制代码
ls | more

将 ls 实用工具的标准输出通过管道导向 more 实用工具的标准输入。

| 如果你想 | 使用 |
|---------------|-------------------------|----|
| 在 shell 中创建管道 | 管道符号("\ | ") |
| 在程序中创建管道 | pipe() 或 popen() 函数 |

FIFO

FIFO 本质上与管道相同,只是 FIFO 是存储在文件系统目录中的命名永久文件。

如果你想 使用
在 shell 中创建 FIFO mkfifo 实用工具
在程序中创建 FIFO mkfifo() 函数
在 shell 中移除 FIFO rm 实用工具
在程序中移除 FIFO remove() 或 unlink() 函数

第四章 插桩微内核(The Instrumented Microkernel)

插桩版本的微内核(procnto-instr)配备了一套精密的跟踪(tracing)与剖析(profiling)机制,让你能够实时监控系统的执行情况。procnto-instr 模块在单 CPU 和 SMP 系统上都能工作。

procnto-instr 模块的开销非常小,性能极其出色------通常能达到非插桩内核约 98% 的速度(在不进行日志记录时)。插桩内核中额外增加的代码量(在 x86 系统上约 30 KB),相对于这个有用工具带来的额外功能和灵活性,是一个相对较小的代价。根据你最终系统的占用空间(footprint)要求,你可以选择将这个特殊内核用作开发/原型工具,或作为最终产品中的实际内核。

插桩模块是非侵入式 的------你无需修改程序的源代码,就能监控该程序如何与内核交互。你可以在内核与系统中任何运行中的线程或进程之间,跟踪任意多或任意少的交互(例如内核调用、状态变化和其他系统活动)。你甚至可以监控中断。在此上下文中,所有这些活动都被称为事件(event)。

更多详细信息,请参阅《系统分析工具包用户指南》(System Analysis Toolkit User's Guide)。

4.1 插桩概览

内核插桩涉及的基本任务如下:

  • 插桩微内核(procnto-instr)因各种系统活动而发出跟踪事件。这些事件被自动拷贝到一组缓冲区中,这些缓冲区被组织成一个循环链表。
  • 一旦缓冲区中的事件数量达到高水位标记,内核就通知一个数据采集实用工具。
  • 数据采集实用工具随后将缓冲区中的跟踪事件写入输出设备(例如串行端口、事件文件等)。
  • 数据解释设施随后解释这些事件,并将数据呈现给用户。

图 1 插桩概览。

插桩概览

4.2 事件控制

鉴于实时系统中发生的大量活动,内核发出的事件数量可能是压倒性的(就数据量、处理需求和存储所需的资源而言)。但你可以轻松控制发出的数据量。

具体来说,你可以:

  • 控制触发事件发出的初始条件
  • 应用预定义的内核过滤器来动态控制发出
  • 实现你自己的事件处理程序,以进行更多过滤

一旦数据被数据采集实用工具(tracelogger)收集,就可以进行分析。你可以在收集相关事件后实时分析数据,或离线分析数据。IDE 中的系统分析(System Analysis)工具以图形方式呈现这些数据,让你能看到系统中正在发生的事情。

4.3 数据解释

事件的数据包括一个高精度时间戳,以及生成该事件的 CPU 的 ID 号。这些信息帮助你轻松诊断困难的时序问题,而这类问题在多处理器系统上更有可能发生。

事件格式还包括 CPU 平台(例如 x86、ARM 等)和字节序类型,这有利于远程分析(无论是实时的还是离线的)。使用数据解释器,你可以以各种方式查看数据输出,例如:

  • 基于时间戳的整个系统的线性呈现
  • 仅显示活动线程/进程的运行视图
  • 每个进程/线程的基于状态的事件视图

数据解释器的线性输出可能看起来像这样:

复制代码
TRACEPRINTER version 0.94
 -- HEADER FILE INFORMATION --
 TRACE_FILE_NAME:: /dev/shmem/tracebuffer
 TRACE_DATE:: Fri Jun 8 13:14:40 2001
 TRACE_VER_MAJOR:: 0
 TRACE_VER_MINOR:: 96
 TRACE_LITTLE_ENDIAN:: TRUE
 TRACE_ENCODING:: 16 byte events
 TRACE_BOOT_DATE:: Fri Jun 8 04:31:05 2001
 TRACE_CYCLES_PER_SEC:: 400181900
 TRACE_CPU_NUM:: 4
 TRACE_SYSNAME:: QNX
 TRACE_NODENAME:: x86quad.gp.qa
 TRACE_SYS_RELEASE:: 6.1.0
 TRACE_SYS_VERSION:: 2001/06/04-14:07:56
 TRACE_MACHINE:: x86pc
 TRACE_SYSPAGE_LEN:: 2440
 -- KERNEL EVENTS --
t:0x1310da15 CPU:01 CONTROL :TIME msb:0x0000000f, lsb(offset):0x1310d81c
t:0x1310e89d CPU:01 PROCESS :PROCCREATE_NAME
 ppid:0
 pid:1
 name:./procnto-smp-instr
t:0x1310eee4 CPU:00 THREAD :THCREATE pid:1 tid:1
t:0x1310f052 CPU:00 THREAD :THRUNNING pid:1 tid:1
t:0x1310f144 CPU:01 THREAD :THCREATE pid:1 tid:2
t:0x1310f201 CPU:01 THREAD :THREADY pid:1 tid:2
t:0x1310f32f CPU:02 THREAD :THCREATE pid:1 tid:3
t:0x1310f3ec CPU:02 THREAD :THREADY pid:1 tid:3
t:0x1310f52d CPU:03 THREAD :THCREATE pid:1 tid:4
t:0x1310f5ea CPU:03 THREAD :THRUNNING pid:1 tid:4
t:0x1310f731 CPU:02 THREAD :THCREATE pid:1 tid:5

为了帮助你微调对事件数据流的解释,我们提供了一个库(traceparser),让你可以编写自己的自定义事件解释器。

4.4 主动跟踪

虽然插桩内核提供了一种出色的、不引人注目的方法来插桩和监控进程、线程以及系统的整体状态,但你也可以让你的应用主动地影响事件收集过程。

使用 TraceEvent() 库调用,应用本身可以将自定义事件注入跟踪流。这个设施在构建大型、紧密耦合的多组件系统时特别有用。

例如,下面这个简单的调用会将整数 eventcode、first 和 second 的值注入事件流:

复制代码
TraceEvent(_NTO_TRACE_INSERTSUSEREVENT, eventcode, first, second);

你还可以将字符串(例如 "My Event")注入事件流,如下面的代码所示:

复制代码
#include <stdio.h>
#include <sys/trace.h>

/* Code to associate with emitted events */
#define MYEVENTCODE 12

int main(int argc, char **argv) {
    printf("My pid is %d \n", getpid());

    /* Inject two integer events (26, 1975) */
    TraceEvent(_NTO_TRACE_INSERTSUSEREVENT, MYEVENTCODE,
                26, 1975);

    /* Inject a string event (My Event) */
    TraceEvent(_NTO_TRACE_INSERTUSRSTREVENT, MYEVENTCODE,
                "My Event");

    return 0;
}

由 traceprinter 数据解释器采集的输出看起来像这样:

复制代码
.
.
.
t:0x38ea737e CPU:00 USREVENT:EVENT:12, d0:26 d1:1975
.
.
.
t:0x38ea7cb0 CPU:00 USREVENT:EVENT:12 STR:"My Event"

请注意,为这些事件指定的跟踪用户事件代码是 12。


第五章 多核处理(Multicore Processing)

QNX Neutrino RTOS 可以运行在单核或多核系统上。多处理系统可以有以下形式:

分立式或传统式

一个系统,具有通过板级总线以多处理模式连接起来的独立物理处理器。

多核(Multicore)

一个芯片,具有一个物理处理器,多个 CPU 通过芯片级总线互连。

多核处理器通过并发性提供更强的计算能力,提供更高的系统密度,并且比单处理器芯片以更低的时钟速度运行。多核处理器还降低了热耗散、功耗和板面积(从而降低了系统成本)。

多处理包括几种运行模式:

  • 非对称(asymmetric):一个独立的操作系统,或同一操作系统的独立实例,在每个 CPU 上运行。
  • 对称(symmetric):操作系统的一个实例同时管理所有 CPU,应用可以浮动到其中任何一个。
  • 绑定(bound):操作系统的一个实例同时管理所有 CPU,但每个应用被锁定到特定的 CPU。

注 :要确定你系统上有多少个处理器,请查看系统页的 num_cpu 条目。更多信息请参阅《构建嵌入式系统》的《系统页》一章。

5.1 非对称多处理(AMP)

非对称多处理提供了一个类似于传统单处理器系统的执行环境。它为移植遗留代码提供了一条相对直接的路径,并提供了控制 CPU 使用方式的直接机制。在大多数情况下,它让你能够使用标准的调试工具和技术。

AMP 可以是:

  • 同构(homogeneous)------每个 CPU 运行相同类型和版本的操作系统
  • 异构(heterogeneous)------每个 CPU 运行不同的操作系统,或同一操作系统的不同版本

QNX Neutrino 的分布式编程模型让你能在同构环境中充分利用多个 CPU。在一个 CPU 上运行的应用可以透明地与另一个 CPU 上的应用和系统服务(例如设备驱动、协议栈)通信,而无需传统处理器间通信形式所强加的高 CPU 利用率。

在异构系统中,你必须实现专有的通信方案,或者选择两个共享公共基础设施(很可能是基于 IP 的)的操作系统来进行处理器间通信。为了帮助避免资源冲突,操作系统还应提供标准化的机制来访问共享硬件组件。

使用 AMP 时,由你决定应用所使用的共享硬件资源如何在 CPU 之间划分。通常,这种资源分配在启动期间静态发生,包括物理内存分配、外设使用和中断处理。虽然系统可以动态分配资源,但这样做需要 CPU 之间复杂的协调。

在 AMP 系统中,一个进程总是在同一个 CPU 上运行,即使其他 CPU 处于空闲状态。因此,某个 CPU 最终可能被利用不足或过度利用。为了解决这个问题,系统可以允许应用在 CPU 之间动态迁移。然而,这样做可能涉及复杂的状态信息检查点,或应用在一个 CPU 上停止、在另一个 CPU 上重启时可能的服务中断。此外,如果 CPU 运行不同的操作系统,这种迁移即使不是不可能,也很困难。

5.2 对称多处理(SMP)

在多核设计中分配资源可能很困难,尤其是当多个软件组件不知道其他组件如何利用这些资源时。

对称多处理通过只在整个系统的所有 CPU 上运行一份 QNX Neutrino RTOS 副本来解决这个问题。由于操作系统始终洞察所有系统元素,它可以在多 CPU 上分配资源,而几乎不需要应用设计者的输入。

此外,QNX Neutrino 提供了内置的标准化原语,例如 pthread_mutex_lock()、pthread_mutex_unlock()、pthread_spin_lock() 和 pthread_spin_unlock(),让多个应用能够安全、轻松地共享这些资源。

通过只运行一份 QNX Neutrino,SMP 可以动态地将资源分配给特定的应用,而不是 CPU,从而实现更高的可用处理能力利用率。它还让系统跟踪工具能够为整个多处理系统收集运行统计和应用交互,为你提供关于如何优化和调试应用的宝贵洞察。

例如,IDE 中的系统剖析器(System Profiler)可以跟踪线程从一个 CPU 到另一个 CPU 的迁移,以及操作系统原语使用、调度事件、应用到应用的消息传递和其他事件,全部带有高分辨率时间戳。应用同步也变得容易得多,因为你使用标准操作系统原语,而不是复杂的 IPC 机制。

QNX Neutrino 让应用内的执行线程可以在任何 CPU 上并发运行,使芯片的全部计算能力在任何时候都可供应用使用。QNX Neutrino 的抢占和线程优先级能力帮助你确保 CPU 周期流向最需要它的应用。

注 :在 QNX Neutrino 7.0 或更高版本中,我们要求 SMP 系统上所有处理器的 ClockCycles() 底层硬件保持同步。如果没有同步,你可能会遇到一些意外行为,例如时间漂移和定时器漂移。

5.3 绑定多处理(BMP)

绑定多处理提供了非对称多处理模型的调度控制,同时保留了对称多处理的硬件抽象和管理。

BMP 与 SMP 类似,但你可以指定线程可以在哪些处理器上运行。你可以在同一个系统上同时使用 SMP 和 BMP,允许一些线程从一个处理器迁移到另一个处理器,而其他线程被限制在一个或多个处理器上。

与 SMP 一样,一份操作系统副本维护所有系统资源的整体视图,允许它们在应用之间动态分配和共享。但是,在应用初始化期间,由系统设计者确定的一个设置会强制应用的所有线程只在指定的 CPU 上执行。

与完全的、浮动的 SMP 运行相比,这种方法有几个优势:

  • 它通过允许共享同一数据集的应用只在同一 CPU 上运行,消除了可能降低 SMP 系统性能的缓存抖动(cache thrashing)。
  • 它提供比 SMP 更简单的应用调试,因为应用内的所有执行线程都在单个 CPU 上运行。
  • 它帮助使用劣质技术同步共享数据的遗留应用正确运行,同样是通过让它们在单个 CPU 上运行。

使用 BMP 时,锁定到一个 CPU 的应用不能使用其他 CPU,即使它们空闲。然而,QNX Neutrino RTOS 让你能够动态更改指定的 CPU,而无需检查点,也无需停止和重启应用。

QNX Neutrino 通过一个 runmask(运行掩码) 支持硬处理器亲和性的概念。runmask 中设置的每个位代表一个线程可以在其上运行的处理器。默认情况下,线程的 runmask 被设置为全 1,允许它在任何处理器上运行。值 0x01 将允许线程只在第一个处理器上执行。

默认情况下,进程或线程的子进程不继承 runmask;有一个单独的继承掩码(inherit mask)。

通过仔细使用这些掩码,系统设计者可以进一步优化系统的运行时性能(例如,将非实时进程降级到特定处理器)。然而,一般来说,这不应该成为必要,因为我们的实时调度器总是会在更高优先级线程变为就绪时立即抢占较低优先级线程。处理器锁定可能只会影响缓存的效率,因为可以防止线程迁移。

你可以通过以下方式为新线程或进程指定 runmask:

  • 调用 posix_spawnattr_setrunmask(),使用 posix_spawnattr_setxflags() 设置 POSIX_SPAWN_EXPLICIT_CPU 扩展标志,然后调用 posix_spawn()。

或者:

  • 在调用 spawn() 时设置继承结构的 runmask 成员,并指定 SPAWN_EXPLICIT_CPU 标志。

或者:

  • 在启动程序时对 on 实用工具使用 -C 或 -R 选项。这也会将进程的继承掩码设置为相同的值。

你可以通过以下方式更改现有线程或进程的 runmask:

  • 对 ThreadCtl() 内核调用使用 _NTO_TCTL_RUNMASK 或 _NTO_TCTL_RUNMASK_GET_AND_SET_INHERIT 命令。

或者:

  • 对 slay 实用工具使用 -C 或 -R 选项。如果你还使用 -i 选项,slay 会将继承掩码设置为相同的值。

更多信息,请参阅《QNX Neutrino 程序员指南》的《多核处理》一章。

5.4 在 AMP、SMP 和 BMP 之间选择

在 AMP、SMP 和 BMP 之间的选择取决于你试图解决的问题:

  • AMP 适用于遗留应用,但在超过两个 CPU 之后可伸缩性有限。
  • SMP 提供透明的资源管理,但未针对并发性正确设计的软件可能会有问题。
  • BMP 提供许多与 SMP 相同的好处,但保证单处理器应用能正确运行,大大简化了遗留软件的迁移。

正如下表所示,从这些模型中的任何一种中进行选择的灵活性,让你能在性能、可伸缩性和迁移便利性之间取得最佳平衡。

特性 SMP BMP AMP
无缝资源共享 是 是 ---
可扩展到双 CPU 以上 是 是 有限
遗留应用运行 大多数情况下 是 是
混合操作系统环境(例如 QNX Neutrino 和 Linux) --- --- 是
按功能专用处理器 --- 是 是
核间消息传递 快(操作系统原语) 快(操作系统原语) 较慢(应用)
CPU 之间的线程同步 是 是 ---
负载均衡 是 是 ---
系统级调试和优化 是 是 ---

第六章 进程管理器(Process Manager)

进程管理器能够创建多个 POSIX 进程(每个进程可以包含多个 POSIX 线程)。

在 QNX Neutrino RTOS 中,微内核与进程管理器配对在一个单一模块(procnto)中。这个模块是所有运行时系统所必需的。它的主要职责包括:

  • 进程管理------管理进程的创建、销毁,以及进程属性,例如用户 ID(uid)和组 ID(gid)。
  • 内存管理------管理一系列内存保护能力、共享库,以及进程间 POSIX 共享内存原语。
  • 路径名管理------管理资源管理器可以挂接进来的路径名空间。

用户进程可以通过内核调用直接访问微内核函数,并通过向 procnto 发送消息来访问进程管理器函数。请注意,用户进程通过调用 MsgSend*() 内核调用来发送消息。

需要重点指出的是:在 procnto 内执行的线程,与在其他进程中的线程以完全相同的方式调用微内核。进程管理器代码与微内核共享同一进程地址空间这一事实,并不意味着存在特殊的或私有的接口。系统中的所有线程共享同一个一致的内核接口,并且都在调用微内核时执行权限切换。

6.1 进程管理

procnto 的首要职责是动态创建新进程。这些进程随后将依赖 procnto 的内存管理和路径名管理的其他职责。

进程管理包括进程的创建和销毁,以及进程属性的管理,例如进程 ID、进程组和用户 ID。

6.2 内存管理

虚拟内存系统实现了用于内存管理的 POSIX 模型,以及一些 QNX Neutrino 扩展。

这些扩展包括:

  • 包含指向内存对象映射的虚拟地址空间
  • 可以按页面大小粒度操作的映射
  • 使用持久文件系统后备存储的内存映射文件
  • 使用 RAM(带交换后备存储)的共享内存对象
  • 表示物理内存范围的类型化内存对象
  • 使用 shm_ctl() 和 shm_ctl_special() 接口的共享内存对象
  • MAP_ANON 用于分配匿名内存(不与文件或设备关联的内存)
  • 按需分页(demand paging),将页面的分配和映射推迟到它们被显式引用时
  • 尽可能广泛地使用写时复制(copy-on-write)共享
  • 支持匿名内存的交换后备存储
  • 一个全局页面替换算法,用于在内存压力下选择要丢弃的页面。如果实现了交换存储,这允许所有非固定(non-wired)页面成为潜在的替换候选;之前的 memmgr 只允许替换具有文件系统后备存储的页面。

内存管理器的任务是管理虚拟内存,而不是物理内存。虚拟内存是物理内存的一种抽象,它允许内存内容来自 RAM 中的任何位置,以及文件系统中的文件、映射的设备寄存器等。虚拟内存提供了每个进程的内存视图,这有助于安全性和保密性,但也允许共享内存对象和共享库。

图 1 虚拟内存是物理内存的抽象。

虚拟内存抽象

你运行 QNX Neutrino 的硬件必须具有内存管理单元(MMU)。MMU 在启动序列的早期、在内核自身启动之前就被打开。一旦 MMU 启动,就没有对物理内存的直接引用;MMU 将虚拟地址转换为物理地址。由内存管理器创建用于管理物理内存的信息,包括虚拟到物理地址的映射。

MMU 将物理内存划分为固定大小的块,称为页(page),通常是(但不总是)4 KB。一些物理内存用于镜像文件系统(image filesystem),内存管理器有一个启动堆(boot heap),用来分配它所需的数据结构。系统 RAM(或 sysram)是内存管理器可以分配以响应内存请求的物理内存。

图 2 管理物理内存。

物理内存管理

**区域(zone)**是一个固定大小的、连续的物理内存范围(目前为 16 MB)。将 RAM 划分为区域,是为了通过让区域专用于特定分配类型来减少碎片。

内存管理器创建足够的虚拟页(virtual page) 来服务所有可用内存,包括 sysram、文件等,但虚拟页和物理页之间没有固定的关联。如果一个虚拟页与一个物理页关联,我们说该虚拟页由物理内存后备(backed)。

图 3 虚拟页。

虚拟页

每个进程都有自己的虚拟地址空间,这是虚拟内存的每个进程视图。这保护了进程地址空间彼此隔离,防止一个进程中线程的编码错误损坏其他进程甚至操作系统中的线程所使用的内存。这种保护对开发和已安装的运行时系统都有用,因为它使事后分析(postmortem analysis)成为可能。几乎所有操作系统服务和驱动都是独立的进程。

进程的虚拟地址空间被划分为区域(region) ,这些区域是引用各种内存片段的连续虚拟地址范围。映射(map) 将虚拟地址与虚拟内存或物理设备关联起来,并包含权限。mmap() 函数创建区域和映射。最初没有内存后备这些区域。

图 4 两个虚拟地址空间,说明四种类型的故障。

地址空间

默认行为是按需故障(faulting)和分页;故障可能发生在各种情况下,对应上图中的编号:

  • 第一次访问用 mmap() 创建的区域时,发生故障,内存管理器将该映射与一个虚拟页和一个物理页关联起来。
  • 物理页在需要时后备虚拟页,但不一定总是被分配。当你访问一个没有后备的虚拟页时,内存管理器为它获取一个物理页。
  • 如果没有区域,则发生 SIGSEGV。
  • 两个地址空间有引用同一个虚拟页的区域。这可能发生在 fork() 之后,其中子进程有父进程区域的副本。共享内存类似。在这种情况下,映射被设为只读;在写入时(如果允许),发生故障,内存管理器为发生故障的进程分配一个与物理页关联的新虚拟页。

内存管理器跟踪后备所有已分配内存所需的内存量。如果没有足够的内存供其预留(reserve) ,mmap() 函数会失败。

内存被惰性后备的事实(即,先预留,然后在需要时后备)可能会导致对 mmap() 的 MAP_LAZY 标志产生一些困惑。如果你设置了这个标志,那么内存甚至不会被预留;随后发生故障会导致 SIGBUS。

使用故障来后备内存的例外情况,是映射隐式或显式由物理对象后备的东西。示例包括使用 MAP_PHYS 的映射、用 shm_ctl() 创建的共享内存对象,以及类型化内存。对于用 MAP_ANON 或 MAP_FILE 映射的内存,当页面最初被访问时发生故障,并且在后续访问时也可能发生故障。

6.3 内存记账(Memory accounting)

对系统和进程使用的内存进行记账可能很有用,尤其是在调试时。本章涵盖如何读取与内存相关的文件并计算预留内存的示例。由于不同类型映射的性质各不相同,这是一个复杂的过程,并且取决于你的需求。从以下页面提供的进程计数器计算得出的值,产生一个表示预留内存的通用值。要获得准确值,你需要根据内存管理器中找到的原始信息进行更复杂的计算。

术语

请注意本文档中使用的以下术语:

  • 匿名状态(Anonymous state):处于匿名状态的页是不由显式对象后备的页。
  • 域(Domain):连续物理内存段的集合。
  • 非活动状态(Inactive state):处于非活动状态的页是一个干净的、未映射的页,它可能被保存在缓存中。
  • 映射(Map):一种结构,用于为地址空间中的每个页构建页表条目。
  • 区域(Region) :区域是进程地址空间的一个子范围。它是 mmap() 调用的结果。区域中的页面都共享相同的后备对象、保护位和标志。
  • 固定状态(Wired state):处于固定状态的页是与物理内存永久关联的虚拟页。
  • 零页(Zero page):零页是一个不由文件后备的新页。

6.4 路径名管理

I/O 资源不是 构建在微内核中的,而是由可以在运行时动态启动的资源管理器进程提供的。procnto 管理器允许资源管理器通过标准 API 将路径名空间的一个子集采纳(adopt)为要管理的授权域。

随着其他资源管理器采纳它们各自的授权域,procnto 负责维护一个路径名树,以跟踪拥有路径名空间各部分的进程。被采纳的路径名有时被称为前缀(prefix) ,因为它前缀了位于其下的任何路径名;前缀可以排列成一个称为前缀树(prefix tree) 的层次结构。被采纳的路径名也被称为挂载点(mountpoint),因为那是服务器挂载到路径名中的位置。

这种路径名空间管理方法,使得 QNX Neutrino 能够保留设备和文件访问的 POSIX 语义,同时使这些服务的存在对于小型嵌入式系统而言是可选的。

在启动时,procnto 用以下路径名前缀填充路径名空间:

前缀 描述
/ 文件系统的根。
/proc/boot/ 启动镜像中的一些文件,以扁平文件系统的形式呈现。
/proc/pid 运行中的进程,每个进程由其进程 ID(PID)表示。更多信息请参阅《QNX Neutrino 程序员指南》中《进程》一章的《通过 /proc 文件系统控制进程》。
/dev/zero 一个总是返回零的设备。用于使用 mmap() 函数分配零填充页面。
/dev/mem 一个表示所有物理内存的设备。

第七章 动态链接(Dynamic Linking)

在一个典型的系统中,会有多个程序在运行。每个程序依赖多个函数,其中一些是标准 C 库函数,例如 printf()、malloc()、write() 等。

如果每个程序都使用标准 C 库,那么每个程序通常会在其内部拥有一份该特定库的独特副本。不幸的是,这会导致资源浪费。由于 C 库是公共的,让每个程序引用 该库的公共实例,比让每个程序包含一份该库的副本更有意义。这种方法带来几个优势,其中最重要的是节省了所需的总系统内存。

在我们继续之前,先看一些术语:

链接器(Linker)

一种工具,例如 ld,你通常在编译程序之后立即运行它,以便组合目标文件和归档文件、重定位它们的数据,并解析符号引用。

运行时链接器(Runtime linker)

一种工具,在你运行程序时查找并加载共享对象。它也被称为动态链接器(dynamic linker) ,但我们将使用运行时链接器以避免与动态链接(由非运行时链接器完成)混淆。

运行时链接器的名称是 ldqnx。在 ELF 文件的 .interp 段中,对于 32 位目标,它被称为 /usr/lib/ldqnx.so;对于 64 位目标,被称为 /usr/lib/ldqnx-64.so。你需要将适当的版本包含在你的操作系统镜像中;更多细节请参阅《实用工具参考》中 mkifs 的条目。

静态链接(Statically linked)

程序和它所链接的特定库在链接时由链接器组合在一起。

这意味着程序和特定库之间的绑定在链接时是固定的、已知的------远远早于程序运行之前。这也意味着除非我们用新版本的库重新链接程序,否则无法更改这种绑定。

在你不确定运行时是否有正确版本的库可用,或者你正在测试一个你还不希望作为共享库安装的新版本库时,你可以考虑静态链接程序。

静态链接的程序是针对对象的归档(库 )链接的,这些归档通常具有 .a 扩展名。这类对象集合的一个例子是标准 C 库 libc.a。

动态链接(Dynamically linked)

程序和它所引用的特定库在链接时不由链接器组合在一起。

相反,链接器将信息放入可执行文件中,告诉加载器代码位于哪个共享对象模块中,以及应该使用哪个运行时链接器来查找和绑定引用。这意味着程序和共享对象之间的绑定是在运行时完成的------在程序启动之前,找到并绑定适当的共享对象。

这种类型的程序被称为部分绑定可执行文件(partially bound executable) ,因为它没有被完全解析 ------链接器在链接时并未使程序中所有被引用的符号与库中的特定代码关联起来。相反,链接器只是说:这个程序调用了某个特定共享对象中的一些函数,所以我只记下这些函数位于哪个共享对象中,然后继续。实际上,这把绑定推迟到了运行时。

动态链接的程序是针对具有 .so 扩展名的共享对象链接的。这类对象的一个例子是标准 C 库的共享对象版本 libc.so。

你使用编译器驱动 qcc 的命令行选项来告诉工具链你是静态链接还是动态链接。这个命令行选项随后决定使用的扩展名(.a 或 .so)。

7.1 在运行时增强代码

更进一步,一个程序可能直到运行时才知道它需要调用哪些函数。虽然这最初可能看起来 有点奇怪(毕竟,一个程序怎么可能不知道它要调用什么函数?),但它确实可以是一个非常强大的特性。原因如下。

考虑一个通用的磁盘驱动。它启动、探测硬件,并检测到一个硬盘。然后该驱动会动态加载 io-blk 代码来处理磁盘块,因为它发现了一个面向块的设备。现在驱动在块级别可以访问磁盘,它发现磁盘上有两个分区:一个 DOS 分区和一个 Power-Safe 分区。与其强制磁盘驱动包含它可能遇到的所有可能分区类型的文件系统驱动,我们保持简单:它没有任何 文件系统驱动!在运行时,它检测到两个分区,然后 才知道它应该加载 fs-dos.so 和 fs-qnx6.so 文件系统代码来处理这些分区。

通过推迟调用哪些函数的决定,我们增强了磁盘驱动的灵活性(也减小了它的体积)。

7.2 共享对象如何使用

要理解程序如何使用共享对象,让我们先看看可执行文件的格式,然后检查程序启动时发生的步骤。

ELF 格式

QNX Neutrino RTOS 使用 ELF(可执行与链接格式,Executable and Linking Format)二进制格式。ELF 不仅简化了制作共享库的任务,还增强了模块在运行时的动态加载。

在下图中,我们展示了 ELF 文件的两种视图:链接视图和执行视图。链接视图在程序或库被链接时使用,处理对象文件内的节(section) 。节包含对象文件的大部分信息:数据、指令、重定位信息、符号、调试信息等。执行视图在程序运行时使用,处理段(segment)。

在链接时,程序或库是通过将具有相似属性的节合并成段来构建的。通常,所有可执行和只读数据节被组合成一个单一的文本段(text segment),而数据和 BSS 被组合成数据段(data segment)。这些段被称为加载段(load segment),因为它们在进程创建时需要被加载到内存中。其他节,例如符号信息和调试节,被合并到其他非加载段中。

图 1 对象文件格式:链接视图和执行视图。

对象文件格式

没有 COFF 的 ELF

大多数 ELF 加载器的实现源自 COFF(通用对象文件格式,Common Object File Format)加载器;它们在加载时使用 ELF 对象的链接视图。这是低效的,因为程序加载器必须使用节来加载可执行文件。一个典型的程序可能包含大量的节,每个节都必须在程序中定位并分别加载到内存中。

然而,QNX Neutrino 完全不依赖 COFF 的节加载技术。在开发我们的 ELF 实现时,我们直接基于 ELF 规范工作,并将效率放在首位。ELF 加载器使用程序的执行视图。这样做,加载器的任务被大大简化了:它所要做的只是将程序或库的加载段(通常是两个)拷贝到内存中。因此,进程创建和库加载操作要快得多。

7.3 典型进程的内存布局

下图显示了一个典型进程的内存布局。进程加载段(对应图中的 text 和 data)加载在进程的基地址处。主栈位于其下方并向下增长。创建的任何额外线程都将有自己的栈,位于主栈下方。每个栈都由一个保护页(guard page)隔开,以检测栈溢出。堆位于进程加载段上方,并向上增长。

图 1 x86 系统上的进程内存布局。

进程内存布局

在进程地址空间的中间,保留了一个大区域用于共享对象。共享库位于地址空间的顶部,并向下增长。

当创建新进程时,进程管理器首先将可执行文件的两个段映射到内存中。然后它解码程序的 ELF 头。如果程序头表明该可执行文件是针对共享库链接的,进程管理器将从程序头中提取动态解释器(dynamic interpreter) 的名称。动态解释器指向一个包含运行时链接器代码的共享库。进程管理器将此共享库加载到内存中,然后将控制权传递给该库中的运行时链接器代码。

7.4 运行时链接器

当一个针对共享对象链接的程序启动时,或当一个程序请求动态加载共享对象时,运行时链接器被调用。这个链接器包含在 ldqnx-64.so 库中,它与 libc 是分开的。

运行时链接器在加载共享库(.so 文件)时执行几项任务:

  • 如果请求的共享库尚未加载到内存中,运行时链接器会加载它:
  • 如果共享库名称是完全限定的(即以斜杠开头),则直接从指定位置加载。如果在那里找不到,则不进行进一步搜索。
  • 如果它不是完全限定的路径名,链接器按如下方式搜索:
  • 如果可执行文件的动态段包含 DT_RPATH 标签,则搜索 DT_RPATH 指定的路径。
  • 如果找不到共享库,运行时链接器在 LD_LIBRARY_PATH 指定的目录中搜索它。

> 注 :出于安全原因,如果二进制文件设置了 setuid 位,运行时链接器会取消设置 LD_LIBRARY_PATH。

  • 如果仍然找不到共享库,链接器搜索由传递给 procnto 的 LD_LIBRARY_PATH 环境变量(即 CS_LIBPATH 配置字符串)指定的默认库搜索路径。如果没有指定,则默认库路径设置为镜像文件系统的路径。
  • 一旦找到请求的共享库,就将其加载到内存中。对于 ELF 共享库,这是一个非常高效的操作:运行时链接器只需要使用 mmap() 调用两次,将两个加载段映射到内存中。
  • 然后,共享库被添加到进程已加载的所有库的内部列表中。运行时链接器维护这个列表。
  • 运行时链接器随后解码共享对象的动态段。

这个动态段向链接器提供关于该库所链接的其他库的信息。它还提供关于需要应用的重定位和需要解析的外部符号的信息。运行时链接器将首先加载任何其他所需的共享库(这些库本身可能引用其他共享库)。然后它将处理每个库的重定位。其中一些重定位是库本地的,而另一些需要运行时链接器解析全局符号。在后一种情况下,链接器将在库列表中搜索此符号。在 ELF 文件中,哈希表用于符号查找,因此速度非常快。搜索符号时库的顺序非常重要,正如我们将在下文的《符号名解析》一节中看到的那样。

一旦应用了所有重定位,就会调用在共享库的 init 段中注册的任何初始化函数。这在 C++ 的某些实现中用于调用全局构造函数。

7.5 在运行时加载共享库

进程可以使用 dlopen() 调用在运行时加载共享库,该调用指示运行时链接器加载此库。一旦库被加载,程序就可以通过使用 dlsym() 调用来确定其地址,从而调用该库中的任何函数。

注 :请记住:共享库只对 动态链接的进程可用。

程序还可以使用 dladdr() 调用来确定与给定地址关联的符号。最后,当进程不再需要共享库时,它可以调用 dlclose() 从内存中卸载该库。

7.6 符号名解析

当运行时链接器加载共享库时,该库内的符号必须被解析。符号解析的顺序和范围很重要。如果共享库调用了一个恰好与程序已加载的多个库中同名存在的函数,那么搜索此符号时这些库的顺序就至关重要。这就是为什么操作系统定义了在加载库时可以使用的几个选项。

所有具有全局作用域的对象(可执行文件和库)都存储在一个内部列表(全局列表,global list)上。默认情况下,任何全局作用域对象都将其所有符号提供给任何被加载的共享库。全局列表最初包含可执行文件以及程序启动时加载的任何库。

默认情况下,当使用 dlopen() 调用加载新的共享库时,该库内的符号按以下顺序搜索解析:

  • LD_PRELOAD 环境变量指定的库列表。你可以使用这个环境变量在运行程序时添加或更改功能。

> 注 :出于安全原因,如果二进制文件设置了 setuid 位,运行时链接器会取消设置 LD_PRELOAD。

  • 共享库本身
  • 全局列表
  • 共享库引用的任何依赖对象(即共享库所链接的任何其他库)

在 dlopen() 共享库时,运行时链接器的作用域行为可以通过两种方式更改:

  • 当程序加载新库时,它可以通过向 dlopen() 调用传递 RTLD_GLOBAL 标志,指示运行时链接器将该库的符号放在全局列表上。这将使该库的符号对随后加载的任何库可用。
  • 解析共享库内符号时搜索的对象列表可以被修改。如果向 dlopen() 传递 RTLD_GROUP 标志,则只搜索该库直接引用的对象来查找符号。如果传递 RTLD_WORLD 标志,则只搜索全局列表上的对象。

第八章 资源管理器(Resource Managers)

为了赋予 QNX Neutrino RTOS 极大的灵活性、最小化最终系统的运行时内存需求,并应对自定义嵌入式系统中可能发现的各种设备,操作系统允许用户编写的进程充当资源管理器(resource manager),它们可以被动态地启动和停止。

资源管理器通常负责为各种类型的设备呈现一个接口。这可能涉及管理实际的硬件设备(如串行端口、并行端口、网卡和磁盘驱动器)或虚拟设备(如 /dev/null、网络文件系统和伪 tty)。

在其他操作系统中,这种功能传统上与*设备驱动(device driver)*关联。但与设备驱动不同,资源管理器不需要与内核做任何特殊安排。事实上,资源管理器看起来就像任何其他用户级程序。

8.1 客户端程序如何与资源管理器通信?

由于 QNX Neutrino RTOS 是一个分布式、微内核操作系统,几乎所有非内核功能都由用户可安装的程序提供,因此客户端程序与资源管理器之间需要一个清晰、定义良好的接口。所有资源管理器函数都有文档;内核与资源管理器之间没有魔法或私有接口。

事实上,资源管理器基本上就是一个用户级服务器程序,它接受来自其他程序的消息,并且可选地与硬件通信。再次强调,我们原生 IPC 服务的强大和灵活性使得资源管理器能够与操作系统解耦。

资源管理器与使用相关资源的客户端程序之间的绑定,是通过一种称为*路径名空间映射(pathname space mapping)*的灵活机制完成的。

在路径名空间映射中,在路径名和资源管理器之间建立关联。资源管理器通过告知进程管理器:它负责处理某个**挂载点(mountpoint)**处(或之下,对于文件系统而言)的请求,来建立这种路径名空间映射。这使得进程管理器能够将服务(即资源管理器提供的函数)与路径名关联起来。

例如,一个串行端口可能由名为 devc-ser* 的资源管理器管理,但实际资源在路径名空间中可能被称为 /dev/ser1。因此,当客户端程序请求串行端口服务时,它通常通过打开一个串行端口(在本例中为 /dev/ser1)来实现。

8.2 资源管理器架构

这是一个资源管理器的核心:

复制代码
初始化分发(dispatch)接口
向进程管理器注册路径名
DO forever
  接收一条消息
  SWITCH on 消息类型
    CASE io_open:
      执行 io_open 处理
    ENDCASE
    CASE io_read:
      执行 io_read 处理
    ENDCASE
    CASE io_write:
      执行 io_write 处理
    ENDCASE
    . // 等等,处理所有其他可能出现的消息,
    . // 适当地执行处理
  ENDSWITCH
ENDDO

该架构包含三个部分:

  • 一个创建的通道,以便客户端程序可以连接到资源管理器向它发送消息。
  • 资源管理器将要负责的路径名(或路径名们)。这个路径名向进程管理器注册,以便它可以将对该特定路径名的 open 请求解析到这个资源管理器。
  • 一个接收和处理消息的循环。

这个消息处理结构(上面的 switch/case)对每个资源管理器都是必需的。然而,我们提供了一组方便的库函数来处理这个功能(以及其他关键功能)。

8.3 小结

通过支持路径名空间映射、拥有定义良好的资源管理器接口,以及为常见资源管理器函数提供一组库,QNX Neutrino RTOS 为开发人员在新硬件的驱动开发方面提供了前所未有的灵活性和简洁性------这对许多嵌入式系统来说是一个关键特性。

关于开发资源管理器的更多细节,请参阅《QNX Neutrino 快速入门》的《资源管理器》一章,以及《编写资源管理器》(Writing a Resource Manager)指南。


第九章 文件系统(Filesystems)

QNX Neutrino RTOS 提供了丰富多样的文件系统。与操作系统中的大多数提供服务进程一样,这些文件系统在内核之外执行;应用通过由 POSIX API 的共享库实现生成的消息与它们通信。

这些文件系统是本书所述的资源管理器 。每个文件系统采纳路径名空间的一部分(称为挂载点 ),并通过标准 POSIX API(open()、close()、read()、write()、lseek() 等)提供文件系统服务。文件系统资源管理器接管一个挂载点,并管理其下的目录结构。它们还检查各个路径名组件的权限和访问授权。

这种实现意味着:

  • 文件系统可以被动态地启动和停止。
  • 多个文件系统可以并发运行。
  • 无论底层文件系统的配置和数量如何,应用都被呈现为一个单一统一的路径名空间和接口。
  • 如果你使用 Qnet,在一个节点上运行的文件系统可以从任何其他节点透明地访问。

有一些特殊类型的文件系统:

  • **直通文件系统(pass-through filesystem)**位于另一个文件系统的前面,操纵底层文件系统中的文件。它的一个例子是 Inflator 文件系统,我们将在本章稍后描述。

> 注:在重叠的路径名空间上运行多个直通文件系统或资源管理器可能会导致死锁。

  • 虚拟文件系统(virtual filesystem) 是这样的:其中的文件或目录不一定直接绑定到底层媒体,也许是按需制造的。/proc 文件系统就是一个例子;见《QNX Neutrino 程序员指南》中《进程》一章的《通过 /proc 文件系统控制进程》。
  • **QNX 可信磁盘(QNX Trusted Disk)**设备在安全启动环境中使用时,为底层磁盘数据提供完整性保护。

9.1 文件系统与路径名解析

你可以无缝地定位并连接到已向进程管理器注册的任何服务或文件系统。当文件系统资源管理器注册一个挂载点时,进程管理器在内部分挂载表(mount table)中为该挂载点及其对应的服务器 ID(即 nd、pid、chid 标识符)创建一个条目。

这个表有效地将多个文件系统目录连接成用户所感知的单一目录。进程管理器处理路径名的挂载点部分;各个文件系统资源管理器负责路径名的其余部分。文件系统可以以任何顺序注册(即挂载)。

当解析路径名时,进程管理器联系所有能够处理该路径某个组件的文件系统资源管理器。结果是一组能够解析该路径名的文件描述符。

如果路径名表示一个目录,进程管理器在调用 readdir() 时,请求所有能够解析该路径名的文件系统提供该目录中文件的列表。如果路径名不是目录,则访问第一个解析该路径名的文件系统。

关于路径名解析的更多信息,请参阅本指南《进程管理器》一章中的《路径名管理》一节。

9.2 文件系统类别

许多可用的文件系统可以归类为以下类别:

镜像(Image)

一种特殊的文件系统,呈现镜像中的模块,并且始终存在。请注意,procnto 进程自动提供一个镜像文件系统和一个 RAM 文件系统。

块(Block)

在块设备(如硬盘和 DVD 驱动器)上运行的传统文件系统。这包括 Power-Safe 文件系统、DOS 和通用磁盘格式(UDF)文件系统。

闪存(Flash)

明确为闪存设备特性设计的非面向块的文件系统。对于 NOR 设备,使用 FFS3 文件系统;对于 NAND,使用 ETFS。

网络(Network)

提供对远程主机计算机上文件系统的网络文件访问的文件系统。这包括 NFS 和 CIFS(SMB)文件系统。

直通(Pass-through)

位于另一个文件系统前面、操纵底层文件系统中的文件的文件系统。这包括 Inflator 文件系统,它解压之前(使用 deflate 实用工具)压缩的文件。

注:在重叠的路径名空间上运行多个直通文件系统或资源管理器可能会导致死锁。

虚拟(Virtual)

其中的文件或目录不一定直接绑定到底层媒体、也许是按需制造的文件系统。这包括 /proc 文件系统;见《QNX Neutrino 程序员指南》中《进程》一章的《通过 /proc 文件系统控制进程》。

9.3 镜像文件系统

每个 QNX Neutrino 系统镜像都提供一个简单的只读文件系统,呈现构建在操作系统镜像中的文件集合。

由于这个镜像可能同时包含可执行文件和数据文件,这个文件系统对许多嵌入式系统来说已经足够。如果需要额外的文件系统,它们将作为模块放在镜像中,可以在需要时启动。

9.4 RAM 文件系统

每个 QNX Neutrino 系统还提供一个简单的、基于 RAM 的文件系统,允许将读/写文件放在 /dev/shmem 下。

注 :请注意, /dev/shmem 实际上不是一个文件系统。它是共享内存名称上的一个窗口,恰好具有 一些类似文件系统的特性。

这个 RAM 文件系统在微小的嵌入式系统中用途最大,这些系统不需要跨重启的持久存储,但又需要一个小的、快速的、具有有限特性的临时存储文件系统。

这个文件系统随 procnto 免费提供,不需要任何设置。你可以简单地在 /dev/shmem 下创建文件,并将它们增长到任意大小(取决于 RAM 资源)。

虽然 RAM 文件系统本身不支持硬链接、软链接或目录,但你可以通过使用进程管理器链接来创建到它的链接。例如,你可以创建一个到基于 RAM 的 /tmp 目录的链接:

复制代码
ln -sP /dev/shmem /tmp

这告诉 procnto 创建一个指向 /dev/shmem 的进程管理器链接,称为 /tmp。然后应用程序可以像使用普通文件系统一样在 /tmp 下打开文件。

注:为了最小化进程管理器内 RAM 文件系统代码的大小,这个文件系统特别不包含大的文件系统特性,例如文件锁定和目录创建。

9.5 嵌入式事务文件系统(ETFS)

ETFS 实现了一个高可靠性文件系统,用于嵌入式固态存储设备,特别是 NAND 闪存。

该文件系统支持具有 POSIX 语义的完全分层目录结构,如上表所示。

ETFS 是一个完全由*事务(transaction)*组成的文件系统。每个写操作,无论是用户数据还是文件系统元数据,都包含一个事务。一个事务要么成功,要么被视为从未发生。

事务从不覆盖活动数据。对文件中部或目录更新的写入,总是写入一个新的未使用区域。这样,如果操作中途失败(由于崩溃或电源故障),旧数据仍然完好无损。

一些基于日志的文件系统也在从不覆盖活动数据的原则下运行。但 ETFS 将其发挥到极致,将一切都变成事务日志。文件系统层次结构通过处理设备中的事务日志在运行中构建。这个扫描在启动时发生,但其设计使得只读取并 CRC 校验一小部分数据,从而在不牺牲可靠性的情况下实现更快的启动时间。

事务在设备中是位置无关的,可以以任何顺序发生。你可以从一个设备读取事务,并以不同的顺序将它们写入另一个设备。这一点很重要,因为它允许对包含可能位于任意位置的坏块的设备进行批量编程。

这种设计非常适合 NAND 闪存。NAND 闪存出厂时带有工厂标记的坏块,这些坏块可能出现在任何位置。

图 1 ETFS 是一个完全由事务组成的文件系统。

ETFS

事务内部

每个事务由一个头后跟数据组成。头包含以下内容:

  • FID:一个唯一的文件 ID,标识事务属于哪个文件。
  • Offset:数据部分在文件内的偏移量。
  • Size:数据部分的大小。
  • Sequence:一个单调递增的数字(以启用时间排序)。
  • CRC:数据完整性检查(用于 NAND、NOR、SRAM)。
  • ECC:错误纠正(用于 NAND)。
  • Other:为未来扩展保留。

存储介质类型

虽然最适合 NAND 设备,ETFS 也通过使用驱动类支持其他类型的嵌入式存储介质,如下所示:

类别 CRC ECC 磨损均衡擦除 磨损均衡读取 簇大小
NAND 512+16 是 是 是 是 1 KB
NAND 2048+64 是 是 是 是 2 KB
RAM 否 否 否 否 1 KB
SRAM 是 否 否 否 1 KB
NOR 是 否 是 否 1 KB

注:虽然 ETFS 可以支持 NOR 闪存,但我们推荐使用 FFS3 文件系统(devf-*),它是明确为 NOR 闪存设备设计的。

可靠性特性

ETFS 被设计为能够跨电源故障生存,即使是在活动闪存写入或块擦除期间。以下特性有助于其可靠性:

  • 动态磨损均衡
  • 静态磨损均衡
  • CRC 错误检测
  • ECC 错误纠正
  • 读退化监控与自动刷新
  • 事务回滚
  • 原子文件操作
  • 自动文件碎片整理

动态磨损均衡

闪存允许闪存块在失败之前进行有限次数的擦除周期。这个数字可以低至 100,000。ETFS 跟踪每个块的擦除次数。在选择要使用的块时,ETFS 尝试将擦除周期均匀地分布到整个设备上,从而大幅延长其寿命。差异可能是极端的:从没有磨损均衡时几天内失败的使用场景,到有磨损均衡时超过 40 年的寿命。

静态磨损均衡

文件系统通常由大量只读不写的静态文件组成。这些文件将占用没有理由被擦除的闪存块。如果闪存中的大多数文件是静态的,这将导致包含动态数据的剩余块以大幅增加的速率磨损。

ETFS 注意到这些未充分工作的静态块,并通过将其数据拷贝到一个过度工作的块来迫使它们投入使用。这解决了两个问题:它给过度工作的块一个休息(因为它现在包含静态数据),并且它迫使未充分工作的静态块进入动态块池。

CRC 错误检测

每个事务都受循环冗余校验(CRC)保护。这确保了快速检测损坏的数据,并构成启动时对损坏或不完整事务进行回滚操作的基础。CRC 可以检测电源故障期间可能发生的多位错误。

ECC 错误纠正

在 CRC 错误时,ETFS 可以应用错误纠正编码(ECC)来尝试恢复数据。这适用于 NAND 闪存,在正常使用期间可能发生单位错误。ECC 错误是一个警告信号,表明发生错误的闪存块可能正在变弱,即正在丢失电荷。

ETFS 将弱块标记为*刷新(refresh)*操作,该操作将数据拷贝到一个新的闪存块并擦除弱块。擦除会给闪存块重新充电。

读退化监控与自动刷新

NAND 闪存块内的每个读操作都会削弱维持数据位的电荷。大多数设备支持约 100,000 次读取,之后就有丢失一位的危险。ECC 恢复单位错误,但可能无法恢复多位错误。

ETFS 通过跟踪读取并在达到 100,000 次读取限制之前标记块进行刷新来解决这个问题。

事务回滚

当 ETFS 启动时,它处理所有事务并回滚(丢弃)最后一个部分或损坏的事务。回滚代码被设计为处理回滚操作期间的电源故障,从而允许系统从多个嵌套故障中恢复。事务的有效性由每个事务上的 CRC 代码保护。

原子文件操作

ETFS 在设备上实现了一个非常简单的目录结构,允许通过单次闪存写入进行重大修改。例如,将文件或目录移动到另一个目录在大多数文件系统中通常是多阶段操作。在 ETFS 中,移动通过单次闪存写入完成。

自动文件碎片整理

基于日志的文件系统常常遭受碎片化,因为对现有文件的每次更新或写入都会导致创建新事务。ETFS 使用写缓冲将小写入合并成较大的写事务,以尽量减少大量非常小的事务造成的碎片化。ETFS 还监控每个文件的碎片化程度,并对确实严重碎片化的文件进行后台碎片整理操作。请注意,这个后台活动总是会被用户数据请求抢占,以确保立即访问正在被碎片整理的文件。

9.6 Power-Safe 文件系统

Power-Safe 文件系统是一个可靠的磁盘文件系统,能够承受电源故障而不丢失或损坏数据。

这个文件系统由 fs-qnx6.so 共享对象支持。

可扩展文件系统

对于 Power-safe 文件系统类型,你可以创建一个可扩展文件系统,即一个最大存储容量可以超过其指定大小的文件系统。你可以使用主机端 mkqnx6fsimg 工具或目标端 mkqnx6fs 工具来完成;这些工具使用不同的单位来表示文件系统大小。

对于构建 Power-Safe 文件系统镜像的 mkqnx6fsimg 工具,你必须设置 num_sectors 属性来指示存储镜像所需的扇区数,并设置 max_sectors 属性来指示文件系统支持的最大扇区数。关于最大容量如何确定的说明,请参阅《实用工具参考》中 mkqnx6fsimg 条目的 max_sectors 属性。

对于格式化 Power-Safe 文件系统的 mkqnx6fs 工具,你必须设置 -x 选项来请求可扩展文件系统,并设置 -n 选项来指示你希望文件系统支持的最大逻辑块(即可分配单元)数。关于所需计算的细节,请参阅 mkqnx6fs 的 -n 选项。

位图与 inode 文件

在 Power-Safe 文件系统中,必须为位图(bitmap)和 inode 文件预留空间。

位图文件是一个大的位域,覆盖了表示文件系统镜像中每个块(每个块一个位)所需的尽可能多的内存块。如果块已分配,该位为 1;如果未分配,则为 0。位图文件大小根据最大扇区数(max_sectors)计算。

inode 文件是一个包含系统中特定文件信息的表。如果提供给 mkqnx6fsimg,num_inodes 属性决定信息节点(inode)的数量。这个数量是 inode 表中的条目数。如果未提供此属性,mkqnx6fsimg 根据其他属性(包括 num_sectors)确定 inode 数量。关于此计算的更多信息,请参阅 mkqnx6fsimg 的 num_inodes 属性。关于 inode 的更多细节,请参阅《QNX Neutrino 用户指南》中《使用文件系统》一章的《链接与 inode》。

由于这两个系统文件在镜像中占用一些空间,用户可访问的内存略少于文件系统的最大容量。

9.7 DOS 文件系统

DOS 文件系统 fs-dos.so 提供对 DOS 磁盘的透明访问,因此你可以将 DOS 文件系统视为 POSIX 文件系统。这种透明性允许进程操作 DOS 文件,而无需任何特殊知识或工作。

磁盘上的 DOS 文件系统结构陈旧且低效,并且缺乏许多理想的特性。它唯一的主要优点是它可以移植到 DOS 和 Windows 环境。只有当你需要将 DOS 文件传输到需要它的其他机器时,你才应该选择这个文件系统。如果 DOS 文件可移植性不是问题,考虑单独使用 Power-Safe 文件系统;如果是问题,则结合使用 Power-Safe 和 DOS 文件系统。

如果 POSIX 特性没有 DOS 等价物,fs-dos.so 将返回错误或合理的默认值。例如,尝试创建 link() 将导致返回适当的 errno。另一方面,如果尝试读取文件上的 POSIX 时间,fs-dos.so 会将任何不支持的时间视为与最后写入时间相同。

DOS 版本支持

fs-dos.so 程序支持从 DOS 版本 2.1 到 Windows 98 的带长文件名的硬盘分区。

DOS 文本文件

DOS 用两个字符(CR/LF)终止文本文件中的每一行,而 POSIX(和大多数其他)系统用单个字符(LF)终止每一行。请注意,fs-dos.so 不尝试转换正在读取的文本文件。大多数实用工具和程序不受此差异影响。

还要注意,一些非常老的 DOS 程序可能使用 CtrlZ(^Z)作为文件终止符。这个字符也被不加修改地传递。

QNX 到 DOS 的文件名映射

在 DOS 中,文件名不能包含以下任何字符:

/ \ [ ] : * | + = ; , ?

尝试创建包含这些无效字符之一的文件将返回错误。DOS(8.3 格式)还期望所有字母字符都是大写的,所以 fs-dos.so 在磁盘上创建文件名时将这些字符映射为大写。但默认情况下,它在向 QNX Neutrino 应用返回文件名时将其映射为小写,以便 QNX Neutrino 用户和程序可以始终看到并输入小写(通过 sfn=sfn_mode 选项)。

处理文件名

你可以指定你希望 fs-dos.so 如何处理长文件名(通过 lfn=lfn_mode 选项):

  • 忽略它们------只显示/创建 8.3 文件名。
  • 显示它们------如果文件名长于 8.3,或使用了混合大小写。
  • 总是同时创建短文件名和长文件名。

如果你使用忽略选项,你可以指定是否静默截断超出 8.3 限制的文件名字符。

国际文件名

DOS 文件系统支持用于本地化文件名的 DOS 代码页(国际字符集)。短 8.3 名称使用特定字符集存储(通常,某个语言环境的最常见扩展字符被编码在第 8 位字符范围内)。支持所有常见的美国以及西欧和东欧代码页(437、850、852、866、1250、1251、1252)。如果你生产的软件必须访问各种 DOS/Windows 硬盘,或在非美式英语国家运行,这个特性提供了重要的可移植性------文件名将同时以 Unicode 和本地化名称创建,并且可以通过任一名称访问。

注 :DOS 文件系统仅支持文件名中的国际文本。不尝试感知数据内容,唯一的例外是 Windows 快捷方式( .LNK)文件,如果你指定了该选项( lnk=lnk_mode),它将被解析并转换为符号链接。

DOS 卷标

DOS 使用卷标(volume label)的概念,它是 DOS 文件系统根目录中的一个实际目录条目。为了区分卷标和实际的 DOS 目录,fs-dos.so 根据你指定其 vollabel 选项的方式报告卷标。你可以选择:

  • 忽略卷标。
  • 将卷标显示为名称特殊文件。
  • 将卷标显示为名称特殊文件,卷名的第一个字符是等号(=)(默认)。

DOS-QNX 权限映射

DOS 不支持 POSIX 指定的所有权限位。它有一个 READ_ONLY 位,代替单独的 READ 和 WRITE 位;它没有 EXECUTE 位。当创建 DOS 文件时,如果所有 POSIX WRITE 位都关闭,则设置 DOS READ_ONLY 位。当访问 DOS 文件时,POSIX READ 位总是假定为用户、组和其他设置。

由于你不能执行没有 EXECUTE 权限的文件,fs-dos.so 有一个选项(exe=exec_mode),让你指定如何处理可执行文件的 POSIX EXECUTE 位。

文件所有权

虽然 DOS 文件结构不支持用户 ID 和组 ID,但 fs-dos.so(默认)在尝试更改它们时不返回错误代码。不返回错误,是因为许多实用工具尝试这样做,而失败会导致意外错误。采取的方法是你可以将任何东西更改为任何东西,因为它反正不会写入磁盘。

posix= 选项让你设置更严格的 POSIX 检查并启用 POSIX 仿真。例如,在 POSIX 模式下,对以下任何操作的尝试都会标记 EINVAL 错误:

  • 将用户 ID 或组 ID 设置为默认值(root)以外的值。
  • 移除 r(读)权限。
  • 设置 s(执行时设置 ID)权限。

如果你将 posix 选项设置为 emulate(默认)或 strict,你将获得以下好处:

  • 在根目录中创建 . 和 .. 目录条目。
  • 计算目录大小。
  • 基于其子目录计算目录中的链接数。

9.8 FFS3 文件系统

FFS3 文件系统驱动在 NOR 闪存设备上实现一个类似 POSIX 的文件系统。这些驱动是独立可执行文件,同时包含闪存文件系统代码和闪存设备代码。有适用于不同嵌入式系统硬件以及 PCMCIA 存储卡的 FFS3 文件系统驱动版本。

驱动的命名约定是 devf-system,其中 system 描述嵌入式系统。

要了解我们目前支持的闪存设备,请参考以下来源:

  • bsp_working_dir/src/hardware/flash 下的 boards 和 mtd-flash 目录
  • QNX Neutrino RTOS 文档(《实用工具参考》中的 devf-* 条目)
  • BlackBerry QNX 网站(www.qnx.com)

除了预构建的闪存文件系统驱动(包括通用驱动 devf-generic),我们还提供构建自定义闪存文件系统驱动所需的库和源代码,用于不同的嵌入式系统。

组织

FFS3 文件系统驱动支持一个或多个逻辑闪存驱动器。每个逻辑驱动器被称为一个socket(插槽),它由连续且同质的闪存区域组成。例如,在一个包含两种不同类型闪存设备(位于不同地址)的系统中,一个闪存设备用于启动镜像,另一个用于闪存文件系统,每个闪存设备将出现在不同的 socket 中。

每个 socket 可以被划分为一个或多个分区。支持两种类型的分区:原始分区和闪存文件系统分区。

原始分区(Raw partitions)

socket 中的原始分区是任何不包含闪存文件系统的分区。驱动不识别闪存文件系统以外的任何文件系统类型。原始分区可能包含镜像文件系统或一些特定于应用的数据。

文件系统将通过原始挂载点(见下文)使闪存上任何不是闪存文件系统分区的分区可访问。请注意,闪存文件系统分区也可以作为原始分区访问。

文件系统分区(Filesystem partitions)

闪存文件系统分区包含类似 POSIX 的闪存文件系统,它使用 BlackBerry QNX 专有格式在闪存设备上存储文件系统数据。这个格式与 Microsoft FFS2 或 PCMCIA FTL 规范不兼容。

该文件系统允许自由创建和删除文件和目录。它使用类似于垃圾回收的回收(reclaim)机制从已删除文件中恢复空间。

当你启动闪存文件系统驱动时,它默认会挂载它在 socket 中找到的任何分区。每个闪存设备只能有一个驱动实例,并且必须给驱动闪存芯片的完整大小。你可以使用 mkefs 或 flashctl 指定挂载点(例如 /flash)。

挂载点 描述
/dev/fsX socket X 的原始挂载点
/dev/fsXpY socket X 分区 Y 的原始挂载点
/fsXpY socket X 分区 Y 的文件系统挂载点
/fsXpY/.cmp socket X 分区 Y 的文件系统压缩挂载点

特性

FFS3 文件系统支持许多高级特性,例如 POSIX 兼容性、多线程、后台回收、故障恢复、透明解压、字节序感知、磨损均衡和错误处理。

POSIX

文件系统支持标准 POSIX 功能(包括长文件名、访问权限、随机写入、截断和符号链接),但有以下例外:

  • 你不能创建硬链接。
  • 不支持访问时间(但支持文件修改时间和属性更改时间)。

这些设计妥协使这个文件系统保持小而简单,同时包含块设备文件系统通常具有的大多数特性。

存储效率

文件系统对存储在文件系统上的每段用户数据使用固定大小的 32 字节 extent 头。文件系统有一个 512 字节的追加缓冲区,用于在刷新到磁盘之前合并对文件的小追加。文件系统中没有其他用户数据缓存(读或写)。对于运行时创建的文件,最大 extent 大小目前限制为 4096 字节数据(加上 32 字节头),尽管 mkefs 可以创建 16 KB 的 extent。

磨损均衡

磨损均衡在给定分区内的块上执行。如果闪存被分割成多个分区,那么每个分区都有更小的池来进行均衡。块从不从一个分区迁移到另一个分区。

文件系统中最常见的回收类型(前台回收)总是执行双重回收操作。第一次回收总是取分区中擦除计数最低的块,并将其与备用块交换。然后需要回收的块与备用块交换。已证明在使用前台回收时,这能实现近乎完美的磨损均衡。

后台回收

FFS3 文件系统将文件和目录存储为 extent 的链表,extent 在被删除或更新时被标记为删除。要回收的块使用一个简单算法选择:找到有最多空间可回收的块,同时保持每个单独块的磨损量均衡。这种磨损均衡增加了闪存设备的 MTBF(平均故障间隔时间),从而延长其寿命。

后台回收过程在没有足够空闲空间时执行。回收过程首先将回收块的内容拷贝到一个空的备用块,然后备用块替换回收块。回收块随后被擦除。与具有机械头的旋转介质不同,数据的邻近性对闪存文件系统不是因素,因此数据可以分散在介质上而不损失性能。

故障恢复

文件系统被设计为最小化由于意外断电故障造成的损坏。对 extent 头和擦除块头的更新总是以精心调度的顺序执行。这些顺序允许在数据损坏的情况下恢复文件系统的完整性。

请注意,适当设计的闪存硬件对于有效的故障恢复系统至关重要。特别是,必须有特殊的复位电路,在电源水平降到临界值以下之前将系统保持在复位状态。否则,杂散或随机的总线活动可能形成写/擦除命令,并将闪存损坏到无法恢复。

重命名操作被保证是原子的,即使在断电故障中也是如此。这意味着,例如,如果你在给镜像或可执行文件一个新名称时断电,恢复后你仍然可以通过其旧名称访问该文件。

当 FFS3 文件系统驱动启动时,它扫描介质上每个 extent 头的状态(以验证其完整性),并采取适当的行动,范围从简单的块回收到擦除悬空的 extent 链接。这个过程与文件系统的正常挂载过程合并,以实现最佳的引导时序。

压缩与解压

为了快速高效的压缩/解压,你可以使用 deflate 和 inflator 实用工具,它们依赖于流行的 deflate/inflate 算法。

deflate 算法结合了两种算法。第一种负责去除文件中的数据重复;第二种算法通过给出现最频繁的数据序列更短的符号来处理它们。这两种算法提供了数据和可执行文件的出色无损压缩。inflate 算法只是逆转 deflate 算法所做的事情。

deflate 实用工具旨在与 mkefs 的 filter 属性一起使用。你也可以使用它来预压缩用于闪存文件系统的文件。

inflator 资源管理器位于之前使用 deflate 实用工具压缩的其他文件系统前面。它几乎可以使闪存的有效大小翻倍。

压缩文件可以用 cp 或 ftp 等标准实用工具操作------如果使用适当的挂载点,它们可以用 ls 实用工具显示其压缩和未压缩大小。这些特性使压缩闪存文件系统的管理对系统设计者来说是无缝的。

闪存错误

随着闪存硬件磨损,其写入状态机可能会发现它无法写入或擦除特定的位单元。当这种情况发生时,错误状态被传播到闪存驱动,以便它能采取适当的行动(即标记坏区域并尝试在另一个地方写入/擦除)。

这个错误处理机制是透明的。请注意,在多次闪存错误之后,所有失败的写入和擦除最终将使闪存变为只读。幸运的是,这种情况在闪存运行数年之前不应该发生。检查你的闪存规格并分析你的应用对闪存的数据流,以计算其潜在的寿命或 MTBF。

字节序感知

FFS3 文件系统是字节序感知的,使其可以跨不同平台移植。最佳方法是使用 mkefs 实用工具来选择目标的字节序。

实用工具

文件系统支持所有标准 POSIX 实用工具,例如 ls、mkdir、rm、ln、mv 和 cp。还有一些用于管理闪存的 QNX Neutrino 实用工具:

  • flashctl:擦除、格式化和挂载闪存分区。
  • deflate:为闪存文件系统压缩文件。
  • mkefs:创建闪存文件系统镜像文件。

系统调用

文件系统支持所有标准 POSIX I/O 函数,例如 open()、close()、read() 和 write()。特殊函数(如擦除)使用非 POSIX 的 devctl() 函数支持。

9.9 NFS 文件系统

网络文件系统(NFS)允许客户端工作站通过网络执行透明的文件访问。它允许客户端工作站操作驻留在跨各种操作系统的服务器上的文件。客户端文件访问调用被转换为 NFS 协议请求,并通过网络发送到服务器。服务器接收请求,执行实际的文件系统操作,并将响应发送回客户端。

网络文件系统通过使用远程过程调用(RPC)和 TCP/IP 作为其传输,以无状态方式运行。因此,要使用 fs-nfs3,你还需要为 QNX Neutrino 运行 TCP/IP 客户端。

远程服务器文件系统中的任何 POSIX 限制都将传递给客户端。例如,来自不同操作系统的服务器,文件名长度可能不同。NFS 版本 3 将文件名限制为 255 个字符;mountd(版本 1 和 3)将路径名限制为 1024 个字符。

9.10 CIFS 文件系统

以前称为 SMB,通用互联网文件系统(CIFS)允许客户端工作站通过网络对 Windows 系统或运行 SMB 服务器的 UNIX 系统执行透明的文件访问。客户端文件访问调用被转换为 CIFS 协议请求,并通过网络发送到服务器。服务器接收请求,执行实际的文件系统操作,并将响应发送回客户端。

注:CIFS 协议不试图符合 POSIX。

fs-cifs 管理器使用 TCP/IP 作为其传输。因此,要使用 fs-cifs,你还需要为 QNX Neutrino RTOS 运行 TCP/IP 客户端。

9.11 Linux Ext2 文件系统

Ext2 文件系统提供对 Linux 磁盘分区的透明访问。

fs-ext2.so 实现支持 Ext2 版本 0 和 1 中的标准特性集。

包含稀疏文件支持是为了与现有的 Linux 分区兼容。其他文件系统只能在稀疏文件上只读地堆叠。普通文件没有这种限制。

如果 Ext2 文件系统未正确卸载,文件系统检查器通常负责在下次挂载文件系统时进行清理。虽然 fs-ext2.so 模块配备了执行快速测试的能力,但如果它检测到任何重大问题(应使用文件系统检查器修复),它会自动将文件系统挂载为只读。

9.12 通用磁盘格式(UDF)文件系统

通用磁盘格式(UDF)文件系统提供对可记录介质的访问,例如 CD、CD-R、CD-RW 和 DVD。它用于 DVD 视频,但也可以用于备份到 CD 等。更多信息,请参阅 http://osta.org/specs/index.htm。

UDF 文件系统由 fs-udf.so 共享对象支持。

注:在我们的实现中,UDF 文件系统是只读的。

9.13 Apple Macintosh HFS 与 HFS Plus

Apple Macintosh HFS(分层文件系统)和 HFS Plus 是 Apple Macintosh 系统上的文件系统。

fs-mac.so 共享对象在 QNX Neutrino 系统上提供对 HFS 和 HFS Plus 磁盘的只读访问。可识别以下变体:HFS、HFS Plus、HFSJ(带日志的 HFS Plus)、HFS 包装器中的 HFS Plus、HFSX(区分大小写)以及 HFS/ISO-9660 混合。在 QNX Neutrino 7.0.4 或更高版本中,你还可以挂载带有热(或脏)日志的 HFSJ 文件系统。

9.14 Windows NT 文件系统

NT 文件系统用于 Microsoft Windows NT 及更高版本。

fs-nt.so 共享对象在 QNX Neutrino 系统上提供对 NTFS 磁盘的只读访问。

9.15 Inflator 直通文件系统

QNX Neutrino 提供了一个 Inflator直通 文件系统,它是一个资源管理器,位于其他文件系统前面,对之前(使用 deflate 实用工具)压缩过的文件进行解压。

inflator 实用工具通常在底层文件系统是闪存文件系统时使用。使用它几乎可以使闪存的有效大小翻倍。

如果文件被打开以进行读取,inflator 尝试在底层文件系统上打开该文件本身。它读取前 16 个字节并检查 deflate 文件的签名。如果文件是被 deflate 过的,inflator 将自己置于应用和底层文件系统之间。所有读取都返回文件在 deflate 之前的原始数据。

从应用的角度看,文件看起来是未压缩的。也支持随机查找(seek)。如果应用对文件执行 stat(),则返回膨胀后文件的大小(deflate 之前的原始大小)。

注:在重叠的路径名空间上运行多个直通文件系统或资源管理器可能会导致死锁。

9.16 QNX 可信磁盘(QTD)

QNX 可信磁盘(QTD)设备在安全启动环境中为底层磁盘数据提供完整性保护。它们可以将安全启动链扩展到存储关键二进制文件和配置文件的核心操作系统文件系统。

QTD 保护机制基于 Merkle 树,并由 fs-qtd.so 共享对象支持。

在构建 QTD 镜像时,从源文件系统镜像的块构建一个元数据哈希树。

QTD Merkle 哈希树

QTD 磁盘设备是只读的。QTD 驱动位于原始块设备和受支持的上层文件系统层之间(例如,由 fs-qnx6.so 支持的 Power-Safe 文件系统)。在读取访问时,它使用哈希树元数据来验证数据的完整性。如果验证失败,则返回错误。

QTD 元数据使用密钥对进行签名和验证。验证签名确保树的根哈希是有效的,并且未被篡改。它是可信验证机制的根。

QTD 镜像的大小取决于所选的块大小以及所选的摘要算法。你可以使用 mkqfs 实用工具生成描述元数据消耗多少额外空间的统计信息。

将 QTD 用作受保护的容器(应用模型)

你可以通过挂载本身是 QTD 镜像的文件,将 QTD 用作包容器解决方案。示例请参阅《实用工具参考》中 fs-qtd.so 的条目。

安全哈希算法

使用下表,根据你的目标架构和摘要函数所需的安全强度,选择性能最佳的算法。对你的系统进行基准测试以确定最佳组合。

架构 128 位摘要安全 256 位摘要安全
32 位 sha256、blake2s256、blake3 sha512、blake2b512
64 位 sha512-256、blake2b256、blake3 sha512、blake2b512

签名密钥

关于支持的密钥类型和签名算法,请参阅 mkqfs 实用工具。

加密引擎

QTD 使用通过 QNX 加密库(qcrypto)可用的加密算法。它可以使用任何支持所选摘要算法和签名类型的 qcrypto 插件。更多信息,请参阅《系统安全指南》中的《QNX 加密库》。

9.17 文件系统事件

跟上频繁且快速变化的、非常活跃的文件系统,对于与之一起工作的程序来说是一个挑战。为了帮助应对这一挑战,QNX Neutrino 包含了文件系统事件(filesystem events)。

这些事件让应用能够跟踪感兴趣的文件系统中的变化------包括文件和目录的创建与删除、所有权和权限的变化、挂载和卸载操作等。文件系统事件的管理涉及一个事件管理器、一个事件机制和客户端事件处理程序。

事件管理器(event manager) fsevmgr 是一个进程,它在系统命名空间中注册一个固定名称(默认是 /dev/fsevents),并接收文件系统事件(可能来自多个 io-blk.so 实例),确定哪些客户端需要哪些事件,并将事件交给这些客户端。

事件机制(event mechanism) 是 io-blk.so 的一部分,任何文件系统或块 I/O 系统的部分都可以轻松使用它。这提供了最大的灵活性,因为我们可以在系统调用级别协调进入文件系统的事件,从文件系统内部、分区、缓存,甚至块设备驱动(如果需要的话)。

该机制将事件放入缓冲区,最终发送给事件管理器。这个机制仅限于一个 API,它报告事件来源的位置、指示事件的唯一标识符,以及进一步描述事件的属性。io-blk.so 库负责打包每个事件并将其发送给事件管理器。该实现使用一个定时器来确保事件不会在这个缓冲区中停留过长时间。

在启动期间,io-blk.so 查找事件管理器。如果它未加载,事件机制被禁用。提供了三个配置参数:

  • fse-device ------ 事件管理器的名称
  • fse-period ------ 在向事件管理器发送事件之前延迟的最长时间
  • fse-size ------ 保存事件的缓冲区大小

在加载事件管理器期间,向各个 io-blk.so 挂载点发送一个 DCMD_FSYS_FSEVMGR_CHECK devctl 命令,通知它们事件管理器已加载。

最后,客户端事件处理程序(client event handler) 可以实例化一个线程,该线程阻塞在从 /dev/fsevents 的读取上,或选择文件描述符以便在数据可用时获得通知。

以下是客户端通常如何与事件管理器交互的概述:

open()

FSE_DEFAULT_MANAGER_NAME(在 中定义)描述事件管理器的默认路径。请记住,它可能被事件管理器覆盖,io-blk.so 也可能被更改为查找另一个事件管理器。

客户端通常应以 O_RDONLY 模式打开事件管理器,以简单地读取事件。只有当客户端需要发送事件时,才需要写模式。如果客户端要轮询事件管理器,则设置 O_NONBLOCK 标志。

read()

客户端从事件管理器读取事件到一个字节数组中。字节数组被填充到存储完整事件所需的大小。换句话说,部分事件不会返回给读取者。不能对缓冲区中事件的对齐做任何假设。

FSE_READ_EVENT_S(pev, pbytes) 作为 memcpy() 的包装器提供,用于将事件从字节数组复制到对齐的结构中。从那里,客户端可以使用其他 FSE_* 访问器宏来解释事件特征。

虽然事件管理器通常有一个大的队列来保存事件,但它不可避免地会填满并开始覆盖旧事件。如果任何事件客户端没有跟上新事件的添加,这些读取者将被置于溢出状态。换句话说,如果事件管理器已经开始覆盖读取者尚未消费的事件,那么从该描述符的下一次读取将指示 EOVERFLOW 错误。随后的 read() 返回最旧的事件。

lseek()

事件管理器支持有限的 seek 功能。通常,客户端从队列头部开始读取,并立即阻塞等待新事件。如果客户端必须评估过去的事件,它可以使用 lseek()。

whence 值为 SEEK_SET 且偏移量为零,将文件描述符定位到队列中最旧的事件。请注意,这种情况可能导致后续 read() 期间出现 EOVERFLOW。

whence 为 SEEK_END 且偏移量为零,将读取者定位在队列末尾以读取新事件。

客户端可以使用事件计数作为偏移量,配合 SEEK_SET 或 SEEK_END,分别从队列头部或尾部索引一定数量的事件。

write()

客户端可以使用 write() 向事件管理器发送事件。事件管理器检查事件数据以确保其格式良好;失败时,事件管理器中止单次写操作中发送的所有事件的写入。

close()

一旦不再需要文件描述符,就可以关闭它。

头文件包含了从事件管理器读取和处理事件所需的一切。更多信息,请参阅《C 库参考》中的 FSE_*()。


第十章 PPS(Persistent Publish/Subscribe)

QNX Neutrino 持久化发布/订阅(PPS)服务是一个小型、可扩展的发布/订阅服务,提供跨重启的持久性。它旨在为嵌入式系统中的发布/订阅和持久化提供一个简单易用的解决方案,满足使用异步发布和通知构建松耦合系统的需求。

使用 PPS,发布是异步的:订阅者无需等待发布者。事实上,发布者和订阅者很少知道彼此;它们唯一的连接是一个对象,该对象对发布者和订阅者都有意义和用途。

PPS 的设计在许多方面与许多过程控制系统相似,在这些系统中,对象是由硬件或软件更新的控制值。订阅者可以是报警处理代码、显示器等。由于对象只有一个实例,持久性是一个可以自然应用于它的属性。

10.1 持久性

PPS 在运行时将其对象保存在内存中。

它会根据需要:

  • 将其对象保存到持久存储,可以在运行时按需保存,也可以在关闭时保存
  • 在启动时恢复其对象,可以立即恢复,也可以在首次访问时恢复(延迟加载)

PPS 使用的底层持久存储依赖于可靠的文件系统,例如:

  • 磁盘------Power-Safe 文件系统
  • NAND 闪存------ETFS 文件系统
  • NOR 闪存------FFS3 文件系统
  • 其他------客户生成的文件系统

当 PPS 启动时,它立即根据持久文件系统上编码的文件名构建目录层次结构。它将目录中对象的加载推迟到首次访问其中一个文件时。这个访问可以是对 PPS 对象的 open() 调用,或是对 PPS 目录的 readdir() 调用。

在关闭时,PPS 总是将任何修改过的对象保存到持久文件系统。你也可以通过对对象调用 fsync() 来随时强制 PPS 保存对象。当 PPS 保存到持久文件系统时,它将所有对象保存到单个目录中。

注 :你可以设置 PPS 对象和属性限定符,让 PPS 不保存特定的对象或属性。

10.2 PPS 对象

PPS 使用基于对象的系统;也就是说,一个系统,其中的对象具有发布者可以修改的属性。订阅某个对象的客户端在该对象变化时------当发布者修改了它时------接收更新。

PPS 对象在一个特殊的 PPS 文件系统中以带有属性的文件形式存在。默认情况下,PPS 对象出现在 /pps 下。你可以:

  • 通过在目录中创建文件,创建目录并用 PPS 对象填充它们。
  • 使用 open(),然后使用 read() 和 write() 函数来查询和更改 PPS 对象。
  • 使用标准实用工具作为简单的调试工具。

PPS 目录可以包含特殊对象,例如 .all 和 .notify,应用可以打开它们以促进订阅行为。

当 PPS 创建、删除或截断一个对象(文件或目录)时,它将一个通知字符串放入任何已打开该对象或该目录的 .all 特殊对象(针对含有被修改对象的目录)的订阅者或发布者的队列中。这个文件可以以完整模式(full mode)或增量模式(delta mode)打开。

PPS 支持路径名打开选项,以及对象和属性限定符。PPS 使用路径名打开选项在用于打开对象的文件描述符上应用打开选项。对象和属性限定符设置对对象或属性要采取的特定动作;例如,使对象非持久化,或删除属性。

路径名打开选项

PPS 对象支持在用于打开它们的路径名上使用扩展语法。打开选项作为后缀添加到路径名,跟在问号(?)之后。也就是说,PPS 服务使用路径名中问号之后的任何数据,在用于访问对象的文件描述符上应用打开选项。多个选项由问号分隔。

对象和属性限定符

你可以通过在包含对象或属性名称的行前加一个开方括号,后跟一个单字母或单数字限定符列表,并以闭方括号结尾,来为 read() 和 write() 调用设置限定符。

10.3 发布

要发布到 PPS 对象,发布者只需以 O_WRONLY(仅发布)或 O_RDWR(发布和订阅)模式调用 open() 打开对象文件。然后发布者可以调用 write() 来修改对象的属性。这个操作是非阻塞的。

PPS 支持多个发布者发布到同一个 PPS 对象。这个能力是必需的,因为不同的发布者可能访问适用于同一对象的不同属性的数据。

例如,在多媒体系统中,渲染器可能是 time::value 属性的来源,而 HMI 可能是 duration::value 属性的来源。只更改 time 属性的发布者在写入对象时只会更新该属性。它会保持其他属性不变。

10.4 订阅

PPS 客户端可以订阅多个对象,PPS 对象可以有多个订阅者。当发布者更改一个对象时,订阅该对象的所有客户端都会收到更改通知。

要订阅一个对象,客户端只需以 O_RDONLY(仅订阅)或 O_RDWR(发布和订阅)模式调用 open() 打开对象。然后订阅者可以用 read() 调用查询该对象。

订阅者可以以完整模式、增量模式、或同时以完整和增量模式打开对象。下图说明了发送给以完整模式和增量模式打开 PPS 对象的订阅者的不同信息。

图 1 PPS 完整和增量订阅模式。

PPS 订阅模式

注 :在所有情况下,PPS 都维护带有状态的持久对象------总是有一个对象。用于打开对象的模式 不会改变对象;它只决定订阅者对对象的视图。

完整模式(Full mode)

在完整模式(默认)下,订阅者总是接收整个对象的单一、一致版本,即它被请求时刻存在的版本。

如果发布者在订阅者请求之前多次更改了对象,订阅者只接收询问时刻对象的状态。如果对象再次更改,订阅者会再次收到更改通知。因此,在完整模式下,订阅者可能会错过对象的多次更改------在订阅者请求之前发生的对象更改。

增量模式(Delta mode)

在增量模式下,订阅者只接收对象属性的更改(但是所有的更改)。在第一次读取时,由于订阅者对对象的状态一无所知,PPS 假定一切都已更改。因此,订阅者在增量模式下的第一次读取返回对象的所有属性,而后续读取只返回自该订阅者上一次读取以来的更改。因此,在增量模式下,订阅者总是接收对象的所有更改。

PPS 使用目录作为自然的分组机制,以简化和提高订阅多个对象的任务的效率。订阅者可以打开多个对象,可以通过对对象调用 open() 然后 poll(),或者更简单的方式,通过打开特殊的 .all 对象,它合并其目录中的所有对象。

PPS 提供了一种机制,将一组文件描述符与一个通知组关联起来。这个机制允许你只读取 PPS 特殊通知对象,来接收与通知组关联的所有对象的更改通知。