前两篇讲了广播信道怎么躲 WiFi、HCI 抓包怎么逐字节拆。这篇解决最后一个问题:连接建立之后,数据在 37 个信道上到底按什么顺序跳?答案写在两个算法里------CSA#1 和 CSA#2。它们都是纯算术,一个只有加法和取模,另一个多了一层置换和乘法。这篇文章把两个算法的每一步都手算给你看,所有中间值都来自我按 Zephyr 官方控制器源码(lll_chan.c)复刻的程序实算,不是编的。

1. 为什么需要跳频,为什么要懂它
2.4GHz 频段没有准入门槛:WiFi、微波炉、别人的蓝牙全挤在一起。蓝牙把 2400~2483.5MHz 切成 40 个 2MHz 信道,编号 0~36 是数据信道,37/38/39 专门发广播。跳频的本质是分散押注:每个连接事件(connection event)换一个信道,这次撞上 WiFi,下次大概率就躲开了。
问题在于,两端必须跳得完全一致,否则各说各话。所以跳频不能真随机------必须是一个双方都能算的确定函数,输入只有几样:连接时定好的参数。这就是 CSA 算法的全部使命:看起来像随机,实际是确定函数。
跳频参数在 CONNECT_IND 里就发给了从机(就是上篇 HCI 抓包里那条连接的空口版本):跳频算法选择位、hop 增量、信道图(Channel Map)。主机改信道图,控制器照图跳------你调参数,算法照跑,但每一步都是可以手算的。
2. 信道图:跳频的第一输入
信道图是 5 字节位图,37 个 bit 对应 37 个数据信道,1 可用、0 被禁。典型场景:办公室 WiFi 占了 1/6/11 三个经典信道,对应蓝牙信道 0~10、11~25 这段被打穿,芯片或手机会把这段从图里抠掉,只剩 26~36 和低段的一部分。
本文手算用一个图做例子:禁掉信道 11~25,剩 22 个可用信道:
0-10, 26-36
位图: FF 07 00 FC 1F
位图是 5 字节共 40 bit,只有前 37 bit 有效,第 N 个 bit(从 0 数)对应信道 N,是 1 即可用。按位展开,每个字节单独看(bit0 在最右,对应编号最小的信道):
字节0 FF = 11111111 → 信道 0~7 全可用
字节1 07 = 00000111 → 信道 8~10 可用,11~15 禁
字节2 00 = 00000000 → 信道 16~23 全禁
字节3 FC = 11111100 → 信道 24~25 禁,26~31 可用
字节4 1F = 00011111 → 信道 32~36 可用(剩余 bit 对应广播信道,不进图)
从表中可直接读出"0~10、26~36 可用",与位图一致。注意编码的坑:字节内从右往左读 ,bit0 对应编号最小的信道。所以"禁掉 11~25"写出来不是一段连续的 0,而是 07 00 FC 这种两头被切的形状。第一次看信道图的人十个有九个在这翻车------拿到图先按上表展开一遍再往下算。
算法拿这张图做两件事:判断候选信道可不可用;不可用时按"可用信道表"重映射。可用信道表就是把图中为 1 的信道按编号排开:
编号: 0 1 2 ... 10 11 12 ... 21
信道: 0 1 2 ... 10 26 27 ... 36
3. CSA#1:一个加法解决跳频
CSA#1 是蓝牙 4.0 的默认算法,简单到不像算法:
nextChan = (lastChan + hop) mod 37
hop 是 5~16 之间的连接参数(CONNECT_IND 里带的 hopIncrement),lastChan 是上个连接事件未经重映射的原始信道------被禁后做了重映射,参与下一步加法的仍是重映射前的那个数。算出来的 nextChan 若在信道图里可用,直接用;不可用就重映射。
手算一遍。设 hop=5,上一信道 lastChan=10,信道图还是上面那张 22 信道的:
nextChan = (10 + 5) mod 37 = 15
信道 15 被禁了(11~25 全禁),进入重映射。这一步公式的含义是:把 0~36 的原始编号,折叠到 0~21 的可用信道表下标上:
chanIndex = nextChan mod numUsed
numUsed = 22(信道图里数出来的可用信道数)
chanIndex = 15 mod 22 = 15
为什么 mod 22?因为可用信道只有 22 个,重映射的结果必须在"可用信道表"里挑一个,表的下标范围就是 0~21。15 mod 22 = 15------15 本来就小于 22,取模不改变它,这一步的实际作用是:把候选位置压进可用表的下标范围(比如原始算到 35,35 mod 22 = 13,就取表里第 13 个)。
到可用信道表里数第 15 个(从 0 数):0,1,2,...,10 是第 0~10 个,26,27,...,36 是第 11~21 个。第 15 个 = 信道 30。所以这个连接事件在信道 30 上收发。注意重映射只看 nextChan 这一个数、不看跳频历史,所以同一个被禁原始值每次都重映射到同一个可用信道------这就是后面 CSA#1 序列里 30 反复出现的根源。
把整个序列连算 12 个连接事件(hop=5、从信道 0 起):
事件: 1 2 3 4 5 6 7 8 9 10 11 12
信道: 5 10 30 35 3 30 35 3 8 28 33 1
规律一眼可见:算术跳 5、撞禁段、被重映射拉到 30/35 一带,再继续算术跳。CSA#1 的跳频图是固定周期循环------37 与 hop 互质,序列周期最长 37 个事件,然后整个重来。对躲干扰来说这够用(周期对一个连接事件几十毫秒来说已经很长了),但如果固定窄带干扰正好压住你循环里反复经过的几个点,效率会掉。

4. CSA#2:三层运算换真随机
CSA#2 是 4.2 引入的可选算法(LL feature 协商里叫 Channel Selection Algorithm #2),动机就一条:CSA#1 的周期性可预测。CSA#2 用伪随机序列,相邻连接事件落点之间看不出规律,对固定窄带干扰和故意跟跳都更鲁棒。
4.1 输入:访问地址
CSA#2 的随机性来自 Access Address------连接建立时主机生成的 32 位数,规则是至少 24 位翻转、无 6 连 0/1 等,本身就长得像随机数。先压缩成 16 位的 chan_id:
chan_id = AA低16位 XOR AA高16位
手算例:设 AA = 0x3050AB5F。
AA低16位 = 0xAB5F,AA高16位 = 0x3050
chan_id = 0xAB5F XOR 0x3050 = 0x9B0F
4.2 生成器:perm 和 mam
CSA#2 的核心是两个 16 位操作,先各拆一步。两个操作的示例输入别凭空造------就用下面生成器的真实起点:counter=1 时 s0 = 1 XOR chan_id(0x9B0F) = 0x9B0E。注意这个 0x9B0E 不是笔误:它就是 chan_id 被 counter 的最低位异或了一下(F→E),后面整个序列都从它出发。
perm(i) ------ 先把 16 位数拆成高低两个字节,各自按 bit 反转(bit0↔bit7、bit1↔bit6...),再拼回去。纯粹是个位置打乱,没有任何算术。手算:perm(0x9B0E) → 低字节 0x0E 反转 = 0x70,高字节 0x9B 反转 = 0xD9,拼回 → 0xD970。三步拆开:

mam(a, b) ------ a × 17 + b,保留低 16 位。第一参数是上一操作的输出,第二参数固定是 chan_id。手算:mam(0xD970, 0x9B0F):0xD970 × 17 = 0xE7070,加 0x9B0F = 0xF0B7F,截 16 位 = 0x0B7F。这就是完整的第一轮。我自己手算到这一步时也翻过车------16 位乘加必须老老实实列竖式,心算基本必错。perm 和 mam 串成一轮:

prn_s 生成器:s = counter XOR chan_id 起步,然后循环 3 次{perm → mam}。counter 是连接事件计数器(每个连接事件 +1)。counter=1、chan_id=0x9B0F 的完整轨迹(程序实算)------开头两行就是上面 perm/mam 演算过的那两个数:
s0 = 1 XOR 0x9B0F = 0x9B0E
perm(0x9B0E) = 0xD970
第1轮 mam(0xD970, 0x9B0F) = 0x0B7F
perm(0x0B7F) = 0xD0FE
第2轮 mam(0xD0FE, 0x9B0F) = 0x7BED
perm(0x7BED) = 0xDEB7
第3轮 mam(0xDEB7, 0x9B0F) = 0x6536
prn_s = 0x6536
最后 prn_e = prn_s XOR chan_id = 0x6536 XOR 0x9B0F = 0xFE39。这就是本次连接事件的伪随机数。

4.3 落信道:mod 37,禁了就重映射
chan = prn_e mod 37
0xFE39 = 65081,65081 mod 37 = 35。查信道图:35 可用,本事件用信道 35。
撞上禁段时的重映射和 CSA#1 不同,这是 CSA#2 最精妙的一步:
chanIndex = (numUsed × prn_e) >> 16
不是对 37 取模,而是把 prn_e 当 16 位小数(0~1 之间的比例),乘上可用信道数再截断------本质是"在可用信道空间里等概率取点"。手算:counter=3 时 prn_e=4381,raw 信道 = 4381 mod 37 = 15,被禁。chanIndex = (22 × 4381) >> 16 = 96382 >> 16 = 1。查可用信道表第 1 个 = 信道 1。
这里的 22 不是固定值,就是当前信道图里数出来的可用信道数,和第 3 节 CSA#1 重映射里的 numUsed 是同一个数。37 个信道全可用时它就是 37;换了信道图它跟着图变------算法不关心具体数值,只要求"可用信道空间有多大"。
4.4 完整序列
同一 AA、同一信道图,前 12 个连接事件的完整落点(程序实算):
事件: 1 2 3 4 5 6 7 8 9 10 11 12
prn_e: FE39 9CF5 111D A533 DBFB 8DB7 070F CB52 EE1A 6ED6 ECFE B714
raw: 35 36 15 0 1 19 31 28 15 32 27 26
信道: 35 36 1* 0 1 27* 31 28 35* 32 27 26
(带*为撞禁段后重映射的结果)
和 CSA#1 那条 5、10、30、35、3 的周期序列放一起对比:CSA#2 的落点看不出任何规律,而且同一个 prn_e 在不同信道图下会落到不同信道(37 全可用时 raw 15 落信道 15;22 可用时落信道 1)------跳频结果由"随机数 × 信道图"共同决定,改信道图不影响随机数本身,只改映射。

5. 两个算法怎么选,什么时候在乎
选择权在主机:CONNECT_IND 里一个 bit 指明用哪个,前提是双方 feature 协商里都支持 #2。协议栈配置项(Zephyr 里是 CONFIG_BT_CTLR_CHAN_SEL_2)开了才有。
实际在乎的场景:
- 固定干扰下的均匀性:CSA#1 撞禁段时重映射结果有聚集性(上面的序列里 30/35 反复出现),窄带干扰若正好坐在聚集点上,丢包率抬升;CSA#2 的重映射在可用空间里均匀取点,没有聚集。
- 吞吐调优的配套:上篇讲过 251 字节长包 + DLE,那是把单包做大;跳频均匀性是另一个维度------跳得好,重传少,等于变相加吞吐。两者是配合关系。
- 产品抗扰测试:跳频跟 WiFi 共存测试(ETSI 那套)里,用的算法和信道图直接决定通过率。懂手算,才能在测试不过时判断是"算法限制"还是"信道图配错"。
一个工程判断:消费级产品多数默认 CSA#1 就过了共存测试,不必折腾;对跳频有硬指标的(比如要求跟跳攻击抵抗、或共存测试余量小)再开 #2。先把信道图配对,比换算法收益大。
说清楚"配对"什么意思。信道图确实是主机定的------但主机不是拍脑袋定的:它要么靠控制器上报的信道质量报告,要么靠协议栈自带的 WiFi 共存分类器,来判断哪段频段有干扰、该禁哪段。所以"配对"指的是:图要如实反映现场实际干扰段。常见翻车是图配得很随意(比如照抄别人的五字节、或者干脆全 1 不禁),办公室 WiFi 实际占的频段根本没禁掉------这时干扰段还在跳频循环里,换 CSA#2 只是把落点弄均匀了,照样每次都撞上去。图禁对了,CSA#1 的周期序列也绕开干扰段;图禁错了,CSA#2 也救不回来。所以排查共存问题先核图,再谈算法。

6. 手算工具化:一段 40 行的验证脚本
懂手算的真正价值是能验证。把 Zephyr 的 lll_chan.c 翻译成 Python 只要几十行,协议栈升级、芯片换代、抓包疑点,都能用它对答案。核心函数原样照抄(节选):
def perm(i): # 双字节 bit 反转
r8 = lambda b: int(format(b, '08b')[::-1], 2)
return (r8((i >> 8) & 0xFF) << 8) | r8(i & 0xFF)
def mam(a, b): # a*17+b 截 16 位
return (a * 17 + b) & 0xFFFF
def prn_s(counter, chan_id):
s = counter ^ chan_id
for _ in range(3):
s = mam(perm(s), chan_id)
return s
def prn_e(counter, chan_id):
return prn_s(counter, chan_id) ^ chan_id
空口抓包(Ellisys 这类仪器,或者部分芯片的跟跳日志)能拿到每个连接事件的实际信道,跟脚本算出来的序列一比对,信道图、算法选择位、hop 全都能反推验证。没有空口仪器也能用:Linux 的 btmon 日志里连接事件信道有时可见,至少能抽查落点分布。
7. 总结
两个算法,三层结构,就这些:信道图圈出可用空间;CSA#1 用 (lastChan + hop) mod 37 加取模重映射,周期可循;CSA#2 用 chan_id(来自 Access Address)驱动 perm/mam 三轮生成 prn_e,mod 37 落信道,禁段用 (numUsed × prn_e) >> 16 均匀重映射。全部输入只有连接参数,手算可复现。
下一篇回到协议栈本身:GATT 服务发现流程里主机和从机的攻防------谁决定句柄范围,谁在偷懒。
蓝牙 BLE 跳频 CSA#1 CSA#2 channel map hop increment prn_e Access Address 信道图 连接事件 抗干扰 共存