UCD-ECS50-C-与汇编笔记-全-
UCD ECS50 C 与汇编笔记(全)
001:C语言编程速成 🚀


在本节课中,我们将学习C语言的基础知识,包括程序结构、数据类型、变量、函数、输入输出以及如何组织代码文件。这些是后续学习汇编语言和计算机系统原理的重要基础。
概述
C语言是一种编译型语言,它允许我们直接与计算机硬件进行交互。本节课将快速介绍C语言的核心语法和编程概念,帮助你为后续更深入的学习做好准备。
课程内容
程序结构与编译
C程序需要一个main函数作为入口点。程序通过编译器(如GCC)将源代码转换为可执行文件。建议在编译时开启所有警告选项,以帮助发现潜在错误。
编译命令示例:
gcc -g -Wall -Werror -Wextra -o my_program main.c areas.c
基本数据类型与变量
C语言是静态类型语言,变量在声明时必须指定类型,且类型不可更改。基本数据类型包括整数(int)、字符(char)和浮点数(double)。
变量声明与初始化示例:
int number = 35;
char letter = 'B';
double real_number = 35.27;
重要提示: 使用变量前务必初始化,否则其值是未定义的,可能导致程序行为异常。
输入与输出
使用printf函数进行格式化输出,使用scanf函数读取用户输入。scanf需要传入变量的地址(使用&操作符)。
输入输出示例:
printf("Number is %d\n", number);
scanf("%lf", &real_number); // 读取一个double类型值
字符串
C语言中的字符串是字符数组,以空字符(\0)结尾。字符串字面量(如"Hello")会自动包含结尾的空字符。
字符串声明示例:
char str[] = "Hi class"; // 编译器自动计算大小,包含\0
注意: 手动构建字符串时,必须确保数组有足够空间并正确添加结尾的\0。
函数
函数由返回类型、函数名、参数列表和函数体组成。C语言采用“自上而下、单遍编译”的方式,因此函数需要先声明后使用(或定义在使用之前)。
函数声明与定义示例:
// 函数声明(通常在头文件或文件顶部)
double get_rect_area(double width, double height);
// 函数定义
double get_rect_area(double width, double height) {
return width * height;
}
代码组织:头文件与源文件
为了更好的代码组织和复用,通常将函数声明放在头文件(.h)中,将函数定义放在源文件(.c)中。使用头文件保护符防止重复包含。
头文件示例 (areas.h):
#ifndef CRASH_COURSE_AREAS_H
#define CRASH_COURSE_AREAS_H
// 函数声明
double get_rect_area(double width, double height);
double get_tri_area(double base, double height);
double get_trap_area(double long_side, double short_side, double height);
#endif
源文件示例 (areas.c):
#include "areas.h"
// 函数定义
double get_rect_area(double width, double height) {
return width * height;
}
// ... 其他函数定义
主程序示例 (main.c):
#include <stdio.h>
#include "areas.h"
int main() {
// 使用头文件中声明的函数
double area = get_rect_area(5.0, 3.0);
printf("Area: %lf\n", area);
return 0;
}
控制流:循环
C语言支持while循环和for循环,用于重复执行代码块。
while循环示例:
int index = 0;
while (index < 10) {
printf("%d\n", index);
index++;
}
for循环示例:
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}
总结

本节课我们一起学习了C语言编程的速成知识。我们了解了C程序的基本结构、如何编译程序、各种数据类型和变量的用法、如何进行输入输出操作、字符串的本质、如何定义和使用函数,以及如何通过头文件和源文件来组织代码。最后,我们还简单介绍了控制程序流程的循环结构。掌握这些基础知识是理解后续计算机系统与汇编语言课程的关键。请务必花时间练习和巩固这些概念。
002:指针与动态内存分配 🧠

在本节课中,我们将要学习C语言中两个核心且强大的概念:指针和动态内存分配。我们将从指针的基础概念开始,逐步深入到如何利用指针修改函数参数,并最终学习如何在程序运行时动态地创建和管理内存。
指针基础回顾
上一节我们介绍了变量的基本概念,本节中我们来看看指针是什么。指针是一种特殊的变量,其值是另一个变量的内存地址。
-
&(取地址)运算符:获取一个变量的内存地址。- 如果变量
var的类型是T,那么表达式&var的类型就是T*(指向T的指针)。 - 一个变量前只能使用一个
&运算符,因为不能获取“地址的地址”。
- 如果变量
-
*(解引用)运算符:访问指针所指向地址中存储的值。- 如果变量
var的类型是T*,那么表达式*var的类型就是T。 - 可以这样理解:
&为类型增加一个*,而*为类型减少一个*。
- 如果变量
确保赋值语句左右两边的类型匹配,是检查指针操作是否正确的好方法。
指针操作示例
为了理解指针如何工作,让我们通过一个具体的例子来观察内存的变化。
假设我们有以下声明:
int a = 5, b = 10, c = 43;
假设它们的地址分别是 100, 217, 313。
我们可以进行以下指针操作:
int *p1 = &a; // p1 存储 a 的地址 (100)
int **p2 = &p1; // p2 存储 p1 的地址 (假设为125)
int *p3 = &b; // p3 存储 b 的地址 (217)
现在,让我们看看解引用操作:
*p1:获取 p1 指向地址 (100) 的值,结果是5。*p2:获取 p2 指向地址 (125) 的值,结果是100(即 p1 的值)。**p2:等价于*(*p2)。先计算*p2得到100,再解引用地址100,得到5。
我们还可以通过指针修改值:
*p2 = p1 + 117; // *p2 即 p1。 p1 原值为100,加上117后为217。
这条语句将 p1 的值修改为 217,使得 p1 现在指向了变量 b。因此,*p1 现在的结果是 10。
指针的用途之一:修改函数参数
C语言采用“按值调用”,函数接收的是参数的副本。若想在函数内部修改外部变量的值,必须传递该变量的指针。
核心原则:若要修改类型为 X 的变量,函数必须至少接收一个 X*(指向 X 的指针)类型的参数。
例如,实现一个交换两个整数的函数 swap:
void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
调用时,需要传入变量的地址:
int a = 10, b = 5;
swap(&a, &b);
// 现在 a == 5, b == 10
如果函数参数只是 int x, int y,那么交换的只是函数内部的副本,外部的 a 和 b 不会改变。
重要警告:永远不要返回指向栈内存(局部变量或参数)的指针。因为函数结束后,这些内存将被释放,返回的指针将指向无效区域,导致未定义行为。
内存布局与动态分配
程序的内存通常分为四个区域:
- 代码区:存储程序指令。生命周期与程序相同。
- 数据区:存储全局变量和静态变量。生命周期与程序相同。
- 栈区:存储局部变量和函数参数。生命周期到函数结束(变量离开作用域)。
- 堆区:用于动态内存分配。生命周期由程序员控制,从分配开始到释放结束。
栈内存是“自动管理”的,而堆内存是“程序员管理”的。当我们需要在函数调用结束后仍然保留数据,或者需要灵活调整数据结构大小时,就需要使用堆内存。
动态内存管理函数
要使用动态内存分配函数,需要包含头文件 <stdlib.h>。主要函数有三个:
以下是动态内存操作的核心函数:
malloc:分配指定字节数的未初始化内存。void* malloc(size_t size);calloc:分配指定数量和大小的内存,并将所有位初始化为零。void* calloc(size_t num, size_t size);realloc:调整之前分配的内存块大小。void* realloc(void* ptr, size_t new_size);free:释放之前动态分配的内存。void free(void* ptr);
黄金法则:每一个 malloc/calloc 调用,最终都必须对应一个 free 调用,以防止内存泄漏。
使用 malloc 和 calloc
创建一个动态整数数组:
int* make_array_malloc(int num_elements) {
// 为 num_elements 个整数分配空间
int* arr = (int*)malloc(num_elements * sizeof(int));
// 或使用更安全的写法:sizeof(*arr) 获取指针指向类型的大小
// int* arr = (int*)malloc(num_elements * sizeof(*arr));
return arr; // 返回指向分配内存首地址的指针
}
malloc 返回 void*,通常需要强制转换为目标指针类型。分配的内存内容是未初始化的“垃圾值”。
更推荐使用 calloc,因为它能自动初始化内存为零,且参数设计不易忘记乘以类型大小:
int* make_array_calloc(int num_elements) {
int* arr = (int*)calloc(num_elements, sizeof(*arr));
return arr;
}
动态分配的数组元素在内存中是连续的。arr[i] 等价于 *(arr + i)。
使用完毕后,必须释放内存:
int* my_array = make_array_calloc(10);
// ... 使用 my_array ...
free(my_array);
my_array = NULL; // 良好习惯:释放后将指针置为NULL,防止“悬空指针”
注意:只能 free 由 malloc、calloc 或 realloc 返回的指针。free 之后不应再访问该内存。
使用 realloc
realloc 用于调整动态内存块的大小。它可能需要在内存中移动数据块,因此必须将其返回值重新赋值给指针变量。
void resize_array(int** arr, int current_size, int new_size) {
*arr = (int*)realloc(*arr, new_size * sizeof(**arr));
// 注意:为了修改调用方的指针arr,函数参数需要是int**
}
- 新尺寸更大:新增空间添加到末尾,新空间未初始化。
- 新尺寸更小:空间从尾部被截断。
- 新尺寸为0:等价于
free(ptr)。 ptr为NULL:等价于malloc(new_size)。
关于 NULL:NULL 是一个表示“空指针”的特殊值,意味着指针不指向任何有效内存。解引用 NULL 指针会导致程序崩溃(段错误)。
实践应用:实现数组追加函数
让我们实现一个类似 Python append 的函数,向动态数组末尾添加元素。
void append(int** arr, int* length, int value) {
// 1. 重新分配内存,增加一个元素的空间
*arr = (int*)realloc(*arr, (*length + 1) * sizeof(**arr));
// 2. 在新位置存入值
(*arr)[*length] = value; // 注意括号!确保先解引用arr
// 3. 更新长度
(*length)++;
}
调用示例:
int* my_arr = NULL;
int len = 0;
append(&my_arr, &len, 10); // 首次追加
append(&my_arr, &len, 20); // 再次追加
// ... 使用 my_arr ...
free(my_arr);
关键点:因为我们需要修改调用方的 arr 指针(realloc 可能改变它)和 length 整数值,所以函数参数必须是指向它们的指针(int** 和 int*)。
创建与释放二维数组
动态创建二维数组(矩阵)需要两步:
// 创建 rows x cols 的整数矩阵
int** make_matrix(int rows, int cols) {
int** mat = (int**)calloc(rows, sizeof(*mat)); // 分配行指针数组
for (int i = 0; i < rows; i++) {
mat[i] = (int*)calloc(cols, sizeof(**mat)); // 为每一行分配列空间
}
return mat;
}
释放内存时顺序相反,先释放每一行,再释放行指针数组:
void delete_matrix(int** mat, int rows, int cols) {
for (int i = 0; i < rows; i++) {
free(mat[i]); // 释放第 i 行
}
free(mat); // 释放行指针数组
// 注意:mat 本身不会自动变为 NULL
}

本节课中我们一起学习了C语言指针的核心概念、如何利用指针在函数间修改数据,以及如何使用 malloc、calloc、realloc 和 free 在堆上进行动态内存管理。理解并正确使用这些概念,是编写高效、灵活且无内存泄漏的C程序的关键。记住,动态分配的内存必须手动释放,并且要小心指针的有效性。
003:二进制与位运算
在本节课中,我们将要学习计算机中信息表示的基础——二进制与位运算。我们将了解计算机如何使用比特表示一切数据,并学习如何通过位运算来操作这些比特。
概述:计算机中的比特表示
上一节我们介绍了指针等概念,本节中我们来看看计算机信息表示的基础。计算机内部的所有信息,无论是整数、文本、音乐还是视频,最终都使用比特来表示。比特是“二进制数字”的简称,其值只能是0或1。八个比特构成一个字节,四个比特构成一个半字节。
由于计算机只能处理比特,我们必须找到一种方法,将高级概念映射到特定的比特串上。每个独特的概念都需要一个独特的比特模式来表示。
比特数量的计算
为了表示一定数量的独特状态,我们需要计算所需的比特数。以下是相关的核心公式:
- 如果你有 S 个独特的状态需要表示,你至少需要 ceil(log₂(S)) 个比特。
- 如果你有 B 个比特,你可以表示 2ᴮ 个独特的事物。
例如,老麦克唐纳的农场上有猪、鸭、牛、鸡、狗、猫六种动物。计算 ceil(log₂(6)) ≈ ceil(2.58) = 3,因此我们需要3个比特来唯一标识每种动物。我们可以建立如下映射:000 代表猪,001 代表鸭,010 代表牛,011 代表鸡,100 代表狗,101 代表猫。3比特可以表示8种状态,因此有两种模式未被使用。
比特映射的选择
当我们为状态选择比特映射时,这个选择通常是任意的。然而,我们倾向于选择那些在硬件上易于实现、在程序中便于处理的规则模式。如果某些比特模式在我们的问题中没有意义,那么在一个编写正确的程序中,它们就不应该出现。
位运算基础
现在我们已经了解了比特表示,本节中我们来看看如何操作这些比特。位运算允许我们在比特级别上检查和修改数据。常见的位运算符包括:与(&)、或(|)、非(~)、异或(^)、左移(<<)、算术右移(>>)和逻辑右移(>>,具体行为取决于操作数类型)。
在比特运算中,我们通常将1视为真(True),0视为假(False)。
以下是这些运算符的真值表定义:
- 非(NOT):
~A,翻转所有比特。 - 与(AND):
A & B,仅当两个对应比特都为1时结果为1。 - 或(OR):
A | B,当至少一个对应比特为1时结果为1。 - 异或(XOR):
A ^ B,当两个对应比特不同(一个为0,一个为1)时结果为1。
移位运算
移位运算将比特串中的所有比特向指定方向移动。以下是移位的规则:
- 左移(
<<):A << n。所有比特向左移动n位。左侧溢出的比特被丢弃,右侧空出的位用0填充。这通常可以解释为将A乘以2ⁿ。 - 右移(
>>):具体行为取决于操作数的类型。- 对于无符号类型(如
unsigned int),执行逻辑右移:比特向右移动n位,右侧溢出的比特被丢弃,左侧空出的位用0填充。 - 对于有符号类型(如
int),执行算术右移:比特向右移动n位,右侧溢出的比特被丢弃,左侧空出的位用最高位(符号位)的值填充(即,如果原符号位是0则填0,是1则填1)。
- 对于无符号类型(如
位运算的应用
了解了基本运算符后,我们来看看它们在实际编程中的具体应用。
设置比特位为1
要将变量 a 的第 i 位设置为1,可以使用或(|)运算。因为任何比特与1进行或运算,结果都是1。
以下是具体操作:
a |= (1 << i); // 将变量a的第i位设置为1
如果要同时设置第 i 位和第 j 位,可以组合使用:
a |= (1 << i) | (1 << j); // 将变量a的第i位和第j位同时设置为1
设置比特位为0
要将变量 a 的第 i 位设置为0,可以使用与(&)运算。因为任何比特与0进行与运算,结果都是0。我们需要一个掩码,该掩码在第 i 位为0,其他位为1。
以下是具体操作:
a &= ~(1 << i); // 将变量a的第i位设置为0
同时清零多个位:
a &= ~((1 << i) | (1 << j)); // 将变量a的第i位和第j位同时设置为0
检查比特位的值
要检查变量 a 的第 i 位是1还是0,可以使用与(&)运算。通过将 a 与一个只有第 i 位为1的掩码进行与运算,可以提取出该位的值。
以下是具体操作:
if (a & (1 << i)) {
// 第i位是1
} else {
// 第i位是0
}
在C语言中,0被视为假(False),任何非零值(包括只有一位为1的结果)都被视为真(True)。
翻转比特位的值
要翻转(即取反)变量 a 的第 i 位,可以使用异或(^)运算。因为任何比特与1进行异或运算,结果都会翻转。
以下是具体操作:
a ^= (1 << i); // 翻转变量a的第i位
同时翻转多个位:
a ^= (1 << i) | (1 << j); // 翻转变量a的第i位和第j位
提取比特字段
有时,多个小的数据字段会被打包存储在一个变量中。为了提取某个特定字段,需要两个步骤:
- 右移:将整个变量右移,直到目标字段的最低有效位移动到第0位。
- 与运算:使用一个掩码进行与运算,该掩码的宽度等于目标字段的比特数(即对应位全为1),以屏蔽掉其他字段。
例如,一个32位无符号整数 packed 中存储了多个字段。要提取从第 start_bit 位开始、宽度为 width 比特的字段,可以这样做:
unsigned int field = (packed >> start_bit) & ((1 << width) - 1);
其中,((1 << width) - 1) 会生成一个低 width 位全为1,其余位全为0的掩码。
比特模式与上下文
最后需要强调的是,一个比特模式本身没有固定的含义。它的含义完全取决于上下文。例如,比特模式 101 在不同的上下文中可能表示:
- 在之前的农场例子中,它可能代表“猫”。
- 如果被解释为无符号整数,它代表数字5。
- 如果被解释为有符号整数(使用二进制补码),它可能代表数字-3。
- 在文件权限中,它可能代表“读”和“执行”权限。
在高级语言中,类型系统帮助我们确定上下文。在机器层面,我们通过执行的指令来推断数据的类型。
总结




本节课中我们一起学习了计算机信息表示的基石——二进制与位运算。我们了解到计算机使用比特表示一切,并学习了如何计算表示特定状态所需的比特数。我们深入探讨了各种位运算符(与、或、非、异或、移位)的功能和真值表,并掌握了它们在编程中的实际应用,包括设置、清除、检查和翻转特定位,以及从打包数据中提取字段。最后,我们认识到比特模式的意义依赖于上下文,而类型系统或指令集决定了这种上下文。掌握这些知识是理解底层数据操作和进行高效编程的基础。
004:浮点数与内存基础 🧮
在本节课中,我们将要学习计算机中数字的表示方法,特别是二进制、十六进制、有符号整数(原码与补码)以及浮点数的基本原理。我们还将探讨内存的组织方式,包括字节寻址和字节序(大端序与小端序)。

问题解决流程 📝


在开始编程解决任何问题之前,遵循一个清晰的流程至关重要。以下是推荐的步骤:
- 理解问题:不要立即开始写代码。首先退一步思考,明确需要解决的问题是什么。
- 手动示例:通过手动计算解决一些具体例子。确保例子具有多样性,并测试边界情况。
- 提炼步骤:回顾你的手动计算过程,总结出解决该问题的一系列连贯步骤。这些步骤应该保持通用性,不涉及具体的编程概念(如类型、数组)。
- 验证步骤:将你总结出的步骤重新应用到其他问题上,严格按照步骤执行,而不是依赖直觉。
- 实现代码:现在你有了清晰的计划,可以开始编写代码。让代码的结构反映你的计划,使用函数来隐藏复杂性。
- 测试与调试:测试你的代码。如果结果不符合预期,使用调试器检查代码执行过程,对比实际结果与你的步骤计划,找出并修复错误。
上一节我们介绍了系统化的问题解决流程,本节中我们来看看计算机如何表示不同类型的数据。
数字表示基础 🔢


一个比特模式本身没有特定含义,其意义取决于我们赋予它的上下文。例如,数字 8 的表示可能与某个新字母表中的字母 D 的表示完全相同,但根据上下文,它们代表不同的事物。
十进制与二进制
我们熟悉的十进制数 2356 实际上是以下形式的简写:
2 * 10^3 + 3 * 10^2 + 5 * 10^1 + 6 * 10^0
在二进制(基数为2)系统中,每个位只能是0或1。一个二进制数 1011 表示:
1 * 2^0 + 0 * 2^1 + 1 * 2^2 + 1 * 2^3 = 1 + 0 + 4 + 8 = 13 (十进制)
快速将十进制转换为二进制的方法:
- 列出2的幂(1, 2, 4, 8, 16, 32, 64...),直到找到第一个大于或等于目标数的值。
- 从左到右检查每个幂值:如果该值小于等于当前剩余的数,则在该位置写1,并从剩余数中减去该值;否则写0。
- 继续此过程直到处理完所有位。
示例:将 72 转换为二进制
- 列出幂值:64, 32, 16, 8, 4, 2, 1 (128 > 72,停止)
- 64 <= 72? 是,写1,剩余 72 - 64 = 8
- 32 <= 8? 否,写0
- 16 <= 8? 否,写0
- 8 <= 8? 是,写1,剩余 8 - 8 = 0
- 后续位(4,2,1)均写0
- 结果:
01001000(通常写作1001000)
将二进制转换回十进制:将每个位乘以其对应的2的幂次,然后求和。
十六进制表示法 🔠
十六进制(基数为16)在编程中非常常用,因为它能简洁地表示二进制字符串。每个十六进制数字对应4个二进制位(一个“半字节”)。
- 数字范围:0-9, A(10), B(11), C(12), D(13), E(14), F(15)
- 前缀:在代码中,通常用
0x前缀表示十六进制数(例如0x1A)。 - 转换:
- 二进制转十六进制:从右向左将二进制位每4位一组,将每组转换为对应的十六进制数字。
- 十六进制转二进制:将每个十六进制数字扩展为4位二进制。
示例:二进制 0010 1101 1111 1000 转十六进制
- 分组:
0010,1101,1111,1000 - 转换:
2,D,F,8 - 结果:
0x2DF8
示例:十六进制 0xF1B 转二进制
F->1111,1->0001,B->1011- 结果:
111100011011
任意进制转换 🔄

在不同进制间转换的通用算法如下:

- 转换为十进制:将给定进制的数按位乘以其进制的幂次,转换为十进制数。这是因为我们更擅长十进制运算。
- 公式:
(digit_n * base^n) + (digit_{n-1} * base^{n-1}) + ...
- 公式:
- 除以目标基数:将得到的十进制数不断除以目标基数,记录每次的余数,直到商为0。
- 逆序排列余数:将记录的余数从最后一个到第一个排列,得到目标进制下的表示。
示例:将 432(五进制)转换为七进制
- 转十进制:
4*5^2 + 3*5^1 + 2*5^0 = 100 + 15 + 2 = 117 - 除以7:
117 / 7 = 16 余 516 / 7 = 2 余 22 / 7 = 0 余 2
- 逆序余数:
2, 2, 5 - 结果:
225(七进制)
有符号整数表示:原码与补码 ➖
计算机需要表示负数。有多种表示方法,最常见的是原码和补码。
原码
- 最高位为符号位:0表示正,1表示负。
- 其余位表示数值的绝对值。
- 问题:存在
+0(0000) 和-0(1000) 两种零的表示,使得比较运算复杂。
补码
现代计算机普遍使用补码表示有符号整数。
- 正数:表示方式与原码和二进制相同。
- 负数:其补码表示通过对应正数“取反加一”得到。
- 特点:只有一种零的表示(全0),且加减法硬件实现更简单。
- 范围:对于n位,可表示
-2^{n-1}到2^{n-1}-1。
补码取反(求负)操作:
- 按位取反(0变1,1变0)。
- 将结果加1。
C语言中求补码负值的函数示例:
int negate(int x) {
return ~x + 1; // 按位取反后加1
}
将补码二进制转换为十进制:
- 如果最高位是1(负数),先写下负号。
- 对该数执行取反加一操作,得到其绝对值的二进制表示。
- 将这个正二进制数转换为十进制。
- 结合第1步的负号,得到最终值。
示例:补码 1011 (4位) 转十进制
- 是负数(最高位为1)。
- 取反加一:
1011->0100->0101(十进制5)。 - 结果:
-5。
内存基础与字节序 🧠
字节寻址
大多数计算机内存是字节可寻址的,意味着每个字节(8位)都有一个唯一的内存地址。CPU一次处理的数据单位称为字长(如32位、64位)。
字节序
当存储一个多字节的数据类型(如int)时,字节在内存中的排列顺序称为字节序。
- 大端序:最高有效字节存储在最低内存地址。看起来“自然”。
- 例如,
0x01020304在地址100开始存储为:01 02 03 04
- 例如,
- 小端序:最低有效字节存储在最低内存地址。看起来“反向”。
- 例如,
0x01020304在地址100开始存储为:04 03 02 01
- 例如,
- 现状:Intel处理器使用小端序。网络传输通常使用大端序。两者无优劣之分,但必须保持一致。
浮点数表示 🌊
浮点数使用类似于科学计数法(但基数为2)的形式来表示很大或很小的数,例如 1.01 * 2^4。
IEEE 754 单精度浮点数格式(32位)
浮点数被分为三个字段:
- 符号位 S (1位):0代表正数,1代表负数。
- 指数域 E (8位):表示2的幂次。为了能表示负指数,实际存储的值是 指数 + 127(偏移量)。
- 尾数域 M (23位):存储小数点后的二进制部分。注意,规范化浮点数总是
1.xxxx的形式,因此开头的1是隐含的,不存储。
浮点数的值计算公式:
Value = (-1)^S * (1.M) * 2^(E - 127)
将十进制小数转换为浮点表示
示例:转换 6.25
- 转换为二进制:
6.25(十进制) =110.01(二进制) - 规范化:移动小数点,使其成为
1.xxxx形式:1.1001 * 2^2 - 确定各部分:
- 符号 S:正数,
S = 0 - 指数 E:
E = 2 + 127 = 129(十进制) =10000001(二进制) - 尾数 M:取
1.后面的部分1001,并在右侧补零至23位:10010000000000000000000
- 符号 S:正数,
- 组合:
S | E | M->0 | 10000001 | 10010000000000000000000
总结 🎯
本节课中我们一起学习了计算机中数据表示的核心概念:
- 进制转换:掌握了二进制、十六进制与十进制之间的转换方法,以及任意进制转换的通用算法。
- 有符号整数:理解了原码和补码两种表示法,重点是补码的表示规则和取反操作。
- 内存与字节序:了解了内存的字节寻址特性,以及大端序和小端序对多字节数据存储的影响。
- 浮点数:初步认识了IEEE 754单精度浮点数的存储格式,包括符号位、指数域(带偏移)和尾数域(隐含前导1)。




理解这些底层表示是理解程序行为、进行位操作和调试复杂问题的基础。
005:内存与底层

在本节课中,我们将要学习计算机内存中数据的底层表示与组织方式。我们将探讨二进制表示、浮点数、字符编码、数组的内存布局以及结构体的内存结构。理解这些概念对于编写高效且正确的程序至关重要。
二进制表示与解释
上一节我们回顾了二进制的基础,本节中我们来看看如何解释一个给定的比特模式。
一个比特模式本身没有意义,其含义取决于我们如何解释它。例如,比特模式 10 1101 可以代表不同的值:
- 如果解释为无符号整数,其值为:
1*32 + 0*16 + 1*8 + 1*4 + 0*2 + 1*1 = 45。 - 如果解释为原码,最高位为符号位(1表示负),其余位表示大小:
-(0*16 + 1*8 + 1*4 + 0*2 + 1*1) = -13。 - 如果解释为补码,最高位为1表示负数。求其绝对值的方法是:按位取反,然后加1。
- 原模式:
10 1101 - 按位取反:
01 0010 - 加1:
01 0010 + 1 = 01 0011 01 0011作为无符号整数的值是19,因此原补码模式代表-19。
- 原模式:
浮点数表示
现在,让我们看看浮点数在内存中是如何表示的。浮点数遵循 IEEE 754 标准,使用类似科学计数法的方式,但是以2为底。
一个单精度浮点数(32位)由三部分组成:
- 符号位 S(1位)
- 指数位 E(8位)
- 尾数位 M(23位)


其表示的数值公式为:(-1)^S * 1.M * 2^(E-127)
示例:求 -19.0 的浮点表示
- 求绝对值的二进制:
19的二进制是10011。 - 规格化:写成
1.0011 * 2^4。所以尾数 M =0011,指数部分应为4 + 127 = 131(加上偏置值127)。 - 组合字段:
- 符号 S:1(因为是负数)
- 指数 E:
131的二进制(10000011) - 尾数 M:
0011后补19个0,凑足23位。
- 最终32位模式:
1 10000011 00110000000000000000000
特殊值表示:
- 如果 E=0 且 M=0,表示数字
0。 - 如果 E全为1 且 M=0,表示
无穷大 (Infinity)。 - 如果 E全为1 且 M≠0,表示
非数字 (NaN)。
字符表示
字符在内存中也由比特模式表示,这种映射关系是人为定义的。常见的编码方案是ASCII(1字节)和Unicode(如UTF-16,2字节)。
在ASCII表中,字符的编码是连续的,这使得我们可以对字符进行算术运算。例如:
'a'的ASCII码是97,'b'是98,'c'是99。- 因此,
'a' + 2的结果是99,对应字符'c'。 - 同样,
'c' - 'a'的结果是2。
这种特性也解释了字符串比较时的行为:计算机会逐个比较字符的编码值。由于大写字母的编码(‘A‘=65)小于小写字母(‘a‘=97),所以 "Zebra" < "apple" 在字典序比较中结果为真。
数组的内存布局
数组元素在内存中是连续存储的。对于一维数组 arr[i],其访问等价于 *(arr + i)。
对于多维数组,主要有两种内存组织方式:
1. 单一大块内存(行优先)
C语言中的静态多维数组采用这种方式。所有元素存储在一个连续的内存块中,按行排列。
对于一个 M x N 的二维数组,要访问 arr[i][j],其对应的内存地址计算公式为:
地址 = arr + i * N + j
示例:一个 3 x 4 的数组,访问 arr[1][2]。
地址 = arr + 1 * 4 + 2 = arr + 6,即从起始地址向后偏移6个元素的位置。


对于更高维的数组(如 D1 x D2 x D3),访问 arr[i1][i2][i3] 的公式为:
地址 = arr + i1 * (D2 * D3) + i2 * D3 + i3
规律是:每个索引乘以其后所有维度的乘积之和。
2. 数组的数组(指针数组)
动态创建的多维数组或Python/Java中的列表的列表采用此方式。它实际上是一个“指针数组”,每个指针指向一个一维数组(一行)。
访问 arr[i][j] 需要两次解引用:
值 = *(*(arr + i) + j)
优势与劣势对比:
以下是两种方式的比较:
| 特性 | 单一大块内存 | 数组的数组 |
|---|---|---|
| 内存开销 | 小(仅一个头指针) | 大(需要额外存储指针数组) |
| 灵活性 | 差,每行大小必须相同 | 好,可创建“锯齿状数组” |
| 访问速度 | 快(一次计算,缓存友好) | 慢(多次指针跳转) |
| 函数传参 | 需指定除第一维外的所有维度 | 无需指定维度(使用指针的指针) |
| 内存分配 | 通常需在编译时确定大小 | 可在运行时动态决定大小 |
结构体的内存布局
结构体(或类)的成员在内存中也是顺序存放的,但可能会因为“内存对齐”而存在填充字节,以确保每个成员在自然边界上对齐,从而提升访问速度。
示例:
struct Example {
int x; // 假设占4字节,偏移 0
char y; // 占1字节,偏移 4
short s; // 占2字节,偏移 6 (需对齐到2的倍数)
char name[10]; // 占10字节,偏移 8
double d; // 占8字节,偏移 18 (需对齐到8的倍数,所以实际从24开始)
float f; // 占4字节,偏移 32
};
访问 myStruct.member 时,编译器会计算该成员相对于结构体起始地址的偏移量。
关于变量存储顺序:
- 全局变量:按声明顺序存储(先声明的在低地址)。
- 函数参数:按参数顺序存储(第一个参数在低地址)。
- 局部变量:通常按声明逆序存储(后声明的变量在低地址)。
绕过访问限制
C++中的 private 等访问控制是编译器层面的保护,硬件层面没有限制。如果你知道一个私有成员在对象内存布局中的偏移量,可以通过指针直接访问和修改它。
class MyClass {
private:
int secret;
public:
MyClass() : secret(42) {}
int getSecret() { return secret; }
};
int main() {
MyClass obj;
int* p = (int*)&obj; // p 指向对象起始地址,即 secret 的位置
*p = 100; // 直接修改私有成员 secret
cout << obj.getSecret(); // 将输出 100
}
注意:这是一种破坏封装性的危险操作,仅用于理解底层原理,不应在实际开发中使用。
总结


本节课中我们一起学习了计算机内存的底层表示。我们探讨了如何解释二进制比特模式,深入了解了浮点数的IEEE 754标准表示法,以及字符的编码原理。我们还分析了数组在内存中的两种主要组织方式——单一大块内存和数组的数组,并比较了它们的优缺点。最后,我们了解了结构体的内存布局,并看到了如何通过内存地址操作绕过高级语言的一些抽象限制。理解这些底层概念有助于你写出更高效、更可靠的代码,并更好地调试复杂问题。
006:硬件基础知识
在本节课中,我们将学习计算机硬件的基础知识,包括位运算的实际应用、计算机内部数据的表示方式,以及CPU、内存、总线等核心硬件组件的工作原理。理解这些概念是学习汇编语言和深入理解程序运行机制的关键。
位运算应用
上一节我们介绍了位运算的基本操作符。本节中我们来看看如何利用这些操作符来精确地设置、清除或翻转一个整型变量中的特定位。
设置特定位为1
要将变量 var 的第 i 位设置为1,需要使用 或(OR) 运算。因为任何位与1进行或运算的结果都是1,而与0进行或运算则保持不变。
公式:var |= (1 << i)
以下是具体步骤:
- 使用左移操作
(1 << i)生成一个掩码,该掩码仅在位置i处为1,其余位均为0。 - 使用
|=操作符将变量var与该掩码进行或运算,从而将第i位设为1。
设置特定位为0
要将变量 var 的第 i 位设置为0,需要使用 与(AND) 运算。因为任何位与0进行与运算的结果都是0,而与1进行与运算则保持不变。
公式:var &= ~(1 << i)
以下是具体步骤:
- 使用左移操作
(1 << i)生成一个掩码。 - 使用按位取反操作
~对该掩码进行翻转,得到一个在位置i处为0,其余位均为1的新掩码。 - 使用
&=操作符将变量var与该新掩码进行与运算,从而将第i位清零。
翻转特定位的值
要翻转(即取反)变量 var 的第 i 位,需要使用 异或(XOR) 运算。因为任何位与1进行异或运算的结果是其相反值,而与0进行异或运算则保持不变。
公式:var ^= (1 << i)
以下是具体步骤:
- 使用左移操作
(1 << i)生成一个掩码。 - 使用
^=操作符将变量var与该掩码进行异或运算,从而翻转第i位的值。
检查特定位的值
要检查变量 var 的第 i 位是1还是0,可以结合使用 与(AND) 运算和条件判断。
代码:
if (var & (1 << i)) {
// 第 i 位是 1
} else {
// 第 i 位是 0
}
以下是原理说明:
- 使用
(1 << i)生成掩码。 - 将
var与掩码进行与运算。如果第i位是1,结果将是一个非零值(在C语言中视为“真”);如果第i位是0,结果将是0(视为“假”)。
注意:不应将结果与数字1直接比较(如 (var & (1 << i)) == 1),因为当 i 不为0时,结果可能是 2^i(例如8),它不等于1,但依然是非零的“真”值。
数值表示范围
理解了位操作后,我们来看看不同类型整数在给定位数下的表示范围。这对于理解数据溢出和内存使用至关重要。
假设我们使用 B 位来表示一个整数。
- 无符号整数:所有位都用于表示数值大小。
- 范围:
0到2^B - 1
- 范围:
- 有符号整数(原码表示):使用1位作为符号位,其余位表示数值大小。
- 范围:
-(2^(B-1) - 1)到+(2^(B-1) - 1)
- 范围:
- 有符号整数(补码表示):现代计算机普遍采用的方式。
- 范围:
-2^(B-1)到+(2^(B-1) - 1)
- 范围:
移位运算的注意事项
移位运算在处理有符号和无符号数时行为不同,这是一个容易出错的地方。
- 左移(
<<):总是向空出的低位补0。 - 右移(
>>):- 对无符号整数进行右移是逻辑右移,向空出的高位补0。
- 对有符号整数进行右移是算术右移,向空出的高位补符号位(即最高位的值)。
示例:
int x = -1; // 补码表示为全1
unsigned int y = -1; // 位模式也是全1,但解释为很大的无符号数
x = x >> 1; // 算术右移,结果仍是全1,值保持为-1
y = y >> 1; // 逻辑右移,高位补0,结果是一个很大的正数
// 因此 (x >> 1) == (y >> 1) 的结果是 假 (0)
数据的多重解释
计算机中所有数据最终都以比特(0和1)的形式存储。这些比特串本身没有固有含义,其意义取决于我们如何解释它。同一个比特序列可以被解释为整数、浮点数、字符或机器指令。
示例:比特序列 0x43415453(十六进制)
- 作为十六进制数:
0x43415453 - 作为无符号整数:
1129463891 - 作为补码整数:
1129463891(因为最高位是0) - 作为浮点数:可能与0.0相关(取决于指数域)
- 作为字符(小端序):最低有效字节
0x43是'C',连续解读可得字符串"CATS"。
这个例子强调了“类型”的概念:它告诉编译器或程序员应该如何理解某块内存中的比特。
计算机硬件组成概述
现在,让我们从宏观角度了解计算机硬件的几个核心组成部分。
中央处理器(CPU) 🧠
CPU是计算机的“大脑”,负责执行程序指令。其主要部件包括:
- 算术逻辑单元(ALU):执行加、减、乘、除、与、或等运算。
- 寄存器:CPU内部的高速存储单元,用于临时存放数据、地址和控制信息。常见的有:
EAX,EBX,ECX,EDX:通用数据寄存器。EIP:指令指针,存放下一条要执行指令的地址。ESP:栈指针,指向当前栈顶。
- 控制单元:协调CPU内部各部件的工作。
内存
内存用于存储正在运行的程序和所需数据。
- 随机存取存储器(RAM):可读写,访问任何位置耗时相近。易失性,断电后数据丢失。
- 只读存储器(ROM):通常只能读取,存储固件(如BIOS)。非易失性,断电后数据保留。
注意:硬盘、固态硬盘(SSD)等属于输入/输出设备,不是这里所说的“内存”。
输入/输出设备(I/O)
这些设备使计算机能与外界交互。
- 输入设备:键盘、鼠标、麦克风、扫描仪。
- 输出设备:显示器、打印机、扬声器。
- 网络设备:网卡、Wi-Fi适配器。
系统总线
总线是一组用于在计算机各部件(CPU、内存、I/O设备)间传输数据的公共通信线路。可分为:
- 控制总线:传输控制信号(如读/写命令)。
- 地址总线:指定要访问的内存或设备地址。
- 数据总线:实际传输的数据内容。
软件可移植性问题
程序的可移植性受到硬件和软件两方面的限制。
- 硬件限制:为特定CPU架构(如x86、ARM)编译的程序无法直接在其他架构上运行。
- 软件/操作系统限制:程序通过操作系统提供的接口(系统调用)访问硬件。不同操作系统(如Windows、Linux、macOS)的系统调用接口不同,因此为某一系统编译的程序通常无法在另一系统上直接运行。
这就是为什么下载软件时需要选择对应操作系统和处理器位数的版本。


本节课中我们一起学习了位运算的实用技巧,包括设置、清除、翻转和检查特定位。我们还探讨了不同整数类型的表示范围、有符号/无符号数移位运算的区别,以及数据比特的多重解释性。最后,我们概述了计算机硬件的核心组件:CPU、内存、I/O设备和总线,并了解了影响软件可移植性的硬件与操作系统因素。这些基础知识是后续学习汇编语言和计算机系统工作原理的坚实基石。
007:x86汇编基础 🖥️

在本节课中,我们将学习x86汇编语言的基础知识,包括CPU的工作原理、指令格式、寄存器使用以及如何编写和调试一个简单的汇编程序。


概述
上一节我们介绍了CPU的基本组成和工作周期。本节中,我们将深入x86汇编语言,学习其指令格式、操作数类型,并通过一个实际的例子来理解如何将C语言逻辑转换为汇编代码。
CPU执行周期回顾
CPU通过一个简单的循环来执行程序,这个循环称为“取指-译码-执行-写回”周期。具体步骤如下:
- 取指:从程序计数器(PC)指向的内存地址获取指令,放入指令寄存器。
- 译码:解析指令,确定要执行的操作(如加法、减法)。
- 执行:执行算术或逻辑运算。
- 写回:将结果写回寄存器或内存。
然后,CPU返回步骤1,获取下一条指令,周而复始,直到程序结束。
衡量计算机速度


衡量计算机速度有两种主要方式:
- CPU时间:CPU实际执行程序指令所花费的时间。
- 墙上时间:程序从开始到结束实际经过的物理时间。
这两个时间可能不同,因为CPU可能在等待用户输入或执行其他任务。估算CPU时间的一个简单公式是:
总CPU时间 ≈ 指令数 × 每条指令时钟周期数 × 每个时钟周期时间
CPU的主频(如3.2 GHz)表示每秒可以执行的时钟周期数。
提升速度:并行性
提升计算速度的一种重要方法是利用并行性,主要有两种形式:
- 流水线:将指令执行过程分解为多个阶段(如取指、译码、执行、写回),让不同指令的不同阶段同时进行,类似于工厂的装配线。
- 多任务并行:让多个处理器核心或线程同时处理不同的任务。这又分为:
- 消息传递:进程间通过发送消息进行通信。
- 共享内存:进程通过共享的内存区域进行通信。
编写高效的并行程序具有挑战性,因为进程间通信会带来开销。但某些问题(如矩阵加法)是“易并行”的,可以几乎线性地提升速度。
x86汇编指令格式
现在,我们开始学习x86汇编语言。大多数x86指令遵循以下格式:
操作码 目标操作数, 源操作数
其效果通常是:目标操作数 (操作符)= 源操作数。例如,add %eax, %ebx 相当于 %ebx += %eax。x86是一种“双操作数”架构。
操作数类型

指令中的操作数可以是以下几种类型:
以下是常见的操作数类型及其表示方法:

- 立即数(常量):以美元符号
$开头。例如,$4表示数值4。- 重要:忘记
$会导致程序试图访问内存地址4的内容,通常会引起段错误。
- 重要:忘记
- 寄存器:以百分号
%开头。例如,%eax、%ebx。- 寄存器是CPU内部的高速存储单元,用于临时存放数据和运算结果。
- 内存地址:使用特定的寻址模式表示,例如
(%eax)或4(%eax, %ebx, 2)。
寄存器详解
x86架构有一组通用寄存器。以下是主要的32位寄存器及其部分功能:
- EAX, EBX, ECX, EDX:通用寄存器,可用于各种计算。
- ESI, EDI:通常用作源/目标索引寄存器。
- EBP:基址指针,在C函数调用中用于定位栈帧。
- ESP:栈指针,指向当前栈顶。不要将其用作通用寄存器。
对于EAX, EBX, ECX, EDX,还可以访问其低16位(AX, BX, CX, DX)以及AX的低8位(AL)和高8位(AH)。例如,EAX是一个32位寄存器,AX是其低16位,AL是AX的低8位,AH是AX的高8位。修改AL、AH或AX都会影响EAX的值。
编写第一个汇编程序
理论学习之后,我们通过一个实例来学习如何编写汇编程序。我们的目标是实现一个C语言函数:计算一个整数数组所有元素的和。
对应的C代码如下:
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += array[i];
}
汇编程序结构
一个完整的汇编程序通常包含以下部分:
# 数据段:用于定义全局变量和静态数据
.section .data
array: .long 12, 12, 12, 12, 12, 12, 12, 12, 12, 12 # 定义一个包含10个整数的数组,每个初始值为12
array_len = 10 # 定义数组长度的常量
# 代码段:存放程序指令
.section .text
.globl _start # 声明_start为全局符号,链接器需要知道程序入口
_start:
# 初始化寄存器:%eax 存放和(sum),%ecx 存放计数器(i)
movl $0, %eax # sum = 0
movl $0, %ecx # i = 0
loop_start:
# 循环条件判断:如果 i >= array_len,则跳转到循环结束
cmpl $array_len, %ecx # 比较 i 和 array_len (计算 %ecx - array_len)
jge loop_end # 如果 i - array_len >= 0 (即 i >= array_len),则跳转
# 循环体:sum += array[i]
# 使用“基址+变址*比例因子”寻址模式访问数组
# array(, %ecx, 4) 等价于 C 中的 &array[i],因为每个整数占4字节
addl array(, %ecx, 4), %eax # %eax += array[i]
# 更新计数器:i++
incl %ecx # i = i + 1
# 跳回循环开始
jmp loop_start
loop_end:
# 循环结束,程序逻辑完成
# 在实际可执行程序中,这里需要调用系统退出,但本例中我们仅作演示
nop # 无操作指令,用于设置断点
done:
nop # 另一个标签,用于在调试器中标记程序结束点
关键指令解释
.section .data / .text:定义数据段和代码段。.globl _start:使_start标签对链接器可见,作为程序入口。movl $0, %eax:将立即数0移动到寄存器EAX。l后缀表示操作长字(4字节)。cmpl A, B:比较操作,计算B - A,并根据结果设置标志位,但不保存结果。jge label:条件跳转指令。如果上一次比较的结果是“大于或等于”,则跳转到label。addl array(, %ecx, 4), %eax:内存寻址。从地址array + %ecx * 4处读取一个长字(4字节),并将其值加到%eax中。incl %ecx:递增指令,%ecx的值加1。jmp label:无条件跳转到label。



汇编、链接与调试
- 汇编:使用
as命令将汇编源代码(.s文件)转换为目标文件(.o文件)。as --gstabs -o sum.o sum.s--gstabs参数用于生成调试信息。


- 链接:使用
ld命令将目标文件链接成可执行文件。ld -m elf_i386 -o sum.out sum.o-m elf_i386指定生成32位的可执行文件。

- 调试:汇编程序没有直接的输出语句,必须使用调试器(如DDD或GDB)来观察寄存器值和程序状态。
- 在DDD中,可以使用
graph display $eax来监视EAX寄存器。 - 使用
break _start和break done设置断点。 - 使用
run运行,step单步执行,continue继续运行。
- 在DDD中,可以使用
常见C结构到汇编的转换
最后,我们总结一个将C语言 while 循环转换为汇编的通用模式。
C语言代码:
while (i < value) {
// 循环体代码
}
转换步骤:

- 将条件重写为与0比较:
i - value < 0。 - 取其反条件用于跳出循环:
i - value >= 0。 - 在汇编中,在循环开始处检查反条件,若成立则跳转到循环结束。

汇编代码框架:
# 假设 i 在 %ecx 中,value 在 %eax 中
loop_start:
cmpl %eax, %ecx # 计算 i - value
jge loop_end # 如果 i >= value (即 i-value >= 0),跳出循环
# ... 循环体代码 ...
jmp loop_start # 跳回循环开始
loop_end:
# 循环结束后的代码
总结


本节课我们一起学习了x86汇编语言的基础。我们回顾了CPU的工作周期,了解了并行计算的概念,重点掌握了x86汇编的指令格式、操作数类型和寄存器的使用方法。通过一个求数组和的完整示例,我们实践了汇编程序的编写、汇编、链接和调试流程。最后,我们学习了将C语言循环结构转换为汇编代码的基本模式。掌握这些基础知识是后续进行更复杂汇编编程和理解程序底层运行机制的关键。
008:C语言到汇编语言基础 🖥️

在本节课中,我们将学习如何将常见的C语言结构(如循环、条件判断和数组访问)翻译成汇编语言。我们将从基础的控制流结构开始,逐步深入到更复杂的表达式和内存访问模式。
概述 📋
上一节我们介绍了汇编语言的基本指令。本节中,我们来看看如何将C语言中的核心控制结构和数据操作转换为等价的汇编代码。理解这种转换是掌握底层编程的关键。
循环结构的翻译 🔄
While循环
首先,我们来看如何翻译一个简单的while循环。假设我们有如下C代码:
while (i < val) {
// 一些代码
}
我们假设寄存器ECX存储变量i,寄存器EAX存储变量val。
在汇编中,所有比较操作最终都是与零进行比较。因此,我们需要将条件 i < val 重写为 i - val < 0。更常见的做法是使用其否定形式,并在条件为真时跳出循环。
以下是翻译步骤:
- 创建一个循环开始的标签(例如
while_start)。 - 在循环顶部,计算
i - val并设置标志位。 - 如果
i >= val(即否定条件成立),则跳转到循环结束的标签(while_end)。 - 执行循环体内的代码。
- 在循环体末尾,无条件跳转回循环开始标签(
while_start)。 - 定义循环结束标签(
while_end)。
对应的汇编代码框架如下:
while_start:
cmpl %eax, %ecx # 计算 i - val (ECX - EAX)
jge while_end # 如果 i >= val,跳转到循环结束
# ... 循环体代码 ...
jmp while_start # 跳回循环开始
while_end:
Do-While循环
do-while循环与while循环的主要区别在于条件检查在循环体之后进行。这意味着循环体至少会执行一次。
翻译一个do-while循环(do { ... } while (i < val);)的步骤如下:
- 创建循环开始标签(
do_start)。 - 首先执行循环体代码。
- 在循环体后,计算
i - val。 - 如果
i < val条件为真,则跳转回循环开始标签。
对应的汇编代码框架如下:
do_start:
# ... 循环体代码 ...
cmpl %eax, %ecx # 计算 i - val
jl do_start # 如果 i < val,跳回循环开始
For循环
一个典型的for循环(for (i=0; i<val; i++) { ... })可以看作是while循环的扩展,包含了初始化、条件检查和更新步骤。
翻译步骤如下:
- 在循环开始前,执行初始化(将
i设为0)。 - 创建循环开始标签(
for_start)。 - 在标签后,进行条件检查(
i < val)。如果否定条件成立(i >= val),则跳转到循环结束(for_end)。 - 执行循环体代码。
- 执行更新步骤(
i++)。 - 无条件跳转回循环开始标签。
- 定义循环结束标签。
对应的汇编代码框架如下:
movl $0, %ecx # i = 0
for_start:
cmpl %eax, %ecx # 比较 i 和 val
jge for_end # 如果 i >= val,跳转到结束
# ... 循环体代码 ...
incl %ecx # i++
jmp for_start # 跳回循环开始
for_end:
条件判断的翻译 ⚖️
If-Else If-Else 语句
翻译多分支的if-else if-else语句时,一种清晰的方法是使用条件的否定形式,并在条件失败时跳过相应的代码块。
以如下C代码为例:
if (i < 10) {
// code1
} else if (i < 20) {
// code2
} else {
// code3
}
假设i在ECX寄存器中。
翻译策略是:
- 检查第一个条件(
i < 10)。如果其否定形式(i >= 10)为真,则跳转到第二个条件(if2)的标签。 - 执行第一个条件为真时的代码块(
code1),然后无条件跳转到整个语句的结束标签(if_end)。 - 定义第二个条件开始的标签(
if2)。检查第二个条件(i < 20)。如果其否定形式(i >= 20)为真,则跳转到else块的开始标签(else_start)。 - 执行第二个条件为真时的代码块(
code2),然后无条件跳转到结束标签。 - 定义
else块开始标签(else_start),执行code3。 - 定义整个条件语句的结束标签(
if_end)。
对应的汇编代码框架如下:
cmpl $10, %ecx # 比较 i 和 10
jge if2 # 如果 i >= 10,跳转到 if2
# ... code1 ...
jmp if_end
if2:
cmpl $20, %ecx # 比较 i 和 20
jge else_start # 如果 i >= 20,跳转到 else
# ... code2 ...
jmp if_end
else_start:
# ... code3 ...
if_end:
复合条件(AND 和 OR)
对于使用&&(AND)的复合条件,例如if (a && b && c),可以将其转换为嵌套的if语句来翻译。
对于使用||(OR)的复合条件,例如if (a || b || c),目标是如果任一条件为真就执行代码块,且只执行一次。可以通过串联条件检查来实现:如果第一个条件为真,则跳转到公共的代码块;否则检查第二个条件,以此类推。所有条件都失败时,才跳转到整个判断的结尾。
表达式与数据移动 🧮
在汇编中,复杂的表达式必须分解为单步操作。例如,对于C语句 d = a + b + c;,假设a在EAX,b在EBX,c在ECX,d在EDX。
翻译过程如下:
movl %eax, %edx # d = a
addl %ebx, %edx # d = d + b (即 a + b)
addl %ecx, %edx # d = d + c (即 a + b + c)
注意:在汇编中,一条指令最多只能有一个操作数在内存中。因此,不能直接用一条指令将数据从一个内存地址移动到另一个内存地址,必须通过寄存器中转。
数组访问与高级寻址模式 🗂️
在汇编中访问数组元素,需要使用高级寻址模式,其通用格式为:
D(O, I, S)
其计算的内存地址为:D + O + I * S
- D (Displacement):位移量,必须是一个常量(常数或代表固定内存地址的标签)。
- O (Offset):基址偏移,必须是一个寄存器。
- I (Index):索引,必须是一个寄存器。
- S (Scale):比例因子,只能是1、2、4或8。

数组访问示例

假设有一个整型数组ar(int ar[...];),在C中访问ar[i]等价于 *(ar + i)。由于每个int占4字节,为了正确索引,需要将i乘以4。
情况一:ar是内存中的标签(常量地址)
# 将55存入 ar[i]
# 假设 i 在 ECX 中
movl $55, ar(, %ecx, 4) # ar 是常量标签,放入 D 字段;i 在 ECX,放入 I 字段;int大小为4
情况二:ar的地址存储在寄存器EAX中(非常量)
# 将55存入 ar[i]
# 假设 ar 的地址在 EAX 中,i 在 ECX 中
movl $55, (%eax, %ecx, 4) # ar 的地址在寄存器中,放入 O 字段;D 字段为空(0)
关键点:
- 标签(如
ar)代表一个固定的内存地址,是一个常量,可以放在寻址模式的D字段。 $符号在汇编中表示常量,而不是“取地址”。$ar表示标签ar代表的地址值本身这个常数。- 指令后缀(
.l,.w,.b)决定了从计算出的内存地址读取/写入多少字节。
总结 🎯
本节课我们一起学习了如何将C语言的基本结构翻译成汇编语言。
- 我们掌握了循环结构(
while,do-while,for)的翻译模式,核心在于条件重写和标签跳转。 - 我们探讨了条件判断(
if-else-if, 复合条件)的翻译方法,通常使用条件的否定形式来控制流程跳转。 - 我们理解了复杂表达式需要分解为单步的算术和移动指令。
- 最后,我们学习了关键的高级寻址模式
D(O, I, S),它是访问数组和结构体等内存数据的基础,需要特别注意常量、寄存器和数据大小的正确使用。


通过理解这些转换,你能够更深入地洞察高级语言如何在底层硬件上执行,并为编写和调试底层代码打下坚实基础。
009:x86汇编中的高级数组索引 🧮



在本节课中,我们将学习如何在x86汇编语言中翻译和处理高级的数组索引操作。我们将重点关注C语言中常见的数组表达式如何转换为等价的汇编指令,特别是使用高级索引模式(Displacement + Offset + Index * Scale)。通过具体的例子,我们将掌握处理静态和动态数组、不同数据类型以及多维数组的方法。
课程公告与回顾 📢
上一节我们介绍了汇编语言的基础知识。本节开始前,有一些课程安排需要通知。
- 作业二已发布,今天课程结束后即可开始。请确保尽早开始。
- 作业二的C语言部分必须用C语言完成,不能使用C++。
- 第二章测验将在本周末开放,内容简短。
- 第三章(汇编部分)的测验预计在下周发布。
- 期中考试也即将到来。
现在,让我们回到核心内容。
高级索引模式详解 🔍
我们正在学习将常见的C语言表达式翻译成汇编代码。我们使用的高级索引模式格式为 D(O, I, S),其效果是计算内存地址:D + O + I * S,并访问该地址的值。
其各部分含义如下:
- D (Displacement): 必须是一个常量表达式。
- O (Offset): 必须是一个寄存器。
- I (Index): 必须是一个寄存器。
- S (Scale): 可以是1、2、4或8。
如果你不提供D、O或I,它们将默认为0。如果不提供S,则默认为1。S的值实际上受你操作的数据类型大小影响:
- 操作字符(char)时,S应为 1。
- 操作短整型(short)时,S应为 2。
- 操作整型(int)或浮点型(float)时,S应为 4。
- 操作指针时(无论是什么类型的指针),在32位机器上,S也应为 4,因为所有指针的大小都是4字节(存储一个32位地址)。
接下来,我们将通过翻译一些常见的数组类型表达式来巩固理解。我们会分别演示当AR是数据段中的一个标签(静态地址)和当AR是一个寄存器中的值(动态地址)时的情况。
静态数组访问示例 📝
假设我们有一个短整型(short)数组 AR。在C语言中,表达式 AR[7] = 55; 等价于 *(AR + 7) = 55;。
在汇编中,如果AR是数据段的一个标签(即常量地址),那么AR和7都是常量。根据规则,常量必须放在位移(D)字段。此外,因为操作的是短整型(2字节),我们需要使用movw指令。
因此,正确的翻译是计算 AR + 7 * 2 的地址,并将55存入。汇编代码如下:
movw $55, AR+14
这里,14 是 7 * 2 的结果,因为每个短整型占2字节。
再看一个取值操作:AX = AR[3];(假设AX用于存放16位值)。这需要从内存地址 AR + 3 * 2 处读取一个短整型到AX寄存器。
movw AR+6, %ax
现在考虑一个涉及变量的例子:AR[i+3] = 55;,其中AR是字符(char)数组,i存储在ECX寄存器中。
表达式等价于 *(AR + i + 3) = 55;。AR和3是常量,i(ECX)是变量。
常量部分 AR+3 放入D字段。变量部分 i 应放入索引(I)字段,因为Scale为1(字符大小),乘1不影响结果,但为保持一致性我们通常使用I字段。使用movb指令操作字节。
movb $55, AR+3(,%ecx,1)
动态地址与复合索引 🧩
当数组基地址存储在一个寄存器中时,情况有所不同。假设AR地址在EAX中,我们执行 AR[i+3] = 55;(AR为字符数组,i在ECX)。
此时,AR(即EAX)不是常量,只有3是常量。常量3放入D字段。基地址寄存器EAX应放入偏移(O)字段,索引i(ECX)放入索引(I)字段。
movb $55, 3(%eax,%ecx,1)
对于更复杂的表达式,如 AR[i+j+5](整型数组,i在ECX,j在EBX),我们需要先计算变量偏移之和 i+j,然后加上常量偏移。
这通常需要多条指令来完成:
movl %ebx, %edx # 将j复制到EDX
addl %ecx, %edx # EDX = i + j
# 现在 EDX 中为变量偏移 (i+j)
movl $55, AR+20(,%edx,4) # AR + 5*4 + (i+j)*4, Scale=4对应整型
可以看到,一行C代码可能需要多行汇编来实现。
多维数组与乘法指令 ✖️
处理多维数组时,例如一个5x16的短整型静态数组 AR[5][16],访问 AR[i][j] 需要计算偏移:i * 16 + j(行主序)。这引入了乘法运算。
x86的乘法指令 imull 有些特殊。当两个32位数相乘时,结果可能高达64位。指令格式为 imull source,其效果是将 %eax 与 source 相乘,结果的低32位存入 %eax,高32位存入 %edx。
因此,在使用imull前,通常需要把第一个操作数放入%eax,并且要注意%edx的值会被覆盖。
例如,计算 i * 16(i在ECX):
movl $16, %eax
imull %ecx # 结果 i*16 在 %eax 中
addl %ebx, %eax # %eax = i*16 + j (j在EBX)
# 现在可以用高级索引模式访问 AR[%eax*2]
movw $55, AR(,%eax,2)


动态分配的多维数组 🔄



如果AR是一个动态分配的多维数组(例如 short **AR),那么 AR[i][j] 的含义不同。它等价于 *(*(AR + i) + j)。
这需要分两步进行:
- 首先,获取行指针:
AR[i],即*(AR + i)。这里AR是指针的指针,所以加i是移动i个指针,Scale为4。movl AR(,%ecx,4), %edx # %edx = AR[i],即第i行的起始地址 - 然后,在该行内访问列元素:
*(%edx + j)。此时%edx是short*类型,加j是移动j个短整型,Scale为2。movw $55, (%edx,%ebx,2)
理解数组在内存中的实际布局(连续块 vs 指针数组)对于正确翻译至关重要。
实战:矩阵加法编程 🖥️
最后,我们通过一个实战例子来综合运用:编写汇编代码实现两个矩阵的加法(C = A + B)。假设矩阵以静态一维数组形式存储。
解决问题的第一步总是先编写C代码,这有助于理清逻辑。矩阵加法的C代码核心是一个双重循环:
for (row=0; row<num_rows; row++) {
for (col=0; col<num_cols; col++) {
C[row][col] = A[row][col] + B[row][col];
}
}
由于我们的数组是连续存储的,A[row][col] 的实际地址是 A + row*num_cols + col(再乘以元素大小)。
翻译成汇编时,我们需要:
- 为变量分配寄存器(例如,row用EDX,col用ESI,A用标签,B的地址加载到EBX,C的地址加载到ECX)。
- 实现外层循环和内层循环,使用
cmp和条件跳转指令(如jge)控制循环。 - 在最内层,分别计算A、B、C中当前元素的地址。以计算A中元素地址并取值到EAX为例:
注意:movl num_cols, %eax # 将列数放入EAX imull %edx # EAX = row * num_cols, 注意这会破坏EDX! addl %esi, %eax # EAX = row*num_cols + col movl A(,%eax,4), %eax # EAX = A[row][col], Scale=4对应整型imull会覆盖EDX寄存器,而EDX正存储着row的值!因此,在乘法之前,必须将row的值保存到其他安全的地方(例如另一个寄存器或内存中)。这展示了在汇编编程中管理寄存器冲突的重要性。
完整的解决方案需要仔细处理所有这些细节,包括寄存器的保存与恢复、循环的正确跳转以及最终结果的存储。
总结 📚
本节课中,我们一起深入学习了x86汇编语言中高级数组索引的翻译方法。
我们掌握了高级索引模式 D(O,I,S) 的用法,理解了位移、偏移、索引和比例因子各字段的规则。我们通过大量例子,实践了如何将静态数组、动态数组以及不同数据类型的C语言访问语句转换为汇编指令。我们还探讨了处理多维数组(包括静态连续存储和动态指针数组)时的不同策略,并介绍了必要的乘法指令 imull。最后,我们以一个矩阵加法的实战例子,展示了如何将复杂的C语言逻辑分解并系统地翻译成汇编代码,其中特别需要注意寄存器分配和冲突避免。


记住,编写汇编代码的关键在于耐心地将复杂问题分解为小步骤,并清晰理解每个操作的数据类型和内存布局。
010:高级汇编示例

在本节课中,我们将深入学习高级汇编编程,特别是如何将复杂的C语言表达式(如矩阵加法)翻译成汇编代码。我们将重点探讨高级寻址模式的应用、寄存器的管理以及如何将大问题分解为可管理的小步骤。
高级寻址模式回顾

上一节我们介绍了汇编的基础指令,本节中我们来看看高级寻址模式,这是处理数组和复杂数据结构的关键。

高级寻址模式遵循以下形式:位移(偏移, 索引, 比例因子)。其中:
- 位移 必须是一个常量表达式。
- 偏移 和 索引 是寄存器。
- 比例因子 只能是 1、2、4(在32位模式下可能还有8)。
它计算的内存地址公式为:地址 = 位移 + 偏移 + (索引 * 比例因子)。
这种模式完美匹配C语言中的数组访问。例如,C代码 array[i + 7] 等价于 *(array + i + 7)。在汇编中:
- 如果
array是一个内存标签(常量地址),它进入位移字段。 - 如果
array的值存储在一个寄存器中,它进入偏移字段。 - 变量索引
i进入索引字段。 - 数组元素的大小(以字节为单位)作为比例因子。
内存是按字节寻址的,因此通过这种指令格式能访问的最小单位是单个字节。要操作单个比特,需要使用位运算指令。
以下是关于移位指令的重要说明:
- 变量移位量必须放在
%cl寄存器中。 - 常量移位量可以直接指定。
- 参考指令表:
SAR(算术右移)、SHR(逻辑右移)、SHL(左移)。
矩阵加法:单一大数组实现
现在,我们应用这些概念来翻译一个具体的C语言任务:矩阵加法。假设矩阵在内存中存储为单一大数组(行主序)。
我们的目标是翻译这行C代码:
c[i][j] = a[i][j] + b[i][j];
由于是单一大数组,它等价于:
c[i * num_columns + j] = a[i * num_columns + j] + b[i * num_columns + j];
我们首先进行规划,将寄存器用途和关键常量定义清楚:
# 寄存器规划
# %eax: 行索引 i
# %ebx: 列索引 j
# %ecx: 矩阵 c 的基地址
# %edx: 矩阵 a 的基地址
# %esi, %edi: 临时用途
.equ WORD_SIZE, 4 # 整数大小为4字节
.equ NUM_ROWS, 3
.equ NUM_COLUMNS, 4
面对这样一个长表达式,我们的策略是将其分解。计算主要分为三部分:获取 a[i][j]、获取 b[i][j]、存储结果到 c[i][j]。
第一步:计算 a[i * num_columns + j] 并存到 %eax
以下是实现步骤:
- 计算
i * num_columns,结果存入%eax。注意mul指令会覆盖%edx。 - 加上列索引
j(%ebx)。 - 使用高级寻址模式获取
a矩阵在该偏移处的值。因为a是标签,放入位移字段;计算出的偏移在%eax中,作为索引;比例因子为WORD_SIZE(4)。 - 将取出的值移动到
%eax。
在第一步中,我们发现 mul 指令会破坏 %edx 寄存器,而 %edx 当前保存着行索引 i,我们需要保存它。我们选择在数据段中预留空间:
row: .long 0 # 为变量 row 预留4字节空间,初始化为0
然后,在乘法前保存,在计算后恢复:
movl %edx, row # 保存行索引 i 到内存
# ... 执行乘法和其他计算 ...
movl row, %edx # 从内存恢复行索引 i 到 %edx
一个重要的优化是,表达式 i * num_columns + j 在计算 a, b, c 的地址时都会被用到。我们可以只计算一次并复用。因此,我们将这个公共偏移值计算出来,存放到一个临时寄存器(例如 %edx)中,而不是在每次访问时都重新计算。
第二步:计算 a[i][j] + b[i][j]
现在 %eax 中已经是 a[i][j] 的值。我们需要加上 b[i][j]。
- 使用高级寻址模式获取
b[i][j]。注意,在我们的规划中,b的基地址在%ebx寄存器中。因此,在寻址模式中,%ebx进入偏移字段;公共偏移值在%edx中,作为索引;比例因子仍为4。 - 使用
addl指令将b[i][j]加到%eax中。现在%eax中就是和。
第三步:将结果存储到 c[i][j]
最后,我们需要将 %eax 中的和存入 c 矩阵的对应位置。
c的基地址在%ecx寄存器中。- 使用高级寻址模式:偏移字段为
%ecx,索引字段为%edx(公共偏移),比例因子为4。目的地是内存地址。 - 使用
movl %eax, ...指令完成存储。

一个常见的错误
初学者可能会写成:
movl (%ecx, %edx, WORD_SIZE), %edi # %edi = c[i][j]
addl %eax, %edi # %edi = a[i][j] + b[i][j]
这行代码是错误的,因为它只是改变了寄存器 %edi 中的值,并没有改变内存中 c[i][j] 位置的值。%edi 只是 c[i][j] 值的一个副本,两者是独立的存储位置。

矩阵加法:数组的数组实现
现在,考虑另一种矩阵存储方式:数组的数组(即每行是一个独立的数组,有一个指针数组指向这些行)。C代码的参数和内存布局会不同,但循环结构相似。
关键区别在于地址计算。对于 a[i][j],现在需要两次解引用:
- 首先根据
a(指针数组的地址)和索引i,找到第i行的地址。这对应a[i]。 - 然后根据找到的行地址和索引
j,找到具体元素a[i][j]。


因此,a[i][j] 的汇编计算步骤如下:
- 获取行指针:
movl a(,%eax,4), %ecx。这里a是标签(位移),%eax是行索引i(索引),比例因子4是因为每个指针是4字节。 - 获取元素:
movl (%ecx, %ebx, 4), %ecx。这里%ecx是第一步得到的行地址(偏移),%ebx是列索引j(索引),比例因子4是因为每个整数是4字节。
计算 b[i][j] 和存储到 c[i][j] 的逻辑类似,都需要进行两级寻址。通过清晰的注释和分步计算,可以有效地管理这种复杂性。
函数(子程序)概念介绍
为了模块化代码,我们需要使用函数(在汇编中常称为子程序)。
一个简单的例子是计算C风格字符串的长度。C代码如下:
int len = 0;
while (str[len] != '\0') {
len++;
}
return len;
在汇编中实现函数,需要定义调用约定(Calling Convention),即规定:
- 参数如何传递:通过寄存器(如
%ecx)还是通过特定内存位置? - 返回值放在哪里:通过寄存器(如
%edx)还是内存? - 寄存器的保存责任:哪些寄存器由调用者(Caller)保存,哪些由被调用者(Callee)保存?这是为了避免函数覆盖掉调用者还需要的数据。
例如,我们可以定义 strlen_reg 函数:
- 调用前,调用者应将字符串地址放入
%ecx。 - 函数执行后,调用者应从
%edx获取长度返回值。 - 如果调用者需要保护
%edx的原始值,它应在调用前自行保存。
函数的基本结构是:
strlen_reg:
# 函数体开始
# 可以在这里保存需要保护的寄存器(被调用者保存)
# ... 计算长度的代码 ...
# 结果放入 %edx
# 恢复之前保存的寄存器
ret # 返回到调用处,注意 ret 不返回值,只改变指令指针
_start:
# ...
movl $my_string, %ecx # 按照约定设置参数
call strlen_reg # 调用函数
# 现在 %edx 中是字符串长度
# ...
遵循一致的调用约定至关重要,就像遵守交通规则一样,能确保程序各部分正确协作。
调试与工具使用




编写汇编代码时,调试器(如GDB/DDD)是必不可少的工具。因为程序可能无法直接运行,需要在调试器中逐步执行,检查寄存器和内存的变化。
使用 make 工具可以自动化汇编和链接过程。一个典型的 Makefile 规则如下:
add.o: add.s
as --gstabs -32 -o add.o add.s
add.out: add.o
ld -m elf_i386 -o add.out add.o
.o文件是目标文件(已汇编但未链接)。.out文件是可执行文件(已链接)。调试时是针对可执行文件进行的。
在调试器中,可以设置断点、单步执行、并查看内存内容。例如,在DDD中可以使用 graph display 命令以特定格式显示内存,如将 c 的地址作为整数数组来查看矩阵加法的结果。
总结


本节课中我们一起学习了高级汇编编程的核心技术。我们深入探讨了高级寻址模式,并实践了如何将复杂的C语言矩阵加法运算分解、翻译成汇编指令。我们比较了单一大数组和数组的数组两种内存布局下的代码差异。我们还引入了函数(子程序)的基本概念,包括调用约定的重要性。最后,我们强调了使用调试器和自动化构建工具对于汇编开发的关键作用。掌握这些技能,将使你能够理解和控制更底层的程序行为。
011:字符串、函数与栈入门


在本节课中,我们将要学习如何将计算字符串长度的C语言函数翻译成汇编语言子程序,并探讨如何通过寄存器和内存传递参数与返回值。同时,我们将初步了解栈(Stack)这一关键数据结构在汇编语言中的工作原理,包括其基本操作和指令。
子程序参数传递与寄存器保存
上一节我们介绍了子程序的基本概念。本节中我们来看看在编写子程序时需要考虑的三个核心问题。
- 如何向子程序传递值?
- 如何从子程序返回值?
- 谁负责保存子程序内部使用的寄存器?
对于前两个问题,我们可以选择通过寄存器或内存来传递参数和返回值。关键在于,调用者必须知道如何正确使用你的函数。
对于第三个问题,有两种选择:调用者(caller)保存,或被调用者(callee)保存。通常,我们倾向于让被调用者负责保存它将要使用的寄存器。这意味着,在子程序开始执行时,先将这些寄存器的旧值保存到内存中,在使用完寄存器后,再恢复其旧值。这样对于调用者来说,这些寄存器的值就像从未被改变过一样。
字符串长度计算:寄存器传参版本
现在,我们来看一个具体的例子:将计算字符串长度的C语言函数翻译成汇编子程序。首先,我们实现通过寄存器传递参数的版本。
以下是该子程序的核心C代码逻辑:
int l = 0;
for (; str[l] != '\0'; l++);
return l;
在汇编实现中,我们约定字符串指针通过ECX寄存器传入,长度结果通过EDX寄存器返回。
以下是实现该逻辑的汇编代码步骤:
- 初始化长度计数器
L为0:movl $0, %edx - 设置循环开始标签,例如
strlen_reg_for_start。 - 在循环中,比较当前字符与空字符(
\0,值为0):cmpb (%ecx, %edx, 1), $0 - 如果相等(即遇到字符串结尾),则跳转到循环结束标签,例如
strlen_reg_for_end:je strlen_reg_for_end - 如果不相等,则增加长度计数器:
incl %edx - 跳转回循环开始标签,继续下一轮迭代:
jmp strlen_reg_for_start - 在循环结束标签后,使用
ret指令返回。注意,ret本身不返回值,它只是跳转回调用处。返回值已经存放在约定的EDX寄存器中。


字符串长度计算:内存传参版本
接下来,我们看看如何修改上述子程序,使其通过内存位置来传递参数和接收返回值。


在这个版本中,调用者需要先将字符串地址存入一个特定的内存标签(例如str_len_str),然后调用子程序。子程序计算出的长度需要存回另一个特定的内存标签(例如str_len_ret)。
虽然参数和返回值的位置变了,但核心计算逻辑不变。我们仍然需要在子程序内部使用寄存器进行计算。关键在于,我们需要在子程序开始时,将我们要使用的寄存器(例如EAX用于长度L,EBX用于字符串指针)的旧值保存到内存中,然后在返回前,先将结果存入str_len_ret,再恢复那些寄存器的旧值。
以下是需要注意的步骤:
- 不能直接用内存标签进行索引访问(如
str_len_str(%edx, 1)),因为偏移量必须是常量。必须先将指针加载到寄存器中。 - 应尽量减少对内存的直接访问,因为访问寄存器比访问内存快得多。通常的策略是:从内存加载数据到寄存器,在寄存器中完成计算,最后将结果存回内存。
栈(Stack)的基本概念
为了支持更复杂的函数特性(如递归),我们需要引入栈的概念。栈是一种“后进先出”(LIFO)的数据结构。
栈支持两个基本操作:
- 压栈(Push):将元素添加到栈顶。
- 出栈(Pop):移除并返回栈顶的元素。
在Intel汇编中,栈由栈指针寄存器ESP管理。ESP始终指向栈的顶部。需要注意的是,Intel架构中的栈是向低地址方向增长的。这意味着栈顶的地址是最低的。
所有对栈的访问(如压栈、出栈)都必须是字长(word-sized)的。在32位系统中,就是4字节。
栈操作指令与函数调用
汇编语言提供了与栈操作相关的指令。
push source指令将源操作数压入栈顶。其等效操作可以分解为:
- 扩展栈空间:
subl $4, %esp(ESP减小4) - 存入数据:
movl source, (%esp)
pop dest指令将栈顶元素弹出并存入目标位置。其等效操作可以分解为:
- 取出数据:
movl (%esp), dest - 收缩栈空间:
addl $4, %esp(ESP增加4)


函数调用指令call label与跳转指令jmp label的关键区别在于,call会保存返回地址。它实际上执行两个步骤:
- 将下一条指令的地址(返回地址)压入栈中:
pushl $next_instruction - 跳转到目标标签:
jmp label
函数返回指令ret则与call配对使用。它执行的操作是:
- 将栈顶的值(应该是之前保存的返回地址)弹出到指令指针
EIP中:popl %eip
这会导致程序从调用后的下一条指令继续执行。
理解栈和返回地址的机制非常重要。如果栈上的返回地址被意外覆盖(例如,由于缓冲区溢出漏洞),ret指令将跳转到一个错误的地址,这可能导致程序崩溃或被恶意利用(例如,缓冲区溢出攻击)。
总结


本节课中我们一起学习了如何实现汇编语言子程序来模拟C语言函数,重点探讨了通过寄存器和内存传递参数与返回值的两种方式及其实现细节。我们还引入了栈这一核心概念,了解了其在内存中的生长方向,学习了push、pop、call、ret等关键指令的工作原理。理解这些内容是后续学习更复杂的函数调用约定和实现递归函数的基础。
012:GCC调用约定与栈



在本节课中,我们将要学习GCC调用约定以及栈在函数调用过程中的具体应用。我们将了解参数如何传递、返回值如何返回,以及寄存器的保存责任如何划分。
概述



上一节我们介绍了栈的基本概念和操作。本节中,我们来看看GCC调用约定,它定义了函数调用时的一系列规则,确保不同代码模块(如C代码和汇编代码)能够协同工作。

GCC调用约定规则
GCC调用约定主要规定了以下几件事:
- 参数如何传递给函数。
- 值如何从函数返回。
- 当函数运行时,谁负责保存寄存器的值。
以下是具体的规则:




- 参数传递:参数应通过栈来传递。并且,它们应以声明顺序的逆序被压入栈中。
例如,对于函数func(a, b, c),调用前的汇编代码应为:push c push b push a call func - 返回值:如果函数有返回值,该值应存放在
EAX寄存器中。这也解释了为什么C语言函数通常只能返回一个值,因为只有一个EAX寄存器。 - 寄存器保存责任:
- 调用者 负责保存
EAX、ECX和EDX寄存器的值(如果这些值在调用后还需要)。 - 被调用者 负责保存所有其他它打算使用的寄存器(如
EBX、ESI、EDI、EBP等)。
- 调用者 负责保存
这些规则就像交通规则,统一标准后,不同部分编写的代码才能正确交互。
函数调用示例分析
让我们通过一个简单的C函数 add1(x) 来观察这些规则如何应用。该函数返回 x + 1。


使用 gcc -S 命令可以生成C代码对应的汇编文件。观察 main 函数中调用 add1 的部分,可以看到参数 a(值为10)在 call 指令前被压入了栈。调用结束后,栈上的参数被清理。
为了编写能被C代码调用的汇编函数,我们必须遵循相同的约定。
汇编函数的结构:序言与尾声
为了使汇编函数易于编写和维护,并正确处理栈帧,我们引入序言和尾声。
序言在函数开始时执行,主要完成以下工作:
- 保存旧的栈帧基指针:
push %ebp - 建立新的栈帧基指针:
mov %esp, %ebp - 为局部变量分配栈空间:
sub $[字节数], %esp - 保存需要使用的非易失寄存器(如
%ebx,%esi,%edi):push %ebx


使用栈帧基指针 %ebp 的好处是,它为参数和局部变量提供了固定的偏移量参考点,即使栈指针 %esp 在函数执行期间因其他调用而变动,我们也能轻松定位它们。
尾声在函数返回前执行,其步骤与序言相反,用于恢复现场:
- 恢复保存的非易失寄存器:
pop %edi(等) - 释放局部变量空间(将栈指针移回帧基指针处):
mov %ebp, %esp - 恢复旧的栈帧基指针:
pop %ebp - 返回:
ret
实践:编写 strlen 函数
现在,我们应用所学知识,用汇编编写一个遵循GCC调用约定的 strlen 函数。
首先,我们写出C语言版本的 strlen 作为蓝图。然后,将其翻译成汇编代码,并确保包含完整的序言和尾声。
在汇编函数中,我们通过 %ebp 加固定偏移来访问参数(例如,第一个参数在 8(%ebp))。我们选择使用 %ecx 寄存器存储字符串指针,因为调用者不负责保存它,这可以减少我们的保存工作。
循环部分比较每个字符是否为结束符 \0,并使用 %eax 寄存器作为长度计数器,这很方便,因为返回值正是通过 %eax 传递的。



一个常见错误:在访问内存时,务必注意寻址方式。例如,mov 8(%ebp), %ecx 是将第一个参数(地址)加载到 %ecx,而 mov (%ecx, %eax, 1), %dl 是访问该地址加上偏移量处的字符。混淆两者会导致错误。

进阶示例:矩阵加法与 malloc 调用
上一个例子展示了如何编写一个被调用的函数。现在,我们看一个更复杂的例子:一个用汇编编写的矩阵加法函数,它内部需要调用C标准库函数 malloc。
这演示了如何调用其他函数。步骤同样遵循GCC约定:
- 将参数以逆序压栈。
- 使用
call malloc。 - 函数返回后,返回值在
%eax中。 - 调用者负责清理栈上的参数(例如
add $4, %esp)。
在我们的矩阵加法函数中,序言部分需要为局部变量(如循环计数器 i, j 和结果矩阵指针 c)分配空间。通过 .equ 指令为这些变量在栈帧上定义有意义的符号名(如 I = -4(%ebp)),可以极大提高代码的可读性和可维护性。
总结


本节课中我们一起学习了GCC调用约定的核心规则,包括参数传递、返回值存放和寄存器保存责任。我们深入探讨了汇编函数中序言和尾声的重要性,它们通过建立稳定的栈帧,简化了对参数和局部变量的访问。最后,我们通过 strlen 和矩阵加法的实例,实践了如何编写符合规范的、能与C代码互操作的汇编函数,以及如何在汇编中调用C库函数。掌握这些知识是进行混合编程和深入理解程序运行机制的关键。
013:GCC调用约定与递归


在本节课中,我们将学习GCC调用约定的具体应用,并通过一个矩阵加法的例子来实践。随后,我们将探讨如何将递归函数(以斐波那契数列为例)翻译成汇编语言,并在这个过程中加深对调用约定的理解。
课程概述与公告
首先是一些课程公告。下一次测验已经发布,题目数量很少。期中考试即将发布,内容将涵盖第一章和第二章的测验内容,不会有太大变化。预计下周发布关于汇编内容的第三次测验。之后可能还会有一次关于课程所有内容的复习性测验作为期末项目。
鉴于接下来几天预计出勤率和答疑时间会减少,如果大家有需要长时间讨论的问题,现在是一个很好的时机。
回顾GCC调用约定
在开始新内容之前,我们先回顾一下GCC调用约定的三条核心规则。这些规则确保了汇编代码与C代码能够正确交互。
- 参数传递:参数通过栈传递,并且以相反的顺序压入。例如,如果参数顺序是
A, B, C,则压栈顺序为C, B, A。 - 返回值:返回值存放在
EAX寄存器中。 - 寄存器保存责任:
- 调用者(Caller) 负责保存
EAX、ECX和EDX寄存器的值(如果它们的内容在调用后还需要)。 - 被调用者(Callee) 负责保存所有其他寄存器的值(如
EBX、ESI、EDI、EBP等)。
- 调用者(Caller) 负责保存
遵守这些规则,就能编写出可以被C代码调用的汇编函数,也能在汇编代码中调用C库函数。
上一节我们介绍了调用约定的基本规则,本节我们将通过具体例子来看看如何应用这些规则。
实践:矩阵加法函数
我们将一个C语言的矩阵加法函数翻译成汇编语言。这个函数接收两个矩阵A和B,以及它们的行数(numRows)和列数(numCols),然后动态分配内存给结果矩阵C,并计算C[i][j] = A[i][j] + B[i][j]。
函数框架与局部变量
首先,每个C风格函数都需要一个序言(Prologue)来建立栈帧。
pushl %ebp
movl %esp, %ebp
subl $LOCAL_VAR_SPACE, %esp # 为局部变量分配空间
我们需要为局部变量(如循环计数器i、j)以及需要保存的寄存器(如EBX、ESI、EDI)在栈上分配空间。可以通过.EQU伪指令为这些位置定义易于理解的标签。
.equ numRows, 8(%ebp) # 参数 numRows 在 EBP+8
.equ numCols, 12(%ebp) # 参数 numCols 在 EBP+12
.equ A, 16(%ebp) # 参数 A (指针) 在 EBP+16
.equ B, 20(%ebp) # 参数 B (指针) 在 EBP+20
.equ C, -4(%ebp) # 局部变量 C (指针) 在 EBP-4
.equ i, -8(%ebp) # 局部变量 i 在 EBP-8
.equ old_EBX, -12(%ebp) # 保存的 EBX 在 EBP-12
.equ old_ESI, -16(%ebp) # 保存的 ESI 在 EBP-16
.equ old_EDI, -20(%ebp) # 保存的 EDI 在 EBP-20
在序言中,我们需要分配足够的空间并保存那些被调用者需要负责的寄存器。
# 序言
pushl %ebp
movl %esp, %ebp
subl $20, %esp # 为5个局部变量分配空间 (每个4字节)
movl %ebx, old_EBX(%ebp) # 保存 EBX
movl %esi, old_ESI(%ebp) # 保存 ESI
movl %edi, old_EDI(%ebp) # 保存 EDI
外层循环初始化
翻译外层循环 for (i = 0; i < numRows; i++)。我们将i存储在内存中,并将numRows加载到寄存器(例如EBX)中以加速比较。
movl $0, i(%ebp) # i = 0
movl numRows(%ebp), %ebx # EBX = numRows
outer_loop_start:
movl i(%ebp), %ecx # ECX = i
cmpl %ebx, %ecx # 比较 i 和 numRows
jge outer_loop_end # 如果 i >= numRows,跳出循环
# ... 循环体 ...
incl i(%ebp) # i++
jmp outer_loop_start
outer_loop_end:
动态内存分配与调用约定
在循环体内,我们需要为每一行分配内存:C[i] = (int *)malloc(numCols * sizeof(int));。
这涉及到调用C标准库函数malloc。根据调用约定,我们需要:
- 将参数(
numCols * sizeof(int))压栈。 - 调用
malloc。 - 清理栈上的参数(
addl $4, %esp)。 - 处理返回值(在
EAX中)。
在调用malloc之前,我们必须注意:malloc可能会覆盖EAX、ECX、EDX。如果ECX中存有我们关心的值(比如当前的i),调用者(我们)有责任保存它。
# 计算参数:numCols * 4 (因为sizeof(int)=4)
movl numCols(%ebp), %edx # EDX = numCols
sall $2, %edx # EDX = numCols * 4
# 保存调用者负责的寄存器(ECX 存有 i)
movl %ecx, i(%ebp) # 将 i 保存回内存
# 调用 malloc
pushl %edx # 压入参数
call malloc
addl $4, %esp # 清理栈
# 恢复 i
movl i(%ebp), %ecx # ECX = i
# 现在 EAX 中是 malloc 返回的指针
# 需要将其赋值给 C[i]
# 首先,获取 C 的地址
movl C(%ebp), %edx # EDX = C (C本身的地址)
# 然后计算 C[i] 的地址: EDX + i*4
leal (%edx, %ecx, 4), %edx # EDX = &C[i]
# 最后,将 malloc 的返回值存入该地址
movl %eax, (%edx) # C[i] = malloc返回值
关键点:注意C[i]的赋值。C是一个int**,C[i]是一个int*。我们不能直接写movl %eax, C(%ebp, %ecx, 4),因为C(%ebp)是变量C本身的地址,而不是它指向的内容。我们必须先加载C的值(一个地址),然后基于这个地址进行计算。
内层循环与矩阵元素相加
内层循环 for (j = 0; j < numCols; j++) 和核心计算 C[i][j] = A[i][j] + B[i][j] 的翻译遵循类似原则。我们需要小心地计算A[i][j]和B[i][j]的地址,并处理寄存器不足的问题(可能需要使用ESI、EDI并提前保存)。
# 假设 j 使用 EDX 寄存器
movl $0, %edx # j = 0
inner_loop_start:
cmpl numCols(%ebp), %edx
jge inner_loop_end
# 计算 A[i][j] 的地址并加载其值到 ESI
movl A(%ebp), %esi # ESI = A
movl (%esi, %ecx, 4), %esi # ESI = A[i] (行地址)
movl (%esi, %edx, 4), %esi # ESI = A[i][j]
# 计算 B[i][j] 的地址并加载其值,同时加到 ESI
movl B(%ebp), %edi # EDI = B
movl (%edi, %ecx, 4), %edi # EDI = B[i]
addl (%edi, %edx, 4), %esi # ESI = A[i][j] + B[i][j]
# 计算 C[i][j] 的地址并存入结果
movl C(%ebp), %edi # EDI = C
movl (%edi, %ecx, 4), %edi # EDI = C[i]
movl %esi, (%edi, %edx, 4) # C[i][j] = ESI
incl %edx # j++
jmp inner_loop_start
inner_loop_end:
收尾工作与尾声



函数最后需要设置返回值(如果需要),然后执行尾声(Epilogue)来恢复栈帧和保存的寄存器。
# 设置返回值,例如返回矩阵C
movl C(%ebp), %eax
# 尾声
movl old_EDI(%ebp), %edi # 恢复 EDI
movl old_ESI(%ebp), %esi # 恢复 ESI
movl old_EBX(%ebp), %ebx # 恢复 EBX
movl %ebp, %esp # 清理局部变量空间
popl %ebp # 恢复旧的 EBP
ret # 返回
递归函数示例:斐波那契数列

现在,我们来看一个递归函数的例子:斐波那契数列 fib(n)。C代码如下:
int fib(int n) {
if (n == 0 || n == 1) {
return 1;
} else {
return fib(n-1) + fib(n-2);
}
}
翻译中的挑战与调用约定
翻译递归函数时,调用约定尤为重要,因为函数会调用自身。最常见的错误是忘记在调用前保存调用者负责的寄存器(EAX、ECX、EDX)。
错误示例:
# 假设 ECX 存有 n
decl %ecx # ECX = n-1
pushl %ecx
call fib # 调用 fib(n-1)
# 问题:fib 调用可能覆盖了 ECX,我们丢失了 n 的值!
# 接下来无法计算 n-2 并调用 fib(n-2)
正确做法:我们需要在调用前,将需要保存的值(如当前的n,或fib(n-1)的中间结果)存放到安全的地方——要么是被调用者负责的寄存器(需先保存),要么是栈上的局部变量空间。
# 序言
pushl %ebp
movl %esp, %ebp
subl $4, %esp # 为局部变量分配空间
.equ n, 8(%ebp)
.equ temp, -4(%ebp) # 用于保存中间结果



# 基础情况判断
movl n(%ebp), %ecx
cmpl $0, %ecx
je base_case
cmpl $1, %ecx
je base_case

# 递归情况:计算 fib(n-1)
movl n(%ebp), %ecx
decl %ecx # ECX = n-1
pushl %ecx # 参数压栈
call fib # 调用 fib(n-1)
addl $4, %esp # 清理栈
movl %eax, temp(%ebp) # 将结果 fib(n-1) 保存到局部变量
# 计算 fib(n-2)
movl n(%ebp), %ecx
subl $2, %ecx # ECX = n-2
pushl %ecx # 参数压栈
call fib # 调用 fib(n-2)
addl $4, %esp # 清理栈
# 求和并返回
addl temp(%ebp), %eax # EAX = fib(n-1) + fib(n-2)
jmp epilogue
base_case:
movl $1, %eax # 返回 1
epilogue:
movl %ebp, %esp
popl %ebp
ret
新指令:LEA(加载有效地址)
在翻译过程中,我们经常需要计算内存地址而不进行实际访问。LEA(Load Effective Address)指令正是用于此目的。它的格式是leal source, destination,其中source是一个标准的内存寻址模式,destination是一个寄存器。
LEA计算source操作数指定的地址,并将这个地址(而不是该地址处的值)存入destination寄存器。
示例:
movl 8(%ebp), %eax:将内存地址EBP+8处的值加载到EAX。leal 8(%ebp), %eax:将内存地址EBP+8这个地址本身(即一个数值)加载到EAX。这等价于movl %ebp, %eax加上addl $8, %eax,但更简洁。
这在计算数组元素地址或获取局部变量/参数的地址时非常有用。


总结与调试建议


本节课中,我们一起学习了如何将复杂的C函数(包括循环和动态内存分配)以及递归函数翻译成遵循GCC调用约定的汇编代码。关键点包括:
- 严格遵守调用约定中关于参数传递、返回值和寄存器保存责任的规则。
- 在调用函数(尤其是可能覆盖
EAX、ECX、EDX的函数)前,如果这些寄存器中有重要数据,调用者必须负责保存它们。 - 递归翻译需要格外小心,因为函数会调用自身,必须妥善管理每一层递归的上下文。
- 合理使用栈空间来存储局部变量和中间结果。
- 利用
LEA指令高效地计算内存地址。
调试是至关重要的。当代码不按预期运行时,不要只是盯着代码看,要使用调试器(如GDB)。调试器允许你:
- 单步执行汇编指令。
- 检查寄存器和内存中的值。
- 在发生段错误时,查看是哪个地址访问出了问题。
- 观察函数调用过程中栈的变化。

通过调试器,你可以直观地看到调用约定是否被正确遵守,数据流是否如你所想,从而快速定位并修复问题。掌握调试技巧对编写和理解汇编代码有极大帮助。
014:递归与栈 📚




在本节课中,我们将学习如何将递归函数转换为汇编语言,并深入理解栈帧在函数调用过程中的作用。我们将通过一个计算数组和的递归函数示例,来演示栈帧的创建、使用和销毁过程。

概述
上一节我们介绍了函数调用约定和栈的基本概念。本节中,我们将通过一个具体的递归函数示例,来看看栈帧是如何在递归调用中被创建和管理的。我们将编写一个递归求和的C函数,并将其手动翻译成汇编代码,同时通过一个可视化的栈帧示例来加深理解。
递归求和函数示例
首先,我们需要编写一个递归函数来计算整数数组的和。以下是该函数的C语言实现:
int rec_sum(int *nums, int len) {
if (len == 0) {
return 0;
} else {
return nums[0] + rec_sum(nums + 1, len - 1);
}
}
这个函数的工作原理是:如果数组长度为0,则直接返回0(基本情况)。否则,返回数组第一个元素与剩余元素递归求和的结果。
将C代码翻译为汇编
将C函数翻译为汇编代码需要遵循几个标准步骤:编写函数标签和全局声明、设置栈帧(序言)、翻译函数逻辑、最后清理栈帧(尾声)。
1. 全局声明与标签
首先,在.text节中定义函数标签,并使用.global指令使其可被外部文件调用。同时,定义字长常量。
.global rec_sum
.text
rec_sum:
.EQU WORD_SIZE, 4
2. 函数序言(Prologue)




序言负责建立新的栈帧。这包括保存旧的基指针(EBP)、设置新的基指针,以及为局部变量和保存的寄存器分配空间。
push %ebp
mov %esp, %ebp
sub $1 * WORD_SIZE, %esp ; 为保存EBX预留空间
接下来,我们使用.EQU指令为参数定义偏移量,以便通过EBP访问它们。
.EQU nums, 2 * WORD_SIZE
.EQU len, 3 * WORD_SIZE
.EQU old_ebx, -1 * WORD_SIZE
3. 翻译函数逻辑

现在,我们开始翻译C代码的逻辑。首先,将参数len加载到寄存器中,并与0进行比较以处理基本情况。

mov len(%ebp), %eax
cmp $0, %eax
jne else_case
; 基本情况:len == 0
mov $0, %eax
jmp epilogue
如果len不为0,则进入递归情况。我们需要保存nums指针,因为它会被使用多次,并且需要在一个函数调用中存活。
else_case:
mov nums(%ebp), %ebx ; 将nums存入EBX
mov %ebx, old_ebx(%ebp) ; 保存旧的EBX值
接下来,准备递归调用的参数。参数需要按从右到左的顺序压栈。

; 计算 len - 1
dec %eax
push %eax ; 压入 len - 1
; 计算 nums + 1 (指针算术,移动4字节)
lea WORD_SIZE(%ebx), %ecx
push %ecx ; 压入 nums + 1
call rec_sum ; 递归调用
add $2 * WORD_SIZE, %esp ; 清理参数

递归调用返回后,结果在%eax中。我们需要加上nums[0]。

mov (%ebx), %edx ; 获取 nums[0]
add %edx, %eax ; 加到结果上

4. 函数尾声(Epilogue)
尾声负责恢复栈帧并返回。这包括恢复保存的寄存器、将栈指针移回基指针位置、弹出旧的基指针,最后执行ret指令。
epilogue:
mov old_ebx(%ebp), %ebx ; 恢复EBX
mov %ebp, %esp ; 恢复栈指针
pop %ebp ; 恢复旧的基指针
ret
栈帧可视化示例
为了更直观地理解栈帧在嵌套函数调用中的变化,我们来看一个示例。假设有三个函数:foo、goo和woo。
以下是调用过程中栈帧的变化步骤:
- 调用
foo时:参数A、B、C和返回地址被压栈。foo的序言保存旧的EBP,设置新EBP,并为局部变量D、E、F及保存的寄存器EBX、ESI分配空间。 foo调用goo时:foo将参数压栈(顺序为F、E、B、A),然后调用goo。goo的序言建立自己的栈帧,包含其参数、局部变量和保存的寄存器。goo调用woo时:过程类似,woo建立自己的栈帧。- 函数返回时:每个函数的尾声按相反顺序工作。
woo的尾声恢复其栈帧并返回到goo。goo恢复其栈帧并返回到foo。最后,foo恢复其栈帧并返回到调用者(如main)。
关键点在于,每个栈帧通过保存的EBP值链接到其调用者的栈帧,这形成了调用链,使得调试器可以进行回溯(backtrace)。
从汇编中调用C函数
要使汇编函数能被C代码调用,只需确保它遵循GCC调用约定。例如,可以在一个C文件中这样声明和调用rec_sum:
#include <stdio.h>
// 函数声明
int rec_sum(int *nums, int len);
int main() {
int nums[] = {10, 20, 30};
int length = 3;
int total = rec_sum(nums, length); // 像普通C函数一样调用
printf("The sum is %d\n", total);
return 0;
}
编译时,将C文件和汇编文件一起链接即可:
gcc -m32 -Wall -Werror main.c rec_sum.s -o program
总结


本节课中我们一起学习了递归函数在汇编层面的实现。我们通过一个递归求和的例子,详细演练了如何将C代码翻译成汇编,包括处理栈帧的序言和尾声、管理参数与局部变量、以及进行正确的递归调用。此外,我们还通过一个栈帧可视化的示例,深入理解了函数调用链中栈帧的动态创建和销毁过程。理解这些概念对于编写和调试底层代码至关重要。
015:调试 🐛
在本节课中,我们将学习如何有效地调试程序。调试是编程中至关重要的技能,它能帮助我们找出代码中的错误并理解程序的实际运行情况。我们将通过一个角色扮演练习来模拟调试过程,并学习使用调试工具来验证代码行为。
调试过程概述
上一节我们介绍了调试的重要性,本节中我们来看看一个实际的调试场景。假设你是一名助教,而我是带着问题代码前来求助的学生。
我的代码无法正常工作。具体来说,输出结果与预期答案不匹配。我还没有尝试运行调试器,也不知道如何操作。
开始调查
首先,我们需要明确问题所在。仅仅知道“代码不工作”是不够的。我们必须深入调查,了解具体哪里出了问题。例如,是程序崩溃了,还是输出了错误的数字顺序?
为了进行调试,我们需要知道正确的预期结果。假设我们正在对一个数字列表进行排序,期望的排序结果是:-7, 38, 12, 45, 2345, 51。
当我们运行程序时,它确实有输出,但输出结果是错误的。现在,我们需要找出问题所在。
使用调试器定位问题
我们的代码主要分为三个部分:解析参数、排序参数和打印结果。鉴于输出错误,我们应该首先检查参数解析部分是否正确。
以下是在调试器中可以采取的关键步骤:


- 设置断点:在解析参数后的代码行(例如第66行)设置断点,以检查数组是否正确填充。
- 检查变量:在断点处暂停后,使用调试器的打印功能查看数组内容。例如,使用命令
print array[0]@len来打印整个数组。 - 逐步执行:进入排序函数,逐步执行代码,观察每一步中数组元素的变化,并与预期行为进行比较。
- 验证交换:在排序过程中,特别关注
swap函数是否正确交换了元素。在交换操作前后检查相关数组索引的值。
调试的核心在于不断验证:代码的实际行为是否符合我们的预期?我们需要像侦探一样,询问代码并检查证据。




培养调试直觉
调试不仅仅是一项技术,更是一种需要培养的直觉。当代码出现问题时,你应该:
- 定位相关代码:如果排序失败,就查看排序相关的代码;如果游戏AI射击位置错误,就查看决定射击位置的代码。
- 主动调查:不要只是盯着代码看,期望错误自己跳出来。你必须使用工具主动调查程序的状态。
- 提出假设:带着假设进行调试,例如“我认为问题出在变量X被意外修改了”,然后去验证它。
一个常见的错误是,学生来到答疑时间,只说“我的代码不工作”,而没有进行任何初步调查。更有效的方式是:“我的代码不工作。这是它产生的错误输出。我认为问题可能出在XX函数里,因为YY,但我找不到确切原因。”
高级调试技巧
除了基本的断点和打印,调试器还提供了其他强大功能:
- 监视点(Watchpoint):使用
watch variable_name命令。当指定变量的值发生变化时,调试器会自动暂停,并显示旧值和新值。这对于追踪难以定位的意外修改变量非常有用。 - 条件断点:可以设置仅在特定条件满足时才触发的断点。例如,
break line_number if i > 25。 - 在调试器中编译:在GDB或DDD中,可以直接输入
make命令重新编译程序,然后使用reload source更新源代码视图,而无需退出调试器,所有断点和显示设置都会保留。
在汇编层面调试
在汇编语言中调试原理相同,但需要更直接地处理内存和寄存器。
以下是查看不同数据类型的命令示例:
- 查看寄存器:
print $eax - 将寄存器值解释为指针:
print (int *)$ebx - 查看标签地址的值:
print (int)len - 查看数组:
print *(int **)$ebx(假设$ebx指向指针数组)- 要查看数组的一段内容:
print array[0]@10
- 要查看数组的一段内容:
- 查看栈上的参数/局部变量:在函数内,第一个参数通常位于
$ebp + 2*word_size处。例如,打印第一个int参数:print *(int *)($ebp + 8)。对于char等小于字长的类型,需要先定位到正确地址,再进行类型转换:print *(char *)($ebp + 12)。
访问调用者栈帧

在汇编中,可以通过栈帧指针(EBP)链访问祖先函数的局部变量或参数。当前函数的EBP指向调用者栈帧的底部。通过连续解引用EBP,可以沿调用链向上回溯。





例如,在函数 wu 中访问函数 fo 的局部变量 e(假设 e 在 fo 的栈帧中偏移为 -2*word_size):
mov %ebp, %eax; EAX 指向当前栈帧mov (%eax), %eax; 解引用一次,EAX 指向调用者(g)的栈帧mov (%eax), %eax; 再解引用一次,EAX 指向 fo 的栈帧mov -8(%eax), %edx; 访问 fo 栈帧中偏移为 -8(即 -2*字长)的位置,获取变量 e
调试器的 backtrace 命令正是利用这个原理来显示调用栈的。
内联汇编简介
最后,我们简要介绍了内联汇编。虽然编写完整的汇编程序很繁琐,但有时我们不得不在C/C++代码中插入少量汇编指令(例如,使用特殊CPU指令或进行极致优化)。
内联汇编的基本格式如下:
__asm__ (
“汇编指令字符串” // 汇编代码
: “输出操作数列表” // 输出到C变量的部分
: “输入操作数列表” // 从C变量输入的部分
: “破坏列表” // 会被汇编代码修改的寄存器/内存
);
在汇编代码字符串中,使用 %% 来表示寄存器名(如 %%eax),以区分C语言中的 % 操作符。通过约束字符(如 ”a” 对应EAX,”r” 让编译器选择)将C变量与寄存器绑定。”+” 约束表示该操作数既是输入也是输出。”&”(早期破坏)约束等更高级的用法将在后续课程中介绍。
总结

本节课中我们一起学习了系统化的调试方法。关键在于从“代码不工作”的模糊陈述,转向主动、有策略地调查程序状态。我们回顾了使用调试器设置断点、检查变量、逐步执行代码的流程,并介绍了监视点、条件断点等高级功能。我们还探讨了在汇编层面调试的特殊性,以及如何通过栈帧指针访问调用链上的数据。最后,我们简要了解了内联汇编的概念和基本语法。请记住,调试是一项通过不断练习才能熟练掌握的核心技能,它能极大地提升你解决问题的效率和能力。
016:内联汇编与字符串指令

在本节课中,我们将学习内联汇编的进阶知识,特别是“早期破坏”修饰符的作用,并介绍一些特殊的汇编字符串指令。最后,我们将探讨操作系统如何加载和运行程序。
内联汇编格式回顾




上一节我们介绍了内联汇编的基本格式。本节中,我们来看看一个关键但容易混淆的修饰符:&(早期破坏)。


内联汇编的基本结构如下:
__asm__ (
"汇编指令"
: 输出操作数列表
: 输入操作数列表
: 破坏列表
);
编译器默认假设:在使用完所有输入操作数之前,你不会向任何输出操作数写入数据。这个假设允许编译器进行“寄存器共享”优化,即将输入和输出映射到同一个物理寄存器。
早期破坏修饰符 (&)
当你的汇编代码违反了上述假设,即在完成所有输入操作数的使用之前,就向某个输出操作数写入了数据,你必须使用 & 修饰符来标记该输出操作数为“早期破坏”。
以下是判断何时使用 & 的方法:
- 查看你的汇编指令。
- 找到你首次写入某个输出操作数的那一行。
- 检查在这行代码之后,你是否还使用了任何输入操作数。
- 如果答案是“是”,那么这个输出操作数必须标记为
&。
让我们通过一个例子来理解。考虑以下有问题的内联汇编函数:
int add(int a, int b) {
int result;
__asm__ (
"movl $0, %[res] \n\t" // 过早地向‘result’写入
"movl %[b], %[res]\n\t"
"addl %[a], %[res]"
: [res] "=r"(result) // 未标记 &,但应该标记
: [a] "r"(a), [b] "r"(b)
);
return result;
}
在这段代码中,第一条指令 movl $0, %[res] 就向输出操作数 result 写入了数据。然而,在这之后,代码仍然在使用输入操作数 a 和 b。这违反了编译器的假设。如果不标记 &,编译器可能会将 result 和某个输入(例如 a)分配到同一个寄存器(如 EAX),导致 a 的值在第一条指令就被意外覆盖,函数返回错误结果。
正确的写法是使用 & 修饰符:
int add(int a, int b) {
int result;
__asm__ (
"movl $0, %[res] \n\t"
"movl %[b], %[res]\n\t"
"addl %[a], %[res]"
: [res] "=&r"(result) // 使用 & 修饰符
: [a] "r"(a), [b] "r"(b)
);
return result;
}
标记 & 后,编译器会为 result 分配一个独立的、不与任何输入共享的寄存器,从而避免冲突。




字符串指令
除了通用指令,x86汇编还提供了一些专门用于处理内存数据块(如数组或字符串)的“字符串指令”。编译器通常不会自动生成这些指令,因此它们是通过内联汇编使用的好例子。
主要的字符串指令有三个:
MOVS:将数据从源内存位置复制到目标内存位置。STOS:将EAX寄存器中的值存储到目标内存位置。CMPS:比较两个内存位置的数据。SCAS:将EAX寄存器中的值与目标内存位置的数据进行比较。

这些指令通常与重复前缀一起使用,以自动处理整个数据块:
REP:重复执行指令,直到ECX寄存器减为0。REPE/REPZ:当相等/为零时重复。REPNE/REPNZ:当不相等/不为零时重复。
指令执行后,会根据方向标志 DF 自动递增或递减 ESI(源索引)和/或 EDI(目标索引)寄存器。CLD 指令清除 DF(向前移动),STD 指令设置 DF(向后移动)。
以下是这些指令的潜在用途:
REP MOVS:实现类似 C 库函数memcpy的功能,复制内存块。REP STOS:实现类似 C 库函数memset的功能,用特定值填充内存块。REPE CMPS:比较两个内存块是否完全相同。REPNE SCAS:在内存块中搜索特定值。
操作系统与程序加载
现在,让我们将视角从具体的指令提升到整个系统。操作系统本身就是一个大型、复杂的程序,它负责管理计算机的硬件和软件资源。
操作系统的主要职责包括:
- 文件系统管理:将文件路径映射到硬盘上的物理位置。
- 内存管理:为每个程序分配和隔离内存空间,使其仿佛独享整个内存。
- 进程管理:在多个运行的程序(进程)之间调度 CPU 时间。
- I/O 设备管理:控制对键盘、显示器等设备的访问。
当你输入 ./a.out 运行程序时,背后发生了一系列步骤:
- Shell 请求:Shell 程序调用
execve系统调用,请求操作系统运行a.out。 - 控制权转移:CPU 控制权从 Shell 转移到操作系统内核。
- 定位程序:操作系统在硬盘上找到
a.out文件。 - 读取信息:读取
a.out的文件头,了解其大小、代码段和数据段位置等信息。 - 分配内存:在内存的空闲区域中,找到足够大的空间来容纳
a.out。 - 加载程序:将
a.out的代码和数据从硬盘复制到分配的内存中。 - 设置环境:保存操作系统自身的状态(寄存器值),清零通用寄存器(出于安全考虑),将栈指针
ESP设置为新程序的栈起始地址。 - 跳转执行:最后,跳转到
a.out的入口点(如_start),程序开始运行。
一个有趣的问题是:如果操作系统是负责加载程序的程序,那么操作系统自身是如何被加载的?这通过“引导加载程序”解决:
- 计算机启动时,CPU 从固定的内存地址(如
0xFFFF0)开始执行,这里存放着存储在只读存储器中的引导加载程序。 - 引导加载程序非常小,它的任务是从硬盘的第一个扇区读取数据到内存。
- 硬盘的第一个扇区存放着操作系统的内核引导程序。
- CPU 跳转到这个引导程序,由它负责将操作系统的其余部分从硬盘加载到内存,最终完成整个操作系统的启动。这个过程被称为“自举”。
总结

本节课中我们一起学习了:
- 内联汇编中
&(早期破坏)修饰符 的用途和重要性,它用于告知编译器某个输出操作数会在使用完所有输入之前被写入,从而避免寄存器共享导致的错误。 - 一组特殊的 x86 字符串指令(
MOVS,STOS,CMPS,SCAS)及其重复前缀,它们能高效地处理内存块操作,是内联汇编的典型应用场景。 - 操作系统作为资源管理者的角色,以及它如何通过一系列步骤(查找、分配、加载、设置、跳转)来加载和运行一个用户程序。
- 操作系统自身启动的“自举”过程,依赖于存储在固件中的引导加载程序。
017:调用约定、程序加载与进程切换

概述
在本节课中,我们将学习程序如何被加载到内存中运行,以及操作系统如何通过时间片轮转实现多个进程的“同时”运行。我们还将回顾函数调用约定,并初步了解输入/输出设备如何与计算机系统交互。
课程内容回顾与注意事项
上一节我们介绍了函数调用约定。本节中,我们来看看课程相关的其他重要事项。
关于在线测验,请注意答案的格式。例如,如果题目要求输入“hello”,而你输入了“Ho space”,系统可能会判定为错误。在答题时,请确保不要输入任何额外的空白字符。
以下是需要特别注意的几点:
- 部分题目要求编写调试器代码来打印栈或寄存器中的值,这些题目的评分标准更为严格。
- 大部分测验的截止日期是教学周的最后一天,即13号。期末考试截止日期是19号。
- 目前所学的知识足以完成所有测验和期中考试,但期末考试涉及的一些主题我们尚未完全覆盖。
对于编程作业中的问题,例如实现push指令,答案不能仅仅是“push”,而需要使用其他指令来模拟其功能。如果遵循了正确步骤但答案仍不被接受,请参加答疑时间寻求帮助。
函数调用约定回顾
现在,让我们回顾一下GCC调用约定,这是在答疑时间中常见错误点。
调用约定主要有三条规则:
- 返回值存放位置:函数的返回值存放在
EAX寄存器中。 - 寄存器保存责任:
- 调用者负责保存
EAX、ECX和EDX寄存器的值(如果它关心这些值)。 - 被调用者负责保存所有其他需要使用的寄存器(如
EBX、ESI、EDI)的值。
- 调用者负责保存
- 参数传递方式:参数被压入栈中,并且是逆序压入(即最右边的参数先入栈)。
理解“调用者”和“被调用者”的角色至关重要。例如,在函数调用链main -> func1 -> func2中,func1相对于main是被调用者,需要保存EBX等寄存器;而相对于func2,func1又是调用者,在调用func2前需要保存EAX、ECX、EDX。
参数传递与指针
当调用函数时,传递的总是值的副本。即使是传递数组或使用C++的引用,本质上传递的也是地址的副本。
例如,在C++中传递引用:
void func1(int &a) { a = 5; }
在C语言中等效的实现是传递指针:
void func1(int *a) { *a = 5; }
调用时:
int x = 7;
func1(&x); // 传递x的地址(值100)的副本
在函数func1内部,通过得到的地址副本(100),可以修改该内存地址(100)处存储的值(将7改为5)。但函数内部无法修改调用者栈帧中局部变量x本身的地址。
传递数组时,实际上传递的是数组首元素的地址副本。通过这个地址和偏移量,可以访问数组中的任何元素。
程序加载过程
上一节我们讨论了函数调用。本节中,我们来看看一个应用程序是如何被加载到内存并开始执行的。
当在终端输入./a.out时,其加载过程遵循一系列逻辑步骤:
- 系统调用:终端请求操作系统执行程序,通过
execve系统调用实现。 - 定位程序:操作系统在硬盘上找到名为
a.out的程序文件。 - 分配内存:操作系统查询内存管理表,找到一块足够大的空闲内存区域来容纳该程序。
- 读取信息:操作系统读取程序文件的头部信息,了解其代码段、数据段的大小和位置。
- 加载到内存:将程序从硬盘复制到之前分配的内存区域。
- 设置执行环境:操作系统保存当前自身的状态(寄存器等),将栈指针
ESP设置为新程序的栈区起始地址,并清零通用寄存器。 - 跳转执行:最后,将指令指针
EIP设置为程序代码的起始地址,开始执行程序。
操作系统启动与多系统引导
操作系统本身也是一个程序,它由引导加载程序加载。引导加载程序存储在ROM中,计算机启动时自动执行。它的任务是将硬盘第一个扇区的内容(通常是操作系统内核或更复杂的引导选择器)加载到内存并执行。
为了实现多系统引导(如选择启动Windows或Linux),可以将一个“引导选择器”程序放在硬盘的第一个扇区。这个程序会显示菜单,根据用户选择,去加载位于硬盘其他扇区的相应操作系统内核。
进程与时间片轮转
现代操作系统通过时间片轮转实现多个进程的并发执行。其核心思想是让多个进程快速轮流使用CPU,由于切换速度极快,在用户看来它们像是在同时运行。
每个进程被分配一个固定的CPU时间片段,称为时间片或量子。当一个进程的时间片用完,或主动放弃CPU(如进行系统调用),操作系统就会切换到下一个就绪进程。
进程切换的实现
进程切换是操作系统的核心功能之一。其关键在于保存和恢复进程上下文。
进程上下文主要指寄存器的状态,包括通用寄存器(EAX, EBX...)、栈指针ESP和指令指针EIP。进程的代码和数据本身已在其独占的内存空间中,无需在切换时复制。
切换过程如下:
- 保存当前进程状态:将当前运行进程
X的所有寄存器值保存到操作系统内核维护的进程控制块中。 - 选择下一个进程:从就绪队列中选择下一个要运行的进程
Y。 - 恢复下一个进程状态:从进程
Y的控制块中将其保存的寄存器值加载回CPU的各个寄存器。 - 跳转执行:恢复
EIP后,CPU即从Y上次中断的地方继续执行。
一个关键问题是:当操作系统正在执行切换代码时,EIP指向的是操作系统代码。那么,它如何获得进程X被中断时的EIP值呢?这需要硬件机制的支持,即中断。当中断(如定时器中断)发生时,硬件会自动将当前EIP(即下一条指令地址)压入栈,然后跳转到中断处理程序。这样,操作系统在处理中断时,可以从栈中获取被中断进程的EIP。


输入/输出设备访问简介
最后,我们简要了解CPU如何与外部设备通信。主要有两种方式:
- I/O映射I/O:为I/O设备设置独立的地址空间,与内存地址空间分开。CPU使用专门的指令(如
in和out)来访问这些I/O端口。 - 内存映射I/O:将I/O设备寄存器映射到物理内存地址空间的一部分。CPU可以使用普通的访存指令(如
mov)来读写这些地址,从而控制设备。

总结
本节课中我们一起学习了多个核心概念。我们回顾了函数调用约定,明确了调用者和被调用者的责任。我们详细分析了程序从硬盘加载到内存执行的完整步骤,并探讨了操作系统自身的启动原理。我们深入了解了操作系统通过保存和恢复进程上下文来实现进程切换的机制,这是多任务并发执行的基础。最后,我们简介了CPU访问I/O设备的两种主要方式。理解这些底层机制,有助于我们写出更高效、更可靠的程序。
018:IO与内存系统

在本节课中,我们将学习计算机的输入/输出系统与内存系统是如何协同工作的。我们将探讨两种主要的IO访问方式,并深入了解中断驱动IO的工作原理。
IO映射与内存映射IO
上一节我们介绍了内存系统的基本访问方式。本节中我们来看看CPU如何与各种IO设备进行通信。IO设备遍布系统各处,通过其端口(类似于ID,如0、1、2、3等)进行访问。
有两种主要方式可以与它们通信。
IO映射IO
在IO映射系统中,内存地址和IO设备拥有独立的地址空间。这意味着读取IO地址2和读取内存地址2是两个不同的操作。
为了支持这种方式,CPU需要不同的指令来指定是访问IO设备还是内存。为此设计的指令是 in(用于读取)和 out(用于写入)。访问内存则使用 mov 或其他指令。
内存映射IO
在内存映射IO系统中,IO设备与内存共享同一个地址空间,但会保留一部分地址空间专门用于IO设备。
例如,前100个地址可能预留给IO设备,此后的地址才映射到真实内存。因此,整个系统只有一个统一的地址空间。
与IO映射不同,在内存映射IO中,访问IO设备与访问内存使用相同的指令,即 mov。对CPU而言,访问地址1(可能是一个IO设备)和访问地址5(可能是内存)在指令层面没有区别。
以下是内存映射IO的简单示意图:
地址空间:
0: [IO设备]
1: [IO设备]
2: [IO设备]
3: [内存]
4: [内存]
5: [内存]
...
内存映射IO的优点是编程简单,可以用C语言指针直接操作IO地址。但它的缺点是会占用一部分本可用于内存的地址。不过,IO设备数量通常远少于内存地址总数,因此这个损失很小。
Intel处理器实际使用的是IO映射系统。
与IO设备交互:轮询
现在,让我们看一个具体的例子:如何从键盘读取一个字符。
在Intel系统中,键盘数据端口位于地址 0x60,状态端口位于 0x64。状态端口的第4位(bit 4)指示是否有键被按下(1表示有,0表示无)。读取字符后,需要将状态端口的第5位(bit 5)短暂置1再置0,以告知键盘字符已被读取。
那么,读取字符的算法是什么?
核心思路是持续询问键盘:“你有输入吗?” 直到得到肯定答复,然后读取数据并通知键盘。
以下是实现该算法的高级步骤:
- 读取状态端口 (
0x64)。 - 检查第4位是否为1。
- 如果不是1,返回步骤1(循环等待)。
- 如果是1,从数据端口 (
0x60) 读取字符。 - 将状态端口的第5位置1,然后置0,以确认读取。
这种方法称为轮询或忙等待IO。CPU反复询问IO设备是否有输入,一旦有输入就进行处理。
轮询的优点是实现简单,全部可以用软件完成,并且响应延迟低。但它的主要缺点是浪费CPU时间,因为大多数时候输入并不可用,CPU却在空转。
中断驱动IO
轮询效率低下,就像不停地打电话问“你在吗?”。更好的方式是让设备在需要时主动通知CPU,这引出了中断驱动IO。
想象你在等一个重要电话(比如面试)。你不会一直拿着电话问“你在吗?”,而是会先做其他事(比如看视频)。当电话铃响(中断发生),你暂停视频,接听电话,通话结束后再继续看视频。
计算机中的中断流程与此类似:
- 程序P正在运行。
- IO设备产生中断(硬件行为)。
- 保存程序状态:CPU自动将当前程序的EIP(指令指针)、EFLAGS(标志寄存器)和CS(代码段寄存器)压入栈中(硬件行为)。
- 询问中断ID:CPU向IO设备发送中断确认信号,询问“是谁中断了我?”(硬件行为)。
- 获取中断ID:IO设备返回其唯一的中断标识号(硬件行为)。
- 执行中断服务程序:CPU根据中断ID,查询一个由操作系统维护的中断描述符表,找到对应的中断服务程序地址并跳转执行(此时开始运行操作系统代码)。
- 服务中断:ISR处理IO设备的请求(例如,从键盘读取数据)。
- 恢复程序:ISR执行完毕,通过一条特殊指令
iret(中断返回)恢复之前保存的程序状态,CPU从被打断的地方继续执行程序P。
中断描述符表是中断系统的核心。操作系统在启动时设置此表,其中每个条目(称为中断向量)包含对应中断服务程序的地址和一些控制信息。
中断与上下文切换
中断机制也是实现多任务(上下文切换)的基础。当时钟定时器产生中断时,操作系统可以利用这个时机:
- 保存当前运行进程A的所有寄存器状态(包括已由硬件保存的EIP等)到进程A的进程控制块中。
- 从进程B的进程控制块中恢复进程B的所有寄存器状态。
- 关键的一步:将进程B的EIP、CS、EFLAGS值写回到当前栈中原来保存进程A状态的位置。
- 执行
iret指令。该指令会从栈中弹出EIP、CS、EFLAGS并加载到CPU寄存器。由于栈中现在已是进程B的状态,CPU便会开始执行进程B。
中断的硬件与软件分工
中断机制需要硬件和操作系统软件紧密配合:
- 硬件(CPU/IO设备)负责:产生中断信号、自动保存基本程序状态、响应中断确认、提供中断ID、根据ID跳转到ISR。
- 软件(操作系统)负责:设置中断描述符表、编写所有中断服务程序、在ISR中处理具体的IO请求、最后执行
iret返回。
中断的优缺点


中断驱动IO的优点非常明显:
- 高效利用CPU:CPU在等待IO时可以执行其他任务,不必空转。
- 可响应多设备:能同时处理多个IO设备的异步请求。
但它也有一些代价:
- 指令执行变慢:CPU需要在每条指令执行完毕后检查是否有中断 pending,这略微增加了每个指令周期的长度。
- 系统更复杂:需要硬件支持中断机制,操作系统也需要更复杂的管理。
- 响应延迟:由于必须完成当前指令才能响应中断,响应速度不如轮询(但通常可接受)。
多中断与优先级
当多个中断同时发生,或一个中断正在处理时另一个中断发生,该怎么办?系统需要中断优先级管理。例如,Intel 8259A可编程中断控制器芯片可以配置不同中断的优先级。处理策略可以是:
- 禁止中断:在关键ISR开始时关闭中断,处理完后再打开,确保不被干扰。
- 嵌套中断:允许高优先级中断打断低优先级的ISR。
- 忽略直至完成:当前ISR处理完再响应新中断。
总结


本节课中我们一起学习了计算机IO系统的关键概念。
我们比较了IO映射和内存映射两种IO访问方式,了解了轮询(忙等待) 这种简单但低效的IO处理方式。
随后,我们深入探讨了中断驱动IO的完整工作流程,包括中断的发生、程序状态的保存与恢复、中断描述符表的作用,以及中断如何成为实现多任务上下文切换的基石。
我们还分析了中断机制的硬件/软件分工及其优缺点。理解这些底层机制,有助于我们更好地理解操作系统和应用程序如何与硬件交互。
019:IO设备与内存映射 📚





在本节课中,我们将要学习系统调用的实现原理、汇编指令的二进制编码格式,以及可变长度指令集与固定长度指令集的区别。我们将通过具体的例子来理解这些核心概念。
概述
上一节我们介绍了中断系统在上下文切换中的应用。本节中,我们来看看中断如何用于实现系统调用,并深入探讨汇编指令在计算机内部的二进制表示形式。


系统调用与中断
程序在运行时无法直接访问IO设备,必须通过操作系统。如果操作系统仅提供一系列函数供用户调用,会存在安全隐患,因为用户可能执行非预期的操作。此外,用户程序在用户模式下没有权限使用如 in 和 out 这样的指令。
因此,系统调用通过软件中断实现。在Intel架构中,使用 int 指令触发中断,并指定中断ID。例如,int $0x80 用于触发系统调用。
系统调用需要传递参数。由于操作系统和用户程序使用不同的栈,参数通过寄存器传递。
以下是系统调用的参数传递规范:
- 第一个参数(系统调用ID)放入
EAX寄存器。 - 其余参数依次放入
EBX,ECX,EDX等寄存器。
例如,执行 exit(5) 系统调用的汇编代码如下:
movl $1, %eax # 系统调用ID 1 代表 exit
movl $5, %ebx # 参数 5 放入 EBX
int $0x80 # 触发系统调用
再例如,执行 open("bob.txt", 7, mode) 系统调用的汇编代码如下:
movl $5, %eax # 系统调用ID 5 代表 open
movl $bob, %ebx # 文件名指针放入 EBX
movl $7, %ecx # 标志参数放入 ECX
movl $mode, %edx # 模式参数放入 EDX
int $0x80
由于参数通过寄存器传递,系统调用在参数数量上存在限制。如果需要传递超过寄存器数量的参数,可以将多个参数打包成一个结构体,然后传递该结构体的指针。
汇编指令的二进制编码
我们一直在编写汇编代码,但汇编指令在计算机内部实际由二进制位表示。
每条指令的第一部分是操作码,它唯一标识了要执行的操作。例如,特定的位模式代表“将一个寄存器的值移动到另一个寄存器”。
寄存器也需要用二进制编码。例如:
EAX编码为000EBX编码为011ECX编码为001EDX编码为010
因此,指令 movl %eax, %ebx 的二进制编码包含:操作码(代表mov)、源寄存器编码(000代表EAX)、目的寄存器编码(011代表EBX)。
使用 as -a 命令可以让汇编器输出机器码。例如,指令 movl $4, %eax 的编码包含操作码 B8 和紧随其后的4字节立即数 04 00 00 00(小端序)。
由于一切最终都是二进制位,我们甚至可以直接用 .byte 伪指令写入操作码。例如,dec %eax 对应单字节 0x48,因此 .byte 0x48 与 dec %eax 在二进制层面完全等价。
指令集架构:可变长度 vs 固定长度
Intel 使用可变长度指令集。指令格式复杂,包含可选的前缀、操作码、ModR/M字节、位移量和立即数字段。指令长度可以是1、2、4、8甚至更多字节。
解码可变长度指令更复杂,因为处理器需要先确定当前指令的长度,才能找到下一条指令的地址。
另一种设计是固定长度指令集,例如 MIPS 架构。每条指令都是4字节长。
以下是两种架构的对比:
- 可变长度指令集 优势:可以节省内存空间,让常用指令更短。
- 固定长度指令集 优势:硬件解码更简单、更快,易于设计。
这是一种典型的时空权衡。早期计算机内存昂贵,节省空间很重要。现代计算机内存充裕,更倾向于用空间换取更快的解码速度。
静态变量
静态变量在行为上类似于全局变量,在程序的整个生命周期内只有一份实例。它与全局变量的唯一区别在于作用域:静态变量只在定义它的函数内部可访问。
在汇编层面,静态变量和全局变量一样,通常被分配在 .data 节。编译器在编译C源代码时,知道某个变量是静态的,因此只会生成在定义该变量的函数内部访问它的汇编代码,并拒绝在其他函数中生成访问该变量的代码。
总结


本节课中我们一起学习了系统调用如何通过中断机制实现,以及参数如何通过寄存器传递。我们探讨了汇编指令在硬件层面的二进制编码原理,并对比了可变长度与固定长度指令集架构的优缺点。最后,我们了解了静态变量在底层的工作原理。理解这些硬件与软件的交互细节,有助于我们编写更高效、更可靠的代码。

浙公网安备 33010602011771号