Мой очень вольный и дополненый перевод моей же статьи на interrupt.memfault.com блог

Оказывается пробрасывать можно не только сетевые порты, а еще и регистры периферии микроконтроллера(МК). Идея та же самая - открыть периферию для использования вне микроконтроллера. Для наглядности давайте сравним более детально.

Проброс сетевого порта на роутере, обеспечивает доступ внешним клиентам к внутренним ресурсам в локальной сети этого роутера:

Проброс портов на роутере
Проброс портов на роутере

В случае проброса периферии, внешними клиентами будут выступать программы на ПК, и для них мы будем давать доступ к внутренним регистрам микроконтроллера. Для программ это будет выглядеть так, как будто регистры периферии находятся в адресном пространстве компьютера(ПК). Все равно что эти самые регистры периферии висели бы на одной шине с памятью и другими устройствами ввода-вывода:

Проброс периферии миркоконтроллера
Проброс периферии миркоконтроллера

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

#include <stdio.h>

// ==========================================
// from stm32f10x.h header file
typedef struct
{
  int CRL;
  int CRH;
  int IDR;
  int ODR;
  int BSRR;
  int BRR;
  int LCKR;
} GPIO_TypeDef;

//peripheral GPIO address
#define GPIOC ((GPIO_TypeDef*)0x40011000)
// ==========================================

int main() {
  // считываем состояние с пина PC0 в МК STM32
  int pin_PC0_value = GPIOC->IDR & 0x1; 
  // write the value to console
  printf("PC0 value: %d\n", pin_PC0_value);
  return 0;
}

Это не фантастика, это и вправду можно делать, с помощью отладчика, проводков и теперь уже Open Source инструментов. Более того это можно было делать еще в 2018 году, тогда на хабре вышла моя статья Как перестать писать прошивки для микроконтроллеров и начать жить.

Напомню что за идея была в 2018 году:

Путь первый. Перехватываем все что есть

Что бы код выше заработал и заработал правильно, решение тогда представлялось таким: давайте при запуске приложения в runtime будем перехватывать все операции записи/считывания по всем адресам с помощью динамической инструментизации(DBI), а именно используя PinTool.

где происходят перехваты инструкций, которые работают с памятью
где происходят перехваты инструкций, которые работают с памятью

При инструментизации нашей программы PinTool, на каждую инструкцию mov что работает с памятью будет вызываться хук(на картинке выше пометка intercepting)

В каждой инструкции mov что работает с памятью будем проверять операнды, и если в операндах есть адрес, который совпадает с адресом периферии (к примеру адрес GPIOС 0x40011000) в микроконтроллере, то PinTool будет эту операцию выполнять на целевом микроконтроллере с помощью OpenOCD сервера и железного отладчика(st-link) - как это делается, смотри лучше в прошлой статье.

Перехватили и как поступаем дальше с операцией
Перехватили и как поступаем дальше с операцией

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

int pin_PC0_value = GPIOC->IDR & 0x1;

PinTool перехватывает эту операцию и делает запрос в отладчик, прочитать 32 бита по адресу GPIOC->IDR (значение 0x40011000 + 8 , на картинке очепяточка)

что происходит когда обращаемся к регистру периферии GPIO
что происходит когда обращаемся к регистру периферии GPIO

Отладчик считал значение 0xA5 из регистра GPIOC->IDR, в итоге в выражение в рантайме подставляется 0xA5 значение:

int pin_PC0_value = 0xA5 & 0x1;

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

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

А вот про производительность такого нельзя было сказать и дело даже не в медленном обмене данными через отладчик. Он и вправду не быстрый, но больше всего тормозила динамическая инструментизация программы с помощью PinTool. В наших, пусть даже небольших программах, используется множество библиотек, кто-то еще тянет фреймворки. И во всех них, разумеется, используется работа с памятью и каждая mov операция перехватывалась PinTool. Поэтому быстродействие могло проседать на порядок!

К тому же, хоть PinTool и хорош, но динамическая инструментизация работает не очень стабильно, хотя не исключено, что я делал что-то не так. Был еще такой “приятный бонус”: программу можно было не отлаживать... да да, у нас по сути процесс уже находился под отладчиком и прицепиться внешним отладчиком к ней было нельзя. И напоследок физические и проприетарные ограничения PinTool. Работает это только на процессорах от Intel и только на трех PC платформах(Windows, Linux, MacOS), а так же инструмент поставляется только в скомпилированном виде без исходных кодов.

Путь второй. Из Runtime в Compile-Time

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

Раз мы уходим от рантайма, остается только менять код. Код из примера выше для работы с периферией(в данном случае GPIO) используют напрямую регистры:

int pin_PC0_value = GPIOC->IDR & 0x1;

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

Как правило вся работа с периферией чипа идет через вызовы ф-ции драйверов, а в коде драйвера уже напрямую используются регистры МК. Вот тут очень хотелось бы переопределить все операции с регистрами периферии на свои ф-ции. Давайте посмотрим как это выглядело бы на примере. Есть такой код работы с таймером в библиотеке STM32F10x StdPeriph Lib V3.5.0:

tmpsmcr = TIMx->SMC
...
/* Write to TIMx SMCR */
TIMx->SMCR = tmpsmcr;

Предполагается что замена операций с регистром TIMx на наши ф-ции, будет выглядеть как-то так:

tmpsmcr = load_from_register(&TIMx->SMCR);
...
/* Write to TIMx SMCR */
store_to_register(&TIMx->SMCR,tmpsmcr);

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

Мы поняли, что нам нужно во всех драйверах заменить операции с регистрами на свои ф-ции load_from_register и store_to_register. Как это можно сделать?...

Понятное дело что вручную брать и менять код в драйверах бессмысленно. Написать парсер и преобразователь С/С++ кода, это слишком трудоемкая задача. Нужна инструментизация кода попроще, что-то типа как в санитайзерах LLVM. А если поглядеть повнимательнее на санитайзеры, то АddressSanitizer(ASAN) делает почти то что нам нужно.

что АddressSanitizer делает с кодом
что АddressSanitizer делает с кодом

АddressSanitizer вставляет проверку перед каждой операцией по указателю. В этой проверке он вызывает свои ф-ции проверки Poisoned памяти. Для нашей задачи нужно, что бы ф-ция проверки вставлялась не перед операцией с указателем, а вместо. К примеру такой код:

*p = 0xb00;
int var = *p;

Для нашей задачи должен инструментироваться вот так:

our_function(p, 0xb00);
int var = our_function(p);

На основе санитайзера ASAN, я написал свой плагин(LLVM pass) для LLVM, AddressInterceptor(ADIN). Репозиторий выложен на github. Код получился попроще чем в ASAN, потому что меньше кода нужно инструментировать и не нужно заниматься разметкой памяти.

Плагин работает в составе утилиты opt от LLVM. Для сборки в составе LLVM создал отдельный форк ADIN LLVM . LLVM pass работает только с LLVM IR кодом и делает преобразования только в нем. Посмотрим на примере. Был такой простой код на С:

int var = 0;
void f(){
	*(int*)0x100 = 1;
	var = *(int*)0x100;
}

Сделали из него LLVM IR код:

clang -S -emit-llvm example.c -o example.ll
define dso_local void @f() #0 {
  store i32 1, i32* inttoptr (i64 256 to i32*), align 4
  %1 = load i32, i32* inttoptr (i64 256 to i32*), align 4
  store i32 %1, i32* @b, align 4
  ret void
}

Теперь пропустим этот LLVM IR код через наш ADIN Pass. Запускаем утилиту opt c флагом -adin

opt -adin -S example.ll-o adin_modified_example.ll
define dso_local void @f() #0 {
  call void @__adin_store_(i8* inttoptr (i64 256 to i8*), i64 1, i32 32, i32 4)
  %load_i32_ = call i64 @__adin_load_(i8* inttoptr (i64 256 to i8*), i32 32, i32 4)
  %truncated_i32_ = trunc i64 %load_i32_ to i32
  store i32 %truncated_i32_, i32* @b, align 4
  ret void
}

Вместо обращений по указателям он вставляет ф-ции _adin_store_ и _adin_load_ соответственно на запись и на считывание. В некоторых случаях с указателями работают операции memset и memcpy. Они соответственно заменяются на _adin_memset_ и _adin_memcpy_ .

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

if(AllocaAddressSkip.getValue() &&
      AllocaRecognizer.isProbablyAllocaOperation(op.PtrOperand)){
      ADIN_LOG(__DEBUG) << "Inst Alloca, local var detect: " << Inst;
			ADIN_LOG(__DEBUG) << "Skip instuction";
      continue;
}

if(SimpleGlobalVarSkip.getValue()){
    const GlobalValue* GV = dyn_cast<GlobalValue>(op.PtrOperand);
    if(GV != nullptr){
        ADIN_LOG(__DEBUG) << "Simple Global Variable detect: " << *op.PtrOperand;
        ADIN_LOG(__DEBUG) << "Skip instuction: " << Inst;
        continue;
    }
  }

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

int global = 0;

void foo(){
	int var;
	global = 0;
	var = 1;
}

Теперь мы можем пройтись ADIN LLVM pass по всему коду драйверов микроконтроллера и проинструментировать все операции с регистрами. Осталось только реализовать вставляемые ф-ции:

extern "C" void __adin_store_(llvm_pass_addr pointer, llvm_value_type value, llvm_pass_arg TypeSizeArg, llvm_pass_arg AlignmentArg)
extern "C" llvm_value_type __adin_load_(const llvm_pass_addr pointer, llvm_pass_arg TypeSizeArg, llvm_pass_arg AlignmentArg)
extern "C" void __adin_memcpy_(llvm_pass_addr dest, const llvm_pass_addr src, const llvm_pass_arg size)
extern "C" void __adin_memset_(llvm_pass_addr dest, const llvm_pass_arg val, const llvm_pass_arg size)

Реализацию этих четырех функций я выделил в отдельный репозиторий REMCU. В каждой из них, в передаваемых аргументах есть адрес операции (аргумент pointer), который проверяется на вхождения в интервалы адресов периферии для конкретного микроконтроллера. К примеру возьмем чип STM32L0 для него интервалы адресов периферии будут:

  • 0x5000 0000 - 0x5000 1FFF

  • 0x4002 0000 - 0x4002 63FF

  • 0x40010000 - 0x40018000

  • 0x40000000 - 0x40008000

    По картинке из refrence manual можете в этом убедиться:

Если адрес операции к примеру, pointer=0x7FFF5000 - это адрес из памяти хостового ПК, его не трогаем идем дальше, если pointer=0x50000324 - значит что-то настраивается в GPIO на STM32L0, берем остальные аргументы и идем выполнять операцию на целевом чипе.

Как происходит выполнение операции на чипе. В репозитории REMCU реализованы два варианта исполнения операций на микроконтроллере. В обоих этих случаях чип подключается через отладчик к хостовому компьютеру. Библиотека REMCU может работать через поднятый GDB или OpenOCD сервер (версии v0.10.0-12 и v0.12.0-1 на других может не работать, так как у OpenOCD мог поменяться API)

Что у GDB, что у OpenOCD есть API для работы с памятью подключенного чипа, к примеру для OpenOCD:

15.4 Memory access commands

mdw, mdh, mdb — Display contents of address addr, as 32-bit words (mdw), 16-bit halfwords (mdh), or 8-bit bytes (mdb).

mww, mwh, mwb — Writes the specified word (32 bits), halfword (16 bits), or byte (8-bit) value, at the specified address addr.

В своем коде в зависимости от выбранного сервера(GDB или OpenOCD) инициализируйте выбранный клиент, одной из ф-ций:

/**
 * @brief remcu_connect2OpenOCD
 * The function is used to create a connection to the OpenOCD server destination
 * @param host - ip address of OpenOCD server "XXX.XXX.XXX.XXX". If the server runs on the host machine, the argument should be "localhost" or "127.0.0.1" value.
 * @param port - port of OpenOCD server. Default is 6666
 * @param timeout_sec - This parameter specifies the length of time, in seconds, to wait for a response when establishing communications. It can not be negative!
 * @return If no error occurs, function returns true
 */
REMCULIB_DLL_API bool remcu_connect2OpenOCD(const char* host, const uint16_t port,
                      const int timeout_sec);

/**
 * @brief remcu_connect2GDB
 * The function is used to create a connection to the GDB server destination
 * @param host - ip address of GDB server "XXX.XXX.XXX.XXX". If the server runs on the host machine, the argument should be "localhost" or "127.0.0.1" value.
 * @param port - port of GDB server. Default of OpenOCD is 3333
 * @param timeout_sec - This parameter specifies the length of time, in seconds, to wait for a response when establishing communications. It can not be negative!
 * @return If no error occurs, function returns true
 */
REMCULIB_DLL_API bool remcu_connect2GDB(const char* host, const uint16_t port,
                       const int timeout_sec);

Можно сделать реализацию и своих клиентов для работы к примеру через сеть или последовательный порт. Просто отнаследовавшись от базового класса ClientBase. Но надо помнить про одну вещь, если поменять тактовую частоту МК или его режим питания, то такой клиент может “неожиданно” отвалиться. В этом плане связь через отладчик будет более надежной и более гибкой, можно совершать любые операции с периферией и тактированием, хотя и тут можно накосячить, но уже сложнее.

После инициализации клиента(GDB или OpenOCD), стоит сбросить микроконтроллер в состояние halt, что бы не выполнялась записанная внутри прошивка, так как она может параллельно проводить свою работу с периферией:

remcu_resetRemoteUnit(__HALT);

Только после этого могут вызываться ф-ции драйверов.

Для удобной сборки отдельной динамической библиотеки(shared, so, dll) в репозитории REMCU предусмотрены сборочные скрипты, которые облегчают компиляцию и ADIN инструментизацию кода SDK микроконтроллера, компилируют в библиотеку весь необходимый ф-цонал для работы с целевым чипом (OpenOCD и GDB client) и обеспечивают кросс-компиляцию для встроенных платформ(на примере RaspberryPI)

Вариант подключения микроконтроллера к
Вариант подключения микроконтроллера к raspberry

Тут было бы уместно привести наглядную демонстрацию, как происходит ADIN инструментизация кода драйверов и последующая сборка в динамическую библиотеку на каком-нибудь примере. Я выбрал относительно простой пример NRF5 SDK от Nordicsemi. Но это практическая часть статьи все равно вышла очень большой, поэтому я ее выделил в отдельную публикацию Готовим драйвера МК для проброса периферии.

Если вам интересно сразу посмотреть на примеры использования, то на хабре была статья Эксперименты с микроконтроллерами в Jupyter Notebook

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


  1. Gryphon88
    25.10.2023 08:56

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


    1. ser-mk Автор
      25.10.2023 08:56

      Если мы про первый случай с PinTool, там МК находится под отладкой

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

      прошивки на нем лучше не иметь? 

      Можно и иметь, через отладчик можно сбросить чип в режим halt и прошивка не будет выполняться


    1. ser-mk Автор
      25.10.2023 08:56

      И что получается по задержке и джиттеру, если, например, ногой дрыгать?

      Все сильно зависит от конфигурации вашего ПК железа, отладчика и софта отладчика

      Максимум что мне пока удалось вытянуть это пару КГц переключения пина

      Обычная настольная система Windows/Linux/MacOs это не реалтаймовые системы, поэтому задержка и джиттер не регламентированы, ну а так стоит готовиться скорее всего к паре миллисекунд


  1. 1tuz
    25.10.2023 08:56
    +1

    Название статьи довольно расплывчатое. Можно подумать, что в статье будет разбор проброса железа в виртуальную машину


    1. ser-mk Автор
      25.10.2023 08:56

      Интересное замечание, о такой интерпретации названия не подумал)
      Надеюсь оригинальное название в статье memfault понятнее звучит


  1. esaulenka
    25.10.2023 08:56

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

    Вот только я одно не понял - у вас там ведь нет прерываний? А идеи, как их поддержать, есть? Иначе вообще непонятно, зачем это всё - любой сколь сложный код 1-в-1 на ПК перенести не получится...


    1. ser-mk Автор
      25.10.2023 08:56

      Прерываний нет, но тем не менее их поддерживать можно. Конечно такой скорости реакции не дождешься, но переносимость обеспечить можно.
      Если переходить на polling(опрос) не хочется, то он все равно выручит с прерываниями. Можно сделать в отдельном потоке примерно такой код:

      while(true){
        if(check(IRQ_flag_UART))
          IRQ_UART_Handler();
        else if(check(IRQ_flag_TIM1))
          IRQ_TIM1_Handler();
        else if(check(IRQ_flag_DMA1))
          IRQ_DMA1_Handler();
        ...
      }

      Вот так банально можно сделать иммитатор. Есть более сложные схемы, в зависимости от микроконтроллера и его системы прерываний.


    1. ser-mk Автор
      25.10.2023 08:56

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

      Да ножку можно и заMock'aть у себя в коде)

      А вот аналоговую часть или что-то по шине гоняется(не быстро) вот тут помогает (мое субъективные мнение)


  1. ptr128
    25.10.2023 08:56
    +1

    По идее, можно в первом случае не проверять каждое обращение к памяти. Достаточно через userfaultfd() ловить page fault. Возможно, иногда ещё потребуется исключить диапазон адресов регистров МК из доступных в user space. Но это тоже решаемо.


    1. ser-mk Автор
      25.10.2023 08:56

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

      К примеру:

      int pin_PC0_value = GPIOC->IDR & 0x1;

      что бы стало в рантайме

      int pin_PC0_value = 0xA5 & 0x1;


      1. ptr128
        25.10.2023 08:56
        +1

        https://docs.kernel.org/admin-guide/mm/userfaultfd.html
        UFFDIO_CONTINUE maps an existing, previously-populated page."

        То есть в обработчике userfaultfd Вы просто подменяете страницу, вызвавшую page fault страницей, которую заранее аллоцировали. И в которую по нужному адресу Вы можете предварительно поместить все, что желаете.


        1. ser-mk Автор
          25.10.2023 08:56
          +1

          Спасибо за документацию. Судя по написанному в режимеUFFDIO_REGISTER_MODE_MINOR можно такое провернуть

          • For UFFDIO_REGISTER_MODE_MINOR faults, there is an existing page (in the page cache). Userspace has the option of modifying the page's contents before resolving the fault. Once the contents are correct (modified or not), userspace asks the kernel to map the page and let the faulting thread continue with UFFDIO_CONTINUE.

          В 2018-ом такой штуки еще не было, исходя из этого поста https://lwn.net/Articles/844443/ это явилось в начале 21-ого


          1. ptr128
            25.10.2023 08:56

            Ну да, user mode KVM пару лет всего. Но почему бы не воспользоваться тем, что из коробки стало предоставлять ядро )


            1. ser-mk Автор
              25.10.2023 08:56
              +1

              Согласен, это хорошее рабочее решение для Linux c поддержкой user mode KVM :)