标签:
我的博客上的比这个排版显示的更好一些,特别是图片
http://notelzg.github.io/2016/06/29/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F%E6%80%BB%E7%BB%93/
我们还是从hello world程序说起吧:
#include <stdio.h>
int main()
{
printf("hello, world! \n");
return 0;
}
让我们看看从源码到可执行文件,再到运行输出结果之间到底经历了怎么样的过程吧:
gcc -E test.c -o test.i 或 gcc -E test.c
可以输出test.i文件中存放着test.c经预处理之后的代码。打开test.i文件,看一看,就明白了。
后面那条指令,是直接在命令行窗口中输出预处理后的代码.gcc的-E选项,可以让编译器在预处理
后停止,并输出预处理结果。在本例中,预处理结果就是将stdio.h 文件中的内容插入到test.c中了。
仅此而已。
test.i文件编译,生成汇编代码:
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所占有的栈的那一个部分
又被叫做栈帧,我们来看一下
.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
汇编主要是把上一步生成的test.s ,里面的汇编代码转换为机器指令即0 1 代码
gcc -c -m32 test.s -o test.o
objdump -d test.o //反汇编目标文件
观看下面的反汇编文件,跟上面的汇编代码进行比较,很有意思,再跟可执行文件对比,你会发现,他们的地址变了
别的基本没变
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
这一步,我们在函数中使用了库函数的,printf函数,我们的汇编代码中也体现了出来,但是我么发现之前的文件中并没有printf函数的具体
实现,链接就是解析test.o 中的各种符号(test.s中出现的.开头的),并且替换位相应函数的在虚拟存储器中的地址。
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的分布 ,
这样我们就能理解为何可执行文件里面的地址
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
./test //运行test 可执行文件
我们在终端输入 ./test ,终端其实就是shell或者说是一个外壳程序,通过这个程序来加
载和运行我们的test,这个外壳程序首先把我们的test可执行文件加载到内存然后执行具体
就是, 分配虚拟地址空间,虚拟地址空间有相应的结构体,初始化结构体,在这里会给进程
分配资源,其实就是内存如果资源足够就把进程添加到就绪队列,否则并且把结构体指针添加
到等待队列中,然后就是等待资源足够,或者等待cpu的调用,这里就是所谓的cpu调度了,
在处理的过程中有同步,异步 ,共享,信号量,中断,死锁 等问题这个可以去看计算机操作系统
了解了解。我们只说一下,子进程和父进程共享文件的问题,我们都知道父进程创建一个子进程
linux系统函数提供的有fork()函数,生成一个子进程,这个子进程会直接拷贝一份父进程的虚拟地址
空间结构体,当然了进程号是唯一的,并且父进程打开的文件描述,子进程都可以共享,但是会文件
描述的引用计数会增加1,当文件描述的引用计数为0是文件才能关闭,所以子进程结束的时候一定要
关闭相应的文件描述,这样可以避免内存泄露。
test
外壳程序把程序加载到内存,也就是创建进程,但是加载只是一个虚拟地址空间,具体的数据在cpu
需要的时候通过虚拟存储器去内存中去,当然现在的我们的有各种缓存,一级缓存 二级缓存 三级缓存
通过这些缓存加快cpu的处理速度,如果我们写的代码具有很好的时间 ,空间局部性,那样我们的程序
就会运行的更快。
根据源码编译产生的汇编码,通过阅读汇编代码我们可以看到一个程序在cpu是怎么运行的,通过了解
cpu对汇编码的解析我们就会明白自己写的代码的限制,一般的程序优化有循环展开,把递归转成循环,
条件判断改为条件转移,这些通过分析汇编指令的执行,我们会发现程序确实会有一个很大的提升
当然大部分情况下我感受不到,毕竟我的程序太小数据太少,但是在大型项目中这确实会极大的提升一个
程序的性能。所以说为了提高你写的 c c++ 汇编 的性能,这本书应该会提供一些帮助。
并发极大的提高了我们进程运行的速度和对文件分享的方便,但是同时也会带来了一些弊端,比如对全局
变量的修改可能比较混乱,这是因为进程和线程都是由cpu进程调度运行,他们的运行没有顺序也没用规律,因为
需要信号量机制来处理同步问题,保护边界变量或者函数,或者是资源的分配,经典的生产者消费者模型,读者写者
问题,就很好的解释了这些问题。现在的处理器大部分是多核的,可以实现并行计算,极大的提高性能和效率。
这里主要说的就是sockt套接字,在linux上面,socket套接字是一个文件,连接socket的文件描述也是一个文件
我们是通过socket文件进行连接,连接成功的时候会返回一个文件描述符,我们通过对这个文件描述进行读写从而实现
网络信息的传输,Rio包用来处理网络io,我们平常通过浏览器浏览的Web网站,然后在浏览器看到的网页或者下载的文件
都是通过sockt来传输的,通过这个我么可以写一个自己的web服务,加深对网络编程的理解。
读完这本,我对程序的整个运行过程有一个整体的了解,特别是指针、汇编、用户栈、堆,这些我们经常接触到的
有了清晰的认识,对整个操作系统的运行大体也有了自己的理解,我想通过阅读这本书我觉得我有了一定的基础
当然了 这本书只是一个踏进计算机世界的大门垫脚石,但是却极其重要,毕竟哪一本书也不能一章就把汇编讲完是吧,
这就是这书的牛逼之处,并且适合各种阶段的人员来读,当然了我觉得精度一遍,然后需要的时候再仔细研究相关的领域
所谓师傅领进门修行在个人,经典的书就是一个好的师傅。希望大家都有所得。
标签:
原文地址:http://blog.csdn.net/li740207611/article/details/51791115