简介:本资源是面向嵌入式开发初学者与STM32进阶实践者的完整工程示例,聚焦STM32F103系列中定时器精准触发ADC采集的核心技术实现,解决模拟信号周期性、同步化采样的典型工程难题,适用于传感器数据采集、实时监控及控制系统等场景。压缩包共1026个文件,主体为561个C源码与245个头文件(h),涵盖LL库底层驱动、定时器与ADC初始化及中断处理逻辑;另有51个汇编启动文件(s)、31个依赖与编译中间文件(d/o/crf),以及IAR工程配置(icf)、Keil项目文件(uvprojx/uvoptx)和Hex/Axf等可执行输出,整体15.29MB,结构完整、开箱即用。已有6546人学习下载,资源包含arm_math库支持文件(如iar_cortexM3b_math.a、arm_dct4_init_f32.c等)及大量带详细注释的LL库调用示例,便于理解寄存器级配置逻辑、掌握TIM-ADC联动机制,并快速移植到同类项目中。
1. 定时器精准“掐点”触发ADC:为什么不用软件延时,而要让硬件自己掐表?
你有没有试过在STM32F103上用HAL_Delay()或for循环等软件方式“凑”采样间隔?结果要么采样频率飘忽不定(尤其开中断后),要么CPU被死死占住没法干别的事——这根本不是工业级数据采集,是拿单片机当电子秒表使。真正可靠的模拟量采集,必须把“什么时候采”这个权力交给硬件:让定时器(TIM2/TIM3)生成精确周期信号,直接触发ADC启动转换,全程不经过CPU干预。这套方案不是炫技,而是解决实际问题的刚需——比如监测温度传感器每10ms一次的稳定读数、捕获电机电流波形、或为后续FFT分析准备等间隔采样序列。它适用于所有基于Cortex-M3内核的STM32F1系列项目,尤其适合课程设计、毕业设计中需要体现“硬件协同”能力的环节。如果你正卡在“采样不准”“CPU忙不过来”“答辩时被问‘怎么保证时序确定性’”,那这份从需求出发、带完整寄存器配置逻辑、含真实避坑血泪经验的方案,就是为你写的。
2. 总体架构设计:定时器+ADC+DMA三级联动的硬触发闭环
这套方案的核心不是“能采”,而是“采得准、传得稳、不丢数”。它抛弃了CPU轮询或中断服务中启动ADC的传统做法,构建了一个由定时器(Trigger Source)、ADC(Conversion Engine)、DMA(Data Mover)组成的纯硬件流水线。整个流程无需CPU参与采样动作本身,只在DMA传输完成时做一次轻量级处理。这种结构天然具备高确定性、低CPU占用、抗干扰强三大优势,是嵌入式实时采集的黄金组合。
2.1 硬件资源选型与约束映射
我们严格限定在STM32F103C8T6(主流入门型号)资源范围内进行设计,所有外设均来自芯片原生支持,不依赖外部器件:
定时器:选用TIM2(高级控制定时器,支持主模式输出TRGO信号)。
提示:TIM2的TRGO信号可直接连接到ADC的EXTSEL[2:0]位,这是硬件触发链路的起点。不要用TIM6/TIM7——它们没有TRGO输出功能,强行配置会静默失败。
ADC:使用ADC1(F1系列仅ADC1支持外部触发)。
注意:ADC2/3在F103中不可用或无外部触发能力,文档里写得隐晦,但实测配置无效。
DMA:选用DMA1 Channel1(固定映射至ADC1),启用内存增量模式与循环模式。
提示:DMA通道与ADC1是硬绑定关系,查《STM32F103xx参考手册》RM0008第10.4.3节可确认。
采样引脚:PA0(ADC1_IN0),默认复用推挽输入,无需额外上拉/下拉。
关键参数需在设计初期就对齐:
| 参数 | 设定值 | 依据 |
|---|---|---|
| 目标采样率 | 1 kHz(即1 ms间隔) | 满足常见传感器动态响应需求,留有余量 |
| TIM2预分频器(PSC) | 71 | 系统时钟72 MHz → 72 MHz / (71+1) = 1 MHz计数频率 |
| TIM2自动重装载值(ARR) | 999 | 1 MHz / (999+1) = 1 kHz更新事件 → TRGO每1 ms有效一次 |
| ADC采样周期 | 55.5 cycles(对应1.5 μs @ 72 MHz ADCCLK) | PA0为普通IO,选中速采样即可,兼顾精度与速度 |
| DMA缓冲区大小 | 100个uint16_t | 足够存放100 ms原始数据,便于后续滑动平均或FFT |
这些数值不是拍脑袋定的,而是根据芯片时钟树、ADC时序要求、DMA带宽反复核算的结果。例如PSC=71而非72,是因为寄存器是“减到0触发”,所以实际分频系数是PSC+1;ARR=999同理,周期=ARR+1。漏掉这个“+1”是新人最常翻车的点。
2.2 触发链路时序图解:从TIM2溢出到数据进内存
整个硬件触发路径如下(按信号流向):
TIM2计数器 → 溢出事件 → 主模式控制器 → TRGO信号(上升沿) ↓ ADC1 EXTSEL[2:0]选择TRGO作为触发源 → ADC启动一次转换 ↓ ADC转换完成 → EOC信号置位 → DMA请求(通过ADC_CR2的DMA位使能) ↓ DMA1_Channel1响应请求 → 将ADC_DR寄存器值搬移至SRAM缓冲区 → 更新内存地址 & 计数器这个链条中,TRGO到EOC之间存在固有延迟(典型值约1.5 μs),但该延迟是恒定的、与CPU无关的,因此不影响采样间隔的周期性。真正决定采样率的是TIM2的ARR值,而非ADC转换时间——这是理解“硬件触发为何更准”的关键。
注意:必须关闭ADC连续转换模式(CONT=0),否则ADC会在一次转换结束后自动开始下一次,彻底绕过定时器控制。很多初学者调不出正确波形,根源就在这里。
2.3 初始化代码骨架:寄存器级配置逻辑(非HAL库)
虽然HAL库可用,但本方案采用标准外设库(StdPeriph_Lib v3.5)并展示关键寄存器操作,确保你理解每一行代码背后的硬件动作。以下为精简核心片段(完整初始化函数见配套工程):
// 1. 开启时钟:TIM2, ADC1, DMA1, GPIOA RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_ADC1 | RCC_APB2PERIPH_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPERIPH_DMA1, ENABLE); // 2. 配置TIM2为主模式,TRGO=Update事件(溢出) TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 999; // ARR = 999 → 1kHz TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC = 71 → 1MHz计数 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); // TRGO = Update Event // 3. 配置ADC1:外部触发、单次、右对齐、PA0通道 ADC_InitTypeDef ADC_InitStructure; ADC_DeInit(ADC1); ADC_StructInit(&ADC_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; // 单通道,不扫描 ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 关键!禁用连续模式 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; // 接收TIM2 TRGO ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55_5Cycles); // PA0 // 4. 配置DMA1_Channel1:循环模式、内存增量、16位传输 DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; // 外设地址:ADC数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; // 内存地址:你的uint16_t数组 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; // 外设→内存 DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE; // 如100 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不增(DR固定) DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize = DMA_MemoryDataSize_HalfWord; // 16位 DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环填充,防溢出 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); // 5. 启动:先开DMA,再开ADC,最后启动TIM2(顺序不能错!) DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE); // 使能ADC→DMA请求 ADC_Cmd(ADC1, ENABLE); TIM_Cmd(TIM2, ENABLE);这段代码的执行顺序至关重要:DMA必须在ADC使能前启动,ADC必须在TIM2启动前使能。如果先启动TIM2,它立刻发出TRGO,但此时ADC尚未准备好接收触发,该次转换将丢失,且可能引发状态异常。这是硬件时序依赖的典型体现——不是所有初始化都能随便调换顺序。
3. 关键模块实现:ADC校准、数据滤波与实时监控
光有硬件触发链还不够,真实项目中必须应对模拟前端噪声、ADC自身偏移、以及如何验证采集结果是否可信。本节聚焦三个落地必备模块:ADC上电校准、软件滑动平均滤波、串口实时波形输出。
3.1 ADC校准:每次上电必做的“归零”动作
STM32F103的ADC存在固有偏移误差(Offset Error),尤其在VREF不稳定或温度变化时明显。不校准直接读数,12位ADC的有效精度可能跌到10位以下。校准不是可选项,而是启动流程的强制步骤:
// 在ADC_Cmd(ADC1, ENABLE)之后、TIM_Cmd(TIM2, ENABLE)之前插入: ADC_ResetCalibration(ADC1); // 重置校准寄存器 while(ADC_GetResetCalibrationStatus(ADC1)); // 等待重置完成 ADC_StartCalibration(ADC1); // 启动校准 while(ADC_GetCalibrationStatus(ADC1)); // 等待校准完成(典型耗时~10ms)提示:校准期间ADC处于禁用状态,且必须在ADC使能后、任何转换开始前执行。若在校准未完成时启动TIM2,会导致首次采样异常甚至锁死ADC。
校准的本质是让ADC内部电路测量自身零点,并将偏差值存入校准寄存器(ADC1->DR中的特定位)。该过程不可逆,且每次上电都需重做。某高校课程设计曾因省略此步,导致学生采集的NTC温度曲线整体偏高5℃,调试三天才发现问题。
3.2 滑动平均滤波:用16字节内存换3dB信噪比提升
硬件触发解决了“何时采”,但模拟信号必然携带噪声。对单次ADC值直接使用,极易受电源纹波、PCB耦合干扰影响。我们采用最简但最有效的N点滑动平均(Moving Average),在RAM中维护一个长度为N的环形缓冲区,每次新采样到来时更新缓冲区并计算均值。
以N=8为例(平衡实时性与滤波效果):
#define FILTER_LEN 8 static uint16_t filter_buffer[FILTER_LEN]; static uint8_t filter_index = 0; static uint32_t filter_sum = 0; uint16_t apply_moving_average(uint16_t new_sample) { // 减去即将被覆盖的旧值 filter_sum -= filter_buffer[filter_index]; // 写入新值 filter_buffer[filter_index] = new_sample; filter_sum += new_sample; // 更新索引 filter_index = (filter_index + 1) % FILTER_LEN; // 返回均值(整数除法,无浮点开销) return (uint16_t)(filter_sum / FILTER_LEN); }该算法时间复杂度O(1),空间复杂度O(N),完全避免了乘除法(除法被编译器优化为位移)。实测对50Hz工频干扰抑制效果显著,且不引入相位延迟——这对闭环控制类应用至关重要。
注意:滤波应在DMA传输完成中断中执行,而非在主循环里轮询。因为DMA缓冲区满(如100点)才触发一次中断,此时可批量处理这100个点,效率远高于每点中断一次。
3.3 串口实时波形:用ASCII字符画出你的ADC曲线
答辩时最直观的验证方式,不是看串口打印一串数字,而是看到波形“动起来”。我们利用USART1(PA9/PA10)以115200bps发送ASCII波形,每行代表一个采样周期,用#字符高度表示电压相对值:
// 假设adc_buffer已DMA填满100点,max_val/min_val为本次缓冲区极值 void send_ascii_waveform(uint16_t *buf, uint16_t len, uint16_t max_val, uint16_t min_val) { uint16_t range = (max_val > min_val) ? (max_val - min_val) : 1; for(uint16_t i = 0; i < len; i++) { uint8_t height = (uint8_t)(((buf[i] - min_val) * 30) / range); // 映射到0~30行高 for(uint8_t j = 0; j < height; j++) { USART_SendData(USART1, '#'); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等待发送完成 } USART_SendData(USART1, '\n'); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); } }配合PC端串口助手(如Xshell、Tera Term)设置“显示接收到的字符”并关闭回显,就能看到实时跳动的波形。这不仅是调试利器,更是答辩时让老师一眼看懂你工作成果的“视觉后悔药”。
4. 常见问题排查:那些让你熬夜到凌晨三点的硬件玄学
这套方案看似简单,实则暗藏多个极易踩坑的“硬件玄学”点。以下5条均来自真实项目翻车现场,按现象→原因→解决三段式整理,每一条都对应一个具体可复现的错误场景:
4.1 现象:串口只打印一次数据,后续全为空
原因:DMA配置为DMA_Mode_Normal(单次模式),缓冲区填满后DMA自动关闭,不再响应后续ADC请求。
解决:务必使用DMA_Mode_Circular(循环模式)。检查DMA_InitStructure.DMA_Mode赋值是否为DMA_Mode_Circular,且DMA_Cmd(DMA1_Channel1, ENABLE)在ADC使能后执行。
4.2 现象:ADC值始终为0x0FFF(满量程)或0x0000(零)
原因:GPIOA时钟未开启,或PA0未配置为模拟输入模式(GPIO_Mode_AIN)。F1系列中,ADC通道引脚必须显式设为模拟输入,否则内部采样开关无法闭合。
解决:在GPIO初始化中加入:
GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 关键!不是浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure);4.3 现象:采样率实测为500Hz而非设定的1kHz
原因:TIM2的TIM_Prescaler或TIM_Period值计算错误,或系统时钟实际未达到72MHz(如HSE未起振、PLL未锁定)。
解决:用示波器测量PA0(或TIM2_CH1复用引脚)输出的PWM波形频率,反推TIM2实际计数频率;同时检查SystemCoreClock变量值是否为72000000。常见疏漏是RCC_Configuration()中忘记调用RCC_PLLCmd(ENABLE)。
4.4 现象:DMA缓冲区数据全为0,但ADC_DR寄存器读数正常
原因:DMA_PeripheralBaseAddr指向错误地址。ADC1的数据寄存器是&ADC1->DR(0x4001244C),而非&ADC1->CDR(双重ADC寄存器,F103不支持)。
解决:严格使用标准外设库宏定义:(uint32_t)&ADC1->DR。切勿手写地址或误用ADC1_BASE + 0x4C。
4.5 现象:定时器TRGO信号有,ADC也启动了,但DMA不搬运数据
原因:ADC_DMACmd(ADC1, ENABLE)未调用,或调用位置错误(必须在ADC_Cmd(ADC1, ENABLE)之后)。
解决:在ADC使能后立即执行ADC_DMACmd(ADC1, ENABLE),并在启动TIM2前确认该位已置位。可用调试器查看ADC1->CR2寄存器的DMA位(bit11)是否为1。
提示:以上问题,80%可通过调试器“实时查看寄存器”快速定位。建议养成习惯:遇到异常,先停在问题点,打开外设寄存器视图,逐个核对
EN位、DMA位、EXTSEL位、TRGO使能位是否按预期置位。
5. 验收与答辩:用三组对比实验说服评委,而不是靠嘴说
答辩不是技术报告朗诵会,而是用可复现、可测量的证据证明你真的搞懂了。我带过的每个毕设/课设学生,都必须完成以下三组对照实验,并将结果整理成一页PPT(含波形截图+数据表格),这是答辩时最硬核的“免答辩通行证”。
5.1 实验一:触发方式对比——软件延时 vs 硬件触发
目标:量化证明硬件触发的时序确定性优势。
方法:
- A组:用
HAL_Delay(1)在主循环中启动ADC,采集100点,用逻辑分析仪抓取PA0(ADC输入)与PA1(软件触发标记)的时序; - B组:本方案硬件触发,同样采集100点,抓取PA0与PA2(TIM2_TRGO输出)时序。
验收标准:
| 指标 | A组(软件) | B组(硬件) | 达标线 | |------|-------------|-------------|--------| | 最大抖动(Jitter) | > 200 μs | < 2 μs | ≤ 5 μs | | 平均采样间隔偏差 | ±15% | ±0.1% | ≤ ±1% |
提示:逻辑分析仪不必昂贵,Saleae Logic 4或国产DSLogic均可。重点不是设备,而是你能否用客观数据说话。某同学用普源DS1054Z示波器测出抖动曲线,评委当场点头——因为图不会骗人。
5.2 实验二:校准效果验证——校准前后噪声谱对比
目标:证明ADC校准对信噪比的实际提升。
方法:
- 断开PA0外部信号,悬空或接100kΩ下拉电阻,采集1000点原始数据;
- 分别计算校准前、校准后数据的标准差(σ);
- 用Python
matplotlib绘制直方图,观察分布宽度变化。
验收标准:
校准后σ值应降低30%以上。若变化微弱,说明校准未成功执行(检查ADC_GetCalibrationStatus返回值)。
5.3 实验三:滤波效能测试——阶跃响应与频响验证
目标:验证滑动平均滤波未引入有害延迟。
方法:
- PA0接入函数发生器,输出10Hz方波(幅值0~3.3V);
- 同时采集原始ADC值与滤波后值,用串口发送至PC;
- Python脚本绘制双曲线:蓝色为原始值,红色为滤波值,观察上升沿延迟。
验收标准: - 滤波后上升沿延迟 ≤ 1个采样周期(1ms);
- 方波顶部平坦度提升(纹波峰峰值降低≥50%)。
表格:答辩PPT必备数据页(示例)
测试项 校准前σ 校准后σ 降低比例 是否达标 悬空输入 12.8 8.3 35.2% ✓ 10Hz方波纹波 42 LSB 18 LSB 57.1% ✓ 采样抖动 215 μs 1.8 μs — ✓
从那以后我每次带学生做嵌入式采集类课题,都强制他们用这三组实验走一遍——不是为了应付答辩,而是逼自己亲手触摸硬件的真实边界。当你能指着示波器上的波形说“这里抖动超标,是因为TIM2的ARR没加1”,或者指着直方图说“校准后分布变窄,说明offset补偿生效了”,你就已经超越了90%只会调库的同学。希望帮到你。
本文还有配套的精品资源,点击获取