一:Linux系统如何实现收发包
1:接收网络数据包
当网卡收到数据,通过DMA操作讲数据写入内存(ringbuffer结构),然后通过内核ksoftirqd线程负责中断处理,讲从ringbuffer中取出数据帧放到sk_buffer中,通过判断IP头来放到socket的接收缓冲区中,然后通过系统调用讲socket的接收缓冲区数据拷贝到应用层缓冲区。
2:发送网络数据包
应用程序通过系统调用,将用户的数据进行拷贝把sk_buffer放到socket的发送缓冲区中,然后网络协议栈从socket的发送缓冲区中取出sk_buffer,并克隆一个sk_buffer,然后网络驱动程序从发送队列中依次取出sk_buffer写到ringbuffer中去,最后触发发送操作。
二:网络缓冲区是什么?为什么需要?
1:网络缓冲区是什么?
网络缓冲区是在网络通信过程中,用于临时存储网络数据的一块内存区域。它的存在是为了解决网络数据传输时速度不匹配的问题,以及为了优化数据传输的效率和可靠性。缓存未构成一个完整包的数据,缓存用户层未处理的数据。
2:为什么需要网络缓冲区?
在读缓冲区中,如果对方向我方发送一条数据,但是这一条很长,被网络协议栈给分成两条发送过来了,而且发送的间隔很长,那么我方收到的第一条数据,就不是一条完整的数据包,那这个不完整的数据包,很有可能你就看不懂。而且当对方发送的数据很快,两秒给你发送一个小作文,而你只能一个字一个字看,发现就看不过来了,所以这么多没看的都要先存起来慢慢看。
在发送缓冲区中,现在你成了内个写小作文的了,但是你的小作文很长,发送一次可能过不去,会被分开,所以要分成多段发送。而且当对方给你发送的很多,你还读不过来,你也要把他的数据给存放起来。
3:粘包和分包的处理
上述的问题称为:粘包和分包。既然有粘包和分包的问题,肯定有对应的解决方法。
(1)、定长包:
发送方将数据按照固定长度的包进行发送,接收方每次按照固定长度接收数据,并对接收到的数据进行解析。需要两方事先商量好。
(2)、分隔符包:
发送方在每个包的末尾加入特定的分隔符(如"\n"、"\r\n"等),接收方通过分隔符来辨别包的边界,然后对每个包进行解析。本文的网络缓冲区采用的就是分隔符包。
(3)、统计字节包:
接收方接收到数据后,首先读取指定字节长度的数据作为包的长度信息,然后根据包的长度去读取相应长度的包体进行解析。
4:tcp和udp都有缓冲区吗?
tcp是基于流式的可靠性传输,是要将数据一定发送过去的,但是当发送的数据太大,会被MTU(最大传输单元)限制,不得不被分割成更小的数据包,而且也会碰到tcp分段和ip分片重组等。当我方使用了缓冲区后,会按格式进行发送,但是需要对方也要有缓冲区,不然无法识别我方数据包的边界。
udp是基于报文的不可靠性传输,udp是不需要进行分包处理的,因为是不可靠性传输,所以只管发送,不管是否接收,因此在内核协议中没有发送缓冲区。而且没有确认应答机制,报文的大小最大为64k。
三:网络缓冲区的迭代
1:固定的内存
优点:结构简单,易于实现。
缺点:需要频繁的腾挪数据,需要实现扩缩容机制。
//将这块固定内存置为0
memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH );
int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", fd);
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // unfinished
return 0;
} else if (count < 0) { //
printf("count: %d, errno: %d, %s\n", count, errno, strerror(errno));
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
return 0;
}
conn_list[fd].rlength = count;
2:ringbuffer
ringbuffer是一个环形缓冲区,当写入数据,头指针向前移,读取数据尾指针向前移。下面我将拿出几个重要的函数讲一下。
(1):结构体
struct ringbuffer_s {
uint32_t size;
uint32_t tail;
uint32_t head;
uint8_t * buf;
};
typedef struct ringbuffer_s buffer_t;
(2):队列的创建
buffer_t * buffer_new(uint32_t sz) {
if (!is_power_of_two(sz)) sz = roundup_power_of_two(sz); //传入的sz必须满足是2的次幂,当传入的不满次幂,如10,不满16,会自动变成16,让它成为2的次幂
buffer_t * buf = (buffer_t *)malloc(sizeof(buffer_t) + sz);
if (!buf) {
return NULL;
}
buf->size = sz;
buf->head = buf->tail = 0; //头尾指针都等于0,所以判断是否为空,就利用他俩之差是否为0.
buf->buf = (uint8_t *)(buf + 1);
return buf;
}
(3):添加数据
int buffer_add(buffer_t *r, const void *data, uint32_t sz) {
if (sz > rb_remain(r)) { //判断sz是否小于可用的数据
return -1;
}
uint32_t i;
i = min(sz, r->size - (r->tail & (r->size - 1)));
memcpy(r->buf + (r->tail & (r->size - 1)), data, i);
memcpy(r->buf, data+i, sz-i); //添加数据
r->tail += sz;
return 0;
}
(4):取数据包
int buffer_remove(buffer_t *r, void *data, uint32_t sz) {
assert(!rb_isempty(r));
uint32_t i;
sz = min(sz, r->tail - r->head);
i = min(sz, r->size - (r->head & (r->size - 1)));
memcpy(data, r->buf+(r->head & (r->size - 1)), i);
memcpy(data+i, r->buf, sz-i);
r->head += sz;
return sz;
}
(5):找分隔符
// 找 buffer 中 是否包含特殊字符串(界定数据包的)
int buffer_search(buffer_t *r, const char* sep, const int seplen) {
int i;
for (i = 0; i <= rb_len(r)-seplen; i++) {
int pos = (r->head + i) & (r->size - 1);
if (pos + seplen > r->size) {
if (memcmp(r->buf+pos, sep, r->size-pos))
return 0;
if (memcmp(r->buf, sep+r->size-pos, pos+seplen-r->size) == 0) {
return i+seplen;
}
}
if (memcmp(r->buf+pos, sep, seplen) == 0) {
return i+seplen;
}
}
return 0;
}
ringbuffer主要通过上述几个比较重要的函数实现的数据缓冲区,当然逻辑上不算太难。优点是不需要腾挪数据,缺点是需要实现扩缩容机制,造成不连续空间,可能引发多次系统调用。
3、chainbuffer
通过实现链表来进行存储数据,这样就可以动态扩缩容。
(1):结构体
//结点
struct buf_chain_s {
struct buf_chain_s *next;
uint32_t buffer_len; //buffer的长度
uint32_t misalign; //这个buffer使用了的,那buffer地址+misalign就是可用数据的起始地址
uint32_t off; //实际数据的长度
uint8_t *buffer;
};
//表
struct buffer_s {
buf_chain_t *first; //大表的头
buf_chain_t *last; //大表的尾指针
buf_chain_t **last_with_datap; //二级指针,用于指向一个结点的next
uint32_t total_len; //整个长度
uint32_t last_read_pos; // for sep read
};
typedef struct buf_chain_s buf_chain_t;
typedef struct buffer_s buffer_t;
这样写两个结构体你可能想象不出来具体是什么样子的。上图!这个表中的二级指针是指向这个结点中的next的,可以用来查看这块内存,其中的misalign是指这个buffer中已经使用了的,而这个offset是指实际可以使用的大小。这个表和结点的创建没什么可看的,主要是添加结点,和扩容重要。
(2):结点的添加
//这并不是全部代码,是其中的一段重要的
remain = chain->buffer_len - chain->misalign - chain->off; //这一块内存还剩多少
if (remain >= datlen) { //这里够发送来的数据添加进去
memcpy(chain->buffer + chain->misalign + chain->off, data, datlen);
chain->off += datlen;
buf->total_len += datlen;
// buf->n_add_for_cb += datlen;
goto out;
} else if (buf_chain_should_realign(chain, datlen)) { //这里不够了可能会占据新开辟内存的前面一部分,容易造成系统调用。
buf_chain_align(chain); //因此我们需要判断这数据大小是不是需要重新开辟一块整的内存放进去。
memcpy(chain->buffer + chain->off, data, datlen);
chain->off += datlen;
buf->total_len += datlen;
// buf->n_add_for_cb += datlen;
goto out;
}
to_alloc = chain->buffer_len;
if (to_alloc <= BUFFER_CHAIN_MAX_AUTO_SIZE/2)
to_alloc <<= 1;
if (datlen > to_alloc)
to_alloc = datlen;
tmp = buf_chain_new(to_alloc);
if (tmp == NULL)
goto done;
if (remain) {
memcpy(chain->buffer + chain->misalign + chain->off, data, remain);
chain->off += remain;
buf->total_len += remain;
// buf->n_add_for_cb += remain;
}
data += remain;
datlen -= remain;
memcpy(tmp->buffer, data, datlen);
tmp->off = datlen;
buf_chain_insert(buf, tmp);
(3):数据大小的判断以及移动
static int
buf_chain_should_realign(buf_chain_t *chain, uint32_t datlen) //是否要扩容
{//一块内存中前部分是有已经使用过的数据的,如果咱们将要存放的地址挪到前面已经使用过的地址去,可能存储空间就够了
return chain->buffer_len - chain->off >= datlen &&
(chain->off < chain->buffer_len / 2) &&
(chain->off <= MAX_TO_REALIGN_IN_EXPAND); //如果不满足就重新开辟空间
}
static void
buf_chain_align(buf_chain_t *chain) { //进行移动到可以用的地址去
memmove(chain->buffer, chain->buffer + chain->misalign, chain->off);
chain->misalign = 0;
}
优点是不需要腾挪数据,并且自动扩缩容,无需拷贝数据,但是缺点是容易造成不连续的空间,可能引发多次系统调用。
在讲上面的网络缓冲区设计的时候,只挑出来几个重要的函数进行讲解,前两个的定长buffer和ringbuffer相比之下较为简单,而这个chainbuffer较难,而且代码很长,因此罗列的函数很少,但是逻辑上是和ringbuffer还是有共同之处的。感谢大家收看网络缓冲区的设计,https://xxetb.xetslk.com/s/2D96kH。