Как может следующая программа вызывать format_disk, если она никогда не называется в коде?

#include <cstdio>

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

static void (*foo)() = nullptr;

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}

Это отличается от компилятора к компилятору. Компиляция с помощью Clang с оптимизации, функция never_called выполняется во время выполнения.

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

Однако при компиляции с GCC этот код просто дает сбой:

$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)

Версия компиляторов:

$ clang --version
clang version 5.0.0 (tags/RELEASE_500/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
$ gcc --version
gcc (GCC) 7.2.1 20171128
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
24
Mário Feroldi 2 Янв 2018 в 15:49

2 ответа

Лучший ответ

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

Выполнение format_disk во время выполнения - идеальная допустимая ситуация, когда undefined поведение, это так же верно, как просто сбой (например, при компиляции с GCC). Хорошо, но почему Кланг это делает? если ты скомпилируйте его с выключенной оптимизацией, программа больше не будет выводить "форматирование жесткого диска", и произойдет сбой:

$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)

Сгенерированный код для этой версии выглядит следующим образом:

main:                                   # @main
        push    rbp
        mov     rbp, rsp
        call    qword ptr [foo]
        xor     eax, eax
        pop     rbp
        ret

Он пытается вызвать функцию, на которую указывает foo, и как foo инициализируется с помощью nullptr (или, если инициализации не было, это все равно будет так), его значение равно нулю. Здесь undefined поведение было нарушено, так что все может случиться и программа становится бесполезным. Обычно при звонке на такой недействительный адрес приводит к ошибкам сегментации, поэтому сообщение, которое мы получаем, когда выполнение программы.

Теперь давайте рассмотрим ту же программу, но компилируем ее с оптимизацией на:

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

Сгенерированный код для этой версии выглядит следующим образом:

never_called():                         # @never_called()
        ret
main:                                   # @main
        push    rax
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

Интересно, что некоторые оптимизации изменили программу так, что main вызывает std::puts напрямую. Но зачем Кланг это сделал? И почему never_called скомпилирован в одну инструкцию ret?

Вернемся ненадолго к стандарту (в частности, к N4660). Что там говорится о неопределенном поведении?

3.27 неопределенное поведение [defns.undefined]

поведение, для которого этот документ не предъявляет никаких требований

[Примечание: может ожидаться неопределенное поведение , если в этом документе отсутствует любое явное определение поведения или когда программа использует ошибочный построение или ошибочные данные. Допустимые диапазоны неопределенного поведения от полного игнорирования ситуации с непредсказуемыми результатами до поведение во время перевода или выполнения программы задокументированным образом характеристика среды (с выдачей или без выдачи диагностическое сообщение), для завершения трансляции или выполнения (с выдача диагностического сообщения). Многие ошибочные конструкции программы не порождают неопределенного поведения; они должны быть диагностированы. Оценка постоянного выражения никогда не показывает поведение явно указано как неопределенное ([expr.const]). - конец примечания]

Акцент мой.

Программа, демонстрирующая неопределенное поведение, становится бесполезной, поскольку все это было сделано до сих пор и будет работать дальше, не имеет смысла, если он содержит ошибочные данные или конструкции. Имея это в виду, помните, что компиляторы могут полностью игнорировать случай, когда поведение undefined попал, и это фактически используется как обнаруженный факт при оптимизации программа. Например, такая конструкция, как x + 1 > x (где x - целое число со знаком), будет оптимизирована до константы, true, даже если значение x неизвестно во время компиляции. Рассуждение заключается в том, что компилятор хочет оптимизировать для допустимых случаев, и единственный способ сделать эту конструкцию действительной - это когда она не запускает арифметические операции переполнение (т.е. если x != std::numeric_limits<decltype(x)>::max()). это - это новый факт в оптимизаторе. Исходя из этого, конструкция доказано, что всегда оценивает как истину.

Примечание : такая же оптимизация не может происходить для целых чисел без знака, потому что переполнение - это не UB. То есть компилятору необходимо сохранить выражение как есть, поскольку оно может иметь другую оценку при переполнении (без знака - это модуль 2 N , где N - количество бит). Оптимизация его для целых чисел без знака будет несовместима со стандартом (спасибо aschepler).

Это полезно, поскольку позволяет использовать массу оптимизаций в. Так пока все хорошо, но что произойдет, если x сохранит максимальное значение во время выполнения? Что ж, это неопределенное поведение, поэтому бессмысленно рассуждать о это, как бы то ни было, и стандарт не предъявляет никаких требований.

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

static void (*foo)() = nullptr;

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}

Помните, что можно вызвать never_called перед записью main начинает выполняться. Например, при объявлении переменной верхнего уровня, вы можете вызвать его при инициализации значения этой переменной:

void never_called();
int x = (never_called(), 42);

Если вы напишете этот фрагмент в своей программе, программа no дольше демонстрирует неопределенное поведение, а сообщение "жестко форматируется диск! " отображается с включенной или выключенной оптимизацией.

Так как же только эта программа действительна? Вот это never_caled функция, которая присваивает адрес format_disk foo, чтобы мы могли найди что-нибудь здесь. Обратите внимание, что foo отмечен как static, что означает, что имеет внутреннюю ссылку и недоступен извне этого перевода Блок. Напротив, функция never_called имеет внешнюю связь и может быть доступным извне. Если другая единица перевода содержит фрагмент как и предыдущая, тогда эта программа становится действительной.

Круто, но никто не звонит never_called извне. Хотя это это факт, оптимизатор видит, что единственный способ для этой программы быть действительным, если never_called вызывается до выполнения main, в противном случае просто неопределенное поведение. Это новый факт, поэтому компилятор предполагает, что never_called на самом деле называется. На основе этих новых знаний были произведены другие оптимизации, удар может воспользоваться этим.

Например, когда константа сворачивание применен, он видит, что конструкция foo() действительна только в том случае, если foo может быть правильно инициализирован. Единственный способ сделать это - вызвать never_called вне этой единицы трансляции, поэтому foo = format_disk.

Удаление мертвого кода и межпроцедурная оптимизация может обнаружить, что если foo == format_disk, то код внутри never_called не нужен, поэтому тело функции преобразуется в одну инструкцию ret.

Оптимизация встроенного расширения видит, что foo == format_disk, поэтому вызов foo можно заменить своим телом. В итоге мы получаем что-то вроде этого:

never_called():
        ret
main:
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

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

Изучая вывод GCC с включенной оптимизацией, кажется, что он не стал исследовать:

.LC0:
        .string "formatting hard disk drive!"
format_disk():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
never_called():
        mov     QWORD PTR foo[rip], OFFSET FLAT:format_disk()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret

Выполнение этой программы приводит к сбою (ошибка сегментации), но если вы вызываете never_called в другом блоке трансляции до выполнения main, то эта программа больше не проявляет неопределенного поведения.

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


Я рекомендую вам прочитать Что должен знать каждый программист на C about Undefined Behavior и A Guide to Undefined Behavior в C и C ++, обе статьи серии очень информативны и могут помочь вам понять современное состояние дел.

36
Mário Feroldi 27 Окт 2019 в 13:32

Если реализация не определяет эффект попытки вызвать нулевой указатель на функцию, она может вести себя как вызов произвольного кода. Такой произвольный код вполне мог вести себя как вызов функции «foo ()». В то время как приложение L к стандарту C предлагает реализациям различать «критический UB» и «некритический UB», а некоторые реализации C ++ могут применять подобное различие, вызов недопустимого указателя функции будет критическим UB в любом случае.

Обратите внимание, что ситуация в этом вопросе сильно отличается от, например,

unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}

В последней ситуации компилятор, который не утверждает, что он «анализируемый», может распознать, что код будет вызывать, если q больше 46,340, когда выполнение достигает оператора return, и, таким образом, он может также вызвать {{X1} } безусловно. Хотя Приложение L написано плохо, похоже, намерение состоит в том, чтобы запретить такие «оптимизации». Однако в случае вызова недопустимого указателя функции даже непосредственно сгенерированный код на большинстве платформ может иметь произвольное поведение.

0
supercat 3 Янв 2018 в 02:19