UEFI开发探索41 – Event、Timer和任务优先级

(请保留->发布地址: http://yiiyee.cn/blog/author/luobing/ )

作为一个底层的支持系统,UEFI没有支持中断。如果想支持异步操作,只能通过事件(Event)来实现。

在开发Foxdisk的过程中,也遇到需要同时处理的事件。比如提示用户输入的闪烁光标、自动显示的系统时间等,我是采用了时钟中断(int 1Ch)的方式来实现的,是段很有意思的程序。

不过,我只是简单地将需要的功能堆砌在int 1Ch中实现,并没有完整地实现多任务间的互斥,是一种“伪多任务”的实现。那么,UEFI中是怎么来支持多个任务同时执行呢?

1 支持的服务函数

图1为相关的服务函数,总共10个:

图1 事件相关服务函数

这些函数运行于Boot Services环境下,有不同的任务优先级要求(或者称为TPL要求)。在Boot Services环境下,有三种优先级:

TPL_APPLICATION-最低的优先级,应用程序运行在这个级别;
TPL_CALLBACK-中等优先级,比较耗时的一些操作,比如磁盘操作运行在此级别;
TPL_NOTIFY-高优先级,不允许阻塞,应该尽快完成,通常底层的IO操作在此级别。

高优先级运行的的任务可以中断低优先级任务。比如,运行于TPL_NOTIFY级别的任务可以中断运行于TPL_APPLICATION或者运行于TPL_CALLBACK级别的任务。为了让firmware(固件)有处理所有级别任务的能力,比如高级别的时钟事件或者内部设备,设计了第四种TPL,TPL_HIGH_LEVEL。

TPL_HIGH_LEVEL是最高级别优先级,此级别中断是被禁止的,UEFI内核全局变量的修改需要在此级别。

对于每个函数和Protocol运行于哪个级别,可以参考UEFI Spec 2.8 page 141-144。

事件(Event)存在两种互斥的状态,“waiting”(等待)和“signaled”(触发)。当事件被创建之后,固件(firmware)将其设置为等待状态。事件被触发后,固件将其转换为触发状态,如果事件类型为EVT_NOTIFY_SIGNAL,则其相关的notification函数也会放入FIFO队列中。

对TPL_CALLBACK和TPL_NOTIFY的事件,存在着处理队列。如果队列中的notification,其TPL等于或者小于目前任务的TPL,它只能等到当前任务TPL降低了,一般是通过EFI_BOOT_SERVICES.RestoreTPL来改变TPL。

通常来说,事件也可分为同步和异步两种类型。异步的例子,典型的如网络设备驱动中,通过EVT_TIMER事件等待新的网络包。而调用EFI_BOOT_SERVICES.ExitBootServices()是一个同步的例子,当函数完成后,将触发EVT_SIGNAL_EXIT_BOOT_SERVICES事件。

2 函数简述

2.1  CreateEvent()

typedef EFI_STATUS  (EFIAPI *EFI_CREATE_EVENT) (
IN UINT32 Type,   //事件类型
IN EFI_TPL NotifyTpl,   //事件NotifyFunction函数的优先级
IN EFI_EVENT_NOTIFY NotifyFunction, OPTIONAL  //事件NotifyFunction函数
IN VOID *NotifyContext, OPTIONAL  //传给事件NotifyFunction函数的参数
OUT EFI_EVENT *Event   //生成的事件
);

事件类型有如下几种(摘自MdePkg\Uefi\UefiSpec.h):

图2 各种类型事件

EVT_TIMER:普通Timer(定时器)事件,生成事件后需要调用SetTimer服务设置Timer属性。

EVT_RUNTIME:如果事件在调用EFI_BOOT_SERVICES.ExitBootServices()之后触发,事件的数据结构和notification函数都必须由runtime memory分配。

EVT_NOTIFY_WAIT:普通事件,此事件有通知(Notification)函数,当这个事件通过EFI_BOOT_SERVICES.WaitForEvent()等待或者EFI_BOOT_SERVICES.CheckEvent()检查时,此函数将会被放到待执行队列中

EVT_NOTIFY_SIGNAL:当此事件被触发时,它的NotifyFunction将会放到待执行队列中

EVT_SIGNAL_EXIT_BOOT_SERVICES:当调用ExitBootSerices()时,系统会发出此事件

EVT_SIGNAL_VIRTUAL_ADDRESS_CHANGE:当执行SetVirtualAddressMap()时,系统会发出此事件

上述通知函数NotifyFunction的函数原型为:

typedef VOID (EFIAPI *EFI_EVENT_NOTIFY)(
         IN EFI_EVENT Event;  //notification函数调用的事件
         IN VOID  *Context   //指向notification函数的内容(个人感觉,有点类似于Windows控件ListView中的附加字符串)
);

CreateEventEx函数比CreateEvent函数多了一个事件组的入口参数,如果此参数没有指定(NULL),此函数和CreateEvent是一样的。

事件组是一个事件的集合,共享同一EFI_GUID。组中的任意事件被触发后,组中其余所有的事件都会被触发,同组内所有通知函数都将加入待执行队列。

对类型为EVT_SIGNAL_EXIT_BOOT_SERVICES和EVT_SIGNAL_VIRTUAL_ADDRESS_CHANGE来说,事件组是固定的,已经在UEFI中预定好了。

可以让多个EVT_TIMER组成一个事件组,不过,没法方法确定哪个Timer被触发了。

对事件组中的事件,可以使用CloseEvent来将其移除。

2.3 CloseEvent()和SignalEvent()

typedef EFI_STATUS (EFIAPI *EFI_CLOSE_EVENT) (
IN EFI_EVENT Event  //需要关闭的事件
);

typedef EFI_STATUS (EFIAPI *EFI_SIGNAL_EVENT) (
IN EFI_EVENT Event  //触发的事件
);

以下是触发事件组的例子,创建一个事件加入到事件组,触发后移除此事件:

EFI_EVENT Event;
EFI_GUID gMyEventGroupGuid = EFI_MY_EVENT_GROUP_GUID;
gBS->CreateEventEx (0, 0, NULL, NULL,
&gMyEventGroupGuid,
&Event
);
gBS->SignalEvent (Event);
gBS->CloseEvent (Event);

2.4 WaitForEvent()和CheckEvent()

typedef EFI_STATUS (EFIAPI *EFI_WAIT_FOR_EVENT) (
IN UINTN NumberOfEvents, //事件数组中的事件数目
IN EFI_EVENT *Event,   //事件数组
OUT UINTN *Index   //返回处于触发态的事件再数组内的下标(从零开始计数)
);

typedef EFI_STATUS (EFIAPI *EFI_CHECK_EVENT) (
IN EFI_EVENT Event   //需要检查的事件,看是否处于触发状态
);

WaitForEvent必须在优先级TPL_APPLICATION下调用,如果在其他优先级下调用的话,函数将返回EFI_UNSUPPORTED。

WaitForEvent重复从前往后检查事件数组中的事件,直到找到事件被触发或者有错误产生。当检查到某个事件处于触发态时,*Index返回该事件在事件数组中的下标,返回前将该事件重置为等待状态。

如果某个事件类型为EVT_NOTIFY_SIGNAL,WaitForEvent将返回EFI_INVALID_PARAMETER,*Index指明此事件在数组的下标。

WaitForEvent函数是阻塞函数,如果不想等待的话,可以使用CheckForEvent函数来检查事件的状态。

2.5 SetTimer()

typedef EFI_STATUS (EFIAPI *EFI_SET_TIMER) (
IN EFI_EVENT Event,  //Timer事件
IN EFI_TIMER_DELAY Type,  //定时器类别
IN UINT64 TriggerTime    //定时器过期事件,100ns为一个单位
);

typedef enum {
TimerCancel,  //用于取消定时器触发事件,设置后不触发定时器
TimerPeriodic, //重复型定时器,触发时间为TriggerTime * 100ns  
TimerRelative  //一次性定时器,触发时间为TriggerTime * 100ns
} EFI_TIMER_DELAY;

此函数调用后,将取消之前设定的事件相关的时间设置,并启用新的触发事件设置。它只能用于类型为EVT_TIMER的事件。

2.6 RaiseTPL()和RestoreTPL()

typedef EFI_TPL (EFIAPI *EFI_RAISE_TPL) (
IN EFI_TPL NewTpl  //新的优先级,必须高于或者等于当前任务的优先级
);

相关优先级数值:

#define TPL_APPLICATION 4
#define TPL_CALLBACK 8
#define TPL_NOTIFY 16
#define TPL_HIGH_LEVEL 31

typedef VOID (EFIAPI *EFI_RESTORE_TPL) (
IN EFI_TPL OldTpl  //调用RaiseTPL前的任务的优先级
)

这两个函数是成对的,RaiseTPL调用后,会返回当前任务的优先级,以让RestoreTPL恢复。

任务提升到TPL_HIGH_LEVEL时,中断会关闭;恢复到低于此优先级时,中断会被重新打开。

3 机制探索

3.1 时钟中断(时钟0的作用)

在Foxdisk中,我使用了缺省的时钟0来进行操作。系统上电初始化时,会将定时器初始化为每隔55ms发出一次中断请求,CPU在响应中断请求后转入8H号中断处理程序。而在BIOS 的8H中断中,有一条指令“Int 1ch”,所以每秒约调用18.2次1CH号中断处理程序。

8254可编程时钟包含三个独立的16位时钟,时钟0用来系统定时的,在BIOS post阶段会初始化为上述状态。三个时钟分配有三个读写寄存器,分别为0x40、0x41、0x42,还包括一个公用的控制寄存器0x43。

我实际的代码都在Int 1cH中,采用这种方式编程简单,不用去太多操作硬件。连几个任务防止死锁的方式都是关中断。缺点是,没有实现完整的事件机制,不是真正意义上的多任务调度。

对8254的时钟0操作需要用到几个寄存器,我们主要关心模式控制部分,涉及到端口0x40和0x43。(博文中一般用“0x”前缀表示16进制,这是C语言的用法;有时候以“h”后缀表示十六进制,这是汇编语言的用法)

图3 8254的说明节选

从图中可以看出,向端口0x43(模式控制寄存器)写入0x36,是将时钟0设置为模式3。往端口0x40(时钟0的系统滴答,即读写计数寄存器)设置相应的16位计数值即可,设置时,先发送计数值的低有效字节,然后发送高有效字节。

模式3是一种方波方式,用来生成一个周期性方波输出。计数值为0时,输出周期最大,每54.9ms出现一次输出。三个时钟的的输入频率初始都为1.1931817MHz,当计数值为0时,相当于输出频率为18.2Hz,也即输入频率1.1931817MHz与2^16相除的结果。

对时钟0操作的函数在\PcAtChipsetPkg\8254TimerDxe\Timer.c中,仔细读读代码,会发现不少有意思的东西。

函数SetPitCount:

图4 设置时钟0的计数值

函数SetPitCount对时钟0进行计数值设置,计数值由Count传入。此函数由TimerDriverSetTimerPeriod调用,除此外没有其他函数调用它。

TimerDriverSetTimerPeriod函数原型为:

EFI_STATUS EFIAPI  TimerDriverSetTimerPeriod (
  IN EFI_TIMER_ARCH_PROTOCOL  *This,
  IN UINT64                   TimerPeriod
  )

其入口参数TimerPeriod与调用SetPitCount时传入的参数TimerCount之间的公式是这样的:

TimerCount = ((TimerPeriod * 119318) + 500000)/1000000。

也就是说,TimerPeriod乘以100ns,即为时钟0周期输出的时间。而在初始化函数TimerDriverInitialize中,是这样调用设置函数的:

Status = TimerDriverSetTimerPeriod (&mTimer, DEFAULT_TIMER_TICK_DURATION);

时钟0在此被设置成每10ms中断一次,( DEFAULT_TIMER_TICK_DURATION=100000),用来调度任务。

真正工作的调度函数是CoreTimerTick,位于\MdeModulePkg\Core\Dxe\Event\Timer.c下(UDK2018中)。时钟0每过10ms会调用它一次,实现了事件的调度机制。

至于这个函数怎么注册起来的、又如何起效的,在《UEFI原理与编程》中有很详细的描述,就不重复了。

3.2 SetTimer的迷思

SetTimer的第三个参数,是以100ns为粒度单位的。如果使用8254的时钟0,其最高频率是1.19318MHz,最小的时间间隔为840ns,这怎么能行?

UEFI对应Intel的平台下使用的定时器有8254和Hpet两种,在使用中到底是怎么选择的?

图5 Hpet和8254

我使用windbg+Qemu0.13调试时用的是32位OVMF.fd,在枚举TimerDriverInitialize时,定位到的是8254TimerDxe的目录。我觉得平常使用的应该是Hpet定时器才对,很可能与我现在用的Qemu版本有关,它不一定模拟了Hpet定时器。(试了下64位OVMF.fd,也是同样现象)

Hpet定时器提供出去的接口,与8254提供出去的是一样的。我对Hpet不大熟悉,最近也没有时间仔细研究,贴一个其他人研究的帖子,对执行的过程做了一个全景描述:http://www.thzhonggong.com/plus/view.php?aid=30938

从精度来看,Hpet定时器当然能满足粒度为100ns的设置了。不过还是没解决8254设置为100ns粒度的问题,我猜测在使用Hpet定时器时,是以100ns作为中断间隔的;而在使用8254时,最小的粒度应该是10ms。基于以下事实推测的:

1) 调试过程中,除了在启动过程中调用了TimerDriverInitialize外,没有在此处断下来过。而在跟踪SetTimer过程中,也并未调用TimerDriverSetTimerPeriod之类的函数;
2) 跟踪SetTimer时,发现这个代码:

图6 windbg调试SetTimer

我故意将SetTimer的第三个参数设置为7,调试过程中会去比较Event2,而Event2的Period值为10000。它没有加进去?

不过,我没有找到方法来验证这个想法,把它作为一个需要解决的问题留存吧。

4 构建程序

跟踪了这么久的Event,总该用来做点什么。只是定时打印字符,也没有什么意思,我决定做一个有趣点的东西。

功能如下:1) 产生随机数,作为屏幕的坐标;
          2) 设定重复性的Event,每隔200ms触发一次;
          3) 每触发一次,在随机坐标处画一个方块;

然后可以发呆,放空自己,看屏幕什么时候填满。

这是一个毫无用处的佛性程序。

从UEFI spec 2.4开始,提供了一个随机数生成的Protocol-EFI_RNG_PROTOCOL:

图7 生成随机数的Protocol

我在测试代码中,写好了相关的函数,准备用它来实现随机数生成。不过,测试中发现,在TianCore的模拟环境和OvmfPkg的模拟环境中,都无法找到。怀疑可能模拟环境中没有实现它,不知道实际环境中是否支持,回头再试试。

暂时还是手写个伪随机函数吧。我把StdLib中的随机函数rand()拿过来,稍微改了改,就可以直接用了。具体的实现可以看代码。

至于Event的用法,前面的函数说明已经比较详细了。另外,也可以参考《UEFI原理与编程》的例子。

实现的效果如下:

图8 用Event实现随机画图

百度云链接:https://pan.baidu.com/s/1gccSosw8_UAGTI5gZPnLCA
提取码:dx23
文件在 FF RobinPkg/ RobinPkg /Applications/RngEvent 下
(从此篇开始,如非特殊需要要,所有的代码都会放在我自制的Package中编译)

438 total views, 1 views today

《UEFI开发探索41 – Event、Timer和任务优先级》有2个想法

  1. 您好,看了您关于UEFI的开发博文,有些问题想请教一下
    我们公司想通过UEFI去开发一个PC启动管理程序,主要是想实现在虚拟化桌面的客户端引导计算机进入相应的系统镜像版本一类的功能。
    由于是刚接触UEFI,不知道根据您的经验是否可以实现?
    或者您是否有兴趣给给我们做些培训,甚至是开发都可以。
    期待您的回复!
    薛飞 15077880596(可加微信)

    1. 这是一个bootloader啊,可以参考intel 的slimbootloader,目前还没有进入1.0版本。我很久以前写的foxdisk也是一个类似的工具,不过那个只支持legacy bios。最近一直都很忙,实在没有时间做别的事情了,咱们可以技术探讨^^

发表评论

电子邮件地址不会被公开。