
简介面向STM32H750嵌入式开发者的双路正弦波发生器完整工程利用定时器触发DMA搬运预计算正弦表驱动DAC输出两路相位差可调的正弦波全程由STM32CubeMX生成初始化代码并在MDK5中编译验证适合需要掌握TIM、DMA与DAC协同设计的进阶开发者。压缩包共680个文件以383个C源码和127个头文件为核心附有.ioc图形化配置工程与.uvprojx MDK工程文件以及DSP数学库、编译中间产物和HEX固件整体96.69MB打开即可查阅与二次开发。目前已有2784人学习下载。工程内含清晰的双通道DAC配置、定时器更新事件触发DMA的完整代码并给出相位差调整的具体方法可帮助读者理解信号发生器底层机制也为音频合成、相位可控波形输出等场景提供可复用的参考模板。1. 项目概述一套能直接照抄的双路DAC波形方案做信号发生器有个老问题想让单片机一边刷屏、处理按键一边还能稳定地输出正弦波并且要求双路同步、相位差可调这时候单纯靠定时器中断去查表更新DAC就有点力不从心了CPU占用率高不说波形还容易因为中断抖动出现毛刺。这个项目我选用了STM32H750作为主控利用TIM触发DMA搬运正弦波查找表DAC双通道同时输出两路正弦波相位差可以在程序运行过程中动态调整。整套代码由STM32CUBEMX生成底层初始化MDK5工程编译调试不需要写复杂的寄存器操作。这个方案适合谁如果你正在做变频电源、超声驱动、扫频激励源、相控阵信号模拟这类需要多路正弦信号且相位关系可控的项目直接可以参考这套架构。H750这颗芯片主频高、带DMAMUX和双通道DAC用定时器周期性触发DMA本质上就是一个用硬件实现的“数控振荡器”波形更新完全不需要CPU介入CPU只负责在需要的时候修改相位参数。整个过程可以拆成四块来看定时器产生节拍、DMAMUX把节拍路由到DMA通道、DMA把内存里的正弦表灌进DAC寄存器、DAC输出模拟信号。下面我按实际开发顺序把原理、配置、代码、坑位都讲清楚。2. 核心原理定时器为什么要和DMA、DAC绑在一起2.1 DAC的DMA请求到底是怎么产生的STM32H750的DAC外设有两个独立的12位输出通道对应PA4和PA5。如果你把一个正弦波周期离散成256个采样点每个点的电压对应一个0到4095的数值那么只要让DAC按照固定的采样率依次输出这256个值DAC引脚上就会呈现一个正弦波。关键是“依次输出”这个动作怎么触发。最初级的方式是用定时器中断每次中断里写一次DAC数据寄存器这种方式代码简单但会打断主程序。再进一步就是用定时器的更新事件直接触发DMA控制器DMA把内存里已经准备好的正弦波表数据搬运到DAC的数据保持寄存器DHR每个定时器周期搬运一个数循环往复。DAC侧要做的配合很简单就是在DAC控制寄存器里打开对应通道的DMA使能位。HAL库中HAL_DAC_Start_DMA函数会完成这个动作。DMA侧则需要配置成循环模式搬运完一整张表后自动回到表头继续搬运。这样定时器、DMA、DAC三者就形成了一个硬件流水线。H750相比F1/F4多了一个DMAMUX外设它像一个交换机可以把任意外设请求映射到任意DMA通道这也是后面实现双路同源触发的基础。2.2 双路相位差可调的分寸在哪里双路输出相位差可调听起来抽象工程上实现起来其实也不复杂。如果两路DMA都搬运同一张正弦表那么只要让两路DMA从表的不同位置开始读输出的正弦波自然就有了相位差。打个比方一张256点的表就是一个圆周从0度位置开始读是一路波从90度对应的第64个点开始读就是另一路波两者的相位差正好90度。所以核心任务就变成两件事一是让两路DMA都由同一个定时器TRGO事件触发保证采样节拍完全一致二是让第二个DMA通道的起始内存地址指向正弦表偏移后的位置偏移量由相位差换算出来。这里有个关键点H750的DMA请求源是可以自由配置的。正常情况下DAC1_CH1的请求源应该是DAC1_CH1DAC1_CH2的请求源是DAC1_CH2但借助DMAMUX我可以把两个DMA通道的请求源都改成TIM1_TRGO就说同一个定时器事件同时触发了两个DMA通道。这样两路DMA在同一个节拍下各自搬运数据同步性有保证相位差只取决于两路DMA的初始地址差非常好控。2.3 频率与点数的数学关系正弦波输出频率由三个参数决定定时器时钟频率、定时器的分频值、正弦表点数。假设定时器Timer Clock为240MHz设置PSC0、ARR937那么定时器更新事件的频率就是240MHz除以938约等于255863Hz。如果正弦表长度N256最后输出正弦波的频率就是255863除以256约等于999.5Hz。写成公式就是f_sine TIM_CLK / ((PSC1) * (ARR1) * N)不同的目标频率可以组合出多组参数我实测过几组比较常用的配置见下表。目标正弦频率采样点数NPSCARR实际输出频率误差1kHz2560937约999.5Hz0.05%10kHz1280186约10034Hz0.34%100kHz25608约104167Hz4.2%高频时误差变大是因为ARR只能取整数所以实际项目中如果需要精确频率要根据真实时钟频率先算好PSC和ARR必要时候可以选不同采样点数来凑整。采样点数越大波形越光滑但要求定时器触发频率越高DMA带宽占用也越多H750跑几百kHz的更新率毫无压力。3. STM32CUBEMX配置与MDK5工程搭建3.1 时钟和引脚配置在STM32CUBEMX中新建工程选好STM32H750VBT6型号后第一步先把时钟树配好。我实际用的是外部25MHz晶振通过PLL倍频到480MHzAPB2分频后定时器时钟为240MHz。这个频率下H750性能很充裕DAC更新率即便做到1MHz级别也足够。引脚方面DAC1_OUT1默认映射在PA4DAC1_OUT2默认映射在PA5这两个引脚不需要手动做GPIO初始化使能DAC外设后自动复用为模拟输出。注意H750的Flash只有128KB不要试图在工程里塞太多静态资源正弦表这种数据放在RAM里就好。CubeMX配置页面中Vcore电压会默认设在Scale1不需要改改了反而会影响最高主频。时钟树配置完成后先不急着生成代码把DAC和TIM的配置做完。3.2 TIM1和DAC1的CubeMX配置TIM1配置里Clock Source选择Internal ClockPSC和ARR按目标频率填入Counter Period就是ARR值Prescaler就是PSC值。最重要的是在Trigger Output选择Update Event也就是把定时器更新事件作为TRGO输出。这个TRGO信号会送到DMAMUX成为DMA请求源。TIM1本身不需要开中断因为DMA工作在纯硬件触发模式不需要CPU参与。DAC1配置页中把OUT1和OUT2都勾选EnableOutput Buffer建议打开。输出缓冲可以降低输出阻抗但也会让DAC输出电压范围收缩到大约0.2V到VREF减0.2V这个在后面生成正弦表时要考虑不要让期望幅值顶到0或满量程。工作模式保持Normal即可。两个通道都配置好后重点来看DMA设置。3.3 把DMA请求源改成TIM1_TRGODMA Settings里添加两个DMA请求一个分配给DAC1_CH1一个分配给DAC1_CH2。方向都是MemoryToPeripheral也就是从内存往外设寄存器搬数据。模式选Circular数据宽度选Half Word因为12位DAC数据在16位寄存器中右对齐存放。优先级可以根据需要设置我平时用High。这一步生成的代码里两个DMA通道的Request默认会是DMA_REQUEST_DAC1_CH1和DMA_REQUEST_DAC1_CH2但这两个请求源来自DAC自己不是我们想要的定时器触发。要让它们变成TIM1_TRGO我一般不在CubeMX界面里强行改而是在生成的main函数中、DAC和TIM初始化完成之后把两个DMA句柄的Request字段重新赋值再重新调用一次HAL_DMA_Init。代码是这样hdma_dac1_ch1.Init.Request DMA_REQUEST_TIM1_TRGO; HAL_DMA_Init(hdma_dac1_ch1); hdma_dac1_ch2.Init.Request DMA_REQUEST_TIM1_TRGO; HAL_DMA_Init(hdma_dac1_ch2);这样做的原因是避免每次重新生成代码时手动修改的MspInit内容被CubeMX覆盖。放在main函数里相对安全。重新初始化DMA并不影响之前的GPIO和时钟配置DAC的MspInit已经给DMA开过时钟这里只是修改请求源映射实测非常稳定。3.4 一个容易被忽略的内存陷阱H750的RAM分为好几块其中0x20000000起始的DTCM只有CPU核能直接访问DMA外设读不到这块地址。如果正弦表是普通全局变量链接器很可能把它放在默认的0x20000000段那么DMA搬运时会读到全零或者随机值DAC输出变成诡异的直流电平这是新手最容易踩的坑。解决办法有几个最省事的做法是把正弦表数组放到DMA可访问的SRAM区域。我用的MDK5工程里在Options for Target的Linker页面中可以把RW_IRAM1的起始地址从0x20000000改成0x30000000大小按实际内存调整。这样默认的全局变量会被链接到D2域的SRAM1DMA可以正常访问。另外如果你开了D-Cache还要注意缓存一致性问题CPU修改正弦表后要让DMA读到新数据需要调用SCB_CleanDCache()把缓存刷回内存。4. 代码实现查表、启动、调相位差4.1 生成正弦查找表正弦表可以预先计算好写入数组也可以在启动时动态生成。动态生成的好处是可以调节幅值和直流偏置不用来回改代码。我的写法是用一个简单函数填充整个表#define TABLE_SIZE 256 uint16_t sine_table[TABLE_SIZE]; void gen_sine_table(uint16_t *buf, uint32_t size) { for (uint32_t i 0; i size; i) { float rad 2.0f * 3.14159265358979f * i / size; float val 2048.0f 1800.0f * sinf(rad); if (val 0.0f) val 0.0f; if (val 4095.0f) val 4095.0f; buf[i] (uint16_t)val; } }这里幅值取1800直流偏置取2048实际输出电压会以约1.65V为中心上下摆动幅度大约1.45V基本处于DAC输出缓冲的线性区间。如果你需要满幅输出可以关闭输出缓冲但要注意DAC输出阻抗会变大带载能力下降。生成表后如果开了D-Cache记得调用SCB_CleanDCache()刷新缓存。4.2 启动时序与DMA初始化启动顺序很重要。先把定时器启动起来让TRGO信号开始产生然后启动DAC的DMA搬运。我的main中主要流程是这样gen_sine_table(sine_table, TABLE_SIZE); HAL_TIM_Base_Start(htim1); HAL_DAC_Start_DMA(hdac1, DAC_CHANNEL_1, (uint32_t *)sine_table, TABLE_SIZE, DAC_ALIGN_12B_R); HAL_DAC_Start_DMA(hdac1, DAC_CHANNEL_2, (uint32_t *)sine_table[PHASE_INDEX], TABLE_SIZE, DAC_ALIGN_12B_R);第二路启动时内存地址直接指向正弦表偏移后的位置PHASE_INDEX就是相位偏移对应的采样点序号。如果相位差是90度则PHASE_INDEX等于256除以360乘以90也就是64。HAL_DAC_Start_DMA内部会设置DAC通道的DMA使能位并把第三个参数作为DMA的M0AR地址。这里注意强行转成uint32_t指针只是为了符合HAL库函数签名实际传输宽度由之前配置的Half Word决定。4.3 动态改相位差的三种做法如果项目只需要固定相位差初始化时指定PHASE_INDEX就够了。但如果要做可调相位差就需要在运行过程中修改第二路DMA的起始地址。第一种最简单的方法是把第二路DMA停掉修改地址再重新启动void set_phase(uint16_t phase_deg) { uint16_t index (uint16_t)((phase_deg % 360) * TABLE_SIZE / 360); HAL_DAC_DMAStop(hdac1, DAC_CHANNEL_2); HAL_DAC_Start_DMA(hdac1, DAC_CHANNEL_2, (uint32_t *)sine_table[index], TABLE_SIZE, DAC_ALIGN_12B_R); }这种方式的缺点是在停止和重启的瞬间第二路输出会中断几个采样周期波形上可能出现一个短暂的台阶。对于开关电源、压电陶瓷驱动这类应用这种短暂中断通常可以接受。如果要求严格连续第二种方法是在第一路DMA的传输完成中断中做同步更新等到第一张表刚好循环完一个周期同时停止两路DMA重新设置地址后同时启动这样两路波形都从0相位点重新开始相位差准确输出中断时间也最短。第三种方法最平滑准备两张正弦表或双缓冲区在后台用新的相位差生成新表在DMA半传输中断里切换当前输出表地址做到无缝更新但实现复杂度会高一些需要根据实际需求取舍。5. 示波器实测与常见问题排查5.1 我遇到过的5个典型问题这套方案我在调板过程中踩过不少坑很多问题很有代表性整理成了一张排查速查表。现象可能原因解决方法DAC输出一直是0或直流DMA请求源没改成TIM1_TRGODMA根本没有被触发检查Request字段重新HAL_DMA_Init两路波形频率比预期低很多定时器没有启动DMA没有循环跑起来确认HAL_TIM_Base_Start已调用用示波器看TRGO引脚频率波形有严重台阶采样点数太少或者输出端没有滤波增大TABLE_SIZE加一级RC低通或运放滤波DMA读到的全是垃圾数据正弦表被放到了DTCM区域把变量定位到0x30000000地址段或用SCB_CleanDCache刷新缓存修改相位差后第二路波形跳动很大Stop/Start时序没有同步好改用DMA传输完成中断里同步启停两路第一个问题最隐蔽。CubeMX默认生成的是DAC自身请求DMA虽然初始化成功但始终等不到触发信号。我排查的时候在DMA中断回调里加了计数变量发现中断根本不进才意识到请求源配错了。改成TIM1_TRGO之后中断和波形一起恢复正常。5.2 排查工具和调试技巧调这类波形项目示波器双通道是必须的。一个通道接PA4一个通道接PA5触发源设为第一路就可以稳定观察两路正弦波的相位差。用示波器的光标测量功能在屏幕上量出两个波峰之间的时间差再除以一个周期的时间乘360就是实际的相位差角度。我这里实测设置90度时光标读数基本落在89度到91度之间误差主要来自示波器采样率和波形台阶整体精度能满足大部分激励源需求。如果你手头没有双通道示波器也可以用H750的ADC配合DMA去采集DAC输出再通过串口把数据发到PC画波形不过采样率和同步性会差一些只适合验证功能。另外我习惯在DMA中断回调里加一个16位计数变量每次传输完成加一然后通过串口或调试器观察这样可以非常直观地判断DMA到底有没有在跑、触发频率对不对。最后再分享一个经验正弦表不要定义成const放到Flash里虽然看起来省RAM但H750的Flash读写比DMA访问RAM慢高采样率下容易形成瓶颈放RAM里反而更稳。本文还有配套的精品资源点击获取