Posted in

Go环境配置“幽灵问题”终结者:基于eBPF实时捕获go build过程中的系统调用异常

第一章:Go环境配置“幽灵问题”终结者:基于eBPF实时捕获go build过程中的系统调用异常

go build 突然失败却无明确错误信息、GOROOT 被意外覆盖、或 cgo 编译时静默跳过头文件——这类“幽灵问题”往往源于构建过程中被忽略的系统调用异常:如 openat 返回 ENOENT 却被 Go 工具链吞没,statx 权限拒绝未触发警告,或 execve 加载动态链接器失败却回退至静态编译路径。

借助 eBPF,我们可在内核态零侵入地观测 go build 进程树的全量系统调用行为。以下命令启动实时捕获(需 root 权限及 libbpf 支持):

# 编译并加载 eBPF 探针(使用 libbpf-go 示例)
sudo bpftool prog load ./trace_go_syscalls.o /sys/fs/bpf/go_build_trace \
  type tracepoint \
  map name:syscalls_map pinned:/sys/fs/bpf/go_build_maps/syscalls
sudo bpftool prog attach pinned /sys/fs/bpf/go_build_trace \
  tracepoint syscalls:sys_enter_openat id 1

核心观测维度

  • 进程上下文:通过 bpf_get_current_pid_tgid() 关联 go build 主进程及其子 gcc/clang/as 进程;
  • 路径真实性:对 openat/statx 参数使用 bpf_probe_read_user_str() 提取路径字符串,识别 /usr/local/go/src/runtime/cgo.a 等关键路径是否真实存在;
  • 错误传播链:当 ret < 0 时,记录 bpf_get_current_comm() + errno + pathname 三元组,定位被 Go 工具链忽略的底层失败。

常见幽灵问题对应表

系统调用 异常返回值 典型表现 eBPF 可捕获线索
openat -ENOENT go build 报 “cannot find package” pathname="/home/user/go/src/fmt/"
execve -EACCES cgo 编译静默失败 filename="/usr/bin/gcc" + mode=0444
statx -EPERM go env -w GOROOT=... 失效 pathname="/usr/local/go" + uid!=0

验证流程

  1. 启动 eBPF 探针并重定向输出至 go_syscall.log
  2. 执行 GODEBUG=cgocheck=2 go build -x main.go 2>&1 | grep 'exec' 触发构建;
  3. 实时解析日志:tail -f go_syscall.log | grep -E "(ENOENT|EACCES|EPERM)"
  4. 定位首个非零 retopenat 调用,检查其 pathname 是否指向缺失的 Go SDK 子目录。

该方法绕过 Go 工具链日志抽象层,直击内核态事实,将环境配置类“玄学问题”转化为可审计、可复现的系统调用证据链。

第二章:Go构建环境的底层行为解析与可观测性缺口

2.1 Go build生命周期中的关键系统调用链路建模

Go 构建过程并非纯用户态操作,其 go build 命令在底层会触发一系列系统调用,形成可建模的因果链路。

核心调用序列

  • execve() 启动 go 主进程(加载 /usr/local/go/bin/go
  • openat(AT_FDCWD, "main.go", O_RDONLY) 读取源码
  • mmap() 映射 .a 静态库(如 runtime.a)至内存
  • clone() 创建子进程编译各包(CLONE_VM | CLONE_FS
  • wait4() 同步等待链接器 go link 完成

典型链接阶段系统调用流

graph TD
    A[go build main.go] --> B[execve: go tool compile]
    B --> C[openat: read import paths]
    C --> D[mmap: load stdlib archives]
    D --> E[clone: parallel pkg compilation]
    E --> F[wait4: collect object files]
    F --> G[execve: go tool link]

关键参数语义说明

系统调用 关键参数 语义作用
openat(AT_FDCWD, "go.mod", O_RDONLY) AT_FDCWD 以当前工作目录为根解析路径,保障模块感知一致性
clone(..., CLONE_VM \| CLONE_FS) CLONE_VM 共享虚拟内存空间,加速 AST 与 SSA 中间表示传递
// 示例:Go 工具链中实际调用 openat 的封装逻辑(简化)
func openSourceFile(path string) (int, error) {
    // syscall.Openat(AT_FDCWD, path, syscall.O_RDONLY, 0)
    fd, err := unix.Openat(unix.AT_FDCWD, path, unix.O_RDONLY, 0)
    if err != nil {
        return -1, fmt.Errorf("failed to open %s: %w", path, err)
    }
    return fd, nil
}

该封装确保路径解析不依赖 chdir,避免模块根目录漂移;unix.O_RDONLY 明确只读语义,防止构建缓存污染。

2.2 常见环境配置“幽灵问题”的eBPF可观测性归因分析

当应用在K8s集群中偶发503且日志无异常,传统监控常显示“一切正常”——这正是典型的环境配置幽灵问题:如iptables规则残留、cgroup v1资源限制、或kube-proxy IPVS模式下conntrack老化不一致。

核心归因路径

  • 应用Pod启动时继承节点级netfilter配置
  • eBPF程序(如tc bpf)被错误挂载到非预期qdisc
  • bpf_trace_printk()输出被内核静默丢弃(需启用/sys/kernel/debug/tracing/options/printk

实时捕获网络策略冲突

// trace_netfilter.c:挂钩NF_INET_PRE_ROUTING,标记被DROP的包来源链
SEC("tracepoint/net/netif_receive_skb")
int trace_drop_source(struct trace_event_raw_netif_receive_skb *ctx) {
    struct sk_buff *skb = (struct sk_buff *)ctx->skbaddr;
    if (skb && skb->nf_trace) { // 仅追踪启用了nf_trace的包
        bpf_trace_printk("NF_DROP from %s\\n", skb->dev ? skb->dev->name : "unknown");
    }
    return 0;
}

该eBPF程序通过tracepoint精确捕获netfilter决策瞬间;skb->nf_trace为真表明该包已进入Netfilter框架,可排除底层驱动丢包。需配合bpftool prog load加载并tc filter add挂载至对应网卡。

典型幽灵问题对照表

现象 根本原因 eBPF验证方法
Pod间间歇性连接超时 conntrack表满(nf_conntrack_max=65536 bpf_map_lookup_elem(&ct_map, &tuple)统计活跃连接数
InitContainer卡住 cgroup v1 memory.limit_in_bytes=0 bpf_probe_read_kernel(&mem_limit, sizeof(u64), &cgrp->memory.limit)
graph TD
    A[应用异常] --> B{是否复现于特定Node?}
    B -->|是| C[检查Node级ebpf程序]
    B -->|否| D[对比Pod annotation差异]
    C --> E[bpftool prog dump xlated]
    D --> F[kubectl get pod -o yaml \| grep -i bpf]

2.3 eBPF探针在Go编译时上下文中的安全注入机制

Go 编译器(gc)在 SSA 构建阶段暴露了 buildcfgdebugInfo 接口,为 eBPF 探针的编译期静态注入提供可信锚点。

安全注入三原则

  • ✅ 基于 //go:embed//go:linkname 的符号白名单校验
  • ✅ 所有 BPF 程序字节码经 libbpf-goVerifierOptions 静态预检
  • ❌ 禁止运行时 mmap()bpf_prog_load() 调用

注入流程(mermaid)

graph TD
    A[Go源码含//go:bpf注释] --> B[go tool compile -toolexec触发注入器]
    B --> C[解析AST获取函数签名与行号]
    C --> D[生成带校验和的BTF-annotated ELF]
    D --> E[链接进最终binary的.rodata段]

示例:安全探针声明

//go:bpf func http_handler_start(ctx context.Context, r *http.Request)
//go:bpf verify=strict
func init() {
    // 注入器自动绑定至 http.ServeHTTP 的 SSA call 指令
}

此声明被 go tool compilessa.Builder 阶段捕获;verify=strict 触发 libbpf 的 BPF_F_STRICT_ALIGNMENT 校验,确保寄存器状态与 Go runtime ABI 兼容。

2.4 libbpf-go与cilium/ebpf双栈实践:构建轻量级build监控器

为实现零依赖、低开销的构建过程可观测性,我们采用双栈eBPF方案:libbpf-go承载稳定内核态逻辑,cilium/ebpf提供用户态高级抽象与动态加载能力。

核心架构对比

维度 libbpf-go cilium/ebpf
加载方式 静态CO-RE兼容BTF 动态程序验证与重写
调试支持 原生perf event + BPF tracepoint ebpf.LoadCollectionSpec + MapIterator
适用场景 长期驻留、高稳定性监控 快速迭代、CI/CD临时注入

eBPF程序片段(libbpf-go)

// attach to execve syscall to capture build commands
prog := obj.ExecveProbe
link, _ := prog.Attach(&libbpf.BPFProgramOptions{
    AttachType: libbpf.BPF_TRACE_FENTRY,
    AttachTo:   "sys_execve",
})

该代码将eBPF程序挂载至sys_execve内核入口点;BPF_TRACE_FENTRY确保在参数解析前捕获完整argvAttachTo需与内核符号表严格匹配,避免因kprobe alias导致挂载失败。

数据同步机制

使用ringbuf(libbpf-go)与per-CPU maps(cilium/ebpf)协同传输事件:前者保障低延迟丢弃策略,后者支撑高吞吐聚合统计。

2.5 实时syscall trace可视化:从perf event到火焰图的端到端闭环

数据采集:perf record syscall tracing

使用 perf 捕获系统调用事件流,关键参数确保低开销与高保真:

perf record -e 'syscalls:sys_enter_*' -a -g --call-graph dwarf,65536 -o perf.data sleep 5
  • -e 'syscalls:sys_enter_*':匹配所有进入态 syscall tracepoint(内核 4.15+ 支持);
  • -g --call-graph dwarf:启用 DWARF 解析获取用户态调用栈,避免 frame-pointer 依赖;
  • -a:全系统采样,适配容器/多进程场景。

可视化流水线

graph TD
    A[perf.data] --> B[perf script -F comm,pid,tid,cpu,time,trace]
    B --> C[stackcollapse-perf.pl]
    C --> D[flamegraph.pl]
    D --> E[interactive SVG flame graph]

关键工具链兼容性

工具 作用 最小版本要求
perf 内核事件采集 Linux 4.1+(完整 syscall tracepoint)
stackcollapse-perf.pl 栈折叠归一化 bcc-tools v0.20+
flamegraph.pl 火焰图渲染 Brendan Gregg 主干版

实时闭环依赖 perf inject --jit(对 eBPF/JITed 代码补全符号),确保容器内 Go/Rust 应用 syscall 上下文可追溯。

第三章:面向Go开发者的eBPF调试工具链集成

3.1 go env与eBPF环境变量协同校验:PATH、GOCACHE、GOROOT一致性检测

eBPF程序构建高度依赖Go工具链的确定性行为,go env输出与实际运行时环境变量若存在偏差,将导致编译失败或加载异常。

关键变量冲突场景

  • GOROOT 指向非官方Go安装路径,但 PATHgo 命令来自另一版本
  • GOCACHE 权限受限或路径跨文件系统,使 go build -buildmode=plugin 缓存失效,间接影响 cilium/ebpf 的BTF解析

自动化校验脚本

# 检查三者路径归属一致性(同一挂载点 + 可读写)
go_root=$(go env GOROOT)
go_path=$(dirname $(which go))
go_cache=$(go env GOCACHE)

stat -fc "%d" "$go_root" "$go_path" "$go_cache" | sort -u | wc -l
# 若输出 >1,说明跨设备,可能触发eBPF构建时BTF缓存不一致

该命令通过stat -fc "%d"提取各路径所在设备ID,若结果不唯一,表明GOCACHEGOROOT/PATH不在同一文件系统,易引发cilium/ebpf加载器因BTF缓存元数据错位而静默降级。

校验维度对照表

变量 必须满足条件 eBPF影响点
PATH $(dirname $(which go))GOROOT/bin 确保go tool compile版本匹配
GOCACHE 可写、同设备、无符号链接跳转 BTF类型缓存完整性
GOROOT go version报告版本严格一致 //go:embed及cgo交叉编译稳定性
graph TD
    A[读取 go env 输出] --> B{GOROOT == PATH中go所在目录?}
    B -->|否| C[警告:工具链分裂]
    B -->|是| D{GOCACHE可写且同设备?}
    D -->|否| E[错误:BTF缓存不可靠]
    D -->|是| F[通过eBPF构建前置检查]

3.2 基于tracepoint的go:gc:start/go:gc:done事件联动分析

Go 运行时通过内核 tracepoint 暴露 go:gc:startgo:gc:done 两个关键事件,实现 GC 生命周期的零侵入观测。

数据同步机制

当 runtime 触发 STW 前,内核 probe 注入 go:gc:start,携带参数:

  • goid: 当前 GC goroutine ID
  • phase: GC 阶段标识(如 sweep/mark
  • ts: 纳秒级时间戳
// tracepoint 定义节选(Linux kernel side)
TRACE_EVENT(go_gc_start,
    TP_PROTO(unsigned long goid, unsigned int phase, u64 ts),
    TP_ARGS(goid, phase, ts)
);

该结构确保用户态 eBPF 程序可精准捕获 GC 启动上下文,并与 go:gc:doneduration_ns 字段配对计算实际停顿耗时。

事件关联模型

字段 go:gc:start go:gc:done 用途
goid 关联同一 GC 实例
ts 计算 STW 时长
heap_goal 预判内存压力趋势
graph TD
    A[go:gc:start] -->|goid + ts| B[STW 开始]
    B --> C[标记/清扫]
    C --> D[go:gc:done]
    D -->|duration = ts_done - ts_start| E[时延聚合]

3.3 构建失败场景下的eBPF异常快照(snapshot)自动捕获与回溯

当内核模块加载或eBPF程序校验失败时,传统日志仅记录错误码,缺失寄存器状态、验证器路径与map初始值等关键上下文。为此,需在bpf_verifier_log触发前注入快照钩子。

快照触发时机

  • bpf_check() 中 verifier 失败分支
  • bpf_prog_load()ERR_PTR(-EINVAL) 返回路径
  • 用户态通过 BPF_PROG_TEST_RUN 异常退出时

核心快照结构

struct bpf_snapshot {
    __u64 timestamp;
    __u32 prog_id;
    __u16 error_code;      // 如 VERIFIER_ERR_JMP_OUT_OF_RANGE
    __u8  verifier_state[256]; // 压缩的CFG节点栈
    __u32 map_init_keys[8];    // 关键map首8个key哈希
};

此结构嵌入struct bpf_verifier_env末尾,由kmem_cache_alloc()预分配;error_code映射至libbpflibbpf_err()语义,确保用户态可解码。

自动回溯流程

graph TD
    A[Verifer报错] --> B[调用snapshot_capture]
    B --> C[保存寄存器/stack/insn_idx]
    C --> D[写入perf ringbuf]
    D --> E[用户态libbpf监听ringbuf]
    E --> F[生成dot CFG图+寄存器快照]
字段 用途 生命周期
verifier_state 记录失败前最后5个CFG节点ID 内核栈临时存储
map_init_keys 触发map预填充的key样本 持久化至/sys/fs/bpf/snapshots/

第四章:生产级Go项目环境诊断实战体系

4.1 多模块项目中CGO_ENABLED=0与动态链接冲突的eBPF取证

当多模块Go项目启用 CGO_ENABLED=0 编译eBPF程序时,libbpf-go 等依赖动态链接libc的库将因缺失符号而静默失败。

核心矛盾点

  • CGO_ENABLED=0 禁用C调用,但eBPF加载器需调用 bpf() 系统调用封装(如 libbpfbpf_object__load()
  • 静态编译下 libbpf.so 无法解析,导致 bpf_load_program() 返回 -ENOSYS

典型错误日志片段

# 运行时实际报错(非编译期)
failed to load BPF object: failed to load program 'trace_sys_enter': invalid argument

解决路径对比

方案 是否兼容 CGO_ENABLED=0 依赖注入方式 安全性
libbpf-go + cgo ❌(必须 CGO_ENABLED=1 动态链接 .so
cilium/ebpf ✅(纯Go实现) 零C依赖,直接系统调用

关键修复代码

// 使用 cilium/ebpf 替代 libbpf-go
obj := &ebpf.ProgramSpec{
    Type:       ebpf.TracePoint,
    Instructions: traceSysEnterInstrs,
    License:      "MIT",
}
prog, err := ebpf.NewProgram(obj) // 无CGO,直接syscall.Syscall6
if err != nil {
    log.Fatal("eBPF load failed:", err) // 此处错误含具体errno,如 ENOMEM/EPERM
}

该调用绕过libc,通过 syscall.RawSyscall6(SYS_bpf, ...) 直达内核,规避动态链接链断裂。

4.2 Docker/Kubernetes构建环境中cgroup v2与seccomp对go build syscall的拦截识别

cgroup v2 默认启用对 Go 构建的影响

Go 1.21+ 在 go build 过程中会调用 membarrier(MEMBARRIER_CMD_PRIVATE_EXPEDITED) 等新式系统调用,而 cgroup v2 的 unified 层级默认启用 restrictions(如 memory.pressure 监控),可能触发内核对非特权进程的 syscall 审计路径增强。

seccomp 配置中的关键拦截点

Docker 默认 seccomp profile(default.json)显式拒绝以下调用:

{
  "name": "membarrier",
  "action": "SCMP_ACT_ERRNO",
  "args": []
}

逻辑分析membarrier 被拒导致 runtime/internal/syscall 回退至 futex + atomic 组合实现同步,但某些 CGO 依赖的构建阶段(如 //go:build cgo)可能因 errno -EPERM 触发 panic。参数 args: [] 表示无条件拦截,不校验 flags。

典型错误现象对比

场景 错误日志片段 根本原因
cgroup v2 + systemd v250+ cannot allocate memory (ENOMEM) during go:link memory.max 限制下 mmap(MAP_POPULATE) 失败
seccomp enabled exec: "go": cannot run as non-root with membarrier disabled membarrierSCMP_ACT_ERRNO 拦截且未配置 SCMP_FLTATR_CTL_TSYNC

修复策略优先级

  • ✅ 优先在 Kubernetes Pod Security Context 中启用 seccompProfile.type: RuntimeDefault(K8s 1.25+)
  • ⚠️ 禁用 cgroup v2 的 memory controller 需修改 /etc/default/grubsystemd.unified_cgroup_hierarchy=0
  • ❌ 不推荐完全禁用 seccomp —— 应定制 profile 白名单 membarrier, openat2, statx

4.3 跨平台交叉编译(GOOS=linux GOARCH=arm64)时文件系统访问异常的eBPF定位

当在 x86_64 主机上以 GOOS=linux GOARCH=arm64 交叉编译 eBPF 程序并加载至 ARM64 内核时,常因 bpf_probe_read_kernel()struct file 字段偏移计算错误导致文件路径读取为空。

核心诱因:内核结构体布局差异

ARM64 与 x86_64 的 CONFIG_ARM64_MODULE_PLTS=y 及字段对齐策略不同,struct file.f_path.dentry 在 BTF 中的偏移不一致。

复现代码片段

// 使用 btf_helpers.h 安全读取 dentry->d_name.name
char fname[256] = {};
if (bpf_probe_read_kernel(&fname, sizeof(fname), 
    &file->f_path.dentry->d_name.name) < 0) {
    bpf_printk("failed to read d_name.name\n"); // ARM64 上常触发
}

逻辑分析:bpf_probe_read_kernel() 依赖 BTF 信息推导字段地址;交叉编译时若未指定目标内核 BTF(如 /lib/modules/$(uname -r)/build/vmlinux.btf),eBPF verifier 将回退至硬编码偏移,而该偏移仅适配构建主机架构。

排查路径

  • ✅ 使用 bpftool btf dump file /sys/kernel/btf/vmlinux format c 验证目标内核 BTF 完整性
  • ✅ 编译时显式传入 -target-btf /path/to/arm64-vmlinux.btf
工具 ARM64 BTF 必需 说明
libbpf v1.3+ 支持 -target-btf 参数
bpftool 需匹配目标内核版本
graph TD
    A[交叉编译 eBPF] --> B{是否指定 target-btf?}
    B -->|否| C[使用 host BTF → 偏移错位]
    B -->|是| D[生成正确字段偏移 → 读取成功]

4.4 CI流水线中go build超时根因分析:磁盘I/O阻塞 vs. 网络代理劫持的syscall指纹区分

在CI节点上,go build 长时间卡在 syscall.Syscall 调用(如 openat, read, connect)是典型超时信号。关键在于区分阻塞源头:

syscall行为指纹特征

系统调用 磁盘I/O阻塞表现 网络代理劫持表现
openat errno=ETIMEDOUT 罕见,多见 EACCES 或长时间无返回 正常返回,但后续 connect 持续 EINPROGRESS
connect 极少触发(静态链接不依赖动态网络) 高频出现,strace -e trace=connect 显示反复重试

strace诊断代码块

# 捕获go build期间阻塞syscall(需root或CAP_SYS_PTRACE)
strace -p $(pgrep -f "go\ build") -e trace=openat,read,write,connect \
       -T -o /tmp/build.strace 2>&1

逻辑分析:-T 输出每个syscall耗时(单位秒),磁盘I/O阻塞表现为 openat/read 耗时 >5s 且无errno;代理劫持则 connect 耗时稳定在~3s(TCP握手超时阈值),伴随重复调用。

根因决策流程

graph TD
    A[go build卡住] --> B{strace发现connect高频调用?}
    B -->|是| C[检查HTTP_PROXY/https_proxy环境变量]
    B -->|否| D[检查/dev/sda I/O wait% >80%]
    C --> E[对比curl -v https://proxy.golang.org]
    D --> F[iostat -x 1 | grep sda]

第五章:总结与展望

核心成果回顾

在真实生产环境中,某中型电商平台通过落地本方案中的可观测性增强实践,将平均故障定位时间(MTTR)从 47 分钟压缩至 8.3 分钟。关键改进包括:统一 OpenTelemetry SDK 接入全部 Java/Go 微服务(覆盖 127 个服务实例),构建基于 Prometheus + Grafana 的 SLO 仪表盘集群(含 9 类业务黄金指标),并实现日志-链路-指标三者 ID 联动查询(TraceID、RequestID、LogStreamID 全局对齐)。下表为压测环境下的性能对比数据:

指标 改造前 改造后 提升幅度
链路采样延迟 142ms 23ms ↓83.8%
日志检索响应 P95 6.8s 0.41s ↓94.0%
告警准确率(7天) 61.2% 92.7% ↑31.5pp

关键技术瓶颈突破

针对高并发场景下 Span 数据爆炸问题,团队自研轻量级采样策略引擎,支持动态阈值(如 error_rate > 0.5% || p99_latency > 2s 触发全量采样),并通过 Envoy WASM 插件在边缘节点完成预聚合,使后端 Jaeger Collector CPU 占用率峰值下降 67%。该模块已开源为 otel-sampler-wasm,被 3 家金融客户集成进其网关层。

# 示例:WASM 采样策略配置片段
sampling_rules:
- name: "payment-service-error-burst"
  service_name: "payment-service"
  condition: "status.code == 5xx && duration > 1000ms"
  rate: 1.0
  ttl_seconds: 300

生产环境异常处置案例

2024 年 Q2 某次大促期间,系统突现支付成功率下降 12%。运维人员通过 Grafana 中「SLO Burn Rate」看板快速识别出 payment-process 服务的 slo_payment_success_1d 指标已达 3.2x burn rate(阈值 1x),点击下钻进入 Trace Explorer,筛选 http.status_code=500 AND service.name=payment-service,15 秒内定位到数据库连接池耗尽问题——根本原因为下游风控服务超时重试导致连接泄漏。通过熔断该风控接口并扩容连接池,3 分钟内恢复支付成功率至 99.98%。

后续演进路径

团队正推进两项重点工程:一是构建 AI 辅助根因分析模块,基于历史告警-日志-指标序列训练时序异常检测模型(采用 Temporal Fusion Transformer 架构),已在灰度环境实现 73% 的自动归因准确率;二是探索 eBPF 原生可观测性采集,在 Kubernetes Node 层直接捕获 socket-level 网络行为,规避应用层埋点侵入性。Mermaid 流程图展示当前架构向云原生可观测性平台演进的关键阶段:

flowchart LR
A[当前架构:应用埋点+中心化采集] --> B[阶段一:eBPF 扩展网络/文件系统观测]
B --> C[阶段二:AI 异常聚类与因果图生成]
C --> D[阶段三:跨云/多集群统一观测平面]

社区协作机制

所有自研工具均遵循 CNCF 可观测性白皮书规范,已向 OpenTelemetry Collector 贡献 2 个核心插件(k8s-pod-label-enricherlog-correlation-filter),其中后者被纳入 v0.98.0 正式版本。每月举办线上 Debug Clinic,联合 17 家企业用户复盘真实故障案例,沉淀成《可观测性实战手册》第 3 版(GitHub Star 2.4k)。

记录 Golang 学习修行之路,每一步都算数。

发表回复

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