Hatred's Log Place

DON'T PANIC!

Apr 11, 2019 - 10 minute read - programming embedded

MitM-like поддержка RTOS в GDB

Какое-то время назад я писал код прошивки для грабберов Epiphan линейки AV.io:

  • AV.io HD - наш пилот, на котором многое было отлажено и сформирована архитектура
  • AV.io SDI - закрепляем позиции
  • AV.io 4K - уже новое железо и новые подходы, теперь запускаемся практически мгновенно
  • KVM2USB 3.0 - глубокое переосмысление AV.io HD. По сути, благодаря заложенному потенциалу для модернизации в базовую модель, практически без модификации железа, чисто софтом смогли сделать новый продукт.

Там был задействован Cypress FX3, а SDK был построен поверх ThreadX. В качестве JTAG отладчика можно использовать Olimex ARM-USB-OCD-H в связке с OpenOCD. К сожалению, OpenOCD ничего не знает про треды в RTOS и, хотя, базовая поддержка присутствует в коде, конкретно для нашего процессора использовалась схема стекинга регистров, которая отличалась от того, что было уже реализовано. Пришлось разбираться и дорабатывать. Профиты от использования JTAG для разработки трудно переоценить, как минимум в случае распределённой работы.

Итак, время идёт. Теперь очередь за FPGA от Xilinx и его софтовым процессором MicroBlaze, где можно запустить портированый FreeRTOS версии 10.x. Но проблема ровно такая же: поддержки тредов в отладчике нет!

Лирика по части устройства XSDK/Vitis и средств отладки

Xilinx предоставляет среду тесно интегрированных компонент для разработки софта на процессорах с ядром ARM Cortex-A53 (ARM64), ARM Cortex-R5 (ARM32) и MicroBlaze (MicroBlaze64). MicroBlaze стоит особняком - это софтовый процессор, который реализуется внутри FPGA. И количество ядер ограничено только числом свободного места в чипе.

Основная среда для разработки (тут и далее под разработкой понимается написание кода для CPU, не для самого FPGA, для которого среда - Vivado) - Vitis, до версии 2019 известная как XSDK, построенная поверх Eclipse. В одно касание можно грузит автоматически код для FPGA и для процессора. Из огромных плюсов то, что загрузка кода по JTAG работает очень быстро (привет OpenOCD!) ровно, как и дальнейшая загрузка процессорного кода. Вторым огромным плюсом являются заложенные по умолчанию возможности удалённой отладки: запуск, подключение, загрузка.

Есть и минусы тесной интеграции, типа навязывания использования Eclipse, хотя и есть возможности создавать, собирать и загружать проекты используя интерфейс командной строки - XSTC. Но пока не будем про это. Оставим на будущее.

Продолжим немного лирики и рассмотрим схему взаимодействия отладчика с железом.

Непосредственно с кабелем JTAG работает закрытая компонента hw_server. Эта компонента слушает несколько TCP портов, основной - 3121, и серия портов, которая начинается, по умолчанию, с 3000 - GDB серверы для процессоров ( подробнее):

  • N+0 (3000) - ARM aka ARM Cortex-R5
  • N+1 (3001) - ARM64 aka ARM Cortex-A53
  • N+2 (3002) - MicroBlaze (наш случай)
  • N+3 (3003) - MicroBlaze64

Возможно есть ещё какие-то, но нам пока достаточно этого набора.

Итак, идём дальше. Сам отладчик, который подключается к серверу. Их два, рекомендованный (и закрытый!) - System Debugger и GDB. В случае MicroBlaze исполняемый бинарник для GDB - mb-gdb.

System Debugger мы по причинам понятным дальше рассматривать не будем. И, если Xilinx выкинет поддержку GDB сервера - всё станет вновь печально.

Начинаем думать, как мы можем себе помочь

Итак, изучая реализацию поддержки RTOS в OpenOCD, я выделил основные моменты, которые нужны, что бы эту поддержку осуществить:

  1. RTOS хранит информацию о запущенных потоках в TCB (Thread Control Block), обычно это глобальный связанный список и пачка массивов, которыми оперирует планировщик задач. Структура этих блоков так или иначе ясна. Если нет (закрытый код) - добро пожаловать в прекрасный мир Reverse Engineering! Главное: нам нужно знать набор символов, точнее их адресов, что бы побегать по памяти и вычитать нужные данные.
  2. RTOS при переключении потоков должна сохранить значения регистров которые описывают состояние текущего выполнения - контекст потока. Обычно, сохраняется в вершину стека того потока, у которого забирается исполнение. Набор регистров зависит от архитектуры, их количество в контексте, размещение и общий размер контекста - всё это называется стекинг. Т.е. нужно понять как читать и восстанавливать контекст потока. Для чего - ниже.
  3. Из пункта 1 - парсить TCB и получать актуальную картину запущенных потоков (или задач - терминология может отличаться между RTOS).

Теоретически, GDB сервер должен делать все вышеописанный разбор и просто отвечать на стандартные запросы GDB клиента, что бы тот мог просто показать список потоков по команде info threads и показать актуальное значение регистров при помощи команды info registers после выполнения команды thread N и показать правильный стек вызовов по команде bt.

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

Но если подумать: вся информация у нас есть. Мы можем получить адреса нужных символов прямо в отладчике. При помощи команды p можем прочитать участки памяти, добраться до стека каждого потока, вычитать значения регистров. Всё это можно даже оформить в качестве скриптов, как это было сделано мной изначально для ThreadX на Cypress FX3. К сожалению, код потерял этот. Если вдруг найду - статью дополню.

И тут приходит мысль… А если в разрыв между GDB-client и GDB-server вставить посредника, осуществить, так сказать, MitM атаку? То есть, по сути, перехватить запросы, которые относятся к получению состояния потока, получить недостающую информацию от сервера и сформировать ответ клиенту?

Первое, что пришло в голову после этого: поискать готовые решения. Их подходящего находится gdbproxy бородатых годов, который умеет работать с сервером только через последовательный порт.

Второе - начать писать самому. Заодно снова взять в руки свои любимые C++ (на котором, к великой моей печали и грусти, по работе писать практически не приходится), Asio, потыкать палочкой C++17 и, возможно, C++2a - эти задачи, к слову, были выполнены, если не на 100%, то на 90% точно!

Заканчиваем думать, переходим к коду

Первой задачей стояла реализовать просто прокси, который будет всё принимать от клиента и пересылать на сервер и так же - в обратном направлении. Снова погрузиться в дебри асинхронщины. Хотел тут попробовать использовать Asio в реакторной схемы, но руки не дошли. А надо бы. Задача была более или менее реализована.

И тут встал вопрос: получить список потоков. Для этого нужно распарсить TCB, прочитав данные из памяти. И… оказалось что просто взять и спросить GDB-сервер об адресе символа и прочитать данные нельзя. Просто нет таких запросов. Про символы знает… только клиент! А клиенту мы запрос сформировать не можем - только он спрашивать может. Но, оказалось, что клиент может слать (а если этого не делает - дело труба) запрос серверу вида: qSymbol::, который означает: а ты не хочешь ли получить информацию о каких-то символах? Информация - это их адрес. Если ответ пустой - нет, не хочу. Если ответ вида qSymbol:NAME, то в ответ придёт qSymbol:ADDR:NAME если символ известен клиенту или qSymbol::NAME если нет, пустое значение понимается как nullptr. На каждый вопрос-ответ (в терминологии протокола это запрос от клиента к серверу, а по сути - ответ на запрос сервера) клиента сервер может спрашивать следующий символ, пока не обработает их все.

Хм. А что если перехватить этот запрос, подвинуть сервер в сторонку, символы, которые нужны нам, а когда закончим - переслать изначальный запрос уже на сервер и пусть он уже свои вопросы задаёт клиенту… Я даже не поверил своим глазам, когда это сработало!

Т.е. первую задачу, связанную с получением адресов символов мы успешно решили.

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

Аналогичным образом была получена информация о регистрах, хранящихся в стеке неактивных потоков. Здесь стоит сделать уточнение. Текущий активный поток (SMP системы мы не рассматриваем, пока) не будет хранить регистры в стеке - он использует загруженный контекст и непосредственно регистры процессора. В терминологии GDB всегда есть (даже когда нет) - один поток: текущее выполнение. Удалённый сервер понимает только его.

Теперь немного о том, как GDB-client показывает стек вызовов для тредов. На самом деле, он использует соглашение о вызовах для данной платформы и знает как получить адрес возврата их текущей функции. Но в общем и целом это сводится к вычитке значений регистров и разбор значений в них: где-то есть непосредственно адрес возврата, где-то значение SP (Stack Pointer) или FP (Frame Pointer) из которого можно разобрать стек и найти адрес возврата там. Нам этого делать не нужно, нам главно вернуть правильные значения регистров для выбранного треда.

Вычитка происходит так: посылается команда Set Current Thread (можно вернуть OK или EXX - где XX - код ошибки, если треда нет), после чего посылается команда на чтение регистров. В случае RTOS, если текущий тред неактивен, то мы перехватываем запрос на чтение регистров, читаем регистры из стека и отправляем ответ клиенту. Что делать, если запрос - к текущему активному треду. Он же - Current Execution? Казалось бы: всё просто - переслать запрос на сервер. Он же и так знает о текущем выполнении, вот и регистры для него прочитает. И это правильная идея! Но есть нюанс. Если мы перехватываем Set Current Thread и не пересылаем Set Current Thread с идентификатором единственного текущего исполнения на сервер, то получим набор xxxxxxxx для каждого регистра, мол - недоступны они! Поэтому, если идентификатор текущего патока RTOS и того, который устанавливается при помощи Set Current Thread совпадают, следует переслать Set Current Thread с указанием идентификатора текущего исполнения на сервер. Я сделал просто: в своей модели храню текущее исполнение как отдельный поток. Обработка граничных условий значительно упростилась.

Стоит отметить, что значение регистра SP никогда не сохраняется: указатель на стек и так хранится в TCB и значение регистра восстанавливается из значения указателя на стек за вычетом размера фрейма под контекст. Поэтому в реализации стекинга для регистра SP указывается специальное значение, которое обрабатывается особым образом при формировании ответа клиенту.

Следующим нюансом является то, когда обновлять модель потоков. Здесь нужно читать документацию по протоколу. Там есть информация по Stop Reply Packets. Это запросы от клиента к серверу, на которые сервер не присылает ответ. Ответ приходит только после наступления определённого события, типа срабатывания breakpoint, watchpoint или прихода от клиента команды Break (в рамках протокола, дабы упростить обработку, это пакет из единственного байта с кодом 0x03 которой может прийти от клиента после Stop Reply Packet). Если включить логику, то получается, что пока отлаживаемый код остановлен новых тредов в нём возникнуть не может. Соответственно модель тредов имеет смысл обновлять по приходу ответа на любой Stop Reply Packet, что подразумевает остановку выполнения кода. Это наиболее простое решение. К основным Stop Reply Packets относятся команды: vCont и c - Continue и s - Step (пошаговая отладка).

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

Отдельно, возможно, стоит упомянуть о формате ответа на запрос регистров. Если коротко: это просто массив значений. А вот какому регистру конкретное значение соответствует - определяется архитектурой. Точнее реализацией её поддержки в GDB. Вот для MicroBlaze в исходниках GDB можно найти файл microblaze-tdep.h и там перечисление enum microblaze_regnum:

enum microblaze_regnum
{
  MICROBLAZE_R0_REGNUM,
  MICROBLAZE_R1_REGNUM, MICROBLAZE_SP_REGNUM = MICROBLAZE_R1_REGNUM,
  MICROBLAZE_R2_REGNUM,
  MICROBLAZE_R3_REGNUM, MICROBLAZE_RETVAL_REGNUM = MICROBLAZE_R3_REGNUM,
  MICROBLAZE_R4_REGNUM,
  MICROBLAZE_R5_REGNUM, MICROBLAZE_FIRST_ARGREG = MICROBLAZE_R5_REGNUM,
  MICROBLAZE_R6_REGNUM,
  MICROBLAZE_R7_REGNUM,
  MICROBLAZE_R8_REGNUM,
  MICROBLAZE_R9_REGNUM,
  MICROBLAZE_R10_REGNUM, MICROBLAZE_LAST_ARGREG = MICROBLAZE_R10_REGNUM,
  MICROBLAZE_R11_REGNUM,
  MICROBLAZE_R12_REGNUM,
  MICROBLAZE_R13_REGNUM,
  MICROBLAZE_R14_REGNUM,
  MICROBLAZE_R15_REGNUM,
  MICROBLAZE_R16_REGNUM,
  MICROBLAZE_R17_REGNUM,
  MICROBLAZE_R18_REGNUM,
  MICROBLAZE_R19_REGNUM,
  MICROBLAZE_R20_REGNUM,
  MICROBLAZE_R21_REGNUM,
  MICROBLAZE_R22_REGNUM,
  MICROBLAZE_R23_REGNUM,
  MICROBLAZE_R24_REGNUM,
  MICROBLAZE_R25_REGNUM,
  MICROBLAZE_R26_REGNUM,
  MICROBLAZE_R27_REGNUM,
  MICROBLAZE_R28_REGNUM,
  MICROBLAZE_R29_REGNUM,
  MICROBLAZE_R30_REGNUM,
  MICROBLAZE_R31_REGNUM,
  MICROBLAZE_PC_REGNUM,
  MICROBLAZE_MSR_REGNUM,
  MICROBLAZE_EAR_REGNUM,
  MICROBLAZE_ESR_REGNUM,
  MICROBLAZE_FSR_REGNUM,
  MICROBLAZE_BTR_REGNUM,
  MICROBLAZE_PVR0_REGNUM,
  MICROBLAZE_PVR1_REGNUM,
  MICROBLAZE_PVR2_REGNUM,
  MICROBLAZE_PVR3_REGNUM,
  MICROBLAZE_PVR4_REGNUM,
  MICROBLAZE_PVR5_REGNUM,
  MICROBLAZE_PVR6_REGNUM,
  MICROBLAZE_PVR7_REGNUM,
  MICROBLAZE_PVR8_REGNUM,
  MICROBLAZE_PVR9_REGNUM,
  MICROBLAZE_PVR10_REGNUM,
  MICROBLAZE_PVR11_REGNUM,
  MICROBLAZE_REDR_REGNUM,
  MICROBLAZE_RPID_REGNUM,
  MICROBLAZE_RZPR_REGNUM,
  MICROBLAZE_RTLBX_REGNUM,
  MICROBLAZE_RTLBSX_REGNUM,
  MICROBLAZE_RTLBLO_REGNUM,
  MICROBLAZE_RTLBHI_REGNUM,
  MICROBLAZE_SLR_REGNUM, MICROBLAZE_NUM_CORE_REGS = MICROBLAZE_SLR_REGNUM,
  MICROBLAZE_SHR_REGNUM,
  MICROBLAZE_NUM_REGS
};

Это перечисление определяет порядок регистров в ответе, точнее как клиент будет его воспринимать. Если какие-то регистры не поддерживаются на платформе или при стекинге, они всё равно должны присутствовать в ответе, но значение может быть или 00000000 или xxxxxxxx, первое - просто нули, второе - специальное значение имеющее смысл: unavail. Какой вариант выбирать - это решение для конкретной платформы. По хорошему, нужно обрабатывать запрос qFeatures (TBD: уточнить!) от клиента и читать ответ сервера, если он пришёл - там может содержаться информация по регистрам и для неиспользуемых может задаваться специальное значение.

Заткнись и покажи код!

Вот: https://github.com/h4tr3d/gdbproxy/

По лицензии не определился, хочу BSD или MIT. Но активно воровал идеи в OpenOCD (код, по понятным причинам своровать сложно: разный язык, асинхронные заросы, нюансы, накладываемые природой прокси). Так что даже не знаю.

По коду: писалось очень быстро. Писалось с оглядкой, что можно и нужно параметризировать, иметь возможность добавлять другие RTOS, но в текущем виде всё это достаточно неприятно пахнет. Пока. Надеюсь.

А как запускать?

Пока только в примере для Vitis:

  1. По понятным причинам - используем GDB отладчик, не System Debugger

  2. В настройках указываем использовать любой нестандартный порт для GDB, пусть будет 4002 (это значение зашито как значение по умолчанию в gdbproxy, на котором он слушает подключения)

  3. Запускаем gdbproxy, иметь запущенную XSDK или hw_server в этот момент нет необходимости:

    ./gdbproxy --port 4002 --remote-host localhost --remote-port 3002 -- mb_freertos
    
  4. Запускаем отладку

  5. PROFIT!

В случае использования удалённого hw_server в силу малой гибкости конфигурирования Vitis, gdbproxy должен запускаться на той же машине, что и hw_server.

Выводы

Никаких. Просто в сухом остатке: threading на FreeRTOS и MicroBlaze поддерживается.

UPD

  1. 2023.02.20 - обновил информацию о портах hw_server, исправил референсы с XSDK на Vitis (новое название с версии 2019.1), добавил тегов