The LLVM memory model defines a strict load/store architecture where computation only happens in virtual registers, while calling conventions dictate how arguments are passed, return values are handled, and stack memory is managed across function boundaries. Together, they bridge the gap between high-level language semantics and target-specific machine code.
LLVM 的内存模型采用了典型的Load/Store 架构,计算指令只能操作虚拟寄存器,而可变数据必须存储在内存中;同时,LLVM 提供了丰富的调用约定和参数属性,用于精确控制函数跨界调用时的底层行为。
LLVM 内存模型核心机制
在 LLVM IR 的 SSA(静态单赋值)规则下,虚拟寄存器一旦赋值就无法更改。为了支持高级语言中“可重新赋值”的局部变量,LLVM 必须借助内存来完成。
核心循环
高级语言中对局部变量的每次修改,在未开启优化的 LLVM IR 中,都会被严格拆分为三个步骤:
Load:从内存中读取当前值到虚拟寄存器
Compute:在虚拟寄存器之间进行计算,产生新的虚拟寄存器
Store:将计算结果的虚拟寄存器写回内存
如下 C 语言程序:
int main() {
int a = 5;
a = a * 2;
}
对应的 IR:
define dso_local i32 @main() #0 {
; 1. 分配阶段 (Allocation)
%1 = alloca i32, align 4 ; 在栈上分配 4 字节,地址存入 %1 (代表变量 a)
; 2. 初始化阶段
store i32 5, ptr %1, align 4 ; 将常量 5 存入 %1 指向的内存
; 3. Load-Compute-Store 循环
%2 = load i32, ptr %1, align 4 ; [Load] 从 %1 读取值到 %2 (此时 %2 = 5)
%3 = mul nsw i32 %2, 2 ; [Compute] %2 乘以 2,结果存入 %3 (此时 %3 = 10)
store i32 %3, ptr %1, align 4 ; [Store] 将 %3 的值写回 %1 指向的内存 (更新变量 a)
ret i32 0
}alloca:专门用于在当前函数的栈帧上分配内存。当函数返回时,alloca分配的内存会自动释放,无需手动管理。前端(如 Clang)通常会将所有的alloca指令集中放在函数的入口基本块中。
复杂内存寻址
在内存模型中,GEP 是 LLVM 计算内存偏移量的唯一标准方式。
GEP 只做算术运算,绝对不会访问内存,它可以理解为 C 语言中
&struct->field的底层抽象。
观察如下带有数组的结构体 C 代码:
struct Data {
int id;
int array[4];
};
int main() {
struct Data d;
d.array[2] = 99;
}翻译为 IR:
%struct.Data = type { i32, [4 x i32] }
define dso_local i32 @main() #0 {
%1 = alloca %struct.Data, align 4
; GEP 计算数组中索引为 2 的元素的物理地址
; 第一个 i32 0: 跨过结构体本身的基指针(解引用)
; 第二个 i32 1: 进入结构体的第 2 个成员(即 [4 x i32] array)
; 第三个 i32 2: 访问数组的第 3 个元素(索引为 2)
%2 = getelementptr inbounds %struct.Data, ptr %1, i32 0, i32 1, i32 2
; 对计算出的物理地址进行写入
store i32 99, ptr %2, align 4
ret i32 0
}GEP 的索引设计极大地方便了后端的类型安全检查和目标机器码的偏移量计算。
调用约定
调用约定决定了函数在汇编层面是如何交互的。LLVM 提供了高度灵活的调用约定声明。
常见的调用约定标识
ccc(C Calling Convention):默认的 C 语言调用约定。参数通常通过寄存器传递,多余的放入栈中。由调用者(Caller)或被调用者(Callee)清理栈空间取决于具体目标平台(如 x86 的 cdecl)。fastcc(Fast Calling Convention):尽可能将所有参数放入物理寄存器传递,避免使用栈内存,以达到最快调用速度。常用于编译器内部优化的静态函数,不建议用于导出 API。coldcc(Cold Calling Convention):假设该函数极少被调用(如发生异常或错误处理时)。编译器会对其进行特殊的代码布局优化,以避免占用热点缓存(I-Cache)。tailcc(Tail Call):强制支持尾调用优化,确保调用时不增加新的栈帧。
参数与返回值属性
LLVM 允许为函数的参数和返回值添加特定的属性标签,以指导后端的内存分配策略。
大型结构体传参:byval 属性
在 C 语言中,如果将一个结构体按值传递给函数,意味着需要拷贝整个结构体。LLVM 使用 byval 属性来实现这一语义,避免前端生成冗长的 memcpy。
struct BigStruct { int data[100]; };
void process(struct BigStruct b) { }
int main() {
struct BigStruct myStruct;
process(myStruct);
}生成的 IR:
%struct.BigStruct = type { [100 x i32] }
; 参数被标记为 ptr byval(%struct.BigStruct)
define dso_local void @process(ptr noundef byval(%struct.BigStruct) align 4 %0) #0 {
ret void
}
define dso_local i32 @main() #0 {
%1 = alloca %struct.BigStruct, align 4
; 调用时,后端会自动在此处插入代码,将 %1 的内容拷贝一份到新的栈空间传给 process
call void @process(ptr noundef byval(%struct.BigStruct) align 4 %1)
ret i32 0
}byval(<类型>):告诉 LLVM 后端,虽然这里传的是个指针ptr,但它的语义是按值传递。后端负责在被调用函数的栈帧中克隆一份隐藏的副本,被调用者对它的修改不会影响调用者。
2大型结构体返回:sret 属性
在寄存器大小受限(如 64位 / 8字节)的 CPU 上,函数无法直接通过物理寄存器(如 rax)返回几百字节的结构体。LLVM 通过 sret 属性将“返回值”转换为“隐藏的指针参数”。
struct BigStruct createStruct() {
struct BigStruct b;
return b;
}生成的 IR:
; 注意:返回值类型变成了 void!原来的返回值变成了第一个参数,带有 sret 标记
define dso_local void @createStruct(ptr noalias sret(%struct.BigStruct) align 4 %0) #0 {
; 直接将生成的数据写入 %0 指向的内存(这是调用者预先分配好的空间)
ret void
}sret(<类型>):由调用者在自己的栈上分配好空间,并将地址作为第一个隐藏参数传给函数。函数内部直接向该地址写入数据,从而避免了大型对象的二次拷贝。
其他常见修饰符
noundef:明确声明该参数或返回值不可能是未定义值(如未初始化的垃圾内存),辅助优化器放心地进行依赖分析。zeroext/signext:在传参时对较小的整数(如i8、i16)进行零扩展或符号扩展,以匹配目标平台寄存器位宽(例如将 8 位参数扩展为 32 位放入寄存器)。nonnull:承诺该指针参数绝对不会是null,如果运行时传入null则是未定义行为(UB)。以下是一个组合了多种调用约定和内存属性的极致例子:
; 使用 fastcc 约定,返回一个不可为 null 的指针
; 第一个参数是零扩展的 i8,第二个参数是指向 i64 的只读且不为空的指针
define fastcc nonnull ptr @complex_call(i8 zeroext %a, ptr nonnull readonly %b) {
; ...
}