Posted in

Golang控制外部程序必须绕开的4个POSIX陷阱:setpgid、session leader、controlling terminal、signal disposition全勘误

第一章:Golang控制外部程序的底层机制概览

Go 语言通过 os/exec 包提供了一套轻量、安全且符合 Unix 哲学的进程控制能力,其底层直接封装了操作系统级的 fork-exec-wait 范式,而非依赖 shell 解析器。这意味着 Go 启动的子进程是真正的直接子进程(非 shell wrapper),避免了命令注入风险,并确保信号传递与资源回收的可预测性。

进程创建的核心路径

调用 exec.Command() 时,Go 实际执行以下系统调用链:

  • fork() 创建新进程(共享文件描述符表但隔离内存空间)
  • execve() 加载并执行目标二进制(替换当前进程映像)
  • 父进程通过 waitpid() 或 poll() 监控子进程状态(由 Cmd.Wait() 或 Cmd.Run() 封装)

标准 I/O 的管道抽象

Go 将 stdin/stdout/stderr 统一建模为 io.ReadWriteCloser 接口,支持灵活重定向:

cmd := exec.Command("sh", "-c", "echo 'hello'; exit 42")
cmd.Stdout = os.Stdout // 直接透传到终端
cmd.Stderr = &bytes.Buffer{} // 捕获错误输出
err := cmd.Run() // 阻塞等待,返回 *exec.ExitError(若退出码非0)

注意:Run() 会自动调用 Start() + Wait();若需异步交互,应显式调用 Start() 并手动管理生命周期。

关键行为特征对比

特性 默认行为 可配置方式
工作目录 继承父进程当前目录 cmd.Dir = "/tmp"
环境变量 继承父进程全部环境 cmd.Env = append(os.Environ(), "DEBUG=1")
信号转发 不自动转发 SIGINT/SIGTERM 使用 cmd.Process.Signal(syscall.SIGINT) 显式发送

资源清理保障

Go 在 Cmd.Wait() 返回后自动关闭所有关联管道,但需注意:若未读取 stdout/stderr 缓冲区,可能导致子进程因管道满而阻塞(deadlock)。推荐始终显式处理输出流或设置 cmd.Stdout = io.Discard。

第二章:进程组与会话管理的POSIX陷阱勘误

2.1 setpgid调用时机与竞态条件:理论剖析与exec后重置实践

竞态根源:fork 与 setpgid 的时间窗口

当子进程在 fork() 后、exec() 前调用 setpgid(0, 0),若父进程同时发送信号或 shell 执行作业控制操作,可能因进程组尚未稳定导致 SIGCHLD 误判或 tcsetpgrp() 失败。

典型安全调用模式

pid_t pid = fork();
if (pid == 0) {
    // 子进程:立即脱离父进程组(避免被前台信号影响)
    if (setpgid(0, 0) == -1) {
        perror("setpgid");
        _exit(1);
    }
    execvp(argv[0], argv); // exec 后内核自动重置 pgid 为自身 pid(仅当未指定新会话)
    _exit(1);
}

setpgid(0, 0) 中参数 表示“调用进程自身”,第二个 表示“新建独立进程组,组长为本进程”。该调用必须在 exec 前完成,否则 exec 后原进程组归属丢失不可逆。

exec 后的隐式重置行为

场景 exec 前 pgid exec 后 pgid 说明
未调用 setpgid 等于父 pgid 不变 继承父进程组
已成功 setpgid 自设值(如 1234) 保持不变 POSIX 要求:exec 不改变进程组隶属
graph TD
    A[fork] --> B[子进程]
    B --> C{setpgid called?}
    C -->|Yes| D[建立新进程组]
    C -->|No| E[继承父进程组]
    D --> F[exec → pgid 固定]
    E --> F

2.2 子进程继承父进程pgid的隐式风险:Go runtime调度干扰实测分析

当 Go 程序调用 os.StartProcess 启动子进程且未显式设置 SysProcAttr.Setpgid = true 时,子进程将自动继承父进程的进程组 ID(PGID),导致信号(如 SIGINT、SIGQUIT)广播时波及非预期进程。

关键复现代码

cmd := exec.Command("sleep", "30")
cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: false, // 默认值!子进程与父进程同属一个进程组
}
cmd.Start()

Setpgid: false 是默认行为,易被忽略;若父进程在 pgid=1234 的组中,子进程也将归属 1234,kill -INT -1234 会同时终止父子进程。

Go runtime 调度干扰现象

  • Go 的 GOMAXPROCS 并发模型下,runtime.sigsend 信号分发路径与 syscalls 交互紧密;
  • 同 PGID 下子进程接收 SIGURG 或 SIGCHLD 可能触发 runtime 的 goroutine 抢占检查,造成调度延迟抖动(实测 P95 延迟上升 12–18ms)。

风险对比表

场景 子进程 PGID 信号隔离性 runtime 抢占干扰
Setpgid=false(默认) 继承父进程 ❌ ✅(高频)
Setpgid=true 独立新 PGID ✅ ❌
graph TD
    A[父进程 fork] --> B{Setpgid?}
    B -->|false| C[子进程 pgid = 父pgid]
    B -->|true| D[子进程 pgid = 自身 pid]
    C --> E[信号广播影响 runtime]
    D --> F[信号作用域隔离]

2.3 创建独立进程组的正确姿势:SysProcAttr.Setpgid + fork-exec原子性验证

在 Go 中启动子进程并使其成为新进程组组长,需同时满足 Setpgid=true 与 fork-exec 原子性——即 fork 后立即调用 setpgid(0, 0),不可延迟至 exec 后。

关键约束条件

  • Setpgid = true 仅在 SysProcAttr 中声明,实际由 runtime 在 fork 返回父进程前、子进程 exec 前插入 setpgid(0, 0) 系统调用;
  • 若省略或设为 false,子进程继承父进程组 ID(PGID),无法实现信号隔离。

正确代码示例

cmd := exec.Command("sleep", "10")
cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: true, // 必须显式启用
}
if err := cmd.Start(); err != nil {
    log.Fatal(err)
}

✅ Start() 内部触发 fork → setpgid(0,0) → exec 三步原子链;
❌ 若手动 syscall.ForkExec 且遗漏 setpgid 调用,将导致 PGID 继承,失去进程组独立性。

原子性验证方法

验证项 方法
PGID 是否独立 ps -o pid,ppid,pgid,sid,comm
是否可被组信号终止 kill -TERM -${PGID} 测试
graph TD
    A[Start()] --> B[fork syscall]
    B --> C[子进程:setpgid\\n0,0]
    C --> D[子进程:exec]
    B --> E[父进程:返回]

2.4 session leader身份误判导致信号丢失:Go中setsid()调用边界与errno处理

为何setsid()可能静默失败?

在Go中调用syscall.Setsid()时,若当前进程已是session leader,系统会返回-1并置errno = EPERM,但Go标准库未暴露该错误——syscall.Setsid()仅返回error接口,而实际实现中常忽略EPERM(误判为“已成功”)。

典型误用代码

// 错误示例:未检查EPERM,误将失败当作成功
if err := syscall.Setsid(); err != nil {
    log.Printf("unexpected error: %v", err) // EPERM被吞没或转为nil
}

syscall.Setsid()底层调用Linux setsid(2),仅当进程非session leader时才创建新session;否则返回-1且errno=EPERM。Go运行时未将EPERM映射为非nil error,导致上层逻辑误认为“已脱离控制终端”,后续SIGCHLD等信号可能被父session拦截而丢失。

errno处理建议

  • 使用unix.Setsid()(golang.org/x/sys/unix)替代syscall.Setsid(),它显式返回errno
  • 检查err == unix.EPERM以区分“已是leader”与真正错误
场景 errno 含义 是否应重试
首次调用且非leader 0 成功创建session —
已是session leader EPERM 无需操作,身份合法 ❌
权限不足/其他错误 EACCES, EFAULT 真实异常 ✅
graph TD
    A[调用 unix.Setsid()] --> B{errno == 0?}
    B -->|是| C[成功:新session]
    B -->|否| D{errno == EPERM?}
    D -->|是| E[已是leader:继续执行]
    D -->|否| F[真实错误:记录并退出]

2.5 进程组leader退出引发子进程收养异常:ppid追踪与WaitStatus解析实战

当进程组 leader(通常为会话首进程)意外终止,内核需重新分配子进程的父进程(PPID),但若收养逻辑触发时机与 waitpid() 调用存在竞态,将导致 WaitStatus 解析失真。

子进程PPID漂移现象

  • 内核将孤儿进程统一收养至 init(PID 1)或 subreaper 进程
  • 若收养发生在 waitpid() 阻塞期间,WIFEXITED(status) 可能返回 false,而 WTERMSIG(status) 却非零

WaitStatus字段语义对照表

字段宏 含义说明 典型值示例
WIFEXITED(s) 正常退出(exit/syscall) true
WEXITSTATUS(s) 退出码(仅当 WIFEXITED 为真) , 127
WIFSIGNALED(s) 被信号终止 true(如 SIGKILL)
#include <sys/wait.h>
#include <unistd.h>
pid_t pid = waitpid(-1, &status, WUNTRACED | WCONTINUED);
if (pid > 0) {
    if (WIFEXITED(status)) 
        printf("exited, code=%d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status)) 
        printf("killed by signal %d\n", WTERMSIG(status)); // 注意:此处可能误判孤儿进程被 init 收养后的伪信号状态
}

上述代码中,若子进程在 waitpid() 返回前已被 init 收养并静默清理,status 可能残留前次 SIGCHLD 的脏数据;应配合 SIGCHLD handler + SA_RESTART 标志增强鲁棒性。

graph TD
    A[Leader进程exit] --> B[内核标记其子进程为orphan]
    B --> C{init/subreaper扫描孤儿队列}
    C --> D[调用adopt_children]
    D --> E[修改子进程task_struct.ppid]
    E --> F[发送SIGCHLD给新父进程]

第三章:控制终端(controlling terminal)的接管与剥离

3.1 /dev/tty绑定与ctty分配机制:POSIX规范与Linux内核实现差异

POSIX要求每个会话(session)有且仅有一个控制终端(controlling terminal),通过/dev/tty设备文件提供进程访问入口;但未规定其绑定时机与内核实现路径。

ctty分配的触发条件

  • 调用ioctl(fd, TIOCSCTTY, 1)(非会话首进程需加1参数)
  • 会话首进程(session leader)首次打开终端设备且未绑定ctty
  • setsid()后首次打开终端(Linux特有宽松策略)

内核关键路径(drivers/tty/tty_io.c)

// tty_set_session() 中的关键判断
if (!tty->session) {
    tty->session = current->signal->session; // 绑定当前会话
    tty->pgrp = current->pgrp;              // 同步前台进程组
    __proc_set_tty(current, tty);           // 建立进程→tty反向引用
}

该逻辑确保/dev/tty在open()时动态解析为当前进程的ctty,而非静态设备节点映射。

POSIX要求 Linux实现
ctty必须存在且唯一 允许无ctty的会话(如守护进程)
ioctl(TIOCSCTTY)仅限session leader 支持非leader调用(需1参数)
graph TD
    A[进程open /dev/tty] --> B{是否已绑定ctty?}
    B -- 否 --> C[查找当前会话的tty]
    C --> D[设置tty->session/pgrp]
    D --> E[返回对应tty_struct]
    B -- 是 --> E

3.2 Go exec.Cmd默认继承ctty的隐患:nohup场景下输出截断复现实验

现象复现

以下最小可复现代码模拟 nohup 下 exec.Cmd 的输出截断:

cmd := exec.Command("sh", "-c", "for i in {1..1000}; do echo $i; sleep 0.001; done")
cmd.Stdout = os.Stdout // 继承父进程ctty
err := cmd.Run()

⚠️ 当父进程在 nohup 下运行(无控制终端),cmd.Stdout 实际绑定到已关闭的 /dev/tty,导致 write(2) 系统调用随机返回 EIO 或静默丢弃后续输出。

关键差异对比

场景 Stdout 行为 典型错误码
普通终端启动 正常写入 —
nohup ./a.out & 写入中途失败/截断 EIO
显式重定向到文件 完整输出(推荐) —

修复方案

  • ✅ 总是显式设置 cmd.Stdout 为文件或 io.Discard
  • ✅ 使用 cmd.SysProcAttr.Setctty = false(Linux)禁用ctty继承
f, _ := os.Create("out.log")
defer f.Close()
cmd.Stdout = f // 脱离ctty依赖

该赋值绕过 ctty 依赖链,确保 write 系统调用始终作用于稳定文件描述符。

3.3 主动释放控制终端的可靠方案:ioctl(TIOCNOTTY)封装与错误恢复路径

核心原理

ioctl(fd, TIOCNOTTY) 是 POSIX 标准中唯一可移植的主动解绑控制终端(controlling terminal)的系统调用。它不依赖进程会话状态,直接清除内核中 tty->session 和 tty->pgrp 关联。

封装实现

#include <unistd.h>
#include <sys/ioctl.h>
#include <errno.h>

int detach_controlling_tty(void) {
    // 使用负值fd触发内核检查当前进程是否拥有ctty
    if (ioctl(-1, TIOCNOTTY) == 0) return 0;  // 成功
    if (errno == ENOTTY) return 0;             // 本无ctty,视为成功
    return -1;                                 // 其他错误(如权限不足)
}

逻辑分析:传入 fd = -1 是标准惯用法——内核 sys_ioctl() 会忽略 fd 值,直接对调用进程执行 no_tty() 操作;ENOTTY 表示进程本无控制终端,属预期状态,应归为成功路径。

错误恢复策略

  • EPERM:进程非会话首进程(session leader),需先 setsid() 创建新会话
  • EIO:终端设备忙,建议退避重试(最多3次,指数回退)
错误码 含义 推荐动作
ENOTTY 无控制终端 忽略,继续流程
EPERM 非会话首进程 setsid() 后重试
EIO 设备I/O临时阻塞 指数退避重试
graph TD
    A[调用 ioctl\\(-1, TIOCNOTTY\\)] --> B{返回值?}
    B -->|0| C[成功]
    B -->|−1| D{errno?}
    D -->|ENOTTY| C
    D -->|EPERM| E[setsid\\(\\) → 重试]
    D -->|EIO| F[usleep\\(100ms\\) → 重试]
    D -->|其他| G[失败终止]

第四章:信号处置(signal disposition)的跨语言传递失真

4.1 Go runtime信号拦截对子进程sigmask的污染:SIGCHLD与SIGPIPE传播链路测绘

Go runtime 默认接管 SIGCHLD 和 SIGPIPE,并通过 sigprocmask 修改线程级信号掩码(sigmask),该行为会继承至 fork 后的子进程——违反 POSIX 语义。

关键传播路径

  • Go 程序启动时,runtime 调用 signal.init() → signal.enableSigChld()
  • rt_sigprocmask(SIG_SETMASK, &newset, ...) 将 SIGCHLD 加入阻塞集
  • fork() 后子进程完整继承父进程 sigmask(Linux kernel 保证)

污染验证代码

package main
import "os/exec"
func main() {
    // 触发 fork+exec,子进程将继承被污染的 sigmask
    cmd := exec.Command("sh", "-c", "kill -0 $$ 2>/dev/null && echo 'SIGCHLD unblocked' || echo 'SIGCHLD blocked'")
    cmd.Stdout = os.Stdout
    cmd.Run()
}

此代码中子 shell 的 SIGCHLD 实际处于阻塞态(即使未显式调用 sigprocmask),因 Go runtime 已在主线程中设为 SIG_BLOCK 并被 fork 复制。

信号传播链路

graph TD
A[Go runtime init] --> B[rt_sigprocmask(SIG_SETMASK, {SIGCHLD|SIGPIPE})]
B --> C[fork()]
C --> D[子进程 sigmask 继承阻塞态]
D --> E[execve 后仍保留阻塞位]
信号 runtime 动作 子进程风险
SIGCHLD 阻塞 + 自行 reap 子进程无法自主 wait/waitpid
SIGPIPE 忽略(SIG_IGN) 管道写端关闭后不触发终止

4.2 exec前signal.Reset的局限性:fork后sigprocmask同步缺失的补救策略

signal.Reset 仅重置信号处理函数,不恢复信号掩码(signal mask)。fork 后子进程继承父进程的 sigprocmask 状态,但 exec 启动新程序时,内核不会自动同步该掩码——导致预期屏蔽的信号可能意外递达。

数据同步机制

需在 exec 前显式调用 pthread_sigmask 或 sigprocmask 恢复目标掩码:

// 在 fork 后、exec 前执行
var oldMask syscall.SignalMask
syscall.Sigprocmask(syscall.SIG_SETMASK, &[]uint64{0x0}, &oldMask) // 清空掩码
// 或设置特定掩码:[]uint64{1 << (syscall.SIGCHLD - 1)}

逻辑分析:Sigprocmask(syscall.SIG_SETMASK, ...) 直接覆盖当前线程信号掩码;参数 []uint64{0x0} 表示 64 位全零掩码(即无信号被屏蔽),适用于需“清空继承掩码”的场景。

补救策略对比

方法 是否跨平台 是否影响线程安全 适用阶段
sigprocmask ✅ Linux ❌ 仅调用线程生效 fork 后 exec 前
pthread_sigmask ✅ POSIX ✅ 线程粒度控制 多线程子进程
graph TD
    A[fork] --> B[子进程继承父sigprocmask]
    B --> C{exec前显式重置?}
    C -->|否| D[信号掩码残留→行为不可控]
    C -->|是| E[调用sigprocmask/pthread_sigmask]
    E --> F[掩码按预期生效]

4.3 SIGSTOP/SIGCONT在Go子进程中的不可靠唤醒:ptrace调试与tcsetpgrp交叉验证

Go运行时对信号处理有特殊调度逻辑,SIGSTOP/SIGCONT 在 fork-exec 启动的子进程中常因 goroutine 调度抢占而丢失唤醒时机。

ptrace拦截验证

// 使用ptrace附加子进程,捕获SIGSTOP交付点
ptrace(PTRACE_ATTACH, pid, 0, 0);
waitpid(pid, &status, 0);
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGSTOP) {
    printf("SIGSTOP delivered at %d\n", pid);
}

PTRACE_ATTACH 强制暂停目标并同步信号队列;WSTOPSIG 提取真实停止信号源,排除 Go runtime 自行伪造的伪停止。

tcsetpgrp交叉校验

终端组ID Go子进程状态 tcsetpgrp返回值 可靠性
未变更 挂起但未响应 0 ❌
显式设置 唤醒后就绪 -1 + EPERM ✅

信号唤醒竞态路径

graph TD
    A[Go runtime fork] --> B[子进程exec]
    B --> C{是否已启动goroutine调度器?}
    C -->|否| D[内核级SIGSTOP立即生效]
    C -->|是| E[被runtime信号屏蔽或延迟投递]
    E --> F[tcsetpgrp失败→前台组未更新]

根本原因在于:Go 1.20+ 默认启用 GODEBUG=asyncpreemptoff=1 时,SIGCONT 可能被 runtime 信号掩码阻塞,且 tcsetpgrp() 不触发内核唤醒,导致终端控制权无法移交。

4.4 信号继承语义的Go标准库缺陷:os/exec中Signal字段未覆盖的POSIX行为盲区

POSIX信号继承的隐式契约

POSIX规定:子进程默认继承父进程的信号处理动作(SIG_DFL/SIG_IGN),但不继承SIG_HOLD状态或sigprocmask屏蔽集。os/exec.Cmd的Signal字段仅用于显式发送信号,却未干预子进程的初始信号掩码与处置。

Go标准库的盲区表现

cmd := exec.Command("sleep", "10")
cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: true,
}
// ❌ 未设置SigMask → 子进程沿用父进程当前sigprocmask

该代码未调用syscall.Setpgid()配套的syscall.Sigmask清理,导致子进程可能意外屏蔽SIGINT等关键信号。

关键差异对比

行为 POSIX要求 os/exec实际行为
初始信号掩码 清空(全解除屏蔽) 继承父进程当前sigprocmask
SIGCHLD处置 默认SIG_DFL 受signal.Ignore(SIGHUP)等全局影响
graph TD
    A[父进程调用exec.Command] --> B[内核fork]
    B --> C[子进程继承父sigprocmask]
    C --> D[Go未调用sigprocmask\\n重置信号掩码]
    D --> E[POSIX违规:信号屏蔽非空]

第五章:构建健壮外部程序控制器的工程化建议

容错设计与进程生命周期管理

在生产环境中,外部程序(如 FFmpeg、curl、Python CLI 工具)可能因资源不足、信号中断或版本不兼容而异常退出。某视频转码平台曾因未捕获 SIGCHLD 导致僵尸进程堆积,最终触发系统 PID limit exceeded 错误。推荐采用双层守护机制:父进程通过 waitpid(..., WNOHANG) 主动收割子进程,并配合 prctl(PR_SET_CHILD_SUBREAPER, 1) 确保孤儿进程被可靠回收。同时,对返回码做语义化分类——例如 FFmpeg 的 255 表示内部错误,1 表示参数错误,需分别重试或告警。

资源隔离与超时控制

避免外部程序耗尽宿主机资源。使用 cgroups v2 限制 CPU 时间片与内存上限(示例配置):

# 创建控制器组并设置限制
sudo mkdir -p /sys/fs/cgroup/external-ctrl
echo "max 500M" | sudo tee /sys/fs/cgroup/external-ctrl/memory.max
echo "cpu.max 100000 100000" | sudo tee /sys/fs/cgroup/external-ctrl/cpu.max

配合 timeout --signal=SIGTERM 30s 命令封装,确保单次调用最长执行 30 秒;若超时则发送 SIGTERM,等待 3 秒后强制 SIGKILL。

输入输出安全与路径规范化

某金融系统曾因用户上传含 ../etc/passwd 的文件名导致路径遍历漏洞。控制器必须对所有外部输入执行双重校验:先用 os.path.realpath() 解析绝对路径,再比对白名单根目录。以下为 Python 核心校验逻辑片段:

def safe_resolve_path(user_input: str, base_dir: Path) -> Path:
    resolved = (base_dir / user_input).resolve()
    if not str(resolved).startswith(str(base_dir.resolve())):
        raise ValueError("Path traversal attempt detected")
    return resolved

异步调度与批量任务队列

高并发场景下,直接 fork 大量子进程将引发 fork() 风暴。某日志分析服务改用基于 Redis 的优先级队列(redis-py + priority queue),结合 concurrent.futures.ProcessPoolExecutor(max_workers=4) 控制并发度。任务状态流转如下:

flowchart LR
A[任务提交] --> B{队列容量检查}
B -->|空闲| C[分配Worker执行]
B -->|满载| D[写入Redis延时队列]
C --> E[记录start_time+pid]
E --> F[监控stdout/stderr流]
F --> G[成功→存档结果]
F --> H[失败→触发重试策略]

日志审计与可观测性增强

每个外部调用必须生成结构化日志条目,包含唯一 trace_id、命令完整字符串(脱敏敏感参数)、实际执行耗时、exit_code、stdout/stderr 截断摘要(前200字符)。某电商订单同步系统通过 ELK 栈聚合日志后,发现 curl 调用中 12% 因 DNS 缓存失效导致 3.2s 延迟,遂引入 --dns-interface 显式绑定网卡。

监控指标 采集方式 告警阈值 数据来源
平均启动延迟 clock_gettime(CLOCK_MONOTONIC) >800ms 控制器内埋点
子进程存活率 ps aux \| grep <proc_name> \| wc -l 定时巡检脚本
标准错误率 stderr_bytes / total_output_bytes >15% 流式解析日志

版本兼容性矩阵管理

不同环境部署的外部工具版本差异显著。某 CI/CD 流水线因 Ubuntu 22.04 默认 ffmpeg 5.1 与旧版 -vf scale 语法不兼容导致批量失败。解决方案是建立版本映射表,并在控制器初始化时动态加载适配器:

ffmpeg:
  "4.4": { scale_syntax: "-vf scale=w=640:h=480" }
  "5.1": { scale_syntax: "-vf scale=640:480" }
  "6.0": { scale_syntax: "-vf scale='640:480'" }

故障注入与混沌测试验证

在预发环境定期执行故障注入:随机 kill 指定 PID、模拟磁盘满(dd if=/dev/zero of=/tmp/fill bs=1G count=10)、篡改 /proc/sys/kernel/pid_max。某支付对账服务通过此方法暴露了未处理 OSError: [Errno 12] Cannot allocate memory 的缺陷,后续增加内存预留检查逻辑。

从入门到进阶,系统梳理 Go 高级特性与工程实践。

发表回复

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