本节是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 为基准做的实验。
予焦啦!昨日不幸地卡在一个吊诡之处:整支程序的初始化就是一行一行照着走,但为什麽存在记忆体内的资料却不能正常使用呢?没错,有个关键的初始化我们还没有做的,那就是 BSS 区段的相关处理。
.bss
区段.bss
:未初始化资料区昨日,最令我们困惑的部分是,一开始存在的内容似乎就已经注定让我们走向错误,但事实上有个盲点:那些资料存在记忆体内,但它们未必是应该存在的。
我们可以使用 readelf 工具再看看 g0
与 m0
这两个东西:
$ riscv64-buildroot-linux-musl-readelf -s ethanol/ethanol
...
1004: ffffff80000aa310 16 OBJECT GLOBAL DEFAULT 10 runtime.modinfo
1005: ffffff80000ac040 976 OBJECT GLOBAL DEFAULT 11 runtime.m0
1006: ffffff80000abea0 392 OBJECT GLOBAL DEFAULT 11 runtime.g0
1007: ffffff80000d6770 8 OBJECT GLOBAL DEFAULT 12 runtime.mcache0
...
实际上,这两个符号所代表的结构处在同一个、我们未曾探讨过的区段。使用 readelf 工具观察区段资讯:
$ riscv64-buildroot-linux-musl-readelf -S ethanol/ethanol
...
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[ 9] .noptrdata PROGBITS ffffff80000a9020 000a8020
00000000000012a0 0000000000000000 WA 0 0 32
[10] .data PROGBITS ffffff80000aa2c0 000a92c0
0000000000001990 0000000000000000 WA 0 0 32
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
...
每个区段都标示了所属的位址(Address
栏位)。所以我们可以对照出,g0
所属的 0xffffff80000abea0
其实位在 .bss
区段,因为 .bss
区段从 0xffffff80000abc60
开始,且大小为 0x2aa08
(Size
栏位),完全涵盖 g0
的位址。事实上,它也完全涵盖 m0
的 0xffffff80000ac040
。
关於 .bss 区段的说明,可以参考维基。
在执行档或是物件档中,.bss
区段用来存放未赋予初值的资料。g0
与 m0
在 Golang 程序码中都没有被赋予初值,所以归属在这个区段里面。这个区段的类型(Type
栏位)被标记为 NOBITS
,意味着,这个区段的内容可以在动态执行时期才配置,相反的,这个区段在档案当中可以不存在。
一般而言 C 语言程序的产物的 .bss 在 ELF 档案中完全不会占据空间,但是 Golang 程序还是会占据那些空间,所以从 readelf 的 偏移量(
offset
)栏位还是可以发现到变化。
无论如何,既然 g0
与 m0
是未初始值,而 Golang 的惯例又是未初始变数一定有 0 值,那麽我们就必须将之清除为 0 了。这有三个层面,
go build
指令的产物(ethanol/ethanol 执行档)中,相对应於 .bss
的偏移的部分是否含有内容?-device loader ...
参数项目实际上如何载入整个 ethanol 映像档?.bss
?其实,.bss
或是 .noptrbss
在 Golang 执行档中都不占据实体空间,这可以从几个角度验证。第一个是 readelf 工具给予我们的区段资讯:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
...
类型栏位(Type
)中的 NOBITS
值就代表,这些区段在档案中没有实质内容。第二个是载入时的区段:
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0xffffff8000001040 0xffffff8000001040
0x0000000000000118 0x0000000000000118 R 0x1000
NOTE 0x0000000000000f9c 0xffffff8000001f9c 0xffffff8000001f9c
0x0000000000000064 0x0000000000000064 R 0x4
LOAD 0x0000000000000000 0xffffff8000001000 0xffffff8000001000
0x0000000000055104 0x0000000000055104 R E 0x1000
LOAD 0x0000000000056000 0xffffff8000057000 0xffffff8000057000
0x0000000000051ef8 0x0000000000051ef8 R 0x1000
LOAD 0x00000000000a8000 0xffffff80000a9000 0xffffff80000a9000
0x0000000000002c60 0x0000000000032280 RW 0x1000
Section to Segment mapping:
Segment Sections...
00
01 .note.go.buildid
02 .text .note.go.buildid
03 .rodata .typelink .itablink .gosymtab .gopclntab
04 .go.buildinfo .noptrdata .data .bss .noptrbss
这里可以看到 .bss
与 .noptrbss
对应到第五个区块(segment),而第五个区块也是唯一一个档案中大小(栏位 FileSiz
)与记忆体大小(栏位 MemSiz
)不一样的。
又,也可以使用 objdump 工具观察
$ riscv64-buildroot-linux-musl-objdump -h hw
...
hw: file format elf64-littleriscv
Sections:
Idx Name Size VMA LMA File off Algn
...
8 .data 00001990 ffffff80000aa2c0 ffffff80000aa2c0 000a92c0 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 0002aa08 ffffff80000abc60 ffffff80000abc60 000aac60 2**5
ALLOC
10 .noptrbss 00004c00 ffffff80000d6680 ffffff80000d6680 000d5680 2**5
ALLOC
...
以 .data
区段做对照,则可发现 .bss
与 .noptrbss
都具有特殊的 ALLOC
属性。
QEMU 的 loader 支援三种主要功能,这里有精简的描述。
我们在做实验时主要采用 QEMU 的通用载入器(generic loader)来将档案或资料放置到指定的位址。由於我们在载入 ethanol 映像档的时候强迫开启了原始格式(force-raw = true
),所以是整个档案载入。若是没有开启原始格式,则这个档案会被当作 ELF 档载入。
如前一小节的叙述,虽然档案内没有实质对应到 .bss
或 noptrbss
的内容,但是现在这个原始格式的载入器,会按照档案位置如实地载入。所以我们可以回顾当初存取 m0
内容的时候,当时是存取 0x802ac0e0
这个位址,也就是以档案内偏移量来算的 0xab0e0
的位置,实际上它会对应到:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
[13] .zdebug_abbrev PROGBITS ffffff80000dc000 000ab000
0000000000000119 0000000000000000 0 0 1
[14] .zdebug_line PROGBITS ffffff80000dc119 000ab119
000000000000e7e2 0000000000000000 0 0 1
...
.zdebug_abbrev
区段里面,且使用 hexdump 工具检验看看:
$ hexdump -C hw
...
000ab0e0 59 1f d6 e0 5b e5 e0 74 28 46 bf 0b 67 a5 44 a3 |Y...[..t(F..g.D.|
...
正是我们昨日观察到的,错误记忆体存取的基底!总之是一个不该被拿来当作记忆体位址的夸张奇异值。
所以也许我们别开启原始格式就足够了?但下一节才是最踏实的方案。
以其他系统软件为例,比方说前文中提过的 Linux 与 OpenSBI,都会在相当早期的阶段清除 BSS 的内容。我们也应该这麽做:
diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index c65afa5c79..abaf4ba280 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -72,5 +72,12 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
JMP main(SB)
TEXT main(SB),NOSPLIT|NOFRAME,$0
+ MOV $runtime bss(SB), T0
+ MOV $runtime enoptrbss(SB), T1
+zeroize:
+ SD ZERO, 0(T0)
+ ADD $8, T0, T0
+ BLT T0, T1, zeroize
+
MOV $runtime rt0_go(SB), T0
重新执行的话,得到
...
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000a109
HI0000000000000005
ffffff8000075d00
0000000080245ae8
QEMU: Terminated
仍然一样撞到记忆体存取错误,但卡在不同地方了。触发错误的位置在
ffffff8000045ac0 <runtime.moduledataverify1>:
ffffff8000045ac0: 010db503 ld a0,16(s11)
ffffff8000045ac4: fb810593 addi a1,sp,-72
ffffff8000045ac8: 00b56863 bltu a0,a1,ffffff8000045ad8 <runtime.moduleda
taverify1+0x18>
ffffff8000045acc: 0000df97 auipc t6,0xd
ffffff8000045ad0: b84f82e7 jalr t0,-1148(t6) # ffffff8000052650 <runtime
.morestack_noctxt>
ffffff8000045ad4: fedff06f j ffffff8000045ac0 <runtime.moduledataveri
fy1>
ffffff8000045ad8: f2113c23 sd ra,-200(sp)
ffffff8000045adc: f3810113 addi sp,sp,-200
ffffff8000045ae0: 0d013183 ld gp,208(sp)
ffffff8000045ae4: 0001b383 ld t2,0(gp)
ffffff8000045ae8: 0003e403 lwu s0,0(t2)
而错误正是发生在最後这一行的 t2
暂存器中存放了 ffffff8000075d00
数值。看起来有点眼熟,对吧?是的,它不只是长得像而已,它实际上就是一个当前 ethanol 映像档中的一个虚拟位址,而且是一个区段的开头:
$ riscv64-buildroot-linux-musl-readelf -a hw
...
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[ 7] .gopclntab PROGBITS ffffff8000075d00 00074d00
00000000000331f8 0000000000000000 A 0 0 32
...
Symbol table '.symtab' contains 1089 entries:
Num: Value Size Type Bind Vis Ndx Name
...
118: ffffff8000075ce8 8 OBJECT LOCAL DEFAULT 5 runtime.itablink
119: ffffff8000075d00 0 OBJECT LOCAL DEFAULT 7 runtime.pclntab
120: ffffff8000075240 1686 OBJECT LOCAL DEFAULT 2 runtime.findfunctab
...
相关的程序码位在 src/runtime/symbol.go
当中,
func moduledataverify1(datap *moduledata) { // Check that the pclntab's format is valid.
hdr := datap.pcHeader
if hdr.magic != 0xfffffffa || hdr.pad1 != 0 || hdr.pad2 != 0 || hdr.minLC != sys.PCQuantum || hdr.ptrSize != sys.PtrSize {
...
回顾一下反组译的结果。首先是堆叠指标(sp
)偏移 0x208
之後的值存在 gp
暂存器,接着以 gp
暂存器作为基底取值存在 t2
,然後最後 t2
的内容是我们还不能使用的虚拟位址指标,所以出错。
这个行为对应到传入参数 datap
,这相当於是 gp
的值;datap.pcHeader
这一项取得成员的动作就相当於是 t2
,之後的 hdr.magic
就是事故现场。那麽,为什麽最一开始会有那样的值存入呢?
var firstmoduledata moduledata // linker symbol
var lastmoduledatap *moduledata // linker symbol
var modulesSlice *[]*moduledata // see activeModules
...
func moduledataverify() {
for datap := &firstmoduledata; datap != nil; datap = datap.next {
moduledataverify1(datap)
}
}
可见,源头的 firstmoduledata
符号本身,是连结器在连结时期已经绑定到虚拟位址的符号;这个定址方式是绝对的,直接指定为 0xffffff8000075d00
。相较於我们从一开始进入 ethanol/ethanol 到出现错误,期间经历许多变数存取与函数呼叫,之所以不会遇到一样的问题,是因为目前为止遭遇的符号都使用了相对程序指标定址(PC-related addressing),所以就算现在我们是被载入在实体的物理位址之上,也还是能够正确地在执行时期存取到对的函数或是变数。
也就是说,firstmoduledata
里面的内容,必须直接用到连结时就规定好的绝对位址;以 ethanol 核心的角度来讲,是一个还没有启用的虚拟位址。连结器对於执行期会被载入到哪里去,当然是没有概念的,所以这里我们遇到了一道更高的墙了。
是否已经到了启用虚拟记忆体的时机了呢?这边我们暂且打住,回头检视本日最一开始的问题。
也许诸位读者还是会感到疑惑:原先是因为读取到脏值而导致存取错误,那麽为什麽将脏值清成零能够解决问题呢?难道不会因为存取 0 作为指标而产生一样的错误吗?
这就必须正视原本这个出问题的部分的写入屏障机制。我们回顾昨日展示的执行追踪:
(gdb) bt
#0 0x0000000080254510 in runtime.gcWriteBarrier ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:653
#1 0x000000008023ebd8 in runtime.args (c=-2145037200, v=0x82200000)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/runtime1.go:63
#2 0x00000000802524b4 in runtime.rt0_go ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:54
可知是由 args
函式呼叫 gcWriteBarrier
函式,然而若我们查看原始码,会发现
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
根本就没有这个呼叫!这完全是 Golang 编译器擅作主张插入的内容,我们可以检视反组译的结果:
ffffff800003eba0: 00098f97 auipc t6,0x98
ffffff800003eba4: ae3fa823 sw gp,-1296(t6) # ffffff80000d6690 <runtime
.argc>
ffffff800003eba8: 00098197 auipc gp,0x98
ffffff800003ebac: c881e183 lwu gp,-888(gp) # ffffff80000d6830 <runtime.writeBarrier>
ffffff800003ebb0: 00019a63 bnez gp,ffffff800003ebc4 <runtime.args+0x44>
ffffff800003ebb4: 01813183 ld gp,24(sp)
ffffff800003ebb8: 0006df97 auipc t6,0x6d
ffffff800003ebbc: 0c3fb823 sd gp,208(t6) # ffffff80000abc88 <runtime.argv>
ffffff800003ebc0: 0180006f j ffffff800003ebd8 <runtime.args+0x58>
ffffff800003ebc4: 0006d297 auipc t0,0x6d
ffffff800003ebc8: 0c428293 addi t0,t0,196 # ffffff80000abc88 <runtime.argv>
ffffff800003ebcc: 01813303 ld t1,24(sp)
ffffff800003ebd0: 00016f97 auipc t6,0x16
ffffff800003ebd4: 920f80e7 jalr -1760(t6) # ffffff80000544f0 <runtime.gcWriteBarrier>
ffffff800003ebd8: 00013083 ld ra,0(sp)
ffffff800003ebdc: 00810113 addi sp,sp,8
ffffff800003ebe0: 00008067 ret
关键在於 ffffff800003ebac
基於 WriteBarrier
的判断。这里组语指令是一个载入 4 个位元组的无号整数(lwu
)并判断它是否为零(bnez
),其实是因为对应到这个位址的内容代表的是垃圾回收机制的写入屏障是否启动(enabled)。若是已经啓动,才去执行之前发生问题的 gcWriteBarrier
。
也就是说我们可以总结原本遭遇的错误了。这个用来判断的旗标,其实本身也是处在 .bss
区段中的变数,由於未清除的缘故,触发了还不应该使用的 Golang 垃圾回收机制。触发了之後,沿着写入屏障函数一路深入,最後终於存取到了未清除初值的结构体,因此引发错误。
整个 Golang 执行期提供如此丰富的功能给一般的使用者程序,我们怎麽能够期待现在垃圾回收以及其他机制已经能够提供我们使用了呢?
Golang 的垃圾回收机制从编译(compile)与连结(link)时期就开始布局了,所以最终的产物里面,会像这样看到写入屏障的安插。当记忆体的改动累积到一定程度之後,Golang 的执行期必须要抽空执行垃圾回收演算法,以回收被弃用的指标或空间等等。我们目前还没有必要深入这个部分,但可以先理解到这个机制的存在。
予焦啦!今日我们做了一个小幅修正,确保 .bss
等未初始化的资料区段能够正确的被初始化。我们又推进了一小步,但也马上就又卡住了,而且这次的起因是,连结器符号所代表的结构体当中存放了虚拟记忆体位址。
於是,本章也应该要宣告结束了。笔者目前为止已经展示了基本的除错技法、土法炼钢也要往前爬的姿态以及暴虎冯河之心。无论如何,接下来我们也应该跨出新的步伐了,那就是:启用虚拟记忆体。
说得更精炼一些:由於 Golang RISC-V 没有提供建置位置非相依可执行档(PIE,Position-Independent Executable)的选项,所以遭遇到绝对定址之後,我们就束手无策了。程序不可能按照原本规划的那样进行下去,因为目前为止都还是靠着程序指标相对定址搭配载入时期载入在实体物理位址的条件下执行的。为了解决这个现状,也是时候该启用虚拟记忆体了。
各位读者,我们明日再会!
<<: JavaScript Day 9. if、else if、if包if
>>: [CSS] Flex/Grid Layout Modules, part 13
第二天,不废话,继续摸摸摸。 ProtoPie 的 Conceptual model 影片开始解释 ...
前言 :Websocket除了能建立一个双向通讯通道外,还能干嘛? :当然是拿来Injection阿...
如本日主题,今天要来介绍一下Go语言的程序码架构,以下内容摘录自『 The Go Workshop ...
Transformer跟用attention的Seq2Seq的模型有着一样的pattern 输入的...
1.header的用法 (1)画画时 指定画面Content-type网页输出,用image/jpe...