Отладка по UART или встроенный GDB server

Хочу подробно описать в деталях и коде, как можно отлаживать AVR по UART, не прибегая к использованию внутрисхемной отладке по JTAG, не тратя лишние пины, а задействуя лишь UART, прерывания по таймеру и возможности самопрограммирования FLASH-памяти контроллера.
В этой статье речь пойдет о программной заглушке, которая будет приостанавливать выполнение основной программы, возвращать состояние процессора, читать и писать в память, короче, выполнять роль отладчика, исполняясь непосредственно на контроллере.

Я был оч удивлен, когда на просторах сети не нашел ни одного достойного решения для, казалось бы, нужной задачи, как отладка AVR по UART. На запрос «avr gdb stub» я получил пару куцих решений, которые уже давным давно не поддерживаются, несовместимы с текущей версией gdb и содержат ряд существенных недостатков: отсутсвие исполнения интрукций по шагам или же замедление исполнения отлаживаемой программы в сотни раз. Мне показалось, что это хороший шанс попытаться разобраться в теме и, возможно, улучшить существующие решения.

DisclaimerЯ сразу хотел бы предупредить, что речь пойдет об отладке только в консольном gdb, а если быть совсем точным, то его модификации под AVR — avr-gdb версии 7.4. Именно avr-gdb (далее просто gdb) будет выполнять роль front-end, общааясь по UART с gdb сервером, исполняющимся на контроллере. Мое решение тоже не лишено недостатков: бинарного кода получилось много — ~6кб, хотя никаких попыток оптимизации и не предпринималось, а для расстановки breakpoints и выполнения инструкций по шагам необходима поддержка SPM/LPM в самом AVR чипе, но эти недостатки входили в рамки моей задачи, да и темы, затронутые в процессе реализации, оказались весьма интересными. Также отмечу, что эксперементировал я и затачивал все под ATmega16 и не делал попыток реализовать для нескольких архитектур, хотя, архитектурно зависимые части старался обернуть макросами.

Отладчик внутри ядра ОСДля начала немного общей теории. Ядра современных операционных систем включают в себя код, который позволяет отлаживать само ядро (даже Linus Torvalds, ярый противник отладчиков, сдался, и в основную ветвь Linux были залиты все патчи кернельного отладчика kgdb). Код заглушки отладчика приостанавливает выполнение ядра, сохраняет состояние регистров процессора, переключается между стеками выполнения, анализирует память, короче, процесс отладки ядра практически ничем не отличается от отладки любого другого приложения, работающего в пространстве пользователя. Обычно, заглушка получает команды по UART или TCP от самого отладчика, запущенного на другой машине. Т.е. получается классическая архитектура клиент-сервер, где в роли сервера выступает оч тонкая заглушка, умеющая работать с конкретной архитектурой процессора, а в роли клиента — отладчик, задача которого сформировать низкоуровневые команды для заглушки, чтобы расставить точки останова по адресам или «раскрутить» стек с выводом вызванных функций и их параметров на консоль. Код отладочной заглушки упрощается, если сам процессор поддерживает отладочные инструкции или программные прерывания: например, со времен процессоров 8086 Intel включает в свою архитектуру x86 аппаратную поддержку single-step interrupt, т.е. вызов обработчика прерывания после выполнения одной инструкции. Такая аппаратная поддержка позволяет реализовать отладчик ядра с полным набором функций, не прибегая к разным хитростям, эмулирующим выполнение одной инструкции. В контроллерах AVR нет поддержки подобных инструкций, а есть единственная инструкция BREAK, позволяющая выполнять отладку по JTAG. Так как JTAG нас совсем не интересует, то в настоящей статьи я опишу несколько возможных подходов к расстановке точек останова и исполнению инструкций по шагам.

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

  • прервать/продолжить выполнение программы
  • установить/снять точку останова(адрес)
  • записать/прочитать из памяти(адрес, кол-во байт)
  • записать/прочитать регистр(порядковый номер регистра)
  • прочитать все регистры

Сама команда формируется оч просто:

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

  • c — продолжить выполнение
  • D — прекратить отладку
  • g — прочитать все регистры
  • g или G — прочитать/записать все регистры
  • m или M — прочитать/записать память
  • z или Z — установить/снять точку останова

А вот так выглядит общение gdb с заглушкой при исследовании памяти:

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

Также хотелось бы отметить, что в процессе реализации протокола я брал за основу simulavr. Главным образом меня интересовало множество нюансов отладки под AVR: порядок и список возвращаемых регистров, способ адресации flash/ram памяти, поддерживаемые команды avr-gdb.

Архитектура заглушки под AVR
Как уже писалось выше, заглушка-сервер должна уметь:

  • приостановить выполнение основной программы по команде от отладчика или по breakpoint’у
  • шагать и выполнять ровно одну инструкцию
  • читать и писать в память или в регистры
  • Если с третьим пунктом все понятно, то первые два не имеют аппаратной поддержки и нуждаются в определенной эмуляции.

    Прерванная программа по Ctrl-CВ документации по gdb говорится, что пользователь может в любой момент прервать выполнение программы нажатием Ctrl-C в отладчике. Т.е. заглушка должна обработать команду прерывания (в терминах протокола avr-gdb это просто байт 0x03), приостановить основную программу и ждать следующей команды от gdb. Ничего не напоминает? Это описание работы любого прерывания контроллера, а в нашем случае UART прерывания. Т.е. пришел байт 0x03 по UART, и мы свалились в RXC прерывание, приостановив выполнение основной программы. Отлично. Что дальше? Дальше мы должны сразу же сохранить все регистры на стеке и проинициализировать верхушкой стека указатель на структуру со всеми регистрами. Код сохранения контекста из FreeRTOS прекрасно подходит для этой задачи, нуждаясь в минимальных изменениях.

    /* Определяем структуру для всех регистров */
    struct gdb_regs_ctx {
    uint8_t stack_bottom;
    uint8_t r31;
    uint8_t r30;
    uint8_t r29;
    uint8_t r28;
    uint8_t r27;
    uint8_t r26;
    uint8_t r25;
    uint8_t r24;
    uint8_t r23;
    uint8_t r22;
    uint8_t r21;
    uint8_t r20;
    uint8_t r19;
    uint8_t r18;
    uint8_t r17;
    uint8_t r16;
    uint8_t r15;
    uint8_t r14;
    uint8_t r13;
    uint8_t r12;
    uint8_t r11;
    uint8_t r10;
    uint8_t r9;
    uint8_t r8;
    uint8_t r7;
    uint8_t r6;
    uint8_t r5;
    uint8_t r4;
    uint8_t r3;
    uint8_t r2;
    uint8_t r1;
    uint8_t sreg;
    uint8_t r0;
    uint8_t pc_h;
    uint8_t pc_l;
    };
    /* Указатель на стек со всеми сохраненными регистрами */
    static struct gdb_regs_ctx *gdb_regs;

    /* Макрос сохранения всех регистров в стек и инициализации
    указателя gdb_regs верхушкой стека, т.е. SP */
    #define GDB_SAVE_CONTEXT()
    asm volatile ( "push r0 nt"
    "in r0, __SREG__ nt"
    "cli nt"
    "push r0 nt"
    "push r1 nt"
    "clr r1 nt"
    "push r2 nt"
    "push r3 nt"
    "push r4 nt"
    "push r5 nt"
    "push r6 nt"
    "push r7 nt"
    "push r8 nt"
    "push r9 nt"
    "push r10 nt"
    "push r11 nt"
    "push r12 nt"
    "push r13 nt"
    "push r14 nt"
    "push r15 nt"
    "push r16 nt"
    "push r17 nt"
    "push r18 nt"
    "push r19 nt"
    "push r20 nt"
    "push r21 nt"
    "push r22 nt"
    "push r23 nt"
    "push r24 nt"
    "push r25 nt"
    "push r26 nt"
    "push r27 nt"
    "push r28 nt"
    "push r29 nt"
    "push r30 nt"
    "push r31 nt"
    "lds r26, gdb_regs nt"
    "lds r27, gdb_regs + 1 nt"
    "in r0, __SP_L__ nt"
    "st x+, r0 nt"
    "in r0, __SP_H__ nt"
    "st x+, r0 nt"
    )

    Работа по инициализации указателя на стек происходит здесь:

    1. lds r26, gdb_regs
    lds r27, gdb_regs + 1
    2. in r0, __SP_L__
    3. st x+, r0
    4. in r0, __SP_H__
    5. st x+, r0
    1. загружаем адрес указателя в регистры X (r26, r27)
    2. сохраняем SP_L в r0
    3. сохраняем r0 в память по адресу из X, потом итерируем на 1 байт
    4. сохраняем SP_H в r0
    5. сохраняем r0 в память по адресу из X

    Я не буду приводить макрос GDB_RESTORE_CONTEXT, так как он делает тоже самое, что и GDB_SAVE_CONTEXT, но в обратном порядке.

    Как теперь воспользоваться этими макросами в самом прерывании? Первое, что нужно сделать, так это сказать компилятору не создавать пролог и эпилог для функции, т.е. сделать ее «голой». Для этого есть специальный флажок-макрос ISR_NAKED, который развертывается в специальный аттрибут компилятору gcc: __attribute__ ((naked)). Вот что получилось:

    ISR(USART_RXC_vect, ISR_NAKED)
    {
    GDB_SAVE_CONTEXT();
    /* PC согласно даташиту на ATmega16, сохраненный на стеке, требует обнуления старших трех [15..13] бит.
    это оч важно, так как реально там лежит мусор, от которого gdb сносит голову */
    gdb_ctx->regs->pc_h &= 0x1f;

    /* обрабатываем команды от gdb */
    gdb_trap();

    GDB_RESTORE_CONTEXT();
    /* не забываем "правильно" выйти из прерывания */
    asm volatile ("reti nt");
    }

    После вызова GDB_SAVE_CONTEXT gdb_ctx будет указывать на память стека, которая содержит состояние всех регистров: 32 общих, текущий PC (адрес инструкции, которая будет выполняться после прерывания, т.е. куда прыгнет reti), SREG, короче все, что нужно для отладки.

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

    Что такое breakpoint?
    Это специальная инструкция при выполнении которой процессор должен прервать текущую программу, сменить контекст и прыгнуть в какой-нибудь обработчик, т.е. выполнить программное прерывания. Для x86 breakpoint — это однобайтная инструкция 0xСC, или INT 3. Когда заглушке-серверу приходит команда от gdb установить точку останова по адресу, например, 0xdeadface, то заглушка вычитывает и запоминает оригинальную инструкцию, а потом заменяет ее на инструкцию программного прерывания, т.е. просто пишет 0xCC по адресу 0xdeadface. Все. Всю остальную работу сделает процессор, выполнив программное прерывания и прыгнув в обработчик INT 3. Снятие точки останова — это обратная операция, т.е. замена 0xCC на оригинальную инструкцию.

    У AVR же нет ничего подобного. А как же эмуляция software interrupt на INT0..2 пинах? Да, такая возможность есть, т.е. сконфигурировав пин как выход и выставив у порта нужный бит инструкцией sbi мы попадем в обработчик прерывания. Но вся проблема в том, что мы туда попадем спустя 4 инструкции, т.е. процессор выполнит sbi, а потом еще 3 инструкции после. Какая же это точка останова, если процессор ее пробегает?! Не пойдет.

    Можно попробовать инструкцию CALL. Пропатчил FLASH-память контроллера, вписал CALL по нужному адресу и жди, когда процессор прыгнет в нашу ловушку. Прекрасно. Но CALL — 32-битная инструкция, а минимальный размер инсутркции на 8-bit AVR — 16-бит. Получается, что если мы будем патчить память 32-битными инструкциями, то рано или поздно мы нарушим целостность программы и выполним мусор. Вот иллюстрация:

  • Мы находимся в «ловушке» отладчика, но, вернувшись, мы попадем на 0x1da8.
  • Выставляем breakpoint на адрес 0x1da6, заменяя две 16-битные инструкции LPM, AND на нашу 32-битную инструкцию-«ловушку» CALL.
  • Выйдя из «ловушки», мы сразу же исполним второе слово инструкции CALL, т.е. для процессора это мусор
  • Такое решение никуда не годится.

    У CALL есть 16-битный брат RCALL, но он может адресовать 8K байт, чего явно недостаточно для контроллеров с FLASH-памятью большей 8Kb. Не пойдет.

    Здесь я встретил оч простое, чертовски медленное, но красивое решение для AVR: после разрешения прерывания будет выполнена следующая инструкция до выполнения отложенного прерывания. Т.е. в переводе на человечий: контроллер не будет всегда выполнять только прерывания, как бы много их не приходило, а между прерываниями всегда будет выполняться по одной инструкции. Таким образом код становится оч простым: выполнили инструкцию, ушли в прерывание, сверили текущий PC c адресом точки останова, если не он, то заряжаем еще одно отложенное прерывание, выходим из обработчика на выполнение очередной инструкции, а если все-таки это breakpoint, то ждем команд от gdb. Оч просто, но и оч медленно, так как большую часть времени мы проведем в прерывании. Не пойдет.

    Еще немного подумав, я пришел к решению патчить FLASH контроллера 16-битной инструкцией rjmp -1 или 0xcfff — бесконечный while(1);. Пока процессор крутится в «ловушке», не меняя своего состояния, мы можем сверить текущий PC с адресом точки останова из оч редкого (раз в секунду) обработчика прерывания по таймеру. При таком подходе мы не нагружаем контроллер бесконечными вложенными прерываниями, но и имеем возможность выставить точку останова, не потеряв состояние процессора. Данный подход мне понравился, его и реализовал. Возможно, есть еще какое-то решение, которое я проглядел.

    Что такое шаг в одну инструкцию?
    Это команда stepi от gdb, по которой процессор должен выполнить ровно одну инструкцию отлаживаемой программы и снова вернуться в заглушку, ожидая дальнейших команд от gdb. Как я говорил выше, архитектура x86 аппаратно поддерживает исполнение одной инструкции, чего в AVR опять же нет. При ранее описываемом подходе с вложенными прерываниями такое «шагание» делается оч просто, но в нашем случае очевидного решения нет. Хотя, ведь можно выставлять breakpoint на следующую инструкцию за текущей, а вся задача сводится к определению адреса следующей инструкции. Например, для интструкций перехода мы должны вычислять адрес перехода и выставлять точку останова на этот адрес. Заглянув в AVR Instruction Set Manual я выписал все инструкции группами, которые куда-нибудь да прыгают. Вот что получилось:

  • CALL, JMP
    адрес перехода во втором слове инструкции
  • ICALL, IJMP, EICALL, EIJMP
    адрес перехода в регистрах r31, r32
  • RCALL, RJMP
    адрес перехода — 11..0 биты инструкции
  • RET, RETI
    адрес перехода в стеке
  • CPSE, SBRC, SBRS, SBIC, SBIS
    адрес перехода либо PC + 1, PC + 2 либо PC + 3
  • BREQ, BRNE, BRCS, BRCC, BRSH, BRLO, BRMI, BRPL, BRGE,
    BRLT, BRHS, BRHC, BRTS, BRTC, BRVS, BRVC, BRIE, BRID
    адрес перехода либо PC + 1, либо PC + k + 1, где k — 7-битный адрес
  • LDS, STS
    32-битная инструкция, т.е. адрес PC + 2
  • все остальные
    16-битные инструкции, т.е. адрес PC + 1
  • Вся сложность в анализе групп 5 и 6, так как нужно будет делать всю работу за процессор, т.е. анализировать массу флагов для вычисления правильного адреса перехода. Но намного проще расставить точки по всем адресам, т.е. для пятой группы — это 3 точки останова, а для шестой группы — 2. Получается, что нужно лишь по коду текущей инструкции понять, к какой группе она принадлежит, и расставить точки везде, где только можно, а, вернувшись снова в прерывание, снять эти breakpoint’ы. Код выглядит так:

    static void gdb_insert_breakpoints_on_next_pc(uint16_t pc)
    {
    uint16_t opcode;

    /* Вычитываем код инструкции по текущему адресу */
    opcode = safe_pgm_read_word((uint32_t)pc << 1);

    /* Выставляем breakpoint по адресу из второго слова инструкции если это
    CALL или JMP */
    if ((opcode & CALL_MASK) == CALL_OPCODE ||
    (opcode & JMP_MASK) == JMP_OPCODE)
    gdb_insert_breakpoint(safe_pgm_read_word(((uint32_t)pc + 1) << 1));
    /* Для ICALL, IJMP, EICALL, EIJMP берем адрес из регистров r31, r30 */
    else if (opcode == ICALL_OPCODE || opcode == IJMP_OPCODE ||
    opcode == EICALL_OPCODE || opcode == EIJMP_OPCODE)
    gdb_insert_breakpoint((gdb_ctx->regs->r31 << 8) | gdb_ctx->regs->r30);
    /* Для RCALL, RJMP берем адрес из 11..0 битов */
    else if ((opcode & RCALL_MASK) == RCALL_OPCODE ||
    (opcode & RJMP_MASK) == RJMP_OPCODE) {
    int16_t k = (opcode & REL_K_MASK) >> REL_K_SHIFT;
    /* k может быть и отрицательным, не забываем о знаковом бите */
    if (k & 0x0800)
    k |= 0xf000;
    gdb_insert_breakpoint(pc + k + 1);
    }
    /* Для RET, RETI берем адрес из стека */
    else if ((opcode & RETn_MASK) == RETn_OPCODE) {
    uint8_t pc_h = *(&gdb_ctx->regs->pc_h + 2) & RET_ADDR_MASK;
    gdb_insert_breakpoint((pc_h << 8) | *(&gdb_ctx->regs->pc_l + 2));
    }
    /* Инструкции могут прыгнуть на 3 адреса, выставим breakpoints для всех */
    else if ((opcode & CPSE_MASK) == CPSE_OPCODE ||
    (opcode & SBRn_MASK) == SBRn_OPCODE ||
    (opcode & SBIn_MASK) == SBIn_OPCODE) {
    gdb_insert_breakpoint(pc + 1);
    gdb_insert_breakpoint(pc + 2);
    gdb_insert_breakpoint(pc + 3);
    }
    /* Инструкции могут прыгнуть на 2 адреса, выставим breakpoints для всех */
    else if ((opcode & BRCH_MASK) == BRCH_OPCODE) {
    /* Для всех BRANCH инструкций адрес прячется в 7 битах */
    int8_t k = (opcode & BRCH_K_MASK) >> BRCH_K_SHIFT;
    /* не забываем о знаковости */
    if (k & 0x40)
    k |= 0x80;
    gdb_insert_breakpoint(pc + 1);
    gdb_insert_breakpoint(pc + k + 1);
    }
    /* 32-битные инструкции, выставляем точку на 2 слова вперед */
    else if ((opcode & LDS_MASK) == LDS_OPCODE ||
    (opcode & STS_MASK) == STS_OPCODE)
    gdb_insert_breakpoint(pc + 2);
    /* 16-битные инструкции, выставляем точку на 1 слово вперед */
    else
    gdb_insert_breakpoint(pc + 1);
    }

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

    Как писать/читать FLASH-память?
    Для замены инструкции на breakpoint инструкцию необходимо уметь читать и писать FLASH. Если для чтения в avr-libc есть специальные вызовы: pgm_read_[byte/word/dword/float]: зови функцию, получай данные, то с записью все сложнее. Писать во FLASH мы можем только из специальной секции NRWW, которая располагается в конце FLASH памяти, т.е. код, отвечающий за перезапись каких-то участков во FLASH, должен находиться именно в этой секции. Компилятору можно явно указать, к какой секции принадлежит функция:
    __attribute__ ((section(".nrww"),noinline))
    static void safe_pgm_write(const void *ram_addr,
    uint16_t rom_addr,
    uint16_t sz);
    А линковщику указать, по какому адресу располагать нужную нам секцию:

    -Wl,—section-start=.nrww=0x3ea0
    В моем случае контроллера ATmega16 с 16 Кб памяти я пишу 352 байтную функцию в самый конец FLASH-памяти, т.е. 0x4000 — 352 = 0x3ea0.

    Алгоритм записи во FLASH или self-programming происходит в три этапа:

  • Сначала заполняется специальная временная страница памяти (для ATmega16 — это 128 байт)
  • Страница стирается из FLASH (_не_ временная страница)
  • Копируется уже заполненная временная страница во FLASH (т.е. на место стертой)
  • Если мы хотим записать во FLASH данные, адрес или размер которых не кратен странице, то необходимо сначала заполнить временную страницу, вычитав FLASH, иначе мы запишем мусор. Алгоритм записи буфера из оперативной памяти в произвольный адрес во FLASH выглядит так:

    /* rom_addr — в словах, sz — в байтах, но обязан быть кратен двум. */
    __attribute__ ((section(".nrww"),noinline))
    static void safe_pgm_write(const void *ram_addr,
    uint16_t rom_addr,
    uint16_t sz)
    {
    uint16_t *ram = (uint16_t*)ram_addr;

    /* Sz должен быть не равен нулю и кратен двум */
    if (!sz || sz & 1)
    return;

    /* Ждем, если что-то писалось в EEPROM */
    eeprom_busy_wait();

    /* из байт в слово */
    sz >>= 1;

    /* округляем адрес в ROM до страницы. итерируем адрес страницы ровно на размер страницы */
    for (uint16_t page = ROUNDDOWN(rom_addr, SPM_PAGESIZE_W),
    end_page = ROUNDUP(rom_addr + sz, SPM_PAGESIZE_W),
    off = rom_addr % SPM_PAGESIZE_W;
    page < end_page;
    page += SPM_PAGESIZE_W, off = 0) {

    /* адрес страницы из слов в байты */
    uint32_t page_b = (uint32_t)page << 1;

    /* пробегаем по страницы, итерируемся по словам */
    for (uint16_t page_off = 0;
    page_off < SPM_PAGESIZE_W;
    ++page_off) {
    /* в байты */
    uint32_t rom_addr_b = ((uint32_t)page + page_off) << 1;

    /* если смещения совпадают — заполняем временную страницу
    данными из буфера */
    if (page_off == off) {
    boot_page_fill(rom_addr_b, *ram);
    if (sz -= 1) {
    off += 1;
    ram += 1;
    }
    }
    /* если смещения разные — вычитываем из FLASH */
    else
    boot_page_fill(rom_addr_b, safe_pgm_read_word(rom_addr_b));
    }

    /* чистим страницу, ждем завершения. */
    boot_page_erase(page_b);
    boot_spm_busy_wait();

    /* пишем временную страницу, ждем завершения. */
    boot_page_write(page_b);
    boot_spm_busy_wait();
    }

    /* включаем RWW секцию, иначе останемся здесь на веки вечные */
    boot_rww_enable ();
    }

    Данный алгоритм используется для записи инструкций точек останова во FLASH.

    Что же получилось?
    Получилась полноценная AVR реализация заглушки для avr-gdb, позволяющая отлаживать код, используя UART и одно прерывания по таймеру. Для демонстрации отладки я использовал небольшой алгоритм сортировки, взятый из ядра Linux:

    Код заглушки: github.com/rouming/AVR-GDBServer

    Используемые материалыHowto: GDB Remote Serial Protocol
    GDB remote protocol
    AVR109 Self-programming
    AVR bootloader FAQ
    AVR Instruction Set Manual
    Simulavr gdbserver.cpp source
    FreeRTOS context switch source

    Источник

    Оставить комментарий

    Вы можете использовать следующие теги и атрибуты: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>