使用DAC输出正弦波:配合DMA实现连续波形生成

AI助手已提取文章相关产品:

DAC与DMA协同驱动下的高精度正弦波生成技术详解

在嵌入式系统日益复杂的今天,模拟信号的高质量、低功耗、实时输出已成为音频处理、工业控制、通信调制乃至科研仪器中的核心需求。设想一下:你正在设计一款便携式信号发生器,需要稳定输出1kHz正弦波,THD(总谐波失真)低于0.5%,同时CPU占用率趋近于零——这听起来像是个“不可能的任务”?其实不然。

关键在于, 别再让CPU亲自搬运每一个采样点! 🚫
取而代之的是一个精妙的“三人组”: DAC + DMA + 定时器 。它们分工明确、配合默契,构建出一种几乎无需干预的“自动驾驶式”模拟信号输出架构。


从轮询到自动化:为什么必须引入DMA?

我们先来看一段“教科书级反面案例”:

for(;;) {
    DAC->DHR12R1 = sine_table[i++];  // 手动写入每个值
    delay_us(100);                   // 模拟采样间隔
    if(i >= TABLE_SIZE) i = 0;
}

这段代码看似简单直接,实则暗藏三大致命缺陷:

  1. CPU被完全锁死 :处理器99%以上的时间都在执行循环和延时,根本无法处理其他任务;
  2. 时间基准不精确 delay_us() 依赖软件计数,受中断干扰严重,导致采样抖动(jitter),直接影响波形质量;
  3. 扩展性极差 :一旦你想叠加第二个通道或加入串口通信,整个系统就会崩溃。

💡 真实场景中,哪怕只是来一个UART接收中断,都可能让你的正弦波瞬间“抽搐”。

那么出路在哪?答案就是—— 把数据传输这件事,交给专门的人去做 。这个人,就是 DMA(Direct Memory Access)控制器

DMA的本质是“内存与外设之间的快递员”。它可以在没有CPU参与的情况下,自动将一整块内存中的数据(比如你的正弦查找表)源源不断地送到指定外设寄存器(如DAC的数据保持寄存器)。更妙的是,这个过程还能由定时器精确触发,实现等间隔刷新。

最终形成的协同机制如下:

定时器每到固定时间 → 发出DMA请求 → DMA从内存读取一个采样点 → 写入DAC寄存器 → DAC立即更新模拟电压

整个流程如同流水线作业,环环相扣,且全程不占用CPU资源 ✅。这就是现代高性能嵌入式系统的典型运作方式。


构建稳定硬件平台:STM32上的DAC-DMA实战配置

要让这套机制跑起来,光有理论还不够,还得搭建一套精密的底层架构。下面我们以广泛使用的 STM32F407VG 为例,一步步拆解如何从零开始配置。

微控制器选型背后的考量

不是所有MCU都适合做高质量波形发生器。理想的选择应具备以下特征:

  • ✅ 至少一个独立DAC模块(最好双通道)
  • ✅ 支持外设触发的DMA控制器
  • ✅ 高精度定时器(能提供微秒级分辨率)
  • ✅ 足够SRAM存储波形表(至少几百字节)
  • ✅ 可靠的时钟树管理能力

STM32F4系列完美契合这些要求:
- 双12位DAC(DAC1/DAC2),支持缓冲输出
- 两个DMA控制器(DMA1/DMA2),共16条通道
- 多达17个定时器,包括高级、通用和基本型
- 主频高达168MHz,APB1总线可达42MHz(定时器倍频至84MHz)

特别是其 DAC+DMA+TIM6 的组合 ,堪称“黄金三角”,专为后台静默波形输出而生。

组件 功能角色
DAC 数模转换引擎,输出模拟电压
DMA 自动搬运数据,解放CPU
TIM6 提供精准时间基准,触发每次转换

外设映射与初始化顺序:千万别跳过的第一步

在STM32的世界里,“先使能时钟,再操作寄存器”是一条铁律。任何后续配置只有在外设供电后才有效。

// 第一步:开启相关外设时钟
RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN;           // 启用DMA1
RCC->APB1ENR |= RCC_APB1ENR_DACEN              // 启用DAC
               | RCC_APB1ENR_TIM6EN;          // 启用TIM6
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;          // 启用GPIOA(PA4为DAC1输出)

注意到没?这三个模块分别挂在不同的总线上:
- DAC 和 TIM6 属于 APB1 总线(低速)
- DMA1 和 GPIOA 属于 AHB1 总线(高速)

所以它们的时钟使能位也分布在不同寄存器中。搞错任何一个,都会导致后续配置无效!

接着是引脚配置。虽然DAC输出的是模拟信号,但对应的GPIO仍需设置为 模拟模式 ,否则数字输入路径会引入噪声甚至振荡。

// PA4 配置为模拟输出
GPIOA->MODER |= GPIO_MODER_MODER4_Msk;        // MODER4[1:0] = 11b
GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR4_Msk;       // 无上下拉

这里不需要配置AF复用功能,因为DAC内部已经硬连线到PA4,属于“直达专线”。


定时器作为心跳引擎:如何精准控制采样率?

决定波形质量的关键参数之一,就是 采样率 。根据奈奎斯特采样定理,若想无失真重建1kHz正弦波,至少需要2kHz以上的采样频率;实际工程中通常采用8~16倍,即8–16kSPS以上。

假设我们要实现 100kHz 采样率 ,该如何配置TIM6?

TIM6的时钟来自APB1,经内部倍频后为84MHz(当SYSCLK=168MHz时)。我们通过预分频器(PSC)和自动重载寄存器(ARR)来设定周期:

$$
T = \frac{(PSC + 1)(ARR + 1)}{f_{\text{TIM}}} = \frac{1}{f_s}
$$

令 $ f_s = 100\,\text{kHz} $,则 $ T = 10\,\mu s $

选择 PSC = 839,则每个计数周期为 $ \frac{840}{84\,\text{MHz}} = 10\,\mu s $,此时只需设置 ARR = 0 即可每10μs触发一次更新事件。

TIM6->PSC = 839;                    // 分频后为100kHz
TIM6->ARR = 0;                      // 溢出周期 = 1个计数 → 10μs
TIM6->CR2 |= TIM_CR2_CCDS;          // DMA请求源选择更新事件
TIM6->DIER |= TIM_DIER_UDE;         // 使能更新事件DMA请求
TIM6->CR1 |= TIM_CR1_CEN;           // 启动定时器

📌 关键点解释:
- CCDS 位确保DMA请求由更新事件发出,而非捕获/比较事件;
- UDE 是连接DMA的关键开关,缺一不可;
- CEN 最后打开,避免未准备就绪就开始计数。

此时,TIM6将以100kHz频率持续产生DMA请求,只要DMA已就绪,就能立刻响应并传输下一个数据点。


波形数据放在哪?内存对齐与SRAM布局的艺术

你以为随便定义个数组就行了吗?Too young too simple 😏

DMA可不是随随便便就能干活的。如果源地址没有正确对齐,某些STM32型号可能会触发 BusFault 异常,尤其是当你使用半字(16位)或字(32位)传输时。

推荐做法:

#define WAVE_TABLE_SIZE 256

// 强制4字节对齐,避免DMA访问异常
__attribute__((aligned(4))) const uint16_t sine_wave[WAVE_TABLE_SIZE] = {
    2048, 2090, 2132, /* ... 自动生成的数值 */ 
};

为什么要对齐?
- STM32的DMA控制器在执行16位传输时,期望源地址为偶数;
- 若未对齐,AHB总线可能返回错误或性能下降;
- 使用 aligned(4) 不仅满足16位要求,也为未来升级留出余地。

此外,建议将波形数据放置在 SRAM1 区域(起始地址 0x20000000 ),而不是栈上或未指定段落的位置。你可以通过链接脚本进一步锁定位置:

.wave_data ALIGN(4) : {
    KEEP(*(.wave_section))
} > SRAM1

然后在C代码中标注:

const uint16_t sine_wave[WAVE_TABLE_SIZE] 
    __attribute__((section(".wave_section"), aligned(4)));

这样做的好处是:多波形切换、动态加载、内存保护等高级功能更容易实现。


DAC寄存器级深度配置:不只是打开那么简单

很多人以为启用DAC就是写个 DAC->CR |= EN1 就完事了。但实际上,要想获得稳定可靠的输出,还有很多细节需要注意。

输出缓冲要不要开?

STM32的DAC内置两级缓冲放大器。它的作用是降低输出阻抗,提升驱动能力。对于轻负载应用(如接运放前端或ADC输入),强烈建议开启缓冲。

DAC->CR |= DAC_CR_EN1      // 启用DAC1通道
       | DAC_CR_TEN1       // 使能外部触发
       | DAC_CR_TSEL1_0    // 选择TIM6作为触发源 (0b011)
       | DAC_CR_WAVE1_0;   // 禁用内置波形发生器

注意几个关键位:
- EN1 :使能DAC,这是最基本的;
- TEN1 :启用触发模式,否则只能软件触发;
- TSEL1[2:0] = 0b011 :选择TIM6作为触发源;
- BOFF1 :是否关闭缓冲偏移。一般设为1(关闭),除非你知道自己在做什么。

⚠️ 特别提醒:如果你使用的是内部VDDA作为参考电压(通常是3.3V),那DAC的分辨率和稳定性都会大打折扣。电源波动100mV就会导致输出偏移30LSB以上!

✅ 最佳实践:使用外部精密基准源(如REF3125输出2.048V),并通过0.1μF陶瓷电容旁路,显著提升信噪比和长期稳定性。


DMA通道的建立:如何打造一条永不堵塞的数据高速公路?

如果说DAC是终点站,DMA就是运输车队。要想让它高效运行,必须精心规划路线和规则。

绑定正确的DMA流与通道

在STM32F407中,DAC1_CH1 默认绑定到 DMA1_Stream5_Channel7 。这是一个硬件固定的映射关系,不能更改。

DMA1_Stream5->PAR = (uint32_t)&DAC->DHR12R1;     // 目标地址:DAC寄存器
DMA1_Stream5->M0AR = (uint32_t)sine_wave;        // 源地址:波形数组
DMA1_Stream5->NDTR = WAVE_TABLE_SIZE;            // 数据总量

接下来是控制寄存器配置:

DMA1_Stream5->CR = 
    DMA_SxCR_CHSEL_0 |                            // 选择Channel 7
    DMA_SxCR_PL_1 |                              // 优先级:High
    DMA_SxCR_MSIZE_0 |                           // Memory size: Half-word (16-bit)
    DMA_SxCR_PSIZE_0 |                           // Peripheral size: Half-word
    DMA_SxCR_MINC |                              // 内存地址自增
    DMA_SxCR_CIRC |                              // 循环模式
    DMA_SxCR_DIR_0 |                             // 内存→外设
    DMA_SxCR_TCIE;                               // 可选:开启传输完成中断

逐项解读:
- CHSEL_0 :对应Channel 7(查手册确认);
- PL_1 :设为High优先级,防止被其他低优先级传输打断;
- MSIZE_0 / PSIZE_0 :均为16位,匹配DAC寄存器宽度;
- MINC :内存地址递增,遍历整个数组;
- CIRC :启用循环模式,实现无限播放;
- DIR_0 :方向为内存到外设;
- TCIE :可选开启中断,用于双缓冲切换。

最后别忘了启动DMA流:

DMA1_Stream5->CR |= DMA_SxCR_EN;

多DMA竞争怎么办?仲裁策略很重要!

在一个复杂系统中,可能同时存在多个活跃DMA流:比如UART接收、ADC采集、SPI显示屏刷新……这时候如果不做好优先级分配,很容易出现总线拥堵,导致DAC丢包、波形中断。

STM32支持四级优先级:
- Low
- Medium
- High
- Very High

建议策略:
- DAC-DMA 设为 High 或 Very High
- UART打印日志 设为 Low
- ADC采集若与DAC同步,也应设为 High

更好的做法是使用不同DMA控制器:
- DAC 用 DMA1_Stream5
- ADC 用 DMA2_Stream0

从根本上避免资源冲突。


正弦波算法设计:不只是sin函数那么简单

有了硬件基础,下一步就是生成真正高质量的波形数据。这不仅仅是调用 sin() 函数这么简单,还涉及量化、归一化、误差抑制等多个层面。

查表法(LUT)的核心思想

基本思路是:预先计算一个周期内的N个采样点,并存储为静态数组。运行时DAC通过DMA依次读取这些值,即可重建连续波形。

设采样点数为 $ N = 256 $,则第 $ k $ 个点的角度为:

$$
\theta_k = \frac{2\pi k}{N}, \quad k=0,1,…,N-1
$$

对应的DAC输入值为:

$$
D[k] = \text{round}\left( \left( \frac{\sin(\theta_k) + 1}{2} \right) \times 4095 \right)
$$

为什么加1除以2?因为原始sin值范围是[-1, 1],而DAC只能接受[0, 4095]的非负整数。这个变换相当于加上了一个 直流偏置(DC offset) ,使得波形围绕中点(2048)上下摆动。

for (int k = 0; k < TABLE_SIZE; ++k) {
    float angle = 2.0f * M_PI * k / TABLE_SIZE;
    float raw = sinf(angle);
    float normalized = (raw + 1.0f) * 0.5f;
    sine_lut[k] = (uint16_t)(normalized * 4095.0f + 0.5f); // 四舍五入
}

💡 小技巧:加入 +0.5f 实现四舍五入,可以减少量化误差累积,提升整体精度。


如何用Python自动生成波形表?

手动写256个数?太累了!我们可以用脚本一键生成:

import numpy as np

N = 256
amp = 2047      # ±2047偏移
offset = 2048   # 中心值

angles = np.linspace(0, 2*np.pi, N, endpoint=False)
sine_vals = np.sin(angles)
digital_vals = np.round(amp * sine_vals + offset).astype(np.uint16)

print("const uint16_t sine_table[256] = {")
for i in range(0, len(digital_vals), 8):
    line = digital_vals[i:i+8]
    print("    " + ", ".join(f"{x:4d}" for x in line) + ",")
print("};")

运行结果示例:

const uint16_t sine_table[256] = {
    2048, 2090, 2132, 2174, 2216, 2258, 2299, 2341,
    2382, 2423, 2464, 2504, 2544, 2584, 2623, 2662,
    ...
};

把这个数组复制进工程,编译时就会自动加载到Flash/SRAM中,运行时不占CPU一丝力气。


采样点越多越好吗?平衡内存与性能

当然不是。虽然增加采样点能降低阶梯效应(reducing quantization noise),但也会带来内存开销。

采样点数 THD估计 内存占用(字节) 推荐用途
64 ~2.5% 128 低速控制
128 ~1.2% 256 普通音频
256 ~0.6% 512 高保真输出
1024 <0.2% 2048 测试仪器

实践中推荐 256点 ,兼顾精度与资源消耗。若追求极致音质,可结合插值技术进一步提升等效分辨率。


双缓冲DMA:实现无缝切换的终极武器

单缓冲模式下,一旦传输结束,DMA就会停止,导致输出悬空或保持最后一个值。要实现无限循环输出,必须启用 双缓冲机制(Double Buffer Mode) ,也叫乒乓缓冲(Ping-Pong Buffer)。

中断时机决定一切

STM32的DMA支持两种关键中断:
- HTIF(Half Transfer Interrupt Flag) :一半数据传完时触发
- TCIF(Transfer Complete Interrupt Flag) :全部传完时触发

利用这两个中断,我们可以在后台安全地更新另一个缓冲区的内容,而当前仍在播放的缓冲区不受影响。

#define BUFFER_SIZE 256
__attribute__((aligned(4))) uint16_t buf_a[BUFFER_SIZE];
__attribute__((aligned(4))) uint16_t buf_b[BUFFER_SIZE];

void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) {
    // 当前正在传输buf_a的后半段 → buf_b完全空闲
    update_waveform_params(buf_b, BUFFER_SIZE/2, BUFFER_SIZE);
}

void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) {
    // buf_a已完成 → 下次将切换回buf_b的后半段
    update_waveform_params(buf_a, 0, BUFFER_SIZE/2);
}

📌 核心逻辑:
- HT中断时,可修改“另一半”缓冲区;
- TC中断时,可修改“刚完成”的那个缓冲区;
- 始终保证有一个缓冲区处于活跃状态。


如何避免毛刺?渐变才是王道

即使用了双缓冲,如果在切换时突然改变幅值或频率,仍然会引起电压阶跃,表现为“毛刺”或“咔哒声”。

解决方案: 参数渐变(ramping)

volatile float target_amplitude = 1.0f;
float current_amplitude = 0.5f;

void update_waveform_params(uint16_t* buf, int start, int end) {
    for (int i = start; i < end; ++i) {
        float alpha = (float)(i - start) / (end - start);
        float interp_amp = current_amplitude + (target_amplitude - current_amplitude) * alpha;

        float angle = 2.0f * M_PI * i / BUFFER_SIZE;
        float val = (sinf(angle) * interp_amp + 1.0f) * 0.5f;
        buf[i] = (uint16_t)(val * 4095.0f);
    }
    current_amplitude = target_amplitude;
}

这样,幅值变化不再是“跳变”,而是在线性过渡中完成,极大削弱高频成分。


输出质量评估:别只看示波器波形!

生成了波形,怎么才算“好”?不能光凭肉眼判断。我们需要专业手段进行量化分析。

示波器观察要点

连接10x探头,设置如下:
- 时间基准:200μs/div
- 触发方式:边沿触发,上升沿,阈值1.65V
- 显示模式:持久化(Persistence)

重点关注:
- 是否有周期抖动?→ 定时器不稳定
- 是否呈明显阶梯状?→ 采样率不足
- 是否有过冲/振铃?→ PCB布局问题或缺乏去耦

频谱分析与THD估算

使用带FFT功能的示波器查看频域分布:

基波:1kHz @ -0.5dB
二次谐波:2kHz @ -40dB
三次谐波:3kHz @ -38dB
...
THD ≈ √(10^(-40/10) + 10^(-38/10)) ≈ 0.012 = 1.2%

THD公式:

$$
\text{THD} = \sqrt{ \frac{V_2^2 + V_3^2 + \cdots}{V_1^2} }
$$

目标: THD < 1% 为良好,高端设备可达 0.1% 以下


常见故障排查清单

故障现象 可能原因 解决方案
波形中断 DMA未启循环模式 启用 CIRC
输出恒定电平 查找表未初始化 检查数组填充逻辑
频率不准 定时器分频错误 校准PSC/ARR
毛刺频繁 缓冲区切换不当 改用双缓冲+中断同步

高级玩法:多通道同步、扫频、I/Q调制

掌握了基础,就可以玩些更酷的应用了!

多通道同步输出

利用STM32双DAC,可轻松实现两路同频但相位差可控的正弦波:

// 生成相差90°的I/Q信号
for (int i = 0; i < SAMPLES; ++i) {
    ch1[i] = 2047 + 2047 * sin(2*M_PI*i/SAMPLES);        // 0°
    ch2[i] = 2047 + 2047 * sin(2*M_PI*i/SAMPLES + M_PI_2); // 90°
}

两者共用TIM6触发,确保严格同步,适用于通信调制中的I/Q载波生成。


实时参数调节:串口改频率/幅值

void process_command(char* cmd) {
    if (strncmp(cmd, "FREQ", 4) == 0) {
        float f = atof(cmd + 5);
        set_frequency(f, 44100);  // 更新定时器ARR
    } else if (strncmp(cmd, "AMPL", 4) == 0) {
        target_amplitude = atof(cmd + 5) / 100.0f;
    }
}

结合双缓冲机制,可在下一个半周期平滑应用新参数。


扫频信号发生器:测试系统频率响应

for (float f = 100; f <= 10000; f += 100) {
    set_dac_frequency(f);
    delay_ms(100);  // 每个频点停留100ms
}

配合FFT分析仪,可用于绘制滤波器Bode图、检测机械共振点等。


功耗优化:让电池续航更久一点 ⏳

在便携设备中,功耗至关重要。好消息是,DAC+DMA方案本身就非常节能。

CPU利用率有多低?

实测表明,在DMA驱动DAC运行期间,CPU占用率可低于 3% !其余时间完全可以进入低功耗模式:

while (1) {
    __WFI(); // 等待中断,电流降至微安级
}

通过SysTick统计空闲次数,还可动态上报系统负载。

更进一步:Stop模式下维持输出

部分STM32型号支持在Stop模式下由LSE或LSI维持低速时钟,并继续运行特定DMA通道。虽然DAC本身需要主电源,但合理配置PWR_CR寄存器后,可在超低功耗下维持后台波形输出。


结语:自动化思维才是嵌入式开发的未来

回顾整个设计过程,最核心的思想转变是:

❝ **不要让CPU去做它不该做的事。」

DAC负责输出,DMA负责搬运,定时器负责节奏,CPU只管调度和交互——各司其职,系统才能既高效又稳定。

这种基于 外设协同 + 自动化传输 的设计理念,正是现代嵌入式系统的灵魂所在。无论是音频合成、传感器激励,还是自动测试设备,这套模式都能为你打下坚实基础。

下次当你面对一个新的实时任务时,不妨问问自己:

“这件事,能不能交给DMA来做?” 🤔✨

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

您可能感兴趣的与本文相关内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值