文号
950386
阅读
1049
回复
12
收藏
6
本文在AI协助下创作,作者声明AI的作用仅限于局部修饰性、辅助性工作,文章内容的严肃性由作者负责。
FT8数字通信模式核心DSP算法深度拆解与UI交互体验
魏 广寅
2026/06/03
原创
IP:重庆
摘要:我最近接到一个设计任务,要用无线电技术对远距离超低功耗的北斗信标进行超低频次的经纬度传输和解析。设计中信号处理环节大部分参考了FT8的优秀设计,在此过程中,借助AI和作者论文对FT8的源码进行了深度解析,本着共享精神把算法的精髓分享出来。这里不只讲述“怎么做”,更着重解释“为什么这样设计”即每一个算法选择背后,都蕴含了哪些在弱信号场景下的深刻考量。同时,实现了WebUI实时交互式可视化演示,读者可以直观地观察每个算法步骤的输入输出,建立对FT8算法的直觉感受。
关键词:FT8,GFSK,DSP

FT8数字通信模式核心DSP算法深度拆解与UI交互体验


BG8NJW   2026


引言:当弱信号遇见DSP魔法

近年来,只要打开短波电台,在7.074MHz, 10.136MHz, 14.074MHz, 18.100MHz, 21.07MHz这些频率上,USB模式下,都能听到按照一定时间节律出现的,热闹非凡的“嘤嘤声”。你的电台如果有频谱瀑布图显示的话,会看到在带内出现一段段密密麻麻的蚯蚓状的谱线,这些大概率就是FT8模式的通联信号。前几年大家狂热的PSK31和JT65模式已经鲜有人玩了,绝大多数的“老头乐”“键盘族”都转到了FT8模式下。数字通信模式天生适合I人玩,无需掌握比较溜的英语口语和多种口音的字母解读法,不存在交流尴尬。FT8为何如此火爆,我认为主要是因为该模式比PSK31的解调门限高得多,可以轻松QRP通联到几千公里外的弱台,又比JT65的帧时间短得多(损失4dB解调阈值),不会为了一个通联傻等几分钟。

FT8的魔幻之处:即便信号微弱到几乎淹没在噪声之中,从瀑布图上甚至看不到明显的谱线,你仍然可以可靠地传递信息。这种能力本身就极具魅力,但实际上背后是一套精密的数字信号处理(DSP)算法。

FT8由著名射电天文学家Joe Taylor(K1JT,1993年诺贝尔物理学奖得主)于2017年设计推出。K1JT团队长期专注于弱信号通信研究,此前已开发了JT65、JTDX等一系列模式。FT8的全称是"Franke-Taylor Design 8PSK",其核心设计目标只有一个:在极低信噪比(SNR)条件下实现可靠通信。具体来说,它可以在低至-20dB的信噪比下工作——这意味着有用信号功率只有噪声的1%。

FT8的关键参数:每条消息包含77bit的信息载荷;信号带宽仅50Hz,却能在15秒的传输周期内完成发送;调制方式为8-GFSK(8值高斯频移键控);使用了LDPC(低密度奇偶校验码)这种现代纠错码,使得在极端恶劣的短波信道条件下依然能够还原消息。这些参数每一个都经过了精心设计,彼此配合,共同构成了一个为弱信号而生的通信系统。

我最近接到一个设计任务,要用无线电技术对远距离超低功耗的北斗信标进行超低频次的经纬度传输和解析。设计中信号处理环节大部分参考了FT8的优秀设计,在此过程中,借助AI和作者论文对FT8的源码进行了深度解析,本着共享精神把算法的精髓分享出来。这里不只讲述“怎么做”,更着重解释“为什么这样设计”即每一个算法选择背后,都蕴含了哪些在弱信号场景下的深刻考量。同时,实现了WebUI实时交互式可视化演示,可访问自行体验(XXXXXXXXXXXXXXXXXXXXXXXXXX),读者可以直观地观察每个算法步骤的输入输出,建立对FT8算法的直觉感受。这个网站我应该不会长期维护,看虎爷能否把网页代码移植到KC服务器,可以的话我把源码包发给虎爷。另外这个正文中的代码如何折叠啊?

下面开始正式进入FT8全链路流程的解析:

0.软件环境初始设置

在UI中可对消息字符,信道SNR,载波频率,频偏及时移进行设置。模拟真实短波通信过程。

0.png

图0 FT8 UI界面基础设置

1.消息字符拼接与Pack77压缩

1.1 从键盘字符到比特:FT8消息结构

当你在FT8软件中输入"K1JT BA1AAA -10"并到达发送时机时,这三个字段(两个呼号和一个信号报告)需要被编码成计算机能处理的比特流。FT8定义了一种紧凑的Type1消息格式,将这些字段拼接成一个77bit的消息块:

结构:c28+r1+c28+r1+R1+g15+i3=28+1+28+1+1+15+3=77bit

其中c28代表主呼和副呼,各占28bit,采用pack28编码;r1是/R后缀标志位;R1是R前缀标志位;g15编码Maidenhead网格坐标或信号报告,占15bit;i3是3bit消息类型标识,标准QSO固定为001。77这个数字本身并非随意选择,它是91(LDPC信息位)减去14(CRC校验位)的结果,一切服务于后续的前向纠错编码。

1.png 图1.0 FT8 消息拼接演示

1.2 Pack28:呼号的高效数字化

业余无线电呼号有一套严格的格式规则:由前缀(字母+数字)和后缀(字母)组成,但不同的呼号前缀长度差异极大。美国呼号可能是1个字母加1个数字加2-3个字母(如K1JT),而中国呼号通常是2个字母加1个数字加2-3个字母(如BA1AAA)。Pack28的核心思想是“右对齐+混合基数编码”。

首先将呼号标准化为6个字符,不足部分用空格补齐。关键在于处理数字位置:1×N格式(如K1JT,数字在第二位)需要左补一个空格将数字推到第三位;2×N格式(如BA1AAA,数字在第三位)则直接右补空格。字符集设计:A1表(37个字符)用于第一位;A2表(36个)用于第二位;A3表(10个)专门用于数字位置;A4表(27个)用于其他字母位置。

function pack28(callsign) {
    let cs = callsign.toUpperCase().replace(/[^A-Z0-9]/g, '');
    if (cs.length > 6) cs = cs.substring(0, 6);
    let digitPos = -1;
    for (let i = 0; i < cs.length; i++)
        if (cs[i] >= '0' && cs[i] <= '9') { digitPos = i; break; }
    // 1×N格式(数字在第1位):左补1空格,将数字推到第2位(A3)
    if (digitPos === 1 && cs.length <= 5) cs = ' ' + cs;
    while (cs.length < 6) cs = cs + ' ';
    // 查表 → 混合基数编码: 37×36×10×27×27×27
    const i0 = A1.indexOf(cs[0]);
    const i1 = A2.indexOf(cs[1]);
    const i2 = A3.indexOf(cs[2]);
    const i3 = A4.indexOf(cs[3]);
    const i4 = A4.indexOf(cs[4]);
    const i5 = A4.indexOf(cs[5]);
    let n28 = i0;
    n28 = n28 * 36 + i1;
    n28 = n28 * 10 + i2;
    n28 = n28 * 27 + i3;
    n28 = n28 * 27 + i4;
    n28 = n28 * 27 + i5;
    return NTOKENS + MAX22 + n28;
}

1.3 PackG15:网格与信号报告的合并编码

Maidenhead网格坐标系是一种将全球划分为网格区域的定位系统。FT8使用4字符网格(如EM79),前两位字母对应18×18=324个网格场,后两位数字对应10×10=100个方格,共32400种可能,正好对应g15的1-32400编码空间。信号报告(如-10)映射到32401-32430的范围。这种设计让网格和信号报告共享同一个15bit字段,最大化利用了有限的编码空间。

 2.png

图1.1 FT8 消息Pack77编码演示

2. CRC-14 校验:给消息加一把锁

2.1 为什么需要CRC

短波信道是一个极其恶劣的通信环境——信号在电离层中反射、折射,经历多径衰落,还要忍受大气噪声和人为干扰。CRC(Cyclic Redundancy Check,循环冗余校验)的目的是检错:发送端对77bit消息计算一个14bit的校验值,附加到消息末尾;接收端用同样的算法重新计算,与收到的校验值比对。如果不匹配,说明传输过程中发生了错误。CRC不是加密,而是用数学多项式除法生成消息的数字指纹。

2.2 多项式0x6757的LFSR实现

FT8采用CRC-14,生成多项式为0x6757(二进制110011101010111,阶数为14)。实现上采用线性反馈移位寄存器(LFSR)——一种硬件友好、算法简洁的电路模拟。77bit消息后补5个0(为校验位留出空间),依次送入14位寄存器,每一步判断最高位与输入位的异或来决定反馈路径。经过82次移位后,寄存器中的14位即为CRC值。

function crc14(bits77) {
    let reg = 0; // 14位寄存器
    for (let i = 0; i < 82; i++) {
        const b = (i < 77) ? bits77[i] : 0; // 补5个0
        if (((reg >> 13) & 1) ^ b) {
            reg = ((reg << 1) & 0x7FFF) ^ 0x6757; // 反馈路径
        } else {
            reg = (reg << 1) & 0x7FFF;
        }
    }
    return reg & 0x3FFF; // 取低14位
}

CRC-14可以检测所有1-14位的突发错误和绝大多数更长的错误模式。在FT8的场景下,这意味着即便LDPC解码器碰巧收敛到了一个错误解,CRC校验也能在最后一步将其拦截,避免输出错误的消息。

 3.png

图2.1 CRC-14 校验演示:77bit + 14bit CRC → 91bit

3. LDPC前向纠错编码

3.1 从检错到纠错:CRC和LDPC的分工

CRC只能检错,不能纠错,发现错误后只能丢掉重传。但在短波通信中,重传意味着再等15秒甚至更久,而且信道条件可能并没有改善。FT8的解决方案是LDPC(Low DensityParity CheckCode,低密度奇偶校验码),一种能在接收端直接纠正错误的前向纠错码。LDPC和CRC各司其职:LDPC负责纠错,在噪声中恢复原始比特;CRC负责验证,确保恢复的结果确实是正确的。

3.2 稀疏校验矩阵与生成矩阵

LDPC码由一个稀疏校验矩阵H定义。FT8使用(174,91)LDPC码,即91个信息位编码为174个码字位,校验位83个,码率约0.523。这里低密度的含义是:H矩阵中绝大多数元素为0,只有少量为1。FT8的H矩阵有174列、83行,每行只有约7-10个非零元素。这种稀疏性使得解码算法的复杂度与码长呈线性关系,而非矩阵乘法的立方关系。

3.3 编码:信息位×生成矩阵=码字

编码过程本质上是矩阵乘法:将91bit信息向量与83×174的生成矩阵G相乘,得到174bit码字。实现上,G的前91列构成单位阵(信息位直通),后83列由H矩阵推导而来(校验位)。编码算法逐位计算每个校验位:对于第j个校验位,找出G中第91+j列中值为1的行,将对应的信息位做异或即可。整个过程无需存储庞大的矩阵,只需按行遍历即可。

function ldpcEncode(bits91) {
    const M = 83, N = 174;
    const encoded = new Array(N).fill(0);
    // 前91位直接复制(信息位)
    for (let i = 0; i < 91; i++) encoded[i] = bits91[i];
    // 后83位由校验方程计算(校验位)
    for (let j = 0; j < M; j++) {
        let parity = 0;
        for (let k = 0; k < LDPC_NumRows[j]; k++) {
            const col = LDPC_Nm[j][k] - 1; // 1-based → 0-based
            if (col < 91) parity ^= bits91[col];
        }
        encoded[91 + j] = parity;
    }
    return encoded;
}

4.png 图3.1 LDPC 编码演示:91bit → 174bit(含83个校验位)

4. Gray映射与Costas同步阵列

4.1 Gray码:相邻符号只差1bit

174bit需要映射为58个音调符号(174÷3=58),每个符号携带3bit。如果直接用自然二进制(000,001,010,...,111),相邻音调值可能差2甚至3个bit——一旦解调时将音调1误判为音调2,3个bit中就有2个出错,错误被放大了。

Gray码(格雷码)解决了这个问题。Gray码的精髓是:相邻的两个码字之间只有1bit不同。FT8的Gray映射表为[0,1,3,2,5,6,4,7],即3bit组000→音调0,001→音调1,011→音调2,010→音调3,101→音调4,110→音调5,100→音调6,111→音调7。这样即便解调时发生相邻音调的误判(这是最常见的情况),也只会造成1bit错误,LDPC解码器纠正1bit错误的概率远高于纠正2-3bit错误。

const GRAY_MAP = [0,1,3,2,5,6,4,7]; // 3bit → 音调值
function grayMap(bits3) {
    const idx = (bits3[0] << 2) | (bits3[1] << 1) | bits3[2];
    return GRAY_MAP[idx];
}

5.png

图4.0 Gray映射:3bit → 58音调

4.2 Costas阵列:三段导频信号

FT8的58个数据符号之间,插入3段固定的Costas同步序列,每段7个符号,排列为[3,1,4,0,6,5,2]。三段分别位于序列的开头(0-6)、中间(36-42)和末尾(72-78),总共79个符号。Costas阵列的设计思想类似于OFDM中的导频子载波,它为接收端提供了已知的参考信号,使得接收端可以在不知道数据内容的情况下完成时频同步。

为什么是[3,1,4,0,6,5,2]这个序列?Costas阵列的数学性质保证:任意两个元素之间的差值向量都是唯一的。这意味着无论时偏和频偏如何组合,Costas序列的自相关函数只在(0,0)处有峰值,在其他位置接近于零,从而避免了假同步峰。

const COSTAS = [3, 1, 4, 0, 6, 5, 2];
function insertCostas(tones58) {
    const symbols = [];
    for (let i = 0; i < 7; i++)  symbols.push(COSTAS[i]);  // 段1: 0-6
    for (let i = 0; i < 29; i++) symbols.push(tones58[i]); // 数据: 7-35
    for (let i = 0; i < 7; i++)  symbols.push(COSTAS[i]);  // 段2: 36-42
    for (let i = 29; i < 58; i++)symbols.push(tones58[i]); // 数据: 43-71
    for (let i = 0; i < 7; i++)  symbols.push(COSTAS[i]);  // 段3: 72-78
    return symbols; // 共79个符号
}

6.png 图4.1 Costas阵列插入:58音调 → 79符号

5. 8-GFSK调制:比特变成波形

5.1 频率键控的基本原理

8-GFSK(8值高斯频移键控)是FT8的调制方式。核心思想非常直观:79个符号中,每个符号对应8个频率之一,接收端通过检测哪个频率的能量最高来判断发送的是哪个符号。8个音调的频率间隔为6.25Hz,以载波频率为中心对称分布:carrierFreq+(tone-3.5)×6.25Hz,即载波下方3.5个间隔到上方3.5个间隔。

6.25Hz的间隔看起来非常窄,但FT8的符号时长为0.16秒,对应的频率分辨率为1/0.16=6.25Hz,恰好等于音调间隔。这是FT8设计中的精妙之处:音调间隔=符号时长倒数=频率分辨率极限,既保证了相邻音调可分辨,又没有浪费任何带宽。

5.2 高斯成形:平滑的频率过渡

如果频率在每个符号边界处突然跳变,会产生严重的频谱旁瓣,导致信号"泄漏"到相邻频点。GFSK中的G代表高斯成形,用一个高斯脉冲对频率变化进行平滑处理,使得相邻符号之间的频率过渡是渐变的而非突变的。高斯滤波器的3dB带宽与符号速率的比值BT=2.0,这个参数在频谱效率和符号间干扰之间取得了平衡。

function gfskModulate(symbols79, freqOffset, carrierFreq) {
    const fs = 12000; // 采样率12kHz
    const symLen = 0.16; // 符号时长0.16秒
    const sps = Math.round(fs * symLen); // 每符号采样数=1920
    const BT = 2.0; // 高斯带宽×时间
    const total = 79 * sps;
    const signal = new Float32Array(total);
    let phase = 0;
    const centerFreq = carrierFreq + freqOffset;

    for (let i = 0; i < 79; i++) {
        const tone = symbols79[i];
        const targetFreq = centerFreq + (tone - 3.5) * 6.25;
        for (let s = 0; s < sps; s++) {
            // 高斯成形:符号边界处频率渐变
            let freq = targetFreq;
            if (i > 0) {
                const prevTone = symbols79[i-1];
                const prevFreq = centerFreq + (prevTone - 3.5) * 6.25;
                const t = s / sps;
                const gauss = Math.exp(-0.5 * Math.pow((t - 0.5) / (0.5/BT), 2));
                freq = prevFreq + (targetFreq - prevFreq) * (1 - gauss);
            }
            phase += 2 * Math.PI * freq / fs;
            signal[i * sps + s] = Math.sin(phase);
        }
    }
    return signal;
}

7.png 图5.1 8-GFSK 调制:79个音调符号 → 连续波形

6. 短波信道模拟:加入噪声

FT8设计用于短波(HF)频段,这是一个极其恶劣的通信环境。为了在WebUI中模拟真实场景,我们采用AWGN(Additive White Gaussian Noise,加性高斯白噪声)模型。AWGN假设噪声在所有频率上均匀分布且功率恒定,这是通信系统分析中最基础也是最常用的信道模型。

信噪比(SNR)以dB为单位:SNR(dB)=10×log₁₀(Psignal/Pnoise)。FT8能在-20dB下工作意味着信号功率只有噪声的1%。噪声的生成采用Box-Muller变换:将均匀分布的随机数转换为高斯分布。添加噪声时,先计算信号功率,再根据目标SNR计算噪声功率,确保噪声幅度精确可控。

function addNoise(signal, snrDb) {
    const power = signal.reduce((s, v) => s + v * v, 0) / signal.length;
    const snr = Math.pow(10, snrDb / 10);
    const noisePower = power / snr;
    const sigma = Math.sqrt(noisePower / 2);
    const noisy = new Float32Array(signal.length);
    for (let i = 0; i < signal.length; i++) {
        // Box-Muller变换生成高斯噪声
        const u1 = Math.random(), u2 = Math.random();
        const n = sigma * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
        noisy[i] = signal[i] + n;
    }
    return noisy;
}

8.png

这里可以点击左下角的试听按钮,试听调制到载波上的加噪音频 

图6.1 AWGN 信道模拟:纯净信号 + 噪声 → SNR=-20dB 信号

7. Goertzel频率检测

7.1 为什么不用FFT

接收端的核心任务是:从带噪声的信号中,检测出每个符号位置上哪个频率的信号最强。FFT(快速傅里叶变换)是频率分析的标准工具,但它计算的是整个频谱——对于FT8只需要检测8个特定频率的场景来说,大部分计算都浪费了。

Goertzel算法是专为检测少量特定频率设计的。它本质上是一个二阶IIR滤波器,只需要2个状态变量(实部、虚部)和N次迭代(N为采样点数),就能精确计算出单个频率的DFT值。检测8个频率只需8次Goertzel,远比一次FFT高效。对于每个符号1920个采样点,Goertzel只需约1920×2=3840次乘法/加法,而FFT即使用Radix-2也需要约11×1024=11264次。

function goertzel(samples, targetFreq, sampleRate) {
    const N = samples.length;
    const k = Math.round(N * targetFreq / sampleRate);
    const w = 2 * Math.PI * k / N;
    const coeff = 2 * Math.cos(w);
    let s0 = 0, s1 = 0, s2 = 0;
    for (let i = 0; i < N; i++) {
        s0 = samples[i] + coeff * s1 - s2;
        s2 = s1;
        s1 = s0;
    }
    const re = s1 - s2 * Math.cos(w);
    const im = s2 * Math.sin(w);
    return Math.sqrt(re * re + im * im);
}

7.2 符号检测:8个频率的较量

detectSymbols函数对每个符号位置计算8个音调频率的Goertzel幅度,取最大值作为检测结果。中心频率为载波频率加频偏值,8个音调频率分别为centerFreq+(0~7-3.5)×6.25Hz。在低SNR下,噪声可能导致幅度最大的频率不是实际发送的频率,这就是后续LDPC纠错需要应对的场景。

9.png 图7.1 Goertzel 频率检测:每个符号检测8个音调的幅度

8. Costas同步与时频偏估计

8.1 FT8同步技术

玩过该模式的都知道,FT8的收发要严格对齐于操作者计算机的时钟,15秒为一个发送周期,其间居中的12.64为有效帧,两端的空隙用于纠正同步误差,超差的信号将不予解调。受收发两端计算机的计时误差和电台高频载波的漂移影响会引入两类偏差:时偏(信号比预期早到或晚到若干符号)和频偏(多普勒频移导致信号频率整体偏移)。如果不知道时偏和频偏,接收端就无法对齐符号边界,也无法准确检测频率。这就是Costas同步要解决的问题。

8.2 两阶段搜索:粗搜索+精搜索

Costas同步采用两阶段搜索策略。第一阶段是粗搜索:设定搜索范围为时偏±8个符号(约±1.28秒),频偏±3.5Hz。对每一个(时偏,频偏)候选组合,检查三段Costas阵列(共21个符号)的Goertzel检测能量:对于Costas位置i,预期频率为carrierFreq+freqOffset+(Costas[i]-3.5)×6.25Hz。将这21个频率的Goertzel幅度相加作为"得分"。最优候选就是得分最高的(时偏,频偏)组合。

粗搜索步进为0.25Hz,范围±3.5Hz,共29个频偏候选;时偏±8步进为1符号,共17个时偏候选。总共需要评估29×17=493个候选。第二阶段是精搜索,在粗搜索得到的最优频偏附近,以0.05Hz的步进再次搜索。最终输出精确到0.05Hz的频偏估计和整符号精度的时偏估计。

三段Costas阵列联合搜索的设计非常巧妙:每段独立搜索同一个信号,可以互相验证真伪;在低SNR下,三段平均比单段更稳定;而且三段Costas覆盖了整个79符号序列的不同位置,使得时偏估计不会因为某段信号恰好遇到深度衰落而失败。

function costasSync(signal, costasPattern, fs, carrierFreq) {
    const costasBaseIndices = [0, 36, 72];
    const maxTimeOff = 8, maxFreqOff = 3.5, freqStep = 0.25;
    let bestT = 0, bestF = 0, bestScore = -Infinity;
    const samplesPerSym = Math.round(fs * 0.16);

    // === 粗搜索 ===
    for (let tOff = -maxTimeOff; tOff <= maxTimeOff; tOff++) {
        for (let fOff = -maxFreqOff; fOff <= maxFreqOff; fOff += freqStep) {
            let score = 0, segCount = 0;
            const cFreq = carrierFreq + fOff;
            for (let seg = 0; seg < 3; seg++) {
                let segScore = 0, segValid = false;
                for (let k = 0; k < 7; k++) {
                    const symIdx = costasBaseIndices[seg] + tOff + k;
                    const s = symIdx * samplesPerSym, e = s + samplesPerSym;
                    if (s < 0 || e > signal.length) continue;
                    segValid = true;
                    const freq = cFreq + (costasPattern[k] - 3.5) * 6.25;
                    segScore += goertzelRange(signal, s, e, freq, fs);
                }
                if (segValid) { score += segScore; segCount++; }
            }
            if (segCount === 0) continue;
            if (score > bestScore) { bestScore = score; bestT = tOff; bestF = fOff; }
        }
    }
    // === 精搜索(0.05Hz步进)===
    for (let fOff = bestF - 0.4; fOff <= bestF + 0.4; fOff += 0.05) {
        let score = 0, cFreq = carrierFreq + fOff;
        for (let seg = 0; seg < 3; seg++)
            for (let k = 0; k < 7; k++) {
                const symIdx = costasBaseIndices[seg] + bestT + k;
                const s = symIdx * samplesPerSym, e = s + samplesPerSym;
                if (s < 0 || e > signal.length) continue;
                const freq = cFreq + (costasPattern[k]-3.5)*6.25;
                score += goertzelRange(signal, s, e, freq, fs);
            }
        if (score > bestScore) bestScore = score;
    }
    return { timeOffset: bestT, freqOffset: Math.round(bestF * 100) / 100 };
}

10.png 图8.1 Costas 同步:2D搜索找到时频偏最优估计

9. Gray逆映射与LLR软判决

9.1 频偏校正后重新检测

Costas同步给出了时偏和频偏的估计值,但这个估计并不完美。FT8的解调策略是"先校正再检测":利用估计出的频偏,在Gray逆映射之前先对数据符号重新进行Goertzel检测。具体做法:将中心频率修正为carrierFreq+freqOffset,对数据段(排除Costas位置)的72个符号逐个计算8个音调的Goertzel幅度。

9.2 LLR:比“是0还是1”更有价值的信息

传统的硬判决只告诉我们“这个bit应该是0还是1”,而软判决告诉我们“这个bit是0的把握有多大,是1的把握有多大”。LLR(Log-LikelihoodRatio,对数似然比)正是这种软信息的数学表达:LLR=0.5×ln(P(bit=0)/P(bit=1))。

如何从8个音调的幅度计算每个bit的LLR?以某个bit为例:P(bit=0)正比于所有Gray码该bit为0的音调幅度平方之和,P(bit=1)正比于该bit为1的音调幅度平方之和。幅度本身是信号强度的反映——如果某个音调的Goertzel幅度很大,说明该符号很可能是那个音调,从而对应的bit值可靠性高。

LLR的含义非常直观:正值表示bit=0更可能(|LLR|越大越确信),负值表示bit=1更可能,接近零表示不确定。将这174个LLR输入LDPC解码器,解码器可以优先"相信"可靠的bit,而对不可靠的bit主要依赖校验关系来推断——这就是软判决解码相对于硬判决的增益来源。在-20dB SNR下,这个增益可以高达1-2dB。

function grayUnmapAndLLR(signal, timeOffset, freqOffset, fs, carrierFreq) {
    const correctedCenterFreq = carrierFreq + freqOffset;
    const costasPositions = new Set([...Array(3).keys()].flatMap(s =>
        Array.from({length:7},(_,k) => s*36+k)));
    const llrs = [];
    const samplesPerSym = Math.round(fs * 0.16);
    for (let sym = 0; sym < 79; sym++) {
        if (costasPositions.has(sym)) continue;
        const start = (sym + timeOffset) * samplesPerSym;
        const end = start + samplesPerSym;
        if (start < 0 || end > signal.length) { llrs.push(0,0,0); continue; }
        const amplitudes = [];
        for (let tone = 0; tone < 8; tone++) {
            const freq = correctedCenterFreq + (tone - 3.5) * 6.25;
            amplitudes.push(goertzel(signal.slice(start,end), freq, fs));
        }
        for (let b = 2; b >= 0; b--) {
            let p0 = 0, p1 = 0;
            for (let tone = 0; tone < 8; tone++) {
                const bits3 = GRAY_MAP_INV[tone];
                const bitVal = (bits3 >> b) & 1;
                const amp = Math.max(amplitudes[tone], 1e-10);
                if (bitVal === 0) p0 += amp*amp;
                else p1 += amp*amp;
            }
            llrs.push(0.5 * Math.log(p0 / Math.max(p1, 1e-30)));
        }
    }
    return llrs;
}

 11.png

图9.1 Gray逆映射与LLR软判决

10. LDPC-BP迭代解码与CRC验证

10.1 置信传播:消息传递的迭代过程

LDPC解码采用BP(BeliefPropagation,置信传播)算法,也叫消息传递算法。其核心思想是:将校验矩阵H看作一个二分图,左侧是174个变量节点(码字位),右侧是83个校验节点(校验方程),边表示变量节点参与哪个校验方程。解码过程就是在这张图上反复传递置信消息。

每次迭代包含两步:第一步,每个校验节点向其连接的变量节点发送“外信息”,基于其他变量节点的当前状态,推断目标变量节点应该是什么值;第二步,每个变量节点汇总所有来自校验节点的外信息,加上自身的LLR,更新总置信度并做硬判决。如果在某次迭代后所有83个校验方程都满足,则提前终止,无需跑满30次。这种"早停"机制在信道条件较好时可以大幅减少计算量。

functionldpcDecode(llrs, maxIter = 30) {
    const N = 174, M = 83;
    // 初始化:变量节点的LLR即为输入LLR
    const R = Array.from({length:M}, () => []); // 校验→变量消息
    // 初始化R[j][k] = llrs[col]
    for (let j = 0; j < M; j++)
        for (let k = 0; k < LDPC_NumRows[j]; k++)
            R[j][k] = llrs[LDPC_Nm[j][k] - 1];

    for (let iter = 0; iter < maxIter; iter++) {
        // Step 1: 校验节点→变量节点 (外信息)
        const Q = Array.from({length:N}, () => []);
        for (let j = 0; j < M; j++) {
            for (let k = 0; k < LDPC_NumRows[j]; k++) {
                let prod = 1;
                for (let k2 = 0; k2 < LDPC_NumRows[j]; k2++) {
                    if (k2 === k) continue;
                    prod *= Math.tanh(R[j][k2] / 2);
                }
                Q[LDPC_Nm[j][k] - 1].push(2 * Math.atanhtanh(prod));
            }
        }
        // Step 2: 变量节点更新 + 硬判决
        const decoded = new Array(N);
        let allSatisfied = true;
        for (let i = 0; i < N; i++) {
            let totalLLR = llrs[i];
            for (const q of Q[i]) totalLLR += q;
            decoded[i] = totalLLR < 0 ? 1 : 0;
        }
        // 校验 H × c^T = 0
        for (let j = 0; j < M; j++) {
            let check = 0;
            for (let k = 0; k < LDPC_NumRows[j]; k++)
                check ^= decoded[LDPC_Nm[j][k] - 1];
            if (check !== 0) { allSatisfied = false; break; }
        }
        if (allSatisfied) return { decoded, iterations: iter + 1 };

        // 更新R
        for (let j = 0; j < M; j++)
            for (let k = 0; k < LDPC_NumRows[j]; k++) {
                const col = LDPC_Nm[j][k] - 1;
                R[j][k] = llrs[col];
                for (const q of Q[col]) R[j][k] += q;
                R[j][k] -= Q[col].pop(); // 减去自己发给自己的
            }
    }
    return { decoded, iterations: maxIter };
}

10.2 CRC最终把关

LDPC的校验方程H×cᵀ=0只验证了83个校验方程是否满足,但不能保证解码出的91bit信息就是原始发送的消息,存在“死胡同”的可能,解码器可能陷入一个局部最优但不正确的解。因此CRC-14验证是最后一道安全网:对解码出的前77bit重新计算CRC-14,与附加在后14bit中的CRC比对。只有CRC验证通过,才认为整个解码成功。

12.png 图10.1 LDPC BP 解码演示

11. FIR带通滤波与音频生成

为了让调制后的信号具有干净的频谱特性并便于音频试听,FT8 Web UI实现了FIR带通滤波器和WAV音频生成。FIR滤波器采用201阶窗函数设计法,通带300-3000Hz,覆盖FT8信号的主要频谱成分,同时抑制带外噪声。WAV生成器将浮点采样值编码为16bitPCM格式,采样率12kHz,单声道,生成标准WAV文件供Web Audio API播放。

function bandpassFilter(signal, fs, lowFreq, highFreq, order) {
    const N = order || 201;
    const h = new Float32Array(N);
    const mid = (N - 1) / 2;
    for (let i = 0; i < N; i++) {
        const n = i - mid;
        let hlp = (n === 0) ? 2 * (highFreq/fs)
            : Math.sin(2*Math.PI*(highFreq/fs)*n) / (Math.PI*n);
        let hhp = (n === 0) ? 2 * (lowFreq/fs)
            : Math.sin(2*Math.PI*(lowFreq/fs)*n) / (Math.PI*n);
        h[i] = (hlp - hhp) * (0.54 - 0.46 * Math.cos(2*Math.PI*i/(N-1)));
    }
    // 卷积
    const filtered = new Float32Array(signal.length);
    for (let i = 0; i < signal.length; i++) {
        let y = 0;
        for (let j = 0; j < N && i-j >= 0; j++) y += h[j] * signal[i-j];
        filtered[i] = y;
    }
    return filtered;
}

 

12. 完整链路:从发送到接收

将上述所有算法串联起来,就构成了FT8的完整收发链路。发送端:键盘输入→pack77(77bit)→CRC-14(91bit)→LDPC编码(174bit)→Gray映射(58音调)→插入Costas(79符号)→8-GFSK调制(波形)→加噪声(信道)。接收端:信道信号→Costas同步(时频偏)→Gray逆映射+LLR(174个LLR)→LDPCBP解码(174bit)→CRC验证(91bit)→unpack77(原始消息)。

13.png

 图12.1 噪声中的信号成功还原

一点感受

FT8的整个DSP链路,每一环节都体现了为弱信号场景量身定制的工程哲学。77bit信息经过pack77压缩、CRC-14校验、LDPC编码扩展到174bit,再映射为79个GFSK符号,在12.64秒内以约50Hz带宽发出。这个过程不是简单的编码+调制,而是信息论与工程实践的深度融合。

从信息论的角度看:77bit的原始信息,经LDPC编码后以174/91≈1.91的码率传输,付出了近一倍的带宽代价。但正是这"一倍带宽"换取的纠错能力,使得在-20dB SNR下仍然能够可靠通信——这正是香农编码定理精神的完美体现。Gray映射降低了误码传播效应,Costas阵列同时解决了时频同步问题,FIR带通滤波让信号具有干净的频谱特性。

本文的所有算法,均已实现在配套的WebUI中(XXXXXXXXXXXXXXXXXXXXXXXXXX)。你可以输入任意合法呼号和网格,观察每一步的输入输出:从消息编码的77bit,到LDPC编码的174bit,到频谱图上的8条竖线,到噪声中的Costas同步,到LLR瀑布图上的软信息分布,再到最终的解码结果。这种可视化的DSP算法呈现,比任枯燥公式都更能建立直觉。

由衷膜拜与感谢FT8的设计者K1JT团队,用极少的资源(77bit消息、15秒周期、50Hz带宽)实现了在极端条件下人类语音无法完成的通信。这不是魔法,而是数学、物理与工程的精妙结合。希望本文能帮助你,在欣赏FT8之美的同时,也能动手实践、在自己的项目中借鉴这些算法思想。


[ 修改于2个月8天前 - 2026/08/23 22:10:43 ]
4个月29天前修改于 4个月29天前IP:四川
950387
1楼

过去没有时间研究只知道点皮毛,看了楼主的文章算是第一次深入的了解FT8的编解码原理。

已经做了镜像:https://www.kechuang.org/tools/open/25

引用
评论
1
4个月29天前IP:广东
950388
2楼

LDPC是目前最简单直接的实现极限灵敏度的方案,加长码字还可以进一步贴近香农极限。

我试过用CCSDS C2 LDPC(8160, 7136) + QPSK调制,实测10Mbps速率解调门限-100dbm

引用
评论
1
ehco作者
4个月29天前修改于 4个月29天前IP:重庆
950393
3楼

关键是这种恒包络,窄带宽的信号,对D类射频功放十分友好呀。直接是超低平均功耗超远距离低速数传的不二之选。比lora可控,自由度高。

引用
评论
4个月29天前IP:四川
950396
4楼
引用ehco发表于3楼的内容
关键是这种恒包络,窄带宽的信号,对D类射频功放十分友好呀。直接是超低平均功耗超远距离低速数传的不二之选。比lora可控,自由度高。

但速度太慢了,传一次需要太久时间。这种情况对于超短波或更高频率的移动通信来说,由于存在快衰落,可能就会带来一些问题。模拟器可以增加一个功能,模拟幅度上的衰落,周期可以在数ms到数s之间调节。

引用
评论
1
4个月28天前IP:山西
950412
5楼

以前没有详细了解过,只知道用了LDPC,看了楼主的分析,好像比我想的还简单一些;


不过FT8这种超低信噪比的通信模式着实不适合做数传,怎么在协议设计的框架之下传输自定义载荷数据也要研究研究;高空气球领域会用WSPR网络来传输汇总数据,WSPR同样设计的规则比较多,因此产生了U4B之类的协议借着WSPR的壳来传输。


一直觉得我们缺一套短波业余数据网。

现有的常规数据网络主要是APRS,但APRS系统大多工作在VHF,传播距离短,而且各地区网关标准频率不一样;欧洲部署了很多基于LoRa的UHF网关,覆盖率比较高,欧洲用用还行,出了欧洲就没了。另外有几套针对业余卫星的地面站系统(SatNogs和TinyGS),大多工作在UHF,要根据卫星的协议和频率对地面站的解码器进行调度,所以基本不能按常规使用。


考虑到各类通信需求,我认为至少应当在短波建立一个600bps甚至以上的网络(话说APRS好像也有一套工作在短波的标准诶)


也许可以应用类似DVB-S2的设计,先用一个统一基本标准发送速率,调制方式和信道编码,然后解码器切换,发送端开始发送……

引用
评论
1
4个月28天前IP:广东
950422
6楼

FT8玩的人好像不多

引用
评论(1)
4个月28天前修改于 4个月28天前IP:四川
950424
7楼
引用山雨欲来风满楼发表于5楼的内容
以前没有详细了解过,只知道用了LDPC,看了楼主的分析,好像比我想的还简单一些;不过FT8这种超低信噪比的通信模式着实不适合做数传,怎么在协议设计的框架之下传输自定义载荷数据也要研究研究;高空气球领域...

短波宽带(在短波,600bps以上就可以称为宽带了)的主要问题是选择性衰落,所以几乎必然用到多载波或等效的技术。强信号下成熟的方案可以参考DRM。这些东西如果没有专用芯片的话,成本还是比较高的,难以在爱好者中普及。就FT8的编码方式同时产生几十个信道,冗余纠错,似乎也是一个办法,但用一个小单片机实现起来有点困难。

引用
评论
1
4个月28天前修改于 4个月28天前IP:山西
950425
8楼
引用虎哥发表于7楼的内容
短波宽带(在短波,600bps以上就可以称为宽带了)的主要问题是选择性衰落,所以几乎必然用到多载波或等效的技术,成熟的方案可以参考DRM。这些东西如果没有专用芯片的话,成本还是比较高的,难以在爱好者中...

之前有一个闭源的vara,不过收费,效果好像一般般;今年新出了一个mercury,还没试,不过好像反响不错。
DRM用的是OFDM 4+4/16+16/64QAM,速率挺高的,不过电离层会扭曲相位,是个比较难受的事情。
之前和BI4KUF聊过把LoRa下变频到HF,后来确实也有人在短波上见到了极其像LoRa的信号,不知效果如何。


确实组网需要一些省心的小终端,就此我觉得可能LoRa也还行?

@罗布Carrot

引用
评论(1)
1
4个月28天前修改于 4个月28天前IP:四川
950426
9楼
引用山雨欲来风满楼发表于8楼的内容
之前有一个闭源的vara,不过收费,效果好像一般般;今年新出了一个mercury,还没试,不过好像反响不错。DRM用的是OFDM 4+4/16+16/64QAM,速率挺高的,不过电离层会扭曲相位,是...

Lora其实是个扩频信号,就抗选择性衰落而言有一定效果,主要问题是所占带宽较大,业余无线电实验尚有频谱空间,专业用途几乎不可能获批。Lora毕竟有成熟廉价专用芯片,相比一些短波数传专用的协议,成本低得多。不过,正因为它带宽较大,很难得到政府和业余无线电组织的认可,注定了只能是小圈子玩玩。而FT8由于它天生的频谱经济性,以及对传统SSB电台完全兼容,普及起来就容易得多,直到形成了全球共识。

个人认为,用Lora之类的糊个东西出来不是难事,只能算是中学科技小制作水平(如果只是搭建原型,可以拼装淘宝模块+少量软件开发)。相对来说更高端的玩法是像楼主这样从需求定义和通信原理入手,学习、改进乃至创造适当的协议,才能取得坚实的进步。

引用
评论
1
4个月28天前IP:河南
950427
10楼

厉害呀

引用
评论
4个月20天前IP:山东
950531
11楼
引用山雨欲来风满楼发表于8楼的内容
之前有一个闭源的vara,不过收费,效果好像一般般;今年新出了一个mercury,还没试,不过好像反响不错。DRM用的是OFDM 4+4/16+16/64QAM,速率挺高的,不过电离层会扭曲相位,是...

不知道AFDM在短波的效果怎么样

引用
评论(1)
2
4个月10天前IP:广东
950761
12楼
引用虎哥发表于4楼的内容
但速度太慢了,传一次需要太久时间。这种情况对于超短波或更高频率的移动通信来说,由于存在快衰落,可能就会带来一些问题。模拟器可以增加一个功能,模拟幅度上的衰落,周期可以在数ms到数s之间调节。

PSK鉴相器常用的都是幅度敏感的,需要ACG先归一化。如果相位不确定性强,M-FSK表现会比PSK/QAM/OFDM好得多。

引用
评论
2
笔记
{{note.content}}
{{n.user.username}}
{{fromNow(n.toc)}} {{n.status === noteStatus.disabled ? "已屏蔽" : ""}} {{n.status === noteStatus.unknown ? "正在审核" : ""}} {{n.status === noteStatus.deleted ? '已删除' : ''}}
  • 编辑
  • 删除
  • {{n.status === 'disabled' ? "解除屏蔽" : "屏蔽" }}
我也是有底线的