从 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\n"));
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\n"));
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!\r\n";
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!\r\n";
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
做了处理,如果检测到 NULL
,accpet
回调函数需要提前退出!
不这样写会有什么后果?
比如我将 lwIP-1.4.1 升级到了 lwIP 2.1.3,但是我忘记更改 accept
回调函数了,那么绝大部分情况,不会出现什么问题,然后某一天程序突然死机了(假设没有开启看门狗,PS:我的规定是开发期间禁止开启看门狗)。出现死机的原因是那天有很多个客户端连接服务器,出现 TCP 控制块申请失败,协议栈给 accept
回调函数传递了 pcb 为 NULL
的参数,回调函数直接使用该参数调用 tcp_recv
、tcp_write
等函数,由于此时 pcb 为 NULL
,导致内存越界,触发内存 Fault。Fault 在记录错误信息后会进入死循环,表现为程序死机。
使用 C 语言编程时,检查指针是否为空是一个良好的习惯。虽然之前你确切的知道,accept
回调函数的 pcb 参数决不会为 NULL
,但这里有一个没有明说的前提,前提是在你使用的当前版本上,这个断言才正确!
虽然我比较反感 NULL
这个概念,但在使用别人提供的代码上,我们不能因为武断而省略对 NULL
的检查,说不定哪天代码逻辑就更改了,就像今天讲的这个例子一样。
另外的话题是,绝不隐藏错误。lwIP-1.4.1 实际上隐藏了 TCP 控制块申请失败的错误信息,这会导致开发人员在出现这个问题时摸不着头脑,哪里出错了?一眼看不出来!
我们很多时候都会不经意间隐藏错误:比如某个参数超出不可能的范围,我们会为它指定一个默认值,然后程序继续执行。这被称为防御性编程。防御性编程不可缺少,但我们有没有想过,这样的代码可能让我放过一个重大错误,参数为什么会超出合理范围?这个问题被毫无声息的掩盖掉了。
我的理念是:有错就死给你看。所以我规定开发期间禁止开启看门狗,因为看门狗可能隐藏错误,它会在很短的时间重启设备,让我们看不到错误已经发生。另外,LOG 记录也能替代禁止看门狗,而我两种方式都要。不用担心出现下发给生产的二进制文件没有开启看门狗的问题,因为下发生产的二进制文件是通过自动化脚本产生的,脚本会编译出开启看门狗的二进制文件。
找错、防错、纠错,然后到自动发现错误,再然后是测试驱动编程,目标是无错,这是我十多年来的开发进化过程。
读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)