4、基于Socket的屏幕间通信:Server端实现、Client端实现、心跳包与重连机制

多屏互动,说白了就是让车里的几块屏幕能「说上话」。

中控屏要告诉仪表盘「导航开始了」,副驾屏要把视频流推给后排屏。这些消息怎么传?我个人的习惯是,直接用Socket。为什么?因为它够底层、够灵活,而且几乎所有车载系统都原生支持。

这一章,我们就来手撸一个基于Socket的屏幕间通信方案。包括Server端怎么写、Client端怎么连,以及最容易被忽视的——心跳包和重连机制。

4.1 为什么选Socket而不是其他方案?

你可能会问:现在不是有DDS、Some/IP这些中间件吗?

没错,那些方案在大型SOA架构里确实很香。但咱们做多屏互动,场景相对简单——就是几块屏幕之间传指令、传状态。用Socket,代码量少、依赖少、调试也方便。

我在项目里遇到过,用DDS做屏幕间通信,光配置QoS策略就折腾了两天。后来换成Socket,半天就搞定了。当然,如果你要上量产项目,该用DDS还是得用。但作为学习原型,Socket绝对是最佳切入点。

适用场景判断:
  • 屏幕数量 ≤ 5块
  • 通信频率 ≤ 100Hz
  • 数据包大小 ≤ 64KB
  • 不需要复杂的服务发现

满足以上条件,Socket方案完全够用。

4.2 Server端实现:谁来做「消息中转站」?

多屏通信,一般需要一个Server端。它不一定是单独的硬件,可以是某块屏幕上的一个后台进程。

我建议把中控屏作为Server。原因很简单:中控屏通常性能最强、内存最大,而且一直在线。

4.2.1 基础Server框架

先看一个最简的Server实现。我用的是Linux Socket API,因为车载系统基本都是Linux内核。

// server.c - 车载多屏通信Server端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>

#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

// 客户端列表
typedef struct {
    int sockfd;
    struct sockaddr_in addr;
    char client_id[32];
    int is_connected;
} ClientNode;

ClientNode clients[MAX_CLIENTS];
pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER;

// 初始化Server
int init_server() {
    int server_fd;
    struct sockaddr_in address;
    
    // 创建Socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket failed");
        return -1;
    }
    
    // 设置SO_REUSEADDR,防止端口被占用
    int opt = 1;
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
        perror("setsockopt failed");
        return -1;
    }
    
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        return -1;
    }
    
    if (listen(server_fd, MAX_CLIENTS) < 0) {
        perror("listen failed");
        return -1;
    }
    
    printf("Server started on port %d\n", PORT);
    return server_fd;
}

// 处理客户端消息的线程
void *handle_client(void *arg) {
    int client_index = *(int *)arg;
    ClientNode *client = &clients[client_index];
    char buffer[BUFFER_SIZE];
    
    while (1) {
        memset(buffer, 0, BUFFER_SIZE);
        int bytes_read = read(client->sockfd, buffer, BUFFER_SIZE - 1);
        
        if (bytes_read <= 0) {
            // 客户端断开
            printf("Client %s disconnected\n", client->client_id);
            client->is_connected = 0;
            close(client->sockfd);
            break;
        }
        
        // 广播消息给所有其他客户端
        pthread_mutex_lock(&clients_mutex);
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (clients[i].is_connected && i != client_index) {
                write(clients[i].sockfd, buffer, bytes_read);
            }
        }
        pthread_mutex_unlock(&clients_mutex);
    }
    
    return NULL;
}

int main() {
    int server_fd = init_server();
    if (server_fd < 0) return -1;
    
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);
    
    while (1) {
        int new_socket = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
        if (new_socket < 0) {
            perror("accept failed");
            continue;
        }
        
        // 找个空位放新客户端
        pthread_mutex_lock(&clients_mutex);
        int slot = -1;
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (!clients[i].is_connected) {
                slot = i;
                break;
            }
        }
        
        if (slot == -1) {
            printf("Max clients reached, rejecting\n");
            close(new_socket);
            pthread_mutex_unlock(&clients_mutex);
            continue;
        }
        
        clients[slot].sockfd = new_socket;
        clients[slot].addr = client_addr;
        clients[slot].is_connected = 1;
        sprintf(clients[slot].client_id, "client_%d", slot);
        pthread_mutex_unlock(&clients_mutex);
        
        // 启动处理线程
        pthread_t thread_id;
        int *arg = malloc(sizeof(int));
        *arg = slot;
        pthread_create(&thread_id, NULL, handle_client, arg);
        pthread_detach(thread_id);
        
        printf("New client connected: %s\n", clients[slot].client_id);
    }
    
    close(server_fd);
    return 0;
}
我的经验:

SO_REUSEADDR这个选项一定要加。我在调试时经常需要重启Server,不加这个选项,端口会被系统占用好几分钟,很烦人。

4.2.2 消息格式设计

光传原始字节流肯定不行。我们需要定义一套消息格式。我习惯用JSON,虽然解析开销大一点,但调试方便。

// 消息结构示例
{
    "type": "navigation",
    "from": "center_display",
    "to": "instrument_cluster",
    "timestamp": 1701234567,
    "payload": {
        "action": "start_navigation",
        "destination": "北京西站",
        "distance": 12500
    }
}
字段 类型 说明
type string 消息类型,如navigation、media、system
from string 发送方标识
to string 接收方标识,broadcast表示广播
timestamp int64 Unix时间戳,用于消息排序
payload object 具体业务数据

4.3 Client端实现:怎么优雅地连上Server?

Client端相对简单,就是连上Server,然后收发消息。但这里有个坑:车载环境下,网络不是一直稳定的。屏幕启动顺序也不固定——可能仪表盘先启动,中控屏后启动。

4.3.1 带重试的Client连接

// client.c - 带自动重连的Client端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define SERVER_IP "192.168.1.100"
#define SERVER_PORT 8888
#define MAX_RETRY 5
#define RETRY_INTERVAL 2  // 秒

int connect_to_server() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        return -1;
    }
    
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERVER_PORT);
    
    if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
        return -1;
    }
    
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        close(sock);
        return -1;
    }
    
    return sock;
}

// 带重试的连接函数
int connect_with_retry() {
    int sock;
    int retry_count = 0;
    
    while (retry_count < MAX_RETRY) {
        sock = connect_to_server();
        if (sock > 0) {
            printf("Connected to server successfully\n");
            return sock;
        }
        
        retry_count++;
        printf("Connection attempt %d failed, retrying in %d seconds...\n", 
               retry_count, RETRY_INTERVAL);
        sleep(RETRY_INTERVAL);
    }
    
    printf("Failed to connect after %d attempts\n", MAX_RETRY);
    return -1;
}

int main() {
    int sock = connect_with_retry();
    if (sock < 0) {
        return -1;
    }
    
    // 收发消息循环
    char buffer[1024];
    while (1) {
        memset(buffer, 0, 1024);
        int bytes = read(sock, buffer, 1023);
        
        if (bytes <= 0) {
            printf("Server disconnected, reconnecting...\n");
            close(sock);
            sock = connect_with_retry();
            if (sock < 0) {
                break;
            }
            continue;
        }
        
        // 处理收到的消息
        printf("Received: %s\n", buffer);
    }
    
    close(sock);
    return 0;
}
注意:

重试间隔不要设得太短。我曾经设成200ms,结果屏幕启动时,Server还没起来,Client疯狂重试,把CPU吃满了。建议至少2秒以上。

4.4 心跳包与重连机制:让连接「活」起来

TCP本身有保活机制,但默认超时是2小时。在车上,这显然不行。屏幕可能因为休眠、信号干扰等原因断开,我们需要快速感知并恢复。

4.4.1 为什么需要心跳?

说白了,就是让双方定期互报平安。Server知道Client还活着,Client也知道Server没挂。

我见过一个真实案例:某款车的副驾屏播放视频时突然卡住,就是因为网络短暂断开,但双方都没检测到,数据堵在缓冲区里出不去。

4.4.2 心跳包实现

// 心跳包结构
typedef struct {
    int type;       // 0: ping, 1: pong
    int seq;        // 序列号
    long timestamp; // 时间戳
} HeartbeatPacket;

// Server端心跳检测线程
void *heartbeat_monitor(void *arg) {
    while (1) {
        sleep(5);  // 每5秒检查一次
        
        pthread_mutex_lock(&clients_mutex);
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (clients[i].is_connected) {
                // 发送ping
                HeartbeatPacket ping = {0, i, time(NULL)};
                write(clients[i].sockfd, &ping, sizeof(ping));
                
                // 设置超时等待pong
                struct timeval tv = {2, 0};  // 2秒超时
                setsockopt(clients[i].sockfd, SOL_SOCKET, SO_RCVTIMEO, 
                          &tv, sizeof(tv));
                
                HeartbeatPacket pong;
                int ret = read(clients[i].sockfd, &pong, sizeof(pong));
                
                if (ret <= 0 || pong.type != 1) {
                    // 心跳超时,标记断开
                    printf("Heartbeat timeout for %s\n", clients[i].client_id);
                    clients[i].is_connected = 0;
                    close(clients[i].sockfd);
                }
            }
        }
        pthread_mutex_unlock(&clients_mutex);
    }
    return NULL;
}

// Client端心跳响应
void *heartbeat_responder(void *arg) {
    int sock = *(int *)arg;
    HeartbeatPacket packet;
    
    while (1) {
        int ret = read(sock, &packet, sizeof(packet));
        if (ret <= 0) break;
        
        if (packet.type == 0) {  // 收到ping
            HeartbeatPacket pong = {1, packet.seq, time(NULL)};
            write(sock, &pong, sizeof(pong));
        }
    }
    return NULL;
}

4.4.3 重连策略

心跳检测到断开后,Client需要自动重连。但重连不是简单的「断了就重连」,需要讲究策略。

场景 重连策略 说明
首次连接失败 指数退避重试 2s → 4s → 8s → 16s → 30s(上限)
运行中断开 快速重试3次 每次间隔1秒,之后转为指数退避
长时间断开 降低重试频率 每30秒重试一次,避免耗电
屏幕休眠唤醒 立即重连 配合系统休眠唤醒事件触发
// 指数退避重连实现
int reconnect_with_backoff() {
    int base_interval = 2;  // 初始2秒
    int max_interval = 30;  // 最大30秒
    int attempt = 0;
    
    while (1) {
        int sock = connect_to_server();
        if (sock > 0) {
            printf("Reconnected successfully\n");
            return sock;
        }
        
        // 计算等待时间
        int wait = base_interval * (1 << attempt);
        if (wait > max_interval) wait = max_interval;
        if (attempt < 3) wait = 1;  // 前3次快速重试
        
        printf("Reconnect attempt %d, waiting %d seconds\n", attempt + 1, wait);
        sleep(wait);
        
        attempt++;
    }
}
我的建议:

重连成功后,最好发一条「恢复连接」的消息给Server。Server收到后,可以把断连期间缓存的消息推送给Client。这样用户体验会好很多,不会出现「导航到一半突然没声音了」的情况。

4.5 实战中的坑与避坑指南

做多屏通信,我踩过的坑不少。挑几个典型的说说:

  • 粘包问题:TCP是流式协议,多条消息可能粘在一起。解决方案是定义消息边界,比如固定头部+长度字段,或者用特殊分隔符。我习惯用4字节长度头。
  • 缓冲区溢出:接收缓冲区一定要够大。我曾经设成256字节,结果一条导航信息就超了,导致数据截断。建议至少4KB起步。
  • 线程安全:多个Client线程同时写同一个Socket,不加锁会出乱序。用互斥锁保护写操作,或者每个连接单独一个发送队列。
  • IP地址变化:车载网络可能用DHCP,IP会变。建议用主机名或者mDNS做服务发现,别写死IP。
特别提醒:

不要在UI线程里做Socket操作。阻塞的read/write会卡住界面,用户会感觉「屏幕死机了」。一定要用独立线程或者异步IO。

4.6 小结

这一章我们完整走了一遍Socket通信的实现流程。从Server端的消息中转,到Client端的自动重连,再到心跳包的保活机制。这些东西看起来基础,但做好了,整个多屏互动的底座就稳了。

下一章,我们会在这个基础上,加入具体的业务逻辑——比如导航信息从中控屏同步到仪表盘,视频流从副驾屏推送到后排屏。到时候你会发现,底层的Socket通信写好了,上层业务就是「搭积木」的事。

嗯,今天就到这儿。代码都在上面了,建议你动手跑一遍。遇到问题,欢迎来交流。