Mam pakiet R z kompilowanym kodem C, który był stosunkowo stabilny od dłuższego czasu i jest często testowany na wielu różnych platformach i kompilatorach (windows / osx / debian / fedora gcc / clang).
Niedawno dodano nową platformę do ponownego przetestowania pakietu:
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"
W tym momencie skompilowany kod natychmiast zaczął segfaultować według następujących linii:
*** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'
Byłem w stanie konsekwentnie odtwarzać segfault przy użyciu rocker/r-base
kontenera dokowanego gcc-10.0.1
z poziomem optymalizacji -O2
. Uruchomienie niższej optymalizacji pozbywa się problemu. Uruchamianie innych konfiguracji, w tym w ramach valgrind (zarówno -O0, jak i -O2), UBSAN (gcc / clang), nie wykazuje żadnych problemów. Jestem również dość pewien, że to się skończyło gcc-10.0.0
, ale nie mam danych.
Uruchomiłem gcc-10.0.1 -O2
wersję gdb
i zauważyłem coś, co wydaje mi się dziwne:
Podczas przechodzenia przez podświetloną sekcję wydaje się, że inicjalizacja drugich elementów tablic jest pomijana ( R_alloc
jest to opakowanie, malloc
które gromadzi samo śmieci, gdy wraca kontrola do R; segfault zdarza się przed powrotem do R). Później program ulega awarii, gdy uzyskiwany jest dostęp do niezainicjowanego elementu (w wersji gcc.10.0.1 -O2).
Naprawiłem to, jawnie inicjując dany element wszędzie w kodzie, co ostatecznie doprowadziło do jego użycia, ale tak naprawdę powinien był zostać zainicjowany na pusty ciąg, a przynajmniej tak bym się spodziewał.
Czy brakuje mi czegoś oczywistego lub robię coś głupiego? Oba są dość prawdopodobne, ponieważ C jest zdecydowanie moim drugim językiem . Dziwne, że to właśnie się pojawiło i nie mogę zrozumieć, co kompilator próbuje zrobić.
UPDATE : Instrukcje do odtworzenia tego, choć będzie to jedynie odtworzyć tak długo, jak debian:testing
pojemnik doker ma gcc-10
co gcc-10.0.1
. Ponadto, nie wystarczy uruchomić te polecenia, jeśli nie ufasz mi .
Niestety nie jest to minimalny odtwarzalny przykład.
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
Następnie w konsoli R, po wpisaniu run
aby gdb
uruchomić program:
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)
Sprawdzanie w gdb dość szybko pokazuje (o ile dobrze rozumiem), że
CSR_strmlen_x
próbuje uzyskać dostęp do łańcucha, który nie został zainicjowany.
AKTUALIZACJA 2 : jest to wysoce rekurencyjna funkcja, a ponadto bit inicjujący ciąg jest wywoływany wiele, wiele razy. Jest to głównie b / c byłem leniwy, potrzebujemy tylko zainicjowanych łańcuchów, gdy napotkamy coś, co chcemy zgłosić w rekurencji, ale łatwiej było zainicjalizować za każdym razem, gdy można coś napotkać. Wspominam o tym, ponieważ to, co zobaczysz później, pokazuje wiele inicjalizacji, ale używana jest tylko jedna z nich (prawdopodobnie ta o adresie <0x1400000001>).
Nie mogę zagwarantować, że rzeczy, które tu pokazuję, są bezpośrednio związane z elementem, który spowodował segfault (chociaż jest to ten sam nielegalny dostęp do adresu), ale jak zapytał @ nate-eldredge, pokazuje, że element tablicy nie jest zainicjowany albo tuż przed powrotem, albo tuż po powrocie w funkcji wywoływania. Zauważ, że funkcja wywołująca inicjuje 8 z nich, i pokazuję je wszystkie, wszystkie wypełnione śmieciami lub niedostępną pamięcią.
AKTUALIZACJA 3 , demontaż danej funkcji:
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.
AKTUALIZACJA 4 :
Tak więc próbując przeanalizować tutaj standard, jego części wydają się istotne ( projekt C11 ):
6.3.2.3 Konwersje Par7> Inne argumenty> Wskaźniki
Wskaźnik do typu obiektu można przekonwertować na wskaźnik do innego typu obiektu. Jeśli wynikowy wskaźnik nie jest poprawnie wyrównany 68) dla przywoływanego typu, zachowanie jest niezdefiniowane.
W przeciwnym razie, po ponownej konwersji wynik będzie porównywany z oryginalnym wskaźnikiem. Kiedy wskaźnik do obiektu jest konwertowany na wskaźnik do typu znaku, wynik wskazuje na najniższy adresowany bajt obiektu. Kolejne przyrosty wyniku, aż do wielkości obiektu, dają wskaźniki do pozostałych bajtów obiektu.
6.5 Wyrażenia Par6
Efektywnym typem obiektu dla dostępu do jego przechowywanej wartości jest deklarowany typ obiektu, jeśli taki istnieje. 87) Jeśli wartość jest przechowywana w obiekcie, który nie ma zadeklarowanego typu poprzez wartość o typie innym niż typ znaku, wówczas typ wartości staje się efektywnym typem obiektu dla tego dostępu i dla kolejnych wejść, które nie zmodyfikuj zapisaną wartość. Jeśli wartość jest kopiowana do obiektu bez zadeklarowanego typu przy użyciu memcpy lub memmove, lub jest kopiowana jako tablica typu znaków, to skutecznym typem zmodyfikowanego obiektu dla tego dostępu i dla kolejnych dostępów, które nie modyfikują wartości, jest efektywny typ obiektu, z którego kopiowana jest wartość, jeśli ją posiada. W przypadku wszystkich innych wejść do obiektu bez zadeklarowanego typu efektywnym typem obiektu jest po prostu typ wartości użytej do uzyskania dostępu.
87) Przydzielone obiekty nie mają zadeklarowanego typu.
IIUC R_alloc
zwraca przesunięcie do edytowanego malloc
bloku, który gwarantuje double
wyrównanie, a rozmiar bloku po przesunięciu ma żądany rozmiar (przed przesunięciem dla danych specyficznych istnieje również alokacja). R_alloc
rzutuje ten wskaźnik (char *)
na powrót.
Sekcja 6.2.5 Par 29
Wskaźnik do unieważnienia powinien mieć takie same wymagania dotyczące reprezentacji i wyrównania, jak wskaźnik do typu znaku. 48) Podobnie wskaźniki do kwalifikowanych lub niekwalifikowanych wersji kompatybilnych typów powinny mieć takie same wymagania dotyczące reprezentacji i wyrównania. Wszystkie wskaźniki do typów konstrukcji powinny mieć takie same wymagania dotyczące reprezentacji i wyrównania.
Wszystkie wskaźniki do typów unii mają takie same wymagania dotyczące reprezentacji i wyrównania.
Wskaźniki do innych typów nie muszą mieć takich samych wymagań dotyczących reprezentacji lub wyrównania.48) Te same wymagania dotyczące reprezentacji i wyrównania mają na celu sugerowanie wymienności jako funkcji dla funkcji, zwracania wartości z funkcji i członków związków.
Więc pytanie brzmi „czy wolno nam przekształcenie (char *)
się (const char **)
i napisz do niego jako (const char **)
”. Mój odczyt powyższego jest taki, że dopóki wskaźniki w systemach, w których działa kod, mają wyrównanie zgodne z double
wyrównaniem, to jest w porządku.
Czy naruszamy „ścisłe aliasing”? to znaczy:
6,5 Par 7
Dostęp do przechowywanej wartości obiektu może mieć wyłącznie wyrażenie wartości, które ma jeden z następujących typów: 88)
- typ zgodny z efektywnym typem obiektu ...
88) Celem tej listy jest określenie tych okoliczności, w których obiekt może być lub nie być aliasowany.
Więc co kompilator powinien uważać za efektywny typ obiektu wskazywanego przez res.target
(lub res.current
)? Prawdopodobnie zadeklarowany typ (const char **)
, czy jest to rzeczywiście niejednoznaczne? Wydaje mi się, że nie jest tak w tym przypadku tylko dlatego, że nie ma innej „wartości” w zakresie dostępu do tego samego obiektu.
Przyznaję, że ciężko walczę o wydobycie sensu z tych części standardu.
źródło
-mtune=native
optymalizuje pod kątem konkretnego procesora, który ma Twój komputer. Będzie to różne dla różnych testerów i może być częścią problemu. Jeśli uruchomisz kompilację-v
, powinieneś być w stanie zobaczyć, która rodzina procesorów znajduje się na twoim komputerze (np.-mtune=skylake
Na moim komputerze).disassemble
instrukcji w gdb.Odpowiedzi:
Podsumowanie: Wygląda na to, że jest to błąd w gcc związany z optymalizacją łańcucha. Samodzielna walizka testowa znajduje się poniżej. Początkowo istniały wątpliwości, czy kod jest poprawny, ale tak mi się wydaje.
Zgłoszono błąd jako PR 93982 . Proponowana poprawka została zatwierdzona, ale nie naprawia jej we wszystkich przypadkach, co prowadzi do kontynuacji PR 94015 ( godbolt link ).
Powinieneś być w stanie obejść błąd, kompilując się z flagą
-fno-optimize-strlen
.Udało mi się zredukować twój przypadek testowy do następującego minimalnego przykładu (także na Godbolt ):
Z gcc trunk (gcc wersja 10.0.1 20200225 (eksperymentalna)) i
-O2
(wszystkie inne opcje okazały się niepotrzebne), wygenerowany zestaw na amd64 wygląda następująco:Masz więc
res.target[1]
całkowitą rację, że kompilator nie inicjuje się (zauważ wyraźny brakmovq $.LC1, 8(%rax)
).Ciekawie jest grać z kodem i zobaczyć, co wpływa na „błąd”. Być może znacząco, zmiana typu zwracanego
R_alloc
navoid *
powoduje, że znika i daje „poprawne” dane wyjściowe zestawu. Może mniej znaczące, ale bardziej zabawne, zmiana łańcucha"12345678"
na dłuższy lub krótszy również powoduje, że znika.Poprzednia dyskusja, teraz rozwiązana - kod jest pozornie legalny.
Mam pytanie, czy Twój kod jest rzeczywiście legalny. Fakt, że odbierasz
char *
zwróconyR_alloc()
i przesyłasz goconst char **
, a następnie przechowujesz,const char *
wydaje się, że może to naruszać ścisłą zasadę aliasingu , ponieważchar
iconst char *
nie są kompatybilnymi typami. Istnieje wyjątek, który pozwala na dostęp do dowolnego obiektu jakochar
(w celu zaimplementowania takich rzeczymemcpy
), ale jest na odwrót i najlepiej rozumiem, że nie jest to dozwolone. Sprawia, że kod wywołuje niezdefiniowane zachowanie, dzięki czemu kompilator może legalnie robić, co tylko chce.Jeśli tak jest, poprawną poprawką byłoby, aby R zmienił kod tak, aby
R_alloc()
zwracałvoid *
zamiastchar *
. Wtedy nie byłoby problemu z aliasingiem. Niestety, ten kod jest poza twoją kontrolą i nie jest dla mnie jasne, jak możesz w ogóle korzystać z tej funkcji bez naruszania ścisłego aliasingu. Obejściem może być wstawienie zmiennej tymczasowej, np.void *tmp = R_alloc(); res.target = tmp;
Która rozwiązuje problem w przypadku testowym, ale nadal nie jestem pewien, czy jest to zgodne z prawem.Jednak nie jestem pewien tego „surowe aliasing” hipotezy, ponieważ kompilacja z
-fno-strict-aliasing
, który AFAIK ma make gcc umożliwiają takie konstrukty, czy nie sprawić, że problem znika!Aktualizacja. Próbując różnych opcji, odkryłem, że albo spowoduje
-fno-optimize-strlen
albo-fno-tree-forwprop
wygenerowanie „poprawnego” kodu. Ponadto użycie-O1 -foptimize-strlen
zwraca niepoprawny kod (ale-O1 -ftree-forwprop
nie daje).Po krótkim
git bisect
ćwiczeniu wydaje się, że błąd został wprowadzony w zatwierdzeniu 34fcf41e30ff56155e996f5e04 .Aktualizacja 2. Próbowałem trochę zagłębić się w źródło gcc, aby zobaczyć, czego mogę się nauczyć. (Nie twierdzę, że jestem ekspertem od kompilatorów!)
Wygląda na to, że kod
tree-ssa-strlen.c
ma śledzić ciągi pojawiające się w programie. O ile wiem, błąd polega na tym, że patrząc na instrukcjęres.target[0] = "12345678";
kompilator łączy adres literału łańcucha"12345678"
z samym łańcuchem. (Wydaje się, że jest to związane z tym podejrzanym kodem, który został dodany do wspomnianego wyżej zatwierdzenia, gdzie jeśli próbuje policzyć bajty „ciągu”, który jest faktycznie adresem, zamiast tego patrzy na to, na co wskazuje ten adres.)Dlatego uważa, że oświadczenie
res.target[0] = "12345678"
, zamiast zapisywania adresu z"12345678"
adresemres.target
, jest przechowywanie String się pod tym adresem, jakby były oświadczeniestrcpy(res.target, "12345678")
. Zwróć uwagę na to, co nastąpi, że spowoduje to, że końcowy nul będzie przechowywany pod adresemres.target+8
(na tym etapie w kompilatorze wszystkie przesunięcia są w bajtach).Teraz, kiedy kompilator patrzy
res.target[1] = ""
, traktuje to tak, jakby to byłostrcpy(res.target+8, "")
, 8 pochodzi od wielkości achar *
. To tak, jakby po prostu przechowywał nul bajt pod adresemres.target+8
. Jednak kompilator „wie”, że poprzednia instrukcja już zawierała bajt nul pod tym samym adresem! W związku z tym to stwierdzenie jest „zbędne” i można je odrzucić ( tutaj ).To wyjaśnia, dlaczego ciąg musi mieć dokładnie 8 znaków, aby wyzwolić błąd. (Chociaż inne wielokrotności liczby 8 mogą również powodować błąd w innych sytuacjach).
źródło
int*
ale nieconst char**
.int *
jest również nielegalna (a raczej przechowywanieint
tam, gdzie jest nielegalne).char*
i pracujesz na x86_64 ... Nie widzę tutaj UB, to jest błąd gcc.R_alloc()
program jest zgodny, niezależnie od tego, w której jednostce tłumaczeniowejR_alloc()
jest zdefiniowana. Jest to kompilator, który nie jest tutaj zgodny.