C51学习-DAY7

下面这版已经整理成 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_tickunsigned 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_tickunsigned 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) 的阻塞消抖,升级为更接近实际项目的非阻塞扫描方式。

相关推荐
dtq04241 小时前
C语言刷题函数1-判断素数(分支语句,函数两种方法)
c语言·开发语言·学习
尘汐筠竹1 小时前
Day1-2 学习笔记:在 AMD 云环境上部署 Gemma 4 大模型
笔记·学习·datawhale·amdev
济6171 小时前
BMS系统专栏:认知电池管理系统BMS的知识与功能
嵌入式硬件·嵌入式·ros2·机器人开发·机器人方向
欢乐熊嵌入式编程1 小时前
第2讲:什么是优秀的软件架构?
stm32·单片机·freertos·低功耗蓝牙·嵌入式架构·efr32
Litluecat1 小时前
配合多角色提示语4,学习AI漫剧(刚开始学)
人工智能·学习·计算机视觉
嵌入式ZYXC1 小时前
第9篇:《面试题:ADC前端为什么要加运放跟随器?什么情况下可以不加?》
stm32·单片机·嵌入式硬件·面试·职场和发展
AOwhisky1 小时前
学习自测与解析:Redis系列第一期与第二期核心知识点详解
运维·数据库·redis·学习·云计算
zhangrelay2 小时前
个体智能大模型使用的主观数据复盘-节选-2026-
笔记·学习·课程设计
lunzi_08262 小时前
【学习笔记】《Python编程 从入门到实践》第9章:类、继承、组合与面向对象编程
笔记·python·学习