Однажды меня выдернули из моего уютного безопасного пространства, когда я работал над SDK для видеотранспорта, и мне сказали: «Знаешь что? Теперь вы будете работать над прошивкой FPGA». Будучи приятным парнем, я сказал: «Конечно, а почему бы и нет?» и закатал рукава. Вскоре, однако, стало очевидно, что на пути моей уверенности в себе стояло несколько проблем, не последней из которых было то, что у нас еще не было аппаратного обеспечения. Другая проблема заключалась в том, что я едва знал написание FPGA и понятия не имел, как кто-то разработал прошивку для одного из них.

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

Поскольку ПЛИС очень похожи на специализированное оборудование, для управления ими нужен «обычный» код. Это может быть полноценная операционная система с драйверами или просто среда выполнения, которая воздействует на аппаратные регистры. Это то, что мы бы назвали «прошивкой». Разработчики FPGA — жадные ребята, которые хотят использовать всю схему для себя, но они закатывают глаза, вздыхают и говорят: «Хорошо… у вас может быть небольшой процессор для запуска кода». Они отводят немного места для запуска виртуального (софтового) процессора под прошивку. В случае Altera FPGA это процессор NIOS, который может запускать различные системы, такие как Linux или FreeRTOS.

Теоретически отлично, но у меня до сих пор нет устройства для работы, так как мне вообще начать? Кроме того, эти ребята из FPGA настолько скупы, что едва ли дали нам память или вычислительную мощность. У меня есть эта огромная спецификация SNMP с сотнями настроек, для которой нужна серверная часть на C++, которая может хранить настройки и управлять оборудованием. Кроме того, ему нужны удобочитаемые файлы конфигурации, которые можно установить на устройства после их сборки. Там не так много места, и все запекается в один образ, поэтому мы также не можем просто добавлять библиотеки волей-неволей. Что делать?

В разговоре с некоторыми другими членами команды кажется, что на устройстве есть библиотека, которая может читать и записывать YAML, довольно простой формат файла конфигурации. Эту библиотеку также можно использовать в Windows, так что теперь у нас есть точка входа. Мы можем начать писать универсальный C++ для Windows, управляемый модульными тестами, и просто создать хороший уровень аппаратной абстракции для чтения/записи на аппаратное обеспечение, когда оно работает на FPGA.

Наконец мы подошли к сути статьи. Как мне сериализовать эти настройки в эти огромные классы C++ и из них? Мой извращенный мозг придумал ответ: x-macros. Это умный способ использовать препроцессор для написания за вас целой кучи повторяющегося кода. Например:

// Define a single macro with some colors.
#define COLORS \
    X(RED)     \
    X(BLACK)   \
    X(WHITE)   \
    X(BLUE)
  
// Expand the colors as an enum
enum colors {
    #define X(value) value,
        COLORS
    #undef X
};
  
// Expand the colors into a switch statement
char* toString(enum colors value)
{
    switch (value) {
        #define X(color) \
            case color:  \
                return #color;
                COLORS
        #undef X
    }
}

Сначала вы создаете глобальный список, единую точку истины для набора вещей, с которыми вы хотите работать, в данном случае для некоторых цветов. Затем вы определяете макрос X как произвольный фрагмент кода, включаете глобальный список, и препроцессор расширяет его в код. Для перечисления это расширение — просто значение с запятой после него. Для функции toString это оператор switch, возвращающий строковое значение (#color). У препроцессора есть несколько трюков, таких как «#», чтобы сделать что-то строкой, или ##, чтобы объединить вещи вместе. Как только эта предварительная обработка происходит, приведенный выше код расширяется следующим образом:

enum colors {
    RED,
    BLACK,
    WHITE,
    BLUE,
};
  
char* toString(enum colors value)
{
    switch (value) {
    case RED:
        return "RED";
    case BLACK:
        return "BLACK";
    case WHITE:
        return "WHITE";
    case BLUE:
        return "BLUE";
    }
}

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

#define COLORS(X) \
    X(RED)     \
    X(BLACK)   \
    X(WHITE)   \
    X(BLUE)

#define EXPAND_ENUM(value) value,
#define EXPAND_STRING(color) \
  case color:  \
    return #color;

enum colors {
  COLORS(EXPAND_ENUM)
};
  
// Expand the colors into a switch statement
char* toString(enum colors value)
{
    switch (value) {
      COLORS(EXPAND_STRING)
    }
}

Вы, вероятно, спрашиваете себя, как можно использовать это для сериализации/десериализации класса C++ в YAML. Ответ на это, мой друг, заключается в извращенном спуске к одному из самых вопиющих злоупотреблений препроцессором, которые я когда-либо совершал. Я никоим образом не могу рекомендовать этот подход как нечто иное, как упражнение в мастурбационной умственной гимнастике. В любом случае, давайте погрузимся прямо сейчас, не так ли?

Пока я изо всех сил пытаюсь представить, как это можно объяснить, мне приходит в голову начать с конца и вернуться к началу. Таким образом, вы можете увидеть результат, а затем сами решить, стоило ли оно того в конце концов. Итак, давайте начнем с нашего класса C++, описывающего устройство. У него есть куча методов доступа, которые могут что-то делать с базовым устройством или просто хранить информацию. Итак, у вас есть куча методов GetThis(), GetThat(), SetThis(это), SetThat(то). Вы хотите, чтобы эти вещи хранились в файле YAML и загружались/сохранялись по запросу. Итак, в самом низу вашего класса DeviceInfo вы добавляете этот код:

#define PRESET_MEMBERS(_) \
 _(Desc, "Description", string) \
 _(Manufacturer, "Manufacturer", string) \
 _(PartNumber, "Part Number", string) \
 _(SerialNumber, "Serial Number", string) \
 ...
#define PERSIST_MEMBERS(_) \
 _(UserDesc, "User Description", string) \
 _(SAPAddr, "SAP Broadcast IP", uint32) \
 _(SAPPort, "SAP Broadcast Port", uint) \
 _(SAPIPv6Addr, "SAP Broadcast IPv6", string) \
 ...

#define ISETTINGS_CLASS_OVERRIDE DeviceInfo
#define ISETTINGS_CHILD_TABLE mEthernetEntryVector
#define ISETTINGS_ADD_ENTRY AddEthernetIfEntry
#define ISETTINGS_INDEX_OFFSET 1 // Ethernet entries are indexed from 1
#define ISETTINGS_NESTED

#include "ISettingsPersist.def"

Поскольку я придирчив, я использую «_» вместо «X» для своих макросов, но по сути это два списка всех элементов, которые вы хотите записать в YAML. Предустановленные и постоянные версии предназначены для предустановок только для чтения, которые являются жестко запрограммированными и изменяемыми пользователем настройками соответственно. Любой член, у которого есть метод Get и Set, можно использовать для сохранения и восстановления, просто добавив строку в эти определения. Итак, у нас есть GetDesc/SetDesc, которые получают и устанавливают строку, и GetSAPAddr/SetSAPAddr, которые получают и устанавливают uint32.

Вот и все. Если вам нужен новый член класса, создайте методы get/set и добавьте строку в один из этих списков. С точки зрения простоты будущих модификаций, это не намного лучше. Определения под этими списками — это то, где начинается кроличья нора.

Первая запись ISETTINGS_CLASS_OVERRIDE — это просто имя этого класса, DeviceInfo. Следующие несколько определений я собираюсь пропустить, но, по сути, вы можете иметь вложенные таблицы в спецификации SNMP («MIB»), что означает, что DeviceInfo может иметь одну или несколько записей Ethernet. Кроме того, у MIB есть симпатичные небольшие «причуды», например, то, что некоторые таблицы имеют нулевой индекс, а другие — один. Ничего из этого, потому что это уже достаточно сложно. Мы собираемся погрузиться в ISettingsPersist.def через минуту, но сначала давайте рассмотрим расширения макросов, которые мы собираемся использовать для каждого члена наших списков выше.

Если вы помните, когда мы хотели что-то расширить, нам нужен макрос расширения. В нашем случае нам нужен один для загрузки из YAML и один для сохранения в YAML. Эти макросы выглядят так:

// ISettingsPersist.h (persistence interface class)
// These macros get expanded for each of the member variables
// we want to save or restore.
#define ISETTINGS_EMIT_MEMBERS(member, name, type) \
  yaml_scalar_event_initialize(&lEvent, NULL, NULL, (yaml_char_t *)(name), strlen((name)), 1, 1, YAML_ANY_SCALAR_STYLE); \
  if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error; \
  ISettingsPersist::init_##type(&lEvent, Get##member()); \
  if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error;

#define ISETTINGS_STORE_MEMBERS(member, name, type) \
  if (lKey == (name)) { \
    Set##member(ISettingsPersist::parse_##type(lValue)); \
  } else

Итак, теперь представьте, что эти строки расширены одной из строк-членов сверху, например, _(Desc, «Description», string).

// Emit (writing to file)
yaml_scalar_event_initialize(&lEvent, NULL, NULL,
  (yaml_char_t *)"Description", strlen("Description"), 1, 1, YAML_ANY_SCALAR_STYLE); \
if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error; \
ISettingsPersist::init_string(&lEvent, GetDesc()); \
if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error;

// Store (loading from file)
if (lKey == "Description") { \
  SetDesc(ISettingsPersist::parse_string(lValue)); \
} else

Вы можете увидеть текстовое имя свойства из файла YAML, а также вызовы Get/SetDesc и init_string/parse_string, созданные макросом. Поучительно взглянуть на некоторые из этих вспомогательных функций, некоторые из которых почти ничего не делают, но существуют ради согласованности.

static bool parse_bool(std::string & aValue) {
  static const char *lTruthValues[] = {
    "true",
    "y",
    "yes",
    "on",
    NULL
  };
  int i = 0;
  const char *lValue = aValue.c_str();

  while (NULL != lTruthValues[i]) {
    if (strcasecmp(lValue, lTruthValues[i++]) == 0) {
      return true;
    }
  }

  return false;
}
static std::string parse_string(std::string &aValue) { return aValue; }
static int parse_int(std::string &aValue) { return strtol(aValue.c_str(), NULL, 0); }

static void init_bool(yaml_event_t *aEvent, bool aBool) {
  char const *lBuf = aBool ? "true" : "false";
  yaml_scalar_event_initialize(aEvent, NULL, NULL, (yaml_char_t *)lBuf, strlen(lBuf), 1, 1, YAML_ANY_SCALAR_STYLE);
}

static void init_int(yaml_event_t *aEvent, int aInt) {
  char lBuf[BUF_SIZE];
  sprintf_s(lBuf, "%d", aInt);
  yaml_scalar_event_initialize(aEvent, NULL, NULL, (yaml_char_t *)lBuf, strlen(lBuf), 1, 1, YAML_ANY_SCALAR_STYLE);
}

Прежде чем перейти к ISettingsPersist.def, который мы включаем после определения всех наших членов, мы должны взглянуть на интерфейс ISettingsPersist. Он реализует множество общих функций, таких как флаг isDirty, чтобы знать, когда сохранять, но его основные функции — это интерфейсы, для переопределения которых ему нужны дочерние классы. Глядя на определение класса DeviceInfo, мы видим это:

// Persistence
bool SaveTo(yaml_emitter_t *aEmitterPtr, SettingsType_t aType);

bool LoadFrom(yaml_parser_t *aParserPtr, SettingsType_t aType);
 
char const *GetStartTag() { return "Device Information"; }

char const *GetPresetCfgFileName() { return "DeviceInfo.preset"; }
char const *GetPersistCfgFileName() { return "DeviceInfo.persist"; }

void SetBulkMode(bool aMode);
bool IsDirty(void) const;

Итак, мы видим, что DeviceInfo может устанавливать начальный тег для своих разделов файла YAML, а также их имена файлов. «Массовый режим» используется для временного отключения всех аппаратных побочных эффектов при загрузке файла. В SaveTo и LoadFrom мы, наконец, возвращаемся к ISettingsPersist.def. Он включается после того, как мы настроили список участников и различные макросы ISETTINGS_ выше. Таким образом, для каждого класса C++, который мы планируем сериализовать, этот код включается (показана только более простая невложенная версия). Следите за PRESET_MEMBERS() и PERSIST_MEMBERS(), где списки участников расширяются. Для LoadFrom() это серия операторов if/else, так что вы можете видеть, что если он проваливается, то самым последним случаем является обработчик ошибок. Нефатальный, поэтому дополнительные настройки будут игнорироваться для прямой совместимости.

// Definition file for generic SaveTo() and LoadFrom() methods that are
// suitable for classes needing no fancy handling. ie. Nothing but member
// variables

#define _xstr_(x) _str_(x)
#define _str_(x) #x

#ifdef ISETTINGS_SIMPLE

// A 'simple' settings file that only has member variable to persist.
// Just define PRESET_MEMBERS, PERSIST_MEMBERS and ISETTINGS_CLASS_OVERRIDE

bool ISETTINGS_CLASS_OVERRIDE::SaveTo(yaml_emitter_t * aEmitterPtr, SettingsType_t aType)
{
  yaml_event_t lEvent;

  ISettingsPersist::init_int(&lEvent, (int)mMyIx);
  if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error;

  yaml_mapping_start_event_initialize(&lEvent, NULL, NULL, 1, YAML_ANY_MAPPING_STYLE);
  if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error;

  if (aType == PRESET) {
    PRESET_MEMBERS(ISETTINGS_EMIT_MEMBERS)
  }
  else {
    PERSIST_MEMBERS(ISETTINGS_EMIT_MEMBERS)
  }

  yaml_mapping_end_event_initialize(&lEvent);
  if (!yaml_emitter_emit(aEmitterPtr, &lEvent)) goto error;
  return true;
error:
  ISettingsPersist::ProcessSyntaxError("Unable to emit YAML event\n", NULL);
  return false;
}

bool ISETTINGS_CLASS_OVERRIDE::LoadFrom(yaml_parser_t * aParserPtr, SettingsType_t aType)
{
  yaml_event_t lEvent;
  bool lDone = false;
  std::string lKey("");
  std::stringstream lError;

  SetBulkMode(true);
  while (!lDone) {
    if (!yaml_parser_parse(aParserPtr, &lEvent)) {
      lError << "Parse error";
      goto error;
    }
    switch (lEvent.type) {
    case YAML_NO_EVENT:
    case YAML_ALIAS_EVENT:
      break;
    case YAML_MAPPING_START_EVENT:
      break;
    case YAML_MAPPING_END_EVENT:
      lDone = true;
      break;
    case YAML_SCALAR_EVENT:
      if (lKey.length() == 0) {
        lKey = (char *)lEvent.data.scalar.value;
      }
      else {
        std::string lValue((char *)lEvent.data.scalar.value);
        if (aType == PERSIST) {
          PERSIST_MEMBERS(ISETTINGS_STORE_MEMBERS) {
            lError << "Unknown setting \"" << lKey << " = " << lValue << "\"";
            ISettingsPersist::ProcessSyntaxError(lError.str().c_str(), aParserPtr);
            lError.str("");
          }
        }
        else {
          PRESET_MEMBERS(ISETTINGS_STORE_MEMBERS) {
            lError << "Unknown setting \"" << lKey << " = " << lValue << "\"";
            ISettingsPersist::ProcessSyntaxError(lError.str().c_str(), aParserPtr);
            lError.str("");
          }
        }
        lKey.clear();
      }
      break;
    default:
      lError << "Unexpected YAML " << ISettingsPersist::DecodeYamlEvent(lEvent.type) << "event";
      goto error;
      break;
    }
  }

  SetBulkMode(false);
  return true;
error:
  SetBulkMode(false);
  // decode yaml error with lError
  ISettingsPersist::ProcessSyntaxError(lError.str().c_str(), aParserPtr);
  return false;
}

Последняя часть интерфейса находится в классе ISettingsPersist:

static bool RunEmitter(ISettingsPersist *aPersistTbl[], SettingsType_t aType, char *aBufPtr, int aLen, size_t &aBytesWrittenRef);
static bool RunEmitter(ISettingsPersist *aPersistTbl[], SettingsType_t aType, char const *aPath);

static bool RunParser(ISettingsPersist *aPersistTbl[], SettingsType_t aType, char const *aBufPtr, size_t aLen);
static bool RunParser(ISettingsPersist *aPersistTbl[], SettingsType_t aType, char const *aPath);

Это основные функции «драйвера», которые запускают анализатор или эмиттер для массива классов, реализующих интерфейс ISettingsPersist. Они работают как с файлами, так и со строками, что означает, что для модульных тестов или прошивки очень просто создать любой из этих объектов и загрузить/сохранить их конфигурации. Например, модульный тест может выглядеть так:

TEST_F(DeviceInfoTest, SavePresetConfig)
{
  char lBuf[0x10000];
  size_t lBytesWritten;
  // Write the config to a string
  bool lRet = ISettingsPersist::RunEmitter(mSettings, ISettingsPersist::PRESET, lBuf, 0x10000, lBytesWritten);
  ASSERT_TRUE(lRet);

  std::cout << "Preset Config:\n";
  std::cout << lBuf << std::endl;

  // Change the device info (mDeviceInfo is in mSettings above)
  mDeviceInfo->SetDesc("Some random description");

  // Run the parser on the string and observe that the description
  // has been reverted to the original DEVICE_DESC default
  lRet = ISettingsPersist::RunParser(mSettings, ISettingsPersist::PRESET, lBuf, lBytesWritten);
  ASSERT_TRUE(lRet);
  EXPECT_EQ(mDeviceInfo->GetDesc(), DEVICE_DESC);
}

Есть несколько упущенных деталей, таких как вложенные таблицы и детали некоторых вспомогательных функций yaml, но это почти все. Это сложно, но с точки зрения будущего пользователя очень просто. Чтобы добавить новый элемент, реализуйте Get/Set и добавьте одну строку. Для чтения/записи конфигов просто вызовите RunParser/RunEmitter.

Надеюсь, вам было так же интересно читать об этом, как мне писать.