You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

342 lines
17 KiB
Markdown

2 years ago
# 37 | 从内核到应用:网络数据在内核中如何流转
你好,我是 LMOS。
上节课我们对一次请求到响应的过程积累了一些宏观认识,相信你已经对整个网络架构有了一个整体蓝图。这节课,让我们来仔细研究一下网络数据是如何在内核中流转的,让你开阔视野,真正理解底层工程的实现思路。
凡事先问目的在网络数据在内核中的流转最终要服务于网络收发功能。所以我会先带你了解一次具体的网络发收过程然后带你了解lwIP的网络数据收发。有了这些基础我还会示范一下如何实现协议栈移植你可以在课后自行动手拓展。
好,让我们正式开始今天的学习吧。课程配套代码,你可以点击[这里](https://gitee.com/lmos/cosmos/tree/master/lesson37/Cosmos)获取。
## 先看看一次具体的网络发收过程
理解软件的设计思想,最重要的是先要理解需求。而内核中的数据流转也只是为了满足网络收发的需求而进行的设计。
### 发送过程总览
下面我们一起来看看应用程序通过网络发送数据的全过程。
应用程序首先会准备好数据调用用户态下的库函数。接着调用系统API接口函数进入到内核态。
内核态对应的系统服务函数会复制应用程序的数据到内核的内存空间中,然后将数据移交给网络协议栈,在网络协议栈中将数据层层打包。
最后,包装好的数据会交给网卡驱动,网卡驱动程序负责将打包好的数据写入网卡并让其发送出去。
我为你准备了一张流程图供你参考,如下所示。
![](https://static001.geekbang.org/resource/image/dc/bc/dcb38fc1c0eef666eb1496cbf97a82bc.jpg?wh=2705x3530 "发送过程总览")
上图中只是展示了大致流程其中还有DMA处理、CRC校验、出错处理等细节但对于我们理解梳理发送流程这些就够了。
### 接收过程总览
了解了发送数据的过程以后,掌握接收数据的过程就更容易了,因为它就是**发送数据的逆过程**。
首先网卡接收到数据通过DMA复制到指定的内存接着发送中断以便通知网卡驱动由网卡驱动处理中断复制数据。然后网络协议收到网卡驱动传过来的数据层层解包获取真正的有效数据。最后这个数据会发送给用户态监听的应用进程。
为了让你更加直观地了解这一过程,我特意准备了一张流程图供你参考,如下所示。
![](https://static001.geekbang.org/resource/image/8a/dd/8a726909f0a19ff1683e541d3712b4dd.jpg?wh=3095x3230 "接收过程总览")
前面只是帮你梳理一下数据的发送与接收的流程其实我们真正要关注的是网络协议。可是我们若手动实现一个完整的网络协议不太现实网络协议的复杂度大到也许要重新开一门课程才可以完全解决所以下面我们借用一下lwIP项目以这个为基础来讨论网络协议。
## 认识一下lwIP架构
现在我们清楚了一次具体网络发收过程是怎么回事那怎么让Cosmos实现网络通信呢这里我们选择lwIP这个TCP/IP协议的轻量级开源项目让它成为Cosmos的网络部的战略合作伙伴。
lwIP是由瑞典计算机科学研究院SICS的Adam Dunkels开发的小型开源TCP/IP协议栈。它是一个用C语言实现的软件组件一共有两套接口层向下是操作系统要提供的向上是提供给应用程序的。这样lwIP就能嵌入到任何操作系统之中工作并为这个操作系统上的应用软件提供网络功能支持了。
为啥说lwIP是轻量级的呢很简单跟Linux比从代码行数上就能看得出。lwIP的设计目标就是尽量用少量资源消耗实现一个相对完整的TCP/IP协议栈。
这里的“完整性”主要是指TCP协议的完整性实现的关键点就是**在保持TCP协议主要功能的基础上减少对RAM的占用。**同时lwIP还支持IPv6的标准实现这也让我们与现代交换设备的对接变得非常方便。
这里额外提供你一份扩展阅读资料lwIP的项目[主页链接](http://savannah.nongnu.org/projects/lwip/)这里包含了大量相关资料感兴趣的同学可以课后深入了解。另外lwIP既可以移植到操作系统上运行也可以在没有操作系统的情况下独立运行。
lwIP在结构上可分为四层OS层、API层、核心层、硬件驱动层如下图所示。
![](https://static001.geekbang.org/resource/image/ba/60/ba5f483d0f1a6a5d241bde9c25e6d160.jpg?wh=2745x2524 "lwIP架构图副本")
**第一层**
MCU的业务层是lwIP的服务对象也是其自身代码使用lwIP的地方。大部分时候我们都是从这里入手通过netconn或lwip\_api使用lwIP的各种功能函数。
在典型的TCP通信的客户端应用程序中一般先要通过netconn\_new创建一个struct netconn对象然后调用netconn\_connect连接到服务器并返回成功或失败。成功后可以调用netconn\_write向服务器发送数据也可以调用netconn\_recv接收数据。最后关闭连接并通过netconn\_close释放资源。
**第二层**
lwIP的api层是netconn的功能代码所在的层负责为上层代码提供netconn的api。习惯使用socket的同学也可以使用lwip\_socket等函数以标准的socket方式调用lwIP。新版本增加了http、mqtt等应用的代码这些额外的应用对目前的物联网通信来说确实很方便。
**第三层**
lwIP的核心层存放了TCP/IP协议栈的核心代码它不仅实现了大部分的TCP和UDP功能还实现了DNS、ICMP、IGMP等协议同时也实现了内存管理和网络接口功能。
该层提供了sys\_arch模块设计便于将lwIP移植到不同的操作系统如线程创建、信号量、消息队列等功能。和操作系统相关的真正定义写在了lwip/include/sys.h文件中。
**第四层**
硬件驱动层提供PHY芯片驱动用来匹配lwIP的使用。lwIP会调用该层的代码将组装好的数据包发送到网络同时从网络接收数据包并进行分析实现通信功能。
### lwIP的三套应用程序编程接口
理清了架构我们再说一说lwIP的应用程序编程接口一共有三套。
原始API原始的lwIP API。它通过事件回调机制开发应用程序。该应用编程接口提供了最佳的性能和优化的代码长度但它增加了应用程序开发的复杂性。
Netconn API是高级的有序API、需要实时操作系统RTOS的支持提供进程间通信的方法。Netconn API支持多线程。
BSD套接字API类似伯克利的套接字API在Netconn API上开发需要注意NETCONN API 即为 Sequential API
对于以上三种接口前者只需要裸机调用后两种需要操作系统调用。因此移植lwIP有两种方法一种是只移植内核不过这样之后只能基于RAW/Callback API编写应用程序。第二种是移植内核和上层API。这时应用程序编程可以使用三种API即RAW/Callback API、顺序API和Socket API。
## lwIP执行流程
现在,想必聪明的你已经理解了前文中的网络收发过程。
接下来让我们顺着之前的思路来对应到lwIP在收发过程中的核心函数具体过程我同样梳理了流程图。你可以结合图里关键的函数名以及步骤顺序按这个顺序在IwIP代码中检索阅读。
### 数据发送
首先要说的是数据发送过程。
由于我们把lwIP作为Cosmos的一个内核组件来工作自然要由lwIP接收来自内核上层发来的数据。内核上层首先会调用lwIP的netconn层的接口函数**netconn\_write**通过这个函数数据正式流进lwIP组件层。
接着netconn层调用lwIP组件的TCP层的接口函数tcp\_write在TCP层对数据首次进行打包。然后TCP层将打包好的数据通过调用io\_output函数向下传递给lwIP组件的IP层进行打包。
最后IP层将打包好的数据发送给网卡驱动接口层netif这里调用了实际的网卡驱动程序将数据发送出去。
![](https://static001.geekbang.org/resource/image/b3/2c/b31c19ff5c0f89729f0a3d2a42a2452c.jpg?wh=2455x3222 "数据发送逻辑")
### 数据接收
数据接收的步骤相比数据发送稍微多一些,但也不用害怕,跟住我的讲解思路一定可以理清这个过程。
数据接收需要应用程序首先调用lwIP的netconn层的netconn\_recv接口。然后由netconn层调用sys\_arch\_mbox\_fetch函数进入监听等待相关的mbox。
接着数据会进入网卡驱动程序相关的函数负责把它复制到内存。再然后是调用ethernet\_input函数进入ethernet层。完成相关处理后调用ip4\_input函数数据在lwIP组件的IP层对数据解包进行相应处理之后还会调用tcp\_input函数进入lwIP组件的TCP层对数据解包。
最后调用sys\_mbox\_trypost函数把数据放入特定的mbox也就是消息盒子里这样等待监听的应用程序就能得到数据了。
![](https://static001.geekbang.org/resource/image/d3/ef/d3e0530bb72990da0b56784a73e8ecef.jpg?wh=4030x2305 "数据接收逻辑")
在了解了lwIP组件收发数据的过程之后就可以进行移植的相关工作了。lwIP的结构设计非常优秀这让移植工作变得很容易。我们这里只要了解lwIP组件的**sys\_arch层的接口函数**即可。
下面我们一起了解lwIP的移植细节。
## 协议栈移植
lwIP有两种移植模式一种是NO\_SYS无操作系统模式一种是有操作系统模式。用NO\_SYS模式比较简单你可以自行探索。
操作系统模式主要需要基于操作系统的 IPC 机制,对网络连接进行了抽象(信号量、邮箱/队列、互斥体等机制从而保证内核与应用层API的通讯这样做的好处是**lwIP 内核线程可以只负责数据包的 TCP/IP 封装和拆封,而不用进行数据的应用层处理,从而极大地提高系统对网络数据包的处理效率。**
而这些操作系统模拟层的函数主要是在sys.h中声明的我们一般在sys\_arch.c文件中完成其定义。所以我们很清楚带操作系统的移植就是在无操作系统的基础上添加操作系统模拟层。
再接下来我们就看看操作系统模拟层的编写。
### 有操作系统模式
在之前的课程里我们已经正确实现了Cosmos操作系统了现在我们就可以在Cosmos系统提供的IPC等机制基础之上对照 sys.h 文件中声明的函数一一去实现了。
实际工程中完整移植网络栈需要将后面表格里的这30多个函数全部实现。我会带你完成邮箱和系统线程相关的关键部分移植其他函数的移植思路也大同小异这里就不一一演示了。
![](https://static001.geekbang.org/resource/image/51/ff/51791a1817fca811aaa1c0240c4135ff.jpg?wh=902x1198 "函数表格")
从上表中我们可以发现,这些变量和函数主要面向信号量、互斥体和邮箱,包括创建、删除、释放和获取等各种操作,所以我们需要根据操作系统的规定来实现这些函数。
突然看到这么多功能,是不是有点慌?其实不用怕,因为这些功能的实现起来非常简单。首先,我们通过一个例子来看看邮箱功能的实现。
在lwIP中用户代码通过邮箱与协议栈内部交互。邮箱本质上是指向数据的指针。API将指针传递给内核内核通过这个指针访问数据然后进行处理。相反内核也是通过邮箱将数据传递给用户代码的。
具体代码如下,关键内容我都做了详细注释。
```
/*创建一个空的邮箱。*/
err_t sys_mbox_new(sys_mbox_t *mbox, int size)
{
osMessageQDef(QUEUE, size, void *);
*mbox = osMessageCreate(osMessageQ(QUEUE), NULL);
#if SYS_STATS
++lwip_stats.sys.mbox.used;
if (lwip_stats.sys.mbox.max < lwip_stats.sys.mbox.used) {
lwip_stats.sys.mbox.max = lwip_stats.sys.mbox.used;
}
#endif /* SYS_STATS */
if (*mbox == NULL)
return ERR_MEM;
return ERR_OK;
}
/*重新分配一个邮箱。如果邮箱被释放时邮箱中仍有消息在lwIP中这是出现编码错误的指示并通知开发人员。*/
void sys_mbox_free(sys_mbox_t *mbox)
{
if( osMessageWaiting(*mbox) )
{
portNOP();
#if SYS_STATS
lwip_stats.sys.mbox.err++;
#endif /* SYS_STATS */
}
osMessageDelete(*mbox);
#if SYS_STATS
--lwip_stats.sys.mbox.used;
#endif /* SYS_STATS */
}
/*发送消息到邮箱*/
void sys_mbox_post(sys_mbox_t *mbox, void *data)
{
while(osMessagePut(*mbox, (uint32_t)data, osWaitForever) != osOK);
}
/*尝试将消息发送到邮箱*/
err_t sys_mbox_trypost(sys_mbox_t *mbox, void *msg)
{
err_t result;
if ( osMessagePut(*mbox, (uint32_t)msg, 0) == osOK)
{
result = ERR_OK;
}
else {
result = ERR_MEM;
#if SYS_STATS
lwip_stats.sys.mbox.err++;
#endif /* SYS_STATS */
}
return result;
}
/*阻塞进程从邮箱获取消息*/
u32_t sys_arch_mbox_fetch(sys_mbox_t *mbox, void **msg, u32_t timeout)
{
osEvent event;
uint32_t starttime = osKernelSysTick();;
if(timeout != 0)
{
event = osMessageGet (*mbox, timeout);
if(event.status == osEventMessage)
{
*msg = (void *)event.value.v;
return (osKernelSysTick() - starttime);
}
else
{
return SYS_ARCH_TIMEOUT;
}
}
else
{
event = osMessageGet (*mbox, osWaitForever);
*msg = (void *)event.value.v;
return (osKernelSysTick() - starttime);
}
}
/*尝试从邮箱获取消息*/
u32_t sys_arch_mbox_tryfetch(sys_mbox_t *mbox, void **msg)
{
osEvent event;
event = osMessageGet (*mbox, 0);
if(event.status == osEventMessage)
{
*msg = (void *)event.value.v;
return ERR_OK;
}
else
{
return SYS_MBOX_EMPTY;
}
}
/*判断一个邮箱是否有效*/
int sys_mbox_valid(sys_mbox_t *mbox)
{
if (*mbox == SYS_MBOX_NULL)
return 0;
else
return 1;
}
/*设置一个邮箱无效*/
void sys_mbox_set_invalid(sys_mbox_t *mbox)
{
*mbox = SYS_MBOX_NULL;
}
// 创建一个新的信号量。而 "count"参数指示该信号量的初始状态
err_t sys_sem_new(sys_sem_t *sem, u8_t count)
{
osSemaphoreDef(SEM);
*sem = osSemaphoreCreate (osSemaphore(SEM), 1);
if(*sem == NULL)
{
#if SYS_STATS
++lwip_stats.sys.sem.err;
#endif /* SYS_STATS */
return ERR_MEM;
}
if(count == 0) // Means it can't be taken
{
osSemaphoreWait(*sem,0);
}
#if SYS_STATS
++lwip_stats.sys.sem.used;
if (lwip_stats.sys.sem.max < lwip_stats.sys.sem.used) {
lwip_stats.sys.sem.max = lwip_stats.sys.sem.used;
}
#endif /* SYS_STATS */
return ERR_OK;
}
```
此外还有一些函数也是协议栈需要的函数特别是sys\_thread\_new函数不但协议栈在初始化时需要用到在后续我们实现各类基于lwIP的应用时也会用得到它的具体实现如下。
```
sys_thread_t sys_thread_new(const char *name, lwip_thread_fn thread , void *arg, int stacksize, int prio)
{
const osThreadDef_t os_thread_def = { (char *)name, (os_pthread)thread, (osPriority)prio, 0, stacksize};
return osThreadCreate(&os_thread_def, arg);
}
osThreadId osThreadCreate (const osThreadDef_t *thread_def, void *argument)
{
TaskHandle_t handle;
#if( configSUPPORT_STATIC_ALLOCATION == 1 ) && ( configSUPPORT_DYNAMIC_ALLOCATION == 1 )
if((thread_def->buffer != NULL) && (thread_def->controlblock != NULL)) {
handle = xTaskCreateStatic((TaskFunction_t)thread_def->pthread,(const portCHAR *)thread_def->name,
thread_def->stacksize, argument, makeFreeRtosPriority(thread_def->tpriority),
thread_def->buffer, thread_def->controlblock);
}
else {
if (xTaskCreate((TaskFunction_t)thread_def->pthread,(const portCHAR *)thread_def->name,
thread_def->stacksize, argument, makeFreeRtosPriority(thread_def->tpriority),
&handle) != pdPASS) {
return NULL;
}
}
#elif( configSUPPORT_STATIC_ALLOCATION == 1 )
handle = xTaskCreateStatic((TaskFunction_t)thread_def->pthread,(const portCHAR *)thread_def->name,
thread_def->stacksize, argument, makeFreeRtosPriority(thread_def->tpriority),
thread_def->buffer, thread_def->controlblock);
#else
if (xTaskCreate((TaskFunction_t)thread_def->pthread,(const portCHAR *)thread_def->name,
thread_def->stacksize, argument, makeFreeRtosPriority(thread_def->tpriority),
&handle) != pdPASS) {
return NULL;
}
#endif
return handle;
}
```
至此基于Cosmos操作系统移植lwIP协议栈的关键部分就算完成了。
## 重点回顾
好,这节课的内容告一段落了,我来给你做个总结。
我们首先从数据发送接收的视角,观察了数据从用户态到内核态,再从内核态到流动到用户态的全过程。
接着我们发现网络协议栈移植与DMA、内核的IPC、信号量、DMA等机制密切相关。理解网络栈移植的关键步骤能够让我们更好地理解内核特性在工程中是如何应用的。
最后我们实现了将lwIP网络协议栈的关键部分移植到Cosmos操作系统下。不过这节课我带你实现了邮箱和系统线程相关的关键部分其他函数移植道理相通感兴趣的同学可以自行探索。
## 思考题
我们已经了解到了操作系统内核和网络协议栈的关系,可是网络协议栈真的一定只能放在内核态实现么?
欢迎你在留言区跟我交流探讨。也欢迎你把这节课分享给自己的朋友、同事。
我是LMOS我们下节课见