ARTICLE DETAIL

资讯详情

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

基于STM32的DDS信号发生器:原理、实现与工程优化

基于STM32的DDS信号发生器:原理、实现与工程优化 简介基于STM32的信号发生器工程源码适合用作毕设、课程设计或工程实训项目面向希望学习STM32外设驱动、波形生成与界面交互的初中级开发者。资源共47个文件以22个c源码与22个h头文件为主体覆盖LCD显示、菜单交互、AD9851波形合成、DAC输出、FFT、参数管理等功能模块另含SignalGenerator_v0.1.ioc的CubeMX配置文件和README说明文档整体仅101KB结构清晰。项目支持正弦波、方波、三角波及其线性组合波形可实现100Hz~20kHz频率、0~5V幅度调节并具备波形存储和显示功能。已有601人学习适合作为波形发生器类课题的参考蓝本尤其可借鉴其AD9851驱动框架、菜单与参数管理思路快速搭建或改进自己的信号源方案。1. 为什么做信号发生器第一件事不是查表很多人在 STM32 上做信号发生器第一反应是“建一个正弦波表用定时器中断喂给 DAC”。这个思路没有错但如果你把频率从 1kHz 调到 10kHz会发现波形点数变稀疏失真肉眼可见再往上调中断频繁到把主循环拖垮。问题不在查表本身而在你没有一个“频率可变、波形连续”的机制。“基于 STM32 的信号发生器”这个项目真正要解决的是三件事合成什么样的波形、如何让频率连续可调、以及输出到外部电路时信号还能不能看。DDS直接数字频率合成是消费级和工业级信号发生器里最常用的方案相位累加器加查找表既能保证频率分辨率又能让波形相位连续不会在切换频率时出现跳变。本文用 STM32F103 为主控走通一套从 DDS 原理到 DAC 输出、再到运放调理和串口控制的完整路径你拿到手能用也能看懂每一行代码为什么这么写。适合做毕业设计、仪器类项目或者单纯想把波形发生器模块做成产品原型的工程师。2. DDS 波形合成原理与频率、幅值参数的计算方法2.1 相位累加器为什么是 32 位而不是 16 位DDS 的核心结构是相位累加器加波形查找表。系统时钟每个周期相位累加器累加一次频率控制字FCWFrequency Control Word累加的结果作为查表地址查找表输出对应的波形幅值送给 DAC 转换。这个结构里没有“中断”也没有“分频”频率只要改变 FCW 就立即变化所以能做到频率连续可调。相位累加器的宽度直接决定了频率分辨率。假设系统时钟为 Fclk相位累加器位宽为 N那么最小频率步进 Fclk / 2^NSTM32F103 最高主频 72MHz如果用 32 位相位累加器最小步进约为 0.0168Hz。这意味着在 1kHz 附近你可以做到小数点后两位的频率调节。如果换成 16 位位宽步进直接跳到 1098Hz连 1kHz 的信号都出不来所以 32 位累加器是必须的。相位累加器高位作为查表地址比如查找表有 1024 个点就取累加器的高 10 位累加器为 32 位时即可用acc 22得到。频率控制字的计算公式是FCW Fout * 2^32 / Fclk其中 Fout 为目标信号频率。反过来说实际输出频率为Fout FCW * Fclk / 2^32这里有几个 Fclk 要注意是 DAC 被驱动的时钟频率不是 CPU 主频。DAC 触发来源如果设为定时器那么 Fclk 就是定时器的计数频率。最常见的做法是让定时器以 72MHz 计数每个计数周期触发一次 DAC 转换此时 Fclk 就是 72MHz。下面给出一个频率和相位控制字的对照表方便你验证自己的固件有没有写错目标频率 (Hz)系统时钟 (MHz)32位 FCW (十进制)32位 FCW (十六进制)1007259768850x005B366D100072597688520x0390204A10000725976885240x23A06FCA1000007259768852380x647ACE5E验算一下0x647ACE5E 约为 1685MHz乘以 72MHz 再除以 2^32就得到约 100kHz。你需要在自己代码里把 FCW 定义成uint32_t并且注意乘法溢出问题Fout * 4294967296ULL要用 64 位中间变量来算。2.2 查找表不只正弦波相位修正才是关键查找表里既可以放正弦波也可以放三角波、方波、锯齿波。正弦波表最常见的生成方式是 C 语言里用math.h的sin()函数在 PC 端生成一个整数数组然后复制到 STM32 工程里。但这里有一个很容易出错的细节DAC 输出的正弦波理想情况下一个周期里有两个过零点如果查找表没有包含 0 值对应的点输出波形会叠加一个直流偏置。以 1024 点表、12 位 DAC 为例每个点的值应该是dac_value (sin(2 * PI * i / 1024) 1) * 2047.5注意先加 1 再乘以 2047.5这样输出范围是 0 到 4095 的满幅。如果漏了1负半周全部被 DAC 截掉。还有一个细节DAC 是单极性输出直接出正弦波会有 1.65V 左右的直流偏置在 3.3V 电源下后面需要运放做偏置扣除。方波和锯齿波可以直接用公式生成不需要存储整张表。但为了统一代码逻辑一般情况下我会直接生成一张 1024 点的通用查表每种波形独立成数组。这样波形切换只需要更换查找表指针DDS 核心代码不用改。下面是生成查找表的 Python 脚本你可以把它放在 PC 上输出 C 语言头文件直接拷贝进工程import math # 1024 点查询表 SAMPLE_COUNT 1024 DAC_MAX 4095 def generate_sine_table(): table [] for i in range(SAMPLE_COUNT): val (math.sin(2 * math.pi * i / SAMPLE_COUNT) 1) * (DAC_MAX / 2) table.append(int(round(val))) return table def generate_square_table(): table [] for i in range(SAMPLE_COUNT): if i SAMPLE_COUNT // 2: val DAC_MAX else: val 0 table.append(val) return table sine_table generate_sine_table() square_table generate_square_table() with open(wave_table.h, w) as f: f.write(#ifndef WAVE_TABLE_H\n) f.write(#define WAVE_TABLE_H\n\n) f.write(const uint16_t sine_wave_table[1024] {\n) for i in range(0, len(sine_table), 8): f.write( , .join(str(x) for x in sine_table[i:i8]) ,\n) f.write(};\n\n) f.write(const uint16_t square_wave_table[1024] {\n) for i in range(0, len(square_table), 8): f.write( , .join(str(x) for x in square_table[i:i8]) ,\n) f.write(};\n\n#endif\n)这段代码生成两个数组每个数组是 1024 个 16 位整数。注意生成方波时前 512 点全为高后 512 点全为低这会让 DAC 输出直接饱和而不是产生平滑的边沿实际示波器上看到的就是一条垂直的边沿这在低频时没问题但高频方波会受运放压摆率影响变圆是正常的物理现象。2.3 把 FCW 和查表地址串起来DDS 核心代码下面是 STM32 上最小可用的 DDS 更新函数可以放在定时器更新中断里调用也可以放在 DAC 转换完成中断里调用// dds.c #include dds.h volatile uint32_t phase_acc 0; // 32位相位累加器 volatile uint32_t phase_inc 0; // 频率控制字 FCW const uint16_t *waveform_table sine_wave_table; // 指向当前波形表 uint32_t table_mask 0x3FF; // 1024点 - 取高10位等效于 0x3FF void DDS_SetFrequency(uint32_t freq_hz, uint32_t sys_clk_hz) { // 使用 64 位计算避免溢出 phase_inc (uint64_t)freq_hz * 4294967296ULL / sys_clk_hz; } // 在定时器触发中断中调用 void DDS_UpdateSample(void) { uint32_t table_index; phase_acc phase_inc; table_index phase_acc 22; // 32位累加器高10位范围0~1023 // 从查找表读取输出值写入DAC数据寄存器 uint16_t dac_val waveform_table[table_index]; DAC-DHR12R1 dac_val; }中断每次触发相位累加器增加一个 FCW高位作为查表索引查表结果直接写入 DAC 数据寄存器DAC-DHR12R1。phase_acc 22这一步是把 32 位累加器的高 10 位取出来等价于(phase_acc / 1024) % 1024但移位操作更节省 CPU 周期。table_mask在做波形表长度不是 2 的整数次幂时才需要用按位与代替移位这里 1024 是 2 的 10 次幂所以直接移位即可。要用的话把DDS_SetFrequency放在初始化时调用一次主循环里改频率时再调用。关键在于这个函数里 64 位运算的写法如果不加ULL后缀freq_hz * 4294967296在 32 位平台上会溢出成一个奇怪的小数最终频率输出完全错乱。这是 DDS 代码里最常见的 bug 之一没有之一。3. STM32 HAL 库实现 DAC DMA 的波形输出与工程配置3.1 DAC 选定时器触发还是 DMA 循环优先 DMA 循环DAC 输出信号的流畅程度取决于你喂数据的方式。用HAL_DAC_SetValue()在中断里每次都调用CPU 占用率会随采样率上升。72MHz 主频下如果输出 100kHz 信号、每个周期 1024 个点那么采样率就是 102.4MHz超过 DAC 最大转换速率这个方案根本走不通。实际 DDS 系统中输出频率上限受限于两个因素定时器触发速率和 DAC 转换时间STM32F103 的 DAC 转换时间从设置数据到电压稳定大约需要 2us 左右所以单通道输出最高支持到约 200kHz 的信号但波形点数会变少一般建议不超过 50kHz。DMA 循环模式是最省 CPU 的做法DAC 的数据寄存器地址作为 DMA 外设地址波形表数组作为存储器地址DMA 配置为循环模式定时器触发 DMADMA 把数据搬运到 DAC 输出寄存器。整个过程 CPU 完全不参与你只需要在初始化时启动 DMA之后修改频率只需改相位增量和定时器周期。工程配置方面在 STM32CubeMX 里需要设置以下内容外设配置项参数DACOutput BufferEnableDACTriggerTimer 2 Update EventDMADAC_CH1Circular Mode数据宽度 Half Word16位Timer 2Prescaler0Timer 2Counter Period1 即 72MHz / 1 72MHz 触发Timer 2Auto-Reload PreloadEnable注意 DMA 数据宽度必须选择 Half Word因为查找表类型是uint16_t。如果错设成 WordDAC 会每隔一个表项跳变示波器上看到的波形像被压缩了一半频率是全错的。3.2 最小可运行固件初始化、启动、改频三步走下面是一份可以运行在 STM32F103C8T6 上的 HAL 库工程代码只保留了信号发生器核心逻辑串口部分在下一章展开// main.c 关键片段 #include main.h #include dac.h #include dma.h #include tim.h #include usart.h #include dds.h // 外部已链接波形表 extern const uint16_t sine_wave_table[1024]; extern volatile uint32_t phase_inc; extern volatile uint32_t phase_acc; extern const uint16_t *waveform_table; int main(void) { HAL_Init(); SystemClock_Config(); // 配置为72MHz主频 MX_GPIO_Init(); MX_DMA_Init(); MX_DAC_Init(); // 触发源选择 Timer2 Update MX_TIM2_Init(); // 72MHz / (01) / (11) 36MHz 触发 MX_USART1_UART_Init(); // 用于后续串口控制 // 配置 DAC 通道112 位右对齐使能输出缓冲 DAC_ChannelConfTypeDef sConfig {0}; sConfig.DAC_SampleAndHold DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger DAC_TRIGGER_T2_TRGO; sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1); // 启动 DMA 循环搬运波形表首地址长度1024 HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sine_wave_table, 1024, DAC_ALIGN_12B_R); // 启动定时器触发 HAL_TIM_Base_Start(htim2); // 初始输出 1kHz 正弦波 DDS_SetFrequency(1000, 72000000); while (1) { // 主循环不做任何事波形由 DMA 自动输出 // 后续在这里处理串口接收指令 } }初始化顺序有一个坑必须先启动 DMA 再启动定时器否则定时器先触发时 DMA 还没就绪第一个采样点会丢数据偶尔能看到波形开头有一段毛刺。而HAL_DAC_Start_DMA内部会同时配置 DAC 和 DMA所以先调用它再调HAL_TIM_Base_Start才是正确的。DAC_ALIGN_12B_R表示数据右对齐DMA 搬运的每个 16 位数据只有低 12 位生效高 4 位忽略。这里查找表生成时范围是 0 到 4095正好落在低 12 位里。3.3 修改输出频率的正确方式改 FCW 而不是改定时器很多人做到这一步会在主循环里直接改定时器周期来调频率比如__HAL_TIM_SET_AUTORELOAD(htim2, value)。这在小范围变频时没问题但会引发两个隐患一是定时器计数方向不确定会导致当前周期长度突变示波器上频率切换点出现一个明显拉长或缩短的波形周期二是 DAC 采样率随频率变化当定时器周期变大采样率降低高频信号的点数反而减少波形失真加剧。推荐的做法是保持定时器触发频率固定只修改 DDS_SetFrequency 里的 phase_inc。这样采样率恒定不变波形质量在所有频率范围内一致。TIM2 的 ARR 保持为 172MHz 触发太频繁实际可用 36MHz 以下触发可以设置 PSC1这样 DAC 有足够转换时间DDS 每接收一个采样点输出一个值而输出信号频率由 FCW 决定。// dds.c 中的频率设置接口 void DDS_SetFrequency(uint32_t freq_hz, uint32_t sys_clk_hz) { phase_inc (uint64_t)freq_hz * 4294967296ULL / sys_clk_hz; } // 使用示例 DDS_SetFrequency(5000, 36000000); // 输出 5kHz DDS_SetFrequency(8000, 36000000); // 输出 8kHz无缝切换修改了 FCW 之后DMA 仍按固定速率搬运数据CPU 完全不参与波形生成。实测用这种方案波形频率切换的相位连续性比改定时器 ARR 高一个量级示波器上几乎看不到切换点毛刺。4. 输出信号的质量优化从 DAC 到外部电路的关键调理4.1 DAC 直接输出的三大问题直流偏置、负载能力、输出阻抗从 STM32 的 PA4 引脚直接测量波形你会发现三个问题同时存在。第一DAC 是单极性输出正弦波在 0 到 3.3V 之间摆动平均值大约是 1.65V这是系统电源的一半不是我们习惯的双极性信号。第二DAC 输出缓冲器有驱动能力限制STM32F103 的 DAC 在缓冲关闭时输出阻抗约为 15kΩ直接接 1kΩ 负载电压会跌掉一截。第三波形上叠加了高频量化噪声特别是输出高阻负载时更明显。解决思路是 DAC 后面加一个二阶低通滤波器和一个运放调理电路。滤波器滤掉采样率相关的镜像频率运放负责扣除直流偏置和把信号放大到目标幅值。运放的选型也很关键直接选LM358或LM324这类通用运放在 100kHz 以上会明显变形压摆率跟不上。推荐使用 SGM8521 或 OPA340 这类轨对轨运放压摆率在 1V/us 以上带宽 1MHz 以上对 0-100kHz 的信号足够。4.2 二阶低通滤波器的参数计算截止频率的确定低通滤波器在这里的作用是滤除采样镜像频率。如果 DAC 以 36MHz 速率输出那么信号在 36MHz ± Fout 处都会有镜像必须滤掉。但同时滤波器不能衰减我们需要的目标信号所以截止频率一般设为最高输出频率的 3 到 5 倍。比如系统最高输出 50kHz 的波形滤波器截止频率设为 150kHz这样 50kHz 处衰减很小而 36MHz 镜像被大幅压制。下面是巴特沃斯二阶低通的元件参数截止频率 (kHz)R1 (Ω)R2 (Ω)C1 (nF)C2 (nF)100100010001.60.8150100010001.060.53200100010000.80.4具体计算方法是令 R1 R2 RC1 和 C2 分别取 1.414 倍和 0.707 倍的关系然后代入fc 1 / (2 * PI * sqrt(R1 * R2 * C1 * C2))。实际采购时使用标准容值即可比如 200kHz 截止可以取 C1820pFC2390pF截止频率略微偏移不影响使用。注意滤波器前面串联电阻要靠近 DAC 引脚后面接运放输入。如果系统中还有数字电路干扰在运放电源引脚上并联 100nF 去耦电容能显著减小高频毛刺。4.3 运放做偏置扣除和增益调整电路计算与标定方法运放电路推荐使用差分放大结构一端接 DAC 信号一端接一个参考电压这样能够直接把 0-3.3V 的单极性信号变成 -Vref 到 Vref 的双极性信号同时通过电阻比设置增益。典型电路参数如下DAC 输出接 R1 10kΩ 到运放反相输入端反相输入端 Rf 20kΩ 反馈电阻到输出增益 -Rf / R1 -2运放同相输入端通过 R3 10kΩ 接地R2 10kΩ 接 VREF 1.65VVREF 直接从 STM32 的 VREFINT 输出或用电阻分压后加跟随器设 DAC 输出为 Vdac运放输出电压 Vout (1 Rf / R1) * Vref - Vdac * (Rf / R1)取 Rf R1 10kΩVref 1.65V则 Vout 3.3V - Vdac。所以当 Vdac 0V 时Vout 3.3V当 Vdac3.3V 时Vout0。波形范围被翻转并且压缩这不是我们要的效果。因此需要重新设定电阻比例。实用做法是Vref 接一个可调电阻分压到 0 到 3.3V 之间RfR1 时整体波形上下平移。调节 Vref 使得 DAC 输出中点数值 2048时运放输出为 0V这时候正弦波就会在 0V 上下摆动消除了直流偏置。实测这一步至少需要 10 分钟时间但做完后信号才是真正可用的“信号发生器”输出。4.4 波形失真的边界量化噪声与压摆率约束即使滤波器和运放都做好了输出波形依然有极限边界。首先是 STM32 DAC 是 12 位分辨率理论信噪比约为 74dB但实际布局不好时数字开关噪声耦合到模拟电源上信噪比可能掉到 60dB 以下表现为波形包络出现细密毛刺。改善方法是把 STM32 的 VDDA 引脚单独用磁珠或小电阻从 VDD 隔离加上 1uF 和 100nF 两级去耦电容。其次是运放压摆率导致的高频失真。SGM8521 压摆率约 0.6V/us双极性输出满幅 6.6V 时最高输出频率不能超过SR / (2 * PI * Vp)即 0.6e6 / (2 * 3.14 * 3.3) 约 29kHz。超过这个频率运放追踪不上信号速率正弦波会变成三角波。输出更高频信号时必须选择压摆率更高的运放比如 OPA340 压摆率 6V/us或者降低输出幅值。这一点在项目开头就要想清楚否则后面改板子成本很高。5. 串口在线调频与扫频模式给信号发生器加一个实用操控接口5.1 串口指令协议与最小实现信号发生器作为一个台式仪器必须有外部控制接口。最实用的是用串口发文本指令来控制频率和波形不需要复杂协议。建议用单行文本每行一条指令格式为FREQ 1000 // 设置频率单位 Hz WAVE SINE // 切换波形可选 SINE / SQUARE AMPL 1.5 // 设置幅值需配合硬件增益控制 SWEEP 100 1000 5 // 从 100Hz 扫到 1kHz持续时间 5 秒STM32 端用HAL_UART_Receive_IT接收字符遇到\n解析一行。下面是核心解析代码// uart_cmd.c uint8_t rx_buffer[64]; uint8_t rx_index 0; uint8_t rx_data; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { if (rx_data \n) { rx_buffer[rx_index] \0; ParseCommand((char *)rx_buffer); rx_index 0; } else { if (rx_index sizeof(rx_buffer) - 1) { rx_buffer[rx_index] rx_data; } } HAL_UART_Receive_IT(huart1, rx_data, 1); } } void ParseCommand(char *cmd) { char param[32]; uint32_t freq; if (sscanf(cmd, FREQ %lu, freq) 1) { DDS_SetFrequency(freq, TIMER_TRIGGER_HZ); // 可选把设定值回传方便上位机确认 char ack[32]; sprintf(ack, FREQ OK %lu\r\n, freq); HAL_UART_Transmit(huart1, (uint8_t *)ack, strlen(ack), 100); } else if (sscanf(cmd, WAVE %s, param) 1) { if (strcmp(param, SINE) 0) { waveform_table sine_wave_table; } else if (strcmp(param, SQUARE) 0) { waveform_table square_wave_table; } HAL_UART_Transmit(huart1, (uint8_t *)WAVE OK\r\n, 9, 100); } }这段代码用sscanf而不是手动字符串比较是为了方便扩展参数。注意sscanf解析%lu时对应unsigned longSTM32 上uint32_t就是unsigned long但保险起见可以强转一下。频率接收建议加上限幅度小于 1Hz 或大于 50000Hz 直接丢弃避免误输入导致 DAC 输出不可预期的波形。如果上位机持续以 10Hz 频率改频率DDS_SetFrequency里的 64 位除法每次大约耗时几微秒完全不影响主循环。5.2 扫频模式的时序设计与频率步进策略扫频是信号发生器的高频使用场景用于测滤波器频率响应、扬声器阻抗曲线或者做振动台扫频测试。扫频设计有两个难点频率步进怎么选以及每个频率点停留多久。如果从 100Hz 扫到 10kHz总共 9900Hz 范围步进 10Hz那么需要 990 个频率点。每个点停留 10ms总耗时约 10 秒。但要注意的是人耳对对数频率更敏感所以线性步进在低频段听感变化快高频段变化慢更好的方式是对数步进频率按倍数增长比如每次乘以 1.01这样在示波器上看到的频谱包络更均匀。代码实现可以用非阻塞方式在主循环里检查时间// sweep.h/c —— 非阻塞扫频 #define SWEEP_POINTS 200 typedef struct { uint32_t start_freq; uint32_t end_freq; uint32_t duration_ms; uint32_t last_update; uint8_t active; float freq_multiplier; } sweep_ctx_t; void Sweep_Start(sweep_ctx_t *ctx, uint32_t start, uint32_t end, uint32_t duration_ms) { ctx-start_freq start; ctx-end_freq end; ctx-duration_ms duration_ms; ctx-last_update HAL_GetTick(); ctx-active 1; float ratio (float)end / (float)start; ctx-freq_multiplier powf(ratio, 1.0f / SWEEP_POINTS); DDS_SetFrequency(start, TIMER_TRIGGER_HZ); } void Sweep_Tick(sweep_ctx_t *ctx) { if (!ctx-active) return; uint32_t now HAL_GetTick(); uint32_t elapsed now - ctx-last_update; uint32_t step_ms ctx-duration_ms / SWEEP_POINTS; if (elapsed step_ms) { ctx-last_update now; static uint32_t current_freq 0; if (current_freq 0) { current_freq ctx-start_freq; } current_freq (uint32_t)(current_freq * ctx-freq_multiplier); if (current_freq ctx-end_freq) { current_freq ctx-start_freq; // 循环扫描 } DDS_SetFrequency(current_freq, TIMER_TRIGGER_HZ); } }扫频过程中由于 DDS 相位累加器不回零频率切换时相位是连续的不会出现“咔咔”的爆音。但如果频率跨度太大比如 1Hz 到 50kHz频率字变化范围跨越几千倍一次性切换会产生很大的瞬时相位增量听起来像杂音。解决方法是把扫频改成多次小步进每次频率增量不超过当前频率的 5%。5.3 验证输出是否准确的三个手段固件写完后验证环节不能省以下三个方法廉价有效按优先级排列第一用示波器读频率。示波器自带频率测量功能在 1kHz 和 10kHz 两个典型频率点上读值误差应在 0.1% 以内超过这个数说明系统主频不准或者 FCW 计算有溢出。第二用万用表 AC 档测输出幅值对 1kHz 正弦波读到的交流电压应约为设计满幅的 0.707 倍例如设计 3.3V 峰峰值读数约 1.17V。如果读数偏差超过 20%检查运放增益电阻是否焊错。第三用耳机或者简易音频放大器听扫频过程正常扫频应该是平滑上升的音调如果有“嘶嘶”声则说明波形表里有坏点检查数组越界或者查找表索引计算。还有最后一个很多人忽略的验证点把信号发生器的输出直接接到 STM32 的 ADC 输入用 ADC 采回来对比原始波形表可以算出失真度。但 ADC 采样和 DAC 输出共用同一个系统时钟存在频谱泄漏问题读数会偏乐观所以这个只做辅助最终以独立示波器为准。本文还有配套的精品资源点击获取
返回列表