目录
前言
本章介绍了一些数据类型——包括数组、结构体、联合、位断、枚举,其中前三节是笔者学习csapp的学习笔记,为更详细讲解自定义类型,加入了后两节的内容
1. 数组的分配与访问
1.1 基本原则
C语言中的数组是一种将标量数据聚集成更大数据类型的方式,规定数组是一组相同类型元素的集合
对于数据类型T 和整型常数N ,声明如下:
T A[ N ]
这里的类型可以是int、char、float......等等,声明之后代表数组内的数据只能为所声明的类型。
这个声明有两个效果:
- 在内存中分配了一个L·N 字节的连续区域 (L为数据类型T的大小)
- 引入了标识符A,作为指向数组开头的指针,指针的值为xa
如 char A[8] 和 int B[4] 分别声明了一个含8个char 类型的数组A和一个含4个int 类型数据的数组B——内存中的大小分别为1 * 8 = 8 字节和 4 * 4 = 16 个字节
1.2 指针运算
在C语言中,允许对指针进行运算 ,如果我们声明一个char类型的指针char* p和一个int类型的指针int* q,假设指针p和q指向同一个内存地址
- 对指针p加1的操作使得p指向0x101处
- 对指针q加1的操作使得q指向0x104处
虽然都是对指针进行加1运算,得到的结果却不同,这是因为对指针进行运算时,计算结果会根据该指针引用的数据类型的大小进行伸缩——int*类型指针一次向后访问四字节,char*类型指针一次向后访问一字节
假设定义一个数组 int E[6]
对于数组的每个元素都有两个属性——
- 存储的内容
- 存储地址
通常我们使用数组引用的方式来访问数组中的元素,如E[2]可以访问数组第二个元素e2
数组引用E[2]等价于*(E + 2),其中表达式E + 2表示数组第二个元素的存储地址,然后访问这个内存位置,这里的加2与指针加2类似,也是数据类型相关
假设数组E的起始地址和整数索引i分别放在寄存器 %rdx 和 %rcx 中,而结果保存在 %eax(数据)或 %rax (地址)中,那么对数组引用的相关表达式的汇编代码实现如下表
表达式 | 类型 | 值 | 汇编代码 |
E | int* | xE | movq %rdx, %rax |
E[0] | int | M[xE] | movl (%rdx), %eax |
E[i] | int | M[xE + 4] | movl (%rdx, %rcx, 4), %eax |
&E[2] | int* | xE + 8 | leaq 8(%rdx), %rax |
E + i - 1 | int* | xE + 4*i - 4 | leaq -4(%rdx, %rcx, 4), %rax |
*(E + i - 3) | int | M[xE + 4*i -12] | movl -4(%rdx, %rcx, 4), %eax |
&E[i] - E | long | i | movq %rcx, %rax |
1.3 嵌套的数组
嵌套数组就是二维数组
图中声明了一个二维数组A,int A[5][3],是一个5行3列的二维数组
对于二维数组的声明,int A[5][3]等价于
typedef int row3_t[3];
row3_t A[5];
我们可以将二维数组看成数据类型是一维数组的一维数组,这意味着这个一维数组的每一个元素都是一个一维数组,在这里将A看成一个有5个元素的数组,每一个元素都是一个长度为3的数组
二维数组中元素地址的计算和一维数组类似,对于数组
T D[R][C]
其元素D[i][j]的内存地址为:
&
其中,xD表示数组的起始地址,L表示数据类型T的大小,根据这个公式,对于任意一个5×3的数组A[5][3]中任意元素地址为:
&
假设A在寄存器%rdi中,i在寄存器%rsi中,j在%rdx中,可以用以下汇编代码将数组元素A[i][j]复制到寄存器%eax中
leaq (%rsi, %rsi, 2), %rax // 计算 3i
leaq (%rdi, %rax, 4), %rax // 计算 xA + 12i
movl (%rax, %rdx, 4), %eax // 从M[xA + 12i + 4j]中读数据
1.4 定长数组
C语言编译器能够对于定长的多维数组进行优化
首先使用下面的方式声明一个数据类型为fix_matrix的16*16的数组
#define N 16
typedef int fix_matrix[N][N];
当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过 #define 声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值。这样一来,如果需要修改这个值,只用简单地修改这个 #defien 声明就可以了,这是一种很好的编码习惯
我们用下面的代码来计算矩阵中A中的第i行和矩阵B的第k列的乘积
#define N 16
typedef int fix_matrix[N][N];
int matrix(fix_matrix A, fix_matrix B, long i, long k)
{
long j;
int result = 0;
for (j = 0; j < N;j++)
result += A[i][j] * B[j][k];
return result;
}
注:为方便展示,矩阵下标与代码下标并不一致
经编译器优化后的汇编为代码:
matrix:
salq $6, %rdx;
addq %rdx, %rdi;
leaq (%rsi, %rcx, 4), %rcx;
leqa 1024(%rcx), %rsi;
movl $0, %eax;
.L7:
movl (%rdi), %edx;
imull (%rcx), %edx;
addl %edx, %eax;
addq $4, %rdi;
addq $64, %rcx;
cmpq %rsi, %rcx;
jne .L7
rep; ret
在进行循环前,编译器先计算了三个指针,分别是Aptr、Bptr和Bend,分别指向A数组第i行的首元素、B数组第k列的首元素和B数组第k列的最后一个元素后一个内存空间,并将这三个指针放到不同的寄存器中,具体如图
具体的C代码为
int matrix(fix_matrix A, fix_matrix B, long i, long k)
{
int* Aptr = &A[i][0];
int* Bptr = &B[0][k];
int* Bend = &B[N][K];
int result = 0;
do {
result += *Aptr * *Bptr;
Aptr++;
Bptr += N;
} while (Bptr != Bend);
return result;
}
优化后,
- 先读取Aptr指向元素的内容
- 然后将指针Aptr指向的元素与Bptr指向的元素相乘
- 将乘积结果累计并保存到eax中
- 计算完成后,分别移动指针Aptr和Bptr指向下一个元素(这里Aptr移4动个字节,Bptr移动64个字节)
- 直到 Bptr = Bend
这种优化明显提高了程序的效率,在这个优化中
- 去掉了整数索引 j
- 将所有的数组引用转换成了指针间接引用
1.5 变长数组
历史上,C语言只支持在编译时就能确定的多维数组,如果想要使用变长数组则需要用到内存分配函数。ISO C99引入了一种功能,允许数组的维度是表达式。在变长数组的C版本中,一个变长数组的声明可以为:
int A [ expr1 ][ expr2 ]
int var_ele(long n, int A[n][n], long i, long j) {
return A[i][j];
}
它可以作为一个局部变量,也可以作为函数的参数(在这种情况下,参数n必须在数组A之前)
变长数组的地址计算与定长数组类似,不同的是他引入了参数n,需要用乘法指令来计算n*i
2. 结构体
2.1 结构体声明
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
一个结构体的声明
struct tag
{
member - list;
}variable - list;
2.2 结构体的自引用
我们知道函数内部可以包含它自身,实现递归。那么结构体成员能否包含该结构体本身呢?
考虑下面的声明
struct Node
{
int data;
struct Node next;
};
由于结构体并不像函数那样可以返回,因此结构体自引用的结果是不断地消耗内存,正确的声明应该是
struct Node
{
int data;
struct Node* next;
};
使用结构体类型的指针能够很好的实现结构体之间的引用关系
2.3 结构体访存
为了清晰地研究结构体,考虑下面的结构体声明
struct rec
{
int i;
int j;
int a[2];
int* p;
};
这个结构体包含四个部分,分别是两个4字节d饿整型变量,由两个4字节整型组成的数组,一个8字节的整型指针变量,它们相对于结构体起始位置的偏移为
对于结构体元素的访问,也是通过地址+偏移量来实现的,例如,假设将结构体指针r放在寄存器%rdi中,将 i 复制到 j 中的汇编代码为
movl (%rdi), %eax; //从%rdi中读出i
movl %eax, 4(%rdi) //将i放到j中
2.4 结构体变量的初始化
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1
struct Point p2; //定义结构体变量p2
//初始化:定义变量的同时赋初值。
struct Point p3 = { x, y };
struct Stu //类型声明
{
char name[15];//名字
int age; //年龄
};
struct Stu s = { "zhangsan", 20 };//初始化
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = { 10, {4,5}, NULL }; //结构体嵌套初始化
struct Node n2 = { 20, {5, 6}, NULL };//结构体嵌套初始化
2.5 结构体内存对齐
首先阐明结构体内存对齐的规则
- 第一个成员在与结构体变量偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处,对齐数 = 编译器默认的一个对齐数与该成员大小的较小值
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
考虑下面的结构体声明
struct S1 {
int i;
char c;
int j;
};
一般我们会认为结构体的大小为9字节,但我们用sizeof求得的结果却是12
原因是为了提高内存系统的性能,系统对于数据存储的合法地址做出了限制
例如,变量 j 是 int 类型,占4个字节,因此它的起始地址必须是4的倍数
因此,编译器会在变量 c 和变量 j 之间插入3个字节的间隙,这样,变量 j 相对于起始位置的偏移量为8,满足4的倍数
结构体内每个元素都需要满足这样的对齐原则,除此之外,结构体的尾部也可能需要进行填充,考虑下面的声明
struct S2 {
int i;
int j;
char c;
};
虽然这样的排列方式能够满足所有结构体成员的对齐要求,但是,如果我们声明了一个结构体数组——那么显然,数组内的第二个结构体就不满足对齐的要求了,因为第二个结构体的第一个成员的偏移量是9,而对齐数是4,因此真正的偏移量应该为 9 + 3 = 12,编译器会在结构体的末尾分配3字节的间隙
2.6 修改默认对齐数
之前我们见过了 #pragma 这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数。
#pragma pack(8)//设置默认对齐数为8
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
3. 联合
3.1 联合的定义
联合也是一种特殊的自定义类型
这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)
3.2 联合的大小
- 联合的大小至少是最大成员的大小
- 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍
考虑下面的声明
union Un1
{
char c[5];
int i;
};
union Un2
{
short c[7];
int i;
};
- 对于Un1,最大对齐数是4(数组的对齐数是其数据类型的对齐数,在这里是1),而char c[5]需要五个字节,因此需要对齐到8
- 对于Un2,最大对齐数是4,而short c[7]需要14个字节,因此需要对齐到8
4. 位断
4.1 位断的声明
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是 int、unsigned int 或 signed int
- 位段的成员名后边有一个冒号和一个数字
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
4.2 位断的内存分配
同样考虑上述的声明,通过sizeof计算得到位断的大小是8字节原因是:
- 首先分配一个整型的长度——4个字节(32个比特位)
- 数字代表着所分配的比特位,int_a : 2,说明给_a分配了2个位长度
- int_b : 5 给b分配了5个比特位,a,b加起来7个比特位
- int_c :10,abc加起来17个比特位(不满4字节),剩15个比特位,不够_d
- 再分配一个比特位,用来存_d,因此总共2个int,8个字节
考虑下面的代码:
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = { 0 };
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
return 0;
}
对于这样一个例子,
4.3 位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的
5. 枚举
5.1 枚举定义
考虑下面的声明
enum Day//星期
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex//性别
{
MALE,
FEMALE,
SECRET
};
enum Color//颜色
{
RED,
GREEN,
BLUE
};
以上定义的 enum Day , enum Sex , enum Color 都是枚举类型,{}中的内容是枚举类型的可能取值,也叫 枚举常量,这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值,例如
enum Color//颜色
{
RED = 1,
GREEN = 2,
BLUE = 4
};
5.2 枚举的优点
- 增加代码的可读性和可维护性
- 和#define定义的标识符比较枚举有类型检查,更加严谨
- 防止了命名污染(封装)
- 便于调试
- 使用方便,一次可以定义多个常量
5.3 枚举的使用
enum Color//颜色
{
RED = 1,
GREEN = 2,
BLUE = 4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。