不同体系结构的 CPU,其内部寄存器的数量、种类以及名称可能大不相同,这里我们只介绍 AMD64 的寄存器。AMD64 有 20 多个可以直接在汇编代码中使用的寄存器,其中有几个寄存器在操作系统代码中才会见到,而应用层代码一般只会用到如下三类寄存器。
上述这些寄存器除了段寄存器是 16 位的,其它都是 64 位的,也就是 8 个字节,其中的 16 个通用寄存器还可以作为 32/16/8 位寄存器使用,只是使用时需要换一个名字,比如可以用 EAX 这个名字来表示一个 32 位的寄存器,它使用的是 RAX 寄存器的低 32 位。
AMD64 的通用通用寄存器的名字在 plan9 中的对应关系:
AMD64 |
RAX |
RBX |
RCX |
RDX |
RDI |
RSI |
RBP |
RSP |
R8 |
R9 |
R10 |
R11 |
R12 |
R13 |
R14 |
RIP |
Plan9 |
AX |
BX |
CX |
DX |
DI |
SI |
BP |
SP |
R8 |
R9 |
R10 |
R11 |
R12 |
R13 |
R14 |
PC |
Go 语言中寄存器一般用途:
伪寄存器是 plan9 伪汇编中的一个助记符, 也是 Plan9 比较有个性的语法之一。常见伪寄存器如下表所示:
SB:指向全局符号表。相对于寄存器,SB 更像是一个声明标识,用于标识全局变量、函数等。通过 symbol(SB) 方式使用,symbol<>(SB)表示 symbol 只在当前文件可见,跟 C 中的 static 效果类似。此外可以在引用上加偏移量,如 symbol+4(SB) 表示 symbol+4bytes 的地址。
PC:程序计数器(Program Counter),指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。个人觉得,把他归为伪寄存器有点令人费解,可能是因为每个平台对应的物理寄存器名字不一样。
SP:SP 寄存器比较特殊,既可以当做物理寄存器也可以当做伪寄存器使用,不过这两种用法的使用语法不同。其中,伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。
FP:用于标识函数参数、返回值。被调用者(callee)的 FP 实际上是调用者(caller)的栈顶,即 callee.SP(物理SP) == caller.FP;x+0(FP) 表示第一个请求参数(参数返回值从右到左入栈)。
实际上,生成真正可执行代码时,伪 SP、FP 会由物理 SP 寄存器加上偏移量替换。所以执行过程中修改物理 SP,会引起伪 SP、FP 同步变化,比如执行 SUBQ $16, SP 指令后,伪 SP 和伪 FP 都会 -16。而且,反汇编二进制而生成的汇编代码中,只有物理 SP 寄存器。即 go tool objdump/go tool compile -S 输出的汇编代码中,没有伪 SP 和 伪 FP 寄存器,只有物理 SP 寄存器。
另外还有 1 个比较特殊的伪寄存器:TLS:存储当前 goroutine 的 g 结构体的指针。实际上,X86 和 AMD64 下的 TLS 是通过段寄存器 FS 或 GS 实现的线程本地存储基地址,而当前 g 的指针是线程本地存储的第一个变量。
比如 github.com/petermattis/goid.Get 函数的汇编实现如下:
// func Get() int64
TEXT ·Get(SB),NOSPLIT,$0-8
MOVQ (TLS), R14
MOVQ g_goid(R14), R13
MOVQ R13, ret+0(FP)
RET
编译成二进制之后,再通过 go tool objdump 反编译成汇编(Go 1.18),得到如下代码:
TEXT github.com/petermattis/goid.Get.abi0(SB) /Users/bytedance/go/pkg/mod/github.com/petermattis/goid@v0.0.0-20221215004737-a150e88a970d/goid_go1.5_amd64.s
goid_go1.5_amd64.s:28 0x108adc0 654c8b342530000000 MOVQ GS:0x30, R14
goid_go1.5_amd64.s:29 0x108adc9 4d8bae98000000 MOVQ 0x98(R14), R13
goid_go1.5_amd64.s:30 0x108add0 4c896c2408 MOVQ R13, 0x8(SP)
goid_go1.5_amd64.s:31 0x108add5 c3 RET
可以知道 MOVQ (TLS), R14 指令最终编译成了 MOVQ GS:0x30, R14 ,使用了 GS 段寄存器实现相关功能。
操作系统对内存的一般划分如下图所示:
高地址 +------------------+
| |
| 内核空间 |
| |
--------------------
| |
| 栈 |
| |
--------------------
| |
| ....... |
| |
--------------------
| |
| 堆 |
| |
--------------------
| 全局数据 |
|------------------|
| |
| 静态代码 |
| |
|------------------|
| 系统保留 |
低地址 |------------------|
这里提个疑问,我们知道协程分为有栈协程和无栈协程,go 语言是有栈协程。那你知道普通 gorutine 的调用栈是在哪个内存区吗?
我们先熟悉几个名词。
caller:函数调用者。callee:函数被调用者。比如函数 main 中调用 sum 函数,那么 main 就是 caller,而 sum 函数就是 callee。栈帧:stack frame,即执行中的函数所持有的、独立连续的栈区段。一般用来保存函数参数、返回值、局部变量、返回 PC 值等信息。golang 的 ABI 规定,由 caller 管理函数参数和返回值。
下图是 golang 的调用栈,源于曹春晖老师的 github 文章《汇编 is so easy》 ,做了简单修改:
caller
+------------------+
| |
+----------------------> +------------------+
| | |
| | caller parent BP |
| BP(pseudo SP) +------------------+
| | |
| | Local Var0 |
| +------------------+
| | |
| | ....... |
| +------------------+
| | |
| | Local VarN |
+------------------+
caller stack frame | |
| callee arg2 |
| +------------------+
| | |
| | callee arg1 |
| +------------------+
| | |
| | callee arg0 |
| SP(Real Register) -> +------------------+--------------------------+ FP(virtual register)
| | | |
| | return addr | parent return address |
+----------------------> +------------------+--------------------------+ <-----------------------+
| caller BP | |
| (caller frame pointer) | |
BP(pseudo SP) +--------------------------+ |
| | |
| Local Var0 | |
+--------------------------+ |
| |
| Local Var1 |
+--------------------------+ callee stack frame
| |
| ..... |
+--------------------------+ |
| | |
| Local VarN | |
High SP(Real Register) +--------------------------+ |
^ | | |
| | | |
| | | |
| | | |
| | | |
| +--------------------------+ <-----------------------+
Low
callee
需要指出的是,上图中的 CALLER BP 是在编译期由编译器在符合条件时自动插入。所以手写汇编时,计算 framesize 时不应包括 CALLER BP 的空间。是否插入 CALLER BP 的主要判断依据如下:
// Must agree with internal/buildcfg.FramePointerEnabled.
const framepointer_enabled = GOARCH == "amd64" || GOARCH == "arm64"
以下是 Go 语言函数栈展开逻辑的一段代码,它侧面验证了 BP 插入的条件:
// For architectures with frame pointers, if there's
// a frame, then there's a saved frame pointer here.
//
// NOTE: This code is not as general as it looks.
// On x86, the ABI is to save the frame pointer word at the
// top of the stack frame, so we have to back down over it.
// On arm64, the frame pointer should be at the bottom of
// the stack (with R29 (aka FP) = RSP), in which case we would
// not want to do the subtraction here. But we started out without
// any frame pointer, and when we wanted to add it, we didn't
// want to break all the assembly doing direct writes to 8(RSP)
// to set the first parameter to a called function.
// So we decided to write the FP link *below* the stack pointer
// (with R29 = RSP - 8 in Go functions).
// This is technically ABI-compatible but not standard.
// And it happens to end up mimicking the x86 layout.
// Other architectures may make different decisions.
if frame.varp > frame.sp && framepointer_enabled {
frame.varp -= goarch.PtrSize
}
// Must agree with internal/buildcfg.FramePointerEnabled.
const framepointer_enabled = GOARCH == "amd64" || GOARCH == "arm64"
参考文档:
Go 支持的 X86 指令
https://github.com/golang/arch/blob/v0.2.0/x86/x86.csv
Go 支持的 ARM64 指令
https://github.com/golang/arch/blob/v0.2.0/arm64/arm64asm/inst.json
Go 支持的 ARM 指令
https://github.com/golang/arch/blob/v0.2.0/arm/arm.csv
常用指令:
例如
MOVB $1, DI // 1 byte; 将 DI 的第一个 Byte 的值设置为 1
MOVW $0x10, BX // 2bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
SUBQ $0x18, SP //对SP做减法,扩栈
ADDQ $0x18, SP //对SP做加法,缩栈
ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
IMULQ AX, BX // BX *= AX
JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写一般不会出现
JMP label // 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC) // 向前转2行
JMP -2(PC) // 向后跳转2行
JNZ target // 如果zero flag被set过,则跳转
常用标志位:
1.5 全局变量
参考文档:《Go语言高级编程》的章节 3.3 常量和全局变量
https://github.com/chai2010/advanced-go-programming-book/blob/master/ch3-asm/ch3-03-const-and-var.md
1.5.1 使用语法
使用 GLOBL 关键字声明全局变量,用 DATA 定义指定内存的值:
// DATA 汇编指令指定对应内存中的值; width 必须是 1、2、4、8 几个宽度之一
DATA symbol+offset(SB)/width, value // symbol+offset 偏移量,width 宽度, value 初始值
// GLOBL 指令声明一个变量对应的符号,以及变量对应的内存大小
GLOBL symbol(SB), flag, width // 名为 symbol, 内存宽度为 width, flag可省略
例子:
DATA age+0x00(SB)/4, $18 // age = 18
GLOBL age(SB), RODATA, $4 // 声明全局变量 age,占用 4Byte 内存空间
DATA pi+0(SB)/8, $3.1415926
GLOBL pi(SB), RODATA, $8
DATA bio<>+0(SB)/8, $"hello wo" // <> 表示只在当前文件生效
DATA bio<>+8(SB)/8, $"old !!!!" // bio = "hello world !!!!"
GLOBL bio<>(SB), RODATA, $16
其中 flag 的字面量定义在 Go 标准库下 src/runtime/textflag.h 文件中,需要在汇编文件中 #include "textflag.h",其类型有有如下几个:
flag |
value |
说明 |
NOPROF |
1 |
(TEXT项使用) 不优化NOPROF标记的函数。这个标志已废弃。(For TEXT items.) Don't profile the marked function. This flag is deprecated. |
DUPOK |
2 |
在二进制文件中允许一个符号的多个实例。链接器会选择其中之一。It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use. |
NOSPLIT |
4 |
(TEXT项使用) 不插入检测栈分裂(扩张)的前导指令代码(减少开销,一般用于叶子节点函数(函数内部不调用其他函数))。程序的栈帧中,如果调用其他函数会增加栈帧的大小,必须在栈顶留出可用空间。用来保护程序,例如堆栈拆分代码本身。(For TEXT items.) Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself. |
RODATA |
8 |
(DATA和GLOBAL项使用) 将这个数据放在只读的块中。(For DATA and GLOBL items.) Put this data in a read-only section. |
NOPTR |
16 |
(用于DATA和GLOBL项目)这个数据不包含指针所以就不需要垃圾收集器来扫描。(For DATA and GLOBL items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector. |
WRAPPER |
32 |
(TEXT项使用)这是包装函数 (For TEXT items.) This is a wrapper function and should not count as disabling recover. |
NEEDCTXT |
64 |
(TEXT项使用)此函数是一个闭包,因此它将使用其传入的上下文寄存器。(For TEXT items.) This function is a closure so it uses its incoming context register. |
TLSBSS |
256 |
(用于DATA和GLOBL项目)将此数据放入线程本地存储中。Allocate a word of thread local storage and store the offset from the thread local base to the thread local storage in this variable. |
NOFRAME |
512 |
(TEXT项使用)不要插入指令为此函数分配栈帧。仅在声明帧大小为0的函数上有效。(函数必须是叶子节点函数,且以0标记堆栈函数,没有保存帧指针(或link寄存器架构上的返回地址))TODO(mwhudson):目前仅针对 ppc64x 实现。Do not insert instructions to allocate a stack frame for this function. Only valid on functions that declare a frame size of 0. TODO(mwhudson): only implemented for ppc64x at present. |
REFLECTMETHOD |
1024 |
函数可以调用 reflect.Type.Method 或 reflect.Type.MethodByName。Function can call reflect.Type.Method or reflect.Type.MethodByName. |
TOPFRAME |
2048 |
(TEXT项使用)函数是调用堆栈的顶部。栈回溯应在此功能处停止。Function is the outermost frame of the call stack. Call stack unwinders should stop at this function. |
ABIWRAPPER |
4096 |
函数是一个 ABI 包装器。Function is an ABI wrapper. |
其中 NOSPLIT 需要特别注意,它表示该函数运行不会导致栈分裂,用户也可以使用 //go:nosplit 强制给 go 函数指定 NOSPLIT 属性。例如:
//go:nosplit
func someFunc() {
}
汇编中直接给函数标记 NOSPLIT 即可:
// 表示someFunc函数执行时最多需要 24 字节本地变量和 8 字节参数空间
TEXT ·someFunc(SB), NOSPLIT, $24-8
RET
链接器认为标记为 NOSPLIT 的函数,最多需要使用 StackLimit 字节空间,所以不需要插入栈分裂(溢出)检查,函数调用损耗更小。不过,使用该标志的时候要特别小心,万一发生意外容易导致栈溢出错误,溢出时会在执行期报 nosplit stack overflow 错。Go 1.18 标准库下 go/src/runtime/HACKING.md 中有如下说明:
nosplit functions
Most functions start with a prologue that inspects the stack pointer and the current G's stack bound and calls morestack if the stack needs to grow.
Functions can be marked //go:nosplit (or NOSPLIT in assembly) to indicate that they should not get this prologue. This has several uses:
- Functions that must run on the user stack, but must not call into stack growth, for example because this would cause a deadlock, or because they have untyped words on the stack.
- Functions that must not be preempted on entry.
- Functions that may run without a valid G. For example, functions that run in early runtime start-up, or that may be entered from C code such as cgo callbacks or the signal handler.
Splittable functions ensure there's some amount of space on the stack for nosplit functions to run in and the linker checks that any static chain of nosplit function calls cannot exceed this bound.
Any function with a //go:nosplit annotation should explain why it is nosplit in its documentation comment.
另外,当函数处于调用链的叶子节点,且栈帧小于 StackSmall(128)字节时,则自动标记为 NOSPLIT。此逻辑的代码如下:
//const StackSmall = 128
if ctxt.Arch.Family == sys.AMD64 && autoffset < objabi.StackSmall && !p.From.Sym.NoSplit() {
leaf := true
LeafSearch:
for q := p; q != nil; q = q.Link {
switch q.As {
case obj.ACALL:
// Treat common runtime calls that take no arguments
// the same as duffcopy and duffzero.
if !isZeroArgRuntimeCall(q.To.Sym) {
leaf = false
break LeafSearch
}
fallthrough
case obj.ADUFFCOPY, obj.ADUFFZERO:
if autoffset >= objabi.StackSmall-8 {
leaf = false
break LeafSearch
}
}
}
if leaf {
p.From.Sym.Set(obj.AttrNoSplit, true)
}
}
在汇编代码中使用 go 变量:
#include "textflag.h"
TEXT ·get(SB), NOSPLIT, $0-8
MOVQ ·a(SB), AX // 把 go 代码定义的全局变量读到 AX 中
MOVQ AX, ret+0(FP) // 把 AX 的值写入返回值位置
RET
package main
var a = 999
func get() intfunc main() {
println(get())
}
go 代码中使用汇编定义的变量:
// string 定义形式 1: 在 String 结构体后多分配一个 [n]byte 数组存放静态字符串
DATA ·Name+0(SB)/8,$·Name+16(SB) // StringHeader.Data
DATA ·Name+8(SB)/8,$6 // StringHeader.Len
DATA ·Name+16(SB)/8,$"gopher" // [6]byte{'g','o','p','h','e','r'}
GLOBL ·Name(SB),NOPTR,$24 // struct{Data uintptr, Len int, str [6]byte}
// string 定义形式 2:独立分配一个仅当前文件可见的 [n]byte 数组存放静态字符串
DATA str<>+0(SB)/8,$"Hello Wo" // str[0:8]={'H','e','l','l','o',' ','W','o'}
DATA str<>+8(SB)/8,$"rld!" // str[9:12]={'r','l','d','!''}
GLOBL str<>(SB),NOPTR,$16 // 定义全局数组 var str<> [16]byte
DATA ·Helloworld+0(SB)/8,$str<>(SB) // StringHeader.Data = &str<>
DATA ·Helloworld+8(SB)/8,$12 // StringHeader.Len = 12
GLOBL ·Helloworld(SB),NOPTR,$16 // struct{Data uintptr, Len int}
var Name,Helloworld string
func doSth() {
fmt.Printf("Name: %s\n", Name) // 读取汇编中初始化的变量 Name
fmt.Printf("Helloworld: %s\n", Helloworld) // 读取汇编中初始化的变量 Helloworld
}
// 输出:
// Name: gopher
// Helloworld: Hello World!
Go 语言汇编中,函数声明格式如下:
告诉汇编器该数据放到TEXT区
^ 静态基地址指针(告诉汇编器这是基于静态地址的数据)
| ^
| | 标签 函数入参+返回值占用空间大小
| | ^ ^
| | | |
TEXT pkgname·funcname(SB),TAG,$16-24
^ ^ ^ ^
| | | |
函数所属包名 函数名 表示ABI类型 函数栈帧大小(本地变量占用空间大小)
一些说明:
go 语言编译成汇编:
go tool compile -S xxx.go
go build -gcflags -S xxx.go
从二进制反编译为汇编:
go tool objdump -s "main.main" main.out > main.S
Go 函数调用汇编函数:
// add.go
package main
import "fmt"
func add(x, y int64) int64
func main() {
fmt.Println(add(2, 3))
}
// add_amd64.s
// add(x,y) -> x+y
TEXT ·add(SB),NOSPLIT,$0
MOVQ x+0(FP), BX
MOVQ y+8(FP), BP
ADDQ BP, BX
MOVQ BX, ret+16(FP)
RET
汇编调用 go 语言函数:
package main
import "fmt"
func add(x, y int) int {
return x + y
}
func output(a, b int) int
func main() {
s := output(10, 13)
fmt.Println(s)
}
#include "textflag.h"
// func output(a,b int) int
TEXT ·output(SB), NOSPLIT, $24-24
MOVQ a+0(FP), DX // arg a
MOVQ DX, 0(SP) // arg x
MOVQ b+8(FP), CX // arg b
MOVQ CX, 8(SP) // arg y
CALL ·add(SB) // 在调用 add 之前,已经把参数都通过物理寄存器 SP 搬到了函数的栈顶
MOVQ 16(SP), AX // add 函数会把返回值放在这个位置
MOVQ AX, ret+16(FP) // return result
RET
GO_RESULTS_INITIALIZED:如果 Go 汇编函数返回值含指针,则该指针信息必须由 Go 源文件中的函数的 Go 原型提供,即使对于未直接从 Go 调用的汇编函数也是如此。如果返回值将在调用指令期间保存实时指针,则该函数中应首先将结果归零, 然后执行伪指令 GO_RESULTS_INITIALIZED。表明该堆栈位置应该执行进行 GC 扫描,避免其指向的内存地址呗 GC 意外回收。
NO_LOCAL_POINTERS: 就是字面意思,表示函数没有指针类型的局部变量。
PCDATA: Go 语言生成的汇编,利用此伪指令表明汇编所在的原始 Go 源码的位置(file&line&func),用于生成 PC 表格。runtime.FuncForPC 函数就是通过 PC 表格得到结果的。一般由编译器自动插入,手动维护并不现实。
FUNCDATA: 和 PCDATA 的格式类似,用于生成 FUNC 表格。FUNC 表格用于记录函数的参数、局部变量的指针信息,GC 依据它来跟踪栈中指针指向内存的生命周期,同时栈扩缩容的时候也是依据它来确认是否需要调整栈指针的值(如果指向的地址在需要扩缩容的栈中,则需要同步修改)。
Go 语言仅支持有限的条件编译规则:
根据文件名编译类似 *_test.go,通过添加平台后缀区分,比如: asm_386.s、asm_amd64.s、asm_arm.s、asm_arm64.s、asm_mips64x.s、asm_linux_amd64.s、asm_bsd_arm.s 等.
根据 build 注释编译,就是在源码中加入区分平台和编译器版本的注释。比如:
//go:build (darwin || freebsd || netbsd || openbsd) && gc
// +build darwin freebsd netbsd openbsd
// +build gc
Go 1.17 之前,我们可以通过在源码文件头部放置 +build 构建约束指示符来实现构建约束,但这种形式十分易错,并且它并不支持&&和||这样的直观的逻辑操作符,而是用逗号、空格替代,下面是原 +build 形式构建约束指示符的用法及含义:
Go 1.17 引入了 //go:build 形式的构建约束指示符,支持&&和||逻辑操作符,如下代码所示:
//go:build linux && (386 || amd64 || arm || arm64 || mips64 || mips64le || ppc64 || ppc64le)
//go:build linux && (mips64 || mips64le)
//go:build linux && (ppc64 || ppc64le)
//go:build linux && !386 && !arm
考虑到兼容性,Go 命令可以识别这两种形式的构建约束指示符,但推荐 Go 1.17 之后都用新引入的这种形式。
gofmt 可以兼容处理两种形式,处理原则是:如果一个源码文件只有 // +build 形式的指示符,gofmt 会将与其等价的 //go:build 行加入。否则,如果一个源文件中同时存在这两种形式的指示符行,那么 //+build 行的信息将被 //go:build 行的信息所覆盖。
参考文档:
Go internal ABI specification
https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md
Proposal: Create an undefined internal calling convention
https://go.googlesource.com/proposal/+/master/design/27539-internal-abi.md
名词解释:ABI: application binary interface, 应用程序二进制接口,规定了程序在机器层面的操作规范和调用规约。调用规约: calling convention, 所谓“调用规约”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。
Go 从1.17.1版本开始支持多 ABI:1. 为了兼容性各平台保持通用性,保留历史版本 ABI,并更名为 ABI0。2. 为了更好的性能,增加新版本 ABI 取名 ABIInternal。ABI0 遵循平台通用的函数调用约定,实现简单,不用担心底层cpu架构寄存器的差异;ABIInternal 可以指定特定的函数调用规范,可以针对特定性能瓶颈进行优化,在多个 Go 版本之间可以迭代,灵活性强,支持寄存器传参提升性能。Go 汇编为了兼容已存在的汇编代码,保持使用旧的 ABI0。
Go 为什么在有了 ABI0 之后,还要引入 ABIInternal?当然是为了性能!据官方测试,寄存器传参可以带来 5% 的性能提升。
我们看一个例子:
package main
import _ "fmt"
func Print(delta string)
func main() {
Print("hello")
}
#include "textflag.h"
TEXT ·Print(SB), NOSPLIT, $8
CALL fmt·Println(SB)
RET
运行上面代码会报错:main.Print: relocation target fmt.Println not def
当前标题:Go汇编详解
分享路径:http://www.shufengxianlan.com/qtweb/news20/422270.html
网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联