ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入 Go 汇编语言:函数调用、栈分裂、递归与闭包的底层实现

深入 Go 汇编语言:函数调用、栈分裂、递归与闭包的底层实现 文档教程【免费下载链接】advanced-go-programming-book:books: 《Go语言高级编程》开源图书涵盖CGO、Go汇编语言、RPC实现、Protobuf插件实现、Web框架实现、分布式系统等高阶主题(完稿)项目地址https://gitcode.com/gh_mirrors/ad/advanced-go-programming-book点击查看免费下载《Go语言高级编程》开源图书第 3 章「Go 汇编语言」的 3.6 节是对 Go 汇编函数的进阶剖析。本文以此为主体结合仓库中 examples/ch3.6 目录的完整可运行示例逐层拆解 Go 函数在汇编层面的真实面目从 CALL/RET 的调用约定、汇编器自动插入的栈帧指令到栈分裂与扩容机制、PCDATA/FUNCDATA 元数据再到方法函数、递归函数与闭包函数的汇编实现。读完本文你将能读懂go tool asm -S输出的目标代码并具备用手写 Go 汇编实现复杂函数含递归与闭包的实战能力。图 1函数调用参数与栈帧布局图片来自仓库 images 目录对应原书图 3-133.6.1 函数调用规范CALL 与 RET 的本质在 Go 汇编语言中函数调用由两条指令完成CALL调用函数等价于PUSH IPJMP somefunc两条指令的组合——先把当前 IP 指令寄存器的值压入栈中保存返回地址再通过 JMP 将要调用函数的地址写入 IP 寄存器完成跳转RET从被调用函数返回基本等价于POP IP——将 CALL 时保存在栈中的返回地址重新载入 IP 寄存器实现函数返回。与 C 语言的关键区别在于Go 函数的参数和返回值完全通过栈传递而不是通过寄存器。上图图 3-13 函数调用参数布局展示了 Go 函数调用时栈的完整布局自上而下依次是调用参数与返回值空间调用函数前由调用方在栈上准备好输入参数和返回值占位空间返回地址CALL 指令触发返回地址入栈BP 寄存器进入被调用函数后汇编器自动插入 BP 寄存器保存与恢复指令因此 BP 寄存器与返回地址紧挨着局部变量空间当前函数的局部变量以及再次调用其它函数时需要准备的调用参数空间。被调用函数执行 RET 返回时先从栈恢复 BP 和 SP 寄存器再取出返回地址跳转到对应指令继续执行。为什么参数和返回值完全走栈这样带来的好处是函数调用栈布局非常清晰、易于 GC 扫描与栈分裂管理代价是函数调用有额外的性能损耗Go 编译器通过函数内联来缓解这一问题。这一点在 ch3-asm/ch3-04-func.md 的「调用其它函数」小节有更完整的论述。3.6.2 高级汇编语言汇编器到底插入了什么指令Go 汇编语言其实是一种「高级」汇编语言。这里的高级没有任何褒贬色彩而是强调Go 汇编代码与最终真实执行的机器代码并不完全等价。Go 汇编中的一条指令在最终目标代码中可能被编译成其它等价的机器指令用 Go 汇编实现的函数其调用指令序列也会被汇编器插入额外指令。要彻底理解 Go 汇编就必须搞清楚汇编器到底插入了哪些指令。构造一个禁止栈分裂的 printnl 函数为了便于分析先构造一个禁止栈分裂NOSPLIT的printnl函数函数体内部通过调用runtime.printnl输出换行TEXT ·printnl_nosplit(SB), NOSPLIT, $8 CALL runtime·printnl(SB) RET这段代码在仓库中有完整对应实现见 examples/ch3.6/asm-split/main_amd64.s#define NOSPLIT 4 TEXT ·printnl_nosplit(SB), NOSPLIT, $8 CALL runtime·printnl(SB) RET TEXT ·printnl(SB), $8 CALL runtime·printnl(SB) RET配合的 examples/ch3.6/asm-split/main.go 使用//go:nosplit声明了对应的 Go 函数签名并分别调用了printnl与printnl_nosplit两个版本package main //go:nosplit func main() { printnl() } func printnl()用go tool asm -S查看编译后的目标代码通过go tool asm -S main_amd64.s可以查看编译后的目标代码输出中删除了非指令部分.printnl_nosplit STEXT nosplit size29 args0xffffffff80000000 locals0x10 0x0000 00000 (main_amd64.s:5) TEXT .printnl_nosplit(SB), NOSPLIT $16 0x0000 00000 (main_amd64.s:5) SUBQ $16, SP 0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP) 0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP 0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB) 0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP 0x0018 00024 (main_amd64.s:7) ADDQ $16, SP 0x001c 00028 (main_amd64.s:7) RET重新排版并按缩进表示相关功能TEXT .printnl(SB), NOSPLIT, $16 SUBQ $16, SP MOVQ BP, 8(SP) LEAQ 8(SP), BP CALL runtime.printnl(SB) MOVQ 8(SP), BP ADDQ $16, SP RET逐层解读这段汇编器自动生成的代码第一层TEXT指令表示函数开始RET指令表示函数返回第二层SUBQ $16, SP为当前函数帧分配 16 字节空间返回前通过ADDQ $16, SP回收。注意到我们写的 NOSPLIT 版本栈帧是$8但编译后变成了$16——这正是汇编器「多分配了 8 个字节」的痕迹第三层开始部分MOVQ BP, 8(SP)将 BP 寄存器保存到多分配的 8 字节栈空间LEAQ 8(SP), BP将8(SP)的地址重新装入 BP 寄存器结束部分MOVQ 8(SP), BP从栈中恢复之前备份的 BP 值第四层才是我们手写的代码——CALL runtime.printnl(SB)调用换行输出函数。为什么汇编器要多分配 8 字节并维护 BP这是为了建立基于帧指针的调用链回溯BP 保存了上一层函数的栈帧地址panic 时的调用栈打印、runtime.Caller都依赖这条链。这套 BP 维护逻辑由汇编器自动插入程序员无需也无法手工编写。去掉 NOSPLIT栈分裂检测代码的自动插入如果去掉 NOSPLIT 标志重新编译函数开头和结尾会新增指令。缩进格式化后的结果如下TEXT .printnl_nosplit(SB), $16 L_BEGIN: MOVQ (TLS), CX CMPQ SP, 16(CX) JLS L_MORE_STK SUBQ $16, SP MOVQ BP, 8(SP) LEAQ 8(SP), BP CALL runtime.printnl(SB) MOVQ 8(SP), BP ADDQ $16, SP L_MORE_STK: CALL runtime.morestack_noctxt(SB) JMP L_BEGIN RET开头新增的三个指令是栈分裂检测的核心MOVQ (TLS), CX从线程本地存储TLS加载 g 结构体指针到 CX 寄存器CMPQ SP, 16(CX)将当前 SP 栈指针与 g 结构体中偏移 16 字节处的stackguard0成员爆栈警戒线比较JLS L_MORE_STK若 SP 已经越过警戒线比较结果小于等于 0即栈剩余空间不足跳转到结尾的L_MORE_STK部分。在L_MORE_STK标号处先调用runtime.morestack_noctxt进行栈扩容然后通过JMP L_BEGIN跳回函数开始位置重新检测。此时函数的栈已经调整完毕如果检测依然不足则继续扩容直到栈空间足够为止。g 结构体与 stackguard0爆栈警戒线的定义g 结构体定义在 Go 源码$GOROOT/src/runtime/runtime2.go中开头的结构成员如下type g struct { // Stack parameters. stack stack // offset known to runtime/cgo stackguard0 uintptr // offset known to liblink stackguard1 uintptr // offset known to liblink ... }第一个成员stack表示当前栈的开始和结束地址其定义如下// Stack describes a Go execution stack. // The bounds of the stack are exactly [lo, hi), // with no implicit data structures on either side. type stack struct { lo uintptr hi uintptr }g 结构体中的stackguard0是出现爆栈前的警戒线。由于它是 g 结构体的第三个字段前两个 8 字节字段是stack.lo与stack.hi偏移量恰好为 16 字节因此上述代码中CMPQ SP, 16(CX)就是将当前真实 SP 与爆栈警戒线比较超出警戒线即表示需要栈扩容。栈的收缩又是何时处理的Go 运行时会定期进行垃圾回收其中包含栈的回收工作。如果栈的使用比例小于一定阈值运行时会分配一个较小的栈空间然后将原栈上的数据移动到新栈中栈移动的过程与栈扩容的过程类似。3.6.3 PCDATA 与 FUNCDATA让运行时认识每个地址Go 语言的runtime.Caller函数可以获取当前函数的调用者列表从而在运行时精确地定位每个函数的调用位置和调用链这也是 panic 异常、log 输出能精确定位代码位置的基础。例如以下代码可以打印程序的启动流程func main() { for skip : 0; ; skip { pc, file, line, ok : runtime.Caller(skip) if !ok { break } p : runtime.FuncForPC(pc) fnfile, fnline : p.FileLine(0) fmt.Printf(skip %d, pc 0x%08X\n, skip, pc) fmt.Printf(func: file %s, line L%03d, name %s, entry 0x%08X\n, fnfile, fnline, p.Name(), p.Entry()) fmt.Printf(call: file %s, line L%03d\n, file, line) } }其中runtime.Caller先获取当时的 PC 寄存器值以及文件和行号然后根据 PC 表示的指令位置通过runtime.FuncForPC获取函数的基本信息。Go 语言是如何实现这种特性的呢PC 表格PCDATAGo 语言是静态编译型语言执行时每个函数的地址和每条指令的地址都是固定的。如果为每个函数和每条指令生成一张「地址 → 位置信息」的表格PC 表格运行时就能根据 PC 寄存器值轻松查到指令对应的函数与位置信息。Go 实际采用的正是类似策略只不过表格经过裁剪、舍弃了不必要的信息因为要在运行时获取任意地址的位置必然要发生一次函数调用因此只需要为函数的开始、结束位置以及每个函数调用位置生成地址表格地址是有大小顺序的排序后通过只记录增量来压缩数据体积查询时通过二分法加快查找速度。汇编中PCDATA指令用于生成 PC 表格用法为PCDATA tableid, tableoffset第一个参数是表格类型第二个是表格地址。目前实现中有两种表格类型PCDATA_StackMapIndex栈映射索引表格PCDATA_InlTreeIndex内联函数表格。两种表格数据类似都应包含代码所在文件路径、行号和函数信息区别在于PCDATA_InlTreeIndex用于内联函数的表格。此外对于返回值中包含指针类型的汇编函数在返回值指针被初始化之后需要执行GO_RESULTS_INITIALIZED指令#define GO_RESULTS_INITIALIZED PCDATA $PCDATA_StackMapIndex, $1它记录的同样是 PC 表格信息表示 PC 指针越过某个地址之后返回值才完成初始化状态——这样 GC 才能安全地扫描返回值区域。FUNC 表格FUNCDATAGo 语言二进制文件中除了 PC 表格还有 FUNC 表格用于记录函数参数、局部变量的指针信息。FUNCDATA指令与PCDATA格式类似FUNCDATA tableid, tableoffset。目前实现中定义了三种 FUNC 表格类型FUNCDATA_ArgsPointerMaps0函数参数的指针信息表FUNCDATA_LocalsPointerMaps1局部变量的指针信息表FUNCDATA_InlTree2被内联展开的指针信息表。通过 FUNC 表格Go 的垃圾回收器可以跟踪全部指针的生命周期同时根据指针指向的地址是否落在被移动的栈范围决定是否需要进行指针移动。在前面递归函数的例子中遇到的NO_LOCAL_POINTERS宏定义如下#define FUNCDATA_ArgsPointerMaps 0 /* garbage collector blocks */ #define FUNCDATA_LocalsPointerMaps 1 #define FUNCDATA_InlTree 2 #define NO_LOCAL_POINTERS FUNCDATA $FUNCDATA_LocalsPointerMaps, runtime·no_pointers_stackmap(SB)因此NO_LOCAL_POINTERS宏表示将FUNCDATA_LocalsPointerMaps局部指针表格指向runtime·no_pointers_stackmap——这是一个空的指针表格表示该函数没有指针类型的局部变量。PCDATA 和 FUNCDATA 数据一般由编译器自动生成手工编写并不现实如果函数已有 Go 语言声明编译器可以自动输出参数和返回值的指针表格所有函数调用对应 CALL 指令编译器可以辅助生成 PCDATA 表格编译器唯一无法自动生成的是函数局部变量的表格因此在手写汇编函数时要谨慎在局部变量中使用指针类型。对 PCDATA 和 FUNCDATA 细节感兴趣的读者可以从标准库debug/gosym包入手参考其实现和测试代码。寄存器中的指针为何不需要维护一个常见的疑问是垃圾回收或栈调整时寄存器中的指针是如何维护的答案是寄存器中的指针无需维护。因为 Go 函数调用完全通过栈传递参数、不使用寄存器传参且函数调用之后所有寄存器一律视为失效。因此在调整和维护指针时只需扫描内存中的指针数据寄存器中的数据在 GC 函数返回后都需要重新加载自然不需要扫描。3.6.4 方法函数汇编如何实现方法Go 语言中方法函数和全局函数非常相似。例如有以下方法package main type MyInt int func (v MyInt) Twice() int { return int(v)*2 } func MyInt_Twice(v MyInt) int { return int(v)*2 }其中MyInt类型的Twice方法与MyInt_Twice函数的类型完全一样只不过Twice在目标文件中被修饰为main.MyInt.Twice名称。我们可以用汇编实现该方法函数// func (v MyInt) Twice() int TEXT ·MyInt·Twice(SB), NOSPLIT, $0-16 MOVQ a0(FP), AX // v ADDQ AX, AX // AX * 2 MOVQ AX, ret8(FP) // return v RET注意方法名在汇编中使用中点·连接类型名与方法名·MyInt·Twice函数帧大小为$0-16参数v与返回值各占 8 字节合计 16 字节与 Go 声明的函数签名严格对应。但这只是接收非指针类型的方法。再增加一个接收参数为指针类型的Ptr方法func (p *MyInt) Ptr() *MyInt { return p }在目标文件中Ptr方法名被修饰为main.(*MyInt).Ptr对应汇编中的·(*MyInt)·Ptr。然而在 Go 汇编语言中星号和小括弧都无法用作函数名字因此无法直接用汇编实现接收参数为指针类型的方法。进一步看最终目标文件的标识符中还有很多 Go 汇编语言不支持的特殊符号例如type.string.hello中的双引号这导致手写汇编无法实现全部特性。这或许是 Go 语言官方刻意限制汇编语言能力的设计决策。3.6.5 递归函数1 到 n 求和递归函数通过调用自身并在栈上保存状态来简化问题处理。Go 语言递归函数的强大之处在于不用担心爆栈问题——栈可以根据需要自动扩容和收缩。首先通过 Go 递归函数实现 1 到 n 的求和// sum 12...n // sum(100) 5050 func sum(n int) int { if n 0 {return nsum(n-1) } else { return 0 } }用汇编思维重构递归再用 if/goto 重构上面的递归函数以便转译为汇编版本func sum(n int) (result int) { var AX n var BX int if n 0 {goto L_STEP_TO_END} goto L_END L_STEP_TO_END: AX - 1 BX sum(AX) AX n // 调用函数后, AX 重新恢复为 n BX AX return BX L_END: return 0 }改写后递归调用的参数需要引入局部变量、保存中间结果也需要引入局部变量。通过栈保存中间的调用状态正是递归函数的核心。因为输入参数也在栈上我们可以通过输入参数保存少量状态同时模拟定义的 AX、BX 寄存器在使用前需要初始化并且在函数调用后也需要重新初始化——因为被调用函数很可能破坏寄存器状态。汇编版本的 sum 函数继续改造为汇编语言版本// func sum(n int) (result int) TEXT ·sum(SB), NOSPLIT, $16-16 MOVQ n0(FP), AX // n MOVQ result8(FP), BX // result CMPQ AX, $0 // test n - 0 JG L_STEP_TO_END // if 0: goto L_STEP_TO_END JMP L_END // goto L_STEP_TO_END L_STEP_TO_END: SUBQ $1, AX // AX - 1 MOVQ AX, 0(SP) // arg: n-1 CALL ·sum(SB) // call sum(n-1) MOVQ 8(SP), BX // BX sum(n-1) MOVQ n0(FP), AX // AX n ADDQ AX, BX // BX AX MOVQ BX, result8(FP) // return BX RET L_END: MOVQ $0, result8(FP) // return 0 RET逐段解读函数没有定义局部变量只有用于调用自身的临时栈空间函数参数和返回值共 16 字节因此栈帧大小也是 16 字节$16-16L_STEP_TO_END标号部分处理递归调用是函数最复杂的部分调用 sum 函数的参数放在0(SP)位置调用结束后的返回值在8(SP)位置函数调用之后必须重新为寄存器注入值——被调用的函数内部很可能破坏了寄存器状态同时输入参数的值也不可信任可能在被调用函数内部被修改L_END处理递归终结部分直接返回 0。用汇编实现递归函数与普通函数本质上并无区别在不考虑爆栈的前提下。但要注意当 n 大到一定程度、栈达到一定深度必然会出现爆栈问题。爆栈是 C 语言的特性不应该出现在哪怕是 Go 汇编语言中。让汇编器自动生成栈扩容代码Go 编译器在生成函数的机器代码时会在开头插入一小段栈检测代码。因为 sum 函数需要深度递归调用我们应删除 NOSPLIT 标志让汇编器自动生成栈扩容代码#include funcdata.h // func sum(n int) int TEXT ·sum(SB), $16-16 NO_LOCAL_POINTERS // 原来的代码除了去掉 NOSPLIT 标志还在函数开头增加了NO_LOCAL_POINTERS语句表示该函数没有局部指针变量。栈的扩容必然涉及函数参数和局部指针的调整如果缺少局部指针信息扩容工作将无法进行不仅栈扩容需要函数的参数和局部指针标记表格GC 垃圾回收同样需要。函数参数和返回值的指针状态可以通过 Go 语言中的函数声明获取而局部变量则需要手工指定。因为手工指定指针表格非常繁琐所以一般要避免在手写汇编中出现局部指针。3.6.6 闭包函数捕获变量的汇编本质闭包函数是最强大的函数——它可以捕获外层局部作用域的局部变量因此闭包函数本身就具有了状态。从理论上说全局函数也是闭包函数的子集只不过全局函数没有捕获外层变量而已。从 Go 语言的闭包例子出发为理解闭包的工作方式先构造如下的例子完整代码见 examples/ch3.6/closure-01/main.gopackage main func NewTwiceFunClosure(x int) func() int { return func() int { x * 2 return x } } func main() { fnTwice : NewTwiceFunClosure(1) println(fnTwice()) // 1*2 2 println(fnTwice()) // 2*2 4 println(fnTwice()) // 4*2 8 }NewTwiceFunClosure函数返回一个闭包函数对象捕获了外层的x参数。每次调用返回的闭包函数时都会把捕获的变量乘以 2 再返回因此main中依次输出 2、4、8。从 Go 语言层面这段代码非常容易理解但闭包在汇编层面是如何工作的呢下面手工构造闭包来展示其原理。用结构体手工构造闭包对象首先是构造FunTwiceClosure结构体类型来表示闭包对象完整代码见 examples/ch3.6/closure-02/main.gotype FunTwiceClosure struct { F uintptr X int } func NewTwiceFunClosure(x int) func() int { var p FunTwiceClosure{ F: asmFunTwiceClosureAddr(), X: x, } return ptrToFunc(unsafe.Pointer(p)) }FunTwiceClosure结构体包含两个成员F uintptr闭包函数的函数指令地址X int闭包捕获的外部变量。如果闭包捕获了多个外部变量结构体也要相应扩展。asmFunTwiceClosureAddr函数用于辅助获取闭包函数的指令地址ptrToFunc辅助函数将结构体指针转为闭包函数对象返回二者均由汇编语言实现。汇编实现的三个辅助函数func ptrToFunc(p unsafe.Pointer) func() int func asmFunTwiceClosureAddr() uintptr func asmFunTwiceClosureBody() int其中ptrToFunc将指针转化为func() int类型的闭包函数asmFunTwiceClosureAddr返回闭包函数机器指令的开始地址类似全局函数的地址asmFunTwiceClosureBody是闭包函数对应的全局函数实现。三个辅助函数的 Go 汇编实现如下对应仓库 examples/ch3.6/closure-02/main_amd64.s#include textflag.h TEXT ·ptrToFunc(SB), NOSPLIT, $0-16 MOVQ ptr0(FP), AX // AX ptr MOVQ AX, ret8(FP) // return AX RET TEXT ·asmFunTwiceClosureAddr(SB), NOSPLIT, $0-8 LEAQ ·asmFunTwiceClosureBody(SB), AX // AX ·asmFunTwiceClosureBody(SB) MOVQ AX, ret0(FP) // return AX RET TEXT ·asmFunTwiceClosureBody(SB), NOSPLIT|NEEDCTXT, $0-8 MOVQ 8(DX), AX ADDQ AX , AX // AX * 2 MOVQ AX , 8(DX) // ctx.X AX MOVQ AX , ret0(FP) // return AX RET·ptrToFunc与·asmFunTwiceClosureAddr实现简单无需赘述。最核心的是·asmFunTwiceClosureBody函数它带有NEEDCTXT标志——采用该标志定义的汇编函数表示需要一个上下文环境在 AMD64 环境下通过DX 寄存器传递这个上下文环境指针即FunTwiceClosure结构体的指针这与上一章 3.4 节提到的NEEDCTXT标志「一般用于闭包函数」相互印证函数首先从FunTwiceClosure结构体对象取出捕获的X偏移 8 字节处即8(DX)将X乘以 2 后写回内存最后返回修改后的X值。如果在汇编语言中调用闭包函数需要遵循同样的流程首先构造闭包对象并保存捕获的外层变量调用闭包函数时先拿到闭包对象用闭包对象初始化 DX 寄存器然后从闭包对象中取出函数地址通过CALL指令调用。总结从这一节的深入剖析可以看出Go 汇编语言远不止是机器指令的简单映射而是一套与 Go 运行时深度绑定的「高级汇编」调用约定参数与返回值全部通过栈传递CALL/RET 只负责保存和恢复返回地址栈帧管理汇编器自动插入 BP 保存/恢复指令并自动生成基于stackguard0警戒线的栈分裂检测代码配合runtime.morestack_noctxt实现栈的自动扩容与收缩元数据支撑PCDATA 与 FUNCDATA 表格让运行时能根据 PC 值定位函数位置、跟踪指针生命周期是runtime.Caller、panic 回溯与 GC 指针扫描的基础复杂函数形态方法函数、递归函数乃至闭包函数都能通过汇编实现——方法函数依赖中点式命名与栈传参递归函数依赖调用前对寄存器和参数的谨慎管理闭包函数则借助NEEDCTXT标志与 DX 寄存器传递上下文指针用「函数指针 捕获变量」的结构体天然地表达闭包对象。文中所有汇编示例printnl栈分裂对比、sum递归求和、FunTwiceClosure闭包构造在仓库 examples/ch3.6 目录下均有完整源码可供编译验证读者可以结合go tool asm -S亲自观察汇编器插入的每一段指令从而真正掌握 Go 函数在机器层面的运行真相。赞分享文档教程【免费下载链接】advanced-go-programming-book:books: 《Go语言高级编程》开源图书涵盖CGO、Go汇编语言、RPC实现、Protobuf插件实现、Web框架实现、分布式系统等高阶主题(完稿)项目地址https://gitcode.com/gh_mirrors/ad/advanced-go-programming-book点击查看免费下载相关推荐零基础玩转 RVC 变声器保姆级语音转换教程小白也能 5 分钟上手零基础玩转 RVC 变声器保姆级语音转换教程小白也能 5 分钟上手 打开游戏开黑、登录直播、发一段语音给朋友……如果这一刻你的声音能变成动漫角色的可爱声线人工智能AI 应用语音音频深度学习终极指南Go语言高级编程中的汇编并发原语与调度器底层机制详解终极指南Go语言高级编程中的汇编并发原语与调度器底层机制详解 《Go语言高级编程》开源图书是深入学习Go语言高级特性的权威资源涵盖CGO、Go汇编语言、RP文档教程pure sh bible高级函数递归与闭包实现技巧pure sh bible高级函数递归与闭包实现技巧 你是否在编写Shell脚本时遇到过需要处理层级目录结构或复杂状态管理的场景传统的Shell脚本往往依赖教程上一篇告别API文档tRPC如何用TypeScript类型定义HTTP RPC规范下一篇让模糊记忆重获新生SeedVR-7B视频修复模型使用指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进