From 45fec08f2e92c7a5a84d6092cf3c4480e20eebd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=9A=D0=BE?= =?UTF-8?q?=D0=B7=D0=BB=D1=8E=D0=BA?= Date: Fri, 19 May 2023 18:19:26 +0300 Subject: [PATCH] =?UTF-8?q?lecture07:=20=D0=BD=D0=B8=D0=B7=D0=BA=D0=BE?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B2=D0=BE=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- lectures/lecture07/README.md | 471 +++++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 lectures/lecture07/README.md diff --git a/README.md b/README.md index 290e580..64e08d9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 4. Сборка программ из нескольких файлов 5. Ввод-вывод, модульное тестирование 6. Библиотеки ([презентация](http://uit.mpei.ru/study/courses/cs/lecture07_library.pdf)) -7. Низкоуровневое программирование +7. [Низкоуровневое программирование](lectures/lecture07) 8. Объектно-ориентированное программирование ## Лабораторные работы diff --git a/lectures/lecture07/README.md b/lectures/lecture07/README.md new file mode 100644 index 0000000..4820b2f --- /dev/null +++ b/lectures/lecture07/README.md @@ -0,0 +1,471 @@ +# Лекция 7. Приемы низкоуровневого программирования + +Задачами низкого уровня в программировании называют такие, +которые более связаны с особенностями работы компьютера, +чем с предметной областью. +Примеры: программирование микроконтроллеров (Arduino, STM и т. п.), +использование специфических возможностей операционной системы, +работа с двоичными форматами данных, +оптимизация производительности. +Задачи низкого уровня бывают как сложными, так и простыми, +но всегда требуют большой внимательности. + +## Размер данных (`sizeof`) + +Напомним, что тип данных определяется своим представлением в памяти +и допустимыми операциями над значениями этого типа. +Во многих случаях значения представляются в памяти +непрерывным блоком фиксированного размера. +Например, таковы фундаментальные типы: +`char`, (`unsigned`) (`short`, `long`, `long long`) `int`, `float`, `double`. + +Узнать размер типа в байтах позволяет оператор `sizeof`: + +```cpp +cout << sizeof(char) << '\n'; // 1 +cout << sizeof(int) << '\n'; // 4 +cout << sizeof(float) << '\n'; // 4 +cout << sizeof(double) << '\n'; // 8 +``` + +Оператор `sizeof` можно применять не только к типам, но и к переменным — +в этом случае он даст размер типа переменной-операнда. + +На самом деле, только размер `sizeof(char)` гарантируется. +Размеры других встроенных типов могут отличаться в зависимости от платформы +(процессора, операционной системы). +Есть специальные типы, размер которых в битах фиксирован: + +```cpp +#include + +cout << sizeof(uint8_t) << '\n'; // 1 байт = 8 бит +cout << sizeof(uint16_t) << '\n'; // 2 байта = 16 бит +cout << sizeof(uint32_t) << '\n'; // 4 байта = 32 бита +cout << sizeof(uint64_t) << '\n'; // 8 байт = 64 бита +``` + +Размер массива равен произведению размера элемента на количество элементов. +Например, `uint32_t array[30]` имеет размер `120 == 30 * sizeof(uint32_t)`. + +Размер указателя зависит от платформы, но фиксирован (4 или 8). + +Оператор `sizeof` относится к типу, а типы есть только на этапе компиляции. +Можно ли применить `sizeof` к переменной, +которая хранит динамически определяемое количество данных, +например, к `string` или `vector`? +Формально можно. +Но результат зависит только от внутреннего устройства этих типов, +а не от того, что они хранят во время выполнения программы. +То есть результат бесполезен. +Как правило, применять `sizeof` к таким типам — это ошибка, +хотя компилятор о ней и не предупредит. + +## Системы счисления + +Пусть в памяти хранится целое число: + +```cpp +uint16_t x = 1234; +``` + +Одно и то же значение можно представить в разных системах счисления. +Само значение всегда хранится как биты, а представление выбирают удобное. + +Если интересны биты числа, удобна двоичная система: + +```cpp +#include + +using namespace std; + +// ... +cout << bin << x; // 10011010010 +``` + +Действительно: + +``` +1 0 0 1 1 0 1 0 0 1 0 +1×2¹⁰ + 0×2⁹ + 0×2⁸ + 1×2⁷ + 1×2⁶ + 0×2⁵ + 1×2⁴ + 0×2³ + 0×2² + 1×2¹ + 0×2⁰ +1024 + 128 + 64 + 16 + 2 = 1234 +``` + +Чтобы записать в коде программы константу в двоичной системе, +используется префикс `0b`: + +```cpp +uint16_t y = 0b10011010010; // 1234 +``` + +Однако двоичные константы неудобно читать, они слишком длинные. +Принято вместо них пользоваться шестнадцатеричными с префиксом `0x`: + +```cpp +uint16_t z = 0x4d2; // 1234 +``` + +В шестнадцатеричной системе используются цифры от 0 до 9, +а также латинские буквы для недостающих цифр: A₁₆ = 10₁₀, B, C, D, E, F₁₆ = 15₁₀. +Действительно: + +``` +4 d 2 +4×16² + 13×16¹ + 2×16⁰ +1024 + 208 + 2 = 1234 +``` + +Между двоичной и шестнадцатеричной системой можно переводить числа проще: +каждые 4 двоичных цифры соответствуют одной шестнадцатеричной: + +``` +0b 100 1101 0010 +0x 4 d 2 +``` + +В коде можно группировать цифры числа в любой системе счисления +для читаемости: + +```cpp +uint16_t w = 0b100'1101'0010; // 1234 +``` + +Менее распространена восьмеричная форма записи с префиксом `0o` или `0`: + +```cpp +uint16_t v = 0o2322; // 1234 +``` + +Однако помнить о ней надо, потому что +**целые числа с ведущими нулями считаются записанными в восьмеричной системе.** +Например: + +```cpp +uint16_t u = 01234; // 668 +``` + +## Побитовые операции + +Побитовые операции `&`, `|`, `^` и `~` применяются к целым числам +и производят действие попарно над всеми битами. + +`&` — логическое «И», или логическое умножение, или конъюнкция, +дает 1, если оба операнда равны 1. + +`|` — логическое «ИЛИ», или логическое сложение, или дизъюнкция, +дает 1, если хотя бы один операнд равен 1. + +`^` — исключающее «ИЛИ», дает 1, если 1 равен строго один операнд. + +`~` — логическое «НЕ», или инверсия, дает 1 для 0 и 0 для 1. + +Таблицы истинности: + +|`a`|`b`|`a & b`|`a | b`|`a ^ b`|`~a`| +|---|---|---|---|---|---| +|0|0|0|0|0|1| +|0|1|0|1|1|1| +|1|0|0|1|1|0| +|1|1|1|1|0|0| + +Не следует путать побитовые операции и логические `&&`, `||`, `^^` и `!`. +Логические операции работают со значениями типа `bool`. +Если применить их к целочисленным значениям, +операнды будут сначала преобразованы к `bool` (0 → `false`, иначе `true`), +после чего будет посчитан результат тоже типа `bool`. +Например: + +```cpp +uint8_t x = 42; // 0b00101010 +cout << ~x << '\n'; // 214 = b011010101 +cout << !x << '\n'; // 0 = !true = !(42 != 0) +``` + +Битовые операции часто применяются при работе с бинарными (двоичными) данными, +полученными по сети, считанными с датчиков, или загружаемыми из файла. +Бинарные форматы используется в этих случаях для экономии канала или диска. +Например, если на целое число отведено 32 бита, +в них можно сохранить числа от 0 до 2³²-1 в бинарном формате. +Если бы формат был текстовым, то 32 бит, или 4 байт, +хватило бы всего на 4 символа, то есть на числа всего от 0 до 9999. + +Рассмотрим пример из области двоичных файлов. +В изображении формата BMP пиксель может быть представлен как 16-битное целое. +При этом в одном пикселе закодированы значения трех компонент цвета: +красной (red), зеленой (green) и голубой (blue). +Красная компонента содержится в младших пяти битах. +Голубая компонента содержится в старших пяти битах. +Зеленая компонента содержится в средних шести битах. + +Иллюстрация для конкретного значения: + +```cpp +uint16_t color = 0x1234; +uint8_t r, g, b; +// color = 0b0001'0010'0011'0100 = 0b00010'010001'10100 +// r = 0b10100 = 0x14 +// g = 0b10001 = 0x11 +// b = 0b00010 = 0x02 +``` + +Задача: извлечь из переменной `color` значения `r`, `g`, и `b`. + +Заметим, что `r` представляет собой `color`, у которого все биты, +кроме младших пяти, заменены на нули, а младшие пять сохранены, как есть. +Обозначим биты, которые надо сохранить, как `1`, +а биты, которые надо обнулить, как `0`: + +``` +color = 0b00010'010001'10100 = 0x1234 + mask = 0b00000'000000'11111 = 0x001F + r = 0b00000'000000'10100 = 0x0014 +``` + +Какую операцию `F` можно применить, чтобы выполнялось `color F mask == r`? +Для сочетания любого бита с 0 она должна давать 0: `F(x, 0) = 0`. +Для сочетания любого бита с 1 она должна давать тот же бит: `F(x, 1) = x`. +Легко видеть, что таким свойством обладает `&`: + +```cpp +r = color & 0x1F; // 0x14 +``` + +Значение, где нужные биты обозначены 0, а не нужные — 1, называется *маской,* +а операция конъюнкции в этом случае — *наложением маски.* +Так говорят по аналогии с трафаретом, через прорези которого видно только нужное. + +Как получить компоненту зеленого цвета `g`? +Можно опять начать с наложения маски: + +``` +color = 0b00010'010001'10100 = 0x1234 + mask = 0b00000'111111'00000 = 0x07E0 +value = 0b00000'010001'00000 = 0x0220 +``` + +Результат наложения маски, `value`, не совпадает с ожидаемым значением `g`. +Причина в том, что это правильные биты, но на неправильных местах. +Чтобы получить значение `g`, нужно биты `value` переместить в младшие разряды. + +```cpp +g = value >> 5; // 0x11 +``` + +Можно было бы сделать все одним выражением: + +```cpp +g = (color & 0x7E0) >> 5; // 0x11 +``` + +Приоритет побитовых операций низкий, поэтому требуются скобки. +И вообще, код с побитовыми операциями относится к сложному, +поэтому лучше ставить скобки, даже если это было бы необязательно, +чтобы читателю не вспоминать приоритеты. + +Как получить компоненту голубого цвета `b`? +Сдвинуть старшие 5 бит на позиции младших бит: + +```cpp +b = color >> 11; // 0x02 +``` + +## Порядок байт в машинном слове (byte order, endianness) + +При записи чисел принято писать старшие разряды в начале. +Например, 513 — сначала сотни (5), затем десятки (1), затем единицы (3). +В памяти минимально адресуемая единица не разряд, а байт, +то есть 513 (в шестнадцатеричной системе `0x0201`) +представлено как два байта: `0x02` и `0x01`. +В памяти их можно расположить как `0x02`, `0x01` — +от старшего к младшему (big-endian) +или как `0x01`, `0x02` — от младшего к старшему (little-endian). +Это выбирают разработчики процессора, а программисту их выбор безразличен: +пока работа ведется через переменные, +внутреннее представление всегда будет использоваться одно и то же. + +Эксперимент: + +```cpp +#include +#include + +using namespace std; + +int +main() { + uint16_t u16 = 0x0201; // 513 + uint8_t* u8 = reinterpret_cast(&u16); + for (size_t i = 0; i < sizeof(u16); i++) { + cout << "u16 byte " << i << " = " << u8[i] << "\n"; + } +} +``` + +Результат на Intel Core i7: + +``` +u16 byte 0 = 1 +u16 byte 1 = 2 +``` + +Действительно, архитектура x86 (у Intel и AMD) использует little-endian. + +Порядок байт нужно учитывать, если данные передаются между машинами: +в двоичных файлах или двоичными протоколами по сети. + +Во-первых, это означает, что в описании формата файла или сетевого протокола +должно быть сказано, какой порядок байт используется. +Исторически сложилось, что большинство протоколов используют big-endian, +поэтому его еще называют сетевым порядком байт (network byte order). + +«Сетевой» — это термин, сама по себе передача по сети не влияет на порядок байт. +Поэтому, во-вторых, если данные прибывают или читаются в ином порядке, +чем используется локально, нужно преобразовывать его, +чтобы получить правильное значение. + +Пусть в некотором сетевом протоколе +сначала передается целое 32-битное число в big-endian (длина), +затем столько байт данных, чему равно это число. +Например, передается число 5 и 5 байтов `H`, `e`, `l`, `l`, `o`: + +``` +00 00 00 05 48 65 6c 6c 6f +----------- -- -- -- -- -- + 5 H e l l 0 +``` + +Пусть они приняты в некий буфер — массив байт: + +```cpp +uint8_t payload[] = {0x00, 0x00, 0x00, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f}; +``` + +Скопируем первые 4 байта в переменную целочисленного типа: + +```cpp +#include +// ... +uint32_t length; +std::memcpy(&length, payload, sizeof(length)); // куда, откуда, сколько байт +``` + +Если теперь проверить значение `length`, оно будет не 5, а 83886080 (0x05000000)! + +Обычно порядок байт преобразуют после чтения. +Имеем `0xXXYYZZWW`, нужно преобразовать его в `0xWWZZYYXX`. +Для этого нужно выделить нужные байты масками, +затем сдвинуть их на те позиции, которые нужны: + +``` +→| 8|← + XX YY ZZ WW + + XX 00 00 00 >> 24 + 00 YY 00 00 >> 8 + 00 00 ZZ 00 << 8 + 00 00 00 WW << 24 + + WW ZZ YY XX +``` + +То же самое на C++: + +```cpp +uint32_t xx = length & 0xFF000000; +uint32_t yy = length & 0x00FF0000; +uint32_t zz = length & 0x0000FF00; +uint32_t ww = length & 0x000000FF; +length = (xx >> 24) | (yy >> 8) | (zz << 8) | (ww << 24); +``` + +На практике вручную эти манипуляции обычно не делают. +Например, в сетевом программировании применяются функции `htonl()`/`ntohl()`, +которые делают то же, что расписано выше. +Важно помнить, когда требуется преобразование. + +## Выравнивание данных и упаковка структур + +Рассмотрим структуру: + +```cpp +struct Foo { + uint32_t bar; + uint8_t baz; + uint32_t quux; +}; +``` + +Можно предположить, +что `sizeof(Foo) == 9 == sizeof(uint32_t) + sizeof(uint8_t) + sizeof(uint32_t)`. +Но эксперимент покажет `sizeof(Foo) == 12` или `16`. +Если распечатать байты, как в эксперименте с порядком байт, увидим: + +``` +4 байта 1 1 1 1 4 байта +-------- -- -- -- -- -------- +bar baz xx xx xx quux +``` + +Откуда появились три лишних байта, обозначенные `xx xx xx`? +Дело в том, что во многих процессорах есть разница, +по какому адресу расположена переменная (или поле структуры). +Например, если адрес кратен размеру переменной (4 для `uint32_t`), +то обращение к переменной более эффективно. +Такие переменные называют *выровненными (aligned).* +Некоторые процессоры вообще запрещают обращения к невыровненным данным. +По умолчанию компилятор располагает поля структур для большей скорости, +жертвуя неиспользуемыми байтами. +Они называются байтами выравнивания *(padding).* +Если они в середине структуры, их иногда называют *дырами (holes).* + +Обычно про выравнивание не нужно думать. + +Если размер структуры критичен, например, их будет в программе очень много, +стоит расположить поля так, чтобы компилятору не приходилось выравнивать их, +а потом проверить результат `sizeof`. + +Еще один случай, когда нужно управлять выравниванием — +если структура читается из файла или из сети, +и в формате файла или в описании протокола выравнивание не предусмотрено. +Например, если указано, что 4, 1 и 4 байта `bar`, `baz` и `quux` идут подряд. +Тогда, если скопировать структуру через `std::memcpy()` из буфера, +то часть `quux` окажется в неиспользуемой области, что неправильно: + +``` +uint8_t payload[] = {0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8}; + ------------------ --- ------------------ + bar baz quux + ↓ ↓ ↓ + ------------------ --- --- --- --- ------------------ +Foo foo. bar baz xx xx xx quux +``` + +Чтобы запретить компилятору вставлять padding, +используются специальные директивы: + +```cpp +#pragma pack(push, 1) // Структуры в коде ниже будут упакованы (alignment=1). +struct Foo { + uint32_t bar; + uint8_t baz; + uint32_t quux; +}; +#pragma pack(pop) // Структуры в коде ниже не будут упаковываться + // с alignment=1. +``` + +Некоторые компиляторы делают это иначе — атрибутами: + +```cpp +struct Foo { + // ... +} __attribute__((pack(1)); +``` + +Напомним, что компилятор по умолчанию делает выравнивание для оптимизации, +поэтому упаковывать структуры нужно только в том случае, если это необходимо. + +## Ресурсы + +* [Статья, аналогичная лекции](https://habr.com/ru/companies/ruvds/articles/735668/) +* [Трюки с битами](https://graphics.stanford.edu/~seander/bithacks.html)