工具开发-ESP32程序管理系统 | 具体实现(二)

0 阅读11分钟

阅读本文前建议先阅读本系列前两篇文章:架构设计、具体实现(一)

架构设计篇

具体实现(一)篇

前一章节,我们成功设计了管理器的基础功能,包括添加程序、运行程序等功能,并以方法的形式对外提供了接口,本文,我们将把前一轮编写的功能应用到实际,并完成我们的菜单选择功能与一些基础的硬件驱动,完成第一版作品。

完整代码参见附录

输入与输出

先想想,我们要实现菜单选择程序启动,在嵌入式开发中不可少的就是一个输入设备,比较简单的实现方法就是用轻触开关。对于反馈当前选中的程序,最好的实现方法应该是OLED小屏,但是最早版本我们应该选用更简单的方法,比如串口通讯。

本章可能会牵涉到一定的电子知识,但难度非常简单,相信初中水平就能理解。

轻触开关

轻触开关是嵌入式开发中必不可少的一个硬件,它的构造并不复杂,常见的有两脚、四脚的封装,但原理都差不多,我们先来看四脚的,这是我们在面包板直插场景下最常见的。

button

此类轻触开关有四个引脚,两两成对,在侧面,有相对的两组侧面,一组是没有引脚的,一组是各有两个引脚,也就是说,两个引脚同侧,分两组相对。

如果听不懂文字描述也无所谓,看图或实物很容易理解。

对于轻触开关,同侧的两只脚不相连,异侧的两只脚相连,当按下开关时,同侧的两只脚会相连,也就是说此时四只脚都是导通的状态。

大概是这样子的:


   |           |

|--|-----------|--|

|  |           |  |

|  |           |  |

|  |--- 按键 ---|  |

|  |           |  |

|--|-----------|--|

   |           |

当按键按下时,两边导通。

在Arduino框架中,可以使用digitalRead读取一个gpio的电平,也就是说,我们可以利用按键来给某个gpio一个电平,通过读取电平判断按键是否按下。

对于这种情况,我们一般采用上拉的形式,原理图如下:

diagram.png

看图可知,当开关未按下时,GPIO通过一个4.7k的上拉电阻与电源正极相连,此时GPIO读取到的电平就是正极的高电平,当开关按下时,电源正极通过一个4.7k的电阻与GND相连,此时电阻分压等于电源电压,GPIO读取到GND的低电平。

如果有不理解,可以自行查阅资料,这里不过多叙述。

根据以上原理,我们可以在程序中做如下判断:

  1. 当GPIO读取到高电平时,我们判断按键未被按下。

  2. 当GPIO读取到低电平时,我们判断按键被按下。

转换成简单的代码就是这样:


// button.cpp

#include <Arduino.h>

  


int pin = 按键连接的GPIO;

pinMode(pin, INPUT);

// 由于我们设计的电路为外部上拉

// 所以可以直接使用INPUT模式

// 实际项目可以使用INPUT_PULLUP模式

// 芯片内部会通过一个电阻上拉到VCC

// 所以外部可以省去上拉部分

  


if (digitalRead(pin) == 0){

// 被按下

} else {

// 未被按下

}

此时我们就已经可以做到一个简单的按键输入了,但是这样简单的处理方式有一个弊端,我们一起来看看。

首先,为了让按键读取及时,我们需要让每一轮循环都进行一次检查。


// main.cpp

#include <Arduino.h>

  


void setup(){

// 初始化

}

void loop(){

// 执行判断

}

当前我们的程序并没有什么阻塞的内容,比如delay(),所以loop的执行速度会非常快,读取一次立马进入下一次,密度非常高。

所以,当按下按键时,我们的手必定会停留一个很短的时间,哪怕仅停留50ms,由于代码读取的频率非常高,所以在这短短50ms内程序已经执行了无数次读取,正好这50ms内是一直按下的,所以这无数次读取的结果都是被按下,会导致我们的程序以为你按下了几十几百次,显然不合常理。

我们先来解决这个问题。

从正常角度来说,我们按下一次按键,无论持续多久,肯定都只需要程序收到一次按键时间即可,所以最好的办法是让程序自己记得自己触发了几次,在本次抬起之前不再触发。


// button.cpp

bool press = false;

int pin = 一个GPIO;

pinMode(pin, INPUT);

int gpio_level = digitalRead(pin)

if (gpio_level == 0 && !press){

// 触发一次,但记录已按下

press = true;

return true; // 表示被按下

} else if (gpio_level == 1){

// 当读取到 1 时记录未被按下

press = false;

return false;

}

这样,我们就写出了一个基础的逻辑,当按下时,触发一次按下,并把press改为true。

当又一轮检查时,由于检查速度很快,我们的手指还没有来得及抬起,此时读取GPIO电平得到的还是低电平,但是由于我们已经触发过一次按键,程序执行到if判断时,press为true,所以!press的运算结果为false,故而不会通过判断。又因为此时电平并不为1,程序也不会进入下面的else。

而当我们抬起后,程序会读取到高电平,此时就会进入else分支,将press变量改为false,以便下次按下时正常通过if判断返回true。

再往深处想,这个判断逻辑已经完美了吗?其实不是。

当我们按下按键以及抬起按键的这个过程中,开关会逐渐靠近与远离引脚内部连接的导线,此时会引出另一个问题,就是当开关与导体距离很近时,稍微的抖动可能就会让按键导通。

可能大家尝试过把一个灯泡开关按到一个平整的状态,既不向上合也不向下合,此时灯会一闪一闪的,因为手的抖动导致处于开和关临界点的开关多次进入了开的状态。

同理,对于轻触开关,按键的临界点也会出现这种情况。

在正常的开发过程中,我们理应规避这种不符合我们设计预期的问题。

要规避它,就要找到它和正常操作不同之处。

由于这种现象是由于临界点的抖动,所以持续时间不会很长,而我们正常按下一个按键时肯定会停留一定的时间,哪怕很短,也一定长于抖动。

通过按键长短来判断并规避抖动,这种设计我们称之为:防抖。


// button.cpp

bool press = false;

int pin = 一个GPIO;

pinMode(pin, INPUT);

int gpio_level = digitalRead(pin)

unsigned long press_time = 0;

// millis是Arduino框架中用于获取运行时间的函数

// 返回值类型为无符号长整形(unsigned long)

// 单位为毫秒

  


if (gpio_level == 0 && !press){

press = true;

if (press_time == 0){

// 可以使用0来标记按键刚被按下没有初始化时间

press_time = millis();

}

if ((millis() - press_time) >= 20){

// 我们这里将阈值设置为20ms

return true;

}

} else if (gpio_level == 1){

press_time = 0;

// 按键抬起后再次设置为0

// 便于下次识别

press = false;

return false;

}

每次刚刚识别到按下,程序会记录当前的时间,只有当20ms内每一次识别都是0,才会返回true,如果这20ms内有一次识别到的不是0,程序就会再次将按下时间重置,保证抖动的短时间接触不被错误地识别到。

接下来,为了便于使用,我们应该将其模块化。它具有初始化代码,以及识别部分的代码,同时我们可能具有不止一个按键,所以最好的方法是封装为一个对象。


// button.h

#ifndef BUTTON_H

#define BUTTON_H

  


class Button{

private:

int _pin;

bool _press;

bool _gpio_level;

unsigned long _press_time;

public:

Button(int pin);

bool is_press();

}

  


#endif


// button.cpp

Button::Button(int pin)

:_pin(pin), _press(false), _gpio_level(false), _press_time(0){}

  


bool Button::is_press(){

_gpio_level = digitalRead(_pin) == 1;

if (!_gpio_level && !_press){

_press = true;

if (_press_time == 0){

// 可以使用0来标记按键刚被按下没有初始化时间

_press_time = millis();

}

if ((millis() - _press_time) >= 20){

// 我们这里将阈值设置为20ms

return true;

}

} else if (_gpio_level == 1){

_press_time = 0;

// 按键抬起后再次设置为0

// 便于下次识别

_press = false;

return false;

}

}

由于按键的检测往往需要循环执行,所以is_press()一般需要反复调用,使用millis计算的方式可以减少阻塞,而不是使用delay(20)完全阻塞,使用millis还可以提高准确度。

菜单系统

我们完成了按键的输入处理,接下来只需要简单完善一下输出部分,这个程序的主要逻辑就已经有了具体的代码形状,接下来,我们将用串口通讯来编写菜单部分。

对于一个菜单,最基本的功能应该是显示当前选中的程序,并在按下按键时选中下一个选项,在按下确认时运行程序。

对于显示程序,我们首先需要获得程序列表,这时候就需要给上一节的管理器添加一个方法,把它私有的程序数组适当处理一下,返回给调用方。


// manager.h

// (在原本的类声明中加入get_arr()方法)

const char** get_arr();


// manager.cpp

const char** ProgramManager::get_arr(){

static const char* program_name[16];

program_name[0] = _index;

// 我们可以约定数组的第一位存储当前程序的最大索引

// 便于接收方遍历

for (int i=0;i<=_index;i++){

program_name[i+1] = programs[i].name;

}

return program_name;

}

此时我们就可以使用get_arr方法获取包含程序名称列表及程序数量的列表。

此时我们就完全可以开始上手编写菜单系统有关的代码了。


// menu.h

#ifndef MENU_H

#define MENU_H

class Menu{

// 一些东西

}

  


#endif

我们需要在按下按键时切换选中下一个程序,所以最好的办法是程序每一轮循环都检查一次,如果有按键行为,那就切换下一个,如果没有,直接pass即可。

可以用一个update函数来做这些事。


// menu.h

#ifndef MENU_H

#define MENU_H

class Menu{

public:

void update();

}

  


#endif


// menu.cpp

void Menu::update(){

if (button.is_press()){

// 调用我们前面写的按键部分

// 按键对象可以在setup()中创建,或使用头文件引入

// 切换下一个选项

}

}

切换选项我们可以使用索引来简单的实现。


// menu.h

class Menu{

private:

void _next_choice();

int _now_index;

const char* _program_name;

public:

Menu();

void update();

}


// menu.cpp

  


Menu::Menu()

:_now_index(1)

{

_program_name = program_manager.get_arr();

}

  


void Menu::_next_choice(){

_now_index ++;

if (_now_index > (int)_program_name[0]){

_now_index = 1;

}

Serial.println(_program_name[_now_index]);

}

  


void Menu::update(){

if (button.is_press()){

_next_choice();

}

}

此时我们已经可以实现程序的切换,当我们切换到想要运行的程序时,我们将按下确认键,此时就需要给menu做一点小改动,让它可以把程序运行起来。


// menu.h

// 在原来的基础上添加一个私有函数_run_program

private:

void _run_program();


// menu.cpp

void Menu::_run_program(){

program_manager.start(_program_name[_now_index]);

// 使用我们上一篇讲到的调用方法

}

  


void Menu::update(){

if (button_down.is_press()){

// button_down是下一选项的按键

_next_choice();

}

if (button_ok.is_press()){

// button_ok是确认键

_run_program();

}

}

好了,到这里就基本完成了这个系统的基础编写,包括程序管理部分,输入输出部分,都已经有了基础的形状,可以自己写一个小程序,比如点灯,尝试注册到管理器,并用按键操作选中执行它。

结语

以上就是我们这套系统的基础部分了,很多不在核心范围内的设计,比如对象在哪创建,怎么引入,我都没有细讲。因为本系列的目的是讲解如何把一个灵感变成实际可运行的程序,有了我们一起写出的骨架,后续想再拓展、优化,都会很简单,所以很多内容还是需要你自己来写,自己来完善。

记住,只要有想法,就不要停止创造

附录

以下是本文源码总结。


// button.h

#ifndef BUTTON_H

#define BUTTON_H

  


class Button{

private:

int _pin;

bool _press;

bool _gpio_level;

unsigned long _press_time;

public:

Button(int pin);

bool is_press();

}

  


#endif


// button.cpp

#include <Arduino.h>

#include "button.h"

  


Button::Button(int pin)

:_pin(pin), _press(false), _gpio_level(false), _press_time(0){}

  


bool Button::is_press(){

_gpio_level = digitalRead(_pin) == 1;

if (!_gpio_level && !_press){

_press = true;

if (_press_time == 0){

_press_time = millis();

}

if ((millis() - _press_time) >= 20){

return true;

}

} else if (_gpio_level == 1){

_press_time = 0;

_press = false;

return false;

}

return false;

}



// menu.h

#ifndef MENU_H

#define MENU_H

class Menu{

private:

void _next_choice();

int _now_index;

const char* _program_name;

void _run_program();

public:

Menu();

void update();

}

#endif


// menu.cpp

#include <Arduino.h>

#include "menu.h"

  


Menu::Menu()

:_now_index(1)

{

_program_name = program_manager.get_arr();

}

  


void Menu::_next_choice(){

_now_index ++;

if (_now_index > (int)_program_name[0]){

_now_index = 1;

}

Serial.println(_program_name[_now_index]);

}

  


void Menu::_run_program(){

program_manager.start(_program_name[_now_index]);

}

  


void Menu::update(){

if (button_down.is_press()){

_next_choice();

}

if (button_ok.is_press()){

_run_program();

}

}