韌體更新設計實例 (三) Image檔製作與通訊設計

{alertInfo}遠端韌體更新是嵌入式系統開發者不可或缺的方便工具之一,筆者使用TI 的TMS320F280025C 並搭配 Raspberry Pi 作為傳送更新的系統,透過 I2C 介面將編譯好的 .hex 韌體檔案由 Raspberry Pi傳送到 MCU ,並且正確執行更新後的應用程式。這篇文章說明如何產生要更新的韌體包,他的檔案格式差異以及如何依照軟體包設計傳輸協議。

目錄

    前言

    上篇文章:韌體更新設計實例 (二) Bootloader 與 Code branch 說明如何切換程新舊韌體,主要透過 second bootloader切換程式碼的起始位址來設定。但要如何產生新的韌體檔案呢?在常見的作業系統中,img檔是常用的備份方式,也是系統備份的封装方式。但是對 MCU而言,最後編譯好的韌體包有 .elf,.bin,.out,或者.hex等等格式。這些格式有各自的特點以及缺點,這篇文章會說明他們的特色,最後選用韌體格式的箇中緣由,以及他的產生方法。

    Image 檔案製作

    大部分的嵌入式系統(指使用 GCC toolchain 或者開源工具鍊開發)使用 IDE開發完成之後,經過一連串的預處理,組譯,產生物件檔後連結,最後產生可執行檔。 可執行檔又有很多種檔案格式,如:Linux 系統常用的 .elf檔,嵌入式系統則偏好 .bin檔以及 .out檔,而這次我們選用的是 .hex檔。下面簡單的介紹這四種檔案格式的名稱、用途以及介紹。
    1. elf 檔的全名是 Executable and Linkable Format(可執行與可連結檔案格式)。靈活性高,且跨平台支援,裡面包含 Program header,Section header以及資料段落,被 x86, x64以及 arm平台支援,在作業系統中可以透過 readelf、objdump、nm指令來讀取檔案。對於單晶片的系統來說這個檔案太過龐大且大材小用,我們的應用沒有安裝作業系統,也沒有 Driver或者 Kernel可以執行,所以不採用此格式。
    2. .bin 檔案的全名是 Binary file(二進制檔),是純粹的二進制檔案,由於他並不是文字檔,需要透過特別的讀取程式才能讀取,或者特殊指令如:hexdump。bin檔案的內容會因為使用的 CPU架構不同而以所不同,舉例來說同一條指令 a + b經過編譯後在x86 環境與 arm 環境下的指令會不同,詳細可以參考:深入探索 | Linux下的RISCV開發-GD32VF103 (一) 一文裡頭關於指令級的說明。
      bin 檔其實很通用在MCU內,它體積小內容也很精簡,但是從人類的角度來看是完全看不懂的,這易讀性是 0,且需要特殊工具才能讀取,讀出來又是沒有意義的文字,因此這個方案先保留。
    3. out檔一般來說是工具鍊的組譯器輸出。但在 TI的工具鍊中,他的格式與開發 driver或者常用的 C on OS的格式不同。C2000工具鍊生出來的 .out檔案格式其實是 TI自訂義的 "TI COFF" 格式輸出檔,他雖然也包含 header,Symbol Table, String Table等資訊,但是在根本上與 Unix架構定義的 .out檔案不同。 COFF檔案格式詳細說明可以參考 Application Report - Common Object File Format
      除此之外,C2000 編譯器中還提供另一個選項 "EABI"給使用者選擇輸出的 .out檔案格式,他又是另一種與 COEFF不同的 .out格式之一,差異可以看 Compiler/TMS320F28379D: COFF vs. EABI
      挖哩... 好像越搞越複雜了,但可以簡單理解本文說明的 .out檔案都是基於 TI自己提供的工具鍊與檔案格式所產出的一種輸出檔案即可,而這個檔案可以直接被燒錄到 C2000 MCU中,且具備與傳統 .out檔案相同的架構,也需要特殊工具能讀取,這個方案易讀性比 bin檔好一點,也先保留。
    4. hex 檔的全名其實是Intel hexadecimal object file format,最早在1975年就被發明,他將編譯好的二進制韌體檔案用 ASCII碼的形式儲存起來,如此一來可以存在非二進制儲存媒介中,如:紙帶、洞洞卡等。此外,把二進制檔案變成ASCII碼的好處除了增加人類易讀性之外,也不需要像前幾種格式透過專用工具才能開啟,隨便 geditor或者 notepad++就能打開,且他的資料也很好理解。而現在電腦科學技術越來越方便,很多邊緣裝置都可以執行 Python,甚至也有MocroPython的出現,小型 MCU也能執行 Python程式碼。像 Python這種直譯式語言也很常用字元或字串作為操作媒介, Intel hex檔案可以執行的變化就又多更多了!
    這四者的比較如下表:
    格式 全名 功能與描述 應用場景
    .hex Intel HEX ASCII 格式,用來儲存機器碼(Machine Code)。
    每一行包含位址、數據、校驗碼等資訊。
    常用於微控制器(MCU)或嵌入式設備的程式燒錄。
    與燒錄工具(例如 ST-LINK、J-Link)配合使用。
    .out Output File 通常為目標檔案(Object File)的輸出格式,包含執行代碼和符號資訊。
    格式依編譯器不同而異(例如 GCC)。
    開發過程中用於除錯(Debugging)。
    包含符號資訊,用於檢查變數、函數地址等內容。
    .bin Binary File 純二進位碼格式,直接包含執行代碼,不帶有任何額外資訊。 用於燒錄工具,尤其是需要緊湊的檔案時(如 MCU 記憶體有限)。
    用於與其他程式或設備交換純數據內容。
    .elf Executable and
    Linkable Format
    用於嵌入式開發中的執行與除錯。
    常與除錯工具(如 GDB)配合使用以進行進階調試。
    用於嵌入式開發中的執行與除錯。
    常與除錯工具(如 GDB)配合使用以進行進階調試。

    今日的技術雖然發達,也有前述的各種高級檔案格式,但intel hex傳統格式易讀性高,不需要額外工具就可以讀取,且檔案大小也不會太大綜合來說直接打趴上述方案。
    除了有一定的易讀性,使用 ASCII作為儲存格式也非常廣泛的相容於各種不同程式語言、裝置、平台。除此之外,所有的通訊協議都支持字元傳輸,也有內建的標準函式庫可以達成這項功能,這樣在未來要更換通訊協議,或者移植都會更方便。

    HEX 檔案格式說明

    TMS320C28x Assembly Language Tools v22.6.0.LTS User's Guide (Rev. Z)文件第12.15.2節中說明 Intel MCS-86 Object format的檔案格式如下圖:
    這個格式把一行資料分成起始位元,資料位址,資料長度,資料種類,Payload以及 Check sum。 資料種類又分為
  1. 00:Data Record 資料內容,存放真實資料,內容就是韌體內容。注意這裡的格式都是低位元優先(LSB first)
  2. 01:End-of-file 檔案結尾符,此格式出現表示檔案已經傳送到最後一行。
  3. 04:Extended linear address record 位址拓展內容,用於拓展資料位址的位元數量。也可以理解成 Address的 offset。
  4. 下圖是 hex檔案的實際範例以及資料說明,Notepad++很貼心的用不同的顏色幫我們標記資料格式的位置以及型態。
    第一行的資料長度是 2 byte,起始位址是 0x0000,資料種類是04也就是位址拓展,而實際資料是0x8000,最後 check sum是 0xF2。意思是說,整個檔案的位址有一個偏移,偏移量是 0x8000。
    :020000040008F2

    第二行就開始記錄韌體內容,資料長度一樣是 2 byte,起始位址是8000,但加上上一行的位址偏移,實際上會將這行內容存放在0x88000的記憶體位址中。
    接著便是資料內容 0xEF48,以及最後的check sum。
    :0280000048EF47

    而上圖沒記錄到的是,在檔案結束的時候會有一行相同的內容代表檔案結束,從資料種類 01我們就知道它代表的是 End-of-file,這裡很特別,照上述邏輯解析這行資料會發現它的資料長度是 00,但也合理,因為這行資料沒有實際的韌體程式在裏頭,一行固定的結尾資料。
    :00000001FF

    筆者節錄一個完整的韌體檔案可以參考如下:
    :020000040008F2
    :048000000048AB6326
    :20800800761B0005ABBDAABDA2BDA8BDA0BDC2BDC3BDFE04FF6929425616FF207300764842
    :20801800A83D1E44924340A9FFEC0166761F01FC1A020020761F02849218FFE101575201D3
    :20802800FFE101045202FFE100AC5203FFE001549221D100FFE10087F5A973068F00A15A5E
    :20803800969C0B209220606B2B182B1D2B21921B61495201610E52046070761F02855603C3
    :20804800081A761F028494CC0EA91E2E0B256F65F61F2884FFFF8D80A06C8F00A0BCF63F7C
    :208058002884FFFF8AA276C8F000D008761F0281F64F24848F00A18256BF01C292C496921F
    :2080680092CC8F08F0009634761F0284A8A95C1A7640C4118DC07F008AA3761F0284D550A3
    :20807800DC021AC400108F08F000921A9641A8A98AA27640C2E7761F02841AE300102B19CA
    :208088006F2C8DC07F008AA38D80A0BCDC021AC40010921A96418AA206305D227640C2E74C
    :20809800761F02848F00A15A1AE300102B25F61F2884FFFFF63F2882FFFF6F0F40A16C0D52
    :2080A80092A1610B58A1D88156030894949C58258F00A0BC96940A25D90192A15421FFE878
    :2080B800FF7D0A1D922096219221521066069221600656BF01216F0356BF10219321F5A9C1
    :2080C8007320CCA9FFE05002F4A97320F5A9732118A9FFE0CAA8F4A97321FFEF00AD921A73
    :2080D80052026050D1008F00A15AF5A67306A8A90DA18AA9D9017EC492C4721F92A1520457
    :2080E80068F38F00A15A92C49C01962092DC961B921B52016007560308CC94D40EA91E30CA
    :08AB800000069A010006000620
    :02F0000000000E
    :04F008000001000102
    :20F01000005000570052002D003200300030002D005700560044002D003500340031003211
    :0CF02000002D004F0046002D0050004D58
    :20F0280000540048004900530020004900530020004400410054004100200054004F0020B7
    :20F038000042004500200045005200410053004500200049004E00200046004C00410053A4
    :02F0480000487E
    :00000001FF
    

    解析了 intel hex 檔案格式,我們下一步就是把這個檔案放到 MCU的 Flash 記憶體中,並且讓 MCU執行這些資料,就可以更新我們的韌體!
    只不過..我們要怎麼傳送這接資料並且寫入到 MCU中?第一篇文章有比較 I2C,UART以及 SPI的差異,那實際上要怎麼設計通訊協議或者格式呢?
    讓我們繼續看下去...

    通訊協議設計

    我們知道 hex檔的資料種類有3種,00、01跟04,又每一行資料會有一個 length byte紀錄這行資料的數據長度,而且每行資料有起始位元 ":",節數也會有換行符號,那就可以對應的設計出傳輸格式。
    首先我們定義資料種類的 enum
    typedef enum {
        DATA_RECORD     = 0,
        END_OF_FILE     = 1,
        ADDRESS_RECORD  = 4,
    }intel_hex_type_t;

    先假設有個設備可以把這個檔案中的所有字元透過 I2C傳送給我們的 MCU,那它一定是依照順序傳來,所以只要依照資料格式順序去設計狀態機就可以完成資料接收。
    首先是起始符號":",它轉換成 ASCII碼是 0x58,我們可以檢查判斷傳送過來的資料有沒有包含這個符號,藉此知道他是韌體資料的一部分。
    但筆者最後的設計把所有的":"符號在傳輸前全部移除,因為從第一篇文的流程圖知道,再傳送韌體前就有經過一系列的檢查動作,而且也想加速資料傳輸,於是移除":"字元不會影響安全性,且可以更快的完成韌體更新。
    下一個 byte是資料長度,我們要用這個 byte來設定 FIFO的準位 (FIFO level)。當 FIFO內累積的資料達到 FIFO level的程度,I2C模組就會發送一個中斷訊號提醒 CPU要來處理暫存器內的資料。當 CPU讀取這個資料的時候,FIFO就會自動被重置(礙於篇幅,詳細請參考TRM的說明)。
    這個長度位元會是可變化的,也就如同上面的實際檔案,我們不能預知邊譯出來的韌體包他每一個記憶體區段的長度會是多少,所以這部分一定要設計成可以彈性化調整,且可以自適應的方式。
    到這裡我們可以有一個判斷的邏輯,依照資料種類判斷,同時用資料長度 byte設定 FIFO level。於是,可以寫出以下 switch case:
    switch(i2c_package_type){
    	case(DATA_RECORD):
    		LED4_ON;
    		program_flash_data(flash_write_address, (uint16_t*)flash_buffer, flash_write_length, flash_status);
    		LED4_OFF;
    		flash_buffer_idx = 0;
    		memset((void*)i2c_recv_buff, 0xffff, I2C_BUFF_SIZE);
    		memset((void*)flash_buffer, 0xffff, FLASH_BUFF_SIZE);
    		break;
    	case(ADDRESS_RECORD):
    		flash_bias_address = (i2c_recv_buff[0] << 8) + i2c_recv_buff[1];  // combine significant 16-bit address
    		flash_buffer_idx --;
    		break;
    	case(END_OF_FILE):
    		memset((void*)i2c_recv_buff, 0xffff, I2C_BUFF_SIZE);
    		memset((void*)flash_buffer, 0xffff, FLASH_BUFF_SIZE);
    		// backup sector 15 data
    		memcpy((void*)firmware_info_flash, (void*)Bzero_Sector15_start, 80);
    		// modify flash_status data
    		firmware_info_flash[0] = 1;     // change firmware status = MODIFIED
    		firmware_info_flash[8] = new_fw_version[0];
    		firmware_info_flash[8] = new_fw_version[1];
    		// erase section 15
    		erase_flash_sector(Bzero_Sector15_start, flash_status);
    		LED4_ON;
    		// write new data into section 15 for next boot branch to new address
    		program_flash_data(Bzero_Sector15_start, (uint16_t*)firmware_info_flash, 80, flash_status);
    		LED4_OFF;
    		system_status = NORMAL;
    	break;
    }
    這段程式碼依照資料種類的不同,做出對應的行為。當資料格式是 data record的時候,就把後面得資料寫入;當資料格式是 address record的時候,則更新 flash bias address;如果收到結尾符號的時候,代表韌體更新檔案已經全部傳送完畢,可以更新在 Flash中的韌體版本了。
    除此之外,由於資料長度是可變的,但 I2C的 RX FIFO最大也只有 16 byte,我們需要一個機制讓 MCU可以自動設定適當的 FIFO level大小。當 I2C通訊的狀態機是 FW_DATA的時候,我們設計一個變數:i2c_remain_data,這個變數在第一包韌體資料進來的時候,會等於資料長度位元。但當我 FIFO level被觸發一次後,便會減去當前的 FIFO level,也就是代表還剩餘多少資料還沒經過 FIFO被 CPU處理。
    有這個變數我們就可以處理任意長度的資料,同時又利用 FIFO的特色降低 CPU的利用率。實際程式碼節錄如下:
    case(FW_HEADER):
        // resolve firmware data package
        if(flash_status != ERASE_DONE){
            i2c_send_error();
            break;
        }
        for(i = 0; i < 4; i++){
            i2c_recv_buff[i] = I2C_getData(I2CA_BASE);
            i2c_valid_cksum += i2c_recv_buff[i];
        }
        i2c_remain_data = i2c_recv_buff[0] + 1;                                         // get data length + 1 (check sum)
        i2c_package_type = (intel_hex_type_t)i2c_recv_buff[3];                          // get data record type
        if(i2c_package_type != END_OF_FILE)
            flash_write_address = (flash_bias_address << 16) + (i2c_recv_buff[1] << 8) + i2c_recv_buff[2];  // get flash address to write
        else
            flash_write_address = (i2c_recv_buff[1] << 8) + i2c_recv_buff[2];
    
        flash_write_length = (i2c_recv_buff[0] / 2);
        if(flash_write_length % 8 != 0)             // Align to 8-bit width
            flash_write_length = (flash_write_length/8 +1) *8;
    
        i2c_rx_fifo_level = i2c_remain_data;
        if(i2c_remain_data > 16)                    // set RXFIFO level
            i2c_rx_fifo_level = 16;
        I2C_setFIFOInterruptLevel(I2CA_BASE, I2C_FIFO_TX2, (I2C_RxFIFOLevel)i2c_rx_fifo_level);   // set RXFIFO level
        i2c_fifo_ctr = 0;
        i2c_data_type = FW_DATA;
    break;
    

    小結

    這邊文章比較了四種常見的韌體檔案格式,包含 .elf, .bin, .out以及 .hex,並說明選擇 .hex檔案的原因除了易讀性比較高、不需要額外工具就能讀取之外,重要的是他使用 ASCII編碼,可以廣泛的使用不同的通訊協議進行傳輸。
    接著,文章還舉實際 .hex檔案作為範例,說明他的檔案格式還有如何利用現有的檔案格式設計一個通訊協議,讓 MCU直接利用該檔案就將韌體寫入到 Flash之中。
    利用 hex本身的格式,我們設計出一個通訊協議可以將資料長度與 check sum記錄下來,並且使用 FIFO來降低 I2C模組中斷 CPU的次數。

    參考資料

  5. A Brief History of TI Object File Formats
  6. 猴子都寫得出來的 RISC-V CPU Emulator系列 第 30 篇 - Hello: 真的哈囉
  7. Post a Comment

    留個言吧

    較新的 較舊