目录
写在开头
从源代码到本地代码
源代码
本地代码的初级形态(这一节有点不严谨)
编译器
从目标文件到可执行文件
启动和库文件
DLL文件及导入库
可执行文件的运行机制
变量和函数的内存地址
程序加载时生成栈和堆
结尾
本文继续阅读总结《程序是怎样跑起来的》这本书(作者:矢泽久雄)。前三篇博客介绍了这本书的阅读感受,并分别对第一章CPU、第四章内存相关、第五章磁盘的知识进行了总结。详情见:
【计算机组成原理】读书笔记第一期:对程序员来说CPU是什么-CSDN博客
【计算机组成原理】读书笔记第二期:使用有棱有角的内存_Bossfrank的博客-CSDN博客
【计算机组成原理】读书笔记第三期:内存和磁盘的关系_Bossfrank的博客-CSDN博客
本文将介绍本书的第八章:从源文件到可执行文件。重点描述从程序员编写的源程序代码开始,到可运行的.exe文件的过程(限于篇幅,本文仅介绍由.c源代码到.exe可执行文件的示例),涉及到的重点包括源代码和本地代码的概念、编译器的职责、形成可执行文件的过程、可执行文件运行时的内存变化等。
源代码是由高级语言编写的程序,是无法直接运行的。无论是哪种高级编程语言,最终都要翻译转换成本地代码(机器语言,即由二进制组成文件),才能被CPU所理解。换句话讲,即使是不同的编程语言编写的代码,最终转换成计算机可理解的本地代码后,都相当于变成了同一种语言(机器语言)了。如图8-2所示:
用某种高级编程语言编写的程序即成为源代码,保存源代码的文件称为源文件。源文件就是简单的文本文件,用任何文本编辑器都可以编写。用扩展名区分编程语言的种类,比如".c"就是C语言编写的源文件扩展名。代码8-1 sample1.c是一段用C语言编写的Windows程序,用于求取123和456的平均值并显示在消息框种,本篇即以这个程序sample1.c为例子,sample1c就是源代码。
//代码8-1 源文件sample1.c,求取123和456的平均值并在消息框中显示
#include
#include
// 消息框的标题
char* title = " 示例程序1";
// 返回两个参数的平均值的函数
double Average(double a, double b) {
return (a + b) / 2;
}
// 程序运行启始位置的函数
int WINAPI WinMain(HINSTANCE h, HINSTANCE d, LPSTR s, int m)
{
double ave; // 保存平均值的变量
char buff[80]; // 保存字符串的变量
// 求解123,456 的平均值
ave = Average(123, 456);
// 编写显示在消息框中的字符串
sprintf(buff, " 平均值 = %f", ave);
// 打开消息框
MessageBox(NULL, buff, title, MB_OK);
return 0;
}
这个程序最终对应的可执行程序运行后执行结果如下:
本地代码就是计算机可理解的内容,本质是二进制信息的罗列,每个二进制数值都表示某一个命令或者数据,由sample1.c源代码最终转换得到的可执行本地代码sample1.exe的本地代码如下图8-4:(备注:仅就我个人的阅读感受,作者这里关于本地代码和可执行文件搞得有点混乱了。后文又说本地代码是不能直接运行的,还需要链接过程才能形成可执行的.exe文件,或者也可以说可执行文件也是本地代码的最终形式,本节理论上讨论的应该是初级的本地代码,即目标文件sample1.obj才对,这里读者就当目标文件和可执行文件都是本地代码好了,图8-4我感觉有点不太合适,应该是对sample1.obj做dump才更合适,不过无论是sample1.obj还是sample1.exe都是二进制数值的罗列,数据和指令都对应二进制数值。)
《程序是怎样跑起来的》这本书介绍用Dump方式查看本地代码,Dump是指把文件的内容,每个字节用2位十六进制数来表示的方式。 我们也可以用010Editor这样的工具直接查看。而计算机就是把所有的信息作为数值的集合来处理的,对于本地代码而言,数据和指令都是数值的罗列。
这里注意一下:本地代码 和 可执行文件 的关系
应该说目标文件sample1.obj和最终的可执行文件sample1.exe都是本地代码,目标文件是初级的本地代码,可执行文件是最终形态的本地代码,本小节讨论的应该是目标文件这种本地代码,但给出的图8-4却是可执行性文件。我感觉作者稍微有点不严谨了。
编译器是用于将高级编程语言编写的源代码转换成本地代码(目标文件)的程序。
编译器和解释器的不同:编译器是在运行前对所有源代码进行解释处理的。而解释器则是在运行时对源代码的内容一行一行地进行解释处理的。
编译器的三个关键词:
编程语言:每个编写源代码的编程语言都需要其专用的编译器。将C语言编写的源代码转换成本地代码的编译器称为C编译器。
CPU种类:跟据CPU类型的不同,本地代码的类型也不同,因此编译器的种类和CPU的类型也是相关的。同样的源代码通过不同的编译器,就可以翻译成适用于不同CPU的本地代码了(见图8-5)。
操作系统:为编译器本身也是程序的一种,所以也需要运行环境,即操作系统的种类(Windows/Linux等)。
编译器的三要素类型举例如下图8-6(不过现在的编译器已经不需要专门购买了,已经默认集成在IDE集成开发环境中了)。
备注:有的编译器可以通过选择“Build”菜单来生成EXE文件。这种情况下,Build 指的是连续执行编译和链接,这种情况相当于一步到位直接生成了可执行文件。
一图胜千言,见图8-8:
sample1.c经过编译器编译后得到了目标文件sample1.obj,这个目标文件虽然是本地代码,但无法直接运行,这是因为当前程序还处于未完成状态。 源代码中并没有记述sprintf(),MessageBox()函数的内容,因此必须将存储着sprintf() 和MessageBox() 的处理内容的目标文件同Sample1.obj 结合,否则处理就不完整,EXE文件也就无法完成。将多个目标文件结合,生成一个可执行文件exe文件的过程就是链接,运行链接的程序就是链接器。
启动:c0w32.obj这个目标文件记述的是同所有程序起始位置相结合的处理内容,称为程序的启动。因而,即使程序不调用其他目标文件的函数,也必须要进行链接,并和启动结合起来。
库文件:像import32.lib及cw32.lib这样的文件称为库文件。 库文件指的是把多个目标文件集成保存到一个文件中的形式。链接器指定库文件后,就会从中把需要的目标文件抽取出来,并同其他目标文件结合生成EXE文件。
静态链接库:像cw32.lib这样的库文件,存储着目标文件的实体,直接与exe文件相结合的库文件形式称为静态链接库。
使用库文件的好处:
1.简化了为链接器的参数指定多个目标文件这一过程。在链接调用了数百个标准函数的程序时,就要在链接器的命令行中指定数百个目标文件,这样就太繁琐了。而利用存储着多个目标文件的库文件的话,则只需在链接器的命令行中指定几个库文件就可以了。
sprintf() 的目标文件位于cw32.lib中,像sprintf()这样的函数,不是通过源代码形式而是通过库文件形式和编译器一起提供的。这样的函数称为标准函数。
2.对于软件开发公司来说,通过以目标文件的形式或集合多个目标文件的库文件形式来提供
函数,就可以不用公开标准函数的源代码内容。
DLL(Dynamic Link Library动态链接库)文件:程序运行时动态结合的文件,存储了Windows API的目标文件。
API(Application Programming Interface应用程序接口):Windows 以函数的形式为应用提供了各种功能,这些形式的函数称为 API。例如,Sample1.c 中调用的MessageBox(),它并不是C语言的标准函数,而是Windows 提供的API 的一种。MessageBox() 提供了显示消息框的功能。
图8-8中import32.lib 中仅仅存储着两个信息,一是MessageBox() 在user32.dll 这个DLL 文件中,另一个是存储着DLL文件的文件夹信息,MessageBox() 的目标文件的实体实际上并不存在。我们把类似于import32.lib这样仅包含DLL文件中存储的函数信息的库文件称为导入库。
EXE文件是作为单独的文件储存在硬盘中的。通过资源管理器找到并双击EXE文件,就会把EXE 文件的内容加载到内存中运行。
EXE文件作为本地代码的程序,并没有指定变量及函数的实际内存地址。是EXE文件中给变量及函数分配了虚拟的内存地址。在程序运行时,虚拟的内存地址会转换成实际的内存地址。链接器会在EXE文件的开头,追加转换内存地址所需的必要信息。这个信息称为再配置信息。
EXE 文件的再配置信息,就成为了变量和函数的相对地址。相对地址表示的是相对于基点地址的偏移量,也就是相对距离。实现相对地址,也是需要花费一番心思的。在源代码中,虽然变量及函数是在不同位置分散记述的,但在链接后的EXE文件中,变量及函数就会变成一个连续排列的组。这样一来,各变量的内存地址就可以用相对于变量组起始位置这一基点的偏移量来表示,同样,各函数的内存地址也可以用相对于函数组起始位置这一基点的偏移量来表示。而各组基点的内存地址则是在程序运行时被分配的(图8-9)。
加载到内存的程序由四部分构成,见图8-10:
栈和堆需要的内存空间是在EXE文件加载到内存后开始运行时得到分配的。在高级编程语言中,编译器会自动生成指定栈和堆大小的代码,并将其附加到程序中。
栈:用来存储函数内部临时使用的变量(局部变量),以及函数调用时所用的参数的内存区域。
栈中对数据进行存储和舍弃(清理处理)的代码,是由编译器自动生成的,因此不需要程序员的参与。使用栈的数据的内存空间,每当函数被调用时都会得到申请分配,并在函数处理完毕后自动释放。
堆:用来存储程序运行时的任意数据及对象的内存区域。
堆的内存空间,则要根据程序员编写的程序,来明确进行申请分配或释放。C语言中是通过malloc() 函数来进行申请分配、通过free() 函数来释放的。而C++中则是通过new 运算符来申请分配、通过delete运算符来释放的。如果没有在程序中明确释放堆的内存空间,那么即使在处理完毕后,该内存空间仍会一直残留。这个现象称为内存泄露(memory leak),它是令C语言及C++的程序员们十分头疼的一个bug(程序的错误)。如果内存泄露一直存在的话,就有可能会造成内存不足而导致宕机。
本文总结了源文件到可执行文件的相关内容,介绍了由源文件到可执行文件的全过程,包括编译和链接,链接又分为静态链接和动态链接,需要理解源代码、目标代码、可执行的本地代码之间的相互关系以及链接过程中涉及到的库、导入库、DLL文件的相关概念。同时本章还简要介绍了可执行文件运行时的内存机制。
这篇文章就总结到这里吧,下一篇可能重点总结程序从编写到运行的过程相关的内容。除此之外还会进一步更新红队打靶的解析和渗透测试相关的技术分享,恳请希望读者们多多支持。