Posted in

Go panic recover无法捕获的4类致命错误:signal、stack overflow、heap corruption、plugin load failure

第一章:Go panic recover无法捕获的4类致命错误:signal、stack overflow、heap corruption、plugin load failure

Go 的 recover() 仅能拦截由 panic() 主动触发的、在同一线程(goroutine)内且尚未退出 defer 链的运行时异常。以下四类错误发生在 Go 运行时底层或操作系统层面,绕过 panic/recover 机制,导致进程立即终止(通常伴随 exit code 或 signal)。

Signal 错误

SIGSEGV(非法内存访问)、SIGABRT(abort 调用)等信号由操作系统直接发送给进程。Go 运行时虽注册了部分信号处理器,但对 SIGKILLSIGQUIT 及多数同步信号(如栈溢出触发的 SIGBUS不提供 recover 接口。例如:

// 触发 SIGSEGV:空指针解引用(非 panic)
var p *int
_ = *p // 程序立即崩溃,recover 无效

Stack Overflow

Go 的栈是动态增长的,但当 goroutine 栈空间耗尽且无法扩展时(如无限递归+禁用栈扩容),运行时会向当前线程发送 SIGSTKFLTSIGBUS。该信号不可被捕获或恢复,进程强制终止。

Heap Corruption

内存堆损坏(如 unsafe 操作越界写入 malloc 元数据、重复释放、use-after-free)破坏 runtime 内存管理器状态。Go 的 mallocgc 在检测到元数据不一致时会直接调用 throw("invalid memory address or nil pointer dereference") —— 此为 fatal error,非 panic,无法 recover。

Plugin Load Failure

使用 plugin.Open() 加载动态库失败时,若底层 dlopen 返回 NULL(如符号缺失、ABI 不匹配、权限不足),Go 会调用 runtime.throw("plugin.Open: failed to load")。该错误属于 runtime fatal 抛出,不在 defer/panic/recover 控制流中。

错误类型 是否可 recover 典型触发方式 进程行为
Signal (e.g. SIGSEGV) 空指针解引用、非法地址访问 立即终止,无 defer 执行
Stack Overflow 深度递归 + 禁用栈扩展 SIGBUS/SIGSTKFLT
Heap Corruption unsafe 越界写、C.free 多次调用 throw,exit(2)
Plugin Load Failure plugin.Open("bad.so") 且 dlopen 失败 throw,无 panic 上下文

防范策略:避免裸 unsafe 操作;启用 -gcflags="-d=checkptr" 检测指针越界;限制递归深度;对 plugin 使用前做 fileldd 预检。

第二章:Signal 类错误——操作系统级中断的不可拦截性

2.1 Unix信号机制与Go运行时信号处理模型

Unix信号是内核向进程异步传递事件的轻量机制,如 SIGINT(中断)、SIGTERM(终止)、SIGUSR1(用户自定义)等。Go 运行时并未完全交由用户处理信号,而是采用抢占式信号拦截+协程安全转发模型。

信号注册与屏蔽

Go 启动时调用 runtime.siginit() 初始化信号掩码,将多数信号(除 SIGKILL/SIGSTOP 外)设为 SA_RESTART | SA_ONSTACK,并绑定到专用信号线程(sigtramp)。

Go 运行时信号路由表

信号 默认动作 Go 运行时行为
SIGQUIT 终止+coredump 转发至 runtime.sighandler,打印 goroutine stack trace
SIGCHLD 忽略 runtime.sigignore() 显式忽略
SIGURG 忽略 保留给 netpoll 使用
// 在 init() 中注册自定义信号处理器(需在 runtime 启动后)
func init() {
    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGUSR2) // 非阻塞注册
    go func() {
        for range sigc {
            debug.PrintStack() // 安全触发栈快照(不阻塞 GC)
        }
    }()
}

该代码注册 SIGUSR2 到通道 sigc,由独立 goroutine 消费;signal.Notify 内部调用 runtime.signal_enable(),将信号从运行时接管队列中移出,并确保仅投递到指定 channel,避免竞态。

信号分发流程

graph TD
    A[内核发送信号] --> B{信号是否被 runtime 接管?}
    B -->|是| C[专用信号线程 sigtramp]
    B -->|否| D[用户注册的 channel 或默认动作]
    C --> E[runtime.sighandler 分发]
    E --> F[goroutine-safe handler 或 defer 到 main M]

2.2 SIGSEGV/SIGBUS在CGO调用中的真实崩溃复现

CGO调用中内存越界或非法地址访问极易触发 SIGSEGV(无效内存引用)或 SIGBUS(对齐错误/硬件异常),尤其在跨语言边界传递裸指针时。

典型崩溃场景

  • Go 代码传入已释放的 C 内存指针
  • C 函数返回栈上局部数组地址,Go 侧后续访问
  • 结构体字段对齐不一致导致 SIGBUS(如 ARM64 上未对齐的 int64 读取)

复现代码示例

// crash.c
#include <stdlib.h>
char* get_dangling_ptr() {
    char buf[32] = "hello";
    return buf; // 返回栈地址 → 后续 Go 访问即 SIGSEGV
}
// main.go
/*
#cgo LDFLAGS: -L. -lcrash
#include "crash.h"
*/
import "C"
import "unsafe"

func triggerCrash() {
    p := C.get_dangling_ptr()
    _ = *(*byte)(unsafe.Pointer(p)) // 解引用悬垂指针 → 确定性 SIGSEGV
}

逻辑分析get_dangling_ptr() 返回栈帧内局部数组地址,函数返回后该内存被回收。Go 侧通过 unsafe.Pointer 强制解引用,触发内核发送 SIGSEGV 终止进程。参数 p 是已失效的虚拟地址,无有效映射页表项。

异常类型 触发条件 常见平台
SIGSEGV 访问未映射/无权限内存 所有架构
SIGBUS 非对齐访问或硬件不可达地址 ARM64, RISC-V
graph TD
    A[Go 调用 C 函数] --> B{C 返回指针}
    B --> C[栈地址/已释放堆地址]
    B --> D[合法持久内存]
    C --> E[Go 解引用 → SIGSEGV/SIGBUS]
    D --> F[安全访问]

2.3 使用runtime/debug.SetTraceback验证信号逃逸路径

Go 运行时在发生 panic 或 fatal error 时,默认仅显示当前 goroutine 的栈帧。当信号(如 SIGQUIT)由系统触发,需定位其逃逸至 runtime 的完整调用链时,runtime/debug.SetTraceback 是关键调试开关。

调试级别控制

SetTraceback 接收整数参数,影响栈展开深度与 goroutine 可见性:

  • :仅当前 goroutine(默认)
  • 1:所有用户 goroutine
  • 2:含系统 goroutine(如 runtime.sigtramp, runtime.mstart
import "runtime/debug"

func init() {
    debug.SetTraceback(2) // 启用全栈追踪,暴露信号处理逃逸路径
}

此设置使 SIGQUIT(Ctrl+\)触发时输出包含 runtime.sigpanic → runtime.gopanic → ... → signal.Notify 的完整逃逸链,揭示信号如何从内核经 sigtramp 侵入用户逻辑。

信号逃逸典型路径

graph TD
    A[Kernel: SIGQUIT] --> B[runtime.sigtramp]
    B --> C[runtime.sigpanic]
    C --> D[runtime.gopanic]
    D --> E[debug.PrintStack]
    E --> F[用户注册的 signal.Notify handler]
级别 显示 goroutine 是否含 runtime.m0
0 当前
1 所有用户
2 全部(含系统)

2.4 通过sigaction和自定义信号处理器实现有限兜底

当默认信号行为(如SIGSEGV终止进程)无法满足健壮性需求时,sigaction() 提供了比老旧 signal() 更可靠的信号处理机制。

为什么选择 sigaction

  • ✅ 原子性:注册与屏蔽信号一步完成,避免竞态
  • ✅ 可重入:支持 SA_RESTART 自动重启被中断的系统调用
  • ✅ 精细控制:可指定信号掩码、标志位及备用栈

关键代码示例

struct sigaction sa;
sa.sa_handler = segv_handler;        // 自定义处理函数
sa.sa_flags = SA_SIGINFO | SA_ONSTACK; // 支持额外信息 & 替换栈
sigemptyset(&sa.sa_mask);            // 初始不阻塞其他信号
sigaction(SIGSEGV, &sa, NULL);       // 原子注册

逻辑分析SA_ONSTACK 确保在主线程栈已损坏(如栈溢出)时,仍能安全执行 handler;sigemptyset() 显式清空掩码,避免意外阻塞关键信号(如 SIGALRM)。

兜底能力边界(有限性体现)

能力 是否支持 说明
捕获 SIGKILL 内核强制终止,不可捕获
恢复崩溃现场 ⚠️ 仅能记录上下文,不可“续跑”
多线程信号安全 需结合 pthread_sigmask
graph TD
    A[收到 SIGSEGV] --> B{内核检查 sa_flags}
    B -->|SA_ONSTACK| C[切换至备用栈]
    B -->|SA_SIGINFO| D[传递 siginfo_t 结构]
    C --> E[执行 segv_handler]
    D --> E
    E --> F[日志/核心转储/优雅退出]

2.5 生产环境信号监控与gops/pprof联动诊断实践

在高负载微服务中,仅依赖日志难以定位瞬态性能瓶颈。需打通信号触发、运行时探针与火焰图分析链路。

信号驱动的pprof自动采集

通过 SIGUSR1 触发 CPU profile 自动导出:

# 向进程发送信号,由 gops 注册的 handler 捕获并调用 pprof
kill -USR1 $(pgrep myserver)

此操作要求服务已集成 gopsimport "github.com/google/gops/agent")且启动时启用 agent:agent.Listen(agent.Options{Addr: "127.0.0.1:6060"})SIGUSR1 被 gops 拦截后,自动调用 runtime/pprof.StartCPUProfile() 并写入 /tmp/cpu-$(date +%s).pprof

gops + pprof 协同诊断流程

graph TD
    A[生产实例] -->|SIGUSR1| B(gops agent)
    B --> C[启动 30s CPU profile]
    C --> D[写入临时 pprof 文件]
    D --> E[自动上传至 S3/MinIO]
    E --> F[本地下载并生成火焰图]

典型诊断参数对照表

参数 默认值 说明
GOPS_PORT 6060 gops HTTP 管理端口
PPROF_DURATION 30s CPU profile 采样时长
GOPS_STACK_DEPTH 50 goroutine stack 截断深度
  • 所有采集动作均在用户态完成,无 kernel probe 开销
  • 支持按 QPS 阈值动态启用(如 if qps > 5000 { send SIGUSR1 }

第三章:Stack Overflow——Goroutine栈空间耗尽的静默崩塌

3.1 Go 1.22+栈动态扩容机制与溢出临界点分析

Go 1.22 起,runtime 对 goroutine 栈的初始大小与扩容策略进行了精细化调整:初始栈从 2KB 提升至 4KB,并引入“渐进式倍增+上限约束”扩容模型。

扩容触发条件

  • 当前栈使用量 ≥ 当前栈容量 × 90%
  • 连续两次扩容后启用硬上限(默认 1GB)

关键参数对照表

参数 Go 1.21 Go 1.22+ 影响
初始栈大小 2KB 4KB 减少小函数调用的首次扩容概率
扩容倍数 2×(≤64KB),之后 1.5× 降低大栈内存碎片
硬上限 1GB(静态) 1GB(动态可调 via GODEBUG=stacklimit=... 更细粒度控制OOM风险
// 模拟深度递归逼近溢出临界点(需在 GODEBUG=schedtrace=1000 下观察)
func deepCall(n int) {
    if n <= 0 {
        return
    }
    // 每层消耗约 128B 栈帧(含返回地址、局部变量等)
    var buf [128]byte
    deepCall(n - 1)
}

该函数在 n ≈ 32768 时触达默认 4KB→8KB 的首次扩容;实际溢出临界点受内联优化、逃逸分析影响,需结合 go tool compile -S 验证栈帧尺寸。

graph TD
    A[函数调用] --> B{栈剩余空间 < 10%?}
    B -->|是| C[触发扩容:4KB→8KB→12KB→...]
    B -->|否| D[正常执行]
    C --> E{新栈 ≤ 1GB?}
    E -->|是| F[分配并复制栈帧]
    E -->|否| G[panic: runtime: out of memory]

3.2 递归深度探测工具开发:基于runtime.Stack与debug.ReadBuildInfo

递归过深常引发栈溢出,需在运行时动态捕获调用深度。核心依赖两个标准库能力:runtime.Stack 获取当前 goroutine 栈帧快照,debug.ReadBuildInfo 提取编译期元信息用于上下文标记。

栈帧采样与深度提取

func getCallDepth() int {
    buf := make([]byte, 4096)
    n := runtime.Stack(buf, false) // false: 当前 goroutine,不包括运行时内部帧
    lines := strings.Split(strings.TrimSpace(string(buf[:n])), "\n")
    return len(lines) - 1 // 跳过 "goroutine X [running]:" 行
}

runtime.Stack 第二参数设为 false 可避免冗余系统帧;buf 长度需足够容纳深层调用链,否则截断导致深度低估。

构建信息注入

字段 用途 示例值
Main.Version 语义化版本 v1.2.0-rc1
Settings["vcs.revision"] Git 提交哈希 a1b2c3d

诊断流程

graph TD
    A[触发深度检测] --> B{是否 > 阈值?}
    B -- 是 --> C[记录 Stack + BuildInfo]
    B -- 否 --> D[静默继续]
    C --> E[上报至监控管道]

3.3 在HTTP中间件中注入栈水位告警的实战封装

在高并发网关场景中,协程栈深度异常易引发静默崩溃。我们通过 runtime.Stack 结合 http.Handler 封装轻量级水位探测中间件。

栈深采样与阈值判定

func StackWatermark(threshold int) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            buf := make([]byte, 4096)
            n := runtime.Stack(buf, false) // false: 当前goroutine仅采样
            if n > threshold {
                metrics.StackDepthAlarm.Inc() // 上报Prometheus指标
                http.Error(w, "stack overflow risk", http.StatusServiceUnavailable)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

threshold 表示允许的最大栈转储字节数(非调用深度),4096字节约对应20~30层嵌套;runtime.Stack(_, false) 避免全局扫描开销,仅捕获当前请求goroutine。

告警分级策略

水位等级 字节阈值 响应行为 日志级别
警戒 2048 记录Warn日志 Warn
危险 4096 拒绝请求+指标上报 Error

执行流程

graph TD
A[HTTP请求] --> B{栈采样}
B -->|n ≤ threshold| C[放行至业务Handler]
B -->|n > threshold| D[触发告警+返回503]
D --> E[推送指标至Prometheus]

第四章:Heap Corruption 与 Plugin Load Failure——底层内存与模块系统的双重脆弱性

4.1 CGO内存越界写入引发heap corruption的gdb+dlv双调试链路

当 Go 程序通过 CGO 调用 C 函数时,若 C 侧对 C.CString 分配的内存执行越界写入(如 strcpy(buf, oversized_str)),将直接破坏 Go 运行时管理的堆元数据。

复现关键代码片段

// cgo_test.c
#include <string.h>
void corrupt_heap(char* buf) {
    strcpy(buf + 10, "overflow!"); // 越界写入10字节后区域
}

逻辑分析:bufC.CString() 分配,其实际内存布局为 [header][data][padding]buf + 10 可能跨入 header 或相邻块元信息区,触发后续 malloc/free 异常。

双调试协同定位流程

graph TD
    A[gdb attach C stack] --> B[断点在corrupt_heap]
    B --> C[记录buf地址]
    C --> D[dlv attach Go runtime]
    D --> E[观察mspan/mcentral异常]
工具 作用域 关键命令
gdb C 堆布局与写入点 x/20gx $buf-16
dlv Go 堆状态验证 heap list -inuse
  • corrupt_heap 返回后,Go 侧调用 C.free 时触发 abort()
  • dlvruntime·mallocgcmheap_.spans 指针常显示非法地址。

4.2 plugin.Open失败的四层错误溯源:符号解析、TLS冲突、版本哈希、ABI不兼容

plugin.Open 失败常非单一原因,需按加载链路逐层排查:

符号解析失败

动态链接器无法定位导出符号时抛 symbol not found

// 示例:插件中缺失导出函数
func PluginInit() error { return nil } // ❌ 未用 //export 标记
// ✅ 正确写法(需 cgo 环境):
/*
#include <stdlib.h>
extern int PluginInit();
*/
import "C"

plugin.Open 依赖 ELF 的 .dynsym 表和 DT_NEEDED 条目;若插件未正确导出 C 符号或 Go 符号未通过 //export 暴露,将在此层失败。

TLS 冲突

多个插件或主程序共享同一 TLS 变量地址引发覆盖: 冲突类型 触发条件 检测方式
全局 TLS 变量重名 插件与主程序定义同名 //go:threadlocal 变量 readelf -S plugin.so \| grep tls
运行时 TLS 初始化竞争 多插件并发调用 Open() GODEBUG=plugin1=1 启用调试日志

版本哈希与 ABI 不兼容

graph TD
    A[plugin.Open] --> B{检查 _go_plugin_version_hash}
    B -->|不匹配| C[panic: plugin was built with a different version of Go]
    B -->|匹配| D[验证 runtime.typehash/abiVersion]
    D -->|ABI mismatch| E[segfault 或 silent corruption]

核心约束:Go 插件要求主程序与插件完全相同的 Go 版本、构建标签及 GOOS/GOARCH。微小差异(如 go1.22.3 vs go1.22.4)即触发版本哈希校验失败。

4.3 使用go tool compile -S定位unsafe.Pointer误用导致的堆破坏模式

go tool compile -S 能暴露底层汇编中 unsafe.Pointer 转换引发的非法内存访问痕迹,是诊断堆破坏的关键入口。

汇编线索识别特征

unsafe.Pointer 被错误地用于越界偏移或未对齐访问时,编译器生成的汇编常含以下信号:

  • MOVL/MOVQ 指令目标地址无符号截断(如 +1234567890
  • LEAQ 计算地址后立即被当作有效指针解引用
  • 缺少 testcmp 检查边界(对比安全的 slice 访问汇编)

典型误用代码与反汇编对照

// bad.go
func corrupt() {
    s := make([]byte, 4)
    p := (*[1024]byte)(unsafe.Pointer(&s[0])) // ❌ 超出底层数组容量
    p[100] = 42 // 堆破坏起点
}

此代码经 go tool compile -S bad.go 输出中可见 LEAQ 100(%rax), %rbx 后紧跟 MOVB $42, (%rbx) —— %rax 指向仅4字节的 slice 数据首址,100 偏移已落入相邻分配块,触发静默堆覆盖。

现象 安全模式表现 危险模式表现
地址计算 LEAQ (rX), rY LEAQ 100(rX), rY
边界防护 CMPQ $4, %rX 存在 无任何比较指令
内存写入 MOVB %al, (rX) MOVB $42, 100(rX)
graph TD
    A[Go源码] --> B[go tool compile -S]
    B --> C{检查LEAQ/MOV指令偏移量}
    C -->|偏移 ≥ 底层对象len| D[标记高危unsafe.Pointer链]
    C -->|存在cmp/test边界检查| E[暂判为安全]

4.4 基于build constraints与plugin签名验证构建可信插件加载框架

插件加载的双重信任锚点

可信插件框架依赖两个正交机制:编译期裁剪(build constraints)确保仅启用经审核的平台适配代码;运行时签名验证保障二进制完整性。

构建约束示例

// +build linux amd64
package plugin

import "crypto/sha256"
// 此文件仅在 Linux/AMD64 构建时参与编译,隔离 Windows/macOS 风险路径

+build linux amd64 指令由 Go build 工具链解析,排除非目标平台代码,防止误用不安全 syscall 或未审计的 Cgo 绑定。

签名验证核心逻辑

func VerifyPlugin(path string, sig []byte, pubKey *ecdsa.PublicKey) error {
    h := sha256.Sum256()
    if err := hashFile(path, &h); err != nil {
        return err // 文件读取失败或哈希异常
    }
    return ecdsa.VerifyASN1(pubKey, h[:], sig) // 使用 ASN.1 编码签名比对
}

hashFile 计算插件文件完整内容摘要;ecdsa.VerifyASN1 调用标准库执行椭圆曲线签名验签,pubKey 来自白名单证书颁发机构(CA)。

验证流程概览

graph TD
    A[加载插件文件] --> B{build constraint 匹配?}
    B -->|否| C[编译期拒绝]
    B -->|是| D[计算 SHA256 摘要]
    D --> E[读取嵌入签名]
    E --> F[ECDSA 验签]
    F -->|失败| G[运行时拒绝加载]
    F -->|成功| H[动态注入插件接口]
验证阶段 检查项 失败后果
编译期 GOOS/GOARCH 约束 构建失败,无二进制产出
运行时 签名+公钥+哈希三元组 panic 并终止插件注册

第五章:从panic recover局限性到Go系统健壮性设计范式的升维思考

panic与recover的典型误用场景

在微服务网关中,某团队曾对所有HTTP handler统一包裹defer func() { if r := recover(); r != nil { log.Error("Panic recovered", "err", r) } }()。看似兜底,实则掩盖了goroutine泄漏——当http.TimeoutHandler触发超时并调用panic(http.ErrAbortHandler)时,recover捕获后未终止关联goroutine,导致连接句柄持续堆积。压测中QPS仅达2000时即出现too many open files错误。

recover无法捕获的致命边界

异常类型 是否可recover 后果示例 可观测性手段
runtime.Goexit() 协程静默退出,无日志痕迹 pprof goroutine profile + 自定义trace hook
栈溢出(stack overflow) 进程直接SIGABRT终止 ulimit -s + core dump分析
CGO调用中C层segmentation fault Go runtime崩溃 coredump + gdb + bt full

健壮性设计的三层防御模型

// 实战代码:基于context的熔断+超时+重试组合策略
func CallPaymentService(ctx context.Context, req *PaymentReq) (*PaymentResp, error) {
    // L1:上下文生命周期绑定(自动传播取消)
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // L2:熔断器嵌套(避免雪崩)
    if !circuitBreaker.Allow() {
        return nil, errors.New("circuit breaker open")
    }

    // L3:指数退避重试(网络抖动容忍)
    var lastErr error
    for i := 0; i < 3; i++ {
        resp, err := doPayment(ctx, req)
        if err == nil {
            circuitBreaker.Success()
            return resp, nil
        }
        lastErr = err
        time.Sleep(time.Duration(1<<i) * time.Second)
    }
    circuitBreaker.Failure()
    return nil, lastErr
}

真实故障复盘:支付链路中的panic逃逸

某次生产事故中,json.Unmarshal因传入nil指针触发panic,但被外层recover捕获后仅记录"json decode failed",丢失关键字段名信息。通过在recover()后强制注入runtime.Stack()并匹配json.*Unmarshal正则,将日志升级为:

[CRITICAL] panic in json.Unmarshal: 
  field="user_profile.address" 
  value_type="*string" 
  stack="goroutine 123 [running]:\nencoding/json.(*decodeState).unmarshal..."

构建可观测性增强的panic处理链

graph LR
A[panic发生] --> B{是否业务可控异常?}
B -->|是| C[recover + 结构化日志 + Sentry上报]
B -->|否| D[os.Exit 1 + 写入panic.log + 触发告警]
C --> E[metrics: panic_total{type=\"business\"} +=1]
D --> F[metrics: panic_total{type=\"fatal\"} +=1]
F --> G[自动触发coredump采集]

静态检查驱动的健壮性前置

使用golangci-lint配置自定义规则检测高危模式:

  • 禁止裸recover()(要求必须包含runtime.Caller()获取堆栈)
  • 检测defer recover()出现在for select循环内(goroutine泄漏风险)
  • 警告未设置GOMAXPROCS且CPU核数>8的容器部署(调度失衡隐患)

生产环境panic监控看板核心指标

  • panic_rate_per_minute{service="payment"}:每分钟panic次数(阈值>5触发P1告警)
  • recover_success_ratio{handler="order_create"}:recover成功率(低于95%标记为异常handler)
  • goroutines_leaked{service="gateway"}:goroutine泄漏速率(基于pprof diff计算72小时增长斜率)

熔断器状态持久化实战方案

采用Redis Hash存储熔断器状态,Key结构为circuit:payment_service:state,Field包含failure_countsuccess_countlast_failure_ts。每次状态变更通过HINCRBY原子操作更新,并配合TTL自动过期,避免因节点重启导致状态漂移。

扎根云原生,用代码构建可伸缩的云上系统。

发表回复

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