简介:这是一套基于STM32F103C8T6与PCA9685的16路舵机驱动工程源码,面向需要同时控制多路舵机的机器人、机械臂、无人机等嵌入式项目开发者。资源以Keil工程形式提供,共196个文件、压缩包大小约6.93MB,包含uvprojx/uvoptx工程文件、C/H源码、编译生成的o/axf/hex以及辅助编译的bat脚本,覆盖从驱动编写到固件生成的完整流程。已有1185人浏览学习,适合作为多路PWM扩展驱动的参考模板。代码实现STM32通过I2C总线对PCA9685的完整控制,包括I2C初始化、预分频器与模式寄存器配置、16通道独立占空比设置,并封装了便于调用的函数接口;工程按Hardware/User/System等目录组织,便于快速定位与移植,可直接用于搭建16路舵机控制系统,也可为学习STM32底层驱动和I2C通信提供实例参考。
1. 为什么多路舵机方案绕不开PCA9685:从定时器困境到外挂芯片
STM32F103C8T6这颗芯片在低成本电子项目里的出镜率实在太高了,淘宝上一块“最小系统板”十块钱以内就能拿下,Flash 64KB、RAM 20KB,跑裸机或者轻量级FreeRTOS都没压力。但真要让它直接去控制多路舵机,问题马上就会出现:舵机控制所需的PWM信号标准频率是50Hz(周期20ms),脉宽一般在0.5ms到2.5ms之间,虽然数字舵机支持更高的刷新率,但大部分常规模拟舵机按50Hz走才是安全方案。
表面上看,STM32F103C8T6有TIM1、TIM2、TIM3、TIM4四个定时器,每个定时器4个通道,加起来最多16路PWM输出,好像刚好能满足需求。但这个账算得太乐观了。TIM1是高级定时器,除了基本的PWM输出外还带互补输出、死区插入、刹车输入等功能,初始化代码比通用定时器繁琐得多,刚上手的人很容易在寄存器配置上绕晕。更要命的是,多路舵机同时运动时,每一路的占空比都在动态变化,如果你用定时器中断去更新比较寄存器,中断频率一高,CPU占用率就上去了。主程序稍微复杂一点,要处理传感器数据、跑OLED显示、做PID运算,就会出现卡顿,甚至导致舵机运动不连贯。
PCA9685这颗芯片就是为了解决这类问题而设计的。它是一颗16通道、12位分辨率的PWM/舵机驱动IC,通过I2C接口与MCU通信。MCU只需要在I2C总线上写几个寄存器,芯片自己就会把16路PWM波形生成完毕,CPU完全从底层PWM产生中解放出来。打个比方,直接让单片机产生16路PWM,相当于你要同时盯着16个水龙头、手动调节每个的水流量;而用PCA9685,你只需要告诉它每个水龙头开多久、什么时候开,剩下的事它自己拧。
芯片内部集成了25MHz振荡器,PWM频率漂移很小,输出稳定。每个通道都有独立的ON/OFF寄存器,可以单独控制占空比和相位,互不干扰。而且模块成熟度极高,市面上绝大多数PCA9685模块都集成了3.3V/5V电平转换、I2C上拉电阻、电源滤波电容,甚至预留了舵机电源接线端子,拿到手接几根杜邦线就能跑。在我做过的太阳能追光云台和仿生机械手掌项目里,16路全开、舵机负载中等的情况下,稳定性和响应速度都让人满意。
当然,如果只是控制一两路舵机,直接用定时器PWM完全够用,没必要外挂芯片。但一旦通道数超过4路,或者你希望保留CPU资源去跑更复杂的算法、跟其他传感器通信,PCA9685就是性价比最高的选择。
1.1 用定时器输出16路PWM的几个现实问题
我最初做仿生机械手时,还真尝试过用TIM1、TIM2、TIM3、TIM4直接输出PWM。四个定时器全部初始化,每路通道对应一个比较寄存器,代码量并不算大。但真正调试时发现两个问题:其一,TIM1的通道配置和互补输出逻辑相比通用定时器复杂不少,一个不留神就出现波形不对或者通道间互相影响;其二,也是更关键的,16路舵机同时运动时,每路脉宽都要动态更新,我在1ms定时中断里挨个更新比较寄存器,中断服务函数越来越长,后来加了编码器读取和滤波算法,直接挤爆了整个中断周期。用示波器看输出波形,脉宽抖动肉眼可见。
还有一个隐蔽的问题是定时器时钟源。F103C8T6的APB1最大36MHz,TIM2、TIM3、TIM4挂在这条总线上,TIM1挂在APB2上,最高72MHz。如果对时钟树理解不透,PSC和ARR配置计算就容易出错,输出频率和脉宽就全飘了。这些问题叠加在一起,让我果断转向了PCA9685方案。
2. 芯片工作机制:寄存器、频率计算和波形生成
PCA9685默认I2C地址是0x40(7位地址),A0到A5六个地址引脚全部接地就是这个地址。如果需要挂多颗芯片,通过组合A0-A5的电平,最多可以在同一条I2C总线上挂62颗芯片,理论上控制996路PWM。单片机只占两根线(SCL和SDA)就能控制这么多舵机,这种扩展性是定时器方案完全比不了的。
芯片内部寄存器布局很清晰,常用的就那几个:
| 寄存器 | 地址 | 作用 |
|---|---|---|
| MODE1 | 0x00 | 工作模式:SLEEP、AI(自动递增)、软件复位 |
| MODE2 | 0x01 | 输出模式:输出逻辑、驱动器类型、OE引脚功能 |
| LED0_ON_L/H | 0x06/0x07 | 通道0的ON时刻计数值(12位) |
| LED0_OFF_L/H | 0x08/0x09 | 通道0的OFF时刻计数值(12位) |
| LED1~LED15 | 0x0A~0x45 | 依次类推,每通道占4个寄存器 |
| PRE_SCALE | 0xFE | PWM频率预分频值 |
理解PCA9685的PWM生成机制有个关键点:它不像普通定时器那样直接写“占空比百分比”,而是给每个通道配置两个时间点——ON时刻和OFF时刻。芯片内部有一个12位计数器,从0数到4095,一个周期内计数值到达ON时,该通道输出变高;到达OFF时,输出变低。所以每路的完整控制就是那四个寄存器共同决定的。
实际使用中,如果需要控制占空比,最常见的做法是把ON设为0,然后只修改OFF值,占空比就是OFF / 4096。例如12位分辨率下要输出50%占空比,就设OFF=2048。这种方法的好处是相位对齐非常自然,所有通道从同一时刻开始计数,多通道之间不会出现相位偏差。
2.1 频率计算和SLEEP位操作顺序
PWM频率的设置是很多人容易翻车的地方。PRE_SCALE寄存器的计算公式是:
pre_scale = round(25000000 / (4096 × f)) - 1
其中25000000是芯片内部25MHz振荡器频率,4096是12位计数步数,f是目标PWM频率。舵机标准频率50Hz,代入公式:
pre_scale = round(25000000 / (4096 × 50)) - 1 = round(122.07) - 1 = 121 = 0x79
这个计算看着简单,但实际操作顺序有一个很容易忽视的坑:写PRE_SCALE之前,必须先把MODE1寄存器的SLEEP位置1让芯片进入睡眠状态,写入PRE_SCALE之后再清除SLEEP位唤醒芯片,最后等待振荡器稳定标志位就绪。如果跳过SLEEP步骤直接写PRE_SCALE,寄存器写入很可能不生效。我第一次调这个的时候就是直接改PRE_SCALE,结果频率怎么都不对,后来逐行翻数据手册才发现这个操作顺序要求。
MODE1还有一个值得用的位是AI(Auto-Increment)。把它置1后,连续读写寄存器时地址会自动递增。初始化16路通道时就可以用循环连续写,不用每路都重新指定目标寄存器地址,效率高很多。调试阶段需要把16路状态全部dump出来时,这个位也能省不少事。
MODE2寄存器主要管输出模式,比如输出逻辑反相还是正相(INVRT位)、输出驱动器是推挽还是开漏(DRV位)、输出是否随OE引脚状态变化。驱动舵机模块时,默认输出配置基本够用,唯一建议改的是把OUTDRV设为推挽模式,保证输出驱动能力足够强。
3. 硬件接线与电源设计:让16路舵机稳定运行的关键
硬件接线表面上很简单:SCL接PB6,SDA接PB7,PCA9685的VCC接3.3V,舵机电源接5V,GND全部共地。但这里有几个细节没处理好,项目会非常不稳定。
第一是逻辑电平确认。STM32F103C8T6的IO口是5V容忍的,但PCA9685的VCC一般接3.3V,如果模块上的SCL/SDA上拉到5V,长时间运行有风险。市面上大多数模块已经做了电平转换或者自带3.3V稳压,但你不确定时最好用万用表量一下模块SDA、SCL引脚对VCC的电压,确认上拉电平。我见过有人把模块VCC接5V、逻辑引脚也按5V上拉处理,结果I2C通信间歇性失败,排查了半天最后才发现是电平不匹配。
第二是舵机电源绝对不能跟单片机共用同一个稳压源。这点必须强调:舵机启动瞬间电流很大,SG90这种9g舵机堵转时电流能到700mA以上,MG996R这种大舵机堵转电流甚至能到2.5A。如果舵机电源和MCU电源混在一起,舵机一动,电压就被拉低,STM32直接复位,表现就是舵机转一下、板子重启一下、再转一下,反复循环。用示波器看3.3V电源轨,上面叠着一堆尖峰毛刺。
正确做法是:STM32用独立的3.3V供电,舵机用5V或6V电源单独供电,两路电源的GND必须连在一起(共地),然后PCA9685的VCC接3.3V,V+引脚接舵机电源5V。很多模块上V+和VCC是两个独立引脚,出厂时用跳线帽短接,除非你确定舵机电流很小,否则不要用跳线帽短接,坚持分开供电。
第三是地线处理。我做过一个16路舵机的机械手掌,刚开始所有舵机共用一个地线端子,结果单片机偶尔死机。排查后我给每个舵机都单独走了一根地线到电源负极,并在舵机电源端子处并联470uF电解电容,PCA9685模块的V+和GND之间再并联一个100uF电容。处理后,16路舵机同时动作时电源电压跌落从原来的1.2V降到了0.3V以内,系统再没出现复位。这个方案其实就是很多商用舵机控制板的标准设计,自己搭项目时提前把这一步做对,能省掉后面大量排查时间。
3.1 OE引脚的工程意义
很多模块把OE引脚(Output Enable)引出来了,有的则直接接地。OE是低电平有效:拉低时PWM输出使能,拉高时输出高阻。如果你希望系统启动时让舵机先保持静默,可以把OE接到STM32的一个GPIO,程序初始化完成、所有通道脉宽都设置成中位(比如1.5ms)之后,再拉低OE使能输出。这在仿生机器人项目里特别有用,否则每次上电舵机都会抖一下,长期下来对舵机齿轮磨损很大。
4. HAL库驱动代码:初始化、写脉宽、角度映射
开发环境推荐STM32CubeIDE配合HAL库,调试体验比标准库好不少。PCA9685是I2C从机,通信协议不复杂,用HAL的阻塞式接口就足够了,不需要上DMA或中断。原因很简单:I2C总线速率一般设100kHz或400kHz,每帧数据也就几个字节,几百微秒就传输完成,初始化完成后主循环很少再去写寄存器,阻塞式调用不会影响整体性能。
初始化I2C时要注意外设时钟。I2C1挂在APB1总线上,STM32F103C8T6的APB1最大36MHz,如果系统时钟配置成72MHz,I2C时钟源就是36MHz,CubeMX会自动计算好。如果你用标准库,要自己检查RCC配置是否正确。很多人出现I2C通信失败,往往就是这个时钟配置问题,而不是代码本身的问题。
PCA9685的初始化序列和写脉宽核心代码:
#define PCA9685_ADDR 0x40 // 向PCA9685写入寄存器 void PCA9685_WriteReg(uint8_t reg, uint8_t value) { uint8_t buf[2] = {reg, value}; HAL_I2C_Master_Transmit(&hi2c1, PCA9685_ADDR << 1, buf, 2, 100); } // 初始化PCA9685并设置PWM频率 void PCA9685_Init(uint16_t freq_hz) { // 1. 软件复位(向ALL CALL地址0x00发送0x06) uint8_t reset = 0x06; HAL_I2C_Master_Transmit(&hi2c1, 0x00, &reset, 1, 100); // 2. MODE1: SLEEP=1, AI=1,进入睡眠准备写频率 PCA9685_WriteReg(0x00, 0x11); // 3. 计算并写入PRE_SCALE uint8_t prescale = (uint8_t)(25000000.0 / (4096.0 * freq_hz) + 0.5) - 1; PCA9685_WriteReg(0xFE, prescale); // 4. 唤醒: 清除SLEEP位, MODE1 = 0x01 (AI=1) PCA9685_WriteReg(0x00, 0x01); // 5. 等待振荡器稳定 HAL_Delay(1); // 6. MODE2: OUTDRV=1 (推挽输出) PCA9685_WriteReg(0x01, 0x04); } // 设置某通道的ON/OFF计数值 void PCA9685_SetPWM(uint8_t channel, uint16_t on, uint16_t off) { uint8_t buf[5]; buf[0] = 0x06 + channel * 4; // LEDn_ON_L地址 buf[1] = on & 0xFF; buf[2] = (on >> 8) & 0x0F; buf[3] = off & 0xFF; buf[4] = (off >> 8) & 0x0F; HAL_I2C_Master_Transmit(&hi2c1, PCA9685_ADDR << 1, buf, 5, 100); }这段代码有几个细节需要解释。软件复位是向地址0x00(ALL CALL地址)发送0x06来触发的,它能把所有PCA9685设备恢复到默认状态,确保后续配置干净。写PRE_SCALE之前必须先把SLEEP位置1,这一步做了之后芯片停止PWM输出,才能在低功耗状态下修改频率寄存器。唤醒后延时1ms是为了等内部25MHz振荡器稳定。
写脉宽时,ON设为0,OFF设为脉宽对应的计数值。这样每个周期从0开始输出高电平,到OFF时刻变低,就是标准的PWM波形。12位值拆成两个字节发送时,高字节只用低4位(0x0F掩码),因为12位分辨率上限是4095,高字节最多到0x0F。
4.1 角度映射与舵机校准
把舵机角度转为OFF计数值的函数:
// 设置舵机角度 // channel: 0~15 // angle: 0~180度 // min_us/max_us: 舵机实测最小/最大脉宽 void PCA9685_SetServoAngle(uint8_t channel, float angle, float min_us, float max_us) { // 确保角度在0~180范围内 if (angle < 0) angle = 0; if (angle > 180) angle = 180; // 线性映射角度到脉宽 float pulse_us = min_us + (max_us - min_us) * angle / 180.0f; // 脉宽转计数值:50Hz下20ms周期对应4096步 uint16_t off = (uint16_t)(pulse_us * 4096.0f / 20000.0f); PCA9685_SetPWM(channel, 0, off); }这里的关键是min_us和max_us不要用舵机数据手册上的标称值,而要用实测值。我习惯每拿到一个舵机,先不装进结构里,裸舵机接上PCA9685,写一个测试环让舵机从最小脉宽逐步增加到最大脉宽,每100us停一步,观察哪个脉宽对应0度、哪个对应180度。比如某国产SG90实测下来0度是520us、180度是2480us,跟标称值500~2400差得不多;但有一次我买的MG996R金属齿舵机,标称500~2500,实测0度在620us、180度在2320us,差别就很大了。如果不校准就直接按标称值做线性映射,舵机在中位附近可能偏了10度以上,这对机械手抓取这种精度要求高的项目是无法接受的。
还要注意舵机刷新频率的问题。标准模拟舵机用50Hz,但很多数字舵机支持200Hz甚至333Hz,刷新频率越高舵机响应越快、保持力越好,功耗也越高。PCA9685改PRE_SCALE寄存器就能调整频率,比定时器方案方便得多。不过建议先在50Hz下把舵机跑稳定了,再试高频刷新,否则调试时变量太多,出了问题很难定位。
5. 踩坑实录:五个让人抓狂的问题和排查思路
这部分写我在实际项目里遇到过的典型问题和最终排查方案,希望能帮你少走弯路。
5.1 舵机一动,单片机就重启
这是新手最常见的问题,我早期也因为这个卡了一整个晚上。排查步骤如下:用万用表直流档监测STM32的3.3V引脚和舵机电源5V,舵机空载转动时两个电压都正常;但用手轻轻按住舵机输出臂模拟堵转,5V电压瞬间掉到3.8V,3.3V跟着掉到2.9V,单片机随即复位。根因是舵机电源和MCU电源共用了同一个5V转3.3V的LDO,舵机堵转电流把输入电压拉垮了。解决办法就是分电源供电,另外在舵机电源入口处加大电容。
这个现象有个迷惑性:舵机空载时一切正常,只有负载增加时才出问题,很容易误判为程序bug或芯片故障。实际上一旦发现“系统跟着舵机负载走”的规律,基本就能锁定是电源问题。
5.2 I2C通信偶尔失败,读寄存器返回0xFF
这个问题的本质是总线时序被干扰。我遇到过两种情况:一种是杜邦线太长,超过20cm,在400kHz速率下信号质量变差,把I2C频率降到100kHz就稳定了;另一种是舵机电源线和I2C线平行走了一段,舵机转动时产生的电磁干扰串到I2C线上。解决办法是让I2C线远离电源线,或者把SCL和SDA两根线绞在一起减小环路面积。
排查这类问题有个实用技巧:在HAL_I2C_Master_Transmit的返回值处加打印,结合逻辑分析仪抓I2C波形。如果传输函数返回HAL_ERROR,而波形上能看到器件没有产生ACK,基本就是地址错误或设备没上电;如果波形看起来正常但数据内容错了,重点检查时序和电平。
5.3 16路舵机同时动作时,后几路抖得厉害
这个现象很有迷惑性,第一次遇到时我以为是代码bug。后来用示波器同时看第0路和第15路的PWM输出波形,发现第15路的脉宽在抖动,说明PCA9685输出的波形本身不稳定,不是舵机的问题。进一步排查发现是稳压电源输出电流不够,16个舵机同时启动的瞬间总电流超过电源上限,电压被拉下来。换成了5V 10A开关电源后问题消失。如果你用舵机数量多,电源余量一定要留足,建议至少5V 5A起步,舵机越多电源越大。
后来我学到的经验是:多路舵机同时启动时,由于所有通道在同一时刻开始输出高电平,瞬时电流叠加非常明显。如果应用场景允许,可以错开各通道的启动时间,例如每路间隔20ms启动,能有效降低电流峰值。
5.4 上电瞬间舵机猛转一下
几乎所有用PCA9685的板上都有这种现象,不完全是bug,但很影响使用体验。原因是PCA9685和STM32上电时序不一致:如果舵机电源先于控制信号稳定,舵机就会在PCA9685默认输出状态下动作。默认状态下通道寄存器都是0,输出波形是低电平,但很多舵机对低电平持续时间长会产生误判。更常见的情况是STM32还没完成初始化,PB6/PB7引脚处于浮空输入状态,I2C总线电平不稳定,PCA9685收到一些随机数据。
解决办法就是用OE引脚做输出使能控制:启动时让OE保持高电平,PCA9685输出为高阻态,舵机接收不到有效PWM信号;等程序初始化完成、所有通道脉宽设置成中位后,再把OE拉低。这样舵机上电只会轻微响一声,不会猛转。如果你用的模块没有引出OE引脚,可以在舵机电源串一个MOS管开关,程序启动后再给舵机供电,效果类似。
5.5 快速定位问题的工作流
排查PCA9685相关问题时,我常用的流程是:
- 先用i2cdetect类似的I2C扫描,确认设备是否在0x40地址正确应答。STM32上可以写一个简单扫描程序,逐个地址发送读请求,看哪个地址有ACK。
- 用逻辑分析仪看波形,重点确认SCL/SDA的幅值、时序和ACK位是否符合I2C协议。
- 用示波器看PCA9685输出引脚的PWM波形,先确认芯片侧波形正确,再往前查舵机和供电。
- 最后才怀疑代码逻辑。很多问题其实是硬件和电源层面的,代码只是背锅侠。
6. 从单板到系统:级联、RTOS集成与国产替代
16路舵机不够用怎么办?级联多个PCA9685是最干净的方案。A0-A5六根地址引脚通过跳线或者GPIO配置成不同电平,就能把多个模块设置成不同I2C地址,比如第一块0x40、第二块0x41、第三块0x42。STM32只靠两根线就能控制几十路舵机,在蜘蛛机器人、六足机器人这类需要大量关节控制的项目里是绝对优势。
如果项目跑FreeRTOS,建议把舵机控制封装成一个独立任务,用队列接收其他任务发来的角度指令,在任务内部统一调用PCA9685_SetServoAngle。关键点是I2C总线要加互斥锁,否则多个任务同时访问I2C会产生总线竞争。我之前做过一个项目,主任务、传感器任务和舵机控制任务共享同一条I2C总线(传感器也是I2C设备),第一版没加锁,跑几分钟就出现一次传感器读取失败,加了互斥锁之后连续跑了一周都很稳定。
国产替代方面,目前市面上已经出现了一些寄存器级兼容PCA9685的国产芯片,引脚定义和寄存器布局基本一致,直接替换不需要改代码,价格比原厂低一些。GD32、MM32这类国产MCU也可以直接跑STM32F103的HAL库工程,只需要调整时钟树配置。如果你在做一个量产的消费级产品,这些国产替代方案能显著降低成本,是值得认真考虑的。
回到我自己的体验:PCA9685方案最大的价值不是“16路”这个数字,而是把CPU从底层波形生成中彻底解放出来。花半天时间把驱动写好、校准表建好,之后不管项目怎么迭代,舵机控制模块都可以当作一个稳定可靠的子系统直接复用。如果你也是刚开始接触多路舵机控制,先别急着堆代码,把供电源和硬件连接搞扎实,这一步做对了,后面能省出好几个晚上的调试时间。
本文还有配套的精品资源,点击获取