威斯康星-CS354-C-底层编程笔记-全-

威斯康星 CS354 C 底层编程笔记(全)

001:课程导论 👨‍🏫

在本节课中,我们将要学习课程的整体介绍,包括课程结构、教学团队、学习资源、沟通方式和评分标准等核心信息。

大家好,欢迎来到计算机科学354课程:机器组织与编程。我是这门课的讲师,Michael Dosher。本课程由威斯康星大学麦迪逊分校提供。如果你在YouTube上找到了这些视频,欢迎观看。对于无法访问Canvas付费墙后部分内容的情况,我表示歉意,因为我不能分享一些受版权保护的教学材料。

课程概览与结构 📅

首先,让我们对课程进行一个简要介绍。课程主页上方的红色区域包含所有非常重要的链接和公告。接下来的部分是重要链接,我会在整个学期中持续更新这个页面。

课程内容主要分为三个部分:

  1. C语言编程基础:课程的前五周,我们将学习C语言编程。
  2. 汇编语言:从第六周开始到大约第十一周,我们将学习汇编语言编程。大部分内容会通过编写C代码,生成对应的汇编代码,并理解其底层工作原理。
  3. 专题研讨:课程的最后四周(第11至14周),我们将探讨一些专题,例如动态内存分配和操作系统简介。

课程安排有两次期中考试。期中考试一将在C语言编程部分结束后进行,期中考试二将在汇编语言部分结束后进行。期末考试将涵盖整个课程的所有内容。

教学团队与讲师介绍 👥

我是本课程的讲师Michael Dosher,请叫我Mike。我的背景比较特殊:我拥有化学博士学位,曾在美国海军研究实验室工作,后来在本尼迪克特学院教授化学。出于对计算机科学的好奇,我开始学习相关课程,并在威斯康星大学获得了计算机科学硕士学位。我曾在一家初创软件公司工作,目前是这里的全职教师。这是我的第四年教授计算机科学。

我需要说明一些可能影响课程的个人情况。我有一些健康问题,可能导致偶尔状态不佳。因此,课程采用周计划模式,以便我在状态好的时候集中完成工作。同时,我也是一位单亲父亲,需要照顾孩子。我会尽力确保课程顺利进行。

我们的教学团队包括四名研究生助教和两名本科生同伴导师。首席助教是Abby Matthews。

学习方式与资源 📚

本课程采用在线视频讲座的形式。视频将每周发布一个播放列表,其中包含我认为优秀的旧内容和改进后的新内容。

请注意,单纯观看视频是学习计算机科学效率较低的方式。最佳的学习方法是动手实践。我尽力使视频具有互动性,你可以随时暂停视频进行练习、尝试代码或回放不理解的部分。

以下是本课程的核心学习资源:

  • 主要教材Computer Systems: A Programmer‘s Perspective (第二版)。使用第三版亦可,但第二版性价比更高。
  • C语言参考书The C Programming Language (第二版),由C语言发明者撰写。你可以在网上找到免费的电子版。

沟通渠道与支持 💬

为了提供充分的互动和支持,我们设立了多种沟通渠道:

  1. Canvas:课程门户网站,所有公告、日程和链接都会在这里发布。
  2. Piazza:在线论坛,用于提问和讨论。请注意,严禁公开张贴超过5行的作业代码。提问前请先搜索,避免重复提问。我们鼓励同学们互相解答问题。
  3. Office Hours (一对一答疑):你需要通过指定链接预约与助教或讲师的一对一会议。请提供一个你熟悉的、可以共享屏幕的会议链接(如Zoom、Google Meet)。
  4. Lab Hours (实验室开放时间):在Canvas的Blackboard Collaborate Ultra中举行,多位同学可以同时加入,与助教或同伴导师讨论作业策略等。在此环节中,学生无法共享屏幕
  5. 周五直播问答:每周五上午8:30,我将举行一个可选的直播问答环节,用于互动和解答问题。
  6. 电子邮件:用于接收课程公告。如有个人或隐私问题,可直接联系你的助教或我本人。

评分标准与学术诚信 ⚖️

课程的评分构成如下:

  • 作业项目:50%
  • 测验与期中考试:40% (每周有小测验,计入10%;两次期中考试各占10%)
  • 期末考试:20%

关于作业:作业项目旨在提供实践机会。评分基于结果(代码是否能正确运行并通过测试),因此大部分认真完成的同学都能获得高分。请勿拖延,尽早开始。

迟交政策:考虑到疫情等特殊情况,本学期作业迟交不会扣分。但如果你需要延期,必须提前发邮件给助教或我说明,并告知预计完成时间。我们无法在课程结束后接受作业。

学术诚信:我们严肃对待学术诚信。允许讨论解题策略、分享伪代码或图表,并引用非针对本作业的在线代码(需注明出处)。严禁的行为包括:抄袭他人代码、逐行模仿、使用往年泄露的答案、互发代码文件等。我们使用先进的MOSS(软件相似性检测系统)来检查代码抄袭,违规者将受到严肃处理。

其他重要事项 ℹ️

  • 残疾便利:如需因残疾获得考试延长时间或其他便利,请通过McBurney残疾资源中心联系我。
  • 推荐信:由于在线课程限制,我很难深入了解每位同学。仅凭课程成绩不足以撰写有力的推荐信。如果你需要推荐信,最好能通过参与课外项目等方式向我展示你的能力。
  • 账户与环境:你需要激活威斯康星大学的CSL账户,以便登录学校的Linux机器完成和测试作业。我们将在CSL的Linux机器上评分所有作业。
  • 课前任务:请尽快注册Piazza论坛,并填写“认识你”调查问卷,特别是关于你希望的答疑时间,以便我们安排日程。

本节课中我们一起学习了“机器组织与编程”课程的总体框架、教学团队、学习资源、沟通方式、评分标准以及需要完成的课前准备。下节课,我们将开始进入C语言编程基础部分的学习。

002:计算机工作原理 🖥️

在本节课中,我们将要学习计算机内部是如何工作的。我们将从宏观层面了解计算机系统的构成,并深入探讨一个简单C语言程序从编写到运行的全过程,包括编译、加载和执行。

课程概述

上一节我们介绍了课程的基本信息,本节中我们来看看整个课程的结构安排。

接下来的课程将分为三个基本部分:

  • C语言编程:学习高级编程语言。
  • 汇编编程:学习非常底层的、一次执行一条指令的代码。
  • 系统主题:讨论一些系统级话题,例如虚拟内存、缓存、中断、动态内存分配和操作系统。

计算机系统层次

现在,让我们开始深入今天的主题。首先,花一些时间谈谈计算机是如何工作的。

从最明显的层面看,我们面前有一个大机箱或一台笔记本电脑,这是硬件。同时,我们也在运行一些代码,可能是网页浏览器、自己编写的程序或PowerPoint。在硬件和应用程序之间,我们有软件系统

以下是计算机系统的三个主要层次:

  • 应用程序:例如文字处理、网页浏览、C程序、PowerPoint等我们关心运行的程序。
  • 软件系统(操作系统):连接应用程序与计算机硬件的软件,例如Windows、Linux。也包括硬件驱动程序,例如显卡驱动。
  • 硬件:我们今天将讨论的三个主要硬件部件是CPU(处理器)内存(RAM)硬盘(或固态硬盘)

程序如何运行:冯·诺依曼架构

为了理解程序如何运行,我们需要从一个特定的视角来看:冯·诺依曼架构。这是一个多年前(大约20世纪50年代)描述的计算机系统模型。

该模型中的计算机有一个处理器(CPU) 来执行所有逻辑和数学运算,还有内存来存储数据,两者连接在一起。没有输入和输出,这个模型将毫无价值,因此我们也将输入/输出设备(如硬盘、显示器、键盘、鼠标、网络连接)视为模型的一部分。

从内部物理结构看,如果你打开机箱,可以看到内存芯片、处理器(以及它上面的大风扇)和硬盘。它们都通过主板上的无数导线连接在一起。

源代码的存储位置

现在,思考一个问题:当我编写完代码(例如一个名为 sum.c 的文件)并保存它时,这个文件具体存在于哪里?是在处理器、内存还是硬盘上?

答案是:它存储在硬盘上。当我们编写文件并保存时,它会直接进入硬盘。

编译:从源代码到机器码

处理器无法直接理解像 a = 1; 这样的高级语言。它只能读取机器码二进制码(0和1)。因此,我们需要一个编译器将这种可读的高级代码转换为计算机可以读取的东西。

以下是几个核心定义:

  • 源代码:在文本编辑器中编写并保存的代码(如 sum.c)。
  • 编译器:我们将使用的编译器是 GCC(GNU C Compiler)。
  • 可执行文件(目标文件/机器码):编译后生成的二进制代码文件(如 sum)。

编译后,生成的二进制代码文件(sum)同样存储在硬盘上。

加载:将程序放入内存

在Linux机器上,我们通过命令行(如 ./sum)运行程序。此时,操作系统会运行一个名为加载器的程序。

加载器的工作是将硬盘上的机器码加载到内存(RAM) 中,并分为两个部分:

  • 代码段:存储实际执行的指令(例如 c = a + b;)。
  • 数据段:存储所有变量及其内容。

CPU执行:取指、解码、执行循环

程序加载到内存后,CPU开始执行。CPU内部有少量高速存储单元,称为寄存器,用于临时存放数据。

以执行 c = a + b; 为例(假设 a=1, b=2 已在内存中),CPU需要执行以下步骤:

  1. 加载:从内存中取出 a 的值(1)放入寄存器1。
  2. 加载:从内存中取出 b 的值(2)放入寄存器2。
  3. 加法运算:将寄存器1和寄存器2的值相加,结果存入寄存器3。
  4. 存储:将寄存器3中的结果存回内存中 c 的位置。

CPU执行每一个步骤(如一次加载操作)时,都遵循一个核心循环:

  1. 取指:从内存的代码段获取下一条指令。
  2. 解码:解析该指令,确定其类型(是加载、存储还是数学运算)。
  3. 执行:执行该指令的实际操作(如从内存取数、进行加法计算)。

这个 取指-解码-执行 循环会无限重复,直到程序结束。

构建过程详解

最后,让我们详细了解一下从源代码到可执行文件的完整构建过程。编译器本身是一个由多个程序组成的工具链。

构建过程主要分为以下四个步骤:

  1. 预处理

    • 输入:源代码文件(sum.c)。
    • 工具:预处理器(cpp,C Preprocessor)。
    • 作用:处理源代码中的预处理指令(如 #include),移除注释,生成一个中间文件。
    • 输出:预处理后的源文件(通常为 sum.i)。
  2. 编译

    • 输入:预处理后的文件(sum.i)。
    • 工具:编译器本体(cc1gcc -S)。
    • 作用:将高级语言代码转换为汇编语言代码。汇编语言是低级但人类可读的。
    • 输出:汇编代码文件(通常为 sum.s)。
  3. 汇编

    • 输入:汇编代码文件(sum.s)。
    • 工具:汇编器(as)。
    • 作用:将人类可读的汇编代码转换为机器码(0和1),生成可重定位目标文件
    • 输出:目标文件(通常为 sum.o)。

  1. 链接
    • 输入:一个或多个目标文件(如 sum.o)及所需的库文件(如C标准库)。
    • 工具:链接器(ld)。
    • 作用:将所有目标文件和库合并,解析它们之间的引用关系,生成最终的可执行文件
    • 输出:可执行文件(如 sum)。

总结

本节课中,我们一起学习了计算机工作的基本原理。我们从计算机系统的层次(硬件、操作系统、应用程序)开始,然后以冯·诺依曼架构为模型,跟踪了一个简单C程序从源代码(存储在硬盘)到运行的全过程:包括编译成机器码、由加载器加载到内存、以及CPU通过取指-解码-执行循环来执行指令。最后,我们详细剖析了GCC编译器将源代码构建为可执行文件的四个步骤:预处理、编译、汇编和链接。在接下来的部分,我们将通过实际操作来演示这一过程。

003:数组与指针的等价性 📚

在本节课中,我们将深入学习C语言中的数组和指针,探讨它们之间的紧密联系和关键区别。我们将通过实验来观察内存的分配方式,理解编译器如何处理这些数据结构,并学习如何安全有效地使用它们。

课程回顾与概述 🔄

上一节我们介绍了C语言的基础控制流和数据结构。本节中,我们将重点探讨指针和数组,特别是它们的等价性以及内存模型。

首先,我们快速回顾一下课程进度:

  • 控制流:我们学习了 if 语句、whiledo-whilefor 循环以及基本的函数调用。
  • 数据结构:我们介绍了基本数据类型(char, int, float, double)和一维数组。

今天,我们将深入探讨指针和数组,包括字符数组(字符串),并介绍多维数组的内存模型以及结构体(struct)的基本概念。

指针基础回顾 🧭

让我们从一个简单的指针示例开始,以巩固对指针的理解并连接内存模型。

我们创建两个整数和指向它们的指针,然后打印所有相关信息。

#include <stdio.h>

int main() {
    int a = 3;
    int b = 4;

    int *pa = &a; // 指向a的指针
    int *pb = &b; // 指向b的指针

    // 打印变量a和b的值与地址
    printf("a contains %d at address %p\n", a, &a);
    printf("b contains %d at address %p\n", b, &b);

    // 打印指针的信息:解引用值、指针内容(地址)、指针自身的地址
    printf("*pa = %d, pa contains %p, address of pa is %p\n", *pa, pa, &pa);
    printf("*pb = %d, pb contains %p, address of pb is %p\n", *pb, pb, &pb);

    return 0;
}

运行此程序,输出类似于:

a contains 3 at address 0x0061FF1C
b contains 4 at address 0x0061FF18
*pa = 3, pa contains 0x0061FF1C, address of pa is 0x0061FF14
*pb = 4, pb contains 0x0061FF18, address of pb is 0x0061FF10

内存模型分析
程序的内存被划分为几个部分:

  1. 代码区:存储编译后的机器指令。
  2. 全局/静态区:存储全局变量和字符串常量。
  3. :用于动态内存分配(后续课程讲解)。
  4. :用于函数调用、局部变量和参数。我们的变量 a, b, pa, pb 都存储在栈上。

栈从高地址向低地址增长。每个变量(int 或指针)在32位系统中占用4字节。指针变量存储的是它所指向变量的内存地址。* 操作符用于“解引用”,即跟随指针存储的地址去获取该地址处的值。

编译器差异:不同的编译器(如Windows的MinGW和Linux的GCC)可能以不同的顺序在栈上分配局部变量,但数组元素的地址通常是递增的。

数组与内存布局 🗂️

接下来,我们创建一个整数数组,并观察其在内存中的布局。

#include <stdio.h>
#define ARRAY_MAX 5 // 使用编译器宏定义数组大小

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_16.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_17.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_19.png)

int main() {
    int arr[ARRAY_MAX];
    for(int i = 0; i < ARRAY_MAX; i++) {
        arr[i] = i * 10; // 初始化数组
    }

    // 打印数组元素的值和地址
    for(int i = 0; i < ARRAY_MAX; i++) {
        printf("array[%d] = %d at address %p\n", i, arr[i], &arr[i]);
    }

    // 打印数组名本身
    printf("arr (array name) points to address %p\n", arr);
    printf("Address of arr (&arr) is also %p\n", &arr);

    return 0;
}

输出类似于:

array[0] = 0 at address 0x0061FEF4
array[1] = 10 at address 0x0061FEF8
array[2] = 20 at address 0x0061FEFC
array[3] = 30 at address 0x0061FF00
array[4] = 40 at address 0x0061FF04
arr (array name) points to address 0x0061FEF4
Address of arr (&arr) is also 0x0061FEF4

关键发现

  1. 数组元素在内存中是连续存储的,地址依次递增(每个int占4字节)。
  2. 数组名 arr 本身不是一个存储在某地址的独立指针变量。它更像一个“常量指针”,其值就是数组第一个元素的地址 (&arr[0])。因此,arr&arr 的结果相同。
  3. C语言不进行数组边界检查。如果访问 arr[5](越界),程序可能不会立即崩溃,但会读写未分配的内存,导致不可预知的行为(如覆盖其他变量),这是缓冲区溢出错误的基础。

指针与数组的等价性与差异 ⚖️

数组和指针在访问元素时语法可以互换,这是它们“等价性”的核心。但它们在声明和某些行为上有根本区别。

1. 使用指针访问数组

我们可以创建一个指针,让它指向数组,然后使用指针或数组下标来访问元素。

int arr[5] = {0, 10, 20, 30, 40};
int *parr = arr; // parr 是指向数组的指针变量

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_41.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_47.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_49.png)

// 以下三行输出相同的结果:0
printf("arr[0] = %d\n", arr[0]);
printf("*(arr + 0) = %d\n", *(arr + 0));
printf("*parr = %d\n", *parr);
printf("parr[0] = %d\n", parr[0]); // 指针也能用下标!

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_51.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_53.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_55.png)

// 指针算术:编译器根据类型自动调整步长
parr = parr + 1; // parr 现在指向 arr[1],地址增加了4字节(sizeof(int))
printf("After increment, *parr = %d (should be 10)\n", *parr);

指针算术:当对指针进行加/减运算时,编译器会根据指针指向的数据类型自动调整地址的偏移量。parr + 1 意味着 parr的地址值 + 1 * sizeof(int)

2. 核心差异:可修改性

  • 指针是变量parr 本身存储在内存中(有地址 &parr),其存储的值(指向的地址)可以改变。
  • 数组名是常量arr 是标识符,不是变量。arr = arr + 1; 这样的赋值是非法的,编译器会报错。你不能改变 arr 代表的地址。

3. 数组下标访问的本质

表达式 arr[i] 在编译时会被转换为 *(arr + i)。这意味着:

  • arr[i] 等价于 *(arr + i)
  • i[arr] 这种奇怪的写法在语法上也是合法的,因为它等价于 *(i + arr),但不建议使用。

数组作为函数参数 📤

在C语言中,所有参数都是“按值传递”。但当数组作为函数参数时,传递的实际上是数组首元素的地址(一个值),这模拟了“按引用传递”的效果。

#include <stdio.h>
#define ARRAY_MAX 5

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_85.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_87.png)

// 函数原型:参数可以写为 int arr[] 或 int *arr,它们是等价的。
void printArray(int arr[], int size); // size 需要额外传递,因为数组“退化”为指针

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_89.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_91.png)

int main() {
    int myArr[ARRAY_MAX] = {1, 2, 3, 4, 5};
    printf("In main, address of myArr is %p\n", (void*)myArr);
    printArray(myArr, ARRAY_MAX);
    return 0;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_93.png)

// 函数定义
void printArray(int arr[], int size) { // 这里的arr实际上是一个指针
    printf("In printArray, parameter arr holds address %p\n", (void*)arr);
    for(int i = 0; i < size; i++) {
        printf("%d ", arr[i]); // 依然可以使用下标
    }
    printf("\n");
}

重要

  • 函数内的 arr 是一个独立的指针变量,它存储了主函数中 myArr 的地址。
  • 通过这个指针,函数可以修改原始数组的内容。
  • 函数无法通过 sizeof(arr) 获取原始数组的长度,因为 arr 已退化为指针。因此,通常需要额外传递数组大小参数。

字符数组与字符串 🔤

在C语言中,字符串本质上是字符数组,以空字符 '\0'(ASCII值为0)结尾。

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_117.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_119.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_121.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_123.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_125.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_127.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_129.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_131.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/88960f0f92259fe66d7c97d5819c0fe3_133.png)

int main() {
    // 初始化字符串,编译器会自动添加 '\0'
    char str1[] = "Hello"; // 实际是 {'H','e','l','l','o','\0'},占6字节
    char str2[10] = "World"; // 剩余字节初始化为0或垃圾值

    // 使用指针遍历字符串(直到遇到'\0')
    char *p = str1;
    while(*p != '\0') {
        printf("%c ", *p);
        p++; // 指针移动到下一个字符
    }
    printf("\n");

    // 字符串复制示例(简化版,未考虑安全)
    char src[] = "Source";
    char dest[20];
    char *ps = src;
    char *pd = dest;
    while((*pd++ = *ps++) != '\0'); // 经典的字符串复制循环
    printf("Copied string: %s\n", dest);

    return 0;
}

关键点

  • 字符串字面量(如"Hello")会自动包含终止符 '\0'
  • 许多标准库函数(如strcpy, strlen)都依赖这个终止符来工作。
  • 确保字符数组有足够空间容纳字符串内容终止符 '\0',否则会导致缓冲区溢出。

总结 🎯

本节课我们一起深入学习了C语言中数组和指针的核心概念:

  1. 指针基础:指针存储地址,* 用于解引用,& 用于取地址。
  2. 数组内存布局:数组元素在内存中连续存储。数组名是一个指向首元素的常量指针。
  3. 指针与数组的等价性:可以使用指针算术 *(arr + i) 来访问数组元素 arr[i],反之亦然。
  4. 核心差异:指针是变量,可修改其指向;数组名是常量,不可重新赋值。
  5. 函数参数:传递数组时,实际传递的是其首地址,函数内通过指针操作原数组。
  6. 字符串:C语言中的字符串是以 '\0' 结尾的字符数组,操作时需特别注意边界和终止符。

理解数组和指针的底层内存模型,是编写高效、安全C程序的关键,也是后续学习计算机体系结构和操作系统的基础。请务必动手实验代码,观察不同情况下的内存地址和行为。

004:多维数组

在本节课中,我们将要学习C语言中的多维数组,特别是二维数组。我们将探讨它们在内存中的存储方式,如何通过指针访问其元素,以及如何将它们传递给函数。理解这些概念对于掌握C语言的数据组织和底层内存操作至关重要。

数组与指针的回顾

上一节我们介绍了一维数组及其与指针的紧密联系。本节中,我们来看看如何将这种联系扩展到二维数组。

二维数组的声明与初始化

二维数组可以看作是一个矩阵或一个字符串数组(即字符数组的数组)。以下是声明和初始化一个二维数组的方法。

#define MAX_ROWS 3
#define MAX_COLS 5

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/b0e15b4171dcd21b16fb58832af05d93_9.png)

int main() {
    int m[MAX_ROWS][MAX_COLS];
    for (int i = 0; i < MAX_ROWS; i++) {
        for (int j = 0; j < MAX_COLS; j++) {
            m[i][j] = (i + 1) * 10 + (j + 1);
        }
    }
}

二维数组在内存中的布局

二维数组在内存中是按行优先顺序连续存储的。这意味着第一行的所有元素之后紧跟着第二行的所有元素,依此类推。

为了验证这一点,我们可以打印出每个元素的地址。

for (int i = 0; i < MAX_ROWS; i++) {
    for (int j = 0; j < MAX_COLS; j++) {
        printf("m[%d][%d] = %d stored at address %p\n", i, j, m[i][j], &m[i][j]);
    }
}

输出将显示地址是连续递增的,证实了线性存储的布局。

通过指针访问二维数组元素

与一维数组类似,我们可以通过指针算术来计算二维数组中任意元素的地址。给定一个二维数组 m,要访问元素 m[i][j],其地址计算公式为:

地址 = 基地址 + (i * 列数 + j) * sizeof(元素类型)

在C代码中,使用指针访问该元素的语法是:

int value = *(*(m + i) + j); // 等价于 m[i][j]

理解这个公式的关键在于,编译器需要知道每一行有多少列(即MAX_COLS),才能正确计算跨越行时的偏移量。

将二维数组传递给函数

将二维数组传递给函数时,情况变得有些微妙。函数需要知道数组的列数来进行正确的指针运算。

以下是传递二维数组给函数的正确方式:

void print_matrix(int mat[][MAX_COLS], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < MAX_COLS; j++) {
            printf("%d\t", mat[i][j]);
        }
        printf("\n");
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/b0e15b4171dcd21b16fb58832af05d93_39.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/b0e15b4171dcd21b16fb58832af05d93_41.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/b0e15b4171dcd21b16fb58832af05d93_43.png)

int main() {
    int m[MAX_ROWS][MAX_COLS];
    // ... 初始化数组
    print_matrix(m, MAX_ROWS);
}

重要注意事项:

  • 在函数参数中,第一维的大小(MAX_ROWS)可以被省略,编译器会忽略它。
  • 但是,第二维的大小(MAX_COLS)必须明确提供。如果传递的数组列数与函数声明的不匹配,编译器会发出警告,并且程序会因错误的地址计算而访问到错误的数据。
  • 在函数内部,数组参数 mat 本身是一个局部指针变量,它存储了传入数组的基地址。它的地址与主函数中数组 m 的地址不同。

实验:错误的列维度导致的后果

如果函数声明中指定的列数与实际数组的列数不一致,会导致地址计算错误。例如,若实际数组有5列,但函数预期3列,那么访问第二行第一列时:

  • 正确地址应为:基地址 + (1 * 5 + 0) * sizeof(int)
  • 错误计算为:基地址 + (1 * 3 + 0) * sizeof(int)

这将导致访问到第一行第4个元素的位置,从而打印出错误的值。这个实验深刻揭示了二维数组底层寻址的原理。

更高维度的数组

对于三维或更高维度的数组,原理是相同的。在函数声明中,只有第一维的大小可以省略,其余所有维度的大小都必须指定,以便编译器进行地址计算。

例如,一个三维字符数组(可表示字符串数组)的函数原型可能如下:

void process_strings(char strings[][MAX_WORD_LEN][MAX_CHARS], int num_strings);

总结

本节课中我们一起学习了C语言中多维数组的核心知识。我们了解到二维数组在内存中是线性连续存储的,并通过公式 基地址 + (行索引 * 列数 + 列索引) * 元素大小 来访问元素。关键在于,将数组传递给函数时,必须提供除第一维之外所有维度的大小,以确保正确的指针运算。理解这些底层细节有助于我们编写更安全、高效的C语言代码,并避免因数组维度不匹配而导致的隐蔽错误。

005:结构体 🏗️

在本节课中,我们将要学习C语言中的结构体。结构体是一种复合数据类型,允许我们将不同类型的数据组合在一起。我们将学习如何定义结构体、创建结构体变量、访问其成员,以及如何通过值和指针将结构体传递给函数。最后,我们还会了解如何使用typedef关键字为结构体创建别名,以简化代码。

定义结构体

结构体在C语言中用于创建自定义的复合数据类型。与Java或C++中的类不同,C的结构体只包含数据成员,不包含函数。

定义一个结构体的基本语法如下:

struct 结构体名 {
    数据类型 成员1;
    数据类型 成员2;
    // ... 更多成员
};

例如,我们可以定义一个表示学生的结构体:

struct student {
    char *name; // 姓名,字符指针
    int id;     // 学号,整数
};

这里,struct student是一个新的数据类型,它包含两个成员:一个指向字符串的指针name和一个整数id

创建和使用结构体变量

定义了结构体类型后,我们就可以声明该类型的变量,并为其成员赋值。

以下是创建结构体变量并访问其成员的步骤:

  1. 声明变量:使用结构体类型声明一个变量。
  2. 访问成员:使用点运算符(.)来访问结构体变量的成员。

示例代码如下:

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/c027cd881a09cfe95dd09079c3f66e22_27.png)

struct student {
    char *name;
    int id;
};

int main() {
    // 声明一个student类型的变量
    struct student student1;

    // 为结构体成员赋值
    student1.name = "Mike";
    student1.id = 123456;

    // 打印结构体成员
    printf("Name: %s\n", student1.name);
    printf("ID: %d\n", student1.id);

    return 0;
}

编译并运行这段代码,将输出学生的姓名和学号。

结构体在内存中的布局

为了理解结构体在内存中是如何存储的,我们可以打印出结构体及其成员的地址。

以下是查看地址的代码示例:

printf("Address of student struct: %p\n", &student1);
printf("Address of student.name: %p\n", &(student1.name));
printf("Address of student.id: %p\n", &(student1.id));

运行后,你会发现结构体变量的地址与其第一个成员(name)的地址相同。这表明结构体在内存中是连续存储的,起始地址就是第一个成员的地址。

字符串字面量(如"Mike")通常存储在内存的只读区域(文本段),其地址远大于栈上局部变量(如student1)的地址。结构体中的name指针存储的正是这个字符串的地址。

将结构体传递给函数

在C语言中,函数参数默认是“按值传递”。这意味着当我们将一个结构体传递给函数时,函数内部会得到该结构体的一个完整副本。

按值传递结构体

以下是一个按值传递结构体的函数示例:

void printStudent(struct student s) {
    printf("Name (in function): %s\n", s.name);
    printf("ID (in function): %d\n", s.id);
    printf("Address of s in function: %p\n", &s);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/c027cd881a09cfe95dd09079c3f66e22_44.png)

int main() {
    struct student student1 = {"Mike", 123456};
    printStudent(student1); // 传递结构体副本
    return 0;
}

在函数printStudent内部,参数sstudent1的一个完整拷贝,它拥有自己独立的内存空间。修改s的成员不会影响main函数中的student1

对于大型结构体,这种复制方式会消耗较多时间和内存。

按指针传递结构体

更高效的方式是传递指向结构体的指针。这样,函数内部操作的是原始数据,避免了复制整个结构体的开销。

当使用指针时,我们需要使用箭头运算符(->)来访问结构体成员。

以下是一个按指针传递结构体的函数示例:

void printStudentByPointer(struct student *s) {
    // 使用箭头运算符访问成员
    printf("Name (via pointer): %s\n", s->name);
    printf("ID (via pointer): %d\n", s->id);
    printf("Address of pointer s: %p\n", s); // s本身存储的是地址
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/c027cd881a09cfe95dd09079c3f66e22_67.png)

int main() {
    struct student student1 = {"Mike", 123456};
    printStudentByPointer(&student1); // 传递结构体的地址
    return 0;
}

printStudentByPointer函数中,参数s是一个指向struct student的指针。我们通过&student1student1的地址传递给函数。函数内部通过s->names->id来访问原始结构体的成员。

箭头运算符->是解引用和成员访问的组合简写,s->name等价于(*s).name

使用typedef简化结构体

在C代码中反复书写struct student可能会显得冗长。我们可以使用typedef关键字为结构体类型创建一个别名。

使用typedef有两种常见方式:

方式一:先定义结构体,再创建别名

struct student {
    char *name;
    int id;
};
typedef struct student Student; // 现在可以用Student代替struct student

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/c027cd881a09cfe95dd09079c3f66e22_80.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/c027cd881a09cfe95dd09079c3f66e22_81.png)

Student stu1; // 声明变量

方式二:在定义结构体的同时创建别名(更简洁)

typedef struct {
    char *name;
    int id;
} Student; // Student 就是新类型的名字

Student stu1; // 声明变量

使用别名后,代码变得更加简洁。typedef的另一个常见用途是为基本类型创建别名,以便于未来统一修改类型,例如将程序中所有float改为double只需修改一处typedef定义。

本节课中我们一起学习了C语言结构体的核心知识。我们了解了如何定义和初始化结构体,如何访问其成员,以及结构体在内存中的布局。我们还探讨了通过值和指针两种方式将结构体传递给函数的区别与优劣,并学会了使用typedef来简化结构体类型名。掌握结构体是组织复杂数据的基础,对于后续学习数据结构和系统编程至关重要。

006:类型转换与字节序 🖥️

在本节课中,我们将要学习C语言中的类型转换机制以及计算机存储多字节数据的方式——字节序。理解这些底层概念对于编写高效、可移植的代码至关重要。

上一节我们回顾了C语言的基础,本节中我们来看看数据在内存中的具体表示和转换规则。

类型转换回顾 🔄

类型转换是指将一种数据类型的值转换为另一种数据类型。当操作涉及不同大小的类型或不同的符号性(有符号/无符号)时,转换顺序会影响最终结果。

转换顺序的重要性

考虑一个将短整型(short)转换为无符号整型(unsigned int)的例子。这涉及两个变化:从2字节变为4字节,以及从有符号变为无符号。问题是,哪个转换先发生?

答案是:首先改变大小(进行符号扩展),然后改变符号性。

以下代码演示了这一过程:

short x = -12345; // 假设为 0xCFC7
unsigned int y = (unsigned int) x;

转换步骤:

  1. 符号扩展:将2字节的 short 扩展为4字节的 int。由于原始值是负数(最高位为1),扩展的高位字节全部填充为1。0xCFC7 变为 0xFFFFCFC7
  2. 改变符号性:将 int 重新解释为 unsigned int。位模式 0xFFFFCFC7 不变,但C语言会将其视为一个巨大的正数(4,294,901,191)。

如果顺序相反(先改变符号性,再改变大小),结果将完全不同:

  1. 先将 short 重新解释为 unsigned short。位模式 0xCFC7 不变,但值变为正数(53,191)。
  2. 再将 unsigned short 零扩展为 unsigned int。高位填充0,最终结果为 0x0000CFC7,即53,191。

比较操作中的隐式转换

在比较操作中,如果操作数类型不同,C语言会自动进行转换。默认规则是将有符号数转换为无符号数。

考虑以下表达式:

int a = -1;
unsigned int b = 0;
int result = (a < b); // 结果为 0 (false)

尽管直觉上 -1 < 0 为真,但在比较时,-1 被隐式转换为无符号数,变成了一个非常大的正数(0xFFFFFFFF),导致比较结果为假。这是编写代码时需要特别注意的地方。

字节序:多字节数据的存储方式 🥚

字节序(Endianness)指的是计算机在内存中存储多字节数据(如 int, long)时,字节的排列顺序。

两种字节序

假设有一个4字节的十六进制数 0x87654321,其四个字节分别是:0x87(最高有效字节)、0x650x430x21(最低有效字节)。

以下是两种存储方式:

  • 大端序:将最高有效字节存储在最低的内存地址。
    内存地址递增方向: 0x1000 -> 0x1001 -> 0x1002 -> 0x1003
    存储的字节:     0x87     0x65     0x43     0x21
    
  • 小端序:将最低有效字节存储在最低的内存地址。
    内存地址递增方向: 0x1000 -> 0x1001 -> 0x1002 -> 0x1003
    存储的字节:     0x21     0x43     0x65     0x87
    

术语“字节序”源自《格列佛游记》中关于打鸡蛋应该从大端还是小端打破的争论。在计算机领域,不同的处理器架构采用不同的字节序(如x86为小端序,某些网络协议采用大端序),这在网络通信和数据交换时需要特别注意。

如何检测系统的字节序

我们可以通过编写一个小程序来检测当前机器的字节序。思路是:创建一个多字节整数(如 int),然后通过字符指针(char*)逐个访问其字节,观察它们的顺序。

以下是示例代码:

#include <stdio.h>

int main() {
    int num = 0x87654321;
    char *bytePtr = (char *)# // 将int指针强制转换为char指针

    printf("内存地址及内容:\n");
    for (int i = 0; i < 4; i++) {
        printf("地址 %p: 值 0x%hhx\n", (void*)(bytePtr + i), bytePtr[i]);
    }
    return 0;
}

如果输出显示最低内存地址存储的是 0x21,则系统为小端序;如果存储的是 0x87,则为大端序


本节课中我们一起学习了C语言中类型转换的底层机制,特别是转换顺序对结果的影响,以及计算机存储多字节数据时的字节序概念。理解这些原理有助于我们避免因隐式转换或平台差异导致的程序错误,并编写出更健壮、可移植的代码。在接下来的课程中,我们将深入探讨位运算,这是与硬件交互的另一个基础工具。

007:位运算 🧮

在本节课中,我们将要学习C语言中的位运算。位运算允许我们直接操作数据的二进制位,这对于理解计算机底层工作原理、编写高效代码以及处理特定任务(如图形数据)至关重要。我们将介绍六种主要的位运算符,并通过实例展示其应用。

概述

位运算是对二进制位进行的数学操作。它让我们得以一窥CPU指令集的能力,并学习如何利用这些知识来提升代码的运行速度。我们将探讨位掩码、打包整数和位标志等核心概念。

位运算符介绍

C语言提供了六种位运算符,用于直接操作整数中的二进制位。

以下是这些运算符的列表:

  • 按位或 (OR):运算符为 |
  • 按位与 (AND):运算符为 &
  • 按位取反 (NOT):运算符为 ~
  • 按位异或 (XOR):运算符为 ^
  • 左移 (Left Shift):运算符为 <<
  • 右移 (Right Shift):运算符为 >>

运算符详解与示例

上一节我们列出了所有位运算符,本节中我们来看看它们具体是如何工作的。理解它们的最佳方式是通过具体的二进制示例。

按位或 (|)

按位或运算符对两个操作数的每一位进行比较。如果任意一个对应位为1,则结果位为1;只有两个位都为0时,结果位才为0。

示例
假设 x = 1010 (十进制10), y = 0110 (十进制6)。

x: 1 0 1 0
y: 0 1 1 0
-----------
x|y: 1 1 1 0  (十进制14)

在B0位(最右位),0|0=0;在B1位,1|1=1;在B2位,0|1=1;在B3位,1|0=1。

按位与 (&)

按位与运算符同样对每一位进行操作。只有当两个对应位都为1时,结果位才为1,否则为0。

示例
使用相同的 xy

x: 1 0 1 0
y: 0 1 1 0
-----------
x&y: 0 0 1 0  (十进制2)

在B0位,0&0=0;在B1位,1&1=1;在B2位,0&1=0;在B3位,1&0=0。

按位取反 (~)

按位取反是一元运算符,它对操作数的每一位执行逻辑“非”操作,即将1变为0,将0变为1。这相当于求二进制数的“反码”。

示例
x = 1010 取反。

x: 1 0 1 0
-----------
~x: 0 1 0 1  (十进制5)

按位异或 (^)

按位异或运算符在对应位不相同时结果为1,相同时结果为0。

示例
使用 xy

x: 1 0 1 0
y: 0 1 1 0
-----------
x^y: 1 1 0 0  (十进制12)

在B0位,00=0;在B1位,11=0;在B2位,01=1;在B3位,10=1。

移位运算符 (<<, >>)

移位运算符将二进制位向左或向右移动指定的位数。

左移 (<<):将所有位向左移动,右侧空出的位用0填充,左侧移出的位被丢弃。
示例1010 << 2 结果为 101000

右移 (>>):行为取决于操作数的类型。

  • 对于无符号数,执行逻辑右移:左侧空出的位用0填充。
  • 对于有符号数,通常执行算术右移:左侧空出的位用原数的符号位(最高位)填充。这可以保持负数的符号。

示例(算术右移)
假设有符号数 x = 10011010(负数)。
x >> 2 可能结果为 11100110,左侧移入了符号位1。

位运算的应用

理解了基本操作后,我们来看看位运算在实际编程中的强大用途。

位掩码

位掩码用于从数据中提取或设置特定的位。其核心思想是使用按位与 (&) 操作来“屏蔽”掉我们不关心的位。

示例:提取最低4位
假设我们有一个8位数 x = 10010010,想得到它的最低4位 0010
我们可以创建一个掩码 00001111(十进制15),然后进行按位与操作:

x:    1 0 0 1 0 0 1 0
掩码: 0 0 0 0 1 1 1 1
-----------------------
结果: 0 0 0 0 0 0 1 0

代码实现int low_bits = x & 0xF; // 0xF 是十六进制,等于二进制 1111

示例:提取最高4位并移至低位
要提取 x = 10010010 的最高4位 1001 并使其位于结果的低4位,需要两步:

  1. 右移x >> 4 得到 00001001
  2. 掩码:与 00001111 进行按位与(此例中可省略,因为高位已是0)。

代码实现int high_bits = (x >> 4) & 0xF;

打包整数

在计算机图形学等领域,常需要将多个小整数(如颜色通道)打包到一个大整数中以节省空间。例如,将红(R)、绿(G)、蓝(B)、透明度(A)各1字节(0-255)打包成一个4字节(32位)整数。

数据布局

位位置: 31-24    23-16    15-8     7-0
通道:     R        G        B        A

示例:提取绿色通道(G)
假设打包后的颜色值为 0x00FF00FF(绿色全满,其他为0)。绿色通道位于第16到23位。

  1. 右移:将数值右移16位,使绿色通道移动到最低8位:color >> 16
  2. 掩码:与 0xFF(二进制11111111)进行按位与,确保只保留低8位。

代码实现int green = (packed_color >> 16) & 0xFF;

位标志(位域)

布尔值只需要1位存储,但C语言中一个boolint会占用更多空间。位标志利用一个整数的不同位来存储多个布尔值,极大节省内存。

步骤

  1. 为每个标志定义唯一的位位置(掩码)。
    #define FLAG_OLD      (1 << 0) // 0001
    #define FLAG_EMPLOYED (1 << 1) // 0010
    #define FLAG_PARENT   (1 << 2) // 0100
    
  2. 使用按位或 (|) 来组合设置多个标志。
    int person_status = FLAG_OLD | FLAG_EMPLOYED | FLAG_PARENT; // 二进制 0111
    
  3. 使用按位与 (&) 来检查某个标志是否被设置。
    if (person_status & FLAG_OLD) {
        // 这个人是“老”的,条件为真
    }
    if (!(person_status & FLAG_FERRARI)) {
        // 这个人没有法拉利,条件为真
    }
    

用于快速乘除的技巧

移位运算可以用于优化乘以或除以2的幂次方(如2、4、8、16)的操作,因为这在CPU上通常比直接乘除更快。

原理

  • 将一个数左移 n 位,相当于乘以 2^n
    • 例如:14 << 1 等于 28 (142)。14 << 2 等于 56 (144)。
  • 将一个数右移 n 位,相当于除以 2^n(对于非负整数是整除)。
    • 例如:28 >> 1 等于 14 (28/2)。28 >> 2 等于 7 (28/4)。

代码示例

int multiplyBy8 = x << 3; // 等同于 x * 8
int divideBy4 = x >> 2;   // 等同于 x / 4 (x为非负整数时)

注意:对于有符号负数,右移是算术右移,结果与除法略有不同(向负无穷取整,而C语言除法向零取整)。此技巧最安全地用于无符号数。

总结

本节课中我们一起学习了C语言的位运算。我们从六种基本的位运算符(|, &, ~, ^, <<, >>)开始,详细了解了它们的功能。接着,我们探讨了位运算的三个重要应用:使用位掩码提取和操作特定位;利用打包整数高效存储多个数据;以及通过位标志紧凑地存储布尔值集合。最后,我们学习了一个优化技巧:使用移位运算来加速乘以或除以2的幂次方的计算。掌握位运算能帮助你写出更高效、更接近机器底层的代码,并更好地理解数据在计算机中的表示形式。

008:二进制算术 🧮

在本节课中,我们将学习二进制算术的基础知识,包括无符号数和有符号数的加法、减法,以及溢出和借位的概念。我们将通过简单的例子来理解这些操作在计算机底层是如何实现的。


无符号数加法与溢出

上一节我们介绍了二进制的基本概念,本节中我们来看看无符号数的加法。二进制加法的过程与十进制类似,但只涉及0和1两个数字。

以下是二进制加法的基本步骤:

  1. 从最低有效位(最右边)开始相加。
  2. 如果和为2(即 1 + 1),则在该位写下0,并向高位进1(进位)。
  3. 重复此过程,直到所有位都处理完毕。

例如,在一个3位机器上计算 3 + 1

  011 (3)
+ 001 (1)
------
  100 (4)

过程如下:

  • 个位:1 + 1 = 2,写0,进1。
  • 二位:1 + 0 + 进位1 = 2,写0,进1。
  • 四位:0 + 0 + 进位1 = 1,写1。
    最终结果为 100(二进制),即十进制的4。

然而,计算机的存储空间是有限的。在3位系统中,无符号数只能表示0到7。当我们计算的结果超出这个范围时,就会发生溢出

例如,计算 5 + 3

  101 (5)
+ 011 (3)
------
 1000 (8)

理论上结果是 1000(十进制8)。但由于我们只有3位存储空间,只能保留最低的3位 000。因此,存储在内存中的结果变成了0,而不是8。这就像数字在一个圆环上,超过了最大值7后,又从0开始。

判断溢出的一个简单方法是:如果两个正数相加,得到的结果小于其中较大的那个数,则发生了溢出。


二进制减法与借位

理解了加法后,我们来看看减法。二进制减法也遵循类似十进制的“借位”规则。

以下是二进制减法的基本步骤:

  1. 从最低有效位开始相减。
  2. 如果被减数小于减数,则需要从高位“借位”。
  3. 借位相当于从高位减去1,并在当前位加上2(因为二进制中高一位的值是当前位的两倍)。

例如,计算 2 - 1

  010 (2)
- 001 (1)
------
  001 (1)

过程如下:

  • 个位:0 - 1 不够减,从二位借位。二位变成 1-1=0,个位变成 2-1=1
  • 二位:0 - 0 = 0
  • 四位:0 - 0 = 0
    最终结果为 001,即十进制的1。

有符号数与补码运算

在计算机中,负数通常用补码来表示。使用补码的好处是,可以将减法运算转换为加法运算,简化硬件设计。

补码的转换公式分为两步:

  1. 取反:将原二进制数的每一位取反(0变1,1变0),得到反码
  2. 加一:在反码的基础上加1,得到补码

例如,求 -1 在3位系统中的补码表示:

  1. +1 的二进制是 001
  2. 取反得到 110
  3. 加一:110 + 001 = 111
    因此,-1 的补码是 111

现在,我们可以用加法来做减法。计算 2 - 1 等价于计算 2 + (-1)

  010 (2)
+ 111 (-1的补码)
------
 1001 (理论结果)

我们只保留3位,得到 001,即十进制的1。注意,这里最高位的进位 1 被丢弃了,这是补码运算的正常现象。


溢出与下溢

对于有符号数,同样存在表示范围限制。在3位补码系统中,可表示的范围是 -4 到 3。

  • 溢出:当运算结果大于可表示的最大正数时发生。
  • 下溢:当运算结果小于可表示的最小负数时发生。

例如,计算 -2 - 3(预期结果是-5):

  1. -2 的补码是 110
  2. +3 的二进制是 011
  3. 执行减法 110 - 011(或等价地 110 + (-3的补码))。
  4. 最终在3位系统中得到的结果是 011,即十进制的正3,这显然是错误的,因为实际结果-5已经超出了3位补码的表示范围(-4到3),发生了下溢

总结

本节课中我们一起学习了二进制算术的核心概念:

  1. 无符号数加法:涉及进位操作,结果超出位数限制会导致溢出,表现为数值“回绕”。
  2. 二进制减法:涉及借位操作,规则与十进制类似。
  3. 有符号数与补码:负数以补码形式存储,使得减法可以转换为加法。转换公式为 取反后加一
  4. 溢出与下溢:当运算结果超出数据类型所能表示的范围时发生,会导致结果错误。对于有符号数,大于最大正数为溢出,小于最小负数为下溢。

理解这些底层原理对于编写正确、高效的代码至关重要,尤其是在处理边界数值时。

009:文件输入与输出 📂

在本节课中,我们将学习C语言中文件输入与输出的基本操作。文件操作是程序与外部数据交互的重要方式,无论是读取配置文件、处理日志还是保存用户数据,都离不开它。我们将从简单的文件读取开始,逐步介绍如何写入文件,并对比C标准库函数与Linux系统调用的不同用法。

打开与读取文件 📖

上一节我们介绍了程序的基本结构,本节中我们来看看如何打开一个文件并读取其中的内容。在C语言中,我们使用 FILE 指针来代表一个文件流,并通过标准库函数来操作它。

以下是使用 fopen 打开文件并读取的基本步骤:

  1. 定义缓冲区大小:使用常量定义缓冲区大小,避免硬编码,便于后续调整。
    #define MAX 1024
    
  2. 声明文件指针与缓冲区:声明一个 FILE 指针和一个字符数组作为数据缓冲区。
    FILE *fp;
    char buffer[MAX];
    
  3. 打开文件:使用 fopen 函数,指定文件名和模式(如 "r" 表示只读)。
    fp = fopen("gates_quote.txt", "r");
    
  4. 检查打开是否成功:如果 fopen 返回 NULL,则表示文件打开失败。
    if (fp == NULL) {
        exit(1); // 或 return 1;
    }
    
  5. 循环读取文件内容:使用 fgets 函数逐行读取文件内容,直到文件结束。
    while (fgets(buffer, MAX, fp)) {
        printf("%s", buffer);
    }
    
  6. 关闭文件:使用 fclose 函数关闭文件指针,释放系统资源。
    fclose(fp);
    

注意:始终检查 fopen 的返回值并记得关闭文件是良好的编程习惯。即使程序结束时会自动关闭,显式关闭也能避免资源泄漏。

写入与读取二进制文件 💾

上一节我们学会了读取文本文件,本节中我们来看看如何写入数据到文件,然后再将其读回,特别是针对二进制数据的操作。

以下是写入并读取二进制数据的示例步骤:

  1. 以写入模式打开文件:使用 "wb" 模式打开文件以进行二进制写入。
    fp = fopen("bar.bin", "wb");
    
  2. 准备并写入数据:定义一个变量(如整数),使用 fwrite 将其写入文件。fwrite 需要数据的地址、每个元素的大小、元素数量以及文件指针。
    int num_to_write = 0x12345678;
    fwrite(&num_to_write, sizeof(int), 1, fp);
    fclose(fp); // 写入后关闭
    
  3. 以读取模式重新打开文件:使用 "rb" 模式打开同一个文件进行二进制读取。
    fp = fopen("bar.bin", "rb");
    
  4. 读取数据:使用 fread 函数将数据从文件读入另一个变量。fread 的参数与 fwrite 类似。
    int num_to_read;
    fread(&num_to_read, sizeof(int), 1, fp);
    printf("Read: 0x%x\n", num_to_read);
    fclose(fp);
    

提示:使用 hexdump -C bar.bin 命令可以在终端查看二进制文件的实际字节内容,验证写入是否正确。

使用Linux系统调用进行文件操作 🐧

除了C标准库,我们还可以直接使用Linux系统调用来操作文件。这种方式更底层,但需要注意它可能不具备跨平台性,且不会在程序结束时自动关闭文件。

以下是使用系统调用 openwriteclose 的示例:

  1. 打开文件:使用 open 系统调用,它返回一个文件描述符(一个整数),而不是 FILE 指针。参数包括文件名和一系列标志(如 O_CREAT | O_WRONLY | O_APPEND)。
    int fd = open("testfile.txt", O_CREAT | O_WRONLY | O_APPEND, 0644);
    if (fd < 0) {
        // 处理错误
    }
    
  2. 写入数据:使用 write 系统调用,需要文件描述符、要写入的数据地址和字节数。
    char *str = "Hello, World!";
    write(fd, str, strlen(str)); // 注意计算长度时不包括字符串结尾的 '\0'
    
  3. 关闭文件:使用 close 系统调用关闭文件描述符,这一步至关重要。
    close(fd);
    

重要区别fprintf 是C标准库函数,用于格式化输出到文件流。write 是系统调用,直接向文件描述符写入原始字节。系统调用打开的文件在程序异常终止时可能不会自动关闭,因此需要格外小心。

总结 📝

本节课中我们一起学习了C语言文件输入输出的核心知识。我们掌握了使用C标准库函数(fopenfgets/freadfputs/fwritefclose)安全地读写文本与二进制文件。同时,我们也了解了更底层的Linux系统调用(openread/writeclose)的基本用法及其注意事项。记住,无论使用哪种方式,检查操作是否成功以及及时关闭文件都是编写健壮程序的关键。在接下来的编程作业中,你将有机会实践这些概念。

010:汇编语言入门 🖥️

在本节课中,我们将要学习汇编语言的基础知识。我们将了解程序如何从C语言源代码编译成机器可执行的二进制文件,并深入探讨CPU如何执行指令。通过学习汇编语言,我们可以更好地理解计算机底层的工作原理,并可能编写出更高效的代码。

概述

在课程的第一部分,我们学习了C语言。现在,我们进入中间部分,将目光投向更低一层的汇编语言,了解它如何与机器硬件连接。课程的最后部分将探讨一些专题。本周,我们已经了解了位操作、数据在内存中的存储方式、二进制算术,并以C语言的文件操作作为结尾。现在,我们将深入探讨汇编语言指令。

程序的运行过程

首先,让我们快速回顾一下程序运行的过程。当我们编写C语言源代码(绿色部分)后,会使用GCC编译器将其编译成一个二进制可执行文件。

源代码以纯文本形式存储在磁盘上,而二进制文件则包含机器运行所需的所有0和1。

当我们运行程序时,二进制文件会从磁盘加载到内存中。内存通常分为四个部分:

  • 代码段:存放编译后的机器指令(0和1),处理器将根据这些指令执行命令。
  • 数据段:存放全局变量。例如,在项目2中,所有的字符串都是全局变量,它们就存储在数据段。
  • :存放动态分配的内存,任何使用malloc分配的内存都会出现在堆上。
  • 运行时栈:用于函数调用。图中展示了main函数以及其中的变量AB,而C尚未定义。

CPU如何工作

CPU执行指令的过程可以分为三个步骤:

  1. 取指:从内存中获取二进制指令。
  2. 译码:将指令分解成各个组成部分,例如识别出这是一条加法指令,并确定要相加的两个操作数以及结果的存放位置。
  3. 执行:执行该指令。

这个过程会不断重复,获取下一条指令。

然而,设计计算机架构时面临一个挑战:CPU速度极快(每秒可执行约十亿次操作,即每次操作一纳秒),而从内存中读取一个值到处理器(例如,将变量A的值移入处理器进行加法运算)的速度大约要慢100倍。为了解决内存速度慢的问题,我们会在处理器内部放置少量但速度极快的存储器,称为寄存器

寄存器有特定的名称,如%eax%ebx%ecx等,它们被称为通用寄存器。在进行数学运算之前,我们会先将内存中的值移动到这些寄存器中。此外,还有一个指令指针(或程序计数器),它指向即将执行的下一条指令。

从C代码到汇编指令

上一节我们介绍了CPU的基本工作流程和寄存器。本节中我们来看看C代码是如何被翻译成汇编指令的。

以C代码C = A + B;为例,这条语句实际上会被翻译成多条汇编指令:

  1. 将内存中地址为100(假设是变量A的位置)的值移动到寄存器%eax中。
  2. 将内存中变量B的值移动到另一个寄存器,例如%ebx中。
  3. 执行加法指令addl %ebx, %eax。这条指令的含义是:将源寄存器%ebx的值加到目标寄存器%eax上,并将结果存储在目标寄存器%eax中。
  4. 最后,将寄存器%eax中的结果(即和)移回内存中变量C的位置。

每个mov(移动)指令都相对较慢,而且通用寄存器的数量有限。但是,如果在循环中反复操作某些值(例如每次循环将值加1),并且能将这些值保留在处理器的寄存器中,那么这些操作将以纳秒级的速度执行,非常快。如果需要像上面例子那样频繁地在内存和寄存器之间来回移动数据,速度就会慢得多。

编译过程详解

现在,让我们更详细地回顾一下编译过程。从源代码到可执行文件,主要经历四个步骤:

  1. 预处理:处理所有编译器指令。例如,对于#include语句,会直接复制粘贴被包含文件的内容;对于#define,会创建编译器变量并在代码中替换。
  2. 编译:这是最复杂的部分,编译器会将C代码分解成许多独立的步骤,生成汇编代码文件。汇编代码可读性较低,但在对性能要求极高的特殊应用中,人们仍会手动编写汇编代码。我们学习汇编的主要目的是能够查看代码底层发生了什么,从而可能编写出更高效的C代码。
  3. 汇编:汇编器将汇编代码几乎逐行翻译成机器码(二进制指令)。
  4. 链接:将多个目标文件及库文件链接成一个完整的可执行文件。

汇编语言与X86架构

就像有多种高级编程语言一样,也存在多种汇编语言,它们针对不同的处理器架构设计。本课程将专注于Intel的X86汇编语言,它最初为Intel 8086系列处理器设计,并一直沿用至今。

X86汇编主要有两种语法格式:一种由AT&T开发,用于Unix系统;另一种由Intel开发,用于MS-DOS和Windows。它们非常相似,一个主要区别是操作数的顺序(源操作数和目标操作数的先后顺序)。本课程将使用AT&T格式的语法。

X86 CPU寄存器

如果我们更深入地观察CPU中的寄存器,会发现有六个主要的通用寄存器。它们的名称都以%e开头(e代表“扩展”),表示这是一个32位的寄存器。

以下是主要的寄存器及其部分:

  • %eax%ebx%ecx%edx: 这是四个主要的通用寄存器。
    • 每个都可以作为完整的32位寄存器(%eax)使用。
    • 也可以只访问其低16位(%ax)。
    • 对于%ax,还可以进一步分为高8位(%ah)和低8位(%al)。%ebx%ecx%edx同理。
  • %esi%edi: 另外两个通用寄存器,有特定用途(例如用于字符串操作),但通常也可用于一般计算。
  • %esp (栈指针), %ebp (帧指针): 两个特殊的寄存器,专门用于管理函数调用栈。目前建议不要随意操作它们。

本讲核心主题

在接下来的讲解中,我们将聚焦于四个核心主题:

  1. 数据传送:如何将数据移入寄存器。
  2. 算术运算:如何用寄存器进行加、减、乘、除等基本运算。
  3. 内存访问:如何连接CPU寄存器和内存,从内存中读取数据或将数据写回内存。
  4. 位操作:在汇编级别进行位运算。

数据传送指令

首先,我们来看如何将数据移动到寄存器中。使用的指令是mov,其基本语法是 mov 源操作数, 目标操作数。目标操作数通常是一个寄存器。

例如,要将数字27存入%eax寄存器,可以这样写:

movl $27, %eax

这里,$符号表示这是一个立即数(即字面量数值)。数值可以用十进制或十六进制表示。例如,将十六进制数0x100存入%ebx寄存器:

movl $0x100, %ebx

mov指令通常带有后缀,表示操作的数据大小:

  • movl: 移动“长字”,即4字节(32位)。例如:movl $30, %ebx
  • movw: 移动“字”,即2字节(16位)。例如:movw $30, %bx
  • movb: 移动“字节”,即1字节(8位)。例如:movb $30, %bl

需要注意的是,由于历史原因(源于16位的8086处理器),在汇编语境中“字”(word)通常指16位,尽管现代机器是32位或64位的。

重要提示:当使用movl将一个8位立即数(如$0xFF)移入寄存器时,它会将寄存器的高24位清零,低8位设为该值。而使用movb只操作低8位时,不会影响其他位。

算术运算指令

接下来,我们学习基本的算术运算指令:加法、减法、乘法和除法。加法、减法和整数乘法的语法非常相似。

加法使用add指令:

addl 源操作数, 目标操作数

执行的操作是:目标操作数 = 目标操作数 + 源操作数

减法使用sub指令:

subl 源操作数, 目标操作数

执行的操作是:目标操作数 = 目标操作数 - 源操作数。注意顺序,是从目标操作数中减去源操作数。

整数乘法使用imull指令:

imull 源操作数, 目标操作数

执行的操作是:目标操作数 = 目标操作数 * 源操作数

整数除法稍微复杂一些。首先了解术语:被除数(dividend)是我们要除的数(分子),除数(divisor)是我们要除以的数(分母)。X86允许进行64位的除法,这是通过将64位的被除数存放在两个32位寄存器%edx(高32位)和%eax(低32位)中来实现的。

除法指令idivl只需要指定除数(可以是一个寄存器或内存地址):

idivl 除数

执行除法后:

  • (quotient)存储在 %eax 寄存器中。
  • 余数(remainder)存储在 %edx 寄存器中。

重要:在执行除法前,必须正确设置%edx%eax。如果被除数是32位数,通常需要将%edx清零(使用movl $0, %edx),然后将被除数放入%eax

内存寻址模式

上一节我们介绍了如何在寄存器间进行运算。本节中我们来看看如何访问内存中的数据。X86汇编提供了多种内存寻址模式,这对于访问变量、数组元素或结构体成员非常有用。

以下是五种主要的寻址模式:

  1. 绝对寻址:直接使用内存地址。

    movl 0x1000, %eax  # 将内存地址0x1000处的值读入%eax
    

    注意:这里没有$符号。有$表示立即数(值本身),没有$表示内存地址。

  2. 间接寻址:使用寄存器中存储的地址。

    movl $0x1000, %eax  # 将地址0x1000存入%eax
    movl (%eax), %ebx   # 将%eax所指向的内存地址(即0x1000)处的值读入%ebx
    

    括号()表示“间接”,即取寄存器中的值作为地址。

  3. 基址+偏移量寻址:一个基地址加上一个固定的偏移量。

    movl 8(%eax), %ebx  # 有效地址 = %eax中的值 + 8。将该地址处的值读入%ebx
    

    这常用于访问数组元素。例如,如果%eax指向一个整数数组的首地址,那么8(%eax)就访问索引为2的元素(假设每个整数占4字节,8 = 2 * 4)。

  4. 变址寻址:基地址 + 变址寄存器值 + 立即数偏移。

    movl 8(%eax, %ebx), %ecx  # 有效地址 = %eax + %ebx + 8
    

    这适用于访问结构体内部的数组等复杂情况。例如,一个Point结构体可能有xy(各4字节)和一个name字符数组。8(%eax, %ebx)可以解释为:%eax是结构体基址,8跳过xy到达name数组起始处,%ebx是数组内的索引。

  5. 比例变址寻址:最通用的形式 立即数偏移(基址寄存器, 变址寄存器, 比例因子)

    movl (, %ebx, 4), %eax  # 有效地址 = %ebx * 4
    movl 8(%eax, %ebx, 1), %ecx # 有效地址 = %eax + %ebx*1 + 8
    

    比例因子只能是1、2、4或8,对应不同数据类型的大小(char, short, int, long/double)。这种模式非常适合数组访问,变址寄存器%ebx作为数组下标,乘以元素大小(比例因子)后得到正确的字节偏移。上述其他寻址模式都可以看作是这种模式的特殊情况(某些部分为0)。

位操作指令

最后,我们简要介绍汇编级别的位操作指令。在C语言中我们见过的位操作,在汇编中都有对应的指令。

  • andl 源操作数, 目标操作数目标 = 目标 & 源
  • orl 源操作数, 目标操作数目标 = 目标 | 源
  • 异或xorl 源操作数, 目标操作数目标 = 目标 ^ 源
  • notl 目标操作数目标 = ~目标),这是一元操作。
  • 移位
    • 算术左移/逻辑左移:sall k, 目标操作数shll k, 目标操作数目标 = 目标 << k)。左移两者都是补0。
    • 算术右移:sarl k, 目标操作数目标 = 目标 >> k)。对于有符号数,用最高位(符号位)填充空位。
    • 逻辑右移:shrl k, 目标操作数目标 = 目标 >> k)。对于无符号数,总是用0填充空位。

总结

本节课中我们一起学习了汇编语言的基础入门知识。我们回顾了程序从编译到运行的过程,了解了CPU执行指令的步骤以及寄存器的重要作用。我们重点讲解了X86汇编语言的数据传送指令(mov)、基本算术运算指令(addsubimulidiv)、多种内存寻址模式以及位操作指令。理解这些底层概念,有助于我们写出更高效的C代码,并为后续学习计算机体系结构打下坚实基础。建议结合教材中的大量练习题进行巩固,这些练习对掌握本章内容至关重要。

011:GDB调试器教程 🐛

在本节课中,我们将学习如何使用GNU调试器(GDB)来调试C语言程序。我们将涵盖启动调试器、逐步执行代码、检查变量、设置断点、查看调用栈以及分析汇编代码等核心功能。掌握这些技能将帮助你高效地定位和修复程序中的错误。


启动与退出GDB

要使用GDB调试程序,必须在编译时添加 -g 选项,以便在可执行文件中包含调试信息。

gcc -g -m32 -Wall tutorial.c -o tutorial

编译完成后,使用以下命令启动GDB并加载你的程序:

gdb tutorial

进入GDB后,界面会显示版本信息并确认已读取调试符号。要退出GDB,只需输入 quit 命令。


执行控制:运行与逐步执行

上一节我们介绍了如何启动GDB。本节中,我们来看看如何控制程序的执行流程。

GDB提供了两种开始执行程序的主要方式:

  • start: 程序会在 main 函数的第一行暂停,等待你的下一步指令。
  • run: 程序会一直运行,直到遇到断点、程序结束或发生崩溃(如段错误)。

在程序暂停后,你可以使用以下命令逐步执行:

  • step: 执行下一行代码。如果下一行是函数调用,则会“步入”该函数内部。
  • next: 执行下一行代码。与 step 不同,如果下一行是函数调用,则会“步过”整个函数,直接得到其返回值。
  • finish: 继续执行,直到当前函数返回。

你可以按 Enter 键来重复执行上一个命令,这在进行多次单步调试时非常方便。


检查数据:变量与内存

在逐步执行的过程中,查看程序状态是调试的核心。GDB提供了强大的工具来检查变量和内存内容。

以下是获取变量信息的常用命令:

  • print <变量名>p <变量名>: 打印变量的当前值。
  • info locals: 打印当前函数中所有局部变量的值。

要检查原始内存内容,可以使用 examine 命令:

  • x/<格式> <地址>: 从指定地址开始检查内存。
    • 格式由两部分组成:<数量><格式字母><大小字母>
    • 例如,x/10cb &string 会以字符形式显示 string 地址开始的10个字节。

核心概念公式x/[数量][格式][大小] [地址或表达式]


设置断点与监视点

手动逐步执行整个程序非常低效。通过设置断点,你可以让程序在特定位置自动暂停。

以下是管理断点的命令:

  • break <行号或函数名>: 在指定行或函数入口处设置断点。
  • info break: 列出所有已设置的断点和监视点。
  • delete <断点编号>: 删除指定断点。不带编号则删除所有断点。
  • disable/enable <断点编号>: 临时禁用或重新启用一个断点。

除了在代码位置暂停,你还可以监视变量的变化:

  • watch <变量名>: 当变量的值被写入(改变)时,程序暂停。
  • rwatch <变量名>: 当变量的值被读取时,程序暂停。

监视点在程序重新运行(run)后会被清除,而断点则会保留。


分析调用栈与切换帧

当程序因错误或断点而停止在某个函数深处时,了解它是如何调用到这里的至关重要。

以下是分析函数调用链的命令:

  • backtracebt: 显示当前的调用栈,即从 main 函数到当前停止位置的所有函数调用序列。
  • frame <帧编号>: 切换到调用栈中的指定帧。这允许你查看该帧(即该次函数调用)中的局部变量。
  • info args: 打印当前函数的参数值。

调用栈顶部的帧(编号为0)是当前正在执行的函数。通过切换帧,你可以像调试不同函数一样检查各自作用域内的变量。


汇编级调试

GDB不仅能调试高级C代码,还能深入到汇编指令级别,这对于理解程序底层行为和学习计算机体系结构非常有帮助。

以下是在GDB中处理汇编代码的命令:

  • disasdisassemble: 反汇编当前函数,显示其汇编指令。
  • layout asm: 切换到汇编代码布局视图,方便查看。
  • stepisi: 执行一条汇编指令(相当于汇编级的 step)。
  • nextini: 执行一条汇编指令,但会跳过函数调用(相当于汇编级的 next)。
  • info registers: 打印所有CPU寄存器的当前内容。在完成某些底层操作(如项目3)后,答案通常就存放在某个寄存器中。

你同样可以为汇编地址设置断点:break *0x地址


处理崩溃与段错误

程序崩溃(如出现“Segmentation fault”)时,GDB不会退出,而是停留在崩溃发生的那一刻,这是调查原因的最佳时机。

当程序因段错误而停止在GDB中时,你应该:

  1. 使用 backtrace 查看崩溃时的调用栈。
  2. 定位到崩溃行(通常是最后一条你自己的代码)。
  3. 使用 print 检查相关变量的值。例如,如果崩溃是因为对 malloc 返回的指针进行操作,打印该指针可能会发现其值为 0NULL),这表明内存分配失败了。

这种方法能让你直接看到错误发生时的程序状态,远比添加 printf 语句猜测要高效得多。


其他实用命令

最后,我们介绍一些其他有用的GDB命令,它们能在特定场景下提供帮助:

  • kill: 终止当前正在调试的程序,但不退出GDB。
  • call <函数(参数)>: 在当前位置调用一个函数。可用于测试函数在不同参数下的行为。
  • help <命令>: 查看任何GDB命令的详细帮助文档。

本节课中我们一起学习了GDB调试器的核心用法。我们从启动调试器和基本执行控制开始,逐步深入到检查变量、设置断点、分析调用栈以及进行汇编级调试。最后,我们还探讨了如何利用GDB诊断程序崩溃。熟练运用这些工具将极大提升你解决编程问题的效率和深度。

012:CPU虚拟化 💻

在本节课中,我们将学习操作系统如何通过CPU虚拟化技术,让单个物理CPU能够同时运行成百上千个程序。我们将探讨进程的概念、操作系统如何管理它们,以及硬件如何协助实现进程间的安全切换。


概述

操作系统的一个核心功能是虚拟化硬件资源。本节课,我们聚焦于CPU虚拟化。其核心思想是:通过时间共享,操作系统在多个进程之间快速切换CPU使用权,从而创造出每个程序都独占CPU的假象。

上一节我们介绍了操作系统的基本概念,本节中我们来看看它是如何虚拟化CPU的。


进程:运行中的程序

进程是一个正在运行的程序的抽象。它不仅包含程序的代码,还包括运行时所需要的数据、堆栈、寄存器状态以及打开的文件等资源。

当我们在终端中运行多个程序时,操作系统会为每个程序创建一个独立的进程。例如,同时运行多个cpu程序实例,每个打印不同的字符串。

以下是演示代码的核心逻辑:

int main(int argc, char *argv[]) {
    char *str = argv[1]; // 获取命令行参数
    for (int i = 0; i < 30; i++) {
        printf("%s\n", str); // 打印字符串
        spin(1); // 等待1秒
    }
    return 0;
}

当我们同时运行多个实例(如 ./cpu A./cpu AB)时,输出顺序并非严格按照启动顺序。这是因为操作系统在它们之间进行了时间共享切换。


虚拟化的机制:如何实现?

关键问题在于:如何用有限的CPU(例如一个)模拟出近乎无限的CPU供应?答案是结合硬件机制操作系统策略

  • 硬件机制(How):负责实现进程间的安全切换,主要依靠定时器中断内核/用户模式位。
  • 操作系统策略(Which):负责决定接下来运行哪个进程,这基于调度算法。

进程切换时,操作系统必须保存和恢复其状态,主要包括:

  1. 内存状态:代码、数据、堆、栈(即整个地址空间)。
  2. 寄存器状态:所有通用寄存器、程序计数器等值。
  3. I/O状态:打开的文件、网络连接等。

进程的生命周期与系统调用

操作系统通过一系列系统调用来管理进程的生命周期。以下是核心的操作:

  • 创建(Create):启动一个新进程。
  • 销毁(Destroy):终止一个进程。
  • 等待(Wait):暂停当前进程,等待另一个进程结束。
  • 控制(Control):挂起、恢复或切换进程。
  • 获取状态(Get Status):获取进程的资源使用情况。

创建新进程是一个复杂的过程,操作系统需要:

  1. 进程列表中添加新条目(即进程控制块PCB)。
  2. 为代码、数据、堆、栈分配内存。
  3. 设置好命令行参数和运行环境。
  4. 初始化寄存器(通常清零,出于安全考虑)。
  5. 将控制权转交给程序的main函数。

进程在其生命周期中会在不同状态间转换,例如就绪运行阻塞终止。操作系统根据策略决定状态转换的时机。


从直接执行到受限直接执行

最初的简单模型是直接执行:让进程直接在CPU上运行,直到它主动结束或执行yield指令。但这带来两个严重问题:

  1. 无限循环:一个进程陷入死循环会独占CPU。
  2. 安全问题:进程可能执行危险操作(如访问他人文件)。

为了解决这些问题,我们引入了受限直接执行模型。其核心是:

  • 用户模式与内核模式:CPU有一个模式位。在用户模式下,进程不能执行特权指令(如I/O操作)。在内核模式下,操作系统代码可以执行任何操作。
  • 陷阱(Trap)与返回:当用户程序需要执行特权操作(如读取文件)时,它执行一条特殊的陷阱指令。这会触发硬件将CPU切换到内核模式,并跳转到操作系统预设的陷阱处理程序。处理完毕后,操作系统执行从陷阱返回指令,切换回用户模式并恢复程序执行。

陷阱指令的跳转地址在系统启动时就被设置在陷阱表中,用户程序不知道其具体位置,这增强了安全性。


系统调用与上下文切换的完整流程

以下是结合了进程启动、系统调用和定时器中断的完整流程示意图:

  1. 进程启动:操作系统在内核模式下创建进程,初始化其PCB和内核栈,然后执行“从陷阱返回”,切换到用户模式并开始运行main函数。
  2. 执行程序:进程在用户模式下运行。
  3. 发起系统调用/定时器中断
    • 系统调用:进程执行trap指令。
    • 定时器中断:硬件时钟到期,触发中断。
  4. 切换到内核模式:硬件自动将寄存器保存到该进程的内核栈,切换到内核模式,并根据陷阱表或中断表跳转到对应的处理程序。
  5. 处理请求:操作系统执行相应的服务(如处理I/O)或调度决策(如决定是否切换进程)。
  6. 恢复执行:操作系统执行“从陷阱返回”指令,从内核栈恢复寄存器,切换回用户模式,程序从上次中断处继续执行(或开始执行另一个被调度的进程)。
  7. 进程结束:当进程执行exit系统调用或从main返回时,操作系统在内核模式下处理,释放其资源并将其从进程列表中移除。

受限直接执行的关键在于,通过硬件支持的模式切换陷阱机制,操作系统在保持对CPU控制权的同时,为进程提供了高效、安全的运行环境。


总结

本节课我们一起学习了CPU虚拟化的核心原理。我们了解到:

  • 进程是运行中程序的抽象,操作系统通过管理进程列表来追踪所有活动程序。
  • 时间共享是虚拟化CPU的基础,它依靠硬件(定时器中断、双模式)和操作系统(调度策略)的协作。
  • 受限直接执行模型通过用户/内核模式陷阱机制,既保证了程序执行的效率,又确保了系统的安全与控制。
  • 进程通过系统调用请求操作系统服务,而上下文切换的过程涉及精细的寄存器保存与恢复。

下一节,我们将把目光从CPU转向内存,探讨操作系统如何实现内存虚拟化

013:内存虚拟化 🧠

在本节课中,我们将学习操作系统中的一个核心概念:内存虚拟化。我们将探讨操作系统如何让每个程序都认为自己拥有整个内存地址空间,而实际上物理内存是有限的,并且被多个程序共享。我们将介绍两种主要的内存管理模型:分段和分页。


概述 📋

内存虚拟化的核心思想是,为每个运行的程序(进程)提供一个独立的、连续的虚拟地址空间视图,而操作系统和硬件则负责将这个虚拟地址空间映射到有限的物理内存上。这实现了内存保护、资源共享和更高效的内存利用。


历史模型回顾 🕰️

在深入现代技术之前,我们先回顾一下历史上的内存管理模型。

早期模型:单道程序

在早期计算机中,物理内存非常有限。操作系统被加载到内存顶部的一小块区域(例如512KB),而整个用户进程则被加载到剩余的所有内存中。进程知道顶部有一块保留内存(操作系统),当进程结束时,操作系统会加载下一个完整的进程。

特点

  • 一次只能运行一个程序。
  • 不支持上下文切换或分时共享。

改进模型:交换

随着发展,系统开始支持进程切换(分时共享)。操作系统仍常驻在物理内存中。当需要切换到另一个进程时,当前进程的所有状态(内存内容)会被保存到磁盘上,然后从磁盘加载下一个进程。

特点

  • 允许多个程序“同时”运行(分时)。
  • 将内存内容保存到磁盘(交换)的速度很慢,是主要瓶颈。

现代模型:内存分区

现代计算机将物理内存划分为多个区域,分别分配给不同的进程。操作系统仍常驻在物理内存的某个位置,而每个进程被分配到一块独立的物理内存区域。


虚拟地址空间 🗺️

我们整个学期编写的程序都基于一个假设:每个程序都拥有整个地址空间。在32位机器上,这意味着程序可以访问从 0x000000000xFFFFFFFF(2^32个)的所有地址。

这个地址空间的布局通常是:

  • 代码段:存放程序指令。
  • 数据段:存放已初始化的全局变量和字符串常量。
  • :用于动态内存分配(如 malloc)。
  • :用于函数调用和局部变量。

然而,计算机并没有那么多物理内存。操作系统和硬件协同工作,将这个庞大的虚拟地址空间映射到有限的物理内存(RAM)上。如果物理内存不足,部分数据可能会被暂时交换到硬盘上。

每个进程的虚拟地址空间都被映射到物理内存的不同区域。


内存虚拟化的目标 🎯

内存虚拟化旨在实现以下几个关键目标:

  1. 透明性:对程序隐藏所有细节,让每个程序都以为自己独享整个地址空间。
  2. 高效翻译:能够快速地将程序使用的虚拟地址翻译成实际的物理地址
  3. 内存保护:保护一个进程使用的内存不被其他进程访问。例如,用户A的程序不能读取用户B程序的内存。
  4. 资源管理:由操作系统负责管理内存分配,决定当内存不足时哪些数据应被换出到硬盘,并在需要时换入。
  5. 简化编程:让程序员无需关心底层内存布局,简化开发。

地址翻译机制 🔄

地址翻译的核心是基于地址的虚拟化。当程序执行一条指令(例如 mov eax, [0x12345678])时,它使用的是虚拟地址。

硬件中存在一个称为内存管理单元的部件,它负责将这个虚拟地址翻译成对应的物理地址。你可以将其想象成一个查找表,操作系统负责维护这个表,记录哪些物理内存区域分配给了哪个进程,哪些是空闲的。

公式

物理地址 = 地址翻译函数(虚拟地址)

这个函数由MMU和操作系统共同实现。


内存管理模型 📊

有两种主要模型来实现虚拟地址到物理地址的映射:分段分页

模型一:分段

在分段模型中,操作系统为每个进程分配一个连续的物理内存块。

工作原理
操作系统为每个进程维护两个寄存器:

  • 基址寄存器:记录该进程内存块在物理内存中的起始地址。
  • 界限寄存器:记录该内存块的大小或结束地址。

地址翻译非常简单:
公式

物理地址 = 虚拟地址 + 基址

同时,系统会检查虚拟地址是否超出了界限寄存器规定的范围,以进行越界保护。

优点

  • 实现简单,翻译速度快。
  • 可重定位性:如果进程需要更多内存,而当前区域相邻空间不足,操作系统可以将整个进程的内存块复制到物理内存中另一个更大的空闲区域,然后只需更新基址和界限寄存器即可。

缺点

  • 外部碎片:随着进程的创建和终止,物理内存中会产生许多分散的、大小不一的小空闲块。这些空闲块可能因为太小而无法分配给新进程,造成内存浪费。需要定期进行“内存紧缩”来合并碎片,这个过程较慢。

上一节我们介绍了分段模型,它管理连续的内存块。本节中我们来看看另一种更主流的模型。

模型二:分页

分页模型解决了外部碎片问题。它将物理内存和虚拟内存都划分为固定大小的块,称为。典型的页大小是4KB(4096字节,即2^12字节)。

工作原理

  1. 虚拟地址空间被划分为多个页。
  2. 物理内存也被划分为同样大小的页帧
  3. 操作系统为每个进程维护一个页表。页表的每个条目记录了该进程的一个虚拟页映射到了哪个物理页帧。

地址翻译过程:

  1. 拆分地址:虚拟地址被拆分为两部分:
    • 虚拟页号:高位部分,用于在页表中查找对应的条目。
    • 页内偏移:低位部分(对于4KB页,是最后12位),表示在该页内部的字节位置。
  2. 查表:通过虚拟页号在页表中找到对应的物理页帧号。
  3. 组合:将物理页帧号与页内偏移组合,得到最终的物理地址。

代码示例(概念性)

// 假设虚拟地址为 32 位,页大小为 4KB
uint32_t virtual_address = 0x12345678;
uint32_t page_mask = 0xFFFFF000; // 获取高20位(页号)
uint32_t offset_mask = 0x00000FFF; // 获取低12位(偏移)

uint32_t virtual_page_number = (virtual_address & page_mask) >> 12;
uint32_t page_offset = virtual_address & offset_mask;

// 通过页表查询(此处为示意,实际由硬件完成)
uint32_t physical_page_frame = page_table[virtual_page_number];
uint32_t physical_address = (physical_page_frame << 12) | page_offset;

优点

  • 无外部碎片:内存以固定大小的页为单位分配,任何空闲页都可以分配给需要的进程。
  • 灵活高效:一个进程的虚拟页可以映射到物理内存中任何位置的页帧,不需要连续。
  • 简化交换:以页为单位将数据换入/换出硬盘更高效。

挑战

  • 页表大小:对于32位地址空间和4KB页,有 2^32 / 2^12 = 2^20(约100万)个虚拟页。每个页表条目通常占4字节,那么一个进程的页表大小就是4MB。如果有100个进程,仅页表就要占用400MB内存,且每次地址翻译都需要访问这个庞大的表,会影响性能。
    • 解决方案:现代操作系统使用多级页表、翻译后备缓冲器等技术来优化,这将在操作系统课程中深入探讨。

总结 📝

本节课中我们一起学习了内存虚拟化的核心概念。

  1. 目标:内存虚拟化旨在为每个进程提供独立、连续的地址空间视图,同时实现保护、共享和高效利用物理内存。
  2. 关键机制:通过硬件(MMU)和操作系统协作,将程序使用的虚拟地址动态翻译为物理地址
  3. 分段模型:为进程分配连续的物理内存块,通过基址和界限寄存器实现翻译。简单快速,但会产生外部碎片。
  4. 分页模型:将内存划分为固定大小的页。通过页表实现虚拟页到物理页帧的映射。解决了外部碎片问题,但页表本身可能很大,需要优化。

内存虚拟化是操作系统的基础,它使得现代多任务计算成为可能。理解这些概念是进一步学习操作系统、系统编程和性能优化的关键。

014:线程与并发 🧵

在本节课中,我们将学习操作系统中的并发与线程概念。我们将了解如何让程序同时运行多个任务,以及在这个过程中可能遇到的问题和解决方案。

概述

我们将从线程的基本概念开始,通过一个具体的代码示例来展示并发编程。接着,我们会分析示例中出现的“竞态条件”问题,并探讨其背后的原因。最后,我们将简要介绍解决这类问题的两种思路:原子指令和同步原语。

线程的基本概念

上一节我们介绍了进程,本节中我们来看看线程。线程允许我们在同一个程序中运行多个代码副本。这被称为并行处理,可以充分利用计算机可能拥有的多个处理器或多核CPU。

线程与进程的关键区别在于资源共享。以下是线程模型的核心组成部分:

  • 代码段:所有线程共享同一份代码。无需为每个线程重新加载或复制代码。
  • 数据段:所有线程共享全局变量和硬编码的字符串等数据。
  • :所有线程共享动态分配的内存区域。
  • :每个线程拥有自己独立的栈,用于存储局部变量和函数调用信息。

每个线程也有自己的线程控制块,类似于进程控制块,用于保存线程的状态信息,以便操作系统进行调度和上下文切换。

一个并发示例与竞态条件

让我们通过一个具体的C语言程序来理解并发。以下代码创建了两个线程,每个线程都循环递增一个全局计数器。

#include <stdio.h>
#include <pthread.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs354-c-undl-prcp/img/d293276b6e518e68cf012c9dcaab590a_3.png)

volatile int counter = 0; // 全局计数器
int loops; // 循环次数

void *worker(void *arg) {
    for (int i = 0; i < loops; i++) {
        counter++; // 递增计数器
    }
    return NULL;
}

在主函数中,我们创建并运行两个线程,然后打印最终的计数器值。

int main(int argc, char *argv[]) {
    loops = atoi(argv[1]); // 从命令行参数获取循环次数
    pthread_t p1, p2;

    printf("初始值: %d\n", counter);

    // 创建两个线程
    pthread_create(&p1, NULL, worker, NULL);
    pthread_create(&p2, NULL, worker, NULL);

    // 等待两个线程结束
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    printf("最终值: %d\n", counter);
    return 0;
}

理论上,如果每个线程循环 N 次,最终结果应为 2N。然而,实际运行结果往往小于 2N,例如循环1000次,结果可能只有1386。这就是竞态条件

问题根源counter++ 这个操作在底层并非原子操作。它可能被分解为三个步骤:

  1. 从内存读取 counter 的值到寄存器。
  2. 在寄存器中将其值加一。
  3. 将新值写回 counter 所在的内存。

如果线程A在执行完步骤1和2后,被操作系统切换出去,此时线程B开始执行并完整地完成了多次 counter++ 操作。当线程A再次被调度执行时,它会将从旧值计算出的结果(步骤3)写回内存,从而覆盖了线程B所做的所有更新。

解决竞态条件

竞态条件发生在多个线程同时读写共享数据时。要解决这个问题,我们需要确保对共享数据的访问是同步的。

方法一:原子指令

一种思路是使用原子指令。“原子”意味着该操作不可分割,在执行过程中不会被中断。如果 counter++ 是一个原子操作,那么读取、递增、写入将作为一个整体一次性完成,从而避免被其他线程打断。

然而,为所有可能的复杂操作设计原子指令是不现实的,原子指令通常只适用于非常简单的操作。

方法二:同步原语

更通用的解决方案是使用同步原语,例如互斥锁。这需要程序员在代码中明确标出可能产生冲突的“临界区”。

基本思想是:当一个线程进入临界区(例如准备修改 counter)时,它会先“锁住”相关的共享资源。在此锁被释放前,其他试图访问同一资源的线程必须等待。这样就强制线程们排队访问共享数据,避免了竞态条件。

pthread_mutex_t lock; // 声明一个互斥锁

void *worker(void *arg) {
    for (int i = 0; i < loops; i++) {
        pthread_mutex_lock(&lock); // 进入临界区前加锁
        counter++;
        pthread_mutex_unlock(&lock); // 离开临界区后解锁
    }
    return NULL;
}

总结

本节课中我们一起学习了线程与并发。我们了解到线程是共享代码和数据,但拥有独立栈的执行流,能够实现并行处理以提高效率。我们通过一个计数器程序演示了多线程编程中常见的竞态条件问题,其根源在于对共享数据的非原子化操作可能被线程切换打断。最后,我们探讨了解决此问题的两种途径:使用原子指令(适用于简单操作)或更通用的同步原语(如互斥锁),后者通过在代码中定义临界区来强制线程顺序访问共享资源。深入理解这些同步机制是编写正确、高效并发程序的关键。

posted @ 2026-03-29 09:47  布客飞龙III  阅读(19)  评论(0)    收藏  举报