物联网工程师之程序开发流程简介
本章重点
• 分析开发需求
• 设计程序框架
• 测试与调试基础
经过了前面几章的学习,想必大家对于C语言的基础知识已经掌握得差不多了。应当指出的是,学习编程的最终目标不只是为了掌握这门语言,而是为了能够与计算机沟通,开发出各种各样的程序,从而完成梦想、实现个人价值。对于开发而言,编程语言只是“技”,而从掌握编程语言到独立完成程序开发,还有很长的一段路要走。
本章会手把手指导大家体验一个小型应用程序开发的具体步骤,包括需求分析、框架设计、使用刚刚掌握的C语言编写代码,最后还要对程序的半成品进行手工测试。由于篇幅所限,第一个应用不可能尽善尽美,因此还需要大家在本章学习完成后自行完善程序,完成那些未实现的功能。相信通过本章的学习,大家会对如何创建应用程序有基本的了解,为后面的学习做好充分准备。
13.1 创建C语言程序的步骤
C语言程序的创建过程通常由以下四步组成:编辑程序、编译程序、链接目标文件与最终执行。下面将简单介绍这四个步骤。
13.1.1 编写程序
编写程序是指将开发者的想法用C语言写出来,并存成文件的过程。开发人员写出来的代码称为源代码,保存的文件被称为源文件。通常情况下,C语言源文件的扩展名为c,例如main.c就是一个源文件的名称。
创建源文件并不需要什么特殊的工具,开发者完全可以使用Windows上的记事本或Linux上的gedit输入代码,并保存为源文件。不过记事本可不是为了程序开发而准备的。一些代码编辑器提供了比记事本更为强大的功能,比如自动缩进(可以通过自动插入制表符将代码排成比较漂亮的样式)、代码折叠(支持将一部分代码折叠,暂时不显示出来)、代码高亮(用不同的颜色区别显示代码中不同的部分,使代码看上去一目了然、十分清晰)、错误提示等。查看和修改小段代码时,使用代码编辑器进行编辑是很好的选择。常用的代码编辑器有很多,可以参考下表选择一款适合自己的编辑器。
表 131 可供选择的代码编辑器
名称 |
操作系统 |
是否免费 |
特点 |
gedit |
Linux |
是 |
小巧易用,支持代码高亮、自动缩进等 |
vim |
Linux |
是 |
功能强大,插件丰富 |
Emacs |
Linux |
是 |
功能强大,插件丰富 |
UltraEdit |
Windows |
否 |
功能强大,支持16进制编辑 |
Notepad++ |
Windows |
否 |
体积小,功能强 |
Sublime Text |
Windows/Linux |
否 |
功能强大,代码着色非常漂亮,支持16进制编辑 |
TextPad |
Windows |
否 |
|
gVim |
Windows |
是 |
拥有类似Vim的操作环境 |
TextMate |
MacOS |
否 |
|
在正式项目中,开发者往往会使用集成开发环境(Integrated Development Environment,简称IDE)进行开发。如第一章开篇所述,IDE整合了代码编辑器、自动编译和调试的功能,并且在其基础之上提供了很多额外的功能,比如源代码控制与管理、可视化编程、项目管理等等。本书中大家一直使用的Visual Studio就是一款典型的支持多种编程语言的IDE。
脚下留心:
最好不要用常用的文字处理软件(例如Windows上的Word)来编辑源代码。以Word为例,在默认情况下Word会把代码保存为doc或docx格式的文件,而这两种格式为了保存字体、字号、段落等文本信息,定义了特殊的保存格式,不是纯文本文件。而源代码文件应当为纯文本文件。要查看一个文件是否为纯文本文件,可以使用Windows上的记事本将文件打开,如果能看到有意义的内容,那么它是纯文本文件;如果看到的是不知所云的乱码,那么它就不是纯文本文件。
13.1.2 编译程序
先来看下面一段程序:
例程 131 一段伪代码
student_count = person_count – teacher_count
学生数量 = 总人数 – 教师数量
看到上面的代码,大家当然清楚它完成的功能:计算学生的数量。但遗憾的是,CPU是无法理解“学生”、“总人数”和“教师”的概念的,它也无法理解上面伪代码中的英文单词和中文词语。因此要给计算机写程序,就必须用CPU能理解的语言,也就是机器代码。鉴于此,开发人员需要一种把人类能理解的程序语言(比如上面的伪代码,或者C语言程序)转换成机器代码的方法,这种方法被称作编译。
编译器就是负责把人类能理解的程序语言翻译成CPU使用的语言的工具。每一门程序语言都有自己的编译器,C语言同样也有自己的编译器,而且不止两三种。在翻译的同时,编译器还能对程序进行各种必要的优化,从而在不改变原程序语义的前提下加快程序的运行速度。和十年前相比,一个现代的C语言编译器可以给程序带来数倍的性能提升,有时甚至更高。
CPU有很多不同的架构,不同架构的CPU使用的语言也是不同的。因此编译器做的另外一件事情是把程序语言翻译成某种特定的汇编指令和机器码。这样才可能做到“一次开发、到处编译”。
多学一招:计算机的工作原理
C语言是一门与底层连接十分紧密的语言。因此充分了解计算机的工作原理有利于写出更漂亮、性能更好的程序,“知己知彼、百战不殆”嘛。但是考虑到现代计算机架构的复杂性,我这里只能用形象的语言尽可能简洁地介绍计算机的工作原理,以期大家对其能有基本的认识。
现代计算机的主要部件为中央处理器(也就是常说的CPU)、内存、外存(例如硬盘和光盘)和输入/输出设备。这里主要关注的部件是CPU,因为它和程序的性能是最为相关的。
CPU完成的是最重要的程序处理工作。它的工作说起来很简单:从内存中读取一条指令,执行它,然后读取下一条指令,继续执行。那么什么是指令呢?
例程 132 CPU执行的汇编指令与其对应的十六进制机器码
1 50 push eax
1 B801000000 mov eax, 1
2 31C0 xor eax, eax
3 90 retn
在内存中,这些汇编指令当然不是以英文字符的方式存储的,而是以机器码形式存储的。机器码就是汇编指令对应的数,通常使用十六进制数来记录。下图描绘了上述汇编指令在内存中的样子,同时也是CPU看到的样子。
|
内存 |
||||||||
机器码 |
50 |
B8 |
01 |
00 |
00 |
00 |
31 |
C0 |
90 |
对应指令 |
push eax |
mov eax, 1 |
xor eax, eax |
nop |
图 131 指令在内存中存储的方式
以上图中的指令为例,CPU首先读取到十六进制数0x50,将其解释为指令push eax,然后执行该指令;接下来CPU读取到十六进制数0xB8,将其解释为指令的一部分mov eax,接下来继续读取后面四个字节(分别为0x01、0x00、0x00、0x00),这样CPU才知道整条指令是mov eax, 1,最后执行该指令。
目前PC的CPU可以在一秒内执行大约一亿条指令,惊人的效率保证了计算机的运行速度。因此计算机适合做那些简单重复的任务,比如计算一个家庭全年的水电费花销,或是逐个访问磁盘上的文件夹并删除所有文件名长度大于4的文件。从根本上来说,这些任务都被写成了程序,然后通过编译转换成了汇编指令和机器码,最后被扔给CPU去逐条执行。这个过程也从根本上决定了目前的计算机不可能像人类一样进行创新性思考——因为就目前而言,人类的创新思维在本质上是无法被转换成某几项任务的简单重复的。
如果对计算机的组成原理有兴趣,推荐阅读《深入理解计算机系统》(原名Computer Systems: A Programmer’s Perspective,作者为Randal Bryant和David O’Hallaron,中文版由中国电力出版社出版)一书作为参考。
13.1.3 链接目标文件
如果把编译器比作一头奶牛,那么它吃进肚子里的一个个源代码文件(青草),挤出来的是一个个模块(牛奶)。一般而言,一个源文件对应着一个模块,生成的模块称为目标文件(Object File),扩展名一般为o(GCC编译器生成的目标文件的默认扩展名)或obj(MSVC编译器生成的目标文件的默认扩展名)。从目标文件到最终可以运行的可执行程序之间还有一个关键步骤是目标文件的链接。这项由链接器完成的任务说起来很简单,就是将相关的目标文件放到一个文件中,按照可执行文件的格式重新整理一下,并保存成一个新的可执行文件,这样就完成了。
大家肯定会问:“为什么需要链接器呢?直接编译器将程序代码直接转换为可执行程序难道不够么?”对于只有一个模块的小程序来说,确实只需要一次编译、一次链接就可以得到最终的可执行程序了。但是那些规模庞大的工程(比如Linux操作系统)往往包含成千上万个模块,每次开发可能只修改其中的几个模块而已。引入链接器之后,只要重新编译少数被修改过的模块,然后将它们与大部分未修改过的模块重新链接成最终发布的程序就可以了,这样可以节省大量的时间。
在今后的开发过程中,开发者可能会用到外来的程序库。有时候这些库是附有源码的,而在更多情况下,出于保护程序库原始代码的考虑,开发者拿到的仅仅是一个编译好的模块。这时开发者也只能通过链接器将拿到的模块与自己开发的应用程序链接到一起。
如果链接时出现了错误,则必须回到“编辑程序”的步骤,重新修改程序源码。
13.1.4 最终执行
成功完成前面三个步骤之后,就可以兴高采烈地去运行最终的程序了。当然对于初学者而言,程序能够执行并不代表任务成功完成——成功运行的程序往往会出现无输出、输出错误,甚至程序崩溃等非预期结果。这时就需要面对具体问题进行具体分析了。
在Windows下,可执行文件一般都以exe作为扩展名;而在Linux下,程序是否可执行是由文件属性决定的,一般情况下没有特殊的扩展名。
13.2 目标程序简介
动手体验:C语言基础知识回顾
首先请用五分钟的时间回想一下已学过的C语言知识点,并写在下面的表格中。
编号 |
知识点 |
掌握程度(熟练/一般/不熟悉) |
1 |
|
|
2 |
|
|
3 |
|
|
4 |
|
|
5 |
|
|
6 |
|
|
7 |
|
|
8 |
|
|
9 |
|
|
10 |
|
|
上表中应当至少包括如下知识点:
• 变量的使用(定义、初始化、赋值);
• 顺序结构;
• 判断结构;
• 循环结构;
• 格式化输入输出;
• 数组的使用(定义、初始化、赋值)。
只有打牢地基才能“万丈高楼平地起”。在继续学习之前,如果感觉自己对以上某一项掌握程度不够好,请回到相应的章节加以温习。
在本章中,大家将在指导下完成井字游戏的开发。井字游戏,又称“井字棋”,一般由两名玩家共同参与,一人画圈(O),另一人画叉(X)。使用的棋盘是3 X 3的井字格,两名玩家轮流在棋盘上画下符号,最先用三个相同符号连成一条直线的玩家获胜(包括横向、竖向和斜向三种可能)。下图是井字游戏的示意图,使用圈(O)的玩家获胜,因为他在对角线上连成了一条直线。
图 132 井字游戏示意
程序的最终效果如下图所示:
图 133 “井字游戏”程序运行结果
13.3 需求分析
在着手编写代码之前,需要仔细思考该怎样把纸面上的井字游戏变成一个能正常工作的小程序,重点在于思考这个程序需要完成什么功能、必须实现哪些元素。该过程被称为需求分析,即分析最终用户对程序的需求。
程序的需求包括功能性需求和非功能性需求两部分。功能性需求定义了程序需要实现的功能,可以是技术细节、数据处理或其它希望程序最终实现的功能。非功能性需求是与程序特定行为和目标无关的需求,其主要包括程序的质量期望、成本限定等等需求。常见的非功能性需求有“提供完整的灾难恢复支持”、“需要兼容操作系统A、B、C”、“开发时间不得超过XX天”、“明显程序问题不应多于XX个”等等。初学者往往只注重功能性需求,而不在意非功能性需求。但实际中功能性需求决定了程序是否为合格产品,非功能性需求则决定了用户对程序的感受。试想有哪位用户会喜欢一款动不动就报错退出的程序呢?因此在考虑功能性需求的同时,也要重视非功能性需求。
13.3.1 功能性需求
“井字游戏”小程序应包含两种游戏模式,分别是人机模式和双人对战模式。无论哪一种模式,只要有一名玩家获胜时游戏结束,并打印出获胜玩家的信息;当棋盘下满时游戏也将结束,如果没有玩家获胜,则认为游戏以平局结束,并输出平局提示。
“井字游戏”通过字符界面模拟显示棋盘,棋盘上的每个格子分别对应编号1至9,玩家通过输入格子对应的编号实现下子的功能。如果需要的话,在游戏开始前可以输出欢迎信息、游戏规则等提示。
13.3.2 非功能性需求
该游戏的的非功能性需求包括以下几点:
• 程序应当使用C语言完成开发;
• 程序由单人开发完成,总开发时间不应长于一星期;
• 程序应经过完整测试,交付使用时不应存在明显的缺陷和问题。
13.4 搭建程序框架
完成需求分析之后,就要严格按照功能性需求和非功能性需求来设计整个程序了。俗话说“预则立,不预则废”,就是在强调设计对程序开发的重要性。对于初学者而言,最忌讳的是拿到需求就开始写代码,边写代码边设计。诚然,在修改设计时,写过的代码可以复制粘贴到其它地方,但频繁修改设计可能带来预料之外的程序问题和缺陷(bug),且往往会拉长开发时间(在设计上节省下来的时间都花在事后修改设计上了),得不偿失。
一个好的、合理的设计方案可以降低代码量,减少开发过程中开发者思想“卡壳”的次数,并且对程序的质量做出一定保证。不合理的设计方案往往决定了一个程序的上限。因此在程序设计上多花点儿时间是非常值得的。
根据程序需求,可以用画流程图的方式画出整个程序的运行流程。流程图可以帮助开发者检验设计的合理性,明确如何将程序分成不同的模块,甚至可以直接转换成框架代码。下面是程序流程图中常用的框图表示:
图 134 程序流程图的基本元素
不难发现,“流程”对应于前面讲过的顺序结构,而“判定”对应于前面讲到的判断结构。用带方向的箭头连接每一个框,就形成了整个程序的流程图。循环结构是由流程框、判定框配合箭头实现的。下图是玩家在选择游戏模式时的程序流程图示例。
图 135 程序流程图:选择游戏模式
动手体验:画出完整的流程图
请读参考上面的部分流程图,画出整个程序的完整流程图。画图时要注意以下几点:
• 程序由圆角框开始,最后也要由圆角框结束。流程图中不应存在中断的情况,也不应存在从“开始”处不可达的方框(请想想这是为什么);
• 每个判定都应至少包含两条出路,每条出路都要有对应的条件,两个条件不能完全相同;
• 不存在死循环(即无法通过判定跳出的循环);
• 流程图应当与功能性需求相切合。
对于相同的程序需求,程序流程图往往不是唯一的。下面的流程图仅供参考,如果你的答案和此图不同也是可能的。如果对自己的流程图有疑问,请咨询辅导教师。
图 136 程序流程图:井字游戏
结合程序需求并参考上面的流程图,可以确定程序由如下部分组成:
• 主函数;
• 双人对战过程;
• 人机对战过程;
• 检查是否有玩家获胜或出现平局的过程;
• 初始化棋盘;
• 打印棋盘。
流程图明确地说明了各部分之间的关系。编写程序时只要将这些部分分别定义为函数,并按照流程图的指示将相应函数串在一起即可。各部分对应的函数名称如下表所示。
表 132 “井字游戏”函数表
编号 |
作用 |
函数名称 |
返回信息 |
1 |
主函数 |
main |
无 |
2 |
双人对战过程 |
playWithHuman |
无 |
3 |
人机对战过程 |
playWithPC |
无 |
4 |
检查是否出现胜者或平局 |
checkBoard |
获胜方或平局 |
5 |
初始化棋盘 |
initializeBoard |
无 |
6 |
打印棋盘 |
printBoard |
无 |
脚下留心:函数与参数列表
请注意,上表中没有列出每个函数都包括哪些参数,也不包含返回值的类型。这是因为在进行上层设计时往往无法准确确定需要传入的数据类型,可以等到编写代码时再确定每一个函数的参数列表和返回值。
程序中还需要存储棋盘信息,即每个位置上有什么棋子(未布子、“O”或“X”)。由于棋盘是一个三行三列的表格,可以使用一个3 X 3的数组存储棋盘。为了简便起见,可以对数组中元素的值作如下规定:
• 如果元素为’X’,则认为是玩家0下了子;
• 如果元素为’O’,则认为是玩家1下了子;
• 如果为其它值,则认为该格内没有棋子。
在开始游戏前,必须使用初始化棋盘过程对棋盘进行初始化,将每个元素都初始化为’X’和’O’以外的值——否则可能出现游戏尚未开始,一方已经布子的情形,这显然是不公平的。
13.5 编写代码
13.5.1 基本数据结构
根据程序设计,定义一个名为board的int型数组,大小为3 X 3。
例程 133 定义棋盘数组 board
1 int board[3][3];
可以将board声明为全局变量,但是一个好的设计中应尽可能减少全局变量的使用,而是将其作为某个函数(比如主函数)的局部变量,其它函数要使用时就将这个变量作为函数参数传递过去。这种实现方式有助于程序的模块化。
13.5.2 主函数
下面是主函数的参考实现。
例程 134 主函数的实现
1 int main()
4 {
5 int mode;
6 int board[3][3];
7
8 printf("请输入游戏模式(0 - 人机对战;1 - 双人对战):");
9 scanf("%d", &mode);
10 if(mode == 0)
11 {
12 playWithPC(board);
13 }
14 else if(mode == 1)
15 {
16 playWithHuman(board);
17 }
18 else
19 {
20 printf("输入无效。\n");
21 return -1;
22 }
23 }
请将主函数的参考实现与主函数的参考流程图(见下)进行比较,能得到什么结论呢?
图 137 程序流程图:主函数
容易发现,主函数的代码基本上是相应程序流程图的“翻译”。开发人员要做的只是根据流程图选择相应的程序结构,并根据使用的数据结构填好相应的代码即可。与没有流程图的情况相比,现在程序的流程十分清晰,且编写代码井井有条。这就是预先进行框架设计的威力!
13.5.3 初始化棋盘
下面是initializeBoard函数的参考实现。为了访问棋盘,需要将棋盘数组作为函数参数传递到initializeBoard函数中。
例程 135 初始化棋盘函数的实现
1 void initializeBoard(int board[][3])
24 {
25 int i, j;
26 for(i = 0; i < 3; ++i)
27 {
28 for(j = 0; j < 3; ++j)
29 {
30 board[i][j] = '0' + i * 3 + (j + 1);
31 }
32 }
33 }
为了打印棋盘时的方便,程序中直接将每个格子初始化为相应的数字字符1 ~ 9了。初始化时需要注意,字符’1’对应于ASCII码49,而不是ASCII码1,因此不能直接写
board[i][j] = I * 3 + (j + 1);
而必须写成
board[i][j] = '0' + i * 3 + (j + 1);
这样保证了每个格子中都是一个数字字符。
13.5.4 打印棋盘
下面的代码实现了棋盘的格式化输出。
例程 136 打印棋盘函数的实现
1 void printBoard(int board[][3])
34 {
35 printf("\n\n");
36 printf(" %c | %c | %c \n", board[0][0], board[0][1], board[0][2]);
37 printf("---+---+---\n");
38 printf(" %c | %c | %c \n", board[1][0], board[1][1], board[1][2]);
39 printf("---+---+---\n");
40 printf(" %c | %c | %c \n", board[2][0], board[2][1], board[2][2]);
41 }
为了实现输出的美观,编写程序时需要对printBoard函数进行多次试运行(测试),并根据看到的结果进行相应修改和调整。
13.5.5 双人对战
双人对战函数playWithHuman是整个游戏的重头戏。首先请回顾双人对战的流程图。
图 138 程序流程图:双人对战
每个回合由玩家0和1先后布子,每次布子后都应判断是否出现了获胜方或平局,如果满足条件则结束游戏。流程图中没有体现的一点是在玩家落子之前应调用printBoard函数输出当前棋盘的状态,在实现时应当加以注意。
动手体验:实现双人对战函数
请参考上面的流程图和提示,试着自行实现双人对战函数playWithHuman。
下面给出了参考代码以及必要的注释。
例程 137 双人对战函数的实现
1 void playWithHuman(int board[][3])
42 {
43 int currentPlayer = 0; /* 当前玩家 */
44 int winner = -1; /* 获胜玩家 */
45 initializeBoard(board); /* 初始化棋盘 */
46 while(winner == -1)
47 {
48 int choice;
49 printBoard(board); /* 显示棋盘 */
50 printf("玩家 %d(%c),请输入下棋的位置(1 - 9):", currentPlayer, currentPlayer? 'O' : 'X');
51 while(1)
52 {
53 int col, row;
54 scanf("%d", &choice);
55 choice -= 1;
56 col = choice % 3;
57 row = choice / 3;
58 if(row >= 0 && row < 3 /* 行数合法 */
59 && col >= 0 && col < 3 /* 列数合法 */
60 && board[row][col] != 'X' && board[row][col] != 'O' /* 当前位置没有棋子 */
61 )
62 {
63 board[row][col] = (currentPlayer? 'O' : 'X');
64 break;
65 }
66 else
67 {
68 printf("输入无效,请重新输入。\n");
69 }
70 }
71 winner = checkBoard(board);
72 if(winner == 2)
73 {
74 printf("平局\n");
75 }
76 else if(winner == 0 || winner == 1)
77 {
78 printf("玩家 %d(%c)获胜\n", winner, (winner? 'O' : 'X'));
79 }
80 currentPlayer = (currentPlayer + 1) % 2; /* 换手 */
81 }
82 }
动手体验:实现人机对战函数
请试着自行实现人机对战函数playWithPC。人机对战与双人对战的区别在于,程序中需要为计算机提供“智能”,即通过编写程序的方式让计算机知道该如何下子——这就是各种游戏中的人工智能(AI)。如果现在完成井字游戏人机对战的AI对大家来说尚有难度,也可以考虑使用最简单的随机布子策略(使用随机数确定要在哪个格中下子)或顺序布子策略(从首格开始查找第一个没有棋子的棋盘格,并在其中布子)。
13.5.6 检查是否出现胜者或平局
最后来实现检查棋盘函数checkBoard。这个函数除了接受存储了棋盘信息的int型数组board作为传入参数之外,应当包含一个返回值用来确定胜者。不妨对返回值做如下规定:
如果玩家0获胜,则返回0;
如果玩家1获胜,则返回1;
如果棋盘已下满,但没有获胜方,则为平局,返回2;
否则意味着游戏尚未结束,返回-1。
下面是checkBoard函数的参考实现。
例程 138 checkBoard 函数实现
1 /*
83 * 如果棋盘不满,返回 -1
84 * 如果'X'获胜,返回 0
85 * 如果'O'获胜,返回 1
86 * 如果是平局,返回 2
87 */
88 int checkBoard(int board[][3])
89 {
90 int i, j;
91 /* 检查行与列 */
92 for(i = 0; i < 3; ++i)
93 {
94 if(board[i][0] == board[i][1] && board[i][1] == board[i][2])
95 {
96 return (board[i][0] == 'X'? 0 : 1);
97 }
98 if(board[0][i] == board[1][i] && board[1][i] == board[2][i])
99 {
100 return (board[i][0] == 'X'? 0 : 1);
101 }
102 }
103 /* 检查对角线 */
104 if((board[0][0] == board[1][1] && board[1][1] == board[2][2]) ||
105 (board[0][2] == board[1][1] && board[1][1] == board[2][0]))
106 {
107 return (board[i][0] == 'X'? 0 : 1);
108 }
109 /* 检查棋盘是否已经填满 */
110 for(i = 0; i < 3; ++i)
111 {
112 for(j = 0; j < 3; ++j)
113 {
114 if(board[i][j] != 'X' && board[i][j] != 'O')
115 {
116 return -1;
117 }
118 }
119 }
120 return 2;
121 }
13.6 调试与测试
13.6.1 简单测试
做完作业后要检查作业,同理写完代码后,也有必要检查一下程序是否符合之前设定的需求,而不是急着对小伙伴们宣布“我写出了人生第一个游戏”——检查通过后再大声宣布也不迟。
检查程序是否符合需求的过程被称为程序测试。最基础的测试方法是编译程序、运行程序,并简单试试各项功能是否基本正常。也可以在编写程序的过程中随时对程序进行测试,而不一定要等到所有代码都写完之后才安排测试。本书的附录中会介绍关于进阶测试的相关理论和示例以飨读者。
简单测试时可能发现的问题包括但不限于以下几种:
• 无法编译;
• 报错退出;
• 运行结果与预期不符;
• 资源(内存、CPU等)占用量过高。
13.6.2 调试
如果遇到测试结果和预期不符的情形,就是遇到程序缺陷(bug)了,需要通过调试来查找并解决问题。下面通过一个简单的例子来演示如何从报错地点出发“直捣黄龙”——使用VS内建的调试器进行调试,确定问题所在。
问题描述:
大黄在使用自己编写的井字游戏与人对弈时,遇到了下面的提示窗口(大黄的操作系统为Windows 7,他的井字游戏的可执行文件名为Test.exe):
图 139 井字游戏已停止工作
调试第一步:精确描述问题
要解决问题,就要先精确确定问题。为了确定问题,应多次进行相关操作(例如多次运行程序,或多次在同一个位置布子等),并注意问题发生的时机和出现问题时程序的状态。可能的发生问题的时机包括但不限于以下几种:
• 程序刚刚运行就出错;
• 程序运行一段时间后出现错误;
• 进行某个特定操作后发生错误;
• 程序退出时报错。
精确描述遇到的问题对程序调试有很大帮助。有经验的程序员可以通过问题发生的时机与问题发生时程序的状态直接想到相关的代码,迅速排除不相关的代码,许多时候甚至能做到看到问题就直接指出bug所在。
大黄试着多次运行井字游戏,发现程序总是在第一次布子时报错,且无论输入1 ~ 9中的哪个位置,程序都会出错。出错界面如下图所示。
图 1310 井字游戏报错
脚下留心:总能精确描述问题么?
有些问题不一定可以被精确描述。例如某些内存错误可能使程序出现非常随机的错误提示,或者在各种不同的地方报错推出,完全没有规律可循。但是调试时应该尽可能地试着精确描述问题,这样有助于缩小查错的范围。
调试第二步:回想编程逻辑,尝试初步确定错误原因
程序错误往往是由于错误的编程逻辑或思维造成的。回想编写程序时的逻辑有助于开发者检查自己当时的逻辑是否正确。如果发现当时的逻辑是错误的,那么调试工作基本结束了——开发者应当直接去读代码,然后修改代码以使其符合正确的逻辑,再回来继续调试。千万不要跳过这一步,直接去源代码里边改边试——修复的每一个错误都要做到“知其然,知其所以然”。仅仅把错误改正确是不够的,开发者必须要明白错误为什么会发生,以及自己的修复方式为什么是正确的。否则在没有思考明白的前提下改动程序,很可能只是将这个错误掩盖起来了,甚至还可能引入新的错误。
确定问题之后,请根据出错的时机和当时的程序状态试着回想编写程序时的逻辑,看看能不能初步确定错误原因。
大黄想:输入下棋的位置后,程序会首先判断输入的数是否合法,然后根据输入值计算数组中对应的行与列,最后修改数组中对应元素的值。看起来应该是这三步中的某一步出错了。但到底是哪一步出错了呢?
调试第三步:使用VS进行动态调试
VS有强大的动态调试功能,其基本操作大致分为两类:等待被调试的程序运行出错后中断到代码里,或是预先在代码的特定位置设置断点。程序在运行到断点所在的代码位置后将自动中断下来,这时开发者就可以在VS调试期中查看相关变量的值与心理的期望值是否一致了。本章中介绍的动态调试方法需要有源代码,由于大家就是程序的开发者,这一点自然不是问题。
在使用VS进行动态调试前,应先确保项目的当前编译模式为Debug(调试)模式,而不是Release(发布)模式。在Release模式中,编译器会进行各式各样的优化,很可能影响到调试时要查看的变量的值。要改变当前编译模式,只要在工具栏中直接修改即可,如下图所示。
图 1311 Release模式设定在工具栏中的位置
图 1312 Debug 模式设定在工具栏中的位置
大黄点击了Visual Studio主界面“调试”菜单中的“启动调试”选项(或按快捷键F5)以开始调试。
图 1313 “启动调试”项
程序正常运行。大黄照常输入下子的位置。
图 1314 井字游戏截图
大黄按下回车键,立即看到了VS弹出的错误提示框,如下图。
图 1315 Visual Studio 的错误提示
大黄点击“中断”按钮,直接跳转到了发生错误的那行代码,发现那行代码原来是这样的……
图 1316 跳转到发生错误的代码
代码左侧的黄箭头指示着当前执行到的代码位置。将鼠标指针悬浮到每个变量之上,还会弹出查看变量值的小窗体。不过大黄看了一眼代码就明白了:这是哪个坏蛋把原来的“row”改成了“row + 10000”?!随后他立即发现昨天是愚人节,“哦,可能是班上的某个同学和我开玩笑吧。不管了不管了,找到问题就好了。”
调试第四步:改正错误
大黄先停止当前的调试:在“调试”菜单中点击“停止调试”项。
图 1317 “停止调试”项
之后大黄删掉了代码中的“+ 10000”,重新运行程序,一切正常了!至此调试顺利结束。
13.7 本章小结
本章是对前面几章所讲过的C语言基础知识的一次复习和综合运用。大家在本章的指导下完成了“井字游戏”程序,并实践了一个程序从分析需求、设计架构到分拆模块、编写程序和测试等种种步骤。希望大家能进一步完善这款小游戏,从而巩固自己对前面讲过的基础知识的理解和运用,为后面的学习打下牢固基础。
- 点赞
- 收藏
- 关注作者
评论(0)