当单片机遇到状态机 入门QP

当单片机遇到状态机


前言

前些日子在微信上看到李肖遥的公众号,里面系统讲述了QP框架,我很有感触。我用QP框架很多年了,一开始是使用QM和QPC++,到后来抛弃了QM,直接使用QPC裸写程序,到后来自己写状态机框架。可以这么说,QP框架引导了我的技术成长。我共享的博文,虽然都以QP为起点进行展开,但很多东西,都是QP官网的资料所没有的。我希望接受大家的意见、建议和批评,相信对我来说,会有更大的提升。

这一系列的博文,称为《当单片机遇上状态机》系列,暂时先规划以下几篇:

  • 入门QP
    让大家开始使用QP,消除对QP的畏难心理,建立起初步的信心。这一步非常重要。
  • 从switch-case到框架的进化
    大家很难理解,自己用switch-case实现状态机,用的好好的,干嘛要用状态机框架。这篇博文,就是为了说明,switch-case状态机,是如何一步一步进化到一个状态机框架的。我们所写的这个状态机框架,和QP之间,到底有着什么关系,有着多少差距。
  • QP的高阶使用和QM的使用
    QM作为一个辅助工具?它的作用是什么?它是怎么生成代码的?它和QP之间是什么关系?在这一篇里,将会做详细介绍。
  • QP的哲学
    精通QP,理解其哲学思想非常重要。它的哲学思想是什么样的?是如何体现的?
  • 其他
    后续的规划,我希望根据大家的反馈意见而定。我用状态机框架多年,难免做不到换位思考,不能照顾到初学者的感受。希望大家踊跃反馈意见。无论是赞扬还是批评,我都虚心接受。

入门QP

我们学习一个语言,或者一项技术,第一件要做的事情,就是实现一个类似于Hello world的最小程序。在单片机上,当然就是LED灯的闪烁。不说废话了,先上代码。

代码结构

代码结构,可以在Keil工程中看到,是一个QP的运行最小系统。QP版本使用的是最新的V6.9.3版本。
为了便于大家的学习,我抛弃了官方例程。官方例程有些繁琐,里面还有大量的doxygen格式的注释,对初学者不友好。与官方例程相比,能删掉的部分,全部都删掉了,只留下代码和必要中文注释,目的就是为了最大限度降低大家学习QP的入门门槛,也算是中国特色吧。这四个源码,代码未来我们程序架构的不同层次,以后所有的例程,就是以这个代码结构为基础,进行扩充。

还有一个需要说明的,第一个例程,我并没有使用QM建模工具进行LED状态机的建模和代码生成。QM工具,本质上基于模型的开发方法,是形式化开发方法之一。在软件开发中,这种方法一直饱受争议。这个世界现存的大部分软件框架,是不存在所谓代码生成工具的。目前我对QM等建模工具持保守态度,软件开发还是要回归代码本身,能利用工具,但不要依赖工具。QM工具,我认为是QP框架在营销和商业上的需求推动的。因此,在未来的教程中,我将QM的使用,放在次要位置,主要还直接编程为主,我认为这样才会给大家带来真正的提升。

这四个源码分别是:

  • main.c
    包含了硬件的初始化、QP框架的初始化、各状态机模块(暂定称呼,严谨应叫AO模块)的构建,框架的启动等一系列流程。
  • bsp.c
    硬件初始化,此处仅包含SysTick的初始化和SysTick中断函数。
  • ao_led.c
    LED状态机的源码。
  • hook.c
    QP框架的回调函数的实现,此处都为空函数,暂时不予实现。
  • evt_def.h
    事件的定义。QP框架的事件定义,使用枚举实现。个人觉得,事件的定义,如果用字符串实现,更加有利于模块的解耦和对分布式的支持(这个问题可参考后续的博客《将软总线进行到底》)。QP使用枚举来定义事件,个人认为是为了降低RAM和CPU的开销。
  • 其他
    • QP源码
    • QP接口代码
      QP框架对硬件平台或者RTOS的接口源码。
    • MCU相关代码,包含Startup文件、CMSIS相关、固件库相关代码

QP的启动流程

以下代码就是QP框架的启动过程。

#include "qpc.h"                                        // qpc框架头文件
#include "evt_def.h"                                    // 事件定义头文件
#include "bsp.h"                                        // 硬件初始化
#include "ao_led.h"                                     // LED状态机

Q_DEFINE_THIS_MODULE("Main")        // 定义当前的模块名称,此名称在QS和断言中会使用。

ao_led_t led;                                           // 状态机LED对象

int main(void)
{
    static QSubscrList sub_sto[MAX_PUB_SIG];            // 定义订阅缓冲区
    static QF_MPOOL_EL(m_evt_t) sml_pool_sto[128];      // 定义事件池
    
    QF_init();                                          // 状态机框架初始化
    QF_psInit(sub_sto, Q_DIM(sub_sto));                 // 发布-订阅缓冲区的初始化
    QF_poolInit(sml_pool_sto,                           // 事件池的初始化
                sizeof(sml_pool_sto),
                sizeof(sml_pool_sto[0]));
                
    ao_led_ctor(&led);                                  // 状态机的构建
    
    return QF_run();                                    // 框架启动
}

QP的回调函数

通常的调用,都是上层函数调用底层函数。如果使用了某个函数,需要上层实现,这样就产生了底层对上层函数的调用,称为回调函数(Call back),也叫钩子函数(Hook)。

一般而言,回调函数,主要用于顶层功能在底层模块里的插入,或者实现底层模块的定制功能。QP框架定义四个回调函数,需要QP的使用者来实现。

void QF_onStartup(void) {
    bsp_init();                                         // 硬件初始化
}
void QF_onCleanup(void) {}
void QV_onIdle(void) {}
void Q_onAssert(char_t const * const module, int_t const loc)
{
    (void)module;
    (void)loc;
    while (1);
}

QF_onStartup是用于QP框架启动时,所调用的回调函数。一般可以执行一些初始化工作,比如硬件初始化,内存初始化。这也就是为什么在main函数中没有看到硬件初始化的原因。
QF_onCleanup与RTOS相关,暂时用不到。
QV_onIdle是QP框架空闲时,也就是没有任何事件产生时,所执行的函数。
Q_onAssert是QP的断言的实现。断言,是程序一种检查机制,当程序的执行发生异常时,用于检查不可能发生情况。比如下面的函数,当函数func_add的两个参数,都不可能大于或者等于100时,就可以对使用断言进行检查,以防御可能出现的参数输入错误。这种编程方式,也叫做防御式编程。防御式编程的思想就是,若崩溃,就崩溃的更猛烈些,以便在编程的早期,就发现程序错误,并强迫开发者解决掉。具体可以参考后续的博文《谈防御式编程》。

int func_add(int x, int y)
{
    Q_ASSERT(x < 100);
    Q_ASSERT(y < 100);

    return (x + y);
}

系统嘀嗒

在当前的历程中,使用一个QP中自带的协作式内核QV。在使用了QV内核的前提下,SysTick只有一个作用,那就是为时间事件提供时间基准。

#include "bsp.h"
#include "stm32f10x.h"
#include "qpc.h"

void bsp_init(void)
{
    SysTick_Config(SystemCoreClock / 1000);         // 时间基准为1ms
    NVIC_SetPriority(SysTick_IRQn, 0);              // 设置中断优先级
}

void SysTick_Handler(void)
{
    QF_TICK_X(0U, &l_SysTick_Handler);              // 时间基准
}

如果大家需要换一个芯片跑这个例程,那么仅仅需要更换Keil RTE中的Deivce和这里的代码即可。只有这里的代码是硬件相关的。以后大家写程序,也是一样,要执行硬件相关最小原则,也就是说,要把硬件相关的代码压缩到最低。后续也会有博文专门讲这个话题(《将设备抽象进行到底 驱动篇》)。

LED状态机

LED状态机是核心功能,学会了这个,就入门了QP。在QP中,AO(Active Object)是核心,QP的所有功能都是围绕AO展开的,就好比在RTOS中任务是核心一样。AO之间,纯粹靠事件进行通信,原则上是不允许AO间共享全局变量的(详细请参考后续《当单片机遇上并发 Actor篇》)。

LED状态机的类定义

下面是头文件的定义。头文件中,主要定义了LED状态机类,并声明了类方法。这里所说的类,是在逻辑上的类。在C语言中,没有类的概念,只能使用结构体替代类的实现。

#include "qpc.h"

#define AO_LED_QUEUE_LENGTH                 32

// LED类的定义
typedef struct ao_led_tag{
    QActive super;                                      // 对QActive类的继承
    
    QEvt const *evt_queue[AO_LED_QUEUE_LENGTH];         // 事件队列
    QTimeEvt timeEvt;                                   // 延时事件
    
    bool status;                                        // LED状态
} ao_led_t;

// LED的类方法 构造函数
void ao_led_ctor(ao_led_t * const me);

LED状态机是完全按照C语言面向对象的方法实现的。在C语言中,由于在语言层面并没有对面向对象进行支持,因此面向对象的C开发,是运用了一些特殊技巧的。这些技巧,我们会在后续(《将面向对象进行到底 C语言篇》)进行详细介绍。目前,为了增强大家入门的信心,我只说与QP入门相关的东西。

QActive类,简单说就是状态机类。在定义一个状态机对象时,需要从QActive类进行继承。

LED状态机类的实现

LED状态机类的实现,共分为两个部分,一是类方法的实现,二是类状态的实现。

这里只有一个类方法,那就是LED类的构造函数。构造函数,是C++中的概念,C语言中并没有这个概念,这里与类相似,仍然是构造功能的模拟。从代码可以看出,构造函数有几个内容,一个必须的步骤,就是活动对象的构造和启动。构造函数中的另一个内容,就是初始化一个时间事件的对象,因为每500ms要发送一个Evt_Time_500ms事件。

// 活动对象(AO,Active Object)LED的构建
void ao_led_ctor(ao_led_t * const me)
{
    // LED对象的变量初始化
    me->status = false;

    // 活动对象的构建
    QActive_ctor(&me->super, Q_STATE_CAST(&state_init));
    // 时间对象的构建
    QTimeEvt_ctorX(&me->timeEvt, &me->super, Evt_Time_500ms, 0U);
    // 活动对象的启动
    QACTIVE_START(  &me->super,
                    1,                              // 优先级
                    me->evt_queue,                  // 事件队列
                    AO_LED_QUEUE_LENGTH,            // 事件队列深度
                    (void *)0,                      // 任务栈,RTOS相关,可忽略
                    0U,                             // 任务栈深度,RTOS相关,可忽略
                    (QEvt *)0);
}

LED状态类有三个状态,初始状态,ON状态和OFF状态。

  • 初始状态
    所有的初始状态都是一样的,就是先订阅状态机运行所需要的事件。然后直接跳转到某个特定的状态。实际上,事件的订阅,不一定要在初始状态里执行。在状态机运行时,随时都能订阅事件,或者解除对事件的订阅。
    这个事件的订阅机制,就是在软件设计模式中,大名鼎鼎的发布-订阅模式(可参考后续的博文《当单片机遇上设计模式 发布-订阅模式》)。发布-订阅模式的最大好处,就是模块间的彻底解耦。这里插入一个程序设计原则,好的程序,一定是解耦良好的程序。所谓耦合,就是模块A变了,模块B也得跟着变,否则,B模块会运行不正常,模块之间有依赖;所谓解耦,就是去除模块之间的依赖,模块A变了,模块B无须改变。

    // 初始状态
    static QState state_init(ao_led_t * const me, void const * const par)
    {
        // 事件Evt_Time_500ms的订阅
        QActive_subscribe(&me->super, Evt_Time_500ms);
    
        return Q_TRAN(&state_on);
    }
    
  • ON状态

    参数的传输
    从代码中,可以看到,当产生事件时,框架会自动调用state_on函数,led对象,是通过参数me传进来的,这个me指针,相当于C++里的this指针,而所产生的事件,是通过参数e传输进来的。

    事件的处理
    大家注意到代码里有三个事件Q_ENTRY_SIG、Q_EXIT_SIG和Evt_Time_500ms。其中前两个是系统事件,也就是QP框架默认支持的事件。Q_ENTRY_SIG是状态进入事件,当进入一个状态时,QP框架会默认执行这个事件。Q_EXIT_SIG是状态退出事件,当退出一个状态时,QP框架也会默认执行这个事件。Evt_Time_500ms是用户事件,也就是我们自己定义的事件。Q_ENTRY_SIG和Q_EXIT_SIG并不强制定义,而我们要根据自己的需要,看在进入或者退出一个状态时,是否有动作执行,来决定是否对这两个系统事件进行实现。QP还有一个系统事件,Q_INIT_SIG,这个和层次化状态机相关,以后再讨论。

    事件后的返回值
    大家注意到每个状态机在不同的case分支下,都有不同的返回值,比如Q_HANDLED(),Q_TRAN(&state_off)或者Q_SUPER(&QHsm_top)。
    之所以有这些返回值的不同,是为了在处理完毕一个事件后,告诉框架,下一步要干什么。Q_SUPER(&QHsm_top)告诉框架此事件被忽略,什么也不处理;Q_HANDLED()告诉框架,此事件已经处理;而Q_TRAN(&state_off)告诉框架,需要跳转到state_off状态,框架这时会执行当前状态的退出事件和下一个状态的进入事件。

    QP框架的技术约束
    无论是事件处理的机制,还是返回值的格式,都是QP框架的技术约束。任何一个软件框架,在带来编程便利的同时,也会带来性能上的开销和技术的约束。我们要使用一个框架,也就要遵守它制定的技术约束,否则框架就没有办法有效的运行。

    // LED的on状态
    static QState state_on(ao_led_t * const me, QEvt const * const e)
    {
        switch (e->sig) {
            case Q_ENTRY_SIG:                           // 状态的进入事件
                me->status = true;                      // 打开LED灯
                QTimeEvt_armX(&me->timeEvt, 500, 0U);   // 500ms后发送时间事件
                return Q_HANDLED();                     // 通知框架,事件已处理
    
            case Q_EXIT_SIG:                            // 状态的退出事件
                QTimeEvt_disarm(&me->timeEvt);
                return Q_HANDLED();
    
            case Evt_Time_500ms:
                return Q_TRAN(&state_off);              // 通知框架,状态转移至state_off
    
            default:
                return Q_SUPER(&QHsm_top);              // 其他事件,在此时不处理
        }
    }
    
    // LED的Off状态
    static QState state_off(ao_led_t * const me, QEvt const * const e)
    {
        switch (e->sig) {
            case Q_ENTRY_SIG:
                me->status = false;                     // 关闭LED灯
                QTimeEvt_armX(&me->timeEvt, 500, 0U);
                return Q_HANDLED();
    
            case Q_EXIT_SIG:
                QTimeEvt_disarm(&me->timeEvt);
                return Q_HANDLED();
    
            case Evt_Time_500ms:
                return Q_TRAN(&state_on);
    
            default:
                return Q_SUPER(&QHsm_top);              // 其他事件,在此时不处理
        }
    }
    
  • OFF状态
    与ON状态一样,不再赘述。有人可以会提出疑问,在收到Evt_Time_500ms事件的时候,让LED的状态翻转,不必跳转到OFF状态,不就节约了一个状态吗?的确,这样写的确更简练,但我们的目的是为了展示状态机的使用,因此可以增加了一个OFF状态。

上一篇:使用NodeJS将文件或图像上传到服务器


下一篇:C# 模拟浏览器并自动操作