简介:这份资源面向学习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_clk,N 取 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 = 8KB,F407 的 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 值,总共是 ARR+1 个周期。
解决:先用示波器测 TIM2 的触发输出(可以把 TRGO 映射到某个引脚),确认触发频率和计算值一致。如果不一致,检查 SystemCoreClock 和 APB1 分频配置。计算周期时记得减 1。
4.2 波形顶部或底部被削平
现象:正弦波看起来像方波,顶部和底部是平的。
原因:DAC 输出缓冲器没使能,或者负载阻抗太低。F407 的 DAC 输出缓冲器驱动能力有限,如果直接接低阻抗负载(比如 50 欧姆),输出幅度会严重下降。另外,如果查表值超过了 4095,DAC 会输出满量程,波形顶部被削平。
解决:确认 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 在每次触发时都参与计算,触发频率不能太高。如果触发频率 200kHz,CPU 每 5μs 中断一次,中断服务程序要尽量短。我一般把触发频率降到 50kHz 以下,用这种方式换频率精度。
从那以后我每次做 DDS 相关的项目,都会先把定时器触发频率和 DAC 建立时间的关系算清楚,再动手写代码。这个顺序搞反了,后面调波形会非常痛苦。希望帮到你。
本文还有配套的精品资源,点击获取