简介在Android平台上基于Java实现CAN通信的完整演示工程面向车载电子、工控设备及物联网应用开发者重点演示标准帧与扩展帧的收发、解析、显示以及设备连接、波特率配置和错误处理等关键环节能够帮助读者快速理解can4android等开源库的集成思路。压缩包共三百三十七个文件大小约二点一四兆字节包含Java源码、Gradle构建脚本、AndroidManifest配置、资源XML、依赖JAR包以及构建生成的APK、class、dex等产物目录覆盖从工程配置到编译运行的全流程。目前已有一千零五十四人学习下载适合需要直接对照代码实现CAN适配器接入的Android开发人员参考。工程内还提供了权限申请、设备查找、消息监听回调、数据解析与断开连接等典型代码段并配有可安装的调试APK与界面示例能够帮助减少原生CAN开发中的排错时间是入门车载通信开发时实用价值较高的参考demo。1. 想在 Android 上用 Java 写清 CAN 通信 Demo先别急着找 SDK很多做车载或工控的开发者第一次接触这个标题时都会直接去搜某个板卡厂家的 SDK 文档结果发现要么绑定硬件、要么文档残缺折腾一整天都没读到一帧真实数据。实际上 Android 系统对 CAN 总线没有任何原生 API所谓「Android Java 开发 CAN 通信 Demo」核心就三件事打通一条物理通道、把字节流按 CAN 帧格式切开、再把帧里的信号换算成业务值。这篇文章适合准备在工控平板、车载中控屏或自购模块上试水的开发者内容围绕接入方式选型、Java 代码骨架和常见翻车点展开。照着搭完你手上会有一个能收发帧、能解析信号的最小可运行工程。2. 先定接入方式串口透传、USB-CAN 与板载 CANJava 层怎么选2.1 三种接入方式的边界你的设备上有没有现成 CAN 口Android 设备想碰 CAN 总线只有三条路板载 CAN 控制器、USB 转 CAN 适配器、串口转 CAN 模块。这三者在 Java 层的开发量差别很大选错方向会让 Demo 卡在硬件适配阶段。板载 CAN 控制器常见于工业级主板厂商会提供一套 JNI 动态库Java 层只需要调用 open、send、receive 几个声明为 native 的方法。这是最省事的路径但换了主板就要换库代码无法复用。USB 转 CAN 适配器走 Android USB Host 协议厂商同样给 SDK缺点是 OTG 供电不稳的时候容易出现设备掉线而且很多低价适配器的 SDK 包写得比较粗糙回调线程和权限管理要自己做。串口转 CAN 模块是通用性最好的方案。模块一端接 CAN 总线一端通过 TTL 串口与 Android 设备通信Android 端只需要把串口当普通文件读写。市面上常见模块默认工作在「透传模式」CAN 总线上来一帧串口立刻吐出一段固定结构的字节流你下发一段字节流模块就把它转成一帧 CAN 报文发出。这个模式不绑定任何厂商 API换模块只是换波特率和帧格式定义Java 代码几乎不用动。我的建议是学习用串口透传方案生产环境再看板卡原生 SDK。表三种接入方式对比接入方式Java 层工作量硬件依赖适合场景板载 CAN最小调用封装好的 native 接口特定工业主板定点量产USB 转 CAN中等处理厂商 SDK 回调OTG 线 适配器调试、实验室串口转 CAN最大但通用性最强任意串口 模块跨平台 Demo、教学2.2 透传模式下原始帧是怎么从串口到 Java 的字节流与帧头串口是一种流式接口它不关心你传的是几帧数据只会按照波特率把字节一个一个送过来。CAN 模块为了让你区分帧边界会在每帧 CAN 报文前后加上帧头、ID、DLC、数据区和校验。不同厂家的透传帧格式不一样但都有一个共同特征帧头是固定字节帧长是固定的或者可以从 DLC 推算出来。这里以很多模块通用的「帧头 4 字节 ID 1 字节 DLC 8 字节 DATA 1 字节校验」13 字节结构为例。你从串口读到的原始数据是连续字节流可能出现一次读入半帧、两帧被拼在一起的情况。Java 层不能一读到数据就按固定偏移解析必须做一个「切帧」状态机逐个字节扫描找到帧头后开始积累攒够 13 字节再按帧处理。2.3 最小 Java 设备层代码串口打开、读取与关闭Android 打开串口的本质就是打开 /dev/ttySx 这个文件。Java 层的 FileInputStream 可以做读写但波特率、数据位、校验位这些参数需要底层 termios 配置所以必须有很小一段 native 代码。多数开发者不会自己写 JNI而是直接使用现成的串口库但为了让你看清原理我给出一个等价于底层接口的 Java 封装。public class CanSerialPort { private int fd -1; // 底层文件描述符 /** * 打开串口并配置 8N1 模式 * param path 串口设备路径如 /dev/ttyS3 * param baudrate 波特率CAN 透传模块常见 115200 或 460800 */ public synchronized boolean open(String path, int baudrate) { close(); fd nativeOpen(path, baudrate); return fd 0; } public synchronized void close() { if (fd 0) { nativeClose(fd); fd -1; } } public int read(byte[] buffer, int size) { if (fd 0) return -1; return nativeRead(fd, buffer, size); } public int write(byte[] buffer, int size) { if (fd 0) return -1; return nativeWrite(fd, buffer, size); } // native 方法由底层库实现负责 open / close / read / write private native int nativeOpen(String path, int baudrate); private native void nativeClose(int fd); private native int nativeRead(int fd, byte[] buf, int size); private native int nativeWrite(int fd, byte[] buf, int size); }这段代码里 open 方法先 close 再重新打开是为了防止上一次 session 没释放完导致设备占用。read 和 write 都是阻塞式调用底层库内部会把 Java byte 数组与 C 层缓冲做零拷贝映射。实际使用时read 应该放在独立线程里循环调用因为阻塞读会一直占住当前线程。参数上你需要关心的只有两个设备路径和波特率。设备路径由硬件决定常见的是 /dev/ttyS0、/dev/ttyS1、/dev/ttyS3不同主板差异很大可以用 shell 命令查看串口列表确认。波特率必须和 CAN 模块侧一致CAN 总线的波特率和串口波特率是两套东西CAN 总线侧常见 250K 或 500K串口侧常见 115200别把两者混为一谈。3. CAN 帧在 Java 侧的数据模型从字节流到可读字段3.1 标准帧与扩展帧的判断ID、DLC、8 字节数据怎么放CAN 帧的核心字段不多ID、DLC、数据、以及区分标准帧和扩展帧的标志位。标准帧 ID 是 11 位范围 0x000~0x7FF扩展帧 ID 是 29 位范围 0x00000000~0x1FFFFFFF。透传模块通常在帧格式里专门用一个字段标记是否扩展帧或者根据 ID 字段的长度判断。DLC 表示数据域长度CAN 2.0 规定最多 8 字节所以 Java 侧用一个 byte[8] 就够。要注意的是模块吐出来的 DLC 可能小于 8此时解析数据区只取前 DLC 个字节剩余字节忽略。这一条在切帧时就要处理好否则帧与帧之间的数据会因为错位全部乱掉。public final class CanFrame { public int id; // 原始 ID public int dlc; // 有效数据长度 public byte[] data new byte[8]; public boolean isExtended; // 标准帧还是扩展帧 public long timestampMs; // 接收时间戳用于超时判断 }ID 在 Java 侧用 int 存即可29 位扩展帧最大 0x1FFFFFFF 没有溢出风险。data 一开始就固定分配 8 字节避免每收一帧就 new 一次数组产生 GC 压力。timestampMs 建议用 System.currentTimeMillis() 填充虽然它不是严格单调的但 CAN 数据帧周期都在毫秒级以上够用。3.2 用状态机把字节流切成 CAN 帧粘包拆包处理串口读取最常见的现象就是粘包和半包。你在 read 里一次读到 30 字节里面有 2 帧完整报文加半帧残留。如果直接按帧头偏移取数据第二帧就会从错误位置开始解析。正确做法是维护一个内部缓冲逐字节扫描识别帧头后把完整帧提取出来。public class CanStreamParser { private static final byte FRAME_HEAD (byte) 0xAA; private static final int FRAME_LEN 13; // 帧头1 ID4 DLC1 DATA8 校验1 private final byte[] buffer new byte[64]; private int bufferPos 0; public ListCanFrame feed(byte[] data, int len) { ListCanFrame frames new ArrayList(); for (int i 0; i len; i) { buffer[bufferPos] data[i]; // 当前字节不是帧头且缓冲还没到帧长时继续等 if (buffer[0] ! FRAME_HEAD) { bufferPos 0; continue; } // 攒够一帧长度才开始处理 if (bufferPos FRAME_LEN) { CanFrame frame parseFrame(buffer, FRAME_LEN); if (frame ! null) { frames.add(frame); } bufferPos 0; // 一帧消费完重新找下一帧头 } } return frames; } }这段状态机逻辑并不复杂buffer 的第一个字节必须是帧头不是就放弃并重置是帧头就一直攒字节攒到 FRAME_LEN 就切出一帧。关键在于缓存长度要大于最大帧长否则粘包时第二次帧头会落在缓存末尾附近造成越界。buffer 设置为 64 足够容纳好几帧残留数据。实际工程里还要在 parseFrame 里做校验字节核对防止串口线路干扰造成的脏数据被当成真帧。校验不过的帧直接丢弃不要尝试修复因为 CAN 底层已经有 CRC 保证透传帧校验失败基本意味着串口波特率不匹配或者硬件干扰丢帧是最安全的选择。3.3 收帧线程与回调设计HandlerThread 是底线串口读是阻塞操作不能放主线程。很多初学者直接在 Activity 的 onCreate 里循环调用 read界面立即 ANR。正确姿势是启动一个专用读线程把解析出来的 CanFrame 通过回调抛给业务层。public class CanReceiver { private CanSerialPort serialPort; private CanStreamParser parser new CanStreamParser(); private HandlerThread workerThread; private Handler workerHandler; private volatile boolean running false; private OnFrameReceived listener; public void start() { workerThread new HandlerThread(can-reader); workerThread.start(); workerHandler new Handler(workerThread.getLooper()); running true; workerHandler.post(this::readLoop); } private void readLoop() { byte[] buf new byte[128]; while (running) { int n serialPort.read(buf, buf.length); if (n 0) { ListCanFrame frames parser.feed(buf, n); if (listener ! null frames.size() 0) { listener.onFrames(frames); } } } } public interface OnFrameReceived { void onFrames(ListCanFrame frames); } }HandlerThread 兼顾了后台执行和线程安全readLoop 跑在名为 can-reader 的子线程里回调 onFrames 同样在该线程执行。业务层如果需要更新 UI自己再往主线程 post不要把界面刷新直接塞进回调。这里有个常被忽略的点listener 回调的执行耗时不能太长。如果业务层在 onFrames 里做了数据库写入或复杂计算readLoop 会被拖慢串口缓冲区很快被填满底层的读缓冲溢出后会直接丢数据。回调里只做分发耗时的解析放到下一个循环或交给业务层的线程池。4. 从原始帧到业务值scale、offset 与组帧下发4.1 信号解析模型用 raw scale 还原真实物理量CAN 帧里的 data 是裸的字节数组车速、转速这些值需要按协议定义从字节里抽出比特段再乘以比例因子、加上偏移量。一个典型定义是起始位第 8 位长度 16 位scale0.01offset0那么真实车速 raw * 0.01 km/h。public class CanSignal { public final int id; // 所在帧 ID public final int startBit; // 起始位从 0 开始 public final int bitLength; // 位长度通常 8/16/32 public final double scale; // 比例因子 public final double offset; // 偏移量 public final boolean bigEndian; // trueMotorola, falseIntel public CanSignal(int id, int startBit, int bitLength, double scale, double offset, boolean bigEndian) { this.id id; this.startBit startBit; this.bitLength bitLength; this.scale scale; this.offset offset; this.bigEndian bigEndian; } public double decode(CanFrame frame) { long raw extractBits(frame.data, startBit, bitLength, bigEndian); return raw * scale offset; } }extractBits 是这里最容易被写错的函数。CAN 信号位序有两种Intel 格式低位在前Motorola 格式高位在前。同样一段 0x1234 的数据两种解析出来 raw 值完全不同。我建议把 endian 作为 CanSignal 构造参数显式传进来而不是靠信号名猜测。在 Demo 阶段可以先只支持 Intel 格式但数据结构上把 bigEndian 字段留好生产环境碰到标准车厂 DBC 时直接补实现。4.2 周期帧超时与丢帧检测时间戳比你想的更重要CAN 总线上的大部分报文是周期性的比如 50ms 一帧转速、100ms 一帧车速。当解析器长时间没收到某个 ID 的帧说明要么节点掉线、要么总线故障。这时候业务层需要能够感知「这个信号过期了」否则会一直用最后一帧的旧值做计算这在车辆控制里非常危险。public class FrameTimeoutDetector { private final MapInteger, Long lastTimeMap new ConcurrentHashMap(); private final long timeoutMs; public FrameTimeoutDetector(long timeoutMs) { this.timeoutMs timeoutMs; // 一般设为信号周期的 2~3 倍 } public void update(CanFrame frame) { lastTimeMap.put(frame.id, frame.timestampMs); } public boolean isTimeout(int id, long nowMs) { Long last lastTimeMap.get(id); if (last null) return true; return (nowMs - last) timeoutMs; } }这个类的使用要点是每收到一帧就 update业务侧定时器按 100ms 周期扫一遍关心的 ID 列表调用 isTimeout 判断。timeoutMs 不要设置成恰好等于信号周期CAN 总线本身有 jitter偶发延迟很容易造成误报一般取信号周期的 2 到 3 倍。丢帧检测和解析是两个独立动作。解析负责把收到的字节变数值检测只负责看每个 ID 的「最新接收时刻」是否还在有效期内。两者职责分开代码维护起来会很清晰。4.3 下发命令组帧把控制位塞进 8 字节的坑Demo 不只是收还要能发。发送一帧 CAN 报文的 Java 代码看起来很简单创建 CanFramepayload 填充位段交给串口 write。但组帧的位操作和解析是逆过程最容易在符号和无符号上出错。public byte[] buildFrame(int id, boolean isExtended, CanSignal[] signals, long[] values) { byte[] data new byte[8]; for (CanSignal signal : signals) { long raw Math.round((values[0] - signal.offset) / signal.scale); // 把 raw 按 startBit 写入 data int bitCount signal.bitLength; int bitIndex signal.startBit; while (bitCount 0) { int byteIndex bitIndex / 8; int bitOffset bitIndex % 8; int take Math.min(8 - bitOffset, bitCount); long masked (raw (signal.bitLength - bitCount)) ((1L take) - 1); data[byteIndex] | (byte) (masked bitOffset); bitIndex take; bitCount - take; } } return data; }这段代码默认按 bit0 是字节最低位处理也就是小端位序对应 CAN 协议中非常常见的 Intel 格式。如果你的协议是 Motorola 格式上面这个移位逻辑会整体错位需要按字节再倒一次序。这里有个血泪经验组帧前先打印 hex肉眼核对每一位不要直接发到总线上。发送频率也要控制。很多模块的串口透传模式没有流控你连续 write 多帧模块内部缓冲不够时不会帮你排队只会丢帧。保守的做法是每帧之间至少间隔 10ms优先保证总线上有数据而不是数据完整。Demo 阶段控制命令本来就是低频的这个限制完全可以接受。5. Android CAN Demo 避坑指南五个翻车现场与解决办法5.1 串口打不开默认权限和 SELinux 策略拦住你现象调用 nativeOpen 返回 -1logcat 里报 Permission denied。原因Android 应用默认没有 /dev/ttySx 的读写权限部分设备还有 SELinux 限制即使 root 也访问不了。解决先在 shell 里ls -l /dev/ttyS*确认设备节点权限归属。常见做法是添加一个 ueventd 规则把节点赋给 system 组然后在 app 的 AndroidManifest 里声明使用系统权限或者直接 chmod 666。Demo 阶段最简单的方法是 root 后用 adb 临时设置权限验证链路之后再固化到设备的 init.rc 里。5.2 波特率不匹配数据「好像有」但全是乱码现象串口能打开read 也有数据但解析器一帧都拼不出来或者偶尔拼出来也是校验错误的脏帧。原因串口波特率与 CAN 模块的不一致。115200 的设备被配成 9600 后字节位序直接被采样错位。解决确认 CAN 模块侧面标称的默认串口波特率再用串口库或 shell 的 stty 对照确认。板上多个串口的波特率配置可能相互独立改了一个不代表全部生效。建议在 Demo 启动时打一行日志输出实际配置的波特率防止代码里某个常量被覆盖。5.3 读线程写主线程一收数据界面就卡现象数据一进来 Activity 突然变慢点击按钮无响应logcat 里出现 Choreographer 卡顿告警。原因回调里直接做了 UI 操作或者 read 循环和 UI 操作之间没有线程切换。解决onFrames 回调发生在 HandlerThread 上里面只做数据分发。UI 更新用 runOnUiThread 或 View.post 切回主线程耗时统计用独立的计算线程。如果业务层要修改 List 或 Map 数据确保回调里的容器与主线程访问的是同一实例否则要做同步。5.4 数据位序搞反解析结果在合理值附近来回跳现象信号值时对时不对低速的时候正常高速的时候乱跳而且看起来很有规律。原因CAN 信号是 16 位或 32 位跨字节段Intel 格式把低字节放前面Motorola 格式把高字节放前面解析器固定按一种格式处理碰到另一种就会错位。解决不要猜直接用 CAN 分析仪抓一帧已知报文人工把 data 按协议定义的起始位展开成二进制推导出正确 raw。然后把推导过程写成单元测试固定下来改代码时跑一遍。Demo 里如果只有一种设备可以在 CanSignal 构造时把 endian 写死但要留字段。5.5 句柄泄漏反复 close 之后设备再也打不开现象多次 open/close 后某次打开失败或者 close 之后设备节点被占用重启应用也无效。原因native 层的文件描述符没有在 close 时释放Java 层 GC 不感知 fd。读写线程还挂在旧 fd 上设备驱动认为端口被占用。解决open 成功之后立刻记录 fd 非负close 方法里置 runningfalse等读线程退出后再 nativeClose。如果用的是线程池也要在 onDestroy 里 shutdown。排查时用ls -l /proc/pid/fd看 fd 数量是否持续增长如果一直涨就是有句柄没释放。6. 没有实车怎么验证 Demo回环测试与速率统计6.1 用 loopback 模式自收自发先证明链路是通的没有 CAN 总线设备时可以把 CAN 模块切到回环模式。回环模式下模块内部把自己发出的报文直接传回接收路径不需要外部总线。在 Java 层你可以组一帧 ID0x123 的报文发下去然后读线程立刻能收到同一帧如果解析出来的 ID、DLC、数据完全一致至少说明串口链路、切帧状态机、组帧逻辑三个环节是通的。6.2 帧频次与总线负载统计给 Demo 增加两个可验证指标链路通了之后再进一步验证稳定性统计每个 ID 每秒收到的帧数对比理论周期。50ms 周期的报文1 秒应该收到约 20 帧偏差超过 20% 说明读线程被拖慢或者串口缓冲丢帧。另一个指标是总线负载率把每帧的位长ID 加数据加填充累加除以波特率大致估算当前总线占用。这一项直接验证了你的透传链路在真实场景下的余量。最后说一个我自己的习惯每次写 CAN Demo 都会先在工程里埋一个「帧计数器」界面显示总帧数、各 ID 帧频次和最近一帧的时间戳。这比任何日志都好用因为 CAN 调试的多数问题都表现为「频率不对」而不是「值不对」。跑通回环之后再上真车你会发现总线上各种周期帧混在一起计数器能帮你快速确认哪个信号没跟上或被解析错。希望帮到你。本文还有配套的精品资源点击获取