输入输出与编程 / 编程实验发布 内容更新

给 ESP32 接一个旋钮:方向、计数和按压菜单

用 ESP-IDF 5.5.5 读取 Bourns 24格旋钮,完整四相解码、短按菜单和长按回零;附正反20格记录方法、同源fixture与PCNT速度边界。

读完能做什么
得到可回零的旋钮计数器:慢转计数,短按进入 BACK/ZERO 菜单,长按回零。
开始之前
理解按键去抖;已在 ESP-IDF 5.5.5 完成构建、烧录和 monitor。
所需器材
官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8、已正确装配的 Bourns PEC11R-4215F-S0024 编码器转接板、万用表、短导线、USB 数据线
版本组合
开发板:DevKitC-1 v1.1 / WROOM-1-N8R8框架:ESP-IDF 5.5.5工具:ESP-IDF 5.5.5 工具环境
适用范围

仅固定 24 格/24 脉冲 Bourns 型号与 S3 板,软件轮询用于慢速手动输入;没有最大转速保证。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/programming-inputs.md#p09版本:ESP-IDF 5.5.5验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/esp32-rotary-encoder.log对象:esp32s3 / 8 MB QIO / OPI PSRAM版本:ESP-IDF 5.5.5验证日期:
仿真已通过
docs/esp32-evidence/programming-input-host.json;直接运行固件共享头文件的主机 fixture,未模拟外设/电气/RTOS 调度验证日期:
本站实板待验证
本站未实板验证;器件行为、物理方向/范围及速率须按本文记录

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 编程实验

旋钮同时给出方向、步数和一次按压

电位器给出连续电压,增量编码器给出触点先后变化。旋钮没有绝对位置,程序只能根据上一次与这一次的 A/B 状态推断转动方向,再累加成计数。RESET 后本例计数回到零,不声称掉电记忆或知道轴的真实角度。

先完成一个串口可见的小控制器:COUNT 模式每格加减一;短按进入仅含 BACK/ZERO 的菜单,旋转选项再按确认;长按直接回零。这里先把实体输入可靠地转换成事件,以后接屏幕时保留同一输入层。

做到这里,你应该看到
你能区分一格计数、一次短按释放和一次长按事件;按住不会反复回零。
02 / 编程实验

锁定 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 电路也不在本例支持范围。

端子连接空闲/触发
AGPIO6 / J1-6内部上拉;闭合到 C 为低
BGPIO7 / J1-7内部上拉;闭合到 C 为低
C / COMMONGNDA/B 公共触点
开关1GPIO5 / J1-5按下接地
开关2GND按压公共地

参考:[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

03 / 编程实验

完整走过四个合法边沿才算一格

把 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格,不在无实板时预填方向通过。

参考:[1] Bourns PEC11R REV.04/26:型号、端子、正交波形、滤波测试条件

04 / 编程实验

共享输入逻辑:按压先稳定,再区分短长

按钮状态稳定 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

05 / 编程实验

完整 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

06 / 编程实验

用完整工程与 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

07 / 编程实验

正反各 20 格,验收方向与计数,再测按压

把 acceptance.csv 打开,先填板、编码器完整型号与日期,保留从启动开始的日志。以下都是本站尚未进行的实物步骤;主机里20个合成循环不能代替你转20格。

  1. 静止10秒,记录 count 与 invalid 初值;不应自行变化。
  2. COUNT 模式顺时针慢转20格,每格停一下,期望+20;逆时针慢转20格回零。方向相反只改 DIRECTION,重启后重新做整轮。
  3. 重复正反各20格三轮,记录每轮计数差与 invalid 增量。数量异常先核 detents/pulses 型号,不能盲目把结果除二。
  4. 短按打开 MENU;向两端多转,选择必须夹在 BACK/ZERO。选 ZERO 再短按,计数回零并返回 COUNT;BACK 只退出。
  5. 先转到非零,持续按两秒,仅回零一次;松手不打开菜单。按住开关重启,释放后也不应产生假短按。
做到这里,你应该看到
三轮各方向计数、菜单边界、短按、长按和启动按住均有记录;未测时保留空值。
08 / 编程实验

主机检查确认规则;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

09 / 编程实验

从静止信号恢复,再考虑更高速度

静止乱跳先断电核 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

BACK TO THE SOURCE

资料来源与版本说明

具体核对 Bourns REV.04/26 型号编码/相位/端子/滤波测试条件,以及 IDF5.5.5 GPIO/FreeRTOS/PCNT 接口;未实测物理方向与漏计速度。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  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
  4. ESP-IDF v5.5.5 FreeRTOS:任务、队列与tick
  5. ESP-IDF v5.5.5 PCNT:edge/level、glitch filter