Toggle navigation
首页
问答
文章
积分商城
专家
专区
更多专区...
文档中心
返回主站
搜索
提问
会员
中心
登录
注册
LWIP
内存泄漏leak
网络编程
【网络编程开发】一种网络编程中的另类内存泄漏
发布于 2022-02-28 00:16:34 浏览:1286
订阅该版
[tocm] # 1 写在前面 最近我在排查一个网络通讯的**压测**问题,最后发现跟**“内存泄漏”**扯上了关系,但这跟常规理解的内存泄漏有那么一点点不同,本文将带你了解问题的始与末。 面对这样的内存泄漏问题,本文也提供了一些常规的分析方法和解决思路,仅供大家参考,欢迎大家指正问题。 # 2 问题描述 我们直接看下测试提供的issue描述: ![image-20220227235914352](https://oss-club.rt-thread.org/uploads/20220714/e99aee64835f14f48a894a2e8cc10ca90f953424.png) 简单来说,就是设备再执行【断网掉线-》重新联网在线】若干次之后,发现无法再次成功联网,且一直无法成功,直到设备重启后,恢复正常。 # 3 场景复现 ## 3.1 搭建压测环境 由于测试部有专门的测试环境,但是我又不想整他们那一套,麻烦着,还得整一个测试手机。 他们的测试方法是使用手机热点做AP,然后设备连接这个AP,之后在手机跑脚本动态开关Wi-Fi热点,达到让设备**掉网再恢复网络**的测试目的。 有了这个思路后,我想着我手上正好有一个 **360Wi-Fi**(***此处无广告费***),不就恰好可以实现无线热点吗?只要能实现在PC上动态切换这个360Wi-Fi热点开关,不就可以实现一样的测试目的吗? 具备以上物理条件之后,我开始找寻找这样的脚本。 要说在Linux下,写个这样的脚本,真不是啥难事,不过,要是在Windows下写个BAT脚本,还真找找才知道。 费了一会劲,在网上找到了一个还算不错的BAT脚本,经过我修改后,长以下这样,主要的功能就是定时开关网络适配器。 ```shell @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脚本的编码问题,否则会出现识别不到正确的网络适配器名称。 ![image-20220228220658303](https://oss-club.rt-thread.org/uploads/20220714/4058680ae9c22dd3a76eccd231da55ba615df34e.png) ![image-20220228220637441](https://oss-club.rt-thread.org/uploads/20220714/cf5f35a744f116207163d4b087e659b586277f01.png) ## 3.2 压测问题说明 同时,为了精准定位掉网恢复的问题,我在网络掉线重连的地方增加了三个变量,分别记录总的重连次数、重连成功的次数、重连失败的次数。 另一方面,如issue描述所说,这是一个固定次数强相关的问题,也可能跟运行时长联系紧密的一个问题,且重启之后一切恢复正常,这一系列的特征,都把问题导向一个很常见的问题:**内存泄漏**。 于是,在压测前,我在每次重连之后(不管成功与否)重新打印了系统的内存情况(总剩余内存,历史最低剩余内存),以便于判断问题节点的内存情况。 通过调整压测脚本中的**disable_interval_time和enable_interval_time**参数,在比较短的时间内就复现了问题,的确如果issue描述那样,在30多次之后,无法重连成功,且重启即可恢复。 # 4 问题分析 大部分的问题,只要有复现路劲,都还比较好查,只不过需要花点时间,专研下。 ## 4.1 简单分析 首先肯定是我们怀疑最大可能的**内存泄漏**信息,初步一看: ![image-20220228222007710](https://oss-club.rt-thread.org/uploads/20220714/3dd32277cb688880bf97f790a155da72f880e199.png) 由于在断网重连的操作中,可能对应的时间点下Wi-Fi热点还处于关闭状态,所以肯定是会重连失败的,当出现Wi-Fi热点的时候是可以成功的,所以我们会看到free空闲的内存在一个范围内波动,并没有看到它有稳定下降的趋势。 倒是和这个evmin(最低空闲内存)值,在出现问题之后,它出现了一个固定值,并一直持续下去,从这一点上怀疑,这个内存肯定是有问题的,只不过我在第一次分析这个情况的时候并没有下这个结论,现在回过头来看这是一个警惕信号。 我当时推测的点(想要验证的点)是,出现问题的时候,是不是因为内存泄漏导致系统空闲内存不足了,进而无法完成新的连接热点,连接网络等耗内存操作。 所以,通过上面的内存表,我基本笃定了我的结论:**没有明显的内存泄漏迹象,并不是因内存不足而重连不上**。 问题分析到这里,肯定不能停下来,但是原厂的SDK,比如连热点那块的逻辑,对我们来说是个黑盒子,只能从原厂那里咨询看能不能取得什么有效的信息。 一圈问下来,拿到的有效信息基本是0,所以自己的问题还得靠自己! ## 4.2 寻找突破口 在上面的问题场景中,我们已排除掉了**内存不足**的可能性,那么接下来我们重点应分析三个方面: - 设备最后有没有成功连上Wi-Fi热点?能够正常分配子网的IP地址? - 设备成功连上Wi-Fi热点后,对外的网络是否正常? - 设备对外网络正常,为何不能成功回连服务器? 这三个问题是一个递进关系,一环扣一环! 我们先看第一个问题,很明显,当复现问题的时候,我们可以从PC的Wi-Fi热点那里看到所连过来的设备,且看到了分配的子网IP地址。 接下来看第二个问题,这个问题测试也很简单,因为我们的命令行中集成了ping命令,输入ping命令一看,居然发现了一个重要信息: ```shell # ping www.baidu.com ping_Command ping IP address:www.baidu.com ping: create socket failed ``` 正常的ping log长这样: ```shell # 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函数的实现: ```c /** * 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**宏定义的实现: ```c 宏定义替换 #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 lwip_close函数的实现如下: ```c 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: ```c /** 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 为何在这里会讨论这个知识点,那是因为这个知识点是解决整个问题的关键。 具体他们的区别与联系是怎么样的,我这里不做过多阐述,感兴趣的可以自行[去学习](https://blog.csdn.net/u011391629/article/details/71939248)。 这里就直接把结论摆出来: - **close把描述符的引用计数减1,仅在该计数变为0时关闭套接字。shutdown可以不管引用计数就激发TCP的正常连接终止序列。** - **close终止读和写两个方向的数据发送。TCP是全双工的,有时候需要告知对方已经完成了数据传送,即使对方仍有数据要发送给我们。** - **shutdown与socket描述符没有关系,即使调用shutdown(fd, SHUT_RDWR)也不会关闭fd,最终还需close(fd)。** ## 4.4 深入分析 了解了lwip组件中对socket句柄的创建和关闭,我们再回到复现问题的本身。 从最细微的log我们知道问题出在无法分配新的socket具备,我们再看下那个分配socket的逻辑中,有一个判断条件: ```c 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的申请和释放: ![image-20220301001352782](https://oss-club.rt-thread.org/uploads/20220714/7d14f08b2344b81305815eefceac6b6ba7672e93.png) ![image-20220301001444091](https://oss-club.rt-thread.org/uploads/20220714/7c76a5d6c12173b6553f7e1a50db37cc0644918b.png) 为了比较好描述原厂所做的优化,我把其添加的代码做了少量修改,大致就加了几个宏定义,这几个宏定义看其注释应该是为了处理**多任务**下新建、关闭socket的同步问题。 ```c #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链路挂断的时候,是这么玩的: ```c /* * 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函数的逻辑: ![image-20220301002943192](https://oss-club.rt-thread.org/uploads/20220714/e3a5239739430b7e4b1703678540c410f47907e7.png) 于是就出现了之前的问题,socket句柄的index一直在上升,应该旧的scoket句柄一直**被占用**,知道句柄数被耗尽。 最大句柄数NUM_SOCKETS究竟是多少,可以参考之前我的文章将如何看预编译的代码,我们可以清晰地看到他的值就是**38**。 ![image-20220531140012176](https://oss-club.rt-thread.org/uploads/20220714/f3af17c956b2c477c6b69c613ad6619f9eafdaa4.png) 所有的疑惑均打开,为了一定是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源码](http://download.savannah.gnu.org/releases/lwip/lwip-2.0.2.zip) - [TCP编程接口:close函数和shutdown函数](https://blog.csdn.net/u011391629/article/details/71939248) - [优雅关闭TCP链路](https://www.cnblogs.com/wangshaowei/p/11068494.html) # 9 更多分享 欢迎关注我的[github仓库01workstation](https://github.com/recan-li/01workstation),日常分享一些开发笔记和项目实战,欢迎指正问题。 同时也非常欢迎关注我的CSDN主页和专栏: [【CSDN主页:架构师李肯】](http://yyds.recan-li.cn) [【RT-Thread主页:架构师李肯】](https://club.rt-thread.org/u/18001) [【C/C++语言编程专栏】](https://blog.csdn.net/szullc/category_8450784.html) [【GCC专栏】](https://blog.csdn.net/szullc/category_8626555.html) [【信息安全专栏】](https://blog.csdn.net/szullc/category_8452787.html) [【RT-Thread开发笔记】](https://blog.csdn.net/szullc/category_11461616.html) [【freeRTOS开发笔记】](https://blog.csdn.net/szullc/category_11467856.html) [【BLE蓝牙开发笔记】](https://blog.csdn.net/szullc/category_11615545.html) [【ARM开发笔记】](https://blog.csdn.net/szullc/category_11575847.html) [【RISC-V开发笔记】](https://blog.csdn.net/szullc/category_11494874.html) 有问题的话,可以跟我讨论,知无不答,谢谢大家。
3
条评论
默认排序
按发布时间排序
登录
注册新账号
关于作者
李肯陪你玩赚嵌入式
2022年度和2023年度RT-Thread社区优秀开源布道师,COC深圳城市开发者社区主理人,专注于嵌入式物联网的架构设计
文章
47
回答
504
被采纳
82
关注TA
发私信
相关文章
1
RT-THREAD在STM32H747平台上移植lwip
2
{lwip}使能RT_LWIP_DHCP时可以获取到ip
3
stm32f103 LWIP 2.0.2 TCP收发问题
4
lwip2.1不重启修改IP
5
关于网络协议栈的测试
6
可否将LWIP升级到2.1.2 和 2.0.3?
7
socket(AF_INET, SOCK_RAW, IPPROTO_UDP);
8
tcpclient 插拔网线问题?
9
两个tcpclient同时通讯可以吗?
10
SO_BINDTODEVICE 未定义该如何解决
推荐文章
1
RT-Thread应用项目汇总
2
玩转RT-Thread系列教程
3
国产MCU移植系列教程汇总,欢迎查看!
4
机器人操作系统 (ROS2) 和 RT-Thread 通信
5
五分钟玩转RT-Thread新社区
6
【技术三千问】之《玩转ART-Pi》,看这篇就够了!干货汇总
7
关于STM32H7开发板上使用SDIO接口驱动SD卡挂载文件系统的问题总结
8
STM32的“GPU”——DMA2D实例详解
9
RT-Thread隐藏的宝藏之completion
10
【ART-PI】RT-Thread 开启RTC 与 Alarm组件
热门标签
RT-Thread Studio
串口
Env
LWIP
SPI
AT
Bootloader
Hardfault
CAN总线
FinSH
ART-Pi
USB
DMA
文件系统
RT-Thread
SCons
RT-Thread Nano
线程
MQTT
STM32
RTC
FAL
rt-smart
ESP8266
I2C_IIC
UART
WIZnet_W5500
ota在线升级
freemodbus
PWM
flash
cubemx
packages_软件包
BSP
潘多拉开发板_Pandora
定时器
ADC
flashDB
GD32
socket
中断
编译报错
Debug
SFUD
rt_mq_消息队列_msg_queue
msh
keil_MDK
ulog
C++_cpp
MicroPython
本月问答贡献
a1012112796
10
个答案
1
次被采纳
踩姑娘的小蘑菇
4
个答案
1
次被采纳
红枫
4
个答案
1
次被采纳
张世争
4
个答案
1
次被采纳
Ryan_CW
4
个答案
1
次被采纳
本月文章贡献
catcatbing
3
篇文章
5
次点赞
YZRD
2
篇文章
5
次点赞
qq1078249029
2
篇文章
2
次点赞
xnosky
2
篇文章
1
次点赞
Woshizhapuren
1
篇文章
5
次点赞
回到
顶部
发布
问题
投诉
建议
回到
底部