[c] gcc-10.0.1 특정 Segfault

C 컴파일 된 코드 가있는 R 패키지 가 비교적 안정적이며 광범위한 플랫폼 및 컴파일러 (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'

최적화 수준 의 rocker/r-base도커 컨테이너를 사용하여 segfault를 일관되게 재현 할 수있었습니다 . 낮은 최적화를 실행하면 문제가 해결됩니다. valgrind (-O0 및 -O2), UBSAN (gcc / clang)을 포함하여 다른 설정을 실행해도 전혀 문제가 없습니다. 나는 또한 이것이 아래에 있다고 확신 하지만 데이터가 없습니다.gcc-10.0.1-O2gcc-10.0.0

나는 gcc-10.0.1 -O2버전을 사용 gdb하여 나에게 이상한 것으로 나타났습니다.

gdb 대 코드

강조 표시된 섹션을 단계별로 실행하는 동안 배열의 두 번째 요소 초기화가 생략 된 것으로 나타납니다 ( R로 제어를 리턴 할 때 자체 가비지가 수집 R_alloc하는 래퍼 malloc입니다. segfault는 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 : 이것은 매우 재귀 적 인 함수이며 문자열 초기화 비트는 여러 번 호출됩니다. 이것은 주로 게으른 b / c입니다. 재귀에서보고하려는 것이 실제로 발생하는 한 번만 초기화 된 문자열 만 필요하지만 무언가가 발생할 때마다 초기화하는 것이 더 쉽습니다. 다음에 보게 될 것은 다중 초기화를 보여 주지만, 그중 하나 (아마도 <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 draft ).

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)이 목록의 목적은 객체가 별칭을 가질 수도 있고 그렇지 않을 수도있는 환경을 지정하는 것입니다.

그렇다면 컴파일러 는 (또는 )이 가리키는 객체 의 유효 유형 이 무엇이라고 생각해야 합니까? 아마도 선언 된 type 입니까, 아니면 실제로 모호합니까? 범위에 동일한 객체에 액세스하는 다른 ‘lvalue’가 없기 때문에이 경우에만 해당되지 않는다고 생각합니다.res.targetres.current(const char **)

나는 표준의이 부분들에서 의미를 추출하기 위해 힘 쓰고있다.



답변

요약 : 이것은 문자열 최적화와 관련된 gcc의 버그 인 것 같습니다 . 자체 테스트 케이스는 다음과 같습니다. 처음에는 코드가 올바른지에 대한 의심이 있었지만 코드가 맞는 것 같습니다.

PR 93982 로 버그를보고했습니다 . 제안 된 수정 프로그램이 커밋 되었지만 모든 경우에 수정되지는 않아 후속 PR 94015 ( godbolt link )가 발생합니다.

플래그로 컴파일하여 버그를 해결할 수 있어야합니다 -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_allocvoid *멀리 갈 차종을하고 “올바른”어셈블리 출력을 제공합니다. 어쩌면 덜 중요하지만 더 재미있게 문자열 "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-aliasingAFAIK가 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"). 이로 인해 후행 nul이 주소에 저장됩니다 res.target+8(컴파일러 의이 단계에서 모든 오프셋은 바이트 단위입니다).

이제 컴파일러는 res.target[1] = ""이것을 보았을 때 이것을 마치 마치 strcpy(res.target+8, "")8의 크기에서 오는 것처럼 취급 합니다 char *. 즉, 마치 address에 널 바이트를 저장하는 것처럼 res.target+8. 그러나 컴파일러는 이전 명령문이 이미 해당 주소에 널 바이트를 저장했음을 “인식”합니다. 따라서이 설명은 “중복”이므로 버릴 수 있습니다 ( here ).

이것은 버그를 유발하기 위해 문자열이 정확히 8 자 여야하는 이유를 설명합니다. (8의 다른 배수도 다른 상황에서 버그를 유발할 수 있습니다.)


답변