
PHP substr 和中文字符串:为什么截取结果可能乱码?
在 PHP 中,substr() 截取字符串时,处理的是字节,不是我们通常理解的“字符”。这一区别对只包含 ASCII 字符的字符串往往不明显,因为一个 ASCII 字符通常占一个字节;但 UTF-8 编码的中文字符通常占三个字节,表情符号还可能占四个字节。按字节截取时,如果边界落在一个多字节字符的中间,结果就可能包含不完整的编码序列,显示为乱码、替换符号,或在后续处理时被拒绝。
本文说明 substr() 的行为和参数边界,演示如何按字符或用户感知的字符截取,并讨论常见误用。关键结论是:需要按字节处理二进制数据时使用 substr();需要按 UTF-8 字符截取时考虑 mb_substr();需要避免拆开组合字符或表情序列时,使用适合 grapheme cluster(字素簇)的处理方式。无论选择哪种函数,都要先明确输入编码、截取单位和实际安全需求。
先看一个最小示例
<?php
$text = '中文';
echo strlen($text), PHP_EOL;
echo bin2hex($text), PHP_EOL;
$part = substr($text, 0, 1);
echo bin2hex($part), PHP_EOL;
var_dump($part);
在 UTF-8 环境下,strlen('中文') 通常输出 6,因为两个汉字各占三个字节。bin2hex() 会显示底层字节;常见 UTF-8 编码下,结果为 e4b8ade69687。而 substr($text, 0, 1) 只取第一个字节,得到 e4,它不是一个完整的 UTF-8 字符。
这个结果不一定在所有终端或程序里都以同一种方式呈现。有的环境显示乱码,有的显示替代字符,有的函数会报告编码错误;也有程序把字符串当作原始字节继续处理。问题不在于 substr() 随机破坏了数据,而在于调用者要求它按一个字节截取,截取位置却落在了多字节字符内部。
substr() 的单位是字节
substr() 的常用形式是:
<?php
substr(string $string, int $offset, ?int $length = null): string
其中,$offset 和 $length 都以字节为单位。PHP 字符串本质上是一段字节序列,字符串本身并不自动记录“每个用户可见字符占多少字节”。substr() 因此可以处理任意字节内容,适用于二进制字符串,也适用于明确需要按字节切片的场景。
对英文 ASCII 文本来说,字节数和字符数通常相同,所以代码看起来像是在按字符操作。换成 UTF-8 中文、带重音符号的文字或表情符号,这个假设就不成立了。
<?php
$text = 'A中🙂';
echo strlen($text), PHP_EOL; // UTF-8 下通常为 8:A 占 1,中占 3,🙂 占 4
echo substr($text, 0, 1), PHP_EOL; // A:这一段恰好完整
echo bin2hex(substr($text, 1, 3)), PHP_EOL; // 中的三个字节
echo bin2hex(substr($text, 1, 1)), PHP_EOL; // 只取到多字节字符的一部分
strlen() 同样返回字节数,而不是中文字符数。若要在 UTF-8 文本中统计字符数量,通常使用 mb_strlen(),并确保输入确实是预期编码。
按 UTF-8 字符截取:使用 mb_substr()
处理多字节文本时,常见选择是 PHP 的多字节字符串扩展(mbstring)提供的 mb_substr():
<?php
$text = 'PHP 处理中文';
echo mb_strlen($text, 'UTF-8'), PHP_EOL;
echo mb_substr($text, 0, 5, 'UTF-8'), PHP_EOL;
在这里,mb_substr() 的起始位置和长度按指定编码中的字符计数,不是按字节计数。上例的长度参数是 5,表示取前五个字符;它不会因为中文字符在 UTF-8 中占用三个字节,就把中文拆成字节片段。
mb_substr() 需要 mbstring 扩展。可以用下面的代码检查当前运行环境是否提供该函数:
<?php
if (!function_exists('mb_substr')) {
throw new RuntimeException('当前 PHP 环境没有启用 mbstring 扩展');
}
$text = '天气不错';
$firstTwo = mb_substr($text, 0, 2, 'UTF-8');
echo $firstTwo;
在项目中不要只在开发机上确认扩展可用;也要检查生产环境、测试环境和命令行环境的 PHP 配置。若运行环境缺少扩展,应明确决定部署依赖或采用其他实现,而不是静默退回到按字节截取的 substr()。
编码参数也值得明确写出。省略 mb_substr() 的编码参数时,函数会使用 mbstring 的内部编码设置;该设置可以受程序配置影响。项目若约定文本统一为 UTF-8,显式传入 'UTF-8' 通常更易读,也更不依赖环境配置。但传入编码名称并不会自动把任意输入转换成 UTF-8:调用方仍须确认字符串实际编码与参数相符。
<?php
$input = '你好,PHP';
// 假设 $input 确实是 UTF-8。
$preview = mb_substr($input, 0, 3, 'UTF-8');
echo $preview;
字符不总等于屏幕上看到的一个符号
mb_substr() 能避免在多字节编码字符的字节中间切开,但它处理的“字符”仍不一定等同于用户眼里一个完整的符号。Unicode 中,有些看起来是一个字形的内容由多个码点组成,例如基本字母加组合附加符号;某些 emoji 也由多个码点通过连接符组合而成。按码点截取,仍可能把这些组合拆开。
因此,讨论“截取字符”时要先问清楚需求:
- 按字节:适合二进制数据、协议字段或明确规定字节长度的内容。可使用
substr(),并自行遵守数据格式要求。 - 按编码字符或码点:适合多数普通文本截取,UTF-8 文本通常使用
mb_substr()。 - 按用户感知的字素簇:适合需要尽量保留一个完整视觉符号的文本界面,例如 emoji 预览、字符计数器等。可以考虑
grapheme_substr()。
grapheme_substr() 由 PHP 的 intl 扩展提供,按字素簇处理字符串。它适用于有此类需求且环境启用了 intl 的项目;它并不是所有截断问题的通用替代品,也不能代替对输入编码和错误数据的校验。
<?php
if (!function_exists('grapheme_substr')) {
throw new RuntimeException('当前 PHP 环境没有启用 intl 扩展');
}
$text = '示例🙂文本';
$preview = grapheme_substr($text, 0, 3);
if ($preview === false) {
throw new RuntimeException('无法按字素簇截取字符串');
}
echo $preview;
grapheme_substr() 在失败时可能返回 false,调用方应按接口约定处理结果。它的可用性依赖 intl 扩展和相应的国际化库环境;部署前应在目标 PHP 环境验证。若功能只要求避免拆开 UTF-8 编码字符,通常没有必要为了所有场景都改用字素簇处理。
substr() 的负数参数意味着什么
substr() 的参数边界常见误解是把负数当成“非法输入”。负数在函数中有明确含义,但仍然按字节计算:
$offset为负数时,起点从字符串末尾向前计算。$length为负数时,表示从起点截取到字符串末尾之前指定字节数的位置。- 省略
$length或传入null时,截取到字符串末尾。
<?php
$text = 'abcdef';
echo substr($text, -2), PHP_EOL; // ef
echo substr($text, 1, 3), PHP_EOL; // bcd
echo substr($text, 1, -2), PHP_EOL; // bcd
echo substr($text, 20), PHP_EOL; // 空字符串
这里示例使用 ASCII,因而字节和可见字符恰好一致。换成 UTF-8 字符串,负偏移和负长度仍然从字节末尾计算,不能因为从末尾截取就假定边界自动落在字符边界上。若需要按字符从末尾取内容,应选用多字节函数并按对应语义处理。
在现代 PHP 中,substr() 的参数类型由函数签名约束。调用时应传入字符串、整数或允许的 null 长度,不要依赖 PHP 对错误类型的隐式转换。边界行为和类型错误也不应靠猜测处理;对特殊输入应查阅所部署 PHP 版本的官方手册并编写测试。
如何选择正确的函数
可以先明确字符串的用途和长度单位,再决定实现:
- 内容是二进制数据,长度规则以字节定义:使用
substr()。例如从已验证的二进制缓冲区中取固定字节段。 - 内容是 UTF-8 文本,长度规则以字符计数:使用
mb_substr($text, $start, $length, 'UTF-8'),并确保 mbstring 可用。 - 内容要在界面上截成若干完整视觉符号:评估
grapheme_substr(),并确保 intl 可用;同时考虑具体语言和产品的截断规则。 - 长度限制由外部系统按字节规定:必须满足字节上限,但又不能产生无效文本时,应在遵守字节预算的基础上选取完整编码字符,而不是直接把文本按字符长度限制。还要确认服务端、数据库或协议对编码和长度的定义。
下面是一个按 UTF-8 字符截取并添加省略号的简单示例。它依赖 mbstring,并假定输入是有效的 UTF-8 文本:
<?php
function shortenUtf8(string $text, int $limit): string
{
if ($limit < 0) {
throw new InvalidArgumentException('长度不能为负数');
}
if (!function_exists('mb_substr')) {
throw new RuntimeException('需要启用 mbstring 扩展');
}
if (mb_strlen($text, 'UTF-8') <= $limit) {
return $text;
}
return mb_substr($text, 0, $limit, 'UTF-8') . '…';
}
echo shortenUtf8('PHP 字符串处理', 6);
这个函数把参数定义为“最多保留多少个 UTF-8 字符”,不包括追加的省略号;它不负责验证输入是否为合法 UTF-8,也不负责按字节限制输出。真实项目应在接口文档中说明限制单位,并按需要补上输入校验、错误处理和对负数上限的业务定义。
按字节上限截断 UTF-8 文本
有时外部系统确实限制字节数,例如某个字段最多允许固定字节长度。此时单纯使用 mb_substr() 截取固定数量的字符,不能保证结果不超过指定字节数,因为每个字符占用的字节数不同。反过来直接 substr() 到字节上限,又可能切开 UTF-8 字符。
一种实用做法是逐个取得字符,累计其 UTF-8 字节长度,只在完整字符仍能放入预算时加入结果。下面示例使用 mbstring,并假设输入为有效 UTF-8;当剩余空间不足以容纳下一个字符时停止:
<?php
function truncateUtf8ToBytes(string $text, int $maxBytes): string
{
if ($maxBytes < 0) {
throw new InvalidArgumentException('字节上限不能为负数');
}
if (!function_exists('mb_substr')) {
throw new RuntimeException('需要启用 mbstring 扩展');
}
$result = '';
$usedBytes = 0;
$charCount = mb_strlen($text, 'UTF-8');
for ($i = 0; $i < $charCount; $i++) {
$char = mb_substr($text, $i, 1, 'UTF-8');
$charBytes = strlen($char);
if ($usedBytes + $charBytes > $maxBytes) {
break;
}
$result .= $char;
$usedBytes += $charBytes;
}
return $result;
}
$text = 'A中文🙂';
$cut = truncateUtf8ToBytes($text, 7);
echo $cut, PHP_EOL;
echo strlen($cut), PHP_EOL;
这一实现清晰但会逐字符拼接;对于非常长的文本或高频调用,应根据性能要求选择经过验证的实现,并避免在循环中重复做昂贵的工作。它按 UTF-8 编码字符计数,不保证保留完整的字素簇;如果一个用户感知符号包含多个码点,可能仍会在该符号的组成部分之间停止。字节限制究竟包含还是不包含附加内容、尾部标记或终止字节,也要以目标系统的规范为准。
常见错误及其原因
错误一:把 strlen() 的结果当成中文字符数
<?php
$text = '你好';
echo strlen($text); // UTF-8 下通常是 6 字节
echo mb_strlen($text, 'UTF-8'); // 2 个编码字符
用于数据库字段字节限制时,strlen() 可能正是需要的单位;用于界面显示的字符计数时,则往往不是。函数本身没有错,问题在于把字节长度解释成用户看到的字符数。
错误二:用 substr() 做标题、昵称或摘要截断
例如 substr($title, 0, 20) 只代表取前 20 个字节。如果标题含有中文,输出既可能少于预期字符,也可能截断一个 UTF-8 序列。显示文本通常应使用 mb_substr(),若要求完整视觉符号则评估字素簇处理。
错误三:以为 mb_substr() 会自动识别所有输入编码
多字节函数依赖编码参数或其内部编码设置。若字符串实际编码与传入编码不一致,结果可能不符合预期;如果输入本身已经是乱码或编码不合法,改用 mb_substr() 也不会自动修复它。应在数据进入系统时明确编码,必要时先做检测或转换,并为异常输入定义处理方式。
错误四:把“字符安全”当成“输入已验证”
按字符截取可以避免某些多字节边界问题,但不等同于验证字符串合法、可信或符合业务规则。截取前后仍可能需要检查编码、允许的字符范围、长度上限和格式。对于不可信输入,不应因为调用了多字节函数就跳过校验。
错误五:截断后直接输出到 HTML,认为乱码问题解决了就安全
文本截取和 HTML 安全编码是两件不同的事。即使截取结果是有效 UTF-8,也可能包含 HTML 特殊字符或用户输入的标记。输出到 HTML 文本节点时,通常要使用与输出上下文相符的转义方式,例如:
<?php
$title = '用户输入的 <标题>';
$preview = mb_substr($title, 0, 20, 'UTF-8');
echo htmlspecialchars(
$preview,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
htmlspecialchars() 用于 HTML 输出编码,不是截取函数,也不能代替 JavaScript、URL、CSS 等其他上下文所需的编码策略。不要用截取或删除字符来代替正确的输出转义。
截取不是通用的安全措施
截短输入可以实现界面展示、字段长度控制或降低某些资源消耗,但它不能作为 SQL 注入防护、XSS 防护、命令注入防护或身份校验。需要防止 SQL 注入时,应使用参数化查询;输出到 HTML 时,应按上下文正确转义;处理文件名、路径或命令参数时,应按对应规则验证和传递数据。
同样,不能把“截断到固定字节数”直接当作所有数据库字段的长度规则。数据库列可能按字符数、字节数或特定编码定义容量,索引还可能有独立限制。应用层与数据库层应使用一致、经过确认的单位。若字段按字符限制,用字符数校验;若接口按字节限制,用字节数校验,并同时保证编码边界有效。
对于签名、哈希、协议报文、加密数据等内容,任意截断可能改变数据含义或导致验证失败。这些内容必须严格按照对应格式和协议处理,不能为了解决显示乱码而改变原始字节。只有在确认数据是文本,并且了解上下游对编码和长度的约定后,才适合使用文本截断逻辑。
如何检查问题发生在哪一层
遇到乱码时,可以先把问题拆成几个可验证的环节,而不是立刻更换函数:
- 确认输入字节:用
bin2hex()或可靠的日志方式检查原始数据,确认它实际使用什么编码。不要仅凭编辑器里看起来正常就判断。 - 确认截取单位:检查是否在多字节文本上调用了
substr(),以及偏移和长度是否按字节设计。 - 确认截取结果:对比
strlen()、mb_strlen()和十六进制内容,判断结果是否包含不完整编码序列。 - 确认输出环节:检查 HTTP 响应、终端、模板、数据库连接等环节的字符集设置。截取正确并不保证后续显示环境配置正确。
- 确认需求单位:决定业务限制是字节、码点还是用户感知符号,再选择相应函数和测试。
下面示例把几种常见度量并列显示。运行前需要 mbstring;字符串按 UTF-8 编写:
<?php
$text = 'PHP中文🙂';
echo '字节数:', strlen($text), PHP_EOL;
echo '编码字符数:', mb_strlen($text, 'UTF-8'), PHP_EOL;
echo '按字节取前 5 字节:', bin2hex(substr($text, 0, 5)), PHP_EOL;
echo '按字符取前 5 个字符:', mb_substr($text, 0, 5, 'UTF-8'), PHP_EOL;
实用决策清单
- 处理二进制数据或明确的字节段:用
substr(),按字节校验偏移和长度。 - 处理 UTF-8 普通文本的字符截取:用
mb_substr(),明确编码,并确认 mbstring 已启用。 - 需要保留完整的组合符号或 emoji:评估
grapheme_substr(),检查 intl 可用性和失败返回值。 - 限制来自协议、存储或接口:先查清限制按字节、码点、字符还是其他单位定义,再实现对应规则。
- 面对不可信文本:单独做输入校验和上下文相关的输出编码;截取不能代替安全控制。
- 要修复乱码:检查输入编码、切片边界和输出环境;不要把编码不匹配归咎于某一个函数。
总结
substr() 按字节截取字符串,这对二进制数据是有用且明确的行为;但对 UTF-8 中文文本来说,一个字节并不等于一个字符,因此在多字节编码字符中间截取时,结果可能变成无效文本并表现为乱码。按 UTF-8 字符截取时,优先考虑 mb_substr();需要照顾用户感知的组合字符时,再考虑 grapheme_substr()。
最重要的是把需求说清楚:代码中的偏移和长度究竟按字节、编码字符还是字素簇计算?输入是否确实采用预期编码?上限由显示需求、数据库还是协议决定?回答这些问题后,选择函数和验证边界才会可靠。最后还要记住,正确截取只解决边界问题,不负责修复错误编码,也不代替输入验证、数据库参数化或输出转义。



