DMA 是被动搬运工:以 CH32X035 的 TIM1_UP + 通道 5 驱动 WS2812 为例

结论速览

  • DMA 不会自己动:通道 EN + 请求使能 + 事件真的发生,三个条件缺一不可。
  • 请求源不是”默认”的,是硬件焊死的映射:TIM1 的更新事件 TIM1_UP 只连在 DMA1 通道 5 上。
  • DMA 需要的不是”消耗方”而是请求方:目的寄存器 CH1CVR 只是搬运地址,它不会主动来取数据。
  • 唯一的例外是存储器到存储器模式(MEM2MEM=1),只有那种模式才”开了 EN 就自己连搬”。
  • 只处理 TC 不处理 TE,一次传输错误就会让忙标志永远停在”忙”,整层静默假死。

一、先补齐概念:DMA 靠什么动起来

1.1 一次搬运的三个条件

以”TIM1 更新事件把数据搬进 TIM1->CH1CVR“为例:

条件 对应代码 缺了会怎样
① 通道使能 DMA_Cmd(ch, ENABLE) 通道关闭,请求来了也搬不了
② 请求源已打开 TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE) 即使事件发生了,也不会转成 DMA 请求
③ 事件真的发生 TIM_Cmd(TIM1, ENABLE) 计数器在跑 没有更新事件 → 没有请求 → 一个半字都不搬

DMA_Cmd(ENABLE) 只是让通道待命:CNTR 保持满值,源/目的地址一动不动。所以启动顺序必须是「先备好 DMA,再开 TIM1」。

1.2 请求线是硬件固定的:请求映射表

每个外设事件的请求线在芯片内部是硬连线,软件改不了。CH32X035 手册 9.2.3「DMA 请求映射」里 TIM1 一行:

请求源 硬件连到的通道
TIM1_CH1(比较/捕获 1) 通道 2
TIM1_CH2 通道 3
TIM1_CH4 / TIM1_TRIG / TIM1_COM 通道 4
TIM1_UP(更新事件) 通道 5
TIM1_CH3 通道 6

有两点必须记住:

  • “用哪个通道”由”要哪个事件”决定,不是自由挑的。选中 TIM1_UP,通道就只能是 5。
  • 定时器通道号和 DMA 通道号没有对应关系:定时器的 CH2 事件落在 DMA 通道 3,而 DMA 通道 2 接的却是定时器 CH1。三处共用通道 4 也一样反直觉。

1.3 术语速查:UP / TRIG / COM / CCx / CCR

缩写 英文全称 中文 说明
UP Update event 更新事件 计数器上溢/下溢(或软件置 UG)重装寄存器时产生,手册里写作 UEV
TRIG Trigger event 触发事件 软件置 TG 位产生,或从模式控制器识别到触发输入时产生
COM Commutation event 换相事件 高级定时器专有,软件置 COMG 产生;用于批量更新 CCxE/CCxNE/OCxM,BLDC 六步换相靠它
CC1~`CC4` Capture/Compare channel 1~4 捕获比较通道 定时器的 4 个通道,各自方向独立(输入捕获或输出比较)
CCR Capture/Compare Register 捕获/比较寄存器 本芯片写作 TIM1->CH1CVR,STM32 里叫 TIMx->CCR1,同一个东西

CCR 是一个寄存器、两种身份,由通道方向决定:

  • 输出比较 / PWM:CNT 数到 CCR 就翻转输出 → 决定占空比;
  • 输入捕获:捕获到边沿时,硬件把当时的 CNT 复制进 CCR → 当时间戳用(测脉宽/周期)。

ARR 与 CCR 的分工是:ARR 决定周期/频率,CCR 决定高电平长度。以本工程 CK_CNT = 8MHz、ARR = 9 为例:

值 计数 时间 含义
ARR = 9 10 1.25µs 位周期
CODE_1 = 7 7 0.875µs 位 1 的高电平
CODE_0 = 3 3 0.375µs 位 0 的高电平

CCR 还是双缓冲的:写入先落到预装载寄存器,到下一个更新事件才搬进活动寄存器生效(TIM_OCPreload_Enable 就是打开这个行为)。WS2812 正需要这一点——DMA 可以随时写,而新值统一在周期边界生效,不会把高电平从中间截断。

1.4 谁在什么时候”喊”:各事件的触发时机

“喊”的是硬件事件,不是代码。软件只做两件事,之后每次事件自动搬一次:

1
2
TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE);   /* ① 打开“哪个事件”的请求使能 */
DMA_Cmd(WS2812_DMA_CH, ENABLE); /* ② 通道待命 */
请求源 DMA 通道 何时产生 本工程时基下(8MHz / ARR=9) 使能 API
TIM1_UP 5 每次计数器溢出/重装 800kHz(每 1.25µs) TIM_DMA_Update
TIM1_CH1(CC1) 2 CNT == CCR1(比较匹配)或捕获到边沿 每周期最多 1 次,相位由 CCR1 决定 TIM_DMA_CC1
TIM1_CH2(CC2) 3 同上,用 CCR2 同上 TIM_DMA_CC2
TIM1_CH3(CC3) 6 同上,用 CCR3 同上 TIM_DMA_CC3
TIM1_CH4(CC4) 4 同上,用 CCR4 同上 TIM_DMA_CC4
TIM1_TRIG 4 软件置 TG / 触发输入 偶发 与 CC4 共用
TIM1_COM 4 软件置 COMG / 换相 偶发 与 CC4 共用

两个坑:

  • DMA 通道 4 被 CC4 / TRIG / COM 三个源共用,同时只能用一个,否则请求混在一起分不清是谁发的。
  • 一次请求只搬一个数据单元(这里是 1 个半字),不是一整帧;一帧 156 个数据要 156 次请求。

1.5 中断事件:TC / TE / HT / GL

DMA 一共四种事件:

缩写 全称 含义
TC Transfer Complete 传输完成,设定次数搬完了
TE Transfer Error 传输错误,访问了保留/非法地址等
HT Half Transfer 半传输,搬完一半,用于双缓冲
GL Global 全局,上面几种的汇总位

它们有两套位,很容易看混:

1
2
3
4
5
6
7
/* CFGR 里的“中断使能位”——哪种事件能触发中断,给 DMA_ITConfig 用 */
#define DMA_IT_TC ((uint32_t)0x00000002)
#define DMA_IT_TE ((uint32_t)0x00000008)

/* INTFR 里的“状态标志”——事件发生了没有,给 DMA_GetFlagStatus / DMA_ClearFlag 用 */
#define DMA1_FLAG_TC5 ((uint32_t)0x00020000)
#define DMA1_FLAG_TE5 ((uint32_t)0x00080000)

所以「开中断」和「查标志」是两步:

1
2
DMA_ITConfig(WS2812_DMA_CH, DMA_IT_TC | DMA_IT_TE, ENABLE);  /* 让 TC/TE 都能进中断 */
if (DMA_GetFlagStatus(DMA1_FLAG_TE5) != RESET) { /* 进中断后再看是谁 */ }

状态读 DMA_INTFR,清标志写 DMA_INTFCR(写 1 清除)。

1.6 传输错误会自己把通道关掉

这一点决定了”只处理 TC”是危险的。手册原文:发生传输错误时 TEIFx 由硬件置位,同时硬件自动清 0 该通道 CCRx 的 EN 位,通道被关闭;只有置位了 TEIE 才会产生中断。

推论:TE 一旦发生,通道就停了,永远不会再有 TC。如果代码只清 TC 标志、只处理 TC 中断,那么”忙”标志就再也回不到空闲。

二、把机制套到 WS2812 上

2.1 为什么是 TIM1_UP + 通道 5

WS2812 每一位占 1.25µs,需要每个位周期更新一次比较值。TIM1 的时基被设成:

1
2
PSC = 5  -> CK_CNT = 48MHz / 6  = 8MHz
ARR = 9 -> 周期 = 10 / 8MHz = 1.25µs

计数器每 1.25µs 溢出一次,正好产生一次更新事件——和位周期天然对齐。请求源选 TIM1_UP,按 1.2 的映射表,通道就是 DMA1 通道 5。

配置三件套:

1
2
3
BspTim1BaseInit();                                    /* PSC/ARR/启动计数器 */
TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE); /* 打开“更新事件 -> DMA 请求”(DMAINTENR.UDE)*/
DMA_Init(WS2812_DMA_CH, &DMA_InitStructure); /* 目的地址 = &TIM1->CH1CVR,半字,Normal 模式 */

缓冲区长度 = 灯珠数 × 24 + 复位槽:

1
#define WS2812_SLOT_NUM  ((4u * 24u) + 60u)   /* 96 个数据槽 + 60 个复位槽 = 156 */

60 个复位槽输出常低,60 × 1.25µs = 75µs,满足 WS2812 要求的 >50µs 复位间隔。

2.2 每 1.25µs 搬一个:运行时闭环

1
2
3
TIM1 计数器溢出 -> 置更新事件 UEV -> (UDE=1)向 DMA 通道 5 发一次请求
-> DMA 取 1 个半字写入 TIM1->CH1CVR -> CNTR 减 1
-> CNTR 减到 0 -> 置 TC 标志 -> 进 DMA1_Channel5 中断

一趟 156 次搬运就是送完一帧。启动函数要做的收尾:

1
2
3
4
5
6
DMA_SetCurrDataCounter(WS2812_DMA_CH, WS2812_SLOT_NUM);  /* Normal 模式发完即停,每次都要重装 */
WS2812_DMA_CH->MADDR = (uint32_t)s_au16Slot; /* 重装源地址,防止指针漂移 */
TIM_SetCounter(TIM1, 0u);
TIM_SetCompare1(TIM1, 0u);
DMA_Cmd(WS2812_DMA_CH, ENABLE); /* 先备好 DMA… */
TIM_Cmd(TIM1, ENABLE); /* …再开 TIM1,请求才开始产生 */

顺序不能反:反过来的话计数器已经在跑,第一个更新事件可能早于 DMA 就绪,前面几个槽位会漏掉。

2.3 为什么用更新事件,而不是 CH1 比较事件

TIM_DMA_CC1(通道 2)也能触发搬运,但两个方案的生效时机不同。这里用的是更新事件 + TIM_OCPreload_Enable:DMA 写进 CH1CVR 的值先落到预装载寄存器,到下一个更新事件才生效,节拍严格按位周期走。

而用 CC1 事件的话,比较匹配发生在 CNT == CH1CVR 的那一刻——CH1CVR 正是 DMA 搬进去的数据,数据一变匹配点就跟着移,位宽会随 0/1 序列抖动,WS2812 会认错位。

代价是复位后第一个 1.25µs 周期输出的还是 compare=0(低电平),第一个数据位从第 2 个周期才出现——等于帧前多了一段低电平,对 WS2812 只有好处(复位时间更长),没有坏处。

三、工程里的三个易错点

3.1 只开 DMA 不开 TIM1,一个字节都不会搬

除了 MEM2MEM=1 的存储器到存储器传输,DMA 一律需要外设请求。DMA_M2M_Disable 下开 EN 只是待命,CNTR 保持满值。

对照实验:如果初始化里把 DMA_Cmd(ENABLE) 和 TIM_Cmd(TIM1, ENABLE) 都做了,上电就会自动搬一帧(缓冲区全 0,输出一路低电平)——这也是为什么启动动作应该统一收进 Show(),而不是留在初始化末尾。

3.2 只处理 TC 不处理 TE,忙标志会卡死

1
2
3
4
5
6
/* 收尾动作对 TC/TE 是一样的 */
if (DMA_GetFlagStatus(DMA1_FLAG_TE5) != RESET) { DMA_ClearFlag(DMA1_FLAG_TE5); }
if (DMA_GetFlagStatus(DMA1_FLAG_TC5) != RESET) { DMA_ClearFlag(DMA1_FLAG_TC5); }
DMA_Cmd(WS2812_DMA_CH, DISABLE);
TIM_SetCompare1(TIM1, 0u); /* 输出回低电平,别停在最后一个高电平槽 */
s_u8DmaIdle = 1u; /* 允许下一帧 */

漏掉 TE 的表现很隐蔽:灯突然不亮了,Show() 一直返回 E_BUSY,看起来像”卡在发送中”,其实是通道已被硬件关掉、TC 永远不会来。

顺带一个取舍:收尾时**只关本通道 DMA,不要 TIM_Cmd(TIM1, DISABLE)**。DMA 停了、CH1CVR=0,输出自然保持低电平;而关整个 TIM1 会连坐同一时基下将来可能启用的 CH2/CH3。

3.3 宏名别叫 TIM_DMA_CH1_CH

这个通道绑的是 TIM1_UP(更新事件),不是 TIM1_CH1。名字写成 TIM_DMA_CH1_CH 会让人以为它对应比较捕获通道 1,排查问题时按”CH1 的请求在通道 2”去找,方向就偏了。叫 WS2812_DMA_CH 或 TIM1_UP_DMA_CH 更准确。