简介:在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 与板载 CAN,Java 层怎么选
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 List<CanFrame> feed(byte[] data, int len) { List<CanFrame> 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) { List<CanFrame> frames = parser.feed(buf, n); if (listener != null && frames.size() > 0) { listener.onFrames(frames); } } } } public interface OnFrameReceived { void onFrames(List<CanFrame> frames); } }HandlerThread 兼顾了后台执行和线程安全:readLoop 跑在名为 can-reader 的子线程里,回调 onFrames 同样在该线程执行。业务层如果需要更新 UI,自己再往主线程 post,不要把界面刷新直接塞进回调。
这里有个常被忽略的点:listener 回调的执行耗时不能太长。如果业务层在 onFrames 里做了数据库写入或复杂计算,readLoop 会被拖慢,串口缓冲区很快被填满,底层的读缓冲溢出后会直接丢数据。回调里只做分发,耗时的解析放到下一个循环或交给业务层的线程池。
4. 从原始帧到业务值:scale、offset 与组帧下发
4.1 信号解析模型:用 raw + scale 还原真实物理量
CAN 帧里的 data 是裸的字节数组,车速、转速这些值需要按协议定义从字节里抽出比特段,再乘以比例因子、加上偏移量。一个典型定义是:起始位第 8 位,长度 16 位,scale=0.01,offset=0,那么真实车速 = 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; // true=Motorola, false=Intel 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 Map<Integer, 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 代码看起来很简单:创建 CanFrame,payload 填充位段,交给串口 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 返回 -1,logcat 里报 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 方法里置 running=false,等读线程退出后再 nativeClose。如果用的是线程池,也要在 onDestroy 里 shutdown。排查时用ls -l /proc/<pid>/fd看 fd 数量是否持续增长,如果一直涨就是有句柄没释放。
6. 没有实车怎么验证 Demo:回环测试与速率统计
6.1 用 loopback 模式自收自发:先证明链路是通的
没有 CAN 总线设备时,可以把 CAN 模块切到回环模式。回环模式下模块内部把自己发出的报文直接传回接收路径,不需要外部总线。在 Java 层你可以组一帧 ID=0x123 的报文发下去,然后读线程立刻能收到同一帧,如果解析出来的 ID、DLC、数据完全一致,至少说明串口链路、切帧状态机、组帧逻辑三个环节是通的。
6.2 帧频次与总线负载统计:给 Demo 增加两个可验证指标
链路通了之后,再进一步验证稳定性:统计每个 ID 每秒收到的帧数,对比理论周期。50ms 周期的报文,1 秒应该收到约 20 帧,偏差超过 20% 说明读线程被拖慢或者串口缓冲丢帧。另一个指标是总线负载率,把每帧的位长(ID 加数据加填充)累加除以波特率,大致估算当前总线占用。这一项直接验证了你的透传链路在真实场景下的余量。
最后说一个我自己的习惯:每次写 CAN Demo 都会先在工程里埋一个「帧计数器」界面,显示总帧数、各 ID 帧频次和最近一帧的时间戳。这比任何日志都好用,因为 CAN 调试的多数问题都表现为「频率不对」而不是「值不对」。跑通回环之后再上真车,你会发现总线上各种周期帧混在一起,计数器能帮你快速确认哪个信号没跟上或被解析错。希望帮到你。
本文还有配套的精品资源,点击获取