SSE и iostream: неправильный вывод для типов с плавающей запятой

test.cpp:

#include <iostream>
using namespace std;

int main()
{
    double pi = 3.14;
    cout << "pi:"<< pi << endl;
}

При компиляции на cygwin 64-bit с g++ -mno-sse test.cpp, вывод:

пи:0

Тем не менее, он работает правильно, если скомпилирован с g++ test.cpp,

У меня GCC версии 5.4.0.

1 ответ

Решение

Да, я повторяю это. Ну, в основном. Я на самом деле не получаю вывод 0, но какой-то другой вывод мусора. Таким образом, я могу воспроизвести недопустимое поведение, и я точно определил причину.

Вы можете увидеть код, который GCC 5.4.0 генерирует с -m64 -mno-sse флаги здесь в проводнике компилятора Голдболта. В частности, это инструкции, которые нас интересуют:

// double pi = 3.14;
fld     QWORD PTR .LC0[rip]
fstp    QWORD PTR [rbp-8]

// std::cout << "pi:";
mov     esi, OFFSET FLAT:.LC1
mov     edi, OFFSET FLAT:std::cout
call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)

// std::cout << pi;
sub     rsp, 8
push    QWORD PTR [rbp-8]
mov     rdi, rax
call    std::basic_ostream<char, std::char_traits<char> >::operator<<(double)
add     rsp, 16

Что тут происходит? Ну, во-первых, нам нужно понять, что -mno-sse флаг означает. Это препятствует тому, чтобы компилятор генерировал любой код, который использует инструкции SSE (так же как любые более поздние расширения набора инструкций). Следовательно, это означает, что все операции с плавающей запятой должны выполняться с использованием устаревшего x87 FPU. Это прекрасно работает и хорошо поддерживается на 32-битных сборках, но это бессмысленно на 64-битных сборках. Спецификация AMD64 требует как минимум поддержки SSE2, поэтому можно предположить, что все 64-битные процессоры x86 будут поддерживать как SSE, так и SSE2. Это предположение вошло в ABI: все операции с плавающей точкой на x86-64 выполняются с использованием инструкций SSE2, а значения с плавающей точкой передаются в регистрах XMM. Следовательно, выполнение операций с плавающей запятой, но запрещение компилятору инструкций SSE/SSE2 ставит генератор кода в невозможное положение и приводит к неизбежному отказу.

Как именно это терпит неудачу? Давайте пройдемся по коду выше. Это неоптимизировано (так как вы не прошли флаг оптимизации, по умолчанию -O0), что делает его немного трудным для чтения, но терпите меня.

В первом блоке он использует инструкции x87 FPU для загрузки значения с плавающей запятой двойной точности (3.14) из памяти (оно сохраняется как константа в двоичном виде) в регистр в верхней части стека x87 FPU. Затем он выталкивает это значение из стека и сохраняет его в памяти (программный стек). Это просто занятая работа, выполненная в неоптимизированном коде, и вы можете просто проигнорировать это. В результате вы получаете значение с плавающей точкой в ​​памяти rbp-8 (смещение 8 байт от базового указателя).

Следующий блок инструкций может быть полностью проигнорирован. Они просто выводят строку "pi:".

Третий блок инструкций должен выводить значение с плавающей точкой. Сначала в стеке выделяется 8 байт. Затем значение с плавающей точкой, которое мы ранее сохранили в памяти, помещается в стек.

Все идет нормально. Именно так вы обычно передаете параметр с плавающей запятой в функцию, то есть в 32-разрядной сборке, следуя 32-разрядному ABI, где вы использовали инструкции x87. В 64-битной сборке, после 64-битного ABI, параметры с плавающей запятой должны передаваться в регистрах XMM, и именно здесь operator<<(double) Функция ожидает получить свой параметр. Но вы сказали компилятору, что он не может генерировать код SSE, поэтому он не может использовать регистры XMM. Его руки связаны. Он не может правильно вызвать библиотечную функцию, которая следует за ABI, потому что ваши конкретные параметры нарушают ABI.

Отсюда все вниз. Компилятор копирует содержимое rax зарегистрироваться в rdi зарегистрироваться, а затем вызывает operator<<(double) функция. Эта функция пытается записать значение с плавающей точкой, переданное в XMM0 зарегистрируйтесь в stdout, но этот регистр содержит мусор (в вашем случае он, кажется, содержит 0, но его фактическое содержимое формально не определено), поэтому этот мусор записывается в stdout вместо значения с плавающей запятой, которое вы ожидали увидеть.

Теперь, когда мы понимаем проблему, каковы решения?

  • Если вы не хотите использовать инструкции SSE, принудительно скомпилируйте 32-битный двоичный файл, используя -m32 флаг. Это безопасно сочетается с -mno-sse,
  • Если вам нужен 64-битный двоичный файл, не передавайте -mno-sse флаг, потому что это нарушение 64-битного ABI, который предполагает поддержку SSE2 как минимум.

(Хотя я игнорирую это здесь, технически целесообразно передать -mno-sse флаг вместе с -m64 флаг. Действительно, это явно поддерживается GCC, потому что он используется для компиляции кода ядра Linux, где состояние регистров XMM не сохраняется между вызовами. Это работает только потому, что код ядра не выполняет операций с плавающей запятой. -mno-sse Параметр switch используется только для предотвращения использования компилятором инструкций SSE как части расширенной оптимизации, которая не имеет ничего общего с операциями с плавающей запятой.)

Другие вопросы по тегам