做嵌入式这些年,我有个习惯:判断一个人单片机学得怎么样,先不看他背了多少寄存器,而是看他能不能把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 为例,系统主频 108MHz,TIMER2 挂在 APB1 上。默认的时钟配置会把 APB1 设为 54MHz,但定时器时钟会自动倍频成 108MHz。所以 f_timer 就是 108MHz。
如果 PSC = 107,ARR = 999,那么:
- 计数器时钟 = 108MHz / 108 = 1MHz,也就是每 1us 计数一次;
- PWM 频率 = 1MHz / 1000 = 1kHz,周期 1ms;
- 如果 CCR = 500,占空比就是 500 / 1000 = 50%。
这套公式算多了就会形成直觉。想要 20kHz 的 PWM,可以选 PSC = 53,计数器时钟变成 2MHz,ARR = 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 / (107+1) = 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 / (ARR+1)。
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调光、简易DAC
4.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 是 PA0,CH1 是 PA1,CH2 是 PA2,CH3 是 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 | 频率不对时舵机会抖动或不能到指定角度 |
| 简易DAC | 20kHz 以上 | 频率越低纹波越明显 |
这张表是我在实际项目中验证过的大致范围,不同器件会有差异,但作为起步参考完全够用。
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 练扎实,后面会轻松很多。