第七章: 除错

MudOS v21c2

中阶 LPC

 Descartes of Borg
 November 1993

第七章: 除错

7.1 错误的种类

至今, 你大概已经到处碰过各式各样的错误. 一般上, 你可能看到的错误有叁种: 编译时段错误 (compile time error) 、执行时段错误 (run time error) 、故障的程式码 (malfunctioning code). 在大多数的 mud  中, 你会找到一个私人的档案, 里头记录着你的编译时段错误. 对大多数人来说, 你可以在你的家 (home)  目录找到名叫 "log"  或 ".log" 的档案, 或在 "/log" 目录找到以你的名字命名的档案. 另外, mud 执行时, 会维持一份执行时段错误的纪录. 而此档案也在 "/log" 目录中. 对 MudOS mud  来说, 它叫做 "debug.log". 其他的 mud 中, 称为不同的名字, 像是 "lpmud.log". 如果你还不知道编译时段和执行时段错误纪录在哪里, 请询问你的系统管理者.

编译时段错误是 driver 试着载入一个物件到记忆体的时候发生的错误. 如果此时它看不懂你写的东西, 它会无法把物件载入记忆体, 并在你私人的错误纪录档中记录为什麽它无法载入该物件. 最普遍的编译时段错误是打字错误、遗漏或多加 () , {}, [], ""、没有正确宣告物件所使用的函式和变数.

执行时段错误是一个在记忆体中的物件, 当它执行某段叙述时所发生的错误. 举例来说, driver  不可能知道任何情况下, "x/y" 是否有效. 实际上, 它是一个有效的 LPC  运算式. 但是, 如果 y  的值为 0, 则会发生执行时段错误, 因为你不能除以 0. 当 driver 执行一个函式时碰上错误, 它放弃执行函式并纪录在游戏执行时段错误纪录档中. 如果有定义、如果玩家是创作者, driver  也会对 this_player() 显示错误讯息, 不然就只对玩家显示 "什麽 ?".  大多数导致执行时段错误的原因, 是不正确的值和试着执行没有定义运算资料型态的运算式.

不过, 最狡猾的错误种类, 就是故障的程式码. 这些错误不会纪录下来, 因为 driver  永远不可能知道有地方出错. 简单地说, 这种错误就是你认为程式码做的是一件事, 但是实际上它做的是另一件事. 常遇到这种错误的人, 一定会认定是 mudlib 或 driver 的错误. 每个人都制造过各式各样的错误, 而更常见的不是程式码不按照它该运作的的方式工作, 而是你错读它.


7.2 修正编译时段错误

编译时段错误是最常见以及最容易修正的错误. 新手程式撰写人常常因为一些怪异的错误讯息, 而感到挫折. 虽然如此, 只要一个人变得习惯於他们 driver 产生的错误讯息, 修正编译时段错误就成了例行公事.

在你的错误纪录中, driver  会告诉你错误的种类, 还有它最後在第几行注意到该错误. 注意, 这不表示此行一定是错误实际发生的地方. 除了打字错误, 最常见的编译时段错误是遗漏或多加各式括号和引号: (), [], {}, "". 这种是最常困扰新手程式撰写人的错误, 因为 driver 不会注意到遗漏或多加的部分, 直到稍後出问题为止. 以下是范例:

1 int test(string str) { 2    int x;
3    for(x =0; x<10; x++)
4        write(x+"\n");
5    }
6    write("Done.\n");
7 }
看你想做的是什麽, 此处实际上的错误在第叁行 (表示你遗漏了一个 {) 或第五行 (表示你多加一个 }) . 但是, driver  会回报它在第六行找到一个错误. 实际的 driver 讯息每种 driver 可能都不一样, 但是不管是哪一种 driver,  你会看到第六行产生一个错误. 因为第五行的 }  会解释为 test() 函式结束. 在第六行, driver  看见你有一个 write()  出现在函式定义之外, 所以回报为错误. 一般来说, driver  也会继续回报它在第七行找到一个多加 }  的错误.

修正这种错误的秘诀在於程式撰写风格. 将结束的 }  与该子句开头的 {  垂直对齐, 在你除错时, 会让你看到你哪里遗漏它们. 同样, 当你使用多组括号时, 像这样用空白将各组分开:

    if( (x=sizeof(who=users()) > ( (y+z)/(a-b) + (-(random(7))) ) )
你可以看到, for() 叙述的括号与其馀的叙述以空白隔开. 另外, 个别的子群也用空白隔开, 让它们在产生错误时易於找出.

一旦你拥有帮助你找出错误的程式撰写风格, 你就会学到哪一种错误讯息倾向於指出哪一种错误. 修正此种错误时, 你会检查出问题的那一行之前与之後的程式码. 大多数的情况下, 你会直接找到错误.

另一种普遍的编译时段错误是 driver 回报一个不明的 identifier.  一般来说, 打字错误和错误宣告变数导致此种错误. 幸运的是, 错误纪录档中几乎都能告诉你错误所发生的实际位置. 所以修正此种错误时, 进入编辑程式并找到出问题的该行. 如果该问题出在变数上而不是打字错误, 请确定你正确地宣告该变数. 另一方面, 如果是打字错误, 就改正它 !

但是, 小心一件事, 这种错误有时候会与遗漏括号的错误结合在一起. 在这种情形下, 不明 identifier 的问题常常是误报. driver  误读 {} 或其他东西, 而导致变数宣告混淆. 因此在烦恼此种错误困扰之前, 请确定已修正所有其他的编译时段错误.

与前述的错误同一级的是普通的语法错误. 当 driver 无法了解你写的东西时, 它就产生此种错误. 这又常是打字错误引起的, 却也是因为不了解某些特徵正确的语法所致, 像是把 for()  叙述写成这样:

    for(x=0, x<10, x++)
如果你像这样的错误, 却不是语法错误, 试着重新检查错误发生的叙述中, 语法是否正确.


7.3 修正执行时段错误

执行时段错误比起编译时段错误要复杂得多. 幸运的是, 这些错误都有纪录, 但是许多创作人并不了解, 或是他们不知道纪录在哪里. 执行时段错误的纪录一般也纪录得比编译时段错误详细, 也就是你可以从它开始到它出错之处, 追踪执行程序的过程. 所以你可以利用纪录档, 使用前编译器叙述 (precompiler statement)  设置除错陷阱 (debugging trap).  但是, 执行时段错误常肇因於复杂的程式撰写技巧, 而初学者并不使用这些技巧. 这表示你一般会碰上比简单的编译时段错误还要复杂的错误.

执行时段错误几乎都是肇因於使用错误的 LPC  资料型态. 最常见的是, 试着用 NULL  值的物件变数做外界呼叫, 索引指向 NULL 值的映射、阵列、字串变数, 或函式传入错误的参数. 我们看一个 Nightmare  真实的执行时段错误:

Bad argument 1 to explode()
程式: bin/system/_grep.c, 物件: bin/system/_grep 第 32 行
'       cmd_hook' in '        std/living.c' ('       std/user#4002') 第 83 行
'       cmd_grep' in '  bin/system/_grep.c' ('    bin/system/_grep') 第 32 行

Bad argument 2 to message()
程式: adm/obj/simul_efun.c, 物件: adm/obj/simul_efun 第 34 行
'       cmd_hook' in '        std/living.c' ('      std/user#4957') 第 83 行
'       cmd_look' in '  bin/mortal/_look.c' ('   bin/mortal/_look') 第 23 行
' examine_object' in '  bin/mortal/_look.c' ('   bin/mortal/_look') 第 78 行
'          write' in 'adm/obj/simul_efun.c' (' adm/obj/simul_efun') 第 34 行

Bad argument 1 to call_other()
程式: bin/system/_clone.c, 物件: bin/system/_clone 第 25 行
'       cmd_hook' in '        std/living.c' ('      std/user#3734') 第 83 行
'      cmd_clone' in ' bin/system/_clone.c' ('  bin/system/_clone') 第 25 行

Illegal index
程式: std/monster.c, 物件: wizards/zaknaifen/spy#7205 第 76 行
'     heart_beat' in '       std/monster.c' ('wizards/zaknaifen/spy#7205') 第 76 行

除了最後一个以外, 所有的错误, 都对一个函式传入一个错误的参数. 第一个错误, 是对 explode()  传入错误的第一个参数. explode() 外部函式要一个字串当作第一个参数. 修正这类型的错误时, 我们会到 /bin/system/_grep.c  的第 32 行检查第一个传入参数到底其资料型态为何. 在此情况下, 传入的值应是字串.

如果因为某些原因, 我实际上传入其他的东西, 我在此只要确定传入字串就能修正错误. 但是在此情况要复杂得多. 我需要追踪传入 explode()  的变数值为何, 我才能知道传入 explode()  外部函式的值到底是什麽.

出问题的那行是:

    borg[files[i]] = regexp(explode(read_file(files[i]), "\n"), exp);
files 是一个字串阵列, i 是整数, borg  是映射. 所以很明显, 我们需要找出 read_file(file[i])  的值到底是什麽. 好, read_file() 这个外部函式传回一个字串, 除非该档案根本不存在, 或是该物件没有权限读取该档案, 或是该档案是个空的档案, 这些情形都会导致此函式传回 NULL.  很明显, 我们的问题是这些情形的其中一种. 要找出是哪一种, 我们要看 file[i].

检查程式码, 这个档案阵列透过 get_dir()  外部函式取得它的值. 如果该物件有权限读取此目录, get_dir() 就传回目录中所有的档案. 所以问题不在於权限不足或档案不存在. 导致这个错误的档案一定是空的. 而且事实上, 这就是导致错误的原因. 要修正此错误, 我们要透过 filter_array() 外部函式传入档案, 确定只有档案大小大於 0  的档案可以读入阵列.

修正执行时段错误的关键在於, 了解有问题的所有变数值在产生错误之时, 它们确实的值为何. 你阅读你的执行时段错误纪录时, 小心地经由错误发生的档案分辨物件. 举个例子, 上面的索引错误是物件 /wizard/zaknaifen/spy  产生, 但是错误发生在执行它所继承的 /std/monster.c 函式.


7.4 故障的程式码

你所遇到最阴险的问题, 就是你程式码的行为不是预期中的行为. 物件顺利载入, 没有产生任何执行时段错误, 但是事情就是不对劲. 既然 driver 不可能认出这种错误, 就没有任何纪录. 所以你需要一行接一行浏览整个程式码, 并搞清楚到底发生了什麽事.
第一步: 找出你已知能顺利执行的最後一行程式码.
第二步: 找出你已知开始出错的第一行程式码.
第叁步: 从已知顺利执行的地方到第一个出错的地方, 检查程式码的流程.
常常, 这些问题出现於你使用 if() 叙述没有料到所有的可能情形. 举个例:
int cmd(string tmp) {
    if(stringp(tmp)) return do_a()
    else if(intp(tmp)) return do_b()
    return 1;
}
在此段程式码中, 我们发现它编译和执行起来没有问题. 问题是它执行起来完全没作用. 我们确定 cmd()  函式已经执行, 所以我们可以从此着手. 我们也知道实际上 cmd()  传回 1, 因为我们输入此命令时, 没看到 "什麽 ?" . 马上, 我们可以看到因为某些原因, tmp 变数有字串或整数以外的值. 就此得到的答案是, 我们输入的命令没有参数, 所以 tmp  是 NULL , 并让所有的测试条件失败.

上面的例子相当简单, 几近於愚蠢. 但是, 它让你知道在修正故障的程式码时, 如何检查程式码的流程. 其他的工具能协助你除错. 最重要的工具就是使用前编译器来除错. 以前面的例子来说, 我们有一个子句检查传入 cmd()  的整数. 我们输入 "cmd 10" 时, 我们希望执行 do_b() . 进入回圈之前, 我们需要看 tmp 的值为何:

#define DEBUG

int cmd(string tmp) {
#ifdef DEBUG
    write(tmp);
#endif
    if(stringp(tmp)) return do_a();
    else if(intp(tmp)) return do_b();
    else return 1;
}

在我们输入命令之後, 立刻就知道 tmp  的值是 "10" . 回头看程式码, 我们会怪自己愚蠢, 忘了我们把 tmp  当整数使用之前, 必须要用 sscanf() 把命令参数转换成整数.


7.5 总结

修正任何 LPC  问题的关键是, 永远要知道你程式码中任何一步的变数值为何. LPC 的执行降到变数值改变这种最简单的层级上, 所以程式码载入记忆体时, 不正确的值导致错误发生. 如果你遇到函式有不正确的参数时, 常常是你对一个函式传入 NULL 值的参数. 这情形常发生於物件, 因为大家常常会做以下的事:
    1)  使用设定在一个物件中的值, 而该物件已经被摧毁.
    2)  使用 this_player()  的传回值, 而根本没有 this_player().
    3)  使用 this_object()  的传回值, 而 this_object() 刚好已被摧毁.
另外, 大家会常常遇上不合法的索引 (illegal indexing) 或索引指向不合法的型态 (indexing on illegal types). 最常见的是因为有问题的映射或阵列没有初始化, 所以无法索引之. 关键在於了解出问题的地方, 其阵列和映射的完整值. 另外, 注意索引编号是否比阵列的长度还大.

最後, 使用前编译器暂时扔出或扔进显示变数值的程式码. 前编译器让你很容易地删掉除错程式码. 你只需要在除错完毕之後, 删除 DEBUG  定义.

Copyright (c) George Reese 1993


译者: Spock of the Final Frontier 98.Jul.29.

回到上一页