|
|
# Лекция 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 00 00 XX
|
|
|
00 YY 00 00 >> 8 → 00 00 YY 00
|
|
|
00 00 ZZ 00 << 8 → 00 ZZ 00 00
|
|
|
00 00 00 WW << 24 → WW 00 00 00
|
|
|
|
|
|
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)
|