第二章
多任務的原理
在開始寫操作系統之前需要理解一個問題,一個cpu是如何做到“同時”做多個事情的,比如同一時間又亮燈又檢測按鍵又串口輸出。其實它不能,一個cpu在一個時間點只能執行一條指令,無法同時執行多個指令。但是從現象上看又確實是同時進行的,這是因爲cpu執行指令比人類快很多,他在人類的一瞬間執行了多個指令,當然,這幾個指令實現多個功能的時候就好像在這一瞬間這幾個功能在同時執行。
好了,到目前爲止我們發現一般的前後臺方法也同樣能實現上面說的效果。如下結構:
For(;;)
{
Do_led();
Do_check_key();
Do_uart_write();
}
這樣做也能看起來是同時在做,但是有一個問題無法避免,比如這個函數中如果有長時間佔用的話就可能造成實時性降低,如果do_uart_write中如果消耗100ms,那麼其他兩個函數的執行都要至少間隔100ms,對於按鍵檢測來說最爲明顯,可能100ms檢測一次就會漏掉一些按鍵事件。
好了,當我們知道了前後臺結構的弊端的時候就會想辦法來避免這種問題。
下面就開始說說操作系統的多任務實現方法。首先一個任務,可以是一個函數,當有多個任務,也就是有多個函數需要同時運行,而不是依次的運行。也就是函數在運行的過程中需要能夠被打斷。在單片機編程中,有一種方法是可以實現這個功能的,對的,那就是中斷,中斷就是在函數運行的任何時間都可以打斷當前運行的函數,進入到中斷處理函數中。在中斷退出的時候被打斷的函數還能繼續運行,這不就是我們需要的功能嗎。這個是最直白的打斷恢復機制了,我們可以擴展一下,普通函數是否能夠打斷普通函數呢?當然可以,比如我們在函數A中調用函數B,在運行到B的時候是不是就是B打斷了A呢。而在B退出後A也會繼續的運行,當然了,這種情況與中斷的方法有一個不同就是這種情況打斷和恢復的時機是被確定的。但是從本質來說是一樣的,都是打斷了之前的函數,運行了新的函數,並且在新的函數退出後繼續運行之前的函數。
好了,既然能夠找到方法就是研究這種方法實際上做了什麼事情,實現如何能夠被我們來應用。
首先,我們所編寫的代碼,無論是C還是彙編都是不能被機器直接執行的,需要轉爲對應的機器指令,我們的一條命令,可能會被轉爲一條或者多條機器碼,這就是編譯過程,編輯之後生成一個bin或hex文件,將這個文件燒寫到單片機的flash上,在單片機運行的時候會逐條從flash上讀取指令並執行。那麼單片機是如何知道即將執行的下一條指令是什麼呢?(我們假設可以理解第一條指令是如何被執行的,第一條單片機規定到一個固定地址去取指令,如0地址爲單片機啓動的第一個條指令地址),有一個PC的指針寄存器,這個寄存器用來記錄下一條指令位置,假如單片機第一個指令在0地址位置,在0地址的指令爲跳轉到0xA0地址指令,那麼下一條指令的位置就是0xA0,PC中的值就是0xA0,在第二個指令週期中就會執行0xA0中的指令,比如0xA0中是一個將1賦值給R1寄存器的指令,沒有跳轉的意思,那麼下一條指令就順序向下執行,PC就是0xA1。
那麼當我們在運行一個函數的時候其實單片機早就已經知道了函數的“劇本”,他只是按照劇本的順序在演出而已。那麼打斷函數這個事情好像就可以做到了,也就是在需要打斷並且跳轉到其他函數的時候只需要修改PC指針的值不就行了嗎?但是單片機並沒有給我們權限去修改PC指針那麼問題來了,單片機本身的中斷跳轉是如何做到的呢?這裏就要引入一個棧的概念。
單片機的程序是存儲在ROM中,運行過程中的數據則存儲在RAM中,對於數據分爲全局變量和局部變量,還有靜態變量,單片機在編譯的時候會爲這些變量分配不同的數據區在存放,全局變量和靜態變量放在靜態存儲區中,局部變量放在堆棧存儲區中,堆棧存儲區也會存放函數跳轉的PC指針,這也就是俗稱的保護現場,當函數跳轉回來的時候也會從堆棧中恢復PC指針使程序能夠繼續運行,也是俗稱恢復現場。
單片機是如何訪問堆棧的呢?是否我們可以通過改變堆棧數據來達到我們的多任務目的呢?答案是肯定的,我們可以使用堆棧指針寄存器SP來修改堆棧的位置,同時使用入棧push和出棧pop來達到修改堆棧內容的目的。
我們先來驗證一下上面的猜想是否是對的呢?
新建一個STC工程,並寫一個最簡單demo,我們來看一下單片機是如何分配的ram
代碼如下:
void test1()
{
u8 a[8] = {1,2,3,4,5,6,7,8};
a[7] = a[1]+a[2]+a[3]+a[4]+a[5]+a[6]+a[0];
}
void test2()
{
u8 a[8] = {1,2,3,4,5,6,7,8};
test1();
a[7] = a[1]+a[2]+a[3]+a[4]+a[5]+a[6]+a[0];
}
/******************** 主函數 **************************/
void main(void)
{
while(1)
{
//test1();
test2();
}
}
在Listings文件夾中的rtosmyself.m51中有堆棧的信息
LINK MAP OF MODULE: .\Objects\rtosmyself (MAIN)
TYPE BASE LENGTH RELOCATION SEGMENT NAME
-----------------------------------------------------
* * * * * * * D A T A M E M O R Y * * * * * * *
REG 0000H 0008H ABSOLUTE "REG BANK 0"
IDATA 0008H 0001H UNIT ?STACK
* * * * * * * X D A T A M E M O R Y * * * * * * *
XDATA 0000H 0008H UNIT ?XD?TEST1?MAIN
XDATA 0008H 0008H UNIT ?XD?TEST2?MAIN
* * * * * * * C O D E M E M O R Y * * * * * * *
CODE 0000H 0003H ABSOLUTE
CODE 0003H 00F6H UNIT ?C?LIB_CODE
CODE 00F9H 0063H UNIT ?PR?TEST2?MAIN
CODE 015CH 0060H UNIT ?PR?TEST1?MAIN
CODE 01BCH 0010H UNIT ?CO?MAIN
CODE 01CCH 000CH UNIT ?C_C51STARTUP
CODE 01D8H 0006H UNIT ?PR?MAIN?MAIN
Stack是從8H開始的,而函數test1和test2中用到的變量被分配到了xdata中,這是因爲我將工程配置爲
使用片內擴展ram,這個是由於STC單片機片內除了集成256字節的內部RAM外,還集成了3840字節的擴展RAM,地址範圍是0000H~0EFFH。所以編譯器會優先使用xdata來存放變量。在KEIL C51中定義了xdata、idata、xdata、code幾種域修飾符。這些修飾符決定了變量訪問方式。
data:固定指前面0x00-0x7F的128個RAM,可以用acc直接讀寫,速度最快,生成的代碼也最小。
idata:固定指前面0x00-0xFF的256個RAM,其中前128和data的128完全相同,只是訪問的方式不同。
xdata:外部擴展RAM。
堆棧的最高地址爲FFH,所以這個程序的堆棧大小爲FF-08=F7H。這個已經足夠大了,對於單片機來說,而且堆棧的初始地址爲08H。
通過上面的內容能夠了解這麼多。
這裏建議在學習時添加這個選項
這個是說輸出lst時也把彙編語言同時輸出,對於學習很有幫助。
下面我們來仿真一下,看一下sp的變化
可見當我們要運行第一個函數時,堆棧的地址指向07H,而且idata中沒有數據。這裏有人會問爲什麼指向07H,不應該是08H嗎?入棧時先SP+1再將內容壓入當前SP所指示的堆棧單元中,出棧則先將SP所指示的內部ram單元中內容送入直接地址尋址的單元中,再將
SP減1。所以SP會指向07H。
在當前指令(0x1D8)的下一條指令爲0x1DB,這個時候我們相當於從main函數要進入test2函數,所以我們預測堆棧中會出現PC的下一條指令地址,也就是0x1DB,我們執行一步看看。
果然,堆棧指針地址變化爲09,堆棧中也存入了01DB,我們繼續往下走,因爲test2中還調用了test1,正常也會產生入棧事件。
果然,在進入test1時就發生了入棧,入棧地址爲0121,這個地址應該是test2的返回地址。
之後同學們可以單步觀察一下出棧的過程,就和上面說的一樣。
好了,我們需要的東西都已經準備好了,我們可以想象一下我們的任務切換過程。
首先任務一執行到某處,觸發了任務切換,然後保存這個任務的SP指針,並想辦法將下一個需要運行的任務的地址放入sp中,然後讓sp把地址給PC去執行,這樣就實現了我們的多任務切換過程。
想到這裏問題又來了,如果我們使用默認的棧,那麼會發生其它函數的棧信息被覆蓋的問題,如下圖
首先任務1和任務2都有了自己的棧地址,並且都使用棧底保存pc地址,當任務1不停的運行時,有可能產生過多的棧使用,就有可能覆蓋任務2的PC地址。所以這種方法還是有問題,那麼我們應該怎麼辦呢?我們爲每個任務都在靜態區創建自己的堆棧區域,這樣彼此就不會覆蓋,只是每次切換任務的時候需要把自己的SP地址指向自己的任務堆棧即可。
任務私有堆棧
現在我們準備給任務在靜態區分配一個屬於自己的堆棧。由於sp指針只能指向片內ram,也就是說我們的堆棧最大也就只能使用256字節,也就是下圖這個部分
所以我們只能將堆棧使用idata標記,好讓keil爲我們分配到內部ram中。
代碼如下:
idata u8 stack[20]; //建立一個 20 字節的靜態區堆棧
void task_0(void)
{
while (1) {
delay_ms(100);
}
}
void start_task_with_stack(void (*pfun)(), u8 *pstack)
{
*pstack++ = (unsigned int)pfun; //將函數的地址高位壓入堆棧,
*pstack = (unsigned int)pfun>>8; //將函數的地址低位壓入堆棧,
SP = (u8)pstack; //將堆棧指針指向人工堆棧的棧頂
}
/******************** 主函數 **************************/
void main(void)
{
start_task_with_stack(task_0, stack);
}
在start_task_with_stack函數中我們首先把傳入的函數指針放入傳入的數組最底部,然後將數組的當前指針賦值給SP寄存器,這樣當這個函數退出時,會自動將sp中的函數指針給PC,這樣就能直接執行傳入的函數了。
到這裏我們已經擁有了任務切換函數的雛形。下面我們需要添加一些自己的擴展功能。比如堆棧使用情況統計,基本實現的方法就是在堆棧初始化的時候寫入一個魔數,當隨着堆棧被使用,魔數會被改變,統計的時候我們只要統計有多少沒有變化,就能知道堆棧的使用情況了。有人說如果我往堆棧裏壓入的數據剛好是魔數怎麼辦?我們可以將魔數暴露給用戶更改,如果每次更改的魔數剛好都是你往堆棧中寫入的數據,那麼只能說你牛。。。。。。
首先修改創建任務函數,添加可選宏STACK_DETECT_MODE,如果開啓宏,則添加初始化堆棧數組的for循環。
void start_task_with_stack(void (*pfun)(), u8 *pstack, u8 stack_len)
{
#ifdef STACK_DETECT_MODE
u8 i = 0;
for (; i<stack_len; i++)
pstack[i] = STACK_MAGIC;
#else
stack_len = stack_len; //消除編譯警告
#endif
*pstack++ = (unsigned int)pfun; //將函數的地址高位壓入堆棧,
*pstack = (unsigned int)pfun>>8; //將函數的地址低位壓入堆棧,
SP = (u8)pstack; //將堆棧指針指向人工堆棧的棧頂
}
然後提供一個查詢堆棧使用情況的get函數
#ifdef STACK_DETECT_MODE
u8 get_stack_used(u8 *pstack, u8 stack_len)
{
u8 i = stack_len-1;
u8 unused = 0;
while (STACK_MAGIC == pstack[i]) {
unused++;
if (0 == i)
break;
else
i--;
}
return stack_len-unused;
}
#endif
運行結果如下: