Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
Дмитрий Козлюк 00591fc072
lecture07: улучшена иллюстрация перестановки байтов
2 лет назад
..
README.md lecture07: улучшена иллюстрация перестановки байтов 2 лет назад

README.md

Лекция 7. Приемы низкоуровневого программирования

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

Размер данных (sizeof)

Напомним, что тип данных определяется своим представлением в памяти и допустимыми операциями над значениями этого типа. Во многих случаях значения представляются в памяти непрерывным блоком фиксированного размера. Например, таковы фундаментальные типы: char, (unsigned) (short, long, long long) int, float, double.

Узнать размер типа в байтах позволяет оператор sizeof:

cout << sizeof(char) << '\n';    // 1
cout << sizeof(int) << '\n';     // 4
cout << sizeof(float) << '\n';   // 4
cout << sizeof(double) << '\n';  // 8

Оператор sizeof можно применять не только к типам, но и к переменным — в этом случае он даст размер типа переменной-операнда.

На самом деле, только размер sizeof(char) гарантируется. Размеры других встроенных типов могут отличаться в зависимости от платформы (процессора, операционной системы). Есть специальные типы, размер которых в битах фиксирован:

#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 к таким типам — это ошибка, хотя компилятор о ней и не предупредит.

Системы счисления

Пусть в памяти хранится целое число:

uint16_t x = 1234;

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

Если интересны биты числа, удобна двоичная система:

#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:

uint16_t y = 0b10011010010; // 1234

Однако двоичные константы неудобно читать, они слишком длинные. Принято вместо них пользоваться шестнадцатеричными с префиксом 0x:

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

В коде можно группировать цифры числа в любой системе счисления для читаемости:

uint16_t w = 0b100'1101'0010; // 1234

Менее распространена восьмеричная форма записи с префиксом 0o или 0:

uint16_t v = 0o2322; // 1234

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

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. Например:

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). Красная компонента содержится в младших пяти битах. Голубая компонента содержится в старших пяти битах. Зеленая компонента содержится в средних шести битах.

Иллюстрация для конкретного значения:

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. Легко видеть, что таким свойством обладает &:

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 переместить в младшие разряды.

g = value >> 5; // 0x11

Можно было бы сделать все одним выражением:

g = (color & 0x7E0) >> 5; // 0x11

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

Как получить компоненту голубого цвета b? Сдвинуть старшие 5 бит на позиции младших бит:

b = color >> 11; // 0x02

Порядок байт в машинном слове (byte order, endianness)

При записи чисел принято писать старшие разряды в начале. Например, 513 — сначала сотни (5), затем десятки (1), затем единицы (3). В памяти минимально адресуемая единица не разряд, а байт, то есть 513 (в шестнадцатеричной системе 0x0201) представлено как два байта: 0x02 и 0x01. В памяти их можно расположить как 0x02, 0x01 — от старшего к младшему (big-endian) или как 0x01, 0x02 — от младшего к старшему (little-endian). Это выбирают разработчики процессора, а программисту их выбор безразличен: пока работа ведется через переменные, внутреннее представление всегда будет использоваться одно и то же.

Эксперимент:

#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

Пусть они приняты в некий буфер — массив байт:

uint8_t payload[] = {0x00, 0x00, 0x00, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f};

Скопируем первые 4 байта в переменную целочисленного типа:

#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++:

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(), которые делают то же, что расписано выше. Важно помнить, когда требуется преобразование.

Выравнивание данных и упаковка структур

Рассмотрим структуру:

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, используются специальные директивы:

#pragma pack(push, 1)   // Структуры в коде ниже будут упакованы (alignment=1).
struct Foo {
    uint32_t bar;
    uint8_t baz;
    uint32_t quux;
};
#pragma pack(pop)       // Структуры в коде ниже не будут упаковываться
                        // с alignment=1.

Некоторые компиляторы делают это иначе — атрибутами:

struct Foo {
    // ...
} __attribute__((pack(1));

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

Ресурсы