Всем йоу! В этой статье я хотел бы осветить процесс прошивки плат Arduino на базе микроконтроллера AVR — ATmega328P. Я не стану рассказывать, как пользоваться существующими утилитами, ибо мы разберем этот процесс «под капотом», как он происходит на низком уровне, и что действительно делают те утилиты, которыми мы пользуемся.

На самом деле я не знаю для чего вам эта информация, но она может оказаться полезным студентам, проходящим профильный курс в своем учебном заведении, либо каким‑то радиолюбителям, которые почему‑то еще не в курсе. Поэтому предполагаю, что уважаемый читатель уже знаком, как с Arduino, так и с программированием «голых» AVR'ок тем или иным образом.

Теоретический минимум по прошивке AVR

In‑system programming

Прежде, чем перейти к рассмотрению непосредственно Arduino‑платформы, следует вспомнить про то, как аппаратно прошивается микроконтроллер — для этого аппаратно в самом микроконтроллере реализуется механизм «In‑System programming», внутрисхемного программирования через трех‑проводной SPI интерфейс по соответствующему аппаратному протоколу.

Для прошивки микроконтроллера этим способом обычно задействуется устройство, называемое программатором (например, популярный USBASP), для проксирования команд от пользовательского компьютера до целевого микроконтроллера. Так как подключить персональный компьютер напрямую к контроллеру задача может оказаться нетривиальной, поэтому практичнее задействовать сторонние девайсы, умеющие подключаться как по высокоуровневому интерфейсу, вроде USB, а для работы с микроконтроллером по низкоуровневому интерфейсу, вроде SPI.

Вспомним Arduino — там мы подключаем для загрузки прошивки плату напрямую по USB без использования сторонних инструментов. Неужели программатор находится на плате Arduino? Конечно же нет.

Self‑programming (самопрограммирование)

Если для предыдущего способа требовалось наличия стороннего устройства, который и производил программирование, то самопрограммирование, как понятно из названия, позволяет программировать микроконтроллерами своими собественнымми силами.

Это возможно благодаря поддержки механизма «Self‑programming» и инструкции SPM (Store Program Memory) в частности. Этот механизм позволяет выполняемой прошивке на микроконтроллере перезаписывать FLASH‑память во время своего исполнения, тем самым модифицировать записанные в память данные и инструкции «на лету». Зачастую этот механизм применяется для так называемых «загрузчиков» с английского «bootloader», о чем мы и поговорим далее.

В контексте Arduino — она читает инструкции по USB, исполняет их, тем самым модифицируя свою память.

Загрузчики, они же bootloaders

Вот мы и подошли к основной теме статьи — к загрузчикам. Загрузчик, он же bootloader, — крохотная независимая часть прошивки, служащая для загрузки (от чего и название) основной прошивки и данных в память микроконтроллера из источников внешнего мира. Тут источниками внешнего мира могут выступать всевозможные интерфейсы, но самый распространенный, конечно, RS-232 или в наше время его эмуляция поверх USB — serial‑порт (для Windows пользователей больше знакомо название COM‑порт). 

Схема подключения Arduino UNO к компьютеру
Схема подключения Arduino UNO к компьютеру

Так на плате Arduino UNO располагается микросхема‑конвертор USB‑UART, за неимением аппаратной поддержки со стороны микроконтроллера.

Основная задача загрузчика это:

  1. Запуститься, либо передать управления прошивке

  2. Если все же есть причина для запуска, то инициализировать общение с устройством или источником, от кого станут приходить команды, сохранив при этом состояние основной прошивки

  3. Организовать цикл приема команда и их исполнения

    1. Получить команду

    2. Декодировать ее

    3. Исполнить

  4. По завершению приема команд, передать управления основной прошивки

Boot‑секция

Главным условием реализации загрузчика это поддержка «Boot section» (boot‑секция, секция загрузки) со стороны аппаратной части микроконтроллера, в ATMega328P такая поддержка присутствует, а в младших моделях ATTiny и так далее со скудным запасом памяти — ее уже не найдете.

Эту секцию можно рассматривать как альтернативную точку входа, находящиеся в конце FLASH‑памяти микроконтроллера. Обычно исполнение прошивки микроконтроллера начинается с нулевого адреса (0x0000), но при включенной boot‑секции при начале работы микроконтроллер начнет выполнение начиная с адреса boot‑секции, и там же будет находится вектор прерывания и так далее.

Активируется boot‑секция посредством включения FUSE‑битов, там же настраивается и размер этой секции, размеры уже зашиты в контроллер, «свои размеры» поставить возможности нет. Для примера, загрузчик Optiboot, который используется в Arduino занимает всего 512 байт, оставляя больше свободного место для вашей прошивки. 

Протокол коммуникации STK500v1

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

В случае Arduino и его загрузчика Optiboot реализуется поддержка подмножества (очень урезанного варианта) протокола STK500 первой версии. Вообще STK500 это протокол для STK500 starertkit — стартового набора для быстрой разработки и прототипирования, но для нас это неважно.

Это простой бинарный протокол состоящий из двух сущностей: команд (commands) и ответов/сообщений (responses). Загрузчик ожидает команду, получает ее и выполняет, отправляя ответ об успешности получения и выполнения. Полное описание протокола вы можете получить в «Application note. AVR061: STK500 Communication Protocol», а ниже опишем только необходимую часть.

Ибо среди всего разнообразия команд для минимальной работоспособности — записи прошивки достаточно намного меньше:

Каждая команда заканчивается CRC_EOP байтом, обозначающий конец передачи команды, загрузчик в свою очередь отправляет INSYNC, если получил всю команду, в противном случае NOSYNC. После, в случае успешного завершения выполнения команды, отправляет ответ OK, иначе FAILED.

Числовое представление упомянутых команд и сообщений описываются в уже упомянутом application note — на его пересказе мы останавливаться не будем.

Запись страниц FLASH‑памяти

Последний элемент загрузчика, который мы рассмотрим, будет процесс записи полученной прошивки во FLASH‑память микроконтроллера, ради чего мы все и здесь.

Рассматриваемый процесс схож с тем, что делает функция strcpy — копирует из одного буфера в другой, отличия только в том, что мы работаем напрямую с FLASH‑памятью в рамках ее реализации, которые нам следовало бы понимать для успешной работы с ней.

FLASH‑память микроконтроллера организована из страниц (pages) по 32, 64 или 128 слов, слово в свою очередь это два байта, по которым и происходит адресация, то есть чтобы считать какой‑то определенный байт из FLASH‑памяти: считывается слово (два байта) и вычлиняется из него один байт. 

Тут стоит понимать, что чтение из FLASH‑памяти происходит побайтно, а запись постранично!

Чтобы записать что-либо во FLASH-память, как мы поняли, нам нужны страницы, но чтобы в них произвести запись, прежде их необходимо очистить — это связано с тем, что запись может только перевести бит из состояния 1 в состояние 0, а очистка как раз переводит все биты страницы в 1. То есть если страница не будет прежде очищена, мы не сможем перевести там, где необходимо состояния бита с 0 на 1.

После даже очистки страницы мы не приступаем к прямой ее загрузке в память, ибо сначала должен быть заполнен буфер страницы (page buffer). Этот буфер это специальный выделенный аппаратный буфер (не SRAM), существующий  только для записи страниц во FLASH-память. Он заполняется пословно, одно слово за другим. И по окончанию заполнения содержимое буфера копируется во FLASH-память за одну операцию, но процесс записи занимает некоторое время, в течение которого нельзя обновлять буфер страницы  и выполнять другие операции над памятью.

Toy ATmega328P bootloader

В этом разделе мы изучим исходный код моей миниатюрной реализации загрузчика похожего на Optiboot. Исходный код нашего загрузчика можете получить из GitHub репозитория.

Проект состоит из одного main.с файла и скрипта сборки. Следуя по коду, опустим некоторые детали, чтобы не комментировать каждую строчку.

Код у нас начинается с определения протокола STK500, а именно числовых значений для команд и сообщений:

enum {
    // Messages
    STK500_RESP_OK = 0x10,
    STK500_RESP_INSYNC = 0x14,
    STK500_RESP_NOSYNC = 0x15,
    STK500_CRC_EOP = 0x20,
    STK500_GET_SYNC = 0x30,
    // Commands
    STK500_SET_DEVICE = 0x42,
    STK500_ENTER_PROG_MODE = 0x50,
    STK500_LEAVE_PROG_MODE = 0x51,
    STK500_LOAD_ADDRESS = 0x55,
    STK500_PROG_PAGE = 0x64,
};

Как видите, это самые обычные байты, которые мы станем пересылать и принимать по UART интерфейсу.

Далее идет точка входа в программу, функция main. В нашем случае мы не отключаем startupcode, который отключает тот же Optiboot для экономии места, чтобы быть более похожими на обычную прошивку.

При старте мы проверяем, что произошел запуск микроконтроллера в результате подконтрольного сброса (RESET’а):

int main()
{
    //////////////////////////////////////////////////////////////
    // Application start reason

    if (bit_is_clear(MCUSR, EXTRF))
        enter_to_user_code();

Если да, то далее идет настройка UART на принятие и передачу и сохранение состояния основной прошивки, после чего:

  //////////////////////////////////////////////////////////////
  // Incoming commands handling stage

  uint16_t page_load_address;
  uint8_t page_buffer[SPM_PAGESIZE];

  while (1) {
      const uint8_t rx_byte = uart_getc();

Вот и самый центр загрузчика — цикл обработки приходящих команд. В нем мы смотрим какая команда пришла и выполняем ее со всеми сопутствующими вещами. 

  } else if (rx_byte == STK500_LOAD_ADDRESS) {
      // Cmnd_STK_LOAD_ADDRESS, addr_low, addr_high, Sync_CRC_EOP

      page_load_address = uart_getc();
      page_load_address |= uart_getc() << 8;
      page_load_address *= FLASH_WORD_SIZE;

      verify_crc_eop();
  } else if (rx_byte == STK500_PROG_PAGE) {
      // Cmnd_STK_PROG_PAGE, bytes_high, bytes_low, memtype, data, Sync_CRC_EOP

      uint16_t incoming_data_length = uart_getc() << 8;
      incoming_data_length |= uart_getc();

      const uint8_t _memtype = uart_getc();

      uart_read(page_buffer, incoming_data_length);
      verify_crc_eop();

      write_flush_page(page_load_address, page_buffer, incoming_data_length);

Из интересного в команде LOAD_ADDRESS мы принимаем адрес в побайтовой адресации и преобразуем его в слова. А еще в командах использует разный порядок байт — в одном little-endian, в другом big-endian.

В функции записи FLASH-страницы буквально все тоже самое, что и было у нас в теории: берем из UART буфера полученную страницу, грузим ее в буферную страницу и по окончанию очищаем страницу и грузим туда буферную.

void write_flush_page(const uint16_t addr, const uint8_t* data, const uint16_t length)
{
    if (!is_valid_page_alignment(addr))
        return;

    eeprom_busy_wait();

    for (uint16_t offset = 0; offset < SPM_PAGESIZE; offset += FLASH_WORD_SIZE) {
        uint16_t flash_word = FLASH_EMPTY_WORD;

        // Read one word (two bytes) in little-endian format from the data.
        if (offset < length) {
            const uint8_t low_byte = data[offset];
            const uint8_t high_byte
                = (offset + 1 < SPM_PAGESIZE) ? data[offset + 1] : 0xFF;

            flash_word = (uint16_t)low_byte | ((uint16_t)high_byte << 8);
        }

        // Fill the temporary page buffer with flash_word data.
        boot_page_fill_safe(addr + offset, flash_word);
    }

    // Write the bootloader temporary page buffer to FLASH page.
    boot_page_erase_safe(addr);
    boot_page_write_safe(addr);
}

И функция перехода в основную прошивку просто подчищает за собой и делает прыжок (jmp) в начала FLASH-памяти — 0x0000.

///////////////////////////////////////////////////////////////////////
// Low-level jumping to user application (firmware) and panic.

_Noreturn void enter_to_user_code(void)
{
    // Reenable RWW-section again. We need this if we want to jump back
    // to the application after bootloading.
    boot_rww_enable_safe();

    // ...

    __asm__ __volatile__("clr r1\n\t"
                         "jmp %0"
        :
        : "i"(0x0000));

    __builtin_unreachable();
}

В этом в принципе и состоит весь загрузчик как он есть. В настоящем Optiboot больше поддержки, ожидание на watchdog’ах и другие прелести, но суть и структура однотипна.

Утилита программирования, программатор

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

Задача такой утилиты считать скомпилированный артефакт и преобразовать этот объем данных в корректную последовательность команд для отправки загрузчику микроконтроллера посредством порта последовательного ввода-вывода (serial-порта, он же COM-порт в Windows). Эта задача прикладного уровня, поэтому и требования к ней уже соотвествующие.

Формат артефакта, Intel HEX формат

Говоря про скомпилированную прошивку (артефакт) на компьютере, мы не должны забывать, что она может храниться в разных форматах. Так, например,  компилятор AVR GCC выдает в ELF формате, что богата информацией, но сложна для обработки. Самый простой способ — это считывать артефакт представленный обычным сырым бинарным файлов.

$ avr-objcopy -O binary firmware.elf firmware.bin

Но это тоже не тот путь. Исторически, уже очень давно в обиходе такой формат как Intel HEX, который позволяет представлять бинарную информацию в виде ASCII текста. При этом не просто «как есть», а с указанием адресов и другой информацией, необходимой для корректной загрузки прошивки, то есть что-то грузится по одному адресу, что-то по другому и не обязательно последовательно.

Пример артефакта содержащего фразу «Hello world»:

:02000002000FED
:0600000048656C6C6F20E6
:06000700576F726C6421CA
:00000001FF

Это тоже очень простой формат: Intel HEX объект (object) состоит из записей (record), записи в свою очередь однотипны по структуре, но имеют разный тип, от чего и интерпретации полезной нагрузки записи будет разной.

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

Последовательный порт ввода-вывода

Работу с ним мы реализовали в загрузчике, следовательно и со стороны компьютера мы обязаны ее реализовать. В операционных системах для работу с подобными есть соотвествующие абстракции: в Unix и в Unix-подобных это, конечно, файлы со своим интерфейсом (файловый дескриптор), в Windows в принципе тоже самое (HANDLE объект).

Например, в macOS подключенную Arduino плату можно найти:

$ ls /dev/cu.usbserial-*
/dev/cu.usbserial-11230

На самом деле для этого нам надо посмотреть product ID и vendor ID и увидеть там CH340 какой-нибудь, который и производит эмуляцию.

У serial-порта есть свои конфигурации, про которые вы можете прочитать отдельно, главное из них для нас — это baud rate, пропускная способность, остальное это 8N1 с hardware flow control, если вы понимаете о чем я. Тут важно, чтобы сопряженные устройства обладали одинаковой конфигурацией!

Как и в случае Intel HEX для этого имеются соотвествующие библиотеки, еще больше абстрагирующие работу над serial-портом для удобства и переносимости. 

Процесс прошивки

Мы считали прошивку, открыли serial-порт — что дальше? А дальше мы должны выполнить все то, что писали в загрузчике.

Приведу пример кода из моей утилиты Burav, написанной на языке программирования OCaml.

Это очень эксперементальная и недоленная штука, развивается по мере моего research'а.

Но чтобы попасть в этот цикл надо сделать RESET микроконтроллера. Через serial-порт это делается выключением и включением линий RTS и DTR.

let reset_mcu pd =
  Serialport.Descriptor.Modem.set_data_terminal_ready pd false;
  Serialport.Descriptor.Modem.set_request_to_send pd false;

  Unix.sleepf 0.2;

  Serialport.Descriptor.Modem.set_data_terminal_ready pd true;
  Serialport.Descriptor.Modem.set_request_to_send pd true;

  Unix.sleepf 0.25

Если все прошло, как было задумано, то загрузчик переходит в бесконечный цикл, ожидая от нас команд. Тут на поверку тоже ничего сверхъестественного.

let upload_firmware ~baud_rate ~port_path firmware =
  let pd = Serialport.open_communication port_path in
  (* Set baud rate, parity, data bits, stop bits and flow control *)
  Serialport.Descriptor.configure_with_mode ~baud_rate pd "8N1H";

  let conn : Stk500v1_connection.t = `Conn pd in

  (* Reset MCU and drain serial port *)
  reset_mcu pd;
  Serialport.Descriptor.drain pd;

  (* Send [SYNC] command *)
  Stk500v1_connection.send_sync_command conn;
  (* Send [SET_DEVICE] command *)
  Stk500v1_connection.send_set_options_command conn;
  (* Send [ENTER_PROG_MODE] command *)
  Stk500v1_connection.send_enter_programming_mode_command conn;
  (* Send [CHIP_ERASE] command *)
  Stk500v1_connection.send_chip_erase conn;

  let write address page =
    (* Send [LOAD_ADDRESS address] command *)
    Stk500v1_connection.send_load_address_command conn address;
    (* Send [PROG_PAGE page] command *)
    Stk500v1_connection.send_load_flash_page_command conn page
  in
  Firmware.write_into_memory ~page_size ~write firmware;

  (* Send [LEAVE_PROG_MODE] command *)
  Stk500v1_connection.send_exit_programming_mode_command conn;

Вот и вся магия.

P.S.

Очень надеюсь, что вы нашли этот материал интересным и полезным для себя. Не бойтесь экспериментировать и изучать то, чем вы пользуетесь. Буду рад, если вы поделитесь этой статьей с теми, кому она может также пригодиться.

Всем peace!

Комментарии (0)