Интригующая сборка для сравнения std :: optional от примитивных типов

Valgrind поднял шквал. Условный прыжок или ход зависят от неинициализированной ценности (значений) в одном из моих модульных тестов.

Осмотрев сборку, я понял, что следующий код:

bool operator==(MyType const& left, MyType const& right) {
    // ... some code ...
    if (left.getA() != right.getA()) { return false; }
    // ... some code ...
    return true;
}

Где MyType::getA() const → std::optional<std::uint8_t>, сгенерировал следующую сборку:

   0x00000000004d9588 <+108>:   xor    eax,eax
   0x00000000004d958a <+110>:   cmp    BYTE PTR [r14+0x1d],0x0
   0x00000000004d958f <+115>:   je     0x4d9597 <... function... +123>
x  0x00000000004d9591 <+117>:   mov    r15b,BYTE PTR [r14+0x1c]
x  0x00000000004d9595 <+121>:   mov    al,0x1

   0x00000000004d9597 <+123>:   xor    edx,edx
   0x00000000004d9599 <+125>:   cmp    BYTE PTR [r13+0x1d],0x0
   0x00000000004d959e <+130>:   je     0x4d95ae <... function... +146>
x  0x00000000004d95a0 <+132>:   mov    dil,BYTE PTR [r13+0x1c]
x  0x00000000004d95a4 <+136>:   mov    dl,0x1
x  0x00000000004d95a6 <+138>:   mov    BYTE PTR [rsp+0x97],dil

   0x00000000004d95ae <+146>:   cmp    al,dl
   0x00000000004d95b0 <+148>:   jne    0x4da547 <... function... +4139>

   0x00000000004d95b6 <+154>:   cmp    r15b,BYTE PTR [rsp+0x97]
   0x00000000004d95be <+162>:   je     0x4d95c8 <... function... +172>

    => Jump on uninitialized

   0x00000000004d95c0 <+164>:   test   al,al
   0x00000000004d95c2 <+166>:   jne    0x4da547 <... function... +4139>

Где я отмечен x операциями, которые не выполняются (перепрыгиваются) в случае, когда опция НЕ установлена.

Элемент A здесь находится в смещении 0x1c в MyType. Проверка макета std::optional мы видим, что:

  • +0x1d соответствует bool _M_engaged,
  • +0x1c соответствует std::uint8_t _M_payload (внутри анонимного объединения).

Код интереса для std::optional:

constexpr explicit operator bool() const noexcept
{ return this->_M_is_engaged(); }

// Comparisons between optional values.
template<typename _Tp, typename _Up>
constexpr auto operator==(const optional<_Tp>& __lhs, const optional<_Up>& __rhs) -> __optional_relop_t<decltype(declval<_Tp>() == declval<_Up>())>
{
    return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
         && (!__lhs || *__lhs == *__rhs);
}

Здесь мы видим, что gcc существенно изменил код; если я правильно ее понимаю, в C это дает:

char rsp[0x148]; // simulate the stack

/* comparisons of prior data members */

/*
0x00000000004d9588 <+108>:   xor    eax,eax
0x00000000004d958a <+110>:   cmp    BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>:   je     0x4d9597 <... function... +123>
0x00000000004d9591 <+117>:   mov    r15b,BYTE PTR [r14+0x1c]
0x00000000004d9595 <+121>:   mov    al,0x1
*/

int eax = 0;
if (__lhs._M_engaged == 0) { goto b123; }
bool r15b = __lhs._M_payload;
eax = 1;

b123:
/*
0x00000000004d9597 <+123>:   xor    edx,edx
0x00000000004d9599 <+125>:   cmp    BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>:   je     0x4d95ae <... function... +146>
0x00000000004d95a0 <+132>:   mov    dil,BYTE PTR [r13+0x1c]
0x00000000004d95a4 <+136>:   mov    dl,0x1
0x00000000004d95a6 <+138>:   mov    BYTE PTR [rsp+0x97],dil
*/

int edx = 0;
if (__rhs._M_engaged == 0) { goto b146; }
rdi = __rhs._M_payload;
edx = 1;
rsp[0x97] = rdi;

b146:
/*
0x00000000004d95ae <+146>:   cmp    al,dl
0x00000000004d95b0 <+148>:   jne    0x4da547 <... function... +4139>
*/

if (eax != edx) { goto end; } // return false

/*
0x00000000004d95b6 <+154>:   cmp    r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>:   je     0x4d95c8 <... function... +172>
*/

//  Flagged by valgrind
if (r15b == rsp[097]) { goto b172; } // next data member

/*
0x00000000004d95c0 <+164>:   test   al,al
0x00000000004d95c2 <+166>:   jne    0x4da547 <... function... +4139>
*/

if (eax == 1) { goto end; } // return false

b172:

/* comparison of following data members */

end:
    return false;

Это эквивалентно:

//  Note how the operands of || are inversed.
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
         && (*__lhs == *__rhs || !__lhs);

Я думаю, что сборка правильная, если странная. То есть, насколько я вижу, результат сравнения между неинициализированными значениями фактически не влияет на результат функции (и в отличие от C или C++, я ожидаю, что сравнение хлама в сборке x86 НЕ должно быть UB):

  1. Если один параметр имеет значение nullopt а другой установлен, то условный переход в +148 перескакивает до end (return false), OK.
  2. Если оба параметра установлены, то сравнение считывает инициализированные значения, ОК.

Итак, единственный интересный случай - когда оба nullopt имеют значение nullopt:

  • если значения сравниваются равными, тогда код заключает, что опции равны, что верно, поскольку они оба являются nullopt,
  • в противном случае, код заключает, что опции равны, если __lhs._M_engaged является ложным, что верно.

В любом случае, в этом случае код делает вывод о том, что оба nullopt равны, когда оба являются nullopt; CQFD.


Это первый случай, когда я вижу gcc, генерирующий явно "доброкачественные" неинициализированные чтения, и поэтому у меня есть несколько вопросов:

  1. Неинициализированы, читает OK в сборке (x84_64)?
  2. Является ли это синдромом неудачной оптимизации (обратная ||), которая может возникнуть в недоброкачественных обстоятельствах?

На данный момент я склоняюсь к тому, чтобы аннотировать несколько функций с optimize(1) в качестве рабочего процесса, чтобы предотвратить оптимизацию. К счастью, идентифицированные функции не критичны по производительности.


Окружающая среда:

  • компилятор: gcc 7.3
  • скомпилировать флаги: -std=C++17 -g -Wall -Werror -O3 -flto (+ соответствующий включает)
  • флаги ссылок: -O3 -flto (+ соответствующие библиотеки)

Примечание: может появиться с -O2 вместо -O3, но никогда не будет -flto.


Забавные факты

В полном коде этот шаблон появляется 32 раза в функции, описанной выше, для различных полезных нагрузок: std::uint8_t, std::uint32_t, std::uint64_t и даже struct { std::int64_t; std::int8_t; } struct { std::int64_t; std::int8_t; } struct { std::int64_t; std::int8_t; }.

Он появляется только в небольшом большом operator== сравнивая типы с ~ 40 членами данных, а не в меньших. И он не появляется для std::optional<std::string_view> даже в тех конкретных функциях (которые вызывают в std::char_traits для сравнения).

Наконец, бесцеремонно, изолируя рассматриваемую функцию в своем собственном двоичном, исчезает "проблема". Мифический MCVE оказывается неуловимым.

Ответы

Ответ 1

В x86 asm худшее, что происходит, состоит в том, что один регистр имеет неизвестное значение (или вы не знаете, какое из двух возможных значений оно имеет, старое или новое, в случае возможного упорядочения памяти). Но если ваш код не зависит от этого значения регистра, вы в порядке, в отличие от C++. C++ UB означает, что вся ваша программа теоретически полностью закрыта после однократного переполнения, и даже до этого по кодовым дорожкам, которые может видеть компилятор, приведет к UB. В asm ничего подобного не происходит, по крайней мере, в непривилегированном коде пользовательского пространства.

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


Некоторые ISA имеют "непредсказуемое поведение", например раннее ARM, если вы используете один и тот же регистр для нескольких операндов умножения, поведение непредсказуемо. IDK, если это разрешает разрыв трубопровода и повреждение других регистров, или если оно ограничивается только неожиданным результатом умножения. Последнее было бы моим догадком.

Или MIPS, если вы помещаете ветвь в слот задержки задержки, поведение непредсказуемо. (Обработка исключений является беспорядочной из-за слотов задержки ветки...). Но, по-видимому, есть еще ограничения, и вы не можете свернуть машину или разбить другие процессы (в многопользовательской системе, такой как Unix, было бы плохо, если бы непривилегированный процесс пользовательского пространства мог сломать что-либо для других пользователей).

В очень раннем MIPS также были слоты с задержкой при загрузке и многократно отложенные интервалы: вы не могли использовать результат загрузки в следующей инструкции. Предположительно, вы можете получить старое значение регистра, если вы читаете его слишком рано или, может быть, просто мусор. MIPS = минимально взаимосвязанные этапы трубопровода; они хотели разгрузить свалку на программное обеспечение, но оказалось, что добавление NOP, когда компилятор не мог найти ничего полезного, чтобы делать последующие раздутые двоичные файлы и приводил к замедлению общего кода, а также при необходимости аппаратного срыва. Но мы застряли в слотах с задержкой на ветки, потому что удаление их изменило бы ISA, в отличие от ослабления ограничения на что-то, что раньше не выполнялось в программном обеспечении.

Ответ 2

В x86 целочисленных форматах нет значений ловушки, поэтому чтение и сравнение неинициализированных значений порождает непредсказуемые истинные/ложные значения и никакого другого прямого вреда.

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

Тот факт, что gcc делает неинициализированные чтения, когда это не имеет значения, если чтение дает неправильное значение, не означает, что он сделает это, когда это имеет значение.

Ответ 3

Я не был бы уверен, что это вызвано ошибкой компилятора. Возможно, в вашем коде есть некоторый UB, который позволяет компилятору оптимизировать более агрессивно ваш код. Во всяком случае, на вопросы:

  1. UB не проблема в сборке. В большинстве случаев то, что осталось по адресу, на который вы ссылаетесь, будет считаться. Конечно, большинство ОС заполняют страницы памяти, прежде чем давать им программу, но ваша переменная, скорее всего, находится в стеке, поэтому, скорее всего, она содержит данные об мусоре. Су, как долго Вы в порядке со случайным сопоставлением данных (что довольно плохо, так как могут ложно давать разные результаты) сборка действительна
  2. Скорее всего, это синдром обратного сравнения