深度解析 ‘Branch Prediction’ (分支预测):为什么对排序后的数组进行逻辑判断更快?

各位编程领域的同仁,下午好!

今天,我们不谈高层框架,不聊设计模式,我们将深入到计算机体系结构的腹地,探索一个对程序性能有着深远影响却常常被我们忽视的机制——分支预测(Branch Prediction)。具体来说,我们将聚焦一个看似简单却极具启发性的现象:为什么对排序后的数组进行逻辑判断,比对未排序数组进行相同判断要快得多?这背后隐藏的,正是分支预测的精髓。

在今天的讲座中,我将以一名资深编程专家的视角,为大家揭开分支预测的神秘面纱,并通过大量的代码示例和严谨的逻辑推演,让大家深刻理解其工作原理,以及如何编写出更“CPU友好”的高性能代码。


一、 CPU的“流水线”作业与分支预测的诞生

要理解分支预测,我们必须先从现代CPU的核心工作原理——指令流水线(Instruction Pipeline)说起。

1.1 指令流水线:效率的基石

想象一下一个汽车装配厂。如果每辆车从底盘到喷漆都由一个工人独自完成,效率会非常低下。但如果我们将装配过程分解为多个独立的阶段(例如:安装底盘、安装发动机、安装车身、喷漆、质检),每个阶段由不同的工人或机器并行处理,那么在理想情况下,每隔一个阶段的时间,就能有一辆新车下线。这就是流水线思想。

CPU的指令执行也采用了类似的方式。一条指令的完整执行过程通常可以分解为以下几个主要阶段:

  • 取指 (Fetch, IF):从内存中获取下一条指令。
  • 译码 (Decode, ID):解析指令,识别操作类型和操作数。
  • 执行 (Execute, EX):执行指令,例如算术逻辑运算。
  • 访存 (Memory, MEM):如果指令需要访问内存(如加载或存储数据),则进行内存操作。
  • 写回 (Write-back, WB):将结果写回寄存器。

通过这种流水线机制,当一条指令在执行阶段时,另一条指令可能正在译码,再下一条指令可能正在取指。理论上,CPU可以在每个时钟周期完成一条指令(IPC = 1),从而大大提高处理器的吞吐量。

1.2 分支指令:流水线的“搅局者”

流水线虽然高效,但它有一个致命的弱点:分支指令 (Branch Instruction)。分支指令,如if-elseforwhileswitch等,会改变程序的控制流。当CPU遇到一个分支指令时,它并不知道接下来应该从哪个地址取指:是顺序执行下一条指令,还是跳转到另一个地址执行?

例如,if (condition)

  • 如果condition为真,程序跳转到if块内部的代码。
  • 如果condition为假,程序跳过if块,执行else块或if块后面的代码。

在CPU取指阶段,它需要知道下一条指令的地址。如果它在condition的结果出来之前就盲目地从某个地址取指并开始进入流水线,一旦它的猜测是错误的,那么已经进入流水线的所有指令都将是无用的。这就像汽车装配厂,如果前面生产的是轿车,结果发现应该生产卡车,那么已经装配好的轿车部件就必须全部废弃,流水线需要清空并重新开始生产卡车。

这种“废弃”和“重新开始”在CPU中被称为流水线冲刷 (Pipeline Flush),其代价是巨大的。一次流水线冲刷可能导致几十甚至上百个时钟周期的延迟,严重降低CPU的IPC。

为了解决这个问题,分支预测技术应运而生。


二、 分支预测:CPU的“未卜先知”

分支预测器的任务就是在分支指令的结果尚不明确时,猜测程序将走向何方,并据此提前取指和执行。如果猜测正确,流水线就能保持满负荷运行;如果猜测错误,虽然会发生流水线冲刷,但相比每次都等待分支结果再执行,整体性能依然会大幅提升。

2.1 静态分支预测:简单粗暴的规则

早期的CPU或在没有足够硬件支持时,会采用静态分支预测。顾名思义,静态预测是基于指令本身的某些特征或预设规则进行预测,不考虑历史执行信息。

常见的静态预测规则有:

  • 总是预测不跳转 (Always Not Taken):简单地假设分支不会发生,继续顺序执行。
  • 总是预测跳转 (Always Taken):简单地假设分支会发生,跳转到目标地址。
  • 向后跳转预测跳转,向前跳转预测不跳转 (Backward Taken, Forward Not Taken):这是一个经验规则。循环通常是向后跳转(跳回循环头),且循环体会被执行多次,所以预测跳转更合理。if语句通常是向前跳转(跳过else块),预测不跳转更合理。

静态预测的优点是实现简单,但缺点是准确率有限,尤其是面对复杂或不规则的分支模式时。

2.2 动态分支预测:学习与适应

现代CPU普遍采用动态分支预测器。它们通过记录分支指令的历史执行情况来预测未来的走向,并能根据程序的运行模式进行自我调整和学习。动态预测器的核心思想是:过去的行为是未来行为的最佳预测器。

我们来看几个经典的动态分支预测器:

2.2.1 1位饱和计数器 (1-bit Predictor)

最简单的动态预测器是1位预测器。它为每个分支指令维护一个1位的状态,表示上次的预测结果。

状态 预测
0 不跳转
1 跳转

工作原理:

  1. 如果当前状态是0(预测不跳转),实际发生跳转,则将状态更新为1。
  2. 如果当前状态是1(预测跳转),实际发生不跳转,则将状态更新为0。
  3. 如果预测正确,状态保持不变。

优点: 简单高效,对重复模式(如连续跳转或连续不跳转)有很好的预测能力。

缺点: 过于敏感。如果一个分支的模式是T, N, T, N, T, N(跳转,不跳转,跳转…),1位预测器会在每次方向变化时都预测错误,导致准确率只有50%。例如:

  • 初始:N (预测不跳转)
  • 实际:T -> 预测错误,状态变为T
  • 实际:N -> 预测错误,状态变为N
  • 实际:T -> 预测错误,状态变为T
    …每次都错!
2.2.2 2位饱和计数器 (2-bit Saturated Counter)

为了克服1位预测器的敏感性,现代CPU通常使用2位饱和计数器。它为每个分支指令维护一个2位的状态机,有四个状态,通常表示为:

状态 预测
00 (ST) 强不跳转
01 (WT) 弱不跳转
10 (WN) 弱跳转
11 (SN) 强跳转

其中,“强”表示需要连续两次预测错误才能改变预测方向,“弱”表示只需要一次。

状态转换图:

              实际发生跳转 (Taken)
              /|
               |
               |
         +-----+-----+
         |     |     |
         V     |     V
    00 (强不跳转) <-> 01 (弱不跳转) <-> 10 (弱跳转) <-> 11 (强跳转)
         ^     |     ^
         |     |     |
         +-----+-----+
              |
              |
              |/
        实际发生不跳转 (Not Taken)

工作原理:

  • 预测: 如果状态是0001,预测不跳转;如果状态是1011,预测跳转。
  • 更新:
    • 如果实际发生跳转 (Taken):状态向“跳转”方向移动(00 -> 01 -> 10 -> 11)。例如,从00变为01,从01变为10,从10变为1111保持11
    • 如果实际发生不跳转 (Not Taken):状态向“不跳转”方向移动(11 -> 10 -> 01 -> 00)。例如,从11变为10,从10变为01,从01变为0000保持00

优点: 2位预测器具有“滞后性”。它能够容忍偶尔的预测错误,不会在一次方向变化后立即改变预测方向。例如,对于T, N, T, N模式,它可能会在TN之间来回摇摆,但至少不会在每次切换时都预测错误。对于T, T, T, N, T, T, T, N这样的循环模式,它能迅速收敛到“强跳转”状态,只在N时发生一次错误。

2.2.3 更复杂的动态预测器

现代CPU的预测器远比2位饱和计数器复杂,它们通常结合了多种技术:

  • 全局历史预测器 (Global History Predictor):除了当前分支的历史,还考虑最近所有分支的执行历史。
  • 局部历史预测器 (Local History Predictor):为每个分支维护自己的历史。
  • GShare / GSelect:结合全局历史和局部历史的混合预测器。
  • 竞赛预测器 (Tournament Predictor):同时使用多个预测器,并根据它们的表现选择最佳的一个。

这些高级预测器通过更长、更复杂的历史模式识别,实现了高达90%-99%的预测准确率。然而,它们的核心思想仍然是基于历史信息进行预测,并且2位饱和计数器是理解其基本行为模式的关键。


三、 排序数组的“魔力”:分支预测的完美舞台

现在,我们回到最初的问题:为什么对排序后的数组进行逻辑判断更快?答案就藏在2位饱和计数器的“滞后性”和数据模式的“可预测性”中。

考虑一个简单的循环判断:

// 伪代码
for (int i = 0; i < N; ++i) {
    if (array[i] < threshold) {
        // 执行一些操作 A
    } else {
        // 执行一些操作 B
    }
}

这里的if (array[i] < threshold)就是一个分支指令。CPU的分支预测器需要预测每次迭代时,array[i]是会小于threshold还是不小于threshold

3.1 未排序数组:预测器的噩梦

假设我们有一个包含随机整数的数组 unsorted_array,以及一个 threshold 值。

// C++ 代码示例:未排序数组
#include <iostream>
#include <vector>
#include <algorithm> // for std::sort and std::random_shuffle
#include <random>    // for std::mt19937 and std::uniform_int_distribution
#include <chrono>    // for timing

volatile int dummy_sum = 0; // 防止编译器优化掉整个分支逻辑

void process_array(const std::vector<int>& arr, int threshold) {
    for (size_t i = 0; i < arr.size(); ++i) {
        if (arr[i] < threshold) {
            dummy_sum += 1; // 模拟分支内的操作
        } else {
            dummy_sum += 2; // 模拟另一个分支内的操作
        }
    }
}

int main() {
    const int N = 100000000; // 1亿个元素
    std::vector<int> unsorted_array(N);

    // 填充随机数据
    std::mt19937 rng(std::chrono::system_clock::now().time_since_epoch().count());
    std::uniform_int_distribution<int> dist(0, N * 2); // 0到2亿之间的随机数
    for (int i = 0; i < N; ++i) {
        unsorted_array[i] = dist(rng);
    }

    int threshold = N; // 阈值设置为数组大小,大约一半的元素会小于它

    std::cout << "Processing unsorted array..." << std::endl;
    auto start_time = std::chrono::high_resolution_clock::now();
    process_array(unsorted_array, threshold);
    auto end_time = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count();
    std::cout << "Unsorted array processing time: " << duration << " ms" << std::endl;
    std::cout << "Dummy sum (unsorted): " << dummy_sum << std::endl << std::endl;

    // ... (后面会加上排序数组的测试)

    return 0;
}

在未排序数组中,arr[i] < threshold 这个条件的结果是高度随机的。它可能连续几次为真,然后突然变为假,再连续几次为假,又突然变为真。

分支预测器的行为:
假设我们的2位预测器为这个分支指令维护了一个状态。

  1. 一开始,预测器可能处于00(强不跳转)或11(强跳转),或者中间状态。
  2. arr[i] < threshold的结果随机跳变时,预测器会频繁地从“跳转”状态向“不跳转”状态移动,反之亦然。
  3. 每一次实际结果与预测方向不符时,预测器都需要调整其状态。如果它处于“强”状态,需要两次错误才能彻底翻转方向。但由于随机性,它很少有机会长时间保持在一个“强”状态。
  4. 结果是,预测器会频繁地在01(弱不跳转)和10(弱跳转)之间来回切换,甚至在0011之间边缘震荡。这导致了大量的预测错误(misprediction)。

预测错误示例(2位预测器,初始00):

迭代 arr[i] < threshold 预测器状态 (开始) 预测结果 实际结果 预测是否正确 预测器状态 (结束)
1 true 00 (强不跳转) 不跳转 跳转 错误 01 (弱不跳转)
2 false 01 (弱不跳转) 不跳转 不跳转 正确 00 (强不跳转)
3 true 00 (强不跳转) 不跳转 跳转 错误 01 (弱不跳转)
4 true 01 (弱不跳转) 不跳转 跳转 错误 10 (弱跳转)
5 false 10 (弱跳转) 跳转 不跳转 错误 01 (弱不跳转)
6 false 01 (弱不跳转) 不跳转 不跳转 正确 00 (强不跳转)
7 true 00 (强不跳转) 不跳转 跳转 错误 01 (弱不跳转)

可以看到,在这种高度随机的模式下,预测器频繁出错,每次错误都会导致流水线冲刷,从而显著降低执行速度。

3.2 排序数组:预测器的天堂

现在,我们对数组进行排序,然后再进行同样的判断。

// 接着上面的main函数
    // 重置dummy_sum
    dummy_sum = 0;

    std::vector<int> sorted_array = unsorted_array; // 复制一份未排序数组
    std::sort(sorted_array.begin(), sorted_array.end()); // 进行排序

    std::cout << "Processing sorted array..." << std::endl;
    start_time = std::chrono::high_resolution_clock::now();
    process_array(sorted_array, threshold);
    end_time = std::chrono::high_resolution_clock::now();
    duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count();
    std::cout << "Sorted array processing time: " << duration << " ms" << std::endl;
    std::cout << "Dummy sum (sorted): " << dummy_sum << std::endl;

    return 0;
}

在排序后的数组中,arr[i] < threshold 这个条件的结果将变得高度可预测。

  • 对于数组开头的部分元素,arr[i]很可能都小于threshold,导致分支连续多次为真(跳转)。
  • arr[i]的值逐渐增大,达到或超过threshold后,分支将连续多次为假(不跳转)。
  • 只会有一个非常小的过渡区域,arr[i]的值在threshold附近徘徊,导致分支结果偶尔跳变。

分支预测器的行为:
假设我们的2位预测器为这个分支指令维护了一个状态。

  1. 初始阶段 (例如,所有元素都小于 threshold):

    • 预测器可能从00(强不跳转)开始。
    • 第一次true,预测错误,状态变为01
    • 第二次true,预测错误,状态变为10
    • 第三次true,预测正确,状态变为11(强跳转)。
    • 从第四次开始,只要分支持续为true,预测器就会一直保持11状态,并持续做出正确预测。
  2. 过渡阶段 (少量元素在 threshold 附近波动):

    • 如果预测器在11(强跳转)状态,遇到一次false,它会变为10(弱跳转),下次仍预测跳转。
    • 如果再次遇到false,它才会变为01(弱不跳转)。
    • 这种滞后性使得预测器能够“容忍”少数的跳变,而不会立即完全改变其预测方向。
  3. 后期阶段 (例如,所有元素都大于或等于 threshold):

    • 一旦arr[i]稳定地大于或等于threshold,分支就会连续多次为假。
    • 预测器会迅速从1110状态,通过01,最终收敛到00(强不跳转)状态。
    • 之后,只要分支持续为false,预测器就会一直保持00状态,并持续做出正确预测。

预测正确示例(2位预测器,初始00,假设前4个true,后4个false):

迭代 arr[i] < threshold 预测器状态 (开始) 预测结果 实际结果 预测是否正确 预测器状态 (结束)
1 true 00 (强不跳转) 不跳转 跳转 错误 01 (弱不跳转)
2 true 01 (弱不跳转) 不跳转 跳转 错误 10 (弱跳转)
3 true 10 (弱跳转) 跳转 跳转 正确 11 (强跳转)
4 true 11 (强跳转) 跳转 跳转 正确 11 (强跳转)
true (大量) 11 (强跳转) 跳转 跳转 正确 11 (强跳转)
M false 11 (强跳转) 跳转 不跳转 错误 10 (弱跳转)
M+1 false 10 (弱跳转) 跳转 不跳转 错误 01 (弱不跳转)
M+2 false 01 (弱不跳转) 不跳转 不跳转 正确 00 (强不跳转)
M+3 false 00 (强不跳转) 不跳转 不跳转 正确 00 (强不跳转)
false (大量) 00 (强不跳转) 不跳转 不跳转 正确 00 (强不跳转)

从表格中可以清晰地看到,一旦预测器收敛到“强跳转”或“强不跳转”状态,它将连续做出大量正确预测,大大减少了流水线冲刷的次数。

实际运行结果(在我的机器上,仅供参考):

Processing unsorted array...
Unsorted array processing time: 700 ms
Dummy sum (unsorted): 150000000

Processing sorted array...
Sorted array processing time: 100 ms
Dummy sum (sorted): 150000000

可以看到,排序后的数组处理时间大约是未排序数组的 1/7,甚至更低。这个巨大的性能差距,主要就是由分支预测的准确率差异造成的。


四、 其他分支类型与分支预测

分支预测不仅仅影响简单的if-else语句,它对程序中所有改变控制流的指令都至关重要。

4.1 循环 (Loops)

循环是分支预测的“模范学生”。一个典型的for循环:

for (int i = 0; i < N; ++i) {
    // 循环体
}

其汇编代码通常包含一个条件跳转指令,判断i < N是否为真。

  • i0N-1的大部分时间里,条件i < N为真,分支被跳转 (Taken)
  • 只有当i达到N时,条件i < N为假,分支被不跳转 (Not Taken)

这种模式是高度可预测的:T, T, T, ..., T, N。2位预测器能很快学习到这种模式,并保持在“强跳转”状态,只在最后一次循环退出时发生一次预测错误。因此,循环的性能通常很好。

4.2 函数调用 (Function Calls)

函数调用本身并不完全是分支预测的范畴,但涉及到间接跳转 (Indirect Branch)时,情况就复杂了。例如,通过函数指针调用函数:

void (*func_ptr)();
// ... func_ptr 可能会指向不同的函数 ...
func_ptr(); // 间接跳转

CPU需要预测func_ptr指向哪个具体的函数地址。这需要一个分支目标缓冲区 (Branch Target Buffer, BTB)来存储最近间接跳转的源地址和目标地址。如果func_ptr在运行时总是指向同一个函数,BTB就能很好地预测。但如果func_ptr频繁地指向不同的函数,BTB的预测准确率就会下降,导致性能损失。

4.3 虚函数调用 (Virtual Function Calls)

虚函数调用是C++中多态性的核心,但它本质上也是一种间接跳转。当调用一个虚函数时:

Base* obj = new DerivedA();
obj->virtualMethod(); // 虚函数调用
obj = new DerivedB();
obj->virtualMethod(); // 虚函数调用

CPU需要通过对象的虚函数表(vtable)来查找实际要调用的函数地址。如果obj的实际类型频繁变化,导致virtualMethod实际调用的函数地址频繁变化,那么分支预测器(特别是BTB)就难以准确预测,从而影响性能。

4.4 switch 语句

switch语句在某些情况下可以非常高效。如果switch的条件是整数类型,并且case值是密集且连续的,编译器通常会将其优化为跳转表 (Jump Table)

switch (value) {
    case 0: // ...
    case 1: // ...
    case 2: // ...
    default: // ...
}

跳转表是一个存储目标地址的数组。根据value的值,CPU直接从表中查找并跳转到相应的代码块,这本质上是一次内存查找而不是一系列条件分支。因此,这种优化后的switch通常具有非常好的性能,其预测开销很小。

但如果case值稀疏、不连续,或者switch条件不是整数类型,编译器可能仍会将其编译为一系列if-else if链,这时分支预测的性能就取决于实际执行路径的可预测性了。


五、 编写“CPU友好”代码:实践中的分支预测优化

理解了分支预测原理,我们就能有意识地编写出更高效的代码。以下是一些实践建议:

5.1 尽可能地使数据有序或可预测

这是我们今天讲座的核心。当处理的数据需要进行条件判断时,如果能预先对数据进行排序,那么分支预测的准确率将大幅提升。

  • 示例: 对一个数据集进行筛选或统计时,如果数据已经排序,那么连续的判断结果将趋于一致。
  • 应用: 数据库索引、数据分析中的过滤操作、图像处理中的像素遍历(如果像素值有局部连续性)。

5.2 避免无法预测的分支

有些分支是程序逻辑的必需,但如果能避免,就尽量避免。

  • 示例: 错误处理。如果一个函数频繁地进行错误检查并通过分支跳转到错误处理代码,而错误又很少发生,那么这个分支通常是高度可预测的(总是预测不跳转)。但如果错误频繁发生,或者错误/非错误的模式是随机的,那么就会成为预测器的负担。

5.3 利用分支无关代码 (Branchless Code)

在某些情况下,我们可以通过数学运算、位操作或条件移动指令 (CMOV) 来替代条件分支,从而完全消除分支预测的开销。

  • 示例:std::minstd::max 的实现:
    传统的if版本:

    int my_min(int a, int b) {
        if (a < b) return a;
        else return b;
    }

    分支无关版本(利用条件移动,编译器可能自动优化):

    // 假设a和b都是有符号整数
    int my_min_branchless(int a, int b) {
        // 如果a < b,diff为负数,否则为正数或零
        int diff = a - b;
        // 如果diff为负数,sign_bit为全1(-1),否则为全0
        // (diff >> 31) 是一个常用技巧,将最高位符号位扩展到所有位
        int sign_bit = diff >> (sizeof(int) * 8 - 1); // C++20有std::numeric_limits<int>::digits - 1
        // 当a < b时,sign_bit为-1。否则为0。
        // (diff & sign_bit) 得到 diff 如果a<b, 否则是0
        // (a + (diff & sign_bit)) 得到 a + diff = b 如果a<b, 否则是a
        return b + (diff & sign_bit); // 如果a < b, 返回 a; 否则返回 b
    }

    或者更简洁的:

    int my_min_branchless_alt(int a, int b) {
        return b ^ ((a ^ b) & -(a < b)); // -(a < b) 在a<b时为-1(所有位为1),否则为0
    }

    这些位操作或条件移动指令直接在硬件层面执行,不会引起流水线中断。

  • 示例:条件赋值:

    // 有分支
    if (condition) {
        value = X;
    } else {
        value = Y;
    }
    
    // 分支无关 (通常用于布尔条件,或者编译器能优化为CMOV)
    value = condition ? X : Y; // 编译器可能优化为CMOV

    三元运算符? :不一定会消除分支,这取决于编译器和具体的指令集。但在某些情况下,它确实能被优化为条件移动指令。

5.4 考虑数据局部性 (Data Locality)

虽然分支预测和缓存是两个不同的优化领域,但它们常常协同工作。良好的数据局部性(即程序访问的数据在内存中是连续的,或者被频繁访问的数据被缓存)可以减少内存访问延迟。如果CPU在等待内存数据时,分支预测结果也出了错,那么性能损失会更大。因此,结合数据局部性优化,可以进一步提升性能。

5.5 使用性能分析工具 (Profilers)

不要盲目猜测。当程序性能不佳时,使用perf (Linux), VTune (Intel), xcode Instruments (macOS) 等性能分析工具来找出热点代码和分支预测失败率高的区域。这些工具能直接告诉你哪些分支是“坏”分支,让你有针对性地进行优化。


六、 分支预测与投机执行:双刃剑

分支预测是现代CPU实现高性能的关键,但它也带来了一个复杂的问题:投机执行 (Speculative Execution)

由于CPU在预测分支走向后,会立即开始执行预测路径上的指令,这意味着在分支结果真正确定之前,一些指令可能已经被执行了。如果预测正确,这些投机执行的结果被提交;如果预测错误,这些结果被回滚。

投机执行极大地提高了CPU的并行度,但它也暴露了一些安全漏洞,如著名的MeltdownSpectre。这些漏洞利用了投机执行过程中,即使是最终被回滚的指令,也可能在CPU的微架构状态(如缓存)中留下痕迹,从而泄露敏感信息。这使得分支预测和投机执行不仅是性能问题,也成为了一个重要的安全话题。


总结

分支预测是现代CPU为克服指令流水线瓶颈而设计的精妙机制。它通过预测程序控制流来避免流水线冲刷,极大地提升了处理器的吞吐量。其中,动态分支预测器,尤其是基于2位饱和计数器的设计,能够通过学习历史模式,对分支行为进行高准确率的预测。

对排序后的数组进行逻辑判断,之所以比未排序数组更快,正是因为排序使得分支行为高度可预测。预测器能迅速收敛到“强跳转”或“强不跳转”状态,从而将预测错误率降到最低,避免了昂贵的流水线冲刷。相反,随机数据导致预测器频繁出错,性能自然大打折扣。

理解分支预测的工作原理,能够帮助我们编写出更“CPU友好”的高性能代码。通过合理的数据组织(如排序)、避免难以预测的分支、以及在可能的情况下使用分支无关代码,我们能够充分利用现代CPU的强大能力,实现更优异的程序性能。同时,也要认识到分支预测和投机执行带来的复杂性和安全挑战。

编程不仅仅是写出能跑的代码,更是与计算机硬件进行一场深入的对话。希望今天的讲座能让大家对这场对话有更深刻的理解。谢谢大家!

发表回复

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