gcc-10.0.1 специфический сегмент

23

У меня есть пакет R с скомпилированным кодом C, который довольно долго был относительно стабильным и часто тестировался на широком спектре платформ и компиляторов (windows / osx / debian / fedora gcc / clang).

Совсем недавно была добавлена ​​новая платформа для тестирования пакета:

Logs from checks with gcc trunk aka 10.0.1 compiled from source
on Fedora 30. (For some archived packages, 10.0.0.)

x86_64 Fedora 30 Linux

FFLAGS="-g -O2 -mtune=native -Wall -fallow-argument-mismatch"
CFLAGS="-g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"
CXXFLAGS="-g -O2 -Wall -pedantic -mtune=native -Wno-ignored-attributes -Wno-deprecated-declarations -Wno-parentheses -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"

В этот момент скомпилированный код быстро начал segfaulting по следующим направлениям:

 *** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'

Я был в состоянии воспроизвести segfault последовательно с помощью rocker/r-baseконтейнера Docker gcc-10.0.1с уровнем оптимизации -O2. Запуск более низкой оптимизации избавляет от проблемы. Запуск любой другой установки, в том числе под valgrind (как -O0, так и -O2), UBSAN (gcc / clang), не показывает никаких проблем. Я также достаточно уверен, что это gcc-10.0.0произошло, но у меня нет данных.

Я запустил gcc-10.0.1 -O2версию с gdbи заметил что-то странное для меня:

GDB против кода

Проходя по выделенному разделу, кажется, что инициализация вторых элементов массивов пропущена ( R_allocэто обертка вокруг того, mallocчто сам мусор собирает при возврате управления в R; ошибка происходит до возврата в R). Позже, программа падает, когда происходит доступ к неинициализированному элементу (в версии gcc.10.0.1 -O2).

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

Я что-то упускаю очевидное или делаю что-то глупое? Оба вполне вероятны, поскольку C - мой второй язык на сегодняшний день . Просто странно, что это сейчас произошло, и я не могу понять, что пытается сделать компилятор.


ОБНОВЛЕНИЕ : инструкции, чтобы воспроизвести это, хотя это будет воспроизводиться только до тех пор, пока debian:testingконтейнер докера имеет gcc-10в gcc-10.0.1. Кроме того, не просто запускайте эти команды, если вы не доверяете мне .

Извините, это не минимальный воспроизводимый пример.

docker pull rocker/r-base
docker run --rm -ti --security-opt seccomp=unconfined \
  rocker/r-base /bin/bash
apt-get update
apt-get install gcc-10 gdb
gcc-10 --version  # confirm 10.0.1
# gcc-10 (Debian 10-20200222-1) 10.0.1 20200222 (experimental) 
# [master revision 01af7e0a0c2:487fe13f218:e99b18cf7101f205bfdd9f0f29ed51caaec52779]

mkdir ~/.R
touch ~/.R/Makevars
echo "CC = gcc-10
CFLAGS = -g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection
" >> ~/.R/Makevars

R -d gdb --vanilla

Тогда в R консоли, после ввода , runчтобы gdbзапустить программу:

f.dl <- tempfile()
f.uz <- tempfile()

github.url <- 'https://github.com/brodieG/vetr/archive/v0.2.8.zip'

download.file(github.url, f.dl)
unzip(f.dl, exdir=f.uz)
install.packages(
  file.path(f.uz, 'vetr-0.2.8'), repos=NULL,
  INSTALL_opts="--install-tests", type='source'
)
# minimal set of commands to segfault
library(vetr)
alike(pairlist(a=1, b="character"), pairlist(a=1, b=letters))
alike(pairlist(1, "character"), pairlist(1, letters))
alike(NULL, 1:3)                  # not a wild card at top level
alike(list(NULL), list(1:3))      # but yes when nested
alike(list(NULL, NULL), list(list(list(1, 2, 3)), 1:25))
alike(list(NULL), list(1, 2))
alike(list(), list(1, 2))
alike(matrix(integer(), ncol=7), matrix(1:21, nrow=3))
alike(matrix(character(), nrow=3), matrix(1:21, nrow=3))
alike(
  matrix(integer(), ncol=3, dimnames=list(NULL, c("R", "G", "B"))),
  matrix(1:21, ncol=3, dimnames=list(NULL, c("R", "G", "B")))
)

# Adding tests from docs

mx.tpl <- matrix(
  integer(), ncol=3, dimnames=list(row.id=NULL, c("R", "G", "B"))
)
mx.cur <- matrix(
  sample(0:255, 12), ncol=3, dimnames=list(row.id=1:4, rgb=c("R", "G", "B"))
)
mx.cur2 <-
  matrix(sample(0:255, 12), ncol=3, dimnames=list(1:4, c("R", "G", "B")))

alike(mx.tpl, mx.cur2)

Проверка в GDB довольно быстро показывает (если я правильно понимаю), что CSR_strmlen_xпытается получить доступ к строке, которая не была инициализирована.

ОБНОВЛЕНИЕ 2 : это очень рекурсивная функция, и, кроме того, бит инициализации строки вызывается много-много раз. В основном это потому, что я был ленив, нам нужно, чтобы строки инициализировались только для того случая, когда мы действительно сталкиваемся с чем-то, о чем мы хотим сообщить в рекурсии, но инициализировать было легче каждый раз, когда можно встретиться с чем-то. Я упоминаю об этом, потому что то, что вы увидите далее, показывает несколько инициализаций, но используется только одна из них (предположительно, с адресом <0x1400000001>).

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

введите описание изображения здесь

ОБНОВЛЕНИЕ 3 , разборка рассматриваемой функции:

Breakpoint 1, ALIKEC_res_strings_init () at alike.c:75
75    return res;
(gdb) p res.current[0]
$1 = 0x7ffff46a0aa5 "%s%s%s%s"
(gdb) p res.current[1]
$2 = 0x1400000001 <error: Cannot access memory at address 0x1400000001>
(gdb) disas /m ALIKEC_res_strings_init
Dump of assembler code for function ALIKEC_res_strings_init:
53  struct ALIKEC_res_strings ALIKEC_res_strings_init() {
   0x00007ffff4687fc0 <+0>: endbr64 

54    struct ALIKEC_res_strings res;

55  
56    res.target = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fc4 <+4>: push   %r12
   0x00007ffff4687fc6 <+6>: mov    $0x8,%esi
   0x00007ffff4687fcb <+11>:    mov    %rdi,%r12
   0x00007ffff4687fce <+14>:    push   %rbx
   0x00007ffff4687fcf <+15>:    mov    $0x5,%edi
   0x00007ffff4687fd4 <+20>:    sub    $0x8,%rsp
   0x00007ffff4687fd8 <+24>:    callq  0x7ffff4687180 <R_alloc@plt>
   0x00007ffff4687fdd <+29>:    mov    $0x8,%esi
   0x00007ffff4687fe2 <+34>:    mov    $0x5,%edi
   0x00007ffff4687fe7 <+39>:    mov    %rax,%rbx

57    res.current = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fea <+42>:    callq  0x7ffff4687180 <R_alloc@plt>

58  
59    res.target[0] = "%s%s%s%s";
   0x00007ffff4687fef <+47>:    lea    0x1764a(%rip),%rdx        # 0x7ffff469f640
   0x00007ffff4687ff6 <+54>:    lea    0x18aa8(%rip),%rcx        # 0x7ffff46a0aa5
   0x00007ffff4687ffd <+61>:    mov    %rcx,(%rbx)

60    res.target[1] = "";

61    res.target[2] = "";
   0x00007ffff4688000 <+64>:    mov    %rdx,0x10(%rbx)

62    res.target[3] = "";
   0x00007ffff4688004 <+68>:    mov    %rdx,0x18(%rbx)

63    res.target[4] = "";
   0x00007ffff4688008 <+72>:    mov    %rdx,0x20(%rbx)

64  
65    res.tar_pre = "be";

66  
67    res.current[0] = "%s%s%s%s";
   0x00007ffff468800c <+76>:    mov    %rax,0x8(%r12)
   0x00007ffff4688011 <+81>:    mov    %rcx,(%rax)

68    res.current[1] = "";

69    res.current[2] = "";
   0x00007ffff4688014 <+84>:    mov    %rdx,0x10(%rax)

70    res.current[3] = "";
   0x00007ffff4688018 <+88>:    mov    %rdx,0x18(%rax)

71    res.current[4] = "";
   0x00007ffff468801c <+92>:    mov    %rdx,0x20(%rax)

72  
73    res.cur_pre = "is";

74  
75    return res;
=> 0x00007ffff4688020 <+96>:    lea    0x14fe0(%rip),%rax        # 0x7ffff469d007
   0x00007ffff4688027 <+103>:   mov    %rax,0x10(%r12)
   0x00007ffff468802c <+108>:   lea    0x14fcd(%rip),%rax        # 0x7ffff469d000
   0x00007ffff4688033 <+115>:   mov    %rbx,(%r12)
   0x00007ffff4688037 <+119>:   mov    %rax,0x18(%r12)
   0x00007ffff468803c <+124>:   add    $0x8,%rsp
   0x00007ffff4688040 <+128>:   pop    %rbx
   0x00007ffff4688041 <+129>:   mov    %r12,%rax
   0x00007ffff4688044 <+132>:   pop    %r12
   0x00007ffff4688046 <+134>:   retq   
   0x00007ffff4688047:  nopw   0x0(%rax,%rax,1)

End of assembler dump.

ОБНОВЛЕНИЕ 4 :

Итак, попытка разобрать стандарт здесь - это те его части, которые кажутся актуальными ( черновик C11 ):

6.3.2.3 Преобразования Par7> Другие операнды> Указатели

Указатель на тип объекта может быть преобразован в указатель на другой тип объекта. Если результирующий указатель неправильно выровнен 68) для ссылочного типа, поведение не определено.
В противном случае при обратном преобразовании результат сравнивается равным исходному указателю. Когда указатель на объект преобразуется в указатель на тип символа, результат указывает на младший адресуемый байт объекта. Последовательные приращения результата, вплоть до размера объекта, дают указатели на оставшиеся байты объекта.

6.5 Par6 Выражения

Эффективным типом объекта для доступа к его сохраненному значению является объявленный тип объекта, если таковой имеется. 87) Если значение сохраняется в объекте, у которого нет объявленного типа, через lvalue, имеющий тип, который не является символьным типом, то тип lvalue становится эффективным типом объекта для этого доступа и для последующих доступов, которые не изменить сохраненное значение. Если значение копируется в объект, не имеющий объявленного типа, с использованием memcpy или memmove, или копируется в виде массива символьного типа, то эффективный тип измененного объекта для этого доступа и для последующих доступов, которые не изменяют значение, является действующий тип объекта, из которого копируется значение, если оно есть. Для всех других обращений к объекту, не имеющему объявленного типа, эффективным типом объекта является просто тип lvalue, используемого для доступа.

87) Выделенные объекты не имеют объявленного типа.

IIUC R_allocвозвращает смещение в mallocблок ed, который гарантированно будет doubleвыровнен, и размер блока после смещения имеет запрошенный размер (также имеется распределение перед смещением для R конкретных данных). R_allocбросает этот указатель (char *)на возврат.

Раздел 6.2.5 Пар 29

Указатель на void должен иметь те же требования к представлению и выравниванию, что и указатель на тип символа. 48) Аналогично, указатели на квалифицированные или неквалифицированные версии совместимых типов должны иметь одинаковые требования к представлению и выравниванию. Все указатели на типы конструкций должны иметь те же требования к представлению и выравниванию, что и другие.
Все указатели на типы объединения должны иметь те же требования к представлению и выравниванию, что и другие.
Указатели на другие типы не обязательно должны иметь одинаковые требования к представлению или выравниванию.

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

Так что вопрос «мы позволили переделано (char *)в (const char **)и записи на него как (const char **)». Мое прочтение вышеизложенного состоит в том, что если указатели в системах, в которых выполняется код, имеют выравнивание, совместимое с doubleвыравниванием, то все в порядке.

Мы нарушаем "строгий псевдоним"? то есть:

6.5 Пар 7

Объект должен иметь свое сохраненное значение, доступное только через выражение lvalue, которое имеет один из следующих типов: 88)

- тип, совместимый с эффективным типом объекта ...

88) Цель этого списка - указать те обстоятельства, при которых объект может или не может быть псевдонимом.

Итак, что должен думать компилятор о эффективном типе объекта, на который указывает res.target(или res.current)? Предположительно заявленный тип (const char **), или это на самом деле неоднозначно? Мне кажется, что это не в этом случае только потому, что в области видимости нет другого «lvalue», который обращается к тому же объекту.

Я признаю, что я изо всех сил стараюсь извлечь смысл из этих разделов стандарта.

BrodieG
источник
Если это еще не сделано, возможно, стоит посмотреть на разборку, чтобы точно узнать, что делается. А также сравнить разборки между версиями gcc.
Кайлум
2
Я бы не стал связываться с магистральной версией GCC. С ним приятно повеселиться, но это называется багажником по причине. К сожалению, почти невозможно сказать, что не так, если (1) ваш код и точный config (2) не имеют одинаковую версию GCC (3) в одной архитектуре. Я бы посоветовал проверить, сохраняется ли это, когда 10.0.1 перемещается из транка в стабильный.
Марко Бонелли
1
Еще один комментарий: -mtune=nativeоптимизирует для конкретного процессора, который есть на вашей машине. Это будет отличаться для разных тестеров и может быть частью проблемы. Если вы запустите компиляцию вместе с -vвами, вы сможете увидеть, какое семейство процессоров находится на вашем компьютере (например, -mtune=skylakeна моем компьютере).
Нейт Элдридж
1
Трудно отличить отладочные прогоны. Разборка должна быть окончательной. Вам не нужно ничего извлекать, просто найдите файл .o, созданный при компиляции проекта, и разберите его. Вы также можете использовать disassembleинструкцию внутри GDB.
Нейт Элдридж
5
В любом случае, поздравляю, вы один из немногих, чья проблема на самом деле была ошибкой компилятора.
Нейт Элдридж

Ответы:

22

Описание: Это похоже на ошибку в gcc, связанную с оптимизацией строк. Автономный тестовый пример ниже. Сначала были некоторые сомнения относительно правильности кода, но я думаю, что это так.

Я сообщил об ошибке как PR 93982 . Предложенное исправление было совершено, но оно исправляет его не во всех случаях, что привело к последующей проверке PR 94015 ( ссылка на Godbolt ).

Вы должны быть в состоянии обойти ошибку, компилируя с флагом -fno-optimize-strlen.


Мне удалось сократить ваш тестовый пример до следующего минимального примера (также на Godbolt ):

struct a {
    const char ** target;
};

char* R_alloc(void);

struct a foo(void) {
    struct a res;
    res.target = (const char **) R_alloc();
    res.target[0] = "12345678";
    res.target[1] = "";
    res.target[2] = "";
    res.target[3] = "";
    res.target[4] = "";
    return res;
}

С gcc trunk (версия gcc 10.0.1 20200225 (экспериментальная)) и -O2(все остальные параметры оказались ненужными), генерируемая сборка на amd64 выглядит следующим образом:

.LC0:
        .string "12345678"
.LC1:
        .string ""
foo:
        subq    $8, %rsp
        call    R_alloc
        movq    $.LC0, (%rax)
        movq    $.LC1, 16(%rax)
        movq    $.LC1, 24(%rax)
        movq    $.LC1, 32(%rax)
        addq    $8, %rsp
        ret

Таким образом, вы совершенно правы, что компилятор не может инициализироваться res.target[1](обратите внимание на заметное отсутствие movq $.LC1, 8(%rax)).

Интересно поиграть с кодом и посмотреть, что влияет на «баг». Возможно, значительно, если изменить тип возвращаемого значения R_allocна, void *он исчезнет и даст «правильный» вывод сборки. Может быть, менее существенно, но более забавно, изменение строки "12345678"на более длинную или короткую также заставляет ее исчезнуть.


Предыдущее обсуждение, теперь решенное - код, по-видимому, законный.

У меня вопрос, является ли ваш код на самом деле законным. Тот факт , что вы принимаете char *возвращаемым R_alloc()и бросайте его const char **, а затем сохранить , const char *кажется , что это может нарушить строгое правило наложения спектров , так как charи const char *не совместимые типов. Есть исключение, которое позволяет вам получить доступ к любому объекту как char(для реализации подобных вещей memcpy), но это наоборот, и, насколько я понимаю, это не разрешено. Это заставляет ваш код вызывать неопределенное поведение, и поэтому компилятор может легально делать все, что захочет.

Если это так, правильное исправление было бы для R, чтобы изменить свой код так, чтобы он R_alloc()возвращался void *вместо char *. Тогда не было бы проблемы с наложением. К сожалению, этот код находится вне вашего контроля, и мне не ясно, как вы можете использовать эту функцию вообще, не нарушая строгий псевдоним. Обходной путь может заключаться во вставке временной переменной, например, void *tmp = R_alloc(); res.target = tmp;которая решает проблему в тестовом примере, но я все еще не уверен, допустимо ли это.

Тем не менее, я не уверен в этом «строгие» алиасов гипотезы, так как компиляция с -fno-strict-aliasing, что AFAIK предполагается сделать НКУ позволяют такие конструкции, это не делает проблему уйти!


Обновить. Попробовав несколько разных опций, я обнаружил, что любой из них -fno-optimize-strlenили -fno-tree-forwpropприведет к созданию «правильного» кода. Кроме того, использование -O1 -foptimize-strlenдает неверный код (но -O1 -ftree-forwpropне делает).

После небольшого git bisectупражнения ошибка, кажется, была введена в коммите 34fcf41e30ff56155e996f5e04 .


Обновление 2. Я попытался немного покопаться в исходном коде gcc, чтобы посмотреть, чему я могу научиться. (Я не претендую на звание эксперта по компиляции!)

Похоже, код tree-ssa-strlen.cпредназначен для отслеживания строк, появляющихся в программе. Насколько я могу судить, ошибка в том, что при взгляде на оператор res.target[0] = "12345678";компилятор связывает адрес строкового литерала "12345678"с самой строкой. (Похоже, это связано с этим подозрительным кодом, который был добавлен в вышеупомянутом коммите, где, если он пытается подсчитать байты «строки», которая на самом деле является адресом, он вместо этого смотрит на то, на что указывает этот адрес.)

Поэтому он считает , что заявление res.target[0] = "12345678", вместо того чтобы хранить адрес в "12345678"по адресу res.target, хранят строковые себя по этому адресу, как если бы заявление было strcpy(res.target, "12345678"). Обратите внимание на то, что впереди, что это приведет к тому, что конечный ноль будет храниться по адресу res.target+8(на данном этапе в компиляторе все смещения в байтах).

Теперь, когда компилятор смотрит на res.target[1] = ""это, он также обрабатывает это так, как если бы это было strcpy(res.target+8, ""), 8, приходящийся на размер a char *. То есть как будто он просто хранит нулевой байт по адресу res.target+8. Однако компилятор «знает», что предыдущий оператор уже сохранил нулевой байт по этому самому адресу! Таким образом, это утверждение является «избыточным» и может быть отброшено ( здесь ).

Это объясняет, почему строка должна быть ровно 8 символов, чтобы вызвать ошибку. (Хотя другие кратные 8 также могут вызвать ошибку в других ситуациях.)

Нейт Элдридж
источник
FWIW преобразование в другой тип указателя задокументировано . Я не знаю о псевдонимах, чтобы знать, нормально ли это переписывать, int*но не надо const char**.
BrodieG
Если мое понимание строгого псевдонима верно, то приведение к нему int *также является незаконным (или, скорее, фактически хранение ints там незаконно).
Нейт Элдридж
1
Это не имеет ничего общего со строгим правилом алиасинга. Строгое правило псевдонимов касается доступа к данным, которые вы уже сохранили, используя другой дескриптор. Как вы только назначаете здесь, это не касается строгого правила наложения имен. Приведение указателей допустимо, если оба типа указателей имеют одинаковые требования к выравниванию, но здесь вы выполняете приведение char*и работаете с x86_64 ... Я не вижу здесь UB, это ошибка gcc.
KamilCuk
1
Да и нет, @KamilCuk. В терминологии стандарта «доступ» включает в себя как чтение, так и изменение значения объекта. Поэтому строгое правило псевдонимов говорит о «хранении». Это не ограничено операциями чтения. Но для объектов без объявленного типа это затрагивается тем фактом, что запись в такой объект автоматически меняет свой эффективный тип в соответствии с написанным. Объекты без объявленного типа являются именно динамически размещаемыми объектами (независимо от типа указателя, к которому они обращаются), поэтому здесь действительно нет нарушения SA.
Джон Боллинджер
2
Да, @Nate, с этим определением R_alloc(), программа соответствует, независимо от того, в какой единице перевода R_alloc()определена. Это компилятор, который не соответствует здесь.
Джон Боллинджер