简介:本资源是一套基于STM32F103C8单片机实现主从同步PWM输出的完整工程代码,面向嵌入式初学者与电机控制开发者,解决多路高精度、可调频调占空比PWM信号生成这一典型控制需求,适用于直流电机调速、LED调光及开关电源设计等场景。压缩包含991个文件,主体为558个C源码与242个头文件(构建HAL驱动与定时器配置逻辑),辅以28个IAR链接脚本、27个说明文本及Keil工程文件(uvprojx/uvoptx)、STM32CubeMX配置文件(ioc/mxproject)等,总大小22.32MB,目录结构清晰体现Drivers/Core/MDK-ARM分层设计。已有927人学习下载,提供TIM1与TIM8双高级定时器互补输出的实操范例、预置数学库(如arm_dct4_init_f32.c)支持进阶信号处理,并包含开发履历与完整编译产物(axf/hex/dbgconf),便于快速部署、参数调试与底层原理验证。
1. 这不是“调个PWM”那么简单:为什么两路同步输出在STM32F103C8上值得专门写一篇实操笔记
你手头有一块蓝 pill 板——那块标着 STM32F103C8T6 的经典入门板,想用它驱动两个电机、控制双路LED亮度,或者做逆变器的互补驱动?光是“能输出PWM”远远不够。真正卡住人的,从来不是“怎么让TIM2_CH1亮起来”,而是“怎么让TIM2_CH1和TIM3_CH2在毫秒级误差内严格同频、同相、同占空比变化”。我去年帮一个做无刷直流风机的客户调试时,就栽在这上面:单路PWM一切正常,一加第二路,频率跳变、占空比抖动、甚至偶尔锁死——查了三天才发现,问题不在代码逻辑,而在定时器主从联动的触发源配置和ARR预装载使能的时序配合。
这个项目标题里藏着三个硬核关键词:同步、可调、主从模式。它们不是并列关系,而是因果链。“主从模式”是实现“同步”的技术路径,“可调”是功能目标,而“两路”则是验证同步精度的最小闭环系统。STM32F103C8资源有限——只有2个高级定时器(TIM1)、3个通用定时器(TIM2/3/4),没有TIM8,也没有硬件级的同步总线(如TIMx_ETR或TIMx_BKIN的深度耦合)。所以这里的“同步”,不是靠外部信号硬拉齐,而是靠软件精准调度+寄存器级协同——本质上是一场对APB总线时钟、预分频器、自动重装载值、捕获比较寄存器更新时机的精密编排。
适合谁看?如果你正用蓝 pill 做电机驱动、电源控制、音频DAC波形合成,或者准备参加电子设计竞赛需要多路精确时序控制,这篇就是为你写的。它不讲库函数API怎么调用,而是带你拆开HAL库底层,看清楚HAL_TIM_PWM_Start()背后到底写了哪几个寄存器、为什么必须在__HAL_TIM_SET_COMPARE()之后立刻调用__HAL_TIM_ENABLE_OCx_PRELOAD()、以及那个被无数教程忽略的TIM_CR1_ARPE位——它才是占空比突变不抖动的关键。所有代码都基于标准外设库(SPL)和裸机寄存器操作双版本提供,你可以直接烧录验证,不用纠结CubeMX生成的冗余配置。
2. 同步的本质:不是“一起跑”,而是“同一起跑线+同一发令枪”
2.1 主从模式的物理意义与资源约束
在STM32F103C8上,所谓“主从模式”,本质是利用一个定时器(主)的更新事件(UEV)或触发输出(TRGO)作为另一个定时器(从)的时钟源或启动信号。但请注意:F103C8不支持TIMx的TRGO信号直接作为其他TIMx的ETR输入(那是F4/F7系列才有的特性)。所以我们能用的,只有两种合法路径:
路径A(推荐):主定时器更新事件 → 从定时器软件触发
主定时器(如TIM2)计数到ARR产生UEV时,通过中断服务程序(ISR)手动调用TIM_GenerateEvent(TIM3, TIM_EventSource_Update),强制从定时器(TIM3)执行一次更新。这种方式延迟稳定(约3~5个CPU周期),但需关闭全局中断或使用临界区保护,否则可能漏触发。路径B(更优):主定时器触发输出 → 从定时器外部时钟模式
将主定时器(TIM2)的TRGO引脚(默认为CH2,即PA1)配置为“更新事件触发”,再将该引脚连接到从定时器(TIM3)的ETR引脚(PB4)。此时TIM3工作在“外部时钟模式2”(SMS = 0b111),其计数完全由TIM2的TRGO脉冲驱动。这是真正的硬件同步,抖动<10ns,但需要物理连线——蓝 pill 板上PA1和PB4不相邻,得飞线。
我最终选择路径B,因为客户产品要求PWM边沿抖动<50ns。虽然要焊一根线,但换来的是确定性时序。这里有个关键细节:TRGO信号默认是“更新事件”,但F103C8的TRGO有3种可选源(更新、CC1匹配、CC2匹配),必须显式配置TIM2->CR2 |= TIM_CR2_MMS_1;(即MMS[2:0] = 010),否则TRGO永远输出低电平。
2.2 频率与占空比的数学绑定关系
很多人以为“频率可调”和“占空比可调”是独立参数,其实它们在定时器里是强耦合的。以TIM2为例,其PWM频率由以下公式决定:
f_PWM = f_APB1 / ((PSC + 1) × (ARR + 1))其中:
f_APB1是APB1总线频率(默认72MHz,但F103C8的APB1最大为36MHz,需确认RCC配置)PSC是预分频器值(16位寄存器)ARR是自动重装载值(16位寄存器)
而占空比Duty由比较寄存器CCR决定:Duty = CCR / (ARR + 1)
陷阱来了:当你想把频率从1kHz调到20kHz时,如果只改PSC,ARR不变,那么CCR对应的物理时间会同比例缩放,导致占空比数值虽不变,但实际导通时间变短——这在电机控制中会引起转矩突变。正确做法是:频率调整时,同步重算ARR和CCR,保持CCR/ARR比值恒定。例如原设置PSC=71, ARR=999(f=1kHz),要升频到2kHz,不能只把PSC减半,而应设PSC=35, ARR=499,同时CCR按比例变为原值的一半。
提示:代码中必须封装一个
Set_PWM_Freq_Duty()函数,内部完成PSC/ARR/CCR三者联动计算。我见过太多项目因手动修改寄存器导致占空比“看起来没变,实际已偏移”。
2.3 同步失效的三大隐性根源
即使主从接线正确,仍可能不同步。我在调试时遇到过三次典型故障:
ARR未使能预装载:
TIMx->CR1的ARPE位(Auto-Reload Preload Enable)未置1。结果是修改ARR后,新值立即生效,导致当前周期长度突变,PWM波形出现毛刺。必须TIMx->CR1 |= TIM_CR1_ARPE;且ARR写入后需等待UIF标志。从定时器未启用主模式:TIM3工作在外部时钟模式时,
TIM3->SMCR的SMS位必须设为0b111(External clock mode 2),且TS位(Trigger Selection)要指向ITR0(即TIM2的TRGO)。漏配任一位,TIM3就当普通定时器跑。主定时器TRGO极性错误:TRGO默认高有效,但ETR输入要求上升沿触发。若主定时器配置为“更新事件触发”,其TRGO是方波(50%占空比),上升沿即为更新时刻。但如果误配成“CC1匹配触发”,TRGO可能输出窄脉冲,ETR无法可靠捕获。
3. 实操核心:从寄存器配置到波形验证的完整链路
3.1 硬件连接与引脚复用配置
蓝 pill 板的引脚资源紧张,必须精打细算:
| 定时器 | 通道 | 默认引脚 | 复用功能 | 本项目用途 |
|---|---|---|---|---|
| TIM2 | CH1 | PA0 | AFIO | 主PWM输出(驱动LED) |
| TIM2 | CH2 | PA1 | AFIO | TRGO输出(连PB4) |
| TIM3 | CH1 | PB0 | AFIO | 从PWM输出(驱动电机) |
| TIM3 | ETR | PB4 | AFIO | 接收TIM2_TRGO |
关键配置步骤:
- 开启GPIOA/B时钟:
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN; - 配置PA0/PA1为复用推挽:
GPIOA->CRH &= ~(0xFF << 0); GPIOA->CRH |= (0x02 << 0);(PA0);同理PA1 - 配置PB0/PB4为复用推挽:
GPIOB->CRL &= ~(0xFF << 0); GPIOB->CRL |= (0x02 << 0);(PB0);PB4同理 - 特别注意:PB4在F103C8上默认是JTAG_TDO,必须禁用JTAG才能释放为普通GPIO:
AFIO->MAPR |= AFIO_MAPR_SWJ_CFG_1;(仅保留SWD)
实操心得:第一次焊接时我把PA1和PB4接反了,示波器看到TIM3波形比TIM2慢整整一个周期。后来发现TRGO信号是下降沿触发(误读手册),实际应接上升沿——用逻辑分析仪抓TRGO波形比肉眼观察更可靠。
3.2 主定时器(TIM2)初始化代码详解
void TIM2_Master_Init(void) { // 1. 开启TIM2时钟 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 2. 配置TIM2为PWM模式:CH1为PWM1,CH2为TRGO源 TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // PWM1模式 TIM2->CCMR1 |= TIM_CCMR1_OC2M_1 | TIM_CCMR1_OC2M_2; // PWM1模式(CH2仅作TRGO) TIM2->CCER |= TIM_CCER_CC1E | TIM_CCER_CC2E; // 使能CH1/CH2输出 // 3. 设置TRGO为更新事件(关键!) TIM2->CR2 |= TIM_CR2_MMS_1; // MMS[2:0] = 010 -> Update event // 4. 配置频率:f=10kHz, PSC=35, ARR=99(72MHz/(36*100)=20kHz? 不对!APB1=36MHz) // 实际:f_APB1=36MHz, PSC=35→分频36倍, ARR=99→100计数, f=36MHz/(36*100)=10kHz TIM2->PSC = 35; TIM2->ARR = 99; TIM2->CCR1 = 50; // 50%占空比 TIM2->CCR2 = 0; // CH2不输出PWM,只用TRGO // 5. 关键:使能ARR预装载 & 更新中断(用于路径A备用) TIM2->CR1 |= TIM_CR1_ARPE | TIM_CR1_URS; // URS=1只响应UEV中断 TIM2->DIER |= TIM_DIER_UIE; // 6. 启动 TIM2->CR1 |= TIM_CR1_CEN; }3.3 从定时器(TIM3)初始化与同步握手
void TIM3_Slave_Init(void) { RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; // 1. 配置CH1为PWM输出 TIM3->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; TIM3->CCER |= TIM_CCER_CC1E; // 2. **核心:配置为外部时钟模式2** TIM3->SMCR |= TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1 | TIM_SMCR_SMS_2; // SMS=111 TIM3->SMCR |= TIM_SMCR_TS_0; // TS=001 -> ITR0 (TIM2 TRGO) // 3. ETR配置:滤波+极性+使能 TIM3->SMCR |= TIM_SMCR_ETF_1; // ETR滤波2个时钟周期 TIM3->SMCR |= TIM_SMCR_ETP; // ETR极性:高有效(上升沿触发) TIM3->SMCR |= TIM_SMCR_ETE; // 使能ETR // 4. ARR/CCR设置必须与TIM2完全一致! TIM3->ARR = 99; // 必须等于TIM2->ARR TIM3->PSC = 35; // 必须等于TIM2->PSC TIM3->CCR1 = 50; // 占空比可独立调,但初始值同步 // 5. 启动(此时TIM3不启动计数,等ETR上升沿触发) TIM3->CR1 |= TIM_CR1_CEN; }3.4 频率/占空比动态调节函数
// 输入:目标频率(Hz), 目标占空比(0~100) void Set_Sync_PWM(uint16_t freq_Hz, uint8_t duty_percent) { uint16_t psc, arr; uint16_t ccr; // 计算PSC和ARR:优先保证ARR≤65535,PSC≥0 uint32_t timer_clk = 36000000; // APB1=36MHz uint32_t period_us = 1000000 / freq_Hz; uint32_t ticks_per_period = (timer_clk / 1000000) * period_us; // 策略:先固定PSC=0~65535,找最小ARR for (psc = 0; psc <= 65535; psc++) { arr = ticks_per_period / (psc + 1); if (arr <= 65535 && arr > 0) break; } // 计算CCR:duty_percent / 100.0 * (arr + 1) ccr = (uint32_t)duty_percent * (arr + 1) / 100; // **原子操作:先停TIM2/TIM3,再写寄存器,再启** TIM2->CR1 &= ~TIM_CR1_CEN; TIM3->CR1 &= ~TIM_CR1_CEN; TIM2->PSC = psc; TIM2->ARR = arr; TIM2->CCR1 = ccr; TIM3->PSC = psc; // 必须同步 TIM3->ARR = arr; // 必须同步 TIM3->CCR1 = ccr; // 可不同,但本例同步 // 等待ARR更新完成(UIF置位) while (!(TIM2->SR & TIM_SR_UIF)); TIM2->SR &= ~TIM_SR_UIF; TIM2->CR1 |= TIM_CR1_CEN; TIM3->CR1 |= TIM_CR1_CEN; }4. 故障排查与波形验证:示波器下的真相
4.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 两路PWM完全异步,TIM3无输出 | PB4未正确配置为AFIO,或JTAG未禁用 | 用万用表测PB4对地电压,应为3.3V(TRGO空闲高) | 检查AFIO->MAPR配置,确认SWJ_CFG=10 |
| TIM3有输出但频率是TIM2的2倍 | TIM3的SMS位未设为111,误入编码器模式 | 读TIM3->SMCR,检查SMS[2:0]是否为0b111 | `TIM3->SMCR = (TIM3->SMCR & ~0x7) |
| 占空比调节时TIM2波形正常,TIM3跳变 | TIM3的ARR未同步更新,或ARPE未使能 | 示波器抓TIM3的CH1和ETR引脚,看ARR更新是否滞后 | 在Set_Sync_PWM()中强制TIM3->ARR = arr并等待UIF |
| 调节频率时两路相位差逐渐增大 | 主从定时器PSC值不一致,或ARR计算有浮点误差 | 对比TIM2->PSC和TIM3->PSC寄存器值 | 所有参数必须用整数运算,避免float |
4.2 示波器验证技巧
- 触发设置:将示波器触发源设为TIM2_CH1的上升沿,时基调至2μs/div,观察TIM3_CH1是否严格对齐。
- 测量精度:开启“相位测量”功能,读取两路信号的相位差。合格标准:< 100ns(对应36MHz时钟的3.6个周期)。
- 动态测试:用串口发送指令,让MCU在1kHz↔20kHz间阶跃切换,观察波形是否连续无跳变。若出现毛刺,90%概率是
ARPE未使能或UIF等待缺失。
我踩过的最大坑:在CubeMX生成的代码里,
HAL_TIM_PWM_Start()默认不使能ARPE。结果客户现场运行时,电机在加速过程中发出高频啸叫——示波器显示占空比每周期跳变±5%,根源就是ARR直写导致计数器中途重载。后来在MX_TIM2_Init()末尾硬加上htim2.Instance->CR1 |= TIM_CR1_ARPE;才解决。
5. 进阶思考:当同步需求升级时,你的架构还能撑多久?
这个方案在F103C8上完美实现了两路同步,但若需求变成“四路同步”或“带死区的互补PWM”,硬件资源就捉襟见肘了。TIM1虽是高级定时器,支持互补输出和死区插入,但它的TRGO无法作为TIM2/TIM3的ETR源(F103无此互联能力)。此时有两个现实路径:
路径一:用TIM1做主,TIM2/TIM3做从,但需额外GPIO模拟TRGO
TIM1更新时,用GPIO_SetBits()翻转一个IO(如PC0),再将PC0连到TIM2/TIM3的ETR。缺点是增加100ns级延迟,且占用宝贵IO。路径二:换用STM32F103ZET6
它有TIM1/TIM2/TIM3/TIM4/TIM5/TIM8,且TIM8的TRGO可路由至TIM2/TIM3的ETR(通过AFIO重映射)。这才是工业级同步的起点。
最后分享一个真实案例:某医疗设备公司用此方案控制两路压电陶瓷驱动器,要求相位差<50ns。他们最终在PCB上将PA1和PB4用5mm短线直连,并在TIM2初始化后插入__DSB(); __ISB();确保寄存器写入完成——这些细节,往往比算法本身更重要。
本文还有配套的精品资源,点击获取