一、技术背景
以前我用过一款庆科的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,分成了驱动开发和应用开发。在软件上做好分层的规划、使用低耦合的程序框架,才能写出优秀的代码。