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
那么:
-
pc按char读取,一次读 1 字节 -
pi按int读取,一次读 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 但数组不是指针
这是面试必问。
数组和指针的区别:
-
数组是一个完整对象
-
指针是一个变量
-
sizeof(数组)取整个数组大小 -
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
地址 值
访问关系:
-
p是a的地址 -
*p是a -
pp是p的地址 -
*pp是p -
**pp是a
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 p:p是常量指针 -
const int *p:p指向常量整数
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));
作用:
-
对原有内存扩容或缩容
注意点:
-
可能原地扩容
-
也可能搬到新地址
-
原指针可能失效
更稳妥写法:
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. sizeof 和 strlen 的区别
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 对齐规则(常见面试版)
先记常见规则:
-
每个成员的起始地址,要是该成员大小的整数倍
-
结构体总大小,要是其最大对齐数的整数倍
-
编译器可能会插入填充字节
16.3 经典例子 1
struct A
{
char c;
int i;
};
假设:
-
char1 字节 -
int4 字节
内存布局:
偏移 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,如果再按最大对齐数补齐,也可能为 6 或 8,和平台/编译器设置有关。面试里默认按常见 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. memcpy 和 memmove
也是面试容易问的点。
22.1 memcpy
用于内存拷贝,但源和目标内存不能重叠。
memcpy(dst, src, n);
22.2 memmove
支持内存重叠情况。
memmove(dst, src, n);
经典面试点
“为什么 memmove 能处理重叠?”
因为它会根据重叠方向选择从前往后或者从后往前拷贝,避免数据被提前覆盖。
23. strcpy、strncpy、缓冲区溢出
这是字符串安全问题常考点。
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 * -
动态内存管理四个函数
-
野指针、悬空指针、内存泄漏
-
函数指针和回调
-
struct、union -
内存对齐
-
位域
-
volatile
会分析级别
-
sizeof相关题 -
结构体大小计算
-
指针运算题
-
int *p[10]/int (*p)[10] -
返回局部变量地址为什么错
-
char *p = "abc"和char arr[] = "abc"的区别 -
大小端判断原理
-
memcpy和memmove区别
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



