关注

C语言(一):指针、内存、结构体、联合体与底层细节

1. 指针到底是什么

指针本质上是一个变量,这个变量里存放的值不是普通整数,而是某块内存的地址

例如:

int a = 10;
int *p = &a;

这里:

  • a 是一个 int 变量

  • &a 表示变量 a 的地址

  • p 是一个指针变量

  • p 里保存的是 a 的地址

  • *p 表示“根据地址找到那块内存中的值”

可以把它理解成:

变量a:      值 = 10
地址假设:   0x1000

指针p:      值 = 0x1000
含义:       p指向地址0x1000这块内存
*p:         访问0x1000里存放的数据,也就是10

内存示意图:

+--------+         +--------+
|   p    | ----->  |   a    |
| 0x1000 |         |   10   |
+--------+         +--------+

2. &* 的真正含义

这是面试里最基础但也最容易答乱的地方。

2.1 &:取地址

&a 表示取变量 a 的地址。

2.2 *:解引用

如果 p 存的是某个地址,那么 *p 表示访问这个地址里的内容。

示例:

int a = 20;
int *p = &a;

printf("%p\n", (void*)&a); // a的地址
printf("%p\n", (void*)p);  // p里存的地址
printf("%d\n", *p);        // 地址对应的值

2.3 容易混淆的地方

int *p;

这里的 * 不是解引用,而是在声明:
p 是一个“指向 int 的指针”。

x = *p;

这里的 * 才是解引用:
去访问 p 指向地址里的内容。


3. 指针为什么有类型

很多人学到后面会有一个误区:
“既然地址本质上都是数字,那指针类型是不是无所谓?”

不是。

指针类型决定两件事:

  • 解引用时,一次读几个字节

  • 指针运算时,步长是多少

示例:

int a = 0x12345678;
char *pc = (char *)&a;
int  *pi = &a;

如果小端存储,内存可能是:

地址      数据
0x1000    0x78
0x1001    0x56
0x1002    0x34
0x1003    0x12

那么:

  • pcchar 读取,一次读 1 字节

  • piint 读取,一次读 4 字节

也就是说,指针类型决定了“怎么解释这块内存”


4. 指针运算

4.1 指针加一不是地址加一字节

指针加多少,取决于它指向的数据类型。

int arr[3] = {10, 20, 30};
int *p = arr;

(假设 int 占 4 字节):

地址      内容
0x1000    10
0x1004    20
0x1008    30

则:

  • p 指向 0x1000

  • p + 1 指向 0x1004

  • p + 2 指向 0x1008

即:

printf("%d\n", *p);       // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30

4.2 为什么这样设计

因为指针本来就是为了“访问一组同类型数据”,如果每次只加 1 字节,那遍历数组会非常麻烦。


5. 数组和指针的关系

这块是经典高频题。

5.1 数组名多数情况下会退化为首元素地址

int arr[5] = {1,2,3,4,5};
int *p = arr;

这里 arr 在表达式中通常等价于 &arr[0]

所以:

arr == &arr[0]

在数值上通常相等。


5.2 但数组不是指针

这是面试必问。

数组和指针的区别:

  1. 数组是一个完整对象

  2. 指针是一个变量

  3. sizeof(数组) 取整个数组大小

  4. sizeof(指针) 取指针变量本身大小

示例:

int arr[10];
int *p = arr;

printf("%zu\n", sizeof(arr)); // 40,假设int 4字节
printf("%zu\n", sizeof(p));   // 8,64位系统下

5.3 arr&arr 的区别

int arr[5];
  • arr:大多数情况下是首元素地址,类型接近 int *

  • &arr:整个数组的地址,类型是 int (*)[5]

这两个数值可能看起来一样,但类型不同,指针运算结果也不同。

示意:

arr      -> 指向 arr[0]
&arr     -> 指向整个数组对象

6. 一维指针、二级指针、三级指针

6.1 一级指针

int a = 10;
int *p = &a;

6.2 二级指针

int a = 10;
int *p = &a;
int **pp = &p;

图示:

pp -----> p -----> a
          地址     值

访问关系:

  • pa 的地址

  • *pa

  • ppp 的地址

  • *ppp

  • **ppa

6.3 二级指针常见用途

用途一:修改指针本身

例如函数内部要修改外部指针,让它指向新内存:

void alloc_mem(int **p)
{
    *p = (int *)malloc(sizeof(int));
    if (*p != NULL)
    {
        **p = 100;
    }
}

如果只传一级指针,函数里改的是副本,外面看不到变化。

用途二:处理指针数组、字符串数组
char *strs[] = {"abc", "def", "ghi"};
char **p = strs;

7. 指针作为函数参数

C 语言函数参数传递本质上都是值传递

7.1 想修改实参的值,就传地址

void swap(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

调用:

int x = 10, y = 20;
swap(&x, &y);

7.2 面试高频追问

“为什么传指针就能修改外部变量?”

因为虽然参数仍然是值传递,但传进去的是“地址这个值”。
函数内部通过这个地址可以找到原来的变量并修改它。


8. const 和指针

这是特别容易绕晕的地方。

8.1 const int *p

读作:p 指向的内容不能通过 p 修改。

int a = 10;
int b = 20;
const int *p = &a;

// *p = 100; // 错误
p = &b;      // 可以

含义:

  • 可以改指向

  • 不能改指向的值


8.2 int * const p

读作:p 本身不能改,但它指向的内容可以改。

int a = 10;
int * const p = &a;

*p = 100;   // 可以
// p = &b;  // 错误

含义:

  • 不能改指向

  • 可以改内容


8.3 const int * const p

两边都不能改。


8.4 记忆方法

从右往左看。

  • int * const pp 是常量指针

  • const int *pp 指向常量整数


9. void * 指针

void *通用指针,可以接收任意类型对象的地址。

int a = 10;
void *p = &a;

但是 void * 不能直接解引用,因为编译器不知道应该按几个字节读取。

printf("%d\n", *(int *)p);

必须先强转。

面试点

malloc 返回的就是 void *,因为它不知道你要申请什么类型的内存。


10. 野指针、悬空指针、空指针

10.1 空指针

没有指向任何有效对象的指针,值通常是 NULL

int *p = NULL;

10.2 野指针

没有初始化就乱指向某块未知地址的指针。

int *p;
*p = 10; // 严重错误

10.3 悬空指针

指针原来指向有效内存,但那块内存已经失效了。

int *p = (int *)malloc(sizeof(int));
free(p);
// 此时p成了悬空指针

正确写法:

free(p);
p = NULL;

11. 动态内存管理

11.1 为什么需要动态内存

静态数组大小往往编译时就固定,而实际运行过程中,很多数据规模是不确定的,所以需要运行时申请内存。

C 语言常用函数:

  • malloc

  • calloc

  • realloc

  • free

这些函数定义在:

#include <stdlib.h>

11.2 malloc

int *p = (int *)malloc(10 * sizeof(int));

含义:

  • 申请一块连续内存

  • 大小为 10 * sizeof(int)

  • 返回首地址

  • 申请失败返回 NULL

注意:malloc 不会初始化内存


11.3 calloc

int *p = (int *)calloc(10, sizeof(int));

malloc 区别:

  • calloc 会把申请到的内存初始化为 0

  • 参数是“元素个数”和“每个元素大小”


11.4 realloc

int *p = (int *)malloc(5 * sizeof(int));
p = (int *)realloc(p, 10 * sizeof(int));

作用:

  • 对原有内存扩容或缩容

注意点:

  1. 可能原地扩容

  2. 也可能搬到新地址

  3. 原指针可能失效

更稳妥写法:

int *tmp = (int *)realloc(p, 10 * sizeof(int));
if (tmp != NULL)
{
    p = tmp;
}

11.5 free

free(p);
p = NULL;

面试高频错误

错误一:重复释放
free(p);
free(p); // 错误
错误二:释放非堆区内存
int a = 10;
int *p = &a;
free(p); // 错误
错误三:内存泄漏
int *p = malloc(sizeof(int));
p = malloc(sizeof(int)); // 第一次申请的地址丢了

11.6 动态内存的典型面试题

题 1:函数返回局部变量地址为什么错

int *func()
{
    int a = 10;
    return &a;
}

原因:

  • a 是局部变量,位于栈区

  • 函数结束后栈帧销毁

  • 返回的地址失效

正确做法

方法一:返回静态变量地址
int *func()
{
    static int a = 10;
    return &a;
}
方法二:返回堆内存地址
int *func()
{
    int *p = (int *)malloc(sizeof(int));
    if (p != NULL)
    {
        *p = 10;
    }
    return p;
}

12. 内存的几个区域

面试里经常会问变量存在哪里。

一个进程的内存通常可以粗略理解成:

+------------------+
|      栈 stack     |  局部变量、函数参数
+------------------+
|      堆 heap      |  malloc/calloc/realloc申请
+------------------+
| 全局/静态区 data  |  全局变量、static变量
+------------------+
| 常量区/代码区     |  字符串常量、程序代码
+------------------+

12.1 栈

  • 自动分配,自动释放

  • 存局部变量、函数参数

  • 生命周期随作用域结束

12.2 堆

  • 手动申请,手动释放

  • 生命周期由程序员控制

12.3 全局/静态区

  • 程序运行期间一直存在

12.4 常量区

  • 字符串常量通常放在只读区域

示例:

char *p = "hello";

这里 "hello" 通常在常量区,p 在栈上(如果是局部变量)。


13. sizeofstrlen 的区别

13.1 sizeof

  • 编译期计算

  • 求对象或类型占用的字节数

13.2 strlen

  • 运行期计算

  • 求字符串长度,不包含 '\0'

示例:

char arr[] = "abc";
char *p = "abc";

printf("%zu\n", sizeof(arr)); // 4
printf("%zu\n", strlen(arr)); // 3

printf("%zu\n", sizeof(p));   // 8(假设64位)
printf("%zu\n", strlen(p));   // 3

这个题经常考数组和指针的区别。


14. 函数指针

这是 C 语言里很有“底层味”的部分,也是面试高频。

14.1 函数名的本质

函数名在大多数表达式中可以看作函数地址。

int add(int a, int b)
{
    return a + b;
}

函数指针定义:

int (*fp)(int, int) = add;

含义:

  • fp 是一个指针

  • 它指向的函数参数为两个 int

  • 返回值为 int

调用方式:

printf("%d\n", fp(2, 3));
printf("%d\n", (*fp)(2, 3));

两种都可以。


14.2 为什么函数指针重要

函数指针本质上是一种“把函数当参数传递”的机制,常见用途:

  • 回调函数

  • 状态机

  • 中断向量表

  • 菜单系统

  • 驱动框架

  • 排序比较函数


14.3 回调函数示例

#include <stdio.h>

int add(int a, int b)
{
    return a + b;
}

int sub(int a, int b)
{
    return a - b;
}

int calc(int x, int y, int (*op)(int, int))
{
    return op(x, y);
}

int main()
{
    printf("%d\n", calc(10, 5, add));
    printf("%d\n", calc(10, 5, sub));
    return 0;
}

这里 op 就是函数指针参数。


14.4 函数指针数组

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int (*ops[3])(int, int) = {add, sub, mul};

图示:

ops[0] ---> add
ops[1] ---> sub
ops[2] ---> mul

调用:

printf("%d\n", ops[0](3, 4));
printf("%d\n", ops[1](3, 4));
printf("%d\n", ops[2](3, 4));

这类写法在菜单、命令分发、状态处理中特别常见。


15. struct 结构体

结构体是把多个不同类型的数据组织成一个整体。

struct Student
{
    int id;
    char name[20];
    float score;
};

这表示创建了一个新的复合类型。

定义变量:

struct Student s1;

15.1 为什么需要结构体

因为现实中的一个对象通常不只有一个属性。

例如学生:

  • 学号

  • 姓名

  • 分数

这些属性类型可能都不一样,用结构体组织最自然。


15.2 成员访问

普通变量访问成员

s1.id = 1001;

.

结构体指针访问成员

struct Student *p = &s1;
p->id = 1002;

-> 本质上等价于:

(*p).id

15.3 结构体初始化

struct Student s = {1001, "Tom", 95.5f};

也可以指定成员初始化:

struct Student s = {
    .id = 1001,
    .score = 95.5f,
    .name = "Tom"
};

16. 结构体内存对齐

这块是面试里的重点。

16.1 为什么会有内存对齐

CPU 访问内存时,按“自然边界”对齐通常更高效。
所以编译器会在结构体成员之间插入一些填充字节(padding)。


16.2 对齐规则(常见面试版)

先记常见规则:

  1. 每个成员的起始地址,要是该成员大小的整数倍

  2. 结构体总大小,要是其最大对齐数的整数倍

  3. 编译器可能会插入填充字节


16.3 经典例子 1

struct A
{
    char c;
    int i;
};

假设:

  • char 1 字节

  • int 4 字节

内存布局:

偏移 0: c
偏移 1: padding
偏移 2: padding
偏移 3: padding
偏移 4~7: i

所以 sizeof(struct A) 通常是 8,不是 5。


16.4 经典例子 2

struct B
{
    char c1;
    short s;
    char c2;
};

假设 short 2 字节。

可能布局:

偏移 0: c1
偏移 1: padding
偏移 2~3: s
偏移 4: c2
偏移 5: padding

总大小通常为 6,如果再按最大对齐数补齐,也可能为 68,和平台/编译器设置有关。面试里默认按常见 ABI 思路分析即可。


16.5 如何减少结构体大小

把“大成员放前面,小成员放后面”,通常可以减少填充。

例如:

struct C1
{
    char c;
    int i;
    short s;
};

和:

struct C2
{
    int i;
    short s;
    char c;
};

C2 往往更省空间。

图示对比:

C1:
c + pad + i + s + pad

C2:
i + s + c + pad

16.6 offsetof

offsetof 可以求成员相对结构体首地址的偏移。

#include <stddef.h>

printf("%zu\n", offsetof(struct A, i));

这个宏在底层开发里很常见。


17. typedef struct

这是工程代码里很高频的写法。

typedef struct
{
    int id;
    char name[20];
} Student;

这样以后就可以直接写:

Student s1;

而不用写:

struct Student s1;

18. union 联合体

联合体的核心特征:
所有成员共用同一块内存。

union Data
{
    int i;
    char c;
    float f;
};

这里:

  • i

  • c

  • f

都使用同一块起始地址相同的内存空间。


18.1 联合体大小

联合体大小通常等于最大成员的大小,再考虑对齐要求。

例如:

union Data
{
    int i;    // 4
    char c;   // 1
    float f;  // 4
};

sizeof(union Data) 通常为 4。


18.2 联合体内存示意图

union Data u;

+------------------+
| 共用的一块内存区  |
+------------------+

u.i 解释为 int
u.c 解释为 char
u.f 解释为 float

18.3 联合体的特点

同一时刻通常只有一个成员是“有效”的。
写一个成员,可能会覆盖另一个成员的内容。

示例:

union Data u;
u.i = 0x12345678;
printf("%x\n", u.i);
printf("%x\n", u.c);

这里 u.c 读到的是 u.i 最低地址处那一个字节,和大小端有关。


18.4 联合体的应用

应用一:节省内存

不同场景下同一块内存存不同数据。

应用二:查看数据底层字节

例如判断大小端。

union Test
{
    int i;
    char c;
};

union Test t;
t.i = 1;

if (t.c == 1)
{
    printf("小端\n");
}
else
{
    printf("大端\n");
}

因为如果 int 的最低字节放在低地址,c 读到 1,就说明是小端。


19. 结构体和联合体的区别

对比项结构体 struct联合体 union
成员内存各自独立共用同一块
大小所有成员之和 + 对齐填充最大成员大小 + 对齐
成员可同时有效可以通常不可以
用途描述一个对象的多个属性节省空间、复用内存、查看底层字节

一句话概括:

  • struct:多个成员一起存在

  • union:多个成员轮流共用一块内存


20. 位域

位域本质上是结构体成员按“位”来分配空间。

struct Flags
{
    unsigned int a : 1;
    unsigned int b : 2;
    unsigned int c : 3;
};

这里:

  • a 占 1 位

  • b 占 2 位

  • c 占 3 位


20.1 位域适合什么场景

位域适合表示很多开关量或状态位,例如:

  • 配置寄存器

  • 标志位

  • 通信协议字段

  • 状态压缩存储

比如:

struct Status
{
    unsigned int ready : 1;
    unsigned int error : 1;
    unsigned int mode  : 2;
};

20.2 位域示意图

一个unsigned int假设32位:

bit31                             bit0
+-----------------------------------+
| ... | mode(2) | error(1) | ready(1) |
+-----------------------------------+

20.3 位域注意事项

注意 1:位域的内存布局和编译器、平台相关

不要把位域布局想得过于绝对,尤其做跨平台通信协议时要谨慎。

注意 2:不能对位域取地址

因为位域不一定按字节对齐,可能只是若干位。

注意 3:位域更适合“本地描述”

比如本地状态、寄存器映射。
但做网络协议、文件格式时,很多时候更稳的是手动位运算。


21. volatile

这个关键字在嵌入式和面试里特别高频。

volatile 告诉编译器:
这个变量的值可能在程序控制之外被改变,不要随便优化。

常见场景:

  • 硬件寄存器

  • 中断中会修改的变量

  • 多线程共享变量(但它不能代替锁)

示例:

volatile int flag;

为什么需要它

假设:

while(flag == 0)
{
}

如果没有 volatile,编译器可能认为 flag 不会变,就把它缓存起来,导致死循环。

嵌入式常见场景

#define GPIOA_ODR (*(volatile unsigned int *)0x4001080C)

这里访问的是硬件寄存器地址,就必须告诉编译器别乱优化。


22. memcpymemmove

也是面试容易问的点。

22.1 memcpy

用于内存拷贝,但源和目标内存不能重叠

memcpy(dst, src, n);

22.2 memmove

支持内存重叠情况。

memmove(dst, src, n);

经典面试点

“为什么 memmove 能处理重叠?”

因为它会根据重叠方向选择从前往后或者从后往前拷贝,避免数据被提前覆盖。


23. strcpystrncpy、缓冲区溢出

这是字符串安全问题常考点。

char buf[10];
strcpy(buf, "this is too long");

会越界,发生缓冲区溢出。

所以面试里要有安全意识:

  • 拷贝前检查长度

  • 明确目标数组大小

  • 不要盲目用不安全函数


24. 容易考的底层题总结

24.1 为什么 char *p = "abc"; 里的 "abc" 不能修改

因为字符串字面量通常放在只读存储区。

char *p = "abc";
// p[0] = 'x'; // 未定义行为,通常会出错

如果要可修改,应该写:

char arr[] = "abc";
arr[0] = 'x';

24.2 int *p[10]int (*p)[10] 的区别

int *p[10]

是一个数组,数组里有 10 个元素,每个元素都是 int *

int (*p)[10]

是一个指针,指向“包含 10 个 int 的数组”

这题一定要会拆:

  • 先看 p[10],说明 p 是数组

  • 先看 (*p),说明 p 是指针


24.3 malloc(0) 会怎样

标准允许实现返回:

  • NULL

  • 或一个不可解引用但可传给 free 的指针

所以工程里不要依赖 malloc(0) 的具体行为。


24.4 free(NULL) 会怎样

合法,不会出错。


24.5 sizeof(struct) 一定等于成员大小之和吗

不一定,因为有内存对齐和填充


24.6 为什么不能返回局部数组地址

因为局部数组在栈上,函数结束后失效。


25. 面试回答模板整理

25.1 什么是指针

指针是一个变量,里面存放的是内存地址。通过指针可以间接访问对应地址上的数据。指针类型决定了解引用时按什么类型解释内存,也决定了指针运算的步长。


25.2 数组和指针有什么区别

数组是一个完整对象,指针是一个变量。数组名在大多数表达式里会退化为首元素地址,但数组本身不是指针。sizeof(数组) 得到整个数组大小,sizeof(指针) 得到指针变量本身大小。


25.3 结构体为什么会有内存对齐

因为 CPU 按自然边界访问数据更高效,所以编译器会让成员地址按一定规则对齐,并在必要时插入填充字节。结构体总大小通常也会补齐到最大对齐数的整数倍。


25.4 结构体和联合体有什么区别

结构体中每个成员各自占用独立内存,可以同时保存多个成员值;联合体所有成员共享同一块内存,大小通常取最大成员大小,用于节省空间或从不同角度解释同一块数据。


25.5 volatile 的作用是什么

volatile 用来告诉编译器这个变量可能被外部因素修改,比如硬件寄存器、中断、并发环境等,所以每次都要从内存中重新读取,不能过度优化。


26. 这一篇该真正吃透的内容

会默写级别

  • &* 的含义

  • 一级指针、二级指针

  • 数组与指针的区别

  • const 修饰指针

  • void *

  • 动态内存管理四个函数

  • 野指针、悬空指针、内存泄漏

  • 函数指针和回调

  • structunion

  • 内存对齐

  • 位域

  • volatile


会分析级别

  • sizeof 相关题

  • 结构体大小计算

  • 指针运算题

  • int *p[10] / int (*p)[10]

  • 返回局部变量地址为什么错

  • char *p = "abc"char arr[] = "abc" 的区别

  • 大小端判断原理

  • memcpymemmove 区别


27. 适合自己手写练习的代码题

题 1:用指针实现交换两个数

题 2:手写 strlen

题 3:手写 memcpy

题 4:手写 memmove

题 5:实现一个回调函数示例

题 6:计算多个结构体的 sizeof

题 7:用联合体判断大小端

题 8:二级指针实现函数内部分配内存


28. 本篇收尾

这一篇的核心不是“记概念”,而是要建立一种底层视角:

  • 变量最终都在内存里

  • 指针是操作内存地址的工具

  • 结构体和联合体是在组织内存

  • 对齐、位域、函数指针这些内容,本质上都和“内存怎么放、数据怎么取、函数怎么跳转”有关

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_79911329/article/details/159654495

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--