您现在的位置是:首页 >学无止境 >lwIP更新记07:TCP 控制块申请失败可以检测到了网站首页学无止境

lwIP更新记07:TCP 控制块申请失败可以检测到了

研究是为了理解 2024-06-24 00:01:02
简介lwIP更新记07:TCP 控制块申请失败可以检测到了

从 lwIP-2.0.0 开始,TCP 控制块申请失败可以检测到了。

这个更新应用在 TCP 服务器模式中,处于监听状态的 TCP_PCB ,如果收到客户端发送的 SYN 同步标志,表示一个客户端在请求建立连接了。lwIP 会为这个新连接申请一个 TCP_PCB ,这一过程在 tcp_listen_input 函数中完成的。然而 TCP_PCB 的个数是有限的,如果申请失败,对于失败的处理, lwIP-2.0.0 及以上版本与 lwIP-1.4.1不同。

lwIP-1.4.1 失败的毫无声息,而 lwIP-2.0.0 提供了检测手段。

先看 lwIP 1.4.1 的代码(经简化):

static err_t tcp_listen_input(struct tcp_pcb_listen *pcb)
{
  	// 通过一系列检查 没有错误  	
    npcb = tcp_alloc(pcb->prio);	// 申请新的 TCP_PCB 
    if (npcb == NULL) {				// 内存错误处理
      LWIP_DEBUGF(TCP_DEBUG, ("tcp_listen_input: could not allocate PCB
"));
      return ERR_MEM;
    }
    // 申请成功,初始化新申请的pcb
    // 发送 ACK|SYN 标志
  	return ERR_OK;
}

可以看到, lwIP 1.4.1 版本 tcp_listen_input 函数具有返回值,如果申请 TCP_PCB 失败,则返回 ERR_MEM 错误码。

哎~~,这不是有检测手段吗?怎么说失败的毫无声息?
且压一压疑惑,我们稍微观察下可以知道,这个函数使用关键字 static 修饰,这意味着它仅供模块内部使用,用户层代码是无权使用的。我们看看 lwIP 是怎么使用它的。 tcp_listen_input 函数会被 tcp_input 函数调用,调用代码简化为:

void tcp_input(struct pbuf *p, struct netif *inp)
{
	// 通过一系列检测,报文是合法的
	// 通过一系列操作, 在 tcp_listen_pcbs 中查找到了控制块
	tcp_listen_input(lpcb);		// <------ 这里 
    pbuf_free(p);
    return;
}

嗯~~ 压根没有用到返回值!!
所以对于 lwIP 1.4.1 版本,当 TCP 控制块申请失败时,服务器不会有任何响应,编程人员也根本没有途径得知这一信息,所以说失败的毫无声息。

2014 年 12 月 02,Joel Cunningham 提交了一个 BUG 报告,指出目前 lwIP-1.4.1 版本中,由于 TCP 控制块耗尽而导致不能接收新的连接时,用户层得不到该信息。Joel 的依据是 Open Group Base Specifications (开放基金基本规范)中关于函数 accept 的描述:接收新连过程失败时返回错误消息。

从应用程序的角度来看,如果无法分配 TCP 控制块,这是应该处理的一种错误状态。否则,当因为申请 TCP 控制块失败而连接不上服务器时,开发人员可能需要大量的调试才能发现问题的原因,因为 lwIP-1.4.1 对这种情况没有提供任何检测点。

2015 年 2 月,lwIP 开发人员 Simon Goldschmidt 接受了 Joel 的提议。

2016 年 3 月 24, Simon Goldschmidt 将修改的代码提交到了 lwIP 代码仓库,然后,在 lwIP-2.0.0 版本中,我们看到了这些更改(经简化):

static void tcp_listen_input(struct tcp_pcb_listen *pcb)
{
  	// 通过一系列检查 没有错误  	
    npcb = tcp_alloc(pcb->prio);	// 申请新的 TCP_PCB 
    if (npcb == NULL) {				// 内存错误处理
      LWIP_DEBUGF(TCP_DEBUG, ("tcp_listen_input: could not allocate PCB
"));
      TCP_EVENT_ACCEPT(pcb, NULL, pcb->callback_arg, ERR_MEM, err);  	//<----- 这里
      return;
    }
    // 申请成功,初始化新申请的pcb
    // 发送 ACK|SYN 标志
  	return;
}

区别很明显,首先 tcp_listen_input 函数不具有返回值(返回类型为 void ),其次,lwIP 处理内存错误是通过宏 TCP_EVENT_ACCEPT(pcb, NULL, pcb->callback_arg, ERR_MEM, err) 调用 accept 回调函数来实现的。宏展开代码(简化后)如下所示,注意第二个参数为 NULL

if(pcb->accept != NULL)
	pcb->accept(pcb->callback_arg, NULL, ERR_MEM);

在分配 TCP 控制块失败时,使用 ERR_MEM 参数调用 accept 回调函数,以通知应用程序有关此错误!

这是一个值得特别重视的更改,因为它改变了 accept 回调函数的处理逻辑:应用程序必须在 accept 回调中处理 pcb 句柄为 NULL 的情况

lwIP-1.4.1 版本的 accept 回调函数可以这么写:

/* 客户端连接时, 回调此函数 */
static err_t telnet_accept(void *arg, struct tcp_pcb *pcb, err_t err)
{
    char * p_link_info = "已连接到Telnet!
";

    tcp_recv(pcb, telnet_recv);
    tcp_err(pcb, NULL);
    pcb->so_options |= SOF_KEEPALIVE;  //增加保活机制
    
    tcp_write(pcb, p_link_info, strlen(p_link_info), TCP_WRITE_FLAG_COPY);
    return ERR_OK;
}

而 lwIP-2.0.0 及以上版本的 accept 回调函数需要这么写:

/* 客户端连接时, 回调此函数 */
static err_t telnet_accept(void *arg, struct tcp_pcb *pcb, err_t err)
{
    char * p_link_info = "已连接到Telnet!
";
    
    if(pcb == NULL)
    {
    	if(err == ERR_MEM)
    		// 处理 TCP 连接个数不足,可选
        return ERR_OK;
    }
    
    tcp_recv(pcb, telnet_recv);
    tcp_err(pcb, NULL);
    pcb->so_options |= SOF_KEEPALIVE;  //增加保活机制
    
    tcp_write(pcb, p_link_info, strlen(p_link_info), TCP_WRITE_FLAG_COPY);
    return ERR_OK;
}

这里对 pcb 句柄是否为 NULL 做了处理,如果检测到 NULLaccpet 回调函数需要提前退出

不这样写会有什么后果?

比如我将 lwIP-1.4.1 升级到了 lwIP 2.1.3,但是我忘记更改 accept 回调函数了,那么绝大部分情况,不会出现什么问题,然后某一天程序突然死机了(假设没有开启看门狗,PS:我的规定是开发期间禁止开启看门狗)。出现死机的原因是那天有很多个客户端连接服务器,出现 TCP 控制块申请失败,协议栈给 accept 回调函数传递了 pcb 为 NULL 的参数,回调函数直接使用该参数调用 tcp_recvtcp_write 等函数,由于此时 pcb 为 NULL,导致内存越界,触发内存 Fault。Fault 在记录错误信息后会进入死循环,表现为程序死机。

使用 C 语言编程时,检查指针是否为空是一个良好的习惯。虽然之前你确切的知道,accept 回调函数的 pcb 参数决不会为 NULL,但这里有一个没有明说的前提,前提是在你使用的当前版本上,这个断言才正确!
虽然我比较反感 NULL 这个概念,但在使用别人提供的代码上,我们不能因为武断而省略对 NULL 的检查,说不定哪天代码逻辑就更改了,就像今天讲的这个例子一样。

另外的话题是,绝不隐藏错误。lwIP-1.4.1 实际上隐藏了 TCP 控制块申请失败的错误信息,这会导致开发人员在出现这个问题时摸不着头脑,哪里出错了?一眼看不出来!

我们很多时候都会不经意间隐藏错误:比如某个参数超出不可能的范围,我们会为它指定一个默认值,然后程序继续执行。这被称为防御性编程。防御性编程不可缺少,但我们有没有想过,这样的代码可能让我放过一个重大错误,参数为什么会超出合理范围?这个问题被毫无声息的掩盖掉了。

我的理念是:有错就死给你看。所以我规定开发期间禁止开启看门狗,因为看门狗可能隐藏错误,它会在很短的时间重启设备,让我们看不到错误已经发生。另外,LOG 记录也能替代禁止看门狗,而我两种方式都要。不用担心出现下发给生产的二进制文件没有开启看门狗的问题,因为下发生产的二进制文件是通过自动化脚本产生的,脚本会编译出开启看门狗的二进制文件。

找错、防错、纠错,然后到自动发现错误,再然后是测试驱动编程,目标是无错,这是我十多年来的开发进化过程。






读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)
千金难买知识,但可以买好多奶粉

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。