计算机系统课程笔记

怎么这么多

计算机系统漫游

基础知识

直接使用内存计算慢

寄存器

(X86)32位CPU中包含一组8个32bit的通用寄存器

寄存器储存 整数数据 指针

原型系统

包括 输出设备,输入设备,存储器,运算器,控制

hello world(从底层理解)

gcc -E hello.c -o hello.i 
# 通过预处理器,将.c文件编译为.i文件 此时仍认为是文本文件,可用gedit查看
gcc -S hello.i -o hello.s
# 编译器检查代码后,将代码翻译为汇编语言, 此时仍认为是文本文件,可用gedit查看
gcc -c hello.s -o hello.o
#h汇编器将.s文件转化为二进制目标代码,.o为object文件,使用objdump -d file.o/hexdump file.o查看
gcc hello.o -o hello.out
# 链接(复制过程)后形成可执行文件
-g 添加调试信息

汇编初步

汇编代码是机器指令的符号表达,一种中间形式?

大小端法

小端法 小 低 低 低位的字节放在内存的低地址端

大端法 大 高 低 高位————————低地址端

GDB调试

内存查看

x/nfu address #检查内存中的内容 n 是要显示的单位数量;f 是显示格式u 是单位大小
x/4xb address #查看内存address开始的用十六进制表示的4个字节的值
p/x expression #以十六进制格式打印变量名,寄存器名,直接的内存地址存放的值
x/s $0xfffffff 字符串

AT&T格式

X86汇编语言的语法

Inter语法
movl $1,%eax #立即数寻址-1005.s
movl %ebx,%eax #寄存器寻址 - 1006.s   将ebx存储的值->eax
movl 0x08048054,%eax #绝对寻址 - 1007.s
movl (%ebx),%eax #间接寻址 - 1008.s   将ebx内 存储的地址 的对应值->eax
movl 0x8(%ebx),%eax #基址偏移量寻址 – 1008.s
movl (%ebx,%edx),%eax #变址寻址-1009.s
movl 0x8(%ebx,%edx),%eax #变址基址寻址 – 1009.s
movl (%ebx,%ecx,0x2),%eax #比例变址寻址-1010.s 比例因子为1 2 4 8
movl 0x8(%ebx,%ecx,0x2),%eax #比例变址基址寻址 -1010.s
# lea—Load Effective Address(直接的地址计算与传送) 与mov区别在
lea/leal 8(%edi) %eax     #表示将8+%edi的值(地址)->eax
movl  8(%edi) %eax   ##表示将M[8+%edi]的值(地址8+%edi的内存的数据)->eax

汇编进阶

函数的栈帧(一段连续的地址空间)

将栈类比为一个竖直的抽屉,栈自顶向下生长(向低地址方向生长)

圧栈push 弹栈pop

	push	%ebx
	popl	%eax

算术逻辑指令集

#数据处理
incl D: 将操作数D的值加1。D可以是寄存器或内存地址。
decl D: 将操作数D的值减1。
--inc和dec指令不影响进位标志位
negl D: 将操作数D取负。
notl D: 将操作数D取反(按位取反)。
#位操作
SHR k, D: 将D逻辑右移k位。
SHL k, D: 将D逻辑左移k位。
SAR k, D: 将D算术右移k位(保留符号位)。
SAL k, D / SHL k, D: 将D算术左移k位(同逻辑左移)。
#算术运算
addl S,D: 将D和S相加,结果存入D。
subl S,D: 从D中减去S,结果存入D。
--而add $1,%eax与sub $1,%eax等指令会影响进位标志位
imull S,D: 有符号乘法,将S与D相乘,结果存入D。
xorl S,D: 对S和D进行异或操作,结果存入D。
orl S,D: 对S和D进行或操作,结果存入D。
andl S,D: 对S和D进行与操作,结果存入D。
#乘除法
imull S: 将S与%eax中的值有符号相乘,64位结果的高32位放在%edx中,低32位放在%eax中。
mull S: 将S与%eax中的值无符号相乘,同上。
idivl S: 使用%edx:%eax作为被除数,以S作为除数执行有符号除法。
divl S: 执行无符号除法,操作和idivl类似,但是对无符号值操作。

--cmp指令是对两个数做减法,但不保留结果,仅根据结果设置标志位
--test指令对两个操作数做逻辑与运算,但不保留结果

条件码

CPU根据运算结果由硬件设置的位,体现当前指令执行结果的各种状态信息。(在%eflags寄存器中) 1)CF: 将运算看做“无符号数运算”,如果有进/借位,则置 1; 第0位

clc:将CF标志位清零
stc:将CF标志位设置为1
cmc:将CF标志位置反

2)OF:将运算看做“有符号数运算”,如果产生溢出,则置 1; 第11位 3)SF: 如果运算结果最高位是 1,则置 1; 第7位 4)ZF: 如果运算结果是 0,则置 1. 第6位

标志位设定指令(set):

将后面跟着的操作数(一个字节)设为 1,前提是判定结果满足其后缀,例如:setge %al 表示当大于或等于时将%al 置1 (x86 汇编语言中,setge %al 这条指令的作用是基于最近的比较(cmp)或减法(sub)指令的结果 )。这样,比较的结果就以一个字节 1 的形式保留下来。

无条件跳转jmp

其操作数作为地址写入%eip,实现 CPU 指令执行的改变。

 jmp LABEL 直接使用 LABEL 所在的地址 
 jmp *LABEL 使用 LABEL 地址中存的地址(间接) 
 jmp 0x8048056 直接使用立即数作为跳转的地址 
 jmp *%eax 将%eax 中的内容作为跳转的地址

条件跳转与传送

条件跳转:依据条件码的不同组合,可以判定大小关系,依据大小关系来决定的跳转就是条件跳转。指令符形式为 J 后面接表示判定结果的后缀,例如: JGE,就是:jump if greater or equal—当大于或等于时跳转。 cmov:功能与格式和 mov 完全相同,不同在于它和条件跳转 一样有后缀,满足后缀表示的比较关系才进行数据传送,例如: comvge %ax,%bx 当大于或等于时才将%ax 的内容传给%bx

第二章:信息的表示和处理

信息的机器级表达

二进制,进制间的转化

位运算与逻辑运算:

注意位运算是将参数展开成位向量,然后按位进行 运算,得到的结果依然是位向量;逻辑运算对于操作数只判断是否非 0

移位运算:

逻辑左移和算术左移都是在移位后右侧补 0;逻辑右移,左侧补 0,; 算术右移,左侧补的是符号位(非负数补 0,负数补 1)。

内存是字节的线性组合,内存是依照字节编址的,也就是一个地址对应一个字节,内存中数据的存放遵循两大模式(大端法和小端法)。

整数表示

理解有无符号的区别与补码

本质来说,补码是将最高位为 1 的二进制数“平移”2w到负半轴,用来表示与其有互补关系的负数

整数编码

掌握一些原补码转换

C语言中,

扩展与截断

补码的扩展只需要做符号扩展,值保持不变

截断:

有符号的当成无符号去截断,断了之后用有符号的规则来解释

整数运算

加法:

无符号:

有符号补码加法:

UAdd与TAdd在“位”的操作上一致

舍弃最高有效位,余下结果当补码处理

减法:

在加法基础上进行

乘法:

有/无符号数:正常相乘后截断(舍弃高 w 位),两种乘法在位操作上一样

UMultw(u,v)=uvmod2wUMultw(u , v) = u · v mod2^w

TMultw(u,v)=U2Tw[(uv)mod2w)]TMultw(u , v) = U2Tw [(u · v ) mod 2^w )]

在大部分机器中移位和加法运算比乘法快得多:u << k 得到 u2ku * 2^k

与常数相乘时,C 编译器总是自动生成移位和加法指令以简化机器的运算

除法:(仅讨论除2的幂次)

无符号数:右移补0

有符号数:右移补1

除法矫正:当被除数为正,无需矫正

当被除数为负数,

偏置使得结果加一

当被除数为正,无需矫正

浮点数表示

IEEE754 浮点数:数学形式是 (1)sM2E(-1)^s*M*2^E

计算机中的浮点数据类型有: 单精度: 32 位,符号位 1 位;阶码 8 位,尾数 23 位 双精度: 64 位,符号位 1 位,阶码 11 位,尾数 52 位 扩展精度:80 位,符号位 1 位,阶码 15 位,尾数 64 位 虽然扩展精度浮点数是 80 位,但是在 32 位机器中占据 12 个字节,在 64 位机器中占据 16 个字节。

浮点数表示范围并非线性,较大范围数可能有精度不够而无法表示

舍入与运算

加法:

先对阶(小向大对齐),尾数相加,规格化并舍入,判断溢出

舍入向偶数舍入

乘法:

尾数相乘,阶码相加

浮点数运算具备可交换性和单调性,不具备结合性和分配性 :

本质原因是浮点数每运算一次都会进行规格化和舍入,加上本身就是舍弃 有效数字(精度)换取更大的数值表示范围。

C 语言中整数与浮点可以进行强制转换,但是一定要注意因为格式的转换

导致的精度差异!。

第三章:程序的机器值表示

基础知识

PC从EIP(IA32)或RIP(x86-64)取值

理解汇编与反汇编 看到的都是虚拟地址

理解数据传输 mov取内存的值 lea仅计算要取的内存的地址

push pop

算术操作格式基本为先src后dest

subl src,dest --> dest=dest-src

部分特殊算术操作,那eax当计算数,edx和eax一起放结果

控制

条件码

控制操作的执行依靠对条件码的判断完成,每个条件码一个bit

一些设置条件码的命令: cmpl src.dest —> dest-src 不保存结果只影响标志位 testl src.dest —> dest&src …………………………………………

SetX 根据条件码组合设置字节值

条件分支

条件跳转:类似goto

条件传送: 先计算两种结果,再根据需要去选择 可以减少分支预测风险?流水线效率提升

循环

do-while (至少执行一次)

while 注意汇编上与do-while的识别判断

for 参数全面一点,汇编上差不多?反正都是利用跳转循环

switch语句

多重分支 使用跳转表

跳转表与实际代码分开,每个跳转地址需要四个字节,跳转表中存储当

输入某个选项(x)时,实际需要执行的代码的地址,

即使中间选项缺省,如只有12356选项和default也会给出4,4和default指向同一地址

AT&T语法中,间接寻址和间接跳转通常通过在操作数前使用*来表示 jmp *.L4(,%eax,4) jmp *0x8048680(, %eax,4)

*表示通过寄存器或内存中的值来间接确定跳转的目标地址,即先找到对应的跳转表地址,然后跳到跳转表中存储的代码块的地址处去执行下一步命令。

过程

在高级语言中称为函数

进入时分配空间,退出时释放空间

一般机器只提供转移控制到过程和从过程中转移出控制的简单指令

通过栈进行过程调用

机器用栈传递过程参数,存储返回信息,保存寄存器

函数或过程在执行时,需要在内存中分配一个空间来保存运行时数据,采用栈的方式进行操作,称为栈帧

栈帧连续,被调用者紧挨着调用者的栈帧

一个栈帧的开头放old ebp 之前栈帧的位置(之下的一段可能是保存的一些寄存器的值,便于结束调用后恢复寄存器),old ebp之上是返回地址,即调用结束返回后调用者的下一步执行的命令的地址,再上面就是参数(被调用者的)

先让esp和ebp指向同一个地方(关闭栈帧),然后弹出old ebp给到ebp(打开上一栈帧), (有时该栈帧存储了一些寄存器的值,需要先弹出)

然后需要弹出保存的返回地址给到PC %eip,保证回到应该执行的下一条命令(至此回到了上一栈帧)

数据

数组:

地址连续分配,连续声明的数组内存地址一般也是连续分配,

二维数组:T A[R][C],假设数据类型T需要K字节

大小为R*C*K,以行为主序

取指过程:M[A+i*(C*K)+j*K]=M[A+(i*C+j)*K]

对于定长数组编译器可以优化,提前移位计算C*K的值

嵌套数组:数组univ的元素是数组,可查找univ[i][j],K为单数据需要的字节

数组中存储的是一个数组指针,指向元素数组的首地址

取指过程:M[M[univ+K*i]+K*j],需要两次访问内存,第一次取指向元素数组的头指针,再次访问得到数据

结构(体)struct:

内存连续分配,可以包括不同数据类型的成员

结构名表明首地址,通过偏移量访问

结构需要对齐:便于访问

结构数组由于数组元素连续分配,需要对元素结构(起始地址)进行对齐

对齐的原则:

  1. 结构的首地址必须是最大元素字节数的整数倍;
  2. 结构中每个数据的地址必须是自身字节数的整数倍;
  3. 结构的总体长度必须是最大元素字节数的整数倍

可以考虑将大尺寸数据放在靠前的地方声明

联合union:

根据最大的元素来分配内存空间,一个空间,多种解释方式

—>用一段内存表示多种数据类型(便利换空间),对一个固定的二进制位向量做不同的类型解释

黑客攻击

SQL注入

修改可执行文件,改逻辑

改变程序返回地址

缓冲区溢出:恶意提供数据写入缓冲区,直到向上覆盖了old %ebp和返回地址

病毒

四、CPU原理

困,看不懂

从这一部分往下的课程就比较抽象概念化了,理解为主,涉及计算的方面比较固定

五、存储器层次结构

存储技术与发展

当懒狗了

局部性

局部性原理:一个编写良好的程序倾向于引用最近引用过的数据本身,或者引用的数据项邻近于其最近引用过的数据项

存储器层次结构

高速缓存

—小且快的SRAM存储器

结构与读写

结构(32位下) 高速缓存存储空间设计:存储分组,组中分行,行中分字节 (组选择,行匹配,字抽取)

根据组索引s判断组,通过标记t确定行,通过偏移量确定块的字节 —>右边这个很重要

一次存取、加工和传送的数据长度称为字word,一个字通常由多个字节构成

映射关系与分类

根据E(每个组的高速缓存行数)高速缓存被分为:

全相联高速缓存(只有一个组)

全相联映射:主存中的任一块可以被放置到Cache中的任意一个位置

直接映射(每个组只有一行的简单访问模式)

直接映射:主存中的每一块只能被放置到Cache中唯一的一个位置(循环分配)

E-组相联高速缓存(每组多于一行的高速缓存)

组相联映射:Cache先分组,主存中的每一块可以被放置到Cache中唯一的一个组中的任何一个位置

具体例子看婆婆特吧,反正就是些熟悉计算,理解一下相关参数

偏移量偏移一个地址(一个字节的数据)

为什么要中间位检索:充分利用空间局部性,详见ppt

最大限度地减少内循环中的不命中

重复引用变量是好的 (时间局部性)

步长为1的引用模式 (空间局部性)

多维数组的访问,注意使用行优先模式

性能

读吞吐率(读带宽):单位时间从存储器读出字节的数量 (MB/s)

提高空间局部性:重新排列循环(集中观察内循环,让步长尽可能小) 提高时间局部性:分块传输(保证工作集规模合理的小 (时间局部性))

六、虚拟存储器

基本概念

虚拟内存是存储在磁盘上的N个连续字节的数组 →作为缓存工具 作为内存管理、保护工具

页表是将虚拟页映射到物理页的页表项(page table entries,PTEs)的数组。

详见操作系统相关该部分

地址翻译

页大小P=64字节→vpo=ppo=log(64)=6,VPN=14-6,PPN=12-6

16个条目,4路组相连→TLBI=log(16/4)=2 TLBT=VPN=TLBI=6

CI=log16=4,CO=log4=2(图4,16 行, 每个高速缓存行4字节)

CT=12-4-2=6

内存映射

不是很懂,不管了

将虚存区域与磁盘对象相关联来初始化虚存区域

内存映射(Memory Mapping)是一种将文件或设备的内容映射到进程的地址空间的技术,允许程序通过直接访问内存来访问文件或设备。这种方法通过映射文件内容到虚拟内存的一部分来实现,从而程序可以像访问常规内存一样访问文件数据,而无需显式地执行读写操作。

按需调度页:在引用虚拟页面之前,不会将虚拟页面复制到物理内存中!

mmap

映射len个字节,从文件描述fd指定的文件的offset偏移处开始,最好是在地址start处(如果该处空间未使用)

返回一个指向映射区域开始的指针(可能不是start处)

七、优化程序性能

编译器局限性:安全优化对于已下情况不会优化

涉及到全局变量的函数调用

度量指标:

CPE (Cycles Per Element )每元素的周期数,执行1个元素需要多少个时钟周期。通俗讲:一个元素的运行时间

CPU完成一个最基本动作的时间(对应一个电平信号宽度)

从高级层面

代码移动:识别要执行多次但值不会改变的代码,将其移动到代码前部分,避免重

复求值。(编译器因为保守的优化策略,不能找到所有可以进行代码移动的函数)

减少函数调用:消除循环内的函数调用(可能有损模块化)

消除不必要存储器引用:定义一个局部变量,其在循环中累计值,在循环结束后再将值写入内存,从而消减不必要存储器引用。

现代处理器

标量:每周期只能执行一条指令,一般按程序指定的顺序执行(in-order)

一个超标量处理器可以在一个时钟周期内发出和执行多个指令。指令从顺序指令流中检索,通常是动态调度的(乱序执行out-of-order)优点:无需编程,超标量处理器可以利用大多数程序所具有的指令级并行性

如果各指令之间不存在相关性,那么它们在流水线中是可以并行执行的,这种指令间潜在的重叠就是指令级并行(Instruction-Level Parallelism, ILP)

乱序执行的核心在于分析并识别出指令间的数据依赖性,即某条指令的输出如何成为另一条指令的输入。处理器通过这种分析,可以确定哪些指令可以并行执行,哪些则必须等待先前的指令完成后才能执行。

从机器层面

延迟界限与吞吐量界限

数据流图

对关键路径进行优化。

循环展开

编译器可以自动对“整数”进行“重新结合变换”,GCC觉得可以做(并不总是做)优化,就会做这种优化

提高并行性

重新组合变换

总结

Amdahl 定律

系统的性能提升并不总是线性的,特别是当系统中存在无法通过增加资源来加速的部分时。因此,当我们考虑对系统进行优化时,需要全面考虑所有部分,而不仅仅是可以并行化的部分。这也强调了在设计高性能计算系统时,需要平衡并行化和串行处理的能力,以及合理分配资源。

八、链接

链接与目标文件

预处理 → 编译 → 汇编 → 链接:将多个可重定位文件及库例程(printf.o)链接起来,生成可执行文件

模块化(Modularity)程序不用写成一个巨大的源文件,而是可以分成多个更小、更好管理

的源文件

效率(Efficiency) 时间上: 分别编译;空间上: 使用库 ,无需包含共享库所有代码

链接即符号解析与重定位

三种目标文件(模块)

 可重定位目标文件 (.o file) 每个.o 文件由对应的.c文件生成,代码和数据地址都从0开始

共享的目标文件 (.so file) 在加载或运行时动态加载到存储器并链接 Windows 中叫做动态链接库(Dynamic Link Libraries, DLLs)

 可执行目标文件 (a.out file) 包含的代码和数据可以被直接复制到内存并被执行,代码和数据地址为虚拟地址空间中的地址

目标文件的标准二进制格式:可执行和可链接格式 (ELF) win用PE(可移植可执行)

可重定位目标文件

可执行目标文件

程序头表中包含了可执行文件中连续的片(chunk)如何映射到连续的存储段的信息。

符号解析

符号

Global symbols(模块内部定义的全局符号,不带static)

External symbols(外部定义的全局符号)

Local symbols(本模块的本地符号,带static)

链接器本地符号不是指局部变量,链接器不关心局部变量

static

C语言static修饰符的作用隐藏:把函数和变量隐藏在模块内部(C++/JAVA private)  存储区:把变量存放在静态存储区,具备持久性,默认值0 static函数  函数的定义和声明默认是extern。当定义成静态函数后只在当前文件(模块)中可见,不能被其他文件所用  其他文件中可以定义同名函数,不会发生冲突 static全局变量  存储区:.data或.bss,只初始化一次  作用域:定义该变量的源文件内有效, 在同一源程序的其它源文件中不可见(不能使用它)  其他文件中可以使用同名变量,不会发生冲突。 static局部变量  存储区:.data或.bss,只初始化一次,具备持久性(下一次依据上一次结果值 )  作用域:当前函数

符号表

ABS:不该被重定位的符号;UNDEF:未定义的,在本目标模块引用但是在其他地方定义

COMMON:未初始化的全局变量 .bss :未初始化的静态变量以及初始化为0的全局或静态变量

符号解析

将每个模块中引用的符号与某个目标模块中的符号定义建立关联

每个符号定义在代码节或数据节中都被分配了存储空间,将引用符号与对应符号定义建立关联后,就可在重定位时将引用符号的地址重定位为相关联的符号定义的地址。

“符号的定义”其实质是什么?

是指符号被分配了虚拟地址空间。

符号为函数名,即指明其代码所在区

符号为变量,即指明其占的静态数据区

全局符号的解析

编译时,编译器向汇编器输出的每个全局符号(强符号 弱符号

Strong:函数名和已初始化的全局变量名是强符号定义

Weak:未初始化的全局变量名是弱符号定义

规则:

Rule 1: 强符号不能多次定义

 强符号只能被定义一次

 否则链接错误

Rule 2: 强符号覆盖同名的弱符号

(若一个符号被定义为一次强符号和多次弱符号,则按强符号定义为准)

 对弱符号的引用被解析为其同名的强符号

Rule 3: 若有多个弱符号定义,则选择其中任意一个

 使用命令 gcc –fno-common链接时,告诉链接器在遇到多个弱符号定义的全局符号时输出一条警告信息。

多重定义符号解析

尽量避免使用全局变量,一定需要用的话,就按以下规则使用

 尽量使用本地变量:static

 定义全局变量要初始化

 引用外部全局变量:extern

重定位

定义

合并 定义位置修改 引用指向修改

符号定义进行重定位

 将所有目标模块中相同的节合并成新的聚合段,并将运行时的虚拟地址赋给每个新段中所

有定义的符号。

 例如,所有.text节合并为可执行文件中的.text段,并确定每个.text节在新.text段中的绝对

地址,从而为其中定义的每个函数确定首地址,进而确定每条指令的地址。

 完成这一步后,每条指令和每个全局变量都有唯一的运行时地址。

对节中的符号引用进行重定位

 修改.text节和.data节中对每个符号的引用(指向正确的运行时地址)。

 需要用到在.rel_data和.rel_text节中保存的重定位信息

R_386_PC32(相对寻址)

PC在当前指令的下一个

R_386_32(绝对寻址)

可执行目标文件的存储器映像

库 静态动态库

静态链接库

解析外部引用的链接器算法:

按照命令行顺序扫描.o 文件和.a 文件

在扫描过程中,维护一个当前未解析引用的列表U

遇到每个新的.o.a文件时,尝试根据目标和存档文件中定义的符号来解析列表中每个未解析的引用。

若在扫描结束时,未解析列表中仍有条目存在,则报错

命令行顺序很重要! 原则:将库文件放在命令行的末尾

动态共享库

导图

九、异常控制流

异常:硬件与操作系统内核软件

进程上下文切换:硬件计时器和内核软件

信号:内核软件和应用软件

多任务与外壳(Multitasking & shells)

在这个示例中,对于后台作业

终止后会变成僵尸进程;永远不会被回收,因为shell(通常)不会终止;产生内存泄漏,导致内核运行内存不足;现代 Unix: 一旦运行进程数超过配额,在shell中不能再运行任何新命令,即fork()函数此时会返回-1

解决方案:信号

内核通过更新目标进程的上下文的某些状态,实现从内核发送一个信号到目标进程(系统事件或显式调用kill)

接收信号的三种方式:

Ignore 忽略 这个信号 (什么也不做 )

Terminate 结束 这个进程 (可选core dump)

Catch 捕获 这个信号,通过执行用户级的函数信号处理程序(signal handler)

 类似于为响应异步中断而调用的硬件异常处理程序

信号被发出但没被接收,则该信号叫待处理信号(pending signal):对于任何类型的信号,最多只有一个该类型的待处理信号→信号不能排队

进程可以有选择性地阻塞 接收特定类型的信号(被阻塞信号可以被传送,但不会被接收)

信号基本概念

内核在每个进程的上下文中维护待处理pending阻塞blocked的位向量

非本地跳转:应用程序内

非本地跳转是一种在程序中从一个函数跳转到另一个函数,而不是按照通常的调用和返回顺序执行。这种跳转在C语言中通常通过 setjmplongjmp 函数实现。非本地跳转可以用来实现异常处理和恢复程序的控制流。

#include <stdio.h>
#include <setjmp.h>

jmp_buf jump_buffer;

void functionB() {
    printf("In functionB\n");
    longjmp(jump_buffer, 1); // 跳转回 setjmp 设置的点
    printf("This will never be printed\n");
}

void functionA() {
    printf("In functionA\n");
    functionB();
    printf("This will never be printed either\n");
}

int main() {
    if (setjmp(jump_buffer) == 0) {
        // 第一次调用 setjmp 返回 0
        printf("Calling functionA\n");
        functionA();
    } else {
        // 从 longjmp 跳转回来后执行这里
        printf("Back in main after longjmp\n");
    }
    return 0;
}

以堆栈规则序列工作