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 = 0x10000000const (
    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分配单独的栈是否就能在多核运转?