自制嵌入式操作系统 DAY1

遥想当年刚学习操作系统的时候,很难理解教科书中关于线程/进程的描述。原因还是在于操作系统书上的内容太过抽象,对于一个没有看过内核代码的初学者来说,很难理解各种数据结构的调度。后来自己也买了一些造轮子的书,照着好几本书也造了几个玩具操作系统,有X86,有ARM的。经过实践之后回头再去看操作系统的书,才恍然大悟操作系统书中所写的知识点。

看了许多操作系统实践类的书籍后,有些书只是浅尝辄止,试图用300页将通用操作系统各个模块都讲了一遍,这一类书帮助读者理解操作系统还是有限;而有些书写的确实很不错,内容详实,然而动辄上千页,让读者望而生畏,但是读完并且照着书写完一个玩具OS的话,绝对对OS的理解有很大帮助。这里推荐郑刚老师写的《操作系统真相还原》,本人觉得这本书非常好,深入浅出。那我为何还要写这篇博客呢?我觉得操作系统内核最核心,且初学者最难理解的部分莫过于进程/线程(在RTOS中称为任务),所以本文试图写一个只有不到1000多行代码的RTOS来帮助读者理解操作系统核心部分。一般小型RTOS中并没有虚拟内存管理,文件系统,设备管理等模块,这样减小读者的负担,更好理解操作系统的核心部分(进程/线程/任务),在这之后再去学习其他的模块必然事半功倍。所以本文仅仅作为一篇入门读物,若是能帮助各位看官进入操作系统的大门,也算是功德无量。当然在下才疏学浅,难免有错误的地方,大神发现的话请指出。

话不多说,直接进入正题。

预备知识

虽然本文旨在一篇入门的教程,但希望读者具有以下的预备知识,否则读起来会有诸多不顺。

源码GIT

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,否则可能会有一些环境的问题,概不负责。以下乃环境搭建参考步骤:

  1. git clone https://github.com/JiaminMa/write_rtos_in_3days.git
  2. vim ~/.bashrc
  3. export PATH=$PATH:/mnt/e/write_rtos_in_3days/tools, 这一步每个人的配置不一样,要把write_rtos_in_3days/tools设置为读者自己的tools的目录
  4. source ~/.bashrc
  5. sudo apt-get install gcc-arm-none-eabi

1 QEMU ARM CORTEX M3入门

qemu-system-arm对于CORTEX M的芯片官方只支持了Stellaris LM3S6965EVB和Stellaris LM3S811EVB,本文使用了LM3S6965EVB作为开发平台。非官方的有STM32等其他CM3/4的芯片及开发板,但这里选用官方的支持更稳定一些。我在doc目录下放了LM3S6965的芯片手册,感兴趣的读者可以自己看,实际上本文在写嵌入式操作系统中,除了UART并没有使用到LM3S6965的外设,大部分代码都是针对ARM CM3内核的操作,所以并不需要对LM3S6965EVB很清楚。

打印Hello World

没错,本章就是要在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
};

main函数

CM3内核从异常向量表中取出MSP,然后设置MSP后就跳到reset向量中,在这里是main函数,其启动过程如下图所示。main函数的实现在main.c中,源代码如下,非常简单,往串口数据寄存器中写数据打印Hello World,然后就while(1)循环。由于这是QEMU虚拟机,所以并不需要对串口进行初始化等操作,直接往DR寄存器里写数据即可打印出字符,在真实的硬件这么做是不行的,必须初始化串口的时钟已经相应的寄存器来配置其工作模式。

main.c

#include <stdint.h>
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

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调试,如下图所示

CM3进阶

本节代码在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

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函数主要完成以下两点:
- 清0 BSS段
BSS段里存放的是未初始化的全局变量以及静态变量,内存在真实的物理硬件上上电后是随机值,所以需要对BSS段中的数据清0,以免发生不测。当然在虚拟机上,未曾使用的内存应该是0,但为了规范起见,还是将bss清0。
- 使能systick
systick是CM3的内核组件,其初始化的代码在cm3.c中实现,在下个小节讲解,本小节只讲解main函数的改变。systick_handler是systick的中断服务程序,在main.c中实现,每当systick中断发生时,就会进入到systick_handler中执行相关代码,在这里只是打印一句话。

main.c

#include "os_stdio.h"
#include <stdint.h>
#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使能

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 <stdint.h>

#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;
}

printk/DEBUG打印实现

有了串口打印之后,实现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一秒打印一次如下图。

2 RTOS初探:任务切换

在上述简单讲了CM3的启动以及systick组件后,终于可以上硬菜了。好了,本节主要探讨两个问题:
1. 任务是怎么切换的?
2. 任务是什么切换的?

本节代码位于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中的内核线程,它们共享全局数据区,但都拥有自己的栈空间。独立的栈空间对于主要用于保存任务执行的上下文以及局部变量。

图 双任务结构

include/task.h

#ifndef TASK_H
#define TASK_H

#include <stdint.h>

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*/

任务切换过程

先来谈一谈任务间切换的过程,两个任务切换过程原理很简单,分为两部分:
- 保存当前任务的寄存器
本文中使用CM3的PendSV来实现了任务切换的功能。CM3处理异常/中断时,硬件会把R0-R3,R12,LR,PC, XPSR自动压栈。然后由PendSV的中断服务程序(后面简称PendSV ISR)手动把R4-R11寄存器压入任务栈中,这样就完成了任务上下文的保存。
- 恢复下一个任务的寄存器(包含PC),当恢复PC时就能跳转到任务被打断的地方继续执行。
恢复过程正好与保存过程相反,PendSV ISR会先手动地将R4-R11恢复到CM3中,然后在PendSV ISR退出时,CM3硬件会自动将R0-R3,R12,LR,XPSR恢复到CM3的寄存器中。
如下图所示,便是任务切换的过程:(注:图中任务恢复的<—-SP慢了一拍,看官注意下就好了,不想重画动态图了,图层太多了)

好,那我们先看一下任务切换的源代码。任务切换这一段代码必须使用汇编来写,所以将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 }

NVIC_INT_CTRL寄存器片段

任务初始化

在了解了任务切换的过程后,就知道去初始化任务了,首先任务需要一段自己栈空间,因此传入参数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,看到运行结果如下所示。

3 任务延时

在上一章中,我们实现了任务切换以及任务的调度。当时我们在任务中用到的延时函数是使用软件延时来做的,使用这种延时方式来做是有问题的。比如说当task1在执行软件延时时,task1是独占CPU的,这个时候其他的任务是没办法使用CPU的。而我们使用操作系统的原因之一就是想让CPU的利用率足够高,所以正确的情况应该是当task1调用延迟函数之后,task1应该将CPU使用权交给其他的task。本章就是讨论如何实现这样的任务延时函数。

空闲任务Idle 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 }

task_delay接口实现

这个函数非常简单,仅仅只是对任务表中的delay_ticks进行赋值,然后触发一次任务调度。因为一旦有任务调用该接口,就说明当前任务需要延时不需要再占用CPU,所以需要触发一次任务调度。
task.c

 92 void task_delay(uint32_t ticks)
 93 {
 94     g_current_task->delay_ticks = ticks;
 95     task_sched();
 96 }

task_system_tick_handler接口实现

这个函数就是遍历任务表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 }

任务调度函数task_sched改动

在引用空闲函数以及延时函数之后,需要对调度函数进行一些改造,代码如下,现在这个函数只是为了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使用权交给其他任务。

DAY1总结

总结第一天的如下:
1. 环境搭建
2. QEMU CM3仿真:UART打印,systick,gdb调试
3. RTOS基础:任务切换/任务调度/任务延时简单实现(基于双任务及空闲任务)。

第二天会涉及RTOS的内核核心实现,包括任务挂起/唤醒/删除,延时队列,临界区保护,优先级抢占调度及时间片调度。

猜你喜欢

转载自blog.csdn.net/u011280717/article/details/79337791