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的源码其实不复杂,但细节很多。下一章我会讲串口调试的实战技巧,包括怎么用示波器看波形、怎么分析数据包。到时候见。