C++ 与 寄存器重命名(Register Renaming):分析 C++ 局部变量生命周期对硬件寄存器分配的影响

尊敬的各位同仁,各位对C++性能优化和底层硬件机制充满热情的开发者们:

欢迎来到本次关于“C++局部变量生命周期对硬件寄存器分配的影响及寄存器重命名”的专题讲座。作为一个编程专家,我将带领大家深入探究C++代码的表象之下,编译器和现代CPU微架构如何协同工作,将我们日常编写的局部变量转化为CPU的极速操作数。这不仅仅是理论知识,更是我们理解和编写高性能C++代码的关键。


一、 C++ 局部变量的本质与生命周期:代码的基石

C++中的局部变量是我们日常编程中最常使用的变量类型之一。它们在特定作用域内声明,并拥有自动存储期。理解它们的生命周期是理解其如何影响硬件寄存器分配的第一步。

1.1 局部变量的定义与作用域

局部变量是在函数内部、代码块内部(如if语句、for循环、while循环内部)声明的变量。它们的作用域严格限定在其声明的代码块内。一旦代码执行离开该作用域,局部变量就“失效”了。

#include <iostream>

void process_data() {
    int count = 0; // 局部变量,作用域从声明到函数结束
    for (int i = 0; i < 10; ++i) { // i 也是局部变量,作用域仅限于 for 循环
        int temp = i * 2; // temp 也是局部变量,作用域仅限于 for 循环体
        count += temp;
        // i 和 temp 在这里有效
    }
    // i 和 temp 在这里失效,无法访问
    std::cout << "Count: " << count << std::endl;
} // count 在这里失效

int main() {
    process_data();
    // count, i, temp 在这里都失效
    return 0;
}

1.2 局部变量的存储期与生命周期

C++中的局部变量默认拥有自动存储期(Automatic Storage Duration)。这意味着它们的存储空间在进入作用域时自动分配,在离开作用域时自动释放。这种分配通常发生在程序的栈(Stack)上。

  • 生命周期开始:当程序执行流进入声明局部变量的作用域时,变量被构造(如果是类类型)并分配存储空间。
  • 生命周期结束:当程序执行流离开该作用域时,变量被析构(如果是类类型)并释放其存储空间。

这种“随用随取,用完即弃”的特性,使得局部变量成为编译器进行优化,特别是分配到CPU寄存器中的理想候选者。它们通常生命周期短,且在短时间内被频繁访问。

1.3 register 关键字的演变

在C++的早期版本中,register 关键字曾被用来向编译器“建议”将某个局部变量存储在CPU寄存器中,以期望提高访问速度。

void old_style_function() {
    register int counter = 0; // 尝试建议编译器放入寄存器
    // ...
}

然而,随着现代编译器优化技术(如SSA形式、活跃性分析)的飞速发展,它们在决定哪些变量适合放入寄存器方面,已经远超人类程序员的直觉。register 关键字的建议往往会被编译器忽略,甚至可能干扰编译器的优化策略。

从C++11开始,register 关键字被弃用;从C++17开始,它被彻底移除。 现在,register 关键字不再有任何语义上的影响,它仅仅作为保留字存在。这意味着我们应该完全依赖编译器来智能地进行寄存器分配。


二、 硬件寄存器:CPU 的“快车道”

要理解C++局部变量如何被分配到寄存器,我们首先需要理解硬件寄存器本身。

2.1 什么是硬件寄存器?

硬件寄存器是CPU内部极小、极高速的存储单元。它们位于CPU核心内部,是CPU执行指令时直接操作的数据存储区域。与主内存(RAM)、甚至各级缓存(L1, L2, L3 Cache)相比,寄存器的访问速度是最快的,通常可以在一个或零个CPU时钟周期内完成读写。

2.2 寄存器的类型与数量

现代CPU拥有多种类型的寄存器:

  • 通用寄存器(General-Purpose Registers, GPRs):用于存储整数数据和内存地址。例如,x86-64架构中的 RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8R15 等。
  • 浮点/向量寄存器(Floating-Point/Vector Registers):用于存储浮点数和向量数据,支持SIMD(单指令多数据)操作。例如,x86-64架构中的 XMM, YMM, ZMM 寄存器。
  • 特殊用途寄存器
    • 程序计数器(Program Counter, PC / Instruction Pointer, RIP):存储下一条要执行指令的内存地址。
    • 栈指针(Stack Pointer, SP / RSP):指向当前函数栈帧的顶部。
    • 基址指针(Base Pointer, BP / RBP):通常用于指向当前函数栈帧的底部。
    • 标志寄存器(Flags Register / RFLAGS):存储CPU操作的状态信息,如零标志、进位标志等。

关键点:寄存器的数量非常有限! 尽管现代CPU拥有比以往更多的寄存器,但与程序中可能存在的变量数量相比,它们仍然是稀缺资源。例如,x86-64架构通常有16个通用寄存器,16个XMM/YMM/ZMM寄存器。

2.3 寄存器与内存层次结构

寄存器位于内存层次结构的顶端,其速度和带宽远超其他存储介质。

存储位置 典型容量 典型访问时间 特点
寄存器 几十到几百字节 < 1 纳秒 (0-1 CPU周期) CPU内部,最快,数量极少
L1 Cache 几十到几百KB 几纳秒 (几个CPU周期) CPU内部,非常快,专核
L2 Cache 几百KB到几MB 几十纳秒 (几十个CPU周期) CPU内部,较快,专核或共享
L3 Cache 几MB到几十MB 几十到几百纳秒 (几百个CPU周期) CPU内部,较慢,所有核心共享
主内存 (RAM) 几GB到几百GB 几十到几百纳秒 CPU外部,较慢,容量大
硬盘 (SSD/HDD) 几百GB到几十TB 几毫秒到几十毫秒 最慢,容量最大,非易失性

从上表可以看出,将频繁访问的数据存储在寄存器中,可以显著提高程序的执行效率。

2.4 调用约定与寄存器使用规范

在函数调用过程中,不同的架构和操作系统(通过Application Binary Interface, ABI)定义了寄存器的使用规范,即调用约定(Calling Convention)。这决定了哪些寄存器用于传递参数,哪些用于返回结果,以及哪些寄存器需要在函数调用前后保存。

  • 调用者保存(Caller-saved / Volatile)寄存器:这些寄存器在函数调用过程中可能会被被调用者(Callee)修改。如果调用者在调用函数后还需要这些寄存器中的值,它必须在调用函数之前将这些值保存到栈上。
  • 被调用者保存(Callee-saved / Non-volatile)寄存器:这些寄存器在函数调用过程中必须由被调用者保存其原始值。如果被调用者需要使用这些寄存器,它必须在函数入口处将它们的值保存到栈上,并在函数返回前恢复它们。

这种区分对于编译器进行寄存器分配至关重要,因为它直接影响了函数调用边界处的寄存器溢出(spilling)和填充(filling)开销。


三、 编译器与寄存器分配:从变量到硬件

编译器是连接C++源代码和底层硬件之间的桥梁。在优化阶段,编译器的一项核心任务就是寄存器分配(Register Allocation):决定哪些C++变量应该被存储在CPU寄存器中,哪些应该被存储在内存(通常是栈)中。

3.1 寄存器分配的目标

编译器进行寄存器分配的主要目标是:

  1. 最大化寄存器使用:尽可能将频繁访问的局部变量、表达式中间结果分配到寄存器中,以减少对内存的访问。
  2. 最小化溢出(Spilling):当可用寄存器数量不足以存储所有活跃变量时,编译器需要将一些变量的值“溢出”到内存(通常是栈)中。溢出操作会引入额外的内存访问开销,降低性能。编译器力求将最不活跃的变量溢出。
  3. 遵守调用约定:确保函数调用和返回时,寄存器的使用符合ABI规范。

3.2 活跃性分析(Liveness Analysis)

在进行寄存器分配之前,编译器会进行活跃性分析。一个变量在程序执行的某个点是活跃的(Live),如果它在未来某个点的值会被读取;否则,它是非活跃的(Dead)

活跃性分析能够识别变量的活跃范围(Live Range),即变量从被定义/赋值到最后一次被使用之间的代码区域。只有在活跃范围内的变量才需要存储在寄存器或内存中。当变量不再活跃时,它所占用的寄存器就可以被释放,供其他变量使用。

3.3 寄存器分配算法:图着色(Graph Coloring)

现代编译器中最常用的寄存器分配算法是基于图着色(Graph Coloring)的。

  1. 构建冲突图(Interference Graph)
    • 图中的每个节点代表一个变量(或一个变量的活跃范围)。
    • 如果两个变量的活跃范围在任何时间点上重叠(即它们同时活跃),则在它们对应的节点之间添加一条边,表示它们之间存在冲突。冲突意味着这两个变量不能同时被分配到同一个物理寄存器中。
  2. 着色
    • 尝试用尽可能少的“颜色”(物理寄存器)来对图进行着色,使得任意两个相邻的节点(冲突的变量)拥有不同的颜色。
    • 可用的颜色数量就是CPU的通用寄存器数量(例如,x86-64有16个)。
  3. 溢出(Spilling)
    • 如果无法用可用的颜色数量完成图着色(即寄存器不够用),编译器就需要选择一些变量将其“溢出”到栈内存中。这些被溢出的变量将不再参与寄存器分配,它们在冲突图中对应的节点会被移除或标记。然后重新尝试着色。选择溢出的变量通常是那些活跃范围较短、访问频率较低的变量,以最小化性能损失。

这个过程是一个NP-hard问题,但通过启发式算法可以在合理的时间内找到接近最优的解决方案。

3.4 编译器优化的影响

编译器的优化级别(例如 -O1, -O2, -O3, -Ofast)对寄存器分配有着巨大影响。

  • 更高优化级别:编译器会投入更多时间进行活跃性分析、循环优化、函数内联、死代码消除等,从而更有效地识别出适合放入寄存器的变量,并减少溢出。
  • 函数内联(Inlining):当一个函数被内联时,其代码被直接插入到调用点。这消除了函数调用开销,并且将内联函数的局部变量与调用者的局部变量合并,扩大了编译器对变量活跃范围的视野,可能带来更高效的寄存器分配。
  • 循环优化:在循环中频繁访问的变量是寄存器分配的重点。编译器会尝试将循环变量、循环体内的中间结果放入寄存器,以避免每次迭代都访问内存。
  • 标量替换(Scalar Replacement of Aggregates):如果一个结构体或数组的成员被独立访问,并且其生命周期较短,编译器可能会将其分解为独立的标量变量,并尝试将这些标量变量分配到寄存器中。

代码示例:寄存器分配的潜力

考虑以下C++代码片段:

// example.cpp
int calculate_sum_and_product(int a, int b, int c) {
    int sum = a + b + c;      // 局部变量 sum
    int product = a * b * c;  // 局部变量 product
    int temp_val = sum + product; // 局部变量 temp_val
    return temp_val;
}

int main() {
    int x = 10, y = 20, z = 30;
    int result = calculate_sum_and_product(x, y, z);
    std::cout << "Result: " << result << std::endl;
    return 0;
}

在优化级别较高的编译下(例如 g++ -O3 example.cpp -S -o example.s),calculate_sum_and_product 函数的局部变量 sum, product, temp_val 很有可能被分配到通用寄存器中。参数 a, b, c 也通常通过寄存器传递。

伪汇编(x86-64)概念:

; calculate_sum_and_product(int a, int b, int c)
; 假设a, b, c分别在edi, esi, edx中
calculate_sum_and_product:
    mov eax, edi        ; eax = a
    add eax, esi        ; eax = a + b
    add eax, edx        ; eax = a + b + c  (eax现在存储 sum)

    mov ecx, edi        ; ecx = a
    imul ecx, esi       ; ecx = a * b
    imul ecx, edx       ; ecx = a * b * c  (ecx现在存储 product)

    add eax, ecx        ; eax = sum + product (eax现在存储 temp_val)
    ret                 ; 返回eax的值

在这个例子中,sum, product, temp_val 都没有被溢出到栈上,而是完全在寄存器中进行计算,极大地提高了效率。


四、 寄存器重命名:微架构的魔法

编译器的寄存器分配解决的是将程序中的逻辑变量映射到有限的架构寄存器(Architectural Registers)上。然而,现代高性能CPU为了实现乱序执行(Out-of-Order Execution, OOO),还需要一个更底层的硬件机制:寄存器重命名(Register Renaming)

4.1 乱序执行与数据依赖

为了最大化CPU的利用率,现代处理器不会严格按照程序指令的顺序执行。如果一条指令的执行结果不影响后续指令的输入,或者后续指令所需的输入尚未准备好,CPU可能会跳过它,先执行后面已经准备好的指令。这就是乱序执行。

乱序执行可以显著提高指令级并行性(Instruction-Level Parallelism, ILP),但它面临一个挑战:数据依赖(Data Dependencies)

数据依赖主要有三种类型:

  1. 真数据依赖(Read-After-Write, RAW):一条指令需要使用前一条指令的输出作为输入。这是必须保持的依赖。
    I1: R1 = R2 + R3
    I2: R4 = R1 * R5  (I2 依赖 I1 的 R1)
  2. 反数据依赖(Write-After-Read, WAR):一条指令写入一个寄存器,而前一条指令需要从该寄存器读取。
    I1: R4 = R1 * R5
    I2: R1 = R2 + R3  (I2 写入 R1,I1 读取 R1。如果 I2 先执行,I1 将读到错误值)
  3. 输出数据依赖(Write-After-Write, WAW):两条指令写入同一个寄存器。
    I1: R1 = R2 + R3
    I2: R1 = R4 * R5  (I2 写入 R1,I1 也写入 R1。如果 I2 先执行,I1 的结果将被覆盖)

RAW依赖是真实的,必须按顺序处理(或者通过转发机制加速)。而WAR和WAW依赖是假依赖(False Dependencies)命名依赖(Name Dependencies),它们仅仅是因为多个指令共享了同一个架构寄存器名而产生的。寄存器重命名就是为了消除这些假依赖。

4.2 架构寄存器与物理寄存器

为了消除假依赖,CPU微架构引入了物理寄存器(Physical Registers)的概念,并与我们熟悉的架构寄存器(Architectural Registers)区分开来。

  • 架构寄存器:这是指令集架构(ISA)定义的一组寄存器,例如x86-64的 RAX, RBX 等。程序员和编译器看到并操作的是这些寄存器。它们数量有限(例如16个通用寄存器)。
  • 物理寄存器:这是CPU内部实际存在的、数量更多的寄存器。它们是CPU微架构的实现细节,对程序员和编译器是透明的。现代CPU通常有几十到几百个物理寄存器。

寄存器重命名机制通过一个映射表(重命名表 / Renaming Map Table)将架构寄存器名动态地映射到可用的物理寄存器上。

4.3 寄存器重命名的工作原理

当指令被解码并进入CPU的乱序执行引擎时,寄存器重命名机制开始发挥作用:

  1. 分配物理寄存器:对于每条指令的目的操作数(Destination Operand)(即写入的寄存器),重命名逻辑会从物理寄存器池中分配一个新的、未使用的物理寄存器给它。这个新的物理寄存器将保存该指令的执行结果。
  2. 更新映射表:重命名表会被更新,将目的架构寄存器名指向这个新分配的物理寄存器。同时,之前的那个物理寄存器(如果存在)会被标记为“旧值”,在所有读取它的指令都完成后,可以回收。
  3. 重定向源操作数:对于每条指令的源操作数(Source Operands)(即读取的寄存器),重命名逻辑会查阅映射表,找到当前这些架构寄存器名所指向的最新物理寄存器。这样,指令就能从正确的物理寄存器中读取数据。

核心组件:

  • 重命名表(Rename Map Table):存储架构寄存器到物理寄存器的当前映射关系。
  • 物理寄存器文件(Physical Register File, PRF):存储所有物理寄存器的值。
  • 空闲物理寄存器列表(Free List):维护可用的物理寄存器列表。
  • 重排序缓冲区(Reorder Buffer, ROB):存储乱序执行的指令及其结果。它负责在指令完成后,按程序顺序提交结果,并更新架构寄存器状态。它也负责回收旧的物理寄存器。

示例:消除WAR/WAW依赖

考虑以下指令序列:

I1: R1 = R2 + R3
I2: R4 = R1 * R5
I3: R1 = R6 - R7  // WAW 依赖 with I1, WAR 依赖 with I2
I4: R8 = R1 / R9

没有寄存器重命名: I1、I2、I3、I4必须按顺序执行,或者引入大量的停顿。I3不能在I2之前执行,因为I2需要R1的旧值;I4不能在I3之前执行,因为I4需要R1的新值。

使用寄存器重命名:

假设初始状态:
R1 -> P1
R2 -> P2
R3 -> P3
R4 -> P4
R5 -> P5
R6 -> P6
R7 -> P7
R8 -> P8
R9 -> P9

  1. I1: R1 = R2 + R3

    • 目的寄存器 R1:分配新物理寄存器 P10。
    • 映射表:R1 -> P10。
    • 源寄存器 R2, R3:映射到 P2, P3。
    • 旧的 R1 映射 (P1) 被标记为“待回收”。
  2. *I2: R4 = R1 R5**

    • 目的寄存器 R4:分配新物理寄存器 P11。
    • 映射表:R4 -> P11。
    • 源寄存器 R1, R5:映射到 P10 (I1 刚写入的R1), P5。
    • 旧的 R4 映射 (P4) 被标记为“待回收”。
  3. I3: R1 = R6 – R7

    • 目的寄存器 R1:分配新物理寄存器 P12。
    • 映射表:R1 -> P12。(注意:R1现在指向P12,与I1的P10不同)
    • 源寄存器 R6, R7:映射到 P6, P7。
    • 旧的 R1 映射 (P10) 被标记为“待回收”。
  4. I4: R8 = R1 / R9

    • 目的寄存器 R8:分配新物理寄存器 P13。
    • 映射表:R8 -> P13。
    • 源寄存器 R1, R9:映射到 P12 (I3 刚写入的R1), P9。
    • 旧的 R8 映射 (P8) 被标记为“待回收”。

通过重命名,指令I1、I3和I2、I4之间因为R1和R4造成的假依赖被消除了。I1和I3可以并行执行(如果CPU有多个执行单元),因为它们写入的是不同的物理寄存器(P10和P12)。I2和I4也可以分别从P10和P12获取R1的值,而不会相互干扰。

4.4 寄存器重命名与编译器分配的关系

寄存器重命名并不直接分配C++局部变量到寄存器。它是在编译器已经将变量映射到有限的架构寄存器之后,在CPU微架构层面进行的进一步优化。

  • 编译器:负责将C++局部变量(及其活跃范围)映射到有限的架构寄存器集合,并处理溢出。它尝试为不同的逻辑变量分配不同的架构寄存器,以减少冲突。
  • 硬件(寄存器重命名):负责将这些架构寄存器动态地映射到数量更多的物理寄存器,以消除乱序执行过程中由于架构寄存器复用而产生的假依赖。

它们是互补的:一个良好的编译器寄存器分配(即,尽可能将变量放入架构寄存器,并最小化冲突)会为寄存器重命名提供一个更“干净”的指令流,使得CPU有更大的空间进行乱序执行和并行化。如果编译器已经把所有变量都溢出到栈内存,那么寄存器重命名就无从发挥作用,因为数据都在内存中,访问速度本身就慢。


五、 C++ 局部变量与寄存器重命名的协同效应

C++局部变量的特性与寄存器分配及重命名机制之间存在着紧密的协同效应。

5.1 局部变量是寄存器优化的理想候选

由于C++局部变量的自动存储期和有限作用域,它们通常具有以下特点:

  • 生命周期短:在函数或代码块结束时即失效,使得它们占用的寄存器可以快速被回收。
  • 访问频率高:在它们活跃的短时间内,往往会被频繁地读写。
  • 数量相对可控:在一个小函数或代码块内,同时活跃的局部变量数量通常在CPU物理寄存器的承载范围内。

这些特点使得局部变量成为编译器进行寄存器分配的“首选”。当编译器成功地将大部分局部变量分配到架构寄存器时,后续的硬件寄存器重命名机制就能更高效地工作。

5.2 编译器优化对协同效应的增强

  1. 函数内联:当小函数被内联时,其局部变量的活跃范围与调用者的活跃范围合并。这给了编译器更大的视野,有时可以将更多的变量放入寄存器,甚至使得原本因为函数调用边界而需要溢出的变量得以常驻寄存器。这直接减少了对架构寄存器的“压力”,进而也减少了寄存器重命名需要处理的假依赖的可能性。
  2. 循环优化:在循环中,编译器会特别关注循环变量和循环体内部的局部变量。将它们放入寄存器可以避免每次迭代都进行内存访问。当这些循环变量被成功分配到不同的架构寄存器时,即使在循环体内部,寄存器重命名也能在乱序执行时更好地处理这些变量,避免因为架构寄存器冲突而导致的停顿。
  3. 常量传播与死代码消除:这些优化减少了需要处理的变量和指令数量,使得寄存器分配和重命名任务更加简化。

5.3 寄存器重命名对C++局部变量的隐式加速

即使编译器已经尽力将局部变量分配到了不同的架构寄存器,在复杂的乱序执行场景下,仍然可能出现架构寄存器的复用。例如,在紧密循环中,即使编译器将循环迭代变量分配到 RCX,下一条指令可能又需要使用 RCX 进行其他操作。此时,寄存器重命名就能介入,通过分配新的物理寄存器来消除这种假依赖,从而允许指令并行执行。

简而言之:

  • 编译器通过将C++局部变量映射到架构寄存器,尽量减少内存访问,提升基础性能。
  • 寄存器重命名硬件则在此基础上,通过消除架构寄存器带来的假依赖,进一步提升指令级并行性,让CPU能够更充分地利用其乱序执行能力。

两者共同作用,使得C++局部变量在运行时能够以最快的速度被CPU处理。


六、 实践建议与考量

理解这些底层机制,可以指导我们编写更高效的C++代码。

  1. 保持函数短小精悍

    • 短函数通常意味着更少的局部变量和更短的活跃范围。这使得编译器更容易进行寄存器分配,减少溢出。
    • 也增加了函数内联的可能性,进一步优化寄存器使用。
  2. 最小化变量作用域

    • 在需要时才声明变量,并在它们不再需要时让它们超出作用域。
    • 例如,在 for 循环内部声明循环变量 (for (int i = 0; ...) ) 而不是在循环外部。这使得编译器可以更早地回收寄存器。
    // 推荐
    for (int i = 0; i < N; ++i) {
        // ... 使用 i
    }
    // i 在循环结束后失效,寄存器可被重用
    
    // 不推荐,除非有特殊需求
    int i;
    for (i = 0; i < N; ++i) {
        // ... 使用 i
    }
    // i 在整个函数作用域内都活跃,可能占用寄存器更久
  3. 避免不必要的全局变量和堆分配

    • 全局变量通常无法被分配到寄存器,因为它们的生命周期是整个程序,且可能被多个线程访问。
    • 堆分配(new/malloc)的数据也总是在内存中,访问速度慢。
    • 对于短生命周期、频繁访问的数据,优先使用局部变量。
  4. 利用 const 关键字

    • const 变量可以帮助编译器识别哪些值不会改变。这有时可以简化数据流分析,甚至允许编译器进行更激进的优化,例如将常量直接嵌入指令或更好地分配到寄存器。
  5. 理解编译器的优化级别

    • 始终在发布版本中使用 -O2-O3 等优化级别。现代编译器在这些级别下表现出色。
    • 但在调试时,可以关闭优化(-O0),因为优化后的代码可能难以调试。
  6. 分析汇编代码和性能剖析

    • 当需要深入了解性能瓶颈时,查看编译器生成的汇编代码(g++ -S)可以帮助我们理解变量是如何被分配的,以及是否存在不必要的内存访问。
    • 使用性能剖析工具(如 perf, VTune, Valgrind)可以识别热点代码和实际的性能瓶颈,指导进一步的优化。
  7. 避免过度依赖微优化

    • 虽然理解底层机制很重要,但现代编译器非常智能。通常情况下,编写清晰、符合C++最佳实践的代码,并依赖编译器的优化,比手动进行微优化更有效。
    • 过早的优化是万恶之源。只有在有明确性能瓶颈的情况下,才应该考虑深入到汇编级别进行优化。

C++局部变量的简洁性背后,隐藏着编译器和硬件微架构之间一场复杂而精妙的协同舞蹈。编译器尽力将短生命周期、高访问频率的局部变量映射到有限的架构寄存器中,以减少对内存的访问。而CPU的寄存器重命名机制则在此基础上,动态地消除架构寄存器复用带来的假依赖,从而实现指令的乱序执行和最大化的并行处理。理解这一系列机制,不仅能帮助我们写出更高效的C++代码,更能让我们对现代计算机系统的性能优化有更深刻的洞察。这场编译器与硬件的协同艺术,是高性能计算的基石。

发表回复

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