引言
作为曾经历过求职过程的计算机专业学生,我深感被八股文困扰的滋味。技术面过程中一定会有对底层知识的考察,即便会写代码的同学,面对大多数刁钻的原理性八股也会让人头疼,导致求职时不得不自己专门找对应的八股文进行学习。
但目前市面上很多的八股资料基本都是为了卖课,充斥着零散的知识,通常是一个问题一个问题的问答形式,而非系统的、成体系的教学。这样死记硬背不仅不方便理解记忆,同时也容易消磨热情。而再重新找课程从头学习的话对于已经有基础的小伙伴来说又会浪费宝贵的求职时间。
为此我开启了这个专题,本专题将会从C语言开始整理,带大家由浅入深的系统学习C语言八股,课程会包括底层知识以及各种面试高频问题。C语言专题我将会分为五篇博客讲完:
第 1 篇:C 程序与基本语义 --《C 语言基础:从源码到程序运行》
第 2 篇:指针、数组与内存 --《C 语言核心:指针、数组与内存模型》
第 3 篇:复杂数据与程序组织 --《C 语言工程基础:结构体、函数指针与模块化》
第 4 篇:C 的底层语义与工程实践 --《C 语言底层原理:编译、链接、未定义行为与调试》
第 5 篇:嵌入式 C --《嵌入式 C:从内存映射到驱动程序》
(由于我个人主攻嵌入式,课程中会有一些关于嵌入式的部分内容,不过大家不用担心,这些举例只会更方便大家理解)
每篇也有细化的目录结构,各位可以按需阅读,后续还会单独整理关于C++、Java、Linux、Android等相关专题,需要的话可以关注一下博主哦 ^.^
开始之前
指针难学,通常不是因为语法复杂,而是因为很多资料把变量的值、变量所在的地址、指针保存的地址、数组布局和对象生命周期混在了一起。
这一篇只围绕一个模型展开:
程序通过对象保存数据,通过地址找到对象,通过指针保存地址。
文中的代码片段为了突出重点,省略了部分重复的头文件。复制成完整程序时,根据使用的函数补上<stdio.h>、`<stdlib.h>`、`<stdbool.h>` 或<string.h>。
读完以后,你应该能够解释:
int *p、`*p` 和&value分别表示什么;- 数组为什么和指针密切相关,又为什么不是指针;
- 字符串为什么必须以
\0结束; - 栈、堆和静态存储区分别适合保存什么;
- 野指针、悬空指针、越界访问和内存泄漏如何产生;
- 嵌入式程序为什么经常需要直接操作地址。
1. 对象、值和地址
int value = 42;这句话创建了一个int类型的对象。对象有三个重要属性:
- 值:当前保存的 42;
- 地址:对象在内存中的位置;
- 生命周期:对象从创建到销毁的时间范围。
使用取地址运算符&可以得到对象地址:
int value = 42; int *pointer = &value;pointer本身也是一个对象,它保存的是value的地址。使用解引用运算符*可以通过地址访问目标对象:
printf("%d\n", *pointer); /* 读取 value */ *pointer = 100; /* 修改 value */可以用下面的图理解:
pointer ───────► value 保存地址 保存 100pointer和value是两个不同的对象。修改pointer不会直接修改value,通过*pointer修改的才是目标对象。
2. 指针的类型
int *p; char *q;指针类型描述两件事:
- 它指向什么类型的对象;
- 解引用时读取多少字节,以及如何解释这些字节。
int value = 0x12345678; int *ip = &value; unsigned char *bytes = (unsigned char *)&value;ip按int访问,`bytes` 按单个字节访问。类型转换只改变访问方式,不会改变目标对象本身。
void *可以保存任意对象的地址,但没有具体元素类型,不能直接进行指针运算,也不能直接解引用:
int value = 10; void *raw = &value; int *ip = raw; printf("%d\n", *ip);转换以后必须确保目标类型与实际对象匹配。
3. 指针运算
int values[] = {10, 20, 30}; int *p = values; printf("%d\n", *p); /* 10 */ printf("%d\n", *(p + 1)); /* 20 */p + 1不是地址只增加 1 个字节,而是向后移动一个int对象的距离。指针运算的步长由指向类型决定。
指针加减只有在同一个数组对象或数组末尾位置附近才有明确意义:
int *begin = &values[0]; int *end = &values[3]; for (int *it = begin; it != end; ++it) { printf("%d\n", *it); }end指向数组最后一个元素之后的位置,可以比较和计算距离,但不能解引用。
4. 数组不是指针
数组是一段连续的同类型对象:
int values[3] = {10, 20, 30};它包含三个int对象,整个数组拥有自己的大小和存储空间。指针只是一个保存地址的对象。
int *p = values;在大多数表达式中,数组名会转换为指向第一个元素的指针,因此values[1]可以理解为:
*(values + 1)这种转换有几个重要例外:
sizeof(values); /* 整个数组的大小 */ &values; /* 整个数组的地址,类型是 int (*)[3] */函数参数中的数组会被调整为指针:
void print_values(int values[]) { printf("%zu\n", sizeof(values)); }这里得到的是指针大小,而不是原数组大小。传递数组时通常需要同时传递长度:
void print_values(const int *values, size_t count) { for (size_t i = 0; i < count; ++i) { printf("%d\n", values[i]); } }5. 字符数组和字符串
C 没有内置字符串类型。字符串通常是以\0结尾的字符数组:
char message[] = "hello";内存中的内容是:
'h' 'e' 'l' 'l' 'o' '\0'因此:
sizeof(message); /* 6 */ strlen(message); /* 5 */字符串字面量通常位于只读区域:
const char *text = "hello";不要通过text[0] = 'H'修改它。需要修改内容时,应创建字符数组:
char text[] = "hello"; text[0] = 'H';使用字符串函数时,必须确认目标缓冲区足够大,并且输入包含终止符。嵌入式程序中,字符串缓冲区不足是常见的内存破坏来源。
6. 多维数组和指针
int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} };它是两个连续的int[3]数组。传给函数时,第一维可以省略,但第二维必须提供:
void print_matrix(size_t rows, int matrix[][3]) { for (size_t i = 0; i < rows; ++i) { for (size_t j = 0; j < 3; ++j) { printf("%d ", matrix[i][j]); } putchar('\n'); } }这里的参数类型接近int (*matrix)[3],表示指向“包含三个int的数组”的指针。它与int **不是同一种布局。
int **通常表示指向int *对象的指针。把二维数组强制转换成int **会造成错误的地址计算。
7. 生命周期和存储区域
自动存储期
普通局部变量通常具有自动存储期:
void function(void) { int value = 10; }进入函数时创建,离开函数时结束。它经常被放在栈中,但“栈”是常见实现方式,不是 C 标准规定的唯一位置。
静态存储期
全局变量和带static的变量通常具有静态存储期:
static unsigned int counter; void tick(void) { ++counter; }静态存储期解决的是生命周期问题。文件作用域的static还会限制符号可见范围,这属于链接属性。
动态存储期
通过malloc获取的内存,在调用free前一直存在:
#include <stdbool.h> #include <stdlib.h> bool create_value(void) { int *value = malloc(sizeof(*value)); if (value == NULL) { return false; } *value = 42; free(value); value = NULL; return true; }动态内存的关键不只是申请和释放,还包括所有权:谁负责释放,释放后谁还可能使用这个地址,错误路径是否也能释放资源。
8. 常见指针错误
未初始化指针
int *p; *p = 10;p没有指向有效对象,解引用它会产生未定义行为。暂时没有目标时,应初始化为NULL。
悬空指针
int *p = malloc(sizeof(*p)); free(p); *p = 10;释放后的地址可能仍然保留在p中,但它已经不再指向可访问对象。把p置为NULL能减少重复释放和误用,但不能替代所有权管理。
返回局部变量地址
int *wrong(void) { int local = 10; return &local; }函数返回后,`local` 的生命周期结束。指针值可能看起来仍然存在,但它指向的对象已经不存在。
越过数组边界
int values[3] = {1, 2, 3}; values[3] = 4;C 不会自动检查下标。越界写入可能破坏其他变量、返回地址或内存管理信息。
重叠区域复制
memcpy(buffer + 1, buffer, 5);源区域和目标区域重叠时应使用memmove。`memcpy` 的前提是两个区域不重叠。
9. 函数指针和回调
函数也有地址,可以使用函数指针保存:
typedef int (*compare_fn)(int left, int right); int ascending(int left, int right) { return left - right; } int apply_compare(compare_fn compare, int left, int right) { return compare(left, right); }函数指针常用于驱动接口、状态机和回调。使用它时必须保证函数签名匹配,否则参数解释和调用约定可能不一致。
10. 嵌入式联系
嵌入式系统中,外设寄存器通常映射到固定地址。代码会通过指针访问它:
#define GPIO_REG_ADDR ((volatile unsigned int *)0x40020000u) void set_gpio(void) { *GPIO_REG_ADDR |= (1u << 3); }这里有三个关键点:
- 地址来自芯片手册,而不是普通动态分配;
volatile告诉编译器每次访问都可能产生外部可见效果;- 位运算只修改目标寄存器中需要修改的位。
实际项目中通常使用芯片厂商提供的寄存器定义,而不是手写整数地址。
11. 面试高频
1. 数组和指针有什么区别?
数组是一段连续的对象,拥有固定的存储空间和数组类型;指针是保存地址的对象。在大多数表达式中数组名会转换为指向首元素的指针,但sizeof和取地址等场景能体现它们的区别。
2. 为什么函数参数中的数组会变成指针?
函数参数声明中的数组类型会被调整为指向元素类型的指针。函数只收到首元素地址,不知道原数组长度,因此调用者通常需要额外传递元素个数。
3. 什么是野指针?
没有初始化、已经释放、指向生命周期结束对象,或者经过错误地址计算的指针,都可能成为无效指针。使用无效指针解引用会产生未定义行为。
4.const int *p和int *const p有什么区别?
const int *p表示不能通过p修改它指向的int,但p可以改指向其他对象。`int *const p` 表示指针本身不能改指向,但可以通过它修改目标对象。
5.volatile能保证线程安全或原子性吗?
不能。`volatile` 主要约束编译器保留对对象的访问,适合硬件寄存器和某些异步修改场景。线程同步、原子性和内存顺序需要原子操作、锁或其他同步机制。
6. 为什么sizeof不能用来计算函数参数中的数组长度?
函数参数中的数组声明会被调整为指针。
void print_size(int values[]) { printf("%zu\n", sizeof(values)); }这里的values实际上是一个int *,所以sizeof(values)得到的是指针大小,而不是调用者数组的总大小。
正确做法是显式传递数组长度:
void print_values(const int *values, size_t count) { for (size_t i = 0; i < count; ++i) { printf("%d\n", values[i]); } }7.int **能否代替二维数组参数?
不能直接代替。
二维数组:
int matrix[2][3];通常是连续的int元素,函数参数类型接近:
int (*matrix)[3]而int **表示指向int *对象的指针,常见于指针数组或多级动态分配。
两者的内存布局和地址计算方式不同,因此不能简单地把二维数组转换成int **。
8.malloc(0)会返回什么?
标准允许malloc(0):
- 返回空指针;
- 或返回一个不能解引用,但可以传给
free的特殊指针。
因此代码不应该依赖malloc(0)的具体返回结果。
还要注意申请大小的整数溢出:
size_t total = count * sizeof(int);如果count很大,乘法可能溢出,导致实际申请的内存小于预期。更安全的代码应在计算前检查大小。
9. 为什么释放内存后要避免继续保留别名指针?
释放内存只会结束动态对象的生命周期,不会自动清空其他指针变量。
int *p = malloc(sizeof(*p)); int *alias = p; free(p); *p = 10; // 错误 *alias = 20; // 同样错误p和alias都已经变成悬空指针。
应明确内存所有权,释放后让相关指针失效,必要时统一置为NULL:
free(p); p = NULL; alias = NULL;置为NULL可以减少误用,但不能替代正确的所有权设计。
10.memcpy为什么不能用于任意类型对象?
memcpy只按照字节复制存储内容。
复制普通结构体通常没有问题:
struct Point a = {1, 2}; struct Point b; memcpy(&b, &a, sizeof(a));但如果结构体中包含指针:
struct Buffer { char *data; size_t size; };复制它只会复制data保存的地址,不会复制指针指向的实际数据。此时两个结构体会指向同一块内存,可能产生重复释放或悬空指针问题。
此外,把一段字节直接解释成不兼容的类型,还可能违反对齐和有效类型规则。
使用memcpy前,需要确认:
- 源和目标区域是否有效;
- 两个区域是否重叠;
- 目标类型是否允许这样的字节复制;
- 对象内部是否包含需要深拷贝的指针资源。
12. 练习
- 编写一个函数,交换两个整数的值,并解释为什么必须传递指针。
- 编写一个函数,接收数组首地址和长度,返回最大值。
- 实现安全的字符串复制函数,确保目标缓冲区始终以
\0结尾。 - 使用
malloc创建动态数组,完成初始化、遍历和释放。 - 编写环形缓冲区的最小版本,记录读指针、写指针和剩余空间。
- 使用 AddressSanitizer 分别定位一次越界访问、一次释放后使用和一次内存泄漏。
完成这些练习后,读者已经具备继续学习结构体、模块化、编译链接和嵌入式寄存器访问的基础。