1. 从 Debug 切到 -O2 后,ESP32 的“崩溃”到底发生了什么
先说明一个很容易写错的细节:很多人把优化等级写成-02,其实它是-O2,是大写字母O不是数字0,意思是“优化等级为 2”。这个选项在 ESP-IDF 里对应的菜单配置是Component config → Compiler options → Optimization Level → Optimize for performance (-O2),而默认的 Debug 档通常是Debug (-Og)。
我遇到过的场景是这样的:一块 ESP32 开发板跑一个数据采集+TCP 上报的固件,Debug 档下怎么跑都正常,连续跑两三天也不复位。后来为了把 150ms 的采集周期压到 120ms,我把优化等级切到了-O2,烧进去不到三分钟,串口就刷出了一屏Guru Meditation Error: Core 1 panic'ed (LoadProhibited),然后自动重启。重启后可能正常几分钟,也可能几十秒又崩,完全没有规律。
这种“切个优化等级就崩”的现象在嵌入式圈子里非常经典,不是玄学,也不是 ESP32 芯片本身的问题。它本质上是编译器在更激进的优化策略下,把你代码里原本就存在、但被低级优化“掩盖”住的隐含错误暴露了出来。这篇文章我会把原理、取证方法、一条完整排查链路、常见根因,以及最终怎么让固件在-O2下稳定跑起来,一次性讲清楚。
1.1 ESP-IDF 里四个优化档位的真实区别
先看这张表,后面所有分析都建立在它上面:
| 菜单选项 | 实际编译参数 | 设计目标 | 调试体验 | 性能/体积 |
|---|---|---|---|---|
| Debug (-Og) | -Og | 在保留调试体验的前提下做不干扰排错的优化 | 变量大多保留在栈上,打断点、看值是准的 | 比 -O0 快,但远低于 -O2 |
| Debug (-O0) | -O0 | 完全不优化 | 最直观,代码顺序和源码几乎一一对应 | 最慢,代码最大 |
| Optimize for performance (-O2) | -O2 | 最大化执行速度 | 变量可能放进寄存器,单步执行顺序会跳,断点位置会漂 | 最快 |
| Optimize for size (-Os) | -Os | 压缩 Flash/RAM 占用 | 同 -O2,甚至因为去冗余更难看 | 代码最小,速度不一定快 |
注意第一行:-Og不等于“关闭优化”。它依然会做常量传播、死代码删除等“不伤害调试”的优化,只是不会对变量做激进的寄存器分配和指令重排。所以平时你在-Og下觉得“没优化”,其实编译器已经做了一部分工作,只不过这些工作比较温和,掩盖了很多潜在 bug。
从-Og切到-O2,区别就大了。-O2是 GCC 里一套完整的优化包,包含几十个子选项,稍微列举几个影响最大的:
- 函数内联(
-finline-functions):短函数直接展开到调用点。 - 常量传播与折叠(
-fipa-cp、-foptimize-sibling-calls):把常量带入函数体里计算。 - 循环优化(
-ftree-loop-optimize、-ftree-vectorize):循环展开、向量化、把循环不变的计算提到循环外。 - 指令重排与调度(
-fschedule-insns):为了流水线效率改变指令顺序。 - 严格别名假设(
-fstrict-aliasing):认为不同类型的指针不会指向同一块内存。 - 跨函数调用优化(
-fipa-*):基于整个编译单元的调用关系做剪枝和重排。 - 省略栈帧指针(
-fomit-frame-pointer):不维护a0/a1之外的帧指针,导致 panic 时栈回溯信息变少。
这些优化本身都是为了性能,但它们隐含的前提是:你的代码完全符合 C 标准,没有任何未定义行为(UB),也不依赖“恰好没被优化掉的执行顺�序”。一旦前提不成立,-O2就会把你代码里的隐患从“碰巧没踩到”变成“每次都踩到”。
1.2 为什么 -Og 能跑、-O2 就崩:三个层面的原因
我总结下来,切优化等级后崩溃的原因可以归到三个层面:
第一层:变量的可见性发生变化。-Og下编译器倾向于把局部变量、全局变量的每次读写都落实为真实的内存 load/store,调试器才能看到值的变化。-O2下,如果编译器认为某个变量在两次访问之间没有被修改,就会把它缓存在寄存器里,不再重新从内存读取。如果你的代码里存在“中断/另一个任务修改这个变量,而当前代码用普通变量轮询等待”的写法,-O2很可能让这个轮询永远读不到新值,表现就是卡死、任务看门狗触发、系统复位。
第二层:未定义行为在 -O2 下被“放大”。C 标准对很多情况不做任何保证,比如有符号整数溢出、越界访问、使用未初始化的变量、两个不同指针类型指向同一内存。-Og下由于指令顺序和内存布局比较“本分”,这些 UB 可能只是产生一个错误数值,程序继续跑。而-O2下编译器会基于“UB 不会发生”这个假设做推导,比如它发现某段代码访问了数组第 10 个元素,但数组大小是 9,它可能直接认为这段代码不可达,进而把整个逻辑优化掉,甚至跳转到完全未知的地方。
第三层:时序和资源使用发生变化。-O2下循环跑得更快、函数调用开销变小、内联后栈帧变大或变小。这会导致:依赖空循环延时的代码实际延时严重缩短,外设读写时序不满足;某个任务原本分配 2KB 栈够用,内联后需要 2.5KB,就溢出了;或者任务执行太快,喂狗间隔不够稳定,触发任务看门狗。
这三层不是独立存在的,经常叠在一起。比如一个变量可见性问题,最终以 LoadProhibited 的 panic 形式暴露;一个栈溢出,最终表现为 IllegalInstruction 或随机地址跳转。所以排查的时候不能只看 panic 类型,要往下追真正的根因。
2. 崩溃取证三板斧:panic 日志、backtrace 解码、反汇编对比
崩都崩了,第一件事不是改代码,而是把现场的证据完整保存下来。ESP32 的 panic 日志信息量非常大,很多人只盯着最后那句“Guru Meditation Error”就开始猜,很容易走弯路。我习惯按下面三个步骤来。
2.1 先看懂 panic 日志里的崩溃类型
用idf.py monitor连上串口,崩溃时你会看到类似这样的输出:
Guru Meditation Error: Core 1 panic'ed (LoadProhibited). Exception was unhandled. Core 1 register dump: PC : 0x400d1234 PS : 0x00060e33 A0 : 0x800d1258 A1 : 0x3ffb1f20 A2 : 0x00000000 A3 : 0x3ffc1234 A4 : 0x00000001 A5 : 0x000000aa ...关键信息是括号里的单词。常见的几种:
| 崩溃类型 | 含义 | 通常对应的根因方向 |
|---|---|---|
| LoadProhibited / StoreProhibited | 非法内存地址的读写 | 空指针、野指针、数组越界、地址计算错误 |
| IllegalInstruction | CPU 执行了非法指令 | 跳转到非代码区、栈损坏、函数指针错误 |
| DivideByZero | 除数为 0 | 未初始化变量、传感器数据异常未过滤 |
| Unhandled debug exception | 调试异常未处理 | 断点/观察点配置、硬件触发问题 |
| DoubleException | 异常嵌套 | 栈严重溢出,通常是任务栈太小或中断栈溢出 |
如果日志里还看到Task: xxx,说明崩溃发生在哪个任务里,这个信息非常重要。比如Task: wifi那大概率是协议栈自己出问题;Task: app_main或你自己的任务名,就重点查自己的代码。
如果是任务看门狗触发的复位,日志会不一样:
E (30500) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time: E (30500) task_wdt: - dataCollectTask (CPU 0/1)这种情况往往不是真的“崩溃”,而是某个任务卡死导致喂狗不及时,最终系统复位。卡死位置可能和真正的问题点不在同一个地方,需要结合backtrace或者任务状态来分析。
2.2 用 addr2line 把地址翻译成源代码行号
新版 ESP-IDF 的idf.py monitor会自动解码Backtrace行。如果你用的老版本,或者日志是从其他人那里拿来的,可以用工具链里的addr2line手动解码:
xtensa-esp32-elf-addr2line -pfiaC -e build/your_project.elf 0x400d1234 0x400d1258 0x400d1abc参数解释:
-p输出更友好-f显示函数名-i展开内联函数,这个对 -O2 下特别重要-a显示地址-C去掉 C++ 名字修饰-e指定 ELF 文件
注意:用来解码的 ELF 必须和烧录进去的固件是同一次编译的产物。如果你改过代码又重新编译过,再用旧的 ELF 去解新的崩溃地址,得到的结果是完全不对的。这也是为什么我习惯每次出包都把build/*.elf和对应的sdkconfig一起归档。
解码出来后会得到类似:
0x400d1234: vTaskDelay at /home/user/proj/main/app_main.c:123 (discriminator 2)如果-i展开后出现一串内联函数,别急着只信最外层,要沿内联链往里面看,真正的逻辑可能在最里面那个函数。
2.3 -O2 下的一个取证陷阱:寄存器值不能直接当现场
这是我在 -O2 下排查时踩过最大的坑。-Og下 panic 日志里寄存器A2/A3的值基本能对应当前源码变量的值,因为编译器把变量老老实实地放在栈上和寄存器里,顺序也和源码一致。但在-O2下,寄存器可能保存的是某个临时计算结果、某个循环计数、甚至完全无关的优化残留值。
有一次我遇到一个 StoreProhibited,日志里A2是0x00000000,我以为是空指针赋值导致,结果反汇编一看,A2在那个位置装的其实是别的东西,真正的目标地址在A5寄存器里,值是0x6a6a6a6a,一看就知道是栈越界后踩到了堆区的检查字节。所以,遇到 panic 后不要急着对着寄存器值猜,先反汇编,把崩溃地址附近的几十条指令还原出来,再判断每个寄存器此刻的真实含义。
反汇编命令:
xtensa-esp32-elf-objdump -d build/your_project.elf > /tmp/disasm.txt然后搜索 panic 地址对应的函数名,或者直接在文件里搜400d1234。-O2下函数会被内联、重排,反汇编很长,但没关系,你只需要关注崩溃点前后那一段。后面第三个章节里我会展示一个具体的反汇编对比案例。
3. 一次真实案例的完整排查链路:全局标志变量“消失”了
下面用我之前排查过的一个问题,完整走一遍从现象到修复的链路。这个案例非常典型,几乎可以覆盖到大多数“切 -O2 就卡死”的场景。
3.1 现象记录:上电正常,几十秒后任务看门狗复位
固件逻辑很简单:GPIO 上接一个按钮,按下后外部中断服务函数里设置一个全局标志g_button_pressed = true,主任务里有一个while循环等待这个标志,等到了就执行一次数据上报。
Guru Meditation Error: Core 0 panic'ed (Task watchdog got triggered). Exception was unhandled.日志里明确显示dataCollectTask没有在超时时间内喂狗。也就是说,主任务卡在某个循环里出不来。我当时的第一个反应是“按钮外部中断没触发”,但加了日志以后发现中断里确实把g_button_pressed设成了 true,可主任务就是没反应。
问题代码长这样:
// main.c bool g_button_pressed = false; void IRAM_ATTR button_isr(void *arg) { g_button_pressed = true; } static void data_collect_task(void *arg) { while (1) { while (!g_button_pressed) { // 空转等待按键 } ESP_LOGI(TAG, "button pressed, start upload"); g_button_pressed = false; // ... 数据上报逻辑 } }在-Og下,这个代码完全正常。切到-O2后,就出现上面说的卡死。
3.2 反汇编定位:编译器把“等待循环”变成了“死循环”
拿到build/project.elf后,我先用addr2line解码再看data_collect_task的反汇编。-O2下这个函数的主体被优化成了类似这样的指令(用伪代码表示):
; -Og 版本,每次循环都从内存加载 g_button_pressed loop: l32i.n a8, a13, 0 ; a13 是 g_button_pressed 的地址 bnez.n a8, exit_loop ; 非零就跳出 j loop ; -O2 版本,g_button_pressed 被缓存到寄存器 l32i.n a8, a13, 0 ; 只在进入循环前读一次 loop: beqz.n a8, loop ; 如果第一次读就是 0,永远在这转圈 exit_loop:区别一目了然:-O2下编译器看到while (!g_button_pressed)的循环体是空的、没有任何函数调用、也没有对g_button_pressed的写操作,于是它认为“这个变量在循环过程中不可能变化”,把内存 load 操作提升到了循环外面,只读一次。中断里就算把内存里的g_button_pressed改成 true,循环里缓存的寄存器值也感知不到。
为什么会这样?因为 C 语言的内存模型默认是“单线程顺序执行”的,编译器不知道有一个异步的中断处理函数会修改这个变量。它不是坏了,而是严格按照标准语义做的优化。所以对于中断和主循环之间共享的变量,我们需要主动告诉编译器“这个变量可能会被外部修改”——这就是volatile的经典用途。
3.3 修复方案:加 volatile,但别用成万能药
修法很简单,把声明改成:
static volatile bool g_button_pressed = false;加volatile之后,编译器会保证每次访问都从内存实读实写,不再缓存到寄存器。这个案例里修完,-O2下就正常了。
但我要提醒你:volatile的作用仅仅是“防止编译器优化访问次数”,它不保证原子性。在 ESP32 这种双核芯片上,如果两个核上的任务同时读写同一个变量,即使加了volatile,也可能出现读到半新半旧的数据。比如一个 64 位的值,或者一个大于单指令读取宽度的结构体,写入过程可能被另一个核打断。
更正确的做法分两种情况:
- 单个变量且宽度不超过 32 位(ESP32 的 Xtensa 核心单次读写天然原子):
volatile通常够用。 - 多个变量或复杂数据:用临界区保护,或者用 GCC 提供的内建原子操作。
// 推荐写法:临界区保护 portMUX_TYPE my_mux = portMUX_INITIALIZER_UNLOCKED; portENTER_CRITICAL(&my_mux); g_shared_value = sensor_data; portEXIT_CRITICAL(&my_mux);或者简单场景用原子内建函数:
__atomic_fetch_or(&g_button_pressed, 1, __ATOMIC_SEQ_CST);总之,volatile是解决“编译器看不见外部修改”问题的最直接手段,但它解决不了并发竞争问题。带并发操作时,把它当成“最低限度方案”,别当“最终方案”。
3.4 验证:长跑测试要覆盖最坏时序
修完之后我没有马上切回-O2,而是在-O2下连续跑了 48 小时,期间还专门做了一个自动按按钮的小装置,模拟高频按键触发。同时用idf.py monitor录制了完整串口日志,跑完再确认没有任何 panic 或者任务看门狗提示,才真正算修完。
这里有个经验:如果问题只出现在某些时序下,最好人为制造最坏情况。比如按键中断这种,手动按按钮一天可能触发几百次,但自动装置能一小时触发几万次,覆盖到的路径完全不同。很多“偶发崩溃”修完后偶尔跑又复发,就是因为测试时序覆盖不够。
4. 切优化等级必查的几类代码根因
上面那个 volatile 案例是最常见的一种,但远远不是全部。如果你排除了 volatile 问题,固件在-O2下还是崩,优先检查下面四类情况。我按出现频率排序。
4.1 未定义行为和未初始化变量
这是一个大类。-O0/-Og下,未初始化的局部变量通常落在栈上的某个区域,可能恰好是 0,程序“碰巧”能跑。-O2下编译器会根据寄存器复用情况分配初始值,可能是一个巨大的随机数,也可能直接把这个变量当成常量传播进逻辑,产生完全不可预测的行为。
最典型的例子:
int idx; // 忘记初始化 idx 就直接使用 buffer[idx] = value;-Og下idx可能是 0,数组访问没越界;-O2下idx可能继承了其他代码留下的寄存器值,比如0x400F0000,于是一次简单的数组写入就变成了非法地址写入,直接 StoreProhibited。
排查这类问题最有效的办法是打开编译警告并升级为错误:
# CMakeLists.txt 里全局追加 idf_build_set_property(COMPILE_OPTIONS "-Wall -Wextra -Wshadow" APPEND) idf_build_set_property(COMPILE_OPTIONS "-Werror" APPEND)-Werror在开发阶段可能会让你抓狂——因为警告太多了,但这正是目的:把所有可疑代码都暴露出来,而不是留到切优化等级后变成崩溃。我见过很多老项目,警告几千条视而不见,切到-O2后每一条警告都变成潜在的 panic,这就是欠的债迟早要还。
另外,有符号整数溢出也是 UB 的高发区。比如:
int16_t value = 32760; value += 100; // 溢出,标准未定义-Og下可能按你预期绕回负数,-O2下编译器可能基于“不会溢出”直接优化掉后续判断,结果完全相反。排查时可以把敏感计算改成无符号类型(无符号溢出是标准定义行为),或者强制类型转换后再运算。
4.2 空循环延时和时序敏感代码
用for (i = 0; i < 100000; i++);做延时是很多 MCU 开发者的习惯。这个写法在-O0下可以工作,但-O2下编译器很可能发现循环体没有任何副作用,直接把整个循环删掉,延时变成 0。就算编译器没删除,循环速度也快了好几倍,原来 100ms 的延时可能变成 10ms,外设的读写时序直接不满足。
ESP32 上替代方案非常多,按优先级推荐:
- 精确短延时:
ets_delay_us(us),适合几十微秒以内的 IO 翻转时序。 - 任务级延时:
vTaskDelay(pdMS_TO_TICKS(ms)),适合大于一个 tick 的延时。 - 硬件定时器:需要精确且不阻塞任务时用
timer_group或esp_timer。
如果你确实需要空循环忙等,volatile一下也能防止编译器删除:
static volatile uint32_t dummy; for (uint32_t i = 0; i < 100000; i++) { dummy = i; }但这是权宜之计,真正的工程代码应该用硬件定时器或 RTOS 延时。另外,时序敏感的场景切到-O2后还要检查:SPI/I2C 的时钟极性切换间隔、传感器上电稳定时间、Flash 写操作前后的延时等待等,这些最容易在优化后悄悄失效。
4.3 任务栈大小:内联优化对栈帧的双向影响
很多人以为-O2一定会减小栈帧,因为优化后变量进寄存器了,其实不一定。函数内联会让原本分离的多个函数栈帧合并,一个大的内联函数可能比原来的累加还要大;而-Omit-frame-pointer又会省掉帧指针占用的空间。所以切到-O2后,某个任务的栈大小可能增大,也可能减小,必须实测。
ESP-IDF 提供了很好的栈检查机制。先在 menuconfig 里打开:
Component config → FreeRTOS → Tasks → Check for stack overflow选Check task stacks (via stack canary)或更严格的模式。这样任务栈溢出时会直接打印溢出任务名并复位。
更推荐的做法是主动测量栈余量。在任务里周期性调用:
UBaseType_t high_water = uxTaskGetStackHighWaterMark(NULL); ESP_LOGI(TAG, "remaining stack: %u", high_water);uxTaskGetStackHighWaterMark返回的是任务启动以来剩余栈的最小值,单位是字(4 字节)。在-Og和-O2下分别跑一遍,记录这个值,如果 -O2 下明显变小甚至有多次接近于 0,就给这个任务加大栈。有一个真实案例:一个 RTOS 任务处理 JSON 解析,-Og下峰值用了 1.6KB,任务栈给 2KB;-O2下内联把解析函数展开,峰值涨到 2.4KB,直接栈溢出,随机 panic。这种问题从 panic 日志非常难定位,因为崩溃点已经飞到了被破坏的栈上,最有效的就是拉两个优化等级的 high water mark 对比。
4.4 双核共享数据:volatile 在 ESP32 上确实不够用
前面 3.3 节提到了 volatile 的局限性,这里展开说。ESP32 是双核 Xtensa,两个核可以同时执行不同的任务。如果一块数据在两个核的任务间共享,即使是 32 位对齐的简单变量,也可能出现如下问题:
- 编译器在核 0 上把变量缓存到寄存器。
- 核 1 上的任务对这个变量做了多次写操作,但因为缓存一致性协议复杂,核 0 读到的可能是旧值。
- 产生“看似正确却偶发错误”的诡异行为。
解决双核共享问题的标准姿势是临界区或原子操作。ESP-IDF 里最常见的临界区写法:
portMUX_TYPE g_spinlock = portMUX_INITIALIZER_UNLOCKED; // 写侧 portENTER_CRITICAL(&g_spinlock); g_shared_counter++; portEXIT_CRITICAL(&g_spinlock); // 读侧 portENTER_CRITICAL(&g_spinlock); int local = g_shared_counter; portEXIT_CRITICAL(&g_spinlock);或者直接用__atomic_*:
int old_val = __atomic_add_fetch(&g_counter, 1, __ATOMIC_SEQ_CST);在双核代码里,我建议把“共享变量”这件事当成一个显式的设计决策,写代码前先问自己:这个变量会被哪个核、哪个中断访问?如果超过一条执行路径,直接上临界区或原子操作,不要指望 volatile 兜底。
5. 让固件在 -O2 下稳定落地的工程化手段
排查完根因、修复完代码之后,还要解决一个现实问题:项目必须交付,而交付物不能只在-Og下能跑,客户机器上跑的往往是-O2或-Os版本。下面是我自己项目中用的几个手段,按“临时止血”到“长效预防”排列。
5.1 用 fno 系列做编译选项二分法
如果你不确定具体是哪个优化子项导致崩溃,可以用“二分法”做实验:先加上一个关闭特定优化的参数,看是否还崩溃,逐个缩小范围。
常用的“隔离项”有:
-fno-strict-aliasing # 关闭严格别名假设 -fno-inline # 关闭所有函数内联(代价大,但能定位内联问题) -fno-tree-vectorize # 关闭自动向量化 -fno-omit-frame-pointer # 保留帧指针,增强回溯能力 -fno-schedule-insns # 关闭指令调度 -fno-tree-loop-optimize # 关闭循环优化在 ESP-IDF 中临时加全局参数,可以改 CMakeLists.txt:
idf_build_set_property(COMPILE_OPTIONS "-fno-strict-aliasing" APPEND)改完重新编译烧录,如果崩溃消失,再把参数去掉逐个验证,锁定到具体优化项后,去对应的代码位置找问题。比如确认是-fno-strict-aliasing能解决,那基本就是代码里有类型双关(type punning)或指针别名违规,需要去查相关指针操作。确认是-fno-inline能解决,就去查哪个函数内联后栈帧过大或者破坏了局部变量生命周期。
注意:-fno-inline性能代价极大,只适合排查,不可能作为交付配置。二分法找到方向后,一定要回归到代码本身修复。
5.2 只对特定文件降低优化等级:一个救命但别常用的手段
工程上还有一招“外科手术式”的处理:如果某个驱动文件确实很难修复,或者修复周期太长,可以单独给这个文件降低优化等级,其他文件保持-O2。
在组件的 CMakeLists.txt 里:
set_source_files_properties( "main/sensor_driver.c" PROPERTIES COMPILE_OPTIONS "-O0" )这样sensor_driver.c用-O0编译,其他文件仍然-O2。整体性能影响通常可以接受,这个文件本身也大概率不是性能瓶颈。
但我强烈建议:这只能是短期方案。同一份代码在不同优化等级下行为不一致,本身就说明代码里有未定义行为或竞态条件,治标不治本。我在实际项目里用过一次,后来发现真正的根因是某个未初始化的结构体字段,修完之后再把文件恢复成-O2,一切正常。所以记住:降级优化是创可贴,不是疫苗。
5.3 交付前的稳定性验证清单
经过多次踩坑,我总结了一份切换优化等级后的验证清单,每次从-Og切到-O2或-Os都照着跑一遍:
- 确认实际编译参数:用
idf.py -v build查看真实编译命令,确认 menuconfig 的配置生效,防止出现你以为切了-O2实际还在用-Og的情况。尤其在 CI 构建环境里,缓存可能导致参数不刷新。 - 检查所有编译警告:切到
-O2后,编译器可能因为更多内联和传播产生新的警告(比如maybe-uninitialized),这些警告是金矿,逐条看,别忽略。 - 开启栈溢出检查:menuconfig 里打开 task stack overflow check,并且在不同任务里打印
uxTaskGetStackHighWaterMark对比两个优化等级。 - 全功能长跑测试:至少连续运行 24 到 48 小时,覆盖所有外设通路。高频触发中断和通信,制造最坏时序。
- 保留归档产物:保存最终的
project.elf、sdkconfig、编译日期和 git commit。现场一旦崩溃,用同一份 ELF 解码才有意义。 - 回归对比:如果
-O2下修复了问题,回到-Og下再跑一遍全功能测试,确保修复没有破坏调试版的行为。
这套清单看起来繁琐,但每次都很值。我见过太多项目,开发阶段全用-Og,到了量产前切-O2,崩溃了又不敢切回来,因为性能指标不达标,最后加班熬夜在不到一周的时间里把所有隐藏问题集中暴露处理,那种压力的滋味真的不好受。
另外一个可能有点反直觉的经验:切-Os比切-O2更容易暴露问题。因为-Os在-O2基础上还多了大量为减小体积做的变换,比如更激进的内联策略(用次数少但代码大的函数反而不内联)、函数段合并、分支重排。如果你最终交付要用-Os省 Flash,建议先用-O2把功能性 bug 清完,再切-Os处理体积相关的问题,两个阶段分开做,排查成本会低很多。