【转】彻底弄懂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。
这些环境变量会对日期格式,数字格式,货币格式,字符处理等多个方面产生影响。
参考资料:
如何设置系统默认的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 ");
那么最后一行的输出也就会不一样。