C|数据存储地址与字节偏移、数据索引

话说C是面向内存的编程语言 。数据要能存得进去,取得出来,且要考虑效率 。不管是顺序存储还是链式存储,其寻址方式总是很重要 。顺序存储是连续存储 。同质结构的数组通过其索引表示位置偏移,异质结构的结构体通过其成员名(字段名)的类型大小及对齐方式来计算字节偏移 。链式存储通过一个额外的指针(地址)作为数据成员来指示其相邻节点的地址信息 。
1 数组以数组名和索引提供对数据元素的引用数组是对同质元素的连续存储,是许多结构数据结构的基础 。
int i = 0;int j = 0;int a[12] = {0};a[i] = 1; //a[i]相当于a+i,编译器维护a的元素的类型信息,偏移i个元素长度int b[3][5] = {0};b[i][j] = 1; //b[i][j] 相当于*(*(b+i)+j),编译器维护b的元素及元素的类型信息,// 偏移i个元素长度,j个元素的元素的长度内数组名与运算符“&”、“sizeof”被编译器解释为整个数组,其它情况被解释为指针(一个指向数组首元素的地址),这是为将数组名用做函数实参准备的语法机制:
int arr[i][j][k][……];int (*arp)[j][k][……] = arr;//利用arp做形参,便可以传递arr做实参// 函数体内对指针参数的解引用便是对指针指向对象的操作void foo(int (*arp)[j][k][……], int i)// 便可在函数体内用做左值或右值{*(*(*(*(arp+i)+j)+k)+……); //注意arp的类型是int[j][k][……],arp+i时便可以获得正常的偏移}C语言对于数组引用不进行任何边界检查 。
对于局部数组,函数的局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中 。这两种情况结合到一起就能导致严重的栈错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息(向低地址区溢出) 。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误 。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow) 。
对于全局数组,其溢出操作同样有不可预见的后果 。
2 结构体利用结构体变量名和成员对象名提供地址偏移来访问元素C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中 。用名字来引用结构的各个组成部分 。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址 。编译器维护关于每个结构类型的信息,指示每个字段(fileld)的字节偏移 。以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用 。
void foo(){struct Stru{char ch;// ch要对齐到s,否则s需要两次读取short s;// s要对齐到i,否则i需要两次读取int i;// i要对齐到d,否则i需要两次读取double d;};// 结构体整体也要对齐到一个字长Stru stru;printf("%dn",sizeof(stru)); // 16printf("%dn",int(&stru.i)-int(&stru)); // 4}结构体偏移时会考虑内存对齐,以实现更好的引用效率(避免更多次的引用,因为CPU会一次读取一个字长长度的数据(sizeof(int)或sizeofof(void*)) 。
如果将更大的数据类型靠前放则可以减少对后续元素读取的影响,从而节省内存空间:

C|数据存储地址与字节偏移、数据索引

文章插图
 
代码示例:
void foo(){struct S4{char c;// c要对齐到i,否则i需要两次读取int i;// i要对齐到d,否则i需要两次读取char d;}s4;// 结构体整体也要对齐到一个字长printf("%dn",sizeof(s4)); // 12printf("%dn",int(&s4.i)-int(&s4)); // 4struct S5{int i;char c;char d;}s5;// 结构体整体也要对齐到一个字长printf("%dn",sizeof(s5)); // 8printf("%dn",int(&s5.c)-int(&s5)); // 4}3 switch可以被编译器实现对case语句的直接跳转编译器比你想象的要聪明,不但要做翻译,还要做优化 。例如,你写的switch语句可能会被优化为 jump table,还会消除无用的语句(Dead code elimination)等,汇编代码有时候不仅仅是C代码的直译,也就是说,编译器可以执行不同程度的优化 。
switch语句可以根据一个整数索引值进行多重分支 。通过使用跳转表(jump table)可以实现较高的效率 。跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现当开关索引值等于 i 时程序应该采取的动作 。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标 。和使用一组很长的if else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关 。编译器根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句 。当开关情况数量比较多(如4个以上),并且值的范围跨度比较小时,就会使用跳转表 。


推荐阅读