第一部分:C 语言基础
1. helloworld
配置环境
- 编辑器: Visual Studio Code (VS Code),一款轻量且强大的代码编辑器。
- 编译器: MinGW-w64,在 Windows 上提供 GCC 编译环境,可将 C 代码编译为可执行文件。
- 推荐插件:
C/C++(by Microsoft),Code Runner(用于快速运行代码)。
VSCode 配置 C/C++环境,使用 MinGW 编译器
VS Code安装 Code Runner 插件方法:
- 点击 VS Code 菜单栏的"文件" (File) -> “首选项” (Preferences) -> “设置” (Settings),或者使用快捷键
Ctrl + ,。 - 在设置页面中搜索"Run Code configuration"。
- 找到"Run In Terminal" 选项,并勾选上。
- 点击 VS Code 菜单栏的"文件" (File) -> “首选项” (Preferences) -> “设置” (Settings),或者使用快捷键
实现程序
在 vscode 中,输入main即可快速实现示例程序
main 函数可以接收来自命令行的参数,这在编写工具类程序时非常有用。
int argc: (Argument Count) 整型变量,存储传递给程序的命令行参数的数量(包括程序本身)。char *argv[]: (Argument Vector) 字符串数组,argv[0]是程序名,argv[1]是第一个参数,以此类推。
1 | ## |
2. 数据类型与格式化 IO
基本数据类型
C 语言中的数据类型定义了变量可以存储的数据种类和范围。
| 类型分类 | 关键字 | 描述 | 格式说明符 |
|---|---|---|---|
| 整型 | int | 基本整数,通常 4 字节 | %d |
short | 短整型,通常 2 字节 | %hd | |
long | 长整型,通常 4 或 8 字节 | %ld | |
long long | 更长整型,至少 8 字节 | %lld | |
unsigned | 无符号修饰,范围从 0 开始 | %u, %lu, %llu | |
| 浮点型 | float | 单精度浮点数,约 7 位有效数字 | %f |
double | 双精度浮点数,约 15 位有效数字 (更常用) | %lf | |
| 字符型 | char | 单个字符,本质是 1 字节的整数 (ASCII 码) | %c, %hhd(整数) |
常量与变量
- 变量: 内存中用于存储数据的具名空间,其值可以改变。
- 常量: 值在程序运行期间不可改变。
- 字面常量: 如
100,3.14,'A',"hello"。 - 宏定义常量: 使用
#define,在预编译阶段进行文本替换,无类型检查。 const修饰的变量: 具有类型,会进行类型检查,更安全。
- 字面常量: 如
1 | ## |
格式化输入与输出
printf(): 格式化输出函数,将数据按指定格式打印到屏幕。scanf(): 格式化输入。scanf返回成功读取的项数。如果输入类型不匹配,会返回0并将错误输入留在输入缓冲区 (stdin) 中,可能导致后续读取问题。scanf需要传入变量的地址,使用&运算符获取。
| 格式符 | 描述 | 示例 |
|---|---|---|
%d | 十进制整数 | 123 |
%f | float 类型浮点数 | 3.14 |
%lf | double 类型浮点数 | 3.1415926 |
%c | char单个字符 | 'A' |
%s | string字符串 | "hello" |
%p | point指针地址(十六进制) | 0x7ffc... |
%x | hexadecimal十六进制整数 | ff |
%o | Octal八进制整数 | 177 |
%% | 输出一个 % 符号 | % |
注:printf 使用 %f 即可打印 float 和 double。这是因为在传递给 printf 这样的可变参数函数时,float 类型的实参会被自动提升(promote)为 double 类型。而 scanf 传入的是指针,需要通过 %lf 明确告知函数要写入一个 double 大小的内存。
清理输入缓冲区: 当
scanf失败时,必须清理缓冲区中的非法输入。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int a;
while (1) {
printf("请输入一个整数: ");
int retval = scanf("%d", &a); // &a是取地址
if (retval == 1) {
printf("成功读取: %d\n", a);
break; // 成功则退出循环
} else {
printf("输入错误!正在清理输入缓冲区...\n");
char buf[10]; // 准备一个“垃圾桶”
fgets(buf, 10, stdin); // 把留在输入队列里的垃圾读走,扔到垃圾桶里
//或者用下面这句话,每次读一个来清掉,直到输入队列清空
// while (getchar() != '\n');
}
}
单字符输入输出 (getchar, putchar)
这两个函数效率更高,用于处理单个字符的 I/O。
getchar(): 从stdin读取一个字符,并以int类型返回。putchar(): 将一个字符(以int形式传递)写入stdout。
1 | ## |
类型转换
自动类型转换: 在运算中,低精度类型会自动转换为高精度类型。 (
char->int->float->double)强制类型转换: 使用
(目标类型)语法强制转换。1
2
3
4
5
6int a = 10;
int b = 3;
float result = (float)a / b; // 将a强制转为float,结果为3.333...
// 如果不转换,int / int 结果为3
int int_result = a / b; // 结果为 3
科学计数法
浮点数可以使用e或E表示 10 的幂,这在表示极大或极小值时很有用。
1 | double large_num = 3.14e8; // 3.14 * 10^8 |
数字的进制表示
我们可以用不同进制来输出数字,这对调试程序,操作寄存器等底层硬件非常重要
- 前缀:
- 十六进制:
0x或0X - 八进制:
0 - 十进制: 无前缀
- 十六进制:
- 在内存中,所有进制的数最终都以二进制形式存储。
1 | int dec_val = 100; // 十进制 |
面试题实战-浮点数的安全比较
为什么不能直接用 == 比较两个浮点数?
由于计算机使用二进制表示浮点数时存在固有的精度误差,直接用 == 比较通常会失败。
正确做法是判断两个浮点数之差的绝对值是否小于一个预先设定的极小值(epsilon)。
代码示例:
1 | # |
面试题实战-大型字面量后缀
在处理大型数字的宏或常量时,中间计算结果可能会超出默认 int 类型的范围导致溢出,即便最终结果可以存放在 long long 中。
为了避免这种情况,可以使用后缀强制指定字面量的类型。
L或l:longLL或ll:long longU或u:unsignedULL或ull:unsigned long long
代码示例:
1 | # |
面试题实战-类型提升中的符号扩展
当一个宽度较小的有符号类型(如 char, short)被提升为一个宽度较大的类型(如 int)时:
- 如果原始值是正数,高位用
0填充。 - 如果原始值是负数(即最高位为 1),则会发生符号扩展 (Sign Extension),高位用
1填充以保持其负值。
代码示例:
1 | # |
这个特性在处理来自硬件或网络的字节流时尤其重要.
面试题实战-大小端序转换
建议先看下面的内容理解:
字节序探析:大端与小端的比较
简单总结如下:
- 如果需要逐位运算,或者需要到从个位数开始运算,都是小端序占优势
- 反之,如果运算只涉及到高位,或者数据的可读性比较重要,则是大端序占优势
- 所以计算机存储一般都用小端序方便计算,大端序一般方便人类阅读
题目:写一个函数,要求把小端序存放的整数,转换成大端序存放
代码示例:
1 | int change(int n){ |
3. 运算符
算术运算符
+ (加), - (减), * (乘), / (除), % (取余/模)
自增/自减:
++,--- 前缀 (
++a): 先自增,再使用变量的值。 - 后缀 (
a++): 先使用变量的值,再自增。
1
2
3
4int a = 5;
int b = ++a; // a变为6, b被赋值为6
int c = a++; // c被赋值为6, a变为7
printf("a=%d, b=%d, c=%d\n", a, b, c); // 输出: a=7, b=6, c=6- 前缀 (
关系与逻辑运算符
关系运算符:
>(大于),<(小于),>=(不小于),<=(不大于),==(等于),!=(不等于)。结果为1(真) 或0(假)。逻辑运算符:
!(逻辑非),&&(逻辑与),||(逻辑或)。短路求值:
expr1 && expr2: 如果expr1为假,expr2不会被执行。expr1 || expr2: 如果expr1为真,expr2不会被执行。
1
2
3
4int x = 0, y = 5;
// x为0(假),&&右侧不执行,y的值不变
if (x && (y = 10)) { /* ... */ }
printf("y = %d\n", y); // 输出: y = 5
位运算符
直接对数据的二进制位进行操作。
&(按位与),|(按位或),^(按位异或),~(按位取反)<<(左移):x << n相当于x * 2^n>>(右移):x >> n相当于x / 2^n
1 | // 常用位操作技巧 |
其他运算符
赋值运算符:
=,+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>=条件运算符 (三目运算符):
表达式1 ? 表达式2 : 表达式31
2int a = 10, b = 20;
int max = (a > b) ? a : b; // max 的值为 20sizeof运算符: 计算数据类型或变量占用的内存字节数。1
2
3int arr[10];
printf("int类型占 %zu 字节\n", sizeof(int)); // 通常输出 4
printf("数组arr占 %zu 字节\n", sizeof(arr)); // 输出 40 (4 * 10)
运算符优先级与结合性
- 优先级: 决定了哪个运算符先执行(如
*优先于+)。 - 结合性: 决定了相同优先级的运算符的执行顺序(如赋值
=是从右至左)。 - 黄金法则: 当不确定优先级时,使用圆括号
()来明确指定运算顺序。 这能避免错误并提高代码可读性。
1 | int x = 2 * 3 + 4; // 乘法优先, x = 6 + 4 = 10 |
面试题实战-字节内比特位反转
问题: 编写一个函数,将一个字节中的 8 个比特位进行反转。
核心知识: 这是对位运算符的深入考察。核心思想是逐位从原字节中取出,然后逐位放入新字节的相反位置。
代码示例:
1 | void revert(int len, char A[], char B[]) { |
4. 控制流
分支结构 (if / switch)
if-else: 用于二路或多路分支判断。1
2
3
4
5
6
7
8int score = 85;
if (score >= 90) {
printf("优秀\n");
} else if (score >= 60) {
printf("及格\n");
} else {
printf("不及格\n");
}switch: 用于基于一个整数值的多路分支,通常比if-else if更高效、清晰。case标签后必须是常量表达式。- 每个
case通常以break结束,否则会发生“穿透”,继续执行下一个case。 default处理所有未匹配的case。
1
2
3
4
5
6
7
8int day = 3;
switch (day) {
case 1: printf("周一\n"); break;
case 2: printf("周二\n"); break;
case 3: printf("周三\n"); break;
// case 4 ... 5: ... (GCC/Clang 扩展语法)
default: printf("其他\n"); break;
}
循环结构 (while / for / do-while)
while循环: 先判断条件,再执行循环体。可能一次都不执行。1
2
3
4
5int i = 0;
while (i < 5) { //条件成立执行
printf("%d ", i);
i++;
} // 输出: 0 1 2 3 4for循环: 集初始化、条件判断、迭代于一体,结构清晰,常用于已知循环次数的场景。1
2
3for (int i = 0; i < 5; i++) { //条件成立执行
printf("%d ", i);
} // 输出: 0 1 2 3 4do-while循环: 先执行一次循环体,再判断条件。保证循环体至少执行一次。1
2
3
4int i = 10;
do {
printf("至少执行一次\n");
} while (i < 5); //条件成立执行
跳转语句 (break / continue / goto / return)
break: 立即跳出当前所在的switch结构或循环结构。continue: 立即结束本次循环,跳到循环的下一次迭代判断处。return:- 立即终止当前函数的执行。
- 将一个值(如果函数不是
void类型)返回给调用者。 - 在
void函数中,return;可用于提前退出。
1
2
3
4
5
6
7
8int check_value(int val) {
if (val < 0) {
printf("Error: invalid value.\n");
return -1; // 提前退出并返回错误码
}
// ... 正常处理 ...
return 0; // 返回成功码
}goto: 无条件跳转到同一函数内的标签处。应谨慎使用,易破坏程序结构,通常只在错误处理等多层嵌套退出场景下考虑。
1 | // 示例: goto用于统一的错误处理 |
面试题实战- fgets字符串输入
与 scanf 不同,fgets 在读取字符串时更加安全,因为它会检查缓冲区的边界,有效防止溢出。
- 函数原型:
char *fgets(char *str, int n, FILE *stream); - 参数:
str: 用于存储输入字符串的缓冲区。n: 最多读取n-1个字符(最后一个位置留给\0)。stream: 输入流,通常是stdin(标准输入)。
- 特点:
- 安全: 不会超出缓冲区大小。
- 会读取换行符: 如果输入行中包含换行符
\n,fgets会将其一并读入缓冲区。通常需要手动移除。
1 | # |
第二部分:内存、函数与程序结构
5. 内存布局与管理
对于嵌入式开发,深刻理解程序内存组织至关重要,它直接影响程序的稳定性、效率和资源消耗。
每个 C 程序都运行在一个独立的虚拟内存空间中,其结构从低地址到高地址通常如下:
代码段
只读!
- .text: 存放程序的可执行二进制代码
- .init: 存放系统初始化代码
数据段
此区域用于存储**生命周期与整个程序相同的全局变量和静态变量**。
- .rodata (Read-only Data): 存只读数据,如字符串字面量 (
"hello")、const修饰的全局常量。 - .data:存 已初始化的全局变量和静态变量。
- .bss (Block Started by Symbol):存未初始化的全局变量和静态变量,程序启动时,该区域会被系统自动清零。
1 | // 示例:不同变量在数据段中的位置 |
static 对局部变量的影响
当 static 用于修饰局部变量时,它会改变该变量的存储期:
- 存储位置: 从栈 (Stack) 移动到 数据段 (.data / .bss)。
- 生命周期: 从函数调用时创建、返回时销毁(自动存储期),延长为与整个程序运行时间相同(静态存储期)。
这意味着,static 局部变量只在第一次执行其定义时被初始化,并且它的值在函数调用之间得以保留。
(关于 static 的全面总结,看笔记 12.4 节)
1 | void counter() { |
栈 (Stack)
- 存储内容: 函数的局部变量、函数参数、环境变量、命令行参数及函数调用的上下文(返回地址等)。
- 核心特点:
- 自动管理: 函数调用时,栈向下增长(分配);函数返回时,栈向上收缩(释放)。程序员无法干预。
- 空间有限: 栈大小预设且较小,过大的局部变量或过深的递归会导致栈溢出 (Stack Overflow)。
- 高效率: 分配和释放仅涉及栈指针的移动,速度极快。
1 | void func(int n) { // n 和 c 都在 func 的栈帧上 |
堆 (Heap)
堆是唯一一块可由程序员完全控制的内存区域,用于动态分配和管理内存。
核心特点:
- 手动管理: 必须通过函数(如
malloc)申请,并通过free释放。忘记释放会导致内存泄漏。 - 空间巨大: 大小理论上只受限于系统可用物理内存。
- 匿名访问: 堆内存没有变量名,只能通过指针来访问。
- 生命周期灵活: 从申请到释放,其生命周期由程序员决定。
- 手动管理: 必须通过函数(如
涉及函数(
<stdlib.h>):void* malloc(size_t size): 申请size字节的内存,内容未初始化(随机值)。void* calloc(size_t num, size_t size): 申请num个size字节的内存,内容被自动置零。void free(void* ptr): 释放由malloc或calloc申请的内存。
free最佳实践:free()后,指针ptr会变成一个指向无效内存的悬空指针。- 必须在
free()后立即将指针置为NULL,以防止后续误用。
1 | ## |
6. 存储期
存储期描述了变量在内存中从创建到销毁的生命周期。它与变量存储在哪个内存区域(栈、堆、数据段)直接相关。
自动存储期
- 对应内存区域: 栈 (Stack)。
- 生命周期: 从程序执行进入其所在的代码块开始,到离开该代码块时结束。内存的分配和释放由系统自动完成。
- 包含变量: 所有局部变量(包括函数参数),除非被
static修饰。
1 | void func() { |
静态存储期
- 对应内存区域: 数据段 (.data / .bss)。
- 生命周期: 与整个程序的运行时间相同。在程序启动时创建,在程序结束时销毁。
- 包含变量:
- 所有全局变量(无论是否用
static修饰)。 - 使用
static修饰的局部变量。
- 所有全局变量(无论是否用
1 | int global_var = 1; // 静态存储期 |
自定义存储期
- 对应内存区域: 堆
- 生命周期: 完全由程序员手动控制。从
malloc/calloc成功调用时开始,到free被调用时结束。 - 核心风险: 忘记调用
free会导致内存泄漏。
1 | void dynamic_example() { |
static 关键字全面总结
理解 static 的关键在于区分它修饰的是局部变量还是全局变量/函数。
| 上下文 | 改变的属性 | 效果 | 目的 |
|---|---|---|---|
| 修饰局部变量 | 存储期 | 从 自动 (栈上) 变为 静态 (数据段)。生命周期与程序相同。 | 在函数调用之间保持变量值的持久性。 |
| 修饰全局变量/函数 | 链接属性 | 从 外部链接 变为 内部链接。作用域被限制在当前文件。 | 信息隐藏和避免命名冲突,增强模块化。 |
详细解释与示例
修饰局部变量 (改变存储期)
默认情况: 局部变量存储在栈上,函数调用时创建,函数返回时销毁。
static修饰后:存储位置: 变量从栈移到数据段(.data 或 .bss)。
生命周期: 变量在程序启动时就已创建,直到程序结束才销毁。
初始化: 只在编译时初始化一次。
因此,其初始值必须是一个常量表达式(如字面量
10、'c'或"hello"),不能是运行时才存在的变量。核心效果: 变量的值在多次函数调用之间得以保留。
1 | ## |
修饰全局变量/函数 (改变链接属性)
- 默认情况: 全局变量和函数具有外部链接属性,意味着它们可以被项目中任何其他
.c文件通过extern关键字访问。 static修饰后:- 链接属性: 变为内部链接。
- 核心效果: 该全局变量或函数的作用域被严格限制在定义它的那个源文件内部,对其他文件不可见。
module_a.c 文件:
1 | ## |
main.c 文件:
1 | // 声明我们希望从 module_a.c 中使用的变量和函数 |
这个例子清晰地展示了 static 如何作为模块化的工具,将实现细节(g_static_var, static_func)隐藏在模块内部,只暴露公共接口(g_global_var, global_func)。
7. 函数
函数是 C 语言的功能模块,它将一段可重用的代码封装成一个“黑箱”,对外提供清晰的接口,隐藏内部实现。
函数的构成
- 函数头:
返回类型 函数名(参数列表),定义了函数的对外接口。 - 函数体:
{ ... },包含函数的具体实现。 - 参数:
- 形参: 函数定义中的变量,作为输入。
- 实参: 函数调用时传递的实际值,用于初始化形参。
- 返回值: 使用
return关键字从函数中返回一个值。void类型表示不返回任何值。 - 局部变量: 定义在函数体内的变量,存储在栈上,函数返回后即销毁。严禁返回局部变量的地址。
1 | // 通过指针交换两个变量的值 |
特殊函数类型
递归函数
函数在体内调用自身。必须包含递推关系和终止条件,否则会因无限递归导致栈溢出。
1 | // 计算阶乘: f(n) = n * f(n-1) |
静态函数
使用 static 修饰的函数,其作用域被限制在当前源文件内,用于实现模块化和避免命名冲突。
1 | // 该函数只能在定义它自己所在的.c文件中被调用 |
回调函数
通过函数指针作为参数传递给另一函数,由后者在特定时机“回调”执行。这是一种强大的解耦机制,常见于事件处理、分层设计和系统 API 中。
示例 1:简单的策略切换
下面的 eat 函数并不关心具体“做什么菜”,只负责“吃”。具体菜系(yuecai 或 chuancai)由调用方通过函数指针传入,实现了行为的灵活切换。
1 | ## |
示例 2:系统事件处理 (信号)
这是回调函数最经典的应用之一。我们告诉操作系统:“当某个事件(如SIGINT信号,即按下 Ctrl+C)发生时,请调用我指定的这个函数(my_handler)”。这个过程就是注册回调。
signal 函数详解
signal 函数是 C 标准库 <signal.h> 中用于处理异步信号的核心函数。
函数原型:
void (*signal(int signum, void (*handler)(int)))(int);
这个原型非常复杂,是“返回函数指针的函数”的典型例子。我们可以用typedef来简化理解(这也是推荐的最佳实践):1
2
3
4
5// 1. 为信号处理函数指针定义一个别名 sighandler_t
typedef void (*sighandler_t)(int);
// 2. 原型现在变得清晰易读
sighandler_t signal(int signum, sighandler_t handler);这样就很清楚了:
signal函数接收一个信号编号和一个处理函数指针,并返回一个旧的处理函数指针。参数说明:
int signum: 信号的编号。通常使用标准宏来表示,例如:SIGINT: 中断信号,通常由用户在终端按下Ctrl+C产生。SIGTERM: 终止信号,是kill命令发送的默认信号,用于请求程序正常退出。SIGSEGV: 段错误信号,当程序试图访问其无权访问的内存区域时产生。
sighandler_t handler: 指向信号处理函数的指针。这里可以传递三种值:- 一个自定义函数: 这就是我们的回调函数。它必须接收一个
int(信号编号) 参数并返回void。 SIG_DFL: 一个特殊的宏,表示恢复对该信号的默认处理行为(例如,SIGINT的默认行为是终止程序)。SIG_IGN: 一个特殊的宏,表示忽略该信号。
- 一个自定义函数: 这就是我们的回调函数。它必须接收一个
返回值:
- 成功时: 返回之前的信号处理函数的指针。这允许你保存旧的处理方式,以便之后可以恢复它。
- 失败时: 返回
SIG_ERR,并设置errno。
1 | ## |
面试题实战-函数参数传递:传值 vs 传址
C 语言中所有函数参数传递本质上都是传值。但通过传递指针,可以达到“传址”的效果。
传值:
- 过程: 将实参的副本传递给形参。
- 效果: 函数内部对形参的任何修改,都不会影响到函数外部的实参。
传址:
- 过程: 将实参的地址(通过
&运算符获取)传递给一个指针类型的形参。 - 效果: 函数内部通过解引用指针 (
*p),可以直接访问和修改函数外部实参的值。
- 过程: 将实参的地址(通过
1 | # |
8. 作用域
作用域定义了标识符(变量名、函数名等)在代码中的可见范围。合理利用作用域可以避免命名冲突,是模块化编程的基础。
作用域的类型
块作用域:
范围: 一对花括号
{}内部。特点: 在代码块内定义的变量(局部变量)仅在该块内可见,从定义处开始到右花括号结束。
遮蔽 : 内层作用域可以定义与外层同名的变量,此时内层变量会临时遮蔽外层变量。
1
2
3
4
5
6
7
8
9
10
11
12int a = 100; // 全局作用域
int main() {
// main函数的块作用域
int a = 200; // 遮蔽全局a, 此处a为200
{
// 内部块作用域
int a = 300; // 再次遮蔽, 此处a为300
printf("%d\n", a); // 输出: 300
}
printf("%d\n", a); // 输出: 200, 内部块结束, 遮蔽解除
}
文件作用域:
范围: 从定义处开始,到当前源文件末尾。
特点: 在任何函数之外定义的变量(全局变量)具有文件作用域。默认情况下,全局变量可以被其他文件通过
extern关键字访问。1
2
3
4
5
6
7
8// a.c 文件
int global_var = 10; // 全局变量, 文件作用域
// b.c 文件
extern int global_var; // 声明以访问a.c中的全局变量
void use_global() {
printf("%d\n", global_var); // 输出: 10
}
函数原型作用域:
范围: 仅限于函数声明的括号内。
特点: 参数名只在声明中起解释作用,在编译时会被忽略,可以省略。
1
2// a 和 b 的作用域仅在此行
void func(int a, int b);
static 对全局变量/函数的影响
当 static 用于修饰全局变量或函数时,它会改变标识符的链接属性,从“外部链接(external)”改为“内部链接(internal)”。
- 效果:
static修饰的全局变量或函数的作用域被限制在当前源文件内,其他.c文件无法通过extern关键字访问它们。
这对于信息隐藏和模块化编程至关重要,可以有效避免不同模块间的命名冲突。
(关于 static 的全面总结,看笔记 12.4 节)
1 | // a.c 文件 |
第三部分:C 语言的精髓:指针与复合类型
9. 数组
数组定义与初始化
定义:
类型说明符 数组名 [常量表达式];初始化:
1
2
3
4int b1[5] = {1, 2, 3, 4, 5}; // 完全初始化
int b3[5] = {1, 2, 3}; // 部分初始化,其余元素为未知
int b4[] = {1, 2, 3, 4, 5}; // 根据初始化内容推断大小
int b6[5] = {[0 ... 4] = 1}; // GCC/Clang 的扩展:指定初始化所有元素为1
数组元素引用
通过下标访问:
数组名[下标],下标从0开始。1
2long a[5] = {1, 2, 3, 4, 5};
printf("%d\n", a[1]); // 输出第二个元素: 2
字符数组与多维数组
字符数组初始化:
1
2char s1[3] = {'a', 'b', 'c'};
char s3[5] = "abc"; // 自动在末尾添加 '\0'多维数组定义与初始化:
1
2
3
4
5
6//编译器里只有一维数组
int (c[3]); // 内存分布: |int|int|int|
//二维数组定义分为以下两个部分
// 1, x[3] 是数组的定义,表示该数组拥有3个元素
// 2, int [4]是元素的类型,表示该数组元素是一个具有4个元素的整型数组
int (x[3]) [4]; // 内存分布: |int[4]|int[4]|int[4]|1
2
3
4// 定义一个3行4列的二维数组
int y1[3][4] = { {1,2,3,4}, {4,5,6,7}, {6,7,8,9} };
// 可以省略第一维的大小
int y4[][4] = { {1,2,3,4}, {4,5,6,7} };
[面试题实战] 二维数组边界元素遍历
这是一个常见的算法问题,考验对二维数组索引的精确控制能力。
代码示例 (天彩电子-23.c)
1 | # |
10. 指针
指针定义与赋值
指针: 存储另一个变量的内存地址。
定义:
类型说明符 *指针变量名;赋值 (取地址): 使用
&运算符获取变量地址。1
2
3int a;
int *p; // p是一个指针,准备指向一个int型变量
p = &a; // 将变量a的地址赋给p(取地址)
指针解引用
解引用: 使用
*运算符访问指针指向的内存地址中的值。1
2*p = 100; // 将p指向的地址(即变量a)的值设为100
printf("%d", a); // 输出 100
野指针与空指针
野指针: 指向不确定或已释放内存的指针。
- 避免: 初始化时置为
NULL,释放后也置为NULL。
- 避免: 初始化时置为
空指针: 不指向任何对象的指针,其值为
NULL。1
int *p = NULL; // 良好习惯:初始化指针为NULL
指针运算
对指针进行
+,-运算,其移动的单位是指针所指向类型的大小。1
2
3long a[5] = {1, 2, 3, 4, 5};
// a是数组首元素地址,a+1指向第二个元素
printf("%d\n", *(a + 1)); // 输出 2
面试题实战 - const指针的三种模式辨析
const int *p: 常目标指针。指针指向的内容*p不可变,但指针自身p的指向可以改变。- 记忆:
const在*左边,修饰的是目标。
- 记忆:
int * const p: 常指针。指针自身p的指向不可变,但指针指向的内容*p可以改变。- 记忆:
const在*右边,修饰的是指针p。
- 记忆:
const int * const p: 指针自身和其指向的内容都不可变。代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16int a = 10, b = 20;
// 1. 常目标指针
const int *p1 = &a;
// *p1 = 100; // 错误: 不能通过p1修改a的值
p1 = &b; // 正确: p1可以指向其他变量
// 2. 常指针
int * const p2 = &a;
*p2 = 100; // 正确: 可以通过p2修改a的值
// p2 = &b; // 错误: p2的指向不能改变
// 3. 两者都不可变
const int * const p3 = &a;
// *p3 = 100; // 错误
// p3 = &b; // 错误
11. 数组与指针的关系
数组名即指针
在任意表达式中(除了 sizeof 和&之外),数组名 a 都代表数组的首地址
a[i]本质上是*(a + i)的语法糖。1
2
3
4
5
6
7long a[5] = {1, 2, 3, 4, 5};
// a[1] 和 *(&a[0]+1) 和 *(a+1) 是等价的
printf("%d\n", a[1]); // 输出 2
printf("%d\n", *(a + 1)); // 输出 2
printf("%d\n", *a); //1
printf("%#lun\n", a); //a的首地址另外一个例子:
如果看起来吃力,可以先看 7.0 复杂指针解读方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int main(int argc, char const* argv[]) {
// a: 二维整型数组
// a 是一个包含2个元素的数组,它的每个元素是“一个包含3个int的数组”。
int a[2][3] = {1, 2, 3, 5, 6, 7};
// b: 数组指针
// b 是一个指针数组,它其中的每个元素指向的目标是“一个包含3个int的数组”。
int (*b)[3];
int sum = 0;
b = a;
for (int i = 0; i < sizeof(a) / 4; i++) {
// b 指向 a 的第一行,*b 的类型是 int[3]
// 在表达式中,*b 会退化为指向其首元素(a[0][0])的指针
// 因此 **b 就是 a[0][0] 的值
sum += *(*b + i);
}
// c: 数组指针的数组
// c 是一个包含2个元素的数组,它的每个元素是“一个指向包含3个int的数组的指针”。
int (*c[2])[3] = {&a[0], &a[1]}; // c[]存a[]的地址
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
// c[i]存a[i]的地址,a[i]是个数组
// 因为这是一个数组,所以也可以等于首元素1的地址
// 向右移动int类型的4字节,得到2的地址
// **c[i]解引用得到地址里的值2
sum += *(*c[i] + j);
}
}
// d: 二维指针数组
// d 的每个元素(d[i][j])的类型是“指向int的指针(int *)”。
int* d[2][3];
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
d[i][j] = &a[i][j];
}
}
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
sum += *d[i][j];
}
}
printf("%d\n", sum);
return 0;
}
函数中的数组参数
数组作为函数参数时,实际上传递的是指向数组首元素的指针。
void func(int arr[])的写法本质上等价于void func(int *arr)。编译器会自动将数组形式的参数转换为指针。核心规则: 在任意表达式中(除了 sizeof 和&之外),数组名 a 都代表数组的首地址。当数组名作为函数实参传递时,它被转化为指向其首元素的指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 参数 k[3] 实际上是一个指针 (int *k),因此 sizeof(k) 会得到指针的大小(通常是8字节 on 64-bit system)
// 而不是数组的大小 (3 * 4 = 12 字节)。
void f1(int a, float f, int *p, int k[3]) {
printf("sizeof(k) in function: %ld\n", sizeof(k)); // 输出指针大小, e.g., 8
}
int main(int argc, char const *argv[]) {
int a = 100;
float f = 1.23;
int *p = &a;
int k[3] = {1, 2, 3};
printf("sizeof(k) in main: %ld\n", sizeof(k)); // 输出数组大小, 12
// 当数组 k 作为参数传递时,它退化为指向 k[0] 的指针
f1(a, f, p, k);
return 0;
}
12. 特殊数组与高级指针
复杂指针解读方法
核心方法:右左法则 (Right-Left Rule)
- 从 变量名 开始。
- 优先向 右 看,遇到
[](数组) 或()(函数)。 - 再向 左 看,遇到
*(指针)。 - 遇到括号,先解析完括号内的,再跳出。
- 最后,读最左边的 类型名。
示例 1:数组指针
1 | int (*b[2])[3]; |
- 示例 2:函数指针与
typedef最佳实践
对于极端复杂的声明,手动分析既困难又易错。最佳实践是使用 typedef 定义别名。
识别步骤
找到标识符 signal。
向右看,是 (),说明 signal 是一个函数。
signal 函数括号内接收两个参数:int sig 和 void (*func)(int)
对于第二个参数 func,我们可以独立应用右左法则:func 是一个指向“接收 int,返回 void”的函数的指针
解析完函数参数,回到 signal,向左看,是 *。说明 signal 函数的返回值是一个指针
跳出 signal(…) 的范围,继续向右看,是 (int)。说明 signal 返回的那个指针,指向一个接收 int 参数的函数
最后,看最左边的类型 void。这个函数返回 void 类型
1 | // 原始声明,难以阅读 |
零长数组
GNU C 扩展,
type name[0],用于变长结构体,不占用结构体大小。1
2
3
4
5
6
7
8
9struct stu {
int age;
float score;
char sign[0]; // 零长数组成员
};
// 为结构体和签名分配连续内存
struct stu *s = malloc(sizeof(struct stu) + 10);
strcpy(s->sign, "helloworl");
printf("%s\n", s->sign); // 输出 "helloworl"
变长数组
C99 标准引入,数组大小在运行时由变量确定。
注意: 变长数组具有自动存储期(即在栈上分配),因此不能使用
static关键字修饰。1
2
3
4
5
6
7
8int len = 5;
int b[len]; // 正确:在栈上创建变长数组
// static int c[len]; // 错误:static数组的大小必须在编译时确定
// 无法在编译时给运行时确定大小的数组初始化
// int b[len] = {1,2,3,4,5};
for (int i = 0; i < len; i++) b[i] = 666;
字符指针
char *s = "hello";指针s指向一个字符串字面量(是只读的)。1
2
3
4
5
6
7
8char *p = "abcd";
printf("%s\n", p + 1); // 输出 "bcd"
char *k[3] = {"12345", "abcddegg", "www.yeu"};
printf("%s\n", *k); // "12345"
printf("%s\n", *(k + 1)); // 输出 "abcddegg"
printf("%s\n", k[0] + 1); // "2345"
printf("%c\n", *(k[0] + 1)); //'2
[!WARNING]
通过 char *p = “hello”; 创建的指针 p 指向的是一个字符串字面量。字符串字面量通常存储在内存的只读数据段 (.rodata)。
任何试图通过指针修改其内容的行为都是未定义的,通常会导致程序崩溃(段错误)。
1
2 char *p = "hello";
p[0] = 'H'; // 错误!非常危险的操作!如果你需要一个可以修改的字符串,必须使用数组来创建:char s[] = “hello”;
void 指针
通用指针
void *,可存储任何类型的地址,但不能直接解引用,需先强制类型转换。1
2
3
4
5
6void *m = malloc(20);
// 将void*指针转换为int*指针再进行操作
for (int i = 0; i < 5; i++) {
*((int *)m + i) = i*100;
printf("%d\n", *((int *)m + i));
}
const 指针
const int *p: const 修饰*p,指针指向的内容不可变 (常目标指针)。int const *p: const 修饰*p,指针指向的内容不可变 (常目标指针)。int * const p: const 修饰 p,指针自身存的地址不可变 (常指针)。1
2
3
4
5
6
7
8
9
10int a = 100;
// 常指针: const修饰指针变量p,p的指向不可改,但*p的值可改 (可读写它指向的内容但无法修改它指向谁)
int *const p = &a;
*p = 200; // OK
// p = &b; // Error
// 常目标指针: const修饰类型int,*k的值不可改,但k的指向可改(只读不写)
const int *k = &a;
// *k = 300; // Error
k = &b; // OK
多级指针
指向指针的指针,如
int **p。1
2
3
4
5
6
7
8
9
10int w [10]; // w的类型是int [10]
int (*pw) [10]; // 一级指针
int (**pw2)[10]; // 二级指针,pw2-->pw -->w
(*pw) [0] = 666;
(**pw2)[0] = 666;
printf("%p\n", w); // 输出 数组w的首地值
printf("%d\n", w[0]); // 输出: 666
printf("%d\n", (*pw)[0]); // 输出: 666
函数指针
指向函数的指针,是实现回调函数等高级技巧的基础。
定义:
返回类型 (*指针名)(参数列表);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 这是一个普通函数
int maxValue(int a, int b) {
return a > b ? a : b;
}
// 这是一个接收函数指针作为参数的函数
void f(int (*function)(int a, int b)) {
printf("通过函数指针调用,结果: %d\n", function(7, 8));
}
int main(int argc, char const *argv[]) {
// 1. 定义一个函数指针p,它专门指向“接收两个int,返回int”的函数
int (*p)(int a, int b);
// 2. 将函数maxValue的地址赋给p
// 特殊语法:函数名取地址时,&可以省略
p = maxValue;
// 3. 通过函数名直接调用
int m = maxValue(2, 3);
printf("直接调用,结果: %d\n", m);
// 4. 通过函数指针p间接调用
// 特殊语法:通过��数指针调用函数时,解引用*可以省略
int n = p(3, 4);
printf("通过指针调用,结果: %d\n", n);
// 5. 将函数名作为参数传递
f(maxValue);
return 0;
}
13. 结构体 (Struct)
结构体是 C 语言的核心特性,它允许我们将不同类型的数据项组合成一个单一的、逻辑相关的整体。
定义、初始化与使用
- 定义: 使用
struct关键字创建一个“蓝图”,描述这个复合数据类型包含哪些成员。 - 变量: 根据“蓝图”定义实际的结构体变量。
- 初始化:
- 传统初始化: 按顺序为成员赋值
{...}。 - 指定成员初始化: 使用
.成员名 = 值的方式,可以不按顺序,更清晰。
- 传统初始化: 按顺序为成员赋值
- 成员访问:
- 对结构体变量,使用点运算符
.。 - 对结构体指针,使用箭头运算符
->(它等价于(*指针).成员的语法糖)。
- 对结构体变量,使用点运算符
1 | ## |
结构体内存对齐
为了让 CPU 高效访问内存,编译器会自动对结构体成员进行对齐。
- 对齐规则: 每个成员的地址会是其自身大小的整数倍。例如,
int(4 字节) 会被放在能被 4 整除的地址上。 - 填充: 为了满足对齐,编译器可能会在成员之间填充一些“空白”字节。
- 整体大小: 整个结构体的大小,会是其最宽成员大小的整数倍。
- 取消对齐: 在某些特定场景(如处理硬件数据流),可以使用
__attribute__((packed))(GCC/Clang 扩展)来告诉编译器不要进行内存对齐,以节省空间。
1 | ## |
位域 (Bit-field)
位域允许我们在结构体中定义宽度为“位”(bit)的成员,这对于需要精确控制内存布局(如硬件寄存器、网络协议)的场景非常有用。
- 定义:
类型 成员名 : 位数; - 限制:
- 成员必须是整型 (
int,unsigned int,char等)。 - 不能对位域成员取地址 (
&)。 - 建议成员尽量是同类型,不然对于某些平台可能会出现内存没有按照预期对齐的问题
- 成员必须是整型 (
1 | ## |
14. 联合体 (Union)
联合体所有成员共享同一块内存空间,其大小由最大的成员决定。这使得联合体非常适合表示“互斥”的数据。
- 特点:
- 任何时刻,只有一个成员是有效的。
- 对一个成员赋值,会覆盖其他成员的值。
- 初始化:
- 默认初始化第一个成员。
- 使用指定成员初始化法可以初始化任意成员,但只有最后被初始化的那个有效。
1 | ## |
15. 枚举 (Enum)
枚举用于定义一组命名的整数常量,它比使用 #define 更具优势,因为枚举是类型安全的,且调试器可以识别枚举名。
定义:
enum 枚举名 {常量1, 常量2, ...};赋值:
默认从
0开始,依次递增。 -可以手动为任何一个常量指定一个整数值,后续未指定的常量会在此基础上递增。
1 | ## |
第四部分:构建大型程序
16. volatile关键字
volatile 是一个类型修饰符,它告诉编译器,被修饰的变量可能会在任何时候被程序外部的因素(如硬件、中断服务程序、其他线程)意外地改变。
核心作用: 防止编译器过度优化。确保每次访问该变量时,都直接从其内存地址中读取,而不是使用可能已过时的寄存器缓存值。
使用场景:
- 硬件寄存器: 嵌入式系统中,硬件状态寄存器的值会由硬件实时更新。
- 多线程共享变量: 一个变量被多个线程共享和修改时。
- 中断服务程序: 中断处理函数中会修改的全局变量。
代码示例
1 | // 示例:一个可能被硬件修改的状态寄存器 |
17. 预处理与高级技巧
高级宏定义
宏在预处理阶段进行简单的文本替换,功能强大但易出错。
宏运算符# (字符串化) ## (连接)
#: 将宏参数转换为一个字符串字面量。##: 将两个记号(token)连接成一个记号。
1 | ## |
安全的带参宏 (GCC 扩展)
简单的带参宏有副作用风险(如 MAX(a++, b))。Linux 内核中广泛使用 ({...}) 语句表达式和 typeof 关键字 (均为 GCC 扩展) 来创建更安全的宏。
typeof(x): 获取变量x的类型。({...}): 语句表达式,将多条语句包裹成一个单一的表达式,其值为最后一条语句的结果{}将多句话整合为一句话()将内部的 {…} 代码块“提升”为一个表达式,使其可以被赋值或用在其他需要值的地方。
1 | ## |
内联函数 (inline)
对于功能简单、调用频繁的函数,函数调用的开销可能超过函数本身的执行时间。inline 关键字建议编译器将函数体直接嵌入到调用处,以消除调用开销。
- 特点:
- 它是对编译器的建议,而非强制命令。
- 相比宏函数,内联函数有类型检查,更安全。
- 内联函数的定义通常放在头文件中。
- 适用场景: 函数体小,且被频繁调用的函数。
inline.h 文件:
1 | // inline.h |
main.c 文件:
1 | ## |
18. 多文件编程与头文件
随着项目变大,将所有代码放在一个.c文件里是不可行的。我们需要将代码按模块拆分到多个.c和.h文件中。
条件编译
条件编译允许我们根据编译时定义的宏,来决定哪些代码块被编译,哪些被忽略。这对于编写平台兼容代码、管理调试信息等非常有用。
#if/#elif/#else/#endif: 基于宏的值进行判断,功能类似if-else。#ifdef 宏名: (if defined) 如果宏宏名已被定义,则编译后续代码。#ifndef 宏名: (if not defined) 如果宏宏名未被定义,则编译后续代码。
1 | ## |
头文件的作用与设计
头文件 (.h) 是多文件编程的核心,它扮演着模块“接口说明书”的角色。
头文件的内容:
- 全局变量的声明: 使用
extern关键字告知其他文件该变量的存在。 (extern int g_count;) - 函数声明: 告知其他文件该函数的存在。 (
void print_hello(void);) - 宏定义: 模块提供的常量或宏函数。 (
#define MAX_USERS 100) - 结构体/联合体/枚举的定义: 允许多个
.c文件使用相同的数据结构。 static inline函数: 对于小且频繁调用的函数,可以定义在头文件中。
- 全局变量的声明: 使用
头文件防卫 :
为了防止同一个头文件在编译时被重复包含(这会导致重定义错误),必须使用ifndef机制。这是强制性的最佳实践。
my_module.h 文件示例:
1 | // 1. 头文件防卫开始 |
my_module.c 文件 (模块的实现):
1 | ## |
main.c 文件 (模块的使用者):
1 | ## |
19. 常用字符串函数
strlen - 获取长度
lenth
返回字符串的长度,不包括末尾的 \0。
1 | size_t len = strlen("hello"); // len 的值为 5 |
strcpy - 字符串复制
copy
strncpy是strcpy的安全版本,推荐使用以防止内存溢出。
1 | char dest[10] = "123456789"; |
strcat - 字符串拼接
catch
strncat是strcat的安全版本,推荐使用。
1 | char dest[10] = "Hi, "; |
strcmp - 字符串比较
compare
按字典序比较字符串,返回 <0 (s1<s2), 0 (s1==s2), >0 (s1>s2)。
1 | int result = strcmp("abc", "abd"); // result < 0 |
strchr - 查找字符
char
strchr: 从左向右查找第一个匹配的字符。strrchr: 从右向左查找第一个匹配的字符。
1 | char *p = strchr("a.b.c.d", '.'); // p 指向 ".b.c.d" |
strstr - 查找子串
string
在字符串中查找子字符串首次出现的位置。
1 | char *p = strstr("main.c", ".c"); // p 指向 ".c" |
strtok - 分割字符串
token
注意: 此函数会修改原始字符串。首次调用传入字符串,后续调用传入 NULL。
1 | char str[] = "www.yueqian.com"; |
第五部分:高级应用入门
20. 算法入门
经典排序:冒泡排序
问题: 实现冒泡排序算法。
核心知识: 通过重复遍历数组,比较相邻元素并交换,每一趟都将当前未排序部分的最大(或最小)元素“冒泡”到最终位置。
代码示例:
1 | void bubble_sort(int len, int A[]) { |
21. 数据结构入门
单链表
单链表是一种动态数据结构,它由一系列节点组成,每个节点包含数据和一个指向下一个节点的指针。
- 核心思想: 在堆内存中动态创建节点 (
malloc),并通过指针将这些分散的节点链接成一个有序序列。 - 关键知识点:
struct结构体:用于定义链表节点。malloc/free:动态内存分配和释放。- 指针操作:通过
p->next遍历和操作链表。
1 | ## |
22. 面试题精选
巧妙的级数求和
问题: 计算 S = 1 - 1/2 + 1/3 - 1/4 + ... + 1/99 - 1/100。
核心知识: 直接计算涉及大量浮点减法,可能损失精度。通过数学变换可以优化:S = (1 + ... + 1/100) - 2 * (1/2 + ... + 1/100) = (1 + ... + 1/100) - (1 + ... + 1/50) = 1/51 + ... + 1/100
这个变换将问题转换成了一个简单的正项级数求和。
代码示例:
1 | # |
凑零钱问题
1 | # |
解法二
1 | # |
统计数字’1’的出现次数
1 | # |
二维数组边界元素求平均值
1 | # |
23. 扩展阅读
(待补充…)
24. 笔记总结
C 语言的核心是围绕指针、内存管理和作用域/存储期这三大支柱展开的,static关键字是贯穿其中的关键。
基础与类型系统:程序由
main函数启动,其根本是数据类型。必须精确掌握int,char,double等类型与printf/scanf格式说明符的对应关系:printf中%f可通用打印float和double(因float参数会自动提升为double);而scanf接收double必须用%lf。scanf通过传递变量地址&来实现对调用方变量的修改,务必警惕其输入缓冲区的残留问题。
运算符与表达式:重点掌握逻辑运算的短路求值(
&&,||)、高效的位运算(&,|,^,~,<<,>>)、条件运算符? :以及sizeof(它是一个运算符,不是函数)。当不确定优先级时,用**圆括号()**是最佳实践。控制流:熟练运用
if-else、switch(牢记case的穿透特性与break的重要性)、for/while/do-while循环,以及break(跳出循环/switch)、continue(跳过本次迭代)、return(终止函数)三种跳转语句。指针(难点中的难点):
- 核心操作:指针是存储地址的变量,用
&取地址、*解引用。必须初始化为NULL以防野指针。 - 数组与指针:在表达式中(除
sizeof和&外),数组名会 “降维/退化” 为指向其首元素的指针,因此a[i]本质是*(a+i)的语法糖。这也导致数组作为函数参数时,sizeof在函数内外结果不同。 const修饰:必须严格区分“指向常量的指针”(const int *p,*p不可改,p可改)和“常量指针”(int * const p,p不可改,*p可改)。- 高级指针:理解
void*作为通用指针(使用前须强制类型转换),以及函数指针(返回类型 (*指针名)(参数))作为实现回调函数的基础。
- 核心操作:指针是存储地址的变量,用
内存布局:
- 代码段 (
.text):存放二进制指令,只读。 - 数据段:
.rodata:存放字符串字面量、const全局变量,只读。.data:存放已初始化的全局变量和静态变量。.bss:存放未初始化的全局变量和静态变量,程序启动时系统自动清零。
- 栈 (Stack):存放函数的局部变量和参数,由编译器自动管理,空间有限,有栈溢出风险。严禁返回局部变量的地址,因其在函数返回后即被销毁。
- 堆 (Heap):由程序员通过
malloc/calloc/realloc/free手动管理,空间巨大但易产生内存泄漏(忘记free)或悬空指针(free后未置NULL)。
- 代码段 (
static关键字(重中之重):- 修饰局部变量:改变其存储期(从自动存储期改为静态存储期),使其从栈移至数据段。变量生命周期与程序相同,其值在函数多次调用间保持不变。
- 修饰全局变量/函数:改变其链接属性(从外部链接改为内部链接),使其作用域被限制在当前源文件内,是实现模块化和信息隐藏的关键。
复合类型:
struct:将不同类型数据聚合成单一实体,是 C 语言实现面向对象思想的基础。通过.(变量)或->(指针)访问成员,并须注意编译器为提高效率而进行的内存对齐,以及用于硬件编程的位域。union:所有成员共享同一块内存,大小由最大成员决定,常用于节省空间。enum:创建类型安全的命名整型常量,比#define更优。
预处理:在编译前执行的文本替换。核心指令包括
#include(文件包含)、#define(定义宏,可用\换行)、#if/#ifdef等条件编译指令。宏操作符#(字符串化)和##(符号连接)功能强大。多文件编程中,必须使用**#ifndef...#define...#endif**结构进行 头文件防卫 ,防止重复包含。
高级关键字:volatile的应用场景与原理,二维数组,边界元素遍历,const指针的三种模式辨析,数组与指针的异同


