1. 从寄存器到闪灯:为什么我选TIMER5而不是SysTick
拿到GD32E230这块板子的时候,我第一个想跑通的不是串口,也不是ADC,而是最朴素的LED闪烁。原因很简单——一个1秒周期的闪灯,背后牵扯的是时钟树、定时器分频、中断向量表、NVIC优先级这一整条链路。这条链路跑通了,后面做PWM、输入捕获、编码器接口,心里就有底了。
很多人做LED闪烁习惯用SysTick,因为CubeMX或者例程里默认就给你配好了,改个重装载值就能用。但SysTick本质上是Cortex-M内核的滴答定时器,它的定位是给RTOS提供系统时基,或者做简单的延时。你拿它做产品级的周期性任务,会有一个很尴尬的问题:一旦上了RTOS,SysTick就被系统接管了,你的闪灯逻辑要么被挤掉,要么得挂到系统节拍上去,耦合度太高。而TIMER5是GD32E230片上的通用定时器,独立于内核,不跟任何系统组件抢资源,这才是正经做法。
GD32E230的TIMER5属于通用定时器系列,它是一个16位向上计数的定时器,带预分频器(PSC)、自动重装载寄存器(CAR),支持更新中断。注意这里有个细节:GD32的寄存器命名和STM32略有不同。STM32里叫ARR的,GD32里叫CAR(Counter Auto-Reload);STM32里叫CNT的,GD32里叫CNT没变;STM32里叫PSC的,GD32里也叫PSC。中断标志位方面,STM32的UIF在GD32里叫UPIF。这些命名差异在你看GD32的固件库头文件时一定要留意,不然对着STM32的教程改代码,编译能过但行为不对。
那为什么是TIMER5而不是TIMER0到TIMER4?这得看GD32E230的定时器分布。TIMER0到TIMER4通常是高级定时器或者通用定时器,带互补输出、死区控制、刹车输入这些功能,资源更丰富但也更复杂。TIMER5和TIMER6是基本定时器,功能精简,就是计数+中断,没有PWM输出通道。对于单纯做1秒周期性中断来说,基本定时器完全够用,而且配置起来少踩很多坑。你不需要去管通道使能、输出比较模式这些寄存器,只需要把时钟、分频、重装载、中断这四个环节配对就行。
还有一点,TIMER5挂在APB1总线上。GD32E230的APB1总线时钟默认是系统时钟的分频结果,具体分频系数取决于你的时钟树配置。如果你用的是内部8MHz IRC作为系统时钟,那APB1可能就跑在8MHz;如果你开了PLL倍频到72MHz,那APB1的时钟又不一样。这个时钟频率直接决定了你后面算PSC和CAR的数值,所以第一步一定是确认系统时钟和APB1的实际频率,不能凭感觉填。
我见过太多人在这上面翻车:代码里PSC填了7199,CAR填了9999,以为能出1秒,结果灯闪得跟频闪灯似的。一问才知道,他系统时钟跑的是8MHz,不是72MHz。所以下面我会先把时钟这条线捋清楚,再往下讲配置。
2. 1秒到底怎么算出来的:PSC与CAR的数学关系
定时器的溢出周期公式其实就一个:
T = (PSC + 1) × (CAR + 1) / Tclk
其中Tclk是定时器的工作时钟频率,也就是APB1总线给到TIMER5的时钟。PSC是预分频寄存器的值,CAR是自动重装载寄存器的值。注意这里PSC和CAR都要加1,因为硬件是从0开始计数的。PSC=0表示不分频,CAR=0表示计1个数就溢出。
假设你的系统时钟配置为72MHz,APB1的分频系数为2,那么APB1总线时钟是36MHz。但这里有个关键点:GD32(以及STM32)的定时器时钟有个倍频机制——当APB分频系数不为1时,定时器时钟是APB总线时钟的2倍。所以TIMER5的实际工作时钟是36MHz × 2 = 72MHz。这个倍频机制是硬件自动做的,你不需要手动配置,但算周期的时候必须把它算进去。
那如果APB1分频系数为1呢?定时器时钟就等于APB总线时钟,没有倍频。所以你在配置时钟树的时候,APB1的分频系数选择会直接影响定时器的输入频率。
现在回到1秒的计算。假设TIMER5的工作时钟是72MHz,我们要得到1秒的溢出周期:
T = (PSC + 1) × (CAR + 1) / 72000000 = 1
=> (PSC + 1) × (CAR + 1) = 72000000
这里有很多种组合。你可以PSC=7199,CAR=9999,这样(7199+1)×(9999+1)=7200×10000=72000000,正好1秒。也可以PSC=71999,CAR=999,结果一样。但不同的组合会影响中断的精度和灵活性。
我一般倾向于让PSC尽量大一些,CAR小一些。为什么?因为PSC是预分频器,它决定的是计数器的步进频率。PSC越大,计数器每加1代表的时间越长,CAR就可以设得越小。这样做的好处是,当你需要微调周期的时候,改CAR的数值更直观。比如你要从1秒改成0.5秒,CAR从9999改成4999就行,PSC不用动。反过来如果PSC很小、CAR很大,你改CAR的数值跨度就很大,而且16位定时器的CAR最大只能到65535,如果PSC=0,72MHz的时钟下最大周期只有65536/72000000≈0.91毫秒,根本到不了1秒。
所以PSC和CAR的分配要兼顾两点:一是CAR不能超过65535(16位上限),二是PSC也不能超过65535。对于72MHz时钟出1秒,PSC=7199、CAR=9999是一个很舒服的组合,两个值都在合理范围内,而且CAR=9999方便心算。
但如果你用的是8MHz内部IRC,没有开PLL,那TIMER5的时钟可能就是8MHz。这时候(PSC+1)×(CAR+1)=8000000。你可以PSC=7999,CAR=999,这样8000×1000=8000000,也是1秒。所以同样的1秒,时钟不同,PSC和CAR的取值完全不同。这就是为什么我一直强调先确认时钟。
还有一个容易忽略的点:GD32E230的TIMER5是16位定时器,CAR寄存器只有16位有效。如果你算出来的CAR超过65535,就必须增大PSC来把CAR压下来。比如72MHz时钟下你要出10秒周期,(PSC+1)×(CAR+1)=720000000,如果PSC=7199,那CAR+1=100000,超过65535了,不行。你得把PSC加大到71999,这样CAR+1=10000,CAR=9999,可以。所以PSC和CAR是互相制约的,不能随便填。
下面这张表列出了72MHz时钟下几种常见周期的PSC和CAR组合,方便你直接抄:
| 目标周期 | PSC值 | CAR值 | 实际周期 | 误差 |
|---|---|---|---|---|
| 1秒 | 7199 | 9999 | 1.0000秒 | 0 |
| 0.5秒 | 7199 | 4999 | 0.5000秒 | 0 |
| 2秒 | 7199 | 19999 | 2.0000秒 | 0 |
| 0.1秒 | 7199 | 999 | 0.1000秒 | 0 |
| 1秒 | 71999 | 999 | 1.0000秒 | 0 |
| 1秒 | 35999 | 1999 | 1.0000秒 | 0 |
注意最后两行,同样的1秒,PSC和CAR的组合不同,实际周期完全一样。选哪个看你的习惯。我一般用PSC=7199、CAR=9999,因为这两个数字好记,而且CAR=9999在调试的时候一眼就能看出是1秒。
3. 中断配置的完整链路:从NVIC到中断服务函数
定时器配置好之后,中断能不能正常触发,取决于三个环节:定时器自身的中断使能、NVIC的中断使能、中断服务函数的正确实现。这三个环节缺一不可,而且顺序不能乱。
先说定时器自身的中断使能。GD32的固件库里,使能TIMER5更新中断的函数是timer_interrupt_enable(TIMER5, TIMER_INT_UP)。这个函数做的事情是把TIMER5的DMA/中断使能寄存器(DMAINTEN)里的UPIE位置1。UPIE就是Update Interrupt Enable,更新中断使能。只有这一位置1了,定时器溢出的时候才会产生中断请求。
然后是NVIC的配置。GD32E230的TIMER5中断在中断向量表里对应的位置是TIMER5_DAC_IRQHandler。注意这个中断向量名字里带了DAC,是因为TIMER5和DAC共享一个中断向量。你在写中断服务函数的时候,函数名必须和启动文件里的向量名完全一致,否则中断触发后会跳到一个默认的死循环里。
NVIC的配置包括优先级分组、抢占优先级、子优先级、使能中断。GD32E230的NVIC支持4位优先级,可以配置成不同的分组方式。对于单纯的LED闪烁来说,优先级怎么分都行,因为系统里没有其他中断跟它抢。但如果你后面要加串口中断、ADC中断,就得考虑优先级安排了。我一般把定时器中断的抢占优先级设得低一些,让串口这种实时性要求高的中断优先响应。
配置NVIC的代码大概长这样:
nvic_irq_enable(TIMER5_DAC_IRQn, 2, 0);第一个参数是中断号,第二个是抢占优先级,第三个是子优先级。这里抢占优先级设为2,子优先级设为0。数值越小优先级越高。GD32E230的优先级分组默认是NVIC_PRIGROUP_PRE4_SUB0,也就是4位全给抢占优先级,没有子优先级。所以第三个参数填0就行。
接下来是中断服务函数的实现。函数名必须是TIMER5_DAC_IRQHandler,里面要做两件事:第一,检查更新中断标志位是否置起;第二,清除中断标志位;第三,执行你的业务逻辑(翻转LED)。
void TIMER5_DAC_IRQHandler(void) { if(timer_interrupt_flag_get(TIMER5, TIMER_INT_FLAG_UP) == SET) { timer_interrupt_flag_clear(TIMER5, TIMER_INT_FLAG_UP); gpio_bit_toggle(GPIOA, GPIO_PIN_1); } }这里有个坑:清除中断标志位必须在执行耗时操作之前做,或者至少要在中断服务函数返回之前做。如果你忘了清除标志位,中断会反复触发,程序就卡在中断里出不来了。GD32的固件库里,timer_interrupt_flag_clear函数写的是1到清除位,硬件会自动清零,不需要读-改-写。
还有一个细节:GD32的固件库提供了两个清除中断标志的函数,一个是timer_interrupt_flag_clear,另一个是timer_flag_clear。前者是清除中断标志,后者是清除状态标志。对于更新中断来说,用timer_interrupt_flag_clear就对了。如果你用错了函数,标志位清不掉,中断会一直触发。
另外,中断服务函数里不要做浮点运算、不要调用printf、不要做延时。这些操作要么耗时太长,要么会引入不可重入的问题。翻转LED就是一个GPIO寄存器的写操作,几个时钟周期就完成了,放在中断里完全没问题。
4. 时钟树配置:被大多数人跳过但最关键的一步
前面反复提到时钟频率决定了PSC和CAR的取值,但很多人拿到例程直接改PSC和CAR,根本不看时钟树。结果就是代码看起来没问题,实际闪灯周期完全不对。这一节我把GD32E230的时钟树配置单独拎出来讲清楚。
GD32E230的时钟源有三个:内部8MHz IRC、内部48MHz IRC48M、外部晶振HXTAL。复位之后默认用的是内部8MHz IRC,系统时钟就是8MHz。如果你不配置PLL,那APB1的时钟就是8MHz,TIMER5的时钟也是8MHz。这时候你要出1秒周期,PSC和CAR的取值跟72MHz时完全不同。
如果你要用72MHz系统时钟,就需要配置PLL。GD32E230的PLL时钟源可以选内部8MHz IRC或者外部晶振。以内部8MHz IRC为例,PLL的配置流程是:使能IRC8M、等待IRC8M稳定、配置PLL的预分频和倍频系数、使能PLL、等待PLL锁定、切换系统时钟源到PLL。
GD32E230的PLL倍频系数可以配到18倍,8MHz × 18 = 144MHz,但系统时钟最大只能到72MHz,所以PLL输出后还要经过AHB分频。通常的做法是PLL输出72MHz,AHB不分频,APB1分频系数设为2,这样APB1总线时钟是36MHz,定时器时钟经过倍频后是72MHz。
这里有一个很容易搞混的地方:APB1的分频系数和定时器时钟的倍频关系。当APB1分频系数为1时,定时器时钟等于APB1总线时钟;当APB1分频系数大于1时,定时器时钟等于APB1总线时钟的2倍。所以如果你APB1分频系数设为2,APB1总线是36MHz,定时器时钟是72MHz。如果你APB1分频系数设为1,APB1总线是72MHz,定时器时钟也是72MHz。两种配置下定时器时钟都是72MHz,但APB1总线上的其他外设(比如串口)时钟不同。
我一般把APB1分频系数设为2,这样APB1总线跑36MHz,串口、I2C这些外设的时钟不会太高,功耗和稳定性都好一些。定时器时钟仍然是72MHz,不影响定时精度。
配置时钟树的代码在GD32的固件库里有一套标准流程,我把它整理成下面这个顺序:
- 使能IRC8M,等待稳定
- 配置PLL预分频为1,倍频为18(8MHz × 18 = 144MHz,再经过AHB分频到72MHz)
- 使能PLL,等待锁定
- 配置AHB分频系数为1,APB1分频系数为2
- 切换系统时钟源到PLL
- 更新SystemCoreClock变量
这套流程跑完之后,SystemCoreClock的值应该是72000000。你可以在主函数开头加一句SystemCoreClockUpdate(),然后通过调试器看SystemCoreClock的值确认时钟配置是否正确。
如果你不想折腾PLL,直接用内部8MHz IRC也行,那TIMER5的时钟就是8MHz,PSC=7999、CAR=999就能出1秒。但8MHz的IRC精度不如外部晶振,长时间运行会有累积误差。对于闪灯这种应用无所谓,但如果你后面要做串口通信、定时采样,建议还是上外部晶振加PLL。
5. 代码落地:从零搭建TIMER5中断闪灯工程
前面把原理和时钟都讲清楚了,这一节直接上完整代码。我以GD32E230的官方固件库为基础,从main函数开始,把整个流程串一遍。
首先是头文件和全局变量:
#include "gd32e230.h" #include "gd32e230_gpio.h" #include "gd32e230_timer.h" #include "gd32e230_rcu.h" #include "gd32e230_misc.h" static void led_gpio_config(void); static void timer5_config(void); static void nvic_config(void);LED接在PA1上,GPIO配置为推挽输出模式,速度设为50MHz:
static void led_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOA); gpio_mode_set(GPIOA, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, GPIO_PIN_1); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1); gpio_bit_reset(GPIOA, GPIO_PIN_1); }TIMER5的配置是核心,分四步:使能时钟、设置预分频和重装载值、使能更新中断、使能定时器:
static void timer5_config(void) { timer_parameter_struct timer_initpara; rcu_periph_clock_enable(RCU_TIMER5); timer_deinit(TIMER5); timer_initpara.prescaler = 7199; timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_initpara.period = 9999; timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_initpara.repetitioncounter = 0; timer_init(TIMER5, &timer_initpara); timer_interrupt_enable(TIMER5, TIMER_INT_UP); timer_enable(TIMER5); }这里timer_initpara.period对应的就是CAR寄存器的值。timer_initpara.prescaler对应PSC。clockdivision是时钟分频,用于数字滤波器,跟定时周期无关,填TIMER_CKDIV_DIV1就行。repetitioncounter是重复计数器,基本定时器用不到,填0。
NVIC配置:
static void nvic_config(void) { nvic_irq_enable(TIMER5_DAC_IRQn, 2, 0); }中断服务函数:
void TIMER5_DAC_IRQHandler(void) { if(timer_interrupt_flag_get(TIMER5, TIMER_INT_FLAG_UP) == SET) { timer_interrupt_flag_clear(TIMER5, TIMER_INT_FLAG_UP); gpio_bit_toggle(GPIOA, GPIO_PIN_1); } }主函数:
int main(void) { SystemCoreClockUpdate(); led_gpio_config(); nvic_config(); timer5_config(); while(1) { } }整个工程跑起来之后,PA1上的LED会以1秒为周期闪烁,亮1秒、灭1秒。如果你用示波器或者逻辑分析仪抓PA1的波形,应该看到周期2秒、占空比50%的方波。为什么是2秒?因为中断里是翻转操作,每次中断翻转一次,两次中断才构成一个完整的亮灭周期。所以中断周期是1秒,LED闪烁周期是2秒。这一点在调试的时候要心里有数,别以为代码写错了。
6. 实测中遇到的三个坑和排查过程
代码写完之后,我第一次烧录进去,LED没亮。不是闪得不对,是根本不亮。下面是我排查的完整过程,这三个坑我觉得很有代表性。
第一个坑:GPIO时钟没使能。我检查了代码,rcu_periph_clock_enable(RCU_GPIOA)确实写了。但我在调试的时候发现,GPIOA的时钟使能位在RCU_APB2EN寄存器里,而TIMER5的时钟使能位在RCU_APB1EN寄存器里。这两个时钟使能函数虽然名字一样,但操作的寄存器不同。我一开始把rcu_periph_clock_enable(RCU_TIMER5)写成了rcu_periph_clock_enable(RCU_TIMER0),结果TIMER5根本没时钟,当然不工作。改过来之后,LED开始闪了。
第二个坑:中断服务函数名写错。我一开始写的是TIMER5_IRQHandler,编译能过,因为链接器不会检查中断向量名。但运行的时候,TIMER5中断触发后跳到了启动文件里默认的TIMER5_DAC_IRQHandler,而那个默认函数是个死循环。所以程序一上电就卡在中断里,主循环根本没执行。后来我把函数名改成TIMER5_DAC_IRQHandler,问题解决。这个坑的隐蔽性在于,编译和链接都不会报错,只有运行的时候才会暴露。
第三个坑:中断标志位清除时机。我一开始把timer_interrupt_flag_clear放在了gpio_bit_toggle后面,逻辑上没问题,但实际跑的时候发现LED闪烁频率变成了两倍。原因是gpio_bit_toggle执行的时间比较长(相对于中断响应时间),在这段时间里定时器可能又溢出了一次,更新标志位再次置起。等我清除标志位的时候,清掉的是第二次的标志,第一次的标志被覆盖了。结果就是中断触发频率变成了实际溢出频率的两倍。解决办法是把清除标志位放在业务逻辑之前,先清标志再翻转LED。这样即使翻转过程中又溢出了,下一次中断也能正常响应。
这三个坑排查完之后,我总结了一个定时器中断的检查清单,每次配置新定时器的时候对着过一遍:
| 检查项 | 常见错误 | 验证方法 |
|---|---|---|
| 定时器时钟使能 | 使能了错误的定时器 | 查RCU_APB1EN寄存器 |
| GPIO时钟使能 | 忘记使能或使能错端口 | 查RCU_APB2EN寄存器 |
| PSC/CAR计算 | 时钟频率搞错 | 用示波器测实际周期 |
| 中断使能 | 忘记调用timer_interrupt_enable | 查DMAINTEN寄存器 |
| NVIC使能 | 中断号写错 | 查启动文件向量表 |
| 中断函数名 | 与向量表不一致 | 对比启动文件 |
| 标志位清除 | 清除时机太晚 | 示波器看中断频率 |
7. 从闪灯延伸到实际项目:定时器中断的更多用法
1秒闪灯跑通之后,TIMER5的中断框架就可以直接复用到其他周期性任务上。比如你有一个传感器需要每100毫秒采样一次,只需要把CAR从9999改成999,中断里调用采样函数就行。如果你有多个不同周期的任务,可以用一个定时器做时基,在中断里维护软件计数器,比如每10次中断触发一次100毫秒任务,每100次中断触发一次1秒任务。这种软件分频的方式在资源紧张的MCU上很常见。
但要注意,中断服务函数里做的事情越多,中断响应时间就越长,系统的实时性就越差。我一般遵循一个原则:中断里只做标志位置位和最简单的硬件操作,耗时的处理放到主循环里根据标志位执行。比如:
volatile uint8_t flag_100ms = 0; volatile uint8_t flag_1s = 0; static uint16_t counter_100ms = 0; static uint16_t counter_1s = 0; void TIMER5_DAC_IRQHandler(void) { if(timer_interrupt_flag_get(TIMER5, TIMER_INT_FLAG_UP) == SET) { timer_interrupt_flag_clear(TIMER5, TIMER_INT_FLAG_UP); counter_100ms++; if(counter_100ms >= 10) { counter_100ms = 0; flag_100ms = 1; } counter_1s++; if(counter_1s >= 100) { counter_1s = 0; flag_1s = 1; } } }主循环里检查标志位:
while(1) { if(flag_100ms) { flag_100ms = 0; // 执行100ms任务 } if(flag_1s) { flag_1s = 0; // 执行1s任务 } }这种架构的好处是中断服务函数极短,不会阻塞其他中断。缺点是任务的实际执行时间会有抖动,因为主循环里可能有其他耗时操作。对于闪灯、采样这种对时间精度要求不高的场景完全够用。如果你需要精确的时序控制,那就得用硬件定时器的PWM输出或者输入捕获功能,而不是靠中断里翻转GPIO。
另外,GD32E230的TIMER5还可以配置成单脉冲模式、编码器模式等,但这些模式基本定时器支持有限,高级功能得用TIMER0到TIMER4。如果你后面要做电机控制、编码器测速,建议直接上TIMER0,它的通道多、功能全,虽然配置复杂一些,但能省掉很多外扩电路的麻烦。
最后说一个实际项目中容易忽略的点:定时器中断的优先级和RTOS的配合。如果你在裸机环境下跑,优先级随便设。但如果你上了FreeRTOS或者RT-Thread,定时器中断的优先级必须低于RTOS的系统节拍中断优先级,否则RTOS的调度器会被打断,导致系统不稳定。具体来说,RTOS的系统节拍中断优先级通常设为最低(数值最大),你的定时器中断优先级要设得比它高(数值更小),但也不能太高,否则会阻塞其他外设中断。这个优先级安排需要在项目初期就规划好,后期改起来很麻烦。
我在实际使用中的体会是,GD32E230的TIMER5虽然功能简单,但胜在稳定、配置直观。对于周期性中断这种需求,它比SysTick更可控,比高级定时器更轻量。把它的时钟、分频、中断这条链路吃透之后,换到其他定时器上也是同样的思路,只是寄存器名字和功能多一些而已。