从一个崩溃问题展开,探索gcc编译优化细节

点击链接阅读原文,获取更多技术内容:从一个crash问题展开,探索gcc编译优化细节-阿里云开发者社区

问题分析的过程也是技术之路的成长,本文以一个gcc编译优化引发的崩溃为切入点,逐步展开对编译器优化细节的探索之路,在分析过程中打开了新世界的大门……

作者 | 瞳尘

来源 | 阿里云开发者公众号

背景:一次平平无奇的车祸

去年,客户提了个bug,并甩了给我们一个分段错误截图,必现崩溃。

从一个崩溃问题展开,探索gcc编译优化细节

这个必现问题我根本不慌的,由于有一个伟人曾经说过:“必现问题都不是问题!”

段错误,无非就是use after free、越界读写器等导致的非法内存访问上限。平平无奇的崩溃,且看我分析!

一、寻找元凶

1.1 头部分析猛如虎

经过初步分析,最终问题锁定在了一个循环赋值函数中,

void* readTileContentIndexCallback(TileContentIndexStruct *tileIndexData, int32_t count) {
    TileContentIndex* tileContentIndexList = new TileContentIndex[count];
    for (int32_t index = 0; index < count; index++) {
        TileContentIndexStruct &inData = tileIndexData[index];
        TileContentIndex &outData = tileContentIndexList[index];

        outData.urID = inData.urCode;
        outData.adcode = inData.adcode;
        outData.level = inData.levelNumber;
        outData.southWestTileId = inData.southWestTileId;
        outData.numRows = inData.numRows;
        outData.numColumns = inData.numColumns;
        outData.tileIndex = inData.tileContentIndex;
    }
    return tileContentIndexList;
}

这里面的赋值引发了问题,导致上层访问数据的时候地址非法了。

但是这个循环赋值逻辑超级简单,的确 看不出有啥毛病,问题的分析一时陷入了僵局。

1.2 机智的宗翰

宗翰是我们组的一个超级机智的小伙子。他反馈这块代码一直没改过,由于本次必现崩溃是修改了gcc编译优化级别,从O2改成O3导致的,发现修改回O2之后必目前崩溃就不见了。

因此,问题就很明朗了,我们来看看gcc O3相对于O2做了哪些优化是不是就行了?

进一步优化。 -O3 打开 -O2 指定的所有优化,还打开以下优化标志:

-fgcse-重新加载后

-fipa-cp-克隆

-floop-交汇处

-floop-unroll-and-jam

-fpeel-循环

-f预测共同点

-fsplit-循环

-fsplit-路径

-ftree-循环分布

-ftree-部分预

-funswitch-循环

-fvect-成本模型=动态

-fversion-loops-for-strides

1.3 问题模拟复现

虽然知道了是编译器优化的问题,但是gcc官网上对于各个优化选项没有代码示例,只有几句解释,看着他们的解释我还是不知道我们的代码命中了哪个优化。

还好,我也很机智!

机智的我决定仿照我们出问题的代码写个小demo,用出问题的环境编译链去复现这个问题,然后具体做法如下,我写了跟个问题代码类似逻辑的demo, 然后用问题环境的工具链尝试编译,先用O2试一下。

g++ -O2 -S -o main2.s main.cpp //这个命令可以生成O2下的安装文件

g++ -o main2 main2.s //根据编译文件生成执行程序main2

执行一下,发现一切正常

从一个崩溃问题展开,探索gcc编译优化细节

再用O3搞一下:

g++ -O3 -S -o mainO3.s main.cpp

g++ -o mainO3 mainO3.s

问题复现!

从一个崩溃问题展开,探索gcc编译优化细节

1.4 编译优化选项排查

先查一下当前版本gcc编译器O2和O3分别打开哪些编译优化,使用命令:

gcc/g++ -Q -O<数字>; –help=优化器

例如:

gcc/g++ -Q -O2 –help=优化器

gcc/g++ -Q -O3 –help=优化器

差异如下(左边O3,右边O2):

从一个崩溃问题展开,探索gcc编译优化细节

可以看到除了上面官网说的几个选项外,O3还比O2多了下面几个优化:

  • -ftree-循环-分布模式
  • -ftree-循环向量化
  • -finline 函数
  • -ftree-slp-向量化

其中从字表面看跟循环相关的有如下几个:

  • -floop-交汇处
  • -floop-unroll-and-jam
  • -ftree-循环分布
  • -funswitch-循环
  • -fversion-loops-for-strides
  • -ftree-循环-分布模式
  • -ftree-循环向量化

拿-ftree-loop-vectorize举例,-f表明打开某个选项,改成-fno-邻居就是关闭,改成-fno-tree-loop-vectorize再查一下:

从一个崩溃问题展开,探索gcc编译优化细节

这样最差也可以挨个关闭O3默认比O2多的优化选项来确认是哪个优化选项引起的问题了~

经过简单测试发现是优化选项-ftree-loop-vectorize导致的问题,编译命令如下:

g++ -O3 -fno-tree-loop-vectorize -S -o main3t.s main.cpp //打开O3,但是关闭tree-loop-vectorize

g++ -o main3t main3t.s // 生成执行程序main3t

必然出现的崩溃消失了!

1.5 了解-ftree-loop-vectorize

gcc官网上说这个优化选项是O2默认开启的

对树执行循环矢量化。默认情况下,此标志在 -O2 以及 -ftree-vectorize、-fprofile-use 和 -fauto-profile 中启用。

这与实测不符(官网说的不准或者有版本号前提),

g++ -Q -O2 –help=optimizers|grep 树循环向量化

从一个崩溃问题展开,探索gcc编译优化细节

说明问题环境中O3才会开启这个优化选项,原来如此,这个编译优化导致崩溃,那么它都优化了点啥呢?官网上的描述有点少,其大体思想就是将简单的循环语句展开,减少循环体执行次数,例如赋值循环代码:

for (int i = 0; i < 16; i++) {
  a[i] = b[i];
}

可能会被优化成:

for (int i = 0; i < 16; i+=4) {
    a[i] = b[i];
    a[i + 1] = b[i + 1];
    a[i + 2] = b[i + 2];
    a[i + 3] = b[i + 3];
}

通过增加循环的步长减少了循环体执行次数,提高了代码效率。上面的例子中一个检测单元内部做了四个赋值,而优化之前需要执行四次循环才可以。

我们先查一下demo中哪些代码正在被优化优化了,使用命令g++ -fopt-info-vec-optimized main.cpp -O3

从一个崩溃问题展开,探索gcc编译优化细节

可以看到就是循环赋值的代码(行号38)。

1.6 初获战果&局部性撤退

至此,元凶已经缉拿归案——循环支持化优化导致了本次崩溃。

SDK交付团队,我们必须跟客户的编译工具链保持一致,编译选项也需要维护一致,因此应该开启O3(正常发布版本也是如此),但是单独关闭tree-loop-vectorize来规避此问题。

那么,循环优化到底是如何导致崩溃的?是gcc的bug还是我们自己的代码写的有问题呢?

由于当时练习功底过于薄弱,难以查出凶手的作案动机与详细手法,我选择了局部性撤退,重新开始练习内功来日再战。

二、再探作案细节

一年后。

最近学习达成了共识(我觉得我又行了!!),所以再次找到了这个问题!决定把它的根源一挖,将凶手作案过程详细展开,昭告天下——正义虽然迟缓但到!

2.1 重写demo复现问题

隔一年,数据一些许散佚现,遂再次构造demo复此问题。demo代码见文末链接test2(方便gdb调试入参和出参用了全局变量)。

核心代码如下:

struct TileContentIndexStruct {
    int32_t           urCode; // 4字节
    int32_t           adcode; // 4字节
    int32_t           levelNumber; // 4字节
    int32_t           southWestTileId; // 4字节
    int32_t           numRows; // 4字节
    int32_t           numColumns; // 4字节
    uint8_t*    tileContentIndex; // 8字节
    int32_t           dataSize; // 4字节
    //64位系统8字节对齐,填充4字节
}; // 共40字节

struct TileContentIndex {
    uint16_t urID; // 2字节
    uint16_t level; // 2字节
    uint32_t adcode; // 4字节
    uint32_t southWestTileId; // 4字节
    uint16_t numRows; // 2字节
    uint16_t numColumns; // 2字节(至此正好8字节对齐,无需填充)
    uint8_t* tileIndex; // 8字节
}; // 共24字节

int g_rowCount = 10;
TileContentIndex g_tileContentIndexList[10]; // 被复制
TileContentIndexStruct g_tileContentVector[10]; // 源数据

void* readTileContentIndexCallback(TileContentIndexStruct *tileIndexData, int32_t count)
{
    for (int32_t index = 0; index < count; index++) {
        TileContentIndexStruct &inData = tileIndexData[index];
        TileContentIndex &outData = g_tileContentIndexList[index];

        outData.urID = (uint16_t)inData.urCode;
        outData.adcode = (uint32_t)inData.adcode;
        outData.level = (uint16_t)inData.levelNumber;
        outData.southWestTileId = (uint32_t)inData.southWestTileId;
        outData.numRows = (uint16_t)inData.numRows;
        outData.numColumns = (uint16_t)inData.numColumns;
        outData.tileIndex = inData.tileContentIndex;
    }
    return g_tileContentIndexList;
}

// 注:readTileContentIndexCallback调用时入参传的是g_tileContentVector和g_rowCount

2.2 对比优化的做法

优化之前的准备涉及到指令,看上去就很轻松了。

需要注意的是
readTileContentIndexCallback函数只有两个参数,所以通过注册传入即可。函数
readTileContentIndexCallback的第一个参数使用的是后面的x0注册传递,第二个参数使用的是x1个注册,而w1注册是指x1的低32位。

_Z28readTileContentIndexCallbackP22TileContentIndexStructi:
.LFB48:
  .cfi_startproc
  cmp  w1, 0                       // w1寄存器的值和0比较(w1对应入参count)
  ble  .L2                         // 比较结果w1 <= 0则跳转到.L2
  mov  x2, x0                      // x2 = x0 (x0对应入参tileIndexData)
  adrp  x3, .LANCHOR0             // 将.LANCHOR0高地址(低12bit清零后的地址)load到x3寄存器
  add  x3, x3, :lo12:.LANCHOR0     // 将.LANCHOR0低12位加到x3中,至此x3拥有了完整的.LANCHOR0,即全局变量 g_tileContentIndexList 的地址
  sub  w1, w1, #1                  // w1减一后存入w1,w1是x1的低32bit
  add  x1, x1, x1, lsl 2           // 将寄存器x1的值与寄存器x1左移2位的值相加,并将结果存储回寄存器x1. 相当于将寄存器x1的值乘以5
  add  x0, x0, 40                  // x0 = x0 + 40
  add  x0, x0, x1, lsl 3           // x0 = (x1 << 3) + x0
  // 综合这4行相当于x0 = (x1 - 1) * 40 + (x0 + 40) = x1 * 40 + x0
.L3:
  ldr  w1, [x2]                    // w1 = tileIndexData->urCode,将寄存器x2指向的地址的值加载到寄存器w1
  strh  w1, [x3]                  // 将寄存器w1的值存储到寄存器x3指向的地址(由于强转成了uint16_t所以是半字存储,只存低16bit)
  ldr  w1, [x2, 4]                 // w1 = tileIndexData->adcode
  str  w1, [x3, 4]                 // [x3 + 4] = w1
  ldr  w1, [x2, 8]                 // w1 = tileIndexData->levelNumber
  strh  w1, [x3, 2]               // g_tileContentIndexList.level = (uint16_t)inData.levelNumber
  ldr  w1, [x2, 12]                // w1 = tileIndexData->southWestTileId
  str  w1, [x3, 8]                 // 存到 g_tileContentIndexList.southWestTileId
  ldr  w1, [x2, 16]                // tileIndexData->numRows
  strh  w1, [x3, 12]              // 存到 g_tileContentIndexList.numRows
  ldr  w1, [x2, 20]                // tileIndexData->numColumns
  strh  w1, [x3, 14]              // 存到 g_tileContentIndexList.numColumns
  ldr  x1, [x2, 24]                // x1 = tileIndexData->tileContentIndex
  str  x1, [x3, 16]                // 存到 g_tileContentIndexList.tileIndex
  add  x2, x2, 40                  // tileIndexData++(sizeof(TileContentIndexStruct) = 40)
  add  x3, x3, 24                  // g_tileContentIndexList++ (sizeof(TileContentIndex) = 24)
  cmp  x2, x0                      // 比较x2和x0
  bne  .L3                         //若x2不等于x0则跳转到.L3继续循环
.L2:
  adrp  x0, .LANCHOR0
  add  x0, x0, :lo12:.LANCHOR0     // 两行指令将.LANCHOR0的地址赋给寄存器x0
  ret                             // 返回
  .cfi_endproc
.LFE48:
  .size  _Z28readTileContentIndexCallbackP22TileContentIndexStructi, .-_Z28readTileContentIndexCallbackP22TileContentIndexStructi
  .align  2
  .global  _Z15readTileContentRiPFPvP22TileContentIndexStructiE
  .type  _Z15readTileContentRiPFPvP22TileContentIndexStructiE, %function

我果然行了,这几个指令根本难不倒我,直接逐行读完。

可以看到这个循环就是把 struct TileContentIndexStruct g_tileContentVector[10] 这个内存的内容复制到 struct TileContentIndex g_tileContentIndexList[10] 这个内存中。其中sizeof(TileContentIndexStruct) = 40,sizeof(TileContentIndex) = 24。设置中通过x0 = (x1 – 1) * 40 + (x0 + 40) = x1 * 40 + x0

将x0分配成g_tileContentVector的地址 + 40 * count,即队列结束地址,循环开始前x2为g_tileContentVector[0]的地址,循环中给各个结构体分配分配,每次循环结束就执行< /span> 让x2自增40,变成内存下一个元素的地址,直到x2 == x0时循环结束。add x2, x2, 40

开启循环管理优化后的看到文末链接中的est_bad.S,内容必须不再逐行展开,参见下文解析。

2.3 详解优化后的架构

2.3.1 感受循环提供优化的威力

看之前,我们先来感受下循环发挥化的威力。

如下所示,进入循环分配之前,阵列g_tileContentIndexList队列所有成员还都是初始化的0,执行两行代码后,发现g_tileContentIndexList[0]~g_tileContentIndexList[3]这四个集群成员已经有两个数据被分配了:

从一个崩溃问题展开,探索gcc编译优化细节

这就是循环带来优化的威力——减少了循环的次数,一次循环就能完成4个数据库的分配成员!

内容剩余60%,完整内容可点击下方链接查看:从一个crash问题展开,探索gcc编译优化细节-阿里云开发者社区

阿里云开发者社区,千万开发者的选择。百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,尽在:阿里云开发者社区-云计算社区-阿里云

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 共7条

请登录后发表评论