简介面向安卓开发者的控制器局域网通信示例工程适用于汽车电子、工业控制、物联网等需要与现场总线设备交互的场景适合有一定安卓基础并希望入门硬件通信的开发者。压缩包内共三百三十七个文件整体约二点一四兆字节包含安卓工程源码、构建配置、界面资源、依赖库以及可直接安装的验证包源码演示了完整收发流程界面文件用于状态展示依赖库提供底层协议支持编译产物便于对照学习。已有超过一千人学习本资源。开发者可从示例中掌握标准帧与扩展帧的组装、适配器连接与波特率设置、消息发送与监听解析、异常处理及断开连接等关键步骤还可学习多线程调度与界面刷新技巧借助内置安装包快速验证功能显著降低 CAN 通信开发门槛。1. Android Java 开发 CAN 通信 demo先搞清楚你要接的是哪一路 CAN做 Android Java 开发 CAN 通信 demo 的人多半是被这两类需求逼过来的一是车机或者诊断仪要读 ECU 数据二是工业设备要用手机做现场调试。拿到硬件的第一反应都是插上 USB-CAN 适配器就开干结果十有八九死在第一步——设备插上去了系统压根不认识它或者说设备认到了权限弹窗却反复出现。这个 demo 的难点不在 CAN 协议本身而在 Android 这一侧系统没有原生的 CAN 接口你要么走 USB Host API 跟外置适配器打交道要么走 SocketCAN 直接吃内核的 CAN 栈。这两条路的权限模型、收帧方式和调试手段完全不同动手前必须选对。这篇文章就把两条路都拆开附上能直接改的 Java 和 JNI 代码以及我在这类 demo 上踩过的现场坑。2. CAN 帧到底长什么样协议层和 Java 数据模型的对应2.1 标准帧、扩展帧和 RTRID 位数和数据长度别弄混CAN 总线上的最小通信单位是帧一个帧由仲裁段、控制段、数据段和校验段组成。应用层写代码时真正需要关心的只有三个字段ID、DLC 和数据。ID 决定了这条帧在总线上的优先级数值越小优先级越高DLC 表示数据段的字节数范围是 0 到 8数据段就是你要传的实际内容。标准帧和扩展帧的差别只在 ID 长度标准帧是 11 位 ID扩展帧是 29 位 ID。判断一帧是不是扩展帧看控制段里的 IDE 标志位就行。很多适配器的协议里会把这个标志位直接塞进 ID 字段的最高位比如用0x80000000表示扩展帧解析时必须先按位拆开再取 ID。这个细节在联调时经常翻车双方对 ID 的解析差一位总线上的帧就全部对不上。对比项标准帧扩展帧ID 长度11 位范围 0~0x7FF29 位范围 0~0x1FFFFFFF帧头开销较小较大多 8 位 ID 和标志位常见场景普通控制指令、传感器数据诊断协议、多节点复杂网络仲裁优先级相同 ID 时标准帧优先需要结合 ID 和标志位判断RTR 帧是远程请求帧帧本身不带数据用来向对面节点索要数据。现在绝大多数应用已经不用它了都是目标节点主动周期上报。demo 阶段遇到 RTR 帧直接忽略即可不必专门处理但要注意RTR 标志位如果被误当成数据长度解析会出现莫名其妙的 0 长度帧。2.2 应用层协议决定你怎么解析裸 CAN、J1939 和 UDS 的区别CAN 协议只规定了物理层和链路层怎么把帧搬到对端至于这 8 个字节代表什么、ID 怎么规划完全由应用层自己约定。这既是 CAN 灵活的地方也是联调时最容易扯皮的地方。常见的应用层协议主要有三类。裸 CAN 就是规定 ID 和数据格式不做标准化的协议封装。典型做法是ID 0x100 用来发控制指令数据字节 0~1 是目标地址2~3 是功能码后面跟着参数。demo 阶段用裸 CAN 最合适链路通了再考虑上完整协议栈。J1939 是重卡和工程机械领域的标准核心思想是 29 位扩展 ID 里拆分 PGN、源地址和目的地址多字节参数用低字节在前的方式存储。UDS 是汽车诊断的标准协议固定用一组诊断服务 ID例如请求方发 0x7E0应答方回 0x7E8数据段里第一字节是服务号后面跟着子功能和参数。CANopen 则在工业现场设备里很常见NMT、SDO、PDO 各司其职。选型上记住一条原则demo 只是验证 Android 和 CAN 硬件之间的通路千万别一上来就引入完整协议栈。先把裸 CAN 的收发跑通再按目标协议逐层封装。否则协议栈本身的问题和通路的问题混在一起排错成本会成倍上升。2.3 Java 里组一帧 CAN 数据ByteBuffer 和位运算CAN 帧的数据段固定最长 8 字节不足 8 字节的按 0 补齐。Java 侧最直接的做法是定义一个CanFrame类把 ID、扩展标志和 8 字节数据封装起来。public class CanFrame { public int id; // 11 位标准 ID 或 29 位扩展 ID public boolean isExtended; // 是否为扩展帧 public byte[] data new byte[8]; public int dlc; // 实际数据长度 0~8 }组合多字节数据时建议用ByteBuffer而不是手动移位。ByteBuffer可以通过order()明确指定字节序避免大小端踩坑// 把一个 int 和一个 short 塞进 CAN 数据段 byte[] payload new byte[8]; ByteBuffer buf ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); buf.putInt(0x01020304); // 设备状态字 buf.putShort((short) 0x0A); // 计数值 buf.put((byte) 0x01); // 帧序号这里有个常见的坑CAN 总线上多字节参数的字节序由应用层协议规定不是由 CAN 硬件决定的。J1939 常见低字节在前UDS 常见高字节在前。如果对面设备按小端解析你按大端组包数值就会整个颠倒。用ByteBuffer的好处是在组包阶段就把字节序定死解析的时候再按同样的order()读出来代码和协议能一一对应。发帧时还要注意 ID 的位组装。很多 USB-CAN 适配器的私有协议里一帧数据会包含 4 字节 ID 和 1 字节 DLCpublic byte[] buildCanFrame(CanFrame frame) { ByteBuffer out ByteBuffer.allocate(13).order(ByteOrder.LITTLE_ENDIAN); // 最高位置 1 表示扩展帧低位存实际 ID int id frame.id; if (frame.isExtended) { id | 0x80000000; } out.putInt(id); out.put((byte) frame.dlc); out.put(frame.data); return out.array(); }组帧逻辑就这么点。真正让新手头疼的不是组帧而是组好的帧发出去之后对面不响应这时候就要回头看下一章Android 怎么和 USB-CAN 适配器建立连接。3. USB-CAN 适配器接入Android USB Host API 的完整 demo3.1 为什么 USB-CAN 是大多数 demo 的首选Android 设备本身不带 CAN 控制器想跑 CAN 通信 demo最省事的方式是外接一个 USB-CAN 适配器。这类适配器一端是 USB另一端是 CAN_H、CAN_L 两个引脚插到 Android 的 USB 口上系统通过 USB Host API 和它通信。选择这条路线有三个理由不需要 root普通手机就能跑适配器便宜几十块到几百块不等USB Host API 是 Android 系统自带能力不涉及内核改动。USB-CAN 适配器内部有两种常见形态。一种是模拟串口的适配器在系统里被识别成一个/dev/ttyUSB设备CAN 帧被转换成串口字节流你需要用串口参数去适配。另一种是自定义 USB 协议的适配器暴露给 Android 的是一组 bulk 端点数据格式由厂商私有协议定义。两种形态在 Android 侧的接入方式完全不同采购硬件前一定要找厂商确认清楚或者查系统日志看设备被识别成什么类型。3.2 枚举设备和申请 USB 权限Android 从很早期版本就引入了 USB Host API核心入口是UsbManager。第一步是拿到UsbManager实例遍历当前连接的 USB 设备通过厂商 VID 匹配到你的 CAN 适配器。UsbManager usbManager (UsbManager) getSystemService(Context.USB_SERVICE); HashMapString, UsbDevice deviceList usbManager.getDeviceList(); UsbDevice canDevice null; for (UsbDevice device : deviceList.values()) { // VENDOR_VID 换成适配器厂商提供的 VIDPID 也可以一并判断 if (device.getVendorId() VENDOR_VID) { canDevice device; break; } } if (canDevice null) { // 设备没插上或驱动未识别检查 OTG 线和供电 return; }USB 设备插入时系统会弹窗询问用户是否允许应用访问。这个授权动作是异步的结果通过广播回调送出所以需要注册一个BroadcastReceiver来接收授权结果同时发起授权请求。private static final String ACTION_USB_PERMISSION com.example.can.ACTION_USB_PERMISSION; // 发起授权第一个参数是目标设备第二个是待发送的 PendingIntent PendingIntent permissionIntent PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(canDevice, permissionIntent);private final BroadcastReceiver usbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (!ACTION_USB_PERMISSION.equals(intent.getAction())) return; boolean granted intent.getBooleanExtra( UsbManager.EXTRA_PERMISSION_GRANTED, false); UsbDevice device intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (granted device ! null) { // 拿到权限开始打开设备 openCanDevice(device); } else { // 用户拒绝了授权提示后退出 } } };代码里的FLAG_IMMUTABLE是必须写的现在 Android 对新创建的PendingIntent强制要求指定 mutability不写会直接抛异常。另外注意广播接收器要在界面销毁时注销不然会出现内存泄漏和重复回调。3.3 打开接口与端点找对读写通道拿到UsbDevice之后真正打开设备是通过UsbDeviceConnection。打开前要先确认设备里有哪些接口和端点。对于 USB-CAN 适配器通常只有一个接口接口下有两个 bulk 端点一个用于接收IN一个用于发送OUT。UsbDeviceConnection connection usbManager.openDevice(canDevice); if (connection null) { // 打开失败通常是权限被回收或设备被占用 return; } UsbInterface usbInterface canDevice.getInterface(0); UsbEndpoint inEndpoint null; UsbEndpoint outEndpoint null; for (int i 0; i usbInterface.getEndpointCount(); i) { UsbEndpoint endpoint usbInterface.getEndpoint(i); if (endpoint.getType() ! UsbConstants.USB_ENDPOINT_XFER_BULK) { continue; // CAN 适配器一般用 bulk 传输 } if (endpoint.getDirection() UsbConstants.USB_DIR_IN) { inEndpoint endpoint; } else if (endpoint.getDirection() UsbConstants.USB_DIR_OUT) { outEndpoint endpoint; } } if (inEndpoint null || outEndpoint null) { connection.close(); return; } // claimInterface 把接口据为己有第二个参数 true 表示独占 connection.claimInterface(usbInterface, true);这里有几个值得注意的参数。getInterface(0)是拿第一个接口绝大多数适配器都只有一个接口但也有双接口的需要循环遍历。端点是按方向区分的IN 端点负责从设备读数据OUT 端点负责写数据搞反了直接读不到任何东西。claimInterface如果返回 false说明接口被其他应用占用常见原因是同一台设备同时连着调试工具。3.4 收发循环把总线上的帧变成 Java 对象接口和端点就位后收发数据走UsbDeviceConnection.bulkTransfer()。发送是同步调用接收建议放在子线程里循环执行避免阻塞界面。// 发送一帧以某厂商 13 字节协议为例 public boolean sendCanFrame(CanFrame frame) { byte[] packet buildCanFrame(frame); // 按厂商协议组包 int written connection.bulkTransfer(outEndpoint, packet, packet.length, 200); return written packet.length; }// 接收循环必须放在子线程不能占住主线程 new Thread(() - { byte[] buffer new byte[64]; // 按端点最大包长分配 while (!stopFlag.get()) { // 100ms 超时没有数据就继续轮询 int len connection.bulkTransfer(inEndpoint, buffer, buffer.length, 100); if (len 0) continue; CanFrame frame parseCanFrame(Arrays.copyOf(buffer, len)); if (frame ! null) { // 通过 Handler 或回调抛回主线程避免 UI 线程不安全操作 frameCallback.onFrame(frame); } } }, can-recv-thread).start();bulkTransfer的超时参数单位是毫秒设成 100 意味着每 100ms 轮询一次。这个值不要设太大否则停发指令后线程要等很久才能退出也不要设成 00 表示无限等待线程无法中断。接收缓冲区的长度要适配端点的getMaxPacketSize()很多适配器返回 64 字节但 CAN 帧本身只有 8 字节数据厂商会在协议里把帧头一起拼进来所以缓冲区给足 64 字节比较稳妥。parseCanFrame的解析逻辑就是 2.3 节组帧的逆过程先读 4 字节 ID判断最高位是否为扩展帧再读 1 字节 DLC最后取 DLC 个字节作数据。这里有个很隐蔽的坑有些适配器会在协议里加校验字节或帧序号解析时要把这些额外字段跳过否则 DLC 和数据位置会整体错位。4. SocketCAN 路线在 Android 设备上直接用 Linux 内核的 CAN 栈4.1 什么场景下值得走 SocketCANUSB-CAN 适配器适合快速验证但如果你的 Android 设备本身就是嵌入式板卡或者设备已经 root走 SocketCAN 是更干净的选择。SocketCAN 是 Linux 内核内置的 CAN 协议栈网络接口层直接以can0、can1这样的名字暴露给用户空间。应用层通过标准的 socket 接口收发 CAN 帧性能和稳定性都远好过 USB 转接。适用场景很明确设备硬件上带 CAN 控制器内核开启了 CAN 驱动接口配置好之后用ifconfig能看到can0。如果是普通手机外接 USB-CAN 适配器走 SocketCAN 并不合适因为适配器的驱动不在标准内核里。反过来如果你在做车机或者工业平板的定制系统SocketCAN 几乎是唯一值得认真考虑的方案。4.2 拉起 can0 接口ip 命令和波特率SocketCAN 的接口默认是 down 状态需要先用ip命令配置协议参数并激活。波特率在配置时指定一旦设定就不能动态修改。# 把 can0 设为 500kbit/s 并激活 ip link set can0 up type can bitrate 500000 # 查看 can0 当前状态 ip -details link show can0 # 真实硬件上抓帧验证用的命令 candump can0bitrate 500000对应 500kbit/s这是车载网络最常用的速率很多 ECU 默认就是这个值。总线上的所有节点必须使用相同的波特率否则任何一帧都收不到。candump是调试阶段最好用的抓包工具接上总线跑一下就知道链路通没通。如果板子上没有candump用cat /sys/class/net/can0/statistics/rx_errors也能看出是否有接收错误。4.3 用 JNI 封装 SocketCANnative 代码和 Java 接口SocketCAN 的 socket 接口是 Linux 系统调用Java 层直接访问不了常规做法是写一个 JNI 封装。Java 侧先定义 native 方法public class CanJni { static { System.loadLibrary(can_jni); } // 打开 can 接口返回文件描述符 public static native int open(String ifname); // 发送一帧id 为标准帧或扩展帧 IDdlc 为数据长度 public static native int send(int fd, int id, byte[] data, int dlc, boolean extended); // 接收一帧timeoutMs 为超时时间返回实际数据长度 public static native int receive(int fd, byte[] buffer, int timeoutMs); }C 侧用标准 Linux SocketCAN 头文件即可不需要引入外部库。打开接口的核心逻辑是创建 raw socket 然后绑定到具体的网络接口#include linux/can.h #include linux/can/raw.h #include net/if.h #include sys/socket.h #include string.h JNIEXPORT jint JNICALL Java_com_example_can_CanJni_open(JNIEnv *env, jobject thiz, jstring ifname) { const char *name (*env)-GetStringUTFChars(env, ifname, NULL); int fd socket(PF_CAN, SOCK_RAW, CAN_RAW); if (fd 0) { (*env)-ReleaseStringUTFChars(env, ifname, name); return -1; } struct sockaddr_can addr; memset(addr, 0, sizeof(addr)); addr.can_family AF_CAN; addr.can_ifindex if_nametoindex(name); // 接口不存在时返回 0 if (addr.can_ifindex 0) { close(fd); (*env)-ReleaseStringUTFChars(env, ifname, name); return -2; } int ret bind(fd, (struct sockaddr *)addr, sizeof(addr)); if (ret 0) { close(fd); (*env)-ReleaseStringUTFChars(env, ifname, name); return -3; } (*env)-ReleaseStringUTFChars(env, ifname, name); return fd; }发送一帧的 C 代码要处理扩展帧标志位。Linux 的can_frame结构体里can_id的最高位如果置了CAN_EFF_FLAG内核会按扩展帧处理JNIEXPORT jint JNICALL Java_com_example_can_CanJni_send(JNIEnv *env, jobject thiz, jint fd, jint id, jbyteArray data, jint dlc, jboolean extended) { struct can_frame frame; memset(frame, 0, sizeof(frame)); frame.can_id id; if (extended) { frame.can_id | CAN_EFF_FLAG; } frame.can_dlc dlc; // 把 Java 字节数组拷贝到 can_frame 的 data 字段 (*env)-GetByteArrayRegion(env, data, 0, dlc, (jbyte *)frame.data); ssize_t n write(fd, frame, sizeof(frame)); return (jint)n; }接收一帧需要处理超时。比较简单的方式是先用select判断文件描述符是否可读超时直接返回 0不阻塞调用线程JNIEXPORT jint JNICALL Java_com_example_can_CanJni_receive(JNIEnv *env, jobject thiz, jint fd, jbyteArray buffer, jint timeoutMs) { struct can_frame frame; fd_set fds; struct timeval tv; tv.tv_sec timeoutMs / 1000; tv.tv_usec (timeoutMs % 1000) * 1000; FD_ZERO(fds); FD_SET(fd, fds); int ret select(fd 1, fds, NULL, NULL, tv); if (ret 0) { return 0; // 超时或出错 } ssize_t n read(fd, frame, sizeof(frame)); if (n 0) { return -1; } // 把收到的数据拷贝回 Java 层 (*env)-SetByteArrayRegion(env, buffer, 0, frame.can_dlc, (jbyte *)frame.data); return frame.can_dlc; }这套 JNI 封装在嵌入式 Linux 板和 root 后的 Android 设备上都能跑。相比 USB-CAN 适配器路线的最大优势是语义清晰send和receive是真正的 CAN 帧收发没有厂商私有协议的中间层数据从总线到 Java 层只经过一次拷贝。缺点也明显需要交叉编译.so设备要能拿到can0接口这两条缺一条就走不通。5. CAN 通信 demo 避坑清单5 个能把人磨死的现场问题5.1 硬件上电了、权限也给了硬是一条帧都收不到现象USB-CAN 适配器在设备列表里能查到权限授权也通过了claimInterface成功但接收线程一直在空转一个帧都收不到。原因按出现频率排序第一是总线上根本没有其他节点在发数据CAN 总线没有数据时保持隐性电平收端不会产生任何中断第二是波特率不匹配收端和发端速率不一致物理层上表现为持续错误帧第三是终端电阻没接CAN 总线两端各需要一个 120Ω 电阻没有终端电阻时信号反射严重近端可能能通远端丢帧极其严重第四是适配器内部默认开了过滤只透传特定 ID其他 ID 直接被硬件丢弃。解决先用candump或适配器自带的 Windows 工具在电脑上验证硬件本身能不能收帧。然后把收发两端用一根短总线直连确认只有两个节点时能否通信。接着逐项检查波特率是否一致、终端电阻是否接上。最后查适配器手册里的过滤配置确认没有开 ID 白名单。5.2 收是能收到了但帧内容八字节变六字节、时对时错现象总线另一侧的设备明明发了 8 字节Android 这边解析出来却只有 6 字节或者同一帧数据有时候对有时候错校验位经常过不去。原因最常见的是bulkTransfer返回的长度不是按帧对齐的。USB bulk 传输按端点最大包长切分数据适配器如果一帧协议包超过端点包长会被拆成多次传输。接收方如果只调用一次bulkTransfer拿到的可能只是半截数据。另一个原因是 DMA 或驱动层的时序问题但概率低先不往那想。解决接收时按协议帧长度循环读直到凑齐一整个协议包再丢给解析函数。具体做法是维护一个累积缓冲区每次把bulkTransfer读到的字节追加进去然后检查缓冲区长度是否达到协议帧头声明的长度。达到就切一帧出来解析剩下的留在缓冲区继续累积。解析前还要校验协议帧头里的魔数和长度字段魔数不对说明对齐错了直接清空缓冲区重新同步。5.3 USB 权限弹窗阴魂不散Activity 一重建就掉线现象第一次插入设备系统弹窗授权点击允许后一切正常。但旋转屏幕或者从后台切回来连接就断了重新插拔后又开始弹窗。原因UsbManager.requestPermission()请求的权限是和 PendingIntent 绑定的而 PendingIntent 里带的 Activity 或接收器组件如果因为 Activity 重建而销毁授权结果就丢了。更关键的是UsbDeviceConnection没有持久化持有Activity 重建时新的实例没有恢复之前的连接设备就被系统回收了。解决把连接相关对象全部放到一个全局单例或者Application子类里持有Activity 重建时从单例里取连接而不是重新openDevice。注册广播接收器放在onCreate注销放在onDestroy避免重复注册。同时在onResume里检查连接状态发现连接无效就重新枚举设备并拉起权限流程。权限弹窗本身是系统行为无法绕过但把连接的持有方式改对之后弹窗频率会明显下降。5.4 串口转 CAN 的适配器波特率到底在哪设现象适配器插上后设备列表里有个/dev/ttyUSB0之类的串口节点应用按厂商协议组包发送结果一帧都没发出去。原因这类模拟串口的 USB-CAN 适配器波特率不是在 Android 软件里设置的。适配器内部有一颗 MCU 管理 CAN 控制器MCU 的固件默认了一个波特率常见是 500K你要么用厂商提供的 Windows 工具改固件配置要么通过适配器专用的配置命令写入。如果应用直接把字节流发出去而没先做配置CAN 控制器还在用默认参数工作和总线波特率对不上自然发不出去。解决采购前直接问厂商要 Android 示例代码重点看三件事是否需要在收发前先发一段配置命令、配置命令是每次上电都要发还是掉电记忆、是否独占串口。有些适配器把配置命令做成厂家保密的Android 侧只能在厂商 SDK 范围内调用这种就没办法自己写通用 demo只能在厂商 SDK 上做二次封装。5.5 一收数据主线程就卡死把阻塞读放错线程的后果现象demo 刚跑通时还挺高兴往界面拖了个TextView实时显示数据一运行界面直接白屏过两秒弹出系统无响应。原因把接收循环或bulkTransfer调用写在了主线程里。bulkTransfer是阻塞调用即使超时 100ms主线程也会被反复挂起加上 CAN 数据量一大界面事件根本得不到执行必然 ANR。接收到的数据如果直接更新 View还会引发跨线程更新 UI 的异常。解决接收循环一律放后台线程数据回调通过Handler或者runOnUiThread抛回主线程。回调里只更新 UI 或者把数据丢给队列不要做耗时操作。线程退出用标志位而不是Thread.stop()在超时结束后检查标志并主动 break。这样写既能保证界面流畅也能在界面销毁时及时释放资源。6. 从 demo 到能用帧过滤、日志和环回自测6.1 帧过滤和日志让 demo 有资格变成工具demo 跑通之后下一步是让它能稳定地挂在总线上。总线上可能同时跑着几十个 ID 的帧如果全收进来看不过来也会拖累 CPU。SocketCAN 在内核里就支持过滤规则Java 层通过 JNI 把规则传给内核USB-CAN 适配器则要看厂商有没有提供透传过滤的配置命令没有的话只能在应用层做软过滤。struct can_filter filters[2]; filters[0].can_id 0x123; filters[0].can_mask CAN_SFF_MASK; // 只匹配标准帧 ID 0x123 filters[1].can_id 0x456; filters[1].can_mask CAN_SFF_MASK; setsockopt(fd, SOL_CAN_RAW, CAN_RAW_FILTER, filters, sizeof(filters));CAN_SFF_MASK是 11 位标准帧掩码CAN_EFF_MASK对应 29 位扩展帧。多个过滤规则可以叠加只保留你关心的 ID剩下的帧在内核里就被丢掉了省一次用户态拷贝。日志记录同样不能省每条帧要记时间戳、方向、ID、DLC 和数据格式上对齐到 PostgreSQL 导入方便的 CSV 或 JSON Lines。我见过不少 demo 死在现场联调阶段就是因为没有日志出了问题完全没法复盘。日志文件和发送代码隔离接收线程只管往队列里塞落盘由单独线程处理。6.2 环回自测验证链路的最快办法没有 CAN 总线环境的时候环回模式是验证软硬件链路最快的手段。SocketCAN 支持把某接口设为环回模式发送的帧直接由内核回环给本机接收路径等于自己发自己收。ip link set can0 type can loopback on也可以把 CAN_H 和 CAN_L 两根线短接来触发硬件环回。短接后发送的帧由物理层直接反射回来硬件链路、收发电路和协议栈全链路都能验证。USB-CAN 适配器同样适用于这个方法把 CAN_H 和 CAN_L 用一根线连起来打开接收循环然后周期性发送一帧 ID 已知的数据看是否能收到完全一致的帧。我一般用 0x123 加上递增的计数字节做自测收到后比对计数值是否连续连续说明链路稳定间断说明有丢帧。这两类验证都该写进 demo 的测试用例里而不是靠眼看。CAN 调试最怕的是总线通但无人回应只要环回自测能通过链路本身没问题剩下的就是应用层协议的事情了。做 CAN 通信的 demo 是一条链路的活硬件、系统、协议三个环节都要通我自己的习惯是每调通一次就把当时的波特率、接线方式、适配器型号和异常现象记到调试笔记里否则隔几天再回来面对一个插着 USB-CAN 的开发板你完全想不起来上次是怎么通的。希望帮到你。本文还有配套的精品资源点击获取