简介面向STM32F103ZET6开发者与多舵机控制场景这套PCA9685测试工程直接解决16通道PWM驱动与I²C总线协议集成的常见问题。压缩包共133个文件以C源文件与H头文件为主体同时包含Keil工程配置.uvprojx、编译产物.axf、.hex、.o以及标准外设库中的定时器、复位、ADC等模块整体大小约2.58MB结构紧凑便于导入工程直接参考。已有349人学习下载。资源不仅演示了STM32F103ZET6的I²C初始化与收发数据流程还覆盖了PCA9685预分频寄存器、各通道占空比寄存器的写入方法通过修改占空比即可控制舵机角度或LED亮度。对于无人机、机器人等需要多路PWM独立控制的开发任务该测试工程提供了可复用的代码结构和调试验证思路适合希望深入理解I²C协议与PWM生成原理的嵌入式开发者。1. 先解决多路舵机不够用用STM32F103ZET6把PWM通道扩展到16路当舵机数量超过单片机能直接输出的PWM通道数事情就开始变得麻烦。以STM32F103ZET6为例定时器通道虽然多真正要让16路舵机稳定工作要么抢MCU中断资源要么换主控都不轻松。这套pca9685-test-ZET6工程做的就是更简单的路子用I2C两根线扩展出一片16通道12位PWM芯片把舵机控制全部交给PCA9685MCU只需要每隔几十毫秒更新一次目标角度负担小、逻辑清楚。工程里包含了I2C初始化、PCA9685寄存器操作、频率设定、单路与多路舵机控制封装适用做机器人关节、相机云台、六足或机械臂的开发者新手能直接烧录看舵机扫动老手可以拿它当多路PWM中间层继续往上叠逻辑。2. 工程全局先弄懂PCA9685为什么能用两根线输出16路PWM2.1 从定时器通道到PCA9685为什么需要外扩PWM手头这块ZET6有4个通用定时器每个定时器能出4路PWM理论上16路刚好够但实际做机器人项目时根本挤不出来。编码器要占用定时器、电机调速要占用定时器、超声波测距输入捕获也要占用最后剩下的定时器通道七零八落而且PWM引脚是固定的布线上要绕很远。这就好比手里有16间屋子但每间屋子的位置都定死了人还不能只在固定的几间房活动根本没法住。PCA9685解决的是“把PWM生成这件事从MCU上卸载掉”。这颗芯片内部有一个25MHz振荡器通过分频产生一个基准时钟驱动16个独立的12位计数器做比较输出。每个通道可以单独设置高电平保持到第几个计数点输出推挽或开漏的方波。MCU这边只需要通过I2C写入“哪个时间点变高、哪个时间点变低”两个数字剩下的高精度方波生成完全由芯片自己搞定。对比用定时器DMA去模拟多路舵机信号的做法PCA9685的优势非常明显CPU占用低、逻辑简单、代码量少、多板级联容易。缺点也有就是舵机信号线多了一颗芯片故障排查多一个环节。整体来说做多关节机械臂或者六足机器人用这种方案比堆定时器省心得多。2.2 硬件连接引脚表和解开OE引脚的疑惑拿到这个工程先别急着烧代码连接错了后面全是玄学。我按常见的ZET6核心板加PCA9685模块的方式列一张连接表PCA9685引脚接STM32F103ZET6说明VCC3.3V芯片逻辑电源高过3.6V长期会损伤芯片V5V舵机电源正极只负责给舵机供电不从VCC取电GNDGND必须和MCU共地否则I2C波形解析异常SCLPB6I2C时钟线总线需要4.7kΩ上拉SDAPB7I2C数据线总线需要4.7kΩ上拉OEGND输出使能低有效悬空时16路全部无输出这里最容易让人忽略的就是OE引脚。PCA9685模块上OE通常外接一个排针如果悬空芯片内部输出级是关闭的你配置得再辛苦示波器也看不到波形。翻车现场十有八九是这个引脚没接地。另外V和VCC是两套电源V是舵机电源的汇流排5V还是6V取决于舵机规格别把V接到3.3V上舵机根本没力气。还有一个硬件细节SCL和SDA需要上拉电阻。大多数成品的PCA9685模块板上已经焊了上拉但如果你用的是裸芯片转接板就得自己在总线上加两颗4.7kΩ电阻到3.3V。开漏模式下没人帮它拉高信号根本出不来。2.3 工程目录结构这套测试代码是如何组织的解压这个zip之后工程的目录是典型的标准外设库风格核心文件不多pca9685-test-ZET6/ ├── HARDWARE/ │ ├── pca9685.c │ ├── pca9685.h │ ├── myiic.c │ └── myiic.h ├── SYSTEM/ │ ├── delay/ │ ├── sys/ │ └── usart/ ├── USER/ │ ├── main.c │ └── stm32f10x_it.c └── pca9685-test.uvprojx驱动部分集中在HARDWARE目录里myiic.c是软件模拟I2C底层pca9685.c是在I2C之上的寄存器操作和应用封装main.c里写的是测试流程。这个分层思路对于学习的人来说很好底层时序一个文件芯片驱动一个文件业务逻辑一个文件想改成自己的应用时只需要动main.c不需要碰底层。如果你拿到的版本稍微有些差异比如HARDWARE目录里多了oled.c之类不用慌核心找两个文件就行一个是I2C底层一个是pca9685驱动。剩下无非是串口打印或者显示跟舵机主链路是并行的。3. 核心驱动拆解从I2C底层到寄存器写入的完整链路3.1 软件模拟I2C为什么不用STM32自带的硬件I2C这个工程用的是软件模拟I2C而不是STM32的硬件I2C外设。理由很现实F103的硬件I2C在繁忙标志处理上比较麻烦一旦出错就要清一堆错误标志排查起来周期长很多老项目干脆不上硬件I2C直接用GPIO翻转把时序做出来。PCA9685的通信速率要求不高100kHz、400kHz都行软件模拟完全喂得饱。引脚初始化很简单PB6做SCLPB7做SDA都配成开漏输出void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); }开漏输出是I2C协议的硬性要求总线靠外部上拉电阻才能把电平拉高。如果配成推挽输出虽然单独通信也能工作但总线上多个设备同时驱动时就可能互相打架这在后续挂多个PCA9685或者EEPROM的时候很容易出问题还是规范一点好。有了GPIO之后把电平操作抽成宏#define SCL_H() GPIOB-BSRR GPIO_Pin_6 #define SCL_L() GPIOB-BRR GPIO_Pin_6 #define SDA_H() GPIOB-BSRR GPIO_Pin_7 #define SDA_L() GPIOB-BRR GPIO_Pin_7 #define SDA_READ() ((GPIOB-IDR GPIO_Pin_7) ! 0)直接用BSRR/BRR寄存器置位和复位好处是不用担心读改写被中断打断速度也比调用库函数快。SDA_READ用于从机发ACK时读电平后面发完一个字节要等从机把SDA拉低来确认。3.2 I2C时序起始、停止和发送一字节I2C最关键的两个信号SCL为高期间SDA从高跳变到低是起始信号SCL为高期间SDA从低跳变到高是停止信号。从机靠这两个边沿对齐自己的字节边界写错一个时序整条总线就废了。void IIC_Start(void) { SDA_H(); SCL_H(); delay_us(5); SDA_L(); delay_us(5); SCL_L(); } void IIC_Stop(void) { SDA_L(); SCL_H(); delay_us(5); SDA_H(); delay_us(5); }之所以每条操作后面都加几微秒延时是为了让电平有足够的建立时间。F103主频72MHzGPIO翻转极快不加延时的话信号边沿太陡、保持时间太短接近I2C规范里的极限甚至超限长线缆下特别容易丢ACK。这个延时数值不需要精确2到5微秒都能用关键是每步都要有。发送一个字节时数据在SCL低电平期间变化在SCL高电平期间被从机采样从高位开始逐位移出void IIC_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) SDA_H(); else SDA_L(); data 1; SCL_H(); delay_us(2); SCL_L(); delay_us(2); } }注意每次SCL拉高之前先把SDA放到正确的电平上顺序不能反。SCL拉高之后不要再动SDA否则从机会以为是一个起始/停止信号。发完8位之后主机释放SDA等待从机拉低应答uint8_t IIC_WaitAck(void) { uint8_t timeout 50; SDA_H(); delay_us(1); SCL_H(); delay_us(1); while (SDA_READ() timeout--) { delay_us(1); } SCL_L(); return (timeout 0) ? 1 : 0; }这里加了超时计数是从我踩过的坑里学来的。最早的版本是死循环等ACK一旦从机掉线或者地址写错整个程序就卡死在I2C里连串口输出都看不到。加一个超时返回错误码后续才好排查。3.3 PCA9685寄存器读写地址和字节序PCA9685的I2C地址值得单独说。芯片默认7位地址是0x40A0到A5全部接GND时就是0x40。但在代码里发送时要把7位地址左移一位最低位填上读/写标志所以最终发送的8位地址是0x80。这个转换是新手最容易翻车的地方后面排查章节还会细讲。#define PCA9685_ADDR 0x80 void PCA9685_WriteReg(uint8_t reg, uint8_t data) { IIC_Start(); IIC_SendByte(PCA9685_ADDR); IIC_WaitAck(); IIC_SendByte(reg); IIC_WaitAck(); IIC_SendByte(data); IIC_WaitAck(); IIC_Stop(); } uint8_t PCA9685_ReadReg(uint8_t reg) { uint8_t data; IIC_Start(); IIC_SendByte(PCA9685_ADDR); IIC_WaitAck(); IIC_SendByte(reg); IIC_WaitAck(); IIC_Start(); IIC_SendByte(PCA9685_ADDR | 0x01); IIC_WaitAck(); data IIC_ReceiveByte(); IIC_Stop(); return data; }读取寄存器时用了一个重复起始信号先以写方式发送寄存器地址再以读方式把数据从从机拉出来。这一步如果漏掉重复起始有的从机就等着收下一个字节而不是输出数据读回来的就是0xFF。这套读写函数是整个驱动的地基后面所有配置都建立在它们之上。3.4 初始化与PWM频率SLEEP位和PRE_SCALE的计算PCA9685的频率配置有个特殊流程先把MODE1寄存器置为睡眠模式在睡眠状态下写PRE_SCALE分频值写完再唤醒最后置RESTART位重启PWM输出。顺序搞反频率就写不进去。void PCA9685_SetPWMFreq(uint8_t freq) { uint8_t prescale; uint8_t oldMode; prescale (uint8_t)(25000000.0 / (4096 * freq) 0.5) - 1; oldMode PCA9685_ReadReg(0x00); PCA9685_WriteReg(0x00, oldMode | 0x10); PCA9685_WriteReg(0xFE, prescale); PCA9685_WriteReg(0x00, oldMode); delay_ms(1); PCA9685_WriteReg(0x00, oldMode | 0x80); }pre_scale计算公式出自数据手册25MHz内部时钟除以4096再除以目标频率四舍五入后减1。舵机标准频率50Hz算出来是121写进寄存器0xFE就是0x79。如果算出来的值和代码注释里的对不上多半是忘记减1或者乘除顺序反了。MODE1寄存器0x10是SLEEP位0x80是RESTART位。在SLEEP状态下可以安全地改分频寄存器改完退出SLEEP后还要等内部振荡器稳定最后还要触发一次RESTART让PWM输出按新频率重新跑起来。整个流程不能省省了第一步输出频率就不对省了最后一步输出会停留在上次的状态。初始化函数本身不复杂void PCA9685_Init(void) { PCA9685_WriteReg(0x00, 0x00); PCA9685_WriteReg(0x01, 0x04); PCA9685_SetPWMFreq(50); }MODE1写0x00是正常工作、不睡眠、不自动递增MODE2写0x04是把输出配成推挽模式totem pole这样舵机信号到5V电平才有足够的驱动能力。如果MODE2保持默认的开漏模式输出高电平完全依赖外部上拉接到舵机信号线上就会变得半高不低。4. 舵机控制实战把0到180度映射成可控的PWM脉宽4.1 脉宽计算一个计数约等于4.88微秒PCA9685每个通道输出的是12位精度的PWM一个周期被分成4096个计数。50Hz对应的PWM周期是20毫秒所以一个计数就是20ms/4096约4.88us。标准舵机的控制脉宽是0.5ms到2.5ms对应0度和180度0度对应约102个计数90度对应约307个计数180度对应约512个计数中间任意角度按线性插值计算。这就是把角度转成计数值的全部秘密。#define SERVO_PULSE_MIN 102 // 0度对应0.5ms #define SERVO_PULSE_MAX 512 // 180度对应2.5ms uint16_t PCA9685_AngleToPulse(float angle) { if (angle 0) angle 0; if (angle 180) angle 180; return (uint16_t)(SERVO_PULSE_MIN angle / 180.0f * (SERVO_PULSE_MAX - SERVO_PULSE_MIN)); }两个宏的值在不同的舵机上会有细微差别。有的舵机标称0.5ms到2.5ms但极限位置反而进不去需要把MIN放宽到90、把MAX放宽到525这类属于舵机个体差异改宏就行不用动其他代码。注意不要直接给PWM赋0或者4095那等于输出一直低或一直高舵机收不到有效PWM信号会抖动甚至烧控制板。4.2 单通道输出把脉宽写进寄存器PCA9685的16个通道各占4个寄存器起始地址0x06分别是ON_L、ON_H、OFF_L、OFF_H。LED0_ON_L在0x06LED1_ON_L在0x0A依次往后推。把角度转换好的计数写到OFF寄存器ON保持0也就是一个周期开始后立即打开输出到设定计数点关闭。void PCA9685_SetPWM(uint8_t channel, uint16_t on, uint16_t off) { PCA9685_WriteReg(0x06 channel * 4, on 8); PCA9685_WriteReg(0x06 channel * 4 1, on 0xFF); PCA9685_WriteReg(0x06 channel * 4 2, off 8); PCA9685_WriteReg(0x06 channel * 4 3, off 0xFF); } void PCA9685_SetServoAngle(uint8_t channel, float angle) { PCA9685_SetPWM(channel, 0, PCA9685_AngleToPulse(angle)); }channel取值范围0到15超过范围就是写到了下一个模块的寄存器空间这种错误特别隐蔽因为I2C通信本身不会报错只是舵机没反应。建议在函数入口加一个channel 15的判断直接返回省得后面排查半天。on和off都是12位计数0到4095超出部分在数据手册里还有特殊含义OFF为0表示整个周期都是低电平OFF为4096表示整个周期都是高电平。这类特殊值在这套舵机驱动的场景中用不到但写通用驱动时要注意别让计算值越界。4.3 测试主程序让16路按顺序扫动工程里的测试逻辑是把16个通道全部接上舵机然后统一赋值0度、90度、180度每个位置停留1.5秒循环往复。如果手上舵机不够也可以只接几个代码逻辑不变。int main(void) { uint8_t ch; delay_init(); IIC_Init(); PCA9685_Init(); while (1) { for (ch 0; ch 16; ch) PCA9685_SetServoAngle(ch, 0); delay_ms(1500); for (ch 0; ch 16; ch) PCA9685_SetServoAngle(ch, 90); delay_ms(1500); for (ch 0; ch 16; ch) PCA9685_SetServoAngle(ch, 180); delay_ms(1500); } }这里有个容易被忽略的问题16个通道同时从0度跳到180度瞬间电流会非常大。舵机堵转时单路电流能到1A以上就算空载16路同时动也很容易把电源电压拖垮。所以更稳的写法是循环里加一点错峰延时比如每设置4个通道就等50ms让电流曲线平缓一些。这个在测试时看不出来接了机械臂负载以后就明显了。实际跑测试时建议打开串口看打印用printf在每个动作切换时输出当前角度值。F103上需要把fputc重定向到USART1这一步在USART初始化里完成。看到串口有输出但舵机没动问题一定在硬件或接线串口没输出问题在代码流程。4.4 多芯片级联把16路扩展成更多路机器人项目里16路舵机往往不够这时候PCA9685的地址扩展就派上用场。芯片有A0到A5六个地址引脚接GND代表0接VCC代表1组合出来的7位地址从0x40到0x7F减去若干保留地址一条I2C总线上可以挂几十片。地址公式uint8_t base 0x40 | (A5 5) | (A4 4) | (A3 3) | (A2 2) | (A1 1) | A0;级联时每一片PCA9685的SCL、SDA并联到同一条总线VCC、GND并联V可以各自接电源也可以共用。需要注意总线上拉电阻要适当减小挂着十几片芯片后总线电容变大4.7kΩ换2.2kΩ否则信号边沿变缓通信误码率上升。工程里虽然没有把多片级联封装成完整代码但驱动函数上件地址都是宏定义改成把addr作为参数传入就有余量。后面第6章我会直接给出改造思路。5. 常见问题排查I2C锁死到舵机抖动的那几条踩坑记录5.1 现象I2C扫描不到PCA9685的地址程序跑起来后串口一直没打印或者卡在IIC_WaitAck里出不来。先用万用表量SCL和SDA电压发现都停在0V不动。原因基本是三个一是地址算错把7位的0x40直接当成8位地址发送从机当然不应答二是SDA、SCL缺少上拉电阻开漏模式下没人把电平拉高三是A0到A5引脚悬空地址和默认值不一致。解决方法是先写个I2C扫描函数从0x40到0x7F逐个发地址试ACK打印出能应答的设备。然后确认A0到A5全部接GND确认板上有没有上拉电阻没有就自己焊两颗4.7kΩ最后检查地址宏是不是0x80。这三步走完90%的扫描不到问题都能解决。5.2 现象舵机抖动或者偏到一边舵机接上去左右乱抖或者明明命令是90度实际停在某个角度还发出嗡嗡声。这种抖动多半是PWM频率不对。PCA9685输出的是连续方波频率不是50Hz时舵机内部的脉冲识别逻辑就会混乱。先查SetPWMFreq(50)有没有真正生效把prescale算一遍是不是121。还有一个常见情况是角度映射算出的脉宽超出了舵机有效范围比如把0度算成了0计数PWM直接变成常低电平舵机没有识别信号就会抖。解决确认PRE_SCALE寄存器读到0x79把角度映射函数限制在102到512之间如果舵机依然抖换一个已知正常的舵机排除个体故障。舵机供电不足也会表现出抖动这个问题和第5.5条一起看。5.3 现象寄存器配置都执行了但16路输出全无I2C通信正常读寄存器也能读到数值但示波器看输出引脚一点波形都没有。原因多半是OE引脚。OE是输出使能低电平有效很多模块把这个引脚引出来但默认悬空悬空状态下芯片内部输出级完全不工作配置得再多也白搭。另外MODE2的OUTDRV位如果保持开漏模式输出高电平要靠外部上拉舵机信号线阻抗不匹配波形也可能异常。解决OE直接接地或者用一个GPIO控制它平时拉低使能。MODE2写入0x04切到推挽输出。这两个检查点做完输出基本就有了。5.4 现象运行一段时间后I2C总线卡死设备工作几分钟后突然全部无响应串口也打印不出东西按复位又能恢复。这是I2C总线的经典死锁某个从机在通信中途看到异常时序把SDA拉低不放所有后续通信都无法开始。常见诱因是热插拔、总线收到干扰、或者主机发送字节时SCL/SDA顺序不对。解决给软件I2C加一个总线复位函数把SCL翻转若干次给从机一个完整的脉冲序列让它释放SDAvoid IIC_BusReset(void) { uint8_t i; SDA_H(); for (i 0; i 16; i) { SCL_L(); delay_us(2); SCL_H(); delay_us(2); } }然后在每次I2C操作前检查SDA状态发现被拉低就调用总线复位。这个方法能救回大部分死锁场景要是还不行就检查接线长度和外部干扰源把I2C线剪短到20厘米以内试试。5.5 现象舵机一多系统就复位4路以内一切正常加到8路或者12路一上电STM32就掉电重启或者舵机动作变得毫无规律。这基本是电源问题。舵机启动瞬间电流很大堵转时单路能到1A多路一起动电源电压被拉低到MCU复位阈值以下程序就重启了。舵机和MCU共用一路电源时尤其明显USB口的500mA根本扛不住。解决方法是舵机单独供电5V或6V电源直接接到VGND和MCU共地但电源回路分开。在舵机电源端并一颗1000uF以上的电解电容吸收瞬态软件上做错峰启动把16路的动作时间分散到几百毫秒内。6. 二次开发把测试工程改造成通用多路舵机控制中间层6.1 用结构体抽象设备句柄为多片PCA9685铺路拿到工程后第一件事把驱动函数里的全局地址宏全部换成参数化写法。定义一个设备结构体把地址和通道数装进去typedef struct { uint8_t addr; uint8_t channel_num; } PCA9685_Dev; void PCA9685_InitDev(PCA9685_Dev *dev, uint8_t addr, uint8_t channel_num) { dev-addr addr; dev-channel_num channel_num; PCA9685_SetPWMFreq(dev, 50); }这样后续要做四足机器人、挂四片PCA9685只需维护一个dev数组循环初始化主逻辑里按关节编号找到对应的设备和通道不用为每片芯片复制一份代码。这是从测试工程走向正式项目最值得先做的一步。6.2 给舵机加一个渐变缓冲避免硬跳直接设置目标角度舵机是瞬间跳过去的机械结构承受冲击电源电流也会出现尖锐脉冲。常见做法是加入渐变步进每10ms往目标值移动一小步让舵机平滑过渡。void PCA9685_RampTo(PCA9685_Dev *dev, uint8_t ch, uint16_t target, uint16_t step) { uint16_t cur PCA9685_GetPulse(dev, ch); while (cur ! target) { if (cur target) cur step; else cur - step; PCA9685_SetServoPulse(dev, ch, cur); delay_ms(10); } }step取值3到5时90度行程大概几百毫秒完成step越小运动越平滑但CPU占用越多。这是做机械臂时非常实用的功能能让整个动作看起来自然很多也能明显降低电源瞬态压力。6.3 改造后的验证三板斧改完驱动不要直接上负载先按三步验证第一步I2C扫描确认每片PCA9685的地址都在线把扫描结果打印出来第二步单通道点动用0度、90度、180度三个位置确认每个舵机的方向和限位第三步全通道压力测试把所有舵机接上负载连续动作十分钟观察电源电压有没有跌落、有没有复位、有没有抖动。从那以后我每次改完驱动都会强制走一遍这三板斧尤其是第三步很多在空载下发现不了的问题接上负载就会浮现。驱动代码本身倒简单真正考验人的是电源和机械。这套工程把PCA9685的驱动链路跑通了剩下的扩展空间其实很大从16路舵机到几十路舵机从单板到多板级联都在你能掌控的范围里。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?