预处理器
宏定义与宏替换(#define)
1. 什么是宏定义?如何使用#define 定义一个常量?
“宏定义”是一种预处理指令,用于在代码编译之前对指定的标识符进行文本替换,提高代码可维护性和可读性。
使用#define 定义常量的基本格式:
#define 宏名称 替换文本
宏名称通常用大写字母表示。
替换文本可以是数值、字符串、表达式等。末尾不需要分号。
宏定义的作用域是从定义位置开始生效,直到遇到#undef 指令或文件结束。
编译器仅进行简单的文本替换,不进行语法检查。所以尽量加括号。
#define TWO 2+2 // 注意:TWO 实际是 2+2,而非 4
int result = TWO * 3; // 替换后为 2+2*3 = 8(而非预期的 12)#define TWO (2+2) // 确保替换后逻辑正确
2. 如何使用#define 定义一个带参数的宏?
需要在宏名称后紧跟用括号括起来的参数列表,然后是替换文本。基本格式为:
#define 宏名称(参数列表) 替换文本
示例一:
定义一个计算平方的宏:
#define SQUARE(x) ((x) * (x))int a = 5;
int result = SQUARE(a); // 预处理后变为 ((5) * (5)),结果为 25
示例二:
定义一个求两个数最大值的宏:
#define MAX(x, y) ((x) > (y) ? (x) : (y))int max_val = MAX(10, 20); // 预处理后变为 ((10) > (20) ? (10) : (20)),结果为 20
3. 宏和函数的区别是什么?使用宏有哪些优缺点?
宏:
通过#define 预处理指令定义,在编译前的预处理阶段进行文本替换(宏展开)。
不进行类型检查,直接文本替换。
函数:
通过函数声明和定义实现,在编译阶段生成可执行代码,运行时通过函数调用机制跳转执行。
有明确的类型,调用时先计算参数值再传递,按值传递或指针传递。
使用宏的优缺点:
宏展开后直接嵌入代码,避免了函数调用的压栈、跳转、返回等开销。适合高频使用的简单操作。
宏可在预处理阶段完成计算,(如#define PI 3.14159)
宏定义可实现条件编译。
用于定义常量、简单表达式,简化代码。
4. 什么是宏替换?宏替换的过程是怎样的?
宏替换是预处理阶段的一个功能,可以通过定义宏来实现代码的批量替换。
分为不带参数的宏和带参宏。
不带参数格式:
#define 宏名 替换文本
例如#define PI 3.14159
带参宏:
#define 宏名(参数列表) 替换表达式
例如 #define MAX(a,b) ((a) > (b)?(a):(b))
宏替换的过程:
宏替换发生在编译器对代码进行编译之前的预处理阶段,
对于不带参数的宏:
在预处理阶段,预处理器逐行扫描代码,找到匹配的宏名后,将其替换为对应的替换文本。
对于带参宏:
预处理器将宏调用中的参数按顺序带入宏定义的参数列表中。
例如:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 10, y = 20;
int max_val = MAX(x + 1, y); // 代入后:((x + 1) > (y) ? (x + 1) : (y))
若替换文本中包含#或##:
#用于将参数转换为字符串(字符串化),例如:
#define STR(x) #x
printf(STR(Hello World)); // 替换为 "Hello World",成为了字符串了
##用于连接两个参数(连接符),例如:
#define CONCAT(a, b) a##b
int ab = CONCAT(1, 2); // 替换为 int ab = 12;
替换后的文本如果包含其他宏名,预处理器会再次扫描并替换。
文件包含(#include)
1. #include 的作用是什么?
#include 是一条预处理指令,用于将指定头文件的内容包含到当前源文件中,从而在编译之前将头文件中的代码合并到当前文件里。
可以用尖括号<>(用于引用标准库头文件)和“” (用于引用咱自己的自定义头文件)。
2. #include <> 和 #include “” 的区别是什么?
尖括号<>的文件包含,预处理程序会首先在系统默认的头文件目录中搜索头文件。如果没找到,再搜索用户自定义的其他目录(不过编译时得配置一下)。适合包含标准库头文件。
双引号“”的文件包含,预处理程序会首先在当前源文件所在的目录(或指定的相对路径)中搜索头文件。如果没找到,再按尖括号<>的规则搜索系统默认目录。适合包含自定义的头文件。
3. 如何避免头文件的重复包含?
可以用条件编译,通过#ifndef 、 #define 、#endif 组合。
#ifndef MY_HEADER_H // 保护符命名规则:头文件名大写,替换 `.` 为 `_`,加后缀 `_H`
#define MY_HEADER_H // 首次包含时定义该宏,后续包含时会跳过中间内容// 头文件内容(函数声明、结构体定义、宏定义等)
#include <stdio.h>void print_hello();
typedef struct { int x, y; } Point;#endif // MY_HEADER_H
通常为头文件名全大写,将 . 替换为_ ,并添加后缀 _H
条件编译(#if, #ifdef, #ifndef, #else, #elif, #endif)
1. 什么是条件编译?有哪些条件编译的指令?
条件编译指令没有优先级概念,仅通过“就近分配”和“层级嵌套”确定作用域,
编写代码时需通过 缩进和注释 明确条件块的层级关系。
每个 #ifdef #ifndef #if 必须有对应的#endif 就跟括号()是一样的。
2. 如何使用#ifdef 和#ifndef?
#ifdef 表示 “如果宏已定义”:
#ifdef 宏名// 当“宏名”已被 #define 定义时,编译此处代码(无论是否被赋值)
#endif
可以根据宏定义编译不同代码:
#define DEBUG // 定义 DEBUG 宏以启用调试模式int main()
{#ifdef DEBUGprintf("Debug mode: program started\n"); // 仅当 DEBUG 被定义时编译#endif// 其他代码return 0;
}
#ifndef 表示“如果宏未定义”
#ifndef 宏名// 当“宏名”未被 #define 定义时,编译此处代码
#endif
主要为了放在头文件被重复包含:
// example.h
#ifndef _EXAMPLE_H_ // 若未定义 _EXAMPLE_H_(通常用头文件名大写加下划线)
#define _EXAMPLE_H_ // 定义该宏,确保后续包含时跳过// 头文件内容(结构体、函数声明等)int add(int a, int b);
#endif
3. 在什么情况下使用条件编译?
主要是避免头文件被多次包含导致编译错误(比如重复定义结构体、函数声明等)。
#ifndef _MY_HEADER_H_ // 若未定义 _MY_HEADER_H_,则执行后续内容
#define _MY_HEADER_H_// 头文件内容(结构体、函数声明、宏定义等)#endif // _MY_HEADER_H_
内存管理
1. 如何使用 malloc 分配动态内存?
malloc 用于在堆上动态分配内存,其返回值为void *类型的指针(指向分配内存的起始地址)。
函数原型:
void* malloc(size_t size);
size :需要分配的内存字节数,一般用sizeof。
示例一:分配一个int 类型的动态数组
int n = 5; // 假设需要存储 5 个 int 类型的数据
int* p = (int*)malloc(n * sizeof(int)); // 分配 n 个 int 的内存空间
要检查分配是否成功:
if (p == NULL)
{printf("内存分配失败!\n");exit(EXIT_FAILURE); // 终止程序(或进行错误处理)
}
释放内存用free,否则会导致内存泄漏:
free(p); // 释放动态分配的内存
p = NULL; // 手动置空指针,避免悬空指针
使用malloc 动态分配内存原则:分配后检查指针、使用后及时释放、释放后置空指针。
2. malloc 和 calloc 的区别是什么?
malloc:
功能:分配一块指定大小的连续内存空间。
原型:void* malloc(size_t size),
特点:直接分配size 字节的内存,不初始化内存内容。
calloc:
功能:分配 n 个元素的内存空间,每个元素大小为size 字节,并将内存初始化为0。
原型:void* calloc(size_t n, size_t size),参数 n 是元素个数,size 是每个元素的字节大小。
特点:分配的总内存大小为 n * size 字节,并将所有字节初始化为二进制 0(相对于初始化为0 或 NULL)
示例:
int main()
{// 使用 malloc 分配 5 个 int 的内存(未初始化)int* malloc_p = (int*)malloc(5 * sizeof(int));if (malloc_p == NULL) {perror("malloc failed");return -1;}printf("malloc memory: ");for (i = 0; i < 5; i++) {printf("%d ", malloc_p[i]); // 输出未定义值(垃圾数据)}free(malloc_p);// 使用 calloc 分配 5 个 int 的内存(初始化为 0)int* calloc_p = (int*)calloc(5, sizeof(int));if (calloc_p == NULL) {perror("calloc failed");return -1;}printf("\ncalloc memory: ");for (i = 0; i < 5; i++) {printf("%d ", calloc_p[i]); // 输出 0 0 0 0 0}free(calloc_ptr);return 0;
}
3. 如何释放动态分配的内存?为什么需要释放?
通过free()函数释放动态分配的内存:
int *ptr = (int *)malloc(10 * sizeof(int)); // 分配内存
// 使用内存...
free(ptr); // 释放内存
ptr = NULL; // 建议将指针置为 NULL,避免野指针
释放的原因:
1.避免内存泄漏:动态分配的内存位于堆中,不会像栈内存那样在函数结束后自动释放。
这就会导致可用内存逐渐减少,程序运行效率下降,甚至崩溃。
2.合理利用系统资源。
3.防止野指针问题:释放内存后若不将指针置为NULL,那么该指针还是指向已释放的内存地址,成为野指针。后续使用会导致程序崩溃。
4. 堆和栈的区别
堆和栈是两种不同的内存分配区域 ,用于存储程序运行时的数据。
栈:
由编译器自动管理,用于存储函数参数、局部变量、返回地址等。当函数调用时,栈帧自动创建:函数返回时,栈帧自动释放,无需手动干预。
空间大小固定,linux系统默认栈大小可以通过ulimit -s 查看,一般为8MB左右。
内存由高地址向低地址生长,函数调用时,新的栈帧压入栈顶(低地址方向),返回时弹出栈顶。
位于CPU缓存附近,访问速度极快,仅次于寄存器。
变量生命周期与函数调用周期一致,随栈帧创建而存在,随栈帧销毁而释放。
void fun()
{int n = 10; // 栈上分配
} // 函数结束后,n 自动释放
堆:
得用malloc、calloc、realloc等函数手动申请,并通过free手动释放,不然会导致内存泄漏。
int* p = (int*)malloc(sizeof(int)); // 堆上分配
free(p); // 手动释放
空间大小灵活,适合存储大块数据或动态变化的数据结构。
内存由低地址向高地址生长(向上生长),通过分配器在堆空间中寻找合适的空闲块进行分配。
访问速度较慢,需要通过指针间接操作。
变量生命周期如果没有主动释放,会一直存在直到程序结束(全局堆变量)或系统回收内存。
5. 什么是内存溢出
内存溢出是指程序在申请或使用内存时,超出了系统分配给他的内存空间范围,导致数据“溢出”到其他内存区域,进而引发程序错误。
常见的内存溢出场景:
1.缓冲区溢出:发生在向固定大小的缓冲区(如数组)写入数据时,超出其容量。
int main()
{char buffer[5]; // 缓冲区大小为 5(可存储 4 个字符 + 1 个终止符)strcpy(buffer, "123456"); // 写入 6 个字符,超出缓冲区大小return 0;
}
strcpy 不检查目标缓冲区大小。
2.动态分配内存溢出:使用malloc、calloc等函数分配内存后,访问越界。
int main()
{int *p = (int *)malloc(5 * sizeof(int)); // 分配 5 个 int 的空间if (p == NULL) {return -1;}for (int i = 0; i <= 5; i++) { // 循环 6 次,访问第 6 个元素(越界)p[i] = i; // 溢出到 malloc 分配的内存块之外}free(p);return 0;
}
3.栈溢出:函数调用栈的空间被耗尽,通常由过度递归或过大的局部变量引起。
void recursive_function()
{int a[1000000]; // 局部数组过大,占用大量栈空间recursive_function(); // 无限递归,栈深度不断增加
}int main()
{recursive_function();return 0;
}
6. 什么是内存泄露
内存泄漏是指程序动态分配的内存空间在使用完毕后未被正确释放,导致该内存空间无法被系统重新分配利用的现象。
内存泄漏的常见场景:
1.未调用 free 释放内存:分配内存后直接返回或结束程序,未执行对应的 free。
void example()
{int *p = (int *)malloc(sizeof(int)); // 分配内存// 使用 ptr...// 未调用 free(ptr),导致内存泄漏
}
2.指针的指向改变前未释放原内存:当指针指向新的内存地址时,未提前释放其原来指向的内存。
void example()
{int *p = (int *)malloc(sizeof(int));p = (int *)malloc(sizeof(int)); // 新分配内存,原内存未释放free(p); // 仅释放最后一次分配的内存,第一次分配的内存泄漏
}
3.循环或条件分支中分配内存但未完全释放:在循环或条件判断中分配内存,但若分支未执行释放逻辑,会导致部分内存未被释放。
void example(int flag)
{int *p = (int *)malloc(sizeof(int));if (flag) {return; // 直接返回,未释放 p}free(p); // 仅当 flag 为 false 时释放,flag 为 true 时泄漏
}
4.动态数组或数据结构未完全释放:如果数据结构包含动态分配的子元素,释放时需逐层释放,如果仅释放顶层指针而未释放子元素,会导致子元素内存泄漏。
typedef struct Node
{int data;struct Node *next;
} Node;void leak_example()
{Node *head = (Node *)malloc(sizeof(Node));head->next = (Node *)malloc(sizeof(Node));free(head); // 仅释放头节点,未释放头节点的 next 指针指向的内存
}
内存泄漏会让未释放的内存长期占用系统资源,导致其他程序或当前程序的后续操作可用内存减少。导致程序运行速度下降,甚至无法分配内存而崩溃。
7. 什么是内存碎片
内存碎片是指程序运行过程中,由于频繁地分配和释放动态内存,导致堆内存出现大量不连续的小空闲块,这些空闲块虽然总容量足够满足内存分配需求,但由于不连续而无法被利用的现象。主要有两种:
1.内部碎片:
当分配的内存块大于实际所需大小时,多出的部分无法被利用,成为内部碎片。
例如,使用malloc 分配100字节内存,但实际仅使用80字节,剩余20字节无法被其他分配请求使用。
2.外部碎片:
多次分配和释放内存后,堆中产生大量不连续的小空闲块,虽然总空闲内存足够,但无法找到单个足够大的连续块满足分配需求。
例如,堆中有10个10字节的空闲块(总100字节),但无法满足一个100字节的分配请求。因为他们不连续。
3.内存碎片的影响:
分配效率下降、内存利用率降低、程序性能下降。
4.如何减少内存碎片:
避免频繁分配和释放小块内存,避免长时间持有大块内存。
其它
1.const 和 #define 的区别
const 和 #define 都可以用于定义常量。
1.类型安全和编译器检查:
const 定义的是 有类型的常量,会受到编译器的类型检查。
若定义在函数内,作用域为该函数。
若定义在头文件或全局作用域,需通过extern 声明才能在其他文件中使用。
const int a = 10; // 定义一个整型常量
a = 20; // 编译错误:试图修改常量
#define 定义的是 无类型的宏常量,仅在预处理阶段进行简单的文本替换,不进行类型检查。
作用域从定义位置开始,到文件结束或遇到#undef 终止,没有作用域限制。
#define A 10 // 定义一个宏常量
A = 20; // 预处理后变为 10 = 20,编译错误(语法错误)
2.内存分配
const 修饰的变量在内存中会分配空间,但值不可修改。
#define 不分配内存,直接文本替换。
3.预处理阶段
const 由编译器处理,属于编译阶段。
#define 由预处理器出来,在编译前完成文本替换。
4.可扩展性与灵活性
const 可以定义指针常量、数组常量等复杂类型。
#define 只能定义简单的文本替换。
2.sizeof 和 strlen() 的区别
这俩个都是用于处理数据大小或长度的。
sizeof:
类型:是操作符,而非函数。
功能:计算一个变量或数据类型在内存中占用的字节数,包括所有分配的内存空间(如果是字符串数组,含末尾的 \0,)
可以作用于任意数据类型,包括基本类型、数组、结构体、指针等。
返回值类型为size_t (无符号整数类型)。
计算发生在编译阶段,结果是编译时确定的常量,不实际访问内存。
int a[5] = {1, 2, 3, 4, 5};
sizeof(a); // 结果为 5 * sizeof(int)(假设 int 占 4 字节,则为 20)
sizeof(int); // 结果为 4(int 类型的字节数)
char* p = "hello";
sizeof(p); // 结果为指针变量的字节数(32 位系统为 4,64 位系统为 8)
strlen():
是C标准库中的函数。
只能计算一个以 \0 结尾的字符串的有效字符长度(不包含末尾的 \0)。
参数必须是char *类型的指针(用于指向字符串的首地址)。
返回值类型为 size_t ,需在运行阶段遍历字符串,直到遇到 \0 ,所以会访问内存。
char s[] = "hello";
strlen(str); // 结果为 5(计算 'h','e','l','l','o' 共 5 个字符)
char* p = s;
strlen(ptr); // 结果同样为 5
3. 静态变量和局部变量的区别
1.定义位置与作用域
局部变量:
在函数内部或代码块{}内定义。
仅在定义它的函数或代码块内可见,外部无法访问。
静态变量:
在函数内部或代码块{}内定义,但使用 static 修饰,
作用域 与局部变量相同,但生命周期更长。
2.存储位置与内存分配
局部变量:
存储在 栈 中,由系统自动分配和释放。
函数调用时分配内存,函数结束后释放内存。
静态变量:
存储在 静态存储区 中,程序运行期间始终存在。
程序编译时分配内存,程序结束后释放内存。
3.生命周期
局部变量:
从函数调用开始到函数结束,每次调用重新创建和初始化。
每次函数调用时 值 会被重置,除非使用全局变量或指针间接传递。
静态变量:
从程序启动到程序结束,仅初始化一次,后续调用保留上次的值。
多次调用函数时,值会被保留(类似于 记忆 功能)
4.初始化与默认值
局部变量:
可以在定义时初始化。
未初始化时,值为随机值。
静态变量:
可以在定义时初始化。
为初始化,默认值为0(整形)或空指针(指针类型)。