在裸机上运行Go程序
Go语言虽然不需要虚拟机即可运行,但是Go仍然无法脱离操作系统在裸机环境下执行。Go Runtime中的如垃圾回收、协程调度、标准库都依赖于操作系统提供的接口。本文将尝试摆脱操作系统,让Go程序在Qemu模拟的裸机环境下运行,并打印Hello World。
本文所有代码已发布:
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分配单独的栈是否就能在多核运行?
转载自:https://juejin.cn/post/7250382724879138873