1.问题
1、代码是什么?
2、静态,动态语言,解释型语言,脚本?
3、代码如何被机器执行?
4、如何写出高质量的代码
5、如何提高程序的执行性能?
2.关键词
汇编,指令,虚拟地址,寄存器,立即数,操作数,控制,条件,循环,跳转,过程,栈帧,数组
3.全文概要
通过上两章的介绍,我们明白了计算机如何编码,又构造简易计算机,在硬件层面理解了计算机如何运算的。这章我们将来看看现代计算机上面,我们写的代码是如何在计算机内部流转。代码本质是一段文本,计算机如何识别这段文本,如何转换为机器语言。理解了计算机执行代码机制后如何写出高质量的的代码。
4.代码设计
我知道你是一位编程高手,写代码对你而言是手到擒来的事。但是,你确定自己多年练就的编程技能不是建立在某种想当然的假设基础上?确定自己不是每天都在“稀里糊涂”地写代码?确定真正理解自己的代码是如何运行的吗?
编程的本质是把我们人类想做的事情通过代码命令计算机来执行。就目前而言,计算机是非常低等的物种,因为它根本就不会思考,只能一步一步执行人类设定好的步骤。我们通过上一章知道每一条指令在计算机内部是如何运行的,然后运行结果又是如何通过约定好的编码规则来表达人类能理解的意思。
仔细想想,我们做一件事通常会拆分成有限的小步骤,然后按照先后顺序来一步步完成。但是也可能出现需要选择的情况,这个时候就打破了原来的流水线,需要判断下个步骤的位置。这就构成了编程就基础的思想框架。
计算机能做到的也就是这两种过程,你设定好了10个步骤,也就是10条指令,然后计算机通过程序计数器(时钟周期振荡器)按步骤从存放指令的内存区域一条一条取出来,扔到CPU(我们构造的加法机)执行。每条指令都有一个唯一的地址,当遇到需要选择的时候,当前指令执行完就会把下一条要执行的指令的地址返回给程序计数器。
现在我们已经知道计算机理解代码的过程了,那么我们先来理清楚两个基础概念,程序和代码。
程序:指一组指示计算机或其他具有消息处理能力装置每一步动作的指令。
代码:指一套转换信息的规则系统编码。
其实上一章我们就是手动构造了一台很像计算机的,具有消息处理能力的装置,也就是我们的加法机。那么程序就是加法机从锁存器和记忆盒子里面读取的一条条的指令和数据。而代码就是我们通过控制面板开关设置的数据。控制面板开关本质上输入的是0和1的信号,如果我们把控制面板改造为纸带读取器,那么代码就是穿了孔的一条条的纸带。
计算机底层程序一直都是已0和1为基础执行不同的指令,而代码却一直在进化。最原始的代码其实就是计算机执行的程序,但是由于程序是指导计算机运行的,硬件处通过高低电平来处理信号再计算信息。对于机器很快就可以执行,但是对于人类来讲确异常痛苦。刚开始我记住不同组合的指令代表的意义,慢慢就发展成助记符,来表示程序,从而有了代码的概念。
有了助记符很方便人类的记忆,毕竟记住一个单词比记住一串数字要好记得多。我们从最开始用一连串0和1来写指令,到后面采用了助记符,才发展成汇编语言,而随着硬件的性能提升,旧的代码语言已经无法满足人们的需求,高级语言也应运而生,最早发明出来的真正意义的高级编程语言是Fortran。
我们说的代码就是编程语言的实现,首先它是一门语言,那就有该语言的一套规则,也就是语法。编程语言随着硬件的发展变得越来越接近人类语言,语法也越来越简练。本质上不同语言最终都是被翻译成计算机自己认识的机器程序来执行。从古老的Fortran到现在的JAVA或者PHP(听说是最好的语言)。还有其他庞大的计算机语言家族,把这些语言抽象出共性来,那么基础的语言规则都是相同的。
从计算机语言的两大要素就是数据类型和操作类型。
数据类型:
字符,字符串,整形,浮点型,集合…
操作类型:
顺序,判断,选择,循环,分支…
没了,不管是高级语言还是低级语言,执行指令都是采用顺序和分支的过程。这就是计算机语言的本质,这就是我们日常使用多数计算机语言的语法结构。那么,只要你掌握了这两个概念,就已经是一名入门的程序员了。计算机语言还在飞速发展中,但是核心就是数据和操作这两个概念。甚至,你完全可以发明你自己的语言,只要满足能输入数据,然后操作数据,返回结果,就是一门计算机语言了。
至于我们今天熟悉的函数式编程,面向对象编程,从软件工程到持续集成,是从架构上对事务的抽象,也可以说编程的一套方法论,抛开这些顶层差异,不同编程语言的底层是相通的。
计算机语言分静态语言,动态语言;解释型,脚本型语言,如何区分?
5.代码机器级表示
虽说掌握了这两个核心概念,加上语言的一些细分语法就可以成为一名程序员,但是我们可不单单满足这点要求。当你写下if这行代码,计算机发生了什么?当你看到for这句代码,又发生了什么,这才是我们要探究的。现在我们来讲讲现在看似简明的代码,计算机是怎么识别的,中间通过什么东西来连接起来?
5.1程序执行内存模型
我们首先来理解程序在计算机内部运行的模式。总体上我们知道程序计算器周期性的指向下一条指令,cpu执行指令时会根据指令名称选择具体运算(加/减/乘/除/移),再根据指令上的地址从内存寻找到数据值,执行完毕又把值传输到内存对应地址。我们知道的只是总体流程,但是具体的规则是怎样的呢?
首先要理解的是一个非常重要的概念,栈,是计算机科学中一种特殊的串列形式的数据结构,其特殊之处在于只能允许在链接串列或阵列的一端(称为堆叠顶端指标top)进行加入数据(push)和输出数据(pop)的运算。
这个跟我们程序的执行有何关系?上文讲到程序执行的过程就是顺序和分支。我们知道cpu里面的核心部件就是构成加法机的电路,而内存就是64位锁存器构成的庞大数组(2^64个地址),以位(8bit)为单位。基于这种内存模型,我们分三种情况讨论。
顺序执行:
代码编译成一条条的指令加载到内存(操作系统分配的代码执行区域),由于是顺序执行,程序计数器将逐步执行内存的执行,最后返回结果。
分支选择:
当指令执行完遇到分支的时候,会根据返回的地址跳转到对应位置执行下一条指令。
嵌套执行:
为了代码复用,我们会把一些常用的代码封装起来,称为函数。带代码执行到函数的位置,就要等待嵌套函数执行完,才能接着往下走。那么再连续的内存是怎么做到的呢?用到的技巧就是我们提到的栈结构了,也成为过程。过程是一种抽象机制,用一组参数和可选返回值实现某一功能,包含:函数,方法,子例程,处理函数。一个函数出现就会在内存区域开辟一段新的连续内存空间。函数代码执行前根据参数数量,参数大小,计算分配栈空间,栈底为内存高地址方向。入栈过程栈指针地址减少,出栈过程栈指针地址增加。当前执行过程的活动记录,由标记顶部位置的帧指针(frame point)和标记底部位置的栈指针(stack point)定义。当函数执行完毕,返回下一条指令地址给到帧指针的位置。
这就是程序在内存里面的生命周期,下面我们来分析代码转换为程序的步骤。
5.2程序语言元素
5.2.1数据格式
机器级代码数据格式:b,w,l,s分别代表字节(8位),字(16位),双字(32位),四字(64位)
5.2.2访问信息
机器寄存器从早期的8个到现在的16个,大小也从从8位扩展到64位
%rax %eax %ax %ah %al,64位的以r开头,32位的以e开头,16位的没有前缀,8位的高位以h结尾,8位的低位以l结尾。
通用寄存器 | 名称 | 用途 |
---|---|---|
EAX | 累加寄存器器 | 运算函数返回值 |
EBX | 基址寄存器 | 内存数据的指针 |
ECX | 计数器存器 | 字符串和循环操作计数器 |
EDX | 数据寄存器 | 乘法除法和IO指针 |
ESI | 来源索引寄存器 | 内存数据指针和源字符串指针 |
EDI | 目的索引寄存器 | 内存数据指针和目的字符串指针 |
ESP | 堆栈指针寄存器 | 堆栈栈顶指针 |
EBP | 基址指针寄存器 | 堆栈栈帧指针 |
EIP | 指令指针寄存器 | 指向下一条指令地址 |
5.2.3 操作数指示符
操作数:立即数,寄存器R[r],内存引用M[Addr],其寻址模式如下:
Imm(rb,ri,s)=Imm+R[rb]+R[ri]⋅s
数据传送指令
mov在内存和寄存器直接传送数据
压入和弹出数据
堆栈数据结构采用先进后出方式
5.2.4算术逻辑操作
加载有效地址,一元操作,二元操作,移位运算
加载有效地址
leaq 有效地址写入目的操作数一元操作
二元操作
移位运算:算术移位,逻辑移位
5.2.5控制
顺序执行->条件,循环,分支
条件
条件码:一组单个位的条件码寄存器
访问条件码
间接使用条件码:根据条件吗组合设置字节,跳转到程序其他地方,有条件的传送数据
跳转指令:jump指令导致程序切换到一个新的位置
跳转指令编码:相对寻址,绝对寻址
用条件控制来实现条件分支:需计算条件结果再选择分支入口
用条件传送来实现条件分支:先按流水线计算多条分支再跟进条件选择结果
循环
do-while,while,for都是由条件+跳转实现
分支
switch语句
5.2.6过程
过程是一种抽象机制,用一组参数和可选返回值实现某一功能,包含:函数,方法,子例程,处理函数。函数代码执行前根据参数数量,参数大小,计算分配栈空间,栈底为内存高地址方向。入栈过程栈指针地址减少,出栈过程栈指针地址增加。
当前执行过程的活动记录,由标记顶部位置的帧指针(frame point)和标记底部位置的栈指针(stack point)定义。
运行时栈:栈帧,定长栈帧在运行前就分配好空间大小,超过6个整数值就需要栈帧,否则通用寄存器就足够
转移控制:入口为函数地址,返回下一条指令地址
数据传送:不超过6个整形参数用寄存器传递数据;超过则需要用栈来保存
栈上的局部存储:局部变量,过程私有空间
寄存器中的局部存储空间:寄存器所有过程共享的空间
递归过程:调用自身方法
5.2.7数组分配和访问
数组,底层数据结构,指针为数组地址,可以运算。
基本原则:同类型数据集合
指针运算:&寻址,*寻值
嵌套数组:多维数组
定长数组:初始化前定义数组长度
变长数组:动态扩展数组容量
5.2.8异质的数据结构
不同对象组合在一起的数据类型称为结构,多个对象集合到一起的数据类型称为联合。
结构:不同类型数据的集合
5.3程序编码
现在我们知道编写的代码是高级程序语言,执行的时候会先转换为汇编代码,最终转换为机器代码,由操作系统统一调度执行。
编码工具:nasm,objdump,gcc,make
机器级代码:机器级代码由二进制指令集和虚拟地址(操作系统将内存和其他设备做地址映射)
5.4 代码示例
C代码解析:
C语言源码sum.c如下:
这个示例包含编程语言中最基本的元素,顺序,分支,判断,循环,函数,调用,通过这个示例我们来探索高级语言中的逻辑在机器语言中是如何体现出来的。
#include <stdio.h>
#include <stdlib.h>
int sum(int count,char *cal[]){
int result = 0;
for(int i = 1;i < count;i++){
result += atoi(*(cal+i));
}
return result;
}
int main(int argc,char *argv[]){
if(sum(argc,argv) > 0){
printf("result > 0");
}else{
printf("result <= 0");
}
return 0;
}
经过gcc编译器编译后生产如下汇编代码sum.s:
[root@linux]# gcc -std=c99 -O -S sum.c
.file "sum.c" #文件名
.text #定义文本段
.globl sum #全局函数
.type sum, @function #使链接程序识别sum函数
sum:
.LFB7:
.cfi_startproc #标志函数开始区域
pushq %r13 #13号寄存器往高位压入8个字节(pushq为4字,8字节)
.cfi_def_cfa_offset 16 #距离父栈栈顶指针地址有16个字节
.cfi_offset 13, -16 #把13号寄存器的值保存在距离父栈栈顶指针地址16个字节的位置
pushq %r12 #12号寄存器往高位压入8个字节
.cfi_def_cfa_offset 24 #距离父栈栈顶指针地址有24个字节
.cfi_offset 12, -24 #把12号寄存器的值保存在距离父栈栈顶指针地址24个字节的位置
pushq %rbp #栈基址指针往高位压入8个字节
.cfi_def_cfa_offset 32 #距离父栈栈顶指针地址有32个字节
.cfi_offset 6, -32 #把6号寄存器的值保存在距离父栈栈顶指针地址32个字节的位置
pushq %rbx #基址寄存器往高位压入8个字节
.cfi_def_cfa_offset 40 #距离父栈栈顶指针地址有40个字节
.cfi_offset 3, -40 #把3号寄存器的值保存在距离父栈栈顶指针地址40个字节的位置
subq $8, %rsp #栈指针减8个字节(4字)
.cfi_def_cfa_offset 48 #栈指针距离父栈栈顶指针地址48个字节
movl %edi, %r13d #将目标寄存器的值传递到13号寄存器保持为整数
movl $0, %r12d #将12号寄存器初始化0
cmpl $1, %edi #对比目标寄存器数据与一的关系
jle .L3 #如果小于或等于条件成立则跳转到L3
movq %rsi, %rbx #将源数据寄存器的值传给基址寄存器
movl $1, %ebp #初始化传给基址指针寄存器值为1
.L4:
movq 8(%rbx), %rdi #将基址寄存器偏移8个字节后传给目标寄存器
movl $10, %edx #将10传给目标数据寄存器
movl $0, %esi #初始化来源数据寄存器值为0
call strtol #调用strtol函数
addl %eax, %r12d #将12号寄存器值传到累加器
addl $1, %ebp #初始化基址指针寄存器值为1
addq $8, %rbx #初始化基址寄存器值为8
cmpl %ebp, %r13d #判断基址寄存器与13号寄存器的值
jg .L4 #如果有符号大于则跳转至L4,即是循环
.L3:
movl %r12d, %eax #12号寄存器的值传给累加器
addq $8, %rsp #8字节内容传给栈指针寄存器
.cfi_def_cfa_offset 40
popq %rbx #基址寄存器弹出8个字节数值
.cfi_def_cfa_offset 32
popq %rbp #基址指针寄存器弹出8个字节
.cfi_def_cfa_offset 24
popq %r12 #12号寄存器弹出8个字节
.cfi_def_cfa_offset 16
popq %r13 #13号寄存器弹出8个字节
.cfi_def_cfa_offset 8
ret #返回
.cfi_endproc #函数调用结束
.LFE7:
.size sum, .-sum
.p .rodata.str1.1,"aMS",@progbits,1 #定义内存段
.LC0:
.string "result > 0" #字符串常量池
.LC1:
.string "result <= 0"
.text
.globl main
.type main, @function #全局main函数
main:
.LFB8:
.cfi_startproc #标志main函数开始区域
subq $8, %rsp #初始化基址指针寄存器8个字节
.cfi_def_cfa_offset 16
call sum #调用sum函数
testl %eax, %eax #分支结果测试
jle .L8 #小于等于条件跳转L8
movl $.LC0, %edi #传递字符串常量池给目标索引寄存器
movl $0, %eax #初始化累加器的值为0
call printf #调用打印函数
jmp .L9 #跳转到L9
.L8:
movl $.LC1, %edi #初始化目标寄存器值为常量池的值
movl $0, %eax #初始化累加器的值为0
call printf #打印函数
.L9:
movl $0, %eax #初始化累加器值为0
addq $8, %rsp #栈指针寄存器初始化为8
.cfi_def_cfa_offset 8
ret #返回
.cfi_endproc
.LFE8:
.size main, .-main
.ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-16)" #编译器版本信息
.p .note.GNU-stack,"",@progbits
这个示例充分展示了现在代码语言执行的机制,回顾上一篇计算机运算的本质,内存模式采用的是按条执行指令。该例子共有两个函数被执行,内存模型体现为两个栈帧租车的堆栈结构。
每一个函数由局部变量和逻辑/算术运算组成的过程分支,在内存都为之分配了一个固定长度的栈,也即是连续地址的内存数组空间。如sum函数中需要5个变量的空间,从汇编代码我们可以找到对应的寄存器。在函数执行过程中程序计数器会按顺序执行每一条指令,最终返回结果。
最后汇编链接成可执行文件:
[root@iZ94lmo2v8eZ 3]# gcc -std=c99 -o sum sum.c
[root@iZ94lmo2v8eZ 3]# ./sum 4 -7 2
result <= 0
JAVA代码解析:
JAVA是面向对象的更加高级的语言,JVM拥有自身的指令集架构,屏蔽了内存操作的细节,以此来保持一次编译到处运行的特点,相比C的一次编写到处编译,效率有所提高,但针对不同应用场景各有千秋。我们也写一个业务逻辑一模一样的JAVA程序,探究其底层的运行逻辑。
[root@linux]# cat Sum.java
public class Sum{
int add(int count,String [] mun){
int result = 0;
for(int i = 0;i < count;i++){
result += Integer.parseInt(mun[i]);
}
return result;
}
public static void main(String [] args){
Sum sum = new Sum();
if(sum.add(args.length,args) > 0){
System.out.printf("result > 0");
}else{
System.out.printf("result <= 0");
}
}
}
经过javac编译器编译后生产如下汇编代码Sum.class:
[root@linux]# javap -c Sum.class
Compiled from "Sum.java"
public class Sum {
public Sum();
Code:
0: aload_0
1: invokespecial #1; // Method java/lang/Object."<init>":()V
4: return
int add(int, java.lang.String[]);
Code:
0: iconst_0
1: istore_3
2: iconst_0
3: istore 4
5: iload 4
7: iload_1
8: if_icmpge 27
11: iload_3
12: aload_2
13: iload 4
15: aaload
16: invokestatic #2; // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
19: iadd
20: istore_3
21: iinc 4, 1
24: goto 5
27: iload_3
28: ireturn
public static void main(java.lang.String[]);
Code:
0: new #3; // class Sum
3: dup
4: invokespecial #4; // Method "<init>":()V
7: astore_1
8: aload_1
9: aload_0
10: arraylength
11: aload_0
12: invokevirtual #5; // Method add:(I[Ljava/lang/String;)I
15: ifle 34
18: getstatic #6; // Field java/lang/System.out:Ljava/io/PrintStream;
21: ldc #7; // String result > 0
23: iconst_0
24: anewarray #8; // class java/lang/Object
27: invokevirtual #9; // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
30: pop
31: goto 47
34: getstatic #6; // Field java/lang/System.out:Ljava/io/PrintStream;
37: ldc #10; // String result <= 0
39: iconst_0
40: anewarray #8; // class java/lang/Object
43: invokevirtual #9; // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
46: pop
47: return
}
JVM虚拟机指令集跟汇编的指令集本质上都是通过使用栈帧结构来管理内存里面的数据和执行指令。物理执行的区别是C编译成汇编代码后直接进入CPU执行,而JVM指令由虚拟机进行本地转换成对应架构指令集后再解释执行。
6.程序性能优化
我们已经知道代码写在文本文件里面后,如何被编码成为二进制程序,然后被执行的过程。合理的代码能够最大限度的发挥编译器的优化性能,从而使代码更高效的执行。了解处理器的运行机理,能够突破编译器的局限性,进一步提高代码执行效率。
6.1表示程序性能
程序性能刻度
每元素的周期数CPE:表示程序运行效率的参数
6.2消除循环的低效率
循环中判断标识改为确定的条件
6.3减少过程调用
过程里面包含一些重复校验工作
过程提高封装性降低性能需权衡
6.4消除不必要的内存引用
运算过程用临时变量减少内存频繁读写
6.5理解现代处理器
指令级并行执行
整体操作
ICU指令控制单元,EU执行单元
分支预测
投机执行
6.6循环展开
减少循环次数
基本编码原则
消除连续的函数调用
消除不必要的内存引用
低级优化:循环展开,边界
7.现代计算机计算模式多样性
经过这三篇的介绍,我们已经从计算机如何通过高低电平表示0和1,到如何用0和1来表示数字,文本,多媒体数据。进而我们采用简单的灯泡开关和电线又模拟了计算机内部计算的机制。最后又介绍了代码从文本输入后如何被一步一步的编码成一条条的指令,然后从内存送到CPU执行,如何取指令,定位内存地址等环节。
随着现代计算机硬件的长足发展,我们已经不需要再为了一点内存空间而绞尽脑汁的去调整程序。我们迫切需要的是更加高效的计算机语言,来提成编程效率,虚拟机技术的出现实现了地址指令的封装,为程序员屏蔽了琐碎的指令细节。由于硬件的成本总体趋向下降,在相当多的场景下我们追求的是摈弃一些重复的细节,把精力放在业务开发上。下一篇我们将介绍基于虚拟机架构上的计算模式,对比一下直接编译运行的模式,来看看这两种计算架构的优劣。