gcc-10.0.1 特定的段错误

Bro*_*ieG 25 c gcc segmentation-fault

我有一个带有 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"
Run Code Online (Sandbox Code Playgroud)

此时,编译后的代码立即开始沿着这些行进行段错误:

 *** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'
Run Code Online (Sandbox Code Playgroud)

通过使用具有优化级别的rocker/r-basedocker 容器,我已经能够一致地重现段错误。运行较低的优化可以解决问题。运行任何其他设置,包括在 valgrind(-O0 和 -O2)、UBSAN(gcc/clang)下,都没有显示任何问题。我也有理由确定这在 下运行,但没有数据。gcc-10.0.1-O2gcc-10.0.0

我运行了这个gcc-10.0.1 -O2版本gdb并注意到一些对我来说很奇怪的东西:

gdb 与代码

在单步执行突出显示的部分时,似乎跳过了数组第二个元素的初始化(R_allocmalloc将控制权返回给 R 时自身垃圾收集的包装器;段错误发生在返回 R 之前)。后来,当访问未初始化的元素(在 gcc.10.0.1 -O2 版本中)时,程序崩溃。

我通过在最终导致使用该元素的代码中的任何地方显式初始化有问题的元素来解决这个问题,但它确实应该被初始化为一个空字符串,或者至少这是我所假设的。

我是否遗漏了一些明显的东西或做了一些愚蠢的事情?两者都是合理的,因为到目前为止C 是我的第二语言。奇怪的是,这现在突然出现了,我无法弄清楚编译器想要做什么。


更新:重现此内容的说明,尽管这只会在debian:testingdocker 容器具有gcc-10at 时重现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
Run Code Online (Sandbox Code Playgroud)

然后,在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)
Run Code Online (Sandbox Code Playgroud)

在 gdb 中检查很快就会显示(如果我理解正确的话) CSR_strmlen_x正在尝试访问未初始化的字符串。

更新 2:这是一个高度递归的函数,除此之外,字符串初始化位被多次调用。这主要是 b/c 我是懒惰的,我们只需要在我们真正遇到我们想要在递归中报告的东西时初始化字符串,但是每次可能遇到某些东西时都更容易初始化。我提到这一点是因为接下来您将看到多个初始化,但只有其中一个(可能是地址为 <0x1400000001> 的)被使用。

我不能保证我在这里显示的东西与导致段错误的元素直接相关(尽管它是相同的非法地址访问),但正如@nate-eldredge 所问的那样,它确实表明数组元素不是在调用函数中在 return 之前或 return 之后初始化。请注意,调用函数正在初始化其中的 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.
Run Code Online (Sandbox Code Playgroud)

更新 4

因此,尝试解析这里的标准是其中看起来相关的部分(C11 草案):

6.3.2.3 Par7 转换 > 其他操作数 > 指针

指向对象类型的指针可以转换为指向不同对象类型的指针。 如果结果指针未正确对齐68) 引用类型,则行为未定义。
否则,当再次转换回来时,结果将与原始指针相等。当指向对象的指针转换为指向字符类型的指针时,结果指向对象的最低地址字节。结果的连续增量,直到对象的大小,产生指向对象剩余字节的指针。

6.5 Par6 表达式

访问其存储值的对象的有效类型是该对象的声明类型(如果有)。87) 如果一个值通过一个类型不是字符类型的左值存储到一个没有声明类型的对象中,那么左值的类型成为该访问和后续访问的有效类型修改存储的值。如果使用 memcpy 或 memmove 将值复制到没有声明类型的对象中,或复制为字符类型的数组,则该访问和不修改该值的后续访问的修改对象的有效类型是从中复制值的对象的有效类型(如果有)。 对于没有声明类型的对象的所有其他访问,对象的有效类型只是用于访问的左值的类型。

87) 分配的对象没有声明类型。

IIUCR_alloc返回一个偏移量到一个malloc保证double对齐的ed块中,偏移量之后的块的大小就是请求的大小(在偏移量之前也有分配给R特定的数据)。 在返回时R_alloc将该指针强制转换为(char *)

第 6.2.5 节 29

指向 void 的指针应具有与指向字符类型的指针相同的表示和对齐要求。48) 同样,指向兼容类型的限定或非限定版本的指针应具有相同的表示和对齐要求。所有指向结构类型的指针都应具有相同的表示和对齐要求。
所有指向联合类型的指针都应具有相同的表示和对齐要求。
指向其他类型的指针不需要具有相同的表示或对齐要求。

48) 相同的表示和对齐要求意味着作为函数的参数、函数的返回值和联合成员的可互换性。

所以问题是“我们是否允许重铸(char *)to(const char **)并将其写入为(const char **)”。我对上述内容的理解是,只要代码运行的系统上的指针具有与double对齐兼容的对齐方式,那么就可以了。

我们是否违反了“严格别名”?IE:

6.5 标准杆 7

对象的存储值只能由具有以下类型之一的左值表达式访问:88)

— 与对象的有效类型兼容的类型...

88) 此列表的目的是指定对象可以或不可以别名的情况。

那么,编译器应该认为(或)指向的对象的有效类型是什么?大概是声明的 type ,或者这实际上是模棱两可的?在我看来,在这种情况下不仅仅是因为范围内没有其他“左值”可以访问同一对象。res.targetres.current(const char **)

我承认我正在努力从标准的这些部分中提取意义。

Nat*_*dge 25

总结:这似乎是gcc的一个bug,与字符串优化有关。下面是一个独立的测试用例。最初有人怀疑代码是否正确,但我认为是的。

我已将该错误报告为PR 93982提交了一个提议的修复,但它并没有在所有情况下修复它,导致后续PR 94015godbolt 链接)。

您应该能够通过使用标志进行编译来解决该错误-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;
}
Run Code Online (Sandbox Code Playgroud)

使用 gcc trunk (gcc version 10.0.1 20200225 (experimental)) 和-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
Run Code Online (Sandbox Code Playgroud)

所以你说编译器无法初始化是对的res.target[1](注意 的明显缺失movq $.LC1, 8(%rax))。

玩代码并查看影响“错误”的因素很有趣。也许重要的是,更改R_allocto的返回类型void *使其消失,并为您提供“正确”的程序集输出。也许不那么重要但更有趣的是,将字符串更改"12345678"为更长或更短也会使其消失。


以前的讨论,现在解决了 - 代码显然是合法的。

我的问题是你的代码是否真的合法。您将char *返回的 byR_alloc()并将其强制转换为const char **,然后存储 a的事实const char *似乎可能违反了严格的别名规则,因为charconst char *是不兼容的类型。有一个例外允许您访问任何对象char(以实现诸如 之类的东西memcpy),但这是相反的方式,据我所知,这是不允许的。它使您的代码产生未定义的行为,因此编译器可以合法地做任何它想做的事情。

如果是这样,正确的解决方法是让 R 更改它们的代码,以便R_alloc()返回void *而不是char *. 那么就不会有走样问题了。不幸的是,该代码超出了您的控制范围,我不清楚您如何在不违反严格别名的情况下使用此函数。一种解决方法可能是插入一个临时变量,例如void *tmp = R_alloc(); res.target = tmp;解决测试用例中的问题,但我仍然不确定它是否合法。

但是,我不知道这个“严格走样”的假设,因为编制-fno-strict-aliasing,据我所知这应该使海湾合作委员会允许这样的构造,并没有使问题消失!


更新。尝试一些不同的选择,我发现无论是-fno-optimize-strlen-fno-tree-forwprop将导致产生“正确”的代码。此外,使用会-O1 -foptimize-strlen产生不正确的代码(但-O1 -ftree-forwprop不会)。

经过一些git bisect练习,该错误似乎已在commit 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 来自 a 的大小char *。也就是说,就好像它只是在 address 处存储了一个空字节res.target+8。然而,编译器“知道”前面的语句已经在那个地址存储了一个空字节!因此,此语句是“多余的”,可以丢弃(此处)。

这解释了为什么字符串必须正好是 8 个字符长才能触发错误。(尽管其他 8 的倍数也可以在其他情况下触发错误。)

  • 是的,@Nate,根据“R_alloc()”的定义,无论在哪个翻译单元中定义“R_alloc()”,程序都是一致的。这里是编译器不符合要求。 (2认同)