2.5 C语言入职例程二:指针

2.5.1 强化指针概念

指针是C语言中最基本且很重要的概念,某种程度上甚至可以说:指针是C语言的灵魂。

不巧的是,我们公司新招聘的好多新人对C语言指针都比较陌生。和大家交流,思考背后原因,可能要拜人性中的“选择性遗忘”了(最近脑科学研发成果。人们一般会将极伤心的事忘记的干干净净,如果C语言会给我们带来痛苦,我们会第一时间忘记它)。大家因为道听途说C语言指针很难,然后就故意躲得远远的,即使尝试学了一点点,也努力忘他个干干净净。

真实产品是离不开指针的,尤其是在尝试引入各种架构设计后,指针更是漫天飞舞。因此,在入职C语言训练中,指针强化成为必不可少的课程。

C语言中对指针的基本定义是保存地址的变量,我刚开始学习C语言的时候还容易接受这个概念,但工作多年后反而容易犯糊涂了,挺像那种汉字越看越不像的感觉。

为了给新人讲解指针概念,在多年的培训实践中,我尝试了一种对比法,期望能帮助我们的小伙伴更容易理解指针。

在我眼中,指针由低级到高级,由生到死,一次迭代了三个层次:

1. 硬件层面的指针概念:

追根溯源,我们先来看最底层的汇编程序(直面硬件体系结构)是如何定义数据变量的,如下汇编代码片段:

value1:
    DCD    0x11
    DCD    0x22
value2:
    DCB    "welcome"

上述代码段定义了两个变量,value1和value2。我们习惯性认为value1对应了两个字(32位),value2对应了一个字符串。实际语言层面,value1和value2仅是一个地址标注,value1后面可以逻辑的认为是字符串,而value2后面也可以逻辑的认为是一个字,仅汇编语言本身并没有给予特别约定。

因此,在汇编语言(硬件体系)层次,所有的变量定义本质上都是地址(指针),至于地址里面存储的是什么内容,在汇编语言级别没有特别规定,可完全由我们自己自由发挥。

2. C语言中的指针

进入C语言后,为了编程的方便,我们增加了语义定义,数据开始有类型了,因此也出现了针对各种数据类型的指针定义,如int和float就有着不同又相同的含义。相同是因为都是指针,不同是因为指针指向内容存在差异。

因此,此时指针的含义,不能仅仅的理解为一个地址概念,还要关注地址指定的对象。同时,也需要记住指针变量仅仅是地址,因为这是指针作为变量本身的规则。

在C语言中,大家应该都知道,有且仅有值传递这一种方式,也就是说,传递给被调函数的参数值存放在一个临时变量中,而不是存储在原始变量中。换句话说,一个函数传递参数和返回值,或者一个赋值语句拷贝过程,都是完整字节拷贝模式,即使传递的是一个对象(结构体),也是老老实实的逐项拷贝。如果传递的是一个指针,是以指针的本质(地址)进行拷贝的。

比较下面几条代码片段:

int fun1(int a);
struct A fun2(struct A a)
{
	struct A b;
	……;
	return b;
}
struct A* fun3(struct A* a);

fun1函数为基本的数值拷贝,比较好理解,函数内部怎样折腾变量a,都不担心对外部有影响。fun2参数和返回值的传递都是对象,逐个字节拷贝,当然这种代码效率较低。fun3参数和返回值拷贝的仅仅是指针本身,只是间接达到了对象的引用传递效果而已。

3.高级语言中的“指针”

我们经常说C语言是中级语言,为何呢,我个人的理解是:C语言仅引入了有限的语义,同时保留了大量的汇编级别语言的特性。如数组的概念,本质上依旧是指针而已,没有增加过多的语义概念。

但是随着编程理念的发展,各种新语义概念开始层出不穷。

如在C++中引入了类和对象的概念后,如果理解C++对象模型呢?要知道对象的数据结构比较复杂,甚至干脆就不在一块连续的内存上(如包含静态变量时,下图为典型的C对象内部结构图),此时的对象指针概念如何定义呢?
在这里插入图片描述
为此,在C++中额外增加了“引用”概念,主要就是用于对象的操作抽象,其本质相当于是一种别名。当然为了延续历史脉络,C++需要兼容设计,又需要拓展,导致其长成了一种杂合语言。大家平时会讨论指针和引用的区别,如果理解指针概念的发展史,就非常清晰了,对象尽量用引用,原有的数值和数组继续使用指针即可。

理解了这一点,就会明白很多高级语言为何会逐渐的取消指针,而进一步加强类引用的概念了,如java等。

经历了C语言指针有低级到高级,由生到死的迭代之旅后,您是否对C语言指针有更深刻的理解。我的理解:C语言指针不仅是一个地址,更需要关注它指向的内容,以及与此关联的语义。

2.5.2 指针类型及操作

上一节我们提到了C语言指针不仅是一个地址,更需要关注它指向的内容,以及与此关联的语义,这节就让我们来一起聊一聊C指针常见的语义。

首先,我们先来关注指针地址这个概念。如果有人接触过8位或16位单片机编程,就会发现指针地址本身也是存在很多差异的,有指向小范围的和大范围的,有短跳转和长跳转等。此时,各类指针变量本身的长度(sizeof)都不一样,指针变量拷贝赋值等操作都需要谨慎。

滚滚长江东逝水,目前嵌入式领域已逐渐的进入了32位系统,尤其是以arm cortex-m系列为首,此时,几乎所有的指针地址都是32位了。我们的系列文章如无特殊说明,都是以cortex-m系列芯片为主,大家可以将指针地址等价为32位整数。大家应该了解指针发展的历史脉络,这样会有更加完整的认知。

简单介绍C语言指针地址的概念后,我们来侧重关注指针指向的内容。依据我的工程实践经验,我将其提炼分解为几类:

  1. 0;
  2. void*;
  3. 指向常规变量的指针,如int*,float*等;
  4. 指向字符串的指针;
  5. 指向对象的指针;
  6. 指向函数的指针。

培训中,第一条就会让我们的一些小伙伴不淡定了, 0竟然也是指针!在c语言中,我感觉0就是个捣乱鬼,大家都知道八进制以0为起始,那么0是十进制数呢,还是八进制数呢,这个问题曾经困惑了我很多年。后来受中国文化“求同存异”的影响,我才能安慰自己,0既是十进制数,也是八进制数,破解了我多年的困惑。既然如此,0为何不能也同时是指针呢。

大家应该会经常看到这样的代码片段:

if (p != NULL)
{
	……
}

该处的NULL一般被定义为0,此时,0就是以指针的身份存在的。抛开这些抽象概念,我们总结成一句话:任何指针和0进行相等或不等的比较或赋值操作都是有意义的。换句话说:指针和整数之间不能相互转换(强制转换除外),但0除外,0可以赋值给指针,也可以同指针进行比较(仅限相等和不等)。

◇◇◇

第二类指针语义是void指针。大家知道C语言一开始是没有void的吗?那为何后来有了呢,追根溯源,或许更好理解void*指针。

一开始C语言的通用指针大家都习惯使用int*,但因为指针是允许加减操作的,而且让人痛苦的是指针加减是按照执行对象大小加减的,更痛苦的是int在C语言中是一个自然变量(尽量发挥硬件特性的变量,因此各系统经常存在差异),因此,我们经常一不小心,就引入了一系列异常。

为了规避该问题,有人想出了一招,通用指针使用void*,因指向是虚无的,因此指针加加减减的操作就不被允许了,间接规避了好多无意识的错误。

既然是通用指针,因此可以装得下任何指针,但想返回去,就需要特定类型转换了,如下代码示例:

int *pint;
void *pvoid;
pvoid = pint;     /* 合法 */
pint = pvoid;     /* 不合法  */
pint = (int*)pvoid;     /* 合法  */

因为void这个特点,如果需要传递各种指针时,我们就应该尽量使用void了,最典型的应用场合就是memcpy函数了,原型如下,大家在构建函数原型的时候也可以模仿:

void *memcpy(void *dest, const void *src, size_t n);

在我们团队的项目中,我喜欢将NULL定义为((void*)0),道理类似,其额外的好处就是强调不要拿NULL进行数学运算了。

◇◇◇

第三类为指向变量的指针,这个就不在赘述了,我们直接来关注第四类,指向字符串的函数。

指向字符串的指针实际上也是一种指向char变量的指针,但因为在实际使用过程中大家经常犯糊涂,因此被我独立的归为一类了。

谈起字符串指针,我们先来判断一下下面两条语句的差异:

char szMsg[] = "hello, xiaomaer";
char* pMsg = "hello, xiaomaer";

这道题经常用于面试时考察同学们对C语言的理解深度,如果不理解指针本质含义,一般要迷糊半天。比较如下:

  1. szMsg是一个数组,是一种高级语义定义,赋值语句只是将这个数组大小被约定为字符串长度,并且将字符串给放进去当初值了而已。pMsg是一个指针变量,但其指向了一个常量字符串(在嵌入式系统中,该常量字符串一般位于flash区域了)。

  2. 对szMsg本身加减是不允许的,但可以修改数组内容(这是C语言赋予数组的语义),pMsg可以加减(这是C语言赋予指针的语义),但想去修改字符串内容,结果C语言不作假设(在嵌入式系统中,常量字符串经常放在flash中,会触发异常)。

如能细细体会该处的差异,应该能理解字符串指针概念。

指向对象的指针我们下一小节开始描述,指向函数的指针和架构设计关联比较紧密,几乎是构建整个嵌入式软件系统的基石,我们在例程三种详细描述。

◇◇◇

前面我们介绍了指针指向的语义,但不完整,我们还需要对其进行操作,还需要关心指针运算方面的语义。

指针最基本的操作是*和&运算符,单个都比较好理解,但碰到一些常用的组合语句后,我们就会犯糊涂。(我是做产品的,不允许产品中故意弄一些乱七八糟的组合,如i+++j之类,但下面的语句是真实产品中真实存在且经常使用的):

int* p;
int* q;
*p += 1;
*p++;
(*p)++;
*q++=*p++;
……

p++操作,因为运算符低于++运算符,因此p++是先执行p++运算,后取值,但又因为是后加操作,所以先执行p,等取值操作完成后,p在++。(*p)++是对指针取出的数据进行后加操作的。

上述描述过程中存在两次反转,大家是否会感觉糊涂。实际上,我写了这些年的C语言程序,碰上这类混合运算也经常会犯糊涂。大家如果不服,可以品一品(*p[])()的语法含义,在《C程序设计语言》一本书中还有专门讲解,比较费解,容易折腾人。

工程实践中,为了解决这类问题,我们采取了一种典型策略:严格约定项目中允许使用的组合表达式,并通过实例代码让大家理解这些约定组合表达式含义即可。除了项目组约定的组合表达式之外,就不允许在使用其他组合表达式了。

我们项目组中,典型的组合表达式部分截取如下:

  1. (*p)++主要用于指针指向变量加1操作,示例如下:
	DWORD* pCount;		/* 个数 */
	pCount = (DWORD*)(COUNT_ADDR);
	(*pCount)++;
	if (*pCount >= 32)	/* 反转 */
		*pCount = 0;
  1. *q++=*p++主要用于内存拷贝操作,相当于“*q=*p; p++; q++;”,示例如下:
	pDes = &...;
	pSrc = &...;
	for (i = 0; i < sizeof(...); i++)
		*pDes++ = *pSrc++;
  1. 规避直接的函数指针(*p)()或函数指针数组(*p[])(),通过typedef增加可读性,示例如下:
	/* 端口驱动,读取数据 */
	typedef DWORD (*HWPortDrvRead)(DWORD dwPort, BYTE* pBuf, DWORD dwLen);
	HWPortDrvRead pfnRead;			/* 读函数 */
	HWPortDrvRead pfnReadArray[] = /* 读函数列表 */
	{
		rs232_read, rs485_read, ether_read
	};
	……

除了&和*外,指针还允许==,!=,<,>,+,-等运算符,但要注意不是全部都可以,经常使用的情况汇总如下:

  1. 任何指针和0进行相等或不等的比较都是有意义的;
  2. 指针可以和整数进行加减操作,如p+n,需要注意的是,是按照指向对象大小空间进行加减的;
  3. 同一数组中的指针可以进行比较操作,用于判断前后关系;
  4. 同一数组中指针执行相减操作,尤其是减去头部指针可判断当前元素位置,这是常使用的技巧。

2.5.3 教科书级的指针列表

在上一节文章中,我们将指针按其指向语义分为多个类型,其中第5类是对象指针。在C语言中,对象用struct来表达,如果将指针和数组结合在一起,一种常见且很有价值的数据结构“链表”就诞生了。

为了让小伙伴进一步熟悉链表操作,我们的第二个例程诞生了:使用链表方式构建一个学生信息管理系统,支持添加、删除、查询、遍历等操作。

各位读者,是否有一点点熟悉的味道。没错,因为这经常就是大学C语言教材中讲解链表所用到的例程(各学校使用的教材不同,但不影响本节后续内容阅读)。因此,这个例子我们的小伙伴都完成的很happy。

现在,让我们一起重新温故这个例程。

◇◇◇

为了简单,学生信息结构描述如下:

struct student
{
    int num;        	/* 学号 */
    float score;    	/* 成绩 */
    struct student *next; /* 指向下一结点 */
};

构建的列表结构如下图示意:
在这里插入图片描述

为了访问链表,需要定义如下变量

struct student *head; /* 表头 */
struct student *p1;  /* 新建结点 */
struct student *p2;  /* 表尾结点 */

◇◇◇

我们首先来分析链表的构建(也即添加操作)过程,然后,很郁闷的发现需要分两种情况分别处理:

  1. 链表为空
    在这里插入图片描述
	p1 = malloc(...);
	head = p1;
	p2 = p1;
  1. 链表不为空
    在这里插入图片描述
   p1 = malloc(...);
   p2->next = p1;
   p2 = p1;

◇◇◇

然后,是删除操作。在删除时,首先要执行查找操作,先要找到需要删除的节点,假设用p1指向它,并用p2指向p1的前一个节点。同添加操作一样,我们悲催的发现,需要分三种情况分别处理。

  1. 列表为空,要删除的节点为NULL:
	if (head == NULL)
		return;
  1. 要删除的节点是头节点:
    在这里插入图片描述
	free(head);
	head = p1->next;
  1. 要删除的节点不是头节点:
    在这里插入图片描述
	p2->next = p1->next;
	free(p1);

◇◇◇

前面谈到了链表的添加和删除操作,如果在加上很简单的查询遍历操作,已经构建了一个完备的链表数据模型。但不知大家有没有意识到:链表虽然将学生信息组织在一起,实际上学生信息是乱序的。而如果链表内容要求排序,添加操作(准确描述应该称之为插入操作)就需要重构了。

假设我们的学生信息按学号从小到大排序,p0指向要插入的对象,查找定位p1节点,并在p1之前插入。然后本节最郁闷的事情出现了,竟然需要分为四种情况分别处理:

  1. 链表为空
	head = p0;
  1. 将p0插入第一个结点之前
    在这里插入图片描述
	head = p0;
	p0->next = p1;
  1. 将p0插入表尾结点之后
    在这里插入图片描述
	p1->next = p0;
	p0->next = NULL;
  1. 中间插入,此时p2指向p1的前一个节点
    在这里插入图片描述
	p2->next = p0;
	p0->next = p1;

◇◇◇

至此,我们已经将链表的基本操作温故了一遍,但大家感觉如何,有没有头晕。上面是大学教程中的标准实现,但这种方法好吗!要知道链表操作在真实产品中可经常会用到,但大家能准确的记住上面共计(2+3+4)种分支吗!能保证每次都准确无误且不遗漏的写出来吗!能保证你在审核时碰到类似的代码时能立即发现其中bug吗!

真实产品世界,我们需要寻找新的出路。

2.5.4 实际产品中的指针列表

链表的常见操作包含添加、删除等,在上一节中,我们实际上描述了两种添加操作。为何?因为链表中的内容有两种模式:有序和无序。可惜的是,大部分新人在例程二闭环反馈例程时,很少有新人能提出学生队列是否需要排序的疑问。通过这一次迭代提醒,很多新人会慢慢开悟了,开始真正的认真对待工作闭环反馈。

我们首先来描述无序的链表数据结构,说起无序,实际上类似于集合操作了。在嵌入式系统中,我们经常用链表组织管理一堆信息,如管理所有的任务对象等。

此时如何进行添加操作呢?策略很简单:头部插入即可。

假设表头为pHead,新建节点为pNew,让我们来模拟上一节中插入操作的两种情况:

  1. 链表为空,此时pHead = NULL:
    插入程序为: pNew->next = pHead; pHead = pNew;
  2. 链表不为空,此时pHead != NULL:
    插入程序为: pNew->next = pHead; pHead = pNew;
    大家发现了什么,不管链表是否为空,插入操作一致而简洁,没有分支,有没有好清爽的感觉。

◇◇◇

在嵌入式产品中,为了规避内存碎片,追求长期稳定运行和可靠性,会使用内存预分配策略,也就是指将一个模块估计使用的内存预先分配出来。大家如果接触过商业代码就会发现,很多模块都有一个配置文件,其中最主要的配置项就是约定预分配大小,如ucos中最大任务数,如lwIP中的最大tcp连接数等。

为了应对这种情况,需要额外构建一个空闲指针,初始化时一次分配预定义大小的结构块,并通过空闲指针串联起来,用时取,用完还,不够用报警。

假设表头为pHead,空闲指针为pFree,无序空闲链表的基本操作包含:初始化、用时取、用完还。

设新取的节点指针为pNew,用时取和不够用报警流程如下:

pNew = pFree;
if (pNew == NULL)
    不够用,报警;
pFree = pFree->next;

设p1为要归还的节点,用完还流程如下:

p1->next = pFree;
pFree = p1;

初始化例程,相当于将预分配的空间所有节点都归还,设预分配内存空间为pFree,例程如下:

pHead = NULL;
pFree = NULL;
for (i = 0; i < dwCount; i++)
{
	pPre[i].next = pFree;
	pFree = &pPre[i];
}

此时,构建出来的列表会先从预分配空间的尾部用起,会给调试带来一些不便,优化的策略也很简单,初始化时逆序排列即可,如下所示:

for (i = dwCount-1; i >=0; i--)
{
	pPre[i].next = pFree;
	pFree = &pPre[i];
}

至此,无序的链表操作就介绍完毕了。大家有没有发现,所有的操作都是针对头部操作,没有分支,非常简洁。

◇◇◇

前文提到的无序链表的插入和删除操作,都是通过头部进行的。因为无序,所有其中每个节点都是类似的,插入和删除位置无所谓,有点类似集合的概念,因此可以简化为基于头部指针的操作。

假设我们的链表是有序列表,此时的插入和删除操作就会变的啰嗦很多,有没有好的办法呢?我寻觅多年,终于找到一种比较好的策略,项目组内部喜欢将其称为“虚头”指针。

何为“虚头”指针呢?为了减少指针为NULL的判断,我们为何不给其固定的增加一个头部,使其永远不为null呢!额外增加的这个头部指针就称之为“虚头”指针。

增加虚头指针后的插入和删除操作是怎样的呢?我们继续以学生信息结构为例:

struct student
{
    int num;        /* 学号 */
    float score;    /* 成绩 */
    struct student *next; /* 指向下一结点 */
};
struct student mgr;    /* 学生管理链表头, 虚头指针 */

假设以学号正序排列,且pNew为当前要插入的对象,插入操作如下:
void add(struct student* pNew)
{
    struct student* q;        /* 上一节点 */
    struct student* p;        /* 当前节点 */

    /* 遍历查找并插入 */
    p = &mgr;			/* 虚头指针 */
    for (;;)
    {
        q = p;			/* 备份上一节点位置 */
        p = p->pNext;	/* 下一节点 */
        if (p == NULL || pNew->num < p->num)	/* 插入判断 */
        {
            p1->pNext = p;
            q->pNext = pNew;
            break;
        }
    }
}

借助于虚头指针,我们用一条语句将教科书上的4种情况(空、头、尾、中间)全部覆盖了,注意其中||运算符有一定的技巧性,p为NULL时,pNew->num < p->num不会被执行,不存在内存异常访问的情况,是否有很清爽的感觉。

与插入类似,假设知道学号,删除操作流程如下:

void del(int num)
{
    struct student* q;        /* 上一节点 */
    struct student* p;        /* 当前节点 */

    /* 遍历查找并删除 */
    p = &mgr;
    for (;;)
    {
        q = p;
        p = p->pNext;
        if (p == NULL)     /* 未找到 */
            break;
        if (num == p->num)
        {
            ...;   /* 将p还给free队列 */
            q->next = p->next;
            break;
        }
    }
}

◇◇◇

前文介绍到一种针对有序列表的插入和删除操作,通过增加一个虚拟头部,减少了很多判断分支。但假如该结构由很多成员,这个虚拟头部就过于浪费内存了,要知道在工业嵌入式设备中,内存可是珍贵资源。

如何破解这一困局呢?

细细分析关于虚拟指针相关代码,我们发现mgr的使用主要是为了减少分支情况,其本身也仅使用next字段而已,那么,如果我们是否可以仅保留next字段,而取消其他内容呢。

此时,我们需要额外定义一个结构,同时需要将next字段提到最前面,如下代码示例:

struct student
{
    struct student *next; /* 指向下一结点 */
    int num;        /* 学号 */
    float score;    /* 成绩 */
};
struct studentHead{struct student *next;} mgr;    /* 学生管理链表头 */

然后在插入删除操作中增加强制类型转换即可,如下示意:

p = (struct student *)&mgr;

◇◇◇

链表结构具备一定的灵活性,但一步执行出错,就会导致整个链表变成脏数据而导致系统异常。实际上,指针操作都有一点让人不踏实的感觉,尤其是可靠性要求很高的嵌入式系统。我在进行代码审核时,指针类操作一般都是我审核的重点之一,如果看到不熟悉的指针操作,经常就会睡不着觉了,会担心产品运行失控。

为了解决这个问题,我们在实际项目中经常使用一种称之为“桩检测”的策略,也就是在指针指向的内容中插入一个或多个检测桩,一般有头部或头尾部两种模式,继续以学生信息结构为例,如下示意:

struct student
{
    unsigned int nHeadFlag;    /* 头部检测桩, 约定为'stud' */
    struct student *next; 		/* 指向下一结点 */
    int num;        /* 学号 */
    float score;    /* 成绩 */
    ...
};

struct studentHead
{
    unsigned int nHeadFlag;    /* 头部检测桩, 约定为'stud' */
    struct student *next;
} mgr;    /* 学生管理链表头 */

结合检测桩,对每一个传递进来的指针进行桩检测,可大幅度减少指针乱飞的情况。

◇◇◇

至此,传统的链表操作已经被我们修改的面目全非了,为了便于项目组内交流方便,我们随意起了一个名字,叫"虚头单向链表",大家感觉好不好听,可以随意修改,只要项目组内交流方便就ok了。完整代码示意如下:

#include <stdio.h>

/* 学生信息结构 */
struct student
{
    int flag;    /* 头部检测桩,约定为'stud' */
    struct student *next;    /* 指向下一结点 */
    int num;        /* 学号 */
    float score;    /* 成绩 */
};

/* 学生信息虚拟头结构 */
struct studentHead
{
    int flag;    /* 头部检测桩,约定为'stud' */
    struct student *next;
};

/* 预分配的学生信息 */
#define MAX_NUMBER    4
static struct student inf[MAX_NUMBER];

/* 空闲指针 */
static struct student *pFree;

/* 虚拟头 */
static struct studentHead mgr;

/* 初始化,构建空闲列表 */
void init(void)
{
    int i;

    /* 赋初值 */
    pFree = NULL;
    mgr.flag = (int)'stud';
    mgr.next = NULL;

    /* 依次插入空闲队列,并清标志 */
    for (i = MAX_NUMBER - 1; i >= 0; i--)
    {
        inf[i].flag = 0;
        inf[i].next = pFree;
        pFree = &inf[i];
    }
}

/* 头部(无序)插入 */
void add(int num, float score)
{
    struct student *pNew;

    /* 取出一个空节点 */
    pNew = pFree;
    if (pNew == NULL)    /* 无可用节点 */
        return;
    pFree = pNew->next;
    
    /* 赋值 */
    pNew->flag = (unsigned int)'stud';
    pNew->num = num;
    pNew->score = score;

    /* 插入头部 */
    pNew->next = mgr.next;
    mgr.next = pNew;
}

/* 有序(按学号顺序)插入 */
void insert(int num, float score)
{
    struct student* q;    /* 上一节点 */
    struct student* p;    /* 当前节点 */
    struct student *pNew; /* 新建节点 */

    /* 取出一个空节点 */
    pNew = pFree;
    if (pNew == NULL)    /* 无可用节点 */
        return;
    pFree = pNew->next;
    
    /* 赋值 */
    pNew->flag = (unsigned int)'stud';
    pNew->num = num;
    pNew->score = score;

    /* 遍历查找并插入 */
    p = (struct student *)&mgr;
    for (;;)
    {
        q = p;				/* 备份上一节点位置 */
        p = p->next;
        if (p == NULL || pNew->num < p->num)		/* 插入判断 */
        {
            pNew->next = p;
            q->next = pNew;
            break;
        }
    }
}

/* 已知学号删除 */
void del(int num)
{
    struct student* q;        /* 上一节点 */
    struct student* p;        /* 当前节点 */

    /* 遍历查找并删除 */
    p = (struct student *)&mgr;
    for (;;)
    {
        q = p;			/* 备份上一节点位置 */
        p = p->next;
        if (p == NULL)     /* 未找到 */
            break;
        if (num == p->num)
        {
            q->next = p->next;	/* 删除p节点 */
            p->next = pFree;    /* 将p还给pFree */
            pFree = p;
            break;
        }
    }
}

/* 使用 */
void use(struct student* p)
{
    if (p == NULL)
        return;
    if (p->flag != (int)'stud')
        return;
    printf("num: %d, score: %.2f\n",
        p->num, p->score);
}

/* 遍历 */
void list(void)
{
    struct student* p;

    /* 遍历查找并删除 */
    printf("begin-----\n");
    for (p = mgr.next; p != NULL; p = p->next)
    {
        use(p);
    }
    printf("end-----\n\n");
}

/* 主程序 */
int main (void)
{
	...
}

2.5.5 测试路径

前文我提到,在学校学习编程,能将一个问题用程序表达出来就ok了。但做产品,对代码的要求可不止于此。

做嵌入式产品,代码编写并调试完毕,仅仅是开始,后续无数次的阅读、维护、测试、优化、重构才会占去大部时间,如果一开始的工作量为1,后续可能会进行N倍放大。

在单元代码阶段,最有效的测试手段就是代码审核和白盒测试。很多嵌入式设备需要长期稳定运行,此时对代码质量的最低要求是完成100%覆盖测试,如何有效的达到这一要求呢?本小节,我们一起从白盒测试的角度来审视一遍上一节的链表代码。

◇◇◇

首先,我们分析初始化代码,代码示意如下:

void init(void)
{
    /* 赋初值 */
    ...

    /* 依次插入空闲队列,并清标志 */
    for (i = MAX_NUMBER - 1; i >= 0; i--)
    {
        inf[i].flag = 0;
        inf[i].next = pFree;
        pFree = &inf[i];
    }
}

这段代码,虽然其中有一个循环体,是单流程代码,没有任何分支,因此它的执行流程是唯一的。如果我们需要写针对这个函数的测试用例,只要一次简单的调用即可。

同理,遍历程序也是单流程代码,示意如下:

void list(void)
{
    /* 遍历查找并删除 */
    for (p = mgr.next; p != NULL; p = p->next)
    {
        ...
    }
}

因此,如对list函数编写测试用例,也仅需简单的调用一次即可。

◇◇◇

现在,让我们找一个复杂的例子,有序插入,这段代码示意如下:

void insert(int num, float score)
{
    /* 取出一个空节点 */
    if (pNew == NULL)    /* 无可用节点 */
        return;
    
    /* 赋值 */
    ...

    /* 遍历查找并插入 */
    for (;;)
    {
        if (p == NULL || pNew->num < p->num)
        {
            ...
            break;
        }
    }
}

这个函数就有多个分支了,如何编写白盒测试用例呢,为了达到100%分支覆盖,需如下的几条路径:

  1. if (pNew == NULL) 的直接返回;
  2. for循环体中if语句条件始终不满足的情况;
  3. for循环体中p==nulll条件满足;
  4. for循环体中pNew->num < p->num条件满足。

为了覆盖上面的几条执行路径,需要构建测试用例如下:

  1. 无可用节点(路径1);
  2. 空链表插入(路径3);
  3. 链表头部插入(路径4);
  4. 链表尾部插入(路径2);
  5. 链表中间插入(路径4)。

因此创造条件,五次inser调用即可完成覆盖测试。无序添加和删除构建测试用例,类同于inser过程了,大家可以直接构建。

◇◇◇

前面的代码是已经被我优化过的,因此测试路径比较简洁,但实际产品中,经常是数不清的分支,将测试编程一件不可能的事。实际产品中的代码,一般刚开始很单纯,如下示意:

int test(void)
{
    int a;
    a = 1;
    return a;
}

但随着产品功能的持续增加,我们在不断的加入各种选项判据,导致if判断语句开始增加,可能的代码就变成如下的样子了:

int test(void)
{
    int a;
    if (flag1)
    {
        if (flag2)
            a = 1;
        else
        {
            if (flag3)
                 a = 2;
             else
                 a = 3;
        }
    }
    else
        a = 4;
}

每增加一种判据,经常导致原有的执行路径扩展一倍,很快,构建可覆盖所有分支的测试用例就成为一件不可能的任务了,同时,产品质量也开始失控了。

现在,大家可以在返回比对第三小节节给出的教科书示例,如果我们需要对其代码进行测试,测试用例会是几何?在嵌入式系统中,尤其是很多涉及安全和高可靠行业的设备,都需要进行代码分支覆盖测试,如何能有效减少代码分支数量,是需要在一开始编码阶段就考虑的事情。

在例程一中,我已经提到了一种有效的减少代码分支的策略:条件卫语句。在一个函数的起始位置集中判断各种错误,如存在错误时直接异常或返回,减少各种条件判断语句和主流程的混杂,可大幅度减少程序执行分支。

多年的工程实践活动,让我们形成了一种低成本且高效的单元白盒测试策略:单元测试用例随同模块一起实施编写,并进行统一审核。因为审核要求代码测试用例全分支覆盖,程序员在编写单元代码时,就会有意识的主动减少程序分支。

2.5.6 总结和思考

又到了要求新人总结思考的时候了,你不妨也掩卷沉思,本节是如何带着大家复习加强指针概念的,同传统的课本知识有哪些不同。

我平时喜欢同新人交流,询问他们总结时有哪些收获。一次,一新人告诉我,总结时才发现,没想到自己已经走出去那么远。记得当时听到这一句话,我内心特欣慰。

本节不仅仅在帮助新人复习加强指针的概念,更是从一名嵌入式软件工程师的角度、实际产品研发的角度帮助大家重新梳理指针概念。

一开始,我们比对了汇编、C语言、C++等高级语言中的指针,通过追溯指针的生死之旅,告诉了你指针的全新概念:C语言指针不仅是一个地址,更需要关注它指向的内容,以及与此关联的语义。

然后,从这个概念出发,我们先简要叙述了8、16、32位等嵌入式系统上的指针地址差异,然后依据指针指向语义,将指针分为如下几种:

  1. 0;
  2. void*;
  3. 指向常规变量的指针,如int*,float*等;
  4. 指向字符串的指针;
  5. 指向对象的指针;
  6. 指向函数的指针。

前4种比较简单,依次复习加强,并顺便提炼了指针允许的各种操作,汇总如下:

  1. 任何指针和0进行相等或不等的比较都是有意义的;
  2. 指针可以和整数进行加减操作,如p+n,需要注意的是,是按照指向对象大小空间进行加减的;
  3. 同一数组中的指针可以进行比较操作,用于判断前后关系;
  4. 同一数组中指针执行相减操作,尤其是减去头部指针可判断当前元素位置,这是常使用的技巧。

借助指向对象的指针,我们进入了链表的世界。很多新人刚入职时,身上还带有浓浓的学校的烙印,很难从产品的角度去思考。为了帮助大家理解学校和产品的区别,我首先引出教科书上指针链表实现,然后借助白盒单元测试的概念,告诉大家,这样的代码不应该出现在产品中。

产品中有两种常见的链表实现,无序和有序。这两种实现机制在我们自己的产品中使用非常普遍,大家后续在接触真实产品代码时,立即会有一种熟悉的感觉。对比实验表明,例程二对新人克服指针困局,有较大的帮助。

当然,关于C语言指针的内容,肯定不止这些,一些小伙伴会问,为何仅介绍单向链表呢,没有双向链表吗?双向链表因为一次需要好处理好几个指针,直接写在代码中很容易错误,也难于审核,因此一般都被子程序化了。

真实产品中的链表操作可能比我们想象的要复杂好多,经常是各种情况的组合,举一个简单的例子,os中的任务,一般以链表方式组织,但同时多个任务需要等待一个信号量呢,一个任务要等待多个信号量呢,如何组织,上一幅图让大家意会一下:
在这里插入图片描述

有没有头晕的感觉。实际上,真实产品中这类代码占比非常小,大部分都位于底层或算法模块中。我们一般不会让新人一开始就接触到这类代码。当新人具备一定工作经验,熟悉各种链表操作后,在接触这类程序,自然会手到擒来,轻易克服。

——————————————

我是小马儿,一个渴望良知与灵魂的嵌入式软件工程师,欢迎您的陪伴与同行,如感兴趣可加个人微信号nzn_xiaomaer交流,需备注“异维”二字。

发布了16 篇原创文章 · 获赞 22 · 访问量 3774

猜你喜欢

转载自blog.csdn.net/zhangmalong/article/details/104019400
2.5
今日推荐