Почему Arduino медленная?

#embedded#arduino

Михаил (aka @dx3mod)

Если вы знакомы с платформой Arduino, то наверняка слышали тезис, что Arduino медленная. В принципе вы и сами могли наблюдать, что даже самая простая программа парой выходит в килобайты прошивки.

Но почему это так? Зачастую от экспертов из Сети можно прочитать ответ, мол там куча мифических проверок от дурака и прочей шляпы. Звучит, как минимум, убедительно. По крайней мере потому что Arduino и позиционируется как платформа для новичков, и там должны быть некоторые предохранители, должны же быть, да? 🥺

Вопрос интересный и к тому же (на тот момент) я писал свой Arduino-подобный HAL для решения практических работа в техникуме, из-за чего и решил повозиться немного в исходниках.

Плюс к этому я надеюсь, что у меня получиться осветить данный вопрос в этой статье.

Ахтунг

Говоря Arduino, я говорю про Arduino HAL и про конкретную реализацию -ArduinoCore-avr, так как остальные решения могут иметь существенные изменения.

Также я предполагаю, что вы владеете некоторыми терминами, языком C и в некоторой степени Arduino.

Ещё одно мигание светодиода

Разбирать Arduino я предлагаю на простом боевом примере, на blink'е.

#define LED 7

void setup() {
    pinMode(LED, OUTPUT);
}

void loop() {
    digitalWrite(LED, HIGH);
    delay(1000);
    digitalWrite(LED, LOW);
    delay(1000);
}

И собирать его мы будем для платы Arduino Uno, на борту которой находиться микроконтроллер ATmega328p. Самый типичный набор.

Если мы сейчас скомпилируем это программу, то скомпилированная прошивка выйдет в 924 байта (в 2% от общего объёма flash-памяти).

И тут вопрос, что же там такого, что код на 10 строчек занимает под килобайт flash-памяти?

Внутрь прошивки

В принципе мы можем это посмотреть дизассемблером. Полный листининг можете найти тут. А я попробую пошагово объяснить, что там происходит.

Вектор прерываний

В самом начале прошивки (нулевой адрес) определяется вектор прерываний, именно с него микроконтроллер начинает исполнять инструкцию за инструкцией.

По сути вектор прерываний это просто пару десятков строк безусловных переходов (прыжки aka джампы), большая часть которых ведёт на обработчик __bad_interrupt.

00000000 <__vectors>:
   0:	0c 94 5c 00 	jmp	0xb8	; 0xb8 <__ctors_end>
   4:	0c 94 6e 00 	jmp	0xdc	; 0xdc <__bad_interrupt>
   8:	0c 94 6e 00 	jmp	0xdc	; 0xdc <__bad_interrupt>
   c:	0c 94 6e 00 	jmp	0xdc	; 0xdc <__bad_interrupt>
  10:	0c 94 6e 00 	jmp	0xdc	; 0xdc <__bad_interrupt>
...

Обработчик __bad_interrupt, как понятно из названия, просто заглушка для неопределённых пользователем прерываний, которая перенаправляет на начало вектора.

000000dc <__bad_interrupt>:
  dc:	0c 94 00 00 	jmp	0	; 0x0 <__vectors>

А само начало вектора прерываний переводит нас на __ctors_end, с которого начинается подготовка (aka инициализации) к выполнению пользовательской программы.

То есть после того как микроконтроллер начал исполнять инструкции, он берёт самую первую попавшуюся (это начало вектора) и переходит (прыгает) на блок инициализации __ctors_end.

На этом этапе очищаются регистры, всё приводится к изначальному состоянию и передаётся управление пользовательскому коду (функции main).

Вообще этот код инициализации не написан Arduino (как и вектор), он является частью стандартной библиотеки AVR libc и называется startup code, то есть код для старта. Часто это ещё называют runtime'мом.

Функция main

В результате после всех предыдущих шагов мы попали в функцию main, в точку входа в программу. Но, как мы понимаем, не в наш скетч, а в Arduino.

И это время второго уровню инициализации, инициализации Arduino-окружения. И это достаточно большой кусок прошивки, ибо здесь настраиваются прерывания, таймер-счётчик для подсчёта времени, serial-порт и подобные вещи.

И после чего весьма тривиально вызываются (на самом деле нет, так как они просто подставляются) функции setup и loop. Упрощенный вид:

// void setup() {
    pinMode(7, OUTPUT);
// }
for (;;) {
// void loop() {
    digitalWrite(7, HIGH);
    delay(1000);
    digitalWrite(7, LOW);
    delay(1000);
// }
}

А остальное?

На этом моменте, надеюсь, стало яснее, откуда берется изначальный размер прошивки (так называемый footprint). Но рассмотренная нами часть никак не влияет (почти) на эффективность работы скетча.

Это лишь базовая настройка, отрабатывающая единожды при запуске или reset'е микроконтроллера.

Поэтому опустим остальные части прошивки и сделаем шаг назад.

Погружение в ArduinoCore-avr

Как вам игра в реверс инжиниринг? 😊

К счастью, Arduino это проект с открытым исходном кодом, благодаря чему мы можем изучить его в исходном виде.

По приведённой ссылки вы можете найти репозиторий ArduinoCore-avr, ядра Arduino под микроконтроллеры AVR. В терминах Arduino, ядро это реализация Arduino API (абстрактного интерфейса) под конкретную платформу.

Подробнее читайте по ссылки, останавливаться на этом не будем. Нас в первую очередь интересуют конкретная реализация!

Файловая организация Arduino Core

Открыв репозиторий с ядром можно увидеть следующую организацию проекта:

.
├── bootloaders/
├── cores/
├── drivers/
├── extras/
├── firmwares/
├── libraries/
├── variants/
└── ...

Тут много всяких файлов, но нас интересуют только два каталога: cores/arduino и variants.

Каталог cores/arduino содержит файлы реализации Arduino API, в которых определены все Arduino-функции. Например, функция digitalWrite описана в файле wiring_digital.c.

А в variants же находится аппаратно-зависимый код описания распиновки МК. Например, для плат Uno, Gemma и т.д.

Находим bottleneck

Опять же, к нашему счастью, чтобы понять, почему всё так медленно достаточно взглянуть на реализацию функции digitalWrite.

void digitalWrite(uint8_t pin, uint8_t val)
{
    uint8_t timer = digitalPinToTimer(pin);
    uint8_t bit = digitalPinToBitMask(pin);
    uint8_t port = digitalPinToPort(pin);
    volatile uint8_t *out;

    if (port == NOT_A_PIN) return;

    // If the pin that support PWM output, we need to turn it off
    // before doing a digital write.
    if (timer != NOT_ON_TIMER) turnOffPWM(timer);

    out = portOutputRegister(port);

    uint8_t oldSREG = SREG;
    cli();

    if (val == LOW) {
        *out &= ~bit;
    } else {
        *out |= bit;
    }

    SREG = oldSREG;
}

Что же тут такого интересного, к чему столько много ужасного кода?

Ну во-первых у нас происходит отображение (aka mapping) виртуальных пинов на физические ресурсы.

uint8_t timer = digitalPinToTimer(pin);
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin);

if (port == NOT_A_PIN) return;

volatile uint8_t *out = portOutputRegister(port);

Чтобы сформировать цифровой сигнал на контакте микроконтроллера, надо в определённый бит регистра записать нужное значение. Но прежде - определить, какому регистру и биту соответствует виртуальный пин.

Это делается при помощи макросов на базе определений из variants.

Далее производим проверки и если если у нас к контакту подключен таймер-счётчик, то выключаем на нём формирование ШИМ сигнала.

if (timer != NOT_ON_TIMER) turnOffPWM(timer);

Затем появляется такая интересная конструкция:

uint8_t oldSREG = SREG;
cli();

// code

SREG = oldSREG;

Говоря привычной терминологией, это реализация атомарной операции. Тут происходит отключение прерываний, чтобы ничто не могло прервать запись в регистр:

if (val == LOW) {
    *out &= ~bit;
} else {
    *out |= bit;
}

Что же тут не так? 🫠

Вы и сами видите, код немаленький и делает много всякого. И он идентичен тому, что можно найти в скомпилированной прошивки.

И тут даже проверки есть! На этом можно было бы и закончить...

Но я попытаюсь объяснить всю глубину моего ужаса. Возьмём строчку из нашего blink'а.

digitalWrite(7, HIGH);

В скомпилированном виде она выглядит вот так:

ldi	r24, 0x01	; 1
call	0xe0	; 0xe0 <digitalWrite.constprop.0>

Чего тут примечательного? Функция digitalWrite принимает два аргумента, но в скомпилированном коде передается только один.

Дело в том, что компиляторы куда могущественнее, чем писаки Arduino. И пытаются оптимизировать даже их трешовый код. Наблюдаемые нами эффекты сейчас и далее это результат работы оптимизаций свёртка констант и инлайнинг.

Именно они способны превратить нашу строчку в одну инструкцию, как если бы мы писали нативно.

Настоящий bottleneck

Если компилятор способен оптимизировать этот код, так почему у него ничего не получается?

Чтобы статически (на этапе компиляции) произвести вычисления компилятор должен собственно знать статически все значения. В нашем случае нам всё известно, и виртуальный пин (7) и логический уровень (лог. 1).

Поэтому в поисках узкого места обратимся к механизму маппинга. Возьмём код отображения пина на порт ввода-вывода.

#define digitalPinToPort(P) ( pgm_read_byte( digital_pin_to_port_PGM + (P) ) )

Да тут же чтение из flash-памяти!

const uint8_t PROGMEM digital_pin_to_port_PGM[] = {
    PD, /* 0 */
    PD,
    PD,
    ...

А это значит, что эти данные как-бы не часть программы, про которые компилятору нельзя ничего предполагать. И он обязан по настоящему их прочитать из flash-памяти.

Для подтверждении гипотезы посмотрим скомпилированную прошивки:

uint8_t port = digitalPinToPort(pin);
ec:	e3 e8       	ldi	r30, 0x83	; 131
ee:	f0 e0       	ldi	r31, 0x00	; 0
f0:	e4 91       	lpm	r30, Z

Честное обращение.

В итоге компилятор не может свернуть весь этот код. К тому же, как думаете, что будет если передать в качестве пина значение, выходящие за пределы массива digital_pin_to_port_PGM? Верно, неопределенное поведение! И проверки тут просто бесполезны, ибо считанное значение может быть чем угодно, а проверка на NOT_A_PIN никогда не сработает.

Вы всё ещё видите защиту от дурака?

Правим беспорядок

Теперь зная источник проблемы мы можем попробовать исправить всё это безобразие, написав свой эффективный digitalWrite и pinMode.

namespace fast {

#define byte volatile uint8_t*

struct IoPort {
  byte ddr;
  byte port;
  byte pin;
};


IoPort* mapPinToIoPort(const uint8_t pin) {
  static IoPort const portD = { &DDRD, &PORTD, &PIND };
  static IoPort const portB = { &DDRB, &PORTB, &PINB };
  static IoPort const portC = { &DDRC, &PORTC, &PINC };

  if (pin >= 0 && pin < 8)
    return &portD;
  else if (pin < 14)
    return &portB;
  else if (pin < 20)
    return &portC;

  return NULL;
}

const uint8_t mapPinToBit(const uint8_t pin) {
  return _BV(pin % 7);
}

void digitalWrite(const uint8_t pin, const bool val) {
  IoPort* const ioport = mapPinToIoPort(pin);
  if (ioport == NULL) return;
  // if (timer != NOT_ON_TIMER) turnOffPWM(timer);

  const uint8_t bit = mapPinToBit(pin);

  if (val)
    *ioport->port &= ~bit;
  else
    *ioport->port |= bit;
}

void pinMode(uint8_t pin, uint8_t mode) {
  IoPort* const ioport = mapPinToIoPort(pin);
  if (ioport == NULL) return;

  const uint8_t bit = mapPinToBit(pin);

  switch (mode) {
    case OUTPUT:
      *ioport->ddr |= bit;
      *ioport->port &= ~bit;
      break;
  }
}

}

Немного магии и теперь blink занимает 642 байта: 444 из которых footprint (и мёртвого кода), 191 delay и наших 8 байт (один pinMode и два digitalWrite)!

void setup() {
  fast::pinMode(LED, OUTPUT);
}

void loop() {
  fast::digitalWrite(LED, HIGH);
  delay(1000);
  fast::digitalWrite(LED, LOW);
  delay(1000);
}

Неплохая оптимизация, да?

Зная как оптимизирует компилятор, мы избавились от глобальных переменных (которые всегда обязательно находятся в памяти) и перенесли их в локальную статическую область видимости.

Теперь они не появятся до тех пор, пока мы не воспользуемся fast функциями с аргументами, неизвестными на этапе компиляции. Например:

for (uint8_t i = 0; i < 5; i++)
  fast::pinMode(i, OUTPUT);

Также избавились от отключения прерываний, это определённо лишнее.

Заключение

Изначально я хотел ещё рассказать про то, что хоть у нас и получилось оптимизировать базовые Arduino-абстракции это не победа и всё такое. И что с такими примитивными абстракциями как в Arduino каши не сваришь.

Но, думаю, это оставлю (в лучшем случае) для последующих статей.