遥想当年刚学习操作系统的时候,很难理解教科书中关于线程/进程的描述。原因还是在于操作系统书上的内容太过抽象,对于一个没有看过内核代码的初学者来说,很难理解各种数据结构的调度。后来自己也买了一些造轮子的书,照着好几本书也造了几个玩具操作系统,有X86,有ARM的。经过实践之后回头再去看操作系统的书,才恍然大悟操作系统书中所写的知识点。
看了许多操作系统实践类的书籍后,有些书只是浅尝辄止,试图用300页将通用操作系统各个模块都讲了一遍,这一类书帮助读者理解操作系统还是有限;而有些书写的确实很不错,内容详实,然而动辄上千页,让读者望而生畏,但是读完并且照着书写完一个玩具OS的话,绝对对OS的理解有很大帮助。这里推荐郑刚老师写的《操作系统真相还原》,本人觉得这本书非常好,深入浅出。那我为何还要写这篇博客呢?我觉得操作系统内核最核心,且初学者最难理解的部分莫过于进程/线程(在RTOS中称为任务),所以本文试图写一个只有不到1000多行代码的RTOS来帮助读者理解操作系统核心部分。一般小型RTOS中并没有虚拟内存管理,文件系统,设备管理等模块,这样减小读者的负担,更好理解操作系统的核心部分(进程/线程/任务),在这之后再去学习其他的模块必然事半功倍。所以本文仅仅作为一篇入门读物,若是能帮助各位看官进入操作系统的大门,也算是功德无量。当然在下才疏学浅,难免有错误的地方,大神发现的话请指出。
话不多说,直接进入正题。
虽然本文旨在一篇入门的教程,但希望读者具有以下的预备知识,否则读起来会有诸多不顺。
https://github.com/JiaminMa/write_rtos_in_3days.git
本文使用qemu虚拟机来仿真arm cortex m3的芯片,QEMU可以自己编译,也可以下载,我已经编译好一份QEMU,各位看官可以直接clone该git然后使用tools里面的qemu即可。编译器使用的是GNU的arm-none-eabi-gcc,这个可以使用sudo apt-get install gcc-arm-none-eabi
下载到。哦对了,我的linux用的是ubuntu16 64位,希望各位看官可以用相同版本的ubuntu,否则可能会有一些环境的问题,概不负责。以下乃环境搭建参考步骤:
qemu-system-arm对于CORTEX M的芯片官方只支持了Stellaris LM3S6965EVB和Stellaris LM3S811EVB,本文使用了LM3S6965EVB作为开发平台。非官方的有STM32等其他CM3/4的芯片及开发板,但这里选用官方的支持更稳定一些。我在doc目录下放了LM3S6965的芯片手册,感兴趣的读者可以自己看,实际上本文在写嵌入式操作系统中,除了UART并没有使用到LM3S6965的外设,大部分代码都是针对ARM CM3内核的操作,所以并不需要对LM3S6965EVB很清楚。
没错,本章就是要在qemu平台上打印最喜闻乐见的Hello world。本节的完整代码在01_hello_world中。
当CM3内核响应了一个发生的异常后,对应的异常服务例程(ESR)就会执行。为了决定ESR的入口地址,CM3使用了“向量表查表机制”。这里使用一张向量表。向量表其实是一个WORD(32位整数)数组,每个下标对应一种异常,该下标元素的值则是该ESR的入口地址。向量表在地址空间中的位置是可以设置的,通过NVIC中的一个重定位寄存器来指出向量表的地址。在复位后,该寄存器的值为0。因此,在地址0处必须包含一张向量表,用于初始时的异常分配。
异常类型 | 表项地址偏移量 | 异常向量 |
---|---|---|
0 | 0x00 | MSP初始值 |
1 | 0x04 | 复位函数入口 |
2 | 0x08 | NMI |
3 | 0x0C | Hard Fault |
4 | 0x10 | MemManage Fault |
5 | 0x14 | 总线Fault |
6 | 0x18 | 用法Fault |
7-10 | 0x1c-0x28 | 保留 |
11 | 0x2c | SVC |
12 | 0x30 | 调试监视器 |
13 | 0x34 | 保留 |
14 | 0x38 | PendSV |
15 | 0x3c | SysTick |
16 | 0x40 | IRQ #0 |
17 | 0x44 | IRQ #1 |
18-255 | 0x48-0x3ff | IRQ#2-#239 |
举个例子,如果发生了异常11(SVC),则NVIC会计算出偏移移量是11x4=0x2C,然后从那里取出服务例程的入口地址并跳入。要注意的是这里有个另类:0号类型并不是什么入口地址,而是给出了复位后MSP的初值。 Cortex M3权威指南P43 3.5向量表>
本文中,int_vector.c中包含了异常向量表,源代码如下。我们将MSP(主栈)的值设为0x2000c000,程序入口为main,NMI中断和HardFault中断分别为自己处理函数,其他异常以及中断暂时全部使用IntDefaultHandler。
static void NmiSR(void){
while(1);
}
static void FaultISR(void){
while(1);
}
static void IntDefaultHandler(void){
while(1);
}
__attribute__ ((section(".isr_vector")))void (*g_pfnVectors[])(void) =
{
0x2000c000, // StackPtr, set in RestetISR
main, // The reset handler
NmiSR, // The NMI handler
FaultISR, // The hard fault handler
IntDefaultHandler, // The MPU fault handler
IntDefaultHandler, // The bus fault handler
IntDefaultHandler, // The usage fault handler
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
IntDefaultHandler, // SVCall handler
IntDefaultHandler, // Debug monitor handler
0, // Reserved
IntDefaultHandler, // The PendSV handler
IntDefaultHandler, // The SysTick handler
IntDefaultHandler, // GPIO Port A
IntDefaultHandler, // GPIO Port B
IntDefaultHandler, // GPIO Port C
IntDefaultHandler, // GPIO Port D
IntDefaultHandler, // GPIO Port E
IntDefaultHandler, // UART0 Rx and Tx
IntDefaultHandler, // UART1 Rx and Tx
IntDefaultHandler, // SSI0 Rx and Tx
IntDefaultHandler, // I2C0 Master and Slave
IntDefaultHandler, // PWM Fault
IntDefaultHandler, // PWM Generator 0
IntDefaultHandler, // PWM Generator 1
IntDefaultHandler, // PWM Generator 2
IntDefaultHandler, // Quadrature Encoder 0
IntDefaultHandler, // ADC Sequence 0
IntDefaultHandler, // ADC Sequence 1
IntDefaultHandler, // ADC Sequence 2
IntDefaultHandler, // ADC Sequence 3
IntDefaultHandler, // Watchdog timer
IntDefaultHandler, // Timer 0 subtimer A
IntDefaultHandler, // Timer 0 subtimer B
IntDefaultHandler, // Timer 1 subtimer A
IntDefaultHandler, // Timer 1 subtimer B
IntDefaultHandler, // Timer 2 subtimer A
IntDefaultHandler, // Timer 2 subtimer B
IntDefaultHandler, // Analog Comparator 0
IntDefaultHandler, // Analog Comparator 1
IntDefaultHandler, // Analog Comparator 2
IntDefaultHandler, // System Control (PLL, OSC, BO)
IntDefaultHandler, // FLASH Control
IntDefaultHandler, // GPIO Port F
IntDefaultHandler, // GPIO Port G
IntDefaultHandler, // GPIO Port H
IntDefaultHandler, // UART2 Rx and Tx
IntDefaultHandler, // SSI1 Rx and Tx
IntDefaultHandler, // Timer 3 subtimer A
IntDefaultHandler, // Timer 3 subtimer B
IntDefaultHandler, // I2C1 Master and Slave
IntDefaultHandler, // Quadrature Encoder 1
IntDefaultHandler, // CAN0
IntDefaultHandler, // CAN1
IntDefaultHandler, // CAN2
IntDefaultHandler, // Ethernet
IntDefaultHandler, // Hibernate
IntDefaultHandler, // USB0
IntDefaultHandler, // PWM Generator 3
IntDefaultHandler, // uDMA Software Transfer
IntDefaultHandler, // uDMA Error
IntDefaultHandler, // ADC1 Sequence 0
IntDefaultHandler, // ADC1 Sequence 1
IntDefaultHandler, // ADC1 Sequence 2
IntDefaultHandler, // ADC1 Sequence 3
IntDefaultHandler, // I2S0
IntDefaultHandler, // External Bus Interface 0
IntDefaultHandler, // GPIO Port J
IntDefaultHandler, // GPIO Port K
IntDefaultHandler, // GPIO Port L
IntDefaultHandler, // SSI2 Rx and Tx
IntDefaultHandler, // SSI3 Rx and Tx
IntDefaultHandler, // UART3 Rx and Tx
IntDefaultHandler, // UART4 Rx and Tx
IntDefaultHandler, // UART5 Rx and Tx
IntDefaultHandler, // UART6 Rx and Tx
IntDefaultHandler, // UART7 Rx and Tx
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
IntDefaultHandler, // I2C2 Master and Slave
IntDefaultHandler, // I2C3 Master and Slave
IntDefaultHandler, // Timer 4 subtimer A
IntDefaultHandler, // Timer 4 subtimer B
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
IntDefaultHandler, // Timer 5 subtimer A
IntDefaultHandler, // Timer 5 subtimer B
IntDefaultHandler, // Wide Timer 0 subtimer A
IntDefaultHandler, // Wide Timer 0 subtimer B
IntDefaultHandler, // Wide Timer 1 subtimer A
IntDefaultHandler, // Wide Timer 1 subtimer B
IntDefaultHandler, // Wide Timer 2 subtimer A
IntDefaultHandler, // Wide Timer 2 subtimer B
IntDefaultHandler, // Wide Timer 3 subtimer A
IntDefaultHandler, // Wide Timer 3 subtimer B
IntDefaultHandler, // Wide Timer 4 subtimer A
IntDefaultHandler, // Wide Timer 4 subtimer B
IntDefaultHandler, // Wide Timer 5 subtimer A
IntDefaultHandler, // Wide Timer 5 subtimer B
IntDefaultHandler, // FPU
IntDefaultHandler, // PECI 0
IntDefaultHandler, // LPC 0
IntDefaultHandler, // I2C4 Master and Slave
IntDefaultHandler, // I2C5 Master and Slave
IntDefaultHandler, // GPIO Port M
IntDefaultHandler, // GPIO Port N
IntDefaultHandler, // Quadrature Encoder 2
IntDefaultHandler, // Fan 0
0, // Reserved
IntDefaultHandler, // GPIO Port P (Summary or P0)
IntDefaultHandler, // GPIO Port P1
IntDefaultHandler, // GPIO Port P2
IntDefaultHandler, // GPIO Port P3
IntDefaultHandler, // GPIO Port P4
IntDefaultHandler, // GPIO Port P5
IntDefaultHandler, // GPIO Port P6
IntDefaultHandler, // GPIO Port P7
IntDefaultHandler, // GPIO Port Q (Summary or Q0)
IntDefaultHandler, // GPIO Port Q1
IntDefaultHandler, // GPIO Port Q2
IntDefaultHandler, // GPIO Port Q3
IntDefaultHandler, // GPIO Port Q4
IntDefaultHandler, // GPIO Port Q5
IntDefaultHandler, // GPIO Port Q6
IntDefaultHandler, // GPIO Port Q7
IntDefaultHandler, // GPIO Port R
IntDefaultHandler, // GPIO Port S
IntDefaultHandler, // PWM 1 Generator 0
IntDefaultHandler, // PWM 1 Generator 1
IntDefaultHandler, // PWM 1 Generator 2
IntDefaultHandler, // PWM 1 Generator 3
IntDefaultHandler // PWM 1 Fault
};
CM3内核从异常向量表中取出MSP,然后设置MSP后就跳到reset向量中,在这里是main函数,其启动过程如下图所示。main函数的实现在main.c中,源代码如下,非常简单,往串口数据寄存器中写数据打印Hello World,然后就while(1)循环。由于这是QEMU虚拟机,所以并不需要对串口进行初始化等操作,直接往DR寄存器里写数据即可打印出字符,在真实的硬件这么做是不行的,必须初始化串口的时钟已经相应的寄存器来配置其工作模式。
main.c
#include
volatile uint32_t * const UART0DR = (uint32_t *)0x4000C000;
void send_str(char *s)
{
while(*s != '\0') {
*UART0DR = *s++;
}
}
void main()
{
send_str("hello world\n");
while(1);
}
CM3的存储器映射是相对固定的,具体可以参看《CORTEX_M3 权威指南》84页的图5.1。本文中的存储分布如下表所示,0x0-0x40000为只读存储,即FLASH,0x20000000-0x20040000为SRAM区。FLASH和SRAM分别是256K。
内存地址 | 存储区域 |
---|---|
0x0-0x400 | 异常向量表 |
0x400-0x40000 | 代码段,只读数据段 |
0x20000000-0x20004000 | 数据段,bss段 |
0x20004000-0x20008000 | 进程堆栈段(PSP) |
0x20008000-0x2000c000 | 主栈段(MSP) |
具体实现参看链接文件rtos.ld,链接文件在后面的文章不会改动,所以只需要记住即可。
rtos.ld
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 256K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
}
SECTIONS
{
.text :
{
_text = .;
KEEP(*(.isr_vector))
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH
/DISCARD/ :
{
*(.ARM.exidx*)
*(.gnu.linkonce.armexidx.*)
}
.data : AT(ADDR(.text) + SIZEOF(.text))
{
_data = .;
*(vtable)
*(.data*)
_edata = .;
} > SRAM
.bss :
{
_bss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > SRAM
. = ALIGN(32);
_p_stack_bottom = .;
. = . + 0x4000;
_p_stack_top = 0x20008000;
. = . + 0x4000;
_stack_top = 0x2000c000;
}
Makefile 指定了编译器,编译选项以及编译命令等,在后续章节中,只需要objs := 即可,当加入一个新的源文件只需要在obj后面添加相应的.o即可。比如新建了test.c,那么改成objs := int_vector.o main.o test.o即可。这里不解释Makefile的原理,如果有不熟悉的读者请自行学习Makefile的规则,网上关于Makefile的好教程有许多。
Makefile
TOOL_CHAIN = arm-none-eabi-
CC = ${TOOL_CHAIN}gcc
AS = ${TOOL_CHAIN}as
LD = ${TOOL_CHAIN}ld
OBJCOPY = ${TOOL_CHAIN}objcopy
OBJDUMP = $(TOOL_CHAIN)objdump
CFLAGS := -Wall -g -fno-builtin -gdwarf-2 -gstrict-dwarf -mcpu=cortex-m3 -mthumb -nostartfiles --specs=nosys.specs -std=c11 \
-O0 -Iinclude
LDFLAGS := -g
objs := int_vector.o main.o
rtos.bin: $(objs)
${LD} -T rtos.ld -o rtos.elf $^
${OBJCOPY} -O binary -S rtos.elf $@
${OBJDUMP} -D -m arm rtos.elf > rtos.dis
run: $(objs)
${LD} -T rtos.ld -o rtos.elf $^
${OBJCOPY} -O binary -S rtos.elf rtos.bin
${OBJDUMP} -D -m arm rtos.elf > rtos.dis
qemu-system-arm -M lm3s6965evb --kernel rtos.bin -nographic
debug: $(objs)
${LD} -T rtos.ld -o rtos.elf $^
${OBJCOPY} -O binary -S rtos.elf rtos.bin
${OBJDUMP} -D -m arm rtos.elf > rtos.dis
qemu-system-arm -M lm3s6965evb --kernel rtos.bin -nographic -s -S
%.o:%.c
${CC} $(CFLAGS) -c -o $@ $<
%.o:%.s
${CC} $(CFLAGS) -c -o $@ $<
clean:
rm -rf *.o *.elf *.bin *.dis
###执行/调试
好了,终于把所有的源文件,链接文件和Makefile搞定了,运行一把。可以看到以下打印,那么说明执行正确。
如果需要调试的话,执行make debug,然后在另外一个窗口使用arm-linux-gdb调试,如下图所示
本节代码在02_cm3文件夹下
在完成了hello world后,我们可以实现CM3更多的功能了。我们要把常用的CM3的操作实现一把。首先改写int_vector.c。因为在进入c函数之前需要做一些栈的操作,所以讲reset handler从main换成reset_handler, reset_handler在cm3_s.s中实现。还有就是将会实现sys_tick的中断服务函数。这里有细心的哥们会问为什么reset_handler + 1。原因是对于CM3的thumb code指令集地址最低位必须为1,而reset_handler定义在汇编.S文件中,引入到C文件里编译器并没有自动+1,所以这里手动+1。而main是定义在c文件中,所以它已经自动将最低位+1了。
main.c
main, // The reset handler
...
IntDefaultHandler, // The SysTick handler
改为
((unsigned int)reset_handler + 1), // The reset handler
...
systick_handler, // The SysTick handler
reset_handler的实现很简单,将CM3运行时的栈切换成PSP,然后设置PSP的值,我习惯除了中断处理程序使用MSP,其他代码都用PSP。切换栈寄存器的动作很简单,就是修改CONTROL寄存器的第1位,即可,CONTROL寄存器定义如下图。_p_stack_top定义在rtos.ld中,其值是0x20008000。最后就是跳转到main来执行c代码。对于PSP和MSP是什么的朋友可能需要去看看CM3权威指南了哦。
cm3_s.s
.text
.code 16
.global main
.global reset_handler
.global _p_stack_top
.global get_psp
.global get_msp
.global get_control_reg
reset_handler:
/*Set the stack as process stack*/
/* tmp = CONTROL
* tmp |= 2
* CONTROL = tmp
* /
mrs r0, CONTROL
mov r1, #2
orr r0, r1
msr CONTROL, r0
ldr r0, =_p_stack_top
mov sp, r0
ldr r0, =main
blx r0
b .
main函数主要完成以下两点:
main.c
#include "os_stdio.h"
#include
#include "cm3.h"
extern uint32_t _bss;
extern uint32_t _ebss;
static inline void clear_bss(void)
{
uint8_t *start = (uint8_t *)_bss;
while ((uint32_t)start < _ebss) {
*start = 0;
start++;
}
}
void systick_handler(void)
{
DEBUG("systick_handler\n");
}
int main()
{
systick_t *systick_p = (systick_t *)SYSTICK_BASE;
clear_bss();
DEBUG("Hello RTOS\n");
DEBUG("psp:0x%x\n", get_psp());
DEBUG("msp:0x%x\n", get_msp());
init_systick();
while(1) {
}
return 0;
}
SysTick定时器被捆绑在NVIC中,用于产生SysTick异常(异常号:15)。在以前,操作系统还有所有使用了时基的系统,都必须一个硬件定时器来产生需要的“滴答”中断,作为整个系统的时基。滴答中断对操作系统尤其重要。例如,操作系统可以为多个任务许以不同数目的时间片,确保没有一个任务能霸占系统;或者把每个定时器周期的某个时间范围赐予特定的任务等,还有操作系统提供的各种定时功能,都与这个滴答定时器有关。因此,需要一个定时器来产生周期性的中断,而且最好还让用户程序不能随意访问它的寄存器,以维持操作系统“心跳”的节律。
Cortex-M3处理器内部包含了一个简单的定时器。因为所有的CM3芯片都带有这个定时器,软件在不同 CM3器件间的移植工作就得以化简。该定时器的时钟源可以是内部时钟(FCLK,CM3上的自由运行时钟),或者是外部时钟( CM3处理器上的STCLK信号)。不过,STCLK的具体来源则由芯片设计者决定,因此不同产品之间的时钟频率可能会大不相同。因此,需要检视芯片的器件手册来决定选择什么作为时钟源。
SysTick定时器能产生中断,CM3为它专门开出一个异常类型,并且在向量表中有它的一席之地。它使操作系统和其它系统软件在CM3器件间的移植变得简单多了,因为在所有CM3产品间,SysTick的处理方式都是相同的。 选自CORTEX_M3权威指南 P137
有4个寄存器控制SysTick定时器,如下表所示:
SysTick控制及状态寄存器(地址:0xE000_E010)
|位段|名称|类型|复位值|描述|
|-|-|-|-|
|16|COUNTFLAG|R|0|如果在上次读取本寄存器后,SysTick已经计到了0,则该位为1。如果读取该位,该位将自动清零|
|2|CLKSOURCE|R/W|0|0=外部时钟源(STCLK)
1=内核时钟(FCLK)|
|1|TICKINT|R/W|0|1=SysTick倒数计数到0时产生SysTick异常请求
0=数到0时无动作|
|0|ENABLE|R/W|0|SysTick定时器的使能位|
SysTick重装载数值寄存器(地址:0xE000_E014)
位段 | 名称 | 类型 | 复位值 | 描述 |
---|---|---|---|---|
23:0 | RELOAD | R/W | 0 | 读取时返回当前倒计数的值,写它则使之清零,同时还会清除在SysTick控制及状态寄存器中的COUNTFLAG标志 |
SysTick校准数值寄存器(地址:0xE000_E01C)
位段 | 名称 | 类型 | 复位值 | 描述 |
---|---|---|---|---|
23:0 | CURRENT | R/Wc | 0 | 读取时返回当前倒计数的值,写它则使之清零,同时还会清除在SysTick控制及状态寄存器中的COUNTFLAG标志 |
SysTick校准数值寄存器(地址:0xE000_E01C)
位段 | 名称 | 类型 | 复位值 | 描述 |
---|---|---|---|---|
31 | NOREF | R | - | 1=没有外部参考时钟(STCLK不可用) 0=外部参考时钟可用 |
30 | SKEW | R | - | 1=校准值不是准确的10ms 0=校准值是准确的10ms |
23:0 | TENMS | R/W | 0 | 在10ms的间隔中倒计数的格数。芯片设计者应该通过Cortex-M3的输入信号提供该数值。若该值读回零,则表示无法使用校准功能 |
在本节中,使用SystemClock作为systick的时钟,设置为1s发生一次systick中断,所以将reload寄存器设置为12M,最后是将systick的中断优先级设置为最低。调用这个函数之后,就能使能systick了,systick在后面的RTOS实现中扮演着关键的角色。
cm3.h
#ifndef CM3_H
#define CM3_H
#include
#define SCS_BASE (0xE000E000) /*System Control Space Base Address */
#define SYSTICK_BASE (SCS_BASE + 0x0010) /*SysTick Base Address*/
#define SCB_BASE (SCS_BASE + 0x0D00)
#define HSI_CLK 12000000UL
#define SYSTICK_PRIO_REG (0xE000ED23)
typedef struct systick_tag {
volatile uint32_t ctrl;
volatile uint32_t load;
volatile uint32_t val;
volatile uint32_t calrb;
}systick_t;
extern void init_systick(void);
#endif /*CM3_H*/
cm3.c
#include "cm3.h"
void init_systick()
{
systick_t *systick_p = (systick_t *)SYSTICK_BASE;
uint8_t *sys_prio_p = (uint8_t *)SYSTICK_PRIO_REG;
/*Set systick as lowest prio*/
*sys_prio_p = 0xf0;
/*set systick 1s*/
systick_p->load = (HSI_CLK & 0xffffffUL) - 1;
systick_p->val = 0;
/*Enable interrupt, System clock source, Enable Systick*/
systick_p->ctrl = 0x7;
}
有了串口打印之后,实现printf(k)/DEBUG函数就很简单了,打印函数实现在os_stdio.c中。关于如何实现printf的文章网上有很多,这里就不展开了,读者有兴趣可以去参考其他文章。本文重点还是放在RTOS的实现上。DEBUG是一个宏,只有在DEBUG_SUPPORT定义的情况下才会实现打印
os_stdio.h
#define DEBUG_SUPPORT
#ifdef DEBUG_SUPPORT
#define DEBUG printk
#else
#define DEBUG no_printk
#endif /*DEBUG*/
好了,大功告成,执行make run,可以看到sys_tick一秒打印一次如下图。
在上述简单讲了CM3的启动以及systick组件后,终于可以上硬菜了。好了,本节主要探讨两个问题:
本节代码位于03_rtos_basic下。
task.h定义了任务的数据结构task_t, 以及任务的接口,task_init, task_sched, task_switch, task_run_first。
在当前代码下,定义了一个任务表g_task_table,该表现在只存放两个任务的指针,然后定义了g_current_task用来指向当前任务,g_next_task指向下一个准备运行的任务。
任务控制块task_t中现在只包含一个值,就是当前任务栈的指针。任务与任务之间不共享栈空间,这点在操作系统的书上都有写,其实你可以把任务当做是通用OS中的内核线程,它们共享全局数据区,但都拥有自己的栈空间。独立的栈空间对于主要用于保存任务执行的上下文以及局部变量。
#ifndef TASK_H
#define TASK_H
#include
typedef uint32_t task_stack_t;
/*Task Control block*/
typedef struct task_tag {
task_stack_t *stack;
}task_t;
extern task_t *g_current_task;
extern task_t *g_next_task;
extern task_t *g_task_table[2];
extern void task_init (task_t * task, void (*entry)(void *), void *param, uint32_t * stack);
extern void task_sched(void);
extern void task_switch(void);
extern void task_run_first(void);
#endif /*TASK_H*/
###任务切换过程
先来谈一谈任务间切换的过程,两个任务切换过程原理很简单,分为两部分:
好,那我们先看一下任务切换的源代码。任务切换这一段代码必须使用汇编来写,所以将pendsv ISR放在cm3_s.s中实现。 代码很简单,首先判断PSP是否为0,如果是0的话说明是第一个任务启动,那么就不存在任务保存一说,所以第54行就跳转到恢复任务的代码,后续会看到第一个任务启动与其它任务切换稍有不同,会先设置PSP为0,当然也可以使用一个全局变量来标志是否是第一个任务启动,纯属个人喜好。
第61-64行就是将R0-R11保存到当前任务的栈空间中,然后将SP的值赋给任务控制块中的task_t.stack。这个就完成了整个任务的保存。
第69-73行是将g_next_task指向的任务赋值给g_current_task,然后从g_current_task中取出任务的栈指针。
第75-76行是将任务栈中所保存的R0-R11恢复到CM3的寄存器中。
第78行设置PSP为当前SP值,79行就直接切换到PSP去运行,需要注意的是,此时此刻的LR寄存器并不是返回地址,而是一个特殊的含义:
在出入ISR的时候,LR的值将得到重新的诠释,这种特殊的值称为“EXC_RETURN”,在异常进入时由系统计算并赋给LR,并在异常返回时使用它。EXC_RETURN的二进制值除了最低4位外全为1,而其最低4位则有另外的含义(后面讲到,见表9.3和表9.4)
位段 | 含义 |
---|---|
31:4 | EXC_RETURN标识:必须全为1 |
3 | 0=返回后进入Handler模式 1=返回后进入线程模式 |
2 | 0=从主堆栈中做出栈操作,返回后使用MSP, 1=从进程堆栈中做出栈操作,返回后使用PSP |
1 | 保留,必须为0 |
0 | 0=返回ARM状态。 1=返回Thumb状态。在CM3中必须为1 |
当执行完80行bx lr之后,硬件会自动恢复栈中的值到R0-R3,R12,LR,PC, XPSR。完成任务的切换 摘自《Cortex M3权威指南》
cm3_s.s
51 pendsv_handler:
52 /*CM3 will push the r0-r3, r12, r14, r15, xpsr by hardware*/
53 mrs r0, psp
54 cbz r0, pendsv_handler_nosave
55
56 /* g_current_task->psp-- = r11;
57 * ...
58 * g_current_task->psp-- = r4;
59 * g_current_task->stack = psp;
60 */
61 stmdb r0!, {r4-r11}
62 ldr r1, =g_current_task
63 ldr r1, [r1]
64 str r0, [r1]
65
66 pendsv_handler_nosave:
67
68 /* *g_current_task = *g_next_task */
69 ldr r0, =g_current_task
70 ldr r1, =g_next_task
71 ldr r2, [r1]
72 str r2, [r0]
73
74 /*r0 = g_current_task->stack*/
75 ldr r0, [r2]
76 ldmia r0!, {r4-r11}
77
78 msr psp, r0
79 orr lr, lr, #0x04 /*Swtich to PSP*/
80 bx lr
顺带就把触发任务切换(即触发PendSV)的函数讲了吧,task_run_first是在启动第一个任务的时候调用的,而task_switch是在已经有任务的情况下才会调用。所以task_run_first只会被调用一次,而后面的切换全都使用task_switch。两者唯一的区别在于task_run_first会设置PSP为0,缘由在上面已经讲过,PendSV会根据PSP是否为0判断是不是第一次启动任务。然后往NVIC_INT_CTRL这个寄存器里触发PendSV异常即可进行PendSV ISR完成任务切换
cm3.h
11 #define NVIC_INT_CTRL 0xE000ED04
12 #define NVIC_PENDSVSET 0x10000000
13 #define NVIC_SYSPRI2 0xE000ED22
14 #define NVIC_PENDSV_PRI 0x000000FF
task.c
43 void task_switch()
44 {
45 MEM32(NVIC_INT_CTRL) = NVIC_PENDSVSET;
46 }
47
48 void task_run_first()
49 {
50 DEBUG("%s\n", __func__);
51 set_psp(0);
52 MEM8(NVIC_SYSPRI2) = NVIC_PENDSV_PRI;
53 MEM32(NVIC_INT_CTRL) = NVIC_PENDSVSET;
54 }
在了解了任务切换的过程后,就知道去初始化任务了,首先任务需要一段自己栈空间,因此传入参数stack,然后任务有自己的函数入口地址,因此需要传入entry,entry需要param作为函数参数调用,然后每个任务对应一个task_t控制块。即使是没有运行过的任务,也需要经过任务切换(PendSV)的招待,也就是将任务栈中的上下文恢复到寄存器中。所以目前为止,任务初始化就是将相应的寄存器初始值手动PUSH到任务栈中。PC保存的是任务的入口函数,那么当下一次任务切换时,就能切换到entry函数里面执行。然后把param参数传入到entry里,因为R0是函数调用的第一个参数,所以需要把param压栈到R0的位置,最后将栈指针保存到task_t.stack中。
task.c
void task_init (task_t * task, void (*entry)(void *), void *param, uint32_t * stack)
{
DEBUG("%s\n", __func__);
*(--stack) = (uint32_t) (1 << 24); //XPSR, Thumb Mode
*(--stack) = (uint32_t) entry; //PC
*(--stack) = (uint32_t) 0x14; //LR
*(--stack) = (uint32_t) 0x12; //R12
*(--stack) = (uint32_t) 0x3; //R3
*(--stack) = (uint32_t) 0x2; //R2
*(--stack) = (uint32_t) 0x1; //R1
*(--stack) = (uint32_t) param; //R0
*(--stack) = (uint32_t) 0x11; //R11
*(--stack) = (uint32_t) 0x10; //R10
*(--stack) = (uint32_t) 0x9; //R9
*(--stack) = (uint32_t) 0x8; //R8
*(--stack) = (uint32_t) 0x7; //R7
*(--stack) = (uint32_t) 0x6; //R6
*(--stack) = (uint32_t) 0x5; //R5
*(--stack) = (uint32_t) 0x4; //R4
task->stack = stack;
}
那我们看一下应用程序是如何初始化task的。本章的应用只有两个任务进行来回切换,代码如下,首先定义了两个任务task1和task2,然后分别定义了两个task的栈以及入口函数,在main函数中调用task_init分别对两个任务进行初始化,然后将任务表的第0个元素指向task1,第1个元素指向task2, 如***图 双任务结构***所示一样。然后将下一个任务指向g_task_table[0],即task1,调用task_run_first,进行第一次任务切换,也就是启动第一个任务。
main.c
23 void task1_entry(void *param)
24 {
...
30 }
31
32 void task2_entry(void *param)
33 {
...
39 }
40
41 task_t task1;
42 task_t task2;
43 task_stack_t task1_stk[1024];
44 task_stack_t task2_stk[1024];
45
46 int main()
47 {
48
49 systick_t *systick_p = (systick_t *)SYSTICK_BASE;
50 clear_bss();
51
52 DEBUG("Hello RTOS\n");
53 DEBUG("psp:0x%x\n", get_psp());
54 DEBUG("msp:0x%x\n", get_msp());
55
56 task_init(&task1, task1_entry, (void *)0x11111111, &task1_stk[1024]);
57 task_init(&task2, task2_entry, (void *)0x22222222, &task2_stk[1024]);
58
59 g_task_table[0] = &task1;
60 g_task_table[1] = &task2;
61 g_next_task = g_task_table[0];
62
63 task_run_first();
64
65 for(;;);
66 return 0;
67 }
上述小节回答了任务是怎么切换的?那么本小节和下一章将说明任务是什么切换。在本章中所还未引入systick中断来处理任务的调度(即什么时候进行的切换)。为了给读者更直观的印象,本小节将在任务内部进行手动切换任务。首先看一下任务调度的源码,很简单。当前任务如果是g_task_table[0],那么下一个运行的任务就是g_task_table[1],反之一样,在分配好g_current_task和g_next_task后,调用task_switch进行任务的切换, 即进入PendSV ISR,上一小节已经分析过了PendSV ISR的代码。
task.c
32 void task_sched()
33 {
34 if (g_current_task == g_task_table[0]) {
35 g_next_task = g_task_table[1];
36 } else {
37 g_next_task = g_task_table[0];
38 }
39
40 task_switch();
41 }
main.c
18 void delay(uint32_t count)
19 {
20 while(--count > 0);
21 }
22
23 void task1_entry(void *param)
24 {
25 for(;;) {
26 printk("task1_entry\n");
27 delay(65536000);
28 task_sched();
29 }
30 }
31
32 void task2_entry(void *param)
33 {
34 for(;;) {
35 printk("task2_entry\n");
36 delay(65536000);
37 task_sched();
38 }
39 }
看一下任务内部做了什么?其实很简单,任务1打印了一句话,然后软件延时了一段时间,调用task_sched切换到任务2执行,任务2做相同的工作。这样就实现了连个任务之间来回切换工作,我们可以运行make run,看到运行结果如下所示。
在上一章中,我们实现了任务切换以及任务的调度。当时我们在任务中用到的延时函数是使用软件延时来做的,使用这种延时方式来做是有问题的。比如说当task1在执行软件延时时,task1是独占CPU的,这个时候其他的任务是没办法使用CPU的。而我们使用操作系统的原因之一就是想让CPU的利用率足够高,所以正确的情况应该是当task1调用延迟函数之后,task1应该将CPU使用权交给其他的task。本章就是讨论如何实现这样的任务延时函数。
在正式开始任务延时的话题前,我们需要先引入空闲任务(idle task)的概念,即所有的任务都暂停的时候,CPU干点什么事呢?不可能让CPU跑飞吧,所以此时引用idle task,让CPU运行idle task。当其他task被某一种情况唤醒,需要运行的时候,idle task就会交出的CPU的控制权给其他task。
Idle task的定义,初始化等与其他应用task并无差异,直接看代码。从idle_task_entry中就可以看出空闲任务其实不停地循环,直至被RTOS任务调度函数打断。空闲与其他的区别是不加入到任务表g_task_table[2]中,它有一个独立的指针g_idle_task。
task.c
8 static task_t g_idle_task_obj;
9 static task_t *g_idle_task;
10 static task_stack_t g_idle_task_stk[1024];
12 static void idle_task_entry(void *param)
13 {
14 for(;;);
15 }
110 void init_task_module()
111 {
112 task_init(&g_idle_task_obj, idle_task_entry, (void *)0, &g_idle_task_stk[1024]);
113 g_idle_task = &g_idle_task_obj;
114
115 }
任务延时最理想的实现情况是为一个任务分配一个硬件定时器,当硬件定时器完成定时后触发相应的中断来完成任务的调度。如下图所示,假设定时之前,当前任务是空闲任务,task1拥有硬件定时器1,task2拥有硬件定时器2,分别计数,当定时器1定时时间到,RTOS将当前任务g_current_task切换到任务1执行。
但这样存在的问题是,一般的SOC并不具备太多的硬件定时器,所以当任务达到几十甚至上百个的时候,这种是无法完成的。那就需要软件的方法来完成任务延时。各位看官应该记得CM3进阶章节中的systick定时器,任务延时就使用了这个定时器,我们只使用这一硬件定时器,然后给每一个任务分配一个软件计数器,当systick发生一次中断就对task中软件计数器减1,当某一个任务的软件计数器到时时,就触发一次任务调度。如下图所示:
在理解完使用软件定时器的原理后,我们直接看代码,实现在task_t中定义个字段delay_ticks用于软件计数。然后定义任务延时接口task_delay,其参数是delay_ticks个数,各位看官应该还记得之前systick是1s触发一次中断,所以这里1个delay_tick = 1s。最后定义task_system_tick_handler接口,该接口是被定期器中断函数调用,这是由于不同的芯片的定时器中断不同,所以这里定义一个统一接口让定时器中断函数调用,可以看到systick_handler中什么也没干,就是调用task_system_tick_handler。
task.h
8 typedef struct task_tag {
9
10 task_stack_t *stack;
11 uint32_t delay_ticks;
12 }task_t;
22 extern void task_delay(uint32_t ticks);
23 extern void task_system_tick_handler(void);
cm3.c
14 void systick_handler(void)
15 {
16 /*DEBUG("systick_handler\n");*/
17 task_system_tick_handler();
18 }
这个函数非常简单,仅仅只是对任务表中的delay_ticks进行赋值,然后触发一次任务调度。因为一旦有任务调用该接口,就说明当前任务需要延时不需要再占用CPU,所以需要触发一次任务调度。
task.c
92 void task_delay(uint32_t ticks)
93 {
94 g_current_task->delay_ticks = ticks;
95 task_sched();
96 }
这个函数就是遍历任务表g_task_table,对任务表中的每一个任务的delay_ticks减1,对应于上图中systick中断发生的时候,task1和task2的delay_ticks都会减1操作。前提是确保该task的delay ticks必须大于0才行,delay ticks大于0代表该任务有延时操作。在对所有任务的delay_ticks减1操作后,触发一次任务调度。
task.c
98 void task_system_tick_handler(void)
99 {
100 uint32_t i;
101 for (i = 0; i < 2; i++) {
102 if (g_task_table[i]->delay_ticks > 0) {
103 g_task_table[i]->delay_ticks--;
104 }
105 }
106
107 task_sched();
108 }
在引用空闲函数以及延时函数之后,需要对调度函数进行一些改造,代码如下,现在这个函数只是为了demo任务延时的缓兵之计,后续章节会对该函数进行大改。但在这里还是理解一下这个函数干了什么事。
44-50行处理当前任务是idle task时,分别判断任务表g_task_table是否有任务已经延时时间到,如果某一个任务延时时间到,那么将g_next_task指向该任务,然后调用task_switch进行任务切换,如果在任务表中没有任务延时时间到,那么就不需要进行任务切换,idle task继续运行。
53-58行处理当前任务是task1时,如果task2的延时时间到,那么就切换到task2中执行;如果task1的delay_ticks不为0,那么切换到idle task运行,这种情况实际上就是task1调用了task_delay函数触发的任务调度引起;如果两种都不是,那就不需要进行任务调度,还是继续运行task1。
61-68行处理当前任务是task2的情况,其逻辑跟task1一样,不再重复。
41 void task_sched()
42 {
43
44 if (g_current_task == g_idle_task) {
45 if (g_task_table[0]->delay_ticks == 0) {
46 g_next_task = g_task_table[0];
47 } else if (g_task_table[1]->delay_ticks == 0) {
48 g_next_task = g_task_table[1];
49 } else {
50 goto no_need_sched;
51 }
52 } else {
53 if (g_current_task == g_task_table[0]) {
54 if (g_task_table[1]->delay_ticks == 0) {
55 g_next_task = g_task_table[1];
56 } else if (g_current_task->delay_ticks != 0) {
57 g_next_task = g_idle_task;
58 } else {
59 goto no_need_sched;
60 }
61 } else if (g_current_task == g_task_table[1]) {
62 if (g_task_table[0]->delay_ticks == 0) {
63 g_next_task = g_task_table[0];
64 } else if (g_current_task->delay_ticks != 0) {
65 g_next_task = g_idle_task;
66 } else {
67 goto no_need_sched;
68 }
69 }
70 }
71
72
73 task_switch();
74
75 no_need_sched:
76 return;
77 }
首先在main函数要调用init_task_module()来初始化空闲任务idle task。然后将task1和task2中delay(65536000)改为task_delay。task1 延时一个tick(相当于1s),而task2延时5个tick,最后结果可以看到task1与task2交替执行,但task1打印5句时,task2才打印一句,这就证明延时函数工作了。
main.c
18 void task1_entry(void *param)
19 {
20 init_systick(1000);
21 for(;;) {
22 printk("%s\n", __func__);
23 task_delay(1);
24 }
25 }
26
27 void task2_entry(void *param)
28 {
29 for(;;) {
30 printk("%s\n", __func__);
31 task_delay(5);
32 }
33 }
40 int main()
41 {
...
56 init_task_module();
57
58 task_run_first();
...
61 return 0;
62 }
虽然从打印上来看,跟之前纯软件延迟差不太多,但其背后的原理是完全不同的。纯软件在延时不释放CPU,会使其他任务得不到CPU使用权,而调用task_delay接口,当前任务就会释放CPU使用权,RTOS会进行一次任务调度将CPU使用权交给其他任务。
总结第一天的如下:
第二天会涉及RTOS的内核核心实现,包括任务挂起/唤醒/删除,延时队列,临界区保护,优先级抢占调度及时间片调度。