OI-wiki 编译优化指南:C++ 编译器优化原理、未定义行为与 Sanitizer 实战
OI-wiki 编译优化指南C 编译器优化原理、未定义行为与 Sanitizer 实战【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. 某大型游戏线上攻略内含炫酷算术魔法项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki本文整理自 OI-wiki 的 编译优化 章节。OI信息学奥林匹克与 ICPC 竞赛中最常用的编程语言是 C使用这门语言就注定要与编译器、语言标准打交道。本指南从编译器的视角出发系统讲解常量折叠、死代码消除、循环变换、函数内联、尾调用优化、强度削减、自动向量化等核心优化手段剖析未定义行为UB如何被编译器利用并介绍 Address Sanitizer 与 Undefined Behavior Sanitizer 的本地调试实践。读完本文你将理解开 O2后编译器到底做了什么、为什么开优化后递归与非递归没有区别、以及如何借助 Sanitizer 快速定位越界与溢出类错误。为什么要理解编译器优化OI 界的常用编程语言是 C。众所周知C非常混乱邪恶与它打交道就不可避免地要面对编译器与语言标准。根据 C 标准中的 如同规则The as-if Rule编译器被允许在保持程序可观察行为语义不变的前提下对程序的运行速度、可执行文件大小作出改进——这正是优化Optimization的定义。理解优化对竞赛选手有直接收益判断哪些手写优化是多余的例如开 O2 后的inline、register、x 1判断哪些代码写法会拖累编译器例如需要做符号判断的除法、不透明的指针别名理解开优化后递归版本和非递归版本生成代码一致这一结论的适用范围与前提认识未定义行为UB在优化加持下的危险程度避免写出评测时行为诡异的代码。常见的编译器优化常量折叠Constant Folding常量折叠又称常量传播Constant Propagation如果一个表达式可以确定为常量那么在其下一个定义Definition之前可以进行常量传播。看下面的例子int x 1; int y x; // x 1, y 1 x 3; int z 2 * y; // z 2 * y 2 * 1 2 int y2 x * 2; // x 3, y2 6这段代码在编译期间即可被转换为int x 1; int y 1; x 3; int z 2; int y2 6;注意这里的关键是在其下一个定义前x在赋值给y之后又被重新定义为3因此y2 x * 2使用的是新值3而不是旧值1。编译器需要跟踪变量在每个程序点的取值这属于数据流分析Data Flow Analysis的经典应用。你可以在 Compiler Explorergodbolt.org中观察这类转换例如 OI-wiki 提供的 实例。死代码消除Dead Code Elimination顾名思义死代码消除就是把没用上的代码删去。例如int test() { int a 233; int b a * 2; int c 234; return c; }将被转换为int test() { return 234; }注意这个代码首先进行了常量折叠使得返回值可以确定为234此时a、b属于不活跃变量其值不影响后续计算因此被删除。也就是说各类优化是串联协作的前一个 pass 的输出会成为后一个 pass 的输入最终生成高度精简的代码。循环旋转Loop Rotate循环旋转将循环从 for 形式转换为 do-while 形式并在前面多加一个条件判断。这个变换本身通常不带来直接收益而是主要为其他变换做准备for (int i 0; i n; i) { auto v *p; use(v); }变换为if (0 n) { do { auto v *p; use(v); i; } while (i n); }do-while 形式 前置守卫guard是后续循环优化的标准中间形态下一节会看到它的用武之地。循环不变量外提Loop Invariant Code Motion基于别名分析Alias Analysis编译器将循环中被证明是不变量可能包含内存访问 load/store因此依赖别名分析的代码外提出循环体让循环体内部少一些代码for (int i 0; i n; i) { auto v *p; use(v); }这个代码直观来看可以外提为auto v *p; for (int i 0; i n; i) { use(v); }但实际上如果n 0这个循环永远不会被进入但我们却额外执行了一条可能有副作用的指令如解引用空指针、触发缺页等。因此循环通常先被Rotate 为 do-while 形式方便插入一个 loop guard之后再进行循环不变量外提if (0 n) { // loop guard auto v *p; do { use(v); i; } while (i n); }这个例子很好地说明了优化 pass 之间存在依赖关系循环旋转是循环不变量外提的前置条件。循环展开Loop Unroll循环包含循环体和各类分支语句需要现代 CPU 进行一定的分支预测。直接把循环展开是用一定的代码大小换取运行时间for (int i 0; i 3; i) { a[i] i; }变换为a[0] 0; a[1] 1; a[2] 2;完全展开可以消除分支预测失败的代价并让后续优化如常量传播、寄存器分配看到更长的直通指令序列。展开倍数是一个权衡展开太狠会导致指令缓存I-Cache压力增大因此编译器通常只对已知较小迭代次数或热点循环做完全/部分展开。循环判断外提Loop Unswitching循环判断外提将循环中的条件式移到循环之外然后在两个外部条件下各放置一个循环从而让每个循环体内不再包含该分支判断增加循环向量化、并行化的可能性通常简单循环更容易被向量化// clang-format off void before(int x) { for(;/* i in some range */;) { /* A */; if (/* condition */ x % 2) { /* B */; } /* C */; } } void after(int x) { if (/* condition */ x % 2) { for(;/* i in some range */;) { /* A */; /* B */; // 直接执行 B 不进行循环判断 /* C */; } } else { for(;/* i in some range */;) { /* A */; // 不执行 B /* C */; } } }注意这里的外提条件是循环不变量x % 2不随循环迭代改变这正是它可行的前提。代码布局优化Code Layout Optimizations程序在执行时可以将执行的路径分为冷热路径cold/hot path。CPU 跳转执行绝大多数情况下没有直接顺序执行快后者通常被编译器作者称为 fallthrough顺延执行。经常被执行到的代码称为热代码与之相对的是冷代码。OI 代码中循环里的特判边界条件、异常处理等逻辑就属于冷代码。基本块Basic Block是控制流的基本结构一个过程Procedure由若干个基本块组成形成一个有向图。生成可执行文件的过程中编译器需要安排一个放置基本块的布局Layout如何编排布局就是代码布局优化的重点。原则上应该更偏好将热代码放在一起、将冷代码隔开这样能更好地利用指令缓存热代码的局部性会更好。// clang-format off int hotpath; // -- 热 if (/* 边界条件 */ false) { // -- 冷 } int hotpath_again; // -- 热基本块放置Basic Block Placement用 label 来表达一种伪机器码同一个 C 程序有两种翻译方法??? note 布局 1cpp // clang-format off hotblock1: Stmts; // -- 热 if (/* 边界条件不成立 */ true) goto hotblock2; // 经常发生 ------ coldblock: /* | */ Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | 跨越了大量指令代价高昂 Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | hotblock2: /* | */ Stmts; // - 热 ----------另一种布局为??? note 布局 2cpp // clang-format off hotblock1: Stmts; // -- 热 if (/* 边界条件 */ false) goto coldblock; // 很少发生 hotblock2: /* | 低代价 */ Stmts; // - 热 ----------------- coldblock: Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷可以看到布局 2 中两个热代码块被放到了一起跳转到热块是顺延式的低代价跳转而昂贵的跨冷块跳跃被留给了很少执行的冷路径执行效率更优秀。为了告诉编译器分支是否容易被执行可以使用 C20 的[[likely]]和[[unlikely]]属性参见 cppreference。如果比赛没有采用 C20 以上标准则可以利用__builtin_expectGNU Extension#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0) if (unlikely(/* 一些边界条件检查 */ false)) { // 冷代码 }注意!!(x)的写法先把x规范化成0/1再配合期望值1likely或0unlikely传给内建函数避免把任意非零值误当作布尔语义。冷热代码分离Hot Cold Splitting一个过程Procedure可能同时包含冷热路径如果冷代码较长更好的做法是让冷代码作为函数调用而不是阻断热路径??? note 不好的代码布局cpp // clang-format off void foo() { // clang-format off hotblock1: Stmts; // -- 热 if (/* 边界条件不成立 */ true) goto hotblock2; // 经常发生 ------ coldblock: /* | */ Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | 跨越了大量指令代价高昂 Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | Stmt; // - 冷 | hotblock2: /* | */ Stmts; // - 热 ---------- }??? note 好的代码布局 cpp // clang-format off void foo() { hotblock1: Stmts; // -- 热 if (/* 边界条件 */ false) coldBlock(); // 将冷代码分离出使得热路径对 cache 更友好 hotblock2: Stmts; // - 热 }void coldBlock() { Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 Stmt; // - 冷 } 冷热代码分离其实就是函数内联Function Inlining的反向操作。这一优化的存在告诉我们函数内联不一定会让程序跑得更快——如果被内联的代码是冷代码反而会让程序更慢。因此不要自作聪明地让所有函数inline冷代码对执行速度的阻碍比函数调用要多得多。编译器内部有一个静态分析过程会计算每个基本块、分支的概率以及一个与函数调用相关的代价模型以此决定是否内联。自己决定是否内联不一定比编译器的决策好。事实上在没有额外信息的情况下编译器通常会假设分支跳转与不跳转的概率一致并以此为依据传播各个控制流路径的冷热程度。PGOProfile Guided Optimization剖析引导优化的一部分便是通过若干次性能测试与实验得出真实环境下的程序分支概率这些信息可以让代码布局更加优秀。函数内联Function Inlining函数调用通常需要寄存器和栈传递参数调用者caller和被调用者callee都需要保存一定的寄存器状态这个过程通常被叫做调用约定calling convention。一次函数调用因此会引起一些时间损耗。内联是指将函数体直接写在调用方过程中不进行真正的函数调用int add(int x) { return x 1; } int foo() { int a 1; a add(a); }add()可以被内联到foo()当中int foo() { int a 1; a a 1; // -- add() 的函数体未经过传参 }内联消除了传参、保存/恢复寄存器和 call/ret 指令的代价并且让常量传播等优化能看穿函数边界是 LLVM 中-O2优化的核心组成对应 pass 为 Inliner。always_inline、__force_inline一些编译器提供了手动强制内联的方法在函数前加__attribute__((always_inline))详见 Clang Attribute Reference。但这样使用不一定会比函数调用快——编译器在这种情况下选择相信程序员有足够好的判断能力放弃了自身的代价模型。如前一节所述盲目强制内联冷代码会破坏代码布局得不偿失。尾调用优化Tail Call Optimization当一个函数调用位于函数体尾部的位置时这种函数调用被称为尾调用Tail Call。对于这种特殊形式的调用可以进行一些特别的优化。绝大多数体系结构拥有 Frame Pointer即 FP和 Stack Pointer即 SP用于维护函数的调用帧Frame。如果调用位于函数尾部则可以不保留外层函数的调用记录直接用内层函数取代。用跳转指令代替函数调用函数调用在绝大多数体系结构下需要保存当前程序计数器$pc的位置保存若干 caller-saved 寄存器以便回到现场。而尾调用不需要此过程因为尾调用永远不会返回到调用它的那个位置将被直接翻译为跳转指令int test(int a); int tailCall(int x) { return test(x); }tailCall(int): ; tailCall(int) jmp test(int)PLT ; TAILCALL可以看到tailCall的汇编只有一条jmp没有call、没有栈帧维护、没有返回地址保存。OI-wiki 提供了对应的 Compiler Explorer 实例 供读者自行验证。自动尾递归改写如果一个函数的尾调用是自身则此函数是尾递归的。广义来讲间接递归由两个及以上函数共同形成、且都是尾调用的递归也属于尾递归的范畴。尾递归可以被编译器优化为非递归的形式减小额外的栈开销和函数调用代价。许多算法竞赛选手热衷于写非递归的代码在不开优化的情况下这可以极大优化代码的常数然而如果开优化递归代码生成的二进制质量和手写的非递归代码没有什么区别。int fac(int n) { if (n 2) return 1; return /* 使用 */ n * fac(n - 1); /* 使用了变量 n 无法直接做尾递归优化*/ }注意到这个函数并不是尾递归的——因为fac(n - 1)的返回值还要乘上n调用之后还有工作要做。但它可以改写为累加器风格accumulator styleint fac(int acc, int n) { if (n 2) return acc; return fac(acc * n, n - 1); }新的代码即是尾递归的。现代编译器可以自动帮你完成这个过程如果你的代码有机会被改写为尾递归编译器可以识别出这种形式并完成改写。尾递归消除-Rpasstailcallelim既然函数已经尾递归就可以直接删除递归语句通过一定的静态分析将函数转换为非递归形式。我们不必深究编译器作者如何做到这一点从实际体验来看绝大多数 OI 代码如果存在递归版本和非递归版本则递归版本一般可自动优化为非递归版本。这里给出几个具体的例子??? note GCDcpp int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }??? note 斐波那契数列cpp // 展开 fib(n - 2) 这一项 // fib(n - 1) 不能变换为非递归优化后的代码依然是指数级别的 int fib(int n) { if (n 2) return 1; return fib(n - 1) fib(n - 2); }??? note 阶乘cpp // 展开成标量循环然后执行自动向量化生成的代码是 SIMD 的 unsigned fac(unsigned n) { if (n 2) return 1; return n * fac(n - 1); }这些函数被优化后的汇编和非递归版完全相同递归将被直接消除。对于 OI 选手而言可以在开 O2 的情况下放心写递归版本的各种算法和非递归版不会有什么区别。当然如果你写的函数本身无法被改写成非递归的形式例如fib中展开fib(n-1)的调用仍依赖另一路递归结果那么编译器也无能为力优化后的代码依然保持原有的复杂度级别。提示LLVM 的尾调用消除 pass 可以通过-Rpasstailcallelim输出相关优化信息对应 Clang 的-Rpass系列诊断。编译时加上该选项可以看到哪些尾调用被消除了。强度削减Strength Reduction强度削减是常见的编译优化将高开销的指令转换为低开销的指令。最简单的例子是x * 2变为x 1第二种写法在 OI 中相当常见。编译器会自动做类似的优化在打开优化开关的情况下x * 2和x 1是完全等价的。标量运算符变换移位代替乘法int a; a x * 2; // bad! a x 1; // good!需要注意的是有符号数和无符号数在移位shifting和类型提升promotion层面有明显的差异。符号位在移位时有特别处理包括算术移位和逻辑移位两种类型。这在编写二分查找、线段树等含有大量除二操作时表现突出有符号整数除法不能直接优化为一步右移位运算。int l, r; /* codes */ int mid (l r) / 2; /* 如果编译器不能假定 l, r 非负则会生成较差的代码 */ // 不能优化为 // mid (l r) 1 // 反例 // mid -127 // mid / 2 -63 // mid 1 -64-127 / 2在 C 中向零取整得-63而算术右移一位得到-64向下取整二者结果不同。编译器要么生成带修正的复杂指令序列要么保守地不做优化。如果确实想手动处理可以加符号位再右移int mid (l r); int sign mid 31; /* 逻辑右移, 得到符号位 */ mid sign; mid 1; /* 算术右移 */可行的解决方案用unsigned l, r;——下标本来就应该是无符号的在源代码中使用移位。乘法代替除法int x a / 3;此过程可以被变换为x a * 0x55555556 32即把除以常数转化为乘以一个魔法数magic number再移位通过乘法的廉价性替换除法的昂贵性。具体原理可参考相关 知乎回答 或 原始论文Granlund 与 Montgomery 的 Division by Invariant Integers using Multiplication。编译器在除数是编译期常数时自动执行该变换。索引变量强度削减IndVars编译器自动识别出循环中的索引变量并将相关的高开销过程转换为低开销形式int a 0; for (int i 1; i 10; i) { a 3 * i; // bad! a a 3; // good! }此处直接写a 3 * i在 OI 中很常见而编译器可以自动分析出等价的变换a a 3用代价更低的加法代替乘法。分析循环变量的迭代过程被称为SCEVScalar Evolution标量演化。SCEV 还可以做到优化一些循环的闭合形式例如int test(int n) { int ans 1; for (int i 0; i n; i) { ans i * (i 1); } return ans; }此函数会被优化为O(1) 公式求和参考 Compiler Explorer 实例。这个行为目前仅有基于 LLVM 的编译器如 Clang会出现GCC 编译器更加保守。优化后的汇编不再包含循环test(int): # test(int) test edi, edi jle .LBB0_1 lea eax, [rdi - 1] lea ecx, [rdi - 2] imul rcx, rax lea eax, [rdi - 3] imul rax, rcx shr rax imul eax, eax, 1431655766 and ecx, -2 lea eax, [rax 2*rcx] lea eax, [rax 2*rdi] dec eax ret .LBB0_1: mov eax, 1 ret这里 LLVM 利用 SCEV 推导出1 Σ i(i1)的闭式公式(n-1)n(n1)/3 1将循环整体替换为常数次算术运算。这也是O(n) 循环被编译器算成 O(1)的典型证据。自动向量化Auto-Vectorization单指令流多数据流SIMD是提供单核并行的好方法。使用这类指令可以充分利用 CPU 的 SIMD 寄存器——它们比通用寄存器更宽例如一次放 4 个整数然后同时计算。OI 选手不需要了解自动向量化的全部细节通常而言Clang 编译器会做比 GCC 更激进的自动向量化// https://godbolt.org/z/h1hx5sWoE void test(int *a, int *b, int n) { for (int i 0; i n; i) { a[i] b[i]; } }__restrict类型限定符GNU、MSVC两个任意指针指向的区域可能重叠overlap此时编译器需要特判是否可以使用向量代码。下图展示了一个指针重叠的例子__restrict作为一种约定使编译器假定两个指针所指向的内存区域永远不会重叠从而可以放心生成向量代码void test(int* __restrict a, int* __restrict b, int n) { for (int i 0; i n; i) { a[i] b[i]; } }__restrict并非 C 标准的一部分但各大编译器都可以使用。此关键字影响自动向量化的代码生成质量在极端卡常的情况下可以使用。和编译优化相关的常见语言误用inline —— 内联函数内联在开 O2 的情况下通常由编译器自动完成。结构体定义中的inline完全是多余的如果准备的比赛开 O2 优化则完全不必声明为内联如果不开 O2使用inline也不会让编译器真正内联。需要特别注意的是inline关键字在现代 C 中被当作一种链接、导出符号的语义行为而不是做函数内联。例如在头文件中定义函数时inline用于避免 ODROne Definition Rule违规——它解决的是多个翻译单元重复定义的链接问题与把函数体展开到调用点的速度优化无关。register —— 虚假的寄存器建议现代编译器会直接忽略你的register关键字——你自己认为的寄存器分配一般没有编译器直接跑寄存器分配算法来得聪明。此关键字于C11 被弃用于C17 被删除依据 P0001R1。详见 cppreference。未定义行为Undefined Behavior与编译优化编译器可以认为 C 程序不存在未定义行为UB因此在编译存在 UB 的程序时编译器可能会产生意想不到的结果。同时编译器也可以在假定不存在 UB的前提下进行更加激进而自由的优化——很多看似编译器疯了的变换根源都在这里。常见的 UB 有有符号溢出使用未初始化的变量访问越界空指针解引用无副作用的无限循环。下面逐一用可复现的例子说明这些 UB 会被优化成什么。有符号溢出int f(int x) { return x * 2 / 2; }编译器可以假定程序不存在有符号溢出的行为x * 2不会溢出进而此函数可能被优化为int f(int x) { return x; }相关示例见 godbolt 1、godbolt 2。如果x足够大导致x * 2真正溢出在开启优化的构建中你的程序行为将是未定义的——它可能直接返回x而不是你期望的溢出回绕结果。可通过-fwrapv选项禁用该假设让有符号溢出按回绕wrap-around语义处理见 GCC 文档示例见 godbolt 1、godbolt 2。这也解释了为什么竞赛中计算(l r) / 2的经典写法l (r - l) / 2既避免了溢出 UB又不依赖编译器的溢出假设。使用未初始化的变量int f(int x) { int a; if (x) // either x nonzero or UB a 42; return a; }编译器可以假定程序不存在使用未初始化变量的行为所以a一定会被初始化是成立的进而此函数可能被优化为int f(int) { return 42; }示例见 godbolt 1、godbolt 2。在 OI 评测中这类忘记初始化的代码在不同优化级别下可能表现出完全不同甚至看似随机的结果务必警惕。访问越界int table[4] {}; bool exists_in_table(int v) { // return true in one of the first 4 iterations or UB due to out-of-bounds // access for (int i 0; i 4; i) if (table[i] v) return true; return false; }编译器可以假定程序不存在访问越界的行为所以该函数一定会在发生访问越界之前返回进而此函数可能被优化为bool exists_in_table(int) { return true; }示例见 godbolt。注意循环条件是i 4最后一次迭代table[4]已经越界编译器利用不存在 UB的假设直接推断该函数恒返回true。空指针解引用int f(int* p) { int x *p; if (!p) return x; // Either UB above or this branch is never taken else return 0; }编译器可以假定程序不存在空指针解引用的行为从而!p恒为false进而此函数可能被优化为int f(int*) { return 0; }示例见 godbolt 1、godbolt 2。即使你的代码在先解引用、后判空编译器也会基于 UB 假设删掉那个多余的判空分支。无副作用的无限循环??? note 验证 Fermat 大定理 由 Fermat 大定理 可知不定方程 $a^3b^3c^3$ 没有正整数解。下面的程序试图枚举 $[1,1000]$ 内的整数验证该方程是否成立若返回true则说明在 $[1,1000]$ 范围内找到了一组整数解从而 Fermat 大定理不成立。cpp #include iostream bool fermat() { const int max_value 1000; // Endless loop with no side effects is UB for (int a 1, b 1, c 1; true;) { if (((a * a * a) ((b * b * b) (c * c * c)))) return true; // disproved :() a; if (a max_value) { a 1; b; } if (b max_value) { b 1; c; } if (c max_value) c 1; } return false; // not disproved } int main() { std::cout Fermats Last Theorem ; fermat() ? std::cout has been disproved!\n : std::cout has not been disproved.\n; } 编译器可以假定程序不存在无副作用的无限循环从而认为fermat()中的 for 循环一定会在某一时刻终止并返回true最终程序可能输出Fermats Last Theorem has been disproved!示例见 godbolt 1、godbolt 2。这个例子幽默地展示了在优化开启时我明明写了无限循环不一定是事实——只要循环没有副作用且不终止编译器就有权认为它必然终止。对 OI 选手的启示与其凭直觉猜测某段 UB 代码在评测机上会怎样表现不如从一开始就规避 UB。这也是接下来 Sanitizer 部分的价值所在。Sanitizer编译期插桩的运行时检查工具Sanitizer 可以译为理智保证器在运行时检查你的程序是否有未定义行为、数组越界、空指针解引用等问题。在本地调试模式下建议开启一些 Sanitizer可以极大缩短你的 Debug 时间。这些 Sanitizer 由 Google 开发绝大多数可以在 GCC 和 Clang 中使用。Sanitizer 在 LLVM 中更加成熟因此推荐选手本地使用 Clang 编译器进行相关除错。Address Sanitizer-fsanitizeaddressGCC 和 Clang 都支持这个 Sanitizer详见 Clang AddressSanitizer 文档。它主要检查越界out-of-bounds释放后使用use-after-free返回后使用use-after-return重复释放double-free内存泄漏memory-leaks离开作用域后使用use-after-scope应用这项检查会让你的程序慢 2x 左右因此它适合本地调试而不适合作为最终提交的编译选项。典型用法# 编译时开启 AddressSanitizerGCC 与 Clang 均可 g -fsanitizeaddress -g -O1 main.cpp -o main # 或使用 Clang clang -fsanitizeaddress -g -O1 main.cpp -o main运行程序后一旦触发越界、use-after-free、double-free 等错误会打印出包含出错代码位置与调用栈的详细报告配合-g生成的调试信息即可快速定位问题行。Undefined Behavior Sanitizer-fsanitizeundefinedUndefined Behavior Sanitizer简称 UBSan用于检查代码中的未定义行为GCC 和 Clang 都支持详见 Clang UndefinedBehaviorSanitizer 文档。它自动检查你的程序有无未定义行为检查项目包括移位溢出例如 32 位整数左移 72 位有符号整数溢出浮点数转换到整数数据溢出UBSan 的检查项可选例如-fsanitizeshift,integer可以只开启部分子项对程序的影响参考官方文档提供的说明。典型用法g -fsanitizeundefined -g -O1 main.cpp -o main # 或 clang -fsanitizeundefined -g -O1 main.cpp -o main对于 OI 选手UBSan 尤其适合抓出二分(l r)溢出、移位越界这类隐蔽 bug它比 AddressSanitizer 更轻量通常可以常开于本地测试中。两者也可以组合使用-fsanitizeaddress,undefined。注意Sanitizer 会显著增大二进制体积并降低运行速度且依赖运行时库请勿将其用于最终提交同时 Sanitizer 依赖对内存布局的插桩与部分优化选项如某些内联/循环变换组合时行为可能有差异本地调试建议配合-O1使用。杂项Compiler ExplorerCompiler Explorergodbolt.org 是观察编译器行为的利器在这里可以对比 GCC、Clang 等多个编译器在相同源代码上的汇编输出查看每个优化 pass 的效果也可以验证本文中所有关于 UB 的示例。OI-wiki 在讲解常量折叠、尾调用优化、SCEV、UB 等主题时都附带了对应的 godbolt 实例链接建议读者在阅读时动手验证。扩展阅读The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #1/3The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #2/3The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #3/3参考资料与注释P0001R1: Remove Deprecated Use of the register Keyword——register关键字在 C11 被弃用、C17 被删除的提案依据。本文由 OI-wiki 的 编译优化 章节整理扩充而来该章节在 OI-wiki 中位于 C 进阶 导航见 mkdocs.yml之下与 竞赛常见技巧 中的循环展开、代码布局优化等话题互相呼应。【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. 某大型游戏线上攻略内含炫酷算术魔法项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考