遙想當年剛學習操作系統的時候,很難理解教科書中關於線程/進程的描述。原因還是在於操作系統書上的內容太過抽象,對於一個沒有看過內核代碼的初學者來說,很難理解各種數據結構的調度。後來自己也買了一些造輪子的書,照着好幾本書也造了幾個玩具操作系統,有X86,有ARM的。經過實踐之後回頭再去看操作系統的書,才恍然大悟操作系統書中所寫的知識點。
看了許多操作系統實踐類的書籍後,有些書只是淺嘗輒止,試圖用300頁將通用操作系統各個模塊都講了一遍,這一類書幫助讀者理解操作系統還是有限;而有些書寫的確實很不錯,內容詳實,然而動輒上千頁,讓讀者望而生畏,但是讀完並且照着書寫完一個玩具OS的話,絕對對OS的理解有很大幫助。這裏推薦鄭剛老師寫的《操作系統真相還原》,本人覺得這本書非常好,深入淺出。那我爲何還要寫這篇博客呢?我覺得操作系統內核最核心,且初學者最難理解的部分莫過於進程/線程(在RTOS中稱爲任務),所以本文試圖寫一個只有不到1000多行代碼的RTOS來幫助讀者理解操作系統核心部分。一般小型RTOS中並沒有虛擬內存管理,文件系統,設備管理等模塊,這樣減小讀者的負擔,更好理解操作系統的核心部分(進程/線程/任務),在這之後再去學習其他的模塊必然事半功倍。所以本文僅僅作爲一篇入門讀物,若是能幫助各位看官進入操作系統的大門,也算是功德無量。當然在下才疏學淺,難免有錯誤的地方,大神發現的話請指出。
話不多說,直接進入正題。
預備知識
雖然本文旨在一篇入門的教程,但希望讀者具有以下的預備知識,否則讀起來會有諸多不順。
- C語言,至少熟悉指針的用法
- ARM Cortex M3/M4架構(後面簡稱CM3)
如果沒有學習過ARM CM3的讀者,推薦閱讀CORTEX_M3權威指南,第一,二,三,四,五,六章。 - linux 操作,簡單的shell,Makefile即可
- RTOS 簡介
https://baike.baidu.com/item/%E5%AE%9E%E6%97%B6%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/357530?fr=aladdin&fromid=987080&fromtitle=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,否則可能會有一些環境的問題,概不負責。以下乃環境搭建參考步驟:
- git clone https://github.com/JiaminMa/write_rtos_in_3days.git
- vim ~/.bashrc
- export PATH=$PATH:/mnt/e/write_rtos_in_3days/tools, 這一步每個人的配置不一樣,要把write_rtos_in_3days/tools設置爲讀者自己的tools的目錄
- source ~/.bashrc
- 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的內核核心實現,包括任務掛起/喚醒/刪除,延時隊列,臨界區保護,優先級搶佔調度及時間片調度。