
做嵌入式这些年我有个习惯判断一个人单片机学得怎么样先不看他背了多少寄存器而是看他能不能把PWM这件事讲清楚。呼吸灯、电机调速、蜂鸣器发声、调光灯、舵机控制底层全是同一个东西——定时器输出PWM波。这是零基础国产GD32单片机编程入门系列的第六篇我会拿GD32F103做例子把PWM从原理到代码完整拆开手把手带你把第一个PWM工程跑起来源码可以直接抄走。这篇不需要你有多好的C语言基础但至少要知道GPIO怎么点灯、定时器大概是什么。如果你前面几篇都跟着走过应该很快就能吃透。1. 开始之前先把芯片选型和开发工具定下来1.1 芯片到底选哪颗GD32F103 和 GD32E230 的差别GD32F103 系列应该说是GD32里最经典、资料最全的型号Cortex-M3内核主频能跑到108MHz比同封装的STM32F103还要高一点。Flash、SRAM 的规格选择也很多网上随便一搜就是大把例程和踩坑记录。零基础入门我通常建议从 GD32F103C8T6 或 GD32F103C6T6 这类小封装芯片开始原因只有一个遇到问题时你能找到的参考资料最多。GD32E230 是后起之秀Cortex-M23内核主频虽然不如F103高但价格便宜、低功耗表现好官方现在主推的 GD32 Embedded Builder 对 E 系列支持得也更好。如果你是买新出的开发板很可能是 E230。不用慌PWM 的核心原理完全一样无非是定时器编号、引脚复用号有一点差别把F103学会之后换过去半天就能上手。我的建议是手里有哪颗就先用哪颗别在选型上花太长时间。单片机的学习瓶颈从来不是芯片型号而是你对外设工作机制的理解程度。1.2 Keil 还是 GD32 Embedded Builder固件库怎么选开发工具有三条路可以走。第一条路Keil MDK 加官方Pack。这是最成熟的方案就算你不喜欢 Keil 的编辑器也得承认它调试器的稳定性确实好。GD32的Pack在官网就能下到装上之后可以直接在设备列表里选GD32F103。网上绝大多数教程都基于这个组合遇到问题最方便搜答案。第二条路GD32 Embedded Builder。这是官方自己出的IDE基于Eclipse带图形化时钟配置和引脚配置界面有点类似 STM32CubeMX 那套思路。新手用它有一个好处时钟树、引脚复用这些容易出错的地方图形化界面会帮你挡掉一部分。不过它对老型号F103的支持不如新系列那么顺滑如果你是F103的板子不推荐作为主工具。第三条路VSCode 加 EIDE 插件。EIDE 是个嵌入式开发插件可以管理 GD32 的工程、下载、调试界面比 Keil 现代得多。适合已经对 VSCode 很熟悉的人。但零基础阶段我不建议一开始就折腾插件环境搭不好特别打击信心。固件库方面GD32官方提供的是标准固件库Firmware Library名字叫GD32F10x_Firmware_Library。它不像 STM32 的 HAL 库那样封装得特别厚重而是更接近半寄存器风格——每个外设一组函数函数的背后直接操作寄存器。好处是代码透明你调用一个函数就知道它在改哪个寄存器对理解底层特别有帮助。1.3 硬件清单和接线别到了下载那一刻才发现缺东西做PWM实验最低限度的硬件如下一块 GD32F103C8T6 核心板板载 LED 可有可无一个下载调试器ST-Link V2 或者 DAP-Link 都行GD32 可以像 STM32 一样用 SWD 接口调试一个发光二极管外加一个 220Ω 限流电阻一台逻辑分析仪或者示波器强烈建议配一个。几十块钱的 8 通道逻辑分析仪在这类实验里比万用表好用得多能看到真实的PWM波形。接线非常简单PA0 通过 220Ω 电阻接 LED 的正极LED 负极接 GND共地到核心板。为什么选 PA0因为 GD32F103 的 TIMER2 通道 0 默认复用引脚就是 PA0这是官方数据手册里写死的默认映射。有一点要注意核心板的板载 LED 不一定接在 PA0如果你直接用板载 LED 做实验要先查清楚原理图别在初始化代码里写 PA0、板子上的灯却在另一个引脚上那样折腾半天也看不到效果。2. PWM 到底是怎么产生的定时器内部的三个关键数字2.1 频率、占空比、极性一个类比全部说清PWM 的全称是 Pulse Width Modulation脉冲宽度调制。理解它只需要抓住三个参数频率、占空比、极性。拿人来类比呼吸频率就是频率每分钟呼吸多少次每次呼吸里吸气多长时间占整个呼吸周期的比例就是占空比极性则是问你算高电平时间还是算低电平时间。占空比 50% 的信号就是一个周期里有一半时间输出高电平一半时间输出低电平如果频率够高肉眼看到的就是一个稳定亮度——LED 的亮度其实是占空比决定的。频率也很容易感知。PWM 输出 1Hz 信号LED 就会一秒亮一秒灭输出1kHz信号LED看起来就是常亮的只是亮暗程度不同。这就解释了为什么呼吸灯效果不需要真的去调节电流大小——只要改变占空比LED 的平均功率就变了亮度自然跟着变。2.2 从计数器到矩形波CNT、ARR、CCR 三兄弟的分工定时器产生 PWM 的机制核心只有三个寄存器计数器 CNT、自动重装载值 ARR、通道比较值 CCR。CNT 像一个秒表每个定时器时钟周期加 1加到 ARR 之后清零重新开始。ARR 决定了一个周期的长度也就是计数到多少算到头。CCR 则是提前设好的一个比较点硬件每周期都会拿 CNT 和 CCR 做比较在 PWM0 模式下CNT 小于 CCR 的时候引脚输出有效电平默认高CNT 大于等于 CCR 的时候引脚输出无效电平默认低。用闹钟去理解更直观ARR 是 24 小时一天CNT 是时钟从 0 点走到 24 点再归零CCR 就是你在某一天设的闹钟时刻。闹钟响之前是一个状态响完之后是另一个状态。硬件自动完成这个比较过程完全不需要 CPU 干预这就是硬件 PWM和GPIO 软件翻转最本质的区别。2.3 频率和占空比的计算公式配上实例一次吃透定时器时钟经过预分频器之后才是计数器的实际计数时钟。设定时器输入时钟为 f_timer预分频值为 PSC那么计数器时钟就是f_ck f_timer / (PSC 1)PWM 的频率就是计数器每秒钟完成多少个周期的次数f_pwm f_ck / (ARR 1) f_timer / ((PSC 1) * (ARR 1))占空比则是 CCR 在一个周期里占的份额Duty CCR / (ARR 1) * 100%以 GD32F103 为例系统主频 108MHzTIMER2 挂在 APB1 上。默认的时钟配置会把 APB1 设为 54MHz但定时器时钟会自动倍频成 108MHz。所以 f_timer 就是 108MHz。如果 PSC 107ARR 999那么计数器时钟 108MHz / 108 1MHz也就是每 1us 计数一次PWM 频率 1MHz / 1000 1kHz周期 1ms如果 CCR 500占空比就是 500 / 1000 50%。这套公式算多了就会形成直觉。想要 20kHz 的 PWM可以选 PSC 53计数器时钟变成 2MHzARR 99频率就是 2MHz / 100 20kHz。CCR 设 10占空比就是 10%。有一点容易看走眼公式里的周期是 ARR 1 而不是 ARR因为计数器是从 0 开始数的ARR 999 代表一共数了 1000 个脉冲。同样的道理PSC 也要加 1 才是真实的预分频倍数。3. 第一个 PWM 工程呼吸灯完整源码逐行说明3.1 先把标准工程模板准备好我默认你已经有一套能编译下载的 GD32 工程模板如果还没有去官网下载GD32F10x_Demo_Suites或者从 GitHub 上拉一份直接把官方模板里的Template工程复制出来用。工程里一般已经有main.c、systick.c、gd32f10x_it.c这几个文件。我习惯把所有板级外设的初始化封装成bsp_xxx函数工程结构保持清晰。比如新建一个bsp_pwm.c和bsp_pwm.h专门放 PWM 相关的初始化代码。不要把所有代码都塞进 main.c这是好多新手容易养成的坏习惯。一个外设一个文件后面维护和移植都会轻松很多。3.2 初始化代码时钟、GPIO、定时器三件套PWM 初始化就三件事打开外设时钟、把引脚配置成复用功能、配置定时器时基和通道。看代码#include gd32f10x.h #include bsp_pwm.h void bsp_pwm_init(void) { /* 1. 使能 GPIOA 和 TIMER2 的时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_TIMER2); /* 2. 配置 PA0 为复用推挽输出供 TIMER2_CH0 使用 */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0); /* 3. 配置定时器时基108MHz / (1071) 1MHz */ timer_parameter_struct timer_init_para; timer_init_para.timer_prescaler 107; /* PSC 预分频 */ timer_init_para.timer_period 999; /* ARR 自动重装载 */ timer_init_para.timer_clock_division TIMER_CKDIV_DIV1; timer_init_para.timer_counter_direction TIMER_COUNTER_UP; timer_init_para.timer_aligned_mode TIMER_COUNTER_EDGE; timer_init_para.timer_auto_reload_shadow TIMER_AR_SHADOW_ENABLE; timer_init_para.timer_update_request_source TIMER_UPDATE_SRC_REGULAR; timer_init_para.timer_update_interrupt TIMER_UPDATE_INT_DISABLE; timer_init(TIMER2, timer_init_para); /* 4. 配置通道0为 PWM0 模式高电平有效 */ timer_oc_parameter_struct timer_oc_init_para; timer_oc_init_para.timer_oc_mode TIMER_OC_MODE_PWM0; timer_oc_init_para.timer_oc_polarity TIMER_OC_POLARITY_HIGH; timer_oc_init_para.timer_oc_idle_state TIMER_OC_IDLE_STATE_LOW; timer_channel_output_config(TIMER2, TIMER_CH_0, timer_oc_init_para); /* 5. 设置初始比较值 CCR 500即 50% 占空比 */ timer_channel_output_pulse_value_config(TIMER2, TIMER_CH_0, 500); /* 6. 使能定时器PWM 开始输出 */ timer_enable(TIMER2); }这段代码里有几个值得展开说明的点。首先是 GPIO 配置。GPIO_MODE_AF_PP是复用推挽输出意思是这个引脚的控制权交给片上外设而不是普通的 GPIO 输出寄存器。新手最容易漏的就是这一步漏了之后引脚大概率一直是高电平或者浮空程序里再怎么改 CCR 都没反应。其次是timer_auto_reload_shadow TIMER_AR_SHADOW_ENABLE这行是开启 ARR 的影子寄存器。影子寄存器可以理解为后台副本修改 ARR 或者 CCR 时真正的值要等到定时器溢出更新事件才生效。这种机制可以避免在一个周期中间突然改周期导致的波形撕裂。呼吸灯这种低速场合感觉不到差异但做电机控制的时候没有影子寄存器保护波形会出现一个异常的长周期脉冲那个很致命。整个 PWM0 模式的理解向上计数时CNT 从 0 开始增加小于 CCR 时输出高等于并超过 CCR 时输出低一直低到 CNT 溢出清零进入下一个周期。于是每周期里高电平时间 CCR 个计数周期周期总长度 ARR 1 个计数周期占空比正好是 CCR / (ARR1)。3.3 主循环让 CCR 动起来呼吸灯就出来了PWM 信号输出之后LED 已经亮了但亮度固定的。呼吸灯的本质是让占空比在 0% 到 99.9% 之间来回扫描。这个只需要在循环里不断修改 CCR 就行#include gd32f10x.h #include bsp_pwm.h #include systick.h extern void delay_1ms(uint32_t count); int main(void) { uint16_t duty 0; int8_t direction 1; bsp_pwm_init(); systick_config(); /* 提供 delay_1ms 的基础官方模板自带 */ while (1) { /* 更新占空比 */ timer_channel_output_pulse_value_config(TIMER2, TIMER_CH_0, duty); duty direction; if (duty 999) { duty 999; direction -1; } else if (duty 0) { direction 1; } delay_1ms(2); } }这段代码的逻辑很简单duty 从 0 开始每 2ms 加 1加到 999 之后反向减减到 0 再反向加回来。中间用timer_channel_output_pulse_value_config把新的占空比写进 CCR。整体呼吸周期大概是 2ms × 1000 × 2 4000ms也就是 4 秒完成一个从暗到亮再到暗的循环效果刚刚好。如果你手头的工程没有systick_config()也可以用最简单的软件延时代替只要把 delay 函数换成两层 for 循环观感差别不大。但我还是建议把这个延时函数用起来后面学输入捕获、串口通信都要用它早用早熟悉。3.4 下载观察怎么确认 PWM 真的输出对了程序烧进去之后先用逻辑分析仪夹住 PA0应该能看到一个频率 1kHz、占空比持续变化的方波。如果示波器显示的不是方波而是锯齿波或毛刺先别急着怀疑代码检查一下探头是不是夹错引脚了。没有仪器的话就用肉眼观察 LED。呼吸灯如果流畅均匀说明 PWM 工作正常。如果发现 LED 要么不亮要么全亮那大概率不是 PWM 的问题而是占空比没有正确写入或者 GPIO 复用配置错了。这里插一句逻辑分析仪几乎是调试单片机的必需品。哪怕是最便宜的那种 USB 八通道逻辑分析仪都能让你秒懂 PWM 波形到底长什么样。花几十块钱就能省下大量猜代码的时间这笔买卖非常划算。4. 学会基础之后立刻可以做的三件事电机调速、RGB调光、简易DAC4.1 电机调速为什么不能直接把电机接到 GPIO 上PWM 控制直流电机转速原理和调 LED 亮度完全一样占空比越高电机两端平均电压越大转速越快。但有一个绝对不能省的前提——电机不能直接接 GPIO也不能直接接单片机电源。电机是个大功率感性负载启动瞬间电流可能是额定电流的好几倍而且换向时会产生反向电动势。直接接 GPIO 的后果很可能是引脚烧毁或者复位或者整个板子供电被拉垮。正确做法是让 PWM 信号去控制一个开关管再用这个开关管去控制电机回路。最常用的就是 N-MOS 管加续流二极管的低边驱动也就是电机串在电源正极和 MOS 管漏极之间MOS 管源极接地栅极接 MCU 的 PWM 引脚。PWM 高电平的时候 MOS 管导通电机得电PWM 低电平的时候 MOS 管截止电机断电。电机两端反并联一个二极管给反向电动势提供泄放回路否则 MOS 管会频繁被击穿。选 MOS 管的时候注意栅极驱动电压要能匹配 3.3V 逻辑电平。很多大功率 MOS 管的开启电压非常高3.3V 根本不能完全导通管子会工作在放大区发热非常严重。新手最容易踩的坑就在这里——PWM 输出没问题电机也能转但 MOS 管烫得能煎鸡蛋。解决方案是选逻辑电平 MOS逻辑电平栅极驱动或者加一个专门的栅极驱动芯片。还有一个细节PWM 频率选 10kHz 到 20kHz 比较合适。太低电机会发出刺耳的滋滋声其实这个声音就是 PWM 频率落在人耳可听范围内太高则会增大 MOS 管开关损耗。4.2 RGB 调光三路 PWM 同时工作时的注意点RGB LED 本质上是三个独立的 LED 封装在同一个器件里红、绿、蓝各占一个引脚。想让它混出不同颜色就得用三路频率相同、相位一致的 PWM 分别控制三个通道。GD32 的单个通用定时器一般有四个通道比如 TIMER2 的 CH0 到 CH3可以同时输出四路独立的 PWM。但要注意它们默认映射的引脚TIMER2_CH0 是 PA0CH1 是 PA1CH2 是 PA2CH3 是 PA3。如果你画的板子把引脚接错了就得用重映射功能把通道引到别的地方去这又是一张查说明书的活。三路 PWM 混色的时候有个容易忽略的问题人眼对亮度变化的感知不是线性的。假如你把占空比从 0 等间隔加到 100%看起来会感觉前半段亮度变化很快、后半段明显变慢。想要视觉上均匀的渐变效果需要对亮度做一个 gamma 校正。工程上最简单的办法就是准备一张映射表把实际 CCR 值按比例换算。RGB 调光对 PWM 频率的要求比单色 LED 高一些建议用 5kHz 以上不然快速扫过物体时会看到明显的色彩分裂。4.3 用 PWM 做廉价DAC低通滤波输出模拟电压PWM 的平均电压等于 VCC × 占空比所以只要在 PWM 引脚后面接一个 RC 低通滤波器理论上就能得到一路模拟电压。2.5V 就是占空比 75% 的 PWM 经过滤波后的平均值。这个技巧在没有 DAC 外设的低端 MCU 上非常实用可以用来做简单的音频播放、电压模拟量输出、可调参考源。不过它有两个限制一是纹波RC 滤波不可能做到完全平滑频率越低纹波越大二是响应速度滤波器截止频率越低输出对新占空比的反应就越慢。所以实践中有个折中思路PWM 频率尽量提高RC 截止频率取在需求带宽的十倍以上这样纹波和响应都能接受。4.4 附不同应用场景的PWM频率选择参考应用场景常见频率频率选错时的表现LED 调光1kHz ~ 5kHz频率太低肉眼可见闪烁直流电机调速10kHz ~ 20kHz太低会听到电机啸叫太高增加开关损耗蜂鸣器2.5kHz ~ 4kHz偏离人耳敏感区响度不够舵机控制50Hz频率不对时舵机会抖动或不能到指定角度简易DAC20kHz 以上频率越低纹波越明显这张表是我在实际项目中验证过的大致范围不同器件会有差异但作为起步参考完全够用。5. 我在 GD32 上踩过的 PWM 相关的坑含排查链路5.1 GPIO 一直输出高电平第一个怀疑对象复用功能没配对有段时间我帮学弟调一个用 GD32F103 做的 PWM 程序代码逻辑怎么看都对但 PA0 测出来始终是 3.3V 高电平。百思不得其解最后发现他把 GPIO 配置成了GPIO_MODE_OUT_PP普通推挽输出而不是GPIO_MODE_AF_PP复用推挽输出。普通推挽输出模式下引脚电平由输出数据寄存器 ODR 决定定时器根本插不上手。这个问题我自己初学时也犯过因为心里想着PA0输出PWM结果下意识写了普通输出。排查思路很简单PWM 没输出时第一件事就是用逻辑分析仪测量引脚电平。如果是一个固定电平十有八九是复用配置不对赶紧回去检查 GPIO 模式。另外要提醒的是不同芯片的引脚复用号不一样。GD32F103 的 PA0 默认就是 TIMER2_CH0基本上不用复用号配置但换到其他系列比如 GD32E230可能要额外调用gpio_af_set指定引脚复用功能。所以网上抄代码的时候一定要先确认芯片系列外设一样不代表引脚复用方式一样。5.2 波形频率是预期的一半或者一倍检查定时器时钟源有次我做一个 20kHz 的 PWM算好参数下载下去逻辑分析仪一测频率变成了 10kHz。代码和寄存器都没算错问题出在定时器的输入时钟上。GD32 的定时器时钟不总是等于 APBx 总线时钟APB1 预分频不为 1 的时候定时器时钟会自动变成 APB1 的两倍。比如 APB1 是 54MHz定时器时钟就是 108MHz如果预分频被改成 1定时器时钟就变成 54MHz。我的程序里以为自己用 108MHz 计算实际定时器吃的是 54MHz频率自然减半。排查这类问题有个笨方法先把频率参数凑成整数比如期望 1kHz测出来是 500Hz那就基本能断定是时钟源算错了。修复方式有两个要么改system_gd32f10x.c里的时钟树配置要么在计算 PSC 和 ARR 之前确认一下定时器的实际输入时钟。建议所有认真玩 GD32 的人都养成一个习惯初始化定时器之前先读一下 RCC 相关的时钟配置寄存器心里有数再算参数。5.3 快速改 CCR 时波形出现异常毛刺影子寄存器的锅占空比不是不变的电机加速、呼吸灯渐变都需要快速改 CCR。如果你改得太随意很可能在波形上看到一个很窄的异常尖峰。原因在于新写入的 CCR 值如果在一个周期中间生效计数器和比较点的关系就会突然改变输出状态就乱了。解决办法就是前面提到过的影子寄存器机制。初始化时确认timer_auto_reload_shadow是开启的同时每次写 CCR 的库函数底层会处理好更新时机逻辑分析仪上就不会再看到毛刺。呼吸灯这个例子其实不太容易触发这个坑因为 CCR 每 2ms 才改一次即使当前周期中间生效也只是让当前周期的占空比略微偏离预期。但如果是电机控制一个周期里出现一个 100% 占空比脉冲电机可能抖一下严重的会过流。所以这个知识点宁可早点知道。5.4 下载失败、芯片好像被锁住一套完整的抢救流程GD32 开发中经常遇到下载失败尤其是 SWD 引脚被程序复用或者供电不稳定的情况下。我总结了一套抢救流程按顺序操作百分之九十都能救回来确认接线SWD 的 SWCLK、SWDIO、GND 三根线必须接对有些核心板引脚间距小杜邦线插错特别正常按住核心板复位键不放在 Keil 里点击下载等进度条出现后立刻松开复位键如果还是失败把 BOOT0 拉高重新上电此时芯片不会运行用户程序再用工具擦除擦除成功后恢复 BOOT0 到低电平重新下载。这个流程对 GD32 和 STM32 几乎通用。另外EIDE 或 Keil 里的下载设置记得选 SWD 模式而且速度不要设太高尤其当你用的是杜邦线连接时过高的 SWD 时钟非常容易导致连接失败。我在一次驱动 LED 屏的项目里就因为 SWD 速度设成了 10MHz老是出现could not connect的报错最后降到 1MHz 问题才消失。有些问题不是代码问题是工具链的脾气问题。最后说两句PWM 是 GD32 里少有的配置对了立刻见效果的外设它对新手最大的价值不在于多高级而在于它能让你第一次真切感受到寄存器里写的每一个数字和数据手册里的波形是一一对应的。频率、占空比、极性这些概念一旦你用逻辑分析仪亲眼看过这辈子都不会忘。下一篇我会沿着定时器的方向继续往深走写输入捕获和频率测量。到时候你会发现同一个定时器把比较方向反过来用又是一片完全不同的应用场景。先把 PWM 练扎实后面会轻松很多。