1. 从需求到方案为什么要在STM32上做扫频扫频简单说就是让一个信号的频率按照特定规律比如线性、对数随时间变化。这功能在电子测量、音频分析、材料检测、无线通信测试里太常见了。比如你想测一个音箱的频率响应就得用扫频信号去“激励”它然后分析它的输出。以前干这活儿要么用专门的信号发生器贵要么用电脑声卡精度和频率范围有限。现在一块几十块钱的STM32开发板就能搞定这就是嵌入式开发的魅力——把专业设备的功能“平民化”。STM32实现扫频核心就两点精准的时序控制和灵活的信号生成。时序控制靠定时器这是STM32的看家本领信号生成对于简单的正弦波、方波扫频用DAC数模转换器输出模拟电压或者直接用定时器产生PWM脉宽调制波都行。如果想玩得更花比如生成任意波形的扫频或者频率分辨率要求极高那就得上DDS直接数字频率合成技术了这通常需要外接专门的DDS芯片如AD9833、AD9959或者用STM32的高性能型号配合DMA直接存储器访问和数学运算来模拟。我这次要聊的是基于STM32内部DAC和定时器实现一个基础但完全可用的正弦波扫频发生器。这个方案成本最低硬件最简单一块带DAC的STM32核心板就行特别适合入门学习和对性能要求不苛刻的应用场景比如音频范围内的信号源、简单的网络分析仪前端等。我们会从原理、配置、代码到调试把整个链路掰开揉碎了讲清楚。2. 硬件选型与核心原理为什么是DAC定时器首先得选对芯片。不是所有STM32都有DAC比如经典的STM32F103C8T6蓝色小板就没有而STM32F103C8T6的“大哥”STM32F103RCT6就有。更通用的选择是STM32F4系列比如STM32F407性能更强外设更丰富。我这里以STM32F103RCT6为例因为它比较常见且足以说明问题。扫频的核心原理可以分解为两个并行的任务频率控制需要一个高精度的定时器如TIM2来设定“步进”的时间间隔。比如我们希望频率从1kHz线性扫到10kHz用时10秒。如果每秒改变100次频率即步进率为100Hz那么每次频率需要增加 (10k - 1k) / (10 * 100) 9 Hz。定时器就负责每隔10ms1/100Hz产生一次中断告诉我们“该更新频率了”波形生成需要一个能输出模拟电压的部件这就是DAC。DAC需要源源不断地得到数字量才能输出连续的电压。我们预先计算好一个正弦波表Sine Table存在数组里。DAC的输出频率F_dac由另一个定时器如TIM6的触发频率决定。这个定时器以固定的速率比如100kHz触发DAC进行转换。每次触发DAC就从正弦波表中取出一个点输出。关键的一步是让这两个任务联动任务1频率控制定时器的中断里我们去改变任务2波形生成的“节奏”。具体来说就是改变那个正弦波表的“步进指针增量”。DAC每次触发都固定地从表中取下一个点如果我们每次指针加1输出的正弦波频率就是F_dac / 表长度。如果我们改变这个增量比如变成加2那么DAC“跑完”整个正弦波表的速度就快了一倍输出的频率也就翻倍了。这个“指针增量”就是控制输出频率的关键变量。所以整个系统的信号流是这样的定时器ATIM6以固定频率F_trigger触发DAC转换 - DAC从正弦波表中按当前“指针位置”取值输出 - 指针位置每次增加一个“步进增量”Step Increment- 另一个定时器BTIM2周期性中断根据扫频算法线性/对数重新计算这个“步进增量” - DAC输出的正弦波频率随之改变。为什么不用PWMPWM也可以滤波后得到模拟电压但要做高质量的正弦波滤波电路复杂且动态改变频率时滤波器的特性可能不匹配导致波形失真。DAC是直接输出模拟电压更干净更直接。当然如果只是产生方波扫频用PWM就非常合适。3. 工程搭建与关键外设配置我们使用STM32CubeMX进行初始化配置然后用Keil MDK或者STM32CubeIDE写代码。这样效率最高也最不容易在底层寄存器上出错。3.1 时钟树配置这是稳定性的基石。STM32F103RCT6最高主频72MHz。我们让系统时钟SYSCLK跑在72MHzAPB1总线时钟TIM2, TIM6挂在这里也设为72MHz注意APB1最大36MHz这里设置后定时器时钟会倍频。APB2总线时钟DAC挂在这里设为72MHz。确保所有用到的外设时钟都被使能。3.2 DAC配置在CubeMX中启用DAC1选择通道1PA4。关键配置项输出缓冲Output Buffer启用。这能提高DAC的驱动能力但会引入一点失真。对于音频范围20Hz-20kHz开启没问题。如果追求极低失真可以关闭但需要外部运放缓冲。触发源Trigger选择定时器6触发输出Timer 6 TRGO。这样DAC的转换将由TIM6自动触发无需CPU干预。波形生成Wave generation禁用。我们不用DAC内置的噪声波、三角波生成而是用自己的正弦波表。DMA强烈建议启用为DAC通道1添加一个DMA请求方向为存储器到外设Memory to Peripheral数据宽度为半字Half Word。这样只要DAC被触发DMA就会自动把正弦波表的下一个数据搬给DAC完全解放CPU。这是实现高质量、稳定波形的关键。3.3 定时器6TIM6配置DAC的“节拍器”TIM6是一个基本定时器非常适合做这种简单的触发源。配置时钟源为内部时钟Internal Clock。计算参数目标触发频率F_trigger我们希望DAC的更新率是100kHz。这个值越高生成波形的频率上限也越高奈奎斯特采样定理但对DAC和DMA的速度要求也越高。100kHz对于音频扫频绰绰有余。定时器时钟TIM_CLKAPB1时钟为72MHz但基本定时器挂在APB1上如果APB1预分频系数不为1定时器时钟会倍频。在我们的配置里TIM6的时钟就是72MHz。预分频器PSC设置为71。这样定时器时钟 72MHz / (711) 1MHz。自动重载值ARR设置为9。这样定时器的更新频率 1MHz / (91) 100kHz。这个更新事件Update Event会被设置为触发输出TRGO从而触发DAC转换。在CubeMX中设置PSC71ARR9。开启主模式Master Mode将主模式选择Master Mode Selection设置为更新Update。这样每次定时器溢出更新时都会产生一个触发信号TRGO给DAC。3.4 定时器2TIM2配置扫频的“指挥家”TIM2是一个通用定时器我们用它的周期性中断来更新频率。配置时钟源为内部时钟。计算参数扫频步进率Step Rate假设我们想要100Hz的步进率即每10ms更新一次频率。定时器时钟同样为72MHz。预分频器PSC设置为7199。定时器时钟 72MHz / (71991) 10kHz。自动重载值ARR设置为99。这样定时器中断频率 10kHz / (991) 100Hz即中断周期为10ms。在CubeMX中设置PSC7199ARR99。开启更新中断Update Interrupt。3.5 DMA配置在DAC的配置中启用DMA后CubeMX会自动生成DMA的初始化代码。我们需要检查并确保DMA通道如DMA1_Channel3已配置。模式为循环模式Circular这样当DMA传输完整个正弦波表后会自动回到开头继续传输形成连续的波形。存储器地址自增Increment Memory开启外设地址不增Increment Peripheral。数据宽度为半字16位因为DAC数据寄存器是12位右对齐的我们用16位变量存储刚好。3.6 生成代码与基础框架点击生成代码选择熟悉的IDE。在生成的工程里我们需要做以下几件事定义正弦波表在main.c的开头定义一个全局数组。表的大小很重要太小时波形阶梯状明显太大占用内存且计算增量时精度可能受影响。256点或512点是个不错的折中。#define SINE_TABLE_SIZE 512 uint16_t sineTable[SINE_TABLE_SIZE];填充正弦波表在main函数的初始化部分/* USER CODE BEGIN 2 */之后用数学函数计算正弦值并缩放到DAC的范围内0-4095对应0V-3.3V。为了得到以0V为中间值的交流信号需要叠加一个直流偏置2048对应1.65V。#include math.h // ... 在初始化部分 for(int i 0; i SINE_TABLE_SIZE; i) { // 计算一个完整正弦周期 float sineValue sin(2 * M_PI * i / SINE_TABLE_SIZE); // 缩放到0-4095并加上1.65V偏置 (4095/2 2047.5) sineTable[i] (uint16_t)(2048 2047 * sineValue); }注意使用sin函数需要链接数学库-lm。在Keil中需要在Target选项的Linker栏勾选Use MicroLIB或者手动添加-lm。更高效的做法是预先用电脑生成这个表然后以常量数组形式存储节省运行时的计算开销。启动DMA传输在初始化部分将正弦波表的地址和长度告诉DMA并启动传输。HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)sineTable, SINE_TABLE_SIZE, DAC_ALIGN_12B_R);启动定时器启动TIM6触发DAC和TIM2控制扫频。HAL_TIM_Base_Start(htim6); HAL_TIM_Base_Start_IT(htim2); // 启动并开启中断4. 核心算法实现动态改变频率前面提到改变DAC输出频率的关键是改变从正弦波表中取样的“步进增量”。我们不是让DMA顺序地每次1取数据而是以一个可变的步长在表中跳跃式取数。这个步长就是相位增量Phase Increment。4.1 相位累加器与频率控制字我们需要一个“相位累加器”phaseAccumulator它是一个远大于正弦表索引的变量比如32位无符号整数。每次DMA请求或定时器触发时我们不是直接给DMA一个索引而是让phaseAccumulator加上一个“频率控制字”phaseIncrement。uint32_t phaseAccumulator 0; uint32_t phaseIncrement 0; // 这个值决定了输出频率输出频率F_out的计算公式为F_out (phaseIncrement * F_trigger) / (2^N * SINE_TABLE_SIZE)其中F_trigger是TIM6触发DAC的频率100kHz。N是相位累加器的位宽比如32。SINE_TABLE_SIZE是正弦波表长度512。这个公式的推导基于DDS原理。简单理解phaseIncrement决定了相位累加器跑得多快跑得越快正弦波表被“遍历”得越快输出频率就越高。为了从phaseAccumulator得到正弦表索引我们取它的高若干位因为累加器位数多表索引位数少。例如对于32位累加器和5122^9点的表我们可以取phaseAccumulator的[31:23]这9位作为索引。// 获取当前正弦表索引假设phaseAccumulator是32位 uint16_t tableIndex (phaseAccumulator 23) 0x1FF; // 取高9位对应512点 // 然后将 sineTable[tableIndex] 提供给DAC但是我们用了DMA循环模式DMA是自动顺序访问数组的怎么实现这个“跳跃”呢这里有个技巧我们不直接改变DMA的访问方式而是动态地改变DMA传输的目标数组。也就是说我们准备多个不同频率的正弦波表或者实时计算波形数据填充到DMA传输的缓冲区。对于STM32F1这类性能有限的芯片实时计算负担较重更实用的方法是方法双缓冲区与实时更新我们设置DMA为双缓冲区模式Double Buffer Mode或者使用两个数组bufferA和bufferB和普通循环DMA通过手动切换。在TIM2的中断服务函数10ms一次里我们根据当前要求的频率计算出一个完整的、对应新频率的正弦波周期数据填充到下一个缓冲区比如bufferB。当DMA传输完当前缓冲区bufferA时会产生一个**半传输完成Half Transfer Complete或传输完成Transfer Complete**中断。在这个DMA中断里我们将DMA的目标地址切换到已经准备好的新缓冲区bufferB。这样输出的波形在缓冲区边界处实现了频率的无缝切换。由于切换发生在DMA传输间隙且整个缓冲区是一个完整的周期因此不会产生波形断裂或毛刺。4.2 TIM2中断服务函数扫频逻辑在stm32f1xx_it.c中找到TIM2_IRQHandler或者使用HAL库的回调函数HAL_TIM_PeriodElapsedCallback。// 在main.c中定义全局变量 float startFreq 1000.0; // 起始频率 1kHz float stopFreq 10000.0; // 停止频率 10kHz float sweepTime 10.0; // 扫频时间 10秒 float currentFreq startFreq; uint32_t stepCount 0; uint32_t totalSteps sweepTime * 100; // 步进率100Hz扫10秒共1000步 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim-Instance TIM2) { // 1. 计算当前步的目标频率 (线性扫频) float targetFreq; if(stepCount totalSteps) { targetFreq startFreq (stopFreq - startFreq) * stepCount / totalSteps; stepCount; } else { targetFreq stopFreq; // 扫频结束保持最终频率 // 也可以在这里重置stepCount实现循环扫频 } // 2. 根据目标频率计算相位增量 phaseIncrement // F_out (phaseIncrement * F_trigger) / (2^32 * TABLE_SIZE) // 所以 phaseIncrement F_out * (2^32 * TABLE_SIZE) / F_trigger // 注意这里用浮点计算实际应用可考虑定点数优化 double freqRatio (double)targetFreq / (double)SAMPLE_RATE; // SAMPLE_RATE F_trigger phaseIncrement (uint32_t)(freqRatio * 4294967296.0); // 2^32 4294967296 // 3. 使用新的phaseIncrement生成一个完整周期的正弦波数据到“准备缓冲区” // 这是一个计算密集型任务放在中断里做要非常小心 // 更好的做法是在中断里只设置一个标志位在主循环或更低优先级任务里完成计算和缓冲区填充。 sweepUpdateFlag 1; targetFrequencyForUpdate targetFreq; // 传递目标频率 } }重要提示在定时器中断里进行大量的浮点运算和数组填充是非常危险的行为会严重阻塞系统可能导致DMA传输断流、波形失真。正确的做法是中断只做最轻量的工作更新标志、计算关键参数把耗时的波形计算和填充放到主循环或一个专门的任务中。对于STM32F1如果没有RTOS可以在主循环中不断检查sweepUpdateFlag为真则进行计算。4.3 主循环中的波形计算与缓冲区切换// 定义双缓冲区 uint16_t dacBufferA[SINE_TABLE_SIZE]; uint16_t dacBufferB[SINE_TABLE_SIZE]; uint16_t *activeBuffer dacBufferA; // DMA当前正在传输的缓冲区 uint16_t *nextBuffer dacBufferB; // 正在准备的下一个缓冲区 int bufferReadyFlag 0; // 下一个缓冲区已准备就绪标志 int main(void) { // ... 初始化代码HAL_Init, SystemClock_Config, 外设初始化 // 填充初始缓冲区 dacBufferA generateSineWave(dacBufferA, SINE_TABLE_SIZE, startFreq); // 启动DMA传输指向dacBufferA HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)dacBufferA, SINE_TABLE_SIZE, DAC_ALIGN_12B_R); // 启动定时器 HAL_TIM_Base_Start(htim6); HAL_TIM_Base_Start_IT(htim2); while (1) { // 检查是否需要更新波形由TIM2中断置位 if(sweepUpdateFlag) { sweepUpdateFlag 0; // 根据最新的targetFrequencyForUpdate生成一个完整周期的正弦波到nextBuffer generateSineWave(nextBuffer, SINE_TABLE_SIZE, targetFrequencyForUpdate); bufferReadyFlag 1; // 标记下一个缓冲区已就绪 } // 其他任务... // 缓冲区切换通常放在DMA传输完成中断中这里只是示意主循环逻辑 } } // DMA传输完成中断回调函数半传输或全传输 void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) { // 全传输完成即一个缓冲区传输完了 if(bufferReadyFlag) { // 切换DMA目标到下一个缓冲区 // 注意HAL库可能没有直接提供切换函数可能需要停止DMA更改地址再启动。 // 更安全的方法是使用DMA双缓冲区模式如果支持硬件自动切换。 bufferReadyFlag 0; // 交换缓冲区指针 uint16_t *temp activeBuffer; activeBuffer nextBuffer; nextBuffer temp; // 重新配置DMA目标地址这里需要调用HAL库函数或直接操作寄存器 // __HAL_DMA_SET_COUNTER(hdma_dac_ch1, SINE_TABLE_SIZE); // hdma_dac_ch1.Instance-CMAR (uint32_t)activeBuffer; // 具体操作需参考HAL库手册和芯片参考手册。 } }generateSineWave函数负责根据给定频率计算一个完整周期的正弦波数据。这里的关键是对于不同的频率我们采样的是同一个数学上的正弦函数只是采样点之间的“相位步进”不同。我们可以利用前面计算的phaseIncrement和相位累加器来高效生成这个波形表。void generateSineWave(uint16_t *buffer, uint32_t size, float freq) { uint32_t phaseInc calculatePhaseIncrement(freq); // 计算该频率对应的相位增量 uint32_t phaseAcc 0; for(uint32_t i 0; i size; i) { phaseAcc phaseInc; // 相位累加 uint32_t index (phaseAcc 23); // 取高9位作为表索引假设原始正弦表是512点标准表 // 这里有个问题我们没有一个“标准表”。我们需要直接计算正弦值。 // 因此更直接的方法是使用sin函数计算每个点的值。 } }实际上对于性能有限的MCU在每次频率更新时都用sin函数计算512个点可能太慢。一个优化技巧是我们仍然预存一个标准的512点正弦波表sineTableStandard。当需要生成频率为f的波形时我们根据phaseIncrement在这个标准表中进行“重采样”。这相当于一个查表加插值的过程比直接计算sin快得多但会引入一些失真。对于音频应用如果SINE_TABLE_SIZE足够大比如1024线性插值的精度是可以接受的。void generateSineWaveByResampling(uint16_t *buffer, uint32_t size, uint32_t phaseInc) { uint32_t phaseAcc 0; uint32_t standardTableSize 512; // 标准表大小 uint16_t *stdTable sineTableStandard; // 预计算的标准正弦表 for(uint32_t i 0; i size; i) { phaseAcc phaseInc; // 将32位的相位累加器映射到标准表的索引取高几位 uint32_t indexHigh phaseAcc (32 - 10); // 假设我们需要10位索引精度1024点 indexHigh indexHigh (standardTableSize - 1); // 取模确保在表范围内 // 线性插值可选提高精度 uint32_t indexLow indexHigh 1; if(indexLow standardTableSize) indexLow 0; float frac (phaseAcc (32 - 10 - 8)) 0xFF; // 获取低8位作为小数部分假设8位插值 float fracRatio frac / 256.0f; float value stdTable[indexHigh] (stdTable[indexLow] - stdTable[indexHigh]) * fracRatio; buffer[i] (uint16_t)value; } }5. 调试、优化与常见问题5.1 调试手段用示波器看波形这是最直接的。连接DAC输出引脚PA4到示波器你应该能看到一个干净的正弦波。改变扫频参数观察频率是否平滑变化。检查波形底部和顶部是否有削顶说明DAC值超出0-4095范围是否有明显的台阶正弦表点数太少或DAC更新率太低。用逻辑分析仪或调试器看时序可以监控TIM2和TIM6的中断是否准时发生DMA传输是否连续。如果波形有毛刺或中断很可能是DMA传输被高优先级任务打断或者缓冲区切换不及时。串口打印调试信息在TIM2中断里通过串口打印当前计算出的targetFreq和phaseIncrement确认扫频逻辑是否正确。5.2 性能优化与注意事项中断服务程序ISR要短这是嵌入式开发的铁律。务必把耗时的操作如浮点计算、填充数组移出中断。像计算phaseIncrement这种简单的浮点乘除如果速度成为瓶颈可以预先计算好查表或者使用定点数运算。DMA双缓冲区 vs 手动切换STM32F1的DMA可能不支持自动的双缓冲区模式。手动切换缓冲区时一定要确保在DMA传输完整个缓冲区的空隙进行切换。使用**半传输完成HT和传输完成TC**中断可以很好地管理两个缓冲区实现“乒乓操作”确保波形连续。时钟精度系统的时钟精度决定了输出频率的精度。如果使用内部RC振荡器HSI精度较差±1%。对于要求高的应用必须使用外部晶振HSE。DAC输出缓冲与滤波DAC输出引脚直接驱动能力有限且输出可能有高频毛刺来自数字开关噪声。建议在DAC输出后接一个**电压跟随器运算放大器**进行缓冲并可根据需要添加一个简单的RC低通滤波器滤除高于目标频段的噪声。动态范围与偏置我们生成的正弦波是叠加在1.65V2048直流偏置上的。如果你的后级电路是交流耦合有隔直电容这个偏置会被滤掉得到正负摆动的正弦波。如果需要真正的双极性输出如±1.65V则需要使用双电源运放电路将DAC的单极性输出转换为双极性。5.3 常见问题与排查没有波形输出检查DAC和TIM6的时钟是否使能。检查DAC输出引脚PA4的配置是否正确模拟模式。用调试器检查phaseAccumulator和phaseIncrement变量是否在变化正弦波表数据是否正常。检查DMA是否成功启动传输计数器CNDTR是否在减少。波形频率不对检查F_triggerTIM6的更新频率计算是否正确。检查phaseIncrement的计算公式特别是2^N的值是否与相位累加器位数匹配。检查正弦波表大小SINE_TABLE_SIZE的定义是否与实际数组大小一致。波形有杂音或断点最可能的原因是缓冲区切换不及时导致DMA读到了错误的数据或同一数据被重复读取。仔细检查DMA中断和缓冲区切换的代码逻辑确保没有竞态条件。可能是中断被其他更高优先级的中断长时间阻塞。优化中断优先级确保DMA和定时器中断能得到及时响应。检查堆栈空间是否足够中断中的局部变量是否过多导致栈溢出。扫频速度不线性或卡顿检查TIM2的中断周期是否稳定。如果系统中有其他中断或任务负载过重可能导致TIM2中断被延迟。确认generateSineWave函数执行时间是否过长。如果它执行时间超过10msTIM2中断周期就会错过下一次频率更新。考虑优化该函数或者降低扫频步进率即增大TIM2的周期。6. 进阶思路从音频到射频上面实现的是一个音频范围的扫频源。如果你想挑战更高频率或者更复杂的波形可以考虑以下方向使用高性能STM32比如STM32F4/F7/H7系列主频更高带FPU浮点运算单元甚至有的带DSP指令集可以更快地计算波形。使用DDS芯片对于高达几十MHz的射频扫频STM32的DAC和定时器就力不从心了。此时可以外接一片DDS芯片如AD9833低成本数MHz、AD9850几十MHz、AD9959四通道数百MHz。STM32只需要通过SPI或并行接口向DDS芯片发送频率控制字剩下的高精度波形生成全部由DDS芯片完成性能有质的飞跃。实现任意波形扫频我们的正弦波表可以替换成任意形状的波形表方波、三角波、自定义波形。扫频时动态更新这个波形表即可。但要注意波形变化过快可能会引入高频分量。加入幅度控制在输出波形的同时用另一个DAC通道或PWM控制一个数字电位器或VCA压控放大器实现扫频过程中的幅度调制AM。与上位机联动通过串口、USB或网络接收来自电脑的扫频参数起止频率、时间、波形类型实现程控信号发生器。这个项目虽然基础但它串联了STM32的定时器、DAC、DMA、中断这几个最核心的外设对理解嵌入式系统中的实时信号处理非常有帮助。调试过程中遇到的每一个问题都会让你对“时间”、“中断”、“数据流”这些概念有更深的理解。