跳转至

吞吐量测试

使用技术:SLE SSAP 通知/写入、速率统计

前置阅读:高吞吐传输

学习目标

  • 理解 SLE 吞吐量测试的科学方法——滑动窗口统计、不同配置下的 A/B 对比
  • 掌握有效吞吐量(去协议头)和物理层速率的区别
  • 能够设计一组配置矩阵测试,找到最优 PHY/MCS/连接间隔组合
  • 理解丢包率与 RSSI 的关系,判断链路质量

基本概念

有效吞吐量 vs 物理层速率

物理层 4M PHY = 4M symbols/s,但这个数字是"空中符号率",远不等于实际能传输的有效数据速率。实际有效吞吐量需要层层扣除:

flowchart TD
    PHY[4M PHY 物理层速率<br/>4 Mbps] --> L1[减 L1 开销<br/>前导码 + CRC + 接入地址]
    L1 --> L2[减 L2 开销<br/>GAP 帧头 + 链路层控制]
    L2 --> IDLE[减连接间隔空闲<br/>两次连接事件之间的等待]
    IDLE --> RETX[减重传开销<br/>丢包重传消耗的带宽]
    RETX --> EFF[有效吞吐量<br/>实测约 2~3 Mbps]

    style PHY fill:#f9f,stroke:#333
    style EFF fill:#9f9,stroke:#333
损耗来源 大约占比 说明
L1 开销(前导码、CRC、接入地址) ~15% 每包固定的物理层开销
L2 开销(GAP 帧头、链路层控制) ~10% 链路层协议开销
连接间隔空闲 ~20% 取决于连接间隔参数
重传 ~5-15% 取决于信号质量
有效载荷 ~50-60% 最终应用层可见的数据

测试方法:单变量法

每次测试固定一个组合(PHY/MCS/连接间隔/MTU),发送固定数量包(如 10000 包),用滑动窗口计算 Kbps/Mbps。改变一个变量,其他变量不变——这是科学实验的基本原则。

丢包率统计

每包带递增序号 → 接收方检测序号不连续 → 记录丢失序号。丢包率 = 丢失包数 / 总发包数。丢包率 > 1% 说明当前配置不适合当前信号环境。

flowchart LR
    TX[发送方<br/>seq=0,1,2,3,4,5...]
    AIR[空中传输]
    RX[接收方]

    TX --> AIR --> RX

    subgraph 丢包检测
        RX2[收到 seq: 0,1,3,4,5]
        CHECK[检测: 2 缺失]
        LOSS[丢包计数 +1]
    end

    RX --> RX2
    RX2 --> CHECK
    CHECK --> LOSS

丢包不一定是真的"丢失"——可能是 CRC 校验失败被协议栈丢弃,也可能是发送时协议栈缓冲区满没有真正发出。前者属于链路质量问题,后者属于流控问题。区分它们需要同时观察发送方的流控状态和接收方的 RSSI。

涉及 API

API 谁调用 用途
sle_set_phy_param() 双方 设置 PHY
sle_set_mcs() 双方 设置 MCS
sle_set_data_len() Server 设置载荷长度
ssaps_notify_indicate() Server 发送测试数据包
sle_read_remote_device_rssi() Client 读取 RSSI 辅助分析
osal_get_cur_time_ms() Client 计时用于速率计算

案例说明

做什么

Server 发包 / Client 收包统计,轮换配置矩阵(PHY x MCS x 连接间隔)→ 记录每个组合的吞吐量和丢包率 → 输出最优配置。

测试结果表格模板

PHY MCS 连接间隔 理论速率 实测吞吐 丢包率 RSSI 距离
1M MCS0 12.5ms 0.25 Mbps ??? ??? ??? ???
1M MCS4 12.5ms 1.0 Mbps ??? ??? ??? ???
2M MCS6 12.5ms 2.0 Mbps ??? ??? ??? ???
4M MCS8 2.5ms 2.0 Mbps ??? ??? ??? ???
4M MCS10 2.5ms 3.0 Mbps ??? ??? ??? ???

案例流程说明

sequenceDiagram
    participant S as Server
    participant C as Client

    Note over S,C: 连接 + 配对 + MTU 协商

    S->>C: sle_set_phy_param(4M)
    C->>S: sle_set_phy_param(4M)
    S->>S: sle_set_mcs(MCS10)
    S->>S: sle_set_data_len(1500)

    Note over S: 开始第 1 轮测试: 4M + MCS10 + 2.5ms

    S->>S: pkt_count = 0
    loop 发送 10000 包
        S->>C: Notification (1200B, seq=pkt_count)
        Note right of C: 检测 seq 连续性
        Note right of C: total_bytes += 1200
        S->>S: pkt_count++
        alt pkt_count % 1000 == 0
            Note right of C: 打印瞬时吞吐 + 丢包
        end
    end

    Note over S: 切换到下一组配置...
    Note over S: 第 2 轮: 4M + MCS8 + 5ms

案例操作指导

第一步:编译固件

.config 中设置:

CONFIG_SAMPLE_SUPPORT_SLE_THROUGHPUT_TEST=y
CONFIG_THROUGHPUT_SERVER=y
CONFIG_THROUGHPUT_CLIENT=y
fbb build ws63-liteos-app -p menuconfig
fbb build ws63-liteos-app

第二步:烧录并运行

Server 固件烧录到板子 A,Client 固件烧录到板子 B。Server 先上电(启动广播),Client 后上电(扫描连接)。

第三步:观察测试输出

Server 预期输出:

[throughput server] connected, conn_id=0
[throughput server] test round 1: PHY=4M MCS=10 interval=2.5ms
[throughput server] sending 10000 packets...
[throughput server] round 1 done. wait for switch...

Client 预期输出:

[throughput client] connected, conn_id=0
[throughput client] test round 1: PHY=4M MCS=10 interval=2.5ms
[throughput client] [   0- 999] 285.3 KB/s  lost=0  rssi=-45
[throughput client] [1000-1999] 290.1 KB/s  lost=1  rssi=-44
[throughput client] [2000-2999] 287.8 KB/s  lost=0  rssi=-45
...
[throughput client] === round 1 summary ===
[throughput client] avg: 288.2 KB/s  total lost: 3/10000 (0.03%)

第四步:收集数据

按照配置矩阵逐轮测试,将每轮的测试结果填入表格。最终找出在当前环境下吞吐量最高且丢包率可接受(< 1%)的配置组合。

关键配置

测试参数

/* 测试包数:10000 包 / 轮。
   足够消除启动波动(前几十包可能受连接建立影响)。 */
#define TEST_PACKETS_PER_ROUND   10000

/* 包大小:1200 字节。
   接近 MTU 上限(1500),最大化有效载荷比例。
   减去 20 字节的序列号 + 时间戳头 = 1180 字节纯载荷。 */
#define TEST_PACKET_SIZE         1200

/* 统计窗口:1000 包。
   平衡统计精度和时效性——1000 包约 2~3 秒完成,
   观察者可及时看到吞吐变化趋势。 */
#define STATS_WINDOW_SIZE        1000

配置矩阵

变量 取值 说明
PHY 1M / 2M / 4M 物理层速率
MCS MCS0 / MCS4 / MCS6 / MCS8 / MCS10 调制编码方案
连接间隔 2.5ms / 5ms / 7.5ms / 12.5ms 调度密度
MTU 520 / 1024 / 1500 最大传输单元

不需要遍历全部 4x5x4x3 = 240 种组合——大多数组合没有意义。例如 MCS10 只在 4M PHY 下有效,1M PHY 配 MCS10 没有意义。实际测试通常控制在 15~20 个有代表性的组合。

配置矩阵(常用组合)

typedef struct {
    uint8_t  phy;           // SLE_PHY_1M / SLE_PHY_2M / SLE_PHY_4M
    uint8_t  mcs;           // SLE_MCS_0 ~ SLE_MCS_10
    uint16_t conn_interval; // 连接间隔(单位 125us)
    uint16_t mtu;           // MTU 大小
} throughput_test_config_t;

static const throughput_test_config_t test_matrix[] = {
    /* PHY, MCS,    间隔,    MTU  */
    {SLE_PHY_1M, SLE_MCS_4,  0x64,  520 },  // 基线:1M 标准模式
    {SLE_PHY_2M, SLE_MCS_6,  0x64, 1024 },  // 2M 中速
    {SLE_PHY_2M, SLE_MCS_8,  0x32, 1024 },  // 2M 高速
    {SLE_PHY_4M, SLE_MCS_8,  0x32, 1500 },  // 4M 中速
    {SLE_PHY_4M, SLE_MCS_10, 0x14, 1500 },  // 4M 最高吞吐
    {SLE_PHY_4M, SLE_MCS_10, 0x32, 1500 },  // 4M 高速 + 标准间隔
};

代码详解

数据包结构

/* 每包数据的结构——前 20 字节是元数据,后面是载荷 */
typedef struct {
    uint32_t seq;           // 递增序号(用于丢包检测)
    uint32_t timestamp_ms;  // 发包时刻(用于计算单向延迟)
    uint32_t magic;         // 魔数 0x5LE544B(用于数据完整性校验)
    uint32_t reserved;      // 保留
    uint8_t  test_data[1180]; // 测试载荷(填充 0xA5 模式)
} throughput_packet_t;

/* 打包一个测试包 */
static void fill_test_packet(throughput_packet_t *pkt, uint32_t seq)
{
    pkt->seq          = seq;
    pkt->timestamp_ms = osal_get_cur_time_ms();
    pkt->magic        = 0x5LE544B;
    pkt->reserved     = 0;
    memset(pkt->test_data, 0xA5, sizeof(pkt->test_data));
}

速率统计公式

核心公式非常简单——关键在于统计窗口的选择和计时精度:

/* 速率统计。
   bps = total_data_bytes × 8000 / elapsed_ms
   为什么要 × 8000?
   - 字节 → 比特需要 × 8
   - 毫秒 → 秒需要 × 1000
   - 合并: × 8 × 1000 = × 8000

   为什么用 total_data_bytes 而不是 total_packets?
   - 因为不同测试轮次的包大小可能不同
   - 用字节数统一度量,便于跨轮对比
*/
static uint32_t calc_throughput_bps(uint32_t total_bytes, uint32_t elapsed_ms)
{
    if (elapsed_ms == 0) return 0;
    return (total_bytes * 8000) / elapsed_ms;
}

Server 端发送循环

static void throughput_send_task(void *arg)
{
    throughput_packet_t pkt;
    uint32_t seq = 0;
    uint32_t round_start_time = osal_get_cur_time_ms();

    while (seq < TEST_PACKETS_PER_ROUND) {
        /* 1. 检查连接状态 */
        if (g_conn_id == 0) {
            osal_sleep_ms(10);
            continue;
        }

        /* 2. 填充数据包 */
        fill_test_packet(&pkt, seq);

        /* 3. 发送(Notification 模式——不等待对端确认) */
        errcode_t ret = ssaps_notify_indicate(
            g_conn_id, g_property_handle,
            SSAP_PROPERTY_TYPE_VALUE,
            (uint8_t *)&pkt, sizeof(pkt),
            SSAP_NOTIFY_TYPE_NOTIFICATION);

        if (ret == ERRCODE_SUCC) {
            seq++;
        } else if (ret == ERRCODE_SLE_BUSY) {
            /* 协议栈忙:等待 QoS 恢复。
               高速发送时流控是常态——缓冲区满了就要等。
               不休眠直接重试会 CPU 空转;休眠 1ms 是经验值,
               太短没意义(协议栈不会在微秒级排空缓冲区),
               太长会降低吞吐。 */
            osal_sleep_ms(1);
        } else {
            printf("[tp srv] send error: %d at seq=%d\n", ret, seq);
            osal_sleep_ms(10);
        }

        osal_task_yield();
    }

    uint32_t elapsed = osal_get_cur_time_ms() - round_start_time;
    uint32_t total_bytes = seq * sizeof(throughput_packet_t);
    uint32_t bps = calc_throughput_bps(total_bytes, elapsed);
    printf("[tp srv] round done: %d pkts in %d ms, avg %d KB/s\n",
           seq, elapsed, bps / 8000);
}

Client 端接收与统计

static uint32_t g_total_bytes     = 0;
static uint32_t g_pkt_in_window   = 0;
static uint32_t g_window_start_ms = 0;
static uint32_t g_last_seq        = 0;
static uint32_t g_lost_packets    = 0;
static bool     g_first_pkt       = true;

static void throughput_notification_cb(uint16_t conn_id, uint16_t handle,
                                        uint8_t *data, uint16_t data_len)
{
    throughput_packet_t *pkt = (throughput_packet_t *)data;

    /* 1. 数据完整性校验 */
    if (pkt->magic != 0x5LE544B) {
        printf("[tp cli] corrupted packet, magic mismatch\n");
        return;
    }

    /* 2. 窗口初始化 */
    if (g_pkt_in_window == 0) {
        g_window_start_ms = osal_get_cur_time_ms();
    }

    /* 3. 丢包检测(基于 seq 连续性) */
    if (!g_first_pkt) {
        if (pkt->seq != g_last_seq + 1) {
            /* seq 不连续——中间有包丢失 */
            uint32_t lost = pkt->seq - g_last_seq - 1;
            g_lost_packets += lost;
        }
    } else {
        g_first_pkt = false;
    }
    g_last_seq = pkt->seq;

    /* 4. 累计字节数 */
    g_total_bytes += data_len;
    g_pkt_in_window++;

    /* 5. 每 STATS_WINDOW_SIZE 个包打印一次统计 */
    if (g_pkt_in_window >= STATS_WINDOW_SIZE) {
        uint32_t elapsed = osal_get_cur_time_ms() - g_window_start_ms;
        uint32_t bps     = calc_throughput_bps(g_total_bytes, elapsed);
        int8_t   rssi    = sle_read_remote_device_rssi(conn_id);

        printf("[tp cli] [%5d-%5d] %d KB/s  lost=%d  rssi=%d\n",
               g_last_seq - STATS_WINDOW_SIZE + 1, g_last_seq,
               bps / 8000, g_lost_packets, rssi);

        /* 重置窗口 */
        g_pkt_in_window = 0;
        g_total_bytes   = 0;
    }
}

丢包原因分析

丢包率高时,结合 RSSI 判断根因:

static void analyze_packet_loss(uint32_t lost, uint32_t total, int8_t rssi)
{
    uint32_t loss_rate = (lost * 1000) / total;  // 千分比

    if (loss_rate < 10) {
        return;  // < 1%: 正常,无需关注
    }

    printf("[tp cli] !! high loss rate: %d.%d%%\n",
           loss_rate / 10, loss_rate % 10);

    if (rssi < -75) {
        /* RSSI 弱 → 链路质量差 → 降低 PHY 或缩小距离 */
        printf("[tp cli] cause: weak signal (rssi=%d). "
               "try lower PHY or shorter distance.\n", rssi);
    } else if (rssi < -60) {
        /* RSSI 中等 → 可能是干扰 → 切换信道试试 */
        printf("[tp cli] cause: possible interference. "
               "try different channel.\n");
    } else {
        /* RSSI 强但丢包 → 可能是流控问题,发送方堆积 */
        printf("[tp cli] cause: likely flow control. "
               "check server side QoS status.\n");
    }
}