本篇博客记录一下在孟老师课上学习的代码中的软件工程方法,以一个通用的命令行菜单子系统程序 menu 为例,搭建编译调试环境,并对其中的一些软件工程方法进行分析。
参考资料:代码中的软件工程
1. C/C++编译调试环境配置
1.1 GCC安装配置
我们需要一个 C/C++ 编译器和调试器来运行调试实例程序,GCC 就是这样一套支持多种语言的编译套件,在 Linux 平台上,可以通过软件仓库直接安装,apt install gcc g++
或 yum install gcc g++
,而在Windows 平台下,需要安装 Mingw64,这是 GCC 的 Windows 移植,包含一套完整的 C/C++ 编译调试工具,下载地址:mingw64-installer。
安装过程选项说明:
-
Version:制定版本号,没有特殊要求就用最新版吧
-
Architecture:跟操作系统有关,64位系统选择x86_64,32位系统选择i686
-
Threads:设置线程标准可选posix或win32
-
Exception:设置异常处理系统,x86_64可选为seh和sjlj,i686为dwarf和sjlj
-
Build revision:构建版本号,选择最大即可
安装完成后,将安装目录下的 /bin 目录加入环境变量(不知道安装程序会不会自己加,如果有了就不用手动加了),添加完后打开终端,输入 gcc -v
,看到版本号则安装完成,可以愉快地使用 gcc,g++,gdb 等工具了。
1.2 vscode配置
编辑器我们选择轻量的 vscode,配置好相关插件后可以很方便的查看,调试代码。
打开vscode,在左侧点击扩展图标(ctrl+shift+x),安装以下扩展:
- C/C++
将项目目录 menu 添加到工作区,打开 一个 C 文件,按 F5 ,选择 C++(FDB/LLDB) 和 gcc.exe-生成和调试活动文件,然后目录下会自动生成一个 .vscode 目录,里面有两个文件:
- launch.json:调试设置
- tasks.json:构建任务设置
打开 tasks.json 来配置项目的构建任务(Linux 环境可用 make 直接编译,Windows 还需要额外安装,就不麻烦了):
// 主要是在 args 里面手动添加一些需要编译的文件
// main 函数是在 test.c 文件里面,所以调试是从 test.c 开始
// 手动加入 menu.c 和 linktable.c 编译
"args": [
"-g",
"${file}",
"${fileDirname}\menu.c",
"${fileDirname}\linktable.c",
"-o",
"${fileDirname}\${fileBasenameNoExtension}.exe"
],
修改完成后打开 test.c 文件,按 F5 即可开始编译调试:
2. 软件工程分析
2.1 模块化设计
模块化(Modularity)是在软件系统设计时保持系统内各部分相对独立,以便每一个部分可以被独立地进行设计和开发。说白了就是便于在项目的其他部分或者其他项目中使用已经实现过的功能,那么这个功能模块一定要非常的独立,简洁,拿来即用。模块化设计背后的基本原理是软件开发中的关注点分离,把大问题分解成小问题,分而治之。
回到项目中,将数据结构(链表)与它的操作和菜单业务之间进行分离处理就是一个典型的模块化代码,这里我们重点关注头文件 linktable.h ,在其内部定义了一系列通用的链表节点与操作函数:
/*
* LinkTable Node Type
*/
typedef struct LinkTableNode
{
struct LinkTableNode * pNext;
}tLinkTableNode;
/*
* LinkTable Type
*/
typedef struct LinkTable tLinkTable;
...
/*
* get LinkTableHead
*/
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
/*
* get next LinkTableNode
*/
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
...
具体的实现在 linktable.c 文件中,可以不用关注,从这一部分代码中我们完全无法看到整个项目要实现的业务逻辑,只知道它用到了链表这种数据结构,这就是一个成功的模块化设计。那么在其他部分或其他项目中需要用到链表操作时,只需要导入 linktable.h 头文件,就可以用其中的数据结构快速构建具体的业务逻辑,而不需要从头定义底层代码。
2.2 可重用接口
2.2.1 什么是接口
有了模块化的代码结构之后,要让各个模块之间能很好地互相调用,就需要设计合适的接口,具体来说,要求简洁,清晰,明确。
接口,就是互相联系的双方共同遵守的一种协议规范,换句话说,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务,一般是通过定义一组 API 函数来约定沟通方式。
在面向过程的编程中,接口一般定义了数据结构及操作这些数据结构的函数;而在面向对象的编程中,接口是对象对外开放的一组属性和方法的集合。
一般来说,接口包含五个基本要素:
- 接口的目的
- 接口使用所需满足的前置条件或假定条件
- 使用接口的双方遵守的协议规范
- 接口使用之后的效果,一般称为后置条件
- 接口所隐含的质量属性
2.2.2 项目中的可重用接口设计
在 menu 项目中,上面提到的模块化设计中的 linktable.h 文件中定义的一系列链表操作都是接口定义的例子,如最典型的查找节点函数:
/*
* Search a LinkTableNode from LinkTable
* int Conditon(tLinkTableNode * pNode,void * args);
*/
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
调用者使用这个接口时,需要提供一个链表,一个 callback 函数以及一个额外参数,这么做的好处是大大提高了接口的可重用性,数据结构层只是负责遍历链表,同时使用上层提供的函数来检查是否查找到目标节点,这样应用层只需要根据业务构建具体的链表数据和定制的 callback 函数然后交给数据结构层操作就行了。
menu 项目中实际的接口调用如下:
// 用于匹配的 callback 函数定义
int SearchConditon(tLinkTableNode * pLinkTableNode,void * arg)
{
char * cmd = (char*)arg;
tDataNode * pNode = (tDataNode *)pLinkTableNode;
if(!strcmp(pNode->cmd, cmd))
{
return SUCCESS;
}
return FAILURE;
}
// 查找链表上与输入命令相同的节点
char cmd[CMD_MAX_LEN];
printf("Input a cmd number > ");
scanf("%s", cmd);
tDataNode *p = (tDataNode*)SearchLinkTableNode(head, SearchConditon, (void*)cmd);
2.3 线程安全
2.3.1 什么是线程
线程(thread)是操作系统进行运算调度的最小单位,它包含在进程之中,是进程的实际运作单位。一个线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程执行不同的任务。一个进程至少包含一个线程。
有了多核多线程的CPU之后,操作系统就可以让不同线程运行在CPU不同核的不同线程上,大大减少了进程调度切换的资源消耗。
2.3.2 什么是可重入函数
可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(使用信号量或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。可重入函数要么使用局部变量,要么在使用全局变量时保护自己的数据
2.3.3 线程安全与可重入函数
如果代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行某一段代码,如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的,反之则不是线程安全的。
- 不可重入函数一定不是线程安全的
- 可重入函数不一定是线程安全的,不同的可重入函数(共享全局变量或静态变量)在多个线程并发使用时仍然可能有线程安全问题
2.3.4 项目中的线程安全分析
menu 项目中的线程安全主要体现在链表模块中对链表的各种操作上,首先是链表定义中添加了用于互斥操作的线程锁:
/*
* LinkTable Type
*/
struct LinkTable
{
tLinkTableNode *pHead;
tLinkTableNode *pTail;
int SumOfNode;
pthread_mutex_t mutex; // 线程锁
};
然后,锁的使用主要体现在对链表的写操作上,在 linktable.c 文件中,我们可以看到每一次对链表的写操作,都事先进行上锁,完成后再释放,如:
// 删除一个节点
pthread_mutex_lock(&(pLinkTable->mutex)); // 加锁
pLinkTable->pHead = pLinkTable->pHead->pNext;
pLinkTable->SumOfNode -= 1 ;
pthread_mutex_unlock(&(pLinkTable->mutex)); // 释放
free(p);
// 添加一个节点
pthread_mutex_lock(&(pLinkTable->mutex)); // 加锁
if(pLinkTable->pHead == NULL)
{
pLinkTable->pHead = pNode;
}
if(pLinkTable->pTail == NULL)
{
pLinkTable->pTail = pNode;
}
else
{
pLinkTable->pTail->pNext = pNode;
pLinkTable->pTail = pNode;
}
pLinkTable->SumOfNode += 1 ;
pthread_mutex_unlock(&(pLinkTable->mutex)); // 释放
...
这样对每一步的写操作都施加了线程锁后,项目便可以安全的运行在多线程环境下了。