ESP32_IDF學習4【ESP32上的FreeRTOS】


Bootloader

ESP32的Bootloader(引導加載程序)主要執行以下任務:

  1. 內部模塊的基礎初始化配置
  2. 根據分區表ota_data(如果存在)選擇需要引導的應用程序(app)分區
  3. 將應用程序映像加載到 RAM(IRAM和DRAM)中
  4. 完成以上工作后把控制權轉交給應用程序

引導加載程序位於Flash的偏移地址0x1000處

分區表

每片ESP32的flash可以包含多個應用程序,以及多種不同類型的數據(例如校准數據、文件系統數據、參數存儲器數據等),使用分區表對這些程序和數據進行規划

ESP32 在flash的默認偏移地址0x8000處燒寫一張分區表

該分區表的長度為0xC00字節,最多可以保存95條分區表條目。分區表數據后還保存着該表的MD5校驗和用於驗證分區表的完整性。此外,如果芯片使能了安全啟動功能,該分區表后還會保存簽名信息

分區表中的每個條目都包括以下幾個部分:Name(標簽)、Type(app、data 等)、SubType 以及在flash中的偏移量(分區的加載地址)

燒寫到ESP32中的分區表采用二進制格式,而不是CSV文件本身。ESP-IDF提供了gen_esp32part.py工具來配置、構建分區表

默認分區表

menuconfig中自帶了兩套分區表,如果編寫大程序會經常遇到空間不足的問題(特別是當你像我一樣買了16MB超大FLASH的白金紀念典藏款ESP32-WROOM-32E,甚至還想外掛一個W25Q128(16MB)時會經常感覺默認分區表把FLASH都浪費了),但是很適合學習開發使用

  • Single factory app, no OTA
  • Factory app, two OTA definitions

兩個選項,都將出廠應用程序燒錄至flash的0x10000偏移地址處,但是一個沒有OTA分區,一個有OTA分區

它們都在0x10000 (64KB)偏移地址處存放一個標記為 “factory” 的二進制應用程序,且Bootloader將默認加載這個應用程序

分區表中還定義了兩個數據區域,分別用於存儲NVS庫專用分區和PHY初始化數據

帶OTA分區的Factory app, two OTA definitions里還新增了otadata的數據分區,用於保存OTA升級所需的數據,Bootloader還會查詢該分區的數據來 判斷從哪個OTA應用程序分區加載程序,如果這個分區為空則會執行出廠程序

自定義分區表

在menuconfig里選擇了“自定義分區表”選項后,輸入該分區表的路徑和完整文件名就可以使用自定義分區表了

分區表以CSV的格式書寫,用“#”注釋;offset字段可以為空,程序會自動計算並填充該分區的偏移地址,但size字段一定要填寫好

說明如下(抄自官網文檔)

  • Name字段可以是任何有意義的名稱,但不能超過 16 個字符(之后的內容將被截斷)

  • Type 字段可以指定為app (0) 或data (1),也可以直接使用數字0-254(或者十六進制 0x00-0xFE);但0x00-0x3F不得使用(預留給 esp-idf 的核心功能);bootloader將忽略 app (0) 和 data (1) 以外的其他分區類型

  • SubType 字段長度為8位,內容與具體Type有關。目前esp-idf僅僅規定了“app”和“data”兩種子類型

    • 當 Type 定義為 app 時,SubType 字段可以指定為 factory (0),ota_0 (0x10) … ota_15 (0x1F) 或者 test (0x20)
    • 當 Type 定義為 data 時,SubType 字段可以指定為 ota (0),phy (1),nvs (2) 或者 nvs_keys (4)

    其中factory (0) 是Bootloader默認跳轉到的app分區;ota(0)是OTA數據分區;nvs(2)是NVS專用的分區,最好分配至少0x3000字節的空間;nvs_keys(4)是密鑰分區,用於NVS加密相關功能;phy(1)是用於存放PHY初始化數據的分區,默認配置下phy分區並不啟用,會直接將phy初始化數據編譯至應用程序中,使能CONFIG_ESP32_PHY_INIT_DATA_IN_PARTITION后才能使用該分區

  • 分區若為指定偏移地址,則會緊跟着前一個分區之后開始。若此分區為首個分區,則將緊跟着分區表開始。app 分區的偏移地址必須要與 0x10000 (64K) 對齊,如果將偏移字段留空,gen_esp32part.py 工具會自動計算得到一個滿足對齊要求的偏移地址。如果 app 分區的偏移地址沒有與 0x10000 (64K) 對齊,則該工具會報錯

  • Flags 分區當前僅支持 encrypted 標記。如果 Flags 字段設置為 encrypted,且已啟用Flash Encryption(FLASH加密)功能,則該分區將會被加密

通過改動示例分區表就能配置新的分區表

# Name,   Type, SubType, Offset,   Size, Flags
# 注意,如果你增大了引導加載程序的大小,請確保更新偏移量,避免和其它分區發生重疊
nvs,      data, nvs,     0x9000,   0x4000 # NVS分區
otadata,  data, ota,     0xd000,   0x2000 # OTA數據分區
phy_init, data, phy,     0xf000,   0x1000 # 初始化分區
factory,  0,    0,       0x10000,  1M # 工廠分區
test,     0,    test,    ,         512K # 保留分區
ota_0,    0,    ota_0,   ,         512K # 第一OTA分區,一般用於OTA燒錄
ota_1,    0,    ota_1,   ,         512K # 第二OTA分區,一般用於OTA回滾或備份

出廠程序

出廠程序就是按下復位按鈕后從串口噴涌而出的那一堆自檢信息

自定義出廠程序還可以把自己想要的圖標通過字符畫的形式扔進去,開機的時候就會刷出來,比如可以刷個Ubuntu字符畫假裝移植了linux(誤)

                          ./+o+-
                  yyyyy- -yyyyyy+
               ://+//////-yyyyyyo
           .++ .:/++++++/-.+sss/`
         .:++o:  /++++++++/:--:/-
        o:+o+:++.`..```.-/oo+++++/
       .:+o:+o/.          `+sssoo+/
  .++/+:+oo+o:`             /sssooo.
 /+++//+:`oo+o               /::--:.
 \+/+o+++`o++o               ++////.
  .++.o+++oo+:`             /dddhhh.
       .+.o+oo:.          `oddhhhh+
        \+.++o+o``-````.:ohdhhhhh+
         `:o+++ `ohhhhhhhhyo++os:
           .o:`.syhhhhhhh/.oo++o`
               /osyyyyyyo++ooo+++/
                   ````` +oo+++o\:
                          `oo++.

恢復出廠設置

通過設置CONFIG_BOOTLOADER_FACTORY_RESET來使能GPIO觸發恢復出廠設置

恢復出廠設置時將進行以下操作:

  1. 清除所有數據分區
  2. 從工廠分區啟動

自定義Bootloader

用戶可以自定義當前的Bootloader

  1. 復制/esp-idf/components/bootloader文件夾到項目目錄
  2. 編輯/your_project/components/bootloader/subproject/ain/bootloader_main.c文件

注意:在引導加載程序的代碼中,用戶不可以使用驅動和其他組件提供的函數,如果確實需要,應該將該功能的實現部分放在bootloader目錄中(會增加引導程序的大小)

目前,引導程序被限制在了分區表之前的區域(分區表位於0x8000地址處)

應用級程序追蹤

ESP-IDF提供實用的dubug功能,能夠通過menuconfig開啟,並通過調用庫函數進行使用,可以通過JTAG在ESP32和主機之間傳輸debug logs,可用於:

  • 跟蹤特定應用程序
  • 記錄日志到主機
  • 基於SEGGEr SystemView進行系統行為分析

在程序中#incldue "esp_app_trace.h"即可使用相關庫函數

當前這個debug功能已經比較完善,可以用來做調試,但是因為它通過庫函數進行數據發送,可能會對正常程序執行造成干擾,使用位置需要注意

FreeRTOS簡介

詳細內容可以參考FreeRTOS相關教程

以下內容應當由接觸過RTOS的同學學習,如果你還沒碰過RTOS,還像我這樣遇上了老師發的離譜作業,千萬不要慌,先去按照下面這些標題百度/google/bing一通,弄得差不多再讀一讀下面的API講解應該就能糊弄過去了

顧名思義,freeRTOS是free的RTOS,具有以下特點:

  • FREE!FreeRTOS使用LGPL協議,開源且可用於商業,很自由(雖然FSF那幫人可能覺得LGPL不夠自由)
  • 小內核、模塊化、擴展性強
  • 高效、便於使用
  • 用戶無需關心時間信息,內核中的相關模塊會負責處理計時任務和線程調度

內核組成

FreeRTOS是一個可裁剪、可剝奪型(也可根據用戶需要裁剪為不可剝奪型)的多任務內核,不設置任務數限制

內涵和基於硬件適配層實現跨平台移植

源碼結構

這里參考的FreeRTOS源碼是在官網上下載到的/freertos/FreeRTOSv10.4.1/FreeRTOS/Source目錄下的部分

FreeRTOS-Plus包含kernal之外的系統常用功能組件

Source目錄下才是kernal相關源碼

  • list.c任務鏈表模塊
  • queue.c消息隊列模塊
  • tasks.c任務配置模塊
  • timers.c系統定時器模塊
  • event_groups.c事件集模塊
  • include目錄是各種頭文件

任務管理(線程管理、線程調度)

優先級搶占式調度算法

最低優先級是0,優先級數字越大,當前任務越優先

不同任務可以共用同一個優先級

時間管理(時鍾節拍)

FreeRTOS使用系統節拍(systick)確定其運行時鍾,這個系統節拍由硬件定時器中斷引起

使用configCPU_CLOCK_HZ設置當前硬件平台CPU的系統時鍾,單位Hz

使用configTICK_RATE_HZ設置FreeRTOS的時間片頻率(1秒鍾可以切換多少次任務),單位Hz

ESP32的硬件定時器

ESP32提供兩組硬件定時器,每組包含兩個64位通用定時器,共4個通用定時器,分別標記為TIMER0-3。

所有定時器均包括16位預分頻器和64位自動重載向上/向下計數器

定時器初始化

使用timer_config_t結構體配置定時器參數,然后將這個結構體作為參數傳遞給timer_init()函數來進行定時器初始化

可設置的參數如下:

struct timer_config_t
{
	timer_alarm_t alarm_en;//是否使能報警
	timer_start_t counter_en;//是否是能計數器
    timer_intr_mode_t intr_type;//選擇定時器警報上觸發的中斷類型
    timer_count_dir_t counter_dir;//選擇向上/向下計數
    timer_autoreload_t auto_reload;//設置計數器是否在定時器警報上使用auto_reload自動重載首個計數值,或者繼續遞增/遞減
    uint32_t divider;//計數器分頻器,可設置為2-65536,用作輸入的80MHz APB_CLK時鍾的分頻系數
}

使用timer_get_copnfig()獲取定時器設置的當前值

定時器控制
  1. 開啟定時器

設置timer_config_t::counter_en位true后調用timer_init()初始化即可開啟定時器

或者也可以直接調用timer_start()來開啟定時器

調用timer_pause()隨時暫停定時器

  1. 設置計數值

可以通過調用timer_set_counter_value()來指定定時器的首個計數值

使用timer_get_counter_value()或timer_get_counter_time_sec()檢查定時器的當前值

  1. 設置警報

先調用函數timer_set_alarm_value()設置警報值,再調用timer_set_alarm()使能警報,或在初始化階段通過設置初始化結構體來設置警報值並開啟警報

當警報使能且定時器到達警報值后,可以觸發中斷或重新加載

如果auto_reload已使能,定時器的計數器將重新加載,從之前設置好的值重新計數,使用timer_set_counter_value()預先設置該值

如果已設置警報值且定時器已經超過該值,則將立即觸發警報

警報一旦觸發,將自動關閉,需要重新使能以再次觸發

使用timer_get_alarm_value()來獲取特定的警報值

調用函數timer_isr_register()來為特定定時器組和定時器注冊中斷服務程序

使用timer_group_intr_enable()來使能定時器組的中斷程序,使用timer_enable_intr()使能某定時器的中斷程序;使用timer_group_intr_disable()和timer_disable_intr()關閉對應的中斷程序

在中斷服務程序中處理中斷時,需要明確地清除中斷狀態位,通過以下設置來清除某定時器的中斷狀態位

TIMERGN.int_clr_timers.tM = 1;

//TIMERGN中的N代表定時器組別編號,可設置0或1
//tM中的M代表定時器編號,可設置為0或1

TIMERG0.int_clr_timers.t1 = 1;//清除定時器組別0中定時器1的中斷狀態位

ESP32中的FreeRTOS時鍾

ESP32中的FreeRTOS使用任意硬件定時器通過開啟警報中斷模式來實現系統時鍾(systick)

定時器計數器到達預設警報值后,將觸發中斷,調用相關API來讓RTOS的系統時鍾+1

一般這個API由PRO_CPU執行

內存管理(內存堆)

FreeRTOS可以使用四種內存分配方案

  1. heap1.c

分配簡單,時間確定,實時性強

只分配內存不回收內存,容易造成資源浪費

  1. heap2.c

鏈表式內存塊結構分配

動態分配、最佳匹配

容易造成內存碎片且時間不可控

  1. heap3.c

調用標准庫函數分配內存

速度較慢且內存分配時間不確定

  1. heap4.c

按照物理地址對內存進行排序

使相鄰的內存空間可以合並

容易造成內存碎片且合並效率低

通信管理(消息隊列、事件集、信號量、互斥量)

消息隊列

使用FIFO隊列的數據結構處理消息的存儲和傳輸

消息發出后被緩存到FIFO隊尾,其他任務可以調用接收消息的API接收隊首的消息,該消息被接收后,后面的消息會自動前進

事件集

用於取代全局變量標志,更加安全(但更慢

ESP32上的FreeRTOS

【翻譯自官網】普通的FreeRTOS運行在單核上,不彳亍!我們的ESP32-FreeRTOS能運行在雙核,彳亍!

眾所周知,ESP32是物美價廉的雙核SoC,CPU0和CPU1同時運行、共享內存。樂鑫修改了普通的FreeRTOS,讓它能夠支持SMP(symmetric multiprocessing對稱多處理),所以ESP32的FreeRTOS變成了基於FreeRTOS v8.2.0的Xtensa架構移植版SMP RTOS

下面對移植版的FreeRTOS簡稱為SMP RTOS

【補充】對稱多處理(SMP)架構是一種兩個或多個CPU共享同一內存公共鏈路的計算機體系結構

他改變了FreeRTOS

backport

v9.0版本的FreeRTOS特性被部分移植到了基於v8.0版本的ESP32-SMP-RTOS中

任務刪除機制使用v9.0版本的:使用vTaskDelete()后任務會被立刻刪除;如果任務在此時正好被另一個核心運行,那么釋放內存的步驟會被交給空閑線程(空閑任務)

也引入了TLSP(Thread Local Storage Pointers線程本地存儲指針)機制,當任務刪除時刪除回調函數會被自動執行,這個函數用於釋放被TLSP指向的內存區域

  • TLSP是指向TCB存儲區的指針,它可以讓每個任務都有自己獨立的一套數據結構指針系統;SMP RTOS也提供了通過刪除回調函數和TLSP執行的任務刪除機制:當任務刪除函數被調用,任務轉到空閑線程后觸發這個回調函數,可以配置為自動刪除任務的內存空間,但是不要在這個回調函數中加入阻塞的、延時的、臨界區等相關代碼!盡可能讓回調函數短小來確保系統實時性

  • 回調函數的類型是

    void (*TlsDeleteCallbackFunction_t)(int,void*)
    

    這里針對c語言基礎不太好的老哥解釋一下:這是一個函數指針,它指向一個“以int型變量和任意指針為參數”,“無返回值”的函數

    第一個參數是關聯的TLSP的序號,第二個參數是TLSP自身(它本身就是個指針)

    如果一個刪除回調函數設置為空,那么用戶需要在TLSP被刪除之前手動釋放指向關聯的TLSP指向部分的內存,否則就會造成TLSP指向的部分內存變成“無主內存”,導致內存溢出

雙核任務

使用

BaseType_t xTaskCreatePinnedToCore(TaskFunction_t pvTaskCode,
                                   const char *const pcName,
                                   const uint32_t usStackDepth,
                                   void *const pvParameters,
                                   UBaseType_t uxPriority,
                                   TaskHandle_t *const pvCreatedTask,
                                   const BaseType_t xCoreID);

TaskHandle_t xTaskCreateStaticPinnedToCore(TaskFunction_t pvTaskCode,
                                           const char *const pcName,
                                           const uint32_t ulStackDepth,
                                           void *const pvParameters,
                                           UBaseType_t uxPriority,
                                           StackType_t *const pxStackBuffer,
                                           StaticTask_t *const pxTaskBuffer,
                                           const BaseType_t xCoreID)

創建SMP任務

最后的xCoreID設置為0或1,分別表示單獨在PRO_CPU或APP_CPU上運行任務,也可以設置tskNO_AFINITY來允許任務在兩個核心上運行

SMP RTOS使用輪詢調度算法來進行任務調度,然而當兩個相同優先級的任務同時處於就緒態時會被輪詢算法跳過。應當通過任務阻塞或設置寬優先級的方式避免這種情況

任務掛起僅會對獨立的核心起效,另一個核心上運行的任務不會受到任務掛起的影響

傳統FreeRTOS的xTaskCreate()和xTaskCreateStatic()函數被以內聯函數的形式重定義為上述兩個函數,並默認使用tskNO_AFFINITY作為xCoreID的參數

每個任務控制塊(TCB Task Control Block)將xCoreID作為一個成員存儲起來,因此每個核心都會調用調度器來選擇一個任務來運行,調度器會根據xCoreID成員變量決定是否讓被核心請求運行的任務在該核心上運行(人話:核心請求運行某個任務,調度器會查看這個任務的xCoreID成員變量,如果符合這個核心,就讓任務運行,否則會將任務放到任務鏈表尾並讓當前核心嘗試申請下一個任務)

任務調度

傳統的FreeRTOS通過vTaskSwitchContext()函數執行線程調度。這個函數會從就緒任務鏈表(由處於就緒態的任務組成)中選取最高優先級的任務來運行。但在SMP RTOS中,每個核心都會獨立調用vTaskSwitchContext()來從兩個核心共用的就緒任務鏈表中選取任務來執行。SMP RTOS與傳統FreeRTOS關於任務調度的區別如下所示:

  • 輪詢調度算法:一般的FreeRTOS會在每個任務之間執行輪詢調度,不會遺漏任何任務(一般通過遍歷鏈表的方法進行輪詢);而SMP RTOS可能會在輪詢調度多個相同優先級的就緒態任務中跳過其中的一部分

    傳統FreeRTOS中,使用pxReadyTasksList這個鏈表結構體來管理就緒態任務鏈表,相同優先級的任務被掛到相同鏈表上,這些鏈表被按照優先級從高到低掛到pxReadyTasksList鏈表中,pxIndex指針會指向剛剛被調用過的TCB

    圖示如下:

然而在SMP RTOS中,就緒鏈表被兩個核心共享,因此pxReadyTasksList會包含固定在兩個不同核心上的任務,共用一個核心調用調度器時會發生搶占資源的情況,這種情況下資源調度器會查詢TCB的xCoreID成員變量來決定一個任務是否被允許在當前請求執行的CPU上運行。雖然每個TCB都有一個xCoreID成員變量,但每個優先級鏈表中只有一個pxIndex,因此調度器從某個核心被調用並遍歷鏈表時,他會跳過被標記為另一個核心才能執行的任務,如果另一個核心在此之后請求調度器分配任務,則pxIndex會從頭開始遍歷鏈表,來自另一個核心的上一個調度器並不會在當前核心的當前調度器的考慮范圍內;當一個核心正在執行任務時,另一個核心請求分配任務,會從當前pxIndex的位置向后進行遍歷。這就導致了一個問題:

如上圖所示,藍色和橙色標明了由哪個CPU執行這個任務,當任務A被PRO_CPU執行時,APP_CPU申請分配任務,調度器自動從1向后遍歷,找到了任務C;之后任務A完成,PRO_CPU請求分配任務,調度器從2向后遍歷找到了任務3——這就導致任務B被跳過了!

解決的方法是確保每個任務都會進入一段時間的阻塞態來讓他們從就緒任務鏈表中移除,或是讓每個任務分配不同的優先級

中斷同步

CPU0和CPU1的中斷不同步

不要想當然地用任務延遲函數來進行兩個核心之間的任務同步,如果需要任務同步可以使用信號量來進行

  • 調度器阻塞:一般的FreeRTOS中,使用vTaskSuspendAll()來掛起調度器,這會阻止任務調度,但是中斷服務函數ISR還是會運行;在SMP RTOS中,vTaskSuspendAll()只會阻止一個CPU的任務調度,另一個CPU還是會運行,這個機制很可能會引起數據阻塞、任務不同步等情況,所以最好不要使用vTaskSuspendAll()而是換用互斥量來保護臨界區
  • SMP RTOS中,兩個核心在相同的系統時鍾下可能並沒有運行在相同狀態——兩個核心的調度器、時鍾控制等等都是獨立的,時鍾中斷也是異步的;傳統FreeRTOS中時鍾中斷會觸發一個xTaskIncrementTick()的函數,使得系統時鍾計數器+1,創造出了系統節拍,通過vTaskDelay()可以通過系統節拍進行延時等任務;但在SMP RTOS中使用PRO_CPU處理來自硬件定時器的中斷,並創造出系統節拍(換句話說PRO_CPU是SMP RTOS的心臟),因為各種軟硬件原因,中斷並不會同時到達兩個核心,因此兩個核心任務很可能產生異步行為,延時函數絕對不應當被作為一種同步線程(任務)的方法

臨界區與互斥量

SMP RTOS會使用互斥量訪問臨界區,流程如下

  1. 某任務獲取臨界區互斥量
  2. 關閉線程調度器
  3. 關閉當前核心中斷
  4. 完成任務
  5. 開啟當前核心中斷
  6. 開啟線程調度器
  7. 釋放互斥量
  8. 其他任務可以訪問臨界區

在此期間如果另外核心的任務需要訪問該資源,需要獲取相同的互斥量,但它會被掛起直到當前持有互斥量的任務完成

詳細內容可以參考官網

硬件浮點運算的限制

ESP32支持單精度浮點運算硬件加速。但是使用硬件加速會受到一些SMP RTOS的行為限制。使用浮點數會被自動固定在單一CPU上運行,且浮點數不能在中斷服務例程中使用

ESP32不支持雙精度浮點數的硬件加速,因此雙精度浮點數的運算時間可能比單精度的運算時間慢很多!

可視化編輯

可使用ESP-IDF的menuconfig可視化地配置SMP RTOS相關參數

官方庫中的事件處理函數

wifi、以太網、IP、藍牙這些組件都使用事件event和狀態機FSM讓應用程序處理狀態變化

esp_event庫文件用於取代傳統的事件循環,讓ESP-IDF的事件處理更加方便。所有可能能事件類型和事件數據結構都需要在system_event_id_t枚舉和system_event_info_t聯合中定義;而在的事件循環

使用esp_event_loop_init()來處理事件循環,應用程序通常需要設置一個事件處理函數

傳統的事件處理函數如下:

esp_err_t event_handler(void *ctx, system_event_t *event){}

需要向esp_event_loop_init()傳入一個專門的上下文指針,當使用wifi、以太網、IP協議棧時往往會產生事件,這些事件都會被保存在事件隊列中等待收取,每個處理函數都會獲取一個指向事件結構體的指針,這個指針用於描述現在隊首的事件,這個事件被用聯合標注:event_id、event_info,通常應用程序使用switch結構體與狀態機來處理不同種類的事件

所以在wifi、藍牙、IP協議棧相關代碼中經常會看到大段的switch語句

當需要將事件傳送到其他任務中時,應用程序需要將全部結構體都復制下來並進行傳輸

特別地,藍牙通常使用回調函數來進行事件處理,這些回調函數可以用來收取、發送、處理特定的藍牙協議棧消息;通常也配合各種結構體來使用

ESP32移植FreeRTOS的API簡介

這里以示例程序+API簡介的方式介紹ESP32上的FreeRTOS特性,我對FreeRTOS也處在學習階段(從RTThread和GNU/Linux入手的RTOS),可能會存在不少漏洞,見諒TAT

系統控制

FreeRTOS以任務為程序的最小執行單元,相當於RTT里的線程,擁有自己的上下文。使用信號量、事件集、隊列進行線程間同步與通信

下面是一些控制系統常用的宏定義

configUSE_PREEMPTION//選擇1為搶占式調度器,0則是協作式調度器
configCPU_CLOCK_HZ//MCU內核的工作頻率,單位Hz;對不同的移植代碼也可能不使用這個參數
configTICK_RATE_HZ//FreeRTOS時鍾心跳,也就是FreeRTOS用到的定時中斷的產生頻率
configMAX_PRIORITIES//程序中可以使用的最大優先級
configMINIMAL_STACK_SIZE//任務堆棧的最小大小
configTOTAL_HEAP_SIZE//堆空間大小;只有當程序中采用FreeRTOS提供的內存分配算法時才會用到
configMAX_TASK_NAME_LEN//任務名稱最大的長度,包括最后的'\0'結束字節,單位字節
    
configUSE_COUNTING_SEMAPHORES//是否使用信號量
configUSE_RECURSIVE_MUTEXES//是否使用互斥量遞歸持有
configUSE_MUTEXES//是否使用互斥量
configUSE_TIMERS//是否使用軟件定時器

configTIMER_TASK_PRIORITY//設置軟件定時器任務的優先級
configTIMER_QUEUE_LENGTH//設置軟件定時器任務中用到的命令隊列的長度
configTIMER_TASK_STACK_DEPTH//設置軟件定時器任務需要的任務堆棧大小

任務管理

//創建一個在單核心運行的任務
BaseType_t xTaskCreatePinnedToCore(TaskFunction_t pvTaskCode,
                                   const char *const pcName,
                                   const uint32_t usStackDepth,
                                   void *const pvParameters,
                                   UBaseType_t uxPriority,
                                   TaskHandle_t *const pvCreatedTask,
                                   const BaseType_t xCoreID)//固定執行該任務的核心,不需要則填tskNO_AFFINITY
//用於一般地創建任務,這個API被內聯到了雙核心交替運行任務的API上
static BaseType_t xTaskCreate(TaskFunction_t pvTaskCode,//任務入口函數指針
                              const char *const pcName,//任務名
                              const uint32_t usStackDepth,//任務堆棧大小
                              void *const pvParameters,//任務創建時傳入的參數,如果任務入口函數沒有參數則填NULL
                              UBaseType_t uxPriority,//任務優先級,數字越大優先級越高
                              TaskHandle_t *const pvCreatedTask)//任務回傳句柄,如果沒有任務回傳值則設置為NULL

除了這兩個API用於創建動態任務外,還可以使用以下API創建靜態任務

TaskHandle_t xTaskCreateStaticPinnedToCore(TaskFunction_t pvTaskCode, const char *const pcName, const uint32_t ulStackDepth, void *const pvParameters, UBaseType_t uxPriority, StackType_t *const pxStackBuffer, StaticTask_t *const pxTaskBuffer, const BaseType_t xCoreID)

static TaskHandle_t xTaskCreateStatic(TaskFunction_t pvTaskCode, const char *const pcName, const uint32_t ulStackDepth, void *const pvParameters, UBaseType_t uxPriority, StackType_t *const pxStackBuffer, StaticTask_t *const pxTaskBuffer)

下面是創建任務的例子

void task(void* pvPar)//這個任務傳入了參數
{
	while(1)
	{
		printf("I'm %s\r\n",(char *)pvPar);//傳入的參數在這里調用
        
		vTaskDelay(1000/portTICK_PERIOD_MS);//將任務轉入阻塞態一段時間來達到延時效果
	}
}

void app_main(void)
{
	vTaskDelay(pdMS_TO_TICKS(100));//等待系統初始化

    xTaskCreatePinnedToCore(task,//任務入口函數名作為函數指針調用
    		               "task1",//任務名
			                2048,//任務棧
			               "task1",//傳給任務函數的參數
			                2,//任務優先級
			               NULL,//任務回傳句柄
			               tskNO_AFFINITY);//這個任務將不會固定在某個核心上執行
    xTaskCreate(task,"task2",2048,"task2",2, NULL);
    //創建不固定在某個核心上運行的任務,如果對雙核利用沒有要求,一般情況下可以直接使用這個函數
    
	while(1)
    {
        vTaskDelay(1000/portTICK_PERIOD_MS);//app_main()也被看作一個任務,所以需要設置任務切換
    }
    vTaskDelete();//不會執行到此,但如果不加上面的死循環則必須用這個指令刪除任務防止內存溢出或程序跑飛
}

使用下面的API進行任務延時

//使當前任務掛起xTicksToDelay的時間
void vTaskDelay(const TickType_t xTicksToDelay)
    
//根據系統時間向后延遲到pxPreviousWakeTime
void vTaskDelayUntil(TickType_t *const pxPreviousWakeTime,//任務開始掛起的時間, 第一次使用時必須用當前時間初始化
                      const TickType_t xTimeIncrement)//每次進入掛起的時間

兩個API的不同點在於vTaskDelay()從當前時間開始xTicksToDelay的時間延遲;vTaskDelayUntil()根據pxPreviousWakeTime和xTimeIncrement計算延遲的時間,延遲到系統時鍾為pxPreviousWakeTime時,每次進入延遲的時間為xTimeIncrement

下面是使用例:

vTaskDelay(10);//直接延遲10個時鍾周期

//用下面的函數可以完成恆定頻率的任務
const TickType_t xFrequency=10;
TickType_t xLastWakeTime=xTaskGetTickCount();//獲取當前系統時間
while(1)
	vTaskDelayUntil(&xLastWakeTime,xFrequency);//重復xFrequency延遲

vTaskDelayUntil()可能不太好理解,建議寫幾個程序驗證一下

任務調度

使用以下API控制任務調度

vTaskStartScheduler();//啟動任務調度器
vTaskEndScheduler();//停止使用任務調度器,這將釋放所有內核分配的內存資源,但不會釋放由程序分配的資源

隊列通信與空閑任務

隊列(消息隊列)是任務通信的主要形式

隊列用於在任務和任務之間以及任務和中斷之間發送消息。隊列消息會使用線程安全FIFO進行傳輸

可以使用隊列API函數指定阻塞時間,阻塞時間代表任務進入阻塞狀態,等待隊列中數據或者等待隊列空間變為可以使用時的最大系統節拍數。當一個以上任務在同一個隊列中被阻塞時,高優先級的任務先解除阻塞

使用#include "queue.h"來使用隊列相關的API

項目(消息)在隊列中傳送時,通過復制而不是引用進入FIFO,需要在傳遞項目到隊列時為每個項目分配同樣的大小

//創建新隊列,返回這個隊列的句柄
xQueueHandle xQueueCreate ( 
    unsigned portBASE_TYPE uxQueueLength,//隊列中包含最大項目數量
    unsigned portBASE_TYPE uxItemSize//隊列中每個項目所需的字節數
);

//傳遞項目到隊列
portBASE_TYPE xQueueSend ( 
    xQueueHandle xQueue,//要傳進的隊列 
    const void * pvItemToQueue,//要傳項目的指針 
    portTickType xTicksToWait//等待的最大時間量(單位:系統時鍾) 
);

//從隊列接收一個項目
portBASE_TYPE xQueueReceive ( 
    xQueueHandle xQueue,//項目所在隊列的句柄 
    void *pvBuffer,//指向緩沖區的指針,接收的項目會被復制進去 
    portTickType xTicksToWait//任務中斷並等待隊列中可用空間的最大時間 
);

//從中斷傳遞項目到一個隊列中的后面
portBASE_TYPE xQueueSendFromISR (
	xQueueHandle pxQueue,//將項目傳進的隊列
	const void *pvItemToQueue,//項目的指針
	portBASE_TYPE *pxHigherPriorityTaskWoken//因空間數據問題被掛起的任務是否解鎖
);
/* 如果傳進隊列而導致因空間數據問題被掛起的任務解鎖,並且解鎖的任務的優先級高於當前運行任務,
xQueueSendFromISR 將設置 *pxHigherPriorityTaskWoken 到 pdTRUE
當pxHigherPriorityTaskWoken被設置為pdTRUE 時,則在中斷退出之前將請求任務切換 */

//中斷時從隊列接收一個項目
portBASE_TYPE xQueueReceiveFromISR ( 
    xQueueHandle pxQueue,//發送項目的隊列的句柄 
    void *pvBuffer,//指向緩沖區的指針,接收的項目會被復制進去 
    portBASE_TYPE *pxTaskWoken//任務將被鎖住來等待隊列中的可用空間
);

//移除隊列
void vQueueUnregisterQueue (xQueueHandle xQueue);//要移除隊列的句柄

信號量與互斥量

使用#include "semphr.h"后才能使用信號量相關API

互斥量是特殊的信號量,一般可以用信號量/互斥量替代裸機編程中的全局變量標志

信號量的兩種典型應用

事件計數

事件處理程序在每次事件發生時發送信號量;任務處理程序會在每次處理事件時請求信號量

這樣一邊遞增信號量,一邊遞減信號量,計數值為事件發生和事件處理兩者間的差值

若計數值為正,則存在沒有處理的事件

一般此時將信號量計數值初始化為0

資源管理(臨界區)

如果系統中存在臨界區(多個線程/應用程序同時需要使用的硬件資源)時,一般使用信號量來進行管理

使用信號量計數值指示出可用的資源數量,當計數值降為0時表示沒有空閑資源

任務使用臨界區時申請信號量,而不再訪問臨界區前返還信號量

這種情況下應該將信號量的計數值初始化為臨界區系統資源的值

互斥量與二值信號量

互斥量是一種特殊的二值信號量,又被稱為互斥鎖

二值信號量就是只有兩個可用值的信號量,比如一個只包含了0和1的信號量

互斥鎖包含一個優先級繼承機制,而信號量沒有。這個特點決定了二值信號量適合實現線程間(任務間)同步;互斥鎖更適合實現簡單的互斥

當有另外一個具有更高優先級的任務試圖獲取同一個互斥鎖時,已經獲得互斥鎖的任務的優先級會被提升,已經獲得互斥鎖的任務將繼承試圖獲取同一互斥鎖的任務的優先級。這意味着互斥鎖必須總是要返還的,否則高優先級的任務將永遠也不能獲取互斥鎖,而低優先級的任務將不會放棄優先級的繼承。這就避免了出現互斥鎖卡死的bug

二值信號量並不需要在得到后立即釋放,任務同步可以通過一個任務/中斷持續釋放信號量而另外一個持續獲得信號量來實現

互斥鎖與二元信號量均賦值為xSemaphoreHandle類型,可以在任何此類型參數的API 函數中使用

注意:互斥類型的信號量不能在中斷服務程序中使用

下面介紹互斥量和信號量的相關API

//創建遞歸的互斥鎖
xSemaphoreHandle xSemaphoreCreateRecursiveMutex (void);
/*
一個遞歸的互斥鎖可以重復地被其所有者“獲取”
在其所有者為每次的成功“獲取”請求調用xSemaphoreGiveRecursive()前,此互斥鎖不會再次可用
也就是說,一個任務重復獲取同一個互斥鎖n次,則需要在釋放互斥鎖n次后,其他任務才可以使用此互斥鎖
*/

//獲取信號量與互斥鎖
xSemaphoreTake ( 
    xSemaphoreHandle xSemaphore,//將被獲得的信號量句柄
    portTickType xBlockTime//等待信號量可用的時鍾滴答次數
);//獲得信號量

xSemaphoreTakeRecursive ( 
    xSemaphoreHandle xMutex,//將被獲得的互斥鎖句柄
    portTickType xBlockTime//等待互斥鎖可用的時鍾滴答次數 
);//遞歸獲得互斥鎖信號量

//釋放信號量
xSemaphoreGive (xSemaphoreHandle xSemaphore);
//遞歸釋放互斥鎖信號量
xSemaphoreGiveRecursive (xSemaphoreHandle xMutex);
//從中斷釋放信號量
xSemaphoreGiveFromISR ( 
    xSemaphoreHandle xSemaphore,//將被釋放的信號量的句柄 
    portBASE_TYPE *pxHigherPriorityTaskWoken//因空間數據問題被掛起的任務是否解鎖
);

事件集

FreeRTOS的事件可以理解為多個二值信號量的組合

事件只與任務相關聯,事件之間相互獨立;事件僅用於同步,不提供數據傳輸功能

事件無排隊性,多次向任務設置同一事件,如果任務還未來得及讀走,則等效於只設置一次;允許多個任務對同一事件進行讀寫操作

事件通常可以用來替代裸機編程中的if/switch語句配合枚舉/全局變量標志

EventGroupHandle_t xEventGroupCreate(void);//創建事件標志組,返回事件標志組的句柄

//設置事件標志位
EventBits_t xEventGroupSetBits(EventGroupHandle_t xEventGroup,//事件標志組句柄
                               const EventBits_t uxBitsToSet//事件標志位
);//注意使用前一定要創建對應的事件標志

//從中斷服務程序中設置事件標志位
BaseType_t EventGroupSetBitsFromISR(EventGroupHandle_t xEventGroup,//事件標志組句柄
                                    const EventBits_t uxBitsToSet,//事件標志位設置
                                    BaseType_t *pxHigherPriorityTaskWoken//高優先級任務是否被喚醒的狀態保存
);

ESP-IDF中的事件循環庫

為了處理wifi、藍牙、網絡接口等外設中大量的狀態變化,一般會使用狀態機(FSM),而指示狀態就需要用到事件集。ESP-IDF中提供了可用的事件循環。向默認事件循環發送事件相當於事件的handler依次執行隊列中的命令

事件循環被囊括在事件循環庫(event loop library)中。事件循環庫允許組件將事件發布到事件循環,而當其他組件被注冊到事件循環且設置了對應的處理函數時,程序會自動地在事件發生時執行處理程序。

在ESP32的魔改版FreeRTOS中很少使用正經的事件集,而是使用ESP-IDF提供的更方便的事件循環

使用#include "esp_event.h"即可開啟事件循環庫功能

使用流程如下:

  1. 用戶定義一個事件處理函數,該函數被必須與esp_event_handler_t具有相同的結構(也就是說該函數是esp_event_handler_t類型的函數指針)

    typedef
    void (*esp_event_handler_t)(void *event_handler_arg,//事件處理函數的參數
                                esp_event_base_t event_base,//指向引發事件子程序的特殊指針
                                int32_t event_id,//事件的ID
                                void *event_data)//事件數據
    
  2. 使用esp_event_loop_create()函數創建一個事件循環,該API會傳回一個esp_event_loop_handle_t類型的指針用於指向事件循環。每個用該API創建的事件循環都被稱為用戶事件循環;除此之外,還可以使用一種稱為默認事件循環的特殊事件循環(默認事件循環是系統自帶的事件循環,實際上只使用默認事件循環就足夠了,相關內容在之后敘述)

  3. 使用esp_event_handler_register_with()函數將事件處理函數注冊到事件循環(注意:一個處理函數可以被注冊到多個不同的事件循環中!)

  4. 開始運行程序

  5. 使用esp_event_post_to發送一個事件到目標事件循環

  6. 事件處理函數收取該事件並進行處理

  7. 使用esp_event_handler_unregister_with來取消注冊某個事件處理函數

  8. 使用esp_event_loop_delete刪除不再需要的事件循環

官方給出的流程代碼描述如下:

//1.定義事件處理函數
void run_on_event(void* handler_arg, esp_event_base_t base, int32_t id, void* event_data)
{}

void app_main()
{
    //2.配置esp_event_loop_args_t結構體來配置事件循環
    esp_event_loop_args_t loop_args = {
        .queue_size = ...,
        .task_name = ...
        .task_priority = ...,
        .task_stack_size = ...,
        .task_core_id = ...
    };

    //創建一個用戶事件循環
    esp_event_loop_handle_t loop_handle;
    esp_event_loop_create(&loop_args, &loop_handle);
    //3.注冊事件處理函數
    esp_event_handler_register_with(loop_handle, MY_EVENT_BASE, MY_EVENT_ID, run_on_event, ...);
    ...
    //4.事件源使用以下API將事件發送到事件循環,隨后事件處理函數會根據其中的邏輯進行處理
    //這一系列操作可以跨任務使用
    esp_event_post_to(loop_handle, MY_EVENT_BASE, MY_EVENT_ID, ...)
    ...
    //5.解除注冊一個事件處理函數
    esp_event_handler_unregister_with(loop_handle, MY_EVENT_BASE, MY_EVENT_ID, run_on_event);
    ...
    //6.刪除一個不需要的事件循環
    esp_event_loop_delete(loop_handle);
}

使用如下函數來聲明和定義事件

一個事件由兩部分標識組成:事件類型事件ID

事件類型標識了一個獨立的事件組;事件ID區分在該組內的事件

可以將事件類型視為人的姓,事件ID是人的名

使用以下兩個宏函數來聲明、定義事件類型。一般地,在程序中使用XXX_EVENT的形式來定義一個事件類型

ESP_EVENT_DECLARE_BASE(EVENT_BASE)//聲明事件類型
ESP_EVENT_DEFINE_BASE(EVENT_BASE)//定義事件類型
    
//事件類型舉例:WIFI_EVENT

一般使用枚舉變量來定義事件ID,如下所示

enum {
    EVENT_ID_1,
    EVENT_ID_2,
    EVENT_ID_3,
    ...
}

當注冊一個事件處理函數到不同事件循環后,事件循環可以根據不同的事件類型和事件ID來區分應該執行哪一個事件處理函數

可以使用ESP_EVENT_ANY_BASE和ESP_EVENT_ANY_ID作為注冊事件處理函數的參數,這樣事件處理函數就可以處理發到當前注冊事件循環上的任何事件

默認事件循環

默認事件循環是一種系統事件(如wifi、藍牙事件等)使用的特殊事件循環。特殊的一點是它的句柄被隱藏起來,用戶無法直接使用。用戶只能通過一系列固定的API來操作這個事件循環

API如下表所示

用戶事件循環 默認事件循環 事件循環API
esp_event_loop_create() esp_event_loop_create_default() 創建
esp_event_loop_delete() esp_event_loop_delete_default() 刪除
esp_event_handler_register_with() esp_event_handler_register() 注冊處理函數
esp_event_handler_unregister_with() esp_event_handler_unregister() 取消注冊處理函數
esp_event_post_to() esp_event_post() 事件源發送事件到事件循環

除了API區別和系統事件會自動發送到默認事件循環外,兩者並沒有更多差別,所以說用戶可以將自定義的事件直接發送到默認事件循環,這比用戶定義的事件循環更節約內存且更方便

任務、隊列和事件循環是ESP32中最常用也是最特殊的SMP FreeRTOS API

事件循環庫API簡介

使用以下API控制事件循環

esp_err_t esp_event_loop_create_default(void);//創建默認事件循環
esp_err_t esp_event_loop_delete_default(void);//刪除默認事件循環

//創建用戶事件循環
esp_err_t esp_event_loop_create(const esp_event_loop_args_t *event_loop_args,//事件循環參數
                                esp_event_loop_handle_t *event_loop);//事件循環句柄
    
//刪除用戶事件循環
esp_err_t esp_event_loop_delete(esp_event_loop_handle_t event_loop);//事件循環

esp_err_t esp_event_loop_run(esp_event_loop_handle_t event_loop, TickType_t ticks_to_run);
//將時間分配到一個事件循環,不常用,注意事項一大堆懶得看了——總之詳細用法請參考官網API簡介    

使用以下API來注冊/注銷事件處理函數

//將事件處理程序注冊到系統事件循環
esp_err_t esp_event_handler_register(esp_event_base_t event_base,//事件類型
                                     int32_t event_id,//事件ID
                                     esp_event_handler_t event_handler,//事件處理函數
                                     void *event_handler_arg);//事件處理函數的參數

//將事件處理程序注冊到用戶事件循環
esp_err_t esp_event_handler_register_with(esp_event_loop_handle_t event_loop,//要注冊到的事件循環
                                          esp_event_base_t event_base,//事件類型
                                          int32_t event_id,//事件ID
                                          esp_event_handler_t event_handler,//事件處理函數
                                          void *event_handler_arg);//事件處理函數的參數
//取消注冊(系統事件循環)
esp_err_t esp_event_handler_unregister(esp_event_base_t event_base,//事件類型
                                       int32_t event_id,//事件ID
                                       esp_event_handler_t event_handler);//事件處理函數
//取消注冊(用戶事件循環)
esp_err_t esp_event_handler_unregister_with(esp_event_loop_handle_t event_loop,//要取消注冊的事件循環
                                            esp_event_base_t event_base,//事件類型
                                            int32_t event_id,//事件ID
                                            esp_event_handler_t event_handler);//事件處理函數

可以使用ESP_EVENT_ANY_BASE 和ESP_EVENT_ANY_ID來取消注冊所有事件循環上的事件處理函數

使用以下API來發送事件到事件循環

//發送事件到系統事件循環
esp_err_t esp_event_post(esp_event_base_t event_base,//事件類型
                         int32_t event_id,//事件ID
                         void *event_data,//事件數據
                         size_t event_data_size,//事件數據的大小
                         TickType_t ticks_to_wait);//等待時間

//發送事件到用戶事件循環
esp_err_t esp_event_post_to(esp_event_loop_handle_t event_loop,//要發送到的用戶事件循環的句柄
                            esp_event_base_t event_base,//事件類型
                            int32_t event_id,//事件ID
                            void *event_data,//事件數據
                            size_t event_data_size,//事件數據的大小
                            TickType_t ticks_to_wait)//等待時間

事件循環庫函數會保留事件數據的副本並自動控制副本的存活時間


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM