ESP32保龄球计分系统开发实战:从状态机到蓝牙透传 ESP32 保龄球系统听起来像是一个硬件项目的玩具改造但真正动手之后它覆盖的技术点相当完整GPIO 输入、状态机、保龄球计分规则建模、串口调试、蓝牙透传、烧录恢复、固件备份。这套链路非常适合用来练习 ESP32 从裸机逻辑到完整交互系统的开发方式。本文以一套桌面级迷你保龄球计分装置为例完整梳理系统设计、计分算法、环境搭建、接线、通信协议、烧录验证和常见问题排查。读者学完后可以基于这套思路快速搭建自己的计分或计数类 ESP32 项目。1. 先想清楚这套保龄球系统到底要做什么1.1 保龄球系统中 ESP32 扮演什么角色保龄球系统通常由三部分组成投球区、瓶位检测区、计分显示端。人工记分时投球者自己记录每次击倒的瓶数再由人工或纸笔计算第 10 帧的加分。ESP32 进入这个场景后核心工作是代替人工完成击倒瓶数录入、自动计算得分、实时显示和结果同步。ESP32 适合做这件事的原因很直接。它有一个双核 240MHz 的处理器有丰富 GPIO可以接按钮、红外对射传感器、OLED、LED 点阵它同时支持 Wi-Fi 和 Bluetooth Classic/BLE可以把计分结果同步到手机或局域网更重要的是 Arduino、PlatformIO、ESP-IDF 生态成熟保龄球计分逻辑可以用非常少的代码写清楚。在这套系统里ESP32 通常承担以下角色读取投球完成信号。读取击倒瓶数。维护一局比赛的计分状态。把实时得分输出到屏幕和串口。通过蓝牙或 Wi-Fi 接收外部控制指令。1.2 整体架构和模块划分实际工程里建议先把系统拆成几个清晰模块而不是把所有代码塞进loop()。一个合理的分层是输入层 按钮/传感器/红外对射/手机指令 逻辑层 保龄球计分状态机 显示层 OLED/串口/手机App/Web页面 通信层 Bluetooth Serial / Wi-Fi / ESP-NOW分层后每层可以单独测试。先在串口验证计分逻辑正确再接 OLED 显示最后接手机蓝牙问题会少很多。数据流可以简化为投球动作 - 传感器或按钮产生中断/轮询事件 - 调用 addRoll(pinsDown) - 更新当前帧状态 - 重算总分 - 刷新显示 - 发送到手机这套流程的关键是“计分逻辑不依赖具体硬件”。先写一个纯逻辑模块传感器、按钮、蓝牙指令都只是addRoll的触发来源这样最方便测试。1.3 对一个完整项目来说先画状态机再写代码不要把一局保龄球想成“不断累加分数”的简单过程。它其实是一个有状态的过程等待投球。第 1 到第 9 帧的第一次投球。第 1 到第 9 帧的第二次投球。第 10 帧的第二次、第三次投球。整局结束。用状态机表示后每个状态只回答两个问题当前投球是否结束当前帧当前帧是否需要进入下一帧。这个设计在后面接传感器时会非常有用因为传感器可能误触发状态机可以帮助过滤非法输入。2. 保龄球计分规则是核心逻辑先把它写干净2.1 三条计分规则决定了数据结构保龄球标准计分规则非常明确记分时只需要记住三条规则普通局该帧两次投球击倒的瓶数相加记为本帧得分。补中Spare该帧两次投球击倒 10 瓶本帧得 10 分并加上下一帧第一次投球击倒的瓶数。全中Strike该帧第一次投球击倒 10 瓶本帧得 10 分并加上下两次投球击倒的瓶数。一局共 10 帧。第 10 帧比较特殊如果第 10 帧打出 Strike 或 Spare会额外获得一次或两次投球机会。因此第 10 帧最多可以投 3 次。数据结构上最直观的方式是用一个二维数组记录每帧的投球机会static const int MAX_FRAME 10; static const int MAX_ROLLS_PER_FRAME 3; int pins[MAX_FRAME][MAX_ROLLS_PER_FRAME]; int rollInFrame[MAX_FRAME]; int currentFrame;pins[i][0]是第 i 帧第一次投球击倒的瓶数rollInFrame[i]表示第 i 帧已经投了几次。第 1 到第 9 帧最多 2 次第 10 帧最多 3 次。2.2 实现一个可复用的计分模块下面的代码用一个独立类实现计分逻辑不依赖 Arduino 或任何硬件库方便在 PC 上测试也方便直接放进 ESP32 工程。class BowlingScore { private: int pins[10][3]; int rollInFrame[10]; int currentFrame; public: BowlingScore() { reset(); } void reset() { for (int i 0; i 10; i) { pins[i][0] 0; pins[i][1] 0; pins[i][2] 0; rollInFrame[i] 0; } currentFrame 0; } bool isGameOver() { return currentFrame 10; } void addRoll(int knockedPins) { if (isGameOver()) { return; } int frame currentFrame; pins[frame][rollInFrame[frame]] knockedPins; rollInFrame[frame]; bool frameFinished false; if (frame 9) { // 前 9 帧全中或两次投球后该帧结束 if (pins[frame][0] 10 || rollInFrame[frame] 2) { frameFinished true; } } else { // 第 10 帧全中补中后额外投球否则两次结束 if (pins[frame][0] pins[frame][1] 10 rollInFrame[frame] 2) { frameFinished true; } else if (rollInFrame[frame] 3) { frameFinished true; } } if (frameFinished) { currentFrame; } } int frameScore(int frame) { if (frame 0 || frame 9) { return 0; } int first pins[frame][0]; int second pins[frame][1]; int third pins[frame][2]; // 第 10 帧直接累计三次投球结果即可 if (frame 9) { return first second third; } // Strike if (first 10) { int nextFirst pins[frame 1][0]; int nextSecond; if (nextFirst 10 frame 2 9) { nextSecond pins[frame 2][0]; } else { nextSecond pins[frame 1][1]; } return 10 nextFirst nextSecond; } // Spare if (first second 10) { return 10 pins[frame 1][0]; } return first second; } int totalScore() { int sum 0; for (int i 0; i currentFrame i 10; i) { sum frameScore(i); } return sum; } int getCurrentFrame() { return currentFrame; } };这段代码有几个关键点需要解释。第一addRoll不是简单加分数而是先记录投球结果再判断当前帧是否结束。这个设计让传感器输入和计分逻辑彻底解耦。第二frameScore在计算 Strike 和 Spare 的奖励分时会访问下一帧的投球数据。因此代码里要避免在整局尚未结束时越界访问。上面的示例做了简化实际项目中建议在frameScore里增加防御判断避免第 9 帧 Strike 时访问不存在的第 11 帧。第三第 10 帧不参与奖励分计算直接把三次投球相加即可。很多初学保龄球计分的开发者会把第 10 帧也当成普通帧处理导致得分错误。2.3 计分模块的测试用例计分逻辑必须在接硬件之前先测好。最稳妥的做法是准备一组已知用例把结果提前算好再写一个测试函数验证。投球序列预期总分说明10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 1030012 次全中满分9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9, 1, 9191每次补中后最后一次投 90, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 00全部落空5, 3, 7, 2, ...14普通帧累计测试代码可以直接跑在电脑上把addRoll按顺序调用然后打印totalScore()。ESP32 上用串口输出同样可以但每次烧录成本较高建议先在电脑单元测试。2.4 计分逻辑最容易踩的三个坑第一个坑是没有处理全中的递延奖励。一个 Strike 要等后续两次投球完成后才能算出准确得分如果每次addRoll后立即重算所有帧中间几帧会出现“当前得分比最终得分少”的情况这是正常的。只要整局结束后分数正确即可。第二个坑是没有处理第 10 帧。第 10 帧投出 Strike 或 Spare 后还有额外投球次数共 3 次。忽略这一点会导致最后几球甚至全部得分错误。第三个坑是奖励分越界。如果第 9 帧 Strike计算第 9 帧分数时需要看第 10 帧的两次投球结果如果第 10 帧还没投球访问的数组元素为 0。示例代码里虽然直接索引但实际工程应增加“帧是否已有足够投球数据”的判断。3. 开发环境搭建Arduino IDE、PlatformIO、ESP-IDF 怎么选3.1 三个环境的适用场景ESP32 开发环境的选型很多人纠结了很长时间。其实只要按项目规模和调试需求判断即可。开发环境适合场景优点需要注意Arduino IDE快速原型、小项目、学习上手快库丰富烧录简单大型工程管理弱多文件组织不够清晰PlatformIO中大型项目、多文件工程支持多平台、依赖管理完善、可配置 lib_deps需要了解 VS Code 和构建系统概念ESP-IDF正式产品、深挖底层、RTOS 特性官方完整框架支持 FreeRTOS 高级功能学习曲线陡CMake 配置较重保龄球计分系统如果只是原型用 Arduino IDE 最快。如果计划扩展 Wi-Fi、手机 App、Web 配置、多设备联动推荐一开始就使用 PlatformIO它更容易维护多个源文件。3.2 Arduino IDE 配置 ESP32 核心在 Arduino IDE 中安装 ESP32 支持核心操作是添加开发板管理器地址。常见地址为https://espressif.github.io/arduino-esp32/package_esp32_index.json在“文件 - 首选项 - 附加开发板管理器 URL”中加入该地址再在“工具 - 开发板 - 开发板管理器”中搜索 ESP32 安装。网络不稳定的情况下经常出现开发板安装包下载失败的问题。很多开发者会选择下载离线安装包手动解压到 Arduino 的 hardware/espressif 目录。离线包的好处是不受网络波动影响但要注意安装包版本和 Arduino IDE 版本的匹配否则可能编译时报缺少组件。安装完成后可以先编写一个 Blink 程序验证环境void setup() { pinMode(2, OUTPUT); } void loop() { digitalWrite(2, HIGH); delay(500); digitalWrite(2, LOW); delay(500); }编译并烧录后如果开发板上的 LED 以 1 秒周期闪烁说明环境配置成功。这里要特别注意不同开发板 LED 引脚不同常见的是 GPIO2、GPIO1、GPIO33以自己开发板原理图为准。3.3 PlatformIO 项目初始化PlatformIO 用platformio.ini管理项目。最小配置如下[env:esp32dev] platform espressif32 board esp32dev framework arduino monitor_speed 115200创建项目时源码放在src/目录库发布到lib/目录。VS Code 中安装 PlatformIO IDE 扩展后可以点击底部的 Upload 按钮直接编译烧录比 Arduino IDE 更容易管理多文件。3.4 项目目录结构建议无论用哪种环境建议把代码划分为几个独立文件bowling/ src/ main.cpp BowlingScore.h BowlingScore.cpp DisplayManager.cpp InputManager.cpp BLEManager.cpp platformio.ini如果使用 Arduino IDE一个.ino文件也可以放下全部代码但文件超过 500 行后维护成本会明显上升。推荐至少把计分逻辑单独拆成BowlingScore.h和BowlingScore.cpp。3.5 依赖版本问题ESP32 的 Arduino 核心库更新较快不同版本对 BLE、蓝牙串口、Adafruit 驱动的兼容性不完全一致。项目开始时应该固定一个已知可用的版本。在 PlatformIO 中可以固定为platform espressif326.2.0在 Arduino IDE 中则要注意“开发板管理器”里安装的具体版本。如果代码之前编译正常更新核心库后突然报错优先检查库版本变化而不是代码本身。4. 输入检测和主循环状态机设计4.1 用按钮模拟投球用拨码或传感器模拟击倒瓶数在原型阶段不一定需要真实的保龄球道。可以用两个按钮模拟一次投球按钮 A记录本次投球击倒瓶数递增。按钮 B提交本次投球触发addRoll。这样在桌面上就能完整测试 10 帧计分逻辑。到实际场地后可以把按钮 B 替换成红外对射传感器把按钮 A 替换成数字拨码或激光检测模块但主循环不变。4.2 按钮消抖和输入过滤直接使用按钮时机械抖动会造成一次按压被识别成多次。最简单的消抖方式是延时判断const int BTN_SUBMIT 4; const int BTN_INCREASE 5; int lastSubmitState HIGH; unsigned long lastDebounceTime 0; const unsigned long debounceDelay 20; void readButtons() { int submitState digitalRead(BTN_SUBMIT); if (submitState ! lastSubmitState) { lastDebounceTime millis(); } if ((millis() - lastDebounceTime) debounceDelay) { if (submitState LOW lastSubmitState HIGH) { // 按下动作 int pinsDown currentPinsDown; score.addRoll(pinsDown); currentPinsDown 0; } } lastSubmitState submitState; }在实际算分里还要限制pinsDown的范围是 0 到 10且当前帧剩余瓶数不能为负数。否则一次误触可能提交一个非法值导致计分错乱。4.3 主循环和串口输出主循环不需要太复杂关键是保持读取、计分、显示三个步骤每次循环都完整执行void loop() { readButtons(); processBLECommands(); updateDisplay(); }可以在每帧结束后打印一条串口日志Frame:1 rolls:7 2 frameScore:9 total:9 Frame:2 rolls:10 strike frameScore:20 total:29串口日志的格式没有标准但建议固定字段顺序方便后续用脚本分析或手机 App 解析。4.4 显示模块OLED 还是 LED 点阵如果只显示当前帧、击倒瓶数和总分0.96 寸 OLED 就够用。常见库是 U8g2 或 Adafruit SSD1306。接线通常是 I2COLED 引脚ESP32 GPIOSDAGPIO21SCLGPIO22VCC3.3V 或 5V看模块是否带稳压GNDGND如果项目需要大屏和动效可以接 MAX7219 LED 点阵或 TFT LCD。搜索材料中频繁出现的 LVGL 也可以在这个场景使用尤其当你想做出帧切换动画时LVGL 的anim功能和列表控件会比纯画点方便得多。注意OLED 模块 I2C 地址通常为 0x3C 或 0x3D扫描不到设备时先检查地址和接线再检查代码里传入的地址是否正确。5. 蓝牙和手机控制5.1 为什么用 Bluetooth Serial 而不是直接写 BLE GATTESP32 支持经典蓝牙和 BLE。对于保龄球系统这种小数据量场景最快跑通的方式是BluetoothSerial手机可以用任意串口蓝牙 App 连接发送文本指令。#include BluetoothSerial.h BluetoothSerial SerialBT; void setup() { Serial.begin(115200); SerialBT.begin(ESP32_Bowling); } void loop() { if (SerialBT.available()) { String cmd SerialBT.readStringUntil(\n); cmd.trim(); if (cmd RESET) { score.reset(); SerialBT.println(OK); } else if (cmd.startsWith(ROLL:)) { int v cmd.substring(5).toInt(); if (v 0 v 10) { score.addRoll(v); SerialBT.println(OK); } else { SerialBT.println(INVALID); } } else if (cmd SCORE) { SerialBT.printf(SCORE:%d\n, score.totalScore()); } } }这里的文本协议只是示例。实际项目中如果指令变多建议使用 JSON 格式并用 ArduinoJson 库解析结构更清晰手机端也容易生成。5.2 局域网和远程控制扩展Bluetooth Serial 仅适合手机和 ESP32 一对一的近距离控制。如果需要在局域网内查看计分可以尝试 ESP32 的 Web Server 或通过 MQTT 上报分数。一个比较轻量的做法是让 ESP32 启动一个 Web 页面显示当前分数和帧状态。手机上打开浏览器即可查看不需要安装 App。这会把项目从“蓝牙透传”升级成“嵌入式 Web 服务”学习价值更大。5.3 蓝牙连接不上的排查顺序蓝牙连接问题通常不是代码本身而是模式或电源问题。开发板是否处于下载模式。手机是否已配对但未连接。BLE 和经典蓝牙同时使用时的相互干扰。板上电源是否稳定蓝牙广播或连接瞬间电流较大USB 供电一般没问题电池供电时要注意电压。6. 烧录、运行与验证6.1 Arduino IDE 烧录步骤在 Arduino IDE 中选择正确的开发板型号和串口端口后点击上传。常见的开发板选择如下开发板Arduino IDE 选项ESP32 DevKit V1ESP32 Dev ModuleESP32-S3 DevKitCESP32S3 Dev ModuleESP32-C3 DevKitMESP32C3 Dev Module如果选择错误一般不会损坏硬件但可能出现编译报错或端口选择失败。建议在“工具 - 端口”里确认串口是否出现Windows 下如果端口没有出现先安装 USB 转串口驱动。6.2 烧录失败和无法再烧录的处理烧录失败是最常见的问题但不能一上来就换板子。按以下顺序排查确认开发板是否进入下载模式。部分开发板需要按住 BOOT 按键插入 USB再松开 BOOT。确认串口端口是否被其他软件占用关闭串口监视器再烧录。尝试降低波特率例如从 921600 降到 115200。观察串口监视器是否有 ROM 启动信息例如rst:0x1 (POWERON_RESET)、ets Jun 8 2016等。如果烧录后无法再次烧录最常见的原因是代码里占用了 UART0 或把 GPIO0/GPIO2 等关键引脚设为输出。例如 AT 固件的模块使用 UART0 作为指令口刷入用户代码前需要先擦除 flash。可以通过 esptool 擦除esptool.py --port COM3 erase_flash这个命令会清除整片 flash之后可以重新烧录用户程序。如果开发板本身没有刷过 AT 固件通常不需要擦除。6.3 固件备份和读取现有固件在修改别人板子或刷回原厂固件前建议先备份当前固件。esptool 支持读取 flashesptool.py --port COM3 read_flash 0x00000 0x400000 firmware_backup.bin这样可以把整个 4MB flash 导出为 bin 文件。注意读取时间较长且部分模块带有蓝牙地址或校准数据恢复时要完整写回不要只写一半。6.4 串口监视器验证输出烧录完成后打开串口监视器波特率设为 115200。预期输出包括启动日志和每次投球后的计分信息。如果完全没有输出检查波特率是否匹配。是否选了错误的串口端口。开发板是否在运行观察电源 LED。串口线是否只是充电线不是数据线。6.5 手机端验证蓝牙指令手机安装任意串口蓝牙助手连接ESP32_Bowling。发送ROLL:10观察 OLED 或串口显示当前帧为 Strike发送SCORE查看总分是否符合预期。注意不要只验证“能连接、能收到 OK”要完整打一局 12 次全中确认总分是 300。计分规则的边界最容易在这种完整验证中暴露。7. 常见问题排查7.1 烧录问题汇总问题现象常见原因检查方式处理建议烧录提示连接超时未进入下载模式按住 BOOT 再插 USB手动进入下载模式或按住 BOOT 点击上传烧录后没有串口输出波特率不匹配或选了错误端口检查设备管理器 COM 口匹配 115200重新插拔数据线烧录成功但程序运行异常开发板型号选错核对芯片型号更换为 S3、C3、Dev Module 等正确选项烧录后无法再烧录代码占用 UART0 或 GPIO0串口看启动日志用 esptool erase_flash 后重新烧录7.2 计分错误排查如果计分结果不符合预期不要先怀疑传感器先在串口里把每次addRoll收到的值打印出来。问题现象常见原因检查方式处理建议全中后总分偏少奖励分没有加后续投球检查第 9 帧 Strike 后第 10 帧分数在 frameScore 里增加越界判断第 10 帧得分不对没有处理额外投球打印第 10 帧每次投球结果用第 10 帧单独计分逻辑按键按一次算两次未消抖串口打印按键次数增加延时消抖或使用按键库点击提交后没有生效当前帧已满但状态未切换打印 currentFrame检查帧结束判断条件7.3 传感器误触发现场使用红外对射或振动传感器时可能存在传感器误触发。常见原因包括环境光干扰、传感器安装角度位移、供电不足。处理顺序增加消抖延时避免瞬间信号。对同一帧设置最小间隔时间。在串口记录所有触发事件判断是否真的来自投球动作。如果传感器持续触发先断开传感器用按钮测试主逻辑是否正常。7.4 蓝牙连接不稳蓝牙连接不稳定的现象通常表现为连接后几秒断开或无法同时使用蓝牙和 Wi-Fi。排查时注意检查电源蓝牙广播和连接瞬间电流较大。检查代码是否同时初始化了 BLE 和经典蓝牙。避免在回调函数里执行耗时操作。如果手机之前配对过旧设备删除配对记录后重新连接。7.5 显示屏问题OLED 不显示时先扫描 I2C 地址再确认代码中使用的地址和模块一致。接线时特别注意 SDA、SCL 是否接反。使用 5V OLED 模块时部分模块自带稳压可以直接接 3.3V 逻辑但有些模块需要 5V 供电逻辑电平依然兼容 3.3V具体以模块手册为准。8. 学习环境与现场部署的差异8.1 学习环境怎么快速跑通学习环境的目标是验证逻辑正确硬件越简单越好。桌面用两个按钮、一块 OLED、一块 ESP32 开发板就足够。步骤顺序建议先用电脑测试计分模块。接一个按钮提交每次投球结果。串口打印当前帧和总分。接 OLED 显示。接蓝牙用手机发送指令。再考虑换成传感器模拟真实球道。8.2 现场部署时的额外保障从桌面原型到真实场地需要额外处理这些事项电源用 USB 供电测试时基本稳定但现场用电池或电源适配器时注意电压纹波建议在电源端并联 100uF 左右电容。传感器可靠性传感器安装要固定避免投球震动导致误触发。显示刷新OLED 连续刷新会占用主循环时间建议只在状态变化时刷新而不是每轮循环都执行 draw。日志现场调试时保留串口日志方便回看。防水防尘如果是真实球馆环境元器件需要外壳保护。8.3 日志和调试信息规范调试时建议统一日志格式[MS] eventroll pins7 frame2 rollInFrame1 total16这种格式的日志在串口下易读也方便后续用脚本统计。生产环境则建议通过 Wi-Fi 上报日志到 MQTT 或本地服务并限制日志输出频率。8.4 版本管理和回滚代码量超过几百行后强烈建议使用 Git。每次硬件改动前都提交一次烧录出问题时可以快速回滚到上个版本。固件备份同样重要使用 esptool 备份原始 flash可以避免刷 AT 固件或误擦除后无法恢复。9. 可复用清单和扩展方向9.1 发布前检查清单- 计分模块是否已用至少两组用例验证300 分、191 分、0 分 - 是否验证了第 10 帧的额外投球 - 是否验证了传感器/按钮的防误触 - 是否验证了蓝牙指令非法输入不会被 accept - 是否验证了 OLED 在长时间运行后不会花屏 - 是否备份了原始固件 - 电源是否满足瞬时电流需求 - 串口日志是否输出关键状态 - 代码是否提交到 Git9.2 功能扩展方向保龄球系统可以继续扩展的方向比较多适合作为后续练习接入真实传感器用两个红外对射传感器检测球滚过和瓶倒数量。局域网多屏显示ESP32 通过 Wi-Fi 把比分发给多个 Web 页面。手机 App 控制用 BLE GATT 代替经典蓝牙串口实现更规范的指令集。LVGL 界面接大尺寸显示屏用 LVGL 制作帧动画、奖励动画。保存历史局数用 Preferences 库把历史成绩写入 flash掉电不丢失。接入 micro-ROS如果想把数据接入 ROS 机器人或仿真环境可以研究 ESP32 上的 micro-ROS 方案。语音提示通过 ESP32 外接语音模块或编码器播放音频提示。9.3 对新手最重要的练习建议如果只做一件事建议反复练习“把计分逻辑写成与硬件无关的独立模块”。先写BowlingScore类在电脑上测试再把它放进 ESP32 工程。这个习惯能让你在后来的硬件调试中少花大量时间。保龄球系统这套项目真正有价值的地方不在于保龄球本身而在于它强迫你把状态机、边界条件、外部输入、显示刷新和通信协议串成一个完整闭环。做完它再回头处理任何“按键 显示 通信”类的小项目都会顺畅很多。