简介:在嵌入式数据采集与电机控制中,ADC采样率提升常导致CPU被中断拖累。利用定时器硬件触发ADC转换,再配合DMA双缓冲(乒乓缓冲)搬运数据,可让采样链路几乎不占用CPU资源。STM32H743基于异步时钟域的ADC与高级定时器TRGO事件,为实现稳定触发频率和低抖动采样提供了硬件基础。通过CubeMX配置TIM1触发源、ADC通道与循环DMA,并在半传输/全传输中断中交替消费缓冲区,即可构建高速、低延迟的数据流。该方案适用于振动采集、音频分析以及FOC电流环等场景,是H7系列工程中兼顾吞吐与实时性的典型实践。
1. STM32H743 定时器触发 ADC + DMA 双缓冲:让高速采样不再拖死 CPU
做电机 FOC 电流环或者振动采集时,最怕的就是采样点来了,CPU 还在处理上一轮的算法。用 HAL 库默认的轮询方式读 ADC,看起来逻辑简单,实际上一旦采样率提到几百 kHz,主循环几乎被 EOC 等待占满,稍微有点中断抖动就会丢数。stm32h743 单片机开发里常用的解法,是把定时器触发、ADC 转换和 DMA 搬运串成一条硬件链路:定时器到点,ADC 自动启动;转换完成,DMA 自动把结果搬进内存;搬满一半或搬完一整块,再通过中断通知 CPU 来消费。CPU 该算算法算算法,该跑通信跑通信,采样这件事几乎不占资源。这份源码就是一个可以照着改的完整工程,适合做数据采集、电机控制、音频分析,也适合刚上手 H7 双缓冲 DMA 的新手拿来当模板。
2. H743 的 ADC 触发链路与 DMA 双缓冲:原理、选型与两条实现路径
2.1 三种 ADC 启动方式,为什么非要定时器触发
ADC 启动这件事,HAL 库给了三种常规思路:轮询、中断、定时器触发。轮询是 while 循环里反复查转换完成标志,适合低频、单通道、对 CPU 占用不敏感的场景;中断是每次转换完成进一次中断,在中断里把数据读走,适合中等频率,但中断频率一高,上下文切换的开销就很可观;定时器触发则完全不同,ADC 的启动信号来自定时器硬件事件,转换结果由 DMA 搬走,整个过程 CPU 只在 DMA 半满或全满时被打断一次。
在 H743 上,定时器触发不是可选项而是必选项,原因在于 H7 的 ADC 是挂在异步时钟域上的。CubeMX 里 ADC 的 Clock Prescaler 作用于 adc_ker_ck,这颗时钟可以独立配置,不跟 APB2 完全绑定。只要定时器 TRGO 事件能稳定产生,ADC 就能在完全确定的时间点启动转换,采样时刻的抖动只取决于定时器计数精度,而不是中断响应延迟。这个特性对计算 ADC 信噪比非常关键——采样抖动直接决定高频信号采出来的 SNR 上限,CPU 中断响应里的几十纳秒随机延迟足够把信噪比拉低好几个 dB。
我一般会把「定时器触发 + DMA 搬运」当成 H7 采样工程的默认架构,轮询和中断只留作调试兜底。触发源常用 TIM1 或 TIM8,这两路高级定时器能直接向 ADC 输出 TRGO 事件,不需要额外飞线。选择哪个定时器,主要看它是否同时承担 PWM 输出任务。电机 FOC 场景下 PWM 波形和定时器本来就共用 TIM1,让 ADC 在 PWM 中心点触发,采样时刻恰好避开开关噪声,这比单独开一个定时器做触发更有工程价值。
2.2 双缓冲的两种实现:硬件 DBUF 与半传/全传乒乓
DMA 双缓冲在 H743 上有两条实现路径,很多人第一次接触时会被命名搞混。一条是寄存器级的硬件双缓冲(DBUF),DMA 控制器内部维护两个内存地址,每次传输完成自动切换到另一块;另一条是用 DMA 循环模式配合半传输、全传输两个中断自己搭乒乓逻辑,业内也习惯叫它软双缓冲。
硬件 DBUF 在 STM32H7 的 DMA_SxCR 寄存器里有专门的 DBUF 位,开启后 DMA 会在 M0AR 和 M1AR 两个地址之间来回切换。听起来很方便,但 HAL 库对它的封装并不完整,不像 F4 系列那样有现成的 MultiBuffer 接口,工程里直接用的反而不多。
真正主流的是第二种:把一块连续内存切成前后两半,DMA 开循环模式,先写前半块写满触发半传输中断,接着写后半块写满触发全传输中断。这样从 CPU 视角看,DMA 正在写后半块时,前半块可以安全消费,反过来也一样,形成无缝交替。下面这张表列出两者的差异,方便按场景选型:
| 对比项 | 硬件 DBUF | 半传/全传乒乓 |
|---|---|---|
| 配置复杂度 | 需要手写寄存器,HAL 支持弱 | CubeMX 生成后少量改回调即可 |
| 中断次数 | 每满一块一次 | 半满、全满各一次 |
| CPU 消费窗口 | 一个整块的处理时间 | 半个块的处理时间,延迟更低 |
| 适用场景 | 内存带宽紧张、追求极致吞吐 | 绝大多数工程,含电机控制与采集 |
双缓冲的核心收益在于生产者与消费者并行。DMA 是生产者,不断往内存写;CPU 是消费者,在处理算法时不需要等采样停下。没有双缓冲时,DMA 写完一整块才通知 CPU,这期间 CPU 可能在空转等数据;有了双缓冲,CPU 在处理前半块时 DMA 已经在写后半块,流水线不空转。后面要讲的代码就按乒乓路径展开,这也是这份源码实际采用的方案。
3. 定时器、ADC、DMA 三端打通:CubeMX 参数与逐行初始化代码
3.1 TIM1 触发源配置:通用采集与电机 FOC 两种做法
定时器触发 ADC 的配置点,核心在 TRGO 事件的选择。CubeMX 里 TIM1 的配置界面有几个关键项:Clock Source 选 Internal Clock;Mode 里按需要使能 PWM Generation CH1;Trigger Output (TRGO) 选 Update Event。选 Update Event 的触发频率由定时器时钟和重载值决定,适合做普通数据采集。
假设 APB2 定时器时钟是 240MHz,想要 600kHz 的触发频率,PSC 设为 0,ARR 设为 399,频率就是 240M / (399 + 1) = 600kHz。这个触发频率可以稳定驱动单通道 ADC 跑到 600kSPS,对大多数振动和音频采集都足够。
电机 FOC 的场景要换一种配置。FOC 里 PWM 载波通常用中心对齐模式,PWM 波形关于计数周期中点对称,开关管在计数值等于比较值的时刻动作,会有明显的 dv/dt 噪声。ADC 采样要刻意避开这个时刻,采在 PWM 中间才最干净。配置方法是 TIM1 的 Counter Mode 选 Center Aligned Mode,Trigger Output 不选 Update Event,而是选 OC1REF,也就是比较脉冲。CubeMX 里 TRGO 选项较多,FOC 工程里常见的是用 TIM1 的 TRGO2,它能独立选择比较事件作为触发源,这样触发时刻精确落在 PWM 中间相位。
通用采集和 FOC 两种方案,CubeMX 参数差异我用一张表列出来:
| 配置项 | 通用采集 | 电机 FOC |
|---|---|---|
| 计数模式 | Up Counting | Center Aligned Mode 1 |
| 频率/周期 | ARR=399,PSC=0 | 按载波频率定 ARR,如 20kHz 载波 ARR=5999 |
| TRGO 事件 | Update Event | OC1REF 或 TRGO2 比较脉冲 |
| 触发相位 | 每次计数溢出时 | PWM 中间点,避开开关噪声 |
| 适用通道 | ADC1 单通道 | ADC1 三路电流或母线电压 |
实际配置时,我习惯先在 CubeMX 里把 TIM1 调出 PWM 波形,再用逻辑分析仪确认边沿周期,最后才接 ADC 触发。跳过这一步直接进代码调,出问题时很难分清是定时器的问题还是 ADC 配置的问题。
3.2 ADC 初始化与 DMA 请求绑定
ADC 侧的初始化代码分三块:外设基本参数、校准、DMA 请求绑定。H7 的校准函数和 F4 不一样,多了偏移校准参数,不调用校准直接采样,零点误差会偏大,新手容易在这翻车。
/* ADC1 外设配置 */ hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; /* adc_ker_ck 四分频 */ hadc1.Init.Resolution = ADC_RESOLUTION_12B; /* 12 位分辨率,H7 可配到 16 位 */ hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; /* 多通道扫描必须开 */ hadc1.Init.EOCSelection = ADC_EOC_SEQ_CONV; /* 一个序列全部转换完再置 EOC */ hadc1.Init.ExternalTrigConv = ADC_EXTERNAL_TRIGCONV_T1_TRGO; /* TIM1 TRGO 触发 */ hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNAL_TRIGCONV_EDGE_RISING; /* 上升沿启动 */ hadc1.Init.ContinuousConvMode = DISABLE; /* 禁止连续转换,由定时器决定节奏 */ hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.NbrOfConversion = 3; /* 3 个通道依次转换 */ HAL_ADC_Init(&hadc1);H7 的转换时钟分频和 F4 语义不同,这里 ClockPrescaler 分的是 ADC 异步时钟,不是 APB2 时钟。H743 的 ADC 时钟上限大约 36MHz,如果配得太高,转换结果会出现偶发的符号位跳动,采样值看着是 12 位但低两位不稳定。分辨率选 12 位还是 16 位要权衡,16 位模式实际上是硬件过采样,转换时间明显变长,对高采样率场景未必划算。
通道配置是重复动作,三个通道写三遍,每个通道单独设置采样周期。采样周期直接影响 ADC 的输入阻抗匹配和信噪比,信号源内阻大时采样周期要放宽,但放宽意味着单次转换时间变长,限制了最高触发频率。
sConfig.Channel = ADC_CHANNEL_0; sConfig.SamplingTime = ADC_SAMPLETIME_8CYCLES_5; /* 采样周期 8.5 个 ADC 时钟 */ sConfig.SingleDiff = ADC_SINGLE_ENDED; /* 单端输入 */ sConfig.OffsetNumber = ADC_OFFSET_NONE; sConfig.Offset = 0; HAL_ADC_ConfigChannel(&hadc1, &sConfig);DMA 请求绑定放在 MspInit 里,CubeMX 会自动生成骨架,但有几个参数要手动确认:DMA 方向是外设到内存,内存递增开,外设地址递增关,数据宽度半字。ADC 的结果寄存器是 16 位有效,用半字搬运既能省一半总线带宽,也避免高字节无效数据混进缓冲区。
hdma_adc1.Instance = DMA1_Stream0; hdma_adc1.Init.Request = DMA_REQUEST_ADC1; /* DMAMUX 把 ADC1 请求映射到该流 */ hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; /* 外设地址固定为 ADC DR */ hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; /* 内存地址递增 */ hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; /* 循环模式,双缓冲的前提 */ hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);循环模式里 DMA 会持续响应外设请求,相当于一种连续请求模式。H7 的 DMA 支持突发传输,可以把几个请求合并成一次总线突发,减少总线仲裁次数,但突发长度配得过大、内存端口带宽不足时反而会阻塞其他 DMA 通道,工程上先用 SINGLE 跑通再调 BURST 更稳。
3.3 双缓冲缓冲区布局与启动代码
缓冲区布局是双缓冲工程的核心,我一般把两个子缓冲定义成一个二维数组,DMA 从首地址开始循环写入,前半块视为 BUF_A,后半块视为 BUF_B。数组必须 32 字节对齐,否则后面做 Cache 操作会出问题。
#define CH_NUM 3 /* ADC 通道数 */ #define BLOCK_SIZE 512 /* 每个通道每个缓冲区的采样点数 */ __attribute__((aligned(32))) uint16_t adc_buf[2][CH_NUM * BLOCK_SIZE]; /* 前半 = BUF_A,后半 = BUF_B */ #define BUF_A adc_buf[0] #define BUF_B adc_buf[1]启动代码比预期简单,真正的工作全在中断回调里:
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, CH_NUM * BLOCK_SIZE);第三个参数是 DMA 要搬运的次数,而不是缓冲区字节数,这里写 1536,对应 3 通道乘 512 点。DMA 搬完前半块触发半传输中断,搬完整个数组触发全传输中断。中间不需要任何手动参与,定时器只要不停止,DMA 就会按触发节奏持续填数。
有一点要特别说清楚:这份资源里的「双缓冲」不是指 HAL_ADC_Start_DMA 传了两个缓冲地址,而是依靠循环 DMA 天然地把一块连续内存分成前后两半来交替使用。这两半的边界由 DMA 数据长度决定,前半块填满时 DMA 自动回到起始地址继续写,不会等你处理完。这就是双缓冲并行性的来源。
4. 乒乓缓冲与数据消费:让每个采样周期都被完整接管
4.1 半传输与全传输回调:双缓冲的「换班」时机
DMA 循环模式下,半传输中断对应前半块写满,全传输中断对应整块写满,也就是后半块写满。两个回调就是双缓冲的换班信号。HAL 库的默认回调是弱函数,直接在用户文件里重写即可。
volatile uint8_t buf_a_ready = 0; volatile uint8_t buf_b_ready = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { buf_a_ready = 1; /* BUF_A 已填满,可以消费 */ } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { buf_b_ready = 1; /* BUF_B 已填满,可以消费 */ } }回调里只置标志位,不做数据处理。原因有两条:一是中断里跑算法会拉长中断时间,其他高优先级中断可能被堵住;二是数据处理耗时不确定,万一处理时间超过 DMA 填满一块的周期,DMA 已经开始覆盖缓冲区,数据就毁了。标志位由主循环消费,这个模式是嵌入式里最常见的生产者-消费者模型。
每次回调进来,CPU 拿到的是一整块连续的三通道数据。通道排列顺序由 ADC 序列寄存器决定,第一个转换的通道 0 在最前,接着通道 1、通道 2,三个一组循环。消费端取数据时要按这个交错结构解析。
void process_adc_block(uint16_t *buf, uint32_t total_samples) { uint32_t i, group_count = total_samples / CH_NUM; uint32_t ch0_sum = 0; for (i = 0; i < group_count; i++) { ch0_sum += buf[i * CH_NUM]; /* 每组第一个是通道 0 */ } ch0_avg = ch0_sum / group_count; /* 均值滤波,简单去噪 */ }参数上注意 total_samples 传入的是 CH_NUM * BLOCK_SIZE,循环里按 CH_NUM 步进,才能保证每个通道的数据都被正确取到。这块代码如果循环步长写错,最常见的现象就是三个通道的数据互相串位,看着像通道间相位偏移,实际是解析逻辑错了。
4.2 消费数据的同步:标志位、临界区与主循环
主循环的消费逻辑要处理好两个问题:一是同一块数据不能被消费两次,二是 DMA 正在写的块不能被消费。乒乓结构能解决这两个问题,但标志位的清空时机必须小心。
while (1) { if (buf_a_ready) { buf_a_ready = 0; /* 先清标志再处理 */ process_adc_block(BUF_A, CH_NUM * BLOCK_SIZE); } if (buf_b_ready) { buf_b_ready = 0; process_adc_block(BUF_B, CH_NUM * BLOCK_SIZE); } }先清标志再处理这个顺序是我的习惯,可以把临界区压到最小。如果先处理再清标志,处理过程中 DMA 又触发了一次同类型中断,标志位会被重新置上,等处理完再清会把新数据就绪信号误删,导致丢一块数据。虽然 DMA 循环模式要过一整轮才会再次触发相同中断,但半传输和全传输回调彼此独立,临界问题依然存在,养成先清后用的习惯能少踩很多坑。
主循环里还要留意处理耗时。单块处理时间必须低于 DMA 填满一块的时间。以 600kHz 触发率、每块 512 点、三通道为例,一块数据是 1536 次转换,DMA 填满一块约 2.56ms,半块约 1.28ms。如果你的处理循环里跑了浮点 FFT,一次耗时超过这个数,DMA 就会覆盖仍在消费的数据。此时有两个方向:一是把 BLOCK_SIZE 加大,延长单块时间;二是把处理任务拆到不同优先级线程,用 RTOS 的信号量来做同步,而不是裸奔主循环。这套源码里裸奔主循环的写法适合学习,上产品时我会建议换 RTOS 版本。
5. 常见问题与排查:触发断链、缓存一致性与通道错位的坑
5.1 触发类问题:采样率不对、DMA 中断不进
现象 1:实际采样频率是预期的一半或两倍。原因通常是中心对齐模式引发的。中心对齐模式下,定时器先向上计数再向下计数,一个完整周期里 Update 事件会出现两次,如果 TRGO 选的是 Update Event,ADC 触发频率就是预期值的两倍。解决方法是把 TRGO 改成比较事件,或者在计算 ARR 时主动把触发频率除 2。我曾经在 FOC 调试时遇到过电流波形周期比设定载波高一倍,查了半天 PWM,最后发现是 TRGO 事件选错。
现象 2:定时器在跑,PWM 波形正常,但 ADC 数据纹丝不动。原因大概率是外部触发边沿没配。HAL 库里 ExternalTrigConvEdge 默认是 None,必须显式配置成 Rising 或 Falling。配置寄存器之后确认接线:ADC 的外部触发来自定时器内部事件,不需要 GPIO 连接,CubeMX 里如果看不到 TIM1 的触发选项,检查定时器的 Trigger Output 有没有在 Mode 里使能。
现象 3:DMA 中断一直不进,程序卡在 HAL_ADC_Start_DMA 后的等待里。原因多半是 NVIC 没使能 DMA 中断。CubeMX 生成代码时,如果 MX_DMA_Init 之后没有看到 HAL_NVIC_SetPriority 和 HAL_NVIC_EnableIRQ 调用,DMA 传输完成不会产生中断回调。解决方法是手动补上中断使能代码,并确认中断优先级比主循环里其他快速中断低或相当,避免回调被无限抢占导致标志位长期不更新。
5.2 数据类问题:缓存不一致、通道错位、Overrun
现象 4:打开 D-Cache 后,ADC 数据偶发旧值,波形出现周期性毛刺。这是 H743 特有的坑,本质是缓存一致性问题。DMA 写内存是绕过 CPU 缓存的,数据到达物理内存后,D-Cache 里可能还存着旧值,CPU 读缓冲时命中的是 Cache 里的过期数据。现象像玄学,波形大部分时间正常,偶尔冒出一个旧数据点。解决方法是把缓冲区声明改成 32 字节对齐,并且在消费之前调用 Cache 失效操作。
SCB_InvalidateDCache_by_Addr((uint32_t *)BUF_A, (int32_t)sizeof(BUF_A)); SCB_InvalidateDCache_by_Addr((uint32_t *)BUF_B, (int32_t)sizeof(BUF_B));注意这里用的是 Invalidate 而不是 Clean,因为 DMA 写内存方向是外设到内存,CPU 只需要丢弃旧缓存,不需要把缓存里的脏数据写回。如果缓冲区没对齐,这个函数会报错或无效。我一般会在工程初始化里把 D-Cache 的启用和缓冲区对齐做成编译期断言,防止后续加代码时新缓冲区没对齐就上线。
现象 5:三通道数据整体错位,通道 1 的数据显示在通道 0 的位置。原因在 ADC 序列配置的 Rank 顺序和 DMA 数据解析不一致。HAL_ADC_ConfigChannel 里每个通道都有 Channel 和 Rank 两个参数,Rank 决定转换顺序,DMA 缓冲区里数据就按 Rank 排列。如果通道配置顺序是 Rank1=通道1、Rank2=通道0,缓冲区里第一个位置就是通道 1 的样本,主循环按 CH_NUM 步进解析时自然会错。检查方法很简单,把三个输入分别接固定电平,采集后看缓冲区前三个数的排列,能直接确认 Rank 顺序。
现象 6:ADC 错误回调里报 Overrun,采样值卡在 0 或满量程。Overrun 表示 ADC 转换完成时,DMA 还没把上一次数据搬走,数据寄存器被新值覆盖。常见原因有两个:ADC 时钟配置过高导致转换时间短于 DMA 响应时间;或者 DMA 优先级太低被其他高优先级 DMA 流抢占。解决方法是先降低 ADC 时钟分频系数,把转换时间拉长,同时把 DMA 优先级提到 High。如果还在报 Overrun,检查 DMAMUX 的请求映射是否配到了低速总线端口。
6. 验证与进阶:测触发频率、算 ADC 信噪比、试试多 ADC 交叉采样
双缓冲工程跑通之后,验证不能只看波形像不像,要量化触发频率和 ADC 信噪比。先验证触发链路:TIM1 的 PWM 输出直接接到另一个定时器的输入捕获通道,比如 TIM2_CH1,测一下 PWM 频率是否符合设定值。触发频率验证这一步做扎实,后面所有采样率推断都建立在这个数字上。我的习惯是每次改 ARR 或 PSC 后,都强制用输入捕获或频率计测一遍,而不是心算完就信了。
接着验证 ADC 链路质量。给一个通道输入标准正弦波,采集一段数据,在 PC 端做 FFT,观察频谱里有没有明显杂散。12 位 ADC 的理论信噪比大约是 74dB,工程上能跑到 60dB 以上说明链路基本健康。如果 FFT 里出现很多与输入信号无关的尖峰,优先检查采样触发抖动和 D-Cache 一致性处理,这两个是 H7 采集系统里最常见的杂散来源。
/* FFT 输入整理示例:取 BUF_A 中通道 0 的数据 */ for (int i = 0; i < BLOCK_SIZE; i++) { fft_input[i] = BUF_A[i * CH_NUM]; }进阶方向有三条。一条是把缓冲加深到四块,做「四缓冲」,消费延迟更低,代价是内存占用线性增加。另一条是把这套乒乓思路平移到串口 DMA,日志输出同样可以做到不阻塞主循环。第三条更有价值:H743 有两个 ADC,配成双 ADC 交叉采样模式,两个 ADC 交替转换同一个通道,在相同的定时器触发频率下把有效采样率翻倍。配置时需要给两个 ADC 设置不同的相位偏移,CubeMX 里能直接生成,但双缓冲的回调要同时处理两个 ADC 的中断,标志位要区分 adc1_ready 和 adc2_ready,逻辑比单 ADC 稍微绕一点。
从那以后,我每次在新板子上调试 ADC,都强制自己按固定顺序过一遍:先测定时器触发频率,再确认 DMA 半传/全传回调进入次数,然后接标准信号看 SNR,最后才碰控制算法。这套流程帮我把采样链路的变量隔离得干干净净,排查问题再也不用靠猜。希望帮到你。
本文还有配套的精品资源,点击获取