1.volatile关键字
volatile的意思是”易变的”,这个关键字主要是防止编译器对变量进行优化。即告诉编译器每次存取该变量的时候都要从内存去存取而不是使用它之前在寄存器中的备份。详细分析一下什么是编译器优化,以及为什么使用这个关键字。
1.1.关于编译器优化
首先理解CPU(寄存器)读取规则,如:
int a, b; // 为a,b申请内存
a = 1; // 1 -> CPU
// CPU -> 内存(&a)
b = a; // 内存(&a) -> CPU
// CPU -> 内存(&b)
如上图代码所示,a = 1这个程序,先将1写入CPU,再从CPU中将1写入a所在的内存地址中; b = a是先从内存中将a的值取出到CPU,再从CPU将值存入b的内存地址中。
int a = 1, b, c; // 为a,b,c申请内存并初始化
b = a; // 内存(&a) -> CPU
// CPU -> 内存(&b)
c = a; // * 内存(&a) -> CPU *
// CPU -> 内存(&c)
如上图代码所示,上边的程序如果按第一段代码所说的顺序执行,则c = a语句在编译时是可以被编译器优化的,即注释部分(* 内存(&a) -> CPU *)的内容不被执行,因为在b = a这个语句中,a已经被移入过寄存器(CPU),那么在执行c = a时,就直接将a在寄存器(CPU)中传递给c。这样就减少了一次指令的执行,就完成了优化。
上面就是编译器优化的原理过程,但是这个过程,有时会出现问题,而这个问题也就volatile存在的意义。
1.2.volatile的引入
上边程序中,如果在执行完b = a后,a此时的值存放在CPU中。但是a在内存中又发生了变化(比如中断改变了a的值),但是存在CPU中的a是原来未变的a,按理应该是已经变化后的a赋值给c,但是此时却导致未变化的a赋值给了c。
这种问题,就是编译器自身优化而导致的。为了防止编译器优化变量a,引入了volatile关键字,使用该关键字后,程序在执行时c = a时,就会先去a的地址读出a到CPU,再从CPU将a的值赋予给c。这样就防止了被优化。
volatile int a = 1, b, c; // 为a,b,c申请内存并初始化
b = a; // 内存(&a) -> CPU
// CPU -> 内存(&b)
c = a; // 内存(&a) -> CPU
// CPU -> 内存(&c)
1.3.哪些情况下使用volatile
(1)并行设备的硬件寄存器。存储器映射的硬件寄存器通常加volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用volatile,它会告诉编译器不要对存储在这个地址的数据进行假设。
(2) 一个中断服务程序中修改的供其他程序检测的变量。volatile提醒编译器,它后面所定义的变量随时都有可能改变。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
(3)多线程应用中被几个任务共享的变量。
2.static关键字
2.1.static关键词的作用?
static是被声明为静态类型的变量,存储在静态区(全局区)中,其生命周期为整个程序,如果是静态局部变量,其作用域为一对{ }内,如果是静态全局变量,其作用域为当前文件。静态变量如果没有被初始化,则自动初始化为0。
2.2.为什么 static变量只初始化一次?
对于所有的对象(不仅仅是静态对象),初始化都只有一次,而由于静态变量具有“记忆”功能,初始化后,一直都没有被销毁,都会保存在内存区域中,所以不会再次初始化。存放在静态区的变量的生命周期一般比较长,它与整个程序“同生死、共存亡”,所以它只需初始化一次。而auto变量,即自动变量,由于它存放在栈区,一旦函数调用结束,就会立刻被销毁。
static修饰的全局变量,只能在本文件被调用;修饰的函数也只能在本文件调用。
3.const关键字
用于声明常量或只读变量,其核心作用是限制数据的可修改性,让编译器帮助检查代码中是否存在意外修改数据的操作,从而提高代码的安全性、可读性和可维护性。
3.1. 修饰普通变量(常量)
用 const
修饰的变量,定义后不能被重新赋值,试图修改会导致编译错误。
const int max_size = 100; // 声明一个常量,值固定为100
max_size = 200; // 错误!编译时会报错(试图修改const变量)
注意:const
变量必须在定义时初始化(否则后续无法赋值,变量值无意义):
const int a; // 错误!const变量未初始化
const int b = 5; // 正确:定义时初始化
3.2. 修饰指针(关键难点)
const
修饰指针时,根据位置不同,含义完全不同,核心看 const
和 *
的位置关系:
(1)const
修饰指针指向的数据(常量指针)
const
在 *
左侧,表示指针指向的数据不可修改,但指针本身可以指向其他地址。
int num1 = 10, num2 = 20;
const int* p = &num1; // p是常量指针:*p不可改,p可改
*p = 30; // 错误!不能修改指针指向的数据
p = &num2; // 正确!指针可以指向其他地址
(2)const
修饰指针本身(指针常量)
const
在 *
右侧,表示指针本身不可修改(不能指向其他地址),但指向的数据可以修改。
int num1 = 10, num2 = 20;
int* const p = &num1; // p是指针常量:p不可改,*p可改
p = &num2; // 错误!指针不能指向其他地址
*p = 30; // 正确!可以修改指向的数据(num1变为30)
(3)const
同时修饰指针和指向的数据(指向常量的常量指针)
const
同时在 *
左右两侧,表示指针本身和指向的数据都不可修改。
int num1 = 10, num2 = 20;
const int* const p = &num1; // 指针和数据都不可改
p = &num2; // 错误!指针不能改
*p = 30; // 错误!指向的数据不能改
3.3. 修饰函数参数
用 const
修饰函数参数,表示函数内部不会修改该参数,避免意外修改传入的数据,同时明确告知函数调用者 “参数只读”。
(1)保护传入的普通变量
// 计算a和b的和,明确不会修改a和b
int add(const int a, const int b) {
// a = 5; // 错误!不能修改const参数
return a + b;
}
(2)保护指针指向的数据(常用)
当函数参数是指针时,用 const
修饰指针指向的数据,避免函数内部误修改指针指向的外部数据:
// 打印数组内容,明确不会修改数组元素
void print_array(const int* arr, int len) {
for (int i = 0; i < len; i++) {
// arr[i] = 0; // 错误!不能修改const指针指向的数据
printf("%d ", arr[i]);
}
}
3.4. 修饰函数返回值
用 const
修饰函数返回值,表示返回的结果不可被修改,通常用于返回指针的场景,避免外部通过返回的指针修改内部数据。
// 返回内部数组的指针,但限制外部不能修改数组元素
const int* get_internal_array() {
static int arr[5] = {1,2,3,4,5};
return arr; // 返回const指针
}
// 调用函数
int main() {
const int* p = get_internal_array();
// *p = 10; // 错误!不能修改返回的const数据
printf("%d", *p); // 正确:可以读取
return 0;
}
3.5. 修饰全局变量
用 const
修饰全局变量,可以限制其作用域(默认仅当前文件可见,需用 extern
才能跨文件访问),同时避免被其他文件意外修改。
// file1.c
const int global_const = 100; // const全局变量,默认仅file1.c可见
// file2.c
extern const int global_const; // 需用extern声明才能访问
void func() {
printf("%d", global_const); // 正确:可以读取
// global_const = 200; // 错误!不能修改
}
4.typedef和#define有什么区别?
#define
:
是预处理指令,在编译预处理阶段(编译前)工作,仅做文本替换(无类型检查,不理解 C 语言的语法规则)。
例:#define PI 3.14
会在预处理时,将代码中所有PI
替换为3.14
。typedef
:
是C 语言关键字,在编译阶段工作,用于给已有数据类型定义一个新的别名(会进行类型检查),仅用于简化类型定义。
例:typedef int INT
是给int
类型起一个新名字INT
,编译器会将INT
识别为int
类型的等价物。
5.定义常量时#define和const有什么区别?
#define
是预处理指令,在编译前的预处理阶段工作,仅做文本替换(不理解 C 语言语法,无类型概念)。
例:#define MAX 100
会在预处理时,将代码中所有MAX
直接替换为100
,编译器后续处理的是替换后的代码。const
是关键字,在编译阶段工作,用于声明只读变量(有明确的类型,编译器会进行类型检查)。
例:const int max = 100
定义了一个int
类型的变量max
,其值被强制固定为 100,任何修改操作都会触发编译错误。
因此,#define
是 “文本替换工具”,灵活但无类型安全;const
是 “只读变量定义”,类型安全且可调试。现代 C/C++ 开发中,定义数值常量时优先使用 const
,仅在必要时(如条件编译、宏函数)使用 #define
。
6.全局变量和局部变量的区别是什么?
在 C/C++ 等编程语言中,全局变量和局部变量的核心区别在于作用域(可访问的范围)和生命周期(存在的时间),这直接影响它们在程序中的使用方式和特性。
对比维度 | 全局变量(Global Variable) | 局部变量(Local Variable) |
---|---|---|
定义位置 | 定义在所有函数外部(包括 main 函数外) |
定义在函数内部、代码块(如 if /for 块)内部 |
作用域 | 从定义处到整个文件结束(若用 extern 声明,可跨文件访问) |
仅限定义它的函数或代码块内部(离开该范围则不可访问) |
生命周期 | 从程序启动到程序结束(与程序同生共死) | 从进入函数 / 代码块时创建,离开时销毁(临时存在) |
默认初始化 | 若未手动初始化,会被编译器自动初始化为 0(或空指针) | 若未手动初始化,值为随机垃圾值(未定义) |
内存存储区 | 存储在全局数据区(静态存储区) | 存储在栈区(函数调用时动态分配) |
全局变量示例:
#include <stdio.h>
// 全局变量:定义在所有函数外部
int global_var = 100; // 作用域:从这里到整个文件结束
void func1() {
// 可访问全局变量
printf("func1中访问全局变量:%d\n", global_var); // 输出100
global_var = 200; // 可修改全局变量
}
void func2() {
// 全局变量的值已被func1修改
printf("func2中访问全局变量:%d\n", global_var); // 输出200
}
int main() {
func1();
func2();
printf("main中访问全局变量:%d\n", global_var); // 输出200
return 0;
}
局部变量示例:
#include <stdio.h>
void func() {
// 局部变量:定义在函数内部
int local_var = 10; // 作用域:仅限func函数内
printf("func中访问局部变量:%d\n", local_var); // 输出10
}
int main() {
func();
// printf("main中访问func的局部变量:%d\n", local_var); // 错误!局部变量超出作用域
// 代码块内的局部变量
if (1) {
int block_var = 20; // 作用域:仅限当前if块内
printf("if块中访问局部变量:%d\n", block_var); // 输出20
}
// printf("if块外访问block_var:%d\n", block_var); // 错误!超出作用域
return 0;
}
全局变量和局部变量的核心区别是作用域(访问范围)和生命周期(存在时间):
- 全局变量:“全文件可见,随程序生死”,适合共享数据,但需谨慎使用以避免副作用。
- 局部变量:“局部可见,随函数 / 代码块生死”,内存管理简单、安全性高,是编程中的主要选择。
实际开发中,应优先使用局部变量,仅在必要时(确实需要跨函数共享数据)使用全局变量,并尽量通过命名规范(如前缀 g_
)明确标识全局变量。
7.全局变量可不可以定义在可被多个.C文件包含的头文件中?为什么?
可以,在不同的C文件中以static形式来声明同名全局变量。
可以在不同的C文件中声明同名的全局变量,前提是其中只能有一个C文件中对此变量赋初值,此时连接不会出错。(仅定义一次,分配内存)
8.局部变量能否和全局变量重名?
能,局部会屏蔽全局。
局部变量可以与全局变量同名,在函数内引用这个变量时,会用到同名的局部变量,而不会用到全局变量。 对于有些编译器而言,在同一个函数内可以定义多个同名的局部变量,比如在两个循环体内都定义一个同名的局部变量,而那个局部变量的作用域就在那个循环体内。
9.数组指针与指针数组
数组指针(Array Pointer)和指针数组(Pointer Array)是 C 语言中容易混淆的两个概念,核心区别在于:数组指针是 “指向数组的指针”,而指针数组是 “存储指针的数组”。理解它们的语法和用途,需要从 “优先级” 和 “本质” 两方面入手。
数组指针(指向数组的指针)
语法:
类型 (*指针名)[数组长度]
括号()
提高*
的优先级,确保指针名
先与*
结合,形成指针,再指向一个 “长度固定的数组”。本质:一个指针变量,专门用于指向整个数组(而非数组中的元素)。
示例:
int arr[5] = {1,2,3,4,5};
int (*p)[5] = &arr; // p是数组指针,指向整个int[5]类型的数组
p
指向数组arr
的首地址(与arr
的值相同,但类型不同)。*p
等价于数组arr
本身,因此(*p)[i]
等价于arr[i]
。
指针数组(存储指针的数组)
语法:
类型 *数组名[数组长度]
方括号[]
优先级高于*
,确保数组名
先与[]
结合,形成数组,数组中的每个元素都是指针。本质:一个数组,其元素类型是指针(如
int*
、char*
等)。示例:
int a = 1, b = 2, c = 3;
int *p[3] = {&a, &b, &c}; // p是指针数组,存储3个int*类型的指针
p
是一个数组,长度为 3,每个元素都是指向int
类型的指针。p[0]
指向变量a
,*p[0]
等价于a
。
对比维度 | 数组指针(Array Pointer) | 指针数组(Pointer Array) |
---|---|---|
本质 | 指针(变量),指向一个数组 | 数组,元素是指针 |
内存占用 | 仅占用一个指针的内存(如 4 字节或 8 字节) | 占用 “数组长度 × 指针大小” 的内存(如 3×4=12 字节) |
初始化方式 | 必须指向一个同类型、同长度的数组 | 初始化列表中是指针(地址) |
访问元素 | 通过 (*指针名)[索引] 访问数组元素 |
通过 *数组名[索引] 访问指针指向的内容 |
典型用途 | 指向二维数组的行、处理多维数组 | 存储多个字符串(字符指针数组)、管理多个同类型变量的地址 |
区分两者的关键是运算符优先级([]
高于 *
):
int (*p)[5]
:()
让p
先与*
结合 →p
是指针,指向int[5]
数组 → 数组指针。int *p[5]
:[]
优先级高,p
先与[]
结合 →p
是数组,元素是int*
→ 指针数组。
10.数组下标可以为负数吗?
可以,因为下标只是给出了一个与当前地址的偏移量而已,只要根据这个偏移量能定位得到目标地址即可。
11.函数指针与指针函数
函数指针 (Function Pointer)
函数指针是指向函数的指针变量,它存储的是函数的入口地址,可以通过该指针调用函数。
特点:
- 指向函数的指针
- 可以像函数一样被调用
- 常用于回调函数、函数表等场景
示例:
int add(int a, int b) {
return a + b;
}
int main() {
// 声明函数指针
int (*func_ptr)(int, int);
// 将函数地址赋给指针
func_ptr = add;
// 通过指针调用函数
int result = func_ptr(3, 4);
printf("%d\n", result); // 输出7
return 0;
}
指针函数 (Pointer Function)
指针函数是指返回值为指针类型的函数,本质上是一个函数,只是它的返回值是一个指针。
特点:
- 返回指针的函数
- 函数名本身不是指针
- 调用后返回一个指针
示例:
int* create_array(int size) {
int *arr = (int*)malloc(size * sizeof(int));
for(int i = 0; i < size; i++) {
arr[i] = i + 1;
}
return arr; // 返回指针
}
int main() {
int *my_array = create_array(5);
for(int i = 0; i < 5; i++) {
printf("%d ", my_array[i]); // 输出1 2 3 4 5
}
free(my_array);
return 0;
}
主要区别
对比维度 | 函数指针(Function Pointer) | 指针函数(Pointer Function) |
---|---|---|
本质 | 指针变量,指向函数 | 函数,返回值为指针 |
语法特征 | 有 (*指针名) 结构,括号不可省略 |
无特殊括号,返回值类型带 * |
核心用途 | 回调函数、函数表(动态选择执行的函数) | 返回数据的地址(如数组元素、动态分配的内存) |
内存占用 | 仅占用一个指针的内存(4 字节或 8 字节) | 函数本身不占用变量内存(代码存储在代码段) |
- 函数指针是指向函数的指针变量,用于动态调用函数(如回调机制),语法为
返回值 (*名)(参数)
。 - 指针函数是返回指针的函数,用于返回地址(如动态内存、数组元素),语法为
返回值* 名(参数)
。
12.数组和指针的区别与联系是什么?
- 本质
数组的本质是一个连续的内存空间,用于存储相同类型的多个数据。
指针是一种变量,用来存储另一个变量的内存地址(指向某块内存)
- 存储方式
数组在编译时会分配固定大小的连续内存(栈 / 全局区)。数组是根据数组的下标进行访问的。
指针很灵活,它可以指向任意类型的数据,仅占用一个指针变量的内存。(通常 4/8 字节,存储地址)
- 求sizeof
数组:
数组所占存储空间的内存:sizeof(数组名)
数组的大小:sizeof(数组名)/sizeof(数据类型)
指针:
在32位平台下,无论指针的类型是什么,sizeof(指针名)都是4,在64位平台下,无论指针的类型是什么,sizeof(指针名)都是8。
- 数据访问方面
指针对数据的访问方式是间接访问,需要用到解引用符号(*数组名)。
数组对数据的访问则是直接访问,可通过下标访问或数组名+元素偏移量的方式
- 使用环境
指针多用于动态数据结构(如链表,等等)和动态内存开辟。
数组多用于存储固定个数且类型统一的数据,已知数据量且固定时,用数组。
此外,数组名是常量,是不能被赋值的,如 arr = p 就是
错误的。指针是变量,可被重新赋值指向其他内存,如 *p = arr。
13.
指针进行强制类型转换后与地址进行加法运算,结果是什么?
在 C 语言中,指针强制类型转换后与地址进行加法运算时,结果取决于转换后的指针类型。指针加法的本质是 “根据指针指向的数据类型大小,计算偏移后的地址”,而非简单的字节数相加。
指针加法的计算公式为:
新地址 = 原地址 + (n × 转换后类型的字节大小)
其中,n
是加法的整数(如 p + 1
中 n=1
),“转换后类型的字节大小” 由 sizeof(转换后类型)
决定。
假设系统为 32 位(指针占 4 字节),有一个初始地址 0x1000
,通过不同类型转换后的加法结果如下:
#include <stdio.h>
int main() {
// 假设初始地址为0x1000(实际中可用具体变量地址测试)
char* p_char = (char*)0x1000; // 转换为char*类型(1字节)
short* p_short = (short*)0x1000; // 转换为short*类型(2字节)
int* p_int = (int*)0x1000; // 转换为int*类型(4字节)
double* p_double = (double*)0x1000; // 转换为double*类型(8字节)
// 计算指针+1后的地址
printf("char* +1: %p\n", p_char + 1); // 0x1000 + 1×1 = 0x1001
printf("short* +1: %p\n", p_short + 1); // 0x1000 + 1×2 = 0x1002
printf("int* +1: %p\n", p_int + 1); // 0x1000 + 1×4 = 0x1004
printf("double* +1: %p\n", p_double + 1); // 0x1000 + 1×8 = 0x1008
return 0;
}
输出:
char* +1: 0x1001
short* +1: 0x1002
int* +1: 0x1004
double* +1: 0x1008
因此,指针加法的偏移量由 “转换后的类型大小” 决定,而非指针本身的原始类型。
例如:(int*)p + 1
偏移 4 字节,(char*)p + 1
仅偏移 1 字节(即使 p
原本是 int*
类型)。核心是:指针的类型决定了每次加法的 “步长”,这是 C 语言指针算术的基础特性。
14.野指针
野指针指的是指向无效内存区域的指针,其行为完全不可预测,严重的会导致程序崩溃。这种指针没有明确的指向,或者指向的内存已经被释放,继续使用可能导致不可预测的程序行为。
原因:
- 当指针被创建时,指针不可能自动指向NULL,这时,默认值是随机的,此时的指针成为野指针。
- 当使用 free() 释放指针指向的内存后,指针本身的值并未改变(仍保留原地址),但该地址已不属于程序,指针就会变成野指针。
- 指针超出数组的合法范围后,指向数组之外的无效内存也会造成野指针,也就是指针越界。
因此在使用 free() 后,要及时将指针设为 NULL(空指针)避免野指针。并且要在指针定义时立即初始化,在操作数组或内存块时,确保指针不超出边界。
15.C语言中内存分配的方式有几种?
静态存储区分配
内存分配在程序编译之前完成,且在程序的整个运行期间都存在,与程序生命周期一致(从程序启动到退出),例如全局变量、静态局部变量等。无需手动申请 / 释放,编译器自动处理。
好处是使用简单,无需手动管理内存。坏处是无法动态调整。
栈上分配(自动分配)
在函数执行时,函数内的局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动释放。局部变量、函数内参数都在栈上。编译器自动分配和释放,无需手动干预。
这种方式的好处是内存自动管理(无泄漏风险),分配 / 释放速度快(栈操作效率高)。但是栈空间通常较小(如几 MB),分配大内存(如大数组)可能导致栈溢出(Stack Overflow)
堆上分配(动态分配)
在程序运行阶段,通过库函数手动申请内存存储在堆区(Heap)。生命周期从手动申请成功开始,到手动释放为止(与作用域无关)。需通过 malloc
/calloc
/realloc
申请,free
释放,完全由程序员控制。
适合存储大数据或长度不固定的数据。缺点是需要手动管理,申请和释放的效率低于栈上分配。
16.堆与栈的区别(内存)
- 从申请方式来说,栈的空间由操作系统自动分配/释放,堆上的空间手动分配/释放,通过
malloc
申请内存,使用完后必须通过free
释放,否则会导致内存泄漏。 - 从申请大小的限制来说,栈的空间有限。栈顶的地址和栈的最大容量是预先规定好的,如果申请的空间超过栈的剩余空间时,会导致栈溢出(Stack Overflow),程序崩溃。堆是不连续的内存区域,获得的空间比较灵活,也比较大,适合存储大数据。
- 从申请效率来说,栈由系统自动分配,速度较快,栈的内存是连续的,分配时只需将 “栈顶指针” 向下移动。 堆是由手动分配的内存,堆内存不连续,一般速度比较慢。
因此:
- 简单、小型、短期使用的数据 → 用栈(局部变量、参数);
- 复杂、大型、长期使用或动态大小的数据 → 用堆(动态分配内存)。
17.栈在C语言中有什么作用?
C语言中栈用来存储临时变量,临时变量包括函数参数和函数内部定义的临时变量(局部变量)。函数调用中和函数调用相关的函数返回地址,函数中的临时变量,寄存器等均保存在栈中,函数调动返回后从栈中恢复寄存器和临时变量等函数运行场景。
多线程编程的基础是栈,栈是多线程编程的基石,每一个线程都最少有一个自己专属的栈,用来存储本线程运行时各个函数的临时变量。 操作系统最基本的功能就是支持多线程编程,每个线程都有专属的栈,中断和异常处理也具有专属的栈,因此栈也是操作系统的基石。
18.C语言函数参数压栈顺序是怎样的?
大多数 C 编译器默认采用从右到左的参数压栈顺序。这一设计主要是为了支持可变参数函数(如 printf
),其原理是:
- 最后一个参数先入栈,第一个参数最后入栈;
- 栈顶位置最终会指向第一个参数,函数内部可通过栈顶指针固定偏移量访问所有参数(包括可变参数)。
这是因为,可变参数函数的参数数量不固定(如 printf("%d", a)
和 printf("%d,%s", a, str)
),但第一个参数的位置固定。从右到左压栈后,第一个参数位于栈顶,函数内部可通过它确定后续可变参数的位置和类型
19.内存泄漏
指的是程序在动态分配内存后,由于疏忽或错误导致不再需要的内存没有被正确释放,从而造成内存资源浪费的现象。简单来说,就是程序 “忘记” 回收已经用不到的内存,这些内存会一直被占用,直到程序结束。
在 C 语言中,内存泄漏通常与动态内存分配函数(malloc、calloc、realloc)和释放函数(free)相关,如果函数malloc分配内存后没有释放,则每次循环调用都会泄漏内存,最终可能导致程序因内存耗尽而崩溃。
所以,每调用一次 malloc
,就必须对应调用一次 free,
或使用内存泄漏检测工具。
20.如何判断内存泄漏?
- 通过代码特征初步判断。重点检查有没有动态内存未释放,或者指针丢失(指针被重新赋值,原内存地址丢失)。
- 通过程序运行的特征判断,内存泄漏的典型表现包括:程序运行时间越长,内存占用越高;重复执行某操作后内存暴涨;程序崩溃前出现 “内存不足” 错误。
- 使用一些专业工具检测内存泄漏。
21.malloc、calloc、realloc区别
malloc
(memory allocate)
- 原型:
void* malloc(size_t size);
- 功能:在堆上分配一块大小为
size
字节的连续内存块。 - 特点:分配的内存未初始化,内容为随机垃圾值(原内存区域的残留数据)。
// 分配能存储5个int的内存(假设int为4字节,共20字节)
int* arr = (int*)malloc(5 * sizeof(int));
if (arr != NULL) {
// 内存分配成功,需手动初始化(如 arr[0] = 0;)
}
calloc
(contiguous allocate)
- 原型:
void* calloc(size_t num, size_t size);
- 功能:在堆上分配一块能存储 **
num
个大小为size
字节的元素 ** 的连续内存块(总大小为num * size
)。 - 特点:分配的内存会被自动初始化为 0(数值型为 0,指针型为
NULL
)。
// 分配能存储5个int的内存(总大小5×4=20字节),并初始化为0
int* arr = (int*)calloc(5, sizeof(int));
if (arr != NULL) {
// 无需手动初始化,arr[0]~arr[4]均为0
}
realloc
(re-allocate)
- 原型:
void* realloc(void* ptr, size_t new_size);
- 功能:调整已分配内存块的大小(扩大或缩小),原内存块中的数据会被保留(新大小小于原大小时,超出部分会被截断)。
- 特点:
- 若
ptr
为NULL
,则等价于malloc(new_size)
; - 若
new_size
为 0,则等价于free(ptr)
(释放内存)。
- 若
int* arr = (int*)malloc(5 * sizeof(int)); // 初始分配5个int
// 重新分配为10个int的大小
int* new_arr = (int*)realloc(arr, 10 * sizeof(int));
if (new_arr != NULL) {
arr = new_arr; // 调整指针指向新内存块
}
对比:
对比维度 | malloc |
calloc |
realloc |
---|---|---|---|
参数 | 单个参数:总字节数 size |
两个参数:元素个数 num + 单个元素大小 size |
两个参数:原指针 ptr + 新大小 new_size |
初始化 | 不初始化,内存内容为随机值 | 自动初始化为 0 | 保留原数据(新大小范围内) |
主要用途 | 分配内存,需手动初始化 | 分配数组 / 结构体等,需初始化为 0 的场景 | 动态调整已分配内存的大小 |
效率 | 略高(无需初始化操作) | 略低(需执行清零操作) | 取决于是否需要移动内存块 |
典型使用场景区别:
malloc
:
适合分配内存后需要手动设置初始值的场景,避免calloc
的清零开销。
例:分配缓冲区存储用户输入的数据(输入前无需清零)。calloc
:
适合分配数组、结构体等需要初始化为 0 的场景,省去手动初始化的步骤。
例:分配一个计数器数组(初始值必须为 0)。realloc
:
适合内存大小动态变化的场景,如:- 读取文件时,根据内容长度动态扩展缓冲区;
- 链表 / 哈希表扩容时调整内存块大小。
22.预处理器标识#error的作用
在 C 中,#error
是一个预处理指令,其核心目的是在预处理阶段主动触发错误,并输出指定的错误信息,从而阻止程序继续编译。它通常用于检查编译条件是否满足,确保程序在不符合预期环境或配置时及时报错,避免后续可能的逻辑错误。
例如:
#ifndef OS_VERSION
#error "编译错误:必须定义 OS_VERSION 宏(如 -DOS_VERSION=10)"
#endif
或是当代码仅支持特定编译器、系统或架构时,可用 #error
阻止在不兼容环境下编译。
#ifdef _WIN32
#error "该代码不支持 Windows 系统,请在 Linux 下编译"
#endif
因此,#error
的核心作用是在预处理阶段主动检查编译条件,及时发现并报告不满足预期的环境或配置,从而避免在错误环境下生成存在隐患的可执行程序。它是一种 “防患于未然” 的预处理手段。
23.如何使用 define声明个常数,用以表明1年中有多少秒(忽略闰年问题)
// 定义一年的秒数(忽略闰年,按365天计算)
#define SECONDS_PER_YEAR (365 * 24 * 60 * 60)
24.#include< filename. h>和#include" filename. h"有什么区别?
对于#include< filename.h>,编译器先从标准库路径开始搜索filename.h,使得系统文件调用较快。而 对于#include“ filename.h”,编译器先从用户的工作路径开始搜索filename.h,然后去寻找系统路径,使得自定义文件较快。
格式 | 搜索优先级 | 适用场景 |
---|---|---|
#include <xxx.h> |
优先搜索系统标准库目录 | 引入标准库头文件 |
#include "xxx.h" |
优先搜索当前源文件所在目录 | 引入用户自定义头文件 |
记住一个简单原则:系统库用 <>
,自己写的用 ""
,能避免路径搜索问题,提高代码可读性。
25.头文件的作用
头文件的作用主要表现为以下两个方面:
1. 通过头文件来调用库功能。出于对源代码保密的考虑,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口是怎么实现的。编译器会从库中提取相应的代码。
2. 头文件能加强类型安全检查。当某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,大大减轻程序员调试、改错的负担。
因此头文件的核心价值是作为 “接口契约”:
- 对使用者:提供了 “如何使用模块功能” 的说明(函数参数、返回值、类型定义);
- 对开发者:实现了代码的模块化拆分,允许多人协作和独立编译。
简单说,头文件就像 “产品说明书”—— 告诉别人如何使用你的代码,而不必暴露内部构造。。
26.在头文件中定义静态变量是否可行,为什么?
在头文件中可以定义静态变量,但这种做法不推荐,逻辑上不合理。因为会导致 “同一变量在多个源文件中被重复定义”。
当多个 .c
文件包含该头文件时(如 a.c
和 b.c
),预处理器会将头文件内容分别复制到每个 .c
中。这导致:
a.c
中会有一个static int num
;b.c
中也会有一个static int num
。
这两个 num
是完全独立的变量(内存地址不同),修改 a.c
中的 num
不会影响 b.c
中的 num
。这可能会导致逻辑混乱(例如在 a.c
中修改后,在 b.c
中读取不到修改后的值)。
多个源文件包含头文件时,会创建多个相同的静态变量副本,浪费内存。
正确的做法是:静态变量应在具体的源文件中定义(限制在单个文件内使用),或通过 extern
声明全局变量实现跨文件共享。
27.写一个"标准"宏MIN ,这个宏输入两个参数并返回较小的一个
// 标准MIN宏定义,使用双重括号确保优先级正确
#define MIN(a, b) ((a) < (b) ? (a) : (b))
28.C语言宏中“#”和“##”的区别
#
:字符串化运算符
核心作用:参数转字符串。预处理时自动将宏的参数直接转换为对应的字符串常量(即给参数外层包裹一对双引号 ""
)。
- 操作对象:宏的参数(而非宏名或其他符号)。
- 关键特点:仅对宏参数生效,且会保留参数中的空格、符号(如
+
、*
),直接转为字符串内容。
#include <stdio.h>
// 宏:将参数x转为字符串
#define STR(x) #x
int main() {
int num = 123;
// 1. 传入字面量:"hello" → 输出 "hello"
printf("字符串1:%s\n", STR(hello));
// 2. 传入变量名:"num" → 输出 "num"(注意:不会取变量值,仅转变量名)
printf("字符串2:%s\n", STR(num));
// 3. 传入表达式:"1+2*3" → 保留表达式原样,转为字符串
printf("字符串3:%s\n", STR(1+2*3));
return 0;
}
##
:连接运算符
核心作用:将宏中的两个符号(可以是宏参数、宏名、变量名、关键字等)“连接” 成一个新的单一符号(如变量名、函数名、宏名)。
- 操作对象:两个独立的 “预处理符号”(Token),而非字符串。
- 关键特点:连接后生成的新符号必须是合法的 C 标识符(如不能以数字开头),且预处理阶段完成连接,编译阶段按新符号处理。
使用规则与示例
基本用法:连接参数与固定符号
定义宏时,用
##
连接宏参数和其他符号,生成新的变量名 / 函数名。
#include <stdio.h>
// 宏:生成变量名 "var_xxx"(xxx为传入的参数)
#define MAKE_VAR(x) var_##x
int main() {
// 1. 定义变量:var_num(由 "var_" + "num" 连接而成)
int MAKE_VAR(num) = 456;
// 2. 访问变量:var_num → 输出 456
printf("连接生成的变量值:%d\n", var_num);
// 3. 连接函数名:func_add(由 "func_" + "add" 连接而成)
#define CALL_FUNC(x) func_##x()
CALL_FUNC(add); // 等价于调用 func_add()
return 0;
}
// 定义被连接调用的函数
void func_add() {
printf("调用了func_add函数\n");
}
进阶用法:连接两个宏参数
##
也可连接两个宏参数,生成动态符号。
#define CONCAT(a, b) a##b
int CONCAT(a, 1) = 10; // 定义变量 a1(a + 1 连接)
int CONCAT(2, b) = 20; // 错误!连接结果 "2b" 不是合法标识符(不能以数字开头)
所以,当需要 “把宏参数变成字符串” 时,用 #
(如打印参数名);当需要 “动态生成变量名 / 函数名” 时,用 ##
(如批量定义相似变量);二者本质是预处理阶段的文本操作,不影响运行时逻辑,需注意符号合法性(如 ##
连接结果不能以数字开头)。
29.extern”C” 的作用是什么?
extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。
30.strlen与sizeof的区别
例:strlen("\0") =0,sizeof("\0")=2。
strlen是库函数,用来计算字符串的长度(在C/C++中,字符串是以"\0"作为结束符的),它从起始地址开始扫描直到碰到第一个字符串结束符\0为止,然后返回计数器值。仅针对字符串。
sizeof是C语言的关键字,它以字节的形式给出了其操作数的存储大小,操作数可以是任意数据类型 / 变量 / 数组 / 表达式。它计算对象在内存中占用的总字节数(包含所有元素 / 内部成员)。
31.struct与union的区别
在 C 语言中,struct
(结构体)和union
(共用体)都是用于组合不同类型数据的复合数据类型,但它们的内存分配方式和使用场景有本质区别。
内存分配方式是二者最根本的区别,直接决定了它们的特性和用途:
类型 | 内存分配规则 | 示意图(假设 int 占 4 字节,char 占 1 字节) |
---|---|---|
struct | 所有成员依次占用独立的内存空间,总内存大小为各成员大小之和(需考虑内存对齐) | struct S { int a; char b; } → 总大小为 8 字节(a 占 4 字节,b 占 1 字节,因对齐补 3 字节) |
union | 所有成员共用同一块内存空间,总内存大小等于最大成员的大小(需考虑内存对齐) | union U { int a; char b; } → 总大小为 4 字节(a 和 b 共用 4 字节,b 只占用 a 的第一个字节) |
struct
:成员独立存储
结构体的每个成员都有自己专属的内存区域,地址连续且按定义顺序排列。总大小是各成员大小的总和(受内存对齐影响,可能大于总和)。
union
:成员共享内存
共用体的所有成员从同一块内存的起始地址开始存储,相互覆盖。总大小由最大的成员决定。
struct
:成员可同时访问,数据独立
结构体的成员互不干扰,修改一个成员不会影响其他成员,可同时存储和访问所有成员的数据。
union
:同一时间只能有效访问一个成员
由于成员共享内存,修改一个成员会覆盖其他成员的数据。因此,同一时间只有最后赋值的成员数据是有效的。
struct适合
需要同时存储多种关联数据时用于组合不同类型但逻辑相关的数据,例如表示一个 “学生” 的信息(姓名、年龄、成绩)、一个 “坐标” 的 x/y 值等。
示例:
struct Student { char name[20]; // 姓名 int age; // 年龄 float score; // 成绩 }; // 同时存储学生的多项属性
union
适合需要节省内存,且不同时使用多种数据时适用于 “同一内存区域在不同场景下表示不同类型数据” 的场景,例如:
- 存储不同类型的配置参数(同一位置可能是整数或字符串);
- 节省内存(如嵌入式系统中,内存受限的场景)。
示例:
union ConfigValue { int int_val; // 整数类型配置 float float_val;// 浮点类型配置 char str_val[16];// 字符串类型配置 }; // 同一配置项在不同场景下的不同类型值
对比维度 | struct (结构体) |
union (共用体) |
---|---|---|
内存分配 | 成员独立占用内存,总大小≥成员大小之和 | 成员共享内存,总大小 = 最大成员大小(含对齐) |
成员访问 | 可同时访问所有成员,数据互不干扰 | 同一时间只能有效访问一个成员(覆盖机制) |
核心用途 | 组合关联数据,同时存储和使用 | 节省内存,同一空间在不同场景表示不同数据 |
典型场景 | 学生信息、坐标点、结构体嵌套 | 多类型配置、内存受限系统 |
结构体是 “组合” 数据,共用体是 “复用” 内存。
32.左值和右值是什么?
左值是指可以出现在等号左边的变量或表达式,它最重要的特点就是可写(可寻址)。也就是说,它的值可以被修改,如果一个变量或表达式的值不能被修改,那么它就不能作为左值。
右值是指只可以出现在等号右边的变量或表达式。它最重要的特点是可读。一般的使用场景都是把一个右值赋值给一个左值。通常,左值可以作为右值,但是右值不一定是左值。因为可写一定可读,可读不一定可写,两者是包含关系。
33.有符号数和无符号数的运算
int a = -20, unsigned int b = 6,a+b是否大于6?
有符号和无符号运算,强制转换为无符号,所有a+b会变成(unsigned int)a+b;
(unsigned int)a 就会相当于无符号最大值-20,那么是一个非常大的值,这个值加上6,那么肯定是大于6的;
最后的值是2^32-20+6=4294967282,肯定大于6。