很多球友打到一定程度都会遇到同一个瓶颈:没人陪练。花钱请教练不便宜,找个水平相当的球友也不容易。我自己练球三年多,从徒手挥拍到多球训练都试过,后来实在不想每次都跑去球馆蹭人家的发球机,就决定自己动手做一台。用Arduino做乒乓球发球机这个项目,我在网上翻了很久,发现大部分资料要么只讲机械结构,要么只给一段控制代码,真正把机械设计、电子接线、控制逻辑串起来讲透的非常少。所以这篇文章想把我从零开始做这台发球机的完整过程写出来,包括我踩过的坑、反复调过的参数,以及最后跑通的心得,希望能帮到也想自己搞一台的朋友。
这台发球机能做到什么程度?简单说,它能实现两种出球模式——固定频率出球和随机落点出球;能通过按键调节出球间隔;能通过舵机控制发射管俯仰角,实现远近落点的变化;还能用OLED显示屏实时显示当前模式、间隔和俯仰角数值。整机成本控制在300元以内,核心主控用Arduino Uno,执行机构用两个直流减速电机加两个舵机。无论是硬件接线、机械装配还是代码烧录,全部开源可复现,新手照着做也能两三个晚上搞定。
如果你正准备入门Arduino,或者打乒乓球想有个不占地方的陪练伙伴,又或者单纯想看看“一个机电一体化小项目”到底怎么从图纸变成实物,这篇文章都很适合你。接下来我会从整体设计思路开始,一步步拆解机械结构、控制电路、核心代码和调试过程。
1. 项目概述与总体设计思路
1.1 这台发球机要解决什么问题
先说清楚需求边界。市面上成品发球机从几百到几千都有,贵的主要贵在出球精度、旋转模拟、遥控编程这些功能上。但对我来说,最核心的需求就三个:能把球稳定地发射出去、能控制发射频率、能改变落点范围。至于旋转球、弧线球、多球不规则落点,属于进阶玩法,可以先不做。
再拆一下,这三个需求分别对应三个子系统:
- 把球发射出去——需要一个“出球机构”,常见方案有单轮摩擦、双轮摩擦、气动喷射等,其中双轮摩擦结构最简单、成本最低、速度调节也最直观。
- 控制发射频率——需要一个可调定时器,用Arduino的定时器中断或非阻塞延时来实现,配合按键步进调节。
- 改变落点范围——通过舵机控制发射管俯仰角,球出去之后的远近就靠这个角度决定;如果条件允许,还可以加一个水平摆动舵机控制左右落点。
我最终选择的方案是:双轮摩擦出球 + 舵机驱动发射管俯仰角。整机分为机械底座、出球结构、发射管、控制盒四部分,全部用3D打印件加标准件拼装,方便后期调整。
1.2 整体架构与技术选型
整个系统的架构可以用“输入—控制—输出”来衡量:
- 输入:三个按键,分别负责模式切换、间隔加、间隔减(也可以扩展成更多按键)。
- 控制:一块Arduino Uno,负责读取按键状态、维护当前模式与参数、计算舵机目标角度、按时间控制出球电机启动。
- 输出:两个直流电机(负责摩擦轮)、两个舵机(一个管俯仰角,另一个预留管水平摆动)、一个OLED显示屏。
为什么主控选Arduino Uno而不是ESP32或STM32?说实话,这个项目对主控的算力要求极低,不涉及网络、图像、音频处理,纯粹是GPIO读写加定时控制,Uno的ATmega328P完全够用。更重要的是,Uno的生态最成熟,接线教程、库文件、出问题排查方案都多,适合新手第一次接触机电一体化项目。如果你的手里现成有ESP32开发板,那也能做,代码几乎不用改,只是引脚号需要对应调整,后面我会提一嘴差异。
1.3 为什么用Arduino而不是纯硬件电路
有人可能会问:这种控制用555定时器加继电器不也能实现吗?确实能,纯硬件方案在很多工业设备里还在用,但它有两个痛点:一是参数调整要改电阻电容,不够灵活;二是后期想加功能(比如随机落点、蓝牙遥控)几乎要重新设计电路。
Arduino方案把“控制逻辑”从硬件里抽离出来,写进代码里,这就带来了几个实实在在的好处:
- 改参数不用动硬件:要调整出球间隔,改一行代码里的时间变量就行,不用拆机器换电阻。
- 功能扩展只需要写代码:从“固定间隔出球”升级到“随机落点”,本质上是加一个随机数函数,其他硬件不用动。
- 调试友好:通过串口监视器能实时看到当前模式、间隔值、舵机角度,出问题能快速定位是硬件还是逻辑问题。
我自己在调试阶段就深刻体会到了这个好处。有一次电机不转,我先看了串口输出,发现控制信号正常,那问题就锁定在电机驱动模块和电源上,直接拿万用表量,五分钟就找到是驱动模块的GND没接好。要是纯硬件方案,排查路径会复杂得多。
2. 机械设计与零部件选型
2.1 整机结构布局
先画一下整机的布局,方便理解后面每一部分的位置关系。我的设计分四层,自上而下是:
- 顶层是乒乓球储球斗,一个倾斜的料斗,球靠重力自然滚落到出球轮位置。
- 中间层是出球机构,两个摩擦轮呈上下布局,球从两个轮子之间穿过被挤压弹出,轮子外壳有一个弧形导向槽,限制球的运动轨迹。
- 再往下是发射管支架,一根直径略大于乒乓球的圆管,可以绕水平轴旋转,管口方向决定了球的出射角度。
- 底层是底座,固定所有部件,同时安装Arduino、电机驱动模块、舵机和电源。
这个布局的好处是重心低,运行时稳定。我之前在图纸阶段试过把储球斗放侧面,球容易卡在转角处,后来改成顶部直落才解决。
2.2 出球机构的核心设计——双轮摩擦
双轮摩擦出球的原理很直观:两个轮子同向高速旋转,球进入两轮之间的间隙后,轮缘表面与球面产生摩擦力,把球加速到接近轮缘线速度,然后从导向槽飞出。
具体有四个参数需要重点计算:
轮距(两轮之间的间隙)。乒乓球直径40mm,那么轮距应该略小于40mm,通常在36-38mm之间。轮距越小,球被挤压的形变越大,摩擦力越大,出球速度越快,但太小会导致卡球,电机负载急剧升高。我测试下来,38mm是个比较舒服的折中值,既能稳定出球又不会卡死。
轮子直径。我用的轮子是直径60mm的橡胶轮,配上减速比1:30、空载转速300RPM的直流减速电机,轮缘线速度大约是 ( v = \pi \times 0.06 \times (300/60) \approx 0.94 , m/s ),这个速度对应球速不算快,适合练习近台基本功。想提高球速,可以把减速比调低到1:10,或换用更大功率的电机。
轮子表面材质。一定要用橡胶或硅胶材质的轮子,纯塑料轮摩擦力不够,球会打滑,要么出不去,要么出去的球转速不稳。我在淘宝上买过一套“轮式AGV小车橡胶轮”,直径60mm,轴孔6mm,几块钱一个,效果就很好。
导向槽弧度。导向槽的作用是把球限定在两轮中间的通道里,槽的横截面应该是圆弧形,半径略大于球的半径,约22mm。槽太浅球容易跳出来,太深则增加摩擦阻力。
出球速度的粗略估算公式是:( v_{球} = \omega_{轮} \times r_{轮} \times \eta ),其中 (\eta) 是摩擦传递效率,一般在0.7-0.85之间。比如轮子角速度 (\omega = 31.4 , rad/s)(300RPM),轮半径 (r = 0.03m),效率取0.8,那么 ( v_{球} \approx 31.4 \times 0.03 \times 0.8 \approx 0.75 , m/s )。这个速度作为入门练习够用,但肯定不如商用机动不动十几米每秒那么暴力。
2.3 俯仰角调节机构
落点的远近由发射管的俯仰角决定。我用了MG996R金属舵机,扭矩标称9.4kg·cm(实际6V供电下大概能到7kg·cm左右),驱动一根发射管完全没有问题。
结构上,发射管尾端铰接在底座支架上,舵机臂通过一根连杆连接发射管中部。舵机转动时,连杆推拉发射管,使其绕尾端铰点转动。这里要注意连杆的长度和舵机臂的角度要匹配,否则会卡死。我建议在装配前先用SolidWorks或者FreeCAD简单仿真一下运动范围,如果没有建模基础,也可以直接用硬纸板裁个模型试角度,我当初就是这么干的。
俯仰角的控制精度其实不需要很高,毕竟目标是把球打到桌面不同位置,而不是精确到毫米。舵机输出约0-180度,对应发射管仰角约-5度到40度,这个范围覆盖了近台和半台远两种落点。需要注意的是,发射管仰角越大,球出去后落在桌面上的距离越近;仰角越小甚至水平,球飞得越远。原理很简单,出射速度不变时,抛射角的水平射程随仰角变化,但在实际桌面上还要考虑球台高度和落点缓冲,不能直接拿真空抛体公式硬套,实机调试更重要。
2.4 3D打印件与标准件清单
整机外壳和支架我用PLA材料3D打印,层高0.2mm,填充率30%就够,不需要太结实,毕竟受力不大。没有3D打印机的朋友也可以用亚克力板、木板甚至硬纸板加热熔胶搭一个,这个项目对结构刚度的要求真的不高。以下是完整物料清单:
| 部件 | 型号/规格 | 数量 | 备注 |
|---|---|---|---|
| 主控板 | Arduino Uno R3 | 1 | 兼容板就行 |
| 直流减速电机 | 300RPM,6V | 2 | 配橡胶轮 |
| 电机驱动模块 | L298N 或 TB6612FNG | 1 | L298N便宜,TB6612效率高 |
| 舵机 | MG996R | 1-2 | 俯仰必用,水平摆动可选 |
| 橡胶轮 | 直径60mm,轴孔6mm | 2 | 摩擦出球用 |
| 电源 | 12V 5A 开关电源 | 1 | 给电机驱动和舵机供电 |
| 降压模块 | MP1584或LM2596 | 1 | 12V转5V给Arduino供电 |
| OLED屏 | 0.96寸 I2C SSD1306 | 1 | 显示状态 |
| 按键 | 轻触按键 | 3 | 模式/加/减 |
| 结构件 | 3D打印外壳+支架 | 1套 | PLA即可 |
| 发射管 | 直径45mm PVC管,长200mm | 1 | 不要太长 |
2.5 机械装配的坑(提前说)
装配顺序建议是先装出球轮和电机,再装发射管和舵机,最后装储球斗和控制盒。千万先别急着把外壳完全合上,因为后面调试时大概率还要拆开调整。我在第一次装配时犯过一个低级错误:把舵机臂的角度装反了,结果舵机一转,发射管往桌子上砸而不是往天上抬,幸好速度不快,不然管子都断掉。
还有一个细节:直流电机转动方向的一致性。两个摩擦轮必须同向旋转,球才能顺利往前送,装电机前用万用表确认电机正负极,接好线后点动测试,确认两个轮子的转向一致再固定螺丝。我发现很多第一次做这个项目的人卡在“球老是吐不出来”,原因往往就是两个轮子反向转了。
3. 控制系统搭建与接线
3.1 接线方案总览
控制系统的接线是整个项目里最容易被忽视却又最关键的一步。接线错了,轻则工作不正常,重则烧板子,所以我单独把这块拎出来好好讲讲。
整机需要供电的设备有:Arduino Uno、两个直流电机、两个舵机、OLED屏、按键。它们在电压和电流需求上差异很大:
| 设备 | 工作电压 | 典型工作电流 | 供电来源 |
|---|---|---|---|
| Arduino Uno | 5V(可通过Vin接7-12V) | 50mA左右 | 独立降压模块 |
| 直流电机 | 6V | 每个0.5-1.5A(堵转时更大) | L298N 电机驱动输出 |
| MG996R舵机 | 5-6V | 待机10mA,堵转可达2A | 独立5V稳压模块 |
| OLED屏 | 3.3-5V | 20mA左右 | Arduino的5V引脚 |
| 按键 | 5V | 几毫安 | Arduino的5V引脚 |
注意上面两个重点:舵机千万不要直接由Arduino的5V引脚供电。MG996R堵转电流能到2A,而Uno板载AMS1117稳压芯片最大输出也就1A左右,直接接会掉压重启甚至烧板子。直流电机更不要从Arduino引脚取电,必须经电机驱动模块。
我实际用的供电拓扑是:
- 总电源:12V 5A开关电源,接入L298N的12V输入端子。
- 电机供电:L298N的OUT1/OUT2接一个电机,OUT3/OUT4接另一个电机,驱动模块内部把12V降压到6V输出给电机(如果觉得12V对6V电机太暴力,可以在L298N电源输入端前面加一个DC-DC降压模块,把电压先降到7V左右)。
- Arduino供电:从L298N的5V输出(该输出来自板载稳压)接到Arduino的5V引脚。这里有个坑——L298N板载5V是从12V线性稳压出来的,电流不大,带动Uno和OLED屏没问题,但不要再去带舵机。
- 舵机供电:单独用一个MP1584降压模块,把12V降到5.5V左右,接到舵机电源线。MP1584输出电流能力大约3A,带两个舵机够用。
- 信号线:Arduino的数字引脚给舵机、电机驱动模块提供PWM信号,给OLED提供I2C信号,给按键提供开关量输入。
3.2 具体接线对照表
以下是我用的接线引脚,你可以根据自己的习惯修改,只要在代码里同步更新就行:
| Arduino引脚 | 连接设备 | 说明 |
|---|---|---|
| D3 | 舵机1(俯仰) | 信号线 |
| D5 | 舵机2(水平摆动,预留) | 信号线 |
| D6 | L298N ENA | 电机A使能脚,PWM调速 |
| D7 | L298N IN1 | 电机A方向 |
| D8 | L298N IN2 | 电机A方向 |
| D9 | L298N ENB | 电机B使能脚,PWM调速 |
| D10 | L298N IN3 | 电机B方向 |
| D11 | L298N IN4 | 电机B方向 |
| A4 | OLED SDA | I2C数据 |
| A5 | OLED SCL | I2C时钟 |
| D2 | 按键1(模式切换) | 接10K下拉电阻 |
| D4 | 按键2(间隔加) | 接10K下拉电阻 |
| D12 | 按键3(间隔减) | 接10K下拉电阻 |
| GND | L298N GND、电源模块GND | 全部共地,必须的 |
共地这件事多说一句:所有模块的GND必须连在一起,如果不共地,信号线的参考电压不一样,就会出现舵机乱抖、按键失灵之类的诡异问题。我一开始没给舵机电源模块和Arduino共地,导致舵机偶尔自己抖一下,找了半天才发现是共地没处理好。
3.3 按键与OLED接线细节
按键的接法有两种,一种是用内部上拉,一种是用下拉。我习惯用外部下拉电阻,这样逻辑上按下为HIGH,读起来直观。如果不想焊电阻,也可以把按键一端接GND、另一端接引脚,然后启用Arduino的INPUT_PULLUP,代码里读LOW为按下。两种都可以,只要你心里清楚逻辑就行。
OLED是I2C接口,只有四根线:VCC、GND、SDA、SCL。把SCL接到A5、SDA接到A4,再用Uno的5V供电。这里要注意,如果OLED模块是3.3V版本,最好接3.3V引脚而不是5V,虽然大多数模块标称兼容5V,但长期满压供电容易发热。我用的是5V版本,就没这个问题。
3.4 供电预算验证
在通电之前,先估算一下总电流,避免电源模块过载。整机最耗电的是电机和舵机:
- 两个直流电机同时转,电流约 (2 \times 1A = 2A)。
- 两个舵机同时动作,峰值电流约 (2 \times 0.5A = 1A)(静态更低)。
- Arduino加OLED和按键,约100mA。
- 总电流约3.1A。
12V 5A的开关电源有足够余量,但如果手头只有2A电源,肯定会在电机启动瞬间掉压,然后整机复位。很多DIY发球机的“一开电机就重启”问题,基本都是电源余量不足。我的建议是电源规格至少按最大电流的1.5倍选,也就是 ( 3.1A \times 1.5 \approx 4.6A ),所以5A刚好合适。
4. 控制逻辑设计与代码实现
4.1 软件整体架构
控制逻辑这部分是整个项目的大脑,代码看起来不长,但设计思路上有几个关键点值得展开讲。
先明确需求:
- 三种工作模式:待机模式(电机不转)、连续出球模式(按设定间隔一直出球)、随机落点模式(出球间隔和俯仰角在范围内随机变化)。
- 按键调整:模式切换键循环切换三种模式;间隔加/减键在待机模式下调整出球间隔,步进0.5秒,范围1-5秒。
- 舵机控制:待机时舵机回到初始角度;连续出球时舵机固定在当前角度;随机模式时每出一次球,舵机在25度和75度之间随机取一个值。
- 状态显示:OLED显示当前模式、出球间隔、舵机角度。
软件架构上,我用了最常见的“非阻塞状态机”思路——主循环里不断检查按键、刷新显示器、查询当前时间并判断是否该出球,通过millis()函数来判断时间,而不是用delay()卡住整个程序。这一点很重要,因为如果用了delay(),程序在等待期间做不了任何其他事,按键会失灵,OLED也不会刷新。
4.2 出球频率控制的两种实现方式
控制出球频率有两种常规做法:
- 定时器中断法:利用ATmega328P的定时器1产生固定时基中断,中断服务函数里翻转电机IO状态。优点是计时精准,不占用主循环;缺点是中断和
Servo.h库存在资源冲突(Servo库自己也用定时器1),需要换其他定时器。 - 非阻塞延时法(
millis()差量判断):主循环里持续记录上一次出球的时间lastFireTime,每次循环判断millis() - lastFireTime >= intervalMillis,成立则执行一次出球动作并更新时间戳。优点是代码简单、不会阻塞其他任务,计时精度对于发球机这种场景绰绰有余。
我最终采用了第二种,原因很简单:出球间隔是秒级,不需要微秒级精度的中断定时;而且非阻塞写法让我能在同一个循环里同时处理按键、OLED刷新和舵机控制,代码结构清爽得多。
4.3 舵机控制与PWM原理
Arduino用Servo.h库控制舵机,本质是输出50Hz的PWM信号,通过改变脉冲宽度(通常1ms到2ms,对应0到180度)来设定舵机角度。这个库使用方便,但你不需要在代码里手动计算PWM占空比,直接myservo.write(angle)就行。
有一点要留意:Servo.h库和高频PWM引脚(如D9、D10)会有冲突。如果你把舵机接到D9/D10,同时用analogWrite()在这些引脚上输出PWM给电机调速,两者会互相干扰。我建议舵机信号线接D3/D5,电机PWM接D6/D9,这样避开冲突。具体原因涉及Arduino内部定时器的分配,这里不展开,记住结论即可。
4.4 完整代码解析
以下是我实际烧录的代码,注释写得比较详细,你直接复制到Arduino IDE里,按照上面的接线表连接硬件,烧录后就能跑起来。
#include <Wire.h> #include <Adafruit_SSD1306.h> #include <Servo.h> // 引脚定义 #define BTN_MODE 2 #define BTN_ADD 4 #define BTN_SUB 12 #define SERVO_PITCH 3 #define MOTOR_ENA 6 #define MOTOR_IN1 7 #define MOTOR_IN2 8 #define MOTOR_ENB 9 #define MOTOR_IN3 10 #define MOTOR_IN4 11 Servo servoPitch; // OLED 0.96寸 I2C Adafruit_SSD1306 display(128, 64, &Wire, -1); // 全局状态 int mode = 0; // 0=待机, 1=连续出球, 2=随机落点 int intervalSec = 2; // 出球间隔默认2秒 int pitchAngle = 60; // 俯仰角默认60度 unsigned long lastFireTime = 0; bool motorRunning = false; void setup() { Serial.begin(115200); pinMode(BTN_MODE, INPUT); pinMode(BTN_ADD, INPUT); pinMode(BTN_SUB, INPUT); pinMode(MOTOR_ENA, OUTPUT); pinMode(MOTOR_IN1, OUTPUT); pinMode(MOTOR_IN2, OUTPUT); pinMode(MOTOR_ENB, OUTPUT); pinMode(MOTOR_IN3, OUTPUT); pinMode(MOTOR_IN4, OUTPUT); // 电机初始停止 digitalWrite(MOTOR_IN1, LOW); digitalWrite(MOTOR_IN2, LOW); digitalWrite(MOTOR_IN3, LOW); digitalWrite(MOTOR_IN4, LOW); analogWrite(MOTOR_ENA, 0); analogWrite(MOTOR_ENB, 0); servoPitch.attach(SERVO_PITCH); servoPitch.write(pitchAngle); // OLED初始化 if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println("OLED init failed"); while(1); } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); lastFireTime = millis(); } void loop() { checkButtons(); updateDisplay(); updateMotorState(); } // 按键扫描与逻辑处理 void checkButtons() { static unsigned long lastDebounceTime = 0; static int lastModeBtnState = HIGH; static int lastAddBtnState = HIGH; static int lastSubBtnState = HIGH; int modeBtn = digitalRead(BTN_MODE); int addBtn = digitalRead(BTN_ADD); int subBtn = digitalRead(BTN_SUB); // 防抖动处理,只处理按下沿 if (modeBtn == HIGH && lastModeBtnState == HIGH && millis() - lastDebounceTime > 200) { mode = (mode + 1) % 3; lastDebounceTime = millis(); Serial.print("Mode changed to: "); Serial.println(mode); } if (addBtn == HIGH && lastAddBtnState == HIGH && millis() - lastDebounceTime > 200) { if (mode == 0) { intervalSec = constrain(intervalSec + 1, 1, 5); Serial.print("Interval: "); Serial.println(intervalSec); } lastDebounceTime = millis(); } if (subBtn == HIGH && lastSubBtnState == HIGH && millis() - lastDebounceTime > 200) { if (mode == 0) { intervalSec = constrain(intervalSec - 1, 1, 5); Serial.print("Interval: "); Serial.println(intervalSec); } lastDebounceTime = millis(); } lastModeBtnState = modeBtn; lastAddBtnState = addBtn; lastSubBtnState = subBtn; } // 电机与舵机状态机更新 void updateMotorState() { if (mode == 0) { // 待机模式:电机停,舵机回初始角度 stopMotor(); if (pitchAngle != 60) { pitchAngle = 60; servoPitch.write(pitchAngle); } return; } if (mode == 1) { // 连续出球模式:按固定间隔触发 if (millis() - lastFireTime >= intervalSec * 1000UL) { fireBall(); lastFireTime = millis(); } return; } if (mode == 2) { // 随机落点模式:每次出球时随机调整间隔和角度 if (millis() - lastFireTime >= intervalSec * 1000UL) { intervalSec = random(1, 5); pitchAngle = random(25, 76); servoPitch.write(pitchAngle); fireBall(); lastFireTime = millis(); Serial.print("Random fire: interval="); Serial.print(intervalSec); Serial.print(" pitch="); Serial.println(pitchAngle); } return; } } // 触发一次出球动作 void fireBall() { analogWrite(MOTOR_ENA, 200); analogWrite(MOTOR_ENB, 200); digitalWrite(MOTOR_IN1, HIGH); digitalWrite(MOTOR_IN2, LOW); digitalWrite(MOTOR_IN3, HIGH); digitalWrite(MOTOR_IN4, LOW); motorRunning = true; // 这里有个重要设计:电机不立即停,而是持续转一段时间 // 因为球从储球斗滚到轮子之间并加速需要时间 // 我在实际测试中450ms比较合适,太短球还没出来电机就停了 static unsigned long motorStopTime = 0; motorStopTime = millis() + 450; while (millis() < motorStopTime) { // 这里用while是为了保证出球动作完成,但因为时间很短,对系统影响可忽略 } stopMotor(); } void stopMotor() { digitalWrite(MOTOR_IN1, LOW); digitalWrite(MOTOR_IN2, LOW); digitalWrite(MOTOR_IN3, LOW); digitalWrite(MOTOR_IN4, LOW); analogWrite(MOTOR_ENA, 0); analogWrite(MOTOR_ENB, 0); motorRunning = false; } // OLED显示刷新 void updateDisplay() { display.clearDisplay(); display.setCursor(0,0); display.print("Mode: "); switch(mode) { case 0: display.print("IDLE"); break; case 1: display.print("CONTINUOUS"); break; case 2: display.print("RANDOM"); break; } display.setCursor(0,16); display.print("Interval: "); display.print(intervalSec); display.print("s"); display.setCursor(0,32); display.print("Pitch: "); display.print(pitchAngle); display.print(" deg"); display.setCursor(0,48); display.print("State: "); display.print(motorRunning ? "RUN" : "STOP"); display.display(); }这段代码有几个地方值得特别说明:
fireBall()函数里我用了while (millis() < motorStopTime)来让电机持续转450ms。严格来说这不是最好的做法,因为它会阻塞主循环450ms。但考虑到这个动作发生在出球瞬间,按下按键或切换模式不会在450ms里发生,影响可以忽略。如果你写代码更追求优雅,可以把这个“持续450ms”也改成非阻塞状态,在updateMotorState()里再维护一个“电机开启状态”和“剩余运行时间”。我实际测试时先用了阻塞版,确定450ms参数合适后才改成非阻塞版。建议你先用阻塞版把机器跑起来,再考虑优化。
checkButtons()里的防抖逻辑我做了简化,没有用经典的滤波算法,而是用“读取状态下沿+200ms间隔”的方式。因为按键本身是机械结构,按下瞬间会有抖动,但如果两次有效操作之间至少间隔200ms,抖动就被天然忽略了。对发球机这种使用场景,没必要做太精细的消抖。
4.5 参数调节的依据和思路
代码里几个关键参数是我反复测试出来的,现在把测试过程和思路写出来,方便你根据自己的硬件情况调整。
450ms的出球电机运行时间是怎么定的?我先把电机设为常转,然后手动把球一颗一颗放进储球斗,观察球从进轮到飞出的时间。最终发现从球进入轮子空隙到被射出,大约需要350-500ms,取决于球的放置位置和轮子转速。取450ms作为电机持续运行时间,能保证球有足够时间穿过轮子。
出球间隔intervalSec的范围1-5秒是我拍脑袋定的上限吗?其实我测试过更短的间隔,比如0.5秒,但发现两个问题:一是球从储球斗滚到轮子之间的时间约0.8秒,间隔太短会导致第二颗球还在下落途中就被第一颗球卡住;二是电机频繁启停,驱动器发热明显。所以我最终把下限设在1秒,这刚好是“连续快攻”训练中比较快的节奏。如果你想让间隔更短,可以考虑把储球斗改成螺旋下料器,或者把电机改成常转,用挡板机构逐颗放球——但那样机械结构会复杂不少。
4.6 用Wokwi仿真验证控制逻辑
写代码阶段如果你手头没有硬件,可以用Wokwi这个在线仿真平台先验证逻辑。Wokwi支持Arduino Uno仿真,也内置了Servo、OLED、按键这些虚拟器件,不需要买任何硬件就能把代码跑起来。我装机的第一版代码就是在Wokwi里调试通过的,尤其是按键防抖和OLED显示逻辑,在仿真器里先验证,再去接真机,能少很多低级错误。
Wokwi的使用很简单:打开 wokwi.com,选择Arduino Uno模板,把代码复制进去,然后在右侧添加Servo、OLED、Button等虚拟元件,连线后点运行,就能看到串口输出和舵机角度变化。这特别适合还在犹豫要不要买材料的朋友,先用仿真清除代码层面的问题,再下单硬件,成功率会高很多。
5. 组装调试与常见问题排查
5.1 机械部分的组装调试
机械部分的调试重点在出球机构。装好之后我先不接控制板,直接给电机通电,让它持续旋转,然后手动把球放进储球斗,观察球的路径是否顺畅。
常见的三个问题:
- 球卡在储球斗出口:说明出口尺寸太小或者角度太平。我做的储球斗出口宽42mm,斜坡角度30度,球能顺利滚落。如果球又卡住,第一件事是量出口尺寸,第二件事是确认斜坡角度是否大于摩擦角。
- 球从导向槽侧边飞出去:说明导向槽太浅或者槽壁不够高。我的导向槽深度做到20mm,球正好嵌在里面,没有跳出来的机会。
- 出球速度太低:先看两个轮子的转速是否一致,再看轮距是不是太大。轮距可以从38mm逐步调小到36mm,每次调0.5mm,测试一次出球速度,调到满意为止。但注意别低于35mm,否则球会被过度挤压,出球力量很大,对电机和轮子寿命都有影响。
5.2 电子部分的调试顺序
电子部分不要一次性把所有模块都接上去调,那样出了问题很难定位。我建议按这个顺序来:
- 先只接Arduino和电脑USB,烧录一个LED闪烁程序,确认板子正常。
- 接OLED屏,跑I2C扫描程序确认屏幕能点亮。
- 接按键,串口打印按键值,测试消抖逻辑。
- 接舵机,让它从0到180度来回扫,确认能正常转动。
- 最后接电机驱动模块和电机,先用小PWM值测试转向,再逐步加大PWM。
这样每一层都验证过,再合起来整体跑,出问题就能快速缩小范围到某个模块。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 上电后Uno灯不亮 | 电源没接对或功率不够 | 万用表测Uno的5V引脚电压 | 换成独立5V电源 |
| 舵机静止时嗡嗡抖 | 电源电压不足或没共地 | 测舵机电源电压,低于5V则不稳定 | 用MP1584单独供电并共地 |
| 电机不转但代码正常 | L298N使能脚接错或PWM太小 | 量ENA/ENB电压,确认PWM值>100 | 查代码PWM值并检查接线 |
| 电机转但球吐不出来 | 两轮转向不一致或轮距太大 | 空转看轮子旋转方向 | 调电机接线顺序或减小轮距 |
| 球偶尔连续出两颗 | 电机运行时间过长 | 观察450ms时间是否合适 | 缩短电机运行时间 |
| OLED屏幕花屏 | I2C地址不对或干扰 | 跑I2C扫描程序找地址 | 在代码中修改0x3C为实际地址 |
| 按下按键没反应 | 接线错或下拉没接 | 万用表测按键两端电压 | 确认按键接法和代码读取引脚一致 |
5.4 我最想提醒的几个坑
第一,Arduino的5V引脚不要接电机和舵机。这句话我重复了无数遍,因为真有人拿Uno直驱MG996R,结果板子烧了。Uno的板载稳压器不是一个靠谱的功率器件,驱动电机的活儿交给L298N和DC-DC模块,Uno只负责发信号。
第二,出球电机的PWM值不是越大越好。我刚开始把PWM设成255(满速),结果球速太快,落点直接飞出球台,根本没法练。后来调成200,出球速度比较温和,落点也更容易控制。具体数值跟你的电机减速比、轮子直径、轮距都有关系,建议从150开始慢慢往上加,找到自己舒服的速度区间。
第三,随机落点模式的“随机”要限制范围,不能完全放飞。我一开始让角度在0到180度之间随机,结果球有时直接射上天,有时砸在自己的脚边。后来改成25到75度之间,才达到“大半张台面但可控”的效果。
第四,接线时所有线材尽量用颜色区分。电源正极用红色,负极用黑色,信号线用其他颜色,后面排查会轻松很多。我去调试现场总是先拍照存档,再动手改线,不然装完之后发现接错了,光是理线就能耗掉半小时。
6. 后续扩展思路
机器能跑通之后,还可以往这几个方向做功能扩展。如果你手头有ESP32开发板,可以直接把Uno换掉,代码改动量很小,主要是引脚映射不同。ESP32的好处是自带Wi-Fi和蓝牙,能扩展出手机App控制、网页控制面板等功能。
另一个很实用的扩展是把“声音识别”加进来。我注意到今年Arduino社区里ESP32加INMP441麦克风做声音识别的玩法很火,思路是用ESP32读取麦克风数据,通过边缘计算识别特定的语音指令或拍手声,然后触发发球机启动或停止。比如你说一声“开球”或者拍一下手,发球机就开始工作,这对于一个人练球来说非常方便,不用跑回控制台按按键。
当然这需要增加麦克风模块和相应的音频处理代码,复杂度比原来的按键方案高不少,但作为进阶项目很有挑战性。
如果你对运动控制有更高追求,还可以在出球机构里增加轮子的独立调速——两个摩擦轮速度不同时,球会带上旋或下旋。做法很简单,用两个独立的PWM通道分别控制两个电机的转速,然后在代码里增加一个“转速差”参数。我用这个方法试过,左右轮速度差达到20%时,球的旋转效果能明显感知,弧线会有明显变化,这对练习弧圈球防守非常有帮助。
再往上走,就是在发射管的水平方向加一个舵机,通过左右摆动让球落在球台的左半台或右半台,配合俯仰角控制,基本就能模拟标准多球训练的内容。水平摆动的控制逻辑和俯仰角类似,只是在随机模式里增加一个轴的随机变量而已,难度不大。
从我个人实际使用的体验来说,这台发球机最大的价值不是省了买发球机的钱,而是让我对机电一体化的整个流程有了完整的理解。从机械尺寸的验证,到电机选型的功率计算,再到Arduino的控制逻辑设计和参数调试,每一步都踩过坑、填过坑。如果你照着这篇文章做下来,遇到问题还能自己排查解决,那这个项目带给你的收获绝对远超一台成品发球机。