笔记-使用C语言过程中遇到的指针现象
在学对树进行层序遍历的时候写了一个链队列, 出了个毛病(具体参见后面分析). 修的时候发现是由于悬浮指针导致的, 而且还造成了内存泄漏(后面所提到的不停地把头结点扔掉现象).
所谓悬浮指针就是说一个指针指向了法外之地(并未分配给它使用的内存). 一般会发生在当内存区域被 free 掉了, 但指针未被设置为 NULL 或其他错误赋值的情况(比如本例).
还有一个相关概念叫野指针. 野指针的意思主要是说指针在被第一次赋值之前是指向一个未知区域的(当局部变量未赋值之前, 编译器不会将其初始化为0. 这时候里面那个乱七八糟的数就可能会错误地当成地址来用). 关于悬浮指针的概念可以参考这里https://en.wikipedia.org/wiki/Dangling_pointer.
问题代码
数据结构
1 |
|
链表方法(问题出在出队的实现上)
1 |
|
主函数
主函数就是一个标准的层序遍历. 用循环的方式来对树的每个结点进行 被入队--出队--该结点的左右孩子入队 过程.
1 |
|
其他函数
把其他用到的函数也放在这里 mark 一下.
1 |
|
分析(第一回合)
第一回合是根结点(e=1)的入队和出队.
我们都知道层序遍历的算法是用循环的方式来对树的每个结点进行
被入队--出队--该结点的左右孩子入队 过程.
在这里我口头上将一个while (!isE(q))
循环称为一个回合.
(进入while
前的根结点 入队-出队 动作视为第一个回合)
另外需要先澄清几个名词的不同含义:
- 头结点: 链表的头结点(Qnode).
- 第一结点: 链表除了头结点以外的第一个元素结点(Qnode). 以后第n结点以此类推(树的第n结点直接注明树第n结点).
- 根结点: 被层序遍历的那棵树的树根结点(Treenode).
总结为: (头结点->第一结点->第二结点->...->NULL)
产生错误的样子
一颗完全二叉树, 结点编号从 1~10. 本来的想法是层序遍历, 打印 1~10 出来. 但事实上上面的代码只打印如图的 1, 3, 7. 并且可以遍历到大部分孩子结点. 如图所示, 可以说是相当诡异.
断点分布
上 F5 直接开始调试. 从输出可以明确的是问题一定出在队列数据结构实现上而不是层序遍历算法, 主要就是不清楚到底是歪在在入队, 出队, 还是初始化上. 我们在入队(EnQ)和出队(DeQ), 初始化(InitQ)实现算法中分别打断点.
- EnQ实现:
1 |
|
- DeQ实现:
1 |
|
- InitQ实现:
1 |
|
上述的点布置主要是为了在 hit break 的时候能从窗口监视到指针变量值, 然后判断链表的当前状态(因为队列打印函数(pQ)也需要遍历链表, 如果实现有问题, 此时已经不可信. 因此也只能调试).
排除正常函数
首先介绍一个 gdb 调试方法. 在 gdb
命令行控制台直接输入x/32xb <变量名>
或在 vscode
调试控制台输入-exec x/32xb <变量名>
可以查看目标指针指向点位往后
32B 区域的二进制.
x/<n>xb
格式解释:/<n>
表示要显示的内存单元数量为n个单位,在这里是 32 个字节。x
是显示格式,b
表示按字节(byte)查看数据。
InitQ
InitQ 主要是用来创建头结点的. 也就是下面2个事:
- 给队列结构体
q
分配内存.(创建链表管理指针. 逻辑上的队列) - 给
q->f
和q->r
分配一个头结点.(创建链表)
捋到这里, 先来总结一下当前的内存情况.为了方便只看内存的后四位. 拿
InitQ
的断点首次被撞击时刻的情况来看:
- *q: 队列结构体
*Quece
类型指针的地址(指针的指针), 已被分配正常的内存为0xD5D0
. 在这之后就用不到了(初始化需要给指针赋值). - **q: 队列结构体
Quece
类型指针, 指向0x98F0~0x990F
这一块范围的内存(下面有输出可以看).0x98F0~0x9908
: 就是q->f
. 即q
指针所指向的内存0x98F0~0x9908
中的0x98f0~0x98f7
字段值(555555559910H
, 也就是0x555555559910
, 这里仍然记为0x9910
).0x98F0~0x9908
: 就是q->r
. 即q
指针所指向的内存0x98F0~0x9908
中的0x98f8~0x98ff
字段值. 同上, 为0x9910
. 对比下面 gdb 二进制输出和图中gdb监视器可以证明.0x9900~0x990f
: 包含了我没有用到的size
和malloc
生成的padding
填充(内存对齐-内存填充). 最终结果是q
,q->f
,q->r
,q->next
等后面会经常提到的变量事实上各占 32B.
(关于内存对齐)备注:
malloc
会把堆对齐到某个特定的大小.
观察q->f
和q->r
所指向的地址之差32B
. 而
Qnode, Quece 结构体也因为其对齐策略(不同于malloc
,
结构体按照最大成员的整数倍进行对齐),
在代码上下文中事实上被视为sizeof(Qnode)==16
和sizeof(Quece)==24
.
1 |
|
EnQ
在对 InitQ 进行初步检查后, 暂时认为 InitQ 是没有问题的. 下面对 EnQ 排查. 第一次进入 EnQ 时, EnQ 所做工作如下:
- 创建一个新结点
new_qnode
(指向0x9930
) - 初始化新结点的
e
(值为1, 树根结点) 和next
(初始化为指向NULL
). - 把原链表尾结点的
next
指向新结点, 也就是让q->r->next(指向NULL)
指向新结点所在的内存区域*new_qnode(0x9930)
, 这一步将new_qnode
上传到链上. 属于使用尾指针的尾插法(1/2). - 把队列尾指针
q->r
指向新结点(原0x9910
--后0x9930
), 补全链表的指针关系. 属于使用尾指针的尾插法(2/2).
1 |
|
1 |
|
这里对比看下图中代码和调试内容也没什么问题. 故排除 EnQ 的嫌疑.
DeQ
首先来说明一点, 出队的时候需要先判断2个特殊情况: 队长为 0 和 1 的时候. 对这俩情况做俩不同的处理:
- 0:当队长判断为0, 实际操作如下.
1 |
|
- 1:当队长判断为1, 实际操作如下.
1 |
|
- 一般的:当队长判断为非0也非1, 实际操作如下.
1 |
|
在对 EnQ 进行初步检查后, 暂时认为 EnQ 是没有问题的. 下面对 DeQ 排查. 第一次进入 DeQ 时, DeQ 所做工作如下:
- 对队列链表判空. 空则不需要出队.(这里队长为1, 1!=0)
- 对队列链表判是否长度为1.
创建一个临时的
Qnode* p
指向第一结点(0x9930
). - 拿出第一结点数据.
- 让头结点的 next 指针
q->f->next
指向"第二结点".- 一般地: 如果第二结点存在, 指向它.
- 本次的情况:如果第二结点不存在, 指向它(空).
并且操作
q->r
指向q->f
(bug在这一步赋值反了).
- 释放
p
所指向的内存, 也就是原本的第一结点. 现在原本的第二结点接班变成新的第一结点了(一般地).
第一回合的总结说明
从代码片段显然可以看出,
在操作q->r
指向q->f
过程中赋值写反了.
由于这句写反的赋值,
以及对是否队长为1的判断(队尾r是否和f->next指向同一区域)导致在第一回合的结尾彻底改变了链表的结构,
抛弃了头结点. 而后续也产生了连锁错, 造成了速度为每回合 0~1
个结点的内存泄漏.
现在来详细说明一下第一回合内改变链表结构的过程:
链表的头结点由于f
和r
和*(0x9910)->next
都跑去指向了即将被删除的第一结点,
整个链表于是断开, 分成两个结构:
- 头[0x9910]->NULL (没有指针指向它. 正常情况下
s->f
,s->r
应当指向它)- 作为再也无法被访问的内存区域, 这里也是内存泄漏的开始.
- 非法区域[0x9930] (
s->f
,s->r
,p
)- 注: 在本次运行的情况下,
这个非法区域(0x9930)事实上还保留着之前第一结点的内容(
free
函数只会标记该区域为空闲, 不会将其全体写0). 因此下面两个表达式也成立: - *(0x9930)->e==1
- *(0x9930)->next==NULL
- 注: 在本次运行的情况下,
这个非法区域(0x9930)事实上还保留着之前第一结点的内容(
这里附图当时的分析. 需要注意的是图中一些名词比如野指针的用法错误了. 这里应当是悬浮指针. 以及现在所到达的位置.
1 |
|
分析(第二回合)
第二回合是根的左孩子结点(e=2)的入队和出队.
EnQ
当 hit break 到 EnQ 内部的时候, 第二回合的 EnQ 也将进行 创建-尾插 的两个工作. 这时候 EnQ 要运行两次, 入队 root 的左右孩子, 也就是树的 2 和 3 结点. 下面按照入队顺序挨个看.
入队树的第二结点(链表第一结点)
创建过程:由图可以看到
new_qnode
被malloc
分配到了0x9930
所开辟的一块Qnode
内存空间(32B). 而巧合的是在上一回合中,q->f
和q->r
也都被错误地指向了0x9930
. 这样就导致队列链表的结构在新结点初始化完毕, 并且尚未投入使用之前立刻就发生了如下的变化. 此时, 链表主体不存在头结点, 有且仅有一个结点就是这个新生产的第一结点. 同时也需要注意的是,s->f
,s->r
,new_qnode
现在全都指向0x9930
.头[0x9910]->NULL (再也没有指针可以指向它)
2[0x9930] (
s->f
,s->r
,*(0x9930)->next
,new_qnode
)
"尾插"过程:在创建新结点完毕后, 正常流程下要进行带头单链表的尾插操作了. 也就是下面代码块的
q->r->next = new_qnode;
和q->r = new_qnode;
两步. 从前面可以看到s->f
,s->r
,new_qnode
现在全指向0x9930
. 因此这一通操作下来, 几个变量值, 0x9930内存块存储的值就变成了下面这样.- q->f, q->f->next, q->r, q->r->next, new_qnode:
0x9930
- q->f, q->f->next, q->r, q->r->next, new_qnode:
总结为存在的指针域全都指向了
0x9930
.f
,r
全部指向第一结点, 第一结点的 next 指针也指向自己.
1 |
|
入队树的第三结点(链表第二结点)
创建过程:由图可以看到
new_qnode
被malloc
分配到了0x9950
所开辟的一块Qnode
内存空间(32B). 创建过程没有太大问题.尾插过程:
q->r->next = new_qnode;
和q->r = new_qnode;
两步操作, 解除了自指, 并且把 r 指向了新结点. 为使得链表现在变成一个不带头的单链表. 回顾全体的结点如下所示.头[0x9910]->NULL (再也没有指针可以指向它)
2[0x9930] (
s->f
)-->3[0x9950] (s->r
)-->NULL
DeQ
在树的 2 和 3 结点入队之后, 队列链表接受 DeQ 处理. 由于失去了头结点,
q->f直接指向了第一结点,
因此再次满足了关系式q->f->next==q->r
.
尽管根本不是这个关系式所期待的实际情况.
1 |
|
经过q->f->next = p->next;
和q->f=q->r;
的处理,
链表的结构发生了和第一回合一样的变化. 经过指针误操作,
导致f
, r
, p
,
全部指向第二结点(第一回合的第一结点). 第一结点(第一回合的头结点)被丢弃,
链表断裂再也无法被访问, 造成第二次内存泄漏. 而另一边f
,
r
,
p
所指向的0x9950
旋即被free
掉,
f和r再次变成悬浮指针. 此时链表的结构如下.
头[0x9910]->NULL (再也没有指针可以指向它)
2[0x9930]->NULL (再也没有指针可以指向它)
3[0x9950] (
s->f
,s->r
)-->NULL
第二回合的总结说明
从第二回合可以看到 bug 所形成的结构开始产生规律,
也就是在抛掉头结点后变成了一个不带头的单链表形式, 并且在当