UCD-ECS036a-C-底层编程笔记-全-
UCD ECS036a C 底层编程笔记(全)
001:Unix进程创建基础 🖥️
在本节课中,我们将学习Unix/Linux系统的基础知识,包括文件操作、进程管理以及一些核心的Shell命令。这些知识是后续学习C语言底层编程的重要基础。
概述
本节课程将介绍Unix系统的基本操作单元——进程,并讲解如何在Shell环境中进行文件管理、查看进程状态以及控制进程的执行。
文件与目录操作
上一节我们介绍了课程的整体框架,本节中我们来看看如何在Unix系统中操作文件和目录。
删除文件与目录
使用 rm 命令可以删除文件。如果系统询问是否删除常规文件,例如“core”,用户可以选择确认或取消操作。
取消命令执行:如果误操作,可以按下 Control + C 来终止当前命令。
删除目录:使用 rm -r 命令可以递归删除目录及其内容。使用此命令需要非常小心。
目录权限
使用 ls -l 命令可以列出目录内容及其权限。权限分为三组:用户(user)、组(group)和其他人(others)。
对于目录,权限的含义如下:
- r(读取):允许列出目录内容。
- w(写入):允许在目录中创建或删除文件。
- x(执行):允许搜索目录,即进入目录或查找特定文件。
例如,权限 drwxr-xr-x 表示:
- 所有者可以读、写、执行(进入)该目录。
- 组用户和其他用户可以读和执行(进入)该目录,但不能写入。
这意味着,即使其他用户不能列出你的目录内容或创建文件,但如果他们有执行(x)权限,他们仍然可以通过指定完整路径进入该目录。
在大多数CSIF账户设置中,其他用户通常没有对个人主目录的搜索(执行)权限。但 /tmp 目录是一个临时区域,通常所有人都可以写入。
移动与复制文件
以下是文件操作的基本命令:
移动/重命名文件:使用 mv 命令。
mv randomX randomY
此命令将文件 randomX 重命名为 randomY。执行后,randomX 将不再存在,只有 randomY。
复制文件:使用 cp 命令。
cp hello.c hi_there.c
此命令创建 hello.c 的一个副本,名为 hi_there.c。使用 cat 命令查看两个文件,可以确认它们内容相同。
查看文件内容
cat 命令用于显示文件内容,其名称来源于“concatenate”(连接)。
直接使用 cat 查看长文件时,内容可能会快速滚过屏幕。为了解决这个问题,可以使用以下命令:
分页查看:使用 more 命令。
more filename
more 会一次显示一页内容,按空格键可以继续翻页。
更灵活的分页查看:使用 less 命令。
less filename
less 的功能与 more 类似,但提供了更多导航功能(如上下滚动)。要退出 less,只需按 q 键。
用户可以根据习惯选择使用 more 或 less。
进程管理
在了解了文件操作后,我们来看看Unix系统的核心——进程。
什么是进程
进程是系统的基本执行单元,简单来说,就是一个正在运行的程序。
例如,当你输入 date 命令时,系统会创建一个进程来执行该程序,然后输出当前的日期和时间。有些程序(如 date)非交互式地运行并立即结束;而有些程序(如文本编辑器或计算器)则是交互式的,会等待用户输入。
查看进程
要查看当前系统中正在运行的进程,可以使用 ps 命令。
基本查看:输入 ps 会列出与你当前终端会话相关的进程。
ps
输出通常包括进程ID(PID)、终端标识(如 pts/0)和命令名称(如 bash)。PID是进程的唯一编号,非常重要。
查看所有进程:使用 ps aux 或 ps -ef 可以列出系统上运行的所有进程。
ps aux
控制进程
进程ID(PID)在控制进程时非常有用。例如,当你需要终止一个长时间运行或失去响应的程序时。
终止进程:使用 kill 命令。
kill PID
此命令会向指定PID的进程发送一个终止信号(默认是 SIGTERM),请求其正常退出。
强制终止进程:如果进程不响应普通的 kill 命令(例如,Shell程序通常会忽略某些信号以防止误操作),可以使用 kill -9 命令。
kill -9 PID
-9 参数发送的是 SIGKILL 信号,该信号不可被进程捕获或忽略,会立即强制终止进程。请注意,强制终止Shell进程可能会导致你被登出系统,请谨慎使用。
输出重定向
Shell的一个强大功能是输出重定向,它允许你将命令的输出保存到文件中,而不是显示在屏幕上。
重定向输出:使用 > 符号。
ls > filelist.txt
此命令将 ls 命令的输出(当前目录的文件列表)写入到 filelist.txt 文件中。如果文件已存在,其原有内容将被覆盖。
默认情况下,重定向到文件的输出是单列显示的。这与直接在终端中显示的多列格式有所不同。
总结
本节课我们一起学习了Unix/Linux系统编程的基础知识。我们介绍了文件和目录的基本操作命令(rm, mv, cp, cat, more, less),理解了目录权限的含义。我们深入探讨了进程的概念,学习了如何查看(ps)和控制(kill)进程。最后,我们还了解了输出重定向(>)这一实用功能。


这些Shell操作是进行C语言编程,特别是系统级编程的必备技能。在下一节课中,我们将开始学习如何编译和运行C语言程序。
002:C程序基础与编译 🖥️

在本节课中,我们将学习C语言程序的基本结构,了解如何编写、编译和运行一个简单的C程序。我们还将通过一个“找零钱”的例子,学习如何将解决问题的思路转化为C语言代码。
概述
上一节我们介绍了Linux系统的基本操作。本节中,我们来看看C语言编程的基础。我们将从一个经典的“Hello World”程序开始,学习C程序的基本框架、编译过程,并通过一个实际例子理解如何设计算法并将其转化为C代码。
C程序的基本结构
一个最简单的C程序是打印“Hello World”。以下是其基本结构:
#include <stdio.h>
int main(void) {
printf("Hello World\n");
return 0;
}
#include <stdio.h>:这是一个头文件。它包含了标准输入输出函数的声明,例如我们使用的printf。几乎每个进行输入输出的C程序都需要包含它。int main(void):这是每个C程序的入口点。程序从这里开始执行。int表示这个函数会返回一个整数。{ }:花括号{ }定义了main函数的代码块。所有要执行的语句都写在这对花括号内部。printf("Hello World\n");:这是一个函数调用,用于在屏幕上打印文本。\n表示换行。在C语言中,每条语句必须以分号;结尾。return 0;:这表示程序正常结束。在C语言惯例中,返回0通常表示程序成功运行。
编译与运行C程序
C语言是一种编译型语言。这意味着你不能直接运行 .c 源代码文件。你必须先用编译器将其翻译成计算机能理解的机器语言,生成一个可执行文件。
以下是编译和运行的基本步骤:
-
编译:使用
gcc编译器。假设你的源代码文件名为hello.c。gcc hello.c如果代码没有错误,这个命令会生成一个名为
a.out的可执行文件。 -
运行:执行生成的可执行文件。
./a.out程序将运行并输出
Hello World。 -
指定输出文件名:你可以使用
-o选项为生成的可执行文件指定一个不同的名字。gcc hello.c -o hello ./hello
重要提示:如果编译时出现 error,说明代码有致命错误,编译器无法生成可执行文件。如果出现 warning,虽然可能生成文件,但你也应该仔细检查代码,因为警告可能预示着潜在的问题。
一个更复杂的例子:温度转换表
为了更深入地了解C语言,我们来看一个将华氏温度转换为摄氏温度并打印表格的程序。这个例子展示了变量、循环和算术运算。
#include <stdio.h>
#include <stdlib.h>
/* 程序:fahr2cel
* 功能:打印华氏温度到摄氏温度的转换表
* 用法:./fahr2cel
*/
int main(void) {
int fahr, celsius;
int lower = 0; /* 温度表下限 */
int upper = 300; /* 温度表上限 */
int step = 20; /* 步长 */
printf("Degrees F\tDegrees C\n"); // \t 是制表符,用于对齐
fahr = lower;
while (fahr <= upper) {
celsius = 5 * (fahr - 32) / 9;
printf("%d\t\t%d\n", fahr, celsius);
fahr = fahr + step; // 等价于 fahr += step;
}
exit(0);
}
- 变量声明:在C语言中,使用变量前必须声明其类型(如
int表示整数)。这通常在函数开头完成。 while循环:循环执行花括号{ }内的代码,直到条件(fahr <= upper)为假。printf格式化:%d是一个占位符,表示在此处打印一个整数。第一个%d对应第一个参数fahr,第二个对应celsius。- 算术运算:注意表达式
5 * (fahr - 32) / 9。在C语言中,如果除号/两边的操作数都是整数,则执行整数除法,结果会舍弃小数部分。
如何设计一个程序:找零钱示例
上一节我们介绍了C语言的语法基础。本节中,我们来看看如何将解决实际问题的思路转化为程序。我们将以“计算找零所需的硬币数量”为例,演示从问题分析到代码实现的完整过程。
设计程序的关键在于先理清思路,再编写代码。以下是核心步骤:
- 明确目标:编写一个程序,计算给定金额(美分)需要多少个25美分(quarters)、10美分(dimes)、5美分(nickels)和1美分(pennies)。
- 定义输入输出:
- 输入:一个整数(例如
99,代表99美分)。 - 输出:四个整数,分别代表25美分、10美分、5美分和1美分硬币的数量。
- 输入:一个整数(例如
- 设计算法(用自然语言描述):思考如果是你手动计算会怎么做。
- 读入总金额
amount。 - 计算
quarters = amount / 25。 - 计算剩余金额
leftover = amount % 25。 - 计算
dimes = leftover / 10。 - 更新
leftover = leftover % 10。 - 计算
nickels = leftover / 5。 - 更新
leftover = leftover % 5,此时的leftover就是pennies的数量。 - 打印结果。
- 读入总金额
- 转化为C语言代码:将上述步骤逐句翻译成C语句。
以下是实现该算法的C程序代码:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int amount; // 总金额
int leftover; // 剩余金额
int quarters, dimes, nickels, pennies; // 各种硬币数量
// 1. 读取输入
printf("Enter amount in cents: ");
scanf("%d", &amount);
// 2. 计算硬币数量
leftover = amount;
quarters = leftover / 25;
leftover = leftover % 25;
dimes = leftover / 10;
leftover = leftover % 10;
nickels = leftover / 5;
leftover = leftover % 5;
pennies = leftover;
// 3. 打印结果
printf("For %d cents, you need:\n", amount);
printf(" Quarters: %d\n", quarters);
printf(" Dimes: %d\n", dimes);
printf(" Nickels: %d\n", nickels);
printf(" Pennies: %d\n", pennies);
return 0;
}
scanf(“%d”, &amount):这是用于从用户输入读取数据的函数。%d表示读取一个整数,&amount表示将读取的值存入变量amount中(&是取地址运算符,后续课程会详细讲解)。/和%:/用于整数除法得到商,%用于求余数得到模。它们是解决此类问题的关键运算符。
总结


本节课中我们一起学习了C语言编程的入门知识。我们首先了解了C程序的基本结构,包括 main 函数、头文件和语句格式。然后,我们掌握了使用 gcc 编译器将源代码转换为可执行文件的过程。最后,我们通过“找零钱”这个实例,完整地演练了从问题分析、算法设计到代码实现的编程思维流程。记住,编程的关键在于清晰地定义问题并一步步地将其转化为计算机能执行的指令。
003:输入处理

概述
在本节课中,我们将学习C语言中的变量命名规则、基本数据类型、算术运算符以及类型转换。这些是编写C程序的基础,理解它们对于后续学习至关重要。
变量命名规则
在C语言中,变量名由字母、数字和下划线字符组成。以下是有效的变量名示例:amount_of_money、hello2、_XYZZY。
需要注意的是,变量名不能以数字开头,例如2amount是无效的。此外,C语言区分大小写,因此Amount和amount是两个不同的变量。
选择有意义的变量名非常重要,这有助于理解代码的功能。例如,在处理财务问题时,使用amount_of_money比使用x更清晰。
基本数据类型
C语言有几种基本数据类型:整数、字符和浮点数。
整数类型
整数类型包括short、int和long。这些类型的大小取决于系统架构,但保证short的长度不超过int,int的长度不超过long。
整数可以是带符号的(signed)或不带符号的(unsigned)。带符号整数可以表示正数和负数,而不带符号整数只能表示非负数。
字符类型
字符类型(char)用于存储单个字符,例如字母或数字。字符用单引号表示,例如'5'或'x'。
浮点数类型
浮点数类型包括float和double。double类型可以表示更大范围的数字,精度也更高。
类型转换
在C语言中,可以使用类型转换将一种类型转换为另一种类型。例如,将整数转换为浮点数:
float f = (float)3;
将浮点数转换为整数时,小数部分会被截断,而不是四舍五入:
int i = (int)3.7; // i的值为3
算术运算符
C语言支持基本的算术运算符:加法(+)、减法(-)、乘法(*)和除法(/)。需要注意的是,C语言没有幂运算符(**)。
运算符优先级
算术运算符的优先级遵循数学规则:乘法和除法优先于加法和减法。例如:
int result = 8 * 5 + 3; // 结果为43,而不是64
如果需要改变运算顺序,可以使用括号:
int result = 8 * (5 + 3); // 结果为64
整数除法
当两个整数相除时,结果也是整数,小数部分会被忽略。例如:
int result = 5 / 2; // 结果为2,而不是2.5
如果需要得到浮点数结果,至少其中一个操作数必须是浮点数:
float result = 5.0 / 2; // 结果为2.5
示例程序
以下是一个将华氏温度转换为摄氏温度的程序示例:
#include <stdio.h>
#define LOWER 0
#define UPPER 300
#define STEP 20
int main() {
float fahr, celsius;
printf("华氏温度 摄氏温度\n");
for (fahr = LOWER; fahr <= UPPER; fahr += STEP) {
celsius = (5.0 / 9.0) * (fahr - 32.0);
printf("%3.0f %6.1f\n", fahr, celsius);
}
return 0;
}
在这个程序中,我们使用了#define定义常量,并通过循环计算并输出温度转换结果。需要注意的是,5.0 / 9.0确保了浮点数除法,避免了整数除法导致的错误。

总结

本节课中,我们一起学习了C语言中的变量命名规则、基本数据类型、类型转换和算术运算符。理解这些基础知识是编写正确且高效C程序的关键。在后续课程中,我们将进一步学习循环、条件语句和其他高级主题。
004:条件语句与输入错误处理 🧠

在本节课中,我们将要学习C语言中的逻辑关系、运算符以及条件语句。我们还将探讨如何处理用户输入中的错误,确保程序的健壮性。课程内容从逻辑运算的基础开始,逐步深入到if、else if、switch等条件控制结构,最后讲解如何利用循环和错误检查来构建一个可靠的用户输入处理流程。
逻辑运算符与关系运算符 🔍
在C语言中,逻辑运算的处理方式有些特殊,因为C语言没有内置的布尔类型。其核心规则是:零(0)代表假(false),任何非零值都代表真(true)。
逻辑运算符主要分为两类:关系运算符和逻辑连接运算符。
关系运算符
关系运算符用于比较两个值。以下是基本的关系运算符:
>:大于>=:大于或等于<:小于<=:小于或等于==:等于!=:不等于
当进行关系运算赋值时,结果为0(假)或1(真)。例如:
int x = 7, y = 19, z;
z = (x > y); // z 被赋值为 0,因为 7 > 19 为假
z = (x != y); // z 被赋值为 1,因为 7 != 19 为真
逻辑连接运算符
逻辑连接运算符用于组合多个逻辑条件。
&&:逻辑与(AND)。两个操作数都必须为非零,结果才为真。||:逻辑或(OR)。至少一个操作数为非零,结果就为真。!:逻辑非(NOT)。对操作数的真假值取反。
注意:单个&和单个|是按位运算符,与逻辑运算含义不同,切勿混淆。
运算符优先级与“短路求值”
运算符的优先级从高到低为:!(非) > &&(与) > ||(或)。关系运算符的优先级高于&&和||,但低于!。
C语言采用“短路求值”(Lazy Evaluation)。这意味着,在计算逻辑表达式时,一旦能确定整个表达式的结果,就会立即停止计算。例如,在(条件A && 条件B)中,如果条件A为假,则整个表达式必为假,条件B将不会被计算。
上一节我们介绍了逻辑运算的基础,本节中我们来看看如何利用这些逻辑表达式来控制程序流程。
条件语句:if 与 else 🚦
if语句用于根据条件决定是否执行某段代码。其基本语法如下:
if (表达式) {
// 如果表达式的结果为非零(真),则执行这里的语句
}
如果表达式的结果为0(假),则跳过花括号内的语句。如果只有一条语句,花括号可以省略。
使用 else 处理相反情况
当条件为假时,如果需要执行另一段代码,可以使用else语句。
if (表达式) {
// 条件为真时执行
} else {
// 条件为假时执行
}
处理多个条件:else if
在Python中,有elif关键字。在C语言中,我们使用else if来实现多重条件判断。这是一种比嵌套if语句更清晰、更易读的方式。
if (条件1) {
// 语句1
} else if (条件2) {
// 语句2
} else if (条件3) {
// 语句3
} else {
// 默认语句
}
这种方式避免了代码因多层缩进而向右移动,使逻辑结构一目了然。
除了if-else链,C语言还提供了switch语句来处理基于单个变量或表达式与多个常量比较的情况。
多路选择:switch 语句 🔀
switch语句提供了一种更清晰的方式来处理多个离散值的分支。其语法结构如下:
switch (表达式) {
case 常量1:
// 语句1
break;
case 常量2:
// 语句2
break;
...
default:
// 默认语句
break;
}
表达式的结果必须是一个整型值。case后面的必须是常量(如1、‘A’)。break语句至关重要,它用于退出整个switch块。如果省略break,程序会继续执行下一个case中的语句,直到遇到break或switch结束。这种“贯穿”特性有时会被特意利用。default分支是可选的,用于处理所有case都不匹配的情况。
防御性编程提示:即使在default分支或最后一个case后,也建议写上break。这可以防止未来其他开发者添加新的case时,因疏忽而引发意外的“贯穿”错误。
掌握了条件判断,我们就可以开始构建更健壮的程序。接下来,我们将学习如何处理用户输入,特别是如何检测和应对错误输入。
输入处理与错误检测 🛡️
一个友好的程序应该引导用户并提供清晰的错误提示。我们使用printf进行提示,使用scanf读取输入。
基本输入与scanf的返回值
scanf函数用于读取格式化输入。关键点:在变量前必须加上&(取地址符),因为scanf需要知道数据存储的内存地址。
int amount;
printf(“请输入金额:”);
scanf(“%d”, &amount); // 注意 & 符号
scanf会返回一个整数,表示成功读取并赋值的变量个数。例如,scanf(“%d”, &amount)成功读取一个整数时返回1。
检测文件结束(EOF)
当输入源没有更多数据时(例如用户在键盘输入时按下Ctrl+D(Linux/Mac)或Ctrl+Z(Windows)),scanf会返回一个特殊值EOF(通常定义为-1)。我们应该检查这种情况。
if (scanf(“%d”, &amount) == EOF) {
printf(“输入结束。\n”);
return 1; // 通常非零返回值表示异常退出
}
验证输入有效性
仅仅读取输入还不够,我们还需要验证输入是否符合预期(例如,金额不能为负数)。这正if语句的用武之地。
if (amount < 0) {
// 处理错误:金额不能为负数
}
向标准错误流输出错误信息
为了将正常的程序输出和错误信息分开,我们可以使用fprintf函数将错误信息输出到stderr(标准错误流)。这样即使用户将程序输出重定向到文件,错误信息仍会显示在屏幕上。
if (amount < 0) {
fprintf(stderr, “错误:请输入一个非负整数。\n”);
// 后续处理,如退出或要求重新输入
}
当用户输入错误时,我们通常不希望程序直接崩溃退出,而是应该给用户重新输入的机会。这就需要用到循环。
使用循环实现健壮的输入 🔄
由于我们无法预知用户需要多少次尝试才能输入正确,因此适合使用while循环(一种不定次循环)来实现重复提示输入,直到获得有效值为止。
以下是一个结合了错误检查、EOF处理和循环的健壮输入示例框架:
#include <stdio.h>
int main() {
int amount;
int leftover;
while (1) { // 构建一个“无限”循环,在内部条件满足时跳出
printf(“请输入找零金额(美分):”);
if (scanf(“%d”, &amount) == EOF) {
printf(“\n”); // 为了输出美观
return 0; // 遇到EOF,正常退出
}
if (amount < 0) {
fprintf(stderr, “错误:金额不能为负数,请重新输入。\n”);
continue; // 跳过本次循环剩余部分,重新开始提示输入
}
// 输入有效,跳出循环进行处理
break;
}
leftover = amount; // 初始化剩余金额
// ... 后续计算硬币数量的逻辑 ...
printf(“处理完成。\n”);
return 0;
}
在这个结构中:
while(1)创建了一个循环。- 首先检查
EOF,如果用户表示结束输入,则程序退出。 - 然后检查输入有效性(如是否为负数)。如果无效,使用
fprintf(stderr, …)打印错误信息,并用continue语句跳回循环开头,让用户再次输入。 - 只有输入有效时,才执行
break跳出循环,继续执行后面的业务逻辑。


本节课中我们一起学习了C语言中逻辑运算的真值规则、if/else if/else条件分支语句、用于多路选择的switch语句,以及如何利用scanf的返回值和while循环来构建一个能够有效处理用户输入(包括错误输入和正常结束)的健壮程序。理解并运用这些概念,是编写可靠、用户友好的命令行程序的基础。
005:循环结构 🔄


在本节课中,我们将要学习C语言中用于重复执行代码块的三种循环结构:for循环、while循环和do-while循环。理解这些循环是编写高效程序的关键。
课程概述与回顾
上一节我们介绍了条件判断语句。本节中我们来看看如何让计算机重复执行某些任务,即循环结构。
首先,有几个课程相关的通知。作业一的附加学分部分已在Canvas平台发布。作业的前两个问题评分标准已设定,第三个问题和附加学分部分预计明天完成。作业截止日期是4月24日。作业中的问题需要使用一些程序,特别是第二个问题,要求你在现有代码中插入循环。代码中会明确标注“在此插入你的for循环”等位置,因此你无需担心编写函数,只需专注于编写循环本身。


此外,今天的办公时间因故取消,将在下周安排补课,可能在周二,以便大家都有机会查看作业。
关于之前内容的修正
在继续之前,需要修正之前讲座中提到的一个概念。在第二讲的第6张幻灯片中,关于unsigned -53的表述有误。它不应该是53。这是因为计算机以二进制表示数字。在32位系统中,-53的二进制表示中,最高位是符号位。对于unsigned(无符号)类型,这个最高位不被解释为负号,而是作为数值的一部分(2^31)。因此,unsigned -53的实际值是2^32 - 53,而不是53。
如果你需要确保得到一个正数,可以使用数学库中的abs函数(取绝对值),或者手动检查并取负。无符号类型在处理永远不会是负数的数据时很方便,在系统编程中更为常见,例如指定硬盘位置时就没有负值区域的概念。不过在本课程中,我们大部分时间将使用有符号类型。
另一个需要说明的是上节课演示的scanf.c程序。该程序旨在演示scanf函数如何读取两种输入模式:一个整数后跟一个浮点数,或者一个整数后跟“Xxx”。程序在读取“3 Xxx”(3和Xxx之间有空格)时,并未按预期返回1并存储整数,而是返回了0。这是因为scanf将输入视为字符流。当它读取了整数3后,遇到空格,这与格式字符串中的“Xxx”不匹配,因此停止读取,只返回了匹配成功的整数个数(1)。当程序流再次执行scanf时,输入缓冲区中只剩下“Xxx”,无法匹配整数格式,因此返回0。这个例子说明了scanf的使用需要格外小心,后续我们会介绍更好的处理方法。
循环结构介绍
条件语句(如if和switch)只应用条件一次。但有时我们需要重复执行某段代码,例如将数字从1加到10,这就需要循环。C语言提供了三种循环结构。
for循环
for循环是一种确定性循环,通常在你明确知道循环次数或范围时使用。
for循环包含三个部分:
- 初始化:在循环开始前执行一次。
- 条件测试:在每次循环迭代前检查。如果为真,则执行循环体;如果为假,则退出循环。
- 增量/更新:在每次循环体执行完毕后执行。
其基本语法如下:
for (初始化; 条件; 增量) {
// 循环体
}
以下是几个例子:
for (i = 1; i < 10; i++):i从1开始,每次循环后加1,直到i不小于10时停止。for ( ; j < 10; j = j + 3):省略了初始化(假设j已提前定义),每次循环j增加3。for ( ; x < 10; ):省略了初始化和增量,这通常不是for循环的最佳用法,while循环更合适。for ( ; ; ):省略了所有三个部分,这将创建一个无限循环,因为条件默认为真。
i++是C语言中常见的自增运算符,等同于i = i + 1。j = j + 3也是常见的更新方式。
while循环
while循环是一种条件不确定的循环,适用于你不知道具体循环次数,但知道循环终止条件的情况。
while循环在每次迭代之前检查条件。如果条件为真,则执行循环体;如果为假,则跳过整个循环。执行完循环体后,程序流返回while语句再次检查条件。
其基本语法如下:
while (条件) {
// 循环体
}
例子:
while (i < 10):当i小于10时,持续循环。while (j != 13):当j不等于13时,持续循环。while (1):条件永远为真,这是一个无限循环。如果循环体为空(只有一个分号),则是一个空循环,有时用于实现特定延时。
do-while循环
do-while循环与while循环类似,但关键区别在于其条件测试在循环体之后进行。
这意味着do-while循环的循环体至少会执行一次。执行完循环体后,检查条件。如果条件为真,则返回do处再次执行循环体;如果为假,则退出循环。
其基本语法如下:
do {
// 循环体
} while (条件);
通常,即使循环体只有一条语句,也建议使用花括号{}。
循环控制语句
在循环中,有两个重要的控制语句:break和continue(本节课主要演示break)。
break语句用于立即跳出当前所在的最内层循环(对于for、while、do-while都有效),并继续执行循环之后的代码。
以下是一个使用break在无限循环中控制退出的例子:
int i = 0;
for ( ; ; ) { // 无限循环
i++;
if (i >= 5) {
break; // 当 i 大于等于 5 时跳出循环
}
printf(“for the %d time\n”, i);
}
printf(“Bye\n”);
这段代码会打印4次信息(i从1到4),当i变为5时,break执行,跳出循环并打印“Bye”。如果去掉break,循环将无限进行,在终端中可以使用Ctrl+C来强制终止程序。
关于格式的注意事项:C语言编译器不关心代码的缩进。缩进只是为了人类阅读方便。因此,在编写if、else或循环时,务必使用花括号{}来明确界定代码块的范围,避免逻辑错误。例如,else总是与它前面最近的if配对。
循环结构的选择与实践
那么,如何选择使用哪种循环呢?这里有一个一般性的指导原则:
- 当你明确知道循环需要执行的次数,或者有一个清晰的计数器时,使用
for循环。 - 当你不确定循环次数,循环的终止依赖于循环体内发生的事件(例如用户输入、文件结束标志、某个条件被满足)时,使用
while循环。 - 当你需要确保循环体至少执行一次,然后再根据条件决定是否继续时,使用
do-while循环。
让我们通过一个读取用户输入的例子来实践while循环的用法:
#include <stdio.h>
int main(void) {
int c;
printf(“Type an ‘A’ to quit:\n”);
while ((c = getchar()) != ‘A’) {
// 循环体,这里可以处理输入的字符
// 注意:getchar()也会读取用户按下的回车键‘\n’
}
printf(“Goodbye!\n”);
return 0;
}
这个程序会持续等待用户输入,直到用户输入字符‘A’并按回车。这是一个典型的while循环应用,因为程序无法预知用户何时会输入‘A’。getchar()函数返回一个int类型,这是为了能够表示文件结束符EOF(通常是-1),这个值超出了字符0-127的范围。
总结
本节课中我们一起学习了C语言的三种循环结构:
for循环:适用于循环次数确定的场景,结构清晰,包含初始化、条件和更新。while循环:适用于循环次数不确定,但终止条件明确的场景,条件在循环前检查。do-while循环:适用于需要至少执行一次循环体的场景,条件在循环后检查。


我们还学习了break语句,用于在满足特定条件时提前跳出循环。记住,在C语言中,代码块必须用花括号{}明确界定,缩进仅为了可读性。合理选择循环结构,并正确使用控制语句,是编写清晰、高效C程序的基础。
006:函数 🧩



在本节课中,我们将学习C语言中一个核心概念:函数。函数是封装代码块、提高代码复用性和可读性的重要工具。我们将从函数的基本语法开始,逐步深入到参数传递、作用域以及调试技巧。
函数概述与定义
上一节我们讨论了程序的基本结构,本节中我们来看看如何将代码组织成独立的模块——函数。
函数是一段执行特定任务的代码块,可以被多次调用。定义函数时,需要指定其返回类型、名称和参数列表。
一个函数定义的通用格式如下:
返回类型 函数名(参数类型1 参数名1, 参数类型2 参数名2, ...) {
// 函数体:执行操作的代码
return 返回值; // 如果返回类型不是void
}
例如,一个将输入值加17的函数可以这样定义:
int addSeventeen(int par) {
int result;
result = par + 17;
return result;
}
调用这个函数时,可以这样写:
int some1 = addSeventeen(53);
int some2 = addSeventeen(-12);
函数原型与参数传递
理解了基本定义后,我们来看看如何提前声明函数,以及参数是如何传递给函数的。
如果函数定义出现在其调用之后,编译器可能无法正确检查参数类型和数量。为了解决这个问题,C语言引入了函数原型(或称前向声明)。函数原型是函数定义的第一行,但用分号代替了花括号。
函数原型示例:
int funky(int par1, float par2, char par3); // 函数原型
在函数调用中,参数是通过传值的方式传递的。这意味着函数内部接收到的是外部变量值的副本。因此,在函数内部修改参数的值,不会影响函数外部原始变量的值。
以下是调用funky函数的示例:
int x = 7;
float fx = 3.14;
int result = funky(x, fx, 'a');
在这个例子中,变量x和fx的值被复制给了函数funky的参数par1和par2。
变量作用域
上一节我们介绍了参数传递是“传值”的,本节中我们来探讨变量的可见性范围,即作用域。
作用域决定了程序中哪个部分可以访问某个变量。基本原则是:在代码块(由花括号{}界定)内部定义的变量,其作用域仅限于该块内部。如果内部块和外部块有同名变量,则内部块的变量会“遮蔽”外部块的变量。
考虑以下示例程序:
#include <stdio.h>
int variable = 1; // 全局变量
void g(int variable) {
printf("Inside g: %d\n", variable);
}
void h(void) {
printf("Inside h: %d\n", variable);
}
int main(void) {
int variable = 2; // 遮蔽了全局变量
printf("In main (outer): %d\n", variable);
{
int variable = 3; // 遮蔽了main中的variable
printf("In main (inner block): %d\n", variable);
}
printf("In main (after block): %d\n", variable);
g(variable); // 传递的是main中的variable,值为2
h(); // 内部无定义,使用全局变量,值为1
return 0;
}
程序输出将清晰地展示不同作用域下同名变量的值。
使用GDB进行调试
编写程序时难免会遇到错误。本节我们将学习使用GNU调试器(GDB)来查找和诊断程序中的问题。
GDB允许你逐行执行程序、设置断点、检查变量值,是强大的调试工具。以下是一些基本命令:
gcc -g program.c -o program:编译时加入调试信息。gdb ./program:启动GDB并加载程序。break line_number或b line_number:在指定行设置断点。run:开始运行程序。next或n:执行下一行代码(不进入函数内部)。step或s:执行下一行代码(会进入函数内部)。print variable_name或p variable_name:打印变量的值。continue或c:继续运行直到下一个断点或程序结束。list或l:列出源代码。
例如,要调试一个scanf读取输入有误的程序,可以在GDB中运行程序,在关键的scanf调用前后设置断点,然后使用print命令检查scanf的返回值和相关变量的值,从而定位逻辑错误。
总结


本节课中我们一起学习了C语言的函数。我们了解了如何定义和调用函数,理解了“传值”参数传递机制意味着函数内部操作的是参数的副本。我们还探讨了变量的作用域规则,知道了局部变量如何遮蔽全局变量。最后,我们介绍了使用GDB进行程序调试的基本方法,这是定位和修复代码错误的重要技能。掌握这些概念是构建模块化、可维护C程序的基础。
007:指针与间接引用

在本节课中,我们将学习C语言中一个核心且强大的概念——指针。指针允许我们直接操作内存地址,从而突破函数无法直接修改外部变量值的限制。理解指针是掌握C语言底层编程的关键一步。
课程概述与准备
上一节我们讨论了函数参数传递的局限性。本节中,我们来看看如何使用指针来解决这个问题。
在深入之前,请注意一些编程实践细节。以下是关于代码编写和环境配置的几个要点:
- 在代码块或函数顶部定义变量,例如
int x;,然后赋值x = 0;。在语句中直接声明并初始化(如int x = 0;)在某些C标准中可能不被支持,编译器可能会报错。 - 单行注释应使用
/* ... */格式。//双斜杠注释在我们当前使用的C版本中可能不被识别。 - 将文件传输到CSIF服务器时,请先确保VPN连接激活,然后使用
scp命令。例如:scp 文件名 pc12.cs.ucdavis.edu:可将文件上传至你的CSIF主目录。 - 从CSIF服务器下载文件到本地,可使用命令:
scp pc12.cs.ucdavis.edu:文件名 .。 - 关于程序兼容性,不应假设在一种Linux系统上运行成功的程序必然能在GradeScope(运行Ubuntu 22.04)上正常工作。最佳实践是在目标环境或类似环境中进行测试。
指针的基本概念
现在,让我们进入正题。你是否记得,我们之前提到函数无法以让外部调用者感知的方式改变其参数的值?这确实限制了函数的功能。然而,在C语言中,有一种被广泛使用的方法可以绕过这个限制,它依赖于一种我们尚未讨论的结构——指针。
指针本质上也是一种变量,就像整数或浮点数一样。但关键区别在于,指针变量存储的不是普通数据,而是其他数据在内存中的地址。
指针的声明与操作
理解指针需要掌握两个核心运算符:取地址运算符 & 和间接引用(解引用)运算符 *。
&运算符用于获取一个变量的内存地址。*运算符用于访问指针所指向地址处存储的值。
让我们通过一个例子来理解:
int x = 0; // 声明一个整型变量x,并赋值为0
int *px; // 声明一个指向整型的指针变量px
px = &x; // 将变量x的地址赋值给指针px
如何阅读声明 int *px; 呢?一个简单的方法是从右向左读:px 是一个变量,* 表示它是一个指针,int 表示这个指针指向一个整数类型。所以,px 是一个指向整数的指针。
指针的使用示例
通过指针,我们可以间接地操作它所指向的变量。
printf("x is %d\n", x); // 直接打印变量x的值,输出:x is 0
printf("*px is %d\n", *px); // 通过指针px打印它指向的值,输出:*px is 0
*px = 5; // 通过指针px修改它指向的内存位置的值
printf("Now x is %d\n", x); // 再次打印x,输出:Now x is 5
在这个例子中,*px = 5; 这条语句意味着“前往 px 所存储的地址,并将该地址处的值设置为5”。由于 px 存储的是 x 的地址,因此这实际上修改了变量 x 的值。这就实现了在函数内部修改外部变量的效果。
总结


本节课我们一起学习了C语言中指针的基础知识。我们了解到指针是一种存储内存地址的特殊变量,通过取地址运算符 & 可以获取变量的地址,通过间接引用运算符 * 可以访问或修改指针所指向的值。掌握指针是进行C语言底层编程、动态内存管理以及构建复杂数据结构的基石。在接下来的课程中,我们将继续探索指针的更多应用,例如在函数参数传递中的使用。
008:数组与字符串

在本节课中,我们将要学习C语言中数组和字符串的核心概念。数组是存储相同类型数据的集合,而字符串本质上是以空字符结尾的字符数组。理解它们对于进行底层编程至关重要。
数组基础
上一节我们介绍了指针,本节中我们来看看数组。数组是存储相同类型数据的连续内存空间。
在C语言中,数组的声明和初始化方式如下:
int iarr[5] = {1, 2, 3, 4, 5};
或者,你可以让编译器自动计算数组大小:
int iarr[] = {1, 2, 3, 4, 5}; // 编译器会分配5个整数的空间
数组名本身是一个指向数组首元素的指针常量。这意味着 iarr[0] 和 *iarr 访问的是同一个元素。当使用下标访问时,如 iarr[12],编译器会自动进行地址计算:iarr 的地址 + 12 * sizeof(int)。
指针初始化
指针变量必须初始化为一个有效的地址。以下是初始化指针的方法:
int ivar = 10;
int *pvar = &ivar; // pvar 指向 ivar
一个特殊的指针值是 NULL(通常定义为0),它表示指针不指向任何有效地址。尝试解引用一个 NULL 指针会导致程序崩溃,但这有助于快速发现错误。
字符串入门
字符串是C语言编程的核心。一个字符串就是一个以空字符(\0)结尾的字符数组。
以下是定义字符串的两种方式:
char ca[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
char *cstr = "Hello"; // 编译器会自动添加 '\0'
需要注意的是,像 char *cstr = "Hello"; 这样的字符串字面量通常存储在只读内存区域,不能修改。如果需要修改字符串,必须将其复制到可写的字符数组中。
字符串操作与安全
以下是处理字符串时需要特别注意的操作。
字符串复制
不要使用赋值运算符来“复制”字符串,因为这只会复制指针(地址),而不是字符串内容本身。
char *cstr = "Hello";
char *cdup_str = cstr; // 错误!这只是让两个指针指向同一个地址。
要复制字符串内容,应使用 strcpy 函数。但更安全的是使用 strncpy,它可以指定最大复制字符数,防止缓冲区溢出。
char dest[100];
strncpy(dest, cstr, sizeof(dest) - 1); // 安全复制
dest[sizeof(dest) - 1] = '\0'; // 确保以空字符结尾
安全的输入读取
使用 scanf 读取字符串有时会很麻烦。更安全、更常用的方法是 fgets。
char buff[100];
fgets(buff, sizeof(buff), stdin); // 从标准输入读取,最多99个字符+'\0'
绝对不要使用 gets 函数,因为它不检查缓冲区边界,极易导致“缓冲区溢出”或“栈粉碎”攻击,这是严重的安全漏洞。fgets 会限制读取的字符数,因此安全得多。
指针与多维数组
理解指针对于处理复杂的数组结构(如字符串数组)非常重要。
考虑以下声明:
char *c[] = {"enter", "new", "point", "first"};
char **cp[] = {c+3, c+2, c+1, c};
char ***cpp = cp;
这里:
c是一个指针数组,每个元素指向一个字符串。cp是一个指针的指针数组,每个元素指向c中的某个元素。cpp是一个指向cp的指针。
通过操作符(* 解引用,++/-- 增减)来遍历这些结构,可以访问到不同的字符串或字符。例如,*(*cpp) 会先解引用 cpp 得到 cp 中的一个元素,再解引用该元素得到 c 中的一个字符串指针。手动画图跟踪指针的指向变化是理解这类代码的最佳方式。


本节课中我们一起学习了C语言中数组和字符串的基础知识。我们了解了如何声明和初始化数组与字符串,掌握了使用 strncpy 和 fgets 进行安全字符串操作的重要性,并初步探讨了指针与多维数组之间的关系。理解这些概念是进行有效且安全的C语言编程的基石。
009:字符串处理进阶 🧵
在本节课中,我们将深入学习C语言中字符串处理的进阶技巧,包括字符串的表示、通过指针操作字符串、以及一些常见的字符串处理习惯用法。我们还将探讨如何通过命令行参数向程序传递字符串。
概述
在上一节中,我们介绍了指针的基本概念。本节中,我们将看看如何将指针应用于字符串处理。C语言中没有内置的字符串类型,字符串通常通过字符指针或字符数组来表示。理解如何高效地操作这些结构是编写健壮C程序的关键。
命令行参数
在C语言中,main函数可以接收来自命令行的参数。这是通过两个标准参数实现的:argc和argv。
以下是main函数的标准形式:
int main(int argc, char *argv[])
argc(参数计数)是一个整数,表示命令行参数的数量,包括程序名本身。argv(参数向量)是一个指向字符指针数组的指针。每个指针指向一个以空字符(\0)结尾的字符串,代表一个命令行参数。
例如,执行命令 ./loopy 5 9 时:
argc的值为 3。argv[0]指向字符串"./loopy"。argv[1]指向字符串"5"。argv[2]指向字符串"9"。argv[3]是一个空指针(NULL),用于标记参数列表的结束。
main函数就像一个普通函数一样被调用,其参数由系统启动代码设置。你可以为这些参数使用任何你喜欢的名称。
字符串作为函数参数
当我们将字符串传递给函数时,实际上传递的是指向该字符串第一个字符的指针。
以下是函数原型和定义的示例:
// 函数原型(前向声明)
void strfunc(char *, char *);
// 函数定义
void strfunc(char *first, char *second) {
// 函数体
}
在调用时,例如 strfunc(x, y),编译器会检查 x 和 y 是否为字符指针。函数内部通过指针来访问和操作原始字符串。
字符串操作习惯用法
由于字符串在C中被广泛使用,因此形成了一些常见的操作习惯用法。
遍历字符串
以下是一个使用指针遍历并打印字符串的经典例子:
char *cp = "Hello";
while (*cp != '\0') {
printf("%c", *cp);
cp++;
}
printf("\n");
工作原理:
cp初始指向字符串"Hello"的首地址。- 循环条件
*cp != '\0'检查cp当前指向的字符是否为空字符(字符串结束标志)。 - 如果不是空字符,则打印该字符。
cp++将指针移动到下一个字符的地址。- 重复此过程,直到遇到空字符,循环结束。
后缀递增运算符 cp++ 表示“先使用 cp 的当前值,然后再将其加1”。这与前缀递增 ++cp 不同,后者是“先将 cp 加1,然后使用新值”。
复制字符串
让我们看看几种复制字符串的方法,从最清晰的方法到更简洁(有时更晦涩)的方法。
方法一:使用数组索引
这是最直观的方法。
void strcopy1(char new[], char old[]) {
int i = 0;
while (old[i] != '\0') {
new[i] = old[i];
i++;
}
new[i] = '\0'; // 确保新字符串以空字符结尾
}
方法二:简化条件判断
因为空字符的值为0,而C语言中0代表假,非0代表真,所以可以简化循环条件。
void strcopy2(char new[], char old[]) {
int i = 0;
while (old[i]) { // 等价于 while (old[i] != '\0')
new[i] = old[i];
i++;
}
new[i] = '\0';
}
方法三:利用赋值表达式的值
在C语言中,赋值表达式本身也有一个值,即被赋的值。我们可以利用这一点将赋值和条件检查合并。
void strcopy3(char new[], char old[]) {
int i = 0;
while ((new[i] = old[i]) != '\0') {
i++;
}
}
甚至可以进一步简化,因为当 old[i] 是空字符时,赋值表达式 new[i] = old[i] 的值就是0(假)。
void strcopy3_compact(char new[], char old[]) {
int i = 0;
while (new[i] = old[i]) {
i++;
}
}
方法四:使用指针
直接操作指针通常更高效,代码也更紧凑。
void strcopy4(char *new, char *old) {
while ((*new = *old) != '\0') {
new++;
old++;
}
}
同样可以简化为:
void strcopy4_compact(char *new, char *old) {
while (*new++ = *old++) {
; // 空循环体
}
}
注意:循环结束后,new 和 old 指针都指向了各自字符串末尾的空字符之后的位置。此时不应再使用它们进行写入操作,否则可能导致程序崩溃或数据损坏。
重要注意事项
- 字符串字面量 vs. 字符数组:像
char *p = "Hello";这样的字符串字面量通常存储在只读内存区。而像char s[] = "Hello";这样的字符数组则存储在可读写内存区。试图修改字符串字面量的内容会导致未定义行为。 - 初始化指针:声明一个字符指针(如
char *cp;)后,必须将其初始化为指向有效的内存地址(例如一个数组或动态分配的内存),然后才能使用它来操作字符串。 - 使用标准库函数:在实际编程中,应优先使用标准库函数(如
strcpy,strncpy)来复制字符串。这些函数通常经过高度优化,并且使代码意图更清晰。
总结


本节课中我们一起学习了C语言字符串处理的进阶知识。我们了解了如何通过 argc 和 argv 处理命令行参数,掌握了字符串作为函数参数传递的本质(传递指针)。我们重点探讨了遍历和复制字符串的多种习惯用法,从清晰的数组索引法到高效的指针操作法。记住,在操作字符串时,始终要留意空字符的终止作用,并谨慎处理指针,避免越界访问。对于常见的字符串操作,积极利用标准库函数是编写安全、高效代码的最佳实践。
010:运算符详解 🧮

在本节课中,我们将深入学习C语言中的运算符,特别是自增(++)和自减(--)运算符的用法、函数参数求值顺序,以及字符处理的实际应用。课程最后会简要介绍递归的概念。
课程公告与作业答疑 📢
首先,有几项课程公告。期中考试的样题和学习指南已准备就绪,将在近期上传至Canvas。关于作业,特别是问题二和附加题,许多同学遇到了类似错误。错误信息通常表明程序仍在运行但被终止,这往往是由于程序中存在无限循环导致的。例如,在使用负增量时,如果循环条件设置不当,就可能进入无限循环。
自增与自减运算符 🔄
上一节我们介绍了基本运算符,本节中我们来看看自增(++)和自减(--)运算符。它们用于对变量进行加一或减一操作。
- 基本功能:
++用于加一,--用于减一。 - 指针运算:当操作对象是指针时,
++意味着移动到下一个元素,--意味着移动到上一个元素。 - 前缀与后缀:运算符的位置决定了操作的顺序。
- 前缀形式(如
++a):先对变量进行自增/自减,然后使用新值。 - 后缀形式(如
a++):先使用变量的当前值,然后再进行自增/自减。
- 前缀形式(如
以下是具体示例:
int a = 5;
int x;
x = ++a; // a先加1变为6,然后赋值给x,所以 x=6, a=6
x = a++; // 先将a的值5赋给x,然后a加1变为6,所以 x=5, a=6
函数参数的求值顺序 ⚠️
关于函数调用,有一个重要但容易被忽视的细节:函数参数的求值顺序在C语言标准中并未定义。这意味着,编译器可以自由选择从左到右或从右到左的顺序对参数表达式进行求值。
考虑以下代码:
int x = 5;
printf("%d %d\n", x++, x++);
这段代码的输出是不确定的,可能是“5 6”,也可能是“6 5”,这取决于编译器采用的求值顺序。编写依赖于特定求值顺序的代码是糟糕的风格,因为它会损害程序的可移植性。安全的做法是避免在同一个函数调用中,对同一个变量使用多个带有副作用的运算符。
字符计数程序示例 📝
为了巩固概念,我们来看一个实际应用:统计输入行中可打印字符和空白字符(如空格、制表符)的数量。
以下是程序的核心逻辑步骤:
- 使用
fgets从标准输入读取一行文本。 - 遍历该行中的每个字符。
- 使用
isprint()函数判断是否为可打印字符,若是则计数。 - 否则,判断是否为空白字符(如空格),若是则计数。
在遍历字符数组时,我们常使用指针。p++ 或 ++p 在这里的效果是相同的,因为我们的目的仅仅是移动指针,而不使用其表达式本身的值。
字符与整数的转换及凯撒密码 🔐
C语言中,字符本质上是小整数。我们可以利用这个特性进行字符和数字之间的转换。
- 数字字符转整数:
int digit = ch - '0'; - 整数转数字字符:
char ch = digit + '0'; - 字母索引计算:
int index = ch - 'a';(小写字母)或ch - 'A';(大写字母)
基于此,我们可以实现一个简单的凯撒密码(移位密码)。其核心思想是将字母在字母表中移动固定的位置(如3位)进行加密,解密时反向移动。
加密过程的关键步骤是处理移位后超出字母表范围的情况,这可以通过取模运算(% 26)来实现循环移位。例如,加密‘z’(索引25)移动3位:(25 + 3) % 26 = 2,对应字母‘c’。
文件结尾与递归概念简介 🚪➡️🧠
最后,我们区分两个重要的常量:
EOF:用于表示字符输入结束,通常定义为-1。这也是为什么getchar()返回int而非char类型,以便能容纳-1这个非字符值。NULL:空指针常量,用于表示指针不指向任何有效对象。
本节课的最后,我们预告了下一个重要主题:递归。递归是指函数直接或间接调用自身的一种编程技巧。它与数学归纳法思想类似,需要定义基准情形(何时停止)和递归步骤(如何向基准情形推进)。我们将在下节课深入探讨。


本节课总结:我们一起深入探讨了C语言中自增/自减运算符的前后缀区别、函数参数求值顺序的不确定性、如何编写字符统计程序、利用字符的整数特性进行转换和实现凯撒密码,并区分了EOF和NULL,最后引出了递归的概念。理解这些细节对于编写正确、可移植的C程序至关重要。
011:递归
在本节课中,我们将要学习一种非常强大且广泛使用的编程技术——递归。递归的核心思想是将一个复杂问题表达为更简单或更小版本的自身,这常常能极大地简化编程工作并减少代码量。
课程概述与公告
在开始之前,有几个公告需要说明。作业二已发布在网站上,和之前一样,请将你的程序上传到GradeScope。助教Chris的答疑时间从周五的11-12点调整到了12-1点,地点不变。此外,计算机科学辅导俱乐部已开放,可以为包括ECS 36A在内的多门课程提供帮助,详细信息请查看公告。

关于作业,有一个问题要求计算幂次,你需要使用名为pow的库函数。为此,你需要在程序中包含头文件math.h,并且在编译命令的末尾加上-lm来链接数学库。
什么是递归?🧠
递归是一种编程技术,其核心在于一个函数直接或间接地调用自身。它通常用于解决可以分解为相似但规模更小的子问题的问题。
递归函数通常包含两个关键部分:
- 基准情形:这是递归停止的条件。它不包含任何递归调用,直接返回一个结果。
- 递归情形:这是函数调用自身的部分。每一次递归调用都必须使问题规模更接近基准情形,否则递归将无限进行下去,导致栈内存溢出。
递归示例:阶乘函数
阶乘是一个经典的递归例子。n的阶乘(记作 n!)定义为所有小于等于n的正整数的乘积,并且 0! = 1。
迭代实现
首先,我们看看熟悉的迭代(循环)实现方式:
int factorial_iterative(int n) {
if (n == 0) return 1;
int product = 1;
for (int i = 1; i <= n; i++) {
product *= i;
}
return product;
}
递归实现
现在,我们来看看如何用递归定义阶乘。其数学关系(递推关系)是:
n! = n * (n-1)!,且 0! = 1。
根据这个关系,我们可以写出递归函数:
int factorial_recursive(int n) {
// 基准情形
if (n == 0) {
return 1;
}
// 递归情形:问题规模从 n 减小为 n-1
return n * factorial_recursive(n - 1);
}
在这个函数中,if (n == 0) return 1; 是基准情形。当 n 不为0时,函数通过调用 factorial_recursive(n - 1) 来计算 (n-1)! 的值,然后将结果与 n 相乘并返回。每次递归调用,n 的值都减小1,最终会达到基准情形 n == 0。
理解递归调用栈 🥞
为了深入理解递归的执行过程,我们需要了解程序调用栈。每当一个函数被调用时,系统都会在栈上为其分配一块内存空间(称为栈帧),用于存储局部变量、参数和返回地址等信息。


让我们以计算 factorial_recursive(4) 为例,一步步跟踪栈的变化:
main函数调用factorial_recursive(4)。栈上压入一个帧,其中n = 4。- 由于
n != 0,函数执行return 4 * factorial_recursive(3)。为了计算这个表达式,它必须先调用factorial_recursive(3)。 - 栈上压入新的帧,
n = 3。它需要计算return 3 * factorial_recursive(2),于是调用factorial_recursive(2)。 - 栈上压入新的帧,
n = 2。它调用factorial_recursive(1)。 - 栈上压入新的帧,
n = 1。它调用factorial_recursive(0)。 - 栈上压入新的帧,
n = 0。此时触发基准情形,函数直接返回1。 n = 0的帧从栈中弹出(销毁)。返回值1传递给n = 1的帧,该帧计算return 1 * 1,得到结果1。n = 1的帧弹出,返回值1传递给n = 2的帧,计算return 2 * 1,得到2。n = 2的帧弹出,返回值2传递给n = 3的帧,计算return 3 * 2,得到6。n = 3的帧弹出,返回值6传递给n = 4的帧,计算return 4 * 6,得到最终结果24,并返回给main函数。
这个过程清晰地展示了递归如何通过栈来管理多次函数调用,以及每次调用如何拥有自己独立的变量空间。
递归示例:判断回文串
上一节我们介绍了阶乘这个数学概念的递归实现,本节中我们来看看一个更贴近文本处理的例子——判断一个字符串是否是回文。回文是指正读和反读都一样的字符串,例如 “madam”。
迭代方法可能需要复制或反转字符串进行比较。递归方法则更加优雅:比较字符串的首尾字符,如果相同,则递归判断去掉首尾字符后的子串。
以下是递归实现的思路和代码:
- 基准情形:
- 如果字符串长度为0(空串)或1(单个字符),那么它自然是回文。
- 递归情形:
- 比较字符串的第一个字符(
s[0])和最后一个字符(s[strlen(s)-1])。 - 如果它们不相等,则不是回文,立即返回
0(假)。 - 如果相等,则问题转化为判断去掉首尾字符后的中间子串是否是回文。我们通过递归调用自身来实现。
- 比较字符串的第一个字符(
#include <string.h>
int is_palindrome(char *s) {
int len = strlen(s);
// 基准情形:空串或单字符是回文
if (len == 0 || len == 1) {
return 1;
}
// 检查首尾字符
if (s[0] != s[len - 1]) {
return 0; // 首尾不同,不是回文
}
// 递归情形:去掉首尾字符,检查剩余子串
// 将末尾字符临时替换为字符串结束符 ‘\0‘,以缩短字符串
char temp = s[len - 1];
s[len - 1] = ‘\0‘;
// 递归调用,传入字符串指针指向下一个字符(即去掉首字符)
int result = is_palindrome(s + 1);
// 恢复原字符串的末尾字符(可选,取决于是否需要保持原字符串不变)
s[len - 1] = temp;
return result;
}
一个重要注意事项:上面的代码尝试修改输入字符串 s(将末尾字符置为 ‘\0‘)。如果 s 是一个字符串字面量(例如 is_palindrome(“madam”)),它通常存储在只读内存区,修改会导致程序崩溃。安全的做法是先将字符串复制到可写的数组(如 char buffer[])中,再对 buffer 进行操作。
递归的注意事项与总结
本节课中我们一起学习了递归编程的核心概念。我们来总结一下要点:
- 核心思想:将问题分解为规模更小的同类子问题。
- 两个必备部分:基准情形用于终止递归,递归情形用于向基准情形推进。
- 执行机制:依赖程序调用栈来管理每次函数调用的上下文。每次递归调用都会创建新的栈帧,拥有独立的局部变量。
- 关键要求:递归调用必须使问题规模不断减小,确保最终能到达基准情形,否则会导致无限递归和栈溢出。
- 应用:递归非常适合解决具有自相似结构的问题,如树/图的遍历、分治算法(如快速排序、归并排序)、以及某些数学计算和字符串处理。


递归是一种强大的思维和编程工具。初学时,通过画调用栈图来跟踪执行流程会非常有帮助。随着练习的深入,你会更加熟练地识别哪些问题适合用递归优雅地解决。
012:字符串数组与逗号运算符 📚

在本节课中,我们将学习如何处理命令行参数,了解逗号运算符的用法,并深入探讨字符串操作函数。最后,我们将通过一个递归求最大公约数的例子来巩固递归概念。
命令行参数处理 🖥️
上一节我们介绍了函数的基本概念,本节中我们来看看如何让程序接收来自命令行的输入。在C语言中,main函数可以接收两个参数:argc(参数数量)和argv(参数向量,即字符串数组)。
argv是一个指向字符串数组的指针。其中,argv[0]是程序名本身,argv[1]是第一个真正的参数,依此类推。
以下是一个简单的“echo”程序示例,它打印出除程序名外的所有参数:
#include <stdio.h>
int main(int argc, char *argv[]) {
for (int i = 1; i < argc; i++) {
printf("%s ", argv[i]);
}
printf("\n");
return 0;
}
命令行选项
有时,参数是用于控制程序行为的“选项”,通常以短横线(-)或双短横线(--)开头。程序需要自己解析这些选项。
以下是处理 -n 选项(抑制末尾换行符)的 echo 程序变体:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
int print_newline = 1; // 默认打印换行符
int start_index = 1; // 默认从第一个参数开始打印
// 检查第一个参数是否是选项“-n”
if (argc > 1 && strcmp(argv[1], "-n") == 0) {
print_newline = 0; // 不打印换行符
start_index = 2; // 跳过选项参数
}
for (int i = start_index; i < argc; i++) {
printf("%s ", argv[i]);
}
if (print_newline) {
printf("\n");
}
return 0;
}
程序通过设置标志变量(如 print_newline)来记录选项状态,并根据此状态改变行为。


逗号运算符 , ➕
逗号运算符允许我们在一个表达式内顺序执行多个子表达式。其求值规则是:从左到右依次求值每个子表达式,整个逗号表达式的值是最后一个子表达式的值。
其语法形式为:
表达式1, 表达式2
注意:逗号运算符的优先级是所有运算符中最低的。为了避免与函数参数分隔符混淆,通常用括号将整个逗号表达式括起来。
示例与用途
以下是一个演示逗号运算符的示例:
int a = 5, b = 2, x;
x = (a = a + 5, b++);
// 执行后:a = 10, b = 3, x = 2
解释:
- 先求值
a = a + 5,a变为 10。 - 再求值
b++,该表达式的值为b的原始值 2,然后b自增为 3。 - 整个逗号表达式的值是第二个子表达式的值,即 2,然后赋值给
x。
逗号运算符的一个常见用途是在 for 循环的“增量”部分同时更新多个变量:
for (i = 0, j = 10; i < j; i++, j--) {
// 循环体
}
另一个实用场景是将提示输入和读取输入组合在循环条件中,使代码更紧凑:
while (printf("Enter a number: "), scanf("%d", &x) != EOF) {
// 处理 x
}
printf 会先执行并打印提示,然后 scanf 执行并读取输入。循环条件取决于 scanf 的返回值。
字符串操作函数 📝
C标准库 <string.h> 提供了一系列用于处理字符串的函数。以下是几个核心函数:
以下是常用字符串函数的简要说明:
strlen:计算字符串长度(不包括结尾的空字符\0)。strcpy:将源字符串复制到目标字符数组。strncpy:安全版本的strcpy,可指定最大复制字符数,防止目标数组溢出。strcat:将源字符串追加到目标字符串末尾。strncat:安全版本的strcat,可指定最大追加字符数。strcmp:比较两个字符串。返回值为:0:字符串相等。正数:第一个字符串大于第二个(按字典序)。负数:第一个字符串小于第二个。
strncmp:比较两个字符串的前n个字符。
重要提示:使用 strcpy 和 strcat 时必须确保目标数组有足够空间,否则会导致缓冲区溢出。strncpy 和 strncat 更安全,但需注意它们可能不会自动添加字符串终止符 \0,需要手动处理。
递归示例:最大公约数 (GCD) 🔄
递归是函数调用自身的一种技术。我们之前介绍了递归的基本原理,现在来看一个经典例子:使用欧几里得算法计算两个整数的最大公约数 (GCD)。
欧几里得算法基于一个原理:gcd(a, b) = gcd(b, a % b),直到 b 为 0 时,gcd(a, 0) = a。
递归实现
递归实现非常简洁,直接反映了算法定义:
int gcd(int m, int n) {
if (n == 0) {
return m; // 基本情况
} else {
return gcd(n, m % n); // 递归步骤
}
}
迭代实现
同一算法也可以用循环(迭代)来实现:
int gcd_iterative(int m, int n) {
int remainder;
while (n != 0) {
remainder = m % n;
m = n;
n = remainder;
}
return m;
}
递归调用过程分析
为了理解递归如何工作,我们可以在函数中添加打印语句来跟踪调用栈的深度和参数变化。
以计算 gcd(8, 12) 为例,递归调用过程如下:
gcd(8, 12)调用gcd(12, 8 % 12)即gcd(12, 8)gcd(12, 8)调用gcd(8, 12 % 8)即gcd(8, 4)gcd(8, 4)调用gcd(4, 8 % 4)即gcd(4, 0)gcd(4, 0)满足基本情况n == 0,返回4- 返回值沿调用链向上传递,最终
gcd(8, 12)也返回4
每次递归调用都会在内存栈上创建一个新的帧。当达到基本情况开始返回时,栈帧被依次弹出。递归的优雅之处在于它用简洁的代码清晰地表达了问题的递归结构。


本节课中我们一起学习了如何处理命令行参数和选项,掌握了逗号运算符的求值规则和实用场景,回顾了关键的字符串操作函数及其安全注意事项,并通过最大公约数的例子深入理解了递归的工作原理和调用过程。掌握这些概念对于编写健壮、灵活的C程序至关重要。
013:动态内存管理入门

在本节课中,我们将学习递归编程的两个经典示例,并深入了解C语言预处理器的工作原理。课程最后会简要介绍文件操作的概念,为后续学习打下基础。
递归示例:字符串反转
上一节我们讨论了递归的基本概念,本节中我们来看看如何利用递归来反转一个字符串。
核心思路是:如果字符串为空或只有一个字符,那么它本身就是反转后的结果。对于更长的字符串,我们可以交换首尾字符,然后对剩余的子字符串递归地应用相同的过程。
以下是实现该逻辑的函数原型和核心代码:
void reverse_string(char *str, int begin, int end) {
if (begin >= end) {
return; // 基线条件:无需交换
}
// 交换首尾字符
char temp = str[begin];
str[begin] = str[end];
str[end] = temp;
// 递归处理内部子串
reverse_string(str, begin + 1, end - 1);
}
在主函数中调用时,初始参数应为 reverse_string(str, 0, strlen(str) - 1)。需要注意的是,由于我们直接修改了传入的字符数组,因此不需要返回值。
递归示例:汉诺塔问题
接下来,我们探讨一个更复杂的递归问题:汉诺塔。这个问题很难用迭代方法解决,但用递归则非常清晰。
问题的目标是:将N个盘子从柱子A移动到柱子C,每次只能移动一个盘子,并且任何时候大盘子都不能放在小盘子上面。
递归解决方案是:
- 将顶部的N-1个盘子从A移动到B(借助C)。
- 将最大的第N个盘子从A直接移动到C。
- 再将B上的N-1个盘子移动到C(借助A)。
以下是计算移动次数的递归函数:
int tower_of_hanoi(int n, char from, char to, char temp) {
int moves = 0;
if (n == 1) {
printf("Move disk from %c to %c\n", from, to);
return 1; // 移动一次
}
moves += tower_of_hanoi(n - 1, from, temp, to); // 步骤1
printf("Move disk from %c to %c\n", from, to); // 步骤2
moves += 1;
moves += tower_of_hanoi(n - 1, temp, to, from); // 步骤3
return moves;
}
移动N个盘子所需的总步数是一个指数函数:总步数 = 2^N - 1。这展示了递归虽然优雅,但在问题规模较大时可能带来巨大的计算开销。
C预处理器详解
在深入新主题之前,我们需要了解C代码是如何变成可执行文件的。编译的第一步是“预处理”,它进行纯粹的文本替换和处理。
预处理器指令均以 # 开头。以下是三个最常用的指令:
#define 宏定义
#define 用于创建宏,即简单的文本替换规则。
#define BOARD_SZ 8
#define SIDE (BOARD_SZ + 2)
编译器在预处理阶段,会将代码中所有的 BOARD_SZ 替换为 8,将所有的 SIDE 替换为 (8 + 2)。
重要警告:宏是文本替换,不是函数计算。定义带参数的宏时,务必为参数和整个表达式加上括号,以避免运算符优先级导致的错误。
// 错误示例:可能产生非预期结果
#define SQUARE(x) x * x
// 调用 SQUARE(a+1) 会被替换为 a + 1 * a + 1
// 正确示例
#define SQUARE(x) ((x) * (x))
#undef 取消宏定义
#undef 用于取消一个已定义的宏。这在你想重新使用某个标识符作为变量名,或者需要重写某个来自头文件的宏时非常有用。
#define DEBUG_MODE 1
// ... 一些使用 DEBUG_MODE 的代码 ...
#undef DEBUG_MODE
int DEBUG_MODE = 0; // 现在可以作为一个变量名使用
#include 文件包含
#include 将其后指定文件的内容原封不动地插入到当前指令的位置。我们一直在使用的 #include <stdio.h> 就是将标准输入输出库的头文件包含进来。
预处理后,头文件中的所有代码(包括其本身可能包含的其他头文件)都会被复制到你的源文件中。编译器看到的是一份合并后的大代码文件。
文件操作简介
最后,我们简要介绍C语言中的文件操作。程序通过标准库函数与文件系统交互,这提供了一个跨平台的接口。
核心概念是文件指针(FILE *)。使用 fopen() 函数打开文件会返回一个文件指针,后续的读写操作(如 fprintf, fscanf, fgets)都需要使用这个指针。
fopen() 的第二个参数指定打开模式:
"r":只读。"w":只写(如果文件存在则清空)。"a":追加(在文件末尾写入)。"r+":读写(文件必须存在)。
操作完成后,必须使用 fclose() 函数关闭文件,以释放资源。
总结
本节课中我们一起学习了:
- 使用递归解决字符串反转和汉诺塔问题,理解了递归如何将复杂问题分解为相似的子问题。
- 深入了解了C预处理器,掌握了
#define(宏定义)、#undef(取消定义)和#include(文件包含)指令的用法及其注意事项,特别是宏只是文本替换的本质。 - 初步认识了C语言中文件操作的基本概念,知道了如何使用
fopen和fclose来打开和关闭文件。


这些知识是进行更复杂C语言编程,特别是涉及代码组织、条件编译和持久化数据存储的基础。
014:结构体与文件I/O



在本节课中,我们将学习C语言中两个重要的概念:结构体(struct)和文件输入/输出(I/O)。结构体允许我们将不同类型的数据组合成一个单一的单元,而文件I/O则使我们能够从文件中读取数据或将数据写入文件。掌握这两个概念对于处理复杂数据和持久化存储至关重要。
结构体基础
上一节我们讨论了指针和内存管理,本节中我们来看看如何将不同类型的数据组织在一起。结构体是C语言中用于将多个变量(可以是不同类型)组合成一个单一类型的方法。它类似于其他语言中的“记录”。
结构体的声明与定义
声明一个结构体需要使用 struct 关键字,后跟一个可选的标签名和一对花括号 {},花括号内是成员变量的列表。
struct student {
char *name;
int id;
};
上面的代码定义了一个名为 student 的结构体类型,它包含两个成员:一个指向字符的指针 name 和一个整数 id。编译器会为这个结构体分配足够的内存来存放这两个成员,但请注意,结构体的总大小不一定等于各成员大小之和,因为编译器可能会为了内存对齐而插入填充字节。
使用 typedef 简化
反复书写 struct student 可能很繁琐。我们可以使用 typedef 关键字为结构体类型创建一个别名。
typedef struct student {
char *name;
int id;
} Student;
现在,Student 就成为了 struct student 类型的别名,我们可以像使用 int 或 char 一样使用它来声明变量。
Student s1; // 声明一个 Student 类型的变量
Student *ps; // 声明一个指向 Student 的指针
访问结构体成员
要访问结构体变量的成员,我们使用点运算符 .。如果要通过指针访问,则使用箭头运算符 ->。
Student s1;
s1.id = 12345; // 使用 . 访问成员
Student *ps = &s1;
ps->id = 67890; // 使用 -> 通过指针访问成员
链表:结构体的一个应用
结构体一个非常强大的应用是构建动态数据结构,例如链表。链表由一系列节点组成,每个节点包含数据和指向下一个节点的指针。
链表节点的定义
一个简单的链表节点可以这样定义:
typedef struct node {
int data;
struct node *next;
} Node;
这里,next 是一个指向 struct node 类型(即它自身类型)的指针。这种自引用的特性是构建链表的基础。
链表的操作:插入节点
以下是向一个已排序的链表中插入新节点的三种情况:
- 在链表头部插入:新节点成为新的头节点。
- 在链表中间插入:新节点插入到两个现有节点之间。
- 在链表尾部插入:新节点成为最后一个节点。
以下是插入操作的逻辑和代码示例:
在头部插入
首先让新节点的 next 指向当前头节点,然后更新头指针指向新节点。
new_node->next = head;
head = new_node;
在中间或尾部插入
需要遍历链表找到正确的插入位置(prev 和 temp 指针),然后调整指针。
// 假设已找到插入点,prev 是前一个节点,temp 是后一个节点(可能为NULL)
new_node->next = temp; // 新节点指向后一个节点
prev->next = new_node; // 前一个节点指向新节点
如果 temp 为 NULL,则表示插入到尾部,上述代码同样适用。
结构体数组与排序
当我们需要管理一组相关的数据时(例如行星及其直径),可以使用多个平行的数组,但这容易导致数据不同步。更好的方法是使用结构体数组。
使用平行数组的问题
假设我们有两个数组:
char *planet_names[] = {"Mercury", "Venus", ...};
int planet_diameters[] = {4878, 12104, ...};
对行星按直径排序时,必须同时交换两个数组中对应位置的元素,操作繁琐且易错。
使用结构体数组的优势
定义一个行星结构体,并将数据存入结构体数组:
typedef struct {
char *name;
int diameter;
} Planet;
Planet planets[] = {
{"Mercury", 4878},
{"Venus", 12104},
// ...
};
现在,排序时只需交换整个结构体,所有数据(名称和直径)会自动一起移动,保证了数据的一致性。
文件输入/输出
上一节我们使用硬编码的数据,本节中我们来看看如何从文件中读取数据来填充我们的结构体数组。这使程序更加灵活。
打开与读取文件
使用 fopen 函数打开文件,使用 fscanf 读取格式化数据。
FILE *file = fopen(filename, "r"); // “r” 表示读取模式
if (file == NULL) {
perror(filename); // 打印错误信息
return 1;
}
char name[100];
int diameter;
while (fscanf(file, "%s %d", name, &diameter) == 2) {
// 成功读取到名称和直径,进行处理
// 例如,存入结构体数组
}
fclose(file); // 关闭文件
动态内存分配与结构体
从文件读取时,我们通常不知道会有多少数据。因此,需要动态地为每个行星的结构体及其名称分配内存。
// 为结构体本身分配内存
Planet *p = (Planet*)malloc(sizeof(Planet));
if (p == NULL) { /* 处理错误 */ }
// 为名称字符串分配内存(+1 用于存放字符串结束符 ‘\0’)
p->name = (char*)malloc((strlen(name_read_from_file) + 1) * sizeof(char));
if (p->name == NULL) { /* 处理错误,并记得释放 p */ }
strcpy(p->name, name_read_from_file);
p->diameter = diameter_read_from_file;
// 将 p 加入数组或链表
重要:使用 malloc 分配的内存,在程序结束或不再需要时必须使用 free 函数释放,以防止内存泄漏。
命令行参数
为了让用户指定输入文件,我们可以使用 main 函数的参数 argc 和 argv。

int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "用法: %s <文件名>\n", argv[0]);
return 1;
}
char *filename = argv[1];
// ... 使用 filename 打开文件 ...
}
argc 是参数个数,argv[0] 是程序名,argv[1] 是第一个用户提供的参数(此处应为文件名)。
总结

本节课中我们一起学习了C语言中结构体和文件I/O的核心知识。我们了解了如何定义和使用结构体来组织复杂数据,并探索了链表这一动态数据结构的实现。接着,我们比较了使用平行数组和结构体数组管理数据的优劣,认识到结构体在保持数据一致性方面的优势。最后,我们学习了如何从文件中读取数据并动态地构建结构体数组,以及如何通过命令行参数使程序更交互。这些概念是进行底层系统编程和构建复杂应用程序的基石。
015:C程序调试 🐛

在本节课中,我们将学习C程序调试的两种主要方法:静态调试和动态调试。我们将通过分析几个包含错误的示例程序,一步步学习如何定位和修复这些错误。
静态调试 🖨️
静态调试是指在代码中插入打印语句(如printf),通过分析程序运行时的输出来定位错误。这种方法不依赖于外部工具,直接修改和编译代码即可。
上一节我们介绍了调试的概念,本节中我们来看看静态调试的具体实践。
示例一:循环累加错误
以下程序的目标是将变量j累加100次,每次加2,最终应输出200,但实际只输出2。
#include <stdio.h>
int main() {
int j = 0;
for (int i = 0; i < 100; i++);
j += 2;
printf("%d\n", j);
return 0;
}
首先,我们编译并运行程序,确认输出错误。
为了找出问题,我们在关键位置插入打印语句,观察程序执行流程。
以下是插入调试代码的步骤:
- 在
for循环前打印i和j的初始值。 - 在
j += 2;语句前打印i和j的值。
修改后的代码如下:
#include <stdio.h>
int main() {
int j = 0;
printf("Before for loop: i=0, j=%d\n", j);
for (int i = 0; i < 100; i++);
printf("Inside loop? i=%d, j=%d\n", i, j);
j += 2;
printf("%d\n", j);
return 0;
}
重新编译运行后,我们发现for循环内的打印语句从未执行。这表明for循环体没有按预期执行。
检查for循环语句,发现行尾有一个多余的分号;。在C语言中,for (int i = 0; i < 100; i++);意味着循环体是一个空语句,其后的j += 2;只会在循环结束后执行一次。
修复方法是删除多余的分号。
for (int i = 0; i < 100; i++) // 分号已删除
j += 2;
管理调试代码 🏷️
在大型项目中,我们可能希望保留调试代码以备后用,但又不希望它影响最终版本的输出。这时可以使用宏定义来条件编译调试代码。
以下是具体做法:
#include <stdio.h>
#define DEBUG 1 // 设置为1启用调试,0则禁用
int main() {
int j = 0;
#if DEBUG
printf("Debug: Starting, j=%d\n", j);
#endif
for (int i = 0; i < 100; i++)
j += 2;
#if DEBUG
printf("Debug: After loop, j=%d\n", j);
#endif
printf("%d\n", j);
return 0;
}
#if DEBUG和#endif之间的代码只有在DEBUG宏定义为非零值时才会被编译。这样,我们可以轻松地开启或关闭调试输出。
更多静态调试示例 🔍
上一节我们修复了一个简单的循环错误,本节中我们来看看更复杂的情况。
示例二:无限循环与赋值错误
这个程序意图是不断将s加到自身,直到总和超过100时停止。
#include <stdio.h>
int main() {
int i = 1;
int s = 3;
while (i) {
s += s;
if (s > 100)
i = 0; // 意图:当s>100时,使循环条件为假
}
printf("Final s: %d\n", s);
return 0;
}
程序运行后陷入无限循环。我们插入打印语句来观察s和i的变化。
while (i) {
printf("Loop: s=%d, i=%d\n", s, i);
s += s;
if (s > 100) {
printf("s > 100 is true, setting i=0\n");
i = 0;
}
}
从输出发现,s的值不断翻倍增长(3, 6, 12, 24...),远远超过了100,但循环并未停止。打印语句显示if (s > 100)条件为真,也执行了i = 0;,但下一次循环i又变成了1。
问题出在while (i)的判断条件上。在C语言中,while(i)检查i是否为非零值。我们的本意是检查i是否等于1,但错误地写成了赋值语句i = 0,这会将i设置为0,而赋值表达式的结果就是0。因此,while(i)检查的是赋值结果0,循环本应结束。然而,由于while循环体外的i初始化为1,且没有改变,所以循环条件一直为真。
正确的写法应该是使用比较运算符==:
int i = 1;
while (i == 1) { // 使用 == 进行比较
s += s;
if (s > 100)
i = 0;
}
或者,更清晰的方式是使用一个布尔标志:
int done = 0; // 0表示未完成
while (!done) {
s += s;
if (s > 100)
done = 1; // 1表示完成
}
修复后,程序能在s超过100时正确停止。
关于整数溢出:在最初的无限循环中,s的值会变得非常大,最终超出int类型(32位)能表示的范围,发生整数溢出,导致值回绕到0或其他不可预测的值。这并非程序逻辑错误,而是数据类型的限制。
示例三:递归函数错误
这个程序计算阶乘(n!),但存在递归逻辑错误。
#include <stdio.h>
int fact(int n) {
int x;
x = n * fact(n + 1); // 错误:递归方向错误,且缺少终止条件
return x;
}
int main() {
int result = fact(10);
printf("10! = %d\n", result);
return 0;
}
运行程序会导致栈溢出或段错误。我们插入打印来观察递归调用。
int fact(int n) {
printf("Calling fact with n=%d\n", n);
int x;
x = n * fact(n + 1);
printf("Returning x=%d for n=%d\n", x, n);
return x;
}
输出显示n的值在不断增加(10, 11, 12...),这导致了无限递归。递归函数必须有一个基准情形(base case)来终止递归。
我们发现了两个错误:
- 递归调用方向错误:
fact(n + 1)应该向fact(n - 1)方向进行。 - 缺少基准情形:当
n为 0 或 1 时,应直接返回值 1。
修复后的函数如下:
int fact(int n) {
// 基准情形
if (n <= 1) {
return 1;
}
// 递归情形
return n * fact(n - 1);
}
现在,fact(10) 能正确计算并返回结果 3628800。
动态调试 🎯
动态调试使用专门的调试器工具(如GDB)来运行程序。它允许你暂停程序执行、检查变量状态、单步执行代码,而无需修改源代码。
上一节我们使用打印语句进行调试,本节中我们来看看如何使用GDB进行更强大的动态调试。
使用GDB调试阶乘程序
首先,我们需要在编译时加入调试信息,使用 -g 标志。
gcc -g -o nfact_bug nfact_bug.c
然后,在GDB中启动程序:
gdb ./nfact_bug
GDB加载后,我们首先运行程序以重现崩溃:
(gdb) run
程序会因段错误(Segmentation fault)而崩溃。GDB会指出错误发生在fact函数的某一行(例如第12行)。
我们在可能出错的递归调用行设置一个断点:
(gdb) break 12 # 在源文件第12行设置断点
重新运行程序:
(gdb) run
程序会在断点处暂停。此时,我们可以检查变量n的值:
(gdb) print n
使用 step (或 s) 命令可以进入被调用的函数(如fact),使用 next (或 n) 命令则执行下一行但不进入函数内部。通过单步执行和检查变量,我们可以观察到n在错误地增加,从而定位到fact(n + 1)这个错误。
退出GDB,修复源代码,将n + 1改为n - 1,并添加基准情形。
重新编译并用GDB测试,使用断点和print命令验证递归调用正确递减,并在n<=1时正确返回。这样,我们就用动态调试的方式修复了程序。
调试链表插入程序 🧩
最后,我们看一个链表插入的调试例子。程序本应按顺序插入节点,但输出顺序错误。
// ... 链表结构和插入函数 ...
void insert(struct node** head, int data) {
// ... 插入逻辑可能存在错误 ...
}
编译时带上-g标志,然后用GDB启动:
gdb ./linked_list
在insert函数的关键位置设置断点:
(gdb) break insert
运行程序并传入测试数据:
(gdb) run
当程序在断点处暂停时,我们可以检查链表头指针head和待插入节点new_node的状态:
(gdb) print *head # 查看头节点内容
(gdb) print new_node->data # 查看新节点的数据
使用 continue (或 c) 让程序继续执行直到下一个断点或结束。通过反复检查插入后链表的连接状态(例如head->next是否正确指向下一个节点),我们可以发现是插入到链表头部时的指针操作有误,例如没有正确更新head指针或某个节点的next指针。
通过GDB的实时观察,我们可以精确地定位到是哪个指针赋值语句出了问题,并进行修正。
总结 📝
本节课中我们一起学习了C程序调试的两种核心方法:
- 静态调试:通过向代码中插入条件编译的打印语句,分析输出结果来推断程序状态和逻辑错误。这种方法简单直接,适用于逻辑相对清晰的场景。
- 动态调试:利用GDB等调试器,在程序运行时设置断点、单步执行、实时查看和修改变量值。这种方法功能强大,可以深入跟踪复杂的执行流程和内存状态,是解决棘手Bug的利器。
关键要点:
- 调试的核心是观察:无论是打印输出还是调试器,目的都是获取程序运行时的内部信息。
- 从简单案例开始:先在小规模或简化的问题上验证调试思路。
- 理解程序意图:清楚代码应该做什么,才能判断它实际做错了什么。
- 善用工具:
-g编译选项、GDB的break、run、print、step、next、continue等命令是动态调试的基础。

掌握这些调试技能,将能有效帮助你定位和修复C语言程序中的各种错误。
016:Unions与gdb高级调试

概述
在本节课中,我们将学习两个核心主题:如何使用GDB(GNU调试器)进行高级调试,以及C语言中Union(联合体)的概念和用法。我们将从GDB的基本命令开始,逐步深入到条件断点、观察点等高级功能,然后探讨Union如何允许不同类型的数据共享同一块内存空间。
GDB高级调试技巧
上一节我们介绍了程序调试的基本概念,本节中我们来看看如何使用GDB这一强大工具来更高效地定位和解决程序中的问题。
启动与基本命令
要使用GDB调试程序,首先需要使用 -g 选项编译程序以包含调试信息。
gcc -g -o myprogram myprogram.c
然后,在终端中启动GDB并加载可执行文件。
gdb myprogram
在GDB中,help 命令非常重要,它可以提供关于各种命令的详细信息。
运行程序与处理错误
在GDB中,使用 run 命令执行程序。如果程序需要命令行参数,可以在 run 后面加上。
run arg1 arg2
如果程序运行出错,例如发生段错误(Segmentation Fault),GDB会停止执行并显示错误发生的具体位置,例如函数名和行号。
设置断点
为了在程序执行过程中暂停以检查状态,可以设置断点(Breakpoint)。使用 break 命令(可简写为 b)后跟行号或函数名。
break 15
break main
break nfact
设置断点后,使用 run 命令重新启动程序,程序会在到达断点时暂停。
条件断点
有时,我们只希望在特定条件满足时才暂停程序,这时可以使用条件断点。
break 15 if n >= 20
这条命令会在程序执行到第15行且变量 n 的值大于或等于20时才触发断点。
单步执行与继续执行
当程序在断点处暂停后,有几个命令可以控制执行流程:
step(或s):执行下一行代码。如果下一行是函数调用,会进入该函数内部。next(或n):执行下一行代码。如果下一行是函数调用,会将该函数作为一个整体执行,不会进入其内部。continue(或c):从当前暂停处继续执行程序,直到遇到下一个断点或程序结束。
检查变量值
在程序暂停时,可以使用 print 命令(可简写为 p)来检查变量的值。
print n
print/x n # 以十六进制格式显示
观察点
观察点(Watchpoint)用于监控变量的变化。当被监视的变量值发生改变时,程序会自动暂停。
watch x
需要注意的是,观察点遵循变量的作用域。在函数外部设置的观察点,进入函数内部后可能失效。
查看调用栈
当程序崩溃或暂停在深层调用中时,backtrace (或 bt,或 where)命令非常有用。它能显示当前的函数调用栈,帮助你理解程序的执行路径。
backtrace
其他实用命令
以下是其他一些有用的GDB命令:
info breakpoints:列出所有已设置的断点和观察点。delete <breakpoint-number>:删除指定编号的断点。list:显示源代码。可以指定行号范围,例如list 10, 20。
理解Union(联合体)
在掌握了调试工具后,我们来看看C语言中一个独特的数据结构——Union。它允许我们在同一块内存位置存储不同的数据类型。
Union的基本概念
Union的语法与结构体(struct)非常相似,关键区别在于关键字 union。在Union中,所有成员共享同一段内存空间,其大小由最大的成员决定。
union Data {
int i;
float f;
char str[20];
};
这段代码定义了一个名为 Data 的Union,它包含一个整数、一个浮点数和一个字符数组。这三个变量占用同一块内存。
Union的用途
Union的主要用途之一是查看同一段内存的不同解释。例如,我们可以检查一个浮点数在内存中的二进制表示。
union intFloat {
int i;
float f;
} u;
u.f = 2.456; // 将浮点数存入union
printf("Float: %f\n", u.f);
printf("As integer (hex): 0x%x\n", u.i); // 以整数形式(十六进制)解释同一段内存
printf("As integer (dec): %d\n", u.i); // 以整数形式(十进制)解释同一段内存
通过将浮点数赋值给 u.f,然后以整数格式打印 u.i,我们可以看到构成浮点数 2.456 的底层比特位。
另一个例子是拆分一个整数的各个字节:
union splitInt {
int x;
unsigned char bytes[sizeof(int)];
} s;
s.x = 1234567;
for (int i = 0; i < sizeof(int); i++) {
printf("Byte %d: 0x%02x\n", i, s.bytes[i]);
}
命令行参数处理的两种方式
在讲解Union之前,课程回顾了处理命令行参数的两种常见循环方式。
以下是第一种方式,使用索引和 argc:
for (int j = 1; j < argc; j++) {
printf("Argument %d: %s\n", j, argv[j]);
}
以下是第二种方式,直接遍历 argv 指针数组直到遇到 NULL 指针:
char **a = argv + 1; // 跳过程序名
while (*a != NULL) {
printf("Argument: %s\n", *a);
a++;
}
这两种方式是等价的。第二种方式利用了 argv 数组最后一个元素之后是一个 NULL 指针这一事实。
总结


本节课中我们一起学习了GDB调试器的高级功能,包括设置条件断点、使用观察点监控变量以及查看调用栈。这些技巧能极大地提升调试复杂程序的效率。同时,我们也介绍了C语言中的Union(联合体),它允许不同的数据类型共享同一块内存,常用于类型转换、节省内存或直接操作数据的底层表示。理解这些概念对于进行底层编程和系统级开发至关重要。
017:位运算符与基数

概述
在本节课中,我们将要学习计算机中数据的二进制表示,以及如何使用C语言提供的位运算符直接操作这些二进制位。我们将从不同进制(如二进制、十进制、十六进制)之间的转换开始,然后深入探讨位运算(如与、或、非、异或)和移位操作,最后学习如何利用这些操作来提取和检查整数的特定位。
进制转换
上一节我们介绍了课程安排,本节中我们来看看数据在计算机中的基础表示——进制。
十六进制与二进制转换
十六进制与二进制之间的转换非常直接,因为每四位二进制数恰好对应一位十六进制数。
以下是十六进制与二进制对应表:
0000->00001->10010->20011->30100->40101->50110->60111->71000->81001->91010->A或a1011->B或b1100->C或c1101->D或d1110->E或e1111->F或f
示例:十六进制数 0x401D2F1B 转换为二进制,只需将每位十六进制数替换为对应的四位二进制数即可。
反之,将二进制转换为十六进制,只需从右向左每四位一组,查表转换为对应的十六进制数字。
十进制与二进制转换
十进制转二进制相对复杂,需要使用“除2取余,逆序排列”的方法。
方法:将十进制数反复除以2,记录每次的余数(0或1),直到商为0。最后,将所有余数从最后一个到第一个逆序排列,即得到二进制表示。
示例:将十进制数345转换为二进制。
345 / 2 = 172 ... 余 1
172 / 2 = 86 ... 余 0
86 / 2 = 43 ... 余 0
43 / 2 = 21 ... 余 1
21 / 2 = 10 ... 余 1
10 / 2 = 5 ... 余 0
5 / 2 = 2 ... 余 1
2 / 2 = 1 ... 余 0
1 / 2 = 0 ... 余 1
逆序读取余数:101011001,所以 345(十进制) = 101011001(二进制)。
二进制转十进制则使用按权展开求和的方法。
公式:对于一个n位的二进制数 b_{n-1}b_{n-2}...b_1b_0,其对应的十进制值为:
值 = b_{n-1} * 2^{n-1} + b_{n-2} * 2^{n-2} + ... + b_1 * 2^1 + b_0 * 2^0
示例:二进制 101011001 转换为十进制。
1*2^8 + 0*2^7 + 1*2^6 + 0*2^5 + 1*2^4 + 1*2^3 + 0*2^2 + 0*2^1 + 1*2^0 = 256 + 0 + 64 + 0 + 16 + 8 + 0 + 0 + 1 = 345
八进制
八进制(基数为8)现在不常用,但其与二进制的转换也很简单,因为每三位二进制数对应一位八进制数。方法与十六进制转换类似,只是使用三位分组。
位运算符
理解了二进制表示后,我们来看看C语言中用于直接操作这些位的运算符。
C语言之所以流行,一个重要原因是它能够方便地访问和操作机器的底层架构(位和字节)。以下是基本的位运算符:
- 按位与 (
&):两个操作数对应的位都为1时,结果位才为1。0 & 0 = 00 & 1 = 01 & 0 = 01 & 1 = 1
- 按位或 (
|):两个操作数对应的位有一个为1时,结果位就为1。0 | 0 = 00 | 1 = 11 | 0 = 11 | 1 = 1
- 按位异或 (
^):两个操作数对应的位不同时,结果位为1。0 ^ 0 = 00 ^ 1 = 11 ^ 0 = 11 ^ 1 = 0
- 按位取反 (
~):将操作数的每一位取反(0变1,1变0)。~0 = 1~1 = 0
注意:&、| 与逻辑运算符 &&、|| 不同。位运算符对整数的每一位进行操作,而逻辑运算符将整个值视为真(非零)或假(零)进行判断。
移位运算符
有时我们只关心整数中的某一位或某几位,这时就需要移位操作。
- 左移 (
<<):将操作数的所有位向左移动指定的位数,右侧空出的位用0填充,左侧移出的位被丢弃。- 示例:
x << n将x的位左移n位。
- 示例:
- 右移 (
>>):将操作数的所有位向右移动指定的位数,但左侧空出的位填充规则取决于操作数的类型:- 对于无符号 (
unsigned) 整数,左侧空位用0填充。 - 对于有符号 (
signed) 整数,行为是实现定义的。常见做法是进行算术右移:如果数是正数,左侧补0;如果数是负数,左侧补1(即复制符号位)。这保证了右移操作在数学上相当于除以2的幂(向下取整)。
- 对于无符号 (
声明说明:默认的 int 是 signed(有符号)。使用 unsigned int 声明无符号整数,通常用于处理不会出现负值的大数或进行位级操作。
位操作实践:提取特定位
了解了运算符后,我们来看看如何实际应用它们。一个常见任务是提取整数中的特定位。
假设我们有一个32位整数,最高位(最左边)是第31位,最低位(最右边)是第0位。我们想提取第 i 位(例如第8位)。
方法:
- 将整数左移
i位,使目标位移动到最低位(第0位)的位置。 - 将结果与数字
1(二进制...0001)进行按位与 (&) 操作。这样,除了最低位,其他所有位都会被屏蔽为0。 - 最终结果就是目标位的值(0或1)。
代码描述:
int bit_value = (number >> i) & 1;
// 或者先左移再与,但右移更常见且直观
// int bit_value = (number << (31 - i)) >> 31; // 另一种方法,将目标位移到最高位再右移回来
示例:提取十进制数345(二进制...0001 0101 1001)的第8位。
345 >> 8得到...0000 0000 0001(二进制)。(...0000 0000 0001) & 1得到1。
所以第8位是1。
编写程序打印二进制位
理论结合实践,让我们编写一个程序来打印任意整数的二进制表示。
思路:从最高位(第31位)开始,依次右移并将该位移动到最低位,用 & 1 提取,然后打印。
初始有bug的代码示例:
void print_bits(unsigned int x) {
int nbits = sizeof(x) * 8; // 计算总位数
for (int i = nbits - 1; i >= 0; i--) {
int bit = (x >> i) & 1;
printf("%d", bit); // 打印数字0或1
}
printf("\n");
}
注意:早期尝试中,曾错误地使用 printf("%c", bit) 来打印,这会将bit的整数值(0/1)当作ASCII码打印,导致输出不可见字符(如NULL或SOH)。正确做法是打印为数字 (%d) 或转换为字符 '0' + bit。
另一个版本:从最低位打印到最高位(顺序相反),只需改变循环方向:
for (int i = 0; i < nbits; i++) {
int bit = (x >> i) & 1;
// 或者使用字符转换:putchar('0' + ((x >> i) & 1));
}
提取一组位(位域)
有时我们需要提取连续的多位(一个位域),例如第 i 位到第 j 位(假设 i > j)。
方法:
- 将整数右移
j位,使目标位域的最低位移到第0位。 - 创建一个掩码(mask),这个掩码的低
(i - j + 1)位全是1,高位全是0。掩码可以通过(1 << (i - j + 1)) - 1计算得到。 - 将右移后的结果与这个掩码进行按位与 (
&) 操作,即可提取出目标位域。
示例:提取二进制数 ...0101 0110 01... 中第8位到第5位(共4位:0101)。
- 右移5位:得到
...0000 0101 0...。 - 创建掩码:需要提取4位,所以掩码是低4位为1,即
0xF(十六进制)或(1 << 4) - 1 = 15(十进制)。 (...0000 0101 0...) & 0xF得到0101(二进制),即十进制5。
代码描述:
int extract_bitfield(unsigned int number, int high, int low) {
int width = high - low + 1;
unsigned int mask = (1u << width) - 1; // 创建掩码
return (number >> low) & mask;
}
负数的表示(补码)
计算机中,有符号整数通常采用二进制补码形式表示。
规则:
- 正数的补码是其二进制本身。
- 负数的补码是其对应正数按位取反后加1。
示例:用8位表示 -5。
+5的二进制:0000 0101- 按位取反:
1111 1010 - 加1:
1111 1011这就是-5的补码表示。
这种表示法的好处是:
- 只有一个零(
0000 0000),没有+0和-0之分。 - 加减法运算可以使用同一套电路。
- 最高位(符号位)为1表示负数,为0表示正数或零。
总结


本节课中我们一起学习了C语言底层编程的核心——位操作。
我们首先回顾了二进制、十六进制和十进制之间的转换方法。
然后,我们详细介绍了C语言的位运算符:与(&)、或(|)、异或(^)、取反(~)以及移位运算符(<<, >>),并特别强调了有符号数右移的符号位扩展行为。
接着,我们通过编写打印整数二进制表示的程序,实践了提取单个位的方法。
最后,我们探讨了如何提取一组连续的位(位域),并简要介绍了负数在计算机中的补码表示。
这些知识是理解计算机如何存储数据、进行底层硬件交互(如设备驱动、网络协议)以及编写高效算法的基础。下节课,我们将开始学习如何将大型程序组织到多个文件中。
018:动态字符串输入与多文件编译 📚

在本节课中,我们将学习如何创建一个更灵活的字符串输入函数,以解决标准库函数fgets在处理未知长度输入时的限制。我们还将探讨如何将程序分解为多个源文件进行编译和链接,这是构建大型、可维护软件项目的基础。
概述
fgets函数要求调用者预先分配一个固定大小的缓冲区。如果输入行的长度超过了缓冲区大小,fgets会截断输入,并且不会在缓冲区末尾添加换行符,这给按行处理输入带来了复杂性。本节课的目标是设计并实现一个名为dgets(动态获取字符串)的函数,它能够动态分配内存来读取任意长度的行,同时保持与fgets相似的接口。此外,我们将学习如何将dgets函数和调用它的主程序分别放在不同的文件中,并通过编译和链接将它们组合成一个可执行程序。
动态字符串输入函数的设计
上一节我们介绍了fgets的局限性。本节中,我们来看看如何设计一个更强大的替代函数。
函数接口设计
为了保持接口清晰并减少使用错误,dgets函数应尽可能模仿fgets的参数列表。其原型设计如下:
char *dgets(char *buff, int n, FILE *fp);
参数buff和n的含义与fgets相同:buff是用于存储输入的缓冲区指针,n是该缓冲区的大小。然而,为了实现动态分配,我们引入一个特殊规则:如果buff是NULL,则函数会自己分配内存来存储整行输入,并忽略参数n。
核心实现思路
函数的核心逻辑是逐个读取字符,并根据需要动态扩展内部缓冲区。为了在多次函数调用之间保持这个内部缓冲区的状态(如它的地址和已分配大小),我们需要使用static关键字来声明这些变量。
以下是static关键字的简要说明:
int count_calls() {
static int call_count = 0; // 静态变量,在函数调用间保持值
call_count++;
return call_count;
}
在这个例子中,call_count在函数返回后不会被销毁,下次调用时其值会保留,从而实现跨调用的状态记录。
字符插入与缓冲区管理
dgets函数的主体会循环读取字符。对于每个字符,它调用一个辅助函数ch_ins(字符插入)来将其放入缓冲区。ch_ins函数负责检查缓冲区剩余空间,并在空间不足时进行内存分配。
ch_ins函数需要处理两种分配情况:
- 首次分配:当缓冲区指针为
NULL时,使用malloc分配初始空间。 - 重新分配:当缓冲区已满需要扩容时,使用
realloc分配更大的空间。realloc可能会在原地扩展,也可能会在内存的新位置分配一块空间并复制原有数据。
由于realloc可能返回一个新的地址,因此传递给ch_ins的缓冲区指针必须是一个双重指针(char **),以便在函数内部更新主函数中指向缓冲区的指针。
以下是ch_ins函数的基本逻辑结构:
int ch_ins(int c, int pos, char **space, int *nspace) {
// 检查是否需要更多空间
if (pos >= *nspace) {
// 增加所需空间大小,例如增加 INCREMENT 个字节
*nspace += INCREMENT;
if (*space == NULL) {
// 首次分配
*space = malloc(*nspace * sizeof(char));
} else {
// 重新分配
*space = realloc(*space, *nspace * sizeof(char));
}
if (*space == NULL) return 0; // 分配失败
}
// 将字符c放入缓冲区*space的位置pos
(*space)[pos] = c;
return 1; // 成功
}
完整的dgets函数流程
- 如果
buff不为NULL,直接调用fgets并返回。 - 如果
buff为NULL,进入动态分配模式。 - 使用
static变量维护内部缓冲区指针和大小。 - 循环从文件指针
fp读取字符:- 对每个字符调用
ch_ins插入缓冲区。 - 如果
ch_ins返回0(分配失败),则打印错误信息并返回NULL。
- 对每个字符调用
- 遇到文件结束符(EOF)时:
- 如果这是该行的第一个字符,则返回
NULL表示没有更多输入。 - 否则,调用
ch_ins插入一个换行符\n。
- 如果这是该行的第一个字符,则返回
- 在行末尾,调用
ch_ins插入字符串终止符\0。 - 函数成功完成后,返回指向内部缓冲区的指针。
多文件编译与链接 🧩
上一节我们实现了dgets函数。本节中,我们来看看如何将它用于实际程序,并组织多个源文件。
示例程序:mcat
假设我们有一个程序mcat(简易版cat),它使用dgets来读取并显示文件内容。我们将把程序分成两个源文件:
mcat.c:包含main函数,调用dgets。dgets.c:包含dgets函数及其辅助函数ch_ins的实现。
在mcat.c中,我们需要声明dgets的函数原型,以便编译器知道它的存在:
char *dgets(char *buff, int n, FILE *fp);
编译过程
我们可以使用gcc分别编译每个源文件为目标文件(.o文件),然后将它们链接在一起:
# 编译 mcat.c 为目标文件 mcat.o
gcc -g -c mcat.c -o mcat.o
# 编译 dgets.c 为目标文件 dgets.o
gcc -g -c dgets.c -o dgets.o
# 将两个目标文件链接成可执行程序 mcat
gcc -g mcat.o dgets.o -o mcat
-c选项告诉编译器只进行编译和汇编,不进行链接,生成目标文件。-g选项添加调试信息。-o选项指定输出文件名。
链接器的作用
链接器(linking loader)负责将多个目标文件合并成一个可执行文件。它主要完成两项工作:
- 符号解析:每个目标文件都有一个符号表,记录了它定义的函数和变量(如
dgets),以及它引用但未定义的符号(如mcat.o中引用的dgets)。链接器会匹配这些引用和定义。 - 地址重定位:将各个目标文件中的代码和数据段组合起来,并为其分配最终的内存地址。
如果链接器找不到某个被引用的符号的定义(例如,编译时忘了链接dgets.o),就会报告“未定义的引用”错误。
使用make自动化构建
对于多文件项目,手动输入编译命令很繁琐。我们可以使用make工具和Makefile文件来管理构建过程。一个简单的Makefile可能如下所示:
CC = gcc
CFLAGS = -g
mcat: mcat.o dgets.o
$(CC) $(CFLAGS) -o mcat mcat.o dgets.o

mcat.o: mcat.c
$(CC) $(CFLAGS) -c mcat.c
dgets.o: dgets.c
$(CC) $(CFLAGS) -c dgets.c
clean:
rm -f mcat *.o
之后,只需在终端输入make命令,它就会根据文件依赖关系自动执行必要的编译和链接步骤。如果只修改了dgets.c,make会智能地只重新编译dgets.o并重新链接,从而节省时间。
错误处理:perror函数
在文件操作(如fopen)失败时,系统会将一个错误代码存入全局变量errno中。perror函数可以方便地打印出对应的可读错误信息。它的用法是:
FILE *fp = fopen("somefile.txt", "r");
if (fp == NULL) {
perror("Error opening file");
// 输出类似:Error opening file: No such file or directory
}
perror会将其参数字符串与系统根据errno查到的错误描述一起打印到标准错误输出。
总结

本节课中我们一起学习了两个重要的主题。首先,我们设计并实现了dgets函数,它通过动态内存分配解决了fgets对输入行长度的限制,并利用static变量在函数调用间保持状态。其次,我们探讨了多文件编程,学习了如何将程序拆分为逻辑独立的模块(如mcat.c和dgets.c),并通过编译、链接步骤将它们组合成最终的可执行程序。我们还简要介绍了使用make工具自动化构建过程以及使用perror进行错误报告的方法。这些是构建结构化、可维护的C语言程序的基础技能。
019:字符串转数字与getopt

概述
在本节课中,我们将学习C语言标准库中的两个实用功能:将字符串转换为数字的函数,以及处理命令行选项的getopt函数。我们还将回顾标准I/O库的缓冲机制,并了解如何操作文件读写指针。
关于期中考试
在开始之前,我想就期中考试说几句,因为很多同学感到焦虑。期中考试的平均分是61分,中位数是62分。最高分是94分,最低分是27分。根据我计划使用的评分曲线,平均分大约对应B或B-的成绩。因此,如果你的分数在平均分左右,目前没有不及格的危险。但请注意,最终成绩还取决于期末考试和作业。
焦虑会影响学习效率。我建议通过练习编程来复习。你可以自己编写程序,或者从书本、网络上找一些程序来写,并尝试运用课堂上学到的知识。如果你写了程序并调试好,或者遇到调试困难,我很乐意提供帮助。
我使用的评分方法不依赖于班级平均分,而是基于每个人的独立作业和考试成绩。所以,请不要现在就恐慌。等到期末考试后再担心也不迟。
库函数与系统调用
许多库函数用于计算,而另一些库,例如我们今天要讨论的,则作为程序与操作系统之间的接口。操作系统通过称为“系统调用”的函数来工作,这些函数将控制权转移给操作系统,执行操作后再返回控制权。
例如,操作系统不使用文件指针,而是使用文件描述符。文件指针是标准I/O库添加的一层抽象,它使得操作更加容易,尤其是在将程序从一个系统移植到另一个系统时。使用标准I/O库,跨平台移植时所需的更改非常少。
因此,如果在系统调用和库函数之间选择,并且程序有可能移植到其他机器,应始终使用库函数。当然,如果是永远不会移动的系统程序,则可以直接使用系统调用。
C标准库函数分类
C标准库包含多种类型的函数,以下是一些重要的类别:
- 标准I/O库:用于输入输出操作。
- 数学库:包含数学函数,例如
pow用于幂运算。 - 字符类型处理:例如
isalpha,isdigit等。 - 字符串与数字转换:例如
atoi,strtol等。 - 选项处理:例如
getopt,用于解析命令行参数。 - 时间与随机数:用于获取时间、生成随机数。
- 字符串与内存操作:例如
strcpy,memcpy等。
标准I/O库与缓冲
使用标准I/O库需要包含头文件<stdio.h>。它基于“流”的概念,你可以将其视为文件。标准I/O库会进行缓冲以提高效率。
当进程从磁盘读取数据时,这是一个耗时的操作。为了加快速度,标准I/O库使用缓冲区。例如,当你使用getc从文件读取一个字符时,如果缓冲区为空,系统会读取一大块数据(例如1024字节)填充缓冲区,然后给你第一个字符。后续读取字符时,直接从缓冲区获取,直到缓冲区为空,系统才会再次读取。
写入操作类似,数据先写入缓冲区,并不立即写入磁盘。当缓冲区满时,或程序正常退出时,或调用fflush函数时,缓冲区的内容才会被写入磁盘。
Linux中有三种缓冲类型:
- 全缓冲:通常用于文件等块设备。缓冲区满时才进行I/O操作。
- 行缓冲:通常用于终端等字符设备。遇到换行符
\n时进行I/O操作。 - 无缓冲:数据立即读写。标准错误流
stderr默认是无缓冲的,这样即使程序崩溃,错误信息也能立即输出。
文件定位函数
文件读写指针指示了文件中下一次读写操作的位置。这允许我们在文件中跳转,实现随机访问,这对于数据库等应用非常重要。
以下是相关的函数:
fgetpos:获取当前文件位置。ftell:功能同上。fsetpos:设置文件位置到指定点。rewind:将文件位置重置到开头。fseek:通用的文件定位函数。
fseek函数的原型是:
int fseek(FILE *stream, long offset, int whence);
参数whence可以是:
SEEK_SET:从文件开头计算偏移。SEEK_CUR:从当前位置计算偏移。SEEK_END:从文件末尾计算偏移。
例如,要跳过文件的前100字节,可以调用fseek(fp, 100, SEEK_SET)。
使用这些函数时需要小心,因为如果返回-1,可能是错误,也可能是成功定位到了-1的位置。建议在使用前将全局变量errno设为0,调用函数后检查errno是否为0来判断是否出错。
字符串转数字函数
将字符串转换为数字是常见的需求。C库提供了以下函数:
atoi:将字符串转换为int。atol:将字符串转换为long。atof:将字符串转换为double。
这些函数的问题在于没有错误检查。如果返回0,你无法区分是因为字符串是"0",还是因为转换失败(如字符串是"abc")。
更安全的函数是:
strtol:将字符串转换为long。strtod:将字符串转换为double。
strtol的函数原型是:
long strtol(const char *nptr, char **endptr, int base);
它接受一个字符串nptr、一个基数base(例如10表示十进制),以及一个指向字符指针的指针endptr。函数会尽可能多地转换字符串开头的数字部分,并在endptr指向的位置停止。如果endptr指向字符串末尾的\0,说明整个字符串都被成功转换。如果endptr指向其他字符,则转换在遇到非数字字符时停止。如果根本没有数字,endptr会被设为字符串起始地址,并返回0。
命令行选项处理:getopt
程序经常需要处理命令行选项,例如gcc -o hello hello.c。你可以自己编写代码解析参数,但使用库函数getopt更方便。
getopt的函数原型是:
int getopt(int argc, char * const argv[], const char *optstring);
你需要传入main函数的argc和argv,以及一个选项字符串optstring。optstring列出了程序接受的选项字母。如果一个选项后面需要跟一个参数(如-o filename),则在选项字母后加一个冒号:。
getopt会依次解析argv中的选项。当遇到以-开头的参数时,它认为这是一个选项,并返回选项字母。如果选项需要参数,全局变量optarg会指向该参数字符串。变量optind会记录下一个要处理的argv索引。
如果遇到未在optstring中定义的选项,getopt返回?。当所有选项处理完毕,getopt返回-1。
一个简单的使用模式是:
int main(int argc, char *argv[]) {
int opt;
while ((opt = getopt(argc, argv, "nt:")) != -1) {
switch (opt) {
case 'n':
// 处理 -n 选项
break;
case 't':
// 处理 -t 选项,其参数在 optarg 中
long val = strtol(optarg, NULL, 10);
break;
case '?':
// 处理未知选项
break;
}
}
// 处理剩余的非选项参数(从 argv[optind] 开始)
}
总结


本节课我们一起学习了C语言标准库的几个重要部分。我们回顾了标准I/O库的缓冲机制及其对性能的影响,并学习了如何使用fseek等函数在文件中进行随机访问。接着,我们探讨了将字符串转换为数字的函数,比较了简单的atoi系列和更安全、功能更强的strtol系列。最后,我们介绍了如何使用getopt函数来优雅地解析命令行参数,这能极大地简化处理程序选项的代码。掌握这些库函数的使用,将使你的C程序更加健壮和易于使用。
020:库函数详解


在本节课中,我们将深入学习C语言中的库函数,特别是与指针、时间处理相关的函数。课程首先会回顾指针的核心概念,然后详细介绍时间库 time.h 中的函数,帮助你理解如何在程序中获取和处理时间信息。
指针核心概念回顾
上一节我们介绍了指针的基础,本节中我们来看看指针在实际问题中的应用,特别是通过分析考试题目来加深理解。
指针是一个地址,类似于一个整数。它可以指向内存中的某个位置。
指针常量与指针变量
以下是区分指针常量与指针变量的关键点:
- 指针常量:例如数组名。其值(即指向的地址)不能被改变。
int arr[10]; // `arr` 是一个指针常量,不能进行 `arr = ...` 这样的赋值。 - 指针变量:使用
*声明的指针。其值可以被改变,可以指向不同的地址。int *p; // `p` 是一个指针变量,可以进行 `p = &x;` 这样的赋值。
指针运算示例分析
以下通过具体代码片段分析指针的行为:
-
改变指针变量的指向:
char c; char *b; b = &c; // b 的值变为 c 的地址,不再指向原来的数组。这会导致
b之前指向的内存无法被释放,造成“内存泄漏”。 -
非法赋值:
int a[5]; int *b; a = b; // 错误!`a` 是指针常量,不能赋值。 -
数组访问的等价形式:
a[3]在编译器内部等价于*(a + 3)。根据加法交换律,3[a]也是合法的,等价于*(3 + a)。 -
通过指针修改外部数据:要向函数内部传递一个可修改的变量,需要传递该变量的地址(指针)。
void increment(int *x) { (*x)++; // 修改 x 所指向的值 } int main() { int val = 5; increment(&val); // val 变为 6 }如果函数参数是
int x,则函数内部对x的修改不会影响外部的val。
使用指针的建议
- 将指针视为普通的常量或变量来理解。
- 在函数参数列表中,
int arr[]可以被视为指针变量int *arr。 *p是解引用操作,表示获取指针p所指向地址中存储的值。&a是取地址操作,表示获取变量a的地址。- 遇到复杂情况时,画图是理清思路最有效的方法。
- 声明
char str[10];会分配 10 个字符的空间。而声明char *str;只分配一个指针的空间,需要使用malloc来分配字符串空间。
时间处理库函数
理解了指针后,我们来看看如何利用库函数处理时间和日期。C标准库提供了 time.h 头文件来支持时间操作。
获取当前时间戳
time_t time(time_t *timer); 函数用于获取当前时间。时间是从 1970年1月1日午夜(UTC) 开始计算的秒数,这个起点称为“纪元”。
time_t通常是long或unsigned long的别名。- 参数
timer是一个指针。如果非空,函数会将时间戳写入该地址。 - 返回值也是当前的时间戳。通常可以传入
NULL来忽略参数,只使用返回值。#include <time.h> #include <stdio.h> int main() { time_t now; now = time(NULL); // 获取当前时间戳 if (now == (time_t)-1) { perror("time"); return 1; } printf("Seconds since epoch: %ld\n", now); return 0; }
将时间戳转换为字符串
char *ctime(const time_t *timer); 函数将 time_t 类型的时间戳转换为一个易读的字符串。
- 字符串格式示例:
"Wed Jun 30 21:49:08 1993\n"。 - 如果转换失败,返回
NULL。time_t now = time(NULL); char *time_str = ctime(&now); if (time_str != NULL) { printf("Current time: %s", time_str); // 字符串已包含换行符 }
分解时间结构体 struct tm
为了更灵活地处理时间的各个部分(如年、月、日、时、分、秒),库定义了 struct tm 结构体。
struct tm {
int tm_sec; // 秒 [0, 59]
int tm_min; // 分 [0, 59]
int tm_hour; // 时 [0, 23]
int tm_mday; // 月中的天数 [1, 31]
int tm_mon; // 月份 [0, 11],0代表一月
int tm_year; // 自1900年起的年数
int tm_wday; // 星期几 [0, 6],0代表星期日
int tm_yday; // 年中的天数 [0, 365]
int tm_isdst; // 夏令时标志
};
时间转换函数
以下是用于在 time_t 和 struct tm 之间转换的函数:
struct tm *localtime(const time_t *timer);:将时间戳转换为本地时间的tm结构体。struct tm *gmtime(const time_t *timer);:将时间戳转换为UTC(格林威治标准时间)的tm结构体。char *asctime(const struct tm *timeptr);:将tm结构体转换为ctime格式的字符串。time_t mktime(struct tm *timeptr);:将本地时间的tm结构体转换回time_t时间戳。

重要提示:localtime 和 gmtime 返回指向静态内存区的指针。这意味着连续调用会覆盖之前的结果。如果需要保存多个结果,应使用 memcpy 复制结构体内容。

#include <time.h>
#include <stdio.h>
int main() {
time_t now = time(NULL);
struct tm *local_tm = localtime(&now); // 获取本地时间结构体
printf("Year: %d\n", local_tm->tm_year + 1900);
printf("Month: %d\n", local_tm->tm_mon + 1); // 月份+1以符合习惯
printf("Day: %d\n", local_tm->tm_mday);
printf("Hour: %d\n", local_tm->tm_hour);
// 转换为字符串输出
printf("Formatted time: %s", asctime(local_tm));
return 0;
}
总结


本节课中我们一起学习了C语言中两个重要的主题。首先,我们通过实例深入回顾了指针的概念,区分了指针常量与指针变量,并理解了通过指针在函数间修改数据的方法。随后,我们系统学习了 time.h 库函数,掌握了如何获取系统时间戳(time),如何将其转换为可读字符串(ctime),以及如何通过 struct tm 结构体及其相关函数(localtime, gmtime, asctime, mktime)来灵活处理和格式化时间。理解这些库函数对于编写需要时间管理功能的程序至关重要。
021:标准库函数 📚

在本节课中,我们将学习标准库函数的使用,特别是如何利用qsort函数对数组进行排序,并回顾与作业相关的链表操作和字符串处理技巧。
课程概述与公告 📢
首先是一些课程公告。作业4已发布,评分系统已就绪。本周五的课程将通过录像形式发布。关于作业3,许多同学的问题集中在两点:如何将一行文本拆分为字母数字单词,以及如何构建和操作链表。我们将详细探讨这些问题。
字符串分割技术 🔪
上一节我们介绍了课程安排,本节中我们来看看如何从一行输入中提取出由字母数字字符组成的“单词”。这里的关键是识别并分离出连续的字母数字序列。
以下是两种常见但可能存在问题的方法:
- 使用
strtok函数:此函数通过指定分隔符来分割字符串。但问题在于,你需要将所有非字母数字字符都列为分隔符,这通常需要遍历ASCII字符集来构建分隔符字符串,过程较为繁琐。 - 使用
fscanf与模式匹配:例如使用%[^...]格式来读取非指定字符。但这种方法要求你列出所有需要排除的非字母数字字符,如果不全,会遇到问题。例如,如果遇到未列出的字符(如&),读取会停止,导致后续处理被阻塞。
一个更可靠的方法是手动遍历输入缓冲区。其核心思路如下:
- 将一行文本读入缓冲区。
- 使用一个指针从头开始遍历缓冲区。
- 当指针指向字母数字字符时,开始将其复制到一个临时数组中。
- 当遇到非字母数字字符时,在临时数组末尾添加空字符(
\0),形成一个完整的“单词”,然后将其插入链表或其他数据结构。 - 跳过所有非字母数字字符,重复步骤3-4,直到指针指向行尾的空字符。
以下是该算法的伪代码描述:
while (获取下一行到缓冲区) {
p = 缓冲区起始地址;
while (*p != '\0') {
// 跳过非字母数字字符
while (*p != '\0' && !isalnum(*p)) {
p++;
}
if (*p == '\0') break; // 行已结束
// 开始复制字母数字字符到临时数组 temp
char temp[MAX_WORD_LEN];
int i = 0;
while (isalnum(*p)) {
temp[i++] = *p;
p++;
}
temp[i] = '\0'; // 终止字符串
// 将 temp 中的单词插入链表
insert_word_into_list(temp);
}
}
重要提示:切勿简单地将链表节点中的字符串指针直接指向临时数组temp。因为temp的内容会被下一次单词覆盖,导致链表中所有节点都指向相同的、最后被读取的单词。必须使用strdup或malloc+strcpy为每个单词分配独立的内存空间。
链表构建与排序插入 ⛓️
在成功分割出单词后,我们需要将其存储并排序。链表是完成此任务的理想数据结构。本节我们将学习如何构建一个有序链表。
链表节点结构体通常包含以下字段:
- 一个字符串指针(
char *word),指向存储的单词。 - 一个整型计数(
int count),记录该单词出现的次数。 - 一个指向下一个节点的指针(
struct node *next)。
插入新节点的核心在于维护链表的有序性。这被称为插入排序。插入位置分为三种情况:
-
插入到链表头部:当新单词按顺序应排在第一个节点之前时。
- 操作:令新节点的
next指针指向当前头节点,然后更新链表头指针指向新节点。
new_node->next = head; head = new_node; - 操作:令新节点的
-
插入到链表中间:当新单词位于两个现有节点之间时。
- 操作:需要两个指针
prev和curr遍历链表,找到插入位置(prev->next应指向比新单词大的节点)。将新节点的next指向curr,再将prev->next指向新节点。
new_node->next = curr; prev->next = new_node;关键:务必先设置新节点的
next指针,再修改前驱节点的next指针,否则会丢失对后续节点的引用。 - 操作:需要两个指针
-
插入到链表尾部:当新单词比所有现有单词都大时。
- 操作:遍历到链表末尾,将最后一个节点的
next指针指向新节点,并将新节点的next指针置为NULL。
last_node->next = new_node; new_node->next = NULL; - 操作:遍历到链表末尾,将最后一个节点的
字符串比较:使用strcmp进行区分大小写的比较。对于不区分大小写的比较,可以使用strcasecmp函数。
使用qsort进行数组排序 🚀
对于作业4,我们需要对结构体数组进行排序,而不是链表。C标准库提供了高效的qsort函数。
qsort函数原型如下:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
base:指向待排序数组起始位置的指针。nmemb:数组中元素的数量。size:每个元素的大小(字节数),可使用sizeof运算符获得。compar:指向比较函数的指针。
比较函数是qsort的核心。它接收两个指向数组元素的const void *类型指针。在函数内部,你需要先将它们转换为实际的数据类型指针,然后定义比较规则:
- 如果第一个元素应排在第二个之前,返回负整数。
- 如果两个元素相等,返回0。
- 如果第一个元素应排在第二个之后,返回正整数。
例如,对整数数组进行升序排序的比较函数如下:
int compare_ints(const void *a, const void *b) {
const int *pa = (const int *)a;
const int *pb = (const int *)b;
return (*pa > *pb) - (*pa < *pb); // 简洁的返回方式
// 或 return *pa - *pb; (注意溢出风险)
}
调用qsort对整数数组arr(包含n个元素)进行排序:
qsort(arr, n, sizeof(int), compare_ints);
对于结构体数组,你需要在比较函数中根据具体的字段(如原子量、元素名)进行比较。作业4中,你需要根据命令行参数决定按哪个字段排序。
随机数生成示例 🎲
在演示qsort的示例中,我们使用了随机数来填充数组。以下是相关代码片段:
#include <stdlib.h>
#include <time.h>
srand(time(NULL)); // 用当前时间初始化随机数种子,确保每次运行结果不同
int num_elements = (rand() % 90) + 11; // 生成11到100之间的随机数作为数组元素个数
for (int i = 0; i < num_elements; i++) {
array[i] = rand() % 100; // 生成0到99之间的随机整数
}
srand(time(NULL)):基于当前时间设置随机种子,使每次程序运行时生成的随机数序列不同。rand():返回一个伪随机整数。- 通过取模运算(
%)可以将rand()的返回值限制在特定范围内。
总结与作业提示 📝

本节课中我们一起学习了:
- 字符串分割:通过手动遍历缓冲区来稳健地提取字母数字单词,并注意为每个单词分配独立内存。
- 链表操作:实现了有序链表的插入排序,涵盖了头部、中间和尾部插入三种情况。
- 数组排序:掌握了使用标准库函数
qsort对数组(包括结构体数组)进行排序的方法,重点是编写正确的比较函数。 - 辅助工具:简要了解了使用
srand和rand生成随机数的方法。
关于作业的最终提示:
- 作业3:最终截止日期已延长。请确保程序在成功时返回0,失败时返回1(通过
main函数的return或exit()函数)。 - 作业4:你将处理一个结构体数组。使用
qsort并根据命令行参数(如-dN,-dS,-dW)决定按哪个字段(原子序数、符号、原子量)排序。记得检查argv[1]是否以-开头来判断是否为选项。 - 通用建议:采用增量开发方式。先让程序处理标准输入或单个文件的核心逻辑(如排序),再逐步添加文件读取、参数解析等功能。这样更容易定位和修复错误。
祝大家编程顺利!
022:Linux与CSIF系统入门教程 🐧
在本节课中,我们将学习如何连接和使用加州大学戴维斯分校的CSIF(计算机科学教学设施)Linux系统。课程将涵盖从登录系统、浏览文件系统、到执行基本命令和文件操作的完整流程。内容专为初学者设计,力求简单明了。
概述 📋
本节教程将引导你完成在CSIF Linux环境下的基本操作。我们将从如何通过SSH安全登录开始,然后学习如何在文件系统中导航、查看文件内容、管理目录与文件,最后介绍一些编译和调试C程序的技巧。
登录CSIF系统 🔐
上一节我们介绍了课程目标,本节中我们来看看如何连接到CSIF系统。
要登录CSIF,你需要使用SSH(Secure Shell)命令。基本格式如下:
ssh [你的Kerberos用户名]@pc[数字].csif.ucdavis.edu
例如,连接到pc12主机:
ssh studentname@pc12.csif.ucdavis.edu
连接时,系统可能会询问你是否信任该主机,输入yes。接着会提示你输入密码。
重要前提:你必须先连接到校园VPN(如图书馆VPN或工程学院VPN)。学生通常使用图书馆VPN。未安装VPN将无法连接。
主机选择:pc后面的数字可以是01到43之间的任意两位数。建议选择11到30之间的主机,因为01到10号主机通常用于图形计算,可能不是你的常用环境。
如果连接某台主机遇到问题,可以查看CSIF状态页面。页面上会用红点表示主机宕机,绿点表示运行正常。
在本地与CSIF间传输文件 📁
成功登录后,你经常需要在本地电脑和CSIF之间传输文件。这需要使用scp(secure copy)命令,它基于SSH协议,能安全地复制文件。
从本地复制文件到CSIF:
命令格式为 scp [本地文件路径] [目标地址]。
scp myfile.c studentname@pc14.csif.ucdavis.edu:.
这个命令将本地的myfile.c文件复制到CSIF上pc14主机你的家目录(:后面的.代表当前目录,即你的家目录)。:符号是关键,它告诉scp后面是远程主机名。
从CSIF复制文件到本地:
只需将源和目标参数对调。
scp studentname@pc14.csif.ucdavis.edu:wordsort1.c .
这个命令将CSIF上pc14主机家目录中的wordsort1.c文件复制到你本地机器的当前目录。
关键点:scp命令需要在你自己的电脑终端(如Mac的Terminal或Windows的PowerShell)中执行,而不是在已经登录的CSIF终端里执行。
浏览文件系统与查看文件 👀
登录系统后,你需要知道如何查看当前有哪些文件和目录。
列出文件:使用ls命令可以列出当前目录下的文件和目录。
ls
要查看指定目录的内容,可以在ls后加上目录名。
ls old
获取详细信息:使用ls -l命令可以获取文件的详细信息列表。
ls -l
以下是ls -l输出中一行信息的解读:
-rwxr-xr-x 1 bishop student 2048 Jun 2 2023 filename.c
- 第一个字符:文件类型(
-普通文件,d目录,l符号链接)。 - 接下来9个字符:权限位,分为三组。
- 前三位
rwx:文件所有者(owner)的读(r)、写(w)、执行(x)权限。 - 中间三位
r-x:文件所属用户组(group)的权限。 - 后三位
r-x:其他所有用户(others)的权限。 -表示无相应权限。
- 前三位
- 数字
1:指向此文件的链接数(可理解为有多少个别名指向它)。 bishop:文件所有者。student:文件所属用户组。2048:文件大小(字节)。Jun 2 2023:最后修改日期。filename.c:文件名。
权限检查顺序是:先检查你是否是所有者,如果是则应用所有者权限;如果不是,则检查你是否在所属用户组中,是则应用组权限;否则应用其他人权限。
查看隐藏文件:以点.开头的文件是隐藏文件(通常是配置文件)。使用ls -a可以显示所有文件,包括隐藏文件。
ls -a
常见的隐藏文件有:
.login:登录时执行的脚本。.forward:邮件转发配置。.viminfo:Vim编辑器的历史信息。.filename.swp:Vim编辑器崩溃时生成的交换文件,用于恢复未保存的工作。可以使用vim -r filename.c来恢复。
递归列出目录内容:使用ls -R可以递归地列出当前目录及其所有子目录下的内容。
ls -R
可以组合选项,例如ls -laR可以以长格式递归列出所有文件(包括隐藏文件)。
查看文件内容 📄
知道文件在哪之后,接下来学习如何查看文件内容。
直接显示全部内容:使用cat命令。
cat filename.c
如果文件很长,内容会快速滚过屏幕。
分页查看:使用more命令可以一屏一屏地查看文件。
more filename.c
在more的浏览界面中:
- 按空格键:向下翻一屏。
- 按回车键:向下翻一行。
- 按
Ctrl+B或b:向上翻一屏。 - 按
Ctrl+F或f:向下翻一屏。 - 按
q:退出。
查看文件首尾:使用head和tail命令。
head -20 filename.c # 查看文件前20行
tail -15 filename.c # 查看文件最后15行
tail还有一个实用选项-f(follow),可以实时监控文件尾部新增的内容,常用于查看日志。
tail -f server.log
使用 Ctrl+C 可以终止tail -f命令。
理解Linux目录结构 🌳
Linux文件系统是一个树形结构,根目录(/)位于最顶端。
以下是部分重要目录:
/:根目录,一切目录的起点。/bin:存放基本命令二进制文件,如bash。/home:存放所有普通用户的家目录,例如你的目录可能是/home/studentname。/lib:存放系统库文件。/usr/bin:存放用户安装的大多数程序。/usr/local:存放本地安装的软件。
路径表示方法:
- 绝对路径:从根目录
/开始的完整路径,如/home/bishop/ecs36a/hw1.c。 - 相对路径:相对于你当前所在目录的路径。
.:代表当前目录。..:代表上级目录(父目录)。~:代表当前用户的家目录。- 例如,当前在
/home/bishop,想访问/home/psared,相对路径可以是../psared。
在文件系统中导航 🧭
查看当前目录:使用pwd(Print Working Directory)命令。
pwd
切换目录:使用cd(Change Directory)命令。
cd /home/bishop/ecs36a # 使用绝对路径
cd ../lec17 # 使用相对路径,先返回上级目录再进入lec17
cd ~ # 切换到家目录
cd # 不加参数,同样切换到家目录
创建与删除目录 📂
创建目录:使用mkdir命令。
mkdir new_folder
如果目录已存在,会报错。
删除目录:使用rmdir命令。注意:rmdir只能删除空目录。
rmdir empty_folder
如果目录非空,需要先删除其中的所有文件,再删除目录本身。
管理文件:创建、复制、移动与删除 ⚙️
创建空文件或更新文件时间戳:使用touch命令。如果文件不存在则创建;如果存在,则更新其访问和修改时间。
touch newfile.txt
复制文件:使用cp命令。
cp source.c destination.c # 复制文件
cp -r sourcedir/ destdir/ # 递归复制整个目录
警告:如果目标文件已存在,cp会直接覆盖它。安全起见,可以使用-i(interactive)选项,覆盖前会询问确认。
cp -i source.c destination.c
移动或重命名文件:使用mv命令。它同时具备移动和重命名功能。
mv oldname.c newname.c # 重命名
mv file.c ../otherdir/ # 移动到其他目录
与cp类似,mv也会静默覆盖已存在的目标文件。使用mv -i可以在覆盖前询问。
删除文件:使用rm命令。这是非常危险的命令,删除后通常无法恢复。
rm unwanted_file.c
rm *.tmp # 删除所有.tmp结尾的文件
极度重要的安全建议:
- 始终使用
rm -i命令,它会在删除每个文件前询问你。 - 在输入通配符(如
*)时要格外小心。错误的空格可能导致灾难性后果,例如rm abc *本意是删除abc开头的文件,但空格导致它试图删除abc和当前目录所有文件(*)。
rm -i abc* # 正确的安全做法
编译与调试C程序 🛠️
最后,我们来看看如何在CSIF上编译和调试C程序。


基本编译:使用gcc编译器。
gcc -pedantic -Wall -o myprogram myprogram.c
-pedantic:严格遵循ANSI C标准。-Wall:启用所有常用警告信息。-o myprogram:指定输出的可执行文件名为myprogram。如果不加-o选项,默认生成名为a.out的可执行文件。
为调试做准备:要使用GDB调试器,必须在编译时加上-g选项,以便在可执行文件中包含调试符号信息。
gcc -pedantic -Wall -g -o myprogram myprogram.c
启动调试器:
gdb myprogram
使用命令历史:在终端中,可以使用上下箭头键翻阅之前执行过的命令。history命令可以列出所有历史命令。!加命令开头可以执行最近一条以该字符串开头的命令,例如!gcc会重新执行上一次的gcc命令。
总结 🎯

本节课中我们一起学习了在CSIF Linux环境下进行编程工作的完整基础流程。我们从SSH登录和文件传输开始,掌握了使用ls、cat、more、head、tail等命令查看文件和目录。我们理解了Linux的树形目录结构,并学会了使用cd、pwd、mkdir、rmdir进行导航和管理。在文件操作方面,我们学习了touch、cp、mv以及需要格外谨慎使用的rm命令。最后,我们回顾了使用gcc编译C程序并添加-g选项为调试做准备的基本步骤。牢记-i交互选项在cp、mv、rm命令中的使用,是保护你工作成果的关键安全习惯。
023:总结与C预处理器详解 🎓

在本节课中,我们将回顾课程公告,分析一个常见的编程错误,并深入探讨C语言预处理器的工作原理。这是本课程的最后一节新内容讲解,之后我们将进入复习阶段。
课程公告与安排 📢
首先,关于课程安排有几个重要通知。1月2日的讲座录像链接已发布在Canvas页面。5月31日的视频之前不可见,现已开放。
以下是具体安排:
- 期末考试学习指南和一份模拟试卷已发布。模拟试卷的答案可在Canvas上找到。
- 将增加两次额外的办公时间:周二11:00-11:50和周四11:00-14:00。周三的办公时间可能会调整至10:00-11:00,具体信息会发布在Canvas。
- 周三的课程将作为复习课,欢迎大家带着问题前来。
- 关于成绩:目前Canvas上显示的成绩仅包含前两次作业和期中考试。请记住,还有作业3、4和期末考试未计入。最终成绩会进行曲线调整,目前计划采用平方根曲线(例如,64分将调整为80分)。
- 关于作业:作业4(生日悖论模拟)是额外学分作业,由于涉及随机数,无法通过GradeScope自动评分,请通过Canvas提交,我会手动运行检查。
- 额外学分的作用是:在最终评定等级时,如果你处于边界线附近,额外学分将帮助我决定是否将你的等级提升一档。
一个常见的排序错误分析 ⚠️
上一节我们介绍了qsort函数,本节中我们来看看一个在使用qsort对double类型数组排序时容易犯的错误。
错误代码如下,其本意是比较两个double值:
int cmp(const void *x, const void *y) {
const double *px = x;
const double *py = y;
return *px - *py; // 错误做法
}
问题在于,qsort的比较函数要求返回int类型,而两个double值相减的结果(如2.3 - 2.1 = 0.2)被截断为整数0,导致本应有序的元素被判定为相等。
正确的做法是使用显式的比较:
int cmp(const void *x, const void *y) {
const double *px = x;
const double *py = y;
if (*px > *py) return 1;
if (*px < *py) return -1;
return 0;
}
核心要点:当混合使用double和int类型时需格外小心。对于指针参数,应在函数内部进行类型转换(const double *px = x;),虽然直接使用double*参数编译器可能只给出警告,但遵循标准做法更清晰安全。
条件运算符(三元运算符) ?
C语言中还有一个我们未讨论的运算符:条件运算符(? :)。
其语法为:
A ? B : C
运算规则:首先计算表达式A。如果A为真(非零),则计算表达式B并以其结果作为整个表达式的结果;如果A为假(零),则计算表达式C并以其结果作为整个表达式的结果。关键点在于,B和C只有一个会被求值。
这本质上是if-else语句的表达式形式,允许在赋值等场景中更简洁地使用。例如:
x = (a != 0) ? b++ : c--;
等效于:
if (a != 0) {
x = b;
b++;
} else {
x = c;
c--;
}
记住:条件运算符中,B和C只有一个会被执行,永远不会两者都执行。
C预处理器深度解析 🔧
现在,我们来深入回顾C预处理器,这是C编程中非常基础且重要的部分。预处理器(cpp)在编译器之前运行,执行纯粹的文本替换,它并不理解C语言的语法。
宏定义(#define)
最基本的用途是定义常量或简化代码。
定义常量:
#define PI 3.14159265
预处理器会将代码中所有独立的PI替换为3.14159265。这样做的好处是:一,避免重复输入;二,需要修改值时只需改动一处。


增加代码可读性:
#define EOS '\0' // 字符串结束符
#define NULL ((void*)0) // 空指针
这样,代码中的EOS和NULL就有了明确的语义,而不是令人困惑的0。
必须注意的陷阱:
考虑以下宏定义:
#define SIDE 8+2
#define BOARD SIDE * SIDE
我们的本意是BOARD代表(8+2)*(8+2)=100。但预处理器进行文本替换后,代码变为:
8+2 * 8+2
根据C运算符优先级,这实际计算为8 + (2*8) + 2 = 26。
解决方案:为宏定义中的表达式加上括号。
#define SIDE (8+2)
#define BOARD (SIDE * SIDE)
通用规则:除非宏是简单的单个数字或标识符,否则应该用括号将整个定义体括起来。
带参数的宏
宏可以像函数一样接受参数。
#define BETWEEN(X, LO, HI) ((X) >= (LO) && (X) <= (HI))
使用示例:BETWEEN(4, 0, 9) 会被替换为 ((4) >= (0) && (4) <= (9)),结果为真。
重要警告:参数X在宏中每次出现都会被替换和求值。如果这样调用:
x = 9;
if (BETWEEN(x++, 0, 9)) { ... }
替换后变为:
if (((x++) >= (0) && (x++) <= (9))) { ... }
x++会被求值两次,导致逻辑错误和x的意外递增。因此,在带参数的宏中,每个参数本身也应该用括号括起来。
取消宏定义(#undef)
用于移除一个已定义的宏,通常在需要重新定义同名宏时使用。
#undef XYZ
#define XYZ 100
文件包含(#include)
用于将其他文件的内容插入当前位置。
#include <file.h>:在系统标准目录中查找file.h。#include "file.h":首先在当前文件所在目录查找,如果没找到,再去系统标准目录查找(GCC的行为)。- 可以使用
-I编译器选项添加额外的头文件搜索目录:gcc -I/my/include/dir ...。
条件编译
这是预处理器最强大的功能之一,允许根据条件决定编译哪些代码。
基本形式:
#ifdef MACRO_NAME
// 如果MACRO_NAME已定义,则编译此部分代码
#else
// 如果MACRO_NAME未定义,则编译此部分代码
#endif
变体包括#ifndef(如果未定义)、#if defined(MACRO)(等价于#ifdef)以及#elif。
条件编译的常见用途:
-
调试代码:
#define DEBUG 1 // 调试时启用 // ... #if DEBUG printf("调试信息: x = %d\n", x); #endif更灵活的方法是通过编译器选项定义宏:
gcc -DDEBUG ...。发布时去掉-DDEBUG选项,所有调试代码就不会被编译进最终程序,节省空间。 -
代码注释(临时禁用大段代码):
#if 0 // 被注释掉的大段代码 // 编译器会忽略这部分 #endif这比用
/* ... */注释多行代码更安全,因为它可以嵌套。 -
平台/系统适配:
#ifdef __linux__ // Linux系统专用代码 #elif defined(_WIN32) // Windows系统专用代码 #else #error "本程序仅支持Linux或Windows系统" #endif编译器通常会预定义一些标识系统或环境的宏,利用它们可以编写可移植的代码。
总结 📝
本节课中我们一起学习了以下内容:
- 了解了期末阶段的课程安排和注意事项。
- 分析了一个在使用
qsort比较double值时因类型转换导致的常见错误,并掌握了正确的比较方法。 - 学习了C语言的条件运算符(
? :)及其求值规则(只执行一个分支)。 - 深入探讨了C预处理器,理解了其文本替换的本质。
- 掌握了
#define定义常量和带参数宏的方法及加括号的重要原则。 - 了解了
#include的两种形式及其查找路径的差异。 - 学习了条件编译(
#ifdef,#ifndef,#if等)的语法及其在调试、代码管理和跨平台开发中的强大用途。


预处理器是C语言工具链中不可或缺的一部分,熟练使用它能极大提高代码的灵活性、可读性和可维护性。

浙公网安备 33010602011771号