✔零知派(零知开源)是一个专为电子初学者/电子兴趣爱好者设计的开源软硬件平台,在硬件上提供超高性价比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 摇杆模块原理)
项目概述
本项目以零知派标准板 (主控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