4. 定时器:系统滴答定时器SysTick、通用定时器TIM、PWM输出、输入捕获、定时器中断

定时器这东西,在嵌入式开发里就像呼吸一样自然。你几乎找不到一个项目不用它的。做点灯延时要用,做通信协议要用,做电机控制更要用。CH32系列基于RISC-V内核,它的定时器模块设计得挺灵活,但坑也不少。这一章,我把SysTick、通用定时器、PWM、输入捕获和中断串起来讲,都是实战里高频使用的功能。

本章核心脉络: 从最基础的SysTick系统时钟出发,掌握通用定时器的配置方法,再延伸到PWM输出和输入捕获这两个典型应用场景,最后用中断机制把定时器事件和CPU处理串联起来。
CH32 定时器知识体系 CH32 定时器分类 SysTick 系统滴答 通用定时器 TIM 高级定时器 系统时钟基准 延时 / 调度 PWM 输出 输入捕获 定时器中断 互补PWM / 刹车 编码器接口 本章重点:SysTick + 通用定时器 + PWM + 输入捕获 + 中断

4.1 SysTick:系统的心跳

SysTick,说白了就是内核自带的一个24位递减计数器。它挂在系统时钟上,每来一个时钟脉冲就减1,减到0就触发中断,然后自动重装。我习惯拿它做操作系统的心跳,或者简单的延时函数。

在CH32上配置SysTick很简单。核心寄存器就三个:

寄存器 功能 关键位
CSR (0xE000E010) 控制与状态 ENABLE(0)、TICKINT(1)、COUNTFLAG(16)
RVR (0xE000E014) 重装载值 24位有效,写入后自动加载
CVR (0xE000E018) 当前计数值 读返回当前值,写清零

举个例子,系统时钟144MHz,想要1ms中断一次:

// SysTick 1ms 中断配置
SysTick->RVR = 144000 - 1;   // 144MHz / 1000 = 144000
SysTick->CVR = 0;            // 清空计数器
SysTick->CSR = 0x07;         // 使能 + 中断使能 + 使用系统时钟
我的习惯: 我一般把SysTick的优先级设成最高,因为它是系统时基。如果被其他中断堵住,整个系统的延时和调度都会乱掉。

4.2 通用定时器TIM:多才多艺的计时员

CH32的通用定时器比SysTick灵活得多。它有16位自动重装载计数器,支持向上、向下、中央对齐三种计数模式。每个定时器还有4个独立通道,可以配置成PWM输出、输入捕获或者比较输出。

我个人觉得,通用定时器最常用的场景就是产生周期性的中断。比如控制一个LED以500ms的频率闪烁:

// 使用TIM2,1秒中断一次
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

TIM_TimeBaseInitTypeDef tim;
tim.TIM_Prescaler = 144 - 1;      // 144MHz / 144 = 1MHz
tim.TIM_CounterMode = TIM_CounterMode_Up;
tim.TIM_Period = 1000000 - 1;     // 1MHz / 1000000 = 1Hz
tim.TIM_ClockDivision = 0;
TIM_TimeBaseInit(TIM2, &tim);

TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
TIM_Cmd(TIM2, ENABLE);

这里有个细节要注意:预分频器是计数器+1的关系。你写144-1,实际分频系数是144。我刚开始做的时候经常在这里算错,导致定时时间翻倍或者减半。

4.3 PWM输出:让LED呼吸起来

PWM,脉冲宽度调制。说白了就是通过调节占空比来控制输出功率。在CH32上,通用定时器的每个通道都可以独立输出PWM。

配置PWM的步骤其实就四步:

  1. 设置定时器的时基(频率)
  2. 配置通道为PWM模式
  3. 设置占空比(比较值)
  4. 使能输出和定时器

看个呼吸灯的例子,频率1kHz,占空比从0到100%渐变:

// TIM2_CH1 PWM输出,PA0引脚
TIM_OCInitTypeDef oc;
oc.TIM_OCMode = TIM_OCMode_PWM1;
oc.TIM_OutputState = TIM_OutputState_Enable;
oc.TIM_Pulse = 0;                // 初始占空比0
oc.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM2, &oc);

// 主循环中渐变
for(uint16_t duty = 0; duty < 1000; duty++) {
    TIM_SetCompare1(TIM2, duty);
    Delay_Ms(2);
}
我曾经踩过的坑: 在CH32上,如果定时器用作PWM输出,一定要检查GPIO的复用功能配置。PA0要配置成AF_PP(复用推挽输出),否则PWM信号出不来。我那次排查了半小时,最后发现是GPIO模式没改。

4.4 输入捕获:测量外部信号的脉搏

输入捕获,就是测量外部信号的频率或脉宽。原理很简单:当检测到指定的边沿(上升沿或下降沿)时,定时器会把当前的计数值锁存到捕获寄存器里。两次捕获的差值,就是信号的周期或脉宽。

配置输入捕获的要点:

  • 选择捕获通道和对应的GPIO
  • 设置触发边沿(上升沿、下降沿或双边沿)
  • 使能捕获中断(如果需要)
  • 读取捕获值并计算

测量一个PWM信号的频率,代码骨架:

// TIM2_CH1 输入捕获配置
TIM_ICInitTypeDef ic;
ic.TIM_Channel = TIM_Channel_1;
ic.TIM_ICPolarity = TIM_ICPolarity_Rising;
ic.TIM_ICSelection = TIM_ICSelection_DirectTI;
ic.TIM_ICPrescaler = TIM_ICPSC_DIV1;
ic.TIM_ICFilter = 0;
TIM_ICInit(TIM2, &ic);

// 中断中读取捕获值
void TIM2_IRQHandler(void) {
    if(TIM_GetITStatus(TIM2, TIM_IT_CC1)) {
        uint16_t cap = TIM_GetCapture1(TIM2);
        // 计算频率:定时器时钟 / (两次捕获差值)
        TIM_ClearITPendingBit(TIM2, TIM_IT_CC1);
    }
}
避坑指南: 输入捕获的滤波器参数要根据信号质量来调。如果信号有毛刺,把ICFilter设到4或8,能滤掉窄脉冲干扰。我做过一个项目,电机编码器信号抖动厉害,加了滤波后数据就稳了。

4.5 定时器中断:让CPU从轮询中解放

定时器中断,是定时器最核心的能力。没有中断,CPU就得一直查标志位,啥也干不了。有了中断,定时器自己跑,到了时间通知CPU,CPU处理完继续干别的活。

CH32的定时器中断类型主要有:

中断类型 触发条件 典型应用
更新中断 计数器溢出或软件更新 周期性任务、系统时基
捕获/比较中断 输入捕获事件或比较匹配 PWM占空比调整、信号测量
触发中断 外部触发事件 同步启动多个定时器

中断服务函数里,记得做三件事:

  1. 读取中断状态寄存器,确认中断源
  2. 执行用户逻辑(比如翻转LED、更新数据)
  3. 清除中断标志位

我见过不少新手在中断里做复杂运算,导致中断响应时间过长。记住,中断服务函数要短小精悍。复杂计算放到主循环里做,中断只负责设标志位或搬数据。

经验之谈: 在CH32上,定时器中断的优先级可以通过NVIC配置。我一般把更新中断设成中等优先级,捕获中断设成高优先级。因为捕获事件对实时性要求更高,丢一个捕获可能就测不准频率了。

好了,定时器的核心内容就这些。从SysTick到通用定时器,从PWM到输入捕获,再到中断机制,这些东西在物联网项目里几乎天天用。你动手试试,把每个例程跑一遍,遇到问题再回头看这一章,理解会更深刻。