原始碼解讀·RT-Thread作業系統從開機到關機
本篇內容比較簡單,但卻很繁瑣,篇幅也很長,畢竟是囊括了整個作業系統的生命週期。這篇文章的目的是作為後續設計多工開發的鋪墊,後續會單獨再抽出一篇分析任務的相關知識。另外本篇文章以單核MCU為背景,並且以最新的3.1.xLTS版本原始碼進行分析。主要內容目錄如下:
-
基於bsp/stm32/stm32f103-mini-system為背景
-
Cortex-M3的堆疊基礎概念
-
C語言main函式和rt-thread的main
-
rt-thread作業系統的傳統初始化與自動初始化元件
-
任務是怎樣執行起來的
-
Idle任務與新的構想
基於bsp/stm32/stm32f103-mini-system的開機介紹
關於體系結構的知識這裡不做過多的介紹,因為這些知識要講清楚的話足以寫出一本大部頭的書出來。不過會簡單介紹一些必要的東西。
Stm32f103微控制器是cortex-m3核心,在cortex-m3核心中使用雙堆疊psp和msp,模式分為執行緒模式和handler模式,許可權級別分為非特權級別和特權級別(現在只需要知道這麼多就行了),handler模式就是當處理髮生中斷的時候自動進入的模式,其handler模式永遠為特權級。
上電開機最開始執行的是MCU內部的ROM部分,這部分程式碼我們通常看不到,其通常是對晶片進行必要的初始化,比如FLASH和RAM的時鐘初始化等,然後跳轉到使用者flash區域執行使用者程式碼。在STM32中使用者flash地址從0x08000000開始。我們寫的程式碼都是從這裡開始執行的。其次由於cortexM規定其使用者FLASH區域的最前面必須是一張中斷向量表。所以也就是說STM32的0x08000000開始是一張中斷向量表,這是必須的也是預設的,當然在之後還可以重對映其它地方的向量表。這張向量表中的第一項是一個棧地址,第二項復位向量地址。下面貼一段向量表部分程式碼(摘錄自startup_stm32f103xb.s):
__Vectors DCD __initial_sp ; Topof Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMIHandler DCD HardFault_Handler ; Hard Fault Handler DCD MemManage_Handler ; MPU Fault Handler DCD BusFault_Handler ; Bus Fault Handler DCD UsageFault_Handler ; Usage Fault Handler DCD 0 ; Reserved DCD 0 ; Reserved DCD 0 ; Reserved DCD 0 ; Reserved DCD SVC_Handler ; SVCall Handler DCD DebugMon_Handler ; Debug Monitor Handler DCD 0 ; Reserved DCD PendSV_Handler ; PendSV Handler DCD SysTick_Handler ; SysTick Handler
另外需要注意的是開機後會自動進入復位異常,通常我們叫上電覆位過程,不過意外的是上電覆位處理的模式是特權級執行緒模式。在特權模式下堆疊指標將使用MSP,非特權模式下可以被切換到PSP。RT-Thread作業系統就是這麼做的。所以回過頭來看,中斷向量表第一項指定了MSP的棧起始地址,並被自動載入到MSP,第二項指定了復位向量地址,也被自動載入到PC並執行。這樣一來開機後我們能通過debug看到PC指標最先指向復位向量的第一條指令上。我們看一下stm32f103在armcc編譯器上的復位向量程式碼:
; Reset handler Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP
這是一段彙編程式碼,其完成兩件事,第一件事呼叫systemInit函式完成一些初始化,第二件事跳轉到__main函式。其中systemInit函式我們是可以找到並可以修改的一個C語言實現的函式(暫時不討論,有興趣的可以看system_stm32f1xx.c)。而這個__main就牛逼了,這既不是我們自己寫的C語言的main也看不到它在哪裡實現的。但是現在進入__main後它就是會跑到你最終用C語言寫的main。這個__main的來龍去脈稍後會在第三部分分析。
Cortex-M3的堆疊基礎概念
在Cortex-M3的處理器核心上堆疊指標分為PSP和MSP。handler模式下總是使用MSP,執行緒模式可以通過CONTROL暫存器來配置(修改的時候必須處於特權模式才可以)。
之所以需要這樣設計就是為了將普通軟體和系統軟體通過許可權隔離開,避免普通使用者許可權作業系統關鍵資源帶來安全風險。當我們使用帶有作業系統的環境進行開發時,作業系統就會將關鍵操作例如任務切換、中斷處理等在特權模式操作。而其它的操作都會執行在非特權模式下完成。
作業系統一般都會將必要的操作封裝出API介面,以提供給普通軟體呼叫。而這背後的設計思想就是通過觸發異常,然後進入特權模式執行異常向量處理程式。而這段異常處理程式早就讓作業系統實現了,進而這部分特權操作是作業系統接管處理的。這也就避免使用者普通軟體去進行不必要的特權操作。例如使用者任務想主動放棄CPU從而呼叫yield,yield將進行任務切換,其中過程大概是“選出另一個任務”->”觸發SVC或者Pendsv異常”->進入SVC/Pendsv的handler異常處理程式,此時是特權模式,完成操作後返回到新任務執行。在RT-Thread中進入任務切換是通過觸發Pendsv異常。
C語言main函式和RT-Thread的main
前面提到過開機啟動最後進入復位向量處執行,最終呼叫__main就跑到我們外面寫的C語言的main函數了。但這並非這麼簡單,在從__main到我們的main中間還有一系列操作比如初始化堆疊、初始化全域性變數區域、初始化C執行時庫等,然後再在最後呼叫使用者的main函式。
不過在不同的編譯器上這個__main並非是固定的,這裡也就armcc是如此,如果是GCC和IAR的話其就不太一樣,不過不影響我們分析核心主題。這裡僅以借用armcc為例來分析主題中心思想。另外在說明RT-Thread中開啟RT_USING_USER_MAIN的時候在ARMCC編譯器上還有一個支援掛鉤的操作,這種操作一般見於補丁修復的時候。其實現方式是在原有函式的名字前加上$Sub$$
字首就可以將原有函式劫持下來,並通過加上$Super$$
字首再呼叫原始函式。具體如下:The followingexample shows how to use $Super$$and $Sub$$ to insert a callto the function ExtraFunc() before the call to the legacy functionfoo().
extern void ExtraFunc(void); extern void $Super$$foo(void); /* this functionis called instead of the original foo() */ void $Sub$$foo(void) { ExtraFunc(); /* does some extra setup work */ $Super$$foo(); /* calls the original foo() function */ /* To avoid calling the original foo() function * omit the $Super$$foo(); function call. */ }
上例中原本有一個原始函式叫做foo,但是現在通過$Sub$$foo來劫持所有呼叫foo的地方,自動會呼叫$Sub$$foo,然後新的$Sub$$foo裡面先呼叫自己的擴充套件實現ExtraFunc後,再接著呼叫原始版本的foo函式,不過呼叫原始的foo是加了字首$Super$$的$Super$$foo.
當使用RT-Thread作業系統開啟RT_USING_USER_MAIN後就是利用這種騷操作來完成RT-Thread作業系統的初始化過程的。(程式碼摘錄自components.c)
extern int $Super$$main(void); /* re-definemain function */ int $Sub$$main(void) { rtthread_startup(); return 0; }
關於rtthread_startup函式稍後再講解,不過先接著看下面這個函式:
/* the systemmain thread */ void main_thread_entry(void*parameter) { extern int main(void); extern int $Super$$main(void); /* RT-Thread components initialization*/ rt_components_init(); /* invoke system main function */ #if defined(__CC_ARM) || defined(__CLANG_ARM) $Super$$main(); /* for ARMCC. */ #elif defined(__ICCARM__) || defined(__GNUC__) main(); #endif }
上面這個函式其實是個小任務,就是完成元件初始化後再跳轉到使用者main函式的。這個小任務在rtthread_startup中呼叫rt_application_init時建立的,所以此時rt-thread系統早就以經跑起來了。也就是說當呼叫rtthread_startup後正常情況就不再會返回到原來的呼叫地方,接下來會交給系統的排程器去接管,切換執行任務去了。看下面的程式碼瞭解rt_application_init:
void rt_application_init(void) { rt_thread_t tid; #ifdef RT_USING_HEAP tid = rt_thread_create("main", main_thread_entry, RT_NULL, RT_MAIN_THREAD_STACK_SIZE, RT_MAIN_THREAD_PRIORITY, 20); RT_ASSERT(tid != RT_NULL); #else rt_err_t result; tid = &main_thread; result = rt_thread_init(tid, "main", main_thread_entry, RT_NULL, main_stack, sizeof(main_stack), RT_MAIN_THREAD_PRIORITY, 20); RT_ASSERT(result == RT_EOK); /* if not define RT_USING_HEAP, using toeliminate the warning */ (void)result; #endif rt_thread_startup(tid); }
至此,關於各種main的子子孫孫以經差不多瞭解清楚了,其流程大概如下:
ResetHandle->__main->$Sub$$main->(rtthread_startup->rt_application_init->main_thread_entry)->$Super$$main。
其中$Super$$main就是我們的使用者main函式。如果沒有啟用RT_USING_USER_MAIN那就簡單了,其流程如下:
ResetHandle->__main->main
接下來再接著分析$Sub$$main中呼叫的rtthread_startup函式。
RT-Thread作業系統的傳統初始化與自動初始化元件
這裡著重討論rtthread_startup函式,因為這就是RT-Thread作業系統的入口和初始化流程。不過既然說到rtthread_startup函數了,就不得不一起介紹一下RT-Thread作業系統的自動初始化元件了。
rtthread_startup函式是一個函式呼叫鏈,依次呼叫各個階段的初始化函式,並在最後啟動排程器不再返回。程式碼摘錄自components.c
int rtthread_startup(void) { rt_hw_interrupt_disable(); /* board level initialization * NOTE: please initialize heap insideboard initialization. */ rt_hw_board_init(); /* show RT-Thread version */ rt_show_version(); /* timer system initialization */ rt_system_timer_init(); /* scheduler system initialization */ rt_system_scheduler_init(); #ifdef RT_USING_SIGNALS /* signal system initialization */ rt_system_signal_init(); #endif /* create init_thread */ rt_application_init(); /* timer thread initialization */ rt_system_timer_thread_init(); /* idle thread initialization */ rt_thread_idle_init(); /* start scheduler */ rt_system_scheduler_start(); /* never reach here */ return 0; }
以上程式碼我們主要脈絡是這樣的:先關閉全域性中斷->初始化硬體板上的資源->列印RT-Thread的LOGO->系統定時器功能初始化->排程器初始化->signal功能初始化->應用程式初始化(這個通常是用來建立使用者任務的)->系統軟timer任務初始化->系統idle任務初始化->啟動排程器,永遠不再返回。
這裡我們先來說一下為什麼要先關閉全域性中斷,因為在初始化過程中,有可能MCU就有其它的中斷和異常觸發了,這個時候系統還沒有初始化完成,這就勢必導致系統出現故障,所以先關閉全域性中斷,並在啟動排程器後再開啟。
rt_hw_board_init非常關鍵,在這個函式裡面必須完成一些必須的初始化過程:堆記憶體系統的初始化和硬體資源模組以及如果開啟了自動初始化元件時還需要呼叫rt_components_board_init完成必要的初始化,這個函式是自動初始化元件的一個介面。(程式碼摘錄自bsp\stm32\libraries\HAL_Drivers\drv_common.c)
RT_WEAK void rt_hw_board_init() { #ifdef SCB_EnableICache /* EnableI-Cache---------------------------------------------------------*/ SCB_EnableICache(); #endif #ifdef SCB_EnableDCache /* Enable D-Cache---------------------------------------------------------*/ SCB_EnableDCache(); #endif /* HAL_Init() function is called at thebeginning of the program */ HAL_Init(); /* System clock initialization */ SystemClock_Config(); rt_hw_systick_init(); /* Heap initialization */ #if defined(RT_USING_HEAP) rt_system_heap_init((void*)HEAP_BEGIN, (void*)HEAP_END); #endif /* Pin driver initialization is open bydefault */ #ifdef RT_USING_PIN rt_hw_pin_init(); #endif /* USART driver initialization is openby default */ #ifdef RT_USING_SERIAL rt_hw_usart_init(); #endif /* Set the shell console output device*/ #ifdef RT_USING_CONSOLE rt_console_set_device(RT_CONSOLE_DEVICE_NAME); #endif /* Board underlying hardwareinitialization */ #ifdef RT_USING_COMPONENTS_INIT rt_components_board_init(); #endif }
然後回到rtthread_startup函式中再看rt_application_init函式,由於我們是用的stm32的BSP,這個bsp系列是使用自動初始化元件和RT_USING_USER_MAIN功能的,所以過程稍微隱蔽一些,先是在rt_application_init中建立了一個小任務,然後再在小任務中呼叫了rt_components_init,這也是自動初始化元件的介面。如果沒有開啟自動初始化元件的話,通常我們的使用者任務可以在rt_application_init中建立了。也可以像這裡的實現一樣,先建立一個小任務,然後再在小任務裡完成一些初始化和建立使用者任務。
然後再回到rthtread_startup中看到有初始化軟timer和idle任務的,其中軟體timer功能是可以通過裁剪配置選擇的,如果開啟後就可以在後續建立softtimer。否則所有的timer都會在OS TICK的中斷上下文中計時。另外這個idle任務也是系統中必不可少和優先順序最低的任務。即使我們啟動排程器後沒有建立任何使用者任務,系統中也有一個idle任務在執行。Idle任務的優先順序最低,在此我建議開發人員最好不要將自己的使用者任務優先順序配置成最低以免和idle競爭時間片,這會給你今後的開發帶來不必要的麻煩。關於這個問題,我最後會提出一些新的設計構想。不過這裡先要介紹一下idle任務的功能。Idle任務會在系統空閒時被排程執行,所以我們通常在idle任務裡做低功耗設計。其次idle任務裡還會完成系統資源的回收。例如被刪除的任務,被刪除的module等。
最後rthtread_startup啟動排程器rt_system_scheduler_start開始排程系統的任務,從此就開始執行任務,不再返回。這裡又要記住一個概念,在上文提到的PSP和MSP,到目前為止MCU還是使用一開始中斷向量表中指定的MSP棧。但是當排程任務後,任務會有自己的棧,且rt-thread系統會將任務的棧切換到PSP棧指標。值得注意的是,這個MSP是全域性共享的,所有的中斷程式都會使用這個棧空間,所以我們需要根據自己的情況來配置這個MSP棧的空間大小。
接下來我們再來介紹自動初始化元件。RT-Thread中的自動初始化元件思路來自於Linux核心。其實現手段是將需要初始化的函式介面通過連結器指令放在特殊的section中。這個section的概念是當我們程式最終連結成一個image後會形成一個標準格式的檔案,其中armcc中叫做ARM ELF。詳細的介紹可以查閱官方資料。其中ELF檔案就有將程式碼分成稱為section的區域,可以稱作段。並且可以指定自己的程式碼放在指定名稱的段中,且可以指定這個section段的ROM地址。這樣當我們設計玩初始化介面後,通過連結器的指令以及連結指令碼檔案將我們的初始化程式碼放在特定的地方,並且利用命名規則來做到順序排序。等需要呼叫初始化的時候可以利用這些section的地址轉換成函式指標直接批量迴圈呼叫。通常你會在MDK的工程檔案連結器引數中看到這樣的指令:--keep *.o(.rti_fn.*),這是為了在連結階段保證這些自定義段不被刪除。同時也可以看出rti_fn就是自動初始化元件的section名字。類似的將函式放置在這些段中的連結器指令如下:(摘錄自rtdef.h)
/*initialization export */ #ifdef RT_USING_COMPONENTS_INIT typedef int (*init_fn_t)(void); #ifdef _MSC_VER/* we do notsupport MS VC++ compiler */ #define INIT_EXPORT(fn,level) #else #if RT_DEBUG_INIT struct rt_init_desc { const char* fn_name; const init_fn_t fn; }; #define INIT_EXPORT(fn, level) \ const char __rti_##fn##_name[] =#fn; \ RT_USED const struct rt_init_desc __rt_init_desc_##fn SECTION(".rti_fn."level)=\ { __rti_##fn##_name, fn}; #else #define INIT_EXPORT(fn, level) \ RT_USED const init_fn_t __rt_init_##fn SECTION(".rti_fn."level)= fn #endif #endif #else #define INIT_EXPORT(fn, level) #endif /* board initroutines will be called in board_init() function */ #define INIT_BOARD_EXPORT(fn) INIT_EXPORT(fn,"1") /*pre/device/component/env/app init routines will be called in init_thread */ /* componentspre-initialization (pure software initilization) */ #define INIT_PREV_EXPORT(fn) INIT_EXPORT(fn,"2") /* deviceinitialization */ #define INIT_DEVICE_EXPORT(fn) INIT_EXPORT(fn,"3") /* componentsinitialization (dfs, lwip, ...) */ #define INIT_COMPONENT_EXPORT(fn) INIT_EXPORT(fn,"4") /* environmentinitialization (mount disk, ...) */ #define INIT_ENV_EXPORT(fn) INIT_EXPORT(fn,"5") /* appliationinitialization (rtgui application etc ...) */ #define INIT_APP_EXPORT(fn) INIT_EXPORT(fn,"6")
其中不同的數字代表不同的初始化順序,可以根據需要來選擇。接著如上文提到的兩個函式rt_components_board_init和rt_components_init是如何實現的:摘錄自components.c
#ifdef RT_USING_COMPONENTS_INIT /* * Components Initialization will initializesome driver and components as following * order: * rti_start --> 0 * BOARD_EXPORT --> 1 * rti_board_end --> 1.end * * DEVICE_EXPORT --> 2 * COMPONENT_EXPORT --> 3 * FS_EXPORT --> 4 * ENV_EXPORT --> 5 * APP_EXPORT --> 6 * * rti_end --> 6.end * * These automatically initialization, thedriver or component initial function must * be defined with: * INIT_BOARD_EXPORT(fn); * INIT_DEVICE_EXPORT(fn); * ... * INIT_APP_EXPORT(fn); * etc. */ static int rti_start(void) { return 0; } INIT_EXPORT(rti_start,"0"); static int rti_board_start(void) { return 0; } INIT_EXPORT(rti_board_start,"0.end"); static int rti_board_end(void) { return 0; } INIT_EXPORT(rti_board_end,"1.end"); static int rti_end(void) { return 0; } INIT_EXPORT(rti_end,"6.end"); /** * RT-Thread Components Initialization forboard */ void rt_components_board_init(void) { #if RT_DEBUG_INIT int result; const struct rt_init_desc *desc; for (desc = &__rt_init_desc_rti_board_start; desc < &__rt_init_desc_rti_board_end; desc ++) { rt_kprintf("initialize %s", desc->fn_name); result = desc->fn(); rt_kprintf(":%d done\n", result); } #else const init_fn_t *fn_ptr; for (fn_ptr = &__rt_init_rti_board_start; fn_ptr < &__rt_init_rti_board_end; fn_ptr++) { (*fn_ptr)(); } #endif } /** * RT-Thread Components Initialization */ void rt_components_init(void) { #if RT_DEBUG_INIT int result; const struct rt_init_desc *desc; rt_kprintf("do components initialization.\n"); for (desc = &__rt_init_desc_rti_board_end; desc < &__rt_init_desc_rti_end; desc++) { rt_kprintf("initialize %s", desc->fn_name); result = desc->fn(); rt_kprintf(":%d done\n", result); } #else const init_fn_t *fn_ptr; for (fn_ptr = &__rt_init_rti_board_end; fn_ptr < &__rt_init_rti_end; fn_ptr++) { (*fn_ptr)(); } #endif }
之所以要分開這兩個函式就是因為board階段的初始化比其它普通的元件初始化早,board階段的初始化通常沒什麼系統資源依賴。而其它情況下則通常在作業系統已經完成必要的初始化後才能做的初始化才會放在rt_components_init裡。
任務是怎樣執行起來的
要說明任務是怎麼執行起來的,就得知道任務是怎麼建立的,其次結合之前寫的文章<原始碼解讀·RT-Thread多工排程演算法>就差不多了。那麼這裡就介紹一下任務的建立。照樣用上面的rt_application_init裡建立任務的程式碼來舉例:
void rt_application_init(void) { rt_thread_t tid; #ifdef RT_USING_HEAP tid = rt_thread_create("main", main_thread_entry, RT_NULL, RT_MAIN_THREAD_STACK_SIZE, RT_MAIN_THREAD_PRIORITY, 20); RT_ASSERT(tid != RT_NULL); #else rt_err_t result; tid = &main_thread; result =rt_thread_init(tid, "main", main_thread_entry, RT_NULL, main_stack, sizeof(main_stack),RT_MAIN_THREAD_PRIORITY, 20); RT_ASSERT(result == RT_EOK); /* if not define RT_USING_HEAP, using toeliminate the warning */ (void)result; #endif rt_thread_startup(tid); }
首先要說明的是RT-Thread任務建立有兩種,一種是動態的,一種是靜態的。所謂的動態就是其任務棧自動在堆記憶體中分配;靜態是使用者自己指定棧空間,當然通常這個棧來自於使用者定義的陣列。如上例中當RT_USING_HEAP巨集被開啟,也就是有堆記憶體的時候會採用rt_thread_create介面來建立動態資源的任務。當然可以利用rt_thread_init來建立一個靜態資源的任務。先來了解一下這兩個函式在建立任務時的一些引數:”main”這是任務的名稱,任務名稱用一個字串來指定,不是很重要,不過最好能起到一定的說明性,有利於今後除錯用。main_thread_entry這是任務的入口函式,所謂的任務就是一個C語言中的函式而已。RT_NULL,這是傳給任務入口函式的引數,如果沒有就為NULL.因為RT_Thread中的任務原型為:void (*entry)(void*parameter);RT_MAIN_THREAD_STACK_SIZE為任務的棧大小,以位元組為單位。RT_MAIN_THREAD_PRIORITY為任務的優先順序號。20為任務的時間片大小。其中靜態任務中還有tid代表任務的TCB資料結構控制代碼。main_stack為棧空間起始地址。當用動態建立的方法建立成功後會返回一個任務的TCB任務控制代碼出來。之後我們利用rt_thread_startup(任務控制代碼)的形式啟動任務即可。例如上例中rt_thread_startup(tid);不過rt_thread_startup函式真正的功能是將任務放置於排程佇列中,並置任務狀態為ready,由此交給排程器去排程,能不能立馬執行取決與排程器的排程。一般情況下,要想任務獲得執行必須滿足的條件:排程器已經執行,任務已經ready,沒有更高優先順序任務,沒有中斷髮生。只要條件滿足排程器就會排程此任務,做好必要的棧初始化和狀態置位,就會切換到任務開始執行。只要任務獲得執行就會使用建立任務時指定的棧空間。
不過一般的任務通常是一直執行,持續的服務。形式如下:
void task(void *parameter) { while (1) { // do_work(); } }
idle任務與新的構想
上面解釋過idle任務在rt-thread作業系統中的功能:釋放資源、低功耗設計。
關於資源釋放通常是任務的析構過程,這就是任務的結束。例如上例中的main_thread_entry任務之所以稱為小任務的原因就是它做完事情就結束了。那麼可能就會想,既然任務都結束了那麼它的資源如何釋放呢?比如棧空間,TCB等。這就是idle該乾的事情。即使所有的使用者任務都結束,最後也會剩下idle任務在執行。如果有必要的話,可以在idle任務中可以通過呼叫低功耗元件進入低功耗或者乾脆呼叫電源開關控制來關機。
其次idle任務佔用了最低優先順序。雖然使用者任務也可以使用和idle任務相同的優先順序,但是並不建議這樣做,比如在低功耗設計時就會出問題。另外我個人在思考一個問題,idel任務既然以經在設計之初就明確了其獲得執行的條件,那麼何不做成無需優先順序的任務,唯一的排程決策就是:當排程器沒有任務處於ready狀態時就切換到idel任務執行。這就無需關注最低優先順序被idle霸佔的問題了。
感謝各位網友的支援,可以關注我的微信公眾號:鵬城碼夫 (微訊號:rocotona)