了解RTOS任務
超級循環編程范式通常是嵌入式系統工程師最先接觸到的編程方法之一。用超級循環實現的程序有一個單一的頂層循環,在系統需要執行的各種功能之間循環。這些簡單的while循環很容易創建和理解(當它們很小的時候)。在FreeRTOS中,任務與超級循環非常相似--主要區別在于,系統可以有一個以上的任務,但只有一個超級循環。
在本章中,我們將仔細研究超級循環和用它們實現一定程度的并行性的不同方法。之后,將對超級循環和任務進行比較,并從理論上介紹任務執行的思維方式。最后,我們將看看任務是如何通過RTOS內核實際執行的,并比較兩種基本的調度算法。
超級循環編程介紹
所有的嵌入式系統都有一個共同的特性--它們沒有退出點。由于其性質,嵌入式代碼通常被期望總是可用的--靜靜地在后臺運行,處理內務工作,并隨時準備接受用戶的輸入。與旨在啟動和停止程序的桌面環境不同,如果微控制器退出main()函數,它就沒有任何事情可做。如果發生這種情況,很可能是整個設備已經停止運作。由于這個原因,嵌入式系統中的main()函數從不返回。與應用程序不同的是,應用程序是由其主機操作系統啟動和停止的,大多數基于嵌入式MCU的應用程序在上電時開始,在系統斷電時突然結束。由于這種突然的關閉,嵌入式應用程序通常沒有任何通常與應用程序相關的關閉任務,如釋放內存和資源。
下面的代碼代表了超級循環的基本思想:
void main ( void )
{
while(1)
{
func1();
func2();
func3();
//do useful stuff, but don't return
//(otherwise, where would we go. . what would we do. . .?!)
}
}
雖然非常簡單,但前面的代碼有許多值得指出的特點。while循環從不返回--它一直在執行同樣的三個函數(這是故意的)。這三個看似無害的函數調用可以在實時系統中隱藏一些令人討厭的驚喜。
基本的超級循環
這個從不返回的主循環一般被稱為超級循環。超級循環總是很有趣,因為它可以控制系統中的大多數事情--除非超級循環使之發生,否則下圖中的任何事情都無法完成。這種類型的設置非常適合于非常簡單的系統,只需要執行一些不需要花費大量時間的任務。基本的超級循環結構非常容易編寫和理解;如果你想解決的問題可以用一個簡單的超級循環來完成,那么就使用一個簡單的超級循環。下面是前面介紹的代碼的執行流程--每個函數都是按順序調用的,而且循環永不退出:
實時系統中的超級環路
當簡單的超級循環快速運行時(通常是因為它們的功能/責任有限),它們的響應速度相當快。然而,超級循環的簡單性可能是一種祝福,也是一種詛咒。由于每個函數總是跟在前面的函數后面,它們總是以相同的順序被調用,并且完全相互依賴。一個函數引入的任何延遲都會傳播到下一個函數,從而導致執行該循環迭代的總時間增加(如下圖所示)。如果func1在循環中執行一次需要10毫秒,而下一次需要100毫秒,那么func2在循環中第二次被調用的時間就不會像第一次那樣快:
讓我們更深入地看一下這個問題。在上圖中,func3負責檢查一個代表外部事件的標志的狀態(這個事件是一個信號的上升沿)。func3檢查標志的頻率取決于func1和func2執行的時間。一個設計良好、反應靈敏的超級循環通常會執行得非常快,檢查事件的頻率要比事件發生的頻率高(呼出B)。當外部事件確實發生時,該循環直到func3的下一次執行才檢測到該事件(呼出A、C和D)。注意,在事件產生和func3檢測到它之間有一個延遲。還要注意的是,這個延遲并不總是一致的:這種時間上的差異被稱為抖動。
在許多基于超級循環的系統中,與被輪詢的緩慢發生的事件相比,超級循環的執行速度非常高。我們在頁面上沒有足夠的空間來顯示循環在檢測到事件之間執行數百次(或數千次)的迭代!這就是所謂的抖動!
如果系統在響應事件時有一個已知的最大抖動量,它被認為是確定性的。也就是說,它將在事件發生后的指定時間內對事件作出可靠的反應。高水平的確定性對于實時系統中的時間關鍵型組件是至關重要的,因為如果沒有它,系統可能無法及時響應重要的事件。
考慮到循環反復檢查硬件標志的事件(這被稱為輪詢)。循環越緊密,標志被檢查的速度就越快--當標志經常被檢查時,代碼將對感興趣的事件做出更多的反應。如果我們有需要及時采取行動的事件,我們可以只寫非常緊密的循環,等待重要事件的發生。這種方法是有效的--但前提是該事件是系統唯一感興趣的事情。如果整個系統唯一的責任就是觀察該事件(沒有后臺I/O、通信等),那么這是一個有效的方法。這種類型的情況在今天復雜的現實世界的系統中很少發生。響應性差是單純基于輪詢的系統的局限性。接下來,我們將看看如何在我們的超級循環中獲得更多的并行性。
用超級循環實現并行操作
盡管基本的超級循環只能按順序通過函數,但仍有辦法實現并行化。單片機有一些不同類型的專用硬件,它們被設計用來減輕CPU的一些負擔,同時還能實現高度響應的系統。本節將介紹這些系統以及如何在超級循環風格的程序中使用它們。
中斷
對單一事件進行輪詢不僅在CPU周期和功率方面是浪費的--它還會導致系統對其他事物沒有反應,這通常是應該避免的。那么,我們怎樣才能讓單核處理器并行地做事情呢?嗯,我們不能--畢竟只有一個處理器。...但由于我們的處理器很可能每秒運行數百萬條指令,所以有可能讓它執行足夠接近于并行的事情。MCU還包括用于生成中斷的專用硬件。中斷向MCU提供信號,使其在事件發生時直接跳到中斷服務程序(ISR interrupt service routine )。這是一個非常關鍵的功能,ARM Cortex-M內核為其提供了一個標準化的外設,稱為嵌套向量中斷控制器(NVIC nested vector interrupt controller。NVIC提供了一種處理中斷的通用方法。這個術語的嵌套部分標志著即使是中斷也可以被其他具有更高優先級的中斷打斷。這相當方便,因為它允許我們將系統中時間最關鍵的部分的延遲和抖動量降到最低。
那么,中斷如何融入超級循環,以更好地實現并行活動的假象?ISR內部的代碼通常被保持得盡可能短,以盡量減少在中斷中花費的時間。這一點很重要,有幾個原因。如果中斷發生得很頻繁,而且ISR包含很多指令,那么ISR就有可能在被再次調用之前不返回。對于UART(universal asynchronous receiver / transmitter)或SPI(serial peripheral interface)等通信外設來說,這將意味著數據丟失(這顯然是不可取的)。保持代碼簡短的另一原因是其他中斷也需要得到服務,這就是為什么把任何責任推給不在ISR上下文中運行的代碼是個好主意。
為了快速了解ISR是如何導致抖動的,讓我們看看簡單的例子:外部模數轉換器(ADC analog to digital converter )向MCU發出信號,表示已經采集了讀數,轉換結果準備傳送給MCU(參考這里的硬件圖):
在ADC硬件中,有一個引腳專門用來指示模擬值的讀數已被轉換為數字表示,并準備傳輸給MCU。然后,MCU將通過通信介質(圖中的COM)啟動傳輸。
接下來,讓我們看看相對于轉換準備線的上升沿,ISR調用如何隨著時間的推移而相互疊加。下圖顯示了六個不同的ISR被調用以響應信號的上升沿的情況。硬件中的上升沿與固件中的ISR被調用之間的少量時間是最小延遲。ISR響應中的抖動是許多不同周期中延遲的差異:
有不同的方法來最小化關鍵ISR的延時和抖動。在基于ARM Cortex-M的MCU中,中斷優先級是靈活的--在運行時可以為單個中斷源分配不同的優先級。重新確定中斷優先級的能力是確保系統中最重要的部分在需要時獲得CPU的一種方式。
如前所述,保持在中斷中執行的代碼量盡可能短是很重要的,因為在ISR中的代碼將優先于任何不在ISR中的代碼(例如main())。此外,較低優先級的ISR不會被執行,直到較高優先級的ISR中的所有代碼都被執行,并且ISR退出--這就是為什么保持ISR的簡短是重要的。嘗試限制ISR的責任(以及因此而產生的代碼)總是好主意。
當多個中斷被嵌套時,它們不會完全返回--實際上ARM Cortex M處理器有非常有用的功能,叫做中斷-尾部鏈。如果處理器檢測到一個中斷即將退出,但另一個中斷正在等待,那么下一個ISR將被執行,而處理器不會完全恢復中斷前的狀態,這進一步減少了延遲。
中斷和超級循環
在ISR中實現最小指令和責任的一種方法是在ISR中做盡可能少的工作,然后設置標志,由超級循環中運行的代碼來檢查。這樣一來,中斷就可以盡快得到服務,而不需要整個系統都致力于等待該事件。在下圖中,注意到在最后由func3處理之前,中斷是如何被多次產生的。
根據該中斷試圖實現的具體目標,它通常會從相關的外設中獲取一個值并將其推入數組(或從數組中獲取值并將其送入外設寄存器)。在我們的外部ADC的情況下,ISR(每次ADC執行轉換時觸發)將出去到ADC,傳輸數字化的讀數,并將其存儲在RAM中,設置標志,表明一個或多個值已經準備好進行處理。這使得中斷可以被多次服務,而不涉及高層代碼:
在通信外設傳輸大塊數據的情況下,可以用數組作為隊列來存儲要傳輸的項目。在整個傳輸結束時,可以設置標志來通知主循環的完成。有很多例子可以說明隊列值是合適的情況。例如,如果需要對數據塊進行一些處理,首先收集數據,然后在中斷之外一起處理整個數據塊,這往往是有利的。中斷驅動的方法并不是實現這種阻斷數據的唯一方法。
DMA
還記得處理器不可能真正做到并行的說法嗎?這仍然是事實。然而......現代的MCU不僅僅包含一個處理核心。當我們的處理核心在處理指令時,還有許多其他硬件子系統在MCU內努力工作。這些努力工作的子系統之一被稱為直接內存訪問控制器(DMA Direct Memory Access Controller):
前面的圖是非常簡化的硬件框圖,顯示了從RAM到UART外設的兩個不同的數據路徑的視圖。
在沒有DMA的情況下,從UART接收字節流,來自UART的信息將進入UART寄存器,被CPU讀取,然后推送到RAM進行存儲:
- CPU必須檢測到何時收到單獨的字節(或字),要么通過輪詢UART寄存器標志,要么通過設置中斷服務例程,當一個字節準備好時就啟動。
- 字節從UART傳輸后,CPU可以將其放入RAM進行進一步處理。
- 步驟1和2重復進行,直到收到整個信息。
當DMA被用在同樣的場景中時,會發生以下情況:
- CPU為傳輸配置DMA控制器和外圍設備。
- DMA控制器負責UART外設和RAM之間的所有傳輸。這不需要CPU的干預。
- 當整個傳輸完成時,CPU會得到通知,它可以直接處理整個字節流。
大多數程序員發現DMA幾乎是神奇的,如果他們習慣于處理超級循環和ISR。控制器被配置為在外設需要時向外設傳輸內存塊,然后在傳輸完成時提供通知(通常是通過一個中斷)--就是這樣!這就是DMA!
當然,這種便利也是有代價的。最初設置DMA傳輸確實需要一些時間,所以對于小的傳輸,實際上可能比使用中斷或輪詢的方法要花費更多的CPU時間來設置傳輸。
還有一些需要注意的地方:每個MCU都有特定的限制,所以在指望DMA對系統的關鍵設計組件的可用性之前,一定要閱讀數據手冊、參考手冊和勘誤表的細節:
MCU內部總線的帶寬限制了可以可靠地放在單一總線上的對帶寬要求高的外設的數量。
偶爾,映射到外設的DMA通道的有限可用性也使設計過程復雜化。
這些類型的原因就是為什么要讓所有的團隊成員參與到嵌入式系統的早期設計中來,而不是直接把它扔到墻上。
DMA對于有效地訪問大量的外設是很好的,使我們有能力為系統添加越來越多的功能。然而,當我們開始向超級循環添加越來越多的代碼模塊時,子系統之間的相互依賴關系也變得更加復雜。在下一節中,我們將討論為復雜系統擴展超級循環的挑戰。
擴展超級循環
現在已經有了能夠可靠地處理中斷的響應系統。也許我們已經配置了DMA控制器來處理通信外圍設備的繁重工作。為什么我們需要實時操作系統?嗯,你完全有可能不需要! 如果系統只處理有限的任務,而且沒有特別復雜或耗時的,那么可能就不需要比超級循環更復雜的東西。
但是,如果系統還要負責生成用戶界面(UI),運行復雜的耗時的算法,或者處理復雜的通信棧,那么這些任務很可能要花費非同小可的時間。如果帶有大量動畫的華麗奪目的用戶界面因為MCU正在處理從關鍵的傳感器收集數據而開始有點結巴,那也沒什么大不了的。要么動畫可以回調,要么取消,而實時系統的重要部分則保持原樣。但是,如果那個動畫看起來仍然非常好,即使有一些來自傳感器的數據被遺漏,又會發生什么呢?
在我們的行業中,這個問題每天都有各種不同的方式。有時,如果系統設計得足夠好,丟失的數據會被檢測到并被標記出來(但它不能被恢復:它永遠消失了)。如果設計團隊真的很幸運,它甚至可能在內部測試中以這種方式失敗。然而,在許多情況下,遺漏的傳感器數據將完全沒有被注意到,直到有人注意到其中一個讀數似乎有點偏離......有時。如果每個人都很幸運,關于粗略讀數的錯誤報告可能包括一個提示,即它似乎只在有人在前面板上(玩那些花哨的動畫)時發生。這至少會給被指派去調試這個問題的可憐的固件工程師一個提示--但我們往往甚至沒有那么幸運。
這些是需要實時操作系統的系統類型。保證時間最緊迫的任務在必要時總是在運行,并在有空閑時間時將低優先級的任務調度到運行,這是搶占式調度器的一個強項。在這種類型的設置中,關鍵的傳感器讀數可以被推到他們自己的任務中,并分配一個高優先級--當需要處理傳感器的時候,有效地中斷了系統中的任何其他任務(除了ISR)。那個復雜的通信堆棧可以被分配一個比關鍵傳感器更低的優先級。最后,具有花哨動畫的華麗用戶界面得到了剩余的處理器周期。它可以自由地執行任意多的滑動阿爾法混合動畫,但只有在處理器沒有其他更好的事情可做的時候。
RTOS任務與超級循環相比較
到目前為止,我們只是非常隨意地提到了任務,但任務到底是什么?思考任務的簡單方法是,它只是另一個主循環。在一搶占式RTOS中,任務和超級循環之間有兩個主要區別:
-
每個任務都收到它自己的私有堆棧。與共享系統堆棧的main中的超級循環不同,任務收到自己的堆棧,系統中的其他任務不會使用。這允許每個任務擁有自己的調用堆棧,而不干擾其他任務。
-
每個任務都有分配給它的優先級。這個優先級允許調度員決定哪個任務應該運行(目標是確保系統中最高優先級的任務總是在做有用的工作)。
考慮到這兩個特點,每個任務都可以被編程,好像它是處理器唯一要做的事情。你有一個你想看的單一標志和一些計算的華美的動畫要攪和嗎?沒問題:只需對任務進行編程,并給它分配合理的優先級,相對于系統的其他功能而言。搶占式調度器將始終確保最重要的任務在有工作要做時被執行。當一個較高優先級的任務不再有有用的工作要做,并且它正在等待系統中的其他東西時,較低優先級的任務將被切換到上下文并允許運行。
用RTOS任務實現并行操作
早些時候,我們看了在三個函數中循環的超級循環。現在讓我們把這三個函數中的每一個移到自己的任務中。我們將用這三個簡單的任務來研究以下內容:
-
理論上的任務編程模型: 如何在理論上描述這三個任務
-
實際的輪流調度: 任務在使用輪回調度算法執行時是什么樣子的
-
實際的搶占式調度: 使用搶占式調度執行的任務是什么樣子的?
在現實世界的程序中,每個任務幾乎都沒有單一的函數;我們只是把它作為類似于前面的過于簡單的超級循環的例子。
理論上的任務編程模型
下面是使用超級循環來執行三個函數的偽代碼。同樣的三個函數也包含在基于任務的系統中--每個RTOS任務(在右邊)包含與左邊的超級循環的函數相同的功能。當我們討論使用超級循環與使用調度器的任務驅動方法時,代碼執行方式的差異時,這點將被繼續使用:
你可能會注意到超級循環實現和RTOS實現之間的直接區別是無限的while循環的數量。在超級循環的實現中,只有一個無限的while循環(在main()中),但是每個任務都有自己的無限的while循環。
在超級循環中,在調用下一個函數之前,被超級循環執行的三個函數分別運行到完成,然后循環繼續到下一個迭代(如下圖所示):
在RTOS的實現中,每個任務本質上是它自己的小的無限的while循環。超級循環中的函數總是一個接一個地被調用(由超級循環中的邏輯協調),而任務可以簡單地被認為是在調度器啟動后的所有并行執行。下面是一個執行三個任務的實時操作系統的圖示:
在圖中,你會注意到每個while循環的大小是不一樣的。這是使用并行執行任務的調度器相對于超級循環的許多好處之一--程序員不需要立即關注最長的執行循環的長度會拖累其他更緊密的循環。圖中描述了任務2的循環比任務1長很多。在超級循環系統中,這將導致func1的功能執行頻率降低(因為超級循環需要先執行func1,然后是func2,最后是func3)。在基于任務的編程模型中,情況并非如此--每個任務的循環可以被認為是與系統中的其他任務隔離的--而且它們都是并行運行的。
這種隔離和感知的并行執行是使用實時操作系統的一些好處;它為程序員減輕了一些復雜性。所以,這是概念化任務的最簡單的方法--它們只是獨立的無限的while循環,都是平行執行的......在理論上。在現實中,事情并沒有這么簡單。在接下來的兩節中,我們將瞥見幕后發生的事情,使其看起來像是任務在并行執行。
循環排程
概念化實際任務執行的最簡單方法之一是輪流調度。在輪流調度中,每個任務得到一小塊時間來使用處理器,這是由調度器控制的。只要任務有工作要執行,它就會執行。就該任務而言,它完全擁有自己的處理器。調度器負責處理為下一個任務切換適當上下文的所有復雜問題:
這和之前展示的三個任務是一樣的,只不過不是理論上的概念化,而是通過任務的循環的每一次迭代都是隨著時間的推移而列舉的。因為循環調度器給每個任務分配了相等的時間片,最短的任務(任務1)已經執行了將近六次循環,而最慢的循環任務(任務2)只完成了第一次循環。任務3已經執行了三次循環。
執行相同函數的超級循環與執行這些函數的輪回調度例程之間的一個極其重要的區別是這樣的: 任務3在任務2之前完成了其適度緊密的循環。當超級循環以串行方式運行函數時,函數3甚至不會開始,直到函數2運行完成。所以,雖然調度器沒有為我們提供真正的并行性,但每個任務都得到了它公平的CPU周期份額。所以,在這種調度方案下,如果任務有較短的循環,它將比有較長循環的任務執行得更頻繁。
所有這些切換都有一個(輕微的)代價--調度器需要在任何有上下文切換的時候被調用。在這個例子中,任務沒有明確地調用調度器來運行。在FreeRTOS運行在ARM Cortex-M上的情況下,調度器將被從SysTick中斷中調用(更多細節可在第7章FreeRTOS調度器中找到)。為了確保調度器內核非常有效,并盡可能地減少運行時間,我們付出了相當多的努力。然而,事實是,它將在某些時候運行并消耗CPU周期。在大多數系統中,少量的開銷通常不會被注意到(或顯著),但在某些系統中,它可能成為問題。例如,如果一個設計處于可行性的極端邊緣,因為它有非常嚴格的時間要求和非常少的空閑CPU周期,如果超級循環/中斷方法已經被仔細地描述和優化,那么增加的開銷可能是不可取的(或者完全有必要)。然而,最好是盡可能地避免這種類型的情況,因為即使是在中等復雜的系統中,忽視中斷堆積(或嵌套條件語句偶爾需要更長的時間)并導致系統錯過最后期限的可能性也是非常大的。
基于搶占式的調度
搶占式調度提供了一種機制,以確保系統總是在執行其最重要的任務。搶占式調度算法將優先考慮最重要的任務,不管系統中還有什么事情發生--除了中斷,因為它們發生在調度器下面,總是有更高的優先級。這聽起來非常直接--而且確實如此--只是有一些細節需要考慮到。
讓我們看一下同樣的三個任務。這三個任務都有相同的功能:簡單的while循環,無休止地增加不穩定的變量。
現在,考慮以下三種情況,看看這三個任務中哪個會得到上下文。下圖中的任務與之前介紹的輪流調度的任務相同。三個任務中都有足夠多的工作要做,這將防止任務脫離上下文:
那么,當三個不同的任務被設置為三組不同的優先級(A、B、C)時會發生什么?
A(左上角): 任務1在系統中擁有最高的優先級--它獲得了所有的處理器時間 不管任務1執行了多少次迭代,如果它是系統中優先級最高的任務,并且它有工作要做(不需要等待系統中的其他東西),它將被賦予上下文并運行。
B(右上方): 任務2是系統中優先級最高的任務。由于它有足夠多的工作要做,不需要等待系統中的其他東西,任務2將被賦予上下文。由于任務2被配置為系統中的最高優先級,它將執行,直到它需要在系統中等待其他東西。
C(左下角): 任務3被配置為系統中的最高優先級任務。沒有其他任務運行,因為它們的優先級較低。
現在,很明顯,如果你真的在設計一個需要多個任務并行運行的系統,如果系統中所有的任務都需要100%的CPU時間,并且不需要等待任何東西,那么搶占式調度器就沒有什么用了。這種設置對于實時系統來說也不是很好的設計,因為它完全超載了(而且忽略了系統所要執行的三個主要功能中的兩個)!這種情況被稱為 "任務"!所呈現的情況被稱為任務饑餓,因為只有系統中優先級最高的任務獲得了CPU時間,而其他任務則被剝奪了處理器時間。
另一個值得指出的細節是,調度器仍然以預定的時間間隔運行。無論系統中發生了什么,調度器都會勤奮地按照預定的時間間隔運行。
這有一個例外。FreeRTOS有無滴答的調度器模式,旨在用于極低功率的設備,它可以防止調度器在相同的預定間隔內運行。
這里顯示了一個使用搶占式調度器的更現實的用例:
在這種情況下,任務1是系統中優先級最高的任務(它也恰好很快完成執行)--任務1只有在調度器需要運行的時候才會被剝奪上下文;否則,它將保持上下文直到沒有任何額外的工作要執行。
任務2是下一個最高優先級的任務--你也會注意到,這個任務被設置為在每個RTOS調度器的勾選中執行一次(由向下的箭頭表示)。任務3是系統中優先級最低的任務:只有當系統中沒有其他值得做的事情時,它才會得到上下文。在這張圖中,有三個要點值得關注:
A:任務2有上下文。即使它被調度器打斷了,但在調度器運行后,它又立即得到了上下文(因為它還有工作要做)。
B: 任務2已經完成了迭代0的工作,調度器已經運行并確定(因為系統中沒有其他任務需要運行)任務3可以擁有處理器時間。
C:任務2已經開始運行迭代4,但是任務1現在有一些工作要做--盡管任務2還沒有完成該迭代的工作。任務1立即被調度器切換到執行其更高優先級的工作。在任務1完成了它需要做的事情后,任務2被切換回來完成迭代4。這一次,迭代運行到下一個tick,任務2再次運行(迭代5)。在任務2迭代5完成后,沒有更高優先級的工作要執行,所以系統中最低優先級的任務(任務3)再次運行。看起來任務3終于完成了迭代0,所以它進入了迭代1并繼續運行......。
希望你還在聽我說! 如果沒有,那也沒關系,因為這只是一個非常抽象的例子。關鍵的啟示是,系統中優先級最高的任務是優先的。
RTOS任務與超級循環的對比--利與弊
超級循環對于責任有限的簡單系統是非常好的。如果系統足夠簡單,它們可以在響應事件時提供非常低的抖動,但前提是循環足夠緊密。隨著系統越來越復雜,獲得更多的責任,輪詢率會下降。這種輪詢率的降低導致對事件的響應抖動大得多。中斷可以被引入到系統中,以應對抖動的增加。隨著基于超級循環的系統變得越來越復雜,跟蹤和保證對事件的反應能力變得越來越難。
對于那些不僅有耗時的任務,而且還需要對外部事件有良好的響應能力的更復雜的系統,實時操作系統變得非常有價值。對于實時操作系統來說,系統復雜性、ROM、RAM和初始設置時間的增加是為了換取一個更容易理解的系統,它可以更容易地保證對外部事件的及時響應。
評論
查看更多