LoRa发20字节为何要占用1.3秒

2026-10-251
AI 快速预览详细 收起
本文讨论了在SF12、125 kHz带宽下,20字节的LoRa报文空中传输时间长达1318.912 ms的问题。提供了一个Python工具,输入无线参数、发送周期和重试上限,可以计算出空中时间和周期预算。工具覆盖SF7到SF12、125/250/500 kHz三个常见带宽,载荷1到255字节。通过计算结果,可以帮助用户决定采样频率、报文长度和重试策略。

温度、湿度、电池电压,再加上设备编号,20字节已经够装下一份小型传感器报文。把发送周期设成一秒,看上去相当宽裕。

可是在 SF12、125 kHz 带宽下,这一包光在空中就要占用 1318.912 ms。下一秒到了,上一包还没发完。串口能迅速把数据交给模块,并不意味着无线链路也能在同样短的时间内完成发送。

这次做一个能直接运行的审查工具。输入无线参数、发送周期和重试上限,它算出空中时间,并明确告诉你周期里是否放得下。先把这笔时间账算清,再决定采样频率、报文长度和重试策略。

20字节在不同SF下的理论空中时间

图1 其余条件固定时,SF7为56.576 ms,SF9为185.344 ms,SF12为1318.912 ms。图中竖线是一秒周期,不包含额外软件开销。

先确认自己在给什么链路算账

工程使用 ESP-32S 主控与 Ra-01 模块,后者采用 SX1278。这里讨论的是 raw LoRa 点对点数据包,不是把一个收发模块当成完整的多通道 LoRaWAN 网关。若实际运行 LoRaWAN,还要把其帧头、MIC、MAC命令等计入传给无线芯片的总载荷,不能只填写应用层那20字节。

从实际 EDA 1.2工程重新导出原理图和网表,可以对应到下面这组控制连接。

ESP32与Ra-01真实原理图局部

图2 从上到下是主控SPI/DIO0相关引脚、主控RESET局部和无线模块。三块局部来自同一张实际原理图,未重新绘制电气连接。

信号 ESP32 GPIO Ra-01 焊盘
NSS 5 15
MOSI 23 14
MISO 19 13
SCK 18 12
RESET 14 4
DIO0 2 5

注意GPIO编号和模组焊盘编号是两回事。例如 ESP-32S 的29号焊盘是GPIO5,不能把它写成GPIO29。DIO0连在GPIO2上,也不能忽略ESP32启动绑带的约束;真正接入固件前,需要检查复位阶段的电平条件。

真实EDA导出的PCB顶底层

图3 上图为顶层铜箔与丝印,下图为底层铜箔与丝印,底层按从顶面观察的投影显示,未镜像。两张母版均从实际EDA矢量导出,正文裁去画布留白。

这块板的资料还提示了值得留意的边界。原理图中USB供电标成5V,稳压输入标成+5V,网表将它们分为不同网络;PCB上的供电标注则是+5V。射频输出附近还预留了标成0.1u的对地电容位置,这不能理解为照焊就能增强信号。

因此,本篇不提供整板照抄接线或射频匹配方案。下面的工具在电脑上独立运行,不依赖这块板供电,也不触发无线发送。电源、天线、匹配和实际链路质量需要另外验证,不能拿计算出的空中时间替代这些工作。

20字节究竟消耗了多少个符号

LoRa的一个符号需要多长时间,由扩频因子SF和带宽BW共同决定。

Ts = 2^SF / BW

BW用Hz,结果就是秒。BW固定在125000 Hz时,SF7每个符号占1.024 ms,SF12则占32.768 ms。把SF调大,首先改变的是每一个符号的持续时间。

还不能到这里就用载荷字节数去乘。数据需要经过编码、分组,最后一组即使没装满,也要占完整的符号组。

按SX127x手册的Time on air公式,载荷部分的符号数可以写成下面的形式。

A = 8×PL - 4×SF + 28 + 16×CRC - 20×IH
B = 4×(SF - 2×DE)
Npayload = 8 + max(ceil(A / B) × (CR + 4), 0)
Tpacket = (Npreamble + 4.25 + Npayload) × Ts

PL是交给调制器的载荷字节数;CRC开启为1;IH为0表示显式头、为1表示隐式头。CR不是直接输入“4/5”,而是编码值1到4,分别代表4/5到4/8。Npreamble是前导长度寄存器里的值,公式还会加上固定的4.25个符号,不能重复相加。

DE是低数据率优化位。手册要求,符号时长超过16 ms时必须开启,而且收发两端设置一致。工具默认据此选择DE,也允许明确指定开启;应当开启时强制关闭,会直接报错。自动计算不会替你修改硬件寄存器,实际固件仍要核对自己的配置。

把20字节、SF12、125 kHz、CR4/5、显式头、CRC开启、前导8代进去,DE应当为1。此时A等于156,B等于40,向上取整得到4;载荷占28个符号,总计40.25个符号。

40.25 × 32.768 ms = 1318.912 ms。

这也是标题中1.3秒的来历。它来自明确参数下的模型计算,不是实测截图里的一个偶然读数。

减少字节之前,先看看有没有跨过台阶

空中时间不会随着字节数一格一格匀速增长。固定SF7、125 kHz、CR4/5和前述报头配置,19字节是51.456 ms;20字节跳到56.576 ms,21和22字节仍然是56.576 ms;23字节才再跳到61.696 ms。

载荷长度与空中时间的台阶

图4 结果来自同一版工具的逐载荷计算。20到22字节没有跨过新的编码分组,所以时间完全相同。

假如把22字节压缩成21字节,数据格式复杂了,空中占用却没减少。压缩仍可能有其他收益,但要用计算结果判断是否真的换来了发送时间。反过来,也不能为凑满当前台阶随便追加字段,接收兼容性和业务必要性仍要考虑。

把重试也放进周期,而不是只看第一包

单包时间够短,还不等于整个发送策略能放进周期。这里用一个保守而清楚的预算式。

本周期预留时间 = 总发送次数 × (单包空中时间 + 每次额外预留)
余量 = 周期 - 本周期预留时间

总发送次数包括第一次。例如允许最多重试一次,就填2,不是1。每次额外预留需要覆盖自己实现中的启动、调度、退避、接收窗口等开销;示例里的10 ms只是演示输入,绝不是对真实硬件的保证。有ACK等待或随机退避时,要先取得对应的最坏开销,再填入预算。

SF12的20字节包,在一秒周期内已经放不下。若周期改成三秒、最多发送两次、每次额外预留10 ms,总预留是2657.824 ms,名义余量342.176 ms。若这个余量无法覆盖实际抖动,就仍然不应采用这组参数。

工具中的air_percent表示本周期计划的空中时间需求比例,可能超过100%。它不是频谱法规合规判断,也不能据此推断多节点不会碰撞。收发器半双工、其他节点竞争、频率配置与地区规则都需要单独处理。

完整工具直接复制运行

保存下面代码为lora_budget.py,使用Python 3.10或更新版本,无需第三方依赖。工具覆盖SF7到SF12、125/250/500 kHz三个常见带宽,载荷1到255字节;SF6涉及特殊检测设置,本版主动排除。其他芯片或更窄带宽也不要直接套用此输入范围。

"""SX127x raw-LoRa timing budget. Python 3.10+, standard library only.
Independent implementation, MIT License, Copyright (c) 2026.
No radio I/O. Not a regulatory or RF-link compliance checker.
"""
import argparse
import json
from fractions import Fraction as F


def packet(sf, bw, payload, cr=1, preamble=8, crc=True,
           implicit=False, de="auto"):
    for name, value, low, high in (
        ("sf", sf, 7, 12), ("payload", payload, 1, 255),
        ("cr", cr, 1, 4), ("preamble", preamble, 6, 65535),
    ):
        if type(value) is not int or not low <= value <= high:
            raise ValueError(f"{name} must be an integer in {low}..{high}")
    if type(bw) is not int or bw not in (125000, 250000, 500000):
        raise ValueError("bw must be 125000, 250000 or 500000 Hz")
    if type(crc) is not bool or type(implicit) is not bool:
        raise ValueError("crc and implicit must be bool")
    if de not in ("auto", "on", "off"):
        raise ValueError("de must be auto, on or off")
    symbol_ms = F((1 << sf) * 1000, bw)
    required = symbol_ms > 16
    if de == "off" and required:
        raise ValueError("symbol exceeds 16 ms; DE cannot be off")
    enabled = required if de == "auto" else de == "on"
    numerator = 8 * payload - 4 * sf + 28 + 16 * crc - 20 * implicit
    denominator = 4 * (sf - 2 * enabled)
    groups = max(-(-numerator // denominator), 0)
    payload_symbols = 8 + groups * (cr + 4)
    airtime_ms = (F(preamble) + F(17, 4) + payload_symbols) * symbol_ms
    return {"symbol_ms": symbol_ms, "de": int(enabled),
            "payload_symbols": payload_symbols, "airtime_ms": airtime_ms}


def budget(airtime_ms, period_ms, guard_ms=10, attempts=1):
    period, guard, air = F(period_ms), F(guard_ms), F(airtime_ms)
    if period <= 0 or guard < 0 or air <= 0:
        raise ValueError("period and airtime must be positive; guard >= 0")
    if type(attempts) is not int or not 1 <= attempts <= 100:
        raise ValueError("attempts must be an integer in 1..100")
    occupied = attempts * (air + guard)
    margin = period - occupied
    return {"reserved_ms": occupied, "margin_ms": margin,
            "air_percent": 100 * attempts * air / period,
            "fits": margin >= 0}


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--sf", type=int, required=True)
    p.add_argument("--bw", type=int, default=125000)
    p.add_argument("--payload", type=int, required=True)
    p.add_argument("--cr", type=int, default=1, help="1..4 means 4/5..4/8")
    p.add_argument("--preamble", type=int, default=8)
    p.add_argument("--no-crc", action="store_true")
    p.add_argument("--implicit", action="store_true")
    p.add_argument("--de", choices=("auto", "on", "off"), default="auto")
    p.add_argument("--period-ms", default="1000")
    p.add_argument("--guard-ms", default="10")
    p.add_argument("--attempts", type=int, default=1,
                   help="maximum TOTAL transmissions, including the first")
    a = p.parse_args()
    try:
        t = packet(a.sf, a.bw, a.payload, a.cr, a.preamble,
                   not a.no_crc, a.implicit, a.de)
        b = budget(t["airtime_ms"], a.period_ms, a.guard_ms, a.attempts)
    except (ValueError, ZeroDivisionError) as e:
        p.error(str(e))
    result = {"sf": a.sf, "bw_hz": a.bw, "payload_bytes": a.payload,
              "cr_code": a.cr, "preamble_register": a.preamble,
              "crc": not a.no_crc, "implicit": a.implicit,
              "de_mode": a.de, "attempts_total": a.attempts,
              "period_ms": a.period_ms, "guard_ms_per_attempt": a.guard_ms}
    result.update(t)
    result.update(b)
    for k, v in result.items():
        if isinstance(v, F):
            result[k] = round(float(v), 6)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0 if b["fits"] else 2


if __name__ == "__main__":
    raise SystemExit(main())

先算标题中的例子。

python lora_budget.py --sf 12 --payload 20 --period-ms 1000

关键输出为airtime_ms=1318.912、reserved_ms=1328.912、margin_ms=-328.912、fits=false。退出码是2,方便让构建脚本或参数生成流程拒绝这份计划。错误参数同样以非零状态退出,调用方应检查退出码和输出内容。

要比较SF7,只需把--sf 12改成--sf 7。若核算最多两次发送,使用下面的命令。

python lora_budget.py --sf 12 --payload 20 --period-ms 3000 --attempts 2

主机运行的周期审查结果

图5 三个配置的真实CLI运行摘要。空中时间、每次10 ms预留和一秒周期使用同一份参数;SF12被明确判为放不下。

计算中使用有理数保留精确值,避免在恰好踩着周期边界时提前舍入。JSON只在展示时保留六位小数,fits判断用的是未舍入结果。--period-ms 1328.912恰好可容纳本例的一次发送,少0.000001 ms就会返回放不下;工程上当然不该把名义余量压到零。

验证分成三层。先人工推导SF7、SF9、SF12的20字节向量,再用Decimal公式独立交叉计算73,440组SF、带宽、编码率、CRC、报头模式和载荷组合;最后检查载荷台阶、DE切换、非法输入、重试计数和精确周期边界。6组自动测试全部通过,没有用无线实测来包装这些主机结果。

把工具放回自己的开发流程时,最有用的一步是让它读取与固件相同的配置来源。报文增加字段、CRC设置变更或重试上限改变,就重新核算一次。计划放不下时,再在发送周期、无线参数、负载和可靠性目标之间做取舍。降低SF可能缩短占用,也可能牺牲链路预算,必须结合现场测试决定。

资料与验证范围

工程与图像来源为盐汽水-BD4USQ的ESPLoRa 1.2v工程,GPL 3.0。项目入口 https://oshwhub.com/yanqisui/lorawan-kai-fa-ban ,实际原理图 https://lceda.cn/editor#id=636b8143f02448bb995733eb067729ac ,PCB https://lceda.cn/editor#id=90071198b8344543803ed21a09059193 。采用其公开工程进行局部裁切、矢量栅格化及排版,未改变电路连接;许可文本 https://www.gnu.org/licenses/gpl-3.0.html 。

公式依据Semtech《SX1276/77/78/79 Datasheet》Rev.7(May 2020)第31页,官方入口 https://www.semtech.com/products/wireless-rf/lora-connect/sx1278 。正文工具为独立编写的MIT许可代码,完整源码就在上方代码块。本文完成网表对应核对、参数推导和主机验证,未完成整板上电、射频匹配、无线距离或法规符合性测试。

技术交流,欢迎关注微信公众号:美男子玩编程。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -