阅读本文前建议先阅读本系列前两篇文章:架构设计、具体实现(一)
前一章节,我们成功设计了管理器的基础功能,包括添加程序、运行程序等功能,并以方法的形式对外提供了接口,本文,我们将把前一轮编写的功能应用到实际,并完成我们的菜单选择功能与一些基础的硬件驱动,完成第一版作品。
完整代码参见附录。
输入与输出
先想想,我们要实现菜单选择程序启动,在嵌入式开发中不可少的就是一个输入设备,比较简单的实现方法就是用轻触开关。对于反馈当前选中的程序,最好的实现方法应该是OLED小屏,但是最早版本我们应该选用更简单的方法,比如串口通讯。
本章可能会牵涉到一定的电子知识,但难度非常简单,相信初中水平就能理解。
轻触开关
轻触开关是嵌入式开发中必不可少的一个硬件,它的构造并不复杂,常见的有两脚、四脚的封装,但原理都差不多,我们先来看四脚的,这是我们在面包板直插场景下最常见的。
此类轻触开关有四个引脚,两两成对,在侧面,有相对的两组侧面,一组是没有引脚的,一组是各有两个引脚,也就是说,两个引脚同侧,分两组相对。
如果听不懂文字描述也无所谓,看图或实物很容易理解。
对于轻触开关,同侧的两只脚不相连,异侧的两只脚相连,当按下开关时,同侧的两只脚会相连,也就是说此时四只脚都是导通的状态。
大概是这样子的:
| |
|--|-----------|--|
| | | |
| | | |
| |--- 按键 ---| |
| | | |
|--|-----------|--|
| |
当按键按下时,两边导通。
在Arduino框架中,可以使用digitalRead读取一个gpio的电平,也就是说,我们可以利用按键来给某个gpio一个电平,通过读取电平判断按键是否按下。
对于这种情况,我们一般采用上拉的形式,原理图如下:
看图可知,当开关未按下时,GPIO通过一个4.7k的上拉电阻与电源正极相连,此时GPIO读取到的电平就是正极的高电平,当开关按下时,电源正极通过一个4.7k的电阻与GND相连,此时电阻分压等于电源电压,GPIO读取到GND的低电平。
如果有不理解,可以自行查阅资料,这里不过多叙述。
根据以上原理,我们可以在程序中做如下判断:
-
当GPIO读取到高电平时,我们判断按键未被按下。
-
当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();
}
}