Posted in

Go标准库syscall与unsafe.Pointer边界实践:如何安全调用系统API而不触发Go 1.21+ vet检查失败

第一章:Go标准库syscall与unsafe.Pointer边界实践概述

Go语言设计哲学强调内存安全与类型安全,但syscall包与unsafe.Pointer构成了官方支持的“受控越界”通道,用于系统调用交互、零拷贝操作及底层资源管理。这种能力并非鼓励滥用,而是为特定场景(如高性能网络栈、设备驱动封装、跨语言桥接)提供必要工具。

syscall与unsafe.Pointer的协同本质

syscall包中多数函数(如syscall.Syscallsyscall.RawSyscall)接受uintptr参数,而unsafe.Pointer是唯一可合法转换为uintptr的指针类型。该转换仅在立即用于系统调用参数传递时有效——若将uintptr保存为变量或参与算术运算,将导致GC无法追踪原始对象,引发悬空指针风险。

安全转换的黄金法则

必须严格遵循“转换→使用→丢弃”单次生命周期:

// ✅ 正确:Pointer转uintptr仅用于syscall参数,不存储
buf := make([]byte, 1024)
_, _, errno := syscall.Syscall(
    syscall.SYS_WRITE,
    uintptr(syscall.Stdout),
    uintptr(unsafe.Pointer(&buf[0])), // 转换后立即传入
    uintptr(len(buf)),
)

// ❌ 危险:uintptr被赋值给变量,GC可能回收buf
ptr := uintptr(unsafe.Pointer(&buf[0])) // 禁止!

常见高危模式对照表

风险行为 安全替代方案
uintptr 存储于结构体字段 使用 unsafe.Pointer 字段 + 显式生命周期管理
uintptr 执行 + 运算获取偏移 改用 unsafe.Offsetofunsafe.Add(Go 1.19+)
uintptr 转回 *T 后长期持有 确保原始对象存活期覆盖整个指针使用周期

实际边界实践场景

  • 文件描述符透传:通过 syscall.Dup 复制fd后,用 syscall.Syscall 直接操作底层句柄;
  • 共享内存映射syscall.Mmap 返回 []byte,需 unsafe.Pointer 获取首地址供C库消费;
  • 信号处理syscall.Signal 注册handler时,unsafe.Pointer 传递Go函数指针(需配合//go:cgo_import_static)。

所有实践均要求开发者主动承担内存生命周期责任——Go编译器不会校验此类代码,错误将直接导致段错误或数据损坏。

第二章:syscall包核心机制与底层系统调用原理

2.1 syscall.Syscall及其变体在不同OS上的ABI适配实践

Go 的 syscall.Syscall 及其平台特化变体(如 Syscall6RawSyscall)是运行时与操作系统内核交互的底层桥梁,其行为高度依赖目标平台的 ABI 规范。

ABI 差异核心维度

  • Linux:遵循 x86-64 System V ABI,寄存器传参(rdi, rsi, rdx…),rax 存系统调用号
  • Darwin/macOS:沿用 System V,但部分系统调用号与 Linux 不兼容,且禁用 __NR_* 宏直接映射
  • Windows:不使用 syscall.Syscall,转而通过 syscall.SyscallNoError 调用 NTAPI,参数经 uintptr 数组传递

典型跨平台调用模式

// Linux: openat(AT_FDCWD, "/tmp", O_RDONLY)
_, _, err := syscall.Syscall6(syscall.SYS_OPENAT, 
    uintptr(syscall.AT_FDCWD),     // fd
    uintptr(unsafe.Pointer(&path)), // path
    uintptr(syscall.O_RDONLY),      // flags
    0, 0, 0)                        // unused

Syscall6 将前6个参数依次装入 rdi–r9;第7+参数压栈。返回值中 r11 保存 errno(Linux),而 macOS 需检查 r15 —— 这正是 syscall 包需按 GOOS 分支实现的根本原因。

OS 主要调用函数 错误提取寄存器 栈对齐要求
linux/amd64 Syscall6 r11 16-byte
darwin/amd64 Syscall6 r15 16-byte
windows/amd64 SyscallNoError 无(SEH/LastError) 32-byte
graph TD
    A[Go 程序调用 syscall.Open] --> B{GOOS == “windows”?}
    B -->|是| C[转 NTAPI + GetLastError]
    B -->|否| D[查 syscall table → Syscall6]
    D --> E[按 ABI 布局寄存器/栈]
    E --> F[触发 int 0x80 或 syscall 指令]

2.2 系统调用号、寄存器约定与errno错误传播的源码级剖析

Linux 系统调用执行依赖三要素:唯一调用号、ABI 规定的寄存器布局,以及 errno 的原子性传播机制。

系统调用号的静态映射

x86-64 下,sys_read 编号为 ,定义在 arch/x86/entry/syscalls/syscall_64.tbl

0       common  read            sys_read

该表由 syscall_table 构建,内核通过 __NR_read 宏(值为 )索引 sys_call_table[0]

寄存器约定(x86-64 ABI)

寄存器 用途
%rax 存储系统调用号
%rdi 第1参数(fd)
%rsi 第2参数(buf)
%rdx 第3参数(count)

errno 传播路径

用户态 read() 失败时,内核返回负错误码(如 -EINTR),glibc 将其转为正数存入 errno 并返回 -1

// glibc sysdeps/unix/syscall-template.S
movq %rax, %rdi    # 保存返回值
testq %rax, %rax
jns  syscall_success
negq %rdi          # 负值 → 正errno
movq %rdi, %gs:errno
movq $-1, %rax

逻辑分析:%rax 中负值表示内核错误;negq 取反后写入 errno,确保用户可见;$-1 是 POSIX 要求的失败返回值。

2.3 Go 1.21+中syscall.RawSyscall被弃用后的安全迁移路径

Go 1.21 起,syscall.RawSyscall 及其变体(RawSyscall6 等)被正式标记为 deprecated,因其绕过 Go 运行时信号处理与栈检查,存在协程抢占、GC 安全及跨平台兼容性风险。

替代方案优先级推荐

  • ✅ 首选:使用 syscall.Syscall(经运行时封装,支持异步抢占)
  • ✅ 次选:调用 golang.org/x/sys/unix 中的类型安全封装(如 unix.Read, unix.Mmap
  • ⚠️ 仅限必要场景:通过 //go:nosplit + //go:systemstack 手动调用 syscall.syscall(需严格审计)

典型迁移示例

// 旧写法(Go ≤1.20,已废弃)
n, _, errno := syscall.RawSyscall(syscall.SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(buf)), uintptr(len(buf)))

// 新写法(Go 1.21+ 推荐)
n, err := unix.Read(int(fd), buf) // 自动错误转换,支持中断重试

unix.Read 内部使用 syscall.Syscall 并封装 EINTR 重试逻辑;参数 int(fd)[]byte 消除了手动地址计算与长度转换风险。

原始 API 安全替代方案 关键改进
RawSyscall unix.Read/Write/Mmap 错误标准化、EINTR 自恢复
RawSyscall6 unix.Syscall(带类型约束) 参数校验、栈溢出防护
graph TD
    A[原始 RawSyscall] -->|绕过 runtime| B[不可抢占/非 GC 安全]
    B --> C[Go 1.21+ panic 或静默失败]
    A --> D[迁移路径]
    D --> E[使用 x/sys/unix]
    D --> F[升级 syscall.Syscall]
    E --> G[类型安全 + 平台抽象]

2.4 syscall.UnixProcAttr与进程创建时的内存布局约束分析

syscall.UnixProcAttr 是 Go 标准库中用于配置 Unix 子进程启动参数的关键结构,其字段直接影响内核 clone()/execve() 调用时的内存视图初始化。

关键字段语义约束

  • Chroot: 若非空,强制新进程根目录隔离,触发 chroot(2),影响 AT_FDCWD 解析路径及 libc 动态链接器查找路径;
  • Setpgid: 控制进程组归属,间接约束 mm_structpgd(页全局目录)的共享策略;
  • Setctty: 决定是否分配控制终端,影响 task_struct->signal->tty 初始化,进而影响 VM_EXEC 权限在 mmap 区域的继承。

内存布局关键限制表

字段 是否影响栈基址 是否约束堆起始 是否修改 VMA 权限位
Chroot 否(但影响 AT_SYSROOT 解析)
Setpgid
Setctty
Cloneflags 是(CLONE_VM 是(CLONE_VM 是(VM_READ|VM_WRITE|VM_EXEC
// 示例:启用 CLONE_VM 时的典型配置
attr := &syscall.UnixProcAttr{
    Cloneflags: syscall.CLONE_VM | syscall.CLONE_FILES,
    Setpgid:    true,
}

此配置使子进程与父进程共享虚拟内存地址空间(mm_struct),但需注意:CLONE_VMexecve() 不兼容——内核会拒绝 execveCLONE_VM 进程中执行,因 execve 要求全新 mm_struct 以重置 brkstackVDSO 映射。这是由 fs/exec.cbprm_check_euid() 后的 would_dump() 校验强制实施的约束。

graph TD
    A[fork/exec 流程] --> B{UnixProcAttr.Cloneflags}
    B -->|含 CLONE_VM| C[内核拒绝 execve]
    B -->|无 CLONE_VM| D[正常加载 ELF, 重建 mm_struct]
    D --> E[按 PT_LOAD 段重置 stack/vvar/vdso/VMA]

2.5 基于syscall.Mmap的内存映射实践及与runtime/msan的兼容性验证

内存映射基础实现

fd, _ := os.OpenFile("/tmp/mmap-test", os.O_CREATE|os.O_RDWR, 0600)
defer fd.Close()
syscall.Ftruncate(int(fd.Fd()), 4096) // 确保文件大小足够

data, err := syscall.Mmap(
    int(fd.Fd()), 0, 4096,
    syscall.PROT_READ|syscall.PROT_WRITE,
    syscall.MAP_SHARED,
)
if err != nil {
    panic(err)
}
defer syscall.Munmap(data)

syscall.Mmap 将文件描述符 fd 映射为可读写内存区域:PROT_READ|PROT_WRITE 控制访问权限,MAP_SHARED 保证修改同步回文件;长度必须是页对齐(此处 4096 恰为一页)。

MSAN 兼容性关键约束

  • Mmap 分配的内存不被 MSAN 自动跟踪,需显式调用 runtime.KeepAlivemsan.Write 注入标记
  • 避免在 Mmap 区域内使用 unsafe.Slice 构造 slice 后直接传给 msan 检查——需先 msan.MemoryRangeState 查询状态

兼容性验证结果汇总

场景 MSAN 报告 是否通过
Mmap + msan.Write 显式标记 ✅ 无误报
直接写入未标记区域 ⚠️ “use-of-uninitialized-value”
MAP_ANONYMOUS + msan.Write ✅ 正常检测
graph TD
    A[调用 syscall.Mmap] --> B{是否 MAP_ANONYMOUS?}
    B -->|是| C[需 msan.Write 初始化标记]
    B -->|否| D[需 msan.Write + 文件同步标记]
    C --> E[MSAN 正确跟踪访问]
    D --> E

第三章:unsafe.Pointer的合法边界与vet检查逻辑深度解析

3.1 Go内存模型下Pointer算术的合法性判定规则(Go spec §13.4)

Go 严格限制指针算术,仅允许在切片底层数组范围内*T 类型指针执行加减整数运算,且结果必须指向同一数组内有效元素或紧邻的“哨兵位置”(即 &slice[len(slice)])。

合法性判定三要素

  • 指针必须源自切片或数组的地址(如 &s[0]
  • 运算后地址不得越界(p + n 必须满足 &s[0] ≤ p+n < &s[len(s)] 或等于 &s[len(s)]
  • 不得对 unsafe.Pointer 直接做算术——需先转为 *T

示例:合法与非法对比

s := []int{1, 2, 3}
p := &s[0]
q := p + 2 // ✅ 合法:指向 s[2]
r := p + 5 // ❌ panic:越界(Go 运行时检测)

p + 2 计算后地址为 &s[2],类型安全且位于底层数组 [1,2,3] 范围内;p + 5 超出 cap(s) 边界,违反 §13.4 的“同一分配块”约束。

场景 是否合法 依据
&arr[0] + 3(arr 长度≥4) 同一数组,未越界
(*int)(unsafe.Pointer(&x)) + 1 非切片/数组衍生指针
graph TD
    A[原始指针 p] --> B{源自切片/数组?}
    B -->|否| C[非法]
    B -->|是| D[计算 p + n]
    D --> E{地址 ∈ [base, base+cap) ?}
    E -->|否| C
    E -->|是| F[合法]

3.2 vet工具对unsafe.Pointer转换的静态检查机制与误报规避策略

go vetunsafe.Pointer 转换实施三类静态约束:类型对称性、内存生命周期可见性、以及指针算术合法性。

检查原理

vet 基于 AST 遍历识别 (*T)(unsafe.Pointer(p)) 模式,验证源/目标类型是否满足 unsafe 文档定义的“可安全转换”条件(如大小一致、无非导出字段嵌套等)。

典型误报场景与规避

  • 使用 //go:nosplit//go:linkname 时需显式标注 //nolint:unsafeptr
  • 字段偏移计算应优先用 unsafe.Offsetof 而非硬编码常量
// ✅ 安全:编译期可验证的结构体字段偏移
type Header struct {
    Len int
}
h := &Header{}
p := unsafe.Pointer(&h.Len) // vet 通过

此处 &h.Len 是合法地址,unsafe.Pointer 转换目标为导出字段,vet 可追溯其所属结构体布局,确认无越界风险。

误报率对比(典型项目)

场景 默认检查 添加 //nolint:unsafeptr
系统调用参数封装 87% 误报 0%
ring buffer 索引计算 42% 误报 0%
graph TD
    A[源指针 p] --> B{是否指向导出类型?}
    B -->|是| C[检查 size(T) == size(U)]
    B -->|否| D[标记潜在误报]
    C --> E{是否在函数作用域内存活?}
    E -->|是| F[允许转换]
    E -->|否| G[触发 vet warning]

3.3 通过go:linkname与//go:uintptrsafe注释绕过vet的合规使用场景

go:linkname 是 Go 编译器支持的底层链接指令,允许将当前包内符号绑定到运行时或标准库中未导出的符号;//go:uintptrsafe 则向 go vet 声明某段指针运算逻辑是内存安全的,从而跳过 unsafe.Pointer 转换检查。

典型合规场景:运行时信号处理钩子注入

//go:linkname sysSigtramp runtime.sigtramp
var sysSigtramp func(uintptr, uintptr, uintptr, uintptr, uintptr)

该声明将本地变量 sysSigtramp 绑定至 runtime 包私有函数 sigtramp,仅限在信号处理扩展等极少数系统级封装中使用。go vet 不报错,因 go:linkname 本身不触发指针安全检查。

安全边界约束

  • ✅ 仅用于对接 runtimesyscall 等核心包内部符号
  • ❌ 禁止用于用户定义包间的非导出符号链接
  • ✅ 必须配合 //go:uintptrsafe 标注所有含 unsafe.Pointer 的转换块
注释类型 触发 vet 检查项 允许绕过的前提条件
//go:linkname 符号可见性违规 目标符号属 runtime/syscall
//go:uintptrsafe unsafe.Pointer 转换 后续操作经实测无悬垂/越界风险
graph TD
    A[调用 go:linkname] --> B{目标符号是否在 runtime/syscall?}
    B -->|是| C[链接成功,vet 无警告]
    B -->|否| D[构建失败或 vet 报 warning]

第四章:syscall与unsafe.Pointer协同调用系统API的安全工程实践

4.1 使用unsafe.Slice替代C pointer转换实现零拷贝I/O缓冲区管理

Go 1.20 引入 unsafe.Slice,为零拷贝缓冲区提供了安全、可读性强的替代方案,彻底规避了 (*[n]byte)(unsafe.Pointer(p))[:n:n] 这类易出错的 C 风格指针转换。

更安全的切片构造

// 旧方式(易越界、不可读)
buf := (*[4096]byte)(unsafe.Pointer(&data[0]))[:4096:4096]

// 新方式(语义清晰、编译器可验证)
buf := unsafe.Slice(&data[0], 4096)

unsafe.Slice(ptr, len) 直接从指针和长度构造切片,无需数组类型中介;编译器能校验 ptr 是否指向可寻址内存,且 len 不触发越界 panic(运行时仍需业务层保障)。

性能与安全性对比

方式 可读性 编译期检查 运行时开销 推荐度
(*[n]T)(p)[:n:n] 极低
unsafe.Slice(p, n) 部分(非nil/对齐) 极低
graph TD
    A[原始字节指针] --> B[unsafe.Slice]
    B --> C[类型安全切片]
    C --> D[直接用于io.Read/Write]

4.2 构建跨平台socket选项设置器:从syscall.Setsockopt到unsafe.Offsetof的联合应用

核心挑战:结构体字段偏移与平台差异

不同操作系统对 sockaddr_in/sockaddr_in6 的字段布局存在细微差异(如 sin_len 是否存在),直接硬编码字段偏移会导致跨平台失败。

关键技术组合

  • syscall.Setsockopt 提供底层 socket 控制能力
  • unsafe.Offsetof 动态计算字段内存偏移,规避硬编码风险

示例:安全设置 SO_BINDTODEVICE

func setBindToDevice(fd int, iface string) error {
    b := []byte(iface)
    // 确保以 \0 结尾,适配 C 接口
    if len(b) >= unix.IFNAMSIZ {
        b = b[:unix.IFNAMSIZ-1]
    }
    b = append(b, 0)
    return syscall.SetsockoptString(fd, syscall.SOL_SOCKET, unix.SO_BINDTODEVICE, string(b))
}

此函数绕过结构体偏移问题,直接使用 SetsockoptString——适用于字符串型选项;对需结构体传参的选项(如 IP_MULTICAST_TTL),则需结合 unsafe.Offsetof 计算 &ttl*int32 中的有效地址。

跨平台字段偏移验证表

字段名 Linux offset FreeBSD offset 是否一致
sin_port 2 2
sin_addr 4 4
sin_zero[0] 8 ❌(FreeBSD 无该字段)

内存布局安全边界

type sockaddrIn struct {
    Family uint8
    Port   uint16
    Addr   [4]byte
    Zero   [8]byte // Linux only
}
// unsafe.Offsetof(sockaddrIn{}.Port) → 编译期确定,零运行时开销

利用 unsafe.Offsetof 获取 Port 相对于结构体起始地址的偏移量(字节),再通过 (*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + offset)) 安全写入,避免 cgo 或反射开销。

4.3 epoll/kqueue事件循环中fd数组与unsafe.Pointer切片的生命周期管控

内存布局与所有权边界

epoll_wait/kqueue 返回就绪事件时,Go runtime 需将底层 struct epoll_event[]struct kevent[] 映射为 Go 切片。若直接用 unsafe.Slice 构造 []unsafe.Pointer,其底层数组生命周期完全依赖于系统调用栈帧——一旦 epoll_wait 返回,内核已释放该临时缓冲区

关键风险点

  • fd 数组未复制即转为 []int → 可能读取脏内存
  • unsafe.Pointer 切片持有未注册的堆指针 → GC 误回收
  • 多轮事件循环复用同一底层数组 → 数据竞争

安全生命周期管理策略

策略 实现方式 保障点
零拷贝复用 使用 sync.Pool 缓存预分配 []epollEvent 避免频繁 alloc/free
显式所有权移交 runtime.KeepAlive(&events[0]) 延长栈变量生命周期 防止提前 GC
指针隔离 unsafe.Slice 仅用于单次解析,结果立即转为 []int[]uintptr 切断 unsafe.Pointer 长期引用
// 安全解析示例(Linux epoll)
var events [64]epollEvent
n, _ := epollWait(epfd, &events[0], -1)
readyFds := make([]int, n)
for i := 0; i < n; i++ {
    readyFds[i] = int(events[i].data.fd) // 复制值,不保留 unsafe.Pointer
}
runtime.KeepAlive(&events[0]) // 告知 GC:events 数组在本作用域仍有效

此代码确保 events 栈数组在 KeepAlive 前不被编译器优化掉;readyFds 为纯值切片,完全脱离 unsafe 上下文,满足 GC 安全与并发安全双重约束。

4.4 在CGO-free环境下通过syscall.Read/Write直接操作iovec结构体的内存对齐实践

iovec结构体的Go原生布局约束

syscall.Iovec 在 Go 中需严格匹配 struct iovec 的 ABI:

  • iov_base 必须为页对齐指针(通常 uintptr(unsafe.Pointer(&buf[0]))
  • iov_len 不得超过单个切片长度,且总和不能溢出

内存对齐验证代码

// 确保 buf 起始地址按 64 字节对齐(常见最小对齐要求)
buf := make([]byte, 1024)
aligned := unsafe.AlignedOffset(unsafe.Pointer(&buf[0]), 64)
if aligned != 0 {
    panic("buffer not 64-byte aligned")
}

unsafe.AlignedOffset 检查首地址偏移量;若非零,说明未对齐,可能导致 syscall.Readv 返回 EINVAL

对齐实践要点

  • 使用 make([]byte, n+63) + buf[alignmentOffset:] 手动对齐
  • 避免 []byte*C.struct_iovec 混用(违反 CGO-free 原则)
  • syscall.Readv/Writeviovs 参数必须是连续 []syscall.Iovec 切片
字段 类型 说明
IovBase uintptr 必须指向对齐内存起始地址
IovLen uint64 实际读写长度,≤切片容量

第五章:未来演进与社区最佳实践共识

开源项目演进的真实轨迹:从 Kubernetes 1.20 到 1.29 的 API 稳定性实践

Kubernetes 社区在 v1.20 中正式弃用 PodSecurityPolicy(PSP),但实际迁移周期长达 27 个月——直到 v1.25 才完全移除。某金融级容器平台团队记录显示,其 PSP 替代方案 PodSecurity Admission 在生产环境灰度上线时,因默认策略未适配旧工作负载导致 3 类核心服务启动失败。解决方案并非简单切换配置,而是构建自动化检测流水线:通过 kubectl get pods --all-namespaces -o json | jq '.items[] | select(.spec.securityContext == null)' 批量识别风险 Pod,并结合 CI/CD 阶段的 kubeval + conftest 双校验机制,在 PR 合并前拦截不合规模板。该实践已被 CNCF SIG-Auth 收录为“渐进式策略迁移”参考案例。

多云可观测性栈的协同治理模式

下表对比了三类主流场景中 OpenTelemetry Collector 的部署策略选择:

场景类型 数据采样率 资源开销(CPU/Mem) 典型落地障碍
边缘 IoT 设备集群 1:100 0.1C/128MB TLS 证书轮换失败率 17%
混合云交易系统 1:5 1.2C/512MB Prometheus Remote Write 延迟抖动 >2s
Serverless 函数 动态采样 0.05C/64MB Lambda 冷启动触发指标丢失

某电商大促期间,通过将 OTel Collector 的 memory_limiter 配置为 limit_mib: 384 并启用 adaptive_sampler,成功将指标延迟从 3.2s 降至 420ms,同时避免因内存溢出触发 AWS Lambda 的 OOM Kill。

安全左移的工程化落地:eBPF 驱动的运行时策略验证

某支付网关团队将 Cilium 的 Runtime Policy Enforcement 与 GitOps 流水线深度集成:当开发人员提交 network-policy.yaml 时,CI 触发 cilium policy validate --dry-run 校验策略语法,并通过 ebpf-loader 加载至测试集群的 eBPF 钩子点进行真实流量模拟。以下为关键验证脚本片段:

# 在测试节点执行真实 TCP 连通性探测
for port in $(seq 8080 8085); do
  timeout 2 nc -zv 10.10.10.10 $port 2>&1 | grep "succeeded" && echo "✅ Port $port allowed"
done | tee /tmp/policy_validation.log

该流程使策略误配置导致的服务中断事件下降 89%,平均修复时间(MTTR)从 47 分钟压缩至 6 分钟。

社区驱动的标准收敛路径

CNCF TOC 在 2023 年 Q4 发布的《Cloud Native Runtime Interoperability Guidelines》已推动 12 个项目实现统一健康检查端点 /healthz 和指标路径 /metrics。某政务云平台基于该指南重构其 37 个微服务的探针逻辑,仅需修改 livenessProbe.httpGet.path 字段即可完成全部服务的就绪态对齐,避免了传统方案中因 /actuator/health/health 路径差异引发的滚动更新卡顿问题。

graph LR
A[开发者提交 Policy YAML] --> B{CI 流水线}
B --> C[语法校验]
B --> D[eBPF 预加载验证]
C --> E[静态分析报告]
D --> F[真实流量模拟]
E --> G[自动 PR 评论]
F --> G
G --> H[合并至 main 分支]
H --> I[策略自动生效]

专攻高并发场景,挑战百万连接与低延迟极限。

发表回复

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