简介:面向嵌入式开发者的STM32函数信号发生器工程包,基于STM32内置DAC与DMA机制,可生成方波、三角波、正弦波,频率100Hz至10kHz可调,按键中断支持实时调节频率与幅值,每次步进100Hz,适合电子工程、通信与测试测量等领域的学习与原型验证。压缩包共188个文件,包含39个C源文件、41个头文件,另有Keil工程配置、编译产物(axf/hex/map)、调试辅助文件等,整体仅2.96MB,目录结构清晰,便于快速定位主程序、外设驱动与中断处理模块。已有3840人学习下载。通过研读工程代码,可掌握STM32定时器触发DAC输出、DMA循环搬运波形数据、外部中断处理用户按键等核心实现,还能借助hex/axf文件直接烧录运行,或参考工程配置理解外设初始化与时钟树设置,是一份可运行的嵌入式信号发生器参考工程,对掌握DAC、DMA和中断系统协同工作很有帮助。
1. 做 STM32 函数信号发生器,第一步不是选单片机,是选信号链路
做个 STM32 函数信号发生器,很多人第一反应是挑一块带 DAC 的开发板,然后把波形“算出来、写进去”。真正动手才发现:同一个程序,换一种定时器配置、换一颗运放,出来的波形完全是两个东西。这个标题看着像单片机项目,实际上难点一大半在模拟链路:DAC 输出之后的那级运放、低通滤波、地线处理,随便哪一环偷懒,示波器上都看得明明白白。本文打算从方案选型、DDS 直接数字频率合成原理、定时器触发 DAC、波形查表实现,一直写到最常见的五个翻车点和进阶的扫频玩法。适合电子竞赛备赛、仪器仪表类课程设计、以及想自己搭一台桌面信号源但又不想买成品的人。如果你只想抄代码,第 4 章可以直接用;如果你想知道为什么代码在你板子上不好使,前面两章才是关键。
2. 方案选型:内置 DAC + DDS 为什么比 PWM 和外置 DDS 芯片更适合动手做
函数信号发生器的核心问题是“怎么把一串有规律的数变成模拟电压”。市面上常见做法有三种:单片机内置 DAC 直接输出、PWM 加低通滤波、外置 DDS 芯片。大部分人第一次选型都会在外置 DDS 芯片和 PWM 之间犹豫,我的建议是:除非你要做到很高频率,否则先做内置 DAC 加 DDS 算法。
先看三者的横向对比,再讲 DDS 原理。选型这件事做得越早,后面返工越少。
2.1 三种信号发生方案的横向对比:成本、频率上限与波形质量
| 方案 | 成本 | 波形质量 | 最高输出频率 | 工程复杂度 | 学习价值 |
|---|---|---|---|---|---|
| 内置 DAC + DDS | 最低,只要 MCU 带 DAC | 中高,受 DAC 位数和更新率限制 | 几十到几百 kHz,取决于更新率 | 中,难点在定时器和中断设计 | 高,核心算法完全可控 |
| PWM + RC 低通 | 最低 | 低,谐波大、边沿粗糙 | 更低,一般不超 10 kHz 且纹波明显 | 低 | 中,能练滤波但做不出干净波形 |
| 外置 DDS 芯片 | 芯片贵一些 | 高,内置完整 DDS 核 | 很高,可达几十 MHz 级别 | 中,SPI 配置 | 低,芯片内部是黑匣子 |
PWM 方案看着简单,定时器翻转引脚再用 RC 滤平,可一旦做方波以外的波形,就暴露出本质问题:PWM 等效 DAC 的位数取决于定时器分辨率,想要 12 位精度就得用 4096 倍分频,输出频率稍微提高,定时器周期耗光,RC 滤波又要把高频纹波压住,结果是需要同时照顾两个矛盾,最后波形质量很勉强。示波器上看正弦波往往带着明显的毛刺和阶梯感,调试起来相当折腾。
外置 DDS 芯片的策略反过来:把相位累加器和波形表做到一颗专用芯片里,单片机用 SPI 写几个寄存器就能输出高质量信号。问题在于,大多数入门者拿到的是“好不好用都不知道”的驱动库,出问题时分不清是 SPI 时序、寄存器配置还是时钟源问题。我见过太多人在频率控制字上栽跟头——不是不懂公式,而是芯片手册的参数表在代码里根本对不上。对课程设计和自研工具来说,这种黑匣子解决不了“让自己学会做信号源”的需求。
所以剩下的内置 DAC + DDS 方案,是把相位累加器、波形表、触发逻辑全部放在 STM32 里自己写。每一个环节都能用调试器看到变量变化,出了问题可以一步步定位。这也是本文后面所有内容的基础。
2.2 DDS 核心原理:相位累加器、频率控制字与查表
直接数字频率合成(DDS)的核心是一个很简单的模型:用一个 N 位的累加器模拟“相位”从 0 到 2π 的循环,每个时钟周期加一个步进值,这个步进值就是频率控制字 FreqWord。累加器溢出时,相当于一个完整波形周期走完了。
输出频率由这个公式决定:Fout = FreqWord × Fupdate ÷ 2^N,Fupdate 是 DAC 的更新率。频率控制字越大,相位跑得越快,每秒输出的波形周期数越多。这里有个关键点:相位累加器的位数决定了频率分辨率,而波形表索引只是取累加器的高几位,两者不冲突。
举个例子。假设更新率 Fupdate 是 200 kHz,相位累加器 N = 32 位,想输出 1 kHz 正弦波,那么 FreqWord = 1k × 2^32 ÷ 200k ≈ 21474836。这个数在代码里用一个 64 位乘法算出来再截断成 32 位就行。
#define PHASE_ACC_WIDTH 32 #define UPDATE_RATE_HZ 200000 uint32_t freq_word; void dds_set_frequency(uint32_t freq_hz) { freq_word = (uint64_t)freq_hz * (1ULL << PHASE_ACC_WIDTH) / UPDATE_RATE_HZ; }逻辑说明:把频率值换算成“每个更新周期相位前进多少”。1ULL << 32 在 64 位运算里不会溢出,除以更新率后,结果正好落在 32 位范围内。注意这里必须先放大再除,不能先除后乘,否则低频时量化误差会大得难以接受。参数方面,UPDATE_RATE_HZ 要和实际 DAC 触发频率严格一致,否则实际输出频率会偏移一个固定的比例。
为什么取累加器的高 8 位做波形表索引而不是直接用它当作 DAC 值?因为相位和幅值不是一回事。累加器保存的是相位进度 0~2π,波形表保存的是该相位下对应的幅值。256 点表把 2π 均分成 256 份,累加器高 8 位正好对应这 256 份之一。表越长波形越细腻,但占内存也越多。256 点在 STM32 上刚好放 RAM 里,够用。
2.3 采样点数与最高输出频率怎么算:先算更新率再定指标
DDS 输出信号的最高频率,理论上只受更新率限制,但实际还受“每个周期至少要有几个采样点”约束。这是很多人忽略的:20 kHz 输出时,如果更新率只有 200 kHz,那么每周期只有 10 个点,示波器上看起来就像折线而不是正弦波,谐波也很大。
经验标准是:正弦波每周期至少保留 20 到 64 个采样点。输出频率为 10 kHz 时,若按 32 点算,更新率至少要 320 kHz。但 STM32F103 在 72 MHz 主频下,定时器中断里做一次 DDS 计算再写 DAC 寄存器,大约要占用几十到一百个周期,更新率拉到 500 kHz 以后 CPU 就基本被吃光了。所以实际项目里更新率通常在 100 kHz 到 300 kHz 之间选,再反推最高输出频率。
举个例子,更新率设 200 kHz、每周期按 20 个采样点计算,最高输出频率约为 10 kHz;如果你对波形要求高、每周期要 50 个点,那这个方案的优点就只到 4 kHz。这种取舍必须在项目开始时就定下来,否则后面会发现频率范围根本不够用,只能推翻重做。要想再往上走,要么把外层中断优化成寄存器直操作,要么换更高主频的型号,或者用 DMA 循环搬运波形表,但那些都不是入门阶段的第一选择。
3. 最小系统搭建:硬件接线、引脚分配与定时器触发 DAC 初始化
方案选定之后,下一步是让信号先“跑起来”,再看怎么调好。这里给出一个我常用的最小系统配置:用带内置 DAC 的 STM32F103 系列芯片,PA4 作为 DAC 通道 1 输出,TIM2 定时器驱动 DAC 更新,按键实现波形切换和频率调节,OLED 显示当前参数。整体电路不超过十来个元件,适合在洞洞板或者最小系统板上搭。
3.1 硬件清单与引脚分配:DAC 输出、按键、OLED 如何接线
| 功能 | 引脚 | 说明 |
|---|---|---|
| DAC 输出 | PA4 | DAC 通道 1,串 100Ω 电阻后接运放输入端 |
| TIM2 触发 | 内部连接 | 定时器更新事件同时触发 DAC 转换 |
| 按键 S1 波形切换 | PA0 | 上拉输入,按一次切换正弦/方波/三角波 |
| 按键 S2 频率增加 | PA1 | 上拉输入 |
| 按键 S3 频率减小 | PA2 | 上拉输入 |
| 按键 S4 保存参数 | PA3 | 上拉输入,保存当前频率到 Flash |
| OLED I2C | PB6 / PB7 | 标准 I2C 400kHz |
接线时注意几点。PA4 输出的 DAC 信号属于模拟信号,和数字信号线保持距离,不要贴着按键线走,否则波形毛刺会很难看。DAC 输出和运放之间串一个 100Ω 电阻,是为了防止运放输入电容和长线电感引起自激。运放电源引脚上放一个 100nF 陶瓷电容加一个 10μF 电解电容,两级去耦。地线用粗一点的线或者干脆铺铜,模拟地和数字地单点连接。
典型信号链路是:PA4 → 100Ω → 运放跟随器 → RC 低通 → 最终输出。运放跟随器提供低输出阻抗,让后级滤波和示波器探头不影响 DAC 本身。低通滤波器截止频率设置为目标最高输出频率的 2~3 倍,输出到 20 kHz 时,取 50 kHz 左右比较合适。
3.2 时钟与 DAC 初始化:CubeMX 里配置的关键项
使用 CubeMX 生成工程时,这几项配置直接影响后面 DDS 能否正常工作。主频设到 72 MHz,HSE 外部晶振 8 MHz,PLL 倍频 9 倍。DAC 开启 OUT1,触发源选择 Timer 2 Trigger Out event,输出缓冲建议打开,这样 DAC 直接带载能力会好一些。TIM2 时钟源选内部时钟 72 MHz,预分频器按 359 和自动重装载值按 199 配置,得到 200 kHz 更新率,具体计算看 3.3 节。按键 GPIO 配上拉输入,OLED 的 I2C 速率设 400 kHz。
初始化代码里,DAC 和 TIM2 的配置是配合使用的。DAC 触发源指向 TIM2 的 TRGO,TIM2 的更新事件会同时做两件事:触发 DAC 把数据寄存器里的值锁存到输出,并触发定时器中断让代码准备下一个点。
static DAC_HandleTypeDef hdac; static TIM_HandleTypeDef htim2; void MX_DAC_Init(void) { DAC_ChannelConfTypeDef sConfig = {0}; __HAL_RCC_DAC_CLK_ENABLE(); hdac.Instance = DAC; HAL_DAC_Init(&hdac); sConfig.DAC_SampleAndHold = DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger = DAC_TRIGGER_T2_TRGO; /* TIM2更新事件触发 */ sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&hdac, &sConfig, DAC_CHANNEL_1); HAL_DAC_Start(&hdac, DAC_CHANNEL_1); } void MX_TIM2_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 71; /* 72MHz/(71+1)=1MHz */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 4; /* 1MHz/(4+1)=200kHz */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfig_Start(&htim2, &sMasterConfig); HAL_TIM_Base_Start_IT(&htim2); /* 开启更新中断 */ }逻辑说明:TIM2 的预分频器把 72 MHz 降成 1 MHz,自动重装载值是 4,所以更新频率是 200 kHz。DAC 的触发源设置成 TIM2 TRGO 之后,每个更新事件硬件自动把 DHR 数据寄存器里的内容搬到 DOR 输出寄存器,不需要软件额外触发。参数需要注意的是 Prescaler 和 Period 都可以按实际主频调整,但两者组合后的结果必须和 DDS 代码里的 UPDATE_RATE_HZ 保持一致。
为什么不把定时器频率配成 3.6 MHz?因为每触发一次就要进一次中断,3.6 MHz 意味着中断服务函数必须在 72 MHz 主频下 20 个周期内跑完,这在实际 C 代码里做不到。200 kHz 是兼顾波形质量与 CPU 负载的折中选择。
3.3 定时器触发 DAC 输出:为什么不用 while 循环直接写寄存器
有人会问:不在中断里写 DAC,直接在 while 主循环里不断输出采样点行不行?答案是不行。主循环里跑按键扫描、OLED 刷新、各种判断,每个循环耗时不确定,采样点之间的间隔会忽长忽短。信号发生器的本质是时间基准,间隔抖动直接体现在输出波形上,表现为相位噪声和杂散频率。定时器中断的意义就是让每一个采样点都在精确等间隔的时刻被写入。
实现方式是在 TIM2 更新中断回调里调用 DDS 的节拍函数。这个函数每执行一次,相位累加器前进一个 FreqWord,然后查表写 DAC。代码里要注意 HAL 库的中断回调处理:写HAL_TIM_PeriodElapsedCallback,判断定时器实例是 TIM2,再调用自己写的dds_tick。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { dds_tick(); /* 在中断里执行DDS一步 */ } }这里的dds_tick并不做太复杂的事,就是加一个累加器、查一次表、写一次寄存器,整个函数控制在几十个周期内。它会占用 CPU,但信号发生器本来就是专职设备,200 kHz 更新率下整体负载可以接受。如果你的项目还要做 UI 动画或者实时波形显示,就需要把更新率降到 100 kHz,或者把 DAC 数据搬运改成 DMA 方式,总之不要让主循环承担输出任务。
4. DDS 波形合成实现:正弦、方波、三角波与频率步进
硬件链路跑通之后,核心的 DDS 算法就可以动手写了。这一章直接给完整实现,从波形表生成到三波形切换,再到频率调节的步进设计。先准备表,再写 DDS 节拍,最后考虑如何操作频率。
4.1 查找表准备:16 位正弦表与 12 位 DAC 的位宽匹配
STM32F103 的内置 DAC 是 12 位分辨率,但常见的波形计算习惯用 16 位整数,所以处理的关键是位宽匹配。生成正弦表时,按 16 位单极性范围保存,取值从 0 到 65534,再在输出到 DAC 时右移 4 位变成 12 位。这样做的原因是:查表计算用 16 位精度,舍入误差更小;DAC 输出时截断到 12 位,对最终波形的量化噪声影响不大。
正弦表长度取 256 点,每个点是 2 字节,总占用 512 字节 RAM,在 STM32 里完全放得下。生成代码如下,在 main 函数开头调用一次。
uint16_t g_sine_table[256]; void waveform_table_init(void) { for (int i = 0; i < 256; i++) { g_sine_table[i] = (uint16_t)(32767.0 * (1.0 + sin(2.0 * M_PI * i / 256.0)) + 0.5); } }逻辑说明:公式32767.0 * (1.0 + sin(...))把正弦值从 -1 到 1 映射到 0 到 65534 之间,保证 DAC 输出的电压在 0 到 VREF 之间,是单极性输出。加 0.5 是做四舍五入而不是直接截断,减小直流偏移误差。参数方面,表长 256 决定了相位分辨率是 2π/256 ≈ 1.4 度,肉眼已经看不出量化阶梯。
方波表和三角波表同理。方波表只有两个值:索引小于 128 填 65535,大于等于 128 填 0。三角波表用分段线性计算:索引 0 到 127 从 0 线性升到最大值,索引 128 到 255 从最大值线性降回 0。实际使用时可以把三种表合成一个二维结构,或者按波形类型切换指针。
uint16_t g_wave_table[3][256]; const uint16_t *g_cur_table = g_wave_table[0]; void waveform_table_init(void) { for (int i = 0; i < 256; i++) { g_wave_table[0][i] = (uint16_t)(32767.0 * (1.0 + sin(2.0 * M_PI * i / 256.0)) + 0.5); g_wave_table[1][i] = (i < 128) ? 65535 : 0; g_wave_table[2][i] = (i < 128) ? (i * 512) : ((255 - i) * 512); } }这段代码把三种波形一次性生成。注意三角波最大值是 127*512=65024,略小于 65535,避免溢出和 DAC 满偏后削顶。
4.2 DDS 核心代码:相位累加器、频率控制字与波形输出
DDS 节拍是整个项目的心脏。每次定时器中断到来,累加器加一次频率控制字,取高 8 位做表索引,再把表中值右移 4 位写进 DAC。配合波形切换变量,就能输出三种波形。
#define PHASE_ACC_WIDTH 32 #define TABLE_SIZE 256 #define UPDATE_RATE_HZ 200000 static uint32_t s_phase_acc = 0; static uint32_t s_freq_word = 0; volatile uint8_t g_wave_type = 0; void dds_set_frequency(uint32_t freq_hz) { s_freq_word = (uint64_t)freq_hz * (1ULL << PHASE_ACC_WIDTH) / UPDATE_RATE_HZ; } void dds_tick(void) { s_phase_acc += s_freq_word; uint16_t index = (s_phase_acc >> 24) & 0xFF; uint16_t sample = g_wave_table[g_wave_type][index]; DAC->DHR12R1 = sample >> 4; /* 右移4位,从16位转12位 */ }逻辑说明:s_phase_acc是无符号 32 位变量,溢出是自然行为,不需要清零。取(s_phase_acc >> 24) & 0xFF得到高 8 位,正好映射到 256 点表的下标。写 DAC 时直接用寄存器DAC->DHR12R1而不是调用HAL_DAC_SetValue,因为后者带参数检查和多层函数调用,在中断里会拉长执行时间。参数方面,g_wave_type为 0 代表正弦、1 代表方波、2 代表三角波,按键切换时只改这个变量。
关于相位累加器位数的选用:32 位累加器配合 200 kHz 更新率,频率分辨率是 200k ÷ 2^32 ≈ 0.000046 Hz,实际取整到 1 Hz 完全没有问题。如果你用 16 位累加器,低频时频率步进会很粗,旋钮调节时能感觉到跳跃。
4.3 频率调节与步进:按键、旋钮与参数边界
信号发生器必须有方便的调频手段。按键方案简单直接,但频率跨度大时必须做步进自适应,否则从 10 Hz 调到 100 kHz 要按一万次。我习惯的做法是把频率范围分成几段,每段用不同的步进值。
uint32_t g_freq_hz = 1000; uint32_t freq_step_get(uint32_t freq) { if (freq < 100) return 1; if (freq < 1000) return 10; if (freq < 10000) return 100; return 1000; }代码逻辑是按当前频率区间返回步进。低频段步进小,方便精细调节;高频段步进大,从 1 kHz 调到 100 kHz 只需要按一百次。实际应用中可以在按键边缘触发时调用dds_set_frequency,让新频率立刻生效。这个函数放在主循环里扫描按键时调用,不要放在中断回调里做耗时判断。
频率范围也要设边界,防止用户把频率调到更新率的十倍以上。按 200 kHz 更新率、每周期至少 4 个采样点打底,最高输出限制在 50 kHz 比较合理;最低频率可以到 1 Hz,32 位累加器在这个值下依然平滑。
4.4 幅值校准与低通滤波设计
DAC 输出经过运放后,实际幅值和理论值会有几个百分比偏差,来源包括电源电压误差、运放失调、电阻精度。如果项目需要精确的 Vpp 值,就在输出端用示波器测一次,把误差折算成一个补偿系数,在查表后乘上系数。更简单的做法是后级加一个可调电位器做幅度微调,校准一次后就不动它。
低通滤波的作用是抹平 DAC 输出的量化台阶。DAC 更新率 200 kHz 时,输出频谱里除了目标信号,还有 200 kHz 及以上的镜像分量,这些必须滤掉。一阶 RC 滤波转折频率取 50 kHz 时,20 kHz 信号衰减约 0.16 dB,可以忽略;200 kHz 镜像被衰减约 12 dB,效果还能接受。想要更陡的衰减就改成二阶有源滤波,但要注意运放带宽和相位裕量。
滤波电阻电容的取值按照转折频率 f = 1 ÷ (2πRC) 计算。R 取 1 kΩ,C 取 3.3 nF,算出约 48 kHz,比较合适。电阻太小会加重 DAC 输出负载,电阻太大会增大输出阻抗,影响带载能力。
5. 避坑记录:函数信号发生器最常见的 5 个翻车点
这一章全部来自实际调试过程中踩过的坑。每一条都按“现象 → 原因 → 解决”的顺序写,你可以对照自己的现象快速定位。很多问题不是代码写错,而是硬件链路和工程习惯导致的。
5.1 输出波形带毛刺与噪声:没加运放缓冲就直接量 DAC 引脚
现象:用示波器探头直接点在 PA4 引脚上,波形整体形状正确,但上面叠加了很多高频毛刺,尤其在按键按下或者 OLED 刷新时更明显。原因有两层:一是 DAC 输出阻抗较高,长探头线本身就成了天线;二是数字电路的地噪声通过共用地线串进了模拟输出。解决方法是把 PA4 先接运放跟随器再输出,并在运放电源处做好去耦。注意探头的地线夹要尽量短,最好用弹簧地针。
5.2 频率调高后幅度明显衰减:运放选型的两个参数没看
现象:低频时输出幅值正常,频率上到十几 kHz 后,示波器测量 Vpp 一路往下掉,50 kHz 时可能只剩低频的一半。原因多数是运放的增益带宽积 GBW 不够,或者压摆率 SR 不足。正弦波输出要求 SR ≥ 2π × Vp × f,例如 Vp = 3V、f = 50kHz 时,SR 至少约 0.94 V/μs,看着不高,但如果你用的是低速通用运放,实际余量很小。解决方法是重新选运放,GBW 取最高输出频率的 10 倍以上,SR 按上面公式算完再留 3 倍余量。
5.3 正弦波在高频段变成“梯田”:每周期采样点太少
现象:输出 1 kHz 时波形光滑,调到 20 kHz 后正弦波变成了明显的折线,像楼梯一样。原因是更新率没变,频率高了每个周期只有 10 个采样点,DAC 输出本身就有 10 级台阶。低通滤波器能削掉一部分台阶感,但削不干净。解决方法是提高更新率到 300 kHz 以上,或者接受频率上限的降低。如果更新率已经拉到 CPU 能承受的极限,那就要从波形表做插值,在两个采样点之间补一个中间值,把每周期点数翻倍,这个办法很有效。
5.4 方波经过低通变成圆角边沿:滤波器和方波天生打架
现象:正弦波和三角波都正常,切换到方波后边沿变圆弧了,频率越高越明显。原因是输出链路里那个用于滤除 DAC 镜像的低通滤波器,同时把方波里的高频谐波也滤掉了。方波的陡峭边沿依赖高次谐波,滤波器把这些谐波衰减后,边沿自然变圆。解决方法不是把滤波器去掉,而是让方波走一条不分频的路径:软件里把方波输出直接从单片机另一个普通 GPIO 引出,或者用比较器把正弦波整形。这样既保留干净的方波,又不动正弦波的滤波链路。
5.5 按键按下瞬间波形断裂:阻塞式消抖把 DDS 更新卡住
现象:任何波形运行中,按下按键时示波器上会出现一个明显的缺口或毛刺,按键松开后又恢复。原因十有八九是按键消抖用了HAL_Delay,这个函数阻塞了主循环,但更关键的是它可能阻塞了中断处理之外的所有任务。如果按键扫描放在主循环里,按下时主循环卡了几十毫秒,DDS 的相位累加器虽然还在中断里跑,但界面和参数更新全部停滞,波形会有一个瞬态畸变。解决方法是把按键消抖改成周期性扫描的状态机:定时器每 1 ms 扫一次按键,连续 5 次读到同一电平才认为是有效按键,期间不调用任何延时函数。
uint8_t key_debounce(uint8_t raw) { static uint8_t cnt = 0; static uint8_t last = 1; if (raw != last) { cnt = 0; last = raw; return 0; } if (cnt < 5) { cnt++; return 0; } return raw; /* 连续5次采样相同,判定为有效 */ }这段代码放在 1 ms 定时器中断里执行,或者放在主循环配合HAL_GetTick做非阻塞延时。它不会像HAL_Delay那样卡住系统,按键响应也不受影响。
6. 进阶玩法:线性/对数扫频、旋转编码器与参数掉电保存
基础功能稳定之后,信号发生器会迎来一个高频需求:扫频。在测量滤波器幅频响应、扬声器阻抗曲线时,你需要信号源自动从低频率扫到高频率。线性扫频适合窄范围,对数扫频适合从 100 Hz 扫到 50 kHz 这种跨几个数量级的场景。
线性扫频实现很简单,每隔固定时间加一个固定步进。对数扫频则要改成频率按比例增长,比如每步乘以 1.01。从 100 Hz 到 50 kHz,线性扫要几万步,对数扫只需要几百步,效率高得多。代码里只需要维护当前频率和方向标志,在定时器里按节奏调用dds_set_frequency即可。
#define SWEEP_START_HZ 100 #define SWEEP_END_HZ 50000 void sweep_tick(void) { static uint32_t freq = SWEEP_START_HZ; static uint8_t up = 1; static uint32_t cnt = 0; if (++cnt < 2) return; /* 每2ms步进一次 */ cnt = 0; if (up) { freq = (uint32_t)(freq * 1.01f); if (freq >= SWEEP_END_HZ) up = 0; } else { freq = (uint32_t)(freq * 0.99f); if (freq <= SWEEP_START_HZ) up = 1; } dds_set_frequency(freq); }逻辑说明:乘法代替加减完成对数扫频,频率变化率恒定,低频段变化慢、高频段变化快,符合幅频响应测试的习惯。2 ms 步进一次,扫完整段大约几百毫秒,便于用示波器观察。注意浮点乘法在中断里别频繁用,这里只在步进时执行一次,影响不大。
如果要进一步改善操控手感,可以把按键改成旋转编码器。编码器的正交信号解码放在一个周期中断里,配合 2 ms 消抖即可。每次旋转一格调用一次dds_set_frequency,比按键连续按要舒服得多。掉电保存则用 STM32 内部 Flash 的最后一个扇区,每次用户按下保存键时,把当前频率和波形类型写进去,上电时读出来恢复。别用 EEPROM 模拟,那会频繁擦写磨损 Flash,简单固化的参数直接用内部 Flash 就好。
最后说一个我自己的教训:第一次做扫频时,我把步进间隔放在主循环里用延时实现,结果扫出来的频率曲线严重非线性,低频段走得慢、高频段快得像飞,后来才发现是主循环里 OLED 刷新占用了大量时间,步进节奏被拖乱了。从那以后,任何和时间相关的逻辑一律放进定时器中断,主循环只负责显示和按键。这个习惯救了我很多次。希望帮到你。
本文还有配套的精品资源,点击获取