首页 / 资讯中心 / 文章详情

TLI传输层接口编程详解:从设计原理到实战应用

TLI传输层接口编程详解:从设计原理到实战应用 ★ FEATURED ARTICLE
1. 为什么还要聊TLI这个“老古董”第一次在《UNIX网络编程》卷1里翻到TLI那一章的时候我的反应跟大多数人一样这玩意儿还有人用书架上那本砖头厚的书讲套接字的部分被翻得起了毛边TLI那几章却干净得像刚印出来。后来接了一个维护老系统的活儿代码里全是t_open、t_bind、t_connect这些调用我才意识到——不是这东西没用是我没碰到用它的场景。TLI全称Transport Layer Interface是ATT在System V Release 3里搞出来的一套传输层编程接口。它跟BSD套接字是同一层次的东西都是让应用程序跟传输协议打交道但设计哲学完全不同。套接字走的是“协议无关”路线你把地址结构、协议族往里一塞剩下的交给内核TLI走的是“传输提供者”路线你得先打开一个传输端点把它绑到一个传输提供者上然后通过一套标准的服务原语来通信。说白了套接字像去一家综合医院挂号你说挂哪个科医院给你安排TLI像去一家专科诊所你得先找到这家诊所然后按它的流程走。两种方式都能看病但流程和体验不一样。这篇文章适合谁看如果你正在维护一套跑在System V上的老系统或者你在研究UNIX网络编程的历史演进再或者你单纯想搞明白“为什么会有两套传输层接口”这个问题那这篇内容应该能帮你省下不少翻文档的时间。我会从设计思路、核心数据结构、实操流程、常见坑这几个角度把TLI这套东西拆开揉碎讲清楚。提示TLI在Linux上的实现叫XTIX/Open Transport Interface两者API几乎一样只是头文件和库的引用方式略有差别。本文以TLI为主XTI的差异会单独标注。2. TLI的设计思路与核心概念拆解2.1 传输提供者模型TLI的世界观要理解TLI先得接受它的世界观网络通信不是内核“内置”的功能而是由一个个“传输提供者”提供的服务。传输提供者可以是一个内核模块也可以是一个用户态进程甚至可以是硬件。应用程序通过TLI跟传输提供者打交道传输提供者负责实际的协议处理。这个模型的好处是解耦。套接字把协议栈焊死在内核里想加一个新协议得改内核TLI把协议实现抽出来只要符合TLI规范谁都能当传输提供者。坏处也很明显——多了一层抽象性能开销上去了而且传输提供者的质量参差不齐调试起来更麻烦。TLI里最核心的概念是传输端点transport endpoint。你可以把它理解成套接字里的“套接字描述符”但它比套接字描述符承载了更多信息。一个传输端点代表一个通信的端点它绑定到一个传输提供者上有自己的状态机有自己的地址。传输端点的状态机是TLI区别于套接字的一个重要特征。套接字的状态相对简单TLI的端点状态有十几种从T_UNBND未绑定到T_IDLE空闲到T_DATAXFER数据传输中每个状态允许的操作不一样。你如果在错误的状态下调用了某个函数会直接返回错误不会像套接字那样“凑合能用”。2.2 服务原语TLI的“动词”TLI的操作是通过一组服务原语来完成的。这些原语的名字都以t_开头看起来挺整齐但用起来得记清楚每个原语在哪个状态下有效。原语作用有效状态对应套接字函数t_open打开传输端点任意sockett_bind绑定地址T_UNBNDbindt_connect建立连接T_IDLEconnectt_listen监听连接T_IDLElistent_accept接受连接T_LISTENacceptt_snd发送数据T_DATAXFERsendt_rcv接收数据T_DATAXFERrecvt_snddis发送断开T_DATAXFERshutdownt_close关闭端点任意close这张表只是最常用的几个实际TLI原语有二十多个。你会发现一个规律TLI的原语命名更“动作化”t_snd、t_rcv、t_snddis一看就知道是干什么的。套接字的send、recv、shutdown其实也差不多但TLI把“断开”分成了t_snddis发送断开和t_rcvdis接收断开粒度更细。2.3 地址结构struct t_bind和struct netbufTLI的地址表示跟套接字很不一样。套接字用sockaddr系列结构TLI用struct netbuf。netbuf不直接存地址它存的是指向地址缓冲区的指针和长度struct netbuf { unsigned int maxlen; unsigned int len; char *buf; };这个设计的好处是地址格式完全由传输提供者决定TLI本身不关心地址长什么样。坏处是你得自己管理缓冲区maxlen和len搞混了就会出问题。绑定地址的时候用struct t_bindstruct t_bind { struct netbuf addr; unsigned int qlen; };addr是地址qlen是连接队列长度只在监听的时候有意义。如果你不想指定具体地址可以把addr.len设为0传输提供者会给你分配一个。注意netbuf里的buf指针必须指向有效的内存而且maxlen要设成缓冲区实际大小。我见过有人把maxlen设成0结果t_bind返回成功但地址根本没绑上后面t_connect直接失败排查了半天。3. TLI实操从打开端点到数据传输3.1 打开传输端点t_open的正确姿势t_open是TLI编程的第一步原型如下int t_open(const char *path, int oflag, struct t_info *info);path是传输提供者的设备文件路径比如/dev/tcp、/dev/udp。这个路径因系统而异得查系统文档。oflag跟open的flag类似常用O_RDWR。info是个输出参数返回传输提供者的能力信息struct t_info { long addr; // 地址最大长度 long options; // 选项缓冲区最大长度 long tsdu; // 传输服务数据单元最大长度 long etsdu; // 加速传输服务数据单元最大长度 long connect; // 连接建立时最大数据长度 long discon; // 断开时最大数据长度 long servtype; // 服务类型 };servtype有三个可能的值T_COTS面向连接、T_COTS_ORD面向连接且保证顺序、T_CLTS无连接。这个信息很重要决定了你后面能用哪些原语。比如T_CLTS就不能用t_connect和t_listen。int fd; struct t_info info; fd t_open(/dev/tcp, O_RDWR, info); if (fd 0) { t_error(t_open failed); exit(1); } printf(servtype: %ld\n, info.servtype);t_error是TLI提供的错误打印函数比perror更懂TLI的错误码。这个函数在调试的时候特别好用建议每个TLI程序都带上。3.2 绑定地址t_bind的细节打开端点之后下一步是绑定地址。服务端和客户端的绑定策略不一样。服务端通常要绑一个“众所周知”的地址让客户端能找到。比如TCP服务端绑端口80struct t_bind bind_req; struct t_bind bind_ret; struct sockaddr_in sin; memset(sin, 0, sizeof(sin)); sin.sin_family AF_INET; sin.sin_port htons(80); sin.sin_addr.s_addr htonl(INADDR_ANY); bind_req.addr.maxlen sizeof(sin); bind_req.addr.len sizeof(sin); bind_req.addr.buf (char *)sin; bind_req.qlen 5; if (t_bind(fd, bind_req, bind_ret) 0) { t_error(t_bind failed); exit(1); }bind_ret返回实际绑定的地址。如果你在bind_req里把addr.len设为0传输提供者会分配一个地址通过bind_ret返回。客户端通常这么干让系统自动分配端口。qlen只在服务端监听时有意义表示连接队列的最大长度。设太小了并发连接一多就会拒绝新连接设太大了占内存。一般设5到10就够了跟套接字的listenbacklog类似。实操心得t_bind的bind_ret参数不能传NULL。有些实现允许传NULL但System V的某些版本会直接段错误。我习惯总是传一个有效的struct t_bind哪怕不关心返回值。3.3 建立连接t_connect与t_listen/t_accept客户端用t_connect发起连接struct t_call call; struct sockaddr_in sin; memset(sin, 0, sizeof(sin)); sin.sin_family AF_INET; sin.sin_port htons(80); inet_pton(AF_INET, 192.168.1.1, sin.sin_addr); call.addr.maxlen sizeof(sin); call.addr.len sizeof(sin); call.addr.buf (char *)sin; call.opt.maxlen 0; call.opt.len 0; call.opt.buf NULL; call.udata.maxlen 0; call.udata.len 0; call.udata.buf NULL; call.sequence 0; if (t_connect(fd, call, NULL) 0) { t_error(t_connect failed); exit(1); }struct t_call是TLI里表示连接请求的结构包含地址、选项、用户数据三部分。sequence用于区分多个未完成的连接请求同步模式下用0就行。服务端用t_listen接收连接请求struct t_call call; call.addr.maxlen sizeof(struct sockaddr_in); call.addr.buf malloc(call.addr.maxlen); call.opt.maxlen 0; call.opt.buf NULL; call.udata.maxlen 0; call.udata.buf NULL; if (t_listen(fd, call) 0) { t_error(t_listen failed); exit(1); }t_listen会阻塞直到有连接请求到达。收到请求后call里包含了客户端的地址。然后你需要用t_accept接受连接int new_fd; new_fd t_open(/dev/tcp, O_RDWR, NULL); if (new_fd 0) { t_error(t_open for accept failed); exit(1); } if (t_accept(fd, new_fd, call) 0) { t_error(t_accept failed); exit(1); }这里有个关键点t_accept的第一个参数是监听端点第二个参数是新的端点用于跟客户端通信。这个新端点必须先用t_open打开但不能绑定地址。t_accept会把连接请求“转移”到新端点上。注意t_accept之后监听端点继续处于监听状态可以接受更多连接。新端点进入T_DATAXFER状态可以开始收发数据。这个流程跟套接字的accept很像但多了一个显式打开新端点的步骤。3.4 数据传输t_snd和t_rcv连接建立之后就可以用t_snd和t_rcv收发数据了char sendbuf[] hello, tli; if (t_snd(fd, sendbuf, strlen(sendbuf), 0) 0) { t_error(t_snd failed); exit(1); } char recvbuf[1024]; int n; n t_rcv(fd, recvbuf, sizeof(recvbuf), 0); if (n 0) { t_error(t_rcv failed); exit(1); } printf(received %d bytes: %.*s\n, n, n, recvbuf);t_snd和t_rcv的flags参数跟套接字的send/recv类似但TLI的flags定义不一样。常用的有T_MORE表示还有后续数据、T_EXPEDITED加速数据、T_PUSH立即发送。T_MORE这个flag特别重要它跟TLI的消息边界概念有关。TLI是面向消息的不是面向字节流的。t_snd一次调用发送的数据构成一个消息接收端t_rcv一次调用会尽量返回一个完整消息。如果消息太大接收缓冲区装不下t_rcv会返回部分数据并设置T_MORE标志告诉你还有后续数据。这个行为跟TCP的字节流语义不一样用惯了套接字的人容易在这里踩坑。实操心得如果你用TLI写TCP程序想模拟字节流语义可以在每次t_snd时都带上T_MORE标志接收端循环t_rcv直到没有T_MORE。但这样效率不高更好的做法是直接按消息边界处理把每次t_snd的数据当成一个完整消息。4. 常见问题与排查技巧实录4.1 状态机错误TLI最让人头疼的地方TLI的状态机比套接字严格得多在错误的状态下调函数会直接返回TSTATECHK错误。我整理了一个常见状态错误速查表错误码含义常见原因解决方法TSTATECHK状态检查失败在错误状态调用了原语检查端点当前状态确认原语是否有效TBADADDR地址格式错误netbuf的len或maxlen不对检查地址缓冲区大小和实际长度TBADOPT选项格式错误选项缓冲区设置不当不用的选项把len和maxlen设为0TACCES权限不足绑定了特权端口但无权限换端口或用root运行TNOADDR地址不可用绑定的地址已被占用换地址或设addr.len0让系统分配TOUTSTATE状态超出范围端点状态与操作不匹配用t_getstate查看当前状态t_getstate是排查状态问题的利器返回端点的当前状态。我习惯在关键操作前后都打印一下状态虽然有点啰嗦但能省下大量调试时间。int state t_getstate(fd); printf(current state: %d\n, state);状态值对应的宏定义在tiuser.h里T_UNBND是1T_IDLE是2T_DATAXFER是6等等。打印出来是数字得对着头文件看。4.2 阻塞与非阻塞t_open的O_NONBLOCKTLI默认是阻塞模式t_listen、t_connect、t_rcv都会阻塞。如果你想用非阻塞模式可以在t_open时加O_NONBLOCK标志fd t_open(/dev/tcp, O_RDWR | O_NONBLOCK, info);非阻塞模式下没有数据可读时t_rcv返回-1并设置errno为EAGAIN。t_connect在非阻塞模式下会立即返回连接完成后再通过t_rcv或t_snd通知你。这个行为跟套接字的非阻塞connect类似但TLI没有select那样的多路复用机制得用poll或者t_look。t_look是TLI特有的函数返回端点上当前挂起的事件类型int event t_look(fd); if (event T_DATA) { // 有数据可读 } else if (event T_DISCONNECT) { // 有断开请求 }t_look不会阻塞适合在轮询循环里用。但它只能告诉你“有事件”不能告诉你“有几个字节可读”所以还是得配合t_rcv。4.3 断开连接的两种方式TLI断开连接有两种方式优雅断开和强制断开。优雅断开用t_snddist_snddis(fd, NULL);这会发送一个断开请求给对端对端t_rcv会返回0或者T_DISCONNECT事件。t_snddis的第二个参数可以带用户数据但大多数传输提供者不支持传NULL就行。强制断开用t_closet_close(fd);t_close直接关闭端点不发送断开请求。对端可能收不到任何通知直到下一次t_snd或t_rcv才发现连接断了。这个行为跟套接字的close类似但TLI的t_close更“粗暴”一些。注意t_snddis之后端点进入T_IDLE状态可以重新t_connect或t_listen。t_close之后端点就没了得重新t_open。如果你在循环里反复建立连接用t_snddis比t_close效率高。4.4 内存管理netbuf的坑netbuf里的buf指针需要你自己分配和释放。TLI不会帮你管理内存忘了释放就是内存泄漏释放早了就是野指针。我踩过的一个坑在t_listen之后call.addr.buf指向的是TLI内部缓冲区你不能直接释放它也不能在下次t_listen之后继续用。正确的做法是每次t_listen之前重新分配call.addr.buf或者用t_alloc分配call.addr.buf t_alloc(fd, T_ADDR, T_ALL);t_alloc是TLI提供的内存分配函数会根据传输提供者的能力分配合适大小的缓冲区。用t_alloc分配的内存要用t_free释放t_free(call.addr.buf, T_ADDR);t_alloc的第二个参数指定分配类型T_ADDR地址、T_OPT选项、T_UDATA用户数据、T_ALL全部。第三个参数指定是用于发送还是接收T_ALL表示两者都适用。实操心得t_alloc分配的内存块大小可能比你实际需要的大但不要自己调整maxlen。TLI内部会检查maxlen是否跟分配时一致不一致会返回TBADADDR。老老实实用t_alloc返回的maxlen就行。5. TLI与套接字的对比什么时候用哪个5.1 功能对比特性TLI套接字设计哲学传输提供者模型协议无关模型地址表示netbuf指针长度sockaddr固定结构状态机严格十几种状态宽松状态较少消息边界保留消息边界字节流TCP多路复用t_lookpollselect/poll/epoll非阻塞O_NONBLOCKO_NONBLOCK内存管理手动t_alloc/t_free内核管理可移植性System V系BSD系更广泛从功能上看套接字在大多数场景下更方便。select/epoll的多路复用机制成熟稳定地址结构简单直接内存管理由内核负责。TLI的优势在于它的传输提供者模型——如果你需要实现自定义协议或者需要跟非IP协议打交道TLI的抽象层能帮你省不少事。5.2 性能对比性能上TLI和套接字没有本质差距。两者最终都是通过系统调用跟内核通信开销主要在内核态和用户态的切换上。TLI多了一层传输提供者的抽象理论上会多一点点开销但实际测试中差异在误差范围内。真正影响性能的是使用方式。TLI的消息边界语义意味着你不能像TCP那样随便发随便收得按消息来。如果你用TLI模拟字节流每次t_snd都带T_MORE接收端循环t_rcv性能会比直接用套接字差一些。5.3 选型建议如果你在维护老系统代码里已经用了TLI那就继续用TLI别折腾着改成套接字。改造成本高收益低还容易引入新bug。如果你在写新代码除非有特殊需求比如要跟某个只提供TLI接口的传输提供者打交道否则直接用套接字。套接字的生态更成熟文档更丰富遇到问题更容易找到答案。如果你在学习UNIX网络编程的历史TLI值得了解。它代表了另一种设计思路理解了TLI能帮你更深刻地理解套接字的设计取舍。但了解归了解实际项目里还是套接字更实用。提示Linux上的XTI跟TLI几乎一样但头文件是xti.h而不是tiuser.h库是-lxti而不是-lnsl。如果你在Linux上编译TLI代码把#include tiuser.h改成#include xti.h链接时加-lxti就行。6. 一个完整的TLI回显服务端示例6.1 代码结构下面是一个完整的TLI回显服务端监听TCP端口收到数据后原样返回。这个示例涵盖了t_open、t_bind、t_listen、t_accept、t_rcv、t_snd、t_snddis、t_close的完整流程。#include stdio.h #include stdlib.h #include string.h #include tiuser.h #include netinet/in.h #include arpa/inet.h #define BUFSIZE 1024 int main(int argc, char *argv[]) { int listen_fd, conn_fd; struct t_info info; struct t_bind bind_req, bind_ret; struct t_call call; struct sockaddr_in sin; char buf[BUFSIZE]; int n; // 1. 打开传输端点 listen_fd t_open(/dev/tcp, O_RDWR, info); if (listen_fd 0) { t_error(t_open failed); exit(1); } // 2. 绑定地址 memset(sin, 0, sizeof(sin)); sin.sin_family AF_INET; sin.sin_port htons(8888); sin.sin_addr.s_addr htonl(INADDR_ANY); bind_req.addr.maxlen sizeof(sin); bind_req.addr.len sizeof(sin); bind_req.addr.buf (char *)sin; bind_req.qlen 5; if (t_bind(listen_fd, bind_req, bind_ret) 0) { t_error(t_bind failed); exit(1); } printf(listening on port 8888\n); // 3. 主循环 while (1) { // 分配地址缓冲区 call.addr.maxlen sizeof(struct sockaddr_in); call.addr.buf malloc(call.addr.maxlen); call.opt.maxlen 0; call.opt.buf NULL; call.udata.maxlen 0; call.udata.buf NULL; // 4. 监听连接 if (t_listen(listen_fd, call) 0) { t_error(t_listen failed); free(call.addr.buf); continue; } // 5. 打开新端点用于通信 conn_fd t_open(/dev/tcp, O_RDWR, NULL); if (conn_fd 0) { t_error(t_open for accept failed); free(call.addr.buf); continue; } // 6. 接受连接 if (t_accept(listen_fd, conn_fd, call) 0) { t_error(t_accept failed); t_close(conn_fd); free(call.addr.buf); continue; } free(call.addr.buf); printf(connection accepted\n); // 7. 回显循环 while (1) { n t_rcv(conn_fd, buf, sizeof(buf), 0); if (n 0) { t_error(t_rcv failed); break; } if (n 0) { printf(client disconnected\n); break; } if (t_snd(conn_fd, buf, n, 0) 0) { t_error(t_snd failed); break; } } // 8. 关闭连接端点 t_snddis(conn_fd, NULL); t_close(conn_fd); } // 9. 关闭监听端点 t_close(listen_fd); return 0; }6.2 编译与运行编译这个程序需要链接TLI库。在System V系统上cc -o echo_server echo_server.c -lnsl在Linux上用XTIcc -o echo_server echo_server.c -lxti运行./echo_server然后用telnet或者nc连接测试nc localhost 8888输入什么就返回什么说明回显服务正常。6.3 代码中的关键点这个示例里有几个地方值得单独说。第一call.addr.buf的分配和释放。每次t_listen之前都重新malloct_accept之后free。如果你在循环外分配一次t_listen可能会覆盖里面的内容导致地址信息错乱。第二conn_fd的打开时机。必须在t_accept之前打开而且不能绑定地址。t_accept会把连接请求“转移”到这个新端点上如果新端点已经绑定了地址t_accept会返回TBADADDR。第三t_snddis和t_close的配合。先t_snddis发送断开请求再t_close关闭端点。如果直接t_close客户端可能收不到断开通知会一直等在那里。第四错误处理。每个TLI调用都检查返回值出错就打印错误信息。t_error会输出详细的错误描述比perror有用得多。生产环境里你可能需要更精细的错误处理比如重试、记录日志、通知监控系统但作为示例这样已经够了。实操心得这个示例是单线程的一次只能处理一个连接。如果你需要并发处理多个连接可以用fork或者多线程。但TLI的端点不能跨进程共享fork之后子进程得自己t_open新端点。多线程的话每个线程管理自己的端点别共享。7. 调试TLI程序的几个实用技巧7.1 用t_getstate跟踪状态变化TLI的状态机是调试的最大障碍。我习惯在关键操作前后都调用t_getstate把状态打印出来。虽然输出有点多但能清楚地看到端点状态的变化过程定位问题特别快。#define PRINT_STATE(fd, msg) \ printf([%s] state%d\n, msg, t_getstate(fd)) PRINT_STATE(fd, before t_connect); t_connect(fd, call, NULL); PRINT_STATE(fd, after t_connect);状态值对应的宏定义在tiuser.h里你可以写个函数把状态值转成字符串const char *state_name(int state) { switch (state) { case T_UNBND: return T_UNBND; case T_IDLE: return T_IDLE; case T_OUTCON: return T_OUTCON; case T_INCON: return T_INCON; case T_DATAXFER: return T_DATAXFER; case T_OUTREL: return T_OUTREL; case T_INREL: return T_INREL; default: return UNKNOWN; } }7.2 用t_look做非阻塞轮询t_look返回端点上当前挂起的事件不会阻塞。在非阻塞模式下你可以用t_look配合poll实现多路复用struct pollfd pfd; pfd.fd fd; pfd.events POLLIN; if (poll(pfd, 1, -1) 0) { int event t_look(fd); if (event T_DATA) { n t_rcv(fd, buf, sizeof(buf), 0); // 处理数据 } else if (event T_DISCONNECT) { // 处理断开 } }t_look的返回值有T_DATA、T_DISCONNECT、T_ORDREL、T_CONNECT等。T_ORDREL表示对端优雅关闭T_DISCONNECT表示对端强制关闭。这两个事件的处理方式不一样T_ORDREL可以继续发送剩余数据T_DISCONNECT则不能。7.3 用strace跟踪系统调用TLI的函数最终都会变成系统调用。用strace跟踪一下能看到底层发生了什么strace -e tracenetwork ./echo_server你会看到t_open变成了opent_bind变成了bindt_connect变成了connect。这个技巧在排查“为什么TLI调用失败了”的时候特别有用因为你能看到内核返回的错误码比TLI的错误码更底层。注意不同系统的TLI实现不一样strace的输出也会有差异。System V的TLI可能走的是ioctl而不是标准的socket系统调用。别被输出吓到抓住关键的错误码就行。8. 写在最后TLI这套东西说它过时吧确实过时了新项目基本没人用。但说它没用吧也不对维护老系统的时候绕不开而且它的一些设计思路——比如传输提供者模型、严格的状态机、消息边界语义——在今天的某些框架里还能看到影子。我个人的体会是学TLI最大的收获不是会用它的API而是理解了“传输层接口”这件事本身可以有多种设计方式。套接字不是唯一的选择也不是天然正确的选择。TLI的很多设计决策放在它诞生的那个年代是有道理的。只是后来BSD套接字赢了不是因为套接字更好而是因为BSD赢了。如果你正在跟TLI打交道希望这篇内容能帮你少踩几个坑。如果你只是好奇那当个故事看也挺好。技术这东西多了解一种思路总没坏处。
阅读完成 · 觉得有帮助?
咨询建站