likes
comments
collection
share

在裸机上运行Go程序

作者站长头像
站长
· 阅读数 13

Go语言虽然不需要虚拟机即可运行,但是Go仍然无法脱离操作系统在裸机环境下执行。Go Runtime中的如垃圾回收、协程调度、标准库都依赖于操作系统提供的接口。本文将尝试摆脱操作系统,让Go程序在Qemu模拟的裸机环境下运行,并打印Hello World。

本文所有代码已发布:

github.com/StellarisJA…

gitee.com/xxjay/barem…

Go程序启动过程

Go程序在执行main函数之前有一系列初始化runtime的过程。这个过程会初始化堆内存、初始化goroutine以及初始化垃圾回收器。

我们可以从下面这个最简单的Go程序来观察启动过程。

package main
import "fmt"

func main() {
    fmt.Println("Hello world")
}

我们用 go build 编译该文件,然后用strip去除debug信息。在linux系统下会生成一个名为main的ELF格式的可执行文件。

go build -gcflags='-N -l' main.go
strip -g main 

通过readelf工具查看ELF文件头,我们可以找到程序的入口地址,然后用objdump来查看代码。不过这种方式太麻烦了,其实有更简单的方法。

$ readelf -h main | grep Entry
  Entry point address:               0x45e4e0

我们可以直接用gdb工具来查看启动过程:

$ gdb main

通过gdb逐函数的调试,可以知道Go程序的初始化实际上是在一个叫rt0_go.abi0的函数中进行的。我们可以打一个断点,来查看该函数的内容。

(gdb) b runtime.rt0_go.abi0
Breakpoint 2 at 0x45aae0
(gdb) c
Continuing.
Breakpoint 2, 0x000000000045aae0 in runtime.rt0_go.abi0 ()
(gdb) x/50i $pc
   0x45abed <runtime.rt0_go.abi0+269>:  call   0x45f380 <runtime.args.abi0>
   0x45abf2 <runtime.rt0_go.abi0+274>:  call   0x45f1a0 <runtime.osinit.abi0>    # osinit
   0x45abf7 <runtime.rt0_go.abi0+279>:  call   0x45f2e0 <runtime.schedinit.abi0> # schedinit
   0x45ac04 <runtime.rt0_go.abi0+292>:  call   0x45f340 <runtime.newproc.abi0>  # newproc
   0x45ac0a <runtime.rt0_go.abi0+298>:  call   0x45ac80 <runtime.mstart.abi0>   # mstart

同样的方法插断点,可以看到schedinit的内容:

   0x435166 <runtime.schedinit+166>:    call   0x40a800 <runtime.mallocinit> # mallocinit
   0x43516b <runtime.schedinit+171>:    call   0x434f60 <runtime.cpuinit>    # cpuinit
   0x435220 <runtime.schedinit+352>:    call   0x416180 <runtime.gcinit>     # gcinit

根据上面的结果,可以看到有下面这些函数被调用:

  • osinit:初始化与操作系统的相关的东西
  • mallocinit:初始化内存分配,会初始化堆内存。
  • gcinit:初始化垃圾回收器。
  • newproc:从字面意思可以猜出,该函数是在创建Processor,我们可以大胆猜测它是在初始化GMP模型中的P。并且在该函数中会将执行主函数的goroutine交给Processor调度。
  • mstart:字面意思,开始M。同样可以联想到GMP中的机器线程M,这个函数应该是启动一个线程开始P对G的执行。

我们大概知道了main函数执行前需要进行哪些runtime初始化。具体每一个初始化的内容不做多的介绍,我们可以肯定的是它们都离不开操作系统的支持。

跳过runtime初始化 🚀

我们看到了runtime的初始化过程是离不开操作系统的,所以要想在裸机上运行Go程序,我们必须想办法跳过Runtime。

其实通过之前看ELF文件头就可以知道,程序是从Entry地址开始执行的,那我们修改Entry地址不就能跳过Runtime了吗。

准备工作

接下来我们需要一个裸机环境来测试,这里我选择使用Qemu虚拟机来模拟。因为我之前安装过RISC-V64版本的Qemu,这里我就不去安装x86版本了,后续的测试都以RISCV进行。

  • Qemu7.0.0
  • riscv64-unknown-elf-binutils:readelf、objcopy、gdb等工具

链接器修改Entry地址

一个程序的构建过程首先经过编译,然后是链接。链接可以将多个对象文件链接在一起,同样也可以修改Entry地址。

运行下列命令来编译并修改entry地址。

# 设置GOARCH riscv64,gcflags禁用编译优化,关闭CGO
$ GOOS=linux GOARCH=riscv64 CGO_ENABLE=0 go build -gcflags='-N -l' -o temp
# ld -e 将entry设置为go的主函数 main.main
$ riscv64-unknown-elf-ld -e main.main -o main temp

我们得到一个叫main的elf文件,用readelf查看ELF文件头,再用objdump查看地址的内容,可以确定entry被设置到了主函数。

$ riscv64-unknown-elf-readelf -h main | grep Entry
  Entry point address:               0x8b058             # 找到Entry地址
$ riscv64-unknown-elf-objdump -d main | grep -A 10 008b058
000000000008b058 <main.main>:     # 地址对应的主函数
   8b058:       010db303                ld      t1,16(s11)
   8b05c:       00236663                bltu    t1,sp,8b068 <main.main+0x10>
   8b060:       a70dc2ef                jal     t0,672d0 <runtime.morestack_noctxt.abi0>
   8b064:       ff5ff06f                j       8b058 <main.main>
   8b068:       fa113823                sd      ra,-80(sp)
   8b06c:       fb010113                addi    sp,sp,-80
   8b070:       00113023                sd      ra,0(sp)
   8b074:       02013423                sd      zero,40(sp)
   8b078:       02013823                sd      zero,48(sp)
   8b07c:       02810513                addi    a0,sp,40

但是这样做还不够,Qemu虚拟机不会从ELF文件读取Entry地址,它会从一个固定地址寻找我们的程序代码,这个地址在qemu-riscv下是0x80000000。然而我们所链接的地址是main.main的0x8b058地址,显然Qemu是无法自己跳转过去的。

那么如何修改我们程序代码所在的地址呢?

链接脚本

链接器支持使用链接脚本来声明程序各个分段的内存分布,关于链接脚本的格式不作介绍,我们直接从当前这个例子来看:

OUTPUT_ARCH(riscv)
ENTRY(main.main)            #这里声明entry地址实际上已经没有意义了,具体看后面的解释 
BASE_ADDRESS = 0x80000000;  #声明一个基地址

SECTIONS
{
    . = BASE_ADDRESS; #数据从基地址开始
    # 接下来是.text代码段
    .text : {                      
        *(.text, .text.*)
    }
    # 然后是.rodata只读数据段
    .rodata : {
        *(.rodata, .rodata.*)
    }
    # .data数据段
    .data : {
        *(.data, .data.*)
    }
    # .bss段
    .bss : {
        *(.bss, .bss.*)
    }
    # 实际上Go程序编译后还有其他的如debug、strtab等段,这里因为暂时不需要我们就不写了
}

现在我们的代码被加载到了0x80000000这个地址,可是我们的主函数并不在这个位置。

现在该如何使程序成功跳转到我们的主函数呢?

引导程序 🚩

我们可以在0x80000000地址插入一段汇编代码,让这段代码帮助我们跳转到主函数的位置。

.section .text
.global __start
__start:
    call main.main

这段汇编代码很简单,首先声明代码在.text段,然后创建了一个__start函数,函数里面call main.main跳转到主函数。

(实际上这段代码是不完整的,还有一些重要的工作没有做。但是先让我们看看它的执行情况)

用gcc编译entry.S,用ld将entry和go程序链接起来。

还需要注意一点,因为裸机环境下是无法识别和解析ELF的,我们还需要用objcopy把它转换成可以直接执行的二进制格式。

# gcc
$ riscv64-unknown-elf-gcc -c -o entry.o entry.S
# 链接 -T 表示使用链接脚本
$ riscv64-unknown-elf-ld -T linker.ld -o main entry.o temp
# objcopy去除ELF文件的信息
$ riscv64-unknown-elf-objcopy --strip-all -O binary main main.bin

经过上面的操作我们就成功得到了一个可以在Qemu中运行的二进制程序,接下来让我们尝试运行。

尝试裸机运行 👀

我们配合Qemu的gdb调试功能,可以看到代码的执行情况。

开启一个新的命令行,开启Qemu,加载main.bin,并开启gdb调试模式

$ qemu-system-riscv64 -machine virt -bios none -kernel main.bin -smp 1 -nographic -s -S

再开启一个新的命令行,使用gdb连接到Qemu,开始交互式调试。(注意这里的file必须是我们链接好的elf文件,而不是二进制文件)

$ riscv64-unknown-elf-gdb -ex 'file main' -ex 'set arch riscv:rv64' -ex 'target remote localhost:1234'

可以看到首先执行的是0x1000地址的一段代码,然后才跳转到了t0地址。

这段代码实际上是Qemu自带的启动代码,我们不需要过多关注它,只需要知道它最终会跳转到0x80000000这个固定地址。

0x0000000000001000 in ?? ()
(gdb) x/10i $pc
=> 0x1000:      auipc   t0,0x0
   0x1004:      addi    a2,t0,40
   0x1008:      csrr    a0,mhartid
   0x100c:      ld      a1,32(t0)
   0x1010:      ld      t0,24(t0)
   0x1014:      jr      t0

执行了0x1014这条代码之后,就跳转到了0x80000000地址,我们用x/i $pc可以看到,0x80000000确实是我们写的entry.S的汇编代码。还能看到entry代码使用jal跳转到了main.main,也就是Go的主函数。

0x0000000080000000 in __start ()
(gdb) x/i $pc
=> 0x80000000 <__start>:        jal     ra,0x8007af10 <main.main>

继续执行我们会遇到一个麻烦。虽然我们确实能来到了主函数里面,但是当我们执行第一条ld指令后,就会出错。

0x000000008007af10 in main.main ()
(gdb) x/10i $pc
=> 0x8007af10 <main.main>:      ld      t1,16(s11)
   0x8007af14 <main.main+4>:    bltu    t1,sp,0x8007af20 <main.main+16>
   0x8007af18 <main.main+8>:    jal     t0,0x80057188 <runtime.morestack_noctxt.abi0>
   0x8007af1c <main.main+12>:   j       0x8007af10 <main.main>
   0x8007af20 <main.main+16>:   sd      ra,-80(sp)
   0x8007af24 <main.main+20>:   addi    sp,sp,-80
   0x8007af28 <main.main+24>:   sd      ra,0(sp)
   0x8007af2c <main.main+28>:   sd      zero,40(sp)
   0x8007af30 <main.main+32>:   sd      zero,48(sp)
   0x8007af34 <main.main+36>:   addi    a0,sp,40
(gdb) si
0x0000000000000000 in ?? () # 到了0x0地址

可以看到我们用si执行0x8007af10这条指令后,跳到了0x0地址。这实际上是Qemu执行出错时跳转的地址。接下来我们分析一下为什么出错。

函数调用与栈指针

在这之前我们需要了解一点,程序的栈是反向增长的!

  • CPU用多个寄存器来管理函数的调用与栈,其中一个是栈指针寄存器 sp 。sp指向当前的栈顶位置。
  • 因为栈是反向增长的,所以栈顶是低地址,栈底是高地址。每次分配栈帧的操作需要减小sp指针。
   0x8007af10 <main.main>:      ld      t1,16(s11) # B:
   0x8007af14 <main.main+4>:    bltu    t1,sp,0x8007af20 <main.main+16> # 比大小,if t1<sp: goto A else:pc+=1
   0x8007af18 <main.main+8>:    jal     t0,0x80057188 <runtime.morestack_noctxt.abi0> # morestack
   0x8007af1c <main.main+12>:   j       0x8007af10 <main.main> # goto B,这段实际上是循环 
   ...
   0x8007af20 <main.main+16>:   sd      ra,-80(sp)  # A: 保存ra寄存器到栈
   0x8007af24 <main.main+20>:   addi    sp,sp,-80   # 移动栈指针,分配80个字节的栈空间
   0x8007af28 <main.main+24>:   sd      ra,0(sp)

来看主函数的这段汇编代码,我们把它分成两部分。

先看上半部分,这部分实际上是一个循环,用伪代码表示大概如下:

// 栈反向增长,所以sp小于guard表示超出了栈的上边界
while sp < guard:
    call runtime.morestack()

大概的逻辑就是如果sp小于guard,就分配更多的栈空间。

根据这个逻辑,我们可以猜测guard就是栈空间的上边界,一旦我们的sp超出边界就需要分配新的栈空间了。

由于morestack是runtime下面的函数,我们需要避免调用它,所以就需要保证循环的条件无法满足,也就是说我们要分配足够的栈空间,避免runtime发现栈不足而去扩容栈。

我们再来看下面这段代码。

   0x8007af20 <main.main+16>:   sd      ra,-80(sp)  # A: 保存ra寄存器到栈
   0x8007af24 <main.main+20>:   addi    sp,sp,-80   # 移动栈指针,分配80个字节的栈空间
   0x8007af28 <main.main+24>:   sd      ra,0(sp)

由于我们跳过了初始化,此时的栈指针sp值为0,第一行代码的保存ra寄存器会向 -80 地址写数据。同样第二行代码让sp减小80,也会导致出现负的栈指针。

现在问题很清晰了,我们要做的就是分配一个足够大的栈,并把栈的上边界记录到guard变量的位置。

我们在entry.S里面加入下列代码:

# alloc a stack area in .data section
.section .data
.global stack_low
stack_low:
    .space 8192 #stack space 8KiB
    .global stack_high
stack_high:

# stack_guard:save stack's low address
.section .data
.global __stack_guard
__stack_guard:
    .space 24

上述代码的作用是在.data区创建一个大小为8KiB的空间,空间的起始和终止位置由stack_low和stack_high标识。

注意,由于栈是反向增长,low是低地址,但是是栈的上边界。high是高地址,但是是栈的下边界。

我们还在.data区创建了一个大小为16字节的空间,并命名叫stack_guard,它的实际作用就是作为上述判断栈大小的循环中的guard变量。它的值应该是我们的stack的上边界,也就是stack_low。

接下来这段代码是对__start的改进,在这里我们不仅要跳转到main函数,还需要为main函数分配栈。

.section .text
.global __start

__start:
    la sp, stack_high #set stack pointer
    la s11, stack_guard
    la a0, stack_guard
    sd a0, 16(s11)
    call main.main  #jump to go func main()

要为main分配栈很简单,只需要将sp指针指向我们的栈下边界,也就是stack_high(栈反向增长!)。然后把我们的栈上边界保存在stack_guard位置,并把s11寄存器设置为stack_guard的地址。

那么现在我们的主函数能够正常运行,并打印HelloWorld了吗?答案是仍然不行。还有最后一步需要做。

没有操作系统,如何打印HelloWorld😱

有操作系统的时候,打印HelloWorld是通过write系统调用实现的,write的目标fd是stdout。现在没有了操作系统,我们该如何打印HelloWorld呢?

UART

要完整的介绍UART是什么和如何使用太麻烦了,而且这不是我们这篇文章讨论的重点。

如果对UART感兴趣,可以去这个链接了解:www.lammertbies.nl/comm/info/s…

简单的说,我们可以通过读写UART规定的内存地址去实现在屏幕打印和读取字符。

这里我直接给出一份Go版本的代码,里面只涉及到unsafe.Pointer的使用,所以没什么难点。

package console

import "unsafe"

// UART的基地址
const UART0 = 0x10000000

const (
    THR uint = 0 // 写缓冲
    IER uint = 1 // Interrupt Enable
    FCR uint = 2 // FIFO control
    LCR uint = 3 // line control
    LSR uint = 5 // line status

    DLL uint = 0 // DLL, divisor latch LSB
    DLM uint = 1 // DLM, divisor latch LMB

    FCR_FIFO_ENABLE byte = 1 << 0
    FCR_FIFO_CLEAR  byte = 3 << 1
    LCR_EIGHT_BITS  byte = 3 << 0 // no parity
    LCR_BAUD_LATCH  byte = 1 << 7 // DLAB, DLL DLM accessible
)

func init() {
    // 关闭中断
    writeRegister(IER, 0x0)
    // DLAB
    writeRegister(LCR, LCR_BAUD_LATCH)
    // 38.4k baud rate
    writeRegister(DLL, 0x03)
    writeRegister(DLM, 0x00)
    // 8 bits payload,无奇偶校验
    writeRegister(LCR, LCR_EIGHT_BITS)
    // 开启FIFO
    writeRegister(FCR, FCR_FIFO_ENABLE|FCR_FIFO_CLEAR)
}

func PutChar(c byte) {
    for {
        // 等待写缓冲区空闲
        if readRegister(LSR)&(1<<5) != 0 {
            break
        }
    }
    // byte写入寄存器
    writeRegister(THR, c)
}

func writeRegister(offset uint, val byte) {
    ptr := unsafe.Pointer(uintptr(UART0 + offset))
    *(*byte)(ptr) = val
}

func readRegister(offset uint) byte {
    ptr := (*byte)(unsafe.Pointer(uintptr(UART0 + offset)))
    return *ptr
}

之后需要修改主函数,因为fmt.Println是依赖于操作系统的。

package main

import (
    "github.com/stellarisjay/baremetalgo/console"
)

func main() {
    console.PutChar('H')
    console.PutChar('e')
    console.PutChar('l')
    console.PutChar('l')
    console.PutChar('o')
    console.PutChar(' ')
    console.PutChar('W')
    console.PutChar('o')
    console.PutChar('r')
    console.PutChar('l')
    console.PutChar('d')
    console.PutChar('\n')
}

接下来我们还是按照之前的流程编译运行代码

可以看到它确实打印了HelloWorld,也就意味着我们这次尝试成功了。

# go build
GOOS=linux GOARCH=riscv64 go build -gcflags='-N -l' -o temp
# 编译entry.S
riscv64-unknown-elf-gcc -c -o entry.o entry.S
# 链接entry和go程序
riscv64-unknown-elf-ld -T linker.ld -o main entry.o temp
# ELF转换二进制文件
riscv64-unknown-elf-objcopy --strip-all -O binary main main.bin
# qemu运行
qemu-system-riscv64 -machine virt -bios none -kernel main.bin -smp 1 -nographic
Hello World

但是我们现在的这个方法真的能运行完整的Go语法吗?

缺陷与展望

由于我们跳过了runtime的初始化,Go语言的很多语法和标准库都无法使用。

  • 没有堆内存:我们跳过了初始化中的mallocint和gcinit,意味着堆内存以及垃圾回收器都没有初始化。我们也就无法使用任何需要堆内存的数据结构,就连编译器产生的对象逃逸也无法正常进行。
  • 无法使用goroutine:goroutine是Go语言最重要的组成部分之一,然而我们跳过了newproc、mstart等GMP相关的初始化过程。并且这些内容都是与操作系统线程相关的,在裸机上都无法使用。
  • 体积太大:我们只是跳过了Runtime的执行,Runtime的代码依然被打包在了我们的可执行文件中。导致最终的二进制文件大部分都是我们不需要的代码。

虽然如此,但是我们所作的尝试是否有意义呢?在此基础上我们还能做什么?

  • 手动分配内存:根据RISCV的特权级架构以及Qemu的模拟,我们的程序处于最高级别的机器模式。这意味着我们不再是受到操作系统管理的用户态程序,我们拥有对内存等资源的绝对控制权。借助Go的指针运算,我们是否可以在物理内存上实现一套手动分配内存机制,比如alloc和dealloc。
  • 寄存器操作:我们不能直接在Go程序中嵌入汇编,因此我们不能直接读取和修改CPU的寄存器。但是CGO允许我们在Go程序里面使用C语言,而C语言具有嵌入汇编的功能。这是否意味着我们能够实现一套读写寄存器的方法,在此基础上就能实现中断、分页内存等功能。
  • 多核运行:本次尝试设置了Qemu的参数smp=1,即模拟一个CPU核心。如果我们模拟多个CPU核心会如何?我们可以将entry代码中的一个栈,改成多个栈。因为每个cpu都有独立的寄存器,所以不用担心sp、s11等寄存器的冲突,只需要为每个CPU分配单独的栈是否就能在多核运行?