简介:STM32H750凭借Cortex-M7内核与双DAC通道,可高效实现精密波形生成。这是一套基于TIM触发、DMA搬运、双路DAC输出的正弦波发生器完整工程,由STM32CubeMX生成初始化代码,并在MDK5环境下编译验证,适合嵌入式开发者学习高级定时器、DMA与数模转换协同工作,也可直接移植到相位可调信号源、音频处理等场景。压缩包共680个文件,以c源文件、h头文件为主体,另有uvprojx工程文件、ioc配置、hex固件及arm数学库等,整体约96.69MB,目录结构完整。已有2800人学习下载,资源内包含单片机源码、CubeMX配置和MDK工程,读者可对照代码理解正弦波查表输出、双路相位差调节的实现思路,并直接烧录验证。对需要快速搭建双路可调相位差正弦波发生器的开发者很有参考价值。
1. 正弦波发生器实战:STM32H750如何用 TIM+DMA 把双路 DAC 跑满速
超声波驱动、逆变器移相控制、生物电刺激这类场景,核心诉求往往是两路同频正弦波,相位差可调且稳定。把正弦表扔进定时器中断逐点喂 DAC,正弦频率到 1kHz 时中断率要拉到 256kHz,主循环基本被挤没。STM32H750 上更干净的方案是 TIM+DMA:TIM6 负责等间隔触发,DMA 把正弦表自动搬运到 DAC 两个通道,相位差由 DMA 起始地址偏移决定,全程 CPU 不干预。本文按 CubeMX 生成 MDK5 工程的完整流程拆这套链路,重点讲清双路 DMA 如何同步、相位差怎么调、以及最常见的四五个翻车点,适合用过 HAL 库但第一次把定时器、DMA、DAC 串成一条链的开发者。
2. TIM+DMA+DAC 链路设计:为什么这个架构能让相位差不漂
2.1 从正弦表到引脚输出的完整数据流
先把三个外设的角色分清楚。TIM6 在这里只干一件事:产生固定频率的更新事件,作为 DAC 的触发源。DAC 收到触发后立刻对两个通道同时做一次转换,把当前数据寄存器里的值变成模拟电压。DMA 干的事则是:DAC 转换完成后,自动从内存里的正弦表取下一个样本,写进 DAC 的数据寄存器。
链路按时间顺序走一遍:TIM6 计数器溢出 → 产生更新事件 → 触发 DAC1 的两个通道同步转换 → 转换结束后 DAC 硬件向 DMA 控制器发出请求 → 两路 DMA 各自把正弦表里对应地址的数据搬到 DAC 通道的数据寄存器 → 等待下一次 TIM6 更新事件。
注意,这个链条里我的描述是“DAC 转换完成后触发 DMA”,而不是“TIM 直接触发 DMA”。在 STM32H750 的 DAC 外设里,DAC 自己会向 DMA 发出请求信号,CubeMX 里把 DMA 请求挂到 DAC 通道上即可。这样做的意义在于:DMA 搬运的节奏严格跟随 DAC 实际转换节奏,不会出现定时器触发了但 DAC 还没准备好导致丢数据的情况。
两路通道的同步是关键。DAC 支持两种触发模式:独立触发和同步触发。独立模式下两个通道各用各的触发源,相位关系天然不稳定。我们要做的是让两个通道共享同一个 TIM6 更新事件,在 CubeMX 里对应 DAC 触发模式选同步触发。这样一来,每次 TIM6 溢出,两个 DAC 通道同时开始转换,转换完成后又几乎同时各自拉高 DMA 请求,两路 DMA 的搬运起点始终对齐。
有人会问:既然 DMA 搬运也是各自独立的,搬着搬着会不会一个快一个慢?不会。DMA 搬运一个半字的时间远小于 TIM6 的触发周期,加上每次搬运都在 DAC 转换结束后立即发生,下一次触发到来前两路早就各就各位了。只要起点对齐过一次,后面就一直对齐。
2.2 DMA 循环模式:相位差稳定性的基石
相位差靠 DMA 起始地址偏移实现,但前提是 DMA 必须工作在循环模式。循环模式的意思是:DMA 每搬运完一次设定长度的数据,传输计数器和内存地址自动回到初始值,继续下一轮搬运。
假设正弦表有 256 个点,通道 1 的 DMA 从 sine_table[0] 开始,搬运 256 个半字后回绕到 sine_table[0],如此循环。通道 2 如果也从 sine_table[0] 开始,两路波形完全同相。通道 2 改成从 sine_table[64] 开始,它每次搬运 256 个半字后回绕到 sine_table[64],偏移量等于 64 个采样点,折合相位差 90 度。
这里有个很多人第一次容易绕晕的点:DMA 循环模式的内存地址回绕,是回到 DMA 配置时给定的初始地址,而不是回到数组首地址。通道 2 初始地址是 &sine_table[64],它回绕到 64,不回 0。这个特性正好被我们利用——每个 DMA 通道只需要一个固定的偏移起点,就能持续输出固定相位差的波形。
用两张独立的表也能实现相位差,比如单独生成一张 cos 表给通道 2。但这种方式浪费 flash,且后续想连续微调相位差很麻烦。用同一张表加地址偏移,相位差本质上就是一个整数索引,想调多少度直接算索引。
2.3 频率、点数、相位差三者的定量关系
三个公式贯穿整个项目,建议写在代码注释里。
定时器触发频率:
f_trigger = f_TIM_CLK / ((ARR + 1) × (PSC + 1))
正弦波输出频率:
f_sine = f_trigger / N
N 是正弦表点数,也是 DMA 每次搬运的半字数量。相位差:
Δφ = 360° × offset / N
其中 offset 是通道 2 DMA 的起始地址相对表首地址的偏移点数。
举个实际数值。主频 480MHz,APB1 定时器时钟常见配置为 240MHz。想要 1kHz 正弦、256 点采样表,触发频率需要 256kHz。反推定时器参数:让 PSC=0,ARR=937,f_trigger = 240MHz / 938 ≈ 255.8kHz,实际正弦频率约 999Hz,误差 0.1% 左右。这个误差来自分频取整,后续章节会给出校准方法。
相位差若定为 90°,offset = 256 × 90 / 360 = 64,整数偏移没有截断误差。若相位差定为 45°,offset 是 32,同样精确。但如果想要 30° 相位差,offset 算出来是 21.33,只能取 21,实际相位 29.53°,误差不到 0.5°。N 越大,相位量化误差越小。
3. STM32CubeMX 配置全程:从时钟树到 DMA 请求的每个选项
3.1 时钟树:先把 DAC 的工作时钟喂饱
CubeMX 新建工程选好 STM32H750VBTx 后,第一步不是配外设,而是把时钟树定下来。H750 最高主频 480MHz,我习惯用外部晶振 HSE 进 PLL1,得到 SYSCLK 480MHz。接着检查两个东西:APB1 总线时钟和 APB1 定时器时钟。
STM32H7 系列 APB1 定时器时钟默认是 APB1 的两倍。比如 APB1 设 120MHz,定时器时钟就是 240MHz。CubeMX 的 Clock Configuration 页面会显示 Timer clocks 一栏,确认 APB1 Timer Clock 接近 240MHz 再往下走。
DAC 本身还有一路独立时钟源。在 CubeMX 左侧选择 DAC1 外设后,Clock Configuration 里会出现 DAC Clock 的下拉选项,可以从 PLL1Q、PLL2P、PLL3P 等时钟源里选。这个时钟决定 DAC 内部模拟采样电路的工作速率。一般配到 50MHz 左右完全够用,不用担心更新率比 DAC 时钟高——只要 DAC 模拟建立时间满足,更新率顶到 1MSPS 都没问题。默认配置通常就能工作,不是瓶颈。
3.2 TIM6 参数:只做触发,别抢中断
在 Timers 里选中 TIM6,勾选 Activated。TIM6 是基本定时器,没有通道,不能输出 PWM,但它有一个重要输出信号 TRGO,可以被 DAC 选中作为触发源。
配置参数按下表填:
| 参数项 | 取值 | 说明 |
|---|---|---|
| Prescaler (PSC) | 0 | 不分频,最大化触发分辨率 |
| Counter Period (ARR) | 937 | 配合 240MHz 得到约 255.8kHz 触发率 |
| Auto-reload preload | Enable | 预装载 ARR,改频率时不影响当前周期 |
| Trigger Output (TRGO) | Update Event | 更新事件作为 DAC 触发信号 |
注意不要在这里使能 TIM6 全局中断。项目里定时器只负责发触发脉冲,数据搬运由 DMA 完成,CPU 不需要进中断。如果你确实需要中断来统计频率或做其他事,优先用 DMA 的半传输中断或传输完成中断,而不是 TIM6 中断。
3.3 DAC1 双通道与 DMA 请求:全部选对才有同步触发
在 Analog 里找到 DAC1,做如下配置。
使能 DAC_OUT1 和 DAC_OUT2 两个输出,引脚分别是 PA4 和 PA5。Output Buffer 选 Enable。DAC 内部输出缓冲能降低输出阻抗,直接带后级负载时波形不容易塌。Mode 下拉参数里,两个通道都选“Triggered by Timer 6 Trigger Out event”。在 H750 的 DAC1 配置界面上,通道 1 和通道 2 的 Trigger Mode 选择同步触发模式,这保证两路同源。
然后切到 DMA Settings 标签页,添加两个 DMA 请求:
| DMA 请求 | 方向 | 模式 | 数据宽度 |
|---|---|---|---|
| DAC1_CH1 | Memory To Peripheral | Circular | Half Word |
| DAC1_CH2 | Memory To Peripheral | Circular | Half Word |
DMA 优先级可以设成 High。STM32H750 有 DMA1 和 DMA2 两个控制器,CubeMX 会自动把 DAC1_CH1 和 DAC1_CH2 映射到合适的通道。通道排布在不同 DMA 控制器上时,DMA1 和 DMA2 各有独立总线矩阵,两路搬运同时进行时总线冲突更小,这是 H7 系比老 F1 系适合做双路 DAC 的硬件基础。
FIFO 选项保持 Disable,让 DMA 工作在直接模式。直接模式下降延迟最短、传输行为最可预测,对于每触发一次只搬一个半字的场景足够。
3.4 生成 MDK5 工程前的最后检查
Project Manager 页面选择 Toolchain 为 MDK-ARM V5.27,这里用 V5 后缀生成的是 AC5 风格工程,MDK5 也能直接打开。如果电脑装的是 ARM Compiler V6,CubeMX 生成时选 MDK-ARM V6 也可行,代码本身兼容两种编译器,但第十四章的编译优化坑只在 V6 上比较明显。
点击 GENERATE CODE 前,再确认三件事:DAC1 两个 DMA 请求都在且模式是 Circular;DAC 触发源是 TIM6 而不是别的定时器;DAC 触发模式是双通道同步。许多后续调试问题,追到根上就是这几项被默认值改掉了。
4. MDK5 工程代码:正弦表、DMA 启动与相位差调整接口
4.1 生成正弦表:两种方式都给出,避免 flash 浪费
正弦表建议用脚本生成后直接粘成数组,不在运行时算。H750 的 flash 和 RAM 都够,256 点 uint16_t 正玄表只需要 512 字节。用 Python 生成 12 位右对齐格式的表:
import math N = 256 bits = 12 max_val = (1 << bits) - 1 # 4095 table = [] for i in range(N): # 输出范围 0~4095,直流偏置在 2047 v = (max_val + 1) / 2 * (1 + math.sin(2 * math.pi * i / N)) v = int(round(v)) - 1 if v > 0 else 0 v = max(0, min(max_val, v)) table.append(v) for i in range(0, N, 8): chunk = ", ".join(f"0x{v:03X}" for v in table[i:i+8]) print(" " + chunk + ",")这段脚本的数学逻辑是:正弦函数本身范围在 -1 到 1 之间,加 1 后变成 0 到 2,乘 2048 后得到 0 到 4096 附近的值,再截断到 12 位 DAC 范围内。这样生成的波形以 2048 为中心上下摆动,DAC 输出大概在 0.5V 到 2.8V 之间,没有负压,适合单极性供电。
如果你不想多开一个 Python 环境,也可以直接在 C 里预生成。常见的做法是把表声明为 static const,编译时固化到 flash,不占 RAM。注意 DMA 访问的地址必须是 SRAM 地址,不能直接让 DMA 读 flash 中的常量表。STM32H750 的 DMA 无法直接访问 flash,所以需要先定义一个非 const 的 uint16_t 数组放在 SRAM,初始化时把常量表拷过去。
#define SINE_TABLE_SIZE 256 static const uint16_t sine_table_flash[SINE_TABLE_SIZE] = { /* 上面 Python 生成的 256 个值 */ }; static uint16_t sine_table_ram[SINE_TABLE_SIZE]; void sine_table_init(void) { for (int i = 0; i < SINE_TABLE_SIZE; i++) { sine_table_ram[i] = sine_table_flash[i]; } }用方波还是用正弦表?这个项目明确是正弦波发生器,表里存的就是正弦每个相位点的幅度。表点数决定波形的相位分辨率和输出频率上限,这点在第 4.3 节展开。
4.2 HAL_DAC_Start_DMA 启动代码与参数语义
工程生成后,在 main.c 的 MX_DAC1_Init() 里 CubeMX 已经配好 DAC 句柄。用户代码要做的就是在初始化完成后启动两路 DMA 传输。最直接的写法:
/* 启动两路 DAC+DMA 输出,相位差 90 度,偏移 64 个点 */ HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_1, (uint32_t *)&sine_table_ram[0], SINE_TABLE_SIZE, DAC_ALIGN_12B_R); HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_2, (uint32_t *)&sine_table_ram[64], SINE_TABLE_SIZE, DAC_ALIGN_12B_R);HAL_DAC_Start_DMA 的五个参数逐一说明:第一个是 DAC 句柄,生成代码里叫 hdac1;第二个是通道号,两个独立通道要分别调一次;第三个是 DMA 内存地址,这里强制转换成 uint32_t 指针是因为 HAL 库的形参类型如此,但实际 DMA 配置里数据宽度必须保持 Half Word,对应 uint16_t 数组;第四个是传输长度,单位与数据宽度一致,这里传 256 表示每次完整循环搬运 256 个半字;第五个是对齐方式,12 位右对齐,含义是 DAC 数据寄存器里低 12 位有效,这个必须和 CubeMX 里 DAC 设置的 12 位右对齐保持一致。
启动顺序有个建议:先启动通道 1,再启动通道 2。两个 DMA 都在等待 DAC 触发信号,谁先启动只决定准备工作顺序,不决定输出初始相位。真正决定相位关系的是两个 DMA 的起始地址偏移,和启动先后无关。
4.3 动态调整相位差的接口设计
相位差不是写死 90 度就要改怎么办?需要一个运行时调整函数。核心思路:先停掉通道 2 的 DMA,更新起始地址偏移,再重新启动。代码:
void set_phase_offset(uint16_t offset_points) { /* 1. 停止通道 2,避免搬运过程中改地址导致半个周期异常 */ HAL_DAC_Stop_DMA(&hdac1, DAC_CHANNEL_2); /* 2. 重新设置 DMA 内存起始地址,偏移单位是采样点数 */ HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_2, (uint32_t *)&sine_table_ram[offset_points], SINE_TABLE_SIZE, DAC_ALIGN_12B_R); }用角度做接口更直观,在调用层换算:
#define PHASE_ANGLE_TO_OFFSET(angle) \ ((uint16_t)((angle) * SINE_TABLE_SIZE / 360.0f))调相位时调用 set_phase_offset(PHASE_ANGLE_TO_OFFSET(45)),得到 2432 小时内一个离散的偏移点数。需要注意,角度换算时存在取整误差,256 点表下最小相位步进是 360/256 = 1.406 度。这是点数固定的内在限制,想提高分辨率就加表点数。
这里有个血泪教训要提前说:HAL_DAC_Stop_DMA 并不会立刻把 DMA 停下来,它只是把 DMA 使能位清掉,但 DMA 可能正处在一个传输节拍中间。改完地址立刻 Start,偶尔会出现首尾半个点不连续,波形看起来像闪了一下。解决方法是 Stop 之后加一个小延时,等 DMA 当前节拍走完。更稳的做法是配合 DMA 传输完成中断,在中断标志位确认停止后再改地址。对一个周期几十微秒的正弦波来说,加 1 微秒延时足够。
5. 调试避坑:相位差不稳、DMA 停转、波形台阶与幅值偏低
5.1 现象一:两路波形肉眼可见的不同步,相位差来回漂
现象:示波器双通道看 PA4 和 PA5,两路都是正弦波,频率也一致,但一个周期内相对位置不稳定,相位差在 80 到 100 度之间晃。
原因:DAC1 两个通道工作在独立触发模式。CubeMX 默认情况下两个 DAC 通道各自使用自己的触发配置,如果通道 1 配了 TIM6 而通道 2 没配,或通道 2 配了另一个定时器,两个通道转换时机完全错开,且错开量不受控。
解决:回到 CubeMX 检查 DAC1 配置,确认两个通道的 Trigger Mode 都选同步触发模式,且 Trigger Source 都指向 Timer 6 Trigger Out event。重新生成代码后初始化文件里可以看到两个通道的 SqrTrigger 相关寄存器配置几乎一致,只有通道号不同。另一个隐蔽点:DMA 请求绑错通道也会导致此现象。通道 1 的 DMA 请求必须挂在 DAC1_CH1 上,通道 2 挂在 DAC1_CH2 上。如果两条 DMA 请求都挂在 CH1 上,通道 2 的波形干脆不出来或错乱。
5.2 现象二:波形输出几毫秒到几十毫秒后停止,好像 DMA 罢工
现象:上电后两路正弦波输出正常,过一段时间后某一路突然变零电平或保持最后值不动。
原因:DMA 配置里把模式设成了 Normal(单次模式)。Normal 模式下 DMA 搬运完 SINE_TABLE_SIZE 个半字后传输就结束,不再有数据补充进 DAC 数据寄存器,DAC 维持最后一个值。看起来就是波形在某个点卡住。
解决:CubeMX 里找到该 DMA 通道的 Mode 选项,从 Normal 改成 Circular。确认方式很简单:看生成的代码,DMA_HandleTypeDef 的 Init.Mode 字段,应该是 DMA_CIRCULAR 而不是 DMA_NORMAL。注意,如果两路 DMA 只有一路是 Normal,现象就是一路正常一路停,更容易误导人以为是 channel 2 硬件故障。
还有一个可能原因:在代码里调用了 HAL_DAC_Stop_DMA 或 HAL_DMA_Abort,且没有重新启动。排查时看主循环里是否有调试代码顺手把 DMA 停了。
5.3 现象三:输出正弦波有明显台阶,甚至毛刺叠加
现象:低频段波形轮廓整体对,但放大后看到一根一根的小台阶,或者波峰波谷处有尖刺。
原因:DAC 是零阶保持型输出,每个采样周期输出一个恒定电压,天然有台阶。台阶幅度和采样点数相关,256 点对低频足够平滑,但如果正弦频率偏高、一个周期只有不到 40 个采样点,台阶感就会很明显。毛刺则是另一回事,通常来自 DMA 搬运和 DAC 转换的时序竞争,或供电噪声。
解决:台阶问题要么增加表点数 N 并同步提高触发频率,要么在输出端加一阶 RC 低通滤波。RC 截止频率取正弦频率的 3 到 5 倍,比如输出 1kHz 正弦,RC 截止放 4kHz 左右,既能滤除采样台阶又不太影响波形形状。我一般用 1kΩ 电阻加 33nF 电容,算下来大约 4.8kHz。注意,加上滤波器后幅值会略微下降,要实测后补偿。
毛刺问题优先查两点:一是 STM32H750 的 VDDA/VREF+ 引脚附近去耦电容是否足够,DAC 对模拟电源噪声敏感;二是 DMA 优先级是否被大量中断抢占导致搬运延迟。把 DAC 相关的 DMA 通道优先级提到最高,通常能缓解。
5.4 现象四:正弦波输出幅值比预期低,峰峰值只有 2.5V 左右
现象:程序里表的最大值是 4095,DAC 参考电压 3.3V,预期峰峰值接近 3.3V,实测只有 2.4 到 2.6V,且波形轻微削顶。
原因:DAC 输出端负载太重,或 VREF 电压被拉低。H750 的 DAC 内部虽带输出缓冲,但缓冲驱动能力有限,如果后面接的负载阻抗很低,比如直接接了 50Ω 负载或者微小阻抗的测量设备,电压会被拉低。削顶多半是表值压到了 DAC 满量程附近,缓冲接近饱和。
解决:先测量 VREF+ 引脚电压,确认不是基准源塌陷。再检查输出负载,换成高阻抗输入示波器探头或把负载电阻提高到 10kΩ 以上。如果负载确实很低,在 DAC 输出和负载之间加一级运放跟随后级。
另外一个容易忽略的点:如果 CubeMX 里把 Output Buffer 设成了 Disable,DAC 输出阻抗会非常高,波形在低负载下直接失真。这个项目应启用缓冲。
5.5 现象五:MDK5 开高优化后波形相位或频率行为异常
现象:程序在 -O0 下一切正常,改成 -O2 或者 ARM Compiler V6 的 -Ofast 后,波形频率偏了,或相位差跳变。
原因:编译器优化把某些 DMA 相关的关键变量优化掉了,或对循环访问正弦表的代码做了重排。最典型的是 H750 这类 Cortex-M7 内核加高等级优化后,CPU 缓存和编译器优化把内存访问顺序改变,DMA 读到的数据和预期不同。
解决:正弦表 RAM 副本声明成 volatile 或加attribute((used)),确保编译器不调整。DMA 传输长度、偏移量这类变量声明成 volatile uint16_t。编译器优化等级建议用 -O2 而不是 -Ofast,对实时性要求高的外设循环代码可单独放到 optimize("O0") 的编译单元里。我在某次调试中遇到频率偏到 97Hz 而不是 100Hz,查到最后就是编译器把查表循环的两次内存访问合并了,DMA 读到了错位数据,加 volatile 后恢复。
6. 验证技巧与极限扩展:示波器 XY 测相位,采样率推到上限
先说要怎么验证相位差。用普通双通道时间波形测相位差很容易引入测量误差,因为手动看波形过零点本来就不准。我的习惯是打开示波器的 XY 模式,把通道 1 接到 X 轴、通道 2 接到 Y 轴,两路正弦波合成李萨如图形。0° 相位差时是一条斜率正的对角直线,90° 时是一个标准圆形,180° 时是斜率负的对角直线。45° 则看到椭圆。这个图形对相位差的变化非常敏感,稍微偏一点椭圆就变胖变瘦,比读数过零点直观得多。用 90° 目标去调,圆形是最容易判断的标准:横纵幅度一致时比较圆,拉伸了说明两路幅值不等,偏椭圆说明相位差不是精确的 90°。
定量验证则靠数学功能。示波器测出两个波形峰值的水平时间差 t,正弦周期 T,相位差就是 t/T × 360°。这个方法要保证两个通道的时间基准和触发点一致,实际测量误差约 ±1°,配合 XY 模式做粗调再定量确认,能保证最终相位误差在 0.5° 以内——这是 256 点表的理论量化极限。
再说扩展频率上限。整个链路的瓶颈不在定时器,而在 DMA 搬运速度和 DAC 模拟建立时间。STM32H750 的 DAC 在单点转换模式下,实际能达到的采样率在 1MSPS 左右。如果 N=256,正弦频率上限大约 1M/256 ≈ 3.9kHz。想输出 10kHz 正弦,必须把表点数降到 64 甚至 32,但正弦台阶感会上升。另一个策略是保持表点数不变,把 TIM6 触发频率顶上去,但 DMA 和 DAC 跟不上就会丢点。我在某个板上试到 1.2MSPS 附近,两路波形开始出现偶发跳变。
针对极限频率,可以做两件事。第一,把两路 DMA 分到不同的 DMA 控制器上,避免同一 DMA 控制器串行处理两个请求。H750 有 DMA1 和 DMA2,CubeMX 生成时若自动分配到了同一个控制器,手动改一下请求映射能明显改善并发瓶颈。第二,把表从 RAM 搬到 DTCM 或 AXI SRAM 等高速内存区域。H750 有 128KB DTCM,放在 DTCM 的地址空间访问延迟更低,DMA 突发能力更强。不过 DTCM 和 DMA1 之间的访问路径要看具体芯片总线矩阵,不是所有组合都能提速。
从那以后,我每回做双路 DAC 输出,都强制自己先走一遍同步触发模式确认、DMA 循环模式确认、输出缓冲启用确认这三步,再开始写应用代码。这三步能挡掉绝大部分翻车现场。希望帮到你。
本文还有配套的精品资源,点击获取