技术博客

日志与断言:UE_LOG/check/ensure 的实现

第 9 / 100 篇 · 2026-07-28 · ue5 · 日志断言调试源码分析

UE 的日志和断言系统看起来是基础设施里最不起眼的部分,但它决定了你排查问题时能看到什么、Shipping 包里保留什么。UE_LOG 的编译期裁剪、check/ensure/verify 的行为差异,都是"上线前必须想清楚"的设计决策。源码在 Runtime/Core/Public/Logging/(LogMacros.h、LogVerbosity.h)和 Runtime/Core/Public/Misc/AssertionMacros.h。

UE_LOG 的三层过滤:类别、级别、编译期裁剪

UE_LOG(LogTemp, Warning, TEXT("HP: %d"), HP) 这行代码背后有三层过滤,每一层在不同阶段生效:

第一层:日志类别(Category)。DECLARE_LOG_CATEGORY_EXTERN(LogMySystem, Log, All) 声明一个类别,第一个参数是名字,第二个是默认级别,第三个是编译期最高级别。类别是全局单例的 FLogCategoryBase,引擎启动时注册到 FLogManager。运行时用控制台命令 Log LogMySystem Verbose 可以动态调级别。

第二层:运行时级别(Verbosity)。ELogVerbosity 从低到高:Verbose < VeryVerbose < Log < Display < Warning < Error < Fatal。每次 UE_LOG 先查类别当前的级别设置,低于设定级别的消息直接跳过——跳过是运行时的,参数表达式不求值。这意味着 UE_LOG(..., TEXT("%s"), *ExpensiveToString()) 在级别不够时不会调 ExpensiveToString(),日志开销可控。

第三层:编译期裁剪。DECLARE_LOG_CATEGORY_EXTERN 的第三个参数(编译期最高级别)控制宏展开时是否生成代码:UE_LOG_ACTIVE(LogMySystem, Verbose) 这个内部宏在编译期判断"该类别的编译期上限是否 >= 本次调用的级别",不满足就整个 UE_LOG 展开为空语句,编译产物里连字符串都没有。Shipping 构建默认把 Log 级别以下的全部裁掉(UE_BUILD_SHIPPING 定义时 COMPILED_IN_MINIMUM_VERBOSITY 变化),所以 Shipping 包里 Verbose 日志是零开销——不是"运行时不打",是"二进制里不存在"。

这三层对应排查问题的三种手段:运行时级别不够 → 控制台调级别;类别没注册 → 查 DEFINE_LOG_CATEGORY 是不是只在 cpp 里写了没配合 DECLARE_LOG_CATEGORY_EXTERN;Shipping 里看不到 → 查编译期上限和 bDisableLoggingInShipping。

日志的输出链路:FOutputDevice 分发

一条日志通过级别检查后,进入 FMsg::Logf(Logging/LogMacros.h 组装后调到这里),然后分发给所有注册的 FOutputDevice:

UE_LOG → FMsg::Logf → FLogManager 按类别路由
  ├── FOutputDeviceFile      → Saved/Logs/MyGame.log
  ├── FOutputDeviceConsole   → 编辑器 Output Log 面板 / 终端
  └── FOutputDeviceDebug     → IDE 的调试输出窗口

FOutputDevice 是可注册的——你可以继承它写一个发网络日志的设备,挂到 FLogManager 上就能收到所有日志。远程诊断工具(UnrealInsights 的日志通道、自定义的日志上报)都是这么做的。

日志的格式化在 FMsg::Logf 里是延迟的:格式串和参数先打包,输出设备真正要写的时候才格式化。这解释了为什么高并发场景下日志不一定是瓶颈——格式化发生在输出线程,主线程只付打包成本。但如果输出设备是同步写文件且开了 flush,I/O 还是会卡住调用线程,bFlushOutputDevice 这类配置要按场景权衡。

check/ensure/verify:三档断言的行为边界

断言宏的行为差异是面试和代码审查的高频考点,也是实际 bug 的高发区。三档的核心区别在"失败后停不停"和"Shipping 里在不在":

check(Ptr != nullptr);        // 失败 → 立即崩溃(触发 crash reporter)
ensure(Ptr != nullptr);       // 失败 → 打日志 + 触发一次 crash reporter 上报,**继续执行**
verify(Ptr != nullptr);       // Shipping 里表达式保留、检查消失
宏 Debug Development Shipping 失败后行为
check 崩溃 崩溃 整个表达式消失 立即终止
ensure 崩溃 上报后继续 消失(WITH_ENSURE 可保留) 上报后继续执行
verify 崩溃 崩溃 表达式保留,检查消失 立即终止(非 Shipping)
checkSlow 崩溃 消失 消失 仅 Debug

check 在 Shipping 里消失是行为差异最大的坑:check(Func()) 在 Shipping 里 Func() 根本不会被调用。把有副作用的表达式塞进 check 是最经典的 Shipping-only bug——开发环境一切正常,打包后功能静默失效。正确写法是 verify:表达式保留,检查在 Shipping 里消失。

ensure 是"软断言":失败时走 crash reporter 上报(带调用栈和上下文)但不终止进程,函数继续执行。它适合"这个情况不该发生但发生了也不至于崩"的场景——数据异常、边界情况、外部输入不合预期。ensure 有频率控制:ensure(每次都报)、ensureOnce(只报一次)、ensureMsgf(带格式化消息)。引擎代码里大量 ensure 的存在说明 Epic 对"线上环境不该崩但要可观测"的态度。

checkSlow/verifySlow/ensureSlow 家族只在 Debug 配置生效,用于开销大的检查(遍历校验、深度一致性检查)——Development 里都不跑。

断言的实现:从宏到 Crash Reporter

check(expr) 展开后的核心是(AssertionMacros.h,简化):

#define check(expr) \
    if (!(expr)) { \
        FDebug::CheckVerifyFailed(#expr, __FILE__, __LINE__); \
        /* 触发崩溃处理 */ \
    }

FDebug::CheckVerifyFailed 会走平台的崩溃处理链:挂起所有线程 → 生成 minidump → 调 Crash Reporter(如果配置开启)→ 终止进程。日志里看到的 Assertion failed: Ptr != nullptr [File:...] [Line: ...] 就是这里打的。

ensure 的实现走另一条路:FDebug::EnsureFailed 收集调用栈和上下文,发给 Crash Reporter 的非致命通道,然后返回让代码继续跑。ensureOnce 用一个静态 bool 保证同一个代码位置只触发一次——这个静态变量是线程不安全的,多线程同时触发可能报多次,但行为上仍是"最多几次",不是"精确一次"。

实战:Shipping 包前的断言审计

打包 Shipping 前值得过一遍的检查清单:

  1. grep 所有 check( 看参数有没有副作用:check(Initialize())、check(Queue.Dequeue(Item)) 这种,换成 verify 或把副作用提到 check 外面。
  2. 确认 ensure 的处理路径:ensure 失败后代码继续跑,后面的逻辑必须能容忍"ensure 的条件不满足"的状态。常见反模式:ensure(Ptr) 之后直接用 Ptr->,ensure 失败时 Ptr 是空,后面接着崩——ensure 失去了意义。
  3. 日志级别的 Shipping 裁剪:确认关键的错误日志是 Warning/Error 级别,Log 级别以下在 Shipping 里会被编译期裁掉,排查线上问题时别指望它们。
  4. bDisableLoggingInShipping:BaseEngine.ini 里这个开关默认 true,意味着 Shipping 连 Error 级别的文件日志都不写。需要线上日志的项目要自己打开并评估 I/O 开销。

UE_LOG 三层过滤与三档断言的 Shipping 行为 UE_LOG 的三层过滤(编译期 → 运行时 → 输出) 1. 编译期裁剪:UE_LOG_ACTIVE 2. 运行时级别:ELogVerbosity 3. FOutputDevice 分发 级别不够 → 参数不求值;编译期裁掉 → 二进制里无此日志;输出设备:文件 / 控制台 / IDE 调试窗口 三档断言的配置矩阵 check:Debug/Dev 崩溃;Shipping 整个表达式消失 → check(Func()) 里 Func 不被调用 verify:Debug/Dev 同 check;Shipping 保留表达式、去掉检查 → 有副作用的表达式用它 ensure:失败后上报 crash reporter 但继续执行 → ensure 后的代码必须容忍失败状态 checkSlow / verifySlow / ensureSlow:仅 Debug 配置生效,用于高开销检查 经典坑:check(有副作用的表达式) 在 Shipping 静默失效 —— 排查线上问题先查这个

结论

  • 日志的开销控制靠三层过滤:编译期裁剪决定"Shipping 里有没有",运行时级别决定"打不打",输出设备决定"写到哪"。排查日志问题先确定卡在哪一层。
  • check 里绝不放有副作用的表达式,有副作用的用 verify;ensure 后面的代码必须能容忍失败状态,否则 ensure 只是个延迟的 check。
  • Shipping 前做一次断言审计:grep 所有 check( 的副作用、确认 ensure 后的容错路径、打开 bDisableLoggingInShipping 的例外清单。
  • 日志分类按子系统建(LogMyGameCombat、LogMyGameUI),别全堆 LogTemp——运行时按类别调级别是线上排查的第一手段,全堆 Temp 等于放弃了这个能力。

基础设施到这里走完一半。最后两篇看运行时的动态面:模块加载(FModuleManager)和插件系统怎么把 DLL 挂进引擎。