本帖最后由 laocuo1142 于 2023-11-23 11:06 编辑
1、牛刀小试
规则描述:
L1L2 状态转换顺序 OFF/OFF--->ON/OFF--->ON/ON--->OFF/ON--->OFF/OFF
通过按键控制 L1L2 的状态,每次状态转换只需按键 1 次
从上一次按键的时刻开始计时,如果 10 秒钟之内没有按键事件,则不管当前 L1L2 状态如何,一律恢复至初始状态。
L1L2 的初始状态 OFF/OFF
现在我们用状态机+事件/消息驱动的思想来分析问题。系统中可提取出两个事件:按键事件和超时事件,分别用事件标志 KEY 和 TOUT 代替。L1L2 的状态及转换关系可做成一个状态机,称为主状态机,共 4 个状态:LS_OFFOFF、LS_ONOFF、 LS_ONON、 LS_OFFON 。主状态机会在事件 KEY 或 TOUT 的驱动下发生状态迁移,各个状态之间的转换关系比较简单,在此略过。
事件/消息驱动机制的任务就是检测监控事件 KEY 和 TOUT,并提交给主状态机处理。检测按键需要加入消抖处理,消抖时间定为 20ms, 10S 超时检测需要一个定时器进行计时。
这里将按键检测程序部分也做成一个状态机,共有 3 个状态:
WAIT_DOWN :空闲状态,等待按键按下
SHAKE :初次检测到按键按下,延时消抖
WAIT_UP :消抖结束,确认按键已按下,等待按键弹起
按键状态机的转换关系可在图 10 中找到。按键检测和超时检测共用一个定时周期为 20ms 的定时中断,这样就可以把按键检测和超时检测的代码全部放在这个定时中断的 ISR 中。我把这个中断事件用 TICK 标记, 按键状态机在 TICK 的驱动下运行, 按键按下且消抖完毕后触发 KEY 事件, 而超时检测则对 TICK 进行软时钟计数,记满 500 个 TICK 则超时 10S,触发 TOUT 事件。
有了上面的分析,实现这个功能的程序的结构就十分清晰了, 图 10 是这个程序的结构示意图,这张图表述问题足够清晰了,具体的代码就不写了。仔细瞅瞅,是不是有点儿那个意思了?
如果忽略定时中断 ISR 中的细节,图 10 中的整个程序结构就是事件/消息驱动+主状态机的结构, ISR 是消息的生产者,与消息缓冲、派发相关的程序部分是管理者,而主状态机则是消息的消费者,应用层代码中只有这一个状态机,是消息的唯一消费者。
这个结构就是通用框架 GF1.0 的标准结构:多个 ISR + 一个消息缓冲区 + 一个应用层主状态机。ISR 生成的消息(事件)全部提交主状态机处理, 在消息的驱动下主状态机不断地迁移。
如果把应用层主状态机看做是一台发动机, 那么 ISR 生成的消息则是燃料, 事件不断的发生, 消息不断的生成,有了燃料(消息)的供给,发动机(主状态机)就能永不停息地运转。
接下来关注一下图 10 中的 ISR, 这个 ISR 里面的内容是很丰富的, 里面还套着 2 个小状态机:按键状态机和计时状态机。按键状态机自不必说, 这个计时部分也可以看做是一个状态机,不过这个状态机比较特殊,只有一个状态 DELAY。
既然是状态机, 想要跑起来就需要有事件来驱动, 在这个 ISR 里, 定时器的中断事件 TICK就是按键状态机和计时状态机的驱动,只不过这两个事件驱动+状态机结构没有消息缓冲,当然也不需要消息缓冲,因为状态机在 ISR 中,对事件是立即响应的。
从宏观上看,图 10 中是事件/消息驱动+状态机,从微观上看,图 10 中的 ISR 也是事件驱动+状态机。ISR 中的状态机在迁移过程中生成消息(事件),而这些消息(事件)对于主状态机来讲又是它自己的驱动事件。事件的级别越高, 事件自身也就越抽象, 描述的内容也就越接近人的思维方式。我觉得这种你中有我我中有你的特点正是事件驱动+状态机的精髓所在 。
2、通用框架GF1.0
前面说过, 状态机总是被动地接受事件, 而 ISR 也只是负责将消息(事件)送入消息缓冲区,这些消息仅仅是数据,自己肯定不会主动地跑去找状态机。那么存储在缓冲区中的消息(事件)是怎么被发送到目标状态机呢?
把消息从缓冲区中取出并送到对应的状态机处理,这是状态机调度程序的任务,我把这部分程序称作状态机引擎(State Machine Engine , 简写作 SME)。图 11 是 SME 的大致流程图。
从图 11 可以看出, SME 的主要工作就是不断地查询消息缓冲队列,如果队列中有消息,则将消息按先入先出的方式取出, 然后送入状态机处理。SME 每次只处理一条消息, 反复循环,直到消息队列中的消息全部处理完毕。
当消息队列中没有消息时, CPU 处于空闲状态, SME 转去执行“空闲任务”。空闲任务指的是一些对单片机系统关键功能的实现无关紧要的工作,比如喂看门狗、算一算 CPU 使用率之类的工作,如果想降低功耗,甚至可以让 CPU 在空闲任务中进入休眠状态,只要事件一发生, CPU 就会被 ISR 唤醒,转去执行消息处理代码。
实际上, 程序运行的时候 CPU 大部分时间是很“闲” 的, 所以消息队列查询和空闲任务这两部分代码是程序中执行得最频繁的部分,也就是图 11 的流程图中用粗体框和粗体线标出的部分。
如果应用层的主状态机用压缩表格驱动法实现,结合上面给出的消息模块, 则GF1.0 的状态机引擎代码如程序清单 List11 所示。
「程序清单List11:」
void sme_kernel(void);
/***************************************
*FuncName : main
*Description : 主函数
*Arguments : void
*Return : void
*****************************************/
void main(void)
{
sys_init();
sme_kernel(); /*GF1.0 状态机引擎*/
}
/***************************************
*FuncName : sme_kernel
*Description : 裸奔框架 GF1.0 的状态机引擎函数
*Arguments : void
*Return : void
*****************************************/
void sme_kernel(void)
{
extern struct fsm_node g_arFsmDrvTbl[]; /*状态机压缩驱动表格*/
INT8U u8Err = 0; /**/
INT8U u8CurStat = 0; /*状态暂存*/
MSG stMsgTmp; /*消息暂存*/
struct fsm_node stNodeTmp = {NULL, 0}; /*状态机节点暂存*/
memset((void*)(&stMsgTmp), 0, sizeof(MSG)); /*变量初始化*/
gbl_int_disable(); /*关全局中断*/
mq_lock(); /*消息队列锁定*/
mq_init(); /*消息队列初始化*/
mq_unlock(); /*消息队列解锁*/
fsm_init(); /*状态机初始化*/
gbl_int_enable(); /*开全局中断*/
while(1)
{
if(mq_is_empty() == FALSE)
{
u8Err = mq_msg_req_fifo(&stMsgTmp); /*读取消息*/
if(u8Err == MREQ_NOERR)
{
u8CurStat = get_cur_state(); /*读取当前状态*/
stNodeTmp = g_arFsmDrvTbl[u8CurStat]; /*定位状态机节点*/
if(stNodeTmp.u8StatChk == u8CurStat)
{
u8CurStat = stNodeTmp.fpAction(&stMsgTmp); /*消息处理*/
set_cur_state(u8CurStat ); /*状态迁移*/
}
else
{
state_crash(u8CurStat ); /*非法状态处理*/
}
}
}
else
{
idle_task(); /*空闲任务*/
}
}
}
3、状态机与ISR在驱动程序中的应用
在驱动层的程序中使用状态机和 ISR 能使程序的效率大幅提升。这种优势在通信接口中最为明显,以串口程序为例。
单片机和外界使用串口通信时大多以数据帧的形式进行数据交换,一帧完整的数据往往包含帧头、接收节点地址、帧长、数据正文、校验和帧尾等内容,图 12 所示为这种数据帧的常见结构。
图12 表明的结构只是数据帧的一般通用结构, 使用时可根据实际情况适当简化, 例如如果是点对点通信, 那么接收节点地址 FRM_USR 可省略;如果通信线路没有干扰, 可确保数据正确传输,那么校验和 FRM_CHKSUM 也可省略。
假定一帧数据最长不超过 256 个字节且串口网络中通信节点数量少于 256 个,那么帧头、接收节点地址、帧长、帧尾都可以用 1 个字节的长度来表示。虽然数据的校验方式可能不同,但校验和使用 1~4 个字节的长度来表示足以满足要求。
先说串口接收, 在裸奔框架 GF1.0 的结构里, 串口接收可以有 2 种实现方式:ISR+消息 orISR+缓冲区+消息。ISR+消息比较简单, ISR 收到一个字节数据,就把该字节以消息的形式发给应用层程序,由应用层的代码进行后续处理。这种处理方式使得串口接收 ISR 结构很简单,负担也很轻, 但是存在 2 个问题。
数据的接收控制是一个很底层的功能, 按照软件分层结构, 应用代码不应该负责这些工作,混淆职责会使得软件的结构变差;用消息方式传递单个的字节效率太低, 占用了太多的消息缓冲资源,如果串口波特率很高并且消息缓冲区开的不够大,会直接导致消息缓冲区溢出。
相比之下, ISR+缓冲区+消息的处理方式就好多了, ISR 收到一个字节数据之后, 将数据先放入接收缓冲区,等一帧数据全部接收完毕后(假设缓冲区足够大),再以消息的形式发给应用层,应用层就可以去缓冲区读取数据。
对于应用层来讲,整帧数据只有数据正文才是它想要的内容,数据帧的其余部分仅仅是数据正文的封皮, 没有意义。从功能划分的角度来看, 确保数据正确接收是 ISR 的职责, 所以这部分事情应该放在 ISR 中做,给串口接收 ISR 配一个状态机,就能很容易的解决问题。图 13为串口接收 ISR 状态转换图。
图13 中的数据帧使用 16 位校验和,发送顺序高字节在前,低字节在后。接收缓冲区属于 ISR 和主程序的共享资源,必须实现互斥访问,所以 ISR 收完一帧数据之后对缓冲区上锁, 后面再发生的 ISR 发现缓冲区上锁之后, 不接收新的数据, 也不修改缓冲区中的数据。
应用层程序收到消息, 读取缓冲区中的数据之后再对缓冲区解锁, 使能 ISR 接收串口数据和对缓冲区的写入。数据接收完毕后,应该校验数据,只有校验结果和收到的校验和相符,才能确信数据正确接收。
数据校验比较耗时,不适合在 ISR 中进行,所以应该放在应用代码中处理。这样实现的串口接收 ISR 比较复杂,代码规模比较大,看似和 ISR 代码尽量简短,执行尽量迅速的原则相悖, 但是由于 ISR 里面是一个状态机, 每次中断的时候 ISR 仅执行全部代码的一小部分,之后立刻退出,所以执行时间是很短的,不会比“ISR+消息” 的方式慢多少。
串口发送比串口接收要简单的多,为提高效率也是用 ISR+缓冲区+消息的方式来实现。程序发送数据时调用串口模块提供的接口函数, 接口函数通过形参获取要发送的数据, 将数据打包后送入发送缓冲区, 然后启动发送过程, 剩下的工作就在硬件和串口发送 ISR 的配合下自动完成,数据全部发送完毕后, ISR 向应用层发送消息,如果有需要,应用层可以由此获知数据发送完毕的时刻。图 14 为串口发送 ISR 的状态转换图。
图片
上面只是讨论了串口设备的管理方法, 其实这种状态机+ISR 的处理方式可以应用到很多的硬件设备中,一些适用的场合:
标准的或自制的单总线协议 (状态机+定时中断+消息)
用 I/O 模拟 I2C 时序并且通信速率要求不高 (状态机+定时中断+消息)
数码管动态扫描 (状态机+定时中断)
键盘动态扫描 (状态机+定时中断 )
小结
裸奔框架 GF1.0 处处体现着事件驱动+状态机的思想, 大到程序整体的组织结构, 小到某个ISR 的具体实现,都有这对黄金组合的身影。从宏观上看, 裸奔框架 GF1.0 是一个 ISR+消息管理+主状态机的结构, 如图 15 所示。
不管主状态机使用的是 FSM(有限状态机)还是 HSM(层次状态机), GF1.0 中有且只有 1 个主状态机。主状态机位于软件的应用层, 是整个系统绝对的核心, 承担着逻辑和运算功能, 外界和单片机系统的交互其实就是外界和主状态机之间的交互, 单片机程序的其他部分都是给主状态机打杂 的。
从微观上看, 裸奔框架 GF1.0 中的每一个 ISR 也是事件驱动+状态机的结构。ISR 的主要任务是减轻主状态机获取外界输入的负担, ISR 负责处理获取输入时硬件上繁杂琐细的操作,将各种输入抽象化,以一种标准统一的数据格式(消息)提交给主状态机,好让主状态机能专注于高级功能的实现而不必关注具体的细节。
图片
裸奔框架 GF1.0 应用的难点在于主状态机的具体实现,对于一个实际的应用,不管功能多复杂, 都必须将这些功能整合到一个主状态机中来实现。这既要求设计者对系统的目标功能有足够详细的了解, 还要求设计者对状态机理论有足够深的掌握程度, 如果设计出的状态机不合理,程序的其他部分设计得再好,也不能很好的实现系统的要求。
将实际问题状态机化,最重要的是要合理地划分状态,其次是要正确地提取系统事件,既不能遗漏, 也不能重复。有了状态和事件, 状态转换图的骨架就形成了, 接下来就是根据事件确定状态之间的转换关系,自顶向下,逐步细化,最终实现整个功能。
|