基础语法:从CPU角度看变量、数组、类型、运算、跳转、函数等语法
本文最后更新于 2024-06-11,文章内容可能已经过时。
对于绝大部分编程语言来说,不管是 Python、Ruby、PHP、JavaScript,还是 Go、C/C++、Java,其包含的基本语法无外乎这样几种:变量、类型、数组、运算(赋值、算术、逻辑、比较等)、跳转(条件、循环)、函数,而其他语法(比如类、容器、异常等)在 CPU 眼里只不过是语法糖。本文,我们就来看下,编程语言中的这些基本语法,在 CPU 眼里是什么样子的。
一、变量
我们知道,内存被划分一个个的内存单元。每个内存单元都对应一个内存地址,方便 cpu 根据内存地址来读取和操作内存单元中的数据。
对于高级语言来说,内存地址可读性比较差,所以,就发明了变量这种语法。变量可以看作是内存地址的别名**。内存地址和变量的关系,跟 IP 地址和域名的关系类似。在机器码中,我们通过内存地址来实现对内存中数据的读写。在代码中,我们通过变量来实现对内存中数据的读写。编译器在将代码编译成机器码时,会将代码中的变量替换为内存地址。
不同的变量有不同的作用域(也可以理解为生命周期)。不同作用域的变量,分配在代码段的不同区域。不同的区域有不同的内存管理方式。不同语言对数据段的分区方式会有所不同,但又大同小异(关于 Java 语言如何对数据段分区,我们在 JVM 部分讲解),常见的分区有栈、堆、常量池等。
笼统来讲,栈一般存储的是“函数内”的数据,如函数内部的变量、参数等,他们只在函数内部参与计算,函数结束之后,就不再使用了,所占用的内存单元就可以释放,以供其他变量来使用。堆一般存储的作用域不局限于"函数内的变量",比如对象,只有在程序员主动释放,比如 c 和 C++语言,而对于 Java 语言来说,当虚拟机检测到不再使用的时候,对象对应的内存会被释放。常量池一般存储常量等,常量的生命周期和程序的生命周期一样,只有在程序结束之后,对应的内存单元才会被释放。
总而言之,对数据进行分区,是为了方便管理不同生命周期的变量。而之所以不同的变量要设置不同的生命周期,是为了有效利用内存空间,方便在变量生命周期结束之后,对应的内存单元能够被快速回收,以供重复使用。
二、数组
在编程语言中,要表示一些连续有规律且数据类型相同的数据,我们可以定义一块连续的内存空间,存储此类数据,并通过下标访问这些数据。Java 中的数据定义如下:
class Test{
public static void main(String[] args){
int a = new int [10];
a[3] = 92;
}
}
在上述代码中,a 表示一个局部变量,存储在栈上。int a = new int [10];
这条语句表示在堆上申请一块能够存储下 10 个 int 类型数据的连续存储空间,并将这块空间的首地址存储在 a 变量所对应的内存单元中。实际上,数组是一种引用类型的数据。
当通过下标访问数组中的元素时,比如 a[3]=92
, 编译器会将这条语句分解为多条 CPU 指令,先通过变量 a 中存储的首地址和如下寻址公式,计算出下标为 3 的元素所在的内存地址,然后将 92 写入到这个内存单元所对应的内存单元。
a[i]的内存地址 = a中存储的值(首地址)+ i*4(int 类型的数据占四个字节)
在 Java 语言中,new 申请的数据存储在堆上,首地址复制给栈上的变量。而在 c 语言中,数组的语法更加灵活,既可以申请在堆上,也可以申请在栈上。如下所示:
int a[100]; //数组在栈中,可以直接类似a[2]=92;这样使用了
int a[100] = malloc(sizeof(int)*100); //数组在堆中
但是对于 Javascript 语言来说,他的数组类型可以存储不同类型的数据,比如:
let array = [1,"zifuchuan",true]
此时,寻址公式就无法使用了。实际上,对应不同编程语言中的数组,其在内存中的存储方式并不完全一样。
三、类型
CPU 眼里,是没有类型这种概念,任何类型的数据,在 CPU 的眼里,都是一串二进制码,至于这些二进制码是表示字符串还是整数,完全在于编译器的解读。引入类型的目的是:方便程序员正确的编写代码,避免赋值操作。比如,不能将 string 类型的数据赋值给 int 类型,当然,这个是针对 Java 语言而言,其他语言不一定是这样。不同的编程语言具有不同的类型系统。根据变量的类型是否可以动态变化和检查发生的时期,分为静态类型和动态类型,静态类型指的是一个变量的类型是唯一确定,动态类型指的是一个变量的类型是可以发生变化的,具体看赋值什么类型的,类型的检查发生在运行期。比如 Java script 语言,由于其类型系统可以发生变化,所以经常会发生一些变量类型赋值错误的现象,因此,可以使用 typescript 语言来约束变量类型。
除了静态类型语言和动态类型语言这种分类方式之外,在平时的开发中,我们还经常听到另外一种分类方式:弱类型语言和强类型语言。实际上,这种分类方式没有太大意义。强和弱是对程度的描述,并不是非黑即白。所以,我们很难判定某种语言到底是强类型语言还是弱类型语言。所以,你不要纠结于这种分类方式,稍微了解即可。
四、运算
在编程运算中,常见的运算符有以下四种:
- 算术运算符
- 关系运算符
- 赋值运算符
- 逻辑运算符
- 位运算符
以上绝大部分运算在 CPU 中都有对应的指令。不过,不同类型的指令对应的电路逻辑不同,所以,执行花费的时间不同,比如位运算会比较快,乘法、除法比较慢。
我们通过一个简单的C语言例子,来看一下上述运算对应的汇编指令。
#include <stdio.h>
int main() {
// 赋值
int a = 1; // 对应movl指令
int b = 2;
// 算术
int c = a + b; //对应addl指令
int d = a * b; //对应imull指令
// 关系
if (c < d) { //对应cmpl和jge指令
printf("c<d");
}
// 逻辑
if (a > 2 || b > 2) { //对应cmpl和jg指令
printf(">2");
}
// 位运算
int e = a & b; //对应andl指令
return e;
}
汇编为汇编代码如下所示。代码中的核心计算都添加了注释,你可以结合注释来理解。
$gcc -S test2.c
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl $0, -4(%rbp)
movl $1, -8(%rbp) ;a存储在栈上,a=1;
movl $2, -12(%rbp) ;b存储在栈上,b=2;
movl -8(%rbp), %eax ;a的值放入寄存器eax
addl -12(%rbp), %eax ;a+b,结果放入eax
movl %eax, -16(%rbp) ;a+b的值放入c(c在栈上)
movl -8(%rbp), %eax ;a的值放入寄存器eax
imull -12(%rbp), %eax ;a*b,结果放入eax
movl %eax, -20(%rbp) ;a*b的值放入d(d在栈上)
movl -16(%rbp), %eax ;c的值放入寄存器eax
cmpl -20(%rbp), %eax ;c<d,结果放到标志寄存器中,
jge LBB0_2 ;jge根据标志寄存器的值做跳转
## %bb.1:
leaq L_.str(%rip), %rdi ;printf("c<d");
movb $0, %al
callq _printf
LBB0_2:
cmpl $2, -8(%rbp) ;判断a>2,结果放到标志寄存器中,
jg LBB0_4 ;jg根据标志寄存器的值做跳转
## %bb.3:
cmpl $2, -12(%rbp) ;判断b>2,结果放到标志寄存器中,
jle LBB0_5 ;jg根据标志寄存器的值做跳转
LBB0_4:
leaq L_.str.1(%rip), %rdi ;printf(">2");
movb $0, %al
callq _printf
LBB0_5:
movl -8(%rbp), %eax ;a放入寄存器eax
andl -12(%rbp), %eax ;a&b,结果放入eax
movl %eax, -24(%rbp) ;a&b的结果放入e(e在栈上)
movl -24(%rbp), %eax ;返回值放入eax
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "c<d"
L_.str.1: ## @.str.1
.asciz ">2"
.subsections_via_symbols
五、跳转
程序由顺序、选择(或叫分支、条件)、循环三种基本结构构成,其中,选择和循环又统称为跳转。接下来,我们通过一个C语言代码示例,来看下两种跳转在CPU眼里是如何实现的。
#include <stdio.h>
int main() {
int a = 1;
int b = 2;
// 选择
if (a < b) {
printf("a<b");
}
// 循环
for (int i = 0; i < 100; ++i) {
printf("%d", i);
}
return 0;
}
汇编成汇编代码如下所示。代码中的核心语句都添加了注释,你可以结合注释来理解。
$ gcc -S test3.c
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $0, -4(%rbp)
movl $1, -8(%rbp) ;a=1,a存储在栈上
movl $2, -12(%rbp) ;b=2,b存储在栈上
movl -8(%rbp), %eax ;a值放入寄存器eax
cmpl -12(%rbp), %eax ;a<b,比较结果放入标志寄存器
jge LBB0_2 ;通过标志寄存器判断如何跳转
## %bb.1:
leaq L_.str(%rip), %rdi
movb $0, %al
callq _printf ;printf("a<b");
LBB0_2:
movl $0, -16(%rbp) ;i=0,i存储在栈上
LBB0_3: ## =>This Inner Loop Header: Depth=1
cmpl $100, -16(%rbp) ;i<100,比较结果放入标志寄存器
jge LBB0_6 ;通过标志寄存器判断如何跳转
## %bb.4: ## in Loop: Header=BB0_3 Depth=1
movl -16(%rbp), %esi
leaq L_.str.1(%rip), %rdi
movb $0, %al
callq _printf ;printf("%d", i);
## %bb.5: ## in Loop: Header=BB0_3 Depth=1
movl -16(%rbp), %eax ;i值放入eax寄存器
addl $1, %eax ;eax寄存器内值+1
movl %eax, -16(%rbp) ;eax寄存器的值赋值给i,相当于i++
jmp LBB0_3 ; ;跳转去判断i<100
LBB0_6:
xorl %eax, %eax
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "a<b"
L_.str.1: ## @.str.1
.asciz "%d"
.subsections_via_symbols
从上述汇编代码中,我们可以看出,不管是 if 选择语句,还是 for 循环语句,底层都是通过 CPU 的跳转指令(jge、jle、je、jmp 等)来实现的。跳转指令比较类似早期编程语言中的 goto 语法,可以实现随意从代码的一处跳到另一处。而在之后的编程语言的演化中,goto 语法被废弃。那么,为什么要废弃 goto 语法呢?
goto语法使用起来非常灵活,随意使用极容易导致代码可读性变差。你可以想象一下,如果代码执行过程中,一会跳到前面某行,一会又跳到后面某行,跳来跳去,代码的执行顺序将会非常混乱。阅读代码将会十分困难。
而之所以,我们能够废弃 goto 语法,就是因为选择、循环这两种基本结构,可以满足编写代码逻辑的过程中对跳转的需求,而且,选择和循环实现的跳转都是局部的,不会到处乱跳,所以,不会影响代码整体的执行顺序,可读性也不会变差。
六、函数
编写函数是代码模块化的一种有效手段。几乎所有的编程语言都会提供函数这种语法。函数的底层实现,相对于前面讲的几种基本语法的底层实现,要复杂一些。函数底层实现依赖一个非常重要的东西:栈。就是我们前面讲到的,用来保存局部变量、参数等的内存区域。因为这块内存的访问方式是先进后出,符合栈这种数据结构的特点,所以,也被称为栈。
为什么函数底层实现需要用到栈呢?
每个函数都是一个相对封闭的代码块,其运行需要依赖一些局部数据,比如局部变量等。这些数据会存储在内存中。当函数 A 调用另一个函数 B 时,CPU 会跳转去执行函数 B 的代码。函数 B 的执行又会涉及一些局部变量等,这些数据也会存储在内存中(紧挨着函数 A 的内存块)。以此类推,当函数 B 调用另一个函数 C 时,CPU 又会跳转去执行函数 C 的代码。函数 C 的内存块会紧邻函数 B 的内存块。如下图所示。
当函数 C 执行完成之后,函数 C 中的局部变量等都不再被使用,对应的内存块也可以释放以供复用,并且,CPU 返回执行函数 B 的代码。函数 B 对应的内存块又开始被使用。同理,函数 B 执行完成之后,其对应的内存块也会被释放,CPU 返回执行函数 A 的代码。函数 A 对应的内存块又开始被使用。如下图所示。
从上图,我们可以发现,在函数调用过程中,同一时间只有一个函数的内存块在被使用,并且内存块被释放的顺序为“先创建者后释放”,符合栈的特点:“只在一端操作、先进后出”。所以,编译器把函数调用所使用的整块内存,组织成栈这种数据结构(叫做函数调用栈)。我们把每个函数对应的内存块叫做栈帧。
当通过函数调用,进入一个新的函数时,编译器会在栈中创建一个栈帧(实际上就是申请一个内存块),存储这个函数的局部变量等数据。当这个函数执行完毕返回上层函数时,栈顶栈帧出栈(也就是释放内存块),此时,新的栈顶栈帧为返回后的函数对应的栈帧。从上图中,我们也可以发现,正在执行的函数对应的栈帧肯定位于栈顶。
- 感谢你赐予我前进的力量