Debug版本和Release版本对代码缺陷的影响分析
1.问题背景
在代码开发的过程中,曾遇到过如下问题:程序在Debug版本下可以正常运行,但切换为Release版本后,就会出现崩溃。通过在代码中添加打印信息,最后将异常定位为一个数组的下标出现了超出界限的数值,从而引发了段错误。除上述问题外,在日常开发工作中也有一些同事反馈过,有的程序在Debug版本下运行正常,却在Release版本下出现异常了,本文对类似问题进行了梳理和分析。
2.Debug版本和Release版本的本质区别
Debug版本又称为调试版本,它包含调试信息,并且不作任何优化,方便开发人员进行调试。Release版本又称为发布版本,它不会携带调试信息,且往往对代码进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户获得更好的使用体验,但也正因此,编译Release版本比编译Debug版本会花更多的时间。
Debug版本和Release版本的本质区别其实就是优化级别的不同,对于C/C++程序的编译器来说,通常有很多优化级别,如下所示:
- O0:不开启优化,方便开发者调试,作为默认级别。
- O1:保守的优化级别,会打开四十多个优化选项,该级别在不需要过多的编译时间情况下,尽量减少代码大小和尽量提高程序运行速度。
- Og:方便调试的优化级别(比O1级的优化更保守),依据个人的代码调试经验来说,如果只是为了调试,该优化级别是比O0级更好的选择,因为它会打开O1级大部分优化选项,但是不会启用那些影响调试的选项。
- O2:常用的发布优化级别,在O1级的基础上会额外打开四十多个优化选项,该级别下几乎执行了所有支持的优化选项,它会增加编译时间,但也会提高程序的运行速度。
- Os:产生较小代码体积的优化选项(比O2级的优化更保守),它会开启-finline-functions优化标志,使编译器根据代码大小而不是程序运行速度进行优化,为的就是减少代码大小,该级别会打开几乎所有O2级的优化选项,但除了那些经常会增加代码大小的优化选项。
- O3:较为激进的优化选项(对错误编码容忍度最低),在O2级的基础上会额外打开十多个优化选项。
- Ofast:更加激进的编译级别,它不会严格遵循标准,在O3级的优化基础上,它又开启了一些可能导致不符合IEEE浮点数等标准的性能优化选项。
本文将上述优化级别中常用的O1、Og、O2、Os级优化的具体内容,整理在了下表2-1至表2-4中。
优化选项 | 优化内容 |
-fauto-inc-dec | 自动识别代码中的递增和递减操作,并尝试将其与内存访问操作相结合,这种优化可以减少不必要的内存访问和指令数量,从而降低程序的执行时间和内存使用量 |
-fbranch-count-reg | 帮助编译器更好地预测控制流 |
-fcombine-stack-adjustments | 合并相邻的栈调整操作,比如对栈的push和pop操作,这种优化可以减少栈操作的次数,提高程序的执行效率 |
-fcompare-elim | 判断计算结果的flag寄存器,如果可以,用flag寄存器的结果来代替显式地比较操作 |
-fcprop-registers | 通过寄存器存有的值,通过计算变量的依赖,减少从内存中读取该变量的值,通过拷贝传播,来减少变量的拷贝 |
-fdce | 消除不会执行的代码 |
-fdefer-pop | 延迟栈的弹出时间,当完成一个函数调用,参数并不马上从栈中弹出,而是在多个函数被调用后,一次性弹出 |
-fdelayed-branch | 根据指令周期时间重新安排指令,把尽可能多的指令移动到条件分支前,以便最充分的利用处理器的治理缓存 |
-fdse | 通过静态分析代码来识别并删除那些永远不会被使用的存储操作 |
-fforward-propagate | 该过程尝试将两个指令组合起来,并检查结果是否可以简化。如果循环展开处于活动状态,则执行两次循环,并在循环展开后安排第二次循环 |
-fguess-branch-probability | 分支预测 |
-fif-conversion | 利用moves, min, max, set, abs等手段把if条件转化为无分支等价代码 |
-fif-conversion2 | 与-fif-conversion基本相同 |
-finline-functions-called-once | 把静态函数转为inline函数 |
-fipa-modref | 跨多个函数或过程分析变量或数据的修改和引用,以便更精确地确定哪些代码可以安全地优化或删除 |
-fipa-profile | 对仅调用一次的函数进行优化 |
-fipa-pure-const | 发现函数是纯函数还是常量函数 |
-fipa-reference | 在多个函数或过程之间分析变量的引用情况,这有助于编译器更准确地识别出哪些变量或数据在程序的执行过程中是活跃的,哪些可以被安全地优化或删除 |
-fipa-reference-addressable | 更精确地确定哪些变量或对象在程序的执行过程中是活跃的,哪些可以被安全地优化或删除 |
-fmerge-constants | 尝试横跨编译单元合并同样的常量 |
-fmove-loop-invariants | 优化循环的不变量 |
-fomit-frame-pointer | 可能的情况下不产生栈帧 |
-freorder-blocks | 重排代码块 |
-fshrink-wrap | 用于减少那些包含大局部变量但实际上只使用部分栈空间的函数的栈帧大小 |
-fshrink-wrap-separate | 将变量的生命周期范围缩小到最小,以减少内存的使用 |
-fsplit-wide-types | 对于某些需要占用多个寄存器的变量,独立的申请寄存器 |
-fssa-backprop | 在定义链上传播相关使用的信息,来简化定义 |
-fssa-phiopt | 优化条件代码 |
-ftree-bit-ccp | 在tree上传播稀疏条件位常量,并对齐传播指针 |
-ftree-ccp | 在tree上传播稀疏条件常量,并对齐传播指针 |
-ftree-ch | 对程序中的数据结构(如抽象语法树)进行某种形式的转换或简化,以便生成更高效、更小的机器代码 |
-ftree-coalesce-vars | 涉及到对程序中的变量进行重命名或替换,以便更有效地使用内存和处理器资源 |
-ftree-copy-prop | 通过分析程序的控制流和数据流,识别出可以复制和重用的计算结果,并在需要的地方直接引用这些结果,而不是重新进行计算,减少不必要的计算量,提高代码的执行效率 |
-ftree-dce | 通过分析代码的控制流和数据流,确定哪些代码是不会执行的代码,并将其从最终生成的机器代码中删除 |
-ftree-dominator-opts | 通常与代码的依赖性和可达性有关,这些优化可能包括常量传播、冗余代码消除等 |
-ftree-dse | 对代码进行数据流分析,并进行相应的优化,以减少不必要的内存访问和存储操作 |
-ftree-forwprop | 避免对中间结果做不必要的中间存储和可能的重复计算 |
-ftree-fre | 基于对数据流和程序控制流的分析,以确定哪些变量在程序执行过程中是必需的,哪些可以被安全地消除 |
-ftree-pta | 对指针变量及其目标的分析和转换,以减少不必要的内存访问、提高缓存命中率等 |
-ftree-scev-cprop | 用于识别代码中的简单标量表达式和常量,并尝试将它们的结果传播到后续使用这些结果的地方,从而避免不必要的重复计算,提高程序的执行效率 |
-ftree-slsr | 分析循环的结构和特性,并尝试通过减少循环次数、简化循环体内的操作或重新组织循环结构等方式来优化性能 |
-funit-at-a-time | 在代码生成前,先分析整个的汇编语言代码,这种优化方式使得一些额外的优化得以执行,但在编译器间需要消耗大量的内存 |
表2-2 Og优化级别开启的优化选项
优化选项 | 优化内容 |
-fbranch-count-reg | 详见表2-1中的描述 |
-fdelayed-branch | 详见表2-1中的描述 |
-fdse | 详见表2-1中的描述 |
-fif-conversion | 详见表2-1中的描述 |
-fif-conversion2 | 详见表2-1中的描述 |
-finline-functions-called-once | 详见表2-1中的描述 |
-fmove-loop-invariants | 详见表2-1中的描述 |
-fssa-phiopt | 详见表2-1中的描述 |
-ftree-bit-ccp | 详见表2-1中的描述 |
-ftree-dse | 详见表2-1中的描述 |
-ftree-pta | 详见表2-1中的描述 |
表2-3 O2优化级别在O1优化级别的基础上额外开启的优化选项
优化选项 | 优化内容 |
-falign-functions | 将函数的起始地址对齐到指定的边界上,通过对齐函数地址,可以确保函数体的指令在内存中是连续且对齐的,从而提高CPU访问这些指令的效率 |
-falign-jumps | 在生成跳转指令时,通过确保跳转指令在内存中的地址是某种特定边界的倍数,来减少CPU在读取和执行这些指令时的等待时间,从而提高程序的执行效率 |
-falign-labels | 将标识程序位置的标记对齐到特定的内存地址边界,以提高代码的执行效率 |
-falign-loops | 把循环的开始地址对齐到边界上,确保循环体内的指令在内存中连续存储,助于减少 CPU 在读取和执行循环指令时的等待时间,从而提高程序的执行效率 |
-fcaller-saves | 减少被调用函数的保存和恢复寄存器的开销 |
-fcode-hoisting | 通过重新排列代码中的指令,将那些在所有分支中都可能需要的计算提前执行,从而避免在分支内部重复执行相同的计算 |
-fcrossjumping | 当编译器发现两个或多个跳转指令的目标地址相同,并且跳转前的代码也相同时,它会尝试将这些跳转指令合并为一个,以减少代码的大小并提高执行效率 |
-fcse-follow-jumps | 它会尝试找出和消除在代码中出现多次的相同或等效的表达式,从而节省计算时间 |
-fcse-skip-blocks | 如果遇到某些特定的代码块(如条件分支后的代码块),并且这些代码块中的表达式不太可能被识别为公共子表达式,那么编译器可以选择跳过这些代码块 |
-fdelete-null-pointer-checks | 检查是否存在delete空指针,并将空指针校验删除,以减小生成的代码大小 |
-fdevirtualize | 允许编译器在编译时分析代码,并尝试将某些虚函数调用转化为直接函数调用,以消除虚函数动态绑定导致的性能开销 |
-fdevirtualize-speculatively | 在-fdevirtualize的基础上增加了推测性,编译器会尝试去猜测某些虚函数调用的实际目标,并提前进行优化,如果猜测正确,那么可以显著提升性能;但如果猜测错误,可能会引入一些额外的开销或问题 |
-fexpensive-optimizations | 它允许编译器执行一些编译更耗时,但可能显著提高最终生成代码性能的操作,包括更复杂的循环优化、更深入的别名分析、更复杂的常量传播和死代码消除等 |
-ffinite-loops | 当编译器能够确定循环的迭代次数是有限的,并且可以在编译时计算出这个次数时,本选项会允许编译器执行一些特定的优化,包括循环展开、循环不变量的识别和计算、以及可能的其他与循环迭代次数相关的优化 |
-fgcse | 会在编译过程中分析代码,并寻找可以在全局范围内共享的公共子表达式,一旦找到这样的表达式,编译器就会生成新的代码,以避免重复计算,从而提高程序的执行效率,并减少内存使用。 |
-finline-functions | 在调用点直接插入函数的代码,来消除函数调用的开销 |
-finline-small-functions | 允许编译器自动将较小的函数内联到它们的调用点 |
-findirect-inlining | 进行间接内联,它告诉编译器在某些条件下尝试对内联候选者进行间接内联,即使这些函数是通过函数指针或虚函数调用的,间接内联消除一些间接调用的开销,然而,它也可能增加代码的复杂性,并可能导致代码大小的增加 |
-fipa-bit-cp | 进行位级的常量传播优化,在涉及位操作的场景下,当编译器识别到位级的常量可以在代码中被广泛使用时,它可以通过替换这些常量以减少冗余操作,从而提升程序的整体效率 |
-fipa-cp | 它会检查函数调用关系,并尝试识别在函数调用过程中保持不变的常量值,编译器会在函数调用之前将这些常量值直接插入到相关的函数中,从而避免了在函数内部对这些常量的重新计算和存储 |
-fipa-icf | 分析函数调用关系,并尝试识别那些可以被内联的候选函数 |
-fipa-ra | 分析函数调用关系,并尝试识别那些可以被重新排列的指令序列,然后编译器会尝试重新组织这些指令,以便在运行时能够更有效地利用CPU的并行处理能力 |
-fipa-sra | 分析函数调用关系,并尝试识别那些可以被重排以改善性能的代码区域,这可能包括将函数内的某些计算移动到更合适的位置,以便更有效地利用缓存和内存层次结构,或者重新组织数据结构以减少不必要的内存访问 |
-fipa-vrp | 识别出那些可以在函数调用间传递的变量值范围,一旦这些值范围被确定,编译器就可以利用这些信息来优化函数调用以及相关的代码 |
-flra-remat | 通过重新组织循环中的指令序列,消除或减少数据依赖,可以使编译器更有效地利用CPU的并行处理能力 |
-foptimize-sibling-calls | 这种优化技术会改变函数调用的方式,以便更有效地利用处理器的指令缓存和内存空间 |
-foptimize-strlen | 对C语言中的字符串处理函数strlen、strcpy等进行优化,目的是使这些函数的执行速度更快 |
-fpartial-inlining | 允许编译器进行部分内联,即只将函数中的一部分代码内联到调用点,而不是整个函数体,这种优化技术可以在一定程度上减少代码膨胀,同时保留内联带来的性能优势 |
-freorder-blocks-algorithm=stc | 编译器会尝试分析程序中的基本块,并来重新排序这些基本块,以便在运行时更好地利用处理器的缓存结构,这种优化可以提高代码的局部性,减少缓存未命中的次数,从而提高程序的执行效率 |
-freorder-blocks-and-partition | 通过重新排列基本块的顺序并考虑分区,编译器可以优化代码的执行路径,减少不必要的依赖和延迟,从而提高程序的性能 |
-freorder-functions | 用于在编译函数时重新安排基本块的顺序,这种优化的主要目的在于减少分支的个数,提高代码的局部性,从而可能提高程序的执行效率 |
-frerun-cse-after-loop | 在循环优化之后重新消除公共子表达式,编译器可以进一步消除这些新的冗余计算,从而进一步提高程序的执行效率 |
-fschedule-insns | 它允许编译器在生成代码时重新排序某些指令,以优化性能 |
-fsched-interblock | 它允许编译器跨越指令块进行指令调度,这意味着编译器在生成代码时,可以更加灵活地重新排列或移动指令,这种优化可以提高代码的并行性,减少不必要的依赖,从而提高程序的执行效率 |
-fsched-spec | 编译器会尝试分析代码中的指令,并确定哪些指令可以提前执行,以提高程序的执行效率,然而这种优化是有风险的,因为如果预测失败,处理器可能会执行错误的指令或产生不正确的结果 |
-fstore-merging | 识别出那些相邻的、对相同内存位置进行写入的存储操作,并将它们合并成一个,这种优化可以减少对内存的访问次数,提高代码的执行效率 |
-fthread-jumps | 可以帮助编译器确定多个跳转之间的最终目标,并通过一连串的跳转,将第一个跳转重新定向到该最终目标 |
-ftree-builtin-call-dce | 在编译过程中分析代码中的内置函数调用,并尝试消除那些永远不会被执行的函数调用。这可以帮助减少最终生成的代码大小,并提高程序的执行效率 |
-ftree-switch-conversion | 尝试提高switch语句执行效率的优化技术,当编译器遇到switch语句时,它会分析switch语句中的case标签和相关的代码块,并尝试找到一个更高效的方式来表示这个switch语句 |
-ftree-vrp | 它通过分析代码中的变量和表达式的值范围,来消除不必要的计算、简化代码或进行其他优化 |
表2-4 Os优化级别会开启几乎所有的O2级优化选项,除了以下经常会增加代码大小的选项
优化选项 | 优化内容 |
-falign-functions | 详见表2-3中的描述 |
-falign-jumps | 详见表2-3中的描述 |
-falign-labels | 详见表2-3中的描述 |
-falign-loops | 详见表2-3中的描述 |
-freorder-blocks-algorithm=stc | 详见表2-3中的描述 |
上述优化内容是造成Debug版本和Release版本下代码缺陷表现不同的主要原因,Debug版本下的源代码是没有被优化的,基本上是直接翻译的,而Release版本下的源代码被编译器进行了一系列优化,使最终生成的机器码发生了巨大的变化,因此当代码有缺陷时,两种版本下的运行结果就可能表现出差异。
3.代码缺陷在Debug版本和Release版本下的差异表现
下面对几种常见的、容易引起Debug版本和Release版本下表现不同的代码缺陷进行分析。
3.1. 变量未初始化
对于未初始化的变量,Debug版本下会默认对其进行初始化,而Release版本下则不会,所以就有个常见的问题,局部变量未初始化时,Debug版本和Release版本表现有所不同,本文给的代码示例如下所示。
1 bool func() { 2 bool found; 3 for (int i = 0; i < vec.size(); ++i) { 4 if (vec[i] == 3) { 5 found = true; 6 } 7 } 8 return found; 9 }
Debug版本下该代码可能会运行正常,因为对于某些开发环境或编译器,其设置可能会为了调试而“填充”未初始化的局部变量,以便在调试时更容易发现未初始化变量的问题,如VS开发环境在Debug版本下,会将栈初始化为0xCC(0xCC为单步调试指令的机器码),但是这种“填充”的值并不是C/C++标准规定的。而Release版本下该代码就可能会返回错误的结果,因为found局部变量在Release版本下没有初始化。
类似的,如果未被初始化的变量用作控制变量将导致流程导向不一致,用作数组下标将会使程序崩溃,更加可能是造成其他变量的不准确而引起其他的错误。
3.2.未知因素修改变量
优化程序为了使程序性能提高,常把一些变量放在寄存器中(类似于register关键字的作用),其它线程只能对该变量所在的内存进行修改,而寄存器中的值没变,如果你的程序是多线程的,或者你发现某个变量的值与预期的不符,而你确信已正确的设置该值,则很可能是遇到了这样的问题,这种错误有时会表现为程序在最快优化时出错,而最小优化时正常。
在本文给出的代码示例中,开辟一个子线程用于修改一个全局变量,主线程接到全局变量变化时退出,然而在却发现在Release版本下,主线程的is_ok变量一直不会变。
1 static bool is_ok = false; 2 void t1() { 3 while(1){ 4 if(!is_ok) is_ok = true; 5 } 6 } 7 8 int main() { 9 std::thread th1(t1); 10 for(int i = 0; i < 10; i ++){ 11 is_ok = false; 12 while(1){ 13 if(is_ok) break; 14 } 15 } 16 th1.join(); 17 18 return 0; 19 }
这里其实就是编译器的优化导致的,is_ok变量被优化掉了。编译器为了提高程序性能,在优化代码时可能会将变量的值存储在寄存器中,而不是每次都从内存中读取,但是,如果这个变量的值可能在程序的其他部分(例如由中断服务程序或并发线程)被改变,那么使用存储在寄存器中的副本就会导致错误。类似的,可能被优化为寄存器变量的包括临时中间变量、循环计数器、函数参数、指针(包括this指针)、引用、公共子表达式等。
volatile关键字可以告诉编译器不要进行上述优化,volatile是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。当要求使用volatile声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据,而且读取的数据立刻被保存,如下图3-1所示。
图3-1 volatile底层原理示意图
当使用volatile阻止is_ok变量优化时,线程启动之后会从主内存中读取变量的副本存在工作内存中,通过read指令读取、load指令载入工作内存,子线程会对is_ok取反操作,通过use指令读取工作内存的变量,通过assign指令进行计算,把计算的新值赋值给工作内存的变量赋值,若共享变量是volatile声明的,就会把新值立即回写到主内存,通过store指令读取工作内存的变量传入到主内存,然后在主内存中通过write指令给变量赋值。
所有的读写请求都通过总线,由总线传递给所有的CPU,然后CPU去“嗅探”这些请求,当感知到数据变化时将自己的缓存的数据置为失效,当需要回写或者使用时会重新从内存中读取,即通过所谓的总线嗅探机制和缓存一致性协议来保证数据的一致性。
3.3.数组越界
若一个函数中,存在某些未被使用的变量,且函数内有数据溢出问题,则Debug版本下可能不会产生问题,因为不会对该变量进行优化,它在栈空间中占有一定字节的空间,但是Release版本下就可能会出问题,因为Release版本下可能会优化掉此变量,栈空间相应变小,数据溢出就会导致栈内存损坏,有可能会产生奇奇怪怪的错误。例如:
1 void func(){ 2 char buffer[10]; 3 char counter; 4 strcpy(buffer, "abcdefghik"); // 11-byte copy, including "\0" 5 }
在Debug版本下,最后一个终止的空字符"\0"要越界放入数组外,可能把其放入counter变量的位置,而在Release版本下,可能会把counter变量放在寄存器中,就没有空间让"\0"存放,从而造成崩溃。
在本文的问题背景中,存放数据的是一个类似二维数组的结构,其中某一维的数组越界时,若处于Debug版本,则越界的数据可能放入了后面其它未被使用维度的数组空间内,而当处于Release版本时,那些未被使用的数组空间就被优化掉了,从而让越界的数据破坏了栈内其它的数据,造成崩溃。
3.4.宏定义变化
对于C++程序,开发者在Debug版本下会使用assert来及时发现潜在的问题,而在Release版本下会禁用assert,此时程序遇到问题后,可能不会及时暴露,而是继续运行,到后期再产生奇奇怪怪的错误,编译器可以在Release版本下定义NDEBUG宏,预处理器会根据该宏来判断是否开启assert检查,从而可能造成两种编译版本下运行现象的差异。
4.如何“调试”Release版本的程序
遇到程序在Debug版本下成功,在Release版本下失败,显然是一件很让人沮丧的事,而且往往无从下手。如果根据前面章节的分析,结合缺陷的具体表现,很快找出了原因,固然很好,但如果一时找不出原因,以下给出了一些在这种情况下的策略:
1、前面已经提过,Debug版本和Release版本在本质上只是优化选项的差别,因此我们可以修改Release版本的优化选项来缩小错误范围,按照前文表2-1至表2-4中的内容,把Release版本的选项逐个添加,判断是何种优化引起的差异,从而有针对性的查找缺陷。
2、由于开发者通常在Debug状态下开发软件,为避免留最后代码量太多,时间又很紧的情况下出现类似的问题,我们最好在编程过程中就要时常注意测试Release版本。
3、重视编译器给出的警告信息,通常这些警告就是你程序中隐藏的缺陷,它们可能会在一定条件下才表现出问题,比如强制类型转换导致的数据丢失或精度降低,可能会让程序在数据计算时才出现意外的结果。