2024-01-01
 3000字
 Python

存储程序

计算机中最重要的部分,非鼎鼎大名的CPU与内存莫属,两者的功能就是彼此配合完成计算机的核心功能——计算。

计算可以分解为若干步对数据的操作。在计算机中,每一步操作都对应一条指令。一条指令分为操作码操作数两部分。操作码告诉CPU执行何种操作,操作数告诉CPU这个操作所需要的数据或者被操作对象在哪里。

一个计算过程可能包含很多操作,也就对应了很多条指令。这些指令按照顺序一条条排列起来,并按照按顺序依次执行。如果某些时候计算要进行分支或者循环,就会执行跳转指令,满足条件则跳转到指令A,否则跳转到指令B。跳转之后就继续顺序执行A或B后面的指令,直到遇到下一条分支或跳转指令。

这就是冯·诺依曼1946年提出的存储程序概念,直到今天都是整个计算机系统的核心工作原理。

内存布局

比特 (bit)是一个信息量单位,可以表示是或否两种状态,对应于二进制中的1和0。内存就是由许多个门电路组成,每个门电路都可以储存1 bit信息。但是一个比特能表达的信息量过少,如果以比特位单位组织内存,会非常不方便,所以人们在比特的基础上提出了字节的概念,每个字节由8比特组成,可以表示$2^8$种状态。在现代计算机系统中,内存都是以字节为单位编址的。所谓编址就是把内存中的各个字节从0开始编号,这个编号就是常说的内存地址。

然而一个字节能表示的信息量还是太少,如果表示整数的话,也就能从0表示到255(或者别的范围,但是能表示的数字总数只有256个),所以经常把若干个字节拼接在一起使其能表示更大的信息量。比如把4个字节拼接在一起,就可以表示$2^{32}$(42,9496,7296)个数字。

也就是说,内存以字节为单位编址的,但是使用时通常一次性使用多个字节。

有些时候某些数据包含很多数据项。例如统计学生的成绩,就需要学号、语文成绩和数学成绩等。如果学号和成绩都使用4个字节大小的带符号整数来保存,单个学生的数据在内存中大概是这样的:

为了表述方便,通常把示意图画成这样的形式(但要注意它们在内存中实际上连续的):

并且把学号成绩等数据项称作字段,或者更形象的称作槽位

上面讨论的『数据在内存中是如何排布的』就是内存布局,包括占用内存大小每个字节(或比特)的含义两个方面。

类型

计算机是靠机械的执行指令来工作的,那这些指令是谁编写的呢?是程序员。但是绝大多数程序员并不会直接编写机器指令告诉CPU做何种操作,而是使用高级语言编写程序(如C语言),然后由翻译程序翻译成CPU可以解读的二进制指令。使用高级语言编写程序,其实就是告诉翻译程序我们想要对数据进行哪些操作,然后再由翻译程序以指令的形式告诉CPU该怎么做。

我们站在翻译程序的角度上看一下a + b这个语句应该如何翻译给CPU。

首先需要把变量a从内存里取出来放到CPU内,结合前面的内存布局不难想到,如果翻译程序知道变量a在内存中的首地址内存布局,就可以告诉CPU:从编号为xxx的字节开始,连续读取xx个字节,这就是变量a的值。

然后需要告诉CPU执行加法操作。但是问题来了,CPU支持两种加法操作:ADD用于整数的加法,FADD用于浮点数加法(与手算小数加法一样,CPU计算浮点数加法也要进行小数点对齐,和整数加法不一样),要让CPU执行哪种加法呢?所以翻译程序还需要知道ab应该进行那种加法,也就是ab支持的操作

首地址内存布局支持的操作三个关键信息中,首地址是不可预知的,但是内存布局和支持操作对于同一类的数据应该是相同或者相似的,所以我们把两者固定下来,称作类型信息

如果是静态类型语言(比如C),需要我们显式的告诉翻译程序变量的类型;如果是动态类型语言(比如Python),翻译程序一般会用某些方法自动推断变量类型。但是不管哪种语言,定义一种新类型的时候都需要明确的告诉翻译程序这种类型的类型信息。

C语言定义结构体和Python用class关键字定义类,都是在告诉翻译程序这种新类型的类型信息。

除了动态类型和静态类型之外,编程语言还有强类型和弱类型之分,指的是对【运算时自动进行类型转换】的支持程度。例如C语言中一个int和一个float进行计算时,会先将int隐式的转换为float,然后两个float进行计算。Python虽然也支持intfloat之间的计算,但是并不会发生类型转换,而是直接计算(在Python层面的表现是直接运算,没有float类型的中间变量产生)。因此Python是一种动态强类型语言,C是一种静态弱类型语言。

当然这个强弱是相对概念,相比于弱到令人发指的JavaScript,C也算是一种“强类型”。

Python执行流程

通常我们会使用命令python <origin_code_file>来执行Python代码,这个命令的作用就是启动Python解释器并且把源代码文件输入解释器,然后解释器会编译代码并执行

编译就是调用Python编译器把代码字符翻译成代码对象SyntaxError错误就出现在这一周期,表示编译时发生了语法错误;执行就是调用Python虚拟机执行编译生成的代码对象。

Python的解释器、编译器和虚拟机的逻辑关系可以参考下图:

图中的依赖库是虚拟机执行代码对象时锁需要的一些库。例如我们import某个模块时,虚拟机就会尝试从sys模块中的path列表中记录的路径中寻找,如果找到了就会正确导入,如果没找到则报错。也就是说,虚拟机的运行依赖于sys模块。除了sys之外,依赖库还包括builtinsos等。

注意,虽然虚拟机在后台使用了这些模块,但是我们用的时候仍然需要显式地导入。

另外,解释器会对被导入的代码文件生成后缀为.pyc的缓存文件,里面是序列化存储的代码对象,以便下次再使用时可以直接读取得到代码对象,省去编译的开销。

交互式环境的运行过程执行与源文件类似,只不过输入的不是源文件而是代码字符串。

在Python中精确访问内存

除了研究源码之外,还可以使用官方的ctypes库来辅助理解和验证Python的内存布局。ctypes定义了一系列和C语言兼容的数据结构和函数,让我们可以在Python层面像C语言那样访问内存。

使用示例如下。

>>> from ctypes import cast, POINTER, c_ulonglong
>>> a = 1.0
>>> ULL_PTR = POINTER(c_ulonglong)
>>> p = cast(id(a), ULL_PTR)
>>> type(p)
# <class '__main__.LP_c_ulonglong'>
>>> type(p.contents)
# <class 'ctypes.c_ulonglong'>
>>> p.contents
c_ulonglong(1)
>>> type(p.contents.value)
# <class 'int'>
>>> p.contents.value
1

在这段代码里,我们首先用ULL_PTR = POINTER(c_ulonglong)定义了一种指针类型,这种类型的指针指向c_ulong_long类型的数据。cast()函数用于类型转换。也就是说,p现在是一个指针,它指向的地址是id(a),但是会以无符号长整型的格式来解读以id(a)为首地址的连续8个字节的内容。

接下来type(p)的结果显示p其实还是一个Python对象,只是它的行为是和C语言中的无符号长整型指针兼容。ctypes中的其它数据结构和函数也与之类似。

p.contents是指针p指向的地址的内容,与C语言中的*p类似,这里指向的是8个连续的字节,按照无符号长整型值为1,但是8个裸字节无法在Python层面使用,于是包装成一个ctypes.c_ulonglong对象。

p.contents.value是把ctypes中的类型转换成Python中的类型,得到一个值相同的常规Python对象。

在后面我们会了解到,这个1不是一个无意义的数字,而是对象a的引用计数。