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"

Після цього скомпільований код негайно розпочав сегментацію по цих рядках:

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

Мені вдалося послідовно відтворити сегментарій за допомогою 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 vs код

Під час переходу до виділеного розділу з'являється ініціалізація другого елемента масивів пропускається ( R_allocце обгортка навколо того, mallocщо самостійно збирається сміття при поверненні управління до R; segfault відбувається перед поверненням до R). Пізніше програма виходить з ладу при доступі до неініціалізованого елемента (у версії gcc.10.0.1 -O2).

Я вирішив це, явно ініціалізувавши відповідний елемент скрізь у коді, що врешті-решт призвело до використання елемента, але воно справді мало бути ініціалізовано до порожнього рядка, або, принаймні, так я б припустив.

Я пропускаю щось очевидне чи роблю щось дурне? Обидва досить імовірно , як C мій друга мова по далеко . Це просто дивно, що це зараз закінчилося, і я не можу зрозуміти, що намагається зробити компілятор.


UPDATE : Інструкція відтворити це, хоча це буде відтворювати тільки до тих пір , як 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 Пар6 вирази

Ефективним типом об'єкта для доступу до його збереженого значення є оголошений тип об'єкта, якщо такий є. 87) Якщо значення зберігається в об'єкті, що не має оголошеного типу, через lvalue, що має тип, який не є символьним типом, то тип lvalue стає ефективним типом об'єкта для цього доступу та для подальших доступу, які не виконують змінити збережене значення. Якщо значення копіюється в об'єкт, що не має оголошеного типу, використовуючи memcpy або memmove, або скопіюється як масив типу символів, то ефективним типом модифікованого об'єкта для цього доступу та для подальших доступу, які не змінюють значення, є ефективний тип об'єкта, з якого скопійовано значення, якщо воно має. Для всіх інших доступу до об'єкта, що не має оголошеного типу, ефективним типом об'єкта є просто тип значення, що використовується для доступу.

87) Виділені об'єкти не мають заявленого типу.

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

Розділ 6.2.5 Пар. 29

Вказівник на недійсність повинен мати ті ж вимоги щодо представлення та вирівнювання, що і вказівник на тип символів. 48) Аналогічно, покажчики на кваліфіковані або некваліфіковані версії сумісних типів повинні мати однакові вимоги щодо представлення та вирівнювання. Усі вказівники на типи структур мають однакові вимоги до представлення та вирівнювання, як один до одного.
Усі вказівники на типи об'єднання мають однакові вимоги щодо представлення та вирівнювання, як один до одного.
Покажчики на інші типи не повинні мати однакових вимог щодо представлення чи вирівнювання.

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

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

Ми порушуємо "суворий псевдонім"? тобто:

6.5 ч. 7

Об'єкт має збережене значення, доступ до якого має лише вираз lvalue, який має один із таких типів: 88)

- тип, сумісний з ефективним типом об'єкта ...

88) Метою цього списку є конкретизація тих обставин, за яких об'єкт може бути, а може і не бути відчуженим.

Отже, що повинен вважати компілятор ефективним типом об'єкта, на який вказує res.target(або res.current)? Імовірно, заявлений тип (const char **), чи це насправді неоднозначно? Мені здається, що це не в цьому випадку тільки тому, що немає іншого "значення" в області, яка отримує доступ до того ж об'єкта.

Я визнаю, що з усіх сил намагаюся витягнути сенс із цих розділів стандарту.


Якщо ще не вивчено, можливо, варто переглянути розбирання, щоб точно побачити, що робиться. А також порівняти розбирання між версіями gcc.
кайлум

2
Я б не намагався возитися з магістральною версією GCC. Приємно розважитися, але це називається багажник чомусь. На жаль, майже неможливо сказати, що не так, якщо (1) ваш код та точний конфігурація (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 (версія 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 повинен зробити gcc дозволити такі конструкції, не змушує проблему згасати!


Оновлення. Спробувавши кілька різних варіантів, я виявив, що -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 *також є незаконною (а точніше, фактично зберігання intтам незаконним).
Нейт Елдредж

1
Це не має нічого спільного із суворим правильним принципом. Суворе правило псевдоніму - це доступ до даних, які ви вже зберігали, використовуючи різні ручки. Оскільки ви призначаєте лише тут, воно не торкається суворого правила дозволу. Кастингові покажчики дійсні, коли обидва типи вказівників мають однакові вимоги до вирівнювання, але тут ви передаваєте char*та працюєте на x86_64 ... Я не бачу тут UB, це помилка gcc.
KamilCuk

1
Так і ні, @KamilCuk. У термінології стандарту "доступ" включає в себе як читання, так і зміну значення об'єкта. Таким чином, правило суворого псевдоніму говорить "зберігання". Він не обмежується операціями зчитування. Але для об’єктів, що не мають заявленого типу, це пояснюється тим, що запис на такий об'єкт автоматично змінює його ефективний тип, щоб відповідати тому, що було написано. Об'єкти без оголошеного типу - це саме динамічно виділені (незалежно від типу вказівника, за яким вони отримують доступ), тому тут дійсно немає порушення SA.
Джон Боллінгер

2
Так, @Nate, з цим визначенням R_alloc()програми відповідає програма, незалежно від того, в якому блоці перекладу R_alloc()визначено. Тут компілятор не відповідає.
Джон Боллінгер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.