UTF-8、Unicode、宽字符、locale

最近使用到了wchar_t类型,所以准备详细探究下,没想到水还挺深,网上的资料大多都是复制粘贴,只有个结论,也没个验证过程。本文记录探究的过程及结论,如有不对请指正。

Unicode、UCS

UCS(Universal Character Set)本质上就是一个字符集。
Unicode的开发结合了国际标准化组织所制定的 ISO/IEC 10646,即通用字符集(
Universal Character Set, UCS)。Unicode 与 ISO/IEC 10646 在编码的运作原理相同,但 The Unicode Standard 包含了更详尽的实现信息、涵盖了更细节的主题,诸如比特编码(bitwise encoding)、校对以及呈现等。摘自(Unicode)
所以也可以简单的理解为,Unicode和UCS等价,都是字符集。

UCS编码的长度是31位,可用4个字节表示,可以表示2的31次方个字符。如果两个字符的高位相同,只有低16位不同,则它们属于同一平面,所以一个平面由2的16次方个字符组成。目前大部分字符都位于第一个平面称为BMP。BMP的编码通常以U+xxxx这种形式表示,其中x是16进制数。
比如中文“你”对应的UCS编码为U+4f60,“好”对应的UCS编码为U+597d。更多中文编码可以在Unicode编码表中查询。

有了UCS编码,任何一个字符在计算机中都最多可以用四个字节来表示,称为码点。

UTF8

现在有了UCS字符集,那么一个字符在计算机中真的要按四个字节(UTF-32)来存储吗?
答案是否定的,一方面每个字符都按四字节来存储非常浪费空间,因为大部分字符都在BMP,只有后16位有效,前16位都是0。另一方面这与c语言不兼容,在c语言中0字节表示字符串的结尾,库函数strlen等函数依赖这一点,如果按UTF-32存储,其中有很多0字节并不表示字符串结尾。

Ken Thompson发明了UTF-8编码,可以很好的解决以上问题。Unicode 和 UTF-8 之间的转换关系表如下:

码点起值 码点终值 字节序列 Byte1 Byte2 Byte3 Byte4 Byte5 Byte6
U+0000 U+007F 1 0xxxxxxx
U+0080 U+07FF 2 110xxxxx 10xxxxxx
U+0800 U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 U+1FFFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U+200000 U+3FFFFFF 5 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U+4000000 U+7FFFFFFF 6 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

第一个字节要么最高位是0(ASCII码),要么最高位都是1,最高位之后的1的个数决定了后面的有多少个字节也属于当前字符编码,例如111110xx,最高位之后还有4个1,表示后面的4个字节属于当前编码。后面的每个字节的最高位都是10,可以和第一个字节区分开来。后面字节的x表示的就是UCS编码。所以UTF-8就像一列火车,第一个字节是车头,包含了后面的哪几个字节也属于当前这列火车的信息,后面的字节是车厢,其中承载着UCS编码。

以中文字符“你”为例,对应的Unicode为"U+4f60",二进制表示为0100 1111 0110 0000。按照表中的规则编码成UTF-8就是11100100 10111101 10100000(0xe4 0xbd 0xa0)。

结论

Unicode本质是字符集,在这个集合中的任意一个字符都可以用一个四字节来表示。

UTF-8是编码规则,可以通过这个规则将Unicode字符集中任一字符对应的字节转换为另一个字节序列。UTF-8只是编码规则中的一种,其它的编码规则还有UTF-16,UTF-32等。

宽字符类型wchar_t

在介绍宽字符前先了解下locale。因为多字节字符串和宽字符串的转换和locale相关。

locale

什么是locale

区域设置(locale),也称作“本地化策略集”、“本地环境”,是表达程序用户地区方面的软件设定。在linux执行locale可以查看当前locale设置:

ubuntu@VM-0-16-ubuntu:~$ locale
LANG=zh_CN.UTF-8
LANGUAGE=
LC_CTYPE="zh_CN.UTF-8"
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_PAPER="zh_CN.UTF-8"
LC_NAME="zh_CN.UTF-8"
LC_ADDRESS="zh_CN.UTF-8"
LC_TELEPHONE="zh_CN.UTF-8"
LC_MEASUREMENT="zh_CN.UTF-8"
LC_IDENTIFICATION="zh_CN.UTF-8"
LC_ALL=

可以将locale理解为一系列环境变量。locale环境变量值的格式为language_area.charset。languag表示语言,例如英语或中文;area表示使用该语言的地区,例如美国或者中国大陆;charset表示字符集编码,例如UTF-8或者GBK。
这些环境变量会对日期格式,数字格式,货币格式,字符处理等多个方面产生影响。

参考资料:

  1. locale wiki
  2. Environment Variables

如何设置系统默认的locale

修改配置文件/etc/default/locale,比如要将locale设为zh_CN.UTF-8,添加如下语句LANG=zh_CN.UTF-8

locale环境变量有何作用

以LC_TIME为例,该变量会影响strftime()等函数。size_t strftime(char *str, size_t maxsize, const char *format, const struct tm *timeptr)
strftime根据format中定义的格式化规则,格式化结构timeptr表示的时间,并把它存储在str中。

#include <locale.h>
#include <stdio.h>
#include <time.h>

int main () {
	time_t currtime;
	struct tm *timer;
	char buffer[80];

	time( &currtime );
	timer = localtime( &currtime );

	printf("Locale is: %s\n", setlocale(LC_TIME, "en_US.iso88591"));
	strftime(buffer,80,"%c", timer );
	printf("Date is: %s\n", buffer);

	printf("Locale is: %s\n", setlocale(LC_TIME, "zh_CN.UTF-8"));
	strftime(buffer,80,"%c", timer );
	printf("Date is: %s\n", buffer);

	printf("Locale is: %s\n", setlocale(LC_TIME, ""));
	strftime(buffer,80,"%c", timer );
	printf("Date is: %s\n", buffer);
	return(0);
}

编译后运行结果如下:

Locale is: en_US.iso88591
Date is: Sun 07 Jul 2019 04:08:39 PM CST
Locale is: zh_CN.UTF-8
Date is: 2019年07月07日 星期日 16时08分39秒
Locale is: zh_CN.UTF-8
Date is: 2019年07月07日 星期日 16时08分39秒

可以看到对LC_TIME设置不同的值后,调用strftime()会产生不同的结果。
char* setlocale (int category, const char* locale);可以用来对当前程序进行地域设置。
category:用于指定设置影响的范围,LC_CTYPE影响字符分类和字符转换,LC_TIME影响日期和时间的格式,LC_ALL影响所有内容。
locale:用于指定变量的值,上例中分别使用了"en_US.iso88591","zh_CN.UTF-8"和空字符串"",""表示使用当前操作系统默认的区域设置。

参考资料:
setlocale()

为什么需要宽字符类型

“你好”对应的Unicode分别为"U+4f60"和"U+597d”,对应的UTF-8编码分别为“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”

多字节字符串在编译后的可执行文件以UTF-8编码保存

#include <stdio.h>
#include <string.h>

int main(void) {
	char s[] = "你好";
	size_t len = strlen(s);
	printf("len = %d\n", (int)len);
	printf("%s\n", s);
	return 0;
}

编译后执行,输出如下:

len = 6
你好

od编译后的可执行文件,可以发现"你好"以UFT-8编码保存,也就是“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”6个字节。
strlen()函数只管结尾的0字节而不管字符串里存的是什么,所以len是6,也就是“你好”的UFT-8编码的字节数。
printf("%s\n", s);相当于将“0xe4 0xbd 0xa0”和“0xe5 0xa5 0xbd”6个字节write到当前终端的设备文件,如果当前终端的驱动程序能识别UTF-8编码就能打印汉字,如果当前字符终端的驱动程序不能识别UTF-8就打印不出汉字。

宽字符串在编译后可执行文件中以Unicode保存

#include <wchar.h>
#include <stdio.h>
#include <locale.h>

int main(void) {
	setlocale(LC_ALL, "zh_CN.UTF-8");   //设置locale
	wchar_t s[] = L"你好";
	size_t len = wcslen(s);
	printf("len = %d\n", (int)len);
	printf("%ls\n", s);
	return 0;
}

编译后执行,输出如下:

len = 2
你好

对编译后的可执行文件执行od命令,可以找到如下这些字节:

193 0003020 001  \0 002  \0   `   O  \0  \0   }   Y  \0  \0  \n  \0  \0  \0
194                00020001        00004f60        0000597d        0000000a

00004f60正是“你”对应的Unicode,0000597d是“好”对应的Unicode。所以对于宽字符串是按Unicode保存在可执行文件中的。
wchar_t是宽字符类型。在字符常量或者字符串前加L就表示宽字符常量或者宽字符串。所以len是2。
wcslen()和strlen()不同,不是见到0字节就结束而是要遇到UCS编码为0的字符才结束。
目前宽字符在内存中以Unicode进行保存,但是要write到终端仍然需要以多字节编码输出,这样终端驱动程序才能识别,所以printf在内部把宽字符串转换成多字节字符串,然后write出去。这个转换过程受locale影响,setlocale(LC_ALL, "zh_CN.UTF-8");设置当前进程的LC_ALL为zh_CN.UTF-8,所以printf将Unicode转成多字节的UTF-8编码,然后write到终端设备。如果将setlocale(LC_ALL, "zh_CN.UTF-8");改为setlocale(LC_ALL, en_US.iso88591):打印结果中将不会输出"你好"。

一般来说程序在内存计算时通常以宽字符编码,存盘或者网络发送则用多字节编码。

多字节字符串和宽字符串相互转换

c语言中提供了多字节字符串和宽字符串相互转换的函数。

#include <stdlib.h>
size_t mbstowcs(wchar_t *dest, const char *src, size_t n);
size_t wcstombs(char *dest, const wchar_t *src, size_t n);

mbstowcs()将多字节字符串转换为宽字符串。
wcstombs()将宽字符串转换为多字节字符串。
考虑下面的例子:

#include <locale.h>
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <wchar.h>
#include <string.h>

wchar_t* str2wstr(const char const* s) {
	const size_t buffer_size = strlen(s) + 1;
    wchar_t* dst_wstr = (wchar_t *)malloc(buffer_size * sizeof (wchar_t));
    wmemset(dst_wstr, 0, buffer_size);
    mbstowcs(dst_wstr, s, buffer_size);	
	return dst_wstr;
}

void printBytes(const unsigned char const* s, int len) {
	for (int i = 0; i < len; i++) {
		printf("0x%02x ", *(s + i));
	}
	printf("\n");
}

int main () {
	char s[10] = "你好";          //内存中对应0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
	wchar_t ws[10] = L"你好";  //内存中对应0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

	printf("Locale is: %s\n", setlocale(LC_ALL, "zh_CN.UTF-8")); //Locale is: zh_CN.UTF-8
	printBytes(s, 7);       //0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
	printBytes((char *)ws, 12);  //0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

	printBytes((char *)str2wstr(s), 12); //0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

	return(0);
}

编译后,执行结果如下:

Locale is: zh_CN.UTF-8
0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00 
0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 
0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00 

第二行输出也印证了我们之前说的多字节字符串在内存中以UTF-8存储,"0xe4 0xbd 0xa0 0xe5 0xa5 0xbd"正是"你好"的UTF-8编码。
第三行输出印证了之前说的宽字符串在内存中以Unicode存储,"0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00"正好是宽字符串L"你好"对应的Unicode。
setlocale(LC_ALL, "zh_CN.UTF-8")设置locale,程序将以UTF-8解码宽字符串。调用mbstowcs()后,可以看到“你好”的UTF-8编码 "0xe4 0xbd 0xa0 0xe5 0xa5 0xbd 0x00"确实被转换成了“你好”对应的Unicode "0x60 0x4f 0x00 0x00 0x7d 0x59 0x00 0x00 0x00 0x00 0x00 0x00"。
如果将setlocale(LC_ALL, "zh_CN.UTF-8")换成setlocale(LC_ALL, "en_US.iso88591 ");那么最后一行的输出也就会不一样。

posted @ 2019-07-08 12:40  gatsby123  阅读(7052)  评论(3编辑  收藏  举报