分类 技术志 下的文章

也谈C语言编译器的标准编译阶段

了解C编译器的工作流程有助于C程序员解决编译代码过程中出现的问题。市面上凡是讲解得还算全面的C语言书籍中都或多或少对此有所提及。

让我们在这里来回顾一下C编译器的工作流程!一般C编译器的工作流程大致分为:预编译、编译、生成目标代码(汇编)和连接这四个主要步骤。我们用实例具体描述一下这四个步骤,以最著名的GCC编译器结合helloworld.c文件为例:

/* helloworld.c */
int main() {
    printf("hello, world\n");
    return 0;
}

使用Gcc编译该源文件,我们看到编译器有如下输出(省略了一些内容):

$ gcc -v -o helloworld helloworld.c
… …
gcc version 4.4.3 (Ubuntu 4.4.3-4ubuntu5)
COLLECT_GCC_OPTIONS='-v' '-o' 'helloworld' '-mtune=generic' '-march=i486'

 /usr/lib/gcc/i486-linux-gnu/4.4.3/cc1 -quiet -v helloworld.c -D_FORTIFY_SOURCE=2 -quiet -dumpbase helloworld.c -mtune=generic -march=i486 -auxbase helloworld -version -fstack-protector -o /tmp/ccgoLMLQ.s
… …

COLLECT_GCC_OPTIONS='-v' '-o' 'helloworld' '-mtune=generic' '-march=i486'
 as -V -Qy -o /tmp/ccN9HVdH.o /tmp/ccgoLMLQ.s
… …

COLLECT_GCC_OPTIONS='-v' '-o' 'helloworld' '-mtune=generic' '-march=i486'
 /usr/lib/gcc/i486-linux-gnu/4.4.3/collect2 –build-id –eh-frame-hdr -m elf_i386 –hash-style=both -dynamic-linker /lib/ld-linux.so.2 -o helloworld -z relro /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crt1.o /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crti.o /usr/lib/gcc/i486-linux-gnu/4.4.3/crtbegin.o -L/usr/lib/gcc/i486-linux-gnu/4.4.3 -L/usr/lib/gcc/i486-linux-gnu/4.4.3 -L/usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib -L/lib/../lib -L/usr/lib/../lib -L/usr/lib/gcc/i486-linux-gnu/4.4.3/../../.. -L/usr/lib/i486-linux-gnu /tmp/ccN9HVdH.o -lgcc –as-needed -lgcc_s –no-as-needed -lc -lgcc –as-needed -lgcc_s –no-as-needed /usr/lib/gcc/i486-linux-gnu/4.4.3/crtend.o /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crtn.o

可以明显看出,Gcc的输出大致分为三段:
首先是调用/usr/lib/gcc/i486-linux-gnu/4.4.3/cc1对源文件helloworld.c进行预编译和编译,生成汇编代码文件/tmp/ccgoLMLQ.s;
然后,汇编器as被启动,编译ccgoLMLQ.s,生成目标代码文件/tmp/ccN9HVdH.o;
最后,链接器collect2将目标文件和一些库文件连接在一起,形成可执行程序helloworld。

简单总结一下就是:
- cc1负责预编译源代码helloworld.c,生成helloworld.i(指代预编译后生成的中间文件,很多编译器为了效率并不使用临时文件,而使用管道等方法),我们可以通过gcc -E helloworld.c > helloworld.i得到helloworld.i这个文件;
- cc1将helloworld.i作为输入,对预编译后的源文件进行编译,生成汇编代码文件helloworld.s(指代编译后的汇编代码文件)。我们可以通过gcc -S helloworld.c得到helloworld.s文件;
- as负责根据helloworld.s生成目标代码文件helloworld.o,我们可以通过gcc -c helloworld.c来获得helloworld.o;
- collect2负责将目标代码与各种库文件连接,形成最终可执行文件helloworld。

其实以上不是这次重点要谈的。粗略了解了以上流程的确有助于解决编译过程中的问题,但是还不能解决全部,你需要了解更多。关于链接过程,我在博客里曾多次谈过,这里就不说了。as执行的汇编过程基本不会出现问题,这里也不谈,我们这次重点要关注的就是C编译器在预编译和编译过程中的一些细节。

C标准(C99)在5.1.1.2小节将C编译器工作流程分成了八个标准阶段,我这里也是结合这八个阶段并按照我的理解做进一步的解释的。在开始之前我们要明确下面这八个阶段中的前七个都是针对一个编译单元/翻译单元的,自始至终你都要牢记这一点。

第一阶段:物理源文件中的多字节字符被映射到源字符集(具体以何种字符编码方式映射与编译器的实现相关)。三字符序列(或称为三字符组)被替换为相应的单字符的内部表示。

标准中的语言总是那么绕口。这里主要说的是编译器读取物理源文件的内容,此时编译器并不知道该源文件中的多字节字符采用的是何种字符集编码方式。以GCC为例,GCC默认源码文件多字节字符的编码为utf8,而GCC其作为内部表示的源字符集默认也是utf8,所以默认情况下,这个阶段GCC不会对源文件中的内容做任何转换。

例如我们有一个内码格式为GBK的名为foo.c的文件:
/* foo.c */
int main() {
    printf("中国\n");
}

按照GBK码表,其中的字符串常量"中国"的编码为d6 d0 b9 fa。将该文件传到一个locale为utf8的平台上编译,我们发现GCC并未尝试将GBK转换为其内部表示的编码格式utf8:
$ gcc -E foo.c > foo.i
$ od -x foo.i
我们可以看到foo.i中"中国"二个字的编码依旧为d6 d0 b9 fa。

不过我们可以显式告知编译器源码文件的编码格式,如果其所在OS支持从该编码格式到utf8的转换,则GCC会在第一阶段就进行这个转换:
$ gcc -E foo.c > foo.i -finput-charset='gbk'
这次foo.i中的"中国"二字的编码变成了utf编码:e4 b8 ad e5 9b bd

三字符序列(trigraphs)的替换过程也是在第一阶段进行的,也就是发生在词法分析之前以及识别字符常量和字符串常量中的转义字符之前。我们看看这个例子:
/* trigraphs_test.c */
int main(int argc, const char *argv[]) {
    printf("hello??/n");
    printf("world\n");
    return 0;
}

$ gcc -E trigraphs_test.c > trigraphs_test.i -std=c99

可以看到trigraphs_test.i内容为:
int main(int argc, const char *argv[]) {
    printf("hello\n");
    printf("world\n");
    return 0;
}

三字符序列发生在转义之前,所以printf("hello??/n");在字符串转义过程之前就先进行了三字符序列的替换(否则编译器会报错),替换成了printf("hello\n");后续在字符串常量转义字符时\n才被当作了换行符处理。

第二阶段:这个阶段比较简单,说白了就是去掉续行符,即所有相邻的'\'和'\n'的组合,将物理源代码的行拼接为逻辑源代码行。

第三阶段:源文件被分解为预处理词法元素(tokens)和空白字符序列(包括注释)。源文件不应该以一个部分预处理词法元素或部分注释结束(例如一个注释不能一半在一个文件中,而另一半在接下来的文件中)。每条注释都被替换成一个空格字符。换行符保留。将非空空白字符序列(诸如空格、TAB键等,除了换行符)保留还是替换为一个空格字符则由编译器的实现决定

这个阶段中预处理器开始执行了词法分析,删除不必要字符,转换字符,为后续处理营造一个干净的环境。

第四阶段:预处理指示符被执行,宏调用被扩展,_Pragma一元操作符表达式被执行。对通用字符名(UCN)进行词法元素连接的行为是未定义的。预处理器从阶段1到阶段4递归地处理源文件中#include预处理指示符中的头文件或源文件。最后所有预处理指示符被删除。

这个阶段预处理器是主力,其结果是我们得到了一个包含了诸多头文件内容的预处理后的编译单元文件,用作后续处理的输入。

第五阶段:字符常量、字符串常量中的源字符集字符或转义字符序列都会被转换为相应的执行字符集中的字符;如果执行字符集中没有对应的字符(除了宽字符null),则转换成什么由编译器的实现确定。

注意与第一阶段不同的是:这个是在foo.i的基础上,也就是说在GCC默认foo.i中的字符都是utf8的基础上,将代码中的字符常量以及字符串常量中的源字符集字符(默认utf8)转换为执行字符集(默认也是utf8),包括通用字符名(UCN)。

注意UCN也可以看成转义字符序列,在这个阶段被转换为执行字符集,如:
char *a = "\u4e2d\u56fd"; /* 两个ucn字符为'中国' */

我们通过gcc -S得到源文件对应的.s汇编文件,从汇编文件内容可以看到a的内部表示为:
.string "\344\270\255\345\233\275"
即utf编码的'中国'。

另外这里说的字符和字符串串常量,也包括宽字符和宽字符串,其转换为内部表示的过程也在这个阶段进行,例如下面代码:
wchar w[] = L"中国";

该代码进行了一次utf8到宽字符内部表示(GCC为unicode32)的转换。

第六阶段:将相邻两个字符串字面元素连接起来
这个阶段用一个例子就能说明问题,很简单:
char *a = "hello"
          " world";

经过编译后,我们可以看到.s文件中关于a的定义:
.string "hello world"

这就相当于将"hello"和" world"连接起来,形成"hello world"。

第七阶段:编译器执行词法分析、语法分析以及语义分析,生成该编译单元对应的目标代码(.o文件)。
第八阶段:Resolve所有外部符号(包括变量和函数),并将诸多编译单元的.o以及外部库连接成可执行程序。

个人感觉编译阶段中的难点就是几个涉及字符集转换的阶段,如第一个阶段和第五个阶段,不过只要弄清楚编译器是如何做的,相信所有编译问题都可以被轻松解决了。

也谈C语言对国际化的支持

C语言对国际化的支持由来已久,最初开始于其第一版标准,即C89标准。在C89中我们可以看到用于支持国际化的locale.h、宽字符、宽字符串以及多字节字符(串)。而之后的"C89增补1"标准,即C90标准,以及C95标准又进一步完善了C语言对国际化的支持,增加了wchar.h、 wctype.h以及大量用于操作宽字符(串)和多字节字符(串)的标准库函数。最新一版C语言标准,即C99,让C语言对国际化的支持变得更加成熟,对非英语字符集也给予了更好的支持。

C语言支持国际化的核心就是大家所熟知的locale技术。C语言中的locale模型于C90标准中被引入。locale模型使得一些库函数的外部行为依赖于locale设置。这样的好处就是你无需重新编译代码,你发布的应用即可根据locale来满足不同区域人们的文化习惯。locale包含若干个类别,诸如LC_CTYPE、LC_COLLATE等,其中每个类别都会独立影响某些C函数的外部行为。比较常见的诸如日期时间显示方式、货币表示方式等。

例如,LC_TIME影响strftime的外部行为,不同locale情况下strftime输出的结果会有不同,见下面示例:

int main() {
        time_t now;
        char buf[1024];

        setlocale(LC_ALL, ""); /* set locale to current locale, which is "zh_CN.GB18030" */

        time(&now);
        strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", localtime(&now));
        printf("%s\n", buf);

        setlocale( LC_TIME, "en_US.UTF-8" );
        memset(buf, 0, sizeof(buf));
        strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", localtime(&now));
        printf("%s\n", buf);
}

这个程序在我的RedHat上输出的结果如下:
五, 01  7月 2011 10:07:59 GMT
Fri, 01 Jul 2011 10:07:59 GMT

locale另外一个重要的作用就是对字符集转换的影响。曾几何时,ASCII字符集曾是计算机上通行的字符集标准,那时的程序员一般根本无需考虑字符集转换。ASCII的好处就是每个字符可以存储在一个字节(8bit)中,其内部表示(Internel Representation)和外部表示(External Representation)是一致的,这样一来,其存储和传输都非常方便。程序内部在内存中对ASCII字符(就是一个字节)的处理(识别字符、计算字符串中字符个数、解析字符串等)也十分简单快捷。不过随着国际化的日益深入,ASCII的缺点便暴露了出来,即其编码集太小了,即便将8个bit都算上,最多也就是256(2的8次方)个字符,这丝毫没有考虑到广大亚洲人民的需要,严重"伤害"了亚洲人民的情感^_^。于是乎亚洲各个国家和地区都纷纷"自己动手,丰衣足食",制定了适合自己国家民族语言文字的字符集标准(当然了,其他大洲的国家也是这个样子的)。这些新字符集编码在满足本国语言需要的同时,也都兼容ASCII字符集,也就是说都是在ASCII字符集的基础上通过扩展字节个数达到支持更多字符的目的的。由于兼容ASCII,所以这些字符集中字符的表示都是非固定长度的,即在ASCII编码区间内的字符(即ASCII字符)用一个字节表示;超出这个区间,就会用2个或3个或更多的字节表示。这样的字符在C语言中被归类称为"多字节字符(multi-bytes character)"。

多字节字符,有着与ASCII同样的优点,即它们是面向字节的,便于传输和存储。之前用于处理ASCII的字符设备(基于字节的)都可以对多字节字符给予很好的支持。不过多字节字符缺点也同样明显。

首先就是程序内部(在内存中)处理起来十分不便。给定一个存储了某种字符集字符的字节数组,如果你没有对应的解析器,你是无法识别字符边界,无法识别出数组中究竟包含了哪些字符的,更不用说返回字符个数等操作了。针对这一问题,C语言引入宽字符的概念,宽字符集中的字符所占用的字节数是相同的,要么都是2 个字节,要么都是4个字节(3字节不利于计算机内存寻址优化),一般最大就是4个字节了,因为4个字节已经可以涵盖全球已知所有语言的所有字符了。在 GCC中,默认C语言宽字符类型,即wchar_t类型的长度为4。我们在内存中操作宽字符显然要比多字节字符更加容易:每个字符与N字节一一对应,这样对于统计字符个数、解析和识别字符大有裨益。因此在考量了多字节字符和宽字符的特点后,一般我们会使用宽字符作为字符在程序中的内部表示(用在各种内存操作中),而在存储、传输和显示过程中则使用多字节字符。再多罗嗦几句:宽字符为何不适于传输和存储呢?大致有以下三个原因:

- 空间利用率不高,或者说比较浪费空间和带宽
我想这个原因不用过多解释了。如果用4字节的宽字符存储一篇英文文章,那么与多字节字符相比,宽字符要浪费3/4的空间。

- 字节序问题
宽字符一般用2或4个字节表示,这样的字符在存储和传输过程中显然会遇到字节序问题,不同的平台采用不同的字节序,这样对于同一份以宽字符存储的数据来说,可能在不同的平台上得到不同的结果。

- 与已有I/O设备兼容性差
以往的设备都是面向字节设备的,处理ASCII字符以及由ASCII扩展而来的多字节字符问题不大。但对于由两个字节或四个字节组成的宽字符来说,显然有些力不从心了。

其次由于各个国家和地区纷纷独立制定多字节字符标准,导致了不同字符集之间的不兼容。比如:GBK编码中"中"字的编码是D6D0,而BIG5中"中"的编码则是A4A4。这样一来,一些涉及文本处理的程序,比如文本编辑器,就需要花费大量的工作在了不同编码间的相互识别和解析上。这时一些组织站了出来,试图建立可以容纳全球所有语言字符的统一字符集,Unicode/ISO 10646(为方便期间,二者之间的一些差异这里就忽略不计了,以下统称Unicode)因此诞生。Unicode简单来说就是一组标量数字集合,其中每个数字映射地球上的一个唯一字符。以往大家对于Unicode的理解就是用2个字节(Unicode-16,UCS-2)或4个字节(Unicode- 32, UCS-4)进行编码的宽字符。实则不然,这些理解只是其一,因为最初使用2个字节(后来发现2个字节是严重不足的)或4个字节可以一一映射 Unicode字符集合,编码值就是Unicode字符对应的Unicode字符集表中的那个数字。但是用宽字符作为Unicode底层编码的实现方式显然也会遇到上面所说的各种问题;于是乎基于多字节编码的Unicode实现出现了,最著名的莫过于utf8了,当然还有utf16和utf32。没错,utf8字符是一种多字节字符,utf8与unicode表示字符个数的能力上是等同的。Unicode字符可以与utf8字符做一一对应的转换。和其它多字节编码方案一样,utf8也兼容ASCII编码,也是面向字节的,utf8可以完全替代各个国家地区自己制定的那些私有编码方案。事实上,目前 utf8已经是全球字符编码的事实标准(de facto standard)了。

我们现在来实现这样一个程序:它可以在不同locale下输出foo.dat文件中的字符个数和字节个数,其中foo.dat文件中存储的数据的编码方式为locale指定的。我们有两个思路:
1、假设我们拥有所有locale的字符解析库,我们可以将数据从文件中读取出来后,用当前locale对应的字符解析库对数据进行解析,得到字符的个数;
2、利用locale技术,将文件中的数据读取后转换为宽字符,再计算宽字符的个数,即为foo.dat文件中字符的个数。

我们粗略对比以下这两种思路,优劣立见。利用locale技术,你无需了解任何有关目标主机字符编码的细节,也无需自携带规模庞大的字符解析库,另外无需做任何修改即可支持新增的locale配置。下面就是一个利用locale技术进行字节/字符计数的例子(仅仅是个例子哦),这个程序可以在不同locale下输出foo.dat中的字符个数和字节个数:

/* wc.c */
int main(int argc, const char *argv[])
{
    int bytes = 0;
    int words = 0;

    setlocale(LC_ALL, "");
    printf("Current locale is %s!\n", setlocale(LC_ALL, NULL));

    FILE *fp = NULL;

    fp = fopen("foo.dat", "rb");
    if (!fp) {
        printf("failed to open foo.dat, err: %d\n", errno);
        return -1;
    }

    char mbs_buf[1024];
    wchar_t wcs_buf[100];
    mbstate_t s;
    size_t n;
    const char *p;
    memset(mbs_buf, 0, sizeof(mbs_buf));

    while (NULL != fgets(mbs_buf, 1024, fp)) {
        memset(&s, 0, sizeof(s));
        memset(wcs_buf, 0, sizeof(wcs_buf));
        p = mbs_buf;

        n = mbsrtowcs(wcs_buf, &p, sizeof(wcs_buf), &s);
        if (n == -1) {
            printf("failed to convert multi-bytes character to wide character, err: %d\n", errno);
            return -1;
        } else {
            bytes += strlen(mbs_buf);
            words += wcslen(wcs_buf);
        }
        memset(mbs_buf, 0, sizeof(mbs_buf));
    }

    printf("bytes = %d\n", bytes);
    printf("words = %d\n", words);

    fclose(fp);
    return 0;
}

分别在具有两个不同locale的账户下制作foo.dat:
cat > foo.dat
中华人民共和国^D (输入Ctrl+D)

在locale为gb18030下的测试结果是:
Current locale is zh_CN.GB18030!
bytes = 14
words = 7

在locale为utf8下的测试结果是:
Current locale is zh_CN.utf8!
bytes = 21
words = 7

在C语言中,除了显式调用库函数在宽字符和多字节字符之间转换外,C语言本身还有一些隐式的转换值得注意。

首先就是宽字符的转换。如果你在源文件中用L"XXX"给一个wchar_t数组赋值,那么Gcc会默认将XXX看成是utf8编码的字符串。如果你的源文件确实是utf8编码的,那么类似wchar_t w[] = L"中国"则相当于编译器做了一次utf8到unicode-32的转换;但是如果你的源码文件不是utf8编码的,比如是gb18030的,那么编译器将提示错误:“converting to execution character set:无效或不完整的多字节字符或宽字符”。这时需要你通过Gcc命令选项显式指定源码字符集类型:-finput-charset='gb18030'。

其次利用%ls输出宽字符串时也需要注意隐式转换,看下面例子:

/* widechar.c, 该文件采用utf8编码 */
int main(int argc, const char *argv[])
{
   wchar_t w[] = L"中国";
   printf("%ls\n", w);
   return 0;
}

编译ok,但执行后发现无法输出“中国"二字。printf在%ls下支持输出宽字符串,但是也是需要显式指定locale的,否则当前LC_ALL就等于"C",在"C"locale下printf显然无法将宽字符"中国"成功转换为utf8编码并输出。我们稍作修改:

/* widechar.c, 该文件采用utf8编码 */
int main(int argc, const char *argv[])
{
   setlocale(LC_ALL, "");
   wchar_t w[] = L"中国";
   printf("%ls\n", w);
   return 0;
}

通过setlocale(LC_ALL, "")将locale指定为用户当前locale,这样我们就可以顺利见到"中国"字样了。printf做了一次宽字符到utf8的转换后,再将utf8字符串打印到控制台上,为我们所见。

最后,C99支持在源码中使用通用字符名(Universal Character Name, UCN)来表示任何扩展字符集中的字符。利用\U或\u来指定一个Unicode字符,但是注意千万不要以为宽字符和\U0000nnnn或\unnnn是等价的。下面这么做是无法达到你的预期的:

wchar_t w = '\u4e2d'; /* 4e2d是"中"字的Unicode编码 */

如果按我们的预期,w中的4个字节应该依次是0×00,0×00,0x4e和0x2d。但经过实际探测,我们得到的却是0×00、0xe4、0xb8和0xad,这恰恰是"中"的utf8编码。而且编译器还在这一行给出了警告:warning: multi-character character constant。这里也是一种隐式转换,使用UCN表示的Unicode字符将首先被按照执行字符集做转换后再作为右值,此时它就和一个多字节字符串无异,所以这里使用char mbs[] = "\u4e2d"才是正确的。我们可以将\u或\U作为转义字符来看待,这样在真正的编译开始之前,当Compiler处理所有转义字符及字符串时,这些字符和字符串将被预先转换为执行字符集中对应的字符,正如\u4e2d被转换为e4b8ad。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats