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