系列文章目录
前言
- 这里我要附加一点我自己的私货,对于产品而言不是说你使用非阻塞式程序就更好,我希望每一个看到这篇文章的人其实都应该思考一下,并不能一句话否定那些简单的,被普遍使用的技术,我个人认为应该根据产品的需求出发,根据系统架构需求来判断这个技术在产品的定位上是不是有利的,这个才是我个人认为最主要的。而非是在看见别人使用的阻塞式技术就一眼否定,或许在不同的产品中阻塞式的方式能起到不同的作用以实现不同的需求。
- 这点当解决更多需求的时候就会发现了,如果看官有更好的非阻塞式设计思路,可以指明。
- 对于这篇文章的看官,这篇文章的目的是介绍这种思路,或者说是一种优化的方向,若有误请指出,感激不尽。
提示:以下是本篇文章正文内容
一、什么是非阻塞式程序
- 对于一般的单片机而言,有时候会根据实际需求来设计延时函数,来使得程序能够达到我们所需要的效果,比如对于单一线程的发布事件任务,对于这种任务如果需求不复杂,且并无其他需求,完全可以使用systick的延时来实现定时事件发布,而在我们常用的delay函数中使用循环while等,来实现cpu空转,这一种方式就是阻塞式程序的典型例子。
- 但是上面这种例子一般都是对于非实时性的任务而言才能使用的方法,但实际上在当今工业生产亦或者是消费电子产品上,我们更多的需要产品能够实时对人类的交互或者是实际生产过程中的变量做出对应的反应,在这种方式中我们还使用以上delay的方式,就会在cpu空转的时间内,无法对外界变化产生反应,从而造成我们常说的卡顿现象。
- 非阻塞式程序就是指能实时反应外界变化的程序或者说不会长时间浪费cpu算力的程序。
- 在一般使用过程中主要就表现为delay系统延时函数的使用。
二、设计思想
- 对于产品的需求不同而言,实现的非阻塞式方式不同,具体的话在不使用freertos的方式下,总结下来就是两种思路:中断和遍历。
- 接下来的介绍主要讲这两种方式在实际应用中的思路,具体的代码我会按时更新不同的示例,主要是典型性和普适性。
1.中断
- 中断,在单片机中表现为可以打断cpu执行的任务,跳转到某些特殊事件的处理任务的形式。
- 这种形式在我们非阻塞式程序设计的过程中,主要表现的优势在于其对于特殊事件的实时性,直白说就是反应快,能对外界变化及时做出反应,前提是单片机不死机的情况。
- 但是我们这里谈的是中断的思想,也就是说让单片机能主动跳出当前进程去处理另一个进程的形式(这是freeRTOS,UCOS等的形式),这是一种非固执式的思想,就是说我不会坚持把这件事直接做完,我会在做完一部分之后被老板喊着跳去别的事情里面干活,因为我还有别的更要紧的事情要做。
- 这一种思想的表现形式的典型就是中断,当然她还会有别的表现形式,但我这里只是挑出这一种形式表明。
2.遍历
- 遍历,在单片机中表现为顺序执行的进程,会按照程序编写的形式来执行人类安排的任务。
- 这种形式在非阻塞式程序的设计过程中,和普通的程序又有什么不同呢?我前面谈到了非阻塞式程序在我认知中的定义,不会长时间占用cpu,那在这个遍历的方式中表现出来就是在进程中添加对特殊情况是否发生的判断的代码,也就是标志位的思想。
- 说句实话这个形式,其实是我使用的最多的方式,我经常会在构建屏幕UI的时候使用这种思想,并且这种标志位的思想实际上在状态机编程中就已经烂背于心。
- 但是这种思想在实际应用中在状态或者特殊情况繁多的情况下,就会造成程序的繁琐以及浪费部分性能(当然也浪费不了多少算力)。
3.其他形式
- 其实理论上,在实际生产工作中也会使用诸如DMA,MMU,共享内存的其他外设进行辅助的形式来减少CPU的工作量,但是这里我不做主要介绍,这里主要是注重非阻塞式程序的设计思想。
三.设计思路
这里我会从最经典的按键扫描开始,不断向后更新后续的经典非阻塞式程序的设计思路。
1.GPIO输入输出端口扫描
a.阻塞式设计
- 这里其实应用场景不限于我们常说的按键形式,只要是使用GPIO口的电平作为某个标志位的情况都可以使用。(但还是按键用的多)
- 我们使用实体来与单片机进行交互的方式有很多,如编码器,实体按键等,最终表现形式就是直接占用多个IO来进行输入输出,对于主流8位或是32位单片机而言,每个IO都或多或少带了中断触发机制或者脉冲读取机制。
- 这个初始化就不用我多赘述,按键最终的表现结果会有很多种,我们实际生活中使用的比较多的就是单击+双击+长按的三种形式,那么我们这里就可以直接根据常规思路来设计。
- 比如我这里,对于第一次单击进行读取后,进入while并对使用delay系统延时,并记录延时时长,松手在单次延时后就会进入二级判断,判断按键时长是否超过500ms,若小于则会进入双击判断机制并衰减记录的时间值,若大于则进入长按判断机制。
- 直接上阻塞式代码给观众审查。我这里注释还是写得比较清楚的,我就直接说阻塞式的代码的问题。
- 首先,就是实时性问题,无法及时响应导致用户体验很差。
- 其次,对于系统架构很危险,若是将此段程序用中断的形式进行处理,日常使用没有问题,当上升到复杂情况就可能会导致程序崩溃。我这里是使用遍历的形式进行处理。
- 这里也可以试一下将延时写到中断中,去研究一下后果。
/**
* @brief 获取编码器按键状态
*
* @param encoder 编码器结构体指针
*
* 该函数用于检测编码器按键的状态,并根据按键按下的时间长度设置不同的按键值。
* 具体步骤如下:
* 1. 初始化 `encoder_key` 为 0。
* 2. 检测 GPIOB 的第 0 引脚是否为高电平(按键按下)。
* 3. 如果按键按下,延时 5ms 后再次检测,确认按键是否仍然按下。
* 4. 如果按键仍然按下,设置 `encoder_key` 为 1,并进入一个循环,直到按键释放。
* 5. 在按键释放后,根据 `encoder_key_time` 的值进行不同的处理:
* - 如果 `encoder_key_time` 小于 100,增加 `encoder_key_time` 并进入一个延时循环。
* 在延时循环中,如果按键再次按下,设置 `encoder_key` 为 2,并显示数字 333。
* - 如果 `encoder_key_time` 大于等于 400,重置 `encoder_key_time` 并设置 `encoder_key` 为 3。
*/
void Encoder_KeyGet(encoder_num *encoder) {
encoder->encoder_key = 0;
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
Delay_ms(5);
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
encoder->encoder_key = 1;
while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
Delay_ms(5);
encoder->encoder_key_time++;
}
}
}
switch (encoder->encoder_key_time < 100) {
case 1:
encoder->encoder_key_time += 20;
while (encoder->encoder_key_time--) {
Delay_ms(5);
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
encoder->encoder_key = 2;
break;
}
}
}
break;
default:
if (encoder->encoder_key_time >= 400) {
encoder->encoder_key_time = 0;
encoder->encoder_key = 3;
}
break;
}
}
b.非阻塞式设计
- 那么能看出来阻塞式问题的,该怎么解决呢?根据我一开始对非阻塞式程序定义的阐述,“不占用CPU”,那不占用的基础形式在中断和遍历中该怎么实现呢?
-
首先,我们能看出来我们上述的代码中主要的问题是delay系统延时函数的使用,在这个过程中浪费了大量CPU的算力,这是不可取的,那么该如何优化这个delay函数呢?是直接删掉还是怎么的呢?
-
那么我先来明确目标,我们想要的是能在某个时间段内判断IO口的电平高低,也就是按键是否按下。
-
那么根据常规思想我们就能想到设计一个定时任务,在设定时间段后定时获取IO电平信息,这样的话就不会长时间占用CPU了,很好,能想这里实际上就是一种多进程的思想出现了。
-
但是单片机直接使用这一种方式就要占用一个中断资源,那么还有什么方法呢?不使用中断的方式来实现,那我们只剩遍历了,遍历该怎么做呢。
-
我们无法从时间上设置事件任务,但想一下我们可不可以在任务中获取时间呢?是可行的,只要在不占用系统资源下单片机有好几种获取系统时间的方式,如systick,rtc,dwt等,但是systick一般用作freertos和lvgl等中间件的“心跳”,故而一般不适用,rtc一般用于基本时钟设计,那么还剩dwt可以使用,也正是DWT,他就是专门用于获取系统运行时间的。
-
对于DWT的初始化我这里不过多赘述,但是我们要用到他的CYCCNT寄存器,因为这个寄存器中存储了从系统启动到结束的晶振跳变次数,而这就是我们要的时间。
-
通过对晶振跳变次数的处理,我们可以得到每次调用这个寄存器时记录的时间,这个就是关键,根据这个时间我们就可以获取程序遍历过程中每次经历到此的时间差,而这就是我们可以用来判断单击双击长按的关键。
-
直接上代码,当然这个代码的处理逻辑还可以优化,并且将微秒改为毫秒机制,这样对于双击间隔不容易溢出。同时这个代码是遍历的形式放在主循环中实现的。
#include "bsp_dwt.h"
#define SINGLE_CLICK_THRESHOLD 200000 // 单击阈值,单位为微秒
#define DOUBLE_CLICK_INTERVAL 300000 // 双击间隔阈值,单位为微秒
#define LONG_PRESS_THRESHOLD 1000000 // 长按阈值,单位为微秒
typedef struct {
uint32_t last_press_time;
uint32_t last_release_time;
uint8_t click_count;
} KeyState;
KeyState key_state;
void Key_Init(void) {
key_state.last_press_time = 0;
key_state.last_release_time = 0;
key_state.click_count = 0;
}
void Key_Update(void) {
static uint8_t button_pressed = 0;
uint32_t current_time = DWT_GetTimeline_us();
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) {
if (!button_pressed) {
// 按键按下
button_pressed = 1;
key_state.last_press_time = current_time;
}
} else {
if (button_pressed) {
// 按键释放
button_pressed = 0;
uint32_t press_duration = current_time - key_state.last_press_time;
if (press_duration < SINGLE_CLICK_THRESHOLD) {
// 单击
key_state.click_count++;
key_state.last_release_time = current_time;
if (key_state.click_count == 2 && (current_time - key_state.last_release_time) < DOUBLE_CLICK_INTERVAL) {
// 双击
key_state.click_count = 0;
// 处理双击事件
} else if (key_state.click_count == 1 && (current_time - key_state.last_release_time) > DOUBLE_CLICK_INTERVAL) {
// 单击
key_state.click_count = 0;
// 处理单击事件
}
} else if (press_duration >= LONG_PRESS_THRESHOLD) {
// 长按
key_state.click_count = 0;
// 处理长按事件
}
}
}
}
- 那说完遍历,中断的形式又该如何写呢?
- 这里会有好几种写法,我们可以将中断作为时间间隔,实现定时扫描,以提高实时响应能力,但是对于部分情况,定时的对单片机程序进行打断确实在任务复杂情况下有着更佳的实时响应能力
- 对于代码的话,和上面遍历其实一样,只不过整个key_update函数会在中断函数中执行,逻辑判断也会在中断中进行。
- 我们优先初始化一个定时器,基本定时器就够用了,将其设定为多少ms的定时中断任务,并将逻辑处理代码加入就能达到效果。
- 记得清除中断标志位就行。当然定时器你也可以使用其他的,只要是附带了基本定时中断功能就行。
总结
后续再更新吧,这个月太忙了还没写别的文章。