3、Android串口API详解:SerialPort类源码分析、open()/close()/read()/write()方法实现。
好,咱们直接进入正题。上一章我们聊了串口的基础概念和硬件接线,这一章我带你深入看看Android这边到底是怎么操作串口的。说白了,就是扒开SerialPort这个类的底裤,看看它到底干了什么。
很多初学者拿到一个串口库,上来就调open()、read()、write(),觉得挺简单。但真到了项目里,各种奇奇怪怪的问题就冒出来了——打开失败、读数据乱码、写数据丢包、关闭后资源没释放……嗯,这些坑我基本都踩过。所以这一章,咱们把SerialPort的源码掰开揉碎了讲清楚。
3.1 SerialPort类整体结构
Android官方其实没有提供标准的串口API,我们用的SerialPort类,最早是Google的Android开源项目(AOSP)里那个著名的android_serialport_api示例。后来被各种第三方库继承和改造。我个人习惯用这个经典版本,因为它足够轻量,没有多余依赖。
先看类的基本骨架:
public class SerialPort {
private static final String TAG = "SerialPort";
private FileDescriptor mFd;
private FileInputStream mFileInputStream;
private FileOutputStream mFileOutputStream;
// 构造函数
public SerialPort(File device, int baudrate, int flags)
throws SecurityException, IOException {
// ...
}
// 核心方法
public native static FileDescriptor open(String path, int baudrate, int flags);
public native void close();
// 获取IO流
public InputStream getInputStream() {
return mFileInputStream;
}
public OutputStream getOutputStream() {
return mFileOutputStream;
}
}
看到没?核心的open()和close()都是native方法。这意味着真正的串口操作是在C/C++层完成的。Java层只是封装了文件描述符和IO流。你想想看,为什么这么做?因为串口在Linux里本质上就是一个文件,操作串口就是操作文件描述符。
3.2 open()方法实现分析
open()方法是最关键的。它做了三件事:打开设备文件、配置串口参数、返回文件描述符。咱们看看C层的实现逻辑:
// serial_port.c 简化版
static jobject android_serialport_SerialPort_open(
JNIEnv *env, jclass thiz, jstring path, jint baudrate, jint flags) {
int fd;
struct termios cfg;
// 1. 打开设备文件
const char *pathStr = (*env)->GetStringUTFChars(env, path, NULL);
fd = open(pathStr, O_RDWR | O_NOCTTY | O_NDELAY);
(*env)->ReleaseStringUTFChars(env, path, pathStr);
if (fd == -1) {
// 打开失败,抛出异常
throwIOException(env, "Cannot open serial port");
return NULL;
}
// 2. 获取并配置termios结构
tcgetattr(fd, &cfg);
cfsetispeed(&cfg, getBaudrateConstant(baudrate));
cfsetospeed(&cfg, getBaudrateConstant(baudrate));
// 设置数据位、停止位、校验位
cfg.c_cflag &= ~CSIZE;
cfg.c_cflag |= CS8; // 8数据位
cfg.c_cflag &= ~PARENB; // 无校验
cfg.c_cflag &= ~CSTOPB; // 1停止位
// 3. 应用配置
tcsetattr(fd, TCSANOW, &cfg);
// 4. 创建FileDescriptor对象返回
return createFileDescriptor(env, fd);
}
这里有几个关键点我要强调一下:
- O_RDWR | O_NOCTTY | O_NDELAY:这三个标志缺一不可。O_RDWR表示读写模式;O_NOCTTY防止串口成为控制终端(否则按Ctrl+C会出问题);O_NDELAY表示非阻塞模式,open()不会因为DCD信号而阻塞。
- termios结构体:这是Linux串口编程的核心。所有波特率、数据位、停止位、校验位的配置都在这里。我见过有人直接改这个结构体来实现自定义波特率,比如150bps这种非标值。
- tcsetattr的TCSANOW参数:表示立即生效。还有TCSADRAIN(等待所有数据发送完再改)和TCSAFLUSH(清空缓冲区再改),但大多数场景用TCSANOW就够了。
我曾经在某个项目里,open()返回了文件描述符,但读写就是没反应。查了两天,最后发现是termios配置里忘了设置CLOCAL和CREAD标志。CLOCAL表示忽略调制解调器控制线,CREAD表示启用接收器。这两个标志不设,串口可能无法正常工作。记住,配置完termios后,最好用tcgetattr再读一遍确认配置生效。
3.3 close()方法实现
close()相对简单,但很多人会忽略它的重要性。看看C层实现:
static void android_serialport_SerialPort_close(
JNIEnv *env, jclass thiz) {
// 获取Java层保存的文件描述符
jclass serialPortClass = (*env)->GetObjectClass(env, thiz);
jfieldID fieldId = (*env)->GetFieldID(env, serialPortClass, "mFd", "Ljava/io/FileDescriptor;");
jobject fdObj = (*env)->GetObjectField(env, thiz, fieldId);
if (fdObj != NULL) {
// 获取Linux文件描述符并关闭
int fd = getFileDescriptor(env, fdObj);
if (fd >= 0) {
close(fd);
}
}
}
嗯,这里要注意:close()只是关闭了底层的文件描述符,但Java层的FileInputStream和FileOutputStream还需要手动关闭。我见过有人只调了SerialPort.close(),没关IO流,结果导致文件描述符泄漏。正确的做法是:
public void close() {
try {
if (mFileInputStream != null) {
mFileInputStream.close();
mFileInputStream = null;
}
if (mFileOutputStream != null) {
mFileOutputStream.close();
mFileOutputStream = null;
}
} catch (IOException e) {
e.printStackTrace();
}
// 调用native close
close();
}
我建议在close()方法里加上一个标志位,比如mIsOpen。每次操作前检查这个标志,避免在串口已关闭的情况下继续读写。另外,记得在Activity的onDestroy()或Service的onDestroy()里调用close(),防止内存泄漏。
3.4 read()和write()方法实现
SerialPort类本身没有直接提供read()和write()方法,而是通过getInputStream()和getOutputStream()返回的IO流来操作。为什么这么设计?因为Java的IO体系已经很完善了,直接用InputStream和OutputStream,可以方便地配合BufferedReader、BufferedWriter等包装类使用。
但底层是怎么读写的?还是通过文件描述符。看看InputStream的read()最终调用了什么:
// 在Linux内核中,串口文件的read()最终调用的是tty层的读操作
// 伪代码示意
ssize_t serial_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) {
struct tty_struct *tty = file->private_data;
// 从tty缓冲区读取数据
return tty->ops->read(tty, file, buf, count);
}
这里有个关键点:串口的read()默认是阻塞的。如果没有数据,read()会一直等在那里。这在很多场景下是合理的,但如果你需要非阻塞读取,就得在open()时设置O_NDELAY标志,或者用select()/poll()机制。
write()类似,也是通过文件描述符写入数据。但要注意,write()返回的是实际写入的字节数,不一定等于你要写入的字节数。所以写数据时一定要检查返回值:
byte[] data = new byte[]{0x01, 0x02, 0x03};
OutputStream os = serialPort.getOutputStream();
int written = 0;
while (written < data.length) {
int result = os.write(data, written, data.length - written);
if (result == -1) {
// 写入失败
break;
}
written += result;
}
- read()默认阻塞,建议在子线程中调用
- write()可能部分写入,需要循环写入直到全部写完
- 读写操作都要捕获IOException,串口随时可能断开
- 大数据量读写时,考虑使用缓冲区,避免频繁JNI调用
3.5 实际项目中的封装建议
基于上面的分析,我一般会在项目中再封装一层,提供更友好的接口。比如:
public class SerialPortManager {
private SerialPort mSerialPort;
private InputStream mInputStream;
private OutputStream mOutputStream;
private ExecutorService mExecutor;
public void open(String path, int baudrate) {
// 打开串口
mSerialPort = new SerialPort(new File(path), baudrate, 0);
mInputStream = mSerialPort.getInputStream();
mOutputStream = mSerialPort.getOutputStream();
// 启动读取线程
mExecutor = Executors.newSingleThreadExecutor();
mExecutor.execute(new ReadRunnable());
}
public void sendData(byte[] data) {
try {
mOutputStream.write(data);
mOutputStream.flush();
} catch (IOException e) {
// 处理异常
}
}
private class ReadRunnable implements Runnable {
@Override
public void run() {
byte[] buffer = new byte[1024];
while (!Thread.currentThread().isInterrupted()) {
try {
int size = mInputStream.read(buffer);
if (size > 0) {
// 回调处理接收到的数据
onDataReceived(buffer, size);
}
} catch (IOException e) {
break;
}
}
}
}
}
这个封装解决了几个问题:
- 读取操作在独立线程中执行,不阻塞UI
- 提供了sendData()方法,内部处理了flush()
- 通过回调机制通知上层数据到达
好了,这一章的内容就到这里。SerialPort的源码其实不复杂,但细节很多。下一章我会讲串口调试的实战技巧,包括怎么用示波器看波形、怎么分析数据包。到时候见。