一个程序猿郁结十年的青苹果
Bush 2014-4-24
前言
此文发表在此,由于正吃菜的我才疏学浅,文中难免有错误的地方,欢迎看官和过客指正批评,痛骂也无妨,我虚心接受所有的鄙视。
目录
概述
缩略语
01 何谓任务?
02 任务与中断有啥异同?
03 何谓原子性操作?
04 任务栈是怎么回事?
05 何谓现场?
06 临界保护对子中C语言的变量跟汇编子函数中的寄存器是怎样联系起来的?
07 任务切换时具体做些什么?
08 任务切换在什么时候发生?
09 任务被切后是个什么样子?
10 关于就绪表中高效的调度算法
11 系统启动时运行的第一个任务,第一步是怎么运行的?
12 空闲任务 OS_TaskIdle 在什么时候运行?
13 μC/OS-II中的中断同单片机程序的中断有什么异同?
14 什么是函数的可重入性?其意义何在?
15 同步信号量的内部机理是什么?
16 互斥信号量的内部机理是什么?
17 邮箱的内部机理是什么?
18 消息队列的内部机理是什么?
19 标志组的内部机理是什么?
20 μC/OS-II的内存管理机制是什么?
21 什么是堆?malloc和free是怎么运作的?
22 操作系统到底是什么东东?
后记
概述:
该笔记对应μC/OS-II的源码版本是V252,对照阅读的参考书是北京航空航天大学出版社 2003年5月第一版《嵌入式实时操作系统 μC/OS-II(第二版)》(English name《MicroC/OS-II The Real-Time Kernel Second Edion》Author:Jean J.Labrosse,邵贝贝翻译)。
该笔记对应的硬件平台是STM32F107,编译器是安装了 ARM-MDK 453 的 Keil μVision4,代码阅读工具是SourceInsight3.5。
该笔记并非源代码的详细讲解,亦非μC/OS-II的使用说明,而是汇总了阅读源码过程中产生的疑问及解答,进而从中归纳总结出μC/OS-II系统的内在机理,对于想从本质和源头探索操作系统的程序猿或许有点参考帮助,或许能够启发更优质的使用μC/OS-II的方法,甚者若能就实际情况来优化μC/OS-II的内核以提高软件的质量则更当令此文欣慰了。所谓学然后知不足,教然后知困。本文并非教学总结,无能面面俱到,假如看官正为类似问题而纠结,那么若能知遇此文,就算是缘分了。
缩略语:
缩写代号 含义
OS 操作系统
μC/OS-II 指μC/OS-II内核
TCB TASK CONTROL BLOCK(任务控制块)
ECB EVENT CONTROL BLOCK(事件控制块)
SP CPU中的堆栈指针寄存器
CM3 ARM公司 Cortex M3单片机内核
MSP CM3中的主堆栈指针寄存器,是SP的一个化身
PSP CM3中的进程堆栈指针寄存器,是SP的一个化身
PendSVHandler PendSV中断服务函数
TickHandler CM3中SysTick中断服务函数
系统 主要指μC/OS-II,有时指一般意义上的系统,有时指操作系统,根据上下文来确定
任务 除了一般意义上的任务外,有些地方还包括中断,需根据上下文来确定,请看完01节
上台 任务占据CPU
下台 任务退出CPU
被切 任务在任务切换中退出CPU
绿色的文字 都是源代码的摘录
01 何谓任务?
在此打算辨清任务、进程、线程这些概念。
欲说任务,必先说任务控制块(TCB)。TCB是一个全局变量,它们在μC/OS-II中的组织形式是一个链表化了的数组:OS_TCB OSTCBTbl[OS_MAX_TASKS + OS_N_SYS_TASKS],每个数组元素是一个TCB。它与任务一一对应,不管任务存在与否,TCB这个空间一直存在,只不过是个空的TCB而已。这个TCB数组由μC/OS-II负责管理,通过OSTaskCreate函数分配给任务,通过OSTaskDel函数收回到全局指针OSTCBFreeList所串的链表中。欲说任务,还必须说任务函数。任务函数是一个死循环,类似单片机程序中的主循环。单片机中的主循环,除了中断耽搁了一会儿,一直在主循环中转圈。任务函数的死循环也是这样,除了中断和其它任务耽搁了一段时间,也是一直在死循环里转圈。欲说任务,也必须说任务栈。任务栈是一段全局的内存,由程序猿创建,只限程序猿指定的任务使用,μC/OS-II负责这段全局内存的管理。把任务比作人,TCB相当于人的骨骼,任务函数相当于人的肌肉,程序猿给任务设定的功能相当于人的性格,任务函数的所有运行,相当于人的行为,任务栈相当于人的家,μC/OS-II相当于国王,程序猿相当于天。任务函数一圈圈的周而运行,好似人一年年的轮回。任务占据了CPU好似人睡醒的白天,任务退出CPU好似人睡觉的夜晚。这就是任务,其又名曰进程(还有线程基本也是这个意思)。什么是属于任务私有的呢?TCB是μC/OS-II的,由任务使用,任务栈和任务函数貌似是任务的私有物,其它的,任务函数调用的函数,包括系统函数和程序猿设计的应用函数,是与其它任务共有的。任务函数调用的函数,好似过眼云烟,只是那时那刻,以那个任务的名义,经过了CPU,来无影去无踪。其实那就是任务的精神,精神只在当下有意义,也就是经过CPU的16个寄存器中时才有实际意义,而且必须依托于一个具体的任务才有意义,除此之外,它们只不过是陈旧的记忆或者遥远的空想。可见,在OS的世界里,也跳不出王阳明参悟的真理——“知行合一”。
TCB的具体定义如下,这是精简化了的定义,只是为了理解TCB的目的,以删其繁。其成员共分五组,以空格间隔。第一组,OSTCBStkPtr是任务栈的台账;第二组,是μC/OS-II的户口稽查员,用来查找这个TCB;第三组,是任务通信的台账;第四组是任务睡眠、任务状态、任务级别的台账;第五组,是μC/OS-II的小兵,专用于任务切换的快速计算。这个TCB在任务创建时由OS_TCBInit函数初始化。
/*
*********************************************************************************************************
* TASK CONTROL BLOCK
*********************************************************************************************************
*/
typedef struct os_tcb {
OS_STK *OSTCBStkPtr; /* Pointer to current top of stack */
struct os_tcb *OSTCBNext; /* Pointer to next TCB in the TCB list */
struct os_tcb *OSTCBPrev; /* Pointer to previous TCB in the TCB list */
OS_EVENT *OSTCBEventPtr; /* Pointer to event control block */
void *OSTCBMsg; /* Message received from OSMboxPost() or OSQPost() */
OS_FLAG_NODE *OSTCBFlagNode; /* Pointer to event flag node */
OS_FLAGS OSTCBFlagsRdy; /* Event flags that made task ready to run */
INT16U OSTCBDly; /* Nbr ticks to delay task or, timeout waiting for event */
INT8U OSTCBStat; /* Task status */
INT8U OSTCBPrio; /* Task priority (0 == highest, 63 == lowest) */
INT8U OSTCBX; /* Bit position in group corresponding to task priority (0..7) */
INT8U OSTCBY; /* Index into ready table corresponding to task priority */
INT8U OSTCBBitX; /* Bit mask to access bit position in ready table */
INT8U OSTCBBitY; /* Bit mask to access bit position in ready group */
} OS_TCB;
02 任务与中断有啥异同?
相同点:任务和中断都有独立性,都是主动运行,即任务函数不是因为其它函数的调用才运行。任务函数和中断服务函数都不准return。两者都可以用Post类通信机制以及OSTaskResume。
相异点:任务受μC/OS-II制约,中断不受μC/OS-II制约,甚至μC/OS-II还要让着中断,因为PendSV中断的优先级最低,而且μC/OS-II还得祈求中断给μC/OS-II打招呼。任务可以做到很大,而中断则要求短小精悍。任务有自己的栈空间,中断只是共用中断们的主堆栈。任务主要是内部的行为,而中断主要是外部事件。任务可以用所有通信机制,中断不可以用Pend类通信。
概而言之,在μC/OS-II的世界里,中断是比任务地位更高的存在。若把程序猿比作天,任务居于人道,μC/OS-II是国王,而中断显然不属于人道,也不服从μC/OS-II的管理。那中断算是哪一道的呢?鬼道?阿修罗道?中断比人道的任务有点高明,但毕竟有缺陷,因此还是逃不出宿命,那是程序猿给安排的使命。
03 何谓原子性操作?
所谓原子性操作就是一个在操作期间不能被打断能连续运行的操作。操作系统中经常用的一个术语——原语,就是指原子性操作。实际中的原子性操作基本有两种情况:第一种是不能被中断的汇编指令;第二种是用关中断开中断对子保护起来的代码,无论代码有多长,只要在开始处关了中断,那么它必然是原子性的。
04 任务栈是怎么回事?
任务栈是程序猿定义的全局内存,要么是静态数组,要么是动态数组,在任务创建时,它作为参数由程序猿分配给任务。任务在创建时,在任务栈中初始化一个CPU现场,其中除了xPSR、PC、LR、R0外,其它的都是假的,并保存栈顶至TCB的第一个成员OSTCBStkPtr中。任务栈通过任务切换正式启用,这句话的意思是任务栈在任务上台时,把栈顶指针恢复到CM3中的SP中。任务被切后会把任务的现场保存到任务栈中,并更新栈顶至OSTCBStkPtr。任务上台时,μC/OS-II通过OSTCBStkPtr找到任务栈,并恢复现场。因此可以说,OSTCBStkPtr是任务栈(程序猿定义的全局内存)与任务的纽带。形象的说,任务栈就是任务的一亩三分地,就是程序猿给任务安排的居所。
这儿需要针对性说明的是,CM3中的堆栈指针寄存器有两个:MSP和PSP,CM3上的μC/OS-II对这两个堆栈指针寄存器的分工是,中断用MSP,任务用PSP,因此任务和中断的栈互不干涉。假若都用MSP,则中断会像寄生虫一样借用任务的栈,哪个任务运行期间发生的中断,就借用那个任务的栈。并且任务切换都会是在上一个任务栈的空间里运行切换操作(因为切换操作也是中断)。这样是很不可取的,不仅有可能导致任务栈的溢出,而且还会导致bootload时创建的Stack闲置不用,是浪费。bootload的中的Stack是否在运行到main函数时就撤除了?这个思考题留待以后验证。回答:不会撤除。这个问题同问题21一样,涉及到了运行库的问题。
05 何谓现场?
这个问题其实不属于OS的范畴,这是微机原理的知识,但却是理解OS的基础。现场就是CPU的寄存器们,它们是离ALU最近的存在,在CM3中,它们是R0-R15,xPSR,共16个32位寄存器。任何函数,不管是任务函数还是中断服务函数,必须经过现场寄存器才能达到ALU。函数的运行,都是经流过这些现场寄存器们来完成的。每个时刻,现场寄存器们都对应了函数的最微小的一步。通过现场,能得到正在执行的函数的当下状态。把ALU比作皇帝,现场寄存器们就是皇宫里干活的人,太监及朝臣之类。相比之下,代码区的函数就是地方上的外臣了。每个时刻,现场都对应了一个缘分,这就是OS世界里的当下。
06 临界保护对子中C语言的变量跟汇编子函数中的寄存器是怎样联系起来的?
这是一个特殊问题,在ARM中有明确规定,由文档《ARM Architecture Procedure Call Standard(AAPCS, Ref5)》给出。当主调函数传递实参时使用R0-R3,R0传递第一个,R1传递第二个......。子函数的返回值写到R0中,见《Cortex-M3权威指南》中文版 P152。实际测试中,发现在KEIL STM32F107环境下,当参数小于等于4个时是如此,当参数大于4个时(拿N=10个做实验),是先R0传递第10个,R1传递第9个,R2传递第8个,R3传递第7个,然后把R0-R3依次压入栈中,然后,R0传递第6个,R1传递第5个,R2传递第4个,R3传递第3个,然后再把R0-R1依次入栈,然后再R1传递第2个,R0传递第1个,这时栈中的参数从栈顶到栈尾方向依次是第五个到第十个。多次实验结果,无论参数多少,最终结果同前述规约一致,且栈中的参数顺序从栈顶至栈底方向依次是参数N-参数5。但参数大于15时就出错了,栈中出现丢失。其实这只是一个编译器相关的问题,只是一个规定,编译器在遇到C代码调用汇编过程时,会遵守这个规定,这样,遵守这个规定的程序员和编译器就同节拍走路了。
请查阅这个对子的具体代码,就是如规定所言。
#define OS_ENTER_CRITICAL() {cpu_sr = OS_CPU_SR_Save();}
#define OS_EXIT_CRITICAL() {OS_CPU_SR_Restore(cpu_sr);}
OS_CPU_SR_Save
MRS R0, PRIMASK ; Set prio int mask to mask all (except faults)
CPSID I
BX LR
OS_CPU_SR_Restore
MSR PRIMASK, R0
BX LR
使用方式:
OS_CPU_SR cpu_sr;
......
OS_ENTER_CRITICAL();
原子操作;
OS_EXIT_CRITICAL();
......
07 任务切换时具体做些什么?
μC/OS-II中的任务切换是指从就绪态的任务中选取优先级最高的任务,推入CPU,使之变成执行态。一个任务是否是执行态,在TCB中的分量 OSTCBStat 并无标识,而是由全局变量 OSTCBCur 指向该任务的TCB来决定。形象的说就是,全局指针变量 OSTCBCur 是永恒的舞台,各个任务都是该舞台的过客,虽然任务的优先级有高有低但都是暂时的戏子,都是因缘而住,缘尽而走。这里所说的缘分,主要有两类,一是自己内心不净,有杂念,有心事,在等待某件事(包括 OSFlagPend,OSMboxPend,OSMutexPend,OSQPend,OSSemPend,OSTaskSuspend,OSTimeDly,OSTimeDlyHMSM),于是下了台,去了远方;二是被高优先级的任务抢占。被抢占的任务还原为就绪态,因此可以说就绪态相当于这个舞台的幕后。
概而言之,任务切换的本质就是中断,一个人为的中断而已,在这个中断里,做一些人为的手脚。
任务切换在μC/OS-II中的实现是函数void OS_Sched (void)和void OSIntExit (void),函数OS_Sched在源码中处处可见,函数OSIntExit在中断服务函数中必须殿后。两个函数最终都归于一处,那就是PendSV中断服务函数——OS_CPU_PendSVHandler,在此做切换工作的核心。在此之前,该两个函数所做事情是一样的,都是查询就绪表,找出最高优先级的任务,然后判断当前执行态任务是否是最高优先级的,若不是则把刚刚找到的最高优先级任务的TCB,赋值给全局指针变量OSTCBHighRdy,然后触发PendSV中断。在这里全局指针变量OSTCBHighRdy相当于上台的一个台阶,从幕后到台前的中间一步。触发中断后,在PendSV中断服务函数里,做OSTCBCur = OSTCBHighRdy的最后工作,同时做保存当前任务现场和取出新任务现场的配套工作。这些工作都是关中断的情况下,用汇编语言实现的。其伪代码及汇编代码如下所列。
伪代码:
OS_CPU_PendSVHandler()
{
if (PSP != NULL) {
Save R4-R11 onto task stack;
Make R0 to point to the top of current stack;
OSTCBCur->OSTCBStkPtr = R0;
}
OSTaskSwHook();
OSPrioCur = OSPrioHighRdy;
OSTCBCur = OSTCBHighRdy;
R0 = OSTCBHighRdy->OSTCBStkPtr;
Restore R4-R11 from new task stack;
Make R0 to point to the top of current stack;
Return from exception;
}
汇编代码:
在PendSVHandler之前的主要操作是上台阶的C代码:
OSTCBHighRdy = OSTCBPrioTbl[OSPrioHighRdy];
在PendSVHandler之前的程序环境上文是:
OSPrioCur是当前在台上的任务优先级,即任务ID,OSTCBCur指向当前在台上的任务的TCB。
OS_CPU_PendSVHandler
CPSID I ; 先关中断,此时,xPSR, PC, LR, R12, R3, R2, R1, R0 已经按
; 位置(栈底到栈顶)被CM3暗箱操作自动入栈——PSP指向的进程堆栈
; (第一个任务除外,那时入的是MSP指向的主堆栈,是一桩无用功,
; 白浪费了32byte主堆栈,因为再也不会返回了)。
MRS R0, PSP ; 把任务的堆栈指针搬到R0中,只能用MRS指令,因为此时的SP,
; 也就是R13,化身为MSP,PSP隐身了。
CBZ R0, OS_CPU_PendSVHandler_nosave
; if(R0 == 0) goto OS_CPU_PendSVHandler_nosave;,
; 跳过后面的保存现场操作,因为第一个任务运行后,会一去不返,
; 没必要保存现场。
SUBS R0, R0, #0x20 ; R0是PSP的钦差大臣,PSP虽深居宫中,但可以通过钦差治理国事,
; R0-=32,是为R4-R11的入栈腾出空间来,相当于在PSP指向的栈中
; 开辟了32byte的数组。
STM R0, {R4-R11} ; 把R4-R11入栈,入栈顺序是R11-R4,R4在栈顶,通过此两行,
; 被切换的任务现场保存到任务栈中了;
; 执行完后,R0的值没有变.此处 STM 是 STMIA 的省略式,
; 参见 STM 的语法,Keil下 在 STM 上按F1,可见汇编指令的文档;
; 上面两行汇编代码可以用下面一行汇编代码代替:
; STMDB R0!,{R4-R11} 此条指令,先做R0 -= 4,
; 然后做 *R0 = Rn,并写回R0的更新值;
; 执行完此句指令后,*R0的值是R4的值,即未来PSP指向的值。
LDR R1, =OSTCBCur ; 取得全局指针变量OSTCBCur的地址值至R1中。
LDR R1, [R1] ; 取得OSTCBCur的值至R1中。
STR R0, [R1] ; 把R0的值(也就是最新的任务栈顶)写到OSTCBCur指向的地方,
; 即*(unsigned int*)OSTCBCur,
; 因为OSTCBStkPtr是struct os_tcb的第一个成员,
; 故*(unsigned int*)OSTCBCur就是OSTCBCur->OSTCBStkPtr,
; 到此时,被切换的任务现场保存到该任务的栈中,
; 同时栈顶指针被保存到TCB中了。
OS_CPU_PendSVHandler_nosave
PUSH {R14} ; 下面四行代码,是在这个中断服务函数中调用OSTaskSwHook()函数
LDR R0, =OSTaskSwHook
; 因此有保存LR之举,用的是主堆栈MSP。在OSTaskSwHook函数中,
; 可以随便使用其余寄存器。
BLX R0
POP {R14}
LDR R0, =OSPrioCur ; 下面四行代码,是做C中的赋值操作:OSPrioCur = OSPrioHighRdy;
LDR R1, =OSPrioHighRdy
; C中变量名字到汇编这儿来,就是地址的意思,
; 故先把这两个全局变量的地址值取到寄存器中,
LDRB R2, [R1] ; 然后用加载指令,取出R1地址的内存值,也就是
; OSPrioHighRdy的值,放到R2寄存器中,
STRB R2, [R0] ; 再用存储指令,把OSPrioHighRdy的值写到R0地址的内存中,
; 也就是OSPrioCur的所在地。
LDR R0, =OSTCBCur ; 下面四行代码,是做C中的赋值操作:
;OSTCBCur = OSTCBHighRdy;
; 原理同上。
LDR R1, =OSTCBHighRdy; 分别把全局变量OSTCBCur和OSTCBHighRdy变量的地址值取到
; R0和R1寄存器中,
LDR R2, [R1] ; 把R1地址处的内存值加载到R2中,也就是OSTCBHighRdy的值,
STR R2, [R0] ; 把R2的值写到地址R0处,也就是OSTCBCur = OSTCBHighRdy;。
LDR R0, [R2] ; 运行到此地,此时R2的值是OSTCBHighRdy,它是一个全局的
; 指针变量,因为OSTCBStkPtr是struct os_tcb的第一个成员,故
; OSTCBHighRdy = &(OSTCBHighRdy->OSTCBStkPtr),故,
; OSTCBHighRdy->OSTCBStkPtr=*(unsigned int*)OSTCBHighRdy;
; 因此再以R2为地址继续加载,就能得到
; OSTCBHighRdy->OSTCBStkPtr的值,
; 而此值就是新任务的堆栈栈顶。
; 通过执行该行代码,把新任务的堆栈指针从TCB中读到寄存器R0中,
; 即R0 = SP,现在R0就是PSP的钦差大臣。
LDM R0, {R4-R11} ; 从R0指向的位置从低地址向高地址开始加载8个字依次放到
; R4,R5,R6,R7,R8,R9,R19,R11中。
ADDS R0, R0, #0x20 ; 更新R0的值:R0 = R0 + 32,如同PSP的POP操作。
; 上面两行汇编代码可以用下面一行汇编代码代替:
; LDM R0!, {R4-R11}} 此条指令,R0每加载一个寄存器后
; 会把增加的地址写回,即R0 += 4。
MSR PSP, R0 ; 把R0的值赋值给PSP寄存器,也就是PSP真正的指向了新任务的
; 堆栈栈顶,此时PSP指向的值是上次自动入栈的R0。
ORR LR, LR, #0x04 ; LR是CM3内核中断的出口,共有三个,其中bit2 = 1表示从PSP所
; 指向的堆栈做出栈操作,返回后使用PSP,
; 见《Cortex M3 权威指南》表9.3;
; 此行代码保证LR的bit2 = 1,这样在后续的出栈操作时,
; CM3内核自动POP的8个寄存器,会从新任务的堆栈中POP;
; 其实此句只在OS启动时有效,因为CM3在bootload之后,
; 进入main函数时,默认值是:线程模式,使用MSP,
; 这时LR的bit2=0;
; 第一次之后,再运行到这儿来时,LR的bit2永远为1,
; 所以第一次后此行代码是多此一举,然而没有第一次,
; 哪来后面的N次?(YD!)
CPSIE I ; 开中断,这句指令执行完,可能会马上来了其它中断,
; 这个中断会被嵌套到套子里,不过到此时就不怕了,
; 因为对于切换工作已经万事俱备了。
BX LR ; 往PC里写值为EXC_RETURN的特殊LR值是CM3的中断返回命令;
; 在中断返回序列中,xPSR, PC, LR, R12, R3, R2, R1, R0
; 会从PSP指向的栈中被POP出,
; 这个暗箱操作之后,PSP += 32,然后PC跳转到新任务上次被
; 切换的地方(若是任务第一次运行,则是任务函数开始处)开始运行,
; 至此任务切换圆满完成。
END
08 任务切换在什么时候发生?
触发任务切换的代码只有以下三行:
LDR R0, =NVIC_INT_CTRL ; Trigger the PendSV exception (causes context switch)
LDR R1, =NVIC_PENDSVSET
STR R1, [R0]
使用此三行汇编代码的地方有三处,一是OSStartHighRdy,只在系统启动时调用一次;二是OSCtxSw,其在OS_Sched中调用;三是OSIntCtxSw,其在OSIntExit中调用。所以说任务切换除了第一次的系统启动,都是在OS_Sched中或OSIntExit中。
先说OS_Sched,凡是涉及到任务状态变换的,都要调用此函数,其中分四大类,一是任务自己主动出让型,二是开笼放虎型,三是干涉他国主权型,四是点了鞭炮炸到谁谁倒霉型,详见如下:
第一类:
任务自己想等待的操作:OSTaskCreate,OSFlagPend,OSMboxPend,OSMutexPend,OSQPend,OSSemPend;
自我延时的操作:OSTimeDly,OSTimeDlyHMSM,OSTaskSuspend(唯挂起自己时才OS_Sched);
第二类:
通知其它任务的操作:OSFlagPost,OSMboxPost,OSMutexPost,OSQPost,OSSemPost;
调度器解锁操作:OSSchedUnlock;
第三类:
能够改变其他任务状态的操作:OSTaskChangePrio,OSTaskDel,OSTaskResume;
第四类:
删除通信途径的操作:OSFlagDel,OSMboxDel,OSMutexDel,OSQDel,OSSemDel;
再说OSIntExit,它只在中断服务函数退出时调用,但并非任何中断退出时都调用,必须是最外一层的中断退出时才调用,嵌套到深层的中断退出时,并不做任务切换,这个机制由中断嵌套跟踪标识OSIntNesting来控制。这其中有一个很特别的中断,那就是SysTick中断,与自由散漫的其他中断不一样,它是周期性的中断,其周期就是系统的SysTick——OS_TICKS_PER_SEC(在os_cfg.h中设定)。因此系统即便没有其他情形的切换,也会有这个周期性的任务切换,因此可以说这个SysTick中断就是μC/OS-II的心跳,也只有这个任务切换不是由用户导致的,它是μC/OS-II自己的任务切换。
总而言之,任务切换无时不在,无处不在,但是第四类应该慎用。
09 任务被切后是个什么样子?
任务被切有两种情况:一是主动交出CPU控制权,二是被高优先级的任务抢占。
第一种情况,必然是主动清掉就绪表中的就绪位,然后调用OS_Sched函数(把自己的指挥棒——OSTCBCur交给μC/OS-II原语函数,主要是上文中的第一类操作)。因此任务被切后必然停在OS_Sched函数的结尾处,因为OS_Sched函数是被临界保护对子OS_ENTER_CRITICAL()和OS_EXIT_CRITICAL()保护起来的,在OS_EXIT_CRITICAL()之后,CPU就运行任务切换的PendSV中断服务函数了,因此这个被切的任务的现场就定格在那里,被入了栈,OS通过PendSVHandler这个时空隧道到了另一个任务的世界。当这个被切的任务再次上场的时候,必然从调用OS_Sched函数的下一行代码处开始重新运行。
第二种情况,就不可预知了,只要不是被临界对子保护的代码和调度器锁对子保护的代码,都有可能被切到,任务就地倒下,钻入地洞——现场入栈,然后等时来运转,再哪里跌倒哪里站起来。
10 关于就绪表中高效的调度算法
就绪表是任务调度中一个相当关键的部件。它的主要作用是记录哪个任务处于就绪态。因为就绪态是任务通向运行态的唯一路径,如同潼关之于关中,是兵家必争之地。因此各种原子性操作,诸如各种Pend,各种Post,各种Del,各种Dly,还有Suspend、Create等等,都要读取或者更改就绪表。可以说,整个μC/OS-II就是围绕就绪表这个中心运转的。因此关于就绪表之操作的效率要求是苛刻的。
在解说就绪表之前,先铺垫一个前提,就是μC/OS-II中,任务与优先级是一一对应的,不存在两个任务共用一个优先级,也可以说优先级就是任务的ID。其中在V252中任务的最大个数是64,在V286中任务的最大个数是256,下文所述仅以V252为例解说。而且优先级数越小表示优先级越高,即:prio0 > prio1 > prio2 > ...... > prio62 > prio 63.
所谓就绪表,很简单,就是一个8bit全局变量(OSRdyGrp)和一个最大长度为8的8bit全局数组(OSRdyTbl[8])。这个OSRdyTbl中的每一个bit对应一个任务,对应规则如下:OSRdyTbl[0]的bit0对应优先级为0的任务,bit1对应优先级为1的任务,...,bit7对应优先级为7的任务,OSRdyTbl[1]的bit0对应优先级为8的任务,...,bit7对应优先级为15的任务,以此类推...。把上面OSRdyTbl的每一位的对应关系在纸上画出来,就构成了一个8*8的二维优先级表:
X <--------|
Y
行列 | b7 | b6 | b5 | b4 | b3 | b2 | b1 | b0 |
数组0 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
数组1 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 |
数组2 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 |
数组3 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 |
数组4 | 39 | 38 | 37 | 36 | 35 | 34 | 33 | 32 |
数组5 | 47 | 46 | 45 | 44 | 43 | 42 | 41 | 40 |
数组6 | 55 | 54 | 53 | 52 | 51 | 50 | 49 | 48 |
数组7 | 63 | 62 | 61 | 60 | 59 | 58 | 57 | 56 |
此二维表的使用规则是:若该任务处于就绪态,则其优先级对应的bit位的值就是1,否则就是0.
然后用OSRdyGrp来标识OSRdyTbl数组的下标,即OSRdyGrp中的每一位对应OSRdyTbl中的每一个数组元素,bit0对应数组OSRdyTbl[0],bit1对应OSRdyTbl[1],...,bit7对应OSRdyTbl[7]。该OSRdyGrp的使用规则是:若OSRdyGrp的第x位对应的OSRdyTbl[x] > 0,则位x的值置1,否则置0.也就是说位x对应的一组任务中,有一个以上的任务处于就绪态,则位x置1,若其对应的该组任务全部处于非就绪态,则置0.
综上,可以说,OSRdyGrp中的bit标识了一个优先级的行数,也就是二维表中的y值,OSRdyTbl中的bit标识了一个优先级的列数,也就是二维表中的x值。有了x值和y值,便能唯一标识出一个优先级。比如(4,3)标识的优先级是27,(5,6)标识的优先级是53.不仅从图中直观上是如此,数学上还存在这样便于程序实现的关系:若用prio表示优先级数,则 y = prio / 8 (等价于取prio的高三位: y = prio >> 3), x = prio % 8 (等价于取prio的低三位: x = prio & 0x07)。由以上两式可得出通过 x,y 求 prio 的数学式: prio = y*8 + x (等价于prio的高三位和低三位的合成: prio = y<<3 + x)。这三个数学式是对就绪表操作的根本原理。
然后是对就绪表的操作,有三个:1、使任务进入就绪态;2、使任务脱离就绪态;3、从就绪表中求得最高优先级任务的优先级。在这儿,体现效率的算法出现了,这儿的算法思想是用空间换时间,又引入了两张静态表:OSMapTbl[8] 和 OSUnMapTbl[256]。其中OSMapTbl用于操作1和操作2,OSUnMapTbl用于操作3.具体如下:
INT8U const OSMapTbl[] = {0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}; // 该表的作用是用来对一个8bit变量的某位置1或置0,举例说明:设 a 为被操作的变量,对其bit2置1,具体C程序就是 a |= OSMapTbl[2];,对其bit2置0,具体C程序就是 a &= ~OSMapTbl[2];。其实这张表对提高效率不是很明显。
INT8U const OSUnMapTbl[] = {
0, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x00 to 0x0F */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x10 to 0x1F */
5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x20 to 0x2F */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x30 to 0x3F */
6, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x40 to 0x4F */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x50 to 0x5F */
5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x60 to 0x6F */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x70 to 0x7F */
7, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x80 to 0x8F */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0x90 to 0x9F */
5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0xA0 to 0xAF */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0xB0 to 0xBF */
6, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0xC0 to 0xCF */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0xD0 to 0xDF */
5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, /* 0xE0 to 0xEF */
4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0 /* 0xF0 to 0xFF */
}; // 这张表的作用是用来求一个unsigned char型数其二进制码中最右边的1在第几位,举例说明:求22的二进制码中最右边的1在第几位?22的二进制码是10110,一眼可看出,该问题的答案是bit1,这个一眼可看出的C程序表达就是 OSUnMapTbl[22]。
有了这些基础,下面给出这三个操作的具体实现:
其中铺垫说明下文中用到的一些公共变量的意义:
ptcb->OSTCBY = prio >> 3; // 求优先级数在二维表中所在的列数,即y坐标值
ptcb->OSTCBBitY = OSMapTbl[ptcb->OSTCBY]; // 求y坐标值在一个字节中的位置
ptcb->OSTCBX = prio & 0x07; // 求优先级数在二维表中所在的行数,即x坐标值
ptcb->OSTCBBitX = OSMapTbl[ptcb->OSTCBX]; // 求x坐标值在一个字节中的位置
1、使任务进入就绪态:
OSRdyGrp |= ptcb->OSTCBBitY; // 标记行数,y坐标置1
OSRdyTbl[ptcb->OSTCBY] |= ptcb->OSTCBBitX; // 标记列数,x坐标置1
上段C程序等价于:
OSRdyGrp |= OSMapTbl[prio >> 3];
OSRdyTbl[prio >> 3] |= OSMapTbl[prio & 0x07];
可见使任务进入就绪态的操作实质就是标记出该任务优先级数prio在二维表中的x坐标和y坐标。
2、使任务脱离就绪态:
if ((OSRdyTbl[OSTCBCur->OSTCBY] &= ~OSTCBCur->OSTCBBitX) == 0x00) {
OSRdyGrp &= ~OSTCBCur->OSTCBBitY;
}
等价于:
OSRdyTbl[prio >> 3] &= OSMapTbl[prio & 0x07]; // 去掉 x 坐标标记
if (0 == OSRdyTbl[prio >> 3]) OSRdyGrp &= OSMapTbl[prio >> 3]; // 去掉 y 坐标标记
可见使任务脱离就绪态的操作实质就是去掉该任务优先级数prio在二维表中x坐标标记或y坐标标记。
3、从就绪表中求得最高优先级任务的优先级:
y = OSUnMapTbl[OSRdyGrp]; // 求二维表中最上面的行数,即求 y
OSPrioHighRdy = (INT8U)((y << 3) + OSUnMapTbl[OSRdyTbl[y]]); // 求二维表中最上面的行中
//最右边1,即求 x
等价于:
y = OSUnMapTbl[OSRdyGrp];
x = OSUnMapTbl[y];
OSPrioHighRdy = y << 3 + x;
求得OSPrioHighRdy后,再通过OSPrioHighRdy求得OSTCBHighRdy,这里还要引出μC/OS-II中又一个花费全局空间的数组:OS_TCB *OSTCBPrioTbl[OS_LOWEST_PRIO + 1],这是一个TCB的指针数组。可以说OSPrioHighRdy也是任务上台的一个台阶,而且是第一个台阶,前面所述的OSTCBHighRdy是第二个台阶。
分析此段代码,翻译成人类的语言就是:所谓从就绪表中找出最高优先级的任务,就是在就绪表的二维图中,找出坐标值最小的1(其中y坐标优先于x坐标),然后根据坐标值xy合成优先级prio。操作3这段代码被用在了μC/OS-II中的调度器中(OS_Sched和OSIntExit),能够看出,在这儿,调度器的时间复杂度是O(2),任务再多也是这个常数。假如不用空间换时间的算法,就需要循环查找OSRdyTbl以求最高优先级,这样明显就降低效率了。前面所做的一切都是为了此处的用途——提高切换效率,可谓养兵千日用兵一时。
补充,在V286版本中,最大优先级个数是256,其基本原理同V252一样,不同的是OSRdyGrp的类型是unsigned short,OSRdyTbl[16]的类型是unsigned short,然而OSUnMapTbl还是一样的,去掉了OSMapTbl。对于求就绪表中的最高优先级,仍然用同样的OSUnMapTbl,是先把OSRdyGrp和OSRdyTbl一劈两半,然后再合成的。另一方面,实际中OSRdyTbl[N]数组的长度往往不是最大值(64或256),而是根据实际应用中的任务数来人工配置的,内核中此数组的定义如下:类型定义 OSRdyTbl[OS_RDY_TBL_SIZE]; #define OS_RDY_TBL_SIZE ((OS_LOWEST_PRIO) / 8(V286中是16) + 1) ,通过配置OS_LOWEST_PRIO来实现。这是为了节省不必要的空间,因为设定了任务数,就有与之相关的一系列的全局空间的开销。上面只是为了理解方便而做如此说,否则二维数组就不太美观了,呵呵。
11 系统启动时运行的第一个任务,第一步是怎么运行的?
STM32F107中,系统在bootload后,运行到main函数,这时是线程模式,用的是主堆栈。在main函数中,先调用OSInit函数初始化μC/OS-II,然后至少创建一个任务,(这时系统中至少有两个任务,因为μC/OS-II在OSInit中至少创建了空闲任务OS_TaskIdle),这时系统时钟SysTick尚未初始化,然后调用OSStart函数。
OSStart中找出优先级最高的任务,取得其TCB,把该TCB的指针赋值给全局变量OSTCBHighRdy,同时赋值给全局变量OSTCBCur(OSTCBCur = OSTCBHighRdy;)。然后调用汇编函数OSStartHighRdy,此函数只在μC/OS-II系统启动时调用,在汇编函数OSStartHighRdy中初始化PendSV中断的优先级为最低优先级0xFF,并使CPU产生一个PendSV中断。然后在PendSV中断服务函数OS_CPU_PendSVHandler中,判断出是第一个任务运行,所以不做保存现场的操作,直接把OSTCBHighRdy指向的任务栈的前面几个变量弹出到当前CPU现场中。退出PendSV中断后系统就跳转到第一个任务的任务函数中。总之,第一个任务的第一步也是在任务切换中诞生的,而且从此之后μC/OS-II再也不会返回main函数了。PendSV中断服务函数好似时空穿梭隧道,各个任务通过它来回穿梭,永不停息。
每个任务的第一次运行都是一样的:任务在TaskCreate中,在OSTaskStkInit中,任务栈的前面16个字依次初始化为xPSR,任务函数的地址,R14,R12,R3,R2,R1,R0,R11,R10,R9,R8,R7,R6,R5,R4。然后TaskCreate把任务栈中的最后一个元素,即初始化为R4的栈元素地址保存到TCB中的第一个成员OSTCBStkPtr中,这就是任务栈的栈顶。然后TaskCreate把这个任务放入就绪表,只要时机到来,这个任务就会被推上CPU。这个人为的初始化是对CPU的一个欺骗,这个初始化的结果是造成这样一个假象:好像任务运行过,中途被中断了,保存了现场,就是以上被初始化的寄存器们。这样当任务被切换到运行时,在做恢复现场时,就把这个初始化的现场恢复到CPU中,于是任务开始第一次运行,而在CPU看来,好像第N次运行了。
12 空闲任务 OS_TaskIdle 在什么时候运行?
空闲任务OS_TaskIdle,是μC/OS-II预设的任务,它在OSInit函数中创建,优先级最低。空闲任务的功能是,CPU一空闲了,就会运行这个任务。这个任务可用来处理一些实时性要求不高的工作,诸如计算CPU使用率,更新一些标志灯之类。
假如系统启动时,没有创建其它任务(虽然这样做是不允许的而且是毫无意义的,在此只是为了讨论问题的方便而做的特殊情况),也就是说只创建了空闲任务(空闲任务是必须创建的,其创建位置在OSInit中,是μC/OS-II的初始化步骤之一),而且任务任务在创建时都会在TCB初始化结束时放入就绪表,因此空闲任务会在OSStart之后开始运行,此时时钟节拍中断向量OS_CPU_SysTickHandler未初始化,因此系统一直处在OS_TaskIdle中的死循环中。即便在OSStart之前初始化了时钟节拍中断向量,在时钟节拍中断服务中也不会对OS_TaskIdle这个任务做什么。在时钟节拍中断退出时,会做一次任务调度,这时因为系统只有一个任务,因此OSPrioHighRdy != OSPrioCur条件不满足,所以也不会做切换工作。因此系统还是会回到空闲任务中继续运行。
假如系统有两个以上任务在运行,因为空闲任务的优先级是底限,其它任务的优先级必然高于空闲任务。因此,当其它任务都处于等待状态时,调度器(OS_Sched和OSIntExit)从就绪表中计算出此时最高优先级的任务会是空闲任务,因此做任务切换,运行空闲任务,直到它被其它任务抢占了运行权利。也就是说,空闲任务就是一个死乞白赖的乞丐,他缺乏谦让之德,从来没打算主动出让CPU,然而他缺德的报应就是只能吃别人剩下的,任何任务都可以过来欺负它一下,抢占它的饭碗。空闲任务必须不主动出让CPU(它不可以调用Pend,Suspend,TimeDly之类的函数,也就是说空闲任务在就绪表中的位一直是1),否则系统就可能会产生一种失控状态——所有任务都处在等待状态,这时调度器就会出错(导致0优先级的任务(不管其存在与否)会被逼上前线,假如有0优先级的任务,这时会打乱该任务的等待状态逻辑,假如无0优先级的任务,则系统崩溃),这也就是空闲任务存在的巨大意义。看来在操作系统的世界里,也离不了下贱的存在啊,至贱则无敌,诚哉斯言!
13 μC/OS-II中的中断同单片机程序的中断有什么异同?
不同之处仅是μC/OS-II中的中断服务函数必须用对子OSIntEnter()、OSIntExit()括起来。这个对子的主要作用有两个:一是对中断的嵌套层数进行跟踪,以防止在中断未处理完的情况下做任务切换;二是当中断中调用了Post类的操作时,在退出中断后的任务切换能立即把Post类消息通知到被Post者,增强μC/OS-II的实时性。第二种作用是一个必须的作用,否则的话,必须等到以后的SysTick中断结束才能真正的传递Post类消息。假若在这个间隙里,正好遇到当下的那个任务进入了临界代码区,或者给调度器上了锁,这样就更会延长那个Post类消息的实时性了。假若这个消息的实时性要求很高,那么这个结果就会造成软件的偶发性bug了,这种小概率情况可能会造成一种很难debug的bug。
其实这个对子无论是栈空间上还是时间上都占用了中断服务函数一笔不小的开销,试问对于未调用Post类操作的中断服务函数是否可以不用这个对子括起来呢?这个思考题留待以后验证。
14 什么是函数的可重入性?其意义何在?
函数的可重入性只在非单任务系统中才有意义,包括多任务操作系统,和有中断的单片机系统。除了C语言上机实验课,实际中,几乎不存在单任务系统,因此函数的可重入性需求是广泛存在的。函数的可重入性概念,我是这样定义的:这个函数无论任何时刻无论被谁调用,都不会导致调用者出现不可预料的结果,一个函数具备这样的性质,则这个函数具有可重入性。实现一个可重入的函数可以通过这样的措施:在函数中不使用全局变量,这样的函数肯定是可重入的,因为它的活动范围只陷于调用者的现场或者栈空间,所以肯定不会影响到其它调用者;但是有些函数必须要用到全局变量,怎么办?有三个办法,宗旨就是把访问全局变量的代码保护起来:一是用关中断的临界对子保护,这时在访问全局变量时系统是一个单任务系统模式;二是用调度器上锁对子保护;三是用互斥信号量保护。其中,第一种方法最可靠,但代价也最高,会损失中断响应时间;后两者不能保证这个全局变量被中断服务函数访问时的可重入性。因此,μC/OS-II的系统函数全是通过第一种方法来实现可重入性的,满地都是的临界对子。因此可见,对于小的系统来说,能用单片机实现就不要用操作系统,因为用了操作系统,虽然编程方便,但付出的代价是中断响应延迟了。在操作系统之上的应用层中的函数,其可重入性,不能教条主义,要量力而行,视具体情况而定。假如要访问的全局变量不会被中断函数访问,而且对全局变量的访问时间很长,就不要用第一种方法保护;假如对全局变量的访问时间很短,用第三种方法反而浪费调度时间;假如要访问的全局变量操作不是很紧急,就要用第三种而不是第二种方法来保护;假如要访问的全局变量不会被其它任务用到,就不用保护了,如同用函数内的局部变量一样安心用就行了。不过第三种情况,就没必要定义成全局变量了,可以定义成static的局部变量。全局变量,之所以定义了,还是有通信的作用。还有一种对全局变量的访问情况,就是这一边任务只是读它,而且不要求读最新值,另一边只有一个任务写它,这种情况就不需要做保护了。还有就是全局变量只是用来读,不用来写,也不需要做保护,对函数的调用,就是这种情况,函数名都是全局的,但没见过访问函数还要做保护的,因为函数名从来不用写,而且也不允许写。
15 同步信号量的内部机理是什么?
同步信号量SEMAPHORE,其用于通信的主要操作是OSSemPend、OSSemAccept和OSSemPost,此外还有不太常用的解剖信号量操作OSSemQuery,除此外还有外围的OSSemCreate,OSSemDel。
SEMAPHORE,中文意思是旗语、信号灯的意思,这种信号量对应Vxworks中的信号量是计数信号量,这种信号量据说主要用于管理缓冲池。
SEMAPHORE信号量好比一盏信号灯,灯亮放行,灯灭停车(OSSemPend),或者闯红灯(OSSemAccept),灯从灭变为亮,又会放行。OSSemPost用于点灯,OSSemPend和OSSemAccept用于灭灯。点灯和灭灯,这里有个亮度问题,点一次解除一个等待任务,无等待任务了才能点亮,之后点一次亮度升一级,上限是0xFFFF,灭一次亮度减一级,底限是灯灭(0x00)。V252中,感觉这个亮度的解决办法有问题,上限应该是一个设定值,而不应该是OSEventCnt溢出值。
欲知这个过程的内在机理,需先了解SEMAPHORE的实体——事件控制块ECB(EVENT CONTROL BLOCK)。所谓事件这个概念,在μC/OS-II中指同步信号量、互斥信号量、邮箱、消息队列这四类通信方式,ECB是它们的共有载体类型。同TCB一样,ECB也是一个全局变量,在μC/OS-II中的组织形式也是一个链表化了的数组:OS_EVENT OSEventTbl[OS_MAX_EVENTS],每个数组元素是一个ECB。它与事件一一对应,不管事件存在与否,ECB这个空间一直存在,只不过是个空的ECB而已。这个ECB数组由μC/OS-II负责管理,通过事件的Create函数分配给具体事件,通过事件的Del函数收回到全局指针变量OSEventFreeList所串的链表中。ECB在Create时会返回它的一个指针,对于使用该ECB的任务来说,这就是该事件的句柄。这个指针需要保存到一个程序猿定义的全局变量中,对该全局变量的访问,不存在可重入的问题,因为对它的操作只是读取。ECB的具体定义见下面的代码(为了便于说明,代码行做了调整)。这个结构中的核心是第二个成员——OSEventCnt,它是信号灯的实体,当OSEventCnt=0表示灯灭,>0表示灯亮,大于0多少就是亮度是多少级。还有一个重要的部分,请看最后两个成员,它们是一张就绪表,在此不叫就绪表,而叫等待表,μC/OS-II就是通过这张等待表把事件Post给等待该信号量的任务的。对等待表的操作与就绪表无异,而且一个任务要么处在等待表,要么处在就绪表,要么两者都不处,是鱼和熊掌可兼失而不可兼得的。由此也能看出,μC/OS-II的事件唤醒机制是抢占式的,在V252中暂不支持FIFO方式,V286中也尚未支持。
/*
*********************************************************************************************************
* EVENT CONTROL BLOCK
*********************************************************************************************************
*/
typedef struct {
INT8U OSEventType; /* Type of event control block (see OS_EVENT_TYPE_???) */
INT16U OSEventCnt; /* Semaphore Count (not used if other EVENT type) */
void *OSEventPtr; /* Pointer to message or queue structure */
INT8U OSEventGrp; /* Group corresponding to tasks waiting for event to occur */
INT8U OSEventTbl[OS_EVENT_TBL_SIZE]; /* List of tasks waiting for event to occur */
} OS_EVENT;
四类事件对于事件控制块的操作共有四个,代码在os_core.c中,分别为:
OS_EventTaskRdy 功能:放行操作,由Post类操作调用,把任务从等待表搬到就绪表,
清除TCB中的OSTCBEventPtr关联,清零超时器OSTCBDly,
传递msg,置TCB中的OSTCBStat为就绪态。
OS_EventTaskWait 功能:停车操作,由Pend类操作调用,把任务从就绪表搬到等待表,
并把该ECB的指针存到对应的TCB中的OSTCBEventPtr成员,
该成员在OSTaskChangePrio和OSTaskDel中会用到。
OS_EventTO 功能:由Pend类操作调用,TO是Time Over的意思,
任务在等待过程中超时,在此清掉等待表,
因为这时任务已经被TickHandler放入就绪表了。
OS_EventWaitListInit功能:修路操作,由Create类操作调用,初始化等待表,
就是把OSEventGrp和OSEventTbl[OS_EVENT_TBL_SIZE]置零。
SEMAPHORE通信原理:灭灯停车操作OSSemPend,先判断OSEventCnt的值,若>0则减1放行,若=0则置TCB中的OSTCBStat标识为OS_STAT_SEM,同时把超时Time写入TCB中的OSTCBDly,然后是停车操作OS_EventTaskWait。若OSTCBDly写入0则表示无限等待,这个机理查看一下TickHandler可知,TickHandler遍历服役的TCB们,先判断OSTCBDly,若为0,直接不理睬,保持原状态。若不为0,才做OSTCBDly--操作,减到0时,条件合适则放入就绪表,不合适则继续给OSTCBDly赋值为1,因此上面的过程会循环重复。闯红灯操作OSSemAccept,不管OSEventCnt怎样都放行,只是>0时,OSEventCnt--。放行点灯操作OSSemPost,先查看路上是否有任务排队,若有则OS_EventTaskRdy,调度,然后返回,若无任务排队,则OSEventCnt加1,感觉这儿有问题,应该有一个设定的上限,否则失去实际意义。
SEMAPHORE总结:其实,SEMAPHORE只不过就是一个全局变量而已。对比单片机程序,一个主循环和一个中断的模型,设置了一个全局变量gFlag(volatile int gFlag = 初始值;),如下操作就是OSSemPend:
关中断;
if (gFlag > 0){
gFlag--;
开中断;
放行操作;
} else {
开中断;
停车操作;
}
如下操作就是OSSemAccept:
关中断;
if (gFlag > 0) gFlag--;
开中断;
放行操作;
如下操作就是OSSemPost:
关中断;
gFlag++;
开中断;
区别就是,停车操作时单片机程序需要做转一圈再回来的无用功,而μC/OS-II中则把任务定格在那儿,把CPU交给了其它任务,若无其它任务,μC/OS-II进入空闲函数,这时一样的也是做无用功。简单模型下能看出SEMAPHORE就是小题大做,然而对于复杂模型,单片机系统中有众多中断,主循环有众多if gFlag then gFlag--; ... else ...分支时,就能明显看出SEMAPHORE的优势了。的优势体现在哪里?不只是提高了CPU的利用率,关键是简化了软件的设计,通过任务和任务通信机制把复杂问题模块化并分而治之了。
16 互斥信号量的内部机理是什么?
互斥信号量MUTUAL,区别于SEMAPHORE的是MUTUAL只有两个状态,可用与不可用。
在MUTUAL这儿有一个优先级翻转问题。这个只存在于可剥夺型OS内核的问题曾波及到一起著名的航天事件:1997年,架构于著名实时系统Vxworks上的“探路者”号探测器在火星登陆,开始几天,探路者工作稳定,几天之后却出现系统复位和数据丢失现象。经过WindRiver的研究人员在实验室不断模拟火星探路者的工作情况,发现了故障的原因,原因如下:火星探路者系统上有如下两个任务需要互斥访问共享资源——信息总线:T1,总线管理任务,具有最高优先级,运行频繁,通过互斥信号量M进行总线数据I/O。T6,数据收集任务,低优先级,运行少,收集数据,并通过互斥信号量M将数据发布到信息总线。当T6持有信号量M时,T1就绪,则抢占T6运行,直到申请M时被阻塞,T6接着运行,然而这时出现了一个运行时间较长的T3,它的优先级比T6高比T1低,T3剥夺了T6的运行,于是乎高优先级的T1也被迫陪同T1一起等待。这时,看门狗观测到总线长时间没有活动,将其解释为严重错误,并使系统复位。这个就是优先级翻转问题。所谓翻转就是指,在等待互斥信号量的特殊情况下,T1优先级反而比T3优先级低了。
其实优先级翻转问题不只是在互斥型信号量中存在,在其它类型的信号量中也存在,只要是抢占式的硬实时OS,就存在这个问题。之所以在互斥性信号量这儿顾及到了这个问题,只是因为优先级翻转在互斥性信号量这儿更加明显和便于解决,在其它种信号量模式下不便于解决。
解决优先级翻转问题有两种思路,第一种叫做优先级继承,就是当高优先级在申请互斥信号量被阻塞时,则把持有该信号量的任务的优先级升高至自己的优先级;第二种叫做优先级上限,就是当高优先级在申请互斥信号量被阻塞时,则把持有该信号量的低优先级任务的优先级升高至预设的一个上限优先级。μC/OS-II中采用的是第二个办法。上限优先级是不会被分配给其它任务的优先级,同时也是比使用该MUTUAL的任务们优先级最高者稍高的一个优先级。上限优先级由程序猿设定,在创建MUTUAL时,作为参数传递给创建函数,然后记录在ECB中的OSEventCnt的高字节中。然后任务申请MUTUAL成功后,会把它的优先级记录在OSEventCnt的低字节中,同时把占有者的TCB记录在ECB中的OSEventPtr中。当任务在申请MUTUAL时(OSMutexPend),若判断出MUTUAL已经被其它任务占有,则先通过ECB中的OSEventPtr得到占有者任务,再判断占有者的任务优先级是不是提升到了上限优先级,若否则判断占有者的任务优先级是不是比自己低,若低的话,则进行提升占有者优先级的操作。当释放MUTUAL时(OSMutexPost),先判断自己的优先级是不是等同于上限优先级,若等同则还原回本来面目,这时就用到先前存到OSEventCnt的低字节中的自己的实际优先级了。
其余操作,放行、停车等待、TimeOver,同SEMAPHORE信号量一样,都是事件的公有属性。
17 邮箱的内部机理是什么?
μC/OS-II中的邮箱的作用是传递一个指针值,该指针值存放在OSEventPtr中。通过OSEventPtr的值是否为0来判断所谓的邮箱中是否有信件。通过OSEventPtr这个指针来传递消息,这完全就是单片机模式下通过全局变量通信的方式。假如这个OSEventPtr指向的是全局内存,μC/OS-II只是负责了一个任务同步的问题,而且是通过关中断这种巨大代价来实现同步的;假如OSEventPtr指向的是栈内存,经试验验证,在接收者任务优先级低于传递者任务优先级时,传递会出错。邮箱的用途,据μC/OS-II的作者说,可以用于替代二值信号量和替代OSTimeDly.
18 消息队列的内部机理是什么?
消息队列的内在机理,就是将数个邮箱整合到一个队列中,为此开辟了一段全局单元:OS_Q OSQTbl[OS_MAX_QS];用它来管理消息指针们。而消息指针队列,如同任务用的栈一样,需要程序猿在全局区定义,然后在消息队列Create时把起始地址和大小作为参数传递过去。一个消息指针队列对应OSQTbl中一个OS_Q型元素(定义见下),它与ECB的连接通过ECB中的OSEventPtr,在其中存放了指向OS_Q的指针。然后在实际使用中,把要传递的数据(变量或者数组地址)作为参数送给OSQPost,OSQPend会按照FIFO的方式得到这个地址,通过引用这个地址来使用消息。同邮箱一样,传递栈内存中的消息时,也会出错。消息队列的用途,据μC/OS-II的作者说,可以用于替代SEMAPHORE信号量.
typedef struct os_q { /* QUEUE CONTROL BLOCK */
struct os_q *OSQPtr; /* Link to next queue control block in list of free blocks */
void **OSQStart; /* Pointer to start of queue data */
void **OSQEnd; /* Pointer to end of queue data */
void **OSQIn; /* Pointer to where next message will be inserted in the Q */
void **OSQOut; /* Pointer to where next message will be extracted from the Q */
INT16U OSQSize; /* Size of queue (maximum number of entries) */
INT16U OSQEntries; /* Current number of entries in the queue */
} OS_Q;
19 标志组的内部机理是什么?
μC/OS-II中的标志组就是OS中的信号量集的实践,其实现方法不同于前面四种事件管理机制,它有其专用的结构:OS_FLAG_GRP,其管理思想主旨是:标准组不再使用等待表,而是由每一个等待任务在自己的栈空间里分配一个OS_FLAG_NODE的变量,在其中记录该任务需要的信号量集,即标志组,该变量被连接到OS_FLAG_GRP中的OSFlagWaitList上,同时该变量也链接到对应任务的TCB上,然后若有其它任务也在等待这个标志组,则该任务在它栈空间上产生的OS_FLAG_NODE变量会被链接到前面一个OS_FLAG_NODE变量上。当Post标志组时,首先更新OS_FLAG_GRP中的OSFlagFlags,然后如同TickHandler中的操作一样,从头遍历链表中的OS_FLAG_NODE,若Flag匹配,则删除这个OS_FLAG_NODE,置调度标志为TRUE,然后在遍历结束的时候,启动一次调度。从这儿明显能看出,用标志组机制,无论从时间上还是空间上,开销都要比前面几种通信机制大。前面事件通信机制都能实现时间复杂度为O(2)的通信速率,而标志组通信,最坏情况下的时间复杂度是O(N),N=等待标志组的任务数。
typedef struct { /* Event Flag Group */
INT8U OSFlagType; /* Should be set to OS_EVENT_TYPE_FLAG */
void *OSFlagWaitList; /* Pointer to first NODE of task waiting on event flag */
OS_FLAGS OSFlagFlags; /* 8, 16 or 32 bit flags */
} OS_FLAG_GRP;
typedef struct { /* Event Flag Wait List Node */
void *OSFlagNodeNext; /* Pointer to next NODE in wait list */
void *OSFlagNodePrev; /* Pointer to previous NODE in wait list */
void *OSFlagNodeTCB; /* Pointer to TCB of waiting task */
void *OSFlagNodeFlagGrp; /* Pointer to Event Flag Group */
OS_FLAGS OSFlagNodeFlags; /* Event flag to wait on */
INT8U OSFlagNodeWaitType; /* Type of wait: */
} OS_FLAG_NODE;
20 μC/OS-II的内存管理机制是什么?
μC/OS-II的内存管理机制非常粗糙,仅是对程序猿定义的一个全局数组进行分块,然后把各个小块用链表链接起来,使用时通过系统函数OSMemGet申请,使用完通过系统函数OSMemPut释放。对比C标准库中的malloc和free,这种管理机制据μC/OS-II作者说可以减小内存碎片。然而这个机制一个明显不如malloc的地方是,使用时不能指定内存块的大小。而且,经我在STM32F107上进行裸机验证,频繁的调用malloc和free,并未因产生内存碎片而malloc失败。而且,通过阅读μC/OS-II内存管理的源代码,根本就没看出,μC/OS-II的机制能够减少内存碎片。以此看来,μC/OS-II的内存管理机制实在是不怎么高明。从另一方面来讲,μC/OS-II的应用目的本来就是嵌入式场所,而且一般都是不支持MMU的处理器上,这类场所在不用OS时就是裸机程序,因此也没必要搞复杂的内存管理,若实在需要更精细的内存管理,可以由用户自己添加一个专门管理内存的任务。
21 什么是堆?malloc和free是怎么运作的?
堆这个概念主要是区别于栈这种存在的,《程序员的自我修养》10.1节有如下描述:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc分配内存时,得到的内存来自堆里。堆通常存在于栈的下方,在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多。OS中针对堆有一个必不可少的堆分配算法,堆分配算法有:空闲链表,位图,对象池。
对于μC/OS-II这个OS,其中并未有这么复杂的东东。当然,任何系统,哪怕是裸机程序,堆都是不可或缺的。比如在STM32F107中,堆的定义在汇编写的启动文件(startup_stm32f10x_cl.s)中。在单片机程序中,堆的创建及维护是由一个C语言运行库来做的,其中malloc和free都是这个运行库的内容。malloc和free这两个函数在不同的平台下,其内部实现是不一样的,这两个函数对于程序猿来说就是堆内存驱动层的接口。从这个角度看,μC/OS-II就是一个单片机程序,它不负责堆的管理。OS与运行库的关系这个方面还有待探讨,本文尚无力说清此处。
据网上大虾的解释,malloc函数的实质体现在,它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。调用malloc函数时,它沿链表寻找一个大到足以满足用户请求所需要的内存块。然后,将该内存块一分为二(一块的大小与用户请求的大小相等,另一块的大小就是剩下的字节)。接下来,将分配给用户的那块内存传给用户,并将剩下的那块(如果有的话)返回到链表上。调用free函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。于是,malloc函数开始在空闲链上翻箱倒柜的检查各内存片段,对它们进行整理,将相邻的小空闲块合并成较大的内存块。但合并之后还是不够申请大小呢,怎么办?此时分配器会调用sbrk函数,向内核请求额外的堆存储器,分配器将额外的存储器转换为一个大的空闲块,然后将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
UNIX中free源代码如下:
struct men_control_block {
int is_available;
int size;
}
void free(void *ptr)
{
struct mem_control_block *free;
free = ptr - sizeof(struct mem_control_block);
free->is_available = 1;
}
22 操作系统到底是什么东东?
终于到最后了,把这个问题留到最后,本想概而言之的说说OS,然而真到这儿了,才发现这个概而言之的气概是轻狂的。即便μC/OS-II是简单的,但OS是复杂的。这期间,又重新翻阅了一下大学时的操作系统教科书,我们用的是汤子瀛西电版的,才发现我这费了九牛二虎之力阅读μC/OS-II源码所理解的仅是大学教科书的两章:第二章 进程管理,第三章 处理机调度与死锁,而且是部分内容,没有时间片轮转,不涉及多处理机调度,无关银行家算法......这才明白大学里的学业真不是那么简单啊!以教科书而论,μC/OS-II主要实现了进程管理,也可以说整个μC/OS-II系统就是一个进程,其中的任务是细分的线程。
前面在网上涉猎μC/OS-II移植经验,惊叹于一大虾对任务切换的譬喻:“如果有3个函数foo1(), foo2(), foo3()像是刚被中断,现场保存到栈里面去了,而中断返回时做点手脚(调度程序的作用),想回哪个回哪个,是不是就做了函数(任务)切换了?看到这里应该有点明白OSTaskStkInit()的作用了吧,它被任务创建函数调用,所以要在开始时,在栈中作出该任务好像刚被中断一样的假象。”《ucosii在stm32上的移植详解 –csdn lbl1234文》。对于μC/OS-II这么一个小巧玲珑的进程管理系统,非要概述一下的话,就是围绕单片机的软中断(CM3系列中是PendSV中断),通过一系列的全局变量,来协调函数运行,使函数的设计结构流畅,使函数的运行流程清晰,并在软件规模较大的情形下能提高MCU利用率的东东。说的不准确,就当个屁吧。
下面还是摘抄那令人痛苦的教科书对OS的定义,以正其名:OS是配置在计算机硬件上的第一层软件,是对硬件系统的首次扩充。它在计算机系统中占据了特别重要的地位;而其它的诸如汇编程序、编译程序、数据库管理系统等系统软件,以及大量的应用软件,都依赖于操作系统的支持,取得它的服务。OS有四个基本特征:并发、共享、虚拟和异步。其中,并发特征是OS最重要的特征,其它三个特征都是以并发为前提的。
以教科书的宣讲而论,阅读μC/OS-II还是有很大收获的,那就是入门了OS最重要的特征——并发性。在此要非常感谢μC/OS-II的作者Jean J.Labrosse大牛,他写出了这么成功的内核,而且注释的那么漂亮,并且分享了源码,还配套讲解的书。感谢Jean J.Labrosse先生为全球OS教育的贡献,至少让我受惠了。Jean J.Labrosse先生是一个高尚的人,一个纯粹的人,一个有道德的人,一个脱离了低级趣味的人,一个有益于人民的人。
在阅读μC/OS-II源码的过程中,才知道,OS并发性这个课题中出现的计算机界的诺贝尔奖——图灵奖得主——Dijkstra,引用教科书的话是:“1965年,荷兰学者Dijkstra提出的信号量(Semaphores)机制是一种卓有成效的进程同步工具。......最初由Dijkstra把整形信号量定义为一个整形量,除初始化外,仅能通过两个标准的原子操作(Atomic Operation)wait(S)和signal(S)来访问。很长时间以来,这两个操作一直被分别称为P、V操作。”通过阅读μC/OS-II源码,理解了历史上的信号量,虽然久远,但也是件值得祝贺的事吧。
从实用角度来讲,μC/OS-II V252已经过时了,现在μC/OS-II最新的版本的是V292,而且性能更强大的μC/OS-III也已经出现了。μC/OS-III的具体实现跟μC/OS-II已经有很大不同了,它更加适配于CM3内核,CM3也从嵌入式OS角度上做了很多硬件上的支持。然而OS的进程管理的思想和信号量的原理还是不变的,也不会变,因为这是OS这门学科的基本原理。
对于OS的问题,在此文中回答了22个,此文结束又产生了更多的关于OS的问题,罗列如下,期望后续的前进,继续解答。人生在世,只为求知。
宏内核的OS是什么样子?
虚拟内存是怎么实现的?
Linux系统内存分OS区和用户区,是怎么分开的?
C语言运行库是些什么内容?
多CPU核情况下的进程管理该是什么样子的?与单核的有什么异同?
分布式OS是基于怎样的机制?
较大的系统如Windows、Linux中的IO异步管理机制与驱动的关系是什么?
经典的UNIX系统都有些什么特点?
最近比较流行的Android和IOS都有些什么特点?
后记
我是一个电子信息科学与技术专业本科毕业7年之久的程序猿,大学虽不是名校但也还是个211,从进校算起,至今已逾10年多了。直到今天,我才略懂操作系统的进程管理,说来真是惭愧。在这而立之年,回想这10年的程序猿生涯,尽是无奈和彷徨,还有那被压抑的革命气息时不时的火山状喷发,到最后全部化为悔恨的泪水。大好青春,就这样稀里糊涂的挥霍掉了,至今一事无成。最近在网上偶遇周伟明大师的blog《程序员的十层楼》,对号入座了一下,惭愧的是我年龄这么大了,却只能居最低一层。面对生我养我的天地父母,我觉得我这个生命实在是白活了。最近又遇到CSDN上的一篇采访《专访雷果国:从1.5K到18K,一个程序员的5年成长之路》,更是被震撼的想做只老鼠赶紧找个地洞钻下去。雷牛是比我晚毕业2年的后辈,学校背景也不如我,开始学习编程更比我晚,人家现在已经是18K的价值,而我最近出去找了一番工作,却连12K的offer都拿不到,你说我活着还有什么意义?上学时,觉大学垃圾,虽挣扎了一番但最终还是退缩到自暴自弃的泥沼。毕业后好不容易找到了工作,接触到实际的软件行业,悔恨大学里没好好用功,骂大学老师只是照本宣科。而今,毕业7年了,社会大学的硕士学历都拿到了,我又在悔恨这毕业后的7年也荒废了,到如今只得了个一无所有的学位。时间过得好快,一晃10年过去了,如同发烧时睡觉做的那种连不起来的残缺的梦魇......
痛定思痛,我只想感谢古人,因为他说,亡羊补牢,为时未晚。庆幸的是我尚知羞耻。就让那80年代的彷徨与愤怒,90年代的自满与堕落,新世纪的急躁与消极,都飘过去吧。不要去选择那条绝望的路,真正的路,只在勇敢和坚持的脚踏实地上。
崎岖两边路,中行会有时;一万年太久,只争朝夕。