下面我就说说目前在Windows平台下,使用最常用的开发工具(Visual C++)如何来制作一个符合64kb demo的程序框架和常用技巧。当然这只是一些次要手法,最核心的还是3d引擎、mod音乐的设计。因为那些资料很好找。所以就不再涉及。 我将介绍下面几个方面的技术: 1.如何产生体积最小的程序 2.如何不使用C运行库开发程序 3.如何实现高速GDI绘图 4.对于NT5.0提供的LayeredWindow的使用--不规则窗体、窗口的AlphaBlend渲染、鼠标事件穿透 5.如何将所有数据(代码、图片等)整合在一个C文件中 6.其他的一些编译技巧 7.一个完整的示例程序代码 问题和需求 Scene Demo中有一个项目为4kb-intro 或者 64kb-intro。 他要求Demo的程序体积必须小于或者正好等于4/64kb。而往往正是这类Demo程序在国内流传最广。因为大家都认为那么小的体积能播放长时间的高品质3d动画和音乐是不可思议得。甚至有人将45分钟的demo动画看成是avi视频,45分钟的音乐算作44KHz采样的wav。计算出将他们压缩到64kb完全是不可能的(见farbrausch的作品: the product 中的说明字幕)。当然这只是忽悠外行的吓人话。其实写过游戏引擎的人都知道那只是通过实时渲染的到的,而音乐本身就是体积在12kb左右的mod音乐序列(见我以前写过的文章)。 目前很多机器都已安装最新版本的DirectX,而OpenGL是windows的默认库之一。这样Demo Scene设计者一般就不需要自己去编写基本的3d引擎。动画部分几个基本特效的代码不会超过30kb(这里假设开发者具有较高的设计素质),而一些复杂网格模型的纹理贴图即时采用bmp保存,也在100kb-300kb左右。加上mod音乐和其播放引擎。一个64kb DemoScence程序的原始体积一般应在600kb。而不是通过用等效为avi文件计算方法算出的几个G的天文数字。 不过,问题就产生了,实际程序体积只有64kb。在这600kb->64kb还是有相当距离的。如何尽可能的去减少这部分文件大小,以及其中伴随的一些技巧就是本文所要讨论的。 Some Tricks 对于减小程序代码体积,自然对coding的技巧有一定要求。不过这不是问题所在。大家都知道使用VC编译产生的程序,即使就写了句printf(\"HelloWorld\");,也会产生出100kb以上的代码。但是实际上这行语句的有效代码只是: WriteFile(StdOut, \"Hello World\",12,NULL,NULL); WriteFile只是句API call, 实际上对应汇编大致为(这里只是说明性描述): push NULL push NULL push 12 push offset \"Hello World\" push StdOut call WriteFile 就这么几行汇编指令,大致也就几十字节的数据,但VC却占用了大量的体积。而那些多余的文件数据主要是下面这些内容: 1. C运行库 这应该是最主要的因素,程序中会编译近大部分的c函数库的代码信息。同时,在程序的开始执行点到逻辑上认为起始位置:main函数之间也填充了大块的C运行库代码完成初始化工作(初始化堆数据、获取命令行数据)。对于完成通常编程任务来说,这些代码是十分必要的。但是对于一个需要尽可能小体积,且具有足够经验的Demo Scene开发者来说,这些代码绝对是鸡肋。 因此,减少程序体积的第一要务就是将C运行库完全从程序代码中剥离。不过要实现这个需要满足几个前提: a.程序不能使用C运行库,这是当然的。不过有人会问一些很常用的函数,诸如printf没有了该怎么办。回答只有是:自己使用等效的API实现。不过后文也会介绍一些办法 b.尽量不要使用C++语言,原因是对于class的一些操作,诸如析构操作。new/delete运算符。这些本该是语言特性的语句,实际上在编译时会去调用c运行库来完成。 c.不要使用tchar.h d.关闭VC后续版本提供的堆栈安全检查、异常处理等特性 e.完全采用Win32 API 对于很多人来说,要满足这些条件已经无法正常编写程序了。可能这也是Demo Scene的一个门槛。这里有一个变通办法,就是采用微软提供的精简版C运行库(LIBCTINY.LIB)或者使用ATL/WTL中的精简版C运行库。也能大幅减小体积。(实际上,在kernel.dll和ntdll.dll中也提供了C运行库的API接口) 在符合上述条件后,就能大胆的将C运行库去除。具体办法就是在链接设置中取消默认库,或者用下面语句: #pragma comment(linker, \"/nodefaultlib\") 此时,不会有任何的C运行库被编译进程序,但是基本的windows API还是需要链接的。因此起码需要kernel32.lib。 #pragma comment(lib, \"kernel32.lib\") 接下来可以按照需要添加相关的lib或者用LoadLibrary自行加载其他库。具体相信也不需要我废话了。 不过需要注意的是几个特列。位于Winnt.h中有如下定义: #define RtlFillMemory(Destination,Length,Fill) memset((Destination),(Fill),(Length)) #define RtlZeroMemory(Destination,Length) memset((Destination),0,(Length)) 相信MS这样做无非是考虑到运行效率和debug的需要。但是这样也给我们的工作造成了麻烦。如果在程序中直接或间接的使用了这2个函数(还有其它情况)的话,仍旧会被linker告知symbol _memset不存在。最直接的办法可以去修改这个winnt.h。但相信这是个十分愚蠢的做法。因此,推荐的办法是先undefine这些定义,再重新import。 #undef memset #undef RtlFillMemory extern \"C\" NTSYSAPI BOOL NTAPI RtlFillMemory ( VOID *Source1,DWORD Source2,BYTE Fill ); #define memset(Destination,Fill,Length) RtlFillMemory((Destination),(Length),(Fill)) |
其他类似的情况也可以这样处理,同时大家也可以开动智慧将部分C运行库用kernel32.dll或者ntdll.dll中现成的等价函数替换。这样对于暂时不习惯完全采用WINAPI编写的开发者来说能带来些便利。 到目前为止,在代码逻辑上已经将C运行库从程序剥离了,但实际上编译还是不会通过。原因在于目前程序的真正起始位置还不是int main()或者int WinMain(...)这些。在执行这些逻辑上的开始位置前,还有不少的C运行库Wrapper。 要将这个Wrapper去除,直接办法就是在link选项中修改入口地址到目前的main(WinMain)函数。或者用等效语句: #pragma comment(linker, \"/ENTRY:MyMain\") 此时,如果程序中有MyMain这个函数,那么他将真正作为程序的入口点(OEP)。不过要注意的是,作为OEP的函数不能带参数。因为传统的main函数或者WinMain中那些命令行信息的参数,都是由之前的C运行库Wrapper获取提供的,而直接从OEP启动时候,是没有参数提供给程序的。这样将造成堆栈的不平衡(但实际影响不是很大)。 在做到这一步后,就可以尝试编译程序。以VC中创建的SDK的helloworld示例程序为例。经过目前的操作,产生的程序应该在2kb左右或更小(作者并未测试,只是估计值)。不过这里要注意的是,目前的程序有一个问题:进程无法结束。这是因为在原始的C运行库Wrapper中,执行完了WinMain等程序,会调用ExitProcess()终止进程。因此,在我们的新OEP程序的适当地方,加入此语句即可。 对于原先WinMain等函数中参数的获取 要获取诸如命令行参数或者HINSTANCE值,其实可以调用相应的API实现。对于命令行,可以调用函数: GetCommandLine(void); 对于HINSTANCE,可以调用 GetModuleHandle(NULL); 其他参数这里就不列举了,可以查阅相关文档或者反汇编原始的C Wrapper分析。 2.段合并问题 段(Section)是一个PE文件格式中的术语,具体可以参照MSDN中的一篇很完善的PE格式介绍文章\"An In-Depth Look into the Win32 Portable Executable File Format\"(http://msdn2.microsoft.com/en-us/magazine/cc301805.aspx),对于采用VC生成的程序文件,一般会带有下面3个段: .text .data .rdata 一般而言,代码主体将被置于.text,而一些常量数据和资源文件会分配在其余2个段中。对于一个段来说,它的大小不是随意的。因此当程序代码或者资源较少时,就会在段内存在大量的冗余数据(一般都是0)。这样就白白占用了程序体积。解决办法就是把它们合并到一个或多个段中,压缩体积。 采用如下语句合并2个段: #pragma comment(linker, \"/MERGE:.data=.text\") 也可以重新定义一个段,然后将所有段合并到新定义的段中 #pragma comment(linker, \"/SECTION:tiny,\") #pragma comment(linker, \"/MERGE:.data=tiny\") #pragma comment(linker, \"/MERGE:.text=tiny\") #pragma comment(linker, \"/MERGE:.rdata=tiny\") 这样也能在一定程度上减少体积。 不过注意的是,这个办法适用范围并不大,尤其是对于含有资源的程序,基本很难成功运行。而且他对程序体积的减少贡献不是很大。 3.将程序加壳 如果仅仅利用上述手段,实际上只是得到了一个程序本应该具有的体积。但是就像前面提到的一个典型的demo程序也要占用600kb的空间。那么如果做近一步缩减呢? 方法其实就是数据压缩。对于程序的代码本身、纹理贴图(假设为非压缩的bmp)、MOD音乐(其中音色为wav格式)。这些都含有很高的冗余信息,具有很大的压缩潜力。因此可以使用一些压缩加壳工具将程序加壳。就Demo程序而言,一般采用UPX和由farbrausch专门为demo程序设计的kkrunchy。同时也十分推荐国内由Dwing制作的加壳工具:WinUpack 。 这几个工具均能从网络上免费获取,下面我做一点简要的点评 对于UPX和WinUnpack 采用的是LZ系列的压缩算法,而WinUnpack采用的一些改进措施,所以压缩效果比UPX好。同样采用LZ系列压缩算法的是人人皆知的WinZip和WinRAR工具。对于这2个压缩壳而言,因为定位于通用的程序加壳,所以具有较高的稳定性。而UPX历史悠久,因此基本上可以应对所有情况。 对于kkrunchy 他采用的是目前号称压缩比最高的PAQ7-9算法。 参考: 对于各类压缩算法的比较:http://www.maximumcompression.com/index.html PAQ算法在wikipedia的介绍:http://en.wikipedia.org/wiki/PAQ (大陆需要设置代理访问) PAQ算法的相关说明和论文:http://www.cs.fit.edu/~mmahoney/compression/ PAQ虽然具有很高的压缩比,但是代价就是缓慢的解压缩和压缩速度。有人曾测试说解压缩1MB数据需要半分钟。但是因为很多demo程序体积都不大,而且自身加载后还需要近1分钟的预先计算过程,所以这还可以接受。 不过kkrunchy还有一个缺陷,就是他会采用上面提到的段合并技术。因此很多带有资源的程序加壳后便无法执行。 这里谈谈我本人的看法,我比较倾向使用前2者。第一是这些加壳工具在压缩比率上差别不是很大。不过UPX目前可以看成是开发的技术,WinUnpack为国人开发。而kkrunchy,他的开发者farbrausch自身就是Demo Scene的参赛者。 不过这只是本人的看法。 在经过了加壳处理后,原先600kb的程序很容易的缩小到了64kb左右。这样,又一个“神奇”的demo程序诞生了。(当然真正能让人称的上神奇的应该是本身的画面和音乐) |