Day 1:编译器全景 + LLVM IR 基础

目标:建立编译器全流程概念,搭好 LLVM 环境,深入理解 IR、SSA、CFG 三大核心。今天学完你应该能:看懂任意 LLVM IR、手绘 CFG、解释 SSA 的 φ 节点、独立搭建 LLVM 开发环境。


1. 编译器的阶段划分(龙书第 1 章)

1.1 编译器是一个翻译流水线

编译器不是"一个程序",而是一组阶段的串联。每个阶段把程序从一种表示翻译成下一种:

源程序(字符流)
    │
    ▼
┌──────────┐
│ 词法分析  │  把字符切成 Token: "x=42" → IDENT(x) ASSIGN NUMBER(42)
└──────────┘
    │ Token流
    ▼
┌──────────┐
│ 语法分析  │  把 Token 建成树: 生成 AST (Abstract Syntax Tree)
└──────────┘
    │ AST
    ▼
┌──────────┐
│ 语义分析  │  类型检查、作用域解析、符号表填充
└──────────┘
    │ 标注的AST
    ▼
┌──────────┐
│中间代码生成│  AST → 三地址码 / LLVM IR (与语言无关,与机器无关)
└──────────┘
    │ IR
    ▼
┌──────────┐
│ 代码优化  │  对 IR 做机器无关优化(死代码消除、循环优化...)
└──────────┘
    │ 优化后的IR
    ▼
┌──────────┐
│ 代码生成  │  IR → 目标机器码(指令选择、寄存器分配、指令调度)
└──────────┘
    │ 汇编/机器码
    ▼
  目标程序

核心认知:三个阶段构成三段式设计(Three-Phase Design):

前端(语言相关)       中端(机器无关优化)      后端(机器相关)
    C ─┐                    ┌── x86
  C++ ─┤                    ├── ARM
 Rust ─┼──→ LLVM IR ──→  优化的LLVM IR  ──→├── RISC-V
Swift ─┤                    ├── BPF
  ... ─┘                    └── ...

这就是为什么 LLVM 能支持几十种语言和几十种 CPU——前端和后端完全解耦,它们只通过 IR 交流。给 LLVM 添加一个新语言只需要写前端,添加一个新 CPU 只需要写后端,中端优化对所有人免费。

1.2 符号表——贯穿全程的核心数据结构

每个阶段都可能用到符号表:

// 符号表条目的核心字段
struct Symbol {
    char *name;        // "x"
    Type *type;        // int
    Scope *scope;      // 全局 / 函数内 / 块内
    union {
        int  int_val;     // 如果编译期能确定值
        int  offset;      // 栈帧中的偏移(后端填入)
        void *llvm_val;   // LLVM Value 指针(中端填入)
    };
};

词法分析器往符号表里插入名字,语义分析器往符号表里写入类型,代码生成器往符号表里写入栈偏移。三个阶段通过同一个符号表传递信息——这就是龙书说的"符号表贯穿全程"。

1.3 遍(Pass)的概念

一个"遍" = 从头到尾读完整个中间表示并做某件事。编译器通常把多个阶段组织为若干遍:

-O0:  前端 → 后端                              (越快越好,不做优化)
-O2:  前端 → mem2reg → GVN → LICM → ... → 后端 (几十个优化遍)

每个 Pass 只做一件事。LLVM 的 Pass 体系(New PassManager)支持:

  • 分析 Pass:计算信息供其他 Pass 用(支配树、循环信息、活跃变量…)
  • 变换 Pass:改写 IR(死代码消除、内联、循环展开…)

一个关键设计:分析 Pass 的结果可以被缓存。比如支配树被 10 个变换 Pass 用到,它只算一次——PassManager 负责管理依赖和失效。


2. LLVM 环境搭建

2.1 安装 LLVM

Windows (推荐通过包管理器):

# 用 winget
winget install LLVM.LLVM

# 或者 choco
choco install llvm

Linux (Ubuntu):

sudo apt install llvm-18 clang-18 lld-18
# 或者下载预编译二进制: https://github.com/llvm/llvm-project/releases

macOS:

brew install llvm@18

安装完验证:

clang --version    # 前端编译器
opt --version      # IR 优化器
llc --version      # 静态编译器(IR→汇编)
lli --version      # IR 解释器/JIT执行器
llvm-as --version  # IR文本→IR二进制
llvm-dis --version # IR二进制→IR文本

2.2 第一个 LLVM IR:从 C 到 IR

写一个最小的 C 程序:

// hello.c
int add(int a, int b) {
    return a + b;
}

生成并阅读 IR:

# -S: 输出文本; -emit-llvm: 输出 LLVM IR; -O0: 不做优化
clang -S -emit-llvm -O0 hello.c -o hello.ll
cat hello.ll

生成的 LLVM IR(手动精简注释版):

; Module 级别信息
; 注意: 下面展示的是 -O0 的 IR(内存形式),并非真正的 SSA

define i32 @add(i32 %a, i32 %b) #0 {
entry:
  ; alloca = 在栈上分配空间。这是 C 变量 "a" 和 "b" 在栈上的家
  %a.addr = alloca i32, align 4     ; 给参数 a 分配栈空间
  %b.addr = alloca i32, align 4     ; 给参数 b 分配栈空间

  ; 把函数参数存到栈上(因为 -O0 不做优化,每个变量都必须有栈地址)
  store i32 %a, ptr %a.addr, align 4
  store i32 %b, ptr %b.addr, align 4

  ; 从栈上加载变量值到临时寄存器
  %0 = load i32, ptr %a.addr, align 4
  %1 = load i32, ptr %b.addr, align 4

  ; 真正的加法
  %add = add nsw i32 %0, %1

  ; 返回结果
  ret i32 %add
}

; 属性信息(内联策略、栈保护等)
attributes #0 = { noinline nounwind ... }

关键观察:-O0 下的 IR 把每个 C 变量映射到一块栈内存(alloca),每次读写都显式 load/store。这不是真正的 SSA——%a.addr 这个指针才是 “变量”,它可以被 store 多次;SSA 约束仅作用于虚拟寄存器,栈内存属于可改写存储,不受 SSA 规则管控,必须经由 Mem2Reg 优化消除内存操作,才能将 C 语言变量转化为原生 SSA 值。

现在用 mem2reg 提升为真正的 SSA:

# 跑 mem2reg pass,把 alloca/load/store 提升为 SSA 寄存器
opt -passes=mem2reg hello.ll -S -o hello_ssa.ll
cat hello_ssa.ll
define i32 @add(i32 %a, i32 %b) #0 {
entry:
  ; alloca/load/store 全部消失了!
  ; %a 和 %b 直接就是 SSA 寄存器——不需要栈
  %add = add nsw i32 %a, %b
  ret i32 %add
}

从 5 条指令变成 1 条。这就是 mem2reg 的价值,也是理解 SSA 的关键:当你用 alloca 模拟可变变量时,mem2reg 能自动分析并消除这些冗余操作。

2.3 完整的编译管线演练

# 一条命令走完所有阶段
clang hello.c -S -emit-llvm -o hello.ll        # C → LLVM IR (文本)
opt hello.ll -S -passes=mem2reg -o hello_ssa.ll # IR → 优化IR
llc hello_ssa.ll -o hello.s                      # 优化IR → 汇编
clang hello.s -o hello                            # 汇编 → 可执行文件
./hello                                            # 运行

# 或者一步到位:
clang hello.c -O2 -o hello && ./hello

# 查看每个优化阶段的 IR:
clang hello.c -S -emit-llvm -O2 -o -              # 最终优化结果
clang hello.c -mllvm -print-after-all -O2 2>&1 | less  # 每步的IR变化

3. LLVM IR 深度解析(LLVM 书第 2 章)

3.1 LLVM IR 的三种形态

内存表示(C++对象)  ← 编译器内部操作,各种 Pass 读写的形态
     │  llvm::WriteBitcodeToFile()
     ▼
位码(.bc 文件)     ← 二进制格式,紧凑高效,适合 linker 间传递
     │  llvm-dis
     ▼
文本 IR(.ll 文件)  ← 人类可读,调试和学习用

实际工作中:

  • 写 Pass → 操作内存表示(llvm::Instruction, llvm::BasicBlock 等 C++ 对象)
  • 调试验证 → 看 .ll 文件,或在 Pass 中 F.dump() 打印 IR
  • LTO 链接时优化 → 用 .bc 文件传递

3.2 LLVM IR 的类型系统

; 基础类型
i1          ; 1 位整数(布尔值)
i8          ; 8 位整数(char)
i32         ; 32 位整数(int)
i64         ; 64 位整数(long long)
float       ; 32 位浮点
double      ; 64 位浮点
void        ; 无类型(用于无返回值的函数)

; 指针类型
ptr         ; 不透明指针(LLVM 15+ 默认,不指定指向什么)
i32*        ; 指向 i32 的指针(LLVM 14 及之前)

; 聚合类型
[i32 x 4]           ; 4 个 i32 的数组
{i32, double, i8}   ; 结构体
<4 x float>         ; 4 个 float 的向量(SIMD 用)

; 函数类型
i32 (i32, i32)      ; 接受两个 i32,返回 i32 的函数类型

3.3 SSA(静态单赋值)—— LLVM IR 的灵魂

问题:为什么要 SSA?

传统三地址码中,一个变量可以被赋值多次:

// 传统三地址码
x = 1        // 定义 1
...
x = x + 1    // 定义 2 —— x 现在指向另一个值了
y = x * 2    // 用的是定义 2 的 x

编译器想回答"y 用了哪个 x 的值?"就必须做复杂的 use-def 分析。

SSA 解决这个问题:每个变量只被赋值一次,所以每个 usedef 是明确的——不需要分析,直接就是指针。

; SSA 形式
%x1 = 1          ; 定义 1 —— 永远不会被"覆盖"
...
%x2 = add %x1, 1 ; 定义 2 —— 全新变量
%y  = mul %x2, 2 ; 用的绝对是 %x2
从普通代码构造 SSA:两个问题要解决

问题 1:同一个变量在不同路径赋值

if (cond) {
    x = 1;        // 路径 A
} else {
    x = 2;        // 路径 B
}
// x 应该是什么?
use(x);            // 到达这里时,x 可能来自路径 A 或 B

解决方案:φ 函数(phi function):

entry:
  br i1 %cond, label %then, label %else

then:                               ; 路径 A
  br label %merge

else:                               ; 路径 B
  br label %merge

merge:
  %x = phi i32 [ 1, %then ], [ 2, %else ]   ; φ 节点!
  ; phi i32 的意思是:我是 i32 类型
  ; [1, %then] 的意思是:如果从 %then 来,我的值是 1
  ; [2, %else] 的意思是:如果从 %else 来,我的值是 2
  call void @use(i32 %x)

φ 的语义:根据实际执行路径选择对应值。在合并点,φ 节点同时存在多个可能的来源,但它是一个确定性选择——走到 merge 时,具体从哪个前驱来的是已知的。

问题 2:循环中的变量

int i = 0;
while (i < 10) {
    i = i + 1;      // i 被重新赋值了!
}
entry:
  br label %loop

loop:
  %i = phi i32 [ 0, %entry ], [ %i.next, %loop ]  ; 关键!
  ; 第一次从 entry 来 → i = 0
  ; 之后从 loop 来 → i = %i.next(上一轮迭代的值)
  %cond = icmp slt i32 %i, 10
  br i1 %cond, label %body, label %exit

body:
  %i.next = add nsw i32 %i, 1      ; i + 1 = 新定义(SSA 要求的唯一赋值)
  br label %loop                    ; 跳回 loop,%i.next 成为 phi 的新参数

exit:
  ret i32 %i

这段 IR 每行理解

  1. %i = phi i32 [ 0, %entry ], [ %i.next, %loop ] → 循环的入口值
  2. %i.next = add nsw i32 %i, 1 → 迭代后的新值,这是 SSA 中唯一的"i+1"定义
  3. br label %loop → 跳回去,此时 phi 节点选 [ %i.next, %loop ]
  4. 循环中的 %i 从不被修改,每次迭代产生新的 %i.next——这就是 SSA 的本质

3.4 基本块(Basic Block)与控制流图(CFG)

定义

基本块 = 一段直线代码,只有第一条指令是入口,只有最后一条指令是出口(terminator)。

; 这是一个基本块
%0 = load i32, ptr %p
%1 = add i32 %0, 1
store i32 %1, ptr %p
br label %next             ; terminator 指令 —— 必须是块内最后一条

Terminator 指令:LLVM 中只有以下几种可以结束基本块:

指令含义
br label %target无条件跳转
br i1 %cond, label %true, label %false条件跳转
switch i32 %val, label %default [ i32 1, label %case1 ... ]多路跳转
ret i32 %val返回
ret void无值返回
unreachable不可达(UB 触发后的标记)
invoke带异常处理的调用
画 CFG

控制流图(CFG)= 节点是基本块,边是跳转

int abs(int x) {
    if (x >= 0)
        return x;
    else
        return -x;
}

LLVM IR 和对应的 CFG:

define i32 @abs(i32 %x) {
entry:
  %cmp = icmp sge i32 %x, 0        ; x >= 0 ?
  br i1 %cmp, label %if.then, label %if.else

if.then:
  ret i32 %x                        ; 返回 x

if.else:
  %neg = sub i32 0, %x              ; 0 - x = -x
  ret i32 %neg
}

CFG(ASCII 图):

      [entry]
     x >= 0?
    /        \
  true      false
  /            \
[if.then]    [if.else]
ret x         %neg=0-x
              ret %neg

一个稍复杂的 CFG(循环)

int sum(int n) {
    for (int s = 0,i = 1; i <= n; i++)
        s += i;
    return s;
}
define i32 @sum(i32 %n) {
entry:
  br label %for.cond                     ; 进入循环条件判断

for.cond:                                ; 循环头
  %s = phi i32 [ 0, %entry ], [ %s.next, %for.inc ]
  %i = phi i32 [ 1, %entry ], [ %i.next, %for.inc ]
  %cmp = icmp sle i32 %i, %n
  br i1 %cmp, label %for.body, label %for.end

for.body:                                ; 循环体
  %s.next = add nsw i32 %s, %i
  br label %for.inc

for.inc:                                 ; i++ 块
  %i.next = add nsw i32 %i, 1
  br label %for.cond                     ; 回边!

for.end:                                 ; 循环出口
  ret i32 %s
}

CFG:

      [entry]
         │
         ▼
    ┌[for.cond]◄──────┐
    │ s=φ(0,s.next)    │
    │ i=φ(1,i.next)    │   回边
    │ i <= n?          │
    └──┬──────┬──────┘
      true    false
       │        │
       ▼        ▼
   [for.body]  [for.end]
   s.next=s+i   ret s
       │
       ▼
   [for.inc]
   i.next=i+1
       │
       └──────────┘

手动画 CFG 的技巧

  1. 每个 label 定义一个新基本块的入口
  2. 每条 br 是基本块的出口
  3. 从 entry 开始,顺着 br 画箭头
  4. 回边(朝循环头方向的边)就是循环结构

3.5 LLVM IR 指令全览

下面列出你会在 Day2-Day4 中最常遇到的指令。每条都值得记:

; === 终结符指令(总是块的最后一条) ===
br label %dest                              ; 无条件跳转
br i1 %cond, label %t, label %f             ; 条件跳转
ret i32 %val                                ; 返回值
ret void                                    ; 返回空
switch i32 %v, label %default [i32 0, label %c0, i32 1, label %c1]
unreachable                                 ; 不可达

; === 一元运算 ===
%x = fneg float %y                          ; 浮点取负

; === 二元运算 ===
%x = add  i32 %a, %b                        ; 整数加
%x = sub  i32 %a, %b                        ; 整数减
%x = mul  i32 %a, %b                        ; 整数乘
%x = sdiv i32 %a, %b                        ; 有符号除
%x = udiv i32 %a, %b                        ; 无符号除
%x = srem i32 %a, %b                        ; 有符号取余
%x = urem i32 %a, %b                        ; 无符号取余
%x = fadd float %a, %b                      ; 浮点加
%x = fmul float %a, %b                      ; 浮点乘

; === 位运算 ===
%x = shl  i32 %a, 3                         ; 左移
%x = lshr i32 %a, 3                         ; 逻辑右移(高位补0)
%x = ashr i32 %a, 3                         ; 算术右移(高位补符号位)
%x = and  i32 %a, %b                        ; 按位与
%x = or   i32 %a, %b                        ; 按位或
%x = xor  i32 %a, %b                        ; 按位异或

; === 比较 ===
%x = icmp eq  i32 %a, %b                    ; 整数等于
%x = icmp ne  i32 %a, %b                    ; 整数不等
%x = icmp sgt i32 %a, %b                    ; 有符号大于
%x = icmp sge i32 %a, %b                    ; 有符号大于等于
%x = icmp slt i32 %a, %b                    ; 有符号小于
%x = icmp sle i32 %a, %b                    ; 有符号小于等于
%x = fcmp oeq float %a, %b                  ; 浮点等于(ordered)

; === 内存操作 ===
%p = alloca i32                             ; 栈上分配
%v = load  i32, ptr %p                      ; 读取
      store i32 42, ptr %p                  ; 写入
%p = getelementptr [10 x i32], ptr %arr, i32 0, i32 3  ; 取 arr[3] 的地址

; === 转换 ===
%x = trunc i32 %a to i8                     ; 截断
%x = zext i8 %a to i32                      ; 零扩展
%x = sext i8 %a to i32                      ; 符号扩展
%x = fptoui float %a to i32                 ; 浮点→无符号整数
%x = sitofp i32 %a to float                 ; 有符号整数→浮点
%x = bitcast i32* %a to i8*                 ; 位重解释(不改变位,只改变类型)

; === 其他 ===
%x = call i32 @foo(i32 %a, i32 %b)          ; 调用函数
%x = select i1 %cond, i32 %a, i32 %b        ; 条件选择(类似三元运算符)
%x = phi i32 [ 0, %entry ], [ %y, %loop ]   ; φ 节点

3.6 实战:阅读 LLVM IR 完整示例

// example.c
int max3(int a, int b, int c) {
    int m = a;
    if (b > m) m = b;
    if (c > m) m = c;
    return m;
}
# 生成优化后的 IR(这才是"真正的"LLVM IR 风格)
clang -S -emit-llvm -O2 example.c -o example.ll
define i32 @max3(i32 %a, i32 %b, i32 %c) {
entry:
  ; 第一个比较: b > a ?
  %cmp1 = icmp sgt i32 %b, %a
  ; select: 如果 b > a,选 b,否则选 a
  %m.1 = select i1 %cmp1, i32 %b, i32 %a

  ; 第二个比较: c > m.1 ?
  %cmp2 = icmp sgt i32 %c, %m.1
  ; select: 如果 c > m.1,选 c,否则选 m.1
  %m.2 = select i1 %cmp2, i32 %c, i32 %m.1

  ret i32 %m.2
}

逐行解读这个 IR 的精妙之处

  1. 没有 alloca-O2 自动跑了 mem2reg,所有变量都是 SSA 寄存器
  2. 用了 select 而非 br+phi:LLVM 优化器发现 if-else 足够简单,用 select 避免分支,减少了基本块数量
  3. %m.1%m.2:SSA 的自然结果——中间值各有一个名字
  4. 两个 select 串在一起:逻辑完全等价于原始控制流,但没有分支,CPU 流水线不会因为分支预测失败而 stall

4. 练习

练习 1:写 C 生成 IR(15 分钟)

对以下每种控制流结构,写对应的 C 函数,生成 -O0-O2 的 IR,对比:

// 1. 简单 if-else
int abs_val(int x) { if (x >= 0) return x; else return -x; }

// 2. for 循环
int factorial(int n) {
    int r = 1;
    for (int i = 2; i <= n; i++) r *= i;
    return r;
}

// 3. while 循环
int gcd(int a, int b) {
    while (b != 0) { int t = b; b = a % b; a = t; }
    return a;
}

// 4. switch
int classify(char c) {
    switch (c) {
        case 'a': return 1;
        case 'b': return 2;
        default:  return 0;
    }
}

对每个函数:

  • 找出基本块的边界
  • 手动画 CFG
  • 找出 phi 节点(在 -O0 的 IR 中你能在 mem2reg 之后看到)
▼ 练习 1 参考答案
1. abs_val —— 简单 if-else

-O0 IR

define i32 @abs_val(i32 %x) {
entry:
  %x.addr = alloca i32, align 4
  store i32 %x, ptr %x.addr, align 4
  %0 = load i32, ptr %x.addr, align 4
  %cmp = icmp sge i32 %0, 0       ; x >= 0 ?
  br i1 %cmp, label %if.then, label %if.else

if.then:
  %1 = load i32, ptr %x.addr, align 4
  ret i32 %1                       ; return x

if.else:
  %2 = load i32, ptr %x.addr, align 4
  %sub = sub nsw i32 0, %2         ; 0 - x = -x
  ret i32 %sub
}

基本块: entryif.thenif.else(3 个块)

CFG:

      [entry]
     x >= 0 ?
    /        \
  true      false
  /            \
[if.then]    [if.else]
ret x         %sub = 0-x
              ret %sub

没有循环,所以没有 phi 节点(即使 mem2reg 后也没有)。

-O2 IR

define i32 @abs_val(i32 %x) {
entry:
  %cmp = icmp sgt i32 %x, -1        ; x > -1 即 x >= 0
  %sub = sub nsw i32 0, %x          ; -x(不管用不用,都算出来)
  %ret = select i1 %cmp, i32 %x, i32 %sub  ; 条件选择!
  ret i32 %ret
}

关键变化

  • select 替代了 br+ret——if-else 变成了无分支代码
  • 只有一个基本块——CFG 退化成了单节点
  • -x 被投机计算(speculative execution),反正开销可以忽略

2. factorial —— for 循环

-O0 IR(简化了属性标注):

define i32 @factorial(i32 %n) {
entry:
  %n.addr = alloca i32
  %r = alloca i32
  %i = alloca i32
  store i32 %n, ptr %n.addr
  store i32 1, ptr %r            ; r = 1
  store i32 2, ptr %i            ; i = 2
  br label %for.cond

for.cond:                        ; 循环条件块
  %0 = load i32, ptr %i
  %1 = load i32, ptr %n.addr
  %cmp = icmp sle i32 %0, %1     ; i <= n ?
  br i1 %cmp, label %for.body, label %for.end

for.body:                        ; 循环体
  %2 = load i32, ptr %i
  %3 = load i32, ptr %r
  %mul = mul nsw i32 %3, %2      ; r * i
  store i32 %mul, ptr %r         ; r = r * i
  br label %for.inc

for.inc:                         ; i++ 块
  %4 = load i32, ptr %i
  %inc = add nsw i32 %4, 1       ; i + 1
  store i32 %inc, ptr %i         ; i = i + 1
  br label %for.cond             ; ← 回边!

for.end:                         ; 循环出口
  %5 = load i32, ptr %r
  ret i32 %5
}

基本块: entryfor.condfor.bodyfor.incfor.end(5 个块)

CFG:

      [entry]
      r=1, i=2
         │
         ▼
    ┌[for.cond]◄──────┐
    │ i <= n ?        │
    └──┬──────┬──────┘
      true    false
       │        │
       ▼        ▼
   [for.body]  [for.end]
   r = r * i   ret r
       │
       ▼
   [for.inc]
   i = i + 1
       │
       └──────────┘  ← 回边

mem2reg 后的 phi 节点

define i32 @factorial(i32 %n) {
entry:
  br label %for.cond

for.cond:
  %r = phi i32 [ 1, %entry ], [ %mul, %for.inc ]   ; ← phi 节点!
  %i = phi i32 [ 2, %entry ], [ %inc, %for.inc ]   ; ← phi 节点!
  %cmp = icmp sle i32 %i, %n
  br i1 %cmp, label %for.body, label %for.end

for.body:
  %mul = mul nsw i32 %r, %i
  br label %for.inc

for.inc:
  %inc = add nsw i32 %i, 1
  br label %for.cond

for.end:
  ret i32 %r
}

phi 节点解读

  • %r = phi [1, %entry], [%mul, %for.inc]:第一次从 entry 来是 1,之后从 for.inc 来是上一轮迭代的 %mul
  • %i = phi [2, %entry], [%inc, %for.inc]:第一次是 2,之后是上一轮的 %inc

3. gcd —— while 循环

-O0 IR

define i32 @gcd(i32 %a, i32 %b) {
entry:
  %a.addr = alloca i32
  %b.addr = alloca i32
  %t = alloca i32
  store i32 %a, ptr %a.addr
  store i32 %b, ptr %b.addr
  br label %while.cond

while.cond:                      ; while (b != 0)
  %0 = load i32, ptr %b.addr
  %cmp = icmp ne i32 %0, 0
  br i1 %cmp, label %while.body, label %while.end

while.body:
  %1 = load i32, ptr %b.addr
  store i32 %1, ptr %t          ; t = b
  %2 = load i32, ptr %a.addr
  %3 = load i32, ptr %b.addr
  %rem = srem i32 %2, %3        ; a % b
  store i32 %rem, ptr %b.addr   ; b = a % b
  %4 = load i32, ptr %t
  store i32 %4, ptr %a.addr     ; a = t
  br label %while.cond          ; ← 回边

while.end:
  %5 = load i32, ptr %a.addr
  ret i32 %5
}

基本块: entrywhile.condwhile.bodywhile.end(4 个块)

CFG:

      [entry]
   a.addr=a, b.addr=b
         │
         ▼
    ┌[while.cond]◄──────┐
    │ b != 0 ?          │
    └──┬──────┬────────┘
      true    false
       │        │
       ▼        ▼
  [while.body]  [while.end]
  t=b; b=a%b     ret a
  a=t
       │
       └──────────┘  ← 回边

mem2reg 后的 phi 节点

define i32 @gcd(i32 %a, i32 %b) {
entry:
  br label %while.cond

while.cond:
  %a.phi = phi i32 [ %a, %entry ], [ %b.load, %while.body ]
  %b.phi = phi i32 [ %b, %entry ], [ %rem, %while.body ]
  %cmp = icmp ne i32 %b.phi, 0
  br i1 %cmp, label %while.body, label %while.end

while.body:
  %b.load = load i32, ptr ???     ; 等等——这个不好翻译
  ; ...

实际上 gcd 的 mem2reg 比较微妙——t = b 这个临时变量涉及对 %b 的两次读取(一次存 t,一次和 %a 一起算 a%b),mem2reg 后 %b 直接就是 SSA 寄存器:

; mem2reg 后的实际结果
define i32 @gcd(i32 %a, i32 %b) {
entry:
  br label %while.cond

while.cond:
  %a.0 = phi i32 [ %a, %entry ], [ %b.0, %while.body ]
  %b.0 = phi i32 [ %b, %entry ], [ %rem, %while.body ]
  %cmp = icmp ne i32 %b.0, 0
  br i1 %cmp, label %while.body, label %while.end

while.body:
  %rem = srem i32 %a.0, %b.0       ; a % b
  br label %while.cond              ; 下一轮: a=b, b=a%b

while.end:
  ret i32 %a.0
}

注意 t = b 这条赋值直接消失了——因为 SSA 中 %b.0 在下一轮迭代正好从 phi 的 [%b.0, %while.body] 这个入口得到上一轮的 %b.0 值,也就是 b 在进入 while.body 时的值。gcd 语义天然被 phi 的循环行为表达了。


4. classify —— switch

-O0 IR

define i32 @classify(i8 %c) {
entry:
  %c.addr = alloca i8
  store i8 %c, ptr %c.addr
  %0 = load i8, ptr %c.addr
  %conv = sext i8 %0 to i32        ; char → int 符号扩展
  switch i32 %conv, label %sw.default [
    i32 97, label %sw.bb            ; 'a' = 97
    i32 98, label %sw.bb1           ; 'b' = 98
  ]

sw.bb:                              ; case 'a'
  ret i32 1

sw.bb1:                             ; case 'b'
  ret i32 2

sw.default:                         ; default
  ret i32 0
}

基本块: entrysw.bbsw.bb1sw.default(4 个块)

CFG:

              [entry]
           switch %c
         /     |      \
      'a'    'b'    default
       /      |        \
      ▼       ▼         ▼
  [sw.bb]  [sw.bb1]  [sw.default]
   ret 1    ret 2     ret 0

没有循环 → 没有 phi 节点

-O2 IR:基本一样——switch 只有两个 case,LLVM 不会把它转成跳转表。如果用 10+ 个 case,O2 会生成 jump table。


小结:有循环才有 phi 节点。if-else 和 switch 是无环控制流,不需要 phi——它们的汇合点只是"多条路径到这里",但每条路径只走一次,不会像循环那样"反复经过汇合点、每次带不同值"。


练习 2:手动模拟 mem2reg(10 分钟)

拿这段 -O0 IR:

define i32 @foo(i1 %cond) {
entry:
  %x = alloca i32
  store i32 10, ptr %x
  br i1 %cond, label %then, label %else

then:
  store i32 20, ptr %x
  br label %merge

else:
  br label %merge

merge:
  %val = load i32, ptr %x
  ret i32 %val
}

模拟 mem2reg 的转换过程:

  1. 找出所有 store 指令的位置
  2. 计算支配边界
  3. 确定 φ 节点应该放在哪里
  4. 写出转换后的 SSA IR
  5. opt -passes=mem2reg 验证你的答案
▼ 练习 2 参考答案
第 1 步:找出所有 store 指令

变量 %x 有两个 store:

Store所在块
S1entry10
S2then20
第 2 步:计算支配关系
CFG:
        [entry]
        /     \
     true     false
      /         \
   [then]      [else]
      \         /
       [merge]

支配关系:
  dom(entry) = {entry}
  dom(then)  = {entry, then}
  dom(else)  = {entry, else}
  dom(merge) = {entry, merge}    ← 注意!entry 支配 merge(到 merge 必过 entry)
                                   then 不支配 merge(走 else 路径不到 then)
                                   else 不支配 merge(走 then 路径不到 else)

立即支配者:
  idom(entry) = (无)
  idom(then)  = entry
  idom(else)  = entry
  idom(merge) = entry            ← merge 的 idom 是 entry,不是 then/else
第 3 步:计算支配边界

定义:DF[n] = { x | n 支配 x 的某个前驱,但 n 不严格支配 x }

DF(entry) = ∅              ; entry 支配所有节点,没有"出走"的边
DF(then)  = {merge}        ; then 支配 then 自己,then→merge 的边,终点 merge 不被 then 严格支配
DF(else)  = {merge}        ; 同理,else→merge 的边
DF(merge) = ∅
第 4 步:确定 φ 节点位置

SSA 构造规则:变量在块 B 中有 store → 在 DF[B] 的每个块中插入该变量的 φ 节点。

S1 在 entry → DF[entry] = ∅ → 不插 φ
S2 在 then  → DF[then]  = {merge} → 在 merge 中插 x 的 φ 节点!

else 中没有 store,不需要处理。

φ 节点插在 merge 的开头。因为 S1 和 S2 都定义了 x,而 merge 同时被 then(有 S2)和 else(无 store,来的时候 x 还剩 S1 的值)到达,所以 merge 需要 φ 来汇合:

  • 从 then 来 → x = 20(S2 的值)
  • 从 else 来 → x = 10(S1 的值,因为 else 路径没覆盖它)
第 5 步:写出转换后的 SSA IR

原始 IR 中 %x 是个内存位置(alloca),所有操作通过 store/load 间接访问。

mem2reg 后的等价 SSA IR:

define i32 @foo(i1 %cond) {
entry:
  br i1 %cond, label %then, label %else

then:
  br label %merge

else:
  br label %merge

merge:
  %x = phi i32 [ 20, %then ], [ 10, %else ]   ; ← φ 节点!
  ret i32 %x
}

逐行理解

  • %x = phi i32 [ 20, %then ], [ 10, %else ]:如果从 %then 来,%x = 20;如果从 %else 来,%x = 10
  • allocastoreload 全部消失了——它们被 SSA 寄存器 %x 的 φ 节点表达
  • 基本块从 4 个减少到 3 个(entry 空了?看下面)

实际 LLVM 输出(opt -passes=mem2reg 后):

define i32 @foo(i1 %cond) {
entry:
  br i1 %cond, label %then, label %else

then:                                             ; preds = %entry
  br label %merge

else:                                             ; preds = %entry
  br label %merge

merge:                                            ; preds = %then, %else
  %.0 = phi i32 [ 20, %then ], [ 10, %else ]      ; LLVM 自动命名为 %.0
  ret i32 %.0
}
验证
# 保存原始 IR 到文件
cat > foo.ll << 'EOF'
define i32 @foo(i1 %cond) {
entry:
  %x = alloca i32
  store i32 10, ptr %x
  br i1 %cond, label %then, label %else
then:
  store i32 20, ptr %x
  br label %merge
else:
  br label %merge
merge:
  %val = load i32, ptr %x
  ret i32 %val
}
EOF

opt -passes=mem2reg foo.ll -S
# 输出与上面的结果一致
关键洞察
  1. φ 节点来自支配边界:S2 在 then 中 → DF[then] = {merge} → φ 插在 merge。这是确定的算法,不是"猜"。

  2. else 没有 store,怎么知道值是 10? 因为 else 的前驱只有 entry,而 entry 中的 store(S1) 在 else 中没有被"杀死"——S1 的 x 值 10 沿着 entry→else→merge 这条路径活到了 merge。

  3. 如果 else 里也有 store(比如 store i32 30),那么 DF[else] = {merge},merge 已经在步骤 4 中有了 φ。此时 φ 会有三个入口:[20, %then], [30, %else], [10, ???]——等等,10 从哪条路径来?答案:必须在 entry→else 路径上做分析。如果有 br i1 %cond, label %then, label %else 且 else 也有 store,那 φ 就只需要两个入口 [20, %then], [30, %else]——因为 else 路径上 S1(10) 被 S3(30) 覆盖了。


练习 3:LLVM 工具链热身(10 分钟)

# 生成不同优化级别的 IR,感受差异
clang -S -emit-llvm -O0 factorial.c -o factorial_O0.ll
clang -S -emit-llvm -O2 factorial.c -o factorial_O2.ll
diff factorial_O0.ll factorial_O2.ll | head -50

# 用 opt 逐个跑 Pass,观察变化
opt -passes=mem2reg factorial_O0.ll -S -o factorial_mem2reg.ll
opt -passes=mem2reg,licm factorial_O0.ll -S -o factorial_licm.ll

# 看 LLVM 的 CFG(需要 graphviz)
opt -passes='dot-cfg' factorial_O0.ll -disable-output
dot -Tpng cfg.foo.dot -o cfg.png
▼ 练习 3 参考答案——运行结果与解读
1. -O0 vs -O2 IR 对比(以 factorial 为例)
# 先把 factorial 函数写入文件
cat > factorial.c << 'EOF'
int factorial(int n) {
    int r = 1;
    for (int i = 2; i <= n; i++) r *= i;
    return r;
}
EOF

clang -S -emit-llvm -O0 factorial.c -o factorial_O0.ll
clang -S -emit-llvm -O2 factorial.c -o factorial_O2.ll

diff 结果解读(手动标注):

< define i32 @factorial(i32 %n) #0 {
---
> define i32 @factorial(i32 %n) local_unnamed_addr #0 {
    ↑ O2 带了更多属性注解

<   %r = alloca i32              ← O0 在栈上分配 r
<   %i = alloca i32              ← O0 在栈上分配 i
<   store i32 1, ptr %r
<   store i32 2, ptr %i
<   br label %for.cond
---
>   %cmp5 = icmp sgt i32 %n, 1    ← O2 直接比较 n > 1(因为 n<=1 时循环不执行)
>   br i1 %cmp5, label %for.body.preheader, label %for.end

关键变化:
  1. O0 有 5 个基本块(entry/for.cond/for.body/for.inc/for.end)
  2. O2 通常只有 3-4 个基本块(preheader/body/latch/exit),alloca 全部消失
  3. O2 把 for.inc 合并进了 latch
  4. 指令数从 ~20 降到 ~10

factorial_O0.ll 关键内容(完整版):

define i32 @factorial(i32 %n) {
entry:
  %n.addr = alloca i32, align 4
  %r = alloca i32, align 4
  %i = alloca i32, align 4
  store i32 %n, ptr %n.addr, align 4
  store i32 1, ptr %r, align 4
  store i32 2, ptr %i, align 4
  br label %for.cond

for.cond:
  %0 = load i32, ptr %i, align 4
  %1 = load i32, ptr %n.addr, align 4
  %cmp = icmp sle i32 %0, %1
  br i1 %cmp, label %for.body, label %for.end

for.body:
  %2 = load i32, ptr %i, align 4
  %3 = load i32, ptr %r, align 4
  %mul = mul nsw i32 %3, %2
  store i32 %mul, ptr %r, align 4
  br label %for.inc

for.inc:
  %4 = load i32, ptr %i, align 4
  %inc = add nsw i32 %4, 1
  store i32 %inc, ptr %i, align 4
  br label %for.cond

for.end:
  %5 = load i32, ptr %r, align 4
  ret i32 %5
}

factorial_O2.ll 关键内容

define i32 @factorial(i32 %n) local_unnamed_addr {
entry:
  %cmp5 = icmp sgt i32 %n, 1          ; n > 1?(≤1 直接返回 1)
  br i1 %cmp5, label %for.body.preheader, label %for.end

for.body.preheader:                    ; 循环预入口(preheader)
  br label %for.body

for.body:                              ; 合并了 body + latch
  %i.07 = phi i32 [ %inc, %for.body ], [ 2, %for.body.preheader ]
  %r.06 = phi i32 [ %mul, %for.body ], [ 1, %for.body.preheader ]
  %mul = mul nsw i32 %r.06, %i.07
  %inc = add nuw nsw i32 %i.07, 1
  %cmp = icmp eq i32 %i.07, %n        ; i == n?(代替 i <= n)
  br i1 %cmp, label %for.end.loopexit, label %for.body

for.end.loopexit:
  %mul.lcssa = phi i32 [ %mul, %for.body ]    ; LCSSA φ:循环出口专用
  br label %for.end

for.end:
  %r.0.lcssa = phi i32 [ 1, %entry ], [ %mul.lcssa, %for.end.loopexit ]
  ret i32 %r.0.lcssa
}

O2 的精妙之处

优化体现
提前判空n > 1:n≤1 时跳过整个循环,直接返回 1
合并基本块for.body 同时包含了原来的 bodyinc
归纳变量优化i <= n 变成了 i == n(循环计数器恰好从 2 走到 n)
LCSSA%mul.lcssa 是循环闭包 SSA 形式——循环内定义的变量在循环外使用时插入专用 φ

2. opt 逐个跑 Pass
opt -passes=mem2reg factorial_O0.ll -S -o step1.ll
opt -passes=mem2reg,gvn   factorial_O0.ll -S -o step2.ll
opt -passes=mem2reg,gvn,licm factorial_O0.ll -S -o step3.ll

step1(mem2reg 后)的 IR

define i32 @factorial(i32 %n) {
entry:
  br label %for.cond

for.cond:
  %r.0 = phi i32 [ 1, %entry ], [ %mul, %for.inc ]   ; ← alloca 消失,变成 SSA
  %i.0 = phi i32 [ 2, %entry ], [ %inc, %for.inc ]
  %cmp = icmp sle i32 %i.0, %n
  br i1 %cmp, label %for.body, label %for.end

for.body:
  %mul = mul nsw i32 %r.0, %i.0
  br label %for.inc

for.inc:
  %inc = add nsw i32 %i.0, 1
  br label %for.cond

for.end:
  ret i32 %r.0
}

对比原始 -O0 的 IR:

  • alloca 全部消失 → %r%i 变成了 φ 节点
  • load 全部消失 → 直接用 SSA 寄存器
  • store 全部消失 → 值的传递由 φ 的参数完成
  • 指令数从 ~20 降到 ~10

step2(mem2reg + GVN 后):factorial 这个例子中 GVN 通常没有额外效果——没有冗余的公共子表达式可消除。可以换个例子感受 GVN:

# 写一个有明显公共子表达式的例子
cat > gvn_test.c << 'EOF'
int test(int a, int b, int c) {
    int x = a + b;
    int y = c * 2;
    int z = a + b;   // 公共子表达式!
    return x + z;
}
EOF

clang -S -emit-llvm -O0 gvn_test.c -o gvn_test.ll
opt -passes=mem2reg gvn_test.ll -S -o gvn_step1.ll
opt -passes=mem2reg,gvn gvn_test.ll -S -o gvn_step2.ll
diff gvn_step1.ll gvn_step2.ll

你会看到 %z = add i32 %a, %b 被替换为直接使用 %x

step3(mem2reg + GVN + LICM 后):factorial 中 LICM 也没有额外效果(循环内没有循环不变计算)。换个例子:

cat > licm_test.c << 'EOF'
int test(int a, int b, int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += a * b;   // a * b 在循环内不变!
    }
    return sum;
}
EOF

clang -S -emit-llvm -O0 licm_test.c -o licm_test.ll
opt -passes=mem2reg licm_test.ll -S -o licm_step1.ll
opt -passes=mem2reg,licm licm_test.ll -S -o licm_step2.ll

step2 中 %mul = mul i32 %a, %b 被从 for.body 移到了循环外的专用块。


3. 用 dot-cfg 看 CFG
# 生成 CFG dot 文件
opt -passes='dot-cfg' factorial_O0.ll -disable-output

# 会生成 .dot 文件,比如:
#   cfg.factorial.dot  —— 每个函数一个 .dot 文件
# 用 graphviz 渲染为 PNG:
dot -Tpng cfg.factorial.dot -o cfg.png

# 如果没装 graphviz,可以直接看 .dot 文本:
cat cfg.factorial.dot

.dot 文件内容(factorial -O0 版本):

digraph "CFG for 'factorial' function" {
    label="CFG for 'factorial' function";

    Node0x1 [shape=record,label="{entry:\l  ...\l  br label \%for.cond\l}"];
    Node0x2 [shape=record,label="{for.cond:\l  ...\l  br i1 \%cmp, label \%for.body, label \%for.end\l}"];
    Node0x3 [shape=record,label="{for.body:\l  ...\l  br label \%for.inc\l}"];
    Node0x4 [shape=record,label="{for.inc:\l  ...\l  br label \%for.cond\l}"];
    Node0x5 [shape=record,label="{for.end:\l  ...\l  ret i32 ...\l}"];

    Node0x1 -> Node0x2;
    Node0x2 -> Node0x3;
    Node0x2 -> Node0x5;
    Node0x3 -> Node0x4;
    Node0x4 -> Node0x2;
}

这和图 3.4 节手绘的 CFG 完全一致——5 个节点,回边 for.inc → for.cond 清晰可见。


今日小结

今天你建立的三条认知:

1. 编译器 = 前端 + 中端 + 后端,通过 IR 解耦
   C/C++/Rust → Clang → LLVM IR → opt → llc → x86/ARM/RISC-V

2. LLVM IR 的 SSA 形式
   每个变量只赋值一次 → use-def 关系无需分析 → 优化算法简化
   φ 节点处理控制流汇合 → 支配边界确定 φ 节点的位置
   mem2reg: 把 -O0 的 alloca/store/load 自动提升为 SSA

3. 基本块 + CFG 是所有分析的基础
   BB = 直线代码 + terminator
   CFG = BB 节点 + br 边
   Day2 的数据流分析和支配分析都建立在 CFG 之上

明天要用的:看懂任意 LLVM IR、手动找出基本块、理解 φ 节点的含义——这三项是 Day2 数据流分析的前置技能。

【下垂控制与虚拟同步机】下垂控制与虚拟同步机两种并网型(grid-forming)控制策略的性能研究(Simulink仿真实现)内容概要:本文围绕下垂控制与虚拟同步机(VSG)两种并网型(grid-forming)控制策略,通过Simulink仿真平台对其性能进行了系统性对比研究。重点分析了二者在电网频率调节、电压支撑、动态响应特性、抗扰能力及并网稳定性等方面的差异与优劣,旨在为不同应用场景下的逆变器控制策略选型提供理论依据和技术支撑。研究涵盖了控制原理建模、仿真系统搭建、典型工况测试(如负载突变、电网波动)以及性能指标评估,充分展示了两种技术在现代电力电子并网系统中的应用潜力与局限性。; 适合人群:具备电力电子、自动控制或新能源并网相关基础知识的研究生、科研人员及从事新能源系统仿真的工程技术人员。; 使用场景及目标:①掌握下垂控制与虚拟同步机的核心控制原理及实现方法;②理解两类grid-forming控制策略在动态响应、频率电压支撑方面的性能差异;③为微电网、构网型逆变器等系统的控制方案设计与仿真验证提供参考。; 阅读建议:读者应结合Simulink仿真模型进行实践操作,重点关注控制器参数设计对系统性能的影响,并可通过修改工况条件进一步探究两种策略在复杂电网环境下的适应性。
内容概要:本文提出了一种考虑N-1安全准则的分布鲁棒机会约束低碳经济调度模型,旨在应对电力系统中由可再生能源出力不确定性带来的调度风险。该模型深度融合分布鲁棒优化与机会约束规划方法,在确保系统在单一元件故障(N-1)条件下仍能安全稳定运行的前提下,实现经济性与低碳化双重目标的协同优化。通过Matlab编程实现,结合先进优化算法高效求解复杂调度问题,有效平衡了系统经济性、环保性与安全可靠性之间的矛盾,并提供了完整的代码复现资源,便于科研验证与工程应用。; 适合人群:具备一定电力系统分析基础和Matlab编程能力,从事电力系统优化调度、低碳运行、不确定性建模、鲁棒优化与机会约束等领域研究的研究生、科研人员及工程技术人员。; 使用场景及目标:①用于复现和验证顶级EI期刊中关于低碳经济调度的前沿研究成果;②为高比例可再生能源接入的电力系统提供兼具安全性与经济性的调度决策支持;③深入学习和掌握分布鲁棒优化、机会约束建模、N-1安全约束处理及其在电力系统中的集成应用方法; 阅读建议:读者应结合所提供的Matlab代码进行实践操作,重点剖析N-1故障集的构建逻辑、分布鲁棒对不确定性的建模方式、机会约束的转化技巧以及低碳目标与经济调度的耦合机制,建议配合YALMIP等优化建模工具进行调试、结果分析与模型扩展研究。
为什么这份资料值得你下载? 你是不是也卡在过这些地方:摄像头插上去只出花屏、DCMI 收不到数据、DMA 一跑就溢出、逻辑分析仪抓出来的波形看不懂不知道哪根线错了?并口 CMOS 摄像头(OV2640 / OV5640)是嵌入式视觉的标配,但网上教程大多只给一段「能跑就行的代码」,从 SCCB 寄存器到 DCMI 时序、从帧缓存到调波形,没有一个讲透的。这份工程把整条链路一次性讲明白。 你能直接拿到什么 - 可直接研读的 HAL 驱动源码(STM32 风格,全中文注释):SCCB 总线读写(硬件 I2C + 软件模拟双保险)、OV2640 与 OV5640 两套寄存器配置与初始化、DCMI + DMA 抓帧、多缓冲帧缓存环形队列。 - 一份能救命的波形调试笔记:把「无像素时钟 / HSYNC 极性反 / VSYNC 不翻转 / 数据错位 / JPEG 帧头缺失 / DMA 溢出」六大故障,整理成「现象、波形、排查、判定」对照表,配 ASCII 时序图。纯靠猜会浪费一周,对着表十分钟定位。 - 单文件离线教程:HTML 阅读器自带目录、代码复制、章节折叠、搜索、避坑框,断网也能看;另有 Markdown 源。 核心 1. 一次讲清两款主流传感器:OV2640(入门 JPEG/RGB565)与 OV5640(500 万像素、含 PLL 配置),共用 SCCB 层,各自寄存器表分开实现。 2. 不只给代码,更给「为什么」:每个配置背后的时序与寄存器含义都解释,改分辨率、改输出格式不再靠蒙。 3. 把最隐蔽的硬件坑前置:DCMI 同步信号极性、DMA 双缓冲、场消隐窗口,这些文档里不写、出事才发现的细节,全在笔记里。 适合谁 嵌入式工程师、在校学生、创客,以及做机器视觉小车、智能门锁、工业检测、AI 相机原型的开发者
内容概要:本文围绕概率最小均方自适应滤波器(LMS)在信号处理中的应用展开,重点介绍了其在噪声消除、系统辨识等场景下的Matlab实现方法。文章系统阐述了自适应滤波的基本原理,涵盖滤波器结构设计、权重迭代更新机制及收敛性分析,并通过具体的Matlab代码实例演示了模型的构建、调试与性能评估过程。同时,结合轴承故障诊断、负荷预测等实际工程问题,深入探讨了该算法在多学科交叉领域的应用潜力与有效性,展现了其在复杂信号环境下的强大适应能力。; 适合人群:具备一定信号处理理论基础和Matlab编程能力的高校研究生、科研人员及工程技术人员,尤其适用于自动化、电气工程、通信工程、机械故障诊断等领域从事信号分析与系统建模的相关从业者。; 使用场景及目标:①掌握概率LMS自适应滤波器的核心算法原理与Matlab实现流程;②应用于实际工程项目中如信号去噪、系统辨识、故障特征提取等任务;③为后续研究VMD、CNN-BiLSTM等先进模型提供信号预处理基础和技术支撑; 阅读建议:此资源以Matlab代码实践为核心,建议读者在学习过程中结合文中提供的完整代码进行仿真实验与参数调优,深入理解算法的动态行为与性能边界,同时可参考文末网盘资料拓展学习相关技术内容,全面提升科研与工程应用能力。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值