
PHP substr 中文截取会乱码吗?原因与处理方法
会。substr() 按字节截取字符串,不按中文字符截取。UTF-8 中,一个汉字通常占 3 个字节;如果截取位置落在某个汉字的编码中间,结果就可能包含不完整的 UTF-8 序列,显示为乱码,或在后续处理时被判定为无效编码。
这并不意味着 substr() 有缺陷。它适用于按字节操作二进制数据,或处理明确以字节为单位的内容。需要按字符截取 UTF-8 文本时,通常应使用 mb_substr();若要求以用户感知的字符为单位,例如不拆开组合字符或 emoji,则应考虑 grapheme_substr()。
一、为什么 substr 会截断中文
PHP 字符串本质上是一段字节序列。UTF-8 是一种变长编码:英文 ASCII 字符通常占 1 个字节,常见汉字占 3 个字节,部分其他字符占 4 个字节。substr() 不会自动识别这些字节属于哪个字符。
例如,字符串“中文”在 UTF-8 中通常占 6 个字节。如果从第 0 个字节开始截取 4 个字节,结果会包含第一个汉字的全部 3 个字节,以及第二个汉字的第 1 个字节。末尾这 1 个字节无法构成完整字符,结果就不是有效的 UTF-8 文本。
<?php
$text = '中文';
$result = substr($text, 0, 4);
var_dump($result);
var_dump(bin2hex($result));
var_dump(mb_check_encoding($result, 'UTF-8'));
这段示例需要安装并启用 mbstring 扩展,因为它调用了 mb_check_encoding()。在 UTF-8 环境中,最后一个检查结果通常是 false:截取结果不是合法的 UTF-8 字符串。具体乱码外观取决于终端、浏览器、数据库驱动和后续处理方式;有的环境会显示替代字符,有的会显示问号,也有的会拒绝处理。
需要区分两个概念:字节截取成功,不等于文本截取正确。substr() 通常只是返回指定范围内的字节,不会替你修复被截断的编码。
二、substr 的参数与边界
常见函数签名如下:
substr(string $string, int $offset, ?int $length = null): string
$string:输入字符串。PHP 字符串可以包含任意字节,不一定是文本。$offset:起始位置,单位是字节。正数从字符串开头计数,负数从结尾计数。$length:截取长度,单位同样是字节。省略或传入null时,截取到末尾;正数表示长度,负数表示从结尾排除指定数量的字节。
以下示例使用英文,便于直接观察字节偏移。ASCII 英文字符在 UTF-8 中每个占 1 个字节,因此此处按字节截取恰好与按字符截取一致。
<?php
$text = 'abcdef';
echo substr($text, 2), PHP_EOL; // cdef
echo substr($text, 2, 3), PHP_EOL; // cde
echo substr($text, -2), PHP_EOL; // ef
echo substr($text, 0, -2), PHP_EOL; // abcd
负数参数容易被误解。substr($text, -2) 是从末尾倒数 2 个字节开始截取;substr($text, 0, -2) 是从开头截取到倒数 2 个字节之前。它们都不是“倒数两个字符”的通用 Unicode 操作。
边界行为也应纳入测试:
- 起始偏移超过字符串长度时,通常返回空字符串。
- 负偏移绝对值大于字符串长度时,起点会落在字符串开头附近;PHP 的具体范围规则应以目标版本的函数文档和测试为准,不能拿负数参数替代字符索引。
- 长度为 0 时,结果为空字符串。
- 负长度表示从末尾排除相应数量的字节;若计算出的结束位置早于起点,结果为空字符串。
在 PHP 8 中,传入不符合参数类型的值可能触发 TypeError,不要依赖旧代码中把任意字符串或浮点数隐式当作偏移量的行为。项目应明确传入整数,并在其支持的 PHP 版本上验证边界用例。对负数偏移和负长度,尤其要确认自己要的是字节位置,而不是字符位置。
三、按 UTF-8 字符截取:使用 mb_substr
如果业务中的“字符”指 Unicode 码点,且文本编码确定为 UTF-8,可以使用 mb_substr()。它由 PHP 的 mbstring 扩展提供,偏移量和长度按指定编码中的字符计数,而不是按字节计数。
<?php
$text = 'PHP 中文教程';
$firstFive = mb_substr($text, 0, 5, 'UTF-8');
$lastTwo = mb_substr($text, -2, null, 'UTF-8');
echo $firstFive, PHP_EOL; // PHP 中
echo $lastTwo, PHP_EOL; // 教程
示例中的 mb_substr() 第四个参数明确指定了 UTF-8,避免依赖运行环境的默认编码设置。偏移量和长度是字符数,所以 mb_substr($text, 0, 5, 'UTF-8') 取前 5 个 Unicode 字符;对这个示例而言,结果是“PHP 中”。负偏移同样按字符计数。
mb_substr() 的常用形式为:
mb_substr(
string $string,
int $start,
?int $length = null,
?string $encoding = null
): string
$start、$length 的单位是字符,$encoding 用于指定字符编码。不同 PHP 版本的返回类型和对非法参数的处理细节可能有所差异;不要将它理解为自动识别、修复所有编码问题的工具。输入编码应当已知且一致。对于 UTF-8 文本,直接显式传入 'UTF-8',并在部署环境确认 mbstring 已安装。
检查扩展与输入编码
可在程序启动或部署检查中确认扩展可用。还可以在处理外部输入前检查字节序列是否为合法 UTF-8:
<?php
if (!extension_loaded('mbstring')) {
throw new RuntimeException('需要启用 mbstring 扩展');
}
$text = $_POST['text'] ?? '';
if (!is_string($text) || !mb_check_encoding($text, 'UTF-8')) {
throw new InvalidArgumentException('输入不是有效的 UTF-8 字符串');
}
$preview = mb_substr($text, 0, 100, 'UTF-8');
echo htmlspecialchars($preview, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
在网页输出中,示例通过 htmlspecialchars() 做 HTML 转义。它与截取是两个独立步骤:前者用于把文本安全地放入 HTML 上下文,后者用于决定展示多少字符。即使字符串已经按字符截取,也仍然可能包含需要转义的 HTML 特殊字符。
四、按用户感知的字符截取:grapheme_substr
mb_substr() 按编码中的字符(Unicode 码点)计数,但用户看到的“一个字符”有时由多个码点组成。比如带组合附加符号的字母、某些旗帜 emoji、肤色修饰符和 ZWJ 连接的 emoji 序列,都可能由多个码点组成。按码点截断,仍可能把一个用户感知的字符拆开。
需要尽可能按字素簇(grapheme cluster)截取时,可以使用 PHP Intl 扩展提供的 grapheme_substr()。它按照扩展所实现的 Unicode 字素簇规则处理文本。
<?php
$text = "A👨👩👧👦B";
$result = grapheme_substr($text, 0, 2);
if ($result === false) {
throw new RuntimeException('字素簇截取失败');
}
echo $result, PHP_EOL;
此示例需要启用 intl 扩展;函数失败时可能返回 false,调用方应处理失败结果。字素簇规则和相关 Unicode 数据由运行环境中的实现决定,不应假设它在所有旧版本和所有环境中都完全一致。若业务只要求普通中文标题按字符数限制,通常使用显式指定 UTF-8 的 mb_substr() 已足够;只有当 emoji、组合字符等确实需要作为整体处理时,才需要考虑字素簇级别的截取。
五、根据场景选择函数
| 场景 | 建议 | 注意点 |
|---|---|---|
| 截取 UTF-8 中文标题或摘要 | mb_substr() | 确保安装 mbstring,并明确传入 UTF-8。 |
| 截取包含组合字符或复杂 emoji 的显示文本 | grapheme_substr() | 需要 intl 扩展;检查失败返回值。 |
| 截取二进制数据或协议中的字节字段 | substr() | 偏移和长度按字节;不要把任意截取结果直接当 UTF-8 文本。 |
| 明确只含 ASCII 的字符串 | substr() 或字符函数 | ASCII 字符在常见编码中通常与字节位置一致,但应明确输入约束。 |
选择函数前先回答两个问题:输入是什么编码?业务所说的“长度”是字节、Unicode 码点,还是用户感知的字素簇?这两个问题比单纯替换函数名更重要。
六、不能用字符截取函数代替编码转换
如果字符串本身不是 UTF-8,却把编码参数写成 UTF-8,结果不会因此自动变成正确的 UTF-8。举例来说,某些历史数据可能使用 GBK、Windows-1252 或其他编码。此时应先确认数据的实际编码,再按需要进行编码转换,最后按目标编码截取。
<?php
$source = $legacyData;
// 只有在已确认原数据确实是 GBK 时,才这样转换。
$utf8 = mb_convert_encoding($source, 'UTF-8', 'GBK');
if (!mb_check_encoding($utf8, 'UTF-8')) {
throw new RuntimeException('转换后不是有效的 UTF-8');
}
$preview = mb_substr($utf8, 0, 80, 'UTF-8');
不要为了“修乱码”随意猜测源编码。编码猜错可能造成不可逆的数据损失;数据库连接、表字段、HTTP 请求和模板输出也应使用一致的字符编码。新项目通常应统一使用 UTF-8(数据库侧常见配置为 utf8mb4),但这不能替代对旧数据来源的确认。
七、常见错误与纠正方式
错误 1:对 UTF-8 文本直接使用 substr
<?php
$title = '欢迎阅读 PHP 教程';
echo substr($title, 0, 10);
这里的 10 表示 10 个字节,不是 10 个汉字或 Unicode 字符。不同文本含有多少中文、英文和 emoji,截取结果都可能不同。若需求是前 10 个字符,应改为:
<?php
$title = '欢迎阅读 PHP 教程';
echo mb_substr($title, 0, 10, 'UTF-8');
错误 2:把负数长度当作字符数
substr($text, 0, -3) 的 -3 表示从末尾排除 3 个字节。对包含中文的文本,排除的 3 个字节可能正好是一个汉字,也可能只是另一个字符编码的一部分。按字符处理时应使用 mb_substr(),并在目标 PHP 版本中测试负长度用法。
错误 3:只改 mb_internal_encoding,不显式说明编码
调整 mbstring 的默认编码可能影响省略编码参数时的行为,但会让代码更依赖进程级配置。更易维护的写法是对关键操作显式传入编码,例如 mb_substr($text, 0, 20, 'UTF-8')。同时要确保输入确实是 UTF-8,而不是仅仅把参数标记成 UTF-8。
错误 4:先截断成无效字节,再尝试转码
如果 substr() 已经截掉一个多字节字符的中间部分,原始字节序列已经不完整。后续转换函数可能替换、忽略或拒绝无效字节,无法可靠地还原被截断的字符。正确做法是在字节截断之前先确定数据用途;对文本使用字符级截取,对二进制内容才按字节处理。
错误 5:用截取解决 XSS 或输入校验
把文本限制为 100 个字符,并不会让其中的 HTML 或 JavaScript 变安全。截取不是 HTML 转义、SQL 参数化、命令参数校验或业务规则校验。输出 HTML 时应按上下文转义;SQL 查询应使用参数化语句;其他输出上下文也应使用相应的安全编码方式。
八、截取与安全用途:边界要分清
按正确字符边界截取,可以避免展示文本被拆坏,但它本身不是安全防护机制。以下几点尤其容易混淆:
- 不防 XSS:截取后的内容仍可能包含
<script>等特殊文本。输出到 HTML 时应使用适当的上下文转义,例如htmlspecialchars();不能把“只显示前 N 个字符”当成安全措施。 - 不等于输入验证:长度限制只是规则的一部分。若某个字段只允许特定格式,还要独立校验字符集、结构和业务含义。限制应在服务端执行,不能只依赖前端。
- 注意数据库长度单位:数据库的列限制可能按字符或字节计算,具体取决于数据库类型、字符集和列定义。把 PHP 中按码点截取的长度直接当作数据库字节上限,可能导致写入失败或内容被数据库侧截断。
- 不要截断密码或令牌来“隐藏”它们:掩码显示应使用专门的展示逻辑,并避免记录、回显完整秘密值。截断并不会使秘密值在存储或传输中更安全,也可能改变其校验结果。
- 二进制数据保留字节语义:图片片段、加密数据、压缩内容和网络协议字段等可能必须按字节处理。对这些数据调用多字节文本函数,可能改变预期语义。
九、可复用的 UTF-8 文本截取函数
如果项目需要多次按字符截取,并希望统一校验扩展、类型和编码,可以封装一个简单函数。以下版本限定输入为合法 UTF-8 字符串,返回前指定数量的 Unicode 字符;它不按字素簇处理,因此不会保证复杂 emoji 序列整体保留。
<?php
function truncateUtf8(string $text, int $limit): string
{
if ($limit < 0) {
throw new InvalidArgumentException('limit 不能为负数');
}
if (!extension_loaded('mbstring')) {
throw new RuntimeException('需要启用 mbstring 扩展');
}
if (!mb_check_encoding($text, 'UTF-8')) {
throw new InvalidArgumentException('text 必须是有效的 UTF-8');
}
return mb_substr($text, 0, $limit, 'UTF-8');
}
$title = 'PHP substr 中文截取会乱码吗?';
echo truncateUtf8($title, 12);
这个函数将负数限制视为调用错误,避免把“负数长度”的特殊语义带进一个只表达“取前 N 个字符”的接口。实际项目还应根据业务处理空字符串、超长输入、异常记录和输出转义。PHP 的整数范围由运行平台决定;若长度来自用户输入,也应在转换为整数之前验证格式,并设置合理的业务上限,避免把异常大的值直接用于后续资源消耗型操作。
十、快速排查清单
- 确认出问题的字符串实际编码,而不是只看文件头、页面声明或变量名。
- 检查字符串在截取前是否是有效 UTF-8,可用
mb_check_encoding($text, 'UTF-8')。 - 确认当前函数的偏移和长度单位:
substr()是字节,mb_substr()是编码字符,grapheme_substr()是字素簇。 - 检查负数偏移、负长度以及超出字符串范围的情况,并使用目标 PHP 版本运行测试。
- 确认
mbstring或intl扩展已在生产环境启用,而不只是开发环境可用。 - 确认最终输出位置的转义和数据库写入规则。乱码、无效编码和安全转义是相关但不同的问题。
总结
substr() 按字节截取。对 UTF-8 中文文本,截取位置若落在一个字符的字节序列中间,就可能得到无效 UTF-8,表现为乱码或处理失败。普通文本按 Unicode 字符截取时,优先使用带明确编码参数的 mb_substr();需要尽量保留组合字符和复杂 emoji 时,考虑 grapheme_substr()。处理二进制数据或明确的字节字段时,substr() 仍然合适。
最重要的是不要混淆字节、码点和用户感知的字符,也不要把截取当作编码转换、输入验证或安全转义。先确认数据编码和业务长度单位,再选择函数,并对边界与部署扩展做实际测试,才能稳定地处理中文文本。





