C语言重难点全解析:内存管理到位运算

C 语言进阶重难点系统总结笔记

一、内存管理(malloc/free、内存泄漏、野指针)

1. malloc/free 底层原理

  • 本质 :C标准库函数,不是系统调用!底层通过brk()mmap()两个系统调用向操作系统申请内存

  • 两种分配方式(glibc默认)

    • 小内存(≤128KB) :用brk()系统调用,将堆顶指针向高地址移动,分配连续虚拟内存
    • 大内存(>128KB) :用mmap()系统调用,在堆和栈之间的文件映射区分配独立内存块
  • 💡 通俗理解

    • 操作系统给每个进程分配了一块"大空地"(虚拟地址空间)
    • 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. 内存泄漏

  • 定义:申请的内存使用完后没有释放,导致这块内存永远无法被再次使用,直到程序结束
  • 常见原因
    1. 忘记调用free
    2. 函数返回前没有释放临时分配的内存
    3. 指针重新赋值前没有释放原来指向的内存
    4. 循环中重复申请内存但不释放
  • 危害:程序运行时间越长,占用内存越多,最终导致系统内存不足,程序崩溃
  • 检测工具 :Linux下用valgrind,Windows下用Visual Studio的内存诊断工具

3. 野指针

  • 定义:指向"无效"内存的指针,这块内存可能已经被释放,或者从未被分配过

  • 常见原因

    1. 指针定义后没有初始化(指向随机地址)
    2. free后没有将指针置为NULL(指针仍然指向原来的内存地址)
    3. 返回局部变量的地址(函数结束后局部变量被销毁)
  • ⚠️ 典型错误示例

    c 复制代码
    // ❌ 错误1:返回局部变量地址
    int* func() {
        int a = 10;
        return &a; // 函数结束后a被销毁,地址无效
    }
    
    // ❌ 错误2:free后继续使用指针
    int *p = (int*)malloc(sizeof(int));
    free(p);
    *p = 20; // 野指针,访问已释放的内存,导致程序崩溃
  • 避免方法

    1. 指针定义时立即初始化(可以初始化为NULL
    2. free后立即将指针置为NULL
    3. 不要返回局部变量的地址

二、宏定义与预处理

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的本质是告诉编译器:该变量的值可能被意外修改(非当前代码流触发),禁止对其进行任何优化,每次访问都必须直接读取内存地址,而不是缓存到寄存器

  • 核心作用

    1. 禁止寄存器缓存:每次读变量都从内存读,每次写变量都直接写内存
    2. 禁止指令重排序:编译器不能打乱volatile变量相关代码的执行顺序
    3. 保证内存可见性:一个线程修改了volatile变量,其他线程能立刻看到新值
  • 💡 通俗理解
    volatile就像"实时刷新"按钮,告诉编译器"别信缓存,每次都去现场看"。没有volatile时,编译器会把变量缓存到寄存器,就像你反复看手机上缓存的天气信息,却不知道外面已经下雨了

  • 三大核心应用场景

    1. 硬件寄存器访问:外设寄存器的值可能被硬件异步修改(如GPIO输入电平、ADC采样值)
    2. 中断服务程序中的共享变量:中断中修改的全局变量,主程序需要实时读取
    3. 多线程中的共享变量:一个线程修改,其他线程需要立即看到
  • ⚠️ 典型错误示例

    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

  • 核心作用:声明变量为只读,防止被意外修改,提高代码的健壮性

  • 修饰普通变量

    c 复制代码
    const 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局部变量:存储在静态区,程序结束才销毁,只初始化一次,函数调用之间保留值

    • 代码示例:

      c 复制代码
      void 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;
}

七、总结与学习建议

  1. 内存管理 :记住"谁申请谁释放"的原则,malloc后必须检查返回值,free后必须置空指针
  2. 宏定义:带参数的宏一定要加括号,避免运算符优先级问题
  3. 位运算:背熟寄存器操作的四个通用宏,这是嵌入式开发的基本功
  4. 关键字
    • volatile:记住三大应用场景
    • const:记住修饰指针的四种情况和记忆口诀
    • static:记住三大核心作用
  5. 实战:多动手写动态数组和寄存器操作的代码,理解底层原理
相关推荐
方安乐5 小时前
python之向量、向量和、向量点积
开发语言·python·numpy
三品吉他手会点灯7 小时前
C语言学习笔记 - 20.C编程预备计算机专业知识 - 变量为什么必须的初始化【重点】
c语言·笔记·学习
小小小米粒7 小时前
Collection单列集合、Map(Key - Value)双列集合,多继承实现。
java·开发语言·windows
czhc11400756638 小时前
C# 428 线程、异步
开发语言·c#
FreakStudio8 小时前
亲测可用!可本地部署的 MicroPython 开源仿真器
python·单片机·嵌入式·面向对象·并行计算·电子diy·电子计算机
:1218 小时前
java基础
java·开发语言
SilentSamsara9 小时前
Python 环境搭建完整指南:从下载安装到运行第一个程序
开发语言·python
小短腿的代码世界9 小时前
Qt文件系统与IO深度解析:从QFile到异步文件操作
开发语言·qt
rit843249910 小时前
STM32 + DS3231 + TM1640 实时时钟数码管显示系统
stm32·单片机·嵌入式硬件