C 语言指针数据隐藏难题:从原理困惑到巧妙解决
在编写C代码的过程中,指针是一个频繁出现且极为重要的元素,可以说是无处不在。实际上我们还能对指针进行一些巧妙的额外运用,比如在指针里偷偷存储一些额外的信息。而实现这一巧妙技巧的关键,就在于巧妙利用内存中数据的自然对齐特性。
内存里的数据存储,并非随意地安排在任意的地址上。处理器在读取内存时,总是按照与自身字长相同大小的块来进行读取。从提高效率的角度出发,编译器会将内存中的各种实体(如变量等)的地址分配为它们自身大小(以字节为单位)的整数倍。举个例子,在32位处理器的环境下,一个占据4字节空间的整数类型数据,必然会被存储在能被4整除的内存地址之上。
在这里我们先设定一个前提条件,假设在某个系统中,int类型(整数类型)所占用的空间大小,和指针类型所占用的空间大小,都是4字节。
接下来让我们来深入思考一个指向int类型数据的指针。就像前面所提到的那样,int类型的数据有可能被存储在像0x1000、0x1004或者0x1008这样的内存地址位置,但绝对不会被存储在0x1001、0x1002、0x1003或者其他任何不能被4整除的内存地址上。
我们知道,在二进制表示中,任何一个能够被4整除的二进制数,其末尾的两位数字必然都是00。这也就意味着,对于任意一个指向int类型数据的指针而言,它所对应的内存地址值的二进制表示中,最右边的两个低阶位始终是零。
现在我们发现了指针的这两个低阶位在正常情况下并没有实际的用途,相当于是“闲置”的。这样这里的技巧就在于,我们可以把想要存储的额外数据,巧妙地放置到这两个低阶位中。在后续需要使用这些数据的时候,我们可以将其提取出来使用,而在通过解引用指针去访问内存中的实际数据之前,我们需要把存储在这两个低阶位中的数据移除掉,以确保指针的正常使用。
由于在C语言的标准规范中,直接对指针进行按位操作是不被允许的,不太符合标准要求。所以为了实现我们的目的,我们会把指针转换为unsigned int(无符号整数)类型来进行存储和相关操作。
为了让大家能快速理解核心思路,下面先展示一段较为简单的代码片段。
我们把这段代码执行完后,就会得出下面这样的输出结果:
从这里我们能够晓得,我们可以于指针当中进行存储;而且实际上,我们能够存储任何能够通过2位二进制数予以表示的数字。
当使用putdata()函数时指针所对应的内存地址值的二进制表示中的最后两位,会被设置为我们想要存储的数据。而通过getdata()函数我们就可以访问到存储在指针这两个低阶位中的数据。具体来说,getdata()函数会将除了最后两位之外的所有位都覆盖为零,这样就能把我们之前隐藏存储的数据显示出来了。
cleansepointer()函数的作用则是将指针所对应的内存地址值的二进制表示中的最后两位清零。这样做的目的在于,在对指针进行解引用操作之时;而且当指针所指向的内存地址满足那种特定的对齐要求之时,进而能够保障解引用操作的安全性。
需要特别注意的是,虽然诸如英特尔(Intel)所生产的部分CPU,在某些情形下,并且在某些特定的环境里,能够准许程序去访问未对齐的内存位置;不过像ARM CPU等其他一些类型的CPU,如果去访问未对齐的内存位置,那这样一来,反倒会致使程序出错。所以在对指针进行解引用操作之前,一定要牢记让指针指向一个满足对齐要求的内存位置。
这种方法在现实世界中有应用吗?
答案是肯定的,这种在指针中隐藏数据的方法在实际应用中是有其用武之地的。我们可以查看Linux内核中红黑树(Red Black Trees)的实现。
在Linux内核里,红黑树的节点是按照如下这般方式来予以定义的:
首先存有特定的数据结构以及与之相关联的属性;其次凭借一系列的规则与操作来维系其特性;并且于整个内核的运行进程当中,它发挥着极为重要的作用。
在这里unsigned long rbparentcolor这个变量存储了两个重要的信息:
- 1. 红黑树中当前节点的父节点的地址。
- 2. 当前节点的颜色信息。其中颜色用0来表示红色,用1来表示黑色。
就如同我们前面所举的例子一样,这些信息(节点颜色信息)被巧妙地偷偷存储在了表示父节点地址的指针的“无用”位中。
现在让我们来看看,在Linux内核的相关代码中,是怎样去访问父节点指针以及节点颜色信息的:
参考文献
《Hide data inside pointers》
