网上有一个流传多年的段子。这个段子大致是说,若你在简体中文版本的 Windows 系统下,用系统自带的记事本程序,以默认的 ANSI 编码保存「联通」两个字,那么重新打开后「联通」二字就消失了。如果我没记错的话,还曾有好事者据此编排,认定 Windows 背后的微软和联通有仇,故意不让联通二字正常显示。
当然,这个说法肯定是假的。但是这一现象背后的原因究竟是什么呢?实际上,网络上也有不少文章专门解释了这个问题。虽然以我的经验,能够看懂。但是若是「三秒变小白」,这些文章就不令人满意了。这是此文的缘由。
在介绍 Windows 记事本乱码问题之前,我们先来了解一些基础知识。
Wikipedia 的介绍
,UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,也是一种前缀码。它可以用来表示Unicode标准中的任何字符,且其编码中的第一个字节仍与ASCII兼容,这使得原来处理ASCII字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字的应用中,优先采用的编码。
和 UTF-16 和 UTF-32 不同,UTF-8 采用了特别的编码规则,因此不存在「大端序」和「小端序」的差异。
码点起始值
码点终止值
字节序列长度
Byte 1
Byte 2
Byte 3
Byte 4
Byte 5
Byte 6
0xxxxxxx
U+0080
U+07FF
110xxxxx
10xxxxxx
U+0800
U+FFFF
1110xxxx
10xxxxxx
10xxxxxx
U+10000
U+1FFFFF
11110xxx
10xxxxxx
10xxxxxx
10xxxxxx
U+200000
U+3FFFFFF
111110xx
10xxxxxx
10xxxxxx
10xxxxxx
10xxxxxx
U+4000000
U+7FFFFFFF
1111110x
10xxxxxx
10xxxxxx
10xxxxxx
10xxxxxx
10xxxxxx
具体来说,UTF-8 编码的结果,其长度是变长的。但是,除了每个字符编码的「第一个字节」之外,其余所有字节,二进制表示都以
10
开始。这样,每个字符的第一个字节就变得特殊起来。
一方面,首字节不以
10
开始,表达了「字符开始」这一信息;
另一方面,除了 ASCII 范围内的单字节编码,其余多字节编码时,首字节的开始有多少位
1
,就记录了这个字符占了多少个字节。
因此,一方面,如果一篇文档只包含 ASCII 字符,那么 UTF-8 编码和 ASCII 编码得到的结果完全相同。这就保证了兼容性。另一方面,这样的编码规则保证了字节顺序的确定性,因此没有大端序和小端序的差异,也就不需要 BOM。
margen
对 Windows 记事本程序做的逆向工作。没有他的工作,本文不至于这样精彩。光荣属于前辈!
margen 的逆向分析
,在打开文件的过程中,记事本程序会调用
fDetermineFileType
来判断文件的编码类型。翻译成 C 语言代码,大致如下。,Windows 记事本在以 ANSI 保存文件时,没有任何多余的动作,直接将 buffer 中的内容通过
WriteFile
系统调用写入到
txt
文件当中。
我们以
010editor
打开保存了「联通」二字文件看看。
可以看到,在简体中文 Windows 下,以记事本保存「联通」两个字。那么保存得到的
txt
文件内,就仅有
0xC1AACDA8
这些内容。而
0xC1AA
和
0xCDA8
正是「联通」两个字的 GBK 编码。
margen 的逆向分析
,在打开文件的过程中,记事本程序会调用
fDetermineFileType
来判断文件的编码类型。翻译成 C 语言代码,大致如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
int __stdcall fDetermineFileType (LPVOID lpBuffer,int cb) { int iType = 0 ; WORD wSign = 0 ; if ( cb <= 1 ) return 0 ; wSign = *(PWORD)lpBuffer; switch ( wSign ) { case 0xBBEF : { if ( cb >= 3 && (PBYTE)lpBuffer[3 ] == 0xBF ) iType = 3 ; } break ; case 0xFEFF : { iType = 1 ; } break ; case 0xFFFE : { iType = 2 ; } break ; default : { if ( !IsInputTextUnicode( lpBuffer, cb ) ) { if ( IsTextUTF8( lpBuffer, cb ) ) iType = 3 ; } else iType = 1 ; } } return iType; }
首先,代码从文件头部取出了前 2 个字节,然后走
switch
分支判断。
若前两个字节是
0xBBEF
,且文件第三个字节是
0xBF
,则组成 UTF-8 的 BOM(虽然 UTF-8 不需要)。那么据此判断文件编码是 UTF-8。
若前两个字节是
0xFEFF
,那么这是小端序 UTF-16 的 BOM。据此判断文件编码是(Windows 所谓的)Unicode 编码。
若前两个字节是
0xFFFE
,那么这是大端序的 UTF-16 的 BOM。据此判断文件编码是(Windows 所谓的)Unicode Big Endian 编码。
否则,则需要做更深层次的判断。注意到,
iType
被初始化为
0
,代表 ANSI 编码(简体中文下是 CP936,相当于是 GBK 编码)。若已走到了
default
分支,要函数返回
0
,当且仅当
IsTextUTF8( lpBuffer, cb )
为
false
才行。然而,这个函数的写法是这样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
BOOL IsTextUTF8 ( LPSTR lpBuffer, int iBufSize ) { int iLeftBytes = 0 ; BOOL bUtf8 = FALSE; if ( iBufSize <= 0 ) return FALSE; for ( int i=0 ;i<iBufSize;i++) { char c = lpBuffer[i]; if ( c < 0 ) bUtf8 = TRUE; if ( iLeftBytes == 0 ) { if ( c >= 0 ) continue ; do { c <<= 1 ; iLeftBytes++; } while ( c < 0 ); iLeftBytes--; if ( iLeftBytes == 0 ) return FALSE; } else { c &= 0xC0 ; if ( c != (char )0x80 ) return FALSE; else iLeftBytes--; } } if ( iLeftBytes ) return FALSE; return bUtf8; }
我们重点看
for
循环内部的逻辑。首先,
char c = lpBuffer[i];
从 buffer 中取出一个字节,保存在
signed char
当中。而后判断
if( c < 0 )
。因为
c
是有符号的
char
,所以
c < 0
意味着最高位是
1
。这就意味着该字符肯定不是 ASCII 字符,可能是一个 UTF-8 字符。因此将
bUtf8
置为
true
。
而后,在
if( iLeftBytes == 0 )
分支中,我们看到
c <<= 1; iLeftBytes++;
的
do-while
循环。这是在判断 UTF-8 编码的首字符中,有多少个前缀的
1
。根据 UTF-8 的编码规则,这个数值就是该 UTF-8 字符的编码长度,记录在
iLeftBytes
当中。
接下来,根据
iLeftBytes
的大小,逐一检查后续的字节,是否以
10
开头。一旦发现有不满足条件的字节,就能判定当前文档不是 UTF-8 编码的。或是(在
for
循环结束之后)发现
iLeftBytes
尚未自减到 0 就已经到了文档末尾,则也可以判定当前文档不是 UTF-8 编码的。
也就是说,这个函数的逻辑,是根据 UTF-8 编码规则,全文扫描。若发现有一个字符不符合 UTF-8 的编码规则,则返回
false
;否则若全文都符合 UTF-8 的编码规则,则返回
true
。
https://liam.page/2017/08/27/mojibake-in-Windows-Notepad-due-to-wrong-encoding-detect/
版权声明:
本博客所有文章除特别声明外,均采用
BY-NC-SA
许可协议。转载请注明出处!