ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 DAC信号发生器:基于DMA+定时器的可调波形输出实现

STM32 DAC信号发生器:基于DMA+定时器的可调波形输出实现 简介面向嵌入式初学者与需要快速生成模拟波形的开发者此代码包提供基于STM32的DAC信号发生器完整实现演示方波、正弦波、三角波和噪声波的发生与切换逻辑。包内共1003个文件以560个C源文件、244个头文件为主辅以汇编启动文件、ICF链接脚本、IOC初始化配置及编译生成的AXF、HEX等总大小13.41MB便于直接导入工程查看完整代码结构。已有1817人学习下载。代码整合ARM官方数学库arm_math配合STM32CubeMX生成的初始化工程可清晰理解DAC通道配置、定时器触发更新、波形采样表与频率控制等关键环节编译产物、映射文件及链接脚本也有助于分析程序运行与内存布局适合课程设计、毕业设计或DIY信号发生器时参考复用。 前阵子搭一套闭环控制测试环境缺一台能输出低频正弦波的激励源。实验室现成的DDS板卡都在别的项目上占着我干脆把手边STM32F103C8T6板子上空闲的DAC外设利用起来做了一个基于查表法和DMA的简易信号发生器。做完之后最大的感触是STM32内置DAC做实验室级别的信号源完全够用但DAC、DMA、定时器这三者怎么配合输出通道的硬件特性是什么如果一开始没有整体搞明白后面排查问题会很痛苦。这篇笔记就把整套设计的思路、关键代码和调试踩坑过程完整记录下来给想用STM32 DAC做信号发生器的朋友做个参考。1. 先摸清DAC的硬件底牌12位分辨率、输出缓冲和参考电压1.1 为什么选内置DAC而不是权电阻网络或外置DDS芯片做信号发生器之前我其实先对比过几条路线。外置DDS芯片方案比如常见的AD9833之类确实上限高但要多接一颗芯片、写SPI驱动对于只需要低频正弦激励的场景有点杀鸡用牛刀。PWM加RC滤波也是很多人用的土办法但PWM的量化噪声和纹波都比较明显想要干净的模拟信号还要加高阶低通滤波。权电阻网络DAC这种教科书方案需要精密电阻阵列和模拟开关精度完全靠电阻匹配自己做板子很难保证性能。所以最后选了STM32F103内置DAC。F103的DAC是12位电压输出型有两个独立通道映射到PA4和PA5引脚。在3.3V参考电压下一个LSB大约对应0.8mV对1kHz左右的小幅信号激励来说完全够用。关键是内置外设不占用额外PCB面积只要初始化配置正确CPU基本不参与输出过程全靠DMA搬运非常省心。1.2 输出缓冲、参考电压和带载能力这几个细节决定了波形上限很多人在CubeMX里看到DAC的Output Buffer选项不知道该怎么选。这个缓冲器本质上是一个内部运算放大器开启之后DAC输出阻抗可以降到比较低的水平能直接驱动一些负载。但代价是输出电压范围两端会有明显的饱和区实测在接近0V和接近VREF的地方线性度会变差。如果你要输出满幅度的信号波形顶部和底部会被“压”掉一小截。如果不开启输出缓冲DAC输出的线性范围会好一些但输出阻抗高基本只能接高阻抗负载实际使用时必须在外面加一级运放做缓冲。我的做法是直接开启输出缓冲同时把信号幅度限制在0.3V到3.0V之间避开两端的非线性区这样波形质量比满幅输出要干净很多。参考电压也需要留意。F103的DAC参考电压源是VREF引脚默认情况下直接连着VDDA也就是3.3V电源。如果项目对输出幅度精度有要求最好在VREF上接一颗精密基准芯片比如常用的REF3033之类这样输出幅度不会跟着系统电源波动。AN2834这份ST官方应用笔记里详细讲了DAC精度、失调误差和增益误差的补偿方法有需求的话可以翻一下核心思路就是先测量零点和满量程点然后做两点校准。1.3 先把设计目标定清楚再动手写代码动手前我把指标定得很明确输出波形为正弦波、方波、三角波三种频率范围1Hz到20kHz可调输出幅度0到3V直流偏置可调负载是高阻抗。这个目标如果用外置DDS芯片做当然也不难但用内置DAC做的话就需要认真考虑采样率和波形点数的问题这也直接决定了后面DDS相位累加器的设计。先把需求和硬件边界对齐写代码的时候就会少走很多弯路。2. 波形不是实时算的DDS相位累加器和查表法2.1 相位累加器与频率控制字的推导思路信号发生器的核心思路是查表法事先把一个完整周期的波形离散成若干个点存放在数组里然后让DAC按固定采样率把这些点依次输出。采样率固定的时候波形表中点数越多频率就越低点数越少频率就越高。这个关系很直接但有一个问题如果只靠减少点数来提高频率波形会变得粗糙而且不是任意频率都能精确得到。更灵活的做法是引入DDS直接数字频率合成里的相位累加器概念。想象一个宽度为N位的相位寄存器每个采样周期累加一个步进值M当累加溢出时就是完成一个周期。输出频率的公式是f_out M × F_s / 2^N其中F_s是DAC采样频率M是频率控制字N是相位累加器位数。换个写法如果我们想输出某个频率f_out需要设置的频率控制字就是M f_out × 2^N / F_sN取32位的时候即使F_s是1MHz频率分辨率也能做到亚毫赫兹级别远远够用。实际用代码实现时取相位累加器的高8位作为256点波形表的索引即可低24位相当于细分相位这样波形采样点均匀且频率精度高。2.2 用一段Python脚本提前生成256点正弦波表波形表可以在程序里实时计算但我建议在PC上用脚本生成好以const数组形式放进工程。这样运行时零CPU开销DMA直接从Flash或RAM搬运数据即可。正弦波表生成脚本很简单import math N 256 samples [] for i in range(N): # 将sin值从[-1,1]映射到[0,4095]适配12位DAC val int((math.sin(2 * math.pi * i / N) 1) * 4095 / 2) samples.append(val) # 按16行输出方便复制进C数组 for i in range(0, N, 8): print(, .join(f0x{samples[j]:03X} for j in range(i, min(i 8, N))) ,)生成出来的数组类似这样const uint16_t sine_wave_256[256] { 0x7FF, 0x840, 0x881, 0x8C1, 0x901, 0x940, 0x97F, 0x9BD, // ... 略 };这里有个关键点正弦值必须乘以4095再除以2把范围从负值映射到0到4095。如果把有符号的正弦值直接塞给DAC负半周会被削掉输出波形完全不对。这个问题后面第五部分会细讲。2.3 方波、三角波、锯齿波共用同一个DDS框架DDS框架的好处是换波形只需要换表。方波本质上就是半个周期输出最大值、半个周期输出最小值完全可以预生成一个256点的查表数组三角波和锯齿波同理。在代码里做一个波形选择函数切换时只改变指针指向的波形表地址其他逻辑不用动。为了方便管理我一般把三种波形定义成结构体typedef struct { const uint16_t *table; uint32_t length; uint32_t phase_acc; uint32_t phase_step; } waveform_t;这样每个波形实例自带相位累加器和频率控制字后续想扩展任意波形比如扫频正弦或者自定义任意波只要新增数组就行。这个思路和FPGA里DDS信号发生器的设计是一样的理解了相位累加器换平台也就是换个语法的事。3. DAC、DMA、定时器咬合的代码骨架与句柄关联3.1 HAL库下DAC、DMA、TIM6的初始化配置硬件链路很简单定时器产生触发信号DAC收到触发后启动一次转换同时发出DMA请求DMA把波形表里的下一个数据搬进DAC数据寄存器。整个过程不需要CPU干预波形就能连续输出。以HAL库为例先初始化DAC通道。我用的定时器是TIM6基本定时器结构最简单专门用来做DAC触发DAC_HandleTypeDef hdac; DMA_HandleTypeDef hdma_dac; TIM_HandleTypeDef htim6; static 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_T6_TRGO; // TIM6触发 sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1); }GPIO也必须配成模拟模式而不是复用推挽。这一点特别容易漏我会在坑的章节专门说static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_4; // DAC_OUT1 GPIO_InitStruct.Mode GPIO_MODE_ANALOG; // 必须是模拟模式 HAL_GPIO_Init(GPIOA, GPIO_InitStruct); }3.2 DMA句柄与DAC句柄的关联路径与常见误解DMA初始化里有一个容易被轻视的环节DMA句柄要和DAC句柄关联起来。很多初学者看到__HAL_LINKDMA这行代码不明白它是干嘛的甚至有人觉得是CubeMX自动生成、不用管。实际上这行代码非常关键它把DMA的中断状态和DAC外设联系起来这样DAC启动DMA传输时才能正确拿到DMA句柄里的状态和回调信息。static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_dac.Instance DMA1_Channel3; // F103上DAC通道1对应DMA1通道3 hdma_dac.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_dac.Init.PeriphInc DMA_PINC_DISABLE; hdma_dac.Init.MemInc DMA_MINC_ENABLE; hdma_dac.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_dac.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_dac.Init.Mode DMA_CIRCULAR; // 循环模式持续输出 hdma_dac.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_dac); __HAL_LINKDMA(hdac, DMA_Handle1, hdma_dac); }注意不同芯片的DMA通道映射不一样。我这边是STM32F103C8T6DAC通道1的DMA请求挂在DMA1_Channel3如果你的芯片不同务必去参考手册的DMA请求映射表核对或者直接用CubeMX自动分配自己手写移植的时候最容易在这里翻车。数据对齐也要说清楚。F103的DAC数据寄存器虽然是32位地址但实际只需要低16位有效所以DMA的搬运宽度应该设为半字HALFWORD不能设成字WORD。如果配成32位DMA会一次读取两个16位波形数据拼成一个32位数写进寄存器输出波形就会乱。3.3 启动整条链路验证循环模式是否真的在循环定时器配置同样很直接重点是让TIM6更新事件作为TRGO输出static void MX_TIM6_Init(void) { TIM_MasterConfigTypeDef sMasterConfig {0}; __HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance TIM6; htim6.Init.Prescaler 0; htim6.Init.Period 71; // 72MHz / 72 1MHz触发 HAL_TIM_Base_Init(htim6); sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfigSynchronization(htim6, sMasterConfig); HAL_TIM_Base_Start(htim6); }启动代码在main中这样调用HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sine_wave_256, 256, DAC_ALIGN_12B_R);DAC_ALIGN_12B_R表示12位数据右对齐即波形表中的0到4095直接对应DAC输出值。如果选左对齐数据要整体移位容易搞晕建议统一右对齐。启动之后用示波器去看PA4引脚正常情况应该能直接看到正弦波输出。如果没波形不要急着改硬件先检查是不是少了HAL_TIM_Base_Start(htim6)这一步——定时器没跑起来DAC就没有触发源自然一直停在初始电压。4. 输出频率的计算、标定与动态调节4.1 采样率、波表长度与输出频率之间的数学关系固定波形表启动后实际输出频率由DAC采样率除以波形表长度决定。DAC采样率在这里就是TIM6触发频率F_s 72MHz / (PSC 1) / (ARR 1)比如上面的配置PSC0ARR71那么采样率就是1MHz。波形表256点则正弦波频率为f_out 1MHz / 256 ≈ 3906.25Hz如果目标频率是1kHz代入公式反推ARR 1 72MHz / (256 × 1000) 281.25ARR只能取整数取281时实际频率为72000000 / (256 × 282) ≈ 997.2Hz误差约0.28%。误差主要来自整数取整对一般测试环境完全可接受。如果追求更高精度可以加大波形表长度比如1024点让频率粒度更细或者改用前面说的相位累加器方式用32位频率控制字精确定频。4.2 动态调频的两种做法与取舍动态频率调节有两种常见实现。第一种最简单在运行中修改TIM6的重载值ARR。因为DAC触发频率变了采样率跟着变波形基本频率也就变了。优点是代码简单缺点是频率切换是离散的切换瞬间波形相位可能有一点跳变不过在绝大多数场景下无所谓。第二种是软件DDS不依赖固定波形表而是在DMA的半传输完成中断和传输完成中断中根据相位累加器动态计算下一个半段的数据。这样可以做到任意频率精确输出也不会改变采样率波形更平滑。但缺点是中断里既要更新数据又要处理相位累加采样率高的时候CPU占用会比较大。我的建议是只是普通信号源的话第一种方式就够了稳定且省心。只有在需要扫频、频率连续跳变、相位连续性要求高的场景下才需要上软件DDS。4.3 实测下来误差主要来自这几个方面实际用示波器测了输出波形频率误差主要来源有三个。一是系统时钟源精度板载晶振本身不是高精度恒温晶振频率会有几十ppm的偏差对于实验用途基本不用管。二是ARR取整误差这在低频段比较明显频率越低误差越小高频段反而精确。三是DAC输出缓冲带来的幅度压缩满量程附近波形顶部和底部会略平。如果对幅度稳定性有要求可以在DAC输出后面加一级运放做二阶低通滤波把高频台阶滤掉顺便做跟随器增强带载能力。我的测试里直接输出接示波器高阻探头波形已经够干净不需要额外滤波。5. 实测定标中的坑排查链路从无声到波形畸变5.1 第一个坑引脚没配模拟模式波形像被“压扁”加削顶第一次上电示波器只看到一条接近0V的直线把DAC输出电压寄存器手动改成中间值PA4引脚同样没反应。我顺着代码检查了一圈最后发现GPIO初始化里我把PA4配成了GPIO_MODE_OUTPUT_PP而不是GPIO_MODE_ANALOG。DAC引脚如果被配置成普通推挽输出模式内部输出级和DAC模拟输出会打架电压被钳在低电平自然出不了波形。这个坑的教训是DAC、ADC这类模拟外设对应的GPIO必须配成模拟模式。普通数字输出模式下引脚带数字输入缓冲会额外消耗电流并且破坏模拟信号完整性波形会畸变、幅度也会明显偏低。5.2 第二个坑有符号波形表直接DMA搬运输出出现奇怪直流跳变这个问题是同事调试时遇到的。他直接用DSP库生成的sin值存成int16_t数组范围是-32768到32767然后直接通过DMA搬给DAC。结果示波器上看到的是一个被截掉负半周的半波而且波形底部一直在跳。原因很简单DAC是单极性输出只认0到4095的无符号值。有符号数直接搬运负值在寄存器里会被解释成很大的正数波形自然一塌糊涂。正确做法是在生成波形表时把sin结果映射到0到4095范围也就是我前面Python脚本里做的那步。换成C语言也一样uint16_t val (uint16_t)((sinf(2.0f * PI * i / N) 1.0f) * 4095.0f / 2.0f);5.3 第三个坑低阻负载下拉输出幅度缓冲也救不了项目里有一次需要把信号源接到一个输入阻抗只有50欧姆的测试板上示波器一测输出幅度从3V掉到了不到1V。当时以为DAC坏了后来才意识到是负载太重。F103的DAC输出缓冲虽然能降低输出阻抗但带载能力也就几毫安级别50欧姆负载下等效于直接对地短路了大半电流。解决方式是信号输出端串一个电阻再并联一个高阻负载或者干脆在DAC后面加一级运放电压跟随器用运放来驱动低阻负载。如果只是临时测试直接把示波器探头切到10x档输入阻抗会变成10兆欧波形就恢复正常了。5.4 一个非常好用的调试技巧用ADC自环验证DAC输出手头没有示波器的时候有个技巧很实用把DAC输出引脚PA4短接到ADC输入引脚PA1然后在代码里用ADC以一定采样率采集DAC输出的电压值通过串口把数据发到上位机画波形。这样相当于用芯片自己验证自己的输出不依赖外部仪器。我在调程序时经常先用这个方式快速确认DMA循环和波形表逻辑是否正确再去接示波器做精细测试。需要注意的是这个自环测试不要开启DAC输出缓冲后再接ADC但ADC输入阻抗本身就很高直接连没问题。串口打印频率不用太高采样几千个点就足够看波形形状了。回看整套实现单纯从代码量来说并不复杂但每一个环节都依赖对硬件工作机制的理解GPIO的模拟模式、DAC的触发源、DMA的通道映射、循环模式的意义还有波形表的数值范围。把这几个点都想清楚基于STM32的DAC信号发生器就是一个很稳定的工具以后项目里需要可调模拟信号的时候不用再翻箱倒柜找信号源了。本文还有配套的精品资源点击获取
返回列表