周立功教你学程序设计结构体:内存对齐和基本数据类型

电子说

1.3w人已加入

描述

第二章为程序设计技术,本文为2.2.1 内存对齐和2.2.2 基本数据类型

我们知道,数组和指针是相同类型有序数据的集合,但很多时候需要将不同类型的数据捆绑在一起作为一个整体来对待,使程序设计更方便。在C语言中,这样的一组数据被称为结构体。

>>> 2.2.1 内存对齐

虽然所有的变量最后都会保存到特定地址的内存中,但相应的内存空间必须满足内存对齐的要求。主要出于两个方面的原因:

  • 平台原因:不是所有的硬件平台(特别是嵌入式系统中使用的低端微处理器)都能访问任意地址上的任意数据,某些硬件平台只能访问对齐的地址,否则会出现硬件异常。

  • 性能原因:如果数据存放在未对齐的内存空间中,则处理器访问变量时需要做两次内存访问,而对齐的内存访问仅需要一次访问。

在32位微处理器中,处理器访问内存都是按照32位进行的,即一次读取或写入都是4个字节,比如,地址0x0 ~ 0xF这16字节的内存,对于微处理器来说,不是将其看作16个单一字节,而是4个块,每块4个字节,详见图2.4。

周立功

图2.4  内存空间示意图

显然,只能从0x0、0x4、0x8、0xC等地址为4的整数倍的内存中一次取出4个字节,并不能从任意地址开始一次读取4个字节。假定将一个占用4字节的int类型数据存放到地址0开始的4字节内存中,其示意图详见图2.5。

周立功

图2.5  按内存对齐的方式存储int数据

由于int类型数据存放在块0中,因此CPU仅需一次内存访问即可完成对该数据的读取或写入。反之,如果将该int类型数据存放在地址1开始的4字节内存空间中,其示意图详见图2.6。

周立功

图2.6  按内存未对齐的方式存储int数据

此时,数据存放在块0和块1两个块中,若要完成对该数据的访问,必须经过两次内存访问,先通过访问块0得到该数据的3个字节,再通过访问块1得到该数据的1个字节,最后通过运算,将这几个字节合并为一个完整的int型数据。由此可见,若数据存储在未对齐的内存空间中,将大大降低CPU的效率。但在某些特定的微处理器中,它根本不愿意干这种事情,这种情况下,就出现系统异常,直接崩溃了。内存对齐的具体规则如下:

(1)结构体各个成员变量的内存空间的首地址必须是“对齐系数”和“变量实际长度”中较小者的整数倍。假设要求变量的内存空间按照4字节对齐,则内存空间的首地址必须是4的整数倍,满足条件的地址有0x0、0x4、0x8、0xC……

(2)对于结构体,在其各个数据成员都完成对齐后,结构体本身也需要对齐,即结构体占用的总大小应该为“对齐系数”和“最大数据成员长度” 中较小值的整数倍。

一般来说,对齐系数与微处理器的字长相同,比如,32位微处理器的对齐系数是4字节,变量的实际长度与其类型相关,计算类型长度的方法如下:

周立功

该程序的输出为:1、4、4、4、8。假定CPU为32位微处理器,对齐系数为4,结构体变量data的定义如下:

周立功

结构体的各个成员都是从结构体首地址(其由编译器保证必然满足内存对齐的要求,假定为0)开始计算,按照定义的顺序依次存放各个成员,详见表2.1。

表2.1  依次存放各个成员

周立功

实际存放位置使用[x,y]表示,x表示起始地址,y表示结束地址。如果x与y相等,则直接使用[x]表示。以成员b为例,其长度为2,小于对齐系数,因此按照2字节对齐,就要求其地址必须是2的倍数,地址0已经被成员a占用,则只能使用满足要求的邻近的内存空间[2,3]存放成员b。而空间[1]由于不满足存放成员b的要求,则只能被弃用。特别地,对于数组成员c,存放时不能将其看作一个整体,即长度为2的成员,应该分别看作两个成员c[0]和c[1]。由此可见,实际存放位置为[0,24],1、6、7、17、18、19部分内存空间被弃用。

当所有成员存放完毕后,则结构体本身也需要对齐,即结构体的大小也应该为对齐字节数的整数倍,对齐字节数取长度最长的成员和“对齐系数”的较小值。在这里,其长度最长的成员为double类型的成员d,其长度为8,大于对齐系数,因此结构体本身也要按照4字节对齐,其占用的空间大小必须是4的整数倍。虽然当前存放位置为[0,24],只占用了25个字节。由于必须满足4的整数倍,因此实际上结构体占用的空间是28个字节,即[0,27]。验证结构体占用空间大小的方法如下:

周立功

虽然所有成员的总长度为19个字节,但结构体实际占用了28个字节,多余的9个字节空间为内存对齐弃用的空间,即1、6、7、17、18、19、25、26、27,分为4个段:[1],[6,7],[17,19],[25,27]。查看表2.1可知,这些浪费空间的前面,存放的都是char型数据,由于char型数据只占用一个字节,往往使得其紧接着的空间不能被其它长度更长的数据使用。

为了降低内存浪费的概率,应该在char型数据之后,存放长度最小的成员。即在定义结构体时,应按照长度递增的顺序依次定义各个成员。优化示例结构体的定义如下:

周立功

类似地,依次存放各个成员,详见表2.2。

表2.2  依次存放各个成员

周立功

所有成员实际存放位置为[0,19],中间的地址为5的内存空间被弃用。由于结构体占用的大小为20个字节,已经是4的整数倍,因此无需再做额外的处理。结构体只浪费了1个字节空间,使用率达到95%。显然,通过优化结构体成员的定义顺序,在同样满足内存对齐的要求下,可以大大地减少内存的浪费。

>>> 2.2.2 基本数据类型

1. 范围值校验

如果有min≤value≤max,则check()范围值校验函数需要3个int型参数value、min和max。如果value合法,则返回true,否则返回false,详见程序清单 2.10。

程序清单 2.10 rangeCheck()范围值校验函数的实现(1)

周立功

  • 代码整洁之道

rangeCheck是一个非常具有描述性的名字,因为它较好地描述了函数要做的事,所以好名字的价值怎么评价都不过分。如果每个示例都让你感到深合己意,那就是整洁代码。函数越短小,功能越集中,就越容易取一个好名字。名字长一些并不可怕,长而具有描述性的名字,比短而令人费解的名字更好。选择具有描述性的名字能帮助程序员理清模块的设计思路,追索好名字往往会使代码重构得更好。

从代码整洁之道的角度来看,最理想的函数参数个数是0(零参数函数),其次是单参数函数,再次是双参数函数,因尽量避免三参数函数。如果需要三个以上的参数,需要有足够的理由,否则无论如何也不要这样做,因为参数带有太多的概念性。

从测试的角度来看,参数甚至更叫人感到为难,因为编写确保参数的各种组合运行正常的测试用例,且测试覆盖所有可能值的组合是令人生畏的事情。输出参数比输入参数还要难以理解,因为人们习惯性地认为,信息通过参数输入函数,通过返回值从函数中输出,输出参数往往让人苦思之后才会觉得恍然大悟。如果函数看起来需要两个、三个或三个以上的参数,说明其中的一些参数就应该封装为结构体类。比如:

周立功

由此可见,减少函数参数的最佳方法是一个函数只做一件事,“函数要么做什么事,要么回答什么事!”两者不可兼得。函数应该修改某个对象的状态,或返回该对象的有关信息,两样都干常常会出现混乱。

2. 类型与变量

由于有了结构体,因此可以将rangeCheck()的形参min和max转移到结构体中,不仅减少了一个形参,而且处理起来更方便。比如:

周立功

该声明描述了一个由两个int类型变量组成的结构体,不仅创建了实际数据的对象range,而且描述了该对象是由什么组成的,因为它勾勒出了结构体是如何存储数据的。显然,range是struct _Range类型的结构体变量,如果在该结构体定义前添加typedef:

周立功

此时,range就变成了该结构体的类型,即range等同于struct _Range。习惯的写法是将类型名的首字符大写,将变量名的首字符小写。有了Range类型,即可同时定义一个Range类型的变量range和一个指向Range *类型的指针变量pRange,当然也可以省略类型名_Range。比如:

周立功

注意,结构体有两层含义,一层含义是“结构体布局”,结构体布局告诉编译器是如何表示数据的,但它并未让编译器为数据分配空间。下一步是创建一个结构体变量,即结构体的另一层含义,其定义如下:

周立功

编译器执行这行代码便创建了一个结构体变量range,编译器使用Range为该变量分配空间:一个int类型的变量min和一个int类型的变量max,这些存储空间都与一个名称range结合在一起。

3. 初始化

假设value值的有效范围为0~9,在这里可以使用名为newRangeCheck的宏方便地将结构体初始化。比如:

周立功

使用方法如下:

周立功

宏展开后如下:

周立功

其相当于:

周立功

从本质上来看,.min和.max的作用相当于Range结构体的下标。虽然Range是一个结构体,但range.min和range.max都是int类型的变量,因此可以象使用其它int类型变量那样使用它,比如,&(range.min)。

由此可见,如果初始化一个静态存储期的结构体,初始化列表中的值必须是常量表达式。如果是自动存储期,初始化列表中的值可以不是常量。

4. 接口与实现

(1)传递结构体成员

只要结构体成员是一个具有单个值的数据类型,比如,int、char、float、double或指针,便可将它作为参数传递给接受该特定类型的函数,rangeCheck()的实现详见程序清单 2.11。

程序清单 2.11 rangeCheck()函数的实现(2)

周立功

其调用形式如下:

周立功

rangeCheck()既不知道也不关心实参是否是结构体的成员,它只要求传入的数据是int类型。如果需要在被调函数中修改主调函数中成员的值,就要传递成员的地址。

(2)传递结构体

虽然传递一个结构体比一个单独的值复杂,但标准C同样允许将结构体作为参数使用,rangeCheck()函数的实现详见程序清单 2.11。

程序清单 2.12 rangeCheck()函数的实现(3)

周立功

其调用形式如下:

周立功

虽然通过这种方法能够得到正确的结果,但它的效率很低,因为C语言的参数传址调用方式要求将参数的一份拷贝传递给函数。假设结构体的成员是一个占用128字节的数组,甚至更大的数组。如果要将它作为参数进行传递,则必须将所占用的字节数复制到堆栈中,以后再丢弃。

(3)传递结构体的地址

假设有一组这样的数据,存储在结构体成员数组中。其数据结构如下:

周立功

显然,只要将结构体的地址(int *)&st作为实参传递给iMax()的形参,即可求出数组中元素的最大值,详见程序清单 2.13。

程序清单 2.13 求数组中元素的最大值范例程序

周立功

下面还是以范围值校验器为例,定义一个指向该结构体的指针变量pRange,其初始化、赋值与普通指针变量是一样的:

周立功

和数组不一样,结构名并不是结构体的地址,因此要在结构名前加上&运算符,因此这里的pRange为指向Range结构体变量range的指针变量。虽然pRange、&range和&range.min的类型不一样,但它们的值相等,那么下面的关系恒成立:

周立功

由于.运算符比*运算符的优先级高,因此必须使用圆括号。这里着重理解pRange是一个指针,pRange->min表示pRange指向结构体的首成员,所以pRange->min是一个int类型的变量,rangeCheck()函数的实现详见程序清单 2.14。

程序清单 2.14 rangeCheck()函数的实现(4)

周立功

rangeCheck()使用指向Range的指针pRange作为它的参数,将地址&range传递给该函数,使得指针pRange指向range,然后通过->运算符获取range.min和range.max的值。注意,必须使用&运算符获取结构体的地址,和数组名不同,结构体名只是其地址的别名。

其调用形式如下:

周立功

(4)用函数指针调用

如果需要增加一个奇偶校验器对value值进行偶校验,其数据结构如下:

周立功

oddEvenCheck()函数的实现详见程序清单 2.15。

程序清单 2.15 oddEvenCheck()函数的实现

周立功

当系统需要多个校验器后,在运行时调用者将根据实际情况决定调用哪个函数,根据依赖倒置原则,最好的方法是用函数指针隔离变化。无论什么校验器,其相同的处理部分是value值的合法性判断,因此将其抽象为模块。而可变的是value值和校验参数,由外部传入的参数应对。由于各种校验器的类型不一样,因此必须使用“void *pData”作为形参才能接受任意类型的数据,即将Range *pRange和OddEven *pOddEven泛化成了void *pData。Validate类型的定义如下:

周立功

其中,pData为指向任意校验器参数的指针,value为待校验的值,通用校验器的接口详见程序清单 2.16。

程序清单 2.16 通用校验器接口(validator.h)

周立功

以范围值校验器为例,其调用形式如下:

周立功

这次传递给函数的是一个指向结构体的指针,指针比整个结构体要小得多,所以将它压到堆栈上的效率要高很多,validator接口的实现详见程序清单 2.17。

程序清单 2.17 validator接口的实现(validator.c)

周立功

由于pRange、pOddEven与pData的类型不同,因此需要对pData强制类型转换,才能引用相应结构体的成员。注意,在这里,作者并没有提供完整的代码,请读者补充完善。


打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分