Инструментарий ввода-вывода памяти в C / C ++ для аппаратной эмуляции

Хорошо, немного информации о том, что и почему?

Я хочу скомпилировать и запустить прошивку микроконтроллера (голый металл, без ОС) на настольном Linux. Я не хочу писать интерпретатор байт-кода или двоичный переводчик; Я хочу скомпилировать первоисточник. Запуск FW в качестве стандартного приложения с графическим интерфейсом имеет много преимуществ, таких как быстрые итерации, расширенная отладка, автоматическое тестирование, стресс-тестирование и т. Д. Я делал это раньше с микроконтроллерами AVR для нескольких проектов и обычно предпринимал следующие шаги:

  • предоставить связанные с HW заголовки, которых нет на рабочем столе (в основном определения регистров MMIO -> глобальные переменные)
  • реализовать код эмуляции периферийных устройств (lcd, eeprom)
  • сделать некоторый графический интерфейс, который отражает пользовательский интерфейс исходного устройства (жк, кнопки)
  • склеить все вместе

Первые 3 шага просты (и не очень много кода для AVR), последний хитрый. Некоторые конструкции в FW заканчиваются как бесконечные циклы в настольной версии (например, занятый цикл, ожидающий изменения периферийных регистров, или изменение памяти обработчиками прерываний), другие заканчиваются как неоперация (запись в MMIO, который в реальной системе что-то вызывает) и объединение основного цикла FW с основным циклом библиотеки GUI также требует некоторой креативности. Если FW хорошо наслоен, то низкоуровневый код может быть заменен склеивающими функциями без чрезмерного взлома.

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

Подходя ближе к вопросу …

С точки зрения C / C ++ наиболее важным различием между FW и кодом, работающим в правильной ОС, является MMIO. Доступ MMIO имеет побочные эффекты, различные побочные эффекты для чтения и записи. В настольном приложении эта концепция не существует (если вы не высовываете HW из пространства пользователя). Если было бы возможно определить ловушку, когда ячейка памяти читается или записывается, это позволило бы обеспечить надлежащую эмуляцию периферии, и FW мог бы быть скомпилирован в основном без изменений. Конечно, это не может быть сделано в C ++, вся цель родного языка против этого. Но та же концепция (отслеживание времени доступа к памяти) используется отладчиками памяти с помощью инструментария.

У меня есть несколько идей по реализации, поэтому мой вопрос: насколько вы думаете, насколько они осуществимы, или есть ли другой способ достичь того же результата?

  1. Нет приборов вообще. x86 может сигнализировать, если к ячейке памяти обращаются, и она используется отладчиками для реализации точек наблюдения (прерывание доступа к памяти). В качестве подтверждения концепции я создал эту тестовую программу:

    #include <stdio.h>
    volatile int UDR;
    
    void read()  { printf("UDR read\n"); }
    void write() { printf("UDR write\n"); }
    
    int main()
    {
    UDR=1;
    printf("%i\n", UDR);
    return 0;
    }
    

    UDR — это регистр MMIO, который я хочу отслеживать, и если я запускаю скомпилированную программу под GDB со следующим скриптом:

    watch UDR
    commands
    call write()
    cont
    end
    
    rwatch UDR
    commands
    call read()
    cont
    end
    

    Результат именно то, что я хочу:

    UDR write
    UDR read
    1
    

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

  2. Время выполнения контрольно-измерительных приборов. Если я прав, Valgrind / libvex делает это: читает скомпилированный двоичный файл и вставляет инструментальный код в места доступа к памяти (среди многих других). Я мог бы написать новый инструмент Valgrind, который настроен с адресами и обратными вызовами, как приведенный выше скрипт GDB, и запустить приложение в сеансе Valgrind. Как вы думаете, это возможно? Я нашел некоторую документацию по созданию нового инструмента, но это не похоже на легкую езду.

  3. Время компиляции инструментов. Дезинфицирующие средства памяти и адресов в clang и gcc работают таким образом. Это игра из двух частей, компилятор генерирует инструментированный код, а библиотека дезинфицирующего средства (реализующая фактические проверки) связана с приложением. Моя идея состоит в том, чтобы заменить библиотеку sanitizer собственной реализацией, которая выполняет вышеупомянутые обратные вызовы, без каких-либо модификаций компилятора (что, вероятно, выходит за рамки моих возможностей). К сожалению, я не нашел много документации о том, как инструментальный код и библиотека sanitizer взаимодействуют, я нашел только статьи, описывающие алгоритмы проверки.

Так что это все для моей проблемы, любой комментарий по любой теме приветствуется. 🙂

2

Решение

У меня нет времени, чтобы ответить на ВСЕ вопросы на ваш вопрос, но это, вероятно, будет слишком долго, чтобы быть комментарием …

Что касается «точек наблюдения» в отладчике, они используют регистры отладки, и хотя вы можете написать код для использования этих регистров самостоятельно (для этого есть функции API — для записи в эти регистры вам нужно находиться в режиме ядра), так как Вы заявляете себя, у вас кончатся регистры. Это число НАМНОГО ниже, чем ваши 100. В процессорах x86 есть 4 регистра местоположения отладки, которые охватывают операции чтения и / или записи в ячейку шириной 1-8 байт. Таким образом, это будет работать, если у вас есть всего менее 32 байтов пространства ввода-вывода (которые распределены не более чем на 4 блока по 8 байтов каждый).

В варианте 2 возникает проблема, заключающаяся в том, что вам нужно гарантировать, что регион, используемый вашими регистрами ввода-вывода, не используется для чего-то другого в вашем приложении. Это может быть «легко», если все регистры ввода-вывода находятся, скажем, в первых 64 КБ. В противном случае вы должны попытаться выяснить, является ли это доступом MMIO или обычным доступом. Так же, как написание собственной версии Valgrind — это не то, что вы делаете мгновенно … Даже если вы нанимаете парня, который написал valgrind в первую очередь …

Вариант 3 имеет ту же проблему, что и вариант 2 в отношении сопоставления адресов. Я чувствую, что это вам не очень поможет, и вам лучше подходить к этому по-другому.

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

Или изменив свой код, например, так:

MMIO_WRITE(UDR, 1);

а затем пусть MMIO_WRITE перевести на:

 #if REAL_HW
MMIO_WRITE(x, y)   x = y
#else
MMIO_WRITE(x, y)  do_mmio_write(x, y)
#endif

где do_mmio_write в состоянии понять адреса и что они делают в некотором роде.

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

И да, вам придется переписать часть вашего кода — в идеале ваш код написан так, что у вас есть определенные небольшие участки кода, которые касаются реального оборудования [это, безусловно, хорошая практика, если вы когда-нибудь захотите перейти от одного типа микроконтроллера к другой, так как в таком случае вам бы пришлось гораздо больше переписывать].

Как отмечает Мартин Джеймс, проблема любого такого моделирования заключается в том, что если ваш настоящий симулятор не ОЧЕНЬ хорош, вы сталкиваетесь с «проблемами совместимости» — в частности, такими как аппаратные или программные условия гонки, когда ваше программное обеспечение полностью синхронизировано с моделируемая аппаратная модель, но реальное аппаратное обеспечение будет выполнять действия с программным обеспечением асинхронно, поэтому ваши два чтения двух регистров теперь получат значения, отличные от модели программного обеспечения, потому что в реальном оборудовании произошли некоторые произвольные изменения, которые не были приняты вашей программной моделью во внимание — и теперь у вас есть одна из тех неприятных ошибок, которые встречаются только один раз в голубой луне и только в варианте «не удается отладить», никогда в модели программного обеспечения.

1

Другие решения

Глядя на комментарии MSalters и ответ Матса, я явно усложнил тему. Поскольку у меня есть доступ к исходному тексту, существуют функции уровня языка для упрощения перехвата операций MMIO, чем при использовании инструментария. Я оценил версии, предложенные на примере минималистического последовательного эха:

#include <avr/io.h>

void mainloop(volatile uint8_t* reg) {
while(1) {
loop_until_bit_is_set(UCSRA, RXC);
uint8_t tmp = *reg;
*reg = tmp+1;
loop_until_bit_is_set(UCSRA,  TXC);
}
}

int main(void) {
UCSRB = _BV(RXEN) | _BV(TXEN);  // enable UART rx/tx
UBRRL = 12;                     // 12: 38400 @8Mhz 0.2% error

mainloop(&UDR);
}

Он получает байт на последовательный порт и передает байт, увеличенный на единицу. Он имеет общие случаи использования регистра MMIO, включая передачу указателя регистра в функцию.

Путь С

В этом случае весь доступ к MMIO оборачивается макросами, которые в конечном итоге не используются в производственном коде, но вызывают эмуляцию в хуковских функциях. Регистр — это mmio8_t, который не является целочисленным типом, поэтому, если вы забудете поместить макрос на место, это приведет к ошибке времени компиляции.

#if 0   // this is the Production mode
#include <avr/io.h>

//MMIO macros are no op in production
#define MMIO_READ(mmio_reg) mmio_reg
#define MMIO_WRITE(mmio_reg, data) mmio_reg=data

typedef volatile uint8_t mmio8_t;
#endif

#if 1   // this is the Emulation mode
#include <stdio.h>
#include <stdint.h>
// register bit definitions for UCSRA and UCSRB skipped to shorten code sample

struct st_mmio8 {
const char * name;
// uint8_t value;
// emulation hooks for the register
};
typedef const struct st_mmio8 mmio8_t;

mmio8_t UCSRA = { "UCSRA" };    //these are THE mmio registers
mmio8_t UCSRB = { "UCSRB" };
mmio8_t UBRRL = { "UBRRL" };
mmio8_t UDR = { "UDR" };

// some bit magic taken from <avr/io.h>
#define _BV(bit) (1 << (bit))
#define bit_is_set(sfr, bit) (MMIO_READ(sfr) & _BV(bit))
#define loop_until_bit_is_set(sfr, bit) do { } while (bit_is_clear(sfr, bit))

uint8_t MMIO_READ(mmio8_t addr) {
printf("MMIO_READ id: %s\n", addr.name);
return _BV(RXC) | _BV(TXC);
}

void MMIO_WRITE(mmio8_t addr, uint8_t val) {
printf("MMIO_WRITE id: %s\n", addr.name);
}
#endif

void mainloop(mmio8_t* reg) {
while(1)     {
loop_until_bit_is_set(UCSRA, RXC);
uint8_t tmp = MMIO_READ(*reg);
MMIO_WRITE(*reg, tmp+1);
loop_until_bit_is_set(UCSRA,  TXC);
}
}

int main(void) {
MMIO_WRITE(UCSRB, _BV(RXEN) | _BV(TXEN));  // enable UART rx/tx
MMIO_WRITE(UBRRL, 12);                     // 12: 38400 @8Mhz 0.2% error

mainloop(&UDR);
}

Доступ MMIO корректно подключен, но читаемость кода снижается, особенно если кто-то привык к оригинальному стилю.

Стиль С ++

Эта опция основана на перегрузке оператора C ++. Класс mmio_t определяется с помощью оператора присваивания, преобразующего тип для перехвата записи, и оператора приведения для перехвата чтения:

#if 0   // this is the Production mode
#include <avr/io.h>
using mmio8_t = volatile uint8_t;
#endif

#if 0   // this is the Emulation mode
#include <stdint.h>
#include <iostream>
#include <string>
// register bit definitions for UCSRA and UCSRB skipped to shorten code sample

// some bit magic taken from <avr/io.h>
#define _BV(bit) (1 << (bit))
#define bit_is_set(sfr, bit) (sfr & _BV(bit))
#define loop_until_bit_is_set(sfr, bit) do { } while (bit_is_clear(sfr, bit))

template<typename T>
class mmio_t {
public:
mmio_t(const std::string& regname) : regname(regname) {}

//this is a non-chainable assignment
void operator=(T data) {
std::cout << "mmio_write " << regname << std::endl;
}

operator T() {
std::cout << "mmio_read " << regname << std::endl;
return _BV(TXC) | _BV(RXC);
}
private:
std::string regname;
//T value;
//std::function hooks for emulation code
};
using mmio8_t = mmio_t<uint8_t>;

mmio8_t UCSRA("UCSRA");
mmio8_t UCSRB("UCSRB");
mmio8_t UBRRL("UBRRL");
mmio8_t UDR("UDR");

#endif

void mainloop(mmio8_t* reg) {
while(1) {
loop_until_bit_is_set(UCSRA, RXC);
uint8_t tmp = *reg;
*reg = tmp+2;
loop_until_bit_is_set(UCSRA, TXC);
}
}

int main(void) {
UCSRB = _BV(RXEN) | _BV(TXEN);  // enable UART rx/tx
UBRRL = 12;                     // 12: 38400 @8Mhz 0.2% error

mainloop(&UDR);
}

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

Хотя эти примеры не являются полными или могут быть не на 100% правильными, они показывают основные характеристики каждой версии. Спасибо за все советы и идеи!

0

По вопросам рекламы [email protected]