从调用printf()到显示器上看到字符串

0 引入

看如下最简单的C程序:

int main(int argc, char** argv)
{
    printf("ABC");
    return 0;
}

本文就是力图描述这个程序的执行过程,具体来说,就是从调用printf(),到“ABC”三个字符显示到显示器上,到底是一个什么样的过程。

1 第一阶段: printf()最终调用write()写入终端

使用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)采用相同的字符编码,那么应用程序部分就做够了正确显示字符串的准备了,至于最终是否能在显示器上正确显示出字符串,还要实际操作系统和实际设备的支持,也就是第二阶段。

2 第二阶段: 终端调用显示设备显示

系统调用write(1, 字节数组,长度)会最终调用终端设备的_write()函数,该函数再调用底层硬件(如显卡)驱动控制显示器显示。我们先来看看简单字符串ABC的情况,因为无论采用任何类型的终端设备都可以正确显示它们。

  从调用printf()到显示器上看到字符串_第1张图片

终端按照底层设备,可以分成三大类型。一是底层输出设备就是文字模式的VGA显卡显示器;二是底层输出设备是图形模式的VGA显示设备;三是伪终端设备,底层输出设备是其他GUI系统中的窗口。上图分别针对这三种终端粗略探讨了显示过程。对于字符模式VGA,显卡固件里的字符发生器从来都不支持中文;对于图形模式显卡,理论上只要有中文字形位图那么就可以显示,但是实际上终端软件本身并不支持Unicode编码,也就无法显示中文,非官方有一些软件如zhcon一定程度上支持中文,但是远不完善;对于第三种,几乎所有的GUI窗口都支持中文输出,是目前最完美的终端类型。

 

  3 不同平台的差异

本文在Linux平台讨论printf(),但是printf()是C标准库函数,理论上所有平台通用。但是实际上不同平台的printf()还有有不少差异的,特别是在引入wchar_t之后。例如就wchar_t本身来说,gcc编译时大小为4字节,而VC++编译时却是2字节大小;又如wprintf()函数,在VC++中不支持%ls而是采用%s来表示宽字符串。

字符编码问题是计算机世界里的最基础最重要的一个主题,在UNICODE仍未完全统一全世界的软件之前,各种编码转换让字符串处理更加复杂。乱码问题始终困扰着广大的程序员,深入理解编码原理与转换细节是解决乱码问题的不二选择,坚持使用UNICODE是减少各种麻烦的良好习惯。


你可能感兴趣的:(Linux相关,C/C++)