Back to Notes

计算机系统 - 基础认知

This post is not yet available in English. Showing the original version.
Table of Contents
Table of Contents

一、数据基础单元:位、字节、字

1.1 位(Bit)

1.2 字节(Byte)

1 Byte=8 bit=2 位十六进制数(Hex)1\text{ Byte} = 8\text{ bit} = 2\text{ 位十六进制数(Hex)}
单位换算
1 B8 bit
1 KB1024 B
1 MB1024 KB
1 GB1024 MB

1.3 字(Word)

C 类型大小(字节)说明
char1单字符
short2短整型
int4整型
long8长整型
float4单精度浮点数
double8双精度浮点数
char *(指针)864 位地址

二、多字节数据的内存存储:字节序

2.1 小端法(Little-Endian)

小、低、低:低位字节存在低地址处。

x86 / x86-64 系统均采用小端法。

:整数 0x12345678 在内存中(起始地址 0x0100):

地址内容
0x01000x78(最低有效字节)
0x01010x56
0x01020x34
0x01030x12(最高有效字节)

2.2 大端法(Big-Endian)

高位字节存在低地址处,顺序与人类书写习惯一致。

地址内容
0x01000x12(最高有效字节)
0x01010x34
0x01020x56
0x01030x78

实际意义:跨平台网络通信时需注意字节序转换,C 中可用 htonl() / ntohl() 等函数处理。


三、寄存器

寄存器是 CPU 内部速度最快的存储单元,直接参与运算,访问延迟约 1 个时钟周期(远快于内存的数百个时钟周期)。

3.1 寄存器的用途

3.2 x86(32 位)通用寄存器

32 位 CPU 包含 8 个 32 位通用寄存器

寄存器全称常见用途
%eaxAccumulator返回值、算术运算
%ecxCounter循环计数
%edxDataI/O、乘除法辅助
%ebxBase基址(被调者保存)
%esiSource Index字符串/数组源地址
%ediDestination Index字符串/数组目标地址
%espStack Pointer栈顶指针
%ebpBase Pointer栈帧基址

x86 寄存器图

3.3 x86-64(64 位)通用寄存器

64 位扩展后,寄存器名称以 r 开头,并新增 8 个寄存器,共 16 个 64 位寄存器

64 位32 位低位16 位低位8 位低位用途
%rax%eax%ax%al返回值
%rbx%ebx%bx%bl被调者保存
%rcx%ecx%cx%cl第4参数
%rdx%edx%dx%dl第3参数
%rsi%esi%si%sil第2参数
%rdi%edi%di%dil第1参数
%rsp%esp%sp%spl栈顶指针
%rbp%ebp%bp%bpl栈帧基址
%r8~%r15第5~8参数等

记忆要点(函数参数传递顺序)%rdi, %rsi, %rdx, %rcx, %r8, %r9,超出6个参数则通过栈传递。

3.4 特殊寄存器

寄存器说明
%rip程序计数器(PC),指向下一条指令地址
EFLAGS / RFLAGS条件码寄存器(ZF 零标志、SF 符号标志、OF 溢出标志、CF 进位标志)

注意:“PC中存放的是下一条指令的地址”这一机制是绝大多数机器底层通用的原则,在当前指令从内存中通过“取指”操作被加载到cpu内之后,PC中的地址值就会自增为下一条指令的地址,方便CPU更加快捷的进行连续取指操作,从而为流水线(pipeline)操作奠定基础。

3.5 VSPM 原型机寄存器分工

寄存器职责
R0默认存储 / 输出结果
R1, R2输入 / 输出操作数
R3存储跳转用指令地址,便于 CPU 快速访问
G条件判断寄存器,控制是否跳转

3.6 AT&T 汇编:指令操作数大小后缀

在 x86-64 AT&T 汇编(GCC / GDB 默认使用)中,指令名称后缀用于明确操作数的字节宽度,避免歧义:

后缀全称大小对应 C 类型(典型)示例指令
b(byte)Byte1 字节 = 8 bitcharmovb $0, %al
w(word)Word2 字节 = 16 bitshortmovw %ax, %bx
l(long)Long4 字节 = 32 bitintmovl %eax, %ebx
q(quad)Quad8 字节 = 64 bitlong / 指针movq %rax, %rbx

⚠️ 注意:AT&T 中 l 后缀对应 32 位(4 字节),而非 64 位。这与 Intel 文档中 "DWORD" 的叫法一致,勿与 C 语言 long(64位)混淆。

记忆口诀:b=1、w=2、l=4、q=8(单位:字节)

使用规则

# 示例:将 int 类型变量移动到寄存器(4字节,用 l 后缀)
movl  -4(%rbp), %eax     # 从栈上读取 4 字节 int

# 示例:字节操作(1字节,用 b 后缀)
movb  $0x41, %al         # 将字符 'A' 放入 al(1 字节)

# 示例:64位指针操作(8字节,用 q 后缀)
movq  %rsp, %rbp         # 复制栈指针(8 字节)

四、冯·诺伊曼体系结构

冯·诺伊曼结构将程序和数据统一存储在同一内存中,CPU 顺序取指执行。

flowchart LR
    subgraph CPU[中央处理器(CPU)]
        CU[控制器]
        ALU[运算器]
    end

    IN[输入设备]
    OUT[输出设备]
    MEM[存储器]

    %% ===== 数据流 =====
    IN  -- 数据 --> MEM
    IN  -- 数据 --> ALU
    ALU -- 数据 --> MEM
    MEM -- 数据 --> OUT
    ALU -- 数据 --> OUT

    %% ===== 指令流(从存储器到控制器)=====
    MEM -- 指令 --> CU

    %% ===== 控制命令(控制器发出)=====
    CU  -- 控制 --> MEM
    CU  -- 控制 --> ALU
    CU  -- 控制 --> IN
    CU  -- 控制 --> OUT

    %% 数据流:蓝色
    linkStyle 0,1,2,3,4 stroke:#1f77b4,stroke-width:2px
    %% 指令流:红色
    linkStyle 5 stroke:#d62728,stroke-width:2px
    %% 控制命令:绿色
    linkStyle 6,7,8,9 stroke:#2ca02c,stroke-width:2px

五大核心组成

  1. 运算器(ALU):负责算术和逻辑运算
  2. 控制器(CU):读取并解析指令,发出控制信号
  3. 存储器(Memory):统一存放指令和数据(程序存储思想)
  4. 输入设备:键盘、鼠标等
  5. 输出设备:显示器、打印机等

现代改进:哈佛架构将指令存储和数据存储分离(如 ARM 的 L1 Cache),提升并行度。


五、原型机系统架构

原型系统图


六、C 语言编译流程

.c.i.s.o → 可执行文件(.out / ELF)

源文件.c  ──预处理──►  .i  ──编译──►  .s  ──汇编──►  .o  ──链接──►  a.out

6.1 各阶段详解

阶段工具输入输出主要工作
预处理(Preprocessing)cpp.c.i处理 #include#define 宏展开、条件编译
编译(Compilation)cc1.i.s词法→语法→语义分析,生成汇编代码
汇编(Assembly)as.s.o将汇编指令翻译为机器码,生成可重定位目标文件
链接(Linking)ld.o + 库a.out合并多个目标文件,解析符号引用,输出可执行文件

6.2 GCC 编译阶段命令示例

# 只做预处理,查看宏展开结果
gcc -E hello.c -o hello.i

# 编译到汇编(可读的 .s 文件)
gcc -S hello.c -o hello.s

# 编译到目标文件(二进制)
gcc -c hello.c -o hello.o

# 完整编译链接
gcc hello.c -o hello

# 查看目标文件的符号表
objdump -d hello.o

6.3 链接:静态链接 vs 动态链接

静态链接动态链接
时机编译时合并运行时加载
文件扩展名.a(归档库).so(共享库) / .dll
可执行文件大小较大较小
更新方式需重新编译替换库文件即可
典型示例libc.alibc.so

七、存储器层次结构(Memory Hierarchy)

计算机存储系统从快到慢、从小到大:

CPU 寄存器
    ↓  (~1 cycle)
L1 Cache(~4 cycles,几十 KB)
    ↓  (~10 cycles)
L2 Cache(~10 cycles,几百 KB)
    ↓  (~40 cycles)
L3 Cache(~40 cycles,几十 MB)
    ↓  (~200 cycles)
主内存 RAM(几 GB)
    ↓  (~10,000,000 cycles)
磁盘 / SSD(几百 GB~几 TB)

网络存储(云端)

局部性原理是 Cache 设计的基础:

  • 时间局部性:最近访问过的数据很可能再次被访问(如循环变量)
  • 空间局部性:访问某数据后,其相邻数据也可能被访问(如数组遍历)

八、指令集基础(VSPM 原型机)

8.1 指令格式

一条典型指令包含:操作码(Opcode) + 操作数(Operand)

| 操作码(4 bit)| 目标寄存器(2 bit)| 源寄存器或立即数(... bit)|

8.2 常见指令类型

类型示例(x86 风格)说明
数据传送MOV dst, src寄存器/内存间数据移动
算术运算ADD, SUB, MUL, DIV整数四则运算
逻辑运算AND, OR, XOR, NOT位运算
移位SHL, SHR, SAR左移/逻辑右移/算术右移
比较CMP a, b计算 a-b,结果写入标志位
跳转JMP, JE, JNE, JG无条件 / 条件跳转
调用返回CALL, RET函数调用与返回
栈操作PUSH, POP压栈/弹栈

九、汇编语言基础(x86-64 AT&T)

本节是“第六章 C 编译流程”中 .s 汇编文件的延伸,帮助读懂 gcc -S 的输出。

9.1 AT&T 语法约定

GCC / GDB 默认使用 AT&T 汇编语法,有以下几点基本约定:

数制写法说明

汇编源码中的数字可以用不同进制书写,只是人类表达习惯的差异,汇编器会统一转换为二进制,CPU 拿到的永远是二进制:

写法进制示例等价十进制
$42十进制(默认)$4242
$0x2A十六进制(0x 前缀)$0xFF255
$052八进制(0 前缀,少见)$0108
movl  $42,    %eax    # eax = 42(十进制写法)
movl  $0x2A,  %eax   # eax = 42(十六进制写法,完全等价)

关于地址计算:不需要手动换算进制。地址 0x6010346295604 是同一个地址,汇编器和调试器都会处理好转换。GDB 默认用十六进制显示地址和内存内容,读起来更紧凑(一个字节恰好两位 hex)。偏移量如 -8(%rbp) 中的 -8 用十进制写,是因为它是小数字,十进制更直观。进制只影响源码可读性,不影响实际运算

9.2 常见数据传送指令

# 立即数 → 寄存器
movl  $42, %eax          # eax = 42

# 寄存器 → 寄存器
movq  %rax, %rbx         # rbx = rax

# 内存(栈帧相对寻址)→ 寄存器
movl  -8(%rbp), %eax     # eax = *(rbp - 8)

# 寄存器 → 内存
movq  %rax, (%rsp)       # *rsp = rax

9.2.5 lea vs mov:必考区别 ⚠️

lea(Load Effective Address)和 mov 是汇编中最容易混淆的指令对,也是考试高频考点。

核心区别一句话

指令功能会访问内存吗?
mov内存地址里的数据搬运到目标(有括号时)
lea地址本身的计算结果放进目标不会

对比示例

movl  -8(%rbp), %eax    # ① 取内存里的值:eax = *(rbp - 8)
leaq  -8(%rbp), %rax    # ② 计算地址本身:rax = rbp - 8(不访问内存!)

用 C 来理解:

int x = 42;
// ① movl -8(%rbp), %eax  →  eax = x;      (读 x 的值)
// ② leaq -8(%rbp), %rax  →  rax = &x;     (取 x 的地址)

lea 的两大用途

用途一:取变量地址(等价 C 的 &

leaq  -8(%rbp), %rdi    # rdi = &x,常用于把地址作为参数传入函数

用途二:被编译器用作"带乘法的加法"(算术快捷键)

lea 的地址公式 base + index × scale + offset 本质是一个乘加运算,编译器经常借用它来做整数乘法优化,而不是真的要用那个地址:

leaq  (%rax, %rax, 2), %rax   # rax = rax + rax*2 = rax * 3
leaq  (%rax, %rax, 4), %rax   # rax = rax * 5
salq  $3, %rax                # rax <<= 3,即 rax * 8(这是移位,不是 lea)

等价 C:x = x * 3; 会被编译器变成 leaq (%rax,%rax,2), %rax 以避免慢速的 imul 乘法指令。

常见考题思路

# 给定:rbp = 0x7fff0010,内存[0x7fff0008] = 99

movl  -8(%rbp), %eax    # eax = ?  →  去内存 0x7fff0008 取值 → eax = 99
leaq  -8(%rbp), %rax    # rax = ?  →  只算地址 → rax = 0x7fff0008

判断口诀:看到 lea,不管括号写什么,结果都是那个地址数字本身,不是地址里的内容。mov 加括号才是去内存取值。

深层理解:寄存器不区分「地址」还是「整数」

有一个常见困惑:lea 把一个「地址」写进了寄存器,那取用这个寄存器时,不应该是去那个地址取内存值吗?

不会。 因为寄存器里只有比特,它不知道自己存的是地址还是整数。由地址变成内存访问,必须有指令显式用括号触发,不会自动发生。

lea 做的事分三步,在第②步就停住了:

步骤mov (%rax), %eaxlea (%rax,...), %eax
① 计算括号内的数值
② 去内存取那个地址的内容跳过,直接停
③ 写进目标寄存器写的是内存里的值写的是①算出来的数字本身

所以 int t = a + 2*b

leal (%ecx, %edx, 2), %eax   # eax = ecx + edx*2
movl (%ecx, %edx, 2), %eax   # eax = *(ecx + edx*2) ← 去那个地址取内存里的值

eax 里就存着 a + 2b 这个整数结果——没有人去内存取东西,括号只是借用了 CPU 地址计算硬件来做乘加,和「指针」完全无关。


9.3 变量与内存地址:汇编眼中的「变量」

在 C 语言里我们自然地使用变量名,但汇编和 CPU 只认内存地址,不认名字。理解这个映射关系是读懂汇编的基础。

标量变量(int、long…)

C 中一个 int x = 42 在汇编里的实质:

movl  $42, -8(%rbp)    # x = 42,即往地址 (rbp-8) 写入 42
movl  -8(%rbp), %eax  # 读取 x 的值到 eax

结论:标量变量的「值」就存在它对应的地址里,变量名只是地址的别名。


数组变量(arr[])

这是最容易迷惑的地方:

int arr[4] = {10, 20, 30, 40};
int *p = arr;   // p 赋的是什么?

arr 这个名字在汇编中直接就是数组第一个元素的地址(首地址),而不是某个存了地址的变量。

内存布局(假设 arr 首地址 = 0x601030):

地址内容数组元素
0x60103010arr[0]
0x60103420arr[1]
0x60103830arr[2]
0x60103C40arr[3]
leaq  arr(%rip), %rdi   # 把 arr 的首地址(0x601030)加载进 rdi
movl  (%rdi),    %eax   # eax = arr[0] = 10
movl  8(%rdi),   %eax   # eax = arr[2] = 30(偏移 8 字节)

lea 指令(Load Effective Address)专门用来计算并加载地址本身,不访问内存:
lea -8(%rbp), %raxrax = rbp - 8(rax 里存的是地址,没有取内存内容)


指针变量(int *p)

指针变量本身在内存里也占一块空间(64 位系统下 8 字节),里面存的是另一个变量的地址:

int x = 42;
int *p = &x;   // p 里存的是 x 的地址

内存布局(rbp-8 是 x,rbp-16 是 p):

地址内容
rbp-842
rbp-16(rbp-8 的值)
movl  $42,      -8(%rbp)    # x = 42
leaq  -8(%rbp), %rax        # rax = &x(x 的地址)
movq  %rax,    -16(%rbp)   # p = &x(把地址存进 p)
movq  -16(%rbp), %rax      # 读出 p(得到地址)
movl  (%rax),    %eax      # *p:用 p 存的地址去取值,得到 42

一句话总结

C 表达式汇编含义
x读取变量 x 所在地址的内容
&xx 所在的内存地址本身(用 lea 取得)
arr / arr[0]数组首地址(arr 名字就是首地址)
arr[i]首地址 + i × 元素大小 处的内容(变址寻址)
*p先取 p 的值(一个地址),再去那个地址取内容(间接寻址)

核心直觉:汇编里没有「变量」概念,只有地址和地址里的内容。C 的每一个变量操作,最终都是对某个内存地址的读或写。


指针的底层本质

上面已经知道指针变量"存的是地址",但从底层视角还有更多值得深挖的东西。

指针 = 一个整数

在机器层面,指针就是一个无符号整数,宽度等于地址总线位数:

系统指针宽度地址空间
32 位4 字节0 ~ 0xFFFFFFFF(4 GB)
64 位8 字节0 ~ 0xFFFFFFFFFFFFFFFF(理论 16 EB,实际通常 48 位 = 256 TB)
# 64 位系统中,指针操作全部使用 q(8字节)后缀
movq  %rax, -16(%rbp)    # 存指针:8 字节
movq  -16(%rbp), %rax    # 读指针:8 字节

CPU 不知道寄存器里存的是"地址"还是"整数"——它只看到 64 位的二进制。是指针还是整数,完全由指令决定movq (%rax), %rbx 把 rax 当地址用,addq %rax, %rbx 把 rax 当整数用。

但编译器知道指针的类型

虽然机器层面指针只是整数,但 C 编译器为每个指针记录了它指向什么类型,这影响两件事:

  1. 解引用时读多少字节*psizeof(*p) 字节
  2. 指针算术的步长p + 1 实际加 sizeof(*p) 字节
指针类型sizeof(*p)p + 1 实际偏移
char *1+1 字节
int *4+4 字节
long *8+8 字节
double *8+8 字节
int arr[4] = {10, 20, 30, 40};
int *p = arr;       // p = 0x601030
p + 1;              // = 0x601034(不是 0x601031!)
p + 2;              // = 0x601038

对应汇编(变址寻址):

# p 在 %rdi,i 在 %rsi
movl  (%rdi, %rsi, 4), %eax    # eax = p[i] = *(p + i*4)
#                  ↑ scale = sizeof(int) = 4

p + i 的底层实现就是 (char *)p + i * sizeof(*p),编译器在生成汇编时自动乘上元素大小。


指针运算的规则与限制

运算合法?底层含义结果类型
p + n地址 + n × 元素大小同类型指针
p - n地址 - n × 元素大小同类型指针
p - q(同类型)(p地址 - q地址) / sizeof(*p) = 元素间距ptrdiff_t(整数)
p + q无意义(两个地址相加没有物理含义)编译错误
p * n / p / n无意义编译错误
int a[5] = {0, 1, 2, 3, 4};
int *p = &a[1], *q = &a[4];
q - p;    // = 3(不是 12!编译器自动除以 sizeof(int))

对应汇编:

# q 在 %rax, p 在 %rcx
subq  %rcx, %rax    # rax = q地址 - p地址(字节差 = 12)
sarq  $2,   %rax    # rax >>= 2,即 ÷4 = 3(算术右移,等价整除 sizeof(int))

内存对齐(Alignment)

CPU 访问内存时,硬件要求数据地址是其大小的整数倍

数据类型大小对齐要求合法地址
char11 字节对齐任意地址
short22 字节对齐0, 2, 4, 6 …
int44 字节对齐0, 4, 8, 12 …
long / 指针88 字节对齐0, 8, 16, 24 …

为什么要对齐?

结构体中的对齐与填充

struct S {
    char  a;    // 1 字节      偏移 0
    // 3 字节填充(padding),使 b 对齐到 4 的倍数
    int   b;    // 4 字节      偏移 4
    char  c;    // 1 字节      偏移 8
    // 7 字节填充,使整个结构体大小是 8 的倍数(最大成员对齐)
};
// sizeof(struct S) = 16,不是 1+4+1=6 !
偏移012345678915
内容apadpadpadbbbbcpadpad

优化技巧:把结构体成员按大小从大到小排列,可以减少填充浪费。


数组与指针的等价性(底层视角)

在 C 语言中,a[i]*(a + i) 完全等价。底层原因是编译器对两者生成完全相同的汇编

int a[4] = {10, 20, 30, 40};
a[2];          // 方式一
*(a + 2);      // 方式二
2[a];          // 方式三(合法!因为加法交换律:*(2 + a))

三种写法生成的汇编都是:

movl  8(%rdi), %eax    # *(a + 2*4) = *(a + 8)

但数组名 ≠ 指针变量

数组名 arr指针变量 int *p
本质编译期常量地址(不可修改)一个变量,占 8 字节,内容可变
sizeof整个数组大小(如 4*4=16指针大小(永远是 8)
&arrarr 值相同,但类型是 int (*)[4]&p 是 p 变量自己的地址
赋值arr = ... ❌ 非法p = ... ✅ 合法
# arr 是数组名 → 编译器直接把地址嵌入指令
leaq  arr(%rip), %rdi     # rdi = arr 的地址(编译时已知)

# p 是指针变量 → 要从内存读出 p 里存的值
movq  -16(%rbp), %rdi     # rdi = p 的值(运行时从栈上读)

字符串的底层表示

C 中没有"字符串类型",字符串就是 char 数组 + 末尾的 \0(空终止符,值为 0x00)

char s[] = "Hi!";
// 等价于:char s[4] = {'H', 'i', '!', '\0'};

内存布局:

地址字节值ASCII
0x6010300x48H
0x6010310x69i
0x6010320x21!
0x6010330x00\0(终止符)

strlen 的底层实现(简化版):从首地址开始逐字节扫描,直到遇到 0x00

# rdi = 字符串首地址
strlen_loop:
    cmpb  $0, (%rdi)       # 当前字节 == 0?
    je    strlen_done       # 是 → 结束
    incq  %rdi              # 否 → 指针 +1
    jmp   strlen_loop
strlen_done:
    # rdi - 原始首地址 = 字符串长度

字符串字面量存在哪?

char *p = "hello";    // p 指向 .rodata 段(只读数据段)
char s[] = "hello";   // s 是栈上的数组,内容从 .rodata 复制过来
char *p = "hello"char s[] = "hello"
字符串位置.rodata(只读段)栈上(可写)
可否修改p[0] = 'H' ❌ 段错误s[0] = 'H' ✅ 合法
sizeof8(指针大小)6(含 \0 的数组大小)
# char *p = "hello"
leaq  .LC0(%rip), %rax    # .LC0 是 "hello" 在 .rodata 段的标签
movq  %rax, -8(%rbp)      # p = 字符串的地址

# char s[] = "hello"
movl  $0x6c6c6568, -14(%rbp)   # 把 "hell" 四字节直接写到栈上
movw  $0x006f,     -10(%rbp)   # 把 "o\0" 两字节写到栈上

关键区别:字符串字面量("hello")是放在只读数据段的,用指针指过去就不能改;用数组赋值则是复制一份到栈上,可以随意修改。这是 C 语言面试和 debug 的经典坑。


指针的危险行为(Undefined Behavior)

C 语言把内存管理交给程序员,编译器和 CPU 都不会主动阻止你对指针的误操作。以下这些行为属于未定义行为(UB)——编译器可以做任何事,包括"看起来正常运行"直到某天突然崩溃。

① 悬空指针(Dangling Pointer)

指针指向的内存已经被释放或超出作用域,但指针本身还保留着那个旧地址。

// 场景一:free 之后继续使用
int *p = malloc(sizeof(int));
*p = 42;
free(p);         // 内存已归还给系统
*p = 100;        // ❌ 悬空!这块内存可能已经分配给别人了

// 场景二:返回局部变量的地址
int *bad() {
    int x = 42;
    return &x;   // ❌ x 在函数返回后被销毁,地址失效
}
int *p = bad();
*p;              // 悬空指针解引用,结果不可预测

底层发生了什么:

 栈帧(bad 函数)          bad 返回后
┌──────────┐             ┌──────────┐
│  x = 42  │ ← p 指向   │  ??????  │ ← p 还指向这里
└──────────┘             └──────────┘
                          这块栈空间已被后续函数调用覆盖

防御free(p) 之后立即 p = NULL,养成习惯。

② 空指针解引用(NULL Pointer Dereference)
int *p = NULL;    // p = 0x0000000000000000
*p = 42;          // ❌ 去地址 0 写入 → 段错误(Segmentation Fault)

底层原因:操作系统故意不映射地址 0 附近的页面,任何对这个区域的访问都会触发页错误(Page Fault),内核把它转化为 SIGSEGV 信号发给进程。

movq  $0, %rax          # rax = NULL = 0
movl  $42, (%rax)       # 去地址 0 写入 → 触发硬件异常

这就是为什么段错误的错误地址经常在 0x0 附近——都是 NULL 解引用导致的。

③ 野指针(Wild Pointer)

指针从未被初始化,里面是栈上的垃圾值,指向一个随机地址。

int *p;           // 未初始化!p 的值是栈上残留的垃圾数据
*p = 42;          // ❌ 写入一个随机地址,可能破坏任何数据

与悬空指针的区别:悬空指针曾经有效(后来失效),野指针从来就没有效过

④ 缓冲区溢出(Buffer Overflow)

指针运算越过数组边界,读写不属于你的内存——这是安全漏洞的第一大来源。

char buf[8];
strcpy(buf, "This string is way too long!");
// ❌ 写入远超 8 字节,覆盖了栈上的返回地址等关键数据

栈上的破坏效果:

偏移正常内容溢出后
buf+0~7用户数据(8字节)"This str"
buf+8~15保存的 rbp"ing is w" ← 被覆盖!
buf+16~23返回地址"ay too l" ← 被覆盖!

返回地址被覆盖后,ret 指令会跳转到一个攻击者控制的地址——这就是经典的栈溢出攻击原理。现代系统用 Stack CanaryASLRNX bit 等机制来防御。

⑤ 类型双关(Type Punning)

通过强制类型转换让同一块内存被当作不同类型来解读:

float f = 3.14f;
int *ip = (int *)&f;     // 把 float 的地址当 int 指针
printf("%d\n", *ip);     // 输出 1078523331(float 3.14 的 IEEE 754 位模式)
视角同一块 4 字节内存
float3.14
int(位模式)0x4048F5C3 = 1078523331

这在底层是合法的(内存就是一堆字节),但在 C 标准中属于 UB(违反严格别名规则)。安全的做法是用 memcpyunion

⚠️ 常见指针 Bug 速查
Bug症状常见原因
段错误(SIGSEGV)程序崩溃NULL 解引用、访问已释放/未映射内存
数据莫名被改逻辑出错缓冲区溢出覆盖了相邻变量
每次运行结果不同不可复现悬空指针 / 野指针读到不同的垃圾值
double free 崩溃程序崩溃同一块内存 free 了两次
内存泄漏内存持续增长malloc 后忘记 free(指针丢失)

9.4 操作数寻址模式

🔑 核心判断规则:有括号 = 去内存取值;没有括号 = 值本身

写法读到的是类比 C
$42常量值 4242
%raxrax 里存的值变量值
(%rax)去内存,地址=rax,取那里的内容*rax
-8(%rbp)去内存,地址=rbp-8,取那里的内容*(rbp-8)

唯一例外lea 指令写了括号却只算地址、不取内存:

movl  -8(%rbp), %eax   # 取内存值:eax = *(rbp-8)   → 读变量
leaq  -8(%rbp), %rax   # 只取地址:rax = rbp-8      → &变量

汇编指令的操作数有三类来源:立即数、寄存器、内存。内存寻址又按地址计算方式细分为多种模式。

① 立即数寻址(Immediate Addressing)

操作数直接是一个编码在指令内的常量,不需要任何内存访问。

movl  $42,    %eax    # eax = 42(十进制立即数)
movq  $0xFF,  %rbx    # rbx = 255(十六进制立即数)
addl  $1,     %ecx    # ecx += 1

特点:速度最快,值在编译时固定,无法动态修改。用于常量赋值、循环步长等场合。


② 寄存器寻址(Register Addressing)

操作数存放在寄存器中,直接读写寄存器,无需访问内存。

movq  %rax, %rbx     # rbx = rax(寄存器 → 寄存器)
addl  %ecx, %eax     # eax += ecx

特点:访问延迟约 1 个时钟周期,是最常用的操作数形式。寄存器就是 CPU 内部的"变量"。


③ 绝对寻址(Absolute / Direct Addressing)

操作数的内存地址是一个固定数值,硬编码在指令里。

movl  (0x601030), %eax    # eax = *(0x601030),读取全局变量
movb  %al, (0x601030)     # *(0x601030) = al

特点:地址在链接时确定,常用于全局变量、静态变量。64 位模式下地址空间太大,编译器更倾向于用 RIP 相对寻址代替。


④ 间接寻址(Register Indirect Addressing)

寄存器中保存的是内存地址,通过该地址读写内存——等价于 C 中的指针解引用 *p

movq  (%rax), %rbx    # rbx = *rax(rax 是指针,读取其指向的值)
movl  %ecx, (%rsp)    # *rsp = ecx(写入 rsp 指向的地址)

特点:最基本的指针操作。(%rax) 中的括号表示“把 rax 里的值当地址去取内存”。


⑤ 基址 + 偏移寻址(Base + Displacement)

在间接寻址的基础上加一个常量偏移,地址 = base + offset
这是访问栈帧局部变量的核心方式。是直接在内存地址上的偏移量。

movl  -8(%rbp),  %eax    # eax = *(rbp - 8),访问局部变量
movq   8(%rsp),  %rbx    # rbx = *(rsp + 8),访问上层参数
movb   1(%rdi),  %cl     # cl  = *(rdi + 1),访问结构体字段

特点%rbp 是栈帧基址,负偏移访问局部变量,正偏移访问传入的参数。是函数体中出现频率最高的寻址方式。


⑥ 变址寻址(Indexed / Scaled Addressing)

本质:用两个寄存器里存的值,按公式算出一个内存地址,再去那个地址取/写数据。不是寄存器的地址相加,寄存器本身就不在内存里。

有效地址 = base的值 + index的值 × scale

movl  (%rdi, %rsi, 4), %eax

拆解(假设 rdi = 0x601030rsi = 3):

  1. 有效地址 = 0x601030 + 3 × 4 = 0x60103C
  2. 去内存地址 0x60103C 取 4 字节,放进 eax

等价 C:eax = arr[3]rdi = arrrsi = 34 = sizeof(int)

movq  (%rbx, %rcx, 8), %rdx  # rdx = arr[rcx](long 数组,每元素 8 字节)

⑦ 完整通用寻址(Base + Index × Scale + Displacement)

AT&T 完整格式:offset(base, index, scale),地址 = base + index × scale + offset

movl  8(%rbx, %rcx, 4), %eax   # eax = *(rbx + rcx*4 + 8)

等价 C:

struct Foo *arr = (struct Foo *)rbx;
eax = arr[rcx].field;   // 其中 field 的偏移量为 8,int 类型(4字节)

特点:结合了基址、比例变址和偏移,可以一条指令访问结构体数组中某个字段


📌 寻址模式综合对比

寻址模式AT&T 写法等价 C地址/值计算典型场景
立即数$4242(值本身)值在指令中常量赋值
寄存器%raxrax寄存器中的值通用运算
间接(%rax)*raxM[rax]指针解引用
基址+偏移-8(%rbp)*(rbp-8)M[rbp-8]局部变量
绝对(0x601030)*(int*)0x601030M[0x601030]全局变量
变址(%rdi,%rsi,4)arr[rsi]M[rdi+rsi×4]数组访问
完整通用8(%rbx,%rcx,4)arr[rcx].fieldM[rbx+rcx×4+8]结构体数组

💡 理解要点:立即数和寄存器寻址的操作数就是值本身;其余所有带括号的形式都表示去内存里取值,括号里算出来的是地址,不是值。掌握这一点,读任何一行 mov 指令都不会迷失。

9.5 算术与逻辑指令

addl  %ecx, %eax    # eax += ecx
subl  $1,   %eax    # eax -= 1
imull %edx, %eax    # eax *= edx(有符号乘法)
andq  %rbx, %rax    # rax &= rbx
orq   %rbx, %rax    # rax |= rbx
xorq  %rax, %rax    # rax = 0(常用清零技巧)
salq  $2,   %rax    # rax <<= 2(算术左移,等价 ×4)
sarq  $1,   %rax    # rax >>= 1(算术右移,符号扩展)

9.6 栈(Stack)与 push / pop

栈是什么

首先,栈只是整块内存里的一个区域,和数据段、代码段共享同一片 RAM,只是划分了不同的用途:

内存(整块 RAM)
┌─────────────────┐  高地址
│   栈(Stack)    │  ← 函数调用时在这里操作,push/pop 只在这块区域内
│        ↓        │     向下增长(rsp 减小 = 压栈)
│   ~~空闲~~   │
│        ↑        │
│   堆(Heap)     │  ← malloc / new 动态分配在这
├─────────────────┤
│   数据段         │  ← 全局变量、static 变量
├─────────────────┤
│   代码段(Text) │  ← 程序的机器码指令本身
└─────────────────┘  低地址

push/pop 是在栈这一个区域内操作(不是在不同区域之间搬运):
push = 往栈顶再叠一层;pop = 从栈顶取走一层。
栈的特点是只能动顶部(LIFO),数据段和堆可以通过地址随机访问任意位置。

栈遵循 后进先出(LIFO) 原则,用于:

关键特性:栈向低地址方向增长

在栈中存入0x12345678:

地址    内容    说明
 84   │ 0x12 │ ← 最高地址(最先被写入,最后弹出)
 83   │ 0x34 │
 82   │ 0x56 │
 81   │ 0x78 │ ← rsp 指向这里(栈顶 = 最低地址)
高地址
  ↑  旧帧(调用方)

  │  ← %rbp(当前栈帧基址)
  │  局部变量 1  (rbp - 8)
  │  局部变量 2  (rbp - 16)

  │  ← %rsp(栈顶指针,始终指向最新压入的数据)
低地址

%rsp 始终指向栈顶(当前占用的最低地址)。压栈时 rsp 减小,弹栈时 rsp 增大——和直觉相反,因为栈是「向下长」的。


pushq:压栈

pushq src 等价于两步操作:

pushq %rax
# 等价于:
subq  $8, %rsp        # ① rsp -= 8(腾出空间,64位操作数占8字节)
movq  %rax, (%rsp)   # ② 把 rax 的值写入新的栈顶
压栈前:                 压栈后:
rsp → [  旧内容  ]      rsp → [  rax的值  ]  ← 新栈顶
                               [  旧内容  ]

popq:弹栈

popq dst 等价于两步操作:

popq %rbx
# 等价于:
movq  (%rsp), %rbx    # ① 把栈顶的值读入 rbx
addq  $8, %rsp        # ② rsp += 8(释放栈顶空间)
弹栈前:                 弹栈后:
rsp → [  某个值  ]              [  某个值  ](内存还在,只是rsp不指这里了)
      [  其他  ]       rsp →    [  其他  ]

弹栈后那块内存的数据并没有被清除,只是 rsp 移走了,下次压栈会覆盖它。


push / pop 与数据段的关系

push / pop 的两端是寄存器 ↔ 栈,数据段在整个过程中完全不变

以全局变量 int g = 42(在数据段)为例:

movl  g(%rip), %eax       # ① 从数据段读到寄存器;数据段 g 还是 42
pushq %rax                # ② 寄存器 → 栈;数据段 g 还是 42
popq  %rbx                # ③ 栈 → 寄存器;数据段 g 还是 42
movl  %ebx, g(%rip)       # ④ 只有显式 mov 才能写回数据段
操作谁变了数据段变了吗
mov [内存→寄存器]寄存器❌ 不变(只读)
pushq %rax栈内容 + rsp 减小❌ 不变
popq %rbx寄存器 rbx + rsp 增大❌ 不变
mov [寄存器→内存]数据段✅ 才会变

push 和 pop 只是把「寄存器里的值」在栈上复制一份,或者把栈上的值复制到寄存器,原始来源(数据段/内存)不受影响。数据段只有在显式用 mov 寄存器, 内存地址 写回时才会改变。


常见用法:函数开头保存 / 结尾恢复

func:
    pushq %rbp            # 保存调用方的栈帧基址
    movq  %rsp, %rbp      # 建立本函数的栈帧
    subq  $16, %rsp       # 为局部变量腾出 16 字节空间
    ...
    movq  %rbp, %rsp      # 恢复栈指针(释放局部变量空间)
    popq  %rbp            # 恢复调用方栈帧基址
    ret                   # 弹出返回地址,跳回调用方

9.7 条件跳转与 CMP

cmpl  %ebx, %eax   # 计算 eax - ebx,结果写入 EFLAGS,不修改寄存器
jl    .Lless        # 若 eax < ebx,跳转(Jump if Less)
jge   .Lge          # 若 eax >= ebx,跳转
je    .Leq          # 若 eax == ebx(ZF=1),跳转

常用条件跳转速查:

指令含义触发条件
je等于ZF = 1
jne不等ZF = 0
jl有符号小于SF ≠ OF
jg有符号大于ZF = 0 且 SF = OF
jb无符号小于CF = 1
ja无符号大于CF = 0 且 ZF = 0

9.6 函数调用约定(x86-64 System V ABI)

# 调用方(caller)
movl  $10, %edi    # 第1参数 → %rdi
movl  $20, %esi    # 第2参数 → %rsi
call  add_two      # 压入返回地址,跳转到 add_two
# 函数返回后,返回值在 %rax 中

# 被调用方(callee)
add_two:
    pushq %rbp         # 保存调用方栈帧基址
    movq  %rsp, %rbp   # 建立新栈帧
    movl  %edi, %eax
    addl  %esi, %eax   # eax = arg1 + arg2(返回值放入 rax)
    popq  %rbp         # 恢复栈帧
    ret                # 弹出返回地址,跳回调用方

参数寄存器顺序(前6个整型/指针参数):%rdi, %rsi, %rdx, %rcx, %r8, %r9;超出 6 个参数则通过栈传递。


十、GDB 基础操作手册

GDB(GNU Debugger)是配合 CSAPP 学习汇编的核心工具。下面按操作分类整理常用命令。

10.0 准备:编译时保留调试信息

gcc -g -O0 hello.c -o hello
# -g   保留符号信息(函数名、变量名、行号)
# -O0  关闭编译器优化,汇编与源码严格对应,便于调试

10.1 启动与退出

gdb ./hello              # 加载可执行文件启动 GDB
gdb ./hello core         # 加载 core dump 进行事后分析
(gdb) run                # 从头运行程序(简写 r)
(gdb) run arg1 arg2      # 带命令行参数运行
(gdb) quit               # 退出 GDB(简写 q)

10.2 断点管理

(gdb) break main              # 在 main 函数入口设断点(简写 b)
(gdb) break hello.c:25        # 在源文件第 25 行设断点
(gdb) break *0x401234         # 在指定内存地址设断点(汇编调试首选)
(gdb) break func if x == 0    # 条件断点:仅当 x==0 时触发
(gdb) info breakpoints        # 列出所有断点(简写 info b)
(gdb) disable 2               # 禁用编号 2 的断点(不删除)
(gdb) enable 2                # 启用编号 2 的断点
(gdb) delete 2                # 删除编号 2 的断点
(gdb) delete                  # 删除所有断点

10.3 执行控制

命令简写说明
runr从头运行
continuec继续运行到下一个断点
nextn单步(进入函数调用)
steps单步(进入函数调用)
nextini汇编级单步(不进入 call
stepisi汇编级单步(进入 call
finishfin运行直到当前函数返回
until 30u 30运行到第 30 行(跳出循环用)
killk终止当前运行的程序

学汇编时优先用 ni / si,一条指令一条指令地走,配合 info registers 观察寄存器变化。


10.4 查看寄存器值

(gdb) info registers          # 查看所有通用寄存器(简写 info reg)
(gdb) info registers rax rbp  # 只看指定寄存器
(gdb) print $rax              # 十进制显示 rax 值(简写 p)
(gdb) print/x $rax            # 十六进制显示 rax
(gdb) print/d $rax            # 十进制显示
(gdb) print/t $rax            # 二进制显示
(gdb) print $rsp              # 查看当前栈指针(地址值)

print 的格式修饰符:/x(hex)、/d(dec)、/t(binary)、/c(char)、/f(float)


10.5 查看变量 / 地址值

(gdb) print x                 # 查看变量 x 的值
(gdb) print &x                # 查看变量 x 的地址
(gdb) print *ptr              # 解引用指针 ptr
(gdb) print ptr               # 查看指针本身的值(即地址)
(gdb) print arr[3]            # 查看数组第 4 个元素
(gdb) print sizeof(int)       # 查看类型大小

(gdb) display $rip            # 每次停下自动显示 rip 值
(gdb) display $rax            # 每次停下自动显示 rax
(gdb) undisplay 1             # 取消自动显示编号 1

10.6 查看内存值(examine)

命令格式:x/[数量][格式][大小] 地址

格式字母:

字母含义
x十六进制
d十进制
u无符号十进制
s字符串(直到 \0
i反汇编为指令
c单字符

大小字母:

字母含义
b1 字节
h2 字节(halfword)
w4 字节(word)
g8 字节(giant)
# 查看内存中的原始字节
(gdb) x/4xb 0x601030       # 从 0x601030 起,4 个字节,十六进制
(gdb) x/8xw $rsp           # 从 rsp 起,8 个 4字节,十六进制(看栈)
(gdb) x/4gx $rsp           # 从 rsp 起,4 个 8字节,十六进制(64位栈帧)

# 查看内存中的字符串
(gdb) x/s $rdi             # 把 rdi 指向的地址当字符串打印(常用!)
(gdb) x/s 0x402400         # 查看只读数据段中的字符串常量

# 查看内存中的整数
(gdb) x/d $rbp-8           # 以十进制看 rbp-8 处存的 int(局部变量)
(gdb) x/gd $rsp+16         # 以十进制看 rsp+16 处存的 long

常用组合x/s $rdi 打印字符串参数;x/8gx $rsp 看整个栈帧布局。


10.7 查看汇编代码

# 反汇编
(gdb) disassemble                  # 反汇编当前函数(简写 disas)
(gdb) disassemble main             # 反汇编 main 函数
(gdb) disassemble /m main         # 同时显示对应的 C 源码(混合模式)
(gdb) disassemble /r main         # 同时显示机器码原始字节

# 从某地址开始反汇编若干条指令
(gdb) x/10i $rip               # 从当前 rip 起反汇编 10 条指令
(gdb) x/5i 0x401234            # 从指定地址反汇编 5 条指令

# 设置汇编风格(AT&T 是默认)
(gdb) set disassembly-flavor intel    # 切换为 Intel 风格
(gdb) set disassembly-flavor att      # 切回 AT&T 风格

10.8 查看源码

(gdb) list                    # 显示当前位置附近的源码(简写 l)
(gdb) list main               # 显示 main 函数的源码
(gdb) list hello.c:20         # 显示 hello.c 第 20 行附近
(gdb) list 15,30              # 显示第 15~30 行

注意:只有用 -g 编译时才能看到源码,否则只能看汇编。


10.9 观察点(Watchpoint)

当某个变量/地址被读写时自动暂停,无需在代码里找修改点。

(gdb) watch x                 # 变量 x 被**写入**时暂停
(gdb) rwatch x                # 变量 x 被**读取**时暂停
(gdb) awatch x                # 变量 x 被读或写时暂停
(gdb) watch *0x601030         # 监视指定内存地址的写入

10.10 综合调试流程示例

以 CSAPP《深入理解计算机系统》的 Bomb Lab 为例:

$ gcc -g -O0 bomb.c -o bomb
$ gdb ./bomb

(gdb) break phase_1            # 在 phase_1 函数入口设断点
(gdb) run                      # 运行到断点

(gdb) disassemble              # 查看 phase_1 的汇编,找关键指令
(gdb) x/s $rsi                 # rsi 常常指向比较用的字符串常量,打印出来
(gdb) x/s $rdi                 # rdi 是我们输入的字符串

(gdb) ni                       # 逐条汇编指令执行
(gdb) info registers           # 随时查看各寄存器状态
(gdb) x/8gx $rsp               # 看当前栈帧内容

(gdb) print $eax               # 查看函数返回值 / 比较结果
(gdb) continue                 # 继续运行到下一个断点

快速速查卡

目的命令
查寄存器info reg / p $rax
查变量值p x / p &x(地址)
查内存字节x/Nxb 地址
查内存字符串x/s $rdi
查栈内容x/8gx $rsp
看汇编disas / x/10i $rip
看源码list
混合汇编+源码disas /m
单步汇编ni / si
自动刷新显示display $rip



0 / 2000
Loading comments...