从 C 语言到汇编:二进制安全需要的计算机组成基础
我们平时编写 C 语言程序时,习惯用变量、循环、判断和函数来描述程序逻辑:
int add(int a, int b) {
return a + b;
}
但是 CPU 并不认识 int、return 或函数名。源代码经过编译器处理后,会被翻译成机器指令;机器指令本质上是一串二进制数据。汇编语言则为这些机器指令提供了便于人类阅读的写法。上面的函数可能对应如下汇编逻辑:
add:
lea eax, [rdi + rsi]
ret
对于普通软件开发,我们未必需要关心翻译后的结果;但在逆向工程、漏洞分析和恶意代码分析中,拿到的往往只有编译后的程序。此时,我们必须从寄存器、内存和汇编指令出发,重新理解程序做了什么。
本文不会完整讲授数字电路、流水线或缓存一致性等计算机组成原理课程内容,而是围绕二进制安全所需的主线展开:
硬件如何协作 → CPU 如何保存数据 → 汇编指令如何操作数据和改变控制流 → 函数如何使用栈与寄存器。
文中的汇编代码采用 x86-64 Intel 语法,运行环境默认为 Linux 用户态,函数调用遵循 System V AMD64 ABI。示例以讲清原理为第一目标,实际编译结果会随着编译器版本、优化等级和源代码写法发生变化。
计算机由哪些主要硬件组成
一台计算机包含很多部件。对于分析普通用户态程序,我们首先需要认识 CPU、内存、外部存储、总线和输入输出设备。
| 部件 | 主要作用 | 与二进制分析的关系 |
|---|---|---|
| CPU | 解释并执行机器指令,完成运算和控制 | 汇编指令最终在 CPU 中执行 |
| 内存(RAM) | 临时保存正在运行的程序、代码和数据 | 代码、栈、堆和全局变量都位于进程的虚拟地址空间中 |
| 硬盘或固态硬盘 | 长期保存程序和文件,断电后数据仍存在 | ELF 可执行文件最初存放在这里,运行时再由系统装入内存 |
| 总线 | 在 CPU、内存和设备之间传输地址、数据和控制信息 | 决定各硬件如何交换信息,但初学阶段不必深入电气细节 |
| 输入输出设备 | 让计算机与用户和外界交换数据 | 程序通常通过操作系统提供的接口访问这些设备 |
1. CPU 内部有什么
为了便于理解,可以把 CPU 内部的重要部分概括为:
- 控制单元:取得指令、解释指令,并协调其他部件完成操作。
- 运算单元:执行加减、比较、位运算等操作,也常被称为算术逻辑单元(ALU)。
- 寄存器:CPU 内部容量很小但速度很快的存储位置,用来保存当前正在处理的数据、地址和状态。
- 缓存:位于 CPU 与内存之间,用来保存近期可能再次使用的指令和数据,减少等待内存的时间。
真实 CPU 的结构远比上述描述复杂,还包含多级流水线、分支预测、乱序执行等机制。不过从程序可见的角度看,我们暂时可以把它理解为一台不断读取并执行指令的机器。
2. 取指、译码与执行
CPU 执行程序时,会不断重复一个基本过程:
- 取指:根据指令指针指向的地址,从内存中取得下一条机器指令。
- 译码:判断这条指令要做什么、操作数来自哪里。
- 执行:完成数据传输、运算、访存或跳转,并更新相关寄存器。
- 继续:如果没有发生跳转,就执行地址上的下一条指令;如果发生跳转,则从新的地址继续。
例如,CPU 执行 add eax, ebx 时,需要读取 EAX 和 EBX,完成加法,把结果写回 EAX,同时更新部分状态标志。
3. 为什么需要不同层次的存储
寄存器很快,却只有少量;内存容量大一些,但访问更慢;硬盘容量更大,也能长期保存数据,但速度远低于内存。计算机通过分层存储在速度、容量和成本之间取得平衡。
大致可以按照下面的顺序理解:
速度快、容量小
寄存器
↓
CPU 缓存
↓
内存(RAM)
↓
SSD / HDD
速度慢、容量大
需要注意,“变量存放在哪里”不是永远固定的。编译器可能把一个变量放在寄存器里,也可能放在栈上;开启优化后,一个没有实际用途的变量甚至可能被直接删除。逆向分析时,我们关注的是数据在当前指令中的真实位置,而不是源代码中的名字。
x86-64 寄存器
寄存器位于 CPU 内部。汇编代码经常直接读写寄存器,因此认识寄存器是阅读反汇编的第一步。
1. 通用寄存器
x86-64 提供 16 个主要的 64 位通用寄存器:
| 寄存器 | 常见约定用途 |
|---|---|
RAX | 函数返回值,部分算术指令的隐含操作数 |
RBX | 通用数据;在 System V 调用约定中由被调用者保存 |
RCX | 通用数据、函数第 4 个整数参数、部分循环或字符串操作 |
RDX | 通用数据、函数第 3 个整数参数、部分乘除法操作 |
RSI | 通用数据、函数第 2 个整数参数 |
RDI | 通用数据、函数第 1 个整数参数 |
RBP | 可作为栈帧基址,也可以被编译器当作普通通用寄存器 |
RSP | 栈顶指针 |
R8~R9 | 通用数据、函数第 5、6 个整数参数 |
R10~R11 | 通用数据,由调用者保存 |
R12~R15 | 通用数据,由被调用者保存 |
表中的用途大多来自指令规则或调用约定,并不代表寄存器只能这样使用。例如,RAX 除了保存返回值,也完全可以用于普通加法。分析程序时,应先看指令如何使用它,再结合上下文判断含义。
2. 特殊寄存器
RIP:指令指针,表示下一条将要执行的指令位置。普通mov指令不能随意直接修改它;跳转、调用和返回等控制流指令会改变它。RSP:栈顶指针,保存当前栈顶地址。函数创建局部空间、保存数据或恢复现场时都会修改它。RBP:常用作栈帧基址,使函数能够通过固定偏移访问参数和局部变量;开启优化后经常被省略。RFLAGS:保存运算状态和部分控制标志。条件跳转主要读取其中的零标志、符号标志、进位标志和溢出标志。
本文不展开浮点、SIMD、控制寄存器和内核态寄存器。这些内容在遇到相关程序时再学习即可。
3. 子寄存器
同一个寄存器可以用不同名称访问不同宽度。以 RAX 为例:
63 0
+---------------------------------------+
| RAX | 64 位
+-------------------+-------------------+
| EAX | 32 位
+---------+---------+
| AX | 16 位
+----+----+
| AH | AL | 各 8 位
+----+----+
RBX/EBX/BX/BL、RCX/ECX/CX/CL 等名称也遵循类似规律。R8 到 R15 则使用 R8D、R8W、R8B 这样的后缀表示 32、16、8 位部分。
在 64 位模式下,有一条非常重要的规则:
写入 32 位子寄存器会自动把对应 64 位寄存器的高 32 位清零;写入 8 位或 16 位子寄存器不会自动清除其余位。
例如:
mov rax, 0xffffffffffffffff
mov eax, 1
执行后,RAX 的值是 0x0000000000000001,而不是 0xffffffff00000001。理解这一点能够避免很多寄存器追踪错误。
汇编语言的基本写法
1. 指令和操作数
一条汇编指令通常由“指令助记符 + 操作数”组成:
mov eax, 10
add eax, ebx
本文使用 Intel 语法,基本格式是:
指令 目标操作数, 源操作数
因此 mov eax, ebx 表示把 EBX 的值复制到 EAX,不会反过来修改 EBX。
常见操作数包括:
- 立即数:直接写在指令中的常量,如
10、0x20。 - 寄存器操作数:数据位于寄存器中,如
rax。 - 内存操作数:方括号内给出要访问的内存地址,如
[rbx]、[rbp-8]。
例如:
mov eax, 10 ; EAX = 10
mov ebx, eax ; EBX = EAX
mov ecx, [rdi] ; 从 RDI 指向的内存读取 4 字节到 ECX
mov [rsi + 4], ecx ; 把 ECX 的 4 字节写入 RSI+4 指向的内存
寄存器宽度通常暗示访问的数据大小:AL 是 1 字节,AX 是 2 字节,EAX 是 4 字节,RAX 是 8 字节。某些场景需要显式写出 byte ptr、dword ptr 或 qword ptr,以免数据宽度产生歧义。
x86 的普通 mov 通常不能同时把源和目标都写成内存操作数。若要把一处内存的数据复制到另一处,一般先经过寄存器:
mov eax, [rdi]
mov [rsi], eax
2. 内存寻址
x86-64 常见的内存地址形式可以写成:
[基址寄存器 + 索引寄存器 * 比例 + 偏移]
其中比例只能是 1、2、4 或 8。例如,一个 int 占 4 字节,读取数组的第 i 个元素可能写成:
mov eax, [rdi + rsi*4]
若 RDI 保存数组首地址、RSI 保存下标 i,这条指令就近似对应:
eax = ((int *)rdi)[rsi];
方括号表示“访问该地址处的内存”。没有方括号时,操作的是地址值或寄存器值本身。
3. 数据宽度与小端序
内存可以看成一串按地址编号的字节。一个地址通常对应一个字节,而 int、指针等数据会连续占用多个字节。x86-64 使用小端序保存多字节整数,也就是把数值的低位字节放在较低的内存地址。
假设从地址 0x1000 开始保存 32 位数值 0x11223344,内存布局是:
地址 内容
0x1000 44
0x1001 33
0x1002 22
0x1003 11
这并不表示数值被反转了,只是规定了各字节在内存中的排列顺序。CPU 用 32 位宽度读取 0x1000 时,得到的仍是 0x11223344。但在调试器或十六进制编辑器中逐字节观察时,通常会先看到 44 33 22 11。
数据宽度同样重要。若 RDI = 0x1000:
mov al, [rdi] ; 读取 1 字节,得到 0x44
mov ax, [rdi] ; 读取 2 字节,得到 0x3344
mov eax, [rdi] ; 读取 4 字节,得到 0x11223344
逆向分析时,同一个地址可能被按不同宽度访问。访问宽度有助于推测数据类型和结构布局,也是判断缓冲区边界是否正确的重要依据。
常见的数据传输指令
1. mov:复制数据
mov 是最常见的数据传输指令:
mov rax, rbx ; RAX = RBX
mov eax, [rdi] ; 从内存读取 4 字节
mov [rdi], eax ; 向内存写入 4 字节
它是“复制”而不是“移动后清空”。执行 mov rax, rbx 后,RBX 保持不变。
2. movzx 与 movsx:扩展较小的数据
把 8 位或 16 位数据放入更宽的寄存器时,需要决定高位如何填写:
movzx:零扩展,高位补0,适合无符号数。movsx:符号扩展,根据源数据的最高位填充高位,适合有符号数。
mov al, 0xff
movzx ecx, al ; ECX = 255
movsx edx, al ; EDX = 0xffffffff,即有符号数 -1
相同的位模式 0xff 可以表示无符号数 255,也可以表示 8 位有符号数 -1。CPU 不会给寄存器贴上“有符号”或“无符号”标签,具体含义取决于后续使用的指令。
3. lea:计算有效地址
lea 的原意是“加载有效地址”。它计算方括号中的地址表达式,但不会读取该地址处的内存:
lea rax, [rdi + 8] ; RAX = RDI + 8
mov rax, [rdi + 8] ; RAX = 内存[RDI + 8]
二者看起来相似,含义却完全不同。因为 lea 可以完成加法和有限倍率乘法,编译器也常把它用于普通算术:
int f(int x) {
return x * 5 + 3;
}
一种可能的汇编实现是:
f:
lea eax, [rdi + rdi*4 + 3]
ret
这里没有访问内存,只是计算 x + x*4 + 3。
4. push 与 pop:在栈上保存和取回数据
在 x86-64 中,可以把下面两条指令近似理解为:
push rax
; 等价效果:RSP = RSP - 8;内存[RSP] = RAX
pop rbx
; 等价效果:RBX = 内存[RSP];RSP = RSP + 8
push 先降低 RSP 再写入,pop 先读取再提高 RSP。二者必须配合栈的布局来理解,后文会详细说明。
运算、比较与标志位
1. 基本运算
add eax, ebx ; EAX = EAX + EBX
sub eax, 4 ; EAX = EAX - 4
xor eax, eax ; EAX = 0,同时清零 RAX 的高 32 位
xor eax, eax 常用于将寄存器清零。它并不神秘:任何值与自身逐位异或,结果都是 0。
2. 常见状态标志
许多算术和逻辑指令会更新 RFLAGS 中的状态。阅读条件跳转时,最常见的是:
| 标志 | 名称 | 常见含义 |
|---|---|---|
ZF | Zero Flag | 结果是否为 0 |
SF | Sign Flag | 结果最高位是否为 1 |
CF | Carry Flag | 无符号运算是否产生进位或借位 |
OF | Overflow Flag | 有符号运算是否溢出 |
3. cmp:比较两个值
cmp eax, ebx
cmp 在内部按照 EAX - EBX 更新标志位,但不保存减法结果,也不会改变 EAX 和 EBX。紧随其后的条件跳转会读取这些标志。
有符号数和无符号数使用不同的条件跳转:
| 含义 | 有符号跳转 | 无符号跳转 |
|---|---|---|
| 相等 | je | je |
| 不相等 | jne | jne |
| 大于 | jg | ja |
| 大于等于 | jge | jae |
| 小于 | jl | jb |
| 小于等于 | jle | jbe |
这里的 g/l 可以理解为 signed greater/less,a/b 可以理解为 unsigned above/below。相同的位模式在有符号和无符号解释下大小关系可能完全不同,因此选择哪组跳转非常重要。
4. test:按位测试
test 对两个操作数执行按位与,更新标志位,但不保存结果。最常见的用法是判断一个值是否为 0:
test eax, eax
je .is_zero
若 EAX 为 0,EAX & EAX 的结果也是 0,ZF 被置为 1,je 就会跳转。
控制流指令
程序默认按地址顺序执行。控制流指令可以改变下一条指令的位置,从而实现判断、循环和函数调用。
1. jmp:无条件跳转
jmp .done
执行后,无论标志位如何,都会从 .done 标签处继续。标签只是汇编代码中对地址的命名,生成机器码后通常会变成目标地址或相对偏移。
2. 条件跳转与 if/else
下面的 C 函数返回两个有符号整数中的较大值:
int max_value(int a, int b) {
if (a > b) {
return a;
}
return b;
}
一种直观的汇编写法是:
max_value:
cmp edi, esi ; 比较 a 和 b
jle .use_b ; 如果 a <= b,跳到 .use_b
mov eax, edi ; 返回 a
ret
.use_b:
mov eax, esi ; 返回 b
ret
根据 System V AMD64 调用约定,前两个 int 参数位于 EDI、ESI,返回值写入 EAX。由于 int 是有符号类型,这里使用 jle,而不是用于无符号比较的 jbe。
3. 条件跳转与循环
int sum_to_n(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
一种便于教学的汇编实现是:
sum_to_n:
xor eax, eax ; sum = 0
mov ecx, 1 ; i = 1
.loop:
cmp ecx, edi
jg .done ; i > n 时结束
add eax, ecx ; sum += i
add ecx, 1 ; i++
jmp .loop
.done:
ret
逆向时,循环不一定保留 for 或 while 的外观。我们通常通过“向前的条件跳转 + 跳回较低地址的跳转”识别循环结构,再分析初始化、循环条件和变量更新。
4. call 与 ret
call target 主要完成两件事:
- 把
call后面那条指令的地址压入栈中,作为返回地址。 - 把控制流转移到
target。
ret 则从栈顶取出返回地址,并跳回该地址。可以近似理解为:
call target:push 下一条指令地址;跳转到 target
ret: pop RIP
这就是函数执行结束后能够回到调用位置的基本原因。如果栈上的返回地址被错误覆盖,ret 就可能跳到错误的位置;经典的栈缓冲区溢出利用正是由此展开。
栈与栈帧
1. 栈是什么
栈是进程内存中的一片区域,按照“后进先出”的方式使用。x86-64 用户态程序的栈通常向低地址增长:压入数据时 RSP 变小,弹出数据时 RSP 变大。
假设 RSP 最初为 0x1000,RAX 为 0x1122334455667788:
push rax
执行后:
高地址
0x1000 ← 压栈前的 RSP
0x0ff8 0x1122334455667788 ← 压栈后的 RSP
低地址
随后执行 pop rbx,RBX 得到该值,RSP 恢复为 0x1000。
栈常用于保存:
- 函数返回地址;
- 无法全部放入寄存器的参数;
- 局部变量和临时数据;
- 需要跨函数调用保持的寄存器值;
- 对齐所需的填充空间。
2. 一个典型栈帧
在未优化或保留帧指针的代码中,函数开头常见:
push rbp
mov rbp, rsp
sub rsp, 32
含义依次是:保存调用者的 RBP,让当前 RBP 指向本函数栈帧的固定基准,再为局部变量预留 32 字节空间。此时可通过 [rbp-4]、[rbp-8] 等地址访问局部数据。
函数结束时可能使用:
mov rsp, rbp
pop rbp
ret
也可能用一条 leave 代替前两条。一个简化的栈帧布局如下:
高地址
+-------------------------+
| 栈上传递的额外参数 |
+-------------------------+
| 返回地址 | 由 call 压入
+-------------------------+
| 调用者原来的 RBP | ← RBP
+-------------------------+
| 局部变量、临时数据 |
| 保存的寄存器、填充空间 | ← RSP
+-------------------------+
低地址
这只是常见形式,并不是每个函数都必须如此。优化后的编译器可以省略 RBP 栈帧,直接相对 RSP 访问数据;若所有局部变量都能放入寄存器,函数甚至无需分配栈空间。逆向时不要依赖固定模板,而要追踪 RSP 的实际变化。
3. 调用过程中栈如何变化
假设调用者即将执行:
call worker
若调用前 RSP = 0x1000,则 call 会把返回地址压入栈,进入 worker 时 RSP = 0x0ff8。若 worker 随后执行 push rbp,RSP 变为 0x0ff0。函数结束时:
pop rbp使RSP从0x0ff0回到0x0ff8;ret取出返回地址,使RSP回到0x1000;- CPU 从
call后面的指令继续执行。
只要压入和弹出的数据不匹配,返回地址就可能被当成普通数据弹走,或者 ret 取到错误地址。因此,分析函数时应特别关注每一处对 RSP 的修改。
Linux x86-64 函数调用约定
如果每个函数都随意选择寄存器传参,分别编译的代码将无法可靠协作。调用约定规定了参数放在哪里、返回值放在哪里、哪些寄存器需要保持,以及栈应如何对齐。
本文讨论 Linux 等类 Unix 系统常用的 System V AMD64 ABI。这里只介绍普通整数和指针参数;浮点参数、结构体返回和可变参数还有额外规则。
1. 参数与返回值
前六个整数或指针参数依次使用:
| 参数序号 | 64 位寄存器 | 传递 int 时常见部分 |
|---|---|---|
| 第 1 个 | RDI | EDI |
| 第 2 个 | RSI | ESI |
| 第 3 个 | RDX | EDX |
| 第 4 个 | RCX | ECX |
| 第 5 个 | R8 | R8D |
| 第 6 个 | R9 | R9D |
更多整数参数通过栈传递。整数或指针返回值通常位于 RAX;返回 int 时常看到写入 EAX。
例如:
long add_three(long a, long b, long c) {
return a + b + c;
}
可以写成:
add_three:
lea rax, [rdi + rsi]
add rax, rdx
ret
2. 调用者保存与被调用者保存
函数调用可能覆盖一部分寄存器。为了让调用双方能够配合,ABI 把通用寄存器大致分为两类:
| 类型 | 寄存器 | 规则 |
|---|---|---|
| 调用者保存 | RAX、RCX、RDX、RSI、RDI、R8~R11 | 调用者若想在 call 后继续使用旧值,应在调用前自行保存 |
| 被调用者保存 | RBX、RBP、R12~R15 | 被调用者若要修改,必须先保存,并在返回前恢复 |
RSP 也必须在函数返回时恢复到调用约定要求的位置,否则调用者无法正确继续执行。
“保存”不代表每次都要把所有相关寄存器压栈。函数只需处理自己真正修改且必须保持的寄存器。编译器也可能选择把跨调用仍需使用的值放进 RBX 等被调用者保存寄存器,或暂存在栈上。
3. 栈对齐
System V AMD64 ABI 要求函数调用边界满足 16 字节对齐。便于初学者记忆的方式是:
- 调用者执行
call前,通常让RSP是 16 的倍数; call压入 8 字节返回地址,因此被调用函数刚进入时通常有RSP % 16 == 8;- 被调用函数若还要调用其他函数,需要在下一次
call前重新调整好对齐。
这也是代码中有时会出现看似“多余”的 sub rsp, 8 或更大填充空间的原因。某些 SIMD 指令和库函数依赖正确对齐,破坏约定可能造成崩溃。
4. Red zone
System V AMD64 ABI 在当前 RSP 下方保留了 128 字节的 red zone(红区)。用户态的信号或中断处理过程不会破坏这一区域,因此不再调用其他函数的叶子函数可以把临时数据放在这里,而无需先执行 sub rsp, ...。
但是,普通的函数调用会让被调用函数使用更低地址的栈空间,所以需要跨越 call 保存的数据不能依赖红区。内核代码通常也不会使用这一规则。初学阶段只需做到:看到叶子函数直接访问 [rsp-8] 时,不要立刻认为它越过了栈顶或写错了内存。
综合示例:从 C 函数到汇编
假设有如下代码:
int helper(int x);
int adjust(int value, int limit) {
int doubled = value * 2;
if (doubled > limit) {
return doubled - limit;
}
return helper(doubled);
}
下面是一份符合前述规则、为教学简化过的汇编实现:
adjust:
push rbp
mov rbp, rsp
push rbx
sub rsp, 8
lea ebx, [rdi + rdi] ; EBX = value * 2
cmp ebx, esi ; doubled 与 limit 比较
jle .call_helper ; doubled <= limit 时调用 helper
mov eax, ebx
sub eax, esi ; 返回 doubled - limit
jmp .done
.call_helper:
mov edi, ebx ; helper 的第一个参数
call helper ; 返回值已经位于 EAX
.done:
add rsp, 8
pop rbx
pop rbp
ret
逐步分析:
value和limit是前两个int参数,分别位于EDI、ESI。lea ebx, [rdi+rdi]算出两倍的value。写入EBX也会清除RBX的高 32 位。doubled在调用helper时仍要使用。这里把它放入被调用者保存寄存器RBX,所以函数开头保存旧RBX,返回前再恢复。cmp ebx, esi按照doubled-limit更新标志;源代码使用有符号int,所以用jle。- 大于分支在
EAX中计算返回值;小于等于分支把参数放入EDI后调用helper。 helper的返回值已经在EAX,因此无需再次搬运。- 进入
adjust时,返回地址已经占 8 字节;push rbp、push rbx和sub rsp,8共同让RSP在调用helper前满足 16 字节对齐。 - 两条返回路径最终都到达
.done,按相反顺序撤销栈空间并恢复寄存器。
真实编译器可能使用条件移动、重新排列分支、内联 helper,或采取完全不同的寄存器分配。逆向分析的目标不是背下一种固定模板,而是根据数据流和控制流还原等价逻辑。
简单练习题
练习 1:追踪子寄存器
执行下面的代码后,RAX 是多少?
mov rax, 0xffffffffffffffff
mov eax, 0xffffffff
add rax, 1
查看答案
mov eax, 0xffffffff 会清除 RAX 的高 32 位,因此此时 RAX = 0x00000000ffffffff。再加 1 后:
RAX = 0x0000000100000000
练习 2:判断跳转
若 EAX = 5、EBX = 8,下面的 jl 是否跳转?
cmp eax, ebx
jl .smaller
查看答案
会跳转。cmp eax, ebx 按照 5-8 设置标志,jl 按有符号数判断 EAX < EBX,条件成立。
练习 3:追踪栈指针
假设开始时 RSP = 0x1000,依次执行:
push rax
call target
进入 target 时 RSP 是多少?若 target 立即执行 ret,随后调用者执行 pop rax,最终 RSP 又是多少?
查看答案
push rax 压入 8 字节,RSP 变为 0x0ff8;call 再压入 8 字节返回地址,进入 target 时 RSP = 0x0ff0。
ret 弹出返回地址后,RSP 回到 0x0ff8;pop rax 再弹出先前保存的值,最终 RSP = 0x1000。
练习 4:还原伪代码
尝试把下面的汇编还原成 C 风格伪代码:
calc:
xor eax, eax
xor ecx, ecx
.loop:
cmp ecx, edi
jge .done
add eax, ecx
add ecx, 1
jmp .loop
.done:
ret
查看答案
EDI 是参数,EAX 是累加结果,ECX 是从 0 开始的循环变量。等价逻辑可以写成:
int calc(int n) {
int result = 0;
for (int i = 0; i < n; i++) {
result += i;
}
return result;
}
当 n <= 0 时,第一次比较就满足 i >= n,函数返回 0。