Хардварный проброс

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

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

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

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

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

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

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

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

#include 

// ==========================================
// 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 вставляет проверку перед каждой операцией по указателю. В этой проверке он вызывает свои ф-ции проверки 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(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 для него интервалы адресов периферии будут:

  • 0×5000 0000 — 0×5000 1FFF

  • 0×4002 0000 — 0×4002 63FF

  • 0×40010000 — 0×40018000

  • 0×40000000 — 0×40008000

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

c7260ec00720ce68d9801d900106902e.png

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

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

b75a78389b1832fbe4b57ed8ef0c915c.png

Что у 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

© Habrahabr.ru