VxWorks 代码分析

由于项目关系,有幸接触到了 VxWorks 6.7 的源代码,于是带着膜拜的心态开始阅读并分析其源代码,并将分析的结果记录在这里。

国内关于 VxWorks 的资料比较少,而且网上找到的资料不少是针对 VxWorks 5.x 的,和旧版比起来,VxWorks 6.x 增加了对多核硬件(SMP)的支持,并且加入了实时进程(RTP),可以开发用户态的应用程序,代码上的区别还是很大的。

VxWorks 代码的特点

VxWorks 主要使用 C 语言,BSP 部分包含一些汇编。使用 C89 标准语法,没有使用太多编译器扩展,代码风格很奇怪。在每个源文件开头的注释中,可以看到该文件的修改历史。可以看出,风河似乎很抗拒重构,特别愿意继承原有的代码,许多模块都是尽可能修改,哪怕重构之后反而会更简单。

作为一个实时操作系统,VxWorks 会放弃一定性能和通用性以追求更高的稳定性和可定制性。VxWorks 很多内核函数都要获取全局内核锁,多核环境下也如此。VxWorks 不会去检测硬件配置,例如探测内存大小、CPU 个数,而是要求开发者将这些配置硬编码,编译到 OS image 内部。这是因为 VxWorks 特殊的应用场合,基本都是针对某个硬件平台定制,这也说明很多 VxWorks 可以采用的方案在其他操作系统中完全不适用。

有些广告宣传说 VxWorks 是微内核,这是错的,VxWorks 是标准的宏内核。大部分系统组件和用户开发的 Task 都运行在特权模式,没有地址空间隔离。即使硬件平台支持 MMU,所有任务也是共享同一套页表,MMU 的作用仅仅是将碎片的物理页映射为连续的虚拟页。

VxWorks 模块化做得确实很好,从文件名来看,基本都是 xxxLib.cxxxLib.h。每个模块相对独立,有明确定义的接口,头文件里也有详细的注释(开发文档就是用这些注释自动生成的)。

文件组织结构

VxWorks 并不是一个开源的操作系统,其源代码通常是随着开发套件一起安装的。VxWorks 的开发 IDE 是 Workbench,基于 Eclipse 定制,与源代码和其他辅助工具一起构成风河开发套件。假设风河开发套件安装在 C:\WindRiver,那么 VxWorks 6.7 的源代码位于 C:\WindRiver\vxworks-6.7\target。其中有如下子目录:

下面对代码结构中涉及到的一些概念进行解释:

VxWorks内核的源代码就放在 src 目录下,这个目录下面又有许多子目录,按不同的功能模块组织分类。由于 VxWorks 系统经历过无数次的升级改进,src 下的内容显得有些凌乱,以下是几个核心子目录:

比较重要的两个是 windoswind 目录下包含任务管理相关代码,os 下则包含对象系统、内存管理等内容。这两个目录的功能看起来有些重合,区别在于,wind 模块提供的是公开的内核 API,函数接口是稳定的,而 os 模块提供的则是私有函数,供其他的 VxWorks 内核代码使用,风河不保证函数接口在不同系统版本之间保持一致。

从代码文件的命名方式上,就能大概看出属于哪个组件。例如内存管理组件 memPartLib,相关代码包括 src/os/mm/memPartLib.ch/memPartLib.hh/private/memPartLibP.h,其中 h/private 中的头文件是私有头文件,风河不保证私有头文件中的函数接口发生改变,因此开发者为了确保与后续版本的 VxWorks 兼容,在开发中应该使用 VxWorks 的公开 API。

VxWorks 将类似功能的组件代码放在同一个目录下,每个目录下还有一个 Makefile 文件,这个文件用来指导 VxWorks 内核的编译过程。需要说明的是,编译 VxWorks 内核生成的不是完整 OS,而是内核库,要创建一个完整的 OS,需要得到内核库之后创建 VIP 项目,下一节会有详细说明。

VxWorks 的常规开发流程

平时我们讲 Linux 开发,通常意思是在 Linux 系统之上进行开发应用程序,同时开发出来的应用程序也运行在 Linux 系统上。然而 VxWorks 是一个嵌入式系统,我们不方便在 VxWorks 中进行开发,一般而言,VxWorks 开发工作是在一个运行着 Linux 或 Windows 的开发机上进行的,在开发机上编译之后,再把生成的东西放在开发板上运行。而且编译出来的也不是应用程序,而是一个系统镜像(system image),这个系统镜像可以直接加载到开发版上,或者烧录到设备 ROM 中。

这种系统镜像类型的项目叫做 VIP(VxWorks Image Project),它产生的输出就是一个完整的 OS。而且 VxWorks 的可定制成都很高,开发者可以选择这个 OS 中需要哪些功能,不需要那些功能,还有相关配置参数的取值。开发工具会根据用户的配置,生成一个定制的系统镜像。

VIP 项目的构建过程其实非常简单,就是把相关的静态库链接在一起。由于静态库实际上就是若干目标文件的打包,因此静态库链接就相当于把一对目标文件链接在一起。根据 VIP 项目中的用户配置,开发工具可以选择链接哪些目标文件,不链接哪些目标文件,这就实现了系统功能的定制。(当然,VIP 构建过程肯定不是只有链接,BSP代码、开发者编写的代码、根据 VIP 配置自动生成的代码,这些都需要进行编译,并于内核库共同链接。)

因此,构建 VIP 项目之前,需要保证所需静态库存在,这些静态库就是内核库,通过编译 VxWorks 内核源代码生成。编译内核库有两种方式:

风河之所以将编译 VxWorks 的过程分为两步,一个原因是这可以加快 VIP 项目的构建速度,另一个原因则是用户可以在没有源代码的情况下使用 VxWorks 开发。用户可以创建 BSP 项目开发自己的版级支持包,也可以创建 VIP 项目,编写自己的内核应用程序,但是系统核心部分的代码是不变的。因此首先把系统核心部分编译成静态链接库,这样在构建 BSP 项目时就可以直接链接已经生成的库。

内核库的编译过程

前面已经分析了,VxWorks 系统镜像的构建需要已经编译完成的内核库。而内核库正是 target/src 目录下的代码,俗称的 VxWorks 系统源代码,实际上就是内核库的源代码。编译内核库是通过 make 工具完成的,首先运行的是 target/src/Makefile 文件,这个文件又会递归调用 make 执行子目录下的 Makefile 文件,最终遍历到所有子目录。如果分析一下这些 Makefile 文件,会发现它们基本上都有这样的内容:

include $(TGT_DIR)/h/make/defs.library
include $(TGT_DIR)/h/make/rules.library

这两行引用了 target/h/make/defs.librarytarget/h/make/rules.library 两个文件,分别包含编译过程需要的变量定义和规则定义,相当于把相同的逻辑提取了出来,每个子目录只需要引用这些文件,并定义自己的变量即可。

概括起来,每个源码目录的编译过程非常简单,变量 OBJS 指定了需要编译的目标文件列表,变量 SUBDIRS 指定了需要递归执行 make 命令的子目录,如果 SUBDIRS 没有定义,那就遍历所有的子目录。也就是说,OBJS 决定了当前目录下的哪些文件需要编译,SUBDIRS 则决定哪些子目录需要递归。

每个子目录为一个单位,其中的所有目标文件会在编译完成后打包成一个静态库文件。静态库本质上就是一些目标文件组成的压缩包,因此编译内核库的过程就是将 target/src 目录下的每一个源文件编译成目标文件,存放在 target/lib_smp 中。

组件配置系统

VxWorks 系统中的各个组件都是可以选择添加的,用户通过 Workbench 可以对系统镜像项目中使用的组件进行配置。组件的配置情况并不会决定哪些文件参与编译,毕竟在构建系统镜像之前,内核库就已经编译完成了。组件的配置情况决定的是系统的启动过程,如果启用了某一个组件,那么就会在系统启动阶段调用这个组件的初始化函数。

组件配置情况通过四个文件体现出来:prjComps.hprjParams.hprjConfig.clinkSyms.c

前两个头文件 prjComps.hprjParams.h 通过宏定义了当前配置下启用的每一个组件,并通过宏定义了用户配置的组件参数。这些宏会被 prjConfig.c 和 configlette 文件使用,正是因为使用到了构建镜像时才有的宏,configlette 被强行从内核源代码中分离出来,不和内核库一同编译,而是作为 VIP 项目代码的一部分。

prjConfig.c 则包含系统启动过程中执行的一系列函数,包括多任务环境启动之前执行的 usrInit,以及多任务启动之后,以第一个任务身份运行的函数 usrRoot。大量的组件都需要多任务环境,因此主要在 usrRoot 函数中初始化,而在多任务环境尚未建立的时候,只有一些基础核心组件可以初始化,如 cacheLib、objLib、objOwnerLib、objInfo、classLib、classListLib 等。

linkSyms.c 的内容则比较简单,定义了一个巨大的函数指针数组。由于在链接的时候,链接器会检查每个目标文件的引用数量。如果一个目标文件中没有函数会变量被其他文件引用,那么就说明这个目标文件是多余的,链接器便会把这个目标文件从最终的镜像文件中删除,以减小镜像文件的大小。然而有的时候,我们确实需要一些函数留在镜像中。链接的时候没有引用,并不表示运行时这个函数也不需要,更何况 VxWorks 允许用户通过命令行直接调用函数,还支持内核模块动态加载。为了避免一些关键模块被链接器优化掉,我们定义一个函数指针数组,将那些链接时没有引用但是又非常重要的函数放在数组中。这样强行添加一个引用,模块的引用数量就不再是零了,也就不会被链接器删除。

VxWorks 系统启动过程

在介绍 VxWorks 启动过程之前,首先补充一下系统镜像工程(VxWorks Image Project,VIP)的几种 Build Spec:

包含 bootloader 的镜像可以直接烧录到设备 ROM 中,适合于生产环境。不包含 bootloader 的镜像需要专门的引导器程序启动,但不需要烧录 ROM,可以网络引导,适合于开发环境。bootloader 代码位于 BSP 中的 romInit.s 文件内,负责将保存在 ROM 中的系统镜像复制到 RAM 中,如果启用了压缩,还需要对系统镜像解压。然后跳转到内核的入口点开始执行。bootloader 程序的入口点是 romInit,内核入口点是 sysInit。不管是否使用 bootloader,对内核而言,启动的过程是完全一致的。

sysInit

这个函数是 VxWorks 内核镜像最先开始运行的函数,代码位于 BSP 目录下的 sysALib.s 汇编文件中。源文件中,有着关于本函数功能的一段注释:

This is the system start-up entry point for VxWorks in RAM, the first code executed after booting. It disables interrupts, sets up the stack, and jumps to the C routine usrInit() in usrConfig.c.

可以看出,本函数的主要工作就是禁用中断、设置函数栈,然后跳转到 usrInit 函数,开始执行 C 语言代码。

如果分析以下 VIP 项目生成的系统镜像文件,就会发现 sysInit 函数位于代码段的开始位置,也就是 VxWorks 内核被加载到的位置。这个位置由宏 RAM_LOW_ADRS 控制,表示内核被加载到的位置。由于函数 sysInit 就位于内核镜像的开头位置,bootloader 与开发工具都能够非常方便地找到这个函数的地址,并控制 CPU 跳转过去。

当函数 usrInit 开始执行时,按顺序执行下列操作(usrInit 的执行过程与具体的处理器架构关系非常紧密,下面给出的是 SPARC 架构的情况):

usrInit

这个函数是系统中最早开始运行的 C 语言代码,位于 VIP 项目下的 prjConfig.c 文件中,函数的代码由 Workbench 自动生成。

这个函数运行的时候,多任务环境尚未建立,因此在这一阶段,只能对一些不依赖多任务的模块进行初始化。例如 cacheLib、objLib、objOwnerLib、objInfo、classLib、classListLib 这些模块,都不依赖多任务,因此可以在 usrInit 中进行初始化。基本类和对象相关的模块都在这一阶段初始化,因为类和对象是 VxWorks 其他模块的基础,而且并不需要多任务环境。

除了初始化类和对象相关模块,在 usrInit 函数中还执行了如下操作:

usrKernelInit

这个函数位于 target/config/comps/src/usrKernel.c,由 usrInit 调用,执行过程为:

kernelInit

这个函数在 usrKernelInit 中调用,实现位于 src/wind/kernelLib.c 文件中。这个函数设置了内存分区,创建根任务的 TCB 对象,并且开始运行第一个任务。具体执行过程为:

usrRoot

usrRoot 函数是根任务的入口点,因此 usrRoot 执行时,VxWorks 的多任务执行环境已经建立,此时所有模块都可以初始化。

usrRoot 的实现位于 VIP 项目自动生成的 prjConfig.c 文件中,调用了大部分库的初始化函数。由于不同的 VIP 配置会导致不同的初始化步骤,因此 usrRoot 函数的内容在不同的 VIP 项目里存在一定差异。此外由于 projConfig.c 文件是 Workbench 自动生成的,因此不建议开发者手动编辑该文件。

usrRoot 函数的最后,通常会调用 usrAppInit,执行用户编写的入口函数。usrAppInit 就相当于实时系统的 main 函数,用户编写的代码从这里执行。当函数 usrAppInit 退出之后,根任务便结束运行,但用户如果在 usrAppInit 中创建了新的任务,那么新创建的任务便会在根任务生命周期结束之后开始运行。到此 VxWorks 系统的初始化过程结束。


任务(Task)

任务可谓是 VxWorks 的核心概念,类似于 Linux 系统中的内核线程,是系统中最基本的独立执行单元。任务运行在特权模式(Supervisor Mode),相互之间没有地址空间隔离,整个系统共用同一套虚拟地址空间(暂时不考虑 RTP 的情况)。由于任务直接运行在特权模式,可以直接用函数调用的方法使用 VxWorks API,不需要系统调用。VxWorks 支持多任务,而且是抢占式多任务,因此一个任务不需要主动让出 CPU,系统会在必要的时刻强制停止当前任务的执行,而切换到一个新的任务。

任务切换、陷阱

系统支持多任务,那就需要实现任务切换。任务切换就是从一个任务切换到另一个任务的过程,这个过程往往是借助 CPU 的陷阱机制实现的。

陷阱(Trap)在不同体系架构下可能有不同的名字,例如 x86 下的中断和异常都可以称作陷阱。所谓陷阱,是一种异步的事件,当陷阱发生的时候,CPU 可以暂停目前正在运行的代码,保存状态,跳转到陷阱处理程序执行,完成之后恢复状态,继续执行之前被打断的程序。具体来说就是:

  1. 陷阱发生,CPU 将指令指针(PC)、栈指针(SP)的值保存在栈上(保存状态,这一步由 CPU 自动进行)
  2. 根据陷阱的向量号,跳转到陷阱处理程序的入口点开始执行(执行 ISR,仍然 CPU 自动进行)
  3. ISR 执行完毕之后,软件执行陷阱返回指令,CPU 从栈上恢复出 PC 和 SP 的值(恢复状态)
  4. 继续执行之前被打断的程序

VxWorks 区分了两种不同的陷阱,即中断和异常。二者的主要区别在于来源不同,中断的来源往往外部设备,例如时钟、键盘等;异常则是 CPU 内部产生的,例如除零、违反特权级、分页异常等。中断和异常的处理程序都有自己的栈,当陷阱发生时,首先保存状态到当前栈,然后切换到相应的中断栈或异常栈,执行处理函数,最后切换回执行栈并恢复状态。

由于陷阱前和陷阱后,PC 与 SP 的取值完全相同,因此完全感觉不到任何中断。但是有一点,保存 PC/SP 和恢复 PC/SP 都是通过栈进行的,如果我们在处理陷阱的过程中改变了栈,就会导致陷阱返回的时候,恢复的不是陷阱之前被中断的程序,这就是任务切换的基本思路。

执行栈、异常栈、中断栈

如果了解一些操作系统的概念,应该会知道,每个线程都拥有自己的栈。但是在 VxWorks 中,存在着三种栈:

如无特殊说明,提到“栈”,指的就是执行栈。

每个任务都拥有自己的执行栈和异常栈,当一个任务处于运行状态时,它有可能正在运行栈上执行业务代码,也有可能在异常栈上处理异常,这两种状态都属于运行状态。运行状态下,如果产生中断,任务的当前上下文(PC、SP 以及其他寄存器)会被保存到当前栈(运行栈或者异常栈)上,切换到中断栈,执行中断处理代码。

在中断即将返回的时候,VxWorks 会检查目前是否需要执行调度和任务切换(RoundRobin 情况)。例如中断之前正在运行任务 A,中断之后需要切换到任务 B,那么我们在完成中断返回前,并不会从中断栈切换回任务 A 的运行/异常栈,而是切换到任务 B 的运行/异常栈。


多核支持

Per-CPU Variables

VxWorks 不能运行时动态获取 CPU 数量,而是编译时指定。这样分配 per-CPU var 非常简单,只要把相关变量放在结构体中,有多少个 CPU,就声明多少个结构体变量。

perCPU 变量数组 vxKernelVars,定义在 src/wind/kernelData.c 文件中。结构体类型为 WIND_VARS,定义在 h/private/windLibP.h。

h/private/windLibP.h 提供了许多宏,用来访问 vxKernelVars 成员,如:

自旋锁

VxWorks 使用自旋锁很大胆,根本不担心影响性能。而且 VxWorks 自旋锁是严格先到先得,以便获得确定性。

VxWorks 有一个全局锁 kernelStateLock,位于文件 src/os/lock/kernelLockLib.c。这个锁的结构如下(经过了简化):

struct vxKernelLock {
    atomic_t ticket_counter;
    atomic_t service_counter;
};

获取自旋锁的过程: - atomic fetch ticket_counter and add one, original value is ticket - spin while service_counter != ticket

释放自旋锁的过程: - atomic add one to service_counter

这种锁可以类比银行柜台取号: - 用户在取号机取号,取到的号记作 ticket,同时取号机(下一个号)的数字加一。取号机便对应 ticket_counter。 - 只有一个服务窗口,窗口上方显示正在处理哪个编号的客户,对应 service_counter。 - 每个用户不断关注窗口上方的数字,一旦与自己手上的 ticket 一致,就坐到窗口前,表示得到了自旋锁。否则一直等待,即自旋。 - 拥有锁的用户完成了工作,准备释放锁,只需让窗口上方的数字加一。