C语言复习笔记

第一部分: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 插件方法:

    1. 点击VS Code 菜单栏的"文件" (File) -> “首选项” (Preferences) -> “设置” (Settings),或者使用快捷键 Ctrl + ,
    2. 在设置页面中搜索"Run Code configuration"。
    3. 找到"Run In Terminal" 选项,并勾选上。

实现程序

在vscode中,输入main即可快速实现示例程序

main 函数可以接收来自命令行的参数,这在编写工具类程序时非常有用。

  • int argc: (Argument Count) 整型变量,存储传递给程序的命令行参数的数量(包括程序本身)。
  • char *argv[]: (Argument Vector) 字符串数组,argv[0]是程序名,argv[1]是第一个参数,以此类推。
1
2
3
4
5
6
7
8
9
##include <stdio.h>

// 编译后运行: ./a.out param1 param2
int main(int argc, char *argv[]) {
printf("程序名: %s\n", argv[0]); // 输出: ./a.out
printf("第一个参数: %s\n", argv[1]); // 输出: param1
printf("第二个参数: %s\n", argv[2]); // 输出: param2
return 0;
}

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
2
3
4
5
6
7
8
##define PI 3.14159 // 宏常量

int main() {
int age = 25; // 变量
const int MAX_USERS = 100; // const常量
age = 26; // OK
// MAX_USERS = 101; // Error: 试图修改const变量
}

格式化输入与输出

  • printf(): 格式化输出函数,将数据按指定格式打印到屏幕。
  • scanf(): 格式化输入。scanf返回成功读取的项数。如果输入类型不匹配,会返回0并将错误输入留在输入缓冲区 (stdin) 中,可能导致后续读取问题。 scanf需要传入变量的地址,使用 & 运算符获取。
格式符描述示例
%d十进制整数123
%ffloat 类型浮点数3.14
%lfdouble 类型浮点数3.1415926
%cchar单个字符'A'
%sstring字符串"hello"
%ppoint指针地址(十六进制)0x7ffc...
%xhexadecimal十六进制整数ff
%oOctal八进制整数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
    15
    int 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
2
3
4
5
6
7
8
9
##include <stdio.h>
int main() {
printf("请输入一个字符: ");
int c = getchar(); // 用int接收以处理EOF
printf("你输入的字符是: ");
putchar(c);
putchar('\n');
return 0;
}

类型转换

  • 自动类型转换: 在运算中,低精度类型会自动转换为高精度类型。 (char -> int -> float -> double)

  • 强制类型转换: 使用 (目标类型) 语法强制转换。

    1
    2
    3
    4
    5
    6
    int a = 10;
    int b = 3;
    float result = (float)a / b; // 将a强制转为float,结果为3.333...

    // 如果不转换,int / int 结果为3
    int int_result = a / b; // 结果为 3

科学计数法

浮点数可以使用eE表示10的幂,这在表示极大或极小值时很有用。

1
2
double large_num = 3.14e8;  // 3.14 * 10^8
double small_num = 1.23e-5; // 1.23 * 10^-5

数字的进制表示

我们可以用不同进制来输出数字,这对调试程序,操作寄存器等底层硬件非常重要

  • 前缀:
    • 十六进制: 0x0X
    • 八进制: 0
    • 十进制: 无前缀
  • 在内存中,所有进制的数最终都以二进制形式存储。
1
2
3
4
5
6
7
8
9
10
11
int dec_val = 100;         // 十进制
int hex_val = 0x64; // 十六进制 (值为100)
int oct_val = 0144; // 八进制 (值为100)

// 使用不同格式说明符打印
printf("Decimal: %d, %d, %d\n", dec_val, hex_val, oct_val);
printf("Hex: %x, %x, %x\n", dec_val, hex_val, oct_val);
printf("Octal: %o, %o, %o\n", dec_val, hex_val, oct_val);

// 使用'#'标志位打印进制前缀
printf("Hex with prefix: %#x, Octal with prefix: %#o\n", hex_val, oct_val);

面试题实战-浮点数的安全比较

为什么不能直接用 == 比较两个浮点数?

由于计算机使用二进制表示浮点数时存在固有的精度误差,直接用 == 比较通常会失败。

例子:十进制0.1转二进制计算过程

正确做法是判断两个浮点数之差的绝对值是否小于一个预先设定的极小值(epsilon)。

代码示例:

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
#include <float.h>
#include <math.h>
#include <stdbool.h>
#include <stdio.h>

/*
* 浮点数比较:因存在精度误差,应判断差的绝对值是否小于一个极小阈值(epsilon),
* 而非直接使用 `==`。
*/

// 1. 绝对误差法
#define EPSILON 1e-8 // 定义固定的误差阈值

// 判断值是否小于固定阈值。简单,但对极大或极小值可能失效。
bool is_zero_abs(double value) { return fabs(value) <= EPSILON; }

// 2. 相对误差法 (推荐)
// 使用与基准值相关的动态阈值,更通用
bool is_zero_rel(double value, double base_value) {
// 阈值上线随基准值大小动态调整,下线为1.0
double epsilon = EPSILON * fmax(1.0, fabs(base_value));
return fabs(value) <= epsilon;
}

int main(int argc, char const *argv[]) {
// 错:直接用 `==` 比较浮点数,因精度问题导致判断失败
if (0.1 * 3 - 0.3 == 0)
printf("是0\n");
else
printf("不是0\n"); // 此行被输出

// 绝对误差法:对于小数值的运算结果比较有效
if (is_zero_abs(0.1 * 3 - 0.3))
printf("是0\n"); // 此行被输出
else
printf("不是0\n");

// 相对误差法:对于大数值的比较也能正确处理
double f1 = 3.5e7;
double f2 = f1 + 0.1;
if (is_zero_rel(f2 - f1, f1))
printf(" f2-f1 是0\n"); // 此行被输出
else
printf(" f2-f1 不是0\n");

return 0;
}

面试题实战-大型字面量后缀

在处理大型数字的宏或常量时,中间计算结果可能会超出默认 int 类型的范围导致溢出,即便最终结果可以存放在 long long 中。

为了避免这种情况,可以使用后缀强制指定字面量的类型。

  • Ll: long
  • LLll: long long
  • Uu: unsigned
  • ULLull: unsigned long long

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

// 错误的方式: 所有数字都是int, 乘法过程中会溢出
// #define SECONDS_PER_YEAR (365 * 24 * 60 * 60)

// 正确的方式: 确保第一个数是 long long 类型
// 后续的乘法都会被提升到 long long 类型进行,避免溢出
#define SECONDS_PER_YEAR (365 * 24 * 60 * 60ULL)

int main(void) {
printf("每年秒数: %llu\n", SECONDS_PER_YEAR);
return 0;
}

面试题实战-类型提升中的符号扩展

当一个宽度较小的有符号类型(如 char, short)被提升为一个宽度较大的类型(如 int)时:

  • 如果原始值是正数,高位用 0 填充。
  • 如果原始值是负数(即最高位为1),则会发生符号扩展 (Sign Extension),高位用 1 填充以保持其负值。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void) {
// 0xaa 的二进制是 10101010。作为有符号char,其最高位是1,为负数。
char c = 0xaa;

// 当 c 被赋值给整型变量 j 时,它被提升为 int
// 由于 c 是负数,发生符号扩展,高位全用1填充
int j = c;

// 结果: 0xffffffaa
printf("j = %#x\n", j);
return 0;
}

这个特性在处理来自硬件或网络的字节流时尤其重要.

3. 运算符

算术运算符

+ (加), - (减), * (乘), / (除), % (取余/模)

  • 自增/自减: ++, --

    • 前缀 (++a): 先自增,再使用变量的值。
    • 后缀 (a++): 先使用变量的值,再自增。
    1
    2
    3
    4
    int 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
    4
    int 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 常用位操作技巧
unsigned char reg = 0b10101100; // 假设这是一个8位寄存器
// 注意二进制字面量 0b10101100 是GCC扩展

// 1. 置位 (将第3位置1)
reg = reg | (1 << 3);

// 2. 清零 (将第2位清0)
reg = reg & ~(1 << 2);

// 3. 取反 (翻转第1位)
reg = reg ^ (1 << 1);

// 4. 测试 (检查第7位是否为1)
if (reg & (1 << 7)) {
// 位为1
}

其他运算符

  • 赋值运算符: =, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=

  • 条件运算符 (三目运算符): 表达式1 ? 表达式2 : 表达式3

    1
    2
    int a = 10, b = 20;
    int max = (a > b) ? a : b; // max 的值为 20
  • sizeof 运算符: 计算数据类型或变量占用的内存字节数。

    1
    2
    3
    int arr[10];
    printf("int类型占 %zu 字节\n", sizeof(int)); // 通常输出 4
    printf("数组arr占 %zu 字节\n", sizeof(arr)); // 输出 40 (4 * 10)

运算符优先级与结合性

  • 优先级: 决定了哪个运算符先执行(如 * 优先于 +)。
  • 结合性: 决定了相同优先级的运算符的执行顺序(如赋值=从右至左)。
  • 黄金法则: 当不确定优先级时,使用圆括号 () 来明确指定运算顺序。 这能避免错误并提高代码可读性。
1
2
3
4
int x = 2 * 3 + 4; // 乘法优先, x = 6 + 4 = 10
int y = 10 > 5 && 5 > 0; // 关系运算符优先于逻辑运算符
int a, b, c;
a = b = c = 5; // 赋值运算符从右向左结合, c=5, b=5, a=5

面试题实战-字节内比特位反转

问题: 编写一个函数,将一个字节中的8个比特位进行反转。

核心知识: 这是对位运算符的深入考察。核心思想是逐位从原字节中取出,然后逐位放入新字节的相反位置。

代码示例:

1
2
3
4
5
6
7
8
9
void revert(int len, char A[], char B[]) {
for (int i = 0; i < len; i++) {
B[i] = 0;
for (int k = 0; k < 8; k++) {
B[i] = B[i] << 1; // 先移位
B[i] |= (A[i] >> k) & 0x01; // 后赋值
} // 最后不会多移位
}
}

4. 控制流

分支结构 (if / switch)

  • if-else: 用于二路或多路分支判断。

    1
    2
    3
    4
    5
    6
    7
    8
    int 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
    8
    int 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
    5
    int i = 0;
    while (i < 5) {
    printf("%d ", i);
    i++;
    } // 输出: 0 1 2 3 4
  • for 循环: 集初始化、条件判断、迭代于一体,结构清晰,常用于已知循环次数的场景。

    1
    2
    3
    for (int i = 0; i < 5; i++) {
    printf("%d ", i);
    } // 输出: 0 1 2 3 4
  • do-while 循环: 先执行一次循环体,再判断条件。保证循环体至少执行一次。

    1
    2
    3
    4
    int i = 10;
    do {
    printf("至少执行一次\n");
    } while (i < 5);

跳转语句 (break / continue / goto / return)

  • break: 立即跳出当前所在的 switch 结构或循环结构。

  • continue: 立即结束本次循环,跳到循环的下一次迭代判断处。

  • return:

    1. 立即终止当前函数的执行。
    2. 将一个值(如果函数不是void类型)返回给调用者。
    3. void函数中,return;可用于提前退出。
    1
    2
    3
    4
    5
    6
    7
    8
    int check_value(int val) {
    if (val < 0) {
    printf("Error: invalid value.\n");
    return -1; // 提前退出并返回错误码
    }
    // ... 正常处理 ...
    return 0; // 返回成功码
    }
  • goto: 无条件跳转到同一函数内的标签处。应谨慎使用,易破坏程序结构,通常只在错误处理等多层嵌套退出场景下考虑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 示例: goto用于统一的错误处理
int func() {
FILE *fp = fopen("a.txt", "r");
if (fp == NULL) {
goto error_handle;
}

char *mem = malloc(1024);
if (mem == NULL) {
fclose(fp); // 释放已获取资源
goto error_handle;
}

// ... 正常逻辑 ...

free(mem);
fclose(fp);
return 0;

error_handle:
printf("发生错误,程序退出\n");
return -1;
}

面试题实战- fgets字符串输入

scanf 不同,fgets 在读取字符串时更加安全,因为它会检查缓冲区的边界,有效防止溢出。

  • 函数原型: char *fgets(char *str, int n, FILE *stream);
  • 参数:
    • str: 用于存储输入字符串的缓冲区。
    • n: 最多读取 n-1 个字符(最后一个位置留给 \0)。
    • stream: 输入流,通常是 stdin(标准输入)。
  • 特点:
    • 安全: 不会超出缓冲区大小。
    • 会读取换行符: 如果输入行中包含换行符 \nfgets 会将其一并读入缓冲区。通常需要手动移除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <string.h>

int main() {
char buffer[10];
printf("请输入一行文字 (最多9个字符): ");

if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
// 移除可能存在的换行符
buffer[strcspn(buffer, "\n")] = '\0';

printf("你输入的是: '%s'\n", buffer);
}
return 0;
}

第二部分:内存、函数与程序结构

5. 内存布局与管理

对于嵌入式开发,深刻理解程序内存组织至关重要,它直接影响程序的稳定性、效率和资源消耗。

每个C程序都运行在一个独立的虚拟内存空间中,其结构从低地址到高地址通常如下:

C Process Virtual Memory Layout

代码段

只读

  • .text: 存放程序的可执行二进制代码
  • .init: 存放系统初始化代码

数据段

此区域用于存储**生命周期与整个程序相同全局变量静态变量**。

  • .rodata (Read-only Data): 存只读数据,如字符串字面量 ("hello")、const修饰的全局常量。
  • .data:存 已初始化的全局变量和静态变量。
  • .bss (Block Started by Symbol):存未初始化的全局变量和静态变量,程序启动时,该区域会被系统自动清零
1
2
3
4
5
6
7
8
9
10
11
// 示例:不同变量在数据段中的位置
int global_init = 10; // 存放在 .data 段
int global_uninit; // 存放在 .bss 段 (值为0)
const char* str = "hello"; // "hello"在 .rodata 段, 指针str在 .data 段

void counter() {
static int count = 0; // 'static'修饰局部变量,使其存入.data段
// 只在首次调用时初始化为0
count++;
printf("Counter called %d times.\n", count);
}

static 对局部变量的影响

static 用于修饰局部变量时,它会改变该变量的存储期

  • 存储位置: 从栈 (Stack) 移动到 数据段 (.data / .bss)
  • 生命周期: 从函数调用时创建、返回时销毁(自动存储期),延长为与整个程序运行时间相同(静态存储期)。

这意味着,static 局部变量只在第一次执行其定义时被初始化,并且它的值在函数调用之间得以保留

(关于 static 的全面总结,看笔记 12.4 节)

1
2
3
4
5
6
7
8
9
10
11
12
void counter() {
// 'count' 存储在数据段,只在首次调用时初始化为0
static int count = 0;
count++;
printf("函数被调用 %d 次.\n", count);
}

int main() {
counter(); // 输出: 函数被调用 1 次.
counter(); // 输出: 函数被调用 2 次.
return 0;
}

栈 (Stack)

Stack Growth

  • 存储内容: 函数的局部变量函数参数、环境变量、命令行参数及函数调用的上下文(返回地址等)。
  • 核心特点:
    • 自动管理: 函数调用时,栈向下增长(分配);函数返回时,栈向上收缩(释放)。程序员无法干预。
    • 空间有限: 栈大小预设且较小,过大的局部变量或过深的递归会导致栈溢出 (Stack Overflow)
    • 高效率: 分配和释放仅涉及栈指针的移动,速度极快。
1
2
3
4
5
6
7
8
9
void func(int n) { // n 和 c 都在 func 的栈帧上
char c[10];
} // 函数返回时,栈帧自动销毁,其内存被释放

int main() {
int main_var = 100; // main_var 在 main 函数的栈帧上
func(main_var);
return 0;
}

堆 (Heap)

堆是唯一一块可由程序员完全控制的内存区域,用于动态分配和管理内存。

  • 核心特点:

    • 手动管理: 必须通过函数(如malloc)申请,并通过free释放。忘记释放会导致内存泄漏
    • 空间巨大: 大小理论上只受限于系统可用物理内存。
    • 匿名访问: 堆内存没有变量名,只能通过指针来访问。
    • 生命周期灵活: 从申请到释放,其生命周期由程序员决定。
  • 涉及函数(<stdlib.h>):

    • void* malloc(size_t size): 申请 size 字节的内存,内容未初始化(随机值)。
    • void* calloc(size_t num, size_t size): 申请 numsize 字节的内存,内容被自动置零
    • void free(void* ptr): 释放由 malloccalloc 申请的内存。
  • free 最佳实践:

    • free() 后,指针 ptr 会变成一个指向无效内存的悬空指针
    • 必须free()后立即将指针置为 NULL,以防止后续误用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
##include <stdlib.h>

int main() {
// 申请一块能存放5个int的堆内存
int *arr = (int*)malloc(5 * sizeof(int));
if (arr == NULL) { // 检查内存申请是否成功
return -1;
}

// 使用内存
for (int i = 0; i < 5; i++) {
arr[i] = i * 10;
}

free(arr); // 释放内存
arr = NULL; // 防止悬空指针

return 0;
}

6. 存储期

存储期描述了变量在内存中从创建到销毁的生命周期。它与变量存储在哪个内存区域(栈、堆、数据段)直接相关。

自动存储期

  • 对应内存区域: 栈 (Stack)
  • 生命周期: 从程序执行进入其所在的代码块开始,到离开该代码块时结束。内存的分配和释放由系统自动完成。
  • 包含变量: 所有局部变量(包括函数参数),除非被static修饰。
1
2
3
void func() {
int auto_var = 10; // 进入函数时创建
} // 离开函数时,auto_var被自动销毁

静态存储期

  • 对应内存区域: 数据段 (.data / .bss)
  • 生命周期: 与整个程序的运行时间相同。在程序启动时创建,在程序结束时销毁。
  • 包含变量:
    • 所有全局变量(无论是否用static修饰)。
    • 使用 static 修饰的局部变量
1
2
3
4
5
6
int global_var = 1; // 静态存储期

void counter() {
static int count = 0; // 静态存储期,只在首次调用时初始化
count++; // count的值在函数调用间得以保留
}

自定义存储期

  • 对应内存区域:
  • 生命周期: 完全由程序员手动控制。从 malloc/calloc 成功调用时开始,到 free 被调用时结束。
  • 核心风险: 忘记调用 free 会导致内存泄漏
1
2
3
4
5
6
7
8
9
void dynamic_example() {
int *p = (int*)malloc(sizeof(int)); // 动态内存创建
if (p) {
*p = 100;
// ... 使用 ...
free(p); // 手动销毁
p = NULL;
}
}

static 关键字全面总结

理解 static 的关键在于区分它修饰的是局部变量还是全局变量/函数

上下文改变的属性效果目的
修饰局部变量存储期自动 (栈上) 变为 静态 (数据段)。生命周期与程序相同。在函数调用之间保持变量值的持久性
修饰全局变量/函数链接属性外部链接 变为 内部链接。作用域被限制在当前文件信息隐藏避免命名冲突,增强模块化。

详细解释与示例

修饰局部变量 (改变存储期)
  • 默认情况: 局部变量存储在上,函数调用时创建,函数返回时销毁。

  • static 修饰后:

    • 存储位置: 变量从栈移到数据段(.data 或 .bss)。

    • 生命周期: 变量在程序启动时就已创建,直到程序结束才销毁。

    • 初始化: 只在编译时初始化一次。

      因此,其初始值必须是一个常量表达式(如字面量10'c'"hello"),不能是运行时才存在的变量。

    • 核心效果: 变量的值在多次函数调用之间得以保留

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
##include <stdio.h>

void counter() {
// a1 是 static 局部变量,存储在数据段,生命周期贯穿程序
// 它只在程序加载时被初始化一次
static int a1 = 0; // OK: 0是常量

int runtime_var = 10;
// static int a2 = runtime_var; // 错误!不能用变量初始化static变量

// a3 是 auto 局部变量,存储在栈上
// 每次调用 counter() 时,a3 都会被重新创建并初始化为 0
int a3 = 0;

a1++;
a3++;

printf("static a1 = %d, auto a3 = %d\n", a1, a3);
}

int main() {
counter(); // 输出: static a1 = 1, auto a3 = 1
counter(); // 输出: static a1 = 2, auto a3 = 1
counter(); // 输出: static a1 = 3, auto a3 = 1
return 0;
}
修饰全局变量/函数 (改变链接属性)
  • 默认情况: 全局变量和函数具有外部链接属性,意味着它们可以被项目中任何其他 .c 文件通过 extern 关键字访问。
  • static 修饰后:
    • 链接属性: 变为内部链接
    • 核心效果: 该全局变量或函数的作用域被严格限制在定义它的那个源文件内部,对其他文件不可见。

module_a.c 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
##include <stdio.h>

// g_global_var 具有外部链接,可以被其他文件访问
int g_global_var = 10;

// g_static_var 具有内部链接,仅在此文件内可见
static int g_static_var = 20;

// global_func() 具有外部链接
void global_func() {
printf("这是全局函数.\n");
}

// static_func() 具有内部链接
static void static_func() {
printf("这是静态函数,只能在本文件里调用.\n");
}

void access_vars() {
printf("在本文件中: g_global_var = %d, g_static_var = %d\n", g_global_var, g_static_var);
static_func();
}

main.c 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 声明我们希望从 module_a.c 中使用的变量和函数
extern int g_global_var;
extern void global_func();

// 下面的声明会导致链接错误,因为 g_static_var 和 static_func 是 static 的
// extern int g_static_var;
// extern void static_func();

// 声明一个在 module_a.c 中定义的函数
extern void access_vars();

int main() {
printf("在主函数中: g_global_var = %d\n", g_global_var); // OK
global_func(); // OK

// g_static_var = 30; // 链接错误: undefined reference to `g_static_var`
// static_func(); // 链接错误: undefined reference to `static_func`

access_vars(); // OK, 调用 module_a 内部的函数来访问其内部变量

return 0;
}

这个例子清晰地展示了 static 如何作为模块化的工具,将实现细节(g_static_var, static_func)隐藏在模块内部,只暴露公共接口(g_global_var, global_func)。


7. 函数

函数是C语言的功能模块,它将一段可重用的代码封装成一个“黑箱”,对外提供清晰的接口,隐藏内部实现。

函数的构成

  • 函数头: 返回类型 函数名(参数列表),定义了函数的对外接口。
  • 函数体: { ... },包含函数的具体实现。
  • 参数:
    • 形参: 函数定义中的变量,作为输入。
    • 实参: 函数调用时传递的实际值,用于初始化形参。
  • 返回值: 使用 return 关键字从函数中返回一个值。void类型表示不返回任何值。
  • 局部变量: 定义在函数体内的变量,存储在上,函数返回后即销毁。严禁返回局部变量的地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 通过指针交换两个变量的值
// p1和p2是形参(指针),temp是局部变量
void swap(double *p1, double *p2) {
double temp = *p1;
*p1 = *p2;
*p2 = temp;
}

int main(void) {
double a = 3.14, b = 0.618; // a, b是实参
swap(&a, &b); // 传递a和b的地址进行交换
return 0;
}

特殊函数类型

递归函数

函数在体内调用自身。必须包含递推关系终止条件,否则会因无限递归导致栈溢出。

1
2
3
4
5
// 计算阶乘: f(n) = n * f(n-1)
long long factorial(int n) {
if (n == 0) return 1; // 终止条件
return n * factorial(n - 1); // 递推关系
}

静态函数

使用 static 修饰的函数,其作用域被限制在当前源文件内,用于实现模块化和避免命名冲突。

1
2
// 该函数只能在定义它自己所在的.c文件中被调用
static void helper_function() { /* ... */ }

回调函数

通过函数指针作为参数传递给另一函数,由后者在特定时机“回调”执行。这是一种强大的解耦机制,常见于事件处理、分层设计和系统API中。

示例1:简单的策略切换
下面的 eat 函数并不关心具体“做什么菜”,只负责“吃”。具体菜系(yuecaichuancai)由调用方通过函数指针传入,实现了行为的灵活切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
##include <stdio.h>

// 定义两个菜系函数,它们的签名(参数、返回值)完全相同
void yuecai(int a, float b) { printf("做粤菜: %d %.1f\n", a, b); }
void chuancai(int a, float b) { printf("做川菜: %d %.1f\n", a, b); }

// eat函数接收一个函数指针cook作为参数
void eat(void (*cook)(int a, float b)) {
int a = 1;
float f = 2.5;
cook(a, f); // 调用传入的函数指针
printf("吃饭\n");
}

int main() {
printf("今晚想吃粤菜:\n");
eat(yuecai); // 传入粤菜函数的地址

printf("\n明天想吃川菜:\n");
eat(chuancai); // 传入川菜函数的地址
return 0;
}

示例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 函数接收一个信号编号和一个处理函数指针,并返回一个旧的处理函数指针。

  • 参数说明:

    1. int signum: 信号的编号。通常使用标准宏来表示,例如:
      • SIGINT: 中断信号,通常由用户在终端按下 Ctrl+C 产生。
      • SIGTERM: 终止信号,是 kill 命令发送的默认信号,用于请求程序正常退出。
      • SIGSEGV: 段错误信号,当程序试图访问其无权访问的内存区域时产生。
    2. sighandler_t handler: 指向信号处理函数的指针。这里可以传递三种值:
      • 一个自定义函数: 这就是我们的回调函数。它必须接收一个 int (信号编号) 参数并返回 void
      • SIG_DFL: 一个特殊的宏,表示恢复对该信号的默认处理行为(例如,SIGINT 的默认行为是终止程序)。
      • SIG_IGN: 一个特殊的宏,表示忽略该信号。
  • 返回值:

    • 成功时: 返回之前的信号处理函数的指针。这允许你保存旧的处理方式,以便之后可以恢复它。
    • 失败时: 返回 SIG_ERR,并设置 errno
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
##include <signal.h>
##include <stdio.h>
##include <unistd.h>

// 1. 实现具体的回调函数 (也称 handler 或钩子函数)
void my_handler(int sig) {
printf("\n接收到信号 %d!正在处理...\n", sig);
// 在实际应用中,这里会执行清理工作
// exit(0); // 可以在处理函数中决定是否退出程序
}

int main() {
// 2. 注册回调:将函数指针my_handler传给signal函数
// 相当于告诉内核:当收到SIGINT信号时,请调用my_handler
signal(SIGINT, my_handler);

printf("程序正在运行,PID: %d。请按 Ctrl+C 来触发回调...\n", getpid());

// 无限循环,等待信号的到来
while(1) {
sleep(1); // sleep可以减少CPU占用
}

return 0; // 这行通常不会被执行
}

面试题实战-函数参数传递:传值 vs 传址

C语言中所有函数参数传递本质上都是传值。但通过传递指针,可以达到“传址”的效果。

  • 传值:

    • 过程: 将实参的副本传递给形参。
    • 效果: 函数内部对形参的任何修改,都不会影响到函数外部的实参。
  • 传址:

    • 过程: 将实参的地址(通过 & 运算符获取)传递给一个指针类型的形参。
    • 效果: 函数内部通过解引用指针 (*p),可以直接访问和修改函数外部实参的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

// 传值:a是x的副本
void modify_value(int a) {
a = 99; // 只修改了副本a,不影响外部的x
}

// 传址:p接收了y的地址
void modify_address(int *p) {
*p = 99; // 通过地址,直接修改了外部y的值
}

int main() {
int x = 10;
modify_value(x);
printf("传值调用后, x = %d\n", x); // 输出: 10

int y = 10;
modify_address(&y);
printf("传址调用后, y = %d\n", y); // 输出: 99
return 0;
}

8. 作用域

作用域定义了标识符(变量名、函数名等)在代码中的可见范围。合理利用作用域可以避免命名冲突,是模块化编程的基础。

作用域的类型

  • 块作用域:

    • 范围: 一对花括号 {} 内部。

    • 特点: 在代码块内定义的变量(局部变量)仅在该块内可见,从定义处开始到右花括号结束。

    • 遮蔽 : 内层作用域可以定义与外层同名的变量,此时内层变量会临时遮蔽外层变量。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      int 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
2
3
4
5
6
7
// a.c 文件
static int file_local_var = 5; // 此变量仅在 a.c 中可见
static void helper_func() { /* ... */ } // 此函数仅在 a.c 中可调用

// b.c 文件
// extern int file_local_var; // 错误!无法链接到 a.c 中的 static 变量
// extern void helper_func(); // 错误!无法链接到 a.c 中的 static 函数

第三部分:C语言的精髓:指针与复合类型

9. 数组与指针

数组定义与初始化

  • 定义: 类型说明符 数组名 [常量表达式];

  • 初始化:

    1
    2
    3
    4
    int 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
    2
    long a[5] = {1, 2, 3, 4, 5};
    printf("%d\n", a[1]); // 输出第二个元素: 2

字符数组与多维数组

  • 字符数组初始化:

    1
    2
    char 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
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
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show(int N, int w[N][N]) {
for(int i=0; i<N; i++) {
for(int j=0; j<N; j++)
printf("%d\t", w[i][j]);
printf("\n");
}
}

int main(int argc, char const *argv[]) {
int N = 5; // 示例大小
int w[N][N];

srand(time(NULL));
for(int i=0; i<N; i++) {
for(int j=0; j<N; j++) {
w[i][j] = rand()%1000;
}
}
printf("生成的 %d x %d 数组:\n", N, N);
show(N, w);

if(N == 1) {
printf("周围数据平均数是: %f\n", (float)w[0][0]);
return 0;
}

float sum = 0.0;
// 累加首行和末行
for(int i=0; i<N; i++) {
sum += w[0][i]; // Top row
sum += w[N-1][i]; // Bottom row
}

// 累加首列和末列(不含已计算过的角点)
for(int i=1; i<N-1; i++) {
sum += w[i][0]; // Left column (excluding corners)
sum += w[i][N-1]; // Right column (excluding corners)
}

// 总边界元素个数为 4*N - 4
printf("\n边界元素总和: %.2f\n", sum);
printf("边界元素平均数是: %f\n", sum / (4.0 * N - 4.0));
return 0;
}

10. 指针核心

指针定义与赋值

  • 指针: 存储另一个变量的内存地址。

  • 定义: 类型说明符 *指针变量名;

  • 赋值 (取地址): 使用 & 运算符获取变量地址。

    1
    2
    3
    int 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
    3
    long 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
    16
    int 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
    7
    long 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
    #include <stdio.h>
    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
    #include <stdio.h>

    // 参数 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. 变量名 开始。
    2. 优先向 看,遇到 [] (数组) 或 () (函数)。
    3. 再向 看,遇到 * (指针)。
    4. 遇到括号,先解析完括号内的,再跳出。
    5. 最后,读最左边的 类型名
  • 示例 1:数组指针

1
2
3
4
5
6
int (*b[2])[3];
// 1. b: b 是一个...
// 2. b[2]: ...包含2个元素的数组
// 3. (*b[2]): 数组的元素是 指针
// 4. (*b[2])[3]: 指针指向 一个含3个int类型元素的数组
// 结论: b 是一个包含2个元素的数组,每个元素都是一个指针,指向一个包含3个int的数组
  • 示例 2:函数指针与 typedef 最佳实践

对于极端复杂的声明,手动分析既困难又易错。最佳实践是使用 typedef 定义别名。

  • 识别步骤

    1. 找到标识符 signal

    2. 向右看,是 (),说明 signal 是一个函数

    3. signal 函数括号内接收两个参数:int sig 和 void (*func)(int)

      对于第二个参数 func,我们可以独立应用右左法则:func 是一个指向“接收 int,返回 void”的函数的指针

    4. 解析完函数参数,回到 signal,向左看,是 *。说明 signal 函数的返回值是一个指针

    5. 跳出 signal(…) 的范围,继续向右看,是 (int)。说明 signal 返回的那个指针,指向一个接收 int 参数的函数

    6. 最后,看最左边的类型 void。这个函数返回 void 类型

1
2
3
4
5
6
7
8
9
// 原始声明,难以阅读
void (*signal(int sig, void (*func)(int)))(int);

// 最佳实践: 使用 typedef 别名简化
// 1. 为该函数指针类型起一个清晰的别名
typedef void (*handler_t)(int);

// 2. 使用别名重写声明,一目了然
handler_t signal(int sig, handler_t func);

零长数组

  • GNU C 扩展,type name[0],用于变长结构体,不占用结构体大小。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct 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
    8
    int 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
    8
    char *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
    6
    void *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: 指针指向的内容不可变 (常目标指针)。

  • int * const p: 指针自身存的地址不可变 (常指针)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int 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
    10
    int 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
    #include <stdio.h>

    // 这是一个普通函数
    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
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
50
51
52
53
54
55
56
57
58
59
60
##include <stdio.h>
##include <stdlib.h>

// 1. 定义一个结构体类型(设计蓝图)
// 结构体类型名叫student
struct student
{
// 成员
int age;
float score;
char *name;

// 内嵌结构体
struct
{
char *name;
float price;
} book;//结构体变量叫book
};

// 推荐将结构体作为指针传递,避免内存拷贝开销
void show_student_info(const struct student *p);

int main(int argc, char const *argv[])
{
// 2. 定义结构体变量并初始化
// 传统初始化,按成员顺序赋值
struct student zhangsan = {20, 90.0, "zhangsan", {"嵌入式参考书", 40.5}};

// 指定成员初始化 (推荐)
// 顺序改变不影响给成员赋值
struct student lisi = {
.age = 21,
.name = "lisi",
.book = {
.name = "数学书",
.price = 20.0},
.score = 70.0};

// 3. 访问成员
printf("姓名:%s\n", zhangsan.name);
printf("教材价格:%f\n", zhangsan.book.price);

// 推荐通过指针访问,指针只有8字节,而结构体变量本身可能很大
// 如果直接传递结构体(值传递),会复制整个结构体到栈上,开销巨大
struct student *p = &lisi;
printf("姓名:%s\n", p->name); // 箭头运算符
printf("分数:%f\n", (*p).score); // 等价的点运算符形式

show_student_info(&zhangsan);

return 0;
}

void show_student_info(const struct student *p)
{
printf("----学生信息----\n");
printf("姓名:%s\n", p->name);
printf("分数:%f\n", p->score);
}

结构体内存对齐

为了让CPU高效访问内存,编译器会自动对结构体成员进行对齐

  • 对齐规则: 每个成员的地址会是其自身大小的整数倍。例如,int (4字节) 会被放在能被4整除的地址上。
  • 填充: 为了满足对齐,编译器可能会在成员之间填充一些“空白”字节。
  • 整体大小: 整个结构体的大小,会是其最宽成员大小的整数倍。
  • 取消对齐: 在某些特定场景(如处理硬件数据流),可以使用 __attribute__((packed)) (GCC/Clang扩展)来告诉编译器不要进行内存对齐,以节省空间。
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
##include <stdio.h>

// M = 结构体成员占用最大的成员占用大小 = 4
struct node
{
int a; // 4字节, 放在偏移0处, size=4
char c; // 1字节, 放在偏移4处, size=5
short f; // 2字节, 放在偏移6处(需对齐到2的倍数), size=8
// 编译器在c和f之间填充了1个字节
}; // 整体大小需为M(4)的倍数, 8已经是4的倍数, 所以最终大小为8

// 使用packed属性取消对齐
struct node_packed
{
int a;
char c;
short f;
} __attribute__((packed));

int main(int argc, char const *argv[])
{
printf("默认对齐大小: %ld\n", sizeof(struct node)); // 输出: 8
printf("取消对齐大小: %ld\n", sizeof(struct node_packed)); // 输出: 7 (4+1+2)
return 0;
}

位域 (Bit-field)

位域允许我们在结构体中定义宽度为“位”(bit)的成员,这对于需要精确控制内存布局(如硬件寄存器、网络协议)的场景非常有用。

  • 定义: 类型 成员名 : 位数;
  • 限制:
    • 成员必须是整型 (int, unsigned int, char等)。
    • 不能对位域成员取地址 (&)。
    • 建议成员尽量是同类型,不然对于某些平台可能会出现内存没有按照预期对齐的问题
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
##include <stdio.h>

// 模拟一个硬件设备的数据包
struct device_data
{
// 2字节(16位)上存储了温度和湿度
unsigned short temp : 8; // 低8位
unsigned short humi : 8; // 高8位

// 1字节(8位)上存储了8个开关状态
unsigned char door1 : 1;
unsigned char door2 : 1;
unsigned char door3 : 1;
unsigned char door4 : 1;
unsigned char light1 : 1;
unsigned char light2 : 1;
unsigned char light3 : 1;
unsigned char light4 : 1;
};

int main(int argc, char const *argv[])
{
struct device_data d;
unsigned int input_data = 0x5A96F5; // 假设从硬件读到数据

// 将数据直接映射到结构体
d = *(struct device_data *)&input_data;

printf("原始数据: %#x\n", input_data);
printf("温度:%d°C\n", d.temp); // 0xF5 -> 245
printf("湿度:%d%%\n", d.humi); // 0x96 -> 150
printf("门1的状态:%s\n", d.door1 ? "开" : "关"); // 0x5 -> 0101, door1=1
printf("灯4的状态:%s\n", d.light4 ? "开" : "关"); // 0x5 -> 0101, light4=0

return 0;
}

14. 联合体 (Union)

联合体所有成员共享同一块内存空间,其大小由最大的成员决定。这使得联合体非常适合表示“互斥”的数据。

  • 特点:
    • 任何时刻,只有一个成员是有效的。
    • 对一个成员赋值,会覆盖其他成员的值。
  • 初始化:
    • 默认初始化第一个成员。
    • 使用指定成员初始化法可以初始化任意成员,但只有最后被初始化的那个有效。
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
##include <stdio.h>

// 定义一个联合体类型
union attribute
{
int i_val;
char c_val;
double d_val;
};

int main(int argc, char const *argv[])
{
printf("联合体大小: %ld\n", sizeof(union attribute)); // 8, 由double决定

// 指定成员初始化,只有最后一个有效
union attribute attr = {.i_val = 100, .c_val = 'k', .d_val = 3.14};

// 此时只有 d_val 是有效的
printf("d_val: %lf\n", attr.d_val); // 正确输出 3.14

// 读取其他成员会得到无意义的数据
printf("i_val: %d\n", attr.i_val); // 输出垃圾值
printf("c_val: %c\n", attr.c_val); // 输出垃圾值

// 重新赋值
attr.i_val = 999;
printf("\n赋值后 i_val: %d\n", attr.i_val); // 正确输出 999
printf("d_val: %lf\n", attr.d_val); // d_val被覆盖,输出垃圾值

return 0;
}

15. 枚举 (Enum)

枚举用于定义一组命名的整数常量,它比使用 #define 更具优势,因为枚举是类型安全的,且调试器可以识别枚举名。

  • 定义: enum 枚举名 {常量1, 常量2, ...};

  • 赋值:

    默认从 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
##include <stdio.h>

// 定义交通灯颜色的枚举
enum traffic_light {
RED, // 默认为 0
GREEN, // 在RED基础上+1, 为 1
YELLOW=10, // 手动指定为 10
BLUE // 在YELLOW基础上+1, 为 11
};

int main(int argc, char const *argv[])
{
// 定义枚举变量
enum traffic_light color = YELLOW;

printf("RED=%d, GREEN=%d, YELLOW=%d, BLUE=%d\n", RED, GREEN, YELLOW, BLUE);

if (color == YELLOW)
{
printf("当前颜色是黄灯, 请注意!\n");
}

return 0;
}

第四部分:构建大型程序

16. volatile关键字

volatile 是一个类型修饰符,它告诉编译器,被修饰的变量可能会在任何时候被程序外部的因素(如硬件、中断服务程序、其他线程)意外地改变。

  • 核心作用: 防止编译器过度优化。确保每次访问该变量时,都直接从其内存地址中读取,而不是使用可能已过时的寄存器缓存值。

  • 使用场景:

    1. 硬件寄存器: 嵌入式系统中,硬件状态寄存器的值会由硬件实时更新。
    2. 多线程共享变量: 一个变量被多个线程共享和修改时。
    3. 中断服务程序: 中断处理函数中会修改的全局变量。

代码示例

1
2
3
4
5
6
7
8
9
10
11
// 示例:一个可能被硬件修改的状态寄存器
volatile unsigned int *DEVICE_STATUS_REGISTER = (unsigned int *)0x12345678;

void check_device() {
// 每次循环都必须从内存地址0x12345678重新读取状态
// 如果没有volatile,编译器可能优化为只读一次,导致死循环
while (*DEVICE_STATUS_REGISTER == 0) {
// 等待设备就绪...
}
// ...设备已就绪,继续操作...
}

17. 预处理与高级技巧

高级宏定义

宏在预处理阶段进行简单的文本替换,功能强大但易出错。

宏运算符# (字符串化) ## (连接)

  • #: 将宏参数转换为一个字符串字面量。
  • ##: 将两个记号(token)连接成一个记号。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
##include <stdio.h>

// 使用 # 将宏参数 a 和 b 变成字符串
##define DOMAIN_NAME(a, b) "www." #a "." #b ".com"

// 使用 ## 连接参数,构造函数名
##define LAYER_INITCALL(num, layer) __zinitcall_##layer##_##num

// 模拟内核的初始化函数
void __zinitcall_service_1(void) { printf("%s\n", __FUNCTION__); }
void __zinitcall_feature_2(void) { printf("%s\n", __FUNCTION__); }


int main(int argc, char const *argv[])
{
// # 的使用
printf("%s\n", DOMAIN_NAME(yueqian, lab)); // 输出: "www.yueqian.lab.com"

// ## 的使用
LAYER_INITCALL(1, service)(); // 宏展开为: __zinitcall_service_1();
LAYER_INITCALL(2, feature)(); // 宏展开为: __zinitcall_feature_2();

return 0;
}

安全的带参宏 (GCC扩展)

简单的带参宏有副作用风险(如 MAX(a++, b))。Linux内核中广泛使用 ({...}) 语句表达式和 typeof 关键字 (均为GCC扩展) 来创建更安全的宏。

  • typeof(x): 获取变量 x 的类型。
  • ({...}): 语句表达式,将多条语句包裹成一个单一的表达式,其值为最后一条语句的结果
    • {}将多句话整合为一句话
    • ()将内部的 {…} 代码块“提升”为一个表达式,使其可以被赋值或用在其他需要值的地方。
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
##include <stdio.h>

// 传统宏的风险
// #define MAX(a, b) ((a) > (b) ? (a) : (b))

// 安全的、类型通用的宏 (Linux内核风格)
##define MAX(a, b) ({ \
typeof(a) _a = (a); \
typeof(b) _b = (b); \
(void) (&_a == &_b); /* 警告: 如果a和b类型不同 */ \
_a > _b ? _a : _b; \
})

int main(int argc, char const *argv[])
{
int x = 10;
int y = 20;
// 对于 MAX(x++, y++),传统宏会将++执行两次,而安全宏只执行一次
printf("Max is %d\n", MAX(x, y));

float f1 = 3.14, f2 = 2.71;
printf("Max is %f\n", MAX(f1, f2));

// 不同类型的参数会产生编译警告
// printf("Max is %f\n", MAX(x, f1));

return 0;
}

内联函数 (inline)

对于功能简单、调用频繁的函数,函数调用的开销可能超过函数本身的执行时间。inline 关键字建议编译器将函数体直接嵌入到调用处,以消除调用开销。

  • 特点:
    • 它是对编译器的建议,而非强制命令。
    • 相比宏函数,内联函数有类型检查,更安全。
    • 内联函数的定义通常放在头文件中。
  • 适用场景: 函数体小,且被频繁调用的函数。

inline.h 文件:

1
2
3
4
5
6
7
8
9
10
11
// inline.h
##ifndef INLINE_H
##define INLINE_H

// 将简短、频繁调用的函数定义为inline
static inline int max2(int a, int b)
{
return a > b ? a : b;
}

##endif

main.c 文件:

1
2
3
4
5
6
7
8
9
10
11
##include <stdio.h>
##include "inline.h"

int main(int argc, char const *argv[])
{
// 编译器可能会将 max2 的函数体直接替换到这里
printf("%d\n", max2(1, 2));
printf("%d\n", max2(10, 20));

return 0;
}

18. 多文件编程与头文件

随着项目变大,将所有代码放在一个.c文件里是不可行的。我们需要将代码按模块拆分到多个.c.h文件中。

条件编译

条件编译允许我们根据编译时定义的宏,来决定哪些代码块被编译,哪些被忽略。这对于编写平台兼容代码、管理调试信息等非常有用。

  • #if / #elif / #else / #endif: 基于宏的值进行判断,功能类似if-else
  • #ifdef 宏名: (if defined) 如果宏宏名已被定义,则编译后续代码。
  • #ifndef 宏名: (if not defined) 如果宏宏名未被定义,则编译后续代码。
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
##include <stdio.h>

// 可以在代码中定义宏
##define FEATURE_A 1
##define FEATURE_B 0

// 也可以在编译时通过-D选项定义,例如: gcc -DDEBUG_MODE condition.c
// #define DEBUG_MODE

int main(int argc, char const *argv[])
{
#if FEATURE_A
printf("功能A已启用。\n");
#endif

#if FEATURE_B
printf("功能B已启用。\n");
#else
printf("功能B未启用。\n");
#endif

// 常用于输出调试信息
#ifdef DEBUG_MODE
printf("调试信息: 进入函数 %s, 第 %d 行\n", __FUNCTION__, __LINE__);
#endif

#ifndef RELEASE_MODE
printf("当前为非发布版本。\n");
#endif

return 0;
}

头文件的作用与设计

头文件 (.h) 是多文件编程的核心,它扮演着模块“接口说明书”的角色。

  • 头文件的内容:

    1. 全局变量的声明: 使用 extern 关键字告知其他文件该变量的存在。 (extern int g_count;)
    2. 函数声明: 告知其他文件该函数的存在。 (void print_hello(void);)
    3. 宏定义: 模块提供的常量或宏函数。 (#define MAX_USERS 100)
    4. 结构体/联合体/枚举的定义: 允许多个.c文件使用相同的数据结构。
    5. static inline 函数: 对于小且频繁调用的函数,可以定义在头文件中。
  • 头文件防卫 :
    为了防止同一个头文件在编译时被重复包含(这会导致重定义错误),必须使用ifndef机制。这是
    强制性
    的最佳实践。

my_module.h 文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 头文件防卫开始
##ifndef __MY_MODULE_H__
##define __MY_MODULE_H__

// 2. 包含此模块依赖的其他头文件
##include <stdio.h>

// 3. 模块的“接口”声明
##define MODULE_VERSION "1.0"

extern int g_module_status; // 全局变量声明

struct my_data; // 结构体声明

void module_init(void); // 函数声明
int module_get_status(void);

// static inline函数可以直接在头文件中实现
static inline void print_version() {
printf("Version: %s\n", MODULE_VERSION);
}

// 4. 头文件防卫结束
##endif // __MY_MODULE_H__

my_module.c 文件 (模块的实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
##include "my_module.h" // 首先包含自己的头文件

// 全局变量的定义
int g_module_status = 0;

// 结构体的具体定义
struct my_data {
int id;
char *name;
};

// 函数的具体实现
void module_init(void) {
g_module_status = 1;
printf("模块已初始化。\n");
}

int module_get_status(void) {
return g_module_status;
}

main.c 文件 (模块的使用者):

1
2
3
4
5
6
7
8
##include "my_module.h" // 包含模块头文件以使用其功能

int main() {
module_init();
printf("模块状态: %d\n", module_get_status());
print_version();
return 0;
}


19. 常用字符串函数

strlen - 获取长度

lenth

返回字符串的长度,不包括末尾的 \0

1
size_t len = strlen("hello"); // len 的值为 5

strcpy - 字符串复制

copy

strncpystrcpy的安全版本,推荐使用以防止内存溢出。

1
2
3
4
5
char dest[10] = "123456789";
// 复制"hello"到dest,最多复制sizeof(dest)-1个字符
strncpy(dest, "hello", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // 确保字符串以'\0'结尾
// dest现在是 "hello"

strcat - 字符串拼接

catch

strncatstrcat的安全版本,推荐使用。

1
2
3
4
char dest[10] = "Hi, ";
// 将"Bob"拼接到dest末尾
strncat(dest, "Bob", sizeof(dest) - strlen(dest) - 1);
// dest现在是 "Hi, Bob"

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
2
3
4
5
6
7
char str[] = "www.yueqian.com";
char *p = strtok(str, "."); // p 指向 "www"
while (p != NULL) {
printf("%s\n", p);
p = strtok(NULL, ".");
}
// 依次输出: www, yueqian, com

第五部分:高级应用入门

20. 算法入门

经典排序:冒泡排序

问题: 实现冒泡排序算法。

核心知识: 通过重复遍历数组,比较相邻元素并交换,每一趟都将当前未排序部分的最大(或最小)元素“冒泡”到最终位置。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
void bubble_sort(int len, int A[]) {
for (int i = 0; i < len - 1; i++) {
for (int j = 0; j < len - i - 1; j++) {
if (A[j] > A[j + 1]) {
int temp = A[j];
A[j] = A[j + 1];
A[j + 1] = temp;
}
}
}
}

21. 数据结构入门

单链表

单链表是一种动态数据结构,它由一系列节点组成,每个节点包含数据和一个指向下一个节点的指针。

  • 核心思想: 在堆内存中动态创建节点 (malloc),并通过指针将这些分散的节点链接成一个有序序列。
  • 关键知识点:
    • struct 结构体:用于定义链表节点。
    • malloc / free:动态内存分配和释放。
    • 指针操作:通过 p->next 遍历和操作链表。
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
##include <stdio.h>
##include <stdlib.h>

// 1. 定义链表节点结构体
typedef struct Node {
int data;
struct Node *next;
} Node;

// 2. 创建新节点
Node* create_node(int data) {
Node *new_node = (Node*)malloc(sizeof(Node));
if (new_node == NULL) return NULL;
new_node->data = data;
new_node->next = NULL;
return new_node;
}

// 3. 打印链表
void print_list(Node *head) {
Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}

int main() {
// 创建链表: head -> node1 -> node2
Node *head = create_node(10);
head->next = create_node(20);
head->next->next = create_node(30);

print_list(head); // 输出: 10 -> 20 -> 30 -> NULL

// 释放链表内存 (此处省略)
return 0;
}

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
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
#include <stdio.h>
// 计算 S = 1 - 1/2 + 1/3 - 1/4 + ... + 1/99 - 1/100 的值
int main(int argc, char const *argv[]) {
float sum = 0;
int i = 1;
while (i <= 100) {
if (i % 2 == 1)
sum += 1.0 / i;
else
sum -= 1.0 / i;
// 或者:sum+=pow(-1,i+1)*(1/(float)i); //用(-1)的i+1次方代替判断,需要引用数学库
// 或者:sum+=1/(i*(i+1)); //规律是(1-1/2=1/2)(1/3-1/4=1/12)(1/5-1/6=1/30)

// 或者
// for (int i = 51; i <= 100; i++) sum_a += 1.0 / i;
// 将加法和减法分开:
// S = (1 + 1/3 + 1/5 + ... + 1/99) - (1/2 + 1/4 + 1/6 + ... + 1/100)
// 然后,加上减去的部分,减去两倍的部分:
// S = (1 + 1/2 + 1/3 + ... + 1/100) - 2 * (1/2 + 1/4 + 1/6 + ... + 1/100)
// 然后吧后半部分的2乘进去:
// S = (1 + 1/2 + 1/3 + ... + 1/100) - (1 + 1/2 + 1/3 + ... + 1/50)
// 所以:
// S = (1/51 + 1/52 + 1/53 + ... + 1/100)
i++;
}
printf("%f\n",sum);
return 0;
}

凑零钱问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main(int argc, char const *argv[])
{
// 目标:用1元、2元、5元的纸币凑出100元
int num = 0;
for(int i=0; i<=20; i++) // i个5元
{
for(int j=0; j<=50; j++) // j个2元
{
// 剩下的用1元补足
if(100 - i*5 - j*2 >= 0)
num++;
}
}
printf("num: %d
", num);
return 0;
}

解法二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main() {
int count = 0;
for (int i = 0; i <= 100 / 5; i++) {
for (int j = 0; j <= (100 - 5 * i) / 2; j++) {
int k = 100 - 5 * i - 2 * j;
// printf("5元: %d个, 2元: %d个, 1元: %d个
", i, j, k);
count++;
}
}
printf("使用数值 1, 2, 5 组合成 %d,共有 %d 种不同的组合。
", 100, count);
return 0;
}

统计数字’1’的出现次数

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
#include <stdio.h>

// 计算从1到n的整数中,数字'1'出现的总次数
long long count(int n) {
if (n < 1) return 0;
long long count = 0;
long long base = 1;
int round = n;
while (round > 0) {
int weight = round % 10;
round /= 10;
count += round * base;
if (weight == 1)
count += (n % base) + 1;
else if (weight > 1)
count += base;
base *= 10;
}
return count;
}

int main() {
unsigned int n = 213;
printf("从 1 到 %u, 数字 1 出现的总次数为: %lld
", n, count(n));
return 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
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show(int N, int w[N][N]) {
for(int i=0; i<N; i++) {
for(int j=0; j<N; j++)
printf("%d ", w[i][j]);
printf("
");
}
}

int main(int argc, char const *argv[]) {
int N = 5; // Example size
int w[N][N];

srand(time(NULL));
for(int i=0; i<N; i++) {
for(int j=0; j<N; j++) {
w[i][j] = rand()%1000;
}
}
show(N, w);

if(N == 1) {
printf("周围数据平均数是: %f
", (float)w[0][0]);
return 0;
}

float sum = 0.0;
for(int i=0; i<N; i++) {
sum += w[0][i]; // Top row
sum += w[N-1][i]; // Bottom row
}

for(int i=1; i<N-1; i++) {
sum += w[i][0]; // Left column (excluding corners)
sum += w[i][N-1]; // Right column (excluding corners)
}

printf("周围数据平均数是: %f
", sum/(4.0*N-4.0));
return 0;
}

23. 扩展阅读

(待补充…)

24. 笔记总结

C语言的核心是围绕指针、内存管理和作用域/存储期这三大支柱展开的,static关键字是贯穿其中的关键。

  • 基础与类型系统:程序由main函数启动,其根本是数据类型。必须精确掌握int, char, double等类型与printf/scanf格式说明符的对应关系:

    • printf%f可通用打印floatdouble(因float参数会自动提升double);而scanf接收double必须用%lf

    • scanf通过传递变量地址&来实现对调用方变量的修改,务必警惕其输入缓冲区的残留问题。

  • 运算符与表达式:重点掌握逻辑运算的短路求值&&, ||)、高效的位运算&, |, ^, ~, <<, >>)、条件运算符? :以及sizeof(它是一个运算符,不是函数)。当不确定优先级时,用**圆括号()**是最佳实践。

  • 控制流:熟练运用if-elseswitch(牢记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 pp不可改,*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指针的三种模式辨析,数组与指针的异同