首页 > 代码库 > 《网络编程》带外数据

《网络编程》带外数据

带外数据

        有些传输层协议具有带外(Out Of Band,OOB)数据的概念,用于迅速通告对端本端所发生的重要事件。因此,带外数据比普通数据(也称为带内数据)有更高的优先级,它应该总是立即被发送,而不论发送缓冲区中是否有排队等待发送的普通数据或因流量控制而导致发送端的通告窗口大小为 0(即停止发送数据) 。带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中。

        UDP 没有实现带外数据传输,TCP 也没有真正的带外数据。只不过 TCP 利用其首部中的 紧急指针标志 和 紧急指针 两个字段,给应用程序提供了一种紧急方式。TCP 的紧急方式利用传输普通数据的连接来传输紧急数据。实际应用中,带外数据的使用很少见,它一般总是被映射到传输普通数据的连接中,例如 telnet,rlogin,ftp 等远程程序会使用带外数据。前两个程序会将中止字符作为紧急数据 发送到远程端,这允许远程端冲洗所有未处理的输入,并且丢弃所有未发送的终端输出。ftp 命令使用带外数据来中断一个文件的传输。注意:紧急数据并不是带外数据,它只是带外数据的一种体现。

        首先介绍 TCP 发送带外数据的过程。假设一个进程已经往某个 TCP 连接的发送缓冲区中写入了 N 字节的普通数据,并等待其发送。在数据被发送前,该进程又向这个连接写入了 3 字节的带外数据 “abc”。此时,待发送的 TCP 报文段的首部将被设置 URG 标志,并且紧急指针被设置为指向最后一个带外数据的下一字节(进一步减去当前 TCP 报文段的序号值得到其首部中的紧急偏移值),如图 1 所示。

技术分享

        由图 1 可见,发送端一次发送的多字节的带外数据中只有最后一字节被当作带外数据(字母c),而其他数据(字母a和b)被当成了普通数据。如果 TCP 模块以多个 TCP 报文段来发送图 1 所示 TCP 发送缓冲区中的内容,则每个 TCP 报文段都将设置 URG 标志,并且它们的紧急指针指向同一个位置(数据流中带外数据的下一个位置),但只有一个 TCP 报文段真正携带带外数据。

        现在考虑 TCP 接收带外数据的过程。TCP 接收端只有在接收到紧急指针标志时才检查紧急指针,然后根据紧急指针所指的位置确定带外数据的位置,并将它读入一个特殊的缓存中。这个缓存只有1字节,称为带外缓存。如果上层应用程序没有及时将带外数据从带外缓存中读出,则后续的带外数据(如果有的话)将覆盖它。

        前面讨论的带外数据的接收过程是 TCP 模块接收带外数据的默认方式。如果我们给 TCP 连接设置了 SO_OOBINLINE 选项,则带外数据将和普通数据一样被 TCP 模块存放在 TCP 接收缓冲区中。此时应用程序需要像读取普通数据一样来读取带外数据。这种情况下,使用紧急指针来区分普通数据和带外数据,紧急指针可以用来指出带外数据的位置, socket 编程接口也提供了系统调用来识别带外数据。

带外标记

        在 Linux 系统中,内核检查到 TCP 紧急标志时,将通知应用程序带外数据需要接收。内核通知应用程序带外数据到达有两种常见的方式:I/O 复用产生的异常事件 和  SIGURG 信号。但是,即使应用程序得到了有带外数据需要接收通知,还必须直到带外数据在数据流中的具体位置,这样才能准确接收带外数据。这点可通过系统调用 sockatmark 函数实现。

/* 函数功能:确认套接字是否处于带外标记;
 * 返回值:若处于带外标记则返回1,若不处于带外标记则返回0,若出错则返回-1;
 * 函数原型:
 */
#include <sys/socket.h>
int sockatmark(int sockfd);


处理带外数据


利用 SIGURG 信号处理带外数据

发送数据(包含普通数据和带外数据)程序:

#include <string.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <netdb.h>
#include <unistd.h>

extern int my_connect(const char *, const char *);
int main(int argc, char **argv)
{
    int sockfd;
    if(argc != 3)
    {
        perror("usage: %s <host> <port#>");
        exit(1);
    }

    sockfd = my_connect(argv[1], argv[2]);

    /* 发送普通数据 */
    write(sockfd, "123", 3);
    printf("wrote 3 bytes of normal data\n");
    /* sleep函数的作用是让套接字处于阻塞状态,
     * 使write和send的数据能够作为单个TCP分段发送到对端 */
    sleep(1);
    /* 发送带外数据 */
    send(sockfd, "4", 1, MSG_OOB);
    printf("wrote 1 byte of OOB data\n");
    sleep(1);

    write(sockfd, "56", 2);
    printf("wrote 2 bytes of normal data\n");
    sleep(1);

    send(sockfd, "7", 1, MSG_OOB);
    printf("wrote 1 byte of OOB data\n");
    sleep(1);

    write(sockfd, "89", 2);
    printf("wrote 2 bytes of normal data\n");
    sleep(1);

    exit(0);
}

接收数据(包含普通数据和带外数据)程序:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/socket.h>
#include <signal.h>
#include <fcntl.h>
typedef void Sigfunc(int);

extern int my_listen(const char *, const char *, socklen_t *);
extern void err_quit(const char *, ...);
extern Sigfunc *MySignal(int signo, Sigfunc *func);
extern int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int		listenfd, connfd;

void	sig_urg(int);

int
main(int argc, char **argv)
{
	int		n;
	char	buff[100];

    /* 服务器套接字处于监听状态 */
	if (argc == 2)
		listenfd = my_listen(NULL, argv[1], NULL);
	else if (argc == 3)
		listenfd = my_listen(argv[1], argv[2], NULL);
	else
		err_quit("usage: tcprecv01 [ <host> ] <port#>");

    /* 接受来自客户端的连接请求 */
	connfd = Accept(listenfd, NULL, NULL);

    /* 捕获SIGURG信号,并对该信号进行处理 */
	MySignal(SIGURG, sig_urg);
    /* 设置已连接套接字的属主 */
	fcntl(connfd, F_SETOWN, getpid());

	for ( ; ; ) {
        /* 从套接字读取数据 */
		if ( (n = read(connfd, buff, sizeof(buff)-1)) == 0) {
			printf("received EOF\n");
			exit(0);
		}
		buff[n] = 0;	/* null terminate */
		printf("read %d bytes: %s\n", n, buff);
	}
}

void
sig_urg(int signo)
{
	int		n;
	char	buff[100];

	printf("SIGURG = %d received\n", signo);
    /* 读入带外数据 */
	n = recv(connfd, buff, sizeof(buff)-1, MSG_OOB);
	buff[n] = 0;		/* null terminate */
	printf("read %d OOB byte: %s\n", n, buff);
}

运行结果如下所示:

$ ./recv 127.0.0.1 9877 &
[1] 17193
$ ./send 127.0.0.1 9877
wrote 3 bytes of normal data
read 3 bytes: 123
wrote 1 byte of OOB data
SIGURG = 23 received
read 1 OOB byte: 4
wrote 2 bytes of normal data
read 2 bytes: 56
wrote 1 byte of OOB data
SIGURG = 23 received
read 1 OOB byte: 7
wrote 2 bytes of normal data
read 2 bytes: 89
received EOF
[1]+  Done                    ./recv 127.0.0.1 9877

利用 I/O 复用产生的异常事件处理带外数据

发送端的程序不变,接收端的程序使用 I/O 复用产生的异常事件通知进程需要读取带外数据:

#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/socket.h>
#include <fcntl.h>
#include <sys/select.h>


extern int my_listen(const char *, const char *, socklen_t *);
extern void err_quit(const char *, ...);
extern int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int		listenfd, connfd;


int
main(int argc, char **argv)
{
	int		n;
	char	buff[100];
    /* 为select函数使用的变量 */
    int maxfdp1;
    int justreadoob = 0;/* 标志是否读过由异常事件通知的带外数据 */
    fd_set  rset, xset;

    /* 服务器套接字处于监听状态 */
	if (argc == 2)
		listenfd = my_listen(NULL, argv[1], NULL);
	else if (argc == 3)
		listenfd = my_listen(argv[1], argv[2], NULL);
	else
		err_quit("usage: tcprecv01 [ <host> ] <port#>");

    /* 接受来自客户端的连接请求 */
	connfd = Accept(listenfd, NULL, NULL);

    /* 初始化fd_set结构 */
    FD_ZERO(&rset);
    FD_ZERO(&xset);

	for ( ; ; ) {
        FD_SET(connfd, &rset);
        if(justreadoob == 0)
            FD_SET(connfd, &xset);
        maxfdp1 = connfd+1;
        select(maxfdp1, &rset, NULL, &xset, NULL);

        /* 若产生异常事件,则读取带外数据 */
        if(FD_ISSET(connfd, &xset))
        {
            n = recv(connfd, buff, sizeof(buff)-1, MSG_OOB);
            buff[n] = 0;
            printf("read %d OOB bytes: %s\n", n, buff);
            /* 防止多次读取带外数据 */
            justreadoob = 1;
            FD_CLR(connfd, &xset);
        }
        /* 从套接字读取普通数据 */
        if(FD_ISSET(connfd, &rset))
        {
            if ( (n = read(connfd, buff, sizeof(buff)-1)) == 0) {
                printf("received EOF\n");
                exit(0);
            }
            buff[n] = 0;	/* null terminate */
            printf("read %d bytes: %s\n", n, buff);
            justreadoob = 0;
        }
	}
}

输出结果:

$ ./recv02 127.0.0.1 9877 &
[1] 17748
$ ./send 127.0.0.1 9877
wrote 3 bytes of normal data
read 3 bytes: 123
wrote 1 byte of OOB data
read 1 OOB bytes: 4
wrote 2 bytes of normal data
read 2 bytes: 56
wrote 1 byte of OOB data
read 1 OOB bytes: 7
wrote 2 bytes of normal data
read 2 bytes: 89
received EOF
[1]+  Done                    ./recv02 127.0.0.1 9877


利用 sockatmark 读取带外标记

带外标记有以下两个特性:

  1. 带外标记总是指向普通数据最后一个字节紧后的位置。这意味着,如果设置 SO_OOBINLINE 套接字选项使带外数据在线接收,则若下一个待读入的字节是使用 MSG_OOB 标志发送,sockatmark 返回真;若没有设置在线接收带外数据,则若下一个待读入的字节是跟在带外数据后发送的第一个字节,则 sockatmark 返回真。
  2. 读取操作总是停在带外标记上。也就是说,假设套接字接收缓冲区有 20 个字节,若带外标记之前只有 5 个字节,而进程执行一个 20 个字节的 read 调用,那么真正返回的只是带外标记之前的 5 个字节。

发送端程序:

#include <string.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <netdb.h>
#include <unistd.h>

extern int my_connect(const char *, const char *);
int main(int argc, char **argv)
{
    int sockfd;
    if(argc != 3)
    {
        perror("usage: %s <host> <port#>");
        exit(1);
    }

    sockfd = my_connect(argv[1], argv[2]);

    /* 发送普通数据 */
    write(sockfd, "123", 3);
    printf("wrote 3 bytes of normal data\n");

    /* 发送带外数据 */
    send(sockfd, "4", 1, MSG_OOB);
    printf("wrote 1 byte of OOB data\n");

    write(sockfd, "56", 2);
    printf("wrote 2 bytes of normal data\n");

    send(sockfd, "7", 1, MSG_OOB);
    printf("wrote 1 byte of OOB data\n");

    write(sockfd, "89", 2);
    printf("wrote 2 bytes of normal data\n");

    exit(0);
}

接收端程序:

#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/socket.h>
#include <fcntl.h>
#include <sys/select.h>


extern int my_listen(const char *, const char *, socklen_t *);
extern void err_quit(const char *, ...);
extern int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int		listenfd, connfd;


int
main(int argc, char **argv)
{
	int		n;
    int on = 1;
	char	buff[100];

    /* 服务器套接字处于监听状态 */
	if (argc == 2)
		listenfd = my_listen(NULL, argv[1], NULL);
	else if (argc == 3)
		listenfd = my_listen(argv[1], argv[2], NULL);
	else
		err_quit("usage: tcprecv01 [ <host> ] <port#>");

    /* 设置SO_OOBINLINE套接字选项,表示希望在线接收带外数据 */
    setsockopt(listenfd, SOL_SOCKET, SO_OOBINLINE, &on, sizeof(on));
    /* 接受来自客户端的连接请求 */
	connfd = Accept(listenfd, NULL, NULL);
    sleep(5);/* sleep以接收来自发送进程的所有数据 */


	for ( ; ; ) {

        /* 检测套接字接收缓冲区是否处于带外标记 */
        if(sockatmark(connfd))
            printf("at OOB mark\n");
        /* 读取数据,并显示这些数据 */
            if ( (n = read(connfd, buff, sizeof(buff)-1)) == 0) {
                printf("received EOF\n");
                exit(0);
            }
            buff[n] = 0;	/* null terminate */
            printf("read %d bytes: %s\n", n, buff);
	}
}

       输出结果:从结果可以知道,第一个带外标记并没有并输出,这验证了一个现象:一个给定 TCP 连接只有一个带外标记,若前面的带外标记若不被进程接收,则会被后来新到达的带外标记覆盖。

$ ./recv03 127.0.0.1 9877 &
[1] 19344
$ ./send 127.0.0.1 9877 
wrote 3 bytes of normal data
wrote 1 byte of OOB data
wrote 2 bytes of normal data
wrote 1 byte of OOB data
wrote 2 bytes of normal data
read 6 bytes: 123456
at OOB mark
read 3 bytes: 789
received EOF
[1]+  Done                    ./recv03 127.0.0.1 9877


总结

       TCP 没有真正的带外数据,不过提供紧急模式和紧急指针,一旦发送端进入紧急模式,紧急指针就出现在发送端的报文段的 TCP 首部中。连接的对端接收取该指针是在告知接收进程 发送端已经进入紧急模式,而且该指针指向紧急数据的最后一个字节。套接字 API 把 TCP 的紧急模式映射成带外数据。发送端进程通过指定 MSG_OOB 标志调用 send 让发送端进入紧急模式。该调用中的最后一个数据字节被认定为带外字节。接收端 TCP 收到新的紧急指针后,或者 通过发送 SIGURG 信号,或者通过由 select 返回套接字有异常事件待处理,让接收端进程得以通知。

带外数据概念实际上向接收端传达三个不同的消息:

  1. 发送端进入紧急模式这个事实。接收端得以通知这个事实的方式有两个:I/O 复用调用 select 函数产生的异常事件 和 SIGURG 信号。本通知在发送进程发送带外字节后由发送端 TCP 立即发送,即使接收端的任何数据发送因流量控制而停止,TCP 仍然发送本通知。
  2. 带外字节的位置。也就是它相对于来自发送端的其余数据的发送位置:带外标记。
  3. 带外字节的实际值。

对于TCP的紧急模式,我们可以认为 URG 标志时通知(信息1),紧急指针是带外标记(信息2),数据字节是其本身(信息3)。

与这个带外数据概念相关的问题有:

  1. 每个连接只有一个TCP紧急指针;
  2. 每个连接只有一个带外标记;
  3. 每个连接只有一个单字节的带外缓冲区(该缓冲区只有在数据非在线读入时才需考虑)。如果带外数据时在线读入的,那么当新的带外数据到达时,先前的带外字节并未丢失,不过他们的标记却因此被新的标记取代而丢失了。

        总之,带外数据是否有用取决于应用程序使用它的目的。如果目的是告知对端丢弃直到标记处得普通数据,那么丢失一个中间带外字节及其相应的标记不会有什么不良后果。但是如果不丢失带外字节本身很重要,那么必须在线收到这些数据。另外,作为带外数据发送的数据字节应该区别于普通数据,因为当前新的标记到达时,中间的标记将被覆写,从而事实上把带外字节混杂在普通数据之中。



参考资料:

《Unix 网络编程》

《网络编程》带外数据