简介:本资源是一套面向嵌入式初学者与APM32E1系列单片机开发者的定时器驱动实践工程,聚焦基本定时器的完整配置与中断应用,解决定时精度控制、周期触发、LED闪烁等典型实时控制需求。压缩包共78个文件,含39个头文件(.h)定义外设寄存器与API接口,33个源文件(.c)实现标准外设驱动、中断服务例程及主控逻辑,另有启动文件(.s)、Keil工程配置(.uvprojx/.uvoptx)、固件镜像(.hex)和说明文档(.txt),总大小264KB,结构清晰对应CMSIS、StdPeriphDriver、BSP及User层级。已有188人学习下载,工程基于APM32E103芯片构建,兼容整个APM32E1系列,提供可直接编译运行的MDK-ARM项目,包含预配置的1秒定时中断示例、完整中断向量表映射、时钟使能与预分频参数计算逻辑,以及apm32e10x_int.h/c等关键底层支持,助开发者快速掌握定时器初始化、中断使能与回调处理全流程。
1. 项目缘起:从“点灯”到“定时”的必经之路
刚接触APM32这类ARM Cortex-M内核单片机的新手,往往都是从GPIO点灯开始的。当你能熟练控制一个LED闪烁后,下一个跃跃欲试的功能,十有八九就是定时器。为什么?因为“延时”是嵌入式世界里最基础、最频繁的需求之一。无论是让LED以精确的1Hz频率闪烁,还是每隔固定时间采集一次传感器数据,或者为通信协议提供时间基准,都离不开定时器。
我手头正好有一块APM32E103的开发板,这是极海半导体基于Cortex-M3内核推出的一款高性价比MCU,其外设和编程模型与大家熟悉的STM32F103系列高度兼容。网上关于STM32定时器的教程浩如烟海,但直接套用到APM32上,有时会遇到一些细微的差异,比如寄存器别名、库函数命名或者时钟树的配置细节。这次,我就以APM32E103为蓝本,把基本定时器(TIM6/TIM7)从原理到代码,彻底捋清楚。这个过程不仅适用于E103,对于整个APM32E1系列(包括E103、E107等)都具有参考价值,因为它们的定时器模块是相同的。
很多人会觉得基本定时器功能简单,不就是做个延时嘛,用SysTick(滴答定时器)不也一样?这里有个关键区别:SysTick是内核级别的,主要服务于操作系统的心跳;而基本定时器是外设,它更纯粹,不依赖内核状态,可以独立产生中断或触发DMA,为其他外设(如ADC、DAC)提供精准的触发信号。理解并掌握基本定时器,是你驾驭更高级的通用定时器、高级定时器,进而实现PWM输出、输入捕获等复杂功能的基础。
2. 核心原理:定时器是如何“数数”的?
要玩转定时器,首先得明白它到底在干什么。你可以把一个基本定时器想象成一个向上计数的“沙漏”。这个沙漏的运作,依赖于三个最核心的要素:时钟源、预分频器和自动重装载寄存器。
时钟源是沙漏流动的动力。对于APM32E103的基本定时器(TIM6/TIM7),它的时钟来自于APB1总线。这里有一个关键点需要理解:在APM32的时钟树里,如果APB1的预分频系数不为1(比如是2、4、8等),那么定时器实际得到的时钟频率会是APB1时钟的2倍。这是硬件设计上的一个倍频机制,目的是在总线时钟较低时,仍能为定时器提供较高的计时精度。假设你的系统主频是72MHz,APB1总线时钟配置为36MHz(即2分频),那么TIM6/TIM7的时钟源实际是72MHz,而不是36MHz。这一点在计算定时时间时至关重要,也是很多新手容易忽略导致定时不准的坑。
预分频器决定了沙漏里“沙子”颗粒的大小。它是一个16位的寄存器(TIMx_PSC),可以对输入的时钟源进行分频。公式是:定时器时钟 = 时钟源频率 / (PSC + 1)。如果PSC设置为0,则不分频;设置为71,则72MHz的时钟会被分频为1MHz。它的作用是将高速的系统时钟“降速”到一个合适的计数频率,以适应我们需要的定时周期。比如,我们需要1us的计数步进,那么就可以用72MHz / 72 = 1MHz来实现。
自动重装载寄存器决定了沙漏的容量。它也是一个16位的寄存器(TIMx_ARR)。计数器从0开始向上计数,每来一个定时器时钟脉冲就加1,当计数器的值等于ARR中设定的值时,就会产生一个“更新事件”,同时计数器自动清零,重新开始计数。这个“从0到ARR”的过程,就是一个完整的定时周期。
那么,一个定时周期的时长如何计算呢?综合上面两个因素,公式如下:定时时间 = (ARR + 1) * (PSC + 1) / 时钟源频率
举个例子,我们要实现一个500ms的定时中断:
- 时钟源频率 = 72 MHz
- 设定预分频器 PSC = 7199
- 此时,定时器时钟 = 72MHz / (7199 + 1) = 10 kHz,即每计数一次耗时0.1 ms。
- 设定自动重装载值 ARR = 4999
- 定时时间 = (4999 + 1) * 0.1 ms = 500 ms。
这样,每隔500ms,定时器就会产生一次更新中断,我们只需要在中断服务函数里处理自己的逻辑(比如翻转LED)即可。基本定时器的工作模式就这么纯粹:计数、溢出、中断/事件,周而复始。
3. 环境搭建与工程准备
理论清楚了,接下来就是动手。我使用的是Keil MDK作为开发环境,极海官方提供了对应的设备支持包和标准外设库。首先,你需要去极海半导体官网下载APM32E1系列的SDK包,里面包含了库文件、启动文件和丰富的例程。
创建一个新工程时,关键步骤是正确选择芯片型号为“APM32E103xx”,并添加必要的文件组:
- CMSIS组:包含核心相关的头文件和启动文件(
startup_apm32e10x.s)。这个启动文件决定了芯片上电后的初始化流程,包括中断向量表,非常重要。 - APM32E10x_StdPeriphDriver组:添加标准外设库的源文件,我们至少需要
apm32e10x_tim.c(定时器)和apm32e10x_rcc.c(时钟控制)。 - User组:放置我们的主程序文件
main.c和中断服务函数相关的文件。
工程创建好后,先别急着写定时器代码,第一件要做的事是检查并配置系统时钟。这是所有外设定时准确的基础。在main函数的最开始,我们通常会调用一个SystemInit()函数,这个函数在启动文件中已被调用,它会根据芯片内部的HSI(内部高速时钟)初始化系统。但对于高性能应用,我们通常使用外部晶振HSE。
void RCC_Configuration(void) { RCC_DeInit(); // 复位RCC配置 // 使能外部高速晶振HSE RCC_EnableHSE(RCC_HSE_OPEN); while(RCC_ReadStatusFlag(RCC_FLAG_HSERDY) == RESET); // 等待HSE就绪 // 设置AHB、APB1、APB2预分频,这里APB1设为2分频 RCC_ConfigAPB1Prescaler(RCC_APB1_DIV_2); // APB1时钟 = 72MHz / 2 = 36MHz RCC_ConfigAPB2Prescaler(RCC_APB2_DIV_1); // APB2时钟 = 72MHz RCC_ConfigAHBPrescaler(RCC_AHB_DIV_1); // AHB时钟 = 72MHz // 设置PLL,将HSE*9倍频到72MHz RCC_ConfigPLL(RCC_PLLSEL_HSE, RCC_PLLMF_9); RCC_EnablePLL(); while(RCC_ReadStatusFlag(RCC_FLAG_PLLRDY) == RESET); // 等待PLL就绪 // 选择PLL作为系统时钟源 RCC_ConfigSYSCLK(RCC_SYSCLK_SEL_PLL); while(RCC_ReadSYSCLKSource() != RCC_SYSCLK_SEL_PLL); // 切换确认 }这段代码将系统时钟配置为72MHz。特别注意RCC_ConfigAPB1Prescaler(RCC_APB1_DIV_2)这一行,它使得APB1时钟为36MHz。根据之前讲的倍频规则,连接在APB1上的基本定时器(TIM6/TIM7)实际时钟是72MHz。如果你配置成其他分频系数,一定要重新计算定时器的实际输入时钟。
4. TIM6基本定时器配置详解
时钟配好了,现在我们来啃最核心的骨头:配置TIM6。整个过程可以分解为几个清晰的步骤,我习惯用“初始化结构体”的方式来配置,这样逻辑清晰,易于维护。
4.1 开启外设时钟
任何外设在使用前,必须先开启其对应的时钟。这是STM32/APM32这类芯片的低功耗设计特性,默认外设时钟是关闭的以省电。
RCC_EnableAPB1PeriphClock(RCC_APB1_PERIPH_TIM6);4.2 配置定时器基础参数
我们需要填充一个TIM_BaseConfig_T类型的结构体。这个结构体包含了控制定时器行为的核心参数。
TIM_BaseConfig_T timBaseConfig; // 1. 设置预分频器值 (PSC) // 目标:将72MHz分频为10kHz。计算:PSC = 72MHz / 10kHz - 1 = 7199 timBaseConfig.period = 7199; // 注意:库函数中这个成员名是period,但实际对应PSC寄存器 // 2. 设置自动重装载值 (ARR) // 目标:产生500ms中断。计算:ARR = 定时时间 / 计数周期 - 1 = 500ms / 0.1ms - 1 = 4999 timBaseConfig.countMode = TIM_COUNTER_MODE_UP; // 向上计数模式 timBaseConfig.division = TIM_CLOCK_DIV1; // 时钟分频因子,用于数字滤波,基本定时器通常为1 timBaseConfig.clockDivision = TIM_CLOCK_DIV1; // 重复一下,确保配置 // 注意:在APM32标准库中,ARR值通常通过 TIM_ConfigAutoreload() 函数单独设置,见下一步。 // 3. 应用基础配置 TIM_ConfigTimeBase(TIM6, &timBaseConfig); // 4. 单独设置自动重装载值ARR TIM_ConfigAutoreload(TIM6, 4999);这里有个细节需要注意:在APM32的标准库中,TIM_BaseConfig_T结构体里的period成员,名字容易让人误解为ARR(周期),但它实际上对应的是预分频器PSC的值。而ARR的值需要通过单独的TIM_ConfigAutoreload函数来设置。这一点和STM32标准库的设计略有不同,务必查看你所用库版本的头文件确认。
4.3 使能更新中断并设置优先级
定时器配置好后,我们需要它产生中断,以便在中断服务函数中执行我们的代码。
// 使能TIM6的更新中断 TIM_EnableInterrupt(TIM6, TIM_INT_UPDATE); // 配置NVIC(嵌套向量中断控制器) NVIC_Config_T nvicConfig; nvicConfig.irqChannel = TIM6_IRQn; // 中断通道 nvicConfig.irqChannelPreemptionPriority = 1; // 抢占优先级 nvicConfig.irqChannelSubPriority = 0; // 子优先级 nvicConfig.irqChannelCmd = ENABLE; // 使能该中断 NVIC_Config(&nvicConfig);中断优先级的设置需要根据你系统中其他中断的重要性来统筹考虑。对于简单的定时闪烁,优先级可以设低一些。
4.4 编写中断服务函数
中断服务函数的名字是固定的,在启动文件startup_apm32e10x.s的中断向量表里已经定义好了。我们需要在C代码中实现它。
void TIM6_IRQHandler(void) { // 1. 检查中断源是否为“更新中断” if (TIM_ReadIntFlag(TIM6, TIM_INT_UPDATE) != RESET) { // 2. 清除中断标志位!!!(非常重要,否则会连续进入中断) TIM_ClearIntFlag(TIM6, TIM_INT_UPDATE); // 3. 在这里编写你的中断处理代码 // 例如,翻转LED0的电平 GPIO_ToggleBit(GPIOB, GPIO_PIN_0); // 假设LED0接在PB0 } }这里有一个超级重要的坑:一定要先判断中断标志,再清除它。而且清除操作必须做,否则硬件会认为中断一直未处理,导致程序不断跳转到中断函数,主程序就卡死了。
4.5 启动定时器
最后,万事俱备,只欠东风。
TIM_Enable(TIM6); // 使能TIM6计数器开始工作调用TIM_Enable后,计数器就开始从0向上累加,当计数值达到ARR(4999)时,就会触发更新中断,进入我们写好的TIM6_IRQHandler函数。
5. 实测调试与常见问题排查
代码写完,编译下载到板子,LED可能并没有如预期般闪烁。别急,这是嵌入式开发的常态。我们可以按照以下链路进行排查:
5.1 时钟树确认
这是最源头的问题。首先确认系统时钟是否真的跑在了72MHz。可以在main函数初始化后,通过读取RCC->CFGR寄存器的相关位来验证,或者简单点,用SysTick做一个1ms的延时,然后控制一个GPIO翻转,用逻辑分析仪或示波器测量其周期是否为2ms。如果系统时钟不对,后面的所有定时计算都是空中楼阁。
5.2 定时器时钟源与分频计算
确认APB1的分频系数。如前所述,如果APB1是2分频(36MHz),定时器时钟是72MHz。如果你的计算是基于36MHz的,那么实际定时时间就会差一倍。这就是为什么网上有些代码在STM32上正常,搬到APM32上就变快或变慢的原因之一。务必使用SystemCoreClock(系统核心时钟频率)这个全局变量或你自己定义的宏来进行计算,而不是硬编码的数字。
5.3 中断是否成功触发
- 检查中断服务函数名是否拼写正确:必须与启动文件中定义的完全一致,例如
TIM6_IRQHandler。 - 检查NVIC配置是否使能:单步调试,查看
NVIC->ISER寄存器中对应TIM6的中断使能位是否被置1。 - 在中断函数入口加断点或翻转一个测试IO:这是最直接的调试方法。在
TIM6_IRQHandler函数的第一行设置一个断点,如果程序能停在这里,说明中断触发了。或者在中断里翻转一个未使用的GPIO引脚,用示波器看是否有方波产生。
5.4 更新事件与中断标志
确保你使能的是“更新中断”(TIM_INT_UPDATE),而不是其他事件。清除标志位TIM_ClearIntFlag的操作必须要有。
5.5 硬件连接检查
最后,检查你的LED硬件电路是否正确。LED是否接在了你代码控制的GPIO上?限流电阻是否合适?用万用表量一下GPIO在非中断时的电平,手动写代码控制GPIO翻转看LED是否正常,先排除硬件问题。
6. 进阶应用:定时器触发ADC采样
基本定时器除了产生中断,另一个强大的功能是它可以作为触发源,去触发其他外设,比如ADC(模数转换器)。这对于需要周期性、精准采样的应用(如音频处理、电源监控)非常有用。这样就不需要CPU频繁进入中断来启动ADC,提高了效率,也保证了采样间隔的绝对均匀。
配置思路如下:
- 配置TIM6为所需频率的更新事件:例如,我们需要1kHz的采样率,即每隔1ms触发一次ADC。假设时钟72MHz,设置PSC=7199(得10kHz),ARR=9((9+1)*0.1ms=1ms)。注意,此时要禁用更新中断,因为我们不需要CPU介入。
- 配置TIM6的触发输出:将TIM6的更新事件映射到它的触发输出(TRGO)上。
TIM_ConfigTriggerOutput(TIM6, TIM_TRGO_SOURCE_UPDATE); - 配置ADC为外部触发模式:将ADC的转换启动信号,设置为由TIM6的TRGO触发。
ADC_ConfigExternalTrigConv(ADC1, ADC_EXT_TRIG_CONV_T6_TRGO); ADC_EnableExternalTrigConv(ADC1, ENABLE); - 启动TIM6和ADC:启动TIM6计数器,ADC就会严格按照1ms的周期自动开始转换。转换完成后,可以产生DMA请求或ADC中断,将数据搬走或处理。
这种方式实现了硬件级的精准定时触发,将CPU解放出来,是嵌入式系统设计中提升性能和可靠性的重要手段。
7. 与SysTick滴答定时器的对比与选型
文章开头提到了SysTick,这里详细对比一下,方便你在实际项目中做出选择。
| 特性 | 基本定时器 (TIM6/TIM7) | SysTick (滴答定时器) |
|---|---|---|
| 归属 | 外设,位于APB1总线 | Cortex-M内核组件 |
| 数量 | 2个 (TIM6, TIM7) | 1个 |
| 中断优先级 | 可配置(通过NVIC) | 固定(通常为最低) |
| 主要用途 | 通用定时、触发其他外设(ADC/DAC/DMA) | 为操作系统(如FreeRTOS)提供时钟节拍 |
| 是否依赖内核 | 否,独立运行 | 是,内核时钟使能它才工作 |
| 触发能力 | 强,可输出TRGO信号 | 无 |
| 使用复杂度 | 需配置时钟、分频、中断等 | 配置简单,通常只需设置重装载值 |
选型建议:
- 如果你的应用没有操作系统,且只需要简单的毫秒/微秒级延时,两者皆可。SysTick配置更简单,不占用外设资源。
- 如果你的应用使用了操作系统(如FreeRTOS),SysTick必须留给操作系统作为心跳。所有应用层的定时需求都应使用外设定时器。
- 如果你的定时需求需要触发其他外设(如定时启动ADC采样),必须使用基本定时器或通用定时器。
- 如果需要多个不同周期的定时,或者需要更复杂的PWM、输入捕获功能,那就需要用到通用定时器(TIM1-TIM5, TIM8)。
折腾完APM32E103的基本定时器,再回头看那些热词,像“51单片机定时器计数器工作原理”、“stm32定时器捕获测频率”、“hal库定时器触发adc”,你会发现核心思想都是相通的:时钟源、分频、计数、溢出/匹配。不同的芯片和库,只是寄存器的名字和配置函数换了个写法。掌握了这个“道”,再去学习具体的“术”,就会事半功倍。下次,我们可以聊聊如何用APM32的通用定时器实现PWM输出,驱动舵机或者控制电机,那又是另一个精彩的世界了。
本文还有配套的精品资源,点击获取