8、LwIP原理
1、 网卡接收数据的流程
网卡接收数据基本就是开发板上 eth 接收完数据后产生一个中断,然后释放一个信号量通知网卡接收线程去处理这些接收的数据,然后将数据这些数据封装成消息,投递到 tcpip_mbox 邮箱中, LwIP 内核线程得到这个消息,就对消息进行解析,根据消息中数据包类型进行处理,实际上是调用 ethernet_input()函数决定是否递交到 IP 层,如果是 ARP 包,内核就不会递交给 IP 层,而是更新 ARP 缓存表,对于IP 数据包则递交给 IP 层去处理,这就是一个数据从网卡到内核的过程。
STM32 通过 ETH 接口接收数据,并在产生 ETH 中断时释放信号量(s_xSemaphore),以通知网络接口任务( ethernetif_input)处理接收的数据。该任务将数据封装成消息并传递给tcpip_mbox 邮箱,再通过邮箱发送消息。 lwIP 内核中的协议栈线程负责接收 tcpip_mbox 邮箱的消息,并根据消息类型解析处理。处理前,它会判断消息类型,并根据类型执行相应的代码段。下图是网络接口接收数据示意图:

从这个图中,我们也能很明显看到,用户程序与内核是完全独立的,只是通操作系统的 IPC 通信机制进行数据交互。从上图可以看出, ethernetif_input 是一个任务函数,用于接收 ETH 中断释放的信号量。当接收到信号量时,它调用 low_level_input 函数获取描述符管理缓冲区的数据,并使用 tcp_input函数构建消息,再通过 tcpip_mbox 邮箱发送消息。在初始化时, lwIP 内核创建了 TCP/IP 线程,该线程负责接收 tcpip_mbox 邮箱的消息,并根据消息类型进行解析处理。在处理之前,它会判断消息类型,并根据类型执行相应的代码段。
2、内核超时处理
在 LwIP 中很多时候都要用到超时处理,例如 ARP 缓存表项的时间管理、 IP 分片数据报的重装等待超时、 TCP 中的建立连接超时、重传超时机制等, 因此超时处理的实现是TCP/IP 协议栈中一个重要部分, LwIP 为每个与外界网络连接的任务都有设定了 timeout 属性,即等待超时时间, 超时处理的相关代码实现在 timeouts.c 与 timeouts.h 中。
在旧版本的 LwIP 中(如 LwIP 1.4.1 版本) ,超时处理被称之为定时器(其实现在我们也能这样子称之为定时器,但是为了下文统一,我们一律使用超时处理) ,但是,在最新版的 LwIP 中,原来的 timer.c 已经被删除,转而使用了 timeouts.c 来代替,并且该源码在实现上也有一定的区别。
由于 LwIP 是软件,它自身并没有硬件定时器,更不会对硬件定时器进行管理,所以LwIP 作者就采用软件定时器对这些超时进行处理,因为软件定时器很容易维护,并且与平台无关,只需要用户提供一个较为准确的时基即可。
2.1、 sys_timeo 结构体与超时链表
LwIP 通过一个 sys_timeo 类型的数据结构管理与超时链表相关的所有超时事件。 LwIP使用这个结构体记录下内核中所有被注册的超时事件, 这些结构体会以链表的形式一个个连接在超时链表中, 而内核中只有一条超时链表,那么怎么对超时链表进行管理呢? LwIP定义了一个 sys_timeo 类型的指针 next_timeout,并且将 next_timeout 指向当前内核中链表头部,所有被注册的超时事件都会按照被处理的先后顺序排列在超时链表上。 sys_timeo 结构体与超时链表源码具见下面代码清单:
typedef void (* sys_timeout_handler)(void *arg); struct sys_timeo { struct sys_timeo *next; (1) u32_t time; (2) sys_timeout_handler h; (3) void *arg; (4) }; /** The one and only timeout list */ static struct sys_timeo *next_timeout; (5)
代码清单 9-1(1):指向下一个超时事件的指针,用于超时链表的连接。
代码清单 9-1(2):当前超时事件的等待时间。
代码清单 9-1(3):指向超时的回调函数,该事件超时后就执行对应的回调函数。
代码清单 9-1(4):向回调函数传入参数。
代码清单 9-1(5):指向超时链表第一个超时事件。
2.2 、注册超时事件
LwIP 虽然使用超时链表进行管理所有的超时事件,那么它首先需要知道有哪些超时事件才能去管理,而这些超时事件就是通过注册的方式被挂载在链表上,简单来说就是这些超时事件要在内核中登记一下,内核才会去处理, LwIP 中注册超时事件的函数是sys_timeout(),但是实际上是调用 sys_timeout_abs()函数。
2.3 、超时检查
有的同学可能会发现, 前面讲解的超时处理根本是不需要我们用户去考虑的, 为什么还有讲解呢, 其实不是这样子的, 学习讲究的是一个循序渐进的过程, 本书讲解的东西自然有其要讲解的道理, LwIP 实现了超时处理, 那么无论我们的开发平台是否使用操作系统,都可以对其进行超时检查并且去处理, lwip 中以下两个函数可以实现对超时的处理:
void sys_check_timeouts(void): 这是用于裸机的函数,用户需要在裸机应用程序中周期性调用该函数,每次调用的时候 LwIP 都会检查超时链表上第一个 sys_timeo 结构体是否到期,如果没有到期,直接退出该函数,否则,执行 sys_timeo 结构体中对应的超时回调函数,并从链表上删除它,然后继续检查下一个 sys_timeo 结构体,直到 sys_timeo 结构体没有超时才退出。
tcpip_timeouts_mbox_fetch(sys_mbox_t *mbox, void **msg): 这个函数在操作系统的线程中循环调用,主要是等待 tcpip_mbox 消息,是可阻塞的,如果在等待 tcpip_mbox 的过中发生超时事件,则会同时执行超时事件处理,即调用超时回调函数。 LwIP 是这样子处理的,如果已经发生超时, LwIP 就会内部调用 sys_check_timeouts()函数去检查超时的sys_timeo 结构体并调用其对应的回调函数, 如果没有发生超时,那就一直等待消息,其等待的时间为下一个超时时间的时间, 一举两得。 LwIP 中 tcpip 线程就是靠这种方法,即处理了上层及底层的 tcpip_mbox 消息,同时处理了所有需要超时处理的事件。
3、 tcpip_thread 线程
从前面的章节我们也知道, LwIP 在操作系统的环境下, LwIP 内核是作为操作系统的一个线程运行的,在协议栈初始化的时候就会创建 tcpip_thread 线程,那么我们现在来看看tcpip_thread 线程到底是怎么样的东西,具体见下面代码清单:
static void tcpip_thread(void *arg) { struct tcpip_msg *msg; LWIP_UNUSED_ARG(arg); LWIP_MARK_TCPIP_THREAD(); LOCK_TCPIP_CORE(); if (tcpip_init_done != NULL) { tcpip_init_done(tcpip_init_done_arg); } while (1) { LWIP_TCPIP_THREAD_ALIVE(); /* 等待消息,等待时处理超时 */ TCPIP_MBOX_FETCH(&tcpip_mbox, (void **)&msg); (1) if (msg == NULL) { continue; (2) } tcpip_thread_handle_msg(msg); (3) } }
代码清单 9-7(1): LwIP 将函数 tcpip_timeouts_mbox_fetch()定义为带参宏TCPIP_MBOX_FETCH,所以在这里就是等待消息并且处理超时事件。
代码清单 9-7(2):如果没有等到消息就继续等待。
代码清单 9-7(3):等待到消息就对消息进行处理,
4、 LwIP 中的消息
本小节主要讲解数据包消息与 API 消息,这最常用的两种消息类型,而且是 LwIP 中必须存在的消息,整个内核的运作都要依赖他们。
4.1 、消息结构
从前面的章节,我们知道消息有多种类型, LwIP 中消息是有多种结构的的, 对于不同的消息类型其封装是不一样的, tcpip_thread 线程是通过 tcpip_msg 描述消息的,tcpip_thread 线程接收到消息后,根据消息的类型进行不同的处理。 LwIP 中使用tcpip_msg_type 枚举类型定义了系统中可能出现的消息的类型,消息结构 msg 字段是一个共用体,其中定义了各种消息类型的具体内容,每种类型的消息对应了共用体中的一个字段,其中注册与删除事件的消息使用了同一个 tmo 字段。 LwIP 中的 API 相关的消息内容很多,不适合直接放在 tcpip_msg 中,所以 LwIP 用一个 api_msg 结构体来描述 API 消息,在tcpip_msg 中只存放指向 api_msg 结构体的指针,具体见下面代码清单:
enum tcpip_msg_type { TCPIP_MSG_API, TCPIP_MSG_API_CALL, //API 函数调用 TCPIP_MSG_INPKT, //底层数据包输入 TCPIP_MSG_TIMEOUT, //注册超时事件 TCPIP_MSG_UNTIMEOUT, //删除超时事件 TCPIP_MSG_CALLBACK, TCPIP_MSG_CALLBACK_STATIC //执行回调函数 }; struct tcpip_msg { enum tcpip_msg_type type; (1) union { struct { tcpip_callback_fn function; void* msg; } api_msg; (2) struct { tcpip_api_call_fn function; struct tcpip_api_call_data *arg; sys_sem_t *sem; } api_call; (3) struct { struct pbuf *p; struct netif *netif; netif_input_fn input_fn; } inp; (4) struct { tcpip_callback_fn function; void *ctx; } cb; (5) struct { u32_t msecs; sys_timeout_handler h; void *arg; } tmo; (6) } msg; }; 代码清单 9-9(1): 消息的类型,目前有 7 种。 代码清单 9-9(2): API 消息主要由两部分组成,一部分是用于表示内核执行的 API 函数,另一部分是执行函数时候的参数,都会被记录在 api_msg 中。 代码清单 9-9(3): 与 API 消息差不多,也是由两部分组成,一部分是 tcpip_api_call_fn类型的函数, 另一部分是其对应的形参, 此外还有用于同步的信号量。 代码清单 9-9(4): inp 用于记录数据包消息的内容, p 指向接收到的数据包; netif 表示接收到数据包的网卡; input_fn 表示输入的函数接口, 在 tcpip_inpkt 进行配置。 代码清单 9-9(5): cb 用于记录回调函数与其对应的形参。 代码清单 9-9(6): tmo 用于记录超时相关信息,如超时的时间,超时回调函数,参数等。
4.2 、数据包消息
对于每种类型的消息, LwIP 内核都必须有一个产生与之对应的消息函数,在产生该类型的消息后就将其投递到系统邮箱 tcpip_mbox 中,这样子 tcpip_thread 线程就会从邮箱中得到消息并且处理,从而能使内核完美运作,从图 9-1 中我们可以很直观看到对应数据包的消息, 是通过 tcpip_input()函数对消息进行构造并且投递的, 但是真正执行这些操作的函数是 tcpip_inpkt(), 其源码具体见下面代码清单:
err_t tcpip_input(struct pbuf *p, struct netif *inp) { if (inp->flags & (NETIF_FLAG_ETHARP | NETIF_FLAG_ETHERNET)) { return tcpip_inpkt(p, inp, ethernet_input); (1) } } err_t tcpip_inpkt(struct pbuf *p, struct netif *inp, netif_input_fn input_fn) { struct tcpip_msg *msg; LWIP_ASSERT("Invalid mbox", sys_mbox_valid_val(tcpip_mbox)); msg = (struct tcpip_msg *)memp_malloc(MEMP_TCPIP_MSG_INPKT); (2) if (msg == NULL) { return ERR_MEM; } msg->type = TCPIP_MSG_INPKT; msg->msg.inp.p = p; msg->msg.inp.netif = inp; msg->msg.inp.input_fn = input_fn; (3) if (sys_mbox_trypost(&tcpip_mbox, msg) != ERR_OK) (4) { memp_free(MEMP_TCPIP_MSG_INPKT, msg); (5) return ERR_MEM; } return ERR_OK; } 代码清单 9-10(1): 调用 tcpip_inpkt()函数将 ethernet_input()函数作为结构体的一部分传递给内核,然后内核接收到这个数据包就调用该函数。 代码清单 9-10(2): 申请存放消息的内存空间。 代码清单 9-10(3): 构造消息,消息的类型是数据包消息,初始化消息结构中 msg 共用体的 inp 字段, p 指向数据包,网卡就是对应的网卡,处理的函数就是我们熟悉的ethernet_input()函数。 代码清单 9-10(4): 构造消息完成,就调用 sys_mbox_trypost 进行投递消息, 这其实就是对操作系统的 API 简单封装, 如果投递成功则返回 ERR_OK。 代码清单 9-10(5): 如果投递失败,就释放对应的消息结构空间。
总的来说,万变不离其宗,无论是裸机编程还是操作系统,都是通过 ethernet_input()函数去处理接收到的数据包,只不过操作系统通过线程与线程间数据通信,使用了消息进行传递,这样子能使接收线程与内核线程互不干扰,相互独立开,在操作系统环境下,接收线程只负责接收数据包、构造消息并且完成投递消息即可,这样子处理完又能接收下一个数据包,这样子的效率更加高效,而内核根据这些消息做对应处理即可。
其运作示意图具体见图 9-3。

4.3 、API 消息
LwIP 使用 api_msg 结构体描述一个 API 消息的内容,具体见下面代码清单:
struct api_msg { struct netconn *conn; //当前连接 err_t err; //执行结果 union { struct netbuf *b; //执行 lwip_netconn_do_send 需要的参数,待发送数据 struct { u8_t proto; //执行 lwip_netconn_do_newconn 需要的参数,连接类型 } n; //执行 lwip_netconn_do_bind 和 lwip_netconn_do_connect 需要的参数 struct { API_MSG_M_DEF_C(ip_addr_t, ipaddr); //ip 地址 u16_t port; //端口号 u8_t if_idx; } bc; //执行 lwip_netconn_do_getaddr 需要的参数 struct { ip_addr_t API_MSG_M_DEF(ipaddr);//ip 地址 u16_t API_MSG_M_DEF(port); //端口号 u8_t local; } ad; //执行 lwip_netconn_do_write 需要的参数 struct { const struct netvector *vector; //要写入的当前向量 u16_t vector_cnt; //未写入的向量的数量 size_t vector_off; //偏移到当前向量 size_t len; //总长度 size_t offset; //偏移量 u8_t apiflags; } w; //执行 lwip_netconn_do_write 需要的参数 struct { size_t len; //长度 } r; } msg; };
api_msg 只包含 3 个字段,描述连接信息的 conn、内核返回的执行结果 err、还有 msg,msg 是一个共用体,根据不一样 的 API 接口使用不一样的数据结构。在 conn 中,它保存了当前连接的重要信息,如信号量、邮箱等, lwip_netconn_do_xxx(xxx 表示不一样的NETCONN API 接口) 类型的函数执行需要用这些信息来完成与应用线程的通信与同步;
内核执行 lwip_netconn_do_xxx 类型的函数返回结果会被记录在 err 中; msg 的各个产业记录各个函数执行时需要的详细参数。
我们了解底层的数据包消息,那么同理对于上层的 API 函数,想要与内核进行数据交互,也是通过 LwIP 的消息机制, API 消息由用户线程发出,与内核进行交互,因为用户的应用程序并不是与内核处于同一线程中, 简单来说就是用户使用 NETCON的时候, LwIP 会将对应 API 函数与参数构造成消息传递到 tcpip_thread 线程中,然后根据对应的 API 函数执行对应的操作, LwIP 这样子处理是为了简单用户的编程,这样子就不要求用户对内核很熟悉,与数据包消息类似, 也是有独立的 API 消息投递函数去处理,那就是netconn_apimsg()函数,在NETCONN API 中构造完成数据包,就会调用 netconn_apimsg()函数进行投递消息,具体见下面代码清单:
err_t netconn_bind(struct netconn *conn, const ip_addr_t *addr, u16_t port) { API_MSG_VAR_DECLARE(msg); err_t err; if (addr == NULL) { addr = IP4_ADDR_ANY; } API_MSG_VAR_ALLOC(msg); API_MSG_VAR_REF(msg).conn = conn; API_MSG_VAR_REF(msg).msg.bc.ipaddr = API_MSG_VAR_REF(addr); API_MSG_VAR_REF(msg).msg.bc.port = port; (1) err = netconn_apimsg(lwip_netconn_do_bind, &API_MSG_VAR_REF(msg)); (2) API_MSG_VAR_FREE(msg); return err; } static err_t netconn_apimsg(tcpip_callback_fn fn, struct api_msg *apimsg) { err_t err; err = tcpip_send_msg_wait_sem(fn, apimsg, LWIP_API_MSG_SEM(apimsg)); if (err == ERR_OK) { return apimsg->err; } return err; } err_t tcpip_send_msg_wait_sem(tcpip_callback_fn fn, void *apimsg, sys_sem_t *sem) { TCPIP_MSG_VAR_DECLARE(msg); TCPIP_MSG_VAR_ALLOC(msg); TCPIP_MSG_VAR_REF(msg).type = TCPIP_MSG_API; TCPIP_MSG_VAR_REF(msg).msg.api_msg.function = fn; TCPIP_MSG_VAR_REF(msg).msg.api_msg.msg = apimsg; (3) sys_mbox_post(&tcpip_mbox, &TCPIP_MSG_VAR_REF(msg)); (4) sys_arch_sem_wait(sem, 0); (5) TCPIP_MSG_VAR_FREE(msg); return ERR_OK; } 代码清单 9-12(1): 根据 netconn_bind()传递的参数初始化 api_msg 结构体。 代码清单 9-12(2): 调用 netconn_apimsg()函数投递这个 api_msg 结构体,这个函数实际上是调用 tcpip_send_msg_wait_sem()函数投递 API 消息的, 并且需要等待 tcpip_thread 线程的回应。 代码清单 9-12(3): 构造 API 消息,类型为 TCPIP_MSG_API,函数为 API 对应的函数lwip_netconn_do_bind,将 msg 的指针指向 api_msg 结构体。 代码清单 9-12(4): 调用 sys_mbox_post()函数向内核进行投递消息。 代码清单 9-12(5): 同时调用 sys_arch_sem_wait()函数等待消息处理完毕
总的来说,用户的应用线程与内核也是相互独立的,依赖操作系统的 ICP 通信机制进行数据交互与同步(邮箱、信号量等), LwIP 提供上层 NETCONN API 接口,会自动帮我们处理这些事情,只需要我们根据 API 接口传递正确的参数接口,当然, NETCONN API的使用我们会在后面的章节具体介绍,此处仅做了解一下即可,只是为了让大家对 LwIP整个内核的运作有个详细的了解,其运作示意图具体见图 9-4。

其实这个运作示意图并不是最优的,这种运作的方式在每次发送数据的时候,会进行一次线程的调度,这无疑是增大了系统的开销,而将 LWIP_TCPIP_CORE_LOCKING 宏定义设置为 1 则无需操作系统邮箱与信号量的参与,直接在用户线程中通过回调函数调用对应的处理,当然在这个过程中,内核线程是无法获得互斥量而运行的,因为是通过互斥量进行保护用户线程的处理, 当然, LwIP 的作者也是这样子建议的。

浙公网安备 33010602011771号