C语言:高级语言怎样抽象执行逻辑
平时我们做编程的时候,底层 CPU 如何执行指令已经被封装好了,因此你很少会想到把底层和语言编译联系在一起。但从我自己学习各种编程语言的经历看,从这样一个全新视角重新剖析 C 语言,有助于加深你对它的理解。
本节首先要了解 CPU 执行指令的过程,然后再来分析 C 语言的编译过程,掌握 C 语言的重要组成,最后我们再重点学习 C 语言如何对程序以及程序中的指令和数据进行抽象,变成更易于人类理解的语言(代码从这里下载)。
CPU执行指令的过程
CPU执行一条特定指令的详细过程是取指、译码、执行、访存、回写,这是一个非常详细的硬件底层细节,本节将从软件逻辑的角度看CPU执行多条指令的过程。
这个过程描述起来很简单,就是一个循环:
-
1.以PC寄存器中值为内存地址A,读取内存地址A中的数据
-
2.CPU把内存地址A中的数据作为指令执行,具体执行过程:取指、译码、执行、访存、回写
-
3.将PC寄存器中的值更新为内存地址A+(一条指令占用的字节数)
-
4.回到第一步
上述过程就是CPU执行指令的逻辑过程。
动手来写几行代码,调试一下,观察一下内存的内容和 CPU 寄存器的变化,代码如下:
.text
.global main
main: # main函数
add t1,zero,1 # x6 = 1
add t2,zero,2 # x7 = 2
add t0,t1,t2 # x5 = x6 + x7
add a0,zero,zero # x10 = 0 相当于main函数中的return 0
ret
这是一段 RV32I 指令集编写的汇编代码,设置好断点,执行如下:
看到 t0、t1、t2 寄存器中的数据和我们预期的一样。PC 寄存器从 0x10120,一直变化到 0x1012c,每执行一条指令 PC 寄存器的值都要加 4,这是因为每条 RV32I 指令都占用 4 字节的内存空间。
在调试控制台中执行-exec x/16xb 0x10120
命令,即可显示从 0x10120 开始的 16 字节内存数据,刚好 4 条指令的数据
下图展示了内存中的情况:
大致逻辑是这样的:最开始,由 CPU 控制单元通过控制总线发出要读取数据的控制信号;接着通过地址总线发送地址信号(当前情况下地址数据来源 PC 寄存器(0x10120));然后通过数据总线传送指令数据 (0x00100313);最后执行单元拿到指令数据开始执行,并增加 PC 寄存器使之指向下一条指令。重复这个过程,内存中的指令就能一条一条地执行了。
C语言编译过程
因为 CPU 始终只认识那些二进制数据,就需要把高级语言转化成为二进制数据,这个转化的过程叫编译过程,完成这个转化的工具软件就叫编译器。
比如C语言编译器编译C语言的过程,先通过下图来理解下这个过程,建立一个整体印象:
现代的 C 预处理器、C 编译器、汇编器、链接器是独立的程序,可以分开独立工作,并不是一个程序完成上图中所有的工作。
因为我们不开发编译器,这里你不需要理解词法、语法是如何分析的,中间代码是怎样优化的。我们要关注的焦点是,从 C 源代码到二进制机器指令数据的转化过程。
C语言的重要组成
想要弄清楚 C 如何跟二进制指令数据转化,首先要清楚 C 语言的重要组成部分
从不同层次抽象,里面的内容是不一样的:从高层次看代码中只有声明和定义,下一层看代码只有函数和变量,变量进一步分解还有不同的类型。
硬背这些分类只会让你晕头转向。接下来我们不妨分析一下,想要让一段 C 语言代码编译通过,需要哪些重要成分和逻辑结构。
在 C 语言中经常容易混淆声明和定义这两个概念,先来看看声明
- 声明是给变量、函数、结构体等命名,表明在程序代码中有该变量、函数、结构体,来看看下图中的代码:
在 declaration.c 程序文件中,声明了一个整型变量,一个结构体变量,一个函数。然后我们编译它,确实能编译成功,这说明在 C 语言文法中仅仅需要有声明就可以,当然空文件也是可以的。声明不会分配内存空间。
需要注意的是,只有声明的代码确实能编译成功,但链接的时候就不一定了,我们这里之所以能链接成功。是因为在其它代码中没有对这些声明进行了引用。
- 定义是具体给变量分配内存空间。这个内存空间可以是初始化的,也可以是没有初始化的、给出具体函数的实现。
具体函数可以是空函数,函数中没有语句什么都不做也可以,唯一必需的就是指明结构体成员。结构体也是变量,只不过结构体是多个变量的组合,同样要分配内存空间,可以初始化也可以不做初始化。
写代码验证一下对不对,如下图所示:
还是在 definition.c 程序文件中,定义了一个整型变量,一个结构体变量,一个函数。我们同样能成功编译它。这说明 C 语言文法中没有声明,只有定义也可以成功编译的,其实 C 语言文法的原则是,声明可以出现很多次,定义有且只能出现一次。声明和定义也可以同时出现。
编译的其中一个过程,就是用某种编程语言的文法来检查所写语言(代码)是否正确。你可以这么理解,语言的文法就是对这种语言的最高抽象,所以我们可以说 C 语言最重要的组成部分就是声明或者定义。
声明或者定义中又包含变量和函数,变量又有指针、数组、结构体,它们又包含各种类型,而函数中包含了各种表达式,各种表达式对变量进行操作。
编译器的语法分析过程,就是这样层层递归推导下去,最终构建出语法树,从而检查语言是否正确无误、是否符合该语言文法的规则定义,都符合编译才能通过。就像你学英文一样,你怎么判断一条英语句子是否正确呢?你会拿主谓宾等等约定俗成的语法去套,如果能套上去,就是正确的。
C语言对程序的抽象
看到 C 语言中,包含声明和定义,可以声明变量和函数,由图中绿色箭头指向。也可以定义变量和函数,由图中蓝色箭头指向,注意定义只能出现一次,声明可以出现多次。
故意安排指针在最前端,是因为从 C 语言特性讲,指针能指向任一变量和函数,由图中红色箭头指向;从另一个角度看,指针就是内存,能自由寻址读写内存空间,但能否读写内存则要看操作系统给的权限,指针就是 C 语言中的“上帝之手”。同时,图中黑色线条还表示指针可以有相应的类型,并且能参与运算,这是我把指针放在比函数更高位置的原因。
需要注意的是,各种类型的变量是可以定义在函数以外的,这些定义在函数以外的变量是全局变量,而定义在函数内部的变量叫局部变量。
下面继续研究一下表达式。从前面的图里,可以看到 C 语言表达式包含了变量和运算符。
变量又有各种类型,单个变量也是表达式,但是运算符不能单独存在变成表达式,所以 C 语言表达式要么是单个变量,要么是变量加运算符一起。根据运算符的类型不同,可以分成运算表达式、逻辑表达式、赋值表达式等。
举例:
int sumdata = 0;//全局整型变量sumdata
void func()
{
int i = 1;//局部整型变量i
int *p; //局部整型指针变量p
p = &sumdata;//把sumdata变量的地址赋值给p变量,从而指向sumdata变量
while(1)//循环流程控制
{
if(i > 100)
{
break;//跳出循环,流程控制
}
(*p) += i;//相当于sumdata = sumdata + i
i = i + 1;
}
return;
}
上述代码所有的表达式中,涉及了一个全局变量,两个局部变量。其中局部变量中有一个是指针变量,指向全局的变量。包含了更多的流程控制语句,可以明显地看到表达式就是:变量和运算符组合在一起,完成了对变量的操作。而变量代表了数据,最终就能实现对数据的运算。但是变量有各种类型,这些类型只是规范了变量的位宽和大小。
小结
C 语言是如何抽象程序的,如下表所示。
C 语言就是函数 + 变量。函数表示算法操作,变量存放数据,即数据结构,合起来就是程序 = 算法 + 数据结构。