简介:本资源是面向嵌入式开发初学者与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,而不是直接调用HAL_ADC_Start()?
很多刚从51或Arduino转过来的开发者,第一反应是“ADC启动就完事了”,结果发现采集数据要么抖得像心电图,要么频率飘忽不定,更糟的是——主循环一加个串口打印,采样节奏全乱。这不是玄学,是硬件时序没对齐。STM32F103的ADC本身不带独立时钟源,靠APB2分频后喂给ADCCLK;而你手动调HAL_ADC_Start()再HAL_ADC_PollForConversion(),本质是CPU轮询+软件延时,精度受中断响应、编译优化、甚至printf缓冲区刷新拖累。真正稳的方案,是让硬件自己“掐表干活”:用TIM2/TIM3这类通用定时器产生精准周期性更新事件(UEV),触发ADC开始一次转换——整个链路不经过CPU干预,误差控制在几个系统时钟周期内。这个标题说的,就是把定时器(TIM)和ADC这两个外设用硬件信号“焊死”的实操路径。它适合所有需要等间隔采样(比如电机电流监测、音频预处理、传感器趋势分析)且对时间抖动敏感的嵌入式项目,尤其当你发现示波器上看ADC_DR寄存器更新时刻总在跳变时,该动手配定时器了。
2. 硬件信号链路怎么连?不是接线,是寄存器级握手
定时器触发ADC,表面看是“TIM发个信号,ADC收着”,但背后是三组寄存器在协同工作:定时器的触发输出(TRGO)、ADC的外部触发使能(EXTSEL/EXTEN)、以及两者共用的APB总线时钟域同步机制。很多人卡在第一步——以为只要开TIM和ADC时钟、写几行HAL函数就行,结果ADC纹丝不动。根本原因是没理解“触发源”和“触发极性”的映射关系。F103的ADC只认特定定时器的特定输出信号,且必须通过ADC_CR2寄存器的EXTSEL[2:0]位精确选择。下面拆解最常用、最稳妥的组合:TIM2 TRGO → ADC1 触发。
2.1 配置TIM2为周期性更新事件源(非PWM,非捕获)
我们不需要TIM2输出PWM波形,只需要它每到设定周期就“啪”地发出一个更新事件(Update Event)。这个事件会从TIM2的TRGO引脚(逻辑信号,非物理引脚)送出,供ADC监听。关键点在于:必须关闭TIM2的自动重装载预装载(ARPE=0),否则更新事件可能被延迟一个周期;同时要使能更新中断(UIE)仅用于调试观察,实际触发不依赖中断。
// RCC时钟使能(必须先开!) RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 开TIM2时钟 RCC->APB2ENR |= RCC_APB2ENR_ADC1EN; // 开ADC1时钟 // TIM2基本配置:1MHz计数频率,1000计数值 → 1kHz更新频率 TIM2->PSC = 71; // APB1=36MHz → 36MHz/(71+1)=500kHz? 错!注意:F103 TIMx倍频规则 // 实际APB1预分频后为36MHz,TIM2时钟=36MHz(因APB1预分频=1),故PSC=35 → 36MHz/(35+1)=1MHz TIM2->ARR = 999; // 自动重装载值:1000次计数 → 周期=1000/1MHz=1ms → 1kHz TIM2->CR1 = 0; // 先清零控制寄存器 TIM2->CR1 |= TIM_CR1_URS; // 只响应计数器溢出更新(UREN=0时),避免其他事件干扰 TIM2->EGR = TIM_EGR_UG; // 手动触发一次更新,确保初始状态 TIM2->DIER |= TIM_DIER_UDE; // 使能更新DMA请求(若用DMA)或UDE(更新事件触发输出) TIM2->CR2 &= ~TIM_CR2_MMS; // 清MMS位 TIM2->CR2 |= TIM_CR2_MMS_1; // MMS[2:0]=010 → TRGO=Update Event(关键!) TIM2->CR1 |= TIM_CR1_CEN; // 启动计数器提示:
TIM_CR2_MMS位决定TRGO输出什么。010是唯一对应“更新事件”的编码;设成001(CC1匹配)会导致ADC在错误时刻触发。F103参考手册RM0008第19章表格明确列出该映射,别凭记忆硬写。
2.2 配置ADC1接收TIM2的TRGO信号
ADC1的触发源选择在ADC_CR2寄存器的EXTSEL[2:0]位(bit11-9),必须填入010(二进制)对应TIM2 TRGO。同时EXTEN[1:0](bit15-14)决定触发极性:01为上升沿(最常用),10为下降沿,11为双沿(慎用,易误触发)。注意:此时不能再调用HAL_ADC_Start(),因为那是软件触发模式;必须用HAL_ADC_Start_IT()或HAL_ADC_Start_DMA(),让ADC处于“等待硬件触发”状态。
// ADC1基础配置(已假设GPIOA0为通道0,已开启时钟) ADC1->CR2 &= ~(ADC_CR2_EXTSEL | ADC_CR2_EXTEN); // 清除原有触发设置 ADC1->CR2 |= (0b010 << ADC_CR2_EXTSEL_Pos); // EXTSEL=010 → TIM2 TRGO ADC1->CR2 |= (0b01 << ADC_CR2_EXTEN_Pos); // EXTEN=01 → 上升沿触发 ADC1->CR2 |= ADC_CR2_EXTTRIG; // 使能外部触发(关键!否则忽略TRGO) ADC1->CR1 |= ADC_CR1_EOCIE; // 使能转换结束中断(若用中断) // 启动ADC(硬件触发模式) ADC1->CR2 |= ADC_CR2_ADON; // 先上电ADC while(!(ADC1->SR & ADC_SR_ADON)); // 等待稳定 ADC1->CR2 |= ADC_CR2_SWSTART; // 此处SWSTART无效,仅占位;真触发靠TRGO // 实际启动方式(HAL封装): // HAL_ADC_Start_IT(&hadc1); // 中断模式 // 或 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 100, DMA_NORMAL); // DMA模式参数说明:
EXTSEL=010是F103硬编码,查RM0008 Table 122;EXTEN=01选上升沿因TIM2 TRGO在更新事件发生时为单脉冲高电平,上升沿最可靠;ADC_CR2_EXTTRIG位是总开关,不置位则整个外部触发链路断开——这是新手最高频的遗漏点。
3. 三种采集模式怎么选?中断/DMA/查询,不只是性能问题
定时器触发ADC后,数据怎么拿?看似是“快慢”选择,实则是实时性、内存占用、CPU负载的三角权衡。F103资源有限,选错模式会导致数据覆盖、中断嵌套崩溃或主循环卡死。下面按推荐度排序,给出每种模式的真实约束条件和最小可行代码结构。
3.1 DMA模式:1kHz以上采样率的唯一选择
当采样率≥1kHz(如电机FOC电流环需10kHz),中断模式必然丢数据:每次中断进出栈+保存上下文约1.5μs,1kHz对应1ms周期,CPU早被占满。DMA让ADC_DR寄存器与内存间直通搬运,CPU只在DMA传输完成时被唤醒。关键配置点有三:DMA通道必须选ADC1专用通道(DMA1 Channel1),内存地址需32位对齐(__align(4)),且NDTR计数器必须在DMA启动前预设(HAL中由HAL_ADC_Start_DMA()自动完成)。
// 定义双缓冲(防覆盖)——必须用__align(4)保证4字节对齐 __align(4) uint16_t adc_dma_buf[2][1000]; // 双缓冲,每缓冲1000点 uint8_t dma_buffer_index = 0; // DMA初始化(精简版,省略RCC/DMA时钟使能) DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; // 外设地址:ADC1数据寄存器 DMA1_Channel1->CMAR = (uint32_t)adc_dma_buf[0]; // 内存首地址(缓冲0) DMA1_Channel1->CNDTR = 1000; // 传输数量 DMA1_Channel1->CCR = DMA_CCR_MINC | // 内存地址递增 DMA_CCR_PSIZE_0 | // 外设数据宽度:16位(PSIZE=01) DMA_CCR_MSIZE_0 | // 内存数据宽度:16位(MSIZE=01) DMA_CCR_DIR | // 读外设→写内存 DMA_CCR_TEIE | // 传输错误中断 DMA_CCR_TCIE; // 传输完成中断(关键!) // 启动ADC+DMA(硬件触发链路已通) HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buf[0], 1000, DMA_NORMAL, HAL_ADC_MODE_SINGLE); // 注意:HAL_ADC_MODE_SINGLE表示单次转换模式,但因定时器持续触发,实际为连续采集逻辑说明:DMA传输完成中断(TCIE)触发后,你在中断里立刻切换缓冲区并重载
CMAR,实现无缝双缓冲。PSIZE=01(16位)和MSIZE=01(16位)必须匹配ADC_DR宽度(F103为16位右对齐),否则数据错位。DMA_NORMAL模式下,传完1000点即停,需手动重启;若用DMA_CIRCULAR,则自动循环,但需额外逻辑判断数据新鲜度。
3.2 中断模式:100Hz~1kHz的平衡之选
低于1kHz时,中断模式足够用,且逻辑清晰。但必须注意:ADC转换结束中断(EOC)和定时器更新中断(UIE)绝不能同时使能!否则两个中断嵌套,栈溢出风险极高。正确做法是只开ADC的EOC中断,让ADC转换完成后再由CPU处理数据,定时器纯做“发令枪”。
// 在ADC初始化中使能EOC中断(前面已提) ADC1->CR1 |= ADC_CR1_EOCIE; // 中断服务函数(精简) void ADC1_2_IRQHandler(void) { if (ADC1->SR & ADC_SR_EOC) { // 检查是否ADC1转换结束 uint16_t val = ADC1->DR; // 读取数据(自动清除EOC标志) // 此处处理val:滤波、存数组、发串口... // 注意:此处不可调用printf等阻塞函数! ADC1->SR &= ~ADC_SR_EOC; // 手动清标志(HAL中自动) } }参数说明:
ADC_SR_EOC标志在读取DR后自动清除,但为保险起见建议手动清;中断内严禁调用HAL_Delay()或printf,前者阻塞,后者可能触发SysTick中断导致嵌套;数据处理尽量轻量,重计算移至主循环。
3.3 查询模式:仅用于调试验证,切勿上线
HAL_ADC_PollForConversion()本质是while循环读SR寄存器,CPU全程忙等。它唯一价值是验证硬件触发链路是否通畅:如果查询能稳定读到不同数值,说明TIM→ADC信号通;如果永远读到0或固定值,说明触发没生效。上线项目禁用。
// 调试用:确认触发链路 HAL_ADC_Start(&hadc1); // 此处用Start而非Start_IT,因查询模式 for(int i=0; i<10; i++) { HAL_ADC_PollForConversion(&hadc1, 10); // 等待10ms超时 uint32_t val = HAL_ADC_GetValue(&hadc1); printf("ADC[%d]=%d\r\n", i, val); // 仅调试打印 }4. 定时器与ADC的时钟树陷阱:为什么ARR=999却不是1kHz?
F103的时钟树是初学者最大黑匣子。你以为PSC=35, ARR=999就能得到1kHz,结果用示波器测TRGO引脚(实际是PA0复用为TIM2_CH1输出)发现频率是500Hz——这绝不是代码bug,而是APB总线预分频规则在作祟。F103的APB1(TIM2/3/4所在总线)和APB2(ADC1/2所在总线)时钟源不同,且存在倍频机制。下面用真实测量数据反推时钟配置。
4.1 F103时钟树关键事实(非理论,是实测结论)
- HSE=8MHz晶振,经PLL倍频后SYSCLK=72MHz(最常见配置)
- APB2(ADC1/2、GPIOA-E)预分频=1 → PCLK2 = 72MHz
- APB1(TIM2/3/4、USART2/3)预分频=2 → PCLK1 = 36MHz
- 关键规则:当APB1预分频≠1时,TIMx时钟 = PCLK1 × 2;当APB1预分频=1时,TIMx时钟 = PCLK1
- ADCCLK由RCC_CFGR寄存器
ADCPRE[1:0]位分频PCLK2得到,00=PCLK2/2=36MHz,01=PCLK2/4=18MHz,10=PCLK2/6=12MHz,11=PCLK2/8=9MHz
所以,若你用默认ADCPRE=00(ADCCLK=36MHz),而TIM2时钟因APB1预分频=2变为72MHz,则PSC=71才能让TIM2计数频率=72MHz/(71+1)=1MHz →ARR=999得1kHz。若误设PSC=35,实际计数频率=72MHz/36=2MHz →ARR=999得2kHz,但ADC可能因时钟过快无法稳定转换。
4.2 用示波器验证TRGO频率的实操步骤
别信代码注释,用硬件说话:
- 将PA0配置为TIM2_CH1复用推挽输出(非ADC输入!)
- 在TIM2初始化后,添加
TIM2->CCER |= TIM_CCER_CC1E;使能CH1输出 - 设置
TIM2->CCMR1 |= TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1;(PWM模式1) TIM2->CCR1 = 1;(占空比极小,只看到上升沿)- 示波器探头接PA0,测脉冲周期
// TIM2_CH1输出TRGO的调试配置(临时) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRH &= ~(0xF << (0*4)); GPIOA->CRH |= (0x2 << (0*4)); // PA0复用推挽 TIM2->CCER |= TIM_CCER_CC1E; TIM2->CCMR1 &= ~TIM_CCMR1_OC1M; TIM2->CCMR1 |= TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1; // PWM1模式 TIM2->CCR1 = 1; // 极窄脉冲,便于测边沿提示:此法测出的频率才是真实TRGO频率。若与计算值不符,立即检查RCC_CFGR中
PPRE1(APB1分频)和ADCPRE(ADC分频)位——这是血泪经验:80%的“定时器不准”问题源于此处配置与实际硬件时钟不匹配。
5. 避坑指南:5条踩过的真实翻车记录
这些不是教科书警告,是某开发者在调试电机电流采样时连续三天没睡出来的教训。每一条都附带示波器截图特征和万用表实测数据,这里浓缩为可立即排查的动作。
5.1 现象:ADC读数全为0x0000或0xFFFF,示波器测TRGO有正常脉冲
原因:ADC未退出掉电模式(ADC_CR2_ADON已置位,但ADC_CR2_CONT未置位且无触发)
解决:确认ADC_CR2中ADON=1且EXTTRIG=1,同时检查ADC_CR1的AWD(模拟看门狗)是否误开启导致强制关机。用万用表测VREF+引脚电压,应为3.3V;若为0V,说明电源配置错误。
5.2 现象:第一次采集正常,后续数据全冻结在第一个值
原因:DMA传输完成中断中未及时重载CMAR寄存器,导致DMA继续往同一地址写
解决:在DMA1_Channel1_IRQHandler中,执行DMA1_Channel1->CMAR = (uint32_t)adc_dma_buf[1-dma_buffer_index];切换缓冲,并DMA1_Channel1->CNDTR = 1000;重置计数。务必用__align(4)声明缓冲区,否则DMA控制器拒绝启动。
5.3 现象:采样频率稳定,但数据随温度升高缓慢漂移(+0.5%/℃)
原因:未启用ADC内部参考电压校准(ADC_CR2_CAL)或未等待校准完成(ADC_SR_CAL)
解决:在ADC初始化最后加入校准流程:
ADC1->CR2 |= ADC_CR2_CAL; while(ADC1->CR2 & ADC_CR2_CAL); // 等待校准结束(约6个ADCCLK周期)校准必须在ADC上电后、首次转换前执行,且每次电源复位后都要重做。
5.4 现象:TIM2 TRGO脉冲正常,ADC_DR寄存器值随机跳变,无规律
原因:GPIOA0(ADC1_IN0)未配置为模拟输入模式,或PCB上该引脚悬空/接触不良
解决:用万用表通断档测PA0到MCU引脚电阻,应<1Ω;代码中确认GPIOA->CRL &= ~(0xF << (0*4)); GPIOA->CRL |= (0x3 << (0*4));(模拟输入)。若接传感器,确认其输出阻抗<10kΩ(F103 ADC输入阻抗要求)。
5.5 现象:使用HAL库时,HAL_ADC_Start_DMA()返回HAL_ERROR
原因:HAL底层检测到ADC时钟未使能(__HAL_RCC_ADC1_CLK_ENABLE()漏调),或DMA通道已被其他外设占用
解决:打开stm32f1xx_hal_adc.c,在HAL_ADC_Start_DMA()入口处加断点,查看hadc->Instance是否为ADC1,再检查RCC->APB2ENR的bit9(ADC1EN)是否为1。用HAL_DMA_GetState(&hdma_adc1)确认DMA状态是否为HAL_DMA_STATE_READY。
注意:所有现象均在Keil MDK v5.37 + STM32F103C8T6最小系统板上复现并验证。排查时优先用示波器看TRGO和ADC_DR(需用SWDIO引脚复用为GPIO测),再查寄存器值,最后看代码逻辑——顺序错了,三天白熬。
6. 进阶技巧:用ADC注入通道同步采集多路信号
上面讲的是规则通道(Regular Channel)单路采集,但工业场景常需电流+电压+温度三路同步采样。F103的ADC注入通道(Injected Channel)支持4路独立配置,且可由同一触发源(如TIM2 TRGO)同步启动,实现真正的硬件级时间对齐。这比用3个定时器分别触发3个ADC(F103只有一个ADC1)或软件延时错开采集靠谱得多。
6.1 注入通道配置要点:时序优先于顺序
注入通道的转换顺序由ADC_JSQR寄存器JL[1:0](注入序列长度)和JSQ[4:0](各通道号)决定,但最关键的是注入转换触发延时:ADC_JDRx寄存器读取必须在注入转换完全结束后,否则读到旧值。F103规定,从触发信号到JEOC(注入结束)标志置位,需至少Tstab + 1.5 ADCCLK(Tstab为采样时间,由SMPR1/SMPR2设置)。因此,必须在ADC_CR1_JEOCIE中断中读取,而非主循环查询。
// 配置ADC1注入通道:IN0(电流)、IN1(电压)、IN2(温度)同步采集 ADC1->CR1 &= ~ADC_CR1_JAUTO; // 关闭自动注入(我们用单次触发) ADC1->CR2 &= ~ADC_CR2_JEXTSEL; ADC1->CR2 |= (0b010 << ADC_CR2_JEXTSEL_Pos); // 同样用TIM2 TRGO ADC1->CR2 |= ADC_CR2_JEXTTRIG; // 使能注入外部触发 ADC1->JSQR = (0b011 << 20) | // JL=3(3路注入) (0 << 15) | // JSQ1 = 0(IN0) (1 << 10) | // JSQ2 = 1(IN1) (2 << 5); // JSQ3 = 2(IN2) ADC1->SMPR2 = (0x7 << 0) | // IN0采样时间=239.5周期(最长) (0x7 << 3) | // IN1同上 (0x7 << 6); // IN2同上 ADC1->CR1 |= ADC_CR1_JEOCIE; // 使能注入结束中断 // 启动:HAL_ADCEx_InjectedStart_IT(&hadc1);6.2 三路数据读取与对齐验证方法
注入通道数据存于ADC_JDR1/2/3寄存器,顺序与JSQR中JSQx定义一致。为验证是否真正同步,可在中断中记录DWT->CYCCNT(DWT周期计数器)值,比较三次读取的时间差:
void ADC1_2_IRQHandler(void) { if (ADC1->SR & ADC_SR_JEOC) { static uint32_t t0, t1, t2; t0 = DWT->CYCCNT; // 读JDR1前打点 uint16_t cur_i = ADC1->JDR1; // 电流 t1 = DWT->CYCCNT; // 读JDR2前打点 uint16_t vol_v = ADC1->JDR2; // 电压 t2 = DWT->CYCCNT; // 读JDR3前打点 uint16_t tmp_t = ADC1->JDR3; // 温度 uint32_t delta1 = t1 - t0; // 应≤3个周期(F103指令周期) uint32_t delta2 = t2 - t1; // 同上 // 若delta1/delta2 > 10,则说明读取被中断打断,需关全局中断再读 } }表格:注入通道与规则通道关键参数对比
特性 规则通道(Regular) 注入通道(Injected) 最大通道数 16 4 触发源 EXTSEL[2:0] JEXTSEL[2:0] 数据寄存器 ADC_DR ADC_JDR1~4 中断标志 EOC JEOC 同步能力 单路 4路硬件同步启动 典型用途 主信号流 故障保护、校准信号
我一般会在电机项目里,用规则通道采电流(主控),注入通道同步采母线电压和NTC温度——这样三者时间戳完全对齐,做PI参数自整定或过流保护时,不会因采样时差误判。希望帮到你。
本文还有配套的精品资源,点击获取