零知派——STM32驱动摇杆+PCA9685实现4路360°舵机线性速度控制与自动演示

✔零知派(零知开源)是一个专为电子初学者/电子兴趣爱好者设计的开源软硬件平台,在硬件上提供超高性价比STM32系列开发板、物联网控制板。

​​​​​​​取消Bootloader程序烧录,让开发重心从 "配置环境" 转移到 "创意实现",极大降低了技术门槛。零知开源编程软件,内置上千个覆盖多场景的示例代码,支持项目源码一键下载,项目文章在线浏览。零知派(零知开源)平台通过软硬件协同创新,让你的创意快速转化为实物,来动手试试吧!

目录

一、系统接线部分

[1.1 硬件清单](#1.1 硬件清单)

[1.2 接线方案表](#1.2 接线方案表)

[1.3 接线示意图](#1.3 接线示意图)

[1.4 具体接线图](#1.4 具体接线图)

二、安装与使用教程

三、代码讲解部分

[3.1 Joystick自动校准与归一化](#3.1 Joystick自动校准与归一化)

[3.2 PCA9685驱动与演示模式](#3.2 PCA9685驱动与演示模式)

[3.3 按键长按/短按检测](#3.3 按键长按/短按检测)

[3.4 Display准星极坐标绘制](#3.4 Display准星极坐标绘制)

[3.5 主循环调度](#3.5 主循环调度)

四、项目结果演示

[4.1 操作流程](#4.1 操作流程)

[4.2 视频演示](#4.2 视频演示)

五、技术原理讲解

[5.1 PCA9685工作原理](#5.1 PCA9685工作原理)

[5.2 摇杆模块原理](#5.2 摇杆模块原理)

六、常见问题解答(FAQ)

Q1:摇杆校准完成后,中点附近舵机还是微微转动,怎么调?

Q2:能接超过4路舵机吗?


项目概述

本项目以零知派标准板 (主控STM32F103RBT6)为核心,通过HW-504双轴摇杆 模块配合PCA9685 16路PWM驱动芯片,实现对4路360°连续旋转舵机的精准线性速度控制。摇杆X轴归一化后线性映射舵机脉宽,长按SW键进入/退出自动演示模式,无操作超过10秒自动进入演示模式

项目难点及解决方案

问题描述:ADC中点偏移导致的四象限不对称

**解决方案:**上电100次采样自动校准中点,并在归一化时对每个方向单独计算最大偏移量

cpp 复制代码
// Joystick.cpp - _normalize()核心逻辑
float maxDx = (dx >= 0)
  ? (float)(JOY_ADC_MAX - _centerX)   // 向右:4095 - 校准中点
  : (float)(_centerX - JOY_ADC_MIN);  // 向左:校准中点 - 0

一、系统接线部分

1.1 硬件清单

序号 模块/器件 型号/规格 数量 备注
1 零知派标准板 STM32F103RBT6 1 主控,类Arduino UNO引脚布局
2 PWM舵机驱动模块 PCA9685 16路 1 I2C地址0x40(默认)
3 双轴摇杆模块 HW-504 1 带SW按键,5V供电
4 彩色显示屏 ST7789 240×240 SPI 1 1.3寸,已适配Adafruit库
5 360°连续旋转舵机 MG996R/SG90连续版 1~4 4路360°舵机
6 舵机电源 5~6V/3A以上 1 舵机独立供电,禁用板载5V给多路舵机
7 杜邦线 公对母/公对公 若干

1.2 接线方案表

下方的引脚分配严格按照代码 config.h 中的定义,以本项目实际使用的零知派标准板引脚为准。接线时务必核对以下对应关系!

模块 模块引脚 零知派引脚 说明
HW-504摇杆 VCC 5V 摇杆电源
HW-504摇杆 GND GND
HW-504摇杆 VRX A0 X轴,config.h: JOY_X A0
HW-504摇杆 VRY A1 Y轴,config.h: JOY_Y A1
HW-504摇杆 SW D8 按键,config.h: JOY_SW 8
PCA9685 VCC 3.3V 芯片逻辑电源
PCA9685 GND GND
PCA9685 SCL A5 SoftWire时钟
PCA9685 SDA A4 SoftWire数据
PCA9685 V+ 舵机独立电源5~6V 舵机动力电源(不接板载5V)
PCA9685 舵机CH0 接舵机0 SERVO_CH_MAP[0]=0
PCA9685 舵机CH4 接舵机1 SERVO_CH_MAP[1]=4
PCA9685 舵机CH8 接舵机2 SERVO_CH_MAP[2]=8
PCA9685 舵机CH12 接舵机3 SERVO_CH_MAP[3]=12

ST7789显示屏直插零知派标准板,无需单独接线

1.3 接线示意图

PCA9685的V+舵机电源引脚必须接独立电源,不能接零知派板的5V引脚------避免多路舵机产生1~2A启动电流

1.4 具体接线图

所有模块的GND和零知派GND需要共地连接

  • 上电顺序:先上外接电源,再给零知派标准板上电,避免PCA9685启动异常
  • 舵机线色:红色-V+,黑色/棕色-GND,橙色/白色-信号线

二、安装与使用教程

2.1 开源平台-输入"PCA9685" 并搜索-下载代码自动打开

2.2 连接-验证-上传

2.3 调试-串口监视器

三、代码讲解部分

项目代码结构:ServoJoy_STM32主程序、config全局配置、Joystick摇杆模块、Servo360舵机控制、Display显示模块

3.1 Joystick自动校准与归一化

各方向独立分母后,每个方向都精确到1.0,四象限完全对称,准星才能铺满整个圆形区域

cpp 复制代码
void Joystick::_calibrate() {
  long sumX = 0, sumY = 0;
  for (int i = 0; i < JOY_CALIB_SAMPLES; i++) {  // 100次采样
    sumX += analogRead(JOY_X);
    sumY += analogRead(JOY_Y);
    delay(5);                      // 每次间隔5ms,避免ADC连续采样噪声叠加
  }
  _centerX = (int)(sumX / JOY_CALIB_SAMPLES);   // 均值作为中点
  _centerY = (int)(sumY / JOY_CALIB_SAMPLES);
  _d.centerX = _centerX;  // 写入JoyData,供Display.cpp准星绘制使用
  _d.centerY = _centerY;
}

void Joystick::_normalize(float dx, float dy, float &ratioX, float &ratioY) const {
  // dx>0表示向右偏,向右最大偏移 = 4095 - 校准中点(约1095)
  // dx<0表示向左偏,向左最大偏移 = 校准中点 - 0(约3000)
  float maxDx = (dx >= 0)
    ? (float)(JOY_ADC_MAX - _centerX)
    : (float)(_centerX - JOY_ADC_MIN);
  float maxDy = (dy >= 0)
    ? (float)(JOY_ADC_MAX - _centerY)
    : (float)(_centerY - JOY_ADC_MIN);
  if (maxDx < 1.0f) maxDx = 1.0f;   // 防止除零
  if (maxDy < 1.0f) maxDy = 1.0f;
  ratioX = dx / maxDx;   // 结果范围 -1.0 ~ +1.0,四方向完全对称
  ratioY = dy / maxDy;
}

uint16_t Joystick::toServoUs() const {
  if (_d.inDeadZone) return SERVO_STOP_US;  // 死区内停转
  float r = _d.ratioX;    // 取X轴归一化分量 (-1..+1)
  if (r >= 0) {
    // 正方向(向右):线性从1500us到2500us(CCW方向,越右越快)
    return (uint16_t)(SERVO_STOP_US + r * (SERVO_CCW_MAX_US - SERVO_STOP_US));
  } else {
    // 负方向(向左):线性从1500us到500us(CW方向,越左越快)
    // r为负值,所以结果是1500 - |r| * 1000
    return (uint16_t)(SERVO_STOP_US + r * (SERVO_STOP_US - SERVO_CW_MAX_US));
  }
}

脉宽映射对照表:

ratioX 脉宽(μs) 舵机状态
0(死区内) 1590(根据实际调整) 停转
+0.3 1800 CCW慢速
+1.0 2500 CCW最快
-0.3 1200 CW慢速
-1.0 500 CW最快

3.2 PCA9685驱动与演示模式

完全非阻塞,updateDemo()每次loop只做一个时间判断,通过applyState()只在步骤切换时发送一次I2C写入

cpp 复制代码
// 演示步骤序列
const Servo360Ctrl::DemoStep Servo360Ctrl::_demoSequence[] = {
  { 2000, { S_CW_SLOW,  S_STOP,     S_STOP,     S_STOP } }, // 步骤0: CH0慢速正转2s
  { 2000, { S_CW_FAST,  S_STOP,     S_STOP,     S_STOP } }, // 步骤1: CH0快速正转2s
  { 1500, { S_STOP,     S_STOP,     S_STOP,     S_STOP } }, // 步骤2: 全停1.5s
  { 2000, { S_CCW_SLOW, S_STOP,     S_STOP,     S_STOP } }, // 步骤3: CH0慢速反转2s
  { 2000, { S_CCW_FAST, S_STOP,     S_STOP,     S_STOP } }, // 步骤4: CH0快速反转2s
  { 1500, { S_STOP,     S_STOP,     S_STOP,     S_STOP } }, // 步骤5: 全停1.5s
  { 2000, { S_STOP,     S_CW_SLOW,  S_CCW_SLOW, S_STOP } }, // 步骤6: 双通道协同
  { 2000, { S_STOP,     S_CCW_FAST, S_CW_FAST,  S_STOP } }, // 步骤7: 双通道快速对转
  { 1500, { S_STOP,     S_STOP,     S_STOP,     S_STOP } }  // 步骤8: 全停循环
};

// 非阻塞状态机更新
void Servo360Ctrl::updateDemo(uint32_t nowMs) {
  if (!_demoMode) return;
  // 检查当前步骤是否已超时
  if (nowMs - _demoLastStepTime >= _demoSequence[_demoStepIndex].durationMs) {
    _demoStepIndex++;
    if (_demoStepIndex >= DEMO_STEPS) {
      _demoStepIndex = 0;  // 循环
    }
    _applyDemoStep(_demoStepIndex);
    _demoLastStepTime = nowMs;
  }
}

// 演示模式隔离
void Servo360Ctrl::runJoy(uint16_t usPolar) {
  if (_demoMode) return;  // 演示模式屏蔽摇杆

  servo[ctrlTarget].state = S_JOY;
  servo[ctrlTarget].stateStr = "JOY    ";
  setUs(ctrlTarget, usPolar);

  // 非选中通道强制停止(保证不误转)
  for (uint8_t i = 0; i < SERVO_CH_COUNT; i++) {
...  Serial.print("[SWITCH] -> S"); Serial.print(ctrlTarget);
  Serial.print(" (PCA CH"); Serial.print(SERVO_CH_MAP[ctrlTarget]);
  Serial.println(")");
}

每个DemoStep包含:持续时间(ms)+ 4路舵机的目标状态,结构体清晰嵌套

3.3 按键长按/短按检测

g_buttonLongPressFlag的作用是防止长按触发后、松手时再次触发短按逻辑

cpp 复制代码
void checkButtonLongPress(JoyData &joy) {
  static bool lastPress = false;

  if (joy.btnPress && !lastPress) {
    // ① 刚按下:记录时间,清除长按标志
    g_lastButtonPressTime = millis();
    g_buttonLongPressFlag = false;
  }
  else if (joy.btnPress && !g_buttonLongPressFlag) {
    // ② 持续按下且未触发过长按:检查是否超过阈值
    if (millis() - g_lastButtonPressTime >= LONG_PRESS_MS) {  // 默认1000ms
      g_buttonLongPressFlag = true;   // 置位,防止重复触发
      // 切换演示模式
      ServoCtrl.isDemoMode() ? ServoCtrl.stopDemoMode() : ServoCtrl.startDemoMode();
    }
  }
  else if (!joy.btnPress && lastPress && !g_buttonLongPressFlag) {
    // ③ 已释放 且 未触发过长按 = 短按
    if (!ServoCtrl.isDemoMode()) ServoCtrl.switchTarget();
  }

  lastPress = joy.btnPress;
}

状态机时序:

  • 按下→─────────────────────── 1000ms ──────────────────────→ 继续按

①记录时间 ②长按触发,切换演示模式

  • 按下→释放(<1000ms)

①记录时间 ③短按触发,切换通道

3.4 Display准星极坐标绘制

joy.r和joy.ratioX/Y直接来自归一化计算结果、与舵机映射完全一致,准星和实际控制完全同步

cpp 复制代码
void Display::_drawCrossHair(const JoyData& joy) {
  // 1. 擦除上次红点(用背景色圆形覆盖)
  _tft.fillCircle(_lastDotX, _lastDotY, DOT_R + 1, C_PANEL);
  // 2. 重绘被擦除的准星线段(局部刷新方式,避免全屏刷新的闪烁)
  _tft.drawCircle(CX, CY_CROSS, CR, C_CROSS);
  _tft.drawCircle(CX, CY_CROSS, CR / 3, C_CROSS);
  _tft.drawFastHLine(CX - CR, CY_CROSS, CR * 2, C_CROSS);
  _tft.drawFastVLine(CX, CY_CROSS - CR, CR * 2, C_CROSS);

  int dotX = CX, dotY = CY_CROSS;   // 默认在圆心(死区状态)
  if (!joy.inDeadZone) {
    float maxPix = (float)(CR - DOT_R - 1);  // 红点中心最大偏移像素
    float pixelR = joy.r * maxPix;            // joy.r是归一化极径(0~1)
    float angleRad = atan2f(joy.ratioY, joy.ratioX);  // 用归一化分量算角度
    dotX = CX + (int)(pixelR * cosf(angleRad));
    dotY = CY_CROSS + (int)(pixelR * sinf(angleRad));
    // 安全夹紧(极坐标计算误差保护)
    float ddx = (float)(dotX - CX), ddy = (float)(dotY - CY_CROSS);
    float dd = sqrtf(ddx*ddx + ddy*ddy);
    if (dd > maxPix) {
      dotX = CX + (int)(ddx / dd * maxPix);
      dotY = CY_CROSS + (int)(ddy / dd * maxPix);
    }
  }
  _lastDotX = dotX; _lastDotY = dotY;
  // 死区内灰色,死区外红色
  _tft.fillCircle(_lastDotX, _lastDotY, DOT_R,
                  joy.inDeadZone ? C_LABEL : C_DOT);
}

局部刷新策略

不调用fillScreen(),只在红点移动时:①擦旧点→②补准星线→③画新点,每帧仅写入极少量像素,彻底消除屏幕闪烁。

3.5 主循环调度

主循环每秒约读20-30次摇杆(非阻塞延时10ms),100ms刷新一次显示屏。演示模式和摇杆控制互斥运行,状态机清晰

cpp 复制代码
void loop() {
  uint32_t now = millis();

  Joy.update();                      // ① 读取摇杆(约0.5ms)
  JoyData joy = Joy.getData();

  checkButtonLongPress(joy);         // ② 按键长/短按检测

  // ③ 无操作自动演示(摇杆在死区 且 超过10秒未操作)
  if (!joy.inDeadZone || joy.btnPress) g_lastActiveTime = now;
  if (!ServoCtrl.isDemoMode()
      && (now - g_lastActiveTime > DEMO_AUTO_START_SEC * 1000UL)
      && (now > 5000)) {             // 防止上电5s内误触发
    ServoCtrl.startDemoMode();
  }

  // ④ 舵机控制(手动/演示二选一)
  if (!ServoCtrl.isDemoMode()) {
    ServoCtrl.runJoy(Joy.toServoUs());  // 线性速度映射
  } else {
    ServoCtrl.updateDemo(now);          // 演示状态机
  }

  // ⑤ 显示刷新(100ms周期,不阻塞舵机控制)
  if (now - g_lastDisp >= DISPLAY_INTERVAL_MS) {
    g_lastDisp = now;
    Disp.update(ServoCtrl.servo, ServoCtrl.ctrlTarget, joy, ServoCtrl.isDemoMode());
  }

  serialCommand();   // ⑥ 串口命令响应
  delay(10);         // ⑦ 主循环10ms节拍
}

系统流程图

PCA9685 核心API

cpp 复制代码
// 1. 实例化(传入SoftWire全局实例Wire,地址B000000=0x40,速度100kHz)
PCA9685 _pwm(B000000, Wire, 100000);

// 2. 初始化序列(缺一不可)
_pwm.resetDevices();      // 发送软件复位(广播地址0x00,0x06命令)
_pwm.init(
  PCA9685_OutputDriverMode_TotemPole,    // 推挽输出(舵机信号线需要推挽)
  PCA9685_OutputEnabledMode_Normal,      // OE低电平使能输出
  PCA9685_OutputDisabledMode_Low,        // OE高时输出拉低(安全状态)
  PCA9685_ChannelUpdateMode_AfterStop,   // STOP信号后更新(标准I2C时序)
  PCA9685_PhaseBalancer_None             // 不做相位平衡(舵机不需要)
);
_pwm.setPWMFreqServo();   // 设置50Hz(舵机标准频率,内部自动计算预分频值)

// 3. 写入PWM(核心控制接口)
_pwm.setChannelPWM(physCh, tick);
// physCh: 物理通道号(本项目用0/4/8/12,间隔4个引脚接舵机)
// tick: 0~4095,对应ON时刻到OFF时刻的计数值(固定ON=0)

脉宽到tick的换算

cpp 复制代码
uint16_t _usToPWM(uint16_t us) {
    // 50Hz周期 = 20ms = 20000μs,分辨率4096步
    // tick = us / 20000 * 4096
    return (uint16_t)((uint32_t)us * 4096 / 20000);
}
脉宽μs tick值 说明
500 102 CW最快
1350 276 CW慢速
1590 307 停转
1650 337 CCW慢速
2500 512 CCW最快

四、项目结果演示

4.1 操作流程

上电校准阶段

保持摇杆在中心位置不动,系统自动采集100次ADC值计算中点,串口打印校准结果,显示屏进入主界面

手动控制模式

操作 效果 显示屏变化
摇杆向右推 选中通道CCW方向旋转,越快越远 色条向右延伸,准星红点向右移动
摇杆向左推 选中通道CW方向旋转,越快越远 色条向左延伸,准星红点向左移动
摇杆归中 立即停转 色条回中,准星居中灰色
短按SW 切换选中通道(S0→S1→S2→S3→S0) 标题栏通道标签切换颜色和编号
长按SW(>1s) 进入演示模式 左上角从"MANUAL"切换为绿色"DEMO"

演示模式

系统自动按序展示:CH0慢速正转→快速正转→全停→慢速反转→快速反转→全停→双通道协同对转→双通道快速对转→全停,循环进行

期间摇杆和短按均无效,长按SW退出回手动模式。无手动操作10秒后自动进入演示模式

4.2 视频演示

STM32驱动PCA9685控制4路360°舵机 摇杆线性速度控制+演示模式全功能演示​​​​​​​

视频展示基于零知派标准板(STM32F103RBT6)的ServoJoy多路舵机控制系统完整功能演示。上电自动摇杆校准过程、手动模式下摇杆控制舵机线性速度、短按切换4路舵机控制目标、长按进入自动演示模式(9步循环展示正反转快慢速及多通道协同)、ST7789彩屏实时显示状态与准星追踪、无操作10秒自动演示模式触发

五、技术原理讲解

5.1 PCA9685工作原理

PCA9685是一款16通道12位分辨率PWM扩展芯片,专门为驱动多路舵机或多路LED

① 内部振荡器与PWM计数器

PCA9685内置一个25MHz振荡器,通过MODE1寄存器的SLEEP位控制休眠。PWM频率由PRESCALE寄存器设定

每个通道有4个字节寄存器(LED_ON_L/H,LED_OFF_L/H)

描述该通道PWM信号在4096步时钟内的ON和OFF时刻。本项目固定ON=0,只改变OFF值(即tick),舵机脉宽 = tick/4096 × 20ms

prescale = round(25,000,000 / (4096 × 50)) - 1 = 121

② I2C总线通信

I2C通信帧结构

PCA9685的通道寄存器地址 = 0x06 + 通道号 × 4

本项目写入时自动递增,AUTO INCREMENT模式,MODE1的AI位=1

地址扩展

默认I2C地址为0x40,硬件引脚A0~A5用于配置地址偏移,因此一条I2C总线上最多可挂载62块PCA9685模块,每个模块提供16个PWM输出通道,一个STM32即可控制992路舵机

|-----------|-----|-----|-----|-----|-----|-----|-------|
| 位7 | 位6 | 位5 | 位4 | 位3 | 位2 | 位1 | 位0 |
| 1 | A5 | A4 | A3 | A2 | A1 | A0 | R/W |
| 固定值 " 1 " | 可定义 | 可定义 | 可定义 | 可定义 | 可定义 | 可定义 | 读/写控制 |

舵机控制频率与PWM编码

舵机控制需要50Hz固定频率PWM信号(周期=20000μs),在周期内高电平时间(脉宽)决定舵机的角度/速度

ratioX∈[-1, +1] → 脉宽∈[500,2500] → 舵机转速从最大CW渐变到停止再到最大CCW,连续无级

5.2 摇杆模块原理

HW-504摇杆模块由两个独立10kΩ电位器(X/Y轴)+一个按键开关(Z轴)构成,使用STM32的ADC采集各轴电压变化判断位置

X轴原理:摇杆从最左到最右移动时,X轴电位器的滑动端输出0V→5V连续电压。STM32的12位ADC将电压0-5V量化为0-4095数值。Y轴同理

六、常见问题解答(FAQ)

Q1:摇杆校准完成后,中点附近舵机还是微微转动,怎么调?

A:适当增大JOY_DEAD死区值(config.h),从150增加到200~250试试。如果舵机本身1500μs停转不准确,调整SERVO_STOP_US定义值(±10~30μs微调)

Q2:能接超过4路舵机吗?

A:可以。修改config.h中的SERVO_CH_COUNT和SERVO_CH_MAP数组,PCA9685最多支持16路,增加通道后Display.cpp的行高ROW_H需相应减小(240px高度÷路数)

项目资源整合

PCA9685数据手册: PCA9685.pdf

PCA9685 库: NachtRaveVL/PCA9685

相关推荐
不脱发的程序猿2 小时前
嵌入式软件工程师,怎么把 AI 工具用顺手?
人工智能·单片机·嵌入式硬件·嵌入式
平凡灵感码头3 小时前
芯片合封是个嘛?
单片机·嵌入式硬件
gscsded5 小时前
C2000 GPIO 配置笔记
单片机
Sakuyu434685 小时前
STM32基础
stm32·单片机·嵌入式硬件
桑榆肖物5 小时前
ImprovWifi 跨平台传输层设计:把协议层做薄,把宿主层做稳
嵌入式硬件·wifi·.net·ble
gscsded5 小时前
C2000 CPU Timer 学习笔记
单片机
iCxhust7 小时前
AD0808调试笔记
笔记·单片机·嵌入式硬件·操作系统·微机原理·8088单板机
木子单片机7 小时前
基于51单片机的步进电机调速系统设计
单片机·嵌入式硬件·51单片机·keil
三易串口屏8 小时前
实验10 物理按键实验
单片机·51单片机·串口屏·串口协议·uart 通信·嵌入式 ui