工作了就很长时间没写文章了.在此转发一篇我朋友的文章.也想借此机会介绍一下他开发的一个操作系统内核.希望高手多多指教!
19bytes!玩转嵌入式rtos―r&s在mcs51上的移植
阮海深 2004-12-7 初稿
r&s是一款层次清晰的嵌入式内核,努力追求稳定与快速;r&s主体部分是严格按照ansi c标准语法构建的,幸运的是,当前流行的处理器都有支持的标准c编译环境,只需改动少量与处理器相关代码,即可很容易将r&s移植到具体的处理器上;本文通过在mcs51的移植实例,探讨r&s的一般移植思路。
一 r&s的文件结构
1 /arch,依赖于具体体系结构部分,包括中断、时钟服务、处理器栈指针切换,是移植的重点;
2 /example,演示例子;
3 /inc,内核头文件,包括默认的配置文件config.h;
4 /kernel,基本内核模块,不依赖具体处理器部分代码;
5 /ipc,任务间通讯支持,包括二值信号量、信号量、互斥锁、邮箱、消息队列;
6 /lib,用户支持库,基本IO,文件系统,网络支持…(待完善);
二 移植
r&s的移植过程是非常简单的,实际上只需重新实现5个函数和几个常数宏定义,移植涉及到文件均在目录/arch下:
¨ basetype.h
¨ intr.h
¨ trace.h
¨ misc.c
¨ context.asm
¨ trace.c
共3个头文件与3个源文件,其中1个asm文件。
basetype.h包含了关于cpu位宽、栈的增长方向、存储字节序以及基本数据类型描述。
intr.h、misc.c、context.asm定义了临界段相关的宏、任务上下文切换、以及系统时钟中断服务,涉及到以下项修改:
¨ 临界段控制宏CRITICAL_ENTER、CRITICAL_EXIT与中断控制宏DISABLE_IRQ、ENABLE_IRQ定义
¨ 任务栈初始化 __stack_init、__entry_init
¨ 任务栈指针切换 __switch_start、__switch_to
¨ 系统时钟服务 __timer_irs
trace.h、trace.c是用户接口输出支持,包括对__ASSERT的实现。提供对r&s的DEBUG版本、字符输出lib/pintk等相关的支持。对于没有用户接口硬件支持的系统,可以省略这两个文件。
1 修改basetype.h相关内容
#define ARCH_CPU_BITS 8 //cpu位宽
#define ARCH_STACK_GROW UPWARDS //栈增长方向
#define ARCH_MM_BYTEORDER BIG_ENDIAN //字节序模式:大头模式,即低地址高字节
//以下是基本数据类型
#define __arch_u8 char
#define __arch_u16 short
#define __arch_u32 long
#define __arch_u64 long long
//重定义SP指针和基本类型修饰(可选内容)
#define __sp char idata *
#define __const_ code
#define __p_ data
2 移植intr.h、misc.c、context.asm
1) 临界段宏CRITICAL_ENTER,CRITICAL_EXIT定义
DISABLE_IRQ、ENABLE_IRQ分别定义中断开关控制。
宏CRITICAL_ENTER,CRITICAL_EXIT定义了系统临界段代码保护,一般是通过开、关中断实现。在提供内嵌汇编语句的编译器中,可以很方便定义插入汇编指令来开、关中断;在c51中,可以直接使用c语法来操作51的特殊寄存器,非常方便。
这里我们通过简单的进入临界段CRITICAL_ENTER,关闭中断EA=0,退出临界段CRITICAL_EXIT打开中断EA=1实现。注意,这种模式要避免临界段的嵌套。在稍微复杂的应用,进入临界段要先将中断状态保存到堆栈中,然后关闭中断,退出临界段时,从堆栈中恢复中断状态。
#define DISABLE_IRQ EA = 0
#define ENABLE_IRQ EA = 1
#define CRITICAL_ENTER EA=0
#define CRITICAL_EXIT EA=1
2) 任务栈初始化 __stack_init、__entry_init
初始化任务栈,实际上是模拟函数调用过程,给任务以假象--哦,我是从这个地方中断的,现在我应该回到那继续!我们利用任务的入口地址entry初始化一个假想栈,这样任务就可以从返回到入口开始工作。
实际上,r&s没有让任务马上进入任务入口函数entry运行,在任务开始前,总该做点什么,这个工作交给了__entry_init,很多时候,只需在__entry_init为每个任务简单的打开中断。
static void __entry_init(void)
{
ENABLE_IRQ; //在任务开始前,我们首先打开中断
} //返回entry
现在利用__stack_init构造一个初始化任务栈,使得任务能够最终返回到入口entry开始。
struct reg_context //构造一个初始化任务栈
{
u8 entry_l;
u8 entry_h;
u8 eninit_l;
u8 eninit_h;
u8 arg;
};
typedef struct regs_context regs_t;
u8* __stack_init(entry_t entry, arg_t arg, sp_t stack_base)
{
regs_t* regs;
regs = (regs_t*) stack_base; //regs指向任务栈基地址
regs->entry_l = (u8)entry; //任务入口地址低位
regs->entry_h = (u16)entry >> 8; //任务入口地址高位
regs->eninit_l = (u8)__entry_init; //任务初始化地址(低位)
regs->eninit_h = (u16)__entry_init >> 8; //任务初始化地址(高位)
regs->arg = (u8)arg; //任务入口参数
return (u8*)regs + 3; //返回指向任务初始化函数的栈
}
这里有个问题,标准的c编译器都是支持栈传递函数参数的,但在c51中,函数调用默认是寄存器传递。所以,任务入口参数arg并没有真正传入到任务中,解决这个问题并不难,你得查看编译器手册,知道参数是通过那个寄存器传送的,然后在__entry_init中提取arg内容复制到参数寄存器就可以了。
3) 栈切换实现__switch_start、__switch_to
任务栈指针切换实现__switch_start、__switch_to也非常简单;任务栈初始化的构造和任务栈指针切换一定要严格对应一致的。
__switch_start功能是在r&s启动多任务时候,将处理器栈指针SP指向第一个任务栈过程。
void __switch_start(sp_t next_sp)
{
SP = next_sp; //将SP指向第一个任务栈地址
} //这里隐含了一条return语句,将返回到第一个用户任务
__switch_to工作是在r&s进行任务切换时,保存SP到当前任务栈,然后把栈指针SP指向新的任务栈。实现如下:
void __switch_to(sp_t* pcurrent_sp, sp_t next_sp)
{
*pcurrent_sp = (sp_t)SP; //保存当前任务栈地址
SP = next_sp; //SP指向新的任务栈
} //这里隐含了一条return语句,将返回到新的用户任务
4) 系统时钟服务void __timer_irs (void)
系统时钟服务为r&s提供一个周期的时钟节拍,实现延时和超时控制等操作,时钟节拍一般取10~100Hz。r&s的延时精度取决于时钟节拍的精度,可以利用处理器内含定时器或外置专门的定时芯片实现系统节拍,在对定时要求精度很高的场合,可使用自循环的硬件定时器获得,典型的r&s中断处理流程如下:
关中断;(可选)
任务调度锁定_sched_lock++(可选)
保存现场;
模拟一次中断
开中断;(可选)
任务调度解锁_sched_lock--(可选)
切换任务
恢复现场;
返回
_sched_lock是一字节的无符号整数,只有当_sched_lock值为0时,才允许r&s调度器进行任务切换;如果允许中断嵌套,你需要在中断中锁住r&s调度器,避免在中断嵌套中发生任务调度;如果不允许中断嵌套,那么_sched_lock是不必的。根据_sched_lock可以算出r&s允许达255层深的中断嵌套。
CSEG AT 000BH
INC #_sched_lock
LJMP IT0_IRS
CSEG AT 0100H
;*==============================================*/
IT0_IRS: ;中断入口
PUSH ACC ;保存现场
PUSH B
PUSH PSW
PUSH DPH
PUSH DPL
PUSH 00H
PUSH 01H
PUSH 02H
PUSH 03H
PUSH 04H
PUSH 05H
PUSH 06H
PUSH 07H
;------------------------------------------------------- ;模拟中断
MOV A, #LOW IT0_OUT
PUSH ACC
MOV A, #HIGH IT0_OUT
PUSH ACC
LCALL __do_tick ;中断处理
MOV TH0,#T0H_COUNTER ;刷新定时器
MOV TL0,#T0L_COUNTER ;
RETI ;中断返回
;-------------------------------------------------------
IT0_OUT:
DEC #_sched_lock
LCALL __schedule ;任务切换
POP 07H ;恢复现场
POP 06H
POP 05H
POP 04H
POP 03H
POP 02H
POP 01H
POP 00H
POP DPL
POP DPH
POP PSW
POP B
POP ACC
RET
;*==============================================*/
中断处理关键思路是,截获原中断,保存好任务现场,然后模拟中断,根据中断处理模式和内容在返回点是否安排进行任务切换__schedule过程。
也许有人会问,为何要进行中断模拟过程?在有些处理器上,中断和函数返回只在出栈内容上有所不同,另外一些处理器还包含了一些对特殊寄存器的恢复操作;一般的做法是将任务级和中断级的任务切换过程分开处理,特别的,r&s通过模拟中断,逻辑上分离实际中断过程,将中断任务切换上退化为任务级,使得整个任务的切换和栈的处理变得非常的简单清晰,同时简化了移植的工作量。
3 修改系统配置文件
针对不同位宽处理器实现的设计思路,使得r&s具有了非常好的伸缩性,为了满足这一要求,需要对内核做非常细心的功能模块划分和抽象,每个块都是可装卸的。这样可以在8位处理器上变得小巧玲珑,又能在32位处理器上处理复杂的业务,r&s所有模块配置都集中在文件:
inc/config.h
这是默认的配置文件,在实际应用中,推荐将配置文件复制到另一个目录,以方便对照使用和恢复。
在这里,我们只保留基本的调度功能,定义系统最大的任务数为3,使用简单优先级模式;包括一个系统必须的idle任务,用户能创建两个任务,最高优先级任务为0,把其他模块关闭,这样我们获得一个非常小巧的r&s,编译后只有1k大小。
#define CFG_MAX_TASKS 3 //定义任务的最大数
#define CFG_PRIO_MODE 0 //使用简单优先级模式
#define CFG_IDLE_STACKSZ 20 //定义idle任务栈大小
现在,r&s已经可以在mcs51上工作了。在c51中,任务栈的大小的计算依据是任务函数嵌套层数,加上中断的嵌套的最坏情况;r&s使用ram资源根据具体的各模块配置参数有所不同,在该实例中,可以计算到调度使用的ram资源是4n+3 bytes,n为任务数3,r&s对资源的需求是非常节约的。
加上idle任务栈20bytes,我们实际使用了35bytes!非常让人激动。哦,我还没有解析19bytes的来历,到现在,我们都是按照一般处理器移植思路进行的,但对于mcs51来说,我们还可以进行优化…
三 优化-实现19bytes!
细心的朋友已经发现,任务栈主要是耗费在中断处理中需要大量的现场保护资源,而系统任务idle中,只是简单的空循环,并没有需要保护的现场寄存器,这就是我们切入点。我们从以下方面入手:
¨ 减少函数嵌套层数
¨ 禁止中断嵌套
¨ 针对idle任务对中断现场特殊处理
减少函数的嵌套层数和禁止中断嵌套是对减少用户任务栈也有意义的,如果能把idle任务耗费的栈资源降低,应该能从整体上减少不少的ram开销。
我们主要要修改时钟中断处理部分
CSEG AT 000BH
CLR EA ;禁止中断
LJMP IT0_IRS
CSEG AT 0100H
;*==============================================*/
IT0_IRS: ;中断入口
PUSH ACC ;保存现场
MOV A , #TASK_IDLE_PRIO
CJNE A , _current_prio, IT0_NOR_IN ;判断当前任务是否为idle任务
POP ACC ;如果idle任务,跳过现场保护流程
JMP IT0_IDLE_IN
IT0_NOR_IN: ;如果是一般任务按正常流程
PUSH B
PUSH PSW
PUSH DPH
PUSH DPL
PUSH 00H
PUSH 01H
PUSH 02H
PUSH 03H
PUSH 04H
PUSH 05H
PUSH 06H
PUSH 07H
IT0_IDLE_IN:
;------------------------------------------------------- ;模拟中断
LCALL __mcs51_do_tick ;中断处理
MOV A, #LOW IT0_OUT
PUSH ACC
MOV A, #HIGH IT0_OUT
PUSH ACC
MOV TH0,#T0H_COUNTER ;刷新定时器
MOV TL0,#T0L_COUNTER ;
RETI ;中断返回
;-------------------------------------------------------
IT0_OUT:
MOV A , #TASK_IDLE_PRIO ;仿照进入中断处理过程
CJNE A , _current_prio, IT0_NOR_OUT
LJMP __schedule
IT0_NOR_OUT:
LCALL __schedule ;任务切换
POP 07H ;恢复现场
POP 06H
POP 05H
POP 04H
POP 03H
POP 02H
POP 01H
POP 00H
POP DPL
POP DPH
POP PSW
POP B
POP ACC
RET
;*==============================================*/
这里我们通过判断当前任务是否为idle任务,如果是idle任务,就跳过现场保护过程;
而且我们在中断服务开始加入了禁止中断语句,这样会带来一个问题,在中断处理__do_tick中含有临界段代码,__do_tick返回的时候会重新把中断打开,就是上面我们提到过临界段嵌套的问题,我们可以通过采用可嵌套的临界段模式避免这个问题,这里因为我不想增加任务栈的负担,将原来的__do_tick实现改为__mcs51_do_tick,只是简单的去掉了__do_tick中临界段。
并且将__mcs51_do_tick的位置调整了一下,这样减少了一层(2bytes)压栈深度。
细心读者会发现在当前任务是idle的中断退出处理中使用:
LJMP __schedule 而不是LCALL __schedule
是的,这样可以减少一层压栈深度,效率也更高了,不过这些都不是主要的原因,等会我再分析这样做的关键所在。
经过这么一阵折腾,在idle任务的中断处理过程,最大的压栈深度,没错,是4bytes!别忘了在初始化任务栈__stack_init的时候,使用了5bytes的栈空间,r&s在建立idle任务的时候会调用__stack_init初始化idle任务栈,所以需要针对idle任务做点小手术,这样我们就可以将CFG_IDLE_STACKSZ改为:
#define CFG_IDLE_STACKSZ 4 //定义idle任务栈大小
编译!运行!成功了!――19bytes!
上面提到使用LJMP __schedule的关键是,因为对于idle任务来说,使用LCALL当__schedule返回的时候将会允许中断,这时候idle任务已经有了一层压栈,在返回idle任务之前,如果发生了中断,idle任务栈就已经达到了4bytes临界值,中断服务中任何一个push操作将导致idle的栈溢出!虽然这种情况发生几率是微乎其微的,但这是不允许的。特别我们将任务栈使用到极限的情况下,一定要仔细考虑每一个细节,任何的误差都可能引起系统的崩溃。
最后要注意:
如果你使用极限idle栈,一定要保证任何时候idle栈不溢出
要使用系统idle钩子hook_idle_task,一定要非常谨慎,确保不会导致idle栈溢出
确保其他的中断处理也不会引起idle栈溢出
在系统还有多余资源时,适当的给每个任务栈安排余量是明智的
以上实例代码,是在KeilC 6.12,使用AT80C52的小ram模式编译通过的,我已经将移植代码合入到r&s非正式版本:V1.12b,所有源代码可以在
http://www.01s.org下载,在新的r&s正规版本中,将会包含51和其他处理器移植代码,请随时关注01s网站的嵌入式专版。
在该文完稿前,我已花了数个晚上检查移植代码,以最大努力确保准确性和可读性。实际上这是我是第一次使用KeilC,对51的认识也仅停留在99年夏竞赛期间所学,时隔多年重新拾起,有不妥的地方或者bug,或任何问题,通过
[email protected] 或者
http://www.01s.org
可以与我取得联系,对于本文的最新更正版本,我将发布于上述网站。
转载本文,请保留完整性