内核编程与应用编程的核心差异 钻研底层技术、阅读Linux内核源码,是许多技术爱好者的兴趣方向。然而,专职从事内核开发的机会在实际工作中并不常见。以个人经历为例,早期工作虽涉及负载均衡,但数据处理主要停留在应用层——这已与应用编程中常见的业务逻辑开发有所不同。 直到当前岗位,才真正深入内核开发领域。
钻研底层技术、阅读Linux内核源码,是许多技术爱好者的兴趣方向。然而,专职从事内核开发的机会在实际工作中并不常见。以个人经历为例,早期工作虽涉及负载均衡,但数据处理主要停留在应用层——这已与应用编程中常见的业务逻辑开发有所不同。
直到当前岗位,才真正深入内核开发领域。对于资源有限的中小企业,有时确实难以投入精力构建应用层协议栈。尽管存在netmap、DPDK等成熟框架,以及lwip这类轻量级用户态协议栈可选方案,但将数据包映射至用户空间,会引入额外的内存管理、连接控制等工作量,同时也意味着无法直接利用netfilter(如iptables)的现有功能——尽管其效率未必最优。
长期稳定更新的攒劲资源: >>>点此立即查看<<<
在缺乏正式内核开发经验的情况下,凭借过往技术积累,我承担了内核模块的开发任务。这多少有些“迎难而上”的意味,因为团队中并无更合适人选。所幸负责的是相对独立的网络模块,从最终结果看,整体过程较为顺利。
经过三个多月的运行,模块基本保持稳定,未出现重大故障。期间遇到不少技术难题,也积累了诸多解决方案,因此决定撰文进行记录与分享。希望这些实践经验,能为有意从事内核开发的同行提供参考。
内核编程最显著的体会,是其“执行流”异常复杂,并发逻辑远比应用编程更具挑战。这里所说的“执行流”是一个概括性表述。在应用编程中,并发通常指多进程、多线程,通过锁机制保护共享资源即可基本解决问题。一个线程可视为一个执行流,只要不被信号中断,代码总是顺序执行。换言之,应用层编写的代码和业务逻辑,仅由自身创建的线程或进程执行。信号处理函数通常也设计得较为简单,往往只设置状态标志。
但在内核环境中,情况截然不同。中断、软中断、定时器、系统调用等都可能成为切入业务逻辑的执行流。由于内核自身的特性,对共享资源的保护需要仔细考量,并采用不同的技术手段。
举例来说,某些共享资源最初使用spin_lock进行保护。但随着功能扩展,需要增加与用户空间的交互。在实现过程中,有时会直接调用现有代码模块。结果发现,这些模块中对共享资源的保护同样使用了spin_lock,而数据包转发的核心逻辑运行在软中断上下文中,稍有不慎便可能导致死锁。
除了自身遇到的问题,也曾修复他人遗留的bug。其中一个案例令人印象深刻:产品会不定期重启,但在本地测试环境中无法复现。面对这类难以重现的故障,最直接的方法往往是代码审查。所幸核心功能代码量相对有限,花费两天时间理解大部分逻辑后,顺手修复了几个可能导致重启的隐患。客户升级后,问题大部分消失,但仍有零星重启现象,说明仍有未发现的漏洞。
此时,整个关键流程已在脑海中清晰呈现。解决这个问题的过程颇具启发性:靠在椅背上,将数据包从入口到出口的完整流程,连同所有分支和特殊情况,在脑中完整推演了一遍。随后灵光闪现,整个过程不到十五分钟。接着便是查看代码,验证猜想。
问题根源如下:为满足特定业务需求,代码申请了一个动态结构体,并设置了超时定时器用于到期释放。当业务逻辑访问该结构时,会刷新其访问时间以延长生命周期。但在某些情况下,需要提前删除这个结构,此时会调用del_timer删除定时器,然后释放内存。这段代码存在明显隐患:如果调用del_timer时定时器正在执行,会发生什么?查阅资料证实,del_timer返回并不能保证定时器没有正在执行。那么,定时器仍在执行而动态结构已被释放,显然会导致问题。
解决方案的思考:首先考虑保证同步删除,使用del_timer_sync。但仔细分析后,这仍存在问题。该动态结构原本依赖定时器超时释放,现在要强制释放,即便用del_timer_sync停掉定时器,也可能定时器已超时并完成了释放操作,此时再强制释放就成了双重释放。同时,del_timer_sync这种同步操作必然带来性能开销。最终解决方案是增加标志位,在强制删除时置位,确保释放操作只有一个执行者,同时引入引用计数机制。
近期,在性能优化过程中引入了两个bug,所幸都及时修复。出现bug的原因,仍是对Linux内核本身不够熟悉。其中一个最近发现的bug,花费了一整天时间定位。现象是:运行某个特定应用程序时,会导致内核崩溃。初步甚至怀疑这是内核自身的bug——虽然觉得可能性不大,但仍着手验证排除。因为不运行该程序时,内核模块完全正常;一旦运行,内核就崩溃。而这个应用程序与我们的内核模块没有任何直接交互。
分析该应用程序代码后发现,它与网络最相关的操作是注册了一个PF_PACKET类型的socket,用于抓取所有网卡的数据包。查看相关代码发现,PF_PACKET的收包函数会检查skb是否被共享,如果是就执行克隆。同样,ip_rcv入口处也有类似逻辑。这意味着当该应用程序运行时,ip_rcv会检测到skb是共享的,从而执行克隆操作。这就是应用程序运行与否,内核处理数据包流程的最大区别。
于是修改ip_rcv的代码,不再检查skb是否共享,而是直接克隆。结果发现,即使不启动那个应用程序,内核依然崩溃。这证明问题出在自身代码中,且与skb相关。经过排查,最终找到根本原因。
在netfilter的两个hook点上注册了两个钩子函数。第一个钩子函数初始化了一些per cpu变量;第二个钩子函数则简单判断:如果per_cpu->skb与hook的参数skb相等,就不再初始化,直接使用per cpu的变量。问题在于,当有skb_clone调用时,不同hook点被调用时,skb->data指向的内存地址发生了变化。第二个hook点处,skb->data与第一个hook点处不一致。但skb_clone本身并不会导致这个结果。这说明在netfilter的不同hook点之间,当skb被克隆后,其数据空间可能会被重新分配——具体是哪段代码导致的,暂未深入探究。
这个bug带来了深刻教训:在内核编程中,不可能熟悉Linux内核的所有代码。因此编程时必须牢记,除非是内核明确定义的行为,否则不能放心使用。不能仅依靠简单测试,就认为某些未定义的行为是安全可靠的。正如上例,内核从未保证两个hook点之间的skb是相同的,也从未保证skb的数据空间(skb->data)是一致的。
在Linux内核中实现网关类功能时,还有一个深刻体会:虽然Linux提供了大量现成组件,能加速开发进程,但内核本身的架构是为通用计算设计的,并非专为网络处理优化。其网络模块的架构存在不少固有的弊端和不便之处,尤其是对比之前公司的产品架构——那个架构看似简单,但越深入体会,越能感受到“简单即是美”的真谛!这种美体现在两方面:一是产品效率(即性能),二是开发效率。
实际上,实现高性能网络设备的产品,其底层架构大多有相似之处。但正是那些细微之处的差异,最终造就了产品性能的不同。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述