☰
PHP 7.2 老项目字符集报错怎么修复
2026/9/30 9:04:59 网站建设 项目流程

前言

老项目跑在 PHP 7.2 上,某天开始出现这类现象:页面中文变成??????;用户昵称里的 emoji 报Incorrect string value: '\xF0\x9F\x98\x80' for column ...;接口返回null而不是 JSON;日志里刷出Deprecated: The mbstring.func_overload directive is deprecated。

这些症状看起来散,其实都指向同一个层面:字符集(character set)与编码(encoding)在这条链路的某一环上不一致。一条完整的链路至少有五段:PHP 源文件本身、PHP 的default_charset、数据库连接的字符集、表和列的定义、以及 HTTP 响应的Content-Type。任何一段没对齐,中文就会在那一环上被截断、替换或者直接报错。

PHP 7.2 这个版本号是有意义的,本章要讲的几个点恰好落在它身上:PHP 7.2 弃用了mbstring.func_overload指令(该指令在 PHP 8.0 被彻底移除),并且新增了JSON_INVALID_UTF8_SUBSTITUTE选项(用于在json_encode()遇到非法 UTF-8 时用替代字符而不是直接失败)。这两个变化一个影响老代码怎么改,一个提供了新的兜底手段。

本文按"链路逐段排查 → PHP 层面的编码函数 → 数据库层 → JSON 与输出 → 完整示例"展开。示例在 PHP 7.2 及以上都能跑。

一、链路五段,逐段对齐

环节检查项期望值
源文件编辑器保存编码UTF-8 无 BOM
PHP 配置default_charsetUTF-8
数据库连接连接字符集utf8mb4
表 / 列字符集与排序规则utf8mb4/utf8mb4_unicode_ci
HTTP 输出Content-Typetext/html; charset=UTF-8

排查时按顺序验证,先确认"存进去的和取出来的"是否一致,再确认"页面显示的和数据库里的是否一致",最后才轮到 PHP 代码本身。绝大多数"乱码"问题里,PHP 代码其实没错,错的是某一环的字符集设定。

一个特别容易漏掉的点是源文件本身的编码。如果某个.php文件是 GBK 保存的,里面写的常量字符串就是 GBK 字节,再怎么设置default_charset也救不回来,输出里必然出现乱码。这类文件往往就是那一个"别人接手的老模块"。

二、PHP 层面:把字符串处理函数全部换成 mb_ 系列

PHP 的strlen()、substr()、strpos()是按字节工作的。UTF-8 里一个汉字占 3 个字节,用substr($s, 0, 10)截出来的很可能是一个被砍掉一半的汉字,显示成一个?或者乱码。同理strlen('中')返回 3 而不是 1。

<?php declare(strict_types=1); $s = '中文字符串测试'; echo strlen($s), PHP_EOL; // 18(字节数) echo mb_strlen($s, 'UTF-8'), PHP_EOL; // 6(字符数) echo substr($s, 0, 4), PHP_EOL; // 前半段被切断,可能显示乱码 echo mb_substr($s, 0, 4, 'UTF-8'), PHP_EOL;// 中文字符

老项目在 PHP 5.x 时代常用mbstring.func_overload = 2来让strlen()等函数"透明地"按字符工作。这条指令在 PHP 7.2 被标记为弃用并产生Deprecated警告,在 PHP 8.0 已被移除。所以:


  • 如果你还在用func_overload,升级过程中一定会看到大量弃用警告,且最终必须改代码;

  • 正确的做法是不再依赖任何全局覆盖,显式调用mb_*函数,并显式传编码参数。


需要注意mb_substr()的第四个参数是编码:mb_substr($string, $start, $length, $encoding)。省略它时会用mb_internal_encoding()的值;老代码里经常出现"本地测试是 UTF-8、服务器上mb_internal_encoding还是 ISO-8859-1"的诡异差异,所以显式传 'UTF-8' 是最省心的写法。

另外,处理的起点要先确认输入确实是合法 UTF-8:

<?php declare(strict_types=1); function ensureUtf8(string $s, string $from = 'GBK'): string { if (mb_check_encoding($s, 'UTF-8')) { return $s; } // 不是合法 UTF-8,尝试按来源编码转换;//IGNORE 用于跳过无法映射的字符 $converted = @iconv($from, 'UTF-8//IGNORE', $s); if ($converted !== false) { return $converted; } // iconv 失败时退回 mb_convert_encoding(PHP 5.6+ 支持数组形式的 from_encoding) return mb_convert_encoding($s, 'UTF-8', $from); }

mb_convert_encoding(string $string, string $to_encoding, $from_encoding = null)支持$from_encoding传数组(如['GBK', 'GB2312', 'UTF-8']),PHP 会依次尝试检测。iconv()和mb_convert_encoding()各有所长:iconv的//TRANSLIT能把无法表示的字符近似转写,//IGNORE则直接跳过,但两者都会丢数据,转换前后一定要比对长度或校验结果。

三、数据库层:utf8 不等于 utf8mb4

MySQL 里的utf8(别名utf8mb3)最多只支持 3 字节字符,而 emoji 和一部分生僻汉字是 4 字节的。存进去就会报:

SQLSTATE[HY000]: General error: 1366 Incorrect string value: '\xF0\x9F\x98\x80' for column 'nickname' at row 1

修法分三步,缺一不可:

-- 1. 表与列(示例) ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 2. 库默认字符集(新表生效) ALTER DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
; 3. 服务端配置 /etc/my.cnf 或 my.ini(改完要重启) [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci [client] default-character-set = utf8mb4

连接层是最容易被忽略的一环。只在 PHP 里执行SET NAMES utf8mb4是不够的:一些驱动会用连接配置里的字符集去做字符串转义(mysqli_real_escape_string、PDO 的引号处理),两边不一致时会产生宽字节注入风险。正确做法是让驱动层知道字符集:

<?php declare(strict_types=1); // PDO:把 charset 写进 DSN $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', 'app', 'secret', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false, ] ); // mysqli:用 set_charset(),而不是 exec('SET NAMES ...') $mysqli = new mysqli('127.0.0.1', 'app', 'secret', 'app'); $mysqli->set_charset('utf8mb4'); echo $pdo->query('SELECT @@character_set_client')->fetchColumn(), PHP_EOL; echo $pdo->query('SELECT @@character_set_connection')->fetchColumn(), PHP_EOL;

注意mysql_*系列函数(mysql_query、mysql_set_charset)在PHP 7.0 就已经整组移除,PHP 7.2 上根本不存在。如果你正在维护的项目里还有这些函数名,说明它还没真正跑在 7.2 上,那才是升级的第一道坎。

四、JSON 与输出:失败必须能被看见

json_encode()遇到非法 UTF-8 时返回false,不抛异常、不打警告。接口返回null这类"什么都没说"的故障,很多时候根因就在这。

从PHP 7.2 起可以加JSON_INVALID_UTF8_SUBSTITUTE,让非法字节替换成�,从而拿到部分结果而不是整体失败:

<?php declare(strict_types=1); $bad = "正常前缀\xC3\x28非法字节"; $j1 = json_encode(['v' => $bad]); var_dump($j1); // bool(false) $j2 = json_encode(['v' => $bad], JSON_INVALID_UTF8_SUBSTITUTE); var_dump($j2 !== false); // bool(true) echo json_last_error_msg(), PHP_EOL; // 能拿到可读的错误描述

用JSON_INVALID_UTF8_SUBSTITUTE只是让接口不至于整体失败,它掩盖了"数据里有坏字节"这个事实。正确姿势是先修数据源,再把这个选项当作兜底,同时用json_last_error()/json_last_error_msg()记录日志。

HTTP 输出侧要显式声明编码,不要依赖浏览器猜:

<?php declare(strict_types=1); header('Content-Type: text/html; charset=UTF-8'); // 输出转义时显式给编码,不要省略第三个参数 echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

htmlspecialchars()的第三个参数是编码,省略时会用default_charset(PHP 5.6 起默认就是UTF-8)。但显式写出来能避免"某个环境default_charset被 ini 文件改成了别的值"这类只在生产出现的差异。

实战:完整可运行示例

下面这段程序不依赖数据库,在 PHP 7.2 及以上都能运行,覆盖了字节/字符长度差异、非法 UTF-8 的检测与转换、JSON 的两种失败模式,以及编码声明。

<?php declare(strict_types=1); mb_internal_encoding('UTF-8'); // 显式设置,不依赖 php.ini header('Content-Type: text/plain; charset=UTF-8'); echo 'PHP 版本: ', PHP_VERSION, PHP_EOL; echo 'mb_internal_encoding: ', mb_internal_encoding(), PHP_EOL; echo 'iconv 可用: ', var_export(function_exists('iconv'), true), PHP_EOL, PHP_EOL; $s = '中文字符串测试🙂'; echo "== 1. 字节 vs 字符 ==\n"; printf("strlen() = %d\n", strlen($s)); printf("mb_strlen() = %d\n", mb_strlen($s, 'UTF-8')); printf("substr 前 4 字节 : %s\n", substr($s, 0, 4)); printf("mb_substr 前 4 字符: %s\n", mb_substr($s, 0, 4, 'UTF-8')); echo "\n== 2. UTF-8 合法性检测与转换 ==\n"; $good = '中文'; $bad = "中文\xC3\x28"; // 被截断的 UTF-8 序列 printf("good 合法 UTF-8: %s\n", var_export(mb_check_encoding($good, 'UTF-8'), true)); printf("bad 合法 UTF-8: %s\n", var_export(mb_check_encoding($bad, 'UTF-8'), true)); // GBK 与 UTF-8 互转 $gbk = @iconv('UTF-8', 'GBK', '测试文本'); if ($gbk !== false) { printf("UTF-8 → GBK 字节数: %d\n", strlen($gbk)); $back = iconv('GBK', 'UTF-8', $gbk); printf("GBK → UTF-8 内容 : %s\n", $back); printf("往返一致: %s\n", var_export($back === '测试文本', true)); } // 无法映射的字符://IGNORE 会丢弃,//TRANSLIT 会近似转写 $emoji = 'OK🙂'; printf("iconv //IGNORE : %s\n", var_export(@iconv('UTF-8', 'GBK//IGNORE', $emoji), true)); echo "\n== 3. json_encode 的两种失败模式 ==\n"; $payload = ['name' => '张三', 'note' => "带坏字节\xC3\x28"]; $j1 = json_encode($payload); printf("默认 : %s\n", var_export($j1, true)); printf("json_last_error : %d (%s)\n", json_last_error(), json_last_error_msg()); if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) { // PHP 7.2+ $j2 = json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE); printf("SUBSTITUTE(PHP7.2+): %s\n", $j2); } else { echo "当前版本低于 7.2,没有 JSON_INVALID_UTF8_SUBSTITUTE\n"; } echo "\n== 4. 输出转义要显式给编码 ==\n"; $input = 'A & B "引号" 中文 100%'; printf("htmlspecialchars: %s\n", htmlspecialchars($input, ENT_QUOTES, 'UTF-8')); echo "\n== 5. mbstring.func_overload 状态 ==\n"; $overload = ini_get('mbstring.func_overload'); printf("mbstring.func_overload = %s\n", var_export($overload, true)); if ($overload !== false && $overload !== '' && (int) $overload !== 0) { echo "注意:该指令在 PHP 7.2 起弃用、PHP 8.0 起移除,请改为显式调用 mb_* 函数\n"; }

输出(节选):

== 1. 字节 vs 字符 == strlen() = 21 mb_strlen() = 7 substr 前 4 字节 : 中? <- 被切断,出现替换字符 mb_substr 前 4 字符: 中文字符 == 2. UTF-8 合法性检测与转换 == good 合法 UTF-8: true bad 合法 UTF-8: false == 3. json_encode 的两种失败模式 == 默认 : false json_last_error : 5 (Malformed UTF-8 characters, possibly incorrectly encoded) SUBSTITUTE(PHP7.2+): {"name":"张三","note":"带坏字节�("}

第 1 组里strlen()与mb_strlen()的差值、以及substr()截出来的替换字符,就是老项目里"列表页标题末尾出现一个问号"的根因。第 3 组里json_last_error()返回 5,正好对应JSON_ERROR_UTF8。

常见坑点


  1. ❌ 以为SET NAMES utf8mb4就等于设置好了连接字符集:驱动层并不知道这个改动,mysqli_real_escape_string()仍按连接配置的字符集转义,存在宽字节注入面。


✅用mysqli::set_charset('utf8mb4'),或在 PDO 的 DSN 里写charset=utf8mb4。


  1. ❌ 表用utf8mb4,连接用utf8:读的时候正常,写 emoji 时报Incorrect string value,因为连接层的字符集决定了服务端怎么解释这些字节。


✅库、表、列、连接、客户端五处全部统一为utf8mb4。


  1. ❌ 用substr()/strlen()处理中文:截断在多字节字符中间,产生非法 UTF-8 字节,后面所有环节(正则、JSON、数据库)跟着连锁出错。


✅统一用mb_substr()/mb_strlen(),并且显式传'UTF-8'。


  1. ❌ 依赖mbstring.func_overload让strlen()变成按字符算:该指令在PHP 7.2 起弃用(日志里出现Deprecated),PHP 8.0 起移除,依赖它的代码在升级时行为会突然变化。


✅代码里显式调用mb_*,不再依赖任何全局覆盖。


  1. ❌json_encode()失败只看结果:返回false时不做判断,直接把false写进响应体,客户端收到空内容,排查时毫无线索。


✅每次json_encode()都检查返回值,失败时用json_last_error_msg()记录;PHP 7.2+ 可加JSON_INVALID_UTF8_SUBSTITUTE兜底。


  1. ❌htmlspecialchars($s)不传编码:如果环境的default_charset不是 UTF-8,非 ASCII 字符会被替换成空串或乱码,页面上表现为"某些字直接消失"。


✅显式写htmlspecialchars($s, ENT_QUOTES, 'UTF-8')。


  1. ❌ 用iconv()转码却不检查返回值:遇到无法映射的字符(比如 emoji 转 GBK)时iconv()返回false,被(string)一转换就成了空字符串,数据无声丢失。


✅检查返回值,必要时用//IGNORE或//TRANSLIT并明确接受"会丢字符"这一代价,转换前后对比长度。


  1. ❌ 源文件用了带 BOM 的 UTF-8:BOM 的三个字节会在header()之前被输出,导致Cannot modify header information - headers already sent,同时页面顶部多出几个不可见字符。


✅保存为 "UTF-8 无 BOM",或在 CI 里加一条检测 BOM 的检查。

总结

症状最可能的环节处理
中文变??????连接或表的字符集与数据不一致库/表/列/连接统一utf8mb4
emoji 报Incorrect string value使用 3 字节的utf8(utf8mb3)升级为utf8mb4
列表末尾出现半个字或问号substr()按字节截断改用mb_substr(),显式传编码
接口返回空 /nulljson_encode()遇非法 UTF-8 返回false判返回值 +json_last_error_msg();PHP 7.2+ 可用JSON_INVALID_UTF8_SUBSTITUTE
出现func_overload is deprecated依赖了已在 7.2 弃用的指令改为显式调用mb_*
无法修改 header源文件带 BOM保存为 UTF-8 无 BOM

字符集问题之所以难缠,是因为每一环单独看都"没错",错的是环与环之间没对齐。排查时不要从代码改起,而是按"源文件 → PHP 配置 → 连接 → 表 → 输出"的顺序逐段验证;修的时候记住 PHP 7.2 这个分水岭的两个事实——mbstring.func_overload从此弃用(8.0 移除),JSON_INVALID_UTF8_SUBSTITUTE从此可用——就能把大部分"玄学乱码"变成可复现、可验证的普通 bug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询