花了一个月左右的时间,终于把《深入理解计算机系统》第二版看完了,为了读完这本书还提前看了四本书
读本书的过程是痛苦并快乐着,虽然对整个系统有了一个整体的认识,但是有些东西还是需要去实践才能弄懂,不过我觉的
本书是一个很好的计算机入门的读物,很好很强大。


1. hello wordl

我们还是从hello world程序说起吧:

1
2
3
4
5
6
7
#include <stdio.h>
int main()
{
printf("hello, world! \n");
return 0;
}

让我们看看从源码到可执行文件,再到运行输出结果之间到底经历了怎么样的过程吧:


编译阶段

2.1 预处理

1
gcc -E test.c -o test.i 或 gcc -E test.c

可以输出test.i文件中存放着test.c经预处理之后的代码。打开test.i文件,看一看,就明白了。
后面那条指令,是直接在命令行窗口中输出预处理后的代码.gcc的-E选项,可以让编译器在预处理
后停止,并输出预处理结果。在本例中,预处理结果就是将stdio.h 文件中的内容插入到test.c中了。
仅此而已。

2.2 编译位汇编代码

test.i文件编译,生成汇编代码:

1
gcc -S test.i -o test.s

gcc的-S选项,表示在程序编译期间,在生成汇编代码后,停止,-o输出汇编代码文件。
在64位机器上生成32位的汇编程序gcc -m32 -S test.i -o test.s ,加上-m32 就好了,
表示生成的是32位的程序,让我们来看一下最简单的汇编代码

在汇编代码中 .开头的就是所谓的符号标记,在链接中被解析替换成虚拟地址(后面或说到)
在这里说一下,我们常说的变量的存储看看我们的变量是如何存储和传递的,我们知道一个
程序就是一个进程,一个进程是一个程序在运行时的状态,包括很多部分,我们先说一下进程中
的用户栈,用来保存,传递临时变量,栈的结构是通用的,因为一个进程只有一个栈,但是进程
中有无数个function,为了区分每个function的变量,所以每个fuction所占有的栈的那一个部分
又被叫做栈帧,我们来看一下
自己画的-进程用户栈
自己画的-用户栈内部函数栈帧
深入理解计算机系统-进程用户栈
深入理解计算机系统-用户栈内部函数栈帧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
.file "test.c" ##声明文件的名字
.section .rodata ##标记只读数据
.LC0: ##标记字符串“hello, world” ,并且是只读的
.string "hello, world"
.text ##text 存放已编译程序
.globl main ##全局的
.type main, @function ## 全局函数
main: ##main 函数开始
.LFB0:
.cfi_startproc
pushl %ebp ## ebp 入栈,因为下面要使用%ebp寄存器,为了保护数据,需要入栈,函数返回的需要出栈恢复原值
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp ## 把帧底地址赋值给%ebp寄存器,就是因为这里使用%ebp寄存器,所以上一条指令才把寄存器的值入栈保存
.cfi_def_cfa_register 5
andl $-16, %esp ## esp 寄存器值加上-16 开辟栈空间
subl $16, %esp ## esp 寄存器值减去16 开辟栈空间
movl $.LC0, (%esp) ## 把字符串的地址入栈
call puts ## puts 就是printf函数,函数使用esp的参数
movl $0, %eax ## 把0 设置为返回值
leave ## 位函数返回做准备 该命令在这里 等价于: movl %ebp, %esp(让栈顶指向帧底部) ;popl %ebp (恢复ebp的值)
.cfi_restore 5
.cfi_def_cfa 4, 4
ret ## 返回到调用者函数
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4"
.section .note.GNU-stack,"",@progbits

2.3 汇编

汇编主要是把上一步生成的test.s ,里面的汇编代码转换为机器指令即0 1 代码

1
2
gcc -c -m32 test.s -o test.o
objdump -d test.o //反汇编目标文件

观看下面的反汇编文件,跟上面的汇编代码进行比较,很有意思,再跟可执行文件对比,你会发现,他们的地址变了
别的基本没变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
test.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>e
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 e4 f0 and $0xfffffff0,%esp
6: 83 ec 10 sub $0x10,%esp
9: c7 04 24 00 00 00 00 movl $0x0,(%esp)
10: e8 fc ff ff ff call 11 <main+0x11>
15: b8 00 00 00 00 mov $0x0,%eax
1a: c9 leave
1b: c3 ret

2.4 链接

这一步,我们在函数中使用了库函数的,printf函数,我们的汇编代码中也体现了出来,但是我么发现之前的文件中并没有printf函数的具体
实现,链接就是解析test.o 中的各种符号(test.s中出现的.开头的),并且替换位相应函数的在虚拟存储器中的地址。

1
2
gcc -m32 test.o -o test
objdump -d test //反汇编可执行文件

可执行文件经过了链接之后,会多了很多内容,我们这里只看main函数里面的内容,
我么可以看一下,跟汇编差不多,只不过之前的十进制数字已经变成了16进制的补码
我么知道计算机在存储数据的时候,是以补码存储的,所有的计算也是以补码位基础的
所以补码的学习是非常重要的,整数 浮点数补码的表示和计算,特别注意浮点数的补码
表示和计算,跟整数有很大的区别,同时因为浮点数精度问题所以会有舍入,而cpu在舍入
的时候因为硬件的不同可能会产生不同的结果,所以浮点数从来不进行相等的比较,只进行
大于 大于等于 等操作,判断相等是可以的,但是往往得不到你期待得到的结果,如果真的需要比较相等可以
转换成字符串然,再把字符截断成需要的位数,然后后比较。我们可以看到call 函数上一条指令
movl $0x80484d0,(%esp) ,把一个地址传送到栈顶存储,我想这里面应该是“hello, world”字符串
在计算机中的虚拟地址,通过该虚拟地址可以找到该字符串。其实调用pritntf函数,就是把字符串从内存
的源地址,复制一份到显示器的内存中,然后就显示到我们的屏幕中了。因为现在的存储器有DMA(directory memory access)
所以不需要cpu,从寄存器把字符串取出来然后再存储到显示器的内存中,只需要cpu发送一条命令,存储器
自己就可以把字符串送到显示器的内存中,传输结束之后,产生中断告诉cpu,cpu再进行后续的操作。

如果你观察几个可执行文件的反汇编代码,就会发现其实代码的开始地址是一样的,都是从一个固定的地址开
始,可想而知这里的地址并不是我们物理内存的地址而是是虚拟地址,通过虚拟地址让每个进程都是以为自己
在独占内存,这样对于链接器来说更容易工作,cpu上的mmu(memory management unit)就是把虚拟地址转成物
理地址,我们使用的都是虚拟地址,而不是物理地址。举个例子书大家都很熟悉,书都有目录,通过目录可以
到具体的内容,现在的操作系统就选择把内存分页,intel的页大小一般是4kb/4mb,这样划分之后我们可以得
到一个页表,通过页表和页内偏移就可以找到相应的物理地址,我们的每一个进程都有自己的虚拟存储空间32
位系统虚拟存储空间4GB,64位的2^64次方太大了,虚拟存储空间又分位系统空间和用户空间,32位的一般系统
2G,用户2G,详细的可以看这个网址
https://msdn.microsoft.com/zh-cn/library/windows/hardware/hh439648(v=vs.85).aspx,
http://blog.csdn.net/tennysonsky/article/details/45092229
我们看一下liux的分布 ,

32位的虚拟内存总得来说分为俩个部分
进程的虚拟存储空间结构---linux系统

这样我们就能理解为何可执行文件里面的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0804841d <main>:
804841d: 55 push %ebp
804841e: 89 e5 mov %esp,%ebp
8048420: 83 e4 f0 and $0xfffffff0,%esp
8048423: 83 ec 10 sub $0x10,%esp
8048426: c7 04 24 d0 84 04 08 movl $0x80484d0,(%esp)
804842d: e8 be fe ff ff call 80482f0 <puts@plt>
8048432: b8 00 00 00 00 mov $0x0,%eax
8048437: c9 leave
8048438: c3 ret
8048439: 66 90 xchg %ax,%ax
804843b: 66 90 xchg %ax,%ax
804843d: 66 90 xchg %ax,%ax
804843f: 90 nop

3. 运行阶段

1
./test //运行test 可执行文件

3.1 加载

我们在终端输入 ./test ,终端其实就是shell或者说是一个外壳程序,通过这个程序来加
载和运行我们的test,这个外壳程序首先把我们的test可执行文件加载到内存然后执行具体
就是, 分配虚拟地址空间,虚拟地址空间有相应的结构体,初始化结构体,在这里会给进程
分配资源,其实就是内存如果资源足够就把进程添加到就绪队列,否则并且把结构体指针添加
到等待队列中,然后就是等待资源足够,或者等待cpu的调用,这里就是所谓的cpu调度了,
在处理的过程中有同步,异步 ,共享,信号量,中断,死锁 等问题这个可以去看计算机操作系统
了解了解。我们只说一下,子进程和父进程共享文件的问题,我们都知道父进程创建一个子进程
linux系统函数提供的有fork()函数,生成一个子进程,这个子进程会直接拷贝一份父进程的虚拟地址
空间结构体,当然了进程号是唯一的,并且父进程打开的文件描述,子进程都可以共享,但是会文件
描述的引用计数会增加1,当文件描述的引用计数为0是文件才能关闭,所以子进程结束的时候一定要
关闭相应的文件描述,这样可以避免内存泄露。
test

3.2 运行

外壳程序把程序加载到内存,也就是创建进程,但是加载只是一个虚拟地址空间,具体的数据在cpu
需要的时候通过虚拟存储器去内存中去,当然现在的我们的有各种缓存,一级缓存 二级缓存 三级缓存
通过这些缓存加快cpu的处理速度,如果我们写的代码具有很好的时间 ,空间局部性,那样我们的程序
就会运行的更快。

4. 浅谈程序优化

根据源码编译产生的汇编码,通过阅读汇编代码我们可以看到一个程序在cpu是怎么运行的,通过了解
cpu对汇编码的解析我们就会明白自己写的代码的限制,一般的程序优化有循环展开,把递归转成循环,
条件判断改为条件转移,这些通过分析汇编指令的执行,我们会发现程序确实会有一个很大的提升
当然大部分情况下我感受不到,毕竟我的程序太小数据太少,但是在大型项目中这确实会极大的提升一个
程序的性能。所以说为了提高你写的 c c++ 汇编 的性能,这本书应该会提供一些帮助。

5. 浅谈线程并发

并发极大的提高了我们进程运行的速度和对文件分享的方便,但是同时也会带来了一些弊端,比如对全局

变量的修改可能比较混乱,这是因为进程和线程都是由cpu进程调度运行,他们的运行没有顺序也没用规律,因为
需要信号量机制来处理同步问题,保护边界变量或者函数,或者是资源的分配,经典的生产者消费者模型,读者写者
问题,就很好的解释了这些问题。现在的处理器大部分是多核的,可以实现并行计算,极大的提高性能和效率。

6. 浅谈网络编程

这里主要说的就是sockt套接字,在linux上面,socket套接字是一个文件,连接socket的文件描述也是一个文件

我们是通过socket文件进行连接,连接成功的时候会返回一个文件描述符,我们通过对这个文件描述进行读写从而实现
网络信息的传输,Rio包用来处理网络io,我们平常通过浏览器浏览的Web网站,然后在浏览器看到的网页或者下载的文件
都是通过sockt来传输的,通过这个我么可以写一个自己的web服务,加深对网络编程的理解。

finlly总结

读完这本,我对程序的整个运行过程有一个整体的了解,特别是指针、汇编、用户栈、堆,这些我们经常接触到的

有了清晰的认识,对整个操作系统的运行大体也有了自己的理解,我想通过阅读这本书我觉得我有了一定的基础
当然了 这本书只是一个踏进计算机世界的大门垫脚石,但是却极其重要,毕竟哪一本书也不能一章就把汇编讲完是吧,
这就是这书的牛逼之处,并且适合各种阶段的人员来读,当然了我觉得精度一遍,然后需要的时候再仔细研究相关的领域
所谓师傅领进门修行在个人,经典的书就是一个好的师傅。希望大家都有所得。