开启生长之旅!这是我参与「日新计划 12 月更文挑战」的第11天,点击检查活动概况


作者简介

架构师李肯全网同名),一个专心于嵌入式IoT范畴的架构师。有着近10年的嵌入式一线开发经历,深耕IoT范畴多年,熟知IoT范畴的业务发展,深度掌握IoT范畴的相关技能栈,包含但不限于干流RTOS内核的完成及其移植、硬件驱动移植开发、网络通讯协议开发、编译构建原理及其完成、底层汇编及编译原理、编译优化及代码重构、干流IoT云渠道的对接、嵌入式IoT体系的架构规划等等。拥有多项IoT范畴的发明专利,热衷于技能共享,有多年撰写技能博客的经历堆集,接连多月获得RT-Thread官方技能社区原创技能博文优秀奖,荣获CSDN博客专家、CSDN物联网范畴优质创作者、2021年度CSDN&RT-Thread技能社区之星、2022年RT-Thread全球技能大会讲师、RT-Thread官方嵌入式开源社区认证专家、RT-Thread 2021年度论坛之星TOP4、华为云云享专家(嵌入式物联网架构规划师)等荣誉。深信【常识改动命运,技能改动世界】!


1 写在前面

最近我在排查一个网络通讯的压测问题,终究发现跟 “内存走漏” 扯上了联络,但这跟惯例了解的内存走漏有那么一点点不同,本文将带你了解问题的始与末。

面临这样的内存走漏问题,本文也提供了一些惯例的剖析办法和处理思路,仅供咱们参阅,欢迎咱们纠正问题。

2 问题描绘

咱们直接看下测验提供的issue描绘:

【网络编程开发系列】一种网络编程中的另类内存泄漏

简略来说,便是设备再执行【断网掉线-》重新联网在线】若干次之后,发现无法再次成功联网,且一向无法成功,直到设备重启后,康复正常。

3 场景复现

3.1 建立压测环境

因为测验部有专门的测验环境,可是我又不想整他们那一套,费事着,还得整一个测验手机。

他们的测验办法是运用手机热门做AP,然后设备衔接这个AP,之后在手机跑脚本动态开关Wi-Fi热门,达到让设备掉网再康复网络的测验目的。

有了这个思路后,我想着我手上正好有一个 360Wi-Fi此处无广告费),不就恰好能够完成无线热门吗?只需能完成在PC上动态切换这个360Wi-Fi热门开关,不就能够完成一样的测验目的吗?

具有以上物理条件之后,我开端找寻找这样的脚本。

要说在Linux下,写个这样的脚本,真不是啥难事,不过,要是在Windows下写个BAT脚本,还真找找才知道。

费了一会劲,在网上找到了一个还算不错的BAT脚本,经过我修正后,长以下这样,主要的功用便是定时开关网络适配器。

@echo off
​
:: Config your interval time (seconds)
set disable_interval_time=5
set enable_interval_time=15
​
:: Config your loop times: enable->disable->enable->disable...
set loop_time=10000
​
:: Config your network adapter list
SET adapter_num=1
SET adapter[0].name=WLAN
::SET adapter[0].name=屑薪鈺犘も晲协
::SET adapter[1].name=屑薪鈺犘も晲协 2
​
:::::::::::::::::::::::::::::::::::::::::::::::::::::::
​
echo Loop to switch network adapter state with interval time %interval_time% seconds
​
set loop_index=0
​
:LoopStart
​
if %loop_index% EQU %loop_time% goto :LoopStop
​
:: Set enable or disable operation
set /A cnt=%loop_index% + 1
set /A result=cnt%%2
if %result% equ 0 (
set operation=enabled
set interval_time=%enable_interval_time%
) else (
set operation=disable
set interval_time=%disable_interval_time%
)
echo [%date:~0,10% %time:~0,2%:%time:~3,2%:%time:~6,2%] loop time ... %cnt% ... %operation%
​
set adapter_index=0
:AdapterStart
if %adapter_index% EQU %adapter_num% goto :AdapterStop
set adapter_cur.name=0
​
for /F "usebackq delims==. tokens=1-3" %%I in (`set adapter[%adapter_index%]`) do (
    set adapter_cur.%%J=%%K
)
​
:: swtich adapter state
call:adapter_switch "%adapter_cur.name%" %operation%
​
set /A adapter_index=%adapter_index% + 1
​
goto AdapterStart
​
:AdapterStop
​
set /A loop_index=%loop_index% + 1
​
echo [%date:~0,10% %time:~0,2%:%time:~3,2%:%time:~6,2%] sleep some time (%interval_time% seconds) ...
ping -n %interval_time% 127.0.0.1 > nul
​
goto LoopStart
​
:LoopStop
​
echo End of loop ...
​
pause
goto:eof
​
:: function definition
:adapter_switch
set cmd=netsh interface set interface %1 %2
echo %cmd%
%cmd%
goto:eof

注意:这个当地填的是发射AP热门的网络适配器,比方如下的。假如是中文的名称,还必须注意BAT脚本的编码问题,否则会呈现识别不到正确的网络适配器名称。

【网络编程开发系列】一种网络编程中的另类内存泄漏

【网络编程开发系列】一种网络编程中的另类内存泄漏

3.2 压测问题说明

一起,为了精准定位掉网康复的问题,我在网络掉线重连的当地增加了三个变量,别离记录总的重连次数、重连成功的次数、重连失利的次数。

另一方面,如issue描绘所说,这是一个固定次数强相关的问题,也或许跟运转时长联络紧密的一个问题,且重启之后全部康复正常,这一系列的特征,都把问题导向一个很常见的问题:内存走漏

所以,在压测前,我在每次重连之后(不管成功与否)重新打印了体系的内存情况(总剩下内存,前史最低剩下内存),以便于判断问题节点的内存情况。

经过调整压测脚本中的disable_interval_time和enable_interval_time参数,在比较短的时间内就复现了问题,确实假如issue描绘那样,在30屡次之后,无法重连成功,且重启即可康复。

4 问题剖析

大部分的问题,只需有复现路劲,都还比较好查,只不过需求花点时间,专研下。

4.1 简略剖析

首先必定是咱们置疑最大或许的内存走漏信息,开始一看:

【网络编程开发系列】一种网络编程中的另类内存泄漏

因为在断网重连的操作中,或许对应的时间点下Wi-Fi热门还处于封闭状态,所以必定是会重连失利的,当呈现Wi-Fi热门的时分是能够成功的,所以咱们会看到free闲暇的内存在一个范围内动摇,并没有看到它有安稳下降的趋势。

倒是和这个evmin(最低闲暇内存)值,在呈现问题之后,它呈现了一个固定值,并一向继续下去,从这一点上置疑,这个内存必定是有问题的,只不过我在第一次剖析这个情况的时分并没有下这个定论,现在回过头来看这是一个警惕信号。

我当时推测的点(想要验证的点)是,呈现问题的时分,是不是因为内存走漏导致体系闲暇内存不足了,然后无法完成新的衔接热门,衔接网络等耗内存操作。

所以,经过上面的内存表,我基本笃定了我的定论:没有明显的内存走漏痕迹,并不是因内存不足而重连不上

问题剖析到这儿,必定不能停下来,可是原厂的SDK,比方连热门那块的逻辑,对咱们来说是个黑盒子,只能从原厂那里咨询看能不能取得什么有用的信息。

一圈问下来,拿到的有用信息基本是0,所以自己的问题还得靠自己!

4.2 寻找突破口

在上面的问题场景中,咱们已排除掉了内存不足的或许性,那么接下来咱们要点应剖析三个方面:

  • 设备终究有没有成功连上Wi-Fi热门?能够正常分配子网的IP地址?
  • 设备成功连上Wi-Fi热门后,对外的网络是否正常?
  • 设备对外网络正常,为何不能成功回连服务器?

这三个问题是一个递进联络,一环扣一环!

咱们先看第一个问题,很明显,当复现问题的时分,咱们能够从PC的Wi-Fi热门那里看到所连过来的设备,且看到了分配的子网IP地址。

接下来看第二个问题,这个问题测验也很简略,因为咱们的命令行中集成了ping命令,输入ping命令一看,居然发现了一个重要信息:

# ping www.baidu.com
ping_Command
ping IP address:www.baidu.com
ping: create socket failed

正常的ping log长这样:

# ping www.baidu.com
ping_Command
ping IP address:www.baidu.com
60 bytes from 14.215.177.39 icmp_seq=0 ttl=53 time=40 ticks
60 bytes from 14.215.177.39 icmp_seq=1 ttl=53 time=118 ticks
60 bytes from 14.215.177.39 icmp_seq=2 ttl=53 time=68 ticks
60 bytes from 14.215.177.39 icmp_seq=3 ttl=53 time=56 ticks

WC!ping: create socket failed 这还创立socket失利了!!!?

我第一时间置疑是不是lwip组件出问题了?

第二个置疑:莫非socket句柄不够了?因此创立内存大部分的操作便是在申请socket内存资源,并没有进行其他什么高级操作。

这么一想,第二个或许性就非常大,结合前面的总总痕迹,是个需求要点排查的目标。

4.3 常识点补缺

在精确定位问题之前,咱们先帮相关的常识点补充完好,方便后续的常识铺开讲解。

4.3.1 lwip的socket句柄

  • socket具有的创立

    socket函数调用的路劲如下:

    socket -> lwip_socket -> alloc_socket

    alloc_socket函数的完成:

    /**
     * Allocate a new socket for a given netconn.
     *
     * @param newconn the netconn for which to allocate a socket
     * @param accepted 1 if socket has been created by accept(),
     *         0 if socket has been created by socket()
     * @return the index of the new socket; -1 on error
     */
    static int
    alloc_socket(struct netconn *newconn, int accepted)
    {
     int i;
     SYS_ARCH_DECL_PROTECT(lev);
    ​
     /* allocate a new socket identifier */
     for (i = 0; i < NUM_SOCKETS; ++i) {
      /* Protect socket array */
      SYS_ARCH_PROTECT(lev);
      if (!sockets[i].conn && (sockets[i].select_waiting == 0)) {
       sockets[i].conn    = newconn;
       /* The socket is not yet known to anyone, so no need to protect
         after having marked it as used. */
       SYS_ARCH_UNPROTECT(lev);
       sockets[i].lastdata  = NULL;
       sockets[i].lastoffset = 0;
       sockets[i].rcvevent  = 0;
       /* TCP sendbuf is empty, but the socket is not yet writable until connected
        * (unless it has been created by accept()). */
       sockets[i].sendevent = (NETCONNTYPE_GROUP(newconn->type) == NETCONN_TCP ? (accepted != 0) : 1);
       sockets[i].errevent  = 0;
       sockets[i].err    = 0;
         SOC_INIT_SYNC(&sockets[i]);
       return i + LWIP_SOCKET_OFFSET;
       }
      SYS_ARCH_UNPROTECT(lev);
      }
     return -1;
    }
    

    咱们注意到,上述函数中的for循环有一个宏 NUM_SOCKETS,这个宏的详细数值是可适配的,不同的渠道可根据自己的实践运用情况和内存情况,选择一个合适的数值。

    咱们看下这个NUM_SOCKETS宏界说的完成:

    宏界说替换
    #define NUM_SOCKETS MEMP_NUM_NETCONN
    ​
    在lwipopts.h中找到了其终究的替换
    /**
     * MEMP_NUM_NETCONN: the number of struct netconns.
     * (only needed if you use the sequential API, like api_lib.c)
     *
     * This number corresponds to the maximum number of active sockets at any
     * given point in time. This number must be sum of max. TCP sockets, max. TCP
     * sockets used for listening, and max. number of UDP sockets
     */
    #define MEMP_NUM_NETCONN    (MAX_SOCKETS_TCP + \
        MAX_LISTENING_SOCKETS_TCP + MAX_SOCKETS_UDP)
    

    看着这,有点绕,终究这个值是多少啊?

  • socket句柄的毁掉

    具有的毁掉,咱们都知道运用close接口,它的函数调用路径如下:

    close -> lwip_close -> free_socket

lwip_close函数的完成如下:

int
lwip_close(int s)
{
 struct lwip_sock *sock;
 int is_tcp = 0;
 err_t err;
​
 LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_close(%d)\n", s));
​
 sock = get_socket(s);
 if (!sock) {
  return -1;
  }
 SOCK_DEINIT_SYNC(1, sock);
​
 if (sock->conn != NULL) {
  is_tcp = NETCONNTYPE_GROUP(netconn_type(sock->conn)) == NETCONN_TCP;
  } else {
  LWIP_ASSERT("sock->lastdata == NULL", sock->lastdata == NULL);
  }
​
#if LWIP_IGMP
 /* drop all possibly joined IGMP memberships */
 lwip_socket_drop_registered_memberships(s);
#endif /* LWIP_IGMP */
​
 err = netconn_delete(sock->conn);
 if (err != ERR_OK) {
  sock_set_errno(sock, err_to_errno(err));
  return -1;
  }
​
 free_socket(sock, is_tcp);
 set_errno(0);
 return 0;
}

这儿调用到了free_socket:

/** Free a socket. The socket's netconn must have been
 * delete before!
 *
 * @param sock the socket to free
 * @param is_tcp != 0 for TCP sockets, used to free lastdata
 */
static void
free_socket(struct lwip_sock *sock, int is_tcp)
{
 void *lastdata;
​
 lastdata     = sock->lastdata;
 sock->lastdata  = NULL;
 sock->lastoffset = 0;
 sock->err    = 0;
​
 /* Protect socket array */
 SYS_ARCH_SET(sock->conn, NULL);
 /* don't use 'sock' after this line, as another task might have allocated it */if (lastdata != NULL) {
  if (is_tcp) {
   pbuf_free((struct pbuf *)lastdata);
   } else {
   netbuf_delete((struct netbuf *)lastdata);
   }
  }
}

这个SYS_ARCH_SET(sock->conn, NULL);就会开释对应的socket句柄,然后保证socket句柄可循环运用。

4.3.2 TCP网络编程中的close和shutdown

为何在这儿会评论这个常识点,那是因为这个常识点是处理整个问题的要害。

详细他们的差异与联络是怎么样的,我这儿不做过多阐述,感兴趣的能够自行去学习。

这儿就直接把定论摆出来:

  • close把描绘符的引证计数减1,仅在该计数变为0时封闭套接字。shutdown能够不管引证计数就激发TCP的正常衔接停止序列。
  • close停止读和写两个方向的数据发送。TCP是全双工的,有时分需求奉告对方现已完成了数据传送,即便对方仍有数据要发送给咱们。
  • shutdown与socket描绘符没有联络,即便调用shutdown(fd, SHUT_RDWR)也不会封闭fd,终究还需close(fd)。

4.4 深入剖析

了解了lwip组件中对socket句柄的创立和封闭,咱们再回到复现问题的自身。

从最细微的log咱们知道问题出在无法分配新的socket具有,咱们再看下那个分配socket的逻辑中,有一个判断条件:

if (!sockets[i].conn && (sockets[i].select_waiting == 0)) {
   //分配新的句柄编号
   sockets[i].conn    = newconn;
   。。。
}

经过增加log,咱们知道select_waiting的值是为0的,那么问题就出在conn不为NULL上面了。

在lwip_close中是有对.conn进行赋值NULL的,所以就猜测莫非 lwip_close没调用?进行导致句柄没彻底开释?

答复这个问题,又需求回到咱们的软件架构上了,在完成架构了,咱们不同的芯片渠道运用了不同版别的lwip组件,而上层跑的MQTT协议是共用的,也便是假如是上层逻辑中没有正确处理close逻辑,那么这个问题应该在所有的渠道都会呈现,但为何唯一只要这个渠道才出问题呢。

答案只要一个,问题或许出在lwip完成这一层。

因为lwip是原厂去适配,我第一时间找了原生的lwip-2.0.2版别做了下比照,主要想知道原厂适配的时分,做了哪些优化和调整。

成果一比照,公然发现了问题。

咱们就以出问题的sockets.c为例,咱们要点重视socket的申请和开释:

【网络编程开发系列】一种网络编程中的另类内存泄漏

【网络编程开发系列】一种网络编程中的另类内存泄漏

为了比较好描绘原厂所做的优化,我把其增加的代码做了少量修正,大致就加了几个宏界说,这几个宏界说看其注释应该是为了处理多任务下新建、封闭socket的同步问题。

#define SOC_INIT_SYNC(sock) do { something ... } while(0)
#define SOC_DEINIT_SYNC(sock) do { SOCK_CHECK_NOT_CLOSING(sock); something ... } while(0)
#define SOCK_CHECK_NOT_CLOSING(sock) do { \
		if ((sock)->closing) { \
			SOCK_DEBUG(1, "SOCK_CHECK_NOT_CLOSING:[%d]\n", (sock)->closing); \
			return -1; \
		} \
	} while (0)

只是跟了一下它的逻辑,上层调用lwip_close的时分会调用到SOC_DEINIT_SYNC,一起它会调用到SOCK_CHECK_NOT_CLOSING,然后完毕整一个socket开释的全流程。

可是偏偏咱们做的MQTT上层在调用TCP链路挂断的时分,是这么玩的:

/*
 * Gracefully close the connection
 */
void mbedtls_net_free( mbedtls_net_context *ctx )
{
    if( ctx->fd == -1 )
        return;
    shutdown( ctx->fd, 2 );
    close( ctx->fd );
    ctx->fd = -1;
}

高雅地封闭TCP链路,这时分你应该要想起4.3.2章节的常识点。

这样调用对那几个宏会有影响?

答案是必定的。

本来的,原厂适配时lwip_shutdown也相同调用了SOC_DEINIT_SYNC,这就导致了假如上层封闭链路既调用shutdown又调用close的话,它的逻辑就会出问题,会引发close的流程走不完好。

为了能够简化这个问题,我大概写了一下它的逻辑:

1)shutdown函数调过来的时分,开端发动封闭流程SOC_DEINIT_SYNC,进入到那几个宏里边,会有一步:(sock)->closing = 1;然后正常回来0;

2)等到close函数调过来的时分,再次进入封闭流程SOC_DEINIT_SYNC,成果一判断(sock)->closing现已是1了,然后报错回来-1;这样close的回来就不正常了;

3)再看lwip_close函数的逻辑:

【网络编程开发系列】一种网络编程中的另类内存泄漏

所以就呈现了之前的问题,socket句柄的index一向在上升,应该旧的scoket句柄一向被占用,知道句柄数被耗尽。

最大句柄数NUM_SOCKETS终究是多少,能够参阅之前我的文章将怎么看预编译的代码,咱们能够清晰地看到他的值便是38

【网络编程开发系列】一种网络编程中的另类内存泄漏

所有的疑问均翻开,为了一定是30屡次之后才出问题,这儿给出了答案!

这儿我大胆地猜测了一下,应该原厂在适配这段同步操作逻辑的时分,压根就没考虑上层还能够先shutdown再close,所以引发了这个问题。

5 问题修正

上面的剖析中,现已开始定位了问题代码,接下来便是要进行问题修正了。

问题根源出在先调shutdown再调close,因为是一个上层代码,其他渠道也是共用的,且其他渠道运用并没有问题,所以必定不能把上层高雅封闭TCP链路的操作给去掉,只能底层的lwip组件自行优化处理。所谓是:谁惹的祸,谁来擦屁股!

处理问题的要害是,要保证调完shutdown之后,close那次操作需求走一个完好流程,这样才能把占用的socket句柄给开释掉。

所以在执行shutdown和close的时分,SOC_DEINIT_SYNC需求带个参数奉告是不是close操作,假如不是close那么就走一个简易流程,这样就能保证close流程是完好的。

当上层只调用close,也能保证close的流程是完好的。

可是,入股上层先调用close,再调shutdown,这样流程就不通了。

当然,上层也不能这么玩,详细参阅4.3.2的常识点。

6 问题验证

问题修正之后,需求进行相同的流程复测,以保证这个问题确实被修正了。

问题验证也很简略,修正sockets.c中的NUM_SOCKETS,改成一个很小的值,比方3或5,加速问题复现的速度,一起把alloc_socket中获取的句柄id打出来,调查它有没有上升,正常的测验中,在没有其他网络通讯链路的情况下,它应该安稳值为0。

很快就能够验证,不会再复现这个问题了。

接下来,需求将NUM_SOCKETS的值复原成原理的值,实在测验原本复现的场景,保证真的只要这个当地引发了这个问题,而其他代码并没有干扰到。

走运的是,复原之后的测验也经过了,这就证明了这个问题彻底修正了,且没有带来副作用,是一次成功的bug修正。

7 经历总结

  • 内存走漏的花样很多,但一定要注意其本质特色;
  • socket句柄走漏,也是内存走漏的一种;
  • 每一种优化都有它特定的场景,脱离了这个特定场景,你需求重新考虑这个优化的普适性;
  • 增强对要害log信息的敏感度,有利于在茫茫问题中找到排查的方向灯;
  • 精确了解TCP编程接口中的close函数和shutdown函数,能对处理掉网问题有所协助;
  • 上线前的压力测验,必不可少。

8 参阅链接

  • lwip-v2.0.2源码
  • TCP编程接口:close函数和shutdown函数
  • 高雅封闭TCP链路

9 更多共享

欢迎重视我的github库房01workstation,日常共享一些开发笔记和项目实战,欢迎纠正问题。

一起也非常欢迎重视我的CSDN主页和专栏:

【CSDN主页:架构师李肯】

【RT-Thread主页:架构师李肯】

【C/C++语言编程专栏】

【GCC专栏】

【信息安全专栏】

【RT-Thread开发笔记】

【freeRTOS开发笔记】

【BLE蓝牙开发笔记】

【ARM开发笔记】

【RISC-V开发笔记】

有问题的话,能够跟我评论,知无不答,谢谢咱们。