Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Статический анализ кода в методическом документе ЦБ РФ "Профиль защиты" - 16.02

>
>
>
Тихий враг или молчаливый союзник:...

Тихий враг или молчаливый союзник: коротко о выравнивании в C++

29 Янв 2026

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

Введение

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

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

Давайте представим такую ситуацию: есть 4-байтное число типа int, которое расположено в памяти так, что его начало попадает на конец одного 4-байтного блока, а продолжение — на начало следующего. Чтобы прочитать это число, процессору придётся выполнить две операции чтения вместо одной, затем извлечь нужные части из разных блоков и объединить их. Проблема становится ещё серьёзнее, если эти блоки лежат в разных страницах виртуальной памяти. Это не только замедляет работу, но и усложняет обработку данных для процессора. Чтобы избежать таких сложностей, используется выравнивание.

Что же такое выравнивание? Это способ организации данных и доступа к ним в памяти в соответствии с заданными границами. Тип данных "выровнен", когда его адрес кратен степени двойки, как правило, равной размеру типа. Пока что запомним это ключевое определение.

Как же этот принцип применяется в коде? Будем последовательно погружаться в кроличью нору, изучая всё на примерах.

Естественное выравнивание и паддинги

Чтобы разобраться в таком непростом механизме, начнём с базовых принципов размещения примитивных типов данных в памяти. Для этого рассмотрим простой пример:

#include <iostream>
#include <format>

int main()
{
  char ch;
  char* pCh;
  int i;

  std::cout << std::format("Alignment of ch: {} byte(s)",
                           alignof(decltype(ch)))
            << std::endl
            << std::format("Alignment of pCh: {} byte(s)",
                           alignof(decltype(pCh)))
            << std::endl
            << std::format("Alignment of i: {} byte(s)",
                           alignof(decltype(i)))
            << std::endl;
}

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

При размещении данных компилятор подчиняется строгим правилам выравнивания данных. Сам факт размещения объекта в памяти гарантируется языком на фундаментальном уровне (пункты 6.7.1.1 и 6.7.2.1 C++23). Конкретные значения выравнивания типов зависят от реализации (implementation-defined behavior), или, другими словами, зависят от конкретного компилятора. Чаще всего они определяются моделью данных.

Примечание: далее примеры будут рассматриваться в рамках модели LP64 (short — 2 байта, int — 4 байта, а long, long long и указатели — 8 байт).

Переменная ch типа char имеет выравнивание 1 байт и может находиться в любом месте адресного пространства. Указатель pCh с выравниванием 8 байт будет размещён только по адресу, кратному 8. Переменная in типа int с выравниванием 4 байта займёт позицию, кратную 4.

Но что это значит на практике? Давайте представим, как эти переменные могут быть расположены в памяти, если бы компилятор размещал их последовательно без оптимизации:

Компилятор разместил переменные практически подряд, однако между переменными ch и pCh добавилось 7 неиспользуемых байт. Кто нам помог? Компилятор. Он автоматически вставил неиспользуемые байты между переменными. Это пространство между данными называется байтами выравнивания (паддинг, padding). Его цель — выровнять каждый элемент по его размеру, предотвращая несоответствия, которые могут негативно повлиять на производительность. Поскольку заполнение этих байтов определяется реализацией, это создаёт ряд практических проблем. В частности, при использовании функций вроде memcmp для сравнения структур. Так оно будет анализировать все байты памяти объекта целиком, включая неинициализированный паддинг, что может привести к ложноотрицательному результату даже для структур с одинаковыми данными в полях.

Общий размер переменных составляет 1 + 8 + 4 = 13 байт, однако в памяти они занимают больше места из-за требований выравнивания. Стоит также отметить, что полагаться на конкретное расположение этих данных в стеке функции не стоит. Компилятор вправе применять различные оптимизации, которые не меняют наблюдаемого поведения программы, но при этом могут нарушить "очевидный" порядок. Например, он может как удалить неиспользуемые переменные, так и поместить используемые на регистры процессора.

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

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

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

  • ОС Windows, архитектура x86_64, компилятор MSVC;
  • ОС Linux, архитектура x86_64, компилятор Clang.

Компилятор GCC не стала брать, так как в большинстве случаев результат будет такой же, как на Clang. GCC и Clang в Unix-like системах придерживаются Itanium ABI при формировании компоновки классов (class layout), MSVC под Windows — свой собственный.

Итак, перед вами небольшая структура. Каким будет её размер в каждом компиляторе?

struct Example
{
  short int sh;
  char* ptr;
  char symbol;
  long ln;
};
#include <iostream>
#include <format>

struct Example
{
  short int sh;
  char* ptr;
  char symbol;
  long ln; 
};

int main()
{
  std::cout << "=== Print size and alignment ===" << std::endl
            << std::format("Sizeof of Example: {} byte(s)",
                           sizeof(Example))
            << std::endl
            << std::format("Alignment of Example: {} byte(s)",
                           alignof(Example))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/MxKhn5EWr

=== Print size and alignment ===
Sizeof of Example: 24 byte(s)
Alignment of Example: 8 byte(s)

Compiler Explorer: https://godbolt.org/z/fq43e9EGM

=== Print size and alignment ===
Sizeof of Example: 32 byte(s)
Alignment of Example: 8 byte(s)

Если вы начали вычислять размер структуры Example, просто суммируя размеры полей, то поздравляю, вы ошиблись :)

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

  • Берём первое поле и кладём его по смещению 0x0. Увеличиваем текущее смещение на размер поля. Запоминаем выравнивание этого поля.
  • Берём следующее поле и вычисляем его выравнивание.
    • Если выравнивание следующего поля больше, чем предыдущего, увеличиваем смещение (добавляем паддинги). Прирост вычисляется по простой формуле: Offset += Align - Offset % Align, где Align — выравнивание большего поля, % — взятие остатка от деления.
    • Если выравнивание следующего поля меньше, чем предыдущего, переходим на шаг 3.
  • Кладём поле по текущему смещению. Увеличиваем текущее смещение на размер поля.
  • Повторяем шаги 2–4 для всех оставшихся полей.
  • Полученное смещение запомним и назовём его dsize.
  • Производим финальное выравнивание структуры. Для этого берём максимальное выравнивание от всех полей и делаем смещение кратным ему согласно формуле из шага 2a.
  • Итоговый размер класса (size) — текущий показатель смещения.

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

Теперь попробуйте самостоятельно выстроить размещение данных структуры Example для Clang и MSVC.

Размещение в Clang:

*** Dumping AST Record Layout
         0 | struct Example
         0 |   short sh
         8 |   char * ptr
        16 |   char symbol
        24 |   long ln
           | [sizeof=32, dsize=32, align=8]

Сначала размещается поле sh с размером 2 байта. Следующее значение, которое нужно разместить, — указатель ptr . На выбранной 64-битной архитектуре он занимает 8 байт. Но мы помним, что у нас данные хранятся по адресу, кратному его размеру. Чтобы соответствовать этому правилу, компилятор добавит 6 байт паддинга после sh, сдвигая тем самым начало ptr на нужный адрес. Далее без сюрпризов размещается однобайтовое поле symbol. Следующее поле ln должно быть с выравниванием по 8-ми байтам, поэтому компилятор добавит ещё 7 байт паддинга.

Если посчитаем все байты: 2 + 6 + 8 + 1 + 7 + 8 = 32.

Размещение данных в MSVC:

class Example  size(24):
  +---
 0  | sh
    | <alignment member> (size=6)
 8  | ptr
16  | symbol
    | <alignment member> (size=3)
20  | ln
  +---

Здесь сначала также размещается поле sh размером 2 байта. Далее идёт указатель ptr, перед которым компилятор добавляет 6 байт паддинга, чтобы он начинался с адреса, кратного 8. После ptr размещается однобайтовое поле symbol. Далее мы видим расхождение с Clang: компилятор добавляет 3 байта паддинга, чтобы затем расположить следующее поле ln. Произошло это из-за того, что MSVC работает по модели памяти LLP64, в которой размер и выравнивание типа long — 4 байта. В итоге получаем: 2 байта для sh, 6 байт паддинга, 8 байт для ptr, 1 байт для symbol, 3 байта паддинга и 4 байта для ln.

Если посчитаем все байты: 2 + 6 + 8 + 1 + 3 + 4 = 24.

Влияние порядка полей в структурах

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

Зная это, переделаем структуру (поменяем поля местами):

struct Example
{
  char* ptr;
  long ln;
  short int sh;
  char symbol;
};
#include <iostream>
#include <format>

struct Example
{
  char* ptr;
  long ln;
  short int sh;
  char symbol;
};

int main()
{
  std::cout << "=== Print optimized size and alignment ==="
            << std::endl
            << std::format("Sizeof of Example: {} byte(s)",
                           sizeof(Example))
            << std::endl
            << std::format("Alignment of Example: {} byte(s)",
                           alignof(Example))
            << std::endl;
}

И получим уже такие результаты:

Compiler Explorer: https://godbolt.org/z/4TYzTW3s8

=== Print optimized size and alignment ===
Sizeof of Example: 16 byte(s)
Alignment of Example: 8 byte(s)

Compiler Explorer: https://godbolt.org/z/zn5nGEreq

=== Print optimized size and alignment ===
Sizeof of Example: 24 byte(s)
Alignment of Example: 8 byte(s)

В таком порядке компилятору не нужно добавлять лишние байты паддинга между полями, так как каждое следующее поле уже выровнено по адресу, кратному его выравниванию. Например, после 8-байтового указателя ptr идёт 4-байтовое поле ln, которое автоматически выравнивается по 4 байтам. Далее следуют 2-байтовое поле sh и 1-байтовое поле symbol. В результате общий размер структур будет на MSVC — 16 байт, на Clang — 24 байта.

Получается, стоит всегда располагать поля в таком порядке? Ответ зависит от того, как много порождается объектов таких структур:

  • когда создаётся буквально несколько экземпляров структуры, то оптимизацией можно пренебречь, если она ломает некоторый логичный для разработчика порядок полей;
  • если порядок создаваемых экземпляров высок (миллионы и более), то оптимизация может быть оправдана.

Также нужно помнить про ABI: если порядок полей важен для вашего приложения, то менять его вы не сможете, т. к. это приведёт к слому обратной совместимости.

Пустая структура

Итак, в С++ класс/структура считается пустой, если не содержит ни одного нестатического поля. У таких структур просто нет членов, которые могли бы занять память. Однако мы знаем, что каждый объект должен иметь свой адрес в памяти, поэтому даже под пустые структуры компилятор обязан выделить 1 байт.

Забавный факт: в языке C пустые структуры невозможны, иначе поведение не определено (C23 §6.7.3.2/10).

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

Для решения этой проблемы в C++20 был введён атрибут [[no_unique_address]]. Он говорит компилятору, что поле не обязательно должно иметь уникальный адрес. Если поле является пустым, компилятор может оптимизировать его размещение и не выделять для него отдельную память.

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

struct Empty {};

struct Normal
{
  Empty e;
  int x;
};

struct Optimized
{
  [[no_unique_address]] Empty e;
  int x;
};
#include <iostream>
#include <format>

struct Empty {};

struct Normal
{
  Empty e;
  int x;
};

struct Optimized
{
  [[no_unique_address]] Empty e;
  int x;
};

int main()
{
  std::cout << "=== Empty sub-object optimization ===" << std::endl
            << std::format("Empty size = {} byte(s)",
                           sizeof(Empty))
            << std::endl
            << std::format("Empty alignment = {} byte(s)",
                           alignof(Empty))
            << std::endl
            << std::format("Normal size = {} byte(s)",
                           sizeof(Normal))
            << std::endl
            << std::format("Normal alignment = {} byte(s)",
                           alignof(Normal))
            << std::endl
            << std::format("Optimized size = {} byte(s)",
                           sizeof(Optimized))
            << std::endl
            << std::format("Optimized alignment = {} byte(s)",
                           alignof(Optimized))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/GsGc4GYxe

=== Empty sub-object optimization ===
Empty size = 1 byte(s)
Empty alignment = 1 byte(s)
Normal size = 8 byte(s)
Normal alignment = 4 byte(s)
Optimized size = 8 byte(s)
Optimized alignment = 4 byte(s)

Compiler Explorer: https://godbolt.org/z/fMedjKGhf

=== Empty sub-object optimization ===
Empty size = 1 byte(s)
Empty alignment = 1 byte(s)
Normal size = 8 byte(s)
Normal alignment = 4 byte(s)
Optimized size = 4 byte(s)
Optimized alignment = 4 byte(s)

Интересный факт: в компиляторе MSVC эта оптимизация по умолчанию не сработает, и размер структуры будет 8 байт. Microsoft пока решила не ломать свой ABI, однако при большом желании оптимизацию можно осуществить посредством атрибута [[msvc::no_unique_address]].

Битовые поля

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

В качестве примера рассмотрим такую структуру:

// since C++20
struct Example
{
  unsigned char dummy1;
  unsigned char flag1 : 1;
  unsigned char flag2 : 1;
  unsigned char flag3 : 1;
  unsigned char flag4 : 1;
  unsigned char dummy2;
};

Поля dummy1 и dummy2 нужны в служебных целях: для битовых полей невозможно применить операцию взятия адреса.

#include <iostream>
#include <format>

struct Example
{
  unsigned char dummy1;
  unsigned char flag1 : 1;
  unsigned char flag2 : 1;
  unsigned char flag3 : 1;
  unsigned char flag4 : 1;
  unsigned char dummy2;
};

int main()
{
  Example obj;
  std::cout << "=== Bit-fields ===" << std::endl
            << std::format("Example size without "
                           "dummy data members: {} byte(s)",
                           sizeof(Example) - 2 * sizeof(unsigned char))
            << std::endl
            << std::format("Example alignment: {} byte(s)",
                           alignof(Example))
            << std::endl << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Example flag1 address: 0x{:p}",
                           static_cast<const void *>(
                             &obj.dummy1 + sizeof(Example::dummy1)
                           ))
            << std::endl
            << std::format("Example dummy2 address: 0x{:p}",
                           static_cast<const void *>(&obj.dummy2))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/1K73x9c1P

=== Bit-fields ===
Example size without dummy data members: 1 byte(s)
Example alignment: 1 byte(s)

=== Addresses ===
Example flag1 address: 0x0x7f46affd01
Example dummy2 address: 0x0x7f46affd02

Compiler Explorer: https://godbolt.org/z/KM837qbxa

=== Bit-fields ===
Example size without dummy data members: 1 byte(s)
Example alignment: 1 byte(s)

=== Addresses ===
Example flag1 address: 0x0x7ffcf4de63fe
Example dummy2 address: 0x0x7ffcf4de63ff

На первый взгляд, все битовые поля должны занять всего 4 бита. Однако же размер структуры без учёта наших служебных полей составляет 1 байт. Почему? Ответ очень прост: минимальной единицей хранения (storage unit) является байт, в котором хранится N бит информации (определяется имплементацией посредством макроса CHAR_BIT). На используемых для экспериментов платформах 1 байт хранит 8 бит. Мы воспользовались только четырьмя битами, поэтому компилятор также вставит ещё 4 бита паддинга для полного заполнения единицы хранения.

Строго говоря, стандарт языка полностью оставляет за реализацией (ABI конкретной платформы и компилятором) способ физического расположения битовых полей. Компилятор может как упаковывать ваши биты в одну единицу хранения, так и порождать для каждого битового поля свою собственную единицу хранения. Также компилятор сам выбирает, с какого конца единицы хранения (little/big endian) начинается размещение.

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

При этом есть и некоторые отличия в реализациях компиляторов:

  • при изменении нижележащего типа битового поля компилятор может как неявно завершить предыдущую единицу хранения (MSVC), так и продолжить наполнение дальше (GCC, Clang);
  • при декларации битового поля, размер которого превышает ёмкость его нижележащего типа, все дополнительные биты считаются битами паддинга. Clang и GCC соблюдают это поведение, но выдают предупреждение. MSVC же в такой ситуации считает код ошибочным и отказывается компилировать его.

Директива '#pragma pack'

Иногда бывают случаи, когда нам надо самостоятельно контролировать выравнивания. Для этого существует директива #pragma pack, которая говорит компилятору, сколько байт использовать для выравнивания полей вместо стандартного. Рассмотрим пример:

struct NormalStruct
{
  char a;
  int b;
  double c;
};

#pragma pack(push, 1)
struct PackedStruct
{
  char a;
  int b;
  double c;
};
#pragma pack(pop)
#include <iostream>
#include <format>

struct NormalStruct
{
  char a;
  int b;
  double c;
};

#pragma pack(push, 1)
struct PackedStruct
{
  char a;
  int b;
  double c;
};
#pragma pack(pop)

int main()
{
  std::cout << "=== Structure Packing ===" << std::endl
            << std::format("NormalStruct size: {} byte(s)",
                           sizeof(NormalStruct))
            << std::endl
            << std::format("NormalStruct alignment: {} byte(s)",
                           alignof(NormalStruct))
            << std::endl
            << std::format("PackedStruct size: {} byte(s)",
                           sizeof(PackedStruct))
            << std::endl
            << std::format("PackedStruct alignment: {} byte(s)",
                           alignof(PackedStruct))
            << std::endl; 
  return 0;
}

Compiler Explorer: https://godbolt.org/z/ahnEnPPjz

=== Structure Packing ===
NormalStruct size: 16 byte(s)
NormalStruct alignment: 8 byte(s)
PackedStruct size: 13 byte(s)
PackedStruct alignment: 1 byte(s)

Compiler Explorer: https://godbolt.org/z/3h37Erbfo

=== Structure Packing ===
NormalStruct size: 16 byte(s)
NormalStruct alignment: 8 byte(s)
PackedStruct size: 13 byte(s)
PackedStruct alignment: 1 byte(s)

Директива #pragma pack(1) кардинально меняет правила игры, заставляя компилятор упаковать данные максимально плотно. В структуре PackedStruct поля располагаются строго последовательно без какого-либо выравнивания. Такой подход экономит память (ровно 13 байт), но, как я и писала ранее, имеет серьёзную цену — невыровненный доступ к данным:

  • Если архитектура процессора позволяет производить доступ к таким данным, то может происходить существенное замедление операций чтения и записи. Пример такой архитектуры — x86_64.
  • Процессор может сделать аппаратное прерывание при доступе к невыровненным данным. Пример такой архитектуры — ARM.

__declspec(align), __attribute__((aligned(N))), alignas и alignof

Как мы уже знаем, порядок расположения достаточно важен в структурах/классах. Но если нам нужно самим задать выравнивание? Например, иногда такое требуется при работе с SIMD: данные должны быть выровнены по границе 32 байт.

Для этого компиляторы предоставляют специальные атрибуты и/или спецификаторы. Исторически начнём с __declspec(align(N)) в MSVC и __attribute__((aligned(N))) в Clang и GCC — это компиляторо-специфичные расширения, которые позволяют явно управлять требованиями к выравниванию данных в памяти:

// MSVC
__declspec(align(32)) struct SIMD_Data_MSVC
{
  double d1;
  double d2;
  double d3;
  double d4;
};

// Clang and GCC
struct SIMD_DATA_Clang_GCC
{
  double d1;
  double d2;
  double d3;
  double d4;
} __attribute__((aligned(32)));

Синтаксически эти атрибуты отличаются: в MSVC спецификатор __declspec(align(N)) ставится перед объявлением структуры или переменной, в Clang и GCC используется атрибут __attribute__((aligned(N))), который, как правило, следует после имени структуры или типа в объявлении. Атрибуты могут использоваться как для упаковки, так и для задания более строгих требований к выравниванию.

Разница в синтаксисе — частая причина ошибок при портировании кода между платформами. Эта проблема была решена в стандартах C11 и C++11. Появились новые средства для работы с выравниванием — оператор alignof и спецификатор alignas:

  • оператор alignof возвращает выравнивание переданного типа во время компиляции. Например, alignof(char) всегда равен 1, поскольку байт можно разместить по любому адресу;
  • спецификатор alignasпозволяет явно задавать требование к выравниванию для переменных, полей структур или самих типов. Может принимать как целочисленное значение, так и другой тип. Например, alignas(32) double value; гарантирует, что переменная value будет размещена по адресу, кратному 32 байтам. Важное отличие от компиляторо-специфичных атрибутов — оператор alignas не позволяет делать упаковку. При попытке уменьшить необходимое выравнивание будет выдана ошибка этапа компиляции.

Статические анализаторы и выравнивание

Зачастую самостоятельно контролировать все тонкости выравнивания достаточно сложно. Тут и приходит на помощь статический анализ, который может отлавливать различные аспекты при работе с выравниванием. Например, вот что может подсвечивать PVS-Studio:

  • V802 из группы оптимизаций фокусируется на поиске структур, где размер можно уменьшить, просто изменив порядок объявления полей для более плотной упаковки данных;
  • V1032 сигнализирует о ситуациях, когда приведения указателя может привести к обращению по невыровненному адресу;
  • V1103 акцентирует внимание на ошибках побайтового сравнения структур, в которых присутствуют случайные значения в паддингах;
  • V2666 из группы MISRA отслеживает согласованность спецификаторов выравнивания. Если вы где-то указали alignas для объекта, то все другие повторные объявления должны использовать такое же выравнивание.

Рассмотрим пару ошибок, связанных с выравниванием, которые мы нашли в реальных проектах.

Проект FreeCAD

template <int N>
void TRational<N>::ConvertTo (double& rdValue) const
{
  assert(m_kDenom != 0);
  if (m_kNumer == 0)
  {
    rdValue = 0.0;
    return;
  }

  unsigned int auiResult[2];
  ....
  rdValue = *(double*)auiResult;
  ....
}

Здесь строка rdValue = *(double*)auiResult; — это то самое коварное место. Тут автор пытается собрать значение типа double путём чтения двух последовательно расположенных unsigned int. Просто так такое чтение невозможно, поэтому автор прибегает к преобразованию указателей.

Вариант нерабочий и опасный. Проблема в нарушении требований к выравниванию. Адрес auiResult выровнен для типа unsigned int (4 байта), тогда как тип double (8 байт) требует более строгого выравнивания. Поведение при чтении переменной по адресу, не кратному его требованию типа, не определено.

И срабатывание PVS-Studio: V1032. The pointer 'auiResult' is cast to a more strictly aligned pointer type. Wm4TRational.inl

Проект TDengine

typedef struct STreeNode {
  int32_t index;
  void   *pData;  // TODO remove it?
} STreeNode;

int32_t tMergeTreeAdjust(SMultiwayMergeTreeInfo* pTree, int32_t idx) {
  ....
  STreeNode kLeaf = pTree->pNode[idx];
  ....
  if (memcmp(&kLeaf, &pTree->pNode[1], sizeof(kLeaf)) != 0) {
  ....
}

Здесь ошибка кроется в сравнении объектов структуры STreeNode. Поскольку структура содержит поля разного размера (int32_t и указатель void*), компилятор для обеспечения выравнивания указателя добавляет после index 4 байта паддинга. Для сравнения объектов используется функция memcmp, которая сравнивает всё байтовое представление, включая паддинг. А как мы помним, поведение того, как именно заполняются байты паддинга, зависит от реализации. Это может привести к неожиданным результатам. Например, даже если поля index и pData совпадают, объекты могут быть признаны неравными из-за случайных данных в пустых байтах, которые компилятор добавил для выравнивания.

И срабатывание PVS-Studio: V1103 The values of padding bytes are unspecified. Comparing objects with padding using 'memcmp' may lead to unexpected result. tlosertree.c 127

Заключение

Итак, тайна раскрыта. Выравнивание данных — это не враг, а союзник, чьи правила, будучи понятыми, позволяют писать по-настоящему быстрый и стабильный код. Вооружившись знаниями из этой статьи, вы с лёгкостью превратите загадочные падения и тормоза в решаемые задачи. Ваш код отныне готов ко встрече с любым "железом". Однако во всей этой большой статье несколько тем я заведомо опустила: наследование, POD-структуры, множественное наследование, виртуальные таблицы, виртуальные базовые классы, RTTI и т. д. Продолжение следует... :)

Чтобы на практике убедиться, что все тонкости выравнивания в вашем проекте учтены, вы можете воспользоваться статическим анализатором PVS-Studio. Он поможет проверить код на наличие неочевидных ошибок, связанных с организацией памяти, и станет вашим помощником в написании переносимого и надёжного кода.

Последние статьи:

Опрос:

book gost

Дарим
электронную книгу
за подписку!

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form