Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

472 строки
22 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Лекция 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 <cstdint>
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<T>`?
Формально можно.
Но результат зависит только от внутреннего устройства этих типов,
а не от того, что они хранят во время выполнения программы.
То есть результат бесполезен.
Как правило, применять `sizeof` к таким типам — это ошибка,
хотя компилятор о ней и не предупредит.
## Системы счисления
Пусть в памяти хранится целое число:
```cpp
uint16_t x = 1234;
```
Одно и то же значение можно представить в разных системах счисления.
Само значение всегда хранится как биты, а представление выбирают удобное.
Если интересны биты числа, удобна двоичная система:
```cpp
#include <iomanip>
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 <cstdint>
#include <iostream>
using namespace std;
int
main() {
uint16_t u16 = 0x0201; // 513
uint8_t* u8 = reinterpret_cast<uint8_t*>(&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 <cstring>
// ...
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)