引言
前面我們陸續學習了操作系統常見的基礎概念,接著簡單了解了一下8051單片機的內存結構和執行順序切換的相關概念。接下來,我們就開始進行實操,基于8051單片機STC8來編寫一個簡單的操作系統,這里我們先實現一個單任務的執行程序,體會MCU中指令的執行過程。
一、新建工程
1.1 本地新建工程目錄
1. 選擇一個合適的地方新建一個文件夾os_demo
2. 在os_demo目錄下新建user目錄,用于存放main.c
詳細地說,第一步,新建os_demo文件夾,用于存放工程。筆者在本地的STC目錄下直接創建一個空的os_demo文件夾;第二步,進入os_demo1目錄,繼續創建一個空文件夾user,用于存放main.c源文件。
最后效果如下
1.2 keil5新建工程文件
1. 進入project,點擊新的uVision工程,進入本地創建好的os_demo1目錄下,輸入工程文件名稱os_demo1,保存即可;
2. 接著選擇使用的8051單片機芯片;
3. 在keil中建立邏輯目錄結構;
4. keil中的簡單配置。
具體步驟如下圖所示
第一步,選擇目錄新建keil工程文件,生成相關配置文件
第二步,選擇芯片型號,筆者使用的是STC8H8K64U這款8051單片機芯片
不用添加這個啟動文件,因為后面我們正要編寫的就是這種類似文件
第三步,在keil中建立邏輯目錄結構,盡可能選擇與本地目錄名相同去創建目錄user,然后將本地對于的文件main.c添加進來,OK即可
第四步,配置一下,進入魔法棒,接著進入C51,如下圖所示,將本地新建的user目錄的路徑在這里包含一下
至此,該工程就創建完畢了。
二、程序編寫
在keil中,打開前面創建好的工程文件,然后打開main.c開始編寫代碼。
首先是引入STC8的頭文件,其中定義了該單片機中的一些常用的寄存器地址,包括但不限于特殊功能寄存器等
#include <stc8h.h> // 定義一些寄存器的地址
2.1 任務棧管理
每個MCU要進行的需求可以認為是一個任務,而每個任務要執行的話都有對應的指針或者說地址去便于找到該任務執行的入口。
這里我們需要先定義一個任務堆棧指針數組,用于存放不同任務的堆棧指針SP。關于SP前面也重點提過,用來記錄程序執行時的下一條指令地址的特殊功能寄存器。這里定義它是為了去記錄任務(我們希望執行的程序)的堆棧指針的。
接著定義一個二維數組,用來存放每個任務的堆棧信息,這個堆棧信息可以理解為每個任務的執行入口地址,其實可以見到理解為SP去記錄的那個堆棧指針。
然后這倆數組都是用于存放一定數量任務的堆棧相關內容的,所以要規定最大任務數量和堆棧深度(可先理解為堆棧大小)。同時為了避免魔法數字的出現,筆者在這里便使用宏定義去分別定義最大任務數以及最大堆棧深度。當然為了簡化程序復雜程度,筆者先定義最大任務數量為2,后續再慢慢擴充。
然后我們還需要定義一個任務id,用于表示任務的名字。
因此,針對上述描述的邏輯,給出關于任務堆棧管理的定義部分示例代碼如下:
#define MAX_TASKS 2 // 簡化任務數為2
#define MAX_TASK_DEPTH 32 // 堆棧深度// idata 表明信息定義在STC8訪問最快的內部內存空間里面unsigned char idata task_sp[MAX_TASKS]; // 任務的堆棧指針
unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEPTH]; // 每個tasks任務的堆棧信息unsigned char idata task_id; // 當前任務號,從0開始
2.2 任務的創建
接著,我們開始創建幾個任務,前面筆者為簡化任務數量,將任務最大數定義為2,所以我們就先創建兩個任務。
大家可能疑惑:任務?這怎么創建??實際上,前面說過任務就是咱CPU或MCU要去完成的某項工作,實際就是一個具有特定功能或需求的程序,因此所謂創建倆任務在這里我們直接創建倆函數用來模擬兩個任務的創建就好了。
比如創建兩個不斷進行加法運算的功能任務:
void task0()
{// 0號任務,代表第0個小朋友做的事情unsigned int a = 3;// 死循環,表示該任務永遠不會執行完while(1){a = a + 3;}
}void task1()
{// 1號任務,代表第1個小朋友做的事情unsigned int b = 5;// 死循環,表示該任務永遠不會執行完while(1){b = b + 5;}
}
如上代碼所示,我們創建了兩個任務,分別叫任務0和任務1,都是進行一個不斷累積加的任務。
同時我們知道,對于STC8單片機來說,其MCU為單核MCU,執行程序一般是從上至下依次執行,當碰見這種循環的程序時,更是會一直卡在那里反復運行,如果不給跳出循環的函數可能就無休止了。比如咱現在這個加法累積運算,咱MCU里面的ALU運算邏輯單元就要出馬,但單核僅一個,所以正常情況一次只能處理一個運算,如果需要兩個任務都執行,勢必會產生資源爭搶的問題。
恰巧后續咱會一步一步將這個問題解決,不過為了理解為上,我們循序漸進一步一步來看。因此,我們先從執行一個任務入手,看看單核MCU是如何執行這個任務的。
2.3 任務的加載
我們這里所謂的運行一個任務其實和普通單片機運行有一點點區別,正常情況如果點個燈,那就直接在main函數里面寫段相關邏輯編譯運行就行,但這里不一樣,大家是否還記得前面提到的堆棧指針以及切換程序執行順序的內容?本次我們就是要結合切換程序執行順序的思路去利用這個堆棧指針來修改程序運行的順序,將我們的單個任務嵌入中間運行,等運行完后再去執行原程序內容。
因此這里可能就需要先簡單提一下我們單片機上電后做的事情了:
對于STC8單片機來說,其硬件上電復位后會自動將PC程序計數器強制為復位向量地址,然后從這里開始執行啟動程序,
當啟動程序完成初始化后,就會執行LCALL main指令調用main函數。
由于LCALL指令會在跳轉前先將當前執行指令的下一條指令地址(即PC程序計數器對應的指向)壓入堆棧,
然后等該指令調用的函數執行完后,再從堆棧中彈出返回地址(先前存的原來執行的下一條指令地址)賦值給當前的PC,
接著就能繼續執行原來后面的程序指令了。
對于這段話,可能剛看會有一點理解不過來哈,咱也不求一看就懂,所以沒事,多思考多查總能理解的。
好,我們細品上面這段話,里面有幾個關鍵點:PC程序計數器、(PC指向壓入)堆棧、后彈出給PC繼續執行原程序。同時會發現,LCALL指令就是我們前面所說的call指令的邏輯,可以跳轉,然后實現一個程序順序切換的功能。也就是說,我們可以通過修改壓入堆棧內容,從而使返回原程序的地址變成我們希望執行的任務地址,這樣當跳轉后返回時就能執行咱自己的程序了。具體邏輯如下:
首先,我們肯定要有存放自己任務入口指針的堆棧和自己的堆棧指針,用于管理我們各個任務的堆棧指針和堆棧信息,當前前面我們已經定義好這倆東西了,即task_stack和task_ip;
其次,我們還需要將我們現有的任務的堆棧信息放到模擬的堆棧空間中,同時把任務地址所在地址給我們定義的堆棧指針記錄。其實咱這里就是將各個任務的地址信息存進task_stack,不過需要注意的是,當前任務是函數定義,所以其地址信息是函數指針,其類型int,16位,然后把這塊堆棧信息所在的地址給自定義的task_sp里面;
最后,我們將堆棧指針壓入SP,覆蓋原本PC指向的下一個程序地址,也就是將前面記錄的地址的指針(地址)task_sp壓入SP即可。
從上述邏輯看,接下來要做的應該是“其次...”這部分,也就是讓任務的堆棧信息都放到相關位置,這相當于是真正開始前的初始化,所以這個內容我們當做任務的加載,定義task_load函數,函數原型為void task_load(unsigned int fn, unsigned char tid)
代碼示例如下:
// fn 函數指針,注意數據類型int,16bit
// tid task id,8bit 0, 1
// 函數功能: 將一個task的函數指針放入對應的堆棧空間
void task_load(unsigned int fn, unsigned char tid)
{// 1. task的堆棧指針記錄相應taskId堆棧信息地址task_sp[tid] = task_stack[tid] + 1;// 2. 使用兩個空間存放task的函數指針task_stack[tid][0] = fn & 0xFF; // 低8位task_stack[tid][1] = fn >> 8; // 高8位
}
從代碼上看,由于task_sp是用于記錄堆棧指針,所以我們讓存放對應id任務的堆棧空間所在地址給task_sp;然后由于定義的堆棧是8位的,所以每8位依次將對應id的任務堆棧信息存到自定義的堆棧task_stack中。
2.4 main函數執行
好了,任務加載完后,就可以開始運行咱們得任務了,按照前面所說,接下來就是覆蓋SP的內容即可,所以main函數中我們先加載任務,然后指定任務id(前面定義過記錄任務id的變量task_id),最后覆蓋SP即可
代碼如下:
void main()
{task_load(task0, 0); // 裝載任務0到對應堆棧內存task_id = 0;SP = task_sp[0]; // 將當前的堆棧指針壓入SP中
}
從代碼上看,我們是希望將任務0的程序運行出來,首先加載了任務0,將其堆棧信息和id傳入了任務加載函數中,完成了堆棧信息的存儲和堆棧指針的記錄,接著指明任務id為0,最后將任務堆棧指針賦值給SP,覆蓋原SP,實現main執行后返回執行的程序為任務0的程序。
三、調試驗證
前面我們已經完成了這個單任務運行代碼的編寫,邏輯是通過修改SP來間接改變程序執行順序,嵌入了自定義的程序從而完成指定程序的執行,但還未實測。因此接下來,我們在keil中測試一下:
首先編譯一下
可以看出,編譯沒有錯誤。接著我們來調試一下,在任務0的累加處打斷點一步一步看看
單任務執行測試
可以看出,我們指定的任務0確實按照指定邏輯被執行。當前,整個程序的執行情況為以下狀態:
硬件上電復位后會先執行一段啟動程序,然后啟動程序完成初始化后會自動發出LCALL main的指令。
接著在調用main時確實是先將LCALL指令之后的下一條指令的地址(PC當前指向的地址)壓入SP中,然后再開始執行的main中的程序。不過在執行main程序時,按順序先執行task_load函數中的內容時,函數中會將task0的函數指針的低8位和高8位依次存儲自定義堆棧中。
然后讓SP堆棧指針指向自定義堆棧棧頂地址,進而覆蓋了原先SP記錄的LCALL之后實際的棧頂地址。
所以,main執行完后繼續執行ret指令出現的結果就是:實際通過SP記錄的堆棧棧頂地址從堆棧依次彈出給到PC的返回地址是我們自定義的task0函數的高8位和低8位地址,
最后程序就會跳回此時PC所指向的地址即task0函數的位置去執行task0函數的內容了。
當然,這只是一個簡單的測試,還有很多可以擴展的地方,比如可以更換其他任務邏輯進行測試、開空調時信息中的內容是否正確等,可自己多探索一下。
四、小結
本次我們理解了基于STC8單片機的單任務執行的原理,使用keil進行工程創建、代碼編寫和實際測試的實操,深入理解了單核MCU的程序指令運行過程和其單任務運行的局限性。
筆者小白,能力有限,以上內容難免存在不足和紕漏,僅供參考,各位閱讀時請帶著批判性思維學習,遇到問題多查查。同時歡迎各位評論區批評指正。謝謝。