gcc-10.0.1 Segfault spécifique

23

J'ai un package R avec du code compilé C qui est relativement stable depuis un certain temps et est fréquemment testé contre une grande variété de plates-formes et de compilateurs (windows / osx / debian / fedora gcc / clang).

Plus récemment, une nouvelle plateforme a été ajoutée pour tester à nouveau le package:

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"

À ce stade, le code compilé a rapidement commencé à effectuer des erreurs de segmentation dans ce sens:

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

J'ai pu reproduire le segfault de manière cohérente en utilisant le rocker/r-baseconteneur docker avec gcc-10.0.1un niveau d'optimisation -O2. L'exécution d'une optimisation inférieure supprime le problème. L'exécution de toute autre configuration, y compris sous valgrind (-O0 et -O2), UBSAN (gcc / clang), ne montre aucun problème. Je suis également raisonnablement sûr que cela gcc-10.0.0ne fonctionnait pas, mais je n'ai pas les données.

J'ai exécuté la gcc-10.0.1 -O2version avec gdbet j'ai remarqué quelque chose qui me semble étrange:

gdb vs code

En parcourant la section en surbrillance, il semble que l'initialisation des deuxièmes éléments des tableaux soit ignorée ( R_allocest un wrapper autour de ce mallocque les ordures collectent automatiquement lors du retour du contrôle à R; la faute de segmentation se produit avant de revenir à R). Plus tard, le programme se bloque lors de l'accès à l'élément non initialisé (dans la version gcc.10.0.1 -O2).

J'ai corrigé cela en initialisant explicitement l'élément en question partout dans le code qui a finalement conduit à l'utilisation de l'élément, mais il aurait vraiment dû être initialisé sur une chaîne vide, ou du moins c'est ce que j'aurais supposé.

Suis-je en train de manquer quelque chose d'évident ou de faire quelque chose de stupide? Les deux sont raisonnablement probables car C est de loin ma deuxième langue . C'est juste étrange que cela apparaisse maintenant, et je ne peux pas comprendre ce que le compilateur essaie de faire.


MISE A JOUR : Instructions pour reproduire ce, bien que cela ne se reproduire tant que debian:testingconteneur docker a gcc-10à gcc-10.0.1. Aussi, ne vous contentez pas d'exécuter ces commandes si vous ne me faites pas confiance .

Désolé, ce n'est pas un exemple reproductible minimal.

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

Puis dans la console R, après avoir tapé runpour arriver gdbà exécuter le programme:

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)

L'inspection dans gdb montre assez rapidement (si je comprends bien) qui CSR_strmlen_xessaie d'accéder à la chaîne qui n'a pas été initialisée.

UPDATE 2 : il s'agit d'une fonction hautement récursive, et en plus de cela, le bit d'initialisation de la chaîne est appelé plusieurs fois. C'est surtout parce que j'étais paresseux, nous n'avons besoin que des chaînes initialisées pour la seule fois où nous rencontrons réellement quelque chose que nous voulons signaler dans la récursivité, mais il était plus facile d'initialiser chaque fois qu'il est possible de rencontrer quelque chose. Je le mentionne car ce que vous verrez ensuite montre plusieurs initialisations, mais une seule d'entre elles (probablement celle avec l'adresse <0x1400000001>) est utilisée.

Je ne peux pas garantir que les éléments que je montre ici sont directement liés à l'élément qui a causé la panne (bien qu'il s'agisse du même accès illégal à l'adresse), mais comme @ nate-eldredge l'a demandé, cela montre que l'élément de tableau n'est pas initialisé juste avant le retour ou juste après le retour dans la fonction appelante. Notez que la fonction appelante est en train d'initialiser 8 d'entre eux, et je les montre tous, avec tous remplis de déchets ou de mémoire inaccessible.

entrez la description de l'image ici

MISE À JOUR 3 , démontage de la fonction en question:

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.

MISE À JOUR 4 :

Donc, en essayant d'analyser la norme, voici les parties qui semblent pertinentes ( projet C11 ):

6.3.2.3 Conversions Par7> Autres opérandes> Pointeurs

Un pointeur vers un type d'objet peut être converti en pointeur vers un type d'objet différent. Si le pointeur résultant n'est pas correctement aligné 68) pour le type référencé, le comportement n'est pas défini.
Sinon, une fois reconverti, le résultat doit être égal au pointeur d'origine. Lorsqu'un pointeur vers un objet est converti en pointeur vers un type de caractère, le résultat pointe vers l'octet adressé le plus bas de l'objet. Des incréments successifs du résultat, jusqu'à la taille de l'objet, fournissent des pointeurs vers les octets restants de l'objet.

6.5 Expressions Par6

Le type effectif d'un objet pour un accès à sa valeur stockée est le type déclaré de l'objet, le cas échéant. 87) Si une valeur est stockée dans un objet n'ayant pas de type déclaré via une lvalue ayant un type qui n'est pas un type de caractère, alors le type de la lvalue devient le type effectif de l'objet pour cet accès et pour les accès ultérieurs qui ne le sont pas. modifier la valeur stockée. Si une valeur est copiée dans un objet sans type déclaré à l'aide de memcpy ou memmove, ou est copiée en tant que tableau de type de caractère, le type effectif de l'objet modifié pour cet accès et pour les accès ultérieurs qui ne modifient pas la valeur est le type effectif de l'objet à partir duquel la valeur est copiée, si elle en a un. Pour tous les autres accès à un objet n'ayant pas de type déclaré, le type effectif de l'objet est simplement le type de la valeur l utilisée pour l'accès.

87) Les objets alloués n'ont pas de type déclaré.

IIUC R_allocrenvoie un décalage dans un mallocbloc ed dont l' doublealignement est garanti , et la taille du bloc après le décalage est de la taille demandée (il y a également une allocation avant le décalage pour les données spécifiques à R). R_alloctransforme ce pointeur en (char *)retour.

Section 6.2.5 Par 29

Un pointeur vers void doit avoir les mêmes exigences de représentation et d'alignement qu'un pointeur vers un type de caractère. 48) De même, les pointeurs vers des versions qualifiées ou non qualifiées de types compatibles doivent avoir les mêmes exigences de représentation et d'alignement. Tous les pointeurs vers les types de structure doivent avoir les mêmes exigences de représentation et d'alignement les uns que les autres.
Tous les pointeurs vers les types d'union doivent avoir les mêmes exigences de représentation et d'alignement les uns que les autres.
Les pointeurs vers d'autres types n'ont pas besoin d'avoir les mêmes exigences de représentation ou d'alignement.

48) Les mêmes exigences de représentation et d'alignement sont censées impliquer l'interchangeabilité comme arguments pour les fonctions, les valeurs de retour des fonctions et les membres des unions.

La question est donc "sommes-nous autorisés à refondre le (char *)to (const char **)et à y écrire (const char **)". Ma lecture de ce qui précède est que tant que les pointeurs sur les systèmes dans lesquels le code est exécuté ont un alignement compatible avec l' doublealignement, alors ça va.

Sommes-nous en train de violer un "alias strict"? c'est à dire:

6.5 Par 7

Un objet doit avoir sa valeur stockée accessible uniquement par une expression lvalue qui a l'un des types suivants: 88)

- un type compatible avec le type effectif de l'objet ...

88) Le but de cette liste est de spécifier les circonstances dans lesquelles un objet peut ou non être replié.

Alors, quel devrait être le compilateur pense que le type effectif de l'objet pointé par res.target(ou res.current) est? Vraisemblablement le type déclaré (const char **), ou est-ce réellement ambigu? Il me semble que ce n'est pas dans ce cas uniquement parce qu'il n'y a pas d'autre «lvalue» de portée qui accède au même objet.

J'admets que je me bats puissamment pour extraire du sens de ces sections de la norme.

BrodieG
la source
S'il n'est pas déjà examiné, il peut être utile de regarder le démontage pour voir exactement ce qui se fait. Et aussi pour comparer le démontage entre les versions gcc.
kaylum
2
Je n'essaierais pas de jouer avec la version tronc de GCC. C'est agréable de s'amuser avec, mais ça s'appelle le tronc pour une raison. Malheureusement, il est presque impossible de dire ce qui ne va pas sans (1) avoir votre code et la configuration exacte (2) avoir la même version GCC (3) sur la même architecture. Je suggère de vérifier si cela persiste lorsque 10.0.1 passe du tronc à stable.
Marco Bonelli
1
Un autre commentaire: -mtune=nativeoptimise pour le processeur particulier de votre machine. Ce sera différent pour différents testeurs et peut faire partie du problème. Si vous exécutez la compilation avec, -vvous devriez pouvoir voir quelle famille de processeurs se trouve sur votre machine (par exemple -mtune=skylakesur mon ordinateur).
Nate Eldredge
1
Difficile à distinguer des débogages. Le démontage doit être concluant. Vous n'avez pas besoin d'extraire quoi que ce soit, il vous suffit de trouver le fichier .o produit lors de la compilation du projet et de le démonter. Vous pouvez également utiliser l' disassembleinstruction dans gdb.
Nate Eldredge
5
Quoi qu'il en soit, félicitations, vous êtes l'un des rares dont le problème était en fait un bogue du compilateur.
Nate Eldredge

Réponses:

22

Résumé: Cela semble être un bogue dans gcc, lié à l'optimisation des chaînes. Un testcase autonome est ci-dessous. Au départ, il y avait un doute quant à savoir si le code est correct, mais je pense que oui.

J'ai signalé le bogue comme PR 93982 . Un correctif proposé a été validé mais il ne le corrige pas dans tous les cas, conduisant au suivi PR 94015 ( lien Godbolt ).

Vous devriez pouvoir contourner le bogue en compilant avec le drapeau -fno-optimize-strlen.


J'ai pu réduire votre cas de test à l'exemple minimal suivant (également sur 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;
}

Avec gcc trunk (gcc version 10.0.1 20200225 (experimental)) et -O2(toutes les autres options se sont avérées inutiles), l'assembly généré sur amd64 est le suivant:

.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

Vous avez donc tout à fait raison que le compilateur ne parvient pas à s'initialiser res.target[1](notez l'absence évidente de movq $.LC1, 8(%rax)).

Il est intéressant de jouer avec le code et de voir ce qui affecte le "bug". Peut-être de manière significative, le fait de changer le type de retour de R_allocle void *fait disparaître et vous donne une sortie d'assemblage "correcte". Peut-être moins de manière significative mais plus amusante, changer la chaîne "12345678"pour qu'elle soit plus longue ou plus courte la fait également disparaître.


Discussion précédente, maintenant résolue - le code est apparemment légal.

Ma question est de savoir si votre code est réellement légal. Le fait que vous prenez le char *retourné par R_alloc()et jeté à const char **, puis stocker un const char *semble que cela pourrait violer la règle stricte de aliasing , comme charet const char *ne sont pas compatibles avec les types. Il y a une exception qui vous permet d'accéder à n'importe quel objet en tant que char(pour implémenter des choses comme memcpy), mais c'est l'inverse, et si je comprends bien, ce n'est pas autorisé. Cela fait que votre code produit un comportement indéfini et donc le compilateur peut légalement faire tout ce qu'il veut.

Si tel est le cas, le correctif correct serait que R modifie son code afin qu'il R_alloc()renvoie à la void *place de char *. Il n'y aurait alors pas de problème d'alias. Malheureusement, ce code est hors de votre contrôle, et je ne sais pas comment vous pouvez utiliser cette fonction sans violer l'aliasing strict. Une solution de contournement pourrait être d'interposer une variable temporaire, par exemple, void *tmp = R_alloc(); res.target = tmp;ce qui résout le problème dans le cas de test, mais je ne sais toujours pas si c'est légal.

Cependant, je ne suis pas sûr de cette hypothèse de "strict aliasing", car la compilation avec -fno-strict-aliasinglaquelle AFAIK est censé permettre à gcc d'autoriser de telles constructions ne fait pas disparaître le problème!


Mise à jour. En essayant différentes options, j'ai trouvé que l'un -fno-optimize-strlenou l' autre -fno-tree-forwpropentraînerait la génération d'un code "correct". En outre, l'utilisation -O1 -foptimize-strlengénère le code incorrect (mais -O1 -ftree-forwproppas).

Après un petit git bisectexercice, l'erreur semble avoir été introduite dans la validation 34fcf41e30ff56155e996f5e04 .


Mise à jour 2. J'ai essayé de creuser un peu dans la source gcc, juste pour voir ce que je pouvais apprendre. (Je ne prétends pas être une sorte d'expert en compilation!)

Il semble que le code tree-ssa-strlen.csoit destiné à garder une trace des chaînes apparaissant dans le programme. Pour autant que je sache, le bogue est qu'en regardant l'instruction, res.target[0] = "12345678";le compilateur confond l' adresse du littéral de chaîne "12345678"avec la chaîne elle-même. (Cela semble être lié à ce code suspect qui a été ajouté dans la validation susmentionnée, où s'il essaie de compter les octets d'une "chaîne" qui est en fait une adresse, il regarde plutôt ce vers quoi pointe cette adresse.)

Il pense donc que l'instruction res.target[0] = "12345678", au lieu de stocker l' adresse de "12345678"à l'adresse res.target, stocke la chaîne elle-même à cette adresse, comme si l'instruction l'était strcpy(res.target, "12345678"). Notez pour ce qui est à venir que cela entraînerait le stockage du zéro final à l'adresse res.target+8(à ce stade du compilateur, tous les décalages sont en octets).

Maintenant, lorsque le compilateur regarde res.target[1] = "", il traite également cela comme s'il l'était strcpy(res.target+8, ""), le 8 provenant de la taille de a char *. Autrement dit, comme s'il s'agissait simplement de stocker un octet nul à l'adresse res.target+8. Cependant, le compilateur "sait" que l'instruction précédente stockait déjà un octet nul à cette adresse! En tant que telle, cette déclaration est "redondante" et peut être supprimée ( ici ).

Cela explique pourquoi la chaîne doit comporter exactement 8 caractères pour déclencher le bogue. (Bien que d'autres multiples de 8 puissent également déclencher le bug dans d'autres situations.)

Nate Eldredge
la source
FWIW refonte vers un autre type de pointeur est documenté . Je ne connais pas l'alias pour savoir s'il est correct de refondre int*mais pas const char**.
BrodieG
Si ma compréhension de l'aliasing strict est correcte, le transtypage vers int *est également illégal (ou plutôt, le stockage de ints y est illégal).
Nate Eldredge
1
Cela n'a rien à voir avec une règle d'alias stricte. Une règle d'alias stricte consiste à accéder aux données que vous avez déjà stockées à l' aide d'un descripteur différent. Comme vous l'affectez uniquement ici, il ne touche pas à la règle stricte d'alias. La conversion de pointeurs est valide lorsque les deux types de pointeurs ont les mêmes exigences d'alignement, mais ici vous effectuez une conversion char*et travaillez sur x86_64 ... Je ne vois pas d'UB ici, c'est un bug gcc.
KamilCuk
1
Oui et non, @KamilCuk. Dans la terminologie de la norme, «accéder» comprend à la fois la lecture et la modification de la valeur d'un objet. La règle stricte d'alias parle donc de "stockage". Elle n'est pas limitée aux opérations de relecture. Mais pour les objets sans type déclaré, cela est évoqué par le fait que l'écriture sur un tel objet change automatiquement son type effectif pour correspondre à ce qui a été écrit. Les objets sans type déclaré sont exactement ceux alloués dynamiquement (quel que soit le type du pointeur par lequel ils sont accédés), il n'y a donc pas de violation SA ici.
John Bollinger
2
Oui, @Nate, avec cette définition de R_alloc(), le programme est conforme, quelle que soit l'unité de traduction R_alloc()définie. C'est le compilateur qui ne parvient pas à se conformer ici.
John Bollinger