ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 DAC三角波生成:频率与幅度精准控制实战

2026/9/18 13:34:27 拓冰建站 浏览量
STM32 DAC三角波生成:频率与幅度精准控制实战 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 / ((PSC1) * (ARR1))其中f_timer_clk是定时器的时钟源频率PSC是预分频器ARR是自动重装载值。这个公式适用于向上计数模式下的更新事件触发。以F407为例TIM6挂在APB1总线上。如果系统时钟是168MHzAPB1的分频系数是4那么APB1的时钟是42MHz。但这里有个容易搞错的地方当APB的分频系数不为1时定时器的时钟会是APB时钟的2倍。所以TIM6的实际时钟是84MHz不是42MHz。这个细节如果搞错算出来的频率会差一倍。假设我们要生成1kHz的三角波那么定时器的触发频率应该是1kHz乘以一个周期内的采样点数。比如一个三角波周期用100个点来描述那触发频率就是100kHz。代入公式100000 84000000 / ((PSC1) * (ARR1))取PSC0则ARR1 840ARR 839。这里就引出一个权衡采样点数越多波形越平滑但定时器频率越高。100个点对于1kHz的三角波来说每个周期只有100个台阶用示波器看还是能看出阶梯感。如果你想要更平滑的波形可以增加到256个点那触发频率就变成256kHzARR就要相应减小。但ARR太小的话定时器的分辨率会下降频率调节的粒度会变粗。3.2 采样点数与波形质量的取舍我实测过几组不同的采样点数用示波器观察三角波的阶梯感每周期采样点数触发频率(1kHz输出时)波形观感CPU/DMA负载3232kHz阶梯明显像楼梯极低6464kHz轻微阶梯可接受低100100kHz比较平滑中等256256kHz非常平滑较高512512kHz几乎看不出阶梯高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到4095V_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.303peak ≈ 620。然后生成新表的时候值域就是2048-620到2048620。但这样有个问题直流偏置固定在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系统时钟168MHzAPB1分频4所以TIM6的时钟是84MHz。在CubeMX里TIM6的配置如下Prescaler: 0Counter Mode: UpCounter Period (ARR): 839Trigger Event Selection: Update Event这样TIM6的TRGO输出频率就是84MHz / 840 100kHz。如果我用100个点的三角波表输出频率就是1kHz。DAC的配置Output Buffer: EnableTrigger: TIM6 Trigger Out eventWave Generation: Disabled我们用软件表不用DAC内部的噪声/三角波发生器这里要特别说明STM32的DAC内部确实有一个三角波发生器但它的频率和幅度控制非常有限而且和定时器触发是互斥的。如果你启用了DAC的三角波生成模式就不能再用外部触发了。所以我们要的是关闭内部波形生成用外部触发软件表的方式。5.2 DMA双缓冲的配置细节DMA的配置是整个方案里最容易出问题的地方。我用的是DMA1的Stream5对应DAC通道1配置如下Channel: 7Direction: Memory to PeripheralPeripheral Increment: DisableMemory Increment: EnablePeripheral Data Alignment: Half Word (16-bit)Memory Data Alignment: Half WordMode: CircularPriority: 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变到2kHzARR从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。这个计数器可以让定时器每N1个周期才产生一次更新事件。对于DAC触发来说这意味着你可以用更高的定时器频率但DAC的更新率是它的1/(N1)。这个技巧的用处在于你可以用很高的定时器时钟来获得精细的频率调节粒度同时用RCR来控制实际的DAC更新率。比如定时器跑10MHzRCR设为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左右可以显著平滑波形。比如触发频率是100kHzRC截止频率设10kHz取R1kΩC16nF。这个滤波器对三角波的斜边影响很小但能把台阶的高频成分滤掉。实测下来加了RC之后示波器上的波形从楼梯变成了斜坡观感提升非常明显。但要注意RC滤波器会引入相位延迟如果你后面还有反馈控制这个延迟要算进去。另外RC的电阻不能太小否则会超过DAC输出缓冲器的驱动能力。8. 关于频率和幅度解耦的一点个人体会回到标题里的频率与幅度精准控制我最大的体会是这两个参数之所以难控制不是因为单独调它们难而是因为它们会互相影响。用查表法的时候改幅度要重新生成表如果这时候DMA正在搬运波形就会撕裂。改频率要改ARR如果时机不对会出现异常周期。用实时计算法的时候改幅度和改频率都是在中断里做中断执行时间会波动导致输出抖动。我最后采用的方案是频率用定时器的ARR控制幅度用表的缩放控制两者通过双缓冲机制解耦。具体来说频率的调整随时可以做因为ARR有预装载不会影响当前周期。幅度的调整在主循环里生成新表然后在DMA完成中断里切换缓冲区指针切换是原子的不会出现半个周期的问题。这套机制跑下来我在1kHz到20kHz的范围内扫频同时做幅度调制示波器上的波形始终干净没有断裂和毛刺。如果你也在做类似的东西建议把这两个参数的更新路径彻底分开不要在一个中断里同时处理那样很容易出问题。另外如果你对波形质量要求很高建议用外部的高速DAC芯片STM32内置的DAC在12位精度下INL和DNL大概在几个LSB做一般信号源够用但做精密测量就有点勉强了。不过对于学习和大多数嵌入式应用来说内置DAC配合定时器触发已经是性价比很高的方案了。