在大多数源自SVR 4的内核中,X/Open传输接口(X/Open Transport Interface,XTI,是独立于套接字API的另一个网络编程API)和网络协议通常就像终端IO系统那样也使用流系统(STREAMS system)实现。
我们将使用传输提供者接口(Transport Provider Interface,TPI)开发一个简单的TCP客户程序,TPI是在基于流的系统上,XTI和套接字通常使用的传输层访问接口。
流由Dennis Ritchie设计,并于1986年随SVR 3首次广泛提供支持。POSIX规范将流定义为一个选项组(option group),意味着POSIX兼容系统可以不实现流,但如果实现,则必须符合POSIX规范。基本流函数包括getmsg、getpmsg、putmsg、putpmsg、fattach、所有流ioctl命令。XTI往往使用流实现。所有源自System V的系统都应提供流(SVR即System V Release),但各个4.x BSD版本不提供流。
流(STREAMS)这个名字尽管全是大写字母,但不是一个首字母缩写词,因此改用全小写字母可能更合理。我们需要区分本章讲解的流IO系统(streams IO system)和标准IO流(与标准IO库相关)。
流在进程和驱动程序之间提供全双工的连接:
驱动程序不必与某个硬件设备相关联,它可以是一个伪设备驱动程序(即软件驱动程序)。
流头(stream head)由一个内核例程构成,应用进程针对流描述符执行系统调用(如read、putmsg、ioctl等)时这些内核例程将被激活。
进程可以在流头和驱动程序之间动态增加或删除中间处理模块(processing module),这些模块对顺着一个流上行或下行的消息施行某种类型的过滤:
往一个流中可以推入(pushing)任意数量的模块,推入指的是每个新模块都被插入到流头的紧下方。
多路复选器(multiplexor)是一种特殊类型的伪设备驱动程序,它从多个源接受数据,例如,可在SVR 4上找到的TCP/IP协议族基于流的某个实现如下图所示,其中就有多路复选器:
上图中:
1.在创建一个套接字时,套接字函数库把模块sockmod推入流中,向应用进程提供套接字API的是套接字函数库和sockmod流模块两者的组合。
2.创建一个XTI端点时,XTI函数库把模块timod推入流中,向应用进程提供XTI API的是XTI函数库和timod流模块两者的组合。XTI API的端点相当于套接字API的套接字。
本书早先版本详细叙述了XTI API,但它已不被广泛使用,甚至POSIX规范也不再涵盖它,因此就不讲述了。
3.为了针对XTI端点使用read和write访问网络数据,通常必须把模块tirdwr推入流中,推入该模块后该进程可能不会再使用XTI了,因此上图中我们没有显示XTI库。
4.各种各样的服务接口定义了网络消息在流中上行和下行交换的格式。最常见的3个服务接口为:
(1)传输提供者接口(TPI)定义了传输层提供者(如TCP和UDP)向它上方的模块提供的接口。
(2)网络提供者接口(NPI,Network Provider Interface)定义了网络层提供者(如IP)向它上方的模块提供的接口。
(3)数据链路提供者接口(DLPI)。
一个流中的每个部件(流头、所有处理模块、驱动程序)都包含至少一对队列,即一个写队列和一个读队列:
流消息可分为高优先级(high priority)、优先级带(priority band)、普通(normal)三类。优先级共有256带,在0~255之间取值,其中普通消息位于带0,流消息的优先级用于排队和流量控制,按约定高优先级消息不受流量控制影响。下图是一个给定队列中消息的出现顺序:
虽然流系统支持256个不同的优先级带,网络协议往往只用经加速数据的带1和代表普通数据的带0。
TPI不认为TCP带外数据是真正的经加速数据,事实上TCP的普通数据和带外数据都使用带0。只有那些让经加速数据先于普通数据发送的协议才使用带1发送经加速数据。
在SVR 4之前的版本中没有优先级带的概念,只有普通消息和优先级消息,SVR 4实现了优先级带,并提供了getpmsg和putpmsg函数,较早的优先级消息于是被重命名为高优先级,常用的术语定义[ Rago 1993 ]称高优先级外的消息为普通优先级(normal priority)消息,然后把这些普通优先级消息细分到各个优先级带中。普通消息一词指处于带0的消息。
普通优先级消息和高优先级消息这两大类中分别约有12种和18种,从应用进程和getmsg、putmsg函数角度看,我们仅关注3种不同类型的消息:M_DATA、M_PROTO、M_PCPROTO(PC表示priority control,优先级控制,隐指高优先级消息)。下图说明了这三种消息类型是如何使用write和putmsg函数产生的:
沿着流上行和下行的数据由消息构成,且每个消息含有控制或数据,或两者都有。如果在流上使用read和write函数,那么所传送的仅仅是数据,为了让进程能读写数据和控制两部分信息,流系统增加了以下函数:
消息的控制和数据两部分各自由一个strbuf结构说明:
注意strbuf结构和XTI API所用的netbuf结构之间的相似性,它们由3个同名成员构成,但netbuf结构的两个长度成员是无符号整数,而strbuf结构的两个长度成员是普通整数。原因在于有些流函数使用值为-1的len或maxlen成员表示特殊的含义。
使用putmsg可以单纯发送控制信息或数据,也可同时发送两者。为了指示不发送控制信息,可把ctlptr参数指定为空指针,也可把ctlptr->len
设为-1。同样的手段设置dataptr参数用于指示不发送数据信息。
如果不发送控制信息,putmsg函数将产生一个M_DATA消息,否则根据flags参数产生一个M_PROTO或M_PCPROTO消息,flags参数为0表示普通消息,为RS_HIPRI表示高优先级消息。
getmsg函数的最后一个参数是一个值-结果参数,如果调用时指定的flagsp参数指向的整数值为0,则返回的是流中第一个消息(既可能是普通消息,也可能是高优先级消息),如果该整数值为RS_HIPRI,那就等待一个高优先级消息到达流头,无论哪种情况,存放到flagsp参数指向的整数中的值根据所返回消息的类型或为0,或为RS_HIPRI。
假设传给getmsg函数的ctlptr和dataptr参数都是非空指针,如果没有控制信息待返回(也就是即将返回一个M_DATA消息),getmsg函数就在返回时把ctlptr->len
设为-1作为指示,类似地,没有数据待返回时就把dataptr->len
设为-1。
putmsg函数在成功时返回0,在出错时返回-1。但getmsg函数仅在整个消息完整返回给调用者时才返回0,如果控制缓冲区不足以容纳完整的控制信息,就返回非负的MORECTL,类似地,如果数据缓冲区太小,就返回MOREDATA,如果两个缓冲区都太小,就返回这两个标志的逻辑或。
对不同优先级带的支持随SVR 4被增加到流系统时,以下两个getmsg和putmsg函数的变体函数也被同时引入:
putpmsg函数的band参数必须在0~255之间(含),如果flags参数为MSG_BAND,就产生一个所指定优先级带的消息,把flags参数置为MSG_BAND且把band参数置为0等效于调用putmsg,如果flags参数为MSG_HIPRI,band参数就必须为0,所产生的的是一个高优先级消息(而putmsg函数使用的标志为RS_HIPRI)。
getpmsg的bandp和flagsp参数是值-结果参数,flagsp参数指向的整数可以取值MSG_HIPRI(用来读入一个高优先级消息)、MSG_BAND(用来读入一个优先级至少为bandp参数指向的整数值消息)、MSG_ANY(用来读入任一消息)。函数返回时,bandp参数指向的整数含有所读入消息的优先级带,flagsp参数指向的整数可能是MSG_HIPRI(如果读入的是一个高优先级消息)或MSG_BAND(如果读入的是其他类型消息)。
在流系统中我们会再次使用ioctl函数:
此处的ioctl函数的与第十七章中的相比,唯一的变化就是处理流时所包含的头文件不同。
大约30个ioctl请求影响流头,每个请求都以I_
打头,它们的具体说明通常在streamio手册页面给出。
在图31-3中,我们把TPI表示为传输层向它上方的模块提供的服务接口。在流环境中,套接字和XTI都使用TPI。在图31-3中,应用进程跟TCP和UDP交换TPI消息通过的是套接字函数库和sockmod的组合或XTI函数库和timod的组合。
TPI是一个基于消息的接口,它定义了在应用进程(如XTI函数库或套接字函数库)和传输层之间沿着流上行和下行交换的消息,包括消息的格式和每个消息执行的操作。例如,应用进程向提供者发送一个请求(如bind一个本地地址),提供者则发回一个响应(成功或出错)。一些事件在提供者异步地发生(对某个服务器来说,连接请求的到达),它们导致沿着流向上发送的消息或信号。
我们可以绕过XTI和套接字直接使用TPI,我们将改用TPI取代套接字重新编写我们的时间获取客户程序。用编程语言进行类比,使用套接字或XTI好比使用诸如C或Pascal等高级语言编程,而使用TPI好比使用汇编语言编程。我们不提倡在现实应用程序中使用TPI,但查看TPI如何工作并开发本例有助于我们更好地理解在流环境中套接字函数库和XTI函数库的工作原理。
以下是tpi_daytime.h头文件:
c
#include "unpxti.h"
// 头文件sys/steam.h中包含了所有TPI消息的结构定义
#include <sys/stream.h>
#include <sys/tihdr.h>
void tpi_bind(int, const void *, size_t);
void tpi_connect(int, const void *, size_t);
ssize_t tpi_read(int, void *, size_t);
void tpi_close(int);
以下是时间获取客户程序的main函数:
c
#include "tpi_daytime.h"
int main(int argc, char **argv) {
int fd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in myaddr, servaddr;
if (argc != 2) {
err_quit("usage: tpi_daytime <IPaddress>");
}
// 打开与传输提供者TCP对应的设备(通常为/dev/tcp)
fd = Open(XTI_TCP, O_RDWR, 0);
/* bind any local address */
bzero(&myaddr, sizeof(myaddr));
myaddr.sin_family = AF_INET;
// 以INADDR_ANY和端口0填写一个网际网套接字地址结构,告知TCP捆绑任一本地地址和本地端点,由tpi_bind函数完成捆绑
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(0);
tpi_bind(fd, &myaddr, sizeof(struct sockaddr_in));
/* fill in server's address */
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(13); /* daytime server */
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
// tpi_connect函数建立与服务器的连接
tpi_connect(fd, &servaddr, sizeof(struct sockaddr_in));
for (; ; ) {
if ((n = tpi_read(fd, recvline, MAXLINE)) <= 0) {
if (n == 0) {
break;
} eles {
err_sys("tpi_read error");
}
}
recvline[n] = 0; /* null terminate */
fputs(recvline, stdout);
}
tpi_close(fd);
exit(0);
}
以下是tpi_bind函数:
c
#include "tpi_daytime.h"
void tpi_bind(int fd, const void *addr, size_t addrlen) {
struct {
// T_bind_req结构介绍在代码下面
// 所有TPI请求都定义成以一个长整数类型字段开头的某个结构,如此处的T_bind_req结构,后跟一个缓冲区
// TPI对该缓冲区中内容未做任何规定,由具体的提供者定义,TCP提供者期待该缓冲区中有一个sockaddr_in结构
struct T_bind_req msg_hdr;
char addr[128];
} bind_req;
struct {
struct T_bind_ack msg_hdr;
char addr[128];
} bind_ack;
struct strbuf ctlbuf;
struct T_error_ack *error_ack;
int flags;
bind_req.msg_hdr.PRIM_type = T_BIND_REQ;
bind_req.msg_hdr.ADDR_length = addrlen; // addrlen对于网际网套接字地址结构为16字节
// 设置套接字地址结构的偏移量
bind_req.msg_hdr.ADDR_offset = sizeof(struct T_bind_req);
// 由于我们是客户,因此将CONIND_number设为0
bind_req.msg_hdr.CONIND_number = 0;
// 我们直接用memcpy函数而非赋值运算等方式将sockaddr_in结构复制到bind_req结构中
// 因此这难以保证sockaddr_in结构是适当对齐的
memcpy(bind_req.addr, addr, addrlen); /* sockaddr_in{} */
ctlbuf.len = sizeof(struct T_bind_req) + addrlen;
ctlbuf.buf = (char *)&bind_req;
// TPI要求我们把刚构造的结构作为一个M_PROTO消息传给提供者
// 于是我们将这个bind_req结构指定为控制信息,且指定没有数据信息,且指定标志为0
Putmsg(fd, &ctlbuf, NULL, 0);
ctlbuf.maxlen = sizeof(bind_ack);
ctlbuf.len = 0;
ctlbuf.buf = (char *)&bind_ack;
// 对T_BIND_REQ请求的响应或者是T_BIND_ACK消息,或者是T_ERROR_ACK消息,这些确认消息是高优先级消息
// 于是我们指定RS_HIPRI读入它,既然该应该是高优先级消息,它将绕过流中任意普通优先级消息
// 可能的应答消息的结构介绍在代码下面
// 两个应答消息都以PRIM_type成员打头,因此我们可以假设它是一个T_BIND_ACK消息读入应答
// 查看类型值后再相应地处理该消息
flags = RS_HIPRI;
// 我们不期望提供者的任何数据,因此getmsg函数的第三个参数为空指针
Getmsg(fd, &ctlbuf, NULL, &flags);
// 在验证所返回的控制信息量至少是一个长整数的大小时,我们需要把sizeof的返回值强转为一个int
// 因为sizeof返回的是一个无符号整型,而getmsg函数返回的strbuf.len可能是-1
// 如果不进行转换,比较运算符一边是一个有符号值,一边是一个无符号值,C编译器会将有符号值转换为无符号值
// -1转换为有符号值非常大,导致-1大于4(假设一个长整数占4个字节)
if (ctlbuf.len < (int)sizeof(long)) {
err_quit("bad length from getmsg");
}
switch (bind_ack.msg_hdr.PRIM_type) {
// 如果应答是T_BIND_ACK消息,那么捆绑成功,直接返回,捆绑的实际地址由bind_ack结构的addr成员返回
case T_BIND_ACK:
return;
// 如果应答是T_ERROR_ACK
case T_ERROR_ACK;
// 验证所收到的是完整的消息
if (ctlbuf.len < (int)sizeof(struct T_error_ack)) {
err_quit("bad length for T_ERROR_ACK");
}
error_ack = (struct T_error_ack *)&bind_ack.msg_hdr;
// 如果发生错误直接终止,不再返回到调用者
err_quit("T_ERROR_ACK from bind (%d, %d)", error_ack->TLI_error, error_ack->UNIX_error);
default:
err_quit("unexpected message type: %d", bind_ackmsg_hdr.PRIM_type);
}
}
以上函数中,T_bind_req结构在头文件sys/tihdr.h中定义如下:
以上函数中,对于T_BIND_REQ请求的响应或者是T_BIND_ACK消息,或者是T_ERROR_ACK消息,这两个应答消息的结构定义如下:
对于以上函数,我们尝试捆绑端口1,这需要超级用户权限,因为它是1024以内的端口,我们会得到以下输出:
该系统上EACCES的值为3。如果我们尝试捆绑一个1023以上,但被另一TCP端点使用中的端口,我们会得到以下输出:
该系统上EADDRBUSY的值为23,这个错误是TPI为了支持XTI而引入的,支持TLI的较早版本TPI在请求捆绑一个已使用的端口时将另行捆绑一个未使用的端口,这意味着捆绑众所周知端口的服务器不得不比较返回的地址(返回的地址出自t_bind函数的第3个指针参数返回的T_bind_ack消息)和请求的地址,如果不一致就放弃。
以下是tpi_connect函数,它建立与服务器的连接:
c
#include "tpi_daytime.h"
void tpi_connect(int fd, const void *addr, size_t addrlen) {
// 就像tpi_bind函数一样,此处也自定义一个名为conn_req的结构,它包含一个T_conn_req结构和用于存放协议地址的空间
struct {
// T_conn_req结构介绍在代码下面
struct T_conn_req msg_hdr;
char addr[128];
} conn_req;
struct {
struct T_conn_con msg_hdr;
char addr[128];
} conn_con;
struct strbuf ctlbuf;
union T_primitives rcvbuf;
struct T_error_ack *error_ack;
struct T_discon_ind *discon_ind;
int flags;
conn_req.msg_hdr.PRIM_type = T_CONN_REQ;
conn_req.msg_hdr.DEST_length = addrlen;
conn_req.msg_hdr.DEST_offset = sizeof(struct T_conn_req);
conn_req.msg_hdr.OPT_length = 0;
conn_req.msg_hdr.OPT_offset = 0;
memcpy(conn_req.addr, addr, addrlen); /* sockaddr_in{} */
ctlbuf.len = sizeof(struct T_conn_req) + addrlen;
ctlbuf.buf = (char *)&conn_req;
// 单纯指定控制信息调用putmsg,同时把标志指定为0,以顺着流下行发送一个M_PROTO消息
Putmsg(fd, &ctlbuf, NULL, 0);
ctlbuf.maxlen = sizeof(union T_primitives);
ctlbuf.len = 0;
ctlbuf.buf = (char *)&rcvbuf;
flags = RS_HIPRI;
// 调用getmsg期待接受T_OK_ACK消息(表示连接建立已启动,T_ok_ack结构介绍在代码下面)
// 或可能接收到T_ERROR_ACK消息(上面已给出)
// 既然不知道会接收到什么类型的消息,我们于是定义一个名为T_primitives的由所有可能的请求和应答组成的联合
// 该联合用作控制消息的输入缓冲区
Getmsg(fd, &ctlbuf, NULL, &flags);
if (ctlbuf.len < (int)sizeof(long)) {
err_quit("tpi_connect: bad length from getmsg");
}
switch (rcvbuf.type) {
// 表示成功的T_OK_ACK消息只是告诉我们连接建立已启动,现在还要等待T_CONN_CON消息以获悉对端是否已接受该连接
case T_OK_ACK:
break;
case T_ERROR_ACK:
if (ctlbuf.len < (int)sizeof(struct T_error_ack)) {
err_quit("tpi_connect: bad length for T_ERROR_ACK");
}
error_ack = (struct T_error_ack *)&rcvbuf;
err_quit("tpi_connect: T_ERROR_ACK from conn (%d, %d)", error_ack->TLI_error, error_ack->UNIX_error);
default:
err_quit("tpi_connect: unexpected message type: %d", rcvbuf.type);
}
ctlbuf.maxlen = sizeof(conn_con);
ctlbuf.len = 0;
ctlbuf.buf = (char *)&conn_con;
flags = 0;
// 再次调用getmsg,但所期待的消息是一个M_PROTO消息而非M_PCPROTO消息,于是把标志设为0
// 如果收到一个T_CONN_CON消息(T_conn_con结构介绍在代码下面),则连接建立完毕
// 如果连接未能建立(对端进程不在运行、超时等原因),会返回一个T_DISCON_IND消息(T_discon_ind结构介绍在代码下面)
Getmsg(fd, &ctlbuf, NULL, &flags);
if (ctlbuf.len < (int)sizeof(long)) {
err_quit("tpi_connect2: bad length from getmsg");
}
switch(conn_con.msg_hdr.PRIM_type) {
case T_CONN_CON:
break;
case T_DISCON_IND:
if (ctlbuf.len < (int)sizeof(struct T_discon_ind)) {
err_quit("tpi_connect2: bad length for T_DISCON_IND");
}
discon_ind = (struct T_discon_ind *)&conn_con.msg_hdr;
err_quit("tpi_connect2: T_DISCON_IND from conn (%d)", discon_ind->DISCON_reason);
default:
err_quit("tpi_connect2: unexpected message type: %d", conn_con.msg_hdr.PRIM_type);
}
}
TPI定义了一个T_conn_req结构,用于存放连接的协议地址和选项:
连接建立已启动时,返回的T_ok_ack结构如下:
连接成功建立时,返回的T_conn_con结构如下:
连接建立失败时,返回的T_conn_ind结构如下:
对于以上函数,我们先查看提供者返回的各种错误,如果我们指定连接到一个没有运行标准daytime服务器的主机:
错误146表示ECONNREFUSED。接着指定一个未接入因特网的IP地址:
错误145表示ETIMEDOUT,再次对该IP运行本程序,我们得到另一个错误:
错误148表示EHOSTUNREACH,这两个结果的区别在于,第一次没有ICMP主机不可达错误的返送,而第二次有。
函数tpi_read从一个流中读入数据:
c
#include "tpi_daytime.h"
ssize_t tpi_read(int fd, void *buf, size_t len) {
struct strbuf ctlbuf;
struct strbuf datbuf;
union T_primitives rcvbuf;
int flags;
ctlbuf.maxlen = sizeof(union T_primitives);
ctlbuf.buf = (char *)&rcvbuf;
datbuf.maxlen = len;
datbuf.buf = buf;
datbuf.len = 0;
flags = 0;
// 同时读入控制信息和数据,用于返回数据的strbuf结构指向调用者指定的缓冲区,在读入时流上可能有4种情形:
// 1.数据以一个M_DATA消息到来,这由返回的控制信息长度为-1指示
// 消息会被拷贝到调用者指定的缓冲区,此时本函数返回getmsg函数返回的数据长度
// 2.数据以一个T_DATA_IND消息到来,此时,控制消息是一个T_data_ind结构(此结构的介绍在代码下面)
// 如果返回了此消息,我们就忽略MORE_flag成员(对于TCP这样的字节流协议,该成员不会被设置)
// 此时本函数返回getmsg函数返回的数据长度
// 3.到达一个T_DISCON_IND消息,表示收到一个断连请求,对于TCP提供者,本情形发生在某连接上收到RST后
// 此简单的例子中,我们不处理该情形
// 4.到达一个T_ORDREL_IND消息,表示TCP提供者已收取的所有分节均已被消费,且返回的是FIN
// T_ordrel_ind结构的介绍在代码下面,本函数此时返回0,以向调用者指示已在连接上遇到EOF
// 这是TCP的有序释放(orderly release),即三次挥手
Getmsg(fd, &ctlbuf, &datbuf, &flags);
if (ctlbuf.len >= (int)sizeof(long)) {
if (rcvbuf.type == T_DATA_IND) {
return datbuf.len;
} else if (rcvbuf.type == T_ORDREL_IND) {
return 0;
} else {
err_quit("tpi_read: unexpected type %d", rcvbuf.type);
}
} else if (ctlbuf.len == -1) {
return datbuf.len;
} else {
err_quit("tpi_read: bad length from getmsg");
}
}
T_data_ind结构如下:
当收取FIN时,getmsg函数会返回一个T_ordrel_ind结构:
tpi_close函数:
c
#include "tpi_daytime.h"
// 本函数相当于XTI的t_sndrel函数
void tpi_close(int fd) {
struct T_ordrel_req ordrel_req;
struct strbuf ctlbuf;
ordrel_req.PRIM_type = T_ORDREL_REQ;
ctlbuf.len = sizeof(struct T_ordrel_req);
ctlbuf.buf = (char *)&ordrel_req;
// 构造一个T_ordrel_req结构(此结构的介绍在代码下面)并调用putmsg将其作为一个M_PROTO消息发送出去
Putmsg(fd, &ctlbuf, NULL, 0);
Close(fd);
}
用于主动结束TCP连接的T_ordrel_req结构:
tpi_close函数中,我们调用putmsg沿着流下行发送一个顺序释放请求,然后立即关闭该流,如果关闭流期间,我们的有序释放请求被流子系统丢弃,此时流关闭时的默认处理就是有序释放,这对TCP来说没问题。
本例子展示了TPI的风格,应用进程沿着流下行向提供者发送消息(请求),提供者则沿着流上行发回消息(应答)。一些消息交换是简单的请求-应答情形(如捆绑一个本地地址),另外一些消息交换则需要耗费一段时间(如建立一个连接),并允许我们在等待应答期间做其他事而非空等。本例选择编写使用TPI的TCP客户程序而非服务器程序是为了简单,编写使用TPI的服务器程序并合理地处理连接要困难得多。
从XTI到TPI的函数映射比较接近,而从套接字从TPI的映射不那么接近,但这两个函数库都处理了TPI所需的大量细节,从而简化了应用程序的编写。
我们比较一下使用TPI完成网络操作与套接字实现于内核中的系统上完成同样操作所需的系统调用个数,TPI情形捆绑一个本地地址需要2个系统调用,而内核套接字情形只需要1个;TPI情形在一个阻塞式描述符上建立一个连接需要3个系统调用,而内核套接字情形只需要1个。
XTI一般使用流来实现,为访问流子系统而提供的4个新函数时getmsg、getpmsg、putmsg、putpmsg,已有的ioctl函数也被流子系统频繁使用。
TPI是从上层进入传输层的SVR 4流接口。XTI和套接字均使用TPI。