2016年(11)
分类: LINUX
2016-08-10 16:16:01
字符编码是怎么回事
0. 概念
字节是计算机的最基本存储单位,一个字节包括8个位.
字符是一种文字的基本单位,比如'A' 是一个字符,'汉' 也是一个字符.
1. 计算机被发明之后,程序员们编写了很多复杂的计算让计算机运行.
但是一个问题是,计算机如何把辛苦计算的结果告知程序员? 假设计算机把计算结果放在某个寄存器,内容是 1010010
总不能让程序员去检测每个引脚的电位吧? 还是得有个显示器.
显示器是依靠点阵来显示图像的. CPU必须告诉显示器,当CPU 把一个字节的数据比如 00101010 放入显示器寄存器的时候,显示器要显示怎样的一个点阵(图像),这个图像就是我们人类可以看得懂的字符. 这样问题就解决了,比如当CPU把00110001放入显示器寄存器时,显示器就显示(控制点阵画出一个图像-字体)字符 "1", 这是一个查表的过程,内存中的值(内码)和字符是一一对应的. 问题是这个对应关系是可以自由确定的,我可以指定显示器把 00110001(内码) 显示为字符 "1",也可以指定显示为字符"2". 这样当然会引起混乱,同一个内码被映射为不同的字符,不利于人们的交流.
美国国家标准学会(ANSI)决定着手解决这个问题, 英语有一个很小的字符集,26个字母再加上一些控制字符和标点符号,7位2进制值就足以表示所有的变化.于是ANSI公布了一个标准的对应关系,以字节为单位. 当内码为 0110001 时,大家都公认它代表字符 "1",在所有显示器都显示为同一个字符. 这样大家就可以按照同一个标准相互交换数据而不会引起误解. 这个表就是一个包含了128项的对应关系, 叫做 "ASCII", 美国信息交换标准代码.
2.对于中国这样不使用ABC字符的国家来说,如何显示自己的文字是一个大问题. 我们可以制定一个内码表,指定一个内码对应一个汉字. (由于中文的字符非常多,所以一个字节是不够的,至少也要有2个字节存储一个内码.) 这是很容易的,只要国家公布一个标准的内码字符对应表,大家都遵照这个表就可以了.但是还是有一些问题要注意:
(1). 即使在中国,计算机还是得能显示英文吧? 而英文的内码已经有 ASCII 标准在先,并且已经有无数的程序已经在这个标准上运行了很多年,成为不可或缺的部分. 所以我们新制定的内码表必须和 ASCII 兼容.
(2) 很多C语言的库函数是以内码0作为字符串结束标志的,为了兼容那些以前就已经编写好,并且运行良好的程序,我们指定的内码中不能含有值为0的字节.
巧合的是,所有的ASCII内码的最高位都为0. 那么我们只要让第一个和第二个字节(一个汉字占用2个字节)的最高位都为1,这样既和ASCII内码区分开来,又不会出现0.符合这个规则的内码(2字节长)理论上一共可以标识 127 * 127 = 16129个字符(实际上只用了6000多个码位,保留了一些,不过也已经够用了,常用的中文字符只有4000多个).
我们国家公布的这个内码标准表就是GB2312.
原有的英文软件可以很好的运行,C的库函数也不用做修改, 比如 strlen("ABC") 在GB2312表示的内码中, 由于GB2312对英文字符的编码是和ASCII完全一样的,所以返回3.对于 strlen("A汉字"), 由于strlen()是以内码为0作为边界的,而所有中文字符的GB2312内码高位都为1,不会出现0,并且每个汉字占用2个字节,所以 strlen 返回5. 对于程序来说只要检查一个字节的最高位,就可以很容易的判断这个字符是中文还是英文字符,非常方便.
"一个字母一个字节,一个汉字2个字节" 的观念深入人心.
有了GB2312之后,汉字显示/存储/交换就基本上没什么问题了.
几乎所有的非英语国家都制定了和GB2312类似兼容ASCII的内码字符对应表.
(BIG5 由于有几个字符的内码和ASCII相同但表示不同的字符,不符合2.(1)条件.所以被认为是有"瑕疵"的.)
3. 很明显,GB2312的码位是不够的, 一个例子就是有很多人的人名电脑里打不出来.(只有6000多个码位,而<<康熙字典>>就收录了4万多个汉字).所以后来有出现了诸如GBK, GB18030以及同期流行于台湾香港的BIG5编码. 虽然编码有些不同, 但是设计思想是一致的: 兼容ASCII,并确保不会有某个字节值为0的内码出现.有一个共同的特点是: 它们都是局部的标准,只流行于某个地区/国家内.
4.由于内码表都是各个国家独自制定的,同一个内码,在不同的国家表示的可能是不同的字符.(除了ASCII字符, ASCII字符在所有国家指定的内码表中都有同样的值.)不利于国家间的信息交换. 于是 UNICODE 应运而生.
UNICODE 采用一种很简单的办法来解决这个问题. 就是采用2个 - UCS-2 (或者4个字节 - UCS-4)字节标识一个字符. 2个字节总共可以表示65535个字符,足够表示世界上的所有语言的所有字符.(汉字不就有4万多个吗,65535怎么够. 我估计只是常用的汉字几千个被编在UCS-2中吧. 目前被正式编码到UNICODE码位的只有不超过65534个, 所以就目前的情况来说,用2个字节是可以的.) 注意 UCS-4, UCS-2 和 ASCII是向下兼容的,只要前面补0就可以了.这点很重要,可以一直扩展下去包含全宇宙的字符.
现在地球上每个字符在所有采用UNICODE字符编码的计算机内都有一个唯一的内码了.
要注意, 除了ASCII字符外,其他国家文字的字符的内码是重新分配过的,不一定和各国原有的编码相同.比如大部分汉字的GB2312内码和UNICODE 内码都是不同的.
5. 很显然,对于英语国家来说,UNICODE内码非常浪费空间,对于UCS-2 浪费了50%的存储空间,对于 UCS-4 则浪费了70%的存储空间. 而且还有一个更大的问题, UNICODE的内码中含有很多 '\0', 原有的C标准库函数没办法处理这些字符串.于是有人发明了一种针对UNICODE的变换规则,把UNICODE字符串中的0去除. 注意这个变换规则不是通过查表实现的,而只要用一些位移操作就可以实现. 这就是UTF8. UTF8 只是 UNICODE内码在存储/传输时的状态. 而从GB2312编码转换到UNICODE编码需要查表. UTF8 和 UNICODE 的关系 与 GB2312 和 UNICODE的关系有本质的不同. UTF8 和 UNICODE 是一个人的两个面孔, GB2312 和 UNICODE 是两个人. 所以,要实现UTF8编码到GB2312编码的转换必须先把 UTF8编码还原为UNICODE编码,再通过查表的方式,把UNICODE编码转化为GB2312编码.
以上,虽然说得不是很严谨(比如GB2312其实是区位码,真正的内码还要给每个字节加上A0, 这些我都没提,免得分散注意力),但是文字编码的原理大致就是那么回事,理解就好了. 要想
详细了解细节Google一下能找到很多资料.
字符编码的编程相关问题.
1. Windows从NT开始,内核使用UNICODE内码. 为了向前兼容,前端使用的还是GB2312内码(中文环境). 所以用 Visual Studio 编写代码时, 如果在CPP文件中写这样一句 const char* pszText = "中文", 编译器让 pszText 指向"中文"的GB2312内码值的内存空间. 当调用 printf(pszText)时, WINAPI 把这个GB2312字符串转化为UNICODE字符串再输出.(WIndows自然知道你的编码是GB2312,因为你在Windows系统中设置的语言区域是中国, CodePage 936. 如果改成其它语言,就会显示为乱码.)
微软非常鼓励Windows程序员用Unicode编写程序,很明显,由于Windows内核就是原生的Unicode环境,调用API时,省却了编码转换的操作,效率更高. 而且一个额外的好处就是不会有乱码. 注意,MS的C/C++编译器把sizeof(wchar_t)设置为2个字节. 由于目前所有的UNICODE字符只有65534个码位(BMP),所以用2个字节是没问题的.
2. Linux系统(比如Ubuntu)现在一般都用UTF8编码了. 我们在Linux下创建CPP文件并添加同样的: const char* pszText = "中文" 编译器会让 pszText 指向"中文"UTF8的内码值的内存空间.Linux的终端可以理解为一个只接收UTF8字符串的显示器. 任何被写到终端的字符流都被认为是是一个UTF8字符流.所以,编程的时候,从外部(文件或者控制台)读入UTF8字符流,转换为wchar_t,然后程序在内部使用宽字符处理,最后再把要输出的宽字符流转换为UTF8字符流并输出到控制台/文件中. 用户程序可以通过环境变量LANG的值得知当前的系统环境所使用的字符编码.由此可见,C库函数的 mbstowcs()/wcstombs()主要是为应付这种情况设计的. 如果要处理XML, HTML 等等有明确指明字符编码的字符流,用专门的字符转换库更为方便.
为什么很多Windows下的C源文件的注释在Linux编辑器下会显示为乱码就很好理解了.
3. 字符编码转换相关的函数和库
Windows 的字符转换函数: MultiByteToWideChar() / WideCharToMultiByte()
Linux 的字符转换库: GLIBC iconv函数组.
C标准库使用的 mbstowcs()和wcstombs()和 locale 相关,用起来很不方便,而且功能有限.
(注意不要假设 wchar_t 的大小, 它可能是4字节也可能是2字节,取决于编译器. 比如 MS VC9.0 (2008) 里, sizeof(wchar_t) = 2, 而在GCC中, sizeof(wchar_t) = 4.)
4. 给定一个ANSI兼容的字符串(包括GB2312,GBK,UTF8等),无法确定它的编码类型,只能猜测.所以不要指望会有一个万能的转换函数.
5. BOM (Byte Order Mark)UNICODE: FF FE / FE FF 和 UTF8: EF BB BF 是不完全靠谱的,仅供参考.
最后说明一点,对于不是专门处理字符编码的程序来说,所有字符编码相关的问题只是显示的问题,并不会影响到程序的内在逻辑.
开始用 Unicode 来编写我们的代码吧.
=============================================================
附 上我自己写的判断(猜测)一个文本是否是UTF8文本的函数:
// 返回值说明:
// 0 -> 输入字符串符合UTF-8编码规则,有可能是UTF8串
// -1 -> 检测到非法的UTF-8编码首字节
// -2 -> 检测到非法的UTF-8字节编码的后续字节.
int IsTextMaybeUTF8 (const char* pszSrc)
{
const unsigned char* puszSrc = (const unsigned char*)pszSrc; // 一定要无符号的,有符号的比较就不正确了.
// 看看有没有BOM表示 EF BB BF
if( puszSrc[0] != 0 && puszSrc[0] == 0xEF &&
puszSrc[1] != 0 && puszSrc[1] == 0xBB &&
puszSrc[2] != 0 && puszSrc[2] == 0xBF)
{
return 0;
}
// 如果没有 BOM标识
BOOL bIsNextByte = FALSE;
int nBytes = 0; // 记录一个字符的UTF8编码占用几个字节.
const unsigned char* pCur = (const unsigned char*)pszSrc;
while( pCur[0] != 0 )
{
if(!bIsNextByte)
{
bIsNextByte = TRUE;
if ( (pCur[0] >> 7) == 0) { bIsNextByte = FALSE; nBytes = 1; bIsNextByte = FALSE; } // 最高位为0, ANSI 兼容的.
else if ((pCur[0] >> 5) == 0x06) { nBytes = 2; } // 右移5位后是 110 -> 2字节编码的UTF8字符的首字节
else if ((pCur[0] >> 4) == 0x0E) { nBytes = 3; } // 右移4位后是 1110 -> 3字节编码的UTF8字符的首字节
else if ((pCur[0] >> 3) == 0x1E) { nBytes = 4; } // 右移3位后是 11110 -> 4字节编码的UTF8字符的首字节
%2