加州理工 CS367 C 语言系统编程笔记(一)
001:变量与基础语法 🚀
在本节课中,我们将快速概览C语言的基础知识,包括如何编写一个简单的程序、理解变量声明以及C语言的基本数据类型。我们将通过对比Java来帮助你快速上手。
概述 📋
C语言是一种强大且广泛使用的编程语言,尤其在系统编程领域。本节课我们将学习C语言的基础语法,包括如何编写“Hello, World!”程序、声明变量以及使用基本数据类型。我们将通过实际的代码示例来加深理解。
课程内容 📚
1. 编写第一个C程序
上一节我们介绍了课程的整体安排,本节中我们来看看如何编写第一个C程序。与Java类似,C程序也需要一个入口点,即main函数。
以下是一个简单的“Hello, World!”程序:
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
代码解释:
-
#include <stdio.h>:这是一个预处理器指令,用于包含标准输入输出库,使我们能够使用printf函数。 -
int main():这是程序的入口点。int表示函数返回一个整数值,通常用于指示程序执行状态(0表示成功,非0表示错误)。 -
printf("Hello, World!\n");:printf函数用于输出文本。\n是换行符。 -
return 0;:表示程序成功执行。
2. 编译和运行C程序
在编写完C程序后,我们需要将其编译成可执行文件。本节中我们将学习如何使用GCC编译器。
以下是编译和运行C程序的步骤:
-
编译程序:使用GCC编译器将C源代码编译成可执行文件。
gcc hello.c -o hello-
gcc:GNU C编译器。 -
hello.c:源代码文件。 -
-o hello:指定输出文件名为hello。
-
-
运行程序:执行生成的可执行文件。
./hello
3. 变量与数据类型
在C语言中,变量用于存储数据。本节中我们将学习如何声明变量以及C语言的基本数据类型。
以下是C语言中常见的数据类型及其声明方式:
#include <stdio.h>
#include <stdbool.h>
int main() {
// 整数类型
int myInt = 100;
printf("Integer: %d\n", myInt);
// 字符类型
char myChar = 'A';
printf("Character: %c\n", myChar);
// 浮点数类型
float myFloat = 3.14f;
printf("Float: %f\n", myFloat);
// 双精度浮点数
double myDouble = 3.1415926535;
printf("Double: %lf\n", myDouble);
// 布尔类型(需要包含stdbool.h)
bool myBool = true;
printf("Boolean: %d\n", myBool);
// 字符串(字符数组)
char myString[] = "Hello";
printf("String (array): %s\n", myString);
// 字符串(字符指针)
char *myStringPtr = "World";
printf("String (pointer): %s\n", myStringPtr);
// 长整数
long myLong = 100000L;
printf("Long: %ld\n", myLong);
// 短整数
short myShort = 10;
printf("Short: %hd\n", myShort);
// 无符号整数
unsigned int myUnsignedInt = 200;
printf("Unsigned Int: %u\n", myUnsignedInt);
// 长长整数
long long myLongLong = 10000000000LL;
printf("Long Long: %lld\n", myLongLong);
return 0;
}
代码解释:
-
整数类型:
int用于存储整数,%d是格式化输出整数的占位符。 -
字符类型:
char用于存储单个字符,%c是格式化输出字符的占位符。 -
浮点数类型:
float和double用于存储小数,%f和%lf是格式化输出浮点数的占位符。 -
布尔类型:C语言中没有原生的布尔类型,但可以通过
stdbool.h库使用bool类型。布尔值实际上是整数(0表示false,非0表示true)。 -
字符串:C语言中没有原生的字符串类型,字符串通常通过字符数组或字符指针表示。
-
其他类型:
long、short、unsigned int和long long用于存储不同范围和精度的整数。
4. 使用Shell环境
在C语言编程中,我们经常使用Shell环境来编译和运行程序。本节中我们将学习一些基本的Shell命令。
以下是常用的Shell命令:
-
列出目录内容:
ls -
编译C程序:
gcc program.c -o program -
运行可执行文件:
./program -
查看命令帮助:
man command_name或
command_name --help -
清除终端屏幕:
clear
总结 🎯
本节课中我们一起学习了C语言的基础语法,包括如何编写和运行一个简单的C程序、声明变量以及使用基本数据类型。我们还介绍了如何使用Shell环境来编译和运行程序。通过对比Java,你可以看到C语言在语法上与Java有许多相似之处,但也存在一些关键差异,例如C语言中没有原生的布尔类型和字符串类型。
在接下来的课程中,我们将深入探讨C语言的更多高级特性,如控制结构、函数和指针。希望本节课的内容能帮助你快速上手C语言编程!
002:运算符与基本概念
在本节课中,我们将继续学习C语言的基础知识,重点介绍运算符、数据类型转换以及一些核心编程概念。我们将通过对比Java语言,帮助你理解C语言中的相似与不同之处。
概述
上一节我们介绍了C语言的基本类型和变量声明。本节中,我们将深入探讨C语言中的各种运算符,包括算术运算符、关系运算符、逻辑运算符和位运算符。我们还将学习数据类型转换(截断)以及处理浮点数时的注意事项。
算术运算符
C语言内置了多种基本运算符,它们与Java中的运算符非常相似。以下是一个展示基本算术运算的示例。
#include <stdio.h>
int main() {
int a = 15;
int b = 4;
int sum = a + b;
printf("Sum: %d\n", sum);
int difference = a - b;
printf("Difference: %d\n", difference);
int product = a * b;
printf("Product: %d\n", product);
int quotient = a / b;
printf("Quotient: %d\n", quotient);
int remainder = a % b;
printf("Remainder: %d\n", remainder);
return 0;
}
编译并运行此代码,你将看到预期的算术结果。整数除法会截断小数部分,这与Java的行为一致。
递增与递减运算符
递增(++)和递减(--)运算符有两种形式:前置和后置。它们的行为取决于在语句中的位置。
#include <stdio.h>
int main() {
int a = 5;
int result;
// 前置递增
result = a;
printf("Before pre-increment: %d\n", result);
result = ++a; // 先递增,再赋值
printf("During pre-increment: %d\n", result);
printf("After pre-increment: %d\n", a);
// 重置
a = 5;
result = a;
printf("Before post-increment: %d\n", result);
result = a++; // 先赋值,再递增
printf("During post-increment: %d\n", result);
printf("After post-increment: %d\n", a);
// 递减操作同理
a = 5;
result = a;
printf("Before pre-decrement: %d\n", result);
result = --a;
printf("During pre-decrement: %d\n", result);
printf("After pre-decrement: %d\n", a);
a = 5;
result = a;
printf("Before post-decrement: %d\n", result);
result = a--;
printf("During post-decrement: %d\n", result);
printf("After post-decrement: %d\n", a);
return 0;
}
前置操作先改变变量值,再使用该值;后置操作先使用当前值,再改变变量。
关系与相等运算符
关系运算符用于比较两个值,返回一个整数结果(0表示假,非0表示真,通常为1)。
#include <stdio.h>
int main() {
int a = 10;
int b = 20;
int result;
result = (a == b);
printf("%d == %d: %d\n", a, b, result);
result = (a != b);
printf("%d != %d: %d\n", a, b, result);
result = (a > b);
printf("%d > %d: %d\n", a, b, result);
result = (a < b);
printf("%d < %d: %d\n", a, b, result);
result = (a >= b);
printf("%d >= %d: %d\n", a, b, result);
result = (a <= b);
printf("%d <= %d: %d\n", a, b, result);
return 0;
}
C语言没有内置的布尔类型,因此用整数代表逻辑值。
逻辑运算符
逻辑运算符(&&, ||, !)用于组合或取反条件表达式,它们同样操作整数并返回整数结果。
#include <stdio.h>
int main() {
int a = 1; // 代表真
int b = 0; // 代表假
int result;
result = a && b;
printf("%d && %d = %d\n", a, b, result);
result = a || b;
printf("%d || %d = %d\n", a, b, result);
result = !a;
printf("!%d = %d\n", a, result);
result = !b;
printf("!%d = %d\n", b, result);
return 0;
}
位运算符
位运算符直接对整数的二进制位进行操作。理解二进制表示对于掌握这些运算符至关重要。
#include <stdio.h>
int main() {
unsigned int a = 12; // 二进制: 1100
unsigned int b = 5; // 二进制: 0101
unsigned int result;
printf("a = 0x%x, b = 0x%x\n", a, b);
result = a & b;
printf("a & b = 0x%x\n", result);
result = a | b;
printf("a | b = 0x%x\n", result);
result = a ^ b;
printf("a ^ b = 0x%x\n", result);
result = ~a;
printf("~a = 0x%x\n", result);
result = a << 1;
printf("a << 1 = 0x%x\n", result);
result = a >> 1;
printf("a >> 1 = 0x%x\n", result);
return 0;
}
使用unsigned类型可以确保在进行位操作时,数值被当作纯二进制序列处理,避免符号位带来的意外影响。
数据截断
当将一个较大类型的值赋给一个较小类型的变量时,会发生截断。多余的数据会被丢弃。
#include <stdio.h>
int main() {
int i = 321;
char c = i; // 整数截断为字符
printf("int %d -> char %d\n", i, c);
double pi = 3.14159;
int intPi = pi; // 浮点数截断为整数,丢弃小数部分
printf("double %f -> int %d\n", pi, intPi);
double precise = 123.4567890123;
float approx = precise; // 双精度截断为单精度,损失精度
printf("double %.10f -> float %.10f\n", precise, approx);
return 0;
}
浮点数近似与相等性比较
浮点数(float和double)在计算机中是近似值。直接使用相等运算符(==)比较它们可能导致错误结果。
#include <stdio.h>
#include <math.h>
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/86d2e7e4a1e2be61d7319ea32b06403f_6.png>
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/86d2e7e4a1e2be61d7319ea32b06403f_7.png>
int main() {
float a = 0.1f;
float b = 0.8f;
float sum = a + b;
float c = 0.9f;
// 错误的比较方式
int resultWrong = (sum == c);
printf("sum=%f, c=%f, (sum == c) = %d\n", sum, c, resultWrong);
// 正确的比较方式:使用阈值
float threshold = 0.0001f;
int resultCorrect = fabsf(sum - c) < threshold;
printf("|sum - c| < %f = %d\n", threshold, resultCorrect);
// 对double类型同理
double x = 0.1;
double y = 0.2;
double sumDouble = x + y;
double z = 0.3;
int resultWrongDouble = (sumDouble == z);
printf("sumDouble=%f, z=%f, (sumDouble == z) = %d\n", sumDouble, z, resultWrongDouble);
double thresholdDouble = 0.0000001;
int resultCorrectDouble = fabs(sumDouble - z) < thresholdDouble;
printf("|sumDouble - z| < %f = %d\n", thresholdDouble, resultCorrectDouble);
return 0;
}
永远不要直接使用==比较浮点数。应该计算两个数的绝对差值,并检查该差值是否小于一个可接受的微小阈值。
复合赋值运算符
复合赋值运算符将一个运算和赋值合并为一步。
#include <stdio.h>
int main() {
int a = 10;
int temp;
temp = a; a += 5; printf("a += 5: %d -> %d\n", temp, a);
temp = a; a -= 3; printf("a -= 3: %d -> %d\n", temp, a);
temp = a; a *= 2; printf("a *= 2: %d -> %d\n", temp, a);
temp = a; a /= 4; printf("a /= 4: %d -> %d\n", temp, a);
temp = a; a %= 3; printf("a %%= 3: %d -> %d\n", temp, a); // 注意%%用于打印%
a = 1;
temp = a; a <<= 2; printf("a <<= 2: %d -> %d\n", temp, a);
temp = a; a >>= 1; printf("a >>= 1: %d -> %d\n", temp, a);
a = 6;
temp = a; a &= 3; printf("a &= 3: %d -> %d\n", temp, a);
temp = a; a |= 2; printf("a |= 2: %d -> %d\n", temp, a);
temp = a; a ^= 1; printf("a ^= 1: %d -> %d\n", temp, a);
return 0;
}
选择使用复合赋值运算符还是分开写的表达式,应以代码的清晰度和可读性为首要标准。
C语言的安全性问题
与Java不同,C语言缺乏许多安全保护机制。例如,C允许你使用未初始化的变量,这会导致不可预测的行为。
#include <stdio.h>
int main() {
int uninitializedVariable;
printf("未初始化变量的值: %d\n", uninitializedVariable); // 输出是随机的
return 0;
}
编译器不会报错,但程序会读取该内存地址中残留的任意值。这使得C语言编程需要格外小心,以确保代码的正确性和稳定性。
总结
本节课中我们一起学习了C语言的核心运算符和重要概念。我们详细探讨了算术运算符、递增递减运算符、关系与逻辑运算符以及位运算符。我们理解了数据截断的含义,并掌握了安全比较浮点数的方法。最后,我们认识了C语言中复合赋值运算符的用法,并意识到了C语言因缺乏安全机制而需要程序员更加谨慎。这些基础知识是将Java知识迁移到C语言环境的关键一步。在接下来的课程中,我们将学习控制结构。
003:控制结构、复杂类型与指针
在本节课中,我们将继续学习C语言,将Java中的知识映射到C语言上。我们将探讨控制结构、复杂数据类型(如结构体和数组)以及指针的概念。完成本次课程后,我们将布置第一个实验,让大家进行一些基础的C语言编程。
控制结构
上一节我们介绍了运算符,本节中我们来看看如何组织语句的执行流程,即控制结构。
为了将多个语句组合在一起执行,我们使用花括号,这与Java非常相似。花括号可以创建一个代码块,其中的变量共享作用域。无论是选择语句还是循环语句,默认情况下,如果不使用花括号,则只影响紧随其后的一条语句。花括号的价值在于,它允许你将多个语句组合成一个原子实体,编译器会将其作为一个整体执行。
花括号的打开和关闭方式与Java完全相同。
选择语句
选择语句是单选择、双选择和多选择语句的总称,你可能更熟悉它们的名字:if、if-else 或嵌套的 if 语句。
以下是选择语句的示例代码:
#include <stdio.h>
int main() {
int number = 15;
// 单选择语句
if (number > 10) {
printf("Number is greater than 10\n");
}
// 双选择语句
if (number > 10) {
printf("Number is greater than 10\n");
} else {
printf("Number is not greater than 10\n");
}
// 多选择语句(嵌套if-else)
if (number < 10) {
printf("Number is less than 10\n");
} else if (number > 10 && number < 20) {
printf("Number is between 10 and 20\n");
} else {
printf("Number is greater than or equal to 20\n");
}
return 0;
}
单选择语句是一个决策点,决定是否执行某个代码块或语句。双选择语句保证会得到两种结果之一:如果条件为真,则执行 if 后的代码;如果为假,则执行 else 后的代码。多选择语句(嵌套 if-else)允许在 else 后附加另一个 if,直到最后的 else 作为默认情况。
Switch语句
除了嵌套的多选择语句,还可以使用 switch 控制结构来表示多选择。switch 语句基于相等性检查,而嵌套选择语句可以检查相等性和关系属性(如大于或小于)。switch 只适用于检查相等性的情况。
switch 语句使用标签(case)进行跳转,类似于汇编语言中的标签跳转。因此,需要使用 break 来跳出 switch 块,否则会继续执行后续的代码块。
以下是 switch 语句的示例:
#include <stdio.h>
int main() {
int number = 3;
switch (number) {
case 1:
printf("Number is one\n");
break;
case 2:
printf("Number is two\n");
break;
case 3:
printf("Number is three\n");
break;
default:
printf("Number is not one, two, or three\n");
break;
}
// 等效的嵌套if-else
if (number == 1) {
printf("Number is one\n");
} else if (number == 2) {
printf("Number is two\n");
} else if (number == 3) {
printf("Number is three\n");
} else {
printf("Number is not one, two, or three\n");
}
return 0;
}
通常,如果可能,更推荐使用嵌套的多选择语句,这是更好的软件工程实践。有些语言(如Python)甚至不支持 switch 语句。
循环语句
循环语句在C语言中看起来几乎与Java完全相同。Java从C语言继承了许多语法。
以下是循环语句的示例:
#include <stdio.h>
int main() {
// for循环
for (int i = 1; i <= 5; i++) {
printf("For loop: %d\n", i);
}
// while循环
int j = 1;
while (j <= 5) {
printf("While loop: %d\n", j);
j++;
}
// do-while循环
int k = 6;
do {
printf("Do-while loop: %d\n", k);
k++;
} while (k <= 5);
return 0;
}
for 循环的特点是所有管理循环逻辑的数据(控制变量的初始化、条件判断和更新)都放在 for 语句的括号内。while 循环在条件为真时重复执行代码块。do-while 循环保证至少执行一次循环体,即使条件初始为假。
通常,如果事先知道需要循环的次数,使用 for 循环;如果不知道,使用 while 循环。do-while 循环适用于需要至少执行一次的情况。
Break和Continue
break 和 continue 在循环中的行为与Java完全相同。break 用于跳出循环,continue 用于跳过当前迭代的剩余语句,直接进入下一次迭代。
以下是 break 和 continue 的示例:
#include <stdio.h>
int main() {
// break示例
int i;
for (i = 1; i <= 10; i++) {
if (i == 5) {
printf("Breaking at %d\n", i);
break;
}
}
printf("Loop ended at i = %d\n", i);
// continue示例
for (int j = 1; j <= 10; j++) {
if (j % 2 == 0) {
continue; // 跳过偶数
}
printf("Odd number: %d\n", j);
}
return 0;
}
在第一个循环中,当 i 等于5时,使用 break 跳出循环。在第二个循环中,当 j 是偶数时,使用 continue 跳过打印语句,只打印奇数。
复杂数据类型
我们已经讨论了原始数据类型(如整数、短整型、长整型、浮点数、双精度浮点数和字符)。现在,让我们看看如何创建更类似于Java中对象和数组的数据结构。
结构体
C语言没有对象,但有一种称为结构体(struct)的原型,它是对象的前身。结构体允许你创建包含不同类型数据的集合。
以下是结构体的基本示例:
#include <stdio.h>
// 定义一个结构体
struct fraction {
int numerator;
int denominator;
};
// 辅助函数:打印分数
void printFraction(struct fraction f) {
printf("Fraction: %d/%d\n", f.numerator, f.denominator);
}
int main() {
// 声明结构体变量
struct fraction f1, f2;
// 打印未初始化的值(危险!)
printf("Before initialization:\n");
printFraction(f1);
// 设置结构体字段
f1.numerator = 22;
f1.denominator = 7;
printf("After initialization:\n");
printFraction(f1);
// 打印未初始化的f2
printf("Before copying:\n");
printFraction(f2);
// 使用赋值运算符复制结构体
f2 = f1;
printf("After copying:\n");
printFraction(f2);
return 0;
}
结构体使用 struct 关键字定义,后跟结构体名称和花括号内的字段。你可以像访问对象属性一样,使用点运算符(.)访问结构体的字段。C语言中,结构体赋值是字段的逐位复制。
Typedef
每次引用结构体时都需要写 struct fraction,为了简化,可以使用 typedef 为结构体创建别名。
以下是使用 typedef 的示例:
#include <stdio.h>
// 使用typedef为结构体创建别名
typedef struct {
int numerator;
int denominator;
} Fraction;
// 辅助函数:打印分数
void printFraction(Fraction f) {
printf("Fraction: %d/%d\n", f.numerator, f.denominator);
}
int main() {
// 使用别名声明变量
Fraction f1 = {22, 7};
printFraction(f1);
return 0;
}
typedef 允许你使用更简洁的名称(如 Fraction)来代替 struct fraction。这是C语言中最接近对象的方式。
结构体允许混合不同类型的数据,而数组则要求所有元素类型相同。
数组
数组在C语言中与Java类似,但有一个重要区别:C语言中的数组不知道自己的长度,而Java数组知道。因此,在C语言中,你需要自己跟踪数组的大小。
以下是数组的基本示例:
#include <stdio.h>
// 辅助函数:打印整数数组
void printIntArray(int arr[], int size) {
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
// 声明数组
int numbers[10];
// 打印未初始化的数组(危险!)
printf("Initial state (uninitialized):\n");
printIntArray(numbers, 10);
// 设置数组值
for (int i = 0; i < 10; i++) {
numbers[i] = i * 2;
}
printf("After initialization:\n");
printIntArray(numbers, 10);
// 字符数组(字符串)
char greeting[] = "Hello, World!";
printf("String: %s\n", greeting);
// 使用数组字面量初始化
int primes[] = {2, 3, 5, 7, 11};
printf("Primes: ");
printIntArray(primes, 5);
// 危险操作:访问越界索引
printf("Dangerous access (index 15): %d\n", numbers[15]);
return 0;
}
数组使用方括号([])声明和索引。字符数组可以用字符串字面量初始化,字符串以空字符(\0)结尾。数组字面量使用花括号初始化。C语言不检查数组越界,访问无效索引可能导致未定义行为。
预处理器指令#define
为了安全地使用数组大小,可以使用预处理器指令 #define 定义常量。
以下是使用 #define 的示例:
#include <stdio.h>
#define ARRAY_SIZE 10
void printIntArray(int arr[], int size) {
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int numbers[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
numbers[i] = i * i;
}
printIntArray(numbers, ARRAY_SIZE);
return 0;
}
#define 创建一个标签(如 ARRAY_SIZE),在编译时替换为对应的数值。这允许你使用常量值,便于修改且不可变。
多维数组
与Java类似,C语言支持多维数组。多维数组实际上是数组的数组。
以下是一个二维数组的示例:
#include <stdio.h>
#define SIZE 10
int main() {
int board[SIZE][SIZE];
// 初始化二维数组
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
board[i][j] = i * SIZE + j;
}
}
// 高效访问(行优先)
printf("Row-major order (efficient):\n");
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
printf("%2d ", board[i][j]);
}
printf("\n");
}
// 低效访问(列优先)
printf("\nColumn-major order (inefficient):\n");
for (int j = 0; j < SIZE; j++) {
for (int i = 0; i < SIZE; i++) {
printf("%2d ", board[i][j]);
}
printf("\n");
}
return 0;
}
多维数组在内存中按行优先顺序连续存储。高效访问应遵循行优先顺序,以减少内存跳转。
结构体数组
可以创建结构体数组,类似于Java中的对象数组。
以下是结构体数组的示例:
#include <stdio.h>
#define ARRAY_SIZE 1000
typedef struct {
int numerator;
int denominator;
} Fraction;
void printFraction(Fraction f) {
printf("%d/%d ", f.numerator, f.denominator);
}
int main() {
Fraction numbers[ARRAY_SIZE];
// 设置前三个元素
numbers[0].numerator = 1;
numbers[0].denominator = 2;
numbers[1].numerator = 3;
numbers[1].denominator = 4;
numbers[2].numerator = 5;
numbers[2].denominator = 6;
// 打印前三个元素
for (int i = 0; i < 3; i++) {
printFraction(numbers[i]);
}
printf("\n");
// 危险操作:访问越界索引
// printf("Dangerous: ");
// printFraction(numbers[ARRAY_SIZE]); // 可能导致崩溃
return 0;
}
结构体数组允许你存储多个结构体实例。与普通数组一样,需要小心越界访问。
指针
指针是C语言的核心概念之一。它们允许你直接操作内存地址,实现按引用传递。
指针基础
指针是存储内存地址的变量。使用 & 运算符获取变量的地址,使用 * 运算符声明指针或解引用指针。
以下是指针的基本示例:
#include <stdio.h>
int main() {
int a = 5;
int *pointer; // 声明指向整数的指针
pointer = &a; // 将a的地址赋值给指针
printf("Value of a: %d\n", a);
printf("Address of a: %p\n", (void*)&a);
printf("Value of pointer: %p\n", (void*)pointer);
printf("Dereferenced pointer: %d\n", *pointer);
return 0;
}
指针类型告诉系统该地址存储的数据类型所占用的字节数。解引用指针可以访问或修改该内存地址存储的值。
通过指针修改变量
可以通过指针修改变量的值。
以下是通过指针修改变量的示例:
#include <stdio.h>
int main() {
int value = 5;
int *ptr = &value;
printf("Original value: %d\n", value);
// 通过指针修改值
*ptr = 10;
printf("Modified value: %d\n", value);
return 0;
}
通过解引用指针并赋值,可以修改原始变量的值。
结构体指针
可以使用指针指向结构体,并通过箭头运算符(->)访问结构体字段。
以下是结构体指针的示例:
#include <stdio.h>
typedef struct {
int numerator;
int denominator;
} Fraction;
void printFraction(Fraction *f) {
printf("Fraction: %d/%d\n", f->numerator, f->denominator);
}
int main() {
Fraction f1 = {22, 7};
Fraction *ptr = &f1;
printFraction(ptr);
return 0;
}
当函数参数是指向结构体的指针时,使用箭头运算符访问字段。如果参数是结构体本身,则使用点运算符。
指针的指针
指针可以指向其他指针,形成多级间接引用。
以下是指针的指针的示例:
#include <stdio.h>
int main() {
int value = 5;
int *ptr = &value;
int **ptrToPtr = &ptr;
printf("Value: %d\n", value);
printf("Value via pointer: %d\n", *ptr);
printf("Value via pointer to pointer: %d\n", **ptrToPtr);
return 0;
}
多级指针类似于多维数组,允许你通过多个间接层访问数据。
空指针
空指针(NULL)表示指针不指向任何内存地址。在C语言中,NULL 通常定义为0。
以下是空指针的示例:
#include <stdio.h>
int main() {
int *ptr = NULL;
if (ptr == NULL) {
printf("Pointer is NULL\n");
}
if (ptr == 0) {
printf("Pointer is also equal to 0\n");
}
return 0;
}
空指针用于表示指针未初始化或指向无效地址。
链表示例
指针常用于构建动态数据结构,如链表。
以下是一个简单链表的示例:
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
void printList(Node *n) {
while (n != NULL) {
printf("Node data: %d\n", n->data);
n = n->next;
}
}
int main() {
Node *head = NULL;
Node *second = NULL;
Node *third = NULL;
// 分配内存
head = (Node*)malloc(sizeof(Node));
second = (Node*)malloc(sizeof(Node));
third = (Node*)malloc(sizeof(Node));
// 赋值并链接节点
head->data = 100;
head->next = second;
second->data = 200;
second->next = third;
third->data = 300;
third->next = NULL;
// 打印链表
printList(head);
// 释放内存
free(head);
free(second);
free(third);
return 0;
}
链表节点包含数据和一个指向下一个节点的指针。通过指针链接节点,可以动态创建和遍历链表。
指针的陷阱
指针使用不当可能导致程序崩溃或未定义行为。最常见的陷阱之一是使用未初始化的指针。
以下是指针陷阱的示例:
#include <stdio.h>
int main() {
int *ptr; // 未初始化的指针
// 危险操作:解引用未初始化的指针
// *ptr = 13; // 可能导致段错误
// 正确做法:先初始化指针
int value = 13;
ptr = &value;
*ptr = 42;
printf("Value: %d\n", value);
return 0;
}
未初始化的指针指向随机内存地址,解引用它可能导致段错误。始终确保指针指向有效的内存地址。
总结
本节课中我们一起学习了C语言的控制结构、复杂数据类型和指针。我们探讨了选择语句、循环语句、结构体、数组以及指针的基本概念和用法。指针是C语言强大但也危险的特征,它提供了直接操作内存的能力,但需要谨慎使用以避免错误。掌握这些概念是进行系统编程的基础。
004:字符串、函数、输入与动态内存分配
在本节课中,我们将深入学习C语言的核心概念,包括字符串的操作、函数的定义与使用、从用户获取输入以及动态内存分配。我们将通过具体的代码示例来理解这些概念,并学习如何将它们应用到实际编程中。
指针与数组的关联
上一节我们介绍了指针的基本概念,本节中我们来看看指针与数组之间的紧密联系。实际上,我们可以使用指针来遍历一组元素,就像使用数组一样。
以下是指针算术的示例代码:
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr;
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
for (int i = 0; i < 5; i++) {
printf("*(ptr + %d) = %d\n", i, *(ptr + i));
}
arr[2] = 60;
*(ptr + 3) = 80;
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
return 0;
}
数组名 arr 本质上是一个指向数组首元素的指针。通过指针算术(如 ptr + i),我们可以访问数组中的任意元素。方括号 [] 操作符和指针解引用 * 操作符可以实现相同的功能。
字符串基础
字符串在C语言中并非内置类型,它们是以空字符 \0 结尾的字符数组。以下是字符串的基本操作:
#include <stdio.h>
int main() {
char str1[] = "Hello World";
char *str2 = "Hello World";
printf("%s\n", str1);
printf("%s\n", str2);
str1[0] = 'F';
str1[1] = 'o';
str1[2] = 'o';
str1[3] = '\0';
str2 = "Ted was here and left this long message";
printf("%s\n", str1);
printf("%s\n", str2);
return 0;
}
使用字符数组定义字符串时,可以预留固定空间。使用字符指针定义字符串则更灵活,但存在缓冲区溢出的风险。修改字符串时,字符数组可以通过索引逐个修改,而字符指针可以重新指向新的字符串字面量。
字符串库函数
C标准库 <string.h> 提供了丰富的字符串处理函数,弥补了语言本身的不足。
以下是常用字符串操作的示例:
-
字符串连接:使用
strcat函数。 -
字符串比较:使用
strcmp函数。它返回两个字符串的差值,0表示相等。 -
获取字符串长度:使用
strlen函数。 -
查找子字符串:使用
strstr函数。它返回指向子字符串首次出现位置的指针。 -
复制字符串:使用
strcpy函数。
#include <stdio.h>
#include <string.h>
int main() {
char str1[20] = "Hello";
char str2[] = " World";
strcat(str1, str2);
printf("%s\n", str1);
if (strcmp(str1, "Hello World") == 0) {
printf("Strings are equal.\n");
}
printf("Length: %zu\n", strlen(str1));
char *sub = strstr(str1, "World");
if (sub != NULL) {
printf("Found substring: %s\n", sub);
}
char src[] = "Hello";
char dest[100];
strcpy(dest, src);
printf("%s\n", dest);
return 0;
}
类型转换
从用户输入获取的通常是字符串,我们需要将其转换为数值类型。<stdlib.h> 库提供了转换函数。
以下是类型转换的示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
char int_str[] = "123";
char float_str[] = "123.45";
int int_val = atoi(int_str);
double float_val = atof(float_str);
printf("Integer: %d\n", int_val);
printf("Float: %f\n", float_val);
return 0;
}
atoi 函数将字符串转换为整数,atof 函数将字符串转换为浮点数。
函数
函数是C语言组织代码的基本单元。我们需要理解函数原型、定义、参数传递(值传递与引用传递)以及函数指针。
以下是函数相关概念的示例:
-
函数原型与定义:原型声明函数签名,定义提供具体实现。
-
值传递与引用传递:值传递复制参数值,引用传递通过指针直接操作原变量。
-
常量指针参数:使用
const关键字保护指针指向的数据不被修改。 -
静态函数:使用
static关键字限制函数作用域为当前文件。 -
函数指针:可以指向函数的指针,用于实现回调或类似对象的行为。
#include <stdio.h>
void add_by_reference(int *a, int b) {
*a += b;
}
int add_by_value(int a, int b) {
return a + b;
}
void print_value(const int *val) {
printf("Value: %d\n", *val);
}
static void helper() {
printf("This is a static helper function.\n");
}
typedef struct {
int numerator;
int denominator;
void (*init)(struct Fraction *, int, int);
void (*print)(const struct Fraction *);
} Fraction;
void init_fraction(Fraction *f, int num, int den) {
f->numerator = num;
f->denominator = den;
}
void print_fraction(const Fraction *f) {
printf("%d/%d\n", f->numerator, f->denominator);
}
int main() {
int x = 10;
add_by_reference(&x, 5);
printf("After reference add: %d\n", x);
x = add_by_value(10, 5);
printf("After value add: %d\n", x);
print_value(&x);
helper();
Fraction f1;
f1.init = init_fraction;
f1.print = print_fraction;
f1.init(&f1, 1, 2);
f1.print(&f1);
return 0;
}
用户输入
从标准输入获取数据是交互式程序的基础。对于数字和字符串,有不同的安全输入方法。
以下是获取用户输入的示例:
-
获取数字:使用
scanf配合格式说明符(如%d,%f)。 -
获取字符串(危险方式):使用
scanf("%s", str),不检查缓冲区边界。 -
获取字符串(较安全方式):使用
scanf("%9s", str),限制读取字符数。 -
获取字符串(安全方式):使用
fgets(str, sizeof(str), stdin),并处理末尾的换行符。
#include <stdio.h>
#include <string.h>
int main() {
int num;
float fnum;
char str[10];
printf("Enter an integer: ");
scanf("%d", &num);
printf("Enter a float: ");
scanf("%f", &fnum);
getchar();
printf("Enter a short string (dangerous): ");
scanf("%s", str);
getchar();
printf("Enter a short string (safer): ");
scanf("%9s", str);
getchar();
printf("Enter a short string (safest): ");
fgets(str, sizeof(str), stdin);
str[strcspn(str, "\n")] = '\0';
printf("You entered: %d, %f, %s\n", num, fnum, str);
return 0;
}
动态内存分配
静态内存分配在编译时确定大小,缺乏灵活性。动态内存分配允许程序在运行时请求所需内存,使用后释放,提高了内存利用率和程序灵活性。
以下是动态内存分配的示例:
-
分配单个整数:使用
malloc(sizeof(int))。 -
分配数组:使用
malloc(n * sizeof(int))。 -
分配字符串:使用
malloc((length + 1) * sizeof(char)),为终止符预留空间。 -
释放内存:使用
free(ptr)释放分配的内存,并将指针设为NULL。
#include <stdio.h>
#include <stdlib.h>
int main() {
int *dynamic_int = malloc(sizeof(int));
if (dynamic_int == NULL) {
return 1;
}
*dynamic_int = 42;
printf("Dynamic integer: %d\n", *dynamic_int);
free(dynamic_int);
dynamic_int = NULL;
int n;
printf("Enter number of elements: ");
scanf("%d", &n);
int *dynamic_arr = malloc(n * sizeof(int));
if (dynamic_arr == NULL) {
return 1;
}
for (int i = 0; i < n; i++) {
dynamic_arr[i] = i * 10;
}
for (int i = 0; i < n; i++) {
printf("%d ", dynamic_arr[i]);
}
printf("\n");
free(dynamic_arr);
dynamic_arr = NULL;
return 0;
}
多文件编程与头文件
为了组织大型项目,我们可以将代码拆分到多个源文件和头文件中。头文件(.h)包含函数原型和常量定义,源文件(.c)包含具体实现。
以下是多文件编程的示例:
头文件 myheader.h:
#ifndef MYHEADER_H
#define MYHEADER_H
void say_hello(void);
#endif
源文件 implementation.c:
#include <stdio.h>
#include "myheader.h"
void say_hello(void) {
printf("Hello from another file!\n");
}
主文件 main.c:
#include "myheader.h"
int main() {
say_hello();
return 0;
}
编译时需链接所有源文件:
gcc main.c implementation.c -o myprogram
数学库
C标准库 <math.h> 提供了常用的数学函数,如 sqrt, pow, sin, cos, fabs 等。使用这些函数通常需要在编译时链接数学库(-lm)。
#include <stdio.h>
#include <math.h>
int main() {
double x = 16.0;
double y = 3.0;
printf("sqrt(%f) = %f\n", x, sqrt(x));
printf("pow(%f, %f) = %f\n", x, y, pow(x, y));
printf("sin(%f) = %f\n", x, sin(x));
return 0;
}
编译命令示例:
gcc math_example.c -lm -o math_example
总结
本节课中我们一起学习了C语言中字符串的处理方式、函数的定义与多种参数传递机制、从用户安全获取输入的方法,以及至关重要的动态内存分配技术。我们还了解了如何利用标准库扩展功能,以及如何通过头文件和多个源文件来组织代码。掌握这些概念是进行C语言系统编程的基础。下一节课,我们将尝试综合运用这些知识构建一个简单的应用程序。
005:从Java到C实战转换
概述
在本节课中,我们将通过一个具体的实践项目,学习如何将Java语言编写的应用程序转换为C语言版本。我们将回顾之前课程中涉及的核心概念,如内存管理、函数指针等,并应用这些知识来完成一个“待办事项列表”应用的代码转换。
上一讲我们以异步在线形式进行,探讨了内存分配等核心概念,并了解了C++如何构建在C语言之上。我们还讨论了如何通过函数指针为数据结构创建方法,这为实现面向对象风格的编程提供了思路。
本节我们将转换学习节奏,进行一次基于团队的实践工作坊。目标是检验大家对C语言知识的掌握程度,并通过动手实践加深理解。
以下是本次工作坊的核心任务描述。
-
我们将分析一个用Java编写的简单“待办事项列表”应用程序。
-
该应用具备添加任务、查看任务、移除任务和退出程序等基本功能。
-
我们的目标是以团队形式,在限定时间内,将这份Java代码转换为功能等效的C语言代码。
现在,让我们先来查看这个Java应用程序的运行效果。
# 这是一个示意性的命令,表示运行Java程序
java TodoApp
程序运行后,会显示一个菜单:1. 添加任务,2. 查看任务,3. 移除任务,4. 退出。用户可以与之交互,例如添加“任务一”、“任务二”,然后查看列表,或移除中间的任务,列表会相应更新。
这个应用在Java中实现起来相对简单直接。接下来,我们看看已经存在的C语言版本。
# 编译C语言版本的待办事项应用
gcc todo_app.c -o todo_app
# 运行编译后的程序
./todo_app
C语言版本的程序运行起来,其外观和功能与Java版本完全一致。这证明了用C语言可以实现相同的逻辑。
对于在线学习的同学,建议通过Discord等工具组建小组进行协作。大家可以从课程GitHub仓库的第五讲目录中获取Java源代码文件。
在转换过程中,你可能会遇到各种挑战。请记住,我们课程中讨论过的所有示例源代码都可以在GitHub仓库中找到。
以下是可供参考的核心C语言概念示例代码。
// 示例:函数指针的用法
int (*operation)(int, int); // 声明一个函数指针
operation = add; // 指向add函数
int result = operation(5, 3); // 通过指针调用函数
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/c24a76055b5e33e33e0bae0ae502514b_5.png>
// 示例:动态字符串数组(指针的指针)
char **task_list = malloc(sizeof(char*) * capacity);
task_list[0] = malloc(strlen("Task 1") + 1);
strcpy(task_list[0], "Task 1");
如果对如何处理指向字符的指针、多维数组或字符串操作有疑问,可以参考这些基础示例。你们可以下载这些源代码文件,编译并运行它们,以此作为构建C语言版本待办事项应用的基石。
总结
本节课我们一起进行了一次从Java到C的代码转换实战。我们回顾了内存地址、函数指针等关键概念,并尝试在团队协作中应用这些知识来解决实际问题。虽然C和Java语法相近,但考虑到内存管理的差异,完成一个功能完整的转换并非易事。请大家继续以小组形式完成这个项目,利用课程提供的资源作为参考。
006:.hello程序的生命周期 🖥️
在本节课中,我们将学习一个简单的“Hello World”程序从编写到在计算机上运行并退出的完整生命周期。我们将从软件和硬件两个视角,深入理解计算机系统是如何协同工作来执行程序的。
概述
我们将追踪一个C语言“Hello World”程序的生命周期。这个过程可以分为六个阶段:创建、编译、加载、执行、输出和终止。理解这个过程是理解计算机系统如何工作的基础。
软件视角:程序的生命周期
从纯软件的角度看,一个程序的生命周期可以抽象为六个阶段。
1. 创建阶段
在这个阶段,我们使用文本编辑器编写程序代码,并将其保存为一个源文件(例如 hello.c)。这个文件以文本形式存储在我们的系统中。
2. 编译阶段
接下来,我们需要将人类可读的源代码转换成计算机可以执行的格式。这是由编译器完成的。对于C语言,我们通常使用 gcc 编译器。
编译过程本身包含四个步骤:
-
预处理:处理源代码中以
#开头的指令(如#include),将引用的头文件内容插入到源代码中,生成一个.i文件。 -
编译:将预处理后的C代码翻译成汇编语言代码,生成一个
.s文件。 -
汇编:将汇编代码翻译成机器语言指令(二进制代码),生成一个
.o目标文件。 -
链接:将程序的目标文件与所需的标准库(如包含
printf函数的库)合并,生成最终的可执行文件(例如a.out或hello)。
3. 加载阶段
当我们通过命令行(例如在shell中输入 ./hello)运行程序时,操作系统会将可执行文件从磁盘加载到主内存中,为执行做好准备。
4. 执行阶段
中央处理器(CPU)开始执行加载到内存中的机器语言指令。CPU从程序计数器指示的位置读取指令,解释并执行它,然后更新程序计数器指向下一条指令,如此循环。
5. 输出阶段
程序通过调用 printf 这样的函数,将字符串“Hello World”发送到系统的输出流,最终显示在用户的屏幕上。
6. 终止阶段
程序执行完毕后,操作系统会回收分配给该程序的所有内存资源,使其可供系统其他部分使用。
深入编译系统
上一节我们概述了程序的生命周期,本节中我们来看看编译阶段的具体细节。理解编译系统有助于我们优化代码、调试和避免安全漏洞。
gcc 驱动程序在后台调用了四个独立的程序来完成编译工作:
-
预处理器 (cpp):
hello.c->hello.i -
编译器 (cc1):
hello.i->hello.s -
汇编器 (as):
hello.s->hello.o -
链接器 (ld):
hello.o+printf.o->hello(可执行文件)
系统信息表示
在深入硬件之前,我们需要理解一个核心概念:计算机系统中的所有信息——包括磁盘文件、内存中的程序和网络传输的数据——都是用二进制位序列表示的。
不同的数据对象(如整数、浮点数、字符串或机器指令)之所以有意义,是因为我们为这些位序列提供了上下文。例如,同样的位序列,作为整数解释和作为浮点数解释会得到完全不同的值。
文本文件是二进制序列的一种特例,它使用 ASCII 编码标准,将每个字符映射为一个字节大小的整数值。我们可以使用 xxd 或 od 命令行工具来查看文件的二进制/十六进制表示。
# 以十六进制和ASCII字符形式查看 hello.c
xxd -g 1 hello.c
# 以十进制整数和ASCII字符形式查看 hello.c
od -t dC hello.c
硬件视角:程序如何运行
现在我们已经了解了软件层面的流程,本节我们将看看硬件是如何支持这个流程的。计算机硬件可以简化为四个主要组件,它们通过总线连接。
1. 总线
总线是贯穿整个系统的电子管道,负责在各个部件之间传递固定大小的字节块,这个块被称为字。字的大小是系统的一个基本参数,例如32位系统或64位系统。
2. 输入/输出设备
I/O设备是系统与外部世界的连接通道,例如键盘、鼠标、显示器、磁盘驱动器、网络适配器等。每个设备都通过控制器或适配器与I/O总线相连。
3. 主存储器
主存储器是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。逻辑上,内存可以被看作一个线性的字节数组,每个字节都有唯一的地址。
4. 处理器
处理器(CPU)是解释并执行存储在主存中指令的引擎。它的核心部件包括:
-
程序计数器:指向下一条要执行的指令。
-
寄存器文件:小型、高速的存储设备。
-
算术/逻辑单元:执行算术和逻辑运算。
CPU不断重复执行一个简单的指令周期:取指 -> 译码 -> 执行 -> 更新程序计数器。指令集架构定义了CPU可以执行的基本操作,如加载、存储、运算和跳转。
“Hello”程序在硬件上的运行轨迹
结合以上硬件知识,让我们跟踪 hello 程序的执行过程:
-
输入指令:我们在shell中键入
./hello并回车。Shell程序通过键盘I/O设备读取字符,并将其存入内存。 -
加载程序:Shell通过执行一系列指令,指示操作系统将
hello可执行文件从磁盘加载到主存。 -
执行指令:处理器开始执行
hello程序主函数中的机器指令。这些指令将“Hello World”字符串从内存复制到寄存器文件。 -
输出结果:处理器再将数据从寄存器文件复制到显示设备(图形适配器),最终字符串显示在屏幕上。
重要概念:缓存、进程与抽象
在程序执行过程中,数据在不同存储层次间频繁移动。为了弥补处理器和主存之间的速度差距,系统采用了高速缓存存储器。缓存利用了程序的局部性原理,将处理器近期可能会用到的信息存放在更小、更快的存储设备中。
操作系统是管理硬件和应用程序的软件层。它通过几个关键抽象来简化我们的编程:
-
进程:是对一个正在运行的程序的抽象。操作系统通过上下文切换来实现多个进程的并发执行。
-
虚拟内存:为每个进程提供一个统一的、私有的地址空间视图,让每个进程都感觉自己独占了主存。
-
文件:是对所有I/O设备的抽象,将一切输入输出都视为对字节序列的读写。网络通信也可以被视为一种特殊的文件I/O。
总结
本节课中,我们一起学习了“Hello World”程序完整的生命周期。我们从软件角度分析了它的六个阶段:创建、编译、加载、执行、输出和终止。接着,我们从硬件角度剖析了计算机系统的核心组件——总线、I/O设备、主存和处理器,并跟踪了程序指令和数据在这些组件间的流动。最后,我们介绍了操作系统提供的关键抽象:进程、虚拟内存和文件,这些抽象是构建复杂、安全、高效应用程序的基石。理解这些底层机制,将为我们后续学习系统编程、优化代码和调试程序打下坚实的基础。
007:数据表示与存储
在本节课中,我们将学习计算机系统中数据表示与存储的核心概念。我们将探讨信息如何被编码为二进制,以及不同的编码方式(如整数、浮点数)如何影响程序的运行。理解这些底层原理对于进行系统编程至关重要。
信息存储基础
上一节我们介绍了计算机系统的基本构成。本节中,我们来看看数据在计算机中最基本的存储形式。
计算机是电子机器,而非机械机器。它们利用电信号进行操作,这使得处理速度极快。数据通过晶体管(一种电子开关)的状态来表示,每个开关有两种状态:开(1)或关(0)。这种二进制状态是计算机存储和处理信息的基础。
比特与字节
一个二进制位称为一个比特,它是信息的基本单位。八个比特组成一个字节,字节是计算机中最小的可寻址内存单元。
选择8比特作为一个字节,最初是为了能够编码所有必要的文本字符(如ASCII标准)。一个字节可以表示256种不同的状态(2^8 = 256)。
数据的上下文
相同的二进制序列,根据不同的上下文,可以代表完全不同的含义。例如,二进制序列 01000011 可以解释为:
-
整数:67
-
字符:
'C' -
图像中某个像素的红色分量值
-
一段声音的特定采样值
-
一条CPU指令
文件扩展名(如 .txt, .jpg, .mp3)为操作系统和应用程序提供了理解文件内二进制数据所需的上下文。
数值表示与编码
既然所有数据最终都表示为数字,那么理解数值的编码方式就变得尤为重要。我们主要关注三种编码:
-
无符号编码:用于表示非负整数。
-
补码编码:用于表示可正可负的整数。
-
浮点数编码:用于表示实数(包含小数点的数)。
整数是离散值,而浮点数旨在表示连续范围内的值,这涉及到微积分中“无穷小”的概念。
进制转换
为了便于人类理解和操作二进制数据,我们经常需要在不同进制间转换。以下是常见的三种进制:
-
二进制:基数为2,使用数字0和1。
-
十进制:基数为10,使用数字0-9。
-
十六进制:基数为16,使用数字0-9和字母A-F(或a-f)。
在C语言中,我们可以直接使用不同进制的字面量:
-
十进制:
314156 -
十六进制:
0x4CB2C(前缀0x或0X) -
二进制:
0b1101(某些编译器支持,C标准未正式定义)
进制转换方法
以下是不同进制间转换的核心方法:
十六进制与二进制互转
由于16是2的4次方,转换非常直接。每个十六进制数字对应4个二进制位。
- 例:
0xA3->A=1010,3=0011->10100011
十进制转十六进制
使用“除16取余”法,从下往上读取余数。
314156 ÷ 16 = 19634 ... 余 12 (C)
19634 ÷ 16 = 1227 ... 余 2 (2)
1227 ÷ 16 = 76 ... 余 11 (B)
76 ÷ 16 = 4 ... 余 12 (C)
4 ÷ 16 = 0 ... 余 4 (4)
结果:0x4CB2C
十六进制转十进制
使用乘幂求和法。
- 例:
0x7AF=7*16^2 + 10*16^1 + 15*16^0=7*256 + 10*16 + 15*1=1792 + 160 + 15=1967
字长与字节顺序
字长
系统的字长决定了其一次性能处理数据的最大位数,也决定了虚拟地址空间的大小。常见的字长有32位和64位。
对于一个 w 位的字长,虚拟地址的范围是从 0 到 2^w - 1。
字节顺序
对于多字节数据(如 int, double),在内存中存储时,字节的排列顺序有两种约定:
-
小端序:最低有效字节存储在最低内存地址。
-
大端序:最高有效字节存储在最低内存地址。
例如,一个32位整数 0x01234567 在内存中的存储方式如下:
| 内存地址 | 小端序存储内容 | 大端序存储内容 |
| :— | :— | :— |
| 0x1000 | 0x67 | 0x01 |
| 0x1001 | 0x45 | 0x23 |
| 0x1002 | 0x23 | 0x45 |
| 0x1003 | 0x01 | 0x67 |
不同的计算机体系结构可能采用不同的字节序,这是在系统间传输数据时需要特别注意的问题。
本节课中,我们一起学习了计算机中数据表示的基础。我们了解了信息如何以二进制比特的形式存储,以及字节作为基本寻址单元的角色。我们探讨了上下文如何赋予二进制序列意义,并介绍了数值的几种关键编码方式。此外,我们还回顾了不同进制(二进制、十进制、十六进制)之间的转换方法,并了解了系统字长和字节顺序(大端序/小端序)的概念。这些知识是理解后续整数运算、浮点数表示等更深入话题的基石。
008:运算符、向量与集合
在本节课中,我们将学习计算机系统中数据的二进制表示,以及如何使用位运算符和逻辑运算符来操作这些二进制数据。我们将探讨字节序、位向量、集合的概念,并通过代码示例展示如何将这些理论应用于实际。
字节序与数据表示
上一节我们介绍了数据在计算机中都以二进制形式表示。本节中,我们来看看数据在内存中存储的具体顺序,即字节序。
任何数据,无论是数值、文本、图像还是音频,最终都会被转换为数值表示。因此,我们可以使用处理数值的原始运算符,以逻辑和有趣的方式来操作这些更复杂的数据类型。
由于所有数据本质上都是数字,理解数字表示是理解系统如何运行的基础。例如,图片可以表示为三个整数值(红、绿、蓝),这依赖于整数编码。声音的振幅则需要能表示正负值的有符号整数编码。
我们之前讨论了字符编码(如ASCII码),它使用一组非负整数来表示字符集中的每个符号。当我们讨论数字编码时,主要关注两个问题:无符号整数和有符号整数的表示。
所有二进制表示都基于数值格式。如果需要更大的编码空间,只需在二进制字符串中添加更多比特位。这就是区分 short、int、long 等数据类型的关键——它们可编码的比特位数不同,从而提供了不同数量的二进制“单词”排列组合。
系统可寻址的最小单位是单个字节。如果要存储一个需要多个字节的单一值(如一个整数),系统必须知道这一点。这就是为什么我们有强类型系统。当你在虚拟地址空间中放入一个整数值时,它会预留一个连续的4字节块来存储该完整二进制字的每个片段。
读取数据时,就引出了字节序的问题:是从最高有效位到最低有效位读取(大端序),还是从最低有效位到最高有效位读取(小端序)。
以下是展示系统字节序的代码示例:
#include <stdio.h>
typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, size_t len) {
size_t i;
for (i = 0; i < len; i++)
printf(" %.2x", start[i]);
printf("\n");
}
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_4.png>
void show_int(int x) {
show_bytes((byte_pointer) &x, sizeof(int));
}
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_6.png>
void show_float(float x) {
show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void *x) {
show_bytes((byte_pointer) &x, sizeof(void *));
}
int main() {
int ival = 12345;
float fval = (float) ival;
int *pval = &ival;
show_int(ival);
show_float(fval);
show_pointer(pval);
return 0;
}
运行此代码可以显示整数、浮点数和指针的字节序列,从而判断系统使用的是小端序(如输出 39 30 00 00)还是大端序。
不同数据类型的编码
上一节我们看到了数据的字节序。本节中,我们来看看相同数值在不同数据类型(如整数、浮点数、字符串)中是如何被编码成不同二进制序列的。
字符使用单字节表示。字符串可以看作是字符数组,通常以空字符 \0 结尾,作为分隔符。
以下代码比较了相同数字 12345 在整数、浮点数、指针和字符串中的不同编码:
// ...(show_bytes等函数定义同上)
void show_string(char *x) {
show_bytes((byte_pointer) x, strlen(x)+1); // +1 包含空字符
}
int main() {
int ival = 12345;
float fval = 12345.0;
int *pval = &ival;
char *sval = "12345";
show_int(ival);
show_float(fval);
show_pointer(pval);
show_string(sval);
return 0;
}
字符串 "12345" 的输出(如 31 32 33 34 35 00)对应的是每个字符的ASCII码十六进制值。这说明了字符代码同时具有文本和数值属性。
代码即数据
我们已了解数据在内存中的表示。本节中,我们来看一个关键概念:代码本身在系统中也是以二进制数据的形式存储的。
当C函数被编译后,其机器码会以十六进制表示形式存入内存。系统知道如何将这些十六进制值转换回汇编指令。像 objdump 这样的反汇编工具可以将可执行文件中的十六进制序列转换回汇编语言。
以下是一个简单的C程序及其通过Shell脚本反汇编的示例:
C源文件 (sum.c):
#include <stdio.h>
int sum(int x, int y) {
return x + y;
}
int main() {
printf("Sum: %d\n", sum(1, 2));
return 0;
}
Shell脚本 (disassemble.sh):
#!/bin/bash
# 编译C代码,保留调试信息(-g)
gcc -g -o sum_program sum.c
# 反汇编可执行文件,并搜索sum函数
objdump -d sum_program | grep "<_sum>"
通过 chmod +x disassemble.sh 赋予脚本执行权限,然后运行 ./disassemble.sh,可以看到 sum 函数的汇编指令和对应的十六进制机器码。这证明了代码在系统中与其他数据一样,都是存储在内存中的二进制序列。
位运算符与位向量
我们已经看到数据在底层都是比特序列。本节中,我们来看看用于转换这些比特序列的基本工具:位运算符和逻辑运算符。
位运算符(如 &, |, ^, ~)旨在一次操作一对比特。然而,就像线性代数可以变换整个场一样,位运算符可以应用于整个位向量。前提是两个位向量长度必须相同(可通过零填充实现)。运算符会统一作用于向量中的每一个比特位。
以下是对两个位向量进行各种位运算的示例输出:
A: 11111111
B: 00000001
A|B: 11111111
A&B: 00000001
A^B: 11111110
~A: 00000000
位向量可以用来表示有限集合。我们可以用两种方式看待位向量:
-
向量:有序集合,顺序重要,元素可重复。例如二进制字符串
01101001。 -
集合:无序集合,元素唯一。可以用值为1的比特位的位置索引来表示。例如
01101001可表示为集合{0, 3, 5, 6}。
集合论中的操作与位运算符有清晰的对应关系:
-
并集 (Union) -> 按位或
| -
交集 (Intersection) -> 按位与
& -
补集 (Complement) -> 按位非
~
这种对应关系非常强大,它允许我们使用更丰富的数学工具(集合论)来理解和操作二进制数据。
应用于复杂数据:图像与声音
位运算符不仅能操作抽象比特,还能处理具体的复杂数据。本节中,我们来看看如何将图像和声音这类抽象数据映射到底层的数值表示,从而用位运算符进行处理。
例如,一个像素的颜色可以用RGB(红、绿、蓝)三个数值表示。假设每个颜色分量只用1比特表示(0关/1开),我们可以组合出8种颜色:
-
000: 黑 -
001: 蓝 -
010: 绿 -
011: 青 -
100: 红 -
101: 品红 -
110: 黄 -
111: 白
混合红色(100)和绿色(010)得到黄色(110),这正是按位或(|)操作的结果。这展示了如何用底层比特操作来实现上层的颜色混合。
以下Python代码演示了如何通过直接指定RGB数值来生成图像像素和声音:
生成彩色像素:
import numpy as np
from PIL import Image
# 定义像素颜色 (R, G, B)
red_pixel = [255, 0, 0]
green_pixel = [0, 255, 0]
blue_pixel = [0, 0, 255]
white_pixel = [255, 255, 255]
# 创建图像数组并显示
pixel_array = np.array([red_pixel, green_pixel, blue_pixel, white_pixel], dtype=np.uint8)
img = Image.fromarray(pixel_array.reshape(1, 4, 3), 'RGB')
img.show()
生成简单音调:
import numpy as np
import sounddevice as sd
def generate_tone(frequency, duration, sample_rate=44100):
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
wave = 0.5 * np.sin(2 * np.pi * frequency * t)
return wave
# 定义音符频率 (Hz)
A4 = 440.00
C5 = 523.25
E5 = 659.25
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_22.png>
# 生成并播放和弦
duration = 2.0
chord = generate_tone(A4, duration) + generate_tone(C5, duration) + generate_tone(E5, duration)
sd.play(chord, samplerate=44100)
sd.wait()
这些例子表明,只要找到数据的数学模型,就可以用数值表示并自动化处理它们。
掩码操作
位运算符的一个实用技巧是掩码。本节中,我们来看看如何使用掩码从位向量中选择或屏蔽特定的比特位。
掩码是一个比特模式,用于“保留”原数据中与之对应的位(通过与操作&),而“屏蔽”或清零其他位。这在需要提取数据的特定部分(如从一个多字节值中取出低字节)时非常有用。
#include <stdio.h>
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_24.png>
int main() {
unsigned int x = 0x89ABCDEF; // 一个多字节值
unsigned int mask = 0xFF; // 掩码:保留最低字节
unsigned int masked_value = x & mask;
printf("Original: 0x%X\n", x);
printf("Mask: 0x%X\n", mask);
printf("Result: 0x%X\n", masked_value); // 输出:0xEF
return 0;
}
逻辑运算符与移位运算符
最后,我们区分一下逻辑运算符和移位运算符。逻辑运算符(&&, ||, !)将整个表达式求值为布尔值(0或1),不保留位向量的结构,通常用于条件判断。
移位运算符(<<, >>)将比特位向左或向右移动。需要注意的是右移:
-
逻辑右移:对无符号整数操作,左侧空位补0。
-
算术右移:对有符号整数操作,左侧空位用符号位(最高位)填充,以保持数值的正负性。
#include <stdio.h>
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_32.png>
int main() {
int x_signed = -100; // 有符号整数
unsigned int x_unsigned = 100; // 无符号整数
printf("Signed -100 >> 2 (算术右移): %d\n", x_signed >> 2);
printf("Unsigned 100 >> 2 (逻辑右移): %u\n", x_unsigned >> 2);
// 查看二进制表示(简化示意)
// 算术右移保留符号,逻辑右移补零
return 0;
}
总结
本节课中我们一起学习了:
-
数据的二进制本质:所有类型的数据最终都表示为二进制数字。
-
字节序:数据在内存中存储的字节顺序(大端序 vs 小端序)。
-
代码即数据:可执行程序本身也是以二进制形式存储在内存中。
-
位运算符与位向量:使用
&,|,^,~操作比特序列,以及位向量与集合的对应关系。 -
复杂数据的数值表示:图像(RGB)、声音(波形)如何映射为数值,从而能用位级操作处理。
-
掩码操作:使用位与运算提取特定位。
-
移位运算:区分逻辑移位和算术移位。
理解这些底层概念是进行系统级编程和高效数据操作的基础。下一讲,我们将深入探讨整数(特别是有符号整数)的具体表示方法。
009:整数编码 🧮
在本节课中,我们将要学习计算机系统中整数的两种核心编码方式:无符号编码和二进制补码。理解这些编码是理解数据如何在计算机底层表示和操作的基础。
概述 📋
在之前的课程中,我们了解到所有数据(无论是文本、图像还是声音)最终都会被转换为数值表示,并编码为二进制序列存储在计算机中。本节课,我们将深入探讨如何将整数(即没有小数部分的数字)映射到这些二进制序列上。我们将重点学习两种主要的整数表示方法:无符号整数和二进制补码。
无符号整数编码
上一节我们介绍了数据在计算机中都以二进制序列表示。本节中,我们来看看如何用二进制序列表示非负整数,即无符号整数。
无符号编码是最直观的编码方式,它只包含零和正数。在一个固定宽度(例如8位、16位)的二进制序列中,每一位都代表一个2的幂次方值。
其核心转换公式如下:
对于一个宽度为 w 的位向量 x(表示为 [x_{w-1}, x_{w-2}, ..., x_0]),将其转换为无符号整数的函数 B2U_w 定义为:
B2U_w(x) = Σ_{i=0}^{w-1} x_i * 2^i
以下是该公式在C语言中的一种实现方式:
unsigned int B2U(const char *binary_str, int length) {
unsigned int value = 0;
for (int i = 0; i < length; i++) {
if (binary_str[i] == '1') {
value += (1U << i); // 将1左移i位,相当于加上2的i次方
}
}
return value;
}
无符号整数的范围
对于一个宽度为 w 位的二进制字符串:
-
最小可编码值为 0(所有位都为0)。
-
最大可编码值为 2^w - 1(所有位都为1)。
例如,一个8位(1字节)的无符号整数可以表示的范围是 0 到 255。
二进制补码编码
理解了如何表示非负数后,我们自然需要一种方法来编码负数。这就是二进制补码编码的目的,它允许我们在同一个二进制序列中表示正数、零和负数。
在二进制补码中,最高有效位(最左边的位)被用作符号位。符号位为0表示非负数,为1表示负数。这使得可表示的数值范围大致被平分给了负数和正数(包括零)。
其核心转换公式如下:
对于一个宽度为 w 的位向量 x,将其转换为二进制补码整数的函数 B2T_w 定义为:
B2T_w(x) = -x_{w-1} * 2^{w-1} + Σ_{i=0}^{w-2} x_i * 2^i
以下是该公式在C语言中的一种实现方式:
int B2T(const char *binary_str, int length) {
int value = 0;
for (int i = 0; i < length; i++) {
if (binary_str[i] == '1') {
if (i == length - 1) {
// 处理符号位(最高位)
value -= (1 << i);
} else {
// 处理其他位
value += (1 << i);
}
}
}
return value;
}
二进制补码的范围
对于一个宽度为 w 位的二进制字符串:
-
最小可编码值(最负的数)为 -2^{w-1}。
-
最大可编码值(最正的数)为 2^{w-1} - 1。
例如,一个8位的二进制补码整数可以表示的范围是 -128 到 127。
编码的转换与上下文
我们已经看到了两种不同的编码方式。一个关键点是:相同的二进制序列,在不同的编码解释下,会得到不同的整数值。
例如,二进制序列 10000001:
-
解释为 无符号整数 时,值是 129。
-
解释为 8位二进制补码 时,值是 -127。
在C语言中,通过类型转换,我们可以改变解释二进制序列的上下文。但这可能导致非预期的结果,因为转换改变的是解读方式,而不是底层比特位。
int main() {
int signed_val = -12345;
unsigned int unsigned_val = (unsigned int) signed_val; // 转换:保持比特位不变,改变解释方式
printf("Signed: %d\n", signed_val);
printf("Unsigned: %u\n", unsigned_val); // 将输出一个很大的正数
return 0;
}
因此,在进行混合类型运算或强制类型转换时,必须格外小心。
总结 🎯
本节课中我们一起学习了计算机中整数的两种基本编码方案:
-
无符号编码:用于表示零和正整数,其转换基于二进制位的加权求和。
-
二进制补码编码:用于表示包含负数、零和正数的整数集,其最高位作为符号位,使得编码范围对称地分布在零的两侧(近似对称)。
理解这两种编码的差异和转换关系至关重要,因为它影响着数据的存储、运算以及我们在C语言中进行类型转换时的行为。它们是理解计算机如何表示和处理信息的基石。在接下来的课程中,我们将继续探讨其他数据类型的编码,如浮点数。
010:整数运算符 🧮
在本节课中,我们将学习整数在计算机中的算术运算,包括加法、减法、乘法和除法。我们将探讨这些运算在固定位宽下的行为,特别是溢出问题,并了解其背后的二进制操作原理。
概述:整数运算的挑战
上一节我们介绍了整数的二进制表示(无符号数和补码)。本节中,我们来看看如何对这些表示进行算术运算。由于计算机使用固定位宽(如32位)存储整数,运算结果可能超出可表示的范围,导致溢出。理解这些行为对于编写健壮的程序至关重要。
无符号加法与溢出
无符号加法是最基础的运算。但当我们对两个w位的无符号数相加时,结果可能需要w+1位来表示。
以下是理解溢出的关键步骤:
-
考虑两个4位无符号数的最大值:
1111(15)。 -
计算
1111 + 1111,手工二进制加法结果为11110(30)。 -
这需要5位来存储。但在固定4位系统中,最高位的
1会被丢弃,结果回绕为1110(14)。
更多推荐
所有评论(0)