C编程从入门到精通

一份写给零基础新手的C语言完全教程 · 章节清晰 · 实例丰富 · 循序渐进

第1章 环境搭建与第一个C程序

欢迎来到C语言的世界!C语言诞生于1972年,由丹尼斯·里奇(Dennis Ritchie)在贝尔实验室设计。虽然它已经50多岁了,但至今仍是编程界的"常青树"——操作系统(如Linux、Windows内核)、嵌入式设备、游戏引擎、数据库……到处都有C语言的身影。

学习编程的第一步,就是搭建好环境,然后亲手写出第一个程序!

1.1 安装C语言编译器

C语言是编译型语言,我们写的代码需要通过"编译器"转换成机器能懂的程序。最常用的C语言编译器是 GCC(GNU Compiler Collection)。

Windows 系统

推荐安装 MinGW-w64 或使用 Dev-C++ / Code::Blocks 这类集成开发环境(IDE),它们自带编译器,安装即用。

macOS 系统

打开终端,输入 xcode-select --install,系统会自动安装命令行工具(包含 GCC/Clang 编译器)。

Linux 系统

打开终端,运行:

bashsudo apt update
sudo apt install gcc
gcc --version

看到版本号就说明安装成功了!

1.2 第一个C程序:Hello World

打开一个文本编辑器(记事本、VS Code、Sublime等),输入以下代码:

c#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

逐行解释

代码含义
#include <stdio.h>引入标准输入输出库,这样我们才能使用 printf 等函数
int main()程序的入口函数,程序从这里开始执行
{ ... }花括号内的代码是 main 函数要执行的内容
printf("Hello, World!\n");在屏幕上打印"Hello, World!",\n 表示换行
return 0;程序正常结束,返回0给操作系统

1.3 编译和运行

把文件保存为 hello.c(注意后缀是 .c),然后在终端中执行:

bashgcc hello.c -o hello
./hello

第一行命令:用GCC编译 hello.c,生成可执行文件 hello-o 指定输出文件名)。
第二行命令:运行这个程序。

Hello, World!
✨ 小提示:如果看到 "Hello, World!" 打印在屏幕上,恭喜你——你的第一个C程序跑起来了!

1.4 代码的基本结构

每个C程序都有固定的基本骨架,就像盖房子需要地基一样:

c// 1. 头文件引入(使用哪个库就引入哪个)
#include <stdio.h>

// 2. 主函数(每个程序有且只有1个main函数)
int main() {
    // 3. 在这里写你的代码
    return 0;  // 4. 返回0表示正常退出
}

1.5 基础实战:写几个小程序

实例1:输出多行文字

c#include <stdio.h>

int main() {
    printf("*****************\n");
    printf("*  欢迎学C语言  *\n");
    printf("*  坚持就是胜利 *\n");
    printf("*****************\n");
    return 0;
}
***************** * 欢迎学C语言 * * 坚持就是胜利 * *****************
💡 动手试试:修改 printf 里的文字,换成你自己的问候语,重新编译运行看看效果!

第2章 变量与数据类型

程序本质上是在处理数据。就像做菜需要不同的食材一样,编程也需要用不同的"容器"来存放不同类型的数据。这些"容器"就叫做变量

2.1 什么是变量?

变量就是一块内存空间,我们给它取个名字,然后往里面存数据。有点像带标签的盒子:

cint age;          // 声明一个叫age的变量,用来存整数
age = 18;          // 把18这个值存进去
int score = 95;     // 也可以声明的同时直接赋值(初始化)
💡 通俗理解:变量 = 给内存地址起了一个好记的名字。

2.2 基本数据类型

C语言提供了几种基本的"盒子"类型:

类型名称大小取值范围示例
int整型(整数)4字节-21亿 ~ 21亿42, -100
float单精度浮点(小数)4字节约 ±3.4×10³⁸3.14, -0.5
double双精度浮点(小数)8字节约 ±1.8×10³⁰⁸3.1415926
char字符型1字节-128 ~ 127'A', '9'
void无类型用于函数返回值

2.3 变量命名规则

⚠️ 新手常犯错误:int 1st; — 数字开头,错误!
float my-score; — 不能用连字符,错误!
正确写法:int first;float my_score;

2.4 实例:使用各种数据类型

c#include <stdio.h>

int main() {
    int age = 25;
    float price = 19.99;
    double pi = 3.141592653589793;
    char grade = 'A';

    printf("年龄:%d\n", age);
    printf("价格:%.2f\n", price);
    printf("圆周率:%.15f\n", pi);
    printf("成绩等级:%c\n", grade);

    return 0;
}
年龄:25 价格:19.99 圆周率:3.141592653589793 成绩等级:A

%d%f%c格式占位符,就像占座一样,告诉 printf 这个位置要填入什么类型的数据。后面会详细讲。

2.5 整型的各种修饰

int 还可以配合 shortlongunsigned 等修饰符使用:

c#include <stdio.h>

int main() {
    short s = 100;          // 短整型(2字节,范围更小但省内存)
    long l = 123456789L;    // 长整型(8字节,范围更大)
    unsigned int u = 300;  // 无符号整型(只存非负数,范围翻倍)

    printf("short: %d\n", s);
    printf("long: %ld\n", l);
    printf("unsigned: %u\n", u);
    return 0;
}
💡 怎么选?一般就用 intdouble。只有需要节省内存或特殊需求时才用 short / long / unsigned

第3章 运算符与表达式

有了变量之后,就需要对它们进行各种"运算"——加减乘除、比大小、判断真假……这就是运算符的用武之地。

3.1 算术运算符

运算符含义示例结果
+加法10 + 313
-减法10 - 37
*乘法10 * 330
/除法10 / 33(整数除法)
%取模(求余数)10 % 31
⚠️ 重点注意:整数除法!
5 / 2 的结果是 2,不是2.5!两个整数相除,结果还是整数(直接丢弃小数部分)。
如果想得到小数,至少一个数写成浮点数:5.0 / 25 / 2.0 结果是 2.5

3.2 实例:算术运算

c#include <stdio.h>

int main() {
    int a = 15, b = 4;

    printf("%d + %d = %d\n", a, b, a + b);
    printf("%d - %d = %d\n", a, b, a - b);
    printf("%d * %d = %d\n", a, b, a * b);
    printf("%d / %d = %d\n", a, b, a / b);   // 整数除法
    printf("%d %% %d = %d\n", a, b, a % b);  // 取余

    // 浮点数除法对比
    printf("15.0 / 4 = %.2f\n", 15.0 / 4);
    return 0;
}
15 + 4 = 19 15 - 4 = 11 15 * 4 = 60 15 / 4 = 3 15 % 4 = 3 15.0 / 4 = 3.75

3.3 关系运算符(比较运算)

用于比较两个值的大小关系,结果只有:真(1)假(0)

运算符含义示例(a=5, b=3)结果
==等于a == b0(假)
!=不等于a != b1(真)
>大于a > b1(真)
<小于a < b0(假)
>=大于等于a >= b1(真)
<=小于等于a <= b0(假)

3.4 逻辑运算符

运算符含义说明示例
&&与(AND)两边都为真才为真(5>3) && (4>2) → 真
||或(OR)一边为真即为真(5>3) || (4<2) → 真
!非(NOT)取反!(5>3) → 假

3.5 复合赋值与自增自减

复合赋值运算符

cint x = 10;
x += 5;    // 等价于 x = x + 5,现在x=15
x -= 3;    // 等价于 x = x - 3,现在x=12
x *= 2;    // 等价于 x = x * 2,现在x=24

自增(++)和自减(--)

cint i = 5;
i++;        // 先使用i的当前值,再加1(后置++)
++i;        // 先加1,再使用i的新值(前置++)

3.6 实例:自增前置后置的区别

c#include <stdio.h>

int main() {
    int a = 5, b = 5;

    printf("前置++a:%d (先加后用)\n", ++a);  // a先变成6,再打印
    printf("此时a的值:%d\n", a);              // a已经是6

    printf("后置b++:%d (先用后加)\n", b++);  // 先打印b(5),再加1
    printf("此时b的值:%d\n", b);              // b变成6了

    return 0;
}
前置++a:6 (先加后用) 此时a的值:6 后置b++:5 (先用后加) 此时b的值:6
💡 记忆口诀:"前置先加,后置后加"——++在前面就先变,++在后面就后变。

第4章 输入与输出

程序要和用户交互,就需要输入(接收用户的数据)和输出(把结果显示出来)。C语言中最常用的就是 printf(输出)和 scanf(输入)。

4.1 printf — 格式化输出

printf 的完整格式:printf("格式字符串", 变量1, 变量2, ...);

常用格式占位符

占位符对应类型示例
%dint(整数)printf("%d", 42);
%ffloat / double(小数)printf("%.2f", 3.14);
%cchar(单个字符)printf("%c", 'A');
%s字符串printf("%s", "hello");
%p指针(内存地址)printf("%p", &x);
%ldlong intprintf("%ld", 123L);
%uunsigned intprintf("%u", 100);

4.2 scanf — 格式化输入

scanf("格式字符串", &变量1, &变量2, ...);

⚠️ 特别注意:scanf 的变量前面要加 &(取地址符)!新手最常忘的就是这个。不加 & 程序会崩溃!

4.3 实例:基本的输入输出

c#include <stdio.h>

int main() {
    int age;
    float height;

    printf("请输入你的年龄:");
    scanf("%d", &age);        // 注意age前面的&

    printf("请输入你的身高(米):");
    scanf("%f", &height);

    printf("\n=== 你的信息 ===\n");
    printf("年龄:%d岁\n", age);
    printf("身高:%.2f米\n", height);

    return 0;
}
请输入你的年龄:25 请输入你的身高(米):1.75 === 你的信息 === 年龄:25岁 身高:1.75米

4.4 实例:计算圆的面积

一个完整的实用例子——让用户输入半径,程序计算面积:

c#include <stdio.h>
#define PI 3.14159

int main() {
    double radius, area;

    printf("请输入圆的半径:");
    scanf("%lf", &radius);    // double用%lf

    area = PI * radius * radius;

    printf("半径为 %.2f 的圆,面积 = %.4f\n", radius, area);

    return 0;
}
请输入圆的半径:5 半径为 5.00 的圆,面积 = 78.5398
💡 判断一个数是否为偶数:n % 2 == 0,如果余数为0就是偶数。后面学了 if 语句就可以做这种判断了。

第5章 条件判断

程序不是一条路走到黑的——很多时候要根据不同的条件执行不同的操作。比如:如果分数≥60分就显示"及格",否则显示"不及格"。这就是条件判断

5.1 if 语句

最基本的条件判断结构:

cif (条件) {
    // 条件为真时执行这里的代码
}

5.2 if-else 语句

cif (条件) {
    // 条件为真时执行
} else {
    // 条件为假时执行
}

5.3 实例:判断奇偶与成绩等级

c#include <stdio.h>

int main() {
    int num;

    printf("请输入一个整数:");
    scanf("%d", &num);

    if (num % 2 == 0) {
        printf("%d 是偶数\n", num);
    } else {
        printf("%d 是奇数\n", num);
    }

    return 0;
}
请输入一个整数:7 7 是奇数

5.4 else if — 多条件判断

c#include <stdio.h>

int main() {
    int score;

    printf("请输入考试成绩(0-100):");
    scanf("%d", &score);

    if (score < 0 || score > 100) {
        printf("分数无效!\n");
    } else if (score >= 90) {
        printf("优秀!\n");
    } else if (score >= 80) {
        printf("良好\n");
    } else if (score >= 70) {
        printf("中等\n");
    } else if (score >= 60) {
        printf("及格\n");
    } else {
        printf("不及格,继续努力!\n");
    }

    return 0;
}
请输入考试成绩(0-100):85 良好
💡 注意顺序:else if 是从上往下依次判断的,一旦某个条件成立,后面的就不执行了。所以条件范围要从"最严格"往"最宽松"写。

5.5 switch 语句

当条件是基于某个变量的"等于"判断时,用 switch 比写一堆 if-else 更清晰:

c#include <stdio.h>

int main() {
    int day;

    printf("请输入星期几(1-7):");
    scanf("%d", &day);

    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:
        case 7: printf("周末\n"); break;
        default: printf("无效输入!\n");
    }

    return 0;
}
请输入星期几(1-7):5 星期五
⚠️ 别忘了 break!如果某个 case 后面没有 break,程序会"穿透"继续执行下一个 case——这叫"fall through"(向下穿透)。有时候是故意用的(如上例中6和7都进入"周末"),但大多数时候是bug!

5.6 三目运算符

一种简洁的条件表达式:条件 ? 值1 : 值2

c#include <stdio.h>

int main() {
    int a = 10, b = 20;
    int max = (a > b) ? a : b;  // 如果a>b则取a,否则取b

    printf("最大值是:%d\n", max);

    // 也嵌套使用
    int x = 0;
    printf("%d 是%s\n", x, (x > 0) ? "正数" : (x == 0) ? "零" : "负数");

    return 0;
}
最大值是:20 0 是零
💡 什么时候用三目?简单二选一的时候用它,代码更简洁。条件复杂还是用 if-else,可读性更好。

第6章 循环结构

生活中经常需要重复做同一件事——比如每天背10个单词,连续30天。编程中也一样,如果需要让某段代码重复执行,就用循环

C语言提供了3种循环:forwhiledo-while

6.1 while 循环

先判断条件,条件为真就执行循环体:

cwhile (条件) {
    // 循环体——条件满足时重复执行
}

实例:从1数到5

c#include <stdio.h>

int main() {
    int i = 1;

    while (i <= 5) {
        printf("%d ", i);
        i++;  // 别忘了让i增加,否则死循环!
    }
    printf("\n");

    return 0;
}
1 2 3 4 5
⚠️ 死循环警告!如果忘记写 i++,条件永远为真,循环会一直运行下去——按 Ctrl+C 才能强制终止!

6.2 do-while 循环

和 while 很像,但先执行一次再判断条件——也就是说,循环体至少会执行1次:

c#include <stdio.h>

int main() {
    int i = 1;

    do {
        printf("%d ", i);
        i++;
    } while (i <= 5);

    printf("\n");

    // 即使条件不满足,也会执行一次
    i = 10;
    do {
        printf("至少执行一次!i=%d\n", i);
        i++;
    } while (i <= 5);

    return 0;
}
1 2 3 4 5 至少执行一次!i=10

6.3 for 循环——最常用的循环

for 循环把"初始化"、"条件"、"更新"集中在一起,结构清晰:

cfor (初始化; 条件; 更新) {
    // 循环体
}

实例:for循环多种用法

c#include <stdio.h>

int main() {
    // 从1到10
    printf("1到10:");
    for (int i = 1; i <= 10; i++) {
        printf("%d ", i);
    }
    printf("\n");

    // 递减:从10到1
    printf("10到1:");
    for (int i = 10; i >= 1; i--) {
        printf("%d ", i);
    }
    printf("\n");

    // 步长不为1:输出偶数
    printf("2到20的偶数:");
    for (int i = 2; i <= 20; i += 2) {
        printf("%d ", i);
    }
    printf("\n");

    return 0;
}
1到10:1 2 3 4 5 6 7 8 9 10 10到1:10 9 8 7 6 5 4 3 2 1 2到20的偶数:2 4 6 8 10 12 14 16 18 20

6.4 实例:九九乘法表

嵌套循环的经典例子——用两个 for 循环实现:

c#include <stdio.h>

int main() {
    for (int i = 1; i <= 9; i++) {
        for (int j = 1; j <= i; j++) {      // j只到i,形成三角形
            printf("%d×%d=%-2d ", j, i, i * j);  // %-2d左对齐,占2位
        }
        printf("\n");
    }
    return 0;
}
1×1=1 1×2=2 2×2=4 1×3=3 2×3=6 3×3=9 1×4=4 2×4=8 3×4=12 4×4=16 1×5=5 2×5=10 3×5=15 4×5=20 5×5=25 1×6=6 2×6=12 3×6=18 4×6=24 5×6=30 6×6=36 1×7=7 2×7=14 3×7=21 4×7=28 5×7=35 6×7=42 7×7=49 1×8=8 2×8=16 3×8=24 4×8=32 5×8=40 6×8=48 7×8=56 8×8=64 1×9=9 2×9=18 3×9=27 4×9=36 5×9=45 6×9=54 7×9=63 8×9=72 9×9=81

6.5 break 和 continue

c#include <stdio.h>

int main() {
    // break:找到第一个能被7整除的数就停
    printf("break示例:从1开始找7的倍数\n");
    for (int i = 1; i <= 20; i++) {
        if (i % 7 == 0) {
            printf("找到7的倍数:%d,中断循环\n", i);
            break;  // 跳出整个for循环
        }
        printf("%d ", i);
    }

    printf("\n\n");

    // continue:跳过偶数
    printf("continue示例:只打印奇数\n");
    for (int i = 1; i <= 10; i++) {
        if (i % 2 == 0) {
            continue;  // 跳过偶数,不打印
        }
        printf("%d ", i);
    }
    printf("\n");

    return 0;
}
break示例:从1开始找7的倍数 1 2 3 4 5 6 找到7的倍数:7,中断循环 continue示例:只打印奇数 1 3 5 7 9

6.6 实例:猜数字游戏

综合运用循环和条件判断:

c#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    int secret, guess, attempts = 0;

    srand(time(0));                   // 初始化随机种子
    secret = rand() % 100 + 1;          // 生成1-100的随机数

    printf("猜数字游戏(1-100之间)\n");

    do {
        printf("请输入你的猜测:");
        scanf("%d", &guess);
        attempts++;

        if (guess > secret) {
            printf("猜大了!\n");
        } else if (guess < secret) {
            printf("猜小了!\n");
        } else {
            printf("🎉 恭喜!猜对了!共用%d次\n", attempts);
        }
    } while (guess != secret);

    return 0;
}
猜数字游戏(1-100之间) 请输入你的猜测:50 猜大了! 请输入你的猜测:25 猜小了! 请输入你的猜测:37 🎉 恭喜!猜对了!共用3次
💡 程序结构总结:
for:知道要循环几次的时候用(如遍历数组)
while:不确定次数,只知道条件的时候用
do-while:保证至少执行一次的情况(如猜数字,至少猜1次)

第7章 数组

前面我们学的变量,一个变量只能存一个值。如果要存全班50个同学的成绩,难道要定义50个变量吗?那也太麻烦了!数组就是用来解决这个问题的——它可以存储一组相同类型的数据

💡 一句话理解:数组就像一个有很多格子的储物柜,每个格子都有一个编号(下标),你可以往格子里放东西,也可以从格子里取东西。

7.1 一维数组

7.1.1 声明数组

声明数组需要告诉计算机三件事:数据类型数组名数组大小

c// 语法:数据类型 数组名[元素个数];
int scores[5];       // 声明一个包含5个整数的数组
float prices[10];     // 声明一个包含10个浮点数的数组
char letters[26];     // 声明一个包含26个字符的数组

声明了数组之后,系统会在内存中开辟一块连续的空间。比如 int scores[5],会在内存中分配 5×4=20 个字节的空间(每个 int 占 4 字节)。

💡 数组下标从0开始:C语言的数组下标(索引)从0开始,所以 int a[5] 的下标范围是 0~4,没有 a[5]!这是新手最容易犯的错误。

7.1.2 初始化数组

数组可以在声明时直接赋初值:

c#include <stdio.h>

int main() {
    // 完全初始化——给所有元素赋值
    int a[5] = {10, 20, 30, 40, 50};
    
    // 部分初始化——未指定的元素自动赋值为0
    int b[5] = {1, 2, 3};  // b = {1, 2, 3, 0, 0}
    
    // 省略大小——编译器自动计算元素个数
    int c[] = {100, 200, 300};  // c的大小自动设为3
    
    // 全部初始化为0
    int d[5] = {0};  // d = {0, 0, 0, 0, 0}
    
    // 逐一赋值(声明后)
    int e[3];
    e[0] = 7;
    e[1] = 8;
    e[2] = 9;
    
    return 0;
}

7.1.3 访问和遍历数组

通过 数组名[下标] 来访问数组元素。配合循环,可以很方便地遍历整个数组:

c#include <stdio.h>

int main() {
    int scores[5] = {85, 92, 78, 90, 88};
    
    // 遍历数组:用下标从0到4访问每个元素
    printf("所有成绩:");
    for (int i = 0; i < 5; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n");
    
    // 修改某个元素
    scores[2] = 82;  // 把78改成82
    
    // 重新打印
    printf("修改后的成绩:");
    for (int i = 0; i < 5; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n");
    
    return 0;
}
所有成绩:85 92 78 90 88 修改后的成绩:85 92 82 90 88

7.2 二维数组

一维数组就像一列格子,二维数组就像一张表格——有行有列。比如存储一个班级的考试成绩,一行是一个学生,一列是一科成绩。

7.2.1 声明和初始化

c// 语法:数据类型 数组名[行数][列数];
int matrix[3][4];  // 3行4列的二维数组

// 声明时初始化
int grid[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

// 也可以写成一行(不推荐,不易读)
int grid2[2][3] = {1, 2, 3, 4, 5, 6};

// 省略第一个维度的大小(编译器会自动算出行数)
int grid3[][3] = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};  // 3行3列

7.2.2 遍历二维数组

需要两层循环:外层循环控制行,内层循环控制列。

c#include <stdio.h>

int main() {
    int scores[3][4] = {
        {85, 90, 78, 92},   // 第1个学生的4科成绩
        {76, 88, 95, 80},   // 第2个学生的4科成绩
        {91, 82, 87, 79}    // 第3个学生的4科成绩
    };
    
    printf("        语文 数学 英语 编程\n");
    printf("        ─────────────────\n");
    
    for (int i = 0; i < 3; i++) {       // 外层循环:遍历每个学生
        printf("学生%d:  ", i + 1);
        for (int j = 0; j < 4; j++) {   // 内层循环:遍历每科成绩
            printf("%-5d", scores[i][j]);
        }
        printf("\n");
    }
    
    return 0;
}
语文 数学 英语 编程 ───────────────── 学生1: 85 90 78 92 学生2: 76 88 95 80 学生3: 91 82 87 79

7.2.3 二维数组的存储方式

在内存中,二维数组是按行优先存储的:先存第0行的所有元素,再存第1行的所有元素……所以 int a[2][3] 在内存中的排列是:a[0][0] → a[0][1] → a[0][2] → a[1][0] → a[1][1] → a[1][2]

7.3 数组的经典应用:冒泡排序

冒泡排序是最经典的排序算法。它的思路很简单:每次比较相邻的两个数,把较大的往后"冒泡"。

💡 形象理解:就像一杯汽水里的气泡,轻的气泡往上浮,重的往下沉。每次比较相邻的两个数,如果顺序不对就交换,一轮下来最大的数就"沉"到最后了。

来看一个详细的例子,对数组 {64, 34, 25, 12, 22} 进行升序排序:

c#include <stdio.h>

int main() {
    int arr[] = {64, 34, 25, 12, 22};
    int n = 5;  // 数组长度
    
    printf("原始数组:");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n\n");
    
    // 冒泡排序
    for (int i = 0; i < n - 1; i++) {       // 外层循环:需要n-1轮比较
        printf("第%d轮开始:\n", i + 1);
        
        for (int j = 0; j < n - 1 - i; j++) { // 内层循环:每轮比较的次数递减
            printf("  比较 arr[%d]=%d 和 arr[%d]=%d → ",
                   j, arr[j], j + 1, arr[j + 1]);
            
            if (arr[j] > arr[j + 1]) {
                // 交换相邻的两个数
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                printf("交换 → 数组变为:");
            } else {
                printf("不交换 → 数组:");
            }
            
            // 打印当前数组状态
            for (int k = 0; k < n; k++) printf("%d ", arr[k]);
            printf("\n");
        }
        printf("  第%d轮结果:", i + 1);
        for (int k = 0; k < n; k++) printf("%d ", arr[k]);
        printf("\n\n");
    }
    
    printf("排序完成!最终结果:");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    
    return 0;
}
原始数组:64 34 25 12 22 第1轮开始: 比较 arr[0]=64 和 arr[1]=34 → 交换 → 数组变为:34 64 25 12 22 比较 arr[1]=64 和 arr[2]=25 → 交换 → 数组变为:34 25 64 12 22 比较 arr[2]=64 和 arr[3]=12 → 交换 → 数组变为:34 25 12 64 22 比较 arr[3]=64 和 arr[4]=22 → 交换 → 数组变为:34 25 12 22 64 第1轮结果:34 25 12 22 64 第2轮开始: 比较 arr[0]=34 和 arr[1]=25 → 交换 → 数组变为:25 34 12 22 64 比较 arr[1]=34 和 arr[2]=12 → 交换 → 数组变为:25 12 34 22 64 比较 arr[2]=34 和 arr[3]=22 → 交换 → 数组变为:25 12 22 34 64 第2轮结果:25 12 22 34 64 第3轮开始: 比较 arr[0]=25 和 arr[1]=12 → 交换 → 数组变为:12 25 22 34 64 比较 arr[1]=25 和 arr[2]=22 → 交换 → 数组变为:12 22 25 34 64 第3轮结果:12 22 25 34 64 第4轮开始: 比较 arr[0]=12 和 arr[1]=22 → 不交换 → 数组:12 22 25 34 64 第4轮结果:12 22 25 34 64 排序完成!最终结果:12 22 25 34 64
⚠️ 常见错误: 1. 数组下标越界int a[5] 只有 a[0]~a[4],访问 a[5] 会访问到未知内存,导致不可预料的错误。 2. 数组大小用变量:早期的C标准(C89)不允许 int n=5; int a[n];,标准C99之后才支持变长数组(VLA),最好用常量或宏定义。 3. 忘记初始化:局部数组如果没有初始化,里面存的是"垃圾值"(随机数),使用前一定要初始化。

7.4 实例:求最大值和平均值

综合运用数组和循环,写一个程序:输入5个同学的成绩,打印出最高分和平均分。

c#include <stdio.h>

int main() {
    int scores[5];
    int sum = 0;
    int max = 0;  // 假设成绩都≥0
    float avg;
    
    // 1. 输入5个成绩
    printf("请输入5个同学的成绩:\n");
    for (int i = 0; i < 5; i++) {
        printf("  第%d个同学:", i + 1);
        scanf("%d", &scores[i]);
    }
    
    // 2. 计算总和和最大值
    for (int i = 0; i < 5; i++) {
        sum += scores[i];          // 累加求和
        if (scores[i] > max) {     // 如果当前成绩比max大
            max = scores[i];       // 更新max
        }
    }
    
    // 3. 计算平均分
    avg = (float)sum / 5;  // 强制类型转换,防止整数除法
    
    // 4. 输出结果
    printf("\n========== 成绩统计 ==========\n");
    printf("成绩列表:");
    for (int i = 0; i < 5; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n");
    printf("总  分:%d\n", sum);
    printf("最高分:%d\n", max);
    printf("平均分:%.1f\n", avg);
    printf("==============================\n");
    
    return 0;
}
请输入5个同学的成绩: 第1个同学:85 第2个同学:92 第3个同学:78 第4个同学:90 第5个同学:88 ========== 成绩统计 ========== 成绩列表:85 92 78 90 88 总 分:433 最高分:92 平均分:86.6 ==============================
💡 编程技巧: 1. 求最大值的"打擂法":先假设第一个元素最大,然后逐个比较,发现更大的就"换人"。 2. 求平均值时注意整数除法:sum / 5 是整数除法(会丢掉小数),要写成 (float)sum / 5sum / 5.0。 3. 可以用 #define SIZE 5 定义数组大小,这样修改数据量时只需要改一处。

第8章 字符串

C语言中没有专门的"字符串"类型。那字符串怎么表示呢?C语言用字符数组来存储字符串,并且用 '\0'(空字符) 作为字符串的结束标志。

💡 形象理解:字符串就像一个装满字符的小火车,火车末尾挂着一个特殊的车厢 '\0',告诉计算机"到这为止了"。

8.1 字符数组与字符串

8.1.1 字符数组声明

c// 字符数组(不一定以\0结尾,不一定是字符串)
char c1[5] = {'H', 'e', 'l', 'l', 'o'};  // 只是字符数组,不是字符串

// 字符串(以\0结尾的字符数组才是字符串)
char s1[6] = {'H', 'e', 'l', 'l', 'o', '\0'};  // 字符串
char s2[6] = "Hello";  // 直接用字符串常量初始化,编译器自动加\0
char s3[] = "Hello";   // 编译器自动计算长度为6(含\0)
⚠️ 重要区别: char c1[5] = {'H','e','l','l','o'}; —— 这只是字符数组,没有 '\0',不能当字符串用char s3[] = "Hello"; —— 这是字符串,末尾自动有 '\0',长度为6(不是5!)。

字符串常量用双引号括起来,字符常量用单引号括起来,别搞混了:

c"A"  // 字符串常量,包含'A'和'\0'两个字符
'A'  // 字符常量,就是一个字符

8.1.2 字符串的输入与输出

c#include <stdio.h>

int main() {
    char name[20];  // 预留20个字符的空间
    
    printf("请输入你的名字:");
    scanf("%s", name);  // 注意:不需要 & 符号!数组名本身就是地址
    
    printf("你好,%s!\n", name);  // %s 是字符串格式符
    
    // 逐个字符遍历字符串
    printf("你的名字是:");
    for (int i = 0; name[i] != '\0'; i++) {
        printf("%c ", name[i]);
    }
    printf("\n");
    
    return 0;
}
请输入你的名字:ZhangSan 你好,ZhangSan! 你的名字是:Z h a n g S a n
⚠️ 注意:scanf("%s", ...) 遇到空格就会停止读取,所以输入 "Zhang San" 只会读到 "Zhang"。要读取带空格的字符串,可以用 fgets() 函数,后面会讲到。

8.2 字符串处理函数

C语言提供了几个常用的字符串函数,都在 <string.h> 头文件中。在写代码之前需要先 #include <string.h>

8.2.1 strlen() —— 获取字符串长度

返回字符串的长度,不包括 '\0'。

c#include <stdio.h>
#include <string.h>

int main() {
    char str[] = "Hello";
    
    printf("字符串:%s\n", str);
    printf("长度:%zu\n", strlen(str));  // %zu 是 size_t 类型的格式符
    
    // sizeof 和 strlen 的区别
    printf("sizeof(str) = %zu\n", sizeof(str));   // 6(包含\0)
    printf("strlen(str) = %zu\n", strlen(str));    // 5(不包含\0)
    
    // 空字符串
    char empty[] = "";
    printf("空字符串的长度:%zu\n", strlen(empty));  // 0
    
    return 0;
}
字符串:Hello 长度:5 sizeof(str) = 6 strlen(str) = 5 空字符串的长度:0
💡 记忆方法:sizeof 问的是"数组这个盒子有多大",strlen 问的是"盒子里的字符串有多长"。一个是容器的容量,一个是内容的长度。

8.2.2 strcpy() —— 字符串拷贝

把一个字符串复制到另一个字符数组中。不能直接用 = 给字符串赋值!

c#include <stdio.h>
#include <string.h>

int main() {
    char src[] = "Hello";
    char dest[20];
    
    // 错误的做法(不能用 = 给数组赋值)
    // dest = src;  // ❌ 编译错误!
    
    // 正确的做法:用 strcpy
    strcpy(dest, src);  // 把 src 复制到 dest
    
    printf("源字符串:%s\n", src);
    printf("目标字符串:%s\n", dest);
    
    // 也可以直接复制字符串常量
    char str[20];
    strcpy(str, "C语言真好玩!");
    printf("str = %s\n", str);
    
    return 0;
}
源字符串:Hello 目标字符串:Hello str = C语言真好玩!
⚠️ 安全提醒:strcpy() 不会检查目标数组是否够大!如果源字符串比目标数组长,会溢出到后面的内存中(这叫"缓冲区溢出",是安全漏洞的常见来源)。安全做法是用 strncpy()

8.2.3 strcat() —— 字符串拼接

把一个字符串追加到另一个字符串的末尾。

c#include <stdio.h>
#include <string.h>

int main() {
    char str1[30] = "Hello";  // 长度一定要够大,否则会溢出!
    char str2[] = " World";
    
    printf("拼接前:%s\n", str1);
    
    strcat(str1, str2);  // 把 str2 拼接到 str1 的末尾
    
    printf("拼接后:%s\n", str1);
    
    // 可以多次拼接
    strcat(str1, "!!!");
    printf("再加感叹号:%s\n", str1);
    
    // 自己拼接自己
    char s[50] = "Ha";
    strcat(s, s);   // 要注意:s 必须有足够空间!
    printf("Ha 重复:%s\n", s);
    
    return 0;
}
拼接前:Hello 拼接后:Hello World 再加感叹号:Hello World!!! Ha 重复:HaHa

8.2.4 strcmp() —— 字符串比较

比较两个字符串是否相等。不能直接用 == 比较字符串!

c#include <stdio.h>
#include <string.h>

int main() {
    char pass[] = "123456";
    char input[20];
    
    printf("请输入密码:");
    scanf("%s", input);
    
    // 错误的做法
    // if (input == pass)  → ❌ 这样比较的是地址,不是内容!
    
    // 正确的做法
    int result = strcmp(input, pass);
    
    if (result == 0) {
        printf("✅ 密码正确!\n");
    } else {
        printf("❌ 密码错误!\n");
    }
    
    // strcmp 的返回值含义
    printf("\nstrcmp 比较示例:\n");
    printf("strcmp(\"abc\", \"abc\") = %d\n", strcmp("abc", "abc"));  // 0
    printf("strcmp(\"abc\", \"abd\") = %d\n", strcmp("abc", "abd"));  // 负数('c' < 'd')
    printf("strcmp(\"abd\", \"abc\") = %d\n", strcmp("abd", "abc"));  // 正数('d' > 'c')
    printf("strcmp(\"abc\", \"ab\")  = %d\n", strcmp("abc", "ab"));   // 正数('c' > '\0')
    
    return 0;
}
请输入密码:123456 ✅ 密码正确! strcmp 比较示例: strcmp("abc", "abc") = 0 strcmp("abc", "abd") = -1 strcmp("abd", "abc") = 1 strcmp("abc", "ab") = 99
💡 strcmp 返回值速记:
  • 0 = 相等(Zero = 等于 Zero difference)
  • 负数 = 第一个字符串"小于"第二个(按字典序)
  • 正数 = 第一个字符串"大于"第二个
其实是比较第一个不同的字符的 ASCII 码差值。

8.2.5 字符串函数总结

c#include <stdio.h>
#include <string.h>

int main() {
    char s1[50] = "Learn ";
    char s2[] = "C ";
    char s3[50];
    
    // 1. 拷贝
    strcpy(s3, "Programming");  // s3 = "Programming"
    printf("拷贝后 s3 = %s\n", s3);
    
    // 2. 拼接
    strcat(s1, s2);             // s1 = "Learn C "
    strcat(s1, s3);             // s1 = "Learn C Programming"
    printf("拼接后 s1 = %s\n", s1);
    
    // 3. 比较
    if (strcmp(s2, "C ") == 0) {
        printf("s2 等于 \"C \"\n");
    }
    
    // 4. 长度
    printf("s1 的长度 = %zu\n", strlen(s1));
    
    return 0;
}
拷贝后 s3 = Programming 拼接后 s1 = Learn C Programming s2 等于 "C " s1 的长度 = 21

8.3 实例:统计一句话中的单词个数

写一个程序,输入一句话,输出这句话中单词的个数。思路是:遍历每个字符,遇到空格就说明一个单词结束了。

c#include <stdio.h>
#include <string.h>

int main() {
    char sentence[200];
    int count = 0;
    int in_word = 0;  // 标记是否在单词中:1=在单词中,0=不在
    
    printf("请输入一句话:");
    fgets(sentence, sizeof(sentence), stdin);  // fgets能读取带空格的字符串
    
    // 去掉末尾的换行符(fgets会读取回车)
    int len = strlen(sentence);
    if (sentence[len - 1] == '\n') {
        sentence[len - 1] = '\0';
    }
    
    // 遍历字符串
    for (int i = 0; sentence[i] != '\0'; i++) {
        char ch = sentence[i];
        
        if (ch != ' ' && ch != '\t') {
            // 如果当前不是空格,且之前不在单词中
            // 说明遇到了一个新单词
            if (in_word == 0) {
                count++;        // 单词个数+1
                in_word = 1;    // 标记进入单词状态
            }
        } else {
            // 遇到空格,标记退出单词状态
            in_word = 0;
        }
    }
    
    printf("这句话有 %d 个单词。\n", count);
    
    // 逐个字符显示分析过程(调试用)
    printf("\n逐字分析:\n");
    in_word = 0;
    count = 0;
    for (int i = 0; sentence[i] != '\0'; i++) {
        char ch = sentence[i];
        if (ch != ' ' && ch != '\t') {
            if (in_word == 0) {
                count++;
                printf("[单词%d开始] ", count);
                in_word = 1;
            }
            printf("'%c' ", ch);
        } else {
            if (in_word == 1) {
                printf("[单词%d结束] ", count);
                in_word = 0;
            }
            printf("  ");
        }
    }
    if (in_word == 1) {
        printf("[单词%d结束]", count);
    }
    printf("\n");
    
    return 0;
}
请输入一句话:I love C programming language 这句话有 5 个单词。 逐字分析: [单词1开始] 'I' [单词1结束] [单词2开始] 'l' 'o' 'v' 'e' [单词2结束] [单词3开始] 'C' [单词3结束] [单词4开始] 'p' 'r' 'o' 'g' 'r' 'a' 'm' 'm' 'i' 'n' 'g' [单词4结束] [单词5开始] 'l' 'a' 'n' 'g' 'u' 'a' 'g' 'e' [单词5结束]
💡 拓展思考: 1. 如果用 scanf("%s") 测一下上面的程序会怎样? 2. 如果要处理中英文混排怎么办?(中文空格和英文空格不同) 3. 如果要统计"一句话,有——标点!"这种带标点的句子怎么办?

第9章 函数

前面我们写程序,所有代码都堆在 main() 函数里。但程序一旦变长(几百行、几千行),全堆在一起就像把衣服全塞在一个箱子里——找什么都费劲。函数就是用来把代码分门别类、装进不同"抽屉"的工具。

💡 为什么需要函数?
  1. 避免重复:如果一段代码要反复使用,写成函数就不用每次都复制粘贴。
  2. 方便维护:要修改某个功能,只需要改对应的函数。
  3. 逻辑清晰:每个函数负责一个独立的任务,代码像搭积木一样。

9.1 函数的定义与调用

9.1.1 函数的语法

c/*
返回值类型  函数名(参数列表) {
    函数体  // 要执行的代码
    return 返回值;
}
*/

// 一个最简单的函数:打印分隔线
void printLine() {
    printf("====================\n");
}

// 带参数的函数:打印指定个数的星号
void printStars(int n) {
    for (int i = 0; i < n; i++) {
        printf("*");
    }
    printf("\n");
}

// 带返回值的函数:计算两个整数的和
int add(int a, int b) {
    int sum = a + b;
    return sum;  // 返回计算结果
}

9.1.2 函数的调用

"调用"函数就是"使用"函数,让函数去完成它的工作:

c#include <stdio.h>

// 函数声明(也叫函数原型)——告诉编译器有这个函数
void sayHello();
int max(int x, int y);

int main() {
    // 调用函数
    sayHello();
    
    int result = max(10, 25);
    printf("两数中较大的是:%d\n", result);
    
    // 也可以直接使用返回值
    printf("3和8中较大的是:%d\n", max(3, 8));
    
    return 0;
}

// 函数定义
void sayHello() {
    printf("你好!欢迎学习C语言!\n");
}

int max(int x, int y) {
    if (x > y) {
        return x;
    } else {
        return y;
    }
}
你好!欢迎学习C语言! 两数中较大的是:25 3和8中较大的是:8
⚠️ 重要概念:先声明,后使用! 在 C 语言中,调用一个函数之前,编译器必须先知道这个函数的存在。有三种方式:
  1. 把函数定义放在 main 之前(最简单,但文件一长就不方便)
  2. 在文件开头声明函数原型(推荐做法,如上例)
  3. 什么都不做——编译器会隐式声明,但这是旧式C的行为,现代编译器会警告

9.2 参数传递——值传递

C语言中,函数参数采用值传递(也叫"传值调用")。意思是:调用函数时,把实参的值复制一份给形参,函数内部修改形参,不会影响原来的实参。

c#include <stdio.h>

void swap(int x, int y) {
    int temp = x;
    x = y;
    y = temp;
    printf("  函数内部交换后:x = %d, y = %d\n", x, y);
}

int main() {
    int a = 5, b = 10;
    
    printf("调用函数前:a = %d, b = %d\n", a, b);
    
    swap(a, b);  // 把 a 和 b 的值复制一份传给函数
    
    printf("调用函数后:a = %d, b = %d\n", a, b);
    // a 和 b 的值没有改变!
    
    return 0;
}
调用函数前:a = 5, b = 10 函数内部交换后:x = 10, y = 5 调用函数后:a = 5, b = 10
💡 形象理解: 就像你把自己的照片(实参)给了别人,对方可以随意涂改照片(形参),但你本人并不会因此改变。传递的是"复印件",不是"原件"。

9.3 返回值

函数通过 return 语句把结果传回给调用者。

9.3.1 return 的规则

c// 1. 有返回值的函数,必须有 return
int square(int n) {
    return n * n;  // 返回 n 的平方
    // printf("这行代码永远不会执行!");  // ❌ 死代码!
}

// 2. void 类型不需要 return,也可以写 return; 提前结束
void greet(char name[]) {
    if (name[0] == '\0') {  // 如果名字是空字符串
        printf("你好,匿名者!\n");
        return;  // 提前结束函数
    }
    printf("你好,%s!\n", name);
}

// 3. 可以有多个 return
int absValue(int n) {
    if (n >= 0) {
        return n;     // 非负数直接返回
    } else {
        return -n;    // 负数取相反数
    }
}

// 4. 返回值类型要匹配
float pi() {
    return 3.14159f;  // 返回 float 类型
}

int main() {
    printf("5的平方:%d\n", square(5));
    
    greet("小明");
    greet("");          // 空名字
    
    printf("-3的绝对值:%d\n", absValue(-3));
    printf("圆周率:%f\n", pi());
    
    return 0;
}
5的平方:25 你好,小明! 你好,匿名者! -3的绝对值:3 圆周率:3.141590
⚠️ 常见错误:
  1. 忘记 return:非 void 函数没有 return 语句,会返回一个不确定的"垃圾值"。
  2. return 后面的代码不执行:return 一旦执行,函数立即结束,后面的代码永远不会运行,这叫"死代码"。
  3. 返回值类型不匹配:比如函数声明返回 int,却 return 3.14,会被截断为 3。

9.4 局部变量与全局变量

9.4.1 局部变量

定义在函数内部的变量叫局部变量。它的特点是:

c#include <stdio.h>

void funcA() {
    int x = 10;  // funcA 的局部变量
    printf("funcA 中的 x = %d\n", x);
}

void funcB() {
    int x = 99;  // 可以和 funcA 的 x 同名,互不影响
    printf("funcB 中的 x = %d\n", x);
}

int main() {
    funcA();
    funcB();
    
    // printf("%d", x);  // ❌ 错误!main 里没有 x
    return 0;
}
funcA 中的 x = 10 funcB 中的 x = 99

9.4.2 全局变量

定义在所有函数外部的变量叫全局变量。它的特点是:

c#include <stdio.h>

// 全局变量——定义在所有函数外面
int globalCount = 0;

void addOne() {
    globalCount++;  // 可以访问和修改全局变量
    printf("  addOne() 调用后:globalCount = %d\n", globalCount);
}

void addTen() {
    globalCount += 10;
    printf("  addTen() 调用后:globalCount = %d\n", globalCount);
}

int main() {
    printf("初始值:globalCount = %d\n", globalCount);
    
    addOne();    // +1
    addOne();    // +1
    addTen();    // +10
    
    printf("最终值:globalCount = %d\n", globalCount);
    
    // main 也可以直接修改
    globalCount = 100;
    printf("main 修改后:globalCount = %d\n", globalCount);
    
    return 0;
}
初始值:globalCount = 0 addOne() 调用后:globalCount = 1 addOne() 调用后:globalCount = 2 addTen() 调用后:globalCount = 12 最终值:globalCount = 12 main 修改后:globalCount = 100

9.4.3 局部变量 vs 全局变量 —— 对比

c#include <stdio.h>

int g = 100;  // 全局变量

void test() {
    int g = 200;  // 局部变量,和全局变量同名
    
    printf("在 test 函数中:\n");
    printf("  g = %d(局部变量,遮蔽了全局变量)\n", g);
}

int main() {
    printf("全局变量 g = %d\n", g);
    
    test();
    
    printf("回到 main 函数后:\n");
    printf("  g = %d(全局变量的值没变)\n", g);
    
    return 0;
}
全局变量 g = 100 在 test 函数中: g = 200(局部变量,遮蔽了全局变量) 回到 main 函数后: g = 100(全局变量的值没变)
⚠️ 谨慎使用全局变量: 虽然全局变量很方便,但大型项目中要少用全局变量。原因:
  1. 任何函数都能修改它,出了 bug 很难排查是谁改的。
  2. 函数之间"偷偷"通过全局变量传数据,代码变得难以理解。
  3. 在多线程程序中,全局变量容易引发数据竞争问题。
原则是:能用参数传的就不要用全局变量

9.5 综合实例:判断素数函数

写一个程序,判断用户输入的一个正整数是否为素数(质数)。

什么是素数?只能被 1 和它本身整除的正整数。比如:2、3、5、7、11、13……

c#include <stdio.h>
#include <math.h>  // 要用 sqrt() 函数

// 函数声明
int isPrime(int n);
void printPrimeInfo(int n);

int main() {
    int num;
    
    printf("请输入一个正整数:");
    scanf("%d", &num);
    
    if (isPrime(num)) {
        printf("%d 是素数!\n", num);
    } else {
        printf("%d 不是素数!\n", num);
    }
    
    // 打印更详细的信息
    printf("\n");
    printPrimeInfo(num);
    
    // 打印 1~100 之间的所有素数
    printf("\n1~100 之间的所有素数:\n");
    int count = 0;
    for (int i = 2; i <= 100; i++) {
        if (isPrime(i)) {
            printf("%d ", i);
            count++;
            // 每行显示10个
            if (count % 10 == 0) {
                printf("\n");
            }
        }
    }
    printf("\n共有 %d 个素数。\n", count);
    
    return 0;
}

// 判断一个数是否为素数
// 返回值:1表示是素数,0表示不是素数
int isPrime(int n) {
    // 小于2的数不是素数
    if (n < 2) {
        return 0;
    }
    
    // 2 是素数
    if (n == 2) {
        return 1;
    }
    
    // 偶数(除了2)不是素数
    if (n % 2 == 0) {
        return 0;
    }
    
    // 只需要检查到 sqrt(n) 即可,提高效率
    // 只检查奇数因子
    for (int i = 3; i <= sqrt(n); i += 2) {
        if (n % i == 0) {
            return 0;  // 找到了因子,不是素数
        }
    }
    
    return 1;  // 没有找到因子,是素数
}

// 打印详细的素数判断过程
void printPrimeInfo(int n) {
    if (n < 2) {
        printf("%d 小于2,不是素数。\n", n);
        return;
    }
    
    printf("判断 %d 是否为素数的过程:\n", n);
    printf("  检查从 2 到 sqrt(%d)=%.1f 的因子:\n", n, sqrt(n));
    
    int found = 0;
    for (int i = 2; i <= sqrt(n); i++) {
        if (n % i == 0) {
            printf("    %d ÷ %d = %d,能整除!→ 不是素数\n", n, i, n / i);
            found = 1;
            break;
        } else {
            printf("    %d ÷ %d = %d 余 %d,不能整除\n", n, i, n / i, n % i);
        }
    }
    
    if (!found) {
        printf("  没有找到任何因子!→ %d 是素数!\n", n);
    }
}
请输入一个正整数:37 37 是素数! 判断 37 是否为素数的过程: 检查从 2 到 sqrt(37)=6.1 的因子: 37 ÷ 2 = 18 余 1,不能整除 37 ÷ 3 = 12 余 1,不能整除 37 ÷ 4 = 9 余 1,不能整除 37 ÷ 5 = 7 余 2,不能整除 37 ÷ 6 = 6 余 1,不能整除 没有找到任何因子!→ 37 是素数! 1~100 之间的所有素数: 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 共有 25 个素数。
请输入一个正整数:51 51 不是素数! 判断 51 是否为素数的过程: 检查从 2 到 sqrt(51)=7.1 的因子: 51 ÷ 2 = 25 余 1,不能整除 51 ÷ 3 = 17,能整除!→ 不是素数
💡 这个程序展示的知识点:
  1. 函数的定义和调用isPrime()printPrimeInfo()
  2. 参数传递:把用户输入的数字传给函数
  3. 返回值isPrime() 返回 0 或 1
  4. 函数分工:一个函数做判断,一个函数打印信息,各司其职
  5. 头文件引用math.h 提供 sqrt() 函数
  6. 编程优化:只检查到 sqrt(n),只检查奇数因子——效率大大提高

9.6 本章小结

概念 说明
函数定义 返回值类型 函数名(参数列表) { 函数体 }
函数调用 函数名(实参列表),先声明后使用
值传递 实参的值复制给形参,函数内部修改不影响外部
返回值 用 return 返回结果,void 表示没有返回值
局部变量 定义在函数内,只能在函数内使用,函数结束即销毁
全局变量 定义在函数外,所有函数都能访问,谨慎使用

第10章 指针入门

指针是C语言最强大也最让新手害怕的特性。别担心——我们用快递柜的比喻来理解它。

10.1 内存地址 —— 快递柜的格子编号

计算机的内存就像一栋巨大的快递柜,里面有成千上万个格子(内存单元)。每个格子都有一个唯一的编号(内存地址)

💡 类比记忆:内存地址就像快递柜的格口编号——你不需要记住格子里有什么,你只需要知道它的编号就能找到它。同样,计算机通过地址来定位内存中的数据。

当你声明一个变量 int a = 10; 时,相当于在快递柜里申请了一个格子,存入数字10,而这个格子有一个固定的编号(比如 0x7ffeefbff5a8)。

我们平时用的是变量名(给格子贴的标签),但计算机底层用的是地址(格子的编号)。

c#include <stdio.h>

int main() {
    int a = 10;

    // & 是"取地址"运算符
    printf("变量 a 的值:%d\n", a);
    printf("变量 a 的地址:%p\n", &a);

    return 0;
}
变量 a 的值:10 变量 a 的地址:0x7ffeefbff5a8
⚠️ 注意:%p 是专门用来打印地址的格式符。每次运行程序,地址都可能不同——这是操作系统的内存保护机制(ASLR地址随机化),不影响我们学习指针。

10.2 & 和 * 运算符 —— 问路和按地址敲门

C语言提供两个与指针密切相关的单目运算符:

运算符名称含义类比
&取地址运算符获取变量的内存地址问路:"这个格子的编号是多少?"
*解引用运算符通过地址访问存储的值按地址敲门:"到这个格子里取东西!"
c#include <stdio.h>

int main() {
    int a = 100;

    // & 取地址 —— "这个变量住在几号房?"
    printf("a = %d\n", a);
    printf("a 的地址是:%p\n", &a);

    // * 解引用 —— 拿到地址后,去那个地址拿值
    // 注意:*(&a) 的意思是 "取 a 的地址,然后去那个地址取值"
    printf("*(&a) = %d\n", *(&a));  // 一定等于 100

    return 0;
}
a = 100 a 的地址是:0x7ffeefbff5a8 *(&a) = 100
💡 理解小窍门:可以把 & 看作"我在几号房?",把 * 看作"到这个房里看看有什么"。&变量 返回地址,*地址 返回值,它们互为逆操作:*&x == x

10.3 声明指针变量 —— 写一张"纸条"保存地址

指针变量就是用来存放内存地址的变量。好比你在纸条上写下快递柜的格口编号,以后拿着纸条就能找到那个格子。

声明语法:类型 *变量名;

💡 类比:普通的 int a 是一个盒子,里面装数字。int *p 也是一个盒子,但里面装的是别人家的门牌号(地址)。
c#include <stdio.h>

int main() {
    int a = 42;        // 普通变量:a 里存的是数字 42
    int *p;            // 指针变量:p 里将来要存一个地址(int 类型变量的地址)

    p = &a;            // 把 a 的地址赋值给 p —— 纸条上写下了 a 的门牌号

    // 现在 p 指向 a
    printf("a 的值 = %d\n", a);          // 直接访问
    printf("p 保存的地址 = %p\n", p);     // 打印纸条上的门牌号
    printf("通过 *p 访问 a 的值 = %d\n", *p); // 拿着纸条找到那个格子取出东西

    // 通过指针修改变量的值
    *p = 99;  // 拿着纸条找到格子,把里面的数字改成 99
    printf("修改后 a 的值 = %d\n", a);   // a 被改为 99

    return 0;
}
a 的值 = 42 p 保存的地址 = 0x7ffeefbff5a8 通过 *p 访问 a 的值 = 42 修改后 a 的值 = 99

10.4 指针与变量的关系图解

理解下面这张"内存图"是掌握指针的关键:

内存中的景象(每个格=4字节,int):
┌──────────────────────────────────────────────┐
│ 变量 a(值:42)                              │
│  ┌──────────────┐                             │
│  │      42      │   ← 格子里的内容             │
│  └──────────────┘                             │
│  地址:0x7ffeefbff5a8                         │
│                                              │
│ 变量 p(指针,值:0x7ffeefbff5a8)             │
│  ┌──────────────┐                             │
│  │ 0x7ffeefbff..│   ← 纸条上写着 a 的地址      │
│  └──────────────┘                             │
│  地址:0x7ffeefbff5b0                         │
│                                              │
│ 关系:                                        │
│   p == &a       (纸条上的地址 == a 的门牌号)   │
│   *p == a == 42  (按纸条找格子 == 格子里的东西) │
└──────────────────────────────────────────────┘
💡 一句话总结:p 存的是 a 的门牌号,*p 就是 a 本人。
画家版:p = 一张指向 a 的地图,*p = 按照地图走到 a 面前。

10.5 新手常见错误

⚠️ 错误1 —— 未初始化指针就使用:
cint *p;        // p 里面是垃圾值(随机地址)
*p = 100;      // ❌ 向一个随机的地址写入数据 —— 程序崩溃!
解决:要么 p = &某个变量; 给它一个合法地址,要么 int *p = NULL; 先置空。
⚠️ 错误2 —— 混淆声明语法:
cint* p, q;     // ❌ 你以为 p 和 q 都是指针?
// 实际上:p 是指针,q 是普通 int!
// 正确写法:int *p, *q;  或者分行写

10.6 NULL指针 —— 指向"空"的纸条

如果指针暂时没有指向任何变量,应该给它赋值为 NULL(空指针),表示"这张纸条现在还没写门牌号",而不是让它里面是垃圾值。

c#include <stdio.h>

int main() {
    int *p = NULL;  // 明确表示"没指向任何东西"

    if (p != NULL) {
        printf("*p = %d\n", *p);  // 安全:只有非空才解引用
    } else {
        printf("p 是空指针,不能解引用!\n");
    }

    return 0;
}
p 是空指针,不能解引用!

10.7 实例:用指针输出变量的地址和值

c#include <stdio.h>

int main() {
    int  num   = 2024;
    char ch    = 'A';
    double pi  = 3.14159;

    int    *pNum = &num;
    char   *pCh  = &ch;
    double *pPi  = &pi;

    printf("===== 用指针查看不同类型变量 =====\n\n");

    printf("int 变量:\n");
    printf("  变量名 num 的值 = %d\n", num);
    printf("  地址 &num      = %p\n", &num);
    printf("  指针 pNum      = %p\n", pNum);
    printf("  解引用 *pNum   = %d\n\n", *pNum);

    printf("char 变量:\n");
    printf("  变量名 ch 的值 = %c\n", ch);
    printf("  地址 &ch       = %p\n", &ch);
    printf("  指针 pCh       = %p\n", pCh);
    printf("  解引用 *pCh    = %c\n\n", *pCh);

    printf("double 变量:\n");
    printf("  变量名 pi 的值 = %.5f\n", pi);
    printf("  地址 &pi       = %p\n", &pi);
    printf("  指针 pPi       = %p\n", pPi);
    printf("  解引用 *pPi    = %.5f\n\n", *pPi);

    // 验证:pNum == &num 总是为真
    if (pNum == &num) {
        printf("✅ pNum == &num 成立:指针确实保存了变量的地址!\n");
    }

    // 通过指针修改变量
    *pNum = 2025;
    printf("通过 *pNum = 2025 修改后,num = %d\n", num);

    return 0;
}
===== 用指针查看不同类型变量 ===== int 变量: 变量名 num 的值 = 2024 地址 &num = 0x7ffeefbff5a0 指针 pNum = 0x7ffeefbff5a0 解引用 *pNum = 2024 char 变量: 变量名 ch 的值 = A 地址 &ch = 0x7ffeefbff59f 指针 pCh = 0x7ffeefbff59f 解引用 *pCh = A double 变量: 变量名 pi 的值 = 3.14159 地址 &pi = 0x7ffeefbff598 指针 pPi = 0x7ffeefbff598 解引用 *pPi = 3.14159 ✅ pNum == &num 成立:指针确实保存了变量的地址! 通过 *pNum = 2025 修改后,num = 2025

10.8 本章小结

概念类比代码
变量快递柜的一个格子int a = 5;
地址格子的编号&a
指针变量写着编号的纸条int *p = &a;
解引用按纸条找到格子取东西*p
空指针空白纸条(没写编号)int *p = NULL;

第11章 指针进阶

掌握了指针的基本概念后,本章我们来学习指针的高级用法——指针与数组、指针与函数、多级指针和void指针。

11.1 指针与数组 —— 数组名就是地址

C语言中,数组名本身就是指向数组首元素的指针。这意味着你可以用指针的方式操作数组。

💡 类比:数组就像一栋联排别墅。数组名 arr 就是第一栋(arr[0])的门牌号。要找第 i 栋(arr[i]),只需要在第一栋的门牌号上 + i 即可。
c#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;  // 等价于 int *p = &arr[0];

    printf("arr     = %p  (数组首地址)\n", arr);
    printf("&arr[0] = %p  (第一个元素的地址)\n", &arr[0]);
    printf("p       = %p  (指针保存的值)\n\n", p);

    // 用下标访问
    printf("用下标访问:\n");
    for (int i = 0; i < 5; i++) {
        printf("  arr[%d] = %d\n", i, arr[i]);
    }

    printf("\n用指针访问(*(p+i)):\n");
    for (int i = 0; i < 5; i++) {
        printf("  *(p+%d) = %d\n", i, *(p + i));
    }

    printf("\n用指针移动访问(*p++):\n");
    p = arr;  // 重置指针到开头
    for (int i = 0; i < 5; i++) {
        printf("  *p = %d(p当前指向 arr[%d])\n", *p, i);
        p++;  // 指针移动到下一个元素
    }

    return 0;
}
arr = 0x7ffeefbff590 (数组首地址) &arr[0] = 0x7ffeefbff590 (第一个元素的地址) p = 0x7ffeefbff590 (指针保存的值) 用下标访问: arr[0] = 10 arr[1] = 20 arr[2] = 30 arr[3] = 40 arr[4] = 50 用指针访问(*(p+i)): *(p+0) = 10 *(p+1) = 20 *(p+2) = 30 *(p+3) = 40 *(p+4) = 50 用指针移动访问(*p++): *p = 10(p当前指向 arr[0]) *p = 20(p当前指向 arr[1]) *p = 30(p当前指向 arr[2]) *p = 40(p当前指向 arr[3]) *p = 50(p当前指向 arr[4])
💡 重点理解:arr[i]*(arr + i) 完全等价!C编译器会把 arr[i] 自动翻译成 *(arr + i)。当 p 是一个指针时,p[i] 也等价于 *(p + i)

11.2 指针算术 —— 地址的加减法

指针加1 不是地址值+1,而是加上"指向类型"的字节大小:

c#include <stdio.h>

int main() {
    int   arr_i[] = {1, 2, 3};
    char  arr_c[] = {'A', 'B', 'C'};
    double arr_d[] = {1.1, 2.2, 3.3};

    printf("int 数组:\n");
    printf("  arr_i   = %p\n", arr_i);
    printf("  arr_i+1 = %p  (差了 %ld 字节 = sizeof(int))\n",
           arr_i + 1, (long)((char*)(arr_i+1) - (char*)arr_i));

    printf("char 数组:\n");
    printf("  arr_c   = %p\n", arr_c);
    printf("  arr_c+1 = %p  (差了 %ld 字节 = sizeof(char))\n",
           arr_c + 1, (long)((char*)(arr_c+1) - (char*)arr_c));

    printf("double 数组:\n");
    printf("  arr_d   = %p\n", arr_d);
    printf("  arr_d+1 = %p  (差了 %ld 字节 = sizeof(double))\n",
           arr_d + 1, (long)((char*)(arr_d+1) - (char*)arr_d));

    // 指针还可以相减:得到元素个数
    int *p1 = &arr_i[0];
    int *p2 = &arr_i[2];
    printf("\np2 - p1 = %ld  (两个指针之间相差 %ld 个元素)\n",
           p2 - p1, p2 - p1);

    return 0;
}
int 数组: arr_i = 0x7ffeefbff580 arr_i+1 = 0x7ffeefbff584 (差了 4 字节 = sizeof(int)) char 数组: arr_c = 0x7ffeefbff57c arr_c+1 = 0x7ffeefbff57d (差了 1 字节 = sizeof(char)) double 数组: arr_d = 0x7ffeefbff560 arr_d+1 = 0x7ffeefbff568 (差了 8 字节 = sizeof(double)) p2 - p1 = 2 (两个指针之间相差 2 个元素)

11.3 指针作为函数参数 —— 让函数"改变外面"的变量

C语言函数参数默认是值传递——函数拿到的是副本,改副本不影响原件。用指针传参,函数就能直接修改原始变量

💡 类比:值传递 = 把身份证复印件给办事员,他改复印件不影响你的原件。指针传递 = 把原件地址告诉他,他按地址找到原件直接改。
c#include <stdio.h>

// ❌ 值传递:无法修改外面的变量
void swap_wrong(int x, int y) {
    int temp = x;
    x = y;
    y = temp;
    // 这里交换的是副本,主函数中的 a、b 纹丝不动
}

// ✅ 指针传递:通过地址修改原始变量
void swap_right(int *px, int *py) {
    int temp = *px;   // temp = a 的值
    *px = *py;        // a = b 的值
    *py = temp;       // b = temp
}

int main() {
    int a = 10, b = 20;

    printf("交换前:a = %d, b = %d\n\n", a, b);

    swap_wrong(a, b);
    printf("swap_wrong 之后:a = %d, b = %d(没变化!)\n", a, b);

    swap_right(&a, &b);  // 传入 a 和 b 的地址
    printf("swap_right 之后:a = %d, b = %d(成功交换!)\n", a, b);

    return 0;
}
交换前:a = 10, b = 20 swap_wrong 之后:a = 10, b = 20(没变化!) swap_right 之后:a = 20, b = 10(成功交换!)

另一个常见用途:函数通过指针"返回"多个值。

c#include <stdio.h>

// 计算一个数的平方和立方,通过指针"带出"结果
void calc_square_cube(int n, int *square, int *cube) {
    *square = n * n;
    *cube   = n * n * n;
    // 函数没有 return 值,但通过指针修改了外面的变量
}

int main() {
    int num = 5;
    int sq, cb;

    calc_square_cube(num, &sq, &cb);

    printf("num = %d\n", num);
    printf("平方 = %d\n", sq);
    printf("立方 = %d\n", cb);

    return 0;
}
num = 5 平方 = 25 立方 = 125
⚠️ 使用指针参数时,务必检查指针是否为空:
cvoid safe_calc(int n, int *square, int *cube) {
    if (square == NULL || cube == NULL) {
        return;  // 安全退出
    }
    *square = n * n;
    *cube   = n * n * n;
}

11.4 指针与数组作为函数参数

当数组作为函数参数时,它自动退化为指向首元素的指针。所以 int arr[]int *arr 在函数参数中是等价的。

c#include <stdio.h>

// 这两种写法完全等价:
// void print_array(int arr[], int n)
// void print_array(int *arr, int n)
void print_array(int *arr, int n) {
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);  // arr[i] 等价于 *(arr + i)
    }
    printf("\n");
}

// 通过指针修改数组元素
void double_elements(int *arr, int n) {
    for (int i = 0; i < n; i++) {
        arr[i] *= 2;  // 直接修改原数组
    }
}

int main() {
    int numbers[] = {1, 2, 3, 4, 5};
    int count = sizeof(numbers) / sizeof(numbers[0]);

    printf("原始数组:");
    print_array(numbers, count);

    double_elements(numbers, count);
    printf("翻倍后:  ");
    print_array(numbers, count);

    return 0;
}
原始数组:1 2 3 4 5 翻倍后: 2 4 6 8 10
💡 注意:虽然在函数参数中 int arr[]int *arr 等价,但在局部变量中不同:int arr[5] 会分配空间,int *arr 不分配空间。另外在函数内 sizeof(arr) 得到的是指针大小(8字节),不是数组大小!所以必须额外传长度参数 n。

11.5 多级指针 —— 指向指针的指针

二级指针是指向指针的指针。如果一层指针是"写着门牌号的纸条",那二级指针就是"写着纸条位置的纸条"。

💡 二级指针类比:一级指针 = 你朋友家的门牌号。二级指针 = 我告诉你"你朋友家门牌号写在哪张纸条上"——你得先找到那张纸条(*p),再按纸条上的地址找到朋友家(**p)。
c#include <stdio.h>

int main() {
    int a = 100;       // 普通变量
    int *p = &a;       // 一级指针:p 存的是 a 的地址
    int **pp = &p;     // 二级指针:pp 存的是 p 的地址

    printf("a  = %d\n", a);              // 直接访问
    printf("*p = %d\n", *p);             // 一级解引用
    printf("**pp = %d\n", **pp);         // 二级解引用:先到 p,再到 a

    printf("\n地址关系:\n");
    printf("&a  = %p\n", &a);
    printf("p   = %p  (p 的值 = a 的地址)\n", p);
    printf("&p  = %p\n", &p);
    printf("pp  = %p  (pp 的值 = p 的地址)\n", pp);

    return 0;
}
a = 100 *p = 100 **pp = 100 地址关系: &a = 0x7ffeefbff5a8 p = 0x7ffeefbff5a8 (p 的值 = a 的地址) &p = 0x7ffeefbff5a0 pp = 0x7ffeefbff5a0 (pp 的值 = p 的地址)

二级指针的常见用途:在函数中修改指针本身的值。

c#include <stdio.h>
#include <stdlib.h>

// 通过二级指针让函数"分配内存"并让外面的指针指向它
void create_array(int **pp, int n) {
    *pp = (int*)malloc(n * sizeof(int));  // 修改外面指针的值
    for (int i = 0; i < n; i++) {
        (*pp)[i] = i * 10;
    }
}

int main() {
    int *arr = NULL;

    create_array(&arr, 5);  // 传入一级指针的地址

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    free(arr);  // 用完释放
    return 0;
}
arr[0] = 0 arr[1] = 10 arr[2] = 20 arr[3] = 30 arr[4] = 40

11.6 void指针 —— 万能指针

void* 是一种特殊指针,它可以指向任何类型的数据。你可以把它理解为一个"通用纸条"——什么门牌号都能写。

💡 void* 的特点:可以接收任意类型的地址,但在解引用前必须先转换为具体类型。
c#include <stdio.h>

int main() {
    int    a = 42;
    double b = 3.14;
    char   c = 'X';

    void *vp;  // 万能指针

    // 可以指向任何类型
    vp = &a;
    printf("指向 int:  *(int*)vp   = %d\n", *(int*)vp);

    vp = &b;
    printf("指向 double:*(double*)vp = %.2f\n", *(double*)vp);

    vp = &c;
    printf("指向 char:  *(char*)vp  = %c\n", *(char*)vp);

    return 0;
}
指向 int: *(int*)vp = 42 指向 double:*(double*)vp = 3.14 指向 char: *(char*)vp = X
⚠️ 重要限制:void* 指针不能直接解引用,也不能做指针算术(因为不知道类型大小)。必须先用强制类型转换(如 (int*)vp)转成具体类型后再操作。

void* 常用于实现通用的函数。例如 C 标准库的 qsort 排序函数就用 void* 接受任何类型的数组。

c#include <stdio.h>

// 一个能打印"任意类型"地址和值的示例函数
void print_value(void *ptr, char type) {
    switch (type) {
        case 'i':
            printf("int 值:%d\n", *(int*)ptr);
            break;
        case 'f':
            printf("float 值:%.2f\n", *(float*)ptr);
            break;
        case 'c':
            printf("char 值:%c\n", *(char*)ptr);
            break;
        default:
            printf("未知类型\n");
    }
}

int main() {
    int   iv = 100;
    float fv = 3.14f;
    char  cv = 'Z';

    print_value(&iv, 'i');
    print_value(&fv, 'f');
    print_value(&cv, 'c');

    return 0;
}
int 值:100 float 值:3.14 char 值:Z

11.7 实例:通过指针修改数组元素

c#include <stdio.h>

// 在数组中查找最大值,并把它放到第一个位置
// 同时通过指针返回最大值的位置(下标)
int find_and_promote_max(int *arr, int n, int *max_pos) {
    if (arr == NULL || n <= 0) return -1;

    // 假设第一个是最大值
    int max_val = arr[0];
    *max_pos = 0;

    // 遍历查找真正的最大值
    for (int i = 1; i < n; i++) {
        if (arr[i] > max_val) {
            max_val = arr[i];
            *max_pos = i;
        }
    }

    // 把最大值和第一个元素交换
    if (*max_pos != 0) {
        int temp = arr[0];
        arr[0] = arr[*max_pos];
        arr[*max_pos] = temp;
        *max_pos = 0;  // 最大值现在在位置0
    }

    return max_val;
}

int main() {
    int scores[] = {67, 92, 88, 55, 96, 73, 81};
    int n = sizeof(scores) / sizeof(scores[0]);
    int position;

    printf("原始成绩:");
    for (int i = 0; i < n; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n");

    int max = find_and_promote_max(scores, n, &position);

    printf("最高分:%d(位置 %d)\n", max, position);
    printf("调整后:");
    for (int i = 0; i < n; i++) {
        printf("%d ", scores[i]);
        if (i == 0) printf("← 最高分已移到此处 ");
    }
    printf("\n");

    // 使用指针遍历和修改
    printf("\n所有不及格的分数(<60)加5分:\n");
    int *p = scores;
    for (int i = 0; i < n; i++) {
        if (*p < 60) {
            printf("  scores[%d] = %d → ", i, *p);
            *p += 5;  // 通过指针直接修改
            printf("%d(已加分)\n", *p);
        }
        p++;
    }

    printf("\n最终成绩:");
    for (int i = 0; i < n; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n");

    return 0;
}
原始成绩:67 92 88 55 96 73 81 最高分:96(位置 4) 调整后:96 92 88 55 67 73 81 ← 最高分已移到此处 所有不及格的分数(<60)加5分: scores[3] = 55 → 60(已加分) 最终成绩:96 92 88 60 67 73 81

11.8 本章小结

进阶技能说明代码示例
指针遍历数组arr[i] 等价于 *(arr+i)for(p=arr; p<arr+n; p++)
指针算术p+1 跳过 sizeof(类型) 字节int *p; p+1 → 地址+4
指针作参数函数通过指针修改原始变量void swap(int *a, int *b)
数组作参数退化为指针,需额外传长度void f(int *arr, int n)
多级指针指向指针的指针int **pp
void指针万能指针,需转换后使用void *p; *(int*)p

第12章 结构体与联合体

前面我们学过数组——它可以把多个相同类型的数据组合在一起。但如果想把不同类型的数据(比如姓名、年龄、分数)打包成一个整体呢?这时候就要用结构体

12.1 结构体的定义和使用 —— 创建一个"自定义类型"

💡 类比:结构体就像一张登记表——上面有姓名栏(字符串)、年龄栏(整数)、分数栏(浮点数),各种不同类型的栏目组合在一起,形成一条完整的记录。

定义结构体的语法:

cstruct 结构体名 {
    类型1 成员1;
    类型2 成员2;
    // ...
};  // ← 分号不能少!
c#include <stdio.h>
#include <string.h>

// 定义一个"学生"结构体类型
struct Student {
    char  name[20];    // 姓名(字符数组)
    int   age;         // 年龄(整数)
    float score;       // 分数(浮点数)
};  // ← 这个分号非常重要!

int main() {
    // 声明结构体变量并初始化
    struct Student s1;  // 创建了一个 Student 变量

    // 用点号(.)访问成员
    strcpy(s1.name, "张三");
    s1.age   = 20;
    s1.score = 88.5;

    // 也可以声明时直接初始化(按顺序)
    struct Student s2 = {"李四", 22, 92.0};
    struct Student s3 = {.name = "王五", .score = 76.5, .age = 19};  // 指定成员名(C99)

    printf("===== 学生信息 =====\n\n");

    printf("s1(逐个赋值):\n");
    printf("  姓名:%s\n", s1.name);
    printf("  年龄:%d\n", s1.age);
    printf("  分数:%.1f\n\n", s1.score);

    printf("s2(顺序初始化):\n");
    printf("  姓名:%s\n", s2.name);
    printf("  年龄:%d\n", s2.age);
    printf("  分数:%.1f\n\n", s2.score);

    printf("s3(指定成员初始化):\n");
    printf("  姓名:%s\n", s3.name);
    printf("  年龄:%d\n", s3.age);
    printf("  分数:%.1f\n", s3.score);

    return 0;
}
===== 学生信息 ===== s1(逐个赋值): 姓名:张三 年龄:20 分数:88.5 s2(顺序初始化): 姓名:李四 年龄:22 分数:92.0 s3(指定成员初始化): 姓名:王五 年龄:19 分数:76.5
💡 结构体赋值是"复制":C语言支持结构体之间的直接赋值——s2 = s1; 会把 s1 的所有成员逐字节复制到 s2,相当于给了你一份完整的副本。

12.2 结构体的大小 —— 字节对齐

结构体的大小并不等于所有成员大小的简单相加,因为编译器会做字节对齐以提高访问效率。

c#include <stdio.h>

struct Example {
    char  c;    // 1 字节
    int   i;    // 4 字节
    char  d;    // 1 字节
};  // 直觉上 1+4+1=6,但实际上……

int main() {
    // 注意:我们用了个"空"结构体演示对齐
    struct Example e;
    printf("char: %lu 字节\n", sizeof(char));
    printf("int:  %lu 字节\n", sizeof(int));
    printf("结构体 struct Example 的大小:%lu 字节(不是 1+4+1=6!)\n",
           sizeof(struct Example));

    printf("\n各个成员的地址:\n");
    printf("&e.c = %p\n", &e.c);
    printf("&e.i = %p\n", &e.i);
    printf("&e.d = %p\n", &e.d);

    return 0;
}
char: 1 字节 int: 4 字节 结构体 struct Example 的大小:12 字节(不是 1+4+1=6!) 各个成员的地址: &e.c = 0x7ffeefbff5a0 &e.i = 0x7ffeefbff5a4 &e.d = 0x7ffeefbff5a8
💡 字节对齐规则:结构体成员的地址必须能被它的大小整除(int 的地址必须是4的倍数),结构体总大小必须是最大成员大小的整数倍。所以上面例子中,char c 之后有3个填充字节,char d 之后也有3个填充字节,总共1+3+4+1+3=12字节。
优化技巧:把大的成员放在前面可以减少填充——struct { int i; char c; char d; } 只有 8 字节。

12.3 typedef —— 给类型起"外号"

每次写 struct Student 有点啰嗦。typedef 可以给类型起一个更简短的名字。

c#include <stdio.h>
#include <string.h>

// 定义结构体并用 typedef 取别名
typedef struct {
    char name[20];
    int  age;
    float score;
} Student;  // 以后直接用 Student 代替 struct {…}

// 也可以分两步:
// struct Point { int x; int y; };
// typedef struct Point Point;

int main() {
    Student s1 = {"小明", 18, 95.5};
    // 注意:不再需要写 "struct" 关键字

    printf("姓名:%s\n", s1.name);
    printf("年龄:%d\n", s1.age);
    printf("分数:%.1f\n", s1.score);

    return 0;
}
姓名:小明 年龄:18 分数:95.5
💡 typedef 不只是为结构体服务:也可以给基本类型起别名,比如 typedef unsigned long long uint64; ——以后 uint64 x; 就是 unsigned long long x; 的简写。

12.4 结构体数组 —— 管理多个"记录"

结构体最常用的场景就是作为数组元素——一个数组里存多个学生的信息。

c#include <stdio.h>
#include <string.h>

typedef struct {
    char  name[20];
    int   age;
    float score;
} Student;

int main() {
    // 结构体数组:3个学生
    Student class[] = {
        {"张三", 20, 88.5},
        {"李四", 22, 92.0},
        {"王五", 19, 76.5}
    };
    int n = sizeof(class) / sizeof(class[0]);

    printf("===== 班级学生列表 =====\n");
    printf("%-4s %-8s %-4s %s\n", "序号", "姓名", "年龄", "分数");
    printf("----------------------------\n");

    for (int i = 0; i < n; i++) {
        printf("%-4d %-8s %-4d %.1f\n",
               i + 1, class[i].name, class[i].age, class[i].score);
    }

    // 计算平均分
    float total = 0;
    for (int i = 0; i < n; i++) {
        total += class[i].score;
    }
    printf("\n平均分:%.2f\n", total / n);

    return 0;
}
===== 班级学生列表 ===== 序号 姓名 年龄 分数 ---------------------------- 1 张三 20 88.5 2 李四 22 92.0 3 王五 19 76.5 平均分:85.67

12.5 结构体指针 —— 用箭头(->)访问成员

与普通变量一样,结构体也可以用指针操作。此时访问成员用 -> 箭头运算符。

💡 箭头记忆法:p->成员 等价于 (*p).成员。箭头就像沿着指针"指向"结构体,然后"取"里面的成员。
c#include <stdio.h>
#include <string.h>

typedef struct {
    char name[20];
    int  age;
    float score;
} Student;

// 用结构体指针作为函数参数 —— 避免复制整个结构体
void print_student(const Student *p) {
    // 用 -> 访问成员
    printf("姓名:%s | 年龄:%d | 分数:%.1f\n",
           p->name, p->age, p->score);
}

// 通过指针修改结构体
void update_score(Student *p, float new_score) {
    p->score = new_score;  // 等价于 (*p).score = new_score
}

int main() {
    Student s = {"赵六", 21, 80.0};
    Student *ps = &s;  // 结构体指针

    printf("直接访问:姓名 = %s\n", s.name);
    printf("用指针(->):姓名 = %s\n", ps->name);     // 推荐写法
    printf("用指针(*).:姓名 = %s\n\n", (*ps).name);  // 等价写法

    printf("调用函数前:");
    print_student(&s);

    update_score(&s, 95.0);
    printf("修改分数后:");
    print_student(&s);

    // 结构体数组用指针遍历
    Student class[] = {
        {"张三", 20, 88.5},
        {"李四", 22, 92.0}
    };

    printf("\n用指针遍历数组:\n");
    Student *p = class;         // 指向数组首元素
    Student *end = class + 2;   // 指向数组末尾(不包含)
    while (p < end) {
        printf("  %s  %d岁  %.1f分\n", p->name, p->age, p->score);
        p++;  // 指针移动到下一个结构体
    }

    return 0;
}
直接访问:姓名 = 赵六 用指针(->):姓名 = 赵六 用指针(*).:姓名 = 赵六 调用函数前:姓名:赵六 | 年龄:21 | 分数:80.0 修改分数后:姓名:赵六 | 年龄:21 | 分数:95.0 用指针遍历数组: 张三 20岁 88.5分 李四 22岁 92.0分
⚠️ 为什么函数参数要用结构体指针而不是直接传结构体?直接传结构体会把整个结构体复制一份——如果结构体很大(如包含大数组),复制开销很大。传指针只复制一个地址(8字节),高效得多。加上 const 修饰(如 const Student *p)表示只读不写,更安全。

12.6 联合体 union —— 同一块内存的不同"视角"

联合体(union) 和结构体语法很像,但所有成员共享同一块内存。联合体的大小等于最大成员的大小。

💡 类比:联合体就像一个多功能房间——今天做卧室,明天做书房,但同一时间只能有一种用途。你往一个成员写数据,其他成员的内容就会被覆盖。
c#include <stdio.h>
#include <string.h>

// 定义一个联合体:同一块 4 字节内存的不同解释
union Data {
    int   i;       // 4 字节,解释为整数
    float f;       // 4 字节,解释为浮点数
    char  str[4];  // 4 字节,解释为字符数组
};

int main() {
    union Data data;

    printf("union Data 的大小 = %lu 字节\n\n", sizeof(union Data));

    // 设置为整数
    data.i = 42;
    printf("设为整数:data.i   = %d\n", data.i);
    printf("此时 data.f  = %f(垃圾值)\n", data.f);
    printf("此时 data.str = ");
    for (int i = 0; i < 4; i++) printf("%02X ", (unsigned char)data.str[i]);
    printf("(内存的十六进制表示)\n\n");

    // 设置为浮点数 —— 覆盖了之前的整数
    data.f = 3.14f;
    printf("设为浮点数:data.f   = %f\n", data.f);
    printf("此时 data.i  = %d(被覆盖了!)\n", data.i);
    printf("此时 data.str = ");
    for (int i = 0; i < 4; i++) printf("%02X ", (unsigned char)data.str[i]);
    printf("\n");

    return 0;
}
union Data 的大小 = 4 字节 设为整数:data.i = 42 此时 data.f = 0.000000(垃圾值) 此时 data.str = 2A 00 00 00(内存的十六进制表示) 设为浮点数:data.f = 3.140000 此时 data.i = 1078523331(被覆盖了!) 此时 data.str = C3 F5 48 40
⚠️ 联合体陷阱:联合体不会记住你现在存的是哪种类型!它只负责存储原始字节。通常需要额外用一个变量来"记住"当前类型。这也是为什么要小心使用——读取错误的成员会得到无意义的数据。

联合体的典型应用:

c#include <stdio.h>

// 定义一个"通用数值"类型
typedef struct {
    int type;  // 0=int, 1=float, 2=char
    union {
        int   i;
        float f;
        char  c;
    } value;
} GenericNumber;

void print_value(GenericNumber gn) {
    switch (gn.type) {
        case 0: printf("int:%d\n", gn.value.i); break;
        case 1: printf("float:%.2f\n", gn.value.f); break;
        case 2: printf("char:%c\n", gn.value.c); break;
        default: printf("未知类型\n");
    }
}

int main() {
    GenericNumber n1 = {0, .value.i = 100};
    GenericNumber n2 = {1, .value.f = 3.14f};
    GenericNumber n3 = {2, .value.c = 'A'};

    print_value(n1);
    print_value(n2);
    print_value(n3);

    return 0;
}
int:100 float:3.14 char:A

12.7 struct 与 union 的区别

特性struct(结构体)union(联合体)
内存分配每个成员独立占内存所有成员共享同一块内存
总大小所有成员大小之和(含对齐填充)最大成员的大小
成员访问所有成员同时有效只有最近写入的成员有效
典型用途描述一个对象的多项属性(如学生信息)节省内存、同一数据的不同解释
初始化{成员1, 成员2, ...}{.成员 = 值}(只初始化一个)

12.8 综合实例:简单的学生信息管理系统

下面我们综合运用本章学到的结构体、结构体数组、结构体指针、typedef 等知识,实现一个完整的学生信息管理小程序。

c#include <stdio.h>
#include <string.h>

#define MAX_STUDENTS 100
#define NAME_LEN 20

// 定义学生结构体
typedef struct {
    int   id;              // 学号
    char  name[NAME_LEN];  // 姓名
    int   age;             // 年龄
    float score;           // 成绩
} Student;

// 全局变量:学生数组和学生数量
Student students[MAX_STUDENTS];
int student_count = 0;

// 添加学生
void add_student(int id, const char *name, int age, float score) {
    if (student_count >= MAX_STUDENTS) {
        printf("❌ 学生人数已达上限!\n");
        return;
    }
    students[student_count].id = id;
    strcpy(students[student_count].name, name);
    students[student_count].age = age;
    students[student_count].score = score;
    student_count++;
    printf("✅ 添加成功!\n");
}

// 显示所有学生
void list_students() {
    if (student_count == 0) {
        printf("📭 当前没有学生记录。\n");
        return;
    }

    printf("\n========== 学生列表 ==========\n");
    printf("%-6s %-4s %-10s %-4s %s\n", "序号", "学号", "姓名", "年龄", "成绩");
    printf("------------------------------------\n");

    // 用指针遍历结构体数组
    Student *p = students;
    for (int i = 0; i < student_count; i++, p++) {
        printf("%-6d %-4d %-10s %-4d %.1f\n",
               i + 1, p->id, p->name, p->age, p->score);
    }
    printf("==============================\n");
}

// 通过学号查找学生(返回指针,无符号 const)
Student* find_by_id(int id) {
    for (int i = 0; i < student_count; i++) {
        if (students[i].id == id) {
            return &students[i];
        }
    }
    return NULL;
}

// 修改学生成绩
void update_score(int id, float new_score) {
    Student *p = find_by_id(id);
    if (p == NULL) {
        printf("❌ 未找到学号为 %d 的学生。\n", id);
        return;
    }
    p->score = new_score;  // 通过指针修改
    printf("✅ 已修改 %s 的成绩为 %.1f\n", p->name, new_score);
}

// 删除学生(通过移动数组)
void delete_student(int id) {
    int found = 0;
    for (int i = 0; i < student_count; i++) {
        if (students[i].id == id) {
            found = 1;
        }
        if (found && i < student_count - 1) {
            students[i] = students[i + 1];  // 结构体整体赋值
        }
    }
    if (found) {
        student_count--;
        printf("✅ 已删除学号为 %d 的学生。\n", id);
    } else {
        printf("❌ 未找到学号为 %d 的学生。\n", id);
    }
}

// 统计信息
void show_statistics() {
    if (student_count == 0) {
        printf("📭 没有学生数据可统计。\n");
        return;
    }

    float sum = 0, max = 0, min = 100;
    int pass_count = 0;

    Student *p = students;
    for (int i = 0; i < student_count; i++, p++) {
        sum += p->score;
        if (p->score > max) max = p->score;
        if (p->score < min) min = p->score;
        if (p->score >= 60) pass_count++;
    }

    printf("\n========== 成绩统计 ==========\n");
    printf("总人数:%d\n", student_count);
    printf("平均分:%.2f\n", sum / student_count);
    printf("最高分:%.1f\n", max);
    printf("最低分:%.1f\n", min);
    printf("及格率:%.1f%%\n", (float)pass_count / student_count * 100);
    printf("==============================\n");
}

int main() {
    int choice, id, age;
    char name[NAME_LEN];
    float score;

    // 预置一些测试数据
    add_student(1001, "张三", 20, 88.5);
    add_student(1002, "李四", 22, 92.0);
    add_student(1003, "王五", 19, 45.0);
    add_student(1004, "赵六", 21, 76.5);

    do {
        printf("\n╔══════════════════════════╗\n");
        printf("║   学生信息管理系统       ║\n");
        printf("╠══════════════════════════╣\n");
        printf("║ 1. 显示所有学生          ║\n");
        printf("║ 2. 添加学生              ║\n");
        printf("║ 3. 修改成绩              ║\n");
        printf("║ 4. 删除学生              ║\n");
        printf("║ 5. 按学号查找            ║\n");
        printf("║ 6. 成绩统计              ║\n");
        printf("║ 0. 退出                  ║\n");
        printf("╚══════════════════════════╝\n");
        printf("请选择操作:");
        scanf("%d", &choice);

        switch (choice) {
            case 1:
                list_students();
                break;

            case 2:
                printf("请输入学号:");
                scanf("%d", &id);
                printf("请输入姓名:");
                scanf("%s", name);
                printf("请输入年龄:");
                scanf("%d", &age);
                printf("请输入成绩:");
                scanf("%f", &score);
                add_student(id, name, age, score);
                break;

            case 3:
                printf("请输入要修改的学号:");
                scanf("%d", &id);
                printf("请输入新成绩:");
                scanf("%f", &score);
                update_score(id, score);
                break;

            case 4:
                printf("请输入要删除的学号:");
                scanf("%d", &id);
                delete_student(id);
                break;

            case 5: {
                printf("请输入学号:");
                scanf("%d", &id);
                Student *p = find_by_id(id);
                if (p) {
                    printf("学号:%d | 姓名:%s | 年龄:%d | 成绩:%.1f\n",
                           p->id, p->name, p->age, p->score);
                } else {
                    printf("❌ 未找到该学生。\n");
                }
                break;
            }

            case 6:
                show_statistics();
                break;

            case 0:
                printf("👋 感谢使用学生信息管理系统!\n");
                break;

            default:
                printf("❌ 无效选择,请重新输入。\n");
        }
    } while (choice != 0);

    return 0;
}
✅ 添加成功! ✅ 添加成功! ✅ 添加成功! ✅ 添加成功! ╔══════════════════════════╗ ║ 学生信息管理系统 ║ ╠══════════════════════════╣ ║ 1. 显示所有学生 ║ ║ 2. 添加学生 ║ ║ 3. 修改成绩 ║ ║ 4. 删除学生 ║ ║ 5. 按学号查找 ║ ║ 6. 成绩统计 ║ ║ 0. 退出 ║ ╚══════════════════════════╝ 请选择操作:1 ========== 学生列表 ========== 序号 学号 姓名 年龄 成绩 ------------------------------------ 1 1001 张三 20 88.5 2 1002 李四 22 92.0 3 1003 王五 19 45.0 4 1004 赵六 21 76.5 ============================== 请选择操作:6 ========== 成绩统计 ========== 总人数:4 平均分:75.50 最高分:92.0 最低分:45.0 及格率:75.0% ============================== 请选择操作:0 👋 感谢使用学生信息管理系统!
💡 项目要点分析:
  • typedef 简化:typedef struct {...} Student; 让代码更简洁
  • 结构体数组:Student students[MAX_STUDENTS] 存储多条记录
  • 结构体指针:find_by_id 返回 Student*update_scorep->score 修改
  • 指针遍历:Student *p = students; ... p++ 高效遍历数组
  • 结构体赋值:students[i] = students[i+1] 整体复制结构体

12.9 本章小结

知识点语法说明
定义结构体struct 名 { 成员列表 };组合不同类型的变量
访问成员变量.成员点号运算符
typedeftypedef ... 新名字;起别名,简化书写
结构体指针p->成员 = (*p).成员箭头访问,常用在函数参数
结构体数组Student arr[10];多条记录的集合
联合体union { ... };共享内存,节省空间

第13章 动态内存管理

前面的课程中,我们用的变量和数组都是在程序启动时就分配好内存的,这种方式叫静态内存分配。但你有没有想过:如果我不知道用户要输入多长的名字怎么办?如果运行时才发现需要多少数据怎么办?

这时候就需要动态内存分配——程序在运行的时候,根据实际需要向系统申请内存。用完了还要及时还给系统。C语言提供了一组标准库函数来完成这件事,它们都在 <stdlib.h> 头文件里。

13.1 malloc —— 申请内存

malloc 的全称是 memory allocation(内存分配),它的作用是在堆(heap)上申请一块连续的内存空间。

c#include <stdlib.h>
void *malloc(size_t size);

示例:申请一个整数空间

c#include <stdio.h>
#include <stdlib.h>

int main() {
    int *p = (int *)malloc(sizeof(int));  // 申请一个 int 大小的空间
    if (p == NULL) {                      // 一定要检查是否分配成功
        printf("内存分配失败!\n");
        return 1;
    }
    *p = 42;
    printf("数值: %d\n", *p);
    free(p);                              // 用完释放
    return 0;
}
数值: 42

13.2 calloc —— 申请并清零

callocmalloc 类似,但有两个区别:

  1. 它会把分配到的所有字节都初始化为 0
  2. 参数形式是"数量 × 每个大小",更直观
cvoid *calloc(size_t nmemb, size_t size);
// nmemb: 元素个数
// size:  每个元素的字节数

示例:申请一个包含5个整数的数组

c#include <stdio.h>
#include <stdlib.h>

int main() {
    int *arr = (int *)calloc(5, sizeof(int));  // 5个int,全部初始化为0
    if (arr == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }
    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);  // 全是0
    }
    printf("\n");
    free(arr);
    return 0;
}
0 0 0 0 0

13.3 realloc —— 调整已有内存的大小

有时候申请的内存不够用了,但又不想丢掉原有数据,这时可以用 realloc 来"扩容"或"缩容"。

cvoid *realloc(void *ptr, size_t new_size);
// ptr:      原来 malloc/calloc 返回的指针
// new_size: 新的字节数

注意事项

c#include <stdio.h>
#include <stdlib.h>

int main() {
    int *arr = (int *)malloc(3 * sizeof(int));
    for (int i = 0; i < 3; i++) arr[i] = i + 1;

    // 扩容到5个元素
    int *temp = (int *)realloc(arr, 5 * sizeof(int));
    if (temp == NULL) {
        printf("扩容失败!\n");
        free(arr);
        return 1;
    }
    arr = temp;  // 更新指针
    arr[3] = 4;
    arr[4] = 5;

    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
    free(arr);
    return 0;
}
1 2 3 4 5

13.4 free —— 释放内存

动态申请的内存用完之后,必须用 free 还给系统!如果不释放,程序占用的内存会越来越多,这叫内存泄漏

cvoid free(void *ptr);

黄金法则

cint *p = (int *)malloc(10 * sizeof(int));
// ... 使用 p ...
free(p);
p = NULL;  // 好习惯!

13.5 什么是内存泄漏?

内存泄漏(Memory Leak) 是指程序中动态分配的内存,不再需要后没有被释放,导致这块内存一直"被占用",无法被系统回收再用。

看这个错误的例子:

c// ❌ 内存泄漏的例子
void leak() {
    int *p = (int *)malloc(100 * sizeof(int));
    // 忘掉了 free(p)!
    // 函数返回后,再也找不到 p 的地址了,这块内存永远无法释放
}

如果这个函数在循环里被反复调用:

cwhile (1) {
    leak();  // 每次循环泄漏 400 字节(100 * 4)
}

程序的内存会一直增长,直到系统资源耗尽——对于服务器程序来说,这是致命的 bug!

💡 小窍门:使用 Valgrind 工具可以检测内存泄漏:valgrind --leak-check=full ./你的程序

13.6 实战:动态输入姓名

用户输入自己的名字,但不知道名字有多长。我们用动态内存来搞定:先申请一个小缓冲区,不够了就扩容。

c#include <stdio.h>
#include <stdlib.h>

int main() {
    int capacity = 10;      // 初始容量
    int size = 0;           // 当前有效字符数
    char *name = (char *)malloc(capacity * sizeof(char));
    if (name == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }

    printf("请输入你的姓名(输入 # 结束):\n");
    char ch;
    while (1) {
        ch = getchar();
        if (ch == '#' || ch == '\n') {
            break;
        }
        if (size >= capacity - 1) {  // 留一个位置给 '\0'
            capacity *= 2;
            char *temp = (char *)realloc(name, capacity * sizeof(char));
            if (temp == NULL) {
                printf("扩容失败!\n");
                free(name);
                return 1;
            }
            name = temp;
        }
        name[size++] = ch;
    }
    name[size] = '\0';  // 字符串结束符

    printf("你好,%s!姓名长度为 %d 个字符。\n", name, size);
    free(name);
    return 0;
}
请输入你的姓名(输入 # 结束):
张三丰#
你好,张三丰!姓名长度为 3 个字符。

本章小结

第14章 文件操作

到目前为止,我们的程序都是在内存里运行——数据一关程序就丢了。如果想把数据永久保存下来,就需要把数据写到文件里。C语言通过 <stdio.h> 提供了一套文件操作函数。

14.1 文件指针 FILE *

C语言操作文件的核心是一个叫 FILE 的结构体指针。你可以把它理解为"文件和程序之间的通道"。

cFILE *fp;  // 定义文件指针

14.2 fopen —— 打开文件

操作文件前,必须先打开它。

cFILE *fopen(const char *filename, const char *mode);
// filename: 文件名(可以带路径)
// mode:     "r"读取、"w"写入、"a"追加 等
// 返回值:成功返回 FILE *,失败返回 NULL

文件模式详解

模式含义文件不存在时文件存在时
"r"只读返回 NULL正常读取
"w"只写创建新文件清空原有内容
"a"追加创建新文件从末尾继续写
"r+"读写返回 NULL正常读写
"w+"读写创建新文件清空原有内容
"a+"读写追加创建新文件读取从头,写入在尾

14.3 fclose —— 关闭文件

文件用完后一定要关闭!不关闭可能导致数据丢失(缓冲区没刷新)或资源泄漏。

cint fclose(FILE *fp);
// 成功返回 0,失败返回 EOF

14.4 写入文件

fprintf —— 格式化写入

printf 用法一模一样,只是多了一个文件指针参数:

cint fprintf(FILE *fp, const char *format, ...);

示例:把数据写入文件

c#include <stdio.h>

int main() {
    FILE *fp = fopen("student.txt", "w");  // 打开文件,准备写入
    if (fp == NULL) {
        printf("文件打开失败!\n");
        return 1;
    }

    fprintf(fp, "姓名: 小明\n");
    fprintf(fp, "年龄: 18\n");
    fprintf(fp, "成绩: 95.5\n");

    fclose(fp);
    printf("写入完成!\n");
    return 0;
}
写入完成!
(查看 student.txt 文件内容)

fputs —— 写入字符串

cint fputs(const char *str, FILE *fp);
// 成功返回非负数,失败返回 EOF
cfputs("Hello, File!\n", fp);

14.5 读取文件

fscanf —— 格式化读取

scanf 一样,从文件中读取格式化的数据:

cint fscanf(FILE *fp, const char *format, ...);

示例:读取 student.txt

c#include <stdio.h>

int main() {
    FILE *fp = fopen("student.txt", "r");
    if (fp == NULL) {
        printf("文件打开失败!\n");
        return 1;
    }

    char name[50];
    int age;
    float score;

    fscanf(fp, "姓名: %s\n", name);
    fscanf(fp, "年龄: %d\n", &age);
    fscanf(fp, "成绩: %f\n", &score);

    printf("读取到:%s,%d岁,成绩%.1f\n", name, age, score);

    fclose(fp);
    return 0;
}
读取到:小明,18岁,成绩95.5

fgets —— 逐行读取字符串

cchar *fgets(char *str, int n, FILE *fp);
// 读取一行(含换行符),最多 n-1 个字符
// 成功返回 str,读到文件末尾或失败返回 NULL
cchar line[100];
while (fgets(line, sizeof(line), fp) != NULL) {
    printf("%s", line);  // 逐行打印文件内容
}

14.6 其他常用文件函数

函数功能
fgetc(fp)读一个字符
fputc(ch, fp)写一个字符
feof(fp)判断是否到文件末尾(到末尾返回非0)
remove("文件名")删除文件
rename("旧名","新名")重命名文件

14.7 实战:文件读写成绩

编写一个程序,让用户输入5名学生的姓名和成绩,保存到文件,然后再从文件中读出来显示。

c#include <stdio.h>
#include <string.h>

#define STUDENT_COUNT 5
#define NAME_LEN 20

int main() {
    char names[STUDENT_COUNT][NAME_LEN];
    float scores[STUDENT_COUNT];
    FILE *fp;

    // ====== 写入阶段 ======
    printf("请输入 %d 名学生的姓名和成绩:\n", STUDENT_COUNT);
    for (int i = 0; i < STUDENT_COUNT; i++) {
        printf("第%d名学生姓名: ", i + 1);
        scanf("%s", names[i]);
        printf("第%d名学生成绩: ", i + 1);
        scanf("%f", &scores[i]);
    }

    fp = fopen("scores.txt", "w");
    if (fp == NULL) {
        printf("文件打开失败!\n");
        return 1;
    }

    for (int i = 0; i < STUDENT_COUNT; i++) {
        fprintf(fp, "%s %.1f\n", names[i], scores[i]);
    }
    fclose(fp);
    printf("✅ 成绩已保存到 scores.txt\n\n");

    // ====== 读取阶段 ======
    fp = fopen("scores.txt", "r");
    if (fp == NULL) {
        printf("文件打开失败!\n");
        return 1;
    }

    printf("从文件读取的成绩如下:\n");
    printf("========================\n");
    char name[NAME_LEN];
    float score;
    float sum = 0;
    int count = 0;

    while (fscanf(fp, "%s %f", name, &score) == 2) {
        printf("姓名: %-8s 成绩: %.1f\n", name, score);
        sum += score;
        count++;
    }
    fclose(fp);

    printf("========================\n");
    if (count > 0) {
        printf("平均分: %.2f\n", sum / count);
    }

    return 0;
}
请输入 5 名学生的姓名和成绩:
第1名学生姓名: 张三
第1名学生成绩: 88.5
第2名学生姓名: 李四
第2名学生成绩: 92
第3名学生姓名: 王五
第3名学生成绩: 76
第4名学生姓名: 赵六
第4名学生成绩: 95.5
第5名学生姓名: 孙七
第5名学生成绩: 83
✅ 成绩已保存到 scores.txt

从文件读取的成绩如下:
========================
姓名: 张三 成绩: 88.5
姓名: 李四 成绩: 92.0
姓名: 王五 成绩: 76.0
姓名: 赵六 成绩: 95.5
姓名: 孙七 成绩: 83.0
========================
平均分: 87.00

本章小结

第15章 预处理与宏

你是不是注意到,我们写的每个C程序最上面都有 #include <stdio.h>?这个以 # 开头的语句,就是预处理指令

预处理发生在编译之前,它会对源代码进行文本层面的处理。预处理完成后才进入真正的编译过程。

15.1 #define —— 宏定义

#define 用来定义一个(macro),本质上是文本替换——编译器在预处理阶段会把代码中的宏名称替换成定义的内容。

不带参数的宏(符号常量)

c#define PI 3.14159
#define MAX_STUDENTS 100
#define SCHOOL_NAME "清华大学"

写上 #define PI 3.14159 后,代码中所有的 PI 都会被替换成 3.14159

c#include <stdio.h>
#define PI 3.14159

int main() {
    double r = 5.0;
    double area = PI * r * r;  // 预处理后被替换为: 3.14159 * r * r
    printf("半径%.1f的圆面积 = %.4f\n", r, area);
    return 0;
}
半径5.0的圆面积 = 78.5397

带参数的宏(宏函数)

宏甚至可以像函数一样带参数:

c#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))

⚠️ 注意:参数要加括号!

为什么?看看不加括号的后果:

c#define BAD_SQUARE(x) x * x

// BAD_SQUARE(2 + 3) 被替换成: 2 + 3 * 2 + 3 = 2 + 6 + 3 = 11 ❌
// 正确的 SQUARE(2 + 3) 被替换成: (2+3) * (2+3) = 5 * 5 = 25 ✅
c#include <stdio.h>
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main() {
    printf("SQUARE(5) = %d\n", SQUARE(5));
    printf("SQUARE(2+3) = %d\n", SQUARE(2+3));
    printf("MAX(10, 20) = %d\n", MAX(10, 20));
    printf("MAX(SQUARE(3), SQUARE(4)) = %d\n", MAX(SQUARE(3), SQUARE(4)));
    return 0;
}
SQUARE(5) = 25
SQUARE(2+3) = 25
MAX(10, 20) = 20
MAX(SQUARE(3), SQUARE(4)) = 16

宏 vs 函数:怎么选?

对比项函数
执行速度快(无调用开销)有函数调用开销
代码体积每次使用都会复制代码只有一份代码
类型检查无(只是文本替换)有严格的类型检查
副作用风险较高(如 MAX(x++, y))较低

15.2 #undef —— 取消宏定义

c#define TEMP 100
// ... 可以使用 TEMP ...
#undef TEMP
// 这之后 TEMP 不再被定义

15.3 #include —— 文件包含

#include 用于把另一个文件的内容"复制粘贴"到当前位置。

c#include <stdio.h>    // 标准库头文件——用尖括号 <>
#include "myheader.h" // 自定义头文件——用双引号 ""

两种写法的区别:

自定义头文件示例(utils.h):

c// ====== utils.h ======
#ifndef UTILS_H
#define UTILS_H

#define PI 3.14159
int add(int a, int b) {
    return a + b;
}

#endif
c// ====== main.c ======
#include <stdio.h>
#include "utils.h"

int main() {
    printf("PI = %f\n", PI);
    printf("add(3, 5) = %d\n", add(3, 5));
    return 0;
}
PI = 3.141590
add(3, 5) = 8

15.4 条件编译 #ifdef / #ifndef / #endif

条件编译让编译器根据条件决定哪些代码参与编译。这在跨平台、调试开关等场景中非常有用。

基本语法

c#ifdef 宏名
    // 如果这个宏被定义了,才编译这里的代码
#endif

#ifndef 宏名
    // 如果这个宏没有被定义,才编译这里的代码
#endif

#if / #elif / #else / #endif

c#if 条件表达式
    // 条件为真时编译
#elif 另一条件
    // 另一条件为真时编译
#else
    // 以上都不满足时编译
#endif

实战:跨平台代码

c#include <stdio.h>

int main() {
    #ifdef _WIN32
        printf("你在 Windows 系统上运行\n");
    #elif defined(__linux__)
        printf("你在 Linux 系统上运行\n");
    #elif defined(__APPLE__)
        printf("你在 macOS 系统上运行\n");
    #else
        printf("未知操作系统\n");
    #endif
    return 0;
}

15.5 预定义宏

C语言内置了一些有用的宏:

c#include <stdio.h>

int main() {
    printf("当前文件: %s\n", __FILE__);
    printf("当前行号: %d\n", __LINE__);
    printf("编译日期: %s\n", __DATE__);
    printf("编译时间: %s\n", __TIME__);
    printf("C标准版本: %ld\n", __STDC_VERSION__);
    return 0;
}
当前文件: main.c
当前行号: 7
编译日期: Jun 9 2026
编译时间: 14:30:00
C标准版本: 201112

15.6 实战:调试开关宏

在开发过程中,我们经常需要打印调试信息,但发布时又不能把这些信息留给用户。用条件编译可以完美解决:

c#include <stdio.h>
#include <time.h>

// ====== 调试开关 ======
// 取消下面这行的注释,启用调试模式
// #define DEBUG

#ifdef DEBUG
    // 定义调试打印宏——会输出文件名、行号、信息
    #define DEBUG_PRINT(fmt, ...) \
        printf("[DEBUG] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
    // 发布模式下,调试宏什么都不做
    #define DEBUG_PRINT(fmt, ...) ((void)0)
#endif

// ====== 程序主体 ======
int main() {
    int total = 0;

    DEBUG_PRINT("程序启动");

    for (int i = 1; i <= 10; i++) {
        total += i;
        DEBUG_PRINT("i = %d, 当前累加和 = %d", i, total);
    }

    printf("1 + 2 + ... + 10 = %d\n", total);

    DEBUG_PRINT("程序结束,最终结果 = %d", total);

    return 0;
}

关闭 DEBUG(发布模式)

1 + 2 + ... + 10 = 55

开启 DEBUG(调试模式)
去掉 // #define DEBUG 前面的 //,再编译运行:

[DEBUG] main.c:27: 程序启动
[DEBUG] main.c:31: i = 1, 当前累加和 = 1
[DEBUG] main.c:31: i = 2, 当前累加和 = 3
[DEBUG] main.c:31: i = 3, 当前累加和 = 6
[DEBUG] main.c:31: i = 4, 当前累加和 = 10
[DEBUG] main.c:31: i = 5, 当前累加和 = 15
[DEBUG] main.c:31: i = 6, 当前累加和 = 21
[DEBUG] main.c:31: i = 7, 当前累加和 = 28
[DEBUG] main.c:31: i = 8, 当前累加和 = 36
[DEBUG] main.c:31: i = 9, 当前累加和 = 45
[DEBUG] main.c:31: i = 10, 当前累加和 = 55
[DEBUG] main.c:35: 程序结束,最终结果 = 55

编译时的"开/关"技巧

bash# 编译时开启调试(不需要改代码!)
gcc -DDEBUG main.c -o program

# 编译时关闭调试(默认)
gcc main.c -o program

15.7 #pragma —— 编译器指令

#pragma 是向编译器发送特殊指令,不同编译器支持的不一样。

c#pragma once  // 只包含一次头文件(现代替代 #ifndef 的方案)
#pragma GCC warning "请更新这段代码"  // 编译时产生警告

本章小结

第16章 综合项目:图书管理系统

恭喜你来到了最后一章!我们将用前面学到的所有知识(结构体、动态内存、文件操作、函数、指针)来做一个完整的项目——图书管理系统

功能需求

  1. 添加图书:输入书名、作者、价格、库存数量
  2. 显示所有图书:以表格形式展示
  3. 查找图书:按书名关键词搜索
  4. 删除图书:按书名删除指定图书
  5. 保存到文件:退出时自动保存,启动时自动加载
  6. 退出系统

📚 图书管理系统 —— 完整代码

c/*
 * 图书管理系统 - 完整实现
 * 功能:添加 / 显示 / 查找 / 删除 / 文件持久化
 * 编译:gcc book_manager.c -o book_manager
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ====== 数据定义 ======

#define MAX_BOOKS 100      // 最大图书数量
#define NAME_LEN 50        // 书名最大长度
#define AUTHOR_LEN 30      // 作者名最大长度
#define DATA_FILE "books.dat"  // 数据文件名

typedef struct {
    int id;                // 图书编号
    char title[NAME_LEN];  // 书名
    char author[AUTHOR_LEN]; // 作者
    float price;           // 价格
    int stock;             // 库存数量
} Book;

// ====== 全局变量 ======

Book books[MAX_BOOKS];  // 图书数组
int bookCount = 0;      // 当前图书数量

// ====== 函数声明 ======

void showMenu();
void addBook();
void listBooks();
void searchBook();
void deleteBook();
void saveToFile();
void loadFromFile();

// ====== 主函数 ======

int main() {
    loadFromFile();  // 启动时加载已有数据

    int choice;
    while (1) {
        showMenu();
        printf("请输入你的选择: ");
        scanf("%d", &choice);
        getchar();  // 吃掉回车

        switch (choice) {
            case 1: addBook();     break;
            case 2: listBooks();   break;
            case 3: searchBook();  break;
            case 4: deleteBook();  break;
            case 5:
                saveToFile();
                printf("感谢使用图书管理系统,再见!\n");
                return 0;
            default:
                printf("无效选择,请重新输入(1-5)。\n");
        }
        printf("\n按回车键继续...");
        getchar();
    }
    return 0;
}

// ====== 显示菜单 ======

void showMenu() {
    system("clear");  // Windows 下改成 system("cls");
    printf("╔══════════════════════════════╗\n");
    printf("║      📚 图书管理系统         ║\n");
    printf("╠══════════════════════════════╣\n");
    printf("║  1. 添加图书                 ║\n");
    printf("║  2. 显示所有图书             ║\n");
    printf("║  3. 查找图书                 ║\n");
    printf("║  4. 删除图书                 ║\n");
    printf("║  5. 保存并退出               ║\n");
    printf("╚══════════════════════════════╝\n");
    printf("当前藏书: %d 本\n\n", bookCount);
}

// ====== 添加图书 ======

void addBook() {
    if (bookCount >= MAX_BOOKS) {
        printf("❌ 库存已满,无法添加更多图书!\n");
        return;
    }

    Book *b = &books[bookCount];
    b->id = bookCount + 1;

    printf("\n--- 添加新图书 ---\n");
    printf("书名: ");
    fgets(b->title, NAME_LEN, stdin);
    b->title[strcspn(b->title, "\n")] = '\0';  // 去掉换行符

    printf("作者: ");
    fgets(b->author, AUTHOR_LEN, stdin);
    b->author[strcspn(b->author, "\n")] = '\0';

    printf("价格: ");
    scanf("%f", &b->price);
    printf("库存数量: ");
    scanf("%d", &b->stock);

    bookCount++;
    printf("✅ 图书《%s》添加成功!编号: %d\n", b->title, b->id);
}

// ====== 显示所有图书 ======

void listBooks() {
    if (bookCount == 0) {
        printf("📭 当前没有任何图书。\n");
        return;
    }

    printf("\n%s\n", "═══════════════════════════════════════════════════════════");
    printf("%-4s %-20s %-12s %-8s %-6s\n", "编号", "书名", "作者", "价格", "库存");
    printf("%s\n", "───────────────────────────────────────────────────────────");

    for (int i = 0; i < bookCount; i++) {
        printf("%-4d %-20s %-12s %-8.2f %-6d\n",
               books[i].id,
               books[i].title,
               books[i].author,
               books[i].price,
               books[i].stock);
    }
    printf("%s\n", "═══════════════════════════════════════════════════════════");
    printf("总计: %d 本图书\n", bookCount);
}

// ====== 查找图书 ======

void searchBook() {
    if (bookCount == 0) {
        printf("📭 当前没有任何图书。\n");
        return;
    }

    char keyword[NAME_LEN];
    printf("请输入书名关键词: ");
    fgets(keyword, NAME_LEN, stdin);
    keyword[strcspn(keyword, "\n")] = '\0';

    int found = 0;
    printf("\n🔍 搜索结果(关键词: %s):\n", keyword);
    printf("%s\n", "───────────────────────────────────────────────────────");
    printf("%-4s %-20s %-12s %-8s %-6s\n", "编号", "书名", "作者", "价格", "库存");

    for (int i = 0; i < bookCount; i++) {
        // 使用 strstr 判断书名是否包含关键词
        if (strstr(books[i].title, keyword) != NULL) {
            printf("%-4d %-20s %-12s %-8.2f %-6d\n",
                   books[i].id, books[i].title,
                   books[i].author, books[i].price, books[i].stock);
            found = 1;
        }
    }

    if (!found) {
        printf("未找到包含"%s"的图书。\n", keyword);
    }
}

// ====== 删除图书 ======

void deleteBook() {
    if (bookCount == 0) {
        printf("📭 当前没有任何图书。\n");
        return;
    }

    char title[NAME_LEN];
    printf("请输入要删除的书名: ");
    fgets(title, NAME_LEN, stdin);
    title[strcspn(title, "\n")] = '\0';

    int foundIndex = -1;
    for (int i = 0; i < bookCount; i++) {
        if (strcmp(books[i].title, title) == 0) {
            foundIndex = i;
            break;
        }
    }

    if (foundIndex == -1) {
        printf("❌ 未找到《%s》这本书。\n", title);
        return;
    }

    // 确认删除
    printf("确定要删除《%s》吗?(y/n): ", books[foundIndex].title);
    char confirm;
    scanf(" %c", &confirm);
    if (confirm != 'y' && confirm != 'Y') {
        printf("已取消删除。\n");
        return;
    }

    // 将后面的书往前移,覆盖掉要删除的
    for (int i = foundIndex; i < bookCount - 1; i++) {
        books[i] = books[i + 1];
    }
    bookCount--;

    // 重新编号
    for (int i = 0; i < bookCount; i++) {
        books[i].id = i + 1;
    }

    printf("✅ 已删除《%s》。当前藏书 %d 本。\n", title, bookCount);
}

// ====== 保存到文件 ======

void saveToFile() {
    FILE *fp = fopen(DATA_FILE, "w");
    if (fp == NULL) {
        printf("❌ 无法打开文件保存数据!\n");
        return;
    }

    // 第一行写入总数
    fprintf(fp, "%d\n", bookCount);

    for (int i = 0; i < bookCount; i++) {
        fprintf(fp, "%d|%s|%s|%.2f|%d\n",
                books[i].id,
                books[i].title,
                books[i].author,
                books[i].price,
                books[i].stock);
    }

    fclose(fp);
    printf("✅ 数据已保存到 %s(共 %d 本图书)\n", DATA_FILE, bookCount);
}

// ====== 从文件加载 ======

void loadFromFile() {
    FILE *fp = fopen(DATA_FILE, "r");
    if (fp == NULL) {
        // 首次运行,没有数据文件,正常
        return;
    }

    if (fscanf(fp, "%d\n", &bookCount) != 1) {
        bookCount = 0;
        fclose(fp);
        return;
    }

    for (int i = 0; i < bookCount; i++) {
        Book *b = &books[i];
        // 用 | 分隔各字段来读取
        fscanf(fp, "%d|%[^|]|%[^|]|%f|%d\n",
               &b->id, b->title, b->author, &b->price, &b->stock);
    }

    fclose(fp);
    printf("📖 已从 %s 加载 %d 本图书\n", DATA_FILE, bookCount);
}

💻 编译与运行

bash# 编译
gcc book_manager.c -o book_manager

# 运行
./book_manager

▶️ 运行效果展示

╔══════════════════════════════╗
║ 📚 图书管理系统 ║
╠══════════════════════════════╣
║ 1. 添加图书 ║
║ 2. 显示所有图书 ║
║ 3. 查找图书 ║
║ 4. 删除图书 ║
║ 5. 保存并退出 ║
╚══════════════════════════════╝
当前藏书: 0 本

请输入你的选择: 1

--- 添加新图书 ---
书名: C语言入门到精通
作者: 张三
价格: 59.9
库存数量: 10
✅ 图书《C语言入门到精通》添加成功!编号: 1

--- 添加新图书 ---
书名: 数据结构与算法
作者: 李四
价格: 45.0
库存数量: 5
✅ 图书《数据结构与算法》添加成功!编号: 2

请输入你的选择: 2

═══════════════════════════════════════════════════════════
编号 书名 作者 价格 库存
───────────────────────────────────────────────────────────
1 C语言入门到精通 张三 59.90 10
2 数据结构与算法 李四 45.00 5
═══════════════════════════════════════════════════════════
总计: 2 本图书

请输入你的选择: 5
✅ 数据已保存到 books.dat(共 2 本图书)
感谢使用图书管理系统,再见!

代码设计解析

模块技术点对应章节
图书结构体 Bookstruct + typedef第11章
书本数组 books[MAX_BOOKS]结构体数组第11章
添加/删除时数组移位数组元素移动第6章
fgets 输入字符串安全的字符串输入第9章
strstr / strcmp 字符串操作字符串函数第9章
saveToFile / loadFromFile文件读写(fprintf/fscanf)第14章
分隔符格式存储文件格式设计(|分隔)第14章
函数模块化函数封装第5章
全局变量 bookCount全局变量第5章

挑战一下

如果你觉得这个项目不过瘾,试试加入这些功能:

  1. 修改图书信息:修改指定图书的价格或库存
  2. 借书/还书:扣减/增加库存,添加借阅记录
  3. 排序功能:按价格从低到高排序,或按书名排序
  4. 统计功能:总藏书量、总价值(价格×库存)、平均价格
  5. 分页显示:如果图书很多,每页只显示10本
  6. 改用动态内存:用 malloc / realloc 代替固定数组

🎉 恭喜你完成了C语言入门之旅!

通过这16章的学习,你已经掌握了C语言的核心知识:

下一步可以学习:链表数据结构Linux系统编程算法竞赛入门。继续加油!🚀

iv>