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

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

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

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

2 ответа

Решение

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

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

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 а другой использовал unsigned, однако, и если реализация ожидала возвращаемые значения с расширенным знаком или с нулевым расширением в регистре результатов, результатом может быть то, что компилятор может определить, что x не может быть больше 127, даже если оно содержит 255, или что оно не может быть меньше 0, даже если оно содержит -1. Следовательно, сгенерированный код может получить доступ arr[255] или же arr[-1]с потенциально катастрофическими результатами.

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

Недавно я написал код для теста 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, он начинает работать. Кажется, компоновщик игнорирует последующие реализации тех же функций.

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

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

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

// 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]), за исключением того, что имя может ссылаться на объект const с внутренней или без связи, если объект имеет одинаковый литеральный тип во всех определениях D, и объект инициализируется константным выражением (8.20 [expr.const]), и используется значение (но не адрес) объекта, и объект имеет одинаковое значение во всех определениях D;

Поскольку исключение не позволяет использовать объект const с внутренней связью (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 Автор: Ричард Смит Дата: 2012-06-18

[Перенесено в ДР на совещании в апреле 2013 года.]

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

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

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

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

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

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

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

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

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