STM32F407 DAC+DMA+DDS实现可调波形信号源 简介这份资源面向学习STM32嵌入式开发与信号发生技术的读者提供一套基于STM32F407的DDS函数发生器完整工程。它通过软件配合DAC的方式可生成任意频率、任意占空比、0至3.3V幅值的正弦波、方波与三角波可直接配套正点原子探索者STM32F407ZGT6开发板运行无需改动代码适合课程设计、电子竞赛与自学练手。压缩包共307个文件约10.96MB以c与h源码、o与crf编译中间文件、d依赖文件为主另含uvprojx工程文件、hex烧录文件、sct分散加载文件及bat批处理脚本覆盖核心驱动、固件库、硬件抽象与用户代码等模块。目前已有376人学习下载。读者可从中获得可直接编译下载的完整工程理解DDS查表与定时器、DAC协同输出波形的实现思路并借助工程结构快速定位外设配置与波形参数调整代码为后续二次开发与信号源设计提供参考。1. 一块 F407 加上几行代码就能替代几千块的信号源去年帮一个做模拟电路的朋友调滤波器他桌上摆着一台老式函数发生器旋钮接触不良每次切频率都得拍两下。我问他为什么不换个新的他说凑合用。后来我拿手边一块 STM32F407 的板子用片上 DAC 加定时器写了个 DDS 出来频率、幅度、波形都能在串口里改他看完直接把自己那台老机器收进了柜子。这个项目干的就是这件事用 STM32F407 的 DAC 配合定时器触发通过软件方式实现 DDS直接数字频率合成输出正弦波、方波、三角波、锯齿波频率和幅度可调。它适合正在学 STM32 外设、想搞明白 DAC 和定时器怎么联动的人也适合需要一个低成本、可编程信号源的硬件开发者。核心思路不复杂但要把频率精度、波形切换、幅度控制这几件事同时做对里面有不少细节值得拆开讲。2. 架构拆解DDS 的相位累加器怎么落到 F407 上2.1 DDS 的基本模型与 STM32 的对应关系DDS 的核心是一个相位累加器。每个采样时钟周期累加器加上一个频率控制字FTW累加器的值就代表当前相位。用这个相位去查表得到对应的幅度值送到 DAC 输出。输出频率的公式是f_out (FTW × f_clk) / 2^N其中 f_clk 是采样时钟频率N 是相位累加器的位宽。在 STM32F407 上f_clk 由定时器触发频率决定N 一般取 32 位。这个公式决定了频率分辨率——N 越大能分辨的最小频率间隔越小。F407 的 DAC 是 12 位所以查表得到的值要右移 20 位32 - 12 20才能匹配 DAC 的数据宽度。这一步如果搞错输出波形会严重失真或者幅度小得可怜。整个数据流是这样的定时器溢出 → 触发 DAC → DMA 把查表值搬到 DAC 数据寄存器 → DAC 输出模拟电压。用 DMA 是为了让 CPU 不参与每次采样否则在高频输出时 CPU 根本忙不过来。2.2 定时器触发频率的确定定时器触发频率不能随便设。它受两个条件约束一是 DAC 的建立时间F407 的 DAC 在快速模式下建立时间大约 1μs所以触发间隔不能短于这个值二是 DMA 的搬运速度每次触发要搬一个半字12 位数据放在 16 位寄存器里。我一般把定时器触发频率设在 100kHz 到 500kHz 之间。取 200kHz 的话输出正弦波最高能到 20kHz 左右每周期 10 个点波形已经比较粗糙了。如果要输出更高质量的正弦波触发频率得往上提但受限于 DAC 建立时间实际上限大概在 1MHz 左右。定时器的配置用 TIM2 比较方便它挂在 APB1 上时钟频率 84MHz。要得到 200kHz 的触发频率预分频器和自动重载值这样算// TIM2 时钟 84MHz目标触发频率 200kHz // 预分频器设为 0自动重载值 84MHz / 200kHz - 1 419 TIM_TimeBaseInitTypeDef TIM_InitStruct; TIM_InitStruct.TIM_Prescaler 0; TIM_InitStruct.TIM_Period 419; TIM_InitStruct.TIM_ClockDivision TIM_CKD_DIV1; TIM_InitStruct.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_InitStruct); // 配置 TIM2 的 TRGO 输出用于触发 DAC TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);这里 TIM_Period 设为 419是因为定时器从 0 计数到 419 共 420 个周期84MHz / 420 200kHz。TIM_TRGOSource_Update 表示在更新事件时输出触发信号DAC 收到这个信号就开始一次转换。2.3 DAC 与 DMA 的联动配置DAC 要配置成定时器触发模式而不是软件触发。DMA 要配置成循环模式这样查表数据可以反复搬运不需要 CPU 干预。// DAC 配置 DAC_InitTypeDef DAC_InitStruct; DAC_InitStruct.DAC_Trigger DAC_Trigger_T2_TRGO; // TIM2 触发 DAC_InitStruct.DAC_WaveGeneration DAC_WaveGeneration_None; DAC_InitStruct.DAC_OutputBuffer DAC_OutputBuffer_Enable; DAC_InitStruct.DAC_LFSRUnmask_TriangleAmplitude DAC_LFSRUnmask_Bit0; DAC_Init(DAC_Channel_1, DAC_InitStruct); DAC_Cmd(DAC_Channel_1, ENABLE); // DMA 配置 DMA_InitTypeDef DMA_InitStruct; DMA_InitStruct.DMA_Channel DMA_Channel_7; DMA_InitStruct.DMA_PeripheralBaseAddr (uint32_t)DAC-DHR12R1; DMA_InitStruct.DMA_Memory0BaseAddr (uint32_t)wave_table; DMA_InitStruct.DMA_DIR DMA_DIR_MemoryToPeripheral; DMA_InitStruct.DMA_BufferSize TABLE_SIZE; DMA_InitStruct.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStruct.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStruct.DMA_Mode DMA_Mode_Circular; DMA_InitStruct.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Stream5, DMA_InitStruct); DMA_Cmd(DMA1_Stream5, ENABLE); // 使能 DAC 的 DMA 请求 DAC_DMACmd(DAC_Channel_1, ENABLE);DMA_Channel_7 对应 DAC 通道 1 的请求这个在参考手册里有映射表选错了 DMA 不会搬数据。DMA_Mode_Circular 是关键它让 DMA 搬完一轮后自动回到起点继续搬形成循环输出。DMA_PeripheralDataSize 和 DMA_MemoryDataSize 都设成 HalfWord因为 DAC 数据寄存器是 12 位有效用 16 位访问正好。3. 波形表生成与频率控制从查表到输出的完整链路3.1 四种波形的查表数据生成波形表就是一个数组存的是一个周期内各个采样点的 DAC 值。表越长波形越精细但占用的内存也越多。我一般用 256 个点对 12 位 DAC 来说精度够用内存占用也可接受512 字节。正弦波表用查表法生成方波、三角波、锯齿波可以直接算出来#define TABLE_SIZE 256 #define DAC_MAX_VAL 4095 uint16_t wave_table[TABLE_SIZE]; // 生成正弦波表 void generate_sine_table(void) { for (int i 0; i TABLE_SIZE; i) { // sin 值域 [-1,1]映射到 [0, 4095] float val sinf(2.0f * M_PI * i / TABLE_SIZE); wave_table[i] (uint16_t)((val 1.0f) * DAC_MAX_VAL / 2.0f); } } // 生成方波表 void generate_square_table(void) { for (int i 0; i TABLE_SIZE; i) { wave_table[i] (i TABLE_SIZE / 2) ? DAC_MAX_VAL : 0; } } // 生成三角波表 void generate_triangle_table(void) { for (int i 0; i TABLE_SIZE; i) { if (i TABLE_SIZE / 2) { wave_table[i] (uint16_t)(DAC_MAX_VAL * 2 * i / TABLE_SIZE); } else { wave_table[i] (uint16_t)(DAC_MAX_VAL * 2 * (TABLE_SIZE - i) / TABLE_SIZE); } } } // 生成锯齿波表 void generate_sawtooth_table(void) { for (int i 0; i TABLE_SIZE; i) { wave_table[i] (uint16_t)(DAC_MAX_VAL * i / TABLE_SIZE); } }正弦波表用 sinf 计算值域从 -1 到 1加 1 后变成 0 到 2再乘以 2047.5 映射到 0 到 4095。方波表前半周期输出最大值后半周期输出 0。三角波表前半周期线性上升后半周期线性下降。锯齿波表从 0 线性上升到最大值。这些表在初始化时生成一次之后切换波形只需要重新生成对应的表DMA 会自动搬运新数据。3.2 频率控制字的计算与更新频率控制字决定了输出频率。根据公式 FTW f_out × 2^N / f_clkN 取 32 时2^32 约等于 4.29e9。如果 f_clk 是 200kHz要输出 1kHz 的正弦波FTW 1000 × 4294967296 / 200000 21474836这个值放在 32 位变量里正好。每次定时器触发时相位累加器加上 FTW取高 12 位实际是取高 8 位作为表索引因为表长 256去查表。但这里有个问题如果每次触发都重新计算查表索引CPU 负担太重。实际做法是用 DMA 直接搬运预先算好的表频率控制通过改变定时器触发频率或者改变表内容来实现。改变定时器触发频率的方法更直接输出频率和触发频率成正比。如果表长 256触发频率 200kHz输出频率就是 200kHz / 256 781.25Hz。要输出其他频率就改触发频率// 根据目标输出频率计算定时器周期 // f_out f_clk / TABLE_SIZE // f_clk f_out * TABLE_SIZE // TIM_Period 84MHz / f_clk - 1 void set_output_frequency(uint32_t freq) { uint32_t clk freq * TABLE_SIZE; if (clk 1000000) clk 1000000; // 限制最高触发频率 1MHz uint32_t period 84000000 / clk - 1; TIM2-ARR period; // 产生更新事件让新周期立即生效 TIM2-EGR TIM_EGR_UG; }这个函数把目标频率乘以表长得到需要的触发频率再反算定时器周期。比如要输出 1kHz表长 256触发频率就是 256kHz定时器周期 84MHz / 256kHz - 1 327。写入 ARR 后通过 EGR 寄存器的 UG 位产生一次更新事件新周期立即生效不需要等当前计数周期结束。3.3 幅度控制的实现方式幅度控制有两种做法一种是改 DAC 参考电压这需要外部电路配合另一种是在数字域做乘法把查表值乘以一个系数再输出。数字域做乘法更灵活但每次 DMA 搬运前都要乘一遍CPU 又得参与。折中方案是预计算多个幅度级别的表切换幅度时直接换表。比如把幅度分成 16 级每级对应一个表内存占用 16 × 512 8KBF407 的 192KB SRAM 完全放得下。#define AMP_LEVELS 16 uint16_t wave_tables[AMP_LEVELS][TABLE_SIZE]; void generate_scaled_tables(void) { for (int level 0; level AMP_LEVELS; level) { float scale (float)(level 1) / AMP_LEVELS; for (int i 0; i TABLE_SIZE; i) { wave_tables[level][i] (uint16_t)(wave_table[i] * scale); } } } void set_amplitude(uint8_t level) { if (level AMP_LEVELS) level AMP_LEVELS - 1; // 重新配置 DMA 源地址 DMA1_Stream5-M0AR (uint32_t)wave_tables[level]; }generate_scaled_tables 生成 16 个不同幅度的表set_amplitude 通过改 DMA 的源地址来切换。DMA 的 M0AR 寄存器存的是内存地址改这个寄存器就能让 DMA 从新的表搬数据不需要停止 DMA。4. 避坑与排查DDS 输出不正常时先查这几处4.1 输出波形频率不对现象设置 1kHz 输出示波器测出来是 500Hz 或者 2kHz。原因定时器时钟频率算错了。F407 的 APB1 定时器时钟不一定等于 84MHz如果 APB1 预分频系数不为 1定时器时钟会是 APB1 时钟的 2 倍。另外TIM_Period 的值和实际分频比差 1因为定时器从 0 计数到 ARR 值总共是 ARR1 个周期。解决先用示波器测 TIM2 的触发输出可以把 TRGO 映射到某个引脚确认触发频率和计算值一致。如果不一致检查 SystemCoreClock 和 APB1 分频配置。计算周期时记得减 1。4.2 波形顶部或底部被削平现象正弦波看起来像方波顶部和底部是平的。原因DAC 输出缓冲器没使能或者负载阻抗太低。F407 的 DAC 输出缓冲器驱动能力有限如果直接接低阻抗负载比如 50 欧姆输出幅度会严重下降。另外如果查表值超过了 4095DAC 会输出满量程波形顶部被削平。解决确认 DAC_OutputBuffer_Enable 已设置。输出接高阻抗负载至少 10kΩ或者加一级运放缓冲。检查查表生成代码确保最大值不超过 4095。4.3 DMA 不搬运数据现象DAC 输出一直是 0 或者一个固定电压波形没有变化。原因DMA 通道选错了或者 DMA 请求没使能。F407 的 DMA 通道和请求映射是固定的DAC 通道 1 对应 DMA1 Stream5 的 Channel7。另外DAC 的 DMA 使能位DAC_DMACmd和 DMA 本身的使能位DMA_Cmd都要设置。解决对照参考手册的 DMA 请求映射表确认通道号。检查 DAC_CR 寄存器的 DMAEN 位和 DMA_SxCR 寄存器的 EN 位是否都为 1。可以在 DMA 传输完成中断里翻转一个 GPIO用示波器看有没有中断产生。4.4 切换波形时输出短暂异常现象从正弦波切到方波时输出会跳变一下然后才稳定。原因DMA 正在搬运旧表的数据新表地址写入 M0AR 后DMA 可能还在用旧地址搬完当前轮次。另外如果新表和旧表的直流偏置不同切换瞬间会有电压跳变。解决切换波形前先停止 DMA更新完表地址后再重新使能。或者用双缓冲模式DMA 搬完一轮后自动切换到新缓冲区。对于直流偏置问题确保所有波形的表都映射到相同的电压范围0 到 4095。4.5 高频输出时波形失真严重现象输出 10kHz 以上正弦波时波形变成锯齿状或者幅度明显下降。原因每个周期的采样点数太少。触发频率 200kHz输出 10kHz 时每周期只有 20 个点波形自然粗糙。另外DAC 的建立时间限制了最高触发频率触发太快 DAC 来不及稳定。解决提高触发频率到 500kHz 以上但要注意 DAC 建立时间。如果触发频率已经到极限可以增加表长让每个周期覆盖更多点。不过表长增加会降低最高输出频率因为触发频率固定时表越长输出频率越低。这是一个权衡需要根据实际需求调整。5. 二次开发把 DDS 做成可调参数的工具5.1 用串口命令动态改参数项目跑通之后最实用的改造是加一个串口命令行不用重新烧录就能改频率、幅度、波形。我一般用简单的文本协议比如freq 1000设置频率wave sine切换波形amp 8设置幅度级别。// 串口命令解析简化版 void parse_command(char *cmd) { char *token strtok(cmd, ); if (token NULL) return; if (strcmp(token, freq) 0) { token strtok(NULL, ); if (token) set_output_frequency(atoi(token)); } else if (strcmp(token, wave) 0) { token strtok(NULL, ); if (token) { if (strcmp(token, sine) 0) generate_sine_table(); else if (strcmp(token, square) 0) generate_square_table(); else if (strcmp(token, triangle) 0) generate_triangle_table(); else if (strcmp(token, sawtooth) 0) generate_sawtooth_table(); generate_scaled_tables(); } } else if (strcmp(token, amp) 0) { token strtok(NULL, ); if (token) set_amplitude(atoi(token)); } }这个解析函数用 strtok 按空格拆分命令然后逐个匹配。set_output_frequency 改定时器周期generate_xxx_table 重新生成波形表set_amplitude 改 DMA 源地址。串口接收用中断或者 DMA收到一行就调用 parse_command。5.2 用按键和 OLED 做脱机控制如果不想每次都开电脑连串口可以加几个按键和一个 OLED 屏。按键调频率和幅度OLED 显示当前参数。F407 的 GPIO 资源丰富接几个按键和 I2C OLED 完全没问题。按键用外部中断或者定时器轮询都行。我一般用定时器每 10ms 扫一次做软件消抖。OLED 用 SSD1306 驱动I2C 接口显示频率值和波形名称。// 按键扫描定时器中断里调用 void key_scan(void) { static uint8_t last_state 0; uint8_t current GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); if (current ! last_state) { if (current 0) { // 按下 freq_index; if (freq_index FREQ_TABLE_SIZE) freq_index 0; set_output_frequency(freq_table[freq_index]); OLED_ShowNum(0, 0, freq_table[freq_index], 6); } last_state current; } }freq_table 是一个预定义的频率数组按键按下就切到下一个频率。OLED 显示当前频率值。这种方式适合做便携信号源不需要电脑。5.3 用 DDS 库替代查表法查表法的频率分辨率受表长限制如果要输出任意频率比如 1234.5Hz查表法就不够灵活了。这时候可以用相位累加器加实时计算的方式每个触发周期算一次 sin 值。但实时算 sin 太慢F407 没有硬件浮点 sin 指令软件算一次要几十个周期。折中方案是用相位累加器加查表插值累加器高位查表低位做线性插值。这样频率分辨率可以做到 1Hz 以下同时计算量也不大。// 相位累加器 线性插值 uint32_t phase_acc 0; uint32_t phase_inc 0; void update_phase_inc(uint32_t freq) { // phase_inc freq * 2^32 / f_clk phase_inc (uint32_t)((uint64_t)freq * 4294967296ULL / 200000); } // 在定时器中断里调用 void dds_update(void) { phase_acc phase_inc; uint16_t index phase_acc 24; // 高 8 位作为表索引 uint8_t frac (phase_acc 16) 0xFF; // 次 8 位作为插值系数 uint16_t val1 wave_table[index]; uint16_t val2 wave_table[(index 1) 0xFF]; uint16_t output val1 ((val2 - val1) * frac 8); DAC-DHR12R1 output; }phase_inc 是频率控制字phase_acc 每个触发周期累加一次。高 8 位查表次 8 位做插值。这样输出频率可以精确到 200000 / 2^32 ≈ 0.000046Hz实际上受限于定时器精度做到 0.1Hz 没问题。不过这种方式需要 CPU 在每次触发时都参与计算触发频率不能太高。如果触发频率 200kHzCPU 每 5μs 中断一次中断服务程序要尽量短。我一般把触发频率降到 50kHz 以下用这种方式换频率精度。从那以后我每次做 DDS 相关的项目都会先把定时器触发频率和 DAC 建立时间的关系算清楚再动手写代码。这个顺序搞反了后面调波形会非常痛苦。希望帮到你。本文还有配套的精品资源点击获取