Как своего рода пролог к этому слишком длинному ответу ...
Этот вопрос глубоко увлек меня проблемой латентности прерывания, вплоть до потери сна в циклах подсчета вместо овец. Я пишу этот ответ больше для того, чтобы делиться своими выводами, чем просто для того, чтобы ответить на вопрос: большая часть этого материала может на самом деле не быть на уровне, подходящем для правильного ответа. Я надеюсь, что это будет полезно для читателей, которые приезжают сюда в поисках решений проблем с задержками. Ожидается, что первые несколько разделов будут полезны для широкой аудитории, включая оригинальный постер. Тогда это становится волосатым по пути.
Клейтон Миллс уже объяснил в своем ответе, что в ответах на прерывания есть некоторая задержка. Здесь я сосредоточусь на количественной оценке задержки (которая огромна при использовании библиотек 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 на обоих фронтах. Сообщаемое количество HIGH-уровней - это процент правильно прочитанных импульсов.
Что происходит, когда прерывание срабатывает?
Прежде чем пытаться улучшить приведенный выше код, рассмотрим события, которые разворачиваются сразу после срабатывания прерывания. Аппаратная часть истории рассказана в документации Atmel. Программная часть, путем разборки двоичного файла.
Большую часть времени входящее прерывание обслуживается сразу. Однако может случиться так, что MCU (что означает «микроконтроллер») находится в середине некоторой критической по времени задачи, где обслуживание прерываний отключено. Обычно это тот случай, когда он уже обслуживает другое прерывание. Когда это происходит, входящий запрос на прерывание удерживается и обслуживается только после выполнения этого критического по времени раздела. Эту ситуацию трудно полностью избежать, потому что в базовой библиотеке Arduino есть довольно много критических разделов (которые я назову « libcore»)."в следующем). К счастью, эти разделы короткие и выполняются очень редко. Таким образом, большую часть времени наш запрос прерывания будет обрабатываться сразу. В дальнейшем я буду предполагать, что мы не заботимся о тех немногих случаи, когда это не так.
Затем наш запрос обслуживается немедленно. Это все еще включает в себя много вещей, которые могут занять довольно много времени. Во-первых, есть жесткая последовательность. MCU завершит выполнение текущей инструкции. К счастью, большинство инструкций являются одноцикловыми, но некоторые могут занимать до четырех циклов. Затем MCU очищает внутренний флаг, который запрещает дальнейшее обслуживание прерываний. Это предназначено для предотвращения вложенных прерываний. Затем ПК сохраняется в стек. Стек - это область оперативной памяти, зарезервированная для этого типа временного хранилища. ПК (имеется в виду « Счетчик программ»") является внутренним регистром, содержащим адрес следующей инструкции, которую собирается выполнить MCU. Это то, что позволяет MCU знать, что делать дальше, и сохранение его необходимо, поскольку его необходимо будет восстановить для основной программа возобновляет работу с того места, где она была прервана. После этого на ПК загружается аппаратный адрес, относящийся к полученному запросу, и это конец аппаратной последовательности, а остальная часть управляется программным обеспечением.
MCU теперь выполняет команду с этого аппаратного адреса. Эта инструкция называется « вектором прерывания » и, как правило, является инструкцией «перехода», которая приведет нас к специальной подпрограмме, называемой ISR (« Программа обработки прерывания »). В этом случае ISR называется «__vector_1», иначе «INT0_vect», что неверно, потому что это ISR, а не вектор. Этот конкретный ISR происходит от libcore. Как и любой ISR, он начинается с пролога, который сохраняет в стеке несколько внутренних регистров ЦП. Это позволит ему использовать эти регистры и, когда это будет сделано, восстановить их прежние значения, чтобы не нарушать работу основной программы. Затем он будет искать обработчик прерываний, который был зарегистрирован сattachInterrupt()
, и он будет вызывать тот обработчик, который является нашей read_pin()
функцией выше. Затем наша функция будет вызываться digitalRead()
из libcore. digitalRead()
рассмотрим некоторые таблицы, чтобы сопоставить номер порта Arduino с портом аппаратного ввода-вывода, который он должен прочитать, и соответствующий битовый номер для проверки. Он также проверит, есть ли на этом выводе канал ШИМ, который необходимо отключить. Затем он прочитает порт ввода-вывода ... и все готово. Ну, на самом деле мы еще не закончили обслуживание прерывания, но критическая по времени задача (чтение порта ввода / вывода) выполнена, и это все, что имеет значение, когда мы смотрим на задержку.
Вот краткое резюме всего вышеперечисленного, вместе с соответствующими задержками в циклах ЦП:
- аппаратная последовательность: завершить текущую инструкцию, предотвратить вложенные прерывания, сохранить ПК, адрес загрузки вектора (≥ 4 цикла)
- выполнить вектор прерывания: перейти к ISR (3 цикла)
- Пролог ISR: сохранение регистров (32 цикла)
- Основная часть ISR: найти и вызвать зарегистрированную пользователем функцию (13 циклов)
- read_pin: вызов digitalRead (5 циклов)
- digitalRead: найти соответствующий порт и бит для проверки (41 такт)
- digitalRead: чтение порта ввода / вывода (1 цикл)
Мы примем сценарий наилучшего случая, с 4 циклами для аппаратной последовательности. Это дает нам общую задержку 99 циклов или около 6,2 мкс с тактовой частотой 16 МГц. Далее я расскажу о некоторых приемах, которые можно использовать для снижения этой задержки. Они приходят примерно в порядке возрастания сложности, но все они нуждаются в том, чтобы мы каким-то образом копались во внутреннем пространстве MCU.
Используйте прямой доступ к порту
Очевидная первая цель для сокращения времени ожидания digitalRead()
. Эта функция обеспечивает хорошую абстракцию аппаратного обеспечения MCU, но она слишком неэффективна для работы, требующей срочного выполнения. Избавиться от этого на самом деле тривиально: нам просто нужно заменить его digitalReadFast()
из
библиотеки digitalwritefast . Это сокращает время ожидания почти вдвое за счет небольшой загрузки!
Ну, это было слишком легко, чтобы быть веселым, я скорее покажу вам, как сделать это трудным путем. Цель состоит в том, чтобы заставить нас заняться вещами низкого уровня. Этот метод называется « прямой доступ к порту » и хорошо документирован в справочнике Arduino на странице « Регистры портов» . На данный момент, это хорошая идея, чтобы загрузить и взглянуть на таблицу данных ATmega328P . Этот 650-страничный документ может показаться несколько пугающим на первый взгляд. Однако он хорошо организован в разделы, специфичные для каждого из периферийных устройств и функций MCU. И нам нужно только проверить разделы, относящиеся к тому, что мы делаем. В данном случае это раздел с именем
I / O ports . Вот краткое изложение того, что мы узнаем из этих чтений:
- Вывод Arduino 2 фактически называется PD2 (т.е. порт D, бит 2) на микросхеме AVR.
- Мы получаем весь порт D сразу, читая специальный регистр MCU под названием «PIND».
- Затем мы проверяем бит номер 2, выполняя побитовую логику и (оператор C '&') с помощью
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++;
}
Теперь наш обработчик будет читать регистр ввода-вывода, как только он будет вызван. Задержка составляет 53 такта процессора. Этот простой трюк спас нам 46 циклов!
Напишите свой собственный ISR
Следующей целью для циклической обрезки является INT0_vect ISR. Этот ISR необходим для обеспечения функциональности attachInterrupt()
: мы можем изменить обработчики прерываний в любое время во время выполнения программы. Однако, хотя это приятно иметь, это не очень полезно для наших целей. Таким образом, вместо того, чтобы ISR в libcore находил и вызывал наш обработчик прерываний, мы сэкономим несколько циклов, заменив ISR нашим обработчиком.
Это не так сложно, как кажется. ISR могут быть написаны как обычные функции, мы просто должны знать их конкретные имена и определять их, используя специальный ISR()
макрос из avr-libc. На этом этапе было бы хорошо взглянуть на документацию avr-libc по прерываниям , а также на раздел с описанием внешних прерываний . Вот краткое резюме:
- Мы должны записать бит в специальный аппаратный регистр, называемый EICRA ( регистр внешнего управления прерываниями A ), чтобы настроить прерывание, которое будет запускаться при любом изменении значения контакта. Это будет сделано в
setup()
.
- Мы должны записать немного в другой аппаратный регистр, называемый EIMSK (регистр внешнего прерывания MaSK ), чтобы включить прерывание INT0. Это также будет сделано в
setup()
.
- Мы должны определить ISR с помощью синтаксиса
ISR(INT0_vect) { ... }
.
Вот код для 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 проще, чем тот, который он заменяет, ему нужно меньше регистров для выполнения своей работы, тогда пролог, сохраняющий регистры, короче. Теперь мы сократились до 20 циклов. Неплохо, учитывая, что мы начали около 100!
На данный момент я бы сказал, что мы сделали. Миссия выполнена. Далее следует только для тех, кто не боится испачкать руки при помощи сборки AVR. В противном случае вы можете перестать читать здесь, и спасибо за то, что так далеко.
Написать голый ISR
Все еще здесь? Хорошо! Для продолжения работы было бы полезно иметь хотя бы некоторую базовую идею о том, как работает сборка, и взглянуть на книгу
рецептов Inline Assembler из документации avr-libc. На этом этапе наша последовательность ввода прерываний выглядит следующим образом:
- зашитая последовательность (4 цикла)
- вектор прерывания: переход к ISR (3 цикла)
- Пролог ISR: сохранение регистров (12 циклов)
- первое, что есть в теле ISR: чтение порта ввода-вывода (1 цикл)
Если мы хотим добиться большего, мы должны перенести показания порта в пролог. Идея заключается в следующем: чтение регистра PIND приведет к засорению одного из регистров ЦП, поэтому перед этим нужно сохранить хотя бы один регистр, но другие регистры могут подождать. Затем нам нужно написать собственный пролог, который считывает порт ввода-вывода сразу после сохранения первого регистра. Вы уже видели в документации по прерываниям 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 (регистры ввода / вывода общего назначения ). Хотя они отображаются в адресном пространстве ввода-вывода MCU, они на самом деле неРегистры ввода / вывода: это просто обычная память, как три байта оперативной памяти, которые каким-то образом потерялись в шине и оказались в неправильном адресном пространстве. Они не так способны, как внутренние регистры ЦП, и мы не можем скопировать PIND в один из них с помощью in
инструкции. GPIOR0, тем не менее, интересен тем, что он является адресуемым битом , как PIND. Это позволит нам передавать информацию, не заглатывая внутренний регистр ЦП.
Вот хитрость: мы удостоверимся, что GPIOR0 изначально равен нулю (на самом деле он очищается аппаратно во время загрузки), затем мы будем использовать
sbic
(Пропустить следующую инструкцию, если какой-то бит в каком-либо регистре ввода-вывода равен Clear) и sbi
( Установите 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 - это первое, что делается внутри ISR. Общая задержка составляет 8 циклов. Это лучшее из того, что мы можем сделать, прежде чем запятнать ужасно грешные клуджи. Это снова хорошая возможность перестать читать ...
Поместите критичный по времени код в таблицу векторов
Для тех, кто еще здесь, вот наша текущая ситуация:
- зашитая последовательность (4 цикла)
- вектор прерывания: переход к ISR (3 цикла)
- Тело ISR: считывание порта IO (на 1-м цикле)
Там явно мало места для улучшения. Единственный способ сократить задержку на этом этапе - заменить сам вектор прерывания нашим кодом. Имейте в виду, что это должно быть чрезвычайно неприятным для всех, кто ценит чистый дизайн программного обеспечения. Но это возможно, и я покажу вам, как.
Расположение векторной таблицы 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-битная инструкция, в отличие от большинства инструкций AVR, которые являются 16-битными. Но 32-битный слот слишком мал , чтобы провести первую часть нашего ISR: мы подправить sbic
и sbi
инструкцию, но не 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, а затем выполнение перейдет к следующему вектору. Затем будет вызываться ISR для INT1 вместо ISR для INT0. Это жутко, но так как мы все равно не используем 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.
Теперь чтение порта ввода / вывода - это самая первая инструкция, выполняемая после запуска прерывания. Я протестировал эту версию, посылая ей короткие импульсы от другого Arduino, и он может улавливать (хотя и не надежно) высокий уровень импульсов всего за 5 циклов. Мы больше ничего не можем сделать, чтобы сократить задержку прерывания на этом оборудовании.