找回密码
立即注册
搜索
发回帖 发新帖

5424

积分

0

好友

697

主题
发表于 昨天 22:39 | 查看: 9| 回复: 0

网线插上后 ping 不通,或者 TCP 连上之后隔几分钟就断开重连。

这类问题排查起来很消耗耐心:应用层逻辑看着没问题,初始化例程也“成功”返回,但数据就是不通。下面直接从以太网硬件链路开始,一路走到 lwIP 接口选型、RTOS 适配、内存配置和 TCP 客户端的重连,把经常踩的坑逐个过一遍。

以太网硬件链路

在嵌入式网络方案里,MCU 侧通常内置 MAC,外面挂一颗 PHY(常见的如 LAN8742、DP83848),中间通过 RMII 接口走数据,PHY 寄存器则用 MDIO 总线读写。链路要想真正通,三个条件要同时成立:PHY 被正确复位并从自协商中拿到链路;RMII 的 50MHz 参考时钟稳定;MAC 的 DMA 描述符环形队列配置正确。哪个没满足,都可能出现“看起来初始化成功,实际收发包异常”的情况。

PHY 地址是第一个容易卡住的点。LAN8742 的 PHYAD0 引脚决定地址是 0 还是 1,原理图上没看清楚就照抄例程,读回来的 PHYIDR 全 0xFF 或全 0,现象是初始化“成功”但永远上不了线。稳妥的做法是启动时扫描 0~31,用 PHYIDR1/PHYIDR2 是否可读来判断真实地址。

// 扫描 PHY 地址:读 PHYIDR1(寄存器2),能读到合法 ID 即认为该地址有效
int eth_phy_scan(ETH_HandleTypeDef *heth)
{
    uint32_t reg;
    for (int addr = 0; addr < 32; addr++) {
        if (HAL_ETH_ReadPHYRegister(heth, (uint16_t)addr, PHY_IDR1, ®) == HAL_OK) {
            if (reg != 0x0000 && reg != 0xFFFF) {
                return addr;      // 返回第一个可用的 PHY 地址
            }
        }
    }
    return -1;
}

// 读取链路状态:BCR(寄存器0) 触发,BSR(寄存器1) 的 bit2 是 Link Status
int eth_phy_link_up(ETH_HandleTypeDef *heth, uint16_t phy_addr)
{
    uint32_t bsr;
    HAL_ETH_ReadPHYRegister(heth, phy_addr, PHY_BSR, &bsr);
    return (bsr & 0x0004) ? 1 : 0;
}

链路状态必须周期轮询,不能只在初始化时读一次。网线中途拔掉,PHY 的 BSR 会变化,lwIP 需要收到通知才会停掉 DHCP 和 ARP,否则会一直往一个断开的网卡发包。

// 链路回调:lwIP 侧注册,驱动检测到变化时调用
void eth_link_check(struct netif *netif, ETH_HandleTypeDef *heth, uint16_t phy_addr)
{
    static uint8_t last_up = 0;
    uint8_t up = (uint8_t)eth_phy_link_up(heth, phy_addr);
    if (up != last_up) {
        last_up = up;
        if (up) {
            netif_set_link_up(netif);      // 触发 DHCP 重启、ARP 重新探测
        } else {
            netif_set_link_down(netif);
        }
    }
}

netif_set_link_up() 内部会回调 LWIP_NETIF_LINK_CALLBACK 注册的钩子,DHCP 的自动重启逻辑就挂在这个钩子上。忘记在 lwipopts.h 里打开这个宏,链路回调就是空操作。

三种 API 的取舍

lwIP 提供三层接口,越往上封装越强,代价是内存和一次数据拷贝。

API 调用方式 运行上下文 内存开销 适用场景
raw/callback 回调驱动,非阻塞 tcpip 线程 最小,无额外任务栈 资源紧张、连接数少
netconn 阻塞式,带信号量 独立任务 每连接一个信号量 需要顺序语义
socket BSD 风格 独立任务 最大,含 socket 层封装 移植现有代码

raw API 的约束最需要适应:所有回调都在 tcpip 线程里执行,回调里不能做耗时操作,否则同线程的定时器、ARP、DHCP 全部被拖住。经验法则是回调里只做数据搬移和置标志,业务处理交给自己的任务。

裸机与 RTOS 的两种运行方式

NO_SYS=1 的裸机模式没有 tcpip 线程,协议栈的定时器轮询和收包都要主循环驱动:

// 裸机主循环:两个调用缺一不可
while (1) {
    ethernetif_input(&g_netif);   // 把网卡收到的帧喂给协议栈
    sys_check_timeouts();         // 驱动 ARP/DHCP/TCP 重传等超时
    app_poll();
}

NO_SYS=0 时 lwIP 自带 tcpip 线程,网卡驱动只需要把收到的 pbuf 投递到邮箱,剩下的解析在线程里完成。这时 netif->input 要填 tcpip_input,而不是 ethernet_input——填错的结果是收包在中断上下文里被完整解析,进而调用线程 API,行为不可预期。

RTOS 适配层 sys_arch.c 需要提供信号量、互斥量、邮箱和线程创建:

#include "lwip/sys.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"

err_t sys_sem_new(sys_sem_t *sem, u8_t count)
{
    *sem = xSemaphoreCreateCounting(0xFFFF, (UBaseType_t)count);
    return (*sem == NULL) ? ERR_MEM : ERR_OK;
}

void sys_sem_free(sys_sem_t *sem)
{
    if (*sem != NULL) vSemaphoreDelete(*sem);
}

void sys_sem_signal(sys_sem_t *sem)
{
    xSemaphoreGive(*sem);
}

u32_t sys_arch_sem_wait(sys_sem_t *sem, u32_t timeout)
{
    TickType_t start = xTaskGetTickCount();
    TickType_t wait  = (timeout == 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout);
    if (xSemaphoreTake(*sem, wait) == pdTRUE) {
        return (u32_t)(xTaskGetTickCount() - start);
    }
    return SYS_ARCH_TIMEOUT;
}

int sys_mutex_new(sys_mutex_t *mutex)
{
    *mutex = xSemaphoreCreateMutex();
    return (*mutex == NULL) ? 1 : 0;      // lwIP 约定:返回 0 表示成功
}

void sys_mutex_lock(sys_mutex_t *mutex)   { xSemaphoreTake(*mutex, portMAX_DELAY); }
void sys_mutex_unlock(sys_mutex_t *mutex) { xSemaphoreGive(*mutex); }

err_t sys_mbox_new(sys_mbox_t *mbox, int size)
{
    *mbox = xQueueCreate((UBaseType_t)size, sizeof(void *));
    return (*mbox == NULL) ? ERR_MEM : ERR_OK;
}

void sys_mbox_post(sys_mbox_t *mbox, void *msg)
{
    while (xQueueSend(*mbox, &msg, portMAX_DELAY) != pdTRUE) { }
}

// 中断里投递用 trypost,内部走 FromISR 版本,不能阻塞
err_t sys_mbox_trypost_fromisr(sys_mbox_t *mbox, void *msg)
{
    BaseType_t hpw = pdFALSE;
    if (xQueueSendFromISR(*mbox, &msg, &hpw) == pdTRUE) {
        portYIELD_FROM_ISR(hpw);
        return ERR_OK;
    }
    return ERR_MEM;
}

还有两个容易漏掉的:sys_now() 必须返回毫秒计数值,lwIP 的所有超时都靠它;LWIP_RAND() 要有可用的随机源,否则 TCP 初始序号固定,在部分场景下会被对端拒绝。

u32_t sys_now(void)
{
    return xTaskGetTickCount() * portTICK_PERIOD_MS;
}

中断上下文这条边界要守住:以太网 RX 中断里只做一件事——把 pbuf 指针投递到邮箱,绝不调用 tcp_write、tcp_recved 这类协议栈 API。FreeRTOS 下对应的硬约束是,以太网中断的抢占优先级数值不能低于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,否则在中断里调 FromISR 版本的 API 会触发断言。

内存与缓冲的配置

lwIP 的内存分堆(MEM)和池(MEMP)两套,PBUF 又有自己的池。配置错了不会报编译错误,只会表现为跑一段时间后就收不到数据。

// lwipopts.h 关键项(RTOS + TCP 客户端场景)
#define NO_SYS                    0
#define LWIP_NETIF_LINK_CALLBACK  1
#define LWIP_TCP_KEEPALIVE        1

#define MEM_SIZE                  (16 * 1024)     // 协议栈堆:PCB、定时器、临时结构
#define MEMP_NUM_TCP_PCB          8               // 同时存在的 TCP 连接数上限
#define MEMP_NUM_PBUF             16              // 自定义/ROM 类型 pbuf 数量
#define PBUF_POOL_SIZE            16              // 收包池:突发流量下要够用
#define PBUF_POOL_BUFSIZE         1524            // 必须能装下完整以太网帧(1518 + 对齐)

#define TCP_MSS                   1460            // 默认 536,局域网下应放大
#define TCP_WND                   (8 * TCP_MSS)   // 接收窗口,受带宽时延积约束
#define TCP_SND_BUF               (8 * TCP_MSS)   // 发送缓冲
#define TCP_SND_QUEUELEN          (4 * TCP_SND_BUF / TCP_MSS)

#define TCPIP_THREAD_STACKSIZE    1024
#define TCPIP_THREAD_PRIO         (configMAX_PRIORITIES - 2)

#define CHECKSUM_GEN_IP           0               // 交给 MAC 硬件计算 IP 校验和
#define CHECKSUM_GEN_TCP          0
#define LWIP_STATS                1               // 排障期打开,可查内存池水位

几个数值之间的约束关系:

  • PBUF_POOL_BUFSIZE 小于一帧长度时,长帧会被丢弃或触发分片,局域网内表现为大包丢失、小包正常。
  • PBUF_POOL_SIZE 决定能同时缓存多少帧。突发流量下池耗尽,RX DMA 拿不到新缓冲,丢包从硬件层就开始了。
  • TCP_WND 决定吞吐上限。窗口必须覆盖带宽时延积,跨公网 RTT 30ms、目标 1MB/s 的场景,窗口至少要 30KB,16 位窗口字段不够用时要开 LWIP_WND_SCALE 启用窗口缩放。
  • MEMP_NUM_TCP_PCB 直接限制并发连接数,超出的连接在建链阶段就失败。

LWIP_STATS 打开后可以用 stats_display() 打印各池的高水位,调内存参数时这是唯一可靠的依据,靠猜很容易把堆调大但池还是不够。

网卡驱动与协议栈的对接

ethernetif.c 里三个函数是驱动与协议栈的全部接口:low_level_init 建 DMA 描述符环,low_level_output 把 pbuf 交给发送 DMA,ethernetif_input 把收到的帧转成 pbuf 投递。

static err_t low_level_output(struct netif *netif, struct pbuf *p)
{
    struct pbuf *q;
    uint8_t *buf = g_tx_buf;
    uint16_t total = 0;

    // pbuf 可能是链式的,需要拉平成连续缓冲后交给 DMA
    for (q = p; q != NULL; q = q->next) {
        memcpy(buf + total, q->payload, q->len);
        total += q->len;
    }

    // 带 D-Cache 的核(Cortex-M7)必须在 DMA 读之前清理缓存
    SCB_CleanDCache_by_Addr((uint32_t *)buf, total);

    if (HAL_ETH_Transmit(&g_eth, total) != HAL_OK) {
        return ERR_MEM;      // 描述符耗尽,上层会重传
    }
    return ERR_OK;
}

链式 pbuf 的拉平拷贝是吞吐瓶颈之一。追求性能时可以让 DMA 描述符直接指向 pbuf 的 payload(零拷贝),代价是发送路径要管理 pbuf 的释放时机,复杂度上升一个台阶。多数场景下一帧只有 1~2 个 pbuf,拷贝开销可以接受。

收包路径:

// 以太网中断服务程序:只搬指针,不解析
void ETH_IRQHandler(void)
{
    HAL_ETH_IRQHandler(&g_eth);
}

void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth)
{
    struct pbuf *p = NULL;
    // 从 RX 描述符取出一帧,包装成 PBUF_POOL 类型的 pbuf
    p = low_level_input(heth);
    if (p != NULL) {
        // 投递给 tcpip 线程处理,失败则必须自己释放,否则漏内存
        if (tcpip_input(p, &g_netif) != ERR_OK) {
            pbuf_free(p);
        }
    }
}

收包侧同样有缓存一致性问题:DMA 写完之后 CPU 读之前要使能失效(invalidate)对应缓存行,否则在 Cortex-M7 上会读到旧数据,现象是偶尔解出错误的 IP 头。把 DMA 描述符和缓冲放到 non-cacheable 的内存区(用 MPU 配置)是更省心的做法。

整条数据通路串起来就是:DMA 描述符 ↔ pbuf ↔ 协议栈。理解了这条链路上数据的流动方向,很多奇怪的丢包、缓存异常就能定位到具体环节。

TCP 客户端的连接与重连

长连接客户端要处理三件事:异步建链的结果确认、异常断开后的重连退避、链路假死时的探测。

#define NET_IDLE      0
#define NET_CONNECTING 1
#define NET_CONNECTED 2

typedef struct {
    struct tcp_pcb *pcb;
    uint8_t  state;
    uint8_t  pending_send;
    uint8_t  rx_buf[512];
    uint16_t rx_len;
} net_client_t;

static net_client_t g_client;

static err_t on_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err);
static err_t on_sent(void *arg, struct tcp_pcb *tpcb, u16_t len);
static void on_err(void *arg, err_t err);

static err_t on_connected(void *arg, struct tcp_pcb *tpcb, err_t err)
{
    net_client_t *c = (net_client_t *)arg;
    if (err == ERR_OK) {
        c->state = NET_CONNECTED;
        c->rx_len = 0;
    } else {
        c->state = NET_IDLE;      // 建链失败,交给重连任务处理
    }
    return ERR_OK;
}

void client_connect(net_client_t *c, ip_addr_t *server_ip, uint16_t port)
{
    c->pcb = tcp_new();
    if (c->pcb == NULL) return;

    ip_set_option(c->pcb, SOF_KEEPALIVE);   // 开启保活,需要 LWIP_TCP_KEEPALIVE
    c->pcb->keep_idle  = 30000;             // 空闲 30s 后开始探测,单位 ms
    c->pcb->keep_intvl = 5000;              // 探测间隔
    c->pcb->keep_cnt   = 3;                 // 连续 3 次无响应判定断开

    tcp_nagle_disable(c->pcb);              // 小包立即发,不等聚合
    tcp_arg(c->pcb, c);
    tcp_recv(c->pcb, on_recv);
    tcp_sent(c->pcb, on_sent);
    tcp_err(c->pcb, on_err);

    c->state = NET_CONNECTING;
    tcp_connect(c->pcb, server_ip, port, on_connected);
}

接收回调里有一行不能省:

static err_t on_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err)
{
    net_client_t *c = (net_client_t *)arg;

    if (p == NULL) {                 // p 为 NULL 表示对端关闭连接
        c->state = NET_IDLE;
        return ERR_OK;
    }

    for (struct pbuf *q = p; q != NULL; q = q->next) {
        frame_feed(c, (const uint8_t *)q->payload, q->len);
    }

    tcp_recved(tpcb, p->tot_len);    // 必须调用:否则接收窗口不滑动,对端会停发
    pbuf_free(p);
    return ERR_OK;
}

漏掉 tcp_recved() 的典型现象是“连得上、能发、收几次之后就再也收不到数据”。窗口耗尽后对端一直在等窗口更新,双方都不发,连接表现为假死,keepalive 也未必触发。

错误回调里 pcb 已经被协议栈释放,只能置标志:

static void on_err(void *arg, err_t err)
{
    net_client_t *c = (net_client_t *)arg;
    c->pcb = NULL;             // 不能再引用这个 pcb,lwIP 已释放
    c->state = NET_IDLE;
    (void)err;
}

重连放在自己的任务里做,带退避,避免网络故障时把 CPU 全耗在重试上:

void client_task(void *arg)
{
    ip_addr_t server;
    IP4_ADDR(&server, 192, 168, 1, 100);
    uint32_t backoff = 1000;

    while (1) {
        if (g_client.state == NET_IDLE) {
            client_connect(&g_client, &server, 1883);

            uint32_t waited = 0;
            while (g_client.state == NET_CONNECTING && waited < 5000) {
                vTaskDelay(pdMS_TO_TICKS(100));
                waited += 100;
            }

            if (g_client.state == NET_CONNECTED) {
                backoff = 1000;                                   // 连上就复位退避
            } else {
                g_client.state = NET_IDLE;
                vTaskDelay(pdMS_TO_TICKS(backoff));
                backoff = (backoff < 60000) ? backoff * 2 : 60000; // 指数退避,封顶 60s
            }
        }
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

建链与重连的时序大致是:发起 connect → 等待 on_connected 结果 → 失败则指数退避后重试,成功则进入连接态;链路异常时由 on_err 或 keepalive 探测触发状态回退,再回到同一套重连逻辑。

TCP 是字节流:分帧要在应用层做

一次 recv 回调拿到的数据不一定是完整一帧,也可能粘着两帧。在回调里假设“一次收到一帧”的代码,在小数据量时正常,数据量大或网络拥塞时就解出乱码。

// 帧格式:AA 55 | 长度(2字节,含头部) | 负载 | CRC16
#define FRAME_MAX    500
#define FRAME_HDR    4

static void frame_feed(net_client_t *c, const uint8_t *data, uint16_t len)
{
    for (uint16_t i = 0; i < len; i++) {
        if (c->rx_len < FRAME_HDR) {
            c->rx_buf[c->rx_len++] = data[i];
            if (c->rx_len == FRAME_HDR) {
                uint16_t flen = ((uint16_t)c->rx_buf[2] << 8) | c->rx_buf[3];
                // 帧头或长度非法,丢弃已收字节重新同步
                if (c->rx_buf[0] != 0xAA || c->rx_buf[1] != 0x55 || flen > FRAME_MAX) {
                    c->rx_len = 0;
                }
            }
            continue;
        }

        c->rx_buf[c->rx_len++] = data[i];
        uint16_t flen = ((uint16_t)c->rx_buf[2] << 8) | c->rx_buf[3];
        if (c->rx_len >= flen) {
            handle_frame(&c->rx_buf[FRAME_HDR], flen - FRAME_HDR);
            c->rx_len = 0;      // 处理完复位,继续下一帧
        }
    }
}

长度字段的整数上界一定要校验。缺少 flen > FRAME_MAX 这个判断,收到一段错误数据就会被撑爆缓冲,这是通信代码里最常见的安全漏洞。

发送路径与 ERR_MEM

发送前先查可用缓冲,写失败要区分是暂时拥塞还是连接故障:

int32_t client_send(net_client_t *c, const uint8_t *data, uint16_t len)
{
    if (c->pcb == NULL || c->state != NET_CONNECTED) return -1;

    if (tcp_sndbuf(c->pcb) < len) {
        c->pending_send = 1;     // 缓冲不足,等 on_sent 回调再续发,不要重连
        return -2;
    }

    // TCP_WRITE_FLAG_COPY:把数据拷进 pbuf,允许 data 是栈上缓冲
    err_t e = tcp_write(c->pcb, data, len, TCP_WRITE_FLAG_COPY);
    if (e != ERR_OK) return -3;

    e = tcp_output(c->pcb);      // 不带 TCP_WRITE_FLAG_MORE 时必须显式触发发送
    if (e != ERR_OK) return -4;
    return len;
}

static err_t on_sent(void *arg, struct tcp_pcb *tpcb, u16_t len)
{
    net_client_t *c = (net_client_t *)arg;
    (void)tpcb; (void)len;
    // 对端确认后释放了发送缓冲,此前积压的发送请求可以重试
    if (c->pending_send) {
        c->pending_send = 0;
        app_flush_queue(c);      // 业务层重新投递积压数据
    }
    return ERR_OK;
}

把 ERR_MEM 当成连接故障处理,会导致网络一拥塞就疯狂重连,重连又加剧拥塞,形成正反馈。它的正确语义是“现在发不出去,等会儿再试”。




上一篇:嵌入式 Web 服务器怎么选?Mongoose 实现设备配置页的完整链路
下一篇:Cortex-M 程序内存布局:数据段、链接脚本与启动代码详解
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-22 08:10 , Processed in 0.090899 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表