简介:实验22 PWM DAC实验是一套面向STM32嵌入式开发者和硬件初学者的完整工程资源,聚焦PWM与DAC的协同使用,解决如何利用定时器输出占空比可调方波、并借助DAC生成连续模拟电压的问题。PWM通过占空比变化等效输出电压,DAC直接输出模拟量,两者配合可产生更平滑的波形,适用于波形生成、电压控制等模拟输出场景。压缩包采用RAR格式,共63个文件,核心由30个h头文件和22个c源文件构成,另含启动文件、hex固件、Keil工程与调试配置、串口调试组件和说明文档,整体仅238KB,目录按USMART、SYSTEM、HARDWARE等功能模块划分,便于按需查阅和二次开发。已有411人学习/下载,实验覆盖GPIO复用配置、定时器预分频与计数模式、PWM通道占空比设定、DAC触发与数据写入等关键环节,全部代码可导入Keil直接编译学习。通过示波器观察波形并调整参数,可直观理解模拟信号生成过程;工程中保留的外设驱动与调试信息,也为后续传感器采集、波形合成或闭环控制项目提供了可直接迁移的代码基础和排错思路,适合自学与实验教学两用。
1. 为什么用 STM32 的 PWM 当 DAC:先把概念立住
很多 STM32 开发板实验里,标题写着“实验22 PWM DAC实验”或者“STM32PWMDAC实验”,第一次看到的人多少会疑惑:PWM 不是数字方波吗,DAC 是模拟输出,两个词怎么能并列。这里的关键是:方波经过低通滤波后,直流分量等于高电平乘以占空比,占空比改变,等效输出电压就改变。只要对更新速率要求不高,完全可以用一路 PWM 加一两个阻容元件,当作一路便宜的单通道 DAC 用。常见应用包括 LED 亮度调节、风扇恒速控制、简单波形发生器,甚至让单片机去控制外部的 0~3.3V 可调电平接口。这篇文章会从 PWM 转 DAC 的数学关系入手,再讲 STM32 定时器配置、RC 滤波与 PCB 布局,最后给出一套能直接用的 ADC 校准与故障保护做法,适合还没到上专用 DAC 芯片阶段的嵌入式项目。
2. PWM DAC 的数学原理:占空比计算公式与分辨率边界
2.1 平均电压公式:为什么占空比能当模拟输出
PWM 信号只有高电平 V_H 和低电平 V_L 两个状态,若 V_L = 0,经过低通滤波器后,输出稳定在长时间平均值:
V_out = V_H × D = V_H × CCR / (ARR + 1)在 STM32 定时器里,ARR是自动重装载值,CCR是比较寄存器值。只改CCR就能让占空比在线变化,输出电压随之线性变化。这个线性关系成立的前提是 RC 滤波的时间常数远大于 PWM 周期,并且后级负载阻抗远大于 RC 电阻,否则负载会分压,输出会偏离计算值。
实际写代码时,不要在代码里用百分比去算,而是直接算CCR。比如目标电压是 1.65V,参考电压是 3.3V,ARR 设为 7199,那么CCR = 1.65 / 3.3 × 7200 = 3600。这样做的好处是占空比永远不会因为浮点舍入误差出现跳变,而且每一步都是整数,示波器检查波形时更容易看出阶梯是否均匀。
这里有一个容易踩的坑:占空比公式是CCR / (ARR + 1),不是CCR / ARR。ARR 等于 7199 时,如果用后者计算,CCR 最大只能到 7199,输出永远达不到 100% 占空比;虽然误差很小,但在做满量程校准时会发现高段非线性。
2.2 滤波截止频率和纹波:RC 时间常数怎样权衡
一阶 RC 低通的截止频率为:
f_c = 1 / (2 × π × R × C)PWM 基频是 f_pwm,经过 RC 后,基频和谐波被衰减,输出纹波近似为:
V_ripple ≈ V_H × D × (1 - D) / (f_pwm × R × C)从公式可以看出,纹波与 R×C 成反比,与 PWM 频率成反比。但 R×C 越大,建立时间越长;R 太大又会被后级负载分压,所以不能盲目加大 RC。
常见做法是把截止频率设为 PWM 频率的 1/10 到 1/50。下面这组参数按 3.3V、占空比 50% 估算,可直接用于 STM32 实验板:
| PWM频率 | 目标截止频率 | 电阻 R | 电容 C | 理论纹波(D=0.5) |
|---|---|---|---|---|
| 1kHz | 100Hz | 1.6kΩ | 1.0μF | 约70mV,后级需要再滤波 |
| 10kHz | 1kHz | 1.6kΩ | 0.1μF | 约7mV,建立时间约0.8ms |
| 50kHz | 2kHz | 1kΩ | 0.1μF | 约1.9mV,建立时间约0.3ms |
| 100kHz | 3kHz | 510Ω | 0.1μF | 约1.1mV,建立时间约0.17ms |
纹波估算没有把电容 ESR 和输出负载算进去,实际数值会略大。只要能控制在 ADC 的 1~2 LSB 以内,一般不影响使用。如果你要驱动 LED,这点纹波根本看不出来;如果要给传感器供电作偏置电压,则要尽量选 PWM 频率高的一档。
2.3 分辨率边界:STM32 定时器时钟频率是上限
STM32 的 PWM 计数器由定时器时钟驱动,F103 系列内部是 72MHz。占空比每一步对应计数器一个时钟周期,分辨率位数和 PWM 频率的关系是:
bits = log2(ARR + 1) f_pwm = f_clk / ((PSC + 1) × (ARR + 1))PSC是预分频系数。ARR 越大,分辨率越高,但 PWM 频率越低。我们可以用一小段 Python 直接算,避免手算误差:
import math clk = 72_000_000 # STM32F103 定时器时钟 for arr in (7199, 3599, 719, 99): f_pwm = clk / ((0 + 1) * (arr + 1)) bits = math.log2(arr + 1) print(f"ARR={arr:5d} -> {f_pwm:8.1f}Hz, {bits:5.2f}bit")这段代码把 PSC 固定为 0,打印出的结果很直观:ARR=7199 时 PWM 频率是 10kHz,分辨率约 12.8 位;ARR=99 时频率到 720kHz,但分辨率只有 6.7 位。所以不要凭空追求“高分辨率 PWM DAC”,先看目标的更新率和纹波要求。如果只做直流电压输出,PWM 频率 1kHz 都够,分辨率可以做到 16 位;如果要做音频波形,20kHz 以上才够,分辨率通常要降到 8~10 位。
另外,定时器时钟若是内部 RC 振荡器,频率会随温度漂移,直接影响 RC 滤波后的直流电压精度。位置允许时,尽量用外部晶振作为定时器时钟源,这样 PWM 的“平均电压”才是真正稳定的。
3. STM32 上配 PWM DAC:定时器配置、代码和 DMA 更新
3.1 选定时器和引脚:先定 PWM 频率再选通道
做 PWM DAC 不需要高级定时器的死区功能,普通通用定时器就够用。常见引脚映射如下:
| 定时器 | 通道1引脚(F103) | 通道2引脚 | 备注 |
|---|---|---|---|
| TIM2 | PA0 | PA1 | 通用定时器,常用 |
| TIM3 | PA6 | PA7 | 可与其他输出复用 |
| TIM4 | PB6 | PB7 | 通用定时器 |
| TIM1 | PE9 | PE11 | 高级定时器,带故障保护功能 |
选择通道前,先看开发板引出的引脚,再查数据手册的 AF 映射表。PA0 和 PA1 是很多 STM32 板卡默认引出的,优先用 TIM2_CH1 会方便接线。选好通道后,下一步才是定 ARR 和 PSC。
比如要输出 50kHz 的 PWM,定时器时钟 72MHz,把 PSC 设为 0,ARR 设为 1439,得到分辨率约 10.5 位。这个分辨率对 LED 调光够用,但如果要输出 12 位精度,则 PWM 频率就要降到 18kHz 以下。实际项目里,我一般先定分辨率,再用余下的 ARR 范围反推最高 PWM 频率。
3.2 用 HAL 库输出一路可调 PWM
CubeMX 生成工程后,最小配置看起来像下面这样。下面代码以 TIM2_CH1 为例,输出 10kHz、50% 占空比的 PWM:
TIM_HandleTypeDef htim2; TIM_OC_InitTypeDef sConfigOC; GPIO_InitTypeDef GPIO_InitStruct; __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); htim2.Instance = TIM2; htim2.Init.Prescaler = 0; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 7199; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 3600; // 占空比 3600/7200 = 50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);代码里Prescaler = 0,Period = 7199,所以 PWM 频率是72MHz / 7200 = 10kHz。Pulse就是 CCR,初始值 3600 对应 50% 占空比。如果之后要改变电压,直接用:
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 5000);这个宏会把 CCR 改为 5000,占空比变成 5000/7200≈69.4%。要注意CCR必须小于等于ARR,否则定时器会按照满周期输出,你在示波器上看到的是稳定的高电平,丢失了所有 PWM 信息。还有一个细节:GPIO 速度不要随便设成VERY_HIGH,否则高速翻转会造成地弹,反而增加纹波。
3.3 用 PWM+DMA 把 CCR 值批量刷新,提高更新率
如果要用 PWM DAC 输出正弦波、三角波,或者每个 PWM 周期都需要更新占空比,再用__HAL_TIM_SET_COMPARE一个个写就会占用 CPU。常见做法是让定时器更新事件触发 DMA,把内存里的数组批量搬运到 CCR 寄存器。
CubeMX 里配置 TIM2 通道 1 的 DMA 请求,方向设为“内存到外设”,模式设为“循环”,数据宽度用半字。HAL 启动调用是:
uint16_t ccr_table[256]; // 波形数据预先填入 ccr_table HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t*)ccr_table, 256);这里的关键点是数据宽度必须用 16 位半字。如果你在 CubeMX 里选了 Word,DMA 会把高 16 位当作另一个数据,CCR 会出现跳变,波形上出现随机毛刺。启动后,DMA 每到一个 PWM 更新事件就搬一个数到 CCR,表走完一圈硬件自动循环,不需要中断参与。
用这个方式输出正弦波时,表长 256 点,PWM 频率 50kHz,输出基频就是50kHz / 256 ≈ 195Hz。要改变输出频率,可以修改 PWM 频率,也可以改表长度。修改表长度需要重启 DMA,否则会进入不确定状态;修改 PWM 频率可以直接改 ARR,但要重新计算占空比数值,否则电压范围会跟着变。
4. RC 滤波器选型与布局:PWM DAC 精度从电路上决定
4.1 先定 R 再定 C:负载阻抗决定滤波器能不能用
第 2 章算出了 RC 的理论值,但实际选型还要看后级负载。RC 滤波器的输出阻抗等于 R,如果后级输入阻抗只有 10kΩ,而 R 用了 100kΩ,分压就会让输出比预期低很多。下面的表是一般选择顺序:
| 应用场景 | 建议做法 | 原因 |
|---|---|---|
| 后级是高阻输入 | R 用 1k~10kΩ | 分压误差小于 1% |
| 后级有 10kΩ 负载 | R 降到 1kΩ 以下,或者加运放跟随器 | 避免负载拉低输出 |
| 需要驱动长线 | RC 直接输出,再加一级缓冲放大器 | 提高带载能力 |
| 需要更低纹波 | 两级 RC,后级电阻取前级 3~5 倍 | 第二级减轻第一级负载 |
我先说明一个常见误用:一些人会把两个一样的 RC 串起来,想让纹波变成原来的平方。但两级 RC 之间如果没有缓冲,第二级的输入阻抗会并联到第一级的电容上,实际截止频率不是简单相乘,反而出现台阶。要这样设计,第一级电阻要小,第二级电阻要大,才能获得接近理论的滤波效果。
如果输出要接 ADC 采样脚,还要注意 STM32 的 ADC 采样电容。ADC 采样瞬间会从 RC 输出抽走电荷,造成电压跌落。解决办法是增大 ADC 采样时间,或者把 RC 电阻减小到几百欧姆,让电容在采样间隙恢复回来。对 10kΩ 采样频率不高的场合,R=1kΩ,C=0.1μF,ADC 采样时间设为 239.5 周期,基本够用。
4.2 PCB 布局:规避时钟抖动与电源噪声的 3 个要点
PWM DAC 的电路结构很简单,但布局随意的人经常得到比理论计算高得多的纹波。这里的核心原因不是 RC 选得不对,而是地回路和数字开关噪声被采样到输出点。
第一个要点:PWM 输出线要短,并且不要和 RC 之后的模拟线平行走线。PWM 信号在每次翻转时会对旁边的模拟线形成容性耦合。如果模拟线必须穿过 PWM 线,走线时加一根地线隔开,不要只依赖铺铜。
第二个要点:RC 的接地端要单独回到单片机模拟地。很多实验板把模拟地和数字地直接连成一片,这本身问题不大,但关键是 RC 地不能接到电机 MOS 管、LED 驱动这类大电流开关旁边。大电流回流路径经过的地铜箔会产生毫伏级压差,这些压差会串联进输出电压,表现为离散的毛刺。
第三个要点:ADC 采样点放在 RC 电容引脚处,采样线两侧不要走高频数字线。采样点离电容引脚越远,寄生电感越大,采样到的噪声越多。如果空间允许,在采样点放一个 10nF 的陶瓷电容到模拟地,可以吸收高频噪声。不过这个电容会和 RC 电容并联,改变截止频率,所以加之前要把 RC 电阻适当调大。
另外,时钟抖动对 PWM DAC 的影响往往被人忽略。定时器时钟来自内部 RC 时,频率本身会呈周期性抖动,表现在输出电压上是几十微伏到几百微伏的低频波动。用示波器看 PWM 频率稳定,不代表 RC 后的直流没有抖动。精调电压时,把定时器时钟源切到外部晶振。
4.3 用 ADC 校准:吃掉零点偏移和非线性
STM32 片内 DAC 有时会输出带死区的占空比,RC 元件也有 1%~5% 的误差,所以实测电压和计算值之间会有偏差。对一个 10kHz、12 位的 PWM DAC,理论阶梯是 0.8mV,而电阻电容误差可能带来几十毫伏的偏差,远超一个 LSB。这时用 ADC 回采校准是最快的办法。
下面的例子用两点线性校准。先设置两个不同的 CCR,分别读 ADC 的平均值,再根据目标 ADC 码值反推需要写入的 CCR:
static uint16_t dac_reg_lo, dac_reg_hi; static uint16_t adc_val_lo, adc_val_hi; void pwm_dac_calibrate(TIM_HandleTypeDef *htim, uint32_t channel) { __HAL_TIM_SET_COMPARE(htim, channel, 512); // 低点 HAL_Delay(20); adc_val_lo = read_adc_average(16); __HAL_TIM_SET_COMPARE(htim, channel, 4095); // 高点 HAL_Delay(20); adc_val_hi = read_adc_average(16); dac_reg_lo = 512; dac_reg_hi = 4095; } uint16_t pwm_dac_set_voltage(TIM_HandleTypeDef *htim, uint32_t channel, uint16_t adc_target) { float slope = (float)(dac_reg_hi - dac_reg_lo) / (adc_val_hi - adc_val_lo); float bias = dac_reg_lo - slope * adc_val_lo; uint32_t reg = (uint32_t)(slope * adc_target + bias + 0.5f); if (reg > 4095) reg = 4095; __HAL_TIM_SET_COMPARE(htim, channel, reg); return (uint16_t)reg; }这段代码假设 ARR 设为 4095,CCR 范围是 0~4095。read_adc_average是自己写的函数,连续采样 16 次求平均,目的是去掉 RC 纹波和 ADC 本身的量化噪声。校准过程需要等 RC 到达稳态,HAL_Delay 的 20ms 足够一阶 RC 完成 25 个时间常数,实际可以缩短到 5ms。
两点校准能纠正增益误差和零点偏移,但纠正不了严重非线性。如果校准后发现中间段偏差超过 2 个 LSB,先检查 RC 电容是否使用了 X5R 类型的大容量电容,X5R 的直流偏压特性会让有效电容值随电压下降,导致截止频率漂移。这个问题在 4.7μF 以上的陶瓷电容上特别明显,换成 C0G 电容或薄膜电容会好很多。
5. 把 PWM DAC 用到执行器上:故障保护与波形输出技巧
5.1 给 PWM DAC 加故障保护:Break 事件和死区
普通 PWM 输出在程序死机时会保持最后一次 CCR 值,如果这个值对应的电压恰好是执行器的危险电压,就可能烧毁设备。所以凡是 PWM DAC 输出到电机、发热丝、大功率 LED 的地方,都建议加硬件保护,不依赖软件关闭。STM32 的高级定时器 TIM1/TIM8 提供 Break 输入,可以在一两个时钟周期内把输出拉回空闲电平。
CubeMX 里启用 Break 功能后,需要配置极性、空闲状态和死区:
TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig = {0}; sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSI_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_1; sBreakDeadTimeConfig.DeadTime = 10; sBreakDeadTimeConfig.BreakState = TIM_BREAK_ENABLE; sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_LOW; HAL_TIMEx_ConfigBreakDeadTime(&htim1, &sBreakDeadTimeConfig);BreakPolarity设为低电平有效,表示 BKIN 引脚被拉低时 PWM 立即关闭。DeadTime在 H 桥驱动里用来防止上下管直通,在 PWM DAC 场景下可以设小一些,避免每次换向时输出出现短暂毛刺。要注意 Break 功能只在高级定时器上有,普通 TIM2/TIM3 没有这个寄存器。如果你的主控只有普通定时器,就得用外部比较器或者看门狗额外实现保护。
5.2 用 DMA 查表生成正弦波,并检查有效位数
PWM DAC 最常用的进阶场景是生成正弦波。先建一个 256 点的查找表,再通过 DMA 循环送数:
#define SIN_TABLE_SIZE 256 uint16_t sin_lut[SIN_TABLE_SIZE]; void generate_sin_lut(float vref) { for (int i = 0; i < SIN_TABLE_SIZE; i++) { float v = vref / 2.0f + (vref / 2.0f) * sinf(2.0f * 3.1415926f * i / SIN_TABLE_SIZE); sin_lut[i] = (uint16_t)(v * (4095.0f / 3.3f)); } }这里假设 ARR=4095,相当于 12 位 PWM。输出正弦波的中心电平是 Vref/2,峰峰值接近 Vref。之后调用HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t*)sin_lut, 256),硬件就会持续输出该波形。验证波形时,用示波器看两个相邻台阶的间隔必须完全一致,如果出现间隔错位,先查 DMA 配置的外设地址是否误设成了增量模式,这个问题比电路噪声更难排查。
PWM DAC 的有效位数不一定等于 ARR 的位数。用示波器看占空比高段时,如果每两个相邻 CCR 值对应的电压间隔忽大忽小,说明存在死区或者时钟抖动,这时应当把有效位数往下调 1~2 位,或者在固件里去掉最后几位不用的数据。比如 ARR=4095,实际只取高 10 位,低两位强制清零,输出会稳定不少,代价是电压分辨率从 12 位降到 10 位,但对大多数反馈控制来说已经够用。
本文还有配套的精品资源,点击获取