下面这版已经整理成 CSDN Markdown 技术博客格式,可以直接复制粘贴发布。内容统一描述为"常规小项目 / 小型控制项目",没有出现你要求避开的词。
51单片机C语言重新入门:第七天学习定时器、中断与1ms系统节拍
前言
这是我重新学习 51 单片机 C 语言的第七天笔记。
前面六天主要学习了:
text
第一天:C51 程序结构、main()、while(1)、LED 闪烁
第二天:数据类型、变量、bit、unsigned char、unsigned int
第三天:sfr、sbit、寄存器、位操作
第四天:GPIO 输出控制、高低电平、IO 模式
第五天:GPIO 输入、按键读取、简单消抖
第六天:函数封装、模块化代码结构、.h/.c 文件分离
第七天开始进入 51 单片机学习中的一个重要转折点:
text
定时器
中断
1ms 系统节拍
非阻塞延时
用定时器替代 Delay_ms()
前面写 LED 闪烁、按键消抖时,经常使用:
c
Delay_ms(500);
这种写法简单直观,但是有一个很明显的问题:
程序在延时期间会停在原地,不能及时处理其他任务。
在常规小项目中,如果程序既要处理 LED 闪烁,又要处理按键扫描、电池检测、串口通信、状态刷新等任务,就不能长期依赖阻塞式延时。
所以第七天开始学习定时器中断和 1ms 系统节拍。
一、第七天学习目标
第七天的学习目标是:
text
理解为什么不能一直使用 Delay_ms()
理解什么是阻塞式延时
理解什么是非阻塞思路
理解什么是定时器
理解什么是中断
理解什么是 1ms 系统节拍
能看懂 Timer0 初始化代码
能看懂 Timer0 中断函数
知道 interrupt 1 的含义
知道 g_ms_tick++ 的作用
知道 volatile 的作用
能用 tick 差值实现 500ms LED 翻转
初步理解简单任务调度思想
今天最重要的一句话是:
定时器中断的作用,是让单片机在固定时间间隔自动执行一小段代码,从而为整个程序提供稳定的时间基准。
二、为什么不能一直使用Delay_ms()?
前面写 LED 闪烁,通常会这样写:
c
LED_On();
Delay_ms(500);
LED_Off();
Delay_ms(500);
这段代码的意思是:
text
LED 点亮
原地等待 500ms
LED 熄灭
原地等待 500ms
看起来很简单,但是问题在于:
text
Delay_ms() 执行期间,CPU 一直在空转。
如果程序正在执行:
c
Delay_ms(500);
那么这 500ms 内,主程序很难及时处理其他任务,例如:
text
读取按键
检测电池
处理串口数据
更新显示状态
处理报警闪烁
控制 PWM
进入低功耗
所以 Delay_ms() 适合入门实验,但不适合稍微复杂一点的常规小项目。
三、什么是阻塞式延时?
Delay_ms() 这种写法叫:
text
阻塞式延时
"阻塞"可以理解为:
text
程序卡在这里等时间过去,等完之后才继续往下执行。
例如:
c
LED_On();
Delay_ms(1000);
LED_Off();
程序执行到 Delay_ms(1000) 时,会一直停在那里。
这 1 秒内,主程序不会继续执行后面的代码。
通俗理解:
text
Delay_ms() 就像让程序站在原地等。
等的过程中,程序基本不处理别的事情。
四、什么是非阻塞思路?
非阻塞思路是:
不让程序原地傻等,而是让一个定时器在后台定期计数,主程序根据时间标志决定什么时候做事。
比如想让 LED 每 500ms 翻转一次。
阻塞式写法是:
c
LED_Toggle();
Delay_ms(500);
非阻塞思路是:
text
系统每 1ms 自动计数一次。
主循环一直运行。
当发现距离上次 LED 翻转已经过去 500ms,就翻转一次 LED。
如果没到 500ms,就继续做其他事情。
也就是说,程序不是"停下来等时间",而是"边运行边看时间到了没有"。
这就需要今天学习的:
text
定时器 + 中断 + 1ms 系统节拍
五、什么是定时器?
定时器可以理解为:
单片机内部的一个自动计数器。
它会按照固定频率不断计数。
当计数达到一定值后,就会溢出。
溢出时,可以触发中断。
通俗理解:
text
定时器就像单片机内部的闹钟。
你提前设置好它多久响一次。
时间到了,它就提醒 CPU:该处理一下了。
在 51 单片机中,常用的定时器有:
text
Timer0
Timer1
第七天先学习 Timer0。
六、什么是中断?
中断可以理解为:
CPU 正在执行主程序时,外设突然有重要事情发生,CPU 暂停当前任务,先去处理这个事情,处理完再回来继续执行原来的程序。
例如:
text
主程序正在 while(1) 里循环
Timer0 到达设定时间
Timer0 触发中断
CPU 暂停主程序
进入 Timer0 中断函数
执行 1ms 计数
执行完后返回主程序
通俗理解:
text
中断就像有人敲门。
你本来正在写代码。
有人敲门,你先停下来开门。
开完门后,回来继续写代码。
中断不是主程序主动调用的,而是硬件事件触发后,CPU 自动跳转执行的。
七、定时器中断有什么用?
定时器中断最常见的用途是产生固定时间节拍。
比如:
text
每 1ms 进入一次中断
每次中断让 g_ms_tick 加 1
这样程序里就有了一个持续递增的时间基准。
例如:
c
volatile unsigned int g_ms_tick = 0;
每过 1ms:
c
g_ms_tick++;
这个变量就像系统运行时间。
主程序可以根据它判断:
text
是否过了 10ms
是否过了 20ms
是否过了 500ms
是否过了 1000ms
这就是所谓的:
text
1ms 系统节拍
八、什么是1ms系统节拍?
1ms 系统节拍就是:
定时器每 1ms 触发一次中断,在中断里让一个计数变量加 1。
例如:
c
volatile unsigned int g_ms_tick = 0;
void Timer0_Isr(void) interrupt 1
{
g_ms_tick++;
}
如果定时器确实每 1ms 进入一次中断,那么:
text
g_ms_tick = 1 表示大约过去 1ms
g_ms_tick = 100 表示大约过去 100ms
g_ms_tick = 500 表示大约过去 500ms
g_ms_tick = 1000 表示大约过去 1s
注意这里说"大约",因为实际精度和系统时钟、定时器配置、晶振误差、中断执行时间等因素有关。
九、为什么常用1ms作为基础节拍?
1ms 很适合作为小型控制项目里的基础时间单位。
很多任务都可以用 1ms 派生出来:
text
按键扫描:10ms 一次
LED 闪烁:500ms 一次
电池检测:1000ms 一次
状态刷新:100ms 一次
串口超时:几十 ms
长按判断:1000ms 或 2000ms
所以在嵌入式程序里,经常会设计一个:
text
1ms tick
也就是 1ms 系统节拍。
十、Timer0是什么?
Timer0 就是 51 单片机里的一个定时器。
今天先学习 Timer0。
Timer0 常用到这些寄存器或控制位:
| 名称 | 作用 |
|---|---|
TMOD |
设置定时器工作模式 |
TH0 |
Timer0 高 8 位计数寄存器 |
TL0 |
Timer0 低 8 位计数寄存器 |
TR0 |
Timer0 启动控制位 |
TF0 |
Timer0 溢出标志 |
ET0 |
Timer0 中断允许位 |
EA |
总中断允许位 |
可以先这样记:
text
TMOD:选择模式
TH0/TL0:装初值
TR0:启动定时器
TF0:溢出标志
ET0:允许 Timer0 中断
EA:打开总中断
十一、Timer0工作模式:16位定时器模式
今天使用 Timer0 的 16 位定时器模式,也就是常见的模式 1。
16 位定时器可以从某个初值开始计数,一直加到 65535,再加 1 就溢出回到 0。
text
0000H → 0001H → 0002H → ... → FFFFH → 溢出
如果希望 1ms 溢出一次,就需要计算一个初值。
十二、定时器初值怎么计算?
今天为了方便学习,统一假设:
text
芯片:STC8G 系列
系统时钟:12MHz
Timer0:1T 模式
Timer0 模式:16 位定时器模式
目标:1ms 中断一次
如果是 1T 模式:
text
定时器每 1 个时钟计数一次
12MHz 表示:
text
1 秒计数 12,000,000 次
那么 1ms 计数次数是:
text
12,000,000 / 1000 = 12,000 次
16 位定时器溢出值是:
text
65536
所以初值为:
text
65536 - 12000 = 53536
把 53536 转成十六进制:
text
53536 = 0xD120
所以:
c
TH0 = 0xD1;
TL0 = 0x20;
这表示:
text
Timer0 从 0xD120 开始计数
计数 12000 次后溢出
大约经过 1ms
十三、如果主频不是12MHz怎么办?
定时器初值和系统时钟有关。
在 1T 模式、16 位定时器、1ms 定时的前提下,可以先用下面公式理解:
text
定时器计数次数 = FOSC / 1000
定时器初值 = 65536 - 定时器计数次数
例如如果 FOSC 是 11.0592MHz:
text
1ms 计数约 11059 次
初值约等于 65536 - 11059 = 54477
54477 = 0xD4CD
所以大约可以写:
c
TH0 = 0xD4;
TL0 = 0xCD;
如果使用 12T 模式,公式就会变化。
12T 模式下:
text
定时器计数频率 = FOSC / 12
所以 1ms 的计数次数变成:
text
FOSC / 12 / 1000
因此一定要记住:
text
定时器初值必须结合系统时钟和 1T/12T 模式计算。
十四、Timer0初始化代码
Timer0 初始化代码可以写成:
c
void Timer0_Init(void)
{
AUXR |= 0x80; /* Timer0 使用 1T 模式 */
TMOD &= 0xF0; /* 清除 Timer0 模式位 */
TMOD |= 0x01; /* Timer0 设置为模式1,16位定时器 */
TH0 = 0xD1; /* 1ms 初值,高8位 */
TL0 = 0x20; /* 1ms 初值,低8位 */
TF0 = 0; /* 清除 Timer0 溢出标志 */
ET0 = 1; /* 允许 Timer0 中断 */
EA = 1; /* 打开总中断 */
TR0 = 1; /* 启动 Timer0 */
}
下面逐句解释。
十五、Timer0_Init()逐句解释
1. 设置Timer0为1T模式
c
AUXR |= 0x80;
这句的作用是:
text
让 Timer0 使用 1T 模式。
通俗理解:
text
定时器每个系统时钟计数一次。
2. 清除Timer0模式位
c
TMOD &= 0xF0;
TMOD 是定时器模式寄存器。
低 4 位控制 Timer0。
高 4 位控制 Timer1。
text
TMOD 高4位:Timer1
TMOD 低4位:Timer0
0xF0 的二进制是:
text
1111 0000
所以:
c
TMOD &= 0xF0;
意思是:
text
保留 Timer1 的配置
清除 Timer0 的配置
这是一种很常见的位操作写法。
3. 设置Timer0为模式1
c
TMOD |= 0x01;
0x01 的二进制是:
text
0000 0001
这句的作用是:
text
把 Timer0 设置为模式 1,也就是 16 位定时器模式。
4. 装载初值
c
TH0 = 0xD1;
TL0 = 0x20;
这两句表示:
text
Timer0 从 0xD120 开始计数。
前面已经算过:
text
0xD120 → 0xFFFF
中间大约需要 12000 次计数。
在 12MHz、1T 模式下,约等于 1ms。
5. 清除溢出标志
c
TF0 = 0;
TF0 是 Timer0 溢出标志。
清零表示:
text
先把旧的溢出标志清掉。
6. 允许Timer0中断
c
ET0 = 1;
ET0 表示 Timer0 中断允许。
写 1 表示:
text
允许 Timer0 溢出后触发中断。
7. 打开总中断
c
EA = 1;
EA 是总中断开关。
可以理解为:
text
总电源开关。
即使 ET0 = 1,如果 EA = 0,中断也不会真正响应。
所以必须:
c
ET0 = 1;
EA = 1;
8. 启动Timer0
c
TR0 = 1;
TR0 是 Timer0 运行控制位。
写 1 表示:
text
启动 Timer0,让它开始计数。
十六、中断函数怎么写?
C51 里 Timer0 中断函数一般写成:
c
void Timer0_Isr(void) interrupt 1
{
}
其中:
text
void :没有返回值
Timer0_Isr :函数名
(void) :没有参数
interrupt 1 :Timer0 中断编号
这里要特别注意:
interrupt 1不是标准 C 语言语法,而是 Keil C51 编译器提供的中断函数扩展语法。
普通函数结构是:
c
返回值类型 函数名(参数列表)
{
函数体;
}
C51 中断函数可以理解成:
c
返回值类型 函数名(参数列表) interrupt 中断编号
{
中断服务代码;
}
所以:
c
void Timer0_Isr(void) interrupt 1
并没有推翻普通函数结构,只是在参数列表后面多了一个 C51 的中断说明。
十七、interrupt 1是什么意思?
interrupt 1 的意思是:
text
这个函数是 Timer0 的中断服务函数。
其中:
text
interrupt:告诉编译器这是一个中断函数
1:表示中断编号
常见 8051 中断编号如下:
| 中断源 | C51 interrupt 编号 |
|---|---|
| 外部中断 0 | 0 |
| 定时器 0 | 1 |
| 外部中断 1 | 2 |
| 定时器 1 | 3 |
| 串口中断 | 4 |
所以:
c
interrupt 1
表示:
text
Timer0 中断。
函数名 Timer0_Isr 可以自己取,真正决定它是 Timer0 中断函数的是 interrupt 1。
例如下面这些函数名都可以:
c
void Timer0_Isr(void) interrupt 1
{
}
void Timer0_Handler(void) interrupt 1
{
}
void T0_ISR(void) interrupt 1
{
}
不过为了可读性,建议函数名写清楚,比如 Timer0_Isr。
十八、Timer0中断函数里要做什么?
Timer0 每 1ms 中断一次。
所以中断函数里可以写:
c
void Timer0_Isr(void) interrupt 1
{
TH0 = 0xD1;
TL0 = 0x20;
g_ms_tick++;
}
这里做了两件事:
text
重新装载 1ms 初值
系统毫秒计数加 1
为什么要重新装载?
因为 Timer0 溢出后会从 0 开始重新计数。
如果不重新装初值,下一次溢出就不是 1ms,而是完整计数 65536 次后才溢出。
所以在中断里要重新写:
c
TH0 = 0xD1;
TL0 = 0x20;
十九、g_ms_tick++有没有返回值?
在中断函数里经常写:
c
g_ms_tick++;
这句话不是函数调用,而是一条自增语句。
它的作用是:
text
把 g_ms_tick 当前的值加 1。
它等价于:
c
g_ms_tick = g_ms_tick + 1;
如果 Timer0 每 1ms 进入一次中断,那么:
text
1ms 后:g_ms_tick = 1
2ms 后:g_ms_tick = 2
3ms 后:g_ms_tick = 3
...
500ms 后:g_ms_tick = 500
1000ms 后:g_ms_tick = 1000
所以:
text
g_ms_tick++ 不是返回值。
它只是让 g_ms_tick 自己加 1。
中断函数本身也不需要返回值,因为它不是由主程序像普通函数那样调用的。
二十、为什么g_ms_tick要加volatile?
系统节拍变量建议写成:
c
volatile unsigned int g_ms_tick = 0;
这里的 volatile 是一个重要关键字。
它告诉编译器:
这个变量可能会在主程序看不见的地方被改变,不要随便优化它。
因为 g_ms_tick 是在中断函数里改变的。
主函数里可能会读取它。
所以它同时被:
text
主程序使用
中断函数修改
这种变量建议加 volatile。
volatile的通俗理解
普通变量,编译器可能会觉得:
text
这个变量我刚刚读过,应该没变。
然后为了优化速度,不再重新读取。
但是中断可能随时把它改掉。
所以要告诉编译器:
text
别自作聪明,每次用它时都老老实实去内存里读。
这就是 volatile 的作用。
二十一、用g_ms_tick实现非阻塞LED闪烁
假设:
c
volatile unsigned int g_ms_tick = 0;
每 1ms 加 1。
如果想让 LED 每 500ms 翻转一次,可以写:
c
unsigned int last_led_tick = 0;
while(1)
{
if((unsigned int)(g_ms_tick - last_led_tick) >= 500)
{
last_led_tick = g_ms_tick;
LED_Toggle();
}
}
这段代码的意思是:
text
如果当前时间 - 上次 LED 翻转时间 >= 500ms
就更新上次时间
并翻转 LED
这就是非阻塞闪烁。
它不会原地等待。
主循环可以继续执行其他任务。
二十二、阻塞闪烁和非阻塞闪烁对比
1. 阻塞写法
c
LED_Toggle();
Delay_ms(500);
缺点:
text
Delay_ms() 期间程序卡住
按键不容易及时响应
多个任务不好同时运行
2. 非阻塞写法
c
if((unsigned int)(g_ms_tick - last_led_tick) >= 500)
{
last_led_tick = g_ms_tick;
LED_Toggle();
}
优点:
text
主循环一直在运行
可以同时处理按键
可以同时处理 LED 闪烁
可以扩展更多周期任务
这就是实际小项目中更常用的思路。
二十三、第七天完整单文件示例程序
先不拆模块,先写一个完整单文件程序帮助理解。
功能:
text
Timer0 每 1ms 中断一次
g_ms_tick 每 1ms 加 1
LED 每 500ms 翻转一次
代码如下:
c
#include "STC8G.H"
#define BIT7 0x80
#define LED_ON_LEVEL 0
#define LED_OFF_LEVEL 1
#define TIMER0_RELOAD_H 0xD1
#define TIMER0_RELOAD_L 0x20
sbit LED = P3^7;
volatile unsigned int g_ms_tick = 0;
bit led_state = 0;
void GPIO_Init(void);
void Timer0_Init(void);
void LED_On(void);
void LED_Off(void);
void LED_Toggle(void);
void main(void)
{
unsigned int last_led_tick = 0;
GPIO_Init();
Timer0_Init();
while(1)
{
if((unsigned int)(g_ms_tick - last_led_tick) >= 500)
{
last_led_tick = g_ms_tick;
LED_Toggle();
}
}
}
void GPIO_Init(void)
{
LED_Off();
/* P3.7 设置为推挽输出 */
P3M1 &= ~BIT7;
P3M0 |= BIT7;
}
void Timer0_Init(void)
{
AUXR |= 0x80; /* Timer0 1T */
TMOD &= 0xF0; /* 清除 Timer0 模式位 */
TMOD |= 0x01; /* Timer0 模式1,16位定时器 */
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
TF0 = 0;
ET0 = 1;
EA = 1;
TR0 = 1;
}
void Timer0_Isr(void) interrupt 1
{
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
g_ms_tick++;
}
void LED_On(void)
{
LED = LED_ON_LEVEL;
led_state = 1;
}
void LED_Off(void)
{
LED = LED_OFF_LEVEL;
led_state = 0;
}
void LED_Toggle(void)
{
if(led_state == 1)
{
LED_Off();
}
else
{
LED_On();
}
}
二十四、为什么判断里要强制转换unsigned int?
代码里有一句:
c
if((unsigned int)(g_ms_tick - last_led_tick) >= 500)
这是为了处理计数溢出问题。
g_ms_tick 是 unsigned int,最大到 65535。
再加 1 会回到 0。
也就是说:
text
65535 → 0 → 1 → 2
如果使用无符号减法,只要写法正确,即使回绕,也能判断时间差。
这是嵌入式中很常见的写法。
可以先记住这个模板:
c
if((unsigned int)(now - last) >= interval)
{
last = now;
// 执行周期任务
}
二十五、为什么不建议在中断里写复杂代码?
中断函数应该尽量短。
不推荐在中断里写:
c
void Timer0_Isr(void) interrupt 1
{
Delay_ms(20);
LED_Toggle();
Key_Scan();
Battery_Check();
}
原因是:
text
中断执行时间太长,会影响其他中断和主程序
中断里调用延时函数非常危险
中断里处理太多逻辑会导致程序不稳定
推荐中断里只做很短的事情:
text
重装定时器初值
系统节拍加 1
设置某些标志位
例如:
c
void Timer0_Isr(void) interrupt 1
{
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
g_ms_tick++;
}
这就是比较好的写法。
二十六、用1ms tick同时处理多个任务
有了 1ms 系统节拍,就可以同时处理多个周期任务。
例如:
text
LED 每 500ms 翻转一次
按键每 10ms 扫描一次
电池每 1000ms 检测一次
主循环可以写成:
c
while(1)
{
if((unsigned int)(g_ms_tick - last_key_tick) >= 10)
{
last_key_tick = g_ms_tick;
Key_Process();
}
if((unsigned int)(g_ms_tick - last_led_tick) >= 500)
{
last_led_tick = g_ms_tick;
LED_Toggle();
}
if((unsigned int)(g_ms_tick - last_battery_tick) >= 1000)
{
last_battery_tick = g_ms_tick;
Battery_Check();
}
}
这就是最简单的:
text
裸机时间片轮询
不需要操作系统,但已经比 Delay_ms() 高级很多。
二十七、什么是周期任务?
周期任务就是:
每隔固定时间执行一次的任务。
常见任务如下:
| 任务 | 周期 |
|---|---|
| 按键扫描 | 10ms |
| LED 闪烁 | 500ms |
| 电池检测 | 1000ms |
| 状态刷新 | 100ms |
| 低功耗判断 | 1000ms |
主循环不断判断时间是否到了。
到了就执行。
没到就跳过。
这种写法非常适合小型嵌入式项目。
二十八、模块化后的timer模块设计
按照第六天的模块化思路,可以新建:
text
timer.h
timer.c
文件结构变成:
text
Project
│
├── main.c
│
├── led.h
├── led.c
│
├── key.h
├── key.c
│
├── timer.h
├── timer.c
│
└── STC8G.H
二十九、timer.h
c
#ifndef __TIMER_H__
#define __TIMER_H__
#include "STC8G.H"
void Timer0_Init(void);
unsigned int Timer_GetTick(void);
#endif
这里提供两个函数:
text
Timer0_Init():初始化 Timer0
Timer_GetTick():获取当前毫秒计数
三十、timer.c
c
#include "STC8G.H"
#include "timer.h"
#define TIMER0_RELOAD_H 0xD1
#define TIMER0_RELOAD_L 0x20
static volatile unsigned int g_ms_tick = 0;
void Timer0_Init(void)
{
AUXR |= 0x80; /* Timer0 1T */
TMOD &= 0xF0; /* 清除 Timer0 模式位 */
TMOD |= 0x01; /* Timer0 模式1,16位定时器 */
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
TF0 = 0;
ET0 = 1;
EA = 1;
TR0 = 1;
}
unsigned int Timer_GetTick(void)
{
unsigned int tick;
EA = 0;
tick = g_ms_tick;
EA = 1;
return tick;
}
void Timer0_Isr(void) interrupt 1
{
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
g_ms_tick++;
}
三十一、为什么Timer_GetTick()里要关中断?
这里有一个重点。
g_ms_tick 是 unsigned int,占 16 位。
而 51 单片机是 8 位单片机。
读取 16 位变量时,可能需要分两次读:
text
先读低 8 位
再读高 8 位
如果刚读完一半,中断发生了,g_ms_tick 被修改,就可能读到一个不一致的值。
所以这里写:
c
EA = 0;
tick = g_ms_tick;
EA = 1;
意思是:
text
临时关闭总中断
安全读取 g_ms_tick
再打开总中断
这样可以避免主程序读取 16 位 tick 时被中断打断。
这是 8 位单片机里很重要的细节。
三十二、main.c使用timer模块
c
#include "STC8G.H"
#include "led.h"
#include "key.h"
#include "timer.h"
void main(void)
{
unsigned int now_tick = 0;
unsigned int last_led_tick = 0;
LED_Init();
Key_Init();
Timer0_Init();
while(1)
{
now_tick = Timer_GetTick();
if((unsigned int)(now_tick - last_led_tick) >= 500)
{
last_led_tick = now_tick;
LED_Toggle();
}
}
}
这个主函数的意思是:
text
初始化 LED
初始化按键
初始化 Timer0
循环读取当前系统时间
如果距离上次 LED 翻转超过 500ms
就翻转 LED
主函数仍然很清晰。
三十三、加入按键任务
如果想每 10ms 扫描一次按键,可以写:
c
#include "STC8G.H"
#include "led.h"
#include "key.h"
#include "timer.h"
void main(void)
{
unsigned int now_tick = 0;
unsigned int last_led_tick = 0;
unsigned int last_key_tick = 0;
LED_Init();
Key_Init();
Timer0_Init();
while(1)
{
now_tick = Timer_GetTick();
if((unsigned int)(now_tick - last_led_tick) >= 500)
{
last_led_tick = now_tick;
LED_Toggle();
}
if((unsigned int)(now_tick - last_key_tick) >= 10)
{
last_key_tick = now_tick;
if(Key_IsPressed() == 1)
{
LED_On();
}
}
}
}
这里还只是简单示例。
真正完整的按键消抖,后面可以改成非阻塞状态机。
今天先理解:
text
用定时器 tick 决定什么时候执行任务。
三十四、Delay_ms()以后是不是完全不能用了?
不是。
Delay_ms() 仍然可以用在一些简单场景:
text
上电后短暂等待
简单调试
非常简单的小实验
外设初始化需要短延时
但是在主循环里长期使用:
c
Delay_ms(500);
就不推荐了。
因为它会阻塞主循环。
以后更推荐:
c
if((unsigned int)(now_tick - last_tick) >= 500)
{
last_tick = now_tick;
// 执行任务
}
三十五、第七天常见错误
1. 忘记打开总中断
只写:
c
ET0 = 1;
但忘记:
c
EA = 1;
中断不会响应。
2. 忘记启动定时器
忘记:
c
TR0 = 1;
Timer0 不会开始计数。
3. 中断函数编号写错
Timer0 应该是:
c
void Timer0_Isr(void) interrupt 1
如果编号写错,中断不会进入正确函数。
4. 忘记在中断里重装初值
如果用模式 1,又希望每次都是 1ms,需要在中断里重装:
c
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
5. 在中断里写Delay_ms()
不要在中断里写:
c
Delay_ms(20);
中断里不应该做阻塞延时。
6. tick变量没加volatile
不推荐:
c
unsigned int g_ms_tick = 0;
推荐:
c
volatile unsigned int g_ms_tick = 0;
因为它在中断里被修改。
7. 主程序直接读取16位tick
如果 g_ms_tick 是 16 位变量,主程序直接读取可能有一致性问题。
更推荐封装:
c
unsigned int Timer_GetTick(void)
{
unsigned int tick;
EA = 0;
tick = g_ms_tick;
EA = 1;
return tick;
}
三十六、第七天必须掌握的重点
今天必须掌握下面这些内容:
text
Delay_ms() 是阻塞式延时
定时器是单片机内部自动计数器
中断可以让 CPU 暂停主程序去处理紧急事件
Timer0 可以产生固定周期中断
1ms 系统节拍可以作为整个程序的时间基准
Timer0 interrupt 1 是 Timer0 中断函数
TH0/TL0 用来装定时器初值
TR0 用来启动 Timer0
ET0 用来允许 Timer0 中断
EA 是总中断开关
volatile 用于中断和主程序共享的变量
g_ms_tick++ 是让系统毫秒计数加 1
中断函数没有返回值,也不需要返回值
中断函数要短,不要写复杂逻辑
用 tick 差值可以实现非阻塞周期任务
三十七、第七天练习任务
任务1:解释Delay_ms()的缺点
参考答案:
text
Delay_ms() 是阻塞式延时。
执行期间 CPU 一直在空转,不能及时处理按键、串口、电池检测等其他任务。
任务2:解释什么是1ms系统节拍
参考答案:
text
定时器每 1ms 进入一次中断。
在中断中让一个毫秒计数变量加 1。
这个变量就可以作为系统运行时间基准。
任务3:写出Timer0中断函数框架
参考答案:
c
void Timer0_Isr(void) interrupt 1
{
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
g_ms_tick++;
}
任务4:写出Timer0初始化关键语句
参考答案:
c
AUXR |= 0x80;
TMOD &= 0xF0;
TMOD |= 0x01;
TH0 = TIMER0_RELOAD_H;
TL0 = TIMER0_RELOAD_L;
TF0 = 0;
ET0 = 1;
EA = 1;
TR0 = 1;
任务5:用tick实现500ms LED翻转
参考答案:
c
unsigned int now_tick = 0;
unsigned int last_led_tick = 0;
while(1)
{
now_tick = Timer_GetTick();
if((unsigned int)(now_tick - last_led_tick) >= 500)
{
last_led_tick = now_tick;
LED_Toggle();
}
}
任务6:解释volatile的作用
参考答案:
text
volatile 告诉编译器,这个变量可能会被中断等外部过程修改。
编译器不要随意优化它,每次使用时都要重新读取。
任务7:解释为什么Timer_GetTick()里短暂关闭中断
参考答案:
text
因为 51 是 8 位单片机,读取 16 位变量可能分多次完成。
如果读取过程中定时器中断修改了 tick,可能读到错误值。
所以读取前短暂关闭中断,读完后再打开中断。
任务8:解释interrupt 1的含义
参考答案:
text
interrupt 1 是 Keil C51 的中断函数说明。
interrupt 表示这是一个中断函数。
1 表示 Timer0 中断编号。
所以 void Timer0_Isr(void) interrupt 1 表示 Timer0 的中断服务函数。
任务9:解释g_ms_tick++的作用
参考答案:
text
g_ms_tick++ 表示让 g_ms_tick 自加 1。
如果 Timer0 每 1ms 中断一次,那么 g_ms_tick 就会每 1ms 加 1。
主程序可以通过读取 g_ms_tick 判断系统运行了多少毫秒。
三十八、第七天总结
今天学习的是定时器、中断和 1ms 系统节拍。
可以用一句话总结:
定时器中断的作用,是让单片机在固定时间间隔自动执行一小段代码,从而为整个程序提供稳定的时间基准。
今天最重要的理解是:
text
Delay_ms() 是让程序原地等待。
定时器 tick 是让时间在后台自动流动。
主程序不再傻等,而是根据时间差决定什么时候执行任务。
第七天达到下面程度就算合格:
text
知道为什么 Delay_ms() 不适合复杂程序
知道 Timer0 是定时器
知道中断是什么
知道 1ms tick 是什么
能看懂 Timer0_Init()
能看懂 Timer0_Isr() interrupt 1
知道 interrupt 1 表示 Timer0 中断
知道 g_ms_tick++ 是毫秒计数加 1
知道 g_ms_tick 为什么要 volatile
知道如何用 tick 差值实现 500ms LED 翻转
知道中断函数里不要写复杂代码
后续可以继续学习:
text
定时器驱动下的按键扫描
非阻塞按键消抖
按键状态机
短按、长按的基础实现
从下一天开始,按键也会从 Delay_ms(20) 的阻塞消抖,升级为更接近实际项目的非阻塞扫描方式。