看了但是没完全看懂。。。。。。感觉需要仔细学习。
大约两个四个月前,我开始在某个卫星项目的团队内负责进行通信体制设计方面的工作。
虽然笔者作为一个入门级的业余无线电爱好者,在此之前曾接收过许多卫星的遥测信息,也尝试过数据模式的通信,但是都是以一种黑箱的状态进行的,没有任何对数字通信方面的深层理解,对于如何设计和实现一套数据通信体制是毫无经验和头绪的。所以就打算以较为常见和简单的 APRS 为着手点,边学边做边学,按照协议完成了一个最基本的 APRS 的调制程序。
在翻阅协议的过程中,不知是笔者检索能力太差,还是关于协议解析方面的中文内容较少,我找到的大部分资料似乎都偏向于比较浅层的介绍和科普,有一些涉及到技术细节的内容往往也不太完整或不易理解。有些朋友告诉我可以去看《APRS 101》,那份文档很全面但内容庞杂,难以快速抓住重点,对入门者而言并不友好。后来我找到了 BH1PHL 老师的代码实现;BI4PYM 老师提供了一些资料和帮助;我也检索了一些英文资料,终于有了一个基本的理解。忙里偷闲,就在这里做一些分享吧。
不过虽说是以 APRS 为着手点,但本文的侧重并不在于 APRS 的表示层格式说明,而是更倾向于对其所基于的更底层的 AX.25 进行解析。
本文假设您已经对数字通信有了基本的认知。虽然本文已经以尽可能简单的方式进行阐述,但并不意味着能够使所有未接触过数字通信的人士轻易理解。
APRS (自动分组报告系统,Automatic Packet Reporting System)是一种业余无线电常用的数据通信分组协议,广泛用于位置信息、遥测、短报文等数据的传输。APRS 常用 AFSK 300bd、AFSK 1200bd 和 GMSK 9600bd 三种物理层调制方式。
本文假设您已经有了基本的认知。因此段内容并非本文重点,故将从简介绍。
正如寄信一样,基本的 APRS 需要填写一些必要信息。一般的 APRS (以 UI 格式展示)消息如下:
FmCall 即源站地址,发送方的呼号和SSID的组合,例如BG7ZDQ-1
。
ToCall 即目标地址,接收方的呼号和SSID的组合,例如BI4PYM-1
。
Path 即路径地址,中继参数或参与中继转发的中继局呼号和其SSID、参数的组合。
SSID(Secondary Station Identifier)是呼号的扩展,用于区分同一呼号下的不同设备或用途。SSID的取值范围为0到15,共有16个(请注意这个数量)。
每个 SSID 所对应的约定的含义、目标地址与路径地址等内容的特殊规则不在此处赘述,请有兴趣了解的读者自行检索。
此段内容将引入 OSI七层协议模型 的理念。
OSI 模型是一个概念框架,用于描述网络系统的功能。它将复杂的网络通信过程划分为七个不同的层次,每一层都有其特定的职责和功能 。值得注意的是,尽管OSI模型是一个非常全面的理论模型,但它并不总是直接映射到实际的网络系统中,实际的系统可能会将OSI模型的某些层次进行合并。[1]
图 3-1
如 图3-1 所示,在 OSI七层协议模型 中,APRS 位于表示层(第六层),主要是提供了各类消息(如位置信息、天气数据和短消息等内容)的格式要求,便于程序进行解析。在本文中,APRS 的表示层格式不是要阐述的重点,故此段从简。
然而实际完整的网络系统不光要由表示层构成,也需要其他层级的参与。出于减少工作量、避免重复造轮子的原因,APRS 作出了规定,其他层级的功能交由 AX.25 实现。
AX.25 是基于 HDLC(高级数据链路控制协议) 扩展而来的数据链路层(第二层)协议,专为无线电数据通信设计。虽然 AX.25 属于数据链路层协议,但 AX.25 通过其帧结构的不同及其他方法对其余几层的功能进行了简单的实现,以满足基本的网络通信需求。[2]
在本节的内容中,为了行文方便,会将 AX.25 实现的其他层级的功能直接列入相关层级,但读者应知,AX.25 实际属于数据链路层,其他层级的功能只是 AX.25 通过帧结构的差异或其他方法所实现的,这些功能增设与数据链路层中。
图 3-2
APRS 调制的基本流程如 图3-2 所示、具体的 OSI 层级功能请参照 图3-1。
在第二章中,我们已介绍 APRS 需要填写的基本信息。
用户需要通过应用层程序(例如 APRS 软件终端或自带 APRS 功能的电台)输入必要的一些数据,这些数据将传递至表示层(即 APRS 协议所在的层级)。
表示层程序将按 APRS 的格式要求将数据格式化。
例如,一台计算机上连接了一个 GPS 模块,该模块传回类似N38°0'47'', E112°26'12''
的数据格式。然而数据格式多种多样,其他网关不可能挨个针对性的进行解析,这会造成极大的麻烦。所以 APRS 规定了任意位置信息在发射前都需要格式化为类似!3800.78N/11226.20E
这样的格式或另一种压缩格式,以简化解析过程。在位置信息以外,其他的数据也有此类格式化的要求。(题外话:这类似于语法要求,所以表示层也叫做语法层。)
表示层结束后就进入了会话层,该层主要管理会话与连接。AX.25 提供了 10 种帧类型,通过帧类型的差异来实现简单的会话层功能。APRS 协议规定,标准APRS 消息仅使用 AX.25 的 UI 帧(即非号码制信息帧。非号码制指没有添加顺序编号以进行顺序控制),故本文也仅以 UI 帧进行讲解。这种帧是一种无连接帧,不会附加顺序编号,也不具备重传机制,而是作为一种简单的、面向广播的消息传输方式,适用于 APRS 这种不要求严格传输顺序的数据通信场景。[3]
在会话层之后则是传输层,该层的主要任务是保证数据能够有效地从发送端传输到接收端。然而,由于 APRS 采用的 AX.25 UI 帧本身是无连接、无序的,其本身也并未设计数据纠错方法,因此它并没有传统意义上的传输层功能,也可以说传输层仅负责将会话层的数据交由下一层,即网络层。
网络层主要负责路由、转发以及寻址。AX.25 相较于 HDLC ,扩充的内容之一即是“在链路层中增加了一种可以简易中继的中继功能”。AX.25 的地址字段由目的地址、源地址和中继路径地址组成。路径地址最多可以包含 8 个中继站地址,这些地址字段在AX.25 帧传输过程中起到了类似“下一跳”路由的作用。[3]
最后就是数据链路层。数据链路层负责进行帧封装与差错控制。
首先是差错控制。AX.25 使用 CRC-16-CCITT 算法。帧封装时,先计算 16 位 CRC 校验值,按位取反后附加至数据流末尾。
接下来是位填充操作。由于 AX.25 的帧结构继承于 HDLC ,其帧标志与 HDLC 相同,均为 0x7E
(Bin: 01111110
)。传输时,若两个帧标志之间的数据段出现连续 6 个 1,接收端可能误判帧边界。因此,发送端检测到连续 5 个 1 时,会自动插入一个 0,打破 6 个 1 的序列。接收端解析时,若检测到连续 6 个 1,且第 7 位为 0,则识别为帧标志;若第 7 位为 1,则表明数据错误,接收端将丢弃该帧以防止错误传播。
位填充完成后,才正式添加帧标志。帧标志添加后,包含帧标志的二进制流需要经过 NRZI 反向不归零编码 处理。
关于 CRC-16-CCITT 算法 与 NRZI 反向不归零编码 的详细内容将在下一节讲述。
在开始之前,需要讲解三个概念:
CRC-16-CCITT 算法
位填充
NRZI 反向不归零编码
CRC 算法(Cyclic Redundancy Check,循环冗余校验)是一种广泛应用于数字通信和存储系统中的错误检测机制。其核心思想是将数据视为在二进制有限域 GF(2) 上定义的多项式,并通过对该多项式与预定义的生成多项式执行模 2 除法,计算出固定长度的余数作为校验值。该校验值连同原始数据一同传输,接收端则通过对收到的数据重新进行 CRC 运算,判断数据是否在传输过程中被破坏。
简单来讲就是对数据按一定的算法进行运算,使用其运算后的结果作为验证数据在传输过程中是否发生了差错。
例如,若发射端发送数据 24,并约定使用“除以2”的方式生成一个冗余值(结果为12),则传输的数据将变成 24 和 12 的组合。接收端重新进行验证,若得到的结果不是12,而是11,即可判断数据在传输过程中出现了差错,需要丢弃这段数据或要求重新传输。(当然实际上 CRC 校验并非这种逻辑,也远比这个例子复杂)
不过从本质上看,CRC 的计算并非简单的数值除法,而是在 GF(2) 上的多项式代数运算,其所有系数仅为 0 或 1,运算规则为“按位异或,无进位加法”。
其中,CRC-16-CCITT 是一种经典的 16 位 CRC 算法,最早由国际电报电话咨询委员会(CCITT)制定,广泛用于各种协议。它采用的标准生成多项式为:
0x1021
。在实际计算中,为配合低位优先(LSB-first)处理方式,常采用该多项式的反射形式 0x8408
,并以“反射型实现”进行校验流程。其中 AX.25 就使用反射式实现,其参数如下:参数 | 值 | 说明 |
---|---|---|
多项式 |
| 以反转形式 |
初始值 |
| 标准值,寄存器初始化为全 |
输入是否反转 | 是 | 每字节按 最低位优先 的顺序参与计算 |
输出是否反转 | 是 | 最终结果以 最低位优先 顺序输出 |
异或输出值 |
| 不进行额外异或操作 |
输出顺序 | LSB-first(最低位优先) | 整个流程均以 最低位优先 的顺序进行 |
在该反射实现中,数据流中的每个字节均按 最低位优先 的顺序处理。每位数据与当前 CRC 寄存器最低有效位进行异或,根据结果判断是否对寄存器执行与多项式的异或操作。每处理一位后,CRC 寄存器右移一位。
完整的校验流程如下:
初始化 CRC 寄存器值为 0xFFFF
;
对输入数据逐字节处理,每字节按最低位优先的顺序逐位参与校验;
每位数据与 CRC 寄存器最低位进行异或,若不同,则对寄存器与多项式 0x8408
进行异或;
寄存器右移一位,为下一位处理做准备;
数据全部处理完毕后,得到的 CRC 即为校验值,按 LSB-first 顺序输出。
其代码实现如下:
C// 初始化帧的CRC
void dataframe_start(void){
Crc=0xffff; // CRC初始值
……
}
// 按字节更新CRC
void crc_update_byte(uint8_t byte){
uint8_t i;
for(i=0;i<8;i++){
// 逐位处理:检查每一位是否为1
crc_update_bit(CHB(byte,BIT(i))?1:0);
}
}
// 按位更新CRC(CRC-16-CCITT算法,多项式0x8408)
void crc_update_bit(uint8_t crc_bit){
uint8_t shiftbit;
shiftbit=0x0001&(uint8_t)Crc; // 获取当前最低位
Crc>>=1; // 右移一位
if(shiftbit != crc_bit){ // 如果输入位与最低位不同
Crc ^= 0x8408; // 异或多项式
}
}
本文仅对 CRC算法 进行了简要介绍,更多详细的内容,建议读者查找更多资料以获得更全面的理解。
位填充 可能是最简单的内容。正如上一章节所说,位填充是为了适应帧同步头识别机制而需要进行的操作。其功能是,发送端检测到连续 5 个 1 时,会自动插入一个 0,打破 6 个 1 的序列。
C// 位填充:连续5个1后插入0
void stuff_transmit(uint8_t bit){
Stuff_currbyte=(Stuff_currbyte<<1) | bit; // 记录当前位
nrzi_modulate(bit); // 调制发送
if ((Stuff_currbyte & 0x1f)==0x1f){ // 检查是否连续5个1
Stuff_currbyte=(Stuff_currbyte<<1); // 插入0
}
}
NRZI 反向不归零编码 是一种用于确保时钟同步、降低直流分量并提升传输可靠性的相对简单的差分编码方式。
如果数据中出现了连续的相同比特,例如长串“0”或“1”,由于缺乏跳变,接收端难以从信号中提取比特间的边沿信息,从而无法准确恢复发送时钟,导致比特边界判断错误,引发同步失效。而 NRZI 编码通过在比特流中引入电平反转,打破比特间的单一性,即使在数据重复时也能产生边沿变化,从而实现一定程度的自同步。
NRZI 编码较为简单,其基本规则为:若当前比特为 0,则切换电平;若为 1,则保持当前电平不变。例如,编码比特流 00100010
时,假设起始电平为高电平,则对应的信号波形如图 3-3 所示。
图 3-3
不过,本文所讨论的 NRZI 编码主要关注在程序层面对比特流的逻辑处理,而非物理信道中的实际电平变化(尽管最终这些逻辑状态仍会映射到具体的电平输出)。在程序实现中,我们通常将信号电平抽象为一个二值状态变量,通过对该状态进行“翻转”或“保持”操作,完成 NRZI 编码的核心逻辑。在实际代码中,可以按如下方式实现:
C// NRZI编码:0翻转状态,1保持状态
void nrzi_modulate(uint8_t bit){
static uint8_t state=0; // 定义起始状态
if (bit==0){
state=state?0:1; // 输入0时翻转电平
}
modulate(state); // 根据电平状态调制频率
}
实际应用中,起始状态(初始电平)需要在发送端和接收端达成一致,或者在协议中通过预定义的同步头来确定起始状态。不过经过实际测试,起始状态为0或1时均可解码。
回到正题,本小节将按照帧结构的顺序,从左至右依次对 AX.25 解析。
AX.25 数据帧的基本结构如 图 3-4 所示:
图 3-4
在上一小节中,已经初步讲述了帧标志的相关内容。
帧标志采用 0x7E
(Bin: 01111110
),每个帧通过帧标志进行分隔,并标记着下一帧的开始(如果存在)。这意味着,两个连续的帧之间只出现一个标志字节,如 图 3-5 所示。
图 3-5
需要注意的是,帧标志不参与CRC计算,也不接受位填充处理,但需要经过NRZI编码。
以下是一段代码示例,帮助读者理解这一过程:
C// 发送帧标志 0x7E(01111110 b)
void transmit_sync(void){
uint8_t sync_word = 0x7E;
for (int i = 7; i >= 0; i--) {
printf("%d", (sync_word >> i) & 1);
nrzi_modulate((sync_word >> i) & 1); // 帧标志也需要经过NRZI编码处理
}
}
// 初始化帧的CRC和位填充状态
void dataframe_start(void){
Crc=0xffff; // CRC初始值
Stuff_currbyte=0; // 位填充状态清零
}
// 帧组装主函数
void transmit_AX25_frame(……) {
……
// 发送帧头,然后才初始化CRC和位填充状态
transmit_sync();
dataframe_start();
AX.25 协议使用可变长度的地址字段,长度范围为14字节至70字节(即2至10个地址字段)。
地址字段分为三类,按固定顺序排列。如图3-3所示,目标地址是第一个字段,源站地址是第二个字段,路径地址紧随其后。每个地址字段占用7字节,因此,在 AX.25 帧中,目标地址和源地址是必填项,路径地址(如中继节点)为可选项,且路径地址的总数不得超过8个。
前文提到,每个地址字段占用7字节,其中前6个字节为呼号部分,第7个字节为SSID(子站标识)及控制信息。
我们首先解析呼号部分。
呼号占用6个字节,因此呼号最大不能超过六位。如果呼号不足六位,剩余部分使用空格填充(Hex: 0x20)。
根据AX.25协议的规定,若一个地址字段的最后一位(第7字节的最低位)为0,表示后续还有地址;若为1,表示该地址字段结束。为实现这一标志位的嵌入,呼号部分需整体左移一位(包括空格),以腾出最低位。
尽管从实现角度来看,处理每个地址字段的第7字节即可,但由于早期解码硬件的限制,整个呼号被迫统一左移一位以便于解码。虽然现代解码硬件已不再受此限制,但出于兼容性考虑,这一操作仍保留在规范中。
在编码过程中,呼号中的每个字符按ASCII编码转换为二进制后左移一位。由于常见的大小写字母及标点(标准ASCII字符集)二进制最高位均为0,因此左移操作不会导致信息丢失。例如:
字符 | ASCII 原始值(二进制) | 左移一位后(二进制) |
---|---|---|
B |
|
|
G |
|
|
7 |
|
|
Z |
|
|
D |
|
|
Q |
|
|
接下来是 SSID 及控制信息 部分。
该部分占用1字节,格式如 图 3-6 所示:
图 3-6
其中 C 为控制字段,R 为保留位(填充1),X 为终止指示符。
对于源站与目标地址,当 C 为0时表示源站地址,C 为1时表示目标地址。
对于中继地址,C 被称为 H 比特 (Has-been-repeated bit)。
当 H 比特为0时表示尚未完成中继, H 比特为1时表示已经完成中继。
(在应用层界面上,中继完成后呼号后会加上 *
符号进行提示)
X即为前文所述的标志位。当X为1时,表示地址字段到此结束。
笔者曾在第二章中讲述SSID时进行了一个备注:“SSID 共有16个(请注意这个数量)”。现在读者可以知道, SSID 仅占 4 个字节,所以其最多就只有16种组合。
这一部分的示例代码如下:
C// 发送目标地址和SSID(ToCall)
for (int i = 0; i < 6; i++) {
// 如果字符串长度不足6位,补充空格(0x20)
char c = (i < strlen(ToCall)) ? ToCall[i] : 0x20;
transmit_byte(c << 1);
}
// 拼装并发送ToSSID
ssid = atoi(ToSSID); // 将ToSSID转换为0到15之间的整数值
binary_ssid = (ssid << 1);
// 该地址属于目标地址且路径尚未结束,故C=1、X=0,应拼接111????0
assembled_ssid = 0b11100000 | binary_ssid;
transmit_byte(assembled_ssid);
// 发送源站地址和SSID(FmCall)
for (int i = 0; i < 6; i++) {
char c = (i < strlen(FmCall)) ? FmCall[i] : 0x20;
transmit_byte(c << 1);
}
// 拼装并发送FmSSID
ssid = atoi(FmSSID);
binary_ssid = (ssid << 1);
// 该地址属于源站地址且路径尚未结束,故C=0、X=0,应拼接011????0
assembled_ssid = 0b01100000 | binary_ssid;
transmit_byte(assembled_ssid);
// 发送路径信息
for (int i = 0; i < 6; i++) {
char c = (i < strlen(Path)) ? Path[i] : 0x20;
transmit_byte(c << 1);
printf(": %c\n", c);
}
// 拼装并发送PathSSID
ssid = atoi(PathSSID);
binary_ssid = (ssid << 1);
// 该中继地址未命中,路径已经结束,故C=0、X=1,应拼接011????1
assembled_ssid = 0b01100001 | binary_ssid;
transmit_byte(assembled_ssid);
控制字段规定了该帧的类型。AX.25 所用的 UI 帧的控制字段值为 0x03
。
PID 标识了系统在更高层所采用的协议,该部分不进行过多阐述,其值设为 0xf0
即可。
这一部分的示例代码如下:
C// 发送帧信息及数据类型
transmit_byte(0x03); // 标记该帧为UI帧
transmit_byte(0xf0); // 协议指示
信息字段包含需要传输的实际信息。
AX.25 在设计之初仅考虑了ASCII字符的需求。当内容属于 ASCII 字符集所规定的标准字符时,按 ASCII 编码转换为二进制即可(此时每个字节的开头均为0
)。不过随着计算机技术的发展,通过软件进行调制与解调,可以进行不属于 ASCII 字符集所规定的字符传输。此时字符按照程序运行环境的编码进行转换。(转换出来的每个字节的开头为1
)
例如:
00110000: ASCII字符 “0”
同样,接收端根据接收到的字节的开头,能在ASCII编码和接收端程序所用的复杂编码之间切换。需要注意的是,AX.25协议本身并未定义编码控制,因此,接收端和发送端所用编码不一致时可能会导致乱码。
这一部分的示例代码如下:
C// 发送信息
for(pi=INFO; *pi!=0; pi++){
transmit_byte(*pi);
printf(":%c\n", *pi);
}
注意:本节中有关 非ASCII字符 的内容存在问题,欢迎各位探讨。
AX.25 所用的 FCS(即差错控制)手段为本章开头所提及的 CRC-16-CCITT 。
运算后,将 CRC-16-CCITT 的16位校验码取反并拆分为高位和低位,依次插入数据流。
这一部分的示例代码如下:
C// 计算并发送CRC校验值(取反后拆分高低位)
Crc^=0xffff;
crcl=Crc&0xff;
crch=(Crc>>8)&0xff;
transmit_byte(crcl);
transmit_byte(crch);
最后,将从3.3.2至3.3.5所述的处理后的二进制流送入位填充函数。接着,在该二进制流的前后添加3.3.1所述的帧同步标识,送入NRZI编码函数进行处理,然后将最终的二进制流输入AFSK等物理层调制方式。
需要注意的是,程序可以通过不同的逻辑实现相同的功能。本章所介绍的流程是在完成所有非数据链路层工作后,再统一进行数据链路层处理,这样的程序逻辑较为清晰且性能较好。但在实际编写代码时,我将其他层的工作与数据链路层的工作混合进行,虽然这种方式比较混乱且性能一般,但毕竟只是一个练手的作品,目前不打算再进行修改。为便于参考,我已将完成的代码上传到了 Github:https://github.com/XuanJing-Satellite/APRS-Modulation
[1] 谢钧,谢希仁. 计算机网络教程(第4版)[M].人民邮电出版社:北京,2014
[2] 杜庆山. AX.25协议与HDLC及OSI的比较[J]. 无线电通信技术,1989,015 (5):11-17.
[3] 杜庆山. AX.25链路层协议帧结构分析[J]. 无线电通信技术,1990,16(2):14-20.
以及:其他来自国内外爱好者群体和相关行业从业者所创作的众多内容,此处不再详细列举。
这篇文章其实早在今年二月便已开始动笔,但一直到四月份的清明节假期才重新拾起来,花了几天时间才堪堪完成。实际的篇幅比我预想的确实长了很多,内容结构安排可能也并不是那么的好。为了降低理解门槛,文章中用了较大的篇幅向零基础读者解释相关基础知识,但实际效果可能并不理想:对于初学者而言,理解仍有一定难度;而对已有基础的读者来说,又显得过于冗长。
总之,这篇文章就暂时写到这里,欢迎感兴趣的朋友转载、改编或重新整理编排。本文所有内容均采用 CC BY 4.0 知识共享许可协议,保留署名权。同时,也欢迎各位朋友提出宝贵意见与建议!
2025年4月7日 初始提交。
2025年4月11日 根据管理员要求添加程序附件。
[修改于 1个月10天前 - 2025/04/12 00:05:31]
200字以内,仅用于支线交流,主线讨论请采用回复功能。