第2章:以太网基础——TCP/IP协议栈概述、MAC地址与IP地址、UDP与TCP协议对比、Socket编程基础

好,咱们正式开始啃以太网这块硬骨头。很多做CAN的朋友,一听到以太网就头大,觉得那是搞IT的人玩的。其实没那么玄乎。你想想看,CAN和以太网本质上都是在做同一件事——把数据从A点搬到B点,只是规矩不同罢了。

我个人习惯,在讲任何协议之前,先搞清楚它长什么样,为什么要这么长。今天这一章,就是给咱们的CAN转以太网网关打地基。地基不牢,后面写代码全是坑。

2.1 TCP/IP协议栈:到底是个什么玩意儿?

说白了,TCP/IP协议栈就是一套通信的规矩。它把整个通信过程分成了四层,每一层只管自己的事。我刚开始学的时候,总觉得分层是脱裤子放屁,直到我在一个项目里,因为物理层没处理好,导致整个网关疯狂丢包……嗯,从那以后我再也不敢小看任何一层了。

这四层分别是:

  • 应用层:你写的程序就在这层。比如HTTP、MQTT,还有咱们后面要用的自定义协议。
  • 传输层:负责端到端的通信。主要两个小弟——TCP和UDP,后面细聊。
  • 网络层:核心是IP协议,负责寻址和路由。说白了就是告诉数据包:“你该往哪走”。
  • 网络接口层:最底层,管的是网线、网卡、MAC地址这些物理玩意儿。

记住一个原则:上层只管把数据扔给下层,下层只管把数据传出去。每一层都只跟自己的邻居打交道。这就是分层的好处——你改应用层代码,不用去动网卡驱动。

我在做第一个网关原型时,犯过一个低级错误:我在应用层封装了一个很大的数据包,结果发现网络层死活发不出去。后来查了半天,才发现是MTU(最大传输单元)的限制。这就是不懂分层的代价。

2.2 MAC地址与IP地址:两个地址,各管一摊

很多新手会问:为什么要有两个地址?一个不够吗?

我打个比方你就明白了。IP地址就像你的家庭住址,是逻辑上的位置。MAC地址就像你的身份证号,是物理上的唯一标识。你搬家了,家庭住址会变,但身份证号不会变。对吧?

MAC地址

  • 48位,通常写成 00:1A:2B:3C:4D:5E 这种格式
  • 出厂时烧录在网卡里,理论上全球唯一
  • 工作在数据链路层,负责局域网内的设备识别

IP地址

  • IPv4是32位,写成 192.168.1.100 这种点分十进制
  • 可以手动配置,也可以DHCP自动获取
  • 工作在网络层,负责跨网络的路由寻址

避坑指南:我曾经在一个项目里,因为两个设备的MAC地址冲突,导致整个局域网广播风暴。查了整整两天才找到原因。所以,如果你用开发板做网关,记得先检查一下MAC地址是不是唯一的。很多廉价开发板出厂时MAC地址是重复的!

在咱们的CAN转以太网网关里,IP地址是用来让其他设备找到你的网关的。MAC地址则是交换机用来转发数据帧的依据。两个地址,缺一不可。

2.3 UDP与TCP协议对比:一个快,一个稳

这两个协议,是传输层的两大主力。我经常被问到:“做网关到底用TCP还是UDP?”

我的回答是:看场景。

先看个对比表:

特性 UDP TCP
连接方式 无连接,直接发 面向连接,先握手
可靠性 不可靠,丢了不管 可靠,丢包重传
速度 快,开销小 慢,有确认机制
数据边界 保留消息边界 流式传输,无边界
典型应用 视频流、DNS、游戏 网页、文件传输、邮件

UDP的特点:

  • 像寄明信片,写了地址就扔邮筒,不保证对方能收到
  • 没有握手和挥手,延迟极低
  • 适合实时性要求高、可以容忍少量丢包的场景

TCP的特点:

  • 像寄挂号信,有回执,丢了会重发
  • 有三次握手建立连接,四次挥手断开连接
  • 适合要求数据完整、不能出错的场景

注意:在CAN转以太网网关中,我个人更倾向于UDP。为什么?因为CAN本身就是一个实时性很强的总线,数据量小,频率高。如果用TCP,握手重传的开销反而会拖慢整个系统。当然,如果你的应用场景要求绝对不能丢数据(比如固件升级),那就老老实实用TCP。

我记得有一次,客户要求把CAN上的电机控制数据通过网关转发到上位机。我一开始用了TCP,结果发现电机一加速,网关就卡死。后来换成UDP,问题迎刃而解。原因就是TCP的拥塞控制机制在高速数据流下反而成了瓶颈。

2.4 Socket编程基础:让代码开口说话

Socket,中文叫套接字,说白了就是应用程序和网络协议栈之间的一个接口。你往Socket里写数据,协议栈就帮你发出去。你从Socket里读数据,协议栈就把收到的数据递给你。

在嵌入式Linux环境下,Socket编程其实很简单。我给大家看一个最基础的UDP收发示例:

// UDP发送端示例
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>

int main() {
    int sockfd;
    struct sockaddr_in dest_addr;
    char *msg = "Hello from CAN Gateway!";

    // 1. 创建Socket
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    
    // 2. 设置目标地址
    dest_addr.sin_family = AF_INET;
    dest_addr.sin_port = htons(8888);
    dest_addr.sin_addr.s_addr = inet_addr("192.168.1.100");

    // 3. 发送数据
    sendto(sockfd, msg, strlen(msg), 0,
           (struct sockaddr *)&dest_addr, sizeof(dest_addr));

    // 4. 关闭Socket
    close(sockfd);
    return 0;
}

这段代码干了四件事:创建Socket、设置地址、发送数据、关闭Socket。就这么简单。

对于TCP,流程稍微复杂一点,多了个连接的过程:

// TCP客户端示例
int sockfd;
struct sockaddr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);

server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = inet_addr("192.168.1.100");

// 先连接
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 再发送
send(sockfd, msg, strlen(msg), 0);

// 最后关闭
close(sockfd);

小技巧:在写网关程序时,我建议把Socket的创建和配置封装成一个函数。比如叫 udp_socket_init() 或者 tcp_client_connect()。这样主程序看起来清爽,也方便后期维护。我曾经在一个项目里,因为Socket创建代码散落在各个地方,改个端口号要改五个文件……那滋味,你懂的。

好了,这一章的内容就到这里。以太网基础打牢了,后面咱们才能放心地让CAN和以太网“联姻”。下一章,我会带大家看看硬件层面怎么选型,以及如何设计一个靠谱的网关电路。