本文隶属于AVR单片机教程系列。

 

中断,是单片机的精华。

中断基础

当一个事件发生时,CPU会停止当前执行的代码,转而处理这个事件,这就是一个中断。触发中断的事件成为中断源,处理事件的函数称为中断服务程序(ISR)。

中断在单片机开发中有着举足轻重的地位——没有中断,很多功能就无法实现。比如,在程序干别的事时接受UART总线上的输入,而uart_scan_char等函数只会接收调用该函数后的输入,先前的则会被忽略。利用中断,我们可以在每次接受到一个字节输入时把数据存放到缓冲区中,程序可以从缓冲区中读取已经接收的数据。

AVR单片机支持多种中断,包括外部引脚中断、定时器中断、总线中断等。每一个中断被触发时,通过中断向量表跳转到对应ISR。如果一个中断对应的ISR不存在,链接器会把复位地址放在那里,如果这个中断被响应程序就会复位(但单片机不会复位)。

那么,我们以前从未写过ISR,但经常改变引脚电平,为什么没有复位呢?因为中断默认是不开启的。要启用一个中断,需要让两个位于不同寄存器中的位为1,一个是中断对应的中断使能位,每个中断都有各自的位,另一个是全局中断使能位,位于寄存器SREG中,不能直接存取,需要通过定义在<avr/interrupt.h>头文件中的sei()函数开全局中断,相对地,cli()用于关全局中断。

先来写第一个带中断的程序吧。从原理图中可以看到,PB2旁边标明了INT2,表示PB2引脚可用于外部中断2。把一个按键连接到PB2引脚上,即开发板最下方的7P排母的最右边。利用中断,我们实现每按一次按键就翻转LED状态的功能。

#include <avr/io.h>
#include <avr/interrupt.h>

int main()
{
    PORTB |=    1 << PORTB2;
    EICRA |= 0b10 << ISC20;
    EIMSK |=    1 << INT2;
    DDRC  |=    1 << DDC4;
    sei();
    while (1)
        ;
}

ISR(INT2_vect)
{
    PORTC ^= 1 << PORTC4;
}

ISC21:0两位指定外部中断的类型,这里设置为下降沿,即按键按下时触发;INT2位使能外部中断2;全部初始化完成后,sei()启用全局中断,然后单片机就会相应按键按下的事件了。

ISR(INT2_vect)指示这个函数是外部中断2的ISR。每个中断ISR都有自己的名字,由数据手册12章Source一栏的内容加上_vect组成,这个名字可以当成函数名字来使用。

如果多个中断同时触发,单片机会先响应优先级高的。一些单片机支持自定义的优先级,但在AVR单片机中,只有简单的地址低的优先级高的规则。

中断可以被中断吗?在AVR单片机中,执行一个中断处理函数会自动地关闭全局中断,此时程序不会被中断,但可以手动地sei()使中断可以被处理。程序是否相应中断仅取决于该中断是否被启用,与其优先级无关。

当然,中断不是完美的。其一,你也许已经发现上面的程序不能很好的工作,有时候明明按下了按键,灯却一闪就灭。这是因为,按键存在抖动,比单片机时钟周期长,能触发多个中断。以前把button_down()放在main函数的while循环里时就没有这个问题,正是循环中的delay滤除了这种抖动。

其二,进入和退出中断,除了需要CPU几个周期来改变PC(程序计数器,当前执行指令的地址)外,还需要保护和恢复现场,包括SREG寄存器与ISR中用到的通用寄存器。下面这段汇编代码可以在Solution ExplorerOutput Files\xxx.lss中找到。

00000094 <__vector_3>:
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(INT2_vect)
{
  94:	1f 92       	push	r1
  96:	0f 92       	push	r0
  98:	0f b6       	in	r0, 0x3f	; 63
  9a:	0f 92       	push	r0
  9c:	11 24       	eor	r1, r1
  9e:	8f 93       	push	r24
  a0:	9f 93       	push	r25
    PORTC ^= 1 << PORTC4;
  a2:	98 b1       	in	r25, 0x08	; 8
  a4:	80 e1       	ldi	r24, 0x10	; 16
  a6:	89 27       	eor	r24, r25
  a8:	88 b9       	out	0x08, r24	; 8
}
  aa:	9f 91       	pop	r25
  ac:	8f 91       	pop	r24
  ae:	0f 90       	pop	r0
  b0:	0f be       	out	0x3f, r0	; 63
  b2:	0f 90       	pop	r0
  b4:	1f 90       	pop	r1
  b6:	18 95       	reti

这段代码不必理解,更不用会写。94a0行是保护现场,依次将寄存器r1r0SREG(即0x3f)、r24r25push进栈,把r1清零,一共用了12个周期,还要加上响应中断的4个周期;a2a8是恢复现场,把这些寄存器原来的值逆序地从栈上pop出来,用了15个周期;而只有中间aab6的语句是用于执行用户代码的,在总共35个周期中只占4个周期。

当然,这个比例很小是因为这个ISR过于简单。但是,ISR更复杂也意味着有更多寄存器需要push和pop,中断的响应时间更长。

这个例子并没有中断效率低下的意思,而是表明不能过于频繁地依赖中断。比如接下来要讲的定时器中断,我通常设置为1ms间隔,只有一次到0.1ms,再快恐怕就起不到定时的作用了。

定时器中断

定时器,顾名思义,定时用的。之前我们在main函数的while (1)循环中,每个周期执行一些代码,然后延时一个固定的时长。我也曾见过根据该次周期的工作量来计算延时时长的操作,但毕竟写BASIC的人学得也basic吧,这种做法的定时仍不精确。利用定时器中断(其实不必中断),我们可以实现精确的定时,使每一周期的时间严格相同。

如果对操作系统有一点了解,就会知道操作系统需要进行任务调度。然而,任务在执行时,并不知道自己该何时被调度走。实际上,是操作系统在定时器中断中打断了任务的正常执行,然后进行调度。定时器中断是操作系统的基础。

在AVR单片机定时器的各种模式中,普通模式和CTC模式常用于产生定时器中断。我们仍然以定时/计数器0为例。

在普通模式中,使用TIMER0_OVF中断,频率为\(\frac {f_{CPU}} {256 \cdot N}\),\(N\)为分频系数。这样产生的定时器中断精确但不确切,因为N的取值是很离散的。如果只需要在中断中进行外设轮询的话,普通模式就足够了。

如果在ISR的第一行就给TCNT0赋值,或是使用TIMER0_COMPA中断并在起始处写TCNT0 = 0,那么可以改变中断频率,但由于有编译器插入的保护现场的代码的存在,这种定时不够精确,而CTC模式解决了这个问题。

在CTC模式中,使用TIMER0_COMPA中断,频率精确地为\(\frac {f_{CPU}} {N \cdot (OCR0A + 1)}\)(注意没有蜂鸣器频率公式中的\(2\))。

还需要提醒一句,如果想要中断被响应,必须保证main函数不退出,因为编译器会在退出处加上一句cli()。最简单的方法是在main函数的最后加上一句while (1);

后台动态扫描

数码管的动态扫描需要每隔一段时间就换一位点亮是一件很烦人的事,尤其是在操控其他外设的程序已经比较复杂的时候。我本来想把中断完美地拖到第二期再讲,没想到自己也受不了动态扫描的折磨,在某个版本的库中就放出了segment_auto函数来接管这项工作。它正是使用了定时器中断。

实现思路很简单,把要显示的数据放在客户和库可以共同取用的变量中,在中断里逐位显示,只要中断够快,就可以实现动态扫描,使每一位看起来都在亮。

#include <avr/io.h>
#include <avr/interrupt.h>
#include <ee1/segment.h>

void segment_int_init()
{
    // other initializations, ex. pins
    TCCR0A = 0b10 << WGM00; // CTC mode
    TCCR0B = 0b0 << WGM02 | 0b100 << CS00; // divide by 256
    OCR0A = 97; // ~1ms
    TIMSK0 = 1 << OCIE0A; // compare match A interrupt
    sei();
}

static uint8_t segment_int_data[SEGMENT_DIGIT_COUNT];

void segment_int_display(/* ... */)
{
    // store the display pattern in segment_int_data
}

ISR(TIMER0_COMPA_vect)
{
    static uint8_t cur = 0;
    // display the cur-th digit according to segment_int_data
    if (++cur == SEGMENT_DIGIT_COUNT)
        cur = 0;
}

如果你把以上代码放在可执行程序的项目中,那完全没有问题,但如果是放在一个静态库项目中,然后在可执行程序项目中引用它,那么定时器中断的ISR是不会链接进程序的。这是因为,从链接器的角度来讲,这个ISR从来没有被调用过,因此就被当成无用的函数扔掉了。为了让链接器把ISR链接进程序,我们需要在main会执行的代码中调用它,最简单地:

if (0)
    TIMER0_COMPA_vect();

放在初始化中,既达到了目的,又没有运行时的负担。

作业

  1. 试着写一个库,管理开发板引出的16个引脚的外部中断。
  2. 研究定时器中断与PWM的关系。
  3. 改进ADC一讲中最后一个例程,把main函数还给客户。