Русский
Русский
English
Статистика
Реклама

C++

Перевод Почему я всё ещё люблю C, но при этом терпеть не могу C?

19.06.2021 18:15:23 | Автор: admin
Мне на удивление часто приходится говорить о том, почему мне всё ещё нравится язык C, и о том, почему я плохо отношусь к C++. Поэтому я решил, что мне стоит об этом написать, а не снова и снова повторять одно и то же.



Как это обычно бывает у C-программистов, язык C не был ни моим первым языком, ни языком, после которого я уже не изучал ничего другого. Но мне всё ещё нравится этот язык, и когда мне нужно писать программы я выбираю именно его. Правда, в то же время, я стараюсь быть в курсе того, что происходит в мире современных (и не очень) языков программирования. Я слежу за тенденциями в этой сфере и пишу собственный хобби-проект, связанный с мультимедийными технологиями, на Rust. Почему же я до сих пор не поменял C на что-то более современное? И при чём тут C++?

Почему C это не самый лучший язык программирования?


Сразу скажу то, что, пожалуй, и так все знают: нет такого понятия, как самый лучший язык программирования. С каждым языком связан набор задач, для решения которых он подходит лучше всего. Например, хотя и можно заниматься трассировкой лучей в Excel, применяя VBA, лучше будет делать это с использованием более подходящего языка. Поэтому полезно знать об ограничениях языков программирования чтобы не жаловаться на то, что веб-серверы не пишут на Fortran, и на то, что почти нигде Perl или C++ не используется в роли внутренних скриптовых языков. C может считаться не очень хорошим языком по причинам, которые я перечислю ниже (это помимо того, что язык этот просто очень старый и того, что его нельзя назвать активно развивающимся языком, но это дело вкуса).

В синтаксисе C имеются неоднозначные конструкции (например, символ * может играть роль обычного оператора умножения, может применяться в виде унарного оператора разыменования, или может использоваться при объявлении указателей; а радости работы с typedef достойны отдельной статьи).

Этот язык не является безопасным. Например в C-программах довольно часто встречаются ошибки, связанные с обращением к элементам массивов, которые находятся за пределами границ массивов. В языке нет проверок на ошибки такого рода, производимых во время выполнения программы. А вот, например, в Borland Pascal, не говоря уже о более современных языках, такие проверки есть (это плюс, даже если подобные возможности можно, пользуясь параметрами компиляции, отключить ради повышения производительности). А использование в языке указателей ещё сильнее усложняет задачу программиста по поддержанию кода в хорошем состоянии. И, кроме того, C имеет и некоторые другие неприятные особенности, вроде возможности вызова функции без объявления прототипа, в результате чего функции легко можно передать аргумент неправильного типа.

У C довольно-таки скромная стандартная библиотека. В некоторых других языках программирования могут даже иметься стандартные веб-серверы (или, как минимум, всё, что нужно для создания таких серверов), а в стандартной библиотеке C нет даже описаний контейнеров.

Почему я, несмотря на все недостатки C, пользуясь именно этим языком?


Причина, по которой мне всё ещё нравится C, заключается в том, что это простой язык. Простой в смысле того, что он легко позволяет мне описывать имеющиеся у меня идеи, и в смысле того, что я знаю о том, чего можно от него ожидать.

Например, если нужно получить значение элемента массива, имея два смещения, одно из которых может быть отрицательным числом, то при программировании на C можно воспользоваться такой конструкцией: arr[off1 + off2]. А при использовании Rust это уже будет arr[((off1 as isize) + off2) as usize]. C-циклы часто короче, чем идиоматичные механизмы Rust, использование которых предусматривает комбинирование итераторов (конечно, обычными циклами можно пользоваться и в Rust, но это не приветствуется линтером, который всегда рекомендует заменять их итераторами). И, аналогично, мощными инструментами являются memset() и memmove().

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

Если описать это всё, прибегнув к аналогии с автомобилями, то окажется, что C похож на спортивную машину с механической коробкой передач, позволяющую выжать из двигателя всё, что только возможно. Но водитель легко может повредить и коробку передач, и даже двигатель, если перемудрит со сцеплением. Да и с дороги можно вылететь, если слишком сильно надавить на педаль газа. Но при этом, в сравнении с машинами, имеющими автоматическую трансмиссию, на колёса поступает больше мощности двигателя. Водитель лучше понимает то, чего можно ждать от автомобиля. На ручной машине можно делать такие вещи, которых не сделаешь на автомате (из-за того, что водителю, чтобы сделать что-то необычное, придётся бороться с автоматизированными системами управления автомобилем).

При чём тут C++?


Если говорить о C++, то хочу сразу сказать, что я не отношусь к тем, кто ненавидит этот язык. Если вы этим языком пользуетесь и он вам нравится я ничего против этого не имею. Я не могу отрицать того, что C++, в сравнении с C, даёт нам два следующих преимущества. Во-первых это улучшение структуры программ (это поддержка пространств имён и классов; в Simula, в конце концов, есть хоть что-то хорошее). Во-вторых это концепция RAII (если описать это в двух словах, то речь идёт о наличии конструкторов для инициализации объектов при их создании и о наличии деструкторов для очистки ресурсов после уничтожения объектов; а если глубже разработать эту идею, то можно прийти к понятию времени жизни объекта из Rust). Но, в то же время, у C++ есть несколько особенностей, из-за которых мне этот язык очень и очень не нравится.

Это, прежде всего склонность языка постоянно вбирать в себя что-то новое. Если в каком-то другом языке появится какая-нибудь возможность, которая станет популярной, она окажется и в C++. В результате стандарт C++ пересматривают каждые пару лет, и при этом всякий раз в него добавляют новые возможности. Это привело к тому, что C++ представляет собой монструозный язык, которого никто не знает на 100%, язык, в котором одни возможности часто дублируют другие. И, кроме того, тут нет стандартного способа указания того, возможности из какой редакции C++ планируется использовать в коде. В Rust это поддерживается на уровне единицы компиляции. В IIRC C++ для той же цели предлагалась концепция эпох, но эта затея не удалась. И вот одно интересное наблюдение. Время от времени мне попадаются новости о том, что кто-то в одиночку (и за приемлемое время) написал рабочий компилятор C. Но я ни разу не встречал похожих новостей о компиляторе C++.

Вторая особенность C++, из-за которой мне не нравится этот язык, заключается в том, что это, на самом деле, не просто смесь нескольких языков. Это, кроме того, мета-язык, иначе говоря язык, в котором используются шаблоны. Я понимаю для чего это создано, согласен с тем, что это лучше, чем препроцессор C для типонезависимого кода. Но, на самом деле, это приводит к появлению некрасивого громоздкого кода. Это означает переход от идеи заголовочный файл содержит объявления, а компилируемый код функционал к идее заголовочный файл содержит весь код, используемый в проекте, в состав которого входит этот файл. Мне не нравится, когда код долго компилируется, а этот подход ведёт к увеличению времени компиляции проектов.

И, наконец, я мог бы вообще не обращать внимания на C++, если бы этот язык не был бы связан с C и не оказывал бы на C плохое влияние. Я не говорю о ситуации, когда, говоря о C и C++, их объединяют, упоминая как C/C++, и считая, что тот, кто знает C, знает и C++. Я имею в виду связь между языками, которая оказывает влияние и на стандарты, и на компиляторы. С одной стороны C++ основан на C, что придало C++, так сказать, хорошее начальное ускорение. А с другой стороны сейчас C++, вероятно, лучше смотрелся бы без большей части своего C-наследия. Конечно, от него пытаются избавиться, называя соответствующие конструкции устаревшими, но C-фундамент C++ никуда пока не делся. Да и будет ли популярным, скажем, некий С++24, вышедший в виде самостоятельного языка, основанного на C++21 и лишённого большей части устаревших механизмов? Не думаю.

Воздействие компиляторов C++ на C


Вышеописанная связь C и C++ приводит к тому, что к C относятся как к C++, лишённому некоторых возможностей. Печально известным примером такого восприятия C является C-компилятор Microsoft, разработчики которого не позаботились о поддержке возможностей C99 до выхода версии компилятора 2015 года (и даже тогда разработчики придерживались стратегии bug-for-bug compatibility, когда в новой реализации чего-либо воспроизводят старые известные ошибки; делалось это для того, чтобы пользователи компилятора не были бы шокированы, внезапно обнаружив, что вариадические макросы вдруг там заработали). Но тот же подход можно видеть и в стандартах, и в других компиляторах. Эти проблемы связаны друг с другом.

Принципиальная проблема тут заключается в том, что и стандарт C, и стандарт C++ создаются с учётом сведений, получаемых от разработчиков компиляторов, а это, в основном, C++-разработчики (и иногда возникает такое ощущение, что они ничего не знают о настоящем программировании, и полагают, что всё должно укладываться в их точку зрения, но это тема для отдельного разговора). Я не слежу за процессом работы над стандартом, но я почти уверен, что самые неприятные аспекты C99 и более поздних версий стандарта стали результатом воздействия разработчиков компиляторов. Причём, их видение ситуации влияет не только на C++, где в этом видении есть какой-то смысл, но воздействует и на C ради упрощения компиляторов.

Я, конечно, говорю, о неопределённом поведении, и о том, как оно рассматривается компиляторами. Эта тема стала популярным пугалом (код полагается на способ представления чисел с дополнением до двух; в результате в нём имеется неопределённое поведение и компилятор может выбросить оптимизировать целый блок кода!).

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

  • Поведение, определяемое архитектурой системы (то есть то, что зависит от архитектуры процессора). Сюда, в основном, входят особенности выполнения арифметических операций. Например, если я знаю, что целевая машина использует для представления чисел дополнение до двух (нет, это не CDC 6600), то почему компилятор (который, как представляется, тоже знает об особенностях целевой архитектуры) должен считать, что система будет вести себя иначе? Ведь тогда он сможет лучше выполнять некоторые теоретически возможные оптимизации. То же применимо к операциям побитового сдвига. Если я знаю о том, что в архитектуре x86 старшие биты, выходящие за пределы числа, отбрасываются, а на ARM отрицательный сдвиг влево это сдвиг вправо, почему я не могу воспользоваться этими знаниями, разрабатывая программу для конкретной архитектуры? В конце концов, то, что на разных платформах целые числа имеют разные размеры в байтах, считается вполне приемлемым. Компилятор, обнаружив нечто, рассчитанное на конкретную платформу, может просто выдать предупреждение о том, что код нельзя будет перенести на другую платформу, и позволить мне писать код так, как я его писал.
  • Неочевидные приёмы работы с указателями и каламбуры типизации. Такое ощущение, что плохое отношение к подобным вещам возникло лишь ради потенциальной возможности оптимизации кода компиляторами. Я согласен с тем, что использование memcpy() на перекрывающихся областях памяти может, в зависимости от реализации (в современных x86-реализациях копирование начинается с конца области) и от относительного расположения адресов, работать неправильно. Разумно будет воздержаться от подобного. А вот другие ограничения того же плана кажутся мне менее оправданными. Например запрещение одновременной работы с одной и той же областью памяти с использованием двух указателей разных типов. Я не могу представить себе проблему, возникновение которой может предотвратить наличие подобного ограничения (это не может быть проблема, связанная с выравниванием). Возможно, сделано это для того чтобы не мешать компилятору оптимизировать код. Кульминацией всего этого является невозможность преобразования, например, целых чисел в числа с плавающей запятой с использованием объединений. Об этом уже рассуждал Линус Торвальдс, поэтому я повторяться не буду. С моей точки зрения это делается либо для улучшения возможностей компиляторов по оптимизации кода, либо из-за того, что этого требует C++ для обеспечения работы системы отслеживания типов данных (чтобы нельзя было поместить экземпляр некоего класса в объединение, а потом извлечь его как экземпляр совсем другого класса; это тоже может как-то повлиять на оптимизации).
  • Поведение кода, определяемое реализацией (то есть поведение, которое не в точности отражает то, что предписано стандартом). Мой любимый пример подобной ситуации это вызов функции: в зависимости от соглашения о вызове функций и от реализации компилятора аргументы функций могут вычисляться в совершенно произвольном порядке. Поэтому результат вызова foo(*ptr++, *ptr++, *ptr++) неопределён и на подобную конструкцию не стоит полагаться даже в том случае, если программисту известна целевая архитектура, на которой будет выполняться код. Если аргументы передаются в регистрах (как в архитектуре AMD64) компилятор может вычислить значение для любого регистра, который покажется ему подходящим.
  • Полностью неопределённое поведение кода. Это тоже такой случай, когда сложно спорить со стандартом. Пожалуй, самый заметный пример такого поведения кода связан с нарушением правила, в соответствии с которым значение переменной в одном выражении можно менять лишь один раз. Вот как это выглядит в одном знаменитом примере: i++ + i++. А вот пример, который выглядит ещё страшнее: *ptr++ = *ptr++ + *ptr++е.

C++ это язык более высокого уровня, чем C. В то время, как в C++ имеется большинство возможностей C, использовать эти возможности не рекомендуется. Например, вместо прямого преобразования типов надо применять reinterpret_cast<>, а вместо указателей надо применять ссылки. От С++-программистов не ждут того, что они будут понимать низкоуровневый код так же хорошо, как C-программисты (это, конечно, лишь средняя температура по больнице, в реальности всё зависит от конкретного программиста). И всё же, из-за того, что существует очень много C++-программистов, и из-за того, что C и C++ часто воспринимают как C/C++, C-компиляторы часто расширяют в расчёте на поддержку ими C++ и переписывают на C++ для того чтобы упростить реализацию сложных конструкций. Это произошло с GCC, и того, кто начнёт отлаживать с помощью GDB С++-код, находящийся в .c-файлах, ждёт много интересного. Грустно то, что для того чтобы скомпилировать код C-компилятора нужен C++-компилятор, но, чему нельзя не радоваться, всё ещё существуют чистые C-компиляторы, вроде LCC, PCC и TCC.

Итоги


В итоге хочу сказать, что люблю язык C за то, что он занимает промежуточную позицию среди других языков программирования. Он позволяет без труда делать всякие низкоуровневые вещи, вроде манипуляций с содержимым памяти, но при этом даёт нам приятные возможности, характерные для языков высокого уровня (хотя и не мешает программисту делать то, что ему нужно). А моя стойкая неприязнь к C++ основана на архитектурных решениях, принятых в ходе развития этого языка (хотя тут скорее можно говорить не о чётко спланированном развитии языка, а о том, что нечто появляется в языке по воле случая). Не нравится мне и то, что C++ влияет на стандарт C и на компиляторы для C, что немного ухудшает тот язык, который я люблю.

Но, по крайней мере, нельзя заменить C90 на какой-нибудь C90 Special Edition и сделать вид, что C90 никогда не существовало.

Как вы относитесь к языку C?


Подробнее..

DIY регистратор молний

15.06.2021 20:16:32 | Автор: admin

Автор: Alex Wulff (из-за глюков хабраредактора не получилось оформить как перевод)

К старту курса о разработке на С++ мы перевели статью о персональном регистраторе молний небольшом устройстве, сообщающем об ударах молнии поблизости. Оно даже сообщает, на каком расстоянии ударила молния; при этом, по словам автора, собрать его самому обойдётся дешевле, чем купить в магазине. Ещё один очевидный плюс вы сможете отточить свои умения в схемотехнике.

В основу устройства положен детектор молний AS3935 с ВЧ-каналом производства DFRobot. Детектор обнаруживает электромагнитное излучение молнии и с помощью специального алгоритма преобразовывает эту информацию в информацию о расстоянии до удара.


Датчик может обнаруживать удары молнии на расстоянии до 40 км (25 миль) и определять расстояние до места удара молнии с точностью до 4 км (2,5 мили). Сам датчик довольно надёжен, но может срабатывать неверно, если устройство находится на открытом воздухе. Самодельное устройство может работать не так надёжно, как коммерческий регистратор молний.

Материалы
  • микроконтроллер-жучок (beetle) DFRobot #DFR0282. Это плата Arduino Leonardo очень малых размеров;

  • Gravity: датчик расстояния до молнии DFRobot #SEN0290;

  • зарядное устройство для литиевых аккумуляторов DFRobot #SEN0290;

  • аккумулятор LiPo, 500 мАч Amazon #B00P2XICJG;

  • пьезодинамик 5В, например Amazon #B07GJSP68S;

  • маленький скользящий переключатель;

  • монтажный провод (одно- или многожильный).

Инструменты
  • компьютер с бесплатным ПО Arduino IDE.

  • паяльник и припой;

  • пистолет для горячего клея;

  • машинка для зачистки концов провода от изоляции;

  • 3D-принтер (не обязательно).

1. Разработка схемы соединений

Схема устройства проста. Информация с датчика молнии передаётся по линиям SCL и SDA, плюс к этому одно соединение предусмотрено для звукового сигнала. Устройство питается от литий-ионного полимерного аккумулятора (LiPo), поэтому я решил встроить в схему зарядное устройство для такой батареи.

Рисунок AРисунок A

Схема устройства показана на рисунке A. Обратите внимание, что аккумуляторная батарея LiPo соединяется с зарядным устройством через штекерно-гнездовые разъёмы JST и не требует пайки.

2. Сборка схемы

Для сборки устройства лучше всего применить так называемую технику свободной сборки. Детали не крепятся к подложке (например к перфорированной плате) а просто соединяются проводами (рис. Б). Так устройство собирается гораздо быстрее и получается меньше по размеру. Правда, страдает эстетика... Но сомнительную эстетику никто не увидит, если сборку закрыть напечатанным на 3D-принтере корпусом. На видео выше показано, как я собрал схему методом свободной сборки.

Подсоедините жучок к зарядному устройству

Отпаяйте зелёные клеммы от зарядного устройства LiPo. Они бесполезны, но занимают пространство. Соедините положительную (+) и отрицательную (-) клеммы зарядного устройства аккумуляторной батареи LiPo с положительной (+) и отрицательной () клеммами на лицевой части жучка. По этим проводам первичное напряжение батареи LiPo будет подаваться непосредственно на микроконтроллер. Технически жучку требуется 5В, но от напряжения 4В батареи LiPo он всё равно будет работать.

Подключение датчика молнии

Обрежьте входящий в комплект четырёхконтактный кабель так, чтобы от провода осталось примерно 5 см. Зачистите концы и подключите кабель к датчику молнии, выполнив следующие соединения:

  • положительную (+) клемму на датчике молнии соедините с положительной (+) клеммой на жучке;

  • отрицательную () клемму на датчике молнии соедините с отрицательной (-) клеммой на жучке;

  • контакт синхронизации (C) на датчике молнии соедините с колодкой SCL на жучке;

  • контакт данных (D) на датчике молнии соедините с колодкой SDA на жучке.

Контакт IRQ на датчике молнии также должен быть соединён с колодкой RX на жучке. Соединение должно подходить к аппаратному прерывателю на жучке; колодка RX (контакт 0) единственный оставшийся контакт, поддерживающий прерывание.

Подключение зуммера

Подключите короткий провод зуммера к отрицательной () клемме на жучке (земля), а длинный провод к контакту 11. Сигнальный вывод зуммера должен быть подключён к выводу PWM (для обеспечения максимальной гибкости), здесь идеально подходит контакт 11.

Подсоединение переключателя

Подсоедините к аккумуляторной батарее переключатель. Он будет включать и выключать устройство. Сначала припаяйте два провода к соседним клеммам переключателя. Поскольку соединения переключателя довольно хрупкие, после пайки я также закрепил их горячим клеем.

Рисунок БРисунок Б

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

Окончательная компоновка

Рисунок ВРисунок В

Последний шаг избавляемся от беспорядочного скопления проводов и компонентов и приводим устройство в более презентабельный вид (рис. В). Это нужно делать аккуратно, чтобы не переломить провода. Приклейте горячим клеем зарядное устройство LiPo к верхней части батареи LiPo, затем сверху приклейте жучок. И последнее действие: приклейте к самому верху датчик молнии. Зуммер я вывел на сторону, как показано на рисунке В. В результате получилось несколько скреплённых между собой плат с торчащими из них проводами. Выводы переключателя я также оставил свободными, чтобы позже вставить их в корпус, распечатанный на 3D-принтере.

3. Программирование микроконтроллера

Запустите на компьютере Arduino IDE и убедитесь, что в меню ToolsBoard (ИнструментыПлата) выбрано значение Leonardo. Загрузите и установите библиотеку для датчика молнии. Затем скачайте код проекта и загрузите его на жучок. Программа предельно проста и очень легко настраивается.

Обнаружив молнию, устройство сначала подаст несколько звуковых сигналов, чтобы предупредить об ударе молнии поблизости, а затем подаст определённое количество звуковых сигналов, соответствующее расстоянию до молнии в километрах. Если молния находится на расстоянии менее 10 км (6,2 мили), детектор подаст один длинный звуковой сигнал. Если расстояние превышает 10 км (6,2 мили), расстояние будет поделено на 10, округлено, и устройство подаст соответствующее полученному числу количество сигналов. Например, если молния ударит на расстоянии 26 км (16 миль), то сигнала будет три.

Программное обеспечение запускается прерываниями от датчика молнии. Когда он обнаружит электромагнитное излучение от удара, на контакт IRQ подаётся высокое напряжение, оно вызывает прерывание в микроконтроллере. Датчик также может посылать прерывания для событий, не связанных с ударом молнии, например при превышении порогового уровня помех/шума.

В этом случае нужно отойти с детектором подальше от электронных устройств. Исходящее от таких устройств излучение может "забить" сравнительно слабое излучение удалённого удара молнии.

4. Распечатка корпуса на 3D-принтере (не обязательно)

Рисунок ГРисунок ГРисунок ДРисунок ДРисунок ЕРисунок Е

Корпус для устройства разработал я сам. Файлы для 3D-печати можно загрузить здесь. (рис. Г, Д). Верхняя часть корпуса прищёлкивается к нижней, никакого специального оборудования не требуется. Корпус достаточно просторный, чтобы в нём могло поместиться и ваше устройство, если вы будете собирать его по-другому (рис. Д). В любом случае вам ничего не мешает спроектировать аналогичный корпус самому:

  • определите габариты устройства;

  • спроектируйте устройство в программе CAD (мне нравится Fusion 360 студенты могут получить её бесплатно);

  • создайте корпус, перетащив профиль из модели устройства. Допуска в 2 мм будет вполне достаточно.

Обнаружение ударов молнии

Поздравляем, теперь у вас есть работающий регистратор молний! Как проверить, работает ли устройство? Ответ очевиден дождитесь грозы. Не знаю, насколько надёжен датчик, но мой сработал с первого раза.

Заряжать устройство очень просто достаточно подключить microUSB-кабель к зарядному устройству LiPo и дождаться, когда индикатор зарядки загорится зелёным цветом. Во время зарядки устройство должно быть включено, иначе энергия не будет поступать в аккумулятор!

Внесение изменений

Регистратор молнии можно сделать более полезным и удобным в использовании, если внести в программное обеспечение определённые изменения:

  • другие звуковые сигналы: чтобы устройство звучало приятнее, используйте библиотеку звуков Tone.h;

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

Этот материал показывает, что умение разрабатывать программы на С++, работа с электроникой, даёт широкие возможности и может быть полезна в самых разных сферах: представьте, например, что вы часто бываете на природе тогда о грозе лучше знать заранее и даже за километры, но не нужно дорогого оборудования. Если вам нравится чувствовать, как вы управляете железом, идущим по микросхемам током, то вы можете присмотреться к нашему курсу о разработке на С++, где студенты готовятся к началу карьеры разработчика ПО на этом сложном и мощном языке.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Стандарт C20 обзор новых возможностей C. Часть 6 Другие фичи ядра и стандартной библиотеки. Заключение

08.06.2021 16:18:29 | Автор: admin


25 февраля автор курса Разработчик C++ в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.

При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным. Он растянулся на почти 2,5 часа. Для вашего удобства текст мы разбили на шесть частей:

  1. Модули и краткая история C++.
  2. Операция космический корабль.
  3. Концепты.
  4. Ranges.
  5. Корутины.
  6. Другие фичи ядра и стандартной библиотеки. Заключение.

Это шестая, заключительная часть. Она рассказывает о других нововведениях ядра и стандартной библиотеки, добавленных Стандартом C++20.

Другие фичи ядра


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

Шаблоны


Многое из нововведений Стандарта касается шаблонов. В C++ шаблонные аргументы делятся на два вида: типовые и нетиповые. Типовые названы так неспроста: их значение тип данных, например int, контейнер, ваш класс, указатель. Нетиповые шаблонные аргументы это обычные значения, вычисляемые на этапе компиляции, например число 18 или true. В C++17 возможности нетиповых шаблонных параметров были сильно ограничены. Это могло быть числовое значение, bool, enum-тип или указатель. C++20 расширил список и позволил передавать в качестве шаблонного аргумента объект пользовательского класса или структуры. Правда, объект должен удовлетворять ряду ограничений.

Пример:

struct T {    int x, y;};template<T t>int f() {    return t.x + t.y;}int main() {    return f<{1,2}>();}

Статус:
GCC , CLANG , VS 


Ещё из нового крутые возможности вывода шаблонного типа класса. Не буду комментировать, просто оставлю пример. Кому интересно разберитесь:

template<class T> struct B {    template<class U> using TA = T;    template<class U> B(U, TA<U>);  //#1}; B b{(int*)0, (char*)0}; // OK, выведен B<char*>


Статус:
GCC , CLANG , VS 


Лямбды


Раньше в лямбда-функциях было три вида скобок. Теперь их может быть четыре стало возможным писать явные параметры для лямбда-функций:

  1. квадратные скобки для переменных связывания,
  2. угловые скобки для шаблонных параметров,
  3. круглые скобки для списка аргументов,
  4. фигурные скобки для тела функции.

Порядок важен: если его перепутать, будет ошибка. Если шаблонных параметров нет, то угловые скобки не пишите, потому что пустыми они быть не могут. Кстати, эта фича уже нормально поддерживается во всех компиляторах.

int main() {    auto lambda = []<class T>(T x, T y){                                return x * y - x - y; };    std::cout << lambda(10, 12);}


Статус:
GCC , CLANG , VS 


А ещё лямбда-функция, у которой нет состояния, теперь допускает копирование и конструирование, то есть можно вообще вывести тип функции и создать значение этого типа. Круто, потому что можно указать тип лямбда-функции как тип компаратора в контейнере.

int main() {    using LambdaPlus3 = decltype([](int x) {    return x + 3;    });    LambdaPlus3 l1;    auto l2 = l1;    std::cout << l2(10) << std::endl; // 13}

Статус:
GCC , CLANG , VS 

Также в C++20 можно использовать лямбды в невычислимом контексте, например внутри sizeof.

Compile-time


Появился новый вид переменных constinit. Это некоторая замена static-переменным. Локальные static-переменные могут быть проинициализированы во время первого обращения, что иногда нежелательно. А вот constinit инициализируются по-настоящему статически. Если компилятор не смог проинициализировать такую переменную во время компиляции, код не соберётся. По Стандарту constinit-переменная может быть также thread_local.

const char* g() { return "dynamic initialization"; }constexpr const char* f(bool p) { return p ? "constant "      "initializer" : g(); } constinit const char* c = f(true); // OKint main() {    static constinit int x = 3;    x = 5; // OK}

Удивительно, но constinit-переменная не обязана быть константной. Константным и constexpr обязан быть её инициализатор.

Статус:
GCC , CLANG , VS 


И у функций тоже есть новый вид consteval. Это функции, которые вычисляется исключительно во время компиляции. В отличие от constexpr, которые могут вызываться как в run-time, так и в compile-time, эти функции не получится даже вызвать во время выполнения, будет ошибка.

consteval int sqr(int n) {    return n * n;}constexpr int r = sqr(100);  // OK int x = 100;int r2 = sqr(x); // <-- ошибка: результат не константаconsteval int sqrsqr(int n) {    return sqr(sqr(n));}constexpr int dblsqr(int n) { return 2*sqr(n); } // <-- ошибка, constexpr может                                                  // вычисляться в run-time

Многие слушатели вебинара не поняли смысла этой фичи. Зачем нужен ещё один constexpr. Но представьте действие, которое имеет смысл только во время компиляции. Например, вы положили в проект файлы ресурсов и во время компиляции их читаете. Пока что так делать нельзя, поэтому просто представьте. В run-time этих ресурсов уже не будет.

Статус:
GCC , CLANG , VS 

В C++20 очень существенно расширили сам constexpr, внутри него теперь можно вызывать даже виртуальные функции! А ещё, что особенно замечательно, многие контейнеры стали поддерживать constexpr.

Новые синтаксические конструкции


Мы ждали их ещё с 1999 года. Всё это время программисты на чистом C дразнили нас, демонстрируя, как они могут ими пользоваться, а мы нет. Вы уже поняли, о чём речь? Конечно, о designated initializers, или обозначенных инициализаторах!

Теперь, конструируя объект структуры, можно явно написать, какому полю присваивается какое значение. Это очень круто. Ведь для структур с 1015 полями понять, что чему соответствует, практически невозможно. А такие структуры я видел. Кроме того, поля можно пропускать. Но вот порядок менять нельзя. И нельзя ещё несколько вещей, которые можно в C. Они приведены в примере.

struct A { int x; int y; int z; }; A b{ .x = 1, .z = 2 };A a{ .y = 2, .x = 1 }; // <-- ошибка  нарушен порядокstruct A { int x, y; };struct B { struct A a; };int arr[3] = {[1] = 5};     // <-- ошибка  массив   struct B b = {.a.x = 0};    // <-- ошибка  вложенныйstruct A a = {.x = 1, 2};   // <-- ошибка  два вида инициализаторов

Статус:
GCC , CLANG , VS 

Ещё крутое нововведение, которое забыли добавить девять лет назад инициализатор в for по диапазону. Ничего хитрого тут нет, просто дополнительная возможность для for:

#include <vector>#include <string>#include <iostream>int main() {    using namespace std::literals;    std::vector v{"a"s, "b"s, "c"s};    for(int i=0; const auto& s: v) {        std::cout << (++i) << " " << s << std::endl;    }}

Статус:
GCC , CLANG , VS 

Новая конструкция using enum. Она делает видимыми без квалификации все константы из enum-а:

enum class fruit { orange, apple };enum class color { red, orange };void f() {    using enum fruit; // OK    using enum color; // <-- ошибка  конфликт}

Статус:
GCC , CLANG , VS 


Другое


  • Операция запятая внутри [] объявлена как deprecated. Намёк на что-то интересное в будущем.
GCC , CLANG , VS 
  • Запрет некоторых действий с volatile-переменными. Эти операции тоже объявлены как deprecated. Переменные volatile сейчас часто используются не по назначению. Видимо, комитет решил с этим бороться.
GCC , CLANG , VS 
  • Агрегатные типы можно инициализировать простыми круглыми скобками ранее были допустимы только фигурные.
GCC , CLANG , VS 
  • Появился уничтожающий оператор delete, который не вызывает деструктор, а просто освобождает память.
GCC , CLANG , VS 
  • Слово typename в некоторых случаях можно опускать. Лично меня нервировала необходимость писать его там, где оно однозначно нужно.
GCC , CLANG , VS 
  • Теперь типы char16_t и char32_t явно обозначают символы в кодировках, соответственно UTF-16 и UTF-32. Ещё добавили новый тип char8_t для UTF-8.
GCC , CLANG , VS 
  • Различные технические нововведения. Если я что-то упустил пишите в комменты.

Другие фичи стандартной библиотеки


Это были нововведения ядра, то есть то, что меняет синтаксис самого C++. Но ядро даже не половина Стандарта. Мощь C++ также в его стандартной библиотеке. И в ней тоже огромное число нововведений.

  • В <chrono> наконец-то добавлены функции работы с календарём и часовыми поясами. Появились типы для месяца, дня, года, новые константы, операции, функции для форматирования, конвертации часовых поясов и много магии. Этот пример из cppreference оставлю без комментариев:

#include <iostream>#include <chrono>using namespace std::chrono; int main() {    std::cout << std::boolalpha;     // standard provides 2021y as option for std::chrono::year(2021)    // standard provides 15d as option for std::chrono::day(15)     constexpr auto ym {year(2021)/8};    std::cout << (ym == year_month(year(2021), August)) << ' ';     constexpr auto md {9/day(15)};    std::cout << (md == month_day(September, day(15))) << ' ';     constexpr auto mdl {October/last};    std::cout << (mdl == month_day_last(month(10))) << ' ';     constexpr auto mw {11/Monday[3]};    std::cout << (mw == month_weekday(November, Monday[3])) << ' ';     constexpr auto mwdl {December/Sunday[last]};    std::cout << (mwdl == month_weekday_last(month(12), weekday_last(Sunday))) << ' ';     constexpr auto ymd {year(2021)/January/day(23)};    std::cout << (ymd == year_month_day(2021y, month(January), 23d)) << '\\n';}// вывод: true true true true true true


Статус:
GCC , CLANG , VS 


  • Целая новая библиотека format. Про неё также можно послушать в докладе у Антона Полухина. Теперь в C++ есть современная функция для форматирования строк с плейсхолдерами. Библиотека позволит делать подобную магию:

auto s1 = format("The answer is {}.", 42); // s1 == "The answer is 42."auto s2 = format("{1} from {0}", "Russia", "Hello"); // s2 == "Hello from Russia"int width = 10;int precision = 3;auto s3 = format("{0:{1}.{2}f}", 12.345678, width, precision);// s3 == "    12.346"

Она обещает быть куда более производительной, чем вывод в поток stringstream, но тем не менее не лишена проблем. Первая проблема: format не проверяет все ошибки, и вообще, на данном этапе не разбирает формат в compile-time. Это очень огорчает.
Сейчас в комитете хотят это поправить с бэкпортированием функционала в C++20.
Антон Полухин
Вторая проблема: пока не реализован ни в одной стандартной библиотеке, проверить в деле нельзя.

За время, прошедшее с вебинара, format успели реализовать в Visual Studio.

Статус:
GCC , CLANG , VS 


  • Потрясающие новости: теперь в Стандарте есть . Помимо него добавлено обратное , число Эйлера, логарифмы некоторых чисел, корни и обратные корни, корень из вместе с обратным, постоянная Эйлера Маскерони и золотое сечение. Все они доступны при подключении <numbers> в пространстве имён std::numbers.

Статус:
GCC , CLANG , VS 

  • Новые алгоритмы: shift_left и shift_right. Они сдвигают элементы диапазона на заданное число позиций. При этом закручивания не происходит: элементы, уходящие на край, не телепортируются в другой конец, а уничтожаются. С другого края возникают пустые элементы из них был сделан move.

Статус:
GCC , CLANG , VS 

  • Новые функции midpoint и lerp для вычисления среднего и средневзвешенного. Вообще, это не так сложно было писать самим, но мы писали каждый раз, а теперь такая функция доступна из коробки.

Статус:
GCC , CLANG , VS 

  • Ещё одна несложная функция in_range. Она позволяет проверить, представимо ли целое число значением другого типа:

#include <utility>#include <iostream> int main() {    std::cout << std::boolalpha;     std::cout << std::in_range<std::size_t>(-1)               << '\\n'; // false, так как отрицательные числа не представимы в size_t    std::cout << std::in_range<std::size_t>(42)               << '\\n'; // true}

Статус:
GCC , CLANG , VS 

  • make_shared стал поддерживать создание массивов.

Статус:
GCC , CLANG , VS 

  • Добавились операции сравнения для неупорядоченных контейнеров unordered_map и unordered_set.

Статус:
GCC , CLANG , VS 

  • Новая функция std::to_array делает array из C-массива или строкового литерала.

Статус:
GCC , CLANG , VS 

  • В заголовочный файл version добавили макросы для проверки наличия фич стандартной библиотеки. Уже были макросы для фич ядра, но теперь это распространяется и на стандартную библиотеку.

Статус:
GCC , CLANG , VS 

  • Отличное нововведение: многие функции и методы стали constexpr. Теперь его поддерживают контейнеры, такие как string, vector.

Все алгоритмы из <algorithm>, не выделяющие память, стали constexpr. Можно сортировать массивы и делать бинарный поиск на этапе компиляции :)
Антон Полухин

Статус:
GCC , CLANG , VS 

  • Появился новый тип span. Он задаёт указатель и число количество элементов, на которые указывает указатель.

istream& read(istream& input, span<char> buffer) {    input.read(buffer.data(), buffer.size());    return input;}ostream& write(ostream& out, std::span<const char> buffer) {    out.write(buffer.data(), buffer.size());    return out;}std::vector<char> buffer(100);read(std::cin, buffer);

span позволяет заменить два параметра функции одним. Он чем-то похож на string_view это тоже лёгкая оболочка, которая может представлять элементы контейнеров. Но набор допустимых контейнеров больше это может быть любой линейный контейнер: вектор, std::array, string или C-массив. Ещё одно отличие от string_view он позволяет модификацию элементов, если они не константного типа. Важно, что модификацию только самих элементов, но не контейнера.

Статус:
GCC , CLANG , VS 


  • Ещё одно замечательное нововведение файл <bit>. Он добавляет большое количество возможностей для манипуляций с беззнаковыми числами на битовом уровне. Теперь функции вида определить количество единиц в бинарном числе или двоичный логарифм доступны из коробки. Эти функции перечислены на слайде.




Также файл определяет новый тип std::endian. Он, например, позволяет определить, какая система записи чисел используется при компиляции: Little endian или Big endian. А вот функций для их конвертации я, к сожалению, не нашёл. Но в целом считаю, что <bit> очень крутое нововведение.

Статус:
GCC , CLANG , VS 


  • Дождётся тот, кто сильно ждёт! Этот цитатой можно описать многое из Стандарта C++20. Поздравляю всех, мы дождались: у string теперь есть методы starts_with и ends_with для проверки суффиксов и постфиксов. А также другие методы контейнеров и функции, с ними связанные:
    • метод contains для ассоциативных контейнеров. Теперь можно писать my_map.contains(x) вместо my_map.count(x) > 0 и всем сразу понятно, что вам нужно проверить наличие ключа;
    • версии функции std::erase и std::erase_if для разных контейнеров;
    • функция std::ssize для получения знакового размера контейнера.

Статус:
GCC , CLANG , VS 

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

void f(int* p) {   int* p1 = std::assume_aligned<256>(p);}

Если указатель окажется в реальности невыровненным добро пожаловать, неопределённое поведение. Выровненность указателя позволит компилятору сгенерировать более эффективный код векторизации.

Статус:
GCC , CLANG , VS 


  • Добавился в Стандарт и новый вид потоков osyncstream в файле syncstream. В отличие от всех остальных потоков, osyncstream одиночка: у него нет пары на букву i. И это не случайно. Дело в том, что osyncstream всего лишь обёртка. Посмотрите на код:

#include <thread>#include <string_view>#include <iostream>using namespace std::literals;void thread1_proc() {    for (int i = 0; i < 100; ++i) {        std::cout << "John has "sv << i                   << " apples"sv << std::endl;    }}void thread2_proc() {    for (int i = 0; i < 100; ++i) {        std::cout << "Marry has "sv << i * 100                  << " puncakes"sv << std::endl;    }}int main() {    std::thread t1(thread1_proc);    std::thread t2(thread2_proc);    t1.join(); t2.join();}

В его выводе наверняка будет подобная абракадабра:

Marry has John has 24002 applesJohn has 3 applesJohn has 4 applesJohn has 5 applesJohn has 6 applesJohn has 7 apples puncakesJohn has 8 apples

Вывод каждого отдельного элемента производится атомарно, но разные элементы всё равно перемешиваются между собой. osyncstream исправляет ситуацию:

...#include <syncstream>...void thread1_proc() {    for (int i = 0; i < 100; ++i) {        std::osyncstream(std::cout) << "John has "sv << i                                     << " apples"sv << std::endl;    }}void thread2_proc() {    for (int i = 0; i < 100; ++i) {        std::osyncstream(std::cout) << "Marry has "sv << i * 100                                     << " puncakes"sv << std::endl;    }}...


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

Статус:
GCC , CLANG , VS 


  • В Стандарт добавился набор из шести новых функций для сравнения целых чисел:
    • cmp_equal,
    • cmp_not_equal,
    • cmp_less,
    • cmp_greater,
    • cmp_less_equal,
    • cmp_greater_equal.

Пользователь Хабра в комментариях к предыдущей части назвал их чуть ли не главной фичей нового Стандарта. Их особенность в корректной работе с любыми типами аргументов, если это целые числа. Обычные операции сравнения могут давать неадекватный результат, особенно если вы сравниваете знаковое с беззнаковым:

-1 > 0u; // true

По правилам в подобном случае знаковый операнд преобразуется к беззнаковому значению: 0xFFFFFFFFu для 32-битного int. Функция cmp_greater позволит обойти эту особенность и выполнить настоящее математическое сравнение:

std::cmp_greater(-1, 0u); // false

Статус:
GCC , CLANG , VS 

  • Ещё одно нововведение source_location. Это класс, который позволит заменить макросы __LINE__, __FILE__, используемые при логировании. Статический метод current этого класса вернёт объект source_location, который содержит строку и название файла, в котором этот метод был вызван. Тут я задал вопрос. Какое число выведет функция со слайда?



Есть два варианта:

  • число 2, что соответствует строке, где source_location написан;
  • число 7, что соответствует строке, где функция log вызвана.

Правильный ответ 7, где вызвана функция, хоть это и кажется неочевидным. Но именно благодаря этому обстоятельству source_location можно использовать как замену макросам __LINE__, __FILE__ для логирования. Потому что, когда мы логируем, нас интересует не где написана функция log, а откуда она вызвана.

Статус:
GCC , CLANG , VS 


  • Закончим обзор на радостной ноте: в C++ существенно упростили многопоточное программирование.
    • Новый класс counting_semaphore ждём, пока определённое количество раз разблокируют семафор.
    • Классы latch и barrier блокируют, пока определённое количество потоков не дойдёт до определённого места.
    • Новый вид потоков: jthread. Он делает join в деструкторе, не роняя вашу программу. Также jthread поддерживает флаг отмены, через который удобно прерывать выполнение треда stop_token. С этим флагом связаны сразу несколько новых классов.
    • Ещё один новый класс atomic_ref специальная ссылка, блокирующая операции других потоков с объектом.
    • Возможности atomic значительно расширены. Он теперь поддерживает числа с плавающей точкой и умные указатели, а также новые методы: wait, notify_one и notify_all.

Статус:
GCC , CLANG , VS 


Заключение


Рассказ о фичах C++ 20 окончен. Мы рассмотрели все основные изменения, хотя на самом деле в Стандарте ещё много разного и интересного. Но это уже технические особенности, которые не обязательно знать каждому профессиональному программисту.

Вполне возможно, я что-то упустил тогда добро пожаловать в комментарии.
C++ на этом не останавливается, Комитет по стандартизации активно работает. Уже было заседание, посвящённое Стандарту 2023 года. То, что в него уже включили, нельзя назвать киллер-фичами. Но ожидания от нового Стандарта большие. Например, в него войдут контракты и полноценная поддержка корутин.

На Хабре круто встретили предыдущие части. В статьях про Модули и Ranges развернулись особо оживлённые дискуссии. Будет здорово, если вы расскажете о своих впечатлениях от C++20 и ожиданиях от новых стандартов. Например, какую фичу C++20 вы считаете самой крутой и важной? Чего больше всего ждёте от будущего языка? Приветствуются также дополнения, уточнения, исправления наверняка что-то важное я упомянуть забыл.

Лично я больше всего жду от новых Стандартов добавления рефлексии возможности программы анализировать и менять саму себя. В контексте C++ уже есть некоторые предложения по поводу того, как она может выглядеть.

Поживем увидим.

Опрос


Читателям Хабра, в отличие от слушателей вебинара, дадим возможность оценить нововведения.
Подробнее..

Работа с параметрами в EEPROM

02.06.2021 22:14:13 | Автор: admin

Введение

Привет Хабр. Наконец-то у меня появилось свободное время и я могу еще немного поделиться своим опытом, возможно кому-то это будет полезно, и поможет в его работе, и я этому буду безусловно рад. Ну что же,....

Смотря на то, как студенты делают свои курсовые, я стараюсь замечать моменты, которые вызывают у них затруднения. Одним из таких моментов является работа с внешним EEPROM. Это то место, где хранятся пользовательские настройки и другая полезная информация, которая не должна быть уничтожена после выключения питания. Самый простой пример - изменение единиц измерения. Пользователь жмет на кнопку и меняет единицы измерения. Ну или записывает коэффициенты калибровки через какой-нибудь внешний протокол, типа Модбаса.

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

int address = 0;float val1 = 123.456f;byte val2 = 64;char name[10] = "Arduino";EEPROM.put(address, val1);address += sizeof(val1); //+4EEPROM.put(address, val2);address += sizeof(val2); //+1EEPROM.put(address, name);address += sizeof(name); //+10

Этот замечательный код лапшой разрастается по всему проекту, применяясь к месту и не совсем в каждом из 100 EEPROM параметров, имеющих разный тип, длину и адрес. Немудрено, что где-то да и допустит торопливый студент ошибку.

Кроме того, обычно студенты используют РТОС, а потому нужно понимать, что обращение к EEPROM из разных потоков может привести либо к фейлам, либо ко всяким там дедлокам. Поэтому если студент использует EEPROM, я вначале прошу нарисовать дизайн, чтобы показать как он собирается работать этой подсистемой.

Обычно все сводится к двум вариантам:

  • Доступ к EEPROM только из одного места. Типа такой EepromManager , который запускается в отдельной задаче и проходится по списку кешеруемых EEPROM параметров и смотрит, было ли в них изменение, и если да, то пишет его в EEPROM.

    Тут очень большой и толстый плюс: Не нужно блокировать работу с EEPROM, все делается в одном месте.

    Но есть и минусы: обычно запись таких параметров происходит, когда пользователь что-то послал через внешний интерфейс, скажем Modbus протокол, ну типа он говорит, что я тут записал новые единицы измерения - Ок отвечает ему программа, я их закешировала, но на самом деле запись еще не прошла. Она пройдет позже, а ответить пользователю нужно прямо сейчас. И вот пользователь получает ответ, что все хорошо, новые единицы установлены, но вдруг во время отложенной записи происходит сбой и реальные единицы на самом деле не записались. В итоге устройство как бы обмануло пользователя. Понятно, что оно должно выставить ошибку и как-то об этом сообщить в своем статусе, но все же, пользователь уже немного с недоверием начинает смотреть на ваше устройство.

  • Второй способ - пишем всегда сразу по месту.

    Плюс в том, что пользователь всегда получает достоверный ответ. Мы не задумываясь пишем параметр в EEPROM там где надо, и это выглядит просто.

    Но проблем от этого не меньше: так как мы можем писать хоть что, хоть откуда, хоть куда - скажем журнал ошибок из разных подсистем из разных задач, то придется задуматься о блокировке ресурса EEPROM.

    Кроме того возможно проблема с быстрыми протоколами, когда ответить нам нужно в течении ограниченного времени, скажем 5 мс, а те кто работал с EEPROM знают, что записывается там все постранично. Ну точнее, чтобы записать однобайтовый параметр, EEPROM, копирует целую страницу во свой буфер, меняет в этом буфере этот один несчастный байт, стирает страницу, и затем записывает буфер (ну т.е. всю страницу) и того на запись одной страницы сразу тратится от 5 до 10 мс, в зависимости от размера страницы.

Но в обоих этих способах, мы хотим, чтобы доступ к параметрам не был похож, на тот код с Ардуино, что я привел, а был простым и понятным, в идеале, чтобы было вообще так:

//Записываем 10.0F в EEPROM по адресу, где лежит myEEPROMData параметр myEEPROMData = 10.0F;

Но так мы делать не будем, потому что иногда нам понадобится по месту вернуть статус операции записи, вдруг EEPROM битая или проводки отпаялись. И посему мы будем делать, что-то похожее на это:

//Записываем в EEPROM строку из 5 символов по адресу параметра myStrDataauto returnStatus = myStrData.Set(tStr6{"Hello"}); if (!returnStatus){std::cout << "Ok"}//Записываем в EEPROM float параметр по адресу параметра myFloatDatareturnStatus = myFloatData.Set(37.2F); 

Ну что же приступим

Анализ требований и дизайн

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

Давайте поймем, что мы вообще хотим. Сформируем требования более детально:

  • Каждая наша переменная(параметр) должна иметь уникальный адрес в EEPROM

    • Мы не хотим руками задавать этот адрес, он должен высчитываться сам, на этапе компиляции, потому что мы не хотим, чтобы студент нечаянно задал неверный адрес и сбил все настройки

  • Мы не хотим постоянно лазить в EEPROM, когда пользователь хочет прочитать параметр

    • Обычно EEPROM подключается через I2C или SPI. Передача данных по этим интерфейсам тоже отнимает время, поэтому лучше кэшировать параметры в ОЗУ, и возвращать сразу копию из кеша.

  • При инициализации параметра, если не удалось прочитать данные с EEPROM, мы должны вернуть какое-то значение по умолчанию.

  • На плате могут быть несколько EEPROM, а может вообще и не EEPROM, а скажем второй процессор, где хранятся разные данные, поэтому мы должны предусмотреть, возможность того, чтобы параметр мог использовать заданный драйвер для записи и чтения в нужное место.

  • Все должно быть дружелюбным простым и понятным :)

Давайте прикинем дизайн класса, который будет описывать такой параметр и удовлетворять нашим требованиям: Назовем класс CaсhedNvData

CachedNvData

Вообще все должно быть понятно из картинки, но на всякий случай:

При вызове метода Init() мы должны полезть в EEPROM и считать оттуда нужный параметр с нужного адреса.

Адрес будет высчитываться на этапе компиляции, пока эту магию пропустим. Прочитанное значение хранится в data, и как только кому-то понадобится, оно возвращается немедленно из копии в ОЗУ с помощью метода Get().

А при записи, мы уже будем работать с EEPROM через nvDriver. Можно подсунуть любой nvDriver, главное, чтобы у него были методы Set() и Get(). Вот например, такой драйвер подойдет.

NvDriver

Остался еще один штрих, придумать, как автоматически формировать адрес каждого параметра. Для того, чтобы адрес высчитывался автоматически, необходимо, чтобы все параметры для EEPROM были зарегистрированы в каком-нибудь списке. Тогда список может сам посчитать адрес параметра по его положению в списке и собственно вернуть его когда надо.

Например, если у нас есть 3 параметра:

//Длина параметра 6 байтconstexpr CachedNvData<NvVarList, tString6, myStrDefaultValue,  nvDriver> myStrData;//Длина параметра 4 байтаconstexpr CachedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;//Длина параметра 4 байтconstexpr CachedNvData<NvVarList, std::uint32_t, myUint32DefaultValue,  nvDriver> myUint32Data; 

То когда мы сделаем какой-то такой список:

NvVarList<100U, myStrData, myFloatData, myUint32Data>

У нас бы у myStrData был бы адрес 100, у myFloatData - 106, а у myUint32Data - 110. Ну и соответственно список мог бы его вернуть для каждого из параметра.

Собственно нужно чтобы этому списку передавался начальный адрес, и список параметров в EEPROM. Также нужно чтобы у списка был метод GetAdress(), который возвращал бы адрес нужного параметра.

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

Сделаем такой базовый класс, назовем его NvVarListBase:

NvVarListBase

В прицнипе то и все.

Код

А теперь самая простая часть - пишем код. Комментировать не буду, вроде бы и так понятно

CaсhedNvData

template<typename NvList, typename T, const T& defaultValue, const auto& nvDriver>class CaсhedNvData{  public:    ReturnCode Set(T value) const    {      //Ищем адрес EEPROM параметра в списке       constexpr auto address =                 NvList::template GetAddress<NvList,T,defaultValue,nvDriver>();      //Записываем новое значение в EEPROM      ReturnCode returnCode = nvDriver.Set(                                address,                                reinterpret_cast<const tNvData*>(&value), sizeof(T));      //Если значение записалось успешно, обновляем копию в ОЗУ      if (!returnCode)      {        memcpy((void*)&data, (void*)&value, sizeof(T));      }      return returnCode;    }    ReturnCode Init() const    {      constexpr auto address =                 NvList::template GetAddress<NvList,T,defaultValue,nvDriver>();      //Читаем значение из EEPROM      ReturnCode returnCode = nvDriver.Get(                                address,                                 reinterpret_cast<tNvData*>(&data), sizeof(T));      //Tесли значение не прочиталось из EEPROM, устанавливаем значение по умолчанию      if (returnCode)      {        data = defaultValue;      }      return returnCode;    }    T Get() const    {      return data;    }        using Type = T;  private:    inline static T data = defaultValue;};
template<const tNvAddress startAddress, const auto& ...nvVars>struct NvVarListBase{        template<typename NvList, typename T, const T& defaultValue, const auto& nvDriver>    constexpr static size_t GetAddress()    {       //Ищем EEPROM адрес параметра с типом       //CaсhedNvData<NvList, T, defaultValue, nvDriver>      using tQueriedType = CaсhedNvData<NvList, T, defaultValue, nvDriver>;                  return startAddress +             GetAddressOffset<tQueriedType>(NvVarListBase<startAddress,nvVars...>());    }      private:       template <typename QueriedType, const auto& arg, const auto&... args>       constexpr static size_t GetAddressOffset(NvVarListBase<startAddress, arg, args...>)   {    //Чтобы узнать тип первого аргумента в списке,     //создаем объект такого же типа как и первый аргумент    auto test = arg;    //если тип созданного объекта такой же как и искомый, то заканчиваем итерации    if constexpr (std::is_same<decltype(test), QueriedType>::value)    {        return  0U;    } else    {      //Иначе увеличиваем адрес на размер типа параметра и переходим к       //следующему параметру в списке.        return sizeof(typename decltype(test)::Type) +                 GetAddressOffset<QueriedType>(NvVarListBase<startAddress, args...>());    }  }    };

Использование

А теперь встанем не место студента и попробуем это все дело использовать.

Задаем начальные значения параметров:

using tString6 = std::array<char, 6U>;inline constexpr float myFloatDataDefaultValue = 10.0f;inline constexpr tString6 myStrDefaultValue = {"Habr "};inline constexpr std::uint32_t myUint32DefaultValue = 0x30313233;

Зададем сами параметры:

//поскольку список ссылается на параметры, а параметры на список. //Используем forward declarationstruct NvVarList;   constexpr NvDriver nvDriver;//Теперь можем использовать NvVarList в шаблоне EEPROM параметровconstexpr CaсhedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;constexpr CaсhedNvData<NvVarList, tString6, myStrDefaultValue,  nvDriver> myStrData;constexpr CaсhedNvData<NvVarList, uint32_t, myUint32DefaultValue,  nvDriver> myUint32Data;

Теперь осталось определить сам список параметров. Важно, чтобы все EEPROM параметры были разных типов. Можно в принципе вставить статическую проверку на это в NvVarListBase, но не будем.

struct NvVarList : public NvVarListBase<0, myStrData, myFloatData, myUint32Data>{};

А теперь можем использовать наши параметры хоть где, очень просто и элементарно:

struct NvVarList;constexpr NvDriver nvDriver;using tString6 = std::array<char, 6U>;inline constexpr float myFloatDataDefaultValue = 10.0f;inline constexpr tString6 myStrDefaultValue = {"Habr "};inline constexpr uint32_t myUint32DefaultValue = 0x30313233;constexpr CaсhedNvData<NvVarList, float, myFloatDataDefaultValue, nvDriver> myFloatData;constexpr CaсhedNvData<NvVarList, tString6, myStrDefaultValue,  nvDriver> myStrData;constexpr CaсhedNvData<NvVarList, uint32_t, myUint32DefaultValue,  nvDriver> myUint32Data;struct NvVarList : public NvVarListBase<0, myStrData, myFloatData, myUint32Data>{};int main(){        myStrData.Init();    myFloatData.Init();    myUint32Data.Init()        myStrData.Get();    returnCode = myStrData.Set(tString6{"Hello"});    if (!returnCode)    {        std::cout << "Hello has been written" << std::endl;    }    myStrData.Get();    myFloatData.Set(37.2F);        myUint32Data.Set(0x30313233);        return 1;}

Можно передавать ссылку на них в любой класс, через конструктор или шаблон.

template<const auto& param>struct SuperSubsystem{  void SomeMethod()  {    std::cout << "SuperSubsystem read param" << param.Get() << std::endl;   }};int main(){    SuperSubsystem<myFloatData> superSystem;  superSystem.SomeMethod();}

Собственно и все. Теперь студенты могут работать с EEPROM более юзерфрендли и допускать меньше ошибок, ведь часть проверок за них сделает компилятор.

Ссылка на пример кода тут: https://godbolt.org/z/W5fPjh6ae

P.S Хотел еще рассказать про то, как можно реализовать драйвер работы с EEPROM через QSPI (студенты слишком долго понимали как он работает), но слишком разношерстный получался контекст, поэтому думаю описать это в другой статье, если конечно будет интересно.

Подробнее..

Динамическая типизация C

03.06.2021 00:16:55 | Автор: admin

Преамбула

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

Предупреждение

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

Динамическая и статическая типизация

Во многих интерпретируемых языках используется динамическая типизация. Такой подход позволяет хранить в переменной с одним именем значения разных типов. В языке C используется строгая типизация, что, на мой взгляд более, чем правильно. Однако бывают случаи (хоть и не так часто), когда гораздо удобней было бы использовать динамическую типизацию. Зачастую, такая потребность напрямую связана с некачественным проектированием, но не всегда. Не зря же в Qt присутствует тип QVariant.

Здесь мы поговорим про язык C, хотя все, что описано ниже, применимо и к C++.

Магия указателя пустоты

На самом деле, никакой динамической типизации в C нет и быть не может, однако существует универсальный указатель, тип которому void *. Объявление переменной такого типа, скажем, в качестве аргумента функции, позволяет передавать в нее указатель на переменную любого типа, что может быть крайне полезно. И вот он первый пример:

#include <stdio.h>int main(){void *var;int i = 22;var = &i;int *i_ptr = (int *)(var);if(i_ptr)printf("i_ptr: %d\n", *i_ptr);double d = 22.5;var = &d;double *d_ptr = (double *)(var);if(d_ptr)printf("d_ptr: %f\n", *d_ptr);return 0;}

Вывод:

i_ptr: 22d_ptr: 22.500000

Здесь мы одному и тому же указателю присвоили указатели (простите за тавтологию) как на тип int, так и на double.

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

void *var;int i = 22;var = (void *)(&i);

Так точно должно работать.

Первый пример не нес никакой полезной нагрузки. Попробуем ее поискать во втором примере:

#include <stdio.h>int lilround(const void *arg, const char type){if(type == 0) // если передан intreturn *((int *)arg); // просто возвращаем значение целого аргумента// если передан doubledouble a = *((double *)arg);int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){int i = 12;double j = 12.5;printf("round int: %d\n", lilround(&i, 0)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j, 1)); // пытаемся округлить число двойной точностиreturn 0;}

Вывод:

round int: 12round double: 13

Здесь мы создали, можно сказать, универсальную функцию для округления как целых чисел (которым оно не требуется, конечно), так и для чисел двойной точности. Следует понимать, что функция может выполнять и что-то более полезное, в зависимости от типа аргумента.

Для тех, кому хочется слегка поломать мозг альтернативная реализация функции lilround():

int lilround(const void *arg, const char type){return type == 0? *((int *)arg): ((int)*((double *)arg) == (int)(*((double *)arg) - 0.5)? (int)(*((double *)arg)) + 1: (int)(*((double *)arg)));}

Но для того, чтобы функция знала с чем имеет дело мы передаем в нее второй аргумент. Если он равен 0, то первый интерпретируется как указатель на int, если нет как указатель на double. Такой подход может во многих случаях сгодиться, но, в основном, смысл использования универсального указателя как раз-таки в том, чтобы не указывать тип передаваемого параметра.

Предположим, что у нас две или более структур (struct), которые содержат различный набор полей. Но так уж получилось, что нужно передать их одной и той же функции. Почему так вышло рассуждать не будем.

Что же делать? Ответ почти очевиден: передавать их в виде указателя неопределенного типа. И, все ничего, но как же тогда наша функция узнает об их типе? Все просто: в самое начало структуры добавим поле type, в которое будем записывать идентификатор структуры, по которому наша функция и будет определять ее тип, предварительно приведя неопределенный указатель к любой из структур. Идентификатором может быть поле любого типа, хоть еще одна структура, но оно должно стоять первым в каждой из структур и иметь один и тот же тип. Такое условие следует из способа расположения структур в памяти компьютера. Если написать так:

typedef struct {char type;int value;} iStruct;typedef struct {char type;double value;} dStruct;

То все сработает корректно. Но если написать так:

typedef struct {char type;int value;} iStruct;typedef struct {double value;char type;} dStruct;

То программа соберется, но во время работы выдаст неверный вариант, так как, в зависимости от того к какой структуре приведем указатель, в случае обращения программа попытается считать первый байт из double value или, вообще, неизвестно откуда.

А вот и пример использования такого подхода:

#include <stdio.h>#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыdouble value; // значение двойной точности} dStruct;#pragma pack(pop)int lilround(const void *arg){iStruct *s = (iStruct *)arg;if(s->type == 0) // если передан intreturn s->value; // просто возвращаем значение целого аргумента// если передан doubledouble a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){iStruct i;i.type = 0;i.value = 12;dStruct j;j.type = 1;j.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}

Примечание: директивы компилятора #pragma pack(push, 1) и #pragma pack(pop) необходимо помещать до и после каждой специфической структуры, соответственно. Данная директива используется для выравнивания структуры в памяти, что обеспечит корректность метода. Однако не стоит также забывать о порядке полей.

В теле функции аргумент приводится к структуре iStruct и проверяется значение поля type. Дальше уже аргумент приводится к другому типу структуры, если нужно.

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

#include <stdio.h>int main(){int i = 22;void *var = &i; // объявляем void-указатель и инициализируем его адресом переменной i(*(int *)var)++; // приводим void-указатель к int-указателю, разыменовываем его и производим операцию инкрементаprintf("result: %d\n", i); // выводим измененное значение ireturn 0;}

Исходя из кода: для совершения операции необходимо записать (*(int *)var) и уже к данной записи применить требуемый оператор.

Подобие интерфейсов в C

Вернемся к структурам. Если структура "засылается" далеко и глубоко в код, возможно даже чужой, то имеет смысл передать вместе с ней и методы, которые будут обрабатывать ее значения. Для этого создадим дополнительную структуру, которая заменит поле type:

typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;

Опишем реализации указанных функций для разных сткрутур, а также функции инициализации разных типов структур. Результат ниже:

#include <stdio.h>typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииdouble value; // значение двойной точности} dStruct;#pragma pack(pop)void intPrintType() // вывод типа для iStruct{printf("integer\n");}int intRound(const void *arg) // округление для iStruct{return ((iStruct *)arg)->value; // приводим аргумент к указателю на iStruct и возвращаем значение}void intInit(iStruct *s) // инициализация iStruct{s->m.printType = intPrintType; // задаем полю printType указатель на функцию вывода для iStructs->m.round = intRound; // задаем полю round указатель на функцию округления для iStructs->value = 0;}void doublePrintType() // вывод типа для dStruct{printf("double\n");}int doubleRound(const void *arg) // округление для dStruct{double a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}void doubleInit(dStruct *s){s->m.printType = doublePrintType; // задаем полю printType указатель на функцию вывода для dStructs->m.round = doubleRound; // задаем полю round указатель на функцию округления для dStructs->value = 0;}int lilround(const void *arg){((iStruct *)arg)->m.printType(); // приводим к любой структуре, в данном случае iStruct, и выводим типreturn ((iStruct *)arg)->m.round(arg); // возвращаем округленное значение}int main(){iStruct i;intInit(&i); // инициализируем целочисленную структуруi.value = 12;dStruct j;doubleInit(&j); // инициализируем структуру с данными двойной точностиj.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}

Вывод:

integerround int: 12doubleround double: 13

Примечание: директивами компилятора следует обрамлять только те структуры, которые необходимо использовать в качестве аргумента для void-указателя.

Заключение

В последнем примере можно заметить сходство с ОПП, что, в общем-то, правда. Здесь мы создаем структуру, инициализируем ее, задаем ее ключевым полям значения и вызываем функцию округления, которая, кстати говоря, крайне упростилась, хотя мы сюда же добавили вывод типа аргумента. На этом все. И помните, что применять подобные конструкции нужно размумно, ведь, в подавляющем большинстве задач их наличие не требуется.

Подробнее..

Перевод Vulkan. Руководство разработчика. Проходы рендера (Render passes)

04.06.2021 18:05:38 | Автор: admin
Меня зовут Александра, я работаю в IT-компании CG Tribe в Ижевске и занимаюсь переводом Vulkan Tutorial на русский язык (ссылка на источник vulkan-tutorial.com).

Сегодня хочу поделиться переводом заключительных глав раздела, посвященного графическому конвейеру (Graphics pipeline basics), Render passes и Conclusion.

Содержание
1. Вступление

2. Краткий обзор

3. Настройка окружения

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
  3. Графический конвейер (pipeline)
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Проходы рендера




Подготовка


Прежде чем завершить создание графического конвейера нужно сообщить Vulkan, какие буферы (attachments) будут использоваться во время рендеринга. Необходимо указать, сколько будет буферов цвета, буферов глубины и сэмплов для каждого буфера. Также нужно указать, как должно обрабатываться содержимое буферов во время рендеринга. Вся эта информация обернута в объект прохода рендера (render pass), для которого мы создадим новую функцию createRenderPass. Вызовем эту функцию из initVulkan перед createGraphicsPipeline.

void initVulkan() {    createInstance();    setupDebugMessenger();    createSurface();    pickPhysicalDevice();    createLogicalDevice();    createSwapChain();    createImageViews();    createRenderPass();    createGraphicsPipeline();}...void createRenderPass() {}


Настройка буферов (attachments)


Мы используем только один цветовой буфер, представленный одним из images в swap chain.

void createRenderPass() {    VkAttachmentDescription colorAttachment{};    colorAttachment.format = swapChainImageFormat;    colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;}

Формат цветового буфера (поле format) должен соответствовать формату image из swap chain, и поскольку мы пока не задействуем мультисэмплинг, нам понадобится только 1 сэмпл.

colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;

loadOp и storeOp указывают, что делать с данными буфера перед рендерингом и после него. Для loadOp возможны следующие значения:

  • VK_ATTACHMENT_LOAD_OP_LOAD: буфер будет содержать те данные, которые были помещены в него до этого прохода (например, во время предыдущего прохода)
  • VK_ATTACHMENT_LOAD_OP_CLEAR: буфер очищается в начале прохода рендера
  • VK_ATTACHMENT_LOAD_OP_DONT_CARE: содержимое буфера не определено; для нас оно не имеет значения

Мы будем использовать VK_ATTACHMENT_LOAD_OP_CLEAR, чтобы заполнить фреймбуфер черным цветом перед отрисовкой нового фрейма.

Для storeOp возможны только два значения:

  • VK_ATTACHMENT_STORE_OP_STORE: содержимое буфера сохраняется в память для дальнейшего использования
  • VK_ATTACHMENT_STORE_OP_DONT_CARE: после рендеринга буфер больше не используется, и его содержимое не имеет значения

Нам нужно вывести отрендеренный треугольник на экран, поэтому перейдем к операции сохранения:

colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;

loadOp и storeOp применяются к буферам цвета и глубины. Для буфера трафарета используются поля stencilLoadOp/stencilStoreOp. Мы не используем буфер трафарета, поэтому результаты загрузки и сохранения нас не интересуют.

colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

Текстуры и фреймбуферы в Vulkan это объекты VkImage с определенным форматом пикселей, однако layout пикселей в памяти может меняться в зависимости от того, что вы хотите сделать с image.

Вот некоторые из наиболее распространенных layout-ов:

  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: images используются в качестве цветового буфера
  • VK_IMAGE_LAYOUT_PRESENT_SRC_KHR: images используются для показа на экране
  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: image принимает данные во время операций копирования

Более подробно мы обсудим эту тему в главе, посвященной текстурированию. Сейчас нам важно, чтобы images были переведены в layouts, подходящие для дальнейших операций.

В initialLayout указывается layout, в котором будет image перед началом прохода рендера. В finalLayout указывается layout, в который image будет автоматически переведен после завершения прохода рендера. Значение VK_IMAGE_LAYOUT_UNDEFINED в поле initialLayout обозначает, что нас не интересует предыдущий layout, в котором был image. Использование этого значения не гарантирует сохранение содержимого image, но это и не важно, поскольку мы все равно очистим его. После рендеринга нам нужно вывести наш image на экран, поэтому в поле finalLayout укажем VK_IMAGE_LAYOUT_PRESENT_SRC_KHR.

Подпроходы (subpasses)


Один проход рендера может состоять из множества подпроходов (subpasses). Подпроходы это последовательные операции рендеринга, зависящие от содержимого фреймбуферов в предыдущих проходах. К ним относятся, например, эффекты постобработки, применяемые друг за другом. Если объединить их в один проход рендера, Vulkan сможет перегруппировать операции для лучшего сохранения пропускной способности памяти и большей производительности (прим. переводчика: видимо, имеется в виду тайловый рендеринг). Однако мы будем использовать для нашего треугольника только один подпроход.

Каждый подпроход ссылается на один или несколько attachment-ов. Эти отсылки представляют собой структуры VkAttachmentReference:

VkAttachmentReference colorAttachmentRef{};colorAttachmentRef.attachment = 0;colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

В поле attachment указывается порядковый номер буфера в массиве, на который ссылается подпроход. Наш массив состоит только из одного буфера VkAttachmentDescription, его индекс равен 0. В поле layout мы указываем layout буфера во время подпрохода, ссылающегося на этот буфер. Vulkan автоматически переведет буфер в этот layout, когда начнется подпроход. Мы используем attachment в качестве буфера цвета, и layout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL обеспечит нам самую высокую производительность.

Подпроход описывается с помощью структуры VkSubpassDescription:

VkSubpassDescription subpass{};subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;

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

subpass.colorAttachmentCount = 1;subpass.pColorAttachments = &colorAttachmentRef;

Директива layout(location = 0) out vec4 outColor ссылается именно на порядковый номер буфера в массиве subpass.pColorAttachments.

Подпроход может ссылаться на следующие типы буферов:

  • pInputAttachments: буферы, содержимое которых читается из шейдера
  • pResolveAttachments: буферы, которые используются для цветовых буферов с мультисэмплингом
  • pDepthStencilAttachment: буферы глубины и трафарета
  • pPreserveAttachments: буферы, которые не используются в текущем подпроходе, но данные которых должны быть сохранены


Проход рендера (render pass)


Теперь, когда буфер и подпроход, ссылающийся на этот буфер, описаны, мы можем создать сам проход рендера. Перед pipelineLayout создадим новую переменную-член класса для хранения объекта VkRenderPass:

VkRenderPass renderPass;VkPipelineLayout pipelineLayout;

Теперь создадим объект прохода рендера. Для этого заполним структуру VkRenderPassCreateInfo массивом буферов и подпроходами рендера. Обратите внимание, объекты VkAttachmentReference используют индексы из этого массива (прим. переводчика: видимо, имеется в виду массив renderPassInfo.pAttachments).

VkRenderPassCreateInfo renderPassInfo{};renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;renderPassInfo.attachmentCount = 1;renderPassInfo.pAttachments = &colorAttachment;renderPassInfo.subpassCount = 1;renderPassInfo.pSubpasses = &subpass;if (vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {    throw std::runtime_error("failed to create render pass!");}

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

void cleanup() {    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);    vkDestroyRenderPass(device, renderPass, nullptr);    ...}

Мы проделали большую работу, и осталось лишь собрать все воедино, чтобы наконец-то создать графический конвейер!

C++ code / Vertex shader / Fragment shader


Заключение


Теперь мы можем объединить все структуры и объекты, чтобы создать графический конвейер!
Давайте вспомним, какие объекты у нас уже есть:

  • Шейдеры: шейдерные модули, определяющие функционал программируемых стадий конвейера
  • Непрограммируемые стадии: структуры, описывающие работу конвейера на непрограммируемых стадиях, таких как input assembler, растеризатор, вьюпорт и функция смешивания цветов
  • Layout конвейера: описание uniform-переменных и push-констант, которые используются конвейером и которые могут обновляться динамически
  • Проход рендера (render pass): описания буферов (attachments), в которые будет производиться рендер

Все эти объекты полностью определяют функционал графического конвейера. С ними можно начать заполнение структуры VkGraphicsPipelineCreateInfo. Сделаем это в конце функции createGraphicsPipeline, но перед вызовами vkDestroyShaderModule, поскольку шейдерные модули будут использоваться во время создания конвейера.

VkGraphicsPipelineCreateInfo pipelineInfo{};pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;pipelineInfo.stageCount = 2;pipelineInfo.pStages = shaderStages;

Начнем с указателя на массив структур VkPipelineShaderStageCreateInfo.

pipelineInfo.pVertexInputState = &vertexInputInfo;pipelineInfo.pInputAssemblyState = &inputAssembly;pipelineInfo.pViewportState = &viewportState;pipelineInfo.pRasterizationState = &rasterizer;pipelineInfo.pMultisampleState = &multisampling;pipelineInfo.pDepthStencilState = nullptr; // OptionalpipelineInfo.pColorBlendState = &colorBlending;pipelineInfo.pDynamicState = nullptr; // Optional

Затем заполним указатели на все структуры, описывающие непрограммируемые стадии конвейера.

pipelineInfo.layout = pipelineLayout;

После этого укажем layout конвейера, который является дескриптором Vulkan, а не указателем на структуру.

pipelineInfo.renderPass = renderPass;pipelineInfo.subpass = 0;

В конце сделаем ссылку на проход (render pass) и номер подпрохода (subpass), который используется в создаваемом пайплайне. Во время рендера можно использовать и другие объекты прохода, но они должны быть совместимы с нашим renderPass. Требования к совместимости вы можете найти здесь, однако в руководстве мы будем использовать только один проход.

pipelineInfo.basePipelineHandle = VK_NULL_HANDLE; // OptionalpipelineInfo.basePipelineIndex = -1; // Optional

Остались два параметра basePipelineHandle и basePipelineIndex. Vulkan позволяет создать производный графический конвейер из существующего конвейера. Суть в том, что создание производного конвейера не требует больших затрат, поскольку большинство функций берется из родительского конвейера. Также переключение между дочерними конвейерами одного родителя осуществляется намного быстрее. В поле basePipelineHandle вы можете указать дескриптор существующего конвейера, либо сделать отсылку к другому конвейеру, который будет создан по индексу, в поле basePipelineIndex. У нас только один конвейер, поэтому укажем VK_NULL_HANDLE и невалидный порядковый номер. Эти значения используются только в том случае, если в VkGraphicsPipelineCreateInfo в поле flags указано VK_PIPELINE_CREATE_DERIVATIVE_BIT.

Прежде чем завершить создание конвейера создадим член класса для хранения объекта VkPipeline:

VkPipeline graphicsPipeline;

И наконец создадим графический конвейер:

if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline) != VK_SUCCESS) {    throw std::runtime_error("failed to create graphics pipeline!");}

Функция vkCreateGraphicsPipelines содержит больше параметров, чем обычная функция создания объектов в Vulkan. За один вызов она позволяет создать несколько объектов VkPipeline из массива структур VkGraphicsPipelineCreateInfo.

Параметр VkPipelineCache необязательный, поэтому мы передаем VK_NULL_HANDLE. Кэш конвейера может использоваться для хранения и повторного использования данных, связанных с созданием конвейера. Он совместно используется множеством вызовов vkCreateGraphicsPipelines и даже может быть сохранен на диск для переиспользования при последующих запусках программы. Впоследствии это может значительно ускорить процесс создания конвейера. Мы еще вернемся к этой теме в главе, посвященной кэшу конвейера.

Графический конвейер понадобится для всех операций рисования, поэтому он должен быть уничтожен в самом конце:

void cleanup() {    vkDestroyPipeline(device, graphicsPipeline, nullptr);    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);    ...}

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

C++ code / Vertex shader / Fragment shader
Подробнее..

Перевод Ваш ABI, скорее всего, неверен

07.06.2021 18:08:54 | Автор: admin

ABI, или двоичный интерфейс приложения (Application Binary Interface), определяет способ взаимодействия двоичных файлов друг с другом на конкретной платформе и включает соглашение о вызовах. Большинство ABI имеют один конструктивный недостаток, который снижает производительность.

Давайте начнем с рассмотрения ABI System V для процессоров линейки x86. ABI классифицирует аргументы функции по ряду различных категорий; мы будем рассматривать только две:

INTEGER: Этот класс состоит из целочисленных типов, которые помещаются в один из регистров общего назначения.

MEMORY: Этот класс состоит из типов, которые будет переданы в память и возвращены через стек.

Я не буду подробно описывать правила классификации аргументов; достаточно сказать, что в общем смысле:

  1. Целые числа, указатели и небольшие структуры имеют класс INTEGER и передаются в регистры.

  2. Если структура слишком большая, она имеет класс MEMORY и передается в стек.

  3. Если аргументов слишком много, те, которые не помещаются в регистры, будут переданы в стек.

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

Но что в этом плохого? Конечно, мы можем просто делать то, что делали во времена наивных компиляторов, и передавать структуры по указателю. К сожалению, это больше не работает; компиляторы сейчас умны, и им не нравится, когда объекты имеют псевдонимы.

Например:

void foo(int*);void bar(void);int x = 5;foo(&x);   // насколько нам известно, foo мог сохранить &x в глобальной переменнойx = 7;bar();       // через которую, bar мог изменить xreturn x;   // что означает, что это должно превратиться в фактическую загрузку; он не может быть обернут в константу     // (Этого не произошло бы, если бы x был передан по значению, но, как мы знаем, это не всегда приемлемо для больших структур.)

restrict во спасение! Если бы параметр foo был аннотирован с restrict, foo не смог бы использовать его псевдоним (C116.7.3.1p4,11). К сожалению, компиляторы обычно не в курсе об этом факте. Более того, поскольку принудительного применения типа restrict в C нет, в общем понимании на добросовестность атрибута рассчитывать нельзя, даже если он может быть правильным в тех случаях, когда ABI C используется для связи между языками с более строгой типизацией.

И действительно, ABI должен поступать правильно по умолчанию. void foo(struct bla) намного легче читать, чем void foo(const struct bla *restrict), не говоря уже о том, что он лучше передает намерение и фактически обеспечивает более сильную семантическую гарантию.

Что ж, такова System V. Как обстоят дела с другими ABI? Microsoft похожа, но она передает структуры с указателем:

Структуры или объединения [не малых] размеров передаются как указатель на память, выделенную вызывающей стороной.

Это дает вам некоторую гибкость (хотя это также, вероятно, немного сбивает с толку переименователь памяти), но не решает реальной проблемы. Память, выделенная вызывающей стороной, принадлежит вызываемой стороне, которая может изменять ее по своему желанию, поэтому вызывающей стороне по-прежнему необходимо излишнее копирование.

Больше ABI! ARM (извините, AAA arch 64):

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

RISC-V:

Агрегаты размером более 2XLEN бит [примечание: какого черта вы говорите о битах?] передаются по ссылке и заменяются в списке аргументов адресом.

[...]

Аргументы, переданные по ссылке, могут быть изменены вызываемой стороной.

PowerPC:

Все [неоднородное] агрегаты передаются в последовательные регистры общего назначения (GPR), в регистры общего назначения и в память, или в память.

MIPS n32:

Структуры (structs), объединения (unions),или другие составные типы рассматриваются как последовательность двойных слов (doublewords), и передаются в целые регистры или регистры с плавающей запятой, как если бы они были простыми скалярными параметрами в той степени, в которой они помещаются, с любым избытком в стеке, упакованным в соответствии с обычной структурой памяти объекта.

Все это повторения одних и тех же двух ошибок.


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

Мотайте на ус, будущие создатели ABI!


Ссылки:

Перевод материала подготовлен в рамках курса "C++ Developer. Professional". Если вам интересно узнать больше о курсе, приглашаем на день открытых дверей онлайн, на котором можно будет узнать о формате и программе курса, познакомиться с преподавателем.

- ЗАПИСАТЬСЯ НА DEMO DAY

Подробнее..

Перевод Пара мыслей о геттерах и сеттерах в C

11.06.2021 18:10:18 | Автор: admin

Эта статья посвящена геттерам и сеттерам в C++. Приношу свои извинения, но речь пойдет не о корутинах. К слову, в ближайшее время появится вторая часть про пулы потоков.

TL;DR: геттеры и сеттеры не очень хорошо подходят для структуроподобных объектов.

Введение

В этой статье я лишь высказываю свое личное мнение, я не преследую цели кого-нибудь обидеть или задеть, я просто собираюсь объяснить, почему и когда стоит или не стоит, использовать геттеры и сеттеры. Буду очень рад любым дискуссиям в комментариях.

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

Производительность и геттеры

Допустим, у нас есть простая структура с обычными геттерами и сеттерами:

class PersonGettersSetters {  public:    std::string getLastName() const { return m_lastName; }    std::string getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Сравним эту версию с версией без геттеров и сеттеров.

struct Person {    int age = 26;    std::string firstName = "Antoine";    std::string lastName = "MORRIER";};

Она намного лаконичнее и надежнее. Здесь мы не можем, например, верну фамилию вместо имени.

Оба кода полностью функциональны. У нас есть класс Person с именем (firstName), фамилией (lastName) и возрастом (age). Однако предположим, что нам нужна функция, которая возвращает некоторую сводку по конкретному человеку.

std::string getPresentation(const PersonGettersSetters &person) {  return "Hello, my name is " + person.getFirstName() + " " + person.getLastName() +  " and I am " + std::to_string(person.getAge());}std::string getPresentation(const Person &person) {  return "Hello, my name is " + person.firstName + " " + person.lastName + " and I am " + std::to_string(person.age);}

Версия без геттеров выполняет эту задачу на 30% быстрее, чем версия с геттерами. Почему? Из-за возврата по значению в геттере. При возврате по значению создается копия, что снижает производительность. Давайте сравним производительность person.getFirstName(); и person.firstName.

Как видите, прямой доступ к полю имени без геттера эквивалентен noop.

Геттер по константной ссылке

Однако можно использовать возврат не по значению, а по ссылке. Так образом мы получим такую же производительность, как и без использования геттеров. Обновленный код будет выглядеть так:

class PersonGettersSetters {  public:    const std::string &getLastName() const { return m_lastName; }    const std::string &getFirstName() const { return m_firstName; }    int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }  private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Так как мы получаем ту же производительность, что и в лаконичной версии, мы можем на этом успокоиться, не так ли? Прежде чем отвечать на этот вопрос, попробуйте выполнить этот код.

PersonGettersSetters make() {    return {};   }int main() {    auto &x = make().getLastName();         std::cout << x << std::endl;        for(auto x : make().getLastName()) {        std::cout << x << ",";       }}

Вы можете заметить некоторые странные символы, выведенные в консоли. Но почему? Что произошло, когда мы сделали make().getLastName()?

  1. Вы создаете экземпляр Person.

  2. Вы получаете ссылку на фамилию.

  3. Вы удаляете экземпляр Person.

И вот у нас есть висячая ссылка! Это может привести к крашам (в лучшем случае) или чему-то еще более худшему, чему-то, что можно найти только в фильмах ужасов.

Чтобы предупредить это, мы должны ввести ref-qualified функции.

class PersonGettersSetters {  public:    const std::string &getLastName() const & { return m_lastName; }    const std::string &getFirstName() const & { return m_firstName; }        std::string getLastName() && { return std::move(m_lastName); }    std::string getFirstName() && { return std::move(m_firstName); }        int getAge() const {return m_age; }        void setLastName(std::string lastName) { m_lastName = std::move(lastName); }    void setFirstName(std::string firstName) { m_firstName = std::move(firstName); }    void setAge(int age) {m_age = age; }      private:    int m_age = 26;    std::string m_firstName = "Antoine";    std::string m_lastName = "MORRIER";    };

Вот новое решение, которое будет работать везде. Вам нужно два геттера. Один для lvalue и один для rvalue (как xvalue, так и для prvalue).

Проблемы с сеттерами

Тут особо нечего сказать. Если вы хотите добиться максимальной производительности, вы должны написать один сеттер, который принимает lvalue, и один, который принимает rvalue. Однако, как правило, достаточно иметь всего один сеттер, который принимает перемещаемое значение. Тем не менее, вам придется расплатиться за это дополнительным move. Однако таким образом у вас не получится производить небольшие изменения в переменных. Вы должны заменять всю переменную целиком. Если вы просто хотите заменить одну букву A в имени на D, то вы не сможете сделать это с помощью сеттеров. Однако с помощью прямого доступа так делать можно.

А как насчет иммутабельных переменных?

Кто-то может посоветовать вам просто сделать атрибут члена const. Однако меня это решение не устраивает. Создание константы предотвратит move-семантику и приведет к ненужному копированию.

У меня нет волшебного решения, которое я мог бы предложить вам прямо сейчас. Тем не менее, мы можем написать обертку, которую мы можем назвать immutable<T>. Эта обертка должна быть:

  1. Constructible

  2. Так как она immutable, она не должна быть assignable

  3. Она может быть copy constructible или move constructible

  4. Она должна быть конвертируемой в const T&, будучи lvalue

  5. Она должна быть конвертируемой в T, будучи rvalue

  6. Она должна использоваться, как и другие оболочки, с помощью оператора * или оператора ->.

  7. Получить адрес базового объекта должно быть легко.

Вот небольшая реализация:

#define FWD(x) ::std::forward<decltype(x)>(x)template <typename T>struct AsPointer {    using underlying_type = T;    AsPointer(T &&v) noexcept : v{std::move(v)} {}    T &operator*() noexcept { return v; }    T *operator->() noexcept { return std::addressof(v); }    T v;};template <typename T>struct AsPointer<T &> {    using underlying_type = T &;    AsPointer(T &v) noexcept : v{std::addressof(v)} {}    T &operator*() noexcept { return *v; }    T *operator->() noexcept { return v; }    T *v;};template<typename T>class immutable_t {  public:    template <typename _T>    immutable_t(_T &&t) noexcept : m_object{FWD(t)} {}    template <typename _T>    immutable_t &operator=(_T &&) = delete;    operator const T &() const &noexcept { return m_object; }    const T &operator*() const &noexcept { return m_object; }    AsPointer<const T &> operator->() const &noexcept { return m_object; }    operator T() &&noexcept { return std::move(m_object); }    T operator*() &&noexcept { return std::move(m_object); }    AsPointer<T> operator->() &&noexcept { return std::move(m_object); }    T *operator&() &&noexcept = delete;    const T *operator&() const &noexcept { return std::addressof(m_object); }    friend auto operator==(const immutable_t &a, const immutable_t &b) noexcept { return *a == *b; }    friend auto operator<(const immutable_t &a, const immutable_t &b) noexcept { return *a < *b; }  private:    T m_object;};

Таким образом, для иммутабельного объекта Person вы можете просто написать:

struct ImmutablePerson {    immutable_t<int> age = 26;    immutable_t<std::string> firstName = "Antoine";    immutable_t<std::string> lastName = "MORRIER";};

Заключение

Я бы не сказал, что геттеры и сеттеры - это зло. Однако, когда вам не нужно делать что-либо еще в геттере и сеттере, достижение максимальной производительности, безопасности и гибкости подводит вас к написанию:

  • 3-х геттеров (или даже 4-х): const lvalue, rvalue, const rvalue и, по вашему усмотрению, для неконстантного lvalue (даже если это уже просто очень странно звучит, так как проще использовать прямой доступ)

  • 1 сеттер (или 2, если вы хотите выжать максимальную производительность).

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

Некоторые люди могут вам сказать, что геттеры и сеттеры обеспечивают инкапсуляцию, но это не так. Инкапсуляция - это не просто делать атрибуты приватными. Речь идет о сокрытии внутренностей от пользователей, а в структуроподобных объектах вы редко хотите что-либо скрывать.

Мой совет: когда у перед вами структуроподобный объект, просто не используйте геттеры и сеттеры, а используйте публичный/прямой доступ. Проще говоря, если вам не нужен сеттер для поддержания инвариантности, вам не нужен приватный атрибут.

PS: Для людей, которые используют библиотеки с поверхностным копированием, влияние на производительность менее важно. Однако вам все равно нужно написать 2 функции вместо 0. Не забывайте, что чем меньше кода вы напишете, тем меньше будет ошибок, проще поддерживать и легче читать этот самый код.

Ну а что думаете вы? Используете ли вы геттеры и сеттеры? И почему?


Перевод материала подготовлен в рамках курса "C++ Developer. Basic". Всех желающих приглашаем на двухдневный онлайн-интенсив HTTPS и треды в С++. От простого к прекрасному. В первый день интенсива мы настроим свой http-сервер и разберем его что называется от и до. Во второй день произведем все необходимые замеры и сделаем наш сервер супер быстрым, что поможет нам понять на примере, чем же все-таки язык С++ лучше других. Регистрация здесь

Подробнее..

Про uuid-ы, первичные ключи и базы данных

16.06.2021 00:20:01 | Автор: admin

Статья посвящена альтернативным версиям Qt-драйверов для работы с базами данных. По большому счету отличий от нативных Qt-драйверов не так много, всего пара: 1) Поддержка типа UUID; 2) Работа с сущностью "Транзакция" как с самостоятельным объектом. Но эти отличия привели к существенному пересмотру кодовой реализации исходных Qt-решений и изменили подход к написанию рабочего кода.

Первичный ключ: UUID или Integer?

Впервые с идеей использовать UUID в качестве первичного ключа я познакомился в 2003 году, работая в команде дельфистов. Мы разрабатывали программу для автоматизации технологических процессов на производстве. СУБД в проекте отводилась существенная роль. На тот момент это была FireBird версии 1.5. По мере усложнения проекта появились трудности с использованием целочисленных идентификаторов в качестве первичных ключей. Опишу пару сложностей:

  • Проблема архитектурная: периодически заказчики присылали справочные данные с целью включения их в новую версию дистрибутива. Иногда справочники содержали первичные ключи уже имеющиеся в нашей базе. Приходилось устранять коллизии в процессе агрегирования данных. На этом проблемы не заканчивались: при разворачивании нового дистрибутива периодически возникали обратные коллизии.

  • Проблема программная: чтобы получить доступ к вставленной записи нужно было выполнить дополнительный SELECT-запрос, который возвращал максимальное значение первичного ключа (значение для только что вставленной записи). Причем этот процесс должен был проходить в пределах одной транзакции. Далее можно было обновлять или корректировать запись. Это сейчас я знаю, что некоторые драйверы БД возвращают значение первичного ключа для вставленной записи, но в 2003 году мы такими знаниями не обладали, да и не припомню что бы Делфи-компоненты возвращали что-то подобное.

Использование UUID-ов в качестве первичных ключей сводило к минимуму архитектурную проблему, и полностью решало программную. UUID-ключ генерировался перед началом вставки записи на стороне программы, а не в недрах сервера БД, таким образом дополнительный SELECT-запрос стал не нужен, и требование единой транзакции утратило актуальность. FireBird версии 1.5 не имел нативной поддержки UUID-ов, поэтому использовались строковые поля длинной в 32 символа (дефисы из UUID-ов удалялись). Факт использования строковых полей в качестве первичных ключей нисколько не смущал, нам не терпелось опробовать новый подход при работе с данными.

У UUID-ов есть свои минусы: 1) Существенный объем; 2) Более низкая скорость работы по сравнению с целочисленными идентификаторами. В рамках проекта достоинства оказались более значимы, чем указанные недостатки. В целом, опыт оказался положительным, поэтому в последующих решениях при создании реляционных связей предпочтение отдавалось именно UUID-ам.

Примечание: Более подробный анализ UUID vs Integer для СУБД MS SQL можно посмотреть в статье "Первичный ключ GUID или автоинкремент?"

Первый драйвер для FireBird

В 2012 году мне снова довелось поработать с FireBird. Нужно было создать небольшую программу по анализу данных. Разработка велась с использованием QtFramework. Примерно в это же время у FireBird вышла версия 2.5 с нативной поддержкой UUID-ов. Я подумал: "Почему бы не добавить в Qt-драйвер для FireBird поддержку типа QUuid?" Так появилась первая версия Qt-драйвера с поддержкой UUID-ов. Этот вариант не сильно отличался от оригинальной версии драйвера и, в основном, был ориентирован на использование в однопоточных приложениях.

Появление сущности "Транзакция"

Следующая модификация Qt-драйвера для FireBird произошла в конце 2018 года. Наша фирма взялась за разработку проекта по анализу данных большого объема. Для фирмы выросшей из стартап-а эта работа была очень важна, как с финансовой, так и с репутационной точек зрения. Сроки исполнения были весьма жесткие. В качестве СУБД была выбрана FireBird, несмотря на определенные сомнения в ее пригодности. Хорошим вариантом могла бы стать PostgreSQL, но у нашей команды на тот момент отсутствовал опыт эксплуатации данной СУБД.

Архитектура программы предполагала подключение к базе данных из разных потоков. Нативный Qt-FireBird драйвер не очень подходил для такого режима работы. К тому же, у концепции Qt-драйверов, на мой взгляд, есть один существенный недостаток: управление транзакциями (точнее одной транзакцией) происходит на уровне объекта подключения к базе данных (сущность "Транзакция" инкапсулирована внутри объекта Driver). То есть при работе с нативным Qt-драйвером в один момент времени можно оперировать только одной транзакцией. Почему так сделано, в принципе, понятно: достаточно много популярных СУБД работают по этой схеме (одно подключение - одна транзакция). В качестве примера можно назвать Oracle, PostgreSQL, MS SQL при работе через ODBC. Но FireBird не такой, его API позволяет оперировать сразу несколькими транзакциями в контексте одного подключения. Обладая этими знаниями, я начал адаптацию Qt-FireBird драйвера для работы в многопоточном приложении.

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

Новая концепция не нуждалась в сущности "транзакция", как в самостоятельной единице, тем не менее, я не стал ее упразднять. Дальнейшая эксплуатация показала, что наличие объекта "транзакция" делает работу с базой данных более гибкой, дает больше инвариантов при написании кода. Например, разработчик может передать объект "Транзакция" в функцию в качестве параметра, явно говоря таким образом, что внутри нужно работать в контексте указанной транзакции. В функции можно проверить активна транзакция или нет, можно выполнить COMMIT или ROLLBACK. Для вспомогательных операций можно создавать альтернативную транзакцию, не затрагивающую основную. Таких возможностей нет у нативных Qt-драйверов.

Ниже приведен пример с тремя функциями, которые последовательно вызываю друг друга. Каждая функция запрашивает объект подключения к базе данных (Driver) у пула коннектов. Так как функции вызываются в одном потоке, они получают объект коннекта, ссылающийся на одно и тоже подключение к БД. Далее в каждой функции создается собственный независимый объект транзакции и все последующие запросы будут выполняются в его контексте.

Приведенный пример не будет работать с нативным Qt-драйвером, причина описана выше: ограничение на одно подключение и одну транзакцию

Кто-то скажет: "Зачем все так усложнять?! Вот есть же понятная концепция 'Один коннект - одна транзакция', с ней легко и просто работать!" Действительно, в большинстве случаев этого будет достаточно, но я хорошо помню, еще по команде дельфистов, как эта "простота" выходила нам боком при работе с ODBC драйверами. Поэтому считаю, что наличие инвариантов - лучше, чем их отсутствие.

void function3(int value3){    db::firebird::Driver::Ptr dbcon = fbpool().connect();    db::firebird::Transaction::Ptr transact3 = dbcon->createTransact();    QSqlQuery q3 {db::firebird::createResult(transact3)};    if (!transact3->begin())        return;            if (!q3.prepare("INSERT INTO TABLE3 (VALUE3) VALUES (:VALUE3)"))        return;            sql::bindValue(q3, ":VALUE3" , value3);        if (!q3.exec())         return;    transact3->commit();}void function2(int value2){    db::firebird::Driver::Ptr dbcon = fbpool().connect();    db::firebird::Transaction::Ptr transact2 = dbcon->createTransact();    QSqlQuery q2 {db::firebird::createResult(transact2)};    if (!transact2->begin())        return;    if (!q2.prepare("SELECT * FROM TABLE2 WHERE VALUE2 = :VALUE2"))        return;             sql::bindValue(q2, ":VALUE2 " , value2);          if (!q2.exec())         return;             while (q2.next())    {        qint32 value3;        sql::assignValue(value3, q2.record(), "VALUE3");        function3(value3);    }}void function1(){    db::firebird::Driver::Ptr dbcon = db::firebird::pool().connect();    db::firebird::Transaction::Ptr transact1 = dbcon->createTransact();    QSqlQuery q1 {db::firebird::createResult(transact1)};        if (!transact1->begin())        return;            if (!sql::exec(q1, "SELECT * FROM TABLE1"))        return;            while (q1.next())    {        QSqlRecord r = q1.record();        QUuidEx  id;        qint32   value1;        qint32   value2;        sql::assignValue(id     , r, "ID     ");        sql::assignValue(value1 , r, "VALUE1 ");        sql::assignValue(value2 , r, "VALUE2 ");        ...        function2(value2);    }}

В примере экземпляры транзакций (1-3) созданы для наглядности. В рабочем коде их можно опустить. В этом случае транзакции будут создаваться неявно внутри объекта QSqlQuery. Неявные транзакции всегда завершаются ROLLBACK-ом для SELECT-запросов и попыткой COMMIT-а для всех остальных.

Ниже показано как можно использовать одну транзакцию для трех sql-запросов. Подтвердить или откатить транзакцию можно в любой из трех функций.

void function3(db::firebird::Transaction::Ptr transact, int value3){    QSqlQuery q3 {db::firebird::createResult(transact)};    // Тут что-то делаем}void function2(db::firebird::Transaction::Ptr transact, int value2){    QSqlQuery q2 {db::firebird::createResult(transact)};    // Тут что-то делаем    function3(transact, value3);}void function1(){    db::firebird::Driver::Ptr dbcon = db::firebird::pool().connect();    db::firebird::Transaction::Ptr transact = dbcon->createTransact();    QSqlQuery q1 {db::firebird::createResult(transact)};        if (!transact->begin())        return;            while (q1.next())    {        // Тут что-то делаем        function2(transact, value2);    }    transact->commit();}

Драйвер для PostgreSQL

В начале 2020 года мы приступили к новому объемному проекту. Требования к СУБД заказчик сформулировал однозначно: PostgreSQL. В отличие от ситуации с проектом 18-го года, сейчас время на изучение матчасти было. Для PostgreSQL хотелось создать драйвер похожий по поведению и функционалу на FireBird. Первой мыслью было подглядеть решение в Qt, но взять оттуда получилось только знание о том, что этот вариант нам не подходит. Работа Qt-драйвера построена на двух командах: PREPARE и EXECUTE. Эти команды могут работать только со строковым представлением, а это означает, что любые бинарные данные придется преобразовывать к строкам. Подумав, что "это не наш путь", я принялся отсматривать существующие решения и документацию по PostgreSQL API. Из библиотеки libpqxx получилось взять концепцию уровней изоляции для транзакций, все остальное было написано с нуля. Не обошлось без "ложки дегтя". Выяснилось, что для одного подключения к БД в один момент времени можно создать только одну транзакцию. Ограничение концептуальное, существует на уровне движка СУБД. Пример с тремя функциями, описанный выше, в PostgreSQL работать не будет. Были попытки посмотреть в сторону субтранзакций, но элегантного решения найти не удалось. Нивелировать проблему получилось переработкой пула коннектов. В него было добавлено свойство singleConnect(), определяющее режим создания нового подключения к базе данных. По умолчанию пул коннектов создает в одном потоке исполнения только одно подключение к БД. Свойство singleConnect() установленное в FALSE позволяет создавать новое подключение при каждом обращении к пулу. Таким образом, ограничение в одну транзакцию на одно подключение удалось обойти. Обратной стороной этого решения является большое количество подключений к базе. Но так как пул коннектов может использовать повторно уже существующие подключения, их количество не будет расти неконтролируемо и со временем придет к равновесному состоянию. Теперь пример с тремя функциями работает.

Драйвер для MS SQL

Пару раз на переговорах с заказчиками возникал вопрос по поводу интеграции с их системами через MS SQL. Чтобы опять не оказаться в ситуации цейтнота при разработке нового драйвера, было принято решение сделать его загодя. Драйвер для MS SQL реализован с использованием ODBC. В плане работы с транзакциями он похож на PostgreSQL: одно подключение - одна транзакция. Возможно, в драйвере OLE DB для MS SQL этого ограничения нет, но ODBC инвариантов не допускает. Сейчас драйвер имеет статус демонстрационного решения, в "бою" не применялся. На текущий момент, не реализованным остался биндинг и запись NULL-значений. Полагаю, к очередному проекту эту недоработку закроем.

Чего нет в классе Driver

Описываемые здесь драйверы не повторяют один в один функционал Qt-решений. В классе оставлены следующие методы:

  • beginTransaction();

  • commitTransaction();

  • rollbackTransaction().

С введением сущности "Транзакция" они утратили актуальность и нужны исключительно для отладки и диагностирования их вызовов из Qt-компонентов.

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

  • tables();

  • record();

  • primaryIndex();

  • formatValue();

  • escapeIdentifier().

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

Еще один момент, на который хотелось бы обратить внимание: по умолчанию все драйверы работают в режиме "Forward Only". Точнее сказать это единственный режим, в котором работаю драйверы, при получении данных с сервера СУБД. Тем не менее, механизм кэширования имеется, он реализован при помощи класса SqlCachedResult. Основное назначение механизма - отображение данных в визуальных Qt-компонентах.

Новые функции

В классе Driver добавлена функция abortOperation(), она дает возможность асинхронно прерывать тяжелые sql-запросы, тем самым предотвращая "замерзание" приложения. В классе Result появилась сервисная функция size2(), она возвращает количество записей для подготовленного sql-запроса. Функция size2() расположена с защищенной секции, доступ к ней осуществляется через глобальную функцию resultSize(const QSqlQuery&). Знание о количестве записей бывает полезно при реализации механизма пагинации.

Лицензионные ограничения

Все драйверы могу использоваться только под лицензиями GPL/LGPL 2.1. Драйверы зависят от класса SqlCachedResult, который принадлежит компании Qt и распространяется под вышеозначенными лицензиями. Это налагает запрет на прямое включение кода драйверов в закрытые коммерческие проекты. Даже драйвер PostgreSQL, который фактически написан с нуля, подпадает под указанное ограничение (заражен копилефтом). Тем не менее, ситуация вполне разрешима: можно собрать драйвер как динамическую библиотеку и использовать ее в коммерческом продукте под лицензией LGPL. В этом случае, все формальности будут соблюдены.

Зависимости

В реализации драйверов используется система логирования ALog, которая является составной частью библиотеки общего назначения SharedTools.

Демо-примеры

Специально для этой статьи был создан демонстрационный проект. Он содержит примеры работы с тремя СУБД: FireBird, PostgreSQL, MS SQL. Репозиторий с драйверами расположен здесь, он подключен в проект как субмодуль. Библиотека SharedTools так же подключена как субмодуль.

Проект создан с использованием QtCreator, сборочная система QBS. Есть четыре сборочных сценария:

  1. db_demo_project.qbs - демо примеры для всех СУБД (содержит пункты 2-4);

  2. db_demo_firebird.qbs - демо пример для FireBird (требуется FireBird-клиент);

  3. db_demo_postgres.qbs - демо пример для PostgreSQL (требуется пакет libpq-dev);

  4. db_demo_mssql.qbs - демо пример для MS SQL.

Драйвера в первую очередь разрабатывались для работы в Linux, поэтому эксплуатационное тестирование выполнялось именно для этой ОС. В Windows будет работать FireBird-драйвер (проверено), для остальных драйверов тестирование не проводилось.

Демо-примеры записывают следующие логи:

  • /tmp/db-demo-firebird.log

  • /tmp/db-demo-mssql.log

  • /tmp/db-demo-postgres.log

При первом запуске, примеры проверяют наличие тестовой базы данных. Если базы не обнаружено, в лог-файл будет выведен скрипт для ее создания.

Заключение

Черновой вариант статьи не предполагал наличие этого раздела, за что старый товарищ и, по совместительству, корректор подверг меня критике: "Мол, непонятна мотивация, целеполагание неясно. Зачем ты вообще писал эту статью?!" Что ж, исправляюсь!

В создание драйверов вложено много моего труда и труда коллег, потрачено время жизни. Зная нелюбовь программистов к внешним зависимостям, я не питаю иллюзий по поводу того, что представленные решения будут использоваться "как есть". Допускаю, что кто-то решит "выжечь каленым железом" ALog и заменит его на нечто свое - я не буду против (сам так поступаю с другими логгерами ;) В любом случае, если наши решения сэкономят кому-то время, или послужат отправной точной для новых идей - будет хорошо!

Подробнее..

Перевод Повышение производительности дебажных билдов в два-три раза

18.06.2021 02:21:22 | Автор: admin

Нам удалось добиться значительного повышения производительности рантайма для дебажной (отладочной) конфигурации по умолчанию Visual Studio в компиляторе C++ для x86/x64. Для программ, скомпилированных в режиме дебага в Visual Studio 2019 версии 16.10 Preview 2, мы отмечаем ускорение в 23 раза. Эти улучшения связаны с уменьшением накладных расходов на проверки ошибок в рантайме (/RTC), которые включены по умолчанию.

Дебажная конфигурация по умолчанию (Default debug configuration)

Когда вы компилируете свой код в Visual Studio с дебажной конфигурацией, по умолчанию компилятору C++ передаются некоторые флаги. Наиболее релевантными для этой статьи являются /RTC1, /JMC и /ZI.

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

Рассмотрим следующую простую функцию:

int foo() {    return 32;}

и сборку для x64, сгенерированную компилятором 16.9 при компиляции с флагами /RTC1 /JMC /ZI (ссылка Godbolt):

int foo(void) PROC                  $LN3:        push rbp        push rdi        sub rsp, 232                ; дополнительное пространство, выделенное из-за /ZI, /JMC        lea rbp, QWORD PTR [rsp+32]        mov rdi, rsp        mov ecx, 58                 ; (= x)        mov eax, -858993460         ; 0xCCCCCCCC        rep stosd                   ; записать 0xCC в стек для x DWORDов        lea rcx, OFFSET FLAT:__977E49D0_example@cpp        ; вызов из-за /JMC        call __CheckForDebuggerJustMyCode        mov eax, 32        lea rsp, QWORD PTR [rbp+200]        pop rdi        pop rbp        ret 0 int foo(void) ENDP

В показанной выше сборке флаги /JMC и /ZI добавляют в сумме 232 дополнительных байта в стек (строка 5). Это пространство в стеке не всегда необходимо. В сочетании с флагом /RTC1, который инициализирует выделенное пространство стека (строка 10), это потребляет много тактов ЦП. В этом конкретном примере, хоть выделенное пространство стека необходимо для правильного функционирования /JMC и /ZI, его инициализация - нет. Мы можем убедиться во время компиляции, что в этих проверках нет необходимости. Таких функций предостаточно в любой реальной кодовой базе на C++ - отсюда и выигрыш в производительности.

Далее мы глубже погрузимся в каждый из этих флагов, их взаимодействие с /RTC1 и узнаем, как мы избегаем ненужных накладных расходов.

/RTC1

Использование флага /RTC1 эквивалентно использованию обоих флагов /RTCs и /RTCu. /RTCs инициализирует стек функций с 0xCC для выполнения различных проверок в рантайме, а именно обнаружения неинициализированных локальных переменных, обнаружения переполнения или недозаполнения массива и проверки указателя стека (для x86). Вы можете посмотреть код, раздутый /RTC, здесь.

Как видно из приведенного выше ассемблерного кода (строка 10), инструкция rep stosd, внесенная /RTCs, является основной причиной замедления работы. Ситуация усугубляется, когда /RTC (или /RTC1) используется вместе с /JMC, /ZI или обоими.

Взаимодействие с /JMC

/JMC означает Just My Code Debugging (функциональ дебага только моего кода), и во время отладки он автоматически пропускает функции, написанные не вами (например, фреймворк, библиотека и другой непользовательский код). Он работает, вставляя вызов функции в пролог, который вызывает рантайм библиотеку. Это помогает дебагеру различать пользовательский и непользовательский код. Проблема здесь в том, что вставка вызова функции в пролог каждой функции в вашем проекте означает, что во всем вашем проекте больше не будет листовых функций (leaf functions). Если функции изначально не нужен какой-либо стек фрейм, теперь он ей будет нужен, потому что в соответствии с AMD64 ABI для Windows платформ нам нужно иметь по крайней мере четыре слота стека, доступные для параметров функции (так называемая домашняя область параметров - Param Home area). Это означает, что все функции, которые ранее не инициализировались /RTC, потому что они были листовыми функциями и не имели стек фрейма, теперь будут инициализированы. Наличие множества листовых функций в вашей программе - это нормально, особенно если вы используете сильно шаблонную библиотеку, такую как STL. В этом случае /JMC с радостью съест часть ваших тактов ЦП. Это не относится к x86 (32 бит), потому что там у нас нет домашней области параметров. Вы можете посмотреть эффекты /JMC здесь.

Взаимодействие с /ZI

Следующее взаимодействие, о котором мы поговорим, будет с /ZI. Он позволяет вашему коду использовать функцию Edit and Continue (изменить и продолжить), что означает, что вам не нужно перекомпилировать всю программу во время дебага для небольших изменений.

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

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

Решение

Корень всех этих проблем - ненужная инициализация. Неужели нам действительно нужно каждый раз инициализировать область стека? Нет. В компиляторе можно спокойно проконтролировать, когда инициализация стека действительно необходима. Например, она нужна вам, когда есть хотя бы одна адресная переменная, массив, объявленный в вашей функции, или неинициализированные переменные. В любом другом случае мы можем спокойно пропустить инициализацию, так как в любом случае мы не найдем ничего полезного с помощью проверок в рантайме.

Ситуация немного усложняется, когда вы компилируете с edit-and-continue, потому что теперь вы можете добавлять неинициализированные переменные в процессе дебага, которые могут быть обнаружены только в том случае, если мы инициализируем область стека. А мы, скорее всего, этого не сделали. Чтобы решить эту проблему, мы включили необходимые биты в дебажную информацию и предоставили ее через Debug Interface Access SDK. Эта информация сообщает дебагеру, где область заполнения, введенная /ZI начинается и заканчивается. Она также сообщает дебагеру, нужна ли функции инициализация стека. Если да, то отладчик безоговорочно инициализирует область стека в этом диапазоне памяти для функций, которые вы редактировали во время сеанса дебаггинга. Новые переменные всегда размещаются поверх этой инициализированной области, и наши проверки в рантайме теперь могут определить, безопасен ли ваш недавно добавленный код или нет.

Результаты

Мы скомпилировали следующие проекты в конфигурации отладки по умолчанию, а затем использовали сгенерированные исполняемые файлы для проведения тестов. Мы заметили 23-кратное улучшение во всех проектах, которые мы тестировали. Для проектов с сильным использованием STL могут потребоваться более значительные улучшения. Сообщите нам в комментариях о любых улучшениях, которые вы заметили в своих проектах. Проект 1 и Проект 2 предоставлены пользователями.

Расскажите нам, что вы думаете!

Мы надеемся, что это ускорение сделает ваш рабочий процесс дебаггинга эффективным и приятным. Мы постоянно прислушиваемся к вашим отзывам и работаем над улучшением вашего рабочего цикла. Мы хотели бы услышать о вашем опыте в комментариях ниже. Вы также можете связаться с нами в сообществе разработчиков, по электронной почте (visualcpp@microsoft.com) и в Twitter (@VisualC).


Напоминаем о том, что сегодня, в рамках курса "C++ Developer. Basic" пройдет второй день бесплатного интенсива по теме: "HTTPS и треды в С++. От простого к прекрасному".

Подробнее..

Вебинар Вычисляем на видеокартах. Технология OpenCL

18.06.2021 12:04:53 | Автор: admin
22 июня в 18.30 (Мск) Яндекс.Практикум проведет открытый вебинар Вычисляем на видеокартах. Технология OpenCL. На вебинаре расскажем, как использовать видеокарту в качестве полноценного вычислительного устройства, мощности которого чаще всего простаивают.

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



Для кого?


  • Для программистов, которые хотят освоить новую технологию и кардинально улучшить производительность программ
  • Для учёных, которым не хватает вычислительных мощностей для решения своих научных задач
  • Для всех, кому интересно, почему видеокарты завоевали мир и в чём секрет успешного майнинга


В программе вебинара


1. История возникновения и особенности видеокарт


Почему устройство, созданное для графики в играх, идеально для прогнозирования погоды, майнинга криптовалюты и deep learning

2. Архитектура видеокарты


О написании кода, если у вас 4000 ядер, правильном использовании видеопамяти, локальных и глобальных размерах, рабочих группах и ворпах

3. Конкретные цифры: обзор технологий


  • CUDA vs OpenCL. Преимущества и недостатки этих технологий
  • Чем отличаются видеокарты компаний NVidia и AMD Radeon


4. Как программировать на OpenCL


  • Пишем первую программу для видеокарты
  • Разберём, что можно, а что нельзя на OpenCL
  • Посмотрим, как использовать OpenCL не только с C++, но и с другими языками программирования


5. Популярные алгоритмы


  • Базовые алгоритмы и принципы программирования через призму видеокарты
  • Разбор типичных проблем


6. Отладка, обфускация, тулинг


  • Что делать, если ваш код BSODит. Отладка на видеокарте: миф или реальность
  • Профилируем OpenCL и делаем его быстрее
  • Как защитить свой код: пара слов об обфускации


7. Q&A-сессия



Ведущий Георгий xjossy Осипов, разработчик в Лаборатории компьютерной графики и мультимедиа ВМК МГУ и автор факультета Разработчик C++ в Яндекс.Практикуме

Вебинар пройдёт 22 июня в 18.30 (Мск). Подробности и регистрация
Подробнее..

Производительность компилятора при работе с концептами в C20

20.06.2021 18:15:44 | Автор: admin

Привет, меня зовут Александр, я старший разработчик ПО в Центре разработкиOrionInnovation. Хочу признаться, я люблю рассказывать про C++ и не только на различных митапах и конференциях.Ивотядобрался доХабра. НаCppConfRussiaPiter2020 я рассказывал про концепты и послевыступленияполучилочень много вопросов про производительность компилятора при работе сними.Замеры производительности не были цельюмоегодоклада:мне было известно, что концепты компилируются с примерно такой же скоростью, что и обычные метапрограммы,адодетального сравнения я смог добраться совершенно недавно.Спешуподелиться результатом!

Несколько слов о концептах

Концептыпереосмыслениеметапрограммирования, аналогичноеconstexpr.Еслиconstexprэто про вычисление выраженийво время компиляции, будь то факториал, экспонента и так далее, то концептыэто про перегрузки, специализации, условия существования сущностей.Вобщем, про чистоеметапрограммирование. Иными словами, в C++20 появилась возможность писать конструкциибез единой, привычной для нас треугольной скобки, тем самым получая возможность быстро и читаемо описать какую-либо перегрузку или специализацию:

// #1void increment(auto & arg) requires requires { ++arg; }; // #2void increment(auto &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);  // Вызывается #1    increment(ni); // Вызывается #2}

О том, как всё это работает, есть море информации,например, отличный гайд "Концепты: упрощаем реализацию классов STD Utility" по мотивам выступления Андрея Давыдова на C++ Russia 2019. Ну а мы сфокусируемся на том, какой ценой достигается подобный функционал, чтобы убедиться, чтоэтонетолькопросто, быстро и красиво, ноещёи эффективно.

Описание эксперимента

Итак, мы будем наблюдать за следующими показателями:

  1. Время компиляции

  1. Размер объектного файла

  1. Количество символов в записи (или же количество кода), в некоторых случаях

Прежде чем мы начнём несколько важных уточнений:

  • Во-первых, при подсчёте количества символов в записи мы будем считать все не пустые.

  • Во-вторых, в данной статье мы посмотрим лишь на самые простые (буквально несколько строк) случаи, чтобы быть уверенными на 100%, что мы сравниваем абсолютно аналогичные фрагменты кода.

  • В-третьих, поскольку компилируемые примеры крайне просты, время компиляции выражается в десятках миллисекунд.Чтобы исключитьпогрешность, длявремени компиляции мы будем использовать усреднённые значения за100запусков.

В замерах будут участвовать clang 12.0.0 и g++ 10.3.0, как с полной оптимизацией, так и без неё.

В качестве операционной системы выступит Ubuntu 16.04, запущенная на Windows 10 через WSL2.На всякий случай прилагаю характеристики ПК:

Характеристики ПК
------------------System Information------------------         Operating System: Windows 10 Enterprise 64-bit (10.0, Build 19043) (19041.vb_release.191206-1406)                 Language: Russian (Regional Setting: Russian)      System Manufacturer: Dell Inc.             System Model: Latitude 5491                     BIOS: 1.12.0 (type: UEFI)                Processor: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz (8 CPUs), ~2.3GHz                   Memory: 32768MB RAM      Available OS Memory: 32562MB RAM                Page File: 9995MB used, 27430MB available------------------------Disk & DVD/CD-ROM Drives------------------------      Drive: C: Free Space: 26.5 GBTotal Space: 243.0 GBFile System: NTFS      Model: SAMSUNG SSD PM871b M.2 2280 256GB

Эксперименты

Посленеобходимыхотступлениймы можем,наконец,начать эксперименты.

Эксперимент 1: Эволюция метапрограммирования

Для началапосмотрим на то, как компиляторы справляются с созданием перегрузки функции для инкрементируемых инеинкрементируемыхтипов данных аргумента. Компилируемый код для C++ 03, 17 и 20 представлены ниже. Один из показателей, а именнообъем кода, можно оценить уже сейчас: видно, что количество кода существенно сокращается по мере эволюции языка, уступая место читаемости и простоте.

Код
incrementable_03.cpp
// copied from boosttemplate<bool C, typename T = void>struct enable_if { typedef T type; };template<typename T>struct enable_if<false, T> {};namespace is_inc {    typedef char (&yes)[1]; typedef char (&no)[2];struct tag {};struct any { template &lt;class T&gt; any(T const&amp;); };tag operator++(any const &amp;);template&lt;typename T&gt;static yes test(T const &amp;);static no test(tag);template&lt;typename _T&gt; struct IsInc{    static _T &amp; type_value;    static const bool value = sizeof(yes) == sizeof(test(++type_value));};}template<typename T>struct IsInc : public is_inc::IsInc<T> {};template<class Ty>typename enable_if<IsInc<Ty>::value>::type increment(Ty &);template<class Ty>typename enable_if<!IsInc<Ty>::value>::type increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}
incrementable_17.cpp
#include <type_traits>template<class, class = std::void_t<>>struct IsInc : std::false_type {};template<class T>struct IsInc<T, std::void_t<decltype( ++std::declval<T&>() )>>    : std::true_type{};template<class Ty>std::enable_if_t<IsInc<Ty>::value> increment(Ty &);template<class Ty>std::enable_if_t<!IsInc<Ty>::value> increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}
incrementable_20.cpp
void increment(auto & arg) requires requires { ++arg; };void increment(auto &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}

Давайте взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

incrementable_03.cpp

clang

O0

43,02

1304

782

incrementable_17.cpp

clang

O0

67,46

1320

472

incrementable_20.cpp

clang

O0

43,42

1304

230

incrementable_03.cpp

clang

O3

47,21

1296

782

incrementable_17.cpp

clang

O3

77,77

1304

472

incrementable_20.cpp

clang

O3

45,70

1288

230

incrementable_03.cpp

gcc

O0

19,89

1568

782

incrementable_17.cpp

gcc

O0

34,71

1568

472

incrementable_20.cpp

gcc

O0

17,62

1480

230

incrementable_03.cpp

gcc

O3

18,44

1552

782

incrementable_17.cpp

gcc

O3

38,94

1552

472

incrementable_20.cpp

gcc

O3

18,57

1464

230

Как уже отмечалось ранее,количество кода существенно уменьшается по мере развития языка: c 782 до 472 и затем до 230.Разницапочти в 3,5 раза, если сравнитьС++20 и С++03 (на самом деле даже больше,т.к.порядка150170символов во всех примерахтестирующий код). Размеры объектного файла также постепенно уменьшаются. Что же современем компиляции? Странно, новремя компиляции 03 и 20 примерно равно, а вот в С++17в два раза больше. Давайте взглянем на код наших примеров: помимо всего прочего, в глаза бросается#includeв случае C++17. Давайте реализуемdeclval,enable_ifиvoid_tи проверим:

incrementable_no_tt.cpp
template<bool C, typename T = void>struct enable_if { typedef T type; };template<typename T>struct enable_if<false, T> {};template<bool B, typename T = void>using enable_if_t = typename enable_if<B, T>::type;template<typename ...>using void_t = void;template<class T>T && declval() noexcept;template<class, class = void_t<>>struct IsInc {    constexpr static bool value = false;};template<class T>struct IsInc<T, void_t<decltype( ++declval<T&>() )>>{    constexpr static bool value = true;};template<class Ty>enable_if_t<IsInc<Ty>::value> increment(Ty &);template<class Ty>enable_if_t<!IsInc<Ty>::value> increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}

И давайте обновим нашу таблицу:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

incrementable_03.cpp

clang

O0

43,02

1304

782

incrementable_17_no_tt.cpp

clang

O0

44,498

1320

714

incrementable_20.cpp

clang

O0

43,419

1304

230

incrementable_03.cpp

clang

O3

47,205

1296

782

incrementable_17_no_tt.cpp

clang

O3

47,327

1312

714

incrementable_20.cpp

clang

O3

45,704

1288

230

incrementable_03.cpp

gcc

O0

19,885

1568

782

incrementable_17_no_tt.cpp

gcc

O0

21,163

1584

714

incrementable_20.cpp

gcc

O0

17,619

1480

230

incrementable_03.cpp

gcc

O3

18,442

1552

782

incrementable_17_no_tt.cpp

gcc

O3

19,057

1568

714

incrementable_20.cpp

gcc

O3

18,566

1464

230

Время компиляции на 17 стандарте нормализовалось и стало практически равно времени компиляции 03 и 20, однако количество кода стало близко к самому тяжёлому, базовому варианту. Так что, если у вас есть под рукой C++20 и нужно написать какую-то простую мета-перегрузку,смело можно использовать концепты. Это читабельнее, компилируется примерно с такой же скоростью, а результат компиляции занимает меньше места.

Эксперимент 2: Ограничения для методов

Давайте взглянем на еще одну особенность: ограничение для функции или метода (в том числе и для конструкторов и деструкторов) на примере типаOptionalLike, имеющего деструктор по умолчанию в случае, если помещаемый объект тривиален, а иначедеструктор, выполняющийдеинициализациюкорректно. Код представлен ниже:

Код
optional_like_17.cpp
#include <type_traits>#include <string>template<typename T, typename = void>struct OptionalLike {    ~OptionalLike() {        /* Calls d-tor manually */    }};template<typename T>struct OptionalLike<T, std::enable_if_t<std::is_trivially_destructible<T>::value>>{    ~OptionalLike() = default;};void later() {    OptionalLike<int>         oli;    OptionalLike<std::string> ols;}
optional_like_20.cpp
#include <type_traits>#include <string>template<typename T>struct OptionalLike{    ~OptionalLike() {        /* Calls d-tor manually */    }    ~OptionalLike() requires (std::is_trivially_destructible<T>::value) = default;};void later() {    OptionalLike<int>         oli;    OptionalLike<std::string> ols;}

Давайте взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

optional_like_17.cpp

clang

O0

487,62

1424

319

optional_like_20.cpp

clang

O0

616,8

1816

253

optional_like_17.cpp

clang

O3

490,07

944

319

optional_like_20.cpp

clang

O3

627,64

1024

253

optional_like_17.cpp

gcc

O0

202,29

1968

319

optional_like_20.cpp

gcc

O0

505,82

1968

253

optional_like_17.cpp

gcc

O3

205,55

1200

319

optional_like_20.cpp

gcc

O3

524,54

1200

253

Мы видим, что новый вариант выглядит более читабельным и лаконичным (253 символа против 319 у классического), однако платим за это временем компиляции: оба компилятора как с оптимизацией, так и без показали худшее время компиляции в случае с концептами. GCC аж в 22,5 раза медленнее. При этом размер объектного файла уgccне изменяется вовсе, а в случаеclangбольше для концептов. Классический компромисс: либо меньше кода, но дольше компиляция, либо больше кода, но быстрее компиляция.

Эксперимент 3: Влияние использования концептов на время компиляции

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

Код
inline.cpp
template<typename T>void foo() requires (sizeof(T) >= 4) { }template<typename T>void foo() {}void later() {    foo<char>();    foo<int>();}
concept.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo() requires IsBig<T> { }template<typename T>void foo() {}void later() {    foo<char>();    foo<int>();}

Сразу взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

inline.cpp

clang

O0

38,666

1736

concept.cpp

clang

O0

39,868

1736

concept.cpp

clang

O3

42,578

1040

inline.cpp

clang

O3

43,610

1040

inline.cpp

gcc

O0

14,598

1976

concept.cpp

gcc

O0

14,640

1976

concept.cpp

gcc

O3

14,872

1224

inline.cpp

gcc

O3

14,951

1224

Как мы можем заметить,размеры получившихся объектных файлов идентичны, а показатели времени компиляции практически совпадают. Так что при выборе концепт илиinline-требованиеможнонезадумываться о производительности компилятора.

Эксперимент 4: Варианты ограничения функции

Теперь посмотрим на варианты наложения ограничения на шаблонные параметры на примере функций. Ограничить функцию можно аж четырьмя способами:

  • Имя концепта вместоtypename

  • Requires clauseпослеtemplate<>

  • Имя концепта рядом сauto

  • Trailing requiresclause

Давайте узнаем, какой же из предложенных способов самый оптимальный с точки зрения компиляции. Компилируемый код представлен ниже:

Код
instead_of_typename.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<IsBig T>void foo(T const &) { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}
after_template.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>    requires IsBig<T>void foo(T const &) { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}
with_auto.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo(IsBig auto const &) { }template<typename T>void foo(auto const &) {}void later() {    foo<char>('a');    foo<int>(1);}
requires_clause.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo(T const &) requires IsBig<T> { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}

А вот и результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

function_with_auto.cpp

clang

O0

40,878

1760

function_after_template.cpp

clang

O0

41,947

1760

function_requires_clause.cpp

clang

O0

42,551

1760

function_instead_of_typename.cpp

clang

O0

46,893

1760

function_with_auto.cpp

clang

O3

43,928

1024

function_requires_clause.cpp

clang

O3

45,176

1032

function_after_template.cpp

clang

O3

45,275

1032

function_instead_of_typename.cpp

clang

O3

50,42

1032

function_requires_clause.cpp

gcc

O0

16,561

2008

function_with_auto.cpp

gcc

O0

16,692

2008

function_after_template.cpp

gcc

O0

17,032

2008

function_instead_of_typename.cpp

gcc

O0

17,802

2016

function_requires_clause.cpp

gcc

O3

16,233

1208

function_with_auto.cpp

gcc

O3

16,711

1208

function_after_template.cpp

gcc

O3

17,216

1208

function_instead_of_typename.cpp

gcc

O3

18,315

1216

Как мы видим, время компиляции отличается незначительно, однако мы можем заметить следующее:

  • Вариант с использованием имени концепта вместоtypenameоказался самым медленным во всех случаях.

  • Вариантыtrailing requiresclauseили использование концепта рядом сautoоказались самыми быстрыми.

  • Варианты, где присутствуетtemplate<>на510% медленнее остальных.

  • Размерыобъектных файлов изменяются незначительно, однако вариант с именем концепта вместоtypenameоказался самым объемным в случаеgcc, а вариант сautoоказался наименее объемным в случаеclang.

Эксперимент 5: Влияние сложности концепта на время компиляции

Последнее, что мырассмотрим в рамкахданной статьи, и, наверное, самое интересное: влияние сложности концепта на время компиляции. Давайте возьмём и скомпилируем следующие примеры, где сложность используемого концепта (количество проверок или условий) возрастает от первого к последнему.

Код
concept_complexity_1.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept TestedConcept = ConceptA<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}
concept_complexity_2.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept ConceptB =  requires(T i, int x) {    { i++     } noexcept -> ConceptA;    { ++i     } noexcept -> ConceptA;    { i--     } noexcept -> ConceptA;    { --i     } noexcept -> ConceptA;    { i + i   } noexcept -> ConceptA;    { i - i   } noexcept -> ConceptA;    { i += i  } noexcept -> ConceptA;    { i -= i  } noexcept -> ConceptA;    { i * i      } noexcept -> ConceptA;    { i / i      } noexcept -> ConceptA;    { i % i      } noexcept -> ConceptA;    { i *= i     } noexcept -> ConceptA;    { i /= i     } noexcept -> ConceptA;    { i %= i     } noexcept -> ConceptA;    { i |  i     } noexcept -> ConceptA;    { i &  i     } noexcept -> ConceptA;    { i |= i     } noexcept -> ConceptA;    { i &= i     } noexcept -> ConceptA;    { ~i          } noexcept -> ConceptA;    { i ^  i      } noexcept -> ConceptA;    { i << x      } noexcept -> ConceptA;    { i >> x      } noexcept -> ConceptA;    { i ^=  i      } noexcept -> ConceptA;    { i <<= x      } noexcept -> ConceptA;    { i >>= x      } noexcept -> ConceptA;};template<typename T>concept ConceptC =  requires(T i, int x) {    { i++     } noexcept -> ConceptB;    { ++i     } noexcept -> ConceptB;    { i--     } noexcept -> ConceptB;    { --i     } noexcept -> ConceptB;    { i + i   } noexcept -> ConceptB;    { i - i   } noexcept -> ConceptB;    { i += i  } noexcept -> ConceptB;    { i -= i  } noexcept -> ConceptB;    { i * i      } noexcept -> ConceptB;    { i / i      } noexcept -> ConceptB;    { i % i      } noexcept -> ConceptB;    { i *= i     } noexcept -> ConceptB;    { i /= i     } noexcept -> ConceptB;    { i %= i     } noexcept -> ConceptB;    { i |  i     } noexcept -> ConceptB;    { i &  i     } noexcept -> ConceptB;    { i |= i     } noexcept -> ConceptB;    { i &= i     } noexcept -> ConceptB;    { ~i          } noexcept -> ConceptB;    { i ^  i      } noexcept -> ConceptB;    { i << x      } noexcept -> ConceptB;    { i >> x      } noexcept -> ConceptB;    { i ^=  i      } noexcept -> ConceptB;    { i <<= x      } noexcept -> ConceptB;    { i >>= x      } noexcept -> ConceptB;};template<typename T>concept ConceptD =  requires(T i, int x) {    { i++     } noexcept -> ConceptC;    { ++i     } noexcept -> ConceptC;    { i--     } noexcept -> ConceptC;    { --i     } noexcept -> ConceptC;    { i + i   } noexcept -> ConceptC;    { i - i   } noexcept -> ConceptC;    { i += i  } noexcept -> ConceptC;    { i -= i  } noexcept -> ConceptC;    { i * i      } noexcept -> ConceptC;    { i / i      } noexcept -> ConceptC;    { i % i      } noexcept -> ConceptC;    { i *= i     } noexcept -> ConceptC;    { i /= i     } noexcept -> ConceptC;    { i %= i     } noexcept -> ConceptC;    { i |  i     } noexcept -> ConceptC;    { i &  i     } noexcept -> ConceptC;    { i |= i     } noexcept -> ConceptC;    { i &= i     } noexcept -> ConceptC;    { ~i          } noexcept -> ConceptC;    { i ^  i      } noexcept -> ConceptC;    { i << x      } noexcept -> ConceptC;    { i >> x      } noexcept -> ConceptC;    { i ^=  i      } noexcept -> ConceptC;    { i <<= x      } noexcept -> ConceptC;    { i >>= x      } noexcept -> ConceptC;};template<typename T>concept TestedConcept = ConceptA<T> && ConceptB<T> && ConceptC<T> && ConceptD<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}
concept_complexity_3.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept ConceptB =  requires(T i, int x) {    { i++     } noexcept -> ConceptA;    { ++i     } noexcept -> ConceptA;    { i--     } noexcept -> ConceptA;    { --i     } noexcept -> ConceptA;    { i + i   } noexcept -> ConceptA;    { i - i   } noexcept -> ConceptA;    { i += i  } noexcept -> ConceptA;    { i -= i  } noexcept -> ConceptA;    { i * i      } noexcept -> ConceptA;    { i / i      } noexcept -> ConceptA;    { i % i      } noexcept -> ConceptA;    { i *= i     } noexcept -> ConceptA;    { i /= i     } noexcept -> ConceptA;    { i %= i     } noexcept -> ConceptA;    { i |  i     } noexcept -> ConceptA;    { i &  i     } noexcept -> ConceptA;    { i |= i     } noexcept -> ConceptA;    { i &= i     } noexcept -> ConceptA;    { ~i          } noexcept -> ConceptA;    { i ^  i      } noexcept -> ConceptA;    { i << x      } noexcept -> ConceptA;    { i >> x      } noexcept -> ConceptA;    { i ^=  i      } noexcept -> ConceptA;    { i <<= x      } noexcept -> ConceptA;    { i >>= x      } noexcept -> ConceptA;};template<typename T>concept ConceptC =  requires(T i, int x) {    { i++     } noexcept -> ConceptB;    { ++i     } noexcept -> ConceptB;    { i--     } noexcept -> ConceptB;    { --i     } noexcept -> ConceptB;    { i + i   } noexcept -> ConceptB;    { i - i   } noexcept -> ConceptB;    { i += i  } noexcept -> ConceptB;    { i -= i  } noexcept -> ConceptB;    { i * i      } noexcept -> ConceptB;    { i / i      } noexcept -> ConceptB;    { i % i      } noexcept -> ConceptB;    { i *= i     } noexcept -> ConceptB;    { i /= i     } noexcept -> ConceptB;    { i %= i     } noexcept -> ConceptB;    { i |  i     } noexcept -> ConceptB;    { i &  i     } noexcept -> ConceptB;    { i |= i     } noexcept -> ConceptB;    { i &= i     } noexcept -> ConceptB;    { ~i          } noexcept -> ConceptB;    { i ^  i      } noexcept -> ConceptB;    { i << x      } noexcept -> ConceptB;    { i >> x      } noexcept -> ConceptB;    { i ^=  i      } noexcept -> ConceptB;    { i <<= x      } noexcept -> ConceptB;    { i >>= x      } noexcept -> ConceptB;};template<typename T>concept ConceptD =  requires(T i, int x) {    { i++     } noexcept -> ConceptC;    { ++i     } noexcept -> ConceptC;    { i--     } noexcept -> ConceptC;    { --i     } noexcept -> ConceptC;    { i + i   } noexcept -> ConceptC;    { i - i   } noexcept -> ConceptC;    { i += i  } noexcept -> ConceptC;    { i -= i  } noexcept -> ConceptC;    { i * i      } noexcept -> ConceptC;    { i / i      } noexcept -> ConceptC;    { i % i      } noexcept -> ConceptC;    { i *= i     } noexcept -> ConceptC;    { i /= i     } noexcept -> ConceptC;    { i %= i     } noexcept -> ConceptC;    { i |  i     } noexcept -> ConceptC;    { i &  i     } noexcept -> ConceptC;    { i |= i     } noexcept -> ConceptC;    { i &= i     } noexcept -> ConceptC;    { ~i          } noexcept -> ConceptC;    { i ^  i      } noexcept -> ConceptC;    { i << x      } noexcept -> ConceptC;    { i >> x      } noexcept -> ConceptC;    { i ^=  i      } noexcept -> ConceptC;    { i <<= x      } noexcept -> ConceptC;    { i >>= x      } noexcept -> ConceptC;};template<typename T>concept ConceptE =  requires(T i, int x) {    { i++     } noexcept -> ConceptD;    { ++i     } noexcept -> ConceptD;    { i--     } noexcept -> ConceptD;    { --i     } noexcept -> ConceptD;    { i + i   } noexcept -> ConceptD;    { i - i   } noexcept -> ConceptD;    { i += i  } noexcept -> ConceptD;    { i -= i  } noexcept -> ConceptD;    { i * i      } noexcept -> ConceptD;    { i / i      } noexcept -> ConceptD;    { i % i      } noexcept -> ConceptD;    { i *= i     } noexcept -> ConceptD;    { i /= i     } noexcept -> ConceptD;    { i %= i     } noexcept -> ConceptD;    { i |  i     } noexcept -> ConceptD;    { i &  i     } noexcept -> ConceptD;    { i |= i     } noexcept -> ConceptD;    { i &= i     } noexcept -> ConceptD;    { ~i          } noexcept -> ConceptD;    { i ^  i      } noexcept -> ConceptD;    { i << x      } noexcept -> ConceptD;    { i >> x      } noexcept -> ConceptD;    { i ^=  i      } noexcept -> ConceptD;    { i <<= x      } noexcept -> ConceptD;    { i >>= x      } noexcept -> ConceptD;};template<typename T>concept ConceptF =  requires(T i, int x) {    { i++     } noexcept -> ConceptE;    { ++i     } noexcept -> ConceptE;    { i--     } noexcept -> ConceptE;    { --i     } noexcept -> ConceptE;    { i + i   } noexcept -> ConceptE;    { i - i   } noexcept -> ConceptE;    { i += i  } noexcept -> ConceptE;    { i -= i  } noexcept -> ConceptE;    { i * i      } noexcept -> ConceptE;    { i / i      } noexcept -> ConceptE;    { i % i      } noexcept -> ConceptE;    { i *= i     } noexcept -> ConceptE;    { i /= i     } noexcept -> ConceptE;    { i %= i     } noexcept -> ConceptE;    { i |  i     } noexcept -> ConceptE;    { i &  i     } noexcept -> ConceptE;    { i |= i     } noexcept -> ConceptE;    { i &= i     } noexcept -> ConceptE;    { ~i          } noexcept -> ConceptE;    { i ^  i      } noexcept -> ConceptE;    { i << x      } noexcept -> ConceptE;    { i >> x      } noexcept -> ConceptE;    { i ^=  i      } noexcept -> ConceptE;    { i <<= x      } noexcept -> ConceptE;    { i >>= x      } noexcept -> ConceptE;};template<typename T>concept ConceptG =  requires(T i, int x) {    { i++     } noexcept -> ConceptF;    { ++i     } noexcept -> ConceptF;    { i--     } noexcept -> ConceptF;    { --i     } noexcept -> ConceptF;    { i + i   } noexcept -> ConceptF;    { i - i   } noexcept -> ConceptF;    { i += i  } noexcept -> ConceptF;    { i -= i  } noexcept -> ConceptF;    { i * i      } noexcept -> ConceptF;    { i / i      } noexcept -> ConceptF;    { i % i      } noexcept -> ConceptF;    { i *= i     } noexcept -> ConceptF;    { i /= i     } noexcept -> ConceptF;    { i %= i     } noexcept -> ConceptF;    { i |  i     } noexcept -> ConceptF;    { i &  i     } noexcept -> ConceptF;    { i |= i     } noexcept -> ConceptF;    { i &= i     } noexcept -> ConceptF;    { ~i          } noexcept -> ConceptF;    { i ^  i      } noexcept -> ConceptF;    { i << x      } noexcept -> ConceptF;    { i >> x      } noexcept -> ConceptF;    { i ^=  i      } noexcept -> ConceptF;    { i <<= x      } noexcept -> ConceptF;    { i >>= x      } noexcept -> ConceptF;};template<typename T>concept TestedConcept = ConceptA<T> && ConceptB<T> && ConceptC<T> && ConceptD<T> &&                                       ConceptE<T> && ConceptF<T> && ConceptG<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}

Давайте взглянем на результат:

Файл

Компиляция

Время, мс

Количество символов, шт

concept_complexity_1.cpp

clang

O0

37,441

201

concept_complexity_2.cpp

clang

O0

38,211

2244

concept_complexity_3.cpp

clang

O0

39,989

4287

concept_complexity_1.cpp

clang

O3

40,062

201

concept_complexity_2.cpp

clang

O3

40,659

2244

concept_complexity_3.cpp

clang

O3

43,314

4287

concept_complexity_1.cpp

gcc

O0

15,352

201

concept_complexity_2.cpp

gcc

O0

16,077

2244

concept_complexity_3.cpp

gcc

O0

18,091

4287

concept_complexity_1.cpp

gcc

O3

15,243

201

concept_complexity_2.cpp

gcc

O3

17,552

2244

concept_complexity_3.cpp

gcc

O3

18,51

4287

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

Заключение

В результате вышеописанных экспериментов мы можем сделать следующие выводы:

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

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

  • Время компиляции прямо пропорционально сложности концептов/constraint'ов.

Post Scriptum

Во-первых, к статье прилагаюссылку на гитхаб, пройдя по которой вы можете найтискриптыдля запуска тестов,а такжеиспользуемыев статьефрагменты кода и повторить некоторые (а может и все) тесты локально.

Ну а во-вторых,мне быочень хотелосьувидеть, как ведут себя компиляторы с более сложными конструкциями.Если вы знаете/придумали подходящиепримеры, смелопишите о них в комментариях,ияс радостьюпроизведузамеры.

Подробнее..

Хочу больше годных профстатей, Хабр

21.06.2021 10:15:25 | Автор: admin

Листая страницы Хабра, поймал себя на мысли, что я воспринимаю Хабр как новостную ленту в социальной сети. То есть как нечто, что прямого отношения лично ко мне не имеет и касается меня очень косвенным путем. Нечто полуразвлекательное-полупознавательное.

Ну, судите сами. Вот примерный список тем, которые превалируют на Хабре.

  1. Что там новенького у Илона Петровича Маска.

  2. Как с помощью Arduino, говна и палок сделать годный фаллоимитатор радиоприемник.

  3. Как я ушел с прошлой работы, и как мне было там плохо.

  4. Как я нашел свою текущую работу, и какая она крутая.

  5. Как живется специалисту X в стране Y.

  6. Какой путь нужно проделать фельдшеру из Ангарска, чтобы стать тестировщиком мобильных приложений в Ирландии.

  7. Обсуждение новомодной платформы для веб-разработки, которая через 3 года станет старомодной.

  8. Промываем косточки крупным компаниям.

  9. Исторические экскурсы в IT/технологии/медицину.

  10. Реклама компаний.

  11. Мнения обо всем отвлеченном на свете.

  12. И т. д.

Все эти темы и все статьи неплохие, интересные. Но я хотел бы другого.

Я давно программирую на С++/qt. Думаю, что могу создать с нуля любой программный продукт (desktop) . Могу набирать команды, могу выстраивать отношения с заказчиками и т.д. Периодически приходится запускать (создавать) новые направления/программные продукты. Однако время, затрачиваемое мной и командой на каждый новый продукт, остается примерно постоянным. Вернее, не совсем так. Время суммарной работы оказывается в прямой пропорциональной зависимости от сложности продукта (объема кодовой базы). То есть постоянной величиной оказывается производительность работ, или эффективность труда. На всякий случай оговорюсь, что речь не идет о новичках, в команде только опытные толковые сотрудники.

Так вот, эту самую производительность труда очень хотелось бы увеличить. Как и в чем мне может помочь Хабр? Или я зря надеюсь?

Мне хотелось бы поднимать свой профессиональный уровень серьезными профессиональными статьями по проектированию/созданию/ведению больших продуктов. Статьями, которые по легкости восприятия были бы такого же классного уровня, как и сейчас большинство статей на Хабре. Но статьями, которые по глубине и полезности были бы как классические книги Скотта Майерса, банды четырех, Алана Купера, Роберта Мартина и др. Знаете, читая эти книги, я прибавлял каждый раз в квалификации. К сожалению, читая статьи на Хабре, я этого не чувствую. Даже более того: не могу припомнить случая, когда я хотел изучить какой-то новый для меня (и обычно нетривиальный) нюанс и находил бы его на Хабре. Я находил его где угодно, но только не на Хабре. Или вообще не находил.

Посему я очень жду и буду приветствовать появление на Хабре статей по следующим направлениям.

Новые шаблоны проектирования (С++)

Да, я знаю, что шаблоны не догма и не панацея, и всё всегда можно придумать и самому. Но я также знаю, что это проверенная годами экономия времени архитекторов и программистов. Это кругозор, который (при его наличии) позволяет делать сложную работу быстрее, а то и вообще моментально. У меня сложилось ощущение, что в мире С++ развитие шаблонов практически остановилось на известной книге банды четырех. Там описано 23 шаблона, и еще примерно столько же можно накопать в интернете. Я хочу больше! В моей практике были случаи, когда приходилось создавать шаблоны, разительно непохожие на известные. И приходилось тратить довольно много времени на приведение их к товарному использованию, хотя бы даже на продумывание такой терминологии, которая бы естественно бы воспринималась коллегами. Уверен, что если бы мы имели возможность в нужный момент найти и прочитать описание свежего шаблончика, наша работа местами была бы намного быстрее.

Думаю, на Хабре замечательно бы смотрелись статьи в классическом стиле банды четырех: описание и идеология шаблона, пример реальной задачи, код С++, границы применения.

Кстати, по шаблонам есть фундаментальный труд POSA: 5-томник на 2000+ страниц, перевода на русский язык до сих пор нет. Чем не непаханное поле?

Шаблоны ведения проектов в Git

Под этим можно подразумевать стратегии ветвления, схемы тестирования и создания релизов на ветках Git. Думаю, для многих было бы полезно появление серии статей в таком духе:

  1. Я веду маленький проект на 20 тысяч строк кода, в нем задействовано 5 человек, с системой контроля версий мы работаем вот так (и описать конкретно, вплоть до команд командной строки).

  2. Я веду неплохой проект на 100 тысяч строк кода, в нем задействовано 10 человек, и мы работаем вот так: схемы, команды git.

  3. Мы с тремя командами по 10 человек развиваем проект на 1 миллион строк кода, продаем продукт разным клиентам в разных конфигурациях, и всё свое хозяйство мы покрыли регрессионным тестированием. И для этого мы делаем вот это: схемы, команды git.

  4. У нас работает 200 человек, и у нас 10 миллионов строк кода в монорепе на 5 продуктов, и каждый продукт ещё поставляется в трех разных версиях. Мы опытным путем пришли, что только так: схемы, команды git.

  5. А у нас очень много кода и много микросервисов, каждый в своем репозитории, впрочем, есть и подрепозитории (подмодули). Часть кода покрыта тестами, часть нет. Раньше мы работали вот так, а теперь перешли на вот это (схемы, команды), но по эффективности труда одинаково приемлемо.

GUI

Дизайн GUI, новые подходы, примеры удачных и неудачных виджетов, примеры плохих и хороших интерфейсов/решений. Эта область очень динамично развивается в плане того, что мы видим на экранах своих смартфонов и мониторов (причем на смартфонах более динамично, чем на мониторах). Однако в плане научного описания, осмысления и систематизации всё как-то вяло. То есть решений полно, но ими никто не делится. А если и делится, то как-то не очень доходит до конкретных исполнителей, и те пишут с нуля. Думаю, ситуация бы улучшилась, если бы появилась серия статей в таком духе:

  1. Камрады, я внедрил к себе гамбургер-меню на 500 строк (qt) вот отсюда (ссылка). Вот, смотрите: скриншот, gif-анимация. Работает чётко! Лицензия LGPL. Короче, пользуйтесь все.

  2. Я создал свой виджет ввода паролей. Он круче, чем другие по таким-то причинам. Делюсь! Ссылка на репозиторий, скриншоты.

  3. Я смог объединить панели меню и тулбаров в одной панели. Получился принципиально новый виджет, похожий одновременно и на меню, и на тулбары. Область применения вот такая, описываю. Скриншоты, описание даю, а вот код нет!

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

  5. Раньше у нас был такой интерфейс, и такие задачи. А потом добавилась еще одна, и мы в корне переделали интерфейс. Рассказываю, почему прошлый интерфейс был оптимален, а текущий супероптимален.

  6. Я создал свою библиотеку виджетов, да еще с библиотекой стилей. Ссылка. Область применения такая-то. Могу продать хорошим людям (или подарить).

  7. Я супермегадизайнер, и на примере 30 известных приложений за последний год объясню вам, что попало в тренд, что не попало, а что создает будущий тренд.

Успешный опыт решения важных и нетривиальных задач из самой близкой действительности

Знаете, меня не сильно цепляют новости, о том, какие планы у Джеффа Безоса на космос или как Boston Dynamics обучает своего пса. Это не увеличивает мою зарплату. Я хочу чего-то более близкого и понятного мне, но самое главное применимого в моей работе.

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

Сложные ли это задачи? Непростые, но решаемые. И некоторые из них решены многократно самыми разными производителями софта.

Давайте еще пример. Я запускаю приложение X и параллельно еще парочку для полного занятия вычислительных ресурсов (например, конвертор видео). И вот в приложении X вижу слайд-анимацию, который замечательно себя ведет (нисколько не тормозит) при том, что соседние приложения на 100% заняли мой процессор. Это неплохой результат для X, и, черт возьми, я хочу знать, как они этого добились.

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

Вместо послесловия

Изложенные мной хотелки уже присутствуют в некотором небольшом количестве на Хабре, а помимо этого, подобную информацию по крупинкам можно собирать на конференциях, искать в книгах и некоторых научных журналах. Но трудозатраты слишком велики! Бывает, читаешь книгу в 500 страниц, чтобы найти хоть намек на решение, а его там не оказывается. Или, того хуже, оказывается, но такой, что лучше бы его не было Статьи в научных журналах часто оказываются написанными не с целью поделиться со всем миром своими достижениями, а просто формально отчитаться за очередной грант. Да и вообще, кажется, что печатные научные журналы по программированию эволюционно проигрывают интернет-платформам.

В общем, хочется надеяться, что Хабр сможет подобрать это непростое знамя нести серьезную, неразвлекательную науку в профессиональные сообщества, но делать это легко и изящно. И создавать условия для современных Скоттов Майерсов, готовых писать статьи для русскоязычной аудитории и потом компилировать их них свои бестселлеры. Надеюсь, жду, верю И всем не болеть!

Подробнее..

C20 удивить линкер четыремя строчками кода

09.06.2021 16:12:52 | Автор: admin

Представьте себе, что вы студент, изучающий современные фичи C++. И вам дали задачу по теме concepts/constraints. У преподавателя, конечно, есть референсное решение "как правильно", но для вас оно неочевидно, и вы навертели гору довольно запутанного кода, который всё равно не работает. (И вы дописываете и дописываете всё новые перегрузки и специализации шаблонов, покрывая всё новые и новые претензии компилятора).

А теперь представьте себе, что вы преподаватель, который увидел эту гору, и захотел помочь студенту. Вы стали упрощать и упрощать его код, и даже тупо комментировать куски юнит-тестов, чтобы оно хоть как-то заработало... А оно всё равно не работает. Причём, в зависимости от порядка юнит-тестов, выдаёт разные результаты или вообще не собирается. Где-то спряталось неопределённое поведение. Но какое?

Сперва преподаватель (то есть, я) минимизировал код вот до такого: https://gcc.godbolt.org/z/TaMTWqc1T

// пусть у нас есть концепты указателя и вектораtemplate<class T> concept Ptr = requires(T t) { *t; };template<class T> concept Vec = requires(T t) { t.begin(); t[0]; };// и три перегрузки функций, рекурсивно определённые друг через другаtemplate<class T> void f(T t) {  // (1)  std::cout << "general case " << __PRETTY_FUNCTION__ << std::endl;}template<Ptr T> void f(T t) {  // (2)  std::cout << "pointer to ";  f(*t);  // допустим, указатель не нулевой}template<Vec T> void f(T t) {  // (3)  std::cout << "vector of ";  f(t[0]);  // допустим, вектор не пустой}// и набор тестов (в разных файлах)int main() {  std::vector<int> v = {1};    // тест А  f(v);  // или тест Б  f(&v);  // или тест В  f(&v);  f(v);  // или тест Г  f(v);  f(&v);}

Мы ожидаем, что

  • f(v) выведет "vector of general case void f(T) [T=int]"

  • f(&v) выведет "pointer to vector of general case void f(T) [T=int]"

А вместо это получаем

  • А: "vector of general case void f(T) [T=int]"

  • Б: "pointer of general case void f(T) [T=std::vector<int>]" ?

  • В: clang выводит Б и А ?!, gcc ошибку линкера

  • Г: clang и gcc выводят ошибку линкера

Что здесь не так?!

А не так здесь две вещи. Первая это то, что из функции (2) видны объявления только (1) и (2), поэтому результат разыменования указателя вызывается как (1).

Без концептов и шаблонов это тоже прекрасно воспроизводится: https://gcc.godbolt.org/z/47qhYv6q4

void f(int x)    { std::cout << "int" << std::endl; }void g(char* p)  { std::cout << "char* -> "; f(*p); }  // f(int)void f(char x)   { std::cout << "char" << std::endl; }void g(char** p) { std::cout << "char** -> "; f(**p); }  // f(char)int main() {  char x;  char* p = &x;  f(x);  // char  g(p);  // char* -> int  g(&p); // char** -> char}

В отличие от инлайн-определений функций-членов в классе, где все объявления видны всем, определение свободной функции видит только то, что находится выше по файлу.

Из-за этого, кстати, для взаимно-рекурсивных функций приходится отдельно писать объявления, отдельно (ниже) определения.

Ладно, с этим разобрались. Вернёмся к шаблонам. Почему в тестах В и Г мы получили нечто, похожее на нарушение ODR?

Если мы перепишем код вот так:

template<class T> void f(T t) {.....}template<class T> void f(T t) requires Ptr<T> {.....}template<class T> void f(T t) requires Vec<T> {.....}

то ничего не изменится. Это просто другая форма записи. Требование соответствия концепту можно записать и так, и этак.

Но вот если прибегнем к старому доброму трюку SFINAE, https://gcc.godbolt.org/z/4sar6W6Kq

// добавим второй аргумент char или int - для разрешения неоднозначностиtemplate<class T, class = void> void f(T t, char) {.....}template<class T> auto f(T t, int) -> std::enable_if_t<Ptr<T>, void> {.....}template<class T> auto f(T t, int) -> std::enable_if_t<Vec<T>, void> {.....}..... f(v, 0) .......... f(&v, 0) .....

или ещё более старому доброму сопоставлению типов аргументов, https://gcc.godbolt.org/z/PsdhsG6Wr

template<class T> void f(T t) {.....}template<class T> void f(T* t) {.....}template<class T> void f(std::vector<T> t) {.....}

то всё станет работать. Не так, как нам хотелось бы (рекурсия по-прежнему сломана из-за правил видимости), но ожидаемо (вектор из f(T*) видится как "general case", из main - как "vector").

Что же ещё с концептами/ограничениями?

Коллективный разум, спасибо RSDN, подсказал ещё более минималистичный код!

Всего 4 строки: https://gcc.godbolt.org/z/qM8xYKfqe

template<class T> void f() {}void g() { f<int>(); }template<class T> void f() requires true {}void h() { f<int>(); }

Функция с ограничениями считается более предпочтительной, чем функция без них. Поэтому g() по правилам видимости выбирает из единственного варианта, а h() - из двух выбирает второй.

И вот этот код порождает некорректный объектный файл! В нём две функции с одинаковыми декорированными именами.

Оказывается, современные компиляторы (clang 12.0, gcc 12.0) не умеют учитывать requires в декорировании имён. Как когда-то старый глупый MSVC6 не учитывал параметры шаблона, если те не влияли на тип функции...

И, судя по ответам разработчиков, не только не умеют, но и не хотят. Отмазка: "если в разных точках программы одинаковые обращения к шаблону резолвятся по-разному, такая программа ill-formed, никакой диагностики при этом не нужно" (однако, ill-formed означает "не скомпилируется", а не "скомпилируется как попало"...)

Проблема известна с 2017 года, но прогресса пока нет.

Так что живите с этим. И не забывайте объявлять взаимно-рекурсивные функции до определений. А если увидите странные ошибки линкера, то хотя бы будете понимать, из-за чего они возникают. (А если компилятор будет инлайнить наобум, ну, тогда не повезло).

Подробнее..

Разработка стековой виртуальной машины и компилятора под неё (часть II)

04.06.2021 20:14:20 | Автор: admin

В первой части Разработка стековой виртуальной машины и компилятора под неё (часть I) сделал свою элементарную стековую виртуальную машину, которая умеет работать со стеком, делать арифметику с целыми числами со знаком, условные переходы и вызовы функций с возвратом. Но так как целью было создать не только виртуальную машину, но и компилятор C подобного языка, пришло время сделать первые шаги в сторону компиляции. Опыта никакого. Буду действовать по разумению.

Одним из первых этапов компиляции является синтаксический анализ (parsing) исходного кода нашего "C подобного языка" (кстати, надо какое-то название дать как только он станет более формальным). Задача простая - "нарубить" массив символов (исходный код) на список классифицированных токенов, чтобы последующий разбор и компиляция не вызывали сложности.

Итак, первый шаг - определиться с типами токенов (интуитивно будем ориентироваться на синтаксис C), символами которые будут отделять токены друг от друга, а также определить структуру для хранения токена (тип, текст токена, длина, строка и столбец в исходном коде).

constexpr char* BLANKS = "\x20\n\t";constexpr char* DELIMETERS = ",;{}[]()=><+-*/&|~^!.";enum class TokenType {NONE = 0, UNKNOWN, IDENTIFIER,CONST_CHAR, CONST_INTEGER, CONST_REAL, CONST_STRING,COMMA, MEMBER_ACCESS, EOS, OP_BRACES, CL_BRACES, OP_BRACKETS, CL_BRACKETS, OP_PARENTHESES, CL_PARENTHESES,BYTE, SHORT, INT, LONG, CHAR, FLOAT, DOUBLE, STRING, IF, ELSE, WHILE, RETURN,ASSIGN, EQUAL, NOT_EQUAL, GREATER, GR_EQUAL, LESS, LS_EQUAL,PLUS, MINUS, MULTIPLY, DIVIDE, AND, OR, XOR, NOT, SHL, SHR,LOGIC_AND, LOGIC_OR, LOGIC_NOT};typedef struct {TokenType type;          char* text;              WORD length;             WORD row;                WORD col;                } Token;

Далее напишем класс для разбора, ключевым методом которого станет parseToTokens(char*). Тут алгоритм простой: идем до разделителя (BLANKS и DELIMETERS), определяем начало и конец токена, классифицируем его и добавляем в вектор (список) токенов. Особые случаи разбора - это отличать целые числа от вещественных, вещественные числа (например, "315.0") отличать от применения оператора доступа к членам структуры/объекта ("obj10.field1"), а также отличать ключевые слова от других идентификаторов.

void VMParser::parseToTokens(const char* sourceCode) {TokenType isNumber = TokenType::UNKNOWN;bool insideString = false;                                         // inside string flagbool isReal = false;                                               // is real number flagsize_t length;                                                     // token length variablechar nextChar;                                                     // next char variablebool blank, delimeter;                                             // blank & delimeter char flagstokens->clear();                                                   // clear tokens vectorrowCounter = 1;                                                    // reset current row counterrowPointer = (char*)sourceCode;                                    // set current row pointer to beginningchar* cursor = (char*)sourceCode;                                  // set cursor to source beginning char* start = cursor;                                              // start new token from cursorchar value = *cursor;                                              // read first char from cursorwhile (value != NULL) {                                            // while not end of stringblank = isBlank(value);                                          // is blank char found?delimeter = isDelimeter(value);                                  // is delimeter found?length = cursor - start;                                         // measure token length    // Diffirentiate real numbers from member access operator '.'isNumber = identifyNumber(start, length - 1);                    // Try to get integer part of real numberisReal = (value=='.' && isNumber==TokenType::CONST_INTEGER);     // Is current token is real numberif ((blank || delimeter) && !insideString && !isReal) {          // if there is token separator                   if (length > 0) pushToken(start, length);                      // if length > 0 push token to vectorif (value == '\n') {                                           // if '\n' found rowCounter++;                                                // increment row counterrowPointer = cursor + 1;                                     // set row beginning pointer}nextChar = *(cursor + 1);                                      // get next char after cursorif (!blank && isDelimeter(nextChar)) {                         // if next char is also delimeterif (pushToken(cursor, 2) == TokenType::UNKNOWN)              // try to push double char delimeter tokenpushToken(cursor, 1);                                      // if not pushed - its single char delimeterelse cursor++;                                               // if double delimeter, increment cursor} else pushToken(cursor, 1);                                   // else push single char delimeterstart = cursor + 1;                                            // calculate next token start pointer}else if (value == '"') insideString = !insideString;             // if '"' char - flip insideString flag else if (insideString && value == '\n') {                        // if '\n' found inside string// TODO warn about parsing error}cursor++;                                                        // increment cursor pointervalue = *cursor;                                                 // read next char}length = cursor - start;                                           // if there is a last tokenif (length > 0) pushToken(start, length);                          // push last token to vector}

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

class VMParser {public:VMParser();~VMParser();void parseToTokens(const char* sourceCode);Token getToken(size_t index);size_t getTokenCount();  private:vector<Token>* tokens;WORD rowCounter;char* rowPointer;  bool isBlank(char value);bool isDelimeter(char value);TokenType pushToken(char* text, size_t length);TokenType getTokenType(char* text, size_t length);TokenType identifyNumber(char* text, size_t length);TokenType identifyKeyword(char* text, size_t length);};

Попробуем используя класс VMParser разобрать строку исходного кода C подобного языка (исходный код бессмысленный, просто набор случайных токенов для проверки разбора):

int main(){    printf ("Wow!");    float a = 365.0 * 10 - 10.0 / 2 + 3;while (1 != 2) {    abc.v1 = 'x';}if (a >= b) return a && b; else a || b; };

В итоге получаем в консоли следующий перечень классифицированных токенов:

Результат разбора исходного кода C подобного языкаРезультат разбора исходного кода C подобного языка

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

Для простоты сначала реализуем компилятор арифметических выражений с целыми числами (приоритет "*" и "/" над "+" и "-" учитывается), без скобок, унарных операций и других важных вещей, в том числе проверки синтаксических ошибок. Разбор выражений напишем вот так:

void VMCompiler::parseExpression(size_t startIndex, VMImage* destImage) {Token tkn;currentToken = startIndex;parseTerm(destImage);tkn = parser->getToken(currentToken);while (tkn.type==TokenType::PLUS || tkn.type==TokenType::MINUS) {  currentToken++;  parseTerm(destImage);  if (tkn.type==TokenType::PLUS) destImage->emit(OP_ADD); else destImage->emit(OP_SUB);  tkn = parser->getToken(currentToken);}}void VMCompiler::parseTerm(VMImage* destImage) {Token tkn;parseFactor(destImage);currentToken++;tkn = parser->getToken(currentToken);while (tkn.type == TokenType::MULTIPLY || tkn.type == TokenType::DIVIDE) {  currentToken++;  parseFactor(destImage);  if (tkn.type == TokenType::MULTIPLY) destImage->emit(OP_MUL); else destImage->emit(OP_DIV);  currentToken++;  tkn = parser->getToken(currentToken);}}void VMCompiler::parseFactor(VMImage* destImage) {Token tkn = parser->getToken(currentToken);char buffer[32];strncpy(buffer, tkn.text, tkn.length);buffer[tkn.length] = 0;destImage->emit(OP_CONST, atoi(buffer));}

Попробуем скормить этому компилятору выражение "3+5*6+2*3+15/5", запускаем компилятор с выводом скомпилированных команд и сразу запускаем виртуальную машину. Ожидаем, что результат вычисления должен остаться на вершине стека - 42.

Ура! Получилось! Первые шаги в сторону компилятора сделаны.

Подробнее..

Перевод Альтернатива ML-Agents интегрируем нейросети в Unity-проект с помощью PyTorch C API

05.06.2021 10:15:16 | Автор: admin


Кратко объясню, что будет происходить в этой статье:

  • покажу, как использовать PyTorch C++ API для интеграции нейросети в проект на движке Unity;
  • сам проект я подробно описывать не буду, это не имеет значения для данной статьи;
  • использую готовую модель нейросети, преобразовав её трассировку в бинарник, который будет подгружаться в рантайме;
  • покажу, что такой подход существенно облегчает деплой сложных проектов (например, нет проблем с синхронизацией сред Unity и Python).

Добро пожаловать в реальный мир


Методы машинного обучения, в том числе нейронные сети, по-прежнему очень комфортно себя чувствуют в экспериментальных окружениях, а запуск таких проектов в реальном мире часто вызывает трудности. Я немного расскажу про эти трудности, опишу ограничения по способам выхода из них, а также дам поэтапное решение проблемы интеграции нейросети в Unity-проект.

Другими словами, мне нужно превратить исследовательский проект на PyTorch в готовое решение, способное вместе с движком Unity работать в боевых условиях.

Можно несколькими способами интегрировать нейронную сеть в Unity. Я предлагаю использовать C++ API для PyTorch (под названием libtorch) для создания нативной разделяемой библиотеки, которую затем можно будет подключить к Unity как плагин. Существуют и другие подходы (например, использовать ML-Agents), которые в определённых случаях могут быть проще и эффективнее. Но преимущество моего подхода заключается в том, что он обеспечивает большую гибкость и даёт больше возможностей.

Допустим, у вас есть какая-то экзотическая модель и вы просто хотите использовать существующий PyTorch-код (который был написан без намерения общаться с Unity); или ваша команда разрабатывает собственную модель и не хочет отвлекаться на мысли о Unity. В обоих случаях код модели может быть сколь угодно сложным и использовать все возможности PyTorch. А если вдруг дело дойдёт до интеграции, в игру вступит C++ API и завернёт всё в библиотеку без малейшего изменения изначального PyTorch-кода модели.

Итак, мой подход сводится к четырём ключевым шагам:

  1. Настройка окружения.
  2. Подготовка нативной библиотеки (C++).
  3. Импорт функций из библиотеки / подключение плагина (Unity / C#).
  4. Сохранение / развёртывание модели.


ВАЖНО: поскольку я делал проект, сидя под Linux, некоторые команды и настройки отталкиваются от этой ОС; но не думаю, что здесь что-либо должно слишком зависеть от неё. Поэтому вряд ли подготовка библиотеки под Windows вызовет трудности.

Настройка окружения


Прежде чем устанавливать libtorch, убедитесь, что у вас есть

  • CMake

А если хотите использовать GPU, вам потребуются:


С CUDA могут возникнуть сложности, потому что драйвер, библиотеки и прочая хурма должны дружить между собой. И вам придётся поставлять эти библиотеки вместе с Unity-проектом чтобы всё работало из коробки. Так что для меня это самая неудобная часть. Если вы не планируете использовать GPU и CUDA, то знайте: вычисления замедлятся в 50100 раз. И даже если у пользователя довольно слабый графический процессор лучше с ним, чем без него. Даже если ваша нейросеть включается в работу довольно редко, эти редкие включения приведут к задержке, которая будет раздражать пользователя. Возможно, в вашем случае всё будет иначе, но нужен ли вам этот риск?

После того, как вы установили означенное выше ПО, пора загрузить и (локально) установить libtorch. Необязательно устанавливать для всех пользователей: можно просто поместить её в каталог своего проекта и обратиться к нему при запуске CMake.

Подготовка нативной библиотеки


Следующий шаг конфигурирование CMake. Я взял за основу пример из документации PyTorch и изменил его так, чтобы после сборки мы получали библиотеку, а не исполняемый файл. Положите этот файл в корневой каталог вашего проекта с нативной библиотекой.

CMakeLists.txt

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)project(networks)find_package(Torch REQUIRED)set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS})add_library(networks SHARED networks.cpp)target_link_libraries(networks ${TORCH_LIBRARIES})set_property(TARGET networks PROPERTY CXX_STANDARD 14)if (MSVC)file(GLOB TORCH_DLLS ${TORCH_INSTALL_PREFIX}/lib/*.dll)add_custom_command(TARGET networksPOST_BUILDCOMMAND ${CMAKE_COMMAND} -E copy_if_different${TORCH_DLLS}$<TARGET_FILE_DIR:example-app>)endif (MSVC)

Исходный код библиотеки будет размещён в networks.cpp.

В этом подходе есть ещё одна приятная особенность: нам пока не нужно думать, какую именно нейронную сеть мы хотим использовать с Unity. Причина (немного забегая вперед) заключается в том, что мы в любой момент можем запустить сеть в Python, получить её трассировку и просто сказать libtorch применить эту трассировку для этих входов. Поэтому, можно сказать, что наша нативная библиотека просто обслуживает некий чёрный ящик, работая с вводом-выводом.

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

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

networks.cpp

#include <torch/script.h>#include <vector>#include <memory>extern C{// This is going to store the loaded networktorch::jit::script::Module network;

Чтобы вызывать функции нашей библиотеки непосредственно из Unity, нужно передать информацию об их точках входа. В Linux я использую для этого __attribute__((visibility(default))). В Windows для этого существует спецификатор __declspec( dllexport ), но, честно говоря, я не проверял, работает ли он там.

Итак, начнём с функции загрузки трассировки нейросети с диска. Файл имеет относительный путь он лежит в корневом каталоге проекта Unity, а не в Assets/. Так что будьте внимательны. Вы также можете просто передать имя файла из Unity.
extern __attribute__((visibility(default))) void InitNetwork(){network = torch::jit::load(network_trace.pt);network.to(at::kCUDA); // If we're doing this on GPU}

Теперь перейдём к функции, которая кормит сеть входными данными. Напишем на С++ код, который использует указатели (ими управляет Unity) для перегонки данных туда и обратно. В этом примере я полагаю, что моя сеть имеет входы и выходы фиксированной размерности и запрещаю Unity менять это. Здесь, например, я возьму Tensor {1,3,64,64} и Tensor {1,5,64,64} (например, такая сеть нужна для сегментации пикселей RGB-изображений на 5 групп).

В общем случае вам придётся передать информацию о размерности и объёме данных, чтобы избежать переполнения буфера.

Чтобы преобразовать данные в формат, с которым работает libtorch, мы используем функцию torch::from_blob. Она принимает массив чисел с плавающей запятой и описание тензора (с указанием размерности) и возвращает созданный Тензор.

Нейросети могут принимать несколько входных аргументов (например, вызов forward () принимает x, y, z в качестве входных данных). Чтобы справиться с этим, все входные тензоры упаковываются в вектор стандартной библиотеки шаблонов torch::jit::IValue (даже если аргумент только один).

Чтобы получить данные из тензора, проще всего обработать их поэлементно, но если из-за этого упадёт скорость обработки, для оптимизации процесса чтения данных можно использовать Tensor::accessor. Хотя лично мне это не понадобилось.

В результате для моей нейросети получается вот такой простой код:

extern __attribute__((visibility(default))) void ApplyNetwork(float *data, float *output){Tensor x = torch::from_blob(data, {1,3,64,64}).cuda();std::vector<torch::jit::IValue> inputs;inputs.push_back(x);Tensor z = network.forward(inputs).toTensor();for (int i=0;i<1*5*64*64;i++)output[i] = z[0][i].item<float>();}}

Чтобы скомпилировать код, следуйте указаниям в документации, создайте подкаталог build/ и запустите следующие команды:

cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch <strong>..</strong>cmake --build <strong>.</strong> --config Release

Если всё пойдёт хорошо, будут созданы файлы libnetworks.so или networks.dll, которые вы сможете разместить в Assets/Plugins/ вашего Unity-проекта.

Подключение плагина к Unity


Для импорта функций из библиотеки используем DllImport. Первая функция, которая нам понадобится, это InitNetwork(). При подключении плагина Unity вызовет именно её:

using System.Runtime.InteropServices;public class Startup : MonoBehaviour{...[DllImport(networks)]private static extern void InitNetwork();void Start(){...InitNetwork();...}}

Чтобы движок Unity (С#) мог обмениваться данными с библиотекой (C++), я поручу ему всю работу по управлению памятью:

  • выделю память под массивы нужного размера на стороне Unity;
  • передам адрес первого элемента массива в функцию ApplyNetwork (её тоже перед этим нужно импортировать);
  • просто позволю адресной арифметике C++ обращаться к этой памяти при получении или отправке данных.

В коде библиотеки (С++) я должен избегать какого-либо выделения или освобождения памяти. С другой стороны, если я передаю адрес первого элемента массива из Unity в функцию ApplyNetwork, я должен сохранять этот указатель (и соответствующий участок памяти) до тех пор, пока нейросеть не завершит обработку данных.

К счастью, моя нативная библиотека делает простую работу по перегонке данных, так что следить за этим было достаточно легко. Но если вы хотите распараллелить процессы так чтобы, нейронная сеть одновременно и обучалась, и занималась обработкой данных для пользователя, придётся искать какое-то решение.

[DllImport(networks)]private static extern void ApplyNetwork(ref float data, ref float output);void SomeFunction() {float[] input = new float[1*3*64*64];float[] output = new float[1*5*64*64];// Load input with whatever data you want...ApplyNetwork(ref input[0], ref output[0]);// Do whatever you want with the output...}

Сохранение модели


Статья близится к концу, а мы так и обсудили, какую нейронную сеть я выбрал для моего проекта. Это простая свёрточная нейросеть, которую можно использовать для сегментации изображений. Я не включил в модель сбор данных и обучение: моя задача рассказать про интеграцию с Unity, а не про заморочки с трассировкой сложных нейросетей. Не обессудьте.

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

Так может выглядеть Python-код для нашей простой модели:

import torchimport torch.nn as nnimport torch.nn.functional as Fclass Net(nn.Module):def __init__(self):super().__init__()self.c1 = nn.Conv2d(3,64,5,padding=2)self.c2 = nn.Conv2d(64,5,5,padding=2)def forward(self, x): z = F.leaky_relu(self.c1(x)) z = F.log_softmax(self.c2(z), dim=1)return zКод не очень красивый, конечно, но, думаю, идея понятна.Сохранить (экспортировать) модель с текущими значениями коэффициентов можно так:network = Net().cuda()example = torch.rand(1, 3, 32, 32).cuda()traced_network = torch.jit.trace(network, example)traced_network.save(network_trace.pt)

Развёртывание модели


Мы сделали статическую библиотеку, но для развёртывания этого недостаточно: в проект нужно включить дополнительные библиотеки. К сожалению, у меня нет 100-процентной уверенности в том, какие именно библиотеки нужно включить обязательно. Я выбрал libtorch, libc10, libc10_cuda, libnvToolsExt и libcudart. В сумме они добавляют 2 Гб к изначальному размеру проекта.

LibTorch vs ML-Agents


Я считаю, что для многих проектов, особенно в области исследований и прототипирования, действительно стоит выбрать ML-Agents, плагин, созданный специально для Unity. Но когда проекты становятся более сложными, нужно подстраховаться на случай, если что-то пойдёт не так. А такое случается нередко

Пару недель назад я как раз использовал ML-Agents для взаимодействия между демо-игрой на Unity и парой нейронных сетей, написанных на Python. В зависимости от игровой логики Unity вызывал одну из этих сетей с разными наборами данных.

Мне пришлось основательно покопаться в Python API для ML-Agents. Некоторые операции, которые я использовал в моих нейросетях, например 1d свёртка и операции транспонирования, не поддерживались в Barracuda (это библиотека трассировки, которую в настоящее время использует ML-Agents).

Проблема, с которой я столкнулся, заключалась в том, что ML-Agents собирает запросы от агентов в течение некого временного интервала, а затем для оценки отправляет, к примеру, в Jupyter notebook. Однако некоторые из моих нейросетей зависели от результатов работы других моих сетей. И, чтобы получить оценку всей цепочки моих нейронных сетей, мне пришлось бы каждый раз, делая запрос, ждать какое-то время, получать результат, делать другой запрос, ждать, получать результат и так далее. Кроме того, порядок включения этих сетей в работу нетривиально зависел от ввода данных пользователем. А это означало, что я не мог просто последовательно запускать нейросети.

Кроме того, в некоторых случаях объём данных, который мне нужно было отправить, должен был варьироваться. А ML-Agents больше рассчитан на фиксированную размерность для каждого агента (вроде бы её можно менять на лету, но я отношусь к этому скептически).

Я мог бы сделать что-то вроде вычисления последовательности вызова нейросетей по запросу, отправляя соответствующие входные данные для Python API. Но из-за этого мой код, как на стороне Unity, так и на стороне Python, стал бы слишком сложным, или вовсе избыточным. Поэтому я решил изучить подход с использованием libtorch и не прогадал.

Если бы раньше кто-нибудь попросил меня встроить в Unity-проект предсказательную модель GPT-2 или MAML, я бы посоветовал ему постараться обойтись без этого. Реализация такой задачи с использованием ML-Agents слишком сложна. Но теперь я могу найти или разработать любую модель с PyTorch, а потом завернуть её в нативную библиотеку, которая подключается к Unity как обычный плагин.



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Перевод Находим и устраняем уязвимости бинарных файлов в Linux с утилитой checksec и компилятором gcc

12.06.2021 16:15:59 | Автор: admin

Изображение: Internet Archive Book Images. Modified by Opensource.com. CC BY-SA 4.0

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

Некоторые из них компилятор включает или отключает по умолчанию. Так в бинарных файлах могут возникать уязвимости, о которых мы не знаем.

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

  • как использовать утилиту checksec для поиска уязвимостей;
  • как использовать компилятор gcc для устранения найденных уязвимостей.

Установка checksec


Для Fedora OS и других систем на базе RPM:

$ sudo dnf install checksec

Для систем на базе Debian используйте apt.

Быстрый старт с checksec


Утилита сhecksec состоит из единственного скриптового файла, который, впрочем, довольно большой. Благодаря такой прозрачности вы можете узнать, какие системные команды для поиска уязвимостей в бинарных файлах выполняются под капотом:

$ file /usr/bin/checksec/usr/bin/checksec: Bourne-Again shell script, ASCII text executable, with very long lines$ wc -l /usr/bin/checksec2111 /usr/bin/checksec

Давайте запустим checksec для утилиты просмотра содержимого каталогов (ls):

$ checksec --file=/usr/bin/ls<strong>RELRO      STACK CANARY   NX      PIE       RPATH   RUNPATH   Symbols    FORTIFY Fortified    Fortifiable  FILE</strong>Full RELRO   Canary found   NX enabled  PIE enabled   No RPATH  No RUNPATH  No Symbols    Yes  5    17       /usr/bin/ls

Выполнив команду в терминале, вы получите отчёт о том, какими полезными свойствами обладает этот бинарник, и какими не обладает.

Первая строка это шапка таблицы, в которой перечислены различные свойства безопасности RELRO, STACK CANARY, NX и так далее. Вторая строка показывает значения этих свойств для бинарного файла утилиты ls.

Hello, бинарник!


Я скомпилирую бинарный файл из простейшего кода на языке С:

#include <stdio.h>int main(){printf(Hello World\n);return 0;}

Обратите внимание, что пока я не передал компилятору ни одного флага, за исключением -o (он не относится к делу, а просто говорит, куда выводить результат компиляции):

$ gcc hello.c -o hello$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, not stripped$ ./helloHello World

Теперь запущу утилиту checksec для моего бинарника. Некоторые свойства отличаются от свойств

ls (для него я запускал утилиту выше):$ checksec --file=./hello<strong>RELRO      STACK CANARY   NX      PIE       RPATH   RUNPATH   Symbols     FORTIFY Fortified    Fortifiable   FILE</strong>Partial RELRO  No canary found  NX enabled  No PIE     No RPATH  No RUNPATH  85) Symbols    No  0    0./hello

Checksec позволяет использовать различные форматы вывода, которые вы можете указать с помощью опции --output. Я выберу формат JSON и сделаю вывод более наглядным с помощью утилиты jq:

$ checksec --file=./hello --output=json | jq{./hello: {relro: partial,canary: no,nx: yes,pie: no,rpath: no,runpath: no,symbols: yes,fortify_source: no,fortified: 0,fortify-able: 0}}

Анализ (checksec) и устранение (gcc) уязвимостей


Созданный выше бинарный файл имеет несколько свойств, определяющих, скажем так, степень его уязвимости. Я сравню свойства этого файла со свойствами бинарника ls (тоже указаны выше) и объясню, как это сделать с помощью утилиты checksec.

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

1. Отладочные символы


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

Отладочные символы обычно удаляются из той версии бинарника, которую разработчики выпускают для широкого использования. Это никак не влияет на работу программы. Такая очистка (она обозначена словом strip) часто выполняется для экономии места, так как файл становится легче после удаления символов. А в проприетарном программном обеспечении эти символы часто удаляют ещё и потому, что у злоумышленников есть возможность прочитать их в двоичном формате и использовать в своих целях.

Сhecksec показывает, что отладочные символы присутствуют в моём бинарнике, но их нет в файле ls.

$ checksec --file=/bin/ls --output=json | jq | grep symbolssymbols: no,$ checksec --file=./hello --output=json | jq | grep symbolssymbols: yes,


То же самое может показать запуск команды file. Символы не удалены (not stripped).

$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, <strong>not stripped</strong>

Как работает checksec


Запустим эту команду с опцией --debug:

$ checksec --debug --file=./hello

Так как утилита checksec это один длинный скрипт, то для его изучения можно использовать функции Bash. Выведем команды, которые запускает скрипт для моего файла hello:

$ bash -x /usr/bin/checksec --file=./hello

Особое внимание обратите на echo_message вывод сообщения о том, содержит ли бинарник отладочные символы:

+ readelf -W --symbols ./hello+ grep -q '\.symtab'+ echo_message '\033[31m96) Symbols\t\033[m ' Symbols, ' symbols=yes' 'symbols:yes,'

Утилита checksec использует для чтения двоичного файла команду readelf со специальным флагом --symbols. Она выводит все отладочные символы, которые содержатся в бинарнике.

$ readelf -W --symbols ./hello

Из содержимого раздела .symtab можно узнать количество найденных символов:

$ readelf -W --symbols ./hello | grep -i symtab

Как удалить отладочные символы после компиляции


В этом нам поможет утилита strip.

$ gcc hello.c -o hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=322037496cf6a2029dcdcf68649a4ebc63780138, for GNU/Linux 3.2.0, <strong>not stripped</strong>$$ strip hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=322037496cf6a2029dcdcf68649a4ebc63780138, for GNU/Linux 3.2.0, <strong>stripped</strong>

Как удалить отладочные символы во время компиляции


При компиляции используйте флаг -s:

$ gcc -s hello.c -o hello$$ file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=247de82a8ad84e7d8f20751ce79ea9e0cf4bd263, for GNU/Linux 3.2.0, <strong>stripped</strong>

Убедиться, что символы удалены, можно и с помощью утилиты checksec:

$ checksec --file=./hello --output=json | jq | grep symbolssymbols: no,

2. Canary


Canary (осведомители) это секретные значения, которые хранятся в стеке между буфером и управляющими данными. Они используются для защиты от атаки переполнения буфера: если эти значения оказываются изменены, то стоит бить тревогу. Когда приложение запускается, для него создаётся свой стек. В данном случае это просто структура данных с операциями push и pop. Злоумышленник может подготовить вредоносные данные и записать их в стек. В этом случае буфер может быть переполнен, а стек повреждён. В дальнейшем это приведёт к сбою работы программы. Анализ значений canary позволяет быстро понять, что произошёл взлом и принять меры.

$ checksec --file=/bin/ls --output=json | jq | grep canarycanary: yes,$$ checksec --file=./hello --output=json | jq | grep canarycanary: no,$Чтобы проверить, включен ли механизм canary, скрипт checksec запускает следующую команду:$ readelf -W -s ./hello | grep -E '__stack_chk_fail|__intel_security_cookie'

Включаем canary


Для этого при компиляции используем флаг -stack-protector-all:

$ gcc -fstack-protector-all hello.c -o hello$ checksec --file=./hello --output=json | jq | grep canarycanary: yes,

Вот теперь сhecksec может с чистой совестью сообщить нам, что механизм canary включён:

$ readelf -W -s ./hello | grep -E '__stack_chk_fail|__intel_security_cookie'2: 0000000000000000   0 FUNC  GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4 (3)83: 0000000000000000   0 FUNC  GLOBAL DEFAULT UND __stack_chk_fail@@GLIBC_2.4$

3. PIE


Включённое свойство PIE позволяет произвольно размещать в памяти исполняемый код независимо от его абсолютного адреса:

PIE (Position Independent Executable) исполняемый позиционно-независимый код. Возможность предсказать, где и какие области памяти находятся в адресном пространстве процесса играет на руку взломщикам. Пользовательские программы загружаются и выполняются с предопределённого адреса виртуальной памяти процесса, если они не скомпилированы с опцией PIE. Использование PIE позволяет операционной системе загружать секции исполняемого кода в произвольные участки памяти, что существенно усложняет взлом.

$ checksec --file=/bin/ls --output=json | jq | grep piepie: yes,$ checksec --file=./hello --output=json | jq | grep piepie: no,

Часто свойство PIE включают только при компиляции библиотек. В выводе ниже hello помечен как LSB executable, а файл стандартной библиотеки libc (.so) как LSB shared object:

$ file hellohello: ELF 64-bit <strong>LSB executable</strong>, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=014b8966ba43e3ae47fab5acae051e208ec9074c, for GNU/Linux 3.2.0, not stripped$ file /lib64/libc-2.32.so/lib64/libc-2.32.so: ELF 64-bit <strong>LSB shared object</strong>, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=4a7fb374097fb927fb93d35ef98ba89262d0c4a4, for GNU/Linux 3.2.0, not stripped

Checksec получает эту информацию следующим образом:

$ readelf -W -h ./hello | grep EXECType:               EXEC (Executable file)

Если вы запустите эту же команду для библиотеки, то вместо EXEC увидите DYN:

$ readelf -W -h /lib64/libc-2.32.so | grep DYNType:               DYN (Shared object file)

Включаем PIE


При компиляции программы нужно указать следующие флаги:

$ gcc -pie -fpie hello.c -o hello

Чтобы убедиться, что свойство PIE включено, выполним такую команду:

$ checksec --file=./hello --output=json | jq | grep piepie: yes,$

Теперь у нашего бинарного файла (hello) тип сменится с EXEC на DYN:

$ file hellohello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bb039adf2530d97e02f534a94f0f668cd540f940, for GNU/Linux 3.2.0, not stripped$ readelf -W -h ./hello | grep DYNType:               DYN (Shared object file)

4. NX


Средства операционной системы и процессора позволяют гибко настраивать права доступа к страницам виртуальной памяти. Включив свойство NX (No Execute), мы можем запретить воспринимать данные в качестве инструкций процессора. Часто при атаках переполнения буфера злоумышленники помещают код в стек, а затем пытаются его выполнить. Однако, если запретить выполнение кода в этих сегментах памяти, можно предотвратить такие атаки. При обычной компиляции с использованием gcc это свойство включено по умолчанию:

$ checksec --file=/bin/ls --output=json | jq | grep nxnx: yes,$ checksec --file=./hello --output=json | jq | grep nxnx: yes,

Чтобы получить информацию о свойстве NX, checksec вновь использует команду readelf. В данном случае RW означает, что стек доступен для чтения и записи. Но так как в этой комбинации отсутствует символ E, на выполнение кода из этого стека стоит запрет:

$ readelf -W -l ./hello | grep GNU_STACKGNU_STACK   0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10

Отключение NX


Отключать свойство NX не рекомендуется, но сделать это можно так:

$ gcc -z execstack hello.c -o hello$ checksec --file=./hello --output=json | jq | grep nxnx: no,

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

$ readelf -W -l ./hello | grep GNU_STACKGNU_STACK   0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RWE 0x10

5. RELRO


В динамически слинкованных бинарниках для вызова функций из библиотек используется специальная таблица GOT (Global Offset Table). К этой таблице обращаются бинарные файлы формата ELF (Executable Linkable Format). Когда защита RELRO (Relocation Read-Only) включена, таблица GOT становится доступной только для чтения. Это позволяет защититься от некоторых видов атак, изменяющих записи таблицы:

$ checksec --file=/bin/ls --output=json | jq | grep relrorelro: full,$ checksec --file=./hello --output=json | jq | grep relrorelro: partial,

В данном случае включено только одно из свойств RELRO, поэтому checksec выводит значение partial. Для отображения настроек сhecksec использует команду readelf.

$ readelf -W -l ./hello | grep GNU_RELROGNU_RELRO   0x002e10 0x0000000000403e10 0x0000000000403e10 0x0001f0 0x0001f0 R  0x1$ readelf -W -d ./hello | grep BIND_NOW

Включаем полную защиту (FULL RELRO)


Для этого при компиляции нужно использовать соответствующие флаги:

$ gcc -Wl,-z,relro,-z,now hello.c -o hello$ checksec --file=./hello --output=json | jq | grep relrorelro: full,

Всё, теперь наш бинарник получил почётное звание FULL RELRO:

$ readelf -W -l ./hello | grep GNU_RELROGNU_RELRO   0x002dd0 0x0000000000403dd0 0x0000000000403dd0 0x000230 0x000230 R  0x1$ readelf -W -d ./hello | grep BIND_NOW0x0000000000000018 (BIND_NOW) 

Другие возможности checksec


Тему безопасности можно изучать бесконечно. Даже, рассказывая про простую утилиту checksec в этой статье, я не смогу охватить всё. Тем не менее, упомяну ещё несколько интересных возможностей.

Проверка нескольких файлов


Нет необходимости для каждого файла запускать отдельную команду. Можно запустить одну команду сразу для нескольких бинарных файлов:

$ checksec --dir=/usr/bin

Проверка процессов


Утилита checksec также позволяет анализировать безопасность процессов. Следующая команда отображает свойства всех запущенных программ в вашей системе (для этого нужно использовать опцию --proc-all):

$ checksec --proc-all

Вы также можете выбрать для проверки один процесс, указав его имя:

$ checksec --proc=bash

Проверка ядра


Аналогично вы можете анализировать уязвимости в ядре вашей системы.

$ checksec --kernel

Предупреждён значит вооружён


Подробно изучите свойства безопасности и попытайтесь понять, на что именно влияет каждое из них и какие виды атак это позволяет предотвратить. Checksec вам в помощь!



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

История портирования Reindexerа как покорить Эльбрус за 11 дней

15.06.2021 18:13:51 | Автор: admin

Всем привет! На связи Антон Баширов, разработчик из ИТ-кластера Ростелекома. Импортозамещение набирает обороты, а российский софт всё глубже проникает в нашу повседневную ИТ-шную сущность бытия. Процессоры Эльбрус и Байкал становятся более востребованными, комьюнити расширяется, но мысли о необходимости портировать весь наш любимый технологический стек на неизведанную архитектуру E2K звучат страшнее рассказов про горящий в пламени production-кластер.

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

Итак, гость в студии база данных Reindexer, разработка нашего ИТ-кластера.

Стоит сказать, почему выбор пал именно на Reindexer, а не другую БД. Во-первых, всеми любимый и известный Postgres уже есть в составе пакетов ОС Эльбрус. Переносить его нет необходимости. Во-вторых, наш гость уже прошел испытания на ARM, следовательно, пришло время ему покорить Эльбрус.

Стоит упомянуть, что Reindexer появился на свет как продуктвыбора между Elastic и Tarantool. Это определенно хочется попробовать завести на отечественном процессоре.

День 0. Знакомство с гостем

Несколько слов про нашего гостя:

Имя:NoSQL in-memory database Reindexer с функционалом полнотекстового поиска
Возраст:на момент испытаний версия БД была 3.1.2
Происхождение:Россия
Место рождения:пытливый ум разработчика Олега Герасимова@olegator99
Место жительства:https://github.com/Restream/reindexer
Место работы:Ростелеком
Строение:основная составляющая C++, имеются примеси из языка Ассемблера и немного CMake
Особенности:- Ассемблерные вставки;
- Много С++11 и C++14;
- Используются корутины.

Деятельность:- Хранение данных в памяти и на диске;
- Выполнение SQL запросов со скоростью 500K queries/sec для PK запросов;
- Выполнение запросов типа full-text search (почти как Elastic, только не тормозит);
- Работа в режиме server и embedded;
- Общение с крутыми парнями: Go, Java, Rust, .NET, Python, C/C++ и (да простит меня Хабр) PHP.

Заскучали? На этом нудная часть закончилась. В эфире рубрика Ээээксперименты!

День 1. А ты думал, в сказку попал?

Для начала попробуем нашего друга собрать из того, что есть.Идем на тестовый сервер Эльбрус 8C с ОС Эльбрус 6.0.1, клонируем туда репозиторий и запускаем CMake.

Хорошие новости, мы нашли компилятор! Новость действительно хорошая, ведь у Эльбруса свой компилятор LCC.

К счастью, создатели Эльбрус сделали LCC полностью совместимым с GCC и наши любимые нативные программки и сборщики смогут чувствовать себя хорошо без особых манипуляций. LCC уже сделал за вас нужные линки:gcc -> /opt/mcst/bin/lcc*.

Зачем Эльбрусу свой компилятор?

Дело в том, что компилятор у Эльбруса не такой, как все. Он выполняет много работы, связанной с анализом зависимостей и оптимизацией:

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

Ключевая идея архитектуры давайте попробуем выполнить несколько операций за один присест. Эльбрусы позволяют реализовать параллельные выполнение кода без применения многопоточности. Достигается это путем одновременного выполнения разных инструкций внутри одной широкой команды на разных устройствах процессора. Но для того, чтобы процессор понимал, что от него хотят и мог правильно распределить инструкции, ему нужно дать на вход оптимизированный поток больших команд. Именно в формировании этого потока и заключается назначение LCC компилировать код так, чтобы широкие команды содержали параллельно выполняемые инструкции с целью задействовать все устройства CPU. Подробнеетут.

Но вернемся к нашему гостю и тому, что не понравилось CMake.

В скриптах сборки, в CMakeLists.txt выполняется определение операционной системы и архитектуры процессора, на которой собирается Reindexer. Нужно это для того, чтобы подключать правильные исходники ассемблера, ведь как говорится Write once, run anywhere.

Разумеется, на момент написания скриптов никто не знал про процессоры Эльбрус, поэтому наш скрипт и упал. Исправляем.

Программисты люди ленивые, поэтому:

Попытка 2:

А что так можно было? На самом деле да для того, чтобы завелся CMake, этого было достаточно. Теперь ударим в бубен и запустимmake -j8:

Для тех, кто уже успел поработать с кроссплатформенным софтом, не новость, что С/С++ код с помощью макросов разделяют на платформозависимые секции.

Поэтому, в некоторые места кода Reindexer понадобилось добавить парочку новых условий для__E2K__и__LCC__:

После колдовства с макросами нас ждал монстр пострашнее:

Вот что бывает, когда игнорируешь messages у CMake.

Из-за того, что CMake не нашел подходящих исходников, компилятор не смог найти реализации двух функций:jump_fcontextиmake_fcontext. Про назначение этих функций я расскажу чуть позже, а пока что давайте успокоим нашего гостя и подсунем ему пару пустышек:

Этих операций оказалось достаточно, чтобы Reindexer собрался.

Поздравляю, у вас двойня!

Вот они, наши два долгожданных файла:

# file reindexer_serverreindexer_server: ELF 64-bit LSB executable, MCST Elbrus, version 1 (GNU/Linux
# file reindexer_toolreindexer_tool: ELF 64-bit LSB executable, MCST Elbrus, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux.so.2, for GNU/Linux 2.6.33, with debug_info, not stripped

Это можно считать успехом. Мы смогли собрать наш первый Эльбрус-бинарник. Даже два бинарника: reindexer_server и reindexer_tool.

Подведем итоги. Чтобы собрать Reindexer, мы:

  • Поправили CMake-скрипты;

  • Добавили несколько условий в макросы препроцессора;

  • Заглушили ASM функции.

День 3. Наш Гость, попав на Эльбрус, начал подавать признаки жизни

Первый запуск прошел успешно - сервер поднялся, и даже открылся web-ui.

Я привык не останавливаться на достигнутом и решил немного поработать над Reindexer.

Результат меня порадовал - Гость устал и прилег отдохнуть:

Такое поведение наблюдалось только на Эльбрус. На i5 все работало штатно.

Давайте разбираться. Вооружившись отладчиком (gdb кстати под E2K работает прекрасно) и CLion, мне удалось докопаться до истины.

Пару запросов к Reindexer смогли воспроизвести ошибку:

Падает в деструкторе. Интересный момент - упало на free (в данном случае free реализуется через jemalloc). Видимо, здесь идет высвобождение памяти по некорректному указателю. Ошибку эту я исправлял в два этапа:

  1. work around - дело в том, что QueryEntry лежит в объекте ExpressionTree, в самом классе примечательного я не нашел, поэтому копнул в сторону родителя. Оказалось, что до вызова деструктора был вызван вот такой копирующий конструктор, в котором есть интересный MakeDeepCopy(), реализованный с помощью библиотечкиmpark-variant.

    Подробнее про expression tree рассказываюттут.

    На часах было 2 часа ночи, я засиделся в офисе и разбираться в variant было уже лениво, поэтому, каюсь, я решил просто убрать вызов этого метода.

    Итог - оно заработало.

  2. TODO: Исправление тоже есть, но рассказ про него уже выходит за рамки данной статьи. Небольшой спойлер (код из mpark-variant с патчем под e2k):

inline constexpr DECLTYPE_AUTO visit(Visitor &&visitor, Vs &&... vs)#ifdef E2K //Fix for Elbrus    -> decltype(detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...))    {     return detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...);    }#else    DECLTYPE_AUTO_RETURN(        (detail::all(             lib::array<bool, sizeof...(Vs)>{{!vs.valueless_by_exception()...}})             ? (void)0             : throw_bad_variant_access()),        detail::visitation::variant::visit_value(lib::forward<Visitor>(visitor),                                                 lib::forward<Vs>(vs)...))#endif

День 5. Он ожил! Ну почти

Сервер у нас, действительно, работает, даже удалось получить первые результаты бенчмарков.

Но все это время я упорно игнорировал один компонент.

Помните, как мы убрали завязку на ASM и функцииmake_fcontext и jump_fcontext?

Так вот, ASM исходники в Reindexer нужны для реализации C++ корутин, а эти функции - ключевые для корутин из библиотеки boost/context.

Кто такая эта ваша Корутина?

Корутина это функция, которая может быть приостановлена и возобновлена в любой момент. Для реализации достаточно трех основных компонентов:
- функция
- механизм для ее паузы (suspend)
- механизм для возобновления ее с той точки, в которой была сделана пауза (resume).

Реализацию корутин можно увидеть в таких языках и библиотеках, как:

  • Libcoro(корутины на C/С++)

  • Koishi(тоже корутины, тоже на С/С++)

  • Boost(и это тоже корутины, тоже на С/С++, корутин много не бывает!)

  • Node-fibers(корутины для NodeJS на базе libcoro)

  • Tarantool(fibers на базе libcoro)

  • Kotlin(свои корутины, не на C++)

  • C++20

  • Goroutine

Для наглядности вот скрин теста корутин из Koishi:

Последовательность следующая:

  1. Создали корутину, выделили стек, задали функцию;

  2. Возобновили корутину - в этот момент вызвалась наша функция;

  3. Приостановили корутину с помощьюkoishi_yield здесь мы вернули управление в test1 сразу после строчкиkoishi_resume;

  4. Если мы еще раз сделаемkoishi_resumeна нашей корутине мы вернемся на строчку функции cofunc1 сразу после первого вызоваkoishi_yield.

Без корутин не будет работать reindexer_tool, но не можем же мы оставить Reindexer без такой важной конечности. Будем исправлять.

Начнем с анализа ситуации. Ожидаемо, что ASM Эльбруса бустовские корутины не поддерживают (пока что), а значит нам нужно реализовать их самим. Подробности и реализацию бустовских корутин можете посмотретьздесь, а сейчас наступило время скрещивать ужа с ежом.

Есть несколько вариантов реализовать корутины:

  • Вариант 1:написать ASM реализацию. Самый очевидный, но при этом самый сложный вариант. У нас есть в планах попробовать именно этот вариант, так как ASM корутины - самые быстрые.

  • Вариант 2: забить. Нет, это не наш путь.

  • Вариант 3: использовать библиотеку.

По счастливой случайности Koishi оказалось той самой библиотекой, поддерживающей реализацию корутин с помощью встроенных E2K функций:makecontext_e2k()иfreecontext_e2k().

Koishi, Koishi, что это вообще такое?

Если коротко это библиотека, предоставляющая удобный API по использованию корутин на C/C++ с несколькими вариантами реализации:

  • ucontext

  • ucontext_e2k (наша прелесть)

  • fcontext

  • win32fiber

  • ucontext_sjlj

  • emscripten

Там, где стандартная медицина бессильна, в дело вступает генная инженерия.

Для начала внедрим Koishi в организм Reindexer:

Заменим больной орган на здоровый:

Стараемся не повредить то, что уже работает:

Одним из backend-ов Koishi для корутин выступает fcontext (те же самые boost исходники, что в Reindexer). Последуем древней мудрости работает не трогай! и оставим как есть в случае, если у нас не E2K архитектура. Для Эльбруса мы будем использоватьucontext_e2k.c

И вот он, наш мутант с корутинами полностью здоров и функционален (и на amd64, и на E2K):

День 11. Проводим финальные испытания

Две половинки нашего Франкенштейна собраны вместе, даже сторонняя библиотека прижилась и отлично себя показала. Пришло время для финальных тестов.

Всего в Reindexer около 300 функциональных тестов, и мне не терпится запустить их все.

Тесты бежали, бежали и не добежали. Один из тестов вызвал Segmentation Fault.

Всему виной оказались вот эти строчки:

struct ConnectOpts {  /* тут тоже код, но он не такой интересный */  uint16_t options = 0;  int expectedClusterID = -1;};

Обычный код, что тут такого? скажете вы. Но не все так просто. Запоминаем, что здесь два поля, которые инициализируются при объявлении.

Попробуем воспроизвести на изолированном более простом примере.

ASM, настало твое время

Внимание на скриншот:

Ошибка проявляется именно в том случае, когда отсутствуют не проинициализированные поля. Другими словами, если все ваши поля инициализируются при объявлении вы счастливчик, поймавший Segmentation Fault.

Баг заключается в том, что скомпилированное создание структуры выглядит как странныйaddd. Именно эта строчка и вызывает segfault. Кстати, если вместоbool anyField = falseнаписать простоbool anyField, то код совершенно другой и ошибки нет.

Семь бед, один ответ обновитесь!

Разгадка тайны оказалась проста. На момент работы с Reindexer последней версией компилятора LCC была v.1.25.16, однако в состав пакетов ОС Эльбрус по умолчанию входит 1.25.14, на котором я и запускал тесты. Добрые люди из сообщества подсказали нам, что уже в 15 версии данный баг был исправлен. Я решил обновиться на 16-ую и посмотреть, что будет.

Вот что нам принес новый компилятор LCC v.1.25.16:

C++ код такой же, однако ASM совсем другой, и что самое главное он работает! Последовательно (заранее прошу прощения за мой ломаный asm-русский переводчик):

  1. gestp - выделяем стек и кладем его в %dr3

  2. setwd - задаем текущее окно в регистровом файле

  3. setbn - задаем подвижную базу регистров в текущем окне регистрового файла

  4. addd - кладем "дно" стека (стек размером 0x20) в %dr2

  5. addd - выделяем на стеке нашу структуру

  6. ldb - достаем из констант наш false и кладем его в %r5

  7. stb - кладем наш false из регистра %r5 в наше поле anyField1

  8. addd - подготовили локальный указатель на структуру для вызова метода

  9. addd - передаем указатель в базовый регистр для передачи при вызове anyMethod (в anyMethod мы достаем указатель из регистра %dr0)

  10. disp - подготавливаем регистр перехода на anyMethod

  11. call - вызываем anyMethod

Дальше все просто пересобрать, перезапустить, обрадоваться.

Тесты прошли успешно, и теперь у нас есть полностью рабочий и протестированный Reindexer на Эльбрусе.

На этом все ?

На самом деле нет. Сейчас мы смогли:

  • собрать Reindexer Server

  • запустить Reindexer Server

  • собрать Reindexer Tool

  • запустить Reindexer Tool

  • переписать кусочек Reindexer Tool

  • уронить Reindexer и поднять его

  • добиться 100% проходимости Reindexer тестов

Мы научились:

  • собирать C++ софт на Эльбрус

  • переписывать C++ софт под особенности и отличия Эльбрусов

  • разбираться в ASM E2K

  • превозмогать трудности

  • разбираться в C++ корутинах даже на Эльбрусе

Что в планах:

  • корутины на ASM E2K (может быть даже сравнить fcontext ASM на i5 и ucontext/ASM на E2K)

  • оптимизировать Reindexer под архитектуру Эльбрус

  • перенести еще несколько интересных приложений на Эльбрус

  • дополнить базу библиотек и пакетов Эльбрус

  • организовать E2K инфраструктуру и песочницу

Что же можно сказать про Эльбрус со стороны простого разработчика?

Эльбрус это ваш друг. Он уже умеет в 80% современного софта, работать с ним можно и достаточно комфортно. Его архитектура - действительно произведение искусства, идея одноядерного параллелизма достойна уважения и несет в себе большой потенциал. Вопрос лишь в том, получится ли его раскрыть.

На сегодня это всё, что хотелось бы вам рассказать, но я еще вернусь с новыми интересными подробностями про портирование и оптимизацию, и да прибудет с вами сила Эльбруса!

Подробнее..

О параметре компилятора SAFESEH

16.06.2021 18:22:08 | Автор: admin

Введение

В этой истории я расскажу вам об увлекательном приключении, которое привело меня к решению одной загадки, которую я сам себе загадал. Разгадка являет собой небольшую подробность в механизме загрузчика 32-х разрядных приложений в системе Windows 7 и выше, а процесс разгадки - длинное путешествие воина, который следует по пути сердца.

Если вы попали на эту страницу в поисках ответа на вопрос, то смотрите спойлер ниже, потому что основное содержание может быть интересно тем, кто только пытается разобраться в механизме SEH.

Поле boundImport, которое содержится в структуре dataDirectories, указывает на структуру IMAGE_LOAD_CONFIG_DIRECTORY32. Эта структура содержит поле SEHandlerTable. Если оно равно нулю, то параметр /SAFESEH выключен. Если оно является виртуальным указателем на таблицу безопасных обработчиков, то будут работать только те обработчики, которые указаны в таблице. Таблица представляет из себя список смещений, относительно виртуального адреса секции .text . Количество обработчиков задаётся в поле SEHandlerCount.

С чего всё началось

Задумавшись о способе защиты своей программы от копирования, я вспомнил о статье Криса Касперского, в которой я узнал о существовании антиотладочных приёмов. Тщетно пытаясь реализовать разобранный Крисом пример в своей программе, я натыкался на стену непонимания со стороны Windows. Проблема заключалась в том, что Windows отказывалась пользоваться моим обработчиком исключений, посчитав его небезопасным.

Более менее подробно о SEH мне удалось почерпнуть из этой статьи. Но, тем не менее, пока я сам не рассмотрел этот механизм c лупой и инструментами, у меня оставались очень смутные представления о его устройстве. Я разматывал цепочки обработчиков, ставил на них точки останова, наблюдал порядок их выполнения, рассматривал возвращаемые значения и т.д.

Ближе к делу

Рассмотрим маленький пример (я пользуюсь компилятором MicrosoftVisual C++ )

int main(){ __asm { mov eax, DWORD PTR SS : [0] }}

Ничего особенного, пытаемся положить в регистр eax содержимое по адресу 0.

Компилируем, запускаем под отладчиком OllyDbg.

Получаем грустное сообщение access violation when reading 0x00000000.

Берем на заметку, что размер инструкции, которая пытается прочитать по адресу 0 - равен 6 байтам. Обратим также внимание на регистр Eip, который указывает на проблемную инструкцию (совпадает с её адресом слева). Сама же инструкция выглядит как последовательность из 6 байт: 36 A1 00 00 00 00.

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

Напишем же свой собственный обработчик для обработки этого исключения!

Есть информация, что при возникновении исключения в функцию-обработчик система отправляет следующие аргументы:

    struct _EXCEPTION_RECORD* exceptionRecord,    void* establisherFrame,    struct _CONTEXT* contextRecord,    void* dispatcherContext

где exceptionRecord структура, содержащая код ошибки, адрес исключения, а contextRecord структура содержащая контекст регистров (включая регистр Eip). Соглашение о вызове функции-обработчика должно быть __cdecl, а возвратить она должна одно из следующих значений:

typedef enum _EXCEPTION_DISPOSITION{ ExceptionContinueExecution, ExceptionContinueSearch, ExceptionNestedException, ExceptionCollidedUnwind} EXCEPTION_DISPOSITION;

Я решил обработать это исключение таким нехитрым образом:

EXCEPTION_DISPOSITION __cdecl ExceptionHanler(    struct _EXCEPTION_RECORD* exceptionRecord,    void* establisherFrame,    struct _CONTEXT* contextRecord,    void* dispatcherContext){    contextRecord->Eip += 6;    MessageBoxA(0, "Exteption was handled", "Success!", 0);    return ExceptionContinueExecution;}

Сместив регистр Eip на 6 байт, мы обойдём проблемную инструкцию! И говорим: ExceptionContinueExecution продолжить выполнение. Соответственно, выполнение продолжится с инструкции по адресу Eip + 6 и исключений больше возникнуть не должно.

Просто поместить код обработчика в программе - этого мало. Необходимо дать понять системе, что нужно вызывать именно его. И ещё я хочу, чтобы он вызвался самым первым, среди прочих. Для этого нам необходимо где-то создать структуру, состоящую из двух полей: Next и Handler. Поле Next будет содержать указатель на такую же структуру, но с предыдущим обработчиком (с тем, который мы потесним). Поле Handler будет содержать указатель на нашу функцию-обработчик. Указатель на текущую такую структуру находится в TIB (thread information block), иначе говоря, по адресу fs:[0]. Создадим её в стеке.

Преобразуем нашу функцию main:

int main(){__asm{    push ExceptionHanler //положим в стек адрес функции-обработчикаpush fs:[0]          //положим в стек указатель на структуру текущего обработчика                     //теперь в стеке лежит структура, содержащая 2 поля: указатель на     //следующий обработчик и адрес нашего обработчикаmov fs:[0] , esp   //положим указатель на свою структуру, вместо текущего                                                                                                                                                   //обработчика по адресу fs:[0]mov eax, DWORD PTR SS:[0] //пытаемся прочитать по адресу 0    add esp, 8          //чистим стек, удалив 8 байт (размер структуры)}}

По незнанию, я компилирую это с настройками по умолчанию - с флагом /SAFESEH и игнорирую непонятные рекомендации компилятора о том, что мне неплохо было бы отключить этот флаг, раз я записываю что-то по адресу fs:[0]. Да что там говорить, я далеко не сразу обратил на это внимание.

Результат все та же ошибка чтения по адресу 0 и приложение падает как ни в чем не бывало!

Хорошо, что все позади. Теперь я знаю об этом флаге. Пробую компилировать с настройкой /SAFESEH:NO.

Компилируем, выполняем наш код, видим наше развесёлое сообщение:

Жмем ок программа успешно завершается. Фух.

Переломный момент

Но нет, подождите-ка, но что ИМЕННО сделал в тот раз компилятор, чтобы проигнорировать мой обработчик исключения? Что это за фокусы? И как мне быть, если вдруг ну чисто теоретически, мне вдруг однажды захочется сделать так, чтобы моё приложение имело флаг /SAFESEH, но при этом, чтобы оно заработало, я должен буду встроить в него код mov fs:[0], esp, с указателем на свою функцию-обработчик, вызвать в произвольном месте исключение и обработать его так, как моей душе угодно? Не считаю, что флаг /SAFESEH должен быть помехой для моих фантазий.

Пытаясь получить информацию о работе этого флага, я увидел что-то размытое про таблицы безопасных обработчиков. Если они есть, то как мне на них посмотреть? Как добавить в них свой обработчик? Каким образом нужно изменить исполняемый файл, чтобы заставить систему при его исполнении выбирать обработчик именно из этой таблицы или наоборот, отключить выбор обработчика? Столько вопросов и так мало ответов

Не падая духом, в надежде разгадать загадку

Полистав срабатывающие обработчики, я увидел те, которые пушит компилятор на стадии выполнения crt0 (код до функции main), а также обработчик из библиотеки ntdll.dll. Все эти обработчики исправно срабатывают в порядке своей очереди, если я не помещаю свой обработчик по адресу fs:[0]! Но как система узнает, какая функция безопасный обработчик, а какая нет?

В своем путешествии я провёл серию экспериментов. Например, компилируя с флагом /SAFESEH, ставил точку останова перед проблемной инструкцией, брал первый обработчик, на который указывала первая структура по адресу fs:[0] и менял начало функции прямо в отладчике на JMP ExceptionHandler (мой самописный обработчик). Срабатывает! Срабатывает именно мой обработчик! И к чему тогда эта неведомая таблица безопасных, когда я могу легко подставить вместо любого безопасного прыжок на свой опасный? Впрочем, не важно. Это не даёт ответа на мои изначальные вопросы.

Теперь я пробую записать лог от entry point до функции main двух EXE файлов, скомпилированных с флагом /SAFESEH и /SAFESEH:NO, наивно предполагая, что компилятор на этой стадии вызывает некие WinApi функции, с помощью которых можно добавить в систему адреса безопасных обработчиков. Все тщетно. Два лога практически не отличаются, разве что серией дополнительных невнятных инструкций, не имеющих ничего общего с адресами безопасных обработчиков. Но подозрения все равно остались.

Чтобы окончательно исключить crt0 из списка подозреваемых мне необходимо выяснить еще кое-что. Я придумал следующий эксперимент, результат которого даст мне точный ответ на вопрос! Меняю инструкцию точки входа в программу на следующие команды push main, ret. Таким образом, первой командой в стек попадает адрес функции main, а вторая команда ret возвращает регистр указатель на выполнение функции main и функция main выполняется сразу, без crt0! (можно было обойтись и простым JMP, но его немного сложнее реализовать на лету в отладчике)

Я снова был удивлён, ведь мой обработчик вновь не вызвался! Несмотря на неудачу, это дало очень важный ответ: тайна кроется внутри EXE, скомпилированного хитрым образом, а не в коде, который выполняет компилятор.

Может быть, они спрятали этот флаг в PE хедере? В каком-нибудь поле LoaderFlags? И тут неудача У обоих файлов абсолютно идентичные структуры, за исключением разницы в размере секции rdata. Но даже если в секции rdata и лежит несколько дополнительных байт, разве это может быть способом взаимодействия с загрузчиком, кардинально изменяющим работу программы? Я был уверен, что нет

Ведь в секциях можно размещать что угодно, главное - соблюсти ключевые правила, думаю я. Там ведь полно мусора! Всякие строки, цифры, дни недели, это ведь не монолитная структура какая-то, а блоки данных, на которые указывают указатели. Эти блоки располагаются в произвольном месте, но в диапазоне секции. При сравнении двух файлов, никаких указателей на область rdata я не увидел, кроме известной importTable, debug и неизвестного указателя на boundImport. Кроме того, такой же указатель был и в файле без флага /SAFESEH! Его размер в таблице dataDirectories был очень маленьким и он был заполнен нулями. Это не вызывало моих подозрений, потому что я был уверен, что если бы это каким-то образом включало в себя таблицу обработчиков, то в файле без флага /SAFESEH это поле просто напросто отсутствовало бы, что было бы знаком для загрузчика у этого EXE таблицы нет.

Победа близка

Отчаявшись, я беру адрес первого безопасного обработчика, который подсовывает в мой EXE компилятор. А точнее - первые два младших байта его адреса и прошелся поиском по всему файлу. То, что я решил взять именно 2 байта, включая те значения, которые не являются виртуальными адресами, является везением. И вот, я выписал все сырые адреса, по которым располагаются эти значения, преобразовал адреса в виртуальные (их было около 3-х) и приступил к задуманному.

Имея несколько адресов в памяти, предположительно намекающих на законный обработчик от компилятора, я ставлю точку останова перед командой mov eax, DWORD PTR SS : [0] и жму play. Срабатывает. Я расставляю точки останова на чтение памяти по записанным адресам и снова жму play.

Удивительно! О брекпоинт споткнулась некая безымянная функция в библиотеке ntdll.dll.

Смотрим. Функция пытается считать по адресу 0x1341d20 из диапазона rdata число 0x1be0, а затем сравнить его с числом, находящимся в EBX 0x1000. Так, а ведь по адресу imageBase + 0x1000 расположился мой подставной обработчик! Он - то сейчас и находится в числе первых обработчиков. А по адресам 0x1be0, 0x2080 и 0x2351 располагаются безопасные обработчики от компилятора. Вот она эта таблица! Она всё это время была в моём файле!

В отладчике я меняю содержимое адреса 0x1341d20 с 0x1be0 на 0x1000 и мой обработчик стал вдруг безопасным и послушно обработал исключение!

Реализация проверки безопасных обработчиков в открытом виде! Она не находится внутри ядра Windows, как я ожидал. Так что можно даже подкорректировать код в ntdll.dll, чтобы в нужное время он разрешал или запрещал выбранные нами обработчики!

Вернёмся к нашему файлу еще раз внимательно посмотрим, что находится в окрестностях.

Ага, таблица на месте. Кроме того, случайно мне бросился в глаза адрес-указатель на начало таблицы, а также странное совпадение число 3, равное количеству обработчиков.

Попробую-ка я заполнить нулями этот адрес-указатель.

Успех! Программа, которую я скомпилировал с флагом /SAFESEH теперь выполняется так, как будто бы был установлен флаг флага /SAFESEH:NO!

А сравним с файлом, который был скомпилирован без этого флага?

Чёрт ни адреса-указателя на таблицу в этом месте, ни таблицы обработчиков И как я сразу не додумался сравнить два файла скомпилированных с разными флагами в Total Commander?!

Тем не менее, что указывает на это место в PE хедере? Самым близким является boundImport. И вот только теперь я открываю сайт Microsoft с описанием PE формата.

В общем я предлагаю вам самим ознакомиться с тем, что парни из Microsoft пишут там про boundImport. Что-то о таблице каталога отложенной загрузки на этапе связывания пост-обработки? Что?

В этом большом и непонятном документе, в котором описаны разные различия этого формата для разных платформ и т.д. Но меня уже не остановить, я начинаю упорно вводить в поиске слово except прыгаю по результатам поиска.. Вот, .sxdata - содержит индекс символа каждого из обработчиков. Какая . sxdata? У меня такого нет. Есть только виртуальный указатель в поле boundImport, относительно которого неподалёку размещается адрес-указатель и таблица с адресами функций-обработчиков.

Ищу дальше... Вот, ещё говорят, что в разделе pdata содержится массив обработчиков и что таблица может быть в нескольких форматах. Даже если и так, как загрузчик попадает на начало этой таблицы? В том месте, куда указывает boundImport, нет ничего похожего на тот формат, который представлен.

Смотрю дальше... The Load Configuration Structure. Судя по описанию очень похоже! Они говорят, что структура IMAGE_LOAD_CONFIG_DIRECTORY где-то присутствует и описывают ее предназначение, связанное с SEH.

Думаю, а попробую-ка. Взял, да и написал, что по адресу boundImport находится структура IMAGE_LOAD_CONFIG_DIRECTORY32 и рассмотрел её поля. Все сошлось как пазл! Это опять же было большим везением, что я решился на это.

Вот и всё, весь секрет заключался в том, что boundImport в PE хедере (разложенном по таблице из Википедии) указывает на структуру IMAGE_LOAD_CONFIG_DIRECTORY32. Эта структура содержит поля, которые кардинальным образом меняют работу программы! Теперь мы с уверенностью можем сами создать свою таблицу обработчиков и добавить в неё только то, что посчитаем нужным!

Может быть, где-то в недрах документации есть информация о том, что boundImport уже называется не так на моей платформе? Или информация о том, что его назначение изменилось? Может быть, boundImport оказался исключен или сдвинут?

Подробнее..

Разработка стековой виртуальной машины и компилятора под неё (часть III)

20.06.2021 10:05:00 | Автор: admin

По ходу разработки генератора кода для виртуальной машины понял, что виртуальная машина не готова к полноценным вызовам функций, с передачей аргументов и хранением локальных переменных функций. Поэтому её необходимо доработать. А именно, нужно определиться с Соглашением о вызовах (calling convention). Есть много разных вариантов, но конечный выбор за разработчиком. Главное - это обеспечить целостность стека, после вызова.

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

На сегодняшний день, наиболее знакомые мне Соглашения о вызове (calling convention), регулирующее правила передачи аргументов функции, очистки стека после вызова, а также логика хранения локальных переменных - это C declaration (cdecl, x86/64) и pascal. Попробую применить эти знания с небольшими модификациями, а именно без прямого доступа программы к регистрам виртуальной машины (она же всё таки стековая, а не регистровая). Итак, логика будет следующая:

Поясню что происходит. Функция main() вызывает функцию sum() и передаёт ей два аргумента - значение переменной i и константное число 10. Осуществляется передача аргументов путём добавления значений аргументов в стек слева направо (как в pascal). После чего осуществляется вызов функции инструкцией виртуальной машины call, которой указывается адрес вызова и количество передаваемых через стек аргументов - 2.

Дальше команда call должна сделать следующие вещи:
1) сохранить в стек адрес возврата после вызова IP (Instruction Pointer + 1)
2) сохранить в стек значение Frame Pointer (регистр виртуальной машины, которым мы показываем до куда очищать стек после вызова).
3) сохраняем в стек значение Locals Pointer (регистр указывающий на место в стеке где начинаются локальные переменные вызывающей функции).
4) выставить значение Frame Pointer на первый аргумент в стеке, чтобы мы знали докуда очищать стек после завершения выполнения функции.
5) выставить значение Locals Pointer на адрес в стеке сразу после сохранных значение IP, FP, LP.

В свою очередь команда ret должна выполнить действия в обратном порядке:
1) Восстановить из стека предыдущие значения IP, FP, LP.
2) Взять результат выполнения функции с вершины стека.
3) Выставить SP = FP (очистить стек в состояние до вызова).
4) Положить на вершину стека результат выполнения функции.

Так как делаем стековую, а не регистровую виртуальную машину, не хочу давать прямой доступ к регистрам, поэтому доработаем инструкцию CALL / RET, а также добавим четыре дополнительные инструкции LOAD (положить в стек значение локальной переменной с указанным индексом), STORE (взять верхнее значение в стеке и сохранить в локальной переменной с указанным индексом), ARG (добавить в стек значение аргумента функции с указанным индексом), а также DROP - инструкция выбросить из стека верхнее значение. Последняя инструкция DROP нужна для функций значение которых нам не нужно, так как мы не даём прямой доступ к регистрам.

case OP_CALL:a = memory[ip++];      // get call address and increment addressb = memory[ip++];      // get arguments count (argc)b = sp + b;            // calculate new frame pointermemory[--sp] = ip;     // push return address to the stackmemory[--sp] = fp;     // push old Frame pointer to stackmemory[--sp] = lp;     // push old Local variables pointer to stackfp = b;                // set Frame pointer to arguments pointerlp = sp - 1;           // set Local variables pointer after top of a stackip = a;                // jump to call addressbreak;case OP_RET:a = memory[sp++];      // read function return value on top of a stackb = lp;                // save Local variables pointersp = fp;               // set stack pointer to Frame pointer (drop locals)lp = memory[b + 1];    // restore old Local variables pointerfp = memory[b + 2];    // restore old Frame pointerip = memory[b + 3];    // set IP to return addressmemory[--sp] = a;      // save return value on top of a stackbreak;case OP_LOAD:a = memory[ip++];         // read local variable indexb = lp - a;               // calculate local variable addressmemory[--sp] = memory[b]; // push local variable to stackbreak;case OP_STORE:a = memory[ip++];         // read local variable indexb = lp - a;               // calculate local variable addressmemory[b] = memory[sp++]; // pop top of stack to local variablebreak;case OP_ARG:a = memory[ip++];         // read parameter indexb = fp - a - 1;           // calculate parameter addressmemory[--sp] = memory[b]; // push parameter to stackbreak;case OP_DROP:                   // pop and drop value from stacksp++;break;

Скомпилируем код представленный на иллюстрации выше чтобы протестировать как работают новые инструкции CALL, RET, LOAD, STORE, ARG (примечание: syscall 0x21 - это распечатка числа с вершины стека в консоль):

Запустим исполнение этого кода с распечаткой состояния виртуальной машины после выполнения каждой инструкции:

[   0]    iconst  5     IP=2 FP=65535 LP=65534 SP=65534 STACK=[5] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[5,5] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[5,4] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[5,4,4] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[4,4,4] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[4,4,4,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[4,4,4,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[4,4,4,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,4] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[4,4,4,10,14,65535,65534,10,4,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,14] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[4,4,4,10,14,65535,65534,10,14,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[4,4,4,10,14,65535,65534,10,4] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[4,4,4] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[4,4,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[4] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[4,4] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[4,3] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[4,3,3] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[3,3,3] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[3,3,3,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[3,3,3,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[3,3,3,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,3] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[3,3,3,10,14,65535,65534,10,3,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,13] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[3,3,3,10,14,65535,65534,10,13,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[3,3,3,10,14,65535,65534,10,3] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[3,3,3] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[3,3,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[3] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[3,3] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[3,2] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[3,2,2] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[2,2,2] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[2,2,2,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[2,2,2,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[2,2,2,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,2] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[2,2,2,10,14,65535,65534,10,2,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,12] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[2,2,2,10,14,65535,65534,10,12,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[2,2,2,10,14,65535,65534,10,2] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[2,2,2] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[2,2,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[2] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[2,2] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[2,1] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[2,1,1] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[1,1,1] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[1,1,1,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[1,1,1,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[1,1,1,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,1] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[1,1,1,10,14,65535,65534,10,1,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,11] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[1,1,1,10,14,65535,65534,10,11,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[1,1,1,10,14,65535,65534,10,1] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[1,1,1] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[1,1,0] -> TOP[  18]    icmpjg  [2]   IP=2 FP=65535 LP=65534 SP=65534 STACK=[1] -> TOP[   2]    iload   #0    IP=4 FP=65535 LP=65534 SP=65533 STACK=[1,1] -> TOP[   4]    idec          IP=5 FP=65535 LP=65534 SP=65533 STACK=[1,0] -> TOP[   5]    idup          IP=6 FP=65535 LP=65534 SP=65532 STACK=[1,0,0] -> TOP[   6]    istore  #0    IP=8 FP=65535 LP=65534 SP=65533 STACK=[0,0] -> TOP[   8]    idup          IP=9 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[   9]    iconst  10    IP=11 FP=65535 LP=65534 SP=65531 STACK=[0,0,0,10] -> TOP[  11]    call [32], 2  IP=32 FP=65533 LP=65527 SP=65528 STACK=[0,0,0,10,14,65535,65534] -> TOP[  32]    iconst  10    IP=34 FP=65533 LP=65527 SP=65527 STACK=[0,0,0,10,14,65535,65534,10] -> TOP[  34]    iarg    #0    IP=36 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,0] -> TOP[  36]    iarg    #1    IP=38 FP=65533 LP=65527 SP=65525 STACK=[0,0,0,10,14,65535,65534,10,0,10] -> TOP[  38]    iadd          IP=39 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,10] -> TOP[  39]    iload   #0    IP=41 FP=65533 LP=65527 SP=65525 STACK=[0,0,0,10,14,65535,65534,10,10,10] -> TOP[  41]    isub          IP=42 FP=65533 LP=65527 SP=65526 STACK=[0,0,0,10,14,65535,65534,10,0] -> TOP[  42]    ret           IP=14 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[  14]    syscall 0x21  IP=16 FP=65535 LP=65534 SP=65533 STACK=[0,0] -> TOP[  16]    iconst  0     IP=18 FP=65535 LP=65534 SP=65532 STACK=[0,0,0] -> TOP[  18]    icmpjg  [2]   IP=20 FP=65535 LP=65534 SP=65534 STACK=[0] -> TOP[  20]    ---- halt ----IP=21 FP=65535 LP=65534 SP=65534 STACK=[0] -> TOPEXECUTION TIME: 0.620997s

В консоли данная программа выдает следующее

Эти числа в консоли говорят, что вызовы функций, передача аргументов, аллокация локальных переменных, возврат значения и восстановление стека после вызова работают нормально. Значит можно полностью переходить и фокусироваться на разработке компилятора для этой виртуальной машины (AST и генерацию кода). Теперь в виртуальной машине есть всё необходимое.

Ура! Это вдохновляет!

Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru