从源码角度剖析tcp/ip---ip协议(1)
庖丁解牛,从源码角度来深入tcp/ip。----《TCP/IP详解 卷2:实现》。
一、简介和介绍
ip协议的是tcp和udp的根本,一般我们只需要了解子网划分,路由转发机制即可。但是底层是怎么实现的呢?毫无疑问,一个顶级的服务端工程师应该从根本从把知识点剖析清楚。下面就让我们从源码的角度解剖ip协议。(关于netinet中的函数,现在的linux代码已经重构过了,所以采用4.4BSD版本的代码。)
(我找了很久)Github地址:https://github.com/neilss/4.4BSD-Lite
《TCP/IP详解 卷2:实现》在第四章中阐述了一个数据包通过网络接口发生硬件中断时,数据包就会放进iprinrq队列中,如上图。而文章主要讲述的是在路由器中,ip层的函数是如何实现的,大体的组织形式如下。
image.png如果是路由器,当分组来到ipintrq并且发生软件中断时
- iprintrq中的分组数据就会传递到ipintr函数让其进行分组验证,转发等等的操作。
- ip_forward会根据ip分段和路由表来定位下一条。
- 最后在ip_ouput就是构造首部,选择路由和分片。
如果是主机的话,数据来到iprintr的时候就会包ip报文传送都网络层。熟悉osi7层结构的应该很清楚,这里就不再阐述了。
一、iprintr
我们可以知道当软中断发生的时候,内核就会调用iprintr把数据包从队列中获取出来。这个函数比较复杂。首先我们先看一下iprintr函数的开头,代码如下。
void
ipintr()
{
register struct ip *ip;
register struct mbuf *m;
register struct ipq *fp;
register struct in_ifaddr *ia;
int hlen, s;
next:
/*
* Get next datagram off input queue and get IP header
* in first mbuf.
*/
s = splimp();
IF_DEQUEUE(&ipintrq, m);
splx(s);
if (m == 0)
return;
...
我们可以看到iprintr调用IF_DEQUEUE从队列中获取分组数据,iprintr从iprintrq中移走分组,并对以处理直到整个队列为空为止。然后接下来就是分别对分组进行验证,选项处理和转发,重装和分用。
1.分组验证
把分组从ipintrq中取出,验证它们对内容之后。损坏可有差错的分组会被自动丢弃。
1.1. 验证ip版本
if (in_ifaddr == NULL)
goto bad;
ipstat.ips_total++;
if (m->m_len < sizeof (struct ip) &&
(m = m_pullup(m, sizeof (struct ip))) == 0) {
ipstat.ips_toosmall++;
goto next;
}
ip = mtod(m, struct ip *);
if (ip->ip_v != IPVERSION) {
ipstat.ips_badvers++;
goto bad;
}
当网关接口没有配置好的时候,ip地址会为空。所以分组来到这里的时候就会被中断,跳到bad。可以看到在4.4的BSD实现中,ip版本必须是ipv4的。不过现在已经支持ipv6了。
1.2. IP校验和
if (ip->ip_sum = in_cksum(m, hlen)) {
ipstat.ips_badsum++;
goto bad;
}
一个完整的IP数据包必须要有完整的校验和,我们可以看到内核已经封装了一个in_cksum来进行校验。
校验和检验是一个很耗时的操作,关于这个in_cksum函数的实现其实有很多优化的地方。这里有很多论文才研究这个算法。这里就不展开了。
1.3. 字节顺序
个人感觉底层最无趣,也是最麻烦的地方就是字节问题。因为操蛋的主机有大端跟小端之分,网络字节顺序跟主机字节顺序不一致的问题贼麻烦。不过操作系统已经帮我们解决了,感谢stevens,以及各个位linux贡献的大神们。
NTOHS(ip->ip_len);
if (ip->ip_len < hlen) {
ipstat.ips_badlen++;
goto bad;
}
NTOHS(ip->ip_id);
NTOHS(ip->ip_off);
首先把几个16bit的值先转为主机顺序,内核封装了一个宏NTOHS来进行转换。如果首部长度不满足要求,那么就会跳到bad分支。
1.4 分组长度
if (m->m_pkthdr.len < ip->ip_len) {
ipstat.ips_tooshort++;
goto bad;
}
if (m->m_pkthdr.len > ip->ip_len) {
if (m->m_len == m->m_pkthdr.len) {
m->m_len = ip->ip_len;
m->m_pkthdr.len = ip->ip_len;
} else
m_adj(m, ip->ip_len - m->m_pkthdr.len);
}
分组的长度是由链路的最小mtu决定,所以是有可能出现分组逻辑长度大于mbuf的数据量的(mbuf是tcp/ip底层存储数据的数据结构,参考《TCP/IP 详解卷2 实现》第一章)
2.选项处理与转发
选项处理实在是太过复杂,这里是介绍不了这么多的。IP数据包有40个字节存储选项,仅仅是RFC定义的IP选项就有8个。我实在是没有精力去看这里了,除非我要做协议栈。至于转发就比较好理解,就是根据根据Internet地址表,决定是否有分组目的地匹配的地址。
/*
* Check our list of addresses, to see if the packet is for us.
*/
for (ia = in_ifaddr; ia; ia = ia->ia_next) {
#define satosin(sa) ((struct sockaddr_in *)(sa))
if (IA_SIN(ia)->sin_addr.s_addr == ip->ip_dst.s_addr)
goto ours;
if (
#ifdef DIRECTED_BROADCAST
ia->ia_ifp == m->m_pkthdr.rcvif &&
#endif
(ia->ia_ifp->if_flags & IFF_BROADCAST)) {
u_long t;
if (satosin(&ia->ia_broadaddr)->sin_addr.s_addr ==
ip->ip_dst.s_addr)
goto ours;
if (ip->ip_dst.s_addr == ia->ia_netbroadcast.s_addr)
goto ours;
t = ntohl(ip->ip_dst.s_addr);
if (t == ia->ia_subnet)
goto ours;
if (t == ia->ia_net)
goto ours;
}
}
if (IN_MULTICAST(ntohl(ip->ip_dst.s_addr))) {
struct in_multi *inm;
#ifdef MROUTING
extern struct socket *ip_mrouter;
if (ip_mrouter) {
ip->ip_id = htons(ip->ip_id);
if (ip_mforward(m, m->m_pkthdr.rcvif) != 0) {
ipstat.ips_cantforward++;
m_freem(m);
goto next;
}
ip->ip_id = ntohs(ip->ip_id);
if (ip->ip_p == IPPROTO_IGMP)
goto ours;
ipstat.ips_forward++;
}
#endif
IN_LOOKUP_MULTI(ip->ip_dst, m->m_pkthdr.rcvif, inm);
if (inm == NULL) {
ipstat.ips_cantforward++;
m_freem(m);
goto next;
}
goto ours;
}
当然了,这里选取下一跳的代码在卷1中是有比较详细的说明的。
2.重装代码
我们可以知道,从网关得到的数据包是已经被分片了的。所以iprintr函数最后是需要把代码重装的。要理解如何重装,首先要理解分片后的ip数据包。如下图。
感觉说再多也不够上图直观,IP分片就是把原来的IP报文分割成若干个更小的IP报文,但是每一个小报文需要重新添加IP首部。然而要对分片重装远比对IP分片复杂得多。再下一篇文章再总结。
回到第一个函数,iprintr函数主要是做验证,处理,重装和分用等等功能。其中转发和重装逻辑很复杂,日后再详细总结。
二、ip_forward函数
这个函数主要是用来对重装后的代码进行重装,不过我不知道为啥在iprintr函数中还要查一遍地址表。=。=#。这个函数主要有三个用途:
1)判断分组转发的合法性
2)减少TTL
3)定位下一跳
void
ip_forward(m, srcrt)
struct mbuf *m;
int srcrt;
{
register struct ip *ip = mtod(m, struct ip *);
register struct sockaddr_in *sin;
register struct rtentry *rt;
int error, type = 0, code;
struct mbuf *mcopy;
n_long dest;
struct ifnet *destifp;
dest = 0;
#ifdef DIAGNOSTIC
if (ipprintfs)
printf("forward: src %x dst %x ttl %x\n", ip->ip_src,
ip->ip_dst, ip->ip_ttl);
#endif
if (m->m_flags & M_BCAST || in_canforward(ip->ip_dst) == 0) {
ipstat.ips_cantforward++;
m_freem(m);
return;
}
HTONS(ip->ip_id);
if (ip->ip_ttl <= IPTTLDEC) {
icmp_error(m, ICMP_TIMXCEED, ICMP_TIMXCEED_INTRANS, dest, 0);
return;
}
ip->ip_ttl -= IPTTLDEC;
第一个用途不用解释了,简而言之就是对“链路层广播,环回广播或者其他寻址查询参数是否正确“。
至于第二点,我们看下面代码。
if (ip->ip_ttl <= IPTTLDEC) {
icmp_error(m, ICMP_TIMXCEED, ICMP_TIMXCEED_INTRANS, dest, 0);
return;
}
ip->ip_ttl -= IPTTLDEC;
系统是不接受TTL为0的数据包的,因为每一跳都约定了TTL要减少至少1s,所以实现中就减少IPTTLDEC(宏为1)。如果小于1,那么就向源地址发送ICMP超时报文。
第三点,定位下一跳。
我们看下面代码。
sin = (struct sockaddr_in *)&ipforward_rt.ro_dst;
if ((rt = ipforward_rt.ro_rt) == 0 ||
ip->ip_dst.s_addr != sin->sin_addr.s_addr) {
if (ipforward_rt.ro_rt) {
RTFREE(ipforward_rt.ro_rt);
ipforward_rt.ro_rt = 0;
}
sin->sin_family = AF_INET;
sin->sin_len = sizeof(*sin);
sin->sin_addr = ip->ip_dst;
rtalloc(&ipforward_rt);
if (ipforward_rt.ro_rt == 0) {
icmp_error(m, ICMP_UNREACH, ICMP_UNREACH_HOST, dest, 0);
return;
}
rt = ipforward_rt.ro_rt;
}
上面那段代码是检查是否是需要发送重定向报文。出现重定向的原因是上一台主机的路由表太久了,产生了错误的转发。接下来就是选择合适路由器来发送差错报文。
三、ip_output(略)
四、总结
ip协议的处理中,首先是ipintr函数对分组报文进行验证和重装;然后ip_forward对数据包进行转发和定位;最后ip_output对数据包进行分片发送。
源码我花了不少时间去找,最后意识到源码是4.4BSD标准的,跟linux版本无关。本来想说清楚的,但是限于篇幅和自己的理解问题,比较难展开。个人感觉要理解tcp/ip,卷1就可以了。但是要深入tcp/ip,要把socket用好,用透,还是需要从源码上去阅读和理解。
如果说C++是一个骄傲的信仰,那么就让我执着一次自己的信仰吧。
image.png