4、传输层协议:TCP与UDP在DoIP中的角色、端口号分配(UDP 13400, TCP 13400)、Socket编程基础

好,我们进入第四章。这一章聊传输层,说白了就是TCP和UDP这对老搭档在DoIP里怎么分工的。

我记得刚接触DoIP那会儿,有个困惑:为什么同一个端口13400,既要用UDP又要用TCP?这不是找麻烦吗?后来踩过坑才明白——这恰恰是ISO 13400标准设计的精妙之处。

4.1 TCP与UDP的角色分工

先给个结论:UDP管发现,TCP管通信

你想想看,ECU刚上电,诊断仪连它的IP地址都不知道,怎么建立连接?这时候就需要UDP上场了。UDP是无连接的,发个广播报文出去,所有ECU都能收到。这就是车辆发现路由激活阶段的核心机制。

但UDP有个致命问题——丢包不重传。诊断请求要是丢了,轻则超时,重则误诊断。所以一旦连接建立,后续的所有诊断消息(比如读取DTC、刷写固件)都必须走TCP。

我个人习惯把DoIP的通信过程分成三个阶段:

  • 阶段一(UDP):车辆发现、路由激活
  • 阶段二(TCP):连接建立、诊断通信
  • 阶段三(TCP):连接关闭

核心原则:UDP只做轻量级的控制面通信,TCP负责可靠的数据面传输。

4.2 端口号分配:为什么都是13400?

ISO 13400标准明确规定:UDP端口13400TCP端口13400都用于DoIP。

你可能会问:同一个端口号,两种协议不会冲突吗?

不会。因为TCP和UDP在内核里是独立的协议栈,端口号空间也是独立的。TCP 13400和UDP 13400是两个完全不同的东西,就像你家门牌号是13400,但有两个门——一个走快递(UDP),一个走客人(TCP)。

协议 端口号 用途 典型报文
UDP 13400 车辆发现、路由激活 Vehicle Announcement, Routing Activation
TCP 13400 诊断通信、数据交换 Diagnostic Message, Alive Check

避坑指南:我曾经在项目里犯过一个低级错误——把UDP和TCP的socket都绑定到同一个端口,但忘了设置SO_REUSEADDR选项。结果第二个bind直接返回EADDRINUSE。记住:TCP和UDP虽然端口号独立,但如果你在同一个套接字上重复绑定,还是会冲突的。

4.3 Socket编程基础

好了,理论说完了,咱们动手写点代码。DoIP的socket编程其实不复杂,核心就两个套路:UDP的recvfrom/sendto,TCP的accept/read/write。

4.3.1 UDP Socket:车辆发现

先看UDP接收端。ECU上电后,需要监听UDP 13400端口,等待诊断仪的车辆发现请求。

#include <sys/socket.h>
#include <netinet/in.h>

int udp_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_sock < 0) {
    perror("socket creation failed");
    return -1;
}

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(13400);

// 关键:设置SO_REUSEADDR,防止重启时端口被占用
int opt = 1;
setsockopt(udp_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

if (bind(udp_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
    perror("bind failed");
    close(udp_sock);
    return -1;
}

// 接收车辆发现请求
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
char buffer[1024];
int recv_len = recvfrom(udp_sock, buffer, sizeof(buffer), 0,
                        (struct sockaddr*)&client_addr, &addr_len);
if (recv_len > 0) {
    // 解析报文,发送Vehicle Announcement响应
    // ...
}

注意:UDP的recvfrom是阻塞的。在实际产品中,建议用select或epoll做超时处理,否则一个坏掉的诊断仪就能让你的ECU卡死。

4.3.2 TCP Socket:诊断通信

TCP这边稍微复杂一点,因为要处理连接管理。ECU作为服务器,需要监听TCP 13400端口,接受诊断仪的连接。

int tcp_sock = socket(AF_INET, SOCK_STREAM, 0);
if (tcp_sock < 0) {
    perror("TCP socket creation failed");
    return -1;
}

struct sockaddr_in tcp_addr;
memset(&tcp_addr, 0, sizeof(tcp_addr));
tcp_addr.sin_family = AF_INET;
tcp_addr.sin_addr.s_addr = htonl(INADDR_ANY);
tcp_addr.sin_port = htons(13400);

// 同样设置SO_REUSEADDR
int opt = 1;
setsockopt(tcp_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

if (bind(tcp_sock, (struct sockaddr*)&tcp_addr, sizeof(tcp_addr)) < 0) {
    perror("TCP bind failed");
    close(tcp_sock);
    return -1;
}

// 开始监听,最大等待队列长度设为5
if (listen(tcp_sock, 5) < 0) {
    perror("listen failed");
    close(tcp_sock);
    return -1;
}

// 接受客户端连接
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_sock = accept(tcp_sock, (struct sockaddr*)&client_addr, &addr_len);
if (client_sock < 0) {
    perror("accept failed");
    close(tcp_sock);
    return -1;
}

// 接收诊断消息
char diag_buffer[4096];
int bytes_read = read(client_sock, diag_buffer, sizeof(diag_buffer));
if (bytes_read > 0) {
    // 处理DoIP诊断消息
    // ...
}

经验之谈:TCP的read返回值一定要检查。我曾经遇到一个bug,诊断仪发送了100字节的请求,但read只返回了50字节。为什么?因为TCP是流协议,没有消息边界。DoIP协议在应用层用4字节的Payload Type和4字节的Payload Length来界定消息,所以你的代码必须实现一个消息组装器,把TCP流切分成完整的DoIP报文。

4.3.3 多连接管理

实际项目中,ECU可能需要同时处理多个诊断仪的连接。我建议用select或epoll实现并发。

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(tcp_sock, &read_fds);
int max_fd = tcp_sock;

// 假设我们维护一个客户端socket列表
for (int i = 0; i < client_count; i++) {
    FD_SET(client_socks[i], &read_fds);
    if (client_socks[i] > max_fd) {
        max_fd = client_socks[i];
    }
}

struct timeval timeout;
timeout.tv_sec = 1;
timeout.tv_usec = 0;

int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret > 0) {
    // 处理新连接
    if (FD_ISSET(tcp_sock, &read_fds)) {
        // accept新客户端
    }
    // 处理已有连接的数据
    for (int i = 0; i < client_count; i++) {
        if (FD_ISSET(client_socks[i], &read_fds)) {
            // read数据
        }
    }
}

小技巧:DoIP标准规定,一个ECU最多支持多少个并发TCP连接?答案是没有硬性限制,但实际项目中我一般限制在4-8个。太多连接会消耗ECU的RAM和CPU资源,尤其是那些只有几百KB内存的MCU。

4.4 常见问题与避坑

  • 端口被占用:ECU重启后,之前的TCP连接处于TIME_WAIT状态,bind会失败。解决方案就是前面提到的SO_REUSEADDR。
  • UDP广播风暴:车辆发现阶段,如果多个ECU同时响应,网络可能瞬间拥塞。我建议在UDP响应中加入随机延迟(0-100ms),避免冲突。
  • TCP粘包:这是新手最容易犯的错误。记住:一次read不一定能读完一个完整的DoIP报文。必须根据报文头部的Length字段,循环读取直到收齐指定字节数。
  • 心跳超时:DoIP标准要求TCP连接空闲超过一定时间(通常是5秒)要发送Alive Check。我习惯在select的超时处理中实现这个逻辑。

嗯,这一章的内容就这些。TCP和UDP在DoIP中的角色其实很清晰——一个负责开门迎客,一个负责关门做生意。下一章我们会深入DoIP的报文格式,看看那些0x0001、0x0002到底是什么意思。

记住:代码写得好,不如协议理解得透。Socket编程只是工具,真正值钱的是你对DoIP状态机的理解。