最近正在学习 ecos 嵌入式系统,国内关于这玩意的东西还真是少之又少啊,闲着没事也翻译了两篇,看了下面这篇翻译,觉得比自己翻译的好多了,虽然 bakayi 翻译的是 ecos2.0 版,现在 ecos 都到 3.0 了,但是仔细看了看,发现就这篇概览来讲, ecos2.0 和 3.0 似乎没什么区别。美中不足的是,这个家伙貌似就翻译了这么一篇 ~
注:原文出处
http://blog.youkuaiyun.com/bakiya/archive/2008/04/25/2329124.aspx
名称
内核 - ecos 内核概览
描述
内核是 ecos 的一个关键包。它提供了开发多线程应用程序的核心方法。
1. 创建新线程,在系统启动或者已经运行的时候。
2. 控制不同的线程,比如操作线程的优先级。
3. 一个调度器,决定哪个线程当前可以运行。
4. 一组同步元语,允许多线程通讯和安全共享数据。
5. 集成系统中断和异常。
在其它一些操作系统内核中,一般会提供一些额外的功能,比如内核还会提供内存申请,并且设备驱动也作为内核的一部分。而在 ecos 中,内存申请模块被放在了一个独立的包中,同样,每个设备驱动也被放在一个独立的包中。我们可以用 ecos 的配置技术( configtool ),来将应用程序需要的包整合在一起。
ecos 内核包也是一个可选的。你完全可以写一个单线程的程序,不需要用到内核的任何功能,比如 RedBoot 。它是一个典型的基于中心循环检测的程序,它会不停的检查设备,当有 I/O 发生时,作出相应处理。每次循环都会有一个小的计数,用来指示 I/O 事件与循环检测之间的时间。当需求简单直接的时候,应用程序就可以用循环检测来实现,这样就可以避免多线程的同步问题了。当需求变得复杂的时候,就适合用多线程来解决,这时就需要内核包了。实际上 ecos 中一些更高级的包,比如 TCP/IP 协议栈,它内部就使用了多线程。因此,当应用需要用到这些包的时候,内核就必须包含,而不再是可选的了。
内核的功能可以通过两种方式来使用。内核提供了它自己的 API ,比如 cyg_thread_create 和 cyg_mutex_lock 等一些函数。这些函数可以直接被应用程序或者其它的包调用。还有一种方式是使用一些包中提供的兼容 API ,比如 POSIX 或μ ITRON 。这些兼容层允许应用程序调用标准的 API ,比如 pthread_create ,这些 API 由底层的 ecos 内核 API 实现。应用程序中使用兼容 API 可以使程序更加简单,也可以在其它系统中减少代码量,共享代码,方便移植。
尽管不同的兼容层在内核上有着相同的需求,比如创建一个新的线程,但它们还是有一些准确的语义差别。比如,严格的μ ITRON 要求内核的时间片轮转被关闭。这主要通过 ecos 的配置技术来完成。内核会提供大量的配置选项来控制这些差别和一些兼容层的特殊设置选项。这样会导致两种结果。第一,通常在一个 ecos 配置中,不会同时存在两种不同的兼容层,因为它们对内核的要求有冲突。第二,内核的 API 在语义上只能宽松的定义,因为有很多的配置选项。比如, cyg_mutex_lock 只会试图锁住一个 mutex ,但是当 mutex 锁住以后,不同的配置选项会决定不同的行为,并且很可能会引起优先级倒置。
内核可选的特性会导致其它一些问题,特别是设备驱动层。不管有没有内核,一个设备驱动应该正确工作。但是,系统的一些部分,特别是中断处理,在多线程和单线程的时候有着不同的实现。为了处理这种语义差别, HAL 包提供了一个驱动 API ,比如 cyg_drv_interrupt_attach 。当选择了内核的时候,这些 API 直接映射到内核提供的函数,比如 cyg_interrupt_attach 。当没有选择内核的时候,驱动 API 会自己实现,但是这种实现会比内核的实现要简单的多,因为它假定系统处在单线程环境中。
调度器
当一个系统包含多线程的时候,就需要一个调度器来决定哪个线程可以在当前运行。 ecos 的内核可以被配置成两种-- bitmap 和 MLQ 。 bitmap 调度器更高效,但是有数量限制。大多数系统会安装 MLQ 调度器。其它一些调度器会在将来加入进来,或者作为现有内核包的扩展,或者作为一个独立的包。
两种调度器都使用简单的数字优先级来决定哪个线程应该运行。优先级的级别用数字表示,可以通过 CYGNUM_KERNEL_SCHED_PRIORITIES 选项配置,但是一个典型的系统一般会有 32 个优先级别。因此线程的优先级一般会在 0--31 范围内。 0 是最高级别, 31 是最低级别。通常只有系统的空闲线程会运行在最低级别。线程的优先级是绝对的,因此内核只会在所有的高级别线程阻塞的时候,才会运行低级别的线程。
bitmap 调度器只允许每个级别一个线程,所以如果系统配置成 32 级别,那么就最多只有 32 个线程 -- 仍然满足大多数应用程序。一个简单的 bitmap 调度器可以被用来追踪当前哪些线程可以运行。它还可以追踪哪些线程正在等待 mutex 或其它的同步元语。识别最高级别的线程是否可以运行或正在等待其它线程是一个很简单的位操作,并且一个数组的索引操作可以被用来得到线程自身的数据结构。这让 bitmap 调度器处理很快,并且有完全的确定性。
MLQ 调度器则提供多个线程共用一个优先级别,这意味着系统对线程的数量没有限制,只要在系统内存允许的条件下。但是操作,像查找最高级别的可运行线程,将会比 bitmap 开销更大。
另外, MLQ 调度器支持时间片轮转,帮助调度器在一定的时钟 ticks 到来时,自动在多个优先级相同的线程间选择运行。时间片轮转只会发生在在两个可运行的线程处在同一优先级别,并且没有更高级别的可运行线程存在的时候。如果时间片轮转被关闭了,那么一个线程就不能抢占另一个相同级别的线程,只能等到那个线程运行完成或者被阻塞的时候(比如等待一个同步元语),才能运行。配置选项 CYGSEM_KERNEL_SCHED_TIMESLICE 和 CYGNUM_KERNEL_SCHED_TIMESLICE_TICKS 控制着时间片轮转。 bitmap 调度器不支持时间片轮转,它只允许一个级别一个线程,所以不可能存在相同优先级别的线程抢占问题。
另外一个影响 MLQ 调度器的重要配置是 CYGIMP_KERNEL_SCHED_SORTED_QUEUES 。它决定当一个线程被阻塞的时候(比如等待一个事件还未发生的信号量),将如何选择。系统默认的行为是先进先出 (FIFO) 队列。比如,当几个线程等待一个信号量,事件发生的时候,最先调用 cyg_semaphore_wait 入队的线程被唤醒。这就使得入队出队的操作既简单又非常快。但是,如果有几个不同优先级别的线程进入队列的时候,就很可能不是最高级别的线程最先被唤醒。实际上这是一个很少见的问题:通常最多只会有一个线程在等待队列,或者有多个线程,但它们处在同一级别。但是如果应用程序确实如此,那么就需要将配置选项 CYGIMP_KERNEL_SCHED_SORTED_QUEUES 打开。这有几个缺点:只要有线程入队,就需要做更多的工作,调度器也会被锁起来,因此系统延迟会有错误。如果 bitmp 调度器被启用,那么优先级队列会自动去掉,不需要任何改动。
一些内核的功能目前只被 MLQ 调度器所支持,而 bitmap 则没有支持。这些包括 SMP 系统,保护优先级倒置的解决方案:共用优先级和优先级继承。
同步元语
ecos 内核提供了一组同步元语:共用体 (mutex) 、条件变量 (condition variables) 、信号量 (semaphore) 、信箱 (mail box) 、事件标志 (event flag) 。
mutex 和其它的同步元语作用非常不同。 mutex 允许多个线程安全地共享一个资源:一个线程锁住 mutex ,然后操作共享资源,最后解锁 mutex 。其它的同步元语则通常用来在线程间通讯,或者是在一个中断发生的时候,随 ISR 之后的 DSR 与线程通讯。
当一个线程锁住一个 mutex 需要等待某个条件变成真的时候,你应该使用一个条件变量。条件变量本质上是提供给一个线程等待的空间,其它的线程或 DSR 可以使用它来唤醒那个线程。当一个线程等待一个条件变量的时候,它会在之前释放 mutex ,并且在唤醒前重新要求得到 mutex ,然后才能接着处理。这些操作都是原子的,所以竞争条件的概念没有引入进来。
信号量通常用在一件特殊的事件发生的情况下。一个等待线程将一直等待直到事件发生,而另一个发射线程或 DSR 会通告该事件。这个信号量是和数字相关的,所以如果事件发生在多个连续的时间点上,信息不会丢失,等待在相关的数字下的信号量会被处理。
信箱通常也被用来指示某种特殊的时间发生,并且允许在时间发生的时候交换一项数据。典型的数据项是一个指向某个数据结构的指针。正因为需要储存这些额外的数据,所以信箱只有一定的容量。如果一个线程收到邮件的速度要快于它所能处理的速度,那么为了避免溢出,它会被阻塞直到再次获得足够的空间。这意味着邮箱通常不能被 DSR 用来唤醒一个线程,而典型的用途是用于线程间的通讯。
事件标志可以被用来等待一定数目的不同事件,当有一件或多件事件发生的时候被唤醒。这通过一个代表不同事件的位掩码来完成。和信号量不同,它并不需要追踪事件的序号,实际上只要有事件发生就可以了。和邮箱不同的是,事件发生时它不能发送额外的数据,但这也意味着它不会引起溢出,所以既可以被用在 DSR 和线程之间,又可以用在线程之间。
ecos 的通用 HAL( 硬件抽象层 ) 包提供了自己的设备驱动 API ,其中也包含了以上的同步元语。它允许一个中断的 DSR 可以向上层代码通告事件。如果配置中加入了内核,那么驱动 API 会映射到等价的内核 API 上,这样中断就可以和线程交互。如果内核没有包含,应用程序也简单的运行在单线程环境下,驱动 API 就完全由 HAL 实现,同时也不用担心多线程问题,实现也更加简单。
线程和中断处理
在普通的操作期间,处理器将会运行在系统的多个线程中的一个上。它或许是一个应用程序的线程,也或许是一个 TCP/IP 协议内部的系统线程,又或者是一个空闲线程。在某个时间,中断发生了,这会将处理器的使用权暂时交给中断处理。当中断处理完毕,系统的调度器会决定把处理器控制权交给被中断的线程还是其它可以运行的线程。
线程和中断处理程序必须是可以交互的。如果一个线程正在等待一些 I/O 操作完成,与那个 I/O 相关的中断处理程序就可以通知线程操作已完成。这有几种方法来实现。一个最简单的方法就是设置一个 volatile 变量,线程可以间歇性检测,直到变量被设置,很可能这个间歇的睡眠时间是一个时钟周期。间歇性检测意味着 cpu 时间对其它运行的线程变得不可用,这或许可以被一些应用程序接受,但不是所有。每隔一个时钟周期检测一次使得开销很小,但是意味着不能检测到一个时钟周期内发生的 I/O 事件,典型的系统中这个时钟周期是 10 毫秒。这样一个延迟或许能被一些应用程序接受,但是不是所有。
一个好点的解决方案或许是用一个同步元语。中断处理程序可以发送一个条件变量,发射一个信号量,或者是一个其他的同步元语。线程会执行一个等待的同步元语。这样在 I/O 事件发生前就不会浪费任何的 cpu 周期了,并且线程也可以立即运行起来(假设没有更好级别的线程正在等待运行)。
同步元语会创建共享数据,所以要特别注意引起并发访问的问题。如果一个被中断的程序仅仅是执行一些计算,那么中断处理程序可以很安全的操作同步元语。但是如果被中断的程序正处在内核调用中时,很有可能内核数据遭到破坏。
一个避免此问题的方法就是,在一个内核关键区域内,禁止中断。在大多数的体系中,这非常容易实现也非常快捷,但是它将意味着中断会被经常禁止很长一段时间。对一些应用来说可能不是一个问题,但是对于要求有最快中断响应的嵌入式应用来说,内核禁止中断的机制将不能够满足它。
为了解决这个问题, ecos 内核使用了两级结构来处理中断。与每个中断向量相关的是一个中断服务程序 (ISR) ,它可以以最快的速度运行,所以可以服务硬件。但是, ISR 只可以调用系统小部分的内核函数,大多数和中断子系统相关,并且它不能调用使用任何唤醒线程的调用。如果一个 ISR 检测到一个 I/O 操作完成,线程应该被唤醒时,它会调用一个相关联的延迟服务程序 (DSR) 。 DSR 可以调用更多的内核函数,比如,发送一个条件变量,或这发射一个信号量。
禁止中断会阻止 ISR 运行,但是在系统的极少数部分,会禁止中断很短一段时间。让线程禁止中断的一个主要原因为了操作 ISR 所共享的数据。例如,如果一个线程需要加入一块缓冲区到一个链表中,但是 ISR 很可能会移除这个缓冲区的时候,线程就会禁止中断,从而操作这个链表。如果这个时候硬件产生一个中断,它将被推迟到中断打开后处理。
类似中断的禁止与打开,内核也有一个调度器锁。有几种内核函数像 cyg_mutex_lock 和 cyg_semaphore_post 都要求得到调度器锁,以便操作内核数据,完成后解开调度器锁。如果一个中断引起的 DSR 被调用,但是调度器被锁上,它就会被延迟处理。只有当调度器解锁后,它才可以继续运行。这或许会发送同步事件,唤醒高级别的线程。
例如,设想一下下面的情景。系统有一个高级别的线程 A ,负责处理来自外部设备的数据。当数据可用的时候,设备会发起一个中断。同时有两个线程 B 和 C, 正在执行计算,偶尔会写入一些分类信息到屏幕上。屏幕是一个共享的资源,所以一个 mutex 被用来控制访问。
在一个特殊的时刻,线程 A 似乎被阻塞了,等待一个信号量或者是其它的同步元语,直到数据变得可用。线程 B 或许正在处理一些运算,线程 C 正在等待下个时间片。中断被打开,调度器也被解锁,因为没有任何线程正在进行内核操作。就在这个时刻,中断发生了,接着相应的 ISR 开始运行。这个 ISR 操作硬件,确定数据可用,想要通过发送一个信号量来唤醒线程 A 。但是 ISR 不能直接调用 cyg_semaphore_post ,所以它要求相应的 DSR 运行。现在没有其它的中断发生,所以内核开始检查 DSR 。它发现有一个 DSR 正待处理,并且调度器没有锁上,所以 DSR 可以马上运行起来,发送一个信号量。这样就会使得线程 A 变成可运行态,调度器的数据也相应调整。当 DSR 返回时,线程 B 就不是最高级别的可运行线程了,而线程 A 则得到了 cpu 的控制权。
在上面这个列子中,没有内核数据在中断发生的那一瞬间被操作,但是我们可以想象。假设线程 B 完成当前的计算任务,想要写入结果到屏幕上。它会要求得到 mutex ,从而操作屏幕。现在假设线程 B 得到时间片,开始运行,而线程 C 也完成了计算想要写入数据到屏幕上。线程 C 先调用了 cyg_mutex_lock 。这个时候线程 B 把调度器锁上,检查 mutex 的当前状态,发现 mutex 已经被其它的线程得到了,于是调度器终止了当前的线程,选择了其它可以运行的线程。刚好另外一个中断发生在 cyg_mutex_lock 的调用期间,导致 ISR 立即运行。 ISR 决定唤醒线程 A ,所以它调起 DSR, 返回内核。这个时候系统有一个待处理的 DSR ,但是调度器仍然被锁住,所以 DSR 不能立即运行起来。而调用 cyg_mutex_lock 的线程继续运行,直到某个时刻解开调度器。待处理的 DSR 才可以运行,安全得发送信号量,唤醒线程 A 。
如果 ISR 直接调用 cyg_mutex_lock 而不是把它留给 DSR 的化,很有可能内核数据会遭到破坏。例如内核可能完全失去对某个线程的追踪,从而导致这个线程永远不会再次运行。两个级别的中断处理机制, ISR 和 DSR ,可以有效得防止这些问题,而不需要禁止中断。
调用 context
ecos 定义了很多 context 。每个 context 只允许一定的调用,例如大多数线程操作或同步元语不能在 ISR context 调用。这些不同的 context 有:初始化、线程、 ISR 、 DSR 。
当 ecos 启动的时候,它会经历一系列阶段,包括设置硬件,调用 C++ 静态构造。在这期间,中断被禁止,调度器也被锁上。当一个配置包含内核,最后的操作是调用 cyg_scheduler_start. 在这个时候中断被打开,调度器被解锁,控制权交给最高优先级的线程。如果配置同样包含了 C 库,那么通常 C 库的启动包会创建一个线程来调用应用程序的入口函数 main 。
一些应用程序的代码同样可以在调度器启动之前运行,这些代码就运行在初始化 context 。如果应用程序部分或完全由 C++ 写成,那么任何静态对象的构造器会运行。相应地,应用程序代码可以定义一个函数 cyg_user_start ,它将在 C++ 静态构造器运行之后被调用。这样就允许应用程序完全由 C 来写。
void
cyg_user_start(void)
{
/* 在这里执行应用程序的特定初始化动作 */
}
应用程序并不一定要提供这个函数,因为系统提供了一个默认的,但是并不做任何事情。
在静态构造器和 cgy_user_start 里,最典型的操作,包括创建新线程、同步元语、设置报警器、注册应用程序指定的中断处理程序。实际上,对于大多数应用程序来说,这些创建的操作一般都发生在这个时候,使用静态申请的数据,避免动态申请内存或其它花费。
代码运行在初始化 context ,中断被关闭,调度器被锁上。在这个时候,系统不能保证运行在一个完全一致的状态,所以拒绝打开中断和解锁调度器。一个结果就是,初始化代码不能使用同步元语,比如用 cyg_semaphore_wait 等待一个外部的事件。锁上和解锁 mutex 也是不允许的:没有其它任何线程正在运行,所以能够保证 mutex 还没有被锁上,因此,上锁的操作永远不会阻塞线程。当在内部使用一个 mutex 来调用库函数的时候,这会非常有用。
在启动阶段的最后,系统将调用 cyg_scheduler_start, 然后大量的线程就可以开始运行了。在线程 context ,几乎所有的内核函数都可用。但是中断相应的操作可能会有一些限制,这取决于目标硬件。例如,硬件可能会要求在控制回到线程 context 之前,在 ISR 和 DSR 中得到应答,在这种情况下, cyg_interrupt_acknowledge 必须被线程调用。
在任何时候,处理器可能接收到一个外部中断请求,导致控制权从当前线程转移。典型的例子是,一个 ecos 提供的 VSR ,会运行并准确的确定那个中断发生。这时 VSR 会选择对应的 ISR ,它可以被 HAL 、设备驱动、或者应用程序提供。在这段期间,系统运行在 ISR context ,大多数的内核调用都被禁止。这些包括大量的同步元语,所以一个 ISR 不能发送一个信号量,指示某个事件发生。通常在 ISR 内唯一被允许的操作就是和中断相关的子系统,例如屏蔽一个中断或者是应答一个已经处理的中断。另外,在 SMP 系统中,还可以使用 spinlocks 。
当一个 ISR 返回时,他将要求相应的 DSR 尽快的安全运行起来,然后就系统运行在 DSR context 。这个 context 也允许报警器函数,线程也可以通过锁上调度器而临时得到运行。在 DSR context ,只有一定的内核函数可以被调用,然而也比 ISR context 多多了。较为特殊的是,它允许使用同步元语,但是不能产生阻塞。这些包括 cyg_semaphore_post, cyg_cond_signal, cyg_cond_broadcast, cyg_flag_setbits, and cyg_mbox_tryput. 不允许可以产生阻塞的同步元语,包括 cyg_semaphore_wait, cyg_mutex_lock, or cyg_mbox_put 。调用这些函数会使系统挂掉。
有关各种内核函数的文档给出了更多的细节,关于正确的 context 。
错误处理和断言
在许多 API 中,每个函数都会对参数的正确性,或者系统的状态作出验证。这样可以确保每个函数都可以被正确的使用,比如,应用程序不会试图对一个信号量像共用体 (mutex) 一样操作。如果根据返回的一个错误代码检测到一个错误,比如 POSIX 函数 pthread_mutex_lock 可以返回不同的错误代码,像 EINVAL 和 EDEADLK 等。这样做会有一些问题,尤其是在嵌入式系统中:
1. 执行这些检查,不管在 mutex lock 内还是在其它的函数中,都需要额外的 cpu 周期,并且会明显地增加代码大小。即使程序编写完全正确,并且调用系统函数的参数有意义,并在正确的条件下,这些开销仍然存在。
2. 返回错误代码只在一种情况下有用,即调用代码能够识别这些错误代码并作出合理处理。实际通常调用者会忽略一些错误,因为程序员“知道”函数被正确的使用。如果程序员犯了错误,那么一个错误的条件验证将被检测到,但是程序却会继续执行,最后以一种神秘的方式失败。
3. 如果调用者一直检查错误代码,那么将增加更多的 cpu 周期,更多的代码。
4. 通常没有方法可以恢复错误代码,所以如果程序代码检查到一个错误比如 EINVAL ,它所能做的,只能是挂起程序。
ecos 内核采取了一种不同的方式。一些函数,比如 cyg_mutex_lock ,不会返回一个错误代码。作为替代,他包含大量断言,这些断言能被打开或关闭。在开发期间,断言通常都被打开,内核的函数会进行参数检查和一些系统检查。如果一个问题被检测到,那么断言就会失败,从而应用程序被终止。在一个典型的调试中,程序员会设置一些断点,然后检查系统状态,准确地知道将要发生的事情。在开发的最后阶段,通常会通过配置选项关闭断言,这样所有的断言就会在编译阶段被清楚。这样做有一个假定:所有的程序代码 bug 都已经得到最好的解决了,代码必须可以操作信号量像操作共用体一样,但是不会出错。这样做有几个好处:
1. 在最终的程序中,没有检查参数的系统开销。所有这些开销都在编译阶段清除掉了。
2. 因为最终的程序不会忍受额外的开销,开发期间系统做更多的工作也是合理的。特别是断言可以测试更多的错误条件和复杂的错误。当以个错误被检测到的时候,一条错误的信息比一个错误的代码有用的多。
3. 程序不需要处理内核函数的返回值。这可以简化程序代码。
4. 如果错误被检测到,断言失败,程序立即挂起。没有忽略错误条件的可能,因为程序代码不会检查返回的错误代码。
尽管没有内核函数返回错误代码,它们很多会返回一个状态条件。例如,函数 cyg_semaphore_timed_wait 一直等待,直到一个事件发生,或者一定的时钟周期完成。通常调用者直到等待操作完成了还是时钟周期发生了。 cyg_semaphore_timed_wait 返回一个 boolean 值: 0 或者 false 表示超时,一个非零的数值代表等待已完成。
一个常见的错误条件是内存不足。例如, POSIX 函数 pthread_create 通常需要动态申请一些内存给线程的堆栈和数据使用。如果目标硬件没有足够的内存满足所有的请求,或者更一般的情况是程序有内存泄漏,那么没有足够的内存将导致函数调用失败。 ecos 内核通过避免申请动态内存来避免该问题。相反,它所需要的内存需要由应用程序来提供。在这样的情况下, cyg_thread_create 意味着一个 cyg_thread 数据结构包含所有线程的细节,和用来作为堆栈的一个 char 型数组。
在很多程序中,这种方式,导致所有的数据结构都必须被静态的申请而不是动态。这有几个好处。如果程序实际上需要太大的内存,那么在链接阶段就会报错,而不是运行阶段,这会使问题更易诊断。静态申请不像动态申请那样,需要的额外的开销。例如,不需要追踪可用的内存块,也可以完全将 malloc 从系统消除。诸如内存碎片和内存泄漏的问题也不会发生,如果所有的数据都是静态申请的话。然而,一些应用程序却十分复杂,不得不使用动态申请内存,这时内核函数却不能区分这些内存是动态申请的还是静态申请的。它仍然要求调用者确保所提供的内存可用,当传递一个空指针给内核的时候会导致断言失败或者系统崩溃。