手把手教你用Keil5写一个精准的C语言定时器驱动
你有没有遇到过这种情况:想让LED每秒闪一次,结果用了delay(1000)之后发现程序卡住了?别的任务全都被耽误了,连串口收数据都丢包。这其实是很多初学者都会踩的第一个坑——别再用软件延时了!
真正靠谱的时间控制,得靠硬件定时器。今天我就带你从零开始,在Keil5(MDK-ARM)环境下,用最原始的寄存器操作方式,实现一个每10ms触发一次中断的定时器驱动。不依赖HAL库,不调CubeMX,只用C语言和CMSIS标准头文件,让你彻底搞懂底层原理。
我们以STM32F103为例,但这套方法适用于所有Cortex-M系列MCU。最后还会告诉你怎么在Keil里配置工程、调试寄存器、看中断频率是否准确。
为什么非要用硬件定时器?
先说清楚一件事:软件延时是“假时间”。
比如这段代码:
for(int i = 0; i < 1000000; i++);它依赖CPU一条条执行空循环,期间不能干任何事。一旦编译器优化开高一点,或者系统主频变了,这个“1秒”可能就变成0.3秒。更可怕的是,如果你在延时期间来了个中断,整个计时就被打乱了。
而硬件定时器完全不同:
- 它是一个独立运行的计数器,挂在APB总线上;
- 自动递增或递减,不需要CPU干预;
- 溢出时自动产生中断,响应快且精确;
- CPU可以继续跑主逻辑,甚至进入低功耗模式。
换句话说,硬件定时器才是真正意义上的“并行计时”。
| 对比项 | 软件延时 | 硬件定时器 |
|---|---|---|
| 是否阻塞 | 是 ✘ | 否 ✔ |
| 精度稳定性 | 受编译/中断影响 ❌ | 微秒级稳定 ✔ |
| 多任务支持 | 不支持 ❌ | 支持 ✔ |
| 实时性 | 差 ❌ | 强 ✔ |
所以只要是正经项目,必须上硬件定时器。
TIM2定时器是怎么工作的?
我们选的是STM32上的TIM2,属于通用定时器,挂载在APB1总线(PCLK1)。虽然F1系列的APB1默认是36MHz,但定时器时钟会被自动倍频到72MHz——这是ST家的一个小细节,很多人会忽略。
它的核心工作机制其实很简单:
- 接收一个输入时钟(比如72MHz)
- 经过预分频器(PSC)分频 → 得到计数时钟
- 计数器(CNT)按这个频率往上加
- 加到设定值(ARR)后归零,并产生“更新事件”
- 更新事件可以触发中断 → 进入ISR处理用户逻辑
举个例子:
要实现10ms 中断一次(即100Hz),假设输入时钟为72MHz
我们设置:
- 预分频器 PSC = 7199 → 分频后为 72MHz / (7199+1) = 10kHz
- 自动重载 ARR = 99 → 计数100次 → 周期 = 100 / 10kHz = 10ms
公式如下:
$$
T_{\text{overflow}} = \frac{(Prescaler + 1) \times (Period + 1)}{Clock_Frequency}
$$
搞定参数后,剩下的就是写代码了。
写一个真正的裸机定时器驱动
下面这段代码是你能在Keil5里跑起来的最小可用版本。我已经去掉所有宏封装,直接操作寄存器,让你看得明明白白。
#include "stm32f10x.h" #define SYSTEM_CLOCK 72000000UL // 系统主频72MHz #define TICK_FREQ_HZ 100 // 中断频率:100Hz → 每10ms一次 void Timer2_Init(void) { // 第一步:开启TIM2时钟 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 第二步:计算并设置分频与周期 uint16_t prescaler = (SYSTEM_CLOCK / 10000) - 1; // 10kHz计数频率 uint16_t period = (10000 / TICK_FREQ_HZ) - 1; // 溢出周期 TIM2->PSC = prescaler; // 设置预分频 TIM2->ARR = period; // 设置自动重载值 TIM2->CNT = 0; // 清零计数器 TIM2->EGR = TIM_EGR_UG; // 手动触发更新,加载配置 // 第三步:使能更新中断 TIM2->DIER |= TIM_DIER_UIE; // 第四步:清除可能存在的中断标志 TIM2->SR &= ~TIM_SR_UIF; // 第五步:启动定时器 TIM2->CR1 |= TIM_CR1_CEN; } // 开启NVIC中的TIM2中断 void Enable_Timer2_IRQ(void) { NVIC_EnableIRQ(TIM2_IRQn); NVIC_SetPriority(TIM2_IRQn, 1); // 设定优先级 } // 中断服务程序 void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { // 判断是否为更新中断 TIM2->SR &= ~TIM_SR_UIF; // 必须手动清除标志位! // 用户操作:翻转PA5引脚(接LED) GPIOA->ODR ^= GPIO_ODR_ODR5; } }关键点解析:
RCC->APB1ENR |= ...:一定要先开外设时钟,否则TIM2不会工作。EGR |= UG:强制重新初始化计数器和预分频器,确保配置立即生效。- 清除中断标志
UIF是必须的!否则会反复进中断。 - ISR中尽量少做事,这里只是翻转IO;复杂逻辑建议设标志位由主循环处理。
- 使用
^=异或操作翻转电平,简洁高效。
在Keil5中搭建这个工程
光有代码不行,你还得知道怎么把它放进Keil5里跑起来。
步骤一:新建工程
打开 μVision5 → Project → New μVision Project
选择芯片型号:STM32F103C8
Keil会自动提示添加启动文件,选“是”。你会看到项目里多了个startup_stm32f10x_md.s,这就是中断向量表所在。
步骤二:添加源文件
把上面的代码保存为timer_driver.c,拖进Source Group。
记得包含头文件路径:
Project → Options → C/C++ → Include Paths
添加:.\Inc或你存放头文件的目录
同时定义两个宏(防止库冲突):
Define:
USE_STDPERIPH_DRIVER, STM32F10X_MD
步骤三:配置目标选项
Target 标签页
XTAL: 8.0 MHz(外部晶振)
注意:你要在代码里自己通过RCC配置PLL倍频到72MHz!Output 标签页
勾选 “Create HEX File” —— 方便用STC-ISP这类工具烧录Debug 标签页
选择你的下载器,比如 ST-Link Debugger
勾选 “Run to main()” —— 下载后自动停在main函数开头C/C++ 标签页
Optimization Level:-O2(推荐平衡性能与体积)
Warning Level: 默认即可
步骤四:链接脚本(Scatter File)
Keil默认生成一个分散加载文件,控制代码放在Flash哪里、变量放RAM哪里。
典型内容如下:
LR_IROM1 0x08000000 0x00010000 { ; Flash起始地址+大小(64KB) ER_IROM1 0x08000000 0x00010000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00002000 { ; RAM起始地址+大小(8KB) .ANY (+RW +ZI) } }你可以根据实际芯片容量修改数值。例如F103C8是64KB Flash + 20KB RAM,那RAM段应改为0x00005000。
主函数怎么写?
别忘了还有一个main()函数:
int main(void) { SystemInit(); // CMSIS提供的系统初始化(设置时钟) // 配置PA5为输出(用于LED) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~GPIO_CRL_MODE5; GPIOA->CRL |= GPIO_CRL_MODE5_1; // 推挽输出,最大速度2MHz Timer2_Init(); Enable_Timer2_IRQ(); while (1) { // 主循环可做其他事情 // 比如采集传感器、处理通信协议…… } }注意:SystemInit()是CMSIS自带的函数,负责初始化系统时钟到72MHz。如果你不用它,就得自己写RCC配置。
如何验证定时器真的准?
Keil5的强大之处就在于它的调试能力。你可以实时查看寄存器状态,甚至画波形。
方法一:用逻辑分析仪观察PA5波形
连接ST-Link → 下载程序 → 运行
用示波器或低成本逻辑分析仪抓PA5引脚,你应该看到一个20ms周期(高低各10ms)的方波。
如果周期不准,检查以下几点:
- 是否正确设置了系统时钟?
- APB1是否真的输出72MHz给TIM2?(查手册确认)
- PSC和ARR算错了没?
方法二:Keil内置“Serial Windows”查看计数器
View → Serial Windows → Timer
虽然名字叫Timer,其实是用来监控任意内存地址变化的窗口。你可以手动输入:
&TIM2->CNT, u然后运行程序,就能看到CNT从0一路加到99再归零,每10ms一次循环。
实际开发中的坑与避坑指南
我在无数个项目中用过这种定时器方案,总结几个新手最容易犯的错:
❌ 坑1:忘记开外设时钟
// 错误示范 // 没有开RCC_APB1ENR_TIM2EN → TIM2根本不会工作!❌ 坑2:不清中断标志导致反复进ISR
// 错误示范 void TIM2_IRQHandler(void) { // 没有清UIF标志 → 中断持续挂起 → 死循环进ISR GPIOA->ODR ^= GPIO_ODR_ODR5; }❌ 坑3:在ISR里做太多事
// 危险做法 void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR &= ~TIM_SR_UIF; delay_ms(10); // 绝对禁止! printf("tick\n"); // printf太慢,也可能重入 } }✅ 正确做法:在ISR里只设标志位,主循环判断执行:
volatile uint8_t tick_flag = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR &= ~TIM_SR_UIF; tick_flag = 1; } } // 主循环 if (tick_flag) { tick_flag = 0; do_something(); // 安全执行耗时操作 }✅ 最佳实践清单:
| 项目 | 推荐做法 |
|---|---|
| 寄存器访问 | 使用CMSIS结构体(如TIM2->CR1) |
| 共享变量 | 加volatile防止编译器优化 |
| 中断优先级 | 关键定时器设高优先级,避免被阻塞 |
| 初始化顺序 | 时钟 → GPIO → 外设 → NVIC |
| 调试手段 | Keil Watch窗口 + Serial Windows + 逻辑分析仪 |
这个定时器还能干什么?
你以为这只是个LED闪烁?远远不止。
一旦你有了精准的时间基准,就可以构建更复杂的系统:
- RTOS节拍源:替代SysTick,提供更灵活的调度周期
- ADC定时采样:每隔1ms启动一次AD转换
- PWM生成:配合输出比较通道,调节电机速度或LED亮度
- 看门狗喂狗:防止程序跑飞
- 协议超时检测:UART接收等待超过500ms则报错
- 时间戳记录:给事件打上精确时间标签
甚至你可以扩展成一个多通道定时管理器,类似Linux的timerfd机制。
结语:掌握底层,才能驾驭复杂系统
你看,就这么一百来行代码,背后涉及的知识却非常深:
- MCU时钟树理解
- 定时器寄存器映射
- NVIC中断机制
- Keil工程配置
- 编译优化与调试技巧
这些都不是点几下CubeMX就能真正掌握的。只有亲手操作过寄存器,你才会明白每一行代码背后的代价与收益。
未来无论是转向FreeRTOS、还是做低功耗设计、甚至是跑轻量AI模型(比如Arm CMSIS-NN),定时器都是你绕不开的基础模块。
现在你已经拥有了打造“心跳”的能力。接下来,不妨试试用TIM3做个PWM呼吸灯,或者结合RTC做个日历时钟?
如果你在实现过程中遇到了问题,欢迎留言交流。我们一起把嵌入式这条路走得更深更稳。