<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>编码 · XB 现场笔记</title><link>https://www.xbcatm.com/zh/tags/%E7%BC%96%E7%A0%81/</link><description>基于 HTTP、DNS、TLS、Caddy、SSH、发布与恢复的可复现运维现场笔记。</description><generator>Hugo</generator><language>zh-CN</language><atom:link href="https://www.xbcatm.com/zh/tags/%E7%BC%96%E7%A0%81/index.xml" rel="self" type="application/rss+xml"/><item><title>字节边界与解码规则一致，UTF-8 才能完整保留文本</title><link>https://www.xbcatm.com/zh/posts/utf8-signals-encoding-in-one-stroke/</link><pubDate>Wed, 02 Dec 2026 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/zh/posts/utf8-signals-encoding-in-one-stroke/</guid><description>增量解码被切开的汉字和表情序列，拒绝非法编码，并区分 BOM 处理与 Unicode 规范化。</description><content:encoded>&lt;p&gt;UTF-8 用一到四字节编码 Unicode 标量值。ASCII 字符保持熟悉的单字节表示，许多其他字符则需要更长序列。它适合文本交换，却不会让任意字节自动成为有效文本，也不会让每个字节偏移都成为字符边界。正确处理需要编码约定、符合契约的有效性检查，以及对原始字节和已经解码文本的明确区分。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;文件或网络读取可以停在多字节序列中间。每块独立解码，会拒绝原本完整且有效的整个字节流；如果每块都立即替换错误，还可能破坏本应与下一块组成字符的字节。流式解码器需要保留未完成输入，等后续数据到来再处理，并在流结束时检查是否仍有残缺序列。分块边界是传输细节，不是文本天然边界。&lt;/p&gt;
&lt;p&gt;UTF-8 的起始与续字节具有可识别模式，但这不使任意切片安全。直接截断编码后的字节串，可能把一个序列切成两半。使用另一种编码成功解码，也不能证明解释正确。Latin-1 能映射每个字节，因此把 UTF-8 误解为 Latin-1，仍会得到合法 Unicode 字符串，却可能已经成为乱码。有效字符串和正确文本不是同一结论。&lt;/p&gt;
&lt;p&gt;编码有效性还不同于文本等价与显示宽度。一个看起来带重音的字母，可以由单个码点表示，也可以由基础字母和组合标记表示。规范化能够让选定形式便于比较，但 UTF-8 解码不会自动完成规范化。同样，字节顺序标记可能成为字符串开头的实际字符，除非选定编解码器明确消费它。不能把这些处理都笼统称为“转换编码”。&lt;/p&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;将脚本保存为 &lt;code&gt;check-utf8.py&lt;/code&gt;，准备 Docker 后执行 &lt;code&gt;python3 check-utf8.py&lt;/code&gt;。固定 Python 3.11.16 容器在无网络环境下运行标准库编码与 Unicode 操作，输入都是内存中明确给出的字节和字符串。实验不迁移数据库，也不转换现有文件；断言依赖字节和码点，不要求终端能够正确绘制某个表情图形：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;json&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="nn"&gt;subprocess&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;&lt;span class="nn"&gt;uuid&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;program&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;import codecs,json,unicodedata
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;text=&amp;#39;A中🙂&amp;#39;;encoded=text.encode(&amp;#39;utf-8&amp;#39;);assert len(text)==3 and len(encoded)==8
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;assert encoded.hex()==&amp;#39;41e4b8adf09f9982&amp;#39; and encoded.decode(&amp;#39;utf-8&amp;#39;)==text
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;pieces=[encoded[:2],encoded[2:5],encoded[5:]]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;split_errors=0
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;for piece in pieces:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; try:piece.decode(&amp;#39;utf-8&amp;#39;)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; except UnicodeDecodeError:split_errors+=1
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;assert split_errors==3
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;decoder=codecs.getincrementaldecoder(&amp;#39;utf-8&amp;#39;)(errors=&amp;#39;strict&amp;#39;);decoded=[]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;for piece in pieces:decoded.append(decoder.decode(piece,final=False))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;decoded.append(decoder.decode(b&amp;#39;&amp;#39;,final=True));assert &amp;#39;&amp;#39;.join(decoded)==text
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;rejected=&lt;/span&gt;&lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;for label,raw in {&amp;#39;overlong&amp;#39;:bytes.fromhex(&amp;#39;c0af&amp;#39;),&amp;#39;surrogate&amp;#39;:bytes.fromhex(&amp;#39;eda080&amp;#39;),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;#39;too_large&amp;#39;:bytes.fromhex(&amp;#39;f4908080&amp;#39;),&amp;#39;truncated&amp;#39;:bytes.fromhex(&amp;#39;e4b8&amp;#39;)}.items():
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; try:raw.decode(&amp;#39;utf-8&amp;#39;);raise AssertionError(&amp;#39;invalid UTF-8 accepted&amp;#39;)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; except UnicodeDecodeError as error:rejected[label]=error.reason
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;bad_tail=codecs.getincrementaldecoder(&amp;#39;utf-8&amp;#39;)();assert bad_tail.decode(bytes.fromhex(&amp;#39;e4b8&amp;#39;),final=False)==&amp;#39;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;try:bad_tail.decode(b&amp;#39;&amp;#39;,final=True);raise AssertionError(&amp;#39;incomplete final sequence accepted&amp;#39;)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;except UnicodeDecodeError:final_rejected=True
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;mojibake=encoded.decode(&amp;#39;latin-1&amp;#39;);assert mojibake!=text and mojibake.encode(&amp;#39;latin-1&amp;#39;)==encoded
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;decomposed=&amp;#39;e\u0301&amp;#39;;composed=&amp;#39;é&amp;#39;;assert decomposed!=composed and unicodedata.normalize(&amp;#39;NFC&amp;#39;,decomposed)==composed
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;bom=b&amp;#39;\xef\xbb\xbf&amp;#39;+encoded;assert bom.decode(&amp;#39;utf-8&amp;#39;).startswith(&amp;#39;\ufeff&amp;#39;) and bom.decode(&amp;#39;utf-8-sig&amp;#39;)==text
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;replacement=bytes.fromhex(&amp;#39;ff41&amp;#39;).decode(&amp;#39;utf-8&amp;#39;,errors=&amp;#39;replace&amp;#39;);assert replacement==&amp;#39;\ufffdA&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;print(json.dumps({&amp;#39;text&amp;#39;:text,&amp;#39;code_points&amp;#39;:len(text),&amp;#39;bytes&amp;#39;:len(encoded),&amp;#39;hex&amp;#39;:encoded.hex(),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;#39;chunk_hex&amp;#39;:[x.hex() for x in pieces],&amp;#39;independent_chunk_failures&amp;#39;:split_errors,&amp;#39;incremental_output&amp;#39;:decoded,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;#39;invalid_rejections&amp;#39;:rejected,&amp;#39;final_incomplete_rejected&amp;#39;:final_rejected,&amp;#39;latin1_code_points&amp;#39;:[hex(ord(c)) for c in mojibake],
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;#39;normalization_lengths&amp;#39;:[len(decomposed),len(composed)],&amp;#39;bom_utf8_first&amp;#39;:hex(ord(bom.decode(&amp;#39;utf-8&amp;#39;)[0])),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;#39;utf8_sig_strips_bom&amp;#39;:bom.decode(&amp;#39;utf-8-sig&amp;#39;)==text,&amp;#39;replacement&amp;#39;:replacement},ensure_ascii=False))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;xbcatm-socket-&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;docker&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;run&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;--rm&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-i&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;--name&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;--network&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;none&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;--memory&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;128m&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;--pids-limit&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;64&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;python3&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;program&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;docker&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;rm&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-f&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;字符串 &lt;code&gt;A中🙂&lt;/code&gt; 在 Python 中具有三个对应码点的元素，编码后为八字节，十六进制是 &lt;code&gt;41e4b8adf09f9982&lt;/code&gt;。ASCII 占一字节，汉字占三字节，表情占四字节；重新解码返回原文。编码字节数和这些码点数量回答不同问题，也都不能自动等同于屏幕列宽。限制字段长度前，必须先明确限制使用哪种单位。&lt;/p&gt;
&lt;p&gt;程序把字节拆为 &lt;code&gt;41e4&lt;/code&gt;、&lt;code&gt;b8adf0&lt;/code&gt;、&lt;code&gt;9f9982&lt;/code&gt;，每块当作完整 UTF-8 独立解码都会失败。增量解码器却依次输出 &lt;code&gt;A&lt;/code&gt;、&lt;code&gt;中&lt;/code&gt;、&lt;code&gt;🙂&lt;/code&gt;，在调用之间保留部分序列，最后以 final=True 的空输入完成验证。另一个只持有 &lt;code&gt;e4b8&lt;/code&gt; 的解码器，在结束时拒绝残缺序列，没有悄悄丢掉最后一段内容。&lt;/p&gt;
&lt;p&gt;四个负向案例分别拒绝过长编码、代理码点、超出 Unicode 上限的值和不完整序列。即使部分字节看起来符合续字节模式，这些形式仍不属于有效 UTF-8。程序明确捕获 UnicodeDecodeError，不把无关异常当成校验成功，也没有使用允许代理码点的宽松模式。验证对象是标准编码规则，而不只是某个函数是否发生错误。&lt;/p&gt;
&lt;p&gt;把有效 UTF-8 字节按 Latin-1 解码会成功，却产生不同码点。这个受控案例中，再按 Latin-1 编码错误字符串，可以恢复原字节，但它不是通用乱码修复算法。此前若经过替换、规范化或有损转换，信息可能已经消失。诊断编码不一致时，应先保留原始字节，再决定是否有证据支持某个逆向转换。&lt;/p&gt;
&lt;p&gt;脚本还比较分解形式的字母与组合重音，以及组合形式 &lt;code&gt;é&lt;/code&gt;。两者初始不相等，长度分别为二和一，NFC 规范化后相等。另外，普通 UTF-8 解码保留开头 BOM 为 U+FEFF，utf-8-sig 则消费它。这是两种独立转换，不能仅因文本看起来奇怪就同时应用。是否允许规范化或删除标记，需要依据字段和格式契约。&lt;/p&gt;
&lt;h2 id="明确每个边界的字节契约"&gt;明确每个边界的字节契约&lt;/h2&gt;
&lt;p&gt;HTTP charset 声明或文件格式规范告诉消费者怎样解释字节，修改声明本身不会转码正文。生产端编码器、传输元信息、解析器和存储接口需要一致。应让包含多字节字符的样本经过真实边界，特别检查截断、分块缓冲和按字节计量的限制。只在程序入口看到 UTF-8 字样，并不能说明中途每一步都正确处理了内容。&lt;/p&gt;
&lt;p&gt;按数据契约选择错误策略。实验展示 errors=&amp;lsquo;replace&amp;rsquo; 将非法输入变成 U+FFFD，结果是合法但有损的文本。这可能适合诊断显示，却不能证明标识符或签名数据得到保留。拒绝、替换、移除 BOM 和规范化，应作为具有不同可观察效果的独立决定，避免一个宽松解码动作掩盖本来需要向调用方报告的数据损坏。&lt;/p&gt;
&lt;p&gt;限制已解码文本时，使用符合字符需求的操作；限制编码消息时，使用明确消息边界。增量解码解决被切开的字节序列，不定义字素簇、换行布局或数据库比较语义。应验证应用实际要求的属性，而不是把“统一使用 UTF-8”当成所有文本边界都正确的证明。编码一致只是完整文本处理契约的一部分。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc3629.html"&gt;RFC 3629 UTF-8&lt;/a&gt;，有效字节序列、标量范围及非法编码，已核验 2026-09-12。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unicode.org/faq/utf_bom.html"&gt;Unicode UTF and BOM FAQ&lt;/a&gt;，编码边界与字节顺序标记行为，已核验 2026-09-12。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3.11/library/codecs.html"&gt;Python 3.11 codecs&lt;/a&gt;，增量解码、结束校验与错误处理策略，已核验 2026-09-12。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3.11/library/unicodedata.html"&gt;Python 3.11 unicodedata&lt;/a&gt;，作为独立操作的 Unicode 规范化，已核验 2026-09-12。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>