AVR裸中断

在AVR开发中使用裸中断

--by Captdam @ Sep 1, 2024

【中】 这里是这篇文章的中文版

非裸中断

在单片机中,中断(ISR)是一种在主程序执行之间执行的小程序。当中断请求时,硬件将会停止当前程序,记录当前进程的信息(储存于栈中),并将控制权转交给中断程序。当中断程序执行到末尾,将使用RETI指令将控制权返还给之前的程序。

当前程序并不知道中断的存在,亦不期望中断对当前程序所使用的寄存器内数据进行任何修改,除非是特地被标记为volatile的数据。因此,大部分的程序二进制接口(ABI)都规定中断需要还原其所污染的寄存器内的数据。对于诸如68HC11这类单片机来说,硬件将会在中断执行前自动保存数据。但是在AVR上面,情况就不同了。AVR的硬件只会自动保存当前程序指针(PC),其他可能会被修改的寄存器则需要被手动保存,包括:

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
	

下面是在这一段机器码所执行的操作:

  1. 保存R0与R1.R0将被作为临时寄存器储存一些运算中间数据,R1将被作为0寄存器(主要用于与数字0有关的运算)。
  2. 保存SREG:将其复制到一个通用寄存器后并入栈该通用寄存器。该寄存器包含了当前CPU的信息并将随着任何的数据计算被修改,例如EORADD指令。
  3. 清楚R1使其成为0寄存器。
  4. 执行实际的中断程序。
  5. 还原SREG、R0与R1中的数据。
  6. 执行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的程序空间。这个程序在功能性上没有问题,但是我们(出于强迫症)并不满意它的性能与效率。

因为我们只是单纯的将一个常数写进变量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
	

翻译一下就是:

  1. 我们保存了一个高位寄存器来装这个常数。在AVR中只有R16到R31的高位寄存器可以被赋值常数。
  2. 我们载入常数后并为内存中的那个变量赋值。
  3. 我们恢复这个寄存器并结束中断。

在这个裸中断中,我们只是用了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的通用寄存器)。显然,一股脑地入栈变得不太现实。

因为程序空间现在量大管饱,且寄存器的数量也变多了,单片机的设计理念也得到了变化。比起之前的硬件自动一股脑入栈,现在开发者将决定哪些寄存器将入栈,哪些又不需要入栈。这不仅能够提高中断的响应速度,也能够节约栈空间的浪费,尽管这将需要在中断中写更多的代码来手动入栈出栈。