☰
MCU无DAC如何用定时器+DMA 2D输出高保真任意波形
2026/10/27 15:43:15 网站建设 项目流程

接到一个仪表类项目,要在 LAT1189 上输出几种不同波形:正弦、三角、带可调死区的脉冲,频率和幅度都得能实时改。板子上没有 DAC,就一个定时器加几个 DMA 通道。我一开始觉得在定时器中断里改比较寄存器也能应付,后来把频率拉到几百 kHz 才发现根本不行,CPU 占用爆表,波形还抖得没法看。后来把 DMA 的 2D 通道用起来,用定时器更新事件去触发 DMA 搬运波形表数据,CPU 直接解放,效果比预想好很多。这篇笔记就把这套方案的原理、参数计算、初始化和踩坑过程完整记下来,给准备在类似 MCU 上做波形输出的朋友一个可复用的参考。

1. 需求拆解与方案选型

1.1 这个项目到底要输出什么波形

很多场景都需要 MCU 产生模拟信号,比如信号发生器、电机驱动、数字电源、传感器激励、音频合成,甚至是压电陶瓷驱动。这个项目的核心需求就一句话:在指定的 GPIO 引脚上,输出频率和形状可变的周期性波形,同时尽量不占用 CPU。

具体到波形类型,常见有这几类:

  • 方波/带死区的互补 PWM,这是电机控制和电源最常用的
  • 三角波/锯齿波,常用于扫描信号、PWM 斜坡补偿
  • 正弦波,用于逆变、音频及各种测试设备
  • 任意自定义波形,比如从 ADC 采回来的数据回放、特殊激励信号

项目原计划是用外部 DAC 芯片输出,但硬件评审时把 DAC 去掉了,板子上只有一个定时器、几个 DMA 通道,以及一个普通 GPIO。所以我必须用现有资源把“模拟波形”变出来,而且性能不能太差。

1.2 为什么是 Timer + DMA,而不是中断或纯软件模拟

刚接到任务时,我第一反应是开一个定时器中断,每次中断把比较寄存器的值改一下。这样做对小频率没问题,但有一个致命伤:中断响应有延迟,尤其是系统里还有其他中断优先级更高时,CCR 更新时机不稳定,输出波形的周期会出现抖动。高频下更明显,比如 100 kHz 的更新率,意味着 10 us 就要改一次比较值,中断里还要做查表、赋值、清标志,CPU 压力非常大。

纯软件翻转 GPIO 就更不用说了,虽然低频能出波形,但频率一高,GPIO 翻转指令之间的延迟会让波形完全变形,而且 CPU 什么都不能干。

那为什么选 Timer + DMA?因为定时器本身能产生 PWM 输出,而 DMA 可以在硬件层面,把保存在内存里的波形采样点数据,自动搬运到定时器的比较寄存器。整个数据搬运过程不需要 CPU 介入,DMA 控制器自己完成,而且触发源是定时器更新事件,时序非常精确,不会像中断一样有抖动。

DMA 2D 通道则比普通 DMA 更进一步。普通 DMA 只能按线性地址搬一长串连续数据,2D 模式可以理解成“按行按列搬数据”:每次搬完一行数据后,源地址可以按设定好的偏移跳到下一个数据块,而不是只能顺序读取。这对波形表的组织、切换、拼接都提供了额外自由度。

1.3 这套方案需要的最少硬件资源

如果你也打算在类似项目里复现这套方案,需要确认一下手上芯片的资源配置:

  • 一个能输出 PWM、并且能产生 DMA 请求的定时器
  • 一个支持 2D(或“二维”)传输模式的 DMA 控制器
  • 至少一个带复用功能映射的 GPIO 引脚
  • 可选的一级 RC 低通滤波,用来把 PWM 载波滤掉看平滑波形

LAT1189 的定时器和 DMA 部分是支持这种组合的,这也是我最终选择这套方案的原因之一。如果你的芯片没有 2D DMA,也可以先用普通 DMA 加循环模式跑通基础波形,后面再根据手册把 2D 特性补齐。

2. 核心原理:PWM 如何变成任意波形

2.1 PWM 调制的本质

大家通常对 PWM 的印象是“调占空比控制电机速度”,但从信号角度看,PWM 其实是一个可以通过低通滤波还原成模拟电压的“开关信号”。假设 PWM 频率为 100 kHz,占空比为 50%,经过 RC 低通之后,输出电压平均值就是电源电压的一半。占空比从 0% 到 100% 变化,输出电压平均值也跟着线性变化。

所以,只要让占空比按目标波形的采样点序列连续变化,输出端滤波后的电压就是目标波形的复现。这就是用 PWM 实现任意波形的基本原理,本质上是“1-bit DAC”,或者说是“PWM DAC”。

举个例子,要输出一个正弦波,那就在内存里放一张表,表里存的是每个采样点的占空比数值。定时器每更新一次,DMA 自动把表里的下一个值写入比较寄存器,于是每个 PWM 周期的占空比都不同,整体包络就形成正弦波。

2.2 数据链路:定时器更新事件如何驱动 DMA

整个数据流是这样的:

  • 定时器计数器从 0 向上计数,到达自动重载值 ARR 时,溢出产生更新事件
  • 定时器硬件把更新事件映射为一个 DMA 请求信号
  • DMA 控制器收到请求后,按照配置,从内存里的波形表读取一个半字(16 位)数据
  • 该数据被写入定时器的比较寄存器 CCR
  • CCR 的值决定了下个 PWM 周期的占空比

这里有个重要概念:CCR 有预装载功能。如果你在初始化时使能了 CCR 预装载,那么 DMA 写入的新值不会立刻影响当前正在进行的 PWM 周期,而是等到下一个更新事件时才生效。这个特性保证了占空比切换是平滑的,不会在周期中途突然跳变,对波形质量很重要。

如果不用 DMA,改成中断里写 CCR,时序上很难保证每次都能在一个稳定时间点完成更新。而 DMA 是硬件直接触发,延迟固定且极小,实测下来波形周期抖动明显更小。

2.3 DMA 2D 通道说白了是干什么的

普通 DMA 像一个只会“直线搬运”的传送带:你告诉它起始地址和总长度,它就一直往下搬。2D 模式则把搬运任务拆成“行”和“列”两个维度,每次搬完设定长度的数据后,可以按你指定的步长跳到新的内存位置继续搬。

这句话拆开理解:

  • 内层维度:定义一次搬运多少个数据,比如 100 个点
  • 内层步长:每个数据之间的地址偏移,比如 16 位数据就是偏移 2 字节
  • 外层维度:定义搬完一行之后,下一行从哪里开始,也就是行间偏移
  • 外层行数:要这样搬几行

这正是标题里“2D 通道”的价值所在。普通 DMA 要求波形表在内存里是连续的;2D 模式则允许你组织多个波形块,把不连续分布的数据当成一个连续流来搬运。实际项目中,波形表可能放在 Flash 的不同段,或者由多段数据拼接而成,2D 模式可以直接搞定,不需要 CPU 在中间做搬运和拼接。

2.4 参数怎么算:频率、点数、分辨率之间的三角关系

要算参数,先记住三个基本关系:

  • PWM 频率 f_pwm = 定时器时钟 / (PSC + 1) / (ARR + 1)
  • DMA 请求频率 = f_pwm
  • 输出波形频率 f_wave = f_pwm / N,其中 N 是每个周期的采样点数

示例:主频 96 MHz,PSC = 0,ARR = 959,那么 f_pwm = 96 MHz / 960 = 100 kHz。如果波形表每周期 100 个点,输出波形频率就是 1 kHz。假如你想输出 10 kHz,N 仍然是 100,那么 f_pwm 需要 1 MHz,此时 ARR + 1 = 96,即 ARR = 95。这说明高频时 PWM 计数器周期短,占空比分辨率会比较低。

占空比分辨率由计数器周期决定。ARR = 959 时,CCR 可以从 0 变到 959,相当于是 960 级分辨率,接近 10 bit。ARR = 95 时,只有 96 级,约 6.6 bit,正弦波会很粗糙,量化噪声明显。

波形频率每周期点数PWM频率96MHz下(ARR+1)占空比级数波形质量
1 kHz100100 kHz960960级好
10 kHz1001 MHz9696级一般
10 kHz50500 kHz192192级中等
1 kHz200200 kHz480480级很好

所以参数选择是个三角平衡:波形频率越高、点数越多,PWM 频率就要越高,而 ARR 越小,分辨率越低。在实际项目里,我的经验是先定最高输出频率和可接受的最低分辨率,反推需要的定时器时钟,再选择点数。

3. 工程落地:从初始化到波形输出

3.1 前置工作:引脚复用和时钟确认

动手写代码前,务必去参考手册里查定时器的输出通道复用关系,确认你用的 GPIO 口能映射到哪个定时器通道。不要想当然地用任意引脚,很多 GPIO 不能直接当定时器 PWM 输出用。

时钟部分也要确认。很多 MCU 里,APB1 预分频如果大于 1,定时器时钟反而是 PCLK1 的 2 倍。有人配置完定时器发现频率慢了一倍,就是这个原因。这个坑我后面专门写,实际踩过。

3.2 定时器初始化:让更新事件成为 DMA 请求源

定时器配置的核心目标是:输出 PWM,并且把更新事件作为 DMA 请求源。参考代码如下,以 LAT1189 的 SDK 风格简化,具体寄存器名参考芯片手册调整:

TIM_TimeBaseInitTypeDef tim_base; TIM_OCInitTypeDef tim_oc; tim_base.Period = 959; // ARR + 1 = 960,100 kHz 更新率 tim_base.Prescaler = 0; // 不做额外分频 tim_base.CounterMode = TIM_COUNTERMODE_UP; tim_base.ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM0, &tim_base); tim_oc.OCMode = TIM_OCMODE_PWM1; // PWM 模式1:CNT < CCR 时输出高 tim_oc.Pulse = 0; // 初始占空比设为 0 tim_oc.OCPolarity = TIM_OCPOLARITY_HIGH; tim_oc.OCFast = TIM_OCFAST_DISABLE; TIM_OC1Init(TIM0, &tim_oc); TIM_OC1PreloadConfig(TIM0, TIM_OCPreload_ENABLE); // 关键:让定时器更新事件作为 DMA 请求源 TIM_DMARequestEnable(TIM0, TIM_DMA_Update); TIM_Cmd(TIM0, ENABLE);

注意TIM_OC1PreloadConfig这行,我建议一直开启。原因前面说了,预装载可以让 CCR 在更新事件时同步生效,避免占空比更新到一半出现毛刺。如果关掉预装载,修改 CCR 后立刻生效,反而容易造成波形畸变。

3.3 DMA 2D 配置:源地址的二维描述

这是整套方案的核心。先理清我们要搬什么:

  • 源地址:波形表数组的首地址
  • 目标地址:TIM0 的 CCR1 寄存器地址
  • 方向:内存到外设
  • 单次数据宽度:半字,因为 CCR 和波形表数值都是 16 位
  • 内存地址递增:每个点递增 2 字节
  • 外设地址不递增:每次都写同一个 CCR1

再配置 2D 模式的三个关键参数:

  • 每行点数:就是波形表一周期长度,比如 100
  • 行间偏移:搬完一行后源地址跳多少字节,一般设置为 0,表示连续读下一张表
  • 总行数:如果要连续输出多个周期,可以设为循环次数;如果只想周期性无限循环,配合 DMA 循环模式即可

代码结构大致如下:

DMA_2DInitTypeDef dma_2d; dma_2d.PeriphAddr = (uint32_t)&TIM0->CCR1; dma_2d.MemoryAddr = (uint32_t)wave_sin_table; dma_2d.Direction = DMA_MEMORY_TO_PERIPH; dma_2d.PeriphInc = DMA_PINC_DISABLE; dma_2d.MemoryDataSize = DMA_MDATAALIGN_HALFWORD; dma_2d.PeriphDataSize = DMA_PDATAALIGN_HALFWORD; dma_2d.LineLength = WAVE_POINTS; // 100 dma_2d.LineOffset = 0; // 行间偏移,单位是字节 dma_2d.LineCount = 0; // 0 表示无限循环配合 DMA 循环模式 dma_2d.CircularMode = ENABLE; // 开启 DMA 循环 dma_2d.ContinuousRequests = ENABLE; // 开启连续请求 dma_2d.MemoryInc = DMA_MINC_ENABLE; DMA_2DInit(DMA0, DMA_CH0, &dma_2d); DMA_Cmd(DMA0, DMA_CH0, ENABLE);

ContinuousRequests这个位非常重要。有些芯片里叫连续请求,有些叫“循环模式”,它决定 DMA 在搬运完设定长度后是否自动重载并继续下一次搬运。如果这里没有开启,你只能看到非常短暂的单个突发波形,而不是连续波形。

关于LineCount,不同芯片的定义可能不同。有的芯片支持无限循环,有的需要设一个较大的数。如果你发现循环模式已经开了但波形仍然会停,多半是外层行数配置不对,仔细查一下手册。

3.4 波形表怎么生成,存哪里

波形表本质是“占空比数值序列”。以 100 点正弦表为例,我们希望每个点代表一个 PWM 周期内的 CCR 值,CCR 值等于该时刻的占空比乘以 ARR。

我在实际项目里用 Python 离线生成,避免在工程里写一大段启动计算代码。生成代码:

import math ARR = 959 N = 100 for i in range(N): # 占空比在 5% ~ 95% 之间变化,留出一点余量 duty = 0.5 + 0.45 * math.sin(2 * math.pi * i / N) ccr = int(duty * ARR) print(ccr, end=', ')

输出会是一串 0~959 之间的整数,直接贴进 C 代码:

const uint16_t wave_sin_table[100] = { 479, 506, 533, 560, ... };

为什么一定要用const?因为我们只需要 DMA 读取,不需要改,放 Flash 能省掉宝贵的 RAM。这也体现了一个关键点:源地址指向 Flash 是完全可以的,DMA 的源数据宽度和 Flash 读取速度要匹配。实测中 Flash 零等待状态下没问题,如果芯片有 Flash 等待周期,高频 DMA 搬运时可能会有性能问题,那时可以把表放到 RAM。

三角波表也类似:

N = 100 for i in range(N): if i < N // 2: duty = 0.05 + 0.9 * i / (N // 2) else: duty = 0.95 - 0.9 * (i - N // 2) / (N // 2) ccr = int(duty * ARR)

3.5 启动顺序和实时切换波形

启动顺序有个细节:先使能 DMA,再使能定时器。如果反过来,定时器已经开始产生更新事件,而 DMA 还没有准备好,前几次请求会落空,输出的第一个周期可能是错误的。

DMA_Cmd(DMA0, DMA_CH0, ENABLE); // 先 DMA TIM_Cmd(TIM0, ENABLE); // 后 Timer

波形切换时,建议在 DMA 搬运完成中断里改源地址。假设你准备了正弦表、三角表、方波表三张表,切换过程是:

  • 开启 DMA 传输完成中断
  • 在中断回调里把MemoryAddr改成新表地址
  • 清标志,等下一个周期自动生效

为什么一定要在中断里改?因为 DMA 正在按当前的源地址跑,如果任意时刻强行改源地址,可能会让当前周期中间突然跳到另一个地址,造成波形截断或毛刺。在“整行传输完成”的边界改地址,则能保证这一帧数据完整输出,下一帧才开始切换。

如果你不想频繁打断核心逻辑,可以用 DMA 半传输和全传输双缓冲技巧:CPU 在半传输中断里更新前半段表数据,在全传输中断里更新后半段,这样搬运过程完全连续。这个技巧在多段拼接波形时特别好用。

4. 调试实录与常见问题

4.1 最常见问题速查表

现象可能原因排查与解决
引脚一直高或一直低DMA 没触发,或波形表值全是 0/全是 ARR检查定时器 DMA 请求使能、DMA 通道请求源、波形表数值范围
只输出一两个周期就停DMA 循环模式/连续请求没开设置 ContinuousRequests = ENABLE,检查外层行数
波形频率是预期的一半定时器时钟源倍数搞错了查 RCC 时钟树,确认 APB 预分频对 TIM 时钟的影响
占空比跳变有毛刺CCR 预装载未开启,或 DMA 优先级太低开启 CCR 预装载,提高 DMA 通道优先级
换波形不生效修改源地址时机不对在 DMA 传输完成中断里切换,不要在搬运中途改
高频时波形量化感强ARR 太小,分辨率不足提高定时器时钟,或减少每周期点数
运放输出波形差示波器探头地线太长/没有滤波用短地线弹簧夹,加一级 RC 低通

4.2 我调波形时遇到的两个关键时刻

第一个问题是只输出一个脉冲再接一个该死的高电平。排查时钟、引脚、DMA 都比较正常,后来发现是 DMA 循环模式没开。定时器更新事件确实触发了 DMA,DMA 搬完 100 个点后认为任务完成,就停在那里,而定时器还在继续输出,CCR 停留在最后一个值上,所以引脚就变成了固定电平。解决办法就是开循环模式,让 DMA 永远循环读波形表。

第二个问题是频率慢了一倍。我按照 PCLK1 的 48 MHz 计算定时器,怎么算都不对,实际输出频率正好是预期的一半。后来翻了芯片参考手册的时钟树,才发现 APB1 预分频系数不为 1 时,定时器时钟是 APB1 的两倍,也就是 96 MHz,而我还拿 48 MHz 在算。这个问题在各种 MCU 上都很常见,配置定时器前务必先确认 TIMx_CLK 的真实来源。

4.3 测量波形,别用错误方法骗自己

测量 PWM 调制的模拟波形,有两个地方特别容易踩坑。

首先是示波器接地。探头的地线夹如果太长,等效于一个天线,会引入很多高频噪声,毛刺看起来特别多。建议用探头自带的短接地弹簧,直接夹在靠近测试点的地线上。

其次是滤波问题。直接用示波器看 PWM 引脚,你看到的是一堆高频脉冲,而不是平滑正弦波。需要一级 RC 低通把载波滤掉。常见取值 R = 1 kΩ,C = 10 nF,截止频率大约 16 kHz,对 1 kHz 正弦波影响很小,但对 100 kHz 的 PWM 载波衰减比较明显。

如果驱动负载,建议 RC 后加一级运放电压跟随器,否则负载电阻会改变 RC 滤波特性。

5. 扩展思路:这套 DMA 2D 还能怎么用

5.1 不止是波形输出,还能做 ADC 多通道数据重组

这套 Timer + DMA 2D 思维可以用在很多地方。最典型的是 ADC 多通道扫描循环采样。普通 DMA 做 ADC 采样时,数据到达的顺序按 ADC 通道顺序排列,如果你对不同通道的数据做不同处理,需要在内存里排序,会有 CPU 开销。用 2D DMA 可以配置地址步进,让每一通道的数据直接落到独立缓冲区,CPU 只管处理。这个思路在很多需要连续采集的项目里很实用。

串口 DMA 接收也是一样的套路。配合空闲中断,可以把不定长数据包完整接收进环形缓冲区,避免主循环频繁进出串口中断。DMA 2D 地址跳转能力还能把多个不同缓冲区的数据拼接成一条报文直接发送,省掉内存拷贝。

5.2 如果芯片不支持 2D DMA,怎么降级

如果你换了一颗没有 2D 模式的 MCU,不要慌,方案依然能实现,只是波形表必须是连续内存块。降级思路是:

  • 用普通 DMA 通道加循环模式,一张表一个任务
  • 切换波形时,在 DMA 传输完成中断里修改内存起始地址
  • 如果同时要输出多路不同波形,每路 PWM 通道占用一个普通 DMA 通道

这样做的代价是波形表组织不灵活,但基本功能仍然可用。2D 通道更像是一个优化选项,让表与表之间的编排更自由。

我个人的体会是,这种“硬件自动搬运数据到外设”的思路,能解决很多 CPU 忙不过来的实时性问题。做嵌入式越多,越会发现 Timer、DMA 这些外设的潜力远比想象中大,关键是建模清楚“什么事件触发什么搬运”这条链路。遇到类似波形输出需求,先不要急着写中断,翻翻手册看看硬件能不能自己完成,往往会有意外惊喜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询