予焦啦!检验核心映像档:开机流程、OpenSBI 惯例、ELF 浅谈

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

予焦啦!回顾昨日,最终我们能够透过

$ GOOS=opensbi GOARCH=riscv64 go build ../../ethanol/ethanol.go

这般非常相似於一般 Golang 开发应用程序的用法,产生一个还只是空壳的 opensbi/riscv64 系统组合的可执行档。

接下来的目标是,确认这个刚编出来的东西真的能够被当作一个作业系统映像档吗?它如何能够作为 RISC-V 系统开机的一部分?笔者也会在今日检验的过程中回顾一些相关的软件。

本节重点概念

  • 基础
    • 编译流程
    • ELF 档基本结构
    • QEMU RISC-V Generic 装置的启动流程
  • Golang
    • 连结器参数 -T-R 的控制
    • 组合语言基础
  • RISC-V
    • ecall 指令
    • 例外处理简述

ELF 档案检验

我们可以透过 GNU binutils 工具包当中的 readelf 工具观察这个产出的可执行档:

$ riscv64-buildroot-linux-musl-readelf -h hw        
ELF Header:                                     
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64     
  Data:                              2's complement, little endian
  Version:                           1 (current) 
  OS/ABI:                            UNIX - System V
  ABI Version:                       0                                                          
  Type:                              EXEC (Executable file)
  Machine:                           RISC-V
  Version:                           0x1
  Entry point address:               0x64418
  ...

readelf 认识它,就表示它至少还算是个正常可辨识的 ELF 档。然而看到这个进入点位址(Entry point address),就有点不太对了。

以作业系统的惯例来讲,核心的可执行区段位置通常是位在所有可用的虚拟空间的最高位的部分。若是 64 位元系统,以 RISC-V Linux 的 sv39 组态(将於日後介绍虚拟记忆体的篇章详谈)来讲的话,核心的位置会从 0xffffffe000000000 起始,可参考arch/riscv/Kconfig

有一个因素是,核心就可以简单地将使用者程序配置在低位,且可以一路往高位延展。至於具体来说为什麽这会成为一种惯例,我认为这篇是很值得参考的资料。

话说回来,这里看到的进入点位置是 0x64418,当然是非常低的。若使用 readelf 工具近一步检验各个 ELF 区段的位置,也可以发现它们都在很低的位置。这也当然,毕竟我们并没有特别针对这个系统组合做些甚麽特别的处理。RISC-V Linux 中具体的参照,在 arch/riscv/kernel/vmlinux.ld.S 档案当中,Linux 便会指定连结器将程序码区段从前段中提及的高位置开始摆放。

那麽,该怎麽样修改?Golang 是否也有 C 语言生态系里面的连结器脚本(linker script)之类的文件,可以供开发者手动调整可执行档中的区段位址呢?在这之前,我们先继续引用原本的 readelf 工具观察,这个预设的区段排列长成什麽样子:

$ riscv64-buildroot-linux-musl-readelf -S hw                        
There are 23 section headers, starting at offset 0x158:             
                                                                                                
Section Headers:                                                                                
  [Nr] Name              Type             Address           Offset  
       Size              EntSize          Flags  Link  Info  Align  
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0   
  [ 1] .text             PROGBITS         0000000000011000  00001000  
       000000000005405c  0000000000000000  AX       0     0     8      
  [ 2] .rodata           PROGBITS         0000000000070000  00060000
       000000000001e8d5  0000000000000000   A       0     0     32  
  [ 3] .shstrtab         STRTAB           0000000000000000  0007e8e0
       000000000000017a  0000000000000000           0     0     1
...

第零个区段必须是空的,似乎是连结器背後的一些历史因素导致的。

因此关键就在於回答这两个问题:Golang 开发者能够更动 .text 区段的开头,从 0x11000 变成某个看起来更气派的高位址吗?如何做到?

检视编译流程

回顾 C 语言流程

比较粗略一点讲的话,大家都称呼 gcc 为编译器(compiler),因为它能够将人们喂给它的可读的程序码转换成可执行档。但严谨来讲,它只是一个编译驱动(compiler driver),负责把整个流程管理好。所谓整个流程,是因为其中牵涉到的元件至少有以下数个:

  1. 前置处理器(preprocessor):从一般 C 程序码到展开完全的 C 程序码
  2. 编译器:将 C 程序码转换成组合语言
  3. 组译器(assembler):将组合语言转换为物件档
  4. 连结器(linker):将单一或多个物件档合并(因为需要定位多个物件之间的参照关系)转换为可执行档

其中,若要调整程序码区段,那麽开发者必须调整第 4 个步骤中,多喂一个连结器脚本,去指定区段的位址甚至符号(变数、函数等等)的位址与对齐关系(alignment,比方说有些地方开发者希望可以 8 个位元组为单位排列所有内容,有些地方 2 个位元组为单位即可)。

Golang 流程

那麽以 Golang 来说,我们目前为止相当於只观测到 go build 作为编译驱动器的功能。只要能够先特定出连结器的阶段,或许就能够掌握到线索了。以这个为契机,还是好好阅读一下文件吧:

$ go build help
...
非常简洁的描述 build 的行为,值得一看。
...
        -a
                force rebuilding of packages that are already up-to-date.
                译:即使已经存在,也强迫重新编译。
        -x
                print the commands.
                译:印出执行的指令。
        -work
                print the name of the temporary work directory and
                do not delete it when exiting.
                译:印出暂存的工作目录,并保存所有中间产物。
...
        -asmflags '[pattern=]arg list'
                arguments to pass on each go tool asm invocation.
                译:要喂给 Golang 工具 asm 的参数。
        -gcflags '[pattern=]arg list'
                arguments to pass on each go tool compile invocation.
                译:要喂给 Golang 工具 compile 的参数。
        -ldflags '[pattern=]arg list'
                arguments to pass on each go tool link invocation.
                译:要喂给 Golang 工具 link 的参数。

前半节的指令说明可以帮助我们一窥 go build 作为编译驱动器展开整体流程之後的结果。有兴趣的读者不妨直接执行 go build -work -a -x 试试!可以观测到每一个组件(像是昨日看到的 runtimeos 等等)先被个别编译之後,最後才呼叫 link 工具产出执行档:

...
/home/noner/FOSS/hoddarla/ithome/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=s6qzkJHXEbb67EhxxAGl/2SEhUWfSojBvAPlQVFt-/VZzZk01lY2DDxHbprbWq/s6qzkJHXEbb67EhxxAGl -extld=gcc $WORK/b001/_pkg_.a
/home/noner/FOSS/hoddarla/ithome/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal
cp $WORK/b001/exe/a.out hw

其中并没有类似 C 语言里面的连结器脚本。importcfg.link 看起来很可疑,但内容也仅是先前的编译产物而已。但我们从文件当中可以得到别的灵感,也就是对应到 C 语言的编译流程的每个阶段(除了前置处理),都有可以额外传入参数的部分。所以我们可以锁定连结器,阅读它的文件,赫然可见:

  -T address
        set text segment address (default -1)

所以 -T 看起来就是我们要的东西了!但一如往常的事情不会那麽顺利,比方说笔者做的以下两个实验都吃鳖:

  1. 使用 -T 0xffffff8000000000 企图一步到位,结果会回报编译错误
$ GOOS=opensbi GOARCH=riscv64 go build -ldflags='-T 0xffffff8000000000' ethanol/ethanol.go
# command-line-arguments              
invalid value "0xffffff8000000000" for flag -T: value out of range
  1. 使用 -T 0x12000 小幅调整,结果虽然可以编译出产物,看起来却是坏掉的 ELF
$ riscv64-buildroot-linux-musl-objdump -d hw
riscv64-buildroot-linux-musl-objdump: hw: file format not recognized

Golang 连结器参数 -T-R

所以,深入了解 -T 参数是势在必行了。我们可以开启 src/cmd/link/internal/ld/main.go 档案,并且在全域变数中找到 -T 的定义:

FlagRound         = flag.Int("R", -1, "set address rounding `quantum`")
FlagTextAddr      = flag.Int64("T", -1, "set text segment `address`")

这个东西,定义成 Int64 型别的话,那当然 0xffffff8000000000 的赋值就不会成功了,毕竟首位元为 1,这个值就是无号整数才能够容纳的了。这是第一个实验失败的原因,可以理解。如果要骗过它去真的使用这个高位位址,还是可以手动转换 2 的补数,也就是 -T -0x8000000000,结果编译可以成功,但 ELF 本身仍然有点毁损

$ riscv64-buildroot-linux-musl-readelf -h hw
ELF Header:                                     
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64     
  Data:                              2's complement, little endian
  Version:                           1 (current) 
  OS/ABI:                            UNIX - System V             
  ABI Version:                       0
  Type:                              EXEC (Executable file)              
  Machine:                           RISC-V                                                     
  Version:                           0x1
  Entry point address:               0xffffff8000053418
  Start of program headers:          64 (bytes into file)
  Start of section headers:          344 (bytes into file)
  Flags:                             0x4, double-float ABI
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         5
  Size of section headers:           64 (bytes)
  Number of section headers:         23
  Section header string table index: 3
readelf: Error: the PHDR segment is not covered by a LOAD segment

笔者找了一段时间,才锁定实验一与 ELF 毁损的现象,原因可能出在 -R 这个连结器参数上。若我们检索昨日的改动,可以看到在 src/cmd/link/internal/riscv64/obj.go 档案的 archinit 函式里,初始化了 -T-R 分别对应到的参数:

func archinit(ctxt *ld.Link) {
        switch ctxt.HeadType {
                case objabi.Hlinux, objabi.Hopensbi:
                ld.Elfinit(ctxt)
                ld.HEADR = ld.ELFRESERVE
                if *ld.FlagTextAddr == -1 {
                        *ld.FlagTextAddr = 0x10000 + int64(ld.HEADR)
                }
                if *ld.FlagRound == -1 {
                        *ld.FlagRound = 0x10000
                }
...

这就解释了为什麽预设的程序码区段会从 0x11000 开始:因为通常 ELF 档头的大小(ld.ELFRESERVEld.HEADER)是 0x1000。Round 在这里是进位的基准,设置成0x10000 代表有一些以这个值为单位的运算。这可以解释为什麽单纯设置 -T 0x12000 不成功,因为扣除 ELF 档头之後,程序码区段的位址变成 0x11000 开始,但连结器针对 ELF 内其他部分的位址计算就因此没有办法整除 0x10000 而导致问题了。

所以,这个阶段,我们可以先采用 -R 0x1000 -T -0x7ffffff000,来让这个可执行档的程序码区段从 0xffffff8000001000 开始,并且也不会弄乱对齐:

$ riscv64-buildroot-linux-musl-readelf -l hw                           
Elf file type is EXEC (Executable file)
Entry point 0xffffff8000054418
There are 5 program headers, starting at offset 64
                                                
Program Headers:     
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0xffffff8000000040 0xffffff8000000040
                 0x0000000000000118 0x0000000000000118  R      0x1000
  NOTE           0x0000000000000f9c 0xffffff8000000f9c 0xffffff8000000f9c
                 0x0000000000000064 0x0000000000000064  R      0x4
  LOAD           0x0000000000000000 0xffffff8000000000 0xffffff8000000000
                 0x000000000005505c 0x000000000005505c  R E    0x1000
  LOAD           0x0000000000056000 0xffffff8000056000 0xffffff8000056000
                 0x0000000000051e18 0x0000000000051e18  R      0x1000
  LOAD           0x00000000000a8000 0xffffff80000a8000 0xffffff80000a8000
                 0x0000000000002c60 0x0000000000032280  RW     0x1000
...

其实,在 ELF 的术语里面,有分 section 和 segment,前者是给连结器在连结期使用、後者则是载入器(loader)在载入期使用的。显然这里的对齐,是针对後者。可参考笔者在先前铁人赛的拙作

很抱歉这里两者中文直译可能都会有「区段」的困扰性。也有些简体书籍应该是以区块和区段作为区分手法,但仍然拙劣。只能慨叹如今人们的翻译能力完全不如 19 世纪的日本汉学家翻译出「社会」、「经济」、「政治」等造语。资讯时代的汉语智能,几乎可以说是流失殆尽的。

试跑前的分析

诚然,我们目前唯一的核心档案 ethanol/ethanol.go 是一个最基本的 Hello World,但笔者并没有天真到认为这个执行档能够渡过 Golang 设计给使用者空间执行期初始化并最後引用到 fmt 组件,并成功呼叫 fmt.Println 函式,过程中会发生什麽问题还很难说。我们现在能够比较确定的东西,其实只有整个可执行档的进入点位在 _rt0_riscv64_opensbi 而已。

因此我们在这里(src/runtime/rt0_opensbi_riscv64.s)插入一些程序码吧!像是学 python 的时候插 print 函式来学习最基本的追踪程序码方法一样,我们这里加入:

 TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
+       MOV     $0x48, A0
+       MOV     $1, A7
+       MOV     $0, A6
+       ECALL
        MOV     0(X2), A0       // argc
        ADD     $8, X2, A1      // argv
        JMP     main(SB)

这区区 4 行其貌不扬的组合语言,何以能够支援印出讯息这样复杂的功能?我们必须先了解以下概念:

  1. Golang 的组合语言(.s 档)与 GNU 工具预设的组合语言语法不一样,最大的差异在於顺序。这段组合语言码编译完成後使用 objdump 反组译的结果为:
ffffff8000054418:       0480051b                addiw   a0,zero,72
ffffff800005441c:       0010089b                addiw   a7,zero,1
ffffff8000054420:       0000081b                sext.w  a6,zero
ffffff8000054424:       00000073                ecall

a0a6a7 在这里是暂存器,当然是运算的目标对象(destination),一开始的三行都只有赋值的效果,所以只有常数(immediate)作为运算的来源(source)的模式,但方向截然不同:Golang 是目标在後,整行的语义感觉像是从左到右延展,反过来 GNU 则是预设从右到左进行。当然,我们之後还会看到更复杂的组合语言指令,包含三个运算元(operand)也是常有的,届时的「方向性」可能就不是单纯赋值这麽直接,但大致上目标运算元的位置决定了两者间最大的差异。又,MOV 并不是一个 RISC-V 真正定义的组合语言助忆符(mnemonic),而是 Golang 提供的跨架构共用助忆符之一,可以用来代表资料移动在各种单位之间(暂存器、记忆体位址)的指令。
2. ECALL 是 RISC-V Evironment Call 的意思。这个指令会触发例外(exception),分成使用者模式环境呼叫例外(Environment-Call-from-U-mode)、作业系统模式环境呼叫例外(Environment-Call-from-S-mode)、机器模式环境呼叫例外(Environment-Call-from-M-mode)。这里将会触发的是作业系统模式环境呼叫例外(Environment-Call-from-S-mode),因为我们预期这个可执行档是与 Linux 这类的作业系统映像档同样位阶的系统。
3. 转换权限等级(privileged mode)。以结果来说,这里的 ECALL 将会造成权限等级转换回到 M-mode 的 OpenSBI,由 OpenSBI 来服务这个呼叫。
4. 这个呼叫对应到的部分是,OpenSBI 中 lib/sbi/sbi_ecall_legacy.c 档案内的 sbi_ecall_legacy_handler 函式里的 SBI_EXT_0_1_CONSOLE_PUTCHAR 条件。OpenSBI 所支援的环境呼叫规格书在此处可参照。

  1. a6 暂存器代表该呼叫所属於的集合。这里令为 0 值,代表的是传统(legacy)集合,实际上已经不建议使用。现在的 S-mode 系统软件实践都会避免使用传统集合的环境呼叫,我们这里只是暂且挪用其方便性。
  2. a7 暂存器代表该呼叫在该集合中的序号。这里令为 1,代表在 控制台(console)印出一个字元。
  3. 该字元来自 a0 暂存器内的 ASCII 码。这里的 0x48,取的是代表 Hoddarla 的 H 字元。

启动流程

给对於 OpenSBI 有一定了解,较进阶的读者:以下描述的是 Jump mode 的预设行为。本节想要描述的技术重点是如何跳跃到 S-mode 进入点,但如果单纯修改 OpenSBI 跳跃位址,或者直接使用 dynamic mode 控制,都是可以考虑的方案。

回顾 Linux 的启动流程(假设所有软件都已经被载入在对的地方)作为对照的话,会是这个样子:

  1. QEMU 的微型启动程序,将流程带到实体记忆体起始位址 0x80000000
  2. OpenSBI 自 0x80000000 开始动作,执行到尾声时,跳转到 0x80200000 并切换为作业系统模式
  3. Linux Image 档在 0x80200000,继续执行下去。(若只考虑无压缩格式,Linux 的输出物有两种,一种是 ELF 档,通常档名为 vmlinux,一种是将 ELF 档头去掉且增加未初始化变数空间的 Image 档,通常是透过 objcopy 工具获得的,详情可参照 Linux 的 Makefile。)
  4. 过不多久,Linux 就会打开虚拟记忆体的控制开关,而开始使用虚拟记忆体位址,也就是 pc 会从 0x802000XX 转变为0xffffffe0000000XX

但回到 ethanol 映像档,我们还有进入点的问题没有处理。Golang 连结器给我们的产物里面,程序码区段的开头是其他组件所在的位址,而非像 Linux 一样,去除 ELF 档头之後也能够直接摆在记忆体里面执行。也就是说,我们仍然需要一个机制来帮助我们从 OpenSBI 的出口 0x80200000 过渡到对应到 0xffffff8000053418 这个虚拟记忆体位址的实体记忆体位址。

几经考虑之後(详情可以参照 ethanol/goto 资料夹下的档案),笔者决定导入一个小程序序列在 0x80200000,它负责跳到 ethanol 映像档真正的进入点。所以整个流程是

     PA                              VA
     +-------------+ 0x80200000
     | goto        |
     +-------------+ 0x80201000      +------------+ 0xffffff80_00001000
     | hoddarla    |                 | ELF        |
     | kernel ELF  |                 | Header     |
     +     ---     + 0x80202000      +------------+ 0xffffff80_00002000
     | hoddarla    |                 | ELF        |
     | kernel code |                 | .text      |
     ...
     | Entry       | 0x80255418      | Entry      | 0xffffff80_00055418
     ...

由图可见,真正的进入点位址(-T 参数)重新调整为 0xffffff8000002000,这是因为这麽一来,在低位的 20 个位元就可以在实体记忆体与虚拟记忆体位址之间保持一致。

goto 的实作细节这里就不赘述,只是很简单的位址计算而已。当编译完成 ethanol 映像档之後,使用 readelf 工具取得进入点的虚拟记忆体位址,然後拆解这个位址以合成 goto.s 档案,再将之制成一个跳跃用微小程序序列,好让它能够跳跃到真正的进入点去。

首次试跑!

由於我们在进入点处插入了能够印出 H 字元的程序码,因此我们预期的是,QEMU 模拟器启动一个模拟的 RISC-V 系统,CPU 开始执行 OpenSBI 的部分。等到 OpenSBI 启动完成,跳跃并转换权限等级到映像档进入点。然後印出 H 字元之後,Golang 继续进行原本的流程,结果可能踩到某些预期的错误,系统因此进入错误的状态,卡住或是乱跑之类的。

可以存取 github 以进行以下实验。

$ make clean && make
GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
make -C goto
make[2]: 进入目录「/home/noner/FOSS/hoddarla/ithome/ethanol/goto」
./patch.sh
riscv64-buildroot-linux-musl-as goto.s -o goto.o 
riscv64-buildroot-linux-musl-ld -T ld.script goto.o -o goto
riscv64-buildroot-linux-musl-objcopy -O binary goto goto.bin
make[2]: 离开目录「/home/noner/FOSS/hoddarla/ithome/ethanol/goto」
make[1]: 离开目录「/home/noner/FOSS/hoddarla/ithome/ethanol」
qemu-system-riscv64 \
        -smp 4 \
        -M virt \
        -m 256M \
        -nographic \
        -bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
        -device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
        -device loader,file=ethanol/hw,addr=0x80201000,force-raw=on 

OpenSBI v0.9
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name             : riscv-virtio,qemu
Platform Features         : timer,mfdeleg
Platform HART Count       : 4
Firmware Base             : 0x80000000
Firmware Size             : 124 KB
Runtime SBI Version       : 0.3

Domain0 Name              : root
Domain0 Boot HART         : 0
Domain0 HARTs             : 0*,1*,2*,3*
Domain0 Region00          : 0x0000000080000000-0x000000008001ffff ()
Domain0 Region01          : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address      : 0x0000000080200000
Domain0 Next Arg1         : 0x0000000082200000
Domain0 Next Mode         : S-mode
Domain0 SysReset          : yes

Boot HART ID              : 0
Boot HART Domain          : root
Boot HART ISA             : rv64imafdcsu
Boot HART Features        : scounteren,mcounteren,time
Boot HART PMP Count       : 16
Boot HART PMP Granularity : 4
Boot HART PMP Address Bits: 54
Boot HART MHPM Count      : 0
Boot HART MHPM Count      : 0
Boot HART MIDELEG         : 0x0000000000000222
Boot HART MEDELEG         : 0x000000000000a109
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
HHHHHHHHHHHHHHHHHHQEMU: Terminated

什麽?结果 H 字元就这样一直印出?

小结

予焦啦!本章的实际产物可分为三个部分:

  1. Golang 内容改动:这也是实际存在 patch 资料夹下的内容。我们在 src/runtime/rt0_opensbi_riscv64.s 当中新增了 4 行组合语言,代表一个在控制台印出单一字元的 SBI 呼叫。
  2. Hoddarla 专案内容:我们使用 goto 这个中介迷你程序,并介绍 ethanol 进入点如何被串联进启动流程中。过程中我们也回顾了一些 ELF 工具的使用与常识。关於编译後的产物的建置方面,也有 Golang 的连结器参数的介绍。
  3. Hoddarla 专案附带的工具内容:大致上就是如何使用 QEMU 工具开机。

然而,悬而未决的问题是,最後观察到的洗频现象。我们的如意算盘明明是安插一个字元输出,而後继续往後走向 Golang 的执行期初始化流程才对。欲知如何,请待下回分解。


<<:  DAY13 资料室--Vuex载荷Payload

>>:  【Day2】[资料结构]-阵列Array

连续 30 天 玩玩看 ProtoPie - Day 16

新的讲者在 Sky 工作,要来跟我们讲怎麽做有逻辑判断的互动设计。 这次会做三个范例: 判断两次密码...

自动化测试,让你上班拥有一杯咖啡的时间 | Day 19 - 如何写入档案和读取档案

此系列文章会同步发文到个人部落格,有兴趣的读者可以前往观看喔。 writeFile() 语法 cy....

[Day31] 再访碰撞侦测与解析(三) - 解析很复杂!

今日目标 实作AABB的碰撞解析 意外复杂! 今天尝试实作昨天YT教学影片的内容,然後在看看怎麽样把...

【Day18】Git 版本控制 - 多人协作 Git Flow

Okay! 了解 fork 跟 pull request 的运作原理後,接下来我们来谈谈 Flow ...

学习笔记:一起进入 PixiJS 的世界 (六)

上一篇有提到可以利用PIXI.Ticker将定期渲染的机制加进场景,建立基础的小动画,接下来就来试试...