Posted in

【稀缺首发】Golang虚拟化内核模块开发套件(含符号注入调试器+虚拟设备Fuzz框架),仅限本文读者领取

第一章:Golang虚拟化技术全景概览

Go 语言虽非为虚拟化而生,但其轻量级并发模型、静态编译能力、无依赖二进制分发特性,使其在现代虚拟化生态中扮演日益关键的角色——从轻量级容器运行时到用户态虚拟机监控器(VMM),再到安全沙箱与 WASM 虚拟化网关,Golang 正成为构建可扩展、高可靠虚拟化基础设施的首选语言之一。

核心应用场景

  • 容器运行时层containerdCRI-O 均以 Go 编写,通过 runc(Go 实现的轻量级 OCI 运行时)调用 Linux namespaces/cgroups 实现进程级隔离;
  • 用户态虚拟机监控器Firecracker(AWS 开源)完全用 Rust 编写,但其 Go 生态配套工具链(如 firecracker-go-sdk)被广泛用于构建 Serverless 底座;
  • eBPF 辅助虚拟化cilium 使用 Go 编写控制平面,结合 eBPF 程序实现零拷贝网络虚拟化与服务网格透明劫持;
  • WASM 虚拟化平台wasmedge-go 提供 Go 绑定,支持在 Go 服务中直接嵌入 WASM 字节码执行环境,替代传统容器实现更细粒度沙箱。

典型开发实践示例

以下代码片段演示如何使用 gvisor 的 Go API 启动一个受沙箱保护的进程(需提前安装 runsc):

package main

import (
    "os/exec"
    "log"
)

func main() {
    // runsc 是 gVisor 的兼容 runc 接口的沙箱运行时
    cmd := exec.Command("runsc", "--platform=kvm", "run", "my-sandboxed-pod")
    cmd.Env = append(cmd.Env, "RUNTIME_ROOT=/var/run/runtime-root")
    if err := cmd.Run(); err != nil {
        log.Fatalf("Failed to launch sandbox: %v", err) // 错误将包含 KVM 模式启动失败或 seccomp 策略拒绝等具体原因
    }
}

该调用会触发 gVisor 的 Sentry(用户态内核)接管系统调用,避免直接进入宿主机内核,显著提升多租户隔离强度。

主流项目对比简表

项目 语言 隔离粒度 启动延迟 兼容性目标
runc Go 进程/命名空间 完整 OCI 兼容
gVisor Go+Rust 用户态内核 ~100ms 大部分 Linux syscall
Firecracker Rust MicroVM ~120ms KVM-based,精简 ABI

Golang 虚拟化技术并非追求“重”虚拟化性能,而是以工程简洁性、部署一致性与安全纵深为目标,在云原生演进中持续定义新的可信边界。

第二章:Go语言内核模块开发核心机制

2.1 Go运行时与内核空间交互的ABI契约设计

Go运行时通过精确定义的ABI(Application Binary Interface)与Linux内核协同工作,确保goroutine调度、系统调用及内存管理的可移植性与高效性。

核心契约要素

  • 系统调用入口统一经由syscall.Syscall/syscall.RawSyscall封装
  • 用户态栈与内核栈严格隔离,避免寄存器污染
  • g(goroutine结构体)与m(OS线程)在进入syscall前完成状态快照

关键寄存器约定(x86-64)

寄存器 用途 是否保存
RAX 系统调用号
RDI 第一参数(如fd)
RSI 第二参数(如buf)
RDX 第三参数(如count)
RSP 切换至内核栈后自动更新
// runtime/sys_linux_amd64.s 中的典型syscall stub
TEXT ·syscall(SB),NOSPLIT,$0
    MOVQ    trapnr+0(FP), AX  // 系统调用号 → RAX
    MOVQ    a1+8(FP), DI       // 参数1 → RDI
    MOVQ    a2+16(FP), SI      // 参数2 → RSI
    MOVQ    a3+24(FP), DX      // 参数3 → RDX
    SYSCALL                // 触发int 0x80或syscall指令
    RET

该汇编片段严格遵循Linux x86-64 ABI:SYSCALL指令前,RAX存调用号,RD(R)I/SI/DX按序承载前三参数;返回后RAX含结果或负错误码,RCX/R11被内核覆写(故Go运行时不依赖其值)。

graph TD
    A[goroutine阻塞] --> B[runtime.entersyscall]
    B --> C[保存g/m状态到栈]
    C --> D[切换至内核态执行syscall]
    D --> E[内核返回]
    E --> F[runtime.exitsyscall]
    F --> G[恢复用户栈与调度器上下文]

2.2 CGO桥接层安全建模与内存边界验证实践

CGO桥接层是Go与C代码交互的关键枢纽,其内存安全边界极易因指针误用、生命周期错配或越界访问而失效。

安全建模核心原则

  • 遵循“所有权显式移交”:C内存由C侧分配/释放,Go侧仅持有限生存期引用
  • 强制 //go:cgo_import_static + //go:cgo_export_static 符号约束
  • 禁用 unsafe.Pointer 直接转 *C.char,改用 C.CString / C.free 配对

内存边界验证实践

使用 runtime.SetFinalizer 捕获悬空指针,并结合 C.malloc 分配的缓冲区长度校验:

func safeCBuffer(data []byte) *C.uchar {
    buf := C.CBytes(data)
    // 绑定长度元数据,供后续边界检查
    runtime.SetFinalizer(buf, func(p unsafe.Pointer) {
        C.free(p)
    })
    return (*C.uchar)(buf)
}

逻辑分析:C.CBytes 复制数据至C堆,返回 unsafe.PointerSetFinalizer 确保GC前释放,避免内存泄漏。参数 data 必须为不可变切片,防止Go侧修改触发C侧越界读。

验证项 工具链支持 启用方式
堆栈越界检测 -gcflags="-d=checkptr" 编译时启用,捕获非法指针转换
C内存泄漏追踪 valgrind --tool=memcheck 运行时注入,需静态链接C运行时
graph TD
    A[Go调用C函数] --> B{传入指针是否经C.CBytes/C.malloc分配?}
    B -->|否| C[拒绝执行,panic]
    B -->|是| D[绑定长度元数据+Finalizer]
    D --> E[调用C逻辑]
    E --> F[返回前校验指针偏移 ≤ 分配长度]

2.3 基于eBPF+Go的轻量级虚拟化模块编译流水线

为实现低开销容器沙箱隔离,本模块采用 eBPF 程序注入内核路径 + Go 控制面协同的编译范式。

编译流程概览

graph TD
    A[Go 构建脚本] --> B[eBPF C 源码预处理]
    B --> C[clang -O2 -target bpf]
    C --> D[bpftool gen skeleton]
    D --> E[Go 代码嵌入 eBPF 对象]

关键构建步骤

  • 使用 libbpf-go 封装加载逻辑,避免 cgo 依赖
  • eBPF 程序限定在 BPF_PROG_TYPE_CGROUP_DEVICE 类型,仅拦截设备访问
  • Go 侧通过 //go:embed assets/bpf.o 静态绑定字节码

示例:eBPF 设备过滤逻辑

// filter_dev_access.c
SEC("cgroup/device")  
int filter_dev(struct bpf_cgroup_dev_ctx *ctx) {
    if (ctx->access_type & BPF_DEVCG_ACC_WRITE && 
        ctx->major == 1 && ctx->minor == 3) // /dev/null 写入放行
        return 0; // 允许
    return -EPERM;
}

该程序在 cgroup v2 设备控制器中运行;access_type 解析自 bpf_cgroup_dev_access 宏定义,major=1/minus=3 对应 /dev/null;返回 -EPERM 触发内核拒绝路径。

2.4 内核模块热加载与符号表动态注册实战

内核模块热加载依赖 insmod/modprobe 机制,而跨模块符号调用需通过 EXPORT_SYMBOL_GPL() 显式导出并由内核符号表动态解析。

符号导出与注册示例

// my_module.c
#include <linux/module.h>
int helper_func(int x) { return x * 2; }
EXPORT_SYMBOL_GPL(helper_func); // 仅GPL模块可见
MODULE_LICENSE("GPL");

该函数被写入 .kallsyms 符号表,供其他模块 extern int helper_func(int) 调用;EXPORT_SYMBOL(无GPL后缀)则对所有模块开放,但存在安全限制。

动态注册关键流程

graph TD
    A[insmod my_module.ko] --> B[调用module_init]
    B --> C[执行__this_module->init]
    C --> D[解析ELF .export_sym节]
    D --> E[插入kernel_symbol链表]
    E --> F[更新kallsyms_lookup_table]

常见错误排查对照表

现象 根本原因 解决方式
“Unknown symbol” 未导出或未加GPL声明 检查EXPORT宏+LICENSE一致性
模块卸载失败 符号被其他模块引用 使用 rmmod -f 或先卸载依赖
  • 必须确保 modprobe 加载顺序满足符号依赖;
  • cat /proc/kallsyms | grep helper_func 可验证符号是否已注册。

2.5 虚拟化上下文隔离:goroutine调度器与内核线程协同机制

Go 运行时通过 GMP 模型实现轻量级并发抽象:goroutine(G)在逻辑处理器(P)上被调度,由操作系统线程(M)执行。P 作为调度上下文枢纽,既持有可运行 G 队列,又绑定 M 实现用户态与内核态协同。

调度关键状态流转

  • 当 G 发起阻塞系统调用(如 read()),M 脱离 P 并进入内核等待,P 被其他空闲 M 接管;
  • 若无空闲 M,运行时唤醒或创建新 M;
  • G 完成后通过 goready() 重新入 P 的本地队列或全局队列。

核心协同逻辑示例

// runtime/proc.go 简化片段
func goready(gp *g, traceskip int) {
    status := readgstatus(gp)
    if status&^_Gscan != _Gwaiting {
        throw("goready: bad status")
    }
    casgstatus(gp, _Gwaiting, _Grunnable) // 原子切换状态
    runqput(_g_.m.p.ptr(), gp, true)       // 入P本地队列(true=尾插)
}

runqput 将 G 插入 P 的本地运行队列,true 表示尾部插入以保障 FIFO 公平性;casgstatus 使用原子操作避免竞态,确保状态跃迁严格符合调度协议。

协同维度 goroutine 层 内核线程层
上下文保存 G 的栈指针、PC、寄存器 M 的内核栈、寄存器
阻塞处理 自动解绑 P,触发 M 切换 系统调用陷入内核休眠
唤醒恢复 goready 触发重调度 futex/wake_up 返回用户态
graph TD
    A[G 执行 syscall] --> B{是否阻塞?}
    B -->|是| C[M 脱离 P 进入内核]
    C --> D[P 被其他 M 接管]
    D --> E[G 完成后通过 netpoller 唤醒]
    E --> F[goready → 入 P 队列]
    F --> G[调度器择机执行]

第三章:符号注入式调试器深度解析

3.1 符号表劫持原理与ELF重定位节动态修补技术

符号表劫持本质是篡改 .dynsym.rela.dyn/.rela.plt 的关联关系,使动态链接器在运行时将函数调用重定向至攻击者注入的代码地址。

动态重定位关键节区

  • .plt:过程链接表,含跳转桩(stub)
  • .got.plt:全局偏移表,存储真实函数地址(运行时填充)
  • .rela.plt:重定位项集合,含 r_offset(GOT条目地址)、r_info(符号索引+类型)、r_addend

核心修补步骤

  1. 定位目标符号在 .dynsym 中的索引
  2. .rela.plt 中找到对应重定位项
  3. 修改其 r_offset 指向可控 GOT 条目
  4. 向该 GOT 地址写入新函数地址
// 修改 .rela.plt 第 i 项的 r_offset(需先 mmap(PROT_WRITE) 节区)
Elf64_Rela *rela = (Elf64_Rela*)rela_plt_addr + i;
rela->r_offset = got_entry_addr; // 指向待劫持的GOT槽位

r_offset 是虚拟地址,指向 .got.plt 中某 8 字节槽;修改后,ld-linux.so 在解析该重定位时会将新地址写入此处,从而控制后续调用流向。

重定位字段 含义 典型值(劫持 malloc)
r_offset GOT 中待填充地址 0x404020(malloc@GOT)
r_info SYMBOL<<32 \| TYPE 0x123<<32 \| R_X86_64_JUMP_SLOT
r_addend 附加偏移(通常为0)
graph TD
    A[程序调用 malloc] --> B[PLT stub 跳转]
    B --> C[GOT.plt[0] 加载地址]
    C --> D[原 malloc 地址]
    D --> E[执行 libc malloc]
    style D stroke:#f00,stroke-width:2px

3.2 实时寄存器快照捕获与Go栈帧语义还原调试实践

Go运行时在goroutine调度切换瞬间可触发寄存器快照采集,结合runtime.g结构体偏移与SP/PC/RBP寄存器值,实现栈帧边界精准识别。

栈帧解析关键字段映射

寄存器 Go栈语义含义 典型用途
RSP 当前栈顶地址 定位局部变量起始位置
RBP 帧指针(可选) 辅助识别调用链层级
RIP/PC 下一条指令地址 关联源码行号与函数名

快照采集核心逻辑

// 在mcall或goready路径中注入的快照钩子
func captureRegisters(g *g) {
    var regs sys.RegSet
    sys.GetRegisters(&regs) // 获取当前CPU寄存器状态
    g._panic.regSnapshot = &regs // 绑定至goroutine私有上下文
}

sys.GetRegisters通过arch_prctl(ARCH_GET_FS)等系统调用获取硬件寄存器快照;g._panic.regSnapshot为扩展字段,需在runtime中预分配内存避免GC干扰。

graph TD A[调度器触发goroutine切换] –> B[执行mcall保存上下文] B –> C[调用captureRegisters] C –> D[解析RSP+frameSize推导栈帧] D –> E[通过functab匹配函数元信息]

3.3 内核态panic上下文回溯与源码级断点注入演示

当内核触发 panic()dump_stack() 自动捕获寄存器状态与调用栈。关键在于获取精确的源码上下文。

回溯核心路径

  • panic()dump_stack()show_stack()unwind_frame()(ARM64)或 __unwind_start()(x86_64)
  • 栈帧解析依赖 .eh_frameORC(x86_64)/ FP-based unwinding(ARM64)

源码级断点注入(使用kprobe)

static struct kprobe kp = {
    .symbol_name = "panic",
};
// 注入前需确保 CONFIG_KPROBES=y,且内核未启用KASLR绕过保护

逻辑分析:symbol_name 定位符号地址;kp.pre_handler 可插入自定义栈遍历逻辑;kprobedo_syscall_64 等路径中不可用,因 panic 发生在中断上下文。

字段 说明 约束
symbol_name 符号名,非地址(避免KASLR偏移问题) 必须存在于vmlinux符号表
offset 相对偏移(如需hook函数内部某行) 需配合debuginfo定位
graph TD
    A[panic触发] --> B[disable_irq_nosync]
    B --> C[dump_stack]
    C --> D[unwind_frame]
    D --> E[print_symbol + line info]

第四章:虚拟设备Fuzz框架架构与工程落地

4.1 基于Go反射的设备模型模糊语法生成器设计

设备模型语法需适配多厂商异构协议,传统硬编码方式维护成本高。本方案利用 Go reflect 包动态解析结构体标签,自动生成模糊匹配语法树。

核心设计思想

  • 以结构体字段为语法单元,json/mqtt/modbus 标签驱动语义映射
  • 反射遍历字段类型与嵌套深度,构建带权重的模糊路径候选集

关键代码片段

func GenerateGrammar(v interface{}) map[string][]string {
    rv := reflect.ValueOf(v).Elem()
    grammar := make(map[string][]string)
    traverseField(rv, "", grammar)
    return grammar
}

func traverseField(v reflect.Value, path string, g map[string][]string) {
    if v.Kind() == reflect.Struct {
        for i := 0; i < v.NumField(); i++ {
            field := v.Type().Field(i)
            tag := field.Tag.Get("json") // 如 "temp,omitempty"
            if tag == "-" || tag == "" { continue }
            key := strings.Split(tag, ",")[0]
            newPath := joinPath(path, key)
            g[newPath] = append(g[newPath], fuzzyVariants(key)...)
            traverseField(v.Field(i), newPath, g)
        }
    }
}

逻辑分析GenerateGrammar 接收指针并解引用,确保可读取字段标签;traverseField 递归处理嵌套结构,joinPath 构建层级路径(如 "sensor.temp"),fuzzyVariants 返回 "temperature""t" 等模糊等价词。参数 v 必须为 *struct 类型,否则 Elem() panic。

模糊变体映射示例

原始字段 模糊候选列表
temp ["temperature", "t", "TMP"]
humid ["humidity", "h", "HUM"]
graph TD
    A[输入设备结构体] --> B[反射提取字段与tag]
    B --> C{是否为结构体?}
    C -->|是| D[递归遍历子字段]
    C -->|否| E[生成模糊路径+变体]
    D --> E
    E --> F[输出语法候选集]

4.2 虚拟PCI设备IOCTL接口覆盖率导向变异策略

为提升虚拟PCI设备驱动 fuzzing 的有效性,变异策略需精准覆盖 ioctl 接口的命令空间与参数约束。

核心变异维度

  • 命令码(cmd):枚举 PCI_IOCTLS 宏定义范围,结合 _IOC_TYPE/DIR/SIZE 位域解析生成合法变体
  • 用户态缓冲区:按 ioctl 声明的 arg 类型(如 struct pci_dev_info*)进行结构化字段级翻转
  • 内存映射边界:在 mmap() 后对 BAR 区域执行跨页/对齐错位访问

典型 IOCTL 变异示例

// 针对 PCI_GET_DEVICE_INFO (0xc010a001) 的结构体字段变异
struct pci_dev_info info = {
    .domain = 0xdead,      // 变异:非法域号触发边界检查
    .bus    = 0xff,        // 变异:超限总线号
    .slot   = 0x1f,        // 合法但临界值,检验槽位校验逻辑
    .func   = 0x7,         // 功能号上限,触发掩码处理路径
};
ioctl(fd, PCI_GET_DEVICE_INFO, &info);

该调用迫使驱动解析异常域/总线组合,暴露 pci_get_slot() 中未覆盖的空指针解引用路径;slotfunc 的临界值则激活 PCI_SLOT_MASK 位运算分支,提升指令与分支覆盖率。

变异优先级调度表

优先级 触发条件 示例命令码
cmd 属于已注册 ioctl handler PCI_MAP_BAR
arg 指向非零用户地址 PCI_READ_CONFIG
cmd 无对应 handler 0xdeadbeef
graph TD
    A[原始ioctl调用] --> B{cmd是否注册?}
    B -->|是| C[结构体字段变异]
    B -->|否| D[cmd位域重组]
    C --> E[size匹配校验]
    D --> E
    E --> F[提交至qemu-kvm ioctl handler]

4.3 内存映射区域(MMIO)异常触发与崩溃归因分析

MMIO 异常通常源于 CPU 对设备寄存器地址的非法访问,如写入只读寄存器、未对齐访问或访问已断电设备。

常见触发场景

  • 设备驱动未校验寄存器就绪状态
  • 中断上下文与原子操作冲突
  • PCIe 链路降速后未更新 BAR 地址映射

典型崩溃链路

// 驱动中危险访问(无屏障/无状态检查)
writel(0x1, dev->mmio_base + CTRL_REG); // 若 dev->mmio_base 为 NULL 或未 map,触发 #PF

writel() 触发写内存屏障并生成 mov 指令;若页表项缺失或 PTE 的 _PAGE_PRESENT=0,CPU 抛出 page-fault 异常,内核陷入 do_page_faultfixup_exception → 最终 oops

异常类型 触发条件 典型调用栈片段
#GP(0) 访问未启用的 MMIO 空间 __raw_writelnative_write_cr3
#PF 页表无效或权限不足 do_page_faultvmalloc_fault
graph TD
A[CPU 执行 writel] --> B{MMIO 地址有效?}
B -- 否 --> C[#PF 异常]
B -- 是 --> D{PTE 可写?}
D -- 否 --> E[#GP 异常]
D -- 是 --> F[完成写入]

4.4 多租户沙箱隔离下的Fuzz任务编排与结果聚合

在多租户环境中,Fuzz任务需严格隔离执行上下文,同时支持跨租户结果聚合分析。

任务调度策略

采用租户标签(tenant_id)绑定沙箱实例,并通过 Kubernetes Namespace + SELinux 策略实现资源硬隔离:

# fuzz-job.yaml 片段:声明式租户沙箱绑定
spec:
  securityContext:
    seLinuxOptions:
      level: "s0:c1,c2"  # 每租户独占 MCS 标签
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        matchExpressions:
        - key: tenant.sandbox
          operator: In
          values: ["acme-inc"]  # 租户专属节点组

该配置确保内核级隔离,level 字段为 SELinux 多级安全标识,tenant.sandbox 节点标签防止跨租户 Pod 调度。

结果聚合流水线

阶段 组件 输出格式
采集 eBPF trace probe JSON(含 crash stack)
归一化 Tenant-aware parser tenant_id + fuzz_id + crash_hash
聚合 TimescaleDB hypertable 按租户+时间窗口聚合崩溃频次
graph TD
  A[租户Fuzz Job] --> B[eBPF实时捕获]
  B --> C{Tenant ID路由}
  C -->|acme-inc| D[acme-crash-store]
  C -->|demo-org| E[demo-crash-store]
  D & E --> F[Cross-tenant anomaly detector]

核心保障:所有元数据注入 tenant_id 字段,杜绝越权访问与统计污染。

第五章:开源协议与企业级交付规范

开源协议的法律效力边界

企业在使用 Apache 2.0 协议的 Spring Boot 项目时,必须保留 NOTICE 文件并明确声明修改内容;而若集成 MIT 许可的 Lodash,则仅需在分发物中包含原始版权声明——某金融客户曾因漏传 MIT 声明文件,在第三方合规审计中被判定为高风险项,导致交付延期 17 个工作日。GPLv3 的“传染性”更需警惕:当某车联网厂商将 GPLv3 授权的 CAN 协议栈静态链接进车载 OS 固件后,监管机构要求其公开全部内核模块源码,最终被迫重构架构改用 LGPLv2.1 替代。

企业级交付物清单标准

交付项 强制要求 校验方式 示例
源码归档包 ZIP/TAR.GZ 格式,含完整 .git 目录或 commit hash 清单 sha256sum -c SHA256SUMS app-v2.4.1-src-20240521.tar.gz
二进制制品 符合 OCI v1.0 规范的容器镜像,含 SBOM(SPDX JSON) cosign verify --certificate-oidc-issuer https://auth.enterprise.com registry.enterprise.com/app/web:v2.4.1@sha256:...
许可证矩阵表 Excel/CSV 格式,列明每个依赖组件名称、版本、协议类型、兼容性状态 自动化扫描工具输出比对 license-compliance-report-20240521.csv

合规自动化流水线实践

某央企信创项目构建了三级许可证门禁:

  1. 开发阶段:Git Hook 调用 license-checker --failOn 拦截含 AGPL 依赖的 PR;
  2. CI 阶段:Jenkins 执行 syft -o spdx-json app.jar > sbom.json 生成软件物料清单,并通过 grype sbom.json 扫描已知漏洞;
  3. 发布阶段:Nexus Repository Manager 自动校验上传构件是否携带 LICENSENOTICE 元数据字段,缺失则拒绝入库。
# 实际运行中的合规检查脚本片段
if ! grep -q "Apache-2.0" target/META-INF/LICENSE; then
  echo "ERROR: Missing Apache-2.0 license header in JAR"
  exit 1
fi

供应链安全事件响应机制

2023 年 Log4j2 高危漏洞爆发期间,某省级政务云平台依据预设的《开源组件应急响应 SOP》启动四级响应:

  • 一级(小时级):通过 mvn dependency:tree -Dincludes=org.apache.logging.log4j 快速定位所有受影响模块;
  • 二级(4 小时内):利用 Nexus 的 staging repository 机制批量替换 log4j-core-2.17.1;
  • 三级(24 小时内):调用 jdeps --list-deps --recursive target/classes 验证无隐式依赖残留;
  • 四级(72 小时内):向等保测评机构提交《组件替换验证报告》及签名版 SBOM。

商业许可例外条款管理

某 SaaS 厂商采购了 MongoDB Enterprise Advanced,在其多租户平台中部署时,严格遵循其商业许可附加条款:禁止将 MongoDB 作为数据库即服务(DBaaS)对外提供,但允许在自有应用中嵌入其 WiredTiger 存储引擎。法务团队为此建立了许可例外台账系统,对每个商业组件标注“允许场景”、“禁止行为”、“审计证据要求”三栏,并与 Jenkins 构建参数联动——当构建参数 ENV=PRODUCTIONDB_TYPE=mongodb-enterprise 时,自动触发许可证合规性检查。

一线开发者,热爱写实用、接地气的技术笔记。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注