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

C++

Как Qt сделал студента человеком

10.09.2020 02:16:17 | Автор: admin

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


Ранний университет


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


Поиск себя


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


Еще в школе я начал увлекаться WarCraft III, а затем играть онлайн в Battle.net. Так познакомился с несколькими играющими людьми из своего города. Мы общались, играли, устраивали кланвары, в общем довольно весело проводили время, хотя уже тогда игра начала потихонечку увядать, самый расцвет соревновательного времени игры я не застал. Чтобы поддержать интерес к любимой игре, несколько парней из моих знакомых решили организовать турнир, в котором могли принять участие все желающие. Я же вызвался помочь в организации, а именно в расклеивании объявлений о нем на безвозмездной основе.


Сейчас листовка не может не вызывать улыбку, особенно "амбициозный интернет-проект"

(турнир был успешно проведен, а видео с него в 640x480 вызывает дикую ностальгию)


Листовки нужно было физически забрать в офисе одного из ребят, и мне впервые довелось пообщаться с ним лично. Он работал в конторе, базирующейся в здании организации а-ля бизнес-инкубатор, внутри которого были достаточно прикольные и комфортные офисы. Я был очень впечатлен, никогда ранее не видел ничего подобного. В ходе ознакомительной беседы я поинтересовался о том, чем он занимается, как попасть в аналогичное место и как начать зарабатывать как уже говорил раньше, это была животрепещущая для меня тема. Приятель учился на филолога, соответственно все его советы в той или иной мере касались работы с языком. Он порекомендовал мне начать работать хоть где-то, например в call-центре (что неудивительно, учитывая его профиль). Я его послушался, и даже пособеседовался разок в какой-то конторе слава Богу меня не взяли :) Еще один приятель зарабатывал по тем временам неплохие деньги дизайнерством вроде как он рисовал модельки различной мебели в 3D-редакторе, а так же рисовал разнообразные логотипы в Photoshop'e. Каждый кулик свое болото хвалит, так что неудивительно, что этот товарищ порекомендовал мне заниматься дизайнерством :) В эту тему я углубился уже намного сильнее, купил книжку "Photoshop для чайников" (буквально), и прорешал ее от корки до корки, проделав все примеры от начала и до конца. Я реально знал весь Adobe Photoshop CS2.



(та самая книжка и консервативный UI Photoshop CS2)


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


Первые шаги в правильном направлении


Тем временем заканчивался первый курс в университете. На тот момент я знал только самые азы программирования на C (даже не C++) и по-прежнему увлекался Warcraft III. Одним из самых топовых ресурсов в России по этой игре тогда (да и сейчас) был сайт GoodGame.ru. Услышав, что на сайте неким D39 (я его сначала хотел назвать неизвестным волонтером, пока не накопал тему на форуме) создается проект для проведения кастомных игр по варкрафту (нечто вроде Garena или GGC), я тут же решил присоединиться к проекту. На тот момент у него уже много всего было готово примерный UI, старт игр, матчинг соперников, etc. В ответ на мою просьбу о присоединении D39 поинтересовался моими знаниями, а потом отправил читать доку по WxWidgets. Господи, это был просто китайский. Какие-то непонятные вещи, контролы, циклы событий, а в каждой второй ссылке разбирающиеся люди спорят о том, что лучше WxWidgets или Qt. Я старался, но абсолютно ничего не понял. Да и проект, как я помню, сам по себе затух, ничего готового релизнуть не получилось. С большим трудом сейчас откопал тему на форуме и даже нашел свой коммент: https://goodgame.ru/topic/7143/. Жалко скрины все протухли...



(сообщение на форуме о начале разработки)


Еще из примечательного за этот период: в самом конце курса по программированию у нас была задачка на связные списки. Уж не помню почему, но я как-то быстрее всех разобрался в теме, и затем помог выполнить и сдать лабораторку всем в группе. Причем несмотря на пользование Visual Studio, я никогда не пользовался отладкой прокручивал весь код в голове, а если и отлаживался, то обычными принтами (такая закалка с раннего обучения оказалась очень кстати в будущем, когда перешел в разработку под мобильные ОС/Linux). Оглядываясь сейчас назад я понимаю, что это ценнейший опыт понять тему чуть-чуть лучше других и затем объяснять им, получая в процессе еще больше знаний. Потом так случилось с курсом по компиляторам, с git'ом и т.д.


Середина университета


Черт его знает, что меня надоумило это сделать, но после первого курса во время летних каникул я от корки до корки прочитал книгу "Объектно-ориентированное программирование в C++" (Лафоре). Точно помню, что посоветовал мне ее приятель, так что не стоит недооценивать общение с другими студентами во время обучения. На чтение и прорешивание всех заданий книги ушел примерно месяц. Благо я уже натренировался работать с книгой в таком формате на "Photoshop для чайников". К началу нового учебного года я подошел во всеоружии. Начавшийся курс по С++ казался элементарным, я периодически задавал лектору нетривиальные вопросы, общался с ним о тонкостях языка, да и вообще в целом один из немногих понимал, что вообще происходит и о чем речь. К экзамену впервые не нужно было готовиться и учить билеты. Это было абсолютно новое, непривычное чувство всю необходимую для сдачи информацию я знал.


Знакомьтесь Qt


На текущий момент читающий может задаться вопросом а причем вообще Qt в названии статьи? :) А я как раз собирался рассказать Позднее случилось еще одно совершенно случайное, но судьбоносное событие. После одной из практик мы с товарищем засиделись в аудитории. Кажется, мы рисовали какие-то окошки на голом WinAPI, и дико удивлялись тому, сколько всякого кода генерирует Visual Studio в пустом проекте. Когда опомнились, нас уже потихоньку выпроваживал преподаватель, потому как ему нужно было на другую пару. Случился примерно такой диалог (преподаватель и студенты):


П Так WinAPI это старье беспросветное, на нем уже никто не пишет
С Да? А на чем пишут сейчас?
П Ну на Jave и на Qt


Ого! Классно, Qt, я уже слышал про него, надо попробовать, ведь мне столько раз встречалось название этой библиотеки, когда я собирался учить WxWidgets в волонтерском проекте для GG. Дорожки сошлись! С преподавателем на других курсах не встречались, так что он вряд ли узнал, какое сильное влияние на меня оказал этот крошечный диалог.
Как оказалось, на тот момент у меня уже был подходящий уровень знания C++, чтобы освоить первую в своей жизни графическую библиотеку. А дальше было чтение Макса Шлее "Qt 4.8 Профессиональное программирование на C++". Стоит ли говорить о том, что все задания были прорешаны, все примеры кода перенабраны и запущены, а некоторые ради своего интереса модифицированы. Трудно вспомнить точно, сколько в итоге получилось кода, но мне кажется что-то в районе 10к строк. Для студента в начале своего пути это крайне важный опыт.



(прекрасная книжка для самостоятельного изучения Qt)


Где-то через полгода я уже мог сделать абсолютно любую университетскую лаборатурную/курсовик на C++/Qt (когда подразумевалось использование любого удобного инструмента). Программки получались быстрые, симпатичные, с масштабируемым интерфейсом. Помог и предыдущий опыт работы с Photoshop'ом не ленился нарисовать красивые иконки и поставить их на кнопочки или пункты меню. Спустя некоторое время преподаватели начали это намечать, а затем и давать какие-то нестандартные задачки и курсовые. Их было интереснее делать, а профита с них получалось намного больше. Так, с одним из преподавателей мы решали разные задачки вплоть до песледнего курса, конечно же именно в будущем он стал у меня руководителем дипломной работы.


MeeGo


Кажется, в какой-то момент захотелось программировать под мобильные ОС. Шутка ли, тогда смартфоны активно завоевывали рынок, и незнание мобильной разработки казалось большим упущением. Qt под Android тогда еще не было, зато можно было создавать приложения под Symbian^3. Первые мои поделки на этом поприще были очень скромные по функциональности то ли это был трекер калорий/веса, то ли аналогичная простая утилитка. Кстати программировать предлагалось на QML возможно лучшее, что получила экосистема Qt за время пребывания под крылом Nokia. Я изучил эту технологию практически сразу после анонса, периодически мысленно хвастаясь мол если меня спросят, сколько у меня лет опыта работы с QML, я смогу ответить в стиле Чака Норииса все!


Nokia тогда так активно продвигала Qt, что была готова бесплатно раздавать телефоны всем заинтересованным. Да да, программа Qt Ambassador именно это и подразумевала нужно было всего лишь создать какое-то приложение на Qt (под любую платформу если не ошибаюсь), а затем зарегистрироваться в программе. В общем то времена можно охарактеризовать цитатой из Mr. Robot: It's an exciting time in the world, exciting time. За указанные выше нехитрые действия я получил Nokia N9 под управлением MeeGo Harmattan



(Nokia N9 отличное сочетание железа и программной начинки)


Вау! Новое устройство, новая ОС, и самое главное можно программировать приложения на Qt. Кстати, SDK для этой платформы мне до сих пор кажется одним из самых лаконичных, зрелых и быстрых в работе среди всех, что были созданы на базе Qt Quick. Разве что Qt Quick Controls 2 может тем же похвастаться.


Окончание университета


К "зрелому" университету все уже было под контролем я знал несколько языков программирования, подтянул уровень образования с помощью курсов на Coursera'е, без особого труда совмещал учебу с военной кафедрой и периодически получал небольшие деньги за лабораторки/курсовые на Qt. Но, как оказалось, это была еще далеко не вся польза, которую я мог выжать из любимого фреймворка.


Диплом YaD


В 2012 году появился на свет Яндекс.Диск. Мне он был не столько интересен в качестве облачного хранилища, сколько в плане возможности работы с ним по открытому API, базирующемуся на WebDAV. Помнится, прочитал статью и тут же решил попробовать скачать файл программно, на Qt. Буквально 5-7 минут и я уже вижу в консоли содержимое файла hello.txt, который несколькими минутами ранее закинул на сервер. Ух ты, так просто и работает? Я тут же начал накидывать функциональность в свое-мини PoC-приложение, научился выводить список файлов, скачивать выбранный, а потом и загружать. И тут мне пришла ясная идея а почему бы не написать клиент под MeeGo? Ведь там не будет официального клиента, так как позиции ОС в борьбе с Android и iOS уже тогда были нерадужные. И заодно эту разработку можно было использовать в качестве своей дипломной работы, в очередной раз применив Qt в качестве своей палочки-выручалочки в университете. Клиент я назвал просто и кратко YaD. Конечно, тему диплома пришлось сделать немного формальной, если не ошибаюсь "Клиент облачного хранилища данных для мобильной ОС MeeGo YaD". Программировать приложение было намного легче, чем писать пояснительную записку к диплому :)



(несколько скриншотов приложения, позаимствованы из ПЗ)


Я выпустил две версии приложения: платную и бесплатную. В платной была только одна дополнительная возможность шаринг файлом/ссылкой посредством диалога ОС. Если честно, в бесплатной версии она тоже была, и любой желающий мог ее просто раскомментировать в QML-файле. Кстати приложение довольно долго жило, получило несколько форков (в основном косметических), пользователи давали позитивные оценки (особенно запомнился комментарий, что удалось без проблем закачать файл 1.4Гб со своим DSL на тот момент я и подумать не мог о таких объемах), когда-то даже висело в топе продаваемых, а некто Nokia Expert даже снял видео на YouTube. В обзоре довольно много времени разбираются прочие системные приложения, почти полностью упущено одно из двух функциональных меню, но все равно было приятно, что кто-то уделил моему диплому время.



(скриншоты ТОПа платных и бесплатных приложений магазина)


Ubuntu Phone


Когда Canonical объявила о создании Ubuntu Phone, моей радости не было предела. К тому времени я уже порядка года или немногим более сидел на Ubuntu в качестве домашней ОС и полностью проникся духом свободного ПО. Кстати, в те времена можно было заказать бесплатный установочный диск Ubuntu. Его честно отправляли по почте в любую глубинку, а ты находил его в почтовом ящике, довольный такому приятному и самое главное бесплатному сувернирчику. Возвращаясь к Ubuntu Phone компания объявила, что к созданию базовых приложений (таких как калькулятор, календарь, читалка документов и т.д.) будут привлекаться волонтеры из сообщества. Примечательным было то, что в качестве фреймворка для разработки предлагалось использовать Qt. "Вау!",- подумал я,- "кажется мои навыки могут им пригодиться" и ради интереса отправил свою кандидатуру! А потом, будучи на празднике в честь дня рождения подруги, получил официальное приглашение в команду RSS Reader'а. Это был восторг да и только. Ранее я писал об этом в статье. Одной из интересных плюшек участия в этом проекте была возможность заказать себе Nexus 4 в качестве стенда для разработки/тестирования. Конечно же я ей воспользовался второй телефон кряду забесплатно!



(рисунок актуального на тот момент логотипа Qt в исполнении сестры)


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


Первая работа


В нашем университете многие преподаватели совмещали, собственно, преподавание одновременно с работой в прочих коммерческих организациях. Одна из таких контор была буквально через дорогу от университета. Нет ничего удивительного в том, что самых смышленых ребят из нашего потока позвали туда работать за еду, набираться опыта и знаний. Так я попал на свою первую работу. Разрешалось совмещать учебу в университете полдня на учебе, полдня на работе. Сразу скажу, что конкретно Qt там заниматься не довелось, однако знание фреймворка более чем пригодилось в более позднем периоде работы. Дело в том, что нам предлагалось разработать графический интерфейс для самобытной ОС, о которой ранее писали на Хабре (если самую малость постараться, можно без труда сопоставить факты и понять о ком именно я говорю). Задача была не из простых. Однако в коллективе из двух опытных системных разработчиков и двух студентов возникло уникальное сочетание знаний, которое помогло с этой задачей справиться. Мой вклад был в знании принципов работы Qt (об отрисовке кастомных контролов Qt Quick в частности) и в глубоком знании особенностей форматов изображений (привет книжке "Photoshop для чайников" из начала статьи). Фреймворк мы делали что называется "с душой", очень круто сработались и по-настоящему подружились.


Забавный факт видимо я настолько часто трындел про Qt, что даже на приуроченном ко дню рождения компании пиксельарте я стою с табличкой "Qt".



(выдержка из огромного полотна со 150-ю уникально нарисованными человечками)


После университета


Потом было разное портирование YaD на Ubuntu Phone (и неудачное на Android) с получением за это значка Ubuntu Phone Pioneer, участние в Scope Showdown, создание AMeditation. Но только во время написания статьи я понял, что никогда не программировал на Qt в своей регулярной работе. То ли в маленьком городе было не так много вариантов трудоустройства, а удаленка тогда еще не так была распространена (да и софт-скиллов для нее еще было маловато). То ли я намеренно программировал на Qt только для души, бережно храня эту отдушину в стороне от сроков, дедлайнов, багов на продакшене, etc. Но факт остается фактом на основной работе меня увлекло в энтерпрайз с его C#/Java/SQL и прочими вещами, которые в университете казались крайне непривлекательными, особенно в свете отличного знания C++/Qt/QML. Решился бы я написать эту статью про Qt, если бы использовал его ежедневно на протяжении лет? Вопрос риторический...


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


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

Подробнее..

DevSecOps организация фаззинга исходного кода

10.09.2020 06:07:43 | Автор: admin


Узнав результаты голосования, проведённого в одной из наших прошлых статей, мы решили более подробно обсудить вопрос организации фаззинга. Кроме того, в рамках онлайн-встречи по информационной безопасности "Digital Security ON AIR" мы представили доклад, основанный на нашем опыте в DevSecOps, где также рассказали об этой интересной теме.


Записи всех докладов можно посмотреть на нашем Youtube-канале. Если же вы предпочитаете текстовый формат, добро пожаловать под кат!


  1. Фаззинг
  2. Этапы интеграции фаззинга в проект
  3. Фаззинг в облаке
  4. Заключение



Фаззинг


Введение


Относительно недавно вышла замечательная книга Building Secure and Reliable Systems от инженеров компании Google. Её главным лейтмотивом является мысль о том, что безопасность и надёжность систем и ПО тесно взаимосвязаны. Публикация подобного материала одним из крупнейших лидеров IT-рынка отлично демонстрирует, что безопасность становится неотъемлемой частью как процесса разработки, так и всего жизненного цикла системы или ПО.


Эффективно ли тестирование?


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


CI/CD нужны не только для автоматизированных тестов, но и в глобальном плане для того, чтобы соответствовать требованиям времени. Каскадная модель разработки, согласно которой этапы проектирования, разработки и тестирования происходят строго последовательно, постепенно уходит в прошлое или остается лишь в определенных нишах. Сейчас очень важна скорость разработки. Важно, чтобы каждая часть ПО могла развиваться независимо от других частей. Таким требованиям соответствует метод непрерывной разработки. А непрерывной разработке нужно непрерывное тестирование.


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



https://resources.securitycompass.com/blog/how-to-sell-training-costs-internally-2


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



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


Что такое фаззинг?


Для начала небольшой исторический экскурс. Сам термин "фаззинг" ("fuzzing") появился впервые около 30 лет назад в работе An Empirical Study of the Reliability of UNIX Utilities. С его появлением связана одна интересная легенда. В 1988 удар молнии исказил передаваемые по линии данные, что привело к падению утилиты автора исследования. После этого уже профессор Брет Миллер вместе со своими студентами сделал полноценное исследование в данном направлении и на одном из семинаров представил программу fuzzer. Данная программа предназначалась как раз для тестирования ПО. Сам Брет Миллер так описывал свой подход: Наш подход не является заменой формальной верификации или тестирования. Скорее, это легковесный механизм для поиска ошибок в системе и повышения её надёжности. Официальное же определение фаззинга звучит так:


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

Сегодня мы сконцетрируемся на использовании фаззинга для кода на таких языках программирования, как С и С++, потому что:


  • в ПО на таких ЯП чаще находят бинарные уязвимости;
  • эффективные средства поиска уязвимостей, такие как libFuzzer и AFL, ориентированы, в первую очередь, на языки C и C++;
  • огромное количество программ и библиотек написано на C и C++.

Тем не менее все указанные наработки как минимум можно использовать без особых проблем для ПО на Rust или Go.


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



The Art, Science, and Engineering of Fuzzing:A Survey


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


  • по знанию о входных данных:
    • Black-box мы ничего не знаем про формат данных. Нужно реверсить
    • Gray-box что-то о формате мы выяснили, но полная информация о нём нам всё ещё неизвестна
    • White-box у нас есть спецификация. Мы располагаем всеми сведениями о формате данных
  • по цели, которая будет подвергнута фаззингу:
    • source-based у нас есть исходники проекта
    • binary-based у нас нет исходников проекта
  • по наличию обратной реакции от тестируемого приложения:
    • feedback driven есть обратная реакция
    • not feedback driven нет обратной реакции
  • по операциям, которые будут совершаться над входными данными:
    • генерационные
    • мутационные
    • комбинированные

Стоит отметить, что часто binary-based фаззеры называют black-box, а source-based gray- или white-box. В данной статье нас в первую очередь интересует source-based фаззинг с обратной связью, поскольку мы строим DevSecOps для разработки собственного продукта или анализа opensource-проектов, а это значит, что исходный код для него у нас есть.


Фаззинг всё ещё популярен?


Кто-то может задаться вопросом: "Если фаззингу уже более 30 лет (согласно некоторым источникам и с учетом прообраза этого метода тестирования, и все 70), то почему только в последнее время он стал так популярен?".


В начале тысячелетия Microsoft в рамках описания создания Secure Development Lifecycle
выпустили несколько статей, посвящённых тематике фаззинга, но тогда речь в основном шла о black-box-фаззинге. Однако в 2013 году фаззинг получил вторую жизнь, благодаря созданию feedback-driven fuzzing, который позволил достичь новых высот в этой сфере. Важную роль также сыграло появление простых и понятных инструментов, о которых мы расскажем далее.


Feedback-driven fuzzing это вид фаззинга, при котором фаззер изменяет входные данные так, чтобы их обработка затрагивала как можно больше участков кода программы. Работа таких фаззеров возможна, благодаря их способности реагировать на отклик (feedback) программы. Обычно таким откликом является покрытие кода. Метрики покрытия кода отслеживают суммарное количество выполненных строк кода, базовых блоков, количество сделанных условных переходов. Задача фаззера генерировать данные, которые приводят к увеличению покрытия кода.


Одним из наиболее популярных feedback-driven фаззеров является American Fuzzy Lop от Michael Zalewski. AFL был создан в 2013 году и стал главным двигателем прогресса в современном фаззинге.


Про AFL было уже несколько статей на Хабре, но повторимся, как он работает:


  • в исполняемый файл добавляется инструментация для сбора информации о покрытии кода. Информация будет сохраняться в shared bitmap;
  • AFL запускает программу и доходит до функции, которую нужно профаззить. Здесь он сохраняет своё состояние через системный вызов fork;
  • далее происходит цикл "мутация данных запуск откат". При этом данные мутируются с учётом покрытия кода, получаемого от программы.

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


AFL оказался чрезвычайно эффективным при фаззинге различных программ. Это и сделало его знаменитым. Он был добавлен в Google Summer of Code (GSoC), а вокруг него сформировалось большое сообщество. Многочисленные энтузиасты предлагают собственные модификации AFL, которые по разным причинам не были приняты в основную ветку (например, существуют вариации для других ЯП). Об этом можно узнать подробнее в ещё одной нашей статье, посвященной различным вариациям AFL фаззера. Ещё хотелось бы заметить, что создатель AFL отошёл от дел и до недавнего времени проект почти не обновлялся, из-за чего многие стали использовать его активно поддерживаемый форк AFL++.


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


В итоге так и случилось. В 2015 году появился libFuzzer один из подпроектов LLVM. LibFuzzer позволяет реализовать feedback-driven in-memory фаззинг и имеет все преимущества, характерные для средств фаззинга исходного кода:


  • позволяет создавать отдельные фаззеры для каждой функции из рабочего проекта;
  • использует санитайзеры;
  • может быть интегрирован в проект через toolchain.

Статический анализ


Прежде чем обратиться непосредственно к Continuous Fuzzing, скажем пару слов об анализаторах кода и фаззерах. Для тех, кто думает, нужно ли это всё, если те или иные анализаторы кода уже используются в процессе разработки.


Code analysis Fuzzing
Встраивание в CI Да* Да
Тип Статика Динамика
Покрытие кода ~100% Зависит от тестовых данных и алгоритмов мутации
Ложные срабатывания Много Нет
Пропуск ошибок Зависит от базы знаний анализатора Зависит от тестовых данных
Типы ошибок Широкий спектр Определённый спектр ошибок
Переиспользование Нет* Уходит в тест
Ручной анализ Много Нет

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


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


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


На эту тему есть отличная статья How to Prevent the next Heartbleed, в которой автор сравнил эти подходы для улучшения качества openssl на примере нашумевшей уязвимости heartbleed. И именно подход, объединяющий анализ кода и фаззинг, помог выявить проблему с heartbleed. А обычное покрытие кода тестами, пусть и 100% по всем веткам разработки, оказалось не столь эффективным.


Непрерывный фаззинг в непрерывной разработке



Что же такое Continuous Fuzzing? Continuous fuzzing это подход к организации процесса. Иными словами, его эффективность будет зависеть от правильности организации процесса разработки в вашей компании. Все инструменты для организации такого процесса уже давно созданы осталось лишь правильно встроить их в свою систему.


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


Крупные компании уже не первый год настраивают у себя процесс фаззинга и развивают используемые инструменты. Этот подход уже множество раз доказал свою эффективность. Одна только статистика от Google ошеломляет числом найденных за время ее работы уязвимостей. По данным на 2019-й год, за все время существования ClusterFuzz было обнаружено около 16000 ошибок в Chrome и 11000 ошибок в более чем 160 популярных проектах с открытым исходным кодом.


Для организации фаззинга большие компании используют разный инструментарий. Выбор инструментов зависит от того, какие цели преследуются фаззингом, а также насколько безболезненно возможно внедрить этот инструментарий в уже существующий процесс разработки. В основе ClusterFuzz от Google лежат LibFuzzer, AFL и HonggFuzz (создан на базе LibFuzzera). Компания Microsoft недавно сделала публичным свой проект Springfield, превратив его в полноценный SaaS. Mozilla, помимо уже упомянутых фаззеров, разработала свой фреймворк для быстрого построения фаззеров (Grizzly) и дашборд для агрегации результатов фаззинга. Github не отстает. Так, он интегрировал ossfuzz в свою систему.


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


Поэтому, чтобы начать строить процесс Continuous Fuzzing у себя, необязательно быть крупной компанией, как тот же Google. Для этого не нужно крупных кластеров или мейнфреймов стартовые требования для внедрения Continuous Fuzzing минимальны. Речь о них пойдёт ниже, так как мы в своих проектах чаще используем значительно меньшие мощности и при этом успешно находим уязвимости.


Этапы интеграции фаззинга в проект


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


Выбор инструментов для организации фаззинга


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


  • Составление поверхности атаки (Attack Surface)
    • ПО, в котором удобнее проводить аудит кода (Пример: Sourcetrail)
    • ПО для построения MindMap-схем (Пример: Xmind)
  • Фаззинг
  • Санитайзеры (Sanitizers)

Для составления поверхности атаки необходимо ПО для аудита кода. Например, это может быть sourcetrail, understand, или подойдёт какая-нибудь популярная IDE. Лучше, чтобы ПО для инспекции кода имело возможность автоматического "рисования" блок-схем. Если у разработчиков уже есть готовые блок-схемы для программ из их проекта, то это значительно облегчит процесс. Если же нет, то искренне соболезнуем. Далее из составленной блок-схемы необходимо извлечь те куски, где тем или иным способом могут попасть на вход данные из недоверенных источников. Так и определяется поверхность атаки. На практике весьма удобно оформлять поверхность атаки в виде майндмап. Поверхность атаки служит не только для составления метрик "безопасности" (code coverage), а также, можно сказать, выступает чеклистом для написания фаззеров.


Об AFL и libFuzzer мы уже рассказали ранее, а практическое применение рассмотрим дальше, поэтому не будем на них останавливаться подробно. Можно считать их стандартом индустрии на данный момент. KLEE не совсем фаззер, хотя в некотором роде его можно использовать и так. Для описания процесса работы KLEE потребуется отдельная статья (на Хабре можно найти несколько, но KLEE за это время сильно обновился). Если же кто-то хочет углубиться в тему SMT, то в одной из прошлых статей мы опубликовали список материалов, который может помочь в этом. Если вкратце, то KLEE представляет собой символьную виртуальную машину, где параллельно выполняются символьные процессы и где каждый процесс представляет собой один из путей в исследуемой программе. Для каждого уникального пройденного пути KLEE сохраняет набор входных данных, необходимых для прохождения по этому пути. Это помогает улучшить эффективность фаззинга с помощью увеличения покрытия, а следовательно он прекрасно работает в паре с самими фаззерами.


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


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


Мы не зря начали наш список инструментов с тех, что помогают составить Attack Surface. В презентации для FuzzCon, посвящённой 25-летию фаззинга, была представлена следующая схема:



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


Определение Attack Surface


Что же стоит за словосочетанием "attack surface" ("поверхность атаки")? Возможно, вы уже знаете официальное значение, так как оно так прочно обосновалось в глоссарии, что даже удостоилось отдельной страницы в словаре хакерских терминов журнала Wired. Однако повторимся: если говорить по простому, это "возможность получения некорректных данных из недоверенных источников".


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


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


  • файлы различные парсеры (XML, JSON), мультимедиакодеки (аудио, видео, графика);
  • сеть сетевые протоколы (HTTP, SMTP), криптография (SSL/TLS), браузеры (Firefox, Chrome) и архиваторы файлов (ZIP, TAR).

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


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



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


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

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


Анализ целей для фаззинга


Цель для фаззинга (fuzz target) это функция, которая принимает на вход данные и обрабатывает их с использованием тестируемого API. Иными словами, это то, что нам необходимо фаззить.


Данный этап заключается в тщательном анализе каждой цели для фаззинга из attack surface. Вот что необходимо узнать:


  1. Аргументы функции, через которые передаются данные для обработки. Нужен сам буфер для данных и его длина, если её возможно определить.
  2. Тип передаваемых данных. Например, документ html, картинка png, zip-архив. От этого зависит то, как будут генерироваться и мутироваться поступающие на вход данные.
  3. Список ресурсов (память, объекты, глобальные переменные), которые должны быть инициализированы перед вызовом целевой функции.
  4. Если мы фаззим внутренние функции компонентов, а не API, то понадобится составить список ограничений, которые накладываются на данные кодом, выполненным ранее. Бывает так, что проверка данных происходит в несколько этапов это нам тоже следует учитывать.


Анализ opensource-проекта с использованием sourcetrail


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


Подбор входных данных


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


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


При создании набора seeds нужно учитывать, что:


  • Его элементы должны влиять на покрытие кода программы. Чем выше покрытие, тем меньше неисследованных мест остаётся в программе.
  • Размер его элементов не должен быть большим, иначе это повлияет на скорость фаззинга. Ведь чем больше длина входных данных, тем дольше функция будет их обрабатывать и меньше запусков программы сможет сделать фаззер за единицу времени.
  • Кейсы в сидах должны быть функционально различными. Сильно похожие данные будут тормозить процесс фаззинга, поскольку фаззер часто будет попадать в места, которые уже были им исследованы. Перед запуском фаззера лучше провести минимизацию данных и убрать всё лишнее.

В процессе фаззинга сиды трансформируются в корпус. Корпус (corpus) это набор тест-кейсов, которые привели к росту покрытия кода во время фаззинга целевой программы или функции. Иначе говоря, это те самые интересные инпуты, которые потенциально могут привести к крешу программы. В корпусе сначала содержатся сиды, потом их мутации, затем мутации мутаций и т.д. Здесь мы видим, что фаззинг (feedback-driven) это циклический процесс, где на каждой новой итерации у нас всё больше шансов сгенерировать набор входных данных, который позволит найти уязвимости в программе.



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


Написание фаззеров


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


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


#include "MyAPI.h"extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {  MyAPI_ProcessInput(Data, Size);  return 0;}

Данный код скомпилируется в отдельный исполняемый файл, где в цикле на вход функции MyAPI_ProcessInput будут подаваться случайные данные Data размером Size. Если фаззер обнаружит ошибку, то на консоль будет выведено сообщение о ней. Настанет время устранить ошибку, а после запустить фаззинг дальше.


Сообщение об ошибке от libfuzzer
<!--INFO: Seed: 2240819152INFO: Loaded 1 modules   (6 inline 8-bit counters): 6 [0x565e90, 0x565e96), INFO: Loaded 1 PC tables (6 PCs): 6 [0x541908,0x541968), INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytesINFO: A corpus is not provided, starting from an empty corpus#2  INITED cov: 3 ft: 4 corp: 1/1b lim: 4 exec/s: 0 rss: 35Mb===================================================================29562==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fff2fba1ba0 at pc 0x0000004dda61 bp 0x7fff2fba1b30 sp 0x7fff2fba12d0WRITE of size 65 at 0x7fff2fba1ba0 thread T0    #0 0x4dda60 in strcpy (/home/user/Documents/tmp/main+0x4dda60)    #1 0x52540f in MyAPI_ProcessInput(char const*) (/home/user/Documents/tmp/main+0x52540f)    #2 0x52561e in LLVMFuzzerTestOneInput (/home/user/Documents/tmp/main+0x52561e)    #3 0x42fe0a in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/home/user/Documents/tmp/main+0x42fe0a)    #4 0x42f3a5 in fuzzer::Fuzzer::RunOne(unsigned char const*, unsigned long, bool, fuzzer::InputInfo*, bool*) (/home/user/Documents/tmp/main+0x42f3a5)    #5 0x4310ee in fuzzer::Fuzzer::MutateAndTestOne() (/home/user/Documents/tmp/main+0x4310ee)    #6 0x431dc5 in fuzzer::Fuzzer::Loop(std::vector<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, fuzzer::fuzzer_allocator<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > const&) (/home/user/Documents/tmp/main+0x431dc5)    #7 0x427df0 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/home/user/Documents/tmp/main+0x427df0)    #8 0x44b402 in main (/home/user/Documents/tmp/main+0x44b402)    #9 0x7f7ee294409a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)    #10 0x421909 in _start (/home/user/Documents/tmp/main+0x421909)Address 0x7fff2fba1ba0 is located in stack of thread T0 at offset 96 in frame    #0 0x5252ef in MyAPI_ProcessInput(char const*) (/home/user/Documents/tmp/main+0x5252ef)  This frame has 1 object(s):    [32, 96) 'buf' <== Memory access at offset 96 overflows this variableHINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork      (longjmp and C++ exceptions *are* supported)SUMMARY: AddressSanitizer: stack-buffer-overflow (/home/user/Documents/tmp/main+0x4dda60) in strcpyShadow bytes around the buggy address:  0x100065f6c320: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c330: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c340: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c350: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c360: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00 00 00 00=>0x100065f6c370: 00 00 00 00[f3]f3 f3 f3 00 00 00 00 00 00 00 00  0x100065f6c380: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c390: f1 f1 f1 f1 00 00 00 00 f2 f2 f2 f2 f8 f3 f3 f3  0x100065f6c3a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c3b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  0x100065f6c3c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00Shadow byte legend (one shadow byte represents 8 application bytes):  Addressable:           00  Partially addressable: 01 02 03 04 05 06 07   Heap left redzone:       fa  Freed heap region:       fd  Stack left redzone:      f1  Stack mid redzone:       f2  Stack right redzone:     f3  Stack after return:      f5  Stack use after scope:   f8  Global redzone:          f9  Global init order:       f6  Poisoned by user:        f7  Container overflow:      fc  Array cookie:            ac  Intra object redzone:    bb  ASan internal:           fe  Left alloca redzone:     ca  Right alloca redzone:    cb  Shadow gap:              cc==29562==ABORTINGMS: 1 InsertRepeatedBytes-; base unit: adc83b19e793491b1c6ea0fd8b46cd9f32e592fc0xa,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,\x0a\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xffartifact_prefix='./'; Test unit written to ./crash-76387a0aaeb6a1d2b6b6f095ab49c927c00243e5-->

Сборка и её особенности на разных платформах


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


Платформа


Вопрос о выборе платформы для фаззинга упирается в работоспособность инструментов на каждой из них. На данный момент whitebox-тестирование намного лучше показывает себя на unix-системах.


Платформа* LibFuzzer AFL ASAN/UBSAN/TSAN
Windows -
Linux + + +
OSX + + +

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


Компилятор


Думая о том, каким компилятором собирать фаззеры, мы всегда вспоминаем, что libfuzzer это подпроект LLVM, а в AFL предусмотрен llvm_mode, про который в его readme написано:


Настоящая инструментация на этапе компиляции, а не просто грубые изменения на уровне ассемблера.

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


Система сборки


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


# Step 1add_custom_target(${FUZZ_TARGET_NAME})# Step 2function(add_fuzzer name path)    add_executable(${name} ${SRC} ${path})    target_compile_options(...)    set_target_properties(...)    add_dependencies(${FUZZ_TARGET_NAME} ${name})# Step 3set(fuzzers my_ideal_fuzzer1 my_ideal_fuzzer2 ...)foreach(fuzzer ${fuzzers})    add_fuzzer(${fuzzer} ${FUZZERS_DIR})endforeach()

Кратко интеграцию фаззеров в сборку проекта на cmake можно описать в трех шагах на примере нашего недавнего проекта:


  • Создаём цель ${FUZZ_TARGET_NAME}, к которой в зависимости будем добавлять фаззеры.
  • Пишем простую функцию, которая принимает на вход название фаззера и путь к его исходному коду. Реализация этой функции сводится к нескольким вызовам других cmake-функций. Сначала необходимо указать список исходников для сборки компонента который мы будем фаззить. Он хранится в переменной ${SRC}. Потом идут два вызова для установки опций компилятора и компоновщика. Последним вызовом добавляем новый исполняемый файл в качестве зависимости к цели, созданной ранее.
  • Остаётся дописать простой цикл который поочередно добавит все фаззеры к созданной ранее цели.

Другой пример система сборки autotools. Она является достаточно старой и не такой удобной, как cmake. Однако это не помешает внедрению фаззинга в проект. Например, здесь показан пример интеграции фаззинга в WebRTC сервер janus-gateway. Сам скрипт занимает где-то 100 строчек на bash. Конечно, это не так много, однако, как только проект начнёт меняться, эти скрипты тоже необходимо будет поддерживать в актуальном состоянии. На это может потребоваться дополнительное время.


Фаззинг в облаке


Наконец, мы добрались до финальной главы непосредственно про организацию continuous fuzzing. Сначала мы посмотрим, как с этой задачей справились в Google, а затем что получилось у нас.


Фаззинг-ферма от Google


В 2012 году компания Google анонсировала ClusterFuzz облачную инфраструктуру для поиска уязвимостей в компонентах своего браузера. В его описании приведена схема, наглядно показывающая, как должен выглядеть continuous fuzzing, интегрированный в процесс разработки.



Схема сontinious fuzzing от google


Пробежимся по элементам схемы:


  • Developer команда разработчиков проекта
  • Upstream project репозиторий с проектом
  • Oss-fuzz вспомогательный репозиторий для интеграции continuous fuzzing. Хранит build-конфиги фаззеров
  • Builder Сборщик фаззеров. Его задача скачивать исходники фаззеров из репозитория с проектом, подхватывать build-конфиги из oss-fuzz и производить сборку фаззеров. После сборки builder закачивает в хранилку (GCS bucket) файлы, необходимые для запуска фаззера
  • Clusterfuzz масштабируемая система для управления процессом фаззинга. Отвечает за планирование запусков фаззеров, обработку полученных от них данных, сбор статистики и многое другое. Пополняет свою коллекцию фаззеров из хранилки.
  • Issue tracker система отслеживания задач: youtrack, jira и подобные им программы. Сюда приходят отчёты о найденных уязвимостях

А теперь рассмотрим сам процесс интеграции. Начнём с того, что у команды разработчиков должен иметься репозиторий с кодом проекта. Здесь же должны храниться исходные коды фаззеров. Для интеграции необходимо подготовить build-конфиги для сборки и запуска фаззеров в инфраструктуре Google. Готовые build-конфиги нужно добавить в репозиторий oss-fuzz через пулл-реквест. После принятия пулл-реквеста можно радоваться: фаззеры будут автоматически собраны и запущены в Clusterfuzz. С каждым новым коммитом в oss-fuzz будут подхватываться фаззеры из репозитория с проектом. Если вдруг нашлась уязвимость, то уведомление придёт к разработчикам в issue tracker. Разработчики своевременно исправляют баги и все остаются довольными.


Почему решение от Google подходит не всем?


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


Это означает, что:


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

Поэтому мы сделали свою фаззинг-ферму. Собственное решение для continuous fuzzing позволяет нам:


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

Схема нашей фермы похожа на схему от Google, однако она проще. Основная логика сосредоточена в компоненте FuzzFarm. Он выполняет те же задачи, что и Clusterfuzz. Ферма использует только наши внутренние ресурсы и может работать без доступа к интернету.



Схема работы нашей фаззинг-фермы


Интерфейс пользователя мы сделали простым и минималистичным. При этом он покрывает все необходимые задачи в continuous fuzzing.



Интерфейс пользователя нашей фаззинг-фермы


Что получаем в итоге?


В итоге мы имеем следующие преимущества использования continuous fuzzing:


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

Заключение


Тема фаззинга на сегодняшний день уже вышла далеко за пределы просто одного из видов тестирования. Это целое направление, объединившее единомышленников в коммьюнити. Уже проходят целые конференции и митапы, посвященные только фаззингу. Это большое и разноплановое направление со множеством перспектив развития, к примеру, Structure-Aware фаззинг, о котором мы постараемся рассказать позже. Поскольку, как вы могли заметить, развитие как отдельных инструментов, так и в целом направления не стоит на месте, существует возможность улучшать показатели по всем параметрам: скорости, качества, трудозатратам и т.п.


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


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


  • Дмитрий Евдокимов d1g1
  • Никита Кныжов presler

Соавтор статьи: Павел Князев poulix


Материалы


Подробнее..

Почему обзоры кода это хорошо, но недостаточно

23.09.2020 18:06:49 | Автор: admin
image1.png

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

Попробуйте найти ошибку в коде функции, взятой из библиотеки structopt:

static inline bool is_valid_number(const std::string &input) {  if (is_binary_notation(input) ||      is_hex_notation(input) ||      is_octal_notation(input)) {    return true;  }  if (input.empty()) {    return false;  }  std::size_t i = 0, j = input.length() - 1;  // Handling whitespaces  while (i < input.length() && input[i] == ' ')    i++;  while (input[j] == ' ')    j--;  if (i > j)    return false;  // if string is of length 1 and the only  // character is not a digit  if (i == j && !(input[i] >= '0' && input[i] <= '9'))    return false;  // If the 1st char is not '+', '-', '.' or digit  if (input[i] != '.' && input[i] != '+' && input[i] != '-' &&      !(input[i] >= '0' && input[i] <= '9'))    return false;  // To check if a '.' or 'e' is found in given  // string. We use this flag to make sure that  // either of them appear only once.  bool dot_or_exp = false;  for (; i <= j; i++) {    // If any of the char does not belong to    // {digit, +, -, ., e}    if (input[i] != 'e' && input[i] != '.' &&        input[i] != '+' && input[i] != '-' &&        !(input[i] >= '0' && input[i] <= '9'))      return false;    if (input[i] == '.') {      // checks if the char 'e' has already      // occurred before '.' If yes, return false;.      if (dot_or_exp == true)        return false;      // If '.' is the last character.      if (i + 1 > input.length())        return false;      // if '.' is not followed by a digit.      if (!(input[i + 1] >= '0' && input[i + 1] <= '9'))        return false;    }    else if (input[i] == 'e') {      // set dot_or_exp = 1 when e is encountered.      dot_or_exp = true;      // if there is no digit before 'e'.      if (!(input[i - 1] >= '0' && input[i - 1] <= '9'))        return false;      // If 'e' is the last Character      if (i + 1 > input.length())        return false;      // if e is not followed either by      // '+', '-' or a digit      if (input[i + 1] != '+' && input[i + 1] != '-' &&          (input[i + 1] >= '0' && input[i] <= '9'))        return false;    }  }  /* If the string skips all above cases, then  it is numeric*/  return true;}

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

image2.png

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

В таких ситуациях статический анализатор кода отлично дополнит классический обзор кода. Анализатор не устаёт и дотошно проверит весь код. Как результат, в этой функции анализатор PVS-Studio замечает аномалию и выдаёт предупреждение:

V560 A part of conditional expression is always false: input[i] <= '9'. structopt.hpp 1870

Для тех, кто не заметил ошибку, дам пояснения. Самое главное:

else if (input[i] == 'e') {  ....  if (input[i + 1] != '+' && input[i + 1] != '-' &&      (input[i + 1] >= '0' && input[i] <= '9'))      return false;}

Вышестоящее условие проверяет, что i-тый элемент является буквой 'e'. Соответственно следующая проверка input[i] <= '9' не имеет смысла. Результат второй проверки всегда false, о чём и предупреждает инструмент статического анализа. Причина ошибки проста: человек поспешил и опечатался, забыв написать +1.

Фактически получается, что функция не до конца выполняет свою работу по проверке корректности введённых чисел. Правильный вариант:

else if (input[i] == 'e') {  ....  if (input[i + 1] != '+' && input[i + 1] != '-' &&      (input[i + 1] >= '0' && input[i + 1] <= '9'))      return false;}

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


Если вам понравится статья про эффект последней строки, то рекомендую познакомиться с другими аналогичными наблюдениями: 0-1-2, memset, сравнения.

Всем пока. Ставлю лайк тем, кто самостоятельно нашёл ошибку.
Подробнее..

Комитет ISO утвердил стандарт C20

08.09.2020 16:22:26 | Автор: admin


На днях комитет ISO по стандартизации языка С++ (да, есть и такой) утвердил международный стандарт С++20. Возможности, которые представлены в спецификации, поддерживаются в компиляторах GCC, Clang и Microsoft Visual C++. Кроме того, стандартные библиотеки с поддержкой С++20 реализованы в рамках проекта Boost.

Следующий этап подготовка документа к публикации. Затем, в начале ноября, финальный вариант будет направлен в ISO, после чего он будет опубликован под формальным названием ISO/IEC 14882:2020. Сейчас комитет уже работает над следующим стандартом C++23 (C++2b). Под катом особенности С++20 с примерами кода.

Что нового?


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

 template<typename T>   concept EqualityComparable = requires(T a, T b) {       { a == b } -> std::boolean;       { a != b } -> std::boolean;   };

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

Добавлен макрос __VA_OPT__ для адаптивного раскрытия вариативных макросов в зависимости от наличия токенов в вариативном аргументе.

Добавлена поддержка оператора <=> для трехстороннего сравнения.

Поддерживаются инициализаторы элементов по умолчанию для битовых полей.

Появилась возможность лямбда-захвата выражений *this.

   struct int_value {     int n = 0;     auto getter_fn() {       // BAD:       // return [=]() { return n; };        // GOOD:       return [=, *this]() { return n; };     }   }; 

Для классов теперь можно использовать параметры шаблона без типа.

   struct foo {     foo() = default;     constexpr foo(int) {}   };    template <foo f>   auto get_foo() {     return f;    }    get_foo(); // uses implicit constructor   get_foo<foo{123}>();

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

struct A {     int x;     int y;     int z = 123;   };    A a {.x = 1, .z = 2}; // a.x == 1, a.y == 0, a.z == 2

Поддерживаются пустые члены структур данных.

Поддерживаются атрибуты likely и unlikely для информирования оптимизатора о вероятности срабатывания условной конструкции ("[[likely]] if (random > 0) {").

Появилась возможность использования диапазонов для инициализации значений переменной в цикле for

   for (auto v = std::vector{1, 2, 3}; auto& e : v) {

Поддерживаются immediate функции, которые могут работать только с константами.

 consteval int sqr(int n) {     return n * n;   }    constexpr int r = sqr(100); // OK   int x = 100;   int r2 = sqr(x); // ERROR:  'x' не может использоваться как константа

В библиотеку добавлены:
  • поддержка типа char8_t для строк UTF-8.
  • заголовочные файлы bit (битовые операции) и version.
  • возможность проверки префикса и суффикса строк (starts_with, ends_with).
  • типажи std::remove_cvref, std::unwrap_reference, std::unwrap_decay_ref, std::is_nothrow_convertible и std::type_identity.
  • функции std::midpoint, std::lerp, std::bind_front, std::source_location, std::visit, std::is_constant_evaluated и std::assume_aligned.
  • поддержка массивов в std::make_shared.
  • функция std::to_array для преобразования похожих на массив объектов в std::array.

Синтаксис перечислений теперь более удобен:
   enum class rgba_color_channel { red, green, blue, alpha };    std::string_view to_string(rgba_color_channel my_channel) {     switch (my_channel) {       using enum rgba_color_channel;       case red:   return "red";       case green: return "green";       case blue:  return "blue";       case alpha: return "alpha";    }   }

В индексах запрещено использовать операции "," (a[b,c]). Большинство операций с переменными, объявленными с ключевым словом violate, включая запрещенные операции "++" и "--" со стандартными типами, не поддерживаются.

Подробнее..

Разработка python module, чтобы продакшн радовал

10.09.2020 16:11:14 | Автор: admin
Всем привет! Я представляю команду разработчиков некоммерческой организации CyberDuckNinja. Мы создаём и поддерживаем целое семейство продуктов, которые позволяют облегчить разработку backend-приложений и сервисов машинного обучения.
Сегодня хотелось бы затронуть тему интеграции Python в C++.



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

Сразу стало понятно, что все модули данного пакета спроектированы отлично, но большая часть времени уходит на сетевые взаимодействия, парсинг json и другие промежуточные действия.
При подробном изучении ipyparallel выяснилось, что вся библиотека состоит из двух взаимодействующих модулей:
  • Ipcontroler, который отвечает за контроль и планирование задач,
  • Engine, который является исполнителем кода.

Приятной особенностью оказалось, что эти модули взаимодействуют через pyzmq. Благодаря хорошей архитектуре engine удалось заменить реализацию сетевого взаимодействия на наше решение, построенное на cppzmq. Эта замена открывает бесконечный простор для разработки: ответную часть можно написать в C++ части приложения.

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

Нам нужны были какие-то разумные критерии, чтобы понять, какой выбрать подход: лёгкость разработки, декларирование API только внутри C++, отсутствие дополнительных обёрток внутри Python или нативность использования всей мощности библиотек. А чтобы не запутаться в нативных (и не очень) способах протаскивания С++ кода в Python, мы сделали небольшой ресёрч. На момент начала 2019 года в интернете можно было найти четыре популярных способа расширения Python:
  1. Ctypes.
  2. CFFI.
  3. Cython.
  4. CPython API.

Мы рассмотрели все варианты интеграции.

1. Ctypes


Ctypes это Foreign Function Interface, позволяющий загружать динамические библиотеки, которые экспортируют интерфейс на языке Cи. С его помощью можно пользоваться из Python библиотеками на Cи, например, libev, libpq.
Например, есть библиотека написанная на языке C++, имеющая интерфейс:
extern "C"{    Foo* Foo_new();    void Foo_bar(Foo* foo);}

Пишем к нему обёртку:
import ctypeslib = ctypes.cdll.LoadLibrary('./libfoo.so')class Foo:    def __init__(self) -> None:        super().__init__()        lib.Foo_new.argtypes = []        lib.Foo_new.restype = ctypes.c_void_p        lib.Foo_bar.argtypes = []        lib.Foo_bar.restype = ctypes.c_void_p        self.obj = lib.Foo_new()    def bar(self) -> None:        lib.Foo_bar(self.obj)

Делаем выводы:
  1. Невозможность взаимодействия с API интерпретатора. Ctypes является способом взаимодействия с Cи библиотеками на стороне Python, но не предоставляет способ взаимодействия C/C++ кода с Python.
  2. Экспортирование интерфейса в стиле Cи. Сtypes может взаимодействовать с ABI библиотеками этом в стиле, но любой другой язык должен экспортировать свои переменные, функции, методы через Cи-обёртку.
  3. Необходимость написание обёрток. Их приходится писать как на стороне C++ кода для совместимости ABI с Си, так и на стороне Python, чтобы уменьшить количество boilerplate кода.

Сtypes нам не подходит, пробуем следующий способ CFFI.

2. CFFI


CFFI аналогичен Ctypes, но имеет некоторые дополнительные возможности. Продемонстрируем пример с той же библиотекой:
import cffiffi = cffi.FFI()ffi.cdef("""    Foo* Foo_new();    void Foo_bar(Foo* foo);""")lib = ffi.dlopen("./libfoo.so")class Foo:    def __init__(self) -> None:        super().__init__()        self.obj = lib.Foo_new()    def bar(self) -> None:        lib.Foo_bar(self.obj)

Делаем выводы:
У CFFI всё те же минусы, за исключением того, что обёртки становятся немного жирнее, так как требуется указать библиотеке определение её интерфейса. CFFI тоже не подходит, перейдём к следующему способу Cython.

3. Cython


Cython это саб/мета язык программирования, позволяющий писать расширения на смеси C/C++ и Python и загружать результат в виде динамической библиотеки. На этот раз есть библиотека, написанная на языке C++ и имеющая интерфейс:
#ifndef RECTANGLE_H#define RECTANGLE_Hnamespace shapes {    class Rectangle {        public:            int x0, y0, x1, y1;            Rectangle();            Rectangle(int x0, int y0, int x1, int y1);            ~Rectangle();            int getArea();            void getSize(int* width, int* height);            void move(int dx, int dy);    };}#endif

Тогда определяем этот интерфейс на языке Cython:
cdef extern from "Rectangle.cpp":    pass# Declare the class with cdefcdef extern from "Rectangle.h" namespace "shapes":    cdef cppclass Rectangle:        Rectangle() except +        Rectangle(int, int, int, int) except +        int x0, y0, x1, y1        int getArea()        void getSize(int* width, int* height)        void move(int, int)

И пишем к нему обёртку:
# distutils: language = c++from Rectangle cimport Rectanglecdef class PyRectangle:    cdef Rectangle c_rect    def __cinit__(self, int x0, int y0, int x1, int y1):        self.c_rect = Rectangle(x0, y0, x1, y1)    def get_area(self):        return self.c_rect.getArea()    def get_size(self):        cdef int width, height        self.c_rect.getSize(&width, &height)        return width, height    def move(self, dx, dy):        self.c_rect.move(dx, dy)    # Attribute access    @property    def x0(self):        return self.c_rect.x0    @x0.setter    def x0(self, x0):        self.c_rect.x0 = x0    # Attribute access    @property    def x1(self):        return self.c_rect.x1    @x1.setter    def x1(self, x1):        self.c_rect.x1 = x1    # Attribute access    @property    def y0(self):        return self.c_rect.y0    @y0.setter    def y0(self, y0):        self.c_rect.y0 = y0    # Attribute access    @property    def y1(self):        return self.c_rect.y1    @y1.setter    def y1(self, y1):        self.c_rect.y1 = y1

Теперь можем использовать этот класс из обычного Python-кода:
import rectx0, y0, x1, y1 = 1, 2, 3, 4rect_obj = rect.PyRectangle(x0, y0, x1, y1)print(dir(rect_obj))

Делаем выводы:
  1. При использовании Cython всё также приходится писать обёрточный код на стороне C++, но уже не нужно экспортировать интерфейс в стиле Cи.
  2. По-прежнему нельзя взаимодействовать с интерпретатором.

Остаётся последний способ CPython API. Пробуем его.

4. CPython API


CPython API API, которое позволяет разрабатывать модули для интерпретатора Python на C++. Лучше всего использовать pybind11, высокоуровневую библиотеку на С++, которая делает работу с CPython API удобной. С её помощью можно легко экспортировать функции, классы, преобразовать данные между памятью python и нативной памятью в С++.

Итак, возьмём код из предыдущего примера и напишем к нему обёртку:
PYBIND11_MODULE(rect, m) {    py::class_<Rectangle>(m, "PyRectangle")        .def(py::init<>())        .def(py::init<int, int, int, int>())        .def("getArea", &Rectangle::getArea)        .def("getSize", [](Rectangle &rect) -> std::tuple<int, int> {            int width, height;            rect.getSize(&width, &height);            return std::make_tuple(width, height);        })        .def("move", &Rectangle::move)        .def_readwrite("x0", &Rectangle::x0)        .def_readwrite("x1", &Rectangle::x1)        .def_readwrite("y0", &Rectangle::y0)        .def_readwrite("y1", &Rectangle::y1);}

Обёртку написали, теперь ее надо собрать в бинарную библиотеку. Нам потребуются две вещи: система сборки и пакетный менеджер. Возьмём для этих целей CMake и Conan соответственно.

Чтобы сборка на Conan заработала, надо установить сам Conan подходящих способом:
pip3 install conan cmake

и прописать дополнительные репозитории:
conan remote add bincrafters https://api.bintray.com/conan/bincrafters/public-conanconan remote add cyberduckninja https://api.bintray.com/conan/cyberduckninja/conan

Опишем в файле conanfile.txt зависимости проекта на библиотеку pybind:
[requires]pybind11/2.3.0@conan/stable[generators]cmake

Добавим файл CMake. Обратите внимание на включенную интеграцию с Conan при выполнении CMake будет запущена команда conan install, устанавливающая зависимости и формирующая переменные CMake с данными о зависимостях:
cmake_minimum_required(VERSION 3.17)set(project rectangle)set(CMAKE_CXX_STANDARD 17)set(CMAKE_CXX_STANDARD_REQUIRED YES)set(CMAKE_CXX_EXTENSIONS OFF)if (NOT EXISTS "${CMAKE_BINARY_DIR}/conan.cmake")    message(STATUS "Downloading conan.cmake from https://github.com/conan-io/cmake-conan")    file(DOWNLOAD "https://raw.githubusercontent.com/conan-io/cmake-conan/v0.15/conan.cmake" "${CMAKE_BINARY_DIR}/conan.cmake")endif ()set(CONAN_SYSTEM_INCLUDES "On")include(${CMAKE_BINARY_DIR}/conan.cmake)conan_cmake_run(        CONANFILE conanfile.txt        BASIC_SETUP        BUILD missing        NO_OUTPUT_DIRS)find_package(Python3 COMPONENTS Interpreter Development)include_directories(${PYTHON_INCLUDE_DIRS})include_directories(${Python3_INCLUDE_DIRS})find_package(pybind11 REQUIRED)pybind11_add_module(${PROJECT_NAME} main.cpp )target_include_directories(    ${PROJECT_NAME}    PRIVATE    ${NUMPY_ROOT}/include    ${PROJECT_SOURCE_DIR}/vendor/General_NetSDK_Eng_Linux64_IS_V3.051    ${PROJECT_SOURCE_DIR}/vendor/ffmpeg4.2.1)target_link_libraries(    ${PROJECT_NAME}    PRIVATE    ${CONAN_LIBS})

Все приготовления выполнены, давайте собирать:
cmake . -DCMAKE_BUILD_TYPE=Release cmake --build . --parallel 2

Делаем выводы:
  1. Мы получили собранную бинарную библиотеку, которую можно впоследствии загрузить в интепретатор Python его средствами.
  2. Стало гораздо проще экспортировать код в Python по сравнению со способами выше, а обёрточный код стал компактнее и пишется на том же языке.

Одна из возможностей cpython/pybind11 это загрузка, получение или выполнение функции из runtime python, находясь в рантайме С++ и наоборот
Давайте посмотрим на простом примере:
#include <pybind11/embed.h>  // подключаем  работу с интерпретаторомnamespace py = pybind11;int main() {    py::scoped_interpreter guard{}; // инициализируем python vm    py::print("Hello, World!"); // печатаем  на консоль Hello, World!}

Скомбинировав возможность встраивать интерпретатор python в приложение на С++ и механизм Python модулей, мы придумали интересный подход, при помощи которого код ipyparalles engine не чувствует подмену компонентов. Для приложений мы выбрали архитектуру, в которой жизненные и событийные циклы начинаются в коде на C++, а уже потом стартует интерпретатор Python в рамках того же процесса.

Для понимания давайте разберём, как работает наш подход:
#include <pybind11/embed.h>#include "pyrectangle.hpp" // подключаем С++ реализацию rectangleusing namespace py::literals;//  с помощью этого встроенного  скрипта  загружаем собранный модуль rectangleconstexpr static char init_script[] = R"__(    import sys    sys.modules['rect'] = rect)__";//  с помощью этого встроенного  скрипта  загружаем пользовательский  код rectangleconstexpr static char load_script[] = R"__(    import sys, os    from importlib import import_module    sys.path.insert(0, os.path.dirname(path))    module_name, _ = os.path.splitext(path)    import_module(os.path.basename(module_name)))__";int main() {    py::scoped_interpreter guard; //инициализируем интерпретатор     py::module pyrectangle("rect");  создаем модуль     add_pyrectangle(pyrectangle); //инстанируем расширение модуля    py::exec(init_script, py::globals(), py::dict("rect"_a = pyrectangle)); //делаем это расширение доступным для импорта из кода Python.    py::exec(load_script, py::globals(), py::dict("path"_a = "main.py")); //запускаем скрипт main.py    return 0;}

В приведенном выше примере модуль pyrectangle пробрасывается в интерпретатор Python и становится доступным для импорта под именем rect. Продемонстрируем на примере, что для пользовательского кода ничего не поменялось:
from pprint import pprintfrom rect import PyRectangler = PyRectangle(0, 3, 5, 8)pprint(r)assert r.getArea() == 25width, height = r.getSize()assert width == 5 and height == 5

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

Таким образом, ctypes и CFFI для нас не подходят из-за необходимости экспорта интерфейсов библиотеки в стиле Cи, а также из-за необходимости писать обёртки на стороне Python и, в конечном итоге, использования CPython API, если необходимо встраивание. Cython лишён недостатка с экспортом, но сохраняет все остальные недостатки. Pybind11 поддерживает возможность встраивания и написания обёрток только на стороне С++. Также он имеет широкие возможности для управления структурами данных и вызова функций и методов Python. В итоге мы остановились на pybind11 как на высокоуровневой обертке на C++ для CPython API.

Скомбинировав применение embed python внутри C++ приложения с механизмом модулей для быстрых пробросов данных и переиспользовав кодовую базу ipyparallel engine, мы получили rocketjoe_engine. Он идентичен по механикам с оригиналом и работает шустрее за счет уменьшения кастов на сетевые взаимодействия, обработку json и другие промежуточные действия. Теперь это позволяет держать нагрузки на продакшене у моего друга, за что я и получил первую звездочку в проекте GitHub.

Если вы заинтересовались пакетным менеджером Conan, то узнать о нем больше можно на предстоящей конференции Moscow Python Week в докладе про пакетирование проектов на C++, а также про особенности разработки на Python и самого пакетного менеджера Conan вместе с его инфраструктурой.

Moscow Python Week стартует уже через 4 дня она будет с 14 по 17 сентября. Программа готова, и ещё на конференции пройдёт первый Чемпионат России по Python: можно проверить уровень своего мастерства и получить независимую оценку своих скиллов среди Python-разработчиков всей страны. Участие бесплатное, но надо знать стандартную библиотеку Python.
Билеты на саму конференцию здесь.
Подробнее..

Перевод Антипаттерн константа размера массива

11.09.2020 16:15:38 | Автор: admin
Перевод статьи подготовлен в преддверии старта курса C++ Developer. Professional.



Хочу обратить ваше внимание на антипаттерн, который я часто встречаю в коде студентов на Code Review StackExchange и даже в довольно большом количестве учебных материалов (!) других людей. У них имеется массив, скажем, из 5 элементов; а затем, поскольку магические числа это плохо, они вводят именованную константу для обозначения количества элементов 5.

void example(){    constexpr int myArraySize = 5;    int myArray[myArraySize] = {2, 7, 1, 8, 2};    ...


Но решение это так себе! В приведенном выше коде число пять повторяется: сначала в значении myArraySize = 5, а затем еще раз, когда вы фактически присваиваете элементы myArray. Приведенный выше код столь же ужасен с точки зрения обслуживания, как:

constexpr int messageLength = 45;const char message[messageLength] =    "Invalid input. Please enter a valid number.\n";


который, конечно, никто из нас никогда не напишет.

Код, который повторяется, хорошим не является


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

   constexpr int myArraySize = 5;-   int myArray[myArraySize] = {2, 7, 1, 8, 2};+   int myArray[myArraySize] = {3, 1, 4};


Патч выше выглядит так, как будто он изменяет содержимое массива с 2,7,1,8,2 на 3,1,4, но это не так! Фактически он меняет его на 3,1,4,0,0 с дополнением нулями потому что мейнтейнер забыл скорректировать myArraySize в соответствии с myArray.

Надежный подход


Что до подсчета, то компьютеры в этом чертовски хороши. Так пусть же считает компьютер!

int myArray[] = {2, 7, 1, 8, 2};constexpr int myArraySize = std::size(myArray);


Теперь вы можете изменить содержимое массива, скажем, с 2,7,1,8,2 на 3,1,4, изменив только одну строку кода. Дублировать изменение нигде не нужно.

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

for (int elt : myArray) {    use(elt);}std::sort(myArray.begin(), myArray.end());std::ranges::sort(myArray);// Warning: Unused variable 'myArraySize'


В плохой версии этого кода myArraySize всегда используется (в объявлении myArray), и поэтому программист вряд ли увидит, что ее можно исключить. В хорошей версии компилятору легко обнаружить, что myArraySize не используется.

Как это сделать с помощью std::array?


Иногда программист делает еще один шаг к Темной Стороне и пишет:

constexpr int myArraySize = 5;std::array<int, myArraySize> myArray = {2, 7, 1, 8, 2};


Это должно быть переписано по крайней мере как:

std::array<int, 5> myArray = {2, 7, 1, 8, 2};constexpr int myArraySize = myArray.size();  // или std::size(myArray)


Однако простого способа избавиться от ручного подсчета в первой строке нет. CTAD C++17 позволяет писать

std::array myArray = {2, 7, 1, 8, 2};


но это работает, только если вам нужен массив int это не сработает, если вам нужен массив short, например, или массив uint32_t.

C++20 дает нам std::to_array, что позволяет нам писать

auto myArray = std::to_array<int>({2, 7, 1, 8, 2});constexpr int myArraySize = myArray.size();


Обратите внимание, что это создает C-массив, а затем перемещает (move-constructs) его элементы в std::array. Все наши предыдущие примеры инициализировали myArray с помощью списка инициализаторов в фигурных скобках, который запускал агрегатную инициализацию и создавал элементы массива непосредственно на месте.

В любом случае, все эти варианты результируют в большом количестве дополнительных экземпляров шаблонов по сравнению со старыми добрыми C-массивами (которые не требуют создания экземпляров шаблонов). Поэтому я настоятельно предпочитаю T[] более новому std::array<T, N>.

В C++11 и C++14 у std::array было эргономическое преимущество, заключающееся в возможности сказать arr.size(); но это преимущество испарилось, когда C++17 предоставил нам std::size(arr) и для встроенных массивов. У std::array больше нет эргономических преимуществ. Используйте его, если вам нужна его семантика переменной целостного объекта (передать весь массив в функцию! Вернуть массив из функции! Присваивать массивы с помощью =! Сравнить массивы с помощью ==!), Но в противном случае я рекомендую избегать использование std::array.

Точно так же я рекомендую избегать std::list, если вам не нужна стабильность его итератора, быстрая склейка, сортировка без замены элементов и т. д. Я не говорю, что этим типам нет места в C++; Я просто говорю, что у них есть очень специфический сет скилов, и если вы не используете эти скилы, вы, скорее всего, переплачиваете понапрасну.


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


Читать ещё:


Подробнее..

Micro Property минималистичный сериализатор двоичных данных для embedded систем

12.09.2020 20:07:24 | Автор: admin
Micro Property библиотека для сериализации данных с минимальными накладными расходами. Она разработана для использования в микроконтроллерах и различных встраиваемых устройствах с ограничениями по размеру памяти, которым приходится работать по низкоскоростным линиям связи.

Конечно, я знаю про такие форматы как xml, json, bson, yaml, protobuf, Thrift, ASN.1. Даже нашел экзотический Tree, который сам является убийцей JSON, XML, YAML и иже с ними.

Так почему же они все не подошли? Зачем я был вынужден написать еще один сериализатор?

image


Исходные требования


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

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

Также, часть этих устройств подключены к общей линии связи CAN, которая и обеспечивает передачу данных в рамках всей системы в целом. Скорость передачи данных по линии связи Modbus до 115200 Бод, а скорость по шине CAN ограничена скоростью до 50кБод из-за её протяженности и присутствия серьезных индустриальных помех.

Устройства в подавляющем большинстве разработаны на микроконтроллерах серий STM32F1x и STM32F2x. Хотя часть из них работает и на STM32F4x. Ну и конечно, Windows/Linux на базе систем с x86 микропроцессорами в качестве контроллеров верхнего уровня.

Для оценки объема данных, которые обрабатываются и передаются между устройствами или хранятся в качестве настроек/параметров работы: В одном случае 2 числа по 1 байт и 6 чисел по 4 байта, в другом 11 чисел по 1 байту и 1 число 4 байта и т.д. Для справки, размер данных в стандартном кадре CAN до 8 байт, а во фрейме Modbus, до 252 байт полезных данных.

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

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

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

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

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

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

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


Передача двоичных данных с идентификацией полей:
Минусы:
  • Неизбежные накладные расходы для передачи имени и типа данных для каждого поля.

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



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

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

image

А какие есть варианты?


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

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

Основные форматы xml, json, yaml и другие текстовые варианты с очень удобным и простым формальным синтаксисом, который хорошо подходит для обработки документов и одновременно удобен для чтения и редактирования человеком, пришлось сразу отбросить. И как раз из-за своего удобства и простоты они имеют очень большие накладные расходы при хранении бинарных данных, которые как раз и требовалось обрабатывать.

Поэтому, в виду ограниченности ресурсов и низкоскоростных линий связи, было решено использовать бинарный формат представления данных. Но и в случае форматов, умеющих преобразовывать данные в бинарное представление, таких как Protocol Buffers, Flat Buffers, ASN.1 или Apache Thrift, накладные расходы при сериализации данных, а так же общее удобство их применения не способствовало к немедленному внедрению любой из подобных библиотек.

Лучше всего по совокупности параметров подходил формат BSON, который имеет минимальный оверхед. И я серьезно рассматривал возможность его применения. Но в результате, все же решил отказаться и от него, т.к. при прочих равных условиях даже у BSON будут неприемлемые накладные расходы.

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

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


Что получилось


В результате раздумий и нескольких экспериментов получился сериализатор со следующими особенностями и характеристиками:
  • Оверхед для данных фиксированного размера 1 байт (без учета длины имени поля данных).
  • Оверхед для данных переменного размера, таких как блоб, текстовая строка или массив 2 байта (так же без учета длины имени поля данных). Так как использовать данный формат предполагается в устройствах, работающих по протоколам CAN и Modbus, то для хранения размера можно ограничиться одним байтом.
  • Ограничения на размер имени поля 16 байт.
  • В качестве идентификатора поля используется текстовая строка с завершающим нулевым символом, которая обрабатывается как бинарные данные, т.е. без учета завершающего нуля. Вместо текстовой строки в качестве идентификаторов полей, можно использовать целые числа или произвольные бинарные данные размером до 16 байт.
  • Максимальный размер полей данных переменной длины (блоб, текстовая строка или массив) 252 байта (т.к. размеры полей хранятся в одном байте).
  • Общий размер сериализованных данных без ограничений.
  • При работе память не выделяется. Все действия происходят только с внешним буфером без внутреннего выделения и освобождения памяти.
  • Возможен режим работы только чтение, например для работы с настройками приложения, которые сохранены в программной памяти микроконтроллера. В том числе, корректно отрабатывается ситуация, когда данные размещены в очищенной флеш-памяти (заполненной 0xFF).
  • В режиме редактирования поддерживается только добавление новых данных до заполнения буфера. Возможность обновления полей штатным способом не реализована, потому что для изначальных задач подобный функционал не требовался. Хотя при необходимости есть возможность редактировать данные по указателю в буфере.
  • Ну а в случае крайней необходимости, можно будет попробовать добавить возможность обновления полей. Для этого даже оставлен в резерве один из типов.


Поддерживаемые типы данных:


  • Целые числа размером от 8 до 64 бит с преобразованием в сетевой порядок байт и обратно.
  • Логические значения и числа с плавающей запятой одинарной и двойной точности.
  • Двоичные данные переменной длины (блоб или массив байт).
  • Текстовые строки двоичные данные с завершающим нулевым символом в конце. При сериализации строк после данных записывается нулевой символ, чтобы потом было удобно с ними работать как с обычными строками, без необходимости копировать данные в промежуточный буфер или высчитывать количество символов в строке. Хотя есть возможность выстрелить себе в ногу и сохранить текстовую строку с нулевым символом в где нибудь в середине строки ;-)
  • Одномерные массивы для всех типов целых и вещественных чисел. При работе с массивами целых чисел, они автоматически преобразуются в сетевой порядок байт и обратно.


Хотелось бы отметить отдельно


Реализация сделана на С++ x11 в единственном заголовочном файле с использованием механизма шаблонов SFINAE (Substitution failure is not an error).

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

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

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

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

Реализация


Реализация находится тут: https://github.com/rsashka/microprop

Как пользоваться, написано в примерах с различной степенью подробности:

Быстрое использование
#include "microprop.h"Microprop prop(buffer, sizeof (buffer));// Создать сериализатор и назначить ему буферprop.FieldExist(string || integer); // Проверить наличие поля с указанным IDprop.FieldType(string || integer); // Получить тип данных поляprop.Append(string || integer, value); // Добавить данныеprop.Read(string || integer, value); // Прочитать данные



Медленное и вдумчивое использование
#include "microprop.h"Microprop prop(buffer, sizeof (buffer)); // Создать сериализаторprop.AssignBuffer(buffer, sizeof (buffer)); // Назначить буферprop.AssignBuffer((const)buffer, sizeof (buffer)); // Назначить read only буферprop.AssignBuffer(buffer, sizeof (buffer), true); // Тоже read only буферprop.FieldNext(ptr); // Получить указатель на следующее полеprop.FieldName(string || integer, size_t *length = nullptr); // Указатель на ID поляprop.FieldDataSize(string || integer); // Размер сериализованных данных// Дальше все прозрачноprop.Append(string || blob || integer, value || array);prop.Read(string || blob || integer, value || array);prop.Append(string || blob || integer, uint8_t *, size_t);prop.Read(string || blob || integer, uint8_t *, size_t);prop.AppendAsString(string || blob || integer, string);const char * ReadAsString(string || blob || integer);



Пример реализации с использованием enum в качестве идентификатора данных
class Property : public Microprop {public:    enum ID {    ID1, ID2, ID3  };  template <typename ... Types>  inline const uint8_t * FieldExist(ID id, Types ... arg) {    return Microprop::FieldExist((uint8_t) id, arg...);  }  template <typename ... Types>  inline size_t Append(ID id, Types ... arg) {    return Microprop::Append((uint8_t) id, arg...);  }  template <typename T>  inline size_t Read(ID id, T & val) {    return Microprop::Read((uint8_t) id, val);  }  inline size_t Read(ID id, uint8_t *data, size_t size) {    return Microprop::Read((uint8_t) id, data, size);  }      template <typename ... Types>  inline size_t AppendAsString(ID id, Types ... arg) {    return Microprop::AppendAsString((uint8_t) id, arg...);  }  template <typename ... Types>  inline const char * ReadAsString(ID id, Types... arg) {    return Microprop::ReadAsString((uint8_t) id, arg...);  }};



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

Из песочницы Симуляция подъёмной силы Ньютона методом частиц на CUDA

14.09.2020 14:13:59 | Автор: admin

https://www.youtube.com/playlist?list=PLwr8DnSlIMg0KABru36pg4CvbfkhBofAi


Как-то на Хабре мне попалась довольно любопытная статья Научно-технические мифы, часть 1. Почему летают самолёты?. Статья довольно подробно описывает, какие проблемы возникают при попытке объяснить подъёмную силу крыльев через закон Бернулли или модель подъёмной силы Ньютона (Newtonian lift). И хотя статья предлагает другие объяснения, мне бы всё же хотелось остановиться на модели Ньютона подробнее. Да, модель Ньютона не полна и имеет допущения, но она даёт более точное и интуитивное описание явлений, чем закон Бернулли.


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


Я же решил проверить, что же произойдёт в модели Ньютона если её улучшить. Что если добавить в неё недостающий элемент межатомного взаимодействия? Исходный код и бинарники получившегося симулятора доступны на GitHub.


Computational Fluid Dynamics


Вообще, задачами точной симуляции поведений жидкостей и газов занимается вычислительная гидродинамика (Computational fluid dynamics, CFD), где жидкости и газы в общем случае хорошо описываются уравнениями Навье-Стокса.


Если вас пугает внешний вид этих уравнений, но вы хотели бы разобраться, то для вас есть очень хорошее объяснение в 7-м томе лекций Ричарда Фейнмана. Загляните в главы 38 (Упругость), 40 (Течение сухой воды) и 41 (Течение мокрой воды). Если совсем кратко и на пальцах, то система уравнений Навье-Стокса это векторное уравнение второго закона Ньютона. Эта система определяет равнодействующую всех сил (давления, вязкости и гравитации) для всех направлений. Дополнительно может быть задано второе векторное уравнение, обеспечивающее условие несжимаемости, если нужно описать жидкости.


Принципиально систему уравнений можно решать двумя подходами: методами Эйлера или Лагранжа. Эйлеров подход рассматривает среду как поля векторных и скалярных величин. Подход Лагранжа рассматривает отдельно каждую частицу среды.


Один из многих способов численно решить систему уравнений это применить Метод Конечных Элементов в связке с Адаптивным Сеточным Методом в случае подхода Эйлера. Зачем нужна адаптивность сетки и как её можно реализовать, подробно и доступно рассказали ребята из SpaceX в своём докладе. Эйлеров подход как правило, но не обязательно, применяется для моделирования замкнутых объемов неразрывных сред, т. е. тех, в которых отсутствуют пустые места и включения других сред. Для иных сред, чаще всего реализуют подход Лагранжа, через метод сглаженных частиц (Smoothed-particle hydrodynamics, SPH). Метод активно применяется для моделирования воды с полным набором явлений: брызги, капли, лужи, смачивание поверхностей и т. д. Можно даже сымитировать пену или пузыри, если включить в модель частицы воздуха. Реконструкцию поверхности, а точнее изоповерхности, можно произвести любым интересующим вас способом (screen-space meshes, dual contouring, marching tetrahedra, metaballs). Если вы знаете другие интересные подходы, добро пожаловать в комментарии.


Discrete Element Method


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


Метод дискретного элемента (Discrete Element Method, DEM) показался мне очень привлекательным, потому что он решает практически схожую задачу. Однако он в первую очередь предназначен для сыпучих и гранулированных сред, где у каждой частицы в трёхмерном пространстве есть позиция, ориентация, произвольная форма и масса.



Чтобы упростить вычисления, я решил оставить у частиц только две степени свободы (координаты X и Y), одинаковую массу и радиус. При построении своей модели в угоду производительности я хотел отбросить параметры и факторы, которые порождают эффекты второго порядка. Однако при моделировании сложных систем, они могут быть очень существенны. Один из показательных примеров это использование NASA модели идеального газа вместо реального при проектировании космических челноков. В результате, во время миссии STS-1 проявились различные аномалии при входе в атмосферу. Подробнее в разделе Mission Anomalies.


Тем не менее у DEM есть одна важная особенность это обнаружение столкновений постфактум (Discrete Collision Detection). Разрешение столкновений происходит простым силовым воздействием по закону Гука.


В противоположность этому подходу существует априорный метод Continuous Collision Detection (CCD), который рассчитывает, когда столкновение произойдёт в будущем. Зная точное время контакта, можно скорректировать временной шаг, и избежать неприятных физических артефактов. Метод активно применяется в современных играх. Для игр CCD очень важен чтобы объекты не туннелировали друг через друга, не проваливались друг в друга и не застревали в текстурах. Метод поддерживается современными движками, в Unity и в Unreal точно.



Подробный доклад о методе Continuous Collision Detection


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



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


Искомый эффект пушечного ядра


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


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

Ударная волна создаёт щит (изображение National Geographic)


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


Подробнее по ссылке с таймкодом https://youtu.be/cx8XbaQNnxw?t=2206


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


Картинки ниже кликабельны и доступны в высоком разрешении.



Температурная карта давления (скалярная сумма модулей сил)



Та же карта, только при большем масштабе



Температурная карта ускорений частиц


Архитектура симулятора


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


  1. Рендерер текущего состояния.
  2. Модуль симуляции на CPU или CUDA.

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


Фазовое пространство


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


Такое представление очень удобно, потому что практически любую симуляцию можно выразить в таком виде. Движение точки состояния как правило выражается через обыкновенное дифференциальное уравнение первого порядка (Ordinary Differential Equation, ODE). Это уравнение имеет вид $inline$dx/dt = f(x, t)$inline$, где $inline$x$inline$ это позиция точки в фазовом пространстве, а $inline$f$inline$ чёрный ящик, способный определить скорость изменения состояния. Зная $inline$x_0$inline$ и $inline$dx/dt$inline$, можно посчитать следующее значение $inline$x_1 = x_0 + \frac{dx}{dt}dt = x_0 + dx$inline$.


Вне зависимости от того, как работает f, симуляция это всего лишь процесс численного интегрирования.


Подробнее по теме фазового пространства можно ознакомиться в разделах 'Differential Equation Basics' and 'Particle Dynamics' курса https://www.cs.cmu.edu/~baraff/sigcourse/


На канале 3Blue1Brown также доступны отличные материалы:
https://www.youtube.com/playlist?list=PLZHQObOWTQDNPOjrT6KVlfJuKtYTftqH6


Интегратор


После различных экспериментов я решил остановиться на самом грубом, но в тоже время самом простом методе Эйлера (Forward Euler). Я пробовал использовать метод Рунге-Кутты 4-го порядка (RK4), в том числе и с адаптивным шагом, но для конкретно этого сценария больше подошёл метод Эйлера. Преимущество RK4 в том, что он позволяет делать огромные временные шаги ценой четырёхкратного увеличения вычислений, что в некоторых сценариях оправданно. В моём же случае оказалось, что я привязан к малым временным шагам, из-за необходимости избегать туннелирования частиц друг через друга. Кстати, как работают интеграторы с адаптивным временным шагом опираясь на ошибку, можно почитать в 'Differential Equation Basics' lecture notes, section 3, 'Adaptive Stepsizes'.


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


CPU-версия основной функции симулятора. GPU-версия имеет незначительные отличия.
float CSimulationCpu::ComputeMinDeltaTime(float requestedDt) const{    auto rad = m_state.particleRad;    auto velBegin = m_curOdeState.cbegin() + m_state.particles;    auto velEnd = m_curOdeState.cend();    return std::transform_reduce(std::execution::par_unseq, velBegin, velEnd, requestedDt, [](const auto t1, const auto t2)    {        return std::min(t1, t2);    }, [&](const auto& v)    {        auto vel = glm::length(v);        auto radDt = rad / vel;        return radDt;    });}float CSimulationCpu::Update(float dt){    dt = ComputeMinDeltaTime(dt);    m_odeSolver->NextState(m_curOdeState, dt, m_nextOdeState);    ColorParticles(dt);    m_nextOdeState.swap(m_curOdeState);    return dt;}

Вычисление производной состояния


Теперь перейдём к сердцу симулятора определению той самой функции f, упомянутой в параграфе Фазовое пространство. Ниже приведён высокоуровневый код солверов производной для CPU и CUDA версий. Стоит отметить, что CPU версия исторически появилась раньше, так как на ней было проще отладить математику. В CUDA версии появились некоторые улучшения и оптимизации, но суть осталась та же. Отличие состоит в переупорядочивании частиц. Подробнее в разделе Реордеринг частиц.


Высокоуровневый алгоритм расчёта производной состояния
//CPU-версия void CDerivativeSolver::Derive(const OdeState_t& curState, OdeState_t& outDerivative) {    ResetForces();    BuildParticlesTree(curState);    ResolveParticleParticleCollisions(curState);    ResolveParticleWingCollisions(curState);    ParticleToWall(curState);     ApplyGravity();    BuildDerivative(curState, outDerivative);} //CUDA-версия void CDerivativeSolver::Derive(const OdeState_t& curState, OdeState_t& outDerivative) {     BuildParticlesTree(curState);    ReorderParticles(curState);    ResetParticlesState();    ResolveParticleParticleCollisions();    ResolveParticleWingCollisions();    ParticleToWall();    ApplyGravity();    BuildDerivative(curState, outDerivative);}

Поиск столкновений между частицами


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


Один из возможных вариантов это использование Uniform Grid, то есть однородной сетки из ячеек на подобии шахматной доски. Одна из реализаций для GPU описана в статье Chapter 32. Broad-Phase Collision Detection with CUDA.



Каждая ячейка пространства содержит в себе список объектов (изображение Tero Karras, NVIDIA Corporation)


В этом случае, поиск столкновений в среднем будет занимать порядка $inline$O(1)$inline$. Каждой частице нужно обойти списки в 9 (3x3) или 27 (3x3x3) ячейках для 2D или 3D случая соответственно. Ещё один приятный плюс структуры это относительная простота распараллеливания её построения. Память под списки можно выделить либо заранее в виде массива, и вычислять выходной индекс через атомарный инкремент, либо строить классический RCU lock-free односвязный список. Nvidia в своих видеокартах уже давно добавила поддержку кучи, поэтому можно вызвать malloc()/free() прямо в device коде, выделяя и освобождая элементы списков.



CppCon 2017: Fedor Pikus Read, Copy, Update, then what? RCU for non-kernel programmers


Однако, у этой структуры есть следующий ряд фундаментальных ограничений:


  1. Множество значений координат ограничено размером самой сетки.
  2. Близкие ячейки в евклидовом пространстве как правило расположены далеко в адресном пространстве RAM/VRAM, не разделяя единую кэш-линию, что создаёт дополнительную нагрузку на шину памяти.
  3. При низкой плотности объектов или малом их количестве структура данных начинает потреблять больше памяти, чем сами данные.
  4. Возможно появление чрезмерно длинных списков при большой плотности объектов.
  5. В связи с аппаратными особенностями планирования потоков на GPU, некоторые lock-free структуры не способны работать корректно (https://youtu.be/86seb-iZCnI?t=2311, ссылка с таймкодом).

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


Первая важная особенность этой структуры данных в её основе лежит фрактальная Z-кривая, она же Кривая Мортона.



Фрактальная Z-кривая (изображение Wikipedia)



Принцип вычисления индекса на кривой чередование битов координат
(изображение Wikipedia)


Задача этой кривой, как и любой другой space-filling curve, состоит в том, чтобы упаковать пространства высших размерностей в одномерное пространство. Если присвоить каждому объекту в 2D/3D пространстве индекс на любой такой кривой, а затем отсортировать все объекты по этому индексу, то мы увидим, что объекты, расположенные близко в геометрическом пространстве, как правило будут лежать близко и в одномерном пространстве. Это свойство позволяет существенно снизить нагрузку на шину памяти. Кстати, если вам нужно обрабатывать изображения, выполняя различные свёрточные операции и применяя фильтры, возможно, вам стоит хранить пиксели в виде одной из такой кривых, а не в виде матрицы.


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


Детально алгоритм описан в статье Maximizing Parallelism in the Construction of BVHs, Octrees, and k-d Trees.
Краткое изложение:


  1. Обход дерева: https://developer.nvidia.com/blog/thinking-parallel-part-ii-tree-traversal-gpu/
  2. Построение дерева: https://developer.nvidia.com/blog/thinking-parallel-part-iii-tree-construction-gpu/

В сухом остатке, алгоритм следующий. Для каждого из N объектов запускается отдельный поток, который вычисляет bounding box и, на основе его центра, вычисляет код Мортона (индекс на Z-кривой). После этого этапа боксы сортируются в порядке возрастания кода.

Формирование кодов Мортона (изображение Tero Karras, NVIDIA Corporation)


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



Последовательный алгоритм построения префиксного дерева (изображение Tero Karras, NVIDIA Corporation)



Параллельный алгоритм построения префиксного дерева (изображение Tero Karras, NVIDIA Corporation)


После того, как связи между узлами выстроены, начинается этап формирования BVH-структуры.

BVH-структура (изображение Tero Karras, NVIDIA Corporation)


N потоков стартуют с листьев и, поднимаясь к корню, обновляют боксы промежуточных узлов. Так как не определено, какой из детей придёт к родителю первым, то в промежуточных узлах хранится специальный флаг, изначально установленный в ноль. Оба ребёнка с помощью атомарной функции atomicExch() устанавливают флаг в 1. Функция возвращает старое значение, которое было до модификации. Если ребёнку функция вернула 0, то значит он первый. Это также означает, что текущему потоку нельзя модифицировать бокс родителя, потому что бокс его сиблинга может быть ещё не готов. На этом этапе поток завершает своё исполнение. Если же ребёнку функция вернула 1, то можно смело модифицировать родительский бокс, объединяя боксы обоих сиблингов, и снова повторить процесс.


После этого этапа дерево готово к осуществлению запросов.


Реакция на столкновения


В симуляции существует два типа столкновений частица-частица и частица-сегмент профиля.


Реакция частица-частица использует факт того, все объекты уже сохранены в дереве, поэтому существует частная процедура рефлексивного обхода, когда листья ищут столкновения друг с другом. Эта оптимизация была предложена Tero Karras. Особенность процедуры в том, что она распознаёт столкновения A-B и B-A как одно и то же столкновение, поэтому оно детектируется только один раз. Для этого при построении дерева вводится дополнительная информация. В промежуточных узлах хранится индекс самого правого листа (rightmost leaf), до которого можно добраться. Например, на рисунке выше rightmost(N2) = 4, а rightmost(N3) = 8. Когда поток, связанный с листом, скажем, O6, будет опускаться от корня, он обратится к промежуточному узлу N2. Благодаря переменной rightmost, он увидит, что лист O6 недостижим из поддерева N2. В этом случае поток O6 должен проигнорировать всё поддерево N2. Однако, потоки, связанные с листьями из поддерева N2, будут проверять поддерево N3. В конечном итоге, если столкновение с O6 и существует, то об этом сообщит только один поток, и он будет из поддерева N2.


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


template<typename TDeviceCollisionResponseSolver, size_t kTreeStackSize>void CMortonTree::TraverseReflexive(const TDeviceCollisionResponseSolver& solver);

Для случая частица-сегмент профиля, используется универсальная версия:


template<typename TDeviceCollisionResponseSolver, size_t kTreeStackSize>void CMortonTree::Traverse(const thrust::device_vector<SBoundingBox>& objects, const TDeviceCollisionResponseSolver& solver);

Здесь TDeviceCollisionResponseSolver это объект, который должен реализовать следующий интерфейс:


struct Solver{    struct SDeviceSideSolver    {        ...         __device__ SDeviceSideSolver(...);        __device__ void OnPreTraversal(TIndex curId);        __device__ void OnCollisionDetected(TIndex leafId);        __device__ void OnPostTraversal();    };    Solver(...);    __device__ SDeviceSideSolver Create();}; 

Для каждого тестируемого на столкновение объекта, или листа в случае рефлексивного подхода, создаётся отдельный поток. Каждый поток создаёт свой солвер через фабричную функцию Create(). Далее вызывается метод OnPreTraversal, куда передаётся индекс тестируемого объекта. Если бокс текущего тестируемого объекта перекрыл бокс какого-то листа, вызывается функция OnCollisionDetected с индексом листа. Эта функция отвечает за расчёт физики. После обхода дерева вызывается OnPostTraversal.


Такой формат разрешения коллизий появился неслучайно. С самого начала я реализовал его по-другому. Я разделил обход дерева и вычисление физики на две различные стадии, как это сделал Tero Karras. Однако я столкнулся с проблемой построения списков найденных столкновений. Я попробовал сохранять информацию о коллизиях в виде матрицы NxO, где N количество тестируемых объектов, O максимальный размер списка. Но я отказался от этой идеи, потому что при определенных сценариях быстро заканчивалось место в списках. А это в свою очередь создавало различные физические артефакты. К тому же я обратил внимание, что профилировщик сигнализировал о неэффективной работе с памятью (coalesced memory access). Поэтому я решил попробовать подход без списков, который был описан выше. К моему удивлению, способ оказался немного быстрее и без артефактов.


Код солвера частица-частица
struct SParticleParticleCollisionSolver{    struct SDeviceSideSolver    {        CDerivativeSolver::SIntermediateSimState& simState;        TIndex curIdx;        float2 pos1;        float2 vel1;        float2 totalForce;        float totalPressure;        __device__ SDeviceSideSolver(CDerivativeSolver::SIntermediateSimState& state) : simState(state)        {        }        __device__ void OnPreTraversal(TIndex curLeafIdx)        {            curIdx = curLeafIdx;            pos1 = simState.pos[curLeafIdx];            vel1 = simState.vel[curLeafIdx];            totalForce = make_float2(0.0f);            totalPressure = 0.0f;        }        __device__ void OnCollisionDetected(TIndex anotherLeafIdx)        {            const auto pos2 = simState.pos[anotherLeafIdx];            const auto deltaPos = pos2 - pos1;            const auto distanceSq = dot(deltaPos, deltaPos);            if (distanceSq > simState.diameterSq || distanceSq < 1e-8f)                return;            const auto vel2 = simState.vel[anotherLeafIdx];            auto dist = sqrtf(distanceSq);            auto dir = deltaPos / dist;            auto springLen = simState.diameter - dist;            auto force = SpringDamper(dir, vel1, vel2, springLen);            auto pressure = length(force);            totalForce += force;            totalPressure += pressure;            atomicAdd(&simState.force[anotherLeafIdx].x, -force.x);            atomicAdd(&simState.force[anotherLeafIdx].y, -force.y);            atomicAdd(&simState.pressure[anotherLeafIdx], pressure);        }        __device__ void OnPostTraversal()        {            atomicAdd(&simState.force[curIdx].x, totalForce.x);            atomicAdd(&simState.force[curIdx].y, totalForce.y);            atomicAdd(&simState.pressure[curIdx], totalPressure);        }    };    CDerivativeSolver::SIntermediateSimState simState;    SParticleParticleCollisionSolver(const CDerivativeSolver::SIntermediateSimState& state) : simState(state)    {    }    __device__ SDeviceSideSolver Create()    {        return SDeviceSideSolver(simState);    }};void CDerivativeSolver::ResolveParticleParticleCollisions(){    m_particlesTree.TraverseReflexive<SParticleParticleCollisionSolver, 24>(SParticleParticleCollisionSolver(m_particles.GetSimState()));    CudaCheckError();}

Во время отладки я обратил внимание, что при высокой плотности частиц, функция OnCollistionDetected как правило вызывается для одних и тех же аргументов среди потоков одного варпа. Типовой сценарий был следующий: если в какой-то области пространства есть частицы A, B, C и D, которые в указанном порядке расположены на Z кривой, то приблизительно происходило вот что:


lock-step # Thread #1 Thread #2 Thread #3
1 OnCollisionDetected
A <-> C
OnCollisionDetected
B <-> C
OnCollisionDetected
C <-> D
2 OnCollisionDetected
A <-> D
OnCollisionDetected
B <-> D
INACTIVE
3 OnPostTraversal(1) OnPostTraversal(2) OnPostTraversal(3)

Как видно из таблицы, на шаге 1 и 2 потоки #1 и #2 выполняли атомарные обращения atomicAdd с одними и тем же частицам C и D в процессе работы функции OnCollistionDetected. Это создаёт дополнительную нагрузку на atomic транзакции.


Начиная с архитектуры Volta, Nvidia добавила в чипы поддержку новых warp-vote инструкций. С помощью инструкции match_any поток может опросить весь warp, получив битовую маску потоков, у которых значение запрашиваемой переменной имеет такое же значение.

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


Коммуникация внутри варпа тоже стала удобнее, потому что появились новые warp shuffle функции с поддержкой маски потоков.

Warp-wide редукция с помощью старых функций без маски


Благодаря этим функциям, потоки перед обращением в глобальную память могут сгруппироваться по признаку общего выходного адреса. Далее эта группа должна выполнить суммирование на уровне регистров SM и уже после этого только один поток обращается в глобальную память. К сожалению, на моём домашнем Pascal (1080 Ti) таких инструкций нет, поэтому я решил попробовать их проэмулировать. Увы, никакого прироста, как и замедления это не дало. Профилировка показала, что хоть нагрузка на atomic транзакции и упала в несколько раз, существенно возросла нагрузка на Arithmetic Workload и увеличилось количество регистров на поток. Заняться разработкой на чипах с Volta или Turing пока не представилось возможным. Хотя, мне всё же удалось протестировать симуляцию на RTX 2060 и найти баг связанной с atomic операцией. Об этом в разделе Барьер памяти.


Другие оптимизации и дополнения


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


SoA


Structure of Arrays одна из техник, которая позволяет ускорить доступ к памяти в определённых ситуациях.



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


typedef uint32_t TIndex; struct STreeNodeSoA {    const TIndex leafs;    int* __restrict__ atomicVisits;     TIndex* __restrict__ parents;     TIndex* __restrict__ lefts;     TIndex* __restrict__ rights;     TIndex* __restrict__ rightmosts;     SBoundingBox* __restrict__ boxes;     const uint32_t* __restrict__ sortedMortonCodes; };

Тоже самое касается и внутреннего состояния солвера производной состояния:


struct SIntermediateSimState {     const TIndex particles;     const float particleRad;     const float diameter;     const float diameterSq;     float2* __restrict__ pos;     float2* __restrict__ vel;     float2* __restrict__ force;     float* __restrict__ pressure; }; 

В тоже время, массив bounding boxов нет смысла хранить в SoA стиле, потому что во всех сценариях необходимы обе точки. Поэтому боксы хранятся в виде Array of Structures (AoS):


struct SBoundingBox {     float2 min;     float2 max; }; struct SBoundingBoxesAoS {     const size_t  count;     SBoundingBox* __restrict__ boxes; }; 

Реордеринг частиц


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


Такой паттерн доступа очень медленно работает на GPU. Для восстановления coalesced memory access, позиции и скорости частиц тоже упорядочиваются вдоль кривой. А после выполнения всех расчётов, силы и давления как выходные величины возвращаются к исходному порядку. Идея не нова и была позаимствована из уже упомянутого доклада SpaceX: https://youtu.be/vYA0f6R5KAI?t=1939 (ссылка с таймкодом).



Восстановление объединённого доступа к памяти (изображение SpaceX)


Такая оптимизация даёт 50% прироста производительности: с 8 FPS до 12 FPS для двух миллионов частиц.


Стек в Shared Memory


Оригинальная статья приводит пример реализации, где стек для обхода дерева реализуется в виде локального массива в скоупе функции. Проблема этого подхода в том, что задействуется локальная память потока область в глобальной памяти. А значит SM начинает выполнять долгие транзакции на чтение и запись, которые ко всему прочему могут оказаться ещё uncoalesced. Суть данной оптимизации, чтобы использовать сверхбыструю Shared Memory на кристалле самого Streaming Multiprocessorа.


Оригинальный код
__device__ void traverseIterative( CollisionList& list,                                   BVH& bvh,                                    AABB& queryAABB,                                    int queryObjectIdx){    // Allocate traversal stack from thread-local memory,    // and push NULL to indicate that there are no postponed nodes.    NodePtr stack[64]; //<---------------------------- Проблемное место    NodePtr* stackPtr = stack;    *stackPtr++ = NULL; // push    // Traverse nodes starting from the root.    NodePtr node = bvh.getRoot();    do    {        // Check each child node for overlap.        NodePtr childL = bvh.getLeftChild(node);        NodePtr childR = bvh.getRightChild(node);        bool overlapL = ( checkOverlap(queryAABB,                                        bvh.getAABB(childL)) );        bool overlapR = ( checkOverlap(queryAABB,                                        bvh.getAABB(childR)) );        // Query overlaps a leaf node => report collision.        if (overlapL && bvh.isLeaf(childL))            list.add(queryObjectIdx, bvh.getObjectIdx(childL));        if (overlapR && bvh.isLeaf(childR))            list.add(queryObjectIdx, bvh.getObjectIdx(childR));        // Query overlaps an internal node => traverse.        bool traverseL = (overlapL && !bvh.isLeaf(childL));        bool traverseR = (overlapR && !bvh.isLeaf(childR));        if (!traverseL && !traverseR)            node = *--stackPtr; // pop        else        {            node = (traverseL) ? childL : childR;            if (traverseL && traverseR)                *stackPtr++ = childR; // push        }    }    while (node != NULL);}

Стек в Shared Memory
template<typename TDeviceCollisionResponseSolver, size_t kTreeStackSize, size_t kWarpSize = 32>__global__ void TraverseMortonTreeKernel(const CMortonTree::STreeNodeSoA treeInfo, const SBoundingBoxesAoS externalObjects, TDeviceCollisionResponseSolver solver){    const auto threadId = blockIdx.x * blockDim.x + threadIdx.x;    if (threadId >= externalObjects.count)        return;    const auto objectBox = externalObjects.boxes[threadId];    const auto internalCount = treeInfo.leafs - 1;    __shared__ TIndex stack[kTreeStackSize][kWarpSize]; //Тот самый стек    unsigned top = 0;    stack[top][threadIdx.x] = 0;    auto deviceSideSolver = solver.Create();    deviceSideSolver.OnPreTraversal(threadId);    while (top < kTreeStackSize) //top == -1 also covered    {        auto cur = stack[top--][threadIdx.x];        if (!treeInfo.boxes[cur].Overlaps(objectBox))            continue;        if (cur < internalCount)        {            stack[++top][threadIdx.x] = treeInfo.lefts[cur];            if (top + 1 < kTreeStackSize)                stack[++top][threadIdx.x] = treeInfo.rights[cur];            else                printf("stack size exceeded\n");            continue;        }        deviceSideSolver.OnCollisionDetected(cur - internalCount);    }    deviceSideSolver.OnPostTraversal();}

Использование Shared Memory позволяет достичь прироста на 43%: с 14 FPS до 20 FPS. Подробнее о доступных типах памяти можно почитать в официальной документации:


https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#device-memory-accesses


Барьер памяти


Когда я разрабатывал симулятор, я пользовался исключительно одной видеокартой 1080 Ti поколения Pascal. Пользоваться другими в целях разработки, к сожалению, возможности не было. Но у меня была возможность просить трёх моих знакомых запустить приложение на их игровых ноутбуках с новой на тот момент 20-й серией чипов. Все три ноутбука выдавали изображения с вот такими артефактами.



Артефакт на 20-й RTX серии. Позиции и размер артефактов каждый шаг менялись.


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



Доклад об атомиках и барьерах памяти.


Половина доклада посвящена идеи барьеров памяти и почему они важны при работе с atomic-операциями и lock-free структурами. Дело в том, что процессоры имеют тенденцию переупорядочивать выполнение инструкций (Out-of-order execution), но при этом отслеживая и сохраняя зависимости между ними, чтобы гарантировать корректность. В случае с lock-free структурами для процессоров зависимость не очевидна. Поэтому, нужны барьеры памяти, которые подсказывают процессору, что инструкции не могут быть переупорядочены через барьер. Каждая платформа реализует барьеры по-своему, но, к счастью, разработчики стандарта C++ смогли построить наиболее общую модель. Подробное описание каждой семантики барьеров доступно в документации по std::memory_order.


__device__ void CMortonTree::STreeNodeSoA::BottomToTopInitialization(size_t leafId){    auto cur = leafs - 1 + leafId;    auto curBox = boxes[cur];    while (cur != 0)    {        auto parent = parents[cur];        //1. Опасная atomic операция        auto visited = atomicExch(&atomicVisits[parent], 1);        if (!visited)            return;        TIndex siblingIndex;        SBoundingBox siblingBox;        TIndex rightmostIndex;        TIndex rightmostChild;        auto leftParentChild = lefts[parent];        if (leftParentChild == cur)        {            auto rightParentChild = rights[parent];            siblingIndex = rightParentChild;            rightmostIndex = rightParentChild;        }        else        {            siblingIndex = leftParentChild;            rightmostIndex = cur;        }        siblingBox = boxes[siblingIndex];        rightmostChild = rightmosts[rightmostIndex];        SBoundingBox parentBox = SBoundingBox::ExpandBox(curBox, siblingBox);        boxes[parent] = parentBox;        rightmosts[parent] = rightmostChild;        cur = parent;        curBox = parentBox;        //2. Спасительный барьер памяти.         //Следующая итерация гарантированно увидит результаты всех записей         __threadfence();    }}

Моя ошибка была в том, что я не использовал никаких барьеров памяти в коде, который строит BVH дерево, но при этом активно использует атомарный флаг. Интересно, что оригинальная статья также не использует никаких барьеров. Скорее всего, помимо новой SIMT модели (разделы Volta SIMT Model и Starvation-Free Algorithms) Nvidia добавила в новые архитектуры начиная с Volta более агрессивную реализацию уже упомянутой Out-of-order execution. Как следствие, операции, которые должны были выполняться до atomicExch(), т.е. ещё на предыдущей итерации цикла, на Turing исполняются уже после. В результате такого агрессивного реордеринга инструкций, второй ребёнок, приходя к родителю думает, что его сиблинг уже вычислил и сохранил бокс в общую память, а на самом деле нет. В результате получается data race.


thrust::device_vector


Я слишком поздно заметил, что thurst::device_vector работает в синхронном режиме. Этот контейнер в своём конструкторе и в методе resize() выполняет полную синхронизацию с GPU через cudaDeviceSynchronize(). Видимо, разработчики руководствовались следующими рассуждениями. Раз вектор на видеокарте, то и конструкторы элементов нужно тоже вызывать на видеокарте. Но так как конструкторы могут кидать исключения, нужно дождаться их исполнения, чтобы словить все исключения. Единственный доступный способ для них полная синхронизация. Ещё одна из обнаруженных проблем редукция (reduce, sum, min, max) тоже синхронная, так как возвращает результат на хост. Библиотека Cuda UnBound (CUB) в этом плане куда продуманнее. Кстати, недавно она вошла в состав thrust как бэкенд, хотя раньше её нужно было скачивать отдельно.


Результаты профилировки


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



Картинка кликабельна, можно посмотреть в высоком разрешении


Заключение


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


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


Если вы хотели бы начать изучать CUDA, но не знаете, с чего начать, на Youtube есть отличный курс от Udacity Intro to Parallel Programming. Рекомендую к ознакомлению.
https://www.youtube.com/playlist?list=PLAwxTw4SYaPnFKojVQrmyOGFCqHTxfdv2


На последок, ещё несколько видео симуляций:



CPU-версия, 8 потоков, 131'072 частиц



CUDA-версия, 4.2М частиц, 30 минут симуляции

Подробнее..

C20. Coroutines

18.09.2020 02:05:20 | Автор: admin

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


image


Общие сведения


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


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


Мы будем рассматривать семантику функции в контексте двух операций.


Вызов (call). Передача управления вызываемой процедуре. Выполнение операции можно разделить на несколько этапов:


  1. Выделить доступную вызываемой процедуре область памяти кадр (activation record, activation frame), необходимого размера;
  2. Сохранить значения регистров процессора (локальные данные) для последующего их восстановления, когда управление вернётся из вызываемой процедуры;
  3. Поместить значения аргументов вызова в доступную для процедуры область памяти. В этой же памяти размещаются локальные переменные;
  4. Поместить адрес возврата адрес команды, следующей за командой вызова в доступную для процедуры область памяти.

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


Возврат из процедуры (return). Передача управления обратно вызывающей стороне. Выполнение этой операции так же состоит из несколько этапов:


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

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


Важно заметить следующее:


  1. Выделяемая, вызываемой процедуре, память имеет строго вложенную структуру и время жизни (strictly nested lifetime) относительно вызывающей стороны. Другими словами в каждый момент времени есть один активный кадр: кадр вызванной процедуры, после возврата управления, активным становится кадр вызывающей стороны.
  2. Размер кадр известен на стороне вызывающей процедуры.

Эти свойства позволяют использовать структуру, которая называется аппаратным стеком или просто стеком. Аппаратный стек это непрерывная область памяти аппаратно поддерживаемая центральным процессором и адресуемая специальными регистрами: ss (сегментный регистр стека), bp (регистр указателя базы стекового кадра), sp (регистр указателя стека), последний хранит адрес вершины стека (смешение относительно сегмента стека). Чтобы выделить память на стеке достаточно просто сместить указатель вершины стека (в сторону увеличения) на требуемый размер, чтобы освободить память нужно вернуть указатель в исходное положение.


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


Рассмотрим простой пример:


void bar(int a, int b){}void foo(){    int a = 1;    int b = 2;    bar(a, b);}int main(){    foo();}

Без каких-либо оптимизаций будет сгенерирован следующий код (x86-64 clang 10.0.0 -m32,
код сгенерирован в 32х битном окружение просто чтобы продемонстрировать работу стека, по соглашению о вызовах для 64х битных систем при передачи аргументов в функцию, в таком простом случае, стек участвовать не будет, аргументы будут переданы напрямую через регистры):


bar(int, int):        push    ebp        mov     ebp, esp        mov     eax, dword ptr [ebp + 12]        mov     ecx, dword ptr [ebp + 8]        pop     ebp        retfoo():        push    ebp         mov     ebp, esp        sub     esp, 24         mov     dword ptr [ebp - 4], 1        mov     dword ptr [ebp - 8], 2        mov     eax, dword ptr [ebp - 4]        mov     ecx, dword ptr [ebp - 8]        mov     dword ptr [esp], eax        mov     dword ptr [esp + 4], ecx        call    bar(int, int)        add     esp, 24        pop     ebp        retmain:        push    ebp        mov     ebp, esp        sub     esp, 8          call    foo()        xor     eax, eax        add     esp, 8        pop     ebp        ret

Проиллюстрируем работу стека:


Начало работы функции main, на стеке лежит адрес возврата из функции, пушем на стек значение регистра ebp (указатель базы стекового кадра) т.к. дальше значение регистра будет меняться на базу текущего стекфрейма, сохраняем в ebp значение регистра esp (адрес вершины стека)


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp, esp+----------------+

Выделяем необходимую память под локальные перемменные и аргументы для вызова функции foo. Локальных переменных и аргументов у нас нет. Но т.к. стек имеет выравнивание в 16 байт и на стеке уже лежит 8 байт (4 для адреса возврата и 4 для сохраненного ebp) выделяем дополнительные 8 байт, смещая указатель стека.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |     <-- esp-----------------+

Вызываем функцию foo. Команда call сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции foo пушем на стек значение регистра ebp (указатель базы стекового кадра) и сохраняем в ebp значение регистра esp (адрес вершины стека), инициализируем новый стекфрем.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp, esp+----------------+

Выделяем необходимую память под локальные переменные и аргументы для вызова функции bar. У нас две локальные переменные типа int это 8 байт, два аргумента для функции bar типа int это 8 байт. И т.к у нас уже есть на стеке 8 байт (адрес возврата и сохраненный ebp) нужно выделить еще 8 байт чтобы соблюсти требования к выравниванию. Таким образом всего выделяем 8 + 8 + 8 = 24 байта, смещая указатель стека.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| local a        |     <-- ebp - 4+----------------+| local b        |     <-- ebp - 8+----------------+| ...            || 8 byte padding || ...            |+----------------+| arg a          |     <-- esp + 4+----------------+| arg b          |     <-- esp+----------------+

Вызываем функцию bar. Все работает также как и при вызове функции foo. Команда call сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции bar пушем на стек значение регистра ebp и сохраняем в ebp значение регистра esp, инициализируем новый стекфрем.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| local a        |     <-- ebp - 4+----------------+| local b        |     <-- ebp - 8+----------------+| ...            || 8 byte padding || ...            |+----------------+| arg a          |     <-- ebp + 12+----------------+| arg b          |     <-- ebp + 8+----------------+| return address |+----------------+| saved rbp      |     <-- ebp, esp+----------------+

Функция bar ничего не делает. Восстанавливаем значение указателя базы предыдущего стекового кадра ebp (вызывающей стороны, функция foo) и удаляем сохраненное значение со стека, смещая указатель вершины стека на 4 байта вверх. Забираем со стека адрес возврата и удаляем сохраненное значение со стека, таким же смещением указатель вершины стека на 4 байта вверх. Передаем управление обратно функции foo.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| local a        |     <-- ebp - 4+----------------+| local b        |     <-- ebp - 8+----------------+| ...            || 8 byte padding || ...            |+----------------+| arg a          |     <-- esp + 4+----------------+| arg b          |     <-- esp+----------------+

Функция foo после вызова bar завершает свою работу. Удаляем локальные переменные и аргументы предыдущего вызова, смещаем указатель вершины обратно на 24 байта вверх.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp, esp+----------------+

Восстанавливаем значение указателя базы предыдущего стекового кадра ebp (вызывающей стороны, функция main) и удаляем сохраненное значение со стека. Забираем со стека адрес возврата и удаляем сохраненное значение со стека. Передаем управление обратно функции main.


| ...            |+----------------+| return address |+----------------+| saved rbp      |     <-- ebp+----------------+| ...            || 8 byte padding || ...            |     <-- esp-----------------+

main завершает свою работу, выполняя ровно те же действия, что и предыдущие вызовы и мы возвращаемся в исходное состояние.


| ...            |+----------------+| return address |+----------------+

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


Классификация


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


  1. Способ передачи управления;
  2. Способ представления в языке;
  3. Способ локализации внутреннего состояния (состояния выполнения).

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


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


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


По способ представления в языке сопрограммы могут быть представлены как объекты первого класса (first-class object, first-class citizen) или как ограниченные низкоуровневые языковые конструкции (constrained, compiler-internal), скрывающие детали реализации и предоставляющие управляющие описатели (handles), дескрипторы.
Объекты первого класса в контексте языка программирования это сущности которые могут быть сохранены в переменные, могут передаваться в функции как аргументы или возвращаться в качестве результата, могут быть созданы в рантайме и не зависят от именования (внутренне самоопознаваемы). Например, нельзя создавать функции во время выполнения программы, поэтому функции не являются объектами первого класса. В то же время, существует понятие функционального объекта (function object): пользовательский тип данных, реализующий эквивалентную функциям семантику, который является объектом первого класса.


По способу локализации внутреннего состояния сопрограммы можно разделить на стековые (stackful) и стеконезависимые (stackless). Чтобы понять детальнее, что лежит в основе такого разделения, необходимо дать некоторую классификацию аппаратному стеку (proccesor stack).


Аппаратный стек может быть назначен разным уровням приложения:


  • Стек приложения (Application stack). Принадлежит функции main. Система управления памятью операционной системы может определять переполнения или недопустимые аллокации такого стека. Стек расположен в адресном пространстве таким образом, что его можно расширять по мере необходимости;
  • Стек потока выполнения (Thread stack). Стек назначенный явно запущенному потоку. Обычно используются стеки фиксированного размера (до 1-2 мб);
  • Стек контекста выполнения (Side stack). Контекст (Exection context) это некоторое окружение, пользовательский поток управления или функция (top level context function, контекстная функция верхнего уровня) со своим назначенным стеком. Стек обычно выделяется в пользовательском режиме (библиотечным кодом), а не операционной системой. Контекст имеет свойство сохранять и восстанавливать свое состояние выполнение: регистры центрального процессора, счётчик команд и указатель стека, что позволяет в пользовательском режиме переключатся между контекстами.

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


Приведем пример простой сопрограммы, мы воспользуемся семейством функций для управления контекcтами: getcontext, makecontext и swapcontext (см. Complete Context Control)


#include <iostream>#include <ucontext.h>static ucontext_t caller_context;static ucontext_t coroutine_context;void print_hello_and_suspend(){     // выводим Hello     std::cout << "Hello";    // точка передачи управления вызывающей стороне,     // переключаемся на контекст caller_context    // в контексте сопрограммы coroutine_context сохраняется текущая точка выполнения,    // после возвращения контроля выполнение продолжится с этой точки.    swapcontext(&coroutine_context, &caller_context);}void simple_coroutine(){    // точка первой передачи управления в coroutine_context    // чтобы продемонстрировать преимущества использование стека    // выполним вложенный вызов функции print_hello_and_suspend.    print_hello_and_suspend();    // функция print_hello_and_suspend приостановила выполнение сопрограмма    // после того как управление вернётся мы выведем Coroutine! и завершим работу,    // управление будет передано контексту,    // указатель на который хранится в coroutine_context.uc_link, т.е. caller_context    std::cout << "Coroutine!" << std::endl;}int main(){    // Стек сопрограммы.    char stack[256];    // Инициализация контекста сопрограммы coroutine_context    // uc_link указывает на caller_context, точку возврата при завершении сопрограммы.    // uc_stack хранит указатель и размер стека    coroutine_context.uc_link          = &caller_context;    coroutine_context.uc_stack.ss_sp   = stack;    coroutine_context.uc_stack.ss_size = sizeof(stack);    getcontext(&coroutine_context);    // Заполнение coroutine_context    // Контекст настраивается таким образом, что переключаясь на него    // исполнение начинается с точки входа в функцию simple_coroutine    makecontext(&coroutine_context, simple_coroutine, 0);    // передаем управление сопрограмме, переключаемся на контекст coroutine_context    // в контексте caller_context сохраняется текущая точка выполнения,    // после возвращения контроля, выполнение продолжится с этой точки.    swapcontext(&caller_context, &coroutine_context);    // сопрограмма приостановила свое выполнение и вернула управление    // выводим пробел    std::cout << " ";    // передаём управление обратно сопрограмме.    swapcontext(&caller_context, &coroutine_context);    return 0;}

Отметим, что контексты выполнения лежат в основе реализации стековых сопрограмм библиотеки Boost: Boost.Coroutine, Boost.Coroutine2, только в Boost по умолчанию вместо ucontext_t используется fcontext_t собственная, более производительная реализация(ассемблерная, с ручным сохранением/восстановлением регистров, без системных вызовов) POSIX стандарта.


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


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


  1. Передача управления возможна только из самой сопрограммы (top level function), все вложенные вызовы к этому моменту должны быть завершены;
  2. Передача управления возможна только вызывающей стороне;
  3. Возобновление работы сопрограммы происходит на стеке вызывающей стороны, он может отличатся от стека первоначального вызова, это может быть даже другой поток;
  4. Для сохранения состояния (определения набора переменных), восстановления кадра и генерации шаблонного кода для возобновления работы с точки последней остановки, необходима поддержка со стороны стандартов и компиляторов языка.

Мы описали общую теорию и классификацию сопрограмм, рассмотрим техническую спецификацию сопрограмм нового C++20, какое место они занимают в общей теории, их особенности, семантику и синтаксис.


C++20.


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


Подход который применяется для реализации обобщенных механизмов уже встречается и используется стандартом. Это range based for, суть его в том что компилятор генерирует код цикла, вызывая определенный набор методов строго описанным способом, в данном случае это методы begin и end, тем самым давая возможность программистам настраивать необходимое поведение цикла, определяя эти методы и тип итератора который они возвращают. Точно также компилятор генерирует код сопрограммы, вызывая в строго определенный момент методы определенных пользователем типов, позволяя полностью настраивать и контролировать поведение сопрограммы.


В описанной нами классификации предоставляемые средства подпадают под определение compile-internal сопрограмм.


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


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


В итоге, C++20 даёт нам возможность работать с compile-internal asymmetric stackless coroutines.


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


New Keywords


Для оперирования сопрограммами стандарт вводит три ключевых оператора:


  • co_await. Унарный оператор, позволяющий, в общем случае, приостановить выполнение сопрограммы и передать управление вызывающей стороне, пока не завершатся вычисления представленные операндом;
  • co_yield. Унарный оператор, частный случай оператора co_await, позволяющий приостановить выполнение сопрограммы и передать управление и значение операнда вызывающей стороне;
  • co_return. Оператор завершает работу сопрограммы, возвращая значение, после вызова сопрограмма больше не сможет возобновить свое выполнение.

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


Сопрограммой не может быть:


  • Функция main;
  • Функция с оператором return;
  • Функция помеченная constexpr;
  • Функция с автоматическим выведение типа возвращаемого значения (auto);
  • Функция с переменным числом аргументов (variadic arguments, не путать с variadic templates);
  • Конструктор;
  • Деструктор.

User types.


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


Promise.


Объект типа Promise позволяет настраивать поведения сопрограммы как программной единицы. Должен определять:


  • Поведение сопрограммы при первом вызове;
  • Поведение при выходе из сопрограммы;
  • Стратегию обработки исключительных ситуаций;
  • Необходимость в дополнительном уточнении типа выражения операторов co_await;
  • Передача промежуточных и конечных результатов выполнения вызывающей стороне.
    Также тип promise участвует в разрешение перегрузки операторов new и delete, что позволяет настраивать динамическое размещение фрейма сопрограммы.
    Объект типа promise создаётся и хранится в рамках фрейма сопрограммы для каждого нового вызова.

Тип Promise определяется компилятором согласно специализации шаблона std::coroutine_traits по типу сопрограммы, в специализации участвует: тип возвращаемого значения, список типов входных параметров, тип класса, если сопрограмма представлена методом. Шаблон std::coroutine_traits определен следующем образом:


template <typename Ret, typename = std::void_t<>>struct coroutine_traits_base{};template <typename Ret>struct coroutine_traits_base<Ret, std::void_t<typename Ret::promise_type>>{    using promise_type = typename Ret::promise_type;};template <typename Ret, typename... Ts>struct coroutine_traits : coroutine_traits_base<Ret>{};

Тип должен иметь строгое имя promise_type. Из определения std::coroutine_traits, следует что существует как минимум одна специализация, которая ищет определение типа promise_type в пространстве имен типа возвращаемого результата. promise_type может быть как именем типа, так и псевдонимом.


Самый простой способ определения типа Promise для сопрограммы.


struct Task{    struct Promise    {        ...    };    using promise_type = Promise;};...Task foo(){    ...}

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


Другой способ определить тип Promise это явно специализировать шаблон std::coroutine_traits. Это удобно, например, для сопрограмма представленных методом пользовательского типа


class Coroutine{public:    void call(int);};namespace std{    template<>    struct coroutine_traits<void, Coroutine, int>    {        using promise_type = Coroutine;    };}

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


Прежде чем определить интерфейс типа Promise, необходимо описать второй тип ассоциированный со сопрограммой: Awaitable.


Avaitable.


Объекты типа Avaitable определяют семантику потока управления сопрограммы. Позволяют:


  • Определить следует ли приостанавливать выполнение сопрограммы в точке вызова оператора co_await;
  • Выполнить некоторую логику после приостановления выполнения сопрограммы для дальнейшего планирования возобновления ее работы (асинхронные операции);
  • Получить результат вызова оператора co_await, после возобновления работы.

Объект типа Awaitable определяется в результате разрешения перегрузки (overload resolution) и вызова оператора co_await. Если жизнеспособной перегрузки не было найдено, то результат вычисления самого операнда является объектом типа Awaitable. Далее вызов оператора транслируется в последовательность вызовов методов объекта данного типа.


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


Task foo(){    using namespace std::chrono_literals;    // выполнить некоторый набор операций    // вернуть управление    co_await 10s;    // через 10 секунд выполнить еще один набор операций.}

В этом примере выражение переданное в качестве операнда имеет тип std::chrono::duration<long long>, чтобы скомпилировать этот код нам нужно определить перегрузку оператора co_await для выражений такого типа.


template<typename Rep, typename Period>auto operator co_await(std::chrono::duration<Rep, Period> duration) noexcept{    struct Awaitable    {        explicit Awaitable(std::chrono::system_clock::duration<Rep, Period> duration)            : duration_(duration)        {}        ...    private:        std::chrono::system_clock::duration duration_;    };    return Awaitable{ duration };}

Внутри перегрузки мы описываем тип Awaitable, задача которого запланировать и вернуть управление сопрограмме через заданный промежуток времени, и возвращаем объект данного типа как результат.


Нам осталось определить интерфейс типа Awaitable, чтобы это сделать рассмотрим подробнее вызов оператора co_await <expr> и код, который компилятор генерирует в месте вызова.


{    // в начале мы определили тип Promise    using coroutine_traits = std::coroutine_traits<ReturnValue, Args...>;    using promise_type = typename coroutine_traits::promise_type;    ...    // вызов co_await <expr> в рамках сопрограммы    // 1.    // Cоздаем объект типа Avaitable, находим подходящую перегрузку оператора co_await,    // результат сохраняем во фрейме сопрограммы (как создается фрейм мы рассмотрим    // в рамках описания типа Promise), это необходимо     // т.к. с помощью Awaitable мы вернем результат вычисления, после возобновления работы.    frame->awaitable = create_awaitable(<expr>);    // 2.    // Вызываем метод await_ready().    // Основная задача метода позволить нам избежать остановки сопрограммы    // в случаях когда операция (вычисления) могут быть завершены синхронно    // или уже завершены, сохранив вычислительные ресурсы.    if (!awaitable.await_ready())    {        // 3.        // Если вызов await_ready() вернул false,        // то сопрограмма приостанавливает свое выполнение,        // сохраняет состояние: состояние локальных переменных, точку остановки        // (это идентификатор состояния, на которое сопрограмма перейдет        // после возобновления своей работы,         // достаточная информация что бы перейти в точку <resume-point>)        <suspend-coroutine>        // 4.        // Определяем тип coroutine_handle        // corotine_handle - это дескриптор фрейма сопрограммы.        // он обеспечивает низкоуровневую функциональность оперирования сопрограммой:        // передача управления (возобновление выполнения) и удаление.        using handle_type = std::coroutine_handle<promise_type>;        using await_suspend_result_type =            decltype(frame->awaitable.await_suspend(handle_type::from_promise(promise)));        // 5.        // Вызов метода await_suspend(handle),         // задача метода await_suspend выполнить некоторую логику        // на клиентской стороне после приостановления выполнения сопрограммы        // для дальнейшего планирования возобновления ее работы (если необходимо).         // Метод принимает один аргумент - дескриптор сопрограммы.        // Тип возвращаемого результата, определяет семантику передачи управления        if constexpr (std::is_void_v<await_suspend_result_type>)        {            // Тип возвращаемого результата void,            // то мы безусловно передаем управление вызывающей стороне            // (под вызывающей стороной здесь понимается сторона,            // которая передала управление сопрограмме)            frame->awaitable.await_suspend(handle_type::from_promise(promise));            <return-to-caller-or-resumer>;        }        else if constexpr (std::is_same_v<await_suspend_result_type, bool>)        {            // Тип возвращаемого результата bool,            // если метод вернул false, то управление не передается вызывающей стороне            // и сопрограмма возобновляет свое выполнение            // Это полезно, например, когда асинхронная операция            // инициированная объектом Awaitable завершилась синхронно            if (frame->awaitable.await_suspend(handle_type::from_promise(promise))                <return-to-caller-or-resumer>;        }        else if constexrp (is_coroutine_handle_v<await_suspend_result_type>)        {            // Тип возвращаемого результат std::coroutine_handle<OtherPromise>,            // т.е. вызов возвращает дескриптор другой сопрограммы,            // то мы передаем управление этой сопрограмме, это семантика позволяет            // эффективно реализовывать симметричный механизм передачи потока             // управления между сопрограммами            auto&& other_handle = frame->awaitable.await_suspend(                 handle_type::from_promise(promise));            other_handle.resume();        }        else        {            static_assert(false);        }    }    // 6.    // Точка возобновления выполнения (пробуждения)    // Вызов метода await_resume(). Задача метода получить результат вычисления.    // Возвращаемое значение рассматривается как результат вызова оператора co_await.resume_point:    return frame->awaitable.await_resume();}

Здесь есть несколько важных замечаний:


  1. Если в процессе обработки возбуждается исключение, то исключение пробрасывается дальше, наружу оператора co_await. Если во время исключения выполнение сопрограммы было приостановлено, то исключение перехватывается, сопрограмма автоматически возобновляет свое выполнение и только после этого пробрасывается дальше;
  2. Крайне важно, что сопрограмма полностью останавливает свое выполнение до вызова метода await_suspend и передачи дескриптора сопрограммы пользовательскому коду. В этом случае дескриптор сопрограммы может быть свободно передаваться между потоками выполнения без дополнительной синхронизации. Например, дескриптор может быть передан в запланированную в пуле-потоков асинхронную операцию. Конечно здесь следует очень внимательно следить за тем, в какой момент метода await_suspend мы передаем дескриптор другому потоку и как другой поток оперирует этим дескриптором. Поток получившей дескриптор может возобновить выполнение сопрограммы до того как мы вышли из await_suspend. После возобновление работы и вызова метода await_resume, объект Awaitable может быть удален. Также потенциально фрейм и объект Promise может быть удалены, до того как мы завершим метод await_suspend. Поэтому основное чего следует избегать, после передачи контроля над сопрограммой другому потоку в await_suspend: это не обращаться к полям (this может быть удален) и объекту Promise, они могут быть уже удалены.

Формально концепцию Awaitable можно определить в терминах type-traits примерно так:


is_awaitable
// является ли тип std::coroutine_handletemplate<typename Type>struct is_coroutine_handle : std::false_type{};template<typename Promise>struct is_coroutine_handle<std::coroutine_handle<Promise>> : std::true_type{};// типы возможных возвращаемых значений метода await_suspend// - void// - bool// - std::coroutine_handletemplate<typename Type>struct is_valid_await_suspend_return_type : std::disjunction<    std::is_void<Type>,    std::is_same<Type, bool>,    is_coroutine_handle<Type>>{};// метод await_suspenttemplate<typename Type>using is_await_suspent_method = is_valid_await_suspend_return_type<    decltype(std::declval<Type>().await_suspend(std::declval<std::coroutine_handle<>>()))>;// метод await_readytemplate<typename Type>using is_await_ready_method = std::is_constructible<bool, decltype(    std::declval<Type>().await_ready())>;// интерфейс типа Avaitable/*templae<typename Type>struct Avaitable{...    bool await_ready();    void await_suspend(std::coroutine_handle<>);    Type await_resume();...}*/template<typename Type, typename = std::void_t<>>struct is_awaitable : std::false_type{};template<typename Type>struct is_awaitable<Type, std::void_t<    decltype(std::declval<Type>().await_ready()),    decltype(std::declval<Type>().await_suspend(std::declval<std::coroutine_handle<>>())),    decltype(std::declval<Type>().await_resume())>> : std::conjunction<    is_await_ready_method<Type>,    is_await_suspent_method<Type>>{};template<typename Type>constexpr bool is_awaitable_v = is_awaitable<Type>::value;

Дополним предыдущий пример:


template<typename Rep, typename Period>auto operator co_await(std::chrono::duration<Rep, Period> duration) noexcept{    struct Awaitable    {        explicit Awaitable(std::chrono::system_clock::duration duration)            : duration_(duration)        {}        bool await_ready() const noexcept        {            return duration_.count() <= 0;        }        void await_resume() noexcept        {}        void await_suspend(std::coroutine_handle<> h)        {            // Реализация timer::async в данном контексте не очень интересна.            // Важно что это асинхронная операция, которая через заданный            // промежуток времени вызовет переданный callback.            timer::async(duration_, [h]()            {                h.resume();            });        }    private:        std::chrono::system_clock::duration duration_;    };    return Awaitable{ duration };}// сопрограмма, которая через каждую секунду будет выводить текст на экранTask tick(){    using namespace std::chrono_literals;    co_await 1s;    std::cout << "1..." << std::endl;    co_await 1000ms;    std::cout << "2..." << std::endl;}int main(){    tick();    std::cin.get();}

  1. Вызываем функцию tick;
  2. Находим нужную перегрузку оператора co_await и создаем объект Awaitable, передаем в конструктор временной интервал в 1 секунду;
  3. Вызываем метод await_ready, проверяем необходимо ли ожидание;
  4. Приостанавливаем работу функции tick, сохраняем состояние;
  5. Вызываем метод await_suspend и передаем дескриптор сопрограммы;
  6. Метод await_suspend инициирует асинхронную операцию timer::async, которая ожидает заданное время и вызывает переданный callback. Предаем в callback дескриптор сопрограммы чтобы после ожидания передать ей управление;
  7. Передаем управление вызывающей стороне функции main;
  8. Функция main вызывает метод стандартного потока ввода get, это синхронная операция, ожидающая ввода. Мы висим, чтобы просто дать завершится инициированным асинхронным операциям;
  9. Ждем одну секунду, асинхронная операция вызывает переданный нами callback, вызов осуществляется в том же потоке, в котором происходило ожидание;
  10. Вызываем метод resume у дескриптора. Метод передает управление сопрограмме: вызывается функция tick на стеке потока, восстанавливаем сохраненное во фрейме состояние, управление передается в точку последней остановки;
  11. Вызывается метод await_resume у объекта Avaitable, созданного при вызове оператора co_await и сохраненного во фрейме;
  12. Метода await_resume ничего не делает и не возвращает результата, оператор co_await завершает свою работу и передает управление, следующей за ним команде;
  13. Функция tick выводит сообщение на экран с помощью стандартного потока вывода "1...";
  14. Вызов следующего оператора co_await. Выполняем все шаги начиная с пункта 2. Отличие только в том, что управление возвращается не функции main, a асинхронной операции, которая вызвала наш callback, т.е. resumer'у. После это асинхронная операция завершает свое выполнение;
  15. Сопрограмма tick завершает свое выполнение (более детально этот процесс мы рассмотрим ниже)

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


Promise.


Также как и в случае с Awaitable, чтобы понять роль объекта Promise мы начнем с кода, который генерирует компилятор в процессе обработки сопрограммы


Генерируемый код можно разделить на три часть:


  1. Создание и инициализация кадра сопрограммы. Инициация выполнения сопрограммы. Т.е. это код который выполняется при первом вызове сопрограммы;
  2. Описание стейт-машины согласно пользовательским запросам передачи управления (вызовы операторов co_awat/co_yield/co_return). Это код, который выполняется при передаче управления сопрограмме т.е. при первом вызове и при возобновление работы;
  3. Завершение выполнения, освобождение ресурсов и удаление кадра. Код выполняется при естественном завершение или принудительном удаление.

// Примерная организация кадра сопрограммы.// Здесь отражены наиболее важные для понимая части// 1. resume - указатель на функцию, //    коротая вызывается при передаче управления сопрограмме, описывает стейт-машину.// 2. promise - объект типа Promise// 3. state - текущее состояние// 4. heap_allocated - был ли фрейм при создание размещен в куче//    или фрейм был создан на стеке вызывающей стороны// 5. args - аргументы вызова сопрограммы// 6. locals - сохраненные локальные переменные текущего состояния// ...struct coroutine_frame{    void (*resume)(coroutine_frame *);    promise_type promise;    int16_t state;    bool heap_allocated;    // args    // locals    //...};// 1. Создание и инициализация кадра сопрограммы. Инициация выполнения.template<typename ReturnValue, typename ...Args>ReturnValue Foo(Args&&... args){    // 1.    // Определяем тип Promise    using coroutine_traits = std::coroutine_traits<ReturnValue, Args...>;    using promise_type = typename coroutine_traits::promise_type;    // 2.    // Создание кадра сопрограммы.     // Размер кадра определяется встроенными средствами компилятора    // и зависит от размера объекта Promise, количества и размера локальных переменных    // и аргументов, и набора вспомогательных данных,    // необходимых для управления состоянием сопрограммы.    // 1. Если тип promise_type имеет статический метод    //    get_return_object_on_allocation_failure,    //    то вызывается версия оператора new, не генерирующая исключений    //    и в случае неудачи вызывается метод get_return_object_on_allocation_failure,    //    результат вызова возвращается вызывающей стороне.    // 2. Иначе вызывается обычная версия оператора new.    coroutine_frame* frame = nullptr;    if constexpr (has_static_get_return_object_on_allocation_failure_v<promise_type>)    {        frame = reinterpret_cast<coroutine_frame*>(            operator new(__builtin_coro_size(), std::nothrow));        if(!frame)            return promise_type::get_return_object_on_allocation_failure();    }    else    {        frame = reinterpret_cast<coroutine_frame*>(operator new(__builtin_coro_size()));    }    // 3.    // Сохраняем, переданные функции, аргументы во фрейме.    // Аргументы переданные по значению перемещаются.    // Аргументы переданные по ссылке (lvalue и rvalue) сохраняют ссылочную семантику.    <move-args-to-frame>    // 4.    // Создаем объект типа promise_type и сохраняем его во фрейме    new(&frame->promise) create_promise<promise_type>(<frame-lvalue-args>);    // 5.    // Вызываем метод Promise::get_return_object().    // Результат вычисления будет возвращен вызывающей стороне    // при достижение первой точки остановки и передачи потока управления.    // Результат сохраняется как локальная переменная до вызов тела функции,    // т.к. фрейм сопрограммы может быть удален (см. оператор co_await).    auto return_object = frame->promise.get_return_object();    // 6.    // Вызываем функцию описывающую стейт-машину согласно     // пользовательским запросам передачи управления    // В реализации GCC, например, эти две функции называются    // ramp-fucntion (создание и инициализация) и     // action-function (пользовательская стейт-машина) соответственно    void couroutine_states(coroutine_frame*);    couroutine_states(frame);    // 7.    // Возвращаем результат вызывающей стороне,     // мы достигнем этой точки в коде только при первом вызове,    // все последующие запросы на возобновление работы будут вызывать функцию    // стейт-машины couroutine_states, указатель на функцию сохранен во фрейме сопрограммы.    return return_object;}

Мы упоминали выше что тип Promise участвует в разрешение перегрузки операторов new и delete. Например, при таком определение, будут вызваны пользовательские операторы new и delete:


struct Promise{    void* operator new(std::size_t size, std::nothrow_t) noexcept    {        ...    }    void operator delete(void* ptr, std::size_t size)    {        ...    }    // определяем поведение сопрограммы если не удалось создать фрейм    static auto get_return_object_on_allocation_failure() noexcept    {        // создаем и возвращаем вызывающей стороне невалидный объект        return make_invalid_task();    }};

Более того у нас есть возможность добавить перегрузку оператора new c дополнительными аргументами, набор параметров должен быть согласован с параметрами функции. Это позволяет использовать, например, стандартные механизмы такие как leading-allocator convention.


// тип Promise с перегрузкой оператора new c пользовательским аллокаторомtemplate<typename Allocator>struct Promise : PromiseBase{    // std::allocator_arg_t - это tag-тип    // нужен для устранения неоднозначных ситуаций при перегрузке    void* operator new(std::size_t size, std::allocator_arg_t, Allocator allocator) noexcept    {        ...    }    void operator delete(void* ptr, std::size_t size)    {        ...    }};// добавляем соответствующую специализацию в std::coroutine_traitsnamespace std{    template<typename... Args>    struct coroutine_traits<Task, Args...>    {        using promise_type = PromiseBase;    };    template<typename Allocator>    struct coroutine_traits<Task, std::allocator_arg_t, Allocator>    {        using promise_type = Promise<Allocator>;    };}// мы можем вызывать сопрограммы с передачей конкретного аллокатораint main(){    MyAlloc alloc;    coro(std::allocator_arg, alloc);    ...}

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


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


void Coroutine(const std::vector<int>& data){    co_await 10s;    for(const auto& value : data)        std::cout << value << std::endl;}void Foo(){    // 1. Мы передаем сопрограмме  временный объект типа vector<int>;    // 2. Ссылка на этот временный объект сохраняется в константной ссылке data;    // 3. Аргументы вызова сохраняются во фрейме сопрограммы, т.к. ссылочная семантика    //    сохраняется, то поле, в котором мы сохранили data     //    будет указывать на тот же временный объект;    // 4. Временные объекты удаляются после полного вычисления выражения;    // 5. Выражение, в котором участвует временный объект типа vector<int>,     //    будет вычислено, когда оператор co_await вернет управление вызывающей стороне    //    и функция Foo продолжит свое выполнение;    // 6. Через 10 секунд, когда сопрограмма вновь получит поток управления и     //    продолжит свое выполнение c цикла, вектор, на который ссылается data     //    (поле фрейма, в котором сохранена ссылка), будет уже удален.    Coroutine({1, 2, 3});    ...}

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


Далее мы обращаемся к объекту типа Promise и вызываем метод get_return_object. Создаваемый объект не обязательно должен в точности соответствовать типу возвращаемого результата, по необходимости и возможности может быть выполнено неявное преобразование. В стандарте нет требования в какой момент выполнять неявное преобразование: в момент создания объекта и вызова get_return_object или в момент возвращения вызываемой стороне. Это важно, например, если мы используем какие-то операции с побочными эффектами, реализуя императивную последовательность выполнения. Пример эксплуатации такого преобразования Мonadic composition.


class Task{public:    struct promise_type    {        auto get_return_object() noexcept        {            return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };        }        ...    };    void resume()    {        if(coro_handle)            coro_handle.resume();    }private:    Task() = default;    explicit Task(std::coroutine_handle<> handle)        : coro_handle(handle)    {}    std::coroutine_handle<> coro_handle;};

Мы уже встречали тип std::coroutine_handle эти дескриптор сопрограммы, обеспечивает низкоуровневую функциональность оперирования сопрограммой: передача управления (возобновление выполнения) и удаление. Статический метод from_promise, позволяет получить дескриптор сопрограммы по объекту Promise.


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


State Machine.


Функции couroutine_states описание стейт-машину согласно пользовательскому набору вызовов операторов co_awat/co_yield/co_return и вызывается автоматически при передаче управления сопрограмме: при первом вызове или при возобновление работы, вызовом метода дескриптора resume. Указатель на функцию сохранен в кадре сопрограммы.


void couroutine_states(coroutine_frame* frame){    switch(frame->state)    {        case 1:        ... goto resume_point_0;        case N:            goto resume_point_N;        ...    }    co_await promise.initial_suspend();    try    {        // function body    }    catch(...)    {        promise.unhandled_exception();    }final_suspend:    co_await promise.final_suspend();}

Если вернутся к коду, который генерируется вызовом оператора co_await, то можно заметить метку resume_point точка пробуждения.


{    ...resume_point:    return frame->awaitable.await_resume();}

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


При первом вызове сопрограммы состояние не будет задано и мы перейдем к вызову метода initial_suspend с последующей передачей результата оператору co_await. Задача этого вызова определить: следует ли начать выполнение пользовательского кода немедленно или выполнение должно быть отложено. Стандарт предоставляет два тривиальных типа реализующих концепцию Avaitable: std::suspend_never, std::suspend_always, которые упрощают реализацию метода initial_suspend, позволяя реализовать две наиболее распространенных модели поведения.


namespace std{    struct suspend_never    {        bool await_ready() noexcept { return true; }        void await_suspend(coroutine_handle<>) noexcept {}        void await_resume() noexcept {}    };    struct suspend_always    {        bool await_ready() noexcept { return false; }        void await_suspend(coroutine_handle<>) noexcept {}        void await_resume() noexcept {}    };}// В данном случае, вызов сопрограммы приводит к немедленному выполнению// пользовательского кодаclass Task{public:    struct promise_type    {        ...        auto init_suspend() const noexcept        {            return std::suspend_never{};        }    }    ...};// В этом же случае, при вызове сопрограммы, // управление сразу передается вызывающей стороне// и выполнение пользовательского кода будет отложено,// до явной передачи управление обратно через вызов метода resume.class TaskManual{public:    struct promise_type    {        ...        auto init_suspend() const noexcept        {            return std::suspend_always{};        }    }    ...};

Далее следует выполнение пользовательского кода. Если включена поддержка исключений, то пользовательский код заключается в блок try-catch с вызовом метода unhandled_exception в случае возбуждения исключения.


В пользовательском коде, помимо оператора co_await, могут встречаться операторы co_yield и co_return. Эти операторы позволяют передавать вызываемой стороне промежуточный или конечный результат выполнения сопрограммы. Передача результат осуществляется средствами объекта Promise, но с разной семантикой.


Оператор co_yield <expr> эквивалентен вызову:


co_await frame->promise.yield_value(<expr>);

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


template<typename Type>class Task{public:    struct promise_type    {        ...        // Cохраняем переданное сопрограммой значение,        // передаем управление вызывающей стороне,         // возвращая и передавая оператору co_await объект типа std::suspend_always.        auto yield_value(Type value)        {            current_value = std::move(value);            return std::suspend_always{};        }    };    ...};

Пользовательский тип Task может реализовывать разные стратегии получения доступа к сохраненным в объекте Promise значениям. Один из наиболее выразительных вариантов использования семантики оператора coyield это [генераторы](https://en.wikipedia.org/wiki/Generator(computer_programming)). Пример генератора из библиотеки cppcoro


В свою очередь оператор co_return в контексте пользовательского кода обладает такой же семантикой как и оператор return. Позволяет принудительно завершить выполнение и вернуть результат вызывающей стороне.


  • Вызов co_rеturn без операндов эквивалентен:


    // co_return;frame->promise.return_void();goto final_suspend;
    

  • Если тип результат вычисления выражения, переданного оператору в качестве аргумента, отличен от void, то вызов co_rеturn эквивалентен следующему коду


    // co_return <expr>;promise.return_value(<expr>);goto final_suspend;
    

  • Если же тип результата вычисления выражения void, то вызов генерирует следующий код


    // co_return <expr>;<expr>;promise.return_void();goto final_suspend;
    


Важно, если в пользовательском коде нет операторов co_return, то в конце тела функции генерируется вызов оператора без аргументов co_return;. Т.е. выражение frame->promise.return_void() должно быть валидно.


После передачи результата вычислений в Promise через вызов методов return_value или return_void мы завершаем выполнение пользовательского кода и переходим к служебному вызову final_suspend.


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


// В этот случае после выполнения пользовательского кода сопрограмма завершит свою работу// и все ресурсы будут удалены автоматически. Объекту Promise будет вызван деструктор,// аргументы будут удалены, память выделенная под кадр сопрограммы будет очищена// вызовом оператора delete, затем управление будет передано вызывающей стороне.// Передача управления обратно сопрограмме приведет к Undefined Behavior.// Это поведение полезно, когда мы не ожидаем результата выполнения сопрограммы.class Task{public:    struct promise_type    {        ...        auto final_suspend() const noexcept        {            // не передаем управление вызывающей стороне            return std::suspend_never{};        }    };    ...};// В этом же случае, после выполнения пользовательского кода сопрограмма передаст // управление вызывающей стороне и ответственность за удаление ресурсов сопрограммы.// Передача управления обратно сопрограмме на этом этапе приведет к Undefined Behavior.// Удаление ресурсов сопрограммы осуществляется принудительным вызовом// на стороне пользователя coroutine_handle::destroy()// Это стратегия необходима, когда на нужно получить результат работы сопрограммы, // в противном случае они будут удалены вместе с объектом Promise.class TaskManual{public:    struct promise_type    {        ...        auto final_suspend() const noexcept        {            // передаем управление вызывающей стороне            return std::suspend_always{};        }    }    ...};

Мы рассмотрели все случаи использования оператора co_await как в пользовательском так и в служебном коде. Он вызывается с результатами вызова init_suspend и final_suspent, приостанавливает работу сопрограммы в случае вызова оператора co_yeild, может быть вызван в пользовательском коде с произвольным выражением. У последнего использования есть одна особенность. Если тип Promise определяет метод await_transform, то любой вызов оператора co_await в пользовательском коде транслируется в вызов


// co_await <expr>co_await frame->promise.await_transform(<expr>);

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


class Task{public:    struct promise_type    {        ...        template<typename Type>        auto await_transform(Type&& Whatever) const noexcept        {            static_assert(false,                "co_await is not supported in coroutines of type Generator");            return std::suspend_never{};        }    };    ...};

Вместо заключения


Примеры кода:


  • cppcoro. Библиотека примитивов асинхронной и кооперативной композиции построенная на сопрограммах Coroutine TS;
  • folly. Реализована экспериментальная поддержка стандартных сопрограмм;

Возможное дальнейшее развитие:



Ссылки:



Буду рад комментариям и предложениям (можно по почте yegorov.alex@gmail.com)
Спасибо!

Подробнее..

Полиморфные аллокаторы C17

24.09.2020 14:19:46 | Автор: admin
Уже совсем скоро в OTUS стартует новый поток курса C++ Developer. Professional. В преддверии старта курса наш эксперт Александр Ключев подготовил интересный материал про полиморфные аллокаторы. Передаем слово Александру:



В данной статье, хотелось бы показать простые примеры работы с компонентами из нэймспэйса pmr и основные идеи лежащие в основе полиморфных аллокаторов.

Основная идея полиморфных аллокаторов, введенных в c++17, в улучшении стандартных аллокаторов, реализованных на основе статического полиморфизма или иными словами темплейтов.Их гораздо проще использовать, чем стандартные аллокаторы, кроме того, они позволяют сохранять тип контейнера при использовании разных аллокаторов и, следовательно, менять аллокаторы в рантайме.

Если вы хотите std::vector с определенным аллокатором памяти, можно задействовать Allocator параметр шаблона:

auto my_vector = std::vector<int, my_allocator>();


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

auto my_vector = std::vector<int, my_allocator>();auto my_vector2 = std::vector<int, other_allocator>();auto vec = my_vector; // okvec = my_vector2; // error

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

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

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

Одной из основных проблем на текущий момент остается несовместимость новых версий контейнеров из std::pmr с аналогами из std.

Основные компоненты std::pmr:


  • std::pmr::memory_resource абстрактный класс, реализация которого в конечном счете отвечают за работу с памятью.
  • Содержит следующий интерфейс:
    • virtual void* do_allocate(std::size_t bytes, std::size_t alignment),
    • virtual void do_deallocate(void* p, std::size_t bytes, std::size_t alignment)
    • virtual bool do_is_equal(const std::pmr::memory_resource& other) const noexcept.
  • std::pmr::polymorphic_allocator имплементация стандартного аллокатора, использует указатель на memory_resource для работы с памятью.
  • new_delete_resource() и null_memory_resource() используются для работы с глобальной памятью
  • Набор готовых пулов памяти:
    • synchronized_pool_resource
    • unsynchronized_pool_resource
    • monotonic_buffer_resource
  • Специализации стандартных контейнеров с полиморфным аллокатором, std::pmr::vector, std::pmr::string, std::pmr::map и тд. Каждая специализация определена в том же заголовочном файле, что и соответствующий контейнер.
  • Набор готовых memory_resource:
    • memory_resource* new_delete_resource() Свободная функция, возвращает указатель на memory_resource, который использует глобальные операторы new и delete выделения памяти.
    • memory_resource* null_memory_resource()
      Свободная функция возвращает указатель на memory_resource, который бросает исключение std::bad_alloc на каждую попытку аллокации.

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

  • class synchronized_pool_resource : public std::pmr::memory_resource
    Потокобезопасная имплементация memory_resource общего назначения состоит из набора пулов с разными размерами блоков памяти.
    Каждый пул представляет из себя набор из кусков памяти одного размера.
  • class unsynchronized_pool_resource : public std::pmr::memory_resource
    Однопоточная версия synchronized_pool_resource.
  • class monotonic_buffer_resource : public std::pmr::memory_resource
    Однопоточный, быстрый, memory_resource специального назначения берет память из заранее выделенного буфера, но не освобождает его, т.е может только расти.

Пример использования monotonic_buffer_resource и pmr::vector:

#include <iostream>#include <memory_resource>   // pmr core types#include <vector>        // pmr::vector#include <string>        // pmr::string int main() {char buffer[64] = {}; // a small buffer on the stackstd::fill_n(std::begin(buffer), std::size(buffer) - 1, '_');std::cout << buffer << '\n'; std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)}; std::pmr::vector<char> vec{ &pool };for (char ch = 'a'; ch <= 'z'; ++ch)    vec.push_back(ch); std::cout << buffer << '\n';}

Вывод программы:

_______________________________________________________________aababcdabcdefghabcdefghijklmnopabcdefghijklmnopqrstuvwxyz______

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

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

Можно, конечно, вызвать reserve() для вектора, чтобы минимизировать реаллокации, но цель примера именно в том чтобы продемонстрировать, как меняется monotonic_buffer_resource при расширении контейнера.

Хранение pmr::string


Что если мы хотим хранить строки в pmr::vector?

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

Если вы хотите воспользоваться этой возможностью, нужно использовать std::pmr::string вместо std::string.

Рассмотрим пример с заранее выделенным на стеке буфером, который мы передадим в качестве memory_resource для std::pmr::vector std::pmr::string:

#include <iostream>#include <memory_resource>   // pmr core types#include <vector>        // pmr::vector#include <string>        // pmr::string int main() {std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';std::cout << "sizeof(std::pmr::string): " << sizeof(std::pmr::string) << '\n'; char buffer[256] = {}; // a small buffer on the stackstd::fill_n(std::begin(buffer), std::size(buffer) - 1, '_'); const auto BufferPrinter = [](std::string_view buf, std::string_view title) {    std::cout << title << ":\n";    for (auto& ch : buf) {        std::cout << (ch >= ' ' ? ch : '#');    }    std::cout << '\n';}; BufferPrinter(buffer, "zeroed buffer"); std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)};std::pmr::vector<std::pmr::string> vec{ &pool };vec.reserve(5); vec.push_back("Hello World");vec.push_back("One Two Three");BufferPrinter(std::string_view(buffer, std::size(buffer)), "after two short strings"); vec.emplace_back("This is a longer string");BufferPrinter(std::string_view(buffer, std::size(buffer)), "after longer string strings"); vec.push_back("Four Five Six");BufferPrinter(std::string_view(buffer, std::size(buffer)), "after the last string");   }

Вывод программы:

sizeof(std::string): 32sizeof(std::pmr::string): 40zeroed buffer:_______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________after two short strings:#m######n#############Hello World######m#####@n#############One Two Three###_______________________________________________________________________________________________________________________________________________________________________________#after longer string strings:#m######n#############Hello World######m#####@n#############One Two Three####m######n#####################________________________________________________________________________________________This is a longer string#_______________________________#after the last string:#m######n#############Hello World######m#####@n#############One Two Three####m######n#####################________#m######n#############Four Five Six###________________________________________This is a longer string#_______________________________#

Основные моменты, на которые нужно обратить внимание в данном примере:

  • Размер pmr::string больше чем std::string. Связано этот с тем, что добавляется указатель на memory_resource;
  • Мы резервируем вектор под 5 элементов, поэтому при добавлении 4х реаллокаций не происходит.
  • Первые 2 строки достаточно короткие для блока памяти вектора, поэтому дополнительного выделения памяти не происходит.
  • Третья строка более длинная и для потребовался отдельный кусок памяти внутри нашего буфера, в векторе при этом сохраняется только указатель на этот блок.
  • Как можно видеть из вывода, строка This is a longer string расположена почти в самом конце буфера.
  • Когда мы вставляем еще одну короткую строку, она попадает снова в блока памяти вектора

Для сравнения проделаем такой же эксперимент с std::string вместо std::pmr::string

sizeof(std::string): 32sizeof(std::pmr::string): 40zeroed buffer:_______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________after two short strings:###w###########Hello World########w###########One Two Three###_______________________________________________________________________________________________________________________________________________________________________________________________#new 24after longer string strings:###w###########Hello World########w###########One Two Three###0#######################_______________________________________________________________________________________________________________________________________________________________________#after the last string:###w###########Hello World########w###########One Two Three###0#######################________@##w###########Four Five Six###_______________________________________________________________________________________________________________________________#


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

Еще раз про расширение вектора:


Упоминалось, что когда память в пуле заканчивается, аллокатор запрашивает ее с помощью оператора new().

На самом деле это не совсем так память запрашивается у memory_resource, возвращаемого с помощью свободной функции
std::pmr::memory_resource* get_default_resource()
По умолчанию эта функция возвращает
std::pmr::new_delete_resource(), который в свою очередь выделяет память с помощью оператора new(), но может быть заменен с помощью функции
std::pmr::memory_resource* set_default_resource(std::pmr::memory_resource* r)

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

Нужно иметь в виду, что методы do_allocate() и do_deallocate() используют аргумент выравнивания, поэтому нам понадобится С++17 версия new() c поддержкой выравнивания:

void* lastAllocatedPtr = nullptr;size_t lastSize = 0; void* operator new(std::size_t size, std::align_val_t align) {#if defined(_WIN32) || defined(__CYGWIN__)auto ptr = _aligned_malloc(size, static_cast<std::size_t>(align));#elseauto ptr = aligned_alloc(static_cast<std::size_t>(align), size);#endif if (!ptr)    throw std::bad_alloc{}; std::cout << "new: " << size << ", align: "          << static_cast<std::size_t>(align)          << ", ptr: " << ptr << '\n'; lastAllocatedPtr = ptr;lastSize = size; return ptr;}

Теперь давайте вернемся к рассмотрению основного примера:

constexpr auto buf_size = 32;uint16_t buffer[buf_size] = {}; // a small buffer on the stackstd::fill_n(std::begin(buffer), std::size(buffer) - 1, 0); std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)*sizeof(uint16_t)}; std::pmr::vector<uint16_t> vec{ &pool }; for (int i = 1; i <= 20; ++i)vec.push_back(i); for (int i = 0; i < buf_size; ++i)std::cout <<  buffer[i] << " "; std::cout << std::endl; auto* bufTemp = (uint16_t *)lastAllocatedPtr; for (unsigned i = 0; i < lastSize; ++i)std::cout << bufTemp[i] << " ";

Программа пытается положить 20 чисел в вектор, но учитывая, что вектор только растет, нам нужно места больше чем в зарезервированном буфере с 32 записями.

Поэтому в какой-то момент аллокатор запросит память через get_default_resource, что в свою очередь приведет к вызову глобального new().

Вывод программы:

new: 128, align: 16, ptr: 0xc73b201 1 2 1 2 3 4 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 01 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 65535 132 0 0 0 0 0 0 0 144 0 0 0 65 0 0 0 16080 199 0 0 16176 199 0 0 16176 199 0 0 15344 199 0 0 15472 199 0 0 15472 199 0 0 0 0 0 0 145 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

Судя по выводу в консоль выделенного буфера хватает только для 16 элементов, и когда мы вставляем число 17, происходит новая аллокация 128 байт с помощью оператора new().

На 3й строчке мы видим блок памяти аллоцированный с помощью оператора new().

Приведенный выше пример с переопределением оператора new() вряд ли подойдет для продуктового решения.

К счастью, нам никто не мешает сделать свою реализацию интерфейса memory_resource.

Все что нам нужно при этом

  • унаследоваться от std::pmr::memory_resource
  • Реализовать методы:
    • do_allocate()
    • do_deallocate()
    • do_is_equal()
  • Передать нашу реализацию memory_resource контейнерам.

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


Читать ещё


Подробнее..

Новая неделя стримов от SvelteJS до Rider

28.09.2020 20:13:28 | Автор: admin


Мы продолжаем нашу серию разговорных YouTube-шоу для различных IT-специалистов. На этой неделе снова будет шесть разных выпусков для самых разных айтишников: например, джависты смогут похоливарить на тему Spring или Microprofile, а плюсовики на тему билд-систем. Полное расписание под катом.


Вторник, 29 сентября: JS и тестирование


Тяжелое утро с HolyJS


Время: 29 сентября в 10:00 (мск)
Как смотреть: трансляция на YouTube


На прошлой неделе у Тяжелого утра с HolyJS был жаркий холиварный выпуск с королём разработки. На этой должно быть более мирно: новым гостем будет Павел Малышев, лидер русскоязычного комьюнити Svelte. Он обсудит с ведущими Евгением Котом и Алексеем Золотых практику применения этого фреймворка с наглядными примерами. В последнее время о Svelte слышно всё чаще как ни ругай слово хайп, а похоже, пора запрыгивать в хайптрейн и получать хотя бы общее представление. Вот и повод это сделать!


Heisenbug Show


Время: 29 сентября в 13:30 (мск)
Как смотреть: трансляция на YouTube
Гостем выпуска станет Анастасия Бобелева QA Director в Exness. Ветераны конференции Heisenbug могут помнить доклад Анастасии Семенюк о тестировании ВКонтакте. Теперь Анастасия уже и не работает ВКонтакте, и не носит фамилию Семенюк, но поговорить с ней от этого не менее интересно. Она обсудит с ведущими работу QA в крупных компаниях, рабочие практики, результаты их применения, роль тестирования за пределами SDLC и то, как с помощью своей работы сделать мир лучше.




Среда, 30 сентября: Java и .NET


Вторая чашка кофе с Joker


Время: 30 сентября в 14:00 (мск)
Как смотреть: на YouTube-канале


Ведущие Андрей Когунь и Владимир Ситников поговорят с Дмитрием Александровым про кровавый энтерпрайз, конференции и GPU и поспорят, что лучше Microprofile или Spring.


Барная стойка


Время: 30 сентября в 19:00 (мск)
Как смотреть: на YouTube-канале


Барная стойка виртуальная замена афтепати после конференций с разговорами о технологиях и жизни, шутками и неформальной обстановкой. Михаил Щербаков и Максим Аршинов в этот раз поговорят с Кириллом Скрыганом: руководителем проекта Rider, который активно участвует в разработке и планировании IntelliJ IDEA, в прошлом один из основных разработчиков ReSharper.




Четверг, 1 октября: C++


Pure Virtual Cast


Время: 1 октября в 18:00 (мск)
Как смотреть: трансляция на YouTube


Системы сборки можно назвать одной из вечных тем в мире С++, о ней горазд поговорить любой. Но Александр Воронков забирался в тему глубже многих например, выступит с докладом о современном CMake на C++ Russia в ноябре. Вот с ним об этом и погоморим. Почему плохо (или хорошо?) писать в старом cmake-стиле? Зачем вообще cmake изучать, неужели там недостаточно знаний уровня "hello world" на cmake?




Пятница, 2 октября: DevOps


DevOops в рабочий полдник


Время: 2 октября в 18:00 (мск)
Как смотреть: канал DevOops на YouTube


В мире devops многие слышали имя Марка Смолли он и популярный конференционный спикер, и автор известных текстов. А его стаж в IT-индустрии превышает сорок лет, так что он лично застал многие тектонические сдвиги в ней. В общем, с ним явно есть о чём поговорить. Вот и поговорим!




Чтобы не пропускать интересные выпуски и заранее знать их темы, можно подписаться на соответствующую рассылку: Java, C++, тестирование, .NET, JS, DevOps.

Подробнее..

Очередная статья STM32 для начинающих

12.09.2020 20:07:24 | Автор: admin
Всех приветствую!
Это моя первая статья на хабре, поэтому прошу не кидаться тяжелыми предметами. Заранее спасибо.
Начнем с предыстории. Когда-то мне пришлось перейти на микроконтроллеры ARM фирмы ST. Это было связано с тем, что PIC и AVR уже не хватало и хотелось новых приключений. Из доступного в хлебобулочных магазинах и большого количества статей о быстром старте выбор пал именно на STM32F100.
Я привык работать в IAR. Да, есть другие IDE, но мне хватает возможности IAR: относительно удобный редактор, не плохой отладчик и достаточно удобно работать с регистрами во время отладки.
Когда я попытался сделать первый проект меня ждало разочарование CMSIS! Кому как, но для меня это было (и остается) ужасом: много буков, длинные и для меня не понятные структуры. Вникать во все это было не интересно. Попытался скомпилировать пару примеров и понял это не наш метод.

Неужели нет других вариантов? Есть. Тот, встроенный в IAR: iostm32f10xx4.h и подобные инклудники. Вполне не плохо:
RCC_APB2ENR_bit.ADC1EN = 1; // включить тактирование ADC

Оставалось это запихнуть в классы и пользоваться. Так и сделал. Через какое-то время потребовалось сделать код для STM32f4xx. И тут снова засада нет инклудиков. Что делать? писать самому. Проанализировал имеющиеся самописные библиотеки решил немного сделать по другому. Вот об этом и будет рассказ.

Начало


Про установку IAR и драйверов для отладчика рассказывать не буду, т.к. здесь ничего нового. У меня стоит IAR 8 с ограниченем кода в 32кБ. Для работы выбран контроллер STM32F103, установленный на плате plue pill.
Запускаем IAR, создаем проект c++, выбираем нужный контроллер
image
Следующий шаг изучение документации. Нас будет интересовать Reference manual RM0008. Там главное внимательно читать.
Вообще, когда я обучал своих работников программированию контроллеров, я давал задание включить светодиод (подключенный к ножке контроллера), использую дебагер, редактирую регистры и читая документацию.

Модуль RCC. Такирование


Про этот модуль обычно забывают. Вспоминают только тогда, когда не получается мигнуть светодиодом.
Запомните! Что бы включить какую-либо периферию, на нее надо подать тактовые импульсы! Без этого никак.
Порты ввода-вывода сидят на шине APB2. Находим в документации регист для упрвления тактированием этой шины, это RCC_APB2ENR:

Чтобы включить тактирование порта C (светодиод как раз припаян к PC13), требуется записать в бит IOPCEN единичку.
Теперь найдем адрес регистра RCC_APB2ENR. Смещение у него 0x18, базовый адрес для регистров RCC 0x40021000.
Чтобы удобно было работать с битами, создадим структуру:
typedef struct{  uint32_t  AFIOEN         : 1;  uint32_t                 : 1;  uint32_t  IOPAEN         : 1;  uint32_t  IOPBEN         : 1;  uint32_t  IOPCEN         : 1;  uint32_t  IOPDEN         : 1;  uint32_t  IOPEEN         : 1;  uint32_t                 : 2;  uint32_t  ADC1EN         : 1;  uint32_t  ADC2EN         : 1;  uint32_t  TIM1EN         : 1;  uint32_t  SPI1EN         : 1;  uint32_t                 : 1;  uint32_t  USART1EN       : 1;  uint32_t                 :17;} RCC_APB2ENR_b;

Чтобы потом не мучаться, сразу перечислим все адреса регистров
enum AddrRCC{  RCC_CR          = 0x40021000,  RCC_CFGR        = 0x40021004,  RCC_CIR         = 0x40021008,  RCC_APB2RSTR    = 0x4002100C,  RCC_APB1RSTR    = 0x40021010,  RCC_AHBENR      = 0x40021014,  RCC_APB2ENR     = 0x40021018,  RCC_APB1ENR     = 0x4002101C,  RCC_BDCR        = 0x40021020,  RCC_CSR         = 0x40021024};

теперь остается написать код для включения периферии
static void EnablePort(uint8_t port_name){  volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);  switch (port_name)  {    case 'A': apb2enr->IOPAEN = 1; break;    case 'a': apb2enr->IOPAEN = 1; break;    case 'B': apb2enr->IOPBEN = 1; break;    case 'b': apb2enr->IOPBEN = 1; break;    case 'C': apb2enr->IOPCEN = 1; break;    case 'c': apb2enr->IOPCEN = 1; break;    case 'D': apb2enr->IOPDEN = 1; break;    case 'd': apb2enr->IOPDEN = 1; break;    case 'E': apb2enr->IOPEEN = 1; break;    case 'e': apb2enr->IOPEEN = 1; break;  }}

При работе с регистрами не забываем про volatile, иначе после оптимизации компилятором долго будем искать ошибки и ругать разработчиков компилятора.
Тоже самое делаем для включения тактирвания другой периферии.
В итоге получился такой класс (не все перечислено)
STM32F1xx_RCC.h
#pragma once#include "stdint.h"namespace STM32F1xx{  class RCC  {  protected:    enum AddrRCC    {      RCC_CR          = 0x40021000,      RCC_CFGR        = 0x40021004,      RCC_CIR         = 0x40021008,      RCC_APB2RSTR    = 0x4002100C,      RCC_APB1RSTR    = 0x40021010,      RCC_AHBENR      = 0x40021014,      RCC_APB2ENR     = 0x40021018,      RCC_APB1ENR     = 0x4002101C,      RCC_BDCR        = 0x40021020,      RCC_CSR         = 0x40021024    };        typedef struct {      uint32_t  HSION          : 1;      uint32_t  HSIRDY         : 1;      uint32_t                 : 1;      uint32_t  HSI_TRIM       : 5;      uint32_t  HSI_CAL        : 8;      uint32_t  HSEON          : 1;      uint32_t  HSERDY         : 1;      uint32_t  HSEBYP         : 1;      uint32_t  CSSON          : 1;      uint32_t                 : 4;      uint32_t  PLLON          : 1;      uint32_t  PLLRDY         : 1;      uint32_t                 : 6;    } RCC_CR_b;    typedef struct {      uint32_t  SW             : 2;      uint32_t  SWS            : 2;      uint32_t  HPRE           : 4;      uint32_t  PPRE1          : 3;      uint32_t  PPRE2          : 3;      uint32_t  ADC_PRE        : 2;      uint32_t  PLLSRC         : 1;      uint32_t  PLLXTPRE       : 1;      uint32_t  PLLMUL         : 4;      uint32_t  USBPRE         : 1;      uint32_t                 : 1;      uint32_t  MCO            : 3;      uint32_t                 : 5;    } RCC_CFGR_b;    typedef struct    {      uint32_t  TIM2EN         : 1;      uint32_t  TIM3EN         : 1;      uint32_t  TIM4EN         : 1;      uint32_t                 : 8;      uint32_t  WWDGEN         : 1;      uint32_t                 : 2;      uint32_t  SPI2EN         : 1;      uint32_t                 : 2;      uint32_t  USART2EN       : 1;      uint32_t  USART3EN       : 1;      uint32_t                 : 2;      uint32_t  I2C1EN         : 1;      uint32_t  I2C2EN         : 1;      uint32_t  USBEN          : 1;      uint32_t                 : 1;      uint32_t  CANEN          : 1;      uint32_t                 : 1;      uint32_t  BKPEN          : 1;      uint32_t  PWREN          : 1;      uint32_t                 : 3;    } RCC_APB1ENR_b;    typedef struct    {      uint32_t  AFIOEN         : 1;      uint32_t                 : 1;      uint32_t  IOPAEN         : 1;      uint32_t  IOPBEN         : 1;      uint32_t  IOPCEN         : 1;      uint32_t  IOPDEN         : 1;      uint32_t  IOPEEN         : 1;      uint32_t                 : 2;      uint32_t  ADC1EN         : 1;      uint32_t  ADC2EN         : 1;      uint32_t  TIM1EN         : 1;      uint32_t  SPI1EN         : 1;      uint32_t                 : 1;      uint32_t  USART1EN       : 1;      uint32_t                 :17;    } RCC_APB2ENR_b;    typedef struct {      uint32_t  DMAEN          : 1;      uint32_t                 : 1;      uint32_t  SRAMEN         : 1;      uint32_t                 : 1;      uint32_t  FLITFEN        : 1;      uint32_t                 : 1;      uint32_t  CRCEN          : 1;      uint32_t                 :25;    } RCC_AHBENR_r;      public:    static void EnablePort(uint8_t port_name)    {      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);      switch (port_name)      {        case 'A': apb2enr->IOPAEN = 1; break;        case 'a': apb2enr->IOPAEN = 1; break;        case 'B': apb2enr->IOPBEN = 1; break;        case 'b': apb2enr->IOPBEN = 1; break;        case 'C': apb2enr->IOPCEN = 1; break;        case 'c': apb2enr->IOPCEN = 1; break;        case 'D': apb2enr->IOPDEN = 1; break;        case 'd': apb2enr->IOPDEN = 1; break;        case 'E': apb2enr->IOPEEN = 1; break;        case 'e': apb2enr->IOPEEN = 1; break;      }    }    static void DisablePort(char port_name)    {      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);      switch (port_name)      {        case 'A': apb2enr->IOPAEN = 0; break;        case 'a': apb2enr->IOPAEN = 0; break;        case 'B': apb2enr->IOPBEN = 0; break;        case 'b': apb2enr->IOPBEN = 0; break;        case 'C': apb2enr->IOPCEN = 0; break;        case 'c': apb2enr->IOPCEN = 0; break;        case 'D': apb2enr->IOPDEN = 0; break;        case 'd': apb2enr->IOPDEN = 0; break;        case 'E': apb2enr->IOPEEN = 0; break;        case 'e': apb2enr->IOPEEN = 0; break;      }    }    static void EnableAFIO()    {      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);      apb2enr->AFIOEN = 1;    }    static void DisableAFIO()    {      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);      apb2enr->AFIOEN = 0;    }        static void EnableI2C(int PortNumber)    {      switch (PortNumber)      {        case 1:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->I2C1EN = 1;          break;        }        case 2:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->I2C2EN = 1;          break;        }      }    }    static void EnableUART(int PortNumber)    {      switch (PortNumber)      {        case 1:        {          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);          apb2enr->USART1EN = 1;          break;        }        case 2:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->USART2EN = 1;          break;        }        case 3:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->USART3EN = 1;          break;        }      }    }        static void DisableUART(int PortNumber)    {      switch (PortNumber)      {        case 1:        {          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);          apb2enr->USART1EN = 0;          break;        }        case 2:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->USART2EN = 0;          break;        }        case 3:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->USART3EN = 0;          break;        }      }    }        static void EnableSPI(int PortNumber)    {      switch (PortNumber)      {        case 1:        {          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);          apb2enr->SPI1EN = 1;          break;        }        case 2:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->SPI2EN = 1;          break;        }      }    }    static void DisableSPI(int PortNumber)    {      switch (PortNumber)      {        case 1:        {          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast<RCC_APB2ENR_b*>(RCC_APB2ENR);          apb2enr->SPI1EN = 0;          break;        }        case 2:        {          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast<RCC_APB1ENR_b*>(RCC_APB1ENR);          apb1enr->SPI2EN = 0;          break;        }      }    }        static void EnableDMA()    {      volatile RCC_AHBENR_r* ahbenr = reinterpret_cast<RCC_AHBENR_r*>(RCC_AHBENR);      ahbenr->DMAEN = 1;    }        static void DisableDMA()    {      volatile RCC_AHBENR_r* ahbenr = reinterpret_cast<RCC_AHBENR_r*>(RCC_AHBENR);      ahbenr->DMAEN = 0;    }  };}


Теперь можно в main.cpp присоединить файл и пользоваться:
#include "STM32F1xx_RCC.h"using namespace STM32F1xx;int main(){  RCC::EnablePort('c');  return 0;}

Теперь можно и с портами поработать. GPIO


Открываем в документации раздел General-purpose and alternate-function I/Os. Находим Port bit configuration table:

Битами CNF[1:0] задается режим работы порта (аналоговый вход, цифровой вход, выход), биты MODE[1:0] отвечат за скорость работы порта в режиме выход.
Взглянем на регистры GPIOx_CRL и GPIOx_CRH (x=A, B, C,...)

видно, что биты идут последовательно:
CNF[1:0], MODE[1:0]
тогда создадим константы с режимами работы портов
enum mode_e{  ANALOGINPUT             = 0,  INPUT                   = 4,  INPUTPULLED             = 8,  OUTPUT_10MHZ            = 1,  OUTPUT_OD_10MHZ         = 5,  ALT_OUTPUT_10MHZ        = 9,  ALT_OUTPUT_OD_10MHZ     = 13,  OUTPUT_50MHZ            = 3,  OUTPUT_OD_50MHZ         = 7,  ALT_OUTPUT_50MHZ        = 11,  ALT_OUTPUT_OD_50MHZ     = 15,  OUTPUT_2MHZ             = 2,  OUTPUT_OD_2MHZ          = 6,  ALT_OUTPUT_2MHZ         = 10,  ALT_OUTPUT_OD_2MHZ      = 14,  OUTPUT                  = 3,  OUTPUT_OD               = 7,  ALT_OUTPUT              = 11,  ALT_OUTPUT_OD           = 15};

тогда метод для конфигурации будет выглядеть так:
// pin_number - номер портаvoid Mode(mode_e mode){  uint32_t* addr;  if(pin_number > 7)    addr = reinterpret_cast<uint32_t*>(GPIOA_CRH);  else    addr = reinterpret_cast<uint32_t*>(GPIOA_CRL);    int bit_offset;  if(pin_number > 7)    bit_offset = (pin_number - 8) * 4;  else    bit_offset = pin_number * 4;  uint32_t mask = ~(15 << bit_offset);  *addr &= mask;  *addr |= ((int)mode) << bit_offset;}

теперь можно сделать более удобные методы для выбора режима:
    void ModeInput()              { Mode(INPUT);         }    void ModeAnalogInput()        { Mode(ANALOGINPUT);   }    void ModeInputPulled()        { Mode(INPUTPULLED);   }    void ModeOutput()             { Mode(OUTPUT);        }    void ModeOutputOpenDrain()    { Mode(OUTPUT_OD);     }    void ModeAlternate()          { Mode(ALT_OUTPUT);    }    void ModeAlternateOpenDrain() { Mode(ALT_OUTPUT_OD); }

В документации находим адреса управляющих регистров для портов и перечислим
enum AddrGPIO{  PortA           = 0x40010800,  GPIOA_CRL       = 0x40010800,  GPIOA_CRH       = 0x40010804,  GPIOA_IDR       = 0x40010808,  GPIOA_ODR       = 0x4001080C,  GPIOA_BSRR      = 0x40010810,  GPIOA_BRR       = 0x40010814,  GPIOA_LCKR      = 0x40010818,  PortB           = 0x40010C00,  PortC           = 0x40011000,  PortD           = 0x40011400,  PortE           = 0x40011800,  PortF           = 0x40011C00,  PortG           = 0x40012000};

Долго думал использовать базовый адрес и смещения или абсолютные адреса. В итоге остановился на последнем. Это добавляет некоторые издержки, но в процессе отладки удобней находить в памяти.
Модернизируем метод
if(pin_number > 7)  addr = reinterpret_cast<uint32_t*>(GPIOA_CRH - PortA + PortAddr);else  addr = reinterpret_cast<uint32_t*>(GPIOA_CRL - PortA + PortAddr);

Возможно, у кого-то будет глаз дергаться, но красивее пока не придумал.
Чтобы перевести ножку в нужное логическое состояние, достаточно записать соответствующий бит в регистре ODRx. Например, так:
void Set(bool st){  uint32_t* addr;  addr = reinterpret_cast<uint32_t*>(GPIOA_ODR - PortA + PortAddr);  if(st)    *addr |= 1 << pin_number;  else  {    int mask = ~(1 << pin_number);    *addr &= mask;  } }

Также для управления состоянием можно воспользоваться регистрами GPIOx_BSRR.
По аналогии делаем методы для считывания состояния порта, методы для конфигурации и инициализации (не забываем включить тактирование). В итоге получился такой класс для работы с портами
STM32F1xx_Pin.h
#pragma once#include <stdint.h>#include "STM32F1xx_RCC.h"namespace STM32F1xx{  class Pin  {  public:    enum mode_e    {      ANALOGINPUT             = 0,      INPUT                   = 4,      INPUTPULLED             = 8,      OUTPUT_10MHZ            = 1,      OUTPUT_OD_10MHZ         = 5,      ALT_OUTPUT_10MHZ        = 9,      ALT_OUTPUT_OD_10MHZ     = 13,      OUTPUT_50MHZ            = 3,      OUTPUT_OD_50MHZ         = 7,      ALT_OUTPUT_50MHZ        = 11,      ALT_OUTPUT_OD_50MHZ     = 15,      OUTPUT_2MHZ             = 2,      OUTPUT_OD_2MHZ          = 6,      ALT_OUTPUT_2MHZ         = 10,      ALT_OUTPUT_OD_2MHZ      = 14,      OUTPUT                  = 3,      OUTPUT_OD               = 7,      ALT_OUTPUT              = 11,      ALT_OUTPUT_OD           = 15    };      private:    enum AddrGPIO    {      PortA           = 0x40010800,      GPIOA_CRL       = 0x40010800,      GPIOA_CRH       = 0x40010804,      GPIOA_IDR       = 0x40010808,      GPIOA_ODR       = 0x4001080C,      GPIOA_BSRR      = 0x40010810,      GPIOA_BRR       = 0x40010814,      GPIOA_LCKR      = 0x40010818,      PortB           = 0x40010C00,      PortC           = 0x40011000,      PortD           = 0x40011400,      PortE           = 0x40011800,      PortF           = 0x40011C00,      PortG           = 0x40012000    };      private:    int   pin_number;    int   PortAddr;      public:    Pin()                               { }    Pin(char port_name, int pin_number) { Init(port_name, pin_number); }    ~Pin()    {      Off();      ModeAnalogInput();    }  public:    void Init(char port_name, int pin_number)    {      this->pin_number = pin_number;      RCC::EnablePort(port_name);      switch (port_name)      {        case 'A': PortAddr = PortA; break;        case 'a': PortAddr = PortA; break;        case 'B': PortAddr = PortB; break;        case 'b': PortAddr = PortB; break;        case 'C': PortAddr = PortC; break;        case 'c': PortAddr = PortC; break;        case 'D': PortAddr = PortD; break;        case 'd': PortAddr = PortD; break;        case 'E': PortAddr = PortE; break;        case 'e': PortAddr = PortE; break;      }    }    void ModeInput()              { Mode(INPUT);         }    void ModeAnalogInput()        { Mode(ANALOGINPUT);   }    void ModeInputPulled()        { Mode(INPUTPULLED);   }    void ModeOutput()             { Mode(OUTPUT);        }    void ModeOutputOpenDrain()    { Mode(OUTPUT_OD);     }    void ModeAlternate()          { Mode(ALT_OUTPUT);    }    void ModeAlternateOpenDrain() { Mode(ALT_OUTPUT_OD); }    void NoPullUpDown()    {      uint32_t* addr;      if(pin_number > 7)        addr = reinterpret_cast<uint32_t*>(GPIOA_CRH - PortA + PortAddr);      else        addr = reinterpret_cast<uint32_t*>(GPIOA_CRL - PortA + PortAddr);      int bit_offset;      if(pin_number > 7)        bit_offset = (pin_number - 8) * 4;      else         bit_offset = pin_number * 4;      int mask = ~((1 << 3) << bit_offset);      *addr &= mask;    }        void Mode(mode_e mode)    {      uint32_t* addr;      if(pin_number > 7)        addr = reinterpret_cast<uint32_t*>(GPIOA_CRH - PortA + PortAddr);      else        addr = reinterpret_cast<uint32_t*>(GPIOA_CRL - PortA + PortAddr);            int bit_offset;      if(pin_number > 7)        bit_offset = (pin_number - 8) * 4;      else        bit_offset = pin_number * 4;      uint32_t mask = ~(15 << bit_offset);      *addr &= mask;      *addr |= ((int)mode) << bit_offset;    }    void Set(bool st)    {      uint32_t* addr;      addr = reinterpret_cast<uint32_t*>(GPIOA_ODR - PortA + PortAddr);      if(st)        *addr |= 1 << pin_number;      else      {        int mask = ~(1 << pin_number);        *addr &= mask;      }     }    void On()    {      uint32_t* addr;      addr = reinterpret_cast<uint32_t*>(GPIOA_ODR - PortA + PortAddr);      int bit_offset = pin_number;      *addr |= 1 << bit_offset;    }    void Off()    {      uint32_t* addr;      addr = reinterpret_cast<uint32_t*>(GPIOA_ODR - PortA + PortAddr);      int bit_offset = pin_number;      int mask = ~(1 << bit_offset);      *addr &= mask;    }    bool Get()    {      uint32_t* addr = reinterpret_cast<uint32_t*>(GPIOA_IDR - PortA + PortAddr);      int bit_offset = pin_number;      int mask = (1 << bit_offset);      bool ret_val = (*addr & mask);      return ret_val;    }  };};


Ну что, опробуем:
#include "STM32F1xx_Pin.h"using namespace STM32F1xx;Pin led('c', 13);int main(){  led.ModeOutput();  led.On();  led.Off();  return 0;}

Проходим дебагером и убеждаемся, что светодиод сначала загорается (после led.ModeOutput();), потом гаснет (led.On();) и снова загорается (led.Off();). Это связано с тем, что светодиод подклчен к ножке через линию питания. Поэтому, когда на выводе низкий уровень, светодиод загорается.

Не большие итоги


В данной статье я попытался (надеюсь, получилось) показать как можно немного упростить себе жизнь, сделать код более читаемым. Или наоборот как нельзя делать. Каждый решит сам.
Можно было просто написать враперы для CMSIS, но это не интересно.
Спасибо за уделенное время. Если интересно продолжение дайте знать.
Подробнее..

C enum lt-gt string? Легко

12.09.2020 20:07:24 | Автор: admin

Вот, скажем, один из самых популярных примеров. Можно сказать, классических. Сериализуются данные в, скажем, json. В структуре есть enum-поле, которое хочется сохранять в текстовом виде (а не числом). Всё. Стоп. Простого способа решить эту элементарную задачу на C++ не существует. (c)

... Но очень хочется.

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

Код далёк от совершенства (надеюсь,анонимуспоправит), но свою задачу выполняет. Может кому ипригодится:

Реализация
// enum_string.h#pragma once#define DECLARE_ENUM(T, values...)                                    \  enum class T { values, MAX };                                       \  char enum_##T##_base[sizeof(#values)] = #values;                    \  const char* T##_tokens[static_cast<__underlying_type(T)>(T::MAX)];  \  const char* const* T##_tmp_ptr = tokenize_enum_string(              \      const_cast<char*>(enum_##T##_base), sizeof(#values), T##_tokens,\      static_cast<__underlying_type(T)>(T::MAX));#define enum_to_string(T, value) \  (T##_tokens[static_cast<__underlying_type(T)>(value)])static const char* const* tokenize_enum_string(char* base,                                               int length,                                               const char* tokens[],                                               int size) {  int count = 0;  tokens[count++] = base;  for (int i = 1; i < length; ++i) {    if (base[i] == ',') {      base[i] = '\0';      if (count == size) {        return tokens;      }      do {        if (++i == length) {          return tokens;        }      } while (' ' == base[i]);      tokens[count++] = base + i;    }  }  return tokens;}static bool string_equals(const char* a, const char* b) {  int i = 0;  for (; a[i] && b[i]; ++i) {    if (a[i] != b[i]) {      return false;    }  }  return (a[i] == b[i]);}static int string_to_enum_int(const char* const tokens[], int max,                              const char* value) {  for (int i = 0; i < max; ++i) {    if (string_equals(tokens[i], value)) {      return i;    }  }  return max;}#define string_to_enum(T, value)     \  static_cast<T>(string_to_enum_int( \      T##_tokens, static_cast<__underlying_type(T)>(T::MAX), value))

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

Главная идея была в том, чтобы гарантировать биективность множества enum и его строкового эквивалента, а также сделать реализацию универсальной по количеству элементов (до свидания, вырвиглазный хардкодный макрос _NARG). Ну и, чтобы использование было максимально няшным.

пример использования
// main.cpp#include <iostream>#include "enum_string.h"DECLARE_ENUM(LogLevel,  // enum class LogLevel             Alert,     // LogLevel::Alert             Critical,  // LogLevel::Critical             Error,     // LogLevel::Error             Warning,   // LogLevel::Warning             Notice,    // LogLevel::Notice             Info,      // LogLevel::Info             Debug      // LogLevel::Debug             );int main() {  // serialize  LogLevel a = LogLevel::Critical;  std::cout << enum_to_string(LogLevel, a) << std::endl;  // deserialize  switch (string_to_enum(LogLevel, "Notice")) {    case LogLevel::Alert: {      std::cout << "ALERT" << std::endl;    } break;    case LogLevel::Critical: {      std::cout << "CRITICAL" << std::endl;    } break;    case LogLevel::Error: {      std::cout << "ERROR" << std::endl;    } break;    case LogLevel::Warning: {      std::cout << "WARN" << std::endl;    } break;    case LogLevel::Notice: {      std::cout << "NOTICE" << std::endl;    } break;    case LogLevel::Info: {      std::cout << "INFO" << std::endl;    } break;    case LogLevel::Debug: {      std::cout << "DEBUG" << std::endl;    } break;    case LogLevel::MAX: {      std::cout << "Incorrect value" << std::endl;    } break;  }  return 0;}

Как по мне, в дополнительном обьяснении не нуждается.

Также, залил на github.

Любезно приглашаю критиков на ревью.

Подробнее..
Категории: C++ , Enum , Serialization

Продолжение очередной статьи STM32 для начинающих. Интерфейсы

13.09.2020 02:19:21 | Автор: admin
Предыдущая публикация:
Очередная статья: STM32 для начинающих

И как этим пользоваться?


В предыдущей статье создали класс для работы с портами ввода-вывода, проверили. И что дальше? Зачем это все запихивать в класс?
Возьмем для примера простенький опрос кнопок:

Для этой схемы в простейшем случае опрос будет выглядеть так
int GetKey(){  volatile uint32_t* addr = reinterpret_cast<uint32_t*>(GPIOA_IDR);  uint32_t ret_val = *addr;  return ret_val & 0x0F;}

Но, если в схеме поменять порты, подсоединенные к кнопкам, то придется менять и функцию опроса. И так в каждом проекте. Это не всегда удобно. Хочется один раз написать, протестировать и пользоваться.
Перепишем эту функцию под ранее созданный класс:
int GetKey(Pin* p0, Pin* p1, Pin* p2, Pin* p3){  int ret_val = p0->Get() + (p1->Get() << 1) + (p2->Get() << 2) + (p3->Get() << 3);  return ret_val;}

Остается в главной программе инициализировать порты и передать в функцию:
...using namespace STM32F1xx;Pin key0('a', 0);Pin key1('a', 1);Pin key2('a', 2);Pin key3('a', 3);...int main(){  key0.ModeInput();  key1.ModeInput();  key2.ModeInput();  key3.ModeInput();  int key_code = GetKey(&key0, &key1, &key2, &key3);...  return 0;}

А где же интерфейсы?


А теперь представим, законились контроллеры серии f10x, но есть куча f030. По производительности и количеству выводов хватает, только надо хедер поменять для функции GetKey или воспользоваться #ifdef. Сделать глобальный заголовочный файл, в котором прописть тип используемого контроллера (что то типа #define STM32F030) и нагородить кучу дефайнов. Нет, не для этого создавались языки высокого уровня, что бы путаться в макросах!
Пойдем другой дорогой. Создадим класс, в котором перечислим виртуальные методы, необходимые нам по жизни для работы с портами:
iPin.h
#pragma onceclass iPin{public:  virtual void ModeInput()              = 0;  virtual void ModeAnalogInput()        = 0;  virtual void ModeInputPulled()        = 0;  virtual void ModeOutput()             = 0;  virtual void ModeOutputOpenDrain()    = 0;  virtual void Set(bool st) = 0;  virtual bool Get() = 0;  virtual void Reverse() { Set(!Get());}  void On()              { Set(true);  }  void Off()             { Set(false); }};


(те методы, которые приравнены 0, должны быть определены в производном классе!)
и будем его использовать как базовый в классе Pin:
...#include "iPin.h"...class Pin : public iPin...

тогда функция GetKey чуть изменится:
int GetKey(iPin* p0, iPin* p1, iPin* p2, iPin* p3){  int ret_val = p0->Get() + (p1->Get() << 1) + (p2->Get() << 2) + (p3->Get() << 3);  return ret_val;}

Теперь нам любой контроллер нипочем! Даже если это шинный расширитель, работающий по SPI или I2C. Последовательные интерфейсы рассмотрим в следующих статьях.

И что дальше?


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

CRM, мессенджер и штрих-коды продолжаем рассказывать об учебных проектах Технопарка

14.09.2020 12:17:21 | Автор: admin


Мы продолжаем рассказывать о выпускных проектах слушателей наших образовательных проектов. В этом выпуске мы расскажем про три проекта по дисциплине Разработка на С++ в Технопарке. Предыдущие публикации: один, два, три, четыре.

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


JunTracker CRM для школ дополнительного образования


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

CRM написали за семестр с нуля. Сделали многое, но кое-что не успели. В следующем семестре авторы сделают вторую версию с фронтендом на React и сервером на Go.


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


У каждого ученика есть личная карточка со статистикой:


Архитектура CRM выглядит так:


Фронтенд написан на JS, HTML и CSS. В нём использованы паттерны State и компоновщик. В шаблонизаторе использован синтаксис Django и паттерн фабрика. В сервере использован паттерн посредник. Для работы с базой данных PostgreSQL авторы написали обёртку.

Команда проекта: Марк Быховец, Михаил Трущелев, Тимофей Макаров, Андрей Маврин.

GitHub-репозиторий.

Видео с защиты проекта.

Настольный мессенджер JMICKHENGER


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


Мессенджер создан с использованием таких технологий:

  • OpenSSL.
  • Qt используется для отрисовки интерфейса, применён шаблон проектированиz MVC.
  • Boost.
  • MongoDB.
  • SQLite.

Общая архитектура системы:


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

Сетевая часть взаимодействует с удаленным сервером. Асинхронный клиент работает в отдельном выделенном потоке и написан с использованием Boost Asio. Клиент поддерживает TLS-соединение с сервером. Формат обмена данными JSON. Для обеспечения безопасности использован OpenSSL. Взаимодействие с сетевой частью происходит с помощью callbackов. Некоторые callbackи выполняются однократно после прихода ответа от сервера, другие же устанавливаются на всё время работы приложения и срабатывают при каждом получении определенной информации от сервера (аналог observer в Google Firestore). Для некоторой информации, которая редко обновляется, реализован кэш с использованием SQLite.

Асинхронный TCP-сервер тоже написан с использованием Boost Asio и OpenSSL. Сервер идентифицирует подключения с уникальными ID, и с помощью очереди запросов делегирует их выполнение экземпляру класса бизнес-логики. Также авторы с помощью библиотеки mongocxx написали класс-обёртку для работы с MongoDB. При его создании была использована библиотека mongocxx. Бизнес-логику можно условно разделить на три части: получение запроса от клиента в виде JSONа и его парсинг; работа с базой данных и, в некоторых случаях, проверка их правильности; формирование ответа клиенту. Кроме того, бизнес логику реализовали в виде динамической библиотеки, чтобы удобнее было объединять её с частями сервера.

Схема обмена данными между клиентом и сервером:


А так устроен обмен данными с базой:


Команда проекта: Константин Павлов, Сергей Алексеев, Николай Манзеев, Никита Щелканов.

GitHub-репозиторий.

Видео с защиты проекта.

Система штрих-кодирования


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

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

Схема работы:


Клиентское ПО позволяет генерировать штрих-коды и регистрировать в базе данных на сервере производимую продукцию. Исполнители с помощью беспроводных сканеров считывают штрих-коды, нанесённые на упаковку или сами изделия. Сканер отправляет информацию о считанном штрих-коде и просит БД зарегистрировать новое изделие.

Для работы системы необходим беспроводной сканер штрих-кодов, вроде такого:


Особенности встроенного ПО:

  • Суперсовременная IDE, поддерживающая исключительно язык С.
  • Частично задокументированный API на языке С для сканирования и работы с клавишами, дисплеем, лампочками, WiFi, внутренним хранилищем.
  • Есть утилита для загрузки прошивки в память.

Чтобы подружить сканер со своей системой штрих-кодирования, авторы проекта провели обратный инжиниринг IDE. Выяснилось, что устройство оснащено микроконтроллером STM32F103xx с ядром ARMv7-M. Также удалось найти статическую библиотеку с бизнес-логикой для обработки API-вызовов.

Инструменты, применявшиеся для разработки системы:

  • cc-arm-none-eabi (GNU ARM Toolchain) набор средств для разработки под архитектуру ARM (компиляторы С, С++, ASM; ld, objdump).
  • linker-script для разметки Flash и RAM.
  • cmake в качестве системы сборки + cmake toolchain file для кросс-компиляции под микроконтроллеры STM32 (спасибо репозиторию https://github.com/ObKo/stm32-cmake)/
  • Спецификации, документация, Stackoverflow, тематические форумы.

Пришлось написать такие модули:

  • Класс-обёртку для взаимодействия с пользователем через клавиши.
  • GUI для работы с пользователем.
  • Стек TCP-HTTP-JSON над реализованным в API сканера IP-уровнем.
  • Класс-обёртку над API сканера для сканирования штрих-кодов и получения результатов сканирования.

Далее команда написала клиентское ПО, которое позволяет:

  • Авторизовать пользователя.
  • Просматривать все модели изделий и информацию о них.
  • Просматривать все устройства.
  • Добавлять устройства.
  • Добавлять модели.
  • Генерировать штрих-код для модели.
  • Печатать штрих-код на принтере
  • Сохранять штрих-код в файл.

Это ПО написали с помощью:

  • QtCreator (графический интерфейс).
  • Boost (реализация сетевого взаимодействия и парсинг JSON).
  • Библиотеки для преобразования идентификатора изделия в штрих-код.

HTTP-сервер создан с использованием Boost.beast, MySqlCppConnector, Boost.thread и OpenSSL. Его задачи:

  1. Получение HTTP-запросов.
  2. Обращение к базе данных.
  3. Аутентификация пользователя/сканера.
  4. Регистрация пользователя/сканера.
  5. Генерирование уникальных QR-кодов.
  6. Отправка HTTP-ответов.

В роли БД выступает MySQL.

Архитектура сервера и базы данных:


Команда проекта: Даниил Черный, Николай Умрихин, Сергей Чепурной.

Видео с защиты проекта.



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

Перевод Предпочитайте Rust вместо CC для нового кода

21.09.2020 22:05:33 | Автор: admin

2019-02-07


  • Когда использовать Rust
  • Когда не использовать Rust
  • Когда использовать C/C++
  • Ложные причины использования C/C++
  • Приложение: моя история с C/C++
  • Приложение: хор

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


TL;DR: C/C++ имеет достаточно конструктивных недостатков и альтернативные инструменты разработки уже находятся в достаточно хорошей форме, поэтому я не рекомендую использовать C/C++ для новых разработок, за исключением особых обстоятельств. В ситуациях, когда вам действительно нужна мощь C/C++, используйте вместо него Rust. В других ситуациях вам все равно не следовало бы использовать C/C++ используйте что-нибудь другое.


Когда использовать Rust


Такие приложения, как критически важные для безопасности прошивки, ядра операционных систем, криптография, стеки сетевых протоколов и мультимедийные декодеры (в течение последних 30 лет или около этого) в основном были написаны на C и C++. Это именно те области, в которых мы не можем позволить себе быть пронизанными потенциально эксплуатируемыми уязвимостями, такими как переполнения буфера (buffer overflows), некорректные указатели (dangling pointers), гонки (race conditions), целочисленные переполнения (integer overflows) и тому подобное.


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


В данных прикладных областях мы использовали C/C++, а не другой язык, прежде всего по следующим причинам:


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

Rust отвечает всем этим критериям, но также устраняет возможность выстрелить себе в ногу. Он может устранить большинство ошибок безопасности, сбоев и параллелизма, встречающихся в программном обеспечении на основе Cи.


(Если вам не нужны все эти критерии тогда, смотрите следующий раздел.)


Я внимательно слежу за Rust с 2013 года и язык значительно повзрослел. По состоянию на конец 2018 года^1^ я думаю, что он достаточно зрелый, чтобы начать рассматривать его как вариант, если ваша организация спокойно относится к генерации не оптимального кода. Я был одним из первых пользователей C++11 в 2011 году, и мой текущий опыт работы с Rust лучше, чем опыт с C++11 GCC в то время. Что о чем-то говорит.


Почему 2018? Потому что теперь можно заниматься разработкой под "голое железо" и для встраиваемых систем (например, модификацией ядра), не полагаясь на нестабильные функции из ночной сборки набора инструментов Rust (nightly Rust toolchain). К тому же изменения в редакции 2018 являются превосходными.


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


Когда не использовать Rust


Rust выделяется там, где исторически господствовал C/C++, но в результате Rust требует от вас, чтобы вы думали о некоторых вещах, что и в C/C++. В частности, вы потратите время на рассмотрение стратегий выделения памяти. Для большинства приложений в 2019 году это напрасная трата усилий; просто сбросьте эту проблему на сборщик мусора и закончите на этом. Если вам не нужен точный контроль со стороны Rust над локальностью памяти и определенностью, у вас есть множество других вариантов.


Конкретный пример: если бы меня попросили написать вычислитель символьной алгебры (symbolic algebra evaluator), или параллельную постоянную структуру данных (concurrent persistent data structure) или что-нибудь еще, что выполняет тяжелые манипуляции с графами, то я, вероятно, обращусь к чему-то что имеет трассирующий сборщик мусора например, что-то другое, но не Rust. Но это не будет C++, где мне пришлось бы работать так же усердно, как в Rust, но с меньшими затратами. Я бы лично подтолкнул вас к Swift^2^, но Go, Typescript, Python и даже Kotlin/Java вполне разумный выбор.


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


Когда использовать C/C++


Вот несколько веских причин, по которым вы все равно можете выбрать C/C++:


  • Вы уверены, что ваш код никогда не подвергнется атакам, не подвержен атакам повреждения данных или на кого-то полагался. Типа, взлом прототипа на Arduino. Тогда вперед.


  • У вас есть нормативные или договорные требования для использования определенного языка. Хотя в этом случае вы, вероятно, выберете Ada, которая в первую очередь значительно менее подвержена ошибкам, чем C.


  • Ваша целевая платформа не поддерживается в Rust. Поскольку Rust поддерживает почти все, что связано с бэкэндом LLVM, включая множество платформ, которые не поддерживаются в GCC. Это довольно короткий список, но в настоящее время он включает, не поддерживаемые 68HC11 и 68000. (Rust поддерживается на MSP430, Cortex-M и т.д., поддержка AVR в процессе стабилизации). И если вы на телефоне, десктопе или сервере, который вы сами поддерживаете. Даже на мейнфрейме IBM System 390.


  • Вы ожидаете, что ваш компилятор/набор инструментов (toolchain) будет сопровождаться соглашением о коммерческой поддержке. Я не знаю, чтобы кто-нибудь предлагал такое для набора инструментов Rust. Я также не знаю, чтобы кто-нибудь предлагал его сейчас для GCC, когда был куплен CodeSourcery.


  • Вы ожидаете, что ваша система станет достаточно большой, чтобы производительность rustc стала для вас проблемой и вы ожидаете, что это произойдет быстрее, чем rustc смогут улучшить. Rustc компилируется медленнее, чем GCC. Команда внимательно следит за этим, и ситуация улучшается. Ваш опыт будет во многом зависеть от сложности вашего кода C++; один из моих проектов собирается в Rust быстрее, чем в GCC.


  • У вас есть большая кодовая база C++, которая экспортирует только C++ интерфейс, не является независимым от языка API (например, интерфейс extern "C", каналы (pipes) или RPC). Семантика C++ настолько сложна, что ни один язык не справится с ней должным образом. (Swift, возможно, подходит ближе всего.) Наличие подобной системы у вас в какой-то момент вас "укусит".



Ложные причины использовать C/C++


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


У C/C++ есть 30+ лет работы над компилятором, поэтому они будут быстрее/надежнее.


В основном я слышу это от людей, которые не работали над компиляторами. Это заблуждение.


Компиляторы C и C++ значительно улучшились за последние несколько десятилетий не потому, что мы постепенно разработали специальное понимание компиляции языка C, который был разработан, чтобы быть простым для компиляции, а потому, что мы стали лучше писать компиляторы.


Rust использует те же бэкэнд компилятора, оптимизаторы и генераторы кода, что и Swift и C++ (Clang). В большинстве случаев код работает так же быстро или быстрее, как сегодня скомпилированный C/C++.


Но у меня есть команда хорошо обученных программистов C/C++, у которых нет времени на изучение нового языка.


у меня для вас плохие новости. Ваши C/C++ программисты, вероятно, не так хорошо обучены, как вы думаете. Я работаю в месте, где все имеют очень твердое мнение о C++, которое они не хотят менять, работаю вместе с одними из лучших программистов на планете. И тем не менее, при проверке кода я все еще регулярно ловлю их на допущенных ошибках или коде полагающимся на неопределенное поведение (UB). Ошибки, которые они не допустили бы в Rust.


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


Но я пробовал использовать Rust и у меня ничего не получилось, поэтому это игрушечный язык.


Для C программистов, смысл в том, что они пытаются сделать сначала назойливый двусвязный список, который бывает невозможно выразить в безопасном Rust (но мы над этим работаем). Это достаточно распространенная жалоба, поэтому существует целый учебник, относящийся к ней, Learning Rust With Allly Too Many Linked Lists.


Его также очень сложно сделать правильно в C/C++ и я могу практически гарантировать, что вы написали такой один, но он просто не корректен для много поточной / SMP среды. Вот почему его также трудно выразить в Rust.


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


Приложение: моя история с C/C++


Я не тот парень, который пробовал C++ и думал, что это сложно. Я прошел долгий путь, чтобы прийти к этому.


Я использую Cи примерно с 1993 года, а C++ с 2002 года, оба более или менее постоянно. Я использовал их в разных окружениях, включая продакшн в Google, Qt, Chrome, графические демонстрационки, ядра ОС и встроенные микросхемы управления батареями. При создании микропрограммной компании Loon, я твердо выступал за C++ (версии С99); мы быстро перешли на C++11 (в 2011 году), и черт возьми, это окупилось. Позже я вложил много энергии в то, чтобы убедить другие команды в X использовать C++, а не Cи для их прошивок.


Когда Loon не смог найти работающий на "голом железе" C++ код crt0.o для тогда еще новых процессоров Cortex-M, я написал его; они все еще работают на нем. Я написал замену стандартной библиотеки C++, которая устраняет выделение памяти в куче и добавляет некоторые Rust-о подобные возможности. Я знаю стандарт C++ не обычно хорошо или, по крайней мере, я его изучил. Я "заржавел" в прошлом году или года два назад (каламбур).


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


Приложение: хор


Хор в который я собираю примеры умных людей согласных со мной. :-)


Крис Палмер (Chris Palmer): State of Software Security 2019: (выделено мной)


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

Эссе Алекса Гейнора (Alex Gaynor) The Internet Has a Huge C/C++ Problem and Developers Don't Want to Deal With It (в Vice, во всех местах):


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

(У него также есть отличные статьи в блоге по этой теме.)


Manish Goregaokar из Mozilla, пишет в ycombinator что fuzzing тестирование частей Rust кода в Firefox не выявило ошибок безопасности, но помогло найти ошибки в C++ коде, который он заменил:


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

Это было более или менее нашим опытом с fuzzing кода Rust в firefox, ух Фаззинг обнаружил множество паник (и отладочных ассертов / ассертов о безопасном переполнении). В одном из случаев он действительно обнаружил ошибку, которая не была замечена в аналогичном коде Gecko около десяти лет.

Copyright 2011-2019 Cliff L. Biffle Contact

Подробнее..
Категории: C++ , Rust , C , Rust c c++

О работе ПК ч.3 От включения до полной загрузки Windows 10

22.09.2020 08:08:59 | Автор: admin
Мы продолжаем разбираться как работает ПК на примере клавиатуры и Windows 10. В этой статье поговорим о том как происходит единение софта и железа.

Старт системы


Полностью компьютер выключен когда он отключен от питания и конденсаторы на материнской плате разрядились. До эры смартфонов мобильные телефоны часто глючили и если перезагрузка не лечила проблему, то приходилось доставать батарею и ждать 10 секунд, потому что сбрасывалось программное состояние ОС, в то время как чипы на материнской плате и контроллеры устройств оставались активными сохраняя состояние, драйвера ОС к ним просто реконнектились. 10 секунд время на разрядку конденсаторов, состояние чипов сбрасывается только при полном отключении.
Если же ПК подключен к розетке или батарее, то он находится в режиме Stand-By, это значит что по шине питания подаётся маленькое напряжения (5В) от которого запитываются некоторые чипы на материнке. Как минимум это системный контроллер, по сути это мини-компьютер запускающий большой компьютер. Получив уведомление о нажатии кнопки Power он просит блок питания/батарею подать больше напряжения и после инициализирует весь чип-сет, в том числе и процессор. Инициализация включает в себя перекачку кода и данных прошивки материнки (BIOS/UEFI) в оперативную память и настройку CPU на её исполнение.
Думать что кнопка Power это рубильник который подаёт электричество на CPU и тот начинает исполнять с заранее известного адреса прошивку BIOS неправильно. Возможно старые компьютеры так и работали. Кнопка включения находится на своей плате, вместе со светодиодами состояний и к материнке она подключается через специальный разъём. На картинке ниже видны контакты для кнопки Power, Reset, а также светодиодов с состоянием Power и чтения жёсткого диска. Нажатие кнопки включения переводится в сигнал на контакты материнки, откуда он достигает системный контроллер.


Контакты на материнке для подключения кнопки включения, светодиодов состояния Power, жёсткого диска и динамиков.


Плата ноутбука с кнопкой включения и светодиодом состояния

Cистемный контроллер обладает огромными полномочиями включать и выключать компьютер, исполнять код в режиме ядра. Помимо него могут быть и другие чипы со сравнимыми возможностями, такие как Intel Management Engine или AMD Secure Technology (часть CPU), которые так же работают когда компьютер выключен. Чип с Intel ME имеет в себе x86 CPU с операционной системой MINIX 3. Что он может делать:
  1. Включать и выключать компьютер, т.е. выполнять программы имея доступ ко всей вычислительной мощности, периферии машины и сети.
  2. Обходить ограничения файервола.
  3. Видеть все данные в CPU и RAM, что даёт доступ к запароленным файлам.
  4. Красть ключи шифрования и получать доступ к паролям
  5. Логировать нажатия клавиш и движения мыши
  6. Видеть что отображается на экране
  7. Вредоносный код в Intel ME не может быть детектирован антивирусом, потому как на такой низкий уровень он добраться не может
  8. И конечно же скрытно отправлять данные по сети используя свой стек для работы с сетью.

Это вызывает серьёзные вопросы безопасности, потому как он может быть хакнут или использовать в шпионских целях.

Если вы задумаете установить мощную видеокарту (Nvidia 2070 S) на офисный ПК, то просто вставить её недостаточно, потому как она требует питание в 600W, в то время как такой ПК имеет блок на ~500W. Первое что придёт в голову купить новый блок питания на 650W с отдельной линией для видеокарты. Но и здесь будут разочарования, потому как разъёмы материнки будут не совпадать с разъёмами БП, а если его отдельно воткнуть в розетку и подключить к видюхе тоже ничего не будет в блоке питания вентилятор не крутится и изображения нет. Так происходит, потому что БП должен получить сигнал от материнки на полное включение. Очевидное решение новая материнка с совместимыми разъёмами, однако она стоит ~$300. Есть решение проще, хоть оно и вызывает опасения пожаробезопасности. Берём скрепку, разгибаем и вставляем в зелёный (PS_ON) и один из чёрных пинов (COM). Теперь всё должно работать.

Поиск загрузчика ОС


Есть два вида прошивки материнки BIOS (Basic Input Output System) на старых машинах и UEFI (Unified Extensible Firmware Interface) на новых. Windows 10 поддерживает обе и абстрагирует различия между ними. UEFI правильней называть ОС чем прошивкой, потому как он предлагает больше возможностей, к примеру богатый графический интерфейс вместо текстового, наличие мышки, больший объём доступной памяти, улучшенная модель безопасности и валидации файлов ОС, взаимодействие с железом через API, вместо прерываний как в BIOS.


Пример экрана монитора BIOS.

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


Настройки BIOS (системное время, например), хранятся на другом чипе который как правило находится возле круглой батарейки, которая на самом деле является литиевым аккумулятором, подзаряжающимся во время работы ПК. Называется он CMOS, что означает Complementary Metal Oxide Semiconductor, а по-русски просто КМОП, что есть комплементарная структура металл-оксид-полупроводник.


Первым делом программа BIOS выполняет проверку подсистем, эта процедура называется POST Power On Self Test. Тест может быть сокращённый либо полный, это задаётся в настройках BIOS. Процитирую Википедию, что в себя включают эти тесты:
Сокращённый тест включает:
  1. Проверку целостности программ BIOS в ПЗУ, используя контрольную сумму.
  2. Обнаружение и инициализацию основных контроллеров, системных шин и подключённых устройств (графического адаптера, контроллеров дисководов и т. п.), а также выполнение программ, входящих в BIOS устройств и обеспечивающих их самоинициализацию.
  3. Определение размера оперативной памяти и тестирования первого сегмента (64 килобайт).

Полный регламент работы POST:
  1. Проверка всех регистров процессора;
  2. Проверка контрольной суммы ПЗУ;
  3. Проверка системного таймера и порта звуковой сигнализации (для IBM PC ИМС i8253 или аналог);
  4. Тест контроллера прямого доступа к памяти;
  5. Тест регенератора оперативной памяти;
  6. Тест нижней области ОЗУ для проецирования резидентных программ в BIOS;
  7. Загрузка резидентных программ;
  8. Тест стандартного графического адаптера (VGA или PCI-E);
  9. Тест оперативной памяти;
  10. Тест основных устройств ввода (НЕ манипуляторов);
  11. Тест CMOS
  12. Тест основных портов LPT/COM;
  13. Тест накопителей на гибких магнитных дисках (НГМД);
  14. Тест накопителей на жёстких магнитных дисках (НЖМД);
  15. Самодиагностика функциональных подсистем BIOS;
  16. Передача управления загрузчику.

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


Если всё прошло успешно, BIOS начинает процесс поиска загрузчика ОС. Для этого он начинает просматривать все подключенные к материнской плате жёсткие диски. Данные на физических дисках адресуются в единицах называемых сектор, обычно он 512 байт, однако современный стандарт 4096 байт. Установщик Windows в самый первый сектор на диске записывает специальный программный код и данные о разделах. Этот сектор называется Master Boot Record. Диск разбивается на разделы (partitions), отформатированный своей файловой системой. Максимум 4 раздела, каждый из который может быть расширенным (extended partition), такой можно рекурсивно делить на 4 раздела и теоретически их число не ограничено. Как только BIOS находит Master Boot Record он считывает оттуда код и передаёт ему управление. Этот код поочередно просматривает данные о разделах и находит тот который помечен как активный, в нём находится код загрузчика Windows (Это не раздел с C:\Windows\System32!), этот раздел называется system partition. Как правило он занимает 100Мб и скрыт от пользователя. В первом секторе этого раздела хранится загрузочный код, которому передаётся управление. Это volume boot sector, код в нём ищет файл Bootmgr, с которого и начинается процесс загрузки Windows. Файл Bootmgr создан через соединёние в один файлов Startup.com и Bootmgr.exe.

Процессор начинает свою работу в режиме который называется Реальный. Это режим совместимости, в нём CPU работает так же как и старые 16-bit процессоры, не имевшие поддержки виртуальной памяти и работавшие напрямую с физической памятью через 20-bit шину адресов, позволявшую адресовать 1Мб памяти. Простые MS-DOS программы выполнялись в этом режиме и имели расширение .COM. Первое что делает Startup.com (Bootmgr) переключает процессор в режим Защищённый, где под защитой понимается защита процессов друг от друга. В этом режиме поддерживается виртуальная память и 32х битные адреса, которыми можно адресовать 4Гб оперативной памяти. Следующим этапом Bootmgr заполняет таблицу виртуальных адресов на первые 16Мб RAM и включает трансляцию с виртуальных адресов в физические. В этом режиме и работает Windows. Поскольку на этом этапе подсистемы ОС ещё не созданы, Bootmgr имеет свою простую и неполную реализацию файловой системы NTFS, благодаря которой он находит BCD файл (Boot Configuration Data), в котором хранятся настройки параметров загрузки ОС. Вы можете редактировать его через утилиту BcdEdit.exe. В этих настройках BCD может быть указано, что Windows была в состоянии гибернации, и тогда Bootmgr запустит программу WinResume.exe, которая считывает состояние из файла Hyberfil.sys в память и перезапускает драйвера. Если BCD говорит, что есть несколько ОС, то Bootmgr выведет на экран их список и попросит пользователя выбрать. Если ОС одна, то Bootmgr запускает WinLoad.exe, этот процесс и выполняет основную работу по инициализации Windows:
  1. Выбирает соотвествующую версию ядра Windows. Можете думать о нём как о Windows10.exe, хотя на самом деле он называется NtOsKrnl.exe. Какие есть версии? Согласно википедии:
    • ntoskrnl.exe однопроцессорное ядро Windows. без поддержки режима PAE
    • ntkrnlmp.exe (англ. NT Kernel, Multi-Processor version) многопроцессорное ядро Windows. без поддержки режима PAE
    • ntkrnlpa.exe однопроцессорное ядро Windows с поддержкой режима PAE.
    • ntkrpamp.exe многопроцессорное ядро Windows с поддержкой режима PAE.

  2. Загружает HAL.dll (Hardware Abstraction Layer), который абстрагирует особенности материнки и CPU.
  3. Загружает файл шрифтов vgaoem.fon
  4. Загружает файлы в которых содержится инфомрация о представлениях даты времени, форматов чисел и пр. Эта функциональность называется National Language System.
  5. Загружает в память реестр SYSTEM, в нём содержится информация о драйверах которые надо загрузить. Информация о всех драйверах находится в HKLM\SYSTEM\CurrentControlSet\Services\. Драйвера которые надо загрузить имеют ключ start = SERVICE_BOOT_START (0). Об устройстве реестра мы поговорим в другой статье.
  6. Загружает драйвер файловой системы для раздела на котором располагаются файлы драйверов.
  7. Загружает драйвера в память, но пока не инициализирует их из-за круговых зависимостей.
  8. Подготавливает регистры CPU для выполнения ядра Windows выбранного на первом шаге NtOsKrnl.exe.

Во время загрузки драйверов WinLoad проверяет их цифровые подписи и если они не совпадают, то будет синий (BSOD) или зелёный (GSOD, для insider preview сборок) экран смерти.


Запуск на UEFI



Пример экрана загрузки UEFI

BIOS существует больше 30 лет и в попытках исправить его недостатки компания Intel в 1998 году создала стандарт Intel Boot Initiative, позже переименованный в EFI и в 2005 году пожертвованный организации EFI Forum. Недостатки BIOS:
Работает только в 16-битном режиме
Может адресовать только 1Mb оперативной памяти
Часто имеет проблемы совместимости
MBR ограничен только четырьмя главными разделами диска
Диск с ОС не может быть больше чем 2.2Tb.
Имеет очень ограниченные возможности для валидации загрузчика ОС.
На смену BIOS пришёл UEFI, по сути это миниатюрная ОС которая может работать и в 32-bit и в 64-bit. Для совместимости есть опция Compatibility Support Module, которая включается в настройках и эмулирует работу BIOS.


В UEFI загрузка происходит в родной для процессора битности 32 или 64, есть доступ ко всей памяти, поддерживается виртуальная память, включен Secure Boot и есть возможность запустить antimalware до начала загрузки ОС. Порядок загрузки ОС в UEFI:
  1. Инициализация и запуск Firmware, запуск чип-сета.
  2. POST тест, аналогично BIOS
  3. Загрузка EFI-драйверов и поиск диска подпадающего под требования EFI для загрузочного диска
  4. Поиск папки с именем EFI. Спецификация UEFI требует чтобы был раздел для EFI System Partition, отформатированный под файловую систему FAT, размером 100Мб 1Гб или не более 1% от размера диска. Каждая установленная Windows имеет свою директорию на этом разделе EFI\Microsoft.
  5. Читает из настроек UEFI сохранённых в NVRAM (энергонезависимая память) путь к файлу загрузчика.
  6. Находит и запускает EFI/Microsoft/Boot/BootMgrFw.efi.
  7. BootMgrFw.efi находит раздел реестра BCD, который хранится в отдельном файле с именем BCD. Из него он находит WinLoad.efi, который расположен в C:\Windows\System32\winload.efi.

Чтобы посмотреть содержимое раздела EFI System Partition откройте консоль с правами админа (WinKey+X => Windows PowerShell (Admin)) и выполните команды mountvol Z: /s, Z:, dir. CD меняет директорию.
Главное отличие компонентов BootMgr и WinLoad для UEFI от своих копий для BIOS тем что они используют EFI API, вместо прерываний BIOS и форматы загрузочных разделов MBR BIOS и EFI System Partition сильно отличаются.

Инициализация ядра


Напоминаю, что мы рассматриваем загрузку ПК в контексте работы клавиатуры, поэтому не стоит заострять внимание на всех этапах. Надо понять где в этом процессе находится клавиатура, важные для понимания этапы выделены.
На предыдущем этапе был запущен компонент WinLoad.exe/WinLoad.efi, который запускает NtOsKrnl.exe указав ему параметры загрузки в глобальной переменной nt!KeLoaderBlock (память режима ядра доступна всем процессам), которые WinLoad собрал во время своей работы. Они включают:
  1. Пути к System (загрузчик Windows) и Boot (C:\Windows\System32) директориям.
  2. Указатель на таблицы виртуальной памяти которые создал WinLoad
  3. Дерево с описанием подключенного hardware, оно используется для создания HKLM\HARDWARE ветки реестра.
  4. Копия загруженного реестра HKLM\System
  5. Указатель на список загруженных (но не инициализированных) драйверов участвующих в старте Windows.
  6. Прочая информация необходимая для загрузки.

Инициализация ядра Windows происходит в два этапа. До этого происходит инициализация Hardware Abstraction Layer, который в числе всего прочего настраивает контроллеры прерывания для каждого CPU.
На этой же стадии загружаются в память строки с сообщениями для BSOD, потому как в момент падения они могут быть недоступны или повреждены.
  • Первая фаза инициализации ядра:
    1. Слой Executive инициализирует свои объекты состояний глобальные объекты, списки, блокировки. Производится проверка Windows SKU (Stock Keeping Unit), примеры Windows 10 SKU Home, Pro, Mobile, Enterprise, Education.
    2. Если включен Driver Verifier, то он инициализируется.
    3. Менеджер памяти создаёт структуры данных, необходимые для работы внутренних API для работы с памятью (memory services), резервирует память для внутреннего пользования ядром.
    4. Если подключен отладчик ядра (kernel debugger) ему отправляется уведомление загрузить символы для драйверов загружаемых во время старта системы.
    5. Инициализируется информация о версии билда Windows.
    6. Старт Object Manager позволяет регистрировать именованные объекты к которым могут получать доступ по имени другие компоненты. Яркий пример мьютекс по которому приложение позволяет запустить единственный экземпляр. Здесь же создаётся храниться handle table, по которой устанавливается соответствие к примеру между HWND и объектом описывающим окно.
    7. Старт Security Reference Monitor подготавливает всё необходимое для создания первого аккаунта.
    8. Process Manager подготавливает все списки и глобальные объекты для создания процессов и потоков. Создаются процесс Idle и System (в нём исполняется Windows10.exe он же NtOsKrnl.exe), они пока не исполняются, потому как прерывания выключены.
    9. Инициализация User-Mode Debugging Framework.
    10. Первая фаза инициализации Plug and Play Manager. PnP это стандарт который реализовывается на уровне производителей периферии, материнских плат и ОС. Он позволяет получать расширенную информацию о подключенных устройствах и подключать их без перезагрузки ПК.

  • Вторая фаза инициализации ядра. Она содержит 51 шаг, поэтому я пропущу многие из них:
    1. По завершению первой фазы главный поток процесса System (NtOsKrnl.exe) уже начал исполнение. В нём производится вторая фаза инициализации. Поток получает самый высокий приоритет 31.
    2. HAL настраивает таблицу прерываний и включает прерывания.
    3. Показывается Windows Startup Screen, которая по умолчанию представляет из себя чёрный экран с progress bar.
    4. Executive слой инициализирует инфраструктуру для таких объектов синхронизации как Semaphore, Mutex, Event, Timer.
    5. Объекты для User-Mode Debugger проинициализированы.
    6. Создана symbolic link \SystemRoot.
    7. NtDll.dll отображена в память. Она отображается во все процессы и содержит Windows APIs.
    8. Инициализирован драйвер файловой системы.
    9. Подсистема межпроцессного общения между компонентами Windows ALPC проинициализирована. Можете думать о ней как о named pipes или Windows Communication Foundation для межпроцессного общения.
    10. Начинается инициализация I/O Manager, который создаёт необходимые структуры данных для инициализации и хранения драйверов подключенной к компьютеру периферии. Этот процесс очень сложный.
      Здесь же инициализируются компоненты Windows Management Instrumentation и Event Tracing for Windows (на него полагается Windows Performance Analyzer). После этого шага все драйвера проинициализированы.
    11. Запускается процесс SMSS.exe (Session Manager Sub System). Он отвечает за создание режима пользователя, в котором будет создана визуальная часть Windows.


Запуск подсистем SMSS, CSRSS, WinInit


SMSS.exe отличается от пользовательских процессов, это нативный процесс и это даёт ему дополнительные полномочия. SMSS.exe работает с ядром в обход Windows API, он использует то что называется Native API. Windows API обёртка вокруг Native API. SMSS.exe первым делом запускает подсистему Windows (CSRSS.exe Client Server Runtime Sub System) и заканчивает инициализацию реестра.

Процесс и потоки SMSS.exe помечены как критические, это значит что если они неожиданно завершаться, к примеру из-за ошибки, это приведёт к падению системы. Для общения с подсистемами, к примеру вызову API создающему новую сессию, SMSS создаёт ALPC-порт с именем SmApiPort. Загружаются из реестра переменные среды окружения, запускаются программы такие как Check Disk (autochk.exe, эти программы записаны в реестре HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\BootExecute). SMSS.exe запускается для каждой пользовательской сессии. Глобальные переменные (очередь сообщений например) у каждой сессии своя за счёт механизма виртуальной памяти. В Windows есть контексты потока, процесса и сессии. Каждый SMSS.exe запускает свой экземпляр подсистемы, на данный момент это только CSRSS.exe (Windows), в прошлом поддерживались операционные системы OS/2 (os2ss.exe) и POSIX (psxss.exe), но эта идея была неудачной. Самый первый SMSS.exe засыпает в ожидании процесса WinInit.exe. Остальные экземпляры вместо этого создают процесс WinLogon который показывает UI для входа.

WinInit.exe инициализирует подсистемы для создания графической оболочки Windows Station и десктопы, это не тот рабочий стол который вы видите, это иная концепция Windows. Далее он запускает процессы:
  1. Services.exe Services Control Manager (SCM) запускает сервисы и драйвера помеченные как AutoStart. Сервисы запускаются в процессах svchost.exe. Есть утилита tlist.exe, которая запущенная с параметром tlist.exe -s напечатает в консоли имена сервисов в каждом из svchost.exe.
  2. LSASS.exe Local System Authority.
  3. LSM.exe Local Session Manager.

WinLogon.exe загружает провайдеры аутентификации (credential providers), которые могут быть password, Smartcard, PIN, Hello Face. Он порождает процесс LogonUI.exe который и показывает пользователю интерфейс для аутентификации, а после валидирует введённые данные (логин и пароль, PIN).

Если всё прошло успешно, то WinLogon запускает процесс указанный в ключе реестра HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\WinLogon\Userinit. По умолчанию это процесс UserInit.exe, который:
  1. Запускает скрипты указанные в реестрах:
    • HKCU\Software\Policies\Microsoft\Windows\System\Scripts
    • HKLM\SOFTWARE\Policies\Microsoft\Windows\System\Scripts
  2. Если групповая политика безопасности определяет User Profile Quota, запускает %SystemRoot%\System32\Proquota.exe
  3. Запускает оболочку Windows, по умолчанию это Explorer.exe. Этот параметр конфигурируется через реестр:
    • HKCU\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell
    • HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell

WinLogon уведомляет Network Provider о залогинившемся пользователе, на что тот восстанавливает и подключает системные диски и принтеры сохранённые в реестре. Network Provider представляет из себя файл mpr.dll из системной папки, который хостится в процессе svchost.exe, т.е. сервис Windows.

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


Где здесь клавиатура?


Во время запуска ядро Windows считывает из реестра информацию о контроллере системной шины, как правило это шина PCI (реже MSI), к ней подключены контроллеры портов ввода-вывода, в том числе и USB, PS/2. Информация о нём записывается во время установки Windows. Система загружает для него драйвер и рекурсивно обходит все порты так же загружая для каждого из них свой драйвер. Драйвера могут комбинироваться в узлы (driver node), к примеру драйвер клавиатуры, будет соединён с драйвером порта PS2. А вот порт USB сложнее сначала драйвер порта, потом драйвер для работы с протоколом HID и только потом клавиатура.

Каждый порт контроллируется своим чипом, который мониторит подключение, принимает/отправляет сигналы между CPU и устройством. Если чип-сет Южный мост не встроен в CPU, как это часто делают в ноутбуках, а существует отдельным чипом на материнке, то правильней говорить: сигнал между Южным мостом и контроллером порта. Чип контроллирующий порт имеет выделенную линию с контроллером прерываний (PIC или APIC), по которой он может попросить обратить на себя внимание CPU, к примеру считать данные от клавиатуры (порт PS/2, с USB другая история). Поскольку ОС загрузила для порта драйвер, она может отдавать ему команды, читать и отправлять данные. В нашем примере был загружен драйвер из C:\Windows\System32\i8042prt.sys. Давайте вспомним предыдущую статью. В старых компьютерах с PIC на чипе Intel 8259 было 15 линий прерываний, где клавиатура была подключена к ножке IRQ1, таймер IRQ0, а мышка к IRQ12, который на самом деле был пятой ножкой второго чипа 8259, который мультиплексировал свои прерывания через ножку IRQ2 первого контроллера. В современных PIC могут быть 255 контактов для сигналов прерываний. Во время загрузки ОС программирует APIC/PIC возвращать определённое число когда скажем пришло прерывание от порта клавиатуры или USB и по этому номеру CPU находит в таблице векторов прерываний функцию которую надо выполнить. Номер прерываний определяют HAL и PlugnPlay Manager. Контроллер прерываний ищет сигнал на своих ножках в определённом порядке, к примеру в бесконечном цикле проверяет напряжение на ножках от 1 до MAX_PIN. Этот порядок определяет приоритет, к примеру клавиатура будет замечена раньше мышки, а таймер раньше клавиатуры. Чтобы не зависеть от особенностей работы контроллеров прерываний Windows абстрагирует концепцию IRQ (Interrupt Request) в IRQL (Interrupt Request Level). Будь у контроллера прерываний хоть 15 хоть 255 линий они все будут отображены на 32 IRQL для x86 и 15 IRQL для x64 и IA64.
Что означают приоритеты IRQL:
  1. High когда происходит краш системы, обычно это вызов функции KeBugCheckEx.
  2. Power Fail не используется. Изначально был придуман для Windows NT.
  3. Interprocessor Interrupt нужен отправить запрос другому CPU на мультипроцессорной системе выполнить действие, например обновить TLB cache, system shutdown, system crash (BSOD).
  4. Clock нужен чтобы обновлять системные часы, а так же вести статистику сколько времени потоки проводят в режиме пользователя и ядра.
  5. Profile используется для real-time clock (local APIC-timer) когда механизм kernel-profiling включен.
  6. Device 1 Device N прерывания от устройств I/O. Во время прерывания данные от клавиатуры, мыши и других устройств считываются в отдельные буфера и сохраняются в объектах типа DPC (Deferred Procedure Call), чтобы обработать их позже и дать возможность устройствам переслать данные. После приоритет снижается до Dispatch DPC
  7. Dispatch DPC как только данные от устройств получены можно начинать их обрабатывать.
  8. APC Asynchronous Procedure Call. Через этот механизм вы можете исполнить код когда поток будет спать вызвав WaitForSingleObject, Sleep и другие.
  9. Passive/Low здесь исполняются все приложения в User Mode.

Если вы всегда программировали в режиме пользователя, то никогда не слышали про IRQL, потому что все пользовательские программы выполняеются с приоритетом Passive/Low (0). Как только происходит событие с большим уровнем приоритета (событие от клавиатуры, таймер планировщика потоков), процессор сохраняет состояние прерванного потока, которое представляет из себя значения регистров CPU, и вызывает диспетчер прерываний (interrupt dispatcher, просто функция), который повышает приоритет IRQL через API KeRaiseIrql в HAL и вызывает непосредственно сам код обработчика (interrupts service routine). После этого IRQL CPU понижается до прежнего уровня через функцию KeLowerIrql и прерванный поток начинает обработку с того же места где его прервали. На этом механизме основан планировщик потоков. Он устанавливает таймер, который с определённым интервалом (квант времени) генерирует прерывание с приоритетом DPC/Dispatch (2) и в своей interrupts service routine по определённому алгоритму назначает новый поток на исполнение.

Механизм IRQL реализовывается на уровне софта в Hardware Abstraction Layer (HAL.dll), а не железа. В Windows системах есть драйвер шины (bus driver), который определяет наличие устройств подключенных к шинам PCI, USB и др. и номера прерываний которые могут быть назначены каждому устройству. Драйвер шины сообщает эту информацию Plug and play manager, который уже решает какие номера прерываний назначить каждому устройству. Далее арбитр прерываний внутри PnP Mgr (PnP interrupt arbiter) устанавливает связи между IRQ и IRQL.

Когда приходит прерывание от клавиатуры, любой исполняемый в данный момент поток (это может быть ваша программа) назначается на его обработку. Interrupt dispatcher повышает приоритет IRQL CPU до одного из уровней Device1-DeviceN. После этого менеджер виртуальной памяти не сможет найти страницу если она не загружена в RAM (не сможет обработать Page Fault), планировщик потоков не сможет прервать выполнение, потому что они все работают с меньшим уровнем IRQL. Главная задача драйвера клавиатуры в этот момент считать полученные данные и сохранить их для дальнейшей обработки. Данные записываются в объект типа _DPC (Deferred Procedure Call), который сохраняется в список DPC потока (что-то вроде std::list<DPC>, в ядре ОС вместо массивов используются связанные списки). Как только прерывания от всех внешних устройств обработаны, IRQL потока понижается до уровня DPC в котором и производится обработка отложенных процедур (DPC). В коде обработчика DPC для клавиатуры вызывается функция из драйвера клавиатуры Kbdclass.sys:

VOID KeyboardClassServiceCallback(  _In_    PDEVICE_OBJECT       DeviceObject,  _In_    PKEYBOARD_INPUT_DATA InputDataStart,  _In_    PKEYBOARD_INPUT_DATA InputDataEnd,  _Inout_ PULONG               InputDataConsumed);

Так вот, драйвер клавиатуры (kbdclass.sys) получает данные от порта (USB, PS2) через прерывание и записывает их через WriteFile, компонент внутри ядра Windows просыпается, считывает их используя API ReadFile и добавляет в очередь сообщений с клавиатуры. API для работы с файлом могут использоваться для чтения данных с драйверов. С этого момента начинается обработка данных стеком ввода Windows, об этом в следующей статье.

Если у вас есть ПК с PS2 портом и вы умеете пользоваться WinDbg в режиме ядра, то можете легко найти обработчик прерываний клавиатуры напечатав команду !idt, которая выведет на экран всю таблицу векторов прерываний. Прерывание вклинивается в ход выполнения программы, слово вектор здесь подразумевает направление, направление исполнения программы. WinDbg был сделан специально для отладки Windows, самая последняя версия называется WinDbgX. Он имеет текстовый интерфейс, который отпугивает людей привыкших к Visual Studio, однако предоставляет гораздо больше возможностей, в частности исполнение скриптов. Прерывание фиолетового порта PS2 выделено красным. Функция которая его обрабатывает называется I8042KeyboardInterruptService, которая находится в файле i8042prt.sys.

BOOLEANI8042KeyboardInterruptService(  IN  PKINTERRUPT Interrupt,  IN  PVOID Context  );Routine Description:    This is the interrupt service routine for the keyboard device when    scan code set 1 is in use.Arguments:    Interrupt - A pointer to the interrupt object for this interrupt.    Context - A pointer to the device object.Return Value:    Returns TRUE if the interrupt was expected (and therefore processed);    otherwise, FALSE is returned.


Сейчас возникает вопрос, откуда у обработчика прерываний аргумент? Кто его передаёт? Ведь CPU ничего не знает о нём. Если поставите в неё breakpoint, то удивитесь ещё больше увидев несколько функций выше по стеку:

0: kd> kC
# Call Site
00 i8042prt!I8042KeyboardInterruptService
01 nt!KiCallInterruptServiceRoutine
02 nt!KiInterruptSubDispatch
03 nt!KiInterruptDispatch
04 nt!KiIdleLoop


Объяснение здесь простое это не та функция которая сохранена в регистре IDT процессора. То что вы видите на картинке выше на самом деле объекты типа _KINTERRUPT. В таблице прерываний сохранён специальный ассемблерный код (nt!KiIdleLoop), который знает как найти объект описывающий прерывание в памяти. Что же интересного есть в нём?
  1. Указатель на объект представляющий драйвер в памяти.
  2. Указатель на функцию i8042prt!I8042KeyboardInterruptService, которая и вызывает код считывающий данные из порта PS2 через ассемблерную команду IN AL, 0x60 сохранить значение из порта номер 0x60 в регистре AL.
  3. Функция dispatcher ей передаётся указатель функцию из пункта 2 и она вызывает её.
  4. Состояние регистров CPU. Перед вызовом прерывания состояние CPU будет сохранено сюда, и отсюда же будет восстановлено.
  5. Приоритет прерывания. Не тот который определяет контроллер прерываний, а тот который Windows считает нужным. Это IRQL (Interrupt Request Level) абстракция над IRQ.

Как только обработчик прерываний клавиатуры будет вызван, он уведомит драйвер клавиатуры о полученных данных, после чего будет уведомлено ядро ОС, которое обработав данные отправит их дальше по стеку ввода, где они могут быть доставлены приложению, которое на них отреагирует, или перед этим в обработчик языков (азиатские иероглифы, автокоррекция, автозаполнение).
Ядро ОС напрямую не взаимодействует с драйвером клавиатуры, для этих целей используется PlugnPlay Manager. Этот компонент предоставляет API IoRegisterPlugPlayNotification, который вызовет предоставленную callback-функцию когда устройство будет добавлено или удалено.

Пару слов о USB


Ознакомление с работой порта USB потребовало бы отдельной статьи описывающей его работу и плюс описание обработки данных HID на Windows. Это очень сильно усложнило бы материал, к тому же уже есть хорошие статьи по теме, поэтому PS2 идеальный пример из-за своей простоты.

USB создавался как универсальный порт для всех устройств, будь то клавиатура, фотоаппарат, сканнер, игровой руль с педалями, принтер и пр. Вдобавок он поддерживает вложенность портов USB материнки => монитор с USB => клавиатура с USB к которой подключена мышка, флешка и USB-hub к которому подключен жёсткий диск. Взглянув на контакты USB 2.0 вы увидите что они не заточены под передачу каких-то определённых данных, как у PS2. Их всего четыре витая пара для передачи битов данных, плюс и минус питания.


Провода кабеля USB 2.0

USB 3.0 быстрее за счёт дополнительных пяти контактов. Как видите там нету линии CLOCK для синхронизации, поэтому логика передачи данных сложнее. Слева USB 2.0 и справа USB 3.0 для сравнения.
Все данные передаются через протокол HID (Human Interface Device), который описывает форматы, порядок взаимодействия и передачи данных и всё остальное. Стандарт USB 2.0 занимает 650 страниц, документ HID Class Specification, описывающий работу устройств (мыши, клавиатуры и пр) 97 страниц, их рекомендуется изучить если вы работаете с USB.

Первым делом подключенное устройство должно рассказать о себе, для этого оно отправляет несколько структур данных, в которых указывается ID устройства и ID производителя по которым PlugnPlay manager может найти в реестре информацию, загрузить и соединить драйвера. USB устройства пассивны, т.е. хост должен сам с определённым интервалом проверять наличие данных. Частота опроса и размер пакета данных задаются в одном из дескрипторов устройства USB. Максимальный размер пакета 64 байта, что для информации о нажатых клавишах более чем достаточно.

В Windows есть встроенная поддержка HID, она не такая простая как связь драйвера порта PS2 с драйвером клавиатуры, потому что драйвер HID должен уметь обрабатывать все поддерживаемые протоколом сценарии. Вне зависимости от провайдера данных порты PS2, USB или Remote Desktop или виртуальная машина на самом верху driver node будет находится Kbdclass, от которого ядро ОС и будет получать информацию. Уведомления о подсоединении клавиатуры будет обрабатываться через PlugnPlay Manager, так что для ядра Windows не имеет значение какой порт или источник данных устройства используется используется.
Подробнее..

Recovery mode Древний костыль на старом костыле

23.09.2020 20:15:33 | Автор: admin

Начну без обиняков, как-то раз меня постигло откровение(ну не сильно мощное скажу по-честному) и возникла идея напечатать программу которая передает изображение с клиента на сервер. Достаточно просто да? Ну для программиста со стажем так и будет. Условия просты - не использовать сторонние библиотеки. В принципе немного сложнее, но если учесть что придется разбираться и искать примеры, ну такое себе занятие. Я решил, что эта задача мне по плечу. Плюс желательно чтобы было кода столько, чтобы его можно было запостить на форуме, в случае если понадобится помощь. В первую очередь мой взгляд пал на FTP, к слову ОС в которой разрабатывается Windows. Плюс FTP в том, что можно через него передать не только изображение, а любой файл. Скачав Filezilla Server, расшарив одну директорию на чтение/запись и создав юзера с паролем, попробовал подключится Filezilla Client все работало. Создал простенький пример кода на С/С++:

#include <iostream>void main(){FILE* fs;fopen_s(&fs, "1.txt", "w");if (fs){    fwrite("user\r\npassword\r\nsend D:\\share\\1.txt\r\nbye", 1, sizeof("user\r\npassword\r\nsend D:\\share\\1.txt\r\nbye"), fs);    fwrite("\000", 1, sizeof("\000"), fs);    fclose(fs);}system("ftp -s:1.txt 127.0.0.1");}

Если мне не изменяет память, то на локалхосте все работало, а при передаче по сети возникала ошибка в строчке с send. Что здесь удобно а)коротко б)не нужно устанавливать клиент, а использовать уже встроенную тулзу для ftp от майкрософта. Хотя по-мойму ее надо активировать через программы и компоненты. Если вы разберетесь в чем проблема данного метода и напишите в комментарии, будет отлично.

Не найдя ответа на куче форумов, я оставил данный код и решил использовать интерфейс для сетей сокеты. У меня уже был опыт передачи массива char'ов для другой программы. Кстати можете почитать у Таненбаума, Компьютерные сети, в главе про транспортный уровень. Там есть пример клиента и сервера, правда не для соединения "много клиентов - один сервер", а только "один клиент - один сервер". Поскольку передача идет через Интернет, то нужно зашифровать хоть как-то данные. Для этого используется блочный шифр - сеть Фейстеля. Плюсом на сервере надо сделать несколько(больше одного клиента) клиентов. Для этого воспользуемся Thread'ами, изображение для передачи будет брать скриншот экрана с клиента шифроваться и передаваться на сервер, на котором будет расшифровано и сразу же выведено на экран через дефолтную программу для открытия *.tga изображения.

Код сервера:

#include <iostream>#include <WinSock.h>#pragma comment (lib,"WS2_32.lib")#include <fstream>#include <algorithm>#include <string>#include <iterator>#include <vector>void error(const char* msg){    //perror(msg);    std::cout<<'\n'<<WSAGetLastError();    WSACleanup();    std::cin.ignore();    exit(1);}void bzero(char*buf, int l){    for (int i = 0; i < l; i++)        buf[i] = '\0';}struct arg_s{    unsigned char* buffer2;    bool exit;};char** buffer;struct arg_sa{    struct arg_s* lalk;    int current;};#define type struct arg_saint sockfd, * newsockfd;//слушающий и массив клиентских сокетовint buflen2 = 10292000;//максимальный размер изображения в байтах для RGBA*Width*Heightstruct sockaddr_in *cli_addr;int* clilen;int currentclient,cc;//сс-клиент по счету(для записи инкремента имени файла клиента изображения)typedef unsigned long long uint64_t;typedef unsigned int uint32_t;#define N 8//размер блока#define F32 0xFFFFFFFFuint32_t RK[N];//раундовые ключи#define size64 sizeof(uint64_t)#define ROR(x,n,xsize)((x>>n)|(x<<(xsize-n)))#define ROL(x,n,xsize)((x<<n)|(x>>(xsize-n)))#define RKEY(r)((ROR(K,r*3,size64*8))&F32)const uint64_t K = 0x96EA704CFB1CF671;//ключ шифрованияstruct hostent* server;uint32_t F(uint32_t subblk, uint32_t key){    return subblk + key;//функция шифрования}void createRoundKeys(){    for (int i = 0; i < N; i++)        RK[i] = (ROR(K, i * 8, size64 * 8)) & F32;}uint64_t decrypt(uint64_t c_block)//расшифровка блоков сетью фейстеля{    //select subblocks    uint32_t left = (c_block >> 32) & F32;    uint32_t right = c_block & F32;    uint32_t left_, right_;//subblock in the end of round    for (int r = N - 1; r >= 0; r--)    {        uint32_t fk = F(left, RK[r]);        left_ = left;        right_ = right ^ fk;        if (r > 0)//swap places to next round        {            left = right_;            right = left_;        }        else //last round not swap        {            left = left_;            right = right_;        }    }    //collect subblock in block    uint64_t block = left;    block = (block << 32) | (right & F32);    return block;}void session_(LPVOID args)//функция потока ля каждого клиента{    int current = currentclient++;    bzero((char*)&(cli_addr[current]), sizeof(&(cli_addr[current])));    newsockfd[current] = accept(sockfd, (struct sockaddr*)&(cli_addr[current]), &(clilen[current]));    if (newsockfd[current] < 0)    {        error("Error on accept\n");    }    char* s = new char[100];    int n = recv(newsockfd[current], s, 100, 0);    int buflen2 = atoi(s);//получаем число байтов изображения    FILE* f;    std::string name = "Screen";    cc++;    _itoa_s(cc, s, 100, 10);    name += s;    name += ".tga";    fopen_s(&f,name.c_str(), "wb");//создаем файл изображения с увеличиваещимся на 1 именем, чтобы не перезаписать    if (f != NULL)    {        unsigned char tgaHeader[12] = { 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0 };        unsigned char header[6];        n = recv(newsockfd[current], buffer[current], sizeof(tgaHeader), 0);        fwrite((unsigned char*)buffer[current], 1, sizeof(tgaHeader), f);        bzero(buffer[current], buflen2);        n = recv(newsockfd[current], buffer[current],sizeof(header), 0);        fwrite((unsigned char*)buffer[current], 1, sizeof(header), f);//записали хидеры        bzero(buffer[current], buflen2);        n = recv(newsockfd[current], buffer[current], buflen2, 0);//получили байты самого изображения        //        //расшифровка байтов        createRoundKeys();        unsigned long long id;        std::vector<uint64_t>* plaintext = new std::vector<uint64_t>();        int i = 0;        while (i<buflen2)        {            memcpy(&id, (buffer[current]) + i, N);            plaintext->push_back(decrypt(id));            i += 8;        }        std::cout << "i=" << i << std::endl;        i = 0;        char str_[N + 1];        memset(str_, 0, N);        str_[N] = '\0';        for (std::vector<uint64_t>::iterator it = plaintext->begin(); it != plaintext->end(); ++it)        {            memcpy(str_, &*it, N);            fwrite((unsigned char*)str_, sizeof(unsigned char), N/*strlen(str_)*/, f);            i += 8;        }        std::cout << "i=" << i << std::endl;        //конец рашифровки байтов        //fwrite((unsigned char*)buffer[current], sizeof(char), buflen2, f);        fclose(f);    }    system(name.c_str());//открываем изображение *.tga встроенным редактором}int main(){    cc = 0;    WSADATA ws = { 0 };    if (WSAStartup(MAKEWORD(2, 2), &ws) == 0)    {        currentclient = 0;        int maxclients = 2;//максимальное число клиентов        cli_addr = new struct sockaddr_in[maxclients];        clilen = new int[maxclients];        buffer = new char* [maxclients];        for (int i = 0; i < maxclients; i++)        {            clilen[i] = sizeof(cli_addr[i]);        }        sockfd = socket(AF_INET, SOCK_STREAM, 0);//tcp сокет        if (sockfd < 0)            error("ERROR opening socket");        struct sockaddr_in serv_addr;        bzero((char*)&serv_addr, sizeof(serv_addr));        serv_addr.sin_family = AF_INET;        serv_addr.sin_addr.s_addr = INADDR_ANY;        int port = 30000;//порт        serv_addr.sin_port = htons(port);        if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)            error("ERROR on binding");        if (listen(sockfd, 10) < 0)            error("ERROR listen");        HANDLE* thread;//массив потоков для каждого клиента отдельный        struct arg_sa* args;        while (true)        {            newsockfd = new int[maxclients];            thread = (HANDLE*)malloc(sizeof(HANDLE) * maxclients);            args = new struct arg_sa[maxclients];            for (int i = 0; i < maxclients; i++)            {                args[i].lalk = new struct arg_s();                buffer[i] = new char[buflen2];            }            int i = -1;            while (++i < maxclients)            {                Sleep(1);                args[i].current = i;                args[i].lalk->exit = false;                thread[i] = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)(session_), args, 0, 0);            }                for (int i = 0; i < maxclients; i++)                    WaitForSingleObject(thread[i], INFINITE);//ждем завершения всех потоков            i = -1;            while (++i < maxclients)            {                shutdown(newsockfd[i], 0);                TerminateThread(thread[i], 0);            }            //delete[] newsockfd;            //free(thread);            currentclient = 0;            for (int i = 0; i < maxclients; i++)            {                //delete args[i].lalk;                //delete[] args[i].lalk->buffer;            }            //delete[] args;        }        shutdown(sockfd, 0);        WSACleanup();        return 0;    }    std::cin.ignore();}

Вкратце в вечном цикле создаются потоки для каждого клиента и ждут accept пока клиенты подключится. После чего WaitForSingleObject ждет пока они все передадут. У каждого клиента свой сокет и свой буфер передачи. То есть на сервере M+1 сокет, где M количество клиентов. После завершения всех передач, всё повторяется.

Теперь рассмотрим клиент:

#include <iostream>#include <WinSock.h>#include <vector>#pragma comment (lib,"WS2_32.lib")void error(const char* msg){    //perror(msg);    std::cout << '\n' << WSAGetLastError();    WSACleanup();    std::cin.ignore();    exit(1);}void bzero(char* buf, int l){    for (int i = 0; i < l; i++)        buf[i] = '\0';}typedef unsigned long long uint64_t;typedef unsigned int uint32_t;#define N 8#define F32 0xFFFFFFFFuint32_t RK[N];//раундовые ключи#define size64 sizeof(uint64_t)#define ROR(x,n,xsize)((x>>n)|(x<<(xsize-n)))#define ROL(x,n,xsize)((x<<n)|(x>>(xsize-n)))#define RKEY(r)((ROR(K,r*3,size64*8))&F32)const uint64_t K = 0x96EA704CFB1CF671;//ключ шифрованияvoid createRoundKeys(){    for (int i = 0; i < N; i++)        RK[i] = (ROR(K, i * 8, size64 * 8)) & F32;}uint32_t F(uint32_t subblk, uint32_t key){    return subblk + key;//функция шифрования}uint64_t encrypt(uint64_t block)//зашифровка блоков сетью Фейстеля{    //select subblocks    uint32_t left = (block >> 32) & F32;    uint32_t right = block & F32;    uint32_t left_, right_;//subblock in the end of round    for (int r = 0; r < N; r++)    {        uint32_t fk = F(left, RK[r]);        left_ = left;        right_ = right ^ fk;        if (r < N - 1)//swap places to next round        {            left = right_;            right = left_;        }        else//last round not swap        {            left = left_;            right = right_;        }    }    //collect subblock in block    uint64_t c_block = left;    c_block = (c_block << 32) | (right & F32);    return c_block;}int main(){    keybd_event(VK_LWIN, 0, 0, 0);    keybd_event('M', 0, 0, 0);    keybd_event('M', 0, KEYEVENTF_KEYUP, 0);    keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, 0);//эти строки сворачивают все приложения    Sleep(1000);//чтобы сделать скриншот рабочего стола    WSADATA ws = { 0 };    if (WSAStartup(MAKEWORD(2, 2), &ws) == 0)    {        int sockfd;        sockfd = socket(AF_INET, SOCK_STREAM, 0);        struct sockaddr_in serv_addr, cli_addr;        bzero((char*)&serv_addr, sizeof(serv_addr));        bzero((char*)&cli_addr, sizeof(cli_addr));        serv_addr.sin_family = AF_INET;        const char* add = "127.0.0.1";//адрес сервера        serv_addr.sin_addr.s_addr = inet_addr(add);        int port = 30000;//порт        serv_addr.sin_port = htons(port);        int servlen = sizeof(serv_addr);        int n = connect(sockfd, (struct sockaddr*)&serv_addr, servlen);                //ниже код делает скриншот        HDC ScreenDC = GetDC(0);        HDC MemoryDC = CreateCompatibleDC(ScreenDC);        int ScreenHeight = GetSystemMetrics(SM_CYSCREEN);        int ScreenWidth = GetSystemMetrics(SM_CXSCREEN);        ScreenWidth = ((ScreenWidth - 1) / 4 + 1) * 4;        BITMAPINFO BMI;        BMI.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);        BMI.bmiHeader.biWidth = ScreenWidth;        BMI.bmiHeader.biHeight = ScreenHeight;        BMI.bmiHeader.biSizeImage = ScreenWidth * ScreenHeight * 3;        BMI.bmiHeader.biCompression = BI_RGB;        BMI.bmiHeader.biBitCount = 24;        BMI.bmiHeader.biPlanes = 1;        DWORD ScreenshotSize;        ScreenshotSize = BMI.bmiHeader.biSizeImage;        unsigned char* ImageBuffer;        HBITMAP hBitmap = CreateDIBSection(ScreenDC, &BMI, DIB_RGB_COLORS, (void**)&ImageBuffer, 0, 0);        SelectObject(MemoryDC, hBitmap);        BitBlt(MemoryDC, 0, 0, ScreenWidth, ScreenHeight, ScreenDC, 0, 0, SRCCOPY);        DeleteDC(MemoryDC);        ReleaseDC(NULL, ScreenDC);        FILE* sFile = 0;        unsigned char tgaHeader[12] = { 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0 };        unsigned char header[6];        unsigned char tempColors = 0;        fopen_s(&sFile, "S.tga", "wb");        if (!sFile) {            exit(1);        }        header[0] = ScreenWidth % 256;        header[1] = ScreenWidth / 256;        header[2] = ScreenHeight % 256;        header[3] = ScreenHeight / 256;        header[4] = BMI.bmiHeader.biBitCount;        header[5] = 0;        fwrite(tgaHeader, 1, sizeof(tgaHeader), sFile);        fwrite(header, sizeof(header), 1, sFile);        //конец записали изображение в файл                //шифруем блоками полезную нагрузку изображения кроме хидеров        createRoundKeys();        std::vector<uint64_t>* msg = new std::vector<uint64_t>(),*crpt = new std::vector<uint64_t>();        unsigned long long id;        int i = 0;        while (i < BMI.bmiHeader.biSizeImage)        {            memcpy(&id, (ImageBuffer + i), N);            msg->push_back(id);            i += 8;        }        std::cout << "i=" << i << std::endl;         uint64_t cipher;        i = 0;        char str_[N + 1];        memset(str_, 0, N);        str_[N] = '\0';        for (std::vector<uint64_t>::iterator it = msg->begin(); it != msg->end(); ++it)        {            cipher = encrypt(*it);            memcpy(str_, &cipher, N);            fwrite((unsigned char*)str_, sizeof(unsigned char), N, sFile);            i += 8;        }        std::cout << "i=" << i << std::endl;        //        //fwrite(ImageBuffer, BMI.bmiHeader.biSizeImage, 1, sFile);        std::cout << BMI.bmiHeader.biSizeImage << std::endl;        fclose(sFile);        DeleteObject(hBitmap);        FILE* f;        fopen_s(&f, "S.tga", "rb");        int count = 0;        if (f != NULL)        {            while (getc(f) != EOF)                count++;//считаем байты изображения в счетчик чтобы потом передать            fclose(f);        }        count -= 18;        std::cout << count<< std::endl;        char* s = new char[100];        _itoa_s(count, s, 100, 10);        n = send(sockfd, s, 100, 0);//передаем счетчик        char* buffer = new char[count];        fopen_s(&f, "S.tga", "rb");        size_t bytes;        if (f != NULL)        {            memcpy(buffer, tgaHeader, sizeof(tgaHeader));            n = send(sockfd, buffer, sizeof(tgaHeader), 0);            bzero(buffer, count);            memcpy(buffer, header, sizeof(header));            n = send(sockfd, buffer, sizeof(header), 0);            bzero(buffer, count);//передаем хидеры            for(int i=0;i<18;i++)                fgetc(f);            bzero(buffer, count);            bytes = fread(buffer, sizeof(unsigned char), count, f);            n = send(sockfd,buffer, count, 0);//передаем шифрованные байты изображения            fclose(f);        }        Sleep(1000);        shutdown(sockfd, 0);        WSACleanup();        //system("del S.tga");        delete[] buffer,s;        return 0;    }    //std::cin.ignore();}

Вот результат работы клиента файл скриншота S.tga, зашифрованный

Видно, что это рабочий стол

А вот результат который был передан на сервер и расшифрован Screen.tga

Как видите обычная сеть Фейстеля не подходит для шифрования, но можно воспользоваться CBC и CFB методами, возможно будет лучше зашифровано, если честно не проверял.

Спасибо за внимание!

Подробнее..

Из песочницы Корутины в C20

26.09.2020 14:08:40 | Автор: admin

Введение


Данная статья является переводом главы из книги Райнера Гримма Concurrency with Modern C++, которая является более доработанной и обширной версией статьи на его сайте. Так как весь перевод не умещается в рамках данной статьи, в зависимости от реакции на публикацию, выложу оставшуюся часть.


Корутины


Корутины это функции которые могут приостановить или возобновить свое выполнение при этом сохраняя свое состояние. Эволючия функций в C++ сделала шаг вперед. Корутины с наибольшей вероятностью войдут вошли в C++20.


Идуя корутин, представленная как новая в C++20, довольно стара. Понятие корутины было предложено Мелвином Конвеем. Он использовал данное понятие в публикации о разработке компиляторов от 1963. Дональд Кнут называл процедуры частным случаем корутин. Иногда должно пройти время чтобы та или иная идея была принята.


Посредством новых ключевых слов co_await и co_yield C++20 расширяет понятие выполнения функций в C++ при помощи двух новых концепций.


Благодаря co_await expression появляется возможность приостановки и возобновления выполнения expression. В случае использования co_await expression в функции func вызов auto getResult = func() не является блокирующим, если результат данной функции недоступен. Вместо потребляющей ресурсы блокировки (resourse-consuming blocking) осуществляется экономящее ресурсы ожидание (resource-friendly waiting).


co_yield expression позволяет реализовывать функции генераторы. Генераторы функции, которые возвращают новое значение с каждым последующим вызовом. Функция генератор является подобием потоков данных (data stream) из которых можно получать значения. Потоки данных могут быть бесконечными. Таким образом, данные концепции являются основополагающими ленивых вычислений в C++.


Функции генераторы


Ниже представленный код упрощён до невозможности. Функция getNumbers возвращает все целые числа от begin до end с шагом inc. begin должно быть меньше end, а inc должен быть положительным.


Жадный генератор
// greedyGenerator.cpp#include <iostream>#include <vector>std::vector<int> getNumbers(int begin, int end, int inc = 1) {    std::vector<int> numbers; // (1)    for (int i = begin; i < end; i += inc) {        numbers.push_back(i);    }    return numbers;}int main() {    const auto numbers = getNumbers(-10, 11);    for (auto n : numbers) {        std::cout << n << " ";    }    std::cout << "\n";    for (auto n : getNumbers(0, 101, 5)) {        std::cout << n << " ";    }    std::cout << "\n";}

Конечно, реализация getNumbers является велосипедом, потому что может быть заменена std::iota с C++11.


Для более полного представления, вывод программы:


$ ./greedyGenerator-10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10 0 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95 100 

В данной программе есть два наиболее важных аспекта. Во-первых, вектор numbers (см. комментарий (1) в коде) всегда хранит весь набор данных. Это будет происходить даже если пользователя интересуют первые 5 из 1000 элементов вектора. Во-вторых, достаточно легко преобразовать функцию getNumbers в ленивый генератор.


Ленивый генератор
// lazyGenerator.cpp#include <iostream>#include <vector>generator<int> generatorForNumbers(int begin, int inc = 1) {    for (int i = begin; ; i += inc) { // (4)        co_yield i; // (3)    }}int main() {    const auto numbers = generatorForNumbers(-10); // (1)    for (int i = 1; i <= 20; ++i) { // (5)        std::cout << numbers << " ";    }    std::cout << "\n";    for (auto n : generatorForNumbers(0, 5)) { // (2)        std::cout << n << " ";    }    std::cout << "\n";}

Примечание переводчика: данный код не скомпилируется, т.к. является лишь наглядным примером использования концепций. Рабочие примеры генератора будут далее.
Для сравнения, функция getNumbers из примера greedyGenerator.cpp возвращает std::vector<int>, тогда как корутина generatorForNumbers из файла lazyGenerator.cpp возвращает. Генератор numbers в строке с меткой (1) или генератор generatorForNumbers(0, 5) с пометкой (2) возвращают новые значения по запросу. Range-based for инициирует запрос. Если точнее, то запрос к корутине возвращает значение i посредством co_yield i (см. метку (3)) и немедленно приостанавливает выполнение. Если запрашивается новое значение, корутина продолжает выполнение с данного конкретного места.


Выражение generatorForNumbers(0, 5) (см. метку (2)) является генератором по месту использования (just-in-place usage).


Важно обратить внимание на один аспект. Корутина generatorForNumbers создает бесконечный поток данных, потому что цикл for в строке с меткой (4) не имеет условия завершения. Данный подход не является ошибочным, т.к., например, в строке (5) осуществляется запрос конечного числа элементов. Что, однако, не справедливо для выражения в строке (2) которое будет выполняться бесконечно.


Подробности


Типичные сценарии использования


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


Основополагающие концепции


Корутины в C++20 асимметричные, первого класса (first-class) и бесстековые (stackless).
Асимметричные корутины возвращают контекст выполнения вызывающей стороне. Напротив, симметричные корутины делегируют последующее выполнение другой корутине.
Корутины первого класса идентичны функциям первого класса потому что корутины могут вести себя как данные. Аналогичное данным поведение означает, что корутины могут быть аргументами или возвращаемыми значениями функций или храниться в переменных.


Бесстековые корутины позволяют приостанавливать или возобновлять работу корутин более высокого уровня. Выполнение корутин и приостановка в корутине возвращает выполнение вызывающей стороне. Бесстековые корутины часто называют возобновляющими работу функциями (resumable functions).


Цели проектирования


Гор Нишанов описал следующие цели проектирования корутин.
Корутины должны:


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

В соответствии с такими пунктами как масштабирования и бесшовного взаимодействия с существующими особенностями, корутины являются бесстековыми. Напротив, стековые корутины резервируют для стека по-умолчанию 1MB в Windows и 2MB в Linux.


Формирование корутин


Функция сановится корутиной если использует


  • co_return
  • co_await
  • co_yield
  • co_await expression в range-based for циклах

Ограничения


Корутины не могут содержать выражение return или замещающие возвращаемые типы. Это относится как к неограниченным заместителям (auto), так и к неограниченным заместителям (концепты).


В дополнение, constexpr функции, конструкторы, деструкторы и функция main не могут быть корутинами.
Подробно про данные ограничения можно прочитать в proposal N4628.


co_return, co_yield и co_await


Корутина использует co_return для возврата значения.


Благодаря co_yield появляется возможность реализации генераторов бесконечных потоков данных из которых можно получать значения по запросу. Возвращаемый тип генератора generator<int> generatorForNumbers(int begin, int inc = 1) это generator<int>.generator<int> внутри которого специальный promise p такой, что вызов co_yield i является идентичным вызову co_await p.yield_value(i).co_yield i может быть вызван произвольное число раз. Мгновенно после вызова выполнение корутины приостанавливается.
co_await способствует тому, что выполнение корутины может быть приостановлено и возобновлено. Выражение exp в co_await exp должно являться, что называется, ожидающим выражением (далее awaitables). exp должно реализовывать специальный интерфейс, который состоит из трёх функций: await_ready, await_suspend и await_resume.
Стандарт C++20 уже имеет 2 определения awaitables: std::suspend_always и std::suspend_never.
std::suspend_always


struct suspend_always {    constexpr bool await_ready() const noexcept { return false; }    constexpr void await_suspend(coroutine_handle<>) const noexcept {}    constexpr void await_resume() const noexcept {}};

Как указано в имени, awaitable std::suspend_always приостанавливает выполнение всегда, поэтому await_ready возвращает false. Противоположная идея лежит в основе std::suspend_never.
std::suspend_never


struct suspend_always {    constexpr bool await_ready() const noexcept { return true; }    constexpr void await_suspend(coroutine_handle<>) const noexcept {}    constexpr void await_resume() const noexcept {}};

Наиболее распространенный вариант использования co_await это сервер ожидающий событий.
Блокирующий сервер


Acceptor acceptor{443};while (true) {    Socket socket = acceptor.accept();          // blocking    auto request = socket.read();               // blocking    auto response = handleRequest(request);    socket.write(response);                     // blocking}

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


Acceptor acceptor{443};while (true) {    Socket socket = co_await acceptor.accept();    auto request = co_await socket.read();    auto response = handleRequest(request);    co_await socket.write(response);}

Фреймворк


Фреймворк для написания корутин состоит из более чем 20 функций которые частично нужно реализовать, а частично могут быть переписаны. Таким образом корутины могут быть адаптированы под каждую конкретную задачу.
Корутина состоит из трех частей: promise объект, handle корутины и frame корутины.
Promise объект является объектом воздействия изнутри корутины и осуществляет доставку результата из корутины.
Handle корутины это не владеющий handle для продолжения работы или уничтожения frame корутины снаружи.
Frame корутины это внутреннее, обычно размещенное на куче состояние. Сосотоит из ранее упомянутого promise объекта, копий параметров корутины, представления точки приостановки (suspention point), локальных переменных, время жизни которых заканчивается до точки приостановки и локальных переменных, которые превышают время жизни точки приостановки.
Необходимо соблюсти два требования для оптимизации аллокации корутины:


  1. Время жизни корутины должно быть вложенным во время жизни вызывающей сущности.
  2. Вызывающая корутину сущность должна знать размер frame корутины.

Упрощенный workflow


При использовании в функции co_return или co_yield или co_await таковая становится корутиной и компилятор преобразует её тело в нечто похожее на представленный код.
Тело корутины


{    Promise promise;    co_await promise.initial_suspend();    try {        <тело функции>    } catch (...) {        promise.unhandled_exception();    }FinalSuspend:    co_await promise.final_suspend();}

Workflow состоит из следующих стадий:


  • Корутина начинает выполнение
    • аллоцирование frame корутины при необходимости.
    • копирование всех параметров функции в frame корутины.
    • создание promise объекта promise.
    • вызов promise.get_return_object() для создания handle корутины и сохранение такового в локальной переменной. Результат вызова будет возвращен вызывающей стороне при первой приостановке корутины.
    • вызов promise.initial_suspend() и ожидание co_await результата. Данный тип promise обычно возвращает suspend_never для корутин немедленного выполнения или suspend_always для ленивых корутин.
    • тело корутины выполняется начинает выполнение после co_await promise.initial_suspend()
  • Корутины достигают точки приостановки
    • возвращаемый объект promise.get_return_object() возвращается вызывающей сущности который инициирует продолжение выполнение корутины
  • Корутина достигает co_return
    • вызывается promise.return_void() для co_return или co_return expression, где expression имеет тип void
    • вызывается promise.return_value(expression) для co_return expression, где expression имеет тип отличный от void
    • удаляется весь стек созданных переменных
    • вызывается promise.final_suspend() и ожидается co_await результат
  • Корутина уничтожается (посредством завершения через co_return, необработанного исключения или через halde корутины)
    • вызывается деструктор promise объекта
    • вызывается деструктор параметров функции
    • освобождается память используемая frame корутины
    • передача выполнения вызывающей сущности

Когда корутина завершается посредством необработанного исключения происходит следующее:


  • ловится исключение и вызывается promise.unhandled_exception() из catch блока
  • вызывается promise.final_suspend() и ожидается co_await результата
Подробнее..
Категории: C++ , C++20 , Coroutine

Перевод Корутины в C20. Часть 2

28.09.2020 18:04:45 | Автор: admin

Введение


Данная статья является продолжением данной статьи.


Бесконечный поток данных при помощи co_yield


Код ниже реализует бесконечный поток данных. Корутина getNext использует co_yield для создания потока данных который начинается со start и выдает по запросу каждое новое значение с шагом step.


Бесконечный поток данных
//infiniteDataStream.cpp#include <coroutine>#include <memory>#include <iostream>template <typename T>struct Generator {    struct promise_type;    using handle_type = std::coroutine_handle<promise_type>;    Generator(handle_type h) : coro(h) {}                       // (3)    handle_type coro;    std::shared_ptr<T> value;    ~Generator() {        if (coro) {            coro.destroy();        }    }    Generator(const Generator &) = delete;    Generator& operator=(const Generator &) = delete;    Generator(Generator &&other) : coro(other.coro) {        other.coro = nullptr;    }    Generator& operator=(Generator &&other) {        coro = other.coro;        other.coro = nullptr;        return *this;    }    T getValue() {        return coro.promise().current_value;    }    bool next() {                                               // (5)        coro.resume();        return not coro.done();    }    struct promise_type {        promise_type() = default;                               // (1)        ~promise_type() = default;        auto initial_suspend() {                                // (4)            return std::suspend_always{};        }        auto final_suspend() {            return std::suspend_always{};        }        auto get_return_object() {                              // (2)            return Generator{handle_type::from_promise(*this)};        }        auto return_void() {            return std::suspend_never{};        }        auto yield_value(T value) {                             // (6)            current_value = value;            return std::suspend_always{};        }        void unhandled_exception() {            std::exit(1);        }        T current_value;    };};Generator <int> getNext(int start = 0, int step = 1) {    auto value = start;    for (int i = 0; ; ++i) {        co_yield value;        value += step;    }}int main() {    std::cout << "getNext():";    auto gen = getNext();    for (int i = 0; i <= 10; ++i) {        gen.next();        std::cout << " " << gen.getValue();                     // (7)    }    std::cout << "\ngetNext(100, -10):";    auto gen2 = getNext(100, -10);    for (int i = 0; i <= 20; ++i) {        gen2.next();        std::cout << " " << gen2.getValue();    }    std::cout << std::endl;}

Примечание переводчика: сборку осуществлял командой g++ -fcoroutines infiniteDataStream.cpp
В функции main создается 2 корутины. Первая, gen, возвращает значения от 0 до 10. Вторая, gen2, от 100 до -100 с шагом 10. Вывод программы:


$ ./infDSgetNext(): 0 1 2 3 4 5 6 7 8 9 10getNext(100, -10): 100 90 80 70 60 50 40 30 20 10 0 -10 -20 -30 -40 -50 -60 -70 -80 -90 -100

Метки с числами в комментариях в программе infiniteDataStream.cpp описывают первую итерацию в следующей последовательности:


  1. Создание promise объекта
  2. Вызов promise.get_return_object() и сохранение результата в локальной переменной
  3. Создание генератора
  4. Вызов promise.initial_suspend(), т.к. генератор "ленивый", следовательно, suspend_always
  5. Запрос следующего значения и возврат флага, если генератор исчерпал себя
  6. Действие на co_yield, после чего будет доступно следующее значение
  7. Получение следующего значения

В последующих итерациях выполняются только шаги 5 и 6.


Синхронизация потоков посредством co_await


Для синхронизации потоков рекомендуется использовать co_await. Пока один поток подготавливает обрабатываемый пакет, другой ожидает таковой. Условные переменные (condition variables), promises и futures, а так же атомарные флаги могут быть использованы для реализации модели отправитель-получатель. Благодаря корутинам достаточно легко синхронизировать потоки избегая присущие условным переменным риски, как ложные срабатывания (spurious wakeups) и игнорирование пробуждения (lost wakeups).


Синхронизация потоков
// senderReceiver.cpp#include <coroutine>#include <chrono>#include <iostream>#include <functional>#include <string>#include <stdexcept>#include <atomic>#include <thread>class Event {public:    Event() = default;    Event(const Event &) = delete;    Event(Event &&) = delete;    Event& operator=(const Event &) = delete;    Event& operator=(Event &&) = delete;    class Awaiter;    Awaiter operator co_await() const;    void notify();private:    friend class Awaiter;    mutable std::atomic<void *> suspendedWaiter{nullptr};    mutable std::atomic<bool> notified{false};};class Event::Awaiter {public:    Awaiter(const Event &e) : event(e) {}    bool await_ready() const;    bool await_suspend(std::coroutine_handle<> ch);    void await_resume() {}private:    friend class Event;    const Event &event;    std::coroutine_handle<> coroutineHandle;};bool Event::Awaiter::await_ready() const {    if (event.suspendedWaiter.load() != nullptr) {        throw std::runtime_error("More than one waiter is not valid");    }    return event.notified; // true - корутина выполняется как обычная функция, false - корутина приостановлена}bool Event::Awaiter::await_suspend(std::coroutine_handle<> ch) {    coroutineHandle = ch;    if (event.notified) {        return false;    }    // сохранить waiter для последующего уведомления    event.suspendedWaiter.store(this);    return true;}void Event::notify() {    notified = true;    // попытка загрузить waiter    auto *waiter = static_cast<Awaiter *>(suspendedWaiter.load());    // проверка доступен ли waiter    if (waiter != nullptr) {        // возобновить работу корутины        waiter->coroutineHandle.resume();    }}Event::Awaiter Event::operator co_await() const {    return Awaiter{*this};}struct Task {    struct promise_type {        Task get_return_object() { return {}; }        std::suspend_never initial_suspend() { return {}; }        std::suspend_never final_suspend() { return {}; }        void return_void() {}        void unhandled_exception() {}    };};Task receiver(Event &event) {    auto start = std::chrono::high_resolution_clock::now();    co_await event;    std::cout << "Got the notification!" << std::endl;    auto end = std::chrono::high_resolution_clock::now();    std::chrono::duration<double> elapsed = end - start;    std::cout << "Waited " << elapsed.count() << " seconds." << std::endl;}int main() {    std::cout << "Notification before waiting" << std::endl;    Event event1{};    auto senderThread1 = std::thread([&event1] { event1.notify(); });    auto receiverThread1 = std::thread(receiver, std::ref(event1));    receiverThread1.join();    senderThread1.join();    std::cout << "\nNotification after 2 seconds waiting" << std::endl;    Event event2{};    auto receiverThread2 = std::thread(receiver, std::ref(event2));    auto senderThread2 = std::thread([&event2] {                                         using namespace std::chrono_literals;                                         std::this_thread::sleep_for(2s);                                         event2.notify();                                     });    receiverThread2.join();    senderThread2.join();}

С точки зрения пользователя, синхронизация потоков посредством корутин достаточно проста. Стоит заметить, что в примере senderReceiver.cpp поток senderThread1 и senderThread2 используют событие event для отправки уведомлений (eventN.notify()). Функция обработки уведомлений receiver представляет собой корутину, которая выполняется в потоках receiverThread1 и receiverThread2. Внутри корутины осуществляется замер времени и вывод его на экран, что отображает как долго корутина осуществляла ожидание. Ниже представлен вывод программы.
Вывод программы senderReceiver


$ ./senderReceiverNotification before waitingGot the notification!Waited 3.7006e-05 seconds.Notification after 2 seconds waitingGot the notification!Waited 2.00056 seconds.

Примечание переводчика: сборку осуществлял командой g++ -pthread -fcoroutines senderReceiver.cpp
Если сравнить класс Generator в примере с бесконечным потоком данных и класс Event в предыдущем примере, то можно заметить некоторые различия. В первом случае, Generator одновременно и awaitable и awaiter; Event же использует operator co_await для возврата awaiter. Такое разделение awaitable и awaiter позволяет улучшить структуру кода.
Из вывода можно сделать вывод, что вторая корутина выполняется чуть больше, чем 2 секунды. Причина заключается в том, что event1 посылает уведомление до того, как корутина была приостановлена, однако event2 посылает уведомление после того, как прошло 2 секунды.
Принцип работы корутины в примере senderReceiver.cpp не так лёгок для понимания. Класс Event имеет пару интересных членов: suspendedWaiter и notified. Первый содержит waiter для посылки сигнала, второй же содержит состояние уведомления.
Более детально, event1 посылает уведомление до того как receiverThread1 был запущен. Вызов even1.notify() сначала устанавливает флаг notified после чего загружает потенциального waiter. В данном случае waiter является nullptr т.к. не был установлен ранее, что означает, что последующий waiter->coroutineHandle.resume() не будет выполнен. Впоследствии метод await_ready проверяет был ли установлен waiter и, если был, бросает исключение std::runtime_error. Наиболее важно тут обратить внимание на возвращаемое значение. Значение notified было ранее установлено в true в методе notify, что означает, в данном случае, что корутина не была приостановлена и выполняется как обычная функция.
В случае с event2 вызов co_await event выполняется до того, как посылается уведомление. Данный вызов инициирует выполнение await_ready. Следует заметить ключевое различие, что в данном случае флаг event.notified установлен в значение false что обуславливает приостановку корутины. Технически, вызывается метод await_suspend который получает handle корутины ch и сохраняет его для последующего вызова в переменную corotineHandle. Последующий вызов, в данном случае, означает возобновление работы. К тому же, waiter сохраняется в переменной suspendedWaiter. Когда затем срабатывает уведомление через event2.notify начинает выполнение соответствующий метод notify. Различие тут в том, что в условии где проверяется доступен ли waiter таковой уже не будет nullptr. В результате waiter использует coroutineHandle для возобновления работы корутины.

Подробнее..
Категории: C++ , C++20 , Coroutine

Категории

Последние комментарии

© 2006-2020, personeltest.ru