Грокаем C++
7.53K subscribers
25 photos
3 files
336 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Приветственный пост

Рады приветствовать всех на нашем канале!
Вы устали от скучного, монотонного, обезличенного контента по плюсам?

Тогда мы идем к вам!

Здесь не будет бесполезных 30 IQ постов, сгенеренных ChatGPT, накрученных подписчиков и активности.

Канал ведут два сеньора, Денис и Владимир, которые искренне хотят делится своими знаниями по С++ и создать самое уютное коммьюнити позитивных прогеров в телеге!
(ну вы поняли, да? с++, плюс плюс, плюс типа
позитивный?.. ай ладно)

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

Материалы для новичка

ГАЙДЫ:

Мини-гайд по собеседования
Гайд по категория выражения и мув-семантике
Гайд по inline

Дальше пойдет список хэштегов, которыми вы можете пользоваться для более удобной навигации по каналу и для быстрого поиска группы постов по интересующей теме:
#algorithms
#datastructures
#cppcore
#stl
#goodoldc
#cpp11
#cpp14
#cpp17
#cpp20
#commercial
#net
#database
#hardcore
#memory
#goodpractice
#howitworks
#NONSTANDARD
#interview
#digest
#OS
#tools
#optimization
#performance
#fun
#compiler
#multitasking
#design
#exception
#guide
#задачки
#base
#quiz
#concurrency
Терминал

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

Хочу поделиться с вами похожим приколом только из мира computer science. Думаю, что все мы хоть раз в жизни открывали графический терминал на своих Unix системах(реальных или виртуальных), ну или хотя бы подключались удалённо к ним. Все-таки, знание команд для unix - это маст хэв и де факто стандарт для сферы разработки. Если вы хоть раз разрабатывали не локально, то с 99% вероятности вы подключались к Линукс системе и ей надо бы уметь управлять.

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

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

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

Stay surprised. Stay cool.

#fun #tools #OS
Понимание режима ядра Linux

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

1️⃣ Абстракция оборудования. Ядро управляет взаимодействием с аппаратными устройствами такими, как дисководы, сетевые интерфейсы и контроллеры ввода/вывода. Оно обеспечивает унифицированный интерфейс, который позволяет избежать аппаратных сложностей.

2️⃣ Управление процессами. Кернел отвечает за распределение времени ЦП между выполняющимися задачами. Оно создает такие сущности как потоки и процессы, которые являются единицами исполнения кода и его окружением, а также диспетчер, который и реализует алгоритмы распределения времени.

3️⃣ Управление памятью. Ядро распределяет пространство ОЗУ между процессами с помощью механизма страничного отображения - выделяет и освобождает процессам страничные кадры физической памяти и отображает на страницы их адресного пространства. Это адресное пространство реализуется с помощью абстракции виртуальной памяти, где каждому процессу принадлежит весь спектр виртуальных адресов, которые мэтчатся с адресами реальной памяти.

4️⃣ Управление файловой системой. Она предоставляет процессам унифицированный интерфейс файлового доступа к ПЗУ. Она также организует взаимодействие с другими системами. Например, доступ с CD/DVD-накопителю через файл /dev/sr0, к мыши - через /dev/input/mouse0, доступ процессов к страницам памяти друг друга - через файлы /proc/PID/mem, и тд.

5️⃣ Управление устройствами ввода-вывода. Эта подсистема распределяет доступ к устройствам ввода-вывода между процессами и предоставляет унифицированный интерфейс для чтения/записи. Для устройств ВЗУ она организует кэширование с помощью подсистемы управления памятью.

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

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

Stay based. Stay cool.

#OS
Почему не нужно указывать размер освобождаемого блока для free()

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

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

Ссылочка на статью: https://telegra.ph/Pochemu-ne-nuzhno-ukazyvat-razmer-osvobozhdaemogo-bloka-dlya-free-12-07

Stay cool.

#hardcore #OS #memory #howitworks
Доступ к режиму ядра Linux

По умолчанию программы и приложения пользовательского пространства работают в режиме с более низкими привилегиями, называемом пользовательским режимом. Почему? Да потому что мы своими сардельками такого можем понаписать, что все с первого же запуска на*бнется. Чтобы защитить систему от случайного и специального негативного вмешательства и придуман user mode. Однако существуют способы получить доступ к режиму ядра Linux для конкретных задач. Вот самые основные из них:

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

2️⃣ Аппаратные прерывания. Они генерируются периферийными устройствами при наступлении определенных событий (например, завершение дисковой операции ввода/вывода или поступление данных на последовательный порт) и имеют асинхронный характер, поскольку невозможно точно сказать, в какой момент наступит то или иное прерывание. Более того, эти прерывания, как правило, не связаны с текущим процессом, а вызваны внешними событиями.

3️⃣ Особые ситуации. Они вызваны самим процессом, и связаны с выполнением тех или иных инструкций, например, деление на ноль или обращение к несуществующей странице памяти. Таким образом, обработка особых ситуаций производится в контексте процесса, при этом может использоваться его адресное пространство, а сам процесс — при необходимости блокироваться (перемещаться в состояние сна).

4️⃣ Ну и если уж вы взрослый и толстый дядя, то наверняка способны написать свой модуль ядра. Linux предоставляет мощный и обширный API для приложений, но иногда его недостаточно. Для взаимодействия с оборудованием или осуществления операций с доступом к привилегированной информации в системе может понадобиться новый модуль ядра. Например, драйвер для вашего самодельного устройства, чтобы с ним можно было общаться.

Ядро линуска - мощная штука и верный помощник в написании программ. С ним надо обращаться бережно и аккуратно, чтобы на 100% открыть его потенциал.

Stay careful. Stay cool.

#OS
Как система может выделить 131 Терабайт оперативы?

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

Первая подсказка к ответу для вас - практически в точности такой же результат я получил на других своих машинах. Да и под тем постом @dtbeaver оставил скрин, что у него такие же цифры +- 2 Гб от того, что получил я. Значит этот предел - общий для, по крайней мере, большой группы линуксоидов с 64-битными системами. Это наводит на вопрос: а сколько вообще можно адресовать памяти? Может 131 Тб и есть это количество?

Вторая подсказка - выделилось на самом деле не 131(ох уж это эти десятичные приставки в двоичном мире...), а 128. До боли знакомое число...

Однажды на собесе меня спросили: сколько байт я могу адресовать в программе? И я ответил: 2^64 байт. Ну вот у нас есть указатель. Он занимает 8 байт или 64-бит памяти. Минимально адресуемый размер памяти - 1 байт. И получается, что 8 байт памяти могут хранить 2^64 уникальных чисел и, соответственно, именно столько байт и могут быть адресованы. У меня этот ответ приняли, типа я ответил правильно. Но я ошибался....

Для начала вспомним, как вообще данные программы маппятся на физическую память. Напрямую использовать физические адреса мы не можем, потому что тогда каждый процесс должен был знать о том, какие ячейки уже используются, чтобы не нарваться на конфликт. Поэтому придумали такую абстракцию - виртуальная память. Теперь каждый процесс думает, что он пуп вселенной и ему одному принадлежит вся память компьютера. Теперь процессу ничего не нужно знать, он просто кайфует и оперирует всем адресным пространством единолично. А грязной работой занимается ОС. А раз процессу "принадлежит" вся память компьютера, то в теории ему и доступны все те 2^64 байта для размещения своих данных.

Но на самом деле в современных системах для адресации используются только 48 бит адреса. Почему не все 64? 48-бит - это 256 Тб оперативной памяти. Нет таких промышленных систем, которые бы обладали таким объемом оперативной+swap памяти. Сейчас уже конечно стали появляться, поэтому появляются системы с 52/57 адресными битами, но сегодня не об этом. Представим, что их нет. Тогда введение возможности адресовать все 2^64 байта виртуальной памяти будет увеличивать сложность и нагрузку на преобразование виртуального адреса в физический. Зачем платить за то, чем не пользуешься? Да и 64-битная адресация потребовала бы больший размер страниц, больший размер таблиц страниц или большую глубину страничной структуры. Это все увеличивает стоимость кеш промаха в буфере ассоциативной трансляции (TLB). В общем, накладные расходы были бы больше. А никому этого не надо, пока у нас нет столько памяти.

Но вы спросите у меня: 128 терабайт - это 2^47, а ты нам говоришь, что 48 бит адресуются. Куда делся еще один бит, ааа?

Операционная система, как главный дерижер всех процессов в системе, может вмешиваться в их работу по самым разным причинам. Ну например, через системные вызовы. Поэтому в ОС нужно иметь возможность в адресном пространстве конкретного процесса адресовать свой код и свои данные. Поэтому операционка делает свою виртуальную память видимой в адресном пространстве каждого процесса. Это значит, что 2^48 байт делятся между адресным пространством пользователя (user space) и ядра (kernel space). История встречала разные отношения в этом разделении. Но сейчас более-менее все остановились на соотношении 1:1. То есть 256 терабайт делятся поровну между пользовательским процессом и системой. Положительную часть берет себе система, а отрицательную - процесс. Так и получаются те самые 128 Тб.

Продолжение в комментариях

#memory #OS #fun #hardcore
Уникален ли std::thread::id среди всех процессов?

std::thread::id используется как уникальный идентификатор потока внутри вашего приложения. Но тут возникает интересный вопрос. А вот допустим я запустил 2 инстанса моего многопоточного приложения. Могу ли я гаранировать, что айди потоков будут уникальны между двумя инстансами? Например, я хочу какую-то общую для всех инстансов логику сделать, основанную на идентификаторах потоков. Могу ли я положиться на их уникальность? Или даже более общий вопрос: уникален ли std::thread::id среди всех процессов в системе?

Начнем с того, что стандарт С++ ничего не знает про процессы. Точно так же, как и до С++11, стандарт ничего не знал про потоки. У нас нет никаких стандартных инструментов(syscall - это не плюсовый инструмент) для работы с процессами, их запуском или для общения между процессами. И раз это не специфицировано стандратном, мы не можем ничего гарантировать. Потому что никаких гарантий и нет. Единственная гарантия стандарта относительно std::thread::id - идентификаторы - уникальны для каждого потока выполнения и могут переиспользоваться из уничтоженных потоков. Но давайте посмотрим немного глубже, на основу std::thread. Возможно там мы найдем ответ.

И тут есть 2 основных варианта. Для unix-подобных систем std::thread реализован на основе pthreads. Для виндовса это будет Windows Thread API.

В доках pthreads написано: "Thread IDs are guaranteed to be unique only within a process". Так что на юникс системах идентификатор потока уникален только в пределах одного процесса и может повторяться в разных процессах.

А вот доках Win32 API написано следующее: "Until the thread terminates, the thread identifier uniquely identifies the thread throughout the system". Оказывается, что на винде айди потока уникален среди всех процессов. Эти айдишники выдаются из одного пула, поэтому их значения синхронизированы сквозь все процессы.

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

Stay unique all over the world. Stay cool.

#cpp11 #cppcore #OS #multitasking
​​Бесплатная zero-инициализация

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

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

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

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

Поэтому ОС никому не доверяет и сама зануляет всю память, которую выдает новому процессу.

Компилятор/линкер при формировании бинарника собирает все неинициализированные переменные вместе в одну секцию с названием .bss.

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

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

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

Don't reveal secrets. Stay cool.

#OS #compiler #cppcore
​​Почему тогда локальные переменные не зануляются?

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

Но возникает вопрос: раз ОС такая молодец и зануляет всю память, то почему локальные переменные и куча заполнены мусором? Какие-то двойные стандарты.

Все на самом деле немножко сложнее.

Есть такое понятие, как "zero-fill on demand". Заполнение нулями по требованию.

Когда процесс запрашивает память под свои сегменты, стек и кучу, ОС на самом деле не дает ему реальные страницы памяти. А дает "виртуальные". То есть ничего не аллоцирует по факту. Такие страницы заполнены нулями.

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

И так она делает один раз на каждую физическую страницу.

Вот как появляются нули в реальной памяти. Теперь почему они не остаются навсегда.

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

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

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

Именно поэтому кстати, мы можем очень легко получить доступ к данным, которые лежали на стеке ранее:

void fun1() {
int initialize = 10;
std::cout << initialize << std::endl;
}

void fun2() {
int uninitialize;
std::cout << uninitialize << std::endl;
}

int main() {
fun2();
fun1();
fun2();
}


Возможный вывод такого кода:

32760
10
10


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

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

Reuse resources. Stay cool.

#OS #compiler
​​Что происходит до main?

Рассмотрим простую программу:

#include <iostream>
#include <random>

int a;
int b;

int main() {
a = rand();
b = rand();
std::cout << (a + b);
}


Все очень просто. Объявляем две глобальные переменные, в main() присваиваем им значения и выводим их сумму на экран.

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

И это действительно так! Убирая сложные детали, можем увидеть вот такое:

a:
.zero 4

b:
.zero 4

main:

push rbp
mov rbp, rsp
call rand
...
call std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
mov eax, 0
pop rbp
ret


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

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

💥 Программа загружается в оперативную память.

💥 Аллокация памяти для стека. Для исполнения функций и хранения локальных переменных обязательно нужен стек.

💥 Аллокация памяти для кучи. Для программы нужна дополнительная память, которую она берет из кучи.

💥 Инициализация регистров. Там их большое множество. Например, нужно установить текущий указатель на вершину стека(stack pointer), указатель на инструкции(instruction pointer) и тд.

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

💥 Положить на стек аргументы argc, argv(мб envp). Это аргументы для функции main.

💥 Загрузка динамических библиотек. Программа всегда линкуется с разными динамическими либами, даже если вы этого явно не делаете)

💥 Вызов всякий преинициализирующих функций.

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

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

Она уже выполняет более прикладные чтоли вещи:

👉🏿 Статическая инициализация глобальных переменных. Это и недавно обсуждаемая zero-инициализация и константная инициализация(когда объект инициализирован константным выражением). То есть инициализируется все, что можно было узнать на этапе компиляции.

👉🏿 Динамическая инициализация глобальных объектов. Выполняется код конструкторов глобальных объектов.

👉🏿 Инициализация стандартного ввода-вывода. Об этом мы говорили тут.

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

И только вот после этого всего, когда состояние программы приведено в соответствие с ожиданиями стандарта С++, функция _start вызывает main.

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

See what's underneath. Stay cool.

#OS #compiler