必撸系列!Go另外几个黑魔法技巧汇总

3c0e07c7b215f7d3bc3c67769d64251c.png

导语 |  最近一段时间,笔者重新梳理了一下go知识点,并深入地看看了它的源码,在实践中又有了新的沉淀,于是写下这篇文章和大家分享一下。

在上一篇文章《来了!Go的2个黑魔法技巧》中,笔者分享了go中两个有意思的技巧。

而最近一段时间,笔者重新梳理了一下go知识点,并深入地看看了它的源码,在实践中又有了新的沉淀,于是写下这篇文章和大家分享一下。

一、魔法:最小化运行时(minimal runtime)

我们知道,go有一层很重的运行时(runtime),包括内存管理、goroutine 调度等重要组件;这些组件极大地方便了应用程序的开发和迭代,但也带来了一些问题,比如:

  • Go程序自动接管了内存,并自带GC,使我们无法直接操纵内存;

  • Go程序自动化了内存、调度等重要模块,但这些组件本身就占用了一定资源;

  • .....

对于应用程序而言,runtime是良药,能够很大程度上简化机械工作,让开发者集中于核心业务开发和迭代上,但对于一些其它场景,比如操作系统,那么runtime会严重破坏其核心资源管理能力,那么有没有方案能让go摆脱掉runtime的束缚,使其成为一个真正的系统级编程语言呢?

肯定是有的,任何一个可执行文件而言,本质上都是目标文件被链接后生成的(以下均以Linux作为实践平台)。

go编译器默认编译生成的文件,其程序的入口是runtime包中的_rt0_amd64_linux函数:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP _rt0_amd64(SB)


TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ    0(SP), DI   // argc
    LEAQ    8(SP), SI   // argv
    JMP runtime·rt0_go(SB)

经_rt0_amd64_linux入口跳转到_rt0_amd64、rt0_go;在rt0_go中,go程序会初始化一系列的内存、调度、GC等资源,如下图所示:

9a0d20d77c67753e6a193ced9477dffe.png

每个go程序都会走这么一遭,这也是其runtime很重的原因;想要规避掉 runtime,那么最好的方法肯定是从根源入手,将程序入口 _rt0_amd64_linux换掉,不再进入rt0_go函数,也就不会初始化 runtime,那么自然就能摆脱掉runtime。

既然有了正确的思路,那么实现起来也就不会难了。

首先,我们从一个简单的Hello Runtime!入手:

// ....
func main() {
    fmt.Println("Hello Runtime!")
}

绝大多数情况下,我们会选择go run来运行这个简单的程序,或者使用go build来直接将其编译为可执行文件。

但这种做法无疑屏蔽了太多的细节,因此我们选择先编译、后链接的“笨”方法来做:

$ go tool compile -p main -o main.o main.go
$ go tool pack c main.a main.o
# internal 表示使用go内置的链接器
$ go tool link -linkmode=internal main.a
  • 使用工具compile将main.go编译为目标文件main.o;

  • 使用工具pack将目标文件main.o打包为main.a;

  • 使用工具link将打包文件链接,生成可执行文件a.out。

这3个命令分别生成了目标文件main.o,归档文件main.a,以及可执行文件a.out:

$ tree -L 1 .
.
|__a.out
|__main.a
|__main.go
|__main.o

运行a.out:

$ ./a.out
Hello Runtime!

这样就展示了一个程序编译、生成到运行的全过程。

为了改变程序的入口,使其摆脱runtime,我们可以这样做:

  • 定义一个新的函数,比如:entry;

  • 指定链接生成可执行文件时,将entry作为程序入口。

  • 改造main.go文件,新增一个entry函数:

//go:nosplit
//go:noescape
func entry()


func main() {
    fmt.Println("Hello Runtime!")
    entry()
}

这里,我们没有删掉原来的代码,而是直接新增了一个entry函数,并在main函数中调用,是为了更好的展示入口函数的作用。

entry函数体定义在一个单独的汇编文件中,如下:

# linux 下
# main_linux_amd64.s
TEXT ·entry(SB), $0-0
    MOVL    $33,  DI
    MOVL    $60,  AX
    SYSCALL
    RET

entry的功能也很简单,系统调用exit退出程序,并返回退出码33。

由于很多使用MAC的同学,其对应的entry函数略有不同,如下:

# mac 下
# main_darwin_amd64.s
TEXT ·entry(SB), $0-0
    MOVL    $33, DI
    MOVL    $(0x2000000+1), AX
    SYSCALL
    RET

然后,我们需要在链接时指定程序入口为entry,如下:

$ go tool asm -gensymabis -o symabis main_linux_amd64.s
$ go tool compile -symabis symabis -p main -o x1.o main.go
$ go tool asm -o x2.o main_linux_amd64.s
$ go tool pack c x.a x1.o x2.o
$ go tool link -linkmode=internal -E 'main.entry' x.a
  • 使用asm工具为main_linux_amd64.s生成符号文件;

  • 使用compile工具编译 main.go文件,生成x1.o目标文件;

  • 使用asm工具为main_linux_amd64生成目标文件x2.o;

  • 使用pack工具打包x1.o,x2.o并生成x.a归档文件;

  • 使用link工具链接生成a.out可执行文件。

go链接器提供E参数来设置执行文件入口,这里我们指定main.entry为程序入口,这样链接生成的文件就不会再走原来的执行流,执行结果如下:

$ ./a.out
$ echo $?
33

从运行结果来看,程序并没有调用Println函数,而是直接从entry进入,调用exit后退出程序,退出码为33。

这样,程序也不会进入rt0_go中,不会初始化runtime资源,从而达到规避runtime的作用。

这种方式虽然不会初始化runtime,但由于go编译器的默认行为,runtime代码也会被打包进执行文件,因此笔者才没有说丢掉runtime,而是最小化runtime。

二、魔法:即时编译 JIT(just-in-time)

目前很多脚本语言都加入了即时编译的新特性。即时编译的原理很简单,那就是在运行时生成机器码,然后执行,达到提升程序性能的效果。

Python脚本是通过虚拟机(VM)以字节码的方式解释执行的。一个简单的字节码样例大致如下:

>>> def add(x, y):
...     return x + y


>>> import dis
>>> dis.dis(add)
  2           0 LOAD_FAST                0 (x)
              2 LOAD_FAST                1 (y)
              4 BINARY_ADD
              6 RETURN_VALUE

这里,我们定义了一个简单的add函数,并通过dis模块来查看add函数对应的字节码。Python虚拟机是一个典型的栈机,其字节码执行都是基于栈来执行的,以add为例:

0 LOAD_FAST                0 (x)
2 LOAD_FAST                1 (y)
4 BINARY_ADD
6 RETURN_VALUE

LOAD_FAST指令会从局部变量中拿到变量值并入栈,这里会将x,y入栈,然后调用BINARY_ADD指令将栈顶的两个值出栈、相加后将结果再入栈,最后调用RETURN_VALUE指令将栈顶值返回。

如下图所示:

7801ef4bbe43195b9084f5a021b73d25.png

与直接运行机器码相比,字节码执行具有良好的跨平台性,一次编译,处处运行(装有Python虚拟机的前提下),但也损耗了一定的性能。

而即时编译会在程序运行时,将字节码编译为机器码运行,一定程度上补偿了这种损耗。

下面,我们就用go来实现一个简单的Python JIT编译器来领略即时编译的魅力。

即时编译原理虽然简单明了,但实现却很复杂,我们只会实现一个简单的JIT编译器,它会将类似下面的Python函数即时编译为机器码,然后写入内存并执行:

def foo(x, y):
    return x * x - y * y

这个函数会进行简单的四则运算,并将结果返回。其对应的字节码如下:

0 LOAD_FAST                0 (x)
2 LOAD_FAST                0 (x)
4 BINARY_MULTIPLY
6 LOAD_FAST                1 (y)
8 LOAD_FAST                1 (y)
10 BINARY_MULTIPLY
12 BINARY_SUBTRACT
14 RETURN_VALUE

笔者没有写代码去实现如何将Python代码翻译为字节码,这些编译器前端知识感兴趣的同学可以自行去查阅词法分析、语法分析等资料,或者直接去阅读CPython源码。

想要将字节码直接翻译为机器码可不简单,我们需引入中间代码(IR,Intermediate Representation),即先将字节码翻译为IR,然后再将IR翻译为机器码。

很多同学会不会疑惑多此一举?其实IR的存在是非常有必要的,几乎所有的编译器都有IR,甚至可能不止一种IR,至于IR的作用,在后文中笔者带领大家来体会。

我们以foo函数为例,看看如何将其字节码翻译为IR。foo的第一条字节码指令:

LOAD_FAST                0 (x)

LOAD_FAST会从局部变量中拿到数据,然后推入栈,LOAD_FAST指令附带一个参数,即后面的0,表示第0个局部变量,也就是x参数。

使用寄存器来暂存局部变量,我们可以设计出类似下面的 IR:

push rax

与绝大多数汇编一样,这里将一段内存看作一个栈来操作,push表示入栈,即将rax入栈。反过来,pop表示出栈:

pop rdi

这里将栈顶的值推出后,复制到rdi。当然对于一些计算操作,比如加法:

add rdi rsi # rdi = rdi + rsi

会将两个寄存器的值相加后再置入第一个寄存器中。每一个指令只做一件小事情,比如赋值、四则运算等等,类似这样的IR指令称为SSA。

另外,我们的IR指令一般都是一个操作命令,一个操作数或者两个操作数,比如:

mov rax 100
push rax

为了保证统一,我们将所有IR指令严格限制在两个操作数中,如果不足两个,则添加一个none,如下

mov rax 100
push rax none

这样的指令形式被称为Three-address code(TAC)。.com/pr

SSA和TAC都有严格的定义,这里只做了简单说明,有兴趣的同学可以查看:

SSA:

https://en.wikipedia.org/wiki/Static_single_assignment_form
TAC:

https://en.wikipedia.org/wiki/Three-address_code

你肯定会眼熟,这不就是汇编吗?是的,我们的终极目标是翻译成机器码,而汇编是目前对机器码最好的解释码,因此我们的IR差不多就是一种汇编方言(Dialect)。

由于目前我们支持的字节码比较少,我们的翻译代码也很简洁明了,如下:

func (c *Compiler) Compile() ([]*SSA, error) {
    ir := make([]*SSA, 0)
    pushSSA := func(a string, b, c interface{}) {
        ssa := &SSA{
            Action: a,
            Arg1:   b,
            Arg2:   c,
        }
        ir = append(ir, ssa)
    }
    for c.index < len(c.Opcodes) {
        op := c.fetch()
        switch op.Code {
        case "LOAD_FAST":
            pushSSA("push", c.variable(op.Arg.(int)), nil)
        case "STORE_FAST":
            pushSSA("pop", "rdi", nil)
            pushSSA("move", c.variable(op.Arg.(int)), "rdi")
        case "LOAD_CONST": // 加载立即数
            pushSSA("immediate", "rdi", c.Constants[op.Arg.(int)])
            pushSSA("push", "rdi", nil)
        case "BINARY_MULTIPLY": // 乘法
            pushSSA("pop", "rdi", nil)
            pushSSA("pop", "rsi", nil)
            pushSSA("imul", "rdi", "rsi")
            pushSSA("push", "rdi", nil)
        case "BINARY_ADD", "INPLACE_ADD": // 加法
            pushSSA("pop", "rdi", nil)
            pushSSA("pop", "rsi", nil)
            pushSSA("add", "rdi", "rsi")
            pushSSA("push", "rdi", nil)
        case "BINARY_SUBTRACT", "INPLACE_SUBTRACT": // 减法
            pushSSA("pop", "rsi", nil)
            pushSSA("pop", "rdi", nil)
            pushSSA("sub", "rdi", "rsi")
            pushSSA("push", "rdi", nil)
        case "UNARY_NEGATIVE":  // -x
            pushSSA("pop", "rdi", nil)
            pushSSA("neg", "rdi", nil)
            pushSSA("push", "rdi", nil)
        case "RETURN_VALUE":  // 返回
            pushSSA("pop", "rax", nil)
            pushSSA("ret", nil, nil)
        default:
            return nil, fmt.Errorf("%s not support", op.Code)
        }
    }
    return ir, nil
}

Compile函数负责将字节码翻译为IR(SSA),借助寄存器和栈,能够很迅速的将字节码翻译为对应的IR。

运行程序,翻译foo函数的字节码,可以得到如下的输出:

push rax none
push rax none
pop rdi none
pop rsi none
imul rdi rsi
push rdi none
push rbx none
push rbx none
pop rdi none
pop rsi none
imul rdi rsi
push rdi none
pop rsi none
pop rdi none
sub rdi rsi
push rdi none
pop rax none
ret none none

可以看到,foo函数仅有8条字节码指令,但翻译为IR后,数量就翻倍了,而指令数量直接影响到了程序执行效率。

因此,我们加入一个简单的优化器,来优化IR。这里不会涉及到太复杂的优化算法,我们只罗列几个简单的优化实现,比如:

push rax none
push rax none
pop rdi none

这里push rax明显重复了两次,也对应了两次LOAD_FAST,但对于寄存器分配而言,这明显重复了。

另外:

push rax none
pop rdi none

现将rax的栈入栈,然后再将栈顶的值推出,并赋值给rdi,这两个操作明显可以直接优化成一个操作:

mov rdi rax

这里直接将rax的值拷贝到rdi就行了。对应的优化代码如下:

if op1 == "push" &amp;&amp; op2 == "pop" {
    index += 2
    pushSSA("mov", a2, a1)
    continue
}

如果出现类似mov rdi rdi这样无效的复制指令,也是可以直接优化的,对应的代码如下:

if op1 == "mov" &amp;&amp; a1 == b1 {
    index += 1
    continue
}

当然也有一些其它的优化,它们都是对多余指令、重复指令的精简,整体代码如下:

func Optimize(ir []*SSA) []*SSA {
    ret := make([]*SSA, 0)
    fetch := func(n int) (string,
        interface{}, interface{}) {
        if n < len(ir) {
            return ir[n].Action,
                ir[n].Arg1, ir[n].Arg2
        }
        return "", nil, nil
    }
    pushSSA := func(a string, b, c interface{}) {
        ssa := &amp;SSA{
            Action: a,
            Arg1:   b,
            Arg2:   c,
        }
        ret = append(ret, ssa)
    }
    index := 0
    for index < len(ir) {
        op1, a1, b1 := fetch(index)
        op2, a2, b2 := fetch(index + 1)
        op3, a3, _ := fetch(index + 2)
        if op1 == "mov" &amp;&amp; a1 == b1 {
            index += 1
            continue
        }
        if op1 == "mov" &amp;&amp; op2 == "mov" &amp;&amp; a1 == b2 {
            index += 2
            pushSSA("mov", a2, b1)
            continue
        }
        if op1 == "push" &amp;&amp; op2 == "pop" {
            index += 2
            pushSSA("mov", a2, a1)
            continue
        }
        if op1 == "push" &amp;&amp; op3 == "pop" &amp;&amp;
            op2 != "push" &amp;&amp; op2 != "pop" {
            if a2 != a3 {
                index += 3
                pushSSA("mov", a3, a1)
                pushSSA(op2, a2, b2)
                continue
            }
        }
        index++
        pushSSA(op1, a1, b1)
    }
    return ret
}

我们实现了一个简单的优化器,虽然简单,但效果却很好,将字节码生成的IR优化后,新的IR如下:

mov rsi rax
mov rdi rax
imul rdi rsi
push rdi none
mov rsi rbx
mov rdi rbx
imul rdi rsi
mov rsi rdi
pop rdi none
sub rdi rsi
mov rax rdi
ret none none

IR被精简了一半,大幅提高了程序运行效率。其实在这里已经体现出IR的强大作用,IR具有很强的表现力,并且适合程序进行分析和优化,比起直接翻译为机器码,添加IR中间层是很有必要的。

得到IR后,我们需要将IR翻译为具体的机器码,这个地方的翻译是比较枯燥的,实际上就是人肉翻译,以mov指令为例:

func (as *Assembler) mov(a, b string) {
    as.emit(0x48, 0x89, 0xc0|as.register(b, a))
}


func (as *Assembler) register(a, b string) byte {
    order := map[string]byte{
        "rax": 0,
        "rcx": 1,
        "rdx": 2,
        "rbx": 3,
        "rsp": 4,
        "rbp": 5,
        "rsi": 6,
        "rdi": 7,
    }
    enc := order[a]
    if b != "" {
        enc = enc<<3 | order[b]
    }
    return enc
}

mov指令对应的机器码实际上就是唯一的,区别在于不同的寄存器,指令的操作数会不一样,比如rax对应序号0,其它操作寄存器的指令也类似于此,因此这里不做过多介绍,感兴趣的同学可以自己将程序编译为执行文件后,使用objdump工具来查看,整体的翻译代码如下:

func (as *Assembler) Assembly(ir []*SSA) []byte {
    for _, ssa := range ir {
        name, a, b := ssa.Action, ssa.Arg1, ssa.Arg2
        switch name {
        case "ret":
            as.ret()
        case "push":
            as.push(a.(string))
        case "pop":
            as.pop(a.(string))
        case "imul":
            as.imul(a.(string), b.(string))
        case "add":
            as.add(a.(string), b.(string))
        case "sub":
            as.sub(a.(string), b.(string))
        case "neg":
            as.neg(a.(string))
        case "mov":
            as.mov(a.(string), b.(string))
        case "immediate":
            as.immediate(a.(string), b.(int))
        }
    }
    return as.code[:as.index]
}

得到机器码后,JIT的工作差不多就完成了,剩下的就是将机器码写入内存,然后执行了,如下:

func main() {
    opcodes := []*py.Opcode{ /* ... */ }
    c := py.NewCompiler(opcodes, []interface{}{nil})
    ir, err := c.Compile()
    // ... 可根据实际的情况,来选择优化几次
    ir = py.Optimize(ir)
    ir = py.Optimize(ir)
    // ....
    assembler := py.NewAssembler(4096)
    code := assembler.Assembly(ir)
    // ...
    area, err := syscall.Mmap(
        -1,
        0,
        4096,
        syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC,
        syscall.MAP_PRIVATE|syscall.MAP_ANON) // linux for MAP_ANONYMOUS
    if err != nil {
        fmt.Printf("mmap err: %v", err)
    }
    for i, by := range code { // 选择 copy 无疑更佳
        area[i] = by
    }
    type fooFunc func(x, y int64) int64
    unsafePrintFunc := (uintptr)(unsafe.Pointer(&amp;area))
    foo := *(*fooFunc)(unsafe.Pointer(&amp;unsafePrintFunc))
    ret := foo(3, 2)
    fmt.Println(ret)
}

这里使用mmap映射出一段内存后,将生成的机器码写入内存,然后将其通过指针强转为函数指针,最后通过函数指针调用foo(3,2),得到如下结果:

5

提示一下,由于不同语言的调用约定是不一样的,go自1.17使用寄存器来传参后,其参数是按照rax, rcx, rdx来排列的,因此如果是在其它语言下,生成的机器码是不同的。

从结果可以发现,JIT是成功的,一个简单的Python JIT编译器就这样被实现了。

三、魔法:自举(bootstrap)

go是自举实现的,而且是一个完全自举的编译器实现,即编译器、链接器等等都是用go实现的;不仅如此,go把编译器的前端部分直接开放在了标准库中,这意味着我们不仅可以直接使用前端部分,还可以替换另外的中、后端平台来编译go。如下:

561d7ca50714023ba2140b112950395b.png

我们借助go标准库和LLVM就能实现一个非常简单的编译器,不再使用go默认编译器独有的IR和机器码生成器。

目标很明确:

package main


import "fmt"


func add(x int, y int) int {
    return x + y
}


func main() {
    fmt.Println(add(10, 2))
}

实现一个极简编译器,将这段go代码编译为可执行文件即可。

首先,使用go/parser等标准库来等go源代码进行解析,如下:

func main() {
    fset := token.NewFileSet() // 相对于fset的position
    src := `package main


import "fmt"


func add(x int, y int) int {
    return x + y
}


func main() {
    fmt.Println(add(10, 2))
}`


    f, err := parser.ParseFile(fset, "", src, parser.AllErrors)
    if err != nil {
        fmt.Println(err)
        return
    }


    ast.Print(fset, f)
}

由于go良好的自举设计,我们可以轻松得到这段代码的AST树:

0  *ast.File {
1  .  Package: 1:1
2  .  Name: *ast.Ident {
3  .  .  NamePos: 1:9
4  .  .  Name: "main"
5  .  }
6  .  Decls: []ast.Decl (len = 3) {
7  .  .  0: *ast.GenDecl {
8  .  .  .  TokPos: 3:1
9  .  .  .  Tok: import
.... 省略

LLVM为了屏蔽不同平台架构之间的指令差异性,设计了一种IR,举例:

define i32 @add(i32 %a, i32 %b) {
  %1 = add i32 %a, %b
  ret i32 %1
}

LLVM的IR不同于上面提到的汇编方言,而是一种更加高级的抽象,有基本的函数定义、分支等,更像是一种low level C。

LLVM的编译器clang是支持直接将IR编译为可执行文件的,在编译的过程中会自动对IR、机器码进行优化,这也是LLVM近些年火爆的原因,很多语言搭上了LLVM的快车,比如Rust等。

所以,任务更加简单了,只需要在AST树翻译为LLVM IR即可。实际上,有一些项目就是采用这种做法,比如gollvm和tinygo等。

当然,一门语言的语法翻译也不是一件易事,感兴趣的同学可以点开gollvm和tinygo的链接看看它们是如何做的,这里笔者仅简单的翻译上面的例子。

得到AST树后,我们需要拿到add、main函数的定义,并将其翻译为IR,如下:

for _, decl := range f.Decls {
    if fn, ok := decl.(*ast.FuncDecl); ok {
        if fn.Name.Name == "add" {
            translateAdd(m, fn, funcMap)
        } else if fn.Name.Name == "main" {
            translateMain(m, fn, funcMap)
        } else {
            continue
        }
    }
}

对于add函数,没有复杂的函数体,我们提取出参数、返回值然后生成对应的IR即可:

func translateAdd(m *ir.Module, decl *ast.FuncDecl, funcMap map[string]*ir.Func) *ir.Func {
    params := make([]*ir.Param, 0)
    for _, field := range decl.Type.Params.List {
        paramName := field.Names[0].Name
        paramType := field.Type.(*ast.Ident).Name
        if paramType != "int" { // 暂不支持
            continue
        }
        params = append(params, ir.NewParam(paramName, types.I32))
    }
    returnType := decl.Type.Results.List[0].Type.(*ast.Ident).Name
    if returnType != "int" { // 暂不支持
        panic(returnType + " return type is not support")
    }
    funcDefine := m.NewFunc(decl.Name.Name, types.I32, params...)
    ab := funcDefine.NewBlock("")
    ab.NewRet(ab.NewAdd(funcDefine.Params[0], funcDefine.Params[1]))
    funcMap[decl.Name.Name] = funcDefine
    return funcDefine
}

而在main函数中,我们不仅需要调用add函数,还需要调用println函数来打印结果,所以,我们提前定义一个printf内置函数,并将其加入到哈希表中方便后续查找:

// builtin
printf := m.NewFunc(
    "printf",
    types.I32,
    ir.NewParam("", types.NewPointer(types.I8)),
)
printf.Sig.Variadic = true


funcMap := map[string]*ir.Func{
        "printf": printf,
}

main函数中,需要对add函数的参数进行处理,如下:

func translateMain(m *ir.Module, decl *ast.FuncDecl, funcMap map[string]*ir.Func) *ir.Func {
    zero := constant.NewInt(types.I32, 0)
    stmt := decl.Body.List[0].(*ast.ExprStmt).X.(*ast.CallExpr).Args[0].(*ast.CallExpr)
    args := make([]value.Value, 0)
    for _, item := range stmt.Args {
        val := item.(*ast.BasicLit).Value
        i, err := strconv.ParseInt(val, 10, 64)
        if err != nil {
            continue
        }
        args = append(args, constant.NewInt(types.I32, i))
    }


    funcMain := m.NewFunc("main", types.I32)
    mb := funcMain.NewBlock("")
    result := mb.NewCall(funcMap["add"], args...)
    formatStr := m.NewGlobalDef("formatStr", constant.NewCharArrayFromString("%d\n"))
    format := constant.NewGetElementPtr(types.NewArray(3, types.I8), formatStr, zero, zero)
    mb.NewCall(funcMap["printf"], format, result)
    mb.NewRet(zero)
    return funcMain
}

然后,我们将得到的IR打印并写入文件,并调用clang编译为执行文件:

func main() {
    fset := token.NewFileSet() // 相对于fset的position
    src := `package main


import "fmt"


func add(x int, y int) int {
    return x + y
}


func main() {
    fmt.Println(add(10, 2))
}`
    f, err := parser.ParseFile(fset, "", src, parser.AllErrors)
    if err != nil {
        fmt.Println(err)
        return
    }
    m := ir.NewModule()
    // builtin
    printf := m.NewFunc(
        "printf",
        types.I32,
        ir.NewParam("", types.NewPointer(types.I8)),
    )
    printf.Sig.Variadic = true
    funcMap := map[string]*ir.Func{
        "printf": printf,
    }
    for _, decl := range f.Decls {
        if fn, ok := decl.(*ast.FuncDecl); ok {
            if fn.Name.Name == "add" {
                translateAdd(m, fn, funcMap)
            } else if fn.Name.Name == "main" {
                translateMain(m, fn, funcMap)
            } else {
                continue
            }
        }
    }
    fmt.Println(m.String())
    err = ioutil.WriteFile("./add.ll", []byte(m.String()), 0666)
    if err != nil {
        panic(err)
    }
    err = exec.Command("clang", "./add.ll").Run()
    if err != nil {
        panic(err)
    }
}

运行程序,我们可以在命令行中看到生成的IR:

@formatStr = global [3 x i8] c"%d\0A"


declare i32 @printf(i8* %0, ...)


define i32 @add(i32 %x, i32 %y) {
0:
        %1 = add i32 %x, %y
        ret i32 %1
}


define i32 @main() {
0:
        %1 = call i32 @add(i32 10, i32 2)
        %2 = call i32 (i8*, ...) @printf(i8* getelementptr ([3 x i8], [3 x i8]* @formatStr, i32 0, i32 0), i32 %1)
        ret i32 0
}

且在当前文件夹下有a.out可执行文件,我们尝试运行它:

$ ./a.out
12

至此,一个极简的go自举编译器就实现了,我们也顺利得到了正确的结果。

总结

很明显,本文内容与编译器强相关,编译器是一个极其有意思的主题,希望本文能带给你一个新的编译器视角,然后去学习、实践它。体验不一样的乐趣。


参考资料:

1.go

2.gopher-os

3.eggos

4.Go 1.17的新调用规约

5.JIT compiling a subset of Python to x86-64

6.tinygo

7.llir

 作者简介

58ef98d797854f87b520a8f0b3ea8bfc.png

pedrogao 

腾讯后端工程师

腾讯后端工程师,平平凡凡小码农,普普通通打工人!

 推荐阅读

来了!Go的2个黑魔法技巧

从JavaScript看字符编码的前世今生!

揭秘无锁队列的几种实现及性能对比!

PyTorch分布式训练进阶:这些细节你都注意到了吗?

39bdf4b2b2cb6b0672dfbf5260ecedb6.gif

相关推荐

大模型的Agent Skill功能,在LLM HTTP底层交互流中是怎么承载的?

网络文章文本的 skill,昨天我实现了之后也就开始好奇起 Skills 的底层原理了——相比起各种花里胡哨的 agrnt 应用,我一直以来都很对大模型在纯粹的 HTTP 交互层面是如何交互的更感兴趣。Skill 的全部"魔法"都发生在 system prompt 的措辞和 SKILL.md 文件的编写质量上——本质上是一种 prompt engineering + 文件系统的组合。但是各种天花乱坠的 skills,是如何在底层的 HTTP 中,与大模型——这个只会嘴遁的工具交互的呢?

QcloudCommunity的博客 406

万字入门AI Infra:深入理解大模型中的数学与Infra优化

关注腾讯云开发者,一手技术干货提前解锁👇AI Infra科普、入门或者进阶 ~~ 不到 5 万字,带你融会贯通理解大模型的数学、算法、硬件到系统工程。任何问题欢迎沟通交流。可怕,我竟然有点认同这句话。当然大概率是因为姚顺宇太懂AI了,而我则是因为无知者无畏。不过仔细想想,大模型推理中的数学逻辑确实不复杂,很多原理只需要高中数学知识就能看懂。但是为什么像 vLLM 这种推理系统这么复杂呢?本文将拆解大模型中几个核心操作(RMSNorm、Softmax、Causal Mask、Sampling)背后的数学与

QcloudCommunity的博客 1166

平平无奇的源码,竟藏着Agent的核心秘密?

System Prompt 是剧本,Skill 是剧本的扩展包,Agent Loop 是演员按剧本行动。欢迎留言,我们将邀请作者针对性回复你的评论,我们将选取1则优质的评论,送出腾讯云定制文件袋套装。,让 Agent 能主动"巡逻",而不只是被动等待用户消息。OpenClaw 的 System Prompt 分层结构。的"行为底线",任何 Skill 或用户命令都无法覆盖它。精华 2:Skill 的"按需加载"避免 token 浪费。精华 1:System Prompt 是"分层乐高积木"

QcloudCommunity的博客 426

QQ音乐Harness Engineering实践

一个看似简单的需求,背后可能牵动服务调用链、配置、灰度、埋点、错误码、接口契约和历史兼容性。当 AI 开始快速生成大量代码,真正的瓶颈就不再是"写不出来",而是"看不完、想不清、管不住"。在对话式编码阶段,一种典型的工作模式逐渐流行:开发者把需求丢给 AI,扫一眼输出"看起来对"就直接采纳,不审 diff、不追问逻辑、不验证边界。以音乐商业化业务为例,一个需求可能从 TAPD 单开始,经过需求评审、技术方案、服务影响面分析、IDL 契约变更、业务代码实现、测试验证、CR、灰度上线,最后才进入稳定运行。

QcloudCommunity的博客 713

从Prompt、Context到Harness,工程的三次进化与终局之战

同时,强制要求散落各处的决策记录(Slack、邮件、文档)全部迁移至代码仓库,确保 Agent 的唯一信息来源是可信的、版本受控的仓库。更坑爹的是,研究表明,当上下文过长时,模型会出现"中间遗忘(Lost in the Middle)"现象:它对开头和结尾的内容记忆较好,对中间大段内容的关注度大幅下降。这篇文章,我想带你完整走一遍这三次进化的逻辑:它们分别解决了什么问题,它们之间是什么关系,它们的边界在哪里,以及:当三者融合,AI 工程师的终极形态究竟是什么?大语言模型的本质,就是这位金鱼助理。

QcloudCommunity的博客 718

让Skill自己训练自己:8阶段Loop、3层评测、5维AND门控,从此实现自进化

所以每一轮改动都是有据可查的,不是「感觉这里可以优化」,而是「Case 41 挂了,因为 agent 把离职问题路由到了邮箱分类,但正确答案在通讯录,所以我改 root_index.md 加个易混淆提示」。但让我最后决定把它记录下来的,不是这的个「吸收」过程,而是它真的在替我探索我不想费脑力触达的边界,这看起来更像是一个机器智能应该有的样子。不准跨层,每轮只改一层的东西。但我经过这 19 轮之后觉得,更准确的描述是互补——你在明处看着,AI 在暗处替你试错,你看不见的那一半就是 AI 能贡献的地方。

QcloudCommunity的博客 1127

只加两行代码,为什么要两天?一文深度理解业务系统的复杂性

关注腾讯云开发者,一手技术干货提前解锁👇业务系统复杂性一直是令开发者头痛的问题。复杂的不是增加一个需求需要耗费多少时间,而是在增加一个需求后带来的蝴蝶效应:其它功能会不会受到影响、要如何去找到这些影响,最终如何实现系统正常运行......功能之间隐秘增加的耦合、不可避免的代码腐化在导致业务复杂性增加。大家都在说的软件开发提效到底在提什么?程序员日常工作中应该如何提升开发效率?敏捷开发、瀑布流式开发孰是孰非?欢迎阅读。业务背景与目标我依然非常清晰地记得某个时候,我所在团队 Leader 曾讲过一段话:“我担

QcloudCommunity的博客 712

从零设计生产级 Multi-Agent Harness:架构、评估、记忆、成本与 MCP 工具接入全拆解

在 AI Agent 领域,Multi-Agent Harness 指的是把多个 Agent 的能力、工具、状态、通信、编排、监控统一收束在一个运行时之内的框架。第三,Agent 路由。一个输入框,一个大模型,几个工具,一段写得很漂亮的 System Prompt,再配上一个炫酷的前端,看起来就像一个能干活的“数字员工”。Multi-Agent 完全不一样——它有计划、有中间步骤、有工具调用、有 Agent 协作、有重试、有反思、有最终合成。增加 Budget、权限、重试、压缩、轨迹评估、审计、回归测试。

QcloudCommunity的博客 710

拆完Hermes源码,我发现Agent的“自我进化“根本不需要训练模型

两个月 5.2 万 Star,Hermes Agent 用一个"LLM 审判官"机制实现了 Agent 的自我进化——不改模型权重,只改"怎么用模型"的策略。OpenClaw(社区昵称"龙虾"🦞)是 2025-2026 年最火的开源 Agent 框架,30 万+ Star,插件市场 ClawHub 里有数千个插件,能接入 Telegram、WhatsApp、Slack、飞书、企业微信——几乎是一个"个人 AI 操作系统"。Skill 是英语写的,Memory 是英语写的,审查 Prompt 也是英语写的。

QcloudCommunity的博客 909

AI Infra 其实没有多少新东西

传统 Infra 追求横向扩展,而 AI Infra 呈现 “AI 大型机”特性,是因为传统后台服务的可以容忍毫秒级延迟,但 AI 集群不行,GPU 的算力是 CPU 的数百倍,微秒级的延时等待也会造成很大的算力损耗,需要硬件的高度集成。在可预见的1-3年的未来,这样的专用硬件+网络的集中式架构很难发生比较大的改变。与之相似,模型推理的批处理就是将多个输入样本打包(batch),将原本串行的 N 次轻量的推理计算,合并为 1 次重量的计算,实现单位时间内处理更多的请求,提高了 GPU 利用率。

QcloudCommunity的博客 823

程序员越早想通这些越好

永远没有正确架构,你永远无法偿还完所有的技术债务,如同你永远不会设计出完美的界面,请避开完美主义的陷阱,永远没有完美的代码,能够满足当前需求,又为未来不管是自己还是他人留有扩展空间,就很好了。我们努力的方向就是将现实世界映射到有限的数据结构中,这就需要用户自定义类型,如果你的代码中包含类似 trader portfolio 这样的概念,你就可以使用他们的名称来建模,而不是使用基本数据类型复杂嵌套表述你的目的。从另一个角度看,代码里的注释都是补丁,是碎片化的,局部的注释很难推导模块整体的顺序及耦合关系。

QcloudCommunity的博客 465

RAG已死?不,是Grep回归了!

这样一来可以直接复用已有的计算缓存,只为新增的增量部分付全价,前面累积的大头按约 1/10 的缓存价计费。所以实际的工具组合方式是灵活的:Grep(默认模式)→ Read 是最常见的路径,但 Grep(content 模式)可以独立使用,LLM 也可以直接调 Read(如果已经知道文件路径),或者一次同时发起多个 Grep 并行搜索。它从零开始构建自己的对话历史,不继承主对话的消息,这意味着它搜索过程中产生的大量 grep 结果、代码片段都留在自己的 context 里,主对话只收到一段总结性的文本结论。

QcloudCommunity的博客 576

深入浅出Harness Engineerring之核心模式与理念

在智能体生命周期的关键节点(如会话开始、工具调用后)自动触发预设动作(如代码格式化),由系统确保关键流程被执行,不依赖可能被模型遗忘的指令。简单讲就是利用向量存储这个技术,来实现模糊检索,原理是:即使字面不同,但语义相近的文本,其向量在数学空间中的位置也很接近。根据命令类型、参数和影响,自动评估其风险等级(安全、有风险、危险),并采取自动执行、请求确认或直接拦截等不同策略。关键特性是可隔离、可重建、可扩展。不是一次判断就定终身,允许你改变、允许情况复杂,通过不断观察、思考、调整,越来越懂真实的你。

QcloudCommunity的博客 623

Harness Engineering实践心得:如何高效驾驭AI?

但如果这些经验被拆成一条条 Rule、一个个 Skill、一项项 Script 检查——"XAML 不能有中文"、"编译要用这个路径的 MSBuild"、"SVN 命令必须带认证参数"——AI 就能在秒级时间里"吸收"十年的踩坑经验。然后可以看到下方的图,我的PM会很顺利的调度其他agent分别负责不同的事项,遇到问题的时候也能准确的找到合适的agent。MCP 本质上是在扩展 AI 的"感知范围"——Rule 管"该怎么做",Skill 管"怎么操作",MCP 管"能看到什么、能碰到什么"。

QcloudCommunity的博客 841

从第一性原理思考 Agentic Engineering

需要注意的是,这一结论是有条件的:如果 AI 缺乏领域知识,过早介入反而可能引入 1.2 节所述的"似是而非"损耗——因此上下文供给(实践 1)是 AI 全链条参与的前提,而非结果。通过一个专门的自迭代 Skill 实现——当 AI 在协作过程中识别到自己被纠正(用户否定了 AI 的输出并给出了正确方向),它自动评估这个错误的根因,搜索现有的 Rules 和 Skills 中是否已有针对该类问题的处理规则。事实上,越是复杂的项目,工程师的认知负担越重(公理 3),AI 分担认知负担的潜在边际价值也越大。

QcloudCommunity的博客 757

万字干货!Harness Engineering如何工程化落地?

关注腾讯云开发者,一手技术干货提前解锁👇Harness Engineering 的概念已经火了有一阵了,全网很多文章基本都是在讲理念,讲为什么今天做 AI 开发,不能只靠一段提示词,也不能把模型当一个“更聪明的代码补全”。但与此同时,一个问题也在持续地发酵:这个问题特别重要。因为 Harness 这个词听上去很大,很像一个抽象的方法论,但它如果不能落到工程、目录、文档、脚本、工作流里,那它最后就只是一句漂亮的话。事实上,不同项目落地出来的 Harness,表面上可能完全不一样。有的项目是 CI 很强,有的

QcloudCommunity的博客 1168

全方位对比:Hermes VS OpenClaw

多账号矩阵运营 如果你的业务涉及同时管理多个社交媒体账号、多个平台的内容分发、多渠道的客户互动——OpenClaw是目前市场上的"无可替代的底座"。一个有专业背景的个人,现在可以快速构建一个垂直领域的AI助手:比如"财务分析Hermes"、"法律咨询Hermes"、"技术架构Hermes",直接通过微信提供服务,积累自己的专业IP。跨平台生态融合 如果你的工作流涉及飞书、Slack、Discord等多个平台的深度集成,需要这些平台之间的数据流转和联动——OpenClaw的生态支持是强大的。

QcloudCommunity的博客 1217

一文讲透:Harness Engineering即控制论!

而研发模式的升级我认为将会是一个飞跃的过程,之所以现在我们仍然需要需要参与编码,一个非常大的原因就是缺乏传感器和基建,仍然需要人来串流程,比如需要人来验证UI是否正确、需要人来告诉AI代码不符合代码规范,因此在有些场景我们仍然感觉 AI 完成的很吃力,但这不是 AI 能力不行,而是我们并没有为 AI 提供友好的环境……控制论中的突变理论介绍了矫枉过正和飞跃现象之间的矛盾,如果质变中经历的中间过渡态是不稳定的,那么他就是一个飞跃的过程,如果中间过渡态是稳定的,那么他就是一个渐变的过程。但是这些阶段要解决的。

QcloudCommunity的博客 1203

神级开源插件!一句话让QClaw变3D小镇,Agent工作可视化,赛博云监工

这个架构是翻了几次车才想通的—原有AI的实现机制是用 setTimeout 猜动画时长导致了幽灵 NPC、工位撞车、越改越崩,后面我停下来换了思路,推翻原有的架构,按照“意图+回传”的架构进行重构,之前的问题一次性全部解决。15 天,我和 AI 吵了 185 次架, 4.5 万行代码,开发了一款 QClaw / OpenClaw 的UGC游戏插件,这可能是人类跟 Agents 交互的新范式,插件已完全开源,有需要的欢迎自取~我天天用,用着用着我就在思考,Agent 产品现在的交互体验,基本都一个样。

QcloudCommunity的博客 1196

一文搞懂Hermes:新顶流Agent如何从经验中自我进化

Voyager 是一个能在 Minecraft 中自主探索、学习技能、无限积累能力的 AI Agent,它提出了 "Skill Library"(技能库)的概念——Agent 将成功的行为序列编码为可复用的技能函数,存储在一个持续增长的代码库中。如果 Skill 的名称和描述不够精准,或者用户的任务描述与 Skill 的触发条件有语义差距,Agent 可能会错过相关的 Skill。如果进程在写入过程中崩溃,目标文件要么是旧内容(还没被替换),要么是新内容(替换已完成),绝不会出现写了一半的损坏文件。

QcloudCommunity的博客 2634

自掏腰包一万元,拥抱AI这一年,我的工具、实践和思考

其实也很好理解,过往的每个领域(比如前端、后端、设计、测试等等)都主要是这个领域的从业者在为它添砖加瓦,但是大模型时代的 AI 应用领域却几乎是所有使用 AI 的人类一起在迭代,无分领域,无分文理,无分老少。因为每个时期的 AI 都有着不同的特点,很多时候当我费尽心思去钻研,自以为有点弄明白 AI 应该怎么玩的时候,基座模型和行业范式常常就会迎来颠覆式的变革,很多过往的经验都会倾覆,然后只能从头再来。过去一年多,被热议的 AI 范式大概是沿着四个阶段递进演化,每一个阶段其实都在解决前一个阶段的核心问题。

QcloudCommunity的博客 627

办公Agent的CI/CD时刻到来了

核心观点是:企业给员工配了一堆 AI 工具,试点跑了几百个,但真正拖慢 AI 转型速度的瓶颈不在模型能力,在"最后一公里":AI 输出和实际工作流之间的那道鸿沟。代码写完,打个 tar 包,开 FTP 传到服务器上,SSH 进去解压,改配置文件,重启服务,刷一下页面祈祷别 500。你复制,切窗口,粘贴到腾讯文档里,发现 Markdown 格式全乱了,手动调标题层级,代码块重新标记语言类型,表格散架了重排一遍。模型是越来越聪明了,但"生成"到"落地"之间那段路,还是你自己在走,一步一步,手动搬运。

QcloudCommunity的博客 608

4亿token买来5个教训:让6个AI Agent连写4天代码发生了什么?

Watchdog 检测到崩溃 → 启动诊断 Agent 分析原因 → 诊断 Agent 跑满 20 轮没搞定 → Watchdog 判定“已修复”,重启系统 → 重启的系统因为诊断 Agent 留下的环境变量被继承,连续 3 次拒绝启动 → 触发连续失败保护退出 → Watchdog 第 3 次巡检,发现进程和记录文件都没了,尝试手动重启,自己也停了。启动前我翻了论文、做了架构设计、写了监控+修复异常的脚本。这个发现让我的情绪从沮丧变成了兴奋——不是那种"终于修好了"的释然,是"我找到根因了"的快感。

QcloudCommunity的博客 584

赛博斗蛐蛐:9大模型决战三国志,天命在谁?

我猜是这样的,当Gemini面临决策的时候,一系列关键词比如 守城,劣势,粮草,三国等等,在语义层面的关联性中LLM发现了竖壁清野这个关键词概率非常高,所以以此为策略,然后再根据这个策略完美执行操作。MiMo 的外交是三方中最精明的。Kimi作为国产模型,也同样颠覆了我对他的看法,最后的比赛他甚至一度是优势者,存在很多夺冠的可能性,要知道他的对手可是claude和gemini,而他经常出兵慢,回过头来想想或许正是他的策略,毕竟中后期他的优势都来自于稳健的发展和较少的战斗频率(和claude比).

QcloudCommunity的博客 874

Claude Code是怎么知道你在骂他的?这 12 条发现值得关注

确实存在——5,500+ 行,集成了消息队列、工具装配、MCP 编排、会话状态、权限、分析、文件持久化和优雅关闭,基本上一个文件干了所有事。对抗虚假声明——"不要在输出显示失败时声称所有测试通过,不要压缩或简化失败的检查来制造绿色结果"。完整的 KAIROS 是一个持续驻留的守护进程,能感知你在不在电脑前、主动寻找有用的工作做,更像是住在终端里的全职同事。源码里藏着一个叫 "Buddy" 的电子宠物系统,完整度令人惊讶——这不是一个 TODO,而是一个从数值系统到 ASCII 动画都实现了的完整功能。

QcloudCommunity的博客 701

逆向深扒Claude Code源码,我发现了什么!?

Anthropic 内部用户获得更严格的质量控制 prompt、更好的验证机制(独立 Verification Agent)、以及针对 model 弱点的专门对策(如 Capybara v8 的 29-30% 错误声明率对策)。这不是代码混乱的标志——它是整个 Agent Loop 的心脏,所有的流式处理、工具调度、上下文压缩、子代理管理都在这个文件中协调。这种"胖核心"设计是刻意的:保持核心循环的原子性,避免分散到多个模块导致的协调复杂度。核心价值:Fork 代理的工具输出不会回到主代理的上下文窗口。

QcloudCommunity的博客 892

一文讲透如何构建Harness——六大组件全解析

本文将从"裸模型的四个硬伤"出发,逐一拆解 Harness 的六大组件——文件系统、Bash + 沙箱、记忆(AGENTS.md)、Web Search + MCP、上下文工程、编排 + Hooks——带你看清 AI Agent 真正的竞争壁垒,不在模型层,而在 Harness 层。如果你用过 Agent 做复杂任务,你一定有过这样的体验:一开始 Agent 的表现很好,但随着对话变长,它开始"变笨"——遗忘之前的约定,重复之前犯过的错误,甚至对同一个问题给出前后矛盾的回答。对于简单的问答场景,这无所谓。

QcloudCommunity的博客 761

Harness Engineering 来了,SDD 还有意义吗?

他们的解法是把「品味」编码进规则,然后用一个定期运行的「doc-gardening」Agent 自动扫描漂移、发起修复 PR——「技术债务就像一笔高息贷款,不断地以小额贷款的方式偿还,总比让债务不断累积要好」。代码仓库本地的、已版本化的工件,才是 Agent 所能看到的全部。在 Vibe Coding 模式下(即不做规划、随意向 AI 提需求的开发方式),大量设计决策、跨服务约定、「为什么不选另一个方案」的讨论,全部停留在一次性的 AI 对话里——下一个人,或者下一次 AI 会话,完全无从获取。

QcloudCommunity的博客 419

零废话!一文讲透从0构建AI Agent

每个工具都要写一套"定义→解析→执行"的胶水代码。上下文管理就是在"记住足够多"和"别超标"之间找平衡,常见策略包括滑动窗口、摘要压缩、RAG 检索增强。上下文窗口是 Agent 的"工作记忆",但它有硬性限制。LLM 负责"动脑"决策,代码负责"动手"执行。这是从"聊天机器人"进化到"Agent"的关键一步。每次调 API 都把完整对话历史发过去,LLM 本身是无状态的,"记忆"完全靠客户端的数组模拟。这样 Agent 一开始就知道自己"在哪里"、"手头有什么",不需要先花一轮工具调用去探索环境。

QcloudCommunity的博客 711

距离AI把小编我裁掉还有72小时

这是一个基于 OpenClaw 开源生态打造的本地化 AI Agent 助手,已于上周开启全量公测,无需复杂配置,三步即可上手,自带安全防护,支持微信直连绑定,内置多款优质国产大模型,现阶段也是完全免费使用,基本上前面提到过的痛点都可以靠它解决。对了,这里特别强调一点,上面的截图让产品同事汗流浃背,再三找我确认 QClaw 思考过程中优美的“草”字说法,不会是默认出现的吧?AI 好,AI 得学,但更重要的是,在你的工作和生活里,哪些东西是能结构化地被 AI 所接入的,不要死守着那些脏活累活不放。

QcloudCommunity的博客 673
上一篇: TVP尖峰对话李开复&沈春华:AI未来进行式与技术的发展
下一篇: Web函数请求多并发上线,Web服务部署更快更省
腾讯云开发者
腾讯云开发者 企业官方账号 企业官方账号
博客等级 码龄10年 6291粉丝 1962原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值