简介:这是一份面向PIC单片机初学者的PWM脉冲宽度调制实现资料,通过简单但可自由调整占空比的程序示例,帮助开发者快速掌握PWM配置与输出。PWM技术广泛用于电源管理、电机控制、LED调光等场景,本例可作为入门参考。整个压缩包共22个文件,C语言源程序与汇编文件构成核心代码,hex烧写文件用于验证,map映射表和lst列表文件辅助调试,配合MPLAB工程文件(mcw/mcp)可直接打开查看配置,丰富类型覆盖编译、链接、烧录、调试完整流程;压缩包整体仅46KB,小巧易用。目前已有174人学习,适合需要快速上手PIC单片机PWM功能的电子爱好者或工程师。通过这份资料,可以获取一套基础PWM应用示例,包括PWM通道配置、计数器与预分频器设定、占空比调节方法以及中断/同步处理思路,也可为多路PWM扩展和电机控制、LED调光等实际项目提供直接参考。
1. PIC 单片机设置 3 路 PWM,先想清楚一路从哪来
手里只有一块 PIC16F877A,需求是输出 3 路频率可调、占空比可调的 PWM:两个直流电机调速,一路 LED 调光;或者三个舵机。翻开数据手册会发现一个尴尬事实——这类经典 PIC 的 CCP 模块只有两个,硬件 PWM 最多两路。第三路要么换芯片,要么借一个定时器中断用软件“凑”出来,而这一凑就牵出频率上限、占空比步进、中断时序这些真正决定方案能不能用的细节。本篇文章会把这个标题背后的完整解题路径拆开:从 CCP/TMR2 的硬件结构讲到频率与占空比公式,再用 MCC 和手写寄存器两套方式分别实现 3 路 PWM,最后收在舵机周期、H 桥死区和波形验证这些落地时一定会碰到的问题上。适合正在用 PIC 做多路输出控制、又不满足于点亮 LED 就算跑通的工程师。
2. CCP 与 TMR2:PIC 硬件 PWM 的频率、占空比和 10 位边界
2.1 一个 TMR2 带两个比较器,PWM 是这样“比”出来的
PIC16F877A 的 PWM 并不是一个独立外设,而是 CCP 模块工作在 PWM 模式下,配合 TMR2 定时器一起完成的。TMR2 是一个 8 位计数器,每个指令周期加一;当它的值与 PR2 寄存器相等时,在下一个计数周期被清零并重新开始。CCP1 和 CCP2 各自有一个比较器,会在 TMR2 与 CCPRxL 相等时产生一个匹配事件,这个事件直接控制 PWM 输出引脚的电平翻转。
输出高电平从 TMR2 清零开始,到比较匹配结束,所以 PR2 决定了 PWM 周期,CCPRxL 加上 CCPxCON 寄存器里的两位低值共同决定占空比。老工程师口中的“10 位 PWM”指的就是 CCPRxL 的 8 位高值加 CCPxCON 的 bit5:4 两位低值,组成一个 10 位占空比寄存器。这里有个容易忽略的细节:占空比低两位不在 CCPRxL 里,而在控制寄存器的高位。改占空比时只写 CCPRxL,相当于丢掉四分之一精度。
2.2 占空比计算公式:先算 PR2,再算 10 位占空比值
PIC PWM 频率的公式是:
Fpwm = Fosc / (4 × TMR2预分频 × (PR2 + 1))以 8MHz 晶振、指令周期 2MHz 为例,目标频率 10kHz,TMR2 预分频取 1:1,那么 PR2 + 1 = 2000000 / 10000 = 200,PR2 写入 199。预分频在 T2CON 寄存器的 bit1:0 设置,00 表示 1:1。
占空比寄存器值则用另一个公式:
Duty(10位) = 4 × (PR2 + 1) × 占空比百分比注意这个 4 是公式自带的,不是笔误。10kHz、PR2 + 1 = 200 时,50% 占空比对应的 10 位值是 4 × 200 × 0.5 = 400,CCPRxL 写入 100,CCPxCON 低两位写 0。很多初学者把 CCPRxL 直接写 50,结果实测占空比只有 12.5%,就是因为漏掉了公式里的系数 4。
分辨率同样受 PR2 约束。PIC16F877A 数据手册上的分辨率公式是:
分辨率(位) = log2(4 × (PR2 + 1))它不能超过 10 位,因为占空比寄存器只有 10 位。常见频率下的参数如下表所示:
| 目标频率 | TMR2 预分频 | PR2 + 1 | 100% 占空比对应 10 位值 | 实际分辨率 |
|---|---|---|---|---|
| 10kHz | 1:1 | 200 | 800 | 9.64 位 |
| 20kHz | 1:1 | 100 | 400 | 8.64 位 |
| 1kHz | 1:16 | 125 | 500 | 8.97 位 |
在 8MHz 时钟下,1kHz 用 1:4 预分频会算出 PR2 = 499,超出 8 位寄存器最大值 255,所以必须升到 1:16。这个表建议保存,配参数时照着填,比反复试波形快得多。
提示:占空比寄存器写到 4 × (PR2 + 1) 时输出恒为高,再大也没有意义;而 255 是 PR2 的最大值,不是占空比寄存器在 PWM 里的上限。
2.3 三路 PWM 的三种可行路线:2+1、换芯片、全软件
标题里的“3 路”在 PIC16F877A 上有三条实现路线,选哪条取决于频率和占空比精度要求。
路线一是 CCP1 + CCP2 做两路硬件 PWM,第三路用 TMR0 中断软件翻转。这是成本最低、改动最小的方案。两路硬件 PWM 的频率、相位完全由 TMR2 决定,精度高;软件路受中断响应延迟影响,会有纳秒到微秒级的抖动。对于 LED 调光和舵机这种不要求严格相位的场景足够,但用于电机电流闭环就要谨慎。
路线二直接换芯片。PIC18F45K22 这类型号有 CCP 和 ECCP 组合,PWM 通道数更多;部分 PIC16F 系列也带多路 PWM 模块。换芯片的代价是重新确认引脚映射、中断向量和外设寄存器差异,但这在量产项目里反而是更干净的做法。
路线三是三路全部用定时器中断软件输出,只在引脚不够或频率很低时用。它最大的问题是 CPU 占用率:每路都要在中断里翻转引脚,三路 20kHz 意味着每秒 12 万次中断,主循环基本被吃掉大半。所以我的建议是:能用硬件 PWM 就不碰软件 PWM,软件路只做低频、低精度那一路。
2.4 为什么 20ms 舵机周期用硬件 PWM 做不出来
舵机控制需要 50Hz 频率,周期 20ms。把公式反过来算:PIC16F877A 的 TMR2 预分频最大 1:16,PR2 + 1 最大 256,最低硬件 PWM 频率是 8000000 / (4 × 16 × 256) ≈ 488Hz。离 50Hz 差了将近一个数量级,数据手册里不会直接写“最低频率”,但用公式一推就能发现:PIC16F877A 的 CCP 硬件 PWM 根本发不出舵机需要的 20ms 周期脉冲。
这时候第三路软件 PWM 不是备选方案,而是唯一可行解。常见做法是让 TMR0 中断提供一个 0.5ms 或 1ms 的时基,在中断服务程序里计数翻转,实现 20ms 周期内 1ms 到 2ms 的高电平脉宽。这个思路在第 4 章会给出完整代码,也是“3 路 PWM”标题里最容易踩坑的部分。
3. 用 MCC 生成 3 路 PWM:CCP1/CCP2 配硬件,TMR0 配软件路
3.1 MPLAB Code Configurator 里的三块配置
MPLAB X IDE 里装好 MCC(MPLAB Code Configurator)后,新建工程第一件事是 System Module 里把时钟配好。选 8MHz 内部振荡器,对应指令周期 2MHz,这样后面 PR2 的计算才一致。然后按顺序打开三个外设:TMR2、CCP1、CCP2,再加一个 TMR0 给第三路软件 PWM 当时基。
TMR2 页面里要手动确认预分频,MCC 默认不一定是你想要的 1:1。CCP1 和 CCP2 页面各有一个 PWM 模式开关,打开后需要填两个参数:PWM Frequency 和 Duty Cycle。注意 MCC 界面上 Duty Cycle 的单位是百分比,生成代码后 API 接收的参数却是 10 位数值,这个差别在移植时最容易出错。
TMR0 页面把定时器打开,选 8 位模式,预分频随意,后面反正要用中断计数实现 20ms 周期,MCC 生成的数值只是初始值。
3.2 MCC 生成的初始化代码里藏着 PR2 和占空比的关系
生成代码后打开pwm1.c,核心初始化函数大概是这个结构:
void PWM1_Initialize(void) { // 关闭 PWM 输出,占空比清 0 CCP1CON = 0x00; // PWM 周期寄存器:8MHz / (4 * 1 * 10000) - 1 = 199 PR2 = 0xC7; // TMR2 开启,预分频 1:1 T2CON = 0x04; // 占空比高 8 位清 0 CCPR1L = 0x00; // PWM 模式,低 2 位占空比清 0 CCP1CON = 0x0C; // RC2 设为输出 TRISC2 = 0; }PR2 写入 0xC7 就是十进制的 199,对应 10kHz。CCP1CON = 0x0C是重点:0x0C 的二进制是 00001100,低四位 1100 表示 CCP1 工作在 PWM 模式,bit5:4 的占空比低两位为 0。MCC 可能在后续 API 里对这两个位做运算,所以在 main 中不要直接覆盖整个 CCP1CON,而是用 MCC 提供的PWM1_LoadDutyValue。
在 main 函数里改占空比时,传进去的是 10 位数值,不是百分比:
// 10kHz 下 50% 占空比:4 * 200 * 0.5 = 400 PWM1_LoadDutyValue(400); // 25% 占空比:200,CCPR1L 会自动写入 50 PWM2_LoadDutyValue(200);PWM1_LoadDutyValue内部会把 400 拆成高 8 位写入 CCPR1L,低 2 位写入 CCP1CON 的 bit5:4。我见过不少人在调用时写 50 或 0.5,结果示波器上占空比完全不对。每次改频率后,这个 10 位值都必须用公式重算。
3.3 第三路软件 PWM:在 TMR0 中断里翻转一个普通 IO
第三路没法靠 CCP 生成,我用 TMR0 中断做一个可配占空比的软件 PWM。思路是让 TMR0 每 25us 溢出一次,维护一个周期计数器和占空比计数器,中断里比较后决定引脚输出高低:
volatile unsigned int sw_period = 800; // 20ms 周期 = 800 * 25us volatile unsigned int sw_duty = 600; // 15ms 高电平 volatile unsigned int sw_count = 0; void __interrupt() isr(void) { if (TMR0IF) { TMR0IF = 0; TMR0 += 50; // 重装载,补偿中断延迟 if (++sw_count >= sw_period) sw_count = 0; if (sw_count < sw_duty) RC0 = 1; else RC0 = 0; } }主循环里只需要修改sw_duty就能改变占空比,修改sw_period可以改变频率。TMR0 的“25us 中断一次”是由 TMR0 初值和预分频决定的,不同时钟下需要重新算;TMR0 += 50是常见的可重装载写法,比直接赋值更稳,但首次中断会有半个周期的偏差,要求严格时需要用示波器校准这个偏移量。
3.4 MCC 配置里最容易出的两个问题
第一个是把占空比当成百分比直接传给 LoadDutyValue 系列 API,前面已经说过。第二个是换了带 PPS 引脚映射的 PIC 型号后,CCP1 输出没有指定到具体引脚。PIC16F877A 的 CCP1 固定接在 RC2、CCP2 固定接在 RC1,不需要配置;但用 PIC16F1789 这类带 PPS 的芯片时,必须在 MCC 的 Pin Module 或 PPS 页面里把 PWM 映射到某个可输出引脚,再确认这个引脚没有复用为模拟输入,否则上电后引脚被 ADC 占用,输出永远是低电平。
4. 不用 MCC,手写寄存器实现 PIC 3 路 PWM
4.1 两路硬件 PWM 的完整初始化顺序
不依赖 MCC 时,寄存器操作顺序非常关键。很多人先开 TMR2 再写 PR2,结果第一个周期长度是错的。正确顺序是先关 TMR2,再写 PR2,然后配置 CCP 寄存器,最后启动 TMR2:
void pwm_init(void) { // 1. 关闭 TMR2,等待配置完成 T2CON = 0x00; // 2. 周期寄存器:10kHz,预分频 1:1 PR2 = 199; // 3. 占空比高 8 位 CCPR1L = 100; // 10kHz 50%:4 * 200 * 0.5 / 4 = 100 CCPR2L = 50; // 10kHz 25%:4 * 200 * 0.25 / 4 = 50 // 4. CCP1、CCP2 进入 PWM 模式,占空比低 2 位为 0 CCP1CON = 0x0C; CCP2CON = 0x0C; // 5. 输出引脚 TRISC2 = 0; TRISC1 = 0; // 6. 启动 TMR2,预分频 1:1 T2CON = 0x04; }T2CON = 0x04的 bit2 是 TMR2ON,置 1 才启动定时器;bit1:0 为 00 表示预分频 1:1。CCP1CON 和 CCP2CON 的低四位都是 1100,即 PWM 模式。这里初始化占空比用的 CCPRxL 值是“10 位占空比值除以 4 后的商”,因为低两位已经由 0x0C 里的 bit5:4 补上了。PIC16F877A 的 CCP1、CCP2 引脚分别是 RC2 和 RC1,都不是模拟复用引脚,TRIS 清零即可。
4.2 动态改占空比:高 8 位和低 2 位不能分开随便写
运行中改占空比,最忌讳的做法是只写 CCPR1L 而不管 CCP1CON 的低两位,或者整个 CCP1CON 直接赋值覆盖掉 PWM 模式位。标准写法如下:
void pwm1_set_duty(unsigned int duty10) { // 限制最大值为 4 * (PR2 + 1) = 800 if (duty10 > 800) duty10 = 800; // 先写高 8 位 CCPR1L = (unsigned char)(duty10 >> 2); // 保留 CCP1CON 的模式位,只更新 bit5:4 CCP1CON = (CCP1CON & 0xCF) | ((duty10 & 0x03) << 4); }先写 CCPR1L 再改 CCP1CON 低两位,是为了避免占空比跳变过程中出现一两个周期的错误脉宽。实际占空比更新并不在写入瞬间立即生效,而是等当前 PWM 周期结束后的边界才会起作用,所以这种“分成两步写”的手法在绝大多数 PICPIC 应用里不会造成毛刺。要求严苛的场合,可以在关闭 TMR2 的情况下更新寄存器,或者利用 CCPxCON 的同步更新机制。
4.3 第三路软件 PWM:不依赖 MCC 的 TMR0 中断写法
手写版第三路和 MCC 版的思路一致,但要去掉对生成代码的依赖,直接用 OPTION_REG 和 INTCON 配置 TMR0:
void sw_pwm_init(void) { // TMR0 内部时钟,8 位模式,预分频 1:2 OPTION_REG = 0x02; // TMR0 溢出中断允许,总中断开放 TMR0 = 0; INTCON = 0xA0; } void __interrupt() isr(void) { if (TMR0IF) { TMR0IF = 0; TMR0 += 100; // 调整此值改变时基 if (++sw_count >= sw_period) sw_count = 0; if (sw_count < sw_duty) RC0 = 1; else RC0 = 0; } }OPTION_REG = 0x02的含义是:bit5 为 0 选内部指令时钟,bit3 为 0 表示预分频器分配给 TMR0,bit2:0 为 010 表示 1:2 分频。8MHz 时钟下指令周期 2MHz,1:2 分频后 TMR0 计数频率 1MHz。TMR0 每溢出一次需要 256 个计数,即约 256us,所以sw_period设 78 大约就是 20ms 周期。这个值只是个起点,实际中断延迟和重装载开销都需要用示波器校准。
4.4 中断和主循环之间的共享变量
软件 PWM 的sw_duty、sw_period在主循环里改,在中断里读,必须声明为volatile。否则编译器可能把sw_duty优化到寄存器里,中断读到的永远是被优化前的旧值。更稳妥的做法是主循环只写一个“目标值”,中断里再把目标值赋给实际使用变量,这样即使主循环写入半个 int 也不会让中断读到撕裂数据:
volatile unsigned int sw_duty_target = 0; volatile unsigned int sw_duty_actual = 0; // 中断里 if (sw_duty_target != sw_duty_actual) sw_duty_actual = sw_duty_target;8 位 MCU 上 16 位变量的非原子写是真实存在的风险,尤其是编译器优化等级开高之后。这不算小题大做,电机 PWM 项目里遇到过多次随机占空比跳变,最后定位到都是这类问题。
5. 舵机、H 桥死区和波形验证:三路 PWM 的进阶细节
5.1 三路相位对齐:用同一个 TMR2 做不到的就用软件路让步
两路硬件 PWM 由同一个 TMR2 驱动,天然同频同相。第三路软件 PWM 如果挂在 TMR0 上,相位和硬件路没有确定关系。要求三路上升沿对齐的应用(比如三相逆变半成品实验),可以让软件路也使用 TMR2 中断作为时基;但 TMR2 被硬件 PWM 占用时,中断触发点和 PWM 边沿可能只差几个指令周期,抖动无法消除。常见的折中是只在启动时刻做对齐:初始化时先清 TMR2、软件路计数器清零,然后同时启动输出,后续相位会慢慢漂移。要求严格的场合,我的建议是放弃 PIC16F877A 这条路线,直接换多通道 PWM 芯片。
5.2 舵机控制:把 20ms 周期放到软件路上
结合 2.4 节的结论,PIC16F877A 的舵机控制基本固定在软件路。舵机中立点通常要求 1.5ms 高电平脉冲,对应 20ms 周期里的占空比 7.5%。以 TMR0 每 25us 中断一次为例,20ms 周期需要 800 个中断计数,1.5ms 高电平对应 60 个计数:
sw_period = 800; sw_duty = 60; // 60 * 25us = 1.5ms左右极限一般是 1ms 到 2ms,对应sw_duty的 40 到 80。SW 路控制舵机时,占空比分辨率只有 40 格,舵机转角精度约 4.5 度每格,要求精细控制的话需要缩短 TMR0 时基,代价是更高的中断频率。实测时用示波器测舵机信号线的脉宽,校准TMR0 += 100里这个重装载值比盯着角度看要直接得多。
5.3 H 桥死区处理:PIC16F877A 没有内置死区发生器
PIC16F877A 的 CCP 在 PWM 模式下没有死区控制逻辑,这是它和高端定时器的本质差别。H 桥上下桥臂需要两路互补 PWM,且要保证死区时间,否则 MOSFET 直通烧毁。常见做法是让互补的两路用同一路硬件 PWM 加反相器电路,再用 RC 延迟产生死区;或者把两路都放软件路,在翻转沿手动插入一段忙等:
// 低电平转高电平前插入死区 RC0 = 0; __delay_us(1); RC1 = 1;这段延时必须在逻辑分析仪或双通道示波器上确认:两路信号不能同时为高。测量方法是把 CH1 接到上桥 PWM、CH2 接到下桥 PWM,触发方式设为上升沿,放大时间轴观察交叠。用__delay_us做死区虽然占 CPU,但在 1kHz 到 20kHz 频率下足够可靠。
5.4 三路 PWM 上电无波形快速定位
PIC 上电后 PWM 没有输出,按下面顺序查一圈,多数问题五个寄存器内解决:
| 检查项 | 寄存器 | 典型故障 |
|---|---|---|
| 引脚方向 | TRISC1 / TRISC2 / TRIS 对应位 | 引脚还是输入态,PWM 信号被外部电路拉死 |
| 定时器运行 | T2CON bit2(TMR2ON) | 忘开 TMR2,占空比寄存器写入无效 |
| 周期值 | PR2 | PR2 = 0 时频率异常,示波器看到杂散波形 |
| 模式位 | CCP1CON 低 4 位 | 不是 1100,CCP 工作在比较或捕捉模式 |
| 模拟复用 | ANSEL 或 ANSELC(型号相关) | 输出引脚复用为 ADC 输入,电平被模拟电路钳位 |
这五步检查完仍然没有波形,下一个怀疑对象是外部电路:PWM 引脚对地短路、限流电阻开路、示波器探头挡位错误。PWM 输出是高阻态被外部上拉电阻拉成恒高,还是完全无电平,这两类现象对应的排查方向完全不一样。用万用表量引脚直流电压只能判断有无翻转,测频率和占空比还是要上示波器,观察 10us 挡位下的边沿细节,才能确认死区、相位和占空比是否符合预期。
本文还有配套的精品资源,点击获取