从编码算法到 P/Invoke:C# `string` 如何变成 C++ `char*` 要理解这次转换不能只停留在[MarshalAs]属性上。真正的核心是从 C# 字符串中解析 UTF-16得到 Unicode 码点再按照目标编码生成字节最后把字节缓冲区的地址交给 C。下面先实现 UTF-8 转换算法再解释 UTF-16、GBK 等编码的差异最后落到射击游戏的实际接口设计。内容参考 Microsoft、Unicode 和 Unity 的公开资料。一、先分清字符、码点、码元、字节C# 的string是 UTF-16 码元序列每个char为 16 位。C 的char*是字节地址不包含编码信息voidset_name(constchar*name);这个函数究竟接收 UTF-8 还是 GBK必须由接口协议指定。以A中为例内容Unicode 码点UTF-16 码元UTF-8 字节AU0041004141中U4E2D4E2DE4 B8 ADU1F3AFD83C DFAFF0 9F 8E AF所以A中.Length4这里是四个 UTF-16 码元而 UTF-8 编码后是八个字节不含结束符。把每个 C#char强制转换成byte既不是 UTF-8 编码也不能正确处理中文和表情。二、第一层算法从 UTF-16 解析 Unicode 码点UTF-16 有两种情况。1. 非代理码元直接得到码点如果码元不在D800DFFF码点 码元值例如“中”0x4E2D → U4E2D2. 代理对两个码元合成一个码点对于 U10000U10FFFF需要高代理D800DBFF低代理DC00DFFF恢复公式U 0x10000 ((高代理 - 0xD800) 10) (低代理 - 0xDC00)例如高代理 D83C 低代理 DFAF U 10000 (003C 10) 03AF 1F3AF高代理后面没有低代理或者单独出现低代理都是无效 UTF-16 序列。C#string可以保存这些无效序列所以编码器必须选择抛出异常或按回退策略替换。涉及昵称校验、资源标识时通常应优先采用严格拒绝策略。三、第二层算法把码点拆成 UTF-8 字节UTF-8 的核心是将码点的二进制位分组再加上字节前缀。码点范围字节模板U0000U007F0xxxxxxxU0080U07FF110xxxxx 10xxxxxxU0800UFFFF排除代理区1110xxxx 10xxxxxx 10xxxxxxU10000U10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx以“中”U0x4E2D为例第一字节 E0 | (U 12) E4 第二字节 80 | ((U 6) 3F) B8 第三字节 80 | (U 3F) AD这里 0x3F提取六个有效位| 0x80添加10前缀| 0xE0添加1110前缀。这就是 UTF-8 编码的实际计算不需要汉字映射表。可运行的严格 UTF-16 → UTF-8 实现下面同时追加 C 字符串需要的零字节usingSystem;staticclassUtf8Codec{publicstatic(byte[]Bytes,intLength)EncodeCString(strings){ArgumentNullException.ThrowIfNull(s);// 一个 UTF-16 码元最多对应三个 UTF-8 字节// 代理对占两个码元却只生成四个字节。byte[]bytesnewbyte[checked(s.Length*31)];intn0;for(inti0;is.Length;i){uintus[i];if(u0xD800u0xDBFF){if(i1s.Length)thrownewArgumentException(缺少低代理);uintlows[i1];if(low0xDC00||low0xDFFF)thrownewArgumentException(代理对无效);u0x10000((u-0xD800)10)(low-0xDC00);i;}elseif(u0xDC00u0xDFFF){thrownewArgumentException(孤立的低代理);}// 本函数面向零结尾 C 字符串不接受内嵌零。if(u0)thrownewArgumentException(不能包含零字符);if(u0x7F){bytes[n](byte)u;}elseif(u0x7FF){bytes[n](byte)(0xC0|(u6));bytes[n](byte)(0x80|(u0x3F));}elseif(u0xFFFF){bytes[n](byte)(0xE0|(u12));bytes[n](byte)(0x80|((u6)0x3F));bytes[n](byte)(0x80|(u0x3F));}else{bytes[n](byte)(0xF0|(u18));bytes[n](byte)(0x80|((u12)0x3F));bytes[n](byte)(0x80|((u6)0x3F));bytes[n](byte)(0x80|(u0x3F));}}bytes[n]0;return(bytes,n);// Length 不包含结束符}}调用varresultUtf8Codec.EncodeCString(A中);Console.WriteLine(Convert.ToHexString(result.Bytes.AsSpan(0,result.Length)));// 41E4B8ADF09F8EAF算法时间复杂度为 O(n)额外空间为 O(n)。它用于讲清原理生产项目通常应使用经过充分测试和优化的 .NET 编码器。四、第三层编码好的字节如何成为char*1. 手动编码固定数组后传入C 接口以下展示 Windows 导出方式#includecstdint#includestringexternC__declspec(dllexport)int32_t__cdeclsubmit_name(constchar*bytes,int32_tbyteCount)noexcept{if(!bytes||byteCount0)return-1;try{std::stringowned(bytes,static_castsize_t(byteCount));// 实际项目在此校验并将 owned 保存或移入任务队列。return0;}catch(...){return-2;}}C#usingSystem.Runtime.InteropServices;staticunsafeclassNative{[DllImport(ShooterNative,EntryPointsubmit_name,CallingConventionCallingConvention.Cdecl)]privatestaticexternintSubmit(byte*bytes,intbyteCount);publicstaticintSubmitName(stringname){varencodedUtf8Codec.EncodeCString(name);fixed(byte*pencoded.Bytes){returnSubmit(p,encoded.Length);}}}项目需要启用AllowUnsafeBlocks。这里没有再次转码编码器生成 UTF-8 字节。fixed保证数组在该作用域内不会被 GC 移动。byte*以地址形式传给原生const char*。原生函数返回后固定结束。原生侧不能保存这个地址供以后使用也不能释放它。异步处理必须先复制数据。2. 自动封送把上述工作交给运行时如果接口只接收零结尾字符串voidset_name(constchar*utf8Name);对应 C#[DllImport(ShooterNative,CallingConventionCallingConvention.Cdecl)]staticexternvoidset_name([MarshalAs(UnmanagedType.LPUTF8Str)]stringname);自动封送在语义上完成UTF-16 解析 → UTF-8 编码 → 准备零结尾缓冲区 → 调用原生函数 → 清理临时资源.NET 7 也可以使用LibraryImport指定StringMarshallingStringMarshalling.Utf8这些声明不会转用上面的手写编码器。若要严格控制无效字符处理应显式使用严格编码器和字节接口varutf8newSystem.Text.UTF8Encoding(false,true);byte[]payloadutf8.GetBytes(name);GetBytes不自动追加 C 字符串结束符是否追加取决于接口采用零结尾还是显式长度。五、其他编码算法差异才是关键1. UTF-16LE / UTF-16BE按码元拆字节对一个 UTF-16 码元vUTF-16LE byte0 v FF byte1 v 8 UTF-16BE byte0 v 8 byte1 v FF例如“中”UTF-16LE2D 4E UTF-16BE4E 2D补充平面字符先用下面的公式生成代理对再拆字节T U - 10000 高代理 D800 (T 10) 低代理 DC00 (T 3FF)UTF-16 不能按普通char*零结尾字符串读取。比如A的 UTF-16LE 是41 00strlen会错误地认为只有一个字节。如果接口接收 UTF-16应明确使用 16 位码元和相应长度。Windows 的wchar_t通常为 16 位Linux 上通常为 32 位不能跨平台直接等同。2. GBK主要靠映射表不存在通用位运算公式UTF-8 根据 Unicode 数值计算字节GBK 则依赖历史字符集映射U0041 → 41 U4E2D → D6 D0基本编码流程读取 Unicode 码点 ↓ ASCII直接输出 ↓ 查询“Unicode → 指定 GBK/CP936 编码”映射表 ↓ 有映射输出对应字节 无映射报错或执行回退策略GBK 双字节区的常见字节范围是首字节81FE 尾字节40FE排除 7F解码实现可以将这类字节对展开成表索引row first - 81 column second 7F ? second - 40 : second - 41 index row × 190 column但索引只定位映射表不能直接算出 Unicode 码点合法字节范围内也可能有未定义位置扩展规则还取决于具体编码实现。.NET 使用 CP936Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);Encodingcp936Encoding.GetEncoding(936,EncoderFallback.ExceptionFallback,DecoderFallback.ExceptionFallback);byte[]bytescp936.GetBytes(中国);现代 .NET 项目按需引用System.Text.Encoding.CodePages。CP936 无法表示严格编码会抛异常。不要让无法表示的昵称静默变成?。3. GB18030映射表加范围算法GB18030 不是“GBK 多几个字符”它包含单字节、双字节和四字节序列。四字节部分可先计算线性位置p (((b1 - 81) × 10 (b2 - 30)) × 126 (b3 - 81)) × 10 (b4 - 30)再根据标准中的范围映射定位 Unicode 码点。它不是对所有字符统一执行Up且标准版本和实现兼容性需要核对。因此生产环境应使用成熟编码库不宜自行维护完整 GB18030 映射。4. ANSI编码策略不是固定算法CharSet.Ansi不等于 UTF-8也不必然等于 GBK。Windows 上通常涉及系统 ANSI 代码页现代 .NET 的 Unix 平台通常采用 UTF-8。跨平台插件应明确编码避免依赖机器设置。Windows 原生侧转码通常使用源字节 → MultiByteToWideChar UTF-16 → WideCharToMultiByte 目标字节处理 UTF-8 可使用MB_ERR_INVALID_CHARS拒绝非法输入输出旧代码页时应按 API 对标志的限制检测有损替换。六、射击游戏案例把算法放进真实数据流案例一昵称限制为 24 个 UTF-8 字节错误做法namename.Substring(0,24);这限制的是 UTF-16 码元数不是 UTF-8 字节数还可能切断代理对。正确算法逐个解析 Unicode 码点 → 按码点范围计算 UTF-8 长度1、2、3、4 → 加入后若超过 24则拒绝或停止 → 只在完整码点边界输出不过“码点边界完整”不代表显示完整组合音标、肤色修饰符、ZWJ 表情可能由多个码点组成。若产品要求昵称不能被视觉切断应按字素簇处理再计算每个字素簇的 UTF-8 字节数。案例二击杀播报与命中热路径昵称在加入或改名时转成 UTF-8原生侧复制并缓存。每次命中只传攻击者 ID、目标 ID、武器 ID、服务器 Tick、伤害值需要显示播报时才查询名称并本地化。这比优化某一次字符串编码更有效每次转换都要扫描输入高频事件重复转换昵称只会增加无意义的 CPU 和分配开销。案例三旧 CP936 赛事 SDK游戏内部和网络协议统一 UTF-8只在 SDK 适配层转 CP936。对无法表示的字符应拒绝、要求别名或采用 SDK 支持的替代方案。身份匹配始终使用玩家 ID不能依赖有损转码后的昵称。七、参考与结论主要参考Microsoft字符串封送MicrosoftP/Invoke 源生成MicrosoftMultiByteToWideCharUnicodeUnicode 标准Unity原生插件核心区别可以概括为UTF-8 是码点分组与位运算UTF-16 是代理对与字节序GBK 主要依靠映射表P/Invoke 则负责把编码后的存储连接到原生调用。只有把编码算法、长度单位、结束符和指针生命周期一起约定清楚C#string → C char*才算真正实现正确。