予焦啦!准备工具链

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

予焦啦!不管是什麽样的系统或什麽样的程序,只要是用编译式语言,就必须使用编译器以降的工具链(toolchain)先行建置原始码,获取建置产物之後,才能够真正使用。这个章节的目的即是展示,我们应当如何为 Hoddarla 专案准备它所需要的工具链。

既然 Hoddarla 将采用 Golang 开发,那麽理想上就应该允许开发者使用以下指令

go build ./

然而这麽做的盲点在於,Golang 的建置(build)指令在没有给定 GOOSGOARCH 两个环境变数的情况下,会是以当前的作业系统/CPU 架构为建置的对象。比方说最常见的一种系统组合是linux/amd64,所以在那样的环境下执行上述指令,所能够获得的当然是只能够在 x86_64 CPU 的 Linux 作业系统上执行的可执行档。

作业系统/CPU 架构的组合这样的说法实在太过拗口,但是这偏偏又是 Golang 的一个重要概念。以下都以系统组合为代称。

我们预期 Hoddarla 专案的成品是作业系统的系统影像,这与 Golang 常见的用法有着非常大的出入。回顾快速上手章节,我们在 Makefile 里面,使用了以下指令来建置 Hoddarla 的系统影像(system image):

GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x3fffffe000' -o ethanol

为什麽称系统影像,而非系统映像?笔者其实没有偏好,只是认为这次系列文既然要堂堂正正,就尽量采取正体中文写作,且英文名词尽量以国家教育研究院的翻译名词为依归。系列标题的发语词予焦啦以台语正字表示,而非使用乎乾啦,也是一样的精神。然而笔者也意识到国家教育研究院的部分译名可能无法为普罗大众接受,所以遇到比较悖离习惯的翻译的话,会像这样在注解中评述。

参考昨日上传的 debut 分支的话,这段编译命令位在 ethanol 资料夹内的 Makefile 之中。请各位读者先暂时按捺关於 -ldflags 参数的好奇心,这会紧接着在两三日之内说明。又,-o 参数的意义与一般编译器的用法相同,能够将产物命名为预设档名之外的字串,此例即为 ethanol

Hoddarla 专案为什麽将系统影像档命名为 ethanol?这是因为在笔者的规划之中,Hoddarla 是作业系统专案本身,其中,ethanol(酒精的正式学名:乙醇)是它的核心。这次的系列文规划中,不太可能让使用者空间有登场的机会,所以 Hoddarla 和 ethanol 这两个名词之间可能会有点混淆。但後面的篇章都会较偏重於 ethanol 本身。

看起来与一般 Golang 开发流程并没有什麽不同,对吧?但是相信大部分读者,根本没听说过 OpenSBI 这个作业系统;就算听说过,也不太可能认为这是个作业系统。且让我们从这里说起。

OpenSBI:RISC-V 世界的通用韧体实作

OpenSBI 是当今运行 RISC-V 的 Unix-like 系统几乎都会采用的机器模式(M-mode,Machine Mode)系统软件,负责平台等级的控制与整个系统中更高权限等级的控制。开机时会由 OpenSBI 在机器模式预先为作业系统模式(S-mode,Supervisor Mode)设置好一些暂存器和机制,比方说 medelegmideleg 两个系统暂存器,就可以设定有哪些中断或是例外可以交由作业系统模式的软件处理。以最成熟的 Linux 来讲,RISC-V 系统开机时,也是先经过机器模式的 OpenSBI,再透过权限转移机制将系统开进作业系统模式。

必须强调的是,这件事情和 Golang 一点关系都没有。目前以 go 语言社群的动向来看,也没有要支援低阶系统软件写作的样貌。除了 Windows、Plan9 和 wasm/js 之类比较特异的系统组合之外,Golang 通常都是用以开发运行 Unix-like 作业系统上的应用程序。除了应用程序本身,Golang 的执行期函式库、垃圾回收机制等等都必须仰赖作业系统核心的一些功能,如虚拟记忆体或是系统呼叫。今天我们的目标是以 Golang 开发作业系统的话,就表示我们在开拓一个新的抽象层,因为显然我们不可能将 OpenSBI 这样的专案改成更接近 POSIX(可携式作业系统界面)标准的样貌,也不可能有来自机器模式的虚拟记忆体支援。但是,经过我们修改的 Golang 工具链,又是要用以开发作业系统的。

总之,这里的目标很明确,那就是支援 opensbi 这个可以被辨识的新作业系统,并设定它合法的架构只有 riscv64

参考前年的拙作 的话,这里很容易可以找到下手的目标:Golang 的执行期环境函式库(runtime)。在应用程序本身的 main 函式得以启动之前,有些启动时的设置是与该应用程序所在的系统组合(像是 linux/amd64 或 opensbi/riscv64)有关的,其中往往也有许多和系统较底层有关的部份。我们可以在** go/src/runtime 资料夹下观察到比其他函式库还要多的组合语言档**,如 rt0_linux_amd64.s 或是 sys_freebsd_arm64.s。Golang 本身毕竟还是高阶语言,有些底层设置如 ABI 的调整之类的功能不在 Golang 的语法所提供的功能之内,所以需要这种方式来补足基础的控制。也就是说我们可以直接在 runtime 函式库当中寻找可以当作范本的档案,复制出来并将原本的作业系统改成 opensbi 即可。

又,我们也可以观察这资料夹底下档案的新旧程度,寻找最近加入的作业系统,并据以观察那些移植阶段时,非常初期的一些 git 送交纪录(commit),以如法炮制新增一个作业系统的必经步骤。

但,更有趣的就是直接来试试看!首先我们随意创造一个 hw.go

package main

import "fmt"

func main() {
        fmt.Println("Hello World!")
}

整个系列文都会先以这个 Hellow World 档案当作 ethanol 核心的基本范例,位在 Hoddarla 专案底下的 ethanol/ethanol.go。也许看起来实在过於基本,但想要在今年的铁人赛就涵盖所有作业系统的面向实在是不可能的任务,所以请容笔者先用这个范例来进行初阶的目标。

正面尝试

对於发行版可以安装的预设 go 工具链来讲,随意给定它一个系统组合的话,它只会显示

$ GOOS=opensbi GOARCH=riscv64 go run main.go                                                                                         
cmd/go: unsupported GOOS/GOARCH pair opensbi/riscv64

哪怕是看起来很像是那麽一回事的 opensbi/riscv64 也不例外。要解决这个问题,我们可以找找看这个错误讯息的来源在哪里:

$ grep 'unsupported GOOS/GOARCH pair' -R ./go/src/cmd
... # 中间这里很多归属在 testdata 资料夹下的内容,我就先忽略了
go/src/cmd/go/internal/work/action.go:               return fmt.Errorf("unsupported GOOS/GOARCH pair %s/%s", goos, goarch)

为什麽知道要在 go/src/cmd 底下找而不是 go/src 甚至 go 的地毯式搜寻呢?这是因为整个 go 语言软件库包含了自己的工具链,实务上也产出许多可各自执行的程序;也就是说,这是个大型复合专案,里面其实有很多个 package main,除了可以独立作战的连结器和组译器,还有其他许多,都归属在 cmd 资料夹底下。当然,通常的 Golang 专案开发所连结到的那些标准函式库,不会包含这些带有 package main 的部份。

只有一个选项,那这应该就是答案了。这个档案的所在位置在 go/src/cmd/go 就表示,这个档案会被 go 指令本身使用到。Golang 档案阶层架构符合直觉且易懂,在第一层的内容中是主要的内容,而 internal 以下的各个部份则是所需的内部功能;但实际上,有些可能本身的功能就相当於一整个次指令,如 cmd/go/internal/get 包含大部分 go get 指令的功能,或是 cmd/go/internal/vet 实作 go vet 指令的功能一样。cm/go/internal/work 则比较通用,被使用在许多其他的次指令中。

回报错误的片段如下,

311 func CheckGOOSARCHPair(goos, goarch string) error {
312         if _, ok := cfg.OSArchSupportsCgo[goos+"/"+goarch]; !ok && cfg.BuildContext.Compiler == "gc" {                              
313                 return fmt.Errorf("unsupported GOOS/GOARCH pair %s/%s", goos, goarch)                                               
314         }
315         return nil
316 }

显然要印出错误的条件是所使用的编译器为 gc,并且goos/goarch 不是 cfg.OSArchSupportsCgo 这个映射(map)物件的合法索引值。那要去哪里检视这个索引值的列表呢?直接搜寻找到 ./go/src/cmd/go/internal/cfg/zosarch.go

  1 // Code generated by go tool dist; DO NOT EDIT.
  2 
  3 package cfg
  4
  5 var OSArchSupportsCgo = map[string]bool{
  6         "aix/ppc64": true,
  7         "android/386": true,
  ...

这个档案明白告诉我们这是生成的程序码,手动修改的话也是没有意义的,并且告知我们这是由 dist 工具生成。有过编译 Golang 工具链经验的读者应该对这个工具的名称感到眼熟,因为这正是工具链生成之时,需要编成的第一个软件包组合,可说是 go 语言自身内部的根源软件元件。根据文件,dist 的业务范围是启动(bootstrap)、建置与测试工具链,也可以透过 go tool dist 查看其他的子指令。这样循线追查,就能够找到 ./cmd/dist/build.go 档案中生成这个映射阵列的位置。事实上还是稍微有点迂回,因为是在 gentab 这个物件阵列之中纪录 zosarch.go 字串,以及一个对应的函式指标 mkzosarch。这个物件阵列还包含了其他的自动生成档案,注解中描述为「非常简单的生成档案」。

mk 开头的函式或是指令档在 go 语言里面都有自动生成某些组态的功能。

mkzosarch 的内容是

 76 // mkzcgo writes zosarch.go for cmd/go.
 77 func mkzosarch(dir, file string) {
 78         // sort for deterministic zosarch.go file
 79         var list []string
 80         for plat := range cgoEnabled {
 81                 list = append(list, plat)
 82         }
 83         sort.Strings(list)
 84
 85         var buf bytes.Buffer
 86         fmt.Fprintf(&buf, "// Code generated by go tool dist; DO NOT EDIT.\n\n")
 87         fmt.Fprintf(&buf, "package cfg\n\n")
 88         fmt.Fprintf(&buf, "var OSArchSupportsCgo = map[string]bool{\n") 
 89         for _, plat := range list {
 90                 fmt.Fprintf(&buf, "\t%q: %v,\n", plat, cgoEnabled[plat])
 91         }
 92         fmt.Fprintf(&buf, "}\n")
 93
 94         writefile(buf.String(), file, writeSkipSame)
 95 }

这里有点兼用的意味,因为用来参考的 cgoEnabled 阵列原先只是纪录 cgo 的支援性与否的一个阵列而已,但在使用上可以当作一组作业系统与架构是否建档过的参考。

所以,也可以从另一个面向来看 dist 工具是整个工具链建置的第一步的原因。假设今天人们是要在某个已经很成熟且运算资源丰富的环境组合(如 linux/amd64)之下开发某个新的作业系统与架构的组合(如 opensbi/riscv64),dist 工具的程序码中的 cgoEnabled 之中当然会包含到新的那个组合。反之,在建置启动工具链的时候,要是 dist 没有率先更新为新版供後续步骤使用,那麽後面的流程自然不会认得这个新组合。

至於这个 cgo 本身代表的意义,大部分时候是用来指称 C 语言与 go 之间互相使用的界面,但是如果当作子指令使用,也能够有一些转换的功能。由於 Hoddarla 的目标自始至终就是要只包含纯 Golang(以及这个语言框架内所允许的组合语言),这里对於 cgo 就都不会太过着墨。

这个 cgoEnabled 阵列又是如何生成?在这个档案中持续检索,会发现 mkzcgo 函式里面也是参考 cgoEnabled 阵列,然後再写出 cgoEnabled 到另外一个生成档案。但是这不是鸡生蛋蛋生鸡,因为前者是定义在 dist 指令所属的 main 软件包中的阵列,後者则将会归属於 build 软件包。所以,为了要支援 opensbi/riscv64 这样的组合,我们首先要让 dist 认得它,方法就是将它加到 cgoEnabled 之中。重编工具链之後,结果是出现了不同的错误讯息

$ GOOS=opensbi GOARCH=riscv64 go build /tmp/main.go
go tool compile: exit status 1
compile: unknown goos opensbi

面对编译器

上一小节我们发现,一旦 dist 工具放行这个系统组合之後,编译器想要认真做好分内工作时,就会找不到 opensbi 这个作业系统。顺着 unknown goos 的讯息去找,虽然没有在 go/src/cmd/compile 里面找到,但是在 src/cmd/internal/obj/sym.go 找到了

 52         if err := ctxt.Headtype.Set(objabi.GOOS); err != nil {
 53                 log.Fatalf("unknown goos %s", objabi.GOOS)
 54         }

src/cmd/internal 里面有各个 Golang 子指令共享的结构或是定义,所以这里笔者才会在 src/cmd/compile 里面找不到之後,往该资料夹寻找。

从程序码可以看懂 objabi.GOOS 应该就是我们传入的 opensbi,事实上也不难检验,只要针对 objabi 资料夹检索一番

$ grep GOOS -R ./cmd/internal/objabi
./cmd/internal/objabi/zbootstrap.go:const defaultGOOS = runtime.GOOS
...
./cmd/internal/objabi/util.go:  GOOS     = envOr("GOOS", defaultGOOS)
...

就可以看到 GOOS 是从环境变数而来的。要打通这个环节,我们必须从产生错误的根本之处下手,也就是 ctxt.Headtype.Set(objabi.GOOS) 的失败。这个 Set 函式存在於 cmd/internal/objabi/head.go,可以看到一整排的作业系统字串定义

 53 func (h *HeadType) Set(s string) error {
 54         switch s {
 55         case "aix":
 56                 *h = Haix
 57         case "darwin", "ios":
 58                 *h = Hdarwin
 59         case "dragonfly":
 60                 *h = Hdragonfly
 61         case "freebsd":
 62                 *h = Hfreebsd
 63         case "js":
 64                 *h = Hjs
 65         case "linux", "android":
 66                 *h = Hlinux
...

失败的原因就是,字串 opensbi 并不存在这个 switch-case 结构体之中。从这里的内容看来,我们应该补上的东西也蛮单纯的,就是指派一个 Hopensbi 给型别为 HeadType 指标的 h,这个值也跟其他作业系统一样是个常数。除了 Set 之外,也还有另外一个 String 函式,是作反向的操作。

又,其它作业系统的代表符号 Hxxxx 作为常数(const)被定义在这个原始码档案的开头处,因此我们打算新加的 Hopensbi 当然也得在这里定义才行。

这三组系统组合相关的资讯一并补上之後,错误讯息再度随之改变:

$ GOOS=opensbi GOARCH=riscv64 go build /tmp/main.go
# syscall
src/syscall/syscall.go:50:16: undefined: EINVAL
src/syscall/syscall.go:82:11: undefined: Timespec
src/syscall/syscall.go:88:11: undefined: Timeval
src/syscall/syscall.go:93:11: undefined: Timespec
src/syscall/syscall.go:98:11: undefined: Timeval
# runtime/internal/sys
src/runtime/internal/sys/stubs.go:16:61: undefined: GoosAix

看起来有两种不一样的错误。我们这里先看与系统组合设定有关的 GoosAix

Undefined: GoosAix

src/runtime/internal/sys/arch.go 中,GoosAix 变数确实有被用到。它是一个布林值(Boolean),代表 Golang 运作的系统是否为 AIX 作业系统的意思。

const StackGuardMultiplier = StackGuardMultiplierDefault*(1-GoosAix) + 2*GoosAix

这个 StackGuardMultiplier 与 Golang 保护堆叠的功能有关。此处显然是表示,若所运作的作业系统不是 AIX,那麽就使用预设的变数;若是正好是 AIX,那麽就设置为 2。

既然我们准备要使用的作业系统是新加入的 OpenSBI,难道 Golang 无法自动判断它不是 AIX 吗?看来目前是这样没错。搜寻这个变数的话,会发现它存在很多地方:

$ grep GoosAix -R ./runtime                                          
./runtime/export_test.go:var BaseChunkIdx = ChunkIdx(chunkIndex(((0xc000*pageAlloc64Bit + 0x100*pageAlloc32Bit) * pallocChunkBytes) + ar
enaBaseOffset*sys.GoosAix))                                         
./runtime/internal/sys/arch.go:const StackGuardMultiplier = StackGuardMultiplierDefault*(1-GoosAix) + 2*GoosAix
./runtime/internal/sys/zgoos_aix.go:const GoosAix = 1               
./runtime/internal/sys/zgoos_android.go:const GoosAix = 0     
./runtime/internal/sys/zgoos_js.go:const GoosAix = 0
./runtime/internal/sys/zgoos_openbsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_windows.go:const GoosAix = 0
./runtime/internal/sys/zgoos_hurd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_freebsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_ios.go:const GoosAix = 0
./runtime/internal/sys/zgoos_illumos.go:const GoosAix = 0
./runtime/internal/sys/zgoos_darwin.go:const GoosAix = 0
./runtime/internal/sys/zgoos_dragonfly.go:const GoosAix = 0
./runtime/internal/sys/zgoos_solaris.go:const GoosAix = 0
./runtime/internal/sys/zgoos_plan9.go:const GoosAix = 0
./runtime/internal/sys/zgoos_zos.go:const GoosAix = 0
./runtime/internal/sys/zgoos_netbsd.go:const GoosAix = 0
./runtime/internal/sys/zgoos_linux.go:const GoosAix = 0
./runtime/malloc.go:    arenaBaseOffset = 0xffff800000000000*sys.GoarchAmd64 + 0x0a00000000000000*sys.GoosAix

除了前後三个以 sys.GoosAix 的形式被使用之外,其余的都具备很统一的格式,都是 zgoos_作业系统名称.go 里面将 GoosAix 定义成零。与前面章节类似,z开头的 Golang 档案往往是自动生成的结果。随意参考其中的任何一个来看看,比方说 src/runtime/internal/sys/zgoos_linux.go

// Code generated by gengoos.go using 'go generate'. DO NOT EDIT.
...

这第一行的注解本身就是子指令 go generatesrc/runtime/internal/sys/gengoos.go 的内容为基础生成的。这个档案内的原始码很短也很事务性,这里略过它的内容,但指出两个重要的部份:

  5 //go:build ignore   
  6 // +build ignore
 ...
 21 func main() { 
 22         data, err := os.ReadFile("../../../go/build/syslist.go")

前面两行是给 Golang 的各种工具查看的指引(directive),之所以有两行看起来很像的,是因为第 5 行那种语法是在 Go 1.17 之後会导入的新语法,而最近的几个版本都会兼容两种指引语法。虽然不需要知道 Golang 各种指引的细节,但这里很容易可以理解是要 Golang 工具在建置整个 Golang 的时候忽略这个档案。

一般来说,执行期(runtime)原始码资料夹底下的东西都与作业系统或是 CPU 架构高度相关,因此只要系统组合对了,都应该一并建置,但我们所看到的 gengoos.go 正好就是个特例,呼应 21 行可知,这个档案自己有自己的 main 函式,并有自己独立的功能,那就是生成 zgoos 系列档案。

其中明示的是,src/go/build/syslist.go 档案,

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package build

// List of past, present, and future known GOOS and GOARCH values.
// Do not remove from this list, as these are used for go/build filename matching.

const goosList = "aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris windows zos "
const goarchList = "386 amd64 amd64p32 arm armbe arm64 arm64be ppc64 ppc64le mips mipsle mips64 mips64le mips64p32 mips64p32le ppc riscv riscv64 s390 s390x sparc sparc64 wasm "

这就是整个 Golang 工具链检查系统组合时最上游的一个档案!我们在 goosList 列表当中加入 opensbi,然後在 src/runtime/internal/sys 底下重新执行一次 go generate,可以发现这次产生了新的 zgoos_opensbi.go 档案,并且其它所有 zgoos 档案都会包含一行 const GoosOpensbi = 0

再编译一次,果然原本的 GoosAix 的未定义问题就消失了:

$ GOOS=opensbi GOARCH=riscv64 go build ../../ethanol/hw.go 
# syscall
syscall/syscall.go:50:16: undefined: EINVAL
syscall/syscall.go:81:11: undefined: Timespec
syscall/syscall.go:86:11: undefined: Timeval
syscall/syscall.go:91:11: undefined: Timespec
syscall/syscall.go:96:11: undefined: Timeval
# runtime
runtime/alg.go:341:2: undefined: getRandomData
runtime/alg.go:351:2: undefined: getRandomData
runtime/proc.go:142:17: undefined: sigset
runtime/runtime2.go:519:16: undefined: gsignalStack
runtime/runtime2.go:520:16: undefined: sigset
runtime/runtime2.go:597:2: undefined: mOS
runtime/sigqueue.go:54:15: undefined: _NSIG
runtime/sigqueue.go:55:15: undefined: _NSIG
runtime/sigqueue.go:56:15: undefined: _NSIG
runtime/sigqueue.go:57:15: undefined: _NSIG
runtime/alg.go:351:2: too many errors

再检查一下 src/cmd/dist

确实我们已经可以成功编出一只工具链,但还是应该再检查一下这里是否已经成功登录 opensbi 为一个 Golang 认可的作业系统。检查的方法也很简单,就是查询其他的作业系统是否有被纪录在任何 opensbi 还不存在的地方。笔者这里选择 aix 作业系统来当作检索的对象,因为它的使用者较少,应不致於像 linux 那样遍布 Golang 程序码中。

$ grep aix -Ri ./cmd/dist | grep -v '://' | grep -v '.s:'
./cmd/dist/test.go:     case "aix-ppc64",
./cmd/dist/test.go:     if goos == "aix" {
./cmd/dist/test.go:             case "aix-ppc64",
./cmd/dist/test.go:             case "aix/ppc64",
./cmd/dist/test.go:     case "aix-ppc64",
./cmd/dist/test.go:             case "aix-ppc64", "netbsd-386", "netbsd-amd64":
./cmd/dist/main.go:     case "aix":
./cmd/dist/main.go:             // uname -m doesn't work under AIX
./cmd/dist/build.go:    "aix",
./cmd/dist/build.go:    "aix/ppc64":       true,

检索条件中排除了注解内容与架构相依的组译档。检索出来的结果,test.go 是测试用档案,我们可以忽略;main.go 为 Golang 的 dist 指令的入口,在这里出现作业系统或是架构相关的判断,通常是一些特例的排除,我们也可以忽略。因此就只剩下我们稍早也改写过的 cgoEnabled 阵列,还有另外一个 "aix" 字串。

进一步检验,可以发现该字串是在 okgoos 阵列之中:

// The known operating systems.
var okgoos = []string{
        "darwin",
        "dragonfly",
        "illumos",
        "ios",
        "js",
        "linux",
        ...
}

这个阵列现在还没有 opensbi 这个成员,按照注解来看,这会使得 opensbi 不是一个正式被认识的作业系统。这与我们先前的实验却又矛盾了:明明 opensbi/riscv64 组合已经可以成功支援了不是吗?

搜寻一下 okgoos 的用法可以发现,这会影响到许多其它 Golang 开发的层面,所以我们这里还是先将 opensbi 加入,完成今天的分量。

小结

予焦啦!今天让 opensbi/riscv64 组合能够被 dist 工具识别,且编译器已经摩拳擦掌准备要打造一个可执行档了!现在遇到一堆未定义的符号。这个部分已经上传到 github 了,欢迎有兴趣的读者一起来玩一玩。

毕竟我们从一个乾净的 Golang 原始码储存库(source code repository)出发,目前几乎什麽实质内容也还没有开始加进去,也是蛮合理的。


<<:  CSS微动画 - 为什麽别人的按钮点起来比较有感觉?

>>:  Spring Framework X Kotlin Day 6 Unit Test

Spring Framework X Kotlin Day 22 Spring Cloud

GitHub Repo https://github.com/b2etw/Spring-Kotlin...

Day8-安装Kind要在docker之後

从上一章了解各种K8s的特点,在这章将会教学如何安装Kind。 由於其利用docker的特性,会比使...

Eureka 介绍

Eureka 介绍 ...

【课程推荐】UiPath Studio 基础实作2日课 (中文教学)

分享今年度最後一场 UiPath Studio 的公开课程(有专属优惠码~) RPA热潮其实正反映强...

D14 第七周 前端基础 JavaScript

这礼拜的课程进度是 FE102 DOM 事件处理 页面保存资料 介绍比较常使用到的 DOM 方法 d...