前言
经学长推荐最近在学习《程序员的自我修养》这本书,是清华大学出版社出版的作品。这本书的内容非常多,所以写篇读书笔记
我们将学到什么?–链接、装载与库
- WIN和Linux的操作系统下的可执行文件和目标文件格式
- C/C++程序代码如何被编译成目标文件和在其中的存储
- 目标文件的链接和执行、符号处理、重定向和地址分配
- 什么是动态链接,为何动态链接,WIN和Linux如何动态链接和动态链接的相关问题
- 可执行文件如何被装载并执行,与进程的虚拟空间之间如何映射
- 堆与栈
- 函数调用惯例,运行库,Glibc和MSVC CRT的实现分析
基础要求:x86汇编语言基础、C/C++、操作系统基本概念和基本编程技巧、计算机系统结构基本概念
1. 温故而知新
从Hello World说起
一个"Hello World"程序,带领无数人进入了程序的世界,而 简单的背后往往有很多复杂的机制,因此到了某些细节会
模糊
|
|
带着这些思考进入之后的学习:
- 程序为什么要被编译器编译了之后才可以运行?
- 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
- 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的,怎 么组织的?
- #include <stdio.h>是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它 怎么实现的?
- 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM), 以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样 吗?为什么?
- Hello World 程序是怎么运行起来的?操作系统是怎么装载它的?它从哪儿开始执行, 到哪儿结束? main 函数之前发生了什么?main 函数结束以后又发生了什么?
- 如果没有操作系统,Hello World 可以运行吗?如果要在一台没有操作系统的机器上运 行 Hello World 需要什么?应该怎么实现?
- printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字 符串?
- Hello World 程序在运行时,它在内存中是什么样子的?
软件、系统
软件
- 系统软件:传统意义上指用于管理计算机本身的软件
- 可分为两类:
- 平台性:如操作系统内核、驱动程序、运行库、数以千计的系统工具
- 程序开发:编译器、汇编器、链接器等开发工具和开发库
- 可分为两类:
计算机系统软件体系结构采用一种层的结构,有人说过一句名言:
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
“Any problem in computer science can be solved by another layer of indirection.”
计算机的整个体系结构从上到下都是按照严格的层次结构设计的,每个层次之间进行通信的协议就是
接口(interface)
,层次之间遵循这个接口的话任何一个层都能被修改或被替换。
除硬件和应用程序外,其他的都是中间层
,而每个中间层都是对下面层的包装和扩展,由此应用程序和硬件保持相对独立(兼容性)
虚拟机
则是在硬件和操作系统之间增加了一层虚拟层,由此一个计算机上可以同时运行多个操作系统
- 应用程序(最上层):网络浏览器、多媒体播放器、文本编辑器、游戏等
- 应用程序编程接口:开发工具和应用程序使用的接口
- 接口提供者:运行库——WIN32 API、Glibc
系统
操作系统:管理硬件资源,使得硬件资源能充分发挥作用
- CPU:
计算机发展早期,CPU 资源十分昂贵,如果一个CPU只能运行一个程序,那么当程序读写磁盘(当时可能是磁带)时,CPU 就空闲下来了,这在当时简直就是暴珍天物
为了协调CPU、内存和高速的图形设备,人们专门设计了一个高速的北桥芯片,以便它们之间能够高速地交换数据。而南桥是连接低频设备的,最初CPU频率甚至和内存差不多
有了监控系统之后,当某个程序暂时无须使用 CPU时,监控程序就把另外的正在等待 CPU 资源的程序启动,但是最大的问题是程序之间的调度策略太粗糙。对于多道程序来说,程序之间不分轻重缓急,如果有些程序急需使用 CPU 来完成一些任务(比如用户交互的任务),那么很有可能很长时间后才有机会分配到CPU。
经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行一段时间以后都主动让出 CPU 给其他程序,使得一段时间内每个程序都有机会运行一小段时间。这对于一
些交互式的任务尤为重要,比如点击一下鼠标或按下一个键盘按键后,程序所要处理的任务
可能并不多,但是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模式叫
做分时系统(Time-Sharing System)
(但如果一个程序霸占了CPU就会类似while(1),直接死循环。。)
程序员肯定不想天天跟硬件打交道(然而早期程序员又不得不这么做😂),所以操作系统逐步发展,在成熟之后硬件被抽象成一系列概念(NIX 中,硬件设备的访问形式跟访问普通的文件形式一样;在 Windows系统中,图形硬件被抽象成了 GDI,声音和多媒体设备被抽象成了DirectX 对象;磁盘被抽象成了普通文件系统,等等)
-
文件系统: 文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证磁盘中的扇区能 够有效地组织和利用。
-
内存: 一个重要问题:如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机有128 MB 内存,程序A运行需要10MB,程序B需要100MB,很容易想到的一种解决方案是:A分配前10MB,B分配10~110MB,但是很明显这样有问题
- 内存利用率低,假设有个C程序需要20MB,这里就需要将B先换出到磁盘,然后再分配给C,有大量的数据换入换出了
- 地址空间不隔离:直接访问物理地址,恶意程序很容易直接改写进行破坏整个内存空间中的程序(
有联想到pwn中未开启ASLR吗
)- 程序运行地址不确定,每次程序装入分配的空间位置不确定 因此我们需要的是把程序给出的地址看作是一种
虚拟地址
,通过映射再进行转化,从而程序物理空间互不重叠。
关于隔离
地址空间分为两种:虚拟地址空间(Virtual Address Space)和物理地址空间(Physical Address Space)
物理地址空间是实实在在存在的,存在于计算机中,而且对于每台计算机只有唯一一个,把物理空间想象成物理内存,比如一台Intel的Pentium4的处理器,它是32位的机器,物理空间有4GB,但是计算机上如果只装了512MB的内存,实际上物理地址的有效部分就是0x00000000 ~ 0x1FFFFFFF
虚拟地址空间则比较抽象,是指虚拟的、人们想象出来的地址空间,而实际上并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,由此有效地做到了进程隔离
每个进程有自己的虚拟地址空间
每个进程在操作系统中都会被分配一个独立的虚拟地址空间。这个虚拟空间对于每个进程来说是连续的,且相互之间不会干扰。例如:
- 进程 A 可能会把自己的虚拟地址空间分配在
0x00000000
到0x7FFFFFFF
之间。 - 进程 B 则会有一个完全不同的虚拟地址空间,比如
0x00000000
到0x7FFFFFFF
,虽然它和进程 A 的虚拟地址空间在地址范围上重叠,但操作系统确保它们彼此隔离,不会互相访问。
进程只能访问自己的虚拟空间 尽管所有进程的虚拟地址空间可能存在重叠,但操作系统通过内存管理单元(MMU)和页表来确保:
分段和分页
- 分段:把虚拟地址空间分成若干段,每段有独立的属性,如可读、可写、可执行等
我们可以将虚拟空间映射到屋里空间,操作系统中设置一个映射函数,然后实际地址由硬件完成- 由此我们便可以实现
1.地址空间隔离(超出范围判断访问出错)
- 物理地址空间不必继续关心(可以不连续),这使得程序不需再次重定位
- 由此我们便可以实现
但是如何解决内存利用效率问题?
- 分页:把虚拟地址空间分成若干页,操作系统决定页大小 Intel Pentium系列处理器支持4KB或4MB页大小,操作系统可以根据此进行选择 页会在我们需要的时候才从磁盘取出来使其真正有效可用
线程
什么是线程? 线程:有时被称为轻量级进程,是程序执行流的最小单元,可见下图
线程拥有自己的私有存储空间,一般包括- 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。
- 线程局部存储(Thread Local Storage TLS) 线程私有空间,但是很有限
- 寄存器
不过我们也可以从另一个角度来看,从程序员角度来看的话
线程私有 线程之间共享 局部变量 全局变量 函数参数 堆上数据 TLS数据 函数里的静态变量 程序代码 打开的文件,例如A打开的由B读写
关于线程调度和优先级的话,这里不多叙述,简单来讲线程调度分为三种状态:
- 就绪态:可以运行,但是没有被调度
- 运行态:正在运行
- 等待态:无法运行,例如等待I/O完成
主流的调度方式尽管各不相同,但都带有优先级调度和轮转法。
我们一般把频繁等待的
线程称之为IO密集型线程(IO Bound Thread),而把很少等待的线程称为 CPU 密集型线程
(CPU Bound Thread)
而在优先级调度环境下,线程优先级改变一般有三种:
- 用户指定优先级
- 根据进入状态频繁程度进行修改
- 长时间得不到执行会被提高优先级(防止饿死)
Linux的多线程
Windows 对进程和线程的实现如同教科书一般标准,Windows 内核有明确的线程和进 程的概念。在Windows API中,可以使用明确的API: Create Process 和 Create Thread 来创建 进程和线程,并且有一系列的API来操纵它们。但对于Linux 来说,线程并不是一个通用的 概念。
Linux将所有执行实体称之为任务(Task),不过Linux下不同任务可以选择共享内存空间。
我们来看看如何创建新任务
系统调用 | 作用 |
---|---|
fork | 复制当前进程 |
exec | 使用新的可执行映像覆盖当前可执行映像 |
clone | 创建子进程并从指定位置开始执行 |
线程安全
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的 线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
我们常使用锁来实现线程的访问同步。
锁是一种非强制机制,每一个线程在访问数据或 资源之前首先试图获取(Acquire) 锁,并在访问结束之后释放(Release)锁。在锁已经被 占用的时候试图获取锁时,线程会等待,直到锁重新可用。
静态链接
程序运行四大过程:预处理、编译、汇编、链接
预编译:处理