拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 DDS信号发生器实战:DAC+DMA+定时器实现0.1Hz步进

STM32 DDS信号发生器实战:DAC+DMA+定时器实现0.1Hz步进 1. 为什么DDS方案值得在STM32上认真做一遍如果你手头有一块STM32F103或者F407想做一个能输出正弦波、方波、三角波频率还能精确调到0.1Hz步进的信号发生器那DDS直接数字频率合成几乎是绕不开的方案。我最早接触这个需求是帮朋友做一个传感器激励源要求输出1kHz到100kHz可调的正弦波频率误差小于0.5%波形失真尽量低。当时第一反应是用STM32自带的DAC配合定时器查表输出但实际跑起来才发现如果只是简单地在定时器中断里逐个点写DACCPU几乎被占满而且频率分辨率受限于定时器分频系数想做到0.1Hz步进非常困难。DDS的核心思路其实很朴素把一张正弦波查找表存在ROM里用一个相位累加器在每个采样时钟周期累加一个频率控制字累加器的高位作为查表地址查出来的值送给DAC转换成模拟电压。输出频率由公式Fout (Fclk × FTW) / 2^N决定其中Fclk是采样时钟频率FTW是频率控制字N是相位累加器位宽。这个公式意味着只要N足够大比如32位频率分辨率可以做到Fclk / 2^32对于72MHz的时钟来说分辨率大约是0.0168Hz完全满足0.1Hz步进的需求。但真正把DDS在STM32上跑通光理解公式远远不够。你需要解决几个关键问题查找表怎么生成、DAC怎么被驱动、DMA怎么配合、定时器怎么触发、双缓冲怎么设计、输出滤波器怎么加。这些问题每一个都有坑而且坑与坑之间还相互关联。比如你选了DMA传输就要考虑DMA的传输完成中断和半传输中断怎么用你选了定时器触发DAC就要考虑触发频率和DMA请求之间的时序关系。这篇文章我会按照我自己实际调试的顺序来写从DDS的数学原理讲起然后一步步落到STM32的具体外设配置上包括DAC、DMA、定时器、查找表生成、双缓冲机制、输出滤波和实测波形分析。中间会穿插我在调试过程中踩过的坑和验证过的参数。目标读者是已经会用CubeMX配置基本外设、能写简单中断程序的STM32开发者如果你刚接触STM32建议先把GPIO和定时器中断跑通再来看这篇。2. DDS的数学底子相位累加器、频率控制字与查找表2.1 相位累加器到底在累加什么DDS最核心的部件是相位累加器。你可以把它想象成一个圆盘圆盘被分成2^N个刻度每个采样时钟周期圆盘转动FTW个刻度。圆盘当前的位置就是相位用二进制表示就是相位值。当相位值超过2^N时自动溢出回绕对应圆盘转满一圈。查找表把相位值映射成正弦幅度值DAC再把幅度值变成电压。用数学语言描述设相位累加器位宽为N频率控制字为FTW采样时钟频率为Fclk则输出频率为Fout (Fclk × FTW) / 2^N反过来如果你想要输出频率Fout频率控制字应该设置为FTW round((Fout × 2^N) / Fclk)这里有一个实际工程中必须注意的点FTW必须是整数所以实际输出频率和理论频率之间会有量化误差。误差大小取决于N和Fclk。以N32、Fclk1MHz为例频率分辨率是1MHz/2^32≈0.000233Hz这个误差完全可以忽略。但如果N只有16位分辨率就是1MHz/65536≈15.26Hz那就完全不够用了。所以我在实际项目中一律用32位相位累加器哪怕查找表只取高12位作为地址。2.2 查找表该做多大怎么生成查找表的大小直接决定了输出波形的谐波失真。理论上查找表越大波形越接近理想正弦波但占用的ROM空间也越大。STM32F103C8T6只有64KB Flash查找表不能太大。我一般用1024点或2048点的查找表每个点用12位无符号数表示对应DAC的12位分辨率这样一张表占2KB或4KB完全可以接受。生成查找表的公式是for (int i 0; i TABLE_SIZE; i) { float phase 2.0f * 3.1415926f * i / TABLE_SIZE; float value (sinf(phase) 1.0f) * 0.5f * 4095.0f; sine_table[i] (uint16_t)value; }这里加1再乘0.5是为了把正弦值从[-1,1]映射到[0,4095]因为STM32的DAC只能输出0到VREF之间的正电压。如果你需要输出双极性波形后面加一个运放做电平搬移和放大即可。注意查找表里的值最好用const修饰并放在Flash里不要放在RAM里。STM32F103的RAM只有20KB一张2048点的表就占4KB放RAM里太浪费了。2.3 相位截断误差与SFDR的关系当你用32位相位累加器但只取高12位查表时低20位被丢弃了这叫相位截断。相位截断会引入杂散衡量指标是SFDR无杂散动态范围。根据经验公式相位截断带来的SFDR大约为6.02 × PdB其中P是保留的相位位数。保留12位时SFDR大约是72dB对于一般信号发生器应用足够了。如果你需要更高的频谱纯度可以保留14位或16位但查找表会变成16K点或64K点Flash就不够用了。我在实际测试中用1024点表、保留10位相位输出1kHz正弦波时用示波器FFT功能看谐波二次谐波大约在-55dBc左右三次谐波在-48dBc左右。后来换成2048点表、保留11位相位二次谐波改善到-62dBc三次谐波改善到-55dBc。这个数据供你参考实际效果还跟DAC的线性度、输出滤波器和PCB布局有关。3. STM32外设选型与CubeMX关键配置3.1 为什么选DACDMA定时器这个组合STM32上做DDS输出有三种常见方案第一种是定时器中断里手动写DAC第二种是DACDMA定时器触发第三种是PWMRC滤波。第一种方案CPU占用率太高采样率上不去第三种方案输出频率低、波形质量差只适合做低速控制信号。第二种方案是正解定时器产生精确的触发信号DAC在触发下转换DMA自动把查找表数据搬运到DAC数据寄存器整个过程CPU只需要在DMA传输完成中断里更新缓冲区指针。以STM32F103为例DAC的触发源可以选TIM2、TIM4、TIM5、TIM6、TIM7、EXTI_9。我一般用TIM6因为TIM6是基本定时器功能简单不会跟其他外设冲突。TIM6的更新事件可以作为DAC的触发信号同时也可以作为DMA的请求源。但这里有一个细节DAC的DMA请求是由DAC的转换完成事件产生的而不是定时器直接产生的。所以你需要配置DAC的DMA使能并且把DMA的请求源设置为DAC通道。3.2 CubeMX里DAC和DMA的具体配置项在CubeMX里配置DAC时有几个关键选项容易搞错。首先在Analog标签下找到DAC选择OUT1 Configuration。Output Buffer建议开启因为STM32的DAC输出阻抗比较高不开缓冲带负载能力很差。Trigger选择Timer 6 Trigger Out event。Wave generation mode选择Disabled因为我们用DMA搬运自定义波形不需要DAC内部产生三角波或噪声波。DMA配置里添加DAC_CH1的DMA请求方向是Memory to Peripheral优先级设为High。Mode选择Circular这样DMA会自动循环搬运不需要每次传输完成都重新启动。Data Width方面Peripheral和Memory都选Half Word因为DAC数据寄存器是12位右对齐用16位传输正好。TIM6配置里Prescaler设为0Counter Period设为(SystemCoreClock / Fclk) - 1。比如系统时钟72MHz想要1MHz的采样率Counter Period就是71。这里要注意TIM6的时钟源是APB1如果APB1预分频系数不为1TIM6的时钟会是APB1时钟的2倍。CubeMX会自动计算你只需要在Clock Configuration里确认一下。3.3 DMA双缓冲的实现思路单缓冲的问题是当DMA正在搬运缓冲区A时CPU不能修改缓冲区A否则输出波形会出错。如果你需要动态改变输出频率或波形就必须用双缓冲。STM32F103的DMA没有硬件双缓冲支持但可以用DMA的传输完成中断和半传输中断来模拟。具体做法是定义一个长度为2×N的缓冲区前N个点放波形A后N个点放波形B。DMA配置为Circular模式传输长度设为2×N。当DMA传输完前N个点时触发半传输中断在中断里更新后N个点的数据当DMA传输完后N个点时触发传输完成中断在中断里更新前N个点的数据。这样CPU总是在DMA不访问的那一半缓冲区里操作不会产生冲突。STM32H7系列有DMAMUX和真正的双缓冲模式配置起来更简单但F103和F407只能用这种软件模拟的方式。我在F407上实测用这种方式可以在输出1MHz采样率的同时实时更新频率控制字波形切换时没有明显的毛刺。4. 代码实现从查找表到DAC输出的完整链路4.1 查找表生成与频率控制字计算先定义一个2048点的正弦查找表放在Flash里#define TABLE_SIZE 2048 const uint16_t sine_table[TABLE_SIZE] { ... };实际项目中我不会手写这2048个值而是用Python或MATLAB生成一个头文件。Python脚本如下import math TABLE_SIZE 2048 with open(sine_table.h, w) as f: f.write(#define TABLE_SIZE %d\n % TABLE_SIZE) f.write(const uint16_t sine_table[TABLE_SIZE] {\n) for i in range(TABLE_SIZE): val int((math.sin(2 * math.pi * i / TABLE_SIZE) 1) * 0.5 * 4095) f.write(%d, % val) if (i 1) % 16 0: f.write(\n) f.write(};\n)频率控制字的计算函数uint32_t dds_calc_ftw(float freq, uint32_t sample_rate) { return (uint32_t)((freq * 4294967296.0f) / sample_rate); }这里4294967296.0f就是2^32。注意用浮点数计算不要用整数否则精度不够。4.2 DMA缓冲区与中断服务函数定义双缓冲区和相关变量#define BUF_SIZE 256 uint16_t dac_buf[2 * BUF_SIZE]; volatile uint32_t phase_acc 0; volatile uint32_t ftw 0; volatile uint8_t active_buf 0;DMA半传输中断和传输完成中断的处理void DMA1_Channel3_IRQHandler(void) { if (DMA1-ISR DMA_ISR_HTIF3) { DMA1-IFCR DMA_IFCR_CHTIF3; fill_buffer(0); } if (DMA1-ISR DMA_ISR_TCIF3) { DMA1-IFCR DMA_IFCR_CTCIF3; fill_buffer(1); } }fill_buffer函数根据当前相位累加器和频率控制字填充指定半缓冲区void fill_buffer(uint8_t half) { uint16_t *buf dac_buf[half * BUF_SIZE]; for (int i 0; i BUF_SIZE; i) { uint16_t index (phase_acc 20) (TABLE_SIZE - 1); buf[i] sine_table[index]; phase_acc ftw; } }这里右移20位是因为32位相位累加器取高12位作为查找表地址而2048点表需要11位地址所以右移21位更合适。实际代码里我会根据表大小调整移位量。4.3 定时器触发与DAC启动顺序启动顺序很重要搞错了会导致第一个波形周期出错。正确的顺序是配置DAC为触发模式使能DMA请求配置DMA为Circular模式启动DMA配置TIM6启动定时器使能DAC通道如果先启动定时器再启动DMADAC会在没有数据的情况下触发转换输出一个不确定的电压。如果先使能DAC再启动DMADAC会立即转换一次数据寄存器里的旧值。所以一定要按上面的顺序来。HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)dac_buf, 2 * BUF_SIZE, DAC_ALIGN_12B_R); HAL_TIM_Base_Start(htim6);HAL_DAC_Start_DMA会自动使能DAC通道和DMA请求所以不需要再单独调用HAL_DAC_Start。5. 实测波形分析与常见问题排查5.1 输出波形失真与滤波器设计DAC输出的是阶梯波每个采样点保持一个时钟周期所以频谱里除了基波还有大量高频谐波。如果不加滤波器示波器上看到的正弦波会有明显的台阶。我一般加一个二阶或四阶巴特沃斯低通滤波器截止频率设为最高输出频率的1.5到2倍。比如最高输出100kHz截止频率设200kHz。滤波器设计可以用TI的FilterPro或者在线工具我常用的是Sallen-Key二阶低通运放选TL072或OPA2134。电阻电容值按标准值选比如R1kΩC1nF截止频率大约是159kHz。实际调试时用示波器看输出波形调整电容值直到波形干净为止。提示DAC输出缓冲开启后输出阻抗大约在5kΩ左右后级滤波器输入阻抗要足够高否则会分压导致幅度下降。5.2 频率切换时的相位连续性问题DDS的一个优点是频率切换时相位连续不会产生波形跳变。但如果你在DMA中断里直接修改ftw变量而fill_buffer函数正在使用它可能会读到不一致的值。解决办法是在修改ftw时先关中断改完再开中断__disable_irq(); ftw new_ftw; __enable_irq();或者用原子操作但STM32的32位变量读写本身就是原子的只要不在中断里修改就行。我一般把频率设置放在主循环里中断里只读不写。5.3 DMA传输错误与数据错位如果发现输出波形偶尔出现毛刺或错位大概率是DMA传输错误。检查这几个地方DMA的传输长度是否和缓冲区大小一致DMA的源地址和目标地址是否正确DMA的优先级是否被其他外设抢占。我在F103上遇到过SPI DMA和DAC DMA同时使用导致DAC输出抖动的情况后来把DAC DMA优先级设为Very HighSPI设为Medium问题就解决了。另一个常见问题是DMA传输完成中断标志没有及时清除导致中断反复触发。STM32F1的DMA中断标志需要用IFCR寄存器清除HAL库的HAL_DMA_IRQHandler会自动处理但如果你直接写寄存器一定要记得清除对应的标志位。5.4 输出幅度与偏置的调整STM32的DAC输出范围是0到VREFVREF通常接3.3V。如果你需要输出±1V的正弦波就需要加一个运放做电平搬移和放大。典型电路是用一个加法器把DAC输出减去1.65V再放大2倍输出范围就变成±1.65V。实际电路中1.65V可以用电阻分压从3.3V得到也可以用基准电压芯片。我在实际项目中用过一个更简单的办法DAC输出接一个隔直电容再通过电阻分压到运放同相端运放配置成反相放大器偏置电压通过另一路电阻加到反相端。这样输出就是纯交流幅度由反馈电阻决定。这个电路的好处是不需要负电源单电源运放就能工作。6. 从能跑到好用几个提升输出质量的经验6.1 提高采样率与DAC建立时间的关系STM32F103的DAC建立时间是1μs意味着采样率最高大约1MHz。如果你把TIM6触发频率设到2MHzDAC输出会来不及建立波形幅度会下降、失真会增加。我实测F103在1MHz采样率下输出100kHz正弦波幅度下降约3dB降到500kHz采样率幅度下降约1dB。所以如果你的输出频率接近采样率的1/10就要考虑换F407或H7系列它们的DAC建立时间更短。F407的DAC建立时间是1μs跟F103差不多但F407的主频更高可以用更高的采样率。H7系列的DAC建立时间可以到250ns采样率能上4MHz适合输出更高频率的波形。6.2 用定时器DMA Burst模式减少CPU干预STM32F4和F7系列的DAC支持DMA Burst模式可以一次触发传输多个数据。比如配置DAC为Burst模式DMA传输长度设为10每次定时器触发时DAC会自动转换10个数据。这样定时器频率可以降低10倍CPU中断次数也减少10倍。不过Burst模式需要DAC和DMA都支持F103没有这个功能。在H7系列上还可以用DMAMUX把多个请求源映射到同一个DMA通道实现更灵活的触发逻辑。比如用TIM6触发DAC同时用TIM7触发ADC采样两个DMA通道互不干扰。这些高级功能在复杂项目中很有用但学习曲线也比较陡。6.3 输出端加缓冲运放的必要性DAC输出缓冲虽然能带一定负载但驱动能力有限。如果你直接接示波器探头1MΩ输入阻抗波形看起来没问题但接50Ω负载时幅度会大幅下降。所以输出端一定要加一个电压跟随器或同相放大器。我常用的是OPA2134或NE5532带宽足够噪声低。如果输出频率超过1MHz就要选更高带宽的运放比如AD8065或LM6171。注意运放供电要干净最好用LDO单独给模拟部分供电数字部分和模拟部分的电源和地要分开走线最后在一点连接。这个在PCB布局时就要考虑好否则输出噪声会很大。6.4 频率步进与相位累加器位宽的取舍前面说了32位相位累加器频率分辨率很高但代码里每次累加都要做32位加法在F103上跑1MHz采样率时fill_buffer函数里2048次循环加上32位加法CPU占用率大约30%。如果你把相位累加器降到24位频率分辨率变成1MHz/2^24≈0.06Hz仍然满足0.1Hz步进但CPU占用率会降低。实际项目中我会根据最低频率分辨率要求来选位宽不盲目追求32位。另外查找表地址计算时用移位和掩码比用取模运算快得多。比如(phase_acc 20) 0x7FF比(phase_acc 20) % 2048快很多因为编译器会把取模优化成位运算但显式写位运算更直观。7. 调试工具与验证方法7.1 用示波器FFT功能看谐波失真普通示波器只能看时域波形要分析谐波失真需要带FFT功能的示波器。我用的是Rigol DS1054Z开启FFT后可以看到基波和各次谐波的分量。测试时输出1kHz正弦波FFT窗口设为1kHz可以看到二次谐波在2kHz处三次谐波在3kHz处。如果二次谐波很高说明DAC的INL误差大或者查找表不对称如果三次谐波高说明波形有削顶或底部失真。7.2 用频率计验证输出频率精度DDS的输出频率精度取决于晶振精度和频率控制字计算精度。我用一个10块钱的频率计模块测过输出1kHz时实测999.98Hz误差0.002%主要来自晶振本身的误差。如果你需要更高精度可以用TCXO或OCXO作为STM32的时钟源但成本会高很多。7.3 用串口输出调试信息调试DDS时我习惯用串口打印当前频率控制字、相位累加器值和DMA缓冲区状态。STM32F103的USART1接USB转串口模块波特率115200用printf重定向到串口。注意在DMA中断里不要用printf因为printf是阻塞的会打乱DMA时序。我一般只在主循环里打印或者在中断里设置标志位主循环检测到标志位再打印。8. 项目扩展方向与个人体会这个DDS信号发生器的基础框架跑通之后可以扩展的方向很多。比如加一个旋转编码器或按键来调节频率和波形类型加一个OLED屏幕显示当前频率和幅度用上位机通过串口发送频率设置命令甚至可以用SD卡存储任意波形数据实现任意波形发生器。我在实际项目中还做过一个变种用两路DAC同时输出I和Q信号配合正交调制实现AM、FM、PM调制。这个在通信实验里很有用但需要两路DAC同步触发F103只有一个DAC双通道可以同时输出两路但触发是共用的相位关系需要软件控制。踩过几次坑之后我最大的体会是DDS的数学原理很简单但工程实现里最花时间的往往是外设配置和时序调试。特别是DMA和定时器的配合参数差一点波形就出不来。建议你在调试时先用低频比如1kHz采样率跑通整个链路用示波器确认波形正确后再逐步提高采样率。另外CubeMX生成的代码只是起点DMA中断服务函数和缓冲区填充逻辑必须自己写不要指望HAL库能帮你搞定一切。最后分享一个小技巧如果你手头没有示波器可以用STM32的ADC回采DAC输出通过串口把采样值传到电脑上用Python画图。虽然精度不如示波器但验证波形形状和频率是否正确足够了。ADC采样率要至少是DAC输出频率的10倍采1024个点做FFT就能看到谐波分布。这个方法我用了很多次在没有高端仪器的环境下非常实用。
返回列表