Отладка микроконтроллеров ARM Cortex-M по UART Часть 2

    В прошлой статье я рассказывал про прерывание DebugMon и регистры с ним связанные.

    В этой статье будем писать реализацию отладчика по UART.

    Низкоуровневая часть


    Тут и тут есть описание структуры запросов и ответов GDB сервера. Хоть оно и кажется простым, но реализовывать в микроконтроллере его мы не будем по следующим причинам:

    • Большая избыточность данных. Адреса, значения регистров, переменных кодируются в виде hex-строки, что увеличивает объем сообщений в 2 раза
    • Парсить и собирать сообщения займет дополнительные ресурсы
    • Отслеживать конец пакета требуется либо по таймауту (будет занят таймер), либо сложным автоматом, что увеличит время нахождения в прерывании UART

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

    • 0хАА 0xFF — Start of frame
    • 0xAA 0x00 — End of frame
    • 0xAA 0xA5 — Interrupt
    • 0xAA 0xAA — Заменяется на 0xAA

    Для обработки этих последовательностей при приеме потребуется автомат с 4мя состояниями:

    • Ожидание ESC символа
    • Ожидание второго символа последовательности Start of frame
    • Прием данных
    • Прошлый раз был принят Esc символ

    А вот для отправки состояний потребуется уже 7:

    • Отправка первого байта Start of frame
    • Отправка второго байта Start of frame
    • Отправка данных
    • Отправка End of frame
    • Отправка Esc символа замены
    • Отправка первого байта Interrupt
    • Отправка второго байта Interrupt

    Напишем определение структуры, внутри которой будут находиться все переменные модуля:

    typedef struct 
    {    
      // disable receive data
      unsigned tx:1;
      // program stopped
      unsigned StopProgramm:1;
      union {
        enum rx_state_e 
        {
          rxWaitS = 0, // wait Esc symbol
          rxWaitC = 1, // wait Start of frame
          rxReceive = 2, // receiving
          rxEsc = 3, // Esc received
        } rx_state;
        enum tx_state_e 
        {
          txSendS = 0, // send first byte of Start of frame
          txSendC = 1, // send second byte
          txSendN = 2, // send byte of data
          txEsc = 3,   // send escaped byte of data
          txEnd = 4,   // send End of frame
          txSendS2 = 5,// send first byte of Interrupt
          txBrk = 6,   // send second byte
        } tx_state;
      };
      uint8_t pos; // receive/send position
      uint8_t buf[128]; // offset = 3
      uint8_t txCnt; // size of send data
    } dbg_t;
    #define dbgG ((dbg_t*)DBG_ADDR) // адрес задан жестко, в настройках линкера эта часть озу убирается из доступной

    Состояния приемного и передающего автоматов объеденены в одну переменную так как работа будет вестись полудуплексном режиме. Теперь можно писать сами автоматы с обработчиком прерываний.

    Обработчик UART
    void USART6_IRQHandler(void)
    {
      if (((USART6->ISR & USART_ISR_RXNE) != 0U)
          && ((USART6->CR1 & USART_CR1_RXNEIE) != 0U))
      {
        rxCb(USART6->RDR);
        return;
      }
    
      if (((USART6->ISR & USART_ISR_TXE) != 0U)
          && ((USART6->CR1 & USART_CR1_TXEIE) != 0U))
      {
        txCb();
        return;
      }
    }
    
    void rxCb(uint8_t byte)
    {
      dbg_t* dbg = dbgG; // debug vars pointer
      
      if (dbg->tx) // use half duplex mode
        return;
      
      switch(dbg->rx_state)
      {
      default:
      case rxWaitS:
        if (byte==0xAA)
          dbg->rx_state = rxWaitC;
        break;
      case rxWaitC:
        if (byte == 0xFF)
          dbg->rx_state = rxReceive;
        else
          dbg->rx_state = rxWaitS;
        dbg->pos = 0;
        break;
      case rxReceive:
        if (byte == 0xAA)
          dbg->rx_state = rxEsc;
        else
          dbg->buf[dbg->pos++] = byte;
        break;
      case rxEsc:
        if (byte == 0xAA)
        {
          dbg->buf[dbg->pos++] = byte;
          dbg->rx_state  = rxReceive;
        }
        else if (byte == 0x00)
        {
          parseAnswer();
        }
        else
          dbg->rx_state = rxWaitS;
      }
    }
    
    void txCb()
    {
      dbg_t* dbg = dbgG;
      switch (dbg->tx_state)
      {
      case txSendS:
        USART6->TDR = 0xAA;
        dbg->tx_state = txSendC;
        break;
      case txSendC:
        USART6->TDR = 0xFF;
        dbg->tx_state = txSendN;
        break;
      case txSendN:
        if (dbg->txCnt>=dbg->pos)
        {
          USART6->TDR = 0xAA;
          dbg->tx_state = txEnd;
          break;
        }
        if (dbg->buf[dbg->txCnt]==0xAA)
        {
          USART6->TDR = 0xAA;
          dbg->tx_state = txEsc;
          break;
        }
        USART6->TDR = dbg->buf[dbg->txCnt++];
        break;
      case txEsc:
        USART6->TDR = 0xAA;
        dbg->txCnt++;
        dbg->tx_state = txSendN;
        break;
      case txEnd:
        USART6->TDR = 0x00;
        dbg->rx_state = rxWaitS;
        dbg->tx = 0;
        CLEAR_BIT(USART6->CR1, USART_CR1_TXEIE);
        break;
      case txSendS2:
        USART6->TDR = 0xAA;
        dbg->tx_state = txBrk;
        break;
      case txBrk:
        USART6->TDR = 0xA5;
        dbg->rx_state = rxWaitS;
        dbg->tx = 0;
        CLEAR_BIT(USART6->CR1, USART_CR1_TXEIE);
        break;
      }
    }


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

    void parseAnswer()
    {
      dbg_t* dbg = dbgG;
      dbg->pos = 1;
      dbg->buf[0] = 0x33;
      dbg->txCnt = 0;
      dbg->tx = 1;
      dbg->tx_state = txSendS;
      SET_BIT(USART6->CR1, USART_CR1_TXEIE);
    }

    Компилим, зашиваем, запускаем. Результат виден на скрине, оно заработало.

    Тестовый обмен


    Далее нужно реализовать аналоги команд из протокола GDB сервера:

    • чтение памяти
    • запись памяти
    • останов программы
    • продолжение выполнения
    • чтение регистра ядра
    • запись регистра ядра
    • установка точки останова
    • удаление точки останова

    Команда будет кодироваться первым байтом данных. Коды команд имеют номера в порядке их реализации:

    • 2 — чтение памяти
    • 3 — запись памяти
    • 4 — останов
    • 5 — продолжение
    • 6 — чтение регистра
    • 7 — установка breakpointа
    • 8 — очистка breakpointа
    • 9 — шаг (не получилось реализовать)
    • 10 — запись регистра (не реализовано)

    Параметры будут передаваться следующими байтами данных.

    Ответ не будет содержать номер команды, т.к. мы и так знаем какую команду отправляли.

    Чтобы модуль не вызывал исключения BusFault при операциях чтения/записи, нужно маскировать его при использовании на M3 и выше, либо писать обработчик HardFault для M0.

    Безопасный memcpy
    int memcpySafe(uint8_t* to,uint8_t* from, int len)
    {
        /* Cortex-M3, Cortex-M4, Cortex-M4F, Cortex-M7 are supported */
        static const uint32_t BFARVALID_MASK = (0x80 << SCB_CFSR_BUSFAULTSR_Pos);
        int cnt = 0;
    
        /* Clear BFARVALID flag by writing 1 to it */
        SCB->CFSR |= BFARVALID_MASK;
    
        /* Ignore BusFault by enabling BFHFNMIGN and disabling interrupts */
        uint32_t mask = __get_FAULTMASK();
        __disable_fault_irq();
        SCB->CCR |= SCB_CCR_BFHFNMIGN_Msk;
    
        while ((cnt<len))
        {
          *(to++) = *(from++);
          cnt++;
        }
    
        /* Reenable BusFault by clearing  BFHFNMIGN */
        SCB->CCR &= ~SCB_CCR_BFHFNMIGN_Msk;
        __set_FAULTMASK(mask);
    
        return cnt;
    }


    Установка breakpointа реализуется через поиск первого неактивного регистра FP_COMP.

    Код, устанавливающий breakpointы
    	
      dbg->pos = 0; // установим кол-во байт ответа в 0
        addr = ((*(uint32_t*)(&dbg->buf[1])))|1; // требуемое значение регистра FP_COMP
        for (tmp = 0;tmp<8;tmp++) // ищем не был ли установлен breakpoint уже
          if (FP->FP_COMP[tmp] == addr)
            break;
        
        if (tmp!=8) // если был, выходим
          break;
        
        for (tmp=0;tmp<NUMOFBKPTS;tmp++) // ищем свободный регистр
          if (FP->FP_COMP[tmp]==0) // нашли?
          {
            FP->FP_COMP[tmp] = addr; // устанавливаем
            break; // и выходим
          }
        break;


    Очистка реализуется через поиск установленной точки останова. Остановка выполнения устанавливает breakpoint на текущий PC. При выходе из прерывания UART, ядро сразу попадает в DebugMon_Handler.

    Сам же обработчик DebugMon выполнен очень просто:

    • 1. Устанавливается флаг остановки выполнения.
    • 2. Очищаются все установленные точки останова.
    • 3. Ожидается завершение отправки ответа на команду в uart (если он не успел отправиться)
    • 4. Начинается отправка последовательности Interrupt
    • 5. В цикле вызываются обработчики автоматов приема и передачи пока не опустится флаг останова

    Код обработчика DebugMon
    void DebugMon_Handler(void)
    {
      dbgG->StopProgramm = 1; // устанавливаем флаг остановки
      
      for (int i=0;i<NUMOFBKPTS;i++) // очищаем breakpointы
        FP->FP_COMP[i] = 0;
      
      while (USART6->CR1 & USART_CR1_TXEIE) // ждем пока отправится ответ
        if ((USART6->ISR & USART_ISR_TXE) != 0U)
          txCb();
    
      
      dbgG->tx_state = txSendS2; // начинаем отправку Interrupt последовательности
      dbgG->tx = 1;
      SET_BIT(USART6->CR1, USART_CR1_TXEIE);
    
      while (dbgG->StopProgramm) // пока флаг не сбросится командой продолжения выполнения
      {
      	// вызываем автоматы UARTа в цикле
        if (((USART6->ISR & USART_ISR_RXNE) != 0U)
            && ((USART6->CR1 & USART_CR1_RXNEIE) != 0U))
          rxCb(USART6->RDR);
    
        if (((USART6->ISR & USART_ISR_TXE) != 0U)
            && ((USART6->CR1 & USART_CR1_TXEIE) != 0U))
          txCb(); 
      }
    }


    Читать регистры ядра из СИшного когда задача проблематичная, поэтому я переписал часть кода на ASM. В результате получилось что ни DebugMon_Handler, ни обработчик прерывания UART, ни автоматы не используют стек. Благодаря этому упростилось определение значений регистров ядра.

    GDB server


    Микроконтроллерная часть отладчика работает, теперь займемся написанием связующего звена между IDE и нашим модулем.

    С нуля писать сервер отладки не имеет смысла, поэтому за основу возьмем готовый. Так как больше всего опыта у меня в разработке программ на .net, взял за основу этот проект и переписал под другие требования. Правильнее было бы дописать поддержку нового интерфейса в OpenOCD, но это бы заняло больше времени.

    При запуске программа спрашивает с каким COM портом работать, далее запускает на прослушивание TCP порт 3333 и ждет подключения GDB клиента.

    Все команды GDB протокола транслируются в бинарный протокол.

    В результате вышла работоспособная реализация отладки по UART.

    Итоговый результат


    Заключение


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

    Исходники выложил на GitHub для всеобщего изучения

    Микроконтроллерная часть
    GDB сервер
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 24

      0
      Спасибо за статью!
      #define dbgG ((dbg_t*)DBG_ADDR) // адрес задан жестко, в настройках линкера эта часть озу убирается из доступной

      А в чем смысл было убирать в недоступную секцию?
        0
        Если каждый раз перезаливать прошивку вместе с отладчиком, то не зачем.
        А если отладчик будет работать еще и как booloader, могут возникнуть пересечения адресов переменных. Да и проще в настройках линкера убрать немного озу, чем еще и выделить отдельную секцию для отладчика.
          0
          А если отладчик будет работать еще и как booloader

          А GDB разве позволяет грузить прошивку? Или вы отдельный протокол для этого будете делать?
            0
            Есть возможность загружать прошивки через GDB. IAR к примеру сперва загружает командами записи памяти boot в ОЗУ. Далее его алгоритм загружает куски прошивки в ОЗУ, устанавливает PC на адрес boot и ждет breakpointа окончания операции. В папке st-link utility есть примеры таких бутов с исходниками.
            Отдельный протокол нет смысла придумывать, когда он уже есть в openOCD.
              0
              Интересно сделано в IAR. Это у него так в доках прям написано? или вы реверсили его?
              В папке st-link utility есть примеры

              st-link utility это вот эту утилиту имеет ввиду
              фото
              image

              Там в папке ExternalLoader подобное применяется, но я так понял это что бы прошивать внешние чипы памяти
                0
                Реверсил обмен в WireShark между IAR и openOCD. Но в моей конфигурации breakpoint окончания операции не срабатывает по какой-то причине.
                Что-то похожее видел в PIC24 Programming Manual. Там можно либо вручную программатором писать флеш. Либо зашить загрузчик и работать с ним.

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

                  Можно вместо брейкпойнта и поллинг окончания операции сделать.

                  9 — шаг (не получилось реализовать)

                  А в чем возникла проблема сделать шаг? вроде бы просто поставить брейкпойнт на следующую ассемблерную инструкцию.

                    0
                    Можно вместо брейкпойнта и поллинг окончания операции сделать.

                    Поллинг будет забирать часть времени, что увеличит время заливки.

                    А в чем возникла проблема сделать шаг?

                    Тогда нужно реализовать определение текущей инструкции средствами МК. Ато вдруг это инструкция перехода?
                    Но есть другой способ: В регистре DEMCR есть бит MON_STEP. Но тут возникли проблемы. Если не выключить прерывания, программа всегда останавливается на первой инструкции любого из наступивших прерываний. А если их выключить, он больше не попадает в DebugMon_Handler
                    Единственно кажущийся рабочий вариант — эмулировать эту команду на стороне GDB сервера
                      0
                      Ато вдруг это инструкция перехода?

                      Про инструкцию перехода я как-то и забыл)
                      Если не выключить прерывания, программа всегда останавливается на первой инструкции любого из наступивших прерываний. А если их выключить, он больше не попадает в DebugMon_Handler

                      Выключать можно не все, а оставить только DebugMon_Handler, разве нет?
                        +1
                        А вот если оставлять только DebugMon_Handler включенным, работает. При выходе из обработчика он выполняет одну инструкцию и возвращается обратно.
                        Т.е. команду пошагового выполнения реализовать удалось
        0

        А вот это круто! Спасибо!


        Но, как и в прошлый раз, позволю себе немного поворчать:
        0хАА 0xFF — Start of frame
        0xAA 0x00 — End of frame
        0xAA 0xA5 — Interrupt
        0xAA 0xAA — Заменяется на 0xAA

        На мой взгляд, протокол странный. Никакого контроля ошибок, зато есть escape-кодирование.
        Может быть, хотя бы чексумму добавить?
        А если использовать CRC, то можно и без escape-кодирования обойтись...

          0
          Контрольная сумма — хорошая идея, нужно будет добавить.
          Не представляю как можно используя CRC избавиться от escape-кодирования при условии что пакеты переменной длинны
            +3
            Не представляю как можно используя CRC избавиться от escape-кодирования при условии что пакеты переменной длинны

            1. Делаем пакета вида START, SIZE, DATA....DATA, CRC
            2. Во входном потоке байт ищем START.
            3. Следующий байт (байты) — SIZE.
            4. Набираем SIZE байт
            5. Проверяем CRC, сошлась — хорошо, набираем новый пакет. Не сошлась — ищем в том, что уже собрали еще один START; если нашли — пляшем от него по новой.

            UPD: Вроде как это называется https://en.wikipedia.org/wiki/CRC-based_framing

              +1
              Спасибо! Буду знать.
                0
                А как быть с такой ситуацией, что приемник пропустил START байт, а в дата лежит такая последовательность байт «START, SIZE», ведь у нас без escape-кодирования.
                Он этот пакет пропустит, дальше отправитель будет дублировать этот пакет и приемник уже ни один пакет принять не сможет.
                  +1

                  Если сделать правильно, то будет так:


                  • пропущен start, size
                  • в середине данных найден фиктивный start, size, пошло накопление пакета
                  • пакет набран по фиктивному размеру, crc не сошлось.
                  • в уже набранном наборе байт ищется следующий start — он настоящий, синхронизация восстановлена

                  Т.е. если crc не сходится, не нужно выбрасывать все накопленное.


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

                    0
                    С escape-кодированием проще выходит код как-то)
                    Ведь в дата может быть:
                    «START, SIZE,START, SIZE,START, SIZE,START, SIZE...» =)

                    А несколько стартовых байт, по сути, не спасут от
                    «START, SIZE,START, SIZE,START, SIZE,START, SIZE...»

                    Про ограничить размер пакета, что вы имели ввиду?
                      0

                      С эскейп-кодированием нужно эскейп кодирование и контрольная сумма тоже! А так — только контрольная сумма.


                      Код проще? Спорный вопрос.


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


                      Так — только фиксированный запас на CRC.


                      Да и в чем сложность? Crc не сошлось — цикл для поиска старта, сдвиг массива (один вызов std::rotate), запускаем основной автомат набора пакета.


                      «START, SIZE,START, SIZE,START, SIZE,START, SIZE...»

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


                      Про ограничить размер пакета, что вы имели ввиду?

                      Это и имел — сказать, что пакеты могут быть не более Х байт.




                      Лично мое мнение: эскейп-кодирование — это устаревшая, неудобная и ненужная штука. Огромное количество протоколов (в т.ч. Ethernet и TCP) живет без него и отлично себя чувствует.

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

                        Можно и на лету эскапировать и не потребуется двойной буфер, если уж память жалко.

                        Про простоту кода, это мое субъективное мнение, доказать я её не пытаюсь. Однозначно crc нужно всегда это да.

                          0
                          Можно и на лету эскапировать и не потребуется двойной буфер, если уж память жалко.

                          Чтобы эскапировать на лету, придется руками каждый байт перекладывать в UART, не получится ни выделить работу с UART'ом в отдельный модуль, ни использовать DMA, например.


                          Можно, конечно, но не очень удобно.

                          0
                          Код проще? Спорный вопрос.

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

                          Вы читали мой код, в частности функцию tx_cb? Всё кодирование при отправке реализуется средствами конечного автомата, при этом его алгоритм проще некуда. Так-же и при приеме. В буфере лежат только полезные данные.

                          Да и в чем сложность? Crc не сошлось — цикл для поиска старта, сдвиг массива (один вызов std::rotate), запускаем основной автомат набора пакета.

                          Сложность в объеме и скорости выполнения кода. На стороне ПК это рабочий вариант, но не в МК.

                          В некоторых МК у модуля UART есть прерывание IDLE, наступает когда обнаружена пауза в потоке данных. Можно было бы использовать его и не использовать Esc-кодирование, но в STM32F7 нет такого прерывания у UARTа.
                            0
                            Вы читали мой код, в частности функцию tx_cb? Всё кодирование при отправке реализуется средствами конечного автомата, при этом его алгоритм проще некуда. Так-же и при приеме. В буфере лежат только полезные данные.

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


                            Сложность в объеме и скорости выполнения кода. На стороне ПК это рабочий вариант, но не в МК.

                            Как вы поняли, что для МК это не рабочий вариант?
                            Мы ведь с вами говорим про Cortex-M, а не про PIC восьмибитный; тут и памяти вполне достаточно и скорости.


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


                            А уж STM32F7 тактовые за 200 МГц! Это ж почти Пентиум 2 :)

                              0
                              Моя идеология модуля отладки — минимум затрачиваемых ресурсов и влияния на поведение отлаживаемой прошивки. Поэтому усложнять алгоритм не стоит.
                              Но не вижу ничего плохого в использовании таких алгоритмов в рабочих проектах.
                                0
                                Моя идеология модуля отладки — минимум затрачиваемых ресурсов и влияния на поведение отлаживаемой прошивки.

                                Это довод.

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

            Самое читаемое