4. 添加网卡
移植的最后一步就是编写网卡驱动然后将网卡添加到协议栈。网卡驱动其本质上完成的是数据链路层的工作,在整个通讯链路上处于通讯枢纽位置,通讯报文的发送和接收均由其实际完成。针对网卡部分的移植工作共三步:
1)编写网卡驱动;
2)注册网卡到协议栈;
3)对接网卡数据收发接口;
协议栈目前支持两种网卡类型:ethernet和ppp。两种网卡的移植工作虽然步骤一样,但具体移植细节还是有很大区别的,需要分开单独进行。
4.1 ethernet网卡
从移植的角度看,ethernet网卡驱动要提供三个接口函数并完成与协议栈的对接:
1)网卡初始化函数,完成网卡初始及启动工作,并将其添加到协议栈;
2)网卡发送函数,发送上层协议传递的通讯报文到对端;
3)网卡接收函数,接收到达的通讯报文并传递给上层协议;
对于网卡初始化函数,其要做的工作用一句话总结就是:参照网卡数据手册对其进行配置,然后将其注册到协议栈:
#define DHCP_REQ_ADDR_EN 1 //* dhcp请求ip地址使能宏
static PST_NETIF l_pstNetifEth = NULL; //* 协议栈返回的netif结构
int ethernet_init(void)
{
/* 进行初始配置,比如引脚配置、使能时钟、相关工作参数配置等工作 */
//* 在这里添加能够完成上述工作的相关代码,请参照目标网卡的技术手册编写
……
……
/* 到这里网卡配置工作完成,但还未启动 */
//* 添加网卡到协议栈,一定要注意启动以太网卡之前一定要先将其添加到协议栈
EN_ONPSERR enErr;
ST_IPV4 stIPv4;
#if !DHCP_REQ_ADDR_EN
//* 分配一个静态地址,请根据自己的具体网络情形设置地址
stIPv4.unAddr = inet_addr_small("192.168.0.4");
stIPv4.unSubnetMask = inet_addr_small("255.255.255.0");
stIPv4.unGateway = inet_addr_small("192.168.0.1");
stIPv4.unPrimaryDNS = inet_addr_small("1.2.4.8");
stIPv4.unSecondaryDNS = inet_addr_small("8.8.8.8");
stIPv4.unBroadcast = inet_addr_small("192.168.0.255");
#else
//* 地址清零,为dhcp客户端申请动态地址做好准备
memset(&stIPv4, 0, sizeof(stIPv4));
#endif
//* 注册网卡,也就是将网卡添加到协议栈
l_pstNetifEth = ethernet_add(……);
if(!l_pstNetifEth)
{
#if SUPPORT_PRINTF
printf("ethernet_add() failed, %s\r\n", onps_error(enErr));
#endif
return -1;
}
//* 启动网卡,开始工作,在这里添加与目标网卡启动相关的代码
……
#if DHCP_REQ_ADDR_EN
//* 启动一个dhcp客户端,从dhcp服务器申请一个动态地址
if(dhcp_req_addr(l_pstNetifEth, &enErr))
{
#if SUPPORT_PRINTF
printf("dhcp request ip address successfully.\r\n");
#endif
}
else
{
#if SUPPORT_PRINTF
printf("dhcp request ip address failed, %s\r\n", onps_error(enErr));
#endif
}
#endif
return 0;
}
上面给出的样例代码中,省略的部分是与目标系统相关的网卡初始配置代码,其余则是与协议栈有关的网卡注册代码。这部分代码主要是完成了两块工作:一,注册网卡到协议栈;二,指定或申请一个静态/动态地址。注册网卡的工作是由协议栈提供的ethernet_add()函数完成的,其详细说明如下:
//* 注册ethernet网卡到协议栈,只有如此协议栈才能正常使用该网卡进行数据通讯。
//* pszIfName:网卡名称
//* ubaMacAddr:网卡mac地址
//* pstIPv4:指向ST_IPV4结构体的指针(include/netif/netif.h),这个结构体保存用户指定的ip地址、网关、dns、子网掩码等配置信息
//*
//* pfunEmacSend:函数指针,指向发送函数,函数原型为INT(* PFUN_EMAC_SEND)(SHORT sBufListHead, UCHAR *pubErr),这个指针指向的其实
//* 就是网卡发送函数
//*
//* pfunStartTHEmacRecv:函数指针,协议栈使用该函数启动网卡接收线程,该线程为协议栈内部工作线程,用户移植时只需提供启动该线程的接口函数即可
//*
//* ppstNetif:二维指针,协议栈成功注册网卡后ethernet_add()函数会返回一个PST_NETIF指针给调用者,这个参数指向这个指针,其最终会被
//* 协议栈通过pvParam参数传递给pfunStartTHEmacRecv指向的函数
//*
//* penErr:如果注册失败,ethernet_add()函数会返回一个错误码,这个参数用于接收这个错误码
//*
//* 注册成功,返回一个PST_NETIF类型的指针,后续的报文收发均用到这个指针;注册失败则返回NULL。具体错误信息参见penErr参数携带的错误码。
PST_NETIF ethernet_add(const CHAR *pszIfName, const UCHAR ubaMacAddr[ETH_MAC_ADDR_LEN], PST_IPV4 pstIPv4, PFUN_EMAC_SEND pfunEmacSend,
void (*pfunStartTHEmacRecv)(void *pvParam), PST_NETIF *ppstNetif, EN_ONPSERR *penErr);
ethernet_add()函数提供的参数看起来较为复杂,但其实就完成了一件事情:告诉协议栈这个新增加的网卡的相关身份信息及功能接口,包括名称、地址、数据读写接口等。这个函数有两个地方需要特别说明:一个是样例代码中该函数的返回值保存在了一个静态存储时期的变量l_pstNetifEth中;另一个是入口参数pfunStartTHEmacRecv。前一个用于接收注册成功后返回的PST_NETIF指针;后一个则是需要提供一个线程启动函数,启动协议栈内部的以太网接收线程thread_ethernet_ii_recv(),该线程在协议栈源码ethernet.c文件中实现。PST_NETIF指针非常重要,它是网卡能够正常工作的关键。报文收发均用到这个指针。它的生命周期应该与协议栈的生命周期相同,因此这个指针变量在上面的样例代码中被定义成一个静态存储时期的变量,并确保网卡的接收、发送函数均能访问。pfunStartTHEmacRecv参数指向的函数要实现的功能与前面我们编写的os适配层函数os_thread_onpstack_start()相同,其就是调用os提供的线程启动函数启动thread_ethernet_ii_recv()线程。比如rt-thread下:
#define THETHIIRECV_PRIO 21 //* ethernet网卡接收线程(任务)优先级
#define THETHIIRECV_STK_SIZE 384 * 4 //* 接收线程栈大小,这个栈要相对大一些,太小会报错
#define THETHIIRECV_TIMESLICE 10 //* 单次任务调度线程能够工作的最大时间片
static void start_thread_ethernet_ii_recv(void *pvParam)
{
rt_thread_t tid = rt_thread_create("EthRcv", thread_ethernet_ii_recv, pvParam, THETHIIRECV_STK_SIZE, THETHIIRECV_PRIO, THETHIIRECV_TIMESLICE);
if(tid != RT_NULL)
rt_thread_startup(tid);
}
其余os与之类似。我们启动的这个以太网接收线程完成实际的以太网层的报文接收及处理工作。其轮询等待网卡接收中断函数发送的报文到达信号,收到信号则立即读取并处理到达的报文。我们在后面讲述网卡接收函数的移植细节时还会提到这个接收线程。
对于网卡发送函数,有一点需要注意的是——其原型必须符合协议栈的要求,因为我们在进行网卡注册时还要向协议栈注册发送函数的入口地址。前面在介绍ethernet_add()注册函数时我们已经给出了发送函数的原型定义,也就是pfunEmacSend参数指向的函数原型。协议栈的目标系统是资源受限的单片机系统,为了最大限度节省内存,协议栈采用了写时零复制(zero copy)技术,网卡发送函数需要结合协议栈的buf list机制编写实现代码,其伪代码实现如下:
int ethernet_send(SHORT sBufListHead, UCHAR *pubErr)
{
SHORT sNextNode = sBufListHead;
UCHAR *pubData;
USHORT usDataLen;
//* 调用buf_list_get_len()函数计算当前要发送的ethernet报文长度,其由协议栈提供
UINT unEthPacketLen = buf_list_get_len(sBufListHead);
//* 逐个取出buf list节点发送出去
__lblGetNextNode:
pubData = (UCHAR *)buf_list_get_next_node(&sNextNode, &usDataLen); //* 获取下一个节点,buf_list_get_next_node()函数由协议栈提供
if (NULL == pubData) //* 返回空意味着已经到达链表尾部,没有要发送的数据了,直接返回就可以了
return (int)unEthPacketLen;
//* 启动发送,将取出的数据发送出去,其中pubData指向要发送的数据,usDataLen为要发送的数据长度,这两个值已经通过buf_list_get_next_node()函数得到
//* 在这里添加与具体目标网卡相关的数据发送代码
……
//* 取下一个数据节点
goto __lblGetNextNode;
}
关于buf list,其实现机制其实很简单。以udp通讯为例,用户要发送数据到对端,会直接调用udp发送函数,将数据传递给udp层。udp层收到用户数据后,为了节省内存,避免复制,协议栈直接将用户数据挂接到buf list链表上成为链表的数据节点。接着,udp层会再申请一个节点把udp报文头挂接到数据节点的前面,组成一个拥有两个节点的完整udp报文链表——链表第一个节点挂载udp报文头,第二个节点挂载用户要发送的数据。至此,udp层的报文封装工作完成,数据继续向ip层传递。ip层会继续申请一个节点把ip报文头挂接到udp报文头节点的前面,组成一个拥有三个节点的完整ip 报文链表。ip报文在ip层经过路由选择后被送达数据链路层,也就是ethernet层。在这一层,协议栈再将ethernet ii报文头挂接到ip报文头节点的前面。至此,整个报文的封装完成。协议栈此时会根据网卡注册信息调用对应网卡的ethernet_send()函数将报文发送出去。ethernet_send()函数的核心处理逻辑就是按照上述机制再依序取出链表节点携带的各层报文数据,然后顺序发送出去。
网卡移植拼图的最后一块就是完成网卡接收函数,把网卡收到的数据推送给协议栈。其伪代码实现如下:
//* 网卡接收函数,可以是接收中断服务子函数,也可以是普通函数,普通函数必须确保能够在数据到达的第一时间就能读取并推送给协议栈
void ethernet_recv(void)
{
EN_ONPSERR enErr;
unsigned int unPacketLen;
unsigned char *pubRcvedPacket;
//* 在这里添加与具体网卡相关的代码,等待接收报文到达,如果数据到达将报文长度赋值unPacketLen变量,将报文首地址赋值给pubRcvedPacket
……
……
//* 读取到达报文并将其推送给协议栈进行处理,首先利用协议栈mmu模块动态申请一块内存用于保存到达的报文
unsigned char *pubPacket = (UCHAR *)buddy_alloc(sizeof(ST_SLINKEDLIST_NODE) + unPacketLen, &enErr);
//* 申请成功,根据协议栈要求,刚才申请的内存按照PST_SLINKEDLIST_NODE链表节点方式组织并保存刚刚收到的报文
PST_SLINKEDLIST_NODE pstNode = (PST_SLINKEDLIST_NODE)pubPacket;
pstNode->uniData.unVal = unPacketLen;
memcpy(pubPacket + sizeof(ST_SLINKEDLIST_NODE), (UCHAR *)pubRcvedPacket, unPacketLen);
//* 将上面组织好的报文节点放入接收链表,这个接收链表由协议栈管理,ethernet_put_packet()函数由协议栈提供
//* thread_ethernet_ii_recv()接收线程负责等待ethernet_put_packet()函数投递的信号并读取这个链表
//* 参数l_pstNetifEth为前面注册网卡时由协议栈返回的PST_NETIF指针值
ethernet_put_packet(l_pstNetifEth, pstNode);
}
其中buddy_alloc()函数在功能上与c语言的标准库函数malloc()完全相同,都是动态分配一块指定大小的内存给调用者使用,使用完毕后再由用户通过buddy_free()函数释放。这两个函数由协议栈的内存管理(mmu)模块提供。ethernet_put_packet()函数需要重点解释一下。这个函数由协议栈提供。它完成的工作非常重要,它在网卡接收函数与协议栈以太网接收线程thread_ethernet_ii_recv()之间搭建了一个数据流通的“桥”。接收函数收到报文后按照协议栈要求,将报文封装成ST_SLINKEDLIST_NODE类型的链表节点,然后传递给ethernet_put_packet()函数。该函数将立即把传递过来的节点挂载到由协议栈管理的以太网接收链表的尾部,然后投递一个“有新报文到达”的信号量。前文提到的以太网接收线程thread_ethernet_ii_recv()会轮询等待这个信号量。一旦信号到达,接收线程将立即读取链表并取出报文交给协议栈处理。
至此,ethernet网卡相关的移植工作完成。
4.2 ppp拨号网卡
在Linux系统,2g/4g/5g模块作为一个通讯终端,驱动层会把它当作一个tty设备来看待。Linux下ppp栈也是围绕着操作一个标准的tty设备来实现底层通讯逻辑的。至于如何操作这个ppp拨号终端进行实际的数据收发tty层并不关心。所以,协议栈完全借鉴了这个成功的设计思想,在底层驱动与拨号终端之间增加了一个tty层,将具体的设备操作与上层的业务逻辑进行了剥离。ppp拨号网卡的的移植工作其实就是完成tty层到底层驱动的封装工作。协议栈利用句柄来唯一的标识一个tty设备。在os_datatype.h文件中定义了这个句柄类型:
#if SUPPORT_PPP
typedef INT HTTY; //* tty终端句柄
#define INVALID_HTTY -1 //* 无效的tty终端句柄
#endif
这个句柄类型非常重要,所有与tty操作相关的函数都要用到这个句柄类型。tty层要完成的驱动封装工作涉及的函数原型定义依然是在os_adapter.h文件中:
#if SUPPORT_PPP
//* 打开 tty 设备,返回 tty 设备句柄,参数 pszTTYName 指定要打开的 tty 设备的名称
OS_ADAPTER_EXT HTTY os_open_tty(const CHAR *pszTTYName);
//* 关闭 tty 设备,参数 hTTY 为要关闭的 tty 设备的句柄
OS_ADAPTER_EXT void os_close_tty(HTTY hTTY);
//* 向 hTTY 指定的 tty 设备发送数据,返回实际发送的数据长度
//* hTTY:设备句柄
//* pubData:指针,指向要发送的数据的指针
//* nDataLen:要发送的数据长度
//* 返回值为实际发送的字节数
OS_ADAPTER_EXT INT os_tty_send(HTTY hTTY, UCHAR *pubData, INT nDataLen);
//* 从参数 hTTY 指定的 tty 设备等待接收数据,阻塞型
//* hTTY:设备句柄
//* pubRcvBuf:指针,指向数据接收缓冲区的指针,用于保存收到的数据
//* nRcvBufLen:接收缓冲区的长度
//* nWaitSecs:等待的时长,单位:秒。0 一直等待;直至收到数据或报错,大于 0,等待指定秒数;小于 0,不支持
//* 返回值为实际收到的数据长度,单位:字节
OS_ADAPTER_EXT INT os_tty_recv(HTTY hTTY, UCHAR *pubRcvBuf, INT nRcvBufLen, INT nWaitSecs);
//* 复位 tty 设备,这个函数名称体现了2g/4g/5g模块作为tty设备的特殊性,其功能从本质上看就是一个 modem,modem 设备出现通讯
//* 故障时,最好的修复故障的方式就是直接复位,复位可以修复绝大部分的因软件问题产生的故障
OS_ADAPTER_EXT void os_modem_reset(HTTY hTTY);
#endif
依据上述函数的原型定义及功能说明,我们在os_adapter.c文件编码实现它们,相关伪代码实现如下:
#if SUPPORT_PPP
HTTY os_open_tty(const CHAR *pszTTYName)
{
//* 如果你的系统存在多个ppp拨号终端,那么pszTTYName参数用于区分打开哪个串口
//* 在这里添加串口打开代码将连接拨号终端的串口打开
……
……
//* 如果目标系统只有一个拨号终端,那么这里返回的tty句柄x值为0,如果目标系统存在多个模块,这里需要你根据参数
//* pszTTYName指定的名称来区分是哪个设备,并据此返回不同的tty句柄,句柄值x应从0开始自增,步长为1
return x;
}
void os_close_tty(HTTY hTTY)
{
//* 在这里添加串口关闭代码,关闭哪个串口的依据是tty设备句柄hTTY
……
}
INT os_tty_send(HTTY hTTY, UCHAR *pubData, INT nDataLen)
{
//* 在这里添加数据发送代码,其实就是调用对应的串口驱动函数发送数据到拨号终端,如果存在多个tty设备,请依据参数hTTY来
//* 确定需要调用哪个串口驱动函数发送数据,返回值为实际发送的字节数
……
}
INT os_tty_recv(HTTY hTTY, UCHAR *pubRcvBuf, INT nRcvBufLen, INT nWaitSecs)
{
//* 同上,在这里添加数据读取代码,其实就是调用对应的串口驱动函数从拨号终端读取数据,如果存在多个tty设备,请依据参数hTTY来
//* 确定需要调用哪个串口驱动函数读取数据,返回值为实际读取到的字节数
……
}
void os_modem_reset(HTTY hTTY)
{
//* 在这里添加拨号终端的复位代码,如果你的目标板不支持软件复位模块,可以省略这一步
//* 复位模块的目的是解决绝大部分的因软件问题产生的故障
……
}
#endif
参照上述伪代码,依据目标系统具体情况编写相应功能代码即可。注意,上述代码能够正常工作的关键是目标系统的串口驱动必须能够正常工作且健壮、可靠。因为tty层封装的其实就是操作ppp拨号终端的串口驱动代码,tty只是做了一层简单封装罢了。os_adapter.c文件中关于ppp部分还有如下几项定义需要根据你的实际目标环境进行配置:
#if SUPPORT_PPP
//* 连接ppp拨号终端的串口名称,有几个模块,就指定几个,其存储的单元索引应等于os_open_tty()返回的对应串口的tty句柄值
const CHAR *or_pszaTTY[PPP_NETLINK_NUM] = { …… /* 如"串口1", "串口2"等 */ };
//* 指定ppp拨号的apn、用户和密码,系统支持几路ppp,就需要指定几组拨号信息
//* ST_DIAL_AUTH_INFO结构体保存这几个信息,该结构体的详细内容参见协议栈源码ppp/ppp.h文件
//* 这里设置的apn等拨号认证信息会替代前面说过的APN_DEFAULT、AUTH_USER_DEFAULT、AUTH_PASSWORD_DEFAULT等缺省设置
const ST_DIAL_AUTH_INFO or_staDialAuth[PPP_NETLINK_NUM] = {
{ "4gnet", "card", "any_char" }, //* 注意ppp账户和密码尽量控制在20个字节以内,太长需要需要修改chap.c
//* 中send_response()函数的szData数组容量及pap.c中pap_send_auth_request()函数的
//* ubaPacket数组的容量,确保其能够封装一个完整的响应报文
/* 系统存在几路ppp链路,就在这里添加几路拨号认证信息 */
};
//* ppp链路协商的初始协商配置信息,协商成功后这里保存最终的协商结果,ST_PPPNEGORESULT结构体的详细说明参见下文
ST_PPPNEGORESULT o_staNegoResult[PPP_NETLINK_NUM] = {
{
{ 0, PPP_MRU, ACCM_INIT,{ PPP_CHAP, 0x05 /* CHAP协议,0-4未使用,0x05代表采用MD5算法 */ }, TRUE, TRUE, FALSE, FALSE },
{ IP_ADDR_INIT, DNS_ADDR_INIT, DNS_ADDR_INIT, IP_ADDR_INIT, MASK_INIT }, 0
},
/* 系统存在几路ppp链路,就在这里添加几路的协商初始值,如果不确定,可以将上面预定义的初始值直接复制过来即可 */
};
#endif
上面给出的代码做了几件事情:
1)指定tty设备连接的串口名称;
2)指定拨号认证信息:apn、用户和密码;
3)指定ppp链路协商初始值;
总之,你的目标系统连接了几个拨号终端,这几件事情就要针对特定的终端分别做一遍,单独指定。这里需要重点说明的是ppp链路协商配置信息。这些信息由ST_PPPNEGORESULT结构体保存(参见negotiation_storage.h文件):
typedef struct _ST_PPPNEGORESULT_ {
struct {
UINT unMagicNum; //* 幻数(魔术字)
USHORT usMRU; //* 最大接收单元,缺省值由PPP_MRU宏指定,一般为1500字节
UINT unACCM; //* ACCM,异步控制字符映射,指定哪些字符需要转义,如果不确定,建议采用ACCM_INIT宏指定的缺省值
struct { //* 保存认证信息的结构体
USHORT usType; //* 指定认证类型:chap或pap,缺省chap认证
UCHAR ubaData[16]; //* 认证报文携带的数据,不同的协议携带的数据类型不同,一般情况下采用协议栈的缺省值即可
} stAuth;
BOOL blIsProtoComp; //* 是否采用协议域压缩(本地设置项,代表协议栈一侧,协商结果不影响该字段)
BOOL blIsAddrCtlComp; //* 是否采用地址及控制域压缩(本地设置项,代表协议栈一侧,协商结果不影响该字段)
BOOL blIsNegoValOfProtoComp; //* 协议域是否压缩的协商结果值(远端设置项,代表对端是否支持该配置,协商结果影响该字段)
BOOL blIsNegoValOfAddrCtlComp; //* 地址及控制域是否压缩的协商结果值(远端设置项,同上)
} stLCP;
//* 存储ppp链路的初始及协商成功后的地址信息
struct {
UINT unAddr; //* ip地址,初始值由协议栈提供的IP_ADDR_INIT宏指定,不要擅自修改
UINT unPrimaryDNS; //* 主dns服务器地址,初始值由协议栈提供的DNS_ADDR_INIT宏指定,不要擅自修改
UINT unSecondaryDNS; //* 次dns服务器地址,初始值由协议栈提供的DNS_ADDR_INIT宏指定,不要擅自修改
UINT unPointToPointAddr; //* 点对点地址,初始值由协议栈提供的IP_ADDR_INIT宏指定,不要擅自修改
UINT unSubnetMask; //* 子网掩码
} stIPCP;
UCHAR ubIdentifier; //* 标识域,从0开始自增,唯一的标识一个ppp报文,用于确定应答报文
UINT unLastRcvedSecs; //* 最近一次收到对端报文时的秒数,其用于ppp链路故障探测,无需关心,协议栈底层使用
} ST_PPPNEGORESULT, *PST_PPPNEGORESULT;
基本上,要调整的地方几乎没有,我们直接采用缺省值即可。
移植工作的最后一步就是把ppp网卡的主处理线程thread_ppp_handler()添加到os适配层的工作线程列表中。也就是前面讲解os适配层移植工作时提到的lr_stcbaPStackThread数组。这个数组保存了协议栈内部工作线程列表,我们先前已经添加了one-shot定时器工作线程thread_one_shot_timer_count()。我们再把ppp主处理线程添加到这个数组中即可。伪代码实现如下:
//* 协议栈内部工作线程列表
const static STCB_PSTACKTHREAD lr_stcbaPStackThread[] = {
{ thread_one_shot_timer_count, NULL},
#if SUPPORT_PPP
//* 在此按照顺序建立ppp工作线程,入口函数为thread_ppp_handler(),线程入口参数为os_open_tty()返回的tty句柄值
//* 其直接强行进行数据类型转换即可,即作为线程入口参数时直接以如下形式传递:
//* (void *)句柄值
//* 不要传递参数地址,即(void *)&句柄,这种方式是错误的
#endif
};
ppp主处理线程将在协议栈加载时由os适配层函数os_thread_onpstack_start()启动。在这里只需把其添加到工作线程列表中即可,剩下的交由协议栈自动处理。在这里需要特别说明的是主处理线程的入口参数为tty句柄。其值应直接传递给线程,不能传递句柄地址(参见上面的伪代码注释)。比如实际移植到目标系统时如果系统只存在一路ppp,os_open_tty()返回的tty句柄值为0,那么添加到工作线程列表中的ppp主处理线程入口参数的值应为“(void *)0”。不用关心前面的“(void *)”,这段数据类型强制转换代码只是为了避免编译器报错。ppp链路建立成功后,协议栈会以“ppp+tty句柄”的方式命名该链路,命名时的tty句柄值就是通过这个启动参数获得的,所以这个值一定要配置正确。对于单路ppp,由于tty句柄值为0,所以ppp链路的名称为“ppp0”。
至此,ppp网卡相关的移植工作完成。