CH32 标准库入门指南(LED和TIM)
一、点亮一个 LED
1. GPIO 初始化(以推挽输出为例)
// 1. 定义并初始化一个GPIO配置结构体
GPIO_InitTypeDef GPIO_InitStructure = {0};
// 2. 开启GPIOC的时钟(所有GPIO都挂载在APB2总线上)
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
// 3. 配置引脚参数
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; // 选择要配置的引脚:GPIOC的Pin2
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 设置模式为:推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 设置输出速度为:50MHz(高速模式)
// 4. 将配置写入GPIOC的寄存器
GPIO_Init(GPIOC, &GPIO_InitStructure);
2. GPIO 工作模式详解 (CH32)
typedef enum
{
GPIO_Mode_AIN = 0x0, // 模拟输入模式 (用于ADC等外设,引脚上的模拟信号直接送给片上外设)
GPIO_Mode_IN_FLOATING = 0x04, // 浮空输入模式 (引脚悬空,电平不确定,易受干扰,用于外部信号)
GPIO_Mode_IPD = 0x28, // 下拉输入模式 (内部集成下拉电阻,无外部信号时默认为低电平)
GPIO_Mode_IPU = 0x48, // 上拉输入模式 (内部集成上拉电阻,无外部信号时默认为高电平)
GPIO_Mode_Out_OD = 0x14, // 开漏输出模式 (可输出低电平或高阻态,实现"线与"逻辑,需外部上拉电阻)
GPIO_Mode_Out_PP = 0x10, // 推挽输出模式 (可强力输出高/低电平,驱动能力强,最常用)
GPIO_Mode_AF_OD = 0x1C, // 复用开漏输出 (引脚控制权交给片上外设,如I2C)
GPIO_Mode_AF_PP = 0x18 // 复用推挽输出 (引脚控制权交给片上外设,如SPI, USART)
} GPIOMode_TypeDef;
3. GPIO 输出速度配置
/* 输出最大频率选择 */
typedef enum
{
GPIO_Speed_10MHz = 1, // 中速模式
GPIO_Speed_2MHz, // 低速模式
GPIO_Speed_50MHz // 高速模式 (驱动能力强,但功耗也更高,可能带来电磁干扰)
} GPIOSpeed_TypeDef;
4. 实际应用:LED 闪烁
while (1)
{
// 将GPIOC的Pin2设置为高电平 (根据电路设计,可能是点亮或熄灭LED)
GPIO_SetBits(GPIOC, GPIO_Pin_2);
Delay_Ms(500); // 延时500毫秒
// 将GPIOC的Pin2设置为低电平
GPIO_ResetBits(GPIOC, GPIO_Pin_2);
Delay_Ms(500); // 延时500毫秒
}
二、定时器与中断配置
1. 定时器 TIM2 初始化函数
void Tim2_Init(u16 arr, u16 psc)
{
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
NVIC_InitTypeDef NVIC_InitStructure;
// 1. 开启定时器时钟(TIM2挂载在APB1总线上)
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
// 2. 配置定时器基础工作模式
TIM_TimeBaseInitStruct.TIM_ClockDivision = 0; // 时钟分频因子,对基本定时无影响,设为0
TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up; // 设置为向上计数模式
TIM_TimeBaseInitStruct.TIM_Period = arr; // 自动重装载值(ARR),决定计数上限 决定时钟的周期
TIM_TimeBaseInitStruct.TIM_Prescaler = psc; // 预分频值(PSC),对时钟进行分频 决定时钟的速度
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStruct); // 应用定时器基础配置
// 3. 使能定时器中断源
TIM_ITConfig(TIM2, TIM_IT_Update | TIM_IT_Trigger, ENABLE); // 使能更新中断和触发中断
// 4. 配置NVIC(中断控制器)
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; // 指定TIM2中断通道
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 设置抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3; // 设置子优先级(响应优先级)
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; // 使能该中断通道
NVIC_Init(&NVIC_InitStructure); // 应用NVIC配置
// 5. 启动定时器
TIM_Cmd(TIM2, ENABLE);
}
2. 初始化函数逐行详解
这个函数主要完成了三件大事:1. 开启定时器时钟 → 2. 配置定时器工作模式 → 3. 配置中断控制器(NVIC)。
-
定义结构体变量
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct; NVIC_InitTypeDef NVIC_InitStructure;这和 GPIO 的配置类似,标准库通过结构体来传递一大堆配置参数,而不是让用户一个一个地去写寄存器。
步骤 1: 开启定时器时钟
-
代码:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); -
解释:
RCC_APB1PeriphClockCmd(...): 这是时钟控制函数。RCC_APB1Periph_TIM2: TIM2 外设是挂载在 APB1 (Advanced Peripheral Bus 1) 总线上的。ENABLE: 使能/打开 TIM2 的时钟。- 通俗理解: 这是配置任何外设的第一步,也是最容易忘记的一步。单片机为了省电,默认会关闭绝大多数外设的时钟。你必须先“送电”给 TIM2,后续的配置才能生效。
步骤 2: 配置定时器基础工作模式
这部分代码填充 TIM_TimeBaseInitStruct 这个“表单”。
-
代码:
TIM_TimeBaseInitStruct.TIM_ClockDivision = 0; TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInitStruct.TIM_Period = arr; TIM_TimeBaseInitStruct.TIM_Prescaler = psc; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStruct); -
解释:
TIM_ClockDivision: 时钟分割,用于配置定时器时钟CK_INT和死区时间、数字滤波器使用的采样时钟之间的分频比。对于基本的定时中断功能,保持默认值0即可。TIM_CounterMode: 设置为TIM_CounterMode_Up(向上计数模式)。定时器的核心是一个计数器(CNT),它会从0开始一直向上数,数到你设定的arr值时,产生一个中断事件,然后自动清零,重新从0开始数。这是最常用的模式。TIM_Period: 自动重装载值,即arr。它决定了一个计数周期的“终点”。注意: 计数器是从0数到arr,总共是arr + 1个数。TIM_Prescaler: 预分频值,即psc。定时器的计数器不是直接由 APB1 的时钟驱动的,而是由 APB1 时钟经过这个预分频器分频后的时钟驱动。关键点: 分频系数是psc + 1,这一点非常重要!TIM_TimeBaseInit(...): “提交表单”,这个函数会把TIM_TimeBaseInitStruct结构体里的所有设置一次性配置到 TIM2 的硬件寄存器里。
-
代码:
TIM_ITConfig(TIM2, TIM_IT_Update | TIM_IT_Trigger, ENABLE); -
解释:
TIM_ITConfig用来使能定时器内部的特定中断源。TIM_IT_Update: 这是最重要的中断源。当计数器溢出/上溢(即从arr重新变为0)时,会产生一个“更新中断”信号。我们通常用的定时中断就是它。TIM_IT_Trigger: 这是触发中断,主要用于定时器级联或触发ADC等其他外设,这里也一并使能了。|是C语言的按位或运算符,表示同时使能这两个中断源。
步骤 3: 配置中断控制器 (NVIC)
定时器产生了中断信号,但CPU是否要响应这个信号,由 NVIC (Nested Vectored Interrupt Controller, 嵌套向量中断控制器) 决定。
-
代码:
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); -
解释:
NVIC_IRQChannel: 指定要配置的中断通道为TIM2_IRQn,这是 TIM2 的专属通道号。NVIC_IRQChannelPreemptionPriority: 设置抢占优先级。数字越小,优先级越高。高抢占优先级的中断可以打断(抢占)正在执行的低抢占优先级中断服务函数。NVIC_IRQChannelSubPriority: 设置子优先级(响应优先级)。当两个中断的抢占优先级相同时,子优先级高的(数字小)先被响应。但它们之间不能互相抢占。NVIC_IRQChannelCmd: 使能这个中断通道。在NVIC中打开 TIM2 中断通道的“开关”。如果这里是DISABLE,那么即使 TIM2 产生中断信号,CPU也会忽略它。NVIC_Init(...): “提交表单”,将NVIC的配置写入寄存器。
步骤 4: 启动定时器
-
代码:
TIM_Cmd(TIM2, ENABLE); -
解释:
- 这是最后一步,也是启动的扳机。执行此行代码后,TIM2 的计数器
CNT就正式从0开始,按照你设定的频率向上计数。之前的所有配置都是准备工作,这一行才是“Go!”。
- 这是最后一步,也是启动的扳机。执行此行代码后,TIM2 的计数器
3. 如何计算中断时间?
中断的周期 T 由 psc、arr 和定时器时钟频率共同决定。
中断周期 T (秒) = ( (psc + 1) × (arr + 1) ) / 定时器时钟频率 (Hz)
- 定时器时钟频率: 这取决于你的 CH32 芯片型号和系统时钟配置。例如,在很多常见的配置中,如果系统主频(
SYSCLK)是 72MHz,那么挂在 APB1 上的定时器时钟频率也是 72MHz。(注意:在某些STM32型号上,如果APB1预分频器不是1,这个时钟会被分频,但CH32通常是直通的)。 - 举例: 假设你的定时器时钟是 72MHz,你的函数调用是
Tim2_Init(4999, 7199);- psc = 7199 => 预分频系数 = 7199 + 1 = 7200
- arr = 4999 => 计数周期 = 4999 + 1 = 5000
- 计数器频率 = 72,000,000 Hz / 7200 = 10,000 Hz (即10KHz)
- 中断周期 T = 5000 / 10,000 Hz = 0.5 秒 = 500 毫秒 (ms)
4. 编写中断服务函数 (TIM2_IRQHandler)
当你使用 Tim2_Init() 函数配置好定时器并启动后,每当定时器溢出,硬件就会触发一次中断。CPU 会自动暂停当前主程序,跳转到与之对应的中断服务函数 (Interrupt Service Routine, ISR) 来执行代码。
(1) 函数模板
/* 任务控制定时器中断,例如配置为1ms一次 */
void TIM2_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")))
{
// 1. 判断是否为更新中断
if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
{
// 2. 在这里执行你的周期性任务
}
// 3. 清除中断标志位 (!!! 必须有 !!!)
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
(2) 代码详解
void TIM2_IRQHandler(void)- 这是一个有固定名称的特殊函数。这个名字在单片机的启动文件(如
startup_ch32xxxx.s)中与 TIM2 的中断向量绑定。你不能随意更改这个函数名,否则中断发生时,CPU 就找不到这个函数了。
- 这是一个有固定名称的特殊函数。这个名字在单片机的启动文件(如
CH32 特有优化:快速中断声明
__attribute__((interrupt("WCH-Interrupt-fast")))
- 这是什么? 这是沁恒(WCH) MCU 特有的一个编译器属性,用于声明一个快速中断。
- 有什么用? 它可以告诉编译器,在进入和退出这个中断函数时,使用一套优化的、更快的出入栈指令,从而减少中断延迟,让中断响应和处理的速度更快。
- 何时使用? 对于需要频繁、快速响应的中断(如定时器、串口接收等),推荐都加上这个声明来提升性能。
-
if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)- 作用:这是一个严谨的判断,用于检查“真的是 TIM2 的更新中断发生了吗?”
TIM_GetITStatus()函数用于检查 TIM2 的中断标志位。TIM_IT_Update指定我们关心的是“更新中断”(由计数器溢出产生)。- 为什么需要判断? 因为一个中断函数可能对应定时器的多种中断源(如捕获中断、触发中断等)。虽然你可能只使能了更新中断,但写上这个判断是标准、安全且推荐的做法。
-
{ ... }(if 语句内的代码块)-
这里是中断处理的核心部分。当中断发生时,你希望单片机为你做的所有事情都应该写在这里面。例如:
-
示例1:实现系统时钟滴答
volatile uint32_t system_ticks = 0; // 定义一个全局变量 // 在中断函数里 if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { system_ticks++; // 每中断一次(如1ms),这个变量就加1 } -
示例2:控制 LED 闪烁 (每500次中断翻转一次)
if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { static uint16_t led_counter = 0; led_counter++; if (led_counter >= 500) { led_counter = 0; // GPIO_WriteBit(GPIOC, GPIO_Pin_2, !GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_2)); } } -
示例3:设置任务标志位 (中断中置位,主循环处理)
volatile uint8_t task_flag = 0; // 定义全局标志位 // 在中断函数里 if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { task_flag = 1; // 每中断一次,设置标志位 } // 在main函数的while(1)里检查并处理 // if (task_flag == 1) { // task_flag = 0; // // 执行你的耗时任务... // }
-
-
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);- 这是整个中断服务函数中最关键、最不可或缺的一行代码!
- 工作原理: 当定时器溢出时,硬件会自动设置
TIM_IT_Update这个标志位。CPU看到这个标志位后,就跳转来执行中断函数。但是,硬件不会自动清除这个标志位。 - 严重后果: 如果你不写这行代码手动清除它,当中断函数执行完毕返回后,CPU会发现标志位依然存在,于是会认为又发生了一次新的中断,结果就是CPU会立刻、无休止地再次进入
TIM2_IRQHandler函数。你的程序会完全卡死在这个中断里,无法执行主循环中的任何代码。 - 形象比喻: 这就像一个闹钟。闹钟响了(中断发生),你醒了去处理事情(执行ISR)。但如果你不按掉闹钟上的按钮(清除中断标志位),它就会一直响个不停,你就别想再干别的事情了。
5. 如何移植定时器代码 (例如:从 TIM2 移植到 TIM3)
在嵌入式开发中,我们经常需要将一个已经写好的定时器驱动代码,移植到另一个定时器上。这个过程并不复杂,但需要细心。下面以将代码从 TIM2 移植到 TIM3 为例,说明具体的步骤。
步骤 1: 修改函数名称 (全局替换)
你需要将所有与原定时器相关的函数名,都修改为新定时器的名称。
- 初始化函数:
Tim2_Init()→Tim3_Init() - 中断服务函数:
TIM2_IRQHandler()→TIM3_IRQHandler()(别忘了后面的快速中断声明!)
实用技巧:
在 VSCode、Keil 等现代 IDE 中,使用“全局查找与替换” (Find and Replace in Files) 功能可以非常高效地完成这一步,避免遗漏。
步骤 2: 确认总线并使能时钟
不同的定时器可能挂载在不同的总线上(APB1 或 APB2),因此它们的时钟使能函数也不同。这是最关键也最容易出错的一步。
- 查阅手册: 你必须查阅对应芯片的数据手册或参考手册,找到新定时器(TIM3)挂载在哪条总线上。
- 修改代码: 根据查阅结果,修改时钟使能函数。
由于 TIM3 和 TIM2 一样,都挂载在 APB1 总线上,所以时钟使能函数的第一个参数需要修改:
- 修改前:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); - 修改后:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
注意:
如果你要移植到 TIM1 或 TIM8,它们通常在 APB2 总线上,那么函数就要换成RCC_APB2PeriphClockCmd,例如:RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);。
步骤 3: 替换所有相关的宏定义和参数
在初始化函数和中断服务函数内部,所有使用了原定时器(TIM2)的地方,都需要替换成新的定时器(TIM3)。
-
初始化函数
Tim3_Init()内:TIM_TimeBaseInit(TIM2, ...)→TIM_TimeBaseInit(TIM3, ...)TIM_ITConfig(TIM2, ...)→TIM_ITConfig(TIM3, ...)NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;→NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn;(中断通道号必须改!)TIM_Cmd(TIM2, ...)→TIM_Cmd(TIM3, ...)
-
中断服务函数
TIM3_IRQHandler()内:if(TIM_GetITStatus(TIM2, ...))→if(TIM_GetITStatus(TIM3, ...))TIM_ClearITPendingBit(TIM2, ...)→TIM_ClearITPendingBit(TIM3, ...)
移植后示例代码
下面是完全从 TIM2 移植到 TIM3 后的代码:
-
新的初始化函数
void Tim3_Init(u16 arr, u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseInitStruct.TIM_ClockDivision = 0; TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInitStruct.TIM_Period = arr; TIM_TimeBaseInitStruct.TIM_Prescaler = psc; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStruct); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM3, ENABLE); } -
新的中断服务函数
void TIM3_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast"))) { if(TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { // 在这里执行你的周期性任务... TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }