【万字血书】K&R C语言圣经全解:嵌入式硬核玩家的内功心法(第一部分)
第一章:K&R的哲学与C语言的诞生——极简与强大的完美结合
兄弟们,提到C语言,就不得不提那本“大黑书”——《C程序设计语言》(The C Programming Language)。这本书,由C语言的亲爹Dennis M. Ritchie和计算机科学巨匠Brian W. Kernighan合著,它不仅仅是一本教材,更是C语言精神的完美体现:极简、高效、强大、贴近硬件。
1.1 K&R:C语言的“圣经”与设计哲学
为什么K&R被称为C语言的“圣经”?因为它不仅仅是语法手册,它更是C语言设计哲学的集中体现。
-
极简主义(Simplicity): K&R的风格就是简洁,不废话。它用最少的篇幅,把C语言最核心、最强大的部分讲得清清楚楚。C语言本身也是极简的,它没有Java、Python那样复杂的运行时环境和庞大的类库,它把更多的控制权交给了程序员。
-
面向系统编程(System Programming): C语言最初就是为了编写操作系统(Unix)而设计的。它提供了直接操作内存、位的能力,这使得它成为编写驱动程序、操作系统内核、嵌入式固件等底层软件的理想选择。K&R这本书,也处处体现着这种面向底层的设计思想。
-
可移植性(Portability): 尽管C语言贴近硬件,但K&R在设计C语言时,也考虑了其在不同硬件平台上的可移植性。标准C语言的语法和语义在大多数平台上都是一致的,这使得C语言程序可以相对容易地从一个平台移植到另一个平台。
-
“信任程序员”(Trust the Programmer): C语言的设计哲学是“信任程序员”。它提供了强大的底层操作能力,但也意味着程序员需要为自己的行为负责。比如,指针操作的强大与危险并存,内存管理的自由与陷阱同在。K&R教会你如何驾驭这种力量。
大厂面试考点:C语言为什么经典?
-
高效性: 接近汇编语言的执行效率,对硬件资源控制力强。
-
灵活性: 语法简洁,表达力强,可以自由操作内存。
-
可移植性: 遵循ANSI C标准,代码在不同平台间移植性好。
-
底层控制: 直接操作内存地址、位,适合系统编程、嵌入式开发。
-
丰富的库函数: 标准库提供了大量常用功能。
-
强大的生态系统: 编译器、调试器、工具链成熟。
1.2 C语言的诞生与演进:从B到C,从Unix到世界
C语言的诞生,与Unix操作系统的发展密不可分。
-
早期:B语言
-
C语言的前身是贝尔实验室的Ken Thompson在PDP-7上开发的B语言(基于BCPL)。
-
-
C语言的诞生(1972):
-
Dennis M. Ritchie在贝尔实验室开发了C语言,最初是为了改进B语言,并用它来重写Unix操作系统。
-
C语言在B语言的基础上,增加了数据类型、结构体等特性,使其更加强大和灵活。
-
-
Unix的崛起:
-
Unix操作系统的成功,极大地推动了C语言的普及。因为Unix是用C语言编写的,所以很多系统工具和应用程序也开始用C语言开发。
-
-
ANSI C标准(1989):
-
随着C语言的广泛使用,不同编译器之间出现了兼容性问题。为了解决这个问题,美国国家标准协会(ANSI)制定了C语言的第一个标准——ANSI C(C89/C90)。K&R的第二版就是基于这个标准。
-
-
ISO C标准:
-
后来,国际标准化组织(ISO)也采纳了ANSI C标准,并在此基础上发布了C99、C11、C17等后续标准,不断为C语言添加新特性和改进。
-
大厂面试考点:C++和C的区别?
-
面向对象: C++是面向对象的语言(支持类、继承、多态),C是面向过程的语言。
-
兼容性: C++兼容C的大部分语法,但C不兼容C++的面向对象特性。
-
内存管理: C++有
new/delete运算符,C只有malloc/free函数。C++还引入了智能指针等更安全的内存管理机制。 -
泛型编程: C++支持模板,C没有。
-
异常处理: C++有
try-catch异常处理机制,C通常通过错误码或setjmp/longjmp处理异常。 -
复杂性: C++比C更复杂,学习曲线更陡峭。
-
应用领域: C更常用于操作系统、嵌入式、驱动开发等底层领域;C++除了底层,还广泛用于游戏开发、桌面应用、高性能计算等领域。
小结: K&R的哲学,就是C语言的灵魂。它教会我们用极简的工具,去解决最复杂的问题。理解C语言的诞生背景和演进,能让你更深刻地体会到它作为一门系统级编程语言的强大和魅力。
第二章:数据类型、运算符与表达式——C语言的“原子”
兄弟们,C语言的程序,归根结底都是对数据的操作。而数据,在C语言中,都是以数据类型的形式存在的。它们就像C语言的“原子”,是构成一切复杂程序的最小单位。理解这些“原子”的特性,以及如何通过运算符将它们组合成有意义的表达式,是掌握C语言的基石!
2.1 基本数据类型:C语言的“积木”
C语言提供了几种内置的基本数据类型,它们决定了变量在内存中占用的空间大小以及如何解释这些二进制位。
2.1.1 整型家族:char, short, int, long, ``long long`
-
char:-
大小: 通常1字节(8位)。
-
用途: 存储单个字符(ASCII码或扩展字符集),也可以存储小整数。
-
有符号/无符号: 默认是
signed char,范围通常是-128到127。可以使用unsigned char,范围是0到255。在嵌入式中,unsigned char常用作字节数据。
-
-
short:-
大小: 通常2字节(16位)。
-
用途: 存储短整数。
-
范围:
signed short通常是-32768到32767。unsigned short通常是0到65535。
-
-
int:-
大小: 通常4字节(32位),但C标准只规定
int至少与short一样大,且不大于long。在大多数现代系统上,int是32位。 -
用途: 最常用的整数类型。
-
范围:
signed int通常是-2,147,483,648到2,147,483,647。unsigned int通常是0到4,294,967,295。
-
-
long:-
大小: 通常4字节(32位)或8字节(64位),取决于编译器和系统架构。C标准规定
long至少与int一样大。 -
用途: 存储较大整数。
-
-
long long(C99引入):-
大小: 至少8字节(64位)。
-
用途: 存储超大整数。
-
范围:
signed long long范围约为 pm9times1018。unsigned long long范围约为 0 到 1.8times1019。
-
表格:整型数据类型大小与范围(典型32位系统)
|
类型 |
字节数 (典型) |
最小值 (有符号) |
最大值 (有符号) |
最大值 (无符号) |
|---|---|---|---|---|
|
|
1 |
-128 |
127 |
255 |
|
|
2 |
-32,768 |
32,767 |
65,535 |
|
|
4 |
-2,147,483,648 |
2,147,483,647 |
4,294,967,295 |
|
|
4 或 8 |
-2,147,483,648 或 -9E18 |
2,147,483,647 或 9E18 |
4,294,967,295 或 1.8E19 |
|
|
8 |
-9,223,372,036,854,775,808 |
9,223,372,036,854,775,807 |
18,446,744,073,709,551,615 |
大厂面试考点:跨平台差异
-
C标准只规定了数据类型的最小范围,而不是固定大小。例如,
int至少是16位,long至少是32位,long long至少是64位。 -
在不同的编译器和CPU架构上,
int和long的实际大小可能不同。例如,在32位系统上int和long通常都是4字节,而在64位系统上int通常是4字节,long可能是8字节。 -
嵌入式开发实践: 为了确保代码的可移植性和内存的精确控制,通常会使用
<stdint.h>中定义的固定宽度整数类型,如int8_t,uint8_t,int16_t,uint16_t,int32_t,uint32_t,int64_t,uint64_t。这些类型保证了在任何支持它们的平台上都具有确切的位宽。
2.1.2 浮点型家族:float, double, long double
-
float:-
大小: 通常4字节(32位)。
-
用途: 存储单精度浮点数。
-
精度: 通常能表示约6-7位有效数字。
-
-
double:-
大小: 通常8字节(64位)。
-
用途: 存储双精度浮点数。
-
精度: 通常能表示约15-17位有效数字。这是C语言中最常用的浮点类型。
-
-
long double:-
大小: 至少与
double一样大,通常是10字节、12字节或16字节。 -
用途: 存储更高精度的浮点数。
-
大厂面试考点:浮点数精度问题
-
浮点数在计算机中通常采用IEEE 754标准表示,它是一种近似表示,许多十进制小数(如0.1)无法精确表示为二进制浮点数,会导致精度损失。
-
永远不要直接使用
==比较两个浮点数! 应该使用一个很小的误差范围(epsilon)进行比较。 -
在嵌入式中,如果处理器没有浮点运算单元(FPU),浮点运算会非常慢。在资源受限或对实时性要求高的场景,可能需要考虑使用定点数代替浮点数。
代码示例:基本数据类型的大小与范围
#include <stdio.h>
#include <limits.h> // For INT_MIN, INT_MAX, etc.
#include <float.h> // For FLT_MIN, FLT_MAX, DBL_MIN, DBL_MAX, etc.
#include <stdint.h> // For fixed-width integer types
int main() {
printf("--- 基本数据类型大小与范围示例 ---\n");
// 整型家族
printf("\n--- 整型数据类型 ---\n");
printf("char: %zu 字节, 范围: %d 到 %d (signed)\n", sizeof(char), SCHAR_MIN, SCHAR_MAX);
printf("unsigned char: %zu 字节, 范围: %u 到 %u\n", sizeof(unsigned char), 0, UCHAR_MAX);
printf("short: %zu 字节, 范围: %d 到 %d\n", sizeof(short), SHRT_MIN, SHRT_MAX);
printf("unsigned short: %zu 字节, 范围: %u 到 %u\n", sizeof(unsigned short), 0, USHRT_MAX);
printf("int: %zu 字节, 范围: %d 到 %d\n", sizeof(int), INT_MIN, INT_MAX);
printf("unsigned int: %zu 字节, 范围: %u 到 %u\n", sizeof(unsigned int), 0, UINT_MAX);
printf("long: %zu 字节, 范围: %ld 到 %ld\n", sizeof(long), LONG_MIN, LONG_MAX);
printf("unsigned long: %zu 字节, 范围: %lu 到 %lu\n", sizeof(unsigned long), 0UL, ULONG_MAX);
printf("long long: %zu 字节, 范围: %lld 到 %lld\n", sizeof(long long), LLONG_MIN, LLONG_MAX);
printf("unsigned long long: %zu 字节, 范围: %llu 到 %llu\n", sizeof(unsigned long long), 0ULL, ULLONG_MAX);
// 固定宽度整数类型 (嵌入式常用)
printf("\n--- 固定宽度整数类型 (来自 <stdint.h>) ---\n");
printf("int8_t: %zu 字节, 范围: %d 到 %d\n", sizeof(int8_t), INT8_MIN, INT8_MAX);
printf("uint8_t: %zu 字节, 范围: %u 到 %u\n", sizeof(uint8_t), 0, UINT8_MAX);
printf("int16_t: %zu 字节, 范围: %d 到 %d\n", sizeof(int16_t), INT16_MIN, INT16_MAX);
printf("uint16_t: %zu 字节, 范围: %u 到 %u\n", sizeof(uint16_t), 0, UINT16_MAX);
printf("int32_t: %zu 字节, 范围: %d 到 %d\n", sizeof(int32_t), INT32_MIN, INT32_MAX);
printf("uint32_t: %zu 字节, 范围: %u 到 %u\n", sizeof(uint32_t), 0, UINT32_MAX);
printf("int64_t: %zu 字节, 范围: %lld 到 %lld\n", sizeof(int64_t), INT64_MIN, INT64_MAX);
printf("uint64_t: %zu 字节, 范围: %llu 到 %llu\n", sizeof(uint64_t), 0ULL, UINT64_MAX);
// 浮点型家族
printf("\n--- 浮点数据类型 ---\n");
printf("float: %zu 字节, 范围: %e 到 %e, 精度: %d 位\n", sizeof(float), FLT_MIN, FLT_MAX, FLT_DIG);
printf("double: %zu 字节, 范围: %e 到 %e, 精度: %d 位\n", sizeof(double), DBL_MIN, DBL_MAX, DBL_DIG);
printf("long double: %zu 字节, 范围: %Le 到 %Le, 精度: %d 位\n", sizeof(long double), LDBL_MIN, LDBL_MAX, LDBL_DIG);
// 浮点数精度问题示例
float f_val = 0.1f;
double d_val = 0.1;
printf("\n--- 浮点数精度问题 --- \n");
printf("float 0.1f: %.20f\n", f_val); // 打印20位小数
printf("double 0.1: %.20f\n", d_val); // 打印20位小数
if (f_val == 0.1f) {
printf("0.1f == 0.1f (直接比较可能成立,但有隐患)\n");
} else {
printf("0.1f != 0.1f (由于精度,可能不成立)\n");
}
printf("\n--- 基本数据类型大小与范围示例结束 ---\n");
return 0;
}
分析与注意点:
-
sizeof运算符: 用于获取数据类型或变量在内存中占用的字节数。它是一个编译时运算符,其结果在编译时就已经确定。 -
limits.h和float.h: 这两个头文件定义了各种数据类型的最大值、最小值和精度信息,非常有用。 -
格式化输出:
printf中使用%zu打印size_t类型(sizeof的返回值),%d打印int,%ld打印long,%lld打印long long,%f打印float/double,%Le打印long double。 -
嵌入式中的选择: 在嵌入式中,内存和性能是宝贵的资源。选择合适的数据类型至关重要:
-
能用
char解决的,不用int。 -
能用定点数解决的,不用浮点数(如果FPU缺失或性能不足)。
-
使用
<stdint.h>中的固定宽度类型,确保代码在不同平台上的行为一致。
-
2.2 运算符:C语言的“动作”
兄弟们,有了数据“原子”,我们还需要“动作”来操作它们!C语言提供了丰富的运算符,它们是程序中执行各种计算和逻辑判断的“动词”。
2.2.1 算术运算符:+, -, *, /, %
-
+(加),-(减),*(乘),/(除):-
整数除法
/:结果是整数,小数部分被截断(向零取整)。 -
浮点数除法
/:结果是浮点数。
-
-
%(取模/取余):-
只能用于整数类型。
-
结果的符号与被除数相同。
-
#include <stdio.h>
int main() {
printf("--- 算术运算符示例 ---\n");
int a = 10, b = 3;
float x = 10.0f, y = 3.0f;
printf("整数运算:\n");
printf("a + b = %d\n", a + b); // 13
printf("a - b = %d\n", a - b); // 7
printf("a * b = %d\n", a * b); // 30
printf("a / b = %d\n", a / b); // 3 (整数除法,截断小数)
printf("a %% b = %d\n", a % b); // 1
printf("\n浮点数运算:\n");
printf("x + y = %.2f\n", x + y); // 13.00
printf("x - y = %.2f\n", x - y); // 7.00
printf("x * y = %.2f\n", x * y); // 30.00
printf("x / y = %.2f\n", x / y); // 3.33
printf("\n--- 算术运算符示例结束 ---\n");
return 0;
}
2.2.2 关系运算符:==, !=, >, <, >=, <=
-
用于比较两个值的大小关系。
-
结果是布尔值(在C语言中,非零表示真,零表示假)。
#include <stdio.h>
int main() {
printf("--- 关系运算符示例 ---\n");
int a = 10, b = 20;
printf("a == b : %d\n", a == b); // 0 (假)
printf("a != b : %d\n", a != b); // 1 (真)
printf("a > b : %d\n", a > b); // 0 (假)
printf("a < b : %d\n", a < b); // 1 (真)
printf("a >= b : %d\n", a >= b); // 0 (假)
printf("a <= b : %d\n", a <= b); // 1 (真)
printf("\n--- 关系运算符示例结束 ---\n");
return 0;
}
2.2.3 逻辑运算符:&&, ||, !
-
&&(逻辑与):如果两边都为真,结果为真。短路求值:如果左边为假,右边不执行。 -
||(逻辑或):如果两边任一为真,结果为真。短路求值:如果左边为真,右边不执行。 -
!(逻辑非):如果为真,结果为假;如果为假,结果为真。
#include <stdio.h>
int main() {
printf("--- 逻辑运算符示例 ---\n");
int a = 10, b = 0, c = 5;
// 逻辑与 &&
printf("(a > 0 && c > 0) : %d\n", (a > 0 && c > 0)); // 1 (真)
printf("(a > 0 && b > 0) : %d\n", (a > 0 && b > 0)); // 0 (假)
// 逻辑或 ||
printf("(a > 0 || b > 0) : %d\n", (a > 0 || b > 0)); // 1 (真)
printf("(b > 0 || c < 0) : %d\n", (b > 0 || c < 0)); // 0 (假)
// 逻辑非 !
printf("!(a > 0) : %d\n", !(a > 0)); // 0 (假)
printf("!(b > 0) : %d\n", !(b > 0)); // 1 (真)
printf("\n--- 逻辑运算符示例结束 ---\n");
return 0;
}
2.2.4 位运算符:&, |, ^, ~, <<, >>
兄弟们,位运算符是C语言的“底层魔法”,在嵌入式开发中简直是“杀手锏”!它们直接操作数据的二进制位,效率极高,是控制硬件寄存器、处理通信协议、进行数据编解码的必备技能!
-
&(按位与):对应位都为1,结果为1。 -
|(按位或):对应位任一为1,结果为1。 -
^(按位异或):对应位不同,结果为1。 -
~(按位取反):所有位取反(0变1,1变0)。 -
<<(左移):将二进制位向左移动指定位数,右边补0。相当于乘以2的幂次。 -
>>(右移):将二进制位向右移动指定位数。-
无符号数: 左边补0(逻辑右移)。
-
有符号数: 左边补符号位(算术右移)或补0(逻辑右移),取决于编译器实现。通常是算术右移。
-
大厂面试考点:位运算符的实际应用(嵌入式)
-
寄存器操作: 设置、清除、读取特定位或位段。
-
标志位操作: 检查、设置、清除状态标志。
-
数据打包/解包: 将多个小数据打包到一个整数中,或从整数中解包。
-
权限控制: 用位表示权限,进行权限组合或检查。
代码示例:位运算符的嵌入式应用
#include <stdio.h>
#include <stdint.h> // For uint8_t, uint16_t, uint32_t
// 模拟一个8位控制寄存器
// Bit 0: LED_EN (LED使能)
// Bit 1: MOTOR_DIR (电机方向: 0-正转, 1-反转)
// Bit 2-3: SPEED_MODE (速度模式: 00-低速, 01-中速, 10-高速, 11-保留)
// Bit 4: BUZZER_EN (蜂鸣器使能)
// Bit 5-7: RESERVED (保留)
#define LED_EN_BIT (1U << 0) // 0000 0001
#define MOTOR_DIR_BIT (1U << 1) // 0000 0010
#define BUZZER_EN_BIT (1U << 4) // 0001 0000
// 速度模式的掩码和偏移
#define SPEED_MODE_MASK (3U << 2) // 0000 1100 (掩码,用于提取位段)
#define SPEED_MODE_SHIFT 2 // 偏移量
// 速度模式的定义
#define SPEED_LOW (0U << SPEED_MODE_SHIFT) // 00
#define SPEED_MID (1U << SPEED_MODE_SHIFT) // 01
#define SPEED_HIGH (2U << SPEED_MODE_SHIFT) // 10
int main() {
printf("--- 位运算符示例 (嵌入式应用) ---\n");
uint8_t control_reg = 0x00; // 初始控制寄存器值
printf("初始控制寄存器: 0x%02X\n", control_reg);
// 1. 设置特定位 (置1) - 使用 | 运算符
// 使能LED
control_reg |= LED_EN_BIT; // control_reg = 0x00 | 0x01 = 0x01
printf("使能LED后: 0x%02X\n", control_reg);
// 使能蜂鸣器
control_reg |= BUZZER_EN_BIT; // control_reg = 0x01 | 0x10 = 0x11
printf("使能蜂鸣器后: 0x%02X\n", control_reg);
// 2. 清除特定位 (置0) - 使用 & 和 ~ 运算符
// 关闭LED
control_reg &= ~LED_EN_BIT; // control_reg = 0x11 & ~0x01 = 0x11 & 0xFE = 0x10
printf("关闭LED后: 0x%02X\n", control_reg);
// 3. 翻转特定位 - 使用 ^ 运算符
// 反转电机方向 (假设当前为正转,翻转为反转)
control_reg ^= MOTOR_DIR_BIT; // control_reg = 0x10 ^ 0x02 = 0x12
printf("反转电机方向后: 0x%02X\n", control_reg);
control_reg ^= MOTOR_DIR_BIT; // 再次反转回正转
printf("再次反转电机方向后: 0x%02X\n", control_reg);
// 4. 读取特定位 - 使用 & 运算符
if ((control_reg & BUZZER_EN_BIT) != 0) {
printf("蜂鸣器当前已使能。\n");
} else {
printf("蜂鸣器当前未使能。\n");
}
// 5. 设置位段 (速度模式) - 先清零位段,再设置新值
// 设置速度模式为中速 (01)
control_reg &= ~SPEED_MODE_MASK; // 先清零位段: control_reg = 0x12 & ~0x0C = 0x12 & 0xF3 = 0x12
control_reg |= SPEED_MID; // 再设置新值: control_reg = 0x12 | 0x04 = 0x16
printf("设置速度模式为中速 (0x01) 后: 0x%02X\n", control_reg);
// 设置速度模式为高速 (10)
control_reg &= ~SPEED_MODE_MASK; // 清零
control_reg |= SPEED_HIGH; // 设置新值
printf("设置速度模式为高速 (0x02) 后: 0x%02X\n", control_reg);
// 6. 读取位段 - 使用 & 和 >> 运算符
uint8_t current_speed_mode = (control_reg & SPEED_MODE_MASK) >> SPEED_MODE_SHIFT;
printf("当前速度模式 (原始值): %u\n", current_speed_mode);
switch (current_speed_mode) {
case 0: printf("当前速度模式: 低速\n"); break;
case 1: printf("当前速度模式: 中速\n"); break;
case 2: printf("当前速度模式: 高速\n"); break;
default: printf("当前速度模式: 未知\n"); break;
}
// 7. 左移和右移的数学意义
int val = 5; // 0000 0101
printf("\n--- 左移和右移的数学意义 ---\n");
printf("%d << 1 = %d (乘以2)\n", val, val << 1); // 10
printf("%d << 2 = %d (乘以4)\n", val, val << 2); // 20
int val2 = 10; // 0000 1010
printf("%d >> 1 = %d (除以2)\n", val2, val2 >> 1); // 5
printf("%d >> 2 = %d (除以4)\n", val2, val2 >> 2); // 2
// 负数右移的差异 (取决于编译器和系统)
int neg_val = -10; // 补码表示
printf("负数 %d >> 1 = %d (可能算术右移,左边补符号位)\n", neg_val, neg_val >> 1);
printf("\n--- 位运算符示例结束 ---\n");
return 0;
}
分析与注意点:
-
U后缀: 在定义位掩码时,使用1U而不是1,可以确保1被视为unsigned int,避免在位移操作中出现有符号数的问题。 -
位段操作的通用模式:
-
设置位:
reg |= BIT_MASK; -
清除位:
reg &= ~BIT_MASK; -
翻转位:
reg ^= BIT_MASK; -
读取位:
(reg & BIT_MASK) != 0 -
设置位段:
reg = (reg & ~BIT_FIELD_MASK) | (new_value << SHIFT); -
读取位段:
(reg & BIT_FIELD_MASK) >> SHIFT;
-
-
右移(有符号数): 对有符号数进行右移操作时,结果取决于编译器。通常是算术右移(左边补符号位),但C标准允许逻辑右移(左边补0)。为了可移植性,尽量避免对有符号数进行位移操作,或者使用无符号数。
-
面试高频考点! 熟练手写位操作实现寄存器读写、标志位控制。
2.2.5 赋值运算符:=, +=, -=, *= 等
-
=:简单的赋值。 -
+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>=:复合赋值运算符。-
a += b;等同于a = a + b;
-
#include <stdio.h>
int main() {
printf("--- 赋值运算符示例 ---\n");
int a = 10;
int b = 5;
printf("初始 a = %d, b = %d\n", a, b);
a += b; // a = a + b; => a = 10 + 5 = 15
printf("a += b 后, a = %d\n", a);
a -= b; // a = a - b; => a = 15 - 5 = 10
printf("a -= b 后, a = %d\n", a);
a *= b; // a = a * b; => a = 10 * 5 = 50
printf("a *= b 后, a = %d\n", a);
a /= b; // a = a / b; => a = 50 / 5 = 10
printf("a /= b 后, a = %d\n", a);
a %= b; // a = a % b; => a = 10 % 5 = 0
printf("a %%= b 后, a = %d\n", a);
// 位复合赋值
unsigned char flags = 0x01; // 0000 0001
flags |= 0x02; // flags = 0000 0001 | 0000 0010 = 0000 0011 (0x03)
printf("flags |= 0x02 后, flags = 0x%02X\n", flags);
flags <<= 1; // flags = 0000 0011 << 1 = 0000 0110 (0x06)
printf("flags <<= 1 后, flags = 0x%02X\n", flags);
printf("\n--- 赋值运算符示例结束 ---\n");
return 0;
}
2.2.6 条件运算符(三元运算符):? :
-
语法:
expression1 ? expression2 : expression3 -
功能: 如果
expression1为真(非零),则执行expression2并返回其值;否则,执行expression3并返回其值。 -
优点: 简洁,常用于简单的条件赋值。
#include <stdio.h>
int main() {
printf("--- 条件运算符示例 ---\n");
int a = 10, b = 20;
int max_val;
max_val = (a > b) ? a : b; // 如果 a > b 为真,max_val = a;否则 max_val = b
printf("a = %d, b = %d, 最大值 = %d\n", a, b, max_val);
int score = 85;
const char *grade = (score >= 60) ? "及格" : "不及格";
printf("分数 %d, 成绩: %s\n", score, grade);
printf("\n--- 条件运算符示例结束 ---\n");
return 0;
}
2.2.7 逗号运算符:,
-
语法:
expression1, expression2, ..., expressionN -
功能: 从左到右依次计算每个表达式,并返回最后一个表达式的值。
-
用途: 常常用于
for循环的初始化和迭代部分,或者在需要在一个表达式中执行多个操作的场景。
#include <stdio.h>
int main() {
printf("--- 逗号运算符示例 ---\n");
int a = 1, b = 2, c = 3;
int result;
result = (a++, b++, c++); // 依次执行 a++, b++, c++,最后返回 c++ 的值 (3)
printf("result = %d, a = %d, b = %d, c = %d\n", result, a, b, c);
// 在 for 循环中使用
printf("\n--- 在 for 循环中使用逗号运算符 ---\n");
for (int i = 0, j = 10; i < 3; i++, j--) {
printf("i = %d, j = %d\n", i, j);
}
printf("\n--- 逗号运算符示例结束 ---\n");
return 0;
}
2.2.8 sizeof 运算符:计算大小的“尺子”
-
语法:
sizeof(type)或sizeof expression -
功能: 返回数据类型或变量在内存中占用的字节数。
-
特点:
sizeof是一个编译时运算符,它的结果在编译时就已经确定,而不是在运行时计算。 -
用途:
-
动态内存分配时确定需要分配的大小。
-
计算数组元素个数。
-
在跨平台编程时,了解数据类型在不同系统上的实际大小。
-
#include <stdio.h>
int main() {
printf("--- sizeof 运算符示例 ---\n");
int i;
char c;
double d;
int arr[10];
printf("sizeof(int): %zu 字节\n", sizeof(int));
printf("sizeof(char): %zu 字节\n", sizeof(char));
printf("sizeof(double): %zu 字节\n", sizeof(double));
printf("sizeof(i): %zu 字节\n", sizeof(i));
printf("sizeof(c): %zu 字节\n", sizeof(c));
printf("sizeof(d): %zu 字节\n", sizeof(d));
printf("sizeof(arr): %zu 字节 (数组总大小)\n", sizeof(arr));
printf("arr 数组元素个数: %zu\n", sizeof(arr) / sizeof(arr[0]));
// sizeof 表达式
printf("sizeof(10 + 20): %zu 字节 (结果的类型大小)\n", sizeof(10 + 20)); // int 类型大小
printf("sizeof(3.14f): %zu 字节 (float 类型大小)\n", sizeof(3.14f)); // float 类型大小
printf("sizeof(3.14): %zu 字节 (double 类型大小)\n", sizeof(3.14)); // double 类型大小 (默认浮点字面量是 double)
printf("\n--- sizeof 运算符示例结束 ---\n");
return 0;
}
分析与注意点:
-
编译时确定: 这是
sizeof最重要的特性。这意味着你不能用sizeof来获取动态分配内存的大小(因为sizeof只能看到指针变量本身的大小,而不是它指向的内存块大小)。 -
数组与指针: 对数组名使用
sizeof得到的是整个数组的总大小;对指针使用sizeof得到的是指针变量本身的大小(通常是4或8字节),而不是它指向的内存块大小。这是面试高频考点!
2.2.9 运算符优先级与结合性:表达式的“运算顺序”
兄弟们,C语言的运算符这么多,它们之间谁先算谁后算,可不是随便来的!这就涉及到运算符优先级(Precedence)和结合性(Associativity)。理解它们,就像理解数学里的“先乘除后加减”,能让你准确预测表达式的运算结果。
-
优先级: 决定了在没有括号的情况下,哪个运算符先执行。优先级高的先执行。
-
结合性: 当多个运算符优先级相同时,结合性决定了它们的执行顺序。
-
左结合: 从左到右执行(例如:
a - b - c等同于(a - b) - c)。 -
右结合: 从右到左执行(例如:赋值运算符
a = b = c等同于a = (b = c))。
-
表格:C语言常用运算符优先级与结合性(从高到低)
|
优先级 |
运算符 |
结合性 |
描述 |
|---|---|---|---|
|
1 |
|
左 |
圆括号、数组下标、成员访问、后缀自增/自减 |
|
2 |
|
右 |
前缀自增/自减、逻辑非、按位取反、一元正负、解引用、取地址、大小、类型转换 |
|
3 |
|
左 |
乘、除、取模 |
|
4 |
|
左 |
加、减 |
|
5 |
|
左 |
左移、右移 |
|
6 |
|
左 |
关系运算符 |
|
7 |
|
左 |
相等、不相等 |
|
8 |
|
左 |
按位与 |
|
9 |
|
左 |
按位异或 |
|
10 |
` |
` |
左 |
|
11 |
|
左 |
逻辑与 |
|
12 |
` |
` | |
|
13 |
|
右 |
条件运算符 |
|
14 |
|
= |
右 |
|
15 |
|
左 |
逗号运算符 |
代码示例:运算符优先级与结合性
#include <stdio.h>
int main() {
printf("--- 运算符优先级与结合性示例 ---\n");
int a = 10, b = 5, c = 2;
int result;
// 1. 乘除优先级高于加减
result = a + b * c; // 10 + (5 * 2) = 10 + 10 = 20
printf("a + b * c = %d\n", result);
// 2. 赋值运算符的右结合性
int x, y, z;
x = y = z = 5; // z = 5; y = z; x = y;
printf("x = %d, y = %d, z = %d\n", x, y, z);
// 3. 逻辑与 && 优先级高于逻辑或 ||
// (a > 0 && b < 0) || c > 0
// (10 > 0 && 5 < 0) || 2 > 0
// (真 && 假) || 真
// 假 || 真
// 真 (1)
result = (a > 0 && b < 0) || c > 0;
printf("(a > 0 && b < 0) || c > 0 = %d\n", result);
// 4. 位运算符优先级 (比关系运算符低)
// 10 & 5 == 0
// (0000 1010 & 0000 0101) == 0
// (0000 0000) == 0
// 0 == 0 -> 真 (1)
result = (a & b) == 0;
printf("(a & b) == 0 = %d\n", result);
printf("\n--- 运算符优先级与结合性示例结束 ---\n");
return 0;
}
分析与注意点:
-
使用括号: 当你不确定运算符优先级时,或者为了提高代码的可读性,始终使用括号
()来明确运算顺序! 这是一种良好的编程习惯。 -
短路求值: 逻辑与
&&和逻辑或||具有短路求值特性。这在条件判断中非常有用,可以避免不必要的计算或潜在的错误。-
if (ptr != NULL && *ptr > 0):如果ptr为NULL,则*ptr > 0不会被执行,避免了空指针解引用。
-
2.3 表达式与语句:C语言的“句子”
兄弟们,有了“原子”(数据类型)和“动词”(运算符),我们就可以把它们组合成C语言的“句子”——表达式和语句。
-
表达式 (Expression):
-
由运算符和操作数组成,计算后会产生一个值。
-
例如:
a + b,x * y,i++,a > b,func()。 -
每个表达式都有一个类型和值。
-
-
语句 (Statement):
-
执行特定动作的C语言指令。
-
以分号
;结尾。 -
表达式可以作为语句的一部分,例如
a + b;(虽然计算了值,但没有使用,通常无意义)。 -
常见的语句类型:
-
表达式语句:
a = 10;,printf("Hello"); -
复合语句(块): 用
{}包裹的一系列语句,被视为一个整体。 -
控制语句:
if,for,while,switch等。 -
声明语句:
int x; -
空语句:
;(什么也不做,但有时有用,如在循环中等待条件满足)。
-
-
代码示例:表达式与语句
#include <stdio.h>
int main() {
printf("--- 表达式与语句示例 ---\n");
// 1. 声明语句
int a = 10;
int b = 5;
// 2. 表达式语句
a = a + b; // a + b 是表达式,a = a + b; 是表达式语句
printf("a = %d\n", a);
// 3. 复合语句 (块)
if (a > 0) { // if 语句的条件是表达式 (a > 0)
int temp = a * 2; // 局部变量,只在块内有效
printf("在块内: temp = %d\n", temp);
a = temp;
}
// printf("在块外: temp = %d\n", temp); // 错误:temp 不可见
// 4. 空语句
int count = 0;
while (count < 5) {
// 模拟一些操作
printf("计数: %d\n", count);
count++;
}
// 另一个空语句的例子:
// while (getchar() != '\n'); // 等待用户输入换行符,不执行任何操作
printf("\n--- 表达式与语句示例结束 ---\n");
return 0;
}
小结: 数据类型、运算符和表达式是C语言最基本的“原子”和“动词”,理解它们是编写任何C程序的起点。特别是位运算符,在嵌入式开发中更是你的“秘密武器”,务必熟练掌握其原理和应用。
第三章:控制流——程序的“大脑”
兄弟们,程序不是一根筋的,它需要根据不同的情况做出“决策”,需要重复执行某些任务。这就是控制流的作用!控制流语句就像程序的“大脑”,指挥着代码的执行路径,让你的程序变得“智能”起来。
3.1 条件语句:程序的“决策者”
3.1.1 if-else:最基本的决策
-
基本语法:
if (condition) { // 如果条件为真 (非零),执行这里的代码 } else { // 如果条件为假 (零),执行这里的代码 } -
else if结构: 用于处理多个互斥条件。if (condition1) { // 如果条件1为真 } else if (condition2) { // 如果条件1为假,且条件2为真 } else { // 如果所有条件都为假 } -
注意:
-
条件表达式必须放在括号
()中。 -
如果
if或else后面只有一条语句,可以省略花括号{},但为了代码清晰和避免错误,强烈建议始终使用花括号。
-
#include <stdio.h>
int main() {
printf("--- if-else 语句示例 ---\n");
int score = 75;
// 简单 if-else
if (score >= 60) {
printf("恭喜你,考试及格了!\n");
} else {
printf("很遗憾,考试不及格。\n");
}
// if-else if-else
char grade;
if (score >= 90) {
grade = 'A';
} else if (score >= 80) {
grade = 'B';
} else if (score >= 70) {
grade = 'C';
} else if (score >= 60) {
grade = 'D';
} else {
grade = 'F';
}
printf("你的成绩是: %c\n", grade);
// 嵌套 if
int temp = 28;
int humidity = 65;
if (temp > 25) {
if (humidity > 60) {
printf("天气炎热且潮湿。\n");
} else {
printf("天气炎热但干燥。\n");
}
} else {
printf("天气凉爽。\n");
}
printf("\n--- if-else 语句示例结束 ---\n");
return 0;
}
分析与注意点:
-
布尔值: 在C语言中,任何非零值都被视为真,零被视为假。
-
悬空else问题: 当有多个
if和一个else时,else总是与最近的未配对的if匹配。使用花括号可以避免这种歧义。 -
大厂面试考点:
if-else if和switch-case的选择-
if-else if: 适用于条件是范围判断(如score >= 90)、复杂逻辑表达式、或条件之间存在重叠的情况。 -
switch-case: 适用于条件是离散的、固定的整数值(或字符)的情况,通常比长串的if-else if更清晰、更高效(编译器可能优化为跳表)。
-
3.1.2 switch-case:多分支选择
-
语法:
switch (expression) { case constant_expression1: // 如果 expression 的值等于 constant_expression1,执行这里的代码 break; // 必须有 break,否则会“穿透” case constant_expression2: // 如果 expression 的值等于 constant_expression2,执行这里的代码 break; default: // 如果没有匹配任何 case,执行这里的代码 (可选) break; } -
注意:
-
expression必须是整型表达式(包括char)。 -
constant_expression必须是常量表达式,不能是变量。 -
break关键字: 极其重要!如果没有break,程序会继续执行下一个case的代码,直到遇到break或switch语句结束(称为“穿透”或“fall-through”)。 -
default关键字: 可选,用于处理所有case都不匹配的情况。
-
#include <stdio.h>
int main() {
printf("--- switch-case 语句示例 ---\n");
int day = 3; // 假设今天是星期三
switch (day) {
case 1:
printf("今天是星期一。\n");
break;
case 2:
printf("今天是星期二。\n");
break;
case 3:
printf("今天是星期三。\n");
break;
case 4:
printf("今天是星期四。\n");
break;
case 5:
printf("今天是星期五。\n");
break;
case 6:
printf("今天是星期六。\n");
break;
case 7:
printf("今天是星期日。\n");
break;
default:
printf("无效的星期几。\n");
break;
}
// 示例:穿透 (Fall-through) 的使用 (慎用,但有时有用)
char ch = 'a';
printf("\n--- 穿透示例 (Fall-through) ---\n");
switch (ch) {
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
printf("%c 是一个元音字母。\n", ch);
break;
default:
printf("%c 是一个辅音字母。\n", ch);
break;
}
printf("\n--- switch-case 语句示例结束 ---\n");
return 0;
}
分析与注意点:
-
穿透: 在某些情况下,利用
switch的穿透特性可以简化代码(如上面元音字母的例子),但必须非常小心,并添加注释说明,避免误解。 -
大厂面试考点:
switch-case的优化-
对于
switch语句,编译器可能会将其优化为跳表(Jump Table)。当case值是连续的或分布比较密集时,编译器会创建一个地址表,直接根据expression的值查表跳转到对应的case代码块,这比一系列if-else if的条件判断效率更高。 -
如果
case值非常稀疏或不连续,编译器可能仍然会使用一系列if-else if进行比较。
-
3.2 循环语句:任务的“永动机”
兄弟们,重复性的任务,交给循环就对了!C语言提供了三种基本的循环语句,让你的程序成为永不停歇的“任务永动机”!
3.2.1 for 循环:计数型循环的王者
-
语法:
for (initialization; condition; increment/decrement) { // 循环体 }-
initialization:循环开始前执行一次,通常用于初始化循环变量。 -
condition:每次循环开始前判断,如果为真,则执行循环体;为假,则退出循环。 -
increment/decrement:每次循环体执行完毕后执行,通常用于更新循环变量。
-
-
特点: 适合已知循环次数的场景。
#include <stdio.h>
int main() {
printf("--- for 循环示例 ---\n");
// 1. 简单计数
printf("从 1 数到 5:\n");
for (int i = 1; i <= 5; i++) {
printf("%d ", i);
}
printf("\n");
// 2. 倒计数
printf("从 5 倒数到 1:\n");
for (int i = 5; i >= 1; i--) {
printf("%d ", i);
}
printf("\n");
// 3. 多个变量控制 (使用逗号运算符)
printf("多个变量控制:\n");
for (int i = 0, j = 10; i < 3; i++, j -= 2) {
printf("i = %d, j = %d\n", i, j);
}
// 4. 省略部分表达式 (慎用,可能导致无限循环)
printf("\n--- 省略部分表达式 (无限循环,按 Ctrl+C 退出) ---\n");
// int k = 0;
// for ( ; ; ) { // 无限循环
// printf("k = %d\n", k++);
// if (k > 5) break; // 必须有退出条件
// }
printf("\n--- for 循环示例结束 ---\n");
return 0;
}
3.2.2 while 循环:条件型循环的基石
-
语法:
while (condition) { // 循环体 } -
特点: 先判断条件,再执行循环体。适合循环次数未知,但知道何时终止的场景。
#include <stdio.h>
int main() {
printf("--- while 循环示例 ---\n");
// 1. 简单计数
int count = 1;
printf("从 1 数到 5:\n");
while (count <= 5) {
printf("%d ", count);
count++;
}
printf("\n");
// 2. 用户输入直到特定值
int input_val;
printf("请输入一个数字 (输入 0 退出):\n");
while (scanf("%d", &input_val) == 1 && input_val != 0) {
printf("你输入了: %d\n", input_val);
printf("请继续输入 (输入 0 退出):\n");
}
printf("已退出输入循环。\n");
printf("\n--- while 循环示例结束 ---\n");
return 0;
}
3.2.3 do-while 循环:至少执行一次的循环
-
语法:
do { // 循环体 } while (condition); -
特点: 先执行一次循环体,再判断条件。适合至少需要执行一次的场景。
#include <stdio.h>
int main() {
printf("--- do-while 循环示例 ---\n");
// 1. 至少执行一次
int count = 0;
printf("do-while 循环 (即使条件不满足也会执行一次):\n");
do {
printf("当前计数: %d\n", count);
count++;
} while (count < 0); // 条件为假,但循环体仍执行了一次
// 2. 密码验证 (至少尝试一次)
char password[20];
int attempts = 0;
const char *correct_password = "admin";
do {
printf("请输入密码: ");
scanf("%s", password);
attempts++;
if (strcmp(password, correct_password) != 0) {
printf("密码错误!请重试。\n");
}
} while (strcmp(password, correct_password) != 0 && attempts < 3);
if (strcmp(password, correct_password) == 0) {
printf("密码正确,欢迎!\n");
} else {
printf("尝试次数过多,账户锁定。\n");
}
printf("\n--- do-while 循环示例结束 ---\n");
return 0;
}
3.2.4 循环嵌套:多层循环的“舞蹈”
-
概念: 一个循环内部包含另一个循环。
-
用途: 遍历二维数组、矩阵操作、生成图案等。
#include <stdio.h>
int main() {
printf("--- 循环嵌套示例 ---\n");
// 1. 打印九九乘法表
printf("\n--- 九九乘法表 ---\n");
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= i; j++) {
printf("%d*%d=%-2d ", j, i, i * j); // %-2d 左对齐,占2位
}
printf("\n");
}
// 2. 遍历二维数组
printf("\n--- 遍历二维数组 ---\n");
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
printf("%d ", matrix[i][j]);
}
printf("\n");
}
printf("\n--- 循环嵌套示例结束 ---\n");
return 0;
}
大厂面试考点:循环效率优化
-
减少循环次数: 算法优化是根本。
-
减少循环体内的操作: 将不变的计算移到循环外部。
-
避免函数调用: 频繁的函数调用有开销,考虑内联或宏。
-
使用合适的循环类型: 并非所有场景都适合
for,有时while或do-while更自然。 -
缓存优化: 尽可能按内存连续性访问数据,提高缓存命中率。
-
并行化: 在多核CPU上使用多线程或OpenMP等进行并行计算。
-
位操作: 利用位运算符进行高效计算。
3.3 跳转语句:程序的“捷径”
兄弟们,有时候程序执行到一半,你可能想“跳过”一些代码,或者“提前结束”某个循环。这时候,跳转语句就是你的“捷径”!
3.3.1 break:跳出循环或switch
-
功能: 立即终止当前所在的
for、while、do-while循环或switch语句,程序控制流将跳转到循环或switch语句的下一条语句。 -
注意:
break只跳出最近的一层循环或switch。
#include <stdio.h>
int main() {
printf("--- break 语句示例 ---\n");
// 1. 在 for 循环中使用 break
printf("在 for 循环中找到 3 时退出:\n");
for (int i = 1; i <= 5; i++) {
if (i == 3) {
printf("找到 3,退出循环。\n");
break; // 跳出当前 for 循环
}
printf("%d ", i);
}
printf("\n循环结束。\n");
// 2. 在嵌套循环中使用 break
printf("\n在嵌套循环中找到 (2,2) 时退出内层循环:\n");
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 3; j++) {
if (i == 2 && j == 2) {
printf("找到 (2,2),退出内层循环。\n");
break; // 只跳出内层 for 循环
}
printf("(%d,%d) ", i, j);
}
printf("\n");
}
printf("所有循环结束。\n");
printf("\n--- break 语句示例结束 ---\n");
return 0;
}
3.3.2 continue:跳过当前循环的剩余部分
-
功能: 立即跳过当前循环体中
continue语句之后的所有代码,进入下一次循环的迭代。 -
注意:
continue只影响最近的一层循环。
#include <stdio.h>
int main() {
printf("--- continue 语句示例 ---\n");
// 1. 在 for 循环中使用 continue
printf("跳过数字 3:\n");
for (int i = 1; i <= 5; i++) {
if (i == 3) {
printf("跳过 3。\n");
continue; // 跳过当前循环的剩余部分,进入下一次迭代
}
printf("%d ", i);
}
printf("\n循环结束。\n");
// 2. 在 while 循环中使用 continue
int count = 0;
printf("\n跳过偶数:\n");
while (count < 5) {
count++;
if (count % 2 == 0) {
printf("跳过偶数 %d。\n", count);
continue;
}
printf("处理奇数 %d。\n", count);
}
printf("\n循环结束。\n");
printf("\n--- continue 语句示例结束 ---\n");
return 0;
}
3.3.3 goto:无条件跳转的“双刃剑”
-
语法:
goto label; -
功能: 无条件地将程序控制流跳转到由
label:标记的语句处。 -
优点(有限):
-
跳出多层循环: 在多层嵌套循环中,
goto可以直接跳出所有循环,比使用多个break和标志位更简洁。 -
错误处理: 在函数中,当发生错误时,可以
goto到一个统一的错误处理入口,进行资源清理。
-
-
缺点(巨大):
-
破坏程序结构: 导致代码难以阅读、理解和维护,形成“意大利面条式代码”。
-
难以调试: 跳转路径不清晰,增加调试难度。
-
不推荐使用: 除了极少数特定场景,应尽量避免使用
goto。
-
代码示例:goto 的使用(演示其在跳出多层循环和错误处理中的“合理性”)
#include <stdio.h>
#include <stdlib.h> // For malloc, free
int main() {
printf("--- goto 语句示例 ---\n");
// 1. 跳出多层循环
printf("\n--- 跳出多层循环示例 --- \n");
int i, j, k;
for (i = 0; i < 3; i++) {
for (j = 0; j < 3; j++) {
for (k = 0; k < 3; k++) {
printf("(%d,%d,%d) ", i, j, k);
if (i == 1 && j == 1 && k == 1) {
printf("找到特定条件,跳出所有循环!\n");
goto end_loops; // 直接跳转到标签处
}
}
}
}
end_loops: // 标签
printf("所有循环已退出。\n");
// 2. 统一错误处理和资源清理
printf("\n--- 统一错误处理示例 --- \n");
FILE *fp1 = NULL;
FILE *fp2 = NULL;
char *buffer = NULL;
fp1 = fopen("file1.txt", "r");
if (fp1 == NULL) {
perror("打开 file1.txt 失败");
goto error_exit; // 跳转到错误处理
}
printf("file1.txt 打开成功。\n");
fp2 = fopen("file2.txt", "w");
if (fp2 == NULL) {
perror("打开 file2.txt 失败");
goto error_exit; // 跳转到错误处理
}
printf("file2.txt 打开成功。\n");
buffer = (char *)malloc(100);
if (buffer == NULL) {
perror("分配内存失败");
goto error_exit; // 跳转到错误处理
}
printf("内存分配成功。\n");
// 模拟正常操作
printf("执行正常操作...\n");
// ...
// 正常退出时的资源清理
printf("正常退出,清理资源。\n");
if (buffer) free(buffer);
if (fp2) fclose(fp2);
if (fp1) fclose(fp1);
printf("资源清理完成。\n");
goto program_end; // 跳转到程序结束
error_exit: // 错误处理标签
printf("发生错误,进行错误处理和资源清理。\n");
if (buffer) free(buffer); // 确保已分配的内存被释放
if (fp2) fclose(fp2); // 确保已打开的文件被关闭
if (fp1) fclose(fp1);
printf("错误处理和资源清理完成。\n");
program_end: // 程序结束标签
printf("\n--- goto 语句示例结束 ---\n");
return 0;
}
分析与注意点:
-
跳出多层循环:
goto在这种场景下确实能简化代码,但现代C++和一些C编程规范中,更推荐使用标志位或将循环封装到函数中,利用return来实现。 -
错误处理: 在资源清理的场景下,
goto可以避免重复的代码块,确保在任何错误路径下都能正确释放资源。这在嵌入式中,尤其是在驱动程序或底层库中,有时会被采用。 -
可读性与维护性: 尽管
goto有其“合理”的应用场景,但其对代码结构破坏性巨大,应极其谨慎地使用,并确保跳转路径清晰,避免滥用。
小结: 控制流语句是C语言程序的“大脑”,它们决定了程序的执行路径。熟练掌握 if-else、switch-case、for、while、do-while 以及 break、continue,能让你编写出逻辑清晰、功能强大的程序。而对于 goto,则要像对待“核武器”一样谨慎,了解其原理和有限的适用场景,但尽量避免使用。
第四章:函数——模块化编程的“基石”
兄弟们,写程序可不是把所有代码都堆在一个 main 函数里!那样代码会变得又臭又长,难以阅读,更别提维护了!这时候,**函数(Function)**就是你的“模块化编程基石”!它能让你把程序分解成一个个独立、可复用的功能单元,让你的代码变得清晰、高效、易于管理。
4.1 函数定义与声明:构建你的“功能模块”
4.1.1 函数的定义
-
语法:
return_type function_name(parameter_list) { // 函数体:实现功能的代码 // return value; // 如果有返回值 }-
return_type:函数返回值的类型。可以是任何C语言数据类型,包括void(表示不返回任何值)。 -
function_name:函数的名称。 -
parameter_list:函数的参数列表,由零个或多个参数组成,每个参数包括类型和名称。 -
{}:函数体,包含函数要执行的语句。
-
#include <stdio.h>
// 1. 无参数,无返回值
void print_hello() {
printf("Hello, 模块化编程!\n");
}
// 2. 有参数,无返回值
void greet(const char *name) { // const char* 表示 name 是一个指向常量的指针,函数内部不能修改它
printf("你好, %s!\n", name);
}
// 3. 有参数,有返回值
int add(int a, int b) {
int sum = a + b;
return sum; // 返回两个整数的和
}
// 4. 计算阶乘 (递归函数示例)
long long factorial(int n) {
if (n == 0 || n == 1) {
return 1;
} else {
return n * factorial(n - 1); // 递归调用
}
}
int main() {
printf("--- 函数定义示例 ---\n");
print_hello(); // 调用无参数无返回值函数
greet("硬核玩家"); // 调用有参数无返回值函数
int num1 = 10, num2 = 20;
int sum_result = add(num1, num2); // 调用有参数有返回值函数
printf("%d + %d = %d\n", num1, num2, sum_result);
int fact_num = 5;
long long fact_result = factorial(fact_num);
printf("%d 的阶乘是: %lld\n", fact_num, fact_result);
printf("\n--- 函数定义示例结束 ---\n");
return 0;
}
分析与注意点:
-
void返回类型: 如果函数不返回任何值,则返回类型为void。 -
return语句: 用于从函数中返回一个值(如果函数有返回值类型)并终止函数的执行。如果函数返回类型是void,return语句可以省略,或者只写return;。 -
参数类型: 函数参数在定义时必须指定类型。
-
局部变量: 函数体内定义的变量是局部变量,只在函数内部有效,函数执行完毕后会被销毁。
4.1.2 函数的声明(函数原型)
-
概念: 函数声明(或函数原型)告诉编译器函数的名称、返回类型和参数列表。它通常放在头文件(
.h文件)中,或者在使用函数之前。 -
为什么需要声明?
-
编译器检查: 允许编译器在函数调用之前检查参数类型和数量是否匹配,以及返回值是否正确使用。这有助于捕获类型不匹配的错误。
-
代码组织: 允许将函数定义放在源文件(
.c文件)的任何位置,甚至在不同的源文件中,只要在使用它之前有声明即可。 -
模块化: 在大型项目中,通过头文件发布函数接口,隐藏实现细节。
-
-
语法:
return_type function_name(parameter_type1, parameter_type2, ...);-
参数名称在声明中是可选的,但为了可读性,通常会保留。
-
代码示例:函数声明与定义分离
// filename: my_math.h (头文件)
#ifndef MY_MATH_H // 防止头文件被重复包含
#define MY_MATH_H
// 函数声明 (函数原型)
int multiply(int a, int b);
double divide(double a, double b);
#endif // MY_MATH_H
```c
// filename: my_math.c (源文件)
#include "my_math.h" // 包含自己的头文件
#include <stdio.h> // 包含标准库头文件
// 函数定义
int multiply(int a, int b) {
printf("正在执行乘法运算...\n");
return a * b;
}
// 函数定义
double divide(double a, double b) {
if (b == 0) {
fprintf(stderr, "错误: 除数不能为零!\n");
return 0.0; // 返回一个错误值或处理错误
}
printf("正在执行除法运算...\n");
return a / b;
}
```c
// filename: main.c (主程序文件)
#include <stdio.h>
#include "my_math.h" // 包含自定义的头文件
int main() {
printf("--- 函数声明与定义分离示例 ---\n");
int prod_result = multiply(5, 4); // 调用 my_math.c 中定义的函数
printf("5 * 4 = %d\n", prod_result);
double div_result = divide(10.0, 3.0);
printf("10.0 / 3.0 = %.2f\n", div_result);
div_result = divide(10.0, 0.0); // 演示除零错误处理
printf("10.0 / 0.0 = %.2f\n", div_result);
printf("\n--- 函数声明与定义分离示例结束 ---\n");
return 0;
}
编译方式:
gcc main.c my_math.c -o my_app
或者使用Makefile(后面会讲到)
分析与注意点:
-
头文件保护:
#ifndef MY_MATH_H,#define MY_MATH_H,#endif是标准的头文件保护宏,防止头文件被多次包含导致编译错误。 -
模块化: 将函数声明放在头文件中,定义放在源文件中,是C语言模块化编程的基本原则。这使得代码结构清晰,易于维护和复用。
-
嵌入式应用: 在嵌入式项目中,驱动程序、板级支持包(BSP)、中间件等通常都以这种方式组织,通过头文件提供接口,隐藏底层实现。
4.2 参数传递:传值与传址的“乾坤大挪移”
兄弟们,函数调用时,参数是怎么从调用者传递给被调用者的?这里面可大有学问,涉及到C语言最核心的“乾坤大挪移”——传值(Pass by Value)和传址(Pass by Pointer)!
4.2.1 传值(Pass by Value):参数的“副本”
-
原理: 当你将一个变量作为参数传给函数时,函数会接收到这个变量的一个副本。函数内部对这个副本的任何修改,都不会影响到原始变量。
-
特点:
-
安全:函数不会意外修改原始数据。
-
开销:如果参数是大型数据结构(如大数组、大结构体),复制会带来额外的内存和时间开销。
-
-
适用场景: 当函数只需要读取参数的值,不需要修改原始数据时。
#include <stdio.h>
void increment_by_value(int num) {
printf("函数内: 原始 num = %d\n", num);
num++; // 修改的是副本
printf("函数内: 修改后 num = %d\n", num);
}
int main() {
printf("--- 传值示例 ---\n");
int my_num = 10;
printf("主函数: 调用前 my_num = %d\n", my_num);
increment_by_value(my_num); // 传递 my_num 的副本
printf("主函数: 调用后 my_num = %d (未改变)\n", my_num);
printf("\n--- 传值示例结束 ---\n");
return 0;
}
4.2.2 传址(Pass by Pointer):参数的“本体”
-
原理: 当你将一个变量的**地址(指针)**作为参数传给函数时,函数接收到的是这个地址。通过这个地址,函数可以直接访问并修改原始变量的值。
-
特点:
-
高效:只传递一个地址(通常4或8字节),无论原始数据多大,开销都固定且很小。
-
可修改性:函数可以修改原始数据。
-
危险:如果使用不当,可能导致空指针解引用、野指针等问题。
-
-
适用场景:
-
函数需要修改原始数据时。
-
传递大型数据结构(如大数组、大结构体)以提高效率时。
-
函数需要“返回”多个值时(通过修改指针指向的变量)。
-
#include <stdio.h>
void increment_by_pointer(int *num_ptr) { // 接收一个指向 int 的指针
printf("函数内: 原始 *num_ptr = %d\n", *num_ptr);
(*num_ptr)++; // 解引用指针,修改原始变量的值
printf("函数内: 修改后 *num_ptr = %d\n", *num_ptr);
}
// 示例:函数通过指针“返回”多个值
void get_min_max(int a, int b, int *min_val_ptr, int *max_val_ptr) {
if (a < b) {
*min_val_ptr = a;
*max_val_ptr = b;
} else {
*min_val_ptr = b;
*max_val_ptr = a;
}
}
int main() {
printf("--- 传址示例 ---\n");
int my_num = 10;
printf("主函数: 调用前 my_num = %d\n", my_num);
increment_by_pointer(&my_num); // 传递 my_num 的地址
printf("主函数: 调用后 my_num = %d (已改变)\n", my_num);
printf("\n--- 通过指针返回多个值示例 ---\n");
int val1 = 15, val2 = 8;
int min_result, max_result;
get_min_max(val1, val2, &min_result, &max_result);
printf("val1 = %d, val2 = %d\n", val1, val2);
printf("最小值 = %d, 最大值 = %d\n", min_result, max_result);
printf("\n--- 传址示例结束 ---\n");
return 0;
}
大厂面试考点:传值与传址的区别与应用
-
区别: 传值是复制,传址是传递地址。
-
何时用传值: 参数是基本数据类型且函数不需要修改原始值,或参数是小结构体且不考虑性能。
-
何时用传址:
-
函数需要修改原始数据(如
scanf)。 -
传递大型数据结构(数组、结构体)以避免复制开销。
-
函数需要“返回”多个值。
-
-
const关键字: 如果函数通过指针接收参数但不需要修改原始数据,应该使用const关键字修饰指针,如void print_data(const struct Data *data_ptr)。这是一种良好的编程习惯,能防止意外修改,并提高代码可读性。
4.3 函数返回值:函数的“输出”
-
概念: 函数通过
return语句将一个值返回给调用者。 -
类型: 返回值的类型必须与函数定义中声明的返回类型一致。如果函数不返回任何值,则返回类型为
void。 -
注意:
-
不要返回局部变量的地址(指针)! 局部变量在函数返回后会被销毁,其地址将成为野指针。
-
函数可以返回结构体,但会进行一次结构体内容的复制。对于大型结构体,通常建议通过指针参数来“返回”结果。
-
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 返回一个 int 值
int multiply(int a, int b) {
return a * b;
}
// 返回一个字符串常量 (安全)
const char* get_status_message(int status_code) {
switch (status_code) {
case 0: return "成功";
case 1: return "失败";
case 2: return "进行中";
default: return "未知状态";
}
}
// 错误示例:返回局部变量的地址 (危险!)
// int* create_local_int() {
// int local_var = 100;
// return &local_var; // 错误!local_var 在函数返回后被销毁
// }
// 正确示例:返回动态分配的内存地址
int* create_dynamic_int() {
int *dynamic_ptr = (int *)malloc(sizeof(int));
if (dynamic_ptr == NULL) {
perror("内存分配失败");
return NULL;
}
*dynamic_ptr = 200;
return dynamic_ptr; // 返回堆内存的地址,调用者负责释放
}
int main() {
printf("--- 函数返回值示例 ---\n");
int prod = multiply(7, 8);
printf("乘积: %d\n", prod);
const char *msg = get_status_message(1);
printf("状态信息: %s\n", msg);
// 错误示例调用 (如果取消注释,可能导致运行时错误)
// int *bad_ptr = create_local_int();
// printf("错误示例: %d\n", *bad_ptr); // 访问野指针
// 正确示例调用
int *good_ptr = create_dynamic_int();
if (good_ptr != NULL) {
printf("动态分配的整数: %d\n", *good_ptr);
free(good_ptr); // 释放内存
good_ptr = NULL;
}
printf("\n--- 函数返回值示例结束 ---\n");
return 0;
}
4.4 递归函数:自我调用的“魔术”
-
概念: 递归函数是指在函数体内部调用自身的函数。
-
构成要素:
-
基线条件(Base Case): 递归的终止条件。当满足基线条件时,函数不再递归调用自身,直接返回一个结果。这是防止无限递归的关键!
-
递归步(Recursive Step): 在每次递归调用中,问题规模必须向基线条件靠近。
-
-
用途: 解决可以分解为相同子问题的问题,例如阶乘、斐波那契数列、树的遍历、快速排序等。
#include <stdio.h>
// 1. 计算阶乘 (经典递归示例)
long long factorial_recursive(int n) {
printf("计算 factorial(%d)\n", n); // 跟踪递归调用
if (n == 0 || n == 1) { // 基线条件
printf("factorial(%d) 返回 1\n", n);
return 1;
} else { // 递归步
long long result = n * factorial_recursive(n - 1);
printf("factorial(%d) 返回 %lld\n", n, result);
return result;
}
}
// 2. 斐波那契数列 (效率较低,有重复计算)
int fibonacci_recursive(int n) {
if (n <= 1) { // 基线条件
return n;
} else { // 递归步
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2);
}
}
int main() {
printf("--- 递归函数示例 ---\n");
int num_fact = 4;
printf("\n计算 %d 的阶乘:\n", num_fact);
long long fact_res = factorial_recursive(num_fact);
printf("最终结果: %d! = %lld\n", num_fact, fact_res);
int num_fib = 6;
printf("\n计算斐波那契数列第 %d 项:\n", num_fib);
int fib_res = fibonacci_recursive(num_fib);
printf("斐波那契数列第 %d 项是: %d\n", num_fib, fib_res);
printf("\n--- 递归函数示例结束 ---\n");
return 0;
}
大厂面试考点:递归的优缺点及避免栈溢出
-
优点:
-
代码简洁、优雅,符合某些问题的自然定义(如树的遍历)。
-
易于理解和编写(对于适合递归的问题)。
-
-
缺点:
-
性能开销: 每次函数调用都会产生额外的栈帧开销(保存参数、局部变量、返回地址)。
-
栈溢出: 如果递归深度过大,可能导致栈空间耗尽,引发栈溢出错误。
-
重复计算: 某些递归算法(如朴素斐波那契数列)存在大量重复计算,效率低下。
-
-
避免栈溢出:
-
尾递归优化: 如果编译器支持尾递归优化,可以将尾递归函数转换为迭代形式,避免栈帧的累积。
-
将递归转换为迭代: 大多数递归问题都可以通过循环(迭代)来解决,这通常更高效且不会导致栈溢出。
-
增加栈空间: 临时解决方案,治标不治本。
-
-
嵌入式应用: 在嵌入式系统中,栈空间通常非常有限,因此在编写递归函数时需要格外小心,评估其最大递归深度,并优先考虑迭代实现。
小结: 函数是C语言模块化编程的基石。掌握函数的定义、声明、参数传递(传值与传址)、返回值,以及递归的使用,能让你编写出结构清晰、可维护、可复用的高质量代码。特别是在嵌入式开发中,合理地划分函数模块,并注意参数传递的效率和内存安全,是至关重要的。
第五章:数组与字符串——数据的“集合”与“文本”
兄弟们,程序不仅仅是计算,更多时候是在处理数据的“集合”和“文本”!C语言中的数组就是用来存储同类型数据集合的利器,而字符串则是处理文本信息的基石。理解它们在内存中的布局,以及数组和指针之间“剪不断理还乱”的关系,是C语言进阶的必经之路!
5.1 数组:同类型数据的“集合”
5.1.1 一维数组:线性的数据序列
-
概念: 数组是存储相同类型数据元素的连续内存块。
-
定义语法:
type array_name[size];-
size必须是编译时常量表达式(C99及以后支持变长数组,但通常不推荐在嵌入式中使用)。
-
-
初始化:
-
完全初始化:
int arr[5] = {1, 2, 3, 4, 5}; -
部分初始化:
int arr[5] = {1, 2};(未初始化的元素自动为0)。 -
省略大小:
int arr[] = {1, 2, 3};(编译器根据初始化列表推断大小)。
-
-
访问: 使用下标运算符
[],索引从0开始。-
array_name[index]
-
-
注意: C语言不对数组下标进行越界检查!访问越界会导致未定义行为,这是常见的Bug来源!
#include <stdio.h>
int main() {
printf("--- 一维数组示例 ---\n");
// 1. 定义和初始化
int numbers[5] = {10, 20, 30, 40, 50}; // 完全初始化
float temperatures[3] = {25.5f, 26.0f}; // 部分初始化,第三个元素为 0.0f
char vowels[] = {'a', 'e', 'i', 'o', 'u'}; // 省略大小,编译器推断为 5
printf("numbers 数组:\n");
for (int i = 0; i < 5; i++) {
printf("numbers[%d] = %d\n", i, numbers[i]);
}
printf("\ntemperatures 数组:\n");
for (int i = 0; i < 3; i++) {
printf("temperatures[%d] = %.1f\n", i, temperatures[i]);
}
// 2. 访问和修改元素
numbers[2] = 35; // 修改第三个元素
printf("\n修改后 numbers[2] = %d\n", numbers[2]);
// 3. 数组大小 (使用 sizeof)
printf("numbers 数组大小 (字节): %zu\n", sizeof(numbers));
printf("numbers 数组元素个数: %zu\n", sizeof(numbers) / sizeof(numbers[0]));
// 4. 数组越界访问 (危险操作,可能导致崩溃或未定义行为)
// printf("尝试越界访问 numbers[5]: %d\n", numbers[5]);
printf("\n--- 一维数组示例结束 ---\n");
return 0;
}
5.1.2 多维数组:表格型的数据结构
-
概念: 数组的数组,可以模拟表格或矩阵。最常见的是二维数组。
-
定义语法:
type array_name[row_size][col_size]; -
内存布局: 在内存中仍然是连续存储的,按照“行优先”的顺序存储。
-
初始化:
-
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}}; -
也可以省略第一维的大小:
int matrix[][3] = {{1, 2, 3}, {4, 5, 6}};
-
#include <stdio.h>
int main() {
printf("--- 多维数组示例 ---\n");
// 1. 定义和初始化二维数组
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
printf("矩阵内容:\n");
for (int i = 0; i < 3; i++) { // 遍历行
for (int j = 0; j < 4; j++) { // 遍历列
printf("%2d ", matrix[i][j]); // 格式化输出,占2位
}
printf("\n");
}
// 2. 访问和修改元素
matrix[1][1] = 66; // 修改第二行第二列的元素
printf("\n修改后 matrix[1][1] = %d\n", matrix[1][1]);
// 3. 多维数组的内存布局 (行优先)
printf("\n多维数组的内存地址 (连续存储):\n");
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("matrix[%d][%d] 地址: %p\n", i, j, (void*)&matrix[i][j]);
}
}
printf("\n--- 多维数组示例结束 ---\n");
return 0;
}
分析与注意点:
-
内存连续性: 数组在内存中是连续存储的,这是其高效访问的基础。
-
数组越界: 再次强调,C语言不进行数组越界检查,这是最常见的Bug之一。在嵌入式中,越界访问可能导致数据损坏、程序崩溃,甚至被恶意利用。始终确保你的数组访问在合法范围内。
-
嵌入式应用: 数组常用于存储传感器数据、图像帧、查找表、缓冲区等。
5.2 数组与指针的关系:剪不断理还乱的“双生子”
兄弟们,在C语言里,数组和指针的关系,简直就是“剪不断理还乱”的“双生子”!它们既有紧密的联系,又有本质的区别。理解这种关系,是C语言进阶的“分水岭”!
5.2.1 数组名作为指针常量
-
核心: 在大多数表达式中,数组名会被自动转换为指向其第一个元素的指针常量。
-
指针常量: 这意味着数组名本身是一个地址,但你不能修改这个地址(即不能让数组名指向其他地方)。
-
等价性:
array_name[i]等价于*(array_name + i)。
#include <stdio.h>
int main() {
printf("--- 数组名作为指针常量示例 ---\n");
int arr[] = {10, 20, 30, 40, 50};
int size = sizeof(arr) / sizeof(arr[0]);
printf("arr 的地址: %p\n", (void*)arr); // 数组首元素的地址
printf("&arr[0] 的地址: %p\n", (void*)&arr[0]); // 数组首元素的地址
// 数组名作为指针使用
printf("通过数组名作为指针访问元素:\n");
for (int i = 0; i < size; i++) {
printf("arr[%d] = %d, *(arr + %d) = %d\n", i, arr[i], i, *(arr + i));
}
// 数组名是一个指针常量,不能被赋值
// arr = &some_other_array; // 错误:数组名不能作为左值
// 但是,可以将数组名赋值给一个指针变量
int *ptr = arr; // ptr 指向 arr 的第一个元素
printf("\nptr 指向 arr 的第一个元素,地址: %p\n", (void*)ptr);
printf("通过指针 ptr 访问元素:\n");
for (int i = 0; i < size; i++) {
printf("ptr[%d] = %d, *(ptr + %d) = %d\n", i, ptr[i], i, *(ptr + i));
}
printf("\n--- 数组名作为指针常量示例结束 ---\n");
return 0;
}
分析与注意点:
-
arrvs&arr:-
arr:在大多数表达式中,它代表数组首元素的地址(类型是int*)。 -
&arr:代表整个数组的地址(类型是int (*)[5],即指向包含5个int的数组的指针)。它们的数值可能相同,但类型不同。
-
-
传数组参数: 当数组作为函数参数时,它总是以指针的形式传递(即“退化”为指针)。函数内部无法得知原始数组的大小。
5.2.2 数组作为函数参数的“退化”
-
原理: 当你将一个数组作为参数传递给函数时,C语言实际上是传递了数组的首地址(即一个指针)。数组在函数参数列表中会“退化”为指针。
-
后果: 在函数内部,
sizeof(array_param)得到的是指针的大小,而不是原始数组的大小! -
解决方案: 传递数组时,通常需要额外传递数组的长度作为另一个参数。
#include <stdio.h>
// 数组作为参数,实际上是传递指针
void print_array_info(int arr[], int size) { // int arr[] 等价于 int *arr
printf("函数内: arr 的地址: %p\n", (void*)arr);
printf("函数内: sizeof(arr) = %zu 字节 (这是指针的大小,不是数组大小!)\n", sizeof(arr));
printf("函数内: 数组元素:\n");
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
printf("--- 数组作为函数参数的“退化”示例 ---\n");
int my_data[] = {100, 200, 300, 400, 500};
int my_data_size = sizeof(my_data) / sizeof(my_data[0]);
printf("主函数: my_data 的地址: %p\n", (void*)my_data);
printf("主函数: sizeof(my_data) = %zu 字节 (数组总大小)\n", sizeof(my_data));
printf("主函数: my_data 元素个数 = %d\n", my_data_size);
print_array_info(my_data, my_data_size); // 传递数组名和大小
printf("\n--- 数组作为函数参数的“退化”示例结束 ---\n");
return 0;
}
大厂面试考点:数组和指针的区别 | 特性 | 数组 (e.g., int arr[10];) | 指针 (e.g., int *ptr;) | | :------- | :----------------------------- | :--------------------------------- | | 本质 | 一块连续的内存区域,用于存储同类型元素 | 一个变量,存储内存地址 | | 内存分配 | 编译时分配(栈或全局/静态区) | 编译时分配指针变量本身,运行时分配指向的内存(堆) | | 大小 (sizeof) | 整个数组的总大小(元素个数 * 元素大小) | 指针变量本身的大小(4或8字节,取决于系统) | | 可修改性 | 数组名是常量,不能被赋值改变其指向 | 指针变量是变量,可以被赋值改变其指向 | | 作为函数参数 | 退化为指针,传递的是首地址 | 传递的是地址(副本) | | 下标操作 | arr[i] | ptr[i] (等价于 *(ptr + i)) |
5.3 字符串:以\0结尾的字符数组
兄弟们,字符串在C语言中可不是一个独立的数据类型,它就是一串以空字符 \0 结尾的字符数组!理解这个特性,是正确处理C语言字符串的关键。
5.3.1 字符串的定义与初始化
-
字符数组形式:
char str1[] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 显式包含空字符 char str2[] = "World"; // 编译器会自动添加空字符 '\0' char str3[10] = "C Lang"; // 数组大小大于字符串长度,剩余空间自动填充 '\0' char str4[3] = "ABC"; // 错误!数组太小,无法容纳字符串和 '\0' -
字符串字面量(常量字符串):
-
用双引号
""括起来的文本,存储在程序的只读数据区。 -
const char *ptr = "Hello"; -
注意: 字符串字面量是常量,不能通过指针修改其内容!
-
#include <stdio.h>
#include <string.h> // For strlen
int main() {
printf("--- 字符串定义与初始化示例 ---\n");
// 1. 字符数组形式
char str1[] = {'H', 'e', 'l', 'l', 'o', '\0'};
printf("str1: %s, 长度(strlen): %zu, 大小(sizeof): %zu\n", str1, strlen(str1), sizeof(str1));
char str2[] = "World"; // 编译器自动添加 '\0'
printf("str2: %s, 长度(strlen): %zu, 大小(sizeof): %zu\n", str2, strlen(str2), sizeof(str2));
char str3[10] = "C Lang"; // 数组大小10,字符串长度6,自动填充 '\0'
printf("str3: %s, 长度(strlen): %zu, 大小(sizeof): %zu\n", str3, strlen(str3), sizeof(str3));
// 2. 字符串字面量 (常量字符串)
const char *str_literal = "Programming"; // 存储在只读数据区
printf("str_literal: %s, 长度(strlen): %zu, 大小(sizeof): %zu (指针大小)\n",
str_literal, strlen(str_literal), sizeof(str_literal));
// 尝试修改字符串字面量 (会导致运行时错误或编译警告)
// str_literal[0] = 'P'; // 错误!
printf("\n--- 字符串定义与初始化示例结束 ---\n");
return 0;
}
分析与注意点:
-
\0终止符: 它是字符串的结束标志。所有C标准库的字符串函数都依赖于\0来判断字符串的结束。如果字符串没有以\0结尾,函数可能会越界访问内存,导致未定义行为。 -
strlen()vssizeof():-
strlen():计算字符串的实际长度(不包括\0),在运行时计算。 -
sizeof():计算字符数组的总大小(包括\0),在编译时确定。
-
-
字符串字面量: 它们是常量,不能被修改。如果你想修改字符串,必须将其复制到一个可写的字符数组中。
5.3.2 字符串处理函数:C语言的“文本工具箱”
C语言标准库 <string.h> 提供了大量用于字符串操作的函数。
-
size_t strlen(const char *s);:-
计算字符串
s的长度,不包括终止符\0。
-
-
char *strcpy(char *dest, const char *src);:-
将源字符串
src复制到目标字符串dest。 -
危险! 如果
dest缓冲区不够大,会导致缓冲区溢出。 -
大厂面试考点!
strcpy的安全性问题,如何避免?(使用strncpy或确保目标缓冲区足够大)
-
-
char *strncpy(char *dest, const char *src, size_t n);:-
将源字符串
src的前n个字符复制到目标字符串dest。 -
注意: 如果
src的长度小于n,dest的剩余部分会用\0填充。但如果src的长度大于等于n,strncpy不会添加终止符\0! 你需要手动添加。 -
推荐使用! 相比
strcpy更安全,但需要小心处理\0。
-
-
char *strcat(char *dest, const char *src);:-
将源字符串
src连接到目标字符串dest的末尾。 -
危险! 如果
dest缓冲区不够大,会导致缓冲区溢出。
-
-
char *strncat(char *dest, const char *src, size_t n);:-
将源字符串
src的前n个字符连接到目标字符串dest的末尾。 -
推荐使用! 相比
strcat更安全。
-
-
int strcmp(const char *s1, const char *s2);:-
比较两个字符串
s1和s2。 -
返回
0:如果s1等于s2。 -
返回
<0:如果s1小于s2。 -
返回
>0:如果s1大于s2。
-
-
int strncmp(const char *s1, const char *s2, size_t n);:-
比较两个字符串
s1和s2的前n个字符。
-
-
char *strchr(const char *s, int c);:-
在字符串
s中查找字符c第一次出现的位置。 -
返回指向该字符的指针,如果未找到则返回
NULL。
-
-
char *strstr(const char *haystack, const char *needle);:-
在字符串
haystack中查找子字符串needle第一次出现的位置。 -
返回指向子字符串的指针,如果未找到则返回
NULL。
-
-
int sprintf(char *str, const char *format, ...);:-
将格式化数据写入字符串
str。 -
危险! 如果
str缓冲区不够大,会导致缓冲区溢出。
-
-
int snprintf(char *str, size_t size, const char *format, ...);(C99引入):-
将格式化数据写入字符串
str,最多写入size-1个字符,并自动添加终止符\0。 -
推荐使用! 安全的格式化字符串函数。
-
-
int sscanf(const char *str, const char *format, ...);:-
从字符串
str中读取格式化数据。
-
代码示例:字符串处理函数
#include <stdio.h>
#include <string.h> // 包含字符串处理函数
#include <stdlib.h> // For malloc, free
int main() {
printf("--- 字符串处理函数示例 ---\n");
// 1. strlen
char s1[] = "Hello World";
printf("strlen(\"%s\"): %zu\n", s1, strlen(s1));
// 2. strcpy (危险示例)
char dest_strcpy[5]; // 故意设小,演示潜在危险
// strcpy(dest_strcpy, "Very Long String"); // 这行可能导致缓冲区溢出!
// printf("strcpy 结果: %s\n", dest_strcpy);
// 3. strncpy (安全但需注意 \0)
char dest_strncpy[10];
strncpy(dest_strncpy, "Long String", sizeof(dest_strncpy) - 1);
dest_strncpy[sizeof(dest_strncpy) - 1] = '\0'; // 手动添加终止符
printf("strncpy 结果: %s\n", dest_strncpy);
// 4. strcat (危险示例)
char s_cat_dest[15] = "Hello";
// strcat(s_cat_dest, " Very Long World"); // 这行可能导致缓冲区溢出!
// printf("strcat 结果: %s\n", s_cat_dest);
// 5. strncat (安全)
char s_ncat_dest[15] = "Hello";
strncat(s_ncat_dest, " World", sizeof(s_ncat_dest) - strlen(s_ncat_dest) - 1);
printf("strncat 结果: %s\n", s_ncat_dest);
// 6. strcmp
char s_cmp1[] = "apple";
char s_cmp2[] = "banana";
char s_cmp3[] = "apple";
printf("strcmp(\"%s\", \"%s\"): %d\n", s_cmp1, s_cmp2, strcmp(s_cmp1, s_cmp2)); // < 0
printf("strcmp(\"%s\", \"%s\"): %d\n", s_cmp2, s_cmp1, strcmp(s_cmp2, s_cmp1)); // > 0
printf("strcmp(\"%s\", \"%s\"): %d\n", s_cmp1, s_cmp3, strcmp(s_cmp1, s_cmp3)); // = 0
// 7. strchr
char s_chr[] = "programming";
char *p_chr = strchr(s_chr, 'g');
if (p_chr) {
printf("字符 'g' 第一次出现在: %s\n", p_chr);
}
// 8. strstr
char s_str[] = "This is a test string.";
char *p_str = strstr(s_str, "test");
if (p_str) {
printf("子字符串 \"test\" 第一次出现在: %s\n", p_str);
}
// 9. sprintf (危险示例)
char buffer_sprintf[10];
// sprintf(buffer_sprintf, "Value: %d, String: %s", 123, "Too long string"); // 缓冲区溢出!
// printf("sprintf 结果: %s\n", buffer_sprintf);
// 10. snprintf (安全)
char buffer_snprintf[20];
int chars_written = snprintf(buffer_snprintf, sizeof(buffer_snprintf), "Value: %d, Str: %s", 123, "Hello World");
printf("snprintf 结果: %s, 写入字符数: %d\n", buffer_snprintf, chars_written);
// 如果源字符串太长,会被截断
chars_written = snprintf(buffer_snprintf, sizeof(buffer_snprintf), "Value: %d, Str: %s", 123, "This is a very very very long string.");
printf("snprintf 截断结果: %s, 写入字符数: %d\n", buffer_snprintf, chars_written);
// 11. sscanf
char data_str[] = "Name: John Age: 30 Score: 95.5";
char name[20];
int age;
float score;
sscanf(data_str, "Name: %s Age: %d Score: %f", name, &age, &score);
printf("解析结果: 姓名=%s, 年龄=%d, 分数=%.1f\n", name, age, score);
printf("\n--- 字符串处理函数示例结束 ---\n");
return 0;
}
分析与注意点:
-
安全性优先: 始终优先使用带
n的安全函数(strncpy,strncat,snprintf),并仔细计算缓冲区大小,手动添加\0。 -
缓冲区溢出: 这是C语言中最常见的安全漏洞之一。在嵌入式中,缓冲区溢出可能导致程序崩溃、数据损坏,甚至被恶意代码利用。
-
面试高频考点! 解释
strcpy和strncpy的区别,以及如何编写一个安全的strcpy函数。
手写安全 strcpy 示例:
#include <stdio.h>
#include <string.h> // For size_t
/**
* @brief 安全地复制字符串,防止缓冲区溢出。
* @param dest 目标缓冲区。
* @param dest_size 目标缓冲区的大小(包括终止符空间)。
* @param src 源字符串。
* @return char* 返回 dest 指针。
*/
char* safe_strcpy(char *dest, size_t dest_size, const char *src) {
if (dest == NULL || src == NULL || dest_size == 0) {
// 处理无效参数
return NULL;
}
// 确保至少能容纳终止符
if (dest_size == 1) {
dest[0] = '\0';
return dest;
}
size_t src_len = strlen(src);
size_t copy_len = (src_len < dest_size - 1) ? src_len : (dest_size - 1);
// 复制字符
for (size_t i = 0; i < copy_len; i++) {
dest[i] = src[i];
}
dest[copy_len] = '\0'; // 确保添加终止符
return dest;
}
int main() {
printf("--- 手写安全 strcpy 示例 ---\n");
char buffer[10];
// 正常复制
safe_strcpy(buffer, sizeof(buffer), "Hello");
printf("正常复制: \"%s\"\n", buffer);
// 源字符串过长,会被截断
safe_strcpy(buffer, sizeof(buffer), "This is a very long string.");
printf("截断复制: \"%s\"\n", buffer);
// 目标缓冲区只有1字节
char small_buffer[1];
safe_strcpy(small_buffer, sizeof(small_buffer), "Test");
printf("小缓冲区复制: \"%s\"\n", small_buffer); // 应该只包含 '\0'
printf("\n--- 手写安全 strcpy 示例结束 ---\n");
return 0;
}
小结: 数组和字符串是C语言中处理数据集合和文本的基石。理解数组在内存中的连续性,以及数组名和指针之间的紧密联系,是掌握C语言内存操作的关键。同时,务必牢记字符串以 \0 结尾的特性,并始终使用安全的字符串处理函数,避免缓冲区溢出等常见漏洞。
第一部分总结与展望:你已掌握C语言的“基石与核心”!
兄弟们,恭喜你,已经完成了**《K&R C语言圣经全解:嵌入式硬核玩家的内功心法》的第一部分!**
我们在这部分旅程中,深入探索了:
-
K&R的哲学: 理解了C语言极简、高效、面向系统编程的设计理念,以及它与Unix的紧密联系。
-
数据类型: 彻底搞懂了
char,int,float等基本数据类型的大小、范围和在嵌入式中的选择,特别是<stdint.h>的重要性。 -
运算符: 掌握了算术、关系、逻辑、赋值、条件、逗号等各种运算符的用法,特别是位运算符在嵌入式寄存器操作、标志位控制中的“杀手锏”应用,以及运算符优先级和结合性。
-
控制流: 熟练运用
if-else、switch-case进行程序决策,掌握for、while、do-while循环实现任务自动化,并了解了break、continue、goto的使用场景和注意事项。 -
函数: 理解了函数的定义与声明,掌握了参数传递的“乾坤大挪移”(传值与传址),以及递归函数的原理和潜在的栈溢出风险。
-
数组与字符串: 彻底搞懂了数组的定义、访问和内存布局,特别是数组名和指针之间“剪不断理还乱”的关系;深入理解了C语言字符串以
\0结尾的本质,以及如何安全地使用string.h中的字符串处理函数,规避缓冲区溢出等常见漏洞。
现在,你对C语言的理解,已经从“小白”进阶到了“基石与核心”的层面!你不再是简单的语法使用者,而是开始洞察C语言的底层原理,理解它为何如此强大,又为何如此“危险”。你已经具备了构建任何C语言程序的“原子”和“骨架”!
这仅仅是个开始!在接下来的第二部分中,我们将继续深入,直接杀入C语言的“灵魂”——指针!我们将彻底揭开指针的神秘面纱,深入探索指针的各种复杂组合、指针与数组的深层关系,以及如何正确、安全地使用指针来操作内存!同时,我们还将系统学习内存管理,让你成为真正的“内存大师”!
准备好了吗?第二部分的硬核内容,将让你对C语言的理解达到新的高度,成为真正的“指针魔法师”和“内存大师”!
如果你觉得这份“秘籍”对你有亿点点帮助,请务必点赞、收藏、转发!你的支持是我继续分享干货的最大动力!
我们第二部分再见!祝你学习愉快,内功精进!
----------------------------------------------------------------------------------------------------------------------------------------更新于2025.6.17 下午4点20
【万字血书】K&R C语言圣经全解:嵌入式硬核玩家的内功心法(第二部分)
第六章:指针——C语言的“灵魂”与“魔法棒”
兄弟们,如果你想真正掌握C语言,想在嵌入式、操作系统、驱动开发领域混出名堂,那么**指针(Pointer)**是你必须征服的“珠穆朗玛峰”!指针是C语言的灵魂,是它能够直接操作内存、实现高效和灵活的关键。它就像你手中的“魔法棒”,让你能够直接控制内存,实现各种“乾坤大挪移”!
本章,我将带你彻底揭开指针的神秘面纱,从基本概念到高级用法,从指针运算到指针与数组的复杂关系,让你成为真正的“指针魔法师”!
6.1 指针的基本概念:地址的“门牌号”
6.1.1 什么是内存地址?
-
内存(Memory): 计算机中存储数据的地方,可以想象成一个巨大的格子间,每个格子都有一个唯一的编号。
-
内存地址(Memory Address): 每个格子(字节)的唯一编号,用于标识其在内存中的位置。就像你家房子的门牌号。
-
地址空间: 处理器能够访问的所有内存地址的集合。
6.1.2 什么是指针?
-
指针(Pointer): 一个变量,它存储的是另一个变量的内存地址。
-
本质: 指针变量本身也是一个变量,它也有自己的地址和值。它的“值”就是它所指向的那个变量的地址。
-
类型: 指针变量也有类型,例如
int *表示它指向一个int类型的变量,char *表示它指向一个char类型的变量。指针的类型决定了编译器如何解释它所指向的内存区域。
6.1.3 指针的定义、初始化与解引用
-
定义指针变量:
type *pointer_name;-
type:指针所指向的数据类型。 -
*:星号表示这是一个指针变量。 -
pointer_name:指针变量的名称。
-
-
取地址运算符
&:-
用于获取一个变量的内存地址。
-
&variable_name:返回variable_name的地址。
-
-
解引用运算符
*:-
用于访问指针所指向的内存地址中的值。
-
*pointer_name:返回pointer_name所指向地址中的值。
-
代码示例:指针的基本操作
#include <stdio.h>
int main() {
printf("--- 指针基本概念示例 ---\n");
int num = 100; // 定义一个 int 变量
int *ptr_num; // 定义一个指向 int 类型的指针变量
printf("1. 变量的地址和值:\n");
printf(" num 的值: %d\n", num);
printf(" num 的地址: %p\n", (void*)&num); // 使用 %p 打印地址,并强制转换为 void*
// 2. 初始化指针:将变量的地址赋值给指针
ptr_num = #
printf("\n2. 指针的初始化:\n");
printf(" ptr_num 的值 (它存储的地址): %p\n", (void*)ptr_num);
printf(" ptr_num 自己的地址: %p\n", (void*)&ptr_num); // 指针变量本身也有地址
// 3. 解引用指针:通过指针访问它所指向的值
printf("\n3. 解引用指针:\n");
printf(" 通过 *ptr_num 访问 num 的值: %d\n", *ptr_num);
// 4. 通过指针修改原始变量的值
*ptr_num = 200; // 修改 ptr_num 所指向的内存中的值
printf("\n4. 通过指针修改值:\n");
printf(" 修改后 num 的值: %d\n", num); // num 的值已被修改
printf(" 通过 *ptr_num 访问 num 的新值: %d\n", *ptr_num);
// 5. 不同类型的指针
char ch = 'A';
char *ptr_ch = &ch;
printf("\n5. 不同类型的指针:\n");
printf(" ch 的值: %c, 地址: %p\n", ch, (void*)&ch);
printf(" ptr_ch 的值 (存储的地址): %p, 解引用: %c\n", (void*)ptr_ch, *ptr_ch);
printf("\n--- 指针基本概念示例结束 ---\n");
return 0;
}
分析与注意点:
-
%p格式符: 用于打印内存地址,通常需要将指针强制转换为void*。 -
指针的类型很重要:
int *ptr;告诉编译器ptr指向一个int类型的数据。当你解引用*ptr时,编译器知道要从ptr存储的地址开始读取sizeof(int)字节的数据。 -
未初始化指针(野指针): 定义指针后,如果没有初始化,它会包含一个随机的垃圾值。解引用一个未初始化的指针是非常危险的,会导致程序崩溃或未定义行为。
6.2 指针运算:地址的“加减乘除”
兄弟们,指针可不是只能存地址,它还能进行“算术运算”!但这种运算可不是简单的整数加减,它涉及到指针所指向的数据类型的大小,是C语言特有的“地址算术”!
6.2.1 指针的加减整数
-
pointer + N: 指针向高地址方向移动N * sizeof(type)字节。 -
pointer - N: 指针向低地址方向移动N * sizeof(type)字节。 -
用途: 遍历数组、访问连续内存块。
6.2.2 指针的自增自减
-
pointer++/++pointer: 指针向高地址方向移动sizeof(type)字节。 -
pointer--/--pointer: 指针向低地址方向移动sizeof(type)字节。
6.2.3 指针的相减
-
pointer1 - pointer2: 两个相同类型的指针相减,结果是它们之间相隔的元素个数(而不是字节数)。 -
结果类型:
ptrdiff_t,通常是有符号整数类型。 -
注意: 只有当两个指针指向同一个数组的元素时,相减才有意义。
6.2.4 指针的比较
-
==,!=,<,>,<=,>=: 比较两个指针所存储的地址值。 -
注意: 只有当两个指针指向同一个数组的元素,或者都指向
NULL时,比较才有明确意义。
代码示例:指针运算
#include <stdio.h>
#include <stddef.h> // For ptrdiff_t
int main() {
printf("--- 指针运算示例 ---\n");
int numbers[] = {10, 20, 30, 40, 50};
int *p1 = numbers; // p1 指向 numbers[0]
int *p2 = &numbers[2]; // p2 指向 numbers[2]
printf("原始指针值:\n");
printf("p1 指向 numbers[0] 的地址: %p\n", (void*)p1);
printf("p2 指向 numbers[2] 的地址: %p\n", (void*)p2);
printf("一个 int 占用 %zu 字节\n", sizeof(int));
// 1. 指针加整数
printf("\n1. 指针加整数:\n");
int *p_plus_1 = p1 + 1; // p1 向后移动 1 个 int 的大小
printf("p1 + 1 指向 numbers[1] 的地址: %p, 值: %d\n", (void*)p_plus_1, *p_plus_1);
int *p_plus_3 = p1 + 3; // p1 向后移动 3 个 int 的大小
printf("p1 + 3 指向 numbers[3] 的地址: %p, 值: %d\n", (void*)p_plus_3, *p_plus_3);
// 2. 指针自增
printf("\n2. 指针自增:\n");
int *p_inc = numbers; // 重新初始化
printf("初始 p_inc 指向 numbers[0] 的值: %d\n", *p_inc);
p_inc++; // p_inc 移动到 numbers[1]
printf("p_inc++ 后指向 numbers[1] 的值: %d\n", *p_inc);
p_inc++; // p_inc 移动到 numbers[2]
printf("p_inc++ 后指向 numbers[2] 的值: %d\n", *p_inc);
// 3. 指针相减
printf("\n3. 指针相减:\n");
ptrdiff_t diff = p2 - p1; // (numbers[2] 的地址 - numbers[0] 的地址) / sizeof(int)
printf("p2 - p1 = %td (相隔的 int 元素个数)\n", diff); // 2
// 4. 指针比较
printf("\n4. 指针比较:\n");
if (p1 < p2) {
printf("p1 的地址小于 p2 的地址\n");
}
if (p1 == numbers) {
printf("p1 指向 numbers 数组的起始地址\n");
}
if (p_plus_1 != p2) {
printf("p_plus_1 和 p2 指向不同地址\n");
}
printf("\n--- 指针运算示例结束 ---\n");
return 0;
}
分析与注意点:
-
类型感知: 指针运算是“类型感知”的。
ptr + N并不是简单地将地址值加上N,而是加上N * sizeof(*ptr)。这是C语言指针的精髓所在。 -
数组遍历: 指针运算是遍历数组的常用且高效的方式。
-
越界风险: 指针运算同样存在越界风险。将指针移动到数组范围之外,并解引用它,会导致未定义行为。
6.3 指针与数组:深入理解“双生子”的奥秘
兄弟们,在第一部分我们已经初步了解了数组和指针的关系。现在,是时候深入挖掘这对“双生子”的奥秘了!理解它们的细微差别和等价性,是C语言高级编程的基石。
6.3.1 数组名即指针常量(大多数情况下)
-
回顾: 数组名在大多数表达式中会“退化”为指向其第一个元素的指针常量。
-
例外: 两种情况下数组名不会退化为指针:
-
当数组名作为
sizeof运算符的操作数时,sizeof(array_name)返回整个数组的总大小。 -
当数组名作为
&(取地址) 运算符的操作数时,&array_name返回整个数组的地址(类型是指向数组的指针)。
-
代码示例:数组名与指针的特殊情况
#include <stdio.h>
int main() {
printf("--- 数组名与指针的特殊情况示例 ---\n");
int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr; // arr 退化为 int*
printf("arr 的类型是 int[5],但作为表达式时退化为 int*\n");
printf("arr 的地址: %p\n", (void*)arr);
printf("ptr 的地址: %p\n", (void*)ptr); // 与 arr 相同
// 1. sizeof(数组名) 返回整个数组的大小
printf("\n1. sizeof(arr): %zu 字节 (整个数组的大小)\n", sizeof(arr));
printf("sizeof(ptr): %zu 字节 (指针变量的大小)\n", sizeof(ptr)); // 通常是 4 或 8
// 2. &数组名 返回指向整个数组的指针
printf("\n2. &arr 的地址: %p\n", (void*)&arr);
printf("arr 的地址: %p\n", (void*)arr); // 数值相同,但类型不同!
// 声明一个指向包含 5 个 int 的数组的指针
int (*ptr_to_array)[5];
ptr_to_array = &arr; // 赋值成功,因为类型匹配
printf("ptr_to_array 指向整个 arr 数组的地址: %p\n", (void*)ptr_to_array);
printf("通过 ptr_to_array 访问第一个元素: %d\n", (*ptr_to_array)[0]);
printf("通过 ptr_to_array 访问第三个元素: %d\n", (*ptr_to_array)[2]);
printf("\n--- 数组名与指针的特殊情况示例结束 ---\n");
return 0;
}
大厂面试考点:int *p = arr; 和 int (*p)[5] = &arr; 的区别
-
int *p = arr;:p是一个指向int类型的指针,它存储了数组arr第一个元素的地址。p只能“看到”数组的第一个元素,不知道数组的总大小。 -
int (*p)[5] = &arr;:p是一个指向包含5个int的数组的指针。它存储了整个数组arr的地址。p“知道”它指向的是一个大小为5的int数组。
6.3.2 指针数组与数组指针
兄弟们,这里是C语言指针的“绕口令”!int *arr[5]; 和 int (*arr)[5]; 看起来很像,但含义天差地别!
-
指针数组(Array of Pointers):
type *array_name[size];-
首先是一个数组,数组的每个元素都是一个指针。
-
例如:
int *ptr_array[3];定义了一个包含3个元素的数组,每个元素都是一个int *类型的指针。 -
用途:存储多个字符串(
char *names[])、存储指向不同数据块的指针。
-
-
数组指针(Pointer to Array):
type (*pointer_name)[size];-
首先是一个指针,它指向一个完整的数组。
-
例如:
int (*array_ptr)[5];定义了一个指针,它指向一个包含5个int元素的数组。 -
用途:处理多维数组、作为函数参数传递多维数组。
-
代码示例:指针数组与数组指针
#include <stdio.h>
#include <string.h> // For strcpy
int main() {
printf("--- 指针数组与数组指针示例 ---\n");
// 1. 指针数组 (Array of Pointers)
// 定义一个指针数组,每个元素都是 char*,用于存储字符串
char *names[3]; // 这是一个包含3个 char* 指针的数组
names[0] = "Alice";
names[1] = "Bob";
names[2] = "Charlie";
printf("\n1. 指针数组 (char *names[3]):\n");
for (int i = 0; i < 3; i++) {
printf("names[%d] = %s (地址: %p)\n", i, names[i], (void*)names[i]);
}
printf("sizeof(names): %zu 字节 (指针数组的总大小)\n", sizeof(names)); // 3 * sizeof(char*)
// 2. 数组指针 (Pointer to Array)
// 定义一个二维数组
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
// 定义一个数组指针,指向一个包含3个 int 元素的数组
int (*ptr_to_row)[3]; // 注意括号,优先级问题
ptr_to_row = matrix; // matrix 会退化为指向第一行的指针 (int (*)[3])
printf("\n2. 数组指针 (int (*ptr_to_row)[3]):\n");
printf("matrix 的地址: %p\n", (void*)matrix);
printf("ptr_to_row 的地址: %p\n", (void*)ptr_to_row);
// 通过数组指针访问元素
printf("通过 ptr_to_row 访问 matrix[0][0]: %d\n", (*ptr_to_row)[0]); // 解引用 ptr_to_row 得到 matrix[0]
printf("通过 ptr_to_row 访问 matrix[0][1]: %d\n", (*ptr_to_row)[1]);
// 移动数组指针
ptr_to_row++; // ptr_to_row 移动到 matrix 的下一行 (即 matrix[1])
printf("ptr_to_row++ 后指向 matrix[1][0]: %d\n", (*ptr_to_row)[0]);
printf("ptr_to_row++ 后指向 matrix[1][1]: %d\n", (*ptr_to_row)[1]);
printf("\n--- 指针数组与数组指针示例结束 ---\n");
return 0;
}
大厂面试考点:区分指针数组和数组指针
-
看优先级:
[]的优先级高于*。-
int *arr[5];:arr先与[]结合,表示arr是一个数组,数组的元素类型是int *。 -
int (*arr)[5];:arr先与*结合(因为括号),表示arr是一个指针,这个指针指向一个大小为5的int数组。
-
-
用途: 指针数组用于存储多个地址,数组指针用于指向整个数组。
6.3.3 多维数组与指针的复杂关系
-
二维数组作为函数参数:
-
void func(int arr[][3]); -
void func(int (*arr)[3]); -
这两种声明在函数参数中是等价的,都表示
arr是一个指向包含3个int元素的数组的指针。 -
注意: 除了第一维,其他维度的大小必须在函数参数中指定,因为编译器需要这些信息来计算元素在内存中的偏移量。
-
代码示例:多维数组作为函数参数
#include <stdio.h>
// 方式1: 传统二维数组参数声明 (等价于方式2)
void print_matrix_traditional(int matrix[][3], int rows) {
printf("--- print_matrix_traditional ---\n");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 3; j++) { // 列数必须指定
printf("%d ", matrix[i][j]);
}
printf("\n");
}
}
// 方式2: 数组指针作为参数
void print_matrix_pointer(int (*matrix_ptr)[3], int rows) {
printf("--- print_matrix_pointer ---\n");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 3; j++) { // 列数必须指定
printf("%d ", matrix_ptr[i][j]); // 也可以写成 (*(matrix_ptr + i))[j]
}
printf("\n");
}
}
int main() {
printf("--- 多维数组作为函数参数示例 ---\n");
int my_matrix[2][3] = {{10, 11, 12}, {13, 14, 15}};
print_matrix_traditional(my_matrix, 2);
print_matrix_pointer(my_matrix, 2); // 数组名 my_matrix 退化为 int (*)[3]
printf("\n--- 多维数组作为函数参数示例结束 ---\n");
return 0;
}
大厂面试考点:为什么多维数组作为参数时,除了第一维,其他维度必须指定?
-
因为C语言多维数组在内存中是连续存储的,编译器需要知道每一行的长度(即除了第一维以外的其他维度的大小),才能正确计算出
matrix[i][j]对应的内存地址。 -
地址计算公式:
address(matrix[i][j]) = base_address + i * (row_size) + j * (element_size)。这里的row_size就是col_size * element_size。
6.4 const 与指针:不变的“契约”
兄弟们,const 关键字在C语言中是用来声明“不变性”的。当它与指针结合时,会产生几种不同的“不变”契约,理解这些契约对于编写安全、健壮的代码至关重要!
6.4.1 指向常量的指针(Pointer to Constant)
-
语法:
const type *pointer_name;或type const *pointer_name;(两者等价) -
含义: 指针所指向的内容是常量,不能通过这个指针来修改它指向的值。
-
指针本身可变: 指针变量本身可以被修改,使其指向另一个常量或非常量。
#include <stdio.h>
int main() {
printf("--- 指向常量的指针示例 ---\n");
int val = 100;
const int const_val = 200;
// 指向常量的指针
const int *ptr_to_const; // ptr_to_const 指向的内容是常量
ptr_to_const = &val; // 可以指向一个非常量
printf("ptr_to_const 指向 val: %d\n", *ptr_to_const);
// *ptr_to_const = 150; // 错误!不能通过 ptr_to_const 修改 val 的值
ptr_to_const = &const_val; // 也可以指向一个常量
printf("ptr_to_const 指向 const_val: %d\n", *ptr_to_const);
// 指针本身可以改变指向
int another_val = 300;
ptr_to_const = &another_val;
printf("ptr_to_const 改变指向后: %d\n", *ptr_to_const);
printf("\n--- 指向常量的指针示例结束 ---\n");
return 0;
}
6.4.2 常量指针(Constant Pointer)
-
语法:
type *const pointer_name; -
含义: 指针变量本身是常量,一旦初始化后,不能再修改它指向的地址。
-
指向内容可变: 指针所指向的内容是可变的,可以通过这个指针修改它指向的值。
#include <stdio.h>
int main() {
printf("--- 常量指针示例 ---\n");
int val1 = 100;
int val2 = 200;
// 常量指针
int *const const_ptr = &val1; // const_ptr 是一个常量指针,必须初始化
printf("const_ptr 初始指向 val1: %d\n", *const_ptr);
*const_ptr = 150; // 可以通过 const_ptr 修改 val1 的值
printf("通过 const_ptr 修改 val1 后: %d\n", *const_ptr);
printf("val1 的新值: %d\n", val1);
// const_ptr = &val2; // 错误!不能修改 const_ptr 的指向
printf("\n--- 常量指针示例结束 ---\n");
return 0;
}
6.4.3 指向常量的常量指针(Constant Pointer to Constant)
-
语法:
const type *const pointer_name; -
含义: 指针所指向的内容是常量,且指针变量本身也是常量,既不能修改指向的内容,也不能修改指针的指向。
#include <stdio.h>
int main() {
printf("--- 指向常量的常量指针示例 ---\n");
int val = 100;
const int const_val = 200;
// 指向常量的常量指针
const int *const ptr_const_to_const = &val; // 必须初始化
printf("ptr_const_to_const 初始指向 val: %d\n", *ptr_const_to_const);
// *ptr_const_to_const = 150; // 错误!不能通过 ptr_const_to_const 修改 val 的值
// ptr_const_to_const = &const_val; // 错误!不能修改 ptr_const_to_const 的指向
printf("\n--- 指向常量的常量指针示例结束 ---\n");
return 0;
}
大厂面试考点:const 离谁近,谁是常量
-
口诀:
const关键字修饰离它最近的那个东西。-
const int *ptr;:const离int最近,表示int是常量,即指向的内容是常量。 -
int *const ptr;:const离ptr最近,表示ptr是常量,即指针本身是常量。 -
const int *const ptr;:两个const分别修饰int和ptr,表示内容和指针本身都是常量。
-
-
用途:
const在函数参数中非常重要,用于声明函数不会修改传入的指针所指向的数据,这是一种重要的接口契约,提高了代码的安全性和可读性。
6.5 函数指针:函数的“地址”与“回调”
兄弟们,函数也是存储在内存中的,所以它也有地址!函数指针就是指向函数地址的指针。它能让你像操作数据一样操作函数,实现回调、状态机等高级编程技巧,在嵌入式中更是“神器”!
6.5.1 函数指针的定义与使用
-
定义语法:
return_type (*pointer_name)(parameter_list);-
注意
(*pointer_name)必须用括号括起来,因为()运算符的优先级高于*。
-
-
用途:
-
将函数作为参数传递给另一个函数(回调函数)。
-
实现多态行为(在C语言中模拟)。
-
构建状态机。
-
实现事件驱动编程。
-
代码示例:函数指针
#include <stdio.h>
// 1. 定义两个普通函数
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
// 2. 定义一个接受函数指针作为参数的函数 (回调函数示例)
void calculate_and_print(int x, int y, int (*operation_ptr)(int, int)) {
int result = operation_ptr(x, y); // 通过函数指针调用函数
printf("计算结果: %d\n", result);
}
int main() {
printf("--- 函数指针示例 ---\n");
// 1. 定义函数指针并初始化
int (*ptr_add)(int, int); // 定义一个函数指针,指向返回 int,接受两个 int 参数的函数
ptr_add = add; // 将 add 函数的地址赋值给 ptr_add
// 2. 通过函数指针调用函数
int sum = ptr_add(10, 5); // 调用 add 函数
printf("通过函数指针调用 add(10, 5) = %d\n", sum);
// 也可以直接赋值给另一个函数
int (*ptr_subtract)(int, int) = subtract;
int diff = ptr_subtract(10, 5); // 调用 subtract 函数
printf("通过函数指针调用 subtract(10, 5) = %d\n", diff);
// 3. 将函数指针作为参数传递 (回调函数)
printf("\n--- 回调函数示例 ---\n");
printf("使用 add 函数进行计算:\n");
calculate_and_print(20, 10, add); // 传递 add 函数的地址
printf("使用 subtract 函数进行计算:\n");
calculate_and_print(20, 10, subtract); // 传递 subtract 函数的地址
// 4. 函数指针数组 (用于实现状态机或菜单选择)
printf("\n--- 函数指针数组示例 (模拟菜单) ---\n");
typedef int (*OperationFunc)(int, int); // 定义函数指针类型别名
OperationFunc ops[] = {add, subtract}; // 函数指针数组
int choice;
printf("请选择操作 (0: 加法, 1: 减法): ");
scanf("%d", &choice);
if (choice >= 0 && choice < sizeof(ops) / sizeof(ops[0])) {
printf("执行选择的操作: ");
calculate_and_print(30, 15, ops[choice]);
} else {
printf("无效选择。\n");
}
printf("\n--- 函数指针示例结束 ---\n");
return 0;
}
大厂面试考点:函数指针的应用场景
-
回调函数: 当你需要将一个函数传递给另一个函数,让被调用的函数在特定事件发生时“回调”你提供的函数时,函数指针是理想选择。例如,排序函数可以接受一个比较函数作为参数。
-
事件处理: 操作系统、GUI库、嵌入式实时操作系统(RTOS)中,事件处理机制通常依赖于函数指针(或函数指针数组)。
-
状态机: 用函数指针数组实现状态转换,每个数组元素对应一个状态的处理函数。
-
插件机制: 允许动态加载和执行外部代码。
6.6 空指针、野指针与悬空指针:指针的“陷阱”
兄弟们,指针虽然强大,但也充满了“陷阱”!空指针、野指针和悬空指针是C语言中最常见的Bug来源,它们可能导致程序崩溃、数据损坏,甚至被恶意利用。理解并避免这些“陷阱”,是成为合格C程序员的必备素养!
6.6.1 空指针(Null Pointer)
-
概念: 指向地址
0的指针。地址0通常被操作系统保留,不允许用户程序访问。 -
用途: 用于表示指针“不指向任何有效对象”的状态。
-
定义: 通常用
NULL宏来表示空指针。-
int *ptr = NULL;
-
-
检查: 在解引用指针之前,务必检查它是否为空指针!
-
if (ptr != NULL)或if (ptr)
-
#include <stdio.h>
#include <stdlib.h> // For NULL
int main() {
printf("--- 空指针示例 ---\n");
int *ptr = NULL; // 初始化为空指针
printf("ptr 的值 (NULL): %p\n", (void*)ptr);
// 尝试解引用空指针 (会导致运行时错误,如段错误 Segmentation Fault)
// printf("解引用空指针: %d\n", *ptr);
// 正确的做法:在使用前检查
if (ptr != NULL) {
printf("指针有效,解引用: %d\n", *ptr);
} else {
printf("指针为空,不能解引用。\n");
}
printf("\n--- 空指针示例结束 ---\n");
return 0;
}
6.6.2 野指针(Wild Pointer)
-
概念: 指向一个未知或随机内存地址的指针。
-
产生原因:
-
未初始化: 定义指针后未赋值,它会包含一个随机的垃圾值。
-
越界访问: 指针运算导致其指向了有效内存范围之外。
-
-
危险性: 解引用野指针会导致程序访问非法内存区域,引发不可预测的行为,如程序崩溃(段错误)、数据损坏、安全漏洞。
-
避免:
-
始终初始化指针: 定义时就将其初始化为
NULL或一个有效的地址。 -
谨慎指针运算: 确保指针始终在合法范围内移动。
-
#include <stdio.h>
#include <stdlib.h> // For malloc, free
int main() {
printf("--- 野指针示例 ---\n");
// 1. 未初始化指针 (最常见的野指针)
int *wild_ptr; // 未初始化,其值是随机的
printf("未初始化 wild_ptr 的值 (随机地址): %p\n", (void*)wild_ptr);
// *wild_ptr = 10; // 危险!解引用野指针,可能导致崩溃
// 2. 越界访问导致的野指针
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
p += 10; // 越界,p 现在指向一个无效地址
printf("越界后 p 的值 (无效地址): %p\n", (void*)p);
// *p = 100; // 危险!解引用野指针,可能导致崩溃
printf("\n--- 野指针示例结束 (请勿在实际项目中使用此类代码) ---\n");
return 0;
}
6.6.3 悬空指针(Dangling Pointer)
-
概念: 指针曾经指向一个有效的内存地址,但该内存地址已被释放或不再有效,而指针本身却没有被置为
NULL。 -
产生原因:
-
释放后未置空:
free(ptr);后,ptr仍然存储着已释放内存的地址。 -
返回局部变量地址: 函数返回局部变量的地址,但局部变量在函数返回后被销毁。
-
-
危险性: 解引用悬空指针同样会导致未定义行为。如果被释放的内存被系统重新分配给其他用途,那么通过悬空指针写入数据可能会破坏其他合法数据。
-
避免:
-
释放内存后立即将指针置为
NULL:free(ptr); ptr = NULL; -
不要返回局部变量的地址。
-
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 错误示例:返回局部变量的地址 (会导致悬空指针)
int* create_local_int_bad() {
int local_var = 100;
printf("函数内: local_var 的地址: %p\n", (void*)&local_var);
return &local_var; // 返回局部变量的地址
}
int main() {
printf("--- 悬空指针示例 ---\n");
// 1. free 后未置空导致的悬空指针
int *heap_ptr = (int *)malloc(sizeof(int));
if (heap_ptr == NULL) {
perror("malloc failed");
return 1;
}
*heap_ptr = 500;
printf("初始 heap_ptr 指向的值: %d\n", *heap_ptr);
printf("初始 heap_ptr 的地址: %p\n", (void*)heap_ptr);
free(heap_ptr); // 释放内存,但 heap_ptr 仍然存储着这块已释放的地址
printf("free 后 heap_ptr 的值 (已释放的地址): %p\n", (void*)heap_ptr);
// 此时 heap_ptr 就是一个悬空指针。解引用它是危险的。
// *heap_ptr = 600; // 危险!可能导致崩溃或数据损坏
heap_ptr = NULL; // 最佳实践:释放后立即置空
printf("置空后 heap_ptr 的值: %p\n", (void*)heap_ptr);
// 2. 返回局部变量地址导致的悬空指针
printf("\n--- 返回局部变量地址导致的悬空指针示例 ---\n");
int *dangling_ptr = create_local_int_bad(); // dangling_ptr 接收一个已销毁变量的地址
printf("函数返回后,dangling_ptr 的值: %p\n", (void*)dangling_ptr);
// 此时 dangling_ptr 是悬空指针。尝试解引用它是危险的。
// printf("解引用 dangling_ptr: %d\n", *dangling_ptr); // 危险!
printf("\n--- 悬空指针示例结束 (请勿在实际项目中使用此类代码) ---\n");
return 0;
}
大厂面试考点:空指针、野指针、悬空指针的区别与危害
-
空指针: 不指向任何有效内存,通常是
NULL。解引用会立即崩溃(段错误),易于发现。 -
野指针: 指向随机或未知内存,未初始化或越界导致。解引用行为不可预测,可能崩溃,也可能暂时正常,但会破坏数据,难以调试。
-
悬空指针: 指向的内存已被释放,但指针本身未置空。解引用行为不可预测,与野指针类似,更隐蔽。
-
共同危害: 导致程序崩溃、数据损坏、安全漏洞(如缓冲区溢出、代码注入)。
-
防御手段: 始终初始化指针,释放内存后立即置空,避免返回局部变量地址,谨慎进行指针运算。
6.7 多级指针:指针的“指针”
兄弟们,指针本身也是变量,它也有自己的地址。那么,有没有可能有一个指针,它存储的是另一个指针的地址呢?答案是肯定的!这就是多级指针(Pointer to Pointer),也叫指针的指针。
-
语法:
type **pointer_to_pointer_name;-
**表示这是一个指向指针的指针。
-
-
用途:
-
在函数中修改传入的指针变量本身(例如,在函数内部为指针分配内存)。
-
处理指针数组。
-
构建复杂的数据结构,如多级链表、树等。
-
代码示例:多级指针
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 1. 示例:在函数中修改传入的指针变量本身
// 错误示范:无法修改 main 函数中的 ptr
void allocate_memory_bad(int *ptr) {
ptr = (int *)malloc(sizeof(int)); // 这里的 ptr 是 main 函数中 ptr 的副本
if (ptr != NULL) {
*ptr = 100;
}
// 函数返回后,这个副本被销毁,main 函数中的 ptr 仍然是 NULL
}
// 正确示范:使用二级指针来修改 main 函数中的 ptr
void allocate_memory_good(int **ptr_ptr) { // 接收一个指向指针的指针
*ptr_ptr = (int *)malloc(sizeof(int)); // 解引用 ptr_ptr,修改 main 函数中 ptr 的值
if (*ptr_ptr != NULL) {
**ptr_ptr = 200; // 两次解引用,修改分配的内存中的值
}
}
int main() {
printf("--- 多级指针示例 ---\n");
int val = 10;
int *ptr = &val; // ptr 指向 val
int **ptr_ptr = &ptr; // ptr_ptr 指向 ptr
printf("val 的值: %d\n", val);
printf("ptr 的值 (val 的地址): %p\n", (void*)ptr);
printf("ptr_ptr 的值 (ptr 的地址): %p\n", (void*)ptr_ptr);
printf("\n通过多级指针访问和修改值:\n");
printf("*ptr_ptr (ptr 的值): %p\n", (void*)*ptr_ptr); // 解引用一次,得到 ptr 的值
printf("**ptr_ptr (val 的值): %d\n", **ptr_ptr); // 解引用两次,得到 val 的值
**ptr_ptr = 30; // 通过 ptr_ptr 修改 val 的值
printf("通过 **ptr_ptr 修改 val 后: %d\n", val);
printf("\n--- 在函数中修改指针变量本身示例 ---\n");
int *my_dynamic_ptr = NULL;
printf("调用前 my_dynamic_ptr: %p\n", (void*)my_dynamic_ptr);
// allocate_memory_bad(my_dynamic_ptr); // 错误示范,不会改变 my_dynamic_ptr
// printf("错误调用后 my_dynamic_ptr: %p (仍然是 NULL)\n", (void*)my_dynamic_ptr);
allocate_memory_good(&my_dynamic_ptr); // 传入 my_dynamic_ptr 的地址
printf("正确调用后 my_dynamic_ptr: %p\n", (void*)my_dynamic_ptr);
if (my_dynamic_ptr != NULL) {
printf("my_dynamic_ptr 指向的值: %d\n", *my_dynamic_ptr);
free(my_dynamic_ptr); // 释放动态分配的内存
my_dynamic_ptr = NULL;
}
printf("\n--- 多级指针示例结束 ---\n");
return 0;
}
大厂面试考点:二级指针在函数中的作用
-
当函数需要修改传入的指针变量本身(而不是它指向的值)时,必须使用二级指针。
-
最典型的应用场景是:在函数内部为外部传入的指针分配内存(如
char **argv在main函数中接收命令行参数)。
小结: 指针是C语言的灵魂,也是其强大和灵活的源泉。熟练掌握指针的定义、初始化、解引用、各种运算,以及指针与数组、const 的复杂关系,是成为C语言高手的必经之路。同时,务必警惕空指针、野指针、悬空指针这三大“陷阱”,并学会如何避免它们。多级指针虽然复杂,但在特定场景下是不可或缺的“魔法”!
第七章:内存管理——程序的“地盘”与“分配师”
兄弟们,你的程序在运行时,数据和代码都得有地方放,这个“地方”就是内存!C语言不提供垃圾回收机制,这意味着你必须亲手管理内存,就像一个“地盘分配师”!理解内存的各个区域,以及如何进行动态内存分配和释放,是C语言底层编程的重中之重!
7.1 C语言内存区域:程序的“五脏庙”
在C语言程序运行期间,内存通常被划分为几个不同的区域,每个区域有其特定的用途和生命周期。理解这些区域对于内存管理和调试至关重要。
-
代码区(Text Segment / Code Segment):
-
存储内容: 存放CPU执行的机器指令(程序代码)。
-
特点: 只读,防止程序意外修改自身代码。共享,多个进程可以共享同一份代码。
-
生命周期: 整个程序运行期间。
-
示例: 函数的机器码、常量字符串字面量(如
"Hello")。
-
-
数据区(Data Segment / Initialized Data Segment):
-
存储内容: 存放已初始化的全局变量和静态变量。
-
特点: 在程序启动时加载到内存,可读写。
-
生命周期: 整个程序运行期间。
-
示例:
int global_var = 10;
-
-
BSS区(Block Started by Symbol Segment):
-
存储内容: 存放未初始化的全局变量和静态变量。
-
特点: 在程序启动时自动清零(初始化为0),可读写。不占用可执行文件空间,只在运行时占用内存空间。
-
生命周期: 整个程序运行期间。
-
示例:
static int static_uninit_var;int global_uninit_var;
-
-
栈区(Stack Segment):
-
存储内容: 存放局部变量、函数参数、函数返回地址等。
-
特点: 自动分配和释放(由编译器和操作系统管理),遵循“后进先出”(LIFO)原则。栈空间有限,过多的函数调用或大型局部变量可能导致栈溢出。
-
生命周期: 随函数调用而创建,随函数返回而销毁。
-
示例: 函数内部的
int local_var;
-
-
堆区(Heap Segment):
-
存储内容: 存放程序运行时动态分配的内存。
-
特点: 由程序员手动分配和释放(使用
malloc,free等函数)。不遵循LIFO,分配和释放顺序不固定。堆空间通常比栈大得多。 -
生命周期: 从分配到手动释放,或程序结束时由操作系统回收。
-
示例:
int *ptr = (int *)malloc(sizeof(int));
-
内存布局图:
graph TD
A[高地址] --> B[命令行参数和环境变量]
B --> C[栈区 (Stack)]
C -- 向低地址增长 --> D[↓]
D --> E[堆区 (Heap)]
E -- 向高地址增长 --> F[↑]
F --> G[BSS区 (未初始化静态/全局变量)]
G --> H[数据区 (已初始化静态/全局变量)]
H --> I[代码区 (Text/Code)]
I --> J[低地址]
style A fill:#fff,stroke:#333,stroke-width:2px
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#fff,stroke:#333,stroke-width:2px
style E fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#fff,stroke:#333,stroke-width:2px
style G fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#f9f,stroke:#333,stroke-width:2px
style I fill:#f9f,stroke:#333,stroke-width:2px
style J fill:#fff,stroke:#333,stroke-width:2px
代码示例:C语言内存区域
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <string.h> // For strcpy
// 全局已初始化变量 (数据区)
int global_initialized_var = 10;
// 全局未初始化变量 (BSS区)
int global_uninitialized_var;
// 静态已初始化变量 (数据区)
static int static_initialized_var = 20;
// 静态未初始化变量 (BSS区)
static int static_uninitialized_var;
// 常量字符串字面量 (代码区/只读数据区)
const char *const_string = "Hello, C Memory!";
void func_stack_example() {
// 局部变量 (栈区)
int local_var_in_func = 30;
char local_arr[10];
strcpy(local_arr, "func_str");
printf("\n--- 函数内部 (栈区) ---\n");
printf("local_var_in_func 的地址: %p, 值: %d\n", (void*)&local_var_in_func, local_var_in_func);
printf("local_arr 的地址: %p, 值: %s\n", (void*)local_arr, local_arr);
}
int main() {
printf("--- C语言内存区域示例 ---\n");
// 1. 代码区/只读数据区
printf("\n--- 代码区/只读数据区 ---\n");
printf("main 函数的地址: %p\n", (void*)main);
printf("const_string 的地址: %p, 值: %s\n", (void*)const_string, const_string);
printf("字符串字面量 \"Direct String\" 的地址: %p\n", (void*)"Direct String");
// 2. 数据区 (已初始化全局/静态变量)
printf("\n--- 数据区 ---\n");
printf("global_initialized_var 的地址: %p, 值: %d\n", (void*)&global_initialized_var, global_initialized_var);
printf("static_initialized_var 的地址: %p, 值: %d\n", (void*)&static_initialized_var, static_initialized_var);
// 3. BSS区 (未初始化全局/静态变量)
printf("\n--- BSS区 ---\n");
printf("global_uninitialized_var 的地址: %p, 值: %d (默认清零)\n", (void*)&global_uninitialized_var, global_uninitialized_var);
printf("static_uninitialized_var 的地址: %p, 值: %d (默认清零)\n", (void*)&static_uninitialized_var, static_uninitialized_var);
// 4. 栈区 (局部变量、函数参数)
int local_var_in_main = 40;
printf("\n--- main 函数内部 (栈区) ---\n");
printf("local_var_in_main 的地址: %p, 值: %d\n", (void*)&local_var_in_main, local_var_in_main);
func_stack_example(); // 调用函数,观察栈的变化
// 5. 堆区 (动态内存分配)
printf("\n--- 堆区 ---\n");
int *heap_int = (int *)malloc(sizeof(int));
char *heap_str = (char *)malloc(20 * sizeof(char));
if (heap_int == NULL || heap_str == NULL) {
perror("内存分配失败");
return 1;
}
*heap_int = 50;
strcpy(heap_str, "Dynamic String");
printf("heap_int 的地址 (堆上): %p, 值: %d\n", (void*)heap_int, *heap_int);
printf("heap_str 的地址 (堆上): %p, 值: %s\n", (void*)heap_str, heap_str);
// 释放堆内存
free(heap_int);
heap_int = NULL; // 最佳实践:释放后置空指针,避免悬空指针
free(heap_str);
heap_str = NULL; // 最佳实践
printf("\n--- C语言内存区域示例结束 ---\n");
return 0;
}
大厂面试考点:栈和堆的区别 | 特性 | 栈 (Stack) | 堆 (Heap) | | :------- | :--------------------------------- | :--------------------------------------- | | 管理方式 | 编译器自动分配和释放 | 程序员手动分配和释放 (malloc/free) | | 分配速度 | 快(只需移动栈指针) | 相对慢(需要查找空闲内存块) | | 空间大小 | 有限(通常几MB到几十MB),由操作系统决定 | 较大(通常由物理内存大小决定) | | 碎片化 | 不会产生碎片 | 容易产生内存碎片 | | 生长方向 | 向低地址增长 | 向高地址增长 | | 存储内容 | 局部变量、函数参数、返回地址 | 动态分配的数据(如链表节点、大型缓冲区) | | 安全性 | 相对安全,但可能栈溢出 | 容易出现内存泄漏、野指针、重复释放等问题 | | 使用场景 | 局部数据、函数调用 | 不确定大小的数据、需要跨函数生命周期的数据 |
7.2 动态内存分配:堆的“魔法”
兄弟们,有时候你不知道程序运行时需要多少内存,或者需要的数据在函数返回后依然存在。这时候,栈就不够用了,你需要用到堆的“魔法”——动态内存分配!
7.2.1 malloc:最常用的内存分配函数
-
功能: 在堆上分配指定字节数的内存块,并返回指向该内存块的第一个字节的
void *指针。 -
语法:
void *malloc(size_t size);-
size:要分配的字节数。
-
-
返回值:
-
成功:返回指向分配内存块的第一个字节的指针。
-
失败:返回
NULL(内存不足或请求大小为0)。
-
-
注意:
-
malloc返回void *,需要强制类型转换为你需要的指针类型。 -
分配的内存内容是未初始化的(随机值)!
-
7.2.2 calloc:带初始化的内存分配
-
功能: 在堆上分配指定数量和大小的内存块,并将其所有位初始化为0。
-
语法:
void *calloc(size_t num, size_t size);-
num:要分配的元素个数。 -
size:每个元素的大小(字节)。
-
-
返回值: 同
malloc。 -
特点: 自动初始化为0,适用于需要清零的缓冲区或数组。
7.2.3 realloc:调整内存块大小
-
功能: 重新调整之前由
malloc、calloc或realloc分配的内存块的大小。 -
语法:
void *realloc(void *ptr, size_t size);-
ptr:指向之前分配内存块的指针。 -
size:新的内存块大小(字节)。
-
-
返回值:
-
成功:返回指向新的内存块的指针。
-
如果新分配的内存块与旧内存块地址相同,则直接返回
ptr。 -
如果新分配的内存块地址不同,则旧内存块会被自动释放,并返回新内存块的地址。
-
-
失败:返回
NULL。此时,原始内存块不会被释放,仍然有效!
-
-
注意:
-
如果
ptr为NULL,realloc的行为类似于malloc。 -
如果
size为0,realloc的行为类似于free。 -
务必用一个临时指针接收
realloc的返回值,然后判断是否为NULL,再将临时指针赋值给原指针。 否则,如果realloc失败,原指针就丢失了,导致内存泄漏。
-
7.2.4 free:释放内存,避免内存泄漏
-
功能: 释放之前由
malloc、calloc或realloc分配的内存块。 -
语法:
void free(void *ptr);-
ptr:指向要释放的内存块的指针。
-
-
注意:
-
只能释放动态分配的内存! 释放栈内存或全局/静态内存会导致运行时错误。
-
不要重复释放同一块内存! 会导致未定义行为(通常是崩溃)。
-
释放后,务必将指针置为
NULL! 避免悬空指针。
-
代码示例:动态内存分配与释放
#include <stdio.h>
#include <stdlib.h> // For malloc, calloc, realloc, free
#include <string.h> // For strcpy, memset
int main() {
printf("--- 动态内存分配与释放示例 ---\n");
// 1. malloc 示例
printf("\n1. malloc 示例:\n");
int *arr_malloc = (int *)malloc(5 * sizeof(int)); // 分配 5 个 int 的空间
if (arr_malloc == NULL) {
perror("malloc failed");
return 1;
}
printf("malloc 分配的内存地址: %p\n", (void*)arr_malloc);
// 内存内容是随机的
for (int i = 0; i < 5; i++) {
arr_malloc[i] = i + 1;
printf("%d ", arr_malloc[i]);
}
printf("\n");
free(arr_malloc);
arr_malloc = NULL; // 释放后置空
// 2. calloc 示例
printf("\n2. calloc 示例:\n");
int *arr_calloc = (int *)calloc(5, sizeof(int)); // 分配 5 个 int 的空间并初始化为 0
if (arr_calloc == NULL) {
perror("calloc failed");
return 1;
}
printf("calloc 分配的内存地址: %p\n", (void*)arr_calloc);
// 内存内容自动初始化为 0
for (int i = 0; i < 5; i++) {
printf("%d ", arr_calloc[i]);
}
printf("\n");
free(arr_calloc);
arr_calloc = NULL; // 释放后置空
// 3. realloc 示例
printf("\n3. realloc 示例:\n");
char *buffer = (char *)malloc(10 * sizeof(char)); // 初始分配 10 字节
if (buffer == NULL) {
perror("malloc for realloc failed");
return 1;
}
strcpy(buffer, "Hello");
printf("初始 buffer 地址: %p, 内容: \"%s\"\n", (void*)buffer, buffer);
// 扩大内存到 20 字节
char *new_buffer = (char *)realloc(buffer, 20 * sizeof(char));
if (new_buffer == NULL) {
perror("realloc failed");
// realloc 失败时,原始 buffer 仍然有效,需要释放
free(buffer);
buffer = NULL;
return 1;
}
buffer = new_buffer; // 更新指针
strcat(buffer, " World!");
printf("realloc 扩大后 buffer 地址: %p, 内容: \"%s\"\n", (void*)buffer, buffer);
// 缩小内存到 5 字节
new_buffer = (char *)realloc(buffer, 5 * sizeof(char));
if (new_buffer == NULL) {
perror("realloc failed");
free(buffer);
buffer = NULL;
return 1;
}
buffer = new_buffer; // 更新指针
// 缩小后,内容可能被截断
printf("realloc 缩小后 buffer 地址: %p, 内容: \"%s\"\n", (void*)buffer, buffer);
free(buffer);
buffer = NULL; // 释放后置空
printf("\n--- 动态内存分配与释放示例结束 ---\n");
return 0;
}
大厂面试考点:malloc, calloc, realloc, free 的区别与注意事项
-
mallocvscalloc:-
malloc不初始化内存内容,calloc初始化为0。 -
malloc参数是一个总字节数,calloc参数是元素个数和每个元素大小。
-
-
realloc的安全性:-
realloc失败时返回NULL,原始指针仍然有效。所以务必用临时变量接收返回值。 -
realloc可能导致内存块移动,所以旧地址可能失效。
-
-
free的重要性: 避免内存泄漏。 -
重复释放、释放野指针/空指针: 都是未定义行为,应避免。
-
内存泄漏(Memory Leak): 分配的内存没有被及时释放,导致程序可用的内存越来越少,最终耗尽系统资源。
-
内存碎片(Memory Fragmentation): 频繁的分配和释放小块内存,导致内存空间被分割成许多不连续的小块,即使总内存足够,也可能无法分配大的连续内存块。
7.3 内存泄漏与碎片:内存管理的“隐形杀手”
兄弟们,内存管理可不是简单地 malloc 和 free 就能搞定的!这里面潜藏着两个“隐形杀手”:内存泄漏(Memory Leak)和内存碎片(Memory Fragmentation),它们能悄无声息地拖垮你的程序!
7.3.1 内存泄漏(Memory Leak)
-
概念: 程序在运行过程中,分配的内存没有被及时释放,导致这部分内存无法再被程序使用,也无法被操作系统回收,从而造成内存资源的浪费。
-
危害:
-
长时间运行的程序(如服务器、嵌入式设备固件)内存占用不断增加,最终耗尽系统资源,导致程序崩溃或系统不稳定。
-
性能下降。
-
-
常见原因:
-
忘记
free:malloc之后没有对应的free。 -
指针丢失: 指针指向了新分配的内存,而旧的内存地址没有被保存下来,导致无法释放。
-
realloc失败未处理:realloc返回NULL后,没有释放原始内存。 -
函数返回动态分配的内存,但调用者未释放。
-
-
检测工具:
Valgrind(Linux/Unix)、AddressSanitizer (ASan) 等。
代码示例:内存泄漏
#include <stdio.h>
#include <stdlib.h> // For malloc
void func_with_leak() {
int *data = (int *)malloc(100 * sizeof(int)); // 分配内存
if (data == NULL) {
perror("malloc failed in func_with_leak");
return;
}
// ... 使用 data ...
// 没有 free(data); // 内存泄漏!
printf("func_with_leak: 制造了一次内存泄漏。\n");
}
int main() {
printf("--- 内存泄漏示例 ---\n");
for (int i = 0; i < 5; i++) {
func_with_leak(); // 每次调用都会泄漏 100 * sizeof(int) 字节
}
// 示例2: 指针丢失导致的内存泄漏
int *ptr_lost = (int *)malloc(50 * sizeof(int));
if (ptr_lost == NULL) {
perror("malloc failed for ptr_lost");
return 1;
}
printf("ptr_lost 初始地址: %p\n", (void*)ptr_lost);
// ... 使用 ptr_lost ...
ptr_lost = (int *)malloc(60 * sizeof(int)); // 重新赋值,旧的 50 * sizeof(int) 内存泄漏了!
if (ptr_lost == NULL) {
perror("malloc failed for ptr_lost (second time)");
return 1;
}
printf("ptr_lost 新地址: %p (旧地址已丢失,内存泄漏)\n", (void*)ptr_lost);
// 程序结束时,操作系统会回收所有内存,但在长时间运行的程序中,这会是问题。
printf("\n--- 内存泄漏示例结束 (请使用 Valgrind 等工具检测) ---\n");
// 释放最后一次分配的内存,避免双重泄漏
if (ptr_lost != NULL) {
free(ptr_lost);
ptr_lost = NULL;
}
return 0;
}
7.3.2 内存碎片(Memory Fragmentation)
-
概念: 内存空间被频繁地分配和释放,导致可用内存被分割成许多不连续的小块。即使总的空闲内存量很大,也可能找不到足够大的连续内存块来满足新的分配请求。
-
危害:
-
导致大的内存分配请求失败(即使总内存充足)。
-
降低内存分配效率。
-
-
类型:
-
内部碎片: 分配的内存块比实际需要的要大,多余的部分无法被其他程序使用。例如,
malloc可能会为了对齐或管理开销而分配比请求略大的内存。 -
外部碎片: 内存被分割成许多小块,这些小块分散在整个内存空间中,无法合并成一个大的连续块。
-
-
避免/缓解:
-
减少动态内存分配的次数: 尽量一次性分配足够大的内存,而不是频繁地分配小块。
-
使用内存池(Memory Pool): 预先分配一块大的内存,然后从这块内存中进行小块分配和管理。这可以减少系统调用开销,并缓解碎片化。
-
选择合适的内存管理算法: 某些内存分配器算法(如伙伴系统)在一定程度上可以缓解碎片。
-
避免频繁地分配和释放小块内存。
-
在嵌入式系统中,可能需要自定义内存分配器。
-
代码示例:内存碎片(概念性演示,实际效果需长时间运行和复杂场景)
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#define NUM_ALLOCS 1000
#define SMALL_BLOCK_SIZE 16
#define LARGE_BLOCK_SIZE 1024
int main() {
printf("--- 内存碎片示例 (概念性演示) ---\n");
void *ptrs[NUM_ALLOCS];
printf("\n1. 频繁分配和释放小块内存,制造外部碎片...\n");
// 频繁分配和释放小块内存
for (int i = 0; i < NUM_ALLOCS; i++) {
ptrs[i] = malloc(SMALL_BLOCK_SIZE);
if (ptrs[i] == NULL) {
perror("Small malloc failed");
break;
}
}
// 随机释放一部分内存,留下空洞
for (int i = 0; i < NUM_ALLOCS; i += 2) { // 释放一半
if (ptrs[i] != NULL) {
free(ptrs[i]);
ptrs[i] = NULL;
}
}
printf("已分配和释放部分小块内存,可能产生碎片。\n");
printf("\n2. 尝试分配一个大的连续内存块...\n");
void *large_block = malloc(LARGE_BLOCK_SIZE);
if (large_block == NULL) {
printf("尝试分配 %d 字节的大块内存失败!(可能由于内存碎片)\n", LARGE_BLOCK_SIZE);
} else {
printf("成功分配 %d 字节的大块内存。\n", LARGE_BLOCK_SIZE);
free(large_block);
large_block = NULL;
}
// 清理剩余的小块内存
for (int i = 0; i < NUM_ALLOCS; i++) {
if (ptrs[i] != NULL) {
free(ptrs[i]);
ptrs[i] = NULL;
}
}
printf("\n--- 内存碎片示例结束 ---\n");
return 0;
}
大厂面试考点:内存泄漏和内存碎片的区别、危害及如何避免
-
区别: 内存泄漏是“内存丢失”,内存碎片是“内存不连续”。
-
危害: 内存泄漏导致内存耗尽,内存碎片导致大块内存分配失败。
-
避免:
-
内存泄漏: 严格遵循“谁分配谁释放”原则,
malloc必有free,释放后指针置空,警惕指针丢失。 -
内存碎片: 减少动态分配次数,使用内存池,优化分配策略。
-
7.4 其他内存分配方式:栈与静态区的补充
除了堆,C语言还有其他内存分配方式,它们各有特点,适用于不同的场景。
7.4.1 栈上分配:alloca (非标准,慎用)
-
功能: 在当前函数的栈帧上分配内存。
-
语法:
void *alloca(size_t size); -
特点:
-
速度快: 仅移动栈指针。
-
自动释放: 函数返回时自动释放,无需手动
free。 -
非标准: 并非所有C标准都包含
alloca,可移植性差。 -
栈溢出风险: 如果分配过大,容易导致栈溢出。
-
-
用途: 动态大小的局部缓冲区,但通常更推荐使用变长数组(VLA)或堆分配。
#include <stdio.h>
#include <alloca.h> // 可能需要这个头文件,但它不是标准C库的一部分
#include <string.h>
void process_data_on_stack(int size) {
printf("\n--- alloca 示例 ---\n");
// 在栈上动态分配内存
char *buffer = (char *)alloca(size); // 分配 size 字节
if (buffer == NULL) { // alloca 不会返回 NULL,而是直接崩溃或导致未定义行为
// 实际上,alloca 失败通常是栈溢出,不会返回 NULL
printf("alloca 分配失败或栈溢出!\n");
return;
}
memset(buffer, 'A', size - 1); // 填充数据
buffer[size - 1] = '\0'; // 确保字符串终止
printf("栈上分配的 buffer 地址: %p, 大小: %d 字节\n", (void*)buffer, size);
printf("内容: %s\n", buffer);
// 函数返回时,buffer 会自动释放
printf("函数返回时,alloca 分配的内存将自动释放。\n");
}
int main() {
printf("--- 其他内存分配方式示例 ---\n");
// 栈上分配 (局部变量)
int local_array[10]; // 编译时确定大小
printf("局部数组 local_array 的地址: %p, 大小: %zu 字节 (栈上)\n", (void*)local_array, sizeof(local_array));
// 静态区/全局区分配
static int static_var = 1; // 静态变量
printf("静态变量 static_var 的地址: %p, 大小: %zu 字节 (静态区)\n", (void*)&static_var, sizeof(static_var));
// alloca 示例
process_data_on_stack(50); // 在栈上分配 50 字节
// process_data_on_stack(1024 * 1024 * 10); // 尝试分配大内存,可能导致栈溢出
printf("\n--- 其他内存分配方式示例结束 ---\n");
return 0;
}
7.4.2 静态区/全局区分配
-
特点:
-
生命周期: 整个程序运行期间。
-
初始化: 未初始化的自动清零,已初始化的在编译时或程序启动时初始化。
-
访问: 可以在程序的任何地方访问。
-
-
用途: 存储全局变量、静态变量、字符串字面量。
-
注意: 滥用全局变量会增加代码耦合度,降低可维护性。
小结: 内存管理是C语言编程的“核心艺术”。理解内存的各个区域(代码区、数据区、BSS区、栈区、堆区),熟练掌握 malloc, calloc, realloc, free 等动态内存分配函数,并警惕内存泄漏和内存碎片这两个“隐形杀手”,是成为C语言高手的必备技能。在嵌入式开发中,对内存的精细控制更是决定程序性能和稳定性的关键!
第二部分总结与展望:你已成为“指针魔法师”与“内存大师”!
兄弟们,恭喜你,已经完成了**《K&R C语言圣经全解:嵌入式硬核玩家的内功心法》的第二部分!**
我们在这部分旅程中,深入探索了C语言最核心、最强大的两大领域:
-
指针: 彻底揭开了指针的神秘面纱,掌握了指针的定义、初始化、解引用、各种运算,以及指针与数组、
const的复杂关系。你现在应该能够自如地运用指针来操作内存,实现高效的数据访问和复杂的逻辑。 -
内存管理: 深入剖析了C语言的内存布局(代码区、数据区、BSS区、栈区、堆区),理解了它们各自的特点和生命周期。你现在能够熟练使用
malloc,calloc,realloc,free进行动态内存分配,并且对内存泄漏和内存碎片这两个“隐形杀手”有了深刻的认识和防范意识。
现在,你已经不再是C语言的“门外汉”了!你已经具备了:
-
指针的“魔法”: 能够灵活运用指针,直接与内存打交道,实现高效的数据结构和算法。
-
内存的“掌控”: 能够清晰地理解程序在内存中的运行机制,合理分配和释放内存,避免常见的内存错误。
-
底层思维: 你的编程思维已经从“上层逻辑”深入到“底层机制”,这是成为优秀系统级程序员的关键。
你已经成为了真正的“指针魔法师”和“内存大师”!
这仅仅是个开始!在接下来的第三部分中,我们将继续深入,探索C语言的“高级数据结构”与“文件I/O”!我们将学习如何构建复杂的数据结构(如结构体、联合体、枚举),如何高效地进行文件读写,以及预处理器宏和类型定义等高级特性,让你能够构建更复杂、更健壮的应用程序!
准备好了吗?第三部分的硬核内容,将让你对C语言的理解达到新的高度,成为真正的“数据结构工程师”和“文件I/O专家”!
如果你觉得这份“秘籍”对你有亿点点帮助,请务必点赞、收藏、转发!你的支持是我继续分享干货的最大动力!
我们第三部分再见!祝你学习愉快,内功精进!

730

被折叠的 条评论
为什么被折叠?



