[Bonjour STM32] No.3-单片机抽象编程思想小议(附状态机建模方法)

[Bonjour STM32] No.3-单片机抽象编程思想小议(附状态机建模方法)

作者 日期 工具
CNPP 2020-05-14 STM32开发环境,任意带有按键和灯的STM32小板

0. 前言

经过了前面几篇文章的介绍,相信大家已经了解CubeMX工具和HAL库函数是如何帮助我们痛快地建立STM32工程了。在这篇文章中,我将与大家讨论如何让STM32成为我们得心应手的工具,分享一些我在实际应用中的心得。

1. 何为抽象,为何抽象

还记得HAL的含义吗——硬件抽象层,我起初听见这个名词时,立刻就联想到抽象艺术一类的东西,感觉陌生,神秘,捉摸不透。在思考啥是这里的抽象之前,我们先来实际感受一下:

还记得这样一条函数吗:void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);,我们来尝试朗读一下:“无返回值函数-硬件抽象层-通用IO口-翻转-参数1:IO口结构体指针-参数2:IO口具体管脚”。

等等,这是人话吗,别急,其实你已经理解了,不信你看:

词语的顺序并不定一能影阅响读

比如当你看完这句话后

仔味细回一下

才发这现话段里的字全是都乱的

开个玩笑。其实你已经知道这句代码的意思是:“HAL库的函数翻转IO口,指定好是哪一组IO和哪一个管脚就能用了,没有返回值。”其实,你不仅理解了,也已经记住了。此后,一旦想翻转一个IO,你就会边默读边敲:“我在使用HAL_库,要操作HAL_GPIO_,想让他翻转一下HAL_GPIO_Tog翻转是Tog…啥来着,此时,编辑器已经帮你补全了代码,按下回车,HAL_GPIO_TogglePin一气呵成”,你瞧,中途不用翻寄存器手册,甚至连API都不用去查。这便是抽象的含义之一,让人能直接理解代码的里层含义。

Tips:*GPIOxGPIO_Pin这两个参数,有时候还是要查,比如想点亮一个LED,还要找电路图看看LED接在哪个引脚上。其实在CubeMX配置脚时,我们可以把接LED的管脚,假设是PC13命名(User Lable)为LED,这样在生成的工程的main.h中会帮我们定义:#define LED_GPIO_Port GPIOC#define LED_Pin GPIO_PIN_13,这也相当于做了一下抽象,我们在填参数时想这“LED…”,然后自动补全就可填入LED_GPIO_PortLED_Pin,十分方便,同时以后读代码时也可以直接得知这是在操作LED灯。

在C语言开发的体系下,我们把直接操作寄存器,直接与硬件相关的代码叫做底层代码,即BL(Bottom Level),而我们使用的HAL库即是将BL打包,封装成具有高度描述性和可读性的一套函数(这其中还有各种结构体的封装,类似面向对象)而我们自己编写的代码部分,可以称作是应用层,直接与要解决的问题挂钩。

使用HAL编写应用层代码,不仅简单方便,可读性强,更重要的是HAL将不同型号STM32单片机底层硬件之间的差异掩盖了,有了HAL的帮助,我们的应用层代码成为了可拆装的模块,经过简单修改甚至无需修改,就可以用于几乎任意型号的STM32单片机。我认为,这就是抽象的第二个含义——模块化。

2. 把代码做成积木

有了HAL库的示范,我们在编写自己的代码时,也要有意识地组织好各个层次,将代码模块化,以便重复使用。这样的思考,在开始分析解决方案之初就要同步进行。这里,我以编写“OLED显示屏驱动代码”为例说明(不过并不是真的开始写代码哈,关于各种屏幕的驱动我们打算也写一系列小文,敬请期待):

首先我们明确这套代码全部使用HAL编程,可以适配任意一款STM32单片机;其次,需要有单片机与屏之间通信接口的代码,这个代码是要根据不同的单片机以及应用场景进行修改的;再次,需要有针对屏幕的驱动代码,包括初读写屏幕寄存器,初始化指令等,这套代码因屏而异,是随着屏幕走的;最后,要有一套画图算法代码,包括画点、线、方框、圆等,以及各种字体的数组,这部分代码与硬件关联较小,可以适配到多种屏上。因此,我们的代码构架如下:

  • OLED显示屏驱动代码组(共10个C文件,使用时只需调用OLED_Top.h
    • OLED_Top.c
    • OLED_Top.h (在其中调用以下8个文件)
      1. 通信接口
        • OLED_Interface.c
        • OLED_Interfase.h
      2. 屏幕驱动
        • OLED_Driver.c
        • OLED_Driver.h
      3. 画图算法及字体
        • OLED_GFX.c
        • OLED_GFX.h
        • OLED_Front.c
        • OLED_Front.h

相当于将8个零件组装到Top基板上,形成了一个模块,将此模块组装到不同STM32单片机工程中时(甚至不局限于STM32单片机),只需要稍加修改相应零件就行了。

我们在实际应用时通常需要将单片机与各种外部硬件安装在PCB上,对于这些外设,如屏幕、按键、传感器等的驱动代码,我们也通常以模块化的方式加以打包,并形象地称之为BSP(Board Support Package,板级支持包)

所以在建立CubeMX生成工程后,我习惯以Custom为前缀在工程目录下建立我自己的文件夹,用于存放文档、BSP以及更高层的代码:

YBcmi6.png

接下来,我们通过一个实例,介绍一下STM32抽象化编程的过程。

3. 示例:有限状态机

设想有这样一个任务:有一个带有一个用户按键和一个LED的STM32系统板,要求每按一次按键后,LED灯在每秒闪烁一下一秒内前半秒闪烁五下,后半秒灭每秒闪烁五下间轮流切换。如何实现?

通常来说解决这种“交互性较强”的问题,曾经C语言教程中的流程图就不好用了,我们在这里使用的方法叫做状态机建模,即把整个问题抽象成多个稳定的状态,以及状态之间相互跳转的条件,如下图:

graph LR
    A((每秒闪烁一下))
    B((一秒内前半秒闪烁五下后半秒灭))
    C((每秒闪烁五下))
    A--按一次按键-->B
    B--按一次按键-->C
    C--按一次按键-->A

这就是一个状态机,由于状态个数有限,故称为有限状态机(FSM)。其中,FSM本身作为一个较高层的模块,跳转条件这里是按键,我们将按键扫描作为一个单独的模块。用CubeMX建立工程(注意,STM32的具体型号并不重要;另外,请留意对具体IO进行命名抽象的操作):
YBcnJK.png

生成工程之后,建立相应的用户文件夹,BSP以及相关C文件:

YBcZIx.png

接下来,遵循自上而下的设计思想,先编写FSM部分代码,从bsp_fsm.h开始,列出可能用到的函数:

#ifndef __BSP_FSM_H
#define __BSP_FSM_H

// 引用单片机相关头文件
#include "main.h"

// 引用FSM中用到外设的头文件
#include "gpio.h"
//#include "bsp_keyscan.h"

// 函数原型
void BSP_Init(void);        // FSM中所有涉及模块的初始化代码放在这里
void BSP_FSM_Run(void);     //FSM主体,放在单片机主循环里循环执行
void BSP_Deinit(void);      // FSM中所有涉及模块的还原代码放在这里

#endif

接着在bsp_fsm.c中编写具体的函数:

#include "bsp_fsm.h"

// 枚举状态名,指示各个状态,增强可读性
typedef enum {
    S0 = 0,
    S1,
    S2
} StateTypeDef;

// 枚举触发状态,标示是否发生触发,增强可读性
typedef enum {
    TRIGGED = 0,
    NOTRIG
} TrigTypeDef;

// 声明状态
static StateTypeDef State;

// 每个状态中执行的函数原型
static void TASK_S0(void);
static void TASK_S1(void);
static void TASK_S2(void);
static void TASK_ERR(void);

// 触发状态跳转的函数原型
static TrigTypeDef TRIG_S0_S1(void);
static TrigTypeDef TRIG_S1_S2(void);
static TrigTypeDef TRIG_S2_S0(void);

// 在发生状态跳转时执行的函数原型
static void OUTPUT_S0_S1(void);
static void OUTPUT_S1_S2(void);
static void OUTPUT_S2_S0(void);

// 初始化状态以及用到的外设
void BSP_Init(void) {
    State = S0;
//    BSP_KeyScan_Init();
}

// 状态机主体框架,此函数将在主循环中调用,反复执行
void BSP_FSM_Run(void) {
    switch (State) {
        case S0 :
            if (TRIG_S0_S1() == TRIGGED) {
                State = S1;
                OUTPUT_S0_S1();
            } else {
                TASK_S0();
            }
        break;

        case S1 :
            if (TRIG_S1_S2() == TRIGGED) {
                State = S2;
                OUTPUT_S1_S2();
            } else {
                TASK_S1();
            }
        break;

        case S2 :
            if (TRIG_S2_S0() == TRIGGED) {
                State = S0;
                OUTPUT_S2_S0();
            } else {
                TASK_S2();
            }
        break;

        default :
            TASK_ERR();
        break;
    }
}

// 还原外设以及状态
void BSP_Deinit(void) {
    State = S0;
//    BSP_KeyScan_Deinit();
}

// 以上部分形式较为一致,改变应用场景时,作相应的增减即可
// 下面为TASK,TRIG,OUTPUT三组函数的声明,它们特异化的部分较多

// 本例中这里的是各种闪灯函数,需要依赖bsp_fsm.h中声明的外设的头文件了
// 每秒闪烁一下
static void TASK_S0(void) {
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
    HAL_Delay(500);
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
    HAL_Delay(500);
}
// 一秒内前半秒闪烁五下,后半秒灭
static void TASK_S1(void) {
    uint8_t i;
    for (i = 0; i < 5; i++) {
        HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
        HAL_Delay(100);
        HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
        HAL_Delay(100);
    }
    HAL_Delay(500);
}
// 每秒闪烁五下
static void TASK_S2(void) {
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
    HAL_Delay(100);
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
    HAL_Delay(100);
}
// 发生错误的代码,暂不用
static void TASK_ERR(void) {

}

// 本例中,查找按键缓冲区来判断是否跳转
// 这里作者鸽了直接简单写了一下,没用FIFO,按键扫描下次再说(下次一定)
static TrigTypeDef TRIG_S0_S1(void) {
    TrigTypeDef trig = NOTRIG;
    if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
        if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
            trig = TRIGGED;
        }
    }
    return trig;
}

static TrigTypeDef TRIG_S1_S2(void) {
    TrigTypeDef trig = NOTRIG;
    if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
        if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
            trig = TRIGGED;
        }
    }
    return trig;
}

static TrigTypeDef TRIG_S2_S0(void) {
    TrigTypeDef trig = NOTRIG;
    if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
        if (HAL_GPIO_ReadPin(Key_0_GPIO_Port, Key_0_Pin) == GPIO_PIN_RESET) {
            trig = TRIGGED;
        }
    }
    return trig;
}

// 本例中没有用到输出,所以这里空置
static void OUTPUT_S0_S1(void) {

}

static void OUTPUT_S1_S2(void) {

}

static void OUTPUT_S2_S0(void) {

}

接着,在Keil工程中添加我们的C文件,并设置好头文件查找路径:

YDDrMd.png

下载运行即可。

在运行过程中,我们会发现一些问题:

  1. 有时候按下按键,没有按正确的顺序跳转,即多次跳转,这是按键消抖算法不完善导致的误判断。在之后的教程中,我们会介绍完整的按键驱动算法。
  2. 有时候按下按键,却没有反应。阅读我们的FSM代码可知,在TASK中,我们使用了延时函数HAL_Delay来配合实现闪灯函数,同时其也阻塞着我们的CPU,再加上我们是在FSM循环中直接扫描按键的,这就导致我们按下按键时可能错过了扫描窗口。这向我们传达了两点:一,按键要配合FIFO机制使用才能保证检测有效;二,我们要尽一切可能避免对CPU的阻塞,比如尽可能减少延时函数的使用。

4. 后记

哈,很”抽象“地讲完了我对单片机抽象编程的一点理解,写了几行粗劣的代码,真的十分感谢各位朋友能看到最后,衷心期待能与大家一起交流单片机的各种玩法。

其实说实话感觉自己也许不那么适合做嵌入式开发哈哈,感觉自己执行力有点差,也没心潜下来研究一些较大的工程······可能是因为最近太沉迷模拟电路了吧。后面我也许会给大家带来一些硬件电路的制作过程分享,敬请期待哦!

发表回复