在现实和历史中漂流 [index]
在现实和历史中漂流 [index]
所有文章
PL. LLVMLite 文档 [posts/LLVMlite_Document]
- 2025-06-13
PL. LLVMLite 文档 [posts/LLVMlite_Document]
- 2025-06-13
LLVMlite 是 Upenn CIS314(也是上科大 CS131)Compiler 课程中定义的 LLVM IR 的简化子集。它保留了 LLVM 的核心要素,挺适合用作初学者写编译器的目标语言。这篇文章是 LLVMlite 规范的简要记录。
第一感觉
下面是 LLVMlite 的一个例子,可以看到一些基本元素和 LLVM 是一致的。
这个例子也能通过 llc 编译为 ISA 对应的汇编或 clang 直接编译为可以运行的 ELF。
clang demo.ll -o demo && ./demo && echo $?
可以得到结果是 208(在 Bash 中返回值是 uint8_t,fac(6) = 720 截断之后就是 208)。
; demo.ll
define i64 @fac(i64 %n) { ; (1)
%1 = icmp sle i64 %n, 0 ; (2)
br i1 %1, label %ret, label %rec ; (3)
ret: ; (4)
ret i64 1
rec: ; (5)
%2 = sub i64 %n, 1 ; (6)
%3 = call i64 @fac(i64 %2) ; (7)
%4 = mul i64 %n, %3
ret i64 %4 ; (8)
}
define i64 @main() { ; (9)
%1 = call i64 @fac(i64 6)
ret i64 %1
}
全局变量名以 @ 开始,局部变量名以 % 开始,类型别名以 % 开始。函数定义 define 和各种指令(icmp sle、sub、call)都带类型。整体来说是和 C 差不多的弱类型,即有基本的类型检查,但是可以用 bitcast 指令强制转换。
数据类型
数据类型整体上可以分为单元类型、简单类型、聚合类型和函数类型,具体语法如下所示:
| 具体语法 | 种类 | 说明 |
|---|---|---|
void | 单元类型 | 就是 OCaml 里的 unit,注意不是 Java 的 null |
i1,i8,i64 | 简单类型 | 表示固定宽度的整数,LLVMlite 限制了只有这三种,LLVM 没有宽度限制 |
T* | 简单类型 | 指向类型 T 的指针类型 |
F* | 简单类型 | 指向函数 F 的指针类型 |
S(S1, ... SN) | 函数类型 | 参数为 S1 到 SN,返回值为 S 的函数 |
{ T1, ..., TN } | 聚合类型 | 类型 T1 到 TN 构成的元组 |
[ N x T ] | 聚合类型 | N 个 T 类型构成的数组 |
%NAME | * | 命名类型定义,也就是给某个类型取的别名 |
其中命名类型定义的具体语法是 %IDENT = type T,T 中可以提到 %IDENT,但是不允许循环类型,也就是说如果提到 %IDENT 则必须出现在指针 * 下。
下面是对应的 OCaml ADT 表示
(* Local identifiers *)
type uid = string
(* Global identifiers *)
type gid = string
(* Named types *)
type tid = string
(* LLVM types *)
type ty =
| Void
| I1
| I8
| I64
| Ptr of ty
| Struct of ty list
| Array of int * ty
| Fun of ty list * ty
| Namedt of tid
有一个需要注意的点:LLVMlite 规定只有简单类型才可以出现在栈中或者是作为函数的参数,而聚合类型只能出现在全局和(运行时分配的)堆内存中,在栈上只能存一个指针。也就是说,简单类型可以 alloca 但是聚合类型不能 alloca(这一点和完整的 LLVM 不一样,完整的 LLVM 是可以 alloca 一个聚合类型的)。这个限制是简化目的,对于所有的聚合类型就要在运行时实现一个 malloc 之类的函数来处理分配了;结构体也不能像 C 语言那样整个拷贝传值调用,而是只能传一个指针。
程序结构
LLVMlite 程序由(1)全局函数声明;(2)全局数据声明;(3)类型别名声明;(4)外部符号声明四部分构成。所有顶层定义都是互递归的。下面是这四部分的 OCaml 数据结构。这里我们关注 prog 这个类型,是把顶层声明的绑定到名字上,有点像是可以创造出一个 ctxt 之类的东西用来做查询(实际上 LLVMlite 编译到 ISA 的时候也的确会用到)。
(* Function type: argument types and return type *)
type fty = ty list * ty
(* Control Flow Graphs: entry and labeled blocks *)
type cfg = block * (lbl * block) list
(* Function Declarations *)
type fdecl = { f_ty : fty; f_param : uid list; f_cfg : cfg }
(* Global Data Initializers *)
type ginit =
| GNull
| GGid of gid
| GInt of int64
| GString of string
| GArray of (ty * ginit) list
| GStruct of (ty * ginit) list
| GBitcast of ty * ginit * ty
(* Global Declarations *)
type gdecl = ty * ginit
(* LLVMlite Programs *)
type prog = { tdecls : (tid * ty) list;
gdecls : (gid * gdecl) list;
fdecls : (gid * fdecl) list;
edecls : (gid * ty) list }
下面我们依次关注 prog 的各个字段。
类型别名声明
{ tdelcs : (tid * ty) list; ... } 是把类型绑定到一个 tid 上。具体语法如下所示:
%foo = type i64
%bar = type [ 8 x i64 ]
假设上层语言有一个 typealis Foo = Bar 之类的代码,我们可以编译成一个 [ "Foo", cmp_ty (Namedt "Bar") ] 把右边编译好的类型绑定到 "Foo" 这个名字上。
外部符号声明
{ edecls: (gid * ty) list; ... } 在 LLVMlite 中的具体语法有下面的例子:
declare i32 @puts(i8*)
declare i32 @some_extern_func(i32, [5 x i64], i8*)
可以用来编译上层语言的外部声明之类。也可以用来声明上层语言的内建函数,实现可以放到运行时里面用其他语言来生成,只要链接时对上签名 ABI 就行。
全局数据声明
{ gdecls: (gid * gdecl) list; ... },然后回顾一下 gdecl 的类型是 type gdecl = ty * ginit,也就是类型及其初始值。ty 和 ginit 需要手动对上,后者有下面的几种可能:
(* Global Data Initializers *)
type ginit =
| GNull
| GInt of int64
| GString of string
| GGid of gid
| GArray of (ty * ginit) list
| GStruct of (ty * ginit) list
| GBitcast of ty * ginit * ty
全局声明的具体语法是:
@foo = global i64 42
@bar = global i64* @foo
@baz = global i64** @bar
下面的表列出了各种 ginit 的具体语法:
| 具体语法 | 类型 | 说明 |
|---|---|---|
null | T* | 空指针常量 |
[0-9]+ | i64 | 64 位有符号整数字面量 |
c"[A-z]*\00" | [N x i8] | 字符串字面量,长度 N 包括结尾的空字符 |
@IDENT | T* | 全局标识符,注意始终是指针 |
[ T G1, ..., T GN ] | [ N x T ] | 数组字面量 |
{ T1 G1, ..., TN GN } | { T1,...,TN } | 结构体字面量 |
bitcast (T1* G1 to T2*) | T2* | 重新解释比特的类型 |
全局函数声明
{ fdecls: (gid * fdecl) list; ... },其中 type fdecl = { f_ty : fty; f_param : uid list; f_cfg : cfg }。函数声明的具体语法如下所示:
define i64 @fac(i64 %n) {
%1 = icmp sle i64 %n, 0
br i1 %1, label %ret, label %rec
ret:
ret i64 1
rec:
%2 = sub i64 %n, 1
%3 = call i64 @fac(i64 %2)
%4 = mul i64 %n, %3
ret i64 %4
}
函数内部是由基本块组成的。每个基本块(除了函数的第一个基本块)开头都有一个标签,内部都是普通指令,没有跳转指令,最后是一条跳转指令。
在 LLVMlite 中要求第一个基本块没有标签,在 LLVM 中第一个基本块的标签是可选的,如果没有写的话会默认分配一个标签。
指令的操作数如下所示:
| 具体语法 | 类型 | 描述 |
|---|---|---|
null | T* | 空指针常量 |
[0-9]+ | i64 | 64 位整数字面量 |
@IDENT | T* | 全局标识符,注意始终是指针 |
%IDENT | S | 局部标识符,只能是简单类型的值 |
对应的 OCaml 语法是:
(* Syntactic Values *)
type operand =
| Null
| Const of int64
| Gid of gid
| Id of uid
基本指令包括:
| 具体语法 | 操作数 → 结果类型 |
|---|---|
%L = BOP i64 OP1, OP2 | i64 x i64 -> i64 |
%L = alloca S | - -> S* |
%L = load S* OP | S* -> S |
store S OP1, S* OP2 | S x S* -> void |
%L = icmp CND S OP1, OP2 | S x S -> i1 |
%L = call S1 OP1(S2 OP2, ..., SN OPN) | S1(S2, ..., SN)* x S2 x ... x SN -> S1 |
call void OP1(S2 OP2, ... ,SN OPN) | void(S2, ..., SN)* x S2 x ... x SN -> void |
%L = getelementptr T1, T1* OP1, i32 OP2, ..., i32 OPN | T1* x i64 x ... x i64 -> GEPTY(T1, OP1, ..., OPN)* |
%L = bitcast T1* OP to T2* | T1* -> T2* |
基本指令的 OCaml 表示如下所示:
(* Instructions *)
type insn =
| Binop of bop * ty * operand * operand
| Alloca of ty
| Load of ty * operand
| Store of ty * operand * operand
| Icmp of cnd * ty * operand * operand
| Call of ty * operand * (ty * operand) list
| Bitcast of ty * operand * ty
| Gep of ty * operand * operand list
(Load 的 ty 指的是 S*;Store 的 ty 是 S,第一个 operand 存到第二个 operand 里;Bitcase 是左边的 ty cast 成右边的 ty)
终结指令包容:
| 具体语法 | 操作数 → 结果类型 |
|---|---|
ret void | - -> - |
ret S OP | S -> - |
br label %LAB | - -> - |
br i1 OP, label %LAB1, label %LAB2 | i1 -> - |
终结指令的 OCaml 表示如下所示:
type terminator =
| Ret of ty * operand option
| Br of lbl
| Cbr of operand * lbl * lbl
关于 SSA
目前 LLVMlite 里面还没有引入 phi 语句,因此要处理上层语言 mutable 的情况,只能用 alloca 和 store 开洞。但是这个问题不大,因为生成的 LLVMlite 代码和 LLVM 是兼容的,当作 LLVM 走一下 opt 就优化成带有 phi 节点了,然后就可以做 SSA 的优化和生成。
HW. SystemVerilog 练习 [posts/SystemVerilog_Exercise]
- 2025-06-01
HW. SystemVerilog 练习 [posts/SystemVerilog_Exercise]
- 2025-06-01
前两天看完了《使用 SystemVerilog 进行 RTL 建模》这本书,算是第一次系统地学了一遍。最近也回顾了刚上研究生时候写的 EE218-VLSI 课程项目,觉得可以用 SystemVerilog 重写一遍作为练习。于是在这里记录下项目规约和实现。
项目规约
实现一个 MLP 单元,这个单元有 8 层,每层 16 * 16 的权重($W_0 ... W_7$),接收一个 16 * 16 的输入 $I$,计算 $W_7 \times ... \times W_0 \times I$。
端口定义为:
start_ready,1 位输出,表示模块准备好,可以加载输入并开始一轮计算任务start_valid,1 位输入,表示启动计算任务,在之后(从下一个周期开始)16 * 16 个周期顺序加载输入矩阵 $I$,然后开始计算init_ready,1 位输出,表示模块准备好,可以加载权重init_valid,1 位输入,表示启动权重加载,在之后(从下一个周期开始)16 * 16 * 8 个周期顺序加载权重 $W_0 ... W_7$load_payload,16 位输入,用于加载输入和权重矩阵result_valid,1 位输出,表示当前周期输出的数据有效result_payload,16 位输出,表示 MLP 输出的计算结果
其他要求为:
- 所有的输入和输出都是 16 位定点数(7 位整数,9 位小数)。中间计算步骤需要保证全精度,结果先对小数部分使用就近四舍五入,然后对整数部分使用饱和操作处理溢出。
- 输入和输出带宽不超过 32 bits 每 cycle。
- 提供了字宽为 16 和 32 的,深度为 128、256、512 和 1024 的,单端口和伪双端口的 SRAM 硬核,及相应的 RTL 行为模型。
RTL 行为模型如下两个例子所示,其他 RAM 实例的名称和位宽自行类比:
// 深度 1024 字宽 32 伪双端口
module sramTpw1024d32 (
input wire [31:0] D,
input wire [ 9:0] ADRw,
input wire MEw,
output wire [31:0] Q,
input wire [ 9:0] ADRr,
input wire MEr,
input wire clk
);
reg [31:0] tmp_Q;
reg [31:0] sramTp[0:1023];
always @(posedge clk) begin
if (MEw) begin
sramTp[ADRw] <= D;
end
if (MEr) begin
tmp_Q <= sramTp[ADRr];
end
end
assign Q = tmp_Q;
endmodule
// 深度 128 字宽 32 单端口
module sramSpw128d32 (
output wire [31:0] Q,
input wire [ 6:0] ADR,
input wire [31:0] D,
input wire WE,
input wire ME,
input wire clk
);
reg [31:0] tmp_Q;
reg [31:0] sramSp[0:127];
always @(posedge clk) begin
if (ME) begin
tmp_Q <= sramSp[ADR];
end
if (ME && WE) begin
sramSp[ADR] <= D;
end
end
assign Q = tmp_Q;
endmodule
项目提供了一些测试数据,由于以练习 SystemVerilog 为目的,只实现 RTL 模型即可,不用做后端。使用 Icarus Verilog 和 ADM/Xilinx Vivado 分别作为开源和闭源的仿真工具。使用 Yosys-STA 项目评估时序。
项目实现
代码见仓库 SystemVerilog-Exercise。
Copyright (c) 2024-2026 Guiyuan Zhu <zhugy2022@qq.com>. All rights reserved.
Powered by Kodama.