简介:面向嵌入式开发与电子设计学习者的STM32信号发生器完整工程,基于STM32单片机实现三角波、正弦波与方波的信号生成,涵盖数字信号处理算法、HAL库驱动及Altium Designer原理图与PCB布局,适合入门到进阶的开发者参考电路设计、代码编写与硬件调试。资源包共146个文件,整体大小1.46MB,以C语言源码(31个.c、33个.h)为主,辅以编译生成的hex/axf文件、Altium Designer的SchDoc/PrjPcb工程文件以及docx说明文档,可从中了解DAC输出、PWM调制等波形生成思路,也能直接查看电路原理与PCB走线。目前已有2307人学习下载,对于希望系统实践STM32应用、数字信号处理和电路板设计的读者,这份工程提供了从软件到硬件的完整闭环参考。
1. STM32信号发生器:一个标题背后藏着的完整嵌入式链路
打开这个压缩包之前,你先想清楚一件事:你要的不是一个能出波形的Demo,而是一条把DDS算法、DAC外设、定时器触发、低通滤波、按键交互串起来的完整链路。STM32信号发生器这类项目,网上版本很多,但绝大多数人的卡点不在STM32算力不够,而在选型阶段就翻了车——比如拿着没有DAC的型号硬做模拟输出,或者输出端一接负载波形就塌。这篇文章按我自己做这类东西的固定套路,从DDS原理讲到电路参数,再给出能直接抄的代码骨架和五条真实踩坑记录。适合正在学DDS、打算做课程设计或者想搞一台桌面信号源的人,新手能跟着搭起来,老手可以直接跳到避坑和校准章节。
2. DDS原理与选型:为什么相位累加器决定了信号质量的底线
2.1 相位累加器:信号发生器绕不开的核心公式
直接数字频率合成(DDS)的思路,说白了就是查表。你预先存好一个周期的正弦波采样点,然后以固定速率去查这张表,查表地址的步进决定了输出频率。这个步进在代码里叫频率控制字(FCW),核心公式只有一行:
f_out = FCW × f_clk / 2^N
其中N是相位累加器的位宽,f_clk是每次查表更新的触发频率。我习惯把累加器设成uint32_t,也就是N=32,这样频率分辨率是 f_clk / 2^32。如果f_clk是1MHz,分辨率大约0.00023Hz,对于绝大多数调频需求来说已经完全够用,旋钮拧起来不会有跳变感。
真正要理解的是相位累加器的工作方式。每次触发到来时,累加器加上FCW,溢出部分自然丢弃,取高若干位作为波形表的索引。所以FCW越大,查表跳得越快,输出频率越高。这里有个常见的误解,有人说波形表4096个点,输出频率上限就是f_clk除以4096,这是错的。DDS允许跳过中间点,输出频率可以远高于这个值,代价是每个周期采样点变少,波形变粗糙,混叠分量变多。实际项目中我一般让每个周期至少保留32到64个采样点,波形在示波器上才说得过去。
2.2 选型:先看芯片有没有DAC,再谈别的
这一节是很多人翻车的地方。STM32 F1系列里,C8T6、RBT6这些中低容量型号根本没有DAC外设,只有RCT6、ZET6以及F4系列才有真正的12位DAC。如果你手里只有一块C8T6,那DDS方案只能走PWM滤波,性能上限会低不少。选型优先级我一般这么排:
首选F103RCT6或以上型号,自带DAC、有足够引脚、价格和C8T6差距不大。如果手头只有C8T6,那就踏踏实实走PWM方案,别再纠结“为什么我的DAC寄存器写不进去”。F4系列当然更强,但做信号发生器属于杀鸡用牛刀,除非你同时要跑高分辨率屏幕或者做多通道输出,否则性价比不高。
还有一个容易忽略的点:DAC输出引脚是固定的,F103的DAC通道1在PA4,通道2在PA5。你选的型号即使有DAC,也要确认这个引脚没有被其他外设占用。有些开发板把PA4引到了其他接口上,飞线就能解决,但布局之前看清楚总比焊完再改好。
2.3 DAC直出还是PWM滤波:两套方案的取舍
这两条路我都走过,给你一个很主观的结论:能上DAC就上DAC,PWM滤波是妥协方案。
DAC直出的优势在于波形质量干净,12位分辨率在低频段表现很好,代码也简单,直接把采样值丢给寄存器就行。缺点是需要外部运放做缓冲,因为DAC的输出阻抗不低,负载能力强一点就会拉垮电压。PWM滤波则是用定时器输出固定频率的PWM,再经过RC低通把高频分量滤掉,留下的平均电压就是模拟信号。这个方案成本低,任意型号都能做,但纹波大、带宽低、DAC分辨率换算成占空比精度也受限,想输出一个干净的1kHz正弦都费劲,更别说扫频了。
参数对比如下:
| 方案 | 波形质量 | 最高实用频率 | 外围成本 | 实现难度 |
|---|---|---|---|---|
| DAC直出+运放 | 好,谐波少 | 数十kHz级别 | 一个运放+滤波 | 低 |
| PWM+RC滤波 | 一般,纹波明显 | 几kHz级别 | 电容电阻 | 中 |
我的建议是,课程设计或者工具型项目直接选DAC方案,省下来的调参时间远比省那几块钱有意义。PWM方案唯一让我觉得划算的场景是只需要方波输出,那直接用定时器翻转引脚就行,连滤波都省了。
3. 硬件电路:从DAC引脚到示波器探头之间的每一步
3.1 引脚映射、参考电压与地线布局
硬件设计从引脚分配开始。DAC通道1用PA4,运放供电用3.3V,如果希望输出更大的摆幅,可以把运放电源接到5V,但注意DAC输出电压范围不可能超过VDDA,所以输出幅值上限始终是参考电压。参考电压这里有个细节:F103的DAC参考电压是VDDA,不是VDD,板上VDDA和VDD之间通常接一个磁珠或小电阻。如果你发现输出幅值比预期低,先量一下VDDA是不是被拉低了。
地线布局是我做这类电路吃过亏的地方。数字地、模拟地在布局上要分开,最后单点连接,否则DAC输出的波形上会看到一堆毛刺,怎么调滤波参数都消不掉。F103的VSSA引脚附近就是模拟地汇合点,把DAC输出部分的滤波电容、运放地都接到这里。
3.2 低通滤波器:截止频率和阻容参数怎么定
DAC输出的是阶梯状波形,里面包含大量DAC更新频率的高频分量。低通滤波的作用是保留我们想要的信号频率,压掉这些阶梯分量。我用的是二阶Sallen-Key低通,等值R、C的情况下截止频率:
fc = 1 / (2πRC)
比如R=1kΩ,C=10nF,算出来fc≈15.9kHz。这个配置适合输出0到5kHz的正弦波,阶梯分量被压得很干净。如果你要输出更高的频率,按比例缩小电容即可,比如C=1nF时fc≈159kHz。
这里要克制一点,不要为了追求高频输出把截止频率设得太高。滤波器带宽越宽,高频残留越多,波形看起来越粗糙。我的经验是截止频率设为最高输出频率的2到3倍,兼顾带宽和纯净度。
3.3 运放缓冲与增益:别让负载压垮波形
DAC输出端我习惯接一个单位增益的运放缓冲,而不是直接把信号引到输出端子。原因很简单:DAC的输出阻抗和驱动能力有限,接上示波器探头、音频设备或者后级电路时,电压会被拉低,波形形状也跟着变。运放缓冲后输出阻抗降到很低,负载影响可以忽略。
运放选型注意两个参数:轨到轨输出、单位增益稳定。前者保证低电压下输出摆幅接近电源轨,后者避免缓冲电路自激。LMV358这类常见轨到轨运放就能用,如果希望增益大于1,用两个电阻做同相放大,增益公式G = 1 + R2/R1。比如R1=10kΩ,R2=10kΩ,增益就是2倍,3.3V参考电压下输出摆幅约6.6V,配合5V供电正好。
硬件电路调试时手边备一只万用表和一台示波器就够了。先测DAC引脚对地电压,再测运放输出,一级一级往后推。波形不对时,这一节就是你的排查地图。
4. 代码骨架:从相位累加器到可调频的查表输出
4.1 相位累加器与查表输出:最小可跑的DDS代码
先给你一个能在主循环里跑通的最小版本。波形表sine_wave是4096个点的12位正弦波采样值,取值范围0到4095,对应DAC的12位分辨率。核心逻辑就是累加、取索引、输出:
uint32_t phase_acc = 0; // 32位相位累加器 uint32_t fcw; // 频率控制字,外部计算赋值 void DDS_NextSample(void) { phase_acc += fcw; // 相位累加 uint16_t index = (phase_acc >> 20) & 0xFFF; // 取高12位做表索引 DAC->DHR12R1 = sine_wave[index]; // 写入DAC数据寄存器 }索引计算是这段代码的关键。phase_acc是32位,右移20位后剩高12位,范围正好是0到4095,匹配波形表长度。这样写比用取模运算快得多,避免了除法带来的开销。fcw的计算方式如下:
uint32_t Fcw_Calc(uint32_t freq_hz, uint32_t dac_clk_hz) { return (uint32_t)((uint64_t)freq_hz * (1ULL << 32) / dac_clk_hz); }dac_clk_hz是DAC的更新频率,也就是主循环调用DDS_NextSample的频率。比如主循环跑1MHz,想要输出1000Hz正弦,fcw = 1000×2^32 / 1000000,大约4295。这个值代入累加器,输出频率就是精确的1000Hz。注意用uint64_t做中间乘法,避免32位溢出。
4.2 定时器触发DMA:把数据更新从主循环里解放出来
主循环里调用DDS_NextSample有一个隐患:一旦主循环被其他任务阻塞,波形就会抖动。正经做法是用定时器触发DMA搬运,让DAC自己按固定节奏更新数据。配置分三步,第一步初始化DAC触发源:
DAC_InitTypeDef DAC_InitStructure; DAC_InitStructure.DAC_Trigger = DAC_Trigger_T2_TRGO; // TIM2触发DAC DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable; DAC_Init(DAC_Channel_1, &DAC_InitStructure); DAC_Cmd(DAC_Channel_1, ENABLE);第二步配置定时器,让它以目标频率产生TRGO事件。比如想以1MHz更新DAC,TIM2的时钟是72MHz,预分频0,自动重载值71:
TIM_TimeBaseInitTypeDef TIM_InitStructure; TIM_InitStructure.TIM_Period = 71; // 自动重载值 TIM_InitStructure.TIM_Prescaler = 0; // 预分频 TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_SOURCE_UPDATE); TIM_Cmd(TIM2, ENABLE);第三步配置DMA,把整个波形表搬到DAC数据寄存器,循环模式自动重复:
DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&DAC->DHR12R1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)sine_wave; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 4096; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环输出 DMA_Init(DMA_Channel_2, &DMA_InitStructure); DMA_Cmd(DMA_Channel_2, ENABLE);这三步走完,波形就在后台跑了。要改频率时,只需要重新计算fcw并更新相位累加器。但记住,DMA模式输出的是固定波形表,改频率不能靠改DMA速度来实现。正确做法是用定时器触发频率控制DAC更新,同时每次更新时从波形表的不同位置查表,这相当于把4.1节的累加逻辑放进定时器中断里。DMA模式更适合固定频率输出,灵活调频用中断模式更顺手。我一般场景是:固定频率用DMA,扫频和按键调频用定时器中断里查表。
4.3 按键与编码器调频:消抖和响应速度的平衡
调频交互我用旋转编码器,比按键手感好太多。编码器A、B相接入两个GPIO并开启外部中断,在中断里根据相位差判断旋转方向:
void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { uint8_t b = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1); if (b) freq_adjust += STEP_FREQ; // 正转升频 else freq_adjust -= STEP_FREQ; // 反转降频 EXTI_ClearITPendingBit(EXTI_Line0); } }中断里只改一个全局变量,真正的频率更新放在主循环里做。千万不要在中断里直接计算fcw和更新波形,中断里做乘法除法会拖慢响应,还容易和DAC更新抢时间。freq_adjust这个变量用volatile修饰,防止编译器优化掉。
步进值STEP_FREQ的选择是个手感问题。我习惯设成10Hz,快调时按住编码器,慢调时单个脉冲,配合长按按键切换大步进(比如100Hz),基本够用。如果你的旋钮支持按下的功能,用这个按键切换步进最自然。
4.4 显示与波形切换:OLED驱动和模式状态机
显示部分用I2C接口的OLED,SSD1306驱动,整屏刷新频率不要超过10Hz,否则画面闪烁且挤占DAC更新时间。我通常只在频率变化或波形切换时刷新局部区域,静止时不刷,既省CPU又避免干扰。
波形切换用按键触发,状态机里维护当前波形类型,常见做法是用枚举:
typedef enum { WAVE_SINE, WAVE_SQUARE, WAVE_TRIANGLE } WaveType; WaveType wave = WAVE_SINE;切换波形时,把对应的波形表指针赋给DAC输出函数。方波和三角波不用单独建大表,方波可以直接判断相位累加器的最高位输出0或满幅值,三角波则把相位累加器的高位做一次取绝对值再缩放,这样省内存。波形表的实际选择放在显示刷新里同步更新,避免切换瞬间输出毛刺。
5. 避坑:信号发生器最容易翻车的五个细节
5.1 选型翻车:C8T6根本没有DAC外设
现象:照着手册初始化DAC寄存器,发现写进去的值读出来全是零,输出引脚一直是0V。
原因:STM32F103C8T6属于中低容量型号,内部没有DAC外设。DAC只存在于RCT6、ZET6等加大容量型号以及F4系列中。
解决:换芯片,或者改走PWM方案。如果只是学习DDS原理,用PWM滤波也能跑通逻辑,但波形质量差一截。我建议直接换RCT6,差价不大,省下的调试时间值回票价。
5.2 输出频率对不上:忘了系统时钟是校准过的
现象:设置好fcw,理论上输出1000Hz,示波器量出来是990Hz,而且偏差是固定的。
原因:STM32内部RC振荡器精度不够,HSE外部晶振也可能有偏差。你代码里假设的主频72MHz和实际主频不一致,导致DAC更新频率偏移。
解决:先用示波器或频率计测量TIM2的实际触发频率,反推系统时钟的真实值,然后把这个值写进Fcw_Calc函数。更扎实的做法是测出实际输出频率f_meas和目标频率f_set之比,算出修正系数存进Flash,以后每次算fcw都乘上这个系数。
5.3 一接负载波形就塌:DAC输出阻抗太高
现象:空载时波形漂亮,接上示波器以外的负载,幅值掉了一半,波形还变形。
原因:DAC引脚输出阻抗高、驱动能力弱,负载电流一上来电压就被拉垮。这是很多第一次做信号源的人最容易忽略的地方。
解决:在DAC输出端加一级单位增益运放缓冲,然后从运放输出引线。运放输出阻抗极低,负载影响可以忽略。这一步是硬性要求,不能省。
5.4 波形上有台阶和毛刺:更新率与滤波不匹配
现象:低频输出时波形看着还行,频率一上去,波形变成台阶状,还叠加高频毛刺。
原因:DAC更新率不够,每个周期采样点数太少;低通滤波器截止频率设太高,把DAC的量化噪声也放出来了。
解决:提高定时器触发频率,或者降低目标输出频率。更合理的做法是高频率输出时降低波形表长度,保证每周期采样点数。比如输出10kHz信号,DAC更新率1MHz,每周期有100个点,够用;如果更新率只有100kHz,每周期只剩10个点,台阶就明显了。
5.5 调频时波形顿挫:阻塞式按键处理
现象:旋转编码器时波形偶尔卡顿,停一下再继续。
原因:按键中断或主循环里的显示刷新阻塞了DAC数据更新。调频代码里如果有延时函数,问题会更明显。
解决:调频操作只改全局变量,波形更新由定时器中断驱动,两者互不干扰。OLED刷新用非阻塞方式,或者降低刷新频率。记住一点:波形输出这条路永远不能被阻塞,其他任务都要给它让路。
6. 进阶:把信号发生器做成顺手工具的三个习惯
6.1 频率校准:用示波器反推修正系数
每次上电后自动校准输出频率不太现实,但你可以做一次性校准。用频率计测实际输出,把偏差系数存进Flash的最后一个扇区。校准程序写法很简单:输出一个固定频率,量到偏差后计算系数,下次启动时读取并应用到所有频率计算。系数量化用固定点数格式处理,别用浮点数大批量计算,我习惯用千分数表示,比如实际频率是设定值得0.998倍,修正系数就存998。
6.2 波形平滑:插值与更高点数波形表
波形表的长度直接影响谐波含量。想要更干净的波形,不用把表拉得太大,用线性插值就能明显改善。查表时取出相邻两个采样点,根据小数部分做插值:
uint32_t idx = phase_acc >> 20; uint16_t frac = (phase_acc >> 8) & 0xFFF; uint32_t v0 = wave_table[idx]; uint32_t v1 = wave_table[(idx + 1) & 0xFFF]; uint32_t out = v0 + ((v1 - v0) * frac >> 12);这段插值代码在主频72MHz的F103上运行毫无压力,但波形质量提升明显,尤其是低频段,谐波少很多。
6.3 串口遥控:给信号发生器加个远程接口
做到这一步,这台信号发生器就已经是个正经工具了。串口指令我习惯用极简格式,便于脚本调用:
| 指令 | 含义 |
|---|---|
| F 1000 | 设置频率1000Hz |
| W 2 | 切换波形(1正弦、2方波、3三角波) |
| S 50 | 设置扫频起点50Hz |
解析代码用strtok拆分即可。串口和旋钮并行控制,任何一端改了状态,另一端同步刷新。这个接口做完,仪器就可以被Python等脚本自动控制,做自动化测试时很好用。
我自己每次做完一台信号发生器,最后一步都是把校准系数和串口指令固化到固件里,下次直接用。这几个小习惯看着不起眼,但真正决定了这台仪器是玩具还是工具。希望帮到你。
本文还有配套的精品资源,点击获取