Arduino中断(引脚更改)


8

我使用中断函数将接收自的值填充到数组中digitalRead()

 void setup() {
      Serial.begin(115200);
       attachInterrupt(0, test_func, CHANGE);
    }

    void test_func(){
      if(digitalRead(pin)==HIGH){
          test_array[x]=1;  
        } else if(digitalRead(pin)==LOW){
          test_array[x]=0;  
        }
         x=x+1;
    }

问题是当我打印时,test_array会有诸如:111或的值000

据我了解,如果我CHANGEattachInterrupt()函数中使用该选项,则数据序列应始终0101010101不重复。

由于数据来自无线电模块,因此数据变化非常快。


1
中断不会反跳按钮。您是否正在使用硬件反跳?
伊格纳西奥·巴斯克斯

请张贴的完整代码,其中包括pinxtest_array定义,并loop()方法; 它将使我们能够看到在访问由修改的变量时这是否可能是并发问题test_func
jfpoilpret 2015年

2
您不应该在ISR中两次digitalRead():考虑一下,如果您在第一个调用中获得LOW,而在第二个调用中获得HIGH,将会发生什么情况。相反,if (digitalRead(pin) == HIGH) ... else ...;或者更好的是,这个单行ISR: test_array[x++] = digitalRead(pin);
Edgar Bonet 2015年

@EdgarBonet真好!对该评论+1。希望您不要介意我在回答中添加了一些内容,以包括您在此处提到的内容。另外,如果您决定提出自己的答案(包括此详细信息),那么我将删除我的添加内容,并给您一个投票,以便您获得代表。
克莱顿·米尔斯

@克莱顿·米尔斯(Clayton Mills):我正在准备一个(太长且略微切线的)答案,但是您可以继续进行编辑,这对我来说很好。
埃德加·博内

Answers:


21

作为这个冗长答案的序言 ...

这个问题使我对中断等待时间的问题深深着迷,以至于在计算周期而不是绵羊时失去了睡眠。我写此回复更多是为了分享我的发现,而不是仅仅回答问题:实际上,大多数材料可能都不适合于正确答案。我希望它对那些希望找到延迟问题解决方案的读者有用。预计前几节将对包括原始海报在内的广泛受众有用。然后,它一路变得毛茸茸。

克莱顿·米尔斯(Clayton Mills)在他的回答中已经解释说,响应中断会有些延迟。在这里,我将重点介绍量化延迟(使用Arduino库时,这是巨大的),以及将其最小化的方法。以下内容大部分是针对Arduino Uno和类似电路板的硬件的。

最小化Arduino的中断延迟

(或如何从99降为5个周期)

我将使用原始问题作为工作示例,并根据中断等待时间重述该问题。我们有一些外部事件触发中断(此处:引脚更改为INT0)。触发中断时,我们需要采取一些措施(此处:读取数字输入)。问题是:在触发中断与采取适当的措施之间存在一定的延迟。我们称这种延迟为“ 中断延迟 ”。长时延在许多情况下都是有害的。在此特定示例中,输入信号可能会在延迟期间发生变化,在这种情况下,我们会收到错误的读数。我们没有任何办法可以避免延迟:这是中断工作方式所固有的。但是,我们可以尝试使其尽可能短,以期将不良后果减至最少。

我们可以做的第一件事就是尽快在中断处理程序中采取对时间要求严格的操作。这意味着digitalRead()在处理程序的最开始处调用一次(并且只能调用 一次)。这是我们将在其上构建的程序的第零版本:

#define INT_NUMBER 0
#define PIN_NUMBER 2    // interrupt 0 is on pin 2
#define MAX_COUNT  200

volatile uint8_t count_edges;  // count of signal edges
volatile uint8_t count_high;   // count of high levels

/* Interrupt handler. */
void read_pin()
{
    int pin_state = digitalRead(PIN_NUMBER);  // do this first!
    if (count_edges >= MAX_COUNT) return;     // we are done
    count_edges++;
    if (pin_state == HIGH) count_high++;
}

void setup()
{
    Serial.begin(9600);
    attachInterrupt(INT_NUMBER, read_pin, CHANGE);
}

void loop()
{
    /* Wait for the interrupt handler to count MAX_COUNT edges. */
    while (count_edges < MAX_COUNT) { /* wait */ }

    /* Report result. */
    Serial.print("Counted ");
    Serial.print(count_high);
    Serial.print(" HIGH levels for ");
    Serial.print(count_edges);
    Serial.println(" edges");

    /* Count again. */
    count_high = 0;
    count_edges = 0;  // do this last to avoid race condition
}

我通过发送一系列宽度可变的脉冲来测试该程序及其后续版本。脉冲之间有足够的间隔以确保不丢失任何边沿:即使在完成前一个中断之前已接收到下降沿,第二个中断请求也将被保留并最终得到服务。如果脉冲短于中断等待时间,则程序在两个边沿均读取0。然后,报告的高电平数就是正确读取脉冲的百分比。

触发中断时会发生什么?

在尝试改进上面的代码之前,我们将看一下触发中断后立即发生的事件。故事的硬件部分由Atmel文档讲述。软件部分,通过拆卸二进制文件。

大多数情况下,传入的中断会立即得到处理。但是,MCU(意思是“微控制器”)可能处于某些时间紧迫的任务中间,在该过程中,中断服务被禁用。当它已经在服务另一个中断时,通常就是这种情况。发生这种情况时,只有在该时间紧迫的部分完成时,传入的中断请求才会被保留并得到服务。这种情况很难完全避免,因为Arduino核心库中有很多关键部分(我称之为“ libcore”幸运的是,这些部分很短并且仅经常运行。因此,在大多数情况下,我们的中断请求将立即得到处理。在下文中,我将假定我们不在乎这几个部分情况并非如此。

然后,我们的请求将立即得到服务。这仍然涉及很多东西,可能需要花费相当长的时间。首先,有一个固定的序列。MCU将完成当前指令的执行。幸运的是,大多数指令是单周期的,但是有些指令可能需要多达四个周期。然后,MCU清除内部标志,该标志禁止进一步的中断服务。目的是防止嵌套中断。然后,将PC保存到堆栈中。堆栈是为此类临时存储保留的RAM区域。PC(意思是“ 程序计数器“)是一个内部寄存器,用于保存MCU即将执行的下一条指令的地址。这是使MCU知道下一步要执行的操作,因此保存它是必不可少的,因为必须将其还原才能使主程序执行。程序从被中断的地方恢复,然后向PC加载特定于接收到的请求的硬连线地址,这是硬连线序列的结尾,其余部分由软件控制。

MCU现在从该硬连线地址执行指令。该指令称为“ 中断向量 ”,通常是“跳转”指令,它将带我们进入称为ISR(“ 中断服务程序 ”)的特殊例程。在这种情况下,ISR称为“ __vector_1”,也称为“ INT0_vect”,因为它是一个ISR,而不是向量,因此使用不当。该特定的ISR来自libcore。像任何ISR一样,它从一个序言开始,该序言将一堆内部CPU寄存器保存在堆栈中。这将允许它使用那些寄存器,并在完成后将它们恢复为以前的值,以免干扰主程序。然后,它将查找已向其注册的中断处理程序attachInterrupt(),它将调用该处理程序,这是我们read_pin()上面的功能。然后,我们的函数digitalRead()将从libcore 调用。digitalRead()将查看一些表,以将Arduino端口号映射到它必须读取的硬件I / O端口以及要测试的相关位号。它还将检查该引脚上是否有需要禁用的PWM通道。然后它将读取I / O端口...就完成了。好吧,我们实际上并没有完成服务中断的工作,但是时间紧迫的任务(读取I / O端口)已经完成,而当我们查看延迟时,这一切都至关重要。

以下是上述所有内容的简短摘要,以及相关的CPU周期延迟:

  1. 硬连线顺序:完成当前指令,防止嵌套中断,保存PC,向量的加载地址(≥4个周期)
  2. 执行中断向量:跳转到ISR(3个周期)
  3. ISR序言:保存寄存器(32个周期)
  4. ISR主体:查找和调用用户注册的函数(13个周期)
  5. read_pin:调用digitalRead(5个周期)
  6. digitalRead:找到相关端口和位进行测试(41个周期)
  7. digitalRead:读取I / O端口(1个周期)

我们将假设最佳情况,硬连线序列有4个周期。这给我们带来了99个周期的总延迟,在16 MHz时钟下大约为6.2 µs。在下面的内容中,我将探索一些可用于降低延迟的技巧。它们的复杂性从高到低依次排列,但是它们都需要我们以某种方式深入研究MCU的内部。

使用直接端口访问

显然,缩短延迟的首要目标是digitalRead()。该功能为MCU硬件提供了很好的抽象,但是对于时间紧迫的工作而言效率太低。摆脱这一块实际上是微不足道的:我们只是有来取代它digitalReadFast(),从digitalwritefast 库。只需少量下载,这几乎将延迟减少了一半!

好吧,这太容易了,以至于没有任何乐趣,我宁愿向您展示如何用困难的方式做到这一点。目的是使我们开始了解低级内容。该方法称为“ 直接端口访问 ”,并且在Arduino参考的“ 端口寄存器 ”页面上有很好的记录。此时,下载并查看ATmega328P数据表是一个好主意。乍看之下,这份650页的文件似乎有些令人生畏。但是,它被很好地组织为特定于每个MCU外设和功能的部分。我们只需要检查与我们正在做的事情有关的部分。在这种情况下,它是名为I / O ports的部分 。以下是我们从这些阅读中学到的内容的摘要:

  • Arduino引脚2实际上在AVR芯片上称为PD2(即端口D,位2)。
  • 通过读取一个称为“ PIND”的特殊MCU寄存器,我们可以立即获得整个端口D。
  • 然后,通过对进行按位逻辑和(C'&'运算符)来检查第2 1 << 2

因此,这是我们修改后的中断处理程序:

#define PIN_REG    PIND  // interrupt 0 is on AVR pin PD2
#define PIN_BIT    2

/* Interrupt handler. */
void read_pin()
{
    uint8_t sampled_pin = PIN_REG;            // do this first!
    if (count_edges >= MAX_COUNT) return;     // we are done
    count_edges++;
    if (sampled_pin & (1 << PIN_BIT)) count_high++;
}

现在,我们的处理程序将在调用后立即读取I / O寄存器。延迟为53个CPU周期。这个简单的技巧为我们节省了46个周期!

编写自己的ISR

循环修整的下一个目标是INT0_vect ISR。提供以下功能需要此ISR attachInterrupt():我们可以在程序执行期间随时更改中断处理程序。但是,尽管很高兴,但这对于我们的目的并不是真正有用。因此,与其让libcore的ISR定位并调用我们的中断处理程序,不如通过使用处理程序替换 ISR来节省几个周期。

这并不像听起来那么难。ISR可以像普通函数一样编写,我们只需要知道它们的特定名称,并使用ISR()avr-libc中的特殊宏定义它们即可。此时,最好查看avr-libc的有关中断的文档,以及数据表中名为“ 外部中断”的部分。这是简短的摘要:

  • 我们必须在称为EICRA(外部中断控制寄存器A)的特殊硬件寄存器中写一些位,以便配置在引脚值发生任何变化时触发的中断。这将在中完成setup()
  • 为了启用INT0中断,我们必须在另一个称为EIMSK(外部中断MaSK寄存器)的硬件寄存器中写一些位。这也将在中完成setup()
  • 我们必须使用语法定义ISR ISR(INT0_vect) { ... }

这是ISR和ISR的代码setup(),其他所有内容均保持不变:

/* Interrupt service routine for INT0. */
ISR(INT0_vect)
{
    uint8_t sampled_pin = PIN_REG;            // do this first!
    if (count_edges >= MAX_COUNT) return;     // we are done
    count_edges++;
    if (sampled_pin & (1 << PIN_BIT)) count_high++;
}

void setup()
{
    Serial.begin(9600);
    EICRA = 1 << ISC00;  // sense any change on the INT0 pin
    EIMSK = 1 << INT0;   // enable INT0 interrupt
}

这有一个免费的好处:由于此ISR比它所替代的ISR简单,因此需要较少的寄存器来完成其工作,因此节省寄存器的序言更短。现在,我们将延迟降至20个周期。考虑到我们开始接近100,这还不错!

在这一点上,我要说我们完成了。任务完成。以下内容仅适用于那些不怕被AVR组件弄脏的人。否则,您可以在这里停止阅读,谢谢您的学习。

编写裸露的ISR

还在?好!为了进一步进行操作,至少对组装的工作原理有一些非常基本的了解,并 从avr-libc文档中阅读《内联汇编器手册》会很有帮助。此时,我们的中断进入序列如下所示:

  1. 硬连线序列(4个周期)
  2. 中断向量:跳转到ISR(3个周期)
  3. ISR序言:保存注册表(12个周期)
  4. ISR主体中的第一件事:读取IO端口(1个周期)

如果我们想做得更好,我们必须将端口的读数移到序言中。想法如下:读取PIND寄存器将破坏一个CPU寄存器,因此我们需要在此之前至少保存一个寄存器,而其他寄存器可以等待。然后,我们需要编写一个自定义序言,以在保存第一个寄存器后立即读取I / O端口。您已经在avr-libc中断文档中看到了(您读过,对吗?),可以将ISR 裸化,在这种情况下,编译器将不会发出序言或结语,从而使我们能够编写自己的自定义版本。

这种方法的问题在于,我们可能最终会在汇编中编写整个ISR。没什么大不了的,但是我宁愿让编译器为我写那些无聊的序言和结尾。因此,这是一个肮脏的把戏:我们将ISR分为两部分:

  • 第一部分将是一个简短的程序集片段,它将
    • 将单个寄存器保存到堆栈
    • 将PIND读入该寄存器
    • 将该值存储到全局变量中
    • 从堆栈中恢复寄存器
    • 跳到第二部分
  • 第二部分将是带有编译器生成的序言和结语的常规C代码

然后,我们之前的INT0 ISR被替换为:

volatile uint8_t sampled_pin;    // this is now a global variable

/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
    asm volatile(
    "    push r0                \n"  // save register r0
    "    in r0, %[pin]          \n"  // read PIND into r0
    "    sts sampled_pin, r0    \n"  // store r0 in a global
    "    pop r0                 \n"  // restore previous r0
    "    rjmp INT0_vect_part_2  \n"  // go to part 2
    :: [pin] "I" (_SFR_IO_ADDR(PIND)));
}

ISR(INT0_vect_part_2)
{
    if (count_edges >= MAX_COUNT) return;     // we are done
    count_edges++;
    if (sampled_pin & (1 << PIN_BIT)) count_high++;
}

在这里,我们使用ISR()宏使编译器工具 INT0_vect_part_2具有所需的序言和结尾。编译器会抱怨“'INT0_vect_part_2'似乎是拼写错误的信号处理程序”,但是可以安全地忽略该警告。现在,ISR在实际端口读取之前只有一条2周期指令,而总延迟仅为10个周期。

使用GPIOR0寄存器

如果我们可以为该特定工作保留一个寄存器,该怎么办?这样,在读取端口之前,我们不需要保存任何内容。实际上,我们可以要求编译器将全局变量绑定到寄存器。但是,这将要求我们重新编译整个Arduino内核和libc,以确保始终保留寄存器。不太方便。另一方面,ATmega328P恰好有三个寄存器,它们未被编译器或任何库使用,并且可用于存储我们想要的任何内容。它们被称为GPIOR0,GPIOR1和GPIOR2(通用I / O寄存器)。尽管它们映射在MCU的I / O地址空间中,但实际上并没有I / O寄存器:它们只是普通的内存,就像三字节的RAM在某种程度上丢失在总线中并以错误的地址空间结尾。它们不像内部CPU寄存器那样功能强大,我们无法通过in指令将PIND复制到其中之一。GPIOR0很有趣,因为它是可位寻址的,就像PIND一样。这将使我们能够在不破坏任何内部CPU寄存器的情况下传输信息。

这是窍门:我们将确保GPIOR0最初为零(实际上是在引导时由硬件清除的),然后我们将使用sbic(如果某些I / o寄存器中的某些位被清除,则 跳过下一条指令),并使用sbi(在某些I / o寄存器中将某位设置为1,如下所示:

sbic PIND, 2   ; skip the following if bit 2 of PIND is clear
sbi GPIOR0, 0  ; set to 1 bit 0 of GPIOR0

这样,GPIOR0最终将为0或1,具体取决于我们要从PIND读取的位。根据条件是假还是真,sbic指令需要花费1或2个周期来执行。显然,在第一个周期访问了PIND位。在此代码的新版本中,全局变量sampled_pin不再有用,因为它基本上已由GPIOR0代替:

/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
    asm volatile(
    "    sbic %[pin], %[bit]    \n"
    "    sbi %[gpio], 0         \n"
    "    rjmp INT0_vect_part_2  \n"
    :: [pin]  "I" (_SFR_IO_ADDR(PIND)),
       [bit]  "I" (PIN_BIT),
       [gpio] "I" (_SFR_IO_ADDR(GPIOR0)));
}

ISR(INT0_vect_part_2)
{
    if (count_edges < MAX_COUNT) {
        count_edges++;
        if (GPIOR0) count_high++;
    }
    GPIOR0 = 0;
}

应该注意的是,GPIOR0必须始终在ISR中复位。

现在,对PIND I / O寄存器的采样是在ISR内部完成的第一件事。总延迟为8个周期。这是我们在被可怕的罪恶污点弄脏之前所能做的最好的事情。这又是一个停止阅读的好机会...

将时间紧迫的代码放在向量表中

对于仍在这里的人,这是我们目前的情况:

  1. 硬连线序列(4个周期)
  2. 中断向量:跳转到ISR(3个周期)
  3. ISR主体:读取IO端口(在第一个周期)

显然没有什么改进的余地。此时,我们可以缩短延迟的唯一方法是我们的代码替换中断向量本身。请注意,这对于任何重视干净软件设计的人来说都是非常讨厌的。但是有可能,我将向您展示如何。

ATmega328P向量表的布局可在数据表的“ 中断”部分,“ ATmega328和ATmega328P中的中断向量”小节中找到。或通过反汇编此芯片的任何程序。这是它的样子。我正在使用avr-gcc和avr-libc的约定(__init是矢量0,地址以字节为单位),与Atmel的约定不同。

address  instruction      comment
────────┼─────────────────┼──────────────────────
 0x0000  jmp __init       reset vector 
 0x0004  jmp __vector_1   a.k.a. INT0_vect
 0x0008  jmp __vector_2   a.k.a. INT1_vect
 0x000c  jmp __vector_3   a.k.a. PCINT0_vect
  ...
 0x0064  jmp __vector_25  a.k.a. SPM_READY_vect

每个向量都有一个4字节的插槽,由一条jmp指令填充。这是32位指令,与大多数16位AVR指令不同。但是32位插槽太小,无法容纳ISR的第一部分:我们可以放入sbicsbi指令,但不能放入rjmp。如果这样做,向量表最终将如下所示:

address  instruction      comment
────────┼─────────────────┼──────────────────────
 0x0000  jmp __init       reset vector 
 0x0004  sbic PIND, 2     the first part...
 0x0006  sbi GPIOR0, 0    ...of our ISR
 0x0008  jmp __vector_2   a.k.a. INT1_vect
 0x000c  jmp __vector_3   a.k.a. PCINT0_vect
  ...
 0x0064  jmp __vector_25  a.k.a. SPM_READY_vect

当INT0触发时,将读取PIND,相关位将被复制到GPIOR0,然后执行将进入下一个向量。然后,将调用INT1的ISR,而不是INT0的ISR。这令人毛骨悚然,但是由于我们还是不使用INT1,因此我们将“劫持”它的向量以服务INT0。

现在,我们只需要编写自己的自定义向量表即可覆盖默认表。事实证明,这并不容易。默认的向量表由avr-libc发行版提供,位于一个名为crtm328p.o的目标文件中,该文件自动与我们构建的任何程序链接。与库代码不同,目标文件代码不是要被覆盖的:尝试这样做将产生关于两次定义表的链接器错误。这意味着我们必须用自定义版本替换整个crtm328p.o。一种选择是下载完整的avr-libc源代码,在gcrt1.S中进行我们的自定义修改 ,然后将其构建为自定义libc。

在这里,我尝试了一种更轻松的替代方法。我写了一个自定义crt.S,它是avr-libc中原始文件的简化版本。它缺少一些很少使用的功能,例如定义“全部捕获” ISR的功能,或者能够通过调用终止程序(即冻结Arduino)的功能exit()。这是代码。我修剪了向量表的重复部分,以最大程度地减少滚动:

#include <avr/io.h>

.weak __heap_end
.set  __heap_end, 0

.macro vector name
    .weak \name
    .set \name, __vectors
    jmp \name
.endm

.section .vectors
__vectors:
    jmp __init
    sbic _SFR_IO_ADDR(PIND), 2   ; these 2 lines...
    sbi _SFR_IO_ADDR(GPIOR0), 0  ; ...replace vector_1
    vector __vector_2
    vector __vector_3
    [...and so forth until...]
    vector __vector_25

.section .init2
__init:
    clr r1
    out _SFR_IO_ADDR(SREG), r1
    ldi r28, lo8(RAMEND)
    ldi r29, hi8(RAMEND)
    out _SFR_IO_ADDR(SPL), r28
    out _SFR_IO_ADDR(SPH), r29

.section .init9
    jmp main

可以使用以下命令行进行编译:

avr-gcc -c -mmcu=atmega328p silly-crt.S

该草图与上一个草图相同,除了没有INT0_vect,而INT0_vect_part_2被INT1_vect代替:

/* Interrupt service routine for INT1 hijacked to service INT0. */
ISR(INT1_vect)
{
    if (count_edges < MAX_COUNT) {
        count_edges++;
        if (GPIOR0) count_high++;
    }
    GPIOR0 = 0;
}

要编译草图,我们需要一个自定义的编译命令。如果到目前为止,您可能已经知道如何从命令行进行编译。您必须明确要求将silly-crt.o链接到您的程序,并添加-nostartfiles选项以避免链接到原始crtm328p.o中。

现在,读取I / O端口是中断触发后执行的第一条指令。我通过从另一个Arduino发送短脉冲来测试此版本,它可以捕获(尽管不可靠)短至5个周期的高电平脉冲。我们没有任何办法可以缩短此硬件上的中断延迟。


2
很好的解释!+1
Nick Gammon

6

该中断被设置为在更改时触发,并且您的test_func被设置为中断服务例程(ISR),被调用以服务于该中断。然后,ISR打印输入的值。

乍一看,您会期望输出与您所说的一样,并交替设置一组高低点,因为它仅在更改时到达ISR。

但是我们缺少的是,CPU服务中断并转移到ISR需要一定的时间。在此期间,引脚上的电压可能再次发生了变化。特别是在通过硬件反跳或类似操作无法稳定引脚的情况下。由于该中断已被标记且尚未得到服务,因此将错过这种额外的更改(或许多更改,因为如果其寄生电容较低,则引脚电平相对于时钟速度会非常快地变化)。

因此,从本质上讲,如果没有某种形式的去抖动,我们就无法保证当输入发生更改并且中断被标记为需要服务时,当我们在ISR中读取其值时输入仍将保持相同的值。

作为通用示例, Arduino Uno上使用的ATmega328数据表在第6.7.1节“中断响应时间”中详细介绍了中断时间。该微控制器规定,分支到ISR进行服务的最短时间为4个时钟周期,但可以更长(如果在中断时执行多周期指令,则为额外时间;如果MCU处于睡眠状态,则为8 +睡眠唤醒时间)。

正如@EdgarBonet在评论中提到的那样,在ISR执行期间,该引脚也可能发生类似变化。由于ISR从引脚读取两次,因此如果它在第一次读取时遇到LOW,而在第二次读取时遇到HIGH,则它不会向test_array添加任何内容。但是x仍然会增加,从而使阵列中的该插槽保持不变(可能是未初始化的数据,具体取决于之前对阵列所做的操作)。

他的一线ISR是test_array[x++] = digitalRead(pin);对此的完美解决方案。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.