网线插上后 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 当成连接故障处理,会导致网络一拥塞就疯狂重连,重连又加剧拥塞,形成正反馈。它的正确语义是“现在发不出去,等会儿再试”。