内容摘要:本文详细系统地介绍了uC/OS-II在51单片机上的移植、重入实现方法、硬件仿真、固化、人机界面等关键内容。
引言:随着各种应用电子系统的复杂化和系统实时性需求的提高,并伴随应用软件朝着系统化方向发展的加速,在16位/32位单片机中广泛使用了嵌入式实时操作系统。然而实际使用中却存在着大量8位单片机,从经济性考虑,对某些应用场合,在8位MCU上使用操作系统是可行的。从学习操作系统角度,uC/OS- II for 51即简单又全面,学习成本低廉,值得推广。
结语:μC/OS-II具有免费、简单、可靠性高、实时性好等优点,但也有缺乏便利开发环境等缺点,尤其不像商用嵌入式系统那样得到广泛使用和持续的研究更新。但开放性又使得开发人员可以自行裁减和添加所需的功能,在许多应用领域发挥着独特的作用。当然,是否在单片机系统中嵌入μC/OS-II应视所开发的项目而定,对于一些简单的、低成本的项目来说,就没必要使用嵌入式操作系统了。
uC/OS-II原理:
uCOSII 包括任务调度、时间管理、内存管理、资源管理(信号量、邮箱、消息队列)四大部分,没有文件系统、网络接口、输入输出界面。它的移植只与4个文件相关:汇编文件(OS_CPU_A.ASM)、处理器相关C文件(OS_CPU.H、OS_CPU_C.C)和配置文件(OS_CFG.H)。有64个优先级,系统占用8个,用户可创建56个任务,不支持时间片轮转。它的基本思路就是 “近似地每时每刻总是让优先级最高的就绪任务处于运行状态” 。为了保证这一点,它在调用系统API函数、中断结束、定时中断结束时总是执行调度算法。原作者通过事先计算好数据,简化了运算量,通过精心设计就绪表结构,使得延时可预知。任务的切换是通过模拟一次中断实现的。
uCOSII工作核心原理是:近似地让最高优先级的就绪任务处于运行状态。
操作系统将在下面情况中进行任务调度:调用API函数(用户主动调用),中断(系统占用的时间片中断OsTimeTick(),用户使用的中断)。
调度算法书上讲得很清楚,我主要讲一下整体思路。
(1) 在调用API函数时,有可能引起阻塞,如果系统API函数察觉到运行条件不满足,需要切换就调用OSSched()调度函数,这个过程是系统自动完成的,用户没有参与。OSSched()判断是否切换,如果需要切换,则此函数调用OS_TASK_SW()。这个函数模拟一次中断(在51里没有软中断,我用子程序调用模拟,效果相同),好象程序被中断打断了,其实是OS故意制造的假象,目的是为了任务切换。既然是中断,那么返回地址(即紧邻 OS_TASK_SW()的下一条汇编指令的PC地址)就被自动压入堆栈,接着在中断程序里保存CPU寄存器(PUSHALL)……。堆栈结构不是任意的,而是严格按照uCOSII规范处理。OS每次切换都会保存和恢复全部现场信息(POPALL),然后用RETI回到任务断点继续执行。这个断点就是 OSSched()函数里的紧邻OS_TASK_SW()的下一条汇编指令的PC地址。切换的整个过程就是,用户任务程序调用系统API函数,API调用 OSSched(),OSSched()调用软中断OS_TASK_SW()即OSCtxSw,返回地址(PC值)压栈,进入OSCtxSw中断处理子程序内部。反之,切换程序调用RETI返回紧邻OS_TASK_SW()的下一条汇编指令的PC地址,进而返回OSSched()下一句,再返回API下一句,即用户程序断点。因此,如果任务从运行到就绪再到运行,它是从调度前的断点处运行。
(2)中断会引发条件变化,在退出前必须进行任务调度。 uCOSII要求中断的堆栈结构符合规范,以便正确协调中断退出和任务切换。前面已经说到任务切换实际是模拟一次中断事件,而在真正的中断里省去了模拟 (本身就是中断嘛)。只要规定中断堆栈结构和uCOSII模拟的堆栈结构一样,就能保证在中断里进行正确的切换。任务切换发生在中断退出前,此时还没有返回中断断点。仔细观察中断程序和切换程序最后两句,它们是一模一样的,POPALL+RETI。即要么直接从中断程序退出,返回断点;要么先保存现场到 TCB,等到恢复现场时再从切换函数返回原来的中断断点(由于中断和切换函数遵循共同的堆栈结构,所以退出操作相同,效果也相同)。用户编写的中断子程序必须按照uCOSII规范书写。任务调度发生在中断退出前,是非常及时的,不会等到下一时间片才处理。OSIntCtxSw()函数对堆栈指针做了简单调整,以保证所有挂起任务的栈结构看起来是一样的。
(3)在uCOSII里,任务必须写成两种形式之一(《uCOSII中文版》p99页)。在有些 RTOS开发环境里没有要求显式调用OSTaskDel(),这是因为开发环境自动做了处理,实际原理都是一样的。uCOSII的开发依赖于编译器,目前没有专用开发环境,所以出现这些不便之处是可以理解的。
移植过程:
(1)拷贝书后附赠光盘sourcecode目录下的内容到C:\YY下,删除不必要的文件和EX1L.C,只剩下p187(《uCOSII》)上列出的文件。
(2)改写最简单的OS_CPU.H
数据类型的设定见C51.PDF第176页。注意BOOLEAN要定义成unsigned char 类型,因为bit类型为C51特有,不能用在结构体里。
EA=0关中断;EA=1开中断。这样定义即减少了程序行数,又避免了退出临界区后关中断造成的死机。
MCS-51堆栈从下往上增长(1=向下,0=向上),OS_STK_GROWTH定义为0
#define OS_TASK_SW() OSCtxSw() 因为MCS-51没有软中断指令,所以用程序调用代替。两者的堆栈格式相同,RETI指令复位中断系统,RET则没有。实践表明,对于MCS-51,用子程序调用入栈,用中断返回指令RETI出栈是没有问题的,反之中断入栈RET出栈则不行。总之,对于入栈,子程序调用与中断调用效果是一样的,可以混用。在没有中断发生的情况下复位中断系统也不会影响系统正常运行。详见《uC/OS-II》第八章193页第12行
(3)改写OS_CPU_C.C
我设计的堆栈结构如下图所示:
TCB结构体中OSTCBStkPtr总是指向用户堆栈最低地址,该地址空间内存放用户堆栈长度,其上空间存放系统堆栈映像,即:用户堆栈空间大小=系统堆栈空间大小+1。
SP总是先加1再存数据,因此,SP初始时指向系统堆栈起始地址(OSStack)减1处(OSStkStart)。很明显系统堆栈存储空间大小=SP-OSStkStart。
任务切换时,先保存当前任务堆栈内容。方法是:用SP-OSStkStart得出保存字节数,将其写入用户堆栈最低地址内,以用户堆栈最低地址为起址,以 OSStkStart为系统堆栈起址,由系统栈向用户栈拷贝数据,循环SP-OSStkStart次,每次拷贝前先将各自栈指针增1。
其次,恢复最高优先级任务系统堆栈。方法是:获得最高优先级任务用户堆栈最低地址,从中取出“长度”,以最高优先级任务用户堆栈最低地址为起址,以 OSStkStart为系统堆栈起址,由用户栈向系统栈拷贝数据,循环“长度”数值指示的次数,每次拷贝前先将各自栈指针增1。
用户堆栈初始化时从下向上依次保存:用户堆栈长度(15),PCL,PCH,PSW,ACC,B,DPL,DPH,R0,R1,R2,R3,R4,R5,R6,R7。不保存SP,任务切换时根据用户堆栈长度计算得出。
OSTaskStkInit函数总是返回用户栈最低地址。
操作系统tick时钟我使用了51单片机的T0定时器,它的初始化代码用C写在了本文件中。
最后还有几点必须注意的事项。本来原则上我们不用修改与处理器无关的代码,但是由于KEIL编译器的特殊性,这些代码仍要多处改动。因为KEIL缺省情况下编译的代码不可重入,而多任务系统要求并发操作导致重入,所以要在每个C函数及其声明后标注reentrant关键字。另外,“pdata”、 “data”在uCOS中用做一些函数的形参,但它同时又是KEIL的关键字,会导致编译错误,我通过把“pdata”改成“ppdata”, “data”改成“ddata”解决了此问题。OSTCBCur、OSTCBHighRdy、OSRunning、OSPrioCur、 OSPrioHighRdy这几个变量在汇编程序中用到了,为了使用Ri访问而不用DPTR,应该用KEIL扩展关键字IDATA将它们定义在内部RAM 中。
(4)重写OS_CPU_A.ASM
A51宏汇编的大致结构如下:
NAME 模块名 ;与文件名无关
;定义重定位段 必须按照C51格式定义,汇编遵守C51规范。段名格式为:?PR?函数名?模块名
;声明引用全局变量和外部子程序 注意关键字为“EXTRN”没有‘E’
全局变量名直接引用
无参数/无寄存器参数函数 FUNC
带寄存器参数函数 _FUNC
重入函数 _?FUNC
;分配堆栈空间
只关心大小,堆栈起点由keil决定,通过标号可以获得keil分配的SP起点。切莫自己分配堆栈起点,只要用DS通知KEIL预留堆栈空间即可。
?STACK 段名与STARTUP.A51中的段名相同,这意味着KEIL在LINK时将把两个同名段拼在一起,我预留了40H个字节,STARTUP.A51预留了 1个字节,LINK完成后堆栈段总长为41H。查看yy.m51知KEIL将堆栈起点定在21H,长度41H,处于内部RAM中。
;定义宏
宏名 MACRO 实体 ENDM
;子程序
OSStartHighRdy
OSCtxSw
OSIntCtxSw
OSTickISR
SerialISR
END ;声明汇编源文件结束
一般指针占3字节。+0类型+1高8位数据+2低8位数据 详见C51.PDF第178页
低位地址存高8位值,高位地址存低8位值。例如0x1234,基址+0:0x12 基址+1:0x34
(5)移植串口驱动程序
在此之前我写过基于中断的串口驱动程序,包括打印字节/字/长字/字符串,读串口,初始化串口/缓冲区。把它改成重入函数即可直接使用。
系统提供的显示函数是并发的,它不是直接显示到串口,而是先输出到显存,用户不必担心IO慢速操作影响程序运行。串口输入也采用了同样的技术,他使得用户在CPU忙于处理其他任务时照样可以盲打输入命令。
(6)编写测试程序Demo(YY.C)
Demo程序创建了3个任务A、B、C优先级分别为2、3、4,A每秒显示一次,B每3秒显示一次,C每6秒显示一次。从显示结果看,显示3个A后显示1个B,显示6个A和2个B后显示1个C,结果显然正确。
显示结果如下:
AAAAAA111111 is active
AAAAAA111111 is active
AAAAAA111111 is active
BBBBBB333333 is active
AAAAAA111111 is active
AAAAAA111111 is active
AAAAAA111111 is active
BBBBBB333333 is active
CCCCCC666666 is active
AAAAAA111111 is active
AAAAAA111111 is active
AAAAAA111111 is active
BBBBBB333333 is active
AAAAAA111111 is active
AAAAAA111111 is active
AAAAAA111111 is active
BBBBBB333333 is active
CCCCCC666666 is active
Demo程序经Keil701编译后,代码量为7-8K,可直接在KeilC51上仿真运行。
编译时要将OS_CPU_C.C、UCOS_II.C、OS_CPU_A.ASM、YY.C加入项目
文件名 : OS_CPU_A.ASM
- $NOMOD51
- EA BIT 0A8H.7
- SP DATA 081H
- B DATA 0F0H
- ACC DATA 0E0H
- DPH DATA 083H
- DPL DATA 082H
- PSW DATA 0D0H
- TR0 BIT 088H.4
- TH0 DATA 08CH
- TL0 DATA 08AH
- NAME OS_CPU_A ;模块名
- ;定义重定位段
- ?PR?OSStartHighRdy?OS_CPU_A SEGMENT CODE
- ?PR?OSCtxSw?OS_CPU_A SEGMENT CODE
- ?PR?OSIntCtxSw?OS_CPU_A SEGMENT CODE
- ?PR?OSTickISR?OS_CPU_A SEGMENT CODE
- ?PR?_?serial?OS_CPU_A SEGMENT CODE
- ;声明引用全局变量和外部子程序
- EXTRN IDATA (OSTCBCur)
- EXTRN IDATA (OSTCBHighRdy)
- EXTRN IDATA (OSRunning)
- EXTRN IDATA (OSPrioCur)
- EXTRN IDATA (OSPrioHighRdy)
- EXTRN CODE (_?OSTaskSwHook)
- EXTRN CODE (_?serial)
- EXTRN CODE (_?OSIntEnter)
- EXTRN CODE (_?OSIntExit)
- EXTRN CODE (_?OSTimeTick)
- ;对外声明4个不可重入函数
- PUBLIC OSStartHighRdy
- PUBLIC OSCtxSw
- PUBLIC OSIntCtxSw
- PUBLIC OSTickISR
- ;PUBLIC SerialISR
- ;分配堆栈空间。只关心大小,堆栈起点由keil决定,通过标号可以获得keil分配的SP起点。
- ?STACK SEGMENT IDATA
- RSEG ?STACK
- OSStack:
- DS 40H
- OSStkStart IDATA OSStack-1
- ;定义压栈出栈宏
- PUSHALL MACRO
- PUSH PSW
- PUSH ACC
- PUSH B
- PUSH DPL
- PUSH DPH
- MOV A,R0 ;R0-R7入栈
- PUSH ACC
- MOV A,R1
- PUSH ACC
- MOV A,R2
- PUSH ACC
- MOV A,R3
- PUSH ACC
- MOV A,R4
- PUSH ACC
- MOV A,R5
- PUSH ACC
- MOV A,R6
- PUSH ACC
- MOV A,R7
- PUSH ACC
- ;PUSH SP ;不必保存SP,任务切换时由相应程序调整
- ENDM
- POPALL MACRO
- ;POP ACC ;不必保存SP,任务切换时由相应程序调整
- POP ACC ;R0-R7出栈
- MOV R7,A
- POP ACC
- MOV R6,A
- POP ACC
- MOV R5,A
- POP ACC
- MOV R4,A
- POP ACC
- MOV R3,A
- POP ACC
- MOV R2,A
- POP ACC
- MOV R1,A
- POP ACC
- MOV R0,A
- POP DPH
- POP DPL
- POP B
- POP ACC
- POP PSW
- ENDM
- ;子程序
- ;-------------------------------------------------------------------------
- RSEG ?PR?OSStartHighRdy?OS_CPU_A
- OSStartHighRdy:
- USING 0 ;上电后51自动关中断,此处不必用CLR EA指令,因为到此处还未开中断,本程序退出后,开中断。
- LCALL _?OSTaskSwHook
- OSCtxSw_in:
- ;OSTCBCur ===> DPTR 获得当前TCB指针,详见C51.PDF第178页
- MOV R0,#LOW (OSTCBCur) ;获得OSTCBCur指针低地址,指针占3字节。+0类型+1高8位数据+2低8位数据
- INC R0
- MOV DPH,@R0 ;全局变量OSTCBCur在IDATA中
- INC R0
- MOV DPL,@R0
- ;OSTCBCur->OSTCBStkPtr ===> DPTR 获得用户堆栈指针
- INC DPTR ;指针占3字节。+0类型+1高8位数据+2低8位数据
- MOVX A,@DPTR ;.OSTCBStkPtr是void指针
- MOV R0,A
- INC DPTR
- MOVX A,@DPTR
- MOV R1,A
- MOV DPH,R0
- MOV DPL,R1
- ;*UserStkPtr ===> R5 用户堆栈起始地址内容(即用户堆栈长度放在此处) 详见文档说明 指针用法详见C51.PDF第178页
- MOVX A,@DPTR ;用户堆栈中是unsigned char类型数据
- MOV R5,A ;R5=用户堆栈长度
- ;恢复现场堆栈内容
- MOV R0,#OSStkStart
- restore_stack:
- INC DPTR
- INC R0
- MOVX A,@DPTR
- MOV @R0,A
- DJNZ R5,restore_stack
- ;恢复堆栈指针SP
- MOV SP,R0
- ;OSRunning=TRUE
- MOV R0,#LOW (OSRunning)
- MOV @R0,#01
- POPALL
- SETB EA ;开中断
- RETI
- ;-------------------------------------------------------------------------
- RSEG ?PR?OSCtxSw?OS_CPU_A
- OSCtxSw:
- PUSHALL
- OSIntCtxSw_in:
- ;获得堆栈长度和起址
- MOV A,SP
- CLR C
- SUBB A,#OSStkStart
- MOV R5,A ;获得堆栈长度
- ;OSTCBCur ===> DPTR 获得当前TCB指针,详见C51.PDF第178页
- MOV R0,#LOW (OSTCBCur) ;获得OSTCBCur指针低地址,指针占3字节。+0类型+1高8位数据+2低8位数据
- INC R0
- MOV DPH,@R0 ;全局变量OSTCBCur在IDATA中
- INC R0
- MOV DPL,@R0
- ;OSTCBCur->OSTCBStkPtr ===> DPTR 获得用户堆栈指针
- INC DPTR ;指针占3字节。+0类型+1高8位数据+2低8位数据
- MOVX A,@DPTR ;.OSTCBStkPtr是void指针
- MOV R0,A
- INC DPTR
- MOVX A,@DPTR
- MOV R1,A
- MOV DPH,R0
- MOV DPL,R1
- ;保存堆栈长度
- MOV A,R5
- MOVX @DPTR,A
- MOV R0,#OSStkStart ;获得堆栈起址
- save_stack:
- INC DPTR
- INC R0
- MOV A,@R0
- MOVX @DPTR,A
- DJNZ R5,save_stack
- ;调用用户程序
- LCALL _?OSTaskSwHook
- ;OSTCBCur = OSTCBHighRdy
- MOV R0,#OSTCBCur
- MOV R1,#OSTCBHighRdy
- MOV A,@R1
- MOV @R0,A
- INC R0
- INC R1
- MOV A,@R1
- MOV @R0,A
- INC R0
- INC R1
- MOV A,@R1
- MOV @R0,A
- ;OSPrioCur = OSPrioHighRdy 使用这两个变量主要目的是为了使指针比较变为字节比较,以便节省时间。
- MOV R0,#OSPrioCur
- MOV R1,#OSPrioHighRdy
- MOV A,@R1
- MOV @R0,A
- LJMP OSCtxSw_in
- ;-------------------------------------------------------------------------
- RSEG ?PR?OSIntCtxSw?OS_CPU_A
- OSIntCtxSw:
- ;调整SP指针去掉在调用OSIntExit(),OSIntCtxSw()过程中压入堆栈的多余内容
- ;SP=SP-4
- MOV A,SP
- CLR C
- SUBB A,#4
- MOV SP,A
- LJMP OSIntCtxSw_in
- ;-------------------------------------------------------------------------
- CSEG AT 000BH ;OSTickISR
- LJMP OSTickISR ;使用定时器0
- RSEG ?PR?OSTickISR?OS_CPU_A
- OSTickISR:
- USING 0
- PUSHALL
- CLR TR0
- MOV TH0,#70H ;定义Tick=50次/秒(即0.02秒/次)
- MOV TL0,#00H ;OS_CPU_C.C 和 OS_TICKS_PER_SEC
- SETB TR0
- LCALL _?OSIntEnter
- LCALL _?OSTimeTick
- LCALL _?OSIntExit
- POPALL
- RETI
- ;-------------------------------------------------------------------------
- CSEG AT 0023H ;串口中断
- LJMP SerialISR ;工作于系统态,无任务切换。
- RSEG ?PR?_?serial?OS_CPU_A
- SerialISR:
- USING 0
- PUSHALL
- CLR EA
- LCALL _?serial
- SETB EA
- POPALL
- RETI
- ;-------------------------------------------------------------------------
- END
- ;-------------------------------------------------------------------------