C 语言进阶重难点系统总结笔记
一、内存管理(malloc/free、内存泄漏、野指针)
1. malloc/free 底层原理
-
本质 :C标准库函数,不是系统调用!底层通过
brk()和mmap()两个系统调用向操作系统申请内存 -
两种分配方式(glibc默认) :
- 小内存(≤128KB) :用
brk()系统调用,将堆顶指针向高地址移动,分配连续虚拟内存 - 大内存(>128KB) :用
mmap()系统调用,在堆和栈之间的文件映射区分配独立内存块
- 小内存(≤128KB) :用
-
💡 通俗理解 :
- 操作系统给每个进程分配了一块"大空地"(虚拟地址空间)
malloc相当于向"物业"申请一块指定大小的"地皮"free相当于把"地皮"还给"物业",但"物业"不会立即把地还给"政府"(OS),而是先存起来下次再用
-
核心注意事项 :
c// ✅ 正确写法:必须检查malloc返回值! int *p = (int*)malloc(10 * sizeof(int)); if (p == NULL) { perror("malloc failed"); exit(1); } // ✅ 正确写法:free后必须将指针置为NULL,避免野指针 free(p); p = NULL;
2. 内存泄漏
- 定义:申请的内存使用完后没有释放,导致这块内存永远无法被再次使用,直到程序结束
- 常见原因 :
- 忘记调用
free - 函数返回前没有释放临时分配的内存
- 指针重新赋值前没有释放原来指向的内存
- 循环中重复申请内存但不释放
- 忘记调用
- 危害:程序运行时间越长,占用内存越多,最终导致系统内存不足,程序崩溃
- 检测工具 :Linux下用
valgrind,Windows下用Visual Studio的内存诊断工具
3. 野指针
-
定义:指向"无效"内存的指针,这块内存可能已经被释放,或者从未被分配过
-
常见原因 :
- 指针定义后没有初始化(指向随机地址)
free后没有将指针置为NULL(指针仍然指向原来的内存地址)- 返回局部变量的地址(函数结束后局部变量被销毁)
-
⚠️ 典型错误示例 :
c// ❌ 错误1:返回局部变量地址 int* func() { int a = 10; return &a; // 函数结束后a被销毁,地址无效 } // ❌ 错误2:free后继续使用指针 int *p = (int*)malloc(sizeof(int)); free(p); *p = 20; // 野指针,访问已释放的内存,导致程序崩溃 -
避免方法 :
- 指针定义时立即初始化(可以初始化为
NULL) free后立即将指针置为NULL- 不要返回局部变量的地址
- 指针定义时立即初始化(可以初始化为
二、宏定义与预处理
1. 预处理的本质
- 执行阶段:编译之前执行,只做"文本替换",不做任何语法检查和计算
- 💡 通俗理解:预处理就像一个"查找替换"工具,在编译前把代码中所有的宏名替换成对应的文本
2. 宏定义(#define)
-
无参数宏 :
c#define PI 3.1415926 #define MAX_SIZE 100 -
带参数宏 :
c#define MAX(a, b) ((a) > (b) ? (a) : (b)) -
⚠️ 带参数宏的坑(必须加括号!) :
c// ❌ 错误写法:没有加括号,运算符优先级导致错误 #define SQUARE(x) x * x int result = SQUARE(3 + 2); // 展开后:3 + 2 * 3 + 2 = 11,不是25! // ✅ 正确写法:所有参数和整个表达式都加括号 #define SQUARE(x) ((x) * (x)) int result = SQUARE(3 + 2); // 展开后:((3 + 2) * (3 + 2)) = 25
3. 宏与函数的区别
| 对比维度 | 宏定义 | 函数 |
|---|---|---|
| 执行阶段 | 预处理阶段,文本替换 | 运行阶段,函数调用 |
| 参数类型 | 无类型检查,任何类型都可以 | 有严格的类型检查 |
| 代码膨胀 | 每次调用都会展开,代码膨胀 | 只有一份代码,调用时跳转 |
| 执行效率 | 高,没有函数调用开销 | 低,有函数调用开销 |
| 递归 | 不支持递归 | 支持递归 |
4. 其他常用预处理指令
#include:包含头文件#ifdef/#ifndef/#endif:条件编译,用于防止头文件重复包含#pragma once:现代编译器支持的防止头文件重复包含的指令#undef:取消宏定义
三、位运算(嵌入式开发核心技能)
1. 基本位运算符
| 运算符 | 名称 | 作用 | 示例 |
|---|---|---|---|
| & | 按位与 | 对应位都为1,结果为1,否则为0 | 0b1010 & 0b1100 = 0b1000 |
| ` | ` | 按位或 | 对应位只要有一个为1,结果为1 |
| ^ | 按位异或 | 对应位不同为1,相同为0 | 0b1010 ^ 0b1100 = 0b0110 |
| ~ | 按位取反 | 所有位取反(0变1,1变0) | ~0b1010 = 0b0101(注意符号位) |
| << | 左移 | 所有位向左移动,右边补0 | 0b1010 << 1 = 0b10100 |
| >> | 右移 | 所有位向右移动,左边补符号位 | 0b1010 >> 1 = 0b0101 |
2. 🔥 位运算实现寄存器操作
-
💡 通俗理解:寄存器就是一个32位的"开关面板",每个位代表一个开关的状态(0=关,1=开)
-
通用宏定义 (所有嵌入式项目通用):
c// 置位:将寄存器reg的第bit位设为1 #define SET_BIT(reg, bit) ((reg) |= (1U << (bit))) // 清零:将寄存器reg的第bit位设为0 #define CLR_BIT(reg, bit) ((reg) &= ~(1U << (bit))) // 翻转:将寄存器reg的第bit位取反 #define TOG_BIT(reg, bit) ((reg) ^= (1U << (bit))) // 取值:获取寄存器reg的第bit位的值 #define GET_BIT(reg, bit) (((reg) >> (bit)) & 1U) -
使用示例(STM32 GPIO操作) :
c// 假设GPIOA的输出寄存器地址是0x40020014 #define GPIOA_ODR (*(volatile unsigned int *)0x40020014) // 将GPIOA的第5位设为1(点亮LED) SET_BIT(GPIOA_ODR, 5); // 将GPIOA的第5位设为0(熄灭LED) CLR_BIT(GPIOA_ODR, 5); // 翻转GPIOA的第5位(LED闪烁) TOG_BIT(GPIOA_ODR, 5);
四、🔥 关键字详解
1. volatile(最重要)
-
🔥 面试标准答案 :
volatile的本质是告诉编译器:该变量的值可能被意外修改(非当前代码流触发),禁止对其进行任何优化,每次访问都必须直接读取内存地址,而不是缓存到寄存器 -
核心作用 :
- 禁止寄存器缓存:每次读变量都从内存读,每次写变量都直接写内存
- 禁止指令重排序:编译器不能打乱volatile变量相关代码的执行顺序
- 保证内存可见性:一个线程修改了volatile变量,其他线程能立刻看到新值
-
💡 通俗理解 :
volatile就像"实时刷新"按钮,告诉编译器"别信缓存,每次都去现场看"。没有volatile时,编译器会把变量缓存到寄存器,就像你反复看手机上缓存的天气信息,却不知道外面已经下雨了 -
三大核心应用场景 :
- 硬件寄存器访问:外设寄存器的值可能被硬件异步修改(如GPIO输入电平、ADC采样值)
- 中断服务程序中的共享变量:中断中修改的全局变量,主程序需要实时读取
- 多线程中的共享变量:一个线程修改,其他线程需要立即看到
-
⚠️ 典型错误示例 :
c// ❌ 错误:不加volatile,编译器会优化成死循环 int flag = 0; void interrupt_handler() { flag = 1; // 中断中修改flag } int main() { while (!flag) {} // 编译器认为flag在主程序中没被修改,优化成while(1) return 0; } // ✅ 正确:加volatile,每次都从内存读取flag volatile int flag = 0;
2. const
-
核心作用:声明变量为只读,防止被意外修改,提高代码的健壮性
-
修饰普通变量 :
cconst int a = 10; a = 20; // ❌ 错误:不能修改const变量 -
🔥 修饰指针的四种情况(面试必背) :
声明 能否修改指针指向的地址 能否通过指针修改所指内容 记忆口诀 const int *p✅ 能 ❌ 不能 const在左,内容锁 int *const p❌ 不能 ✅ 能 const在右,指针锁 const int *const p❌ 不能 ❌ 不能 左右都有,全锁死 int const *p✅ 能 ❌ 不能 和第一种等价 -
应用场景 :
- 函数参数中用const修饰指针,防止函数内部修改传入的数据
- 声明全局常量,替代宏定义(有类型检查)
3. static(三大核心作用)
-
🔥 作用1:修饰局部变量------延长生命周期
-
普通局部变量:存储在栈区,函数调用结束后销毁
-
static局部变量:存储在静态区,程序结束才销毁,只初始化一次,函数调用之间保留值
-
代码示例:
cvoid increment() { static int count = 0; // 只在第一次调用时初始化 count++; printf("Count: %d\n", count); } int main() { increment(); // 输出:1 increment(); // 输出:2 increment(); // 输出:3 return 0; }
-
-
🔥 作用2:修饰全局变量------限制作用域
- 普通全局变量:作用域是整个程序,其他文件可以通过
extern声明访问 - static全局变量:作用域仅限定在当前源文件内,其他文件无法访问,避免命名冲突
- 普通全局变量:作用域是整个程序,其他文件可以通过
-
🔥 作用3:修饰函数------限制作用域
- 普通函数:可以被其他文件调用
- static函数:只能在当前源文件内被调用,隐藏函数实现细节,提高封装性
-
总结表格 :
修饰对象 普通情况 static修饰后 核心作用 局部变量 栈区,函数结束销毁 静态区,程序结束销毁 延长生命周期,保留值 全局变量 整个程序可见 仅当前文件可见 限制作用域,避免命名冲突 函数 整个程序可见 仅当前文件可见 限制作用域,封装私有逻辑
五、实现动态数组(自动扩容)
1. 动态数组的原理
- 普通数组:大小固定,编译时确定
- 动态数组:大小可以在运行时动态调整,当元素个数超过容量时,自动扩容(通常是2倍扩容)
- 💡 通俗理解:动态数组就像一个"可伸缩的箱子",东西放满了就换一个更大的箱子(2倍大),把原来的东西都搬过去
2. 完整代码实现
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 动态数组结构体
typedef struct {
int *data; // 指向堆内存的指针
size_t size; // 当前元素个数
size_t capacity; // 数组总容量
} DynamicArray;
// 创建动态数组
DynamicArray* createArray(size_t initCapacity) {
DynamicArray *arr = (DynamicArray*)malloc(sizeof(DynamicArray));
if (arr == NULL) {
perror("malloc failed");
return NULL;
}
arr->size = 0;
arr->capacity = initCapacity;
arr->data = (int*)malloc(initCapacity * sizeof(int));
if (arr->data == NULL) {
perror("malloc failed");
free(arr);
return NULL;
}
return arr;
}
// 扩容函数(内部使用,static修饰,仅当前文件可见)
static int resizeArray(DynamicArray *arr, size_t newCapacity) {
int *newData = (int*)realloc(arr->data, newCapacity * sizeof(int));
if (newData == NULL) {
perror("realloc failed");
return -1;
}
arr->data = newData;
arr->capacity = newCapacity;
return 0;
}
// 向数组末尾添加元素
int appendArray(DynamicArray *arr, int value) {
if (arr == NULL) return -1;
// 如果数组已满,扩容2倍
if (arr->size >= arr->capacity) {
size_t newCapacity = arr->capacity * 2;
if (resizeArray(arr, newCapacity) != 0) {
return -1;
}
}
arr->data[arr->size++] = value;
return 0;
}
// 获取指定索引的元素
int getArrayElement(DynamicArray *arr, size_t index, int *value) {
if (arr == NULL || value == NULL || index >= arr->size) {
return -1;
}
*value = arr->data[index];
return 0;
}
// 释放动态数组
void freeArray(DynamicArray *arr) {
if (arr == NULL) return;
free(arr->data); // 先释放数据内存
free(arr); // 再释放结构体内存
}
// 测试函数
int main() {
DynamicArray *arr = createArray(2); // 初始容量2
if (arr == NULL) return 1;
// 添加元素
appendArray(arr, 10);
appendArray(arr, 20);
appendArray(arr, 30); // 触发扩容,容量变为4
appendArray(arr, 40);
appendArray(arr, 50); // 触发扩容,容量变为8
// 打印数组
printf("数组元素:");
for (size_t i = 0; i < arr->size; i++) {
int value;
getArrayElement(arr, i, &value);
printf("%d ", value);
}
printf("\n");
printf("当前大小:%zu,当前容量:%zu\n", arr->size, arr->capacity);
freeArray(arr);
return 0;
}
3. 扩容策略说明
- 为什么用2倍扩容?
- 摊销时间复杂度为O(1):总共log₂n次扩容,总拷贝量小于2n
- 平衡内存利用率和扩容次数
- 其他扩容策略:
- 1.5倍扩容:内存利用率更高,但扩容次数更多
- 固定步长扩容:每次增加固定大小,摊销时间复杂度为O(n),不推荐
六、位运算实现寄存器操作
1. 通用寄存器操作宏
c
#ifndef REGISTER_OP_H
#define REGISTER_OP_H
#include <stdint.h>
// 置位:将reg的第bit位设为1
#define REG_SET_BIT(reg, bit) ((reg) |= (1UL << (bit)))
// 清零:将reg的第bit位设为0
#define REG_CLR_BIT(reg, bit) ((reg) &= ~(1UL << (bit)))
// 翻转:将reg的第bit位取反
#define REG_TOG_BIT(reg, bit) ((reg) ^= (1UL << (bit)))
// 取值:获取reg的第bit位的值
#define REG_GET_BIT(reg, bit) (((reg) >> (bit)) & 1UL)
// 置位多个位:将reg的mask指定的位设为1
#define REG_SET_BITS(reg, mask) ((reg) |= (mask))
// 清零多个位:将reg的mask指定的位设为0
#define REG_CLR_BITS(reg, mask) ((reg) &= ~(mask))
// 修改位域:将reg的指定位域设为value
#define REG_SET_FIELD(reg, mask, shift, value) \
((reg) = ((reg) & ~((mask) << (shift))) | ((value) << (shift)))
#endif // REGISTER_OP_H
2. 实际应用示例(模拟STM32 USART配置)
c
#include <stdio.h>
#include "register_op.h"
// 模拟USART1寄存器地址
#define USART1_SR (*(volatile uint32_t *)0x40013800) // 状态寄存器
#define USART1_DR (*(volatile uint32_t *)0x40013804) // 数据寄存器
#define USART1_BRR (*(volatile uint32_t *)0x40013808) // 波特率寄存器
#define USART1_CR1 (*(volatile uint32_t *)0x4001380C) // 控制寄存器1
// USART_CR1位定义
#define USART_CR1_UE (1 << 13) // USART使能
#define USART_CR1_TE (1 << 3) // 发送使能
#define USART_CR1_RE (1 << 2) // 接收使能
// USART_SR位定义
#define USART_SR_TXE (1 << 7) // 发送数据寄存器空
#define USART_SR_RXNE (1 << 5) // 接收数据寄存器非空
// 初始化USART1
void USART1_Init(void) {
// 1. 使能USART1
REG_SET_BIT(USART1_CR1, USART_CR1_UE);
// 2. 使能发送和接收
REG_SET_BIT(USART1_CR1, USART_CR1_TE);
REG_SET_BIT(USART1_CR1, USART_CR1_RE);
// 3. 设置波特率为9600(假设系统时钟为8MHz)
USART1_BRR = 0x341; // 8MHz / 9600 ≈ 833.33 = 0x341
}
// 发送一个字符
void USART1_SendChar(uint8_t ch) {
// 等待发送数据寄存器为空
while (!REG_GET_BIT(USART1_SR, USART_SR_TXE));
// 发送数据
USART1_DR = ch;
}
// 接收一个字符
uint8_t USART1_ReceiveChar(void) {
// 等待接收数据寄存器非空
while (!REG_GET_BIT(USART1_SR, USART_SR_RXNE));
// 返回接收到的数据
return (uint8_t)USART1_DR;
}
int main() {
USART1_Init();
// 发送字符串"Hello World!"
char *str = "Hello World!";
while (*str) {
USART1_SendChar(*str++);
}
// 回显接收到的字符
while (1) {
uint8_t ch = USART1_ReceiveChar();
USART1_SendChar(ch);
}
return 0;
}
七、总结与学习建议
- 内存管理 :记住"谁申请谁释放"的原则,
malloc后必须检查返回值,free后必须置空指针 - 宏定义:带参数的宏一定要加括号,避免运算符优先级问题
- 位运算:背熟寄存器操作的四个通用宏,这是嵌入式开发的基本功
- 关键字 :
volatile:记住三大应用场景const:记住修饰指针的四种情况和记忆口诀static:记住三大核心作用
- 实战:多动手写动态数组和寄存器操作的代码,理解底层原理