UP | HOME

中断处理

Table of Contents

有一种设备在操作系统开发中特别有用: 系统计时器 。它是一种可以配置为以 某个预定频率 定期 中断 处理器工作 的设备

进程调度是使用计时器的一种特殊应用,调度程序需要测量每个进程执行了多长时间, 并使用此信息选择要运行的下一个进程,而此测量基于计时器中断

在下一章中, 将详细讨论进程调度, 但是现在, 任务是初始化系统计时器并实现计时器的中断处理程序

中断与异常

在ARM.v8体系结构中, 中断是一种异常(更笼统的术语)。 异常有4种:

  • 同步异常: 这种类型的异常总是由当前执行的指令引起

    例如, 可以使用 str 指令将一些数据存储在不存在的内存位置。在这种情况下, 将生成同步异常
    
    • 同步异常也可以用于生成 软件中断 。软件中断是由 svc 指令有意产生的同步异常

      未来将使用该技术来实现系统调用
      
  • IRQ(中断请求): 这些是正常的中断。它们始终是 异步 的, 这意味着它们与当前执行的指令无关
    • 与同步异常相反, 它们始终不是由处理器本身生成的, 而是由 外部硬件 生成的
  • *FIQ(快速中断请求)*: 这种类型的异常称为“快速中断”
    • 仅出于 优先处理 异常的目的而存在. 可以将某些中断配置为“正常”, 将其他中断配置为“快速”
    • 快速中断将首先发出 信号 , 并将由单独的异常处理程序处理

      Linux不使用快速中断, 我们也不会这样做
      
  • SError(系统错误): 像IRQ和FIQ一样, SError异常是 异步 的, 由 外部硬件 生成

    • 与 IRQ 和 FIQ 不同, SError 始终表示某种错误情况
    可以在 https://community.arm.com/processors/f/discussions/3205/re-what-is-serror-detailed-explanation-is-required 找到一个示例来说明何时可以使用SError产生中断 
    

异常处理向量表

每个异常类型都需要有自己的处理程序。另外, 每个不同的 执行状态 (异常生成的时候)应该定义单独的处理程序。从异常处理的角度来看, 有4种执行状态很有趣。如果在EL1工作, 则这些状态可以定义如下:

  1. EL1t: 与 EL0 共享 堆栈指针 时, EL1发生异常

    当 SPSel 寄存器的值为 0 时, 就会发生这种情况
    
  2. EL1h: 为 EL1 分配了 专用 堆栈指针 时, EL1发生了异常

    这意味着 SPSel 拥有值 1, 这是当前正在使用的模式
    
  3. EL0_64: 以 64位 模式执行的 EL0 产生异常
  4. EL0_32: 以 32位 模式执行的 EL0 产生异常

总共, 需要定义16个异常处理程序(4个异常级别乘以4个执行状态)。一个 保存 所有 异常处理程序地址 的特殊数据结构被称为 exception vector tablevector table

向量表的结构在 AArch64-Reference-Manual 第1876页上的 表D1-7向量与向量表基址的向量偏移量 中定义

可以把向量表视为异常向量的数组, 其中每个异常向量(或处理程序)是负责 处理 特定异常连续指令序列 . 因此, 对于来自 AArch64-参考手册 的 表D1-7, 每个异常向量最多可以占用 0x80 字节

这容量虽然不多, 但是没有人阻止从异常向量跳转到其他内存位置

接下来通过一个示例, 所有这些都将更加清晰

与异常处理相关的所有内容都在 entry.S 中进行了定义。第一个有用的宏称为 ventry , 它用于 创建 向量表中的 条目

.macro    ventry    label
.align    7
b    \label
.endm

从命名中可以推断出, 不会在异常向量内部处理异常, 而是跳转到为宏提供的 标签 , 它是 label 参数

  • 注意:需要 .align 7 指令, 因为所有异常向量都应位于彼此偏移的 0x80 字节上

接下来定义向量表, 它由16个条目组成:

        /*
        * Exception vectors.
        */
        .align  11
        .globl vectors 
vectors:
        ventry  sync_invalid_el1t                       // Synchronous EL1t
        ventry  irq_invalid_el1t                        // IRQ EL1t
        ventry  fiq_invalid_el1t                        // FIQ EL1t
        ventry  error_invalid_el1t                      // Error EL1t

        ventry  sync_invalid_el1h                       // Synchronous EL1h
        ventry  el1_irq                                 // IRQ EL1h
        ventry  fiq_invalid_el1h                        // FIQ EL1h
        ventry  error_invalid_el1h                      // Error EL1h

        ventry  sync_invalid_el0_64                     // Synchronous 64-bit EL0
        ventry  irq_invalid_el0_64                      // IRQ 64-bit EL0
        ventry  fiq_invalid_el0_64                      // FIQ 64-bit EL0
        ventry  error_invalid_el0_64                    // Error 64-bit EL0

        ventry  sync_invalid_el0_32                     // Synchronous 32-bit EL0
        ventry  irq_invalid_el0_32                      // IRQ 32-bit EL0
        ventry  fiq_invalid_el0_32                      // FIQ 32-bit EL0
        ventry  error_invalid_el0_32                    // Error 32-bit EL0
现在, 我们只对处理来自 EL1h 的 IRQ 感兴趣, 但是仍然需要定义所有16个处理程序

这不是因为某些硬件要求, 而是因为希望看到有意义的错误消息, 以防出现问题

所有不应该在正常流程中执行的处理程序都具有 invalid 的后缀, 并使用 handle_invalid_entry宏 。来看看如何定义此宏:

.macro handle_invalid_entry type
kernel_entry
mov     x0, #\type
mrs     x1, esr_el1
mrs     x2, elr_el1
bl      show_invalid_entry_message
b       err_hang
.endm
  1. 第一行中, 可以看到使用了另一个宏: kernel_entry

    下一小节将讨论
    
  2. 然后调用 show_invalid_entry_message 并为其准备3个参数
    • 第一个参数: 表示 异常类型 ,它准确地告诉我们执行了哪个异常处理程序
    • 第二个参数: 最重要的参数, 称为 ESR (Exception Syndrome Register), 该参数取自 esr_el1 寄存器

      该寄存器在 AArch64-Reference-Manual 的第2431页中进行了描述,包含有关导致异常的原因的详细信息
      
    • 第三个参数:它的值取自我们熟悉的 elr_el1 寄存器, 其中包含 生成异常时执行的 指令的地址

      主要在同步异常的情况下很重要,这是导致异常的指令
      
  3. show_invalid_entry_message 函数将所有这些信息打印到屏幕之后, 将处理器置于 无限循环 中, 因为无能为力了

保存和恢复寄存器状态

异常处理程序完成执行后, 希望所有通用寄存器具有与生成异常之前相同的值

如果不实现这种功能, 则与当前正在执行的代码无关的中断可能会无法预测地影响该代码的行为

这就是为什么在生成异常后要做的第一件事就是 “保存” 处理器状态

这是在 kernel_entry宏 中完成的

.macro  kernel_entry
sub     sp, sp, #S_FRAME_SIZE
stp     x0, x1, [sp, #16 * 0]
stp     x2, x3, [sp, #16 * 1]
stp     x4, x5, [sp, #16 * 2]
stp     x6, x7, [sp, #16 * 3]
stp     x8, x9, [sp, #16 * 4]
stp     x10, x11, [sp, #16 * 5]
stp     x12, x13, [sp, #16 * 6]
stp     x14, x15, [sp, #16 * 7]
stp     x16, x17, [sp, #16 * 8]
stp     x18, x19, [sp, #16 * 9]
stp     x20, x21, [sp, #16 * 10]
stp     x22, x23, [sp, #16 * 11]
stp     x24, x25, [sp, #16 * 12]
stp     x26, x27, [sp, #16 * 13]
stp     x28, x29, [sp, #16 * 14]
str     x30, [sp, #16 * 15] 
.endm

这个宏非常简单:它只将寄存器 x0-x30 存储堆栈

.macro  kernel_exit
ldp     x0, x1, [sp, #16 * 0]
ldp     x2, x3, [sp, #16 * 1]
ldp     x4, x5, [sp, #16 * 2]
ldp     x6, x7, [sp, #16 * 3]
ldp     x8, x9, [sp, #16 * 4]
ldp     x10, x11, [sp, #16 * 5]
ldp     x12, x13, [sp, #16 * 6]
ldp     x14, x15, [sp, #16 * 7]
ldp     x16, x17, [sp, #16 * 8]
ldp     x18, x19, [sp, #16 * 9]
ldp     x20, x21, [sp, #16 * 10]
ldp     x22, x23, [sp, #16 * 11]
ldp     x24, x25, [sp, #16 * 12]
ldp     x26, x27, [sp, #16 * 13]
ldp     x28, x29, [sp, #16 * 14]
ldr     x30, [sp, #16 * 15] 
add     sp, sp, #S_FRAME_SIZE           
eret
.endm

还有一个相应的宏 kernel_exit , 在异常处理程序完成执行:

  1. 通过把x0-x30寄存器的值压出栈来恢复处理器状态
  2. 执行 eret 指令, 返回到正常的执行流程
顺便说一句, 通用寄存器并不是执行异常处理程序之前唯一需要保存的内容, 但是对于现在的简单内核而言, 这已经足够了

设置向量表

现在准备好了向量表, 但是处理器并不知道它的位置, 因此无法使用它

为了能够处理异常, 必须将 vbar_el1 (向量基址寄存器) 设置向量表地址

        .globl irq_vector_init
irq_vector_init:
        adr    x0, vectors        // load VBAR_EL1 with virtual
        msr    vbar_el1, x0        // vector table address
        ret

屏蔽/取消屏蔽中断

需要做的另一件事是 取消屏蔽 所有类型的中断

这里解释一下“取消屏蔽”中断的含义:有时特定的代码段绝不能被异步中断拦截

想象一下, 例如, 如果在 kernel_entry宏 的中间发生中断, 会发生什么? 在这种情况下, 处理器状态将被覆盖并丢失

这就是为什么每当执行异常处理程序时, 处理器都会自动禁用所有类型的中断。这称为“遮罩”, 如果需要, 也可以手动完成

许多人错误地认为必须在异常处理程序的整个过程中屏蔽中断。但这是不正确的:在 保存处理器状态后 取消屏蔽 中断 是完全合法的

因此嵌套的中断也是合法的。虽然现在不打算这样做, 但是这是要记住的重要信息

以下两个函数负责屏蔽和取消屏蔽中断:

        .globl enable_irq
enable_irq:
        msr    daifclr, #2
        ret

        .globl disable_irq
disable_irq:
        msr    daifset, #2
        ret

ARM处理器状态有 4 位, 负责保持不同类型中断的 屏蔽 状态. 这些位定义如下.

  • D: 屏蔽 调试异常

    这些是同步异常的一种特殊类型, 显然不可能屏蔽所有同步异常, 但是使用单独的标志可以屏蔽调试异常很方便
    
  • A : 屏蔽 SErrors

    之所以称为 A, 是因为有时将 SErrors 称为 异步中止(Aysnchronize Abort)
    
  • I: 屏蔽 IRQs
  • F: 屏蔽 FIQs

因此负责更改中断屏蔽状态的寄存器称为 daifclrdaifset : 这些寄存器在处理器状态下 设置清除 中断屏蔽状态位

现在只想设置并清除 第二个 I 位,所以用常量值 2

配置中断控制器

设备 通常不直接 中断 处理器 :相反, 它们依靠 中断控制器 来完成工作。中断控制器可用于 启用 / 禁用 硬件发送的 中断

还可以使用中断控制器来确定哪个设备产生了中断

Raspberry PI具有自己的中断控制器, 该控制器在 BCM2837 ARM 外设手册 的第109页上进行了描述

Raspberry Pi中断控制器具有 3 个寄存器, 用于保存所有类型的中断的启用/禁用状态:

  • ENABLE_IRQS_1 寄存器:对应于中断 0 ~ 31, 通过寄存器内的不同位置的值来启用或禁用这些中断
  • ENABLE_IRQS_2 寄存器: 对应于 32 ~ 63 号中断
  • ENABLE_BASIC_IRQS 寄存器: 控制一些常见中断以及ARM本地中断

    下一章将讨论ARM本地中断
    

目前, 仅对 计时器中断 感兴趣, 可以使用 ENABLE_IRQS_1 寄存器:

void enable_interrupt_controller()
{
    put32(ENABLE_IRQS_1, SYSTEM_TIMER_IRQ_1);
}
《外围设备手册》有很多错误, 其中之一:外围设备中断表(在手册第113页上进行了说明) 应在 0-3 行包含4个来自系统定时器的中断

从逆向工程Linux源代码并阅读其他一些资源, 能够弄清楚该计时器中断0和2被保留并由GPU使用, 中断1和3可以用于任何其他目的。因此, 这是启用系统计时器IRQ编号1的功能

通用IRQ处理程序

从前面的讨论中, 应该了解, 我们只有一个异常处理程序, 负责处理所有的 IRQ:

void handle_irq(void)
{
    unsigned int irq = get32(IRQ_PENDING_1);
    switch (irq) {
        case (SYSTEM_TIMER_IRQ_1):
        handle_timer_irq();
        break;
    default:
        printf("Unknown pending irq: %x\r\n", irq);
    }
}
在处理程序中, 需要一种方法来确定哪个设备负责产生中断

中断控制器可以帮助完成此工作:它具有 IRQ_PENDING_1 寄存器, 该寄存器保存中断0-31的中断状态。使用该寄存器, 可以检查当前中断是由计时器还是由其他设备产生的, 并调用设备特定的中断处理程序

注意:多个中断可以同时挂起。这就是每个设备特定的中断处理程序必须确认已完成对中断的处理的原因, 只有在IRQ_PENDING_1中的该中断挂起位被清除后, 该原因才会被清除

由于相同的原因, 对于准备投入生产的OS, 可能希望在中断处理程序中把切换开关的逻辑包装在一个循环里:这样, 将能够在单个处理程序执行期间处理多个中断

计时器初始化

Raspberry Pi系统计时器是一个非常简单的设备。它具有一个计数器, 该计数器在每个时钟滴答之后将其值增加1。它还具有连接到中断控制器的4条中断线(因此它可以生成4个不同的中断)和4个相应的比较寄存器。当计数器的值等于存储在比较寄存器之一中的值时, 将触发相应的中断

这就是为什么在能够使用系统定时器中断之前, 需要使用一个非零值初始化比较寄存器之一, 该值越大,则越晚生成中断

这是在 timer_init 函数中完成的:

const unsigned int interval = 200000;
unsigned int curVal = 0;

void timer_init ( void )
{
    curVal = get32(TIMER_CLO);
    curVal += interval;
    put32(TIMER_C1, curVal);
}
  1. 读取当前计数器值
  2. 增加当前计数器值
  3. 为中断编号1设置比较寄存器的值
通过操作 interval 值, 可以调整第一次定时器中断的产生时间 

处理计时器中断

最后, 来到了计时器中断处理程序。实际上很简单:

void handle_timer_irq( void ) 
{
    curVal += interval;
    put32(TIMER_C1, curVal);
    put32(TIMER_CS, TIMER_CS_M1);
    printf("Timer interrupt received\n\r");
}
  1. 更新比较寄存器, 以便在相同的时间间隔后产生下一个中断
  2. 通过将 1 写入 TIMER_CS 寄存器确认 中断

    在文档里 TIMER_CS 中被称为 “计时器控制/状态” 寄存器
    
    该寄存器的位[0:3]可用于确认来自4条可用中断线之一的中断
    

结论

最后需要看的 kernel_main 函数, 其中协调了所有先前讨论的代码

编译并运行示例后, 应在中断发生后输出 "Timer interrupt received" 

请尝试自己动手做, 不要忘记仔细检查代码并进行试验
Next:进程调度 Previous: 处理器初始化 Home: 用树莓派学习操作系统开发]