您的位置:首页 > 娱乐 > 八卦 > 成品网站源码下载_建立网站代码_搜索引擎营销的方式_java培训班学费一般多少

成品网站源码下载_建立网站代码_搜索引擎营销的方式_java培训班学费一般多少

2025/3/14 10:43:25 来源:https://blog.csdn.net/h050210/article/details/146243743  浏览:    关键词:成品网站源码下载_建立网站代码_搜索引擎营销的方式_java培训班学费一般多少
成品网站源码下载_建立网站代码_搜索引擎营销的方式_java培训班学费一般多少

此博客从最底层的硬件定时器原理开始,详细讲解如何实现基于软件定时器的多任务调度,并最终解析一段代码。

这将涵盖以下内容:

  1. 硬件定时器工作原理:包括定时器结构、寄存器、模式以及如何配置和使用。
  2. 软件定时器的实现:如何基于硬件定时器来构建软件定时器,实现任务调度。
  3. 多任务调度的核心逻辑:如何利用软件定时器实现多个任务的周期性执行。
  4. 代码详细解析:逐行分析代码,解释其实现方式。

1. 硬件定时器原理

定时器的基本概念

**定时器(Timer)**是在计算机和微控制器系统中用于测量时间间隔或产生精确定时事件的硬件模块。简单来说,定时器就是一个能够按照固定频率自动递增或递减的计数器。当计数器达到预先设定的值(如计数器溢出或者与某个比较值匹配)时,定时器可以产生一个事件(例如触发中断或改变输出信号)。由于定时器能够精确地控制事件发生的时间,因此它被广泛应用于各种需要时间控制的场景,例如延时、信号测量、脉冲生成和任务调度等。

基本工作原理: 定时器通常由一个时钟源驱动,这个时钟源可以是微控制器的主时钟(例如晶振产生的时钟)或者分频后的时钟。每当时钟信号到来时,定时器的计数器寄存器(Counter)就加一(有的定时器是加一计数,有的是减一计数,这取决于具体硬件设计)。计数器不断累加(或减小)表示时间的流逝。当计数器达到其计数范围的极限值(例如最大值溢出回零,或从初始值减至零)时,就表示预设的一段时间已经过去。此时,定时器可以向CPU发出一个中断请求,通知处理器“时间到了”。

计数单位: 定时器计数的最小时间单位由其时钟源决定。如果时钟源频率为$f$,则定时器每计数一次对应的时间间隔为$1/f$秒。例如,如果定时器时钟频率为1 MHz(每秒1,000,000次脉冲),那么定时器计数器每增加1代表经过了1微秒。如果想让定时器测量1毫秒的时间,就需要让计数器累积到1000(因为1000微秒 = 1毫秒)。这个简单的关系使得我们可以通过选择合适的计数起始值或预分频系数,来实现所需的定时精度。

需要注意的是,不同硬件平台的定时器可能工作在递增计数递减计数模式下:

  • 递增计数模式:计数器从0开始往上数,一直数到预定的最大值(例如8位计数器数到255)然后溢出清零。
  • 递减计数模式:计数器从一个初始装入值开始往下数,数到0时触发溢出/中断,然后自动重新装入初始值再次递减。

无论采用哪种方式,核心思想都是计数器经过固定数量的计数步骤后触发一个事件,这个固定数量对应于特定的时间长度。

硬件定时器的组成结构(计数器、控制寄存器、模式)

一个硬件定时器通常由以下几个关键部分组成:

  • 计数器寄存器(Counter Register):这是定时器的核心部件,用于存放当前的计数值。计数器的位宽(如8位、16位、32位)决定了它能表示的最大计数值,从而影响最大可测量的时间长度。例如,8位计数器的计数范围是0255,16位计数器的范围是065535。计数器可以递增或递减计数,其当前值通常是可以被程序读取的,以获取经过的时间滴答数。

  • 控制寄存器(Control Register):控制寄存器包含用于配置定时器工作方式的位。如启用定时器的开关、选择计数时钟源、设置预分频系数、选择工作模式(单次或连续)、启用中断、清除中断标志等。通过向控制寄存器写入不同的值,软件可以控制定时器的行为。例如,在AVR单片机中,TCCR0(Timer/Counter Control Register 0)就是用于配置定时器0的控制寄存器,可以设置计数模式和时钟分频等参数。

  • 预分频器(Prescaler):预分频器并非一个独立的寄存器,但通常作为定时器的一部分存在。它的作用是对输入时钟频率进行分频,从而降低定时器计数的频率。通过预分频,可以使用一个高速的主时钟却得到相对较慢的计数速度,以获得较长的延时或减轻CPU负担。例如,假设主时钟为16 MHz,如果直接驱动定时器计数,每计数一步是62.5纳秒,这对于产生1秒延时需要计数约1600万次,这超出了一个8位/16位计数器的范围。借助预分频器(比如分频1024),计数频率可以降低到15625 Hz,每计数一步约64微秒,此时16位计数器累加到15625就接近1秒,非常方便。预分频器的系数通常可以在几档固定值之间选择(如2、4、8、16...1024等)。

  • 比较寄存器(Compare Register):许多定时器具有比较匹配单元,即一个或多个比较寄存器。你可以将比较寄存器设置为一个值,当计数器计数到与该比较值相等时,硬件可以触发一个事件(例如产生中断或切换一个输出引脚的电平)。这种比较机制允许定时器在还没溢出前就产生精确定时事件。例如,在16位定时器中,可以将比较值设为 49999,当计数器从0增至49999时就触发中断,而不必等到65535溢出,产生一个特定时间长度(50000个计数周期)的定时。

  • 模式配置:定时器通常支持多种工作模式,这往往通过控制寄存器中的特定位来配置。例如:

    • 基本定时模式:计数器从起始值开始计数,到达终点(0或最大值)时产生一次中断,然后根据设置决定是否停止还是重新开始。
    • 自动重装载模式:计数器达到终点后自动重新加载预设的起始值继续计数,实现周期性重复运行(常用于周期定时)。
    • PWM模式(Pulse Width Modulation):定时器结合比较寄存器,可以产生PWM波形。在这种模式下,定时器每经过一个周期,会根据比较值改变输出引脚的电平,从而产生占空比可控的方波信号。
    • 输入捕获模式:某些定时器可以配置为检测输入信号的时序,例如测量外部引脚上脉冲的高电平或低电平持续时间。这种模式下,当检测到外部事件时,硬件会将当时计数器的值“捕获”到捕获寄存器,供软件读取,以测量事件的时间间隔。

综上,硬件定时器通过计数器+控制逻辑的组合,实现了对时间的测量与控制。软件通过配置控制寄存器中的各个选项,可以让同一个定时器扮演不同的角色,例如纯粹计时、产生PWM信号或者作为事件计数器等。

定时器的工作方式(单次、周期、PWM模式等)

正如上文提到的,硬件定时器有多种工作方式,可以满足不同应用需求。以下是几种主要的定时器工作模式及其特点:

  • 单次定时模式(One-shot):在这种模式下,定时器从设定的初值开始计数,当计数器达到终点时(比如计数到0或者发生上溢)触发中断或标志,然后定时器自动停止。如果需要再次使用定时器,必须由软件重新配置或启动。单次模式适用于需要精确延时一次的场景,例如上电后等待一段时间再执行某任务等。某些硬件定时器没有单次停止的特性(即溢出后自动重启),那么可以通过在中断服务程序中手动关闭定时器来模拟单次行为。

  • 周期定时模式(Periodic / Continuous):也称自动重装载模式。定时器不断循环运行:当计数器溢出或达到比较值触发中断后,硬件会自动将计数器重置为初始值(或从0重新开始计数),继续下一轮计时。这样就会周期性地产生中断或事件。大多数应用中都会使用周期模式来产生固定频率的“节拍”(tick),用于周期性任务调度、周期信号输出等。例如,操作系统内核常使用一个定时器以固定频率产生中断作为系统节拍(如每1毫秒一次)用于时间片轮转和软定时器管理。

  • PWM模式(Pulse Width Modulation,脉冲宽度调制):PWM模式是一种特殊的周期模式。在PWM模式下,定时器不仅会产生周期性计数溢出,还可以在计数期间根据比较寄存器改变输出引脚状态,从而输出一系列方波脉冲。这些脉冲的周期由定时器周期决定,而脉冲的高电平持续时间(宽度)由比较值决定。通过调整比较值,相对于固定周期改变高电平占整个周期的比例(即占空比),就可以控制输出能量或平均电压。例如,用PWM可以控制LED的亮度(占空比越高,看起来越亮)或者电机的转速。

  • 输入捕获模式(Input Capture):在此模式下,定时器充当事件测量工具。当外部引脚发生特定变化(如从低电平跳变到高电平)时,硬件立即将当前计数器的值保存到捕获寄存器中,并产生中断通知软件。软件读取捕获的计数值,可以计算出两个事件之间的时间差。这常用于测量信号的频率或脉宽,例如红外测距中的回波脉冲宽度测量、编码器信号频率测量等。

  • 计数模式(Counter Mode):有些定时器可以配置为计数外部事件的次数,而非时间。在计数模式下,定时器的时钟来源不是内部时钟,而是外部引脚上的脉冲信号。每当外部信号产生一个脉冲,计数器就加一。因此可以用来计数事件发生次数,例如计数一秒内有多少个脉冲,从而测出脉冲频率。

需要强调的是,不是所有微控制器的定时器都支持以上所有模式,具体模式名称和功能也可能根据硬件不同而略有差异。但**单次(One-shot)周期(Periodic)**是最基本和常见的工作方式,PWM和输入捕获则是扩展功能。在使用时,应查阅具体芯片的定时器模块手册,了解它支持哪些模式以及如何配置。

如何配置硬件定时器(设置初值、溢出中断等)

使用硬件定时器时,软件通常需要按以下步骤进行配置:

  1. 选择定时器和时钟源:许多微控制器有多个定时器可用,需要选择其中一个(如Timer0, Timer1等)。然后选择定时器的时钟源和预分频系数。例如,可以选择使用内部主时钟或者外部引脚作为计时基准,并通过预分频器调整计数速度。在配置寄存器中通常有对应的位字段来选择时钟和预分频值。

  2. 设置工作模式:根据应用需要,在控制寄存器中配置定时器的模式。例如,选择是定时模式(基于内部时钟)还是计数模式(基于外部脉冲),是单次还是自动重装载,是否启用PWM输出等等。这一步决定了定时器的行为方式。以典型的单片机为例,8051单片机的TMOD寄存器可以配置定时器工作在方式0/1/2(不同位宽或自动重载),AVR单片机的TCCR寄存器可以配置定时器工作在Normal、CTC、PWM等模式。

  3. 设置计数初值或比较值:如果使用溢出模式(计数到最大值),通常可以通过加载计数器初值来调整实际定时时间。比如,我们希望定时器在计数N个 tick 后溢出,那么对于递增计数的定时器,应将计数器初值设置为(最大值-N);对于递减计数的定时器,则将初值直接设置为N。一旦定时器开始运行,它会从这个初始值开始计数。在预定的计数步数后达到终点触发事件。示例:假设有一个8位递增定时器(最大值255),时钟频率经过预分频后使计数器每走一步是1微秒,我们想要产生1毫秒(1000微秒)的定时中断。计数器需要计数1000次,但8位计数器溢出需要256次。解决办法是使用多次溢出或者更大的计数器。对于8位计数器,可以通过设置初值来缩短实际计数范围。例如,将初值设置为256-1000 mod 256 = 256-232=24(但1000超过了255的一个周期,需要考虑溢出次数,本例仅作概念说明)。更简单的做法是调整预分频使1计数步对应更长时间,比如预分频64,则计数器频率约15625 Hz,每步64微秒,那么计数16步≈1毫秒。此时我们可以将初值设为256-16=240。定时器每溢出一次(计数16步)即约1ms触发中断。类似地,对于16位定时器,要产生1 ms延时,如果不使用预分频,16 MHz主频下需要计数16000次,可将初值设为65536-16000=49536(十六进制0xC150),定时器从49536开始计数,数到65535溢出,经历16000个计数周期正好1ms。

  4. 启用中断(如果需要):通常我们希望定时器在达到计时目标时产生中断,以便CPU能够感知并处理对应的事件。这需要在定时器的中断掩码寄存器中使能该定时器的中断。例如,在AVR中需要设置TIMSK寄存器的相应位,在ARM Cortex-M中需要在NVIC中使能定时器或SysTick的中断。在启用定时器中断的同时,还需要确保CPU全局中断使能是打开的(许多微控制器有一个全局中断使能位,如EI/DI指令或sei()/cli()函数,用于打开/关闭中断响应)。

  5. 启动定时器:最后,设置开始运行定时器的位(通常是控制寄存器中的启用位)。比如,在很多单片机中有一个“启动”位或直接写控制寄存器的某个值就会启动。例如8051中可以通过TR0 = 1启动Timer0,STM32的定时器通过设置CR1中的CEN位启动计数。启动后,定时器就按照配置开始计数。

配置完成并启动后,定时器就会独立于CPU运行。软件可以选择:

  • 中断方式:由定时器硬件在合适时间自动打断CPU,执行预先编写好的中断服务程序(ISR)。ISR中一般会执行定时器触发时应有的操作,比如更新某个全局变量、切换某个LED状态或者唤醒某个等待的任务。
  • 查询方式(轮询):CPU不启用中断,而是在主程序中定期检查定时器的状态,例如轮询定时器的溢出标志位,发现其置位时便手动清除并执行相应操作。这种方式实现也比较简单,但缺点是需要不断占用CPU去检查标志,会浪费一些处理器资源,且如果检查不及时,可能错过定时事件或有延迟。

例子:硬件定时器配置
下面给出一个伪代码示例,演示如何配置一个简单的硬件定时器以产生周期中断(假设我们有相关寄存器定义):

// 假设一个16位定时器 Timer0,主时钟16MHz,我们希望每1ms触发一次中断。
void Timer0_Init_1ms() {T0_STOP();                 // 确保定时器停止状态下配置T0_SELECT_CLOCK_INTERNAL(); // 选择内部时钟T0_SET_PRESCALER(64);      // 设置预分频器为64 (将计数频率降为16MHz/64 = 250kHz)T0_SET_MODE_PERIODIC();    // 设置为周期定时模式(自动重载)// 计算比较值或初值: 250kHz下1ms需要250个计数T0_SET_RELOAD_VALUE(65536 - 250); // 如果是递增到溢出模式,则初值=65536-250T0_CLEAR_FLAG();           // 清除溢出标志(防止误触发)T0_ENABLE_INTERRUPT();     // 开启定时器中断T0_START();                // 启动定时器
}

以上伪代码中,我们假设有一些宏或函数用于配置定时器各项参数。在实际平台上,这些操作需要针对具体寄存器。例如在AVR ATmega系列单片机上,实现1ms定时可以这样做:

// AVR ATmega 定时器0配置为1ms中断
TCCR0A = 0x00;               // 普通模式 (Normal Mode)
TCCR0B = 0x03;               // 时钟选择:预分频32(假设0x03对应预分频32)
// 计算初值:16MHz/32 = 500kHz,即周期2微秒,1ms需要500计数
TCNT0 = 256 - 500 % 256;     // 装入初值(对于8位计数器需要分多次溢出,此处仅演示概念)
TIMSK0 |= (1 << TOIE0);      // 使能定时器0溢出中断
sei();                       // 全局使能中断

(注:上述具体数值根据芯片有所不同,仅为说明定时器配置流程,实际ATmega定时器0最大预分频为1024,此处预分频32仅做演示。)

通过上述配置,当定时器每累计约1毫秒时间,计数器溢出时就会自动触发中断。接下来,我们可以在中断服务程序中处理具体的任务。

硬件定时器的应用(定时、测量、PWM输出)

硬件定时器的灵活性使其在嵌入式系统中有着广泛的应用,下面列举几种常见用途:

  • 精确定时/延时:利用定时器产生的中断可以实现精确定时功能。例如,要让某个LED每隔一段时间闪烁一下,可以设置定时器按该时间间隔产生中断,在中断中翻转LED的状态。相比软件中使用空循环延时的方法,硬件定时器具有精度高且不占用CPU的大部分时间的优点。许多操作系统的系统滴答(SysTick)也是通过硬件定时器实现的,每毫秒或每10毫秒触发一次中断,用于维护系统的时基。

  • 事件计数与测量:通过定时器的输入捕获模式计数模式,可以测量外部事件的时序。例如:

    • 捕获模式:测量两个脉冲上升沿之间的计数差,换算成时间可以得出频率或周期。这常用于测量测速传感器、超声波测距的回波时间等。
    • 计数模式:在一定时间窗口内使用定时器计数外部脉冲的数量。例如测量转轴每秒转过多少脉冲,从而计算转速。计数模式下,定时器相当于一个计数器,记录事件发生次数。
    • 时间戳:利用定时器读取当前计数值,可以为事件打上时间戳。例如在通信协议中记录发送或接收某数据包的时间,用于超时判断。
  • PWM输出:硬件定时器最常见的高级用途就是产生PWM信号。如前所述,在PWM模式下,定时器配合比较寄存器可以产生占空比可调的方波信号。应用例子包括:LED调光(改变占空比控制亮度)、舵机控制(舵机通过PWM脉冲宽度控制转角)、直流电机调速等。在这些应用中,硬件定时器直接控制输出引脚电平翻转,生成精确的时序波形,比软件用CPU来翻转IO口要精确和高效得多。

  • 系统时钟和日历:一些系统利用定时器的定时中断来更新实时时钟或日历。例如每隔1秒产生中断,在中断中把秒表计数+1,并按60进制进位更新分钟、小时,从而实现时钟功能。这通常结合一个长期运行的定时器(比如使用32.768 kHz晶振的实时时钟RTC)。

  • 多任务调度:定时器可以用于操作系统的心跳(heartbeat)或调度滴答。通过周期性中断,操作系统可以检查各个任务的状态,决定是否需要切换任务或启动某些延时到期的任务。稍后将详细讨论的软件定时器,其基础往往就是一个硬件定时器提供的固定节拍中断。

简而言之,硬件定时器提供了一个可靠的“节拍”,让我们能够以确定的时间间隔触发代码执行或硬件行为。不论是简单的闪LED,还是复杂的测量控制,定时器都是嵌入式开发中不可或缺的工具。

2. 软件定时器的实现

软件定时器的概念

有了硬件定时器,我们能够产生精确的时间间隔信号。然而,硬件定时器的数量通常有限(比如一个微控制器可能只有2个或4个定时器模块),而我们的应用可能需要同时管理许多定时事件。这时就引入了**软件定时器(Software Timer)**的概念。

软件定时器是构建在硬件定时基础上的一种机制。通过软件手段,我们可以在一个硬件定时器提供的单一“心跳”下,模拟出多个独立运行的定时器。每个软件定时器都可以设定不同的超时时间和执行不同的动作,从而在逻辑上实现“多路定时”。

简单来说,软件定时器的思想就是:**利用一个硬件定时器产生的周期性中断,在中断或主循环中由软件去更新和检查多个定时事项的倒计时,当某个定时达到设定值(超时)时,触发相应的处理。**因为这些定时的维护和触发动作由软件完成,所以称为软件定时器。

软件定时器的优点包括:

  • 数量灵活:可以根据需要创建任意多个定时器(受限于内存或处理能力),而不局限于硬件定时器的个数。
  • 时间精度:软件定时器的精度取决于基础硬件定时器的精度。常见地,我们用硬件定时器产生固定的tick(比如1毫秒一次),那么软件定时器的超时精度就可以达到1毫秒级别(或更高,如果tick更快)。
  • 易用接口:可以通过软件封装出易于使用的接口,如启动一个定时器设定T毫秒后执行某函数,而不用直接操纵底层寄存器。
  • 统一管理:通过软件定时器模块,可以集中处理所有的定时事件,例如在一个地方检查哪些超时发生,而不需要分散在多个硬件定时器的中断中编写代码,提高代码组织性。

当然,软件定时器的运行依赖于CPU在定时中断中执行软件逻辑,因此在大量软件定时器同时运行或定时精度要求极高的情况下,会增加CPU负担。不过在大多数应用中,这种开销是可以接受的,尤其相比为了每个定时任务都占用一个硬件定时器而言,软件定时器的性价比非常高。

如何基于硬件定时器构建软件定时器

构建软件定时器的基本前提是:硬件定时器能够周期性地以一个固定频率触发中断(tick)。 我们称这个基础硬件定时器为“时基”或“滴答定时器”(tick timer)。典型地,会把硬件定时器配置为比如每1毫秒或每10毫秒产生一次中断。每当中断发生,就表示全局时间又前进了一小步(tick)。

有了这一固定节拍,构建软件定时器的一般思路如下:

  1. 全局时基计数器:维护一个全局的计数变量(通常称为系统时钟tick计数等),在每次硬件定时器中断时递增。例如,每次1ms中断发生就将全局tick计数加1。这样,某种意义上这个全局计数器记录了系统启动以来流逝的时间(以tick为单位)。它可以用于相对计时或时间戳。

  2. 软件定时器数据结构:为每个需要的“软件定时器”创建一个记录结构,保存该定时器的状态信息,例如:

    • 定时时长:这个定时器需要等待的tick数(超时时间)。
    • 剩余时间:当前距离定时到期还剩多少tick,会在运行过程中动态减少。
    • 周期标志:标识这个定时器是一次性的(one-shot)还是周期性的(到期后自动重新开始计时)。
    • 回调函数或任务ID:定时到期时需要执行的动作,可以用函数指针指向需要调用的函数,或者用标志通知主循环去执行某任务。
    • 启用/活动标志:标记这个软件定时器当前是否在运行(有的定时器可能暂时停用)。

    这些信息可以存在于一个结构体中。例如:

    typedef struct {uint32_t period;      // 定时时间长度(tick数)uint32_t remaining;   // 剩余tick数uint8_t periodic;     // 是否周期重复,1=周期定时,0=一次性void (*callback)(void); // 超时回调函数uint8_t active;       // 是否激活
    } SoftTimer;
    

    我们可以用一个数组或链表来存放多个SoftTimer实例,从而管理多个软件定时器。

  3. 初始化软件定时器:当需要启动一个新的软件定时器时,初始化它的各项字段。例如设定period(总计时时长),将remaining初始赋值为period(表示还需计满period个tick),设置periodic标志决定是否循环,设置回调或任务处理函数,并将active标志置为1表示正在运行。对于一次性的定时器,到期后可以将active清零或直接移除。对于周期性的,到期后将remaining重新加载为period继续运行。

  4. 在中断中更新:关键的部分是在硬件定时器的中断服务程序(ISR)中,去更新所有激活的软件定时器。通常做法是在每次tick中断中,遍历所有活动的软定时器,将它们的remaining倒计时减一(或者对比当前时间)。举个例子:

    ISR_TimerTick() {global_tick++;  // 更新全局时间for (int i = 0; i < timer_count; ++i) {if (SoftTimers[i].active) {if (SoftTimers[i].remaining > 0) {SoftTimers[i].remaining--;if (SoftTimers[i].remaining == 0) {// 定时到期SoftTimers[i].callback_flag = 1; // 标记需要执行回调if (SoftTimers[i].periodic) {// 周期性定时器,重装载remainingSoftTimers[i].remaining = SoftTimers[i].period;} else {// 一次性定时器,标记为不活动SoftTimers[i].active = 0;}}}}}
    }
    

    上述伪代码展示了一个思路:每次tick,将每个活动定时器的剩余时间减1。如果减到0,说明该定时器超时,需要触发相应动作。为了不在中断中做过多事情,这里用callback_flag(或者可以直接置一个标志,或者发消息通知主循环)来记录需要执行哪个定时器的回调,而真正的回调函数调用放在主循环中进行。这样做可以缩短中断处理时间,提高系统响应效率。

  5. 主循环中处理超时事件:由于在中断中只是标记了定时器超时事件,真正执行任务的代码可以在主循环(或称调度器)中运行。主循环负责检查哪些软件定时器的超时标志被设置,然后调用其对应的处理函数(或者将任务放入执行队列)。例如:

    void process_timers() {for (int i = 0; i < timer_count; ++i) {if (SoftTimers[i].active && SoftTimers[i].callback_flag) {SoftTimers[i].callback_flag = 0;// 调用回调函数if (SoftTimers[i].callback) {SoftTimers[i].callback();}}}
    }
    

    处理完回调后,如果是一次性定时器且已经标记为不活动,则下次将不会再处理它;如果是周期性的,则它已经在ISR中重新加载了remaining,接下来继续倒计时等待下一次超时。

通过上述机制,我们使用一个硬件定时器(tick定时器)就实现了多个软件定时器的并行计时。每个软件定时器彼此独立运行,它们可以有不同的初始计时值,也可以在不同的时间启动和停止。硬件定时器提供统一的时间基准,而软件通过计算与判断来实现多个超时调度。

需要注意,多软件定时器实现的关键在于定时精度和开销的平衡

  • Tick越短(中断频率越高),软件定时器能够提供的最小时间间隔就越精细,但CPU需要更频繁地响应中断,开销增大。
  • Tick越长,中断频率低则开销小,但软定时器的时间分辨率变粗,可能无法精确计时较小的时间间隔(例如tick=10ms就无法精确地等待5ms的事件,只能近似到最近的10ms)。

常见情况下选择1ms或10ms作为tick,这对大多数人眼可察觉的事务(LED闪烁、任务超时等)已经足够,并且中断处理开销在大部分微控制器上也是可接受的。

设计软件定时器的数据结构(定时器列表、超时时间管理)

如前所述,软件定时器需要一种数据结构来管理多个定时器的信息。设计这个数据结构和管理机制时,需要考虑以下几点:

  • 我们需要存储哪些信息(前面已经提及:定时长度、剩余时间、状态、回调等)。
  • 我们需要高效地更新定时器以及查找哪些定时器到期。
  • 数据结构应尽量简单明了,方便添加或删除定时器。

1. 使用数组管理定时器:
对于定时器数量不多且较固定的情况,最简单的方式是使用一个数组或静态分配的列表来保存所有软定时器。例如:

#define MAX_TIMERS 10
SoftTimer timers[MAX_TIMERS];
int timer_count = 0;

并使用timer_count记录当前使用的定时器数量或者直接固定大小遍历整个数组。每个数组元素是一个SoftTimer结构,保存各自的状态。

添加新的软件定时器时,在数组中找一个空闲的(inactive)槽位填入。当不再需要时,将对应槽位标记为空闲(可以把active设为0,或者用特别的remaining值如0表示未使用——不过0也可能是到期值,因此还是独立的active标志更清晰)。

数组管理的优点是简单直接,访问特定索引定时器的开销低。但缺点是在添加删除定时器时需要遍历寻找空位,而且若定时器很多,每次tick中断要遍历整个数组检查并递减,可能造成时间开销与定时器数量线性相关。

2. 使用链表管理定时器:
链表可以动态地增加或移除定时器节点,不需要固定大小。但遍历更新依然需要访问每个节点,复杂度仍是O(n)每tick。对于几十个定时器的应用,这通常没问题;但如果有成百上千的软件定时器同时运行,则每毫秒遍历上千项可能勉强。这种情况下,需要考虑优化算法。

3. 优化的数据结构:
为了提高效率,可以使用一些优化策略:

  • 定时器最小堆(min-heap):按剩余时间长短构建一个小顶堆,每次tick减少全局最短的时间。当某个定时器到期后,从堆顶取出处理,然后如果是周期的再重新插入堆。堆可以避免每次遍历所有定时器,只关注最小值,但对实现要求高一些,也有维护堆的开销(添加删除是O(log n))。
  • 分层轮转槽(wheel timer):这是一些高级操作系统中使用的方法,把未来的tick分成不同槽位分布,降低每个tick需要检查的项数。不过在一般嵌入式开发中很少自己实现这么复杂的结构,除非定时器数量巨大或者需要非常高效的定时管理。

对于大多数简单应用,数组或链表实现的软件定时器已经足够,而且实现容易理解。我们在设计数据结构时可以额外考虑:

  • 为了方便调试,可以给每个定时器加一个ID或者名字字段。
  • 如果需要支持优先级或者不同精度,可以在结构中加入优先级字段,但通常调度优先级和定时器无关,而是任务调度层处理的事(后面讨论任务调度时会提到)。
  • 考虑溢出处理:如果使用绝对计时(比如记录将来的截止tick时间戳,而非剩余tick数),要注意全局tick溢出时(32位tick若每毫秒增加,一般可运行数十天以上溢出),计算两个时间戳差值的方法需要防止溢出误判。当然,对于一般微控制器运行时间不太可能连续数十天,或者我们可以通过更高位计数器来避免这个问题。

数据结构示例:
综合上面的讨论,我们可以定义如下的软件定时器结构和管理变量:

#define MAX_TIMERS 10typedef void (*TimerCallback)(void);typedef struct {uint32_t period;       // 定时时间长度(tick数)uint32_t remaining;    // 剩余时间tickuint8_t periodic;      // 是否为周期定时器uint8_t active;        // 是否在运行(0=未使用,1=正在运行)TimerCallback callback;// 超时要调用的回调函数
} SoftTimer;SoftTimer timers[MAX_TIMERS]; // 定时器数组

当需要新建一个定时器时,先找到timers数组中active == 0的空闲项,然后初始化:

void start_timer(uint32_t timeout, uint8_t periodic, TimerCallback cb) {for (int i = 0; i < MAX_TIMERS; ++i) {if (!timers[i].active) {              // 找到未使用的槽位timers[i].period = timeout;timers[i].remaining = timeout;timers[i].periodic = periodic;timers[i].callback = cb;timers[i].active = 1;break;}}
}

停止或删除一个定时器可以简单地将其active置0(以及可选地清除其他字段),这样在更新时就不再处理它。

软件定时器的更新机制

软件定时器更新指的就是在每个tick中对所有软件定时器的剩余时间进行递减和检查。这里我们详细看看这一机制,包括中断中更新和主循环中处理的配合。

如前所述,在硬件定时器的中断服务程序中,我们通常会执行如下操作:

  1. global_tick++ :增加全局tick计数(如果需要全局时间)。
  2. 遍历所有软件定时器:
    • 对于每个active的定时器,将其remaining减1(假设tick单位和定时器超时时间单位一致)。
    • 如果减1后remaining变为0,表示该定时器超时:
      • 将一个标志(可以是定时器结构内的一个标志位,如增加一个expired字段,或者用前面提到的callback_flag)设置为1,表示需要执行回调。
      • 如果是周期性定时器,则将remaining重新赋值为period,为下一次计时做准备;如果是一次性定时器,则将其active置0或标记为无效,表示不再继续运行。
  3. 退出中断服务程序。

需要注意的是,在中断中不建议直接调用回调函数或者执行复杂的任务逻辑。原因是:

  • 中断处理应该尽量简短、快速,避免占用太长时间,影响系统实时性。
  • 许多回调任务可能涉及与其他代码的交互,如果在中断中执行,可能造成并发问题或与主循环争用资源(除非采用更复杂的同步机制)。
  • 某些API或函数在中断上下文中是不可用的(比如操作系统的某些调度函数等)。

因此,常见模式是在中断中仅仅记录哪个定时器到了,然后将实际处理延后到主循环。这通常通过标志位事件队列实现:

  • 标志位:为每个定时器增加一个标志,或者使用类似前面结构中的TimerCallback callback直接存函数指针。中断中把需要执行的任务标记出来(或者甚至直接将该任务对应的函数指针存到一个待执行列表中)。
  • 事件队列:中断将超时事件ID放入一个队列(如环形缓冲区),主循环轮询队列非空时取出事件ID进行处理。队列的好处是可以记录发生的顺序,但在简单场景下标志位也足够,因为通常不太会同一个tick内出现太多事件同时到期。

主循环处理:主循环即我们平时写的while(1)或操作系统的空闲循环。在使用软件定时器的情况下,主循环会每次迭代检查是否有定时器超时事件需要处理。伪代码例如:

while (1) {// ...其他可能的处理...// 检查软件定时器超时for (int i = 0; i < MAX_TIMERS; ++i) {if (timers[i].active && timers[i].remaining == 0) {// 定时器i超时需要处理timers[i].remaining = timers[i].period; // 如果是周期定时,准备下一次if (!timers[i].periodic) {timers[i].active = 0; // 若是一次性,标记为停止}// 执行回调if (timers[i].callback) {timers[i].callback();}}}// ...可能的低功耗等待或其他任务...
}

在上面的逻辑中,我们直接通过判断remaining == 0来判断超时,并在主循环中完成回调调用和重载。需要保证的是,在回调执行前不让定时器再次更新。通常可以在中断中将remaining减到0就暂时停住(等待主循环处理)。当主循环一看到0就触发回调,并在此时如果是周期的再重置remaining。如果担心主循环可能因其他事务延迟处理,这种设计也可以改为使用一个独立的标志如expired来更明确地控制。同样也可以采用事件队列模型,这里就不展开了。

线程安全考虑:如果系统中只有一个主循环和中断,不存在操作系统多线程,那上述机制已经可以正常工作。但如果在RTOS环境,有多个线程可能操作软件定时器数据结构,则需要考虑加锁或中断屏蔽等防护,防止竞态条件。幸好在裸机系统或简单循环系统中,定时器结构通常是主循环和中断专用,不会有第三方访问,因此只要在访问共享数据(如timers数组)时禁用中断或使用原子操作,就能避免并发问题。例如:

ISR_TimerTick() {// 更新定时器,需是原子操作防止主循环同时修改for (...) {// ...更新逻辑...}
}

主循环如果需要增删定时器,也应当在临界区进行(或者在添加删除前临时关闭定时中断)。

任务调度逻辑

在实现软件定时器的基础上,我们就可以利用它来调度多任务的执行。调度逻辑指的就是决定何时执行哪些任务,使得任务可以按照各自的时间要求运行。在一个基于软件定时器的简单系统里,任务调度通常采取**合作式(cooperative)**方式,即任务不会抢占(preempt)正在运行的其他任务,而是通过定时器或事件触发后,在主循环中一个接一个地运行。

任务调度逻辑可以概括为:

  1. 任务注册:把需要执行的任务及其执行周期(或延迟)注册到软件定时器系统中。例如任务A需要每隔T_A毫秒运行一次,就设置一个周期性软件定时器T_A,超时回调关联到任务A的执行函数。任务B想在未来延迟X毫秒后执行一次,就设置一个一次性软件定时器X,到时调用任务B的函数。
  2. 时间驱动触发:软件定时器机制会根据tick推进,当相应时间到达时,标记任务需要执行。这相当于把任务放到“就绪”状态。
  3. 主循环调度:主循环扮演一个简单的调度器,不断检查有哪些任务处于就绪状态(通常通过前面说的定时器expired标志或任务队列)。对于就绪的任务,按照某种策略取出一个来执行。
  4. 任务执行:调用相应的任务处理函数,执行任务内容。执行过程中通常是在主循环上下文,除非该任务自己再开中断或多线程,否则不会被别的任务中途打断(在合作式调度中)。
  5. 任务完成/切换:当任务执行完或主动yield(让出CPU)后,控制返回主循环调度器,然后调度器再检查是否还有其他任务就绪需要立即执行,如果有则执行下一个任务;如果没有就绪任务,则可能进入休眠等待下一个tick中断,或者进行空闲处理(例如省电模式等)。

基于软件定时器的调度通常是时间驱动的合作式调度

  • 时间驱动:因为任务的激活由定时器(时间到达)来触发。当然也可以混合事件驱动,比如串口接收完成也可看作一种任务触发源,只是那属于另一种中断驱动的事件,不在此讨论范围。这里集中在定时任务。
  • 合作式:同一时间只有一个任务在运行,任务自己不抢占,因此任务需要尽量短小或定期主动检查让出,以免阻塞其他任务过久。

这样做的好处是实现简单,无需复杂的上下文切换,缺点是无法保证实时性和优先级(一个低优先级但长时间运行的任务会阻塞高优先级任务)。

我们可以举个简单例子来说明调度逻辑:

  • 系统需要同时管理两个LED的闪烁:LED1每500ms翻转一次,LED2每1000ms翻转一次。我们为此创建两个软件定时器:Timer1(500ms,周期性),Timer2(1000ms,周期性)。它们的回调函数分别是toggle_LED1()toggle_LED2(),这些函数就是我们要调度执行的“任务”。
  • 初始时,两任务都未到时间。定时器每1ms tick更新remaining。
  • 当经过500ms后,Timer1的remaining倒数至0,软件标记“LED1任务就绪”。
  • 主循环检查到LED1任务就绪,于是调用toggle_LED1(),执行完毕后重置Timer1剩余时间为500ms再次等待下一次。
  • 在1000ms时,Timer1和Timer2可能同时就绪(如前所分析,在1000ms时LED1正好第二次到期,LED2第一次到期)。假设我们的调度逻辑是顺序检查Timer1然后Timer2,那么会先处理LED1后处理LED2。执行顺序可以根据需要调整,但通常这种简单系统里顺序固定就是一种隐含的优先级了。
  • 如此反复,两个LED就按照不同周期闪烁,实现了类似多任务的并发行为。

任务优先级管理(如适用):在上述简单循环中,每个任务其实占据CPU的时间极短(翻转一个LED电平),因此先后顺序无关紧要。但如果出现这样一种情况:任务A和任务B都到期就绪了,A是高优先级任务需要尽快执行,而B优先级低可以稍后。这时,我们希望调度器能识别任务A和B的优先级并优先运行A。要支持这一点,我们可能需要在软件定时器或任务描述中增加优先级字段,并在主循环调度时按优先级选择任务,而不是简单按固定顺序或先到先服务。

然而,实现优先级就涉及更复杂的调度结构,比如维护不同优先级的就绪队列等。对于基于软件定时器的简易调度,大多数场合并不实现显式的优先级,而是通过设计时合理选取任务顺序以及让任务尽快执行完来间接达到目的。如果需要严格的优先级控制和实时抢占,往往会上一个真正的实时操作系统(RTOS),利用内核来做调度而不仅仅是软定时器。

总结来说,多任务调度逻辑在本质上就是:

  • 什么时候任务变为可执行? —— 由定时器超时(或其他事件)决定。
  • 由谁来执行可运行的任务? —— 由一个简单调度循环在适当的时候调用相应函数。
  • 同时有多个任务时怎么办? —— 简单系统中顺序执行或根据需要增加优先级规则。

接下来,我们将结合上述理论,通过一个具体的C语言代码示例来演示如何实现“基于软件定时器的多任务调度”,并解析代码以巩固理解。

4. C语言代码解析

下面我们通过一段完整的C语言示例代码,演示如何利用硬件定时器产生周期中断,在中断中维护软件定时器列表,并在主循环中调度执行多个任务。本示例的任务包括控制两个LED以不同频率闪烁。我们将分段展示代码并进行逐步解析。

假设:我们的运行环境是一个通用的微控制器,有一个硬件定时器可用。为简化讨论,假设硬件定时器已经配置为每1毫秒触发一次中断(相关配置代码在下面会给出),并且有两个LED连接在可控的IO引脚上。

首先,引入必要的头文件和定义软件定时器的数据结构:

#include <stdint.h>
#include <stdio.h>// 软件定时器最大数量
#define MAX_TIMERS 5// 软件定时器回调函数类型
typedef void (*TimerCallback)(void);// 软件定时器结构体定义
typedef struct {uint32_t period;       // 定时时间长度(ms)uint32_t remaining;    // 剩余时间(ms)uint8_t  periodic;     // 是否周期性定时器uint8_t  active;       // 定时器是否激活TimerCallback callback;// 超时后的回调函数
} SoftTimer;// 定时器列表和全局滴答计数
SoftTimer timers[MAX_TIMERS];
volatile uint32_t system_tick = 0;

解析

  • 这里我们定义了MAX_TIMERS为5,表示最多支持5个软件定时器同时运行(可以根据需要调整)。
  • TimerCallback是一个函数指针类型,用于指向回调函数。这样每个定时器超时后可以调用不同的处理函数。
  • SoftTimer结构与我们前面设计的一致,包含周期、剩余时间、是否周期性、是否激活以及回调函数指针。类型使用uint32_t保证能够表示足够长的延时(比如最多2^32-1毫秒,大约49天)。
  • timers是定时器数组,存放所有软件定时器实例。
  • system_tick是一个全局滴答计数器,用volatile声明,因为它将在中断中修改,并在主程序中读取。volatile确保编译器每次都从内存取值,不做优化,否则可能出现主程序看不到中断更新后的最新值。

接下来,编写一个初始化函数来设置硬件定时器(假定为Timer0)产生1ms的周期中断,并初始化软件定时器数组:

// 硬件定时器初始化为1ms中断
void Timer0_Init_1ms(void) {// 假设使用某MCU的Timer0,这里的寄存器名称和设置是示例TCCR0A = 0x00;               // 配置Timer0为普通计数模式TCCR0B = 0x03;               // 设置预分频比如为64 (具体值视MCU时钟定)TCNT0  = 256 - 250;          // 设定初值,使得每溢出产生1ms中断 (250计数~=1ms)TIMSK0 |= (1 << TOIE0);      // 使能Timer0溢出中断// 注意:需要确保全局中断开启,如sei(),这里假定在主函数中会做
}// 软件定时器系统初始化
void SoftTimer_Init(void) {for (int i = 0; i < MAX_TIMERS; ++i) {timers[i].active = 0;    // 将所有定时器标记为未使用timers[i].callback = NULL;timers[i].period = timers[i].remaining = 0;timers[i].periodic = 0;}
}

解析

  • Timer0_Init_1ms函数演示了如何配置一个硬件定时器。我们这里以AVR风格的寄存器命名举例:设置Timer0为普通模式,预分频选择0x03(在ATmega16MHz时0x03对应64分频,使计数频率=250kHz),然后TCNT0给初值256-250,这样计数250个周期溢出,按照250kHz计算正好1ms。最后通过TIMSK0寄存器使能定时器溢出中断。实际平台上寄存器含义可能不同,但核心就是设定一个合适的初值使得定时器每1ms触发中断
  • SoftTimer_Init函数遍历软定时器数组,将所有槽位标记为未激活状态,并清空参数。这样可确保系统启动时没有残留的定时任务。

接下来实现一个函数,用于启动一个软件定时器:

// 启动一个软件定时器
// timeout_ms: 超时时间(毫秒); periodic: 是否周期性; cb: 超时时调用的回调函数
int SoftTimer_Start(uint32_t timeout_ms, uint8_t periodic, TimerCallback cb) {// 在临界区操作(根据平台,可禁止中断,这里假设在调用此函数前已禁用中断或在安全区域)for (int i = 0; i < MAX_TIMERS; ++i) {if (!timers[i].active) {timers[i].period = timeout_ms;timers[i].remaining = timeout_ms;timers[i].periodic = periodic;timers[i].callback = cb;timers[i].active = 1;return i; // 返回定时器ID}}return -1; // 没有空闲定时器
}

解析
SoftTimer_Start函数尝试在定时器数组中找到一个未使用的条目,然后初始化它。

  • timeout_ms是定时器周期长度,函数将它同时赋给periodremaining,表示倒计时的初始剩余时间。
  • periodic标志直接存储,用于之后判断是否循环。
  • cb函数指针保存到callback,即当定时器超时时要调用的处理函数。
  • active置1表示此定时器现在在运行。函数返回数组索引作为这个定时器的ID,以便后续可以引用或取消。如果数组已满则返回-1表示失败。
  • 实际实现中,最好在操作共享数据结构时禁用中断(临界区),以免和定时器中断同时访问timers冲突。这里为了简单,注释中提示假定调用此函数时已经做好同步(例如在主循环上下文且中断已被暂时禁止)。在很多系统中,也可以用原子操作或锁,但在单线程配合中断的环境下,禁止中断是简单有效的方法。

接下来是核心的定时器中断服务程序。在这个程序中我们将更新软件定时器:

// Timer0 中断服务程序 - 假设使用ISR宏或特定语法声明中断处理函数
ISR(TIMER0_OVF_vect) {system_tick++; // 更新全局滴答计数for (int i = 0; i < MAX_TIMERS; ++i) {if (timers[i].active) {              // 仅处理激活的定时器if (timers[i].remaining > 0) {timers[i].remaining--;       // 减少剩余时间if (timers[i].remaining == 0) {// 定时器超时if (timers[i].periodic) {// 周期性定时器,重新装载剩余时间timers[i].remaining = timers[i].period;} else {// 一次性定时器,标记为不再活动timers[i].active = 0;}// 执行回调函数: 注意在中断中执行可能不安全,这里假设回调很简短if (timers[i].callback) {timers[i].callback();}}}}}
}

解析

  • 这是Timer0定时器溢出(每1ms)的中断服务函数。函数名和声明方式根据平台不同而不同,在AVR上使用ISR(TIMER0_OVF_vect)这种宏,在其他平台可能需要在向量表注册。这里采用AVR风格作为例子。

  • 进入中断后,先将system_tick加1,表明又过去了1个毫秒(滴答)。

  • 然后遍历timers数组的每一个条目,如果该定时器是激活状态,则进行处理。

  • 将定时器的remaining减1,表示消耗了1ms。

  • 如果递减后remaining变为0,说明该定时器计时到了:

    • 如果是周期性定时器(periodic为1),则重置remaining = period,准备开始下一轮计时。这样确保下次经过period毫秒后再次触发。
    • 如果是一次性定时器,则将active置0,表示它不再有效。以后不会再在中断中更新它了。
    • 检查是否设置了回调函数指针,如果有则调用它。这里直接在中断中调用了回调函数。这要谨慎:一般情况下,我们不鼓励在中断中执行过多逻辑。如果回调里做的事情很多,可能会延长中断时间甚至影响下一个tick的准确性。在简单应用(例如本例的回调可能只是切换一个LED状态)中,直接调用还问题不大。但更通用的做法是不要在中断里调用,而是设置一个标志,然后在主循环里调用回调。这一点我们稍后会讨论变体。为了聚焦逻辑,这里假设回调简单到可以在中断中完成。**
  • 由于remaining是无符号整数,即使在周期性定时器中我们重置了remaining,下一个tick又会正确地从period减到period-1继续计时。对于一次性定时器,我们标记active=0,下次进入中断时就跳过它,不会再次调用其回调。

调度方式讨论
在这个实现中,我们其实在中断上下文直接调用了定时器回调函数,也就是任务函数。这意味着任务实质上在中断中运行,多个任务如果在同一个tick同时到期,会依次在一次中断中连续调用回调函数。这在任务非常简短时是可行的,但若任务包含相对复杂的操作,可能会延长中断处理时间,不太优雅。

另一种方式是将callback()的调用挪到主循环中进行:可以修改为在中断中不调用callback(),而是比如设置一个全局的标志数组或任务队列。例如增加一个uint8_t expired[MAX_TIMERS]数组,在中断里当定时器到期时不执行回调,只是expired[i] = 1。然后在主循环里检查expired[i]为1时再调用相应回调并清除标志。这样好处是中断处理非常快,但实现上稍微复杂一些,而且任务执行不在严格的时间边界上(可能会有几个ms的延迟,取决于主循环调度)。具体采用哪种方式视需求而定。本例为了直观,把回调放在中断中,但提醒读者在实际开发时注意中断执行时间的问题。

接下来,定义我们要用的回调函数(任务函数)以及主函数:

// 两个示例任务的回调:LED灯切换
void toggle_LED1(void) {// 假设LED1连接在某端口位,以下为伪代码PORTA ^= (1 << 0);   // 切换LED1引脚电平printf("LED1 Toggled! system_tick=%lu\n", system_tick);
}void toggle_LED2(void) {PORTA ^= (1 << 1);   // 切换LED2引脚电平printf("LED2 Toggled! system_tick=%lu\n", system_tick);
}int main(void) {// 初始化硬件和软件定时器Timer0_Init_1ms();SoftTimer_Init();sei();  // 开启全局中断(针对AVR架构,其他平台请使用相应指令)// 假设已配置PORTA第0位和第1位为输出,用于控制LED1和LED2// 启动两个软件定时器:// LED1每500ms闪烁一次,LED2每1000ms闪烁一次SoftTimer_Start(500, 1, toggle_LED1);SoftTimer_Start(1000, 1, toggle_LED2);// 主循环while (1) {// 主循环在本例中不用执行任务逻辑,因为都在中断回调中处理了// 这里可以执行其他非时间相关的工作,或者进入低功耗等待中断// 我们简单地插入一个空闲操作:asm("NOP");  // 空操作,占位}return 0;
}

解析

  • toggle_LED1toggle_LED2是我们的任务函数,功能是切换对应LED的状态(这里用异或操作^=翻转端口位)。并且通过printf打印了一行日志,显示哪一个LED被切换以及当前的system_tick值。这可以帮助我们观察两LED闪烁的时序(需要串口支持printf输出,这里只是示意)。system_tick的打印可以看到LED切换发生时的全局时间,有助于验证500ms和1000ms间隔是否正确。
  • main函数中,按顺序进行了必须的初始化:
    • Timer0_Init_1ms();配置硬件定时器产生1ms中断。
    • SoftTimer_Init();初始化软件定时器数据。
    • sei();开启全局中断(这是AVR平台特有的,你可以想象在Cortex-M上这里对应__enable_irq();或在8051上EA = 1;)。
    • 假设之前还有代码将LED对应的IO口设为输出模式,这里略去直接假定已经配置好PORTA第0和1位为输出(取决于实际MCU寄存器,这里不详细展开)。
  • 使用SoftTimer_Start启动了两个定时器:
    • 第一个定时器超时时间为500ms,周期性,回调函数为toggle_LED1
    • 第二个定时器超时时间为1000ms,周期性,回调函数为toggle_LED2。 调用后,这两个定时器就开始计时了,各自的remaining会在中断中递减。
  • 主循环部分:由于我们采取了在中断中直接执行回调的策略,主循环反而显得很空闲,不需要做定时检查。如果我们把回调延后到主循环执行,那么这里会有代码去检查定时器expired标志并调用回调。在当前实现下,主循环只是无穷循环等待中断触发。实际中可以在这里执行其他需要持续运行的任务,或者进入节能的休眠模式,由定时器中断来唤醒CPU。这里用asm("NOP")纯粹是表示有个占位,实际什么也没做。

运行过程分析
假设系统时钟开始运行,Timer0每1ms中断。根据我们设置:

  • LED1的定时器remaining初始为500,LED2的为1000。
  • system_tick会在每次中断加1。
  • 在中断中,每毫秒两个定时器的remaining各减1。
  • 当经过500次中断(约500ms),LED1的remaining将从500减到0。这时在中断中检测到remaining == 0且它是周期的,于是重置remaining=500,并调用toggle_LED1()toggle_LED1执行后LED1状态翻转一次,打印信息显示system_tick大约500。
  • LED2这时remaining还剩500(因为1000-500)。继续运行...
  • system_tick达到1000时(约1秒),LED2的remaining也减为0,触发中断中调用toggle_LED2(),LED2翻转,并重置remaining=1000再次开始下一周期。同时此刻LED1的remaining也第二次减到0(因为LED1在500时重载500,经过又500ms到1000时再次为0),所以在同一个中断里,LED1和LED2都会被检测为到期。根据中断代码的遍历顺序,定时器数组index小的先处理——假设LED1的定时器在数组中索引比LED2小,那么它的回调会先调用,然后LED2回调后调用。这样在system_tick=1000的中断中,LED1和LED2各自都会翻转一次。这种情况意味着当两个任务同时到期时,它们几乎同时执行,只是顺序上有先后,但都发生在同一毫秒内,对人眼观察而言等同于同时闪烁。
  • 运行继续,LED1会在1500ms、2000ms...每隔0.5秒翻转,LED2会在2000ms、3000ms...每隔1秒翻转。可以看到LED2闪烁频率是LED1的一半。

关于代码的健壮性
本示例代码为了说明原理,做了一些简化假设。在实际使用中,还应考虑:

  • 中断安全:确保在修改共享数据(如timers数组)时关中断或者互斥。
  • 回调执行时间:如前所述,如果回调执行时间长,最好不要在中断中直接执行,而是使用标志推迟到主循环。
  • 定时精度:1ms的tick在大多数场合足够,如果需要更高精度,可以把硬件定时器设为更高频率,比如0.1ms,每10个tick再作为1ms计算等等,但是频率过高中断开销也会上升。
  • Timer Stop:我们没有实现SoftTimer_Stop函数。如果需要取消一个正在运行的定时器,可编写函数将对应定时器active置0。同样需要注意中断同步。
  • 定时器用完:如果启动定时器超过MAX_TIMERS限制,当前实现返回-1,可以做进一步处理,比如打印警告或者扩展数组大小。

总的来说,通过这段代码,我们已经完成了从硬件定时器到软件定时器再到多任务调度的一个闭环示例。硬件定时器提供了时间基准(1ms中断);软件定时器机制让我们定义了多个不同超时的事件(500ms和1000ms);任务调度逻辑则体现为中断中(或主循环中)按时调用不同的回调函数,实现了看似并行的多任务(两个LED各自周期闪烁)。

总结

从底层硬件到上层软件,我们依次讲解了硬件定时器的原理,如何利用硬件定时器构建软件定时器,进而实现简单的多任务调度,并通过示例代码进行了说明。通过阅读以上内容,读者应当理解以下关键要点:

  • 硬件定时器是如何工作的,如何配置它来产生所需的定时中断,以及典型的应用场景。
  • 什么是软件定时器,为什么需要软件定时器,以及软件定时器如何依赖硬件定时器实现多个独立计时。
  • 如何设计软件定时器的数据结构和更新机制,包括定时器列表管理、倒计时递减以及超时处理流程。
  • 在一个简单的调度器中,如何利用软件定时器来触发任务执行,实现多任务的时间片轮转或定时激活;了解合作式调度的特点和局限。
  • 理解示例代码中每一部分的作用,从初始化、中断处理到主循环调度,以及LED闪烁的实现逻辑。这种代码结构在很多小型嵌入式项目中都十分常见,可以作为模板加以扩展。

通过将上述知识应用于实际开发,读者可以尝试自主实现类似的功能,例如增加更多的任务,或修改定时参数来观察不同的调度效果。如果进一步需要复杂的功能(例如任务的优先级调度、任务阻塞与唤醒等),则可能需要引入更复杂的机制甚至RTOS。但即使在那种情况下,理解底层定时器和调度的原理仍然是至关重要的,它是编写高效可靠嵌入式代码的基础。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com