
做UE5 C的这阵子我被问得最多的问题之一就是“为什么我UE_LOG里写%s后面直接放一个FString会报错”这个问题牵出来的其实是UE5里一整套字符体系的底层逻辑——TCHAR这个宏、FString这个字符串类、还有那个被很多人当成“取内容运算符”的星号。今天这篇就来把这层窗户纸捅破。这篇内容适合刚接触UE5 C的初学者也适合那些已经写了几个月UEC、但一遇到“字符串打印”还是只能复制粘贴、报错就懵的人。看完你会明白三件事TCHAR到底是什么、为什么FString传进打印函数必须加*、以及字符数组在FString内部是怎么组织的。搞懂这几个点以后再看UE的源码很多字符串相关的代码都不会再觉得神秘。1. 先搞清楚UE5里的字符到底长什么样1.1 为什么C的char在UE5里不够用很多从纯C转过来的人一开始都会有一个疑问我明明会printf会std::string为什么到了UE5里全变了先说char的问题。C标准里的char类型标准只规定它“至少能容纳一个字节”具体有没有符号、几个字节都交给编译器决定。在大多数平台上char就是8位能存ASCII但到了中文、日文、emoji这类字符面前就不够用了。你当然可以用多个char拼一个UTF-8字符串但C标准库对“宽字符”这一块处理得很别扭wchar_t在不同平台上的长度都不一样Windows上是16位Linux上却是32位。跨平台代码稍微一长符号、编码、字节序全是坑。UE5要面对的是Windows、主机、移动端等多个平台它必须给上层提供一个“不管在哪个平台都长一样的字符类型”。这个统一类型不能直接用char也不能直接用wchar_t于是就有了TCHAR。你可以把TCHAR理解成UE官方对“我应该用哪种字符”这个问题给出的标准答案你在写UE逻辑时只要用到“单个字符”就应该用TCHAR只要遇到“字符串”就该考虑FString或者FName。1.2 TCHAR的秘密一个宏的自我修养很多教程一上来就说“TCHAR是字符类型”这个说法不够准确。TCHAR本质上是一个宏不是一个新的C类型。它根据不同的编译平台被定义成不同的真实类型。在Windows平台以及在编辑器运行的大多数环境下TCHAR会被定义成wchar_t也就是16位宽字符用于承载UTF-16编码。在Linux、Mac这类平台UE5较新的版本里TCHAR则会被定义成char走UTF-8编码。这意味着你在Windows上看到的“一个TCHAR能装一个汉字”到了Linux上可能就变成“一个汉字要3个TCHAR”。除了TCHAR本身还有一个配套的宏你几乎天天见就是TEXT()。这个宏的作用是把一个普通字符串字面量转换成TCHAR字符串。比如TEXT(Hello)在Windows下它就是宽字符字符串LHello在其他平台下它就是一个普通的窄字符串。很多人刚接触UE的时候写过这样的代码UE_LOG(LogTemp, Warning, Hello)结果编译报错原因就是漏了TEXT宏。因为UE_LOG的格式化字符串要求是TCHAR字符串而你直接给了一个const char*类型对不上。我第一次接触TCHAR的时候也觉得很绕后来我自己给了一个类比TCHAR就像插座转换头你家的电器插头可能各式各样但转换头插上去之后你家里电器需要的都是一样的电压。TCHAR就是UE给你准备好的“统一电压”TEXT宏则是把不同形式的“插头”转成能插进这个统一接口的“转接头”。你不需要关心平台底层细节只要统一用TCHAR和TEXT代码就能在UE支持的平台上正常编译运行。1.3 TChar和FChar两个容易看混的结构体TCHAR是宏而TChar是另一个东西——它是一个模板结构体专门存放各种字符工具函数。这两个名字只差一个字母我第一次翻源码的时候也被骗过。TChar 这个模板实例在UE里有一个别名叫FChar。它提供的功能大多是静态函数比如FChar::ToUpper(TEXT(a))会返回大写字符FChar::ToLower、FChar::IsDigit、FChar::IsAlpha这类判断函数也是它家的。老实说日常写业务逻辑的时候直接调用FChar的地方并不多但在解析字符串、写词法分析器、或者做字符级校验的时候这东西就很好使。有一个点容易引起误解FChar看起来像是一个“字符变量类型”其实它是一个“字符工具类”。你并不能声明一个FChar变量来存字符它是用来调用静态方法的。就比如FChar::ToUpper接受一个TCHAR返回一个TCHAR。真正用来声明变量的类型还是TCHAR本身。我建议初学者把这几个名字的关系记成一句话TCHAR是宏代表“具体字符类型”TChar是模板提供“字符处理功能”FChar是TChar针对TCHAR的特化别名。下次看源码看到FChar你就知道它是在做字符判断或转换而不是定义字符数据。2. 输出打印的几种姿势以及为什么必须用 *FString2.1 从UE_LOG开始第一个能跑的打印UE5 C里最常用的输出方式就是UE_LOG宏它的基础格式长这样UE_LOG(LogTemp, Warning, TEXT(这是一个整数%d), 42); UE_LOG(LogTemp, Error, TEXT(这是一个字符串%s), *FString(TEXT(Hello)));第一行打印整数第二行打印字符串。这里有几个固定写法你最好背下来LogTemp是日志分类你可以理解成给日志打的一个标签方便过滤器筛数据。Warning是日志级别除了Warning还有Log、Error、Fatal等。Error会把日志标红找问题的时候比Warning醒目。TEXT()包裹格式化字符串这一点前面说过是TCHAR字符串的标志。UE_LOG本质上是一个带格式化的日志输出宏它的格式化参数和C语言printf家族非常像。你会在源码里看到很多类似FString::Printf的东西实际上UE_LOG内部也是走的一套格式化逻辑。所以只要学会了UE_LOG后面看Printf也基本无障碍。2.2 %s和*运算符格式化参数的潜规则很多初学者在这里第一次翻车我明明写的是UE_LOG(LogTemp, Warning, TEXT(%s), FString(TEXT(Hello)))为什么编译不过原因很简单UE_LOG的%s要求你提供一个TCHAR*类型也就是TCHAR数组的首地址而不是一个FString对象。FString是什么它是一个类一个封装了字符数组和一堆操作方法的对象。类对象不能直接被当成TCHAR塞给变参函数编译器不知道该如何把一个FString对象转成指针。这时候你就需要FString的运算符了。FString MyStr TEXT(Hello); UE_LOG(LogTemp, Warning, TEXT(%s), *MyStr);MyStr取到的是FString内部字符数组的首地址类型是const TCHAR*正好满足%s的要求。你只要记住一条经验法则在UE_LOG和Printf这类格式化函数里凡是遇到%s后面的FString变量一律手动加。这个*运算符和“取内容”的意思有点像但又不一样。它更像是在说“把我这个字符串对象里实际存储的字符缓冲区地址交出去。”我后面会专门拆开讲它的内部实现这里你先在脑子里留一个印象*FString不是解引用而是拿到内部字符缓冲区的开头。2.3 除了UE_LOG还有哪些输出方式实际调试的时候UE_LOG不是唯一的输出手段我来梳理几个常见的FString::Printf先构造一个格式化的FString再根据需要输出或者拼接。比如FString Result FString::Printf(TEXT(玩家名字%s), *PlayerName);FMessageDialog::Debugf弹出一个调试对话框适合断点不好定位、或者你需要立马看到值的时候。用法和Printf很像FMessageDialog::Debugf(TEXT(当前值%d), Value);AddOnScreenDebugMessage在游戏画面上直接显示调试信息适合调试运行时UI、角色位置这类问题。需要写GEngine-AddOnScreenDebugMessage(-1, 5.f, FColor::Red, FString::Printf(...))这类调用在游戏运行窗口里能直接看到不需要切日志。我自己的习惯是逻辑崩溃类问题用UE_LOG数值实时变化用AddOnScreenDebugMessage需要弹窗确认的情况才用Debugf。日志输出多了以后还可以靠LogTemp/LogMyGame这类Tag过滤不然满屏都是信息反而找不到关键行。另外补充一个格式化占位符速查表写代码的时候忘了可以直接回来翻类型占位符备注int32%d整数float%f浮点数默认6位小数TCHAR字符串%s必须传TCHAR*FString要加*单个TCHAR%c传TCHAR变量bool%s或%d习惯上转成字符串指针%p打地址用3. 拆开FString的*运算符字符数组的真相3.1 *运算符返回的到底是什么说了半天*不看一眼底层实现总觉得没讲透。FString内部核心数据其实是一个TArrayTCHAR也就是TCHAR的动态数组。你可以理解成FString底层有一段连续内存每个元素都是一个TCHAR这段内存就是真正的“字符数组”。当你写下*FString时FString的运算符重载会返回这个内部数组的首元素地址类型是const TCHAR*。伪代码大概是这样的const TCHAR* FString::operator*() const { return Data.GetData(); // 返回内部TCHAR数组的首地址 }这里的GetData就是TArray获取底层连续缓冲区首地址的方法。所以你用*MyString的时候实际拿到的不是数据副本而是内部缓冲区的一个指针。这也是为什么%s能直接使用它格式化函数拿到这个指针后会沿着这个地址往后逐个读TCHAR字符一直读到\0结束符为止。这里有个细节值得留意因为返回的是const TCHAR*所以通过运算符你只能“读”字符不能“写”字符。想修改某个位置的字符应该用FString提供的下标运算符或者相关API而不是直接拿的结果去改内存。这是UE刻意设的保护防止外部代码破坏FString内部对字符数组的管理。3.2 从TCHAR*读数据能读不能乱改既然拿到了const TCHAR*那遍历字符串就非常直观了。我经常在调试时写这种代码来快速确认字符串内容FString MyString TEXT(UE5); const TCHAR* CharPtr *MyString; while (*CharPtr ! 0) { // 这里 *CharPtr 就是当前字符 UE_LOG(LogTemp, Warning, TEXT(当前字符代码%d), (int32)*CharPtr); CharPtr; }这段代码的意思是从首地址开始一个一个读TCHAR读到0就停。因为FString内部一定是以\0结尾的所以这个循环是安全的。不过要提醒你这个方法适合读不适合改。有人会试着写const_castTCHAR*(*MyString)然后修改字符内容这种做法非常危险。FString内部的字符数组不像C风格裸数组那样只管一段内存它还承担了容量、引用计数历史版本有引用机制等管理责任。外部绕过API直接改轻则数据错乱重则崩溃。如果你真的想修改某个字符正确姿势是用下标运算符或者Mid/Replace这类函数FString MyString TEXT(Hello); MyString[1] TEXT(a); // 把字符e改成a3.3 字符数组与性能一次拷贝都不要多理解了*运算符返回的是内部字符数组首地址之后很多关于“性能”的疑惑也会迎刃而解。为什么大家在UE里写字符串传参时特别强调引用传递因为FString底层是动态数组如果按值传递会涉及整个字符数组的拷贝字符串越长开销越大。我在自己的项目里测过几万字符的大字符串如果在热更新循环里反复按值传递性能损耗肉眼可见。所以写函数的时候能传const FString就传引用能传FStringView就用视图尽量不要把FString整个拷来拷去。*运算符本身是O(1)的它只是拿一下内部数组地址不涉及任何拷贝。这也是为什么在UE_LOG这种格式化函数里传*MyString很划算格式化过程中只传一个指针而不是复制整个字符串。在需要频繁打日志的循环里这个习惯能帮你省不少开销。3.4 从数组的角度理解FString的字符操作如果你把FString当成“包装过的字符数组”很多API的理解会变得很轻松。比如下标访问MyString[3]返回的是第4个TCHAR的引用。比如范围for循环FString MyString TEXT(Hello); for (TCHAR Char : MyString) { UE_LOG(LogTemp, Warning, TEXT(字符%c), Char); }这个写法背后其实就是遍历那个TCHAR数组。我之前写过一个统计字符串里某个字符出现次数的工具函数用这个思路几行就能写完int32 CountChar(const FString Text, TCHAR TargetChar) { int32 Count 0; for (TCHAR Char : Text) { if (Char TargetChar) { Count; } } return Count; }理解了字符数组底层你再看FString的Find、Mid、Left、Right这些方法脑子里其实都是“在数组上做区间操作”。比如Mid(Start, Len)就是截取从Start开始长度为Len的一段连续TCHAR。这样理解起来比死记API名字要牢靠得多。4. 编码与乱码实战中最容易翻车的环节4.1 UE5里的文本编码到底怎么回事前面说到Windows下TCHAR是16位wchar_t承载UTF-16非Windows平台TCHAR是8位char承载UTF-8。这会产生一个很多人没意识到的差异同一个中文字符在Windows和Linux下在FString里占用的TCHAR数量不一样。在Windows下一个常见汉字通常处于Unicode的BMP基本多语言平面用一个UTF-16码元就能表示所以一个汉字就是一个TCHAR。但遇到一些生僻字或者emoji一个字符可能需要两个码元也就是在FString里占两个TCHAR。在Linux下TCHAR是8位UTF-8编码下一个汉字占3个字节也就是3个TCHAR。这就导致一个现象同一段中文在Windows上取字符串长度得到结果比Linux上短很多。如果你的代码对字符串长度有强依赖跨平台时很可能踩坑。我遇到过最典型的问题是从存档文件读文本在Windows上测试一切正常部署到Linux服务器上后解析存档的字符串索引全部错位。排查到后来才发现问题不是解析逻辑而是字符在不同平台下占据的TCHAR数量根本不一样。4.2 从外部拿进来的字符串为什么乱码UE5里的FString默认是TCHAR编码体系但外部数据不一定这么守规矩。最常见的乱码场景是从文件、网络、第三方SDK里读进来一段char*或std::string然后顺手赋给FString。比如你从文本文件读到的内容可能是UTF-8编码的char*在Windows平台下直接转FString就可能变成乱码。因为Windows的TCHAR是UTF-16如果你把UTF-8字节流强转成UTF-16解释每个汉字都会变成几个奇怪符号。正确的做法是用UE提供的转换宏或函数// 把UTF-8的char*转成TCHAR字符串指针 FString MyString UTF8_TO_TCHAR(ExternalCharPtr);反过来从FString导出UTF-8给外部库用std::string StdStr TCHAR_TO_UTF8(*MyString);这两个宏能帮你省掉大量手动转换的麻烦。但在用的时候注意一个坑UTF8_TO_TCHAR这类宏在转换时分配的是临时缓冲区适合在表达式或函数调用里使用。如果你要保存这个指针长期使用一定要先拷贝到FString或自己的缓冲区里否则临时对象销毁后指针就悬空了。4.3 窄字符串转宽字符串的常用姿势项目里经常要处理“从std::string到FString”和“从FString到std::string”的转换我这里直接给一套模板方案。从std::string到FStringstd::string StdString hello; FString UeString UTF8_TO_TCHAR(StdString.c_str());从FString到std::stringstd::string StdString TCHAR_TO_UTF8(*UeString);这里我特别建议你统一用UTF-8作为中转编码。原因很简单现在的JSON、网络协议、数据库访问库绝大多数都以UTF-8为标准。你工程内部可以随便用FString但一旦要出引擎边界建议全部转成UTF-8再交接。团队协作时大家约定“外部接口一律UTF-8”能避免很多因为本地代码页不一致导致的乱码。在Windows平台如果你调用的某个老库要求系统ANSI编码还可以用TCHAR_TO_ANSI和ANSI_TO_TCHAR这两个宏但这类场景现在越来越少优先还是靠UTF-8。5. 常见问题与排查技巧实录5.1 为什么FString直接传给%s会编译失败编译错误大致会指向“无法将FString转换为const TCHAR*”或者“没有合适的用户定义转换”。原因就是变参函数的格式化和普通函数重载不一样编译器不会自动把你的FString对象转成指针。我见过最离谱的改法是有人写UE_LOG(LogTemp, Warning, TEXT(%s), MyString.GetCharArray().GetData())这能跑但没必要。GetCharArray是获取内部TArray的引用再GetData拿首地址效果和*运算符的一样的。直接用*MyString更简单也更风格统一。大概从UE4到UE5的各个版本里这种编译错误都特别常见。你只要养成一个条件反射看到%s后面如果是FString就加*。这条经验能解决八成新手编译报错。5.2 *空字符串会不会崩溃很多人担心FString是空的也就是内部TCHAR数组只有一个结束符那*FString还能用吗答案是能。FString内部即使表示空字符串也会保证有一个有效的TCHAR数组至少包含一个\0。所以*EmptyString拿到的指针是有效的指向一个内容为\0的TCHAR。用UE_LOG打印空字符串也不会炸格式化函数拿到指针后读第一个字符就是结束符自然就停止了。不过要小心另一种情况如果你移动了一个FString之后继续用原来的变量或者两个FString共享了内部缓冲区但其中一个被非法释放了那*出来的指针就有可能悬空。正常情况下遵循FString的所属权和生命周期规则不会有问题。5.3 日志中文乱码的排查路径遇到中文乱码我一般按这个顺序查第一先看源码文件本身是不是UTF-8编码。Windows下VS默认会用本地代码页保存文件如果文件被存成了GBKTEXT(中文)就会出问题。解决方式是把源码文件另存为UTF-8 with BOM或者让团队统一编辑器配置。第二看运行平台。Windows下TCHAR是UTF-16非Windows下是UTF-8。如果你在Windows上看到日志里有半个汉字般的乱码很可能是把UTF-8字节流硬塞进了宽字符体系。第三看日志查看工具本身。UE的Output Log对中文支持整体还行但在某些Linux终端、或者通过外部重定向日志文件查看时终端编码可能不匹配。这时候建议把日志过滤条件简化用一个最小的测试字符串比如TEXT(中文测试)验证环境再逐步排查数据链路。这一套走下来大部分乱码问题都能定位到具体环节。5.4 配合调试器看TCHAR数组最后分享一个我每天都在用的调试小技巧在Visual Studio或者Rider里看FString的内容时直接在监视窗口输入MyString通常IDE会显示格式化后的字符串值这很方便。但如果你想看底层字符数组长什么样可以直接展开FString的成员变量找到内部的Data数组。在VS里你可以在监视窗口输入MyString.Data或者MyString.Data.GetData()然后查看内存视图里面的每个元素就是TCHAR。在Windows下你会看到UTF-16码元在Linux下会看到UTF-8的字节序列。我每次怀疑“字符串内容对不上”的时候都会直接打开内存看一下比靠眼睛猜靠谱得多。还有一个更快的办法在*MyString处打断点然后看返回的指针地址Watch窗口里输入地址本身VS会自动帮你解析这个指针指向的字符串内容。这个方法适合你在看UE源码时快速确认某个格式化调用到底传进去的是什么字符串。我个人在实际操作中最常犯的错就是在循环里写UE_LOG时漏加*一漏就是编译错误。踩过几次坑之后我现在写字符串格式化之前都会停顿半秒心里默念一遍%s要配TCHAR*FString要解星。希望这篇从TCHAR到FString再到运算符的内容能帮你把这条链路彻底看清楚。你有空的话建议把上面几个例子自己敲一遍断点停在运算符返回的位置亲眼看看那个字符数组长什么样比我在这儿写一万字都管用。