[i=s] 本帖最后由 begseeder 于 2025-5-27 08:19 编辑 [/i]
[i=s] 本帖最后由 begseeder 于 2025-5-22 15:44 编辑 [/i]
#申请原创# @21小跑堂
前言
在研发阶段需要更新程序时,直接使用调试器进行烧录即可,但是如果想要对一个封装好的产品进行程序升级时,一般都是没有引出烧录接口的,此时只有拆机一途。如果只有一两个需要更新,那么拆也就拆了,但有上百个呢,此时非bootloader不可。本文主旨是在小容量的32单片机上进行开发,比如stm32f103芯片,这样的主控芯片资源比较紧张,但是如果有条件上bootloader,建议一定要做的,功能不需要复杂,只需实现最基本的全量更新就行。
在这基础上,我更想实现的是一插件式的工具,包括boot流程、交互协议、数据流以及flash驱动这4个相关的插件,这个插件机制的实现是基于表驱动的方式,因为表就是策略,怎么驱动表就是机制,一般来说机制是固定的,策略是可替换的,这样就达到了插件的效果,同时满足同一机制的事物可以有多个,这样就把整个bootloader细分出几个具有同样机制的流程结构,通过级联的方式联接部分,每个机制的策略可以进行方便的更换,更大的提高了应用的灵活性。当面对多种应用场景时,这样的插件式设计将极大提高代码的复用能力,方便替换、新增以及删除,从而改变程序的行为,并且更为重要的是这种改变没有影响到程序的主体框架。比如一个项目中通过CAN总线进行更新,而另一个项目使用485,这时候只需要更换相应的插件就可实现功能的调整。
技术要点
表驱动设计
网上关于表驱动的介绍有很多,并且实现起来也是各式各样,但是总体而言使用这种方法的核心还是抽象与辨识,这要求开发者必须对要实现的应用有一个全面的了解和认知,这样才能从无序中识别出有序的主体部分,而这部分必然成为程序的框架部分,是静态的,固定的,而其余的依托框架存在的零散部分,属于易变的,可替换的。要做到以上辨识和分离的操作,还真的需要不少功底。其实只要涉及到某种范式的使用,都或多或少的有一些瓶颈,我现在也正在学习这种方式,它的难点在于怎么构造这些表,表的结构该怎么设计,可以认为这个表就是一个结构化的解空间,所谓解空间就是对表进行输入的所有响应。所以一个好的表,必然有一个可以覆盖可能输入情况的所有响应,当然除了解空间的确定外,求解的方法,或者说把输入变换到解空间的方法也是不可缺少的,这些都极大考验开发者的能力和水平。相比于我之前写代码的思路,那都是直截了当的,平心而论,从任一个局部来看这种代码,完全是心中所思所想的一一映射,也就是完全把我们的思考过程转化为代码,这样的代码应该是很符合逻辑的不是吗。但是从局部上升的系统的整体,就会发现局部的片面和松散,这样的代码的每个部分都会符合原本的预期,但也仅此而已,一把钥匙只能配一把锁,但唯有铁丝才不挑锁。
表驱动与状态机结合
上面只介绍了表驱动的概念,现在介绍一种应用场景,那就是实现状态机。我们都知道在程序中的状态机就是通过定义一组有限状态和其转移的规则来控制系统行为的模型,一般有4个组成部分:状态
、事件
、转换
和 动作
,那么要想基于表驱动实现状态机,就需要把这4个部分添加到表结构中,如下所示:
typedef struct{
State CurState;
Event CurEvent;
void (*Action)(void);
State NextState;
}TransitionItem_t;
这样基本的结构就是表中每一项的内容,而一个表中有多少项,就看开发者自己怎么理解问题和解析问题的了。举个例子,现在有一个按键和一个LED灯,要实现单击时,LED灯常亮,在常亮过程中长按时,LED闪烁,在闪烁过程中单击,LED常灭。在遇到这种应用时,不要直接开始写代码,无论多简单,先分析一下,画个状态图:

然后根据状态图,定义状态表:

上面这个状态表就可以完全设计到我们的代码中,如下所示:
TransitionItem_t FsmTable[] = {
{LED_OFF,KEY_CLICK,LightUp,LED_ON},
{LED_ON,KEY_LONG,Toggle,LED_TOGGLE},
{LED_TOGGLE,KEY_CLICK,LightOff,LED_OFF}
}
那么现在表结构有了,或者说解空间有了,怎么求解呢,最简单的就是查表,对比两个元素,当前状态以及发生的事件,在这个例子中就是 (LED_OFF,KEY_CLICK)
、(LED_ON,KEY_LONG)
以及 (LED_TOGGLE,KEY_CLICK)
,(有没有点像多元离散函数,可以结合着加深对比理解), 只要符合这三个定义好的组合之一,那么必然发生动作,如果不属于三个之一,那么什么反应都没有,也不影响现有状态,驱动代码如下所示:
void RunFsm(TransitionItem_t *fsm,int fsm_size){
/* 判断是否为空指针 */
if(fsm){
for(int i = 0;i<fsm_size;i++){
/* 条件对比 */
if(fsm[i].CurState == GlobalState && fsm[i].CurEvent == GlobalEvent){
/* 执行动作 */
fsm[i].Action();
/* 执行状态转移 */
GlobalState = fsm[i].NextState;
}
}
}
}
Bootloader简介
Bootloader是嵌入式系统中一段特殊的引导程序,用于固件加载或更新等功能,在芯片发生复位时首先会进入Bootloader进行引导,决定是否更新或跳转应用程序。在这里使用的是小容量的单片机,所以Bootloader程序尽量精简,这样可以给应用程序让出更多的空间,一般简单的应用下我们会进行分区,分为Bootloader区和App区,如下图所示:

可以看到大致上最简单的分区方案就是这样,其中app有效标志放在了Bootloader区末尾,这个标志就是Bootloader进行引导的条件。对于固件升级来说,实际上是通过Bootloader对App区域进行擦写动作,数据来源于外部,内容就是App工程编译出来的bin文件,如果在没有其他额外Flash的支持下,进行升级的风险还是蛮大的,所以需要有一定的安全校验措施。
总的来说,要实现一个基本的Bootloader,流程还是比较简单的,大致如下图所示:

可以看到,上图的流程中显示了两种复位情况,即上电复位和软件复位,其中软件复位是从应用程序中进行的复位,是专门为固件升级设定的,表示存在更新请求,当然可以附加更多的信息,同时软件复位有一个特点,就是不会改变RAM的数据;不管哪种复位,只要存在更新请求,就会第一时间把App有效标志位清除,这样如果在更新过程中发生任何问题,标志位都是无效的,避免发生错误跳转的问题,只有完成全部的更新流程,包括校验等操作后才会标记有效;同时,如果是正常的上电复位,或者更新失败后,都会停留在Bootloader中,等待超时,判断有效标志,决定是否跳转。
综合实践
现在经过上面的介绍,已经大致对涉及的技术要求有了一定的了解,那么接下来就是对插件式的Bootloader进行设计了。
有一个很核心的概念,就是策略与机制分离,对应到我们的设计中,机制就是Bootloader功能,策略就是实现功能的方式。乍一看,策略还比较好理解,机制要怎么理解呢,可以认为是一种容器、框架或模型,甚至就是一套固化规则,策略千千万,但都必须映射到机制的规则域中才能在机制的世界中存在。
当然每个人有每个人的理解,不同的理解也产生不同的代码,现在介绍一下我对Bootloader机制的理解,先上图大致描述一下:

从上图中可以看到有4个主要的元素,也可以看作节点,包括通信流程、协议交互流程、Boot流程以及Flash驱动,可以认为要实现一个最基础的Bootloader必须要有这4个元素,而这些也是机制所固有的成分,所有策略都是作用在这4个元素之上的,其中为什么Flash与其他3个不一样,是因为在实现中,Flash读写都是一次性的,并且由Boot流程直接控制;而流程的控制就可以使用状态机来实现,本质上来说,状态机是一个独立的机制,而现在通过表驱动方法,增强了这一机制的通用性和灵活性,只需要更换状态表,就可以实现策略的改变。
那么现在局部元素都介绍完了,该怎么在各元素之间建立关联呢,可以有消息、订阅发布等方式,但是,这些都大材小用了,通过梳理,发现完全可以使用链式通知的方式来建立关系,在上图中可以看到流向中标识的1.1、1.2等字样,用前后级来描述的话就是,由前级主动通知后级该做什么,否则后级什么都不做,当然这个前后级可以不是固定的前后级,是相对的前后级,可以想象一下环形链表,以上就是我所设计的机制内容,它的约束,或者说应用条件可以归纳以下几点:
- 单向流控,链式触发
- 机制结构固定,需识别或转化策略以满足结构要求
下面给出实现机制的主要代码:
状态机的结构:
/* 状态表结构定义 */
typedef struct
{
/* 状态表事件项 */
uint32_t Event;
/* 状态表状态项 */
uint32_t State;
/* 状态表动作项 */
int (*Action)(uint32_t *event, void *arg);
/* 状态表转移项 */
uint32_t NextState;
} TransitionItem_t;
/* 基类状态机定义 */
typedef struct
{
/* 状态表 */
TransitionItem_t *FsmTable;
/* 当前状态 */
uint32_t CurState;
/* 当前发生事件 */
uint32_t CurEvent;
/* 状态表尺寸 */
uint32_t FsmSize;
} BaseFsm_t;
定义了状态表的结构,以及基类状态机的结构,基类状态机提供给4个元素使用,其次是运行状态机的驱动代码:
int RunFsm(BaseFsm_t *fsm, void *arg)
{
/* 防止空指针错误 */
if (fsm->FsmTable != NULL)
{
for (uint32_t i = 0; i < fsm->FsmSize; i++)
{
/* (状态,事件)元组对比 */
if (fsm->CurState == fsm->FsmTable[i].State && fsm->CurEvent == fsm->FsmTable[i].Event)
{
/* 防止空指针错误 */
if (fsm->FsmTable[i].Action != NULL)
{
/* 执行动作 */
fsm->FsmTable[i].Action(&fsm->CurEvent, arg);
}
/* 状态转移 */
fsm->CurState = fsm->FsmTable[i].NextState;
break;
}
}
}
else
{
return -1;
}
return 0;
}
接下来是通信流程的结构设计:
/* 通信流程的结构设计 */
typedef struct
{
struct
{
/* 流程控制状态表 */
BaseFsm_t *Fsm;
/* 驱动器由外部提供 主要是每种通信驱动方式差异比较大,做不到通用 */
void *Driver;
/* 用于通知流程路径中下一节点,适用于单一路径 */
void *Linkto;
} PrivateArea;
/* 初始化 传入状态机和驱动器 都由用户根据框架自定义 */
void (*Init)(BaseFsm_t *fsm, void *driver, void *link);
/* 获取状态机 */
BaseFsm_t *(*GetFsm)(void);
/* 获取通信驱动器 */
void *(*GetDriver)(void);
/* 获取下一节点 */
void *(*GetLink)(void);
} DataStreamHandle_t;
通信节点可以认为是整个机制的触发节点,因为所有的事件流都可以由通信来引导,包括无通信造成的超时事件流;继承了上面的基类状态表,扩展了通信必要的驱动器,还有一些方法定义。
其次是交互协议流程的结构定义:
typedef struct
{
struct
{
/* 流程控制状态表 */
BaseFsm_t *Fsm;
/* 用于通知流程路径中下一节点,适用于单一路径 */
void *Linkto;
/* 协议体 - 封装与解析协议中的数据或协议特征 */
void *ProtoBody;
} PrivateArea;
/* 初始化 传入状态机和协议体 都由用户根据框架自定义 */
void (*Init)(BaseFsm_t *fsm, void *link, void *data_body);
/* 获取状态机 */
BaseFsm_t *(*GetFsm)(void);
/* 获取协议体 */
void *(*GetProtoBody)(void);
/* 获取下一节点 */
void *(*GetLink)(void);
} ProtocalHandle_t;
同样的,继承了基类的状态表,同时扩展了一个协议体属性,这个属性的结构是自定义的,体现协议的通式,用于封装和解析数据使用。
最后就是Boot流程的结构定义:
typedef struct
{
struct
{
/* 流程控制状态表 */
BaseFsm_t *Fsm;
/* flash驱动器 */
void *FlashDriver;
/* 用于通知流程路径中下一节点,适用于单一路径 */
void *Linkto;
} PrivateArea;
void (*Init)(BaseFsm_t *fsm, void *link, void *driver);
BaseFsm_t *(*GetFsm)(void);
void *(*GetFlashDriver)(void);
void *(*GetLink)(void);
} BootloaderHandle_t;
基本和上面的结构大同小异,至此整个机制的结构就定义完成了,接下来就是框架的搭建了。
首先,一般的通信接收功能都会在中断中进行,因为这样实时性最高,能及时的处理数据信息,所以我们的通信流程的触发可以结合接收中断来进行,以CAN接收中断举例:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
uint8_t i = 0;
if (hcan->Instance == CAN1)
{
HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &CANxRxHeader, CANRecvBuf);
if (CANxRxHeader.DLC > 0)
{
/* 作为触发条件 */
CanMsgRecved = 1;
}
}
}
这样,通过状态机就可以控制通信的流程,同时传入通信用的驱动器,因为我们设计的时候都是采用 void *
这种抽象指针,所以灵活程度很高。然后就是协议交互流程和Boot流程的使用,这些都可以放到以某一时基为周期运行的代码块中:
while (1)
{
/* 1ms时基 */
if (TIMEBASE_HOOK(TimeBaseScope, OPT_TIMEBASE_1MS))
{
TIMEBASE_DROP(TimeBaseScope, OPT_TIMEBASE_1MS);
/* 单路径流程,总是通过前级主动通知后级该做什么(自定义事件),同时后级继承(传入)前级的遗产(输出内容) */
/* 放入状态机在直接接收数据的地方 */
RunFsm(DataStreamHandle.GetFsm(), DataStreamHandle.GetDriver());
/* 协议状态机,传入数据收发接口的原生数据形式 比如can报文形式,串口形式 */
RunFsm(ProtocolHandle.GetFsm(), DataStreamHandle.GetDriver());
/* boot流程状态机 */
RunFsm(BootloaderHandle.GetFsm(), ProtocolHandle.GetDataBody());
}
}
因为通过链式传递消息通知,在单路径流程中,总是可以通过前级主动通知后级该做什么(自定义事件),同时后级继承(传入)前级的遗产(输出内容)。以上整个机制的框架的完成了,接下来主要就是策略的定义了,其实就是识别出自定义策略中的事件、状态以及转移,并把这些以状态表的方式呈现出来。
首先给出通信状态图:

通信流程中定义的事件和状态宏定义,以及状态表定义:
/* 通信事件流自定义宏 >>>>>> */
/* 无事件 */
#define E_DATA_NONE 0
/* 有数据进入 */
#define E_DATA_IN (CAST_U32(0X01) << 0)
/* 数据超时 */
#define E_DATA_TIMEOUT (CAST_U32(0X01) << 1)
/* 数据接收结束 */
#define E_DATA_END (CAST_U32(0X01) << 2)
/* 数据请求协议处理 因为只有自己知道自己什么情况,当然得主动通知 */
#define E_DATA_LINK_PTO (CAST_U32(0X01) << 3)
/* 等待协议给过来消息 */
#define E_DATA_RET_PTO (CAST_U32(0X01) << 4)
/* 自定义状态宏 */
/* 空状态 */
#define S_DATA_NONE 0
/* 接收状态 */
#define S_DATA_READING (CAST_U32(0X01) << 0)
/* 数据接收完成状态 */
#define S_DATA_READ_END (CAST_U32(0X01) << 1)
/* 等待协议反馈状态 */
#define S_DATA_WAIT_PTO (CAST_U32(0X01) << 2)
/* 通信事件流自定义宏 <<<<<< */
TransitionItem_t DataStreamTable[] = {
{E_DATA_IN, S_DATA_NONE, BufData, S_DATA_READING},
{E_DATA_TIMEOUT, S_DATA_READING, ResetData, S_DATA_NONE},
{E_DATA_END, S_DATA_READING, LinkProto, S_DATA_READ_END},
{E_DATA_LINK_PTO, S_DATA_READ_END, NULL, S_DATA_WAIT_PTO},
{E_DATA_RET_PTO, S_DATA_WAIT_PTO, SendResp, S_DATA_NONE}
};
其次是交互协议状态图:

对应的事件、状态以及状态表定义如下:
/* 协议收到通信的通知 */
#define E_PROTO_RECV_DATA (CAST_U32(0X01) << 0)
/* 协议解析完成 */
#define E_PROTO_PARSE_OK (CAST_U32(0X01) << 1)
/* Boot反馈 */
#define E_PROTO_RET_BOOT (CAST_U32(0X01) << 2)
/* 空状态 */
#define S_PROTO_NONE 0
/* 解析状态 */
#define S_PROTO_PARSE (CAST_U32(0X01) << 1)
/* 等待Boot反馈状态 */
#define S_PROTO_WAIT_BOOT (CAST_U32(0X01) << 2)
/* 状态表定义 */
TransitionItem_t ProtoTable[] = {
{E_PROTO_RECV_DATA, S_PROTO_NONE, ParseData, S_PROTO_PARSE},
{E_PROTO_PARSE_OK, S_PROTO_PARSE, LinkBoot, S_PROTO_WAIT_BOOT},
{E_PROTO_RET_BOOT, S_PROTO_WAIT_BOOT, RetData, S_PROTO_NONE}
};
最后是Boot流程的状态图:

对应的事件、状态以及状态表定义如下:
/* Boot收到协议的通知 */
#define E_BOOT_RECV_PROTO (CAST_U32(0X01) << 0)
/* Boot起始命令 */
#define E_BOOT_CMD_START (CAST_U32(0X01) << 1)
/* Boot更新命令 */
#define E_BOOT_CMD_UPDATE (CAST_U32(0X01) << 2)
/* Boot结束命令 */
#define E_BOOT_CMD_END (CAST_U32(0X01) << 3)
/* Boot跳转命令 */
#define E_BOOT_CMD_JUMP (CAST_U32(0X01) << 4)
#define S_BOOT_NONE 0
/* Boot准备状态 */
#define S_BOOT_READY (CAST_U32(0X01) << 1)
/* Boot更新状态 */
#define S_BOOT_UPDATE (CAST_U32(0X01) << 2)
/* Boot结束状态 */
#define S_BOOT_END (CAST_U32(0X01) << 3)
/* 状态表定义 */
TransitionItem_t BootTable[] = {
{E_BOOT_RECV_PROTO | E_BOOT_CMD_START, S_BOOT_NONE ResetBoot, S_BOOT_READY},
{E_BOOT_RECV_PROTO | E_BOOT_CMD_UPDATE, S_BOOT_READY, Update, S_BOOT_UPDATE},
{E_BOOT_RECV_PROTO | E_BOOT_CMD_UPDATE, S_BOOT_UPDATE, Update, S_BOOT_UPDATE},
{E_BOOT_RECV_PROTO | E_BOOT_CMD_END, S_BOOT_UPDATE, Check, S_BOOT_END},
{E_BOOT_RECV_PROTO | E_BOOT_CMD_JUMP, S_BOOT_END, JumpApp, S_BOOT_NONE}
};
可以看到状态表中的事件项都是多种事件的组合,因为我们的事件宏定义是通过移位操作进行的,所以提供了组合事件的能力。
至此,Bootloader的机制和策略都制定完毕了,这里只演示了比较简单的策略功能,但因为机制提供了策略更换的能力,所以实现更复杂的功能只需要制定相应的策略状态表即可,实现这个机制也算是一个尝试,学到了很多,同时呢也接触到更高级的内容,还有待进一步深入了解,真真是无知啊,越来越感觉到知识储备不够,设计出来的东西其实价值不是很大,也就自娱自乐而已。
同样是单片机的bootloader,该作者使用花式表驱动操作,打造了一个插件机制,使用一根铁丝,撬开百家锁。文中技术要点阐述专业,实现步骤条理清晰,看得出来是一位优秀的工程师。