第一周:TIM → ADC → DMA(Day1–Day3)
本周只把固定节拍采样链路跑通: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 | 采满一帧后停止,不自动回卷 |
采样率为:
所以:
单点周期:100 μs
128 点半块:12.8 ms
256 点整帧:25.6 ms
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 步:准备输入和调试连接
- PA0 是 ADC 输入,只能接 MCU 允许范围内的模拟电压;本练习按 0~3.3 V 理解。
- 信号源、电位器或传感器必须与开发板 共地。
- 调试器使用 SWD;先保证普通空工程能够编译、下载和运行。
- 不确定输入是否安全时,先用万用表测 PA0 对 GND 的电压。
这一步的逻辑是:软件只能读取引脚上的真实电压,接线、共地或电压范围错误时,后面的 DMA 和算法都没有讨论价值。
第 1 步:建立 MCU 工程
- 打开 STM32CubeMX,选择 New Project → MCU Selector。
- 搜索并选择
STM32F407ZGTx。 - 进入 Pinout & Configuration。
- 在 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 采样节拍
- 打开 Timers → TIM2。
- 将 Clock Source 设为 Internal Clock。
- 在 Parameter Settings 中设置:
Prescaler (PSC) = 15
Counter Period (ARR) = 99
Counter Mode = Up
Clock Division = DIV1
Auto-reload preload = Disable
- 在 TIM2 的 Master Configuration 中设置:
Trigger Event Selection / TRGO = Update Event
Master/Slave Mode = Disable
逻辑是先把 16 MHz 除以 PSC+1=16,得到 1 MHz 计数;再计 ARR+1=100 次产生一次更新:
为什么 TRGO 必须选 Update Event?因为 ADC 需要接收的是“每个计数周期结束”的硬件事件,而不是让 CPU 在定时器中断里手动启动 ADC。
第 4 步:配置 PA0 和 ADC1
- 在芯片图上点击 PA0-WKUP,选择
ADC1_IN0。 - 打开 Analog → ADC1。
- 配置 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
- 在 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:
- Project Name 填当天工程名,例如
Day1。 - Toolchain / IDE 选择
MDK-ARM。 - 勾选保留用户代码(Keep User Code)。
- 生成代码。
自己写的变量、函数和启动代码尽量放在 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 步:加入最小用户代码
按顺序加入:
adc_buf[256]和完成标志;- HT/TC 回调;
- 主循环中的半块处理;
- 最后才加入
centered_buf[]和统计逻辑。
启动顺序必须是:
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 256);
HAL_TIM_Base_Start(&htim2);
ADC DMA 先武装,TIM2 后开始计数,才能避免丢掉第一个触发。
第 10 步:第一次运行只验证数据链路
- 编译并下载。
- 点击 Run,至少运行 30 ms。
- 点击 Stop,不要一直停在回调断点里。
- 在 Watch/Memory 中看
adc_buf[0..255]。 - 再看 HT/TC 标志和错误码。
- 最后才比较
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 位样点,最坏情况下:
因此不能用 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],
¢ered_buf[0],
128);
half_blocks_processed++;
}
if (full_ready) {
full_ready = 0U;
full_stats = Process_Block(&adc_buf[128],
¢ered_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
建议按下面的顺序检查:
- 编译并下载对应 STM32F407 工程;
- 运行至少 30 ms 后暂停;
- 先看启动状态和错误码,再看事件标志;
- 对照
adc_buf与centered_buf; - 检查
centered_mean是否接近 0; - 改变 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[]