MIPS架构中,中断、异常、系统调用以及其它可以中断程序正常执行流的事件统称为异常(exception)
,统一由异常处理机制进行处理。
异常和中断概念在不同架构上的含义区别:
异步中断
;而狭义上的异常称为同步中断
;异常中断
类似于MIPS架构的异常
概念。在阅读相关书籍的时候,请注意区分这些概念。
MIPS架构所涉及的事件,都有哪些呢?
在进一步分析异常和中断之前,先来理解一个概念,什么是精确异常?
在MIPS架构的文档中,我们经常看到一个术语”精确异常”,英文称之为precise exception
。那到底什么是精确异常,什么是非精确异常呢?
在通过流水线获取最佳性能的CPU中,体系结构的顺序执行模型其实是硬件巧妙维护的假象。如果硬件设计不够完美,异常就可能导致该假象暴露。
当异常中断正在执行的线程时,CPU的流水线中肯定还有几条处于不同阶段尚未完成的指令。如果我们想要从异常返回时,继续不受破坏地执行被打断的程序执行流,那么流水线中的每条指令都必须要执行完,从异常返回时,仿佛什么都没有发生才行。
一个CPU体系结构具备精确异常的特性,必须满足任何异常发生时,都必须确定的指向某条指令,这条指令就是产生异常的指令。而在该指令之前的指令必须都执行完,异常指令和后续指令好像都没有发生。所以,当说异常是精确异常时,处理异常的软件就可以忽略CPU实现的时序影响。
MIPS架构的异常基本上都是精确异常。其构成要素满足:
MIPS实现精确异常的代价高昂,因为它限制了流水线的作用范围。尤其是FPU硬件单元。我们前面讲过,浮点指令不能遵守MIPS架构的5级流水线,需要更多级的流水线才能完成。所以,浮点单元一般都有自己独立的流水线。这种现状导致跟在MIPS浮点指令后的指令必须在确认浮点指令不会产生异常后才能提交自己的状态。
早期的MIPS架构乘法和除法指令,因为执行周期不固定。比如,乘法需要4-10个周期,除法占用15-30个周期。读流水线的影响不确定,所以,存在非精确异常的情况。但是符合MIPS32规范的CPU通过规避,已经不存在这个问题了。
既然异常是精确的,那么从程序员的角度看,异常发生的时机就是确定的,没有歧义:异常之前执行的最后一条指令就是一场受害指令的最后一条。如果该异常不是中断,受害指令就是刚刚结束ALU阶段的指令。
但是,需要注意的是,MIPS架构不承诺精确的中断延时,中断信号到达CPU之前可能需要花一个或者几个时钟周期重新同步。
我们知道,CPU使用硬件或者软件分析异常,然后根据类型将CPU派发到不同的入口点。这个过程就是中断响应。如果通过硬件,直接根据中断输入信号就能在不同的入口点处理中断,称为向量化中断。比如,常见的ARM架构的Cortex-M系列基本上就是采用向量化中断的方式。历史上,MIPS架构CPU很少使用向量化中断的方式,主要是基于以下几个方面的考虑。
总结来说,高端CPU的时钟频率肯定远远快于外设,所以写一个中断通用处理程序完全可以满足性能要求。所以,自从在MIPS32架构上添加了向量化中断之后,几乎没有人使用。
但是,MIPS架构上,并不是所有的异常都是平等的,他们之间也是有优先级区分的,总结如下:
为了效率,所有异常入口点都位于不需要地址映射的内存区域,不经过Cache的kseg1空间,经过cache的kseg0空间。当SR(BEV)等于1时,异常入口地址位于kseg1,且是固定的;当SR(BEV=0)时,就可以对EBase寄存器进行编程来平移所有入口点,比如说,kseg0的某个区域。当使用多处理器系统时,想使各个CPU的异常入口点不同时,这个功能就很用了。
对于32位地址的0x80000000
和64位地址的0xFFFFFFFF80000000
而言是一样的。所以,下表只用32位地址表示出异常入口点。
表中的BASE代表设置到EBase寄存器中的异常基址。
最初的异常向量间的距离默认是128字节(0x80),可能是因为最初的MIPS架构师觉得32条指令足够编写基本的异常处理例程了,不需要浪费太多内存。但是现代系统一般不会这么节省。
下面是发生异常时,MIPS架构CPU的处理过程:
这时候,异常处理程序运行在异常模式(SR(EXL)标志位被置),而且不会修改SR寄存器的其余部分。对于常规的异常处理程序保存其状态,将控制权交给更为复杂的软件执行。异常模式下,只是保证系统安全地保存关键的状态,包括旧SR值。
异常处理程序工作在异常模式下,不会再响应外部中断。所以,对于TLB未命中异常处理程序(也就是TLB重填异常处理程序)来说,如果读取TLB表(像Linux内核,一般将映射表保存在kseg2段地址空间中)时,发生页表地址读取异常时,程序会再次返回到异常程序入口点。Cause寄存器和地址异常相关的寄存器(BadAddr,EntryHi,甚至Context和Xcontext)都会被定位到访问页表时的TLB未命中异常相关的信息上。但是EPC寄存器的值仍然指向最初造成TLB未命中的指令处。
这样的话,通用异常程序修复kseg2中的页表未命中问题(也就是将页表的地址合法化),然后,就返回到用户程序。因为我们没有修复任何与第一次地址miss相关的信息,所以,此时用户程序会再次发生地址miss。但是,页表的地址miss问题已经修复,不会再产生二次嵌套地址异常。这时候,TLB异常处理程序就会执行上面的代码,加载页表中的页表映射关系到TLB中。
MIPS异常处理程序的基本步骤:
eret
,完成从异常的返回:它清除SR(EXL)标志位,返回到EPC寄存器保存的地址处开始执行。嵌套异常概念很好理解,就是异常处理程序中,再次发生异常。就像上面我们描述的TLB未命中异常处理程序中,再次发生读取页表地址miss异常一样。
但是,嵌套异常也分为2种:一种就是上面TLB未命中异常嵌套TLB未命中异常,这种不需要人为干预EPC和SR状态寄存器;另外一种,就需要我们必须保存被中断程序的EPC寄存器和SR寄存器内容。虽然,MIPS架构为异常处理程序保留了通用目的寄存器k0和k1。但是,旧异常程序一旦重启,不能完全信赖这两个寄存器。因为这时候,k0和k1可能被插入进来的异常处理程序使用过。
如果想要异常处理程序能够适合嵌套使用,必须使用某些内存位置保存这些寄存器值。所有这些需要保存的数据组成的数据结构通常被称为异常帧
;嵌套的多个异常帧通常存储在栈上。
每个这样的异常处理程序都会消耗栈的资源,所以不能任意嵌套异常。通常的处理机制是,异常嵌套的层数和中断优先级的个数相同,高优先级可以嵌套低优先级,同优先级不能嵌套。
在异常处理程序的设计过程中,应该尽量避免所有的异常:中断可以通过SR(IE)标志位进行屏蔽;其它异常可以通过恰当的软件规则避免。比如,内核态(大多数异常处理程序工作在该模式下)不会发生特权违反异常,程序可以避免寻址错误和TLB未命中异常。尤其是处理高优先级的异常时,这样的原则很重要。
下面是一个非常简单的异常处理程序,只是在增加计数器的值:
.set noreorder
.set noat
xcptgen:
la k0, xcptcount # 得到计数器的地址
lw k1, 0(k0) # 加载计数器
addu k1, 1 # 增加计数值
sw k1, 0(k0) # 存储计数器
eret # 返回到程序
.set at
.set reorder
此处的计数器xcptcount
最好位于kseg0中,这样在读写它时就不会得到TLB未命中异常。
MIPS架构的异常机制是通用的,但是说实话,有两种异常发生的次数比其他所有的加起来都多。一个就是TLB未命中异常;另一个就是中断。而且中断响应的时间要求很严格。
中断是非常重要的,所以我们单独讲解:
MIPS架构的CPU在Cause寄存器中有一组8个独立的中断标志位,其中的2个中断位是软件中断,比如说,计数器和定时器使用。有时侯,计数器/定时器中断也可能和外部中断共享一个中断,但这多半不是一个好主意。
每个时钟周期都会对中断输入信号进行采样,如果使能,就会导致中断发生。
CPU是否响应某个中断,由寄存器SR中的相关位控制,下面是三个相关控制域:
软件中断位的作用是什么? 为什么要在Cause寄存器中提供2个中断标志位,一旦被设置,立即触发一个中断,除非被屏蔽。 根源就在于”
除非被屏蔽
“。我们知道系统中,中断任务也分为高优先级和低优先级。软件中断无疑为处理低优先级中断任务提供了一种比较完美的机制。当高优先级的中断处理完成后,软件将打开中断屏蔽位,挂起的软件中断将会发生。 为什么不使用软件模拟同样的效果呢?既然已经提供了中断处理机制,捎带脚的实现软件中断,减少软件的负荷,岂不是既方便又实惠。
为了查找哪个中断输入信号是有效的,需要查看Cause寄存器。所有的中断优先级都是相同的,较旧的CPU使用通用异常入口点。但是MIPS32/64架构CPU为中断提供了一个可选的不同的异常入口点,这能节省几个时钟周期。通过Cause寄存器的IV标志位进行使能。
中断处理的正常步骤如下:
当对SR寄存器做出改变时,必须小心CP0协处理器的遇险问题。
MIPS架构对所有的中断一视同仁,而如果你想实现不同优先级的中断怎么办呢?
首先,为我们的中断系统定义一个策略:
中断处理程序不仅可以按照分配给具体中断源的优先级IPL运行,它们还允许程序员升高和降低IPL。驱动程序和硬件通信,或者中断处理程序中,经常需要在临界代码段禁止中断,所以,程序员可以通过临时升高IPL,禁止某个设备的中断。
这样设计的一个系统,只要高于IPL设置的中断可以继续响应,而且不会受低IPL中断的影响。这样,我们就可以很好地区分对响应时间严格的中断。类Unix系统一般都是基于这种思想进行设计的,一般使用4到6个IPL优先级。
当然还有其它的方式实现中断系统,但是这样一个简单的策略,具有以下的特点:
MIPS架构的CPU在不同中断级别之间进行转换时,必须修改状态寄存器(SR),因为其包含所有的中断控制位。有一些系统上,中断优先级的切换还会要求修改外部中断控制器,比如X86的高级可编程中断控制器-APIC,还需要维护一些全局变量。但是,这儿我们先不关注这些,先着重理解一下SR中断控制位如何影响IPL。同协处理器的访问一样,SR寄存器同样不能直接访问,所以需要我们编写一段汇编代码对其进行读取、修改:
mfc0 t0, SR
1:
or t0, things_to_set
and t0, ˜(things_to_clear)
2:
mtc0 t0, SR
ehb
上面的代码,先是从SR寄存器中读取原先的数值,然后通过or或者and操作,修改想要的操作位,最后再写回到SR寄存器中。而最后的ehb指令是遇险屏障指令,保证在运行后面的代码之前,前面的内容安全的写入到寄存器中了。
上面的代码我们不得不考虑一个问题,如果在执行过程中,被打断怎么办?所以,我们需要对SR的修改操作是原子操作。
对于原子操作的概念我们之前已经多次提到,故在此不再累述。如果有需要,请看之前的文章。执行原子操作的代码段一般称为临界区。
对于单处理器系统,只要关闭中断,就可以保护临界区代码的执行。这很简单粗暴啊,但是有效就行。
对于多处理器系统而言,禁止中断不能保证RMW(读-修改-写)的步骤是原子操作。所以,MIPS架构必须提供原子性操作。
MIPS架构实现原子性操作的方法:
di
指令代替mfc0
。di
会自动清除SR(IE)标志位,返回SR原始值到一个通用寄存器中。但是,这个功能在此版本上还是一个兼容性功能,所以你需要特别注意你的CPU是否支持这条指令。test-and-set
指令构建原子操作,从而满足临界代码区的保护要求,而不必禁止中断。而且,这种机制适用于多核处理器或者硬件多线程系统。细节参考下一节。众所周知,信号量是实现临界代码区的一种事实约定(当然扩展的信号量可以做更多事情)。简单的信号量也可以称为互斥锁。信号量实质上是并发运行的进行共享的一个内存位置,通过某种设置,一次只能由一个进程访问。对于信号量的理解,我们之前已经写过文章,请参考《Linux内核33-信号量》。
信号量的使用如下代码段所示:
wait(sem);
/* 临界代码区 */
signal(sem);
为了叙述方便,我们假设信号量就是0,1两个值,1表示未使用,0表示在使用。那么,wait()函数就是等待值为1。如果等到,进行P操作,信号量的值减1,并返回。道理很简单,唯一的要求是硬件可以实现减1操作的原子性,换句话说,就是硬件必须提供test-and-set这样的原子操作指令。不管是中断,还是多核系统,都不能影响这个原子操作的正确性。
大部分的CPU都有这样特殊的指令:
lock
前缀锁住总线实现原子操作;ldrex
和strex
独占指令实现原子操作,早期版本的ARM架构使用swp
指令;对于支持X86-多核
的系统而言,使用test-and-set
过程代价非常大。实际上,其执行过程是:所有共享内存都必须停止,使用信号量的用户获取该值,完成test-and-set
操作,然后将结果同步到每一份备份中。因为一些偶尔使用的重要数据,而占用了整个总线,这对于大型多核平台,牺牲了很多性能。
如果能够不在每次都必须严格保证原子性的情况下,实现test-and-set
操作要高效得多。换句话说,就是尝试set操作,如果是原子的,就成功;不是原子的,就重新尝试。完全由软件决定是否set成功,前提是软件能够知道set是否成功。
于是,MIPS架构为支持操作系统的原子操作,特地加了一组指令ll/sc
。它们这样来使用:
atomic_block:
ll XX1, XXX2
….
sc XX1, XXX2
beq XX1, zero, automic_block
….
在ll/sc中间写上你要执行的代码体,这样就能保证写入的代码体是原子执行的(不会被抢占的)。
其实,LL/sc两语句自身并不保证原子执行,但他耍了个花招:
用一个临时寄存器XX1,执行LL后,把XXX2中的值载入XX1中,然后会在CPU内部置一个标志位,我们不可见,并保存XXX2的地址,CPU会监视它。在中间的代码体执行的过程中,如果发现XXX2的内容变了(即是别的线程执行了,或是某个中断发生了),就自动把CPU内部那个标志位清0。执行sc 时,把XX1的内容(可能已经是新值了)存入XXX2中,并返回一个值存入XX1中,如果标志位还为1,那么这个返回的值就为1;如果标志位为0,那么这 个返回值就为0。为1的话,就表明这对指令中间的代码是一次性执行完成的,而不是中间受到了某些中断,那么原子操作就成功了;为0的话,就表明原子操作没 成功,执行后面beq指令时,就会跳转到ll指令重新执行,直到原子操作成功为止。
所以,我们要注意,插在LL/sc指令中间的代码必须短小。
据经验,一般原子操作的循环不会超过3次。
我们再回头分析wait()函数的实现,参考下面的代码。在这儿,sem是一个0/1
信号量:
wait:
la t0, sem
TryAgain:
ll t1, 0(t0)
bne t1, zero, WaitForSem
li t1, 1
sc t1, 0(t0)
beq t1, zero, TryAgain
/* 成功获取锁 */
jr ra
这儿,添加了WaitForSem标签,用来处理如果一直申请锁失败的情况下,需要做的处理。可以用来实现阻塞等待或者非阻塞等待。
ll/sc
是为多核系统设计的,但是,对于单核系统也非常有价值,因为不涉及关闭中断。避免了上面提出的使用过程中,禁止中断的问题。并可以在处理最坏中断延时的情况下发挥作用,这对于嵌入式系统非常重要。
MIPS32规范的第二版中,引入了两个新的特性,使中断的处理更为高效。这两个特性就是向量化中断和EIC模式。
向量化中断,发生中断异常时,根据中断的输入信号,从8个入口地址中选择一个开始执行的地址。如果两个中断同时发生,硬件选择中断号高的执行。向量化中断通过IntCtl(VS)设置,对于不同中断入口地址间距给出了几种不同的选择(零值导致所有中断都是用同样的入口点,这就回到了传统的做法。
嵌入式系统常常有大量的中断信号,远远超过传统的MIPS架构CPU的6个硬件输入。在EIC模式下,这6个以前相互独立的信号变成一个6位的二进制数:0代表没有中断,1-63表示不同的中断码。每个非0的中断码都有自己的中断入口点,允许适当设计的中断控制器能够分派给CPU处理的事件多达63个。
向量化中断在复杂的系统没有使用的原因是因为,因为其它一些约束条件,牺牲掉向量化中断省下的几个时钟周期,并不影响系统的整体性能。向量化中断一般只有在嵌入式CPU中使用。
本文分享自 嵌入式ARM和Linux 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有