现在的位置: 首页 > 综合 > 正文

读《程序员的自我修养——装载、链接与库》

2013年02月12日 ⁄ 综合 ⁄ 共 8884字 ⁄ 字号 评论关闭

《程序员的自我修养》这本书是我看过《深入理解计算机系统》之后看的一本书。在中国人写的书中,它可以算是相当不错的一本书了。但总觉得比《深入理解计算机系统》这样的国外经典书还差那么一点,具体差在哪里,我也说不出来。但如果给它打个分的话,我会毫不犹豫地给五星。

这本书里,首先给出了几个鲜明的观念很不错。知其然更要知其所以然;CPU体系结构、汇编、c语言(C++)和操作系统,永远是编程大师们的护身法宝,犹如少林寺的《易筋经》,是最为上乘的武功,学会了《易筋经》,你将无所不能,任你创造武功,学会了“易筋经”,大师们可以任意开发操作系统、编译器、甚至开发一种新的程序设计语言;万变不离其宗;计算机科学领域的任何问题都可以通过增加一个中间层来解决;真正了不起的程序员是对自己的程序的每一个字节都了如指掌。

从书名可知,本书主要讲述的是三大部分:链接、装载和库,最核心的内容已经在上一篇中说过了。本篇就主要来解决本书开篇从Hello World说起所提的问题

对于c语言编写的HelloWorld程序,

问题:
 


程序为什么要被编译器编译了之后才可以运行?

计算机不能直接理解高级语言,只能直接理解机器语言,所以必须要把高级语言翻译成机器语言,计算机才能执行高级语言编写的程序。 

  翻译的方式有两种,一个是编译,一个是解释。两种方式只是翻译的时间不同。编译型语言写的程序执行之前,需要一个专门的编译过程,把程序编译成为机器语言的文件,比如exe文件,以后要运行的话就不用重新翻译了,直接使用编译的结果就行了(exe文件),因为翻译只做了一次,运行时不需要翻译,所以编译型语言的程序执行效率高。 如c语言就属于这种类型。所以要对C源程序进行编译、链接。

  解释则不同,解释性语言的程序不需要编译,省了道工序,解释性语言在运行程序的时候才翻译,比如解释性basic语言,专门有一个解释器能够直接执行basic程序,每个语句都是执行的时候才翻译。这样解释性语言每执行一次就要翻译一次,效率比较低。 

纠正:java很特殊,java程序也需要编译,但是没有直接编译称为机器语言,而是编译称为字节码,然后用解释方式执行字节码。
 

 


编译器在把c语言程序转换成可执行的机器码的过程中做了什么,怎么做的?

 

直观上来讲,编译器就是把便于编写、阅读的高级语言翻译成计算机能够识别、运行的低级机器语言。在我看来,编译器分为传统编译器和现代编译器。传统编译器经过词法分析、语法分析、语义分析、中间语言生成以及目标代码的生成和优化。而一个现代编译器的主要工作流程如下:

源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 汇编程序 (assembler) → 目标代码 (object code) → 连接器 (Linker) → 可执行程序 (executables) 。

而对每一个步骤的详细展开,内容就非常丰富了。

 


最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的、怎么组织的?

首先,必须搞清什么是“机器码”,它当然不是指唯一为计算机编的序列号。汇编语言或 C 语言等高级语言编译后的最终结果:含有可被微处理器(CPU)加载并执行的由 0 和 1 组成的序列,这就是
机器码

可执行文件中包含两部分内容:

程序(从原程序中的汇编指令翻译过来的机器码)和数据(源程序中定义的数据)

用Linux下的可执行文件elf的结构来看一下这些内容是怎么存放、组织的。

 

 

 


#include <stdio.h>是什么意思?把stdio.h包含进来是什么意思?C语言库又是什么?它怎么实现的?

#include <stdio.h>的意思是将stdio.h包含进来,Stdio.h是标准输入输出头文件,里面包含了标准输入输出函数的声明, printf就是其中的一个。通过#include预编译指令将需要的库函数调入,这样就可以实现一些基本的功能,例如字符串到标准输入输出设备的输入和输出等等。而具体的链接就不详述了。

任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。

这样的一个代码集合称之为运行库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。

一个C语言运行库大致包含了如下功能:

启动与退出:包括入口函数及入口函数所依赖的其他函数等。

标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。

I/O:I/O功能的封装和实现,参见上一节中I/O初始化部分。

堆:堆的封装和实现,参见上一节中堆初始化部分。

语言实现:语言中一些特殊功能的实现。

调试:实现调试功能的代码。

C运行库的具体实现源码就先不考虑了。
 


不同的编译器、不同的硬件平台以及不同的操作系统,最终编译出来的结果一样吗?为什么?

不一样。

对于不同的编译器,整个流程(预处理——编译器(词法分析、语法分析,语义分析...)——汇编器——链接器)之中只要有稍微一点的不同,我想编译后的结果——可执行文件都是不同的。

对于不同的硬件平台,比如x86、SPARC、MIPS、ARM等,它们的寻址方式、地址格式、指令格式等等等等都不相同,那么编译的过程必然也会有所不同,结果自然不同。

对于不同的操作系统,答案是一目了然的。不同的操作系统下,它的可执行文件格式的要求都不相同,共享库以及动态链接方式都不一样,那么结果肯定也就不一样的啦。


Hello World程序是怎么运行起来的?操作系统是怎么装载它的?他从哪儿开始执行的,到哪儿结束?main函数前发生了什么?main函数后发生了什么?

上一篇《程序的流程——链接、装载与运行》很详细地描述了它


如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?

首先,答案是肯定的,试想一下没有操作系统前,程序不是照样跑?!但同时也是需要一些条件的。Hello World在有操作系统时需要操作系统需要进行装载链接(c运行库),那么没有操作系统的情况下,肯定需要自己的装载器和链接器吧,然后需不需要一些什么内存管理器、要不要实现自己的c运行库就不得而知了。下面是从csdn论坛上发帖得到的几个答案,在这里分享一下。

Waiting4you

用汇编,直接调用BIOS中断来输出字符,把编译好的东东(应该不会大于512字节)写到磁盘的第一扇区。
注意,整个程序必须只有一个代码段,汇编的起始地址要改成0x7C00(好像是,不是很确定)。编译好的512字节最后两个字节要改成0x55,0xAA
有个叫《自己动手写操作系统》的书,开篇就有一个类似的代码

bluewanderer

1. printf是C库中的IO部分
2. IO部分包含文件系统
3. 文件系统是操作系统的内容
4. printf对操作系统有依赖
所以,没操作系统休想printf("Hello World/n")
C库这类东西术语上就叫操作系统抽象层,没操作系统你抽象谁去

janneliu

你可以将PC指针指向你要执行的代码段起始,或者想办法开机的时候直接从你的代码段起始执行,当然你编译链接的时候不能用类似libc的库了,像printf都的自己封装,这肯定要汇编的东西了

vcprg

可以实现的。
就现在来讲的话,总的来说需要软件和硬件。
硬件的话需要冯诺依曼或哈佛结构或还是其他别的什么体系结构的计算机。
软件的话需要就是可以编写和编译Hello world程序的宿主机的操作系统。

例如:你可以在C51单片机上运行,用单片机相应的编译器把你的hello world程序编译成二进制代码,然后将程序烧进单片机的存储器(ROW),最后上电就可以运行了。注意:如果你想显示hello world,你就需要一个显示屏或阵列二极管或数码管或是其他什么的,并写出相应的程序显示出“hello world”。

 


printf是怎么实现的?

为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?

我们将以printf的实现源代码为例,讲述printf是怎么实现可变参数的,怎么在终端输出字符串!

首先看printf函数的定义:

 

 

参数中明显采用了可变参数的定义,而在main.c函数的后面直接调用了printf函数,我们可以看下printf函数的参数是如何使用的。

 

先来分析第一个printf调用:

printf("%d buffers = %d bytes buffer space/n/r",NR_BUFFERS, NR_BUFFERS*BLOCK_SIZE);

可以看到*fmt等于"%d buffers = %d bytes buffer space/n/r”,是一个char 类型的指针,指向字符串的启始位置。而可变的参数在这里是NR_BUFFERS和NR_BUFFERS*BLOCK_SIZE。

其中NR_BUFFERS在buffer.c中定义为缓冲区的页面大小,类型为int;BLOCK_SIZE在fs.h中的定义为

#define BLOCK_SIZE 1024

因此两个可变参数NR_BUFFERS和NR_BUFFERS*BLOCK_SIZE都为int类型;

而对于
可变参数
一系列va(
variable-argument
函数

va_list arg_ptr

void va_start( va_list arg_ptr, prev_param ); 

type va_arg( va_list arg_ptr, type ); 

void va_end( va_list arg_ptr );

首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。然后使用va_start使arg_ptr指针指向prev_param的下一位,然后使用va_args取出从arg_ptr开始的type类型长度的数据,并返回这个数据,最后使用va_end结束可变参数的获取。

在printf("%d buffers = %d bytes buffer space/n/r",NR_BUFFERS, R_BUFFERS*BLOCK_SIZE)中,根据以上的分析fmt指向字符串,args首先指向第一个可变参数,也就是NR_BUFFERS(args在经过一次type va_arg( va_list arg_ptr, type )调用后,会根据type的长度自动增加,从而指向第二个可变参数NR_BUFFERS*BLOCK_SIZE)。

我们先不管write函数的实现,首先来看vsprintf。

 

这样我们就实现了根据fmt中的格式转换符将可变参数转换到相应的格式,利用write函数
达到
输出的目的。然而,write函数过于复杂,甚至有不少内嵌汇编语言。下面仅仅描述一下printf输出的一般步骤:

1、当printf被调用后,首先会经过C函数库的处理,也就是字符串解析,得到要输出的字符串。

2、调用WriteChars(),它会调用WriteFile()这个API,所谓的File其实是控制台输出缓冲区的句柄。WriteFile判断句柄类型(如是文件句柄将调用ntdll.dll中的NtWriteFile函数),因为这里是控制台句柄所以将调用WriteConsoleA函数。

3、WriteConsoleA函数将调用ntdll.dll中的csrClientCallServer函数,这个函数的目的是通知csrss.exe要输出字符了。

4、csrClientCallServer最终会调用NtRequestWaitReplyPort,此时系统进入内核态,内核会通知csrss.exe

5、csrss.exe中一个叫CsrApiRequestThread的线程已经用一个叫NtReplyWaitReceivePort(这个函数被调用后,线程就会被阻断,直到上面的NtRequestWaitReplyPort被调用才继续执行)的函数等很久了,此时接到指令欣喜若狂的csrss.exe就会根据发来的内容经过一番纠结判断是要输出字符,于是找到自己的winsrv.dll

6、winsrv.dll有个叫SrvWriteConsole的函数被调用,这个函数会对发来的信息进行一番安全检查、处理,然后给一个叫DoSrvWriteConsole的函数

7、这个DoSrvWriteConsole会做一些单字节、多字节等编码的检查、转换,然后调用FE_DoSrvWriteConsole函数

8、然后调用FE_DoWriteConsole,这个函数调用FE_WriteChars。

9、FE_WriteChars会进行两步工作

(1)更新控制台缓冲区,这个使用叫做FE_StreamWriteToScreenBuffer和BisectWrite函数完成的

(2)更新屏幕缓冲区,这个使用叫做FE_WriteToScreen和FE_WriteRegionToScreen函数完成的,主要过程包括将文本用一个叫FE_PolyTextOutCandidate的函数放到待输出队列里,然后等这一批文本都放进去后调用GdiFlush函数刷到屏幕上。

10、终于快大功告成了,SrvWriteConsole返回,csrss.exe这个时候用一个叫NtReplyPort的函数告诉我们的Hello.exe:嗯,我写完了,于是我们的Hello.exe继续运行,然后你就会看到屏幕上出现可爱的:Hello World!


Hello World程序在运行时,它在内存中是什么样子?

Hello World程序在运行的过程中,是CPU、内存与磁盘三者之间进行的交互。内存中只提供一块有限的区域,称之为“活动区域”。它里面的存放是磁盘上加载进来运行的页。此时内存里面只有固定数量的页,也叫做页帧大小。

稳定后就是
0101010101011111111111100000000000000000000000

 

 

 

 

抱歉!评论已关闭.