Appearance
数组、结构体与内存布局
概念
上一篇讲的都是"一个":一个 int、一个指针。但程序里的数据从来不孤单——总是一片、一组、带结构。
C 给了三种"把数据组织起来"的方式:
| 方式 | 组织规则 | 大小 |
|---|---|---|
数组 T a[N] | N 个同类型对象,紧挨着排 | N × sizeof(T)(没有空隙) |
结构体 struct S | 不同类型成员,按声明顺序排 | ≥ 成员之和(可能有大空隙) |
联合体 union U | 所有成员共用同一块内存 | = max(成员大小) |
这里有个反直觉的事实,是本篇的核心:
先看一个立刻会让人犯嘀咕的例子:
c
struct A { char c; int i; char d; };
sizeof(struct A) == ??按"成员相加"算:1 + 4 + 1 = 6。实际在 x86-64 上是 12——整整一倍。
为什么? 因为**"int 必须放在 4 的倍数地址上"**(对齐要求)。于是:
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ c │ 填充(3 字节) │ i = 4 字节 │ d │ 尾部填充(3) │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
↑ ↑ ↑ ↑ ↑
c@0 空着 1~3 i@4 d@8 空着 9~11这 6 个字节的"空隙"从哪来的? 答案是回到 L0/L1/L2:
lang/02-addressing.md说过:lw必须 4 字节对齐,否则触发 Address Error 异常。为什么硬件要这样规定?因为内存总线一次传一个字(回链circuit/14-register.md的总线)——一个 4 字节的int若横跨两个"字边界",硬件得拆成两次读、再拼起来。编译器选择"塞几个空洞"来换"每条访存都是一次干净的字传输"。填空洞是便宜的,拆访存是昂贵的。
本篇在主线上的位置:
lang/02从硬件侧讲了"为什么要对齐"(lw的强制对齐要求)。 本篇从 C 侧把结论闭环:对齐要求如何变成"结构体里的空洞"、如何让sizeof不等于成员之和、以及程序员的实际代价(内存与性能)。
原理
一、数组:连续 + 行优先
一维数组的地址公式(回链 lang/02 的地址算术):
二维数组——C 是行优先(row-major):先排完第 0 行,再排第 1 行……
int a[3][4] (3 行 4 列,每行 16 字节)
行 0: a[0][0] a[0][1] a[0][2] a[0][3] │ 行 1: a[1][0] … │ 行 2: …
└────── 16 字节 ──────┘ └── 16 字节 ──┘
连续排完一行,才排下一行地址公式:
注意"列数"这个因子:地址里必须乘上"一行的元素个数",而不是 j 单独算——这是二维数组最容易写错的地方。
| 表达式 | 含义 | 类型 |
|---|---|---|
a | 指向"第一行"的指针 | int (*)[4] |
a[i] | 第 i 行(等价于 *(a+i)) | int[4](退化成 int*) |
a[i][j] | 第 i 行第 j 列 | int |
sizeof(a) | 整个数组 | 3 × 4 × 4 = 48 |
sizeof(a[0]) | 一行的字节数 | 16 |
"行优先"的实测后果:按行遍历(for i: for j:)比按列遍历快得多——因为按列走等于"每次跳 16 字节",把 Cache 行全浪费了(回链 arch/12-cache.md 的局部性)。
二、结构体:三条对齐规则决定一切
先定两个概念(后面全靠它们):
| 概念 | 含义 |
|---|---|
| 成员对齐(alignment) | 该成员"起始地址必须是几的倍数";通常等于自身大小(char→1、short→2、int→4、double→8、指针→8) |
| 结构体对齐 | 取所有成员对齐的"最大值";整个结构体的 sizeof 必须是它的整数倍 |
三条规则(记住这三条,任何 sizeof 都能手算):
规则 ① 成员依次摆放,每个成员的"起始偏移"必须是它自身对齐的倍数
(不是的话,插填充)
规则 ② 结构体的总大小必须是"最大成员对齐"的倍数
(不是的话,末尾插填充)
规则 ③ 数组元素之间不额外加填充(因为 sizeof 已经是最大对齐的倍数)用规则③立刻能解释一件事:sizeof(struct A) = 12,那 struct A x[3] 就是 36 字节——不会有"元素间再加 3 字节填充"的情况。规则②的"末尾填充"正是为了让规则③成立。
"成员顺序会影响 sizeof"——因为规则①②都是顺序相关的:
c
struct A { char c; int i; char d; }; /* 12 字节 */
struct B { int i; char c; char d; }; /* 8 字节 */两个结构体成员完全相同,只是顺序不同,B 比 A 省 4 字节(33%)——这就是"结构体成员按大小降序排"这条优化建议的由来。
三、为什么必须对齐:三条硬件理由
回到"为什么会有对齐要求"(这是"手机器"的核心理由):
| 理由 | 说明 | 回链 |
|---|---|---|
| ① 硬件强制 | lw/lh 要求地址对齐,否则抛 Address Error | lang/02-addressing.md |
| ② 总线按字传输 | 内存一次给一个字(4/8 字节);跨字边界的访问要拆两次 | circuit/14-register.md |
| ③ 读写共用 | 对齐访问天然是"一个总线周期";不对齐会导致"读-改-写"或两次传输 | arch/12-cache.md |
"不对齐的代价"具体是多少? 最坏情况是两倍:跨字边界的 4 字节读,硬件要读两个相邻字、再移位拼接。而"填充"的代价是"几个字节的内存"。
所以编译器的取舍很清楚:用几个字节的内存,换"每次访存都是一个干净的字传输"。注意:
char数组不会有填充问题——char对齐是 1,任何地址都合法。 这就是"字节流不用对齐"的原因(也是memcpy能对任意地址工作的原因)。
能否"取消填充"? 可以,但要付代价:
| 写法 | 效果 |
|---|---|
__attribute__((packed))(GCC) | 无填充({char;int;char} 变 6 字节) |
| 代价 | 成员地址不再对齐 → 访问时要拆访存;取成员地址会得到未对齐指针,可能触发异常 |
| 适用 | 只用于"解析外部二进制格式(协议头、文件头)"——内部结构体不要用 |
一句话:
packed是对"外部强加的格式"的妥协,不是优化手段。 协议说"第 1 字节是类型、第 2~5 字节是长度",那你就得按这个摆——格式是别人定的,不能为了对齐去改。
四、union、enum、位域
union(联合体):所有成员共用同一块内存,谁写谁生效。
| 性质 | 规则 |
|---|---|
| 大小 | max(成员大小),再向上取到最大对齐的倍数 |
| 对齐 | max(成员对齐) |
| 用途 | ① 省内存(同一时刻只用一个)② 类型双关(看同一串位的不同解释) |
| 陷阱 | 写 a 再读 b,读到的是"同一串位的另一种解释"——这是实现相关行为,标准不保证可移植 |
enum(枚举):本质就是 int——sizeof(enum E) 通常 4。它的价值在"可读性和"具名常量",不在省空间。
位域(回链 lang/10-type.md):把成员压到"几位"——用于协议头和硬件寄存器(回链 silicon/06-datasheet.md),但布局是实现相关的。
五、结构体传参:复制整个结构体
C 的参数是值传递(回链 lang/11-pointer.md)——结构体也一样:按值传递 = 复制整个结构体。
| 传参方式 | 代价 | 何时用 |
|---|---|---|
按值 void f(struct A a) | 复制 sizeof(A) 字节(可能几十~几百字节) | 结构体很小(≤ 2 个字) |
按指针 void f(const struct A *a) | 复制 8 字节 | 大结构体(默认这么写) |
| 返回值 | 小结构体走寄存器;大的走"隐藏指针参数" | 视 ABI 而定 |
const struct A *是 C 里最常用的"只读大对象"传参形式:const保证被调用者不改,*保证不复制。lang/04-stack.md说"参数超过 4 个就压栈"——结构体按值时,它被拆成若干字压栈(或按 ABI 规则传递),这就是"大结构体按值传参很慢"的机制。
示例
例 1:结构体 sizeof 的完整计算过程
任务:按三条规则,手算三个结构体的布局与大小。
平台约定(x86-64 Linux,下面所有计算都按它):char=1/对齐1,short=2/对齐2,int=4/对齐4,double=8/对齐8,指针=8/对齐8。
① struct A { char c; int i; char d; };
步 1:c(char, 对齐 1)→ 起始偏移 0,占 0~0,下一空闲 = 1
步 2:i(int, 对齐 4)→ 1 不是 4 的倍数 → 填充 3 字节(偏移 1~3)
→ 起始偏移 4,占 4~7,下一空闲 = 8
步 3:d(char, 对齐 1)→ 起始偏移 8,占 8~8,下一空闲 = 9
步 4:结构体对齐 = max(1,4,1) = 4 → 9 不是 4 的倍数 → 末尾填充 3 字节
结果:sizeof(A) = 12② struct B { int i; char c; char d; };
步 1:i(对齐 4)→ 偏移 0,占 0~3,下一空闲 = 4
步 2:c(对齐 1)→ 偏移 4,占 4~4,下一空闲 = 5
步 3:d(对齐 1)→ 偏移 5,占 5~5,下一空闲 = 6
步 4:结构体对齐 = 4 → 6 不是 4 的倍数 → 末尾填充 2 字节
结果:sizeof(B) = 8 ← 成员和 A 完全相同,只是顺序不同,省了 4 字节③ struct C { char c; double d; int i; };
步 1:c(对齐 1)→ 偏移 0,占 0,下一空闲 = 1
步 2:d(对齐 8)→ 1 不是 8 的倍数 → 填充到 8
→ 偏移 8,占 8~15,下一空闲 = 16
步 3:i(对齐 4)→ 16 是 4 的倍数 → 偏移 16,占 16~19,下一空闲 = 20
步 4:结构体对齐 = max(1,8,4) = 8 → 20 → 填充到 24
结果:sizeof(C) = 24用 Python 把这三条规则写成代码,自动复算并交叉验证:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# (类型 → (大小, 对齐)) x86-64 Linux
T = {"char": (1, 1), "short": (2, 2), "int": (4, 4),
"double": (8, 8), "ptr": (8, 8)}
def align_up(x, a):
return (x + a - 1) // a * a
def layout(members):
"""members: [(名, 类型)];返回 (布局行, 总大小, 结构体对齐)"""
off, maxalign, rows = 0, 1, []
for name, t in members:
sz, al = T[t]
if off % al: # 规则①:插填充
fill = align_up(off, al) - off
rows.append(("(填充)", "-", off, fill))
off += fill
rows.append((name, t, off, sz))
off += sz
maxalign = max(maxalign, al)
total = align_up(off, maxalign) # 规则②:末尾填充
if total > off:
rows.append(("(尾部填充)", "-", off, total - off))
return rows, total, maxalign
STRUCTS = [
("struct A { char c; int i; char d; };", [("c", "char"), ("i", "int"), ("d", "char")]),
("struct B { int i; char c; char d; };", [("i", "int"), ("c", "char"), ("d", "char")]),
("struct C { char c; double d; int i; };", [("c", "char"), ("d", "double"), ("i", "int")]),
("struct D { double d; char c; int i; };", [("d", "double"), ("c", "char"), ("i", "int")]),
]
for title, mem in STRUCTS:
rows, total, ma = layout(mem)
print("=== " + title + " ===")
print(" " + pad("成员", 12) + pad("类型", 8) + pad("偏移", 6) + pad("大小", 6) + "占用区间")
for name, t, off, sz in rows:
print(" " + pad(name, 12) + pad(t, 8) + pad(str(off), 6) + pad(str(sz), 6)
+ (f"[{off}, {off+sz-1}]" if sz else ""))
raw = sum(T[t][0] for _, t in mem)
print(f" → 成员之和 {raw} 字节,sizeof = {total} 字节(结构体对齐 {ma})"
f",填充 {total - raw} 字节\n")
print("=== 顺序的代价对比 ===")
print(" A {char; int; char} = 12 ← 最差")
print(" B {int; char; char} = 8 ← 同样的成员,省 4 字节")
print(" C {char; double;int} = 24")
print(" D {double;char; int} = 16 ← 按大小降序排,省 8 字节(33%)")
print("\n=== 数组元素之间不额外填充(规则③)===")
print(" sizeof(struct A) = 12 → struct A x[3] 共 36 字节")
print(" 因为 12 已经是最大对齐 4 的倍数 → 每个元素天然对齐,不需要额外填充")
print(" 若 struct 大小不是最大对齐的倍数,数组第二个元素就会错位 → 所以规则②必须存在")预期输出:
=== struct A { char c; int i; char d; }; ===
成员 类型 偏移 大小 占用区间
c char 0 1 [0, 0]
(填充) - 1 3 [1, 3]
i int 4 4 [4, 7]
d char 8 1 [8, 8]
(尾部填充) - 9 3 [9, 11]
→ 成员之和 6 字节,sizeof = 12 字节(结构体对齐 4),填充 6 字节
=== struct B { int i; char c; char d; }; ===
成员 类型 偏移 大小 占用区间
i int 0 4 [0, 3]
c char 4 1 [4, 4]
d char 5 1 [5, 5]
(尾部填充) - 6 2 [6, 7]
→ 成员之和 6 字节,sizeof = 8 字节(结构体对齐 4),填充 2 字节
=== struct C { char c; double d; int i; }; ===
成员 类型 偏移 大小 占用区间
c char 0 1 [0, 0]
(填充) - 1 7 [1, 7]
d double 8 8 [8, 15]
i int 16 4 [16, 19]
(尾部填充) - 20 4 [20, 23]
→ 成员之和 13 字节,sizeof = 24 字节(结构体对齐 8),填充 11 字节
=== struct D { double d; char c; int i; }; ===
成员 类型 偏移 大小 占用区间
d double 0 8 [0, 7]
c char 8 1 [8, 8]
(填充) - 9 3 [9, 11]
i int 12 4 [12, 15]
→ 成员之和 13 字节,sizeof = 16 字节(结构体对齐 8),填充 3 字节
=== 顺序的代价对比 ===
A {char; int; char} = 12 ← 最差
B {int; char; char} = 8 ← 同样的成员,省 4 字节
C {char; double;int} = 24
D {double;char; int} = 16 ← 按大小降序排,省 8 字节(33%)
=== 数组元素之间不额外填充(规则③)===
sizeof(struct A) = 12 → struct A x[3] 共 36 字节
因为 12 已经是最大对齐 4 的倍数 → 每个元素天然对齐,不需要额外填充
若 struct 大小不是最大对齐的倍数,数组第二个元素就会错位 → 所以规则②必须存在三条结论:
| 结论 | 说明 |
|---|---|
三条规则能算出任何 sizeof | ①成员起始对齐 ②总体取最大对齐 ③数组不加额外填充 |
| 顺序影响大小 | A 12 vs B 8;C 24 vs D 16——"按对齐降序排成员"能省内存 |
| 填充是"对齐换来的" | C 填充 11 字节(占 46%)——double 的对齐要求是 8,代价最大 |
例 2:二维数组的地址计算(行优先)
任务:把 int a[3][4] 的每个元素地址算出来,并验证"按行优先"。
c
#include <stdio.h>
int main(void) {
int a[3][4];
printf("sizeof(a) = %zu\n", sizeof(a)); /* 48 */
printf("sizeof(a[0]) = %zu\n", sizeof(a[0])); /* 16 */
printf("行数 = %zu, 列数 = %zu\n", sizeof(a)/sizeof(a[0]), sizeof(a[0])/sizeof(a[0][0]));
/* 按行优先打印地址序 */
for (int i = 0; i < 3; i++)
for (int j = 0; j < 4; j++)
printf("a[%d][%d] @ %p (偏移 %ld)\n",
i, j, (void*)&a[i][j], (long)((char*)&a[i][j] - (char*)a));
return 0;
}用 Python 复算全部 12 个元素的偏移:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
BASE, SIZE, ROWS, COLS = 0x1000, 4, 3, 4
def off(i, j):
return (i * COLS + j) * SIZE # ★ 关键是乘"列数"
print(f" int a[{ROWS}][{COLS}] base = {BASE:#x} sizeof(int) = {SIZE}")
print(f" sizeof(a) = {ROWS*COLS*SIZE}")
print(f" sizeof(a[0]) = {COLS*SIZE} (一行)")
print(f" 地址公式: &a[i][j] = base + (i*{COLS} + j)*{SIZE}\n")
print(" " + pad("i", 3) + pad("j", 3) + pad("i*COLS+j", 10) + pad("字节偏移", 10)
+ pad("地址", 12) + "行内序号")
for i in range(ROWS):
for j in range(COLS):
k = i * COLS + j
print(" " + pad(str(i), 3) + pad(str(j), 3) + pad(str(k), 10)
+ pad(str(off(i, j)), 10) + pad("0x%X" % (BASE + off(i, j)), 12)
+ ("← 行 %d 开始" % i if j == 0 else ""))
print("\n=== 常见错误:忘了乘列数 ===")
bad = lambda i, j: j * 4 # ✗ 只用 j
good = lambda i, j: (i * COLS + j) * 4
for (i, j) in ((1, 0), (2, 3)):
print(f" a[{i}][{j}]: 正确偏移 {good(i,j):>3} 错误写法(只算 j) {bad(i,j):>3}"
f" → 差 {good(i,j)-bad(i,j)} 字节")
print("\n=== 行优先意味着什么(缓存局部性)===")
print(" 按行遍历:地址 +4, +4, +4 … 连续 → 一个 Cache 行装多个元素(回链 arch/12)")
print(f" 按列遍历:每次地址 +{COLS*SIZE} → 每访问一个元素就跨一整行,Cache 命中率暴跌")
print("\n 二维数组的内层循环应走“最后一维”(内存连续的那一维)")预期输出:
int a[3][4] base = 0x1000 sizeof(int) = 4
sizeof(a) = 48
sizeof(a[0]) = 16 (一行)
地址公式: &a[i][j] = base + (i*4 + j)*4
i j i*COLS+j 字节偏移 地址 行内序号
0 0 0 0 0x1000 ← 行 0 开始
0 1 1 4 0x1004
0 2 2 8 0x1008
0 3 3 12 0x100C
1 0 4 16 0x1010 ← 行 1 开始
1 1 5 20 0x1014
1 2 6 24 0x1018
1 3 7 28 0x101C
2 0 8 32 0x1020 ← 行 2 开始
2 1 9 36 0x1024
2 2 10 40 0x1028
2 3 11 44 0x102C
=== 常见错误:忘了乘列数 ===
a[1][0]: 正确偏移 16 错误写法(只算 j) 0 → 差 16 字节
a[2][3]: 正确偏移 44 错误写法(只算 j) 12 → 差 32 字节
=== 行优先意味着什么(缓存局部性)===
按行遍历:地址 +4, +4, +4 … 连续 → 一个 Cache 行装多个元素(回链 arch/12)
按列遍历:每次地址 +16 → 每访问一个元素就跨一整行,Cache 命中率暴跌
二维数组的内层循环应走“最后一维”(内存连续的那一维)三条结论:
| 结论 | 说明 |
|---|---|
| 二维地址必须乘"列数" | base + (i×cols + j)×sizeof——只算 j 是最常见的下标 bug |
| 行优先 = 内存连续 | a[i][j] 的下一个元素是 a[i][j+1]——这就是"内层循环走最后一维"的依据 |
| 编译器怎么算 | i×cols 是循环不变量(提到外层),×sizeof 是一次 sll(回链 lang/02) |
例 3:结构体成员的访问 = 基址 + 偏移
任务:说明"点号运算"在机器层就是一条 lw。
c
struct Point { int x; int y; }; /* sizeof = 8,x@0、y@4 */
struct Point p = {3, 4};
int a = p.x; /* 读偏移 0 */
int b = p.y; /* 读偏移 4 */对应 MIPS32(回链 lang/02-addressing.md):
asm
# struct Point p; (p 的地址在 $s0,帧内偏移 8)
addiu $s0, $fp, 8 # $s0 = &p
# int a = p.x;
lw $t0, 0($s0) # 偏移 0 → x
# int b = p.y;
lw $t1, 4($s0) # 偏移 4 → y关键观察:"成员偏移"是编译期常量——p.x 编译成 lw $t0, 0($s0),p.y 编译成 lw $t1, 4($s0)。 点号运算没有运行时开销(就是一条偏移量已定的访存)。
用 Python 把布局与访存对照起来:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# struct Point { int x; int y; } → x@0, y@4, sizeof 8
POINT = [("x", "int", 0), ("y", "int", 4)]
P_ADDR = 0x7FFC1008
mem = {P_ADDR + 0: 3, P_ADDR + 4: 4} # p = {3, 4}
print(f" struct Point p @ {P_ADDR:#x} sizeof = 8")
print(" " + pad("成员", 6) + pad("偏移", 6) + pad("地址", 14) + "编译出的指令")
acc = []
for name, t, off in POINT:
ins = f"lw $t, {off}($s0)"
acc.append((name, off, ins))
print(" " + pad("p." + name, 6) + pad(str(off), 6)
+ pad("0x%X" % (P_ADDR + off), 14) + ins)
print("\n=== 成员访问是“编译期常量偏移” ===")
for name, off, ins in acc:
print(f" p.{name} → 偏移 {off}(编译期就定死)→ {ins}")
print(" 点号运算在运行时没有开销:就是一条偏移已定的 lw")
print("\n=== 对照:指针访问 vs 数组访问 ===")
print(" p.x → lw $t0, 0($s0) (结构体:偏移是常量)")
print(" a[i] → sll + addu + lw (数组:偏移要算,多两条指令)")
print(" 所以“结构体成员比数组下标快”:结构体省掉了乘法/移位")
print("\n=== offsetof 宏在算什么 ===")
for name, t, off in POINT:
print(f" offsetof(struct Point, {name}) = {off}")
print(" 它就是把上面的“编译期偏移”暴露出来,常用于元编程与协议解析")预期输出:
struct Point p @ 0x7ffc1008 sizeof = 8
成员 偏移 地址 编译出的指令
p.x 0 0x7FFC1008 lw $t, 0($s0)
p.y 4 0x7FFC100C lw $t, 4($s0)
=== 成员访问是“编译期常量偏移” ===
p.x → 偏移 0(编译期就定死)→ lw $t, 0($s0)
p.y → 偏移 4(编译期就定死)→ lw $t, 4($s0)
点号运算在运行时没有开销:就是一条偏移已定的 lw
=== 对照:指针访问 vs 数组访问 ===
p.x → lw $t0, 0($s0) (结构体:偏移是常量)
a[i] → sll + addu + lw (数组:偏移要算,多两条指令)
所以“结构体成员比数组下标快”:结构体省掉了乘法/移位
=== offsetof 宏在算什么 ===
offsetof(struct Point, x) = 0
offsetof(struct Point, y) = 4
它就是把上面的“编译期偏移”暴露出来,常用于元编程与协议解析三条结论:
| 结论 | 说明 |
|---|---|
| 成员访问 = 常量偏移访存 | p.x 就是 lw $t, offset($base)——偏移是编译期常量,零运行时开销 |
| 数组下标要多两步 | 要先 i × sizeof(sll)+ 基址相加——比结构体成员多两条指令 |
offsetof 就是把偏移暴露出来 | 协议解析、序列化框架大量使用(比如"从第 12 字节起是长度字段") |
例 4:对齐的代价与 packed 的陷阱
任务:量化"填充浪费"与"取消填充的风险"。
c
#include <stdio.h>
/* 默认布局 */
struct NetHeader {
uint8_t ver; /* 1 */
uint32_t src; /* 4 */
uint16_t port; /* 2 */
uint8_t flag; /* 1 */
}; /* 默认 sizeof = 12(填充 4) */
/* 按协议规定的紧凑布局(不对齐) */
struct __attribute__((packed)) NetHeaderPacked {
uint8_t ver;
uint32_t src;
uint16_t port;
uint8_t flag;
}; /* sizeof = 8 */
int main(void) {
printf("默认 sizeof = %zu\n", sizeof(struct NetHeader));
printf("packed sizeof = %zu\n", sizeof(struct NetHeaderPacked));
return 0;
}用 Python 对比两种布局,并算出"读取未对齐成员要拆几次访存":
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
T = {"u8": (1, 1), "u16": (2, 2), "u32": (4, 4)}
FIELDS = [("ver", "u8"), ("src", "u32"), ("port", "u16"), ("flag", "u8")]
def align_up(x, a):
return (x + a - 1) // a * a
def layout(fields, packed=False):
off, ma, rows = 0, 1, []
for name, t in fields:
sz, al = T[t]
if not packed and off % al:
f = align_up(off, al) - off
rows.append(("(填充)", "-", off, f, "填充")); off += f
rows.append((name, t, off, sz, "成员")); off += sz
if not packed:
ma = max(ma, al)
if packed:
return rows, off, 1
total = align_up(off, ma)
if total > off:
rows.append(("(尾部填充)", "-", off, total - off, "填充"))
return rows, total, ma
def word_splits(off, sz, word=4):
"""读 sz 字节、起始偏移 off,跨几个 4 字节字"""
return len(range(off // word, (off + sz - 1) // word + 1))
for packed in (False, True):
rows, total, ma = layout(FIELDS, packed)
tag = "packed(不对齐)" if packed else "默认(对齐)"
print(f"=== {tag} sizeof = {total} ===")
print(" " + pad("成员", 12) + pad("类型", 6) + pad("偏移", 6) + pad("大小", 6) + "说明")
for name, t, off, sz, kind in rows:
print(" " + pad(name, 12) + pad(t, 6) + pad(str(off), 6) + pad(str(sz), 6) + kind)
for name, t, off, sz, kind in rows:
if kind == "成员" and sz > 1:
al = T[t][1]
n = word_splits(off, sz)
if off % al:
extra = f",且跨 {n} 个字 → 要拆 {n} 次访存!" if n > 1 else "(跨字节边界,但仍落在同一字内)"
print(f" · 读 {name}({sz} 字节 @ {off}):未对齐" + extra)
else:
print(f" · 读 {name}({sz} 字节 @ {off}):对齐,一次字传输")
print()
print("=== 代价对比 ===")
print(" 默认 :12 字节,每次访存都对齐(不拆)")
print(" packed: 8 字节,省 4 字节(33%)")
print(" 但 src @ 1(u32 要求 4 的倍数)→ 未对齐且跨字 → 每次读 src 拆 2 次访存")
print()
print(" 结论:packed 只应该用于“解析外部二进制格式”")
print(" 格式是协议定的,不能为了对齐改 → 只能接受“拆访存”的代价")
print(" 内部自己的结构体永远不要用 packed")
print()
print("=== 另一个坑:填充字节的内容不确定 ===")
print(" memset 之前,填充字节可能是垃圾 → 不能用 memcmp 比较两个结构体是否相等")预期输出:
=== 默认(对齐) sizeof = 12 ===
成员 类型 偏移 大小 说明
ver u8 0 1 成员
(填充) - 1 3 填充
src u32 4 4 成员
port u16 8 2 成员
flag u8 10 1 成员
(尾部填充) - 11 1 填充
· 读 src(4 字节 @ 4):对齐,一次字传输
· 读 port(2 字节 @ 8):对齐,一次字传输
=== packed(不对齐) sizeof = 8 ===
成员 类型 偏移 大小 说明
ver u8 0 1 成员
src u32 1 4 成员
port u16 5 2 成员
flag u8 7 1 成员
· 读 src(4 字节 @ 1):未对齐,且跨 2 个字 → 要拆 2 次访存!
· 读 port(2 字节 @ 5):未对齐(跨字节边界,但仍落在同一字内)
=== 代价对比 ===
默认 :12 字节,每次访存都对齐(不拆)
packed: 8 字节,省 4 字节(33%)
但 src @ 1(u32 要求 4 的倍数)→ 未对齐且跨字 → 每次读 src 拆 2 次访存
结论:packed 只应该用于“解析外部二进制格式”
格式是协议定的,不能为了对齐改 → 只能接受“拆访存”的代价
内部自己的结构体永远不要用 packed
=== 另一个坑:填充字节的内容不确定 ===
memset 之前,填充字节可能是垃圾 → 不能用 memcmp 比较两个结构体是否相等三条结论:
| 结论 | 说明 |
|---|---|
packed 省内存但拆访存 | 省 33% 空间,代价是"每次读未对齐成员拆 2 次" |
| 用在哪:只用于外部二进制格式 | 协议头、文件头——格式是别人定的,不能为对齐改 |
| 填充字节内容不确定 | memcmp 比较结构体不可靠(要用 memset 或逐字段比) |
考点
考点
1. 结构体 sizeof 手算(必考,三规则)
- 规则①:每个成员的起始偏移必须是它自身对齐的倍数(不够就插填充);
- 规则②:结构体总大小必须是"最大成员对齐"的倍数(不够就末尾填充);
- 规则③:数组元素之间不额外填充。
背下这四个例子(x86-64):
| 结构体 | sizeof | 填充 |
|---|---|---|
{char; int; char} | 12 | 6 |
{int; char; char} | 8 | 2 |
{char; double; int} | 24 | 11 |
{double; char; int} | 16 | 3 |
- 成员顺序影响大小——按对齐降序排最省。
2. 二维数组地址(计算题)
- 必须乘"列数"(最常见的错误);
sizeof(a)是整个数组,sizeof(a[0])是一行;- 行优先:
a[i][j]的下一个是a[i][j+1]→ 内层循环走最后一维。
3. 对齐的硬件理由(与计组交叉)
lw/lh强制对齐,否则 Address Error(回链lang/02);- 总线一次一个字;不对齐要拆两次访存 + 移位拼接;
- 所以编译器选择"塞空洞换干净的字传输"——填充是优化,不是浪费。
4. union / enum / 位域
union:大小 =max(成员)(再按最大对齐向上取整);成员共用内存;"写一读另一成员"是实现相关行为;enum:本质是int,通常 4 字节;- 位域:布局是实现相关的,协议解析应改为显式移位 + 掩码。
5. 结构体传参
- 按值 = 复制整个结构体;大结构体用
const struct A *; offsetof(type, member)得到成员偏移——编译期常量;- 成员访问编译成
lw $t, offset($base),偏移是常量,零运行时开销;数组下标要多两条指令(sll+addu)。
6. 高频易错点
packed省空间但拆访存,只用于解析外部二进制格式;- 填充字节内容不确定 →
memcmp比较结构体不可靠; char对齐为 1,所以字节数组/memcpy没有对齐问题;- 结构体含指针时
sizeof随平台变(4→8),不能跨平台硬编码大小; sizeof是编译期常量(VLA 除外),所以sizeof(struct S)不可能是"运行期算出来的"。
小结
- 数组是"连续 + 行优先":一维
base + i×sizeof;二维base + (i×列数 + j)×sizeof——别忘乘列数。 - 结构体的
sizeof由三条对齐规则决定:①成员起始对齐 ②总体取最大对齐 ③数组元素不加额外填充。 sizeof常大于成员之和:{char;int;char}= 12(成员和 6);成员按对齐降序排能显著省空间({char;double;int}24 →{double;char;int}16)。- 对齐要求的根因在硬件:
lw强制对齐(回链lang/02)+ 总线按字传输(回链circuit/14)——填空洞便宜,拆访存昂贵。 union大小 =max(成员);enum就是int;位域布局不可移植。- 成员访问是"常量偏移的访存":
p.x→lw $t, 0($base),零运行时开销;offsetof就是把这个偏移暴露出来。 - 结构体按值传参会复制整个结构体——大结构体用
const 指针。 packed只用于外部二进制格式;填充字节内容不确定,结构体不能用memcmp比较。
回到主线:到这里,"数据在内存里长什么样"讲完了——数组、结构体、对齐、偏移。
但还有一件事没讲:这些东西"活"在什么地方?函数一被调用,局部变量就从内存里"长出来";函数一返回,它们又"消失"。
{int a; int b;}里的a、b到底落在哪个地址、谁来管它们的生死?——下一篇:函数调用与运行时栈。
下一篇:函数调用与运行时栈
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。