встроенная функция в разных единицах перевода с разными флагами компилятора неопределенное поведение?

в Visual Studio вы можете установить различные параметры компилятора для отдельных файлов cpp. например: в разделе «генерация кода» мы можем включить базовые проверки времени выполнения в режиме отладки. или мы можем изменить модель с плавающей запятой (точная / строгая / быстрая). это всего лишь примеры. есть много разных флагов.

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

действительно ли мы так быстры в стране неопределенного поведения? или компиляторы справятся с этими случаями?


person phön    schedule 28.08.2018    source источник
comment
Итак, в одном модуле компиляции sum(2,2) дает 4, в другом - 5. Я не вижу здесь неопределенного поведения,   -  person Alex F    schedule 28.08.2018
comment
@AlexF Проблема заключается в единственном правиле определения. Множественные (разные) определения в разных единицах перевода - это UB. С шаблонами дело обстоит немного иначе, но для встроенных функций без шаблонов это определенно может стать проблемой.   -  person Some programmer dude    schedule 28.08.2018
comment
@AlexF, если мы объявляем функцию статической (чтобы иметь внутреннюю связь), вы правы. но я спрашиваю о встроенных функциях   -  person phön    schedule 28.08.2018
comment
@Someprogrammerdude: конечно, это проблема, но это УБ? В вашей первой ссылке Одно правило определения для встроенных функций ограничено каждой единицей перевода.   -  person Alex F    schedule 28.08.2018
comment
@AlexF В нем говорится, что встроенная функция должна быть определена в каждой единице перевода, где она используется odr, и определения должны соответствовать условиям в маркированном списке (который, по сути, говорит, что все определения должны выглядеть одинаково и быть эквивалентными).   -  person molbdnilo    schedule 28.08.2018
comment
Я думаю, что это своего рода серая зона. Требования ODR касаются формы и семантики множественных определений, а не сгенерированного кода.   -  person molbdnilo    schedule 28.08.2018
comment
@Someprogrammerdude Я не понимаю, как это можно решить с помощью любого количества стандартных интерпретаций, поскольку флаги компилятора даже не существуют в том, что касается языка.   -  person Passer By    schedule 28.08.2018
comment
@molbdnilo Цель ODR состоит в том, чтобы несколько компиляций одной и той же функции не имели отличительных черт.   -  person curiousguy    schedule 23.09.2018
comment
Если у вас недетерминированное поведение fp, любая встроенная функция, возвращающая float, потенциально нарушает семантический ODR.   -  person curiousguy    schedule 23.09.2018


Ответы (3)


Что касается Стандарта, каждая комбинация флагов командной строки превращает компилятор в другую реализацию. Хотя реализациям полезно иметь возможность использовать объектные файлы, созданные другими реализациями, Стандарт не требует, чтобы они это делали.

Даже при отсутствии встраивания рассмотрите возможность наличия следующей функции в одном модуле компиляции:

char foo(void) { return 255; }

и следующее в другом:

char foo(void);
int arr[128];
void bar(void)
{
  int x=foo();
  if (x >= 0 && x < 128)
     arr[x]=1;
}

Если char был типом со знаком в обоих модулях компиляции, значение x во втором модуле было бы меньше нуля (таким образом, пропуская присвоение массива). Если бы это был беззнаковый тип в обоих модулях, он был бы больше 127 (аналогично пропуская присвоение). Однако, если одна единица компиляции использовала подписанный char, а другой - беззнаковый, и если реализация ожидала возвращаемые значения с расширением знака или расширением нуля в регистре результатов, результатом может быть то, что компилятор может определить, что x не может быть больше 127, даже если он содержит 255, или что он не может быть меньше 0, даже если он содержит -1. Следовательно, сгенерированный код может обращаться к arr[255] или arr[-1] с потенциально катастрофическими результатами.

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

person supercat    schedule 28.08.2018
comment
Итак, практическое правило - не изменять флаги в отдельных единицах перевода. Если это так строго, как кажется, почему MSVC это позволяет? Я понимаю это из-за уровня предупреждений и других не очень важных вещей, но флаги нарушения совместимости кода вызывают у меня тревогу. - person phön; 30.08.2018
comment
@ phön: Некоторые аспекты компиляции должны согласовываться между единицами перевода; другие части этого не делают. Документация по реализации обычно должна четко указывать, какие части есть, но это проблема качества реализации, выходящая за рамки юрисдикции Стандарта. Я не хочу сказать, что смена флагов обычно опасна, но это не всегда безопасно. - person supercat; 30.08.2018
comment
@ phön: Также, что касается MSVC, я думаю, что соглашения о вызовах, используемые этим компилятором как в режиме x86, так и в режиме x64, указывают, что когда функция возвращает char или unsigned char, нижние 8 бит расширенного регистра AX (EAX или RAX) сохраняют значение и остальные биты (24 или 56 из них) содержат неопределенные значения. Таким образом, при использовании MSVC функция, которая возвращает знаковый символ, но вызывается кодом, ожидающим беззнаковый символ, или наоборот, будет вести себя так, как если бы результат был преобразован в тип, ожидаемый вызывающей стороной. Однако другие платформы, такие как ARM, ведут себя не так хорошо. - person supercat; 30.08.2018
comment
Что касается Стандарта, каждая комбинация флагов командной строки превращает компилятор в другую реализацию Я не знаю, где вы это читаете. Да, любая комбинация флагов, изменяющая определенные реализацией аспекты (знак символа). - person curiousguy; 30.11.2018
comment
@curiousguy: Что касается стандартов, реализация C или C ++ принимает кучу исходных файлов в качестве входных данных вместе с любыми входными данными для запускаемой программы и автоматически делает все необходимое для вывода в качестве выходных данных независимо от рассматриваемой программы. должен производить. В Стандартах нет концепции постоянных объектных файлов и не определены какие-либо средства, с помощью которых можно было бы изменить любые исходные файлы или конфигурацию компилятора между первым моментом начала фазы 1 компиляции для любой части программы и временем завершения выполнения программы. . - person supercat; 30.11.2018

Недавно я написал код для теста GCC, если эта проблема действительно существует.

СПОЙЛЕР: да.

Настраивать:

Я компилирую часть нашего кода с использованием инструкций AVX512. Поскольку большинство процессоров не поддерживают AVX512, нам нужно скомпилировать большую часть нашего кода без AVX512. Возникает вопрос: может ли встроенная функция, используемая в файле cpp, скомпилированном с помощью AVX512, «отравить» всю библиотеку недопустимыми инструкциями.

Представьте себе случай, когда функция из файла cpp, отличного от AVX512, вызывает нашу функцию, но попадает в сборку, исходящую из скомпилированного модуля AVX512. Это даст нам illegal instruction на машинах без AVX512.

Давайте попробуем:

func.h

inline void __attribute__ ((noinline)) double_it(float* f) {
  for (int i = 0; i < 16; i++)
    f[i] = f[i] + f[i];
}

Мы определяем встроенную (в смысле компоновщика) функцию. Использование жестко запрограммированного 16 заставит оптимизатор GCC использовать инструкции AVX512. Мы должны сделать это ((noinline)), чтобы компилятор не встраивал его (то есть вставлял его код для вызывающих). Это дешевый способ притвориться, что эта функция слишком длинная, чтобы ее можно было встраивать.

avx512.cpp

#include "func.h"
#include <iostream>

void run_avx512() {
  volatile float f = 1;
  float arr [16] = {f};
  double_it(arr);
  for (int i = 0; i < 16; i++)
    std::cout << arr[i] << " ";
  std::cout << std::endl;
}

Это использование AVX512 нашей double_it функции. Он удваивает некоторый массив и печатает результат. Мы скомпилируем его с помощью AVX512.

non512.cpp

#include "func.h"
#include <iostream>

void run_non_avx() {
  volatile float f = 1;
  float arr [16] = {f};
  double_it(arr);
  for (int i = 0; i < 16; i++)
    std::cout << arr[i] << " ";
  std::cout << std::endl;
}

Та же логика, что и раньше. Этот не будет компилироваться с AVX512.

lib_user.cpp

void run_non_avx();

int main() {
  run_non_avx();
}

Какой-то код пользователя. Вызывает `run_non_avx, который был скомпилирован без AVX512. Он не знает, что он взорвется :)

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

g++ -c avx512.cpp -o avx512.o -O3 -mavx512f -g3 -fPIC
g++ -c non512.cpp -o non512.o -O3 -g3 -fPIC
g++ -shared avx512.o non512.o -o libbad.so
g++ lib_user.cpp -L . -lbad -o lib_user.x
./lib_user.x

Запуск этого на моей машине (без AVX512) дает мне

$ ./lib_user.x
Illegal instruction (core dumped)

Кстати, если я изменю порядок avx512.o non512.o, он начнет работать. Похоже, компоновщик игнорирует последующие реализации тех же функций.

person S. Kaczor    schedule 12.08.2019
comment
Странно то, что проблема уже решена в GCC для оптимизации LTO, но не для встроенных встроенных функций в заголовках. В любом случае уловка состоит в том, чтобы заставить встраиваться. - person Allan Jensen; 16.07.2020

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

Нет («Идентичный» здесь даже не является четко определенным понятием.)

Формально определения должны быть эквивалентными в каком-то очень строгом смысле, который даже не имеет смысла как требование и который никого не волнует:

// in some header (included in multiple TU):

const int limit_max = 200; // implicitly static

inline bool check_limit(int i) {
  return i<=limit_max; // OK
}

inline int impose_limit(int i) {
  return std::min(i, limit_max); // ODR violation
}

Такой код вполне разумен, но формально нарушает одно правило определения:

в каждом определении D соответствующие имена, найденные в соответствии с 6.4 [basic.lookup], должны ссылаться на объект, определенный в определении D, или должны ссылаться на тот же объект после разрешения перегрузки (16.3 [over.match] ) и после сопоставления частичной специализации шаблона (17.9.3 [temp.over]), за исключением того, что имя может относиться к константному объекту с внутренней связью или без связи, если объект имеет один и тот же литеральный тип во всех определениях D, и объект инициализируется постоянным выражением (8.20 [expr.const]), и используется значение (но не адрес) объекта, и объект имеет то же значение во всех определениях D ;

Поскольку исключение не позволяет использовать константный объект с внутренней связью (const int неявно статичен) с целью прямой привязки константной ссылки (а затем использования ссылки только для ее значения). Правильная версия:

inline int impose_limit(int i) {
  return std::min(i, +limit_max); // OK
}

Здесь значение limit_max используется в унарном операторе +, а тогда константная ссылка привязывается к временной инициализации с этим значением. Кто на самом деле так делает?

Но даже комитет не верит в формальные вопросы ODR, как мы видим в Основная проблема 1511:

1511. переменные const volatile и правило одного определения

Раздел: 6.2 [basic.def.odr] Статус: CD3 Отправитель: Ричард Смит Дата: 18.06.2012

[Перемещено в DR на собрании в апреле 2013 г.]

Эта формулировка, возможно, недостаточно ясна для такого примера, как:

  const volatile int n = 0;
  inline int get() { return n; }

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

Важно то, что эффект встроенной функции неопределенно эквивалентен: выполнение volatile int read, что является очень слабым эквивалентом, но достаточным для естественного использования ODR, что является безразличием экземпляра : какой именно экземпляр встроенной функции используется, не имеет значения и не может иметь значения.

В частности, значение, считываемое при чтении volatile, по определению не известно компилятору, поэтому условие публикации и инварианты этой функции, анализируемые компилятором, совпадают.

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

Если вы используете другие параметры компилятора, они не должны изменять диапазон возможных результатов функции (возможно, с точки зрения компилятора).

Поскольку «стандарт» (который на самом деле не является спецификацией языка программирования) позволяет объектам с плавающей запятой иметь реальное представление, не разрешенное их официально объявленным типом, совершенно неограниченным образом, используя любой не изменчивый квалифицированный тип с плавающей запятой в все, что многократно определяется в соответствии с ODR, кажется проблематичным, если вы не активируете режим «double означает double» (который является единственным нормальным режимом).

person curiousguy    schedule 30.11.2018