作為對此冗長答案的序言 ...
這個問題讓我對中斷延遲的問題深深著迷,以至於在計數時失去睡眠循環而不是綿羊。我寫此回復更多是為了分享我的發現,而不是僅僅回答問題:實際上,大多數材料可能都不處於適合正確答案的水平。我希望這對那些尋求解決延遲問題的讀者有用。預計前幾節將對包括原始海報在內的廣大讀者有用。
Clayton Mills在他的回答中已經解釋說,對中斷的響應有些延遲。在這裡,我將專注於量化延遲(使用Arduino庫時,延遲為 huge ),並著重將其最小化。以下大部分內容特定於Arduino Uno和類似電路板的硬件。
最小化Arduino上的中斷等待時間
(或如何從99減少到5個週期)
我將使用原始問題作為工作示例,並根據中斷等待時間重述該問題。我們有一些外部事件觸發中斷(此處:引腳更改時為INT0)。觸發中斷時,我們需要採取一些措施(此處:讀取數字輸入)。問題是:在觸發中斷與採取適當措施之間存在一定的延遲。我們將此延遲稱為“ interruptlatency ”。長時延在許多情況下都是有害的。在此特定示例中,輸入信號可能會在延遲期間發生變化,在這種情況下,我們會收到錯誤的讀數。我們無法採取任何措施來避免延遲:這是中斷工作方式所固有的。但是,我們可以嘗試將其盡量縮短,以期最大程度地減少不良後果。
我們可以做的第一件事就是採取時間緊迫的措施,
盡快在中斷處理程序中。這意味著在處理程序的最開始處調用一次(僅一次) digitalRead()
。這是我們將在其上構建的程序的第零版本:
#define INT_NUMBER 0#define PIN_NUMBER 2 //中斷0在pin 2#定義MAX_COUNT個200volatile uint8_t count_edges; //信號邊沿計數volatile uint8_t count_high; //高級別計數/ *中斷處理程序。 * / void read_pin(){int pin_state = digitalRead(PIN_NUMBER); //首先執行此操作!如果(count_edges > = MAX_COUNT)個返回; //我們完成了count_edges ++; if(pin_state == HIGH)count_high ++;} void setup(){Serial.begin(9600); attachInterrupt(INT_NUMBER,read_pin,CHANGE);}無效循環(){/ *等待中斷處理程序計數MAX_COUNT個邊沿。 * / while(count_edges < MAX_COUNT){/ *等待* /} / *報告結果。 * / Serial.print(“ Counted”); Serial.print(count_high); Serial.print(“為”的高級別“); Serial.print(count_edges); Serial.println(“ edges”); / *再次計數。 * / count_high = 0; count_edges = 0; //最後執行此操作以避免出現競爭情況}
我通過向其發送寬度可變的一系列脈衝來測試了該程序及其後續版本。脈沖之間有足夠的間隔以確保不丟失任何邊沿:即使在完成前一個中斷之前已接收到下降沿,第二個中斷請求也將被保留並最終得到服務。如果脈衝短於中斷等待時間,則程序在兩個邊沿都讀取0。那麼報告的高電平數就是正確讀取脈衝的百分比。
觸發中斷時會發生什麼?
在嘗試改進上面的代碼之前,我們將看一下觸發中斷後立即展開的事件。故事的硬件部分由Atmel文檔講述。軟件部分,
在大多數情況下,傳入的中斷都會立即得到處理。然而,可能是MCU(意思是“微控制器”)處於某些時間緊迫的任務之中,在這些任務中,中斷服務被禁用了。當它已經在服務另一個中斷時,通常就是這種情況。發生這種情況時,傳入的中斷請求將被擱置並僅在時間緊迫的部分完成後才提供服務。這種情況很難完全避免,因為Arduino核心庫中的那些關鍵部分很少(我稱之為“ libcore ”)。幸運的是,這些部分很短並且僅經常運行一次。因此,在大多數情況下,我們的中斷請求將立即得到處理。在下文中,我將假定在這種情況下我們不在乎這幾個實例。
然後,我們的請求將立即得到處理。這仍然涉及很多東西,可能需要相當長的時間。首先,有一個硬連接序列,MCU將完成當前指令的執行。幸運的是,大多數指令是單週期的,但有些指令可能需要多達四個週期。然後,MCU會清除內部標誌,以禁止進一步處理中斷。這是為了防止嵌套中斷。然後,將PC保存到堆棧中。堆棧是為此類臨時存儲保留的RAM區域。 PC(意為“ 程序計數器”)是一個內部寄存器,用於保存MCU將要執行的下一條指令的地址。這就是使MCU知道下一步該做什麼的原因,並且保存它是必不可少的,因為必須還原它才能使主程序從中斷的地方恢復。然後,向PC加載特定於接收到的請求的硬連線地址,這是硬連線序列的結尾,其餘部分由軟件控制。
MCU現在從該硬連線地址執行指令。這個
該指令稱為“ 中斷向量”,並且通常是“跳轉”指令,它將使我們進入一個稱為ISR(“ Interrupt Service Routine ”)的特殊例程。在這種情況下,ISR稱為“ __vector_1”,也稱為“ INT0_vect”,因為它是一個ISR,而不是向量,所以使用不當。該特定的ISR來自libcore。像anyISR一樣,它以 prologue 開頭,該協議在堆棧中保存了一堆內部CPU寄存器。這將允許它使用那些寄存器,完成後將它們恢復到以前的值,以免打擾主程序。然後,它將查找已在 attachInterrupt()
中註冊的中斷處理程序,並將調用該處理程序,即上面的 read_pin()
函數。然後我們的函數將從libcore調用 digitalRead()
。 digitalRead()
將查看一些表,以便將Arduino端口號映射到必須讀取的硬件I / O口以及要測試的相關位號。它還將檢查該引腳上是否有需要禁用的PWM通道。然後它將讀取I / O端口...就完成了。好吧,我們並沒有真正完成對中斷的服務,而是完成了對時間要求很高的任務(讀取I / O端口),而這正是我們觀察延遲的關鍵。
以上所有內容的摘要以及CPU週期中的相關延遲:
- 硬連線序列:完成當前指令,防止嵌套中斷,保存PC,向量的加載地址(≥4個週期)
- 執行中斷向量:跳轉到ISR(3個週期)
- ISR序言:保存寄存器(32個週期)
- ISR主體:查找並調用用戶註冊的函數(13週期)
- read_pin:調用digitalRead(5個週期)
- digitalRead:找到要測試的相關端口和位(41個週期)
- digitalRead:讀取I / O端口(1個週期)
ol> 我們將假定最佳情況,其中4個週期用於
硬連線序列。這使我們的總延遲為99個週期,在16MHz時鐘下約為6.2μs。在下面的內容中,我將探討一些可用於降低此延遲的技巧。它們的複雜程度從高到低,但是它們都需要我們以某種方式深入研究MCU的內部。
使用直接端口訪問
縮短延遲的顯而易見的首要目標是 digitalRead()
。此函數為MCU硬件提供了很好的抽象,但是對於時間緊迫的工作而言效率太低。擺脫這一事實實際上是微不足道的:我們只需從 digitalwritefast庫中將其替換為 digitalReadFast()
。
好吧,這太容易了,以至於沒有任何樂趣,我寧願向您展示如何做到這一點。目的是讓我們開始了解低級內容。該方法稱為“ 直接端口訪問”,該文檔在 PortRegisters頁面上的Arduino參考中有很好的記錄。此時,下載並查看 ATmega328P數據表是個好主意。這份650頁的文檔乍一看似乎有些令人生畏。但是,它被很好地組織成特定於每個MCU外圍設備和功能的部分。而且我們只需要檢查與我們正在執行的部分有關的部分。在這種情況下,該部分名為 I / O端口。這是從這些讀數中學到的總結:
- Arduino引腳2實際上在AVR芯片上稱為PD2(即端口D,位2)。
- 我們通過讀取一個稱為“ PIND”的特殊MCU寄存器,一次獲得整個端口D。
- 然後,通過執行按位邏輯和(C為“ &”,運算符)與
1 << 2
。
因此,這是我們修改後的中斷處理程序:
#define PIN_REG PIND //中斷0在AVR引腳PD2上#define PIN_BIT 2
/ *中斷處理程序。 * / void read_pin(){uint8_t sampled_pin = PIN_REG; //首先執行此操作!如果(count_edges > = MAX_COUNT)個返回; //我們完成了count_edges ++;如果(sampled_pin &(1 << PIN_BIT))count_high ++;}
現在,我們的處理程序將在調用I / O寄存器後立即對其進行讀取。延遲為53個CPU週期。這個簡單的技巧為我們節省了46個週期!
編寫您自己的ISR
下一個進行週期修整的目標是INT0_vect ISR。需要此ISR提供 attachInterrupt()
的功能:我們可以在程序執行期間隨時更改中斷處理程序。但是,儘管很高興,但這對於我們的目的並不是真正有用。因此,與其讓libcore的ISR定位並調用我們的中斷處理程序,不如通過我們的處理程序替換 ISR來節省幾個週期。
這聽起來並不那麼困難。 ISR可以像正常函數一樣編寫,我們只需要知道它們的特定名稱,並使用avr-libc中的特殊 ISR()
宏對其進行定義即可。此時,最好查看 avr-libc的有關中斷的文檔以及數據表中名為外部中斷的部分。這是簡短的摘要:
- 我們必須在稱為EICRA(外部中斷控制寄存器A )的特殊硬件寄存器中寫一些位,以便配置要觸發的中斷引腳值的任何變化。這將束縛在
setup()
中。 - 我們必須在另一個稱為EIMSK( External Interrupt MaSK register )的硬件寄存器中寫入一些內容。使能INT0中斷。這也將在
setup()
中完成。 - 我們必須使用語法
ISR(INT0_vect){...}
定義ISR。
這是ISR和 setup()
的代碼,其他所有內容都保持不變:
/ * INT0的中斷服務例程。 * / ISR(INT0_vect){uint8_t sampled_pin = PIN_REG; //首先執行此操作!如果(count_edges > = MAX_COUNT)個返回; //我們完成了count_edges ++; if(sampled_pin &(1 << PIN_BIT))count_high ++;} void setup(){Serial.begin(9600); EICRA = 1 << ISC00; //檢測INT0引腳上的任何變化EIMSK = 1 << INT0; //使INT0中斷} 。現在,我們將延遲降至20個週期。考慮到我們開始接近100,這還不錯! 在這一點上,我會說我們完成了。任務完成。 Whatfollows僅適用於那些不怕因使用AVR組件而臟手的人。否則,您可以在這裡停止閱讀,並感謝您走得這麼遠。好!為了進一步進行操作,至少了解組裝工作原理的一些基本概念,並從avr-libc文檔中了解 Inline AssemblerCookbook會很有幫助。此時,我們的中斷進入序列如下所示:
- 硬接線序列(4個週期)
- 中斷向量:跳轉到ISR(3個週期)
- ISR序言:保存註冊表(12個週期)
- 在ISR主體中的第一件事:讀取IO端口(1個週期)
ol> 如果我們想做得更好,我們必須將港口的內容讀入序言。想法如下:讀取PIND寄存器將佔用一個CPU寄存器,因此在執行此操作之前,我們至少需要保存一個寄存器,而其他寄存器可以等待。然後,我們需要編寫一個自定義序言,以在保存第一個寄存器後立即讀取I / O端口。您已經在avr-libc中斷中看到了
可以將ISR製成裸的文檔(您已經閱讀了,對吧?),在這種情況下,編譯器不會發出序言或結語,從而使我們能夠編寫自己的自定義版本。
此方法的問題在於,我們可能最終會在程序集中編寫整個ISR。沒什麼大不了的,但是我寧願讓編譯器為我寫那些無聊的序言和結尾。因此,這是一個骯髒的把戲:我們將ISR分為兩部分:
- 第一部分將是一個簡短的彙編片段,它將
- 將一個寄存器保存到堆棧中
- 將PIND讀取到該寄存器中
- 將該值存儲到全局變量中
- 將該寄存器從堆棧中恢復
- 跳轉到第二個第二部分
- 第二部分將是帶有編譯器生成的序言和結語的常規C代碼
然後,我們將之前的INT0 ISR替換為:
volatile uint8_t sampled_pin; //現在這是一個全局變量/ * INT0的中斷服務程序。 * / ISR(INT0_vect,ISR_NAKED){asm volatile(“ push r0 \ n” //將寄存器r0“保存在r0中,%[pin] \ n” //將PIND讀入r0“ sts sampled_pin,r0 \ n” //將r0存儲在全局“ pop r0 \ n” //恢復先前的r0“ rjmp INT0_vect_part_2 \ n” //轉到第2部分:: [pin]“ I”(_SFR_IO_ADDR(PIND)));} ISR(INT0_vect_part_2){如果(count_edges > = MAX_COUNT)個返回; //我們完成了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;如果PIND的位2為clearsbi GPIOR0,0,則跳過以下內容;設置為GPIOR0的1位0
這樣,根據我們要從PIND讀取的位,GPIOR0將最終為0或1。根據條件是假還是真,sbic指令需要花費1或2個週期來執行。顯然,在第一個週期訪問了PINDbit。在此新版本的代碼中,
全局變量 sampled_pin
不再有用,因為它基本上已被GPIOR0代替:
/ * 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)),[位]” I“(PIN_BIT),[gpio” I“(_SFR_IO_ADDR(GPIOR0)));} ISR(INT0_vect_part_2){如果(count_edges < MAX_COUNT){count_edges ++;如果(GPIOR0)count_high ++; } GPIOR0 = 0;}
應注意,必須始終在ISR中重置GPIOR0。
現在,對PIND I / O寄存器進行採樣是在ISR中完成的第一件事。總延遲為8個週期。這是我們在被可怕的罪惡污點弄髒之前所能做的最好的事情。這再次是停止閱讀的好機會...
將時間緊迫的代碼放在向量表中
對於那些仍在這裡的人,這是我們目前的情況:
- 硬連線序列(4個週期)
- 中斷向量:跳至ISR(3個週期)
- ISR主體:讀取IO端口(在第一個週期)
ol> 顯然沒有什麼改進的餘地。目前,縮短延遲的唯一方法是用我們的代碼替換中斷向量本身。請注意,這對任何重視乾淨軟件設計的人都非常討厭。
ATmega328P向量表的佈局可以在數據表的第 Interrupts 部分,第 ATmega328中的Interrupt Vectors部分中找到。和ATmega328P 。或通過反彙編此芯片的任何程序。這是怎麼樣的。我使用的是avr-gcc和avr-libc的約定(__initis vector 0,地址以字節為單位),與Atmel的約定不同。
address│指令│評論
────────┼────────────────┼────────────────────0 0x0000 │jmp __init│復位向量0x0004│jmp __vector_1│aka INT0_vect 0x0008│jmp __vector_2│aka INT1_vect 0x000c│jmp __vector_3│aka PCINT0_vect ... 0x0064│jmp __vector_25 有一個4字節的插槽,由一條 jmp
指令填充。這是32位指令,與大多數16位的AVR指令不同。但是32位插槽太小,無法容納我們的ISR的第一部分:我們可以使用 sbic
和 sbi
指令,但不能使用 rjmp
。如果這樣做,向量表最終將如下所示: 地址│指令│註釋────────┼ ────────────────┼┼────────────────────────────────────────────────────────────────────────────×0x0000│jmp __init│復位向量0x0004│sbic PIND,2│第一部分... 0x0006│IBI的sbi GPIOR0,0│... ISR 0x0008│jmp __vector_2│aka INT1_vect 0x000c│jmp __vector_3│aka PCINT0_vect ... 0x0064│jmp __vector_25│aka SPM >
當INT0觸發時,將讀取PIND,相關位將被複製到GPIOR0中,然後執行將進入下一個向量,然後將調用INT1的ISR,而不是INT0的ISR。這很令人毛骨悚然,但是由於無論如何我們都沒有使用INT1,因此我們將“劫持”它的向量以服務INT0。
現在,我們只需要編寫自己的自定義向量表來覆蓋默認值即可。事實證明,這並不容易。默認的向量表由avr-libc發行版提供,該文件位於一個名為crtm328p.o的目標文件中,該文件會自動鏈接到我們構建的任何程序。關於兩次定義表的鏈接器錯誤。這意味著我們必須用我們的自定義版本替換整個crtm328p.o。一種選擇是下載完整的avr-libc源代碼,
在 gcrt1.S中進行自定義修改,然後將其構建為自定義libc。
在這裡,我嘗試了一種更輕鬆的替代方法。我寫了一個customcrt.S,它是avr-libc原始版本的簡化版本。它缺少一些很少使用的功能,例如定義“ catchall” ISR的功能,或者能夠通過調用 exit()
終止程序(即凍結theArduino)的功能。這是代碼。為了減少滾動,我修剪了向量表的重複部分:
#include <avr / io.h>.weak __heap_end.set __heap_end,0。宏向量名稱.weak \ name .set \ name,__vectors jmp \ name.endm.section .vectors__vectors:jmp __init sbic _SFR_IO_ADDR(PIND),2;這2行... sbi _SFR_IO_ADDR(GPIOR0),0; ...替換vector_1向量__vector_2向量__vector_3 [...依次類推,直到...]向量__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:
/ *被劫持以服務INT0的INT1的中斷服務例程。 * / ISR(INT1_vect){如果(count_edges < MAX_COUNT){count_edges ++;如果(GPIOR0)count_high ++; } GPIOR0 = 0;}
要編譯草圖,我們需要一個自定義的編譯命令。如果到目前為止,您可能已經知道如何從命令行進行編譯。您必須明確要求將silly-crt.o鏈接到程序,並添加 -nostartfiles
選項以避免鏈接在原來的
現在,讀取I / O端口是中斷觸發後執行的第一條指令。我通過從另一個Arduino發送短脈衝來測試此版本,它可以捕獲(儘管不可靠)短至5個週期的高電平脈衝。我們沒有任何辦法可以縮短此硬件上的中斷延遲。