目录
3.8 数组的分配和访问
3.8.1 基本原则(一维)
对于数据类型 T 和整型常数 N,声明如下:T A[n];
起始位置表示为
x
A
x_A
xA,数据类型 T 的大小为 L;
它在内存中分配一个
L
⋅
N
L·N
L⋅N 字节的连续区域;
引入标识符 A ,可以用 A 来作为指向数组开头的指针,这个指针的值就是
x
A
x_A
xA;
可以用 0~N-1 的整数索引来访问该数组元素;
数组元素 i 会被存放在地址为
x
A
+
L
⋅
i
x_A+L·i
xA+L⋅i 的地方。
x86-64 的内存引用指令 movl (%rdx,%rcx,4), %eax
可以用来简化数组访问
A
[
i
]
A[i]
A[i]:
执行地址计算
x
A
+
4
i
x_A+4i
xA+4i,读取内存位置的值,并将结果存放到寄存器 %eax
中。
3.8.2 指针运算
C 语言允许指针进行运算。
计算的值会根据该指针引用的数据类型的大小进行伸缩。
🌰如果 p 是 T 类型的指针,L 是 T 类型的大小,若 p 的值为
x
p
x_p
xp ,那么表达式
p
+
i
p+i
p+i 的值为
x
p
+
L
⋅
i
x_p+L·i
xp+L⋅i。
数组引用 A [ i ] A[i] A[i] 等同于表达式 ∗ ( A + i ) *(A+i) ∗(A+i),其计算第 i i i 个数组元素的地址,然后访问这个内存位置。
【常见表达式,要理解】
表达式 | 类型 | 值 | 汇编代码 |
---|---|---|---|
E | int * | $x $ | movl %rdx, %rax |
E[0] | int | $M[x] $ | movl (%rdx), %eax |
E[i] | int | $M[x + 4i] $ | movl (%rdx,%rcx,4), %eax |
&E[2] | int * | $x +8 $ | leaq 8(%rdx), %rax |
E+i–1 | int * | $x + 4i – 4 $ | leaq -4(%rdx,%rcx,4), %rax |
*(E+i–3) | int | M [ x + 4 i – 12 ] M[x + 4i – 12] M[x+4i–12] | movl –12(%rdx,%rcx,4), %eax |
&E[i]–E | long | $i $ | movq %rcx, %rax |
👆最后一个🌰表面:可以计算同一个数据结构中的两个指针之差,结果的数据类型为 long ,值等于两个地址之差除以该数据类型的大小。
——即:地址之差算的是以数据类型为单位的计数值,而不是内存字节差值。
3.8.3 理解指针
指针是存在于 C 语言层面的一个抽象概念,机器级代码就是一串连续的字节序列。
——指针和它们映射到机器代码的关键原则:
(1)每个指针都对应一个类型;
如果对象类型为 T
,那么指针的类型为 T *
;
void *
表示通用指针;
(2)每个指针都有一个值;
一般值:某个指定类型的对象的地址;
特殊值:NULL,表示指针暂未指向任何对象。
(3)指针用 &
运算符创建;
&
运算符可以应用到任何左值的 C 表达式;
&
运算符的机器代码实现常常使用 leaq
指令来计算表达式的值。
(4)*
操作符用于间接引用指针;
(5)数组与指针紧密联系;
一个数组的名字可以像一个指针变量一样引用(但是不能对其进行修改);
数组引用和指针运算都需要用对象大小对偏移量进行伸缩。
(6)将指针从一种类型强制转换成另一种类型,改变的只是它的类型,而不会改变其值;
强制转换指针的类型的一个效果,是改变指针运算的伸缩。
注意强制类型转换的优先级高于加法。
如果 p
是一个 short*
类型的指针,其值为
p
0
p_0
p0
(int*)p+7
计算为
p
0
+
27
p_0+27
p0+27;
(int*)(p+7)
计算为
p
0
+
14
p_0+14
p0+14;
(7)指针也可以指向函数。
🌰函数指针的值是所指向函数机器代码表示中第一条指令的地址。
// 有一个函数:
int fun(int x, int *p);
// 声明指针fp,将这个函数的首地址赋值给fp,代码如下:
int (*fp)(int, int *);
fp = fun;
// 然后用这个指针来调用这个函数:
int y = 1;
int result = fp(3, &y);
3.8.4 内存越界引用和缓冲区溢出
C 语言对于数组引用不进行任何边界检查;
局部变量和状态信息都存放在栈中。
——这两种情况结合在一起,就容易导致严重的错误:对越界的数组元素的写操作会破坏存储在栈中的状态信息。
1、缓冲区溢出
——(buffer overflow)常见的一种破坏
表现:通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
【举个🌰】
函数 gets
的问题在于:无法确定是否为保存整个字符串分配了足够的空间。
/* 库函数 gets */
char * gets(char *s)
{
int c;
char *dest = s;
while ((c = getchar()) != `n' && c != EOF)
*dest++ = c;
if (c == EOF && dest == s)
return NULL; /* No characters read */
*dest++ = '0'; /* Terminate string */
return s;
}
/* Read input line and write it back */
void echo()
{
char buf[8]; /* Way too small! */
gets(buf);
puts(buf);
}
# void echo()
echo:
subq $24, %rsp # Allocate 24 bytes on stack
movq %rsp, %rdi # Compute buf as %rsp
call gets # Call gets
movq %rsp, %rdi # Compute buf as %rsp
call puts # Call puts
addq $24, %rsp # Deallocate stack space
ret # Return
👇图画出了函数 echo
运行时栈的组织:
该程序把栈指针减去了 24 ,在栈上分配了 24 个字节作为该函数的栈帧(编译器预先确定的固定长度的栈帧)。
字符数组 buf
位于栈顶,可以看到👇%rsp
被复制到 %rdi
作为调用 gets
和 puts
的参数;
【注意】调用者的栈帧的返回地址和 buf
之间有 16 个未被使用的栈空间——这是预先确定固定栈帧导致的空间浪费,不同机器上情况不同,不必纠结;
![image-20211212012603901](../../../AppData/Roaming/Typora/typora-user-images/image-20211212012603901.png)
只要用户输入不超过 7 个字符,gets
返回的字符串(包括结尾的 ‘\0’
)就能够放进为 buf
分配的空间里。在本🌰中,似乎用户可以输入不超过 23 个字符呢!(但是在编程中,一定不要妄想也会有多余的字符,不要有侥幸心理!!!这里要认为超过了 7 个字符,就是严重错误!!!)
试想:如果本🌰中,用户输入了超过 23 个字符,就把“返回地址”给覆盖掉了!!!
【补充】很多常用的库函数,包括 strcpy
、strcat
、sprintf
都有一个属性——不需要告诉它们目标缓冲区的大小,就产生了一个字节序列,这就很容易导致缓冲区溢出漏洞。
2、缓冲区溢出的致命使用
——让程序跳转去执行“攻击代码”
3、对抗缓冲区溢出攻击方法
(1)栈随机化
思想:使得栈的位置在程序每次运行时都有变化。
已有技术:地址空间布局随机化(ASLR)——每次运行时程序的不同部分(代码、库代码、栈、全局变量、堆)会被加载到内存的不同区域:意味着在不同机器上运行的程序,它们在内存中的地址映射大相径庭。
(2)栈破坏检测
加入栈保护者机制,检测缓冲区越界,其思想:在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,在回复寄存器状态和从函数返回前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了,如果被改变了,则程序异常终止。
(3)限制可执行代码区域
消除攻击者向系统中插入可执行代码的能力——限制一些内存页可读但不可执行。
“NX”(No-Execute,不执行)位,将读和执行访问模式分开,解决了限制可读但是不可执行带来的性能损失。
【总结】上述方法只能降低缓冲区溢出带来影响。不可能完全避免。
3.8.4 嵌套的数组(多维)
数组的元素也可以是数组,这样就形成了嵌套的数组:二维数组、三维数组。
数组元素在内存中的排列原则:“行优先”。
🌰:对于
A
[
2
]
[
3
]
A[2][3]
A[2][3] ,其在内存从低地址到高地址依次是:
A
[
0
]
[
0
]
、
A
[
0
]
[
1
]
、
A
[
0
]
[
2
]
、
A
[
1
]
[
0
]
、
A
[
1
]
[
1
]
、
A
[
1
]
[
2
]
A[0][0]、A[0][1]、A[0][2]、A[1][0]、A[1][1]、A[1][2]
A[0][0]、A[0][1]、A[0][2]、A[1][0]、A[1][1]、A[1][2]。
多维数组元素的访问,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用 MOV 指令。
对于数组:T D[R][C];
,T 类型的大小为 L。
数组元素 D[i][j]
的内存地址为
&
D
[
i
]
[
j
]
=
x
D
+
L
(
C
⋅
i
+
j
)
\&D[i][j]=x_D+L(C·i+j)
&D[i][j]=xD+L(C⋅i+j)
# 定义int D[5][3]
leaq (%rsi,%rsi,2), %rax # Compute 3i
leaq (%rdi,%rax,4), %rax # Compute xD+12i
movl (%rax,%rdx,4), %eax # Read from M[xD+12i+4j]
3.8.5 定长数组
C 语言编译器能够优化定长多维数组上的操作代码。
这里展示优化等级为 -O1
时 GCC 采用的一些优化。|
#define N 16
typedef int fix_matrix[N][N];
/* 计算矩阵A和B乘积的元素 */
int fix_prod_ele (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;
}
# int fix_prod_ele_opt(fix_matrix A, fix_matrix B, long i,long k)
# A in %rdi, B in %rsi, i in %rdx, k in %rcx
# %eax保存result,%rdi保存Aptr,%rcx保存Bptr,%rsi保存Bend
fix_prod_ele:
salq $6, %rdx # Compute 64 * i
addq %rdx, %rdi # Compute Aptr = x + 64i = &A[i][0]
leaq (%rsi,%rcx,4), %rcx # Compute Bptr = x + 4k = &B[0][k]
leaq 1024(%rcx), %rsi # Compute Bend = x + 4k + 1024 = &B[N][k]
movl $0, %eax # Set result = 0
.L7: # loop:
movl (%rdi), %edx # Read *Aptr
imull (%rcx), %edx # Multiply by *Bptr
addl %edx, %eax # Add to result
addq $4, %rdi # Increment Aptr ++
addq $64, %rcx # Increment Bptr += N
cmpq %rsi, %rcx # Compare Bptr:Bend
jne .L7 # If !=, goto loop
rep; ret # Return
优化:
(1)生成一个指针,命名为 Aptr
,指向 A 的行 i 中连续的元素;
(2)生成一个指针,命名为 Bptr
,指向 B 的列 k 中连续的元素;
(3)生产一个指针,命名为 Bend
,其值是假想中的 B 的列 j 的第 (n+1) 个元素的地址,由 C 表达式 &B[N][k]
给出。
/* 计算矩阵A和B乘积的元素——优化后的C代码 */
int fix_prod_ele_opt(fix_matrix A, fix_matrix B, long i,long k)
{
int *Aptr = &A[i][0]; /* Points to elements in rowi of A */
int *Bptr = &B[0][k]; /* Points to elements in column k of B */
int *Bend = &B[N][k]; /* Marks stopping point for Bptr */
int result = 0;
do { /* No need for initial test */
result += *Aptr * *Bptr; /* Add next product to sum */
Aptr ++; /* Move Aptr to next column */
Bptr += N; /* Move Bptr to next row */
} while (Bptr != Bend); /* Test for stopping point */
return result;
}
3.8.6 变长数组
历史上,C 语言只支持大小在编译时就能确定的数组,因此变长数组的实现不得不使用 alloc 系列的函数进行动态的内存申请。
C99 引入新功能,允许数组的维度是表达式——在数组被分配的时候才被计算出来。
int A[expr1][expr2];
可以作为一个局部变量;
也可以作为一个函数的参数。
/* 访问n×n数组的元素 */
int var_ele(long n, int A[n][n], long i, long j) {
return A[i][j];
}// 参数n必须在参数A[n][n]之前,这样函数就可以在遇到这个数组的时候计算出数组的维度。
# int var_ele(long n, int A[n][n], long i, long j)
# n in %rdi, A in %rsi, i in %rdx, j in %rcx
var_ele:
imulq %rdx, %rdi # Compute n·i
leaq (%rsi,%rdi,4), %rax # Compute x+4(n·i)
movl (%rax,%rcx,4), %eax # Read from M[x+4(n·i)+4j]
ret
计算元素 A[i][j]
的地址为:
x
A
+
4
(
n
⋅
i
)
+
4
j
=
x
A
+
4
(
n
⋅
i
+
j
)
x_A+4(n·i)+4j=x_A+4(n·i+j)
xA+4(n⋅i)+4j=xA+4(n⋅i+j)
相比于定长数组的元素的地址计算方式,区别有下:
(1)由于增加了参数 n,寄存器的使用变化了;
(2)用了乘法指令来计算 n·i ,而不是用 leaq
指令来计算 3i。——动态的版本必须用乘法指令对 i 伸缩 n 倍,而不能用一系列的移位和加法。为啥?因为 leaq
中的立即数必须是常数。