本篇內容比較簡單,但卻很繁瑣,篇幅也很長,畢竟是囊括了整個操作系統的生命周期。這篇文章的目的是作為後續設計多任務開發的鋪墊,後續會單獨再抽出一篇分析任務的相關知識。另外本篇文章以單核MCU為背景,並且以最新的3.1.xLTS版本源碼進行分析。主要內容目錄如下: 基於bsp/stm32/stm32f1 ...
本篇內容比較簡單,但卻很繁瑣,篇幅也很長,畢竟是囊括了整個操作系統的生命周期。這篇文章的目的是作為後續設計多任務開發的鋪墊,後續會單獨再抽出一篇分析任務的相關知識。另外本篇文章以單核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
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)