C/C++编译原理(2) | 编译工作流程

2023年 9月 12日 74.2k 0

前言

欢迎持续关注专栏:juejin.cn/column/7265…

上一篇文章我们简述了编译的4个步骤,这节我们来看看其中第二个流程即编译阶段,编译器都帮我们做了什么。

正文

从最原始的角度来看,编译器就是将高级语言编译成机器能够运行的语言的一种工具,注意,由于后面还有一个汇编器进行汇编操作的过程,所以这里的编译流程其实是编译成特定平台的汇编代码。

这里补充一个小知识点,为什么要有汇编代码?直接编译成二进制给机器执行不可以吗?

首先汇编代码是和操作系统和处理器强相关的,不同的处理器架构有着不同的指令集和执行方式,所以不同的处理器需要使用相应的汇编指令进行编程,比如x86架构和ARM架构都有各自的指令集和汇编语法。

操作系统管理与硬件交互的底层任务,并且提供高级抽象的接口供应用层程序使用,不同的操作系统可能有不同的系统调用、内存管理机制、进程间通信等功能,这些功能在汇编代码中会有不同的实现方式和调用约定。

因此编写汇编代码需要考虑所针对的处理器架构和操作系统,以保证代码能够与特定的硬件和操作系统进行交互,汇编代码在不同的处理器架构和操作系统之间不能通用,需要进行适配和修改。

所以我们就可以知道编译器把代码编译成汇编语言就已经足够了,而汇编器是把汇编语言编译成二进制而已。同时从另一个角度来看,汇编语言其实就是给人看的机器码,方便开发人员调试问题。

那为什么要使用高级语言来进行编程呢,原因最直观的有以下几点:

  • 使用机器指令或者汇编语言写程序非常费劲,效率低下,不易阅读。
  • 机器语言或者汇编语言一般都是依赖于特定的机器,程序员可不想为每一种CPU架构都写一份代码。
  • 使用类似自然语言的高级语言,比如C/C++等,可以更好地组织代码,使用各种设计模式,再通过编译器编译成不同平台的机器码,可以提高效率。
  • 高级语言可以让程序员更关注逻辑,而不是计算机本身的限制,比如字长、内存大小等。

所以这个编译过程非常复杂,大致可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,整个过程如图:

编译的6个步骤.jpg

能够理解这6大过程,可以很大程度提高对编程语言的理解,尤其是日常工作中碰到的语法错误、优化等现象,甚至理解编译过程,我们可以自己创造一门语言。

词法分析

首先是将源代码程序输入到扫描器中,进行词法分析,把源代码中的字符序列分割成一系列的记号(Token),说白了就是简单地将代码进行分割和整理。

比如测试代码array[index] = (index + 4) * (2 + 6),这行代码一共有28个非空字符,经过扫描后,可以产生16个记号,如下表:

记号 类型
array 标识符
[ 左方括号
index 标识符
] 右方括号
= 赋值
( 左圆括号
index 标识符
+ 加号
4 数字
) 右圆括号
* 乘号
( 左圆括号
2 数字
+ 加号
6 数字
) 右圆括号

词法分析产生的记号分为关键字、标识符、字面量(包含数字、字符串等)和特殊符号(加号、乘号等),在识别记号的同时,扫描器也完成了一些其他动作,比如将标识符放到符号表中,将数字、字符串常量放入到文字表中。

由此可见,这个词法分析的规则也是不同语言有着不同的规定,比如这里的array并不会分析判断为array这2个标记符,同样的对于括号都有着特殊匹配规则,当写出不符合词分析的代码时,在这一步便会报错。

语法分析

词法分析完后就是进行语法分析,由语法分析器(Grammer Parser)对记号进行语法分析,产生语法树(Syntax Tree)。这一步非常重要,也是我们学习一门新语言时经常会出现IDE提示语法错误,而如何判断语法错误,就是这一步分析的。

首先有个概念就是一条语句是一个表达式,而复杂的语句由多个表达式组合起来的,比如上面那行代码就是由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂表达式。

这时就有个关键问题,就是表达式是具有优先级的。上面示例代码中,我们肯定知道核心是一个赋值表达式,先把赋值表达式左右都计算完,再进行赋值操作。但是这是建立在我们学过编程的前提下,当对于分析器来说,不给它设定规则,它就无法知道是先计算乘法还是先进行赋值操作。

不仅仅是运算符优先级,还包括多重含义符号的区分,比如*,包括表达式是否合法,比如缺少括号、操作符缺少参数等,这些编译器都会报错。

语法分析器生成的语法树.jpg

语法分析进行完后,至少可以判断我们写的代码有没有语法错误,而有兴趣的可以查看一下语法分析的算法和规则,这样可以更加理解语法分析过程,帮助我们平时碰到语法错误时,能快速找到原因。

语义分析

经过语法分析后的语法树基本上能对简单的错误进行判断和提示了,比如运算符缺少参数等,但是语义错误无法判断,这就需要语义分析器来进行语义分析了。

什么是语义呢?就是这个语句是否有意义,比如在C语言中,2个指针做乘法是没有意义的,但是在语法层面是可行的。这种编译器能分析的语义是静态语义(Static Semantic),与之对应的是动态语义(Dynamic Semantic),就是只能在运行期才能确定是否非法的语义,比如除零操作。

静态语义分析有个非常重要的的作用是声明和类型的匹配,以及类型的转换。对于声明和类型匹配,对于C++这种复杂的编程语言来说,是经常写错的,尤其是复合类型和函数类型。而类型转换一般就是隐式类型转换,比如把一个浮点型的表达式赋值给一个整型的表达式,就隐含了一个浮点型转到整型的过程。

经过语义分析后的语法树,每个表达式都有了固定的类型,很多编程语言都是静态类型语言,在这个阶段后要不我们自己声明要不编译器推导,整个语法树的表达式都会被标识上类型。

中间代码生成

现代编译器有着很多的优化,其中一个就是源码级别会有一个优化过程。比如上面代码中(2+8)就可以被优化掉,因为它的值已经被确定了。

除了这种,还有一些常见的优化:

  • 代码消除:编译器会检测和删除不会影响程序结果的代码,从而减少不必要的计算和内存访问。
    比如代码:
  • int square(int x) {
        int result = x * x;
        return result;
    }
    

    这里的变量result可能就会被编译器给优化掉,直接返回x * x;

  • 内联函数:编译器会尝试内联一些简单的函数,可以避免函数调用带来的额外性能开销。
    比如代码:
  • inline int square(int x) {
        return x * x;
    }
    
    int calculateSquare(int y) {
        return square(y); // 内联函数调用
    }
    
    

    比如这里在调用square()方法时,会直接把其函数体复制到调用处,以减少函数调用带来的额外开销。

  • 公共子表达式消除:对于在代码中,编译器可以识别出重复的子表达式,然后把子表达式的值进行缓存,从而减少计算次数。比如代码:
  • int calculateExpression(int x, int y) {
        int z = x * 5 + y * 5; // 公共子表达式
        int w = x * 5 + y * 5; // 公共子表达式
    
        return z + w;
    }
    

    在这里公共的子表达式就是x *5 + y * 5,编译器会把其的值缓存起来,进行一次计算即可。

  • 编译时常量表达式优化:在编译时如果可以确定一些常量表达式的值,就可以在编译时把结果替换。比如下面代码:
  • constexpr int factorial(int n) {
    return (n

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论