Posted in

Go 1.22新增fs.FileMode常量深度解读(含POSIX/SIGWINCH兼容性警告)

第一章:Go 1.22 fs.FileMode常量演进全景概览

Go 1.22 对 fs.FileMode 的常量定义进行了系统性重构,核心目标是提升跨平台一致性、明确权限语义边界,并为未来扩展预留空间。此前版本中,os.FileMode(底层与 fs.FileMode 兼容)的位掩码常量(如 0755)虽被广泛使用,但其符号常量(如 os.ModeDir, os.ModePerm)在语义抽象和类型安全层面存在模糊性——例如 os.ModePerm 仅表示用户/组/其他三段权限的掩码(0o777),却不区分 POSIX 与 Windows ACL 行为。

文件模式常量的语义分层

Go 1.22 将 fs.FileMode 常量划分为三个逻辑层级:

  • 基础类型标志:fs.ModeDir, fs.ModeSymlink, fs.ModeNamedPipe 等,保持原有语义,用于识别文件类别;
  • 权限位抽象:新增 fs.PermUser, fs.PermGroup, fs.PermOther,分别对应 0o700, 0o070, 0o007,替代硬编码八进制值;
  • 组合便捷常量:fs.PermRead, fs.PermWrite, fs.PermExecute 提供单权限位(0o400, 0o200, 0o100),支持类型安全的按位或操作。

迁移兼容性保障

标准库已全面适配新常量,旧代码仍可编译运行,但编译器会发出弃用警告(如 os.ModePerm)。推荐迁移方式如下:

// ✅ 推荐:使用新常量,语义清晰且跨平台一致
mode := fs.ModeDir | fs.PermUser|fs.PermGroup|fs.PermRead|fs.PermExecute

// ❌ 不推荐:混合使用易引发混淆
mode := os.ModeDir | 0o755 // 编译通过但触发 warning

关键变更对照表

旧常量(Go ≤1.21) 新常量(Go 1.22+) 说明
os.ModePerm fs.PermUser | fs.PermGroup | fs.PermOther 显式表达三段权限
os.ModeSetuid fs.ModeSetuid(保留) 类型标志未变更
0o644(字面量) fs.PermUser|fs.PermRead|fs.PermWrite 避免魔法数字

此演进并非简单别名替换,而是通过 fs.FileMode 类型约束与常量作用域隔离,从根本上防止权限位误用(如将目录标志与权限位直接 | 混合),同时为 Windows NTFS 或 WASI 等非 POSIX 文件系统扩展留出接口空间。

第二章:POSIX权限模型与Go FileMode的底层映射机制

2.1 FileMode位掩码结构解析:os.ModeDir/os.ModePerm/os.ModeType的二进制布局

Go 的 os.FileMode 是一个 uint32 类型,通过位掩码(bitmask)复用单个整数表达文件类型、权限与特殊标志。

核心常量的二进制分布(低12位关键区)

常量 十六进制 二进制(低12位) 含义
os.ModeDir 0x4000 0100 0000 0000 0000 目录标识(第15位)
os.ModePerm 0x01FF 0000 0001 1111 1111 权限掩码(低9位)
os.ModeType 0xF000 1111 0000 0000 0000 类型标志总掩码
const (
    ModeDir        = 0x4000 // 1 << 14
    ModePerm       = 0x1ff  // 权限位:user(3)+group(3)+other(3)
    ModeType       = 0xf000 // 类型位:Dir/Symlink/NamedPipe 等共存
)

此定义确保 ModeDir & ModeType != 0,且 fi.Mode()&ModePerm 可安全提取 Unix 权限(如 0755),而 fi.Mode()&ModeDir == ModeDir 判断目录。位运算无冲突,支持组合(如 0x8000 | 0x1ed 表示符号链接+可读写执行)。

位域协同示意

graph TD
    A[FileMode uint32] --> B[高16位:类型标志]
    A --> C[低9位:权限位]
    B --> D[ModeDir 0x4000]
    B --> E[ModeSymlink 0x2000]
    C --> F[User rwx: bits 8-6]
    C --> G[Group rwx: bits 5-3]
    C --> H[Other rwx: bits 2-0]

2.2 新增常量源码级对照:os.ModeSticky、os.ModeIrregular等在runtime/fs.go中的定义溯源

Go 1.23 引入的文件系统权限常量(如 os.ModeSticky、os.ModeIrregular)并非定义在 os 包,而是由 runtime/fs.go 统一提供底层位掩码:

// runtime/fs.go(精简示意)
const (
    ModeSticky = fs.ModeSticky // 0x800
    ModeIrregular = fs.ModeIrregular // 0x40000
)

逻辑分析:runtime/fs.go 通过 fs. 前缀复用 internal/fs 的枚举定义,确保 os.FileMode 与运行时文件系统抽象严格对齐;ModeSticky 对应 POSIX S_ISVTX,ModeIrregular 标识非标准文件类型(如 socket、device)。

关键常量映射关系:

常量名 十六进制值 语义说明
ModeSticky 0x800 目录粘滞位(/tmp 安全)
ModeIrregular 0x40000 非常规文件(非普通/目录/符号链接)

这些常量在 os 包中以别名形式导出,实现跨包一致性与编译期常量折叠。

2.3 chmod系统调用路径追踪:从syscall.Syscall(SYS_chmod)到fs.FileMode.IsRegular()的执行链分析

系统调用入口层

// syscall.Syscall(SYS_chmod, uintptr(unsafe.Pointer(&path)), uintptr(mode), 0)
// path: C字符串地址(null-terminated);mode: uint32,如 0644 → fs.FileMode(0644)

SYS_chmod 触发内核态切换,参数经 ABI 传递至 sys_chmod(),最终映射为 kern_chmod()。

VFS 层解析

// fs/inode.go 中的 chmod 实现节选
func (i *Inode) Chmod(mode FileMode) error {
    if !mode.IsRegular() && !mode.IsDir() && !mode.IsSymlink() {
        return ErrInvalidMode
    }
    i.mode = mode.Perm() // 提取权限位(0777 mask)
    return nil
}

FileMode.IsRegular() 是位运算判断:(m & ModeType) == ModeRegular,仅检查类型位,不依赖文件系统实际状态。

执行链关键节点

阶段 函数/方法 关键动作
用户态 os.Chmod() 封装 syscall.Syscall(SYS_chmod)
内核态 vfs_chmod() 解析 inode,校验权限
抽象层 fs.FileMode.IsRegular() 位掩码判断,无 I/O 开销
graph TD
    A[syscall.Syscall SYS_chmod] --> B[kern_chmod]
    B --> C[vfs_chmod]
    C --> D[Inode.Chmod]
    D --> E[FileMode.IsRegular]

2.4 跨平台权限一致性验证:Linux/macOS/Windows下0o755与fs.ModePerm|fs.ModeUserWrite的实际行为差异实测

实测环境与关键发现

在 Go 1.22+ 中,os.Chmod("file", 0o755) 与 os.Chmod("file", fs.ModePerm|fs.ModeUserWrite) 在三平台表现迥异:

平台 0o755 行为 `fs.ModePerm fs.ModeUserWrite` 行为
Linux 精确设为 rwxr-xr-x(八进制 755) 等效于 0o777(因 fs.ModePerm == 0o777)
macOS 同 Linux 同 Linux(但 ACL 可能叠加)
Windows 仅控制只读位(+w → 可写,-w → 只读) 忽略所有位,仅映射 ModeUserWrite 到可写状态
// 示例:跨平台 chmod 行为对比
err := os.Chmod("test.txt", fs.ModePerm|fs.ModeUserWrite)
if err != nil {
    log.Fatal(err) // Windows 下此调用不报错,但权限无变化
}

逻辑分析:fs.ModePerm 在 io/fs 包中定义为 0o777(即所有用户全权限),与 fs.ModeUserWrite(0o200)按位或后仍为 0o777;而 0o755 是硬编码八进制字面量,语义明确。Windows 文件系统无 POSIX 权限模型,Go 运行时仅将 ModeUserWrite 映射为 FILE_ATTRIBUTE_NORMAL/FILE_ATTRIBUTE_READONLY 切换。

权限映射本质差异

  • Linux/macOS:基于 inode 的 st_mode 位精确控制
  • Windows:仅保留 readonly 二元状态,其余位被静默忽略
graph TD
    A[Go chmod 调用] --> B{OS 类型}
    B -->|Linux/macOS| C[解析 mode 位 → chmod syscall]
    B -->|Windows| D[提取 ModeUserWrite → SetFileAttributes]
    C --> E[支持 755/777 等完整权限]
    D --> F[仅 toggle READONLY flag]

2.5 旧版代码迁移陷阱:从os.FileMode(0755)到fs.ModeUserRead|fs.ModeUserWrite|fs.ModeUserExec的语义转换实践

Go 1.16 引入 io/fs 包后,os.FileMode 的底层语义发生根本性变化:八进制字面量(如 0755)不再直接等价于权限位组合,而是被解释为包含文件类型标志(如 ModeDir, ModeSymlink)的复合值。

权限构造方式对比

旧写法(已弃用) 新写法(推荐) 语义差异
os.FileMode(0755) fs.ModeUserRead | fs.ModeUserWrite | fs.ModeUserExec 后者显式排除类型标志,仅表达权限意图

迁移风险点

  • 0755 & os.ModeType != 0 → 实际隐含 ModeDir(因高位被误设)
  • os.Chmod(path, 0755) 在新 fs.FS 实现中可能被截断或忽略类型位
// ❌ 危险:0755 包含隐式 ModeDir(0x8000),导致非目录文件 chmod 失效
err := os.Chmod("script.sh", os.FileMode(0755))

// ✅ 安全:纯权限位,与文件类型解耦
mode := fs.ModeUserRead | fs.ModeUserWrite | fs.ModeUserExec
err := os.Chmod("script.sh", os.FileMode(mode))

逻辑分析:fs.ModeUserRead(0x0100)等常量定义在 io/fs 中,其值与 os 包中同名常量数值相同但语义更精确;强制使用 os.FileMode() 转换是必要桥接,但源头必须用 fs.* 常量确保位域纯净。

graph TD A[旧代码: 0755] –> B{是否含ModeDir?} B –>|是| C[chmod 对普通文件静默失败] B –>|否| D[需手动屏蔽高位: 0755 &^ os.ModeType] D –> E[推荐: 直接使用 fs.Mode* 组合]

第三章:SIGWINCH兼容性危机:终端信号与文件模式的隐式耦合

3.1 SIGWINCH信号处理机制与文件描述符状态的意外交互场景复现

当终端窗口调整大小时,内核向前台进程组发送 SIGWINCH(Signal Window Change)。该信号默认被忽略,但若进程注册了自定义 handler,在 handler 中执行 I/O 操作(如 write() 到已关闭的 fd)可能触发未预期行为。

数据同步机制

SIGWINCH handler 中若调用 write(STDOUT_FILENO, ...),而此时 stdout 已被 close() 或重定向失效:

void sigwinch_handler(int sig) {
    // ⚠️ 危险:未检查 fd 状态
    write(STDOUT_FILENO, "\033[2J\033[H", 11); // ANSI 清屏序列
}

逻辑分析:write() 返回 -1 并置 errno=EBADF,但 handler 未检查返回值;若此前 stdout 被 dup2(/dev/null, 1) 关闭,该写操作静默失败,后续依赖终端尺寸的渲染逻辑(如 ncurses refresh())将基于陈旧 ioctl(TIOCGWINSZ) 结果出错。

典型交互链路

graph TD
    A[用户缩放终端] --> B[内核发送 SIGWINCH]
    B --> C[进入自定义 handler]
    C --> D[调用 write() 到已失效 stdout]
    D --> E[errno=EBADF 但被忽略]
    E --> F[后续 winsize 缓存未更新 → 渲染越界]

安全实践清单

  • 在 signal handler 中仅设置 volatile flag,由主循环响应;
  • 所有 fd 操作前必须 fcntl(fd, F_GETFD) 验证有效性;
  • 使用 sigprocmask() 临时屏蔽 SIGWINCH 以保护临界区。

3.2 Go runtime对SIGWINCH的默认屏蔽策略及其对openat()系统调用返回值的影响分析

Go runtime 在启动时自动屏蔽 SIGWINCH(窗口大小改变信号),以避免 goroutine 调度器被意外中断。该屏蔽通过 sigprocmask(SIG_SETMASK, &set, nil) 在 runtime.osinit() 中完成,影响所有后续系统调用的信号上下文。

SIGWINCH 屏蔽的传播路径

  • runtime·osinit → runtime·siginit → sigfillset(&sigset) → sigdelset(&sigset, SIGWINCH)
  • 屏蔽后,openat() 等系统调用不再因 SIGWINCH 被中断重试(即不返回 -EINTR)

openat() 行为对比表

场景 是否屏蔽 SIGWINCH openat() 返回值示例 原因
C 程序(默认) 否 -EINTR(偶发) 信号中断后内核重试逻辑触发
Go 程序(默认) 是 或 -ENOENT 无 SIGWINCH 中断路径,调用原子完成
// 示例:Go 中 openat 的典型调用链(简化)
fd, err := unix.Openat(unix.AT_FDCWD, "/tmp/file", unix.O_RDONLY, 0)
// 注意:此处 err 不会是 syscall.EINTR —— 因 SIGWINCH 已被 runtime 全局屏蔽

上述调用中,unix.Openat 最终触发 SYS_openat 系统调用;由于 SIGWINCH 不在进程 signal mask 中可挂起,内核不会在 openat 执行中途注入该信号,从而规避了 ERESTARTSYS 重入路径。

graph TD A[Go runtime init] –> B[调用 sigprocmask] B –> C[屏蔽 SIGWINCH] C –> D[openat 系统调用] D –> E[跳过 signal-check 分支] E –> F[直接返回成功/失败码]

3.3 FileMode常量变更引发的errno.EINTR传播路径重构验证

核心问题定位

os.open() 在 O_NONBLOCK 模式下遭遇信号中断时,原逻辑未区分 EINTR 与 EAGAIN,导致 FileMode 常量(如 stat.S_IRUSR | stat.S_IWUSR)被错误地重用于 errno 判定分支。

关键代码修正

# 修复前(错误复用 FileMode 位掩码逻辑)
if err.errno == errno.EINTR and flags & os.O_NONBLOCK:
    continue  # ❌ FileMode 常量与 errno 语义冲突

# 修复后(显式 errno 分离)
if err.errno == errno.EINTR and not (flags & os.O_NONBLOCK):
    return retry_open(path, flags, mode)  # ✅ mode 仅用于权限设置,不参与 errno 分支

逻辑分析:mode 参数(如 0o600)仅控制文件权限位,与系统调用中断无关;flags 才决定阻塞行为。原实现误将 mode 的位运算逻辑迁移至 errno 分支,造成 EINTR 被静默吞没。

传播路径对比

阶段 旧路径 新路径
系统调用返回 open() → EINTR open() → EINTR
Python 层处理 OSError → 被 mode & ... 误判 OSError → 显式 err.errno == EINTR

重构验证流程

graph TD
    A[open syscall] --> B{errno == EINTR?}
    B -->|Yes| C[检查 flags & O_NONBLOCK]
    C -->|False| D[重试 open]
    C -->|True| E[raise OSError]
    B -->|No| F[按原逻辑处理]

第四章:生产环境权限治理最佳实践

4.1 安全敏感型应用的最小权限原则:基于fs.ModeDir|fs.ModeSymlink|fs.ModeNamedPipe的细粒度控制方案

在文件系统访问控制中,os.FileMode 的位组合是实现最小权限落地的关键。仅依赖 0755 等传统八进制掩码易导致过度授权,而 fs.ModeDir | fs.ModeSymlink | fs.ModeNamedPipe 可精准表达“仅允许目录遍历、符号链接解析与命名管道通信”这一策略边界。

文件类型白名单校验逻辑

func isAllowedMode(mode fs.FileMode) bool {
    return mode&^(fs.ModeDir|fs.ModeSymlink|fs.ModeNamedPipe) == 0
}

该函数通过按位取反后与操作,验证输入模式是否严格限定于三类安全许可位——任何额外位(如 fs.ModeDevice 或 fs.ModeSocket)将使结果非零,从而拒绝访问。

典型受限场景对照表

场景 允许 原因
/tmp/mydir/ ✅ ModeDir 匹配
/etc/passwd → /dev/null ✅ ModeSymlink 显式授权
/run/db.sock ❌ 含 ModeSocket,被拦截

权限决策流程

graph TD
    A[读取文件元数据] --> B{mode &^ allowedMask == 0?}
    B -->|是| C[放行]
    B -->|否| D[拒绝并记录审计事件]

4.2 Docker容器内FileMode行为差异诊断:overlayfs层对os.ModeDevice位的特殊解释处理

Docker默认使用 overlayfs 作为存储驱动,其对 os.FileMode 中 os.ModeDevice(值为 0x8000)的解释与宿主机 ext4/xfs 不同:overlayfs 将该位映射为“底层设备节点是否可被容器内访问”,而非传统意义上的块/字符设备标识。

核心差异表现

  • 宿主机上 mknod /tmp/test c 1 3 生成的字符设备,其 Mode() 返回含 os.ModeDevice
  • 在 overlayfs mount 的容器中 stat /tmp/test,st_mode 仍含 0x8000,但 os.FileInfo.Mode() 不保留 os.ModeDevice 位

验证代码

// 检测容器内 FileMode 解析偏差
fi, _ := os.Stat("/tmp/test")
mode := fi.Mode()
fmt.Printf("Raw mode: %04o\n", mode)           // 输出如 0600(丢失 0x8000)
fmt.Printf("Has ModeDevice: %t\n", mode&os.ModeDevice != 0) // 始终 false

逻辑分析:overlayfs 在 dentry->d_inode->i_mode 转换为 struct stat 时,会过滤掉 S_IFBLK/S_IFCHR 对应的 0x6000 及 os.ModeDevice(0x8000),仅保留权限位。参数 mode&os.ModeDevice 实际检测的是内核 i_mode & S_IFMT 的子集,而 overlayfs 层主动清除了该标志。

场景 宿主机 Mode() 容器内 Mode() 是否触发 os.ModeDevice
mknod b /dev/test 0x6000 0x0000 ❌
touch /tmp/file 0x81a4 0x81a4 ✅(仅权限位透传)
graph TD
    A[容器内 Stat 系统调用] --> B[overlayfs readdir/dentry lookup]
    B --> C{是否为 device node?}
    C -->|是| D[清除 S_IFMT & os.ModeDevice 位]
    C -->|否| E[直通权限位]
    D --> F[返回精简 FileMode]

4.3 CI/CD流水线中权限校验自动化:利用go:embed + fs.WalkDir实现构建时静态权限合规性扫描

在构建阶段嵌入权限策略检查,避免运行时越权风险。使用 go:embed 将 YAML 权限规则文件打包进二进制,配合 fs.WalkDir 遍历源码中所有 HTTP 路由声明。

权限规则嵌入与加载

// embed rules.yml into binary at build time
import _ "embed"

//go:embed rules.yml
var rulesYAML []byte

go:embed 在编译期将 rules.yml 作为只读字节切片注入,零运行时 I/O 开销;_ "embed" 导入确保 embed 包被启用。

静态扫描核心逻辑

err := fs.WalkDir(os.DirFS("."), ".", func(path string, d fs.DirEntry, err error) error {
    if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") {
        return nil // skip test files
    }
    // parse route annotations & match against embedded rules
})

fs.WalkDir 提供确定性遍历顺序,os.DirFS(".") 构建当前目录只读文件系统视图,strings.HasSuffix 过滤测试文件提升扫描效率。

检查项 合规要求 违规示例
/admin/* 必须含 RBAC("admin") http.HandleFunc("/admin/users", handler)
/api/v1/* 禁止匿名访问 r.Get("/api/v1/profile", publicHandler)
graph TD
    A[go build] --> B
    B --> C[WalkDir 扫描 *.go]
    C --> D[正则提取路由+注解]
    D --> E[匹配 embedded 规则]
    E --> F[失败则 exit 1]

4.4 云原生场景下的扩展属性支持:xattr与fs.FileMode的协同使用模式(如SELinux context标记)

在容器化环境中,文件系统安全上下文需在镜像构建与运行时持续传递。xattr(扩展属性)与 fs.FileMode 的组合,为 SELinux context(如 system_u:object_r:container_file_t:s0)提供了声明式持久化能力。

核心协同机制

  • fs.FileMode 携带基础权限与特殊位(如 ModeSetuid, ModeSticky)
  • xattr 存储 SELinux label,通过 setxattr("security.selinux", ...) 写入
  • Go 标准库 os 不直接支持 xattr,需借助 golang.org/x/sys/unix

示例:写入 SELinux context

import "golang.org/x/sys/unix"

// 将 SELinux context 写入文件
err := unix.Setxattr("/app/config.yaml", "security.selinux",
    []byte("system_u:object_r:container_file_t:s0\000"), 0)
if err != nil {
    log.Fatal(err) // 需 root 权限或 CAP_MAC_ADMIN
}

逻辑分析:Setxattr 调用内核 setxattr(2) 系统调用;\000 是 C 字符串终止符,必需;flag 表示覆盖现有值;失败常见于容器未启用 SELinux 或缺失 capability。

典型应用场景对比

场景 是否需 xattr fs.FileMode 作用
Pod 中挂载 ConfigMap 否 控制读写权限(如 0644)
安全敏感 volume 是 配合 security.selinux 实现 MCS 隔离
graph TD
    A[OCI 镜像构建] --> B[buildkit 执行 setxattr]
    B --> C[容器 runtime 加载 label]
    C --> D[内核 LSM 校验访问权限]

第五章:未来演进方向与社区协作建议

开源模型轻量化落地实践

2024年Q2,某省级政务AI平台将Llama-3-8B通过AWQ量化+LoRA微调压缩至3.2GB显存占用,在国产昇腾910B服务器上实现单卡并发12路结构化报告生成。关键路径包括:使用llm-awq工具链完成4-bit权重量化,冻结底层Transformer块,仅训练最后6层Adapter模块,并通过ONNX Runtime加速推理——实测端到端延迟从2.1s降至380ms。该方案已集成进其“智政通”V3.7版本,日均处理公文摘要请求47万次。

多模态协同标注工作流

深圳某自动驾驶初创企业构建了跨模态对齐标注系统:激光雷达点云(.pcd)、环视图像(.jpg)与BEV语义图(.png)三者通过时间戳+GPS坐标联合校准。社区贡献的multimodal-align工具包支持自动检测帧间偏移,当位姿误差>0.15m时触发人工复核队列。下表为2024年6月标注质量审计结果:

模态类型 标注一致性率 自动校验通过率 人工复核耗时/帧
点云3D框 92.7% 86.3% 8.2s
图像2D掩码 95.1% 91.6% 3.7s
BEV车道线 89.4% 79.8% 12.5s

社区共建治理机制

Apache Flink中文文档社区采用“双轨制”协作模型:核心API文档由PMC成员锁定编辑权限,而案例库(/examples/目录)向全体注册用户开放PR。2024年新增的Flink CDC实时同步案例中,37%的代码片段来自企业用户提交,其中美团团队贡献的MySQL Binlog断点续传方案被合并进v1.19主干。所有PR需通过CI流水线验证:mvn clean verify -DskipTests + markdownlint语法检查 + pandoc --from=markdown --to=html渲染测试。

graph LR
A[GitHub Issue] --> B{标签分类}
B -->|bug| C[Assign to Triage Team]
B -->|feature| D[Require RFC Draft]
B -->|doc| E[Auto-assign to Docs WG]
C --> F[Reproduce & Root Cause]
D --> G[Community Vote ≥72h]
E --> H[Preview on docs-preview.flink.apache.org]
F --> I[PR with Test Case]
G --> J[Merge if ≥3 +1 votes]
H --> K[Auto-deploy on next sync]

跨架构编译工具链演进

RISC-V生态正加速适配AI推理框架。平头哥玄铁C910芯片组已通过TVM v0.14的riscv64-linux-gnu-gcc交叉编译验证,但面临向量指令集(V-extension)利用率不足问题。社区最新提出的rvv-tir优化层将GEMM计算图自动拆分为VLEN=256的向量块,使ResNet-18推理吞吐提升2.3倍。该补丁已在OpenHarmony 4.1 SDK中启用,覆盖全志D1及算能BM1684X开发板。

企业级模型监控看板

某银行风控大模型部署后接入Prometheus+Grafana监控体系,定制指标包括:model_inference_p99_latency_seconds、kv_cache_hit_rate_percent、tensor_parallel_stall_ratio。当缓存命中率连续5分钟低于65%时,自动触发cache_warmup_job——从Redis热备池加载最近1000个高频查询的KV缓存快照。该机制在2024年“双十一”峰值期间避免了37次GPU显存OOM事件。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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