Использование coroutines из С++20 в связке с NRF52832 и GTest
Появилась идея в домашнем проекте попробовать использовать сопрограммы из С++20 на маленькой железке. В качестве модуля для экспериментов был выбран E73 NRF52832. Из инструментария, который использовался в процессе разработки- arm-gcc-gnu-none-eabi 10.2, MSVC для проверки идей и прогона тестов на Windows-платформе. Изначально было в планах продемонстрировать на чем-то концепцию и как именно их можно было применять. Была идея адаптирования примера в виде мигания светодиодиком. Но он был слишком простой. Необходимо было придумать что-то более сложное и более полезное, что ли. Таким образом и появилась идея переписать драйвер дисплея и пары фрагментов SPI-FLASH в проекте-долгострое.
Небольшой план, что будет в заметке:
Предыстория с транзакциями
Как это работает в общих чертах
Работаем с SPI интерфейсом
Массив команд инициализации
Идеи с std: tuple транзакций
Реализуем when_all_sequence
Как работает Task из cppcoro
Как это тестировать?
Добавляем параметрические тесты в GTest
Добавляем черновой вариант драйвера для SPI-FLASH
Добавляем чтение JEDEC ID
Добавляем чтение device id
Ожидание окончания выполнения сопрограммы
0. Перед прочтением
Заметка предполагает базовое знакомство читателя с сопрогрмаммами / их синтаксисом. В силу наличия более подробных статей на тему деталей реализации сопрограмм и их устройства рекомендуются к прочтению следующие материалы:
Серия статей от автора библиотеки cppcoro
В заметке изложение материала может показаться непоследовательным. Предложения буду рад увидеть в виде issues или же в комментариях/сообщениях в Telegram.
Что будет рассмотрено:
драйвер дисплея с использованием сопрограмм, с использованием таблицы инициализации на базе std: tuple и классического варианта на базе массива команд.
фрагмент драйвера для SPI-FLASH памяти
написание тестов на драйвер SPI с использованием параметрических тестов в GTest
Рассмотрение работы и реализация нескольких примитивов из cppcoro библиотеки.
1. Предыстория с транзакциями
Изначально, идея переписывания с подхода в виде транзакций появилась как только возникла необходимость расширения транзакционности read-транзакциями. Для формирования очереди отправки этот подход был достаточно удобным, но для очереди приема получалось что-то вроде callback-hell подхода.
В общих чертах, транзакционный подход представлял собой следующую идею:
template void sendCommand(std::uint8_t command, Args... commandArgs) noexcept
{
sendCommand(command);
sendChunk(static_cast(commandArgs)...);
}
template void sendChunk(Args... _chunkArgs) noexcept
{
std::array chunk = {static_cast(_chunkArgs)...};
Interface::Spi::Transaction chunkTransaction{};
chunkTransaction.beforeTransaction = [this] { setDcPin(); };
chunkTransaction.transactionAction = [this, chunkToSend = std::move(chunk)] {
m_pBusPtr->sendChunk(
reinterpret_cast(chunkToSend.data()), chunkToSend.size());
};
chunkTransaction.afterTransaction = [this] { resetDcPin(); };
m_pBusPtr->addTransaction(std::move(chunkTransaction));
}
Т.е. у нас есть некоторое действие до транзакции, сама транзакция и действие, которое будет выполнено после нее. Использование так-же было достаточно удобным:
sendCommand(DisplayReg::SLPOUT);
sendCommand(DisplayReg::COLMOD, 0x55);
sendCommand(DisplayReg::MADCTL, 0x08);
sendCommand(DisplayReg::CASET, 0x00, 0, 0, 240);
Этот подход достаточно хорошо себя показал в работе. Типовая задача для применения имела следующий вид:
Выставить состояние порта D/C для дисплея для выбора типа принимаемых данных (команда или данные)
Сформировать DMA- транзакцию
По ее окончанию выполнить следующую отправку, если блок данных был не полностью отправлен
Или вернуть состояние порта D/C в исходное состояние и перейти к обработке следующей транзакции
Недостатки стали проявляться при попытке реализации драйвера для SPI-FLASH которая должна была быть использована в связке с Little FS. Где-то в тот-же период времени было найдено предложение в стандарт С++20 по сопрограммам, просмотрена серия мотивирующих статей:
И было принято решение о переписывании на сопрограммы части существующих драйверов.
2. Как это работает в общих чертах
Для разработчиков, которые имели дело с C#/Python/JS не являются чем-то новым ключевые слова yeild
, await
, async
.(Да, они тут приведены в общем варианте, без принадлежности к конкретному языку). В кратце, идея сопрограмм состоит в возможности приостановки функции в любом месте исполнения с последствующим восстановлением ее работы из прерванной точки. Фактически, мы получаем легковесные потоки уровня пользователя, т.е. для переключения сопрограмм нам нет необходимости обращаться к ядру ОС/etc.
В С++20 минимальный пример сопрограммы может быть представлен в виде следущей конструкции:
#include
#include
struct Awaitable
{
struct Promise;
using promise_type = Promise;
Awaitable(std::coroutine_handle coroHandle) : thisCoroutine{coroHandle}
{
std::cout << "Awaitable created\n";
}
~Awaitable()
{
std::cout << "Awaitable destroyed\n";
if (thisCoroutine)
thisCoroutine.destroy();
}
struct Promise
{
auto initial_suspend() noexcept
{
return std::suspend_always();
}
auto final_suspend() noexcept
{
return std::suspend_always();
}
void unhandled_exception()
{
std::terminate();
}
Awaitable get_return_object()
{
return Awaitable(std::coroutine_handle::from_promise(*this));
}
void return_void() noexcept
{
}
};
bool isFinished()
{
if (thisCoroutine)
return thisCoroutine.done();
return false;
}
void restore()
{
if (thisCoroutine && !thisCoroutine.done())
thisCoroutine.resume();
}
std::coroutine_handle thisCoroutine;
};
Awaitable callMe()
{
std::cout << "Hello ";
co_await std::suspend_always{};
std::cout << "World\n";
}
int main()
{
auto lazyHello = callMe();
while (!lazyHello.isFinished())
lazyHello.restore();
return 0;
}
Где полезная нагрузка- ленивый вывод фразы «Hello world». Попробуем реализовать идею использования, только на примере чего-то реальнее. Например, инициализации и работы с дисплеем. Для интереса, весь обмен должен происходить с использованием DMA микроконтроллера.
3. Работа с SPI интерфейсом
Для работы с SPI интерфейсом необходимо реализовать небольшой набор Awaitable-типов, на которых может быть вызван оператор co_await
. Самый простой вариант имеет следующий вид:
struct Awaiter
{
bool resetDcPin = false;
bool restoreInSpiCtx = false;
const std::uint8_t* pTransmitBuffer;
This_t* pBaseDisplay;
std::uint16_t bufferSize;
bool await_ready() const noexcept
{
// проверяем, есть ли необходимость выполнять приостановку сопрограммы
const bool isAwaitReady = pTransmitBuffer == nullptr || bufferSize == 0;
return isAwaitReady;
}
void await_resume() const noexcept
{
//вызов, который будет совершен при выполнении .resume() у сопрограммы
if (resetDcPin)
pBaseDisplay->setDcPin();
}
void await_suspend(std::coroutine_handle<> thisCoroutine) const
{
//действие на время приостановки сопрограммы. В SPI драйвер передается handle сопрограммы,
// после чего она приостановлена.
if (resetDcPin)
pBaseDisplay->resetDcPin();
pBaseDisplay->getSpiBus()->transmitBuffer(
pTransmitBuffer, bufferSize, thisCoroutine.address(), restoreInSpiCtx);
}
};
В свою очередь, на стороне SPI-драйвера по окончании передачи данных выполняется следующий код:
void transmitCompleted() noexcept
{
const bool isAllDmaTransactionsProceeded = m_transmitContext.fullDmaTransactionsCount == 0;
const bool isAllChunckedTransactionsCompleted =
m_transmitContext.chunkedTransactionBufSize == 0;
if (!isAllDmaTransactionsProceeded)
{
//Если еще можем передавать транзакции размером с DMA буфер
}
else if (!isAllChunckedTransactionsCompleted)
{
// Если транзакции меньше размера DMA буфера не переданы
}
else
{
// В случае, если все DMA транзакции выполнены- проверяем, в каком контексте выполнения
// должна быть восстановлена сопрограмма.Не забываем, что этот метод вызывается из контекта
// прерывания и восстановление тяжелой сопрограммы может привести к трудноулавливааемым
// ошибкам. Тут есть два варианта - либо восстанавливаемся в контекте прерывания, либо
// передаем обработчик сопрограммы в очередь,которая обрабатывается в главном потоке. Таким
// образом, сопрограмма будет восстановлена из main-потока.
if (m_transmitContext.restoreInSpiCtx)
{
m_coroHandle.resume();
}
else
{
CoroUtils::CoroQueueMainLoop::GetInstance().pushToLater(m_coroHandle);
}
}
}
В целях уменьшения нагрузки на чтение кода- при выполнении transmitBuffer
выполняется формирование контекста передачи данных в драйвере, после чего выполняется рассчет целого количества DMA транзакций + размер оставшегося буфера, который так-же будет передан. Далее, по окончанию передачи DMA транзакции выполлняется вызов transmitCompleted
где выполняется формирование новой посылки или же выполняется восстановление приостановленной сопрограммы в заданном контексте выполнения (в контексте прерывания или же в главном потоке программы).
4. Итерация на массиве команд инициализации
Нам будут необходимы несколько всопомогательных функций. Их больше, чем две., но в общем случае представим их так:
auto sendCommand(const std::uint8_t* pBuffer, std::size_t bufferSize) noexcept
{
const std::uint8_t* commandBuf = pBuffer;
const std::uint8_t* ArgBuf = pBuffer + 1;
const std::uint16_t ArgsBufferSize = static_cast(bufferSize - 1);
return CoroUtils::when_all_sequence(
sendCommandImpl(commandBuf),
ArgsBufferSize > 0 ? sendChunk(ArgBuf, ArgsBufferSize) : sendChunk(nullptr, 0));
}
auto sendChunk(const std::uint8_t* pBuffer, std::size_t bufferSize) noexcept
{
return Awaiter{.pTransmitBuffer = pBuffer,
.pBaseDisplay = this,
.bufferSize = static_cast(bufferSize)};
}
Массив для инициализации дисплея представлен следующим образом:
namespace DisplayDriver::InitializationCommands
{
// clang-format off
constexpr std::size_t CommandsSize = 328;
constexpr std::size_t CommandsTransactionsCount = 59;
static auto Commands = std::array
{
/***Cmd****Argc****delay****argv*****************************/
0xFE, 0, 0
, 0xEF, 0, 0
, 0xEB, 1, 0, 0x14
, 0xFE, 0, 0
, 0xEF, 0, 0
, 0xEB, 1, 0, 0x14
, 0xFE, 0, 0
, 0xEF, 0, 0
, 0xEB, 1, 0, 0x14
.....
}
// clang-format on
} // namespace DisplayDriver::InitializationCommands
Собираем в полноценную картину функцию инициализации дисплея. Для каждой команды необходимо выполнить передачу команды + передачу ее аргументов, если они есть + выполнить ожидание N миллисекунд (после команды DISP_ON, например). Т.к. отправлять мы будем с использованием ранее реализованных функций — получаем следующий вид тела функции:
void initDisplay() noexcept
{
TBaseSpiDisplay::resetResetPin();
Delay::waitFor(100);
TBaseSpiDisplay::setResetPin();
size_t CommandCount = InitializationCommands::CommandsTransactionsCount;
const std::uint8_t* pBuffer = InitializationCommands::Commands.data();
while (CommandCount--)
{
const std::uint8_t* Command = pBuffer++;
const std::uint8_t NumArgs = *pBuffer++;
const std::uint8_t Delay = *pBuffer++;
co_await TBaseSpiDisplay::sendCommandImpl(Command);
if (NumArgs)
{
co_await TBaseSpiDisplay::sendChunk(pBuffer++, NumArgs);
pBuffer += (NumArgs - 1);
}
if (Delay)
Delay::waitFor(Delay);
}
TBaseSpiDisplay::m_displayInitialized.set();
}
Что мы получили? Каждый вызов sendCommandImpl
— ассинхронный «под капотом». При выполнении co_await TBaseSpiDisplay::sendCommandImpl
или co_await TBaseSpiDisplay::sendChunk
— выполняется приостановка функции initDisplay, при этом управление возвращается вызывающей стороне. По окончании процедуры инициализации выполняется установка флага окончания инициализации дисплея.
Как может выглядеть процедура отправки дисплейного буфера? Т.к. в проекте используется библиотека LVGL — фреймбуфер может быть non-screen-sized, т.е. не полностью соответствовать размеру буфера экрана, что позволяет выполнять отрисовку фрагментами.(Иногда может не быть возможности поместить полностью в память буфер дисплея)
Для реализации функции отправки нам необходимо выполнить отправку команд на установку области экрана, в которую будет осуществлена отрисовка, после чего отправить команду на запись в память дисплея и выполнить отправку дисплейного буфера. Что-же. В синхронном варианте, данный код мог бы иметь вид:
void fillRectangle(
std::uint16_t x,
std::uint16_t y,
std::uint16_t width,
std::uint16_t height,
TBaseSpiDisplay::TColor* colorToFill) noexcept
{
const std::uint16_t DisplayHeight = TBaseSpiDisplay::getHeight();
const std::uint16_t DisplayWidth = TBaseSpiDisplay::getWidth();
const bool isCoordsValid{!((x >= DisplayWidth) || (y >= DisplayHeight))};
if (isCoordsValid)
{
//Определяем размеры буфера для отправки
const size_t BytesSizeX = (width - x + 1);
const size_t BytesSizeY = (height - y + 1);
const size_t BytesSquare = BytesSizeX * BytesSizeY;
const size_t TransferBufferSize = (BytesSquare * sizeof(typename TBaseSpiDisplay::TColor));
//Формируем область экрана для обновления
setAddrWindow(x, y, width, height);
static std::uint8_t RamWriteCmd{0x2C};
// Команда на запись в экранный буфер
TBaseSpiDisplay::sendCommandImplFast(&RamWriteCmd);
//Устанавликаем порт Data/Command в режим работы с данными(все что передано в дисплей
//воспринимается, как данные, а не как команды)
TBaseSpiDisplay::setDcPin();
// Отправляем дисплейный буфер
TBaseSpiDisplay::sendChunk(
reinterpret_cast(colorToFill), TransferBufferSize);
// Восстанавливаем состояние порта Data/Command
TBaseSpiDisplay::resetDcPin();
// Сигнализируем об окончании заполнении области дисплея
TBaseSpiDisplay::onRectArreaFilled.emitLater();
}
}
А что необходимо изменить, чтобы код стал выполняться ассинхронно? Т.е. выполнили транзакцию по DMA- вернулись в точку приостановки, отправили следующую команду и/или дисплейный буфер и продолжили выполнение? На фрагменте ниже приведены все изменения, которые необходимо выполнить (пратически все).
//Формируем область экрана для обновления
co_await setAddrWindow(x, y, width, height); // <=============== co_await here
static std::uint8_t RamWriteCmd{0x2C};
// Команда на запись в экранный буфер
co_await TBaseSpiDisplay::sendCommandImplFast(&RamWriteCmd); // <=============== co_await here
TBaseSpiDisplay::setDcPin();
// Отправляем дисплейный буфер
co_await TBaseSpiDisplay::sendChunk(
reinterpret_cast(colorToFill),
TransferBufferSize); // <=============== co_await here
TBaseSpiDisplay::resetDcPin();
TBaseSpiDisplay::onRectArreaFilled.emitLater();
Добавлением в трех строках co_await
мы получили ассинххронный код. Остается только дописать немного примитивов, которые будут нам необходимы, чтобы это все заработало. А именно, реализовать притивы when_all_sequence
для запуска последовательности и Task
для вызова co_await
на возвращаемом результате функции setAddrWindow
.
5. Для любителей посложнее — итерация на std: tuple
Данная идея была первой. Представляем таблицу команд инициализации диспллея в виде std: tuple который в свою очередь состоял бы из Дескрипторов команд. Преимущество- нет необходимости указывать количество аргументов у команды. Все, что задано после DefaultDelay
— будет считаться аргументом команды.
Дескриптор команды был реализован как:
template struct CommandDescriptor
{
};
template
struct CommandDescriptor
{
std::uint8_t commandDelay = CommandDelay;
std::array command{Command, CommandArgs...};
};
template
struct CommandDescriptor
{
std::uint8_t commandDelay = CommandDelay;
std::array command{Command};
};
template struct CommandDescriptor
{
std::uint8_t commandDelay = 0;
std::array command{Command};
};
В свою очередь, для создания таблицы команд для инициализации было необходимо их сохранить в tuple. В первой итерации, это выглядело вот так:
static std::tuple CommandsArray = {
CommandDescriptor<0xFE>{},
CommandDescriptor<0xEF>{},
CommandDescriptor<0xEB, DefaultDelay, 0x14>{},
CommandDescriptor<0xFF, DefaultDelay, 0x60, 0x01, 0x04>{},
CommandDescriptor<0x74, DefaultDelay, 0x10, 0x75, 0x80, 0x00, 0x00, 0x4E, 0x00>{},
CommandDescriptor<0xC3, DefaultDelay, 0x14, 0xC4, 0x14, 0xC9, 0x25>{},
CommandDescriptor<0x74, DefaultDelay, 0x10, 0x85, 0x80, 0x00, 0x00, 0x4e, 0x00>{},
CommandDescriptor<0x29>{}};
Как распаковать подобный std::tuple
дескрипторов? Можно применить std::apply
и в нем уже заниматься распаковкой команд для их отправки. В таком случае, произойдет небольшое видоизменение функции для инициализации дисплея.
template
CoroUtils::VoidTask launchInit(
Tuple&& commandSequence,
std::integer_sequence) noexcept
{
(co_await CoroUtils::when_all_sequence(sendCommand(
std::get(commandSequence).command.data(),
std::get(commandSequence).command.size())),
...);
}
Перейдем к рассмотрению используемых примитивов, а именно CoroUtils::when_all_sequence
и CoroUtils::VoidTask
.
6. Реализуем when_all_sequence
Прежде всего- зачем он необходим? В общем случае- когда мы хотим вызвать co_await
для всех переданных аргументов.
Как было показано в примере выше, у нас есть необходимость распаковать std::tuple
команд. Или же, мы не хотим писать несколько раз co_await
друг-за другом. Например, в случае отправки команды установки экранной области, нам необходимо передать координаты по X/Y.Фрагмент отправки имеет вид:
static std::array xAxisCommand = std::array{static_cast(0x2a),
static_cast(xa >> 24),
static_cast(xa >> 16),
static_cast(xa >> 8),
static_cast(xa)};
static std::array yAxisCommand = std::array{static_cast(0x2b),
static_cast(ya >> 24),
static_cast(ya >> 16),
static_cast(ya >> 8),
static_cast(ya)};
co_await sendCommand(xAxisCommand.data(), xAxisCommand.size());
co_await sendCommand(yAxisCommand.data(), yAxisCommand.size());
Предположим, что два подряд co_await
это не проблема. Но что делать, если их станет больше? Например, в случае распаковки std::tuple
с дескрипторами команд нам необходимо вызвать co_await для каждого дескриптора. Получается, для каждого элемента прийдется писать co_await std::get
? Не порядок. Для данного варианта можно применить fold-expression из С++17, с помощью которых вызвать co_await
для каждого переданного аргумента. При этом, все переданные задачи мы можем сохранить в std::tuple
. Приведем реализацию WhenAllSequence
с комментариями:
template struct WhenAllSequence {
// Хранилище переданных задач
std::tuple m_taskList;
explicit WhenAllSequence(Tasks &&... tasks) noexcept
: m_taskList(std::move(tasks)...) {}
explicit WhenAllSequence(std::tuple &&tasks) noexcept
: m_taskList(std::move(tasks)) {}
bool await_ready() const noexcept {
//Возвращаем false, т.к. вызов await_suspend долежн быть выполнен. Если
//вернуть true- приостановка сопрограммы выполнена не будет, тело
//await_suspend вызвано не будет.
return false;
}
void await_suspend(stdcoro::coroutine_handle<> handle) noexcept {
// при вызове co_await будет выполнен вызов launchAll, в котором компиляторм
// для каждого элемента std::tuple был сгенериррован вызов co_await
co_await launchAll(
std::make_integer_sequence{});
handle.resume();
}
template
VoidTask launchAll(std::integer_sequence) {
// Используя fold-expression из C++17 - вызываем co_await для каждого
// элемента tuple
(co_await std::get(m_taskList), ...);
}
void await_resume() noexcept {}
};
template auto when_all_sequence(Args &&... args) noexcept {
// конструируем WhenAllSequence от переданных аргументов, при этом из
// аргументов формируем tuple.
return WhenAllSequence{std::make_tuple(std::move(args)...)};
}
} // namespace CoroUtils
Таким образом, получаем возможность не дублировать написание co_await
для последовательно выполняемых сопрограмм.
В данной реализации у нас появился возвращаемый тип VoidTask
. Он нам необходим для приостановки выполнения await_suspend
т.к. в ином случае при выполнении co_await
для первого из переданных аргументов в launchAll
будет выполнено возващение на вызывающую сторону, после чего вызов handle.resume()
что не совсем соотвествует ожидаемому результату. Восстановление ожидающей сопрограммы должно быть выполнено только после окончания работы всех переданных задач.
Рассмотрим реализацию типа VoidTask
. Данный тип представляет собой «ленивую» задачу которая не выполняется до момента вызова на ней оператора co_await
. Изначально задача приостановлена. Рассмотрим небольшой пример ее работы, прежде чем описывать детали реализации.
VoidTask lazyTask()
{
std::cout<<"I was called\n";
co_return;
}
int main()
{
auto lazy = lazyTask();
return 0;
}
Если запустить на wandbox — получим следующий вывод:
Start
0
Finish
Т.е. тело сопрограммы не было выполнено. Для того, чтобы выполнить тело сопрограммы — необходимо добавить вызов co_await
и добавить реализацию promise_type
для сопрограммы, в которой будет выполнен co_await
:
struct Promise
{
auto initial_suspend() noexcept
{
return std::suspend_never{};
}
auto final_suspend() noexcept
{
return std::suspend_never{};
}
void get_return_object()
{
}
void return_void()
{
}
void unhandled_exception()
{
while (1)
{
}
}
};
template struct std::coroutine_traits
{
using promise_type = Promise;
};
void taskEval()
{
auto lazy = lazyTask();
co_await lazy;
}
int main()
{ taskEval();
return 0;
}
Таким образом, мы получили «ленивую» задачу.
7. Как работает Task из cppcoro
VoidTask
будет частичным примером из библиотеки cppcoro, т.к. в ее реализации VoidTask
это специализация шаблонного класса Task
вида Task
. Разберемся, как он устроен и как он работает.
Прежде всего, для сопрограммы необходимо реализовать promise_type
. В нашем случае, т.к. сопрограмма приостанавливает свое выполнение после создания- у promise_type
вызов initial_suspend
должен вернуть std::suspend_always
. Отметим, что так-же promise_type
должен реализовать обработку поведения в случае выброса необработанного исключения (в нашем случае можем уйти в std::terminate
), и определить тип возвращаемого объекта наружу, т.е. реализовать метод get_return_object
.
struct task_promise
{
task_promise() noexcept = default;
void return_void() noexcept
{
}
void unhandled_exception() noexcept
{
std::terminate();
}
VoidTask get_return_object() noexcept
{
return VoidTask{std::coroutine_handle::from_promise(*this)};
}
auto initial_suspend() noexcept
{
return std::suspend_always{};
}
};
В свою очередь, VoidTask должен определить у себя оператор co_await
для возможности его вызова.
operator co_await
должен вернуть на вызвающую сторону task_awaitable
, который имеет возможность восстановить выполнение приостановленной сопрограммы и дополнительно установить continuation
, т.е. сопрограмму, которая будет восстановлена после окончания работы текущей сопрограммы.
struct task_awaitable
{
task_awaitable(std::coroutine_handle coroutine) : m_coroutine{coroutine}
{
}
bool await_ready() const noexcept
{
return !m_coroutine || m_coroutine.done();
}
auto await_suspend(std::coroutine_handle<> awaitingRoutine)
{
// Используем symmetric-transfer
// Подробнее можно ознакомиться:
// https://lewissbaker.github.io/2020/05/11/understanding_symmetric_transfer
m_coroutine.promise().set_continuation(awaitingRoutine);
return m_coroutine;
}
void await_resume()
{
}
std::coroutine_handle m_coroutine;
};
Необходимо реализовать у promise
метод для установки continuation
и так-же добавить возможность вызова continuation
.
Реализация установки continuation
представляет собой метод:
void set_continuation(std::coroutine_handle<> continuation)
{
m_continuation = continuation;
}
Для вызова установленного continuation
необходимо реализовать у promise метод final_suspend
. В методе final_suspend
вместо std::suspend_always/std::suspend_never
можно вернуть пользовательский Awaitable-тип, для которого будет сгенерирован вызов co_await
.
struct final_awaitable
{
bool await_ready() noexcept
{
return false;
}
template
auto await_suspend(std::coroutine_handle coroutine) noexcept
{
task_promise& promise = coroutine.promise();
return promise.m_continuation;
}
void await_resume() noexcept
{
}
};
Рассмотрим полную реализацию с комментариями для основных моментов:
#include
#include
struct VoidTask
{
struct task_promise;
using promise_type = task_promise;
struct task_promise
{
task_promise() noexcept = default;
//Сопрограмма ничего не возвращает. return_void
void return_void() noexcept
{
}
// При перехвате необработанного исключения - std::terminate
void unhandled_exception() noexcept
{
std::terminate();
}
//Возвращаемый на вызывающую сторону объект - VoidTask с переданным в конструктор
//coroutine_handle
VoidTask get_return_object() noexcept
{
return VoidTask{std::coroutine_handle<task_promise>::from_promise(*this)};
}
// Изначально сопрограмма приостановлена
auto initial_suspend() noexcept
{
return std::suspend_always{};
}
struct final_awaitable
{
bool await_ready() noexcept
{
// await_ready - false, вызов тела await_suspend является необходимым
return false;
}
template <typename TPromise>
auto await_suspend(std::coroutine_handle<TPromise> coroutine) noexcept
{
// https://lewissbaker.github.io/2020/05/11/understanding_symmetric_transfer
task_promise& promise = coroutine.promise();
return promise.m_continuation;
}
void await_resume() noexcept
{
}
};
void set_continuation(std::coroutine_handle<> continuation)
{
m_continuation = continuation;
}
auto final_suspend() noexcept
{
return final_awaitable{};
}
std::coroutine_handle<> m_continuation;
};
struct task_awaitable
{
task_awaitable(std::coroutine_handle<promise_type> coroutine) : m_coroutine{coroutine}
{
}
bool await_ready() const noexcept
{
return !m_coroutine || m_coroutine.done();
}
auto await_suspend(std::coroutine_handle<> awaitingRoutine)
{
m_coroutine.promise().set_continuation(awaitingRoutine);
return m_coroutine;
}
void await_resume()
{
}
std::coroutine_handle<promise_type> m_coroutine;
};
VoidTask(std::coroutine_handle<task_promise> suspendedCoroutine)
: m_coroutine{suspendedCoroutine}
{
}
~VoidTask()
{
// т.к. мы возвращали для initial_suspend у promise_type std::suspend_always -
// ответственность за удаление coroutine handle остается на нас.
if (m_coroutine)
m_coroutine.destroy();
}
bool await_ready() const noexcept
{
return !m_coroutine || m_coroutine.done();
}
auto operator co_await() noexcept
{
return task_awaitable{m_coroutine};
}
void await_resume()
{
}
std::coroutine_handle<promise_type> m_coroutine;
};
8. Как это тестировать?
Реализация SPI-драйвера представляет собой шаблонный класс, в который можно передать backend, который будет использоваться для передачи. Решение было принято с целью повышения тестопригодности модуля. В целом, пользователь работает в основном с несколькими методами:
void transmitBuffer(
const std::uint8_t* pBuffer,
std::uint16_t pBufferSize,
void* pUserData,
bool restoreInSpiCtx) noexcept
{
// установили coroutine_handle
m_coroHandle = std::coroutine_handle<>::from_address(pUserData);
//установили вызов, который будет вызван по окончанию передачи в конкретной реализации SPI
m_backendImpl.setTransactionCompletedHandler([this] { transmitCompleted(); });
const size_t TransferBufferSize = pBufferSize;
const size_t FullDmaTransactionsCount = TransferBufferSize / DmaBufferSize;
const size_t ChunkedTransactionsBufSize = TransferBufferSize % DmaBufferSize;
const bool ComputeChunkOffsetWithDma = FullDmaTransactionsCount >= 1;
// сформировали контекст транзакции
TransactionContext newContext{.restoreInSpiCtx = restoreInSpiCtx,
.computeChunkOffsetWithDma = ComputeChunkOffsetWithDma,
.pDataToTransmit = pBuffer,
.fullDmaTransactionsCount = FullDmaTransactionsCount,
.chunkedTransactionBufSize = ChunkedTransactionsBufSize,
.completedTransactionsCount = 0};
m_transmitContext = std::move(newContext);
// выполнили передачу данных
if (FullDmaTransactionsCount)
{
--m_transmitContext.fullDmaTransactionsCount;
m_backendImpl.sendChunk(pBuffer, DmaBufferSize);
}
else
{
m_transmitContext.chunkedTransactionBufSize = 0;
m_backendImpl.sendChunk(pBuffer, pBufferSize);
}
}
По окончанию передачи данных, в реализации backend вызвается TransactionCompletedHandler
. Рассмотрим фрагмент реализации для Nordic:
template class NordicSpi
{
public:
using This_t = NordicSpi;
NordicSpi() noexcept
{
nrfx_spim_config_t spiConfig{};
// конфигурация SPI пропущена .... //
//инициализация модуля SPIM. если вместо spimEventHandlerThisOne и this передать nullptr и
//nullptr - передача будет выполнена без использования EasyDMA, т.е. в блокирующем режиме
APP_ERROR_CHECK(nrfx_spim_init(
&SpiInstance::HandleStorage[PeripheralInstance::HandleIdx],
&spiConfig,
spimEventHandlerThisOne,
this));
}
void sendChunk(const std::uint8_t* pBuffer, const size_t bufferSize) noexcept
{
// формирование дескриптора на передачу данных
nrfx_spimxfer_desc_t xferDesc = NRFX_SPIMXFER_TX(pBuffer, bufferSize);
// выполнение транзакции
nrfx_err_t transmissionError = nrfx_spimxfer(
&SpiInstance::HandleStorage[PeripheralInstance::HandleIdx], &xferDesc, 0);
APP_ERROR_CHECK(transmissionError);
}
void xferChunk(
std::span _transmitArray,
std::span _receiveArray)
{
// формирование transmit-receive транзакции
nrfx_spimxfer_desc_t xferDesc = NRFX_SPIMXFER_TRX(
_transmitArray.data(),
_transmitArray.size(),
_receiveArray.data(),
_receiveArray.size());
nrfx_err_t transmissionError = nrfx_spimxfer(
&SpiInstance::HandleStorage[PeripheralInstance::HandleIdx], &xferDesc, 0);
APP_ERROR_CHECK(transmissionError);
}
using TTransactionCompletedHandler = std::function;
void setTransactionCompletedHandler(const TTransactionCompletedHandler& _handler) noexcept
{
m_transactionCompleted = _handler;
}
private:
static void spimEventHandlerThisOne(nrfx_spim_evt_t const* _pEvent, void* _pContext) noexcept
{
// данный обработчик вызывается по окончании передачи данных при помощи EasyDMA
if (_pEvent->type == NRFX_SPIM_EVENT_DONE)
{
// вызываем ранее установленный callback на драйвере верхнего уровня.
auto pThis = reinterpret_cast(_pContext);
pThis->m_transactionCompleted();
}
}
public:
TTransactionCompletedHandler m_transactionCompleted;
};
В свою очередь, для тестирования драйвера верхнего уровня необходимо релизовать соответствующий stub-backend
+ реализовать тесты. Рассматриваемые тестовые сценарии:
передача блока данных меньше размера DMA буфера
передача блока данных размером с DMA буфер
передача нескольких блоков данных размером с DMA буфер
передача нескольких блоков размером с DMA буфер и неполного блока
9. Добавляем параметрические тесты в GTest
Тестирование будем производить с использованием GTest фреймворка. С целью избегания copy-paste для тестов с вышеупомянутыми сценариями- воспользуемся функционалом, позволяющим инстанцировать параметрические тесты.
Для поддержки параметрических тестов в фикстуре необходимо добавить еще однин класс, от которого идет наследование помимо базового ::testing::Test
. Для поддержки параметрических тестов необходимо добавить наследование от ::testing::WithParamInterface
.
В нашем случае это будет std::tuple
вида:
public ::testing::WithParamInterface>
Где std::string_view
будет соответствовать названию теста, а std::uint16_t
— использоваться для задавания размера тестового буфера данных для отправки.
Реализация stub-backend для SPI драйвера представляет собой хранилище spi-транзакций, которые добавляются при каждом вызове sendChunk
. После чего, по окончании передачи данных может быть выполнено преобразование из отправленных транзакций в полноценный буфер с целью сравнения данных, которые были поставлены на отправку и данных, которы были переданы в драйвер низкого уровня.
Реализация данных функций представлена ниже:
void sendChunk(const std::uint8_t* pBuffer, const size_t bufferSize) noexcept
{
BusTransactionsTransmit.emplace_back(pBuffer, bufferSize);
m_completedTransaction();
}
using TDataStream = std::vector;
TDataStream getTransmittedData() const
{
TDataStream stream;
std::ranges::for_each(BusTransactionsTransmit, [&stream](const auto& _transaction) {
const auto& [pArray, blockSize] = _transaction;
auto arraySpan = std::span{pArray, blockSize};
std::ranges::transform(arraySpan, std::back_inserter(stream), [](std::uint8_t _dataByte) {
return std::byte{_dataByte};
});
});
return stream;
}
Для работы с параметрическими тестами необходимо добавить его определение + инстанцирование. На данном этапе будет опущена реализация примитива SyncWait, она будет приведена далее. Нам она необходима для ожидания окончания работы сопрограммы.
Определение параметрического теста будет иметь вид:
TEST_P(SpiDriverTest, CheckRandomSequenceWithLengthTransmissionCorrect)
{
// получаем размер последовательности
const std::uint16_t TransactionLength{std::get<0>(GetParam())};
TDataStream ExpectedStream{TransactionLength};
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::uniform_int_distribution<> distribution(0x00, 0xFF);
// формируем случайную посылку
std::ranges::generate(ExpectedStream, [&distribution, generator]() mutable {
return std::byte(distribution(generator));
});
CoroUtils::syncWait(sendChunk(
reinterpret_cast(ExpectedStream.data()), ExpectedStream.size()));
// проверяем соответствие данных, которые должны были быть переданы данным, которые были отправлены в spi-backend
EXPECT_EQ(TransactionsToDataStream(), ExpectedStream);
}
Инстанцирование параметрического теста представлено на фрагменте ниже:
INSTANTIATE_TEST_SUITE_P(
SpiDriverTesting,
SpiDriverTest,
::testing::Values(
std::make_tuple(Null, "Null"),
std::make_tuple(SmallerThanSingleDmaTransaction, "SmallerThanSingleDmaTransaction"),
std::make_tuple(EqualsToSingleDmaTransaction, "EqualsToSingleDmaTransaction"),
std::make_tuple(ThreeDmaTransactions, "ThreeDmaTransactions"),
std::make_tuple(SingleDmaAndChunk, "SingleDmaAndChunk"),
std::make_tuple(ThreeDmaAndChunk, "ThreeDmaAndChunk"),
std::make_tuple(StressChunked, "StressChunked")),
[](const auto& testParam) {
const auto& suiteName = std::get<1>(testParam.param);
return std::string(suiteName.data());
});
Переданная лямбда позволяет отформатировать название теста, который выполняется.
10. Добавляем черновой вариант драйвера для SPI-FLASH
Рассмотрим добавление драйвера для SPI-FLASH памяти. На первом этапе, рассмотрм получение JEDEC ID и device ID. Условия те-же самые, использовать DMA, ассинхронно, не блокироваться на ожидании событий.
Результат, который мы хотим получить, выглядит следующим образом:
void Board::initBoardSpiFlash() noexcept
{
m_pFlashDriver = std::make_unique();
if (m_pFlashDriver)
{
const std::uint32_t JedecId = co_await m_pFlashDriver->requestJEDEDCId();
LOG_DEBUG("Jedec Id is:");
LOG_DEBUG_ENDL(fmt::format("{:#04x}", JedecId));
const std::span DeviceId = co_await m_pFlashDriver->requestDeviceId();
LOG_DEBUG_ENDL(fmt::format("{:02X}", fmt::join(DeviceId, "")));
}
}
Для этого нам необходимо реализовать примитив, с помощью которого мы сможем вернуть значение на вызвающую сторону. Рассмотрим расширенный интерфейс Task
, а именно необходимые измненения для добавления поддержки возврата значения.
Необходимо изменить тип
await_resume
уtask_awaitable
:
decltype(auto) await_resume()
{
return m_coroutine.promise().result();
}
Добавить метод
.result()
уpromise
:
TResult& result()
{
return m_coroutineResult;
}
Добавить у
promise
методreturn_value
:
void return_value(const TResult& _value)noexcept
{
m_coroutineResult = _value;
}
Сделать
Task
шаблонным:
template struct Task
Добавить member в
promise
для сохранения результата.
Полная реализация измененного Promise
:
struct ResultTaskPromise
{
ResultTaskPromise() noexcept = default;
void unhandled_exception() noexcept
{
std::terminate();
}
Task get_return_object() noexcept
{
// Изменили тип возвращаемого объекта, теперь это шаблонный Task
return Task{std::coroutine_handle::from_promise(*this)};
}
auto initial_suspend() noexcept
{
return std::suspend_always{};
}
void return_value(const TResult& _value) noexcept
{
// добавили метод return value
m_coroutineResult = _value;
}
void set_continuation(std::coroutine_handle<> continuation)
{
m_continuation = continuation;
}
TResult& result()
{
// добавили метод result для возвращения результата
return m_coroutineResult;
}
struct final_awaitable
{
bool await_ready() noexcept
{
return false;
}
template
void await_suspend(std::coroutine_handle coroutine) noexcept
{
ResultTaskPromise& promise = coroutine.promise();
if (promise.m_continuation)
{
promise.m_continuation.resume();
}
}
void await_resume() noexcept
{
}
};
auto final_suspend() noexcept
{
return final_awaitable{};
}
// добавили поле класса для хранения результата
TResult m_coroutineResult;
std::coroutine_handle<> m_continuation;
};
11. Добавляем чтение JEDEC ID
Для чтения JEDEC ID, который представляет собой std: uint32 необходимо добавить в API spi-flash драйвера метод requestJedecId
:
CoroUtils::Task requestJEDEDCId() noexcept
{
auto receivedData = co_await prepareXferTransaction(
std::forward_as_tuple(WindbondCommandSet::ReadJedecId),
WindbondCommandSet::DummyByte,
WindbondCommandSet::DummyByte,
WindbondCommandSet::DummyByte);
std::uint32_t JedecDeviceId{};
for (std::size_t i{}; i < WindbondCommandSet::JedecIdLength; ++i)
{
JedecDeviceId |= (receivedData[i] << (16 - i * 8));
}
co_return JedecDeviceId;
}
Идея с std::forward_as_tuple
следующая — все что передано в аргументах — в функции prepareXferTransaction
будет рассмотрено как команда + количество dummy-bytes которые нужны для принятия команды. Все что после forward_as_tuple
— количество dummy-bytes для вычитки данных.
Реализация функции prepareXferTransaction
следующая: получаем на вход команду в виде tuple + количество пустых посылок. Далее, заполняем передаваемый буфер сначала командой и ее аргументами, передаем, после- пустыми посылками. Для возвращаемого значения возвращаем slice на буфер с принятыми данными
Реализация функции имеет вид:
template
CoroUtils::Task> prepareXferTransaction(
TCommand&& command,
Args&&... argList)
{
auto& transmitBuffer = getSpiBus()->getDmaBufferTransmit();
auto& receiveBuffer = getSpiBus()->getDmaBufferReceive();
getSpiBus()->setCsPinLow();
processTransmitBuffer(transmitBuffer, std::forward(command));
constexpr std::size_t CommandSize = std::tuple_size_v;
co_await transmitChunk(std::span(transmitBuffer.data(), CommandSize));
constexpr std::size_t DummyListSize = sizeof...(argList);
processTransmitBuffer(transmitBuffer, std::forward_as_tuple(argList...));
auto receivedBlockSpan = co_await xferTransaction(
std::span(transmitBuffer.data(), DummyListSize),
std::span(receiveBuffer.data(), DummyListSize));
getSpiBus()->setCsPinHigh();
co_return std::span(receiveBuffer.data(), DummyListSize);
}
На сторону пользователя, в данном случае вернется slice на принимающий DMA-буфер SPI. А далее его можно отформатировать с использованием fmtlib
или же std::format
, в зависимости от поддерживаемого компилятора.
13. Ожидание окончания выполнения сопрограммы
Иногда может возникнуть необходимость подождать выполнения сопрограммы, т.е. заблокировать выполнение текущего потока до момента, пока не будет завершена сопрограмма. Для этого можно реализовать примитив SyncWait.
Рассмотрим построение этого примитива (в cppcoro он так-же реализован, данный параграф скорее рассмотрение того, как он работает)
Мы хотим получить интерфейс вида:
TEST_F(FlashDriverTest, RequestJedecId)
{
auto jedecId = CoroUtils::syncWait(flashDriver.requestJEDEDCId());
constexpr std::uint32_t MagicTrashFromRawMemory = 13487565; // For fun;
EXPECT_EQ(jedecId, MagicTrashFromRawMemory);
}
Т.е. по вызову CoroUtils::syncWait
заблокироваться до получения результата.
Реализуем
syncWait
необходимо получить тип возвращаемого значения из сопрограммы. Для этого необходимо написать небольшую обертку, которая позволит это выполнить. В нашем случае, возвращаемые типы представляют собой Awaitable-примтивы с доступнымoperator co_await
.
Примерный инт