PPSSPP 解析 CodeWarrior C++ 符号:PSP 二进制的 Metrowerks 命名规则与 demangle 实现
2026/9/14 16:40:59 网站建设 项目流程

PPSSPP 解析 CodeWarrior C++ 符号:PSP 二进制的 Metrowerks 命名规则与 demangle 实现

【免费下载链接】ppssppA PSP emulator for Android, Windows, Mac, Linux and iOS, written in C++. Want to contribute? Join us on Discord at https://discord.gg/5NJB6dD or just send pull requests / issues.项目地址: https://gitcode.com/GitHub_Trending/pp/ppsspp

PSP 平台上有一部分商业游戏并非用 PSP SDK 自带的 GCC 编译,而是用 Metrowerks CodeWarrior 构建,因此它们的 C++ 符号不是以_Z开头的 Itanium 风格命名,通用的 demangler 对它们完全无效。本文基于 PPSSPP 仓库中的 CodeWarriorMangling.md 完整梳理这套从 AT&T cfront 演化而来的符号编码规则——包括符号总体结构、__分隔符歧义的处理、模板参数编码、类型码表、编译器生成的@符号族以及隐藏的析构函数参数——并结合 Common/Data/Text/Demangle.cpp 的源码实现与 unittest/TestDemangle.cpp 的测试用例,说明 PPSSPP 是如何把这些符号还原成可读名字、从而使调试器的符号表(symbol map)对这些游戏变得可用的。

一、为什么 PSP 二进制里存在 CodeWarrior 符号

docs/CodeWarriorMangling.md 开篇指出:一些 PSP 游戏用 Metrowerks CodeWarrior 而非 SDK 的 GCC 构建,其符号不是 Itanium 风格的_Z命名,_Z-based 的 demangler 对它们无能为力。PPSSPP 在 Common/Data/Text/Demangle.cpp(DemangleCodeWarrior)中实现了这套规则的反向解析,这正是那些游戏的符号表能被读懂的原因。

这套方案的谱系是 AT&T cfront mangling,基础部分与 Macintosh C++ ABI 文档一致,但实际 PSP 二进制在多处偏离了那份文档——原文档明确强调"只照着 Macintosh ABI 文档走会得出错误答案",因此该文描述的是PSP 二进制里真实存在的东西

规则的考证方法

文档同时交代了这些结论的来源:研究基于两个带完整符号表的 PSP 可执行文件——一个小型 C++ 测试程序,一个约 10,000 个符号的大型应用——对其中每个符号做 demangle,并把结果与反汇编、以及每个数据符号实际指向的字节逐一核对。语料中未出现的构造在下文以(inferred)标注:它们来自 cfront 方案、已实现、被认为是正确的,但没有任何实证证明。

二、符号的总体结构与__分隔符

语法骨架

一个 mangled 符号的形如:

<basename> __ [<class>] [<cv>] F <parameters> [_ <return type>]

其中 class、cv 限定符、F及其后的所有内容都是可选的。文档给出的递进示例:

MangledDemangled
run_tests__Fvrun_tests()
getDistance__6KzUtilFP7st_unitP7st_unitKzUtil::getDistance(st_unit *, st_unit *)
what__Q23std9exceptionCFvstd::exception::what() const
count__Q23foo3barfoo::bar::count

最后一条没有F,因此它根本不是函数——而是一个静态数据成员,没有参数列表可打印。既没有 class 限定也没有F的名字则不是 mangled 符号。

分隔符的真正歧义

__分隔符在语法层面是真正有歧义的,文档列出了三类干扰:

  • 基名自身可以以下划线开头(__SetupFrameInfo__FP12ThrowContext...);
  • 基名内部可以含有自己的____TableUnit__SetValue<i,1>__5shTbbF...);
  • 同一二进制里的普通 C 代码充满了I3dCacheManager__GarbageCollect这样完全没有 mangle的名字。

语法规则本身无法消解这个歧义,PPSSPP 的做法是:尝试符号中每一个__,保留第一个右侧能完整解析的分切;只有当所有分切都失败时,才接受一个"参数没解出来"的分切——而且仅当存在 class 限定时才接受,否则任何 C 语言中带双下划线的名字都会被误判成一个垃圾函数签名。

这一策略在源码 Common/Data/Text/Demangle.cpp 的DemangleCodeWarrior中可见:它从跳过前导下划线后的位置开始扫描每一处__,用for (int pass = 0; pass < 2; pass++)执行两轮——第一轮strict要求完整解码,第二轮宽松允许参数未解码。宽松轮的"必须有 class 限定"约束在 TryCodeWarriorSplit 中落实:

// A plain C name with a "__" in it ("I3dClut__FlushCache") can look like an unqualified // function whose parameters happen not to decode, so in the lenient pass - where the // parameters are allowed not to decode - insist on a class qualifier as evidence that // this really is a mangled name. if (!strict && parser.qualifier().empty()) return false;

测试用例中专门覆盖了这条边界:unittest/TestDemangle.cpp 把Foo__Bara__b__cI3dClut__FlushCache列为必须拒绝的输入——后者正是"尾部恰好以F加合法类型码开头、只靠缺少 class 限定才能与真 mangled 名字区分开"的刁钻例子。

三、名字:长度前缀标识符与限定名

一个标识符写作"十进制长度 + 字符":6KzUtil表示 6 字符的KzUtil

限定名写作Q<count>后跟该数量的标识符:Q23std9exceptionstd::exception(2 个组件:3std9exception)。count 是单个数字;10 个及以上组件写成Q_<count>_(inferred)

模板参数:字面写在名字内部

模板参数以字面<...>形式写在长度前缀的名字内部——长度覆盖整个内容,包括尖括号(Macintosh ABI 文档描述的是__PT前缀,PSP 二进制并不使用它):

39CList<Q38hlScreen5Brwsr13CContentsUnit> shList::CList<hlScreen::Brwsr::CContentsUnit>

这里39CList<Q38hlScreen5Brwsr13CContentsUnit>的长度。每个参数要么是一个 mangled 类型,要么是非类型参数的普通整数,以逗号分隔:

MangledDemangled
15CSimpleChar<24>CSimpleChar<24>
61ForwardIterator<16TABLE_VAR_MEMBER,21TABLE_VAR_SECT_HEADER,v>ForwardIterator<TABLE_VAR_MEMBER, TABLE_VAR_SECT_HEADER, void>
47CList<Q26ssTool29tag_<Q26shFont12SysCmdString>>CList<ssTool::tag_<shFont::SysCmdString>>

它们可以嵌套,所以解析器必须匹配尖括号而不能扫描下一个>,且只在顶层逗号处切分参数。源码中对应的 ParseIdentifier 先按长度截取整个名字,再检测find('<')back() == '>'来判断内部是否含有模板参数;ParseTemplateArgs 则用depth计数器跟踪尖括号深度,只在depth == 0的逗号处分片,且任一参数解析失败时回退到原文("半解码的参数列表比 mangled 原文更糟")。

函数模板的编码方式不同:参数写在基名里,并且——与一般函数不同——在尾部_之后编码返回类型

sort<Pf>__3stdFPfPf_v void std::sort<float *>(float *, float *)

CodeWarriorName 在拼出基名后检测name.find('<'),把<...>内容交给ParseTemplateArgs重新解码;F<params>_<ret>中的_后类型在 ParseAfterSeparator 解析:

if (Peek() == '_') { // Templates encode their return type, same as in the Itanium mangling. pos_++; out->returnType = ParseType(); ... }

特殊基名

Base name含义
__ct构造函数;名字取自所属 class
__dt析构函数
__op<type>转换运算符,例如__opCioperator const int()
__<code>重载运算符,见下

运算符码是 cfront 那一套:nwdlnwadla对应new/deleteplmimldvmd对应算术运算,aplamiamuadvamdaadaoraeralsars对应复合赋值,eqneltgtlege对应比较,aaoont对应逻辑,adorercolsrs对应按位,asppmmclvcrfrmcm对应=++--()[]->->*,

例如__as__9ANIMEDataFRC9ANIMEDataANIMEData::operator=(const ANIMEData &)

实现上这张表就是 Demangle.cpp 中的cwOperators[]数组,按整名匹配("三字母的码不需要排在前面")。注意其中__op的"目标类型"就写在名字本体里——CodeWarriorName 对__op前缀截取第 4 位之后的字符,用一个独立的CodeWarriorParser把它当作完整类型解码,得到如operator Type这样的结果。测试用例 TestDemangle.cpp 验证了__op4Type__3FooCFvFoo::operator Type() const

四、类型系统:cfront 类型码

类型码从左到右读取,每一个都修饰其后的内容。

CodeType备注
vvoid
bbool
cchar
sshort
iint
llong
xlong long
ffloat
ddouble
rlong double(inferred)
wwchar_t(inferred)—— Metrowerks 对 cfront 码集的扩充
e...变参,必须位于最后
P指向……的指针
R对……的引用
Cconst
Vvolatile
Uunsigned
Ssigned(inferred)
A<n>_长度为n的数组(inferred)
F<params>_<ret>函数
<len><name>
Q<n>...限定类

PCcconst char *CPcchar * const。指向函数或指向数组的指针需要包裹声明符而不是简单追加*PFi_iint (*)(int)PPFv_vvoid (**)()

两个形式可以回引同一函数中更早的参数(inferred)

Form含义
T<index>与第<index>个参数(1 起)同类型
N<count><index>追加<count>个参数,每个都与第<index>个参数同类型

所以foo__FPCcUiN21foo(const char *, unsigned int, const char *, const char *)

ParseParams 正是按这个语义实现的:已解码的每个参数被压入params_向量,N<count><index>分支从params_[index-1]复制count份;T<index>则在 ParseDecl 中作为独立类型码处理。宽松模式下,遇到无法解码的参数类型会置unknown并补一个...而非丢弃整个名字——对应测试weird__3FooFZZZFoo::weird(...)(TestDemangle.cpp)。

函数类型F<params>_<ret>的参数循环在遇到_处结束(无参数用单个v表示,循环内if (type == "void" && parts.empty() ...)直接 break),_之后才是返回类型;CWDecl结构(pre/post 两段字符串)就是为了让指针能"包进"已经打开的括号里:

// A function type: parameters, then "_" and the return type. The parens around the // declarator are left open for a P or R to put its sigil in - see above.

函数上的 cv 限定符

CV位于 class 与F之间,修饰的是成员函数本身而非某个参数:InitRun__Q28shCamera11TStillParamVFvshCamera::TStillParam::InitRun() volatile。解析入口 ParseAfterSeparator 在读取完 qualifier 后、判定F之前,用一个while (Peek() == 'C' || Peek() == 'V')循环把它们全部收集进out->qualifiers

五、编译器生成的符号族

CodeWarrior 会为一些本身没有 C++ 名字的东西发出一族符号。它们包裹一个 mangled 名字而不是本身构成一个,且全部不在 ABI 文档中描述:

Form含义示例
__vt__<class>Vtable__vt__Q23std9exception
__RTTI__<class>Typeinfo 记录__RTTI__Q23std9exception
__sinit_<file>某翻译单元的静态初始化器__sinit_hl_app.cpp
__sterm_<file>静态析构函数
@<n>@<symbol>this 调整 thunk,偏移n字节@12@__dt__3SonFv
@STRING@<symbol>[@<n>]该函数内使用的一个字符串字面量@STRING@Get_BGM__Q25hlBHC4SBhcFi@0
@LOCAL@<symbol>@<var>[@<n>]函数局部静态变量@LOCAL@sort<Pf>__3stdFPfPf_v@shuffle@0
@GUARD@<var>$<n>其"已构造"标志@GUARD@app$16079
@<n>匿名字符串常量@10046

@STRING@@LOCAL@尾部的@<n>判别符,只在同一函数内存在多个此类符号时才出现——因此解析器必须把它当作可选部分,且不能把@LOCAL@变量名前面的@误认为判别符。

@<n>@thunk 确实是 thunk:文档举的例子是 8 字节代码,执行addiu a0, a0, -12后跳转,即为次级基类调整this@STRING@符号指向普通的字符串数据——通常是断言展开后的__FILE__

这一整族由 DemangleCodeWarriorSpecial 处理,入口判断放在 DemangleCodeWarrior 最前面:名字以@_开头时先试特殊形式,失败才走常规的双 pass 分隔扫描。内部用splitIndexlambda 剥掉尾部的@<digits>判别符再递归解出内层符号。注意纯匿名@<n>(如@10046)没有内层符号可解,测试用例 notMangled 明确将其列为"无可 demangle"的输入。

测试覆盖了全部族类(TestDemangle.cpp):

{ "@12@__dt__3SonFv", "non-virtual thunk (12) to Son::~Son()" }, { "@STRING@Get_BGM__Q25hlBHC4SBhcFi@0", "string literal 0 in hlBHC::SBhc::Get_BGM(int)" }, { "@LOCAL@sort<Pf>__3stdFPfPf_v@shuffle@0", "void std::sort<float *>(float *, float *)::shuffle" }, { "@GUARD@app$16079", "guard variable for app$16079" },

六、其他装饰

文档还指出两种出现在普通名字内部的装饰,都不需要解码但值得识别:

  • <name>$<digits><file>—— 具有内部链接的类或变量,带上声明位置标签:ClutScreen$11229hl_cplayer_effect_renderer_cpp。因为标签会在类型出现的每个位置重复,所以会产生非常长的符号;
  • <len>@unnamed@<file>@—— 用作文件作用域静态(即匿名命名空间)的限定符。ARWMENU_MODEL_NAME__34@unnamed@hl_editor_arrow_menu_cpp@就是其中一员的数据符号。

七、隐藏的析构函数参数

mangling 描述的是源码级签名,不是生成出来的签名。__dt__3SonFvdemangle 成Son::~Son(),对一个 demangler 而言这就是正确答案——但该函数实际发射的代码带第二个参数,任何把 demangled 名字转成函数签名的工具(反编译器、调试器的参数视图)都会因此出错。

CodeWarrior 每个类只发射一个析构函数(而非 Itanium ABI 的两三个克隆体),用标志位区分各种情形。在 PSP 上,该标志是a1寄存器里的一个short,且析构函数v0中返回this。真实签名是:

Son *__dt__3SonFv(Son *this, short flag);

文档给出了从一个真实析构函数反编译出的函数体:

Son *__dt__3SonFv(Son *this, short flag) { if (this) { // 析构函数对 null 安全 ... restore vtable pointers, destroy members ... __dt__3DadFv(this + 12, 0); // 基类子对象传 0 __dt__3MomFv(this, 0); if (flag > 0) // "blez" - 只有正数标志才释放内存 __dl__FPv(this); // operator delete } return this; }

在一次零售二进制的所有调用点统计,实际传入的标志值为:

Flag含义出现频率
-1销毁但不释放完整对象——栈对象、成员、显式~T()调用大多数调用
1销毁并释放——delete的编译结果少数
0销毁基类子对象少数

0-1调用方一侧的区分:文档考察过的析构函数只测试标志的符号,所以两者都只是"不释放内存",但编译器销毁基类子对象时仍然专门传0、其余场合传-1。阅读调用点时应把0理解为"这次调用把我作为某个类的基类来销毁",即使被调用方无法分辨。

这些二进制里的构造函数只接受this(并且同样返回它)。值得注意:vtable 槽位持有的是同一个函数、连同其标志位——这正是 CodeWarrior 不需要独立的 deleting/non-deleting 析构函数克隆体的原因:虚delete就是让 1 穿过 vtable 传进去。

八、解析中的坑(Hazards)

文档最后列出三条必须在实现中处理的危险:

  1. 被截断的名字不可恢复。一些符号表限制名字长度(127 字符是常见上限),模板名很容易超过。一旦名字被切断,长度前缀与剩余内容不再匹配,截断点之后的一切都不可信——宁可拒绝该符号,也不要打印一个看起来合理的猜测。测试用例 notMangled 就放入了一个被 127 字符上限截断的__distance<Q23std164__wrap_iterator<...>长符号,要求 demangler 原样退回。
  2. 并非所有含__的都是 mangled。同一二进制中的 C 代码把__当普通词分隔符用。"接受部分解析前必须有 class 限定"这条规则,就是防止I3dClut__FlushCache变成I3dClut(long, ...)的关键。
  3. e是真实参数,不是解析失败。Printf__Q26shFont4FontFiiPCce以变参结尾;(int, int, const char *, ...)才是正确答案,而不是部分解析。对应实现见 ParseDecl 的case 'e'

九、在 PPSSPP 中的落地位置

从源码结构看,整套 demangle 逻辑收敛于一个统一入口。Common/Data/Text/Demangle.h 声明了三个平级的 demangler 与一个便捷包装:

// Convenience wrapper: tries all three manglings and returns the demangled name, or a // copy of the input if it isn't something we can demangle. std::string DemangleSymbolName(std::string_view name);

DemangleSymbolName 依次尝试 Itanium(_Z前缀)、CodeWarrior、SN Systems(docs/SNSystemsMangling.md 描述的另一套更紧凑的方案),全部失败则原样返回输入——调用方因此永远拿得到"要么正确、要么原样"的结果,绝不会有半解析的残骸。CodeWarrior 与 SN Systems 两个解析器输出的是结构化的 DemangledSymbol(name/parameters/returnType/qualifiers/isFunction 五字段),测试中专门断言各字段能拼回与ToString()一致的结果。

真正的使用点在 ELF 加载路径:Core/ELF/ElfReader.cpp 在读到STT_FUNC符号时执行

case STT_FUNC: // C++ homebrew is otherwise a wall of _ZN... - see DemangleSymbolName. g_symbolMap->AddFunction(DemangleSymbolName(name).c_str(),value,size);

伴生 ELF(companion ELF,放在游戏目录旁用于符号/行号恢复的单独 ELF)路径同样在 LoadSymbolsFromCompanion 中对每个函数符号先DemangleSymbolName再写入g_symbolMap,且这些"人类写出的名字"会覆盖分析器生成的z_un_<地址>占位符。这就是"符号表对这些游戏可读"的具体机制:无论是 SDK GCC 的_ZN...、CodeWarrior 的 cfront 命名,还是 SN Systems 的__0...命名,都会在同一条路径上被还原为带限定名和参数列表的可读函数名,供调试器的符号表与反汇编视图使用。

十、小结

  • 符号形如<basename>__[<class>][<cv>]F<parameters>[_<return type>],除基名外全部可选;无F即静态数据成员。
  • __分隔有歧义:PPSSPP 逐一尝试每个__,严格解析优先,宽松解析仅在存在 class 限定时才接受。
  • 名字是"十进制长度 + 字符";Q<n>串接限定组件;模板参数字面写在名字内部的<...>中,长度前缀覆盖尖括号。
  • 函数模板把参数写进基名,并在尾部_后编码返回类型。
  • 类型码从左到右逐个修饰后续内容;指针/引用须包裹声明符(PFi_iint (*)(int));T<n>/N<count><index>回引既有参数。
  • __vt__/__RTTI__/__sinit_/@<n>@/@STRING@/@LOCAL@/@GUARD@构成编译器生成符号族,包裹而非构成 mangled 名。
  • 析构函数的真实签名是T *__dt__<class>Fv(T *this, short flag),标志-1/1/0区分"不释放"/"释放"/"基类子对象",vtable 中即持此函数。
  • 三条解析红线:截断名直接拒绝、无 class 限定不接受部分解析、e是合法变参参数。

对要在 PPSSPP 调试器中阅读 CodeWarrior 编译游戏的开发者,理解这套规则意味着能预测每个符号会被还原成什么、识别哪些符号是 thunk/守卫变量/字符串而非常规函数,以及在符号表名字被 127 字符截断时为什么 demangler 会选择放弃而不是猜测。规则本身的完整描述见 docs/CodeWarriorMangling.md,实现见 Common/Data/Text/Demangle.cpp,行为边界由 unittest/TestDemangle.cpp 的codeWarriorCasesnotMangled两组用例共同锁定。

【免费下载链接】ppssppA PSP emulator for Android, Windows, Mac, Linux and iOS, written in C++. Want to contribute? Join us on Discord at https://discord.gg/5NJB6dD or just send pull requests / issues.项目地址: https://gitcode.com/GitHub_Trending/pp/ppsspp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询