在进行嵌入式系统开发时,按钮是最常见的输入设备之一。为了简化按钮的控制和管理,创建了一个简单的按钮库。最初,这个库只提供了基本的功能,但随着项目需求的增加,开始添加更多的功能,比如支持中断和实现按钮的多功能性。在这个过程中,逐渐理解了为什么市面上的按钮库会如此复杂。
在项目开发中,需要一个能够即时反馈用户操作的按钮,同时在某些情况下,按钮需要能够响应中断信号,或者根据用户的按压方式执行不同的功能。为了满足这些需求,开始着手开发一个更加完善的按钮库。
在开发过程中,使用了以下工具和硬件:
虽然可以使用不同的硬件,但需要根据硬件修改 platformio.ini
和 config.hpp
中的引脚配置。
首先,需要在项目中添加 htcw_button
库的依赖,并在代码中包含 。然后,可以在代码中实例化按钮对象,并为它们配置回调函数。
// 配置按钮
using button_1_t = button_ex;
using button_2_t = button;
static button_1_t button_1;
static button_2_t button_2;
在上面的代码中,声明了两个按钮。第一个是一个扩展按钮,第二个是一个常规按钮。这个项目是为TTGO T-Display配置的,所以引脚定义为35和0,开路高。它们都有10毫秒的消抖时间,并且扩展按钮配置为中断驱动。
接下来,需要为按钮配置回调函数。例如,可以为 button_1
配置点击和长按回调,为 button_2
配置一个回调。
button_1.on_click([](int clicks, void* state){
Serial.print("1 - on click: ");
Serial.println(clicks);
});
button_1.on_long_click([](void* state){
Serial.println("1 - on long click");
});
button_2.callback([](bool pressed, void* state){
Serial.print("2- ");
Serial.println(pressed? "pressed" : "released");
});
在这里使用了简单的lambda表达式来将信息输出到串行端口。注意,出于性能原因,它们不能捕获任何值。使用 state
参数来传递值。
为了让回调函数能够触发,需要在任何希望按钮工作的循环中调用按钮的 update()
方法,就像在 loop()
方法中所做的那样:
// 更新所有按钮对象
button_1.update();
button_2.update();
当按钮被操作时,会在串行端口看到相应的消息。
现在,让来探索一下这些按钮的内部工作原理。
首先,来看基础按钮。它主要由几个重要的部分组成。首先是初始化:
bool initialize() {
if (m_pressed == -1) {
m_last_change_ms = 0;
if (open_high) {
pinMode(pin, INPUT_PULLUP);
} else {
pinMode(pin, INPUT_PULLDOWN);
}
m_pressed = raw_pressed();
}
return m_pressed != -1;
}
在这里,检查按钮是否未初始化(m_pressed == -1
),然后初始化成员变量,并根据按钮是开路高还是开路低设置引脚模式。最后,将 pressed
设置为按钮的当前值——按下或未按下。如果初始化成功,它将返回true,在这个例子中它总是会成功。
接下来是 update()
方法,它处理点击事件:
void update() {
bool pressed = raw_pressed();
if (pressed != m_pressed) {
uint32_t ms = millis();
if (ms - m_last_change_ms >= debounce_ms) {
if (m_callback != nullptr) {
m_callback(pressed, m_state);
}
m_pressed = pressed;
m_last_change_ms = ms;
}
}
}
在这里,获取按钮的底层值(raw_pressed()
),如果它与上次记录的值不同,并且已经过去了 debounce_ms
毫秒,那么如果配置了回调函数,就会触发回调。注意,可以不使用回调,只使用 update()
和 pressed()
。一旦回调被触发,按下的值和上次更新时间就会被更新。
这个按钮要复杂得多。在之前的按钮中,update
例程收集按钮点击并报告它们。在这个按钮中,它们是两个独立的例程。此外,这个按钮保持了一个带有关联时间戳的按钮状态变化缓冲区。当注册按钮点击时,这个缓冲区会被填充,当报告按钮事件时,它会被清空。最后,当报告时,使用一个状态机来解析存储的按钮事件,并产生相应的回调。
首先,这个按钮可以通过中断信号,这意味着每当按钮被按下或释放时,MCU的CPU会停止它正在做的任何其他事情,并处理按钮事件变化。这意味着一个启用中断的按钮会收集按下事件,即使 update()
不能被调用,比如在刷新电子纸显示屏的过程中。
update()
仍然必须被调用以实际触发事件。不能在中断例程中触发回调,因为回调中的代码可能不适合中断,这意味着回调必须在整个应用程序的生命周期内都在RAM中。
使用状态机允许对按钮进行复杂的分析,比如计算释放之间的时间,以允许在一个事件上触发多次点击,或者计算按下和释放之间的时间以进行长按,以及为以后扩展其他类型的点击提供空间。
这里是中断例程,它收集按钮的按下和释放以及相关的时间戳:
#ifdef ESP32
IRAM_ATTR
#endif
static void process_change(void* instance) {
type* this_ptr = (type*)instance;
uint32_t ms = millis();
bool pressed = this_ptr->raw_pressed();
if (pressed != this_ptr->m_pressed) {
if (ms - this_ptr->m_last_change_ms >= debounce_ms) {
if (!this_ptr->m_events.full()) {
this_ptr->m_events.put({ms, pressed});
this_ptr->m_pressed = pressed;
this_ptr->m_last_change_ms = ms;
}
}
}
}
可以看到,一旦忽略了 this_ptr
的东西,这与旧按钮的 update()
例程非常相似,除了将事件放入 m_events
成员。指针的东西只是因为这是一个静态成员,所以必须将类实例作为通用状态参数 "instance" 传递,然后从那里重新构建类成员访问。
这是相当复杂的部分——事件处理:
void update() {
if (!initialize()) {
return;
}
if (!use_interrupt) {
process_change(this);
}
if (m_pressed == 1) {
return;
}
if (m_last_change_ms != 0 && !m_events.empty() && millis() - m_last_change_ms >= double_click_ms) {
event_entry_t ev;
uint32_t press_ms = 0;
int state = 0;
int clicks = 0;
int longp = 0;
int done = 0;
while (!done) {
switch (state) {
case 0:
if (!m_events.get(&ev)) {
done = true;
break;
}
if (ev.state == 1) {
state = 1;
break;
} else {
while (ev.state != 1) {
if (!m_events.get(&ev)) {
done = true;
break;
}
state = 1;
}
break;
}
case 1:
++clicks;
press_ms = ev.ms;
while (ev.state != 0) {
if (!m_events.get(&ev)) {
done = true;
break;
}
state = 2;
}
break;
case 2:
longp = !!(m_on_long_click && ev.ms - press_ms >= long_click_ms);
if (!m_events.get(&ev)) {
if (m_on_click) {
if (clicks > longp) {
m_on_click(clicks - longp, m_on_click_state);
}
}
if (longp) {
m_on_long_click(m_on_long_click_state);
}
done = true;
break;
}
state = 1;
break;
}
}
}
}