程序的一生:从源程序到进程的辛苦历程

一、前言

作为计算机专业的人,最遗憾的就是在学习编译原理的那个学期被别的老师拉去干活了,而对一个程序怎么就从源代码变成了一个在内存里活灵活现的进程,一直也心怀好奇。这种好奇驱使我要找个机会深入了解一下,所以便有了本文,来督促自己深入研究程序的一生。不过,本文没有深入研究编译原理、操作系统原理,而是主要聚焦于程序的链接和加载。

学习的过程中主要参考了三本书、一个视频、一个音频(文末有列出),三本书里,最主要的还是《程序员的自我修养 - 链接、装载与库》,里面的代码放到了我的github上,并且配有shell脚本和说明,运行后可以实操理解到更多内容。

南大袁春风老师的计算机原理讲解对我帮助最大,视频是最直接传达知识的方式。另外,为了方便自己的实验,制作了一个ubuntu的环境,并且内置了代码,方便实验:阿里docker镜像

docker pull registry.cn-hangzhou.aliyuncs.com/piginzoo/learn:1.0

 

二、概述

每天都有无数的程序被编译、部署,不停地跑着,它们干着千奇百怪的事情。如同这个光怪陆离的世界,是由每个人、每个个体组成的,如果我们剖析每个人,会发现他们其实都是一样的结构,都是由细胞、组织组成,再深究便是基因了,DNA里那一个个的“核苷酸基”决定了他们。

同样,通过这个隐喻来认知计算机,我们可以知道,计算机的基因和本质就是冯诺依曼体系。啥是冯诺依曼体系呢?通俗地讲,就是定义了整个硬件体系(CPU、外存、输入输出),以及执行的运行流程等等。可是,一个程序怎么就与硬件亲密无间地运行起来了呢?应该很多人都不了解,甚至包括许多计算机专业的同学们。

本质上来说,这个过程其实就是“从代码编译,然后不同目标文件链接,最终加载到内存中,被操作系统管理起来的一个进程,可能还会动态地再去链接其他的一些程序(如动态链接库)的过程”。看起来似乎很简单,但其实每个部分都隐藏着很多细节,好奇心很强的你一定想知道,到底计算机是怎么做到的。

本文不打算讨论硬件、进程、网络等如此庞大的体系,只聚焦于探索程序的链接和加载这两个主题。

 

三、基础

探索之前需要交代一些基础知识,不然无法理解链接和加载。

3.1 硬件基础

3.1.1 CPU

CPU由一大堆寄存器、算数逻辑单元(就是做运算的)、控制器组成。每次通过PC(程序计数器,存着指令地址)寄存器去内存里寻址可执行二进制代码,然后加载到指令寄存器里,如果涉及到地址的话,再去内存里加载数据,计算完后写回到内存里。每条指令都会放到指令寄存器(IR)中,等着CPU去取出来运行。

指令是从硬盘加载到内存里,又从内存里加载到IR里面的。指令运行过程中需要一些数据,这又要求从内存里取出一些数据放到通用寄存器中,然后交给ALU去运算,结果出来后又会放到寄存器或者内存中,周而复始。

每一步都是一个时钟周期,现在的CPU一秒钟可以做1G次,是1000000000,几十亿次/秒。目前市场上的CPU主频据说到4GHz就到极限了,限于工艺,上不去了,所以慢慢转为多核,就是把几个CPU封装到一起共享内部缓存。

3.1.2 主板

如图,我们经常听说的“北桥、南桥”是什么?

北桥其实就是一个计算机结构,准确地说是一个芯片,它连接的都是高速设备,通过PCI总线,把cpu、内存、显卡串在一起;而南桥就要慢很多了,连接的都是鼠标、键盘、硬盘等这些“穷慢”亲戚,它们之间用ISA总线串在一起。

3.1.3 硬盘

硬盘硬件上是盘片、磁道、扇区这样的一个结构,太复杂了,所以从头到尾给这些扇区编个号,就是所谓的“LBA(Logical Block Address)”逻辑扇区的概念,方便寻址。

为了隔离,每个进程有一个自己的虚拟地址空间,然后想办法给它映射到物理内存里。如果内存不够怎么办?就想到了再细分,就是分页,分成4k的一个小页,常用的在内存里,不常用的交换到磁盘上。这就要经常用到地址映射计算(从虚拟地址到物理地址),这个工作就是MMU(Memory Management Unit),为了快都集成到CPU里面了。

3.1.4 输入输出设备

还有很多外设负责输入输出,一旦被外界输入或要输出东西,就得去告诉CPU:“我有东西了,来取吧”;“我要输出啦,来帮我输出吧”。这些工作就要靠一个叫“中断”的机制,可以将“中断”理解成一种消息机制,用于通知CPU来帮我干活。不是每个部分都可以直接骚扰CPU的,它们都要通过中断控制器来集中骚扰CPU。

这些外设都有自己的buffer,这些buffer也得有地址,这个地址叫端口。

还得给每个设备编个号,这样系统才能识别谁是谁。每次中断,CPU一看,噢,原来是05,05是键盘啊;06,06是鼠标啊。这个号,叫中断编号(IRQ)。

每次都必须要骚扰CPU吗?直接把数据从外设的buffer(端口)灌到内存里,不用CPU参与,多好啊!对,这个做法就是DMA。每个DMA设备也得编个号,这个编号就是DMA通道,这些号可不能冲突哦。

3.2 汇编基础

对于汇编,我其实也忘光了,所以得补补汇编知识了,起码要能读懂一些基础的汇编指令。

3.2.1 汇编语法

汇编分门派呢!"AT&T语法” vs “Intel语法”:GUN GCC使用传统的AT&T语法,它在Unix-like操作系统上使用,而不是dos和windows系统上通常使用的Intel语法。

最常见的AT&T语法的指令:movl、%esp、%ebp。movl是一个最常见的汇编指令的名称,百分号表示esp和ebp是寄存器。在AT&T语法中,有两个参数的时候,始终先给出源source,然后再给出目标destination

AT&T语法:

<指令> [源] [目标]

3.2.2 寄存器

寄存器是存放各种给cpu计算用的地址、数据用的,可以认为是为CPU计算准备数据用的。一般分为8类:

命名上,x86一般是指32位;x86-64一般是指64位。32位寄存器,一般都是e开头,如eax、ebx;64位寄存器约定以r开头,如rax、rbx。

1)32位寄存器

32位CPU一共有8个寄存器。

详细的介绍:

2)64位寄存器有:32个

两者的区别:

  • 64位有16个寄存器,32位只有8个。但32位前8个都有不同的命名,分别是e _ ,而64位前8个使用了r代替e,也就是r 。e开头的寄存器命名依然可以直接运用于相应寄存器的低32位。而剩下的寄存器名则是从r8 - r15,其低位分别用d,w,b指定长度。 

  • 32位寄存器使用栈帧作为传递参数的保存位置,而64位寄存器分别用rdi、rsi、rdx、rcx、r8、r9作为第1-6个参数,rax作为返回值。 

  • 32位寄存器用ebp作为栈帧指针,64位寄存器取消了这个设定,没有栈帧的指针,rbp作为通用寄存器使用。 

  • 64位寄存器支持一些形式以PC相关的寻址,而32位只有在jmp的时候才会用到这种寻址方式。

对了,寄存器可不是L1、L2 cache啊!Cache位于CPU与主内存间,分为一级Cache (L1Cache)和二级Cache (L2Cache),L1 Cache集成在CPU内部,L2 Cache早期在主板上,现在也都集成在CPU内部了,常见的容量有256KB或512KB。寄存器很少的,拿64位的来说,也就是16个,64x16,也就是1024,1K。

总结:大致来说数据是通过内存-Cache-寄存器,Cache缓存是为了弥补CPU与内存之间运算速度的差异而设置的部件。

3.2.3 寻址方式

接下来说说寻址,寻址就是告诉CPU去哪里取指令、数据。比如movl %rax %rbx,这个涉及到寻址,寻址会寻“寄存器”、“内存”,可以是暴力的直接寻址,也可以是委婉的间接寻址。下面是各种寻址方式:

你可能会看到这种指令movl,movw,mov后面的l、w是什么鬼?

就是一次搬运的数据数量。

3.2.4 常用的指令

最后说说指令本身,每个CPU类型都有自己的指令集,就是告诉CPU干啥,比如加、减、移动、调用函数等。下面是一些非常常用的指令:

参考:愿意自虐的同学,可以下载【Intel官方的指令集手册】仔细研读。

3.3 一些工具和玩法

本文还会涉及到一些工具:

  • gcc:超级编译工具,可以做预编译、编译成汇编代码、静态链接、动态链接等,本质上是各种编译过程工具的一个封装器。 

  • gdb:太强了,命令行的调试工具,简直是上天入地的利器。 

  • readelf:可以把一个可执行文件、目标文件完全展示出来,让你观瞧。 

  • objdump:跟readelf功能差不多,不过貌似它依赖一个叫“bfd库”的玩意儿,我也没研究,另外,它有个readelf不具备的功能:反编译。剩下的两者都差不多了。 

  • ldd:这个小工具也很酷,可以让你看一个动态链接库文件依赖于哪些其它的动态链接库。

  •  cat /proc/<PID>/maps:这个命令很有趣,可以让你看到进程的内存分布。

还有各种利器,自己去探索吧。

3.4 其他

3.4.1 地址编码

假如有个整形变量1234,16进制是0x000004d2,占4个字节,起始地址是0x10000,终止地址是0x10003,那么在外界看来,是它的地址是0x10000还是0x10003呢?答案是0x10000。

那么问题来了,这4个字节里怎么放这个数?高地址放高位,还是低地址放高位?答案是,都可以!

大端方式:高位在低地址,如 IBM360/370,MIPS

小端方式:高位在高地址,如 Intel 80x86

 

四、编译

由于我没学过编译,对词法分析、语法分析也不甚了解,找机会再深入吧,这里只是把大致知识梳理一下。

词法分析->语法分析->语义分析->中间代码生成->目标代码生成

4.1 词法分析

通过FSM(有限状态机)模型,就是按照语法定义好的样子,挨个扫描源代码,把其中的每个单词和符号做个归类,比如是关键字、标识符、字符串还是数字的值等,然后分门别类地放到各个表中(符号表、文字表)。如果不符合语法规则,在词法分析过程中就会给出各类警告,咱们在编译过程中看到的很多语法错误就是它干的。有个开源的lex的程序,可以体会这个过程。

4.2 语法分析

由词法分析的符号表,要形成一个抽象语法树,方法是“上下文无关语法(CFG)”。这过程就是把程序表示成一棵树,叶子节点就是符号和数字,自上而下组合成语句,也就是表达式,层层递归,从而形成整个程序的语法树。同上面的词法分析一样,也有个开源项目可以帮你做这个树的构建,就是yacc(Yet Another Compiler Compiler)。

4.3 语义分析

这个步骤,我理解要比语法分析工作量小一些,主要就是做一些类型匹配、类型转换的工作,然后把这些信息更新到语法树上。

4.4. 中间语言生成

把抽象语法树转成一条条顺序的中间代码,这种中间代码往往采用三地址码或者P-Code的格式,形如x = y op z。长成这个样子:

t1 = 2 + 6
array[index] = t1

不过这些代码是和硬件不相关的,还是“抽象”代码。

4.5 目标代码生成

目标代码生成就是把中间代码转换成目标机器代码,这就需要和真正的硬件以及操作系统打交道了,要按照目标CPU和操作系统把中间代码翻译成符合目标硬件和操作系统的汇编指令,而且,还要给变量们分配寄存器、规定长度,最后得到了一堆汇编指令。 

对于整形、浮点、字符串,都可以翻译成把几个bytes的数据初始化到某某寄存器中,但是对于数组等其它的大的数据结构,就要涉及到为它们分配空间了,这样才可以确定数组中某个index的地址。不过,这事儿编译不做,留给链接去做。

编译不是本文重点,这里就不过多讨论了,感兴趣的同学ÿ

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值