1. 为什么三角波生成值得单独拿出来讲
很多人玩STM32的DAC,第一步都是照着手册配个DHR寄存器,让DAC输出一个固定电压,用万用表一量,对了,收工。但真正到了要做信号源、做扫频、做传感器激励、做音频测试这些场景的时候,固定电压就不够用了,你需要的是一个频率可调、幅度可控、波形干净的模拟信号。三角波是其中最有代表性的一种——它不像正弦波那样需要查表或者用DSP算,也不像方波那样只有高低两个电平,它天然适合用DAC配合定时器来"画"出来。
我最初接触这个需求,是想做一个简易的线性调频信号源,用来测一段超声换能器的响应。当时想得很简单:DAC输出三角波,定时器控制频率,改个ARR就完事了。结果实际跑起来才发现,频率一高波形就变形,幅度一改直流偏置就跟着跑,用示波器一看全是台阶和毛刺。后来花了差不多两周时间,把定时器触发、DMA搬运、DAC数据对齐方式、参考电压这几个环节全部捋了一遍,才算是把频率和幅度这两个参数真正"解耦"开来。
这篇文章就是把这套东西完整讲清楚。核心关键词是STM32、DAC、定时器、三角波、频率与幅度控制。适合已经会点灯、会配GPIO、对定时器有基本概念,但还没把DAC和定时器联动起来的朋友。我会从原理讲到代码,从参数计算讲到实测踩坑,尽量让你看完就能在自己的板子上复现。
需要提前说明的是,我用的芯片是STM32F4系列(F407),但F1、F3、L4这些系列的DAC和定时器触发逻辑大同小异,代码框架可以直接迁移,差异点我会在对应位置标出来。
2. 三角波到底是怎么被"造"出来的
2.1 DAC输出三角波的两种思路
在动手写代码之前,得先想清楚一件事:三角波不是DAC自己会生成的,DAC只是一个"翻译官",你把数字量给它,它给你对应的模拟电压。所以三角波的形状,本质上是你喂给DAC的那串数字的走势决定的。
第一种思路是查表法。提前在内存里存好一个周期三角波的采样点数组,比如256个点,从0递增到4095再递减回0,然后定时器每隔固定时间触发DMA搬一个点到DAC。这种方法的优点是波形精度完全由表决定,想改成正弦、锯齿都只是换张表的事。缺点是占内存,而且频率调整只能靠改定时器周期,表本身是死的。
第二种思路是实时计算法。不用表,在定时器中断或者DMA完成回调里,用代码实时算出当前应该输出的DAC值。三角波的计算极其简单,就是一个线性递增再线性递减的过程,用两个变量就能搞定。优点是省内存、频率和幅度可以完全用变量控制,缺点是中断频率高的时候CPU开销大。
我最终选的是查表+DMA+定时器触发的组合。原因很实际:三角波虽然计算简单,但如果你用中断去算,每次中断都要进出栈、判断方向、更新DHR,在几十kHz的更新率下CPU基本就被占满了。而DMA搬运是硬件行为,CPU只需要在缓冲区过半和完成的时候处理一下,剩下的时间可以干别的事。这个选择在后面做多通道输出的时候优势更明显。
2.2 定时器触发DAC的硬件链路
这是整个方案的核心机制,必须讲透。STM32的DAC有一个特性:它可以被外部事件触发转换,而不是只能靠软件写DHR。这个"外部事件"最常用的就是定时器的TRGO输出。
整条链路是这样的:定时器按照你设定的频率计数,每次溢出(或者比较匹配)的时候,产生一个TRGO信号,这个信号通过内部连线送到DAC的触发输入端,DAC收到触发后,把DHR寄存器里的值搬到DOR寄存器,DOR的值经过电阻网络转换成模拟电压输出。整个过程不需要CPU参与,是纯硬件行为。
这里有个关键点很多人会忽略:DAC的触发源选择。在F4系列里,DAC_TRIGGER_T6_TRGO、DAC_TRIGGER_T3_TRGO、DAC_TRIGGER_T7_TRGO等等,不同的DAC通道能选的触发源是不一样的。比如DAC通道1可以选TIM2、TIM4、TIM5、TIM6、TIM7、TIM3、TIM8的TRGO,而通道2的选择又略有不同。选错了触发源,DAC根本不会动,你会以为是代码问题,其实是硬件连线没对上。
还有一个坑是TRGO的触发时机。定时器的TRGO可以配置成多种事件:更新事件、比较匹配、比较OC1REF等等。对于三角波生成,我们通常用更新事件(Update Event),也就是计数器溢出的时候。但如果你用的是中央对齐模式,更新事件的时机又不一样,这个后面讲频率计算的时候会详细说。
2.3 数据对齐方式对波形的影响
DAC的数据寄存器有12位有效数据,但寄存器是32位的,所以存在一个对齐问题。STM32提供了三种对齐方式:12位右对齐、12位左对齐、8位右对齐。
右对齐的意思是,你写的值直接就是0到4095,对应0到VREF。左对齐的意思是,你写的值的高12位有效,低20位被忽略,实际值等于你写的值右移20位。8位右对齐则是只用低8位,精度降到256级。
对于三角波生成,强烈建议用12位右对齐。原因很简单:三角波的线性度直接取决于你的量化级数,4096级和256级的波形质量差距是肉眼可见的。左对齐虽然也能用,但在做幅度调制的时候,计算会多一步移位,容易出错。我一开始图省事用了左对齐,结果调幅度的时候怎么算都不对,后来统一改成右对齐,世界就清净了。
3. 频率精准控制:从ARR到实际输出频率的完整推导
3.1 定时器频率计算公式的来龙去脉
要让三角波的频率精准,首先得把定时器的输出频率算准。STM32定时器的基本公式是:
f_trgo = f_timer_clk / ((PSC+1) * (ARR+1))
其中f_timer_clk是定时器的时钟源频率,PSC是预分频器,ARR是自动重装载值。这个公式适用于向上计数模式下的更新事件触发。
以F407为例,TIM6挂在APB1总线上。如果系统时钟是168MHz,APB1的分频系数是4,那么APB1的时钟是42MHz。但这里有个容易搞错的地方:当APB的分频系数不为1时,定时器的时钟会是APB时钟的2倍。所以TIM6的实际时钟是84MHz,不是42MHz。这个细节如果搞错,算出来的频率会差一倍。
假设我们要生成1kHz的三角波,那么定时器的触发频率应该是1kHz乘以一个周期内的采样点数。比如一个三角波周期用100个点来描述,那触发频率就是100kHz。代入公式:
100000 = 84000000 / ((PSC+1) * (ARR+1))
取PSC=0,则ARR+1 = 840,ARR = 839。
这里就引出一个权衡:采样点数越多,波形越平滑,但定时器频率越高。100个点对于1kHz的三角波来说,每个周期只有100个台阶,用示波器看还是能看出阶梯感。如果你想要更平滑的波形,可以增加到256个点,那触发频率就变成256kHz,ARR就要相应减小。但ARR太小的话,定时器的分辨率会下降,频率调节的粒度会变粗。
3.2 采样点数与波形质量的取舍
我实测过几组不同的采样点数,用示波器观察三角波的阶梯感:
| 每周期采样点数 | 触发频率(1kHz输出时) | 波形观感 | CPU/DMA负载 |
|---|---|---|---|
| 32 | 32kHz | 阶梯明显,像楼梯 | 极低 |
| 64 | 64kHz | 轻微阶梯,可接受 | 低 |
| 100 | 100kHz | 比较平滑 | 中等 |
| 256 | 256kHz | 非常平滑 | 较高 |
| 512 | 512kHz | 几乎看不出阶梯 | 高,DMA压力大 |
从实际项目角度,100到256个点是比较甜的区域。低于64个点,三角波的斜边会变成明显的阶梯,如果你后面还要对这个信号做滤波或者比较,阶梯会引入额外的谐波。高于512个点,DMA的搬运频率太高,在F4上虽然还能跑,但如果你同时还在做ADC采集或者串口通信,总线仲裁会开始抢资源。
还有一个隐藏问题:DMA缓冲区的长度最好是2的幂次。虽然STM32的DMA支持任意长度的缓冲区,但用2的幂次(如128、256)在计算半传输和全传输中断时更方便,也更容易和定时器的ARR配合。我一般用256点,缓冲区开两个,做双缓冲。
3.3 频率微调时的参数重算
实际项目中,频率往往不是固定的,需要动态调整。比如做扫频信号源,频率要从1kHz慢慢扫到10kHz。这时候你不能每次都重新算PSC和ARR然后重新初始化定时器,那样会有中断和毛刺。
正确的做法是固定PSC和采样点数,只改ARR。因为ARR是可以随时写入的,而且STM32的定时器支持ARR的预装载(ARPE),你写入的新值会在下一个更新事件生效,不会产生截断的脉冲。
但这里有个精度问题:ARR是整数,当你需要的频率不是整数分频能得到的值时,实际输出频率会有误差。比如你要输出997Hz,算出来的ARR可能是842.3,取整成842,实际频率就变成了997.6Hz。对于大多数应用这个误差可以接受,但如果你要做精确的频率合成,就需要用定时器的预分频器做小数分频,或者用更高的定时器时钟来减小量化误差。
我的经验是:先确定你能接受的频率误差,再反推需要的定时器时钟和ARR范围。如果误差要求是0.1%,那ARR至少要1000以上。如果误差要求是1%,ARR在100以上就够了。
4. 幅度精准控制:不只是改一个数字那么简单
4.1 DAC输出幅度与参考电压的关系
DAC的输出电压公式是:
V_out = V_DHR / 4095 * V_REF
其中V_DHR是你写入数据寄存器的值(0到4095),V_REF是参考电压。在大多数STM32开发板上,V_REF就是VDDA,通常是3.3V。所以当你写4095的时候,输出接近3.3V;写0的时候,输出接近0V。
但这里有几个现实问题。第一,DAC的输出范围不是满轨的。手册上会写,DAC输出缓冲器开启时,输出范围大约是0.2V到VDDA-0.2V。也就是说你写0,实际输出可能是0.2V而不是0V;写4095,输出可能是3.1V而不是3.3V。这个"死区"在需要精确幅度的场合必须考虑。
第二,输出缓冲器的使能会影响带载能力。DAC的输出缓冲器可以开也可以关。开了之后驱动能力强,但输出范围会缩小;关了之后输出范围大,但驱动能力弱,接个负载电阻电压就掉。我一般建议开启输出缓冲器,然后在软件里做幅度补偿,把死区扣掉。
4.2 用查表法实现幅度可调
如果你用的是查表法,幅度控制其实很简单:把表里的值乘以一个幅度系数就行了。但要注意,这个乘法要在生成表的时候做,而不是在DMA搬运的时候做。因为DMA只负责搬数据,不会帮你做运算。
具体做法是:先定义一个标准三角波表,值域是0到4095,中心点在2048。然后根据你想要的幅度,算出实际的峰值:
peak = 2048 * amplitude_ratio
比如你想要输出幅度为1V峰峰值的三角波(相对于3.3V参考),那amplitude_ratio = 1.0/3.3 ≈ 0.303,peak ≈ 620。然后生成新表的时候,值域就是2048-620到2048+620。
但这样有个问题:直流偏置固定在V_REF/2。如果你需要调整偏置,比如让三角波在0.5V到1.5V之间摆动,那就需要同时调整中心点和峰值。公式变成:
value = center + (original_value - 2048) * ratio
其中center是你想要的直流偏置对应的DAC值,ratio是幅度比例。
4.3 实时计算法的幅度控制
如果你不想占内存,用实时计算法,那幅度控制就是在每次更新DAC值的时候做一次乘加运算。三角波的实时计算逻辑是这样的:
// 假设direction为1表示上升,-1表示下降 // step是每个采样点的增量 if (direction > 0) { current += step; if (current >= peak) { current = peak; direction = -1; } } else { current -= step; if (current <= -peak) { current = -peak; direction = 1; } } dac_value = center + current;这里的step决定了三角波的斜率,也就是频率。peak决定了幅度,center决定了偏置。三个参数完全独立,调哪个都不会影响另外两个。这是实时计算法最大的优势。
但实时计算法的代价是CPU开销。每次DAC更新都要执行这段代码,如果更新率是256kHz,那CPU每秒要执行25.6万次这段逻辑。在168MHz的F4上,如果这段代码编译后是20个周期,那占用大约3%的CPU。听起来不多,但如果你还有其他中断和任务,累积起来就不可忽视了。
5. 完整实现:从CubeMX配置到代码落地
5.1 时钟树和定时器参数配置
我用的是STM32F407,系统时钟168MHz,APB1分频4,所以TIM6的时钟是84MHz。在CubeMX里,TIM6的配置如下:
- Prescaler: 0
- Counter Mode: Up
- Counter Period (ARR): 839
- Trigger Event Selection: Update Event
这样TIM6的TRGO输出频率就是84MHz / 840 = 100kHz。如果我用100个点的三角波表,输出频率就是1kHz。
DAC的配置:
- Output Buffer: Enable
- Trigger: TIM6 Trigger Out event
- Wave Generation: Disabled(我们用软件表,不用DAC内部的噪声/三角波发生器)
这里要特别说明:STM32的DAC内部确实有一个三角波发生器,但它的频率和幅度控制非常有限,而且和定时器触发是互斥的。如果你启用了DAC的三角波生成模式,就不能再用外部触发了。所以我们要的是关闭内部波形生成,用外部触发+软件表的方式。
5.2 DMA双缓冲的配置细节
DMA的配置是整个方案里最容易出问题的地方。我用的是DMA1的Stream5(对应DAC通道1),配置如下:
- Channel: 7
- Direction: Memory to Peripheral
- Peripheral Increment: Disable
- Memory Increment: Enable
- Peripheral Data Alignment: Half Word (16-bit)
- Memory Data Alignment: Half Word
- Mode: Circular
- Priority: High
关键点:DAC的数据寄存器是12位的,但DMA搬运的时候要按16位搬运。因为DAC_DHR12R1寄存器虽然是32位的,但低16位有效,DMA按半字搬运正好。如果你按字节搬运,只会搬低8位,波形精度直接降到256级。
双缓冲的实现方式有两种:一种是用DMA的双缓冲模式(Double Buffer Mode),另一种是用半传输中断+全传输中断手动切换。我推荐后者,因为更灵活,而且不依赖具体的DMA流是否支持双缓冲。
具体做法是:定义两个缓冲区buf_a和buf_b,每个长度256。DMA先搬buf_a,当搬了一半的时候触发半传输中断,在中断里更新buf_b的内容(或者切换到buf_b),当搬完的时候触发全传输中断,在中断里更新buf_a。这样CPU只需要在中断里处理,而且处理时间很充裕。
5.3 三角波表的生成代码
#define WAVE_LEN 256 #define DAC_MAX 4095 #define DAC_MID 2048 uint16_t tri_wave[WAVE_LEN]; void generate_tri_wave(uint16_t amplitude, uint16_t offset) { // amplitude: 峰值相对于中心点的偏移量,范围0~2048 // offset: 直流偏置对应的DAC值,范围0~4095 uint16_t i; uint16_t peak = amplitude; uint16_t center = offset; for (i = 0; i < WAVE_LEN / 2; i++) { // 上升段 uint16_t val = center - peak + (2 * peak * i) / (WAVE_LEN / 2); tri_wave[i] = (val > DAC_MAX) ? DAC_MAX : val; } for (i = WAVE_LEN / 2; i < WAVE_LEN; i++) { // 下降段 uint16_t val = center + peak - (2 * peak * (i - WAVE_LEN / 2)) / (WAVE_LEN / 2); tri_wave[i] = (val > DAC_MAX) ? DAC_MAX : val; } }这段代码里,amplitude控制幅度,offset控制偏置。注意我做了边界检查,防止计算出的值超过DAC的最大值。实际使用的时候,amplitude和offset要满足center - peak >= 0且center + peak <= 4095,否则波形会被削顶。
5.4 启动和动态调整的完整流程
启动流程:
- 生成三角波表
- 配置TIM6,启动定时器
- 配置DMA,启动DMA传输
- 使能DAC通道,选择触发源为TIM6 TRGO
- 使能DAC的DMA请求
动态调整频率:只需要改TIM6的ARR值。比如要从1kHz变到2kHz,ARR从839改成419。注意要等当前周期结束再改,或者用ARPE预装载。
动态调整幅度:重新生成三角波表,然后在DMA传输完成中断里切换缓冲区。不要在DMA正在搬运的时候直接改表,那样会出现半个周期的旧值加半个周期的新值,波形会撕裂。
6. 实测中遇到的坑和排查思路
6.1 波形频率对但幅度不对
这是我遇到的第一个坑。频率用示波器量是对的,但幅度比预期小很多。排查过程:
先量DAC输出引脚,发现波形有,但峰峰值只有几百毫伏。第一反应是DAC没使能输出缓冲器,检查代码发现确实没开。开了之后幅度上去了,但还是比理论值小。
然后量VREF引脚,发现只有3.0V而不是3.3V。原来板子上的VDDA和VREF之间有个磁珠,磁珠有压降。换了根短接线直接连过去,幅度就对了。
这个坑的教训是:DAC的输出幅度直接受VREF影响,而VREF在开发板上往往不是理想的3.3V。如果你要做精确幅度,最好用外部基准源,或者至少在软件里做校准。
6.2 高频时波形出现台阶和毛刺
当我把输出频率提到10kHz以上的时候,示波器上的三角波开始出现明显的台阶,而且斜边上还有毛刺。排查过程:
先怀疑是采样点数不够,把表从100点加到256点,台阶感减轻了,但毛刺还在。然后用示波器的单次触发抓波形,发现毛刺出现在DMA传输完成的时刻。
问题定位到DMA中断。原来我在DMA完成中断里做了太多事情,包括重新生成表、计算新参数等等,导致中断执行时间过长,DAC的更新被延迟了。解决办法是把表的生成放到主循环里,中断里只做缓冲区切换,把中断执行时间从几十微秒降到几微秒。
这个坑的教训是:DMA中断里只做最必要的事情。任何耗时的计算都应该放到主循环或者低优先级任务里。
6.3 改频率时波形出现断裂
动态调整频率的时候,偶尔会出现一个周期特别长或者特别短的波形。排查过程:
一开始以为是ARR写入的时机问题,加了ARPE使能,但问题依旧。后来用逻辑分析仪抓TIM6的TRGO信号,发现改ARR的时候,如果正好在计数器接近ARR的时候写入,会出现一次比较长的周期。
原因是ARR的预装载虽然能保证新值在下一个更新事件生效,但如果写入的时刻太接近更新事件,可能会错过这个周期。解决办法是在写入ARR之前,先等一个更新事件,确保当前周期完整结束。或者用定时器的更新中断,在中断里改ARR。
这个坑的教训是:动态改定时器参数一定要考虑生效时机,不能想写就写。
6.4 DAC输出有直流偏置但调不掉
我想让三角波在1V到2V之间摆动,但不管怎么改offset,输出总是在0V到1V左右。排查过程:
检查代码发现,我在计算DAC值的时候用了uint16_t,但中间计算过程有减法,当center - peak小于0的时候,无符号数回绕成了很大的值,然后被边界检查截断到4095,导致波形被削顶。
解决办法是把中间计算改成int32_t,最后再转成uint16_t并做边界检查。这个坑很典型,嵌入式里做数值计算一定要注意数据类型的符号和范围。
7. 几个让方案更稳的进阶技巧
7.1 用定时器的重复计数器做周期同步
如果你用的是高级定时器(TIM1、TIM8),它们有一个重复计数器(RCR)。这个计数器可以让定时器每N+1个周期才产生一次更新事件。对于DAC触发来说,这意味着你可以用更高的定时器频率,但DAC的更新率是它的1/(N+1)。
这个技巧的用处在于:你可以用很高的定时器时钟来获得精细的频率调节粒度,同时用RCR来控制实际的DAC更新率。比如定时器跑10MHz,RCR设为99,那DAC更新率就是100kHz,但频率调节的粒度是10MHz级别的,非常精细。
7.2 用DAC的噪声生成器做抖动
STM32的DAC有一个噪声生成器,可以产生伪随机序列叠加在输出上。虽然我们做三角波不需要噪声,但这个功能可以用来做抖动(dithering),提高小幅度信号的有效分辨率。
原理是这样的:当你要输出一个很小的幅度时,DAC的量化台阶会变得很明显。如果你在输出上叠加一个小的随机噪声,然后后面接一个低通滤波器,平均下来就能得到比单个LSB更精细的电压。这个技巧在音频和精密测量里很常用。
7.3 双通道同步输出
如果你需要两个通道输出同步的三角波(比如做差分驱动或者IQ信号),可以用DAC的双通道模式。STM32的DAC支持两个通道同时被同一个触发源触发,而且可以配置成同时更新。
具体做法是:把DAC通道1和通道2的触发源都设为TIM6 TRGO,然后使能DAC的双通道DMA请求。DMA会依次搬运两个通道的数据,但因为是同一个触发事件,两个通道的DOR会在同一时刻更新。这样两个通道的输出就是严格同步的。
7.4 输出端加RC滤波
不管你的采样点数有多少,DAC输出总是有台阶的。在输出端加一个简单的RC低通滤波器,截止频率设在触发频率的1/10左右,可以显著平滑波形。
比如触发频率是100kHz,RC截止频率设10kHz,取R=1kΩ,C=16nF。这个滤波器对三角波的斜边影响很小,但能把台阶的高频成分滤掉。实测下来,加了RC之后,示波器上的波形从"楼梯"变成了"斜坡",观感提升非常明显。
但要注意:RC滤波器会引入相位延迟,如果你后面还有反馈控制,这个延迟要算进去。另外,RC的电阻不能太小,否则会超过DAC输出缓冲器的驱动能力。
8. 关于频率和幅度解耦的一点个人体会
回到标题里的"频率与幅度精准控制",我最大的体会是:这两个参数之所以难控制,不是因为单独调它们难,而是因为它们会互相影响。
用查表法的时候,改幅度要重新生成表,如果这时候DMA正在搬运,波形就会撕裂。改频率要改ARR,如果时机不对,会出现异常周期。用实时计算法的时候,改幅度和改频率都是在中断里做,中断执行时间会波动,导致输出抖动。
我最后采用的方案是:频率用定时器的ARR控制,幅度用表的缩放控制,两者通过双缓冲机制解耦。具体来说,频率的调整随时可以做,因为ARR有预装载,不会影响当前周期。幅度的调整在主循环里生成新表,然后在DMA完成中断里切换缓冲区指针,切换是原子的,不会出现半个周期的问题。
这套机制跑下来,我在1kHz到20kHz的范围内扫频,同时做幅度调制,示波器上的波形始终干净,没有断裂和毛刺。如果你也在做类似的东西,建议把这两个参数的更新路径彻底分开,不要在一个中断里同时处理,那样很容易出问题。
另外,如果你对波形质量要求很高,建议用外部的高速DAC芯片,STM32内置的DAC在12位精度下,INL和DNL大概在几个LSB,做一般信号源够用,但做精密测量就有点勉强了。不过对于学习和大多数嵌入式应用来说,内置DAC配合定时器触发,已经是性价比很高的方案了。