« August 2008 | Main | October 2008 »

September 28, 2008

买了一台 LinkStation Pro

国庆打算回武汉。长假里得找点东西折腾着玩玩。所以就在网上订了一台 LinkStation Pro ,250G 的版本。准备刷了机做服务器玩。这台 NAS 性能不错,有 400MHz 的 Arm9 CPU ,128M 内存,和一块千兆网卡以及两个 USB 口。最关键是能耗低,才 21w ,没什么噪音,可以 24 小时开着。

下订单之前,用 google 好好做了下功课,确认可以装 debian 就放心了。这周到货之后,这两天晚上一直在折腾这个小玩意。

不过真正刷机时没想象的那么顺利。

刷机教程 我打算刷上 Freelink 。一开始居然点错了,没有按教程里警告的那样先把 Initrd-Only 包刷上去,而是直接刷了整个 Freelink 的包。差点以为变砖头了,没想到一点问题都没有, ssh 顺利的接入。

没过多久就发现,那个刷机网站的 wiki 上提供的傻瓜安装包里是 2.6.12 的内核。跟 Debian Etch 的 ABI 有点冲突,居然 iptables 都不能用 :( 真是欲哭无泪。我可是打算放在家里做 ADSL 的拨号网关外加家庭防火墙用的。然后就是漫长的 google ,以及泡论坛。

然后发现有人已经 build 了最新的 2.6.26 的内核,只要更新了这个就没有问题。但是需要重新刷一次机。只是这次官方的更新程序已经不能用了。需要把 LS Pro 启动进入 EM Mode 。并利用 tftp 更新。

这台机器进入 EM Mode 的方式真是古怪,需要反复启动 3 次,一开始我试了好久都没进去。后来误打误撞进去了,但是 tftpd 没有设置好,引导到了一个不知道什么状态的系统。乱折腾一通,真的把机器弄成砖头了。一开机就滴滴直响。

按网上介绍的方法,把那台开着 tftpd 的 ubuntu 机器和 LS Pro 对接,开机后一点反应都没有。在 Ubuntu 上监视了所有的网络通讯,没发现 LS Pro 有发出任何请求。又折腾了半天才发现原来是网线的问题,用原机附带的网线就好了。在出错状态下开机就会发起 tftp 请求。好在 ubuntu 下监视网络比较方便,很快就猜出了它的网络启动的流程。最终终于把系统更新到了 2.6.26 。

真是不容易啊。

然后就是配防火墙,装 samba lighttpd tor bt 啥的。国庆节有的玩了。

btw, 不知道为啥,国内的几个 debian 的镜像里都没有 armel 。试了几个别的镜像,香港和台湾的比较快,尤其是台湾的,我记得前两年去台湾速度很慢的呢?难道是新铺了海底光缆?

September 25, 2008

IDE 不是程序员的唯一选择(四)

前面我们介绍了一些 Make 的基本知识以及 Make 的工作原理。如果同学们有兴趣的话,翻阅手册已经可以开始做许多事情了。而 GNU Make 更是有许多扩展,灵活应用便能发挥出超乎想象的威力。为了达到这些,Make 必须要有更多的一些可编程的能力。在处理不同的事务的时候,以不同的方式工作。在处理相同的事务时,又不必让编写 Makefile 的人机械性的重复。

今天,我们来谈谈 GNU Make 中的变量。这里提到的都是以 GNU Make 为基础,因为 Make 的各种变种太多,每种版本间都可能有一些细微的差别,但其基本原理是相通的。


昨天我们最终的 Makefile 版本是这样的:

all : foobar.exe

clean :
    -del foobar.exe foo.obj bar.obj

foobar.exe : foo.obj bar.obj
    link /out:$@ $^

foo.obj : foo.c
    cl /c $<

bar.obj : bar.c
    cl /c $<

我们能看出,其中有许多重复的信息,让我们一点点的去掉它们。

首先,将 .c 文件编译成 .obj 文件的方法是一样的。在 GNU Make 里,我们可以为其写一个通用的规则:

%.obj : %.c
    cl /c $<

注意,这并非传统的 makefile 的写法(传统规则定义方法就不介绍了),而是 GNU Make 扩展,以后我们就不再为 GNU Make 做特别说明了。

它的含义是,所有以 .obj 结尾的目标,都依赖于相同模式,但以 .c 结尾的文件。并且这类文件用统一的构建方法:cl /c $< 。$< 我们在前面已经反复提到过,它表示以上的第一个依赖文件。这个符号看起来有些怪异比较难记忆。如果你用 BSD Make ,那么就写作 ${.IMPSRC} 可能会舒服点。其实习惯了后都不错。

% 可以看成一通配符,%.obj 是一个后缀匹配,用 . 做文件后缀名的区分仅仅是一个习惯。如果你愿意,自然也可以写 %obj 去匹配所有obj 结尾的文件。

Makefile 既可简化为:

all : foobar.exe

clean :
    -del foobar.exe foo.obj bar.obj

foobar.exe : foo.obj bar.obj
    link /out:$@ $^

%.obj : %.c
    cl /c $<

因为这里只生成了一个 exe 文件,要是有多个 exe 文件生成,那个 link 也应该可以提取出来。可以写成:

all : foobar.exe

clean :
    -del foobar.exe foo.obj bar.obj

foobar.exe : foo.obj bar.obj

%.exe :
    link /out:$@ $^

%.obj : %.c
    cl /c 

这个版本里,foobar.exe 和 %.exe 被分开定义了,根据我们前面的介绍,知道这样是合法的。一个目标可以有多次的依赖关系定义,Make 只是顺着往它的依赖关系表里添加而已。

接下来,我们发现 foo.obj bar.obj 在两个地方出现。程序员很自然的会想到,让我们用一个变量记录下它们。对 Make 支持变量。定义变量很简单,使用 = 赋值即可。还可以用 += 追加。更多的用法可以去查参考手册。而使用一个标量,则用 $(变量名) 这样的形式。因为 $ 对于 Makefile 文件有特殊含义,所以,一旦你需要在命令行部分(Tab 开头的那些行)写上 $ 就用两个代替,写作 $$ 。

COBJS=foo.obj bar.obj

all : foobar.exe

clean :
    -del foobar.exe $(COBJS)

foobar.exe : $(COBJS)

%.exe :
    link /out:$@ $^

%.obj : %.c
    cl /c 

现在看起来是这样了。我们的定义了一个变量 COBJS ,保存了跟 foobar.exe 有关的所有 obj 文件名。然后在下面用这个变量替换掉。这是个很基本的用法,看起来没有什么意义,但是现在我们考虑一个问题:如果我们使用 C 和 C++ 混合编程,或者再使用了汇编等别的语言。这样,项目里的 .obj 文件就不全是由 .c 文件生成的了。这样,%.obj : %.c 这条规则有有了问题。

因为如果你连续定义

%.obj : %.c
    cl /c 

%.obj : %.cpp
    cl /c 

这里 %.obj 文件有了两条生成方法(虽然它们相同),这是不允许的。注意:简单的定义 %.obj : %.c 和 %.obj : %.cpp 则是合法的,它只是个 %.obj 增加了依赖关系而已。但是导致错误的解决问题:我们生成 .obj 文件并不需要同时拥有同名的 .c 和 .cpp 。

怎么办?

让我们进一步改进一下:

$(COBJS) : %.obj : %.c
    cl /c 

写成这样即可。这一句的变量展开是什么呢?

foo.obj bar.obj : %.obj : %.c
    cl /c 

根据前面我们已经具备的知识,我们知道这个等价于:

foo.obj : %.obj : %.c
    cl /c 

bar.obj : %.obj : %.c
    cl /c 

这是一个严格的模式匹配的过程:foo.obj : %.obj : %.c 表示了,用 %.obj 试着匹配 foo.obj ,如果成功 % 就等于 foo ,如果不成功,Make 会警告你。然后,给 foo.obj 添加了依赖文件 foo.c (用 foo 替换了 %.c 里的 % )

注:对于现在版本的 GNU Make ,(含 % )模式规则定义可以不考虑这种问题。如果一个目标有两条规则可以匹配上,会通过检查所依赖的目标是否存在来决定适用哪条规则。但是,以上的方案在间接目标构建中依然有用。

现在回头来看看昨天的一篇最后那个问题:为什么 foo.c 中包含了 foo.h 文件,但是不能在 Makefile 中写 foo.c : foo.h ?

这是因为 Make 在工作的时候,每个目标的构建的单独判断所依赖的文件是否存在或其时间的。如果,foo.h 被修改,的确会触发 foo.c 的构建。不过这里我们只描述了 foo.c 和 foo.h 的依赖关系,而没有写任何构建指令。缺少构建指令时,默认是成功的。但是 foo.c 的时间并没有修改。这样在接下来的 foo.obj 构建过程中,foo.obj 的时间新于 foo.c (foo.c 未被修改过),故而 foo.h 的修改不会触发 foo.obj 的构建。

btw, 随着 Makefile 的复杂度增加,调试会是一个问题,上次我们介绍了 gmake 的 -n 参数,以观察执行流程。其实还有一个更为强大的 -d 参数,有兴趣的同学可以自己试试。

如何解决这个问题呢?一个苯办法是,当 foo.h 修改后,同时也更新 foo.c 的时间。在 *inx 下,有一个 touch 指令可以做这件事情。我们写作:

foo.c : foo.h
    touch $@

Windows 下没有 touch 指令,但是 copy 可以代劳:

foo.c : foo.h
    copy $@ +

这样做有些坏味道,明明是 foo.h 的修改,凭什么要更新 foo.c 的文件时间呢?所以,一般的做法是,让 foo.obj 直接依赖于 foo.h 。这样,我们的 Makefile 就变成了这样。

COBJS=foo.obj bar.obj

all : foobar.exe

clean :
    -del foobar.exe $(COBJS)

foobar.exe : $(COBJS)

foo.obj : foo.h

%.exe :
    link /out:$@ $^

$(COBJS) : %.obj : %.c
    cl /c 

如果我们需要随时开关调试状态怎么办?也就是说,希望更灵活的打开或关闭 /Zi (调试信息)/O2 (最大速度优化)这样的编译开关。通常会预留一个变量。

$(COBJS) : %.obj : %.c
    cl $(CFLAGS) /c $<

然后再最前面定义 CFLAGS =/Zi

我们还可以把最下面两段比较通用的规则放到一个独立文件 compile.mk 里,就好象 C 语言里的头文件那样。

CFLAGS=/O2    #优化
#CFLAGS=/Zi    #调试

COBJS=foo.obj bar.obj

all : foobar.exe

clean :
    -del foobar.exe $(COBJS)

foobar.exe : $(COBJS)

foo.obj : foo.h

include compile.mk

注意前面两行的 CFLAGS 的定义,它放置了一些我们以后可能会调整的编译选项。这里 # 表示注释,相当于 C++ 里的 //

再注意最后的 include 语句,它完成的工作非常类似于 C 语言中的 #include ,即把一段文本插入当前的 Makefile 文件。只不过,Make 的 include 比 C 语言下的强的多。它可以在 include 后跟多个文件名,而文件名也可以是一个变量。甚至 include 的文件本身也可以是一个目标文件。如果 include 的文件不存在时,可以调用自身对应的目标将其生成出来。这些用法,我们会在以后的时间里细细展开。对于 C 语言头文件的依赖关系的自动生成,通常需要类似的技术来实现。

附上 compile.mk 文件

%.exe :
    link /out:$@ $^

$(COBJS) : %.obj : %.c
    cl $(CFLAGS) /c $<

今天,我不想一直的围绕编译构建 C/C++ 工程谈下去。如前面所说,Make 可以帮助我们简化日常的许多工作。比如今天,我就碰到一个需求:要把一颗目录树下的所有图片文件转换成一种私有格式。

我们有自己的命令行转换工具,每次可以转换一个文件。(这种图片转换我想很多人都遇到过,对于通用图象格式间的互转,有一个非常好用的命令行工具,ImageMagick,google 即得)

我第一感觉想到的是使用命令行指令 for 。不知道的同学可以输入 for /? 查询。

for 可以帮我遍历目录树,并对每一个符合要求的文件做一段命令行指令。但是,这里有几个问题。我们需要转换的图片非常多,有上万个文件。全部转换一次非常长的时间。而转换工具有点小问题,中间如果出了故障,不方便继续。另一方面,我的台式机是双核的,很难利用两个核同时工作。

所以我写了一个 Makefile 来做这件事情。首先我用 for 指令生成了需要转换的文件名列表。

filelist:
    echo # > $@ && for /R %%I in (*.jpg) do echo LIST+=%%~pI%%~nI.dat >> $@

这小段代码会生成一个叫 filelist 的文件,里面有所有的 jpg 文件名。但是把扩展名 jpg 换成了我们的目标扩展名 .dat 。 下面是一段针对转换工具的调用:

$(LIST) : %.dat : %.jpg
    convert $<

完整的 Makefile 最终是这样的,由于是随手写的,有些地方并不完备,但它可以快速工作。

include filelist

all: $(LIST)

filelist:
    echo # > $@ && for /R %%I in (*.jpg) do echo LIST+=%%~pI%%~nI.dat >> $@

$(LIST) : %.dat : %.jpg
    convert $< -o $@

运行 gmake ,转换工作开始了。一开始会显示 makefile:1: filelist: No such file or directory ,表示 filelist 找不到,无法 include 。但是没关系,接下来 Make 找到了 filelist 的构造方法,立刻构造出这个文件列表来。all 这个缺省目标就是依赖这个文件列表上的所有文件的。所以,工作可以随时用 Ctrl-C 停下来,下次运行时,gmake 将根据文件时间继续上次的工作。

这个 makefile 比较通用,其实可以放在任何地方,比如我把它塞在了 c:\tmp 下。需要转换那个目录下的所有 jpg 时,只需要进入那个目录,运行 gmake -f c:\tmp\makefile 即可。没错,gmake 可以用一个 -f 参数指定 makefile 文件的位置。这样,我们便有了一个方便的批量文件转换工具了。

怎么利用双核?太容易了。 gmake -j2 即可。-j2 表示用两个进程同时工作,如果是四核的机器,则可以 -j4 。打开 -j2 开关后,立刻可以发现 CPU 处于满负荷工作状态。短短几行代码就把这件事情搞定了。非常开心。

September 24, 2008

IDE 不是程序员的唯一选择(三)

有了前面的介绍,相信好学的同学们对 make 已经有了一定的了解。

记住,编写 Makefile 也是构建整个软件的一部分,其重要性并不亚于编写 .c 或 .h 文件。当你用 IDE 的时候,是由 IDE 来生成相当于 Makefile 的文件。但是这个生成的过程并不是完全自动的,它是由你的鼠标点击、拖拽(把 .c 文件加入项目)、和填写一些表单、以及勾选编译选项完成的。

如果你的整个项目中其它源文件都是从键盘输入来编写的,那么 Makefile 也由手工编写就理所当然了。

如果你在用 C++ ,在用 C++ 中的 STL ,那么是不是应该搞清楚 STL 到底做了什么,怎么做到的,这些问题呢?这是用 C/C++ 语言编程的程序员的一个基本态度。即使不去研究透彻,至少也应该了解一些大致的原理吧。那么对于 Make 来说也是这样。我们应弄清楚 Make 到底如何在工作,我们在 Makefile 里写的每一行代码是什么含义。为什么可以帮我们完成那些工作。这个连载选择 Make 来展开,也正是因为 Make 的工作原理非常简单,方便我们学习。

越是简单的东西,越可以在其上做出各种奇妙的东西。Make 也是这样。但一开始就给出别人做好的完善的库,简单的用一下,会让我们迷失其真谛。相信我,最终,编写 Makefile 可以非常简单,善用工具、不必写任何多余的东西,甚至比在 IDE 里拖入几个源文件更简洁。但一开始,还是从繁琐开始,这些繁琐都是帮助你去理解,等你理解了自然能找到方法简化这些繁琐的工作。

云风绝非使用 Make 的高手,从某种意义上来说,也是一个入门者。写这个系列的时候,也需要去查阅文档确认是否写错。平时工作的时候,Makefile 文件通常也需要多次调试,才能正确的完成工作。也正是如此,才能体会到:怎样去理解和学习,容易跨过最初的门槛。


上一篇开篇,我们讲到了,如何用命令行分开编译 .c 文件,并把它们链接起来。这样做,可以使每次修改都可以让机器做最少的编译任务。对于小工程,这样做的意义不大。但是大工程,可能就能帮我们节约不少时间了。记住这一点,永远没有通用的最优方案。因为实施方案本身也是有成本的。我们只需要找到最直接最简单的方法就可以了。比如项目一开始,可以写一个最简单的 Makefile 文件,随着项目规模的扩大再逐步完善。

分开编译再链接这件事,人做起来都比较繁琐,把它教给机器去做,当然也会更繁琐。所以我没有在上一篇中详解。好学的同学应该会自己弄了,今天,我也来写写自己的方案。但在此之前,我们先梳理一下对 Make 的理解。

Make 是一个工作于非常简单的模式下的工具。它的内部有一张表,记录了目标文件之间的依赖关系。Makefile 就是用来描述这张依赖关系表的。对于依赖关系表的描述,用了一种非常简单的语法:

目标 : 依赖

这表示,"目标" 的构建依赖于 "依赖" 先构建出来。这里,"目标" 和 "依赖" 都是文件系统中的文件,而"依赖"本身也可以是一个"目标"。如果 "依赖" 的文件时间新于 "目标" 的文件时间,表示 "目标" 需要重新构建。如果 "目标" 文件不存在,也会触发这种构建过程。

一个目标可以有多个依赖,可以在 : 后以空格分开写上多个,比如:

目标 : 依赖1 依赖2 依赖3

这在我们前一篇中已经多次见过了。其实还有另一个规则,我们可以写:

目标 : 依赖1

目标 : 依赖2

这样分两行写,即,每次写 "目标 : 依赖" 都在依赖关系的依赖关系表中添加了一项。(关于同名的依赖添加的问题,以后我们在讨论)

举个例子:

a : b c

a : b
a : c

其实是等价的。

每个目标的构建方法并不是由 Make 内置功能完成的,Make 只是简单调用写在 "目标" 定义的下一行的若干命令行脚本而已。而每一行命令行脚本必须以 Tab 键开头。注意,如果你把目标的依赖关系分成若干行实现,只可以有一个地方定义构建脚本。

举例:

all : a
all : b

a :
    echo $@

b :
    echo $@

这样一个 Makefile 用 Make 运行后,会显示:

echo a
a
echo b
b

为什么呢?因为 Make 会找 Makefile 中定义的第一个目标,做为它这次的终极目标。在这里是 all 。all 依赖于 a 和 b 两个目标。由于你的工作目录下没有 a 和 b 两个文件,所以触发了 a 以及 b 的构建。而 a 的构建指令是 echo $@ ,$@ 指代了当前目标 "a" ,结果就执行了 echo a 。同样,b 文件的不存在,导致了 b 的构建,执行了 echo b 。

需要强调的是,echo 并不是 Make 的内置功能。echo 是 Windows 命令行指令(在 *nix 系统上,称为 shell 指令)。Make 只管忠实的执行那些相关的以 Tab 开始行内描述的命令行指令。目标的构建成功也不以目标文件是否正确生成为依据。而是以命令执行的结果是否为 0 。记得学 C 语言的时候,老师教你,main 函数得到正确结果时,应该 return 0 吧。这个 main 的 return 0 就是返回给系统用的。Make 通过检查这个返回值来觉得命令行指令是否被正确的执行。如果收到非 0 的返回值,整个 Make 的过程会被中断。当然,echo 这样的指令,一般都会返回 0 的。

注意,一个目标是否被正确构建,只取决于构建它的命令行指令是否正确的返回 0 ,而不取决于文件是否被创建出来或是被更新到最新的时间。在一次构建中,每个目标最多只会被构建一次。这是由于 Make 只对依赖关系表做一次简单的拓扑排序,然后就开始工作了。

同样,我们还可以让多个目标依赖同样的东西:

a b : c d

就等价于

a : c d
b : c d

现在我们可以看到,在 Makefile 里写 : 定义,其实就是在填写一张依赖关系表。每次的一个 : 都向表里追加一些项目。Make 工作的时候,先读完整个文件,把完整的依赖关系表建立好,再根据命令行指定的目标开始工作,如果在命令行不指定目标,默认就是 Makefile 里写的第一个目标了。

这样我们就好理解,除了 Makefile 里的第一个目标定义之外,所有的依赖关系描述都是无所谓书写的先后次序的。Make 只管向依赖关系表里添加表项,不会删除。原则上也不保证按依赖表中的次序先后来构建,如果你需要一个目标一定先于另一个目标构建,就需要显式的描述出依赖关系。


让我们回到早先的例子:从 foo.c 和 bar.c 构建出 foobar.exe 。并且要求 foo.c 和 bar.c 里若只修改了一个文件,就只编译新修改的那一个。


foobar.exe : foo.obj bar.obj
    link /out:$@ $^

foo.obj : foo.c
    cl /c $<

bar.obj : bar.c
    cl /c $<

先别急着编译,我们来看看如何调试 Makefile 文件。使用 gmake -n 看看,是不是显示了:

cl /c foo.c
cl /c bar.c
link /out:foobar.exe foo.obj bar.obj

如果显示的是,

gmake: `foobar.exe' is up to date.

那么就先 del foo.obj bar.obj foobar.exe 然后再看。

-n 参数可以让我们观察到底会执行些什么命令行指令,而不真的去执行它们。这对调试复杂的 Makefile 非常有用。

如果没有问题,就可以 gmake 构建出 foobar.exe 了。

如果你烦透了每次做一次实验都要手工清理一下 obj 和 exe 文件,那么可以把 del 指令也写在 Makefile 中。在上面的 Makefile 最后加上两行:


clean :
    -del foo.obj bar.obj foobar.exe

当然,你也可以偷懒写成 del *.obj *.exe

同学们会注意到,del 前有个减号。这个 - 是告诉 Make ,忽略掉后面这行指令的返回值,不要因为返回值非 0 而中断。什么时候 del 会返回非 0 值呢?自然是要删除的文件不存在啦。

现在你就可以通过 gmake clean 来清除目标文件了。或者用 gmake clean foobar.exe 来先清理再编译,这相当于我们在 IDE 中使用的 rebuild 指令。Make 的命令行可以跟多个目标,它会顺着构建这些目标。对于举一反三的同学,应该已经给 Makefile 加了一个 rebuild 目标了:

rebuild: clean foobar.exe

暂时先这么写着,以后我们会介绍更好的,更偷懒的写法。


最后给出一个云风当年初学 Make 时,犯过的一个错误。属于对 Make 理解不当造成的。

假设 foo.c 里 include 了一个 foo.h 文件,依赖关系应该怎么写?

在 Makefile 的最后,加上一行

foo.c : foo.h

这样行吗?

你可以试试,修改一下 foo.h ,然后 gmake 一下,看看有没有重新编译 foo.o 。为什么不能工作呢?留给同学们思考了。

September 22, 2008

IDE 不是程序员的唯一选择(二)

话接上回,话说我们已经大致了解了 C 编译器的工作流程,知道了 IDE 在背后如何在驱动编译器生成代码。对于传统 IDE ,就是集成了编辑器、项目管理、编译器,和调试器等几个大件的一个庞然大物。其中 IDE 企图节省人力的最大的部分就是将源代码组织起来,自动生成其间的关系,调用编译器构建项目。

(此处删去几百字关于 IDE 优劣的讨论。因为我觉得这个话题会陷于无谓的争论,还是直入主题比较好。承接上篇的宗旨,本文只写给有兴趣学习相关知识却不知该如何入门的朋友。说服程序员放弃 IDE 不是本文的初衷。)

对于不太大的项目,比如学校里日常做作业。写一个 make.bat 文件管理你的 C 代码已经足够了。如果你用一些 windows style 的编辑器,比如流行的 editplus 之流,都可以配置相应的所谓 user tool,一键调用 .bat 构建出最终的程序。也可以设置捕获编译器的输出,方便的双击错误信息定位到源代码编译错误的地方。

如果想摆脱鼠标(纯键盘操作对于基于命令行的工作方式来说,非常的有效率。毕竟写程序最终也得靠键盘的。),可以考虑使用 vim 。不要惧怕学习新事物。任何被公认优秀的工具,都有学习的价值。vim 属于有一定学习门槛,但一旦掌握(一般程序员可能需要一周左右的时间熟悉),威力无穷的那种。我的另一个同事强烈推荐 emacs ,我没怎么用过。如果你打算写 10 年以上的程序,花上几天时间学习一个无数程序员公认好用的工具,这项投资我个人认为是非常值得的。


使用 bat 的方式来构建项目有一个问题,那就是当项目慢慢增大时,效率受到影响。因为每次 build 一次,都要重新编译链接所有的源代码。而 C/C++ 从设计之初就考虑到节省编译时间,可以每个源文件独立编译的,然后最后在把分别编译出来的目标文件链接在一起。(关于编译和链接的概念,不明白的同学请自己 google )下一步,我们可以每次只编译刚刚修改过的源文件,和那些没有修改过的源文件以前编译出来的目标链接在一起就够了。

早期的 C 编译器,但是将编译和链接过程分开由两个程序完成的(现在其实也是)。我们今天看到的类似微软的 cl 这种程序只是给独立的连接器加了个壳而已(gcc 也是这样)。下面我们来看怎样分开编译多个源文件,并链接它们。

如果只想编译一个 .c 文件,只需要在给 cl 加上参数 /c ( gcc 是加 -c )。那么编译上一篇中提到的例子 foo.c ,即用命令行指令:

cl /c foo.c

我们可以看到,当前目录下生成了目标文件 foo.obj

我们可以用同样的方法生成 bar.obj 。即 cl /c bar.c

那么如何把 foo.obj 和 bar.obj 链接起来?还是用 cl 即可。

cl /Fefoobar foo.obj bar.obj

cl 这个壳会正确的识别输入文件的类型,做出对应的链接这个行为。我们也可以直接调用微软的链接器:Link 。

link /out:foobar.exe foo.obj bar.obj

注意,这里输出文件必须写全 .exe 后缀。

罗罗嗦嗦写了一大堆,主要是想加深以前不太明白这些的同学们的印象。如果是玩 IDE 的老鸟,其实在 IDE 的各种设置中,差不多也都看过这些了。关于编译器的命令行参数不记得不要紧。一则可以用 /? 看帮助;二还可以去 VS 的 IDE 中对应的设置菜单里看看,通常一个 IDE 菜单选项的修改,都对应了最终命令行参数的差别。生成 exe 还是生成 dll ;使用控制台模式,还是标准 Win32 程序;预定义些什么宏,打开调试信息或是开启编译优化,无所不在。

记住这些参数的写法并不容易,也没有必要。因为相对于编写代码,敲打这些编译开关只占很少的工作。但要记住,如何调用编译器去构建工程,这个步骤本身其实也是项目构建的一部分。亲手写过,也就多了一分了解。


编程之道在于,让机器做机器的事,人专注于人的思考。如果真的靠手键入每条编译指令,无疑让程序员去做了机器之事。把编译指令写入批处理(脚本)文件,仅仅只是节省了每次编译的重复劳动,并没有从根本上解脱。

在从 C 的源代码构建最终的执行文件这个流程中,哪些是人的创造,哪些是机器应行之事?显而易见,程序员应该做的是:

  1. 教会机器,如何把一个 .c 文件编译成 .obj 文件。

  2. 教会机器,如何把若干个 .obj 文件链接成 .exe 文件。

  3. 告诉机器,你的项目由那些 .c 文件构成,最终你想生成一个叫什么名字的 .exe 文件。

其中前两步,对于大多数类似的项目来说是共同的,所以我们只用也只应该教机器一次。而第三步,提供一个文件列表和一个目标文件名给机器即可。

实现以上目标,显然我们需要额外的工具。IDE 的项目管理及构建模块是一个选择,但不是唯一的选择。IDE 对最为常见的构建需求做的相当不错。也就是说,IDE 完美的教会了机器前两个步骤,我们无须干预。但这件事情意义有多大呢?既然我们现在已经知道整件事情的流程手工怎么进行,完成它就有了无数的选择。教会机器做这前两件事情,只是一项一次性劳动。我们可以通过教授机器做这件事的过程,学会更多东西。而后举一反三去干更为复杂的工作。下面,云风将介绍一种叫做 MAKE 的工具。它是我们完成这个目的的第一个台阶。


为什么是 Make ?

我想说, make 只是诸多选择中的一个。它绝对不是最好的,但它是最容易理解的。make 以一种极其简单的规则运作,让程序员可以轻易看透它的实质。而简单的工具组合起来往往可以发挥出极大的能量。因为简单,所以 make 也很容易学习。你只需要掌握它很少的一部分功能,它就帮助你完成各种各样的工作。绝不仅仅是编译程序这么简单。你可以用它收发邮件、下载文件、制作安装包、运行单元测试,等等你能想的到的在你的机器上用命令行能够完成的所有事情。当然这些事情不用 make 也有别的方式去做,只是 make 用起来更方便。Make 会按你告诉它的事情的依赖关系,决定了做事情的前后次序,然后批量完成交给它的任务。如此而已。如果你用过 FreeBSD ,一定会爱上它的 ports 。需要什么软件,进入对应目录,make install 。下载、配置、编译、安装,一气呵成。这就是 make 的威力。

学会了 Make 后,你再接触别的项目构建工具,就不会有太多障碍。Make 做起来比较困难的事,也可以再高一个层次的工具来完成,比如 Automake 。我们要做的是,认识问题是什么,选择合适的工具,用程序员的方式解决它。

Make 有许多分支版本,细节使用起来各有差异。VS 里带了一个叫做 NMAKE 的小工具,是 Make 的一个旁支。我本来想从这个讲起,无奈用的不多,还是换成日常用的比较多的 GNU Make 吧。gmake 的内建指令也更为丰富,稍微熟悉一下,得心应手。更难得的是,在 Windows 下获得 gmake 非常方便,使用 Mingw 版的 gmake 即可,在 google 搜索 “mingw gnu make” 即可。其实不需要安装,它是一个绿色软件,一个独立的一百多K 的 gmake.exe 小程序就可以直接运行。

下面,云风假设你已经正确安装了 Mingw 版的 GNU Make (我的 Windows 系统上安装的版本是 3.81 )。在命令行下,无论在什么目录下都可以直接输入 gmake 运行。(默认安装的 Gnu make 的执行文件名可能不叫这个名字,但你可以自己改个顺手的名字)

让我们开始吧。


还是在一个你可以随意做实验的目录,顶好是你已经创建了 foo.c bar.c 的那个目录下,新建一个叫做 Makefile 的文本文件。编译它,写上:

all :
    echo Hello World

这是你的第一个 Makefile ,输入请小心。第一行的 all: 应该顶格写,而第二行 echo 之前,必须有一个 Tab ,而不能用空格替代。即:第二行必须是 Tab 打头。

Tab 不是可以忽略的空白字符,并且是 Make 工作的关键。这个设定早就为许多人诟病,没有正确的输入 Tab ,也是许多 Make 初学者常见的错误。骂归骂,只能说这是一个历史原因造成的。好在一旦你习惯它,同样会觉得编写 Makefile 其实是非常顺手的。而且现在很多编辑器都可以让 Tab 明显的显示出来,而不会和空格混淆。

现在,运行 gmake ,你会看到它调用了命令行指令 echo Hello World ,回显了这行字符串。庆祝一下,你成功运行了自己编写的第一个 Makefile 。

Make 的一切设计都是为了简单、快捷。你把要做的事情写在一个文件中,运行 gmake 去跑这些任务。按通常的设计,gmake 应该跟一个任务文件的文件名,指定跑哪个文件。但是为了简洁,gmake 默认去找当前目录下的名为 Makefile 的文件了。至于你想把这些任务放在别的文件中,可以通过 gmake 的参数控制。有兴趣的同学可以按 Gnu 软件的习惯,通过 gmake --help 查询。

光能显示一行 Hello World 显然离题万里。接下来我们看看让 gmake 帮我们编译程序。不要删除前面的 Makefile 文件,再后面追加几行:

foobar.exe :
    cl /Fefoobar foo.c bar.c

记住第二行开头的 Tab 不要敲漏了。

现在你的 Makefile 文件看起来应该是这样:

all :
    echo Hello World

foobar.exe :
    cl /Fefoobar foo.c bar.c

我们再运行一下 gmake foobar.exe 看看:gmake 调用了你写好的 cl 指令,编译出了 foobar.exe 这个文件。

OK,大家应该看出点什么。Makefile 比传统的 bat 批处理文件多了点功能。它可以把多个任务放在一个文件里,而不需要我们写多个文件。为了区分任务,我们在命令行指定要做什么。

那些顶行写的文本,如果由一个单词加一个冒号开始。这个单词就被称为一个目标。gmake 目标,就可以做对应的任务了。而 gmake 会去做什么呢?自然由目标定义的下面几行决定。所有以 Tab 开头的文本行,定义了完成这个目标应该执行的命令行指令。gmake 会运行目标定义之下一直到下一个目标定义之间的所有指令。(注:Make 并不会保证这些指令的执行次序,虽然原则上是按你书写次序执行。这可以让 Make 使用多个 CPU 加快运行成为可能)

当我们在命令行输出 gmake all ,它就 echo Hello World ;而输入 gmake foobar.exe 它就调用 cl 去生成 foobar.exe 。如果没有直接输入 gmake 而不跟任何目标参数。它会自动寻找文件里的第一个目标。(而 Makefile 编写的惯例,我们通常把第一个目标起名字为 all ,这仅仅是一个惯例而已)

多试几次看看。

第 2 次运行 gmake foobar.exe 你会发现,gmake 报告:

gmake: `foobar.exe' is up to date.

而拒绝再次编译。

没错,这就是 Make 为数不多的原则之一:一旦目标已经存在,就直接跳过任务。

所有的目标,都被 Make 认为是一个文件,它的规则就是,如果目标文件存在,就认为事情已经做完。除非……

让我们修改一下 Makefile 把 foobar.exe : 这行改成

foobar.exe : foo.c bar.c

然后修改一下 foo.c 存盘,再运行 gmake foobar.exe 试试?又重新编译了对吧。

这就是 Make 的第二条原则:目标 : 后面以空格写上它所依赖的其它目标。如果所依赖目标存在,且比目标本身的时间新,就重新构键一次目标。

由于我们修改了 foo.c 导致了 foo.c 这个文件(对 Make 来说是一个目标,只不过没有构建这个目标的方法而已,只能靠用户自己编辑生成)比 foobar.exe 更新。foobar.exe 依赖 foo.c ,所以触发了 foobar.exe 的构建方法。

同样,我们可以让 all 依赖与 foobar.exe 。这样 gmake all (或省略 all 不写,因为 all 是第一个目标)时,由于不存在 all 这个文件,而触发 foobar.exe 的构建流程。


接下来,我们回头来看看自己编写的这个 Makefile 文件。现在大约是这个样子:

all : foobar.exe
    echo Hello World

foobar.exe : foo.c bar.c
    cl /Fefoobar foo.c bar.c

第 2 个目标 foobar.exe 的编写非常的累赘。程序员的直觉告诉我们,信息的重复不是一个好味道。那么让我们来修改一下。

foobar.exe : foo.c bar.c
    cl /Fe$@ $^

这样是不是味道好点了?$@ 和 $^ 都是 Make 的内设变量,也可以看成是一种类似 C 语言中宏的东西。$@ 在指令被运行时,被宏替换为目标,而 $^ 被宏替换为所有的依赖目标,即冒号后面的那一长串东西。同样常用的还是 $< ,可以替换所依赖的第一个目标。


今天已经写的足够多了。没接触过 Make 工具的同学应该能初窥门径了。没错,从现在已经介绍的知识来看,Make 并没有为我们节省太多体力,甚至还多敲了许多字符,今天快结束时,居然还要多记几个诸如 $@ 这样古怪的符号。而做到的事情离我们的目标还很远。

没关系,云风会带着你渐入佳境的,那么,且听下回分解。

September 21, 2008

IDE 不是程序员的唯一选择(一)

我心目中,这篇文章的目标读者应该是在 Windows 下完全使用 Visual Studio 或 Borland C++ Builder (现在还有人在用么?)等系列 IDE 开发软件的 C/C++ 程序员。

我并不打算从 GNU Make 这种工具的使用写起,因为如果以上提到的这类同学如果都开始看 gmake 的文档(现在翻译工作已经有人做了),应当已经脱离了纯粹 IDE 开发的人群。本文只是一篇非常初步的入门文章,如果你已经使用过类似 gnu make 的工具构建自己的项目,那么完全不必看下去了。

不可否认,IDE 对于软件开发领域,是一项伟大的发明。它极大的降低了软件开发的门槛。但是另一方面,IDE 也限制了程序员们创造软件的手段。这些限制还包括了平台限制,工具选择,甚至新的编译技术,编程语言的选择。所以 IDE 绝对不是程序员的唯一选择,如果你现在作为一个程序员,完全不能离开 IDE 工作。那么,是时候接触一些新东西了。

如果你大约知道一点相关的知识,但是对用 make 工具去构建项目充满了鄙视和厌恶,云风不期望通过这篇文章改变你的想法。因为我不想花太多笔墨来介绍其好处。我个人认为,那些好处,一旦你认真的采用这种开发方式,是显而易见的。

阅读本文,云风假设你至少已经了解了下面这些知识:

会使用 Visual Studio 的某一个版本创建一个由 C/C++ 程序构建起来的工程,并正确编译运行它。

知道 Windows 里有一个叫做控制台(或终端)的程序,通常用 Win-R 然后输入 cmd 启动它。

知道最基本的 dir cd mkdir del copy 等 Windows 命令行指令,并了解 Windows 文件系统的基本结构。

基本了解 "环境变量" 这个概念,知道 PATH 这种常见环境变量的用途。

如果你对上述概念不甚了解,请运用你使用 google 的技能把它们弄清楚。然后,我们可以开始了。


读到这里还没有离开的同学,机器上应该装有一份 Visual Studio 。我的机器上就装了两个版本,但是差不多三年没怎么用它们做项目了。一份是 Visual Studio 6.0 ,早几年订购 MSDN 宇宙版送的。另一份是从微软网站免费下载的 Visual Studio 2005 Express Edition 。

我自己做项目现在是用 gcc ,并且向同学们推荐这款编译器。主要原因当然不是其免费,如上所述,我们现在也可以从微软免费获得编译器了。使用 gcc 最大的好处就是,一旦你打算切换到别的平台开发软件,可以不用更改你的使用习惯。就算你不离开 Windows ,也需要它来开发你的 psp ,nds ,手机,pda 等等。另外 gcc 一直在更新,它给你带来的好处是更强大的编译功能。如果你有一天像我一样放弃了 IDE ,Visual Studio 不断升级的 IDE 界面以及更华丽的工程管理方案对你也会毫无意义。

不过现在,我们暂时不要切换到 gcc 下。那样变化太大,反而难以接受。常年使用 vs 培养出来的习惯是很顽固的。等你学会了 make 这类工具,就会发现,其实切换编译器是再容易不过的事情了,到时候爱用啥用啥。


现在进入 cmd 控制台模式。输入 cl 回车。如果你的机器上安装了 vs 2005 ,你应该可以看到

Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 14.00.50727.42 for 80x86 Copyright (C) Microsoft Corporation. All rights reserved.

usage: cl [ option... ] filename... [ /link linkoption... ]

如果是 vc 6 那么也大同小异,

Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 12.00.8804 for 80x86 Copyright (C) Microsoft Corp 1984-1998. All rights reserved.

usage: cl [ option... ] filename... [ /link linkoption... ]

如果没有出来,可能是环境变量没有配置好。进入 vs 的安装目录(C:\Program Files\Microsoft Visual Studio 8\VC 或 C:\Program Files\Microsoft Visual Studio\VC98\Bin),运行 vcvarsall.bat (vs2005) 或 VCVARS32.BAT (vc6) 即可。实在搞不定就重新安装 vs ,默认安装选项会帮你设置好所有的环境变量。

好了,如果你成功运行了 cl 这个程序,下面的一切都会很顺利。cl 是微软出品的 C/C++ 编译器,就是把 .c 或 .cpp 的源文件编译生成为 PE 文件的工具。常年使用 IDE 的同学也应该注意到它的存在,就是在你使用 vs ide 时,按下 build 的按钮,下面 output 窗口里也会蹦出正在运行 cl 的信息。


现在创建并进入一个工作目录,比如 C:\project\foo ,我们将在这个目录下进行今天的演示。

用你喜欢的文本编辑器在这个目录下编辑一个文件名为 foo.c C 程序。写点 hello world 之内的东西即可。

接下来使用 cl foo.c cl 将为你生成两个文件,一个是 foo.obj 一个是 foo.exe 。我们应该明白这些是什么了。

现在可以试着运行 foo.exe 了,看看是不是你预期的结果?

好吧,对于这种一个源文件就可以搞定的简单程序,我个人认为,无论从任何角度讲,直接使用 cl 都比用 ide 要来的方便。至少我不用构建一个新的工程项目不是?有时候需要写一些实验性的代码,可以把一堆这样的小程序放在一个目录下,只要它们的文件名各不相同就可以了。完全不必在硬盘上留下大堆复杂的目录结构。

但这离构建一个项目还远远不够。通常一个 C/C++ 项目都是由很多个 .c .cpp 源文件,以及若干 .h 构成的。

比如,我们再添加一个 bar.c ,希望把 foo.c 和 bar.c 编译链接到一起。

无论是 cl 还是 gcc ,都支持直接在一个命令行写上多个源文件,然后依次编译并链接成最终的 PE 文件。所以最简单的做法是直接

cl foo.c bar.c

我们会看到, cl 生成了三个文件, foo.obj bar.obj 以及 foo.exe

cl 将 foo.obj 与 bar.obj 链接成了 foo.exe ,这个文件名默认是以第一个输入源文件为参考的。

如果我们想换个最终目标文件名怎么办?查一下 cl 的帮助,输入 cl /? 看看。

/? 是微软风格的命令行求助选项,几乎所有的微软编译工具都支持。如果是用 gcc 则是 gcc --help 。

我们在帮助信息里可以找到,能够用 /Fe 来指定最终生成的 PE 文件名。

现在可以用 cl /Fefoobar foo.c bar.c 试试看了,主要 /Fe 和 foobar 间不要留空格。

现在则生成的是 foobar.exe 而不是 foo.exe 了。


调试怎么办?

我猜用惯 IDE 的同学们现在最想问的就是这个了。如果我再来推荐诸如写 log 这类 “原始“ 调试方法,怕是要被人嗤之以鼻了。vs 的用户们肯定习惯了单步跟踪、设置断点、监视变量,等等这种 ”高科技“ 的调试手段。其实我也喜欢,方便的工具为何不用呢?

调试器的选择并不多,尤其在 Windows 下。但也不是别无选择的。使用 cl 自然就要使用 vs 自家的调试器啦。正如你使用 gcc 就必然选择 gdb 一样。(btw, gdb 习惯后其实并不难用,何况还有 insight ddd 这样的图形界面的选择,只不过他们的表现不如在非 windows 平台上那么好罢了。)

要使用调试器,必须先生成调试信息。对于 cl ,这个编译开关是 /Zi 不太好记,但是用的多了就熟了,一旦忘记了,请用 cl /? 查询。如果以后换成 gcc 那么就是 -g 了。

试试

cl /Zi foo.c

除了原来生成的 obj 和 exe 文件外,cl 还生成了 pdb 文件,这里面就存放了调试信息。当然,如果你用 gcc 的话,是不会有额外的文件的,调试信息就放在 exe 文件内部。

现在开启熟悉的 ide 来调试这个程序,当然这次,我们只是把 ide 当成调试器在用。如果不想使用 VS 的 IDE ,也可以使用微软免费提供调试器 WinDBG ,不过使用稍微麻烦一点,需要自己设定源码以及调试符号文件的路径,这里就不介绍了。

如果是在使用 vs2005 express 版, 请输入 vcexpress foo.exe ;如果是 vc6 的话,输入 msdev foo.exe 。效果都是一样的,都是打开 vs 的 ide 并加载 foo.exe 。

如果你讨厌命令行操作(嘿,同学。你都打算学习怎样不使用 ide 开发项目了,怎么能讨厌命令行环境呢?), 可以先打开 vs 的 ide ,然后从菜单里选择 open ,打开 foo.exe 也可以。

接下来,按一下 F11 (vs ide 默认的 step into 的热键)。看到了什么?foo.c 被打开了,程序运行指示光标停在了 main 函数的第一行。:)


接下来,云风将传授一门关于调试的独门秘籍。

知道调试器是如何实现断点这个功能的吗?其实它偷偷的在你的程序要设置断点的位置放置了一条调试中断指令。在 x86 32 位系统上,这是一条单字节指令,汇编代码是 int 3 。cpu 运行程序的时候,碰到 int 3 就会把控制权交给调试器,当你在调试器中选择继续运行的时候,调试器再将被替换的程序机器指令换回去,让程序继续运行。

知道了这个细节,我们就可以自己提前设置调试断点了。我称它为硬断点。只要程序运行到,就一定会停下来。如果你想在运行期屏蔽硬断点,需要在源码上做一些工作了。

现在在你刚才的 foo.c 的程序入口处加一行 __asm int 3 ( 如果你在用 gcc 可以加 asm ("int $3"); ) 重新用 cl /Zi foo.c 编译一次。然后在命令行直接运行 foo.exe 。

马上,你将会看到一个熟悉的关于程序崩溃的对话框。没关系,它是由你插入的硬断点 (int 3) 造成的。如果你正确安装了 vs ,vs 应该已经把自己设置成系统默认的调试器了。点对话框上的按钮,便将启动 vs 的 ide ,我们会发现程序正好停在了 int 3 那行汇编的地方。现在你可以尽情的单步跟踪了。


写了这么多,似乎还没进入正题。我们一直在玩一些玩具代码和迷你工程。貌似离取代 ide 去工作还很远。只是我今天写累了,还是且听下回分解吧。今天这一篇,让一些完全没接触过命令行编译程序的同学们加深一下,一组源代码是如何生成最终的执行文件,这个过程的理解。达到这个目的就足够了。本质上,IDE 也在做这些事情,作为一个 C/C++ 程序员,怎能对一个天天在用的系统怎样在工作一点都不了解呢?

ps. 希望看到这里的同学不要为在命令行下输入了太多的指令而心烦意乱。或者担心那些命令行参数用完就忘。你知道 windows 下有个叫做批处理的好用的工具么?就是那些后缀为 .bat 的文件。可以把经常输入的指令放在里面,简化日常的命令行操作。如果不知道,那么还请 google 之。

虽然 windows 下的批处理比 *nix 的 shell 脚本弱了上万倍,但毕竟还是可以提高我们的生产力的。在没有介绍 make 工具前,同学们可以先用 bat 文件顶一下。比如将你的工程的编译指令一次性写到一个 .bat 文件里,就不需要每次重新编译都敲上长长的一串编译指令了。

事实上,云风最早从 ide 里出来,就是用批处理来管理自己的工程的。在下一回,本系列将隆重推出更加好用的工具来取代它。

September 20, 2008

重构

随着 engine 开发进入尾声,最近几个月已经在修一些边角的东西,顺便给其他组的同事做介绍和教学。由于不在一起办公,折腾 VPN 和防火墙也折磨了好几天。

中秋假期前,我重新思考了去年设计好,但一直没精力去实现的东西:资源的多线程预读

由于资源管理模块当初设计的还是比较仓促,有些需求到了后期才逐步出现。比如,我们需要同时使用不同的资源加载模块,分别从本地文件系统于打包文件中读取。一开始,我认为,开发期不将资源文件打包是可以的。随着开发的进展,数据文件在数量级上增加,感觉将一些不太变化的文件打包还是有必要的。这就必须实现混合加载。

前期考虑到要做数据预读,我们将文件之间的依赖关系放到了文件系统内。而非自己定制的数据包文件系统和本地文件系统间有很大的不同。这使得混合加载实现比较困难。(具体原因是,我们的文件系统内采用的类似 linux ext2 的思路,每个文件有一个 inode 标识,并描述相互关系。但文件却不一定有文件名。这和本地文件系统相异,导致相互依赖关系难以描述。)

第二,虽然前期设计为多线程加载考虑。但由于中间变化很多,例如需要在加载过程中把部分数据上载到显存。最终,实现多线程安全变的很困难。(具体原因是,每种文件类似都有自解释的代码,我们的客户端主体 engine 代码是按单线程设计,实现资源加载的多线程安全必然对以后扩展资源文件类型的程序员做过多限制)

基于以上两点,我觉得花一番气力对整个资源管理模块做一次大的重构。

为了不影响项目组其他人员的工作,估算了工作量后,我决定自己在中秋节做这次重构工作。并在假期结束可以顺利归并到代码基的主干。

事情没有预期的顺利。

中秋假期三天,按我预估的 3000 行代码的工作量,本应该是绰绰有余的。临时有其他活动安排,结果我到第三天下午才开始做这件事。

已经在稳定运行的版本有些过度设计了。为还没有实现的多线程方案预留了许多灵活度。却没有全盘考虑清楚。

为了让文件类型的的自解析过程在单独线程里可以安全运行,不和主逻辑线程冲突。我为这个模块设计了独立的 CRT 子模块,和并没有考虑线程安全的主 CRT 隔离。慢慢的才发现,严格的做到线程安全,需要对程序员做更多限制。如果全部资源文件解析过程都由我或另外一两个程序员进行,这个要求勉强可以达到。但是一旦隐藏问题依然难以发现。要么采取保守方案用加锁保证,要么牺牲一些多线程好处,把一些不可靠的部分保持在主线程内实现。总之,都违背了当初的设计初衷。

另外,最初没有考虑混合加载模块共存。文件内部的 inode 在不同的加载模块间可能引起冲突。(自定义的包格式中,对每个文件都定义了 id ,而本地文件系统则采用了一种自动生成唯一 id 的方式工作)这点必须修改。

我觉得重构分两步走。第一步先解决较容易的混合模块加载的需求。测试通过后。再把新写的代码连同老的所有资源管理模块抛弃,全部重写。

第一步工作很顺利,大约改写了几百行代码就几乎无错的一次通过了。

第二步工作打算放弃所有的相关代码,重新实现。为了这个,我在节前就在纸上准备了几天。勾勒了大致的子模块关系图和接口设计。

很久没有动笔打草稿了。这真是个很难做简单的设计。虽然我还是力图简化思路。或许放弃 ”一个文件必然拥有一个字符串标识的文件名“ 是个错误的决定。不过我还是肯定,这样带来的性能提高能弥补设计复杂度提升的负面影响。

这次把资源管理模块划分成了三个层次,比以前的简单一些。

第一个层次提供用户界面。并管理多个不同加载模块。第二个层次提供资源的 cache ,可以将已经打开过并存在于内存的资源索引不通过加载模块直接返回到上一层。其中,提供两种 cache 方式,以文件树结构 cache 和以 inode 映射的方式 cache 。第三个层次是真正的加载模块,分别做文件名到 id 的映射,获取文件间的依赖关系,读取文件头,分析数据。另外有一个文件类型的解析器管理模块,供具体文件解析过程,把特定文件解析方法注册进去加以管理。

至于多线程预读的问题,资源模块已经可以了解资源数据文件间的关联信息。简单的开一条线程 touch 那些未来可能读到的文件即可。把预读和缓冲的工作交给 OS ,这应该是一个简洁有效的设计。并不需要太多顾虑线程安全的问题。

想归想,实际操作起来颇费了一番功夫。


第一天,手指没停的新写了 1000 来行代码的实现,替换掉旧的近两千行代码。并且修改了十多个接口相关的源文件。大体完成,眼瞅着假期内完成不了了,只好回家睡觉,留下了些小模块。

然后,陆续跟两个主要负责同事干了几天。把各自负责的资源解析模块按新标准改写。大多数地方重新写可以简化代码,干的还算没那么郁闷。只是做的很小心,时断时续。好在我们的模块划分比较清晰,可以独立编译审核。大家都把代码在我个人机器上做合并,由我逐步做渐进式的测试。

到了周四下午晚饭时,大致重构完成。在跑完整的测试 demo 前,我去开了瓶汽水庆祝。lulu 笑谈,如此大规模的重构,通常应该有 5 个 bug 吧。

果然,第一次运行毫无悬念的 core dump 了。

接着又来了几次。在修改掉第 3 个 bug 后,demo 顺利的跑了起来。真是值得庆贺。

不过晚上继续测试,又发现了两个 bug ,正好不多不少是 5 个 。:D


周五,按计划,我们测试资源在运行中全部 unload 并重新加载。这个功能打算用于编辑器中。或者用在游戏更改设置的位置。同时,也可以测试,所有类型的资源是否可以正确的卸载。

demo 又一次毫无悬念的挂了。原本以为是个小问题,几分钟可以搞定。结果却折磨了我 4 个多小时 :( 。最终发现还是一个弱智的错误。

这个调试过程很有代表性,记录如下:

我们发现程序崩溃时,gdb 停在一个莫名其妙的地方,前后毫无问题。倒溯回去,发现有一处结构里的数据内容无法被代码改写。

ps. 关于程序倒溯,并不指利用 gdb 回退堆栈。这时堆栈已经被破坏掉了。因为我们的程序有录象功能(录制所有时间点的任何外部输入),可以保证在录象文件的输入回放下,程序每次运行都有唯一的输入,保证每次精确的以同一执行流程运行。当然,如果发生 core dump ,也是精确的在同一个地方。所以可以做到再次运行,提前设置断点停下来查看,仿佛时间倒流,代码向前回退。

凭经验,我猜测是某处内存访问越界,破坏了正确的结构指针,让结构指针指向了非法地址。

事实并不完全是这样。几经最终,查到了是我们自己实现的内存分配器分配出了一个并不该它管理的地址空间。而这个内存分配器早在 04 年就经过了严格的测试,应该没有问题。这让我相当迷惑。

由于内存分配器在分配内存块时,会给内存头部添加一个小的 cookie 方便管理。一开始怀疑,这个 cookie 被越界修改,导致内存分配器工作不正常。

但是不段的运行回溯(虽然做起来容易,但是非常费时),观察到,并不是某个内存块的头部 cookie 被篡改。而是内存分配器自己的私有数据不太正常。但我还是相信内存分配器的实现无问题,只是这个结果很让人费解。因为我们的内存分配器的私有数据并没有从运行期的堆内分配出来,而是开在数据段里。而我们的代码模块物理上做了隔离。所以除非分配器本身的代码,很难有地方可以通过非法指针方法篡改模块内部的私有数据。

经过了很长时间的运行时数据分析,最终找到了罪魁祸首。居然有一个内存块的 cookie 中,表识长度的字段为 0 。但这在正常的数据中是不可能存在的。(通过接口分配内存,0 字节的请求最终会被扩展成 8 字节以上)而这个 0 ,在内部工作时被换算成一个负数,作为下标改写分配器内部的一个数组,导致了一个不相关数据的篡改。

了解了这一点,反过来审核代码,很容易的找到了元凶。一处不正确的内存指针调用了 free 接口。


接下来的几个小时都是和内存泄露在做斗争。

传统的内存泄露检查我最早是从 MFC 的源码里学来的。就是记录每处内存分配的源代码行位置,以及分配的内存块大小。并在程序结束的时候统计没有释放的内存块。

再这几年的编码实践中,我逐渐淘汰了这种效率不高(指快速发现问题的效率)的方案。

由于我们完全实现了自己的内存分配器。所以可以更为精确的知道每个内存指针的合法性。并更容易监控每个内存块指针。(我们的内存管理器采用一次性向 OS 申请大块内存,并采用三种不同的分配方式向应用层提供服务的方式工作。)所以完全可以在调试期,申请等大的内存地址空间做监控。

鉴于游戏 client 的特殊性,内存申请需求呈周期性的变化(按渲染帧为周期)。便可以周期性的捕获内存申请的异常变化。最终得以快速的定位内存泄露。

一开始我们捕获了 74 处可疑的内存泄露,分三次定位到了两个漏写 free 的代码。最终排除掉了 4 处合理的地方。用了不太长的时间修正了内存泄露问题。

一个稳定的底层架构,还是对定位并排除 bug 有很大好处的。对比看来,当年大话西游 server 端的一个内存泄露问题,前前后后查了大半年。早期 server 不敢开放太多人同时上线的限制,就和那处内存泄露有关。(人数太多,泄露的更快,服务器撑不到一周就会因为虚拟内存耗近而崩溃)


虽然多花了一周时间,比我预期的时间长了许多。但总算了了件心事。engine 基本上不需要再多做改动了。这个规模的代码重构,上次要前推到 06 年初了。算是一重大事件了,写此流水帐纪念之。

btw, 最近有个小朋友写 email 问我关于 makefile 的问题。和广州一些同事讨论问题时也涉及这个方面。我已经三年没有用 IDE 开发,越来越对 make 有爱。最近计划花掉时间写点普及教程,教那些过度依赖 IDE 开发的同学脱离苦海。不知道这么做有没有意义。貌似用 IDE 的同学们都不以为苦呢。

还有,有个朋友写 email 问我些问题,我好不容易回完,发现他的邮件居然没有发信地址。太寒了。最近老碰到这种郁闷的事,比如写这篇 blog ,浏览器中间崩溃了一次,损失了 1000 来字 :( 。

September 10, 2008

远程设置防火墙要小心

今天想在自己管理的一台机器(安装的 freebsd)上设置下防火墙,使用 ipfw 的时候,发现 ipfw 模块没有加载。

一时冲动就直接 kldload ipfw ,立刻就被防火墙踢了出来 :( 。无奈只好联系机房的同事帮忙按一下电源。

好吧,这次我知道 freebsd ipfw 默认的配置是有一条 65535 deny ip from any to any 的规则了。一加载 ipfw 模块根本不给我机会通过 ssh 远程添加新的规则。

以往都是在自己办公室的机器上折腾,没什么好担心的,第一次操作千里之外的机器的 firewall ,一不小心就傻眼了。

重起之后谨慎多了。还是改 /etc/rc.conf ,然后用 /etc/rc.d/ipfw start 的脚本启动好了。

保险起见,我添加了 firewall_enable="yes"firewall_type="open" ,并在机器的桌面机上测试了一下,感觉没有问题。就登陆上远程机器上操作。

可是当我输入 /etc/rc.d/ipfw start 后,又被踢了,真是欲哭无泪啊。终于有明白的同事告诉我,因为 ipfw 添加规则时会有标准输出。但是加载完 ipfw 模块后,新的规则没来的及加上前,我的连接就被断开了。后续的输出失败会导致系统发信号让程序退出,后续规则就没有加上了。

再次麻烦机房同事重启系统,一切正常。

这次算长了点经验。远程开启 ipfw ,一定要重定向标准输出,更安全点是把标准错误输出也一并重定向。

/etc/rc.d/ipfw start >/dev/null 2>&1

September 05, 2008

高度图压缩后的边界处理

几年前我曾经写过一篇 blog 介绍我发明的一种高度图压缩算法 。最近几天,我将这个算法用于目前正在开发的 engine ,如之前所料,效果不错。由于数据被压缩,更改善了资源加载的速度。并在同等内存条件下,让用户得到更高的体验。(这是因为,现代操作系统,都会把暂时不用的物理内存全部用于磁盘缓存,更小的文件体积同时意味着在同等内存条件下能缓存更多的数据)

因为我们需要做动态地形数据的加载,所以地形高度数据也被切割成了一小块一小块。采用这个算法后带来的一个问题是:由于压缩有数据损失,两个相临块之间不能严丝合缝的衔接在一起。

为了解决这个问题,这两天想了好几个方法。

相临的数据块的接缝上,每个顶点被拆分成了两个。数据未压缩前,拆分出来的顶点值应该完全相等。最容易想到的方案是,每次加载完一个地形块,就去查看周围的块是否有加载入内存,如果有,就把邻接边的数据 copy 一份过来。

但这个方案增加了数据间的耦合度,同时导致在数据读取层的代码需要从更高的层次上获取数据。这会破坏 engine 的设计,故而放弃这个方案。

另一个方案是将边界上的一圈高度信息,以无损方式额外保存。但是我们每个地形块的数据本身不多,这将会极大的损失好不容易获得的压缩比。

今天白天,我一直在想如何有效的保存轮廓信息。想到了一些方法会轮廓上的顶点增加额外的精度(大约每个顶点多使用一个字节)。代码写了一堆,实现出来却不太满意,因为直觉告诉自己,我把问题弄复杂了。

晚上去健身房活动了一下,回来的路上突然灵机一动,想到一个最为简洁的方案。

其实,不应该从提高边界上顶点的精度(甚至是无损)这方面努力。我们需要的仅仅是让邻接的地块可以无缝连接,也就是说,邻接线上的顶点高度值应该一一相等。

其实,让我们降低结果浮点数的精度就够了。关于实际操作的方法,很早以前我在留言本上发过帖子。Blog 上也有一个帖子讨论过 EPSILON 取值的问题

这次我这样实现(把要降低精度的浮点指针传入即可):


static __inline void
reduce_precision(float*fdata)
{
    uint32_t *d=(uint32_t *)fdata;
    *d=(*d + 0x8000) & 0xffff0000;
}


ps. Spore 是个好游戏。


9 月 6 日补充: 这个方法对于在 0 附近的数值为有问题,因为它不能处理正负号所以应该要注意。解决方法很简单:发现数值绝对值小于 1 即浮点指数是负数的时候,把值先加一再做上述处理,然后再减一。

另外对于跨越指数边界的时候可能会出现问题,比如一个值的底数是 1.11111111111 而另一个是 1.000000000 , 而指数差了一位。解决这个问题应该手工判断进位。

有人提到法线,其实法线是按常规方法从顶点高度中推算出来的。可以多保存一圈顶点。也可以在轮廓上用较少顶点来计算。法线的细微差别不会对视觉上有太大影响。(从我们的渲染结果上看是这样)

9 月 10 日再补充: 如果只是想去掉绝对数字上的浮点精度,比如去掉十进制小数点大约 4 位以后的偏差。可以如此:

a+=8192.0f; a-=8192.0f;

这样便可以将 a 降低精度。原理不详述。另外在写程序的时候应该想办法防止编译器优化掉上面的代码。

September 03, 2008

google chrome 的确很 cool

这两天活儿挺多,每天都弄到很晚。

今天搞定收工前发现 google chrome 已经发布了 :) 赶紧下载一个看看。很有 opera 的风范嘛,甚至做的更好。对我来说,如果能把鼠标手势加上就完美了。

之所以这个东西能讨我喜欢,简单说来就是:没必要存在的东西都没有,而不是跟着用户习惯走,去沿袭一些貌似理所当然的拙劣设计。培养更高效、正确、方便的使用习惯是优秀软件的职责。

当然,也可以从另一方面说:简单而不过于简单。直接正确的解决问题,而不是堆砌功能。

大部分用户其实并不知道自己要什么,但软件却不应该一味的向用户展示有什么。