予焦啦!BSS 初始化

本节是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 为基准做的实验。

予焦啦!昨日不幸地卡在一个吊诡之处:整支程序的初始化就是一行一行照着走,但为什麽存在记忆体内的资料却不能正常使用呢?没错,有个关键的初始化我们还没有做的,那就是 BSS 区段的相关处理

本节重点概念

  • 基础
    • readelf 工具查询区段与符号
    • .bss 区段
  • Golang
    • 写入屏障(Write Barrier)机制概述

.bss:未初始化资料区

昨日,最令我们困惑的部分是,一开始存在的内容似乎就已经注定让我们走向错误,但事实上有个盲点:那些资料存在记忆体内,但它们未必是应该存在的。

我们可以使用 readelf 工具再看看 g0m0 这两个东西:

$ 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 开始,且大小为 0x2aa08Size 栏位),完全涵盖 g0 的位址。事实上,它也完全涵盖 m00xffffff80000ac040

关於 .bss 区段的说明,可以参考维基

在执行档或是物件档中,.bss 区段用来存放未赋予初值的资料。g0m0 在 Golang 程序码中都没有被赋予初值,所以归属在这个区段里面。这个区段的类型(Type 栏位)被标记为 NOBITS,意味着,这个区段的内容可以在动态执行时期才配置,相反的,这个区段在档案当中可以不存在。

一般而言 C 语言程序的产物的 .bss 在 ELF 档案中完全不会占据空间,但是 Golang 程序还是会占据那些空间,所以从 readelf 的 偏移量(offset)栏位还是可以发现到变化。

无论如何,既然 g0m0 是未初始值,而 Golang 的惯例又是未初始变数一定有 0 值,那麽我们就必须将之清除为 0 了。这有三个层面,

  1. 产物因素:Golang 的 go build 指令的产物(ethanol/ethanol 执行档)中,相对应於 .bss 的偏移的部分是否含有内容?
  2. 载入器(loader)因素:QEMU 使用的 -device loader ... 参数项目实际上如何载入整个 ethanol 映像档?
  3. 执行期因素:执行期初始化阶段是否应处理 .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 档载入。

如前一小节的叙述,虽然档案内没有实质对应到 .bssnoptrbss 的内容,但是现在这个原始格式的载入器,会按照档案位置如实地载入。所以我们可以回顾当初存取 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.|
...

正是我们昨日观察到的,错误记忆体存取的基底!总之是一个不该被拿来当作记忆体位址的夸张奇异值。

所以也许我们别开启原始格式就足够了?但下一节才是最踏实的方案。

执行期因素

以其他系统软件为例,比方说前文中提过的 LinuxOpenSBI,都会在相当早期的阶段清除 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 核心的角度来讲,是一个还没有启用的虚拟位址。连结器对於执行期会被载入到哪里去,当然是没有概念的,所以这里我们遇到了一道更高的墙了。

是否已经到了启用虚拟记忆体的时机了呢?这边我们暂且打住,回头检视本日最一开始的问题。

写入屏障(Write Barrier)概述

也许诸位读者还是会感到疑惑:原先是因为读取到脏值而导致存取错误,那麽为什麽将脏值清成零能够解决问题呢?难道不会因为存取 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

连续 30 天 玩玩看 ProtoPie - Day 2

第二天,不废话,继续摸摸摸。 ProtoPie 的 Conceptual model 影片开始解释 ...

[Day22] Websocket Injection

前言 :Websocket除了能建立一个双向通讯通道外,还能干嘛? :当然是拿来Injection阿...

[Day 4] -『 GO语言学习笔记』- GO语言架构介绍

如本日主题,今天要来介绍一下Go语言的程序码架构,以下内容摘录自『 The Go Workshop ...

Day 28 利用transformer自己实作一个翻译程序(十) Encoder layer

Transformer跟用attention的Seq2Seq的模型有着一样的pattern 输入的...

Day 42 (PHP)

1.header的用法 (1)画画时 指定画面Content-type网页输出,用image/jpe...