字符编码旧题新解,纠缠不清的(ASCII,GBK, GB2312,GB18030,UNICODE,UTF-8,UTF-16,UTF-32)

最终还是没有绕开字符编码话题。自认为清晰明了,每每遇到仍是受挫,痛定思痛,学问还需刨根问底,追本溯源,不可走马观花。

一、编码历史

1、问题产生

所谓正本清源,解决问题要从问题产生开始,编码是在将所见闻通过计算机储存和传输的过程中产生的。我们都知道计算机有高电平低电平两种状态,分别用 (1,0) 表示,即二进制,要在计算机里面储存字符,可以将字符也转成 (1,0) 表示。于是我们将不同的字符和 (1,0) 的排列作对应,这种关系便是字符集

2、解决方案

例如英文字母a,指定其排列为01100001,此排列也可以用97 (10进制)0x61 (16进制) 描述,但是其本质还是 (0,1) 排列。因此我们在字符集里面可以这么描述,0x61在解析为字符的时候表示为a。

美国人首先按照这个思路解决了英文储存问题,他们发现一个字节 (8个0或者1) 的排列足以包含所有英文字符,所以单字节的字符集一一ASCII码字符集诞生了,1字节也正好是计算机基本储存,利于读写。

字节为8位也是ASCII诞生期间,多方考虑而制定下的标准

3、字符集发展

后来等到我们要存储汉字的时候中发现需要2个字节才放得下我们要用的汉字于是1980年编订了GB2312-80,1995年制定GBK,它们的编码方式都是双字节。为了兼容少数名族的字又制定了国家标准GB18030-2000,GB18030-2005

目前现行的是GB18030-2005,完全支持Unicode,无需动用造字区即可支持中国国内少数民族文字、中日韩和繁体汉字以及emoji等字符,GB18030采用变长多字节编码,每个字可以由1个、2个或4个字节组成,编码空间庞大,最多可定义161万个字符。

当各个地区都开发自己的解决方案的时候,就有人考虑到如果每个地区单独发展各自字符集,那在传递信息的过程中的解析工作会引入更多问题。首先必需事先正确安装对方字符集编码方式,并且同一个文档里面保存不同地区的文字十分困难。例如用big5编码去解析“中国”会出错,繁字体中没有"国"。

于是Unicode 应运而生,旨在规范全世界统一的字符集,这样就不会有转译问题了。就算地区真的必须要有自己的一套字符集,那也只需单独做和unicode的转化。当然这些字符集都是兼容了ASCII字符集。
在这里插入图片描述


二、字符集和编码方式

1、区别

字符的 (0,1) 排列方式称为字符集,而将排列以某种方式储存在电脑里面的称之为编码方式

在ASCII时代定义了一个字节是8位,ASCII用7位表示,一个字节就代表一个字符。而当有了多字节字符集,尤其是变字节字符集后,一个字符是一位或者多位,这个时候就要通过编码方式来告诉读取程序用几个字节来表示一个字符。

ASCII,GB2312,GBK,GB18030同时规定了字符集和编码方式,而Unicode只规定了字符集,编码方式需要另外指定成UTF-8,UTF-16,UTF-32。如果指定编码方式为UTF8,那字符集一定是unicode。

2、研究工具

我们用java来研究编码方式,java字符串的getBytes方法把字符转化为具体字节,转化的字节就是字符集的 (0,1) 排列。我们要知道Java字符串的内部编码String类内部管理着一个char类型的数组,Java API是这样描述char基本类型的:

char 数据类型(和 Character 对象封装的值)基于原始的 Unicode 规范,将字符定义为固定宽度的 16 位实体
也就是说java自己用的字符集是Unicode,编码方式是UTF-16。
String str = "abc"; 等效于
char data[] = {
    
    'a', 'b', 'c'};  
String str = new String(data); 

str.getBytes(Charset.forName("GBK"))) 。本质是"abc"的Unicode字符集的UTF-16编码方式转为GBK的双字节编码方式 ,最终得到字符的GBK编码。

new String(bytes, Charset.forName('GBK'))本质是从GBK编码向Unicode字符集转换的过程,最终得到以GBK编码表示的字符。

这就是unicode字符集与其他字符集的互转。我们可以通过这钟方式观察字符的不同编码方式。这里有几段用于测试的方法:


//将字节数组以某种编码方式进行解析
public static void fromByteToString(byte[] bytes, String charsetDef) {
    
           
	String str = new String(bytes, Charset.forName(charsetDef)); 
	System.out.println("\r\n"+str);
}

//以二进制格式返回byte
public static String fromByteToBinString(byte b) {
    
    
	String tmp = Integer.toBinaryString(b & 0xFF); 
	for(int i = tmp.length(); i < 8; i++) {
    
    
		tmp = "0" + tmp;
	}
	return tmp;
}
//以16进制格式返回byte

public static String fromByteToHexString(byte b) {
    
    
	String tmp = Integer.toHexString(b & 0xFF);  
	tmp = tmp.length() == 1 ? "0" + tmp.toUpperCase() : tmp.toUpperCase();
	return "0x" +  tmp;
}

//打印字符的编码结果,用二进制或者16进制表示
public static void printRes(String maiamng, String charsetDef) {
    
    
	byte[] characterSet = maiamng.getBytes(Charset.forName(charsetDef));
	System.out.print("\"" + maiamng + "\" " + charsetDef + " 字符集(二进制):");
	for(byte b : characterSet) {
    
    
		String tmp = fromByteToBinString(b);
		System.out.print(tmp + " ");
	}
	System.out.println();
	System.out.print("\"" + maiamng + "\" " + charsetDef + " 字符集(16进制):");
	for(byte b : characterSet) {
    
    
		String tmp = fromByteToHexString(b);
		System.out.print(tmp + "     ");
	}
}

Unicode字符集编码向其它字符集编码的转换过程中会出现转换失败的现象。转换失败时该Unicode码自动用0x3F代替。例如:

String maiamng = "麦芒";
printBytes(maiamng + " ASCII: ", maiamng.getBytes(Charset.forName("ASCII")));  

打印结果:  0x3F 0x3F

3、编码方式实现

下面我们进行编码的研究。

i、ASCII

单字节8位 (0,1) 表示,最高位为0。定义了128个字符,0000 0000是NULL空字符,0111 1111是DEL删除控制符,适用于英文字母,例如字母a:

二进制 10进制 16进制
1100001 97 0x61
String maiamng = "a";
String charsetDef = "ASCII";
printRes(maiamng,charsetDef);

"a" ASCII 字符集(二进制):01100001 
"a" ASCII 字符集(16进制):0x61    

ii、GB2312

双字节16位 (0,1) 表示,用于中文汉字,gb2312为了兼容ascii码,设计成其最高位为1,这样就和ascii码 (设计的时候最高位为0) 区分开了。所以,当计算机用gb2312读取一段字符集的时候,将最高位为0的识别为ascii,一字节表示一个字符,最高位为1的则两个字节为一个字符。 (既规定了字符集,也规定了编码方式) 例如 ”麦“:

二进制 10进制 16进制
11000010 11110011 49970 0xC2 0xF3
String maiamng = "a麦芒";
String charsetDef = "gb2312";
printRes(maiamng,charsetDef);

"a麦芒" gb2312 字符集(二进制):01100001 11000010 11110011 11000011 10100010 
"a麦芒" gb2312 字符集(16进制):0x61     0xC2     0xF3     0xC3     0xA2     

假如计算机遇到一段数据,高位为1的只有奇数位只能乱码了。

String charsetDef = "gb2312";
byte[] bytes = {
    
     (byte) 0x61, (byte) 0xC2, (byte) 0xF3,(byte) 0xC3, (byte) 0xA2};   
fromByteToString(bytes,charsetDef); //a麦芒
 
bytes = {
    
     (byte) 0x61, (byte) 0xC2, (byte) 0xF3,(byte) 0xC3};  
fromByteToString(bytes,charsetDef); //a麦?

bytes = {
    
     (byte) 0x61, (byte) 0xC2,(byte) 0xC3, (byte) 0xA2};   
fromByteToString(bytes,charsetDef); //a旅?

iii、GBK

双字节16位 (0,1) 表示,用于中文汉字,GBK字符集兼容GB2312字符集,且GBK编码方式合GB2312编码方式一致。

String maiamng = "a麦芒";
String charsetDef = "gb2312";
printRes(maiamng,charsetDef);

"a麦芒" gb2312 字符集(二进制):01100001 11000010 11110011 11000011 10100010 
"a麦芒" gb2312 字符集(16进制):0x61     0xC2     0xF3     0xC3     0xA2     

这里有张展示ASCII,GB2312,GBK区别的图片。
在这里插入图片描述

我们通过可以看到817E这个编码在gbk里面定义但在gb2312里面未定义。

String charsetDef = "gbk";
byte[] bytes = {
    
     (byte) 0x81, (byte) 0x7e};   
fromByteToString(bytes,charsetDef); //亊

charsetDef = "gb2312";
bytes = {
    
     (byte) 0x81, (byte) 0x7e};   
fromByteToString(bytes,charsetDef); //?~

iv、GB18030

变长1 字节 (8位),2 字节 (16位),4 字节 (32位) 表示,通过字节的范围特征判断字符的字节数,我们这里看下GB18030不同字节的范围定义图。
在这里插入图片描述
比如有一段4字节编码 0x81 0x30 0x81 0x300x81在双字节和四字节的第一字节范围,所以它可能是双字节编码或者四字节编码,0x30小于双字节的第2个字节最低码位,所以只可能是四字节。

这里注意的是eclipse的console是无法打印出4字节编码的中文,因为它用的好是GBK。并且windows操作系统的中文字符用的默认是gbk,好像有些特殊原因导致4字节的gb18030无法在windows显示,因此用ANSI保存的文本文档也是GBK,所以我暂时无法打印出4字节的GB18030。而且我实在没找到GB18030标准的全部字符集 (在中国国家标准系统里面找到了但是都无法下载),这部分的例子等到后面研究出来再进行补充

v、UNICODE

gb18030不仅是字符集,而且还是编码方式 (变长的1,2,4字节) ,而unicode只是字符集,它有单独定义的编码方式。在介绍unicode的编码方式之前,unicode有几个概念。

  1. 代码点 (code point) :指在Unicode编码表中一个字符所对应的代码值。如汉字“一”的代码点是 U+4E00 ,英文字母“A”的代码点是U+0041
  2. 代码单元 (code unit) :规定16bits的存储容量就是一个代码单元。
  3. 代码级别 (code plane): Unicode字符集分为17个代码级别,其中代码点/u0000-/uFFFF为第一级别一一基本多语言级别 (basic multilingual plane),可以用一个代码单元存储。其余16个附加级别 从0x10000~0x10FFFF,需要两个代码单元。
  4. 替代区域 (surrogate area) :在多语言级别中,U+D800~U+DFFF这2048值没有表示任何字符,被称为Unicode的替代区域。

unicode的三种编码方式分别是UTF-8UTF-16UTF-32

i、UFT-8

一种变长的编码方案,使用1~4个字节来存储。

  1. 对于单字节的字符,字节的第一位设为0,兼容ASCII码。
  2. 对于n字节的字符 (n > 1) ,第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个字符的 Unicode 码。
String maiamng = "a麦芒";
String charsetDef = "UTF-8";
printRes(maiamng,charsetDef);

"a麦芒" UTF-8 字符集(二进制):01100001 11101001 10111010 10100110 11101000 10001010 10010010 
"a麦芒" UTF-8 字符集(16进制):0x61     0xE9     0xBA     0xA6     0xE8     0x8A     0x92  

我们可以发现,“a” (01100001) 占了1个字节,“麦” (11101001 10111010 10100110)“芒” (11101000 10001010 10010010 ) 都占3个字节。通过这个我们也可以知道 “麦” 的unicode的代码点为 0x9EA6“芒” 的unicode代码点为 0x8292。我们从unicode字符查询上查看也得出相同结论。
在这里插入图片描述
在这里插入图片描述

ii、UTF-32

一种固定长度的编码方案,不管字符编号大小,始终使用 4 个字节来存储。

String maiamng = "a麦芒";
String charsetDef = "UTF-32";
printRes(maiamng,charsetDef);

"a麦芒" UTF-32 字符集(二进制):00000000 00000000 00000000 01100001 00000000 00000000 10011110 10100110 00000000 00000000 10000010 10010010 
"a麦芒" UTF-32 字符集(16进制):0x00     0x00     0x00     0x61     0x00     0x00     0x9E     0xA6     0x00     0x00     0x82     0x92     

iii、UTF-16

UTF-16使用 2 个或者 4 个字节来存储,当编码长度是4字节时,前2字节必然不可单独解析。utf-16就是通过之前提的unicode的替代区域U+D800-U+DFFF实现变长判断。

  1. 如果 代码点 < U+10000 ,也就是处于Unicode的基本多语言级别中。这样16bits (一个代码单元) 就足够表示出字符的Unicode值。
  2. 如果U+10000 <= 代码点 < U+10FFFF,也就是处于附加级别中。UTF-16用2个代码单元来表示,并且正好将每个16位都控制在替代区域U+D800~U+DFFF中了,具体操作如下:
    分别初始化2个16位无符号的整数 —— W1和W2。其中W1=110110yyyyyyyyyy, y最小取0,最大取1。所以W1范围是1101100000000000 ~ 1101101111111111 (0xD800~0xDBFF) , W2 = 110111xxxxxxxxxx。x最小取0,最大取1,所以W2范围是1101110000000000 ~ 1101111111111111 (0xDC00~OxDFFF)
    然后将 U 减去 0x10000 的结果 (20位) 的高10位分配给W1的低10位,将U的低10位分配给W2的低10位。这样就可以将20bits的代码点U拆成两个16bits的代码单元。而且这两个代码点正好落在替代区域U+D800~U+DFFF中。
    但是也意味着UTF-16不能表示21位的代码点。

我们找一个unicode附加级别的代码点进行说明。这里我用1D578 (附加级别) 来作为例子。
在这里插入图片描述

0x1D578减去0x10000得到0xD578,转为进制代码是0000 1101 0101 0111 1000。按照上面所述我们得到W1 = 1101100000110101 (0xD835),W2 = 1101110101111000 (0xDD78)

由于在eclipse里面的console默认是GBK编码 (参照此文章) ,因此无法打印某些unicode字符,为了实验UTF-16,我们在本机 (我的是win10系统) 建一个UTF-16保存的文本文档。然后用下面这段writeFile程序将编码的字节写入文本中进行查看。

public static void writeFile(byte[] contentInBytes,String filePath) {
    
    
	File file = new File(filePath);
	try {
    
    
		FileOutputStream fop = new FileOutputStream(file,true); // 追加
		fop.write(contentInBytes);
		fop.flush();
		fop.close();
		System.out.println("Done");
	} catch (Exception e) {
    
    
		e.printStackTrace();
	} 
}
String filePath = "C:\\\\Users\\\\zhang\\\\Desktop\\\\readUTF16.txt";
byte[] bytes = new byte[] {
    
    (byte)0xD8, (byte)0x35,(byte)0xDD, (byte)0x78};

writeFile(bytes,filePath);

得到结果:
在这里插入图片描述
这里的BE表示大端模式,下面简短讲解下大小端。

iv、大小端问题(简单讲一讲)

计算机基本存储单位是8位,就是说他们的物理地址是分开的,号码有大小,地址有高低,低地址储存高位是大端储存,用 0xFE 0xFF (BE) 表示,低地址储存低位就是小端储存,用0xFF 0xFE (LE) 表示。假设有两个位置A,B (地址A小于地址B) ,每个位置只能放一个在 0~9 之间的数字, 例如数字65的存放模式可能是A=6;B=5(大端) 也可能是B=6;A=5 (小端)

上面例子0xD835 (W1)0xDD78 (W2) 的大小端如下:

模式 地址(低→高)
大端 0xD8,0x35,0xDD,0x78
小端 0x35,0xD8,0x78,0xDD
大小端问题本人不了解,这里的小端为什么不是0x78,0xDD,0x35,0xD8 ? 现在留位,后面有思路的时候再写大小端的文章。

一般来说,大端小端是CPU决定的,但是ASCII、GB2312、GBK、GB18030既是字符集也是编码方式,所以它们的大小端模式定死了,Unicode由于没有指定编码方式,所以它的大小端模式则是由CPU决定。

我们再创建一个以UTF-16小端模式保存的文本文档,然后用小端模式编码写入文件查看结果。

String filePath = "C:\\\\Users\\\\zhang\\\\Desktop\\\\readUTF16_LE.txt";
byte[] bytes = new byte[] {
    
    (byte)0x35, (byte)0xD8,(byte)0x78, (byte)0xDD};

writeFile(bytes,filePath);

在这里插入图片描述

三、实战

那当从外部(文件或者网络)读取字节码 (0,1) 后,要将其正确显示出来必须知道其编码方式。

我们在本机 (win10) 新建 一个文本文档,用ansi保存。
在这里插入图片描述

在windows下ansi是GBK编码。由于GB18030兼容GBK,所以我们用GB18030或者GBK解析都可以。这里再贡献一段readFile功能。

public static void readFile(String filePath, String charsetDef) {
    
    		
	try {
    
    
		File file = new File(filePath);
		FileInputStream in = new FileInputStream(file);
		
		byte[] bytes = new byte[1];
		int byteindex = 0;
		int n = -1;
		//循环取出数据
		String hexString = "";
		String binString = "";
		while ((n = in.read()) != -1) {
    
    
			byte nByte = (byte)(n & 0xFF);
			//转成16进制数
			String tmp = fromByteToHexString(nByte);
			hexString += tmp + "     ";
		
			String tmp2 = fromByteToBinString(nByte);  
			binString += tmp2 + " ";
			
			byteindex++;
			byte[] bytesTmp = new byte[byteindex];
			System.arraycopy(bytes, 0, bytesTmp, 0, bytes.length);
			
			bytes = new byte[byteindex];
			System.arraycopy(bytesTmp, 0, bytes, 0, bytesTmp.length);
			bytes[byteindex-1] = nByte;
	
		}
		String res = new String(bytes,charsetDef);
		System.out.println("readFile \"" + res + "\" " + charsetDef + " 字符集(二进制):" + binString);
		System.out.println("readFile \"" + res + "\" " + charsetDef + " 字符集(16进制):" + hexString);
			
	} catch (Exception e) {
    
    
		e.printStackTrace();
}
String charsetDef = "GB18030";
String filePath = "C:\\\\Users\\\\zhang\\\\Desktop\\\\readANSI.txt";
readFile(filePath,charsetDef);

readFile "麦芒" GB18030 字符集(二进制):11000010 11110011 11000011 10100010 
readFile "麦芒" GB18030 字符集(16进制):0xC2     0xF3     0xC3     0xA2     

当文件用UTF-8保存,我们就用UTF-8解析,做到编码正确,也就不会有乱码。(写完文章后,更加深知以前对于这句话的理解太流于形式。)

这篇文章歇歇停停写了一个月(2021-02-24开始,到2021-03-23发布)* ,幸亏有始有终地完成了,过程中看了不少文章,下面列出我决定对这个问题理解和描述比较仔细地文章。

参考文章:
https://blog.csdn.net/guxiaonuan/article/details/78678043
https://blog.csdn.net/wh_java01/article/details/53894736
https://cloud.tencent.com/developer/article/1343240
https://www.cnblogs.com/gzhnan/articles/4307717.html
https://blog.csdn.net/zhoubl668/article/details/6914018
https://zhuanlan.zhihu.com/p/27827951

猜你喜欢

转载自blog.csdn.net/kiramario/article/details/114987077
今日推荐