看如下最简单的C程序:
int main(int argc, char** argv) { printf("ABC"); return 0; }
本文就是力图描述这个程序的执行过程,具体来说,就是从调用printf(),到“ABC”三个字符显示到显示器上,到底是一个什么样的过程。
使用strace跟踪执行上面的程序,可以发现,最终导致调用了 write(1, "ABC", 3)。也就是最终的效果就是向终端设备写入三个字节。
现在我们把简单的ABC换成中文试试。
int main(int argc, char** argv) { printf("中文"); return 0; }
同样使用strace跟踪执行,发现最终调用的是: write(1, "\344\270\255\346\226\207", 6)。为便于查看,换成16进制表示,就是向终端设备写入 E4 B8 AD E6 96 87 共6个字节。进一步说,其实就是“中文”这两个字的UTF-8编码。之所以是UTF-8编码,是因为这个C源文件本身就是使用UTF-8编码的。下面我们使用GBK对同样内容的源代码进行编译运行,再用strace跟踪,会发现最终调用的是: write(1, "\326\320\316\304", 4),换成16进制,就是向终端写入 D6 D0 CE C4 共4个字节,而这正是“中文”两字的GBK编码。
由此可见,printf(“字符串")输出最终的结果就是把字符串的编码写入终端设备,而如何编码取决于源文件的存储编码方式。实际上,编码是由编译器完成的。printf只是把给他的参数当成一个字节数组而已,其本身不了解也不需要字符编码的概念。
这显然会导致一些问题,比如UTF-8编码的源文件编译生产的执行文件,只能输出字符串的UTF-8编码,一旦运行在非UTF-8的终端上,则无法正确显示(或者需要使用调用iconv工具 进行转码)。显然这种“编译时就确定字符编码”的方式非常死板。那么如何改进呢,为此printf()提供了一个 %ls 指示符,与之相对应的则是wchar_t类型的字符串,也叫做宽字符。每一个wchar_t类型的字符在内存中占用4个字节,其内容是该字符的UNICODE编码(注意不是UTF-8),而且其编码方式时固定的,不会因为源文件的存放编码改变而改变。为了与传统的char进行区别,声明常量时使用 L"字符”的格式。下面是宽字符版本的源文件。
int main(int argc, char** argv) { printf(”%ls", L"中文"); return 0; }
通过%ls,printf就会了解到后面的指针指向的是一个宽字符串,而不是多字节字符串(简单的字节数组),这样在最终调用write写入终端前,就会把这个宽字符串进行相应的字符编码,然后输出。通过strace 调试运行,我们发现最终会调用 write(1, "-N", 2),显然这是不正确的。原因就在于调用write前的字符编码有问题。我们先来看看宽字符串“中文”的内存。为便于gdb调试,稍微修改一下源程序:
int main(int argc, char** argv) { wchar_t str[] = L"中文"; printf("%ls", str); return 0; }
用gdb调试,查看str指向的内存:
(gdb) p str $2 = L"中文" (gdb) x /12xb &str 0xbffff684: 0x2d 0x4e 0x00 0x00 0x87 0x65 0x00 0x00 0xbffff68c: 0x00 0x00 0x00 0x00
可见每个字符占用4个字节,内容为UNICODE代码点,这个常量字符串的内存结构当然是编译时就已经确定的。那么为什么最后会导致编码为 "-N"了呢。原因在于printf("%ls")对宽字符串编码时,依据的是程序运行时的locale,而我们在程序中没有明确设置locale,那么就会采用默认的 C locale,也就是会把宽字符转换为对应的ASCII码,这种转换其实无法进行,所以只是简单的“不转换”,这样当遇到第三个字节0x00时,就认为字符串结束了,而前面的两个字节 2D 4E 其实正是 -N 两个字符的ASCII码。
虽然转码不成功,但是至少是在运行时进行了转码。下面,我们稍微改动程序,添加设置locale的功能。
int main(int argc, char** argv) { setlocale(LC_ALL, argv[1]); wchar_t str[] = L"中文"; printf("%ls", str); return 0; }
这样,我们就可以在运行时为程序提供locale的值,继续使用strace跟踪, strace ./a.out zh_CN.UTF-8,我们发现最终会调用 write(1, "\344\270\255\346\226\207", 6),这次把宽字符串按照UTF-8进行了编码,然后调用write写入终端了,由于当前终端也是采用的UTF-8编码,所以能正确显示出“中文”两字。那么如果终端编码为GBK的呢?没问题,只要执行程序时,提供zh_CN.GBK这个参数就可以了。如下:
./a.out zh_CN.GBK
继续跟踪发现最终调用 write(1, "\326\320\316\304", 4),可见,写入的是"中文"的GBK编码。
做个小结:(1)printf("%s")或printf("")都是把字符串当成字节数组直接调用write写出,不涉及字符编码。
(2)printf("%ls")把宽字符串根据运行时的locale进行编码后,调用write写出。
(3)通过setlocale()来设置字符编码规则。
这里有四个地方涉及到字符编码:(1)源文件(2)字符串的内存表示(3)printf内部的转码(4)终端本身
只要(3)中的转码能够正确进行,并且(3)和(4)采用相同的字符编码,那么应用程序部分就做够了正确显示字符串的准备了,至于最终是否能在显示器上正确显示出字符串,还要实际操作系统和实际设备的支持,也就是第二阶段。
系统调用write(1, 字节数组,长度)会最终调用终端设备的_write()函数,该函数再调用底层硬件(如显卡)驱动控制显示器显示。我们先来看看简单字符串ABC的情况,因为无论采用任何类型的终端设备都可以正确显示它们。
终端按照底层设备,可以分成三大类型。一是底层输出设备就是文字模式的VGA显卡显示器;二是底层输出设备是图形模式的VGA显示设备;三是伪终端设备,底层输出设备是其他GUI系统中的窗口。上图分别针对这三种终端粗略探讨了显示过程。对于字符模式VGA,显卡固件里的字符发生器从来都不支持中文;对于图形模式显卡,理论上只要有中文字形位图那么就可以显示,但是实际上终端软件本身并不支持Unicode编码,也就无法显示中文,非官方有一些软件如zhcon一定程度上支持中文,但是远不完善;对于第三种,几乎所有的GUI窗口都支持中文输出,是目前最完美的终端类型。