最近研究生成图像的代码时,深入研究了一下osg::Image
中的数据存储,一般从该数据结构中获取某一像素的像素值时会用到data(i,j)
这个函数,其返回值的类型就是一个unsigned char*
数组,而更加神奇的是其返回图像数据的同名函数data()
返回的值也是一个unsigned char*
数组,也就是说unsigned char*
数组里面存储的数据类型还是一个unsigned char*
数组(Q_Q),这就很有意思了。于是从这里出发,我们研究一下C语言中这个unsigned char*
数组的正确使用姿势[1-2]。
memcpy()
函数首先,从问题出发,我们先来探讨一下float
与unsigned char*
数组之间的相互转换。目前来看,可以称之为经典的操作方式有两种,其一是使用C++中的union
联合类,其二是使用C语言中的memcpy()
函数。先来介绍一下memcpy()
函数,该函数的原型为:
#include
void* memcpy(void* _Dst, const void* _Src, size_t _Size);
/***
*memcpy - Copy source buffer to destination buffer
*
*Purpose:
* memcpy() copies a source memory buffer to a destination memory buffer.
* This routine does NOT recognize overlapping buffers, and thus can lead
* to propogation.
*
* For cases where propogation must be avoided, memmove() must be used.
*
*Entry:
* void *dst = pointer to destination buffer
* const void *src = pointer to source buffer
* size_t count = number of bytes to copy
*
*Exit:
* Returns a pointer to the destination buffer
*
*Exceptions:
*******************************************************************************/
void * memcpy (void * dst, const void * src, size_t count)
{
void * ret = dst;
/* copy from lower addresses to higher addresses */
while (count--) {
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
return(ret);
}
其实现的功能是从源_Src
所指的内存地址的起始位置开始拷贝_Size
个字节到目标_Dst
所指的内存地址的起始位置中。这一函数的实现目标业已知晓,则在明确其几个实现要点:
(1)不要直接使用形参,要转换成char*
;
(2)目标地址要实现保存;
(3)要考虑源和目标内存重叠的情况。
的前提下可手动实现其主体代码[6],下面的代码在原有代码的基础上考虑了内存重叠的问题,使得内存重叠时也不至发生数据覆盖的问题,其实也即等同于实现了memmove()
函数的功能:
void * my_memcpy(void *dst, const void *src, size_t size)
{
if (dst == NULL || src == NULL) return NULL;
char *p_dst = static_cast <char*> (dst);
const char *p_src = static_cast <const char*>(src);
int n = size;
if (p_dst > p_src && p_dst < p_src + n){
/* 从高位向低位执行内存拷贝 */
for (size_t i = n - 1; i != -1; --i) { p_dst[i] = p_src[i]; }
}
else{
/* 正常执行从低位向高位的内存拷贝 */
for (size_t i= 0; i < n; i++) { p_dst[i] = p_src[i]; }
}
return p_dst;
}
一个完备的memmove()
函数的关键代码如下所示:
if (dst <= src || (char *)dst >= ((char *)src + count)) {
/*
* Non-Overlapping Buffers
* copy from lower addresses to higher addresses
*/
while (count--) {
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
}
else {
/*
* Overlapping Buffers
* copy from higher addresses to lower addresses
*/
dst = (char *)dst + count - 1;
src = (char *)src + count - 1;
while (count--) {
*(char *)dst = *(char *)src;
dst = (char *)dst - 1;
src = (char *)src - 1;
}
}
如上图所示,memcpy()
函数的内存重叠包括两种情形:当_Dst<_Src
时;当_Dst
∈ \in ∈[_Src,_Src+_Size]
时。由于memcpy()
函数在执行内存拷贝时遵循从低位向高位的准则,所以执行完图中_Dst_1
所代表的拷贝过程之后将使得arr
数组变为:0,3,4,5,6,5,6,7,8,9
;而执行完图中_Dst_2
所代表的拷贝过程之后将使得arr
数组变为:0,1,2,3,4,5,3,4,5,3
。_Dst_1
执行过后与预期的结果没有差别,_Dst_2
的元素a[6]
则先执行了a[3]
元素的拷贝变为3后再次执行将a[6]
拷贝给a[9]
的操作,所以最终a[9]
输出了3而不是预期的6。
对应的memmove()
函数以及基于memmove()
函数思想所设计的my_memcpy()
函数在考虑到_Dst_2
所代表的内存覆盖问题时统一采用了从高位向低位拷贝的逆序拷贝过程,使得重叠区域的元素先被拷贝到目的地,以此来避免非预期情形下的内存重叠拷贝问题。
union
类型共用体union
可以将不同类型的变量存放在同一个地址开始的内存单元中,虽然不同类型的变量所占的内存字节数是不一样的,但是在共用体中都是从同一个地址存放的,也就是使用覆盖技术令共用体中存储的变量互相覆盖,达到使几个不同的变量共占同一段内存的目的。若要在一定程度上理解公用体的结构,可以分析如下代码:
#include
void main()
{
/*
* 共用体的定义方式如下:
*
* union 共用体名 共用体列表 共用体变量
*
* 使用时可以用union U u的方式声明变量u,同样地
* 也可以用typedef union U的方式进行定义,这样
* 即可用U u的方式来定义共用体了。
*/
union U {
short int x;
long y;
unsigned char ch;
} w;
w.y = 0x12345678;
printf("0x%x\n", w.y);
printf("0x%x\n", w.x);
printf("0x%x\n", w.ch);
}
共用体中的所有变量都共享同一块内存,因此其体内的所有变量都将从同一个内存地址出发进行读写,共用体的内存布局如下图所示[8],参考文献中的书籍也即中文译本的《深入理解计算机系统》:
关于C++中union
的使用还需要注意一下硬件层面上的大小端问题。所谓“大小端”是指数据在内存中的字节顺序,例如int
型变量 a=0x12345678
占用 4 个字节,在大端字节序的机器上,int
数据的高位位于低地址。在小端字节序的机器上,int
数据的高位位于高地址。
两位十六进制数可以表示1个字节, 在一个1字节的十六进制数0xBC
中,其高字节就是指16进制数的前4位,如上例中的B
;低字节就是指16进制数的后4位,如上例中的C
。所以大小端问题可以理解为十六进制数的高字节是存储在内存的底地址还是高地址中的问题,如下:
(1)大端模式:是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中;
(2)小端模式:是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。
大小端中的端“endian”一词来源于十八世纪爱尔兰作家Jonathan Swift的小说《Gulliver’s Travels》,小说中,小人国为水煮蛋该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开而争论,争论的双方分别被称为“大端派”和“小端派”。以下为关于这段争论的描述[7]:
我下面要告诉你的是,Lilliput和Blefuscu这两大强国在过去36个月里一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端,可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了。因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。老百姓们对这项命令极其反感。历史告诉我们,由此曾经发生过6次叛乱,其中一个皇帝送了命,另一个丢了王位。这些叛乱大多都是由Blefuscu的国王大臣们煽动起来的。叛乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有11000人情愿受死也不肯去打破鸡蛋较小的一端。关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派任何人不得做官。
—— 《格列夫游记》 第一卷第4章 蒋剑锋(译)
上面的文字其实意在讽刺当时英国和法国之间持续的冲突。网络协议的开创者之一Danny Cohen率先使用这两个术语指代字节顺序,后来就被大家广泛接受,通过抽象的方式可以绘制出这样一幅图来加深对大小端问题的理解:
考虑到大小端问题的影响,在小端机器上运行上面的代码,则其输出结果应该是:
0x12345678
0x5678
0x78
下面是实现float
与unsigned char*
数组之间相互转换的两种方式:
Plan A 使用union
联合类
union FloatAndByte
{
/*
* 使用union共用体实现
*
* ubyte为4字节unsigned char数组;
* ufloat为4字节float浮点数.
*/
unsigned char ubyte[4];
float ufloat;
} FAB;
void float2byte(unsigned char* data, float value)
{
FAB.ufloat = value;
for (int i = 0; i < 4; i++){
/* 按字节由低位到高位拷贝数值. */
data[i] = FAB.ubyte[i];
}
}
float byte2float(unsigned char* data)
{
/* 令ubyte的指针指向data即可. */
FAB.ubyte = data;
return FAB.ufloat;
}
Plan B 使用memcpy()
函数
void float2byte(unsigned char* data, float value)
{
/*
* memcpy拷贝float字节到unsigned char数组
*
* 从value中一个字节一个字节的拷贝十六进制比特串
* 到data中,拷贝float所占的全部4个字节
*/
memcpy(data, &value, 4);
}
float byte2float(unsigned char* data)
{
/*
* memcpy拷贝unsigned char字节到float
*
* 从data数组中一个字节一个字节的拷贝十六进制比特串
* 到value中,由低到高填充float的4个字节
*/
float value;
memcpy(&value, data, 4);
}
当然还有通过位操作符FAB.ubyte = data[0] | (data[1]<<8) | (data[2]<<16) | (data[3]<<24)
的方式来进行相应的转换。输出unsigned char*
数组可以用下面的代码:
printf("char: 0x%x 0x%x 0x%x 0x%x/n", data[0], data[1], data[2], data[3]);
这里需要知道的是char
类型1个字节8比特,可表示 x ∈ [ − 128 , 127 ) , x ∈ N x\in[-128,127),x\in \mathbb{N} x∈[−128,127),x∈N范围内的数;而unsigned char
则表示范围 x ∈ [ 0 , 255 ] , x ∈ N x\in[0,255],x\in \mathbb{N} x∈[0,255],x∈N内的数。C语言语系中的float
基本都是沿用 I E E E 754 \mathrm{IEEE}\ 754 IEEE 754标准所设计的float-point数据存储方式所设计的,其在32位机内存中占4个字节,32位存储空间,由符号位(sign bit)、指数位(exponent)和有效数字亦即尾数(mantissa)所构成[3],如下图所示:
令 S S S为符号位, M M M为尾数, E E E为指数,则可将浮点数 V V V表示为如下形式[4]:
V = ( − 1 ) S × M × 2 E V=(-1)^S\times M\times 2^E V=(−1)S×M×2E如此即可将浮点数表示为32位二进制字符串,同时也可用4字节表示为十六进制串,一个unsigned char
占用1个字节内存,所以用unsigned char*
数组进行对应表示时应使得数组大小为4,由此即建立了数值与byte之间的联系,从根本上来说计算机中的所有东西都可以用比特串来进行表示。