一、技術背景
以前我用過一款慶科的WiFi模組——EMW3162,它由一塊STM32F205RG芯片 + SDIO接口的射頻芯片組成,有趣的是官方將這顆STM32芯片內部Flash做了很多塊的劃分,如下圖所示。
可以看到1MB的Flash被分割成了5部分,分別是:
1. Bootloader,一段引導代碼,一般用於更新APP程序。
2. 信息區,存放OTA的一些信息和用戶參數。
3. 用戶應用區,也就是APP區,用戶可以二次開發後將代碼燒錄到此處。
4. OTA暫存區,接收OTA數據,接收完成後再複製到用戶應用區。
5. 射頻驅動區,用於存放SDIO射頻模組的驅動,供上層使用。
這樣劃分的好處是顯而意見的,廠家不用提供射頻驅動的源碼,甚至連LIB庫都不用提供,並且用戶在OTA時,僅需更新應用區的代碼,無疑加快了OTA的速度。缺點嘛也是有的,因爲每個區都要預留一些Flash空間,所以劃分得越細,浪費的空間也就越多。當然相比起優點,這點缺點還是可以接受的。下文提供了一種方法來實現這種應用,可能和慶科的實現方式不一樣,僅可作爲一種思路。
二、技術方案
以下將用戶業務層稱爲App,將驅動層稱爲Driver,我們要實現的是將Driver編譯成一個固定的bin文件,即Firmware.bin,讓App能夠跨bin文件調用Driver中的函數,而且要求Driver層的更新不能影響App的訪問。
我們知道,C語言中的函數名實際上是個地址,只要知道了函數的地址、函數的格式,就可以調用這個函數。所以關鍵點在於如何讓App知道Firmware.bin中各個函數的地址。以下提供了幾種不同的方案,爲了方便說明,下文所有所述內容均不包括Bootloader區、Param區,僅有App區和Driver區。
方案一:在Driver工程中,聲明一個結構體,結構體成員爲供App使用的函數指針,然後定義一個初始化函數,該函數用於完成上述結構體指針的初始化,然後將該函數放到某一約定好的地址,App需要根據該地址調用這個初始化函數,這樣App就獲得了所需的Driver中的函數指針。
方案二:將Driver中的對外函數地址按4字節對齊,順序地排列到Driver區的起始地址上,App層聲明一個結構體,結構體成員爲所需的函數指針,然後定義一個該結構體指針,強制指向Driver區的起始地址,因爲STM32中的函數指針是4字節的,所以這個結構體指針的成員實際已經指向了Driver層對應的函數。
由於方案二更簡潔,所以本文使用方案二來實現目的。
三、技術原理
3.1 核心思想
根據方案二所述,最大的難點在於如何將函數地址按順序排列到Driver區的起始地址上。事實上,答案就在ST提供的啓動文件裏,啓動文件爲中斷向量表分配了固定的Flash空間,我們完全可以仿照這一方法。對啓動文件不熟悉的讀者可以參考我的另一篇文章:STM32啓動流程詳解。
下面的例子使用的是Keil MDK編譯平臺,芯片爲STM32F103RCT6,Flash和Ram分配如下:
3.2 Driver工程代碼
以下是Driver工程仿照啓動文件編寫的彙編文件,用於將所需函數地址按序排列在起始地址上。
PRESERVE8
THUMB
; Vector Table Mapped to Address 0 at Reset
AREA FIRMWARE, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD FirmwareInit
DCD LED_ON
DCD LED_OFF
DCD uart_put_char
DCD delay_ms
DCD GetCount
__Vectors_End
__Vectors_Size EQU __Vectors_End - __Vectors
AREA |.text|, CODE, READONLY
EXPORT FirmwareInit [WEAK]
EXPORT LED_ON [WEAK]
EXPORT LED_OFF [WEAK]
EXPORT uart_put_char [WEAK]
EXPORT delay_ms [WEAK]
EXPORT GetCount [WEAK]
FirmwareInit
LED_ON
LED_OFF
uart_put_char
delay_ms
GetCount
B .
END
因爲分配了Flash和Ram地址,且上述彙編文件中定義了新的段:FIRMWARE段,Driver工程的分散加載文件也需要修改,以下是修改後的分散加載文件。對分散加載不夠了解的讀者,可以看我的另一篇文章:STM32鏈接腳本詳解。
LR_IROM1 0x08030000 0x00010000 { ; 加載時域
ER_IROM1 0x08030000 0x00010000 { ; 第一段運行時域
*.o (FIRMWARE, +First) ; FIRMWARE段最先編譯
;*(InRoot$$Sections) ; 因爲FirmWare沒有__main,所以不需要這個段
.ANY (+RO)
}
RW_IRAM1 0x20008000 0x00004000 { ; 第二段運行時域
.ANY (+RW +ZI)
}
}
由於沒有__main函數幫忙重定位RW數據段和ZI數據段,所以Driver層還需要手動將Flash中的RW段複製到Ram對應的運行地址,並將Ram中對應的ZI段清零,代碼如下。
static void RW_And_ZI_Init (void)
{
extern unsigned char Image$$ER_IROM1$$Limit; // 獲取RW段在FLASH中的加載地址
extern unsigned char Image$$RW_IRAM1$$Base; // 獲取RW段在RAM中的運行地址
extern unsigned char Image$$RW_IRAM1$$RW$$Limit; // 獲取RW段在RAM中的結束地址
extern unsigned char Image$$RW_IRAM1$$ZI$$Limit; // 獲取ZI段在RAM中的結束地址
unsigned char * psrc, *pdst, *plimt;
psrc = (unsigned char *)&Image$$ER_IROM1$$Limit;
pdst = (unsigned char *)&Image$$RW_IRAM1$$Base;
plimt = (unsigned char *)&Image$$RW_IRAM1$$RW$$Limit;
while(pdst < plimt) // 將FLASH中的RW段拷貝到RAM的RW段運行地址上
{
*pdst++ = *psrc++;
}
psrc = (unsigned char *)&Image$$RW_IRAM1$$RW$$Limit;
plimt = (unsigned char *)&Image$$RW_IRAM1$$ZI$$Limit;
while(psrc < plimt) // 將RAM中的ZI段清零
{
*psrc++ = 0;
}
}
然後將RW_And_ZI_Init函數再做一層封裝。以下程序同時初始化了一些外設,並提供了一個測試函數供App獲取Firmware中的全局變量。
/* 初始化Firmware */
void FirmwareInit(void)
{
SystemInit(); // 初始化系統時鐘、中斷向量
RW_And_ZI_Init(); // 初始化Firmware的RW段、ZI段
delay_init(); // systick初始化
uart_init(115200); // 串口1初始化
LED_Init(); // LED初始化
}
/* 返回Firmware中的一個全局變量並加1 */
unsigned int GetCount(void)
{
return cnt++;
}
3.3 App工程代碼
#define FIRMWARE_ADDR 0x08030000 /* Firmware的起始地址 */
typedef struct FUN /* 聲明Firmware提供的函數類型 */
{ /* 注意函數的順序要和Firmware保持一致 */
void (*FirmwareInit)(void);
void (*LED_ON)(void);
void (*LED_OFF)(void);
int (*uart_put_char)(int ch);
void (*delay_ms)(u16 nms);
u32 (*GetCount)(void);
}FUNC_S;
/* 定義一個全局結構體指針,強制指向Firmware的起始地址 */
FUNC_S *gFunc = (FUNC_S*)(FIRMWARE_ADDR);
int main(void)
{
gFunc->FirmwareInit(); /* 初始化Firmware */
while(1)
{
printf("Cnt = %d\r\n",gFunc->GetCount());
gFunc->LED_ON();
gFunc->delay_ms(500);
gFunc->LED_OFF();
gFunc->delay_ms(500);
}
}
爲了方便說明,上述程序裁剪掉了printf的實現,並將結構體的聲明直接放在了c文件裏。整個工程僅需一個main.c和ST提供的啓動文件即可,連標準庫或HAL庫都不用添加,因爲底層的初始化已經在Firmware中完成了。App的分散加載文件也需要根據Flash和Ram的劃分做簡單的修改,這裏就不再贅述了。
四、總結
上述例子只是一個demo,實際要考慮的問題更多,例如哪些代碼存放在Firmware中,哪些代碼應該存放App中。上述例子直接將標準庫也放到了Firmware中,實際上會有一些問題,因爲沒有考慮到中斷向量的偏移,但可以事先在Firmware中寫好對應的中斷處理函數,然後在App的中斷服務函數中被調用。關於中斷的設想暫時沒有去實際驗證,但這樣有一個好處:如果有Boot工程,那麼Boot也可以調用Firmware的API,例如避免了Boot和App都存一份標準庫,大大節省空間。
這種開發方式將單片機開發拉向了Linux,分成了驅動開發和應用開發。在軟件上做好分層的規劃、使用低耦合的程序框架,才能寫出優秀的代碼。