第一周:TIM → ADC → DMA(Day1–Day3)

info
第一周范围

本周只把固定节拍采样链路跑通:TIM 触发 ADC,DMA 搬进 Buffer,CPU 处理安全半块。内容以 E:\embedded\TIM-ADC-DMA 的实际代码为准,不提前加入 FFT、FIR/IIR 或复杂滤波。

本周主线

Day1:TIM2 → ADC1 → DMA → adc_buf[],建立固定节拍采样
Day2:验证 DMA 的方向、位宽和地址递增,确认一帧数据搬运正确
Day3:利用 HT / TC 处理两个安全半块,完成去直流与统计

三天不是三套互不相关的程序,而是在同一条链路上逐步补齐认识:

TIM 决定什么时候采,ADC 决定采到什么值,DMA 决定数据搬到哪里,Buffer 决定 CPU 如何批量处理。

TIM2 Update / TRGO(10 kHz)
  ↓
ADC1_IN0 / PA0(12 bit)
  ↓
ADC1 数据寄存器
  ↓ DMA2 Stream0 / Channel0
adc_buf[256]
  ↓ HT / TC
centered_buf[256] + BlockStats

三天共用的采样骨架

环节 实际配置 作用
时钟 HSI 16 MHz 为定时器、ADC 等外设提供基础时钟
定时器 TIM2,PSC=15、ARR=99 产生 10 kHz Update / TRGO
ADC ADC1,PA0 / ADC1_IN0,12 位 将输入量化为 0…4095
ADC 触发 TIM2 TRGO,上升沿 一个定时器事件对应一次转换
DMA DMA2 Stream0 / Channel0 从 ADC 外设搬到 RAM
数据宽度 Half Word 与 uint16_t Buffer 对应
Buffer adc_buf[256] 保存一帧 256 点采样
DMA 模式 Normal 采满一帧后停止,不自动回卷

采样率为:

$$ f_s=\frac{16\,\text{MHz}}{(15+1)(99+1)}=10\,\text{kHz} $$

所以:

单点周期:100 μs
128 点半块:12.8 ms
256 点整帧:25.6 ms
warning
Normal DMA 也有 HT/TC

HT 表示本帧前 128 点已经完成,TC 表示整帧 256 点完成;Normal 只是不在 TC 后自动回到数组开头。第一周 Day1–Day3 都没有真正使用 Circular DMA。

开始前先认识这些词

名词 小白解释 在本工程中的具体对象
外设 MCU 内部专门完成某类工作的硬件模块 TIM2、ADC1、DMA2
触发 一个外设发出事件,让另一个外设开始工作 TIM2 的 TRGO 触发 ADC1
TRGO Timer Trigger Output,定时器对外发出的触发信号 TIM2 每 100 μs 发一次 Update TRGO
DMA 不让 CPU 逐个复制数据,而由硬件自动搬运 把 ADC1 的结果搬到 adc_buf[]
Buffer 暂存一批数据的内存数组 adc_buf[256]
HT Half Transfer,Buffer 前半已经写完 adc_buf[0..127] 可以处理
TC Transfer Complete,整块传输完成 adc_buf[128..255] 可以处理
ISR / 回调 硬件事件发生后自动进入的短函数 HAL_ADC_ConvHalfCpltCallback()
Normal DMA 搬满设定长度后停止 第一周每次只采 256 点

先不要把它理解成“大量函数调用”。真正的硬件因果关系只有:

TIM2 到点
→ ADC1 转换一次
→ ADC1 请求 DMA
→ DMA 向数组写一个 uint16_t
→ 重复 128 次触发 HT
→ 再重复 128 次触发 TC

从零开始配置 CubeMX

下面按本地 .ioc 和生成代码写,不依赖猜测。当前工程使用 STM32Cube FW_F4 V1.28.3、MDK-ARM V5.32;不同 CubeMX 版本的栏目位置可能略有变化,但参数含义相同。

第 0 步:准备输入和调试连接

  1. PA0 是 ADC 输入,只能接 MCU 允许范围内的模拟电压;本练习按 0~3.3 V 理解。
  2. 信号源、电位器或传感器必须与开发板 共地。
  3. 调试器使用 SWD;先保证普通空工程能够编译、下载和运行。
  4. 不确定输入是否安全时,先用万用表测 PA0 对 GND 的电压。

这一步的逻辑是:软件只能读取引脚上的真实电压,接线、共地或电压范围错误时,后面的 DMA 和算法都没有讨论价值。

第 1 步:建立 MCU 工程

  1. 打开 STM32CubeMX,选择 New Project → MCU Selector。
  2. 搜索并选择 STM32F407ZGTx。
  3. 进入 Pinout & Configuration。
  4. 在 System Core → SYS 中把 Debug 设为 Serial Wire。

选择 Serial Wire 后,PA13/PA14 会用于 SWD 调试。否则后续可能出现能编译但调试器无法正常连接的问题。

第 2 步:确认时钟

本工程没有启用 PLL,系统时钟使用内部 HSI 16 MHz:

SYSCLK = 16 MHz
AHB    = 16 MHz
APB1   = 16 MHz
APB2   = 16 MHz

在 Clock Configuration 页面确认这些频率。这里不追求高主频,因为第一周只验证采样链路;保持简单时钟能减少变量。

第 3 步:配置 TIM2 产生 10 kHz 采样节拍

  1. 打开 Timers → TIM2。
  2. 将 Clock Source 设为 Internal Clock。
  3. 在 Parameter Settings 中设置:
Prescaler (PSC)       = 15
Counter Period (ARR)  = 99
Counter Mode          = Up
Clock Division        = DIV1
Auto-reload preload   = Disable
  1. 在 TIM2 的 Master Configuration 中设置:
Trigger Event Selection / TRGO = Update Event
Master/Slave Mode              = Disable

逻辑是先把 16 MHz 除以 PSC+1=16,得到 1 MHz 计数;再计 ARR+1=100 次产生一次更新:

$$ 16\,\text{MHz}\div16\div100=10\,\text{kHz} $$

为什么 TRGO 必须选 Update Event?因为 ADC 需要接收的是“每个计数周期结束”的硬件事件,而不是让 CPU 在定时器中断里手动启动 ADC。

第 4 步:配置 PA0 和 ADC1

  1. 在芯片图上点击 PA0-WKUP,选择 ADC1_IN0。
  2. 打开 Analog → ADC1。
  3. 配置 ADC 全局参数:
Clock Prescaler          = PCLK2 divided by 2
Resolution               = 12 bits
Scan Conversion Mode     = Disable
Continuous Conversion    = Disable
Discontinuous Mode       = Disable
External Trigger Source  = TIM2 TRGO
External Trigger Edge    = Rising Edge
Data Alignment           = Right alignment
Number of Conversion     = 1
DMA Continuous Requests  = Disable
EOC Selection            = Single Conversion
  1. 在 Regular Conversion 中确认:
Channel      = ADC Channel 0
Rank         = 1
Sampling Time = 84 cycles

这里最容易混淆的是 Continuous Conversion = Disable。它不是“不连续采样”,而是表示 ADC 不自行不停转换;每次转换都必须等 TIM2 TRGO,所以采样时刻由定时器统一决定。

第 5 步:给 ADC1 添加 DMA

在 ADC1 的 DMA Settings 中点击 Add,选择 ADC1,并设置:

DMA Request              = ADC1
Stream                   = DMA2 Stream0
Channel                  = Channel 0
Direction                = Peripheral to Memory
Peripheral Increment     = Disable
Memory Increment         = Enable
Peripheral Data Width    = Half Word
Memory Data Width        = Half Word
Mode                     = Normal
Priority                 = Medium
FIFO Mode                = Disable

逐项理解:

  • 外设地址是固定的 ADC 数据寄存器,所以 Peripheral Increment 关闭;
  • 目标要依次写 adc_buf[0]、adc_buf[1]……,所以 Memory Increment 打开;
  • ADC 是 12 位,放入 16 位 uint16_t,所以两端选择 Half Word;
  • 第一周只抓一帧观察,因此选择 Normal。

第 6 步:打开 DMA 中断

在 ADC1 或 DMA 的 NVIC Settings 中勾选:

DMA2 Stream0 global interrupt

如果不打开它,DMA 仍可能把数据搬入数组,但 HAL 无法通过中断调用 HT/TC 回调,你会看到数组变化却等不到 half_ready 和 full_ready。

第 7 步:配置工程生成选项

进入 Project Manager:

  1. Project Name 填当天工程名,例如 Day1。
  2. Toolchain / IDE 选择 MDK-ARM。
  3. 勾选保留用户代码(Keep User Code)。
  4. 生成代码。

自己写的变量、函数和启动代码尽量放在 USER CODE BEGIN/END 区域。CubeMX 重新生成时,区域外的手工改动可能被覆盖。

第 8 步:生成后先核对,不急着写算法

在生成代码中确认下面几项:

main.c
  hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO
  htim2.Init.Prescaler = 15
  htim2.Init.Period = 99
  sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE

stm32f4xx_hal_msp.c
  hdma_adc1.Instance = DMA2_Stream0
  hdma_adc1.Init.MemInc = DMA_MINC_ENABLE
  hdma_adc1.Init.Mode = DMA_NORMAL

只有这些硬件配置正确,后面的 Process_Block() 才有可靠输入。

第 9 步:加入最小用户代码

按顺序加入:

  1. adc_buf[256] 和完成标志;
  2. HT/TC 回调;
  3. 主循环中的半块处理;
  4. 最后才加入 centered_buf[] 和统计逻辑。

启动顺序必须是:

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 256);
HAL_TIM_Base_Start(&htim2);

ADC DMA 先武装,TIM2 后开始计数,才能避免丢掉第一个触发。

第 10 步:第一次运行只验证数据链路

  1. 编译并下载。
  2. 点击 Run,至少运行 30 ms。
  3. 点击 Stop,不要一直停在回调断点里。
  4. 在 Watch/Memory 中看 adc_buf[0..255]。
  5. 再看 HT/TC 标志和错误码。
  6. 最后才比较 centered_buf 和统计量。

这个顺序遵循:

先证明硬件事件发生
→ 再证明数据搬运正确
→ 最后证明算法正确

第一周常见问题怎么定位

现象 优先检查 逻辑
adc_buf[] 全是 0 PA0 接线、共地、ADC 是否启动 没有有效输入或采样链未启动
只有 adc_buf[0] 变化 Memory Increment 目标地址没有向后移动
数组变化但 HT/TC 不进入 DMA2 Stream0 NVIC 数据能搬,但 CPU 收不到完成通知
数值长期接近 4095 PA0 电压、参考地 输入可能接近满量程或超范围
采样时间不是 25.6 ms HSI、PSC、ARR、TRGO 采样节拍由 TIM2 配置决定
重新生成后代码消失 USER CODE 区域 手工代码写在了 CubeMX 会覆盖的位置
下载失败 MCU 型号、SWD、供电、调试器 编译成功不等于硬件连接成功

Day1:先建立固定节拍采样

工程:E:\embedded\TIM-ADC-DMA\Day1

启动顺序

先启动 ADC DMA,再启动 TIM2:

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 256);
HAL_TIM_Base_Start(&htim2);

可以记成:

先准备接收者,再启动事件源

如果先启动定时器,第一个 TRGO 到来时 DMA 可能还没有准备好。

两个 Buffer 的含义

uint16_t adc_buf[256];
int16_t  centered_buf[256];
  • adc_buf 是 ADC/DMA 原始数据,范围为 0…4095;
  • centered_buf 是减去直流分量后的结果,需要表示负数;
  • 原始 12 位 ADC 数据用 uint16_t 保存;
  • 去直流后使用有符号类型。

Day1 当前代码已经初步包含块均值去直流。因此它既建立了采样链路,也第一次区分了“原始 ADC 码”和“围绕零点变化的信号”。

回调只负责通知

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        half_ready = 1U;
    }
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) {
        full_ready = 1U;
    }
}

HT/TC 回调只置标志,均值、最值和数组处理放在主循环。这样可以缩短 ISR,也方便在 Keil 中逐步观察。

Day2:确认 DMA 真的把数组搬满

工程:E:\embedded\TIM-ADC-DMA\Day2

Day2 把注意力收回到 DMA 本身:

hdma_adc1.Init.Direction           = DMA_PERIPH_TO_MEMORY;
hdma_adc1.Init.PeriphInc           = DMA_PINC_DISABLE;
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_NORMAL;

这些配置对应:

  • ADC 数据寄存器地址固定,所以外设地址不递增;
  • RAM 目标要从 adc_buf[0] 走到 adc_buf[255],所以内存地址必须递增;
  • 12 位结果写入 16 位槽位,两端都使用 Half Word;
  • Normal 模式采满 256 点后停止,适合冻结现场检查。

Memory Increment 故障实验

Day2 有一个刻意关闭内存地址递增的开关:

#define DAY2_FAULT_DISABLE_MEMORY_INCREMENT  0U

正常实验保持 0U。改为 1U 后,256 次传输都会覆盖 adc_buf[0]:

正确:ADC_DR → adc_buf[0] → adc_buf[1] → ... → adc_buf[255]
错误:ADC_DR → adc_buf[0] → adc_buf[0] → ... → adc_buf[0]

这个实验说明 DMA 不理解“数组”,它只执行地址与传输宽度配置。

Day2 验收

运行至少 30 ms 后暂停,在 Keil Watch 中确认:

adc_start_status == HAL_OK
tim2_start_status == HAL_OK
dma_memory_increment_enabled == 1
adc_error_code == 0
adc_half_done == 1
adc_done == 1
adc_buf[0..255] 均已写入

DMA 只负责搬运,真正的采样时刻仍由 TIM2 决定。

Day3:把原始 ADC 数据变成零中心信号

STM32F407 工程:E:\embedded\TIM-ADC-DMA\Day3_STM32F407_backup

Day3 保持 10 kHz、Normal DMA、256 点 不变,只完善 CPU 侧处理:

adc_buf[256] 原始 ADC 数据
  ↓
HT 处理 adc_buf[0..127]
TC 处理 adc_buf[128..255]
  ↓ int32_t 累加求均值
centered_buf[256] 有符号去直流结果
  ↓
raw_mean / centered_mean / centered_min / centered_max

统计结构

typedef struct {
    int32_t raw_mean;
    int32_t centered_mean;
    int16_t centered_min;
    int16_t centered_max;
} BlockStats;

volatile BlockStats half_stats;
volatile BlockStats full_stats;
volatile uint32_t half_blocks_processed;
volatile uint32_t full_blocks_processed;

前后半块分别保留统计量,既能检查 HT/TC 边界,也能比较两个 12.8 ms 时间窗口中的偏置和摆幅。

为什么求和必须用 int32_t

每个半块有 128 个 12 位样点,最坏情况下:

$$ 4095\times128=524160>65535 $$

因此不能用 uint16_t 累加:

int32_t sum = 0;
for (uint32_t i = 0; i < length; ++i) {
    sum += src[i];
}

int32_t raw_mean = sum / (int32_t)length;

块均值去直流

ADC 不能直接表示负电压,因此交流输入通常先叠加中点偏置。数字域中用当前半块均值估计直流分量:

int32_t centered = (int32_t)src[i] - raw_mean;
dst[i] = (int16_t)centered;

对应关系为:

adc_buf[0..127]   → centered_buf[0..127]
adc_buf[128..255] → centered_buf[128..255]

观察量的含义:

  • raw_mean:原始 ADC 的直流中心;
  • centered_mean:去直流后的均值,理论上接近 0;
  • centered_min / centered_max:相对直流中心的负、正摆幅;
  • 两个处理计数:确认 HT 和 TC 都被主循环消费。

由于整数除法存在截断,centered_mean 不一定严格等于 0。块均值去直流也不等于固定的模拟偏置校准,它会随当前半块的数据变化。

只处理已经安全的区域

if (half_ready) {
    half_ready = 0U;
    half_stats = Process_Block(&adc_buf[0],
                               &centered_buf[0],
                               128);
    half_blocks_processed++;
}

if (full_ready) {
    full_ready = 0U;
    full_stats = Process_Block(&adc_buf[128],
                               &centered_buf[128],
                               128);
    full_blocks_processed++;
}

HT 出现时后半块仍可能正在写入,所以只能处理 0..127;TC 出现后,128..255 才安全。

第一周复盘

天数 核心问题 新增理解
Day1 如何按固定节拍得到一帧 ADC 数据? TIM2 TRGO、ADC1 外部触发、DMA、256 点 Buffer;当前代码也初步加入去直流
Day2 DMA 为什么能或不能把数组搬满? 外设地址固定、内存地址递增、Half Word、Normal 模式与故障实验
Day3 0…4095 怎样变成可处理的正负信号? HT/TC 安全半块、int32_t 累加、块均值去直流、前后半块统计

本周主线可以压缩成:

TIM2 给出稳定采样时刻
→ ADC1 完成一次转换
→ DMA 把 Half Word 搬入 adc_buf[256]
→ HT / TC 告诉 CPU 哪个半块已经安全
→ CPU 求均值并写入 centered_buf[256]
→ 用统计量验证数据链路,而不是只看程序有没有运行

Keil 验收清单

启动:adc_start_status / tim2_start_status
错误:adc_error_code
事件:half_ready / full_ready
计数:half_blocks_processed / full_blocks_processed
原始:adc_buf[0..255]
结果:centered_buf[0..255]
统计:half_stats / full_stats

建议按下面的顺序检查:

  1. 编译并下载对应 STM32F407 工程;
  2. 运行至少 30 ms 后暂停;
  3. 先看启动状态和错误码,再看事件标志;
  4. 对照 adc_buf 与 centered_buf;
  5. 检查 centered_mean 是否接近 0;
  6. 改变 PA0 输入后复位重采,观察原始均值和摆幅是否合理。

编译通过只证明目标文件能够生成;下载、运行和数组值是否正确,仍需连接 STM32F407 与调试器实测。

工程目录中的两个 Day3

当前磁盘上有两个不同平台的 Day3:

Day3_STM32F407_backup/  第一周本文对应的 STM32F407 去直流工程
Day3/                   后来建立的 TI MSPM0G3507 工程

MSPM0G3507 版本使用 TIMG0、ADC12_0、DMA early/full IRQ,同样是 Normal DMA 和 256 点处理。它不能与 STM32 的 HAL 回调代码混用,后续涉及 MSPM0 时应在对应周次单独整理。

下一周接口

第一周的输出是可继续处理的 centered_buf[]。后续周次可以在这条稳定链路之后加入移动平均、连续 Circular DMA、RMS/Vpp 或频域处理,但不回头混改第一周的目标:

第一周输出:centered_buf[] + 可验证的半块统计
下一阶段输入:centered_buf[]

继续阅读:第二周:移动平均与连续 DMA(Day4–Day5)。