☰
打开LWIP_DEBUG:lwIP协议栈调试信息打印与日志输出完整指南
2026/10/7 19:28:56 网站建设 项目流程

敲代码这么多年,网络协议栈的调试始终是我最谨慎的一块。为什么?因为嵌入式网络问题往往是“现场才能复现”的,断点挂不住、单步又改了时序,最后能靠得住的还是日志。lwIP这个协议栈其实早就内置了一套完整的打印调试机制,名字就叫 LWIP_DEBUG,只是不少工程默认把它关得死死的,出了问题只能靠猜。这篇内容我就把打开 LWIP_DEBUG、配置打印信息调试、把调试输出同时写到日志文件并保持屏幕打印的完整思路讲一遍,尤其适合正在裸机、RTOS 或 PC 仿真环境里调 lwIP 的开发者。

lwIP 的调试开关和单片机外设调试有个很大区别:它不是简单的一个“printf 使能位”,而是从全局开关、模块开关、打印级别、底层输出函数四个维度共同组成的。你如果只把某个宏改成 1,大概率什么都看不到。所以这篇文章不会只给你“打开宏”的操作,而是把整个链路拆开讲,每个环节为什么这么设计、常见坑在哪里,都会说明白。

1. 别急着改配置,先把LWIP_DEBUG的调用链看清楚

1.1 调试宏是怎么一层层串起来的

在 lwIP 源码里,真正控制调试打印的宏并不是一个孤立的LWIP_DEBUG。你会在不同头文件里看到LWIP_DEBUGF、LWIP_ASSERT、LWIP_PLATFORM_DIAG三组东西,它们才是整套输出机制的核心。

其中LWIP_DEBUGF是所有调试打印的入口,绝大多数模块在关键路径上都会调用它,比如:

LWIP_DEBUGF(TCP_DEBUG, ("TCP rto: pcb=%p, seq=%"U32_F", ack=%"U32_F"", pcb, seqno, ackno));

注意第二个参数外面有一层括号,这可不是多此一举。因为LWIP_DEBUGF本质上是一个可变参数宏,后面的整个消息内容被当成一个参数列表传递,展开后最终交给底层的打印函数。默认情况下,这个宏的定义大致是:

#define LWIP_DEBUGF(debug, message) \ do { \ if ((LWIP_DEBUG) && \ ((debug) & LWIP_DBG_TYPES_ON) && \ ((debug) & LWIP_DBG_MASK_LEVEL) >= LWIP_DBG_MIN_LEVEL) { \ LWIP_PLATFORM_DIAG(message); \ if ((debug) & LWIP_DBG_HALT) { \ while(1); \ } \ } \ } while(0)

这短短几行代码,包含了很多信息:

  • 第一条件是LWIP_DEBUG必须为真,这是总开关。
  • 第二条件是当前这条打印的类型位掩码要落在LWIP_DBG_TYPES_ON允许的范围内。
  • 第三条件是当前这条打印的严重级别要满足LWIP_DBG_MIN_LEVEL的最低门槛。
  • 条件满足后,才调用LWIP_PLATFORM_DIAG(message)做真正的输出。
  • 如果这条打印还带了LWIP_DBG_HALT标志位,那么输出完之后直接死循环,方便调试器停在出问题的地方。

我见过很多开发者上来就把LWIP_DEBUG改成 1,然后一边抱怨“怎么没打印”,一边去翻别的代码。实际上,LWIP_DEBUG只是第一道门,后面还有LWIP_DBG_TYPES_ON和LWIP_DBG_MIN_LEVEL这两道过滤。任何一个不满足,输出都会静悄悄消失。

1.2 输出开关背后的位掩码与等级过滤

lwIP 里为调试信息定义了几种标志位,常见的有:

  • LWIP_DBG_OFF:完全关闭该模块调试。
  • LWIP_DBG_ON:开启该模块基本调试输出。
  • LWIP_DBG_TRACE:输出运行轨迹类信息。
  • LWIP_DBG_STATE:输出状态机变化信息。
  • LWIP_DBG_WARN:输出警告信息。
  • LWIP_DBG_SERIOUS:输出严重错误信息。
  • LWIP_DBG_HALT:打印后主动停机。

这里要特别注意,LWIP_DBG_ON和LWIP_DBG_WARN、LWIP_DBG_SERIOUS并不是同类事物。LWIP_DBG_ON是总使能位,其余是细化的类型/级别位。一个调试打印宏的第一个参数,往往是通过按位或组合出来的,比如:

#define TCP_DEBUG LWIP_DBG_ON | LWIP_DBG_TRACE | LWIP_DBG_STATE

这样 TCP 模块的轨迹和状态变化都能打印出来,同时又遵守全局的级别过滤条件。

另外,lwIP 把调试级别也做了分级,大致从LWIP_DBG_LEVEL_ALL(全部输出)、LWIP_DBG_LEVEL_WARNING(只输出警告及以上)、LWIP_DBG_LEVEL_SERIOUS(只输出严重及以上)到LWIP_DBG_LEVEL_SEVERE(只输出致命级)。这里的数值关系在不同版本里可能略有差异,但“更严重的级别阈值更高”这个逻辑是固定的。你在lwipopts.h里设置LWIP_DBG_MIN_LEVEL时,意思就是“低于这个严重程度的打印,我全部不要了”。

这种设计非常适合正式版固件和调试版固件的切换。正式版可以直接把LWIP_DBG_MIN_LEVEL调到最严格,只预留致命错误输出,平时几乎不产生日志;调试版则调到最低阈值,把协议栈内部运行细节完整铺开。

1.3 最终输出函数怎么改都行

真正干活的打印函数是LWIP_PLATFORM_DIAG,默认情况下它会被定义为printf或者LWIP_PLATFORM_DIAG自己的实现,取决于你的移植层(cc.h或lwipopts.h)怎么处理。

同理,断言用的LWIP_ASSERT在条件不成立时,会调用LWIP_PLATFORM_ASSERT。默认实现往往是:

#define LWIP_PLATFORM_ASSERT(message) \ do { \ printf("Assertion \"%s\" failed at line %d in %s\n", \ message, __LINE__, __FILE__); \ abort(); \ } while(0)

但在嵌入式环境里,printf可能没有重定向到串口,abort()可能直接触发 HardFault。所以很多移植都会自己重写这两个宏。这也是“打开 LWIP_DEBUG 打印信息调试”真正要落地的第一步:先确保输出有地方去。

2. 动手前先配置好LWIP_DEBUG相关的宏

2.1 lwipopts.h里的全局和模块开关

lwIP 的用户配置集中在lwipopts.h,这个文件通常不放在源码目录里,而是放在你的工程配置目录,编译期通过头文件搜索路径指向它。调试相关的全局配置一般长这样:

#define LWIP_DEBUG 1 #define LWIP_DBG_TYPES_ON LWIP_DBG_ON #define LWIP_DBG_MIN_LEVEL LWIP_DBG_LEVEL_ALL

第一行是总开关,第二行表示允许哪些类型的调试消息,第三行表示最低输出级别。如果你只想看警告和严重错误,就把LWIP_DBG_MIN_LEVEL换成LWIP_DBG_LEVEL_WARNING。

接下来是模块开关。每个模块都有一个对应的宏,常见的有:

#define ETHARP_DEBUG LWIP_DBG_ON #define IP_DEBUG LWIP_DBG_ON #define UDP_DEBUG LWIP_DBG_ON #define TCP_DEBUG LWIP_DBG_ON #define TCP_INPUT_DEBUG LWIP_DBG_ON #define TCP_OUTPUT_DEBUG LWIP_DBG_ON #define TCP_RTO_DEBUG LWIP_DBG_ON #define TCP_CWND_DEBUG LWIP_DBG_ON #define TCP_WND_DEBUG LWIP_DBG_ON #define TCP_FR_DEBUG LWIP_DBG_ON #define TCP_QLEN_DEBUG LWIP_DBG_ON #define TCP_RST_DEBUG LWIP_DBG_ON #define DHCP_DEBUG LWIP_DBG_ON #define DNS_DEBUG LWIP_DBG_ON #define MEM_DEBUG LWIP_DBG_ON #define MEMP_DEBUG LWIP_DBG_ON #define PBUF_DEBUG LWIP_DBG_ON #define SYS_DEBUG LWIP_DBG_ON

这里有一个容易混淆的点:LWIP_DEBUG是总开关,而ETHARP_DEBUG、IP_DEBUG这些模块宏只控制模块自身。模块宏的值不是简单 0/1,而是上面说的调试标志组合。如果你把某模块定义为LWIP_DBG_ON,等价于“允许该模块的基本调试打印进入后面的过滤链”。

我建议不要一股脑全部打开。协议栈的模块之间调用关系很紧密,全量开启后日志量非常大,你根本找不到重点。正确做法是“由外到内”逐步开:物理层/链路层出问题,开ETHARP_DEBUG、PBUF_DEBUG;IP 层出问题,开IP_DEBUG;TCP 传输异常,开TCP_DEBUG、TCP_RTO_DEBUG、TCP_CWND_DEBUG。

2.2 常用调试宏对照表

我把自己在开发中经常用到的一组调试宏整理了一下,方便你按模块快速定位。不同 lwIP 版本宏名可能有小差异,但大体结构是一致的:

调试宏所属模块什么时候重点看
ETHARP_DEBUG以太网地址解析ARP 请求/应答异常,IP 通信建立不起来
IP_DEBUGIP 协议处理收发包 IP 头解析错误、路由选择异常
ICMP_DEBUGICMP 协议ping 不通、差错报文处理异常
IGMP_DEBUG组播管理组播组成员关系异常
UDP_DEBUGUDP 协议UDP 端口/校验和问题
TCP_DEBUGTCP 状态机TCP 连接建立、关闭、重传等整体逻辑
TCP_INPUT_DEBUGTCP 接收路径收到报文后处理异常
TCP_OUTPUT_DEBUGTCP 发送路径数据发送失败、窗口更新问题
TCP_RTO_DEBUGTCP 超时重传重传超时计算不准、频繁重传
TCP_CWND_DEBUGTCP 拥塞窗口吞吐量异常、拥塞控制不生效
TCP_WND_DEBUGTCP 接收窗口零窗口、窗口更新延迟
TCP_FR_DEBUGTCP 快速重传快速重传/快恢复逻辑
TCP_QLEN_DEBUGTCP 接收队列数据队列堆积、内存耗尽
TCP_RST_DEBUGTCP 复位报文收到异常 RST、连接被重置
DHCP_DEBUGDHCP 客户端获取不到 IP、租约异常
DNS_DEBUGDNS 客户端域名解析失败
MEMP_DEBUG内存池管理内存池耗尽、内存泄漏
MEM_DEBUG堆内存管理内存碎片化、分配失败
PBUF_DEBUG数据包缓冲区pbuf 泄漏、链不完整
SYS_DEBUG系统抽象层信号量/互斥量/邮箱操作异常

这张表的价值在于,它能帮你把“现象”对到“模块”。比如你发现 TCP 连接建立后很快就收到 RST,与其打开所有调试宏看海量输出,不如先开TCP_RST_DEBUG和TCP_DEBUG,直接看复位报文从哪来、状态机怎么跳的,效率会高非常多。

2.3 按需设置调试等级和过滤条件

打开LWIP_DEBUG之后,最怕的不是没信息,而是信息太多。我记得有一次调 TCP 拥塞控制,把TCP_DEBUG、TCP_CWND_DEBUG、TCP_OUTPUT_DEBUG全开了,串口每秒刷几千行日志,程序运行效率被串口拖累,原来的时序问题反而找不到了。

后来我学乖了,调试等级一定要分级。lwIP 本身支持LWIP_DBG_MIN_LEVEL,我一般先设成LWIP_DBG_LEVEL_ALL,等确认链路没问题后,再把级别往上调,只留警告和严重错误。如果你用的是实时操作系统,还要考虑日志打印优先级是否会影响协议栈任务调度。最简单的方法是临时把协议栈任务优先级抬高,或者把日志输出放到一个独立低优先级任务里通过消息队列消费,不要直接在协议栈上下文里长时间阻塞打印。

另外,LWIP_DBG_TYPES_ON也可以做精细化过滤。比如你只想看状态变化,可以把LWIP_DBG_TYPES_ON设为LWIP_DBG_STATE,这样即使模块宏里带了LWIP_DBG_TRACE,轨迹类信息也不会输出。这个过滤链非常重要,它能让你在不重新编译的情况下,用同一份代码关注不同粒度的信息。

3. 实操配置:让LWIP_DEBUG的输出同时进日志和屏幕

3.1 裸机/RTOS下把打印重定向到串口

在没有操作系统的裸机环境里,最普遍的做法是把 lwIP 的调试输出最终落到串口。首先要保证你的printf能工作,比如 STM32 上重定向fputc:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

如果你使用 HAL 库,记得把串口初始化放在 lwIP 启动之前。然后通过 lwipopts.h 或者 cc.h 里把诊断宏指向printf:

#define LWIP_PLATFORM_DIAG(x) do { \ printf x; \ } while(0)

这里有个细节必须强调:x是带括号的整个参数列表,所以printf后面不能加括号,直接printf x。很多人写成printf(x),结果宏展开后变成printf("...")还好,一旦遇到多个参数就编译失败,比如printf("%d %d", a, b)展开后可能变成不合法代码。

如果你的串口速度不够高,我建议用 DMA 或环形缓冲区把日志异步发送,不要在协议栈关键路径上阻塞等待。TCP_DEBUG这类高频打印如果每字节都死等发送完成寄存器,性能会断崖式下降。

3.2 在Visual Studio仿真工程中启用LWIP_DEBUG

很多团队会在 PC 上用 Visual Studio 跑 lwIP 仿真,因为编辑调试体验比嵌入式 IDE 好,而且内存大、断点灵活。这种情况下打开LWIP_DEBUG并不复杂,只是输出目的地不再是串口,而是 VS 的“输出”窗口和命令行控制台。

首先在工程里打开 lwipopts.h,设置:

#define LWIP_DEBUG 1 #define LWIP_PLATFORM_DIAG(x) vs_lwip_log x

然后在代码里实现vs_lwip_log:

#include <windows.h> #include <stdio.h> #include <stdarg.h> void vs_lwip_log(const char *fmt, ...) { char buf[512]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); OutputDebugStringA(buf); printf("%s", buf); }

如果你希望 printf 输出到 VS 的控制台窗口,需要在启动时分配控制台并重定向标准输出:

AllocConsole(); freopen("CONOUT$", "w", stdout);

这里的重点是OutputDebugStringA会把内容送到 VS 的“输出”窗口,而printf会送到控制台。两者同时使用,就实现了“屏幕打印”和“IDE 窗口打印”双份输出。对于协议栈这种高频输出,OutputDebugStringA性能一般,高频打印时可能拖慢程序,我用它主要是方便在 VS 里过滤关键字,普通频率下没有太大问题。

3.3 将调试信息同时写入日志文档并保持打印

配合标题里提到的“vs+调试信息保存到日志文档同时打印显示”,我会在 VS 仿真工程里再加一层文件输出。调试网络协议栈时,控制台日志滚得飞快,很多关键历史信息一眨眼就过去了,保存到文件后可以慢慢翻,还能用文本对比工具比较两次调试的差异。

具体做法是维护一个全局文件指针,在初始化时打开日志文件,然后写一个统一的日志函数:

FILE *g_lwip_log_fp = NULL; void lwip_log_init(const char *path) { if (g_lwip_log_fp) { fclose(g_lwip_log_fp); g_lwip_log_fp = NULL; } if (path) { g_lwip_log_fp = fopen(path, "w"); } } void lwip_log_output(const char *fmt, ...) { char buf[1024]; va_list args; int len; va_start(args, fmt); len = vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len < 0) { return; } if (g_lwip_log_fp) { fwrite(buf, 1, (size_t)len, g_lwip_log_fp); fflush(g_lwip_log_fp); } OutputDebugStringA(buf); printf("%s", buf); }

然后在 lwipopts.h 中:

#define LWIP_PLATFORM_DIAG(x) lwip_log_output x

启动时调用:

lwip_log_init("lwip_debug.log");

这样每次协议栈打印调试信息,都会同时写入lwip_debug.log文件、VS 输出窗口、控制台窗口。文件写入调用fflush是为了保证程序崩溃时日志不丢,但代价是频繁写磁盘会影响性能。如果只是做问题复现,可以接受;如果是长时间压测,最好降低刷盘频率,或者改成“每 N 条刷一次”。

其它平台也可以套用这个思路。比如在 Linux 下把日志文件路径改成/tmp/lwip_debug.log,去掉OutputDebugStringA,改为fprintf(stderr, ...);在 RTOS 下则把文件写操作换成“挂到日志任务”或者“写入 RAM 日志区,掉电前统一保存”,核心逻辑不变。

3.4 用一次TCP重传日志演示如何定位问题

为了让你直观看到LWIP_DEBUG的价值,我拿一次典型的 TCP 重传问题来演示。现象是设备作为 TCP 客户端,连接服务器后偶尔收不到数据,网络抓包显示存在大量重传。

先在 lwipopts.h 里打开:

#define TCP_DEBUG LWIP_DBG_ON #define TCP_RTO_DEBUG LWIP_DBG_ON #define TCP_OUTPUT_DEBUG LWIP_DBG_ON

随后看到的日志大致长这样(不同版本格式不同,但关键字段类似):

TCP rto: pcb=0x20001234, seq=0x12345678, ack=0x87654321, wnd=0x4000, eff_wnd=0x0000, rto=2500, nrtx=1 TCP output: pcb=0x20001234, seq=0x12345678, ack=0x87654321, flags=0x18, wnd=0x4000 TCP rto: pcb=0x20001234, seq=0x12345678, ack=0x87654321, wnd=0x4000, eff_wnd=0x0000, rto=5000, nrtx=2

第一眼看过去,wnd=0x4000表示接收窗口有 16KB,看起来不小。但后面eff_wnd=0x0000才是关键,它表示本地实际可发送窗口已经变成 0。再往前翻日志,会发现有一条:

TCP window update: pcb=0x20001234, new_wnd=0x0000

这说明对端曾经通告过零窗口。正常情况下,本地 TCP 应该停止发送,等待对端窗口更新;但如果应用层逻辑在发送缓冲区满的时候仍然不断调用tcp_write,或者tcp_rexmit被误触发,就会看到无意义的重传。顺着日志里的pcb地址,用 VS 在这个地址上打断点,很快就能定位到是哪个连接对象、哪一层逻辑引起的异常。

这个例子想说明的是:LWIP_DEBUG的日志不是用来“看热闹”的,而是要把协议栈内部状态的变化串成一条时间线。窗口变化、RTO 变化、重传次数变化,每一个关键节点在日志里都有迹可循。你把这条时间线拉出来,再对照应用层调用,问题往往自己就浮出水面了。

4. 常见坑与排查技巧实录

4.1 开全量调试后系统性能劣化

我自己最开始犯过的错误就是在LWIP_DEBUG=1的前提下,把所有模块宏全部设成LWIP_DBG_ON。结果系统跑起来后,串口被日志塞满,协议栈任务一直在等串口发送,TCP 连接反而频繁超时。这不是协议栈本身的问题,而是调试行为改变了系统时序。

遇到这种情况,我一般会做三件事:

  1. 先把LWIP_DBG_MIN_LEVEL提高到LWIP_DBG_LEVEL_WARNING,过滤掉大量轨迹信息。
  2. 把串口打印改成 DMA 发送或异步队列发送。
  3. 只打开与当前问题相关的模块宏,不要全开。

如果打印频率仍然很高,还可以在底层输出函数里加关键字过滤。比如只保留包含"TCP"或"rto"的日志行,这样既能减少输出量,也不会让协议栈因为打印阻塞太久。

4.2 输出乱码、丢数据或格式不支持

嵌入式串口调试最常见的现象是乱码。第一反应是查波特率,但还有一个很容易忽略的点:如果LWIP_PLATFORM_DIAG里用了printf,并且你在中断里也打印,两个上下文同时操作同一个串口外设,就可能互相打断,导致数据交叉、乱码。解决方法是加一个互斥保护,或者把日志统一放到一个任务里输出。

格式化方面也有不少坑。lwIP 使用自己的整型打印宏,比如U32_F、S32_F、U16_F,它们在各种平台上会被展开成"u32"或"d"等格式。如果你用了%u、%d而传入的是u32_t,在某些编译器上可能没问题,但在严格类型检查的环境下会告警。建议统一使用 lwIP 提供的格式宏:

LWIP_DEBUGF(TCP_DEBUG, ("seq=%"U32_F", ack=%"U32_F"", seqno, ackno));

另外一个常见问题是标准printf不支持 64 位整数和浮点。你在日志里打印u64_t时,最好先转换成两个 32 位变量分别输出,或者用支持%llu的 C 库。VS 的vsnprintf对这个支持很好,但很多嵌入式 C 库做得不够完善,编译期不报错,运行期输出就是错的。

4.3 断言触发后如何快速定位问题

LWIP_ASSERT是调试利器,但默认行为在不同平台差别很大。有的平台是死循环,有的平台是abort(),如果你的系统里abort()没有正确实现,可能直接进入 HardFault,很难看出是哪里断言了。

我习惯自定义LWIP_PLATFORM_ASSERT:

#define LWIP_PLATFORM_ASSERT(message) lwip_assert_handler(message, __FILE__, __LINE__)

然后在lwip_assert_handler里打印文件名、行号和消息,再进入等待调试器的死循环:

void lwip_assert_handler(const char *msg, const char *file, int line) { printf("LWIP_ASSERT: %s at %s:%d\n", msg, file, line); fflush(stdout); __disable_irq(); while (1); }

如果是在 VS 仿真环境,死循环会影响调试体验,可以直接调用DebugBreak(),让 IDE 断在出问题的地方,然后沿着调用栈向上回溯。很多 lwIP 底层问题都能靠断言消息直接锁到具体函数,比对着波形猜要快得多。

4.4 长时间记录日志对Flash的磨损问题

如果你在嵌入式设备上把LWIP_DEBUG日志直接写进 Flash 文件系统,频繁擦写会迅速消耗 Flash 寿命。尤其TCP_DEBUG这种高频打印,每秒几十条、每条几百字节,几分钟就能写掉几 MB。所以日志进文件这件事,更适合在 PC 仿真环境做;嵌入式设备上我通常只把日志写到 RAM 环形缓冲区,等出现异常后,再统一把 RAM 里的内容搬到 Flash 或通过上位机导出。

如果一定要在嵌入式设备上持续记录文件日志,建议降低刷盘频率,日志分区使用专门的 NOR Flash,并做均衡磨损和循环覆盖。还要注意写日志期间不要让看门狗饿死,大块写 Flash 是很耗时的操作,最好放到低优先级后台任务里执行。

5. 一些不容易写进文档的实操心得

这套 LWIP_DEBUG 调试体系,我自己在多个项目里反复用,慢慢形成了一个固定的排查套路。首先是“先确认链路,再确认协议栈内部状态”。不要一上来就开TCP_DEBUG,先看物理层以太网 link 状态,再开ETHARP_DEBUG确认 ARP 能通,然后开IP_DEBUG确认 IP 包能正常收发,最后才进入 TCP 层。每开一层,确认这一层没问题,再开下一层,这样能避免被多层日志混在一起。

其次是“日志不仅要能看,还要能对比”。同样一个问题,在不同硬件版本或代码版本上表现可能不同。把关键日志保存成文件,用文本对比工具比对两次运行产生的差异,往往能快速发现是哪一次修改引入了问题。这也是我在 VS 仿真工程里坚持把日志“同时打印+存文件”的原因。

最后是“调试宏不要只放在头文件里,最好和版本管理绑定”。我习惯在lwipopts.h里用#ifdef区分调试版和发布版:

#ifdef LWIP_DEBUG_ENABLE #define LWIP_DEBUG 1 #define LWIP_DBG_MIN_LEVEL LWIP_DBG_LEVEL_ALL #define TCP_DEBUG LWIP_DBG_ON #define TCP_RTO_DEBUG LWIP_DBG_ON #else #define LWIP_DEBUG 0 #define LWIP_DBG_MIN_LEVEL LWIP_DBG_LEVEL_SEVERE #endif

这样在调试阶段打开一个编译宏,就能让整套打印信息调试生效;发布时关掉,日志代码路径几乎不产生额外开销。如果你正被网络协议栈问题折磨,不妨按这个思路把 LWIP_DEBUG 用起来,我猜你会和我一样,从此离不开这套日志系统。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询