Posted in

C语言程序员必须掌握的7个Go底层机制(含汇编级goroutine调度对比),否则永远跨不过高级岗门槛

第一章:C语言程序员转型Go语言的就业能力全景图

C语言程序员拥有扎实的内存管理、指针操作与系统级编程功底,这恰恰是Go语言生态中高价值岗位(如云原生基础设施、高性能中间件、eBPF工具链开发)所青睐的核心素养。转型并非从零开始,而是将既有能力映射到Go的现代工程范式中——例如,用unsafe.Pointerreflect安全复用C风格的底层操作,而非抛弃经验重学。

核心能力迁移路径

  • 内存模型理解 → 无缝衔接Go的逃逸分析与sync.Pool优化实践
  • 多线程逻辑直觉 → 快速掌握goroutine调度模型与chan通信契约
  • ABI与系统调用认知 → 高效编写cgo桥接代码,调用C库(如OpenSSL)或内核接口

关键技术栈对标表

C语言能力 Go语言对应能力 典型应用场景
malloc/free make([]T, n) + GC回收 高频小对象分配(避免new滥用)
struct内存布局 unsafe.Sizeof() + 字段对齐 序列化协议(Protocol Buffers)优化
pthread同步原语 sync.Mutex + sync.Once 服务启动阶段单例初始化

必练实战:用Go重写C风格环形缓冲区

type RingBuffer struct {
    data  []byte
    head  int // 读位置
    tail  int // 写位置
    size  int // 容量
}

func NewRingBuffer(capacity int) *RingBuffer {
    return &RingBuffer{
        data: make([]byte, capacity),
        size: capacity,
    }
}

// Write写入时自动覆盖旧数据(类似C中ring buffer的典型行为)
func (rb *RingBuffer) Write(p []byte) int {
    n := len(p)
    for i := 0; i < n; i++ {
        rb.data[rb.tail] = p[i]
        rb.tail = (rb.tail + 1) % rb.size // 模运算实现循环
        if rb.tail == rb.head {           // 已满,推进head丢弃最老字节
            rb.head = (rb.head + 1) % rb.size
        }
    }
    return n
}

此实现保留C程序员熟悉的“无锁环形缓冲”思维,同时利用Go切片避免手动内存管理,是云监控Agent、日志采集器等场景的常见基础组件。企业招聘时,能清晰解释% rb.size替代if分支的性能意义,并指出Write方法在并发场景需加锁(或改用sync/atomic),即体现深度转型能力。

第二章:Go运行时核心机制深度解析(C程序员视角)

2.1 Go内存模型与C内存管理的汇编级对比:malloc/free vs mallocgc/heap alloc

核心差异:运行时介入与自动调度

C 的 malloc/free 是 libc 直接映射系统调用(如 brkmmap),无运行时干预;Go 的 mallocgc 则由 runtime 调度,集成 GC 标记、逃逸分析与 span 管理。

汇编片段对比(x86-64,简化)

; C: call malloc (glibc 实现节选)
call    __libc_malloc
mov     QWORD PTR [rbp-8], rax  ; 返回裸指针

; Go: 调用 runtime.mallocgc (go/src/runtime/malloc.go 编译后)
call    runtime.mallocgc(SB)    ; 参数:size (AX), typ (DX), needzero (BX)
mov     rbx, rax                ; 返回带 write barrier 兼容的堆地址

逻辑分析mallocgc 接收三个关键参数——分配大小(AX)、类型信息指针(DX,用于 GC 扫描)、是否清零标志(BX)。其返回地址已隐式注册到 GC 根集,而 malloc 仅返回原始虚拟地址,无元数据绑定。

内存组织抽象层对比

维度 C (malloc) Go (mallocgc)
元数据关联 无(需手动维护) 强绑定(mspan/mcache/mcentral)
分配延迟 即时(同步系统调用) 可能触发 GC 唤醒(异步协作)
碎片控制 依赖 dlmalloc 等算法 基于 size class + central span 池
graph TD
    A[分配请求] --> B{size < 32KB?}
    B -->|是| C[从 mcache 获取 span]
    B -->|否| D[mmap 分配大对象]
    C --> E[返回带 typeinfo 的指针]
    D --> E

2.2 Go类型系统底层实现:interface{}的itable/itab结构与C函数指针表的映射实践

Go 的 interface{} 并非简单类型擦除,而是通过运行时动态构建的 itab(interface table)实现多态分发。

itab 的核心字段

  • inter: 指向接口类型元数据(*interfacetype
  • _type: 指向具体类型元数据(*_type
  • fun[1]: 可变长函数指针数组,按接口方法签名顺序存放对应方法地址
// runtime/iface.go 简化示意
type itab struct {
    inter *interfacetype // 接口定义
    _type *_type         // 实际类型
    hash  uint32         // 类型哈希,加速查找
    _     [4]byte        // 对齐填充
    fun   [1]uintptr     // 方法指针表起始
}

fun 数组中每个 uintptr 存储的是该类型对应方法的绝对机器码入口地址,由 runtime.getitab() 在首次赋值时动态填充,避免虚函数表(vtable)的静态绑定开销。

C 函数指针映射关键点

项目 说明
调用路径 iface.call → itab.fun[i] → C ABI 兼容函数体
参数传递 Go 方法接收者自动转为首个参数(C 视角为 void* recv
栈对齐 runtime 自动插入适配 stub,确保 C 函数调用约定兼容
graph TD
    A[interface{}变量] --> B[itab查找]
    B --> C{缓存命中?}
    C -->|否| D[生成新itab + 填充fun[]]
    C -->|是| E[直接索引fun[n]]
    D --> E
    E --> F[C函数指针调用]

2.3 Go逃逸分析原理与C栈帧优化策略对照:从-gcflags=”-m”到objdump反汇编验证

Go编译器通过 -gcflags="-m" 输出逃逸决策,揭示变量是否分配在堆上。例如:

func NewNode() *Node {
    return &Node{Val: 42} // → "moved to heap: n"
}

该提示表明 Node 实例逃逸出栈帧,因返回其地址——这与C中局部变量返回地址导致未定义行为本质不同,Go自动升格为堆分配并启用GC。

逃逸判定核心逻辑

  • 地址被返回、存储于全局/堆结构、闭包捕获、或跨goroutine传递 → 必逃逸
  • C则依赖程序员手动管理(如 malloc/free),无编译期自动分析

验证手段对比

工具 Go C
编译期分析 go build -gcflags="-m" gcc -O2 -fverbose-asm
二进制验证 objdump -dCALL runtime.newobject objdump -dCALL malloc
go tool objdump -S main.NewNode | grep "runtime\.newobject"

反汇编可确认堆分配调用,印证逃逸分析结果。

2.4 Go GC三色标记算法与C手动内存管理的协同设计:如何在CGO混合项目中规避悬垂指针

核心矛盾:GC不可见性与C指针生命周期错位

Go 的三色标记(white-gray-black)仅追踪 Go 堆对象,而 C.malloc 分配的内存位于 C 堆,不被 GC 扫描。若 Go 变量持有 *C.char 并在 C 内存释放后继续使用,即产生悬垂指针。

安全协同模式:显式所有权移交

// 正确:用 runtime.KeepAlive 确保 Go 对象存活至 C 操作完成
func processInC(data string) {
    cStr := C.CString(data)
    defer C.free(unsafe.Pointer(cStr)) // C端释放时机可控
    C.process_string(cStr)
    runtime.KeepAlive(cStr) // 阻止 cStr 在 C.process_string 返回前被 GC 回收(虽此处非必须,但体现防御习惯)
}

runtime.KeepAlive(x) 插入屏障指令,确保 x 的生命周期延伸至该点之后;对 cStr 本身无作用(它只是 uintptr),但可强化对关联 Go 对象(如底层数组)的引用保持——尤其当 cStrC.CString 从 Go 字符串派生时。

关键实践清单

  • ✅ 始终用 defer C.free() 匹配 C.CString/C.malloc
  • ✅ 避免将 *C.xxx 存入 Go 全局变量或 channel(脱离作用域后易悬垂)
  • ❌ 禁止在 goroutine 中异步释放 C 内存,除非通过 runtime.SetFinalizer 显式绑定

Go 与 C 内存生命周期对比表

维度 Go 堆内存 C 堆内存(CGO)
管理方式 自动三色标记 + STW 清理 手动 malloc/free
GC 可见性 完全可见 完全不可见
悬垂风险点 闭包捕获已逃逸变量 C.free 调用过早或遗漏
graph TD
    A[Go 代码调用 C 函数] --> B{C 函数是否持有<br>Go 分配的内存地址?}
    B -->|是| C[需 runtime.KeepAlive<br>延长 Go 对象生命周期]
    B -->|否| D[C 独立 malloc →<br>必须显式 free]
    C --> E[GC 三色标记继续运行<br>不影响 C 堆]
    D --> E

2.5 Go编译器中间表示(SSA)与C GCC IR的语义对齐:通过compile -S生成汇编并交叉解读

Go 的 go tool compile -S 输出的是基于 SSA 形式的汇编前视图,而 GCC 的 .s 输出源于 GIMPLE→RTL 的多级 IR 流程。二者虽目标一致(生成高效机器码),但语义抽象层存在根本差异。

汇编级对齐示例

// Go: go tool compile -S main.go(节选)
"".add STEXT size=32
        MOVQ    "".a+8(SP), AX   // 参数a位于栈偏移+8
        ADDQ    "".b+16(SP), AX  // SSA已消除冗余加载,直接ADDQ

此处 MOVQ+ADDQ 组合反映 Go SSA 的寄存器分配结果;GCC 对等函数通常展开为 mov %rdi, %rax; add %rsi, %rax——因 ABI 参数在寄存器中,无需栈访存。

关键语义映射维度

维度 Go SSA GCC IR(GIMPLE/RTL)
内存模型 基于逃逸分析的显式栈/堆决策 基于alias analysis的memref抽象
控制流 CFG节点含Phi指令 GIMPLE中的goto/if_n及RTL跳转链
graph TD
    A[Go source] --> B[Frontend AST]
    B --> C[SSA construction<br>Phi insertion, loop rotation]
    C --> D[Machine-independent opt]
    D --> E[Target-specific lowering]
    E --> F[.s assembly]

第三章:goroutine调度器的C级实现剖析

3.1 M/P/G模型在Linux线程与C pthread中的映射实践:手写简易P复用模拟器

M/P/G模型中,P(Processor) 是调度器核心抽象,对应OS调度单元;Linux中无直接P实体,但可通过pthread绑定CPU、限制并发数模拟其资源复用行为。

核心约束模拟

  • 使用 pthread_attr_setaffinity_np() 绑定线程到特定CPU核(模拟P的局部性)
  • 通过信号量控制活跃P数量(如固定3个P,超限则阻塞新goroutine-like任务)

简易P复用模拟器(关键片段)

#include <pthread.h>
#include <semaphore.h>
sem_t p_pool; // 控制可用P数(如初始化为3)

void* worker(void* arg) {
    sem_wait(&p_pool);        // 获取一个P(阻塞直到有空闲P)
    // 执行任务(模拟G在P上运行)
    sched_yield();            // 主动让出时间片,体现P调度权
    sem_post(&p_pool);        // 归还P
    return NULL;
}

sem_wait() 模拟P获取竞争;sched_yield() 强制触发内核调度,逼近P主动交出执行权的行为;sem_post() 实现P的可重用性。参数&p_pool封装了P池容量语义。

抽象层 Linux/pthread实现 映射意义
M(Machine) clone()/pthread_create() 内核线程载体
P(Processor) sem_t + CPU affinity 可复用的调度上下文容器
G(Goroutine) 用户任务函数 + 栈管理 轻量级协作式执行单元
graph TD
    A[用户提交G] --> B{P池是否有空闲?}
    B -- 是 --> C[绑定CPU并执行G]
    B -- 否 --> D[阻塞等待sem_wait]
    C --> E[执行结束]
    E --> F[sem_post释放P]
    F --> B

3.2 GMP状态机与C状态驱动调度器对比:基于epoll_wait + setjmp/longjmp的轻量协程实验

传统GMP(Go的goroutine-M-P模型)依赖内核线程与运行时调度器协同,而C语言中可构建更轻量的状态驱动调度器——仅用epoll_wait等待I/O就绪,配合setjmp/longjmp实现无栈协程上下文切换。

核心机制差异

维度 GMP状态机 C状态驱动调度器
切换开销 约500ns(runtime.switchto)
栈管理 动态栈分配/收缩 静态协程栈(如8KB)
调度触发点 GC、系统调用、抢占点 epoll_wait返回后显式调度

协程切换示例

// 协程结构体含jmp_buf用于保存/恢复执行点
typedef struct {
    jmp_buf env;
    char stack[8192];
    int state; // READY/RUNNING/BLOCKED
} coro_t;

static coro_t *current = NULL;
static coro_t *next = NULL;

// 切换前保存当前上下文,跳转至目标env
void coro_yield() {
    if (setjmp(current->env) == 0) {
        longjmp(next->env, 1); // 恢复next协程
    }
}

setjmp捕获当前寄存器与栈指针快照;longjmp直接跳转并恢复该快照,绕过函数调用栈展开,实现零开销上下文切换。epoll_wait作为唯一阻塞点,返回后由调度器遍历就绪队列调用coro_yield

graph TD
    A[epoll_wait timeout] --> B{有就绪fd?}
    B -->|是| C[唤醒对应协程]
    B -->|否| D[检查定时器/信号]
    C --> E[longjmp至协程env]
    E --> F[继续执行用户逻辑]

3.3 netpoller与C epoll/kqueue的底层集成:修改Go runtime/net/fd_poll_runtime.go验证事件循环差异

核心钩子函数定位

runtime.netpoll 是 Go 运行时事件循环的统一入口,其实际实现由 net/fd_poll_runtime.go 中的 netpollinit/netpollopen 等函数桥接至平台原生 I/O 多路复用器。

修改验证关键点

  • netpollopen(fd, pd *pollDesc) 前插入 runtime·printf("epoll_ctl(ADD) fd=%d\n", fd)(Linux)或对应 kevent 日志
  • 观察 runtime.netpoll(-1) 调用频率与阻塞行为差异

epoll vs kqueue 行为对比

特性 Linux (epoll) macOS/BSD (kqueue)
事件注册开销 epoll_ctl(EPOLL_CTL_ADD) 一次系统调用 kevent() 批量注册支持更好
边缘触发语义 需显式 EPOLLET 默认边缘触发(EV_CLEAR 需手动重置)
// 修改 runtime/net/fd_poll_runtime.go 中 netpollopen:
func netpollopen(fd uintptr, pd *pollDesc) int32 {
    runtime·printf("netpollopen: fd=%d, os=%s\n", fd, GOOS)
    if GOOS == "linux" {
        return epollevent(fd, pd, _EPOLLIN|_EPOLLOUT|_EPOLLET)
    }
    return kqueueevent(fd, pd) // 返回 kevent 封装结果
}

该修改使 pd.rg/pd.wg 的 goroutine 唤醒路径可被追踪;_EPOLLET 强制边缘触发,暴露 Go runtime 对就绪事件“一次性消费”的假设——若用户层未读尽缓冲区,将丢失后续通知,凸显与 level-triggered 模式的语义鸿沟。

第四章:Go与C互操作的生产级工程实践

4.1 CGO调用链的ABI兼容性陷阱:x86-64 System V ABI与Go调用约定的汇编级校验

Go 运行时默认使用自定义调用约定(如寄存器保存策略、栈帧布局),而 CGO 调用 C 函数时强制切换至 x86-64 System V ABI —— 二者在浮点寄存器(%xmm0–%xmm7)和整数寄存器(%rax, %rdx 等)的调用者/被调用者保存责任上存在根本差异。

关键分歧点

  • Go 函数不保证 %xmm8–%xmm15 的调用者保存,但 System V ABI 要求它们由调用者保存
  • Go 的 runtime·stackcheck 可能覆盖未显式保存的 %r12–%r15,导致 C 函数读取脏值

汇编级校验示例

// cgo_export.h 中声明的函数对应汇编桩
TEXT ·myCFunc(SB), NOSPLIT, $0
    MOVQ    x+0(FP), AX     // 第一个 int64 参数 → %rax
    MOVQ    y+8(FP), DX     // 第二个 int64 → %rdx  
    CALL    my_c_impl(SB)   // 调用 C 函数(遵守 System V ABI)
    MOVQ    AX, ret+16(FP)  // 返回值从 %rax → Go 栈
    RET

此桩代码隐含要求:Go 编译器必须在 CALL 前将 %xmm8–%xmm15%r12–%r15 压栈(若已被使用),否则 C 函数可能破坏 Go 协程栈。实际中,cgo -godefs 不生成此类保存逻辑,依赖开发者手动干预或 -gcflags="-l" 禁用内联规避。

ABI 兼容性检查矩阵

寄存器范围 System V ABI 责任 Go runtime 默认行为 风险场景
%rax, %rdx, %rcx, %rsi, %rdi, %r8–%r11 调用者保存 ✅ 尊重(CGO 桩自动处理)
%r12–%r15, %rbp, %rsp 被调用者保存 ❌ 可能被 runtime 修改 高(栈溢出检测触发)
%xmm0–%xmm7 调用者保存 ✅ 通常安全 中(若 C 函数返回 __m128
%xmm8–%xmm15 调用者保存 ❌ Go 不自动保存 极高(常见于 AVX 数学库)
graph TD
    A[Go 函数调用 CGO] --> B{是否使用 %xmm8+ 或 %r12+?}
    B -->|是| C[需手动插入 SAVE_XMM8_TO_15 / PUSH R12-R15]
    B -->|否| D[ABI 兼容]
    C --> E[否则 C 函数返回后 Go 协程状态损坏]

4.2 C结构体与Go struct内存布局对齐实战:unsafe.Offsetof与offsetof的联合调试案例

跨语言对齐验证目标

需确保 C struct Packet 与 Go Packet 在字段偏移、总大小、填充位置上完全一致,避免 cgo 传参时内存错位。

对齐约束对比表

字段 C offsetof (bytes) Go unsafe.Offsetof (bytes) 是否一致
hdr 0 0
len 8 8
data 16 16

联合调试代码片段

// C side
#include <stddef.h>
struct Packet { uint64_t hdr; uint32_t len; char data[0]; };
// offsetof(struct Packet, data) → 16
// Go side
type Packet struct {
    Hdr  uint64
    Len  uint32
    Data [0]byte
}
// unsafe.Offsetof(p.Len) → 8; unsafe.Offsetof(p.Data) → 16

逻辑分析:C 中 uint64(8B)+ uint32(4B)后因 data 是柔性数组,编译器按 uint64 对齐要求在 len 后填充 4B,使 data 起始地址为 16;Go 的 struct 默认遵循相同对齐规则,unsafe.Offsetof 精确复现该布局。二者偏移一致,是 cgo 安全零拷贝传递的前提。

4.3 Go goroutine阻塞C调用时的M抢占机制:通过GODEBUG=schedtrace=1与strace交叉定位死锁

当 goroutine 调用 C.sleep() 或阻塞式系统调用时,绑定的 M 会陷入内核态,无法响应调度器抢占——此时若无其他空闲 M,整个 P 可能停滞。

调度观测双工具协同

  • GODEBUG=schedtrace=1000:每秒输出调度器快照(含 G/M/P 状态、阻塞原因)
  • strace -p <pid> -e trace=nanosleep,select,poll:精确定位 C 调用阻塞点

典型死锁场景复现

// cgo_block.go
/*
#include <unistd.h>
*/
import "C"

func blockInC() {
    C.usleep(5e6) // 阻塞 5s,不释放 M
}

此调用使当前 M 进入 Msyscall 状态且不触发 entersyscallblock 的唤醒路径,若 P 上无其他 M,新 goroutine 将无限等待。

字段 含义 schedtrace 示例
M:1 M 处于 syscall M1: 1/1/0 [sys]
G:2 G 在 CGO 调用中 G2: waiting on C
graph TD
    A[Goroutine calls C.usleep] --> B[M enters kernel]
    B --> C{Is there idle M?}
    C -->|No| D[All Ps stall → deadlock]
    C -->|Yes| E[M resumes, schedules next G]

4.4 静态链接与符号导出控制:-buildmode=c-archive与CMake集成的CI/CD流水线构建

Go 通过 -buildmode=c-archive 生成 .a 静态库与 header.h,供 C/C++ 项目直接调用,但默认导出所有 //export 标记函数——需精准控制符号可见性。

符号裁剪实践

go build -buildmode=c-archive -ldflags="-s -w" -o libmath.a math.go

-s -w 剔除调试信息与符号表,减小归档体积;-ldflags 必须在 go build 阶段注入,CI 中需确保 Go 版本 ≥1.16(支持 //go:cgo_import_dynamic 细粒度控制)。

CMake 集成关键配置

变量 作用 示例
GO_CMD 指定交叉编译 Go 工具链 aarch64-linux-gnu-go
CMAKE_CXX_STANDARD 匹配 Go 生成头文件的 ABI 17

CI 流水线依赖流

graph TD
  A[Go源码] --> B[go build -buildmode=c-archive]
  B --> C[libmath.a + math.h]
  C --> D[CMake configure/link]
  D --> E[最终可执行镜像]

第五章:从高级岗面试到一线架构师的成长路径

面试不是终点,而是系统性能力验证的起点

2023年Q3,某电商中台团队招聘“高级Java工程师(架构方向)”,收到217份简历,仅12人进入终面。终面采用“现场架构推演”形式:给定一个日均订单量突增300%的支付链路告警场景(TP99从120ms飙升至850ms),要求候选人在45分钟内完成问题定位、方案设计与权衡陈述。最终录用的3人中,2人完整复现了线上真实发生的Redis连接池耗尽+Hystrix线程池隔离失效复合故障,并手绘了熔断降级决策树(含超时阈值动态计算逻辑)。这印证了一个事实:高级岗面试已从语法考题转向对生产环境复杂性的直觉响应。

架构决策必须绑定可验证的约束条件

一线架构师每日面临数十项技术选型,但有效决策依赖明确约束。例如在迁移核心风控引擎至云原生架构时,团队确立四维硬约束:

  • 数据一致性:跨AZ部署下Paxos协议写入延迟≤50ms(实测Kafka事务+RocksDB WAL双写达标)
  • 故障自愈:单节点宕机后服务恢复时间≤8秒(通过eBPF注入网络延迟验证Envoy健康检查重试策略)
  • 成本可控:同等QPS下云资源成本增幅≤15%(采用KEDA基于Prometheus指标的HPA策略降低空闲实例)
  • 合规审计:所有API调用需生成符合GDPR的不可篡改溯源日志(落地OpenTelemetry Collector + Loki日志归档方案)

在混沌工程中锻造架构韧性

2024年2月,某金融平台开展“春节流量压测”,故意在网关层注入10%随机503错误,结果暴露出三个关键缺陷:

  1. 前端SDK未实现指数退避重试,导致瞬时请求洪峰冲击下游
  2. 熔断器配置未区分HTTP状态码,将503误判为服务异常触发全局熔断
  3. 监控大盘缺少“熔断触发率/请求成功率”交叉热力图,故障定位耗时超22分钟

修复后上线混沌实验平台,每周自动执行3类故障注入(网络分区/进程OOM/磁盘满),所有修复方案均通过GitOps流水线自动部署并关联Jira工单闭环。

flowchart LR
    A[混沌实验触发] --> B{故障类型识别}
    B -->|网络抖动| C[TC命令注入延迟]
    B -->|内存泄漏| D[stress-ng --vm 2 --vm-bytes 2G]
    C --> E[采集Envoy指标]
    D --> E
    E --> F[对比基线阈值]
    F -->|超限| G[自动创建Incident]
    F -->|正常| H[标记实验通过]

技术债清偿需要量化驱动机制

团队建立技术债看板,对每项债务标注: 债务类型 影响范围 修复预估工时 当前风险等级 最近一次恶化时间
单体应用数据库分库未覆盖全部业务表 订单/营销/风控模块 120h P0(影响分库扩容) 2024-03-11
Kafka消费者组Rebalance超时未优化 实时风控流处理 32h P1(偶发数据延迟) 2024-02-28

每月技术委员会评审TOP5债务,强制要求修复方案包含可测量的验收标准(如“Rebalance耗时从平均4.2s降至≤800ms”)。

架构文档必须承载可执行性

新成员入职首周需完成三项实操任务:

  • 使用Terraform脚本在测试环境部署一套最小化微服务集群(含Istio ingress gateway与Jaeger)
  • 修改OpenAPI 3.0规范后,通过Swagger Codegen自动生成Spring Boot Controller骨架并提交PR
  • 执行kubectl debug node命令定位模拟的NodeNotReady故障,输出包含crictl ps -ajournalctl -u kubelet的诊断报告

这些任务全部嵌入Confluence文档,每个步骤附带真实终端截图与预期输出哈希值。

记录一位 Gopher 的成长轨迹,从新手到骨干。

发表回复

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