В продолжение темы о прерываниях в AVR микроконтроллерах предлагаю покопаться в коде, генерируемом компилятором. Это поможет проверить на деле приведенный ранее теоретический материал, а также узнать новые интересные моменты. Для этого потребуется описанная в прошлой публикации доработка IDE Arduino для генерации ассемблерного листинга и справочник по языку ассемблера для AVR, доступный по ссылке http://ww1.microchip.com/downloads/en/devicedoc/atmel-0856-avr-instruction-set-manual.pdf. Итак, приступим.
Вставьте приведенный код в IDE Arduino, сохраните его и выполните команду "Экспорт бинарного файла" из меню "Скетч". В папке со скетчем появится файл .asm. При его открытии первое что мы увидим - это таблица векторов:
По нулевому адресу расположен вектор сброса, который представляет из себя команду перехода к адресу 0x68. С этого адреса начинается наша программа и первая ее команда - это очистка уже знакомого нам регистра r1 (zero-register). В следующей команде он используется для очистки регистра SREG, тем самым сбрасываются флаги и запрещается обработка прерываний. Далее идет инициализация указателя стека, цикл очистки глобальных переменных и, наконец, вызов функции main.
Но вернемся к таблице векторов. Мы видим, что в ней присутствуют вектора для прерываний INT0 (идет сразу после вектора сброса) и TIMER0_OVF. Последний используется в работе функций millis, micros, delay, он нас сейчас не интересует. Вектора остальных прерываний указывают на обработчик bad_interrupt, также рассмотренный ранее. Если мы перейдем к нему, то увидим следующий код:
Единственная его команда - это переход на нулевой адрес, к вектору сброса.
Давайте теперь взглянем на код, сгенерированный для нашего обработчика: найдем в листинге метку __vector_1.
Наш обработчик не выполняет никаких действий, поэтому в листинге присутствуют только пролог и эпилог. Вы можете поэкспериментировать с параметрами макроса ISR или добавить в обработчик какие-то простые действия, чтобы посмотреть как меняется его код. Ниже приведен код обработчика, который выполняет инкремент глобальной переменной типа byte:
Как видите, в пролог и эпилог обработчика добавился регистр r24. Он используется для загрузки значения переменной из памяти (команда lds), увеличения его на единицу (осуществляется путем вычитания из него значения 255 командой subi), после чего результат помещается обратно в память (команда sts).
Далее идут не особо интересные манипуляции с регистрами микроконтроллера для разрешения заданного прерывания. Очевидно, наша функция должна вызываться при возникновении прерывания. Найдем, где идет обращение к массиву intFunc. Поиски приведут нас к макросу IMPLEMENT_ISR, который определяет обработчик для заданного прерывания, который в свою очередь вызывает нужную функцию по указателю из массива intFunc. Обращение к данному макросу идет далее, в блоках условной компиляции:
Для платы Uno и других на базе ATmega328/P будет скомпилирован код, находящийся в ветке #else, что приведет к объявлению двух обработчиков: для INT0_vect и INT1_vect. Давайте проверим это, сгенерировав листинг на ассемблере для следующего скетча:
В таблице векторов, действительно, присутствуют вектора для обоих внешних прерываний:
Но интерес для нас представляет другое. Перейдите к реализации обработчика для INT0 (метка __vector_1). Вместо стандартных пролога и эпилога вы увидите код, сохраняющий почти все регистры от r18 до r31. Почему так происходит, давайте разбираться.
В AVR GCC принято соглашение об использовании регистров общего назначения. Мы уже знаем, что r0 используется в качестве временного регистра, а r1 (zero-register) содержит 0. Кроме этого регистры делятся на так называемые Call-Used Registers (или call-clobbered) то есть которые могут свободно использоваться функциями и Call-Saved Registers, первоначальное содержимое которых в случае использования функцией должно быть обязательно восстановлено при возврате из нее. К первой группе относятся регистры r18–r27, r30, r31, а также временный регистр r0 и флаг T. Остальные регистры r2–r17, r28, r29 и r1 относятся ко второй группе. Подробнее вы можете прочитать о них по ссылке https://gcc.gnu.org/wiki/avr-gcc#Register_Layout
Наша функция-обработчик my_isr по факту обычная функция. При использовании в ней Call-Used регистров можно быть уверенным, что вызывающая функция не оставила в них важные данные. Но поскольку данная функция вызывается обработчиком прерывания, которое может поступить в любой момент, то в регистрах могут быть еще не обработанные данные. Поэтому обработчик перед вызовом my_isr сохраняет содержимое этих регистров в стеке. Затем, соответственно, восстанавливает. Это мы и видим в сгенерированном листинге. Аналогичный код будет и для остальных внешних прерываний, в данном случае для INT1. К нему мы не привязывали функцию-обработчик, но по умолчанию к нему уже привязана функция nothing, из файла WInterrupts.c:
Но, разумеется, она не будет вызываться, поскольку обработка прерывания INT1 у нас не разрешена. Хотя трата памяти программ налицо.
А вот фрагмент полученного листинга:
Команда lds загружает из памяти значение нашей переменной в регистр. Затем содержимое регистра проверяется на 0 путем выполнения логического И с самим собой, результат проверки влияет на флаг нуля Z. Если флаг нуля установлен, то команда breq осуществит переход на 8 байт назад, то есть к началу цикла. В противном случае будет выполнена следующая за breq команда. То есть всё в порядке, цикл работает как и задумывалось. Уберем теперь из объявления переменной f квалификатор volatile и посмотрим, что получится:
Как и в прошлый раз значение переменной загружается в регистр r24 для проверки. Команда cpse сравнивает содержимое r24 с r1 (с нулем) и в случае равенства пропускает следующую команду, которой является переход вперед на 2 байта. Этот переход позволяет обойти дальнейший цикл, если r24 не равно нулю. В противном случае программа зайдет в бесконечный цикл: команда rjmp .-2 выполняет переход на саму себя. Таким образом, компилятор, предполагая, что значение переменной f внутри цикла не изменяется, вынес ее проверку за пределы цикла. И изменение значения f в обработчике прерывания не позволит выйти из цикла.
Итак, мы рассмотрели листинги на ассемблере для различных скетчей и увидели, какой код генерирует компилятор. Его изучение помогает не только проверить теорию, но и выявить места для оптимизации программы. В конце концов это просто интересно, если вы хотите узнать больше о работе Ардуино и AVR микроконтроллеров.
Таблица векторов, обработчики прерываний
Для начала рассмотрим листинг, генерируемый для пустого скетча с обработчиком прерывания, например, для INT0.ISR(INT0_vect) {
}
void setup() {
}
void loop() {
}
Вставьте приведенный код в IDE Arduino, сохраните его и выполните команду "Экспорт бинарного файла" из меню "Скетч". В папке со скетчем появится файл .asm. При его открытии первое что мы увидим - это таблица векторов:
По нулевому адресу расположен вектор сброса, который представляет из себя команду перехода к адресу 0x68. С этого адреса начинается наша программа и первая ее команда - это очистка уже знакомого нам регистра r1 (zero-register). В следующей команде он используется для очистки регистра SREG, тем самым сбрасываются флаги и запрещается обработка прерываний. Далее идет инициализация указателя стека, цикл очистки глобальных переменных и, наконец, вызов функции main.
Но вернемся к таблице векторов. Мы видим, что в ней присутствуют вектора для прерываний INT0 (идет сразу после вектора сброса) и TIMER0_OVF. Последний используется в работе функций millis, micros, delay, он нас сейчас не интересует. Вектора остальных прерываний указывают на обработчик bad_interrupt, также рассмотренный ранее. Если мы перейдем к нему, то увидим следующий код:
Единственная его команда - это переход на нулевой адрес, к вектору сброса.
Давайте теперь взглянем на код, сгенерированный для нашего обработчика: найдем в листинге метку __vector_1.
Наш обработчик не выполняет никаких действий, поэтому в листинге присутствуют только пролог и эпилог. Вы можете поэкспериментировать с параметрами макроса ISR или добавить в обработчик какие-то простые действия, чтобы посмотреть как меняется его код. Ниже приведен код обработчика, который выполняет инкремент глобальной переменной типа byte:
Как видите, в пролог и эпилог обработчика добавился регистр r24. Он используется для загрузки значения переменной из памяти (команда lds), увеличения его на единицу (осуществляется путем вычитания из него значения 255 командой subi), после чего результат помещается обратно в память (команда sts).
Функция attachInterrupt
А какой код будет произведен для функции attachInterrupt? Прежде чем перейти к изменению скетча и генерации листинга на ассемблере предлагаю ознакомиться с реализацией данной функции в IDE Arduino. Откройте файл Arduino_dir\hardware\arduino\avr\cores\arduino\WInterrupts.c и найдите в нем указанную функцию. В ее начале происходит помещение указателя на нашу функцию-обработчик в некий массив intFunc:Далее идут не особо интересные манипуляции с регистрами микроконтроллера для разрешения заданного прерывания. Очевидно, наша функция должна вызываться при возникновении прерывания. Найдем, где идет обращение к массиву intFunc. Поиски приведут нас к макросу IMPLEMENT_ISR, который определяет обработчик для заданного прерывания, который в свою очередь вызывает нужную функцию по указателю из массива intFunc. Обращение к данному макросу идет далее, в блоках условной компиляции:
Для платы Uno и других на базе ATmega328/P будет скомпилирован код, находящийся в ветке #else, что приведет к объявлению двух обработчиков: для INT0_vect и INT1_vect. Давайте проверим это, сгенерировав листинг на ассемблере для следующего скетча:
void my_isr() {
}
void setup() {
attachInterrupt(digitalPinToInterrupt(2), my_isr, FALLING);
}
void loop() {
}
В таблице векторов, действительно, присутствуют вектора для обоих внешних прерываний:
Но интерес для нас представляет другое. Перейдите к реализации обработчика для INT0 (метка __vector_1). Вместо стандартных пролога и эпилога вы увидите код, сохраняющий почти все регистры от r18 до r31. Почему так происходит, давайте разбираться.
В AVR GCC принято соглашение об использовании регистров общего назначения. Мы уже знаем, что r0 используется в качестве временного регистра, а r1 (zero-register) содержит 0. Кроме этого регистры делятся на так называемые Call-Used Registers (или call-clobbered) то есть которые могут свободно использоваться функциями и Call-Saved Registers, первоначальное содержимое которых в случае использования функцией должно быть обязательно восстановлено при возврате из нее. К первой группе относятся регистры r18–r27, r30, r31, а также временный регистр r0 и флаг T. Остальные регистры r2–r17, r28, r29 и r1 относятся ко второй группе. Подробнее вы можете прочитать о них по ссылке https://gcc.gnu.org/wiki/avr-gcc#Register_Layout
Наша функция-обработчик my_isr по факту обычная функция. При использовании в ней Call-Used регистров можно быть уверенным, что вызывающая функция не оставила в них важные данные. Но поскольку данная функция вызывается обработчиком прерывания, которое может поступить в любой момент, то в регистрах могут быть еще не обработанные данные. Поэтому обработчик перед вызовом my_isr сохраняет содержимое этих регистров в стеке. Затем, соответственно, восстанавливает. Это мы и видим в сгенерированном листинге. Аналогичный код будет и для остальных внешних прерываний, в данном случае для INT1. К нему мы не привязывали функцию-обработчик, но по умолчанию к нему уже привязана функция nothing, из файла WInterrupts.c:
static void nothing(void) {
}
Но, разумеется, она не будет вызываться, поскольку обработка прерывания INT1 у нас не разрешена. Хотя трата памяти программ налицо.
Ключевое слово volatile
При рассмотрении ключевого слова volatile я приводил пример скетча с использованием разделяемой переменной в условии выхода из цикла. Давайте сгенерируем для него листинг на ассемблере, чтобы увидеть эффект от использования volatile. Вот сам скетч:#define interruptPin 2
volatile byte f = 0;
void setup() {
pinMode(interruptPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(interruptPin), buttonPressed, FALLING);
}
void loop() {
while (f == 0) {
// Что-то делаем в ожидании нажатия кнопки
}
// Кнопка нажата
}
void buttonPressed() {
f = 1;
}
А вот фрагмент полученного листинга:
Команда lds загружает из памяти значение нашей переменной в регистр. Затем содержимое регистра проверяется на 0 путем выполнения логического И с самим собой, результат проверки влияет на флаг нуля Z. Если флаг нуля установлен, то команда breq осуществит переход на 8 байт назад, то есть к началу цикла. В противном случае будет выполнена следующая за breq команда. То есть всё в порядке, цикл работает как и задумывалось. Уберем теперь из объявления переменной f квалификатор volatile и посмотрим, что получится:
Как и в прошлый раз значение переменной загружается в регистр r24 для проверки. Команда cpse сравнивает содержимое r24 с r1 (с нулем) и в случае равенства пропускает следующую команду, которой является переход вперед на 2 байта. Этот переход позволяет обойти дальнейший цикл, если r24 не равно нулю. В противном случае программа зайдет в бесконечный цикл: команда rjmp .-2 выполняет переход на саму себя. Таким образом, компилятор, предполагая, что значение переменной f внутри цикла не изменяется, вынес ее проверку за пределы цикла. И изменение значения f в обработчике прерывания не позволит выйти из цикла.
Итак, мы рассмотрели листинги на ассемблере для различных скетчей и увидели, какой код генерирует компилятор. Его изучение помогает не только проверить теорию, но и выявить места для оптимизации программы. В конце концов это просто интересно, если вы хотите узнать больше о работе Ардуино и AVR микроконтроллеров.
Спасибо!
ОтветитьУдалить