[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:
*GPIOx
和GPIO_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_Port
和LED_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个文件)- 通信接口
OLED_Interface.c
OLED_Interfase.h
- 屏幕驱动
OLED_Driver.c
OLED_Driver.h
- 画图算法及字体
OLED_GFX.c
OLED_GFX.h
OLED_Front.c
OLED_Front.h
- 通信接口
相当于将8个零件组装到Top
基板上,形成了一个模块,将此模块组装到不同STM32单片机工程中时(甚至不局限于STM32单片机),只需要稍加修改相应零件就行了。
我们在实际应用时通常需要将单片机与各种外部硬件安装在PCB上,对于这些外设,如屏幕、按键、传感器等的驱动代码,我们也通常以模块化的方式加以打包,并形象地称之为BSP(Board Support Package,板级支持包)
所以在建立CubeMX生成工程后,我习惯以Custom为前缀在工程目录下建立我自己的文件夹,用于存放文档、BSP以及更高层的代码:
接下来,我们通过一个实例,介绍一下STM32抽象化编程的过程。
3. 示例:有限状态机
设想有这样一个任务:有一个带有一个用户按键和一个LED的STM32系统板,要求每按一次按键后,LED灯在每秒闪烁一下
、一秒内前半秒闪烁五下,后半秒灭
和每秒闪烁五下
间轮流切换。如何实现?
通常来说解决这种“交互性较强”的问题,曾经C语言教程中的流程图就不好用了,我们在这里使用的方法叫做状态机建模,即把整个问题抽象成多个稳定的状态,以及状态之间相互跳转的条件,如下图:
graph LR
A((每秒闪烁一下))
B((一秒内前半秒闪烁五下后半秒灭))
C((每秒闪烁五下))
A--按一次按键-->B
B--按一次按键-->C
C--按一次按键-->A
这就是一个状态机,由于状态个数有限,故称为有限状态机(FSM)
。其中,FSM本身作为一个较高层的模块,跳转条件这里是按键,我们将按键扫描作为一个单独的模块。用CubeMX建立工程(注意,STM32的具体型号并不重要;另外,请留意对具体IO进行命名抽象的操作):
生成工程之后,建立相应的用户文件夹,BSP以及相关C文件:
接下来,遵循自上而下的设计思想,先编写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文件,并设置好头文件查找路径:
下载运行即可。
在运行过程中,我们会发现一些问题:
- 有时候按下按键,没有按正确的顺序跳转,即多次跳转,这是按键消抖算法不完善导致的误判断。在之后的教程中,我们会介绍完整的按键驱动算法。
- 有时候按下按键,却没有反应。阅读我们的FSM代码可知,在TASK中,我们使用了延时函数
HAL_Delay
来配合实现闪灯函数,同时其也阻塞着我们的CPU,再加上我们是在FSM循环中直接扫描按键的,这就导致我们按下按键时可能错过了扫描窗口。这向我们传达了两点:一,按键要配合FIFO机制使用才能保证检测有效;二,我们要尽一切可能避免对CPU的阻塞,比如尽可能减少延时函数的使用。
4. 后记
哈,很”抽象“地讲完了我对单片机抽象编程的一点理解,写了几行粗劣的代码,真的十分感谢各位朋友能看到最后,衷心期待能与大家一起交流单片机的各种玩法。
其实说实话感觉自己也许不那么适合做嵌入式开发哈哈,感觉自己执行力有点差,也没心潜下来研究一些较大的工程······可能是因为最近太沉迷模拟电路了吧。后面我也许会给大家带来一些硬件电路的制作过程分享,敬请期待哦!