C 语言(十):进阶学习复盘-吃透内存与自定义类型

吃透内存与自定义类型,这三节课干货拉满

最近集中啃完了 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. 先看 - 1 的补码:int 类型的 - 1,补码是 32 位全 1:11111111 11111111 11111111 11111111
  2. 赋值给 char 类型,会发生截断,只保留最低的 8 位:11111111
  3. 对于 a 和 b(signed char):11111111的补码,转成原码是10000001,也就是 - 1,用 % d 打印(整型提升后还是 - 1),所以输出 - 1
  4. 对于 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

详细解析:

  1. -128 的补码:int 类型的 - 128,补码是 11111111 11111111 11111111 10000000
  2. 赋值给 char 截断,保留低 8 位:10000000
  3. 用 % u 打印,需要先做整型提升:char 是 signed 的,符号位是 1,所以整型提升的时候,高位全补 1,最终 32 位补码是 11111111 11111111 11111111 10000000
  4. % 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 个字节,假设内存地址从低到高是:0x005DF8480x005DF8490x005DF84A0x005DF84B

  • 小端模式(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

详细拆解:

  1. 首先,int 类型的 9,在内存里存的是 32 位补码:0000 0000 0000 0000 0000 0000 0000 1001
  2. 现在我们用 float 的规则来解读这 32 位,float 的 32 位是这样划分的:| 符号位 S(1 位) | 指数位 E(8 位) | 有效数字 M(23 位) ||----------------|----------------|--------------------|| 0 | 00000000 | 00000000000000000001001 |
  3. 我们看 E 的部分,是全 0,符合 IEEE754 里 E 全为 0 的特殊规则:
    • 真实指数 E = 1 - 127 = -126
    • 有效数字 M 不再补前面的 1,直接就是 0.xxxxxx,也就是 0.00000000000000000001001
  4. 代入公式计算: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

详细拆解:

  1. 先把十进制的 9.0 转成二进制:9.0 的二进制是1001.0
  2. 转成 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
  3. 把 S、E、M 拼起来,32 位的二进制就是:0 10000010 00100000000000000000000完整的二进制:01000001 00010000 00000000 00000000
  4. 现在我们把这个 32 位的二进制,当成 int 类型的补码来解读,转成十进制,就是 1091567616,和打印结果完全一致。

例子 3:常规浮点数 3.14 的存储拆解;我们再拿常用的 3.14 来拆解,加深理解:
  1. 十进制 3.14 转二进制:整数部分 3 是 11,小数部分 0.14 转二进制是无限循环的,大概是0.00100011110101110000101...,所以 3.14 的二进制是11.00100011110101110000101...
  2. 转成科学计数法:1.100100011110101110000101... * 2^1
  3. 拆分 S、E、M:
    • S=0(正数)
    • 真实 E=1,存储的 E=1+127=128,二进制是10000000
    • M 去掉前面的 1,剩下100100011110101110000101...,截取前 23 位,就是10010001111010111000011
  4. 最终 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 没有默认对齐数,对齐数就是成员自身大小):

  1. 第一个成员,直接对齐到结构体起始地址偏移量为 0 的位置;
  2. 从第二个成员开始,要对齐到「对齐数」的整数倍地址。对齐数 = 编译器默认对齐数(VS 默认 8)和成员自身大小的较小值;
  3. 结构体的总大小,必须是所有成员里最大对齐数的整数倍;
  4. 如果嵌套了结构体,嵌套的结构体要对齐到自己内部最大对齐数的整数倍,整体总大小是所有最大对齐数(含嵌套结构体的)的整数倍。
例子 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 的大小:

  1. 第一个成员 c1,char 类型,大小 1 字节,放在偏移量 0 的位置,占用偏移量 0。
  2. 第二个成员 i,int 类型,大小 4 字节。对齐数 = min (8,4)=4,所以要对齐到偏移量是 4 的整数倍的位置。前面偏移量 0 已经被用了,下一个可用的偏移量是 1,1 不是 4 的倍数,所以要填充 3 个字节(偏移量 1、2、3 都空着),从偏移量 4 开始放 i,占用偏移量 4、5、6、7。
  3. 第三个成员 c2,char 类型,大小 1 字节。对齐数 = min (8,1)=1,所以下一个可用的偏移量 8 就是 1 的倍数,直接放在偏移量 8,占用偏移量 8。
  4. 现在所有成员都放完了,一共占用了 9 个字节(0-8)。接下来看规则 3:结构体的总大小必须是最大对齐数的整数倍。S1 里的成员对齐数分别是 1、4、1,最大对齐数是 4。9 不是 4 的整数倍,所以要再填充 3 个字节(偏移量 9、10、11),最终总大小是 12 字节。

S1 的内存布局示意图(偏移量):

01 2 3(填充)4 5 6 789 10 11(填充)
c1浪费的空间ic2浪费的空间

再算 S2 的大小,成员完全一样,只是顺序换了:

  1. c1 放在偏移量 0,占用 0。
  2. c2 是 char,对齐数 1,偏移量 1 是 1 的倍数,放在偏移量 1,占用 1。
  3. i 是 int,对齐数 4,下一个可用偏移量是 2,不是 4 的倍数,填充 2 个字节(偏移量 2、3),从偏移量 4 开始放 i,占用 4、5、6、7。
  4. 所有成员放完,一共占用了 8 字节。最大对齐数是 4,8 正好是 4 的整数倍,不需要额外填充,最终大小就是 8 字节。

S2 的内存布局:

012 3(填充)4 5 6 7
c1c2浪费的空间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字节

计算过程:

  1. d 是 double,大小 8 字节,对齐数 min (8,8)=8,放在偏移量 0,占用 0-7。
  2. c 是 char,对齐数 1,放在偏移量 8,占用 8。
  3. i 是 int,对齐数 4,下一个偏移量 9,不是 4 的倍数,填充 3 个字节(9、10、11),从偏移量 12 开始放 i,占用 12-15。
  4. 总占用 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字节

计算过程:

  1. c1 是 char,放在偏移量 0,占用 0。
  2. 嵌套的结构体 s3,根据规则 4,嵌套的结构体要对齐到自己内部的最大对齐数的整数倍。S3 内部的最大对齐数是 8(double 的对齐数),所以 s3 要对齐到 8 的整数倍。下一个可用偏移量是 1,不是 8 的倍数,填充 7 个字节(1-7),从偏移量 8 开始放 s3,s3 大小 16 字节,占用 8-23。
  3. d 是 double,对齐数 8,下一个偏移量 24,是 8 的倍数,放在 24-31。
  4. 总占用 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,不能随便设置。

这里还要说一下,为什么要有内存对齐?本质就是「用空间换时间」

  1. 平台原因 (移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  2. 性能原因:数据结构 (尤其是栈) 应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

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字节!

计算过程:

  1. 位段的成员是 int 类型,所以一次开辟 4 字节(32 个 bit 位)的空间。
  2. _a 占 2bit,_b 占 5bit,_c 占 10bit,加起来一共 2+5+10=17bit,32bit 还剩 15bit。
  3. 接下来的_d 要占 30bit,剩下的 15bit 不够了,所以 VS 里会舍弃剩下的 15bit,再开辟一个 4 字节(32bit)的空间,用来放_d 的 30bit。
  4. 所以一共开辟了 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

详细解析内存开辟和赋值过程:

  1. 位段成员是 char 类型,一次开辟 1 字节(8bit)的空间。
  2. 第一个成员 a 占 3bit,第一个字节还剩 8-3=5bit。
  3. 第二个成员 b 占 4bit,5bit 够放,所以放在第一个字节剩下的空间里,现在第一个字节还剩 5-4=1bit。
  4. 第三个成员 c 占 5bit,剩下的 1bit 不够了,VS 里会舍弃这 1bit,再开辟第二个字节(8bit),放 c 的 5bit,第二个字节还剩 8-5=3bit。
  5. 第四个成员 d 占 4bit,剩下的 3bit 不够,再开辟第三个字节(8bit),放 d 的 4bit。
  6. 所以一共开辟了 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。
位段的注意事项:
  1. 跨平台问题:位段有很多不确定因素,注重可移植的程序应该避免使用位段。
    • int 位段被当成有符号数还是无符号数是不确定的。
    • 位段中最大位的数目不能确定(16 位机器 int 最大 16bit,32 位机器最大 32bit)。
    • 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
    • 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
  2. 不能对位段成员使用 & 取地址符:因为内存中每个字节分配一个地址,一个字节内部的 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。这就是联合体共用内存的直接体现,也是我们用它判断大小端的核心原理。

联合体大小的计算,也要考虑对齐:联合体的大小计算规则:

  1. 至少是最大成员的大小;
  2. 当最大成员的大小,不是所有成员的最大对齐数的整数倍时,要对齐到最大对齐数的整数倍。

看例子:

#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 也能定义常量,为什么要用枚举?我们对比一下,就知道枚举的好处了:

  1. 一次定义多个常量,代码更简洁:枚举一行声明,就能定义 7 个相关的常量,而 #define 要写 7 行,非常繁琐。
  2. 有类型检查,更严谨:枚举是有类型的,enum Day 类型的变量,只能赋值枚举里的常量,编译器会做类型检查;而 #define 只是预处理的文本替换,没有任何类型,很容易出错。比如你给 enum Day 的变量赋值 100,编译器会给警告,而 #define 的话,完全不会有任何提示。
  3. 方便调试:#define 定义的符号,在预处理阶段就被替换掉了,调试的时候,你只能看到数字 0、1、2,看不到 MON、TUES 这些名字,非常难调试;而枚举常量在调试的时候,能正常显示名字,调试起来方便太多了。
  4. 有作用域规则:枚举常量遵循 C 语言的作用域规则,如果你把枚举声明在函数里,就只能在函数内使用,不会污染全局命名空间;而 #define 定义的是全局宏,不管你写在哪里,后面的代码都能用到,很容易造成命名冲突。
  5. 代码可读性和可维护性更好:枚举把相关的常量都放在一起,一看就知道这些值是属于同一个场景的,代码的语义更清晰;而 #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 返回的指针。
  • 注意事项:
    1. free 只能释放堆区的动态内存,不能释放栈区的内存,否则会崩溃。
    2. 如果 ptr 是 NULL,free 什么都不会做,不会报错。
    3. 释放之后,原来的指针就变成了野指针,一定要手动置为 NULL,不然就是 “悬空指针”,后面不小心解引用会造成未定义行为。
    4. 不能重复释放同一块内存,否则会崩溃。
(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 扩容的时候,有两种情况:

  1. 原地扩容:原来的内存后面,有足够大的空闲空间,直接在后面追加,返回的地址和原来的 ptr 一样,原来的数据不会变。
  2. 异地扩容:原来的内存后面没有足够的空间,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 一个已经被释放的地址,就是非法操作。

正确做法:

  1. 不要重复 free;
  2. 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 语言的你~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值