如何使用Valgrind memcheck工具进行C/C++的内存泄漏检测

字节顺序是令人恶心的一个问题。我想说一下我在这方面难过的经历。这里是关键:

  • 问题:电脑如何像人一样读取不同的语言
    • 有些时候写数据 "从左到右" 有些时候是 "从右到左".
    • 一台机器能正确读取自己的数据。问题发生在当计算机上的存储数据需要不同的读取类型时候。
  • 解决方案
    • 统一一个通用格式 (i.e.,所有网络流都是单一的格式 ), 或
    • 一直包括一个数据头,用来描述数据格式。当计算机读取到数据头的时候,数据头会告诉计算机数据存储在其他格式中,需要被转换后才能读取。

数字与数据

一个很重要的概念就是识别数字与代表数字的数据的区别。

数字是一个抽象的概念,如某物的总和。你拥有十个手指。“十”这个概念不会改变,无论你利用何种形式展现,ten,10,diez(西班牙语),ju(日语),1010(二进制),X(罗马数字)...这些形式都指向同一个概念“十”。

相比而言,数据是一个物理概念,一串存储于计算机中的位与字节。数据没有内在的意义,不同的读取对象赋予其不同的解释。

数据如人类的写作一样,只是简单地书写在纸上。它们可以是字母“IO”,木星的卫星,古希腊女神,或为输入/输出的缩写,亦或为某人的首创,二进制中的数字2(“10”)。数据展现的意义具有无限可能。

关键点在于一份数据可以有多种解释,直到有人明确作者的初衷,该数据的意思才被正确解析。

计算机也面临同样的问题。他们存储数据,非抽象的概念,而是通过一序列的1和0方式进行操作。之后,他们通过读取这些1和0形成的原始数据创建抽象的新概念。基于假设条件,这些1和0具有不同的意义。

为何这些问题会产生呢?那是因为我们没有规定所有的计算机必须使用同一种语言,就如我们人类一样。任何一种计算机需保持内部统一,即可识别其自己的数据,然而其他计算机如何解析该计算的数据并未保证。

基本概念

  • 数据(位,字节,或纸上标识)是无意义的;它必须通过创建抽象概念,如数字,来展现其意义;
  • 计算机如人类一样拥有不同的渠道去存储相同的抽象概念。(如:我们可以通过不同的形式去展现“十”这个概念,ten, 10, diez等)

把数字存储为数据

庆幸的是,大多数计算机都认可一些基本的数据格式 (但并不总是这样). 这给了我们一个同样的入口点,从而让我们的生活更简单:

  • 一位可以有两个取值(开或管,1或0)
  • 一个字节是一个八位的序列
    • 每个字节中最左端的一位是最大的. 所以二进制序列00001001就是十进制里面的数字9. 00001001 = (23 + 20 = 8 + 1 = 9).
    • 位的编号是从右到左的.第0位在最右边也是最小的;第7位在最左边,是最大的.

我们可以依据这些最基本的协议作为构建单元来传输数据. 如果我们每次存储或读取一个字节,那么在所有计算机上都是可行的.字节的概念在所有计算机中都是一样的 . 计算机也认可你发送给他们的字节的顺序-- 它们认可哪个字节是第一个,第二个,第三个等等. 所以第三十五个字节在所有机器上的解释都是一样的.

那,问题是什么呢?计算机都认可一个字节,对吗?

好吧,对于单字节的数据来说这是没问题的,比如ASCII文本. 然而,很多数据需要使用多个字节来存储,比如整数或者浮点数. 而对于这些字节的存储顺序是没有统一的协议的.

以字节为例子

设想一个包含4个字节的序列,其中的元素分别被命名为W X Y和Z - 我没有将它们命名为A B C D是因为它们是16进制的数字,那样的话可能会引起混淆.所以,每一个字节都代表一个值,而且都是由8位组成。

 Byte Name:    W       X       Y       Z
 Location:     0       1       2       3
 Value (hex):  0x12    0x34    0x56    0x78

例如, W 是一整个字节, 在16进制中表示为0x12,在二进制中表示为00010010.如果W被解析为数字,么在十进制中是"18"  (顺便说一下, 没有什么要求我们必须把它解释成数字 - 它可以是一个ASCII字符或者别的东西 ).

跟上我了吗? 我们有四个字节W X Y 和Z, 每一个字节都有一个不同的值.

理解指针

指针是编程中很关键的一部分, 特别是在C语言中. 指针是一个指向内存地址的数字,这个数字的解释是由我们(编程人员)决定的 (the programmer) .

在C语言中, 当你把一个指针映射 (转换)成某种类型 (比如一个char * 或者int *),它将告诉计算机如何解释位于那个地址的数据. 举个例子,让我们定义

void *p = 0; // p is a pointer to an unknown data type
             // p is a NULL pointer -- do not dereference
char *c;     // c is a pointer to a single byte

注意,我们不能根据p获取数据因为我们不知道它的类型. p 可以指向一个数字,一个字母,一个字符串的开始, 你的星座, 一张图片 -- 我们不知道需要读取多少字节,也不知道那里到底有什么.

现在假设我们这样写

c = (char *)p;

啊-- 现在这条语句告诉计算机指向跟p相同的位置, 并且将数据解释成为一个字符 (1 byte).这种情况下, c可以指向内存地址0, 或者字节W.如果我们输出c, 我们将获得W处的值, 也就是十六进制的0x12 (记住W是一个完整的字节).

这个例子不依赖于我们拥有的计算机的类型 -- 另外, 所有的计算机都认同字节这个东西 (在过去可不是这样).

这个例子很有意义,即使对于所有的计算机来说这都一样 -- 如果我们有一个指向单独一个字节的指针 (char *,单个字节), 我们就可以遍历整个内存, 每次读取一个字节. 我们可以检查任何一个内存地址,而计算机的大端还是小端并不影响--每一台计算机都会返回同样的信息.

那,问题在哪儿呢?

当计算机尝试读取多个字节的时候问题就出现了. 一些数据类型包含多个字节,比如长整形或者浮点型数字.一个字节只能表示256个值, 所以能存储0-255.

现在问题来了 - 当你读取多个字节的时候,最大的字节在哪儿?

  • 大端机: 存储数据的时候大端在前. 当查找多个字节的时候,第一个字节(地址最小的)是最大的.
  • 小端机: 存储数据的时候小端在前. 当查找多个字节的时候,第一个字节是最小的.

命名很有意义,对吧? 大端机认为大端在最前面. (顺便说一下,大端 /小端的命名来自格列佛游记,那里面的Lilliputans争论到底是从大的一端打破鸡蛋还是从小段一端. 有时候电脑争论也是同样有意义的 )

再回过来,当只有一个字节的时候,大端还是小端并不重要. 如果只有一个字节,它是你唯一读取的数据,所以只有一种解释它的办法 (因为计算机都认可一个字节代表的值).

现在假设我们的四个字节 (W X Y Z) 在大端机和小端机上以同样的方式存储. 就是说,两台机器上内存地址为0的位置都是W,内存地址为1的位置都是X,以此类推.

我们可以通过记住字节是与机器无关的来制造这样的顺序. 我们可以游走整个内存,每次一个字节,然后设置我们需要的值.这在任何机器上都是行得通的:

c = 0;     // point to location 0 (won't work on a real machine!)
*c = 0x12; // Set W's value
c = 1;     // point to location 1
*c = 0x34; // Set X's value
...        // repeat for Y and Z; details left to reader

上面的代码在任何机器上都是可行的,而且我们把两台机器都设置了字节,W,X,Y,Z分别位于内存地址0,1,2,3的位置.

解析数据

现在让我们以多字节数据举一个例子 (终于讲多字节了!). 快速复习: 短整型是一个两个字节的数字, 取值范围为0-65535 (如果是无符号的). 让我们在一个例子中使用它:

short *s; // pointer to a short int (2 bytes)
s = 0;    // point to location 0; *s is the value

所以,s是一个指向短整型的指针,而且现在指向位于0地址的字节(0字节存放了W),如果我们读取s处的值将会发生什么?

  • 大端机:我认为一个短整型是两个字节,所以我将把它们读完: s位置是地址0 (W, o或者0x12)然后s+1的位置是1 (X, 或者 0x34). 既然第一个字节是最大的 (我是大端机!), 那这数字一定是256 * 字节 0 + 字节1,或者 256*W + X,或 0x1234. 我把第一个字节乘以256 (2的8次方)因为我需要偏移8位.
  • 小端机: 我不知道大端机先生在说什么.我同意短整型是两个字节,我也向他一样把它们读完: s位置是0x12, s + 1的位置是is 0x34. 但在我的世界里,第一个字节是最小的! 这个短整型的值是字节 0 + 256 *字节1, 或256*X + W, 或0x3412.

记住,两台机器都从s位置开始向前读取内存. 对于0地址和1地址代表这个问题没有任何疑问,对于短整型占两个字节这个问题也没有任何疑问.

但你发现问题了吗? 大端机认为 s = 0x1234 而t小端机认为 s = 0x3412.同样的数据却得到两个不同的数字,这不太会是什么好事情.

另一个例子

让我们使用四个字节的整型为例子来“娱乐”一下:

int *i; // pointer to an int (4 bytes on 32-bit machine)
i = 0;  // points to location zero, so *i is the value there

同样我们也会问:i的值是什么?

  • 大端机: 一个整型占4个字节, 第一个字节是最大的.我读取四个字节(W X Y Z) ,W 是最大的. 结果是0x12345678.
  • 小端机: 没错,一个整型占4个字节,但第一个是最小的.我也读取 W X Y Z, 但 W 却完全相反--它是最小的. 结果是 0x78563412.

同样的数据,不同的结果--不是什么好事. 这里有一个使用上面的数字的互动例子,你可以随意添加你自己的:

NUXI 问题

关于字节顺序的问题有时候也被称作NUXI问题UNIX被保存在大端机上在小端机上将显示为NUXI.

假设我们想把4个字节 (U, N, I and X)作为两个短整型存储: UN and IX. 每个字母代表一个字节, 就像上面例子中的WXYZ. 要保存着两个短整型,我们可以这样写:

short *s; // pointer to set shorts
s = 0;    // point to location 0
*s = UN;  // store first short: U * 256 + N (fictional code)
s = 2;    // point to next location
*s = IX;  // store second short: I * 256 + X

这部分代码并不局限于机器.如果我们在一台机器上存储"UN"然后又把它读出来,结果最好是"UN"!我不关心端的问题,如果我们在一台机器上存储了一个值,当我们读取的时候我们需要能够获取同样的值.

然而,如果我们在内存中一次读取一个字节 (使用我们的char * 伎俩), 顺序就可能发生变化.在大端机上我们看到

Byte:     U N I X
Location: 0 1 2 3

这很有意义. U 在 "UN"中是最大的,而且被最先存储.对于IX也是一样: I 是最大的,也被优先存储。

在小端机上我们可以看到

Byte:     N U X I
Location: 0 1 2 3

这也是有意义的. "N" 在"UN"中时最小的,所以被先存储. 而且,即使字节在内存中是被”反向“存储的,小端机知道它是小端的,所以当读取数据的时候也会正确的解析他们.另外, 注意,我们可以再任何机器上像x = 0x1234一样指定16进制数.当你写0x1234的时候,小端机知道你的意思,它不会强制你让你自己把值给倒过来(你指定了要写的数字是16进制的,它意识到这个细节,然后再内存中将字节颠倒过来,底层操作).

这种场景就被称作"NUXI" 问题,因为字节顺序UNIX在其他类型的机器上会被解释为NUXI . 另外,只有当你交换数据的时候这才是个问题--每台机器内部是一致的.

不同端机器之间交换数据

计算机是互联的 - 那些一台机器只需要关系读取自己的数据的日子已经不复存在了.大端机和小端机之间需要交流并且相处.他们是如何做的呢?

解决方案1: 使用通用的格式

最简单的方式就是认可同样的格式以达到在网络上传输数据的目的. 标准的网络传输顺序实际上是大端在前, 但有些人觉得小端输了就变得很傲慢...我们暂且把它叫做"网络顺序".

要把数据转化成网络顺序, 计算机需要调用一个方法来转换 (主机-到-网络). 在大端机中,这不需要做任何事情,但现在我们不讨论这个(小端机们可能会很抓狂).

但在传输数据之前进行转换是非常重要的, 即使你是大端机.你的程序对于不同的机器来说可能非常熟悉,但你想让你的代码更灵活(不是吗?).

同样的,也有一个转换方法(网络到主机)被用来从网络读取数据. 你需要用它来保证你能够正确的将来自网络的数据转化成你主机的格式.你需要知道你接收的数据的类型才能正确的解码,转换方法在这里:

 htons()--"Host to Network Short"
 htonl()--"Host to Network Long"
 ntohs()--"Network to Host Short"
 ntohl()--"Network to Host Long"

记住:一个字节就是一个字节,与顺序没有关系.

这些方法在进行底层网络编程的时候非常关键, 比如验证IP数据包中的校验和. 如果你不能正确的理解“端”的问题,你的生活将很苦逼 -在这方面需要记住我所讲的. 使用转换方法,并且知道为什么需要使用它们.

解决方案2: 使用字节顺序标记 (BOM)

另一个办法就是在每一组数据前面包含一个魔法数字,比如0xFEFF. I如果你读到了魔法数字为0xFEFF, 那就是说读取的数据跟你的机器采用同样的格式,这样一切就好办了.

如果你读到的魔法数字是0xFFFE (它是反向的), 那就意味着读取的数据与你机器上的数据格式是不同的,你就需要进行转化.

有几点需要注意. 首先, 魔法数字并不是真的具有魔法,但编程人员经常使用这个说法来描述选择的任意一个数字(BOM 可以是不同字节的任意序列). 它被称作字节顺序标记是因为它暗示了数据存储的时候使用的顺序.

其次, BOM增加了所有传输数据的开销.即使你正在传输两个字节的数据,你也需要包含两个字节的BOM.哎哟!

Unicode在存储多字节数据的时候就使用BOM (有一些Unicode字符编码会为每个字符使用两个,三个甚至四个字节). XML默认把数据存储成UTF-8格式从而避免了这个复杂的问题, UTF-8每次存一个字节的Unicode信息.为什么可以这么酷?

(第56次重申) "因为端的问题不会影响单个字节".

你是对的.

另外, 使用BOM也会存在其它问题. 如果你忘记包含BOM了怎么办? 你会假设数据是按照你自己的格式传输的吗?你会读取数据并且检查是否它看起来是”反向“(管它什么意思呢)的,然后将它进行转化吗? 如果正常的数据碰巧就包含了BOM呢? 这些情况可不好玩.

为什么会有端的问题呢?我们就不能和睦相处吗?

啊,一个多么具有哲学意义的问题.

任何不同字节顺序的系统都有它的优点.小端机让你先读取小字节数据,从而无需读取其他字节.你可以非常容易地检查一个数据是奇数还是偶数(最后一位是0),如果你有这样的需求.大端机系统以与我们人类思维一致的方式在内存中存储数据(从左到右),这就使得底层的调试变得简单.

为什么我们不都认可一种系统呢? 为什么要尝试这么多的计算机还有这么多的区别呢?

让我用另一个问题来回答这个问题:为什么大家不说同样的语言?为什么有的语言是从左到右写,而有的是从右到左写?

有时候通讯系统是独立开发的,后来就需要互相交流.

结尾: 最后的思考

字节存储顺序这个问题是编码问题中的一个例子。数据需要代表一个抽象的概念,这个概念又需要从数据中创建。这个话题值得在自己的文章中或者系列文章中考虑。你还需要了解更多的关于端相关的问题. 

你可能感兴趣的:(二进制,网络编程,解决方案,数据存储,内存泄漏)