一篇文章,回顾大学计算机知识
当今大学计算机教育的“知识孤岛”现象已经越来越为人诟病。
我想,作为学生,我们所厌恶的不单单是那些编写差劲的教材、枯燥乏味的实验课以及跟不上工程实际的C/java课程项目。
问题的关键在于课堂上讲述的知识并没有很好地和我们的实践结合,我们仿佛仅仅是背了一篇又一篇课文——更多用于考研,而在我们点击鼠标和触摸屏幕时,我们仍未能结合课堂上讲的那些原本很有用的理论,去想明白这背后那些元件是怎样运作的。
今天在b站看到一个视频,便是讲的这个问题
【为什么大学没教会你编程-哔哩哔哩】 https://b23.tv/gnjAHRH
作者尝试帮助CS相关专业的迷茫的大学生们梳理那些“知识孤岛”,我认为这是很有用的科普,同时也启发我去根据我个人的认知来进行一些补充。
我将引用他的图片进行直观的展示(后面有时间,我想我应该自己绘制这些图),在此致谢,我也推荐观众结合我的讲述去看看这个视频:我想,另一张嘴总会把一个题材讲出一个不同的故事,在此与诸位学习者共勉。
(0)主线任务:理解程序是怎么从源代码到实际执行的
既然你要梳理你大学所学的零散的计算机知识,不妨把“理解程序从源代码到实际执行”这个问题作为思考时一以贯之的主线。我想你也一定对此很感兴趣。总的来说,程序从源代码到实际执行(running),经历了这三个大的过程:
1. 将“人看得懂”的源代码(文本文件)转换为“机器看得懂”的机器指令(Instruction),这个过程就叫做编译。
对应课程:《编译原理》。
2. 现在我们有了机器指令,将其从硬盘移动到内存(memory)上。
(硬盘,准确地说,叫非易失存储设备;而通俗地说,电脑上它是磁盘或SSD,手机上是闪存,可移动的有U盘和SD卡,老古董上还有光盘、磁带和软盘这个“保存键实体周边”呢)
操作系统(OS)则进行了这一移动,并且不仅是移动,它更要准备一个规则的运行环境,从而让很多进程(proc)有序地运行。
对应课程:《计算机组成结构》(帮助你了解CPU、内存、硬盘这些概念)、《操作系统》(帮助你理解文件、内存管理、进程这些概念)。
3. 现在指令到了更贴近CPU的内存了,现在轮到CPU一条条执行这些内存中的指令,从而完成它们期望完成的目的了,就是这么简单!
对应课程:《计算机组成原理》(帮助你了解CPU的运作)
当然,你会发现还有很多学习的课程没有出现,准确来说,它们一般都发生在第1步之前,也就是你“编程”出源代码之前:比如《数据结构》、《算法》、《设计模式》帮助你写出更合理而成体系的源代码,至于《数据库》、《xx编程语言》这些,其实就是教你怎么使用这些编程的工具,而《机器学习与人工智能》、《计算机图形学》这些,就是理论上的东西了。
不如说《xx编程语言》这些课程往往更接近程序员大部分的工作,毕竟我们又不是搞硬件的,其实上面说的1-3步,我们哪怕不懂,也能把程序写出来,因为编译器、操作系统的开发者已经帮我们干了——这也是很多人觉得学校花那么多课时教这些玩意很“没用”的原因。
对此我只能说,确实写程序不需要懂这些,但懂一点(没错,不需要成为专家,但当涉猎!)这些能够帮你写出更“好”的程序,也能帮你在debug时不至于望着那些看不懂的16进制数和报错信息一脸茫然。
我想,这些底层知识可以在你陷入此等“逆境”时帮你化解困难——当我们有了“科学”的知识体系作为指导,就不会在电脑出问题时寄希望于烧香拜佛的“玄学”了,所以它们可以说是一名合格程序员的基本素养。
(1)编译:从“人懂”翻译到“机器懂”
以C语言为例:首先,我们已经写好了源代码,不管你写的是一个课程作业,还是一个大型平台系统。总而言之现在有数量不等的xxx.c和xxx.h文件,它们就是我们的“源文件”。

C语言编译流程,分为4步
在你上大学的时候,你可能使用windows系统上的IDE来帮你编译C语言代码,其实这并不算一种能直观了解编译原理的方法。我推荐linux+GCC等工具帮助你完成源代码到可执行二进制文件的编译,现在不妨打开你的linux虚拟机,写一个最简单的hello.c程序:
printf(“Hello world!”)
现在,跟着我完成编译的过程。

1. 预处理(Preprocessing):去掉源代码中的注释、展开头文件和宏。
指令:cpp hello.c > hello.i
这里的cpp可不是C++哦,而是C PreProcessing事实上它是一个linux指令工具,实际上也是一个应用程序,跟我们写的这个helloworld没本质区别。
cpp完成的仍然是一些“文字工作”,输出的.i文件仍然是.c这样的文本文件,所谓的“头文件展开”,也就是物理意义上将头文件的内容复制一份到原本#include的地方,一些文件的行数可能就会得到很大的扩展,当然这里面也有诸如#pragma once这样的技巧来避免包含一些重复的头文件。
2. 编译(Compilation):将C语言代码变成汇编码
指令:gcc <op> -S hello.i
<op>代表一些可选的参数,比如-g:生成调试信息、-Werror:将警告视为错误这些帮助我们debug的东西。当然不写也是可以的。
3. 汇编(Assemble):将汇编码变成真正的“机器能听懂”的指令
指令:as <op> hello.s -o hello.o
所谓汇编码离计算机能直接执行的机器码(指令),还有一段距离。后者我们一般将其称为“可执行的二进制文件”,而一个汇编语言文件.s仍然还是像C语言一样的“文本文件”。其实无论文本还是二进制文件,其本质都是二进制文件,毕竟计算机只能存储0和1啊,只不过文本文件可以被解读为我们人类看得懂的语言,而二进制文件更强调“计算机直接能看懂”了——当然,如果你是个有着“最强大脑”的“极客”,你或许也能直接读懂二进制文件!
所谓汇编这一过程,也就是将汇编语言,根据我们这个计算机上实际的CPU指令集架构,转化为CPU能够理解并执行的指令,也就是机器码啦!执行这一过程的工具,也就是as程序了!
4. 链接(Linking):链接我们写的程序所调用的外部的代码(以静态库的形式),以及一些其他的解析映射类工作,最终组合成一个可执行的二进制文件。
其实我们这里并不需要链接什么外部的静态库,因为我们只是写了一个简单的helloworld代码嘛!(当然,printf这个函数其实是C标准库libc的内容,其实也不在我们写的代码里面,只不过如果我们在链接时没有指定-static参数的话,libc就会在程序运行时动态加载到内存中,而不是静态附加到代码段。)
此时我们要生成最终的可执行文件,这样就够了:
指令:gcc hello.o -o hello
即使代码没有使用外部库,“链接”这一步仍然是必要的,它的含义可不单单是引用外部。它还包括(1)建立函数名、变量名等符号到内存地址的映射关系(2)添加启动代码,启动代码之后就是调用你源码里面的main函数了(3)将运行一个程序(进程)所必要的指令和数据打包到可执行文件中。此外,这个时期也会进行一些编译器优化:也就是编译器自动合并、删除指令以及调整一些指令的执行顺序,从而达到性能优化的效果。
如果你在代码中需要使用到外部库,比如你包含了#include <math.h>,那么你就还需要在gcc指令中加上-lm参数(指定库路径 -L<name>、指定库名 -l<name>)。
以上,我们关于编译的实验就到此为止了。
当然你可能会想:我编译个helloworld都要敲这么多指令,那要是项目规模大了还得了啊——不如IDE方便。
的确,正经的C语言大型项目开发中,肯定不可能一句一句敲这些命令的,但是我们一般也不会用IDE来进行编译(这里仅针对linux C语言编程),我们更多是用IDE来编辑代码:它的自动检查和文本查找功能很好用;而编译我们一般使用“Makefile”文件,你可以将其理解成一个自动规划源代码编译步骤的脚本,它的底层仍然是调用我们刚用到的cpp、gcc、as、ld这些工具,只不过我们现在只需要一个make O=build/xxx 这类的命令就可以完成自动编译、清除产物等原本复杂的任务了。
(2-1)了解计算机的组织结构。
这一章,主要是了解计算机的硬件设备:
(1)存储设备:内存、硬盘。
(2)核心的计算设备:CPU、GPU(你可以理解为CPU的“外包公司”)
(3)输入设备:键鼠、手柄……
(4)输出设备:显示器、音响、耳机……
以及这些设备如何在“冯诺依曼计算机架构”下各司其职——这也就是《计算机组成结构》这门课的核心了,你或许也在课堂上学了个大概;也许你不是计算机专业的,那么不如找个科普视频看看——我所提到的这个视频的作者讲的也很清晰了。

如果你认为自己对于计算机的架构还不甚了解,不如借助这些视频好好梳理一下,然后结合你使用电脑、手机、游戏机、智能家居的实际生活,想一想在我执行一个电脑程序、打开一个移动App、插上卡带启动一个游戏、语音唤醒了小X助手的时候:在这些计算机的内部各个设备之间发生了什么,又是怎么最终达到我的目的呢?我想这道思考题的关键在于:“数据是怎么在各个设备之间流动的”。
(2-2)运行程序时,发生了什么?
关于上一章提出的思考题,不妨我们就拿“电脑/手机启动应用程序”这一最常见的例子来讲解。
首先,输入输出的过程,我想你应该也想得到——关键是电脑在接受了我们的输入,到向我们输出结果之间,是如何管理应用程序的呢?
相信你也知道了,这里面关键的角色是“操作系统”(OS),它可以说是介于计算机硬件设备和软件应用之间的沟通的“桥梁”,也可以说是所有应用软件都要服从的“权威”——只要你运行在我的系统中,你就必须服从我的规则。让我们来看看当我们的“启动程序”的命令下达OS后,它到底做了什么吧:

【1】OS从硬盘(准确说:非易失存储设备)中取出这个程序(program)启动所必要的指令和数据加载到内存中,由此形成了一个进程(process)。进程,这可是OS中的关键概念,它可以理解为程序的运行实例。
而至于硬盘是如何挂载到OS中,组建一个文件系统(FS)的,FS中的文件又是怎么被管理的,这便是《操作系统》的另一个话题了。
总而言之,这一步的核心在于:OS指导计算机,将数据从硬盘搬到内存,从而为下一步“构造进程的内存空间”打下基础。
不妨点开过一个已安装应用程序的文件夹看看:exe和dll(动态库,linux里面叫so)文件自然是“指令”,那些文字图片声音资源自然是“数据”,想想看,每个文件都会在什么时候加载到内存中。
【2】 现在我们已经有了一个进程——但它还处于“草创阶段”,我们只有一些启动所必要的数据和指令,并且我们预料到随着这一应用的运行,还会有更多的数据被加载或生成——因此,有序地管理内存空间是关键,得把这些数据安置好!不然,要么你的内存很快就不够用了,要么这些形形色色的数据就乱作一团——内存管理的混乱可是软件崩溃的一大重要原因。
那么OS是如何进行进程的内存管理的呢?首先,OS为每个进程分配一份独立且连续的内存空间(即虚拟内存,Vm),在这一“圈地”里,进程完全可以把内存视作它自个独有的,而不必担心其他进程的干扰——要知道,一台计算机同时运行的进程可不止一个,假如我们不给每个进程分配一个“独立领地”,而是让它们共享这一整块内存,我们就必须时时刻刻关注进程A有没有侵犯进程B的领地:每个进程“圈地自萌”,就避免了这种无谓的“领土争端”。
那么,在这块进程的虚拟内存之中,又是怎么组织和分配的呢。我们不妨以linux操作系统为例,一步步为你讲解:

(2)建立虚拟内存到实际的物理内存的地址映射:比如你的电脑是16GB的内存条吧,那么你的物理内存空间就是0-0x3FFFFFFFF(也就是2的34次方减1,1K/M/G就是2的10/20/30次方,而内存地址是以Byte为单位的,而不是bit)。
实际上,某时刻的某个物理地址,其对应的变量或指令是唯一的:比如0x010000000,但是在每个进程的“独立领地”中,它们各自都可以使用0x10000000这个地址,而不必担心互相冲突,因为进程自会有一个页表(page table)将这些虚拟内存映射到物理内存的,而映射之后的地址都是独一无二的了。至于内存映射具体是怎样一个算法,包括OS如何基于一块完整的内存统筹规划各个进程内存空间占用的方法:感兴趣的话不妨下来自己了解下。

linux将进程的内存又分为内核空间和用户空间,对于我们使用的微信、浏览器这些用户进程来说,它们在正式运行时只能使用低3G的“用户空间”。那么你会想:既然app不让用,这高1G的内核空间是拿来干嘛的呢:答案是,操作系统进行进程切换等需要更高权限的工作时,就会切换为内核模式,此时就可以使用内核空间,当完成这些高权限工作后,又会切换回用户模式,此时内核空间就又被屏蔽了。关于这部分详细的内容,感兴趣的话不妨了解一下linux的“内核”这一概念。


下面详细介绍属于“用户空间”每一段内存空间,从低到高讲解:

(1)Text Segment(代码段):进程可执行的二进制文件(windows的.exe后缀的文件,linux则叫ELF文件,而linux的后缀名是给人看的,也可以不起后缀名:比如我们运行python test.py这个程序,这里面python就是python解释器的可执行文件)中所包含的机器码的部分,其实也就是我们前面所说的一条条指令——既不是C语言语句,也不是汇编码,而是货真价实机器能听懂的“机器码”。
(2)Data Segment(数据段):不妨暂时理解为,C语言中指定了初始值的static变量待的位置(当然,static的含义不止于此,至今也是面试八股时爱考的基础知识点)
BSS Segment:没有指定初始值的static变量待的位置。
你是否已经忘了static的含义?

回顾一下作用域和生存期的概念吧,当然这个只是简要的讲解
如图里示例:当你使用
static int count = 0;
这样的C语句声明变量时,它们就会被放到这一段中。
(3)Stack(栈):当你往函数传入参数、或者在函数内部声明
int count = 0;
这样的局部变量时,它们都会生成到“栈”中。当你调用一个函数时,也会将一个特殊的数据结构:栈帧(stack frame)压入到栈中,这个栈帧会在此函数返回时被清理掉。可以看到,栈中的数据的生命周期都是小于进程的,在它们被用完后,它们占用的空间就会被释放;相对地,也会有新的数据不断申请新的栈空间,所以栈和堆实际占用的空间都是会“生长”的,而不是像数据段和代码段一样,在进程开始时就固定了空间。
栈中数据的内存申请和释放,是由编译器自动完成的,我们程序员不需要特地在意这个。
(4)Heap(堆):当你在某函数func()中使用
int *arr;
arr = (int *)malloc(n * sizeof(int));
这样的语句动态分配内存时,你就会用到堆了。需要注意的是:arr作为一个局部的指针变量,其本身是放在栈中的,只是,arr所指向的这个分配的数组,是被放在堆中的。
在C语言中,堆中数据的内存分配和释放,必须要我们程序员上心——我们常说:永远不要忘了每个malloc都要有个配对的free。
经常有人说:python是没有指针的——实际上python中还是有内存地址的(试试hex(id(var)))的,只不过它不让程序员直接操作内存地址罢了。
像python这样的语言,将堆内存的分配和释放封装进了语言本身的回收机制之中,这样无疑就帮我们省了不少事。
(5)Memory Mapping Segment(内存映射段):总的来说,是将文件的内容映射到进程的虚拟地址空间中,使得进程可以直接读写文件而无需通过传统的 I/O 操作。
事实上,我对于这一部分也不能说很清楚,我暂时将其理解为和堆一样的动态分配的内存,只是它的目的更多是进行性能的优化:不用内存映射段,程序或许也能跑通,只是这样效率更高罢了——在计算机中很多问题不仅仅是“对不对”,而是效率与资源的取舍。就像图中所说的那样,加载动态库就是个常见的场景。
我们讲了很多的内存空间,现在整理一下:它们主要可以分为以下两类
|
分类 |
特点 |
|
|
地址固定:进程运行时,地址对应的变量或指令不会改变。 特别地,代码段中的指令的值也不会被改变; 而数据和BSS段中变量的值可能被改变(除非再加一个const) 总言之:变量的生命周期==进程的生命周期 |
|
|
地址不固定:进程运行时,住在某个地址上的变量是有可能改变的。铁打的地址、流水的变量。这几段内存地址会经历很多的申请和释放。 总言之:变量的生命周期<进程的生命周期 |
结合内存的知识,是不是就更容易理解C语言课堂上的“作用域”和“生存期”的含义了呢?所以说C语言是能体现底层特性的高级语言,不是乱说的啊!
【3】现在我们已经在内存中为进程划分好了区域,并且也把进程启动时所必需的指令和数据(也就是那个二进制可执行文件中的内容了)加载进了其中的代码段和数据段。

现在可以正式执行这个程序(进程)了:“执行”进程,也就意味着OS要向这个进程转交CPU的控制权:要将CPU中的PC寄存器指向代码段的入口,并且正式让它开始一条条执行里面记载的指令。
当然,哪怕是只有一个处理核心的单核CPU,同一时段内还是会运行多个进程啊,想想你的电脑:挂着微信、浏览器上放着视频,此时你又打开了一个游戏客户端,此外别忘了,OS内部也有不少进程啊——刚刚提到的内存管理、进程管理、文件系统,以及网络功能、硬件驱动,这些统统需要CPU运行啊!
对于单核CPU,事实上它在每个时间步内也只能执行一条指令,最多完成一项任务:那么它是怎么做到同时运行多个进程(也就是行内人经常说的并发)的呢——首先每个时间步被设计得足够短(比如一个Core i3的频率4GHz,也就是说它每个周期的脉冲信号仅仅用时0.25ns),其次CPU处理每条命令的速度也足够快:从而给我们形成一个很多应用程序仿佛在同时运行的幻觉。
至于到底是怎么安排各个进程指令的执行顺序,这就是OS的进程调度及管理的内容了。

至于os是如何实现进程切换,并且在切换前后保证cpu中寄存器的值都能被保存,这就是上下午切换的内容。
推荐你看这个视频:【16分钟讲明白:程序、进程、内存和CPU到底啥关系?| 静态文件 / 地址空间 / 计算机底层-哔哩哔哩】 https://b23.tv/Z5uctFd
当然,现在我们用的CPU很多都是多核的了,这意味着它们可以借此实现更好的进程任务执行的并行性。
(3)CPU怎么执行指令
这一部分,就是《计算机组成原理》的核心了,哪怕是大学生的你,应该也知道:程序的核心其实就是CPU执行一条条指令,哪怕这个简单的本质外在包了再多的操作系统、架构、编程技巧,它的核心仍然就是这个,没了它,外在的一切都无从谈起。
那么,结合我们前面梳理的知识,再来回顾下这一核心过程吧:
现在,程序(进程)运行中所需要的数据和指令(其实也是一种数据)已经被加载到内存中了。CPU已经可以通过数据总线来访问它们,我们前面说过:内存相比于硬盘,是一种很“快”的介质,但其实还不算最“快”——实际上,CPU访问内存的速度比起它自身运算的速度,还是太慢了,以至于成为了后者的瓶颈——就像你每天上班都要坐30分钟地铁一样,虽然不至于像硬盘那样还要“坐长途”,但是毕竟还是比不过“住公司”的人啊。
我们该怎么让一些最近使用的数据“住在公司(也就是CPU)”里呢?——答案是比内存更高速的寄存器(Reg)和缓存(Cache,你可以理解为寄存器和内存之间的存储元件),计算机具体的存储器层次结构如下(这里面的RAM主存,其实也就是前面所说的内存了)。
实际的架构是很复杂,但大致遵循Reg——内存——硬盘这么个“快到慢”的架构:

寄存器就住在CPU里面,CPU获取它的速度也很快,但是寄存器比起内存的容量,就更少了,CPU执行指令的过程,就涉及将指令和数据在Reg、内存、硬盘之间搬来搬去,CPU执行一个进程,总的来说是这么个循环:
0. 程序的代码段,总有个入口,在C中,就是那个main()函数,告诉CPU这个起点,它就从这里开始执行第一条指令。
1. CPU内有个特殊的程序计数器(PC)寄存器,它为CPU指引了下一条将要执行的指令在内存中的地址,而指令的具体内容呢,CPU根据PC从该进程的内存的代码段之中取出这条指令,将其值放入指令寄存器(IR)中。
2.CPU的指令解码器(ID),根据这枚CPU的指令集架构(ISA,简单地说,电脑中最常见的x86 CPU使用的是较为复杂的CISC指令集;手机中最常见的ARM CPU使用的是较为精简的RISC指令集),将IR中存储的二进制数据变成实际的指令操作。
3. 自然,接下来就轮到CPU的算术逻辑单元(ALU)执行具体的指令操作。这主要包含这么几种:
-
算术运算、逻辑运算 -
把数据移动或复制到某个地址 -
跳转到一条别的指令(而不是按照PC的指引顺序执行,这也就是条件、循环语句的原理了)。
如果你想详细了解,不如搜一搜你的手机或电脑使用的指令集里面到底都有哪些指令,不妨试着将其与你C语言课堂上学到的各种语句对应一下,会有更深的理解。
4. 计算操作自然需要数据了:无论是运算要用到的操作数,还是赋值、取地址操作中要用到的数据在内存中的地址,再到条件或控制语句中要用到的指令在内存中的地址——这些数据最开始都在内存里,此时CPU就要访问内存把变量取到Reg中,而在操作完之后将Reg中的值刷回内存完成更新(如有需要的话)。
而对于一些频繁操作的变量,编译器可以在编译时进行一种指令的优化:也就是让它们更久地“住在公司”,等到真正必须刷回时再让它“坐地铁回去”,这也就和C语言中的volatile关键字有关了。
5. 好啦,现在我们的CPU已经完成了一条指令,那么它该继续执行其他指令了,如果此时没有“跳转”指令,不出意外的话CPU就是按照顺序执行内存代码段之中下一条指令了:至于具体的操作,还记得那个“记载了下一条指令的地址”的PC寄存器吗?我们只需要将PC += len(每条指令的长度),然后再回到第1步开始新一轮的循环即可。
而至于我们的“跳转”指令,相信你也不难想到:它也只不过是直接给PC指定了一条指令的地址而已,至于CPU在跳转后,又怎么回到它出发的地方而不至于混乱,不妨想想编译器、操作系统能怎么实现这一点。

(4)一个实例
为了帮助你具象地了解指令和寄存器,让我们看看下面这一张core dump的打印——它记录了程序崩溃时寄存器和内存以及函数调用的状态,如果你在编译时加载了符号表,它还可以贴心地将机器指令和你编程时写的代码建立联系,从而成为你debug的利器。我们不妨从这张dump里面回顾一下以上讲到的知识。

首先,我用的是一台ARMv8(也就是你手机使用的CPU架构)+Linux(OS)的设备,ARMv8架构支持31个通用寄存器(r0-r30),而前面所说的PC属于特殊寄存器,可以看到,PC的值正好是我们下面的“函数调用栈回溯”(backtrace)的<1>(由于<0>是我们开发平台用来处理debug信息的,所以<1>就代表程序崩溃时运行的指令的地址),也就是说程序崩溃时,我们的CPU正运行到处于内存地址0x0000ffff995af9e0
(你发现它的地址有16个16进制数,那也就是64个二进制数,所以这是个64位的设备,使用64bit的地址空间,所以它可以表示2的64次方个地址单元,想想吧,一根32GB的内存条也不过有2的35次方个存储单元而已,64位机的可用地址完全容得下差不多五亿个32G内存,而我前面在讲解linux的虚拟内存时地址很明显没有这里这么长对吧,事实上它只有32位,你可以把前面缺的部分用0填充,总之,感兴趣的话不妨了解下64位linux系统到底如何划分地址单元的)
的这么一条指令,而由于我们加载了符号表,所以linux还贴心地告诉我们,这条机器指令在被编译之前,正好是memset这一函数的一部分,那么此时我按图索骥,找到代码中对应的部分:
memset(bufData, 0, payloadLen);
其中bufData是我打算清零的一个缓冲区(也是一个字符串),payloadLen此时是9,就是我打算将这个数组的前9个元素清零。你可以看到这三个参数正好对应r0-r2三个寄存器,事实上在ARMv8中,r0-r7就是用于函数参数/返回值传递的寄存器,你如果想增长自己的视野,不妨继续搜搜其余寄存器是用来做什么的。
如果你从来没有认真地将课堂上所学和实际的计算机操作结合在一起的话,你是否也会有着对着程序报错的日志和一堆16进制数发懵的时候呢?但是经过梳理,关键是自己的思考,你是否也能多少看懂这个乍一看很唬人的core dump打印呢?我想,我们应该持续保持这种学习中实践的态度,或许才是计算机学习和工作的不二法门吧。


评论