非裸中断
在单片机中,中断(ISR)是一种在主程序执行之间执行的小程序。当中断请求时,硬件将会停止当前程序,记录当前进程的信息(储存于栈中),并将控制权转交给中断程序。当中断程序执行到末尾,将使用RETI
指令将控制权返还给之前的程序。
当前程序并不知道中断的存在,亦不期望中断对当前程序所使用的寄存器内数据进行任何修改,除非是特地被标记为volatile
的数据。因此,大部分的程序二进制接口(ABI)都规定中断需要还原其所污染的寄存器内的数据。对于诸如68HC11这类单片机来说,硬件将会在中断执行前自动保存数据。但是在AVR上面,情况就不同了。AVR的硬件只会自动保存当前程序指针(PC),其他可能会被修改的寄存器则需要被手动保存,包括:
- 通用寄存器R0-R31,如果将会被修改的话。
- 状态寄存器SREG。
- 栈指针,如果有任何入栈出栈操作。
GNU AVR C编译器将会生成一个固定的ISR进入与结束代码段。
下面是一个空白的ISR的C语言代码,内部没有任何操作:/p>
ISR (TIMER0_COMPA_vect) {
}
GNU AVR C编译器将生成如下二进制机器码,即使我们已经打开了O2或O3级别的优化:
00000276 <__vector_14>:
276: 1f 92 push r1
278: 0f 92 push r0
27a: 0f b6 in r0, 0x3f ; 63
27c: 0f 92 push r0
27e: 11 24 eor r1, r1
280: 0f 90 pop r0
282: 0f be out 0x3f, r0 ; 63
284: 0f 90 pop r0
286: 1f 90 pop r1
288: 18 95 reti
下面是在这一段机器码所执行的操作:
- 保存R0与R1.R0将被作为临时寄存器储存一些运算中间数据,R1将被作为0寄存器(主要用于与数字0有关的运算)。
- 保存SREG:将其复制到一个通用寄存器后并入栈该通用寄存器。该寄存器包含了当前CPU的信息并将随着任何的数据计算被修改,例如
EOR
与ADD
指令。 - 清楚R1使其成为0寄存器。
- 执行实际的中断程序。
- 还原SREG、R0与R1中的数据。
- 执行
RETI
指令来结束中断。
如果中断还使用了其他寄存器,那么编译器也会将他们入栈保存。
裸中断
我们也可以通过添加ISR_NAKED
来防止GNU AVR C编译器生成这一段固定的ISR进入与结束代码段。
在大部分情况下,我们都不应该玩这个使用ISR_NAKED
的骚操作,因为自动生成的代码虽然繁复但是一定能正常工作。但是,有时我们也想要这样的骚操作,像是性能不足,或者单纯手痒。
下面这个例子展现了一个在计时器匹配中断中重置一个值的C语言代码:
volatile uint8_t value;
ISR (TIMER0_COMPA_vect) {
value = 20;
}
如果我们直接编译这段C语言代码(打开O2或O3优化),我们将得到如下机器码:
00000276 <__vector_14>:
276: 1f 92 push r1
278: 0f 92 push r0
27a: 0f b6 in r0, 0x3f ; 63
27c: 0f 92 push r0
27e: 11 24 eor r1, r1
280: 8f 93 push r24
282: 84 e1 ldi r24, 0x14 ; 20
284: 80 93 a0 01 sts 0x01A0, r24 ; 0x8001a0
288: 8f 91 pop r24
28a: 0f 90 pop r0
28c: 0f be out 0x3f, r0 ; 63
28e: 0f 90 pop r0
290: 1f 90 pop r1
292: 18 95 reti
这个ISR将会有14行指令,一共消耗15个word的程序空间。这个程序在功能性上没有问题,但是我们(出于强迫症)并不满意它的性能与效率。
- 编译器保存了临时寄存器R0,但是并没有使用。
- 编译器保存并清空了0寄存器R1,但是并没有使用。
- 为了清空R1所执行的
EOR R1, R1
导致了SREG被污染,所以也要入栈。
因为我们只是单纯的将一个常数写进变量value
,我们可以使用如下汇编来提高性能:
volatile uint8_t value;
ISR (TIMER0_COMPA_vect, ISR_NAKED) {
asm volatile (
" PUSH R16 \n"
" LDI R16, 20 \n"
" STS %[var], %[dat] \n"
" POP R16 \n"
" RETI \n"
:
: [var] "m"(value)
, [dat] "I"(20)
);
}
让我们再编译一次:
00000276 <__vector_14>:
276: 0f 93 push r16
278: 04 e1 ldi r16, 0x14 ; 20
27a: 40 93 a0 01 sts 0x01A0, r20 ; 0x8001a0
27e: 0f 91 pop r16
280: 18 95 reti
翻译一下就是:
- 我们保存了一个高位寄存器来装这个常数。在AVR中只有R16到R31的高位寄存器可以被赋值常数。
- 我们载入常数后并为内存中的那个变量赋值。
- 我们恢复这个寄存器并结束中断。
在这个裸中断中,我们只是用了5个指令,消耗了6个word的程序空间。四舍五入赚了一个亿(性能提高了超过一倍)。
什么时候使用裸中断与裸方程?
笼统来说,别碰!这点性能没必要,除非迫不得已。
下面是我个人的想法,仅作参考:
为了性能,我会使用裸中断,但是裸方程还是算了。
因为中断需要做到小而快,所以使用汇编写一些裸中断是有意义的。而对于在主程序中执行的方程,执行时间与代码长度的要求并不高,所以没太大意义。
其次,终端的ABI相对简单:什么被污染,什么就该被保存。而对于方程的接口,我们需要参考ABI考虑哪些寄存器可以被使用,哪些寄存器必须要还原,哪些寄存器又将被用于传递数据,寄存器装不下的数据如何在栈中保存,等等。并且不同编译器也会对方程制定不同的ABI。这太复杂了,写多了会脱发。
一点相关的历史
在单片机发展的早期,储存程序的硬件贵,且单片机并没有太多通用寄存器时,开发者需要面临极其有限的程序空间。
为了节约程序空间,硬件将会在中断前自动将所有数据入栈保存。这就避免了使用宝贵的程序空间来写将这些寄存器入栈的代码,但缺点是,硬件也需要一定时间来入栈这些数据,导致中断的响应变慢。例如,在中断前,68HC11将消耗约十个时钟周期来入栈累加器(2字节),两个指针寄存器(各2字节),栈指针(2字节)与状态寄存器(1字节)。在中断结束时,这个操作还会被以相反顺序执行一遍。
我们可以看到,有9个字节将被入栈。如果手动执行的话,还好机器自动执行了,不然这将是不少的代码。但是代价也是存在的:消耗时间,并且不管我们实际是否需要,硬件都将消耗时间将这些数据入栈。为了提高响应速度,68HC11有一个特殊指令来在等待ISR事件时提前入栈这些数据。
时过境迁,现在的工艺能够相对便宜地生产大量储存空间。而且,现代的RISC单片机的通用寄存器变得比以前更多了(例如CISC的68HC11只有2个8-bit累加器,但RISC的AVR有32个8-bit的通用寄存器)。显然,一股脑地入栈变得不太现实。
因为程序空间现在量大管饱,且寄存器的数量也变多了,单片机的设计理念也得到了变化。比起之前的硬件自动一股脑入栈,现在开发者将决定哪些寄存器将入栈,哪些又不需要入栈。这不仅能够提高中断的响应速度,也能够节约栈空间的浪费,尽管这将需要在中断中写更多的代码来手动入栈出栈。