ICS 与 8086 复习 14:数组 指针 结构体与数据对齐
为什么 p+1 有时增加 4,有时增加 16?为什么结构体成员长度加起来只有 8 字节,sizeof 却可能是 12?这些题目看似各不相同,实际都在考同一件事:对象类型决定访问步长,布局规则决定实际偏移。
本文除特别说明外,采用课程中的 i386 环境:字节为 8 位,short 为 2 字节,int 和指针为 4 字节;结构体示例中它们分别按 2、4、4 字节对齐。把条件写在前面,是计算布局的必要步骤。
指针加一增加的是一个元素长度
若 int *p 指向数组元素,p+1 指向下一个 int,数值地址相差 sizeof(int)。数组第 i 个元素的地址用字节地址表示为:
地址(A[i]) = 数组首地址 + i × sizeof(元素类型)
设 short A[8] 的首址在 EDX,索引 i 在 ECX,读取 A[i] 到 AX,可表示为:
movw (%edx,%ecx,2),%ax
比例因子 2 来自元素类型,movw 来自读取宽度。若改成 int 数组,两处应相应考虑为比例 4 和 32 位读取。地址计算正确而读取宽度错误,仍然不能取得正确的对象值。
二维数组的行长必须计入步长
C 的普通多维数组按行优先存放。对 int a[3][4],每行 4 个 int,行长为 16 字节:
地址(a[i][j]) = 首地址 + (i×4+j)×4
假定首地址为 1000H,求 a[2][1]:先跳过两行,共 32 字节;再跳过本行一个元素,增加 4 字节;所以地址是 1000H+36=1024H。
这个例子还可以区分三种表达式:a+1 指向下一行,地址增加 16;&a[0][0]+1 指向第一行的第二个整数,地址增加 4;sizeof a 是整个数组大小,为 48。
多数表达式中的数组名会转换为指向首元素的指针,但数组对象本身不是指针变量。sizeof a 与 sizeof p 的区别,就是常见考点。函数形参写作 int a[] 时会调整为指针类型,也不能在函数内用 sizeof a 得到调用者整个数组的大小。
二维数组和指针数组怎样区分
int matrix[3][4]; /* 3行,每行4个int,整体连续 */
int *rows[3]; /* 3个指针,各自还要指向有效数据 */
访问 matrix[i][j],可以直接以矩阵首址加行列偏移。访问 rows[i][j],先要从指针数组中取出 rows[i],再沿该指针访问第 j 个整数;各行不必相邻。
matrix 在常见表达式中转换后的类型是“指向含 4 个 int 的数组的指针”,也就是 int (*)[4],不是 int **。地址看上去都能用十六进制打印,并不意味着类型和访问规则相同。
结构体布局按成员逐项向上对齐
考虑:
struct Record {
char a;
int b;
char c;
short d;
};
在前述 ABI 条件下,从偏移 0 开始计算:a 放在 0,占 1 字节;b 需要 4 字节对齐,下一个可用位置从 1 向上取整为 4,因此 1—3 为填充;b 占 4—7;c 放在 8;d 需要 2 字节对齐,从 9 向上取整为 10,占 10—11。
| 成员 | 起始偏移 | 占用字节 |
|---|---|---|
| a | 0 | 1 |
| b | 4 | 4 |
| c | 8 | 1 |
| d | 10 | 2 |
最大成员对齐要求为 4,结构体总长度 12 已是 4 的倍数,所以 sizeof(struct Record)=12。若 struct Record R[10] 首址为 2000H,R[3].d 地址为:
2000H + 3×12 + 10 = 202EH
数组步长使用的是完整 sizeof,包含内部和尾部填充。尾部填充的目的之一,就是保证下一元素仍能按要求对齐。
如果重新定义一个类型,把成员顺序改为 char a; char c; short d; int b;,偏移变为 0、1、2、4,大小为 8。十个元素从 120 字节变为 80 字节。这改变了类型布局,涉及外部文件格式或二进制接口时,不能只看节省空间就认为可以随意替换。
对齐不等于每次未对齐访问都会失败
计算成员布局要遵循目标 ABI 和编译器设置。#pragma pack、packed、显式 aligned 等设置会改变布局;同一个包含 double 的结构体,在不同平台也可能有不同大小。
许多 x86 指令允许未对齐访存,但跨越缓存行或页等情况可能增加代价,某些指令则有专门的对齐要求。因此不应把课程中的教学存储器模型推广成“任何不对齐的 int 都无法读取”。
用 C 检查布局时,可以采用标准的 offsetof:
#include <stddef.h>
#include <stdio.h>
printf("size=%zu, offset_b=%zu\n",
sizeof(struct Record), offsetof(struct Record, b));
这里 %zu 对应 size_t;打印实际对象地址则用 %p 并传入 void *。不要为了查偏移而真的解引用空指针。
联合体与数值转换不是一回事
联合体成员共享存储,大小还要满足其对齐要求,不能简单把所有成员长度相加。课程用联合体观察 10.0f 的位模式:在 IEEE754 binary32 条件下,该模式为 41200000H,与整数 10 的位模式不同。
若只是想复制对象表示,memcpy 可以明确表达意图,避免用不兼容指针强制读取:
#include <stdint.h>
#include <string.h>
float f = 10.0f;
uint32_t bits;
_Static_assert(sizeof f == sizeof bits, "size mismatch");
memcpy(&bits, &f, sizeof bits);
这段示例假定平台采用 binary32、相应整数与浮点字节序相容。它复制位模式;(uint32_t)f 则进行数值转换,在本例得到整数 10。关于数组、布局和对象表示的语言边界,可对照 C11 草案的 6.3.2.1、6.5.6、6.7.2.1 节。
越界题的易错点和防护含义
数组对象后面可能接着另一个局部变量,也可能接着填充或保存信息。向数组越界写入,可能在某次编译的二进制里改变邻近变量,甚至破坏返回信息;但从 C 语言层面,这已经是未定义行为。不能把一次运行观察改写成“访问 a[2] 必然修改变量 d”的通则。volatile 也不会把越界变为合法行为。
课件进一步介绍三类防护:地址空间随机化让某些目标地址更难预测;栈保护值用于发现一部分栈破坏;不可执行页限制在数据区域直接执行机器指令。它们缓解的是不同问题,没有哪一项把越界访问本身变成安全操作。有效的源代码修正仍然要约束索引、检查长度,并为字符串终止符留下空间。
做结构体和越界组合题时,建议先写对象合法范围,再计算偏移。只有在题目明确给出具体机器布局时,才进一步解释越界写入可能覆盖哪些字节。这样既能完成位级分析,也不会把错误程序的某一次结果误当成语言承诺。
自测与简答
- 同一
int a[3][4]中,a+1和&a[0][0]+1的地址增量相同吗? - 一个结构体成员总长为 9,最大对齐要求为 4,为什么不能立刻认定
sizeof为 9? - 观察浮点位模式和把浮点值转换为整数,是否等价?
答案:① 不同,分别增加 16 和 4 字节。② 还要计算内部填充和尾部填充;总长度需满足结构体数组的对齐要求。③ 不等价;binary32 的 10.0f 位模式为 41200000H,数值转换得到 10。
资料对应
主要依据《4.程序的机器级表示-V1.0.pptx》第 61—97 页;字节序与基础对齐可回看《2.数据的机器级表示与处理-V1.0.pptx》第 119—126 页。矩阵地址和 R[3].d 为自编算例,结构体重排沿用课件题型。
讨论这篇文章
关于《ICS 与 8086 复习 14:数组 指针 结构体与数据对齐》的补充、不同意见或延伸话题,都可以留在这里。
滚动到这里时自动载入留言。