先说几个辅助的宏,因为内核不支持浮点运算,当然还有实现需要,minstrel对很多浮点值做了缩放:

/* scaled fraction values */
#define MINSTREL_SCALE    16
#define MINSTREL_FRAC(val, div) (((val) << MINSTREL_SCALE) / div)
#define MINSTREL_TRUNC(val) ((val) >> MINSTREL_SCALE) 

MINSTREL_SCALE是一个放大的倍数,minstrel设定的是16,缩放16位也就是2^16倍,我不知道为什么要设置成这么大的数,不过没有关系不影响理解。MINSTREL_FRAC是两个数相除之后放大,MINSTREL_TRUNC则是逆运算,将某个被放大的数缩小。

下面进行源码分析,根据对外暴露的结构体对指针函数的定义:

static struct rate_control_ops mac80211_minstrel_ht = {.name = "minstrel_ht",.tx_status = minstrel_ht_tx_status,.get_rate = minstrel_ht_get_rate,.rate_init = minstrel_ht_rate_init,.rate_update = minstrel_ht_rate_update,.alloc_sta = minstrel_ht_alloc_sta,.free_sta = minstrel_ht_free_sta,.alloc = minstrel_ht_alloc,.free = minstrel_ht_free,
#ifdef CONFIG_MAC80211_DEBUGFS.add_sta_debugfs = minstrel_ht_add_sta_debugfs,.remove_sta_debugfs = minstrel_ht_remove_sta_debugfs,
#endif
};

我们需要着重关注核心的三个函数:tx_status负责每次发送完聚合帧之后根据ACK的状况更新各个速率状态,get_rate负责每次要发新的数据包的时候指定发送速率,rate_init在与另一站点建立连接的时候初始化相关参数。下面首先介绍一个抽随机速率用的表的生成,然后按照rate_initget_ratetx_status的顺序介绍minstrel的原理。

  1. 初始化探测速率表

minstrel对速率的管理是通过速率组来管理的,这关乎几个重要的变量:

const struct mcs_group minstrel_mcs_groups[];
static u8 sample_table[SAMPLE_COLUMNS][MCS_GROUP_RATES];
mi->groups[]

先说minstrel_mcs_groups,我的实验环境最多支持双流,minstrel_mcs_groups也就是一个长度为8的数组,如果是三流就是长度为12的数组,这8个group的配置按顺序分别是:

组号 空间流树 是否支持SGI 20MHz/40MHz
0 1 20
1 2 20
2 1 20
3 2 20
4 1 40
5 2 40
6 1 40
7 2 40

这个变量存的是这几个速率组不变的配置信息,mi->groups这个数组和minstrel_mcs_groups相对应,存储在对应配置下8个速率的动态数据统计。

sample_table是随机生成的一个速率表,本节就是讲这个表的生成和作用。

当Minstrel模块加载的时候,首先初始化速率表,也就是sample_table,当需要进行探测的时候,探测顺序就是以这个速率表做参考:

static u8 sample_table[SAMPLE_COLUMNS][MCS_GROUP_RATES];
static void
init_sample_table(void)
{int col, i, new_idx;u8 rnd[MCS_GROUP_RATES];memset(sample_table, 0xff, sizeof(sample_table));for (col = 0; col < SAMPLE_COLUMNS; col++) {for (i = 0; i < MCS_GROUP_RATES; i++) {get_random_bytes(rnd, sizeof(rnd));new_idx = (i + rnd[i]) % MCS_GROUP_RATES;while (sample_table[col][new_idx] != 0xff)new_idx = (new_idx + 1) % MCS_GROUP_RATES;sample_table[col][new_idx] = i;}}
}

SAMPLE_COLUMNS的默认取值是10,MCS_GROUP_RATES是每组MCS中有几个速率,也就是8(单流、双流、三流里面各有8个速率),这个速率取样表就是一个10*8的方阵。这段函数生成的速率表,一共10行,每一行都是0-7这七个数字的随机分布,minstrel是随机探测,但是用哪个速率不是在发送的时候才随机获取的,而是提前随机生成这个表,发送的时候依次遍历这个表,所以说这个速率表其实没有什么讲究,就是避免了在运行过程中频繁的抽随机数罢了,这个表已经提前生成了10组MCS随机排列的序列,运行过程中只要顺序读取,就是随机探测了。

  1. 初始化探测的相关参数

minstrel_ht_rate_initminstrel_ht_rate_update分别在连接站点初始化或者需要更新的时候被调用,他们的本质都是调用了minstrel_ht_update_caps,其中和速率调整相关的呢是这么几句:

mi->avg_ampdu_len = MINSTREL_FRAC(1, 1);/* When using MRR, sample more on the first attempt, without delay */
if (mp->has_mrr) {mi->sample_count = 16;mi->sample_wait = 0;
} else {mi->sample_count = 8;mi->sample_wait = 8;
}
mi->sample_tries = 4;
……
for (i = 0; i < ARRAY_SIZE(mi->groups); i++) {u16 req = 0;mi->groups[i].supported = 0;if (minstrel_mcs_groups[i].flags & IEEE80211_TX_RC_SHORT_GI) {if (minstrel_mcs_groups[i].flags & IEEE80211_TX_RC_40_MHZ_WIDTH)req |= IEEE80211_HT_CAP_SGI_40;elsereq |= IEEE80211_HT_CAP_SGI_20;}if (minstrel_mcs_groups[i].flags & IEEE80211_TX_RC_40_MHZ_WIDTH)req |= IEEE80211_HT_CAP_SUP_WIDTH_20_40;if ((sta_cap & req) != req)continue;mi->groups[i].supported =mcs->rx_mask[minstrel_mcs_groups[i].streams - 1];if (mi->groups[i].supported)n_supported++;
}if (!n_supported)goto use_legacy;

这一段的主要作用呢,就是初始化平均AMPDU长度(聚合帧长度)为1,因为我们需要MRR(Multi-rate retry,多速率重传),设置sample_count=16\sample_wait=0\sample_tries=4,这三个参数和探测的频率相关,后面会介绍,最后,确定本文最前面介绍的关于(空间流数、SGI、带宽)设置的数组中的每一数组项是不是被硬件支持。

  1. 发送时确定发送速率

minstrel_ht_get_rate是速率调整最重要的两个函数之一,它决定了当前待发送数据包的发送速率。

if (rate_control_send_low(sta, priv_sta, txrc))
return;

首先,当目标站点不存在,或者本次发送不需要等ACK的时候,为了确保数据包尽可能被对方正确接收,那么会直接用传统速率来发送,不给它分配MCS速率。

下面获取本次取样的速率:

sample_idx = minstrel_get_sample_rate(mp, mi);

深入minstrel_get_sample_rate:

mg = &mi->groups[mi->sample_group];
sample_idx = sample_table[mg->column][mg->index];
mr = &mg->rates[sample_idx];
sample_idx += mi->sample_group * MCS_GROUP_RATES;
minstrel_next_sample_idx(mi);

前面已经介绍,有一个随机生成的速率表用作获取随机数用,首先在这个速率表中选取下一个sample_idx,之后,调用minstrel_next_sample_idxmi->sample_group指向下一个可用的group,这个group就是前面介绍的拥有空间流数、SGI、带宽配置的组了。然后sample_idx在自身取值的基础上加上了sample_group*MCS_GROUP_RATES,所以这个sample_idx可以用来得到当前用的第几个取样组,比如sample_idx=27,那么就是8*3+4,它表示的就是第4个速率组(双流、SGI、20MHz)的第四个速率,因为是双流,就表示MCS11。正常情况下,minstrel_get_sample_rate这个函数会把sample_idx返回,如果不探测,则返回-1,下面继续看minstrel_get_sample_rate的几个返回-1的情况:

if (!mp->has_mrr && (mr->probability > MINSTREL_FRAC(95, 100)))
return -1;

如果不支持mrr(多速率重传),并且当前速率的投递率已经达到了95%以上,就不再取样。

if (minstrel_get_duration(sample_idx) >minstrel_get_duration(mi->max_tp_rate)) {if (mr->sample_skipped < 20)return -1;if (mi->sample_slow++ > 2)return -1;
}

这里把刚刚得到的这个要取样的速率sample_idx和当前吞吐率最高的速率进行比较,那么这个duration是个什么东西?这个duration就是对网卡用当前速率发送一个数据包所用时间的估算,duration越大,基本等效于速率值越低,具体含义如下:

#define AVG_PKT_SIZE    1200
#define MCS_NBITS (AVG_PKT_SIZE << 3)
#define MCS_NSYMS(bps) ((MCS_NBITS + (bps) - 1) / (bps))#define MCS_SYMBOL_TIME(sgi, syms)                    \
    (sgi ?                                \
      ((syms) * 18 + 4) / 5 :    /* syms * 3.6 us */        \
      (syms) << 2            /* syms * 4 us */        \
    )#define MCS_DURATION(streams, sgi, bps) MCS_SYMBOL_TIME(sgi, MCS_NSYMS((streams) * (bps)))/** Define group sort order: HT40 -> SGI -> #streams*/
#define GROUP_IDX(_streams, _sgi, _ht40)    \
    MINSTREL_MAX_STREAMS * 2 * _ht40 +    \
    MINSTREL_MAX_STREAMS * _sgi +        \
    _streams - 1/* MCS rate information for an MCS group */
#define MCS_GROUP(_streams, _sgi, _ht40)                \
    [GROUP_IDX(_streams, _sgi, _ht40)] = {                \
    .streams = _streams,                        \
    .flags =                            \
        (_sgi ? IEEE80211_TX_RC_SHORT_GI : 0) |            \
        (_ht40 ? IEEE80211_TX_RC_40_MHZ_WIDTH : 0),        \
    .duration = {                            \
        MCS_DURATION(_streams, _sgi, _ht40 ? 54 : 26),        \
        MCS_DURATION(_streams, _sgi, _ht40 ? 108 : 52),        \
        MCS_DURATION(_streams, _sgi, _ht40 ? 162 : 78),        \
        MCS_DURATION(_streams, _sgi, _ht40 ? 216 : 104),    \
        MCS_DURATION(_streams, _sgi, _ht40 ? 324 : 156),    \
        MCS_DURATION(_streams, _sgi, _ht40 ? 432 : 208),    \
        MCS_DURATION(_streams, _sgi, _ht40 ? 486 : 234),    \
        MCS_DURATION(_streams, _sgi, _ht40 ? 540 : 260)        \
    }                                \
}

这一段宏的定义比较复杂,简单来说,假定平均每个数据包长度是1200字节,也就是(1200<<3)即(1200*8)bits,根据每个码元(symbol)包含几个bit算出来这个数据包一共有多少码元(MCS_NSYMS),最后根据数据率计算数据包在每一个MCS参数下的发送延时。

花了这么多时间介绍minstrel_get_sample_rate,因为这个函数包括了最主要的,探测速率从哪儿来的问题,现在回到minstrel_ht_get_rate,刚才是从:

sample_idx = minstrel_get_sample_rate(mp, mi);

展开的,获取到sample_idx,后面就是重头戏了:

if (sample_idx >= 0) {sample = true;minstrel_ht_set_rate(mp, mi, &ar[0], sample_idx, true, false);info->flags |= IEEE80211_TX_CTL_RATE_CTRL_PROBE;
} else {minstrel_ht_set_rate(mp, mi, &ar[0], mi->max_tp_rate, false, false);
}

如果sample_idx为-1,那么就不探测,仍然用之前确定的最高吞吐率的速率mi->max_tp_rate来发送,除了前面说的几个情况下会返回-1之外,还有一个重要的控制,就是关于探测周期的控制,本文最后一部分会做介绍,下面继续看需要探测的情况下怎么选择速率,minstrel_ht_set_rate这个函数的其他参数可以不关注,重点是我标红的这三个,第一个是一个(struct ieee80211_tx_rate)类型的参数,最后驱动底层就是从这个结构体里面拿到发送的MCS、重传次数、发送带宽等参数,minstrel用一个ar数组来存,ar[0]是第一个速率,重传多次仍然失败的话就用ar[1],以此类推,第二个参数就是速率索引sample_idx/max_tp_rate,第三个参数代表当前帧是不是探测采样用的帧。对这个函数要重点说的,是重传策略:

if (sample)rate->count = 1;
else if (mr->probability < MINSTREL_FRAC(20, 100))rate->count = 2;
else if (rtscts)rate->count = mr->retry_count_rtscts;
elserate->count = mr->retry_count;

如果当前速率用作探测,则只发送一次,如果当前速率的投递率小于20%,则发两次,其他情况下重传次数依赖于mr->retry_count,这个变量是在每次更新各个速率状态之后更新的,如果该速率的投递率小于10%,初始为1,否则初始为2,然后根据帧平均发送时间还要适当增加。

最后就是MRR,也就是多速率重传的其它几个速率怎么设置了:

if (mp->hw->max_rates >= 3) {if (sample_idx >= 0)minstrel_ht_set_rate(mp, mi, &ar[1], mi->max_tp_rate, false, false);elseminstrel_ht_set_rate(mp, mi, &ar[1], mi->max_tp_rate2, false, true);minstrel_ht_set_rate(mp, mi, &ar[2], mi->max_prob_rate,  false, !sample);ar[3].count = 0;ar[3].idx = -1;
} else if (mp->hw->max_rates == 2) {minstrel_ht_set_rate(mp, mi, &ar[1], mi->max_prob_rate, false, !sample);ar[2].count = 0;ar[2].idx = -1;
} else {ar[1].count = 0;ar[1].idx = -1;
}

如果驱动支持的速率个数多于3,那么按照max_tp_rate>max_prob_rate的顺序来设置剩下两个速率,如果第一个速率不作为探测速率,也就是说第一个速率是用max_tp_rate,那么第二三个速率就用max_tp_rate2>max_prob_rate,如果底层支持的多速率就是能支持两个,则第二速率就用max_prob_rate发送,这个速率是投递率最高的速率,确保在有限次发送后正确传输。

  1. 更新各个速率状态

minstrel_ht_tx_status函数是当每个帧发送完成时的回调函数,但是每个聚合帧中只有一个帧携带了该聚合帧都使用了哪些速率发送、哪一次发送才成功等我们需要的信息,因此对于未携带这些信息的帧,直接返回不予处理:

if ((info->flags & IEEE80211_TX_CTL_AMPDU) &&
!(info->flags & IEEE80211_TX_STAT_AMPDU))
return;

之后根据ar[0]、ar[1]和ar[2]里面携带的实际发送次数,得到每个速率在本次发送中共发送了多少个帧(指单帧)、成功了多少个(单帧)。然后进入核心处理逻辑:

rate = minstrel_get_ratestats(mi, mi->max_tp_rate);
if (rate->attempts > 30 &&MINSTREL_FRAC(rate->success, rate->attempts) <MINSTREL_FRAC(20, 100))minstrel_downgrade_rate(mi, &mi->max_tp_rate, true);rate2 = minstrel_get_ratestats(mi, mi->max_tp_rate2);
if (rate2->attempts > 30 &&MINSTREL_FRAC(rate2->success, rate2->attempts) <MINSTREL_FRAC(20, 100))minstrel_downgrade_rate(mi, &mi->max_tp_rate2, false);if (time_after(jiffies, mi->stats_update + (mp->update_interval / 2 * HZ) / 1000)) {minstrel_ht_update_stats(mp, mi);if (!(info->flags & IEEE80211_TX_CTL_AMPDU))minstrel_aggr_check(sta, skb);
}

minstrel_downgrade_rate是把系统当前的max_tp_rate或者max_tp_rate2切换到比原来速率所在组的前一个较低或相同流数的组的对应值上,比如,现在的max_tp_rate是第3个group的max_tp_rate,此时就会判断,第2组包含的速率都是几个空间流的,如果组3是双流的,则组2是单流或双流的话,就把组2的max_tp_rate作为当前系统的max_tp_rate,如果组3是单流,但组2是双流,就会再看组1是不是单流,以此类推。

根据驱动的注释,是为了防止空间流突然不能用,比如本来用的双流,有一个流突然不起作用了,所以这里判断一下,如果max_tp_rate或者max_tp_rate2已经尝试30次以上,投递率还不超过20%,则降空间流,最后要解决的一个问题就是,每个组的max_tp_ratemax_tp_rate2是怎么算出来的,看代码的最后几行,每过(mp->update_interval / 2 * HZ) / 1000这些时间(单位是jiffies),就会更新整个速率表中各个mcs_group,针对每个mcs_group,会遍历8个MCS:

for (i = 0; i < MCS_GROUP_RATES; i++) {if (!(mg->supported & BIT(i)))continue;mr = &mg->rates[i];mr->retry_updated = false;index = MCS_GROUP_RATES * group + i;minstrel_calc_rate_ewma(mr);minstrel_ht_calc_tp(mi, group, i);

针对每一个速率,也就是mr,都会调用minstrel_calc_rate_ewma去计算吞吐率和投递率的加权平均:

mr->sample_skipped = 0;
mr->cur_prob = MINSTREL_FRAC(mr->success, mr->attempts);
if (!mr->att_hist)mr->probability = mr->cur_prob;
elsemr->probability = minstrel_ewma(mr->probability, mr->cur_prob, EWMA_LEVEL);
mr->att_hist += mr->attempts;
mr->succ_hist += mr->success;

计算这个速率在这个计算周期内的投递率cur_prob,EWMA_LEVEL默认为75,如果之前没有发送成功过,也就是历史尝试数等于0,就直接更新该速率的投递率,否则调用minstrel_ewma以(原投递率*75%+本次投递率*25%)的计算方式更新投递率。算完了prob的ewma之后就调用minstrel_ht_calc_tp计算各个速率的吞吐率,这个吞吐率结合了实际数据包的长度、各个编码方案的数据率,应该是比较准确的算法。

在每个循环中,在算完当前速率的投递率和吞吐率之后,就会和当前组的最佳值进行比较,如果优于最佳值,就进行交换,再把以前的最佳值和以前的max_tp_rate2比较:

if ((mr->cur_tp > cur_prob_tp && mr->probability >MINSTREL_FRAC(3, 4)) || mr->probability > cur_prob) {mg->max_prob_rate = index;cur_prob = mr->probability;cur_prob_tp = mr->cur_tp;
}if (mr->cur_tp > cur_tp) {swap(index, mg->max_tp_rate);cur_tp = mr->cur_tp;mr = minstrel_get_ratestats(mi, index);
}if (index >= mg->max_tp_rate)continue;if (mr->cur_tp > cur_tp2) {mg->max_tp_rate2 = index;cur_tp2 = mr->cur_tp;
}

最后再用同样的方法遍历各个组,更新整个系统的max_tp_ratemax_tp_rate2等域,不再重复帖代码。

  1. 探测频率

那么minstrel多久或者说在什么条件下会进行探测呢,这和前文提到的三个重要的变量有关,就是sample_waitsample_triessample_count。我的理解,sample_wait是说再过多少个数据包之后进行探测,sample_tries是说拿几个帧来探测,但不是说经过一个sample_wait+sample_tries之后就开始计算那个速率最好,什么时候重新计算各个速率的吞吐率是用时间来控制的,在下一次计算之前,最多进行sample_count组(sample_wait+sample_tries)的循环。

在聚合帧发送完成的回掉函数minstrel_ht_tx_status中,对这几个值进行判断:

if (!mi->sample_wait && !mi->sample_tries && mi->sample_count > 0) {
mi->sample_wait = 16 + 2 * MINSTREL_TRUNC(mi->avg_ampdu_len);
mi->sample_tries = 2;
mi->sample_count--;
}

mi->avg_ampdu_len是每个聚合帧聚合长度的加权平均:

mi->avg_ampdu_len = minstrel_ewma(mi->avg_ampdu_len, MINSTREL_FRAC(mi->ampdu_len, mi->ampdu_packets), EWMA_LEVEL);

而在一个数据包将要发送请求采样速率sample_idxminstrel_get_sample_rate函数中,一开始会做如下处理:

if (mi->sample_wait > 0) {mi->sample_wait--;return -1;
}if (!mi->sample_tries)return -1;mi->sample_tries--;

因为要等sample_wait个包之后探测,因此先判断这个值是不是大于0,如果是,则把这个计数器减1,之后返回-1不进行探测。每次探测时会把控制探测数量的计数器sample_tries减1,当这个数已经减到0的时候返回-1不探测。

以上就是Minstrel速率调整算法的基本流程。

【Atheros】minstrel速率调整算法源码走读相关推荐

  1. 深度增强学习DDPG(Deep Deterministic Policy Gradient)算法源码走读

    原文链接:https://blog.csdn.net/jinzhuojun/article/details/82556127 本文是基于OpenAI推出deep reinforcement learn ...

  2. 深度增强学习PPO(Proximal Policy Optimization)算法源码走读

    原文地址:https://blog.csdn.net/jinzhuojun/article/details/80417179 OpenAI出品的baselines项目提供了一系列deep reinfo ...

  3. ConcurrentHashMap源码走读

    ConcurrentHashMap源码走读 文章目录 ConcurrentHashMap源码走读 简介 放入数据 容器元素总数更新 容器扩容 协助扩容 遍历 简介 在从JDK8开始,为了提高并发度,C ...

  4. rvo动态避障算法源码分析

    rvo动态避障算法 源码: snape (Jamie Snape) · GitHub 文档: RVO2 Library - Reciprocal Collision Avoidance for Rea ...

  5. LeGo-LOAM激光雷达定位算法源码阅读(二)

    文章目录 1.featureAssociation框架 1.1节点代码主体 1.2 FeatureAssociation构造函数 1.3 runFeatureAssociation()主体函数 2.重 ...

  6. Apache Spark源码走读之16 -- spark repl实现详解

    欢迎转载,转载请注明出处,徽沪一郎. 概要 之所以对spark shell的内部实现产生兴趣全部缘于好奇代码的编译加载过程,scala是需要编译才能执行的语言,但提供的scala repl可以实现代码 ...

  7. 基于新唐M0的XXTEA加密解密算法源码

    源:基于新唐M0的XXTEA加密解密算法源码 /*--------------------------------------------------------------------------- ...

  8. [转] GIS算法源码集合

    其他GIS相关代码下载索引 http://www.mygis.com.cn/codeindex10.htm 1.深度优先实现的路径分析源码 http://www.mygis.com.cn/codes/ ...

  9. 机器学习算法源码全解析(三)-范数规则化之核范数与规则项参数选择

    前言 参见上一篇博文,我们聊到了L0,L1和L2范数,这篇我们絮叨絮叨下核范数和规则项参数选择.知识有限,以下都是我一些浅显的看法,如果理解存在错误,希望大家不吝指正.谢谢. 机器学习算法源码全解析( ...

  10. Learning to Rank中Pointwise关于PRank算法源码实现

    [学习排序] Learning to Rank中Pointwise关于PRank算法源码实现 标签: 学习排序PRankPointwiseLearning to Rank代码实现 2015-01-28 ...

最新文章

  1. linux生成ext4镜像,mkfs.ext4生成分区镜像自动把文件转成了sparse怎么破?
  2. Alibaba Druid未授权访问漏洞记录(敏感目录,端口:不确定)
  3. cesium js 路径_[CesiumJS]Cesium入门3 – Cesium目录框架结构
  4. 苹果发布iOS 15.3正式版 修复了Safari浏览器漏洞
  5. 设置相机的距离_讲对焦(四):相机对焦有哪些小技巧?
  6. centos 6.5 x64 上安装mariadb10
  7. day4-软件目录开发规范
  8. 拓端tecdat|R语言有限混合模型聚类FMM、广义线性回归模型GLM混合应用分析威士忌市场和研究专利申请、支出数据
  9. c++获取ctrl+v内容_处理文档时,快捷键「Ctrl+E」的 8 大妙用
  10. 数据结构与算法 哈希表的特点
  11. js统计页面刷新次数
  12. 必应(Bing)的站内搜索 site:<域名> <搜索内容>
  13. 人事部门用计算机管理人事档案,计算机在人事管理中的运用分析
  14. oracle 取系统当前年份_Oracle 之 获取当前日期及日期格式化
  15. go配置sublime text时使用MarGo报错及解决方法
  16. ActiveMQ使用及原理浅析(消息队列)
  17. 如何利用Excel快速批量创建文件夹
  18. python打开gz文件_Python(Python2、Python3)读取gzip(.gz)文件中utf8(utf-8)编码字符串
  19. CSDN文章markdown图片居中以及调整大小(超级简单)
  20. 人脸识别门禁安全吗?

热门文章

  1. sql server 2008 r2 “远程过程调用失败“解决方案
  2. 给自己一个618消费的理由 飞利浦B8905回音壁有料分享
  3. 树型结构总结:二叉查找树、堆、平衡树、2-3查找树、红黑树、B树、B+树
  4. 269. Alien Dictionary火星语字典(拓扑排序)
  5. python 字母查找
  6. 【CST】贴片天线阵列设计
  7. AI会玩魔方了!全是自学,比任何人都快,包括机器人
  8. Vue中时间日期格式化
  9. 注册中心开源方案选型
  10. SEO 优化--助力网站推广