STM32F407的DAC双通道波形发生器实现

该文章已生成可运行项目,

基于STM32F407的双通道波形发生器:从原理到实战

你有没有遇到过这样的场景?——想做个简单的信号源,比如输出个正弦波去驱动传感器,或者给学生演示一个函数发生器的工作原理。但一想到要外接DDS芯片、配置SPI时序、处理参考电压和滤波电路……头都大了。

其实,如果你手上有块 STM32F407 ,事情可能比你想象中简单得多。它自带两个12位DAC通道,配合DMA和定时器,完全可以实现 免CPU干预的双路模拟波形输出 ,而且精度不错、成本极低、体积小巧。

别急着翻手册查寄存器,咱们不走那种“先讲框架再堆概念”的AI套路。今天就用工程师之间的对话方式,聊聊怎么真正把这块片上DAC玩明白,让它稳稳地输出干净的正弦波、三角波,甚至还能搞点相位差、I/Q调制的小花样。


为什么选STM32F407做波形发生器?

先说句实话:不是所有MCU都适合干这活儿。很多低端Cortex-M0/M3虽然也能跑DAC,但要么只有一路,要么没DMA支持,要么定时器不够灵活。结果就是你得靠中断喂数据,CPU占用飙高,波形还抖得像地震图 😅。

而STM32F407不一样。它是基于Cortex-M4+FPU的老牌高性能选手,主频能到168MHz,关键是—— 双DAC + 双DMA流 + 独立触发控制 全齐了。这意味着你可以做到:

  • 同时输出两路完全独立的波形(比如CH1正弦,CH2方波)
  • 波形频率精确可控(通过定时器分频)
  • CPU几乎零参与(DMA自动搬运数据)
  • 成本为0(不用额外芯片)

更重要的是,整个过程不需要写一句汇编或操作底层寄存器,HAL库或者LL库都能轻松搞定。对于教学实验、原型验证、小型仪器开发来说,简直是“够用又不浪费”的典范。


DAC是怎么把数字变成电压的?

我们先来拆解最核心的部分:DAC本身。

STM32F407有两个独立的DAC模块(DAC1 和 DAC2),每个都是12位分辨率,也就是能输出 $2^{12} = 4096$ 个不同的电压等级。假设你的参考电压是3.3V,那最小步进就是:

$$
\frac{3.3}{4096} \approx 0.8\,\text{mV}
$$

听起来不大对吧?但这已经足够生成肉眼看起来“平滑”的波形了,尤其是在几十kHz以下的应用中。

它不是“随便写个值就出电压”

很多人初学时会误以为:“哦,我往DAC寄存器写个数,马上就能看到对应电压。”
错!现实远没这么理想。

真正的流程是这样的:

  1. 你设置好DAC工作模式(是否启用缓冲、对齐方式等)
  2. 配置一个外部触发源(比如TIM6溢出)
  3. 每当触发到来,DAC才启动一次转换
  4. 转换期间,它向DMA发出请求:“兄弟,给我下一个数据!”
  5. DMA从内存里取出预存的采样点,塞进DAC的数据保持寄存器
  6. DAC锁存并更新输出端电压

这个过程听起来复杂,其实是为了 保证时间精度 。如果靠软件轮询或中断推送,稍微有个任务延迟,采样间隔就不均匀了,出来的波形就会有“抖动”或“失真”。

所以,关键在于: 让硬件自己动起来,别让CPU插手


如何做到“CPU完全不管”?

这才是重点。我们要的不是一个需要不断被打断的系统,而是一个一旦启动就能自己跑下去的流水线。

答案就是三个字: DMA + 定时器 + 触发联动

想象一下工厂里的传送带:
- 定时器是电机,每隔固定时间转一圈;
- DAC是机械臂,每次转动时抓取一个零件;
- DMA是送料车,提前把零件按顺序摆好;
- 波形表就是那一排零件的清单。

只要一开始电,整条线就自动运转,没人需要站在旁边手动递东西。

具体怎么搭这条“流水线”?

以最常见的组合为例:用 TIM6 作为触发源,驱动 DAC1 和 DAC2 ,通过 DMA1 自动传输波形数据。

第一步:准备波形数据表

先在SRAM里放一张查找表(LUT),比如100个点的正弦波:

#define WAVE_TABLE_SIZE   100
uint16_t sine_wave[WAVE_TABLE_SIZE];

void GenerateSineWave(void) {
    for (int i = 0; i < WAVE_TABLE_SIZE; i++) {
        sine_wave[i] = (uint16_t)(2047 + 2047 * sin(2 * PI * i / WAVE_TABLE_SIZE));
    }
}

这里用了 2047 + 2047*sin(...) 是为了让输出范围落在 0~4095 之间(即12位满量程)。中心点是2047,上下各偏移2047,刚好覆盖整个动态范围。

📌 小贴士:不要用浮点频繁计算!提前生成好静态数组,运行时直接读取,效率更高。

同样可以生成三角波、锯齿波、方波:

// 三角波示例
for (int i = 0; i < WAVE_TABLE_SIZE; i++) {
    float t = (float)i / WAVE_TABLE_SIZE;
    triangle_wave[i] = (uint16_t)(4095 * fabs(4 * (t - floor(t + 0.5)) - 1));
}

这些表放在 .data 段就行,不需要任何特殊处理。


第二步:配置DAC通道(以CH1为例)

接下来初始化DAC。这里我们使用HAL库,代码清晰且可移植性强。

DAC_HandleTypeDef hdac;
DMA_HandleTypeDef hdma_dac1;

void MX_DAC_Init(void) {
    __HAL_RCC_DAC_CLK_ENABLE();
    __HAL_RCC_DMA1_CLK_ENABLE();

    // 基本DAC初始化
    hdac.Instance = DAC;
    HAL_DAC_Init(&hdac);

    // 配置通道1
    DAC_ChannelConfTypeDef sConfig = {0};
    sConfig.DAC_Trigger = DAC_TRIGGER_T6_TRGO;         // 使用TIM6的TRGO作为触发
    sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE;// 启用缓冲,降低输出阻抗
    HAL_DAC_ConfigChannel(&hdac, &sConfig, DAC_CHANNEL_1);

    // 配置DMA(用于CH1)
    hdma_dac1.Instance = DMA1_Stream5;                // 注意:F407中DAC1对应Stream5
    hdma_dac1.Init.Channel = DMA_CHANNEL_7;
    hdma_dac1.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_dac1.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_dac1.Init.MemInc = DMA_MINC_ENABLE;
    hdma_dac1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
    hdma_dac1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
    hdma_dac1.Init.Mode = DMA_CIRCULAR;               // 循环模式!关键!
    hdma_dac1.Init.Priority = DMA_PRIORITY_HIGH;
    HAL_DMA_Init(&hdma_dac1);

    // 把DMA句柄绑定到DAC结构体
    __HAL_LINKDMA(&hdac, DMA_Handle1, hdma_dac1);
}

重点解释几个参数:

  • DAC_TRIGGER_T6_TRGO :表示只有当TIM6产生TRGO信号时,DAC才开始转换。这是实现精准节奏的关键。
  • DMA_CIRCULAR :循环模式意味着DMA传完最后一个数据后,自动回到第一个重新开始,形成无限循环播放。
  • MemInc = DMA_MINC_ENABLE :内存地址递增,因为我们是从数组一路往后读。
  • PeriphInc = DMA_PINC_DISABLE :外设地址不变,始终写入同一个DAC寄存器(DHR12R1)。

第三步:设置定时器TIM6作为节拍器

现在我们需要一个稳定的“心跳”,告诉DAC什么时候该取下一个点。

选择 TIM6 的原因是它是基本定时器,专为DAC/ADC触发设计,不会产生多余的中断干扰系统。

TIM_HandleTypeDef htim6;

void MX_TIM6_Init(void) {
    __HAL_RCC_TIM6_CLK_ENABLE();

    htim6.Instance = TIM6;
    htim6.Init.Prescaler = 83;                    // 84MHz → 1MHz计数频率
    htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim6.Init.Period = 9;                       // 1MHz / 10 = 100kHz 更新率
    htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
    HAL_TIM_Base_Init(&htim6);

    // 启动并开启主模式触发输出(TRGO)
    HAL_TIM_Base_Start(&htim6);
    __HAL_TIM_SET_COUNTER(&htim6, 0);

    // 关键:设置TRGO输出为更新事件
    MODIFY_REG(htim6.Instance->CR2, TIM_CR2_MMS, TIM_TRGO_SOURCE_UPDATE);
}

这段代码的意思是:

  • 输入时钟来自APB1(通常是84MHz,即使PCLK1=42MHz,定时器也会×2倍频)
  • 经过PSC=83分频后,得到1MHz的计数频率(每1μs加1)
  • ARR=9 → 计数到10次溢出一次 → 溢出周期为10μs → 每秒10万次触发

也就是说, DAC每10微秒更新一次输出值

如果我们用的是100点的正弦波表,那么最终输出频率就是:

$$
f_{out} = \frac{100\,\text{kHz}}{100} = 1\,\text{kHz}
$$

完美吻合预期!

🔍 提示:TRGO信号本质上是一个硬件脉冲,可以直接连接到DAC的触发输入引脚,无需GPIO引出,完全内部联动。


第四步:启动播放,放手不管

一切就绪之后,只需要一行命令启动DMA传输,剩下的交给硬件:

// 主函数中调用
GenerateSineWave();           // 生成波形表
MX_DAC_Init();                // 初始化DAC+DMA
MX_TIM6_Init();               // 初始化定时器

// 启动!
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, 
                  (uint32_t*)sine_wave, WAVE_TABLE_SIZE, DAC_ALIGN_12B_R);

注意这里用的是 HAL_DAC_Start_DMA() ,而不是普通的Start。这个函数会:
1. 启动DAC通道
2. 开启DMA请求
3. 自动开始从指定地址搬运数据

一旦执行完这句,CPU就可以去做别的事了——比如处理UI、通信、算法计算……完全不受影响。

用示波器一测,PA4脚上立刻出现一个干净的1kHz正弦波 👏。


怎么加上第二路?同步吗?

当然可以!而且很简单。

STM32F407的DAC2也支持同样的机制,对应的DMA资源是 DMA1_Stream6 ,触发源也可以设为 TIM6_TRGO

这意味着:只要你让两个通道都使用同一个触发源,它们就能在 同一时刻被唤醒 ,实现近乎完美的同步更新。

双通道配置要点

// 先配置通道2
HAL_DAC_ConfigChannel(&hdac, &sConfig, DAC_CHANNEL_2);

// 配置DAC2专用DMA(Stream6)
hdma_dac2.Instance = DMA1_Stream6;
hdma_dac2.Init.Channel = DMA_CHANNEL7;  // 注意:F407中DAC2也是Channel7?
// 实际上:DAC1 -> Ch7, Stream5;DAC2 -> Ch7, Stream6 ✅
// 因为不同Stream可以用相同Channel编号
hdma_dac2.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_dac2.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_dac2.Init.MemInc = DMA_MINC_ENABLE;
hdma_dac2.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_dac2.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_dac2.Init.Mode = DMA_CIRCULAR;
hdma_dac2.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_dac2);

__HAL_LINKDMA(&hdac, DMA_Handle2, hdma_dac2);  // 绑定到CH2

然后分别启动两路DMA:

HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)sine_wave,   WAVE_TABLE_SIZE, DAC_ALIGN_12B_R);
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_2, (uint32_t*)triangle_wave, WAVE_TABLE_SIZE, DAC_ALIGN_12B_R);

此时你会发现:
- PA4 输出正弦波
- PA5 输出三角波
- 两者频率一致、边沿对齐、无明显相位漂移

这就是所谓的“硬同步”——靠硬件触发实现的时间一致性,远胜于软件延时或中断调度。


能不能控制相位差?比如生成I/Q信号?

当然可以!这正是双通道的价值所在。

比如你要做一个简单的IQ调制器,需要两路同频但相差90°的正弦波。

方法很简单: 调整波形表的起始偏移即可

// 假设WAVE_TABLE_SIZE = 100,则90度对应25个点
int phase_offset = 25;

// CH2从第25个点开始读
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_2, 
                  (uint32_t*)&sine_wave[phase_offset], 
                  WAVE_TABLE_SIZE, DAC_ALIGN_12B_R);

但由于DMA是循环模式,直接传偏移地址会导致最后一次传输越界(最后25个点之后回到开头,中间断了一截)。

解决办法有两种:

方法一:复制一份拼接好的缓冲区

uint16_t sine_iq[WAVE_TABLE_SIZE];
memcpy(sine_iq, &sine_wave[phase_offset], (WAVE_TABLE_SIZE - phase_offset)*2);
memcpy(sine_iq + (WAVE_TABLE_SIZE - phase_offset), sine_wave, phase_offset*2);

然后把这个新数组交给DMA。

方法二:使用双缓冲模式(高级技巧)

利用DMA的双缓冲功能(Double Buffer Mode),可以在传输一半时切换指针,实现无缝拼接。不过这需要更复杂的配置,适合追求极致性能的场合。

对于一般应用,方法一只需多花200字节内存,简单可靠,推荐优先使用。


实际输出质量怎么样?有哪些坑?

理论很美好,现实总有摩擦。下面是一些真实项目中踩过的坑,以及应对策略。

❌ 问题1:波形看起来像“台阶”,高频噪声严重

没错,DAC输出的是阶梯状电压,尤其在高频下更明显。这是因为每个采样点之间是跳变的,而不是平滑过渡。

解决方案:加一级RC低通滤波器

典型设计:
- R = 1kΩ
- C = 10nF
- 截止频率 $ f_c = \frac{1}{2\pi RC} \approx 15.9\,\text{kHz} $

这样既能保留基波成分,又能有效抑制采样带来的高频谐波。

如果要求更高,可以用运放搭建一个二阶巴特沃斯有源滤波器,THD(总谐波失真)能降到1%以内。


❌ 问题2:电源噪声导致输出漂移

DAC对电源非常敏感。如果你把数字电源(VDD)和模拟电源(AVDD)混在一起供电,或者地线布局不合理,很容易引入开关噪声。

解决方案:
- 使用独立的LDO给AVDD供电
- AVSS接地要短而粗,最好铺模拟地平面
- 在AVDD引脚加0.1μF陶瓷电容 + 10μF钽电容去耦
- 条件允许的话,外接精密基准源(如REF3133)替代VDDA作为参考电压

你会发现,原本波动±5mV的输出,瞬间稳定到±0.5mV以内。


❌ 问题3:PA4/PA5输出异常,电压不对

常见原因是你忘了把这两个引脚设成 模拟模式

默认情况下,PA4和PA5是通用IO,如果不显式配置,可能会有内部上下拉电阻或者数字逻辑干扰影响输出。

必须加上这句:

GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();

GPIO_InitStruct.Pin = GPIO_PIN_4 | GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_ANALOG;        // 关键!设为模拟模式
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

否则轻则输出不准,重则DAC无法正常工作。


❌ 问题4:改变频率时卡顿或中断

有人喜欢通过修改TIM6的ARR/PSC来实时调频。但要注意: 直接改ARR可能导致当前周期不完整 ,造成一次丢步或重复。

推荐做法:
- 修改前暂停定时器和DMA
- 改完后再重启
- 或者使用影子寄存器+预装载机制,确保在下一个周期生效

更优雅的方式是使用另一个定时器(如TIM2)做主控,动态重载TIM6的参数,避免直接操作正在运行的外设。


性能边界在哪?最高能到多少Hz?

我们不能无限提高频率,受限于两个因素:

1. DAC建立时间(Settling Time)

ST官方手册标明,STM32F407的DAC建立时间为 典型1μs,最大3μs 。也就是说,两次更新之间至少要留出这个时间,否则电压还没稳定就换了下一个值,会导致非线性误差甚至振荡。

所以安全上限是: 每秒最多更新约1MHz ÷ 3 ≈ 300k次

但这是极限值,实际建议控制在 100kHz以内 以获得较好线性度。

2. 波形表长度 vs 输出频率

假设你用100个点表示一个周期,那么最大输出频率为:

$$
f_{max} = \frac{100\,\text{kHz}}{100} = 1\,\text{kHz}
$$

如果你想输出10kHz正弦波,就得把波形表压缩到10个点——这显然太粗糙了,波形会严重失真。

因此,要在 波形质量和输出频率之间权衡

✅ 推荐实践:
- 低频应用(<1kHz):用100~200点,保质保量
- 中频应用(1~10kHz):用50~100点,加滤波补偿
- 高频应用(>10kHz):牺牲保真度,接受明显阶梯感

另外,也可以采用插值法在DMA传输间隙由FPU实时生成部分点,但这会增加复杂度,一般没必要。


还能怎么扩展?不只是“函数发生器”

别以为这只是个玩具级的信号源。结合STM32F407的其他能力,它可以变身成多种实用工具:

✅ 场景1:传感器激励源

某些压电传感器、热敏电阻桥路需要交流激励信号。你可以让DAC输出特定频率的正弦波,再经过运放放大后驱动负载,同时用ADC采集响应信号进行分析。

由于DAC和ADC都可以由同一TIM6触发,还能实现 同步采样与激励 ,非常适合做阻抗测量、LCR测试仪等。


✅ 场景2:简易音频播放

虽然达不到Hi-Fi水准,但在语音提示、报警音、DTMF拨号音等场景下完全够用。

例如将PCM音频数据降采样到22.05kHz,用50点正弦波表合成载波,再通过PWM+滤波还原声音。虽有底噪,但成本极低。


✅ 场景3:教学实验平台

在学校实验室里,这套方案可以让学生直观理解:
- 数字信号如何转化为模拟信号
- 采样定理与奈奎斯特频率
- 波形合成与傅里叶分解
- DMA与中断的区别
- 硬件协同工作机制

比起直接给成品模块,让学生亲手搭一遍更有意义。


✅ 场景4:工业控制中的参考信号

在PID控制系统中,有时需要一个可调的参考电压作为设定值。传统做法是用滑动变阻器,但现在完全可以由程序动态生成斜坡、阶跃、脉冲等信号,实现智能化控制。


写在最后:这不是终点,而是起点

你看,我们没用任何外部芯片,仅靠STM32F407本身的资源,就实现了一个功能完整、性能可靠的双通道波形发生器。

它的优势不在“多强大”,而在“刚刚好”:
- 不需要PCB打样
- 不依赖外部器件
- 开发速度快
- 易于集成进现有系统

更重要的是,这套机制的思想可以迁移到很多地方:
- ADC的连续采样
- PWM波形生成
- 多轴波形同步控制
- 甚至软件无线电的基带信号生成

当你掌握了“ 定时器触发 + DMA搬运 + 外设响应 ”这套黄金组合,你会发现,原来嵌入式系统的自动化程度,可以远远超过你的想象。

下次你在调试某个慢吞吞的轮询代码时,不妨问自己一句:
👉 “能不能让硬件自己动起来?”

也许,答案就在TIM6的TRGO信号里。

本文章已经生成可运行项目

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值