LwIP应用笔记(三):在RTOS环境下运行LwIP协议栈

欢迎来我的CSDN博客转转:https://blog.csdn.net/lczdk

前言

这篇文章是 LwIP应用笔记(二):无操作系统支持下的RAW API移植 的后续,以下所有内容都是建立在已经完成RAW API移植的前提下。本文可能不会太纠结于代码细节,因为本文的目标并不是演示移植过程中每一行代码该怎么写,而是希望在讲清大体框架的基础上,给出移植的主要流程,即在移植过程中,我们需要做什么事。

一、RTOS环境下的运行优势与劣势

在非RTOS环境下,用户程序是通过回调类接口,也就是RAW API与LwIP协议栈进行交互的。用户通过注册回调函数的方式告诉协议栈,当某些事件发生时需要做什么,可以理解为用户将自己所写的部分代码嵌入了协议栈的代码执行流程中,这样做会带来一个很大的问题,即用户注册的回调函数会影响协议栈的运行效率。设想这样一个场景,在非RTOS环境下,用户编写了若干个不同的网络功能模块,它们通过回调的方式与协议栈进行交互,现在假设用户注册了一个需要执行较长时间的回调函数,则每次协议栈执行到这个回调函数就都需要等待一个较长的时间去让回调执行完成,之后才能处理其他事务,注册这个回调函数的网络功能模块成功的以一已之力拖慢了整个网络协议栈以及其他网络功能模块的执行效率,不管其他网络功能模块写的多么高效,它们都必须受制于这个最慢的回调函数。所有发生的事情就如同下图所示。

在这里插入图片描述

同时我们也可以预见,随着网络功能模块注册的回调函数越来越多,协议栈需要耗费更多的时间去逐个处理回调函数。
解决上述问题的方法就是使用RTOS,通过将LwIP的协议栈与用户代码放置于不同的线程中执行,来规避用户代码拖慢协议栈执行的问题。用户代码和协议栈之间通过邮箱来进行数据传输。在RTOS环境下,LwIP提供NETCONN API以及类Socket API给用户使用,用户线程通过这两套API与协议栈线程进行交互。这样对于协议栈来说,它再也不用管何时去执行用户代码了,其只需要处理用户代码发送过来的数据,然后将需要用户代码处理的数据丢给对应的用户处理线程。如果很不幸有一个执行效率低下的用户线程,其可能会由于自身的低效丢失本该处理的数据,但是不会拖累其他的用户线程与协议栈本身的运行效率。用户代码和协议栈的关系此时如下所示。

在这里插入图片描述

但是我们也要注意到,在RTOS中用户程序和LwiP协议栈之间要借助邮箱等手段进行线程间交互,这会导致额外的系统开销以及一定的效率损失。在对性能有极高要求,同时需求比较简单的情况下,基于回调函数的RAW API接口可能会更加适用。

二、RTOS相关的配置项

需要修改用户配置文件lwipopts.h,添加一些操作系统相关的配置项,如下图所示。

在这里插入图片描述

需要重点关注操作系统相关注释下的配置项,如下几个配置项是需要特别关注的:

  • LWIP_COMPAT_MUTEX以及LWIP_CMPAT_MUTEX_ALLOWED,这两个配置使能后便可以在移植操作系统抽象层时不用移植互斥锁相关的接口,这时LwIP会借助二值信号量来代替互斥锁的功能,但是这样便没办法应对线程优先级翻转的问题了,所以需要谨慎考虑是否启用。这个功能,可能是为了照顾一些没有提供互斥锁接口的操作系统。
  • xxx_RECVMBOX_SIZE以及DEFAULT_ACCEPTMBOX_SIZE, 诸如这样的配置项是用于配置各个连接所使用的邮箱大小,邮箱接收的只是指向实际数据的指针地址,所以每项的大小为指针大小。这些项的值会在创建邮箱时传送给操作系统抽象层的邮箱创建API,如果不配置这些值的话,也可以在移植邮箱创建API时忽略传入的邮箱大小参数而使用固定的数值。

三、操作系统抽象层移植

由于LwIP并不知道自己将会运行于什么操作系统,故而为了提高可移植性,LwIP提供了操作系统抽象层,用于与操作系统的函数接口进行对接。总体而言,抽象层需要实现的函数接口分为以下几类:

  • 信号量类函数接口,此类函数接口用于提供信号量相关的操作接口;
  • 互斥锁类函数接口,此类函数接口用于提供互斥锁相关的操作接口,如果在前面配置时,设置了LWIP_COMPAT_MUTEX以及LWIP_CMPAT_MUTEX_ALLOWED为1,则这类函数接口则无需实现;
  • 邮箱类函数接口,此类函数接口用于提供邮箱相关的操作接口,如果操作系统不提供邮箱功能,可以考虑使用队列来实现;
  • 线程管理类函数接口,此类函数接口用于提供线程创建相关的操作接口;
  • 其他类函数接口,包括初始化操作系统抽象层以及获取当前的系统滴答计数时间;

下图为移植过程中需要移植的各类函数接口,函数的具体注释以及操作系统抽象层提供的所有函数接口可以参考LwIP内的sys.h文件,其中的注释详细说明了每个接口的功能以及移植的注意事项,在移植过程中需要频繁参考此文件。同时另一个很有参考价值的资料是官方基于doxygen生成的文档,其中有一节专门介绍操作系统抽象层的相关内容,官方链接如下:http://www.nongnu.org/lwip/2_1_x/groupsysos.html。

在这里插入图片描述

笔者是基于rtthread nano进行的操作系统抽象层移植,可以参考笔者开源仓库中的移植文件sys_arch.c以及sys_arch.h

四、操作系统下的LwIP执行方式

首先来回忆一下在裸机环境中我们是怎么运行LwIP协议栈的,初始化完成协议栈后,我们在主循环中一遍遍的查询网卡是否接收到报文,如果接收到报文就将报文传送给协议栈进行处理。同时我们还要定期执行负责处理超时事件的sys_check_timeouts函数。整个裸机环境下的执行流程如下所示:

在这里插入图片描述

在操作系统环境下,由于LwIP独自运行于一个线程中(线程名字由TCPIP_THREAD_NAME宏定义决定,这里为"tcpip_thread"),要将报文传送给LwIP就只能通过邮箱方式了,为此LwIP实现了tcpip_input函数。不同于netif_input函数直接将收到的报文丢入协议栈处理,tcpip_input函数会将收到的报文传入LwIP的报文接收邮箱等待LwIP线程读取并处理,在初始化流程中调用netif_add函数添加报文接收接口时,用这个函数代替ethernet_input函数传入即可。

如果追踪下LwIP内部的函数调用话,可以发现底层链路层为使用ARP协议的以太网情况下,最终处理报文的方法都是通过调用ethernet_input函数。不同之处在于,当处于裸机环境下,我们可以理解为以太网报文读取和报文处理都处于同一个线程下,函数调用为netif_input->ethernet_input;而实时操作系统下,负责以太网报文读取的线程调用流程为:tcpip_input->tcpip_inpkt->sys_mbox_trypost,到此报文最终被发送到了LwIP内部的tcpip_mbox邮箱(这个邮箱的长度就是由前文的TCPIP_MBOX_SIZE宏定义决定),LwIP线程会在tcpip_mbox邮箱中存在待处理报文时,调用ethernet_input处理这些报文。

这样一来,我们可以建立一个线程专门用于从网卡读取报文(称这个线程为"eth_recv"吧),然后在收到报文时调用eth0_input函数将报文从网卡中读出,然后最终通过tcpip_input函数发送到LwIP线程的接收邮箱。在编写eth_recv线程时,难道我们也要像裸机一样不停轮询是否有报文到来吗?这样未免有点浪费资源了,同时为了确保报文的及时接收,我们肯定会给这个线程分配比较高的优先等级,如果不停轮询一直占用cpu资源,其他低优先级线程便无法执行了。好点的方法是借助网卡芯片的中断硬件,当收到报文时触发外部中断,在中断中通过信号量通知eth_recv线程有报文到来,这时eth_recv线程便开始从网卡中读取报文并发送给LwIP所在线程,完成这一切后,eth_recv线挂起自己,等待下一次信号量的到来。这样就避免了轮询方式对CPU资源的过多占用。上面一堆文字,其实就是下面一张图所表达的。

在这里插入图片描述

具体完整的代码实现可以参考https://gitee.com/water_zhang/enc28j60_arduino_shield_board/blob/master/software/stm32f412g_discovery_board_lwip/Middlewares/lwip/lwip_user/lwip_user_init.c

以下对文件中的几个关键函数进行说明:

  • static void Lwip_User_IRQ_Callback(void *param)
    此函数对应上图中的网卡中断,会在网卡中断发生时被调用,唯一做的是就是释放信号量通知网卡数据包接收线程,网卡有事情发送,该干活了;
  • void Lwip_User_Process(void *param)
    此函数就是前面提到的网卡数据包接收线程eth_recv的执行函数,此线程会堵塞在信号量获取函数上,直到收到中断释放的信号量,收到信号量后先判断中断是否为接收到数据包导致(笔者使用的enc28j60的中断为多源中断,需要查询寄存器得知触发中断的原因),如果是数据包接收中断,则调用Lwip_User_PollEthernetPacket函数,此函数会读取网卡中的所有数据包并最终将这些数据包提交到LwIP线程的tcpip_mbox邮箱等待处理;
  • int Lwip_User_Init(void)
    此函数实现对整个协议栈的初始化,具体在后面的初始化流程一节说明;

五、初始化流程

裸机环境下的初始化流程稍微改动后就可以了,这里将两份代码做一下对比,并给出差异点,没什么好说的其实。为了方便对比差异,这里删去了一些帮助调试的相关语句,只保留有价值的内容。

在这里插入图片描述

六、后续可优化点

其实通过全文可以得知,我们并没有对网络发送部分进行改造,也就是说,此时底层网卡的发送部分还是和LwIP线程在同一个线程中执行的,后期可以考虑将发送部分也独立为单独线程,LwIP将需要发送的数据包通过邮箱给到发送线程,发送线程堵塞在邮箱上,有需要发送的数据包时进行发送。这部分的修改需要涉及到此文件:https://gitee.com/water_zhang/enc28j60_arduino_shield_board/blob/master/software/stm32f412g_discovery_board_lwip/Middlewares/lwip/port/eth/eth0.c