引言
现有一嵌入式设备具备网络通信功能,它要求设计成支持多台数据采集器同时进行通信,如图1所示。多种原因(价格,功耗和尺寸)的限制导致该嵌入式设备的处理能力和存储空间有限,因此选用μC/OSII操作系统和LWIP协议栈。在通信过程中,数据采集器充当客户端,嵌入式设备是服务器,显然,需要将该嵌入式设备设计成并发服务器,另外为节省内存,需要设计代理线程模式。
图1 多台采集器与嵌入式设备通信
1 基于μC/OSII动态生成线程
一般来说OS都支持动态生成线程(或进程),μC/OSII也不例外,对于程序员来说,要处理4方面的问题:线程的正文、优先级、堆栈空间和检测堆栈占用率。
正文(text)即线程的执行代码,经常组织成一个无限循环的函数,更具普遍规律的是,在网络开发中多个线程的正文都是同一个函数,因为这些线程基本上完成相同的任务,差异只是连接的主机不同。
μC/OSII中的优先级具有十分重要的意义,不仅影响该线程的调度,而且在整个系统中它是唯一的ID,可以分配一段ID号给动态线程。
最重要的工作是堆栈的处理,当生成一个线程时需要给它分配一段内存充当堆栈,当删除该线程时需要回收这段内存。
根据线程个数与每个线程堆栈大小定义一块内存区,然后使用OS提供的OSMemCreate()生成动态内存区,每当生成一个线程时,调用OSMemGet()来获取一块内存,将该内存的句柄保存;删除一个线程时,调用OSMemPut()将该内存回收。这里有一个问题,怎么查找被删除线程的堆栈呢?其实它是根据该线程的优先级号来完成的。
一个线程可以调用OSTaskDel ()来删除自己,那么它可以回收自己的堆栈吗?事实上,这样做很危险!当一个线程没有完全删除时,它是依赖堆栈来运行的,如果此时回收了堆栈空间,可能会带来致命的错误。假设一个线程回收自己的堆栈且该内存立即被别的线程使用,那么当它还想使用这个堆栈执行最后一些工作时,堆栈里的有效数据已经被破坏了,这可能会带来程序的崩溃,而且这种错误很难查找。
一个安全可行的办法是,一个线程向创建者发送消息——请回收我的堆栈空间,然后删除自己,注意这2个动作必然是“原子的”,否则上述的错误就不可避免了。μC/OSII在删除线程的函数OSTaskDel()中提供钩子函数App_TaskDelHook(),并且调用该钩子函数时中断被关闭,从而保证原子操作,可以在该函数中发送消息。
最后,一个强大的嵌入式系统需要检测线程的堆栈占用率,以防止堆栈溢出带来的内存错误。引入动态线程后,线程个数是未知的,该如何检测堆栈占用率呢?这个工作可以交给OS来完成,因为它最了解哪些线程已经被创建(通过查看OS_TCB来完成),可以简单地调用OSTaskStkChk()遍历所有优先级号,对于已创建线程会返回堆栈使用数据,不存在的线程会返回错误信息(如该线程不存在)。[1]
2 基于LWIP的并发服务器
我们先来解决一个理论问题:为什么网络通信多连接需要设计并发线程,用一个线程来查询并处理多连接是否可以呢?回答这个问题需要看看处理一个网络连接的线程到底在干什么。在LWIP协议栈中,当线程调用netconn_recv()等待连接主机的报文时会被阻塞在邮箱recvmbox上,直到接收成功该线程才进行下一步处理。阻塞在OS中是通过切换CPU来实现的,阻塞时间内该线程什么也干不了,查询并处理多连接是不可能的。因此,一个线程处理一个网络连接,这种模式最自然,也最科学。
这里有一个主线程负责侦听网络,每建立一个网络连接时创建一个新线程并传递网络连接句柄,新线程开始处理对应网络连接上的所有事务,直到该网络连接断开时才删除自己,并通知主线程回收堆栈空间,整个工作过程如图2所示。
图2 多线程与并发连接
结合LWIP看上述过程的实现,主线程阻塞在netconn_accept()上,每接收一个新连接句柄struct netconn *p_stNetConn,就会创建一个新线程并传递该句柄。新线程将阻塞在netconn_recv()上,每接收一个网络数据包struct netbuf *p_stNetBuf后进行处理,新线程通过查询ERR_IS_FATAL(p_stNetConn->err)来得知网络连接是否有效,如果无效,则删除自己并通知主线程回收堆栈空间。[3]
3 代理线程设计模式
从图1中可以看出,无论多少个数据采集器,嵌入式设备的数据都是一致的,即数据与客户端连接个数无关;另外,每个数据采集器与设备通信的操作是相同的(一个优秀架构会保证通信操作的同构性)。因此,引入代理线程设计模式,至少具备2方面的优点:
① 节省内存。假设解析通信协议需要N字节内存,如果新建M个线程,那么采用代理线程将节省N×(M-1)字节内存,除代理线程外其他都不需要解析协议,这对于嵌入式系统宝贵内存来说是一个极大的优势。
② 避免竞态。一份数据如果被多个线程共享,那必定会带来令人头痛的竞态问题,设计者不得不花费大量精力来保证线程安全;采用代理线程后,该数据只被一个线程操作,从源头避免共享,降低设计复杂度。[2]
图3描述了代理线程的工作原理,当客户端Client_i向线程Thread_i发起通信请求时,线程把该请求委托给ProxyThread来完成,ProxyThread解析该委托任务并回应客户端Client_i。同时,也显示了这种设计模式的缺点,需要线程之间通信和少量的时间开销。
图3 代理线程通信原理
在多线程设计中不得不提的是时序问题,它可以清晰地反映线程之间是如何交互的,OS是如何调度线程的以及系统的运行轨迹。在本设计中,委托线程与代理线程的交互如图4所示,每当委托线程提交任务后它就被阻塞,直到代理线程处理完该任务才解除阻塞;另外代理线程负荷最重,它占用大部分的CPU资源。
图4 线程交互时序图
委托线程与代理线程之间的通信接口该怎样设计呢?从需求出发,代理线程完成委托线程的任务需要以下资源:网络连接句柄、接收数据包指针、同步信号量和委托线程私有数据存储区。发送回应数据包必须提供网络连接句柄;要解析协议必须依赖接收数据包;当代理线程完成任务后需要同步委托线程,这里将使用信号;客户端往往需要设置委托线程,数据将保存在它的私有数据存储区。数据结构的设计如图5所示。
图5 线程通信接口
在上述通信接口设计中有SemProtect用于保护线程的私有数据单元,这个信号量有存在的必要吗?以图6中没有设置信号保护的情况为例,这将直接导致一个错误:当Proxy需要通过连接句柄主动发送数据时,委托线程抢夺了CPU,并将该连接句柄置为无效,等Proxy再次获得CPU时,它并不知道该句柄已经无效了,发送数据将会导致LWIP协议栈紊乱。
图6 没有信号量保护导致错误
解决这个问题的办法就是原子操作,即检测线程有效与使用连接句柄发送数据不能被打断,信号量能够胜任这种场合。图7和图8表明,无论Proxy先还是后,获取信号量都能避免上述状况下错误的发生。
图7 Proxy先获取信号量
图8 Proxy后获取信号量
结语
本文重点研究了基于μC/OSII和LWIP的嵌入式系统下并发服务器和代理线程的实现模式,它具备网络多连接(数目仅依赖内存大小)和极大节省内存的优势,深入探究线程同步和网络开发陷阱,对于嵌入式系统网络开发具备实用价值。
论文中涉及的技术方法已在嵌入式产品上验证成功,该产品软件基于μC/OSII V2.86和LWIP V1.3.2,硬件基于LPC1768处理器和以太网口,实践证明论文中的方法稳定、可行。