吃透内存与自定义类型,这三节课干货拉满
最近集中啃完了 C 语言进阶的核心内容,从数据在内存里的底层存储,到结构体、联合体、枚举这些自定义类型,再到动态内存管理,每一块都是笔试面试的高频考点,也是写好 C 代码的底层基础。今天就以我的学习视角,把这些知识点梳理复盘一遍,配上我踩过坑的详细代码示例,既是给自己做沉淀,也给同样在学 C 语言的小伙伴做个参考。
一、先搞懂底层:数据在内存里到底是怎么存的?
学 C 语言绕不开内存,之前写代码只关心变量存了什么值,却从来没深究过这些值在内存里到底以什么形式放着,这节课直接把我之前的很多疑惑给解开了。
1. 整数存储:原码、反码、补码,别再傻傻分不清
整数的二进制表示有三种:原码、反码、补码,而内存里存的,永远是补码。
- 正整数很简单:原码、反码、补码完全一样,不用额外转换。
- 负整数就有讲究了:原码是数值直接转二进制(符号位为 1),反码是符号位不变、其余位按位取反,补码就是反码 + 1。
举个具体的例子,彻底搞懂原反补:
我们拿 8 位二进制来举例,看得更清楚:
- 正整数 + 5:原码:
0000 0101(最高位 0 是符号位,代表正数)反码:0000 0101(正整数原反补完全相同)补码:0000 0101 - 负整数 - 5:原码:
1000 0101(最高位 1 是符号位,代表负数)反码:1111 1010(符号位不变,其余位按位取反)补码:1111 1011(反码 + 1,这就是内存里真正存的内容)
为什么补码这么重要?用加法实现减法的例子:
CPU 只有加法器,没有减法器,所有的减法都要转成加法来算,补码就是干这个的。比如我们要算 1 - 1,本质就是 1 + (-1),我们用 8 位二进制来算:
- 用原码算:1 的原码:
0000 0001-1 的原码:1000 0001相加:0000 0001 + 1000 0001 = 1000 0010,转成十进制是 - 2,结果完全错了! - 用补码算:1 的补码:
0000 0001-1 的补码:1111 1111相加:0000 0001 + 1111 1111 = 1 0000 0000,因为是 8 位二进制,最高位的 1 会被截断,剩下的就是0000 0000,也就是 0,结果完全正确!
这就是为什么内存里一定要存补码,不仅统一了符号位和数值位,还能让加减运算统一用加法实现,效率拉满。
这里还有个特别容易踩的坑:char类型的 signed 和 unsigned 差异,我把课上的经典例题全拆解了,每一个都是笔试高频题。
高频坑点 1:signed char 和 unsigned char 的取值范围:
首先要明确,在 C 语言里,char 到底是 signed 还是 unsigned,取决于编译器,VS 里默认是 signed char。signed char 占 1 个字节(8bit),取值范围是 -128 ~ 127;unsigned char 占 1 个字节,取值范围是 0 ~ 255;很多 bug 都是因为忽略了这个范围,我们一个个看经典例题:
例题 1:-1 的三种 char 打印
#include <stdio.h>
int main()
{
char a = -1;
signed char b = -1;
unsigned char c = -1;
printf("a = %d, b = %d, c = %d", a, b, c);
return 0;
}
运行结果:a = -1, b = -1, c = 255
详细解析:
- 先看 - 1 的补码:int 类型的 - 1,补码是 32 位全 1:
11111111 11111111 11111111 11111111 - 赋值给 char 类型,会发生截断,只保留最低的 8 位:
11111111 - 对于 a 和 b(signed char):
11111111的补码,转成原码是10000001,也就是 - 1,用 % d 打印(整型提升后还是 - 1),所以输出 - 1 - 对于 c(unsigned char):
11111111没有符号位,直接就是十进制的 255,整型提升后还是 255,所以输出 255。
例题 2:-128 和 128 用 % u 打印的诡异结果
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n",a);
return 0;
}
运行结果:4294967168
详细解析:
- -128 的补码:int 类型的 - 128,补码是
11111111 11111111 11111111 10000000 - 赋值给 char 截断,保留低 8 位:
10000000 - 用 % u 打印,需要先做整型提升:char 是 signed 的,符号位是 1,所以整型提升的时候,高位全补 1,最终 32 位补码是
11111111 11111111 11111111 10000000 - % u 是按无符号十进制整数打印,这个 32 位的二进制,转成无符号十进制就是 4294967168
再看 128 的例子,结果完全一样:
#include <stdio.h>
int main()
{
char a = 128;
printf("%u\n",a);
return 0;
}
运行结果:4294967168
解析:因为 signed char 的最大值是 127,128 已经超出了取值范围,赋值给 char 的时候,同样会截断。128 的 int 补码是 00000000 00000000 00000000 10000000,截断低 8 位还是10000000,和 - 128 截断后的结果一模一样,所以后续的整型提升和打印结果完全相同。这就是溢出的坑,一定要注意 char 的取值范围!
例题 3:strlen 的诡异结果
#include <stdio.h>
#include <string.h>
int main()
{
char a[1000];
int i;
for(i = 0; i < 1000; i++)
{
a[i] = -1 - i;
}
printf("%d", strlen(a));
return 0;
}
运行结果:255
解析:首先要记住,strlen 函数是找字符串里的'\0'(也就是 ASCII 码为 0 的字符),遇到'\0'就停止,返回'\0'之前的字符个数。我们看循环里的赋值:
- i=0,a [0] = -1 -0 = -1 → 二进制
11111111 - i=1,a[1] = -1-1 = -2 →
11111110 - ...
- i=127,a[127] = -1-127 = -128 →
10000000 - i=128,a [128] = -1-128 = -129 → int 补码截断后是
01111111,也就是 127 - i=129,a [129] = -130 → 截断后 126
- ...
- 一直到 i=255,a [255] = -1-255 = -256 → int 补码是
11111111 11111111 11111111 00000000,截断低 8 位就是00000000,也就是'\0'!
所以 strlen 数到 a [255] 的时候遇到了'\0',停止计数,前面一共有 255 个字符,所以输出 255。
例题 4:死循环的坑
#include <stdio.h>
int main()
{
unsigned char i = 0;
for(i = 0; i <= 255; i++)
{
printf("hello world\n");
}
return 0;
}
运行结果:无限循环打印 hello world,程序永远不会结束。
解析:unsigned char 的取值范围是 0~255,当 i=255 的时候,再执行 i++,会发生溢出,unsigned 类型的溢出规则是 “模 2^n”,255+1=256,模 256 就是 0,所以 i 又变回 0 了,循环条件 i<=255 永远成立,就成了死循环。
再看另一个死循环例子:
#include <stdio.h>
int main()
{
unsigned int i;
for(i = 9; i >= 0; i--)
{
printf("%u\n",i);
}
return 0;
}
运行结果:先打印 9、8、7...0,然后开始打印超大的数字,无限循环。
解析:i 是 unsigned int 类型,无符号数永远大于等于 0,所以当 i=0 的时候,再执行 i--,会发生下溢,unsigned int 的 0 减 1,结果是 UINT_MAX(4294967295),还是大于等于 0,循环条件永远成立,就死循环了。
2. 大小端字节序:多字节数据的存储顺序,面试必问
学之前调试代码的时候,我就发现一个奇怪的事:int a = 0x11223344,内存里存的居然是44 33 22 11,完全是倒过来的,这就是大小端字节序搞的鬼。
- 大端模式:数据的高位字节存在内存低地址,低位字节存在内存高地址(符合人的阅读习惯)。
- 小端模式:数据的低位字节存在内存低地址,高位字节存在内存高地址(我们常用的 X86 架构就是小端模式)。
大小端的直观内存例子:
我们定义int a = 0x11223344,这个数里,字节的高低位是这样分的:
-
高位字节 → 低位字节:0x11(最高位)、0x22、0x33、0x44(最低位)int 占 4 个字节,假设内存地址从低到高是:
0x005DF848、0x005DF849、0x005DF84A、0x005DF84B -
小端模式(X86 架构)的内存存储:|内存地址(低→高) | 0x005DF848 | 0x005DF849 | 0x005DF84A | 0x005DF84B |; 存储内容 | 0x44 | 0x33 | 0x22 | 0x11 |也就是 “低位字节存低地址,高位字节存高地址”,完全倒过来存。
-
大端模式的内存存储:| 内存地址(低→高) | 0x005DF848 | 0x005DF849 | 0x005DF84A | 0x005DF84B |;存储内容 | 0x11 | 0x22 | 0x33 | 0x44 |也就是 “高位字节存低地址,低位字节存高地址”,符合我们的阅读习惯。
面试里常考的 “写代码判断当前机器的字节序”,其实有两种很巧妙的实现方式,核心都是取到数据的第一个字节,看它存的是什么:
方法 1:char 指针强转法
#include <stdio.h>
int check_sys()
{
int i = 1;
// &i是int*类型,强转成char*,就只会访问第一个字节(低地址的字节)
return (*(char *)&i);
}
int main()
{
int ret = check_sys();
if(ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
原理:int i=1,它的十六进制是 0x00000001,高位字节是 0x00,低位字节是 0x01。
- 如果是小端模式,低地址存的是低位字节 0x01,所以取第一个字节返回 1,判断为小端。
- 如果是大端模式,低地址存的是高位字节 0x00,所以取第一个字节返回 0,判断为大端。
方法 2:联合体法(更简洁)
#include <stdio.h>
int check_sys()
{
union
{
int i;
char c;
}un;
un.i = 1;
return un.c; // 联合体共用内存,c访问的是第一个字节
}
int main()
{
int ret = check_sys();
printf(ret == 1 ? "小端\n" : "大端\n");
return 0;
}
原理和上面完全一样,只是利用了联合体所有成员共用同一块起始地址的内存的特性,un.c 和 un.i 的起始地址是一样的,un.c 自然访问的就是第一个字节的内容,比指针强转更简洁,也更不容易出错。
3. 浮点数存储:IEEE754 标准,解开 9 和 9.0 的差异之谜
之前遇到过一个很离谱的代码:一个 int 类型的 9,用 float 指针去解读,打印出来居然是 0.000000;而给这个 float 指针赋值 9.0,再用 int 解读,又变成了一个很大的数字。当时完全想不通,学完浮点数的存储规则才彻底明白。
根据 IEEE754 标准,一个二进制浮点数 V 可以表示成这个公式:V = (-1)^S * M * 2^E
- S 是符号位,0 代表正数,1 代表负数;
- M 是有效数字,范围是 1≤M<2;
- E 是指数位。
对于 32 位的 float,内存分配是:1 位 S + 8 位 E + 23 位 M;64 位的 double 则是 1 位 S + 11 位 E + 52 位 M。
这里还有几个关键规则:
- 存 M 的时候,会默认舍去开头的 1,只存小数部分,这样能多省 1 位有效数字;
- 存 E 的时候,要给真实值加上一个中间数(8 位 E 加 127,11 位 E 加 1023),因为 E 可能是负数,但存储时是无符号整数。
- 取的时候还要分情况:E 不全 0 不全 1 是常规情况,E 全 0、E 全 1 是特殊情况,分别用来表示接近 0 的数、无穷大。
例子 1:为什么 int 的 9,用 float 解读是 0.000000?
#include <stdio.h>
int main()
{
int n = 9;
float *pFloat = (float *)&n;
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
return 0;
}
运行结果:
n的值为:9
*pFloat的值为:0.000000
详细拆解:
- 首先,int 类型的 9,在内存里存的是 32 位补码:
0000 0000 0000 0000 0000 0000 0000 1001 - 现在我们用 float 的规则来解读这 32 位,float 的 32 位是这样划分的:| 符号位 S(1 位) | 指数位 E(8 位) | 有效数字 M(23 位) ||----------------|----------------|--------------------|| 0 | 00000000 | 00000000000000000001001 |
- 我们看 E 的部分,是全 0,符合 IEEE754 里 E 全为 0 的特殊规则:
- 真实指数 E = 1 - 127 = -126
- 有效数字 M 不再补前面的 1,直接就是 0.xxxxxx,也就是 0.00000000000000000001001
- 代入公式计算:
V = (-1)^0 * 0.00000000000000000001001 * 2^-126 = 1.001 * 2^-146这是一个无限接近 0 的极小的正数,float 类型的 % f 默认只打印 6 位小数,所以输出 0.000000。
例子 2:为什么 float 的 9.0,用 int 解读是 1091567616:
#include <stdio.h>
int main()
{
int n = 9;
float *pFloat = (float *)&n;
*pFloat = 9.0;
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
return 0;
}
运行结果:
n的值为:1091567616
*pFloat的值为:9.000000
详细拆解:
- 先把十进制的 9.0 转成二进制:9.0 的二进制是
1001.0 - 转成 IEEE754 规定的科学计数法形式:
1.001 * 2^3- 符号位 S:正数,所以 S=0
- 有效数字 M:1.001,所以去掉前面的 1,存 001,后面补 0 凑够 23 位,就是
001 0000 0000 0000 0000 0000 - 指数位 E:真实指数是 3,float 的 E 要加 127,所以 3+127=130,130 的二进制是
10000010
- 把 S、E、M 拼起来,32 位的二进制就是:
0 10000010 00100000000000000000000完整的二进制:01000001 00010000 00000000 00000000 - 现在我们把这个 32 位的二进制,当成 int 类型的补码来解读,转成十进制,就是 1091567616,和打印结果完全一致。
例子 3:常规浮点数 3.14 的存储拆解;我们再拿常用的 3.14 来拆解,加深理解:
- 十进制 3.14 转二进制:整数部分 3 是 11,小数部分 0.14 转二进制是无限循环的,大概是
0.00100011110101110000101...,所以 3.14 的二进制是11.00100011110101110000101... - 转成科学计数法:
1.100100011110101110000101... * 2^1 - 拆分 S、E、M:
- S=0(正数)
- 真实 E=1,存储的 E=1+127=128,二进制是
10000000 - M 去掉前面的 1,剩下
100100011110101110000101...,截取前 23 位,就是10010001111010111000011
- 最终 32 位 float 的二进制是:
0 10000000 10010001111010111000011这也是为什么浮点数会有精度误差,因为小数部分的二进制是无限循环的,只能截取前 23 位,所以存的是近似值,不是精确值。
例子 4:E 全为 0 和 E 全为 1 的特殊情况:
- E 全为 0:用来表示 ±0,还有非常接近 0 的极小数字。比如 float 的 0.0,S=0,E 全 0,M 全 0,就是 0.0;如果 S=1,E 全 0,M 全 0,就是 - 0.0。
- E 全为 1:如果 M 全为 0,就表示无穷大。比如 S=0,E 全 1,M 全 0,就是 +∞;S=1 就是 -∞。如果 M 不全为 0,就表示 NaN(不是一个数字),比如 0.0/0.0 的结果就是 NaN。
二、结构体:不止是打包数据,内存对齐是核心
结构体是我们最常用的自定义类型,之前只知道把不同类型的变量打包在一起,却不知道结构体的大小不是简单的成员大小相加,这里面藏着结构体内存对齐的大学问,也是笔试的必考题。
1. 结构体基础:这些坑别踩
先复习最基础的声明、初始化和自引用,这里有几个新手很容易踩的坑:
- 匿名结构体:声明的时候省略标签,只能用一次,而且就算两个匿名结构体成员完全一样,编译器也会当成不同的类型,不能互相赋值。
- 结构体自引用:想写链表节点,绝对不能在结构体里放同类型的结构体变量,不然大小会无限递归,正确的做法是放同类型的结构体指针。
// 错误写法
struct Node
{
int data;
struct Node next; // 不行!会无限递归,大小无法计算
};
// 正确写法
struct Node
{
int data;
struct Node* next; // 指针才是正解,指针大小固定4/8字节
};
- typedef 重命名的坑:不要用 typedef 给匿名结构体重命名后,在结构体内部用重命名后的类型,因为编译器还没解析到重命名,会报错。
// 错误写法
typedef struct
{
int data;
Node* next; // 错误,编译器还不认识Node
}Node;
// 正确写法
typedef struct Node
{
int data;
struct Node* next; // 先声明结构体标签,再用
}Node;
2. 核心考点:结构体内存对齐,手把手教你算大小
结构体的大小计算,必须严格遵守对齐规则,先把 VS 环境下的规则记死(Linux gcc 没有默认对齐数,对齐数就是成员自身大小):
- 第一个成员,直接对齐到结构体起始地址偏移量为 0 的位置;
- 从第二个成员开始,要对齐到「对齐数」的整数倍地址。对齐数 = 编译器默认对齐数(VS 默认 8)和成员自身大小的较小值;
- 结构体的总大小,必须是所有成员里最大对齐数的整数倍;
- 如果嵌套了结构体,嵌套的结构体要对齐到自己内部最大对齐数的整数倍,整体总大小是所有最大对齐数(含嵌套结构体的)的整数倍。
例子 1:最经典的 S1 和 S2 对比,顺序决定大小:
// 练习1
struct S1
{
char c1; // 1字节
int i; // 4字节
char c2; // 1字节
};
printf("%d\n", sizeof(struct S1)); // 结果是12,不是1+4+1=6!
// 练习2
struct S2
{
char c1; // 1字节
char c2; // 1字节
int i; // 4字节
};
printf("%d\n", sizeof(struct S2)); // 结果是8,比S1小很多
我们一步步算 S1 的大小:
- 第一个成员 c1,char 类型,大小 1 字节,放在偏移量 0 的位置,占用偏移量 0。
- 第二个成员 i,int 类型,大小 4 字节。对齐数 = min (8,4)=4,所以要对齐到偏移量是 4 的整数倍的位置。前面偏移量 0 已经被用了,下一个可用的偏移量是 1,1 不是 4 的倍数,所以要填充 3 个字节(偏移量 1、2、3 都空着),从偏移量 4 开始放 i,占用偏移量 4、5、6、7。
- 第三个成员 c2,char 类型,大小 1 字节。对齐数 = min (8,1)=1,所以下一个可用的偏移量 8 就是 1 的倍数,直接放在偏移量 8,占用偏移量 8。
- 现在所有成员都放完了,一共占用了 9 个字节(0-8)。接下来看规则 3:结构体的总大小必须是最大对齐数的整数倍。S1 里的成员对齐数分别是 1、4、1,最大对齐数是 4。9 不是 4 的整数倍,所以要再填充 3 个字节(偏移量 9、10、11),最终总大小是 12 字节。
S1 的内存布局示意图(偏移量):
| 0 | 1 2 3(填充) | 4 5 6 7 | 8 | 9 10 11(填充) |
|---|---|---|---|---|
| c1 | 浪费的空间 | i | c2 | 浪费的空间 |
再算 S2 的大小,成员完全一样,只是顺序换了:
- c1 放在偏移量 0,占用 0。
- c2 是 char,对齐数 1,偏移量 1 是 1 的倍数,放在偏移量 1,占用 1。
- i 是 int,对齐数 4,下一个可用偏移量是 2,不是 4 的倍数,填充 2 个字节(偏移量 2、3),从偏移量 4 开始放 i,占用 4、5、6、7。
- 所有成员放完,一共占用了 8 字节。最大对齐数是 4,8 正好是 4 的整数倍,不需要额外填充,最终大小就是 8 字节。
S2 的内存布局:
| 0 | 1 | 2 3(填充) | 4 5 6 7 |
|---|---|---|---|
| c1 | c2 | 浪费的空间 | i |
你看,只是把成员的顺序换了一下,结构体大小就从 12 变成了 8,节省了 1/3 的空间!所以设计结构体的时候,一定要把占用空间小的成员尽量集中在一起,减少填充浪费。
例子 2:带 double 的结构体 S3
struct S3
{
double d; // 8字节
char c; // 1字节
int i; // 4字节
};
printf("%d\n", sizeof(struct S3)); // 结果是16字节
计算过程:
- d 是 double,大小 8 字节,对齐数 min (8,8)=8,放在偏移量 0,占用 0-7。
- c 是 char,对齐数 1,放在偏移量 8,占用 8。
- i 是 int,对齐数 4,下一个偏移量 9,不是 4 的倍数,填充 3 个字节(9、10、11),从偏移量 12 开始放 i,占用 12-15。
- 总占用 16 字节,最大对齐数是 8,16 是 8 的整数倍,最终大小 16 字节。
例子 3:嵌套结构体 S4
struct S4
{
char c1; // 1字节
struct S3 s3;// 上面的S3,大小16字节
double d; // 8字节
};
printf("%d\n", sizeof(struct S4)); // 结果是32字节
计算过程:
- c1 是 char,放在偏移量 0,占用 0。
- 嵌套的结构体 s3,根据规则 4,嵌套的结构体要对齐到自己内部的最大对齐数的整数倍。S3 内部的最大对齐数是 8(double 的对齐数),所以 s3 要对齐到 8 的整数倍。下一个可用偏移量是 1,不是 8 的倍数,填充 7 个字节(1-7),从偏移量 8 开始放 s3,s3 大小 16 字节,占用 8-23。
- d 是 double,对齐数 8,下一个偏移量 24,是 8 的倍数,放在 24-31。
- 总占用 32 字节,所有成员的最大对齐数是 8,32 是 8 的整数倍,最终大小 32 字节。
例子 4:修改默认对齐数:我们可以用#pragma pack()来修改编译器的默认对齐数,比如设置对齐数为 1,就不会有任何填充了。
#include <stdio.h>
#pragma pack(1) // 设置默认对齐数为1
struct S
{
char c1;
int i;
char c2;
};
#pragma pack() // 取消设置,还原默认对齐数8
int main()
{
printf("%d\n", sizeof(struct S)); // 结果是6,1+4+1=6,没有任何填充
return 0;
}
解析:因为默认对齐数改成了 1,所以每个成员的对齐数都是 min (1, 成员大小),也就是 1,所有成员都挨着放,不需要填充,总大小就是 6。注意:#pragma pack()只能设置 2 的 n 次方的数,比如 1、2、4、8、16,不能随便设置。
这里还要说一下,为什么要有内存对齐?本质就是「用空间换时间」。
- 平台原因 (移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 性能原因:数据结构 (尤其是栈) 应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
3. 结构体传参:首选传地址,别传值
结构体传参有两种方式:传结构体本身,或者传结构体的地址。这里一定要记住,首选传地址!我们用代码直观对比一下:
#include <stdio.h>
#include <string.h>
// 定义一个比较大的结构体
struct Student
{
char name[20];
int age;
char sex[5];
char id[20];
int scores[100]; // 光这个数组就400字节了
};
// 传值:把整个结构体拷贝一份传进来
void print_stu_by_value(struct Student stu)
{
printf("姓名:%s,年龄:%d\n", stu.name, stu.age);
}
// 传址:只传结构体的地址,4/8个字节
void print_stu_by_addr(struct Student *stu)
{
printf("姓名:%s,年龄:%d\n", stu->name, stu->age);
}
int main()
{
struct Student s = {"张三", 20, "男", "2023001", {90,85,95}};
print_stu_by_value(s); // 传值,要拷贝整个结构体,几百字节
print_stu_by_addr(&s); // 传址,只拷贝一个指针,开销极小
return 0;
}
原因很简单:函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。而传地址,只需要拷贝一个指针(32 位系统 4 字节,64 位系统 8 字节),开销极小,而且还可以通过指针修改原结构体的内容,非常灵活。
4. 位段:极致节省空间的玩法
结构体还能实现位段,位段的成员名后面跟冒号和数字,用来指定这个成员占用几个 bit 位,特别适合那些取值范围很小、不需要整个字节的场景,比如网络协议里的标志位,能极大节省空间。
位段的基础例子:
struct A
{
int _a:2; // 只占2个bit位
int _b:5; // 只占5个bit位
int _c:10; // 只占10个bit位
int _d:30; // 只占30个bit位
};
printf("%d\n", sizeof(struct A)); // 结果是8字节,不是4+4+4+4=16字节!
计算过程:
- 位段的成员是 int 类型,所以一次开辟 4 字节(32 个 bit 位)的空间。
- _a 占 2bit,_b 占 5bit,_c 占 10bit,加起来一共 2+5+10=17bit,32bit 还剩 15bit。
- 接下来的_d 要占 30bit,剩下的 15bit 不够了,所以 VS 里会舍弃剩下的 15bit,再开辟一个 4 字节(32bit)的空间,用来放_d 的 30bit。
- 所以一共开辟了 4+4=8 字节,最终 sizeof 的结果是 8。
char 类型位段的内存详细拆解:
#include <stdio.h>
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;
printf("sizeof(struct S) = %d\n", sizeof(struct S));
return 0;
}
运行结果:sizeof(struct S) = 3
详细解析内存开辟和赋值过程:
- 位段成员是 char 类型,一次开辟 1 字节(8bit)的空间。
- 第一个成员 a 占 3bit,第一个字节还剩 8-3=5bit。
- 第二个成员 b 占 4bit,5bit 够放,所以放在第一个字节剩下的空间里,现在第一个字节还剩 5-4=1bit。
- 第三个成员 c 占 5bit,剩下的 1bit 不够了,VS 里会舍弃这 1bit,再开辟第二个字节(8bit),放 c 的 5bit,第二个字节还剩 8-5=3bit。
- 第四个成员 d 占 4bit,剩下的 3bit 不够,再开辟第三个字节(8bit),放 d 的 4bit。
- 所以一共开辟了 3 个字节,sizeof 结果是 3。
再看赋值后的二进制变化:
- s.a = 10,10 的二进制是 1010,但是 a 只占 3bit,所以会截断,只保留低 3 位 010,所以 a 里存的是 010。
- s.b =12,二进制是 1100,b 占 4bit,正好放下,存 1100。
- s.c=3,二进制是 00011,c 占 5bit,存 00011。
- s.d=4,二进制是 0100,d 占 4bit,存 0100。
位段的注意事项:
- 跨平台问题:位段有很多不确定因素,注重可移植的程序应该避免使用位段。
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定(16 位机器 int 最大 16bit,32 位机器最大 32bit)。
- 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
- 不能对位段成员使用 & 取地址符:因为内存中每个字节分配一个地址,一个字节内部的 bit 位是没有地址的,所以不能对位段的成员使用 & 操作符,也不能用 scanf 直接给位段的成员输入值,只能先输入放在一个变量中,然后赋值给位段的成员。
// 错误写法
scanf("%d", &s.a); // 错误,不能对位段成员取地址
// 正确写法
int tmp = 0;
scanf("%d", &tmp);
s.a = tmp; // 正确,先输入到普通变量,再赋值给位段成员
三、联合体与枚举:被很多人忽略的实用自定义类型
除了结构体,C 语言还有两个很实用的自定义类型:联合体(共用体)和枚举,很多新手不怎么用,但它们在特定场景下特别好用。
1. 联合体:所有成员共用一块内存
联合体的关键字是 union,它和结构体最大的区别,就是所有成员共用同一块内存空间,所以联合体的大小,至少是最大成员的大小。
联合体的基础特性例子:
#include <stdio.h>
union Un
{
char c;
int i;
};
int main()
{
union Un un = {0};
printf("sizeof(un) = %d\n", sizeof(un));
printf("&un = %p\n", &un);
printf("&un.i = %p\n", &un.i);
printf("&un.c = %p\n", &un.c);
return 0;
}
运行结果(示例):
sizeof(un) = 4
&un = 000000821895F944
&un.i = 000000821895F944
&un.c = 000000821895F944
解析:你看,联合体变量 un 的地址、成员 i 的地址、成员 c 的地址,完全一模一样!因为它们共用同一块内存的起始地址。整个联合体的大小是 4 字节,因为最大的成员是 int 类型的 i,占 4 字节,足够放下 char 类型的 c 了。
共用内存的直接体现:改一个成员,影响其他成员
#include <stdio.h>
int main()
{
union Un
{
char c;
int i;
}un;
un.i = 0x11223344;
printf("修改前,un.i = %x\n", un.i);
un.c = 0x55;
printf("修改后,un.i = %x\n", un.i);
return 0;
}
运行结果:
修改前,un.i = 11223344
修改后,un.i = 11223355
解析:在小端模式下,un.i 的 4 个字节,低地址存的是 0x44(最低位字节),而 un.c 访问的就是第一个字节(低地址的字节),所以给 un.c 赋值 0x55,就把第一个字节的 0x44 改成了 0x55,整个 i 的值就变成了 0x11223355。这就是联合体共用内存的直接体现,也是我们用它判断大小端的核心原理。
联合体大小的计算,也要考虑对齐:联合体的大小计算规则:
- 至少是最大成员的大小;
- 当最大成员的大小,不是所有成员的最大对齐数的整数倍时,要对齐到最大对齐数的整数倍。
看例子:
#include <stdio.h>
union Un1
{
char c[5]; // 5个char,一共5字节
int i; // 4字节
};
union Un2
{
short c[7]; // 7个short,一共14字节
int i; // 4字节
};
int main()
{
printf("sizeof(Un1) = %d\n", sizeof(union Un1));
printf("sizeof(Un2) = %d\n", sizeof(union Un2));
return 0;
}
运行结果:
sizeof(Un1) = 8
sizeof(Un2) = 16
详细解析:先看 Un1:
- 最大的成员是 char c [5],大小 5 字节。
- 成员的对齐数:char 的对齐数是 1,int 的对齐数是 4,所以最大对齐数是 4。
- 5 不是 4 的整数倍,所以要对齐到 4 的整数倍,也就是 8 字节,所以 Un1 的大小是 8。
再看 Un2:
- 最大的成员是 short c [7],大小 7*2=14 字节。
- 成员的对齐数:short 的对齐数是 2,int 的对齐数是 4,最大对齐数是 4。
- 14 不是 4 的整数倍,对齐到 4 的整数倍就是 16,所以 Un2 的大小是 16。
联合体的实用场景:节省内存的经典案例:
联合体最常用的场景,就是多个互斥的属性,同一时间只会用到一个,用联合体可以极大节省内存。比如我们要做一个礼品兑换系统,商品有三种类型:图书、杯子、衬衫。
- 公共属性:库存量、价格、商品类型
- 图书专属属性:书名、作者、页数
- 杯子专属属性:设计描述
- 衬衫专属属性:设计描述、颜色数量、尺寸数量
如果不用联合体,直接把所有属性都放在结构体里,是这样的:
struct gift_list
{
// 公共属性
int stock_number;
double price;
int item_type; // 1-图书,2-杯子,3-衬衫
// 所有商品的专属属性,不管用不用都占着空间
char title[20];
char author[20];
int num_pages;
char design[30];
int colors;
int sizes;
};
算上对齐填充,整个结构体大小超过 100 字节。但问题是,一个商品只能是其中一种类型,比如是图书,那杯子和衬衫的属性完全用不到,却一直占着内存,非常浪费。
用联合体优化之后,是这样的:
struct gift_list
{
// 公共属性
int stock_number;
double price;
int item_type; // 1-图书,2-杯子,3-衬衫
// 专属属性用联合体,同一时间只会用到一个,共用内存
union {
struct
{
char title[20];
char author[20];
int num_pages;
}book;
struct
{
char design[30];
}mug;
struct
{
char design[30];
int colors;
int sizes;
}shirt;
}item;
};
我们算一下优化后的大小:
- 公共属性部分:4+8+4=16 字节(对齐后)
- 联合体部分:最大的成员是 shirt 结构体,大小 30+4+4=38 字节,对齐后是 40 字节。
- 整个结构体总大小是 16+40=56 字节左右,比原来的 100 多字节,节省了将近一半的空间!因为联合体里的三个结构体,共用同一块内存,同一时间只会用到其中一个,完全不会浪费空间,这就是联合体的核心价值。
2. 枚举:把可能的取值一一列举
枚举就是把一个变量所有可能的取值,全部一一列举出来,关键字是 enum。比如一周的 7 天、性别、三原色,这些取值有限的场景,都特别适合用枚举。
枚举的基础用法:
// 声明一个星期的枚举类型
enum Day
{
Mon, // 星期一,默认值0
Tues, // 星期二,默认值1
Wed, // 星期三,默认值2
Thur, // 星期四,默认值3
Fri, // 星期五,默认值4
Sat, // 星期六,默认值5
Sun // 星期日,默认值6
};
枚举常量的默认值,是从 0 开始,依次递增 1。我们也可以手动给枚举常量赋初值,没有赋值的,会接着上一个的值递增 1。
enum Color
{
RED=2, // 手动赋值2
GREEN=4, // 手动赋值4
BLUE=8, // 手动赋值8
YELLOW, // 没有赋值,接着上一个8,就是9
PURPLE // 10
};
枚举的使用例子:
#include <stdio.h>
// 性别枚举
enum Sex
{
MALE, // 0
FEMALE, // 1
SECRET // 2
};
int main()
{
enum Sex s = MALE; // 用枚举常量给枚举变量赋值
if(s == MALE)
{
printf("性别:男\n");
}
else if(s == FEMALE)
{
printf("性别:女\n");
}
else
{
printf("性别:保密\n");
}
return 0;
}
枚举最常用的场景之一,就是 switch case 里,用枚举常量代替魔法数字,可读性直接拉满。对比一下:
// 不用枚举的写法(魔法数字,可读性差)
int get_weekend(int day)
{
// 0-周一,1-周二...6-周日,别人看代码根本不知道这些数字是什么意思
if(day == 5 || day == 6)
{
return 1;
}
return 0;
}
// 用枚举的写法(可读性极强,一看就懂)
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
int get_weekend(enum Day day)
{
if(day == Fri || day == Sat)
{
return 1;
}
return 0;
}
枚举相比 #define 的优势:
很多人说,我用 #define 也能定义常量,为什么要用枚举?我们对比一下,就知道枚举的好处了:
- 一次定义多个常量,代码更简洁:枚举一行声明,就能定义 7 个相关的常量,而 #define 要写 7 行,非常繁琐。
- 有类型检查,更严谨:枚举是有类型的,enum Day 类型的变量,只能赋值枚举里的常量,编译器会做类型检查;而 #define 只是预处理的文本替换,没有任何类型,很容易出错。比如你给 enum Day 的变量赋值 100,编译器会给警告,而 #define 的话,完全不会有任何提示。
- 方便调试:#define 定义的符号,在预处理阶段就被替换掉了,调试的时候,你只能看到数字 0、1、2,看不到 MON、TUES 这些名字,非常难调试;而枚举常量在调试的时候,能正常显示名字,调试起来方便太多了。
- 有作用域规则:枚举常量遵循 C 语言的作用域规则,如果你把枚举声明在函数里,就只能在函数内使用,不会污染全局命名空间;而 #define 定义的是全局宏,不管你写在哪里,后面的代码都能用到,很容易造成命名冲突。
- 代码可读性和可维护性更好:枚举把相关的常量都放在一起,一看就知道这些值是属于同一个场景的,代码的语义更清晰;而 #define 的宏是分散的,别人看代码的时候,很难知道这些宏是相关的。
四、动态内存管理:灵活操控堆区内存,避开这些坑
C 语言里,我们平时定义的局部变量,都是存在栈区的,大小固定,函数结束就自动释放了。但如果我们需要在运行时才确定内存大小,或者需要大块的内存,就需要用到动态内存管理,在堆区申请和释放内存。
核心就是四个函数:malloc、calloc、realloc、free,都在<stdlib.h>头文件里。
1. 核心函数用法详解 + 完整例子
先简单回顾 C 语言的内存分区:
- 栈区(stack):存放局部变量、函数参数,函数结束自动释放,大小固定。
- 堆区(heap):动态内存分配的区域,需要我们手动申请、手动释放,大小灵活,不释放会造成内存泄漏。
- 静态区(全局区):存放全局变量、静态变量,程序结束自动释放。
- 代码区:存放程序的二进制代码。
我们要学的这四个函数,都是用来操作堆区的内存的。
(1)malloc 函数:申请堆内存
函数原型:void* malloc(size_t size);
- 参数 size:要申请的内存大小,单位是字节。
- 返回值:申请成功,返回指向这块内存的 void * 指针;申请失败,返回 NULL。
- 注意:void类型的指针,不能直接解引用,需要强转成对应的类型,比如 int、char*。
- 重点:一定要判断返回值是否为 NULL!不然申请失败的话,解引用 NULL 指针会直接崩溃。
malloc 的完整使用例子:
#include <stdio.h>
#include <stdlib.h> // malloc、free的头文件
#include <string.h>
int main()
{
// 申请10个int的内存,也就是10*4=40字节
int* p = (int*)malloc(10 * sizeof(int));
// 一定要判断是否申请成功
if(p == NULL)
{
// perror会打印错误信息,格式是:你写的字符串 + 错误原因
perror("malloc failed");
return 1; // 申请失败,退出程序
}
// 申请成功,使用这块内存:给10个int赋值
for(int i=0; i<10; i++)
{
p[i] = i+1; // 等价于 *(p+i) = i+1
}
// 打印内容
for(int i=0; i<10; i++)
{
printf("%d ", p[i]);
}
printf("\n");
// 用完了,释放内存
free(p);
// 释放后,p变成了野指针,手动置为NULL,避免后面误操作
p = NULL;
return 0;
}
运行结果:1 2 3 4 5 6 7 8 9 10
(2)free 函数:释放动态内存
函数原型:void free(void* ptr);
- 参数 ptr:指向要释放的动态内存的指针,必须是 malloc、calloc、realloc 返回的指针。
- 注意事项:
- free 只能释放堆区的动态内存,不能释放栈区的内存,否则会崩溃。
- 如果 ptr 是 NULL,free 什么都不会做,不会报错。
- 释放之后,原来的指针就变成了野指针,一定要手动置为 NULL,不然就是 “悬空指针”,后面不小心解引用会造成未定义行为。
- 不能重复释放同一块内存,否则会崩溃。
(3)calloc 函数:申请并初始化内存
函数原型:void* calloc(size_t num, size_t size);
- 参数:num 是要申请的元素个数,size 是每个元素的大小,总大小是 num*size。
- 返回值:和 malloc 一样,成功返回 void * 指针,失败返回 NULL。
- 特点:calloc 会把申请的内存里的每一个字节,都初始化为 0,而 malloc 申请的内存里是随机的垃圾值。
calloc 和 malloc 的对比例子:
#include <stdio.h>
#include <stdlib.h>
int main()
{
// malloc申请10个int的内存
int* p1 = (int*)malloc(10 * sizeof(int));
if(p1 == NULL)
{
perror("malloc failed");
return 1;
}
printf("malloc申请的内存内容:\n");
for(int i=0; i<10; i++)
{
printf("%d ", p1[i]); // 打印的是随机的垃圾值
}
printf("\n");
// calloc申请10个int的内存
int* p2 = (int*)calloc(10, sizeof(int));
if(p2 == NULL)
{
perror("calloc failed");
free(p1);
p1 = NULL;
return 1;
}
printf("calloc申请的内存内容:\n");
for(int i=0; i<10; i++)
{
printf("%d ", p2[i]); // 全是0
}
printf("\n");
// 释放内存
free(p1);
p1 = NULL;
free(p2);
p2 = NULL;
return 0;
}
运行结果示例:
malloc申请的内存内容:
-842150451 -842150451 -842150451 -842150451 -842150451 -842150451 -842150451 -842150451 -842150451 -842150451
calloc申请的内存内容:
0 0 0 0 0 0 0 0 0 0
所以,如果你需要申请的内存要初始化为 0,用 calloc 就很方便,不用自己手动 memset 了。当然,calloc 因为要初始化,效率比 malloc 稍微低一点,看场景选择。
(4)realloc 函数:调整动态内存的大小
函数原型:void* realloc(void* ptr, size_t size);
- 参数:ptr 是原来的动态内存的指针(malloc/calloc/realloc 返回的),size 是调整后的新大小,单位是字节。
- 返回值:调整成功,返回指向新内存的指针;调整失败,返回 NULL。
- 核心特点:realloc 不仅能扩容,还能缩容,而且会把原来内存里的数据,拷贝到新的内存里。
【realloc 扩容的两种情况】realloc 扩容的时候,有两种情况:
- 原地扩容:原来的内存后面,有足够大的空闲空间,直接在后面追加,返回的地址和原来的 ptr 一样,原来的数据不会变。
- 异地扩容:原来的内存后面没有足够的空间,realloc 会重新找一块足够大的连续内存,把原来的数据拷贝过去,释放原来的内存,返回新的地址。
realloc 的正确使用例子:
重点提醒:不能直接用原来的指针接收 realloc 的返回值!如果 realloc 扩容失败,会返回 NULL,如果你直接赋值给原来的 p,那原来的地址就丢了,不仅内存泄漏,还会变成野指针。正确的做法是:先用一个临时指针接收,判断不为 NULL,再赋值给原来的指针。
#include <stdio.h>
#include <stdlib.h>
int main()
{
// 先申请5个int的内存,20字节
int* p = (int*)malloc(5 * sizeof(int));
if(p == NULL)
{
perror("malloc failed");
return 1;
}
// 赋值
for(int i=0; i<5; i++)
{
p[i] = i+1;
}
printf("原来的5个元素:");
for(int i=0; i<5; i++)
{
printf("%d ", p[i]);
}
printf("\n");
// 现在需要扩容到10个int,40字节
// 正确写法:先用临时指针接收
int* ptr = (int*)realloc(p, 10 * sizeof(int));
if(ptr == NULL)
{
perror("realloc failed");
// 扩容失败,原来的p还在,要释放掉
free(p);
p = NULL;
return 1;
}
// 扩容成功,把新地址赋给p
p = ptr;
// 给后面新增的5个元素赋值
for(int i=5; i<10; i++)
{
p[i] = i+1;
}
printf("扩容后的10个元素:");
for(int i=0; i<10; i++)
{
printf("%d ", p[i]);
}
printf("\n");
// 也可以缩容,比如缩成3个int
int* ptr2 = (int*)realloc(p, 3 * sizeof(int));
if(ptr2 == NULL)
{
perror("realloc shrink failed");
free(p);
p = NULL;
return 1;
}
p = ptr2;
printf("缩容后的3个元素:");
for(int i=0; i<3; i++)
{
printf("%d ", p[i]);
}
printf("\n");
// 释放内存
free(p);
p = NULL;
return 0;
}
运行结果:
原来的5个元素:1 2 3 4 5
扩容后的10个元素:1 2 3 4 5 6 7 8 9 10
缩容后的3个元素:1 2 3
还有一个小技巧:realloc 的第一个参数传 NULL,效果就和 malloc 一样,比如int* p = realloc(NULL, 40); 等价于 int* p = malloc(40);
2. 动态内存管理的 6 个高频坑,每个都有错误和正确示例
学动态内存,最容易踩坑,这些坑每一个都可能导致程序崩溃、内存泄漏,一定要记牢!
坑 1:不判断 malloc/realloc 的返回值,解引用 NULL 指针
错误代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(1000000000 * sizeof(int)); // 申请超大内存,大概率失败
*p = 10; // 申请失败返回NULL,解引用NULL直接崩溃
free(p);
return 0;
}
正确代码:一定要判断返回值是否为 NULL,申请失败要做错误处理,不能直接解引用。
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(1000000000 * sizeof(int));
if(p == NULL)
{
perror("malloc failed");
return 1;
}
*p = 10; // 只有申请成功,才会执行到这里
free(p);
p = NULL;
return 0;
}
坑 2:动态内存的越界访问
错误代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(5 * sizeof(int)); // 只申请了5个int
if(p == NULL)
{
perror("malloc failed");
return 1;
}
for(int i=0; i<10; i++) // 访问到第10个,越界了!
{
p[i] = i;
}
free(p);
p = NULL;
return 0;
}
后果:越界访问属于未定义行为,轻则程序崩溃,重则数据被篡改,出现各种奇怪的 bug。正确做法:严格控制访问范围,不能超过申请的内存大小。
坑 3:free 非动态内存
错误代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int a = 10; // 局部变量,在栈区
int* p = &a;
free(p); // 错误!free只能释放堆区的动态内存
return 0;
}
后果:直接崩溃,未定义行为。正确做法:free 只能释放 malloc、calloc、realloc 申请的堆内存,其他内存绝对不能用 free。
坑 4:重复释放同一块内存
错误代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if(p == NULL)
{
perror("malloc failed");
return 1;
}
free(p); // 第一次释放
// ... 中间的代码
free(p); // 第二次释放,重复释放,直接崩溃
return 0;
}
为什么会崩溃?因为第一次 free 之后,p 指向的内存已经被回收了,归还给操作系统了,你再去 free 一个已经被释放的地址,就是非法操作。
正确做法:
- 不要重复 free;
- free 之后,立刻把指针置为 NULL,因为 free (NULL) 是安全的,什么都不会做。
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if(p == NULL)
{
perror("malloc failed");
return 1;
}
free(p);
p = NULL; // 释放后立刻置NULL
// ... 中间的代码
free(p); // 现在p是NULL,free(NULL)是安全的,不会报错
return 0;
}
坑 5:内存泄漏:申请的内存用完不释放
错误代码:
#include <stdio.h>
#include <stdlib.h>
void test()
{
int* p = (int*)malloc(40);
if(p == NULL)
{
return;
}
// 用完了,没有free
// 函数结束,p这个局部变量销毁了,申请的40字节内存再也找不到了,就泄漏了
}
int main()
{
while(1)
{
test(); // 循环调用,内存越漏越多
}
return 0;
}
后果:内存泄漏,申请的内存用完没有释放,程序不退出,这块内存就一直被占用着,系统的可用内存会越来越少,最后程序卡死,甚至系统崩溃。尤其是在服务器程序里,7*24 小时运行,一点点内存泄漏,时间长了也会出大问题。
正确做法:谁申请,谁释放,申请的动态内存,用完一定要 free。哪怕是在函数里申请的内存,也要保证在使用完之后释放,或者明确告诉调用者需要释放。
坑 6:释放内存后,继续使用野指针
错误代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if(p == NULL)
{
perror("malloc failed");
return 1;
}
*p = 10;
free(p); // 释放了内存
*p = 20; // 错误!释放后还继续使用,野指针,未定义行为
return 0;
}
后果:释放后的内存已经不属于你的程序了,再去修改,轻则数据错乱,重则程序直接崩溃。正确做法:free 之后,立刻把指针置为 NULL,避免后续误操作。
五、学习总结
这三节课学下来,最大的感受就是:写 C 语言,一定要懂内存。之前写代码只关注功能实现,却忽略了底层的内存逻辑,很容易写出有隐藏 bug 的代码,也搞不懂为什么会出各种奇怪的问题。
不管是数据的存储规则、大小端、结构体内存对齐,还是动态内存管理,本质都是在和内存打交道。理解了这些底层逻辑,不仅能应付笔试面试,更能写出更高效、更安全、更少 bug 的 C 语言代码。
当然,光看知识点没用,这些例子我都是亲手敲过、调试过内存才真正理解的,建议大家也把这些代码复制到编译器里,一步步调试看内存布局,踩一遍坑,才能把这些知识点真正吃透。也希望我的这篇复盘笔记,能帮到同样在学习 C 语言的你~
:进阶学习复盘-吃透内存与自定义类型&spm=1001.2101.3001.5002&articleId=159209658&d=1&t=3&u=442140aa4e624016ae7a59acc354f838)
1505

被折叠的 条评论
为什么被折叠?



