旋钮同时给出方向、步数和一次按压
电位器给出连续电压,增量编码器给出触点先后变化。旋钮没有绝对位置,程序只能根据上一次与这一次的 A/B 状态推断转动方向,再累加成计数。RESET 后本例计数回到零,不声称掉电记忆或知道轴的真实角度。
先完成一个串口可见的小控制器:COUNT 模式每格加减一;短按进入仅含 BACK/ZERO 的菜单,旋转选项再按确认;长按直接回零。这里先把实体输入可靠地转换成事件,以后接屏幕时保留同一输入层。
- 做到这里,你应该看到
- 你能区分一格计数、一次短按释放和一次长按事件;按住不会反复回零。
锁定 24 格 / 24 脉冲,接触点前先看型号
本例选 Bourns PEC11R-4215F-S0024:4 是后向水平 PC 针端子配置,2 是 24 detents,15 是 15 mm 轴长,F 是平轴,S 为瞬时按压开关,0024 是每圈 24 脉冲。型号同时满足每格一个完整正交周期;换成 24 格/12 脉冲型号时不能继续用“四边沿一格”的假设。
采用已正确装配的转接板。厂家手册不推荐手工焊接该器件,初学者不要用临时焊裸脚代替合格装配。断电后按 PEC11R-4xxxF-Sxxxx 尺寸图识别 A、C、B 与开关 1/2,再用万用表确认接触关系。本文图按端子名画,不表示你从正面/背面看到的物理排列。
GPIO6/7/5 全部配置 INPUT 与内部上拉。编码器只把触点接到地;10 mA @5 V 是触点额定条件,不是“给编码器供5 V”的要求。手册滤波参考图画了5 V,不能原样接到 S3;未知模块的 VCC/CLK/DT 电路也不在本例支持范围。
| 端子 | 连接 | 空闲/触发 |
|---|---|---|
| A | GPIO6 / J1-6 | 内部上拉;闭合到 C 为低 |
| B | GPIO7 / J1-7 | 内部上拉;闭合到 C 为低 |
| C / COMMON | GND | A/B 公共触点 |
| 开关1 | GPIO5 / J1-5 | 按下接地 |
| 开关2 | GND | 按压公共地 |
参考:[1] Bourns PEC11R REV.04/26:型号、端子、正交波形、滤波测试条件[2] DevKitC-1 v1.1 固定提交:GPIO5/6/7 排针[3] ESP-IDF v5.5.5 GPIO:gpio_config/get_level、esp_driver_gpio
完整走过四个合法边沿才算一格
把 A 放高位、B 放低位,AB=(A<<1)|B。程序定义 11→10→00→01→11 为 +1,反序列为 -1。每个合法相邻变化只加减四分之一格;11↔10 来回抖动正负抵消,累计到 ±4 才产生一步。这样不把每次边沿都误当一个机械格。
11→00 或10→01 两位同时变,无法知道中间路径,是漏采样或信号异常线索。代码丢弃该跳变、清当前四分步累计、增加 invalid,再以新状态继续;不会猜测丢了多少。合法路径也可能被完整漏过而不增加 invalid,因此 invalid=0 不是“绝不漏计”的证明。
观察面和端子安装会影响你看到的顺时针。先慢转一格,若顺时针为负,只把 DIRECTION 改为 -1 重新构建;不要同时换 A/B 又改方向,导致两次反转抵消。真正验收用下文正反各20格,不在无实板时预填方向通过。
共享输入逻辑:按压先稳定,再区分短长
按钮状态稳定 30 ms 才变化。SHORT 在松手后发;LONG 在稳定按下达到1200 ms时发一次,随后释放不再补 SHORT。上电已按住时先等待第一次释放,避免启动立即冒出一次确认。模型只负责 COUNT/MENU/选项和夹紧计数,不直接读取 GPIO。
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
class Quadrature {
public:
explicit Quadrature(uint8_t initial) : prev_(initial&3) {}
int update(uint8_t ab) {
static constexpr int8_t delta[16]={0,1,-1,0,-1,0,0,1,1,0,0,-1,0,-1,1,0};
ab &= 3;
if ((prev_^ab)==3) { ++invalid_; quarter_=0; prev_=ab; return 0; }
quarter_ += delta[(prev_<<2)|ab]; prev_=ab;
if (quarter_>=4) { quarter_=0; return 1; }
if (quarter_<=-4) { quarter_=0; return -1; }
return 0;
}
uint32_t invalid() const { return invalid_; }
private:
uint8_t prev_;
int8_t quarter_=0;
uint32_t invalid_=0;
};
class Button {
public:
enum Event { NONE, SHORT, LONG };
Button(bool held,uint32_t now):raw_(held),stable_(held),armed_(!held),changed_(now),pressed_(now) {}
Event update(bool held,uint32_t now) {
if (held!=raw_) { raw_=held; changed_=now; }
if (raw_!=stable_ && uint32_t(now-changed_)>=30) {
stable_=raw_;
if (stable_) { pressed_=now; long_sent_=false; }
else {
bool send=armed_&&!long_sent_;
armed_=true;
return send?SHORT:NONE;
}
}
if (armed_&&stable_&&!long_sent_&&uint32_t(now-pressed_)>=1200) {
long_sent_=true; return LONG;
}
return NONE;
}
private:
bool raw_,stable_,armed_,long_sent_=false;
uint32_t changed_,pressed_;
};
struct InputModel {
int count=0;
bool menu=false;
int selection=0; // 0 BACK, 1 ZERO
void rotate(int step) {
int& value=menu?selection:count;
value+=step;
int lo=menu?0:-9999, hi=menu?1:9999;
if(value<lo)value=lo;
if(value>hi)value=hi;
}
void button(Button::Event e) {
if(e==Button::LONG){count=0;menu=false;selection=0;}
if(e==Button::SHORT){
if(!menu){menu=true;selection=0;}
else{if(selection==1)count=0;menu=false;}
}
}
};
参考:[3] ESP-IDF v5.5.5 GPIO:gpio_config/get_level、esp_driver_gpio
完整 IDF 入口:日志慢一点也不反过来等待它
CONFIG_FREERTOS_HZ=1000 下,输入任务使用 vTaskDelayUntil 每1 tick采样;它不做串口打印,把最新快照覆盖到长度1的队列。app_main 阻塞等队列后输出日志。快速变化时日志可跳过旧快照,已经采到的旋转仍在模型中累加;物理边沿若根本没被采样,就没有办法恢复。
gpio_config 一次设置三个输入与内部上拉,返回值用 ESP_ERROR_CHECK 检查;队列或任务创建失败打印 STOP 并停止。采样节拍不是硬实时保证,其他高优先级工作、缓存停顿和接线质量都要在系统扩展后重验。
// SPDX-License-Identifier: MIT
#include <inttypes.h>
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "InputLogic.h"
static constexpr gpio_num_t PIN_A=GPIO_NUM_6, PIN_B=GPIO_NUM_7, PIN_SW=GPIO_NUM_5;
// If your measured clockwise direction is negative, set to -1 and rebuild.
static constexpr int DIRECTION=1;
static QueueHandle_t snapshots;
struct Snapshot { int count; bool menu; int selection; uint32_t invalid; };
static uint32_t now_ms(){return uint32_t(esp_timer_get_time()/1000);}
static uint8_t read_ab(){return uint8_t((gpio_get_level(PIN_A)<<1)|gpio_get_level(PIN_B));}
static void sample_inputs(void*) {
Quadrature encoder(read_ab());
Button button(gpio_get_level(PIN_SW)==0,now_ms());
InputModel model;
TickType_t wake=xTaskGetTickCount();
uint32_t old_invalid=0;
for(;;){
int step=encoder.update(read_ab())*DIRECTION;
auto event=button.update(gpio_get_level(PIN_SW)==0,now_ms());
if(step)model.rotate(step);
model.button(event);
if(step||event!=Button::NONE||encoder.invalid()!=old_invalid){
old_invalid=encoder.invalid();
Snapshot s{model.count,model.menu,model.selection,old_invalid};
xQueueOverwrite(snapshots,&s); // Logger may skip old displays; model keeps each sampled event.
}
vTaskDelayUntil(&wake,pdMS_TO_TICKS(1));
}
}
extern "C" void app_main() {
gpio_config_t cfg{};
cfg.pin_bit_mask=(1ULL<<PIN_A)|(1ULL<<PIN_B)|(1ULL<<PIN_SW);
cfg.mode=GPIO_MODE_INPUT;
cfg.pull_up_en=GPIO_PULLUP_ENABLE;
cfg.pull_down_en=GPIO_PULLDOWN_DISABLE;
cfg.intr_type=GPIO_INTR_DISABLE;
ESP_ERROR_CHECK(gpio_config(&cfg));
snapshots=xQueueCreate(1,sizeof(Snapshot));
if(!snapshots){ESP_LOGE("input","STOP queue allocation failed");return;}
if(xTaskCreate(sample_inputs,"sample_inputs",3072,nullptr,4,nullptr)!=pdPASS){
ESP_LOGE("input","STOP task allocation failed");vQueueDelete(snapshots);return;
}
ESP_LOGI("input","READY count=0 A=6 B=7 SW=5; short:menu/confirm, hold:zero; poll=1 tick");
Snapshot s;
for(;;){
if(xQueueReceive(snapshots,&s,portMAX_DELAY)==pdTRUE){
ESP_LOGI("input","count=%d mode=%s select=%s invalid=%" PRIu32,
s.count,s.menu?"MENU":"COUNT",s.selection?"ZERO":"BACK",s.invalid);
}
}
}
参考:[3] ESP-IDF v5.5.5 GPIO:gpio_config/get_level、esp_driver_gpio[4] ESP-IDF v5.5.5 FreeRTOS:任务、队列与tick
用完整工程与 5.5.5 环境构建
从 E01 的已安装 ESP-IDF 5.5.5 环境打开终端,进入下载包根目录。CMake 主工程引用 IDF project.cmake;main 明确依赖 esp_driver_gpio 与 esp_timer。sdkconfig.defaults 选 esp32s3、8MB QIO Flash、OPI PSRAM 80MHz、UART0 控制台115200和1000Hz tick;无第三方组件,因此不需要额外 dependencies.lock。
实际完成 idf.py build,日志 Exit0,应用大小0x32770字节;元数据记录 staging 工作目录、命令和源码 SHA。构建生成的 sdkconfig 已核实目标、QIO 选项、8MB、OPI/80MHz、UART0与1000Hz。IDF 的烧录命令可显示 --flash_mode dio,这是启动加载阶段参数,不能拿它推断 QIO Kconfig 没生效。
使用板载 USB-to-UART Bridge flash monitor,先看到 READY count=0。Ctrl+] 退出 monitor。换 target 前先备份项目再按需要 fullclean,不清掉全局 SDK。
idf.py --version
idf.py set-target esp32s3
idf.py build
idf.py -p YOUR_BRIDGE_PORT flash monitor- 做到这里,你应该看到
- 版本为 ESP-IDF v5.5.5,完整工程构建 Exit0,日志列 A=6 B=7 SW=5。
参考:[3] ESP-IDF v5.5.5 GPIO:gpio_config/get_level、esp_driver_gpio[4] ESP-IDF v5.5.5 FreeRTOS:任务、队列与tick
正反各 20 格,验收方向与计数,再测按压
把 acceptance.csv 打开,先填板、编码器完整型号与日期,保留从启动开始的日志。以下都是本站尚未进行的实物步骤;主机里20个合成循环不能代替你转20格。
- 静止10秒,记录 count 与 invalid 初值;不应自行变化。
- COUNT 模式顺时针慢转20格,每格停一下,期望+20;逆时针慢转20格回零。方向相反只改 DIRECTION,重启后重新做整轮。
- 重复正反各20格三轮,记录每轮计数差与 invalid 增量。数量异常先核 detents/pulses 型号,不能盲目把结果除二。
- 短按打开 MENU;向两端多转,选择必须夹在 BACK/ZERO。选 ZERO 再短按,计数回零并返回 COUNT;BACK 只退出。
- 先转到非零,持续按两秒,仅回零一次;松手不打开菜单。按住开关重启,释放后也不应产生假短按。
- 做到这里,你应该看到
- 三轮各方向计数、菜单边界、短按、长按和启动按住均有记录;未测时保留空值。
主机检查确认规则;PCNT 解决另一层的速度问题
tests/input_test.cpp 直接 include 固件 InputLogic.h。8组实际检查覆盖合成正反20周期、邻接抖动抵消、非法两位跳变、菜单/夹紧/回零、短按去抖、长按只发一次、上电按住与uint32回绕。这些输入不带真实触点电压、机械速度或RTOS延迟,不能证明某个RPM可靠。
Bourns数据表给出15RPM时抖动2ms max、工作60RPM max,同时注明使用标准降噪滤波测试。本例未复刻其外部滤波,1ms软件轮询也不能由此得到60RPM保证。慢速手动旋钮便于先看清状态;连续快速脉冲、测速或重负载系统应考虑硬件PCNT。
IDF5.5.5 PCNT能结合edge和level控制实现正交计数,glitch filter的max_glitch_ns过滤短毛刺,计数不依赖逐次软件轮询。它不会自动把机械格和完整周期对应好,也不自动消除毫秒级触点抖动;电气整形、滤波上限与事件规则仍要按实际波形验证。此篇只解释选型边界,没有提供或声称跑过另一份PCNT程序。
clang++ -std=c++17 -Wall -Wextra -Werror tests/input_test.cpp -o /tmp/p09-test
/tmp/p09-test参考:[1] Bourns PEC11R REV.04/26:型号、端子、正交波形、滤波测试条件[5] ESP-IDF v5.5.5 PCNT:edge/level、glitch filter
从静止信号恢复,再考虑更高速度
静止乱跳先断电核 C/GND、端子、上拉与导线固定;按钮的30ms去抖不处理A/B,不能把它调大当成旋钮修复。invalid增长时先慢转并查两位跳变原因,再评估整形或PCNT。每格双计数/半计数优先核24格/24脉冲,切勿只改比例掩盖型号不符。
按压不工作,断电通断档核开关1/2与GPIO5。日志跳过中间值但最终计数正确可能是快照覆盖;最终也不对则回到物理输入与采样。恢复旧工程时停止monitor、拔USB、拆编码器再烧回;RAM计数随重启清零。下一步可把输入事件接到已存在的按钮状态机,屏幕菜单扩展另篇验收。
参考:[1] Bourns PEC11R REV.04/26:型号、端子、正交波形、滤波测试条件[3] ESP-IDF v5.5.5 GPIO:gpio_config/get_level、esp_driver_gpio
资料来源与版本说明
具体核对 Bourns REV.04/26 型号编码/相位/端子/滤波测试条件,以及 IDF5.5.5 GPIO/FreeRTOS/PCNT 接口;未实测物理方向与漏计速度。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。