C++ Embedded
Open in Telegram
Леденящие душу прохладные истории про С++ в embedded проектах. Зарисовки из разработки встраиваемых систем.
Show more446
Subscribers
No data24 hours
+27 days
+430 days
Posts Archive
446
std::string_view
Как-то осенью поэт Басё и его ученик Кикаку шли по рисовому полю.
- Сегодня на ревью я увидел красивый код и сложил хокку, - сказал вдруг Кикаку, - если добавить к пейзажу веревку, то увидишь
std::string_view.
- Нужно любить то, о чем пишешь, - ответил Басё и предложил улучшить стих. - Если добавить Кикаку мозгов, то получился бревно.
Тут поэт поймал красную стрекозу и задумчиво оторвал ей крылья, превращая оную в стручок перца.
- Когда ты уже запомнишь, что std::string_view нужно передавать по значению?!
Может, это был Басё, а может, и Arthur O’Dwyer, история умалчивает. Однако последний в своей небольшой статье прямо утверждает, что string_view и подобные объекты просто необходимо передавать по значению. Это исторически верно, ООП должен работать с объектами. Это концептуально правильно! Всякие там передачи по указателю или по ссылке придумали трусы, негодяи и оптимизаторы.
Прежде всего, вспомним, что string_view - это упрощенное представление строки, состоящее из указателя и длины. Идея прозрачна и понятна, как платье Бьянки Цензори. В стандартную библиотеку концепт попал в таком виде:
using string_view = basic_string_view<char>;
где basic_string_view - это шаблонный класс
template<typename _CharT, typename _Traits = std::char_traits<_CharT>>
class basic_string_view {
...
size_t _M_len;
const _CharT* _M_str;
};
Размер всего класса на нашей 32-битной arm платформе будет 8 байт.
Верно ли, что объект такого типа лучше передавать по значению?
Первой причиной можно назвать устранение косвенного обращения по указателю.
Передача константой ссылки значит, что, скорее всего, передается адрес объекта. Передача же значения - что объект передается непосредственно через регистры, если он достаточно субтильный.
Для примера сравним две функции. В одну мы передаем string_view по значению, во вторую - по ссылке. В обоих случаях возвращаем размер представляемой строки.
int byvalue(std::string_view sv) { return sv.size(); }
int byref(const std::string_view& sv) { return sv.size(); }
Результат работы компилятора ошеломляет. Для первого случая нам вообще ничего не придется делать.
byvalue(std::basic_string_view<char, std::char_traits<char>>):
ret
Объект передан через регистры, для его передачи использованы два регистра. В первый как раз попала переменная _M_len, которую нам нужно вернуть. Какая же удача, что через этот регистр мы получаем возвращаемое значение.
А вот в другом случае при передаче ссылки приходится поработать с памятью, что будет сложнее и дольше:
byref(std::basic_string_view<char, std::char_traits<char>> const&): ldr w0, [x0] retПогодите, что это за регистры? Это не наша архитектура, это 64-битный arm. Неудобно получилось, ведь для arm32 картина будет иной.
byvalue(std::basic_string_view<char, std::char_traits<char>>): sub sp, sp, #8 strd r0, r1, [sp] add sp, sp, #8 bx lrВидно, что объект пришел через стек, потом был записан в два регистра. Первым в
std::string_view идет длина, она же и попадет в регистр r0, где и должно быть возвращаемое значение. На этом выполнение функции, в общем-то, можно и закончить.
Передача по ссылке:
byref(std::basic_string_view<char, std::char_traits<char>> const&): ldr r0, [r0] bx lrВыглядит проще. Собственно, в более поздней статье Артур оправдывается, что результат работы зависит от конкретного компилятора и calling convention.
string_view считается большим типом и, что самое обидное - не фундаментальным. Если бы мы использовали uint64_t, компилятор бы разместил значение на двух регистрах, но похоже, сложные объекты он предпочитает передавать через стек, к сожалению.
Но это почти так же быстро, разница особо не чувствуется.
Можно, конечно, написать так:
int byvalue(uint64_t s) { return reinterpret_cast<std::string_view *>(&s)->size(); }
И мы получим желаемое:
byvalue(unsigned long long): bx lrТолько вызывать такую функцию немного сложновато. Ладно, согласен, первый аргумент за передачу по значению слишком уж зыбкий, зависит от конкретной платформы и немного сомнителен. Надеюсь, остальные причины окажутся более убедительными. Проверим.
446
coder market interview
Солидные бородачи в свитерах с оленями сидели на массивных деревянных скамьях вокруг величественного шатра. Рядом высились и другие, но попроще, скорее похожие на палатки, и народ вокруг них сидел прямо на земле, ежась от холода в легкомысленных футболках и хмуро поглядывая на проходящих господ.
- Это и есть рынок разрабов, - пояснил я своему спутнику, агенту одной известной компании.
Он озирался по сторонам с интересом, я же смотрел только на раскинувшийся перед нами знакомый до боли шатер. Он все еще хорош, хотя раньше был великолепен. Узнаваемое созвездие на его полотнище когда-то было выложено бриллиантами, сейчас же их заменили светодиодами.
- Вылезай, Негоро! Тут телеком сеньоров ищет! - крикнул я, откинув полог.
- Я не Негоро! - около нас мгновенно материализовался подтянутый седовласый господин в дорогом костюме, - Себастьян Перейра, СЕО. Торговец естественным интеллектом! У нас все сеньоры, породистые, не с курсов! Сильные, даже не сомневайтесь!
- Не сомневаюсь, но хотел бы проверить... - робко сказал мой подопечный и достал из кармана бумажный листочек.
- Доми-Тори, сюда! - коротко скомандовал разработорговец.
Дюжий разраб медленно приблизился и с усталым вздохом развернул полученный листок.
Вверху было напечатано:
std::function<void()> make_printer() {
int counter = 0;
return [counter]() mutable {
++counter;
std::cout << counter << " ";
};
}
- Элементарно... - произнес Доми-Тори и ровным голосом продолжил.
Функция make_printer возвращает std::function, внутри которой мы поместим лямбда-функцию. Лямбда эта захватывает локальную переменную counter по значению, разумеется. Иначе это был бы epic fail. Внутри функции мы увеличиваем счетчик counter и выводим получившееся значение. Думаю, печать комментировать не нужно, а остальное есть суета и томление духа.
Достаточно вспомнить, что лямбда - это просто класс.
struct lambda {
int counter;
lambda(int counter) : counter {counter} {}
...
Тогда захват по значению сводится к передаче оного через конструктор переменной counter, члену класса по совместительству.
Внутри лямбды мы инкрементируем counter исключительно как член класса, поэтому нам просто необходим спецификатор mutable. Он избавит нас от навязчивого признака const, которым отмечен operator() любой лямбды по умолчанию.
void operator()() /*const*/ {
++(this->counter);
...
}
Доми-Тори умолк. В глазах покупателя забрезжила надежда.
- Что же выведет код? - тихо спросил он.
Разраб посмотрел на вторую половинку листа:
auto printer = make_printer();
printer();
printer();
auto another_printer = printer;
another_printer();
printer();
- Вначале мы просто создаем объект printer функцией make_printer. Член класса counter получил значение ноль. Вызов функтора printer увеличит счетчик на единицу и ее же выкинет в стандартный вывод. Второй вызов доведет счетчик до двух.
Затем мы копируем лямбду, полагаю, не надо пояснять за копирующий конструктор по умолчанию. Значение counter объекта another_printer будет перенесено из printer, т.е. начнется с двоечки.
Соответственно, вызов another_printer доведет счетчик до трех, но и вызов printer сделает то же самое. Поэтому на экране мы увидим: 1 2 3 3
- Чудесно! - представитель компании захлопал в ладоши. - Беру! Беру всех!
После заключения сделки Перейра и агент хитро улыбались, каждый был абсолютно уверен, что надул другого.
- Зачем вам столько безвольников, проект сложный затеяли? - спросил я напоследок.
- Ответственной работы очень много! Тесты на питоне кому попало не доверишь...446
fmt consteval trick
Дорога в Болдино была невероятно красива и настолько же утомительна. Дабы разнообразить нашу постылую жизнь, автобус неожиданно принимался подпрыгивать и трястись, но водитель упорно не хотел сбавлять скорость, торопясь на встречу с прекрасным. Разморенный солнцем и убаюканный внезапно начавшейся ровной дорогой, я было задремал, как вдруг заметил, что в мою сторону крадется странного вида парень. Всклокоченные волосы удачно подчеркивали легкое безумие в глазах. Беззастенчиво продемонстрировав всем весьма потертую футболку с надписью "I eat c++ for breakfast", болезный упал на соседнее кресло.
- Нет, ну ты видел, что они сделали в fmt? - подмигнул он мне.
"Сумасшедший!" - мгновенно понял я, а вслух сказал:
- Аск! Во дают, да?! Ловкачи...
Хотя и понятия не имел, что они там удумали в своем fmt. Однако ехать было еще долго, а ретироваться через окно я счел ниже своего достоинства. Поневоле пришлось прислушиваться к незваному просветителю. Оказалось, что есть такие штуки, как спецификаторы формата. Вещь очень полезная для управления отображением данных во время форматирования. Обычно это символ или набор символов, например в
printf написал %02x и сразу понятно, что мы хотим вывести число в шестнадцатеричном виде. Стандартным функциям хорошо, за них может и компилятор вписаться, кинет предупреждение, если тип переменной не соответствует спецификатору. Только за ваши велосипеды администрация ответственности не несет. Передали переменную не того типа, получите ошибку в run-time или вообще UB.
В GCC есть хотя бы расширения, атрибут формата
int my_printf (void *my_object, const char *my_format, ...) __attribute__ ((format (printf, 2, 3)));
Чтобы компилятор проникся и понял, что my_printf он такой же как printf, только лучше.
Нам этого мало. Чаще всего и спецификаторы формата, и типы переменных известны уже на этапе компиляции, и неплохо бы там же диагностировать несоответствие, а не ждать, пока приложение погибнет в корчах. Вот, ходят слухи, что до такой проверки додумались в fmtlib. Рассмотрим некий упрощенный вариант, функцию fmt, которую корректно можно вызывать только так:
fmt("int", 10);
а такой вызов бы давал ошибку компиляции:
fmt("float", 10);
и вот такой тоже:
fmt("int", "foo");
В этом нам поможет новый инструмент c++20 - consteval. Он такой же, как и constexpr, только лучше и не обязан вкалывать после смены, т.е. в run-time.
Представим fmt в виде
template <typename T>
void fmt(std::type_identity_t<Checker<T>> checked, T) {...}
где первый аргумент мы через type_identity_t принудительно попытаемся привести к Checker<T>. Зачем? Все просто
template <typename T>
struct Checker {
consteval Checker(const char* fmt) {
if (fmt != std::string_view{"int"})
throw;
if (!std::is_same_v<T, int>)
throw;
}
};
Перед нами структура Checker, consteval спецификатор конструктора которой намекает, что неплохо бы исполнить его во время компиляции. Внутри конструктора мы проверяем "строку форматирования", и если она не "int", то разрушаем построение вызовом throw, которого не может быть в compile-time.
Далее мы проверим переданный в качестве шаблонного аргумента типа переменной T. Если он не int, то сигнализируем об ошибке тем же макаром. Использование конструктора Checker в качестве проверяющего дает нам самые широкие полномочия по сопоставлению строковых спецификаторов и типов.
Наконец мы добрались до точки назначения. Окрыленный новым знанием, я выбежал из автобуса.
- Хай, подруга! Ты представляешь, что они наделали в своем fmt? - завопил я, завидев перед собой юную барышню с томиком Онегина.
Глаза потом жгло невероятно после метко пущенной перцовой струи.446
static operator()
Разные есть у людей увлечения. Кто пляшет, кто чайный гриб выращивает, а кто и функторы в коде разводит. Особенно хорошо они во всяких алгоритмах и
ranges размножаются. Бывало, идешь по коду, приподнимешь какой-нибудь transform, а функторы так и разбегаются в разные стороны. Бесспорно, эта мелочь небесполезна. Кинешь одного такого в какой-нибудь хитрый генерализованный алгоритм, он там пищит смешно, когда его за operator() дергают, зато входные данные обработаны универсальным способом.
Однако есть и недовольные текущим положением вещей. Например Barry Revzin и Casey Carter еще в октябре 2018 года выкатили предложение P1169R0, где слезно умоляли разрешить operator() быть статическим членом класса. Жаба, говорят, душит платить еще и за вызов метода класса.
Заглянем внутрь нашему общему знакомому, функтору std::less:
template<typename _Tp>
struct less : public binary_function<_Tp, _Tp, bool> {
bool operator()(const _Tp& __x, const _Tp& __y) const {
return __x < __y;
}
};
Очевидно, внутри оператора сравниваются два значения, попавшие туда в виде аргументов. В тот же метод неявно передается и указатель this, который здесь нужен как собаке пятая нога.
Поэтому, будь operator() обычным методом класса, особо рьяный анализатор уже бы выл о том, чтоб сделать его статическим.
struct X {
bool operator()(int) const;
static bool f(int);
};
Мы можем передать в алгоритм статический метод bool (X::f)(int)
std::count_if(xs.begin(), xs.end(), X::f);
Однако для функтора передача обязана идти через создание "материального" объекта.
inline constexpr X x;
std::count_if(xs.begin(), xs.end(), x);
Предложение разработчиков ranges даром не прошло, и уже в стандарте c++23 двум операторам дозволено быть статическими: operator() и operator[].
Теперь less можно переписать
template <typename T>
struct less {
static constexpr auto operator()(T const& x, T const& y) -> bool {
return x < y;
};
};
Но не надейтесь вызвать оператор как less<int>(), этого не будет, это по-прежнему вызов конструктора.
Вызвать оператор опять же можно напрямую
less<int>::operator()(1, 2),
либо создав объект
less<int>{}(1, 2)
Зато вычислено это будет во время компиляции:
static_assert(less<int>{}(1, 2));
Еще одно важное следствие этого изменения связано с особым типом функторов - лямбдами.
Как вы знаете, все есть класс. Стандартные типы, вроде std::function - класс, std::array - структура вообще-то, но и структура - это класс, и разсахари (т.е. избавь от синтаксического сахара и перепиши в классическом представлении) лямбду, получишь класс. Раз уж мы разрешаем статические операторы для классов, то это должно повлиять и на лямбды. Теперь для лямбду без захвата можно пометить как статическую. Ну и правильно, зачем вам захват, если operator() будет статический и там не будет доступа к захваченным объектам?
Выглядит это примерно так
auto isEven = [](int i) static {return i % 2 == 0;};
От стандарта к стандарту лямбды становятся все прекраснее и быстрее! Но это неточно.446
nothing is what it seems
Как писал Конфуций в общедомовом чате: "Всё не то, чем кажется и не наоборот".
Давно бы пора запомнить, что нельзя смотреть пулл реквесты, если не просят. В код коллег лучше не подглядывать, чтоб сохранить хорошие рабочие отношения. Впрочем, если из кода летят предупреждения компилятора как из рога изобилия, то это явное приглашение. Он как бы кричит тебе: "Приезжайте и соответствуйте!". Вот я и заехал и сей же час поседел во второй раз. Есть у надежного кодирования свои особенности, например, архиважно инициализировать все переменные, но некоторые товарищи делают это весьма оригинальным манером:
struct NiceClass {...};
auto x = NiceClass();
Есть такое ощущение, что запись разоблачает в разработчике постыдное пристрастие к питону. Оно понятно, что хотел сказать автор, хоть для этого может потребоваться чуть больше когнитивных усилий, но после такого теряется концентрация. Как тут погружаться в код, если все время возвращаешься к этим строчкам и думаешь: "Ну вот зачем он это сделал?".
Есть ли еще недостатки у такого подхода, кроме жутковатого вида?
Запись эта теоретически допускает неоднозначность. Например, попытка вызова конструктора NiceClass() уж больно похожа на вызов функции. "Ну уж нет, такого быть не может, компилятор не допустит!" - возмущенно возразит тот, что еще не сталкивался с коварством GCC.
Впишем после определения класса функцию:
int NiceClass() {return 100;}
И теперь выполним еще раз
auto x = NiceClass();
static_assert(std::is_same_v<int, decltype(x)>); // OK, x == 100
Эта функция "затмит" имя класса и будет вызвана вместо конструктора.
Еще NiceClass может оказаться функтором сам по себе:
struct NiceClass {
int operator ()() const {
return 42;
}
}
Само по себе это не страшно, пока кто-то, например я, из хулиганских побуждений не создаст глобальную переменную
NiceClass NiceClass;Тогда вызов
auto x = NiceClass();
static_assert(std::is_same_v<int, decltype(x)>); // OK, x == 42
ничто иное как вызов функтора, вызов перегруженного оператора ().
Конечно, пользователь может обезопасить себя, изобретая еще более уродливые конструкции, не дающие вызвать ничего кроме конструктора
auto x = std::decay_t<decltype(std::declval<NiceClass>())>();
тут в declval ожидается тип и подстановка иной сущности приведет к ошибкам компиляции, но выглядит это еще уродливей.
Самые обычные языковые конструкции, которыми хотят удивить, запуская их в небо под разными углами, могут дать вовсе неожиданный результат. Поэтому в AUTOSAR есть простое и понятное правило:
A8-5-2 При инициализации переменных должна быть использована скобочная инициализация {}, без знака присваивания.
В этом есть сермяжная правда. Делай проще, используй zero initialization, тогда твои намерения будут явными, и ничего страшного не случится.
NiceClass x {};
Здесь конструктор по умолчанию будет вызван, если он есть.
Потом взгляд мой упал на следующую строчку кода:
NiceClass t = decltype(t)();я упал и забился в конвульсиях.
446
compile-time. endgame
Инда взопрели озимые. Рассупонилось солнышко, расталдыкнуло свои лучи по белу светушку. Глянул старик Ромуальдыч портянку кода и аж заколдобился... а все потому, что XXI век на дворе, с++20 давно уже бороздит просторы Большого театра, а вы тут все со SFINAE играетесь. В 2к25 это должно быть постыдно. Надо же делать понятно, настолько доходчиво, чтоб уразумел распоследний зумер.
Поэтому перепишем счетчик еще раз в духе нового времени, с новыми фичами и соответствующим вайбом.
template<unsigned N>
struct reader {
friend consteval auto flag(reader<N>);
};
template<int N>
struct Writer {
friend consteval auto flag(reader<N>) { return true; }
static constexpr int value {N};
};
template <auto Tag, int Value = 0>
[[nodiscard]]
consteval int counter_impl() {
if constexpr (requires(reader<Value> r) { flag(r); }) {
return counter_impl<Tag, Value + 1>();
} else {
return Writer<Value>::value;
}
}
template<auto Tag = []{}, int Val = counter_impl<Tag>()>
constexpr int counter {Val};
Кода будто стало по противного мало, что наверняка сделало его понятнее. Или нет?
Работает счетчик так же:
static_assert(counter<> == 0);
static_assert(counter<> == 1);
...
Как вы могли заметить, теперь счетчик - это, простите за выражение, шаблонная константа. Первый аргумент шаблона Tag - это значение уникального типа, логично, что роль эта отводится лямбда-функции. Вторым аргументом шаблона следует значение Val - непосредственно значение счетчика. Получаем мы это значение через функцию counter_impl, тоже шаблонную, куда опять же передается уникальный тэг. Сделано это все с той же прагматичной целью: переоценивать контекст при каждом вызове.
Внутри counter_impl мы проверяем явно возможен ли вызов функции flag с аргументом типа reader<Value>,
if constexpr (requires(reader<Value> r) { flag(r); })
где Value - это второй агрумент шаблона, по умолчанию нулевой, стартовое значение счетчика. Если такую функцию flag вызвать сейчас вызвать невозможно, то мы идем в ветку, где явно инстанцируем Writer<Value>, чтоб функция появилась.
Иначе мы рекурсивно вызываем counter_impl, увеличивая каждый раз значение счетчика - counter_impl<Tag, Value + 1>().
Принцип все тот же, только записан как-то душевнее, человечнее, что ли.
Writer и reader почти не претерпели изменений, они все такие же пострелята. Однако разница есть. Тип возвращаемого значения функции flag указан как auto, и это важный момент, о котором авторы метода стыдливо умалчивают.
Дело в том, что если прямо указать возвращаемый тип void flag(reader<N>), то оценка
requires(reader<Value> r) { flag(r); }
будет положительной, несмотря на отсутствие реализации этой функции, и мы провалимся в бесконечную рекурсию. С auto все будет не так однозначно. Компилятор не может вывести возвращаемый тип без подглядывания в реализацию, и requires не хватает данных о возможности вызова, и оценка становится отрицательной. При инстанцировании Writer<Value> появляется реализация flag, информации снова достаточно, и оценка становится положительной.
Вот такая загогулина, понимаешь, но вы же серьезные люди и не будете пользоваться всеми этими сомнительными трюками?446
сompile-time сounter. next
Этот пример счетчика был практически хрестоматийным. Я встречал его множество раз. Находил всюду его следы. Почему же пал этот древний титан? Вопрос терзает меня будто ночной комариный писк. Не отмахнешься просто так, он засел занозой в голове. Он не дает спать. Раннее утро, но я уже давно на ногах, всем своим помятым сознанием соображаю, по каким причинам произошел эпичнейший провал. Город за окном еще сладко дремлет, залитый робкими рассветными лучами. Я напряженно вслушиваюсь в хрупкую, еще ночную тишину. Пока звук сирен не разрезал безмолвие нарождающегося дня, время еще есть.
Допустим, разработчики компилятора сломали прошлый пример намеренно, чтоб в разработке был порядок. Ведь нельзя же строить код вокруг лакуны стандарта или же убербага компилятора, иначе в одно прекрасное утро проект с грохотом рухнет. Однако это не остановит извращенные умы, да здравствует
stateful metaprogramming!
Что же сломалось при переходе к новой версии компилятора? Рассмотрим первый вызов функции next: до поры все работает как надо, функция flag(Flag<0>) не будет найдена, и мы обратимся к reader<0>(float), получим заслуженный ноль и определение функции flag(Flag<0>) в глобальном пространстве имен. Следующий вызов next не приводит к желанному эффекту. Компилятор будто не переоценивает заново контекст, игнорирует новое определение функции flag(Flag<0>), хотя оно точно появилось.
Компилятор как будто запомнил, что функцию reader<0, flag(Flag<0>{}), lamda> собрать невозможно и надо использовать reader<0>(float). Или ему просто лень. Он и не пытается даже зедекларировать нужную функцию повторно, и уникальный тип по умолчанию в шаблонных аргументах нам не очень помогает.
Что же, нас в дверь, а мы в окно. Нагло соврем, что компилируем вообще другую функцию, т.е. прямо укажем компилятору
template<int N = 0, auto = []{}>
constexpr int reader(float) {
return Writer<N>::value;
}
template<int N = 0, auto = []{},
bool = flag(Flag<N>{})>
constexpr int reader(int) {
return reader<N + 1>(int{});
}
template<int R = reader<0, []{}>(int{})>
constexpr int next() {
return R;
}
Чтоб не дать GCC вывернуться, внесем значение уникального типа прямо в вызов reader, тогда будет очевидно, что нужно инстанцировать функцию reader заново.
Счетчик снова работает во времени компиляции, древнее зло опять пробудилось! Я удовлетворенно откинулся в кресле и закрыл глаза.
Резкий звук ворвался в сонный полдень. Сирены! Они уже близко. Стук в дверь. Полиция плюсов! "Отворяй, собака! Тут совершается мыслепреступление...."
Опять по рукам будут бить.446
сompile-time сounter
Еретики из секты извращенных попрателей плюсов давно нашли богохульное и интересное применение
friend injection. Они строят на основе этого эффекта богомерзкий счетчик, что во время компиляции возвращает число обращений к нему. То есть, по сути, может генерировать уникальные номера времени компиляции. Код выглядит безумно, как картины Босха, однако это изощренный способ облапошивания простодушного GCC:
template<int N>
struct Flag {
friend constexpr bool flag(Flag<N>);
};
template<int N>
struct Writer {
friend constexpr bool flag(Flag<N>) {
return true;
}
static constexpr int value = N;
};
template<int N = 0>
constexpr int reader(float) {
return Writer<N>::value;
}
template<int N = 0,
bool = flag(Flag<N>{}),
auto = []{}>
constexpr int reader(int) {
return reader<N + 1>(int{});
}
template<int R = reader<0>(int{})>
constexpr int next() {
return R;
}
Пример, наглядно иллюстрирующий работу функции next:
static_assert(next() == 0);
static_assert(next() == 1);
static_assert(next() == 2);
Отвратительное и одновременно завораживающее зрелище.
Как вы могли заметить, next - шаблонная constexpr функция с единственным шаблонным аргументом R, у которого есть значение по умолчанию. Значение это дает шаблонная же функция reader, которую мы вызываем с шаблонным аргументом 0 и обычным аргументом int{}.
Знатоки метапрограммирования сразу поймут, что функция reader рекурсивная, если она инстанцировалась успешно, то пытается вызвать себя же с инкрементированным шаблонным аргументом. Соответственно, неуспех инстанцирования определяет конец рекурсии. Функция reader будет создана только в том случае, если в глобальном пространстве имен присутствует определение функции flag(Flag<N>). Изначально у нас нет ни одного определения функции flag, поэтому компилятор сватает нам низкоприоритетную функцию reader<0>(float), которая нам не очень подходит из-за необходимого неявного преобразования int во float. Однако выбирать не приходится, другой функции у него для нас нет. Зато reader<0>(float) инстанцирует внутри себя шаблонную структуру Writer<N>, в которой будет определена функция flag(Flag<0>)!
Тогда при повторном вызове функции next в глобальном пространстве имен уже будет находиться функция flag(Flag<0>), поэтому функция reader<0, flag(Flag<0>{}), lambda> таки будет создана, но вот на создании reader<1, flag(Flag<1>{}), lambda> компилятор снова споткнется и запросит помощи у reader<1>(float), который вернет единицу и создаст определение для flag(Flag<1>) и так далее. Принцип понятен.
У функции reader есть немаловажная деталь в шаблонных аргументах - третий аргумент должен генерировать значение уникального типа, поэтому по умолчанию там лямбда. Каждая лямбда имеет уникальный тип, не верите? Проверьте:
static_assert(not std::is_same_v<decltype([](){}), decltype([](){})>);
Это нужно, чтоб при инстанцировании шаблона контекст каждый раз оценивался заново, что и дает возможность во время компиляции "менять" поведение функции flag.
Ну, красиво же надурили! Однако не надейтесь, что единожды воспользовавшись слабостью компилятора, вы будете получать дивиденды вечно.
Вот и ликование святотатцев длилось недолго: GCC 12 версии и выше пресекает подозрительные действия, и конкретно этот пример теперь не работает.446
abuses of access rights
Опосля последней публикации пришел ко мне некто Саттер в виде астральном, но смотрел с такой укоризной, что хотелось забиться под плинтус. "Что же ты, собака, про меня ничего не сказал, - говорил он устало, - я тоже много всяких извращений описал лет 15 назад".
"Так ведь все они супротив
friend injection как плотник супротив столяра", - пищу я, забившись в угол.
Тут он бросает в меня zero-cost исключение, и я в ужасе просыпаюсь. Передо мной на дисплее светится заметка Герба Саттера "Uses and Abuses of Access Rights", в которой рассмотрены методы нарушения инкапсуляции разной степени отвратительности. Парочку мы уже случайно рассмотрели, остались еще два любопытных способа.
Способ "Врун":
Пусть в заголовочном файле находится описание класса Secret.
// secret.h
class Secret {
int32_t id;
int32_t code;
public:
int GetCode() const;
};
но, например, в main.cpp мы не подключаем secret.h, а пересоздаем описание класса:
class Secret {
int32_t id;
int32_t code;
public:
int GetCode() const;
friend void Hijack(Secret&);
};
Мы добавили в описание дружескую функцию Hijack, где будем сможем менять значение приватных членов.
void Hijack( Secret& x ) {
x.code = 42; // злодейский смех
}
Итого:
Secret x{};
Hijack(x);
std::cout << "id " << x.GetCode(); // 42!
Это противозаконно, поскольку нарушает One Definition Rule. Ведь если тип определен более одного раза, определения должны быть хотя бы идентичны.
Хотя и в этом случае, даже если класс будет называться так же, может даже выглядеть так же, но это не он, это ловкий врун.
Способ "Дровокат".
Обычно методы, ломающие правила инкапсуляции, воняют, но некоторые источают аромат меньше других.
Допустим, наш класс заполучил какую-то шаблонную функцию.
class Secret {
int32_t id;
int32_t code;
public:
template <class T>
int get(T t) { return 0; }
}
Тогда мы можем специфицировать ее!
namespace {
struct Y {};
}
template<>
int Secret::get(Y const&) {
code = 42; // злодейский смех
return 1;
}
Secret x {};
x.get(Y{}); // code == 42
Если бы метод был человеком, то этот был бы плюсовой шапиро, ловчила-законник, кто знает лазейки. Его не поймать на горячем, он соблюдает букву закона, но полностью выхолащивает его дух. Ведь специализировать шаблон для любого типа разрешено. Ошибка может возникнуть, если бы мы попытались специализировать метод для одного и того же типа разными способами, что будет нарушением ODR, но это можно обойти.
Здесь код использует тип, который гарантировано уникальный, потому как находится в собственном неименованном пространстве имен.
А как вам такое, мистрер Саттер? Если у нас есть просто шаблонный класс:
template <class T>
class Secret {
int32_t id {0x12345678};
int32_t code {100};
public:
int get() { return 0; }
};
Такую функцию тоже можно специализировать:
template <>
int Secret<int>::get() {
code = 42; // злодейский смех
return 1;
}
Взять, к примеру, знакомый всем std::array. Сейчас я легким движением руки выпотрошу его внутренности:
template <>
int& std::array<int, 10>::front() noexcept {
this->_M_elems[0] = 42; // злодейский смех
return *begin();
}
Это даже работает. Не знаю, зачем я это все описал. Забудьте и никогда не используйте.446
friend injection
"Если с другом вышел в путь, веселей дорога!" - говорит нам старинная советская песня. "Если ты упал и подняться не смог, друг под зад тебе отвесит пинок", - вторит ей другая композиция уже из нового времени. В любую эпоху дружба ценилась, ведь имея правильных друзей можно многого достичь, например, закрытых членов класса.
Идиома
friend injection напрямую к криминалу отношения не имеет. Ее суть скорее в возможности использования снаружи класса дружественной функции, которая определена внутри.
Например:
struct Something {
friend void Print() {}
};
Тогда достаточно задекларировать ее
void Print();
и вызывать вне контекста класса:
Print();Олдскулы подсказывают нам, что на этом механизме когда-то строился трюк Бартона-Накмана. Эта другая идиома, которая заготавливала дружественную функции внутри базового шаблонного класса. Например, такой базовый класс:
template<typename T> class EqualComparable {
friend bool operator==(T const &a, T const &b) { return a.EqualTo(b); }
};
Наследуясь от него, мы получаем в качестве бонуса, т.е. на шару, оператор сравнения.
struct Something : private EqualComparable<Something> {
bool EqualTo(value_type const& rhs) const; // Только вот тут определить функцию и ништяк
};
Изначально трюк Бартона-Накмана эксплуатировал упомянутую особенность, и объявление дружественной функции внутри класса делало ее имя доступным в окружающем пространстве имён. Потом кое-кто ужаснулся такому положению дел, и правила поиска подшаманили, теперь такими функциями занимается ADL, и напрямую такой ::operator==(a, b) вызвать нельзя.
Однако возможность куражиться над иными кореш-функциями никуда не делась. А тут еще в с++20 появилось искушение использовать указатель на член класса как шаблонный параметр. Это значит, время усовершенствовать метод кражи закрытых данных.
template < int Secret::*Member >
class Outlaw {
public:
friend int& GetPrivateMember(Secret& obj) {
return obj.*Member;
}
};
Указатель на член класса передается Outlaw как шаблонный параметр. Функция-друг GetPrivateMember принимает как аргумент ссылку на класс Secret, и, применив Member, возвращает ссылку на член класса.
Теперь, чтоб получить доступ к приватным членам, достаточно только явно инстанцировать Outlaw:
template class Outlaw<&Secret::code>;
и декларировать функцию, которая обеспечит беспрепятственный доступ к телу:
int GetPrivateMember(Secret&);
Secret s {};
GetPrivateMember(s) = 42;
Выглядит неплохо, но нельзя же мириться с тем, что один класс Outlaw уходит только на один член класса.
Если модифицировать немного, добавить тэг и аргумент уникального типа в дружественную функцию, то возможно использовать этот Outlaw для вскрытия нескольких членов класса или даже разных классов.
template <auto, int Tag>
struct Outlaw;
template <class S, int S::* Member, int Tag>
struct Outlaw <Member, Tag> {
friend int& GetPrivateMember(Secret& obj, std::integral_constant<int, Tag>) {
return obj.*Member;
}
};
template struct Outlaw<&Secret::code, 0>;
template struct Outlaw<&Secret::id, 1>;
int& GetPrivateMember(Secret&, std::integral_constant<int, 0>);
int& GetPrivateMember(Secret&, std::integral_constant<int, 1>);
Secret s {};
std::cout << "id " << GetPrivateMember(s, std::integral_constant<int, 1>{}) << std::endl;
Хотя, конечно, так делать не стоит. Никогда!446
explicit instantiation
ПОСТОЯННАЯ БДИТЕЛЬНОСТЬ!
Если вы думаете, что с++ непогрешим, то спешу вас расстроить. Я могу такого порассказать, что у вас волосы будут шевелиться в самых нескромных местах! Всякими нестыковками и лазейками в стандарте спешат воспользоваться ушлые и небрезгливые ребята, а потом еще и хвастаются этим на профильных конференциях.
Они говорят: "Да, мы нарушаем инкапсуляцию, но только ради тестирования". Однако мы знаем правду... дай им волю, эта зараза будет повсюду.
Вот, в один несчастный день кто-то все же осилил стандарт целиком, а возможно наткнулся случайно на параграф
14.7.2 Explicit instantiation [temp.explicit] абзац 12:
Обычные правила проверки доступа неприменимы к именам в при явном инстанцировании.Хотя именно в этом разделе правило появилось не так давно, но и раньше его можно было найти в
Template instantiation and specialization [temp.spec].
Интересно, почему эта конструкция получила такие послабления?
Пишут, что явное инстанцирование - штука полезная и может уменьшить время компиляции. Очень может быть. Тогда почему бы закрытому члену класса не воспользоваться этим? Если бы приватность не нарушалась, то это могло бы выглядеть следующим образом:
template <class T>
struct CoolFeature {
};
class Something {
int32_t value {40};
template struct CoolFeature<&Something::value>;
};
Однако такое описание некорректно. Возможно, причина в том, что стандарт настоятельно рекомендует использовать конкретное явное инстанцирование единственный раз в программе (14.7 Template instantiation and specialization [temp.spec]), поэтому размещать его в заголовочных файлах не самая лучшая идея, не говоря уже об описаниях класса.
Исходя из каких-то таких соображений и пришлось смиренно приоткрыть доступ к приватным переменным только для явного инстанцирования.
Вы же понимаете, чем это грозит?
Это огромная дыра, через которую криминальный ум сразу же утащит все ваши потаенные члены класса.
Например, имеется:
class Secret {
int32_t id {0x12345678};
int32_t code {100};
};
Сделаем специальный класс для кражи данных
template <int ID, class R, class C>
struct Thief {
using member = R C::*;
inline static member private_member {};
};
Выглядит невинно аки овечка: всего лишь определяем тип указателя на член класса member и объявляем статическую переменную этого типа.
Шаблонный параметр ID нужен только для придания неповторимости типу Thief, ведь для каждого члена класса Secret нужно определить свой класс.
using Secret_id = Thief<0, int32_t, Secret>;
using Secret_code = Thief<1, int32_t, Secret>;
Secret_id будем использовать для получения доступа к id, а Secret_code - для code.
Наконец, класс, который выдаст нам адреса, пароли и явки.
template <class T, T::member t>
struct Outlaw {
Outlaw(T::member private_member) { T::private_member = private_member; }
inline static Outlaw instance {t};
};
Этот класс свяжет член класса Secret и статический член класса Thief, через статический же объект instance. Очень удобно, что объект создастся при инстанцировании.
template struct Outlaw<Secret_id, &Secret::id>;
При явном инстанцировании Outlaw в шаблонных аргументах можно указать &Secret::id и ничего нам за это не будет. При этом вызовется конструктор Outlaw объекта instance, который перенесет указатель в статическую переменную Secret_id::private_member.
Такая вот нехитрая схема хищения, воспользуемся указателем:
std::cout << s.*Secret_id::private_member << std::endl;
Вот так можно получить доступ к приватным членам класса, и что самое страшное, без шума и пыли.446
unforgivable idioms
Из коридора послышалась знакомая тяжелая поступь. Студенты притихли. Клацающие шаги преподавателя звучали все громче, и вот дверь распахнулась от удара деревянной ноги. Людская молва прочно связывала отсутствие конечности со слишком уж глубоким погружением в темное искусство плюсов. Наконец в аудиторию ввалился и сам легендарный и пугающий Федя "Красный глаз".
Не тратя слов на приветствия, он сразу взял с места в карьер:
- Проректор по воспитательной работе считает, что вы еще недостаточно взрослые, чтоб рассказывать вам об ужасах кровавого энтерпрайза. Чушь! Лучше подготовить вас сейчас. Чтоб потом вы не остолбенели от страха, внезапно увидев нечто жуткое в продакшене! Ведь какой-нибудь питонист не станет посвящать вас в свои планы, собирать митинг, а просто воткнет вам UB в спину...
Федя поморщился, явно вспомнив что-то мерзкое, а глаз угрожающе налился кровью.
ПОСТОЯННАЯ БДИТЕЛЬНОСТЬ! Если AUTOSAR не дает играть с препроцессором, то это неспроста! Правило
A16-0-1 запрещает использовать препроцессор для чего бы то ни было кроме управляемого подключения заголовочных файлов. Иначе рано или поздно ваш класс станет жертвой Public Morozoff!
Эта древняя идиома абсолютного подчинения заставляет класс добровольно раскрыть все свои приватные члены.
#define private public
#define protected public
Пропиши их перед нужным хидером и делай с классом что хочешь! Хотя если класс выглядит вот так:
class Secret {
int32_t id {0x12345678};
int32_t code {42};
};
то придется усилить нажим:
#define class struct
С последним макросом надо быть осторожней, если когда-то это и проходило безболезненно, то сейчас не факт. Работа шаблонов может быть нарушена, например template<class T> после такого фокуса перестанет собираться.
Вы молодые, шутливые, вам все легко. Это не то. Это не просто уродливо смотрится, а еще и незаконно. Ибо стандарт в разделе 17.6.4.3.1 Macro names [macro.names], стихе втором четко говорит:
Единица трансляции не должна содержать #define или #undef имен идентичных ключевым словам языка.Однако это не единственная идиома, позволяющая добраться до ваших потаенных членов класса. Поэтому ПОСТОЯННАЯ БЛИТЕЛЬНОСТЬ! Пытка приведениями - второй непростительный прием, прямо запрещенный АUTOSAR-ом в правиле
A5-2-4: reinterpret_cast не должен применяться никогда.
Следующий код не просто причиняет боль,
Secret s {};
uint8_t *tmp = reinterpret_cast<uint8_t *>(&s);
int *code = reinterpret_cast<int32_t *>(tmp + sizeof(int32_t));
std::cout << "secret: " << *code << std::endl;
но и проворачивает объект s в байтовый фарш, а затем, зная смешение элемента code, получает доступ к полю.
Можно действовать более изящно, использовать указатель на член класса int32_t Secret::*
using member = int32_t Secret::*;
Secret s {};
uint64_t offset{4};
member m {std::bit_cast<member>(offset)};
std::cout << "secret: " << s.*m << std::endl;
Здесь нужно лишь правильно подобрать смещение и преобразовать его указатель. Смещение, например, скорее всего, измеряется в байтах от начала занимаемой объектом памяти. Забавно, но даже reinterpret_cast откажется выполнить такое преобразование, а вот bit_cast не побрезгует, у него вообще нет сострадания. Кстати, объект member, созданный по умолчанию, вовсе не содержит в себе нулевое смешение, там внутри будет -1, поэтому получить доступ к первому объекту id не получится. Класс хранит свои секретики.
Если и этот метод недостаточно болезненный, то можно переложить вычисление смещения на компилятор. Нужно лишь воссоздать теневой публичный двойник класса (хотя идентичность, понятное дело, не гарантирована).
struct SecretPub {
int32_t id;
int32_t code;
};
Тогда указатель на член класса можно получить проще.
using member = int32_t Secret::*;
Secret s {};
member m {reinterpret_cast<member>(&SecretPub::code)};
std::cout << "secret: " << s.*m << std::endl;
Разновидностей тут может быть много, даже не знаю, на какой из них больнее смотреть.
Это не все! Есть еще третья непростительная идиома, куда страшнее их всех...
A suivre.446
std::recursive_mutex & std::recursive_timed_mutex
Снился мне старый офис. Я гордо шел по коридору с теннисной ракеткой в руке. Однако отнюдь не партия в теннис предстояла мне, а заседание в ватерклозете. В бывшем заводском здании была единственная уборная на этаже, одноместная и запирающаяся на ключ. Давным-давно сумрачный начальственный гений повелел присовокупить ракетку к ключу, чтоб сей ценный артефакт не потерялся и всегда возвращался на место.
Ворвавшись наконец в комнату счастья, я запер дверь на ключ и оставил его в замке, дабы подлые негодяи из соседнего офиса не прервали бесцеремонным образом общение с белым другом. Потом я случайно выпал в открытое окно, но раньше люди были крепче, и уже через пять минут снова был у двери афедронной, с ужасом понимая, что закрыта она изнутри. "Это дедлок, - подумал я с грустью. - Лучше бы вместо теннисной ракетки с ключом в комплекте шел
std::recursive_mutex!"
Это еще одна разновидность мьютекса, специально для ситуаций, где использование обычного мьютекса приводит к взаимоблокировке, например, если удерживающая блокировку функция вызывает саму себя или иную функцию, где захватывается тот же мьютекс.
Поток, который владеет рекурсивным мьютексом, может делать lock несколько раз (но не бесконечное число, а то можно дозахватываться до std::system_error), для освобождения мьютекса нужно столько же раз сделать unlock.
На наше счастье FreeRTOS имеет в загашнике и такую функциональность. Принцип работы с рекурсивным мьютексом здесь почти не отличается от обычного, нужно только переопределить соответствующие функции. Создание мьютекса тривиально:
inline void __gthread_recursive_mutex_init_func(__gthread_recursive_mutex_t *__mutex) {
__mutex->mutex = xSemaphoreCreateRecursiveMutexStatic(&__mutex->mutexBuffer);
}
Захват мьютекса нужно делать через xSemaphoreTakeRecursive, со значением portMAX_DELAY на месте таймаута:
inline int __gthread_recursive_mutex_lock(__gthread_recursive_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreTakeRecursive(__mutex->mutex, portMAX_DELAY)};
return result == pdTRUE ? 0 : EINVAL;
}
Попытка захвата:
inline int __gthread_recursive_mutex_trylock (__gthread_recursive_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreTakeRecursive(__mutex->mutex, 0)};
return static_cast<int>(result == pdTRUE);
}
Освобождение:
inline int __gthread_recursive_mutex_unlock (__gthread_recursive_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreGiveRecursive(__mutex->mutex)};
return result == pdTRUE ? 0 : EINVAL;
}
И тотальное уничтожение рекурсивного мьютекса:
inline int __gthread_recursive_mutex_destroy(__gthread_recursive_mutex_t *__mutex) {
vSemaphoreDelete(__mutex->mutex);
return 0;
}
Если есть два типа мьютексов std::timed_mutex и std::recursive_mutex, то появляется естественное желание скрестить их и посмотреть, что выйдет! В этот раз из разработчиков вышел примитив синхронизации - std::recursive_timed_mutex.
Фактически это рекурсивный мьютекс, для привязки ко времени нужно лишь реализовать одну специальную функцию:
inline int __gthread_recursive_mutex_timedlock (__gthread_recursive_mutex_t *__mutex,
const __gthread_time_t *__abs_timeout) {
__gthread_time_t rtime {time_correction(__abs_timeout)};
TickType_t const ms {
static_cast<TickType_t>(rtime.tv_sec * 1000U) +
static_cast<TickType_t>(rtime.tv_nsec / 1000000U)};
BaseType_t result {xSemaphoreTakeRecursive(__mutex->mutex, ms)};
return result == pdTRUE ? 0 : EINVAL;
}
Кажется, все. У нас есть полный набор мьютексов во FreeRTOS, с таким комплектом никаких ракеток не надо.446
_gettimeofday
Последнюю неделю мне не давал покоя вопрос, является ли желание демонстрировать свой код незнакомым лицам в публичных местах проявлением девиантного поведения и можно ли это монетизировать? За разъяснением пришлось обратиться в некое лечебное заведение. Когда меня привезли, случайно подслушал разговор врача с пациентом. Просто если припасть ухом к замочной скважине, то слышимость изумительная.
- Доктор, я вот когда топором махаю долго, то запястья очень болят.
- Вы лесорубом работаете?
- Я тимлид...
- Ага. Это оттого, что "долго" - понятие относительное. Нужно знать точно! А точная работа со временем потребует тщательной подготовки. Можно добавить в код
system_clock::now() или timed_mutex, но нужно переопределить вызов _gettimeofday!
Иначе сборка страшно ругается:
warning: _gettimeofday is not implemented and will always failФункция
_gettimeofday важна. Вообще, в этих ваших линуксах существует системный вызов gettimeofday, который возвращает текущее время в секундах и микросекундах прошедших с начала эпохи Unix. В нашем проекте GCC везде для получения информации о времени использует эту функцию, будь то функция time(), либо system_clock::now().
int _gettimeofday(struct timeval *tv, void *tzvp);
Первый параметр - это указатель на структуру struct timeval.
struct timeval {
time_t tv_sec;
suseconds_t tv_usec;
};
Второй аргумент - это, на самом деле, указатель на структуру struct timezone.
struct timezone {
int tz_minuteswest; // minutes west of Greenwich
int tz_dsttime; // type of DST correction
};
Возвращает же функция ноль в случае успеха и -1 при любом другом исходе.
Работать с разными часовыми поясами нужно, но мне пока неохота, да и запястья болят. А вот первый аргумент вызывает чувство дежавю, поскольку нечто такое я уже делал, только для прошивки на IAR. Там краеугольным камнем работы со временем была функция clock(). Однако это было давно и неправда, теперь рулит _gettimeofday. Свяжем в ней аппаратную часть, т.е. RTC с нашей программной системой. Через HAL получим значения RTC_DateTypeDef и RTC_TimeTypeDef, преобразуем результат в максимально похожую структуру tm, которую уже без труда преобразуем в time_t.
Если особо не принюхиваться к коду, то реализовать функцию можно довольно быстро:
extern "C"
int _gettimeofday(struct timeval *tv, void *) {
if (tv) {
RTC_DateTypeDef rtcDate {};
RTC_TimeTypeDef rtcTime {};
HAL_RTC_GetTime(&hrtc, &rtcTime, RTC_FORMAT_BIN);
HAL_RTC_GetDate(&hrtc, &rtcDate, RTC_FORMAT_BIN);
struct tm c_time{};
c_time.tm_year = 125 + rtcDate.Year; // since 2025
c_time.tm_mon = rtcDate.Month - 1; // tm_mon[0, 11] <?> Month[1, 12]
c_time.tm_mday = rtcDate.Date; // tm_mday[1, 31] <?> Date[1, 31]
c_time.tm_wday = rtcDate.WeekDay - 1; // tm_wday[0, 6] <?> WeekDay[1, 7]
c_time.tm_hour = rtcTime.Hours; // tm_hour[0, 23] <?> Hours[0, 23]
c_time.tm_min = rtcTime.Minutes; // tm_min[0, 59] <?> Minutes[0, 59]
c_time.tm_sec = rtcTime.Seconds; // tm_sec[0, 60] <?> Seconds[0, 59]
tv->tv_sec = mktime(&c_time);
tv->tv_usec = ((rtcTime.SecondFraction - rtcTime.SubSeconds) * 1000000) / (rtcTime.SecondFraction+1);
}
return 0;
}
Главное - помнить, что диапазоны HAL-структур отличаются от допустимых значений struct tm. Например, диапазон tm_year начинается от 0, который на самом деле представляет 1900 год, и до максимального значения переменной int, диапазон же Year зажат в пределах от 0 о 99 лет. Поэтому мы задаем смещение 125 лет, чтоб начать с 2025 года и протянуть аж до 2124 года, когда наконец-то подвезут мое новое кибернетическое тело...
Или вот месяц tm считает с нуля, как всякий нормальный человек. В RTC Month же начинает с единицы, потакая всяким нубам, которые искренне полагают январь первым месяцем. Насчет високосной секунды можно особо не переживать, а просто выставить таймер и вперед, ломать дрова!446
dude shall not live by std::mutex alone
Если долго сидеть на embedded проектах, то рано или поздно придется столкнуться с темпоральным аспектом системного бытия. Время - предмет сложный, заголовочный файл
chrono тому подтверждение. Более того, разработчику обычно не хватает выразительности языка и стандартной библиотеки - и вот в проекте уже десять классов для описания временных моментов и отрезков. Сейчас же я вспомнил об этом, поскольку пришло время вернуться и реализовать застабленные ранее функции через слезы, боль и отвращение.
Некоторое время назад мы успешно реализовали std::mutex в системе FreeRTOS, используя для этого исключительно готовые местные примитивы.
Однако стоит только приоткрыть дверь базовому синхронизирующему примитиву, как в образовавшуюся щель тут же ломится вся его родня.
Первым так и просится на вивисекцию класс std::timed_mutex. Собственно, разница между обычным мьютексом и временным невелика, примерно как между суси и роллом: "та же херь, - там только присыпочка из икры на роллах..." Они оба приватно наследуются от __mutex_base, ибо стыдятся своего родства и не желают открыто заявлять об этом.
Класс обладает стандартным набором функций-членов: lock и unlock, try_lock. Но есть нюанс! timed_mutex может попробовать захватить мьютекс в течение некоторого времени. Отвечают за это методы try_lock_for() и try_lock_until().
Где-то была похожая функциональность... точно! Обычная функция захвата во FreeRTOS .
Обратим внимание на сигнатуру
BaseType_t xSemaphoreTake( SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait );
Функция xSemaphoreTake, она же xQueueSemaphoreTake возвращает pdTRUE в случае успеха или что-то иное, если захват не удался.
Первый аргумент xSemaphore - дескриптор семафора типа SemaphoreHandle_t, он же QueueHandle_t.
Второй аргумент - это время в неких системных или нервных тиках, в течение которого функция ждет.
Собственно, мы уже использовали эту же функцию в __gthread_mutex_lock, только влепили вместо таймаута значение portMAX_DELAY, чтоб ждала вечно, как Хатико.
Вариантов реализации в GCC есть несколько, условимся, что по умолчанию у нас определен макрос _GTHREAD_USE_MUTEX_TIMEDLOCK и не определен _GLIBCXX_USE_PTHREAD_MUTEX_CLOCKLOCK.
В этом случае занырнем поглубже в метод try_lock_until.
template<class Clock, class Duration>
bool try_lock_until(const std::chrono::time_point<Clock, Duration>& timeout_time);
Если захват и освобождение мьютекса осуществляется через знакомую нам пару функций __gthread_mutex_lock/unlock, то новые возможности требуют новой функции. Метод try_lock_until в итоге вызовет функцию __gthread_mutex_timedlock, которую реализуем, например так:
int __gthread_mutex_timedlock(__gthread_mutex_t *__mutex,
const __gthread_time_t *__abs_timeout) {
__gthread_time_t rtime {time_correction(__abs_timeout)};
TickType_t const ms {rtime.tv_sec * 1000U + rtime.tv_nsec / 1000000U};
BaseType_t result {xSemaphoreTake(__mutex->mutex, ms)};
return result == pdTRUE ? 0 : EINVAL;
}
где __mutex - это указатель на дескриптор мьтекса типа __gthread_mutex_t, который мы уже состряпали,
а __abs_timeout - значение времени, до которого нужно ждать. Здесь и спрятался рогатый.
Во-первых, этот тип еще надо определить.
using __gthread_time_t = struct timespec;
Почему timespec? На самом деле вариантов немного, поскольку создается переменная перед вызовом __gthread_mutex_timedlock весьма однозначно.
__gthread_time_t __ts = {
static_cast<std::time_t>(__s.time_since_epoch().count()),
static_cast<long>(__ns.count())
};
Во-вторых, важно понимать, что это абсолютное значение времени, грубо говоря. Поэтому нужно преобразовать его в значение относительного времени, иначе xSemaphoreTake будет ждать очень и очень долго. Для этого мы вызываем функцию time_correction, где получим текущее значение времени и вычтем его из __abs_timeout.
Да, чуть не забыл про try_lock_for. Иронично, но новые методы используют одну __gthread_mutex_timedlock, более того try_lock_for вообще реализуется через try_lock_until. Такие дела. Универсальные решения требуют жертв, товарищ!446
deleting destructor. endgame
Я незаметно подергал ручку и убедился, что дверь заблокирована. В машине повисло неловкое молчание.
- Ну, как можно исправить ситуацию с невиртуальным базовым классом... - сказал я, чтоб только разрядить гнетущую атмосферу, - не работать с такими библиотеками, да и все!
Подозрительный шофер лишь приподнял бровь и продолжал молчать. Не перенеся такого чудовищного давления, я начал вслух размышлять.
Первое, что приходит в голову, метод грубой силы. Для нашего случая изготовим примерную структуру виртуализации объекта.
using dtor = void(*)(Base *);
struct Vtable {
dtor func[10];
};
struct BaseAlt {
Vtable *vt;
};
Альтернативный базовый класс будет содержать в себе указатель на самодельную виртуальную таблицу Vtable, а таблица пусть сплошь состоит из указателей на функции. Это должны быть указатели на виртуальные методы, но нас интересует только деструкторы, поэтому это будет массив из dtor.
Base *a = new A;
BaseAlt *brut = reinterpret_cast<BaseAlt*>(a);
brut->vt->func[2](a);
Приводим указатель a к BaseAlt* и наудачу вызываем третью функцию в псевдовиртуальной таблице. В этот раз нам повезло, и удаляющий деструктор оказался именно там. Однако гарантии никто не даст, мало ли что там компилятор накомпилировал, поэтому метод не очень хорош.
На выражение лица хмыря за рулем было страшно смотреть, будто я вскрывал банку сюрстремминга прямо в салоне. Пришлось срочно развить мысль.
Можно изготовить ложный базовый класс с предполагаемым набором виртуальных методов.
struct FakeBase {
virtual void get() {}
virtual ~FakeBase() {}
};
Поскольку реальный класс Base невиртуальный, метод get пойдет первым в классе A. Из-за этого печального обстоятельства пришлось поместить метод с такой же сигнатурой в ложном базовом классе перед деструктором. Тогда GCC выстроит правильную виртуальную таблицу, которая будет совпадать с таблицей для A. Но это неточно.
FakeBase *fb = reinterpret_cast<FakeBase*>(a);
delete fb;
Опять же, если все подогнано правильно, вызовется правильный деструктор.
Злодей, одним глазом продолжая следить за дорогой, вторым посматривал на меня с беспокойством. Да чего ему еще нужно?
Если подумать, у какого класса виртуальная таблица будет точно совпадать с таблицей класса-наследника? Конечно, у него самого!
Предположим, что в момент создания объекта мы точно знаем его тип. Тогда возьмем умный указатель std::unique_ptr с пользовательским чистильщиком (deleter).
struct Deleter {
void operator ()(Base *p) { del_(p); }
template <class T>
static Deleter Make() { return {.del_ = [](Base *p){
delete reinterpret_cast<T*>(p);
}}; }
std::function<void(Base *)> del_;
};
Вот такой, собранный на скорую руку, вполне сгодится для иллюстрации принципа работы. Когда мы будем создавать умный указатель, позовем и функцию Make с шаблонным параметром, который однозначно определит тип наследника. Тогда внутри функции создается и сохраняется в переменной del_ простейшая лямбда-функция, которая приводит указатель к правильному типу и удаляет объект не через Base*, а как A*. Придет время удалять объект, будет вызван operator(), там мы и задействуем del_.
std::unique_ptr<Base, Deleter> a {new A, Deleter::Make<A>()};
В результате удаления умного указатель объект будет уничтожен правильно.
Автомобиль резко остановился. Глухомань, лишь высокие сосны обступали дорогу.
- Скажу как есть, я не таксист, - вдруг сказал водитель, - я эйчар компании "Ведро", а это наш новый формат собеседования.
"Какой токсичный", - только и успел подумать я.
- По технической части у нас положительная обратная связь. Вернусь за вами совсем скоро.
Дверь открылась, я вылетел на обочину, машина дала по газам, обдав на прощание облаком пыли.446
deleting destructor
- Зачем мне ваше интервью? - звонок очередного рекрутера застал меня в такси.
- Ваш пост про нас был весьма нелицеприятным, но теперь у нас все по-другому! - не сдавался голос в трубке.
- Не верю! - отрезал я и поспешно надавил на большую красную кнопку на экране смартфона.
В этот момент водитель сочувственно хмыкнул:
- Ох уж эти эйчары, спасу от них нет! Мне вот то и дело трезвонят, вся телегу загадили, а почту вообще не открываю, страшно.
Поскольку я молчал, хоть и открыл рот, таксист тут же пояснил:
- Да я на самом деле тоже разработчик, плюсовик, а таксую для души.
- Вот оно что! Коллега, значит, - вежливо улыбнулся я.
- О, тоже кресты?! Плюсики - пропуск в трусики! - собеседник коротко хохотнул, и слова хлынули из него, будто бурный поток по Ковалихинской улице в дождь, - вот третьего дня прихожу я на собес, а там дед сидит - вылитый профессор. Сначала про миссию компании нудел, аж разморило меня. Нарочно усыплял! Потом как спросит, мол, почему наследники страдают, если у базового класса деструктор невиртуальный? Коварный тип, да?
Я, погруженный в чтение бесконечного рабочего чата, рассеянно ответил, что это как раз несложный вопрос. Просто представьте, что есть нормальный базовый класс с наследником:
struct Base {
virtual ~Base() {}
virtual void get() {}
};
struct A : Base {
virtual ~A() {}
};
Тогда GCC создаст для них виртуальные таблицы vtable.
vtable для класса Base:
Base::~Base() [complete object destructor]
Base::~Base() [deleting destructor]
Base::get()
vtable для класса A:
A::~A() [complete object destructor]
A::~A() [deleting destructor]
Base::get()
Что характерно, виртуальные функции у наследника идут в таком же порядке, что и у базового класса. Это важный момент для всей этой виртуальной механики.
Допустим, сначала мы создаем экземпляр класса:
Base *a = new A;и потом удаляем его:
delete a;Поскольку у указателя
a тип Base*, то мы находим по таблице Base::~Base() [deleting destructor] и вызываем этот деструктор. Однако на самом деле, там виртуальная таблица для класса A, и будет вызван именно деструктор A::~A() [deleting destructor].
Удаляющий же деструктор вызовет A::~A() [complete object destructor], внутри которого напрямую вызовется Base::~Base() [complete object destructor], а потом очищается память через оператор delete.
Вот если удалить виртуальный деструктор базового класса, то и таблицы кардинально поменяются.
vtable для класса Base: Base::get() vtable для класса A: Base::get() A::~A() [complete object destructor] A::~A() [deleting destructor]Теперь, когда мы попытаемся удалить объект типа
A по указателю типа Base*, смотреть виртуальную таблицу бессмысленно - там нет деструкторов. Поэтому вызван будет без промедления Base::~Base() [complete object destructor] и очищена память. Деструктор для A остался не у дел, поэтому это плохая практика.
Таксисит слушал меня, задумчиво кивая, и, кажется, пару раз проехал на красный.
- Ладно, - рубанул он рукой воздух, - допустим, мы используем библиотеку, где автор обтриводномился и оставил базовый класс без виртуального деструктора. Что же теперь, помирать? Нам до зарезу нужна эта библиотека! Что можно сделать? Только, чур, базовый класс не менять!
- Легко! Тысячу раз так делал. - с легким зевком ответил я, а про себя подумал: "Зачем я соврал? Я ж не делал... А зачем он спросил? Зубы заговаривает. Очень подозрительный тип! Почему он свернул?! Куда мы вообще катимся?!"446
std::mutex
В выходные случайно сел в поезд и внезапно приехал в Сергиев Посад на большое совещание молодых литераторов "Посадский Экспресс". Видел, как толпы писателей и поэтов осаждают небольшое здание центральной библиотеки. Семинаров и мастер-классов было в избытке, народу еще больше! Не представляю, как организаторы обуздывали все эти бесконечные людские потоки. Свои же потоки мы будем разруливать старыми добрыми примитивами синхронизации. Мьютексом, почему нет.
Вглядимся же в глубину
std::mutex:
class mutex : private __mutex_base ...
Погружаемся еще глубже:
class __mutex_base {
protected:
typedef __gthread_mutex_t __native_type;
__native_type _M_mutex;
Вот и центральный элемент базового класса _M_mutex. Только вот, что за тип такой __gthread_mutex_t?
Зависит от системы. Под FreeRTOS нужно бы самим определить. Фокус в том, что если _M_mutex можно инициализировать так
__native_type _M_mutex = __GTHREAD_MUTEX_INIT;
достаточно определить __GTHREAD_MUTEX_INIT.
Если же тип нетривиальный, то имеет смысл определить макрос __GTHREAD_MUTEX_INIT_FUNCTION.
Тогда конструктор будет чуть сложнее, но и гибче:
__mutex_base() noexcept {
__GTHREAD_MUTEX_INIT_FUNCTION(&_M_mutex);
}
Конечно, не все так просто. В нашем случае все эти "настройки" gthread компилятор уже возьмет из файла gthr-default.h, после чего переопределять их будет бесполезно. Изолируем этот файл, добавив в сборку -D_GLIBCXX_GCC_GTHR_SINGLE_H. Злоупотребим, так сказать, защитой от повторного включения.
Вместо этого файла мы навяжем компилятору другой: -include cpp_freertos\gthr-user.h
В файле gthr-user.h содержатся уже наши макросы и определения для работы с примитивами.
В файле обязательно должно быть определение типа __gthread_mutex_t:
typedef struct gthreadMutexType {
StaticSemaphore_t mutexBuffer;
SemaphoreHandle_t mutex;
} __gthread_mutex_t;
Дескриптор мьютекса во FreeRTOS имеет тип SemaphoreHandle_t, и рядом еще положим буфер типа StaticSemaphore_t, чтоб не пришлось создавать его динамически.
Теперь нужно заняться функцией инициализации
#define __GTHREAD_MUTEX_INIT_FUNCTION __gthread_mutex_init_func
Непосредственно сама функция
inline void __gthread_mutex_init_func(__gthread_mutex_t *__mutex) {
__mutex->mutex = xSemaphoreCreateMutexStatic(&__mutex->mutexBuffer);
}
Инициализация приходит через функцию xSemaphoreCreateMutexStatic, которая возвращает дескриптор мьютекса.
Для деструктора
~__mutex_base() noexcept { __gthread_mutex_destroy(&_M_mutex); }
нам просто необходимо определить еще функцию __gthread_mutex_destroy:
inline int __gthread_mutex_destroy(__gthread_mutex_t *__mutex) {
vSemaphoreDelete(__mutex->mutex);
return 0;
}
Почти готово, остались только самые важные для нормальной работы функции lock/unlock, которые прежде мы безжалостно застабили.
Функция блокировки мьютекса самая требовательная, если она вернет что-то отличное от нуля, то будет брошено исключение. Реализуем ее через вызов xSemaphoreTake
inline int __gthread_mutex_lock(__gthread_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreTake(__mutex->mutex, portMAX_DELAY)};
return result == pdTRUE ? 0 : EINVAL;
}
Если INCLUDE_vTaskSuspend включен, то передача portMAX_DELAY в качестве значения второго аргумента (таймаут) приведет к тому, что задача будет заблокирована на неопределенный срок.
Для попытки блокирования нужно вызвать xSemaphoreTake с нулевым таймаутом.
inline int __gthread_mutex_trylock(__gthread_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreTake(__mutex->mutex, 0)};
return static_cast<int>(result == pdTRUE);
}
Ну и __gthread_mutex_unlock будет реализован через xSemaphoreGive
inline int __gthread_mutex_unlock(__gthread_mutex_t *__mutex) {
BaseType_t const result {xSemaphoreGive(__mutex->mutex)};
return result == pdTRUE ? 0 : EINVAL;
}
Немного усилий и std::mutex готов! Однако расслабляться рано, всюду по городу шныряют поэты, а в окна заглядывают уже и другие примитивы синхронизации.446
std::thread is well when ends well
Давным-давно одна высокоразвитая цивилизация задумала повернуть сибирские реки вспять. Вот где был размах, вот где сила ума! Доведем же дело наших славных предков до конца. В некотором смысле. Пусть нам и не под силу развернуть реки, но хотя бы научимся останавливать потоки. И не важно, что FreeRTOS считает это отступничеством и ересью.
Если задача не зациклена, то рано или поздно ее тушка промчится мимо нас вверх по течению реки, задорно помахивая оператором возврата. Важно не упустить этот момент и выловить озорницу функцией
join. Как вы помните, этот метод класса std::thread блокирует вызывающий поток до тех пор, пока текущий не закончит работу.
Сложность в том, что сейчас такой функции у нас нет. Для ее реализации нужно как-то научиться определять завершение задачи во FreeRTOS.
Представьте, что мы медленно-медленно выходим из функции-задачи... и тут же попадаем в ловушку assert-а внутри prvTaskExitError. Вообще-то, мы не должны были туда заходить, это индикатор того, что задача исполняет странное.
Что же, не зря включали опцию INCLUDE_vTaskDelay (как дефайн в FreeRTOSConfig.h) и прописывали удаление таски после выхода из целевой функции.
static void execute_native_thread_routine(void* __p) {
thread::_State_ptr __t{ static_cast<thread::_State*>(__p) };
__t->_M_run();
vTaskDelete(NULL); // Важно удалить текущую таску!
}
Тут задача не превращается в зомби, но функция vTaskDelete удаляет ее из списка живых: готовых\заблокированных\приторможенных задач и торжественно включает в проскрипционный. Специальная Idle-таска позаботится о том, чтоб вернуть выделенные на задачу ресурсы, прежде чем она исчезнет навсегда.
Включим еще опцию INCLUDE_eTaskGetState, тогда нам будем доступна еще и функция eTaskGetState, которая выдает текущий статус таски.
Для задачи, найденной в списке на удаление, конечно, вернется статус eDeleted. Однако такой же статус вернется и для объекта с полностью уничтоженным TCB. Дескриптора нет в списках? Ну, задача точно удалена.
Дело в шляпе, нам нужно только узнать дескриптор задачи. Хорошо, что он предусмотрительно записан в переменной класса _M_thread: _M_id._M_thread.taskID.
Набросаем простую версию функции join:
void thread::join() {
if (_M_id != id{}) {
eTaskState state {};
while((state = eTaskGetState(_M_id._M_thread.taskID)) != eDeleted) {
switch(state) {
case eInvalid:
__throw_system_error(eInvalid);
break;
default:
taskYIELD();
}
}
this->_M_id = id{};
}
}
Вначале проверим, возможно, мы уже обнаружили завершение задачи ранее, если _M_id пустой, то и делать ничего не надо. Если нет, то будем в цикле проверять статус задачи, до тех пор, пока он не станет eDeleted. Статус eInvalid не должен вернуться, но если вдруг получили, то у нас очень большие проблемы, бросаем исключение. В остальных случаях просто передаем управление другим потокам вызовом taskYIELD.
Осталось только ненавязчиво дать потоку знать, чтоб закруглялся, ведь мы готовы его подхватить. Раньше такое реализовывали кто во что горазд, атомарными флагами, volatile переменными, иными перверсиями. Наконец, в с++20 специально для этих целей появилось стандартизированное извращение - объект std::stop_source.
Через stop_source можно запросить остановку потока, которому был выдан связанный std::stop_token.
void firstTask(std::stop_token stop_token, std::mutex &mtx, Data &data) {
while (!stop_token.stop_requested()) {
std::lock_guard<std::mutex> lock{mtx};
osDelay(10);
}
}
Тогда в главном потоке завершение работы можно прописать так:
std::stop_source stop{};
std::thread first {firstTask, stop.get_token(), std::ref(mtx), std::ref(data)};
// делаем что-то полезное
stop.request_stop();
first.join();
Запрашиваем выход и ждем остановки. Все четко, как в маршрутном такси.446
std::thread memory corruption
Вот и пришло время обрушиться на слабосильный микроконтроллер всей тяжестью наших самописных потоков!
Точка входа будет в функции
MainThread. Бросим туда щепотку потоков:
void MainThread() {
Data data {};
std::mutex mtx {};
std::stop_source stop{std::nostopstate};
std::thread first {startStdTask, stop.get_token(), std::ref(mtx), std::ref(data)};
std::thread second {secondThread, stop.get_token(), std::ref(mtx), std::ref(data)};
while ( 1 ) { osDelay(100); }
}
Предположим, нужно запустить два потока, работающие с общими данными data и синхронизированные mtx, при этом есть stop, выдающий потоку запрос на остановку. После запуска бесконечно сидим в главном потоке, поскольку ждать завершения мы пока не умеем.
В предвкушении жму кнопку отладки...
...и постигаю чувство полнейшего разочарования: задачи не переключаются, планировщик явно приболел. Даю последний золотой зуб соседа, память повредилась! Подозрение пало на реализацию std::thread, поскольку там есть явное динамическое выделение памяти:
_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(std::forward<_Callable>(__f), std::forward<_Args>(__args)...)), __depend);
Какой бесстыдный вызов оператора new. Хоть бы аллокатором прикрылся.
Проверим, могло ли это вывести из строя систему. Нырнем поглубже и взглянем, как работает распределение памяти во FreeRTOS и его окрестностях.
У нас много памяти, аж два раздела RAM:
RAM 0x20000000:0x00028000 RAM2 0x10000000:0x00008000Система, по сути, использует только первый диапазон
0x20000000:0x20028000.
По адресу 0x20000000 ютится небольшая секция .data в 120 байт размером. Ниже вольготно расположилась секция .bss исполинского размера: 20344 байта! Зададимся вопросом, достойным хлесткой пощечины, почему ее так разнесло? Львиную долю памяти занял некий ucHeap. Однако под личиной скромного массива скрывается зарезервированная область для управления памятью FreeRTOS.
Здесь, в общей куче, выделяется место и под стек тасков, и под TCB (Task Control Block), и даже под память, распределенную через pvPortMalloc().
В лучших традициях размер кучи задается дефайном
#define configTOTAL_HEAP_SIZE ((size_t)16384)
Замыкает парад секция ._user_heap_stack. Сложно сказать, какого она на самом деле размера, поскольку граничит с terra incognita, ничейной памятью.
FreeRTOS этой областью уже не владеет. Распоряжаются ей вызовы malloc/free. Динамическое выделение памяти начнется сверху, с адреса 0x20004ff0 в моем случае, и пойдет по наклонной в сторону увеличения адреса. Общий стек будет подниматься с самых низов, с адреса 0x20028000, мы видели его в регистре SP при старте.
Получается, что области динамического распределения памяти не пересекаются и портить друг другу данные не должны.
В этот момент я вспомнил слова бывшего беззаботного коллеги, ныне безработного, Олафа: "Если происходит какая-то дичь в прошивке, я просто увеличиваю размер стека".
Вообще-то это имеет смысл, локальных переменных у нас много. Стек небольшой, всего 512 байт. Мониторим память, действительно, вызовы функций пробивают стек. Граница по адресу 0x20000c30 пройдена очередным вызовом функции и затерта важная переменная uxSchedulerSuspended по адресу 0x20000c24, из-за чего планировщик встает колом.
Для контроля в системе есть функция uxTaskGetStackHighWaterMark, но метод не слишком надежный. Стек изначально заполняется паттерном 0xA5, потом в процессе жизнедеятельности потока ячейки перетираются данными, а функция подсчитывает сколько слов в памяти не подверглись модификации. Если таких ячеек в стеке осталось мало, нет гарантии, что какая-нибудь функция не перескочит их noinit данными. Тогда uxTaskGetStackHighWaterMark хоть и будет возвращать некое небольшое число, но стек будет нарушен. Кто-то писал, что возвращаемое значение меньше 50 слов - уже тревожный показатель, и тут сложно не согласиться.
Увеличим стек в несколько раз и возрадуемся бегущим потокам. Только как их теперь остановить?