资讯动态

STM32信号发生器实战:DDS原理、DAC硬件与嵌入式代码全解析

发布时间:2026/10/14 18:15:41 来源:尧图企业网站定制
简介面向嵌入式开发与电子设计学习者的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也就是N32这样频率分辨率是 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就上DACPWM滤波是妥协方案。DAC直出的优势在于波形质量干净12位分辨率在低频段表现很好代码也简单直接把采样值丢给寄存器就行。缺点是需要外部运放做缓冲因为DAC的输出阻抗不低负载能力强一点就会拉垮电压。PWM滤波则是用定时器输出固定频率的PWM再经过RC低通把高频分量滤掉留下的平均电压就是模拟信号。这个方案成本低任意型号都能做但纹波大、带宽低、DAC分辨率换算成占空比精度也受限想输出一个干净的1kHz正弦都费劲更别说扫频了。参数对比如下方案波形质量最高实用频率外围成本实现难度DAC直出运放好谐波少数十kHz级别一个运放滤波低PWMRC滤波一般纹波明显几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)比如R1kΩC10nF算出来fc≈15.9kHz。这个配置适合输出0到5kHz的正弦波阶梯分量被压得很干净。如果你要输出更高的频率按比例缩小电容即可比如C1nF时fc≈159kHz。这里要克制一点不要为了追求高频输出把截止频率设得太高。滤波器带宽越宽高频残留越多波形看起来越粗糙。我的经验是截止频率设为最高输出频率的2到3倍兼顾带宽和纯净度。3.3 运放缓冲与增益别让负载压垮波形DAC输出端我习惯接一个单位增益的运放缓冲而不是直接把信号引到输出端子。原因很简单DAC的输出阻抗和驱动能力有限接上示波器探头、音频设备或者后级电路时电压会被拉低波形形状也跟着变。运放缓冲后输出阻抗降到很低负载影响可以忽略。运放选型注意两个参数轨到轨输出、单位增益稳定。前者保证低电压下输出摆幅接近电源轨后者避免缓冲电路自激。LMV358这类常见轨到轨运放就能用如果希望增益大于1用两个电阻做同相放大增益公式G 1 R2/R1。比如R110kΩR210kΩ增益就是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更新DACTIM2的时钟是72MHz预分频0自动重载值71TIM_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接口的OLEDSSD1306驱动整屏刷新频率不要超过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设置频率1000HzW 2切换波形1正弦、2方波、3三角波S 50设置扫频起点50Hz解析代码用strtok拆分即可。串口和旋钮并行控制任何一端改了状态另一端同步刷新。这个接口做完仪器就可以被Python等脚本自动控制做自动化测试时很好用。我自己每次做完一台信号发生器最后一步都是把校准系数和串口指令固化到固件里下次直接用。这几个小习惯看着不起眼但真正决定了这台仪器是玩具还是工具。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑