lwIP

[English]

ESP-IDF 使用开源的 lwIP 轻量级 TCP/IP 协议栈,该版 lwIP (esp-lwip) 相对上游项目做了修改和增补。

支持的 API

ESP-IDF 支持以下 lwIP TCP/IP 协议栈功能:

适配的 API

警告

在使用除 BSD 套接字 API 外的任意 lwIP API 时,请确保所用 API 为线程安全。请启用 CONFIG_LWIP_CHECK_THREAD_SAFETY 配置选项并运行应用程序,检查所用 API 是否线程安全。此时,lwIP 断言 TCP/IP 核心功能可以正确访问。如果未能从正确的 lwIP FreeRTOS 任务 访问,或没有正确锁定,则执行中止。建议使用 ESP-NETIF 组件与 lwIP 交互。

ESP-IDF 间接支持以下常见的 lwIP 应用程序 API:

  • 动态主机设置协议 (DHCP) 服务器和客户端,由 ESP-NETIF 功能间接支持。

  • 域名系统 (DNS);获取 DHCP 地址时,可以自动分配 DNS 服务器,也可以通过 ESP-NETIF API 手动配置。

备注

lwIP 中的 DNS 服务器配置为全局配置,而非针对特定接口的配置。如需同时使用不同 DNS 服务器的多个网络接口,在从一个接口获取 DHCP 租约时,请注意避免意外覆盖另一个接口的 DNS 设置。

  • 简单网络时间协议 (SNTP),由 ESP-NETIF 功能间接支持,或通过 lwip/include/apps/esp_sntp.h 中的函数直接支持。该函数还为 lwip/lwip/src/include/lwip/apps/sntp.h 函数提供了线程安全的 API,请参阅 SNTP 时间同步。有关详细信息,请见 protocols/sntp。该示例演示了如何使用 LwIP SNTP 模块从互联网服务器获取时间、配置同步方法与时间间隔,并使用 SNTP-over-DHCP 模块检索时间。

  • ICMP Ping,由 lwIP ping API 的变体支持,请参阅 ICMP 回显

  • ICMPv6 Ping,由 lwIP 的 ICMPv6 Echo API 支持,用于测试 IPv6 网络连接情况。有关详细信息,请参阅 protocols/sockets/icmpv6_ping。该示例演示了如何使用网络接口发现 IPv6 地址,创建原始 ICMPv6 套接字,向目标 IPv6 地址发送 ICMPv6 Echo 请求,并等待目标返回 Echo 回复。

  • NetBIOS 查找,由标准的 lwIP API 支持,protocols/http_server/restful_server 示例中提供了使用 NetBIOS 在局域网中查找主机的选项。

  • mDNS 与 lwIP 的默认 mDNS 使用不同实现方式,请参阅 mDNS 服务。但启用 CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES 设置项后,lwIP 可以使用 gethostbyname() 等标准 API 和 hostname.local 约定查找 mDNS 主机。

  • lwIP 中的 PPP 实现可用于在 ESP-IDF 中创建 PPPoS(串行 PPP)接口。请参阅 ESP-NETIF 组件文档,使用 esp_netif/include/esp_netif_defaults.h 中定义的 ESP_NETIF_DEFAULT_PPP() 宏创建并配置 PPP 网络接口。esp_netif/include/esp_netif_ppp.h 中提供了其他的运行时设置。PPPoS 接口通常用于与 NBIoT/GSM/LTE 调制解调器交互。esp_modem 仓库还支持更多应用层友好的 API,该仓库内部使用了上述 PPP lwIP 模块。

BSD 套接字 API

BSD 套接字 API 是一种常见的跨平台 TCP/IP 套接字 API,最初源于 UNIX 操作系统的伯克利标准发行版,现已标准化为 POSIX 规范的一部分。BSD 套接字有时也称 POSIX 套接字,或伯克利套接字。

在 ESP-IDF 中,lwIP 支持 BSD 套接字 API 的所有常见用法。然而,并非所有操作都完全线程安全,因此多个线程同时进行读写可能需要额外的同步机制。详情请参见 限制

参考

BSD 套接字的相关参考资料十分丰富,包括但不限于:

示例

以下为 ESP-IDF 中使用 BSD 套接字 API 的部分示例:

支持的函数

在 ESP-IDF 中,lwIP 支持以下 BSD 套接字 API 函数,详情请参阅 lwip/lwip/src/include/lwip/sockets.h

  • socket()

  • bind()

  • accept()

  • shutdown()

  • getpeername()

  • getsockopt()setsockopt():请参阅 套接字选项

  • close():通过 虚拟文件系统组件 调用

  • read()readv()write()writev():通过 虚拟文件系统组件 调用

  • recv()recvmsg()recvfrom()

  • send()sendmsg()sendto()

  • select():通过 虚拟文件系统组件 调用

  • poll():ESP-IDF 通过在内部调用 select() 实现 poll(),因此,建议直接调用 select()

  • fcntl():请参阅 fcntl()

非标准函数:

备注

部分 lwIP 应用程序示例代码使用了带前缀的 BSD API,如 lwip_socket(),而非标准 socket()。ESP-IDF 支持使用以上两种形式,但更建议使用标准名称。

套接字错误处理

要使套接字应用程序保持稳定,BSD 套接字错误处理代码至关重要。套接字错误处理通常涉及以下几个方面:

  • 错误检测

  • 获取错误原因代码

  • 根据错误原因代码处理错误

在 lwIP 中,处理套接字错误分以下两种情况:

  • 套接字 API 返回错误,请参阅 套接字 API 错误

  • select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout) 包含异常描述符,表示套接字出现错误,详情请参阅 select() 错误

套接字 API 错误

错误检测

  • 根据返回值判断套接字 API 是否出错。

获取错误原因代码

  • 套接字 API 出错时,其返回值不包含失败原因,可以通过应用程序访问 errno 获取错误原因代码。不同返回值具有不同含义,详情请参阅 套接字错误原因代码

示例:

int err;
int sockfd;

if (sockfd = socket(AF_INET,SOCK_STREAM,0) < 0) {
    //  errno 获取错误代码
    err = errno;
    return err;
}

select() 错误

错误检测

  • select() 包含异常描述符时的套接字错误。

获取错误原因代码

  • 如果 select() 报告套接字错误,访问 errno 无法获取错误原因代码,此时,应调用 getsockopt()。因为当 select() 包含异常描述符时,错误代码不会直接赋值给 errno

备注

getsockopt() 函数具有以下原型:int getsockopt(int s, int level, int optname, void *optval, socklen_t *optlen)。原型可以获取任意类型、任意状态套接字选项的当前值,并将结果存储在 optval 中。例如,要在套接字上获取错误代码,可以通过 getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &optlen) 实现。

示例:

int err;

if (select(sockfd + 1, NULL, NULL, &exfds, &tval) <= 0) {
    err = errno;
    return err;
} else {
    if (FD_ISSET(sockfd, &exfds)) {
        // 使用 getsockopt() 获取 select() 异常集
        int optlen = sizeof(int);
        getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &optlen);
        return err;
    }
}

套接字错误原因代码

以下是常见错误代码列表。有关标准 POSIX/C 错误代码的详细列表,请参阅 newlib errno.h 和特定平台扩展 newlib/platform_include/errno.h

错误代码

描述

ECONNREFUSED

拒绝连接

EADDRINUSE

地址已在使用中

ECONNABORTED

软件导致连接中断

ENETUNREACH

网络不可达

ENETDOWN

未配置网络接口

ETIMEDOUT

连接超时

EHOSTDOWN

主机已关闭

EHOSTUNREACH

主机不可达

EINPROGRESS

连接已在进行中

EALREADY

套接字已连接

EDESTADDRREQ

需要目标地址

EPROTONOSUPPORT

未知协议

套接字选项

getsockopt() 支持获取套接字选项,setsockopt() 支持设置套接字选项。

在 ESP-IDF 中,lwIP 并不支持所有标准套接字选项。以下套接字选项受 lwIP 支持:

常见选项

与级别参数 SOL_SOCKET 一起使用。

IP 选项

与级别参数 IPPROTO_IP 一起使用。

对于组播 UDP 套接字:

  • IP_MULTICAST_IF

  • IP_MULTICAST_LOOP

  • IP_MULTICAST_TTL

  • IP_ADD_MEMBERSHIP

  • IP_DROP_MEMBERSHIP

TCP 选项

只适用于 TCP 套接字,与级别参数 IPPROTO_TCP 一起使用。

  • TCP_NODELAY

与 TCP 保活探测相关的选项:

  • TCP_KEEPALIVE:整数值,以毫秒为单位,设置 TCP 保活探测周期

  • TCP_KEEPIDLE:整数值,以秒为单位,与 TCP_KEEPALIVE 相同

  • TCP_KEEPINTVL:整数值,以秒为单位,设置保活探测间隔

  • TCP_KEEPCNT:整数值,设置超时前进行的保活探测次数

IPv6 选项

只适用于 IPv6 套接字,与级别参数 IPPROTO_IPV6 一起使用。

  • IPV6_CHECKSUM

  • IPV6_V6ONLY

对于组播 IPv6 UDP 套接字:

  • IPV6_JOIN_GROUP / IPV6_ADD_MEMBERSHIP

  • IPV6_LEAVE_GROUP / IPV6_DROP_MEMBERSHIP

  • IPV6_MULTICAST_IF

  • IPV6_MULTICAST_HOPS

  • IPV6_MULTICAST_LOOP

fcntl()

fcntl() 函数是设置与文件描述符相关选项的标准 API。在 ESP-IDF 中,使用 虚拟文件系统组件 层实现该函数。

当文件描述符为套接字时,仅支持以下 fcntl() 值:

  • O_NONBLOCK 用于置位或清除非阻塞 I/O 模式。O_NDELAY 也受支持,与前者功能相同。

  • O_RDONLYO_WRONLYO_RDWR 标志用于不同的读或写模式,只能用 F_GETFL 读取,且无法用 F_SETFL 设置。根据连接状况,即两端开启或任一端关闭,TCP 套接字会返回不同模式,而 UDP 套接字始终返回 O_RDWR

ioctl()

ioctl() 函数以半标准的方式访问 TCP/IP 协议栈的部分内部功能。ESP-IDF 通过 虚拟文件系统组件 层实现此函数。

当文件描述符为套接字时,仅支持以下 ioctl() 值:

  • FIONREAD 返回套接字网络 buffer 中接收的待处理字节数。

  • FIONBIOfcntl(fd, F_SETFL, O_NONBLOCK, ...) 相同,也可置位或清除套接字非阻塞 I/O 状态。

Netconn API

lwIP 支持两种较低级别的 API 和 BSD 套接字 API,即 Netconn API 和 Raw API。

lwIP Raw API 适用于单线程设备,无法在 ESP-IDF 中使用。

Netconn API 用于在 lwIP 内部使用 BSD 套接字 API,支持直接从 ESP-IDF 的应用程序调用。相较于 BSD 套接字 API,该 API 占用资源更少。无需提前将数据复制到内部 lwIP buffer,即可使用 Netconn API 发送和接收数据。

重要

乐鑫尚未在 ESP-IDF 中测试 Netconn API,因此 此功能已启用,但尚无官方支持。对于某些功能,可能只有在从 BSD 套接字 API 中使用时才能正常运作。

有关 Netconn API 的更多信息,请参阅 lwip/lwip/src/include/lwip/api.hlwIP 应用程序 **非官方** 开发手册的一部分

lwIP FreeRTOS 任务

lwIP 创建了专用的 TCP/IP FreeRTOS 任务,处理来自其他任务的套接字 API 请求。

以下配置项可用于修改任务,并调整向 TCP/IP 任务发送数据和从 TCP/IP 任务接收数据的队列(邮箱):

IPv6 支持

系统支持 IPv4 和 IPv6 的双栈功能,并默认启用这两种协议。如无需要,可将其禁用,请参阅 最小内存使用

在 ESP-IDF 中,IPv6 支持仅限 无状态自动配置,不支持 有状态配置,上游的 lwIP 也不支持 有状态配置

IPv6 地址配置通过以下协议或服务定义:

  • 支持 SLAAC IPv6 无状态地址配置 (RFC-2462)

  • 支持 DHCPv6 IPv6 动态主机配置协议 (RFC-8415)

以上两种地址配置默认处于禁用状态,设备仅使用链路本地地址或静态定义的地址。

无状态自动配置流程

要通过路由器通告协议启用地址自动配置,请启用此配置选项:

该配置选项启用了所有网络接口的 IPv6 自动配置。而在上游 lwIP 中,需要设置 netif->ip6_autoconfig_enabled=1,针对每个 netif 明确启用自动配置。

DHCPv6

lwIP 中的 DHCPv6 非常简单,仅支持无状态配置,可通过以下配置选项启用:

由于 DHCPv6 仅在无状态配置下工作,因此还需要通过 CONFIG_LWIP_IPV6_AUTOCONFIG 启用 无状态自动配置流程

此外,还需要使用以下语句,在应用程序代码中明确启用 DHCPv6:

dhcp6_enable_stateless(netif);

IPv6 自动配置中的 DNS 服务器

要自动配置 DNS 服务器,尤其是在仅使用 IPv6 的网络中配置,可使用以下两种选项:

  • 递归域名系统 (DNS),属于邻居发现协议 (NDP) 的一部分,可使用 无状态自动配置流程

    DNS 服务器的数量必须设置为 CONFIG_LWIP_IPV6_RDNSS_MAX_DNS_SERVERS,该选项默认禁用,即置位为 0。

  • DHCPv6 无状态配置,使用 DHCPv6 配置 DNS 服务器。注意,此配置假设 IPv6 路由通告标志 (RFC-5175) 进行了如下设置

    • 管理地址配置标志 (Managed Address Configuration Flag) = 0

    • 其他配置标志 (Other Configuration Flag) = 1

ESP-lwIP 自定义修改

补充内容

以下代码均为新增代码,尚未包含至上游 lwIP 版本:

线程安全的套接字

调用 close() 可以从不同于创建套接字的线程中关闭该套接字。该调用持续阻塞,直至其他任务中使用该套接字的函数调用返回。

然而,任务处于主动等待 select()poll() API 的状态时,无法删除该任务。销毁任务前,这些 API 必须先退出,否则可能会破坏内部数据结构,并导致后续 lwIP 崩溃。这些 API 在栈上分配了全局引用的回调指针,因此,在未完全卸载栈的情况下删除任务时,lwIP 仍可以持有指向已删除栈的指针。

按需定时器

lwIP 中的 IGMP 和 MLD6 功能都会初始化一个定时器,以便在特定时间触发超时事件。

即便没有活动的超时事件,lwIP 也会默认始终启用这些定时器,增加自动 Light-sleep 模式下的 CPU 使用率和功耗。ESP-lwIP 则默认将各定时器设置为 按需 使用,即只在有待处理事件时启用。

如果要返回默认 lwIP 设置,即始终启用定时器,请禁用 CONFIG_LWIP_TIMERS_ONDEMAND

lwIP 定时器 API

不使用 Wi-Fi 时,可以通过 API 关闭 lwIP 定时器,减少功耗。

以下 API 函数均受支持,详情请参阅 lwip/lwip/src/include/lwip/timeouts.h

  • sys_timeouts_init()

  • sys_timeouts_deinit()

附加套接字选项

  • 目前已实现部分标准 IPV4 和 IPV6 组播套接字选项,详情请参阅 套接字选项

  • 使用 IPV6_V6ONLY 套接字选项,可以设置仅使用 IPV6 的 UDP 和 TCP 套接字,而 lwIP 一般只支持 TCP 套接字。

IP 层特性

  • IPV4 源地址基础路由实现不同

  • 支持 IPV4 映射 IPV6 地址

NAPT 和端口转发

支持 IPv4 网络地址端口转换(NAPT)和端口转发。然而,仅限于单个接口启用 NAPT。

  • 要在两个接口之间使用 NAPT 转发数据包,必须在连接到目标网络的接口上启用 NAPT。例如,为了通过 Wi-Fi 接口为以太网流量启用互联网访问,必须在以太网接口上启用 NAPT。

  • NAPT 的使用示例可参考 network/vlan_support

自定义 lwIP 钩子

原始 lwIP 支持通过 LWIP_HOOK_FILENAME 实现自定义的编译时修改。ESP-IDF 端口层已使用该文件,但仍支持通过由宏 ESP_IDF_LWIP_HOOK_FILENAME 定义的头文件,在 ESP-IDF 中包含并实现自定义添加。以下示例展示了向构建过程添加自定义钩子文件的过程,其中钩子文件名为 my_hook.h,位于项目的 main 文件夹中:

idf_component_get_property(lwip lwip COMPONENT_LIB)
target_compile_options(${lwip} PRIVATE "-I${PROJECT_DIR}/main")
target_compile_definitions(${lwip} PRIVATE "-DESP_IDF_LWIP_HOOK_FILENAME=\"my_hook.h\"")

使用 ESP-IDF 构建系统自定义 lwIP 选项

组件配置菜单可以配置常见的 lwIP 选项,但是一些自定义选项需要通过命令行添加。CMake 函数 target_compile_definitions() 可以用于定义宏,示例如下:

idf_component_get_property(lwip lwip COMPONENT_LIB)
target_compile_definitions(${lwip} PRIVATE "-DETHARP_SUPPORT_VLAN=1")

使用这种方法可能无法定义函数式宏。虽然 GCC 支持此类定义,但是未必所有编译器都会接受。为了解决这一限制,可以使用 add_definitions() 函数为整个项目定义宏,例如 add_definitions("-DFALLBACK_DNS_SERVER_ADDRESS(addr)=\"IP_ADDR4((addr), 8,8,8,8)\"")

另一种方法是在头文件中定义函数式宏,该头文件将预先包含在 lwIP 钩子文件中,请参考 自定义 lwIP 钩子

限制

在 ESP-IDF 中,lwIP 在某些场景下线程安全,但存在一定的限制。在 lwIP 中,可以在同一套接字上由多个线程同时分别执行读、写和关闭操作,但不支持在同一套接字上由多个线程同时执行多个读操作或多个写操作。如果应用程序需要在多个线程中同时对同一套接字进行读、写操作,就需要额外的同步机制来确保线程安全。例如,在套接字操作周围加锁。

适配的 API 所述,ESP-IDF 中的 lwIP 扩展功能仍然受到全局 DNS 限制的影响。为了在应用程序代码中解决这一限制,可以使用 FALLBACK_DNS_SERVER_ADDRESS() 宏定义所有接口能够访问的全局 DNS 备用服务器,或者单独维护每个接口的 DNS 服务器,并在默认接口更改时重新配置。

通过网络数据库 API 返回的 IP 地址数量受限:getaddrinfo()gethostbyname() 受到宏 DNS_MAX_HOST_IP 的限制,宏的默认值为 1。

在调用 getaddrinfo() 函数时,不会返回规范名称。因此,第一个返回的 addrinfo 结构中的 ai_canonname 字段仅包含 nodename 参数或相同内容的字符串。

在 UDP 套接字上重复调用 send()sendto() 最终可能会导致错误。此时 errno 报错为 ENOMEM,错误原因是底层网络接口驱动程序中的 buffer 大小有限。当所有驱动程序的传输 buffer 已满时,UDP 传输事务失败。如果应用程序需要发送大量 UDP 数据报,且不希望发送方丢弃数据报,建议检查错误代码,采用短延迟的重传机制。

性能优化

影响 TCP/IP 性能因素较多,可以从多方面进行优化。经调整,ESP-IDF 的默认设置已在 TCP/IP 的吞吐量、响应时间和内存使用间达到平衡。

最大吞吐量

乐鑫使用 iperf 测试应用程序 https://iperf.fr/ 测试了 ESP-IDF 的 TCP/IP 吞吐量。关于实际测试和优化配置的更多信息,请参考 提高网络速度

重要

建议逐步应用更改,并在每次更改后,通过特定应用程序的工作负载检查性能。

最低延迟

除增加 buffer 大小外,大多数增加吞吐量的设置会减少 lwIP 函数占用 CPU 的时间,进而降低延迟,缩短响应时间。

  • 对于 TCP 套接字,lwIP 支持设置标准的 TCP_NODELAY 标记以禁用 Nagle 算法。

最小内存使用

由于 RAM 按需从堆中分配,多数 lwIP 的 RAM 使用也按需分配。因此,更改 lwIP 设置减少 RAM 使用时,或许不会改变空闲时的 RAM 使用量,但可以改变高峰期的 RAM 使用量。

  • 减少 CONFIG_LWIP_MAX_SOCKETS 可以减少系统中的最大套接字数量。更改此设置,会让处于 WAIT_CLOSE 状态的 TCP 套接字在需要打开新套接字时更快地关闭和复用,进一步降低峰值 RAM 使用量。

  • 减少 CONFIG_LWIP_TCPIP_RECVMBOX_SIZECONFIG_LWIP_TCP_RECVMBOX_SIZECONFIG_LWIP_UDP_RECVMBOX_SIZE 可以减少 RAM 使用量,但会影响吞吐量,具体取决于使用情况。

  • 减少 CONFIG_LWIP_TCP_ACCEPTMBOX_SIZE 可以通过限制同时接受的连接数来减少 RAM 使用量。

  • 减少 CONFIG_LWIP_TCP_MSLCONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT 可以减少系统中的最大分段寿命,同时会使处于 TIME_WAITFIN_WAIT_2 状态的 TCP 套接字能更快地关闭和复用。

  • 禁用 CONFIG_LWIP_IPV6 可以在系统启动时节省大约 39 KB 的固件大小和 2 KB 的 RAM,并在运行 TCP/IP 栈时节省 7 KB 的 RAM。如果无需支持 IPV6,可以禁用 IPv6,减少 flash 和 RAM 占用。

  • 禁用 CONFIG_LWIP_IPV4 可以在系统启动时节省大约 26 KB 的固件大小和 600 B 的 RAM,并在运行 TCP/IP 栈时节省 6 KB 的 RAM。如果本地网络仅支持 IPv6 配置,可以禁用 IPv4,减少 flash 和 RAM 占用。

最大 buffer 使用

lwIP 消耗的最大堆内存即 lwIP 驱动程序 理论上可能消耗的最大内存,通常取决于以下因素:

  • 创建 UDP 连接所需的内存:lwip_udp_conn

  • 创建 TCP 连接所需的内存:lwip_tcp_conn

  • 应用程序拥有的 UDP 连接数量:lwip_udp_con_num

  • 应用程序拥有的 TCP 连接数量:lwip_tcp_con_num

  • TCP 的 TX 窗口大小:lwip_tcp_tx_win_size

  • TCP 的 RX 窗口大小:lwip_tcp_rx_win_size

因此,lwIP 消耗的最大堆内存可以用以下公式计算:

lwip_dynamic_peek_memory = (lwip_udp_con_num * lwip_udp_conn) + (lwip_tcp_con_num * (lwip_tcp_tx_win_size + lwip_tcp_rx_win_size + lwip_tcp_conn))

某些基于 TCP 的应用程序只需要一个 TCP 连接。然而,当出现错误(如发送失败)时,应用程序可能会关闭此 TCP 连接,并创建一个新的连接。根据 TCP 状态机和 RFC793,关闭 TCP 连接可能需要很长时间,这可能导致系统中同时存在多个 TCP 连接。


此文档对您有帮助吗?