这一段时间做的项目自动售货机和无线终端设备的通讯,都是通过串口进行对接和通讯。在Android中进行串口通信方式可以用Google官方提供的demo代码(android-serialport-api),也可以通过NDK的方式使用C/C++进行实现(Android串口助手,C++实现),其底层原理都是通过调用open函数打开设备文件来进行读写操作。对串口接触下来,发现真的可以做很多有意思的东西,很多硬件设备都可以通过串口进行通讯,比如:打印机、ATM吐卡机、IC/ID卡读卡等,以及物联网相关的设备。所以有必有对相关知识进行下梳理和总结。
串口简介
串口通信(Serial Communications)的概念非常简单,串口按位(bit)发送和接收字节。串口可以在使用一根线(Tx)发送数据的同时用另一根线(Rx)接收数据。
串口参数
**波特率:**串口传输速率,用来衡量数据传输的快慢,即单位时间内载波参数变化的次数,如每秒钟传送240个字符,而每个字符格式包含10位(1个起始位,1个停止位,8个数据位),这时的波特率为240Bd,比特率为10位*240个/秒=2400bps。波特率与距离成反比,波特率越大传输距离相应的就越短。
**数据位:**这是衡量通信中实际数据位的参数。当计算机发送一个信息包,实际的数据往往不会是8位的,标准的值是6、7和8位。如何设置取决于你想传送的信息。
**停止位:**用于表示单个包的最后一位。典型的值为1,1.5和2位。由于数据是在传输线上定时的,并且每一个设备有其自己的时钟,很可能在通信中两台设备间出现了小小的不同步。因此停止位不仅仅是表示传输的结束,并且提供计算机校正时钟同步的机会。适用于停止位的位数越多,不同时钟同步的容忍程度越大,但是数据传输率同时也越慢。
**校验位:**在串口通信中一种简单的检错方式。有四种检错方式:偶、奇、高和低。当然没有校验位也是可以的。对于偶和奇校验的情况,串口会设置校验位(数据位后面的一位),用一个值确保传输的数据有偶个或者奇个逻辑高位。
串口地址
如下表不同操作系统的串口地址,Android是基于Linux的所以一般情况下使用Android系统的设备串口地址为/dev/ttyS0…
System | Port 1 | Port 2 |
---|---|---|
IRIX® | /dev/ttyf1 | /dev/ttyf2 |
HP-UX | /dev/tty1p0 | /dev/tty2p0 |
Solaris®/SunOS® | /dev/ttya | /dev/ttyb |
Linux® | /dev/ttyS0 | /dev/ttyS1 |
Digital UNIX® | /dev/tty01 | /dev/tty02 |
Android串口实现
在Android上使用串口比较快速的方式就是直接套用google官方的串口demo代码(android-serialport-api),基本上能够应付很多在Android设备使用串口的场景。比如简单的读卡号。
但是问题来了!
在收发数据频率很快的情况下,实际测试这种方式接收数据会有延迟。比如:发送一个命令之后,设备会同时响应两条命令,一条是结果一条是校验且两条命令间隔时间仅1ms,按理两条命令会几乎同时收到,但是实际使用该方式会出现10ms的延迟。所以只能着手优化,尝试使用C/C++的方式进行串口数据的读写。
一番查阅下来,使用C/C++实现其实和上面的demo差别不大,同样是那几个步骤,设置串口参数,通过调用open方法开启串口,再进行数据的读写操作。出现数据读取延迟很可能的原因,就是因为官方demo是通过Java层的文件流(FileInputStream,FileOutputStream)进行读写操作引起的。如果有大神懂这块的可以说明这种方式导致延迟的原因。
关于使用C、C++在Android上实现串口通讯的源代码有很多,没有实际做过C/C++开发,但是也容易看懂。
设置串口波特率、数据位、停止位、校验位主要操作的就是termios 结构体,对应的头文件是termios.h。
比如设置波特率代码:
int SerialPort::setSpeed(int fd, int speed) {
speed_t b_speed;
struct termios cfg;
b_speed = getBaudrate(speed);
if (tcgetattr(fd, &cfg)) {
LOGE("tcgetattr invocation method failed!");
close(fd);
return FALSE;
}
cfmakeraw(&cfg);
cfsetispeed(&cfg, b_speed);
cfsetospeed(&cfg, b_speed);
if (tcsetattr(fd, TCSANOW, &cfg)) {
LOGE("tcsetattr invocation method failed!");
close(fd);
return FALSE;
}
return TRUE;
}
打开串口就是简单的调用open函数,设置相关读写参数,这个和官方推荐的demo一致,代码如下:
int SerialPort::openSerialPort(SerialPortConfig config) {
LOGD("Open device!");
isClose = false;
fd = open(path, O_RDWR);
if (fd < 0) {
LOGE("Error to read %s port file!", path);
return FALSE;
}
if (!setSpeed(fd, config.baudrate)) {
LOGE("Set Speed Error!");
return FALSE;
}
if (!setParity(fd, config.databits, config.stopbits, config.parity)) {
LOGE("Set Parity Error!");
return FALSE;
}
LOGD("Open Success!");
return TRUE;
}
串口数据读取涉及两个函数 select和read ,函数相关的含义暂且没去深究,属于C/C++范凑了,读取数据代码如下:
int SerialPort::readData(BYTE *data, int size) {
int ret, retval;
fd_set rfds;
ret = 0;
if (isClose) return 0;
for (int i = 0; i < size; i++) {
data[i] = static_cast<char>(0xFF);
}
FD_ZERO(&rfds); //清空集合
FD_SET(fd, &rfds); //把要检测的句柄fd加入到集合里
// TODO Async operation. Thread blocking.
if (FD_ISSET(fd, &rfds)) {
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
retval = select(fd + 1, &rfds, NULL, NULL, NULL);
if (retval == -1) {
LOGE("Select error!");
} else if (retval) {
LOGD("This device has data!");
ret = static_cast<int>(read(fd, data, static_cast<size_t>(size)));
} else {
LOGE("Select timeout!");
}
}
if (isClose) close(fd);
return ret;
}
串口写数据就是调用write函数了,代码如下:
int SerialPort::writeData(BYTE *data, int len) {
int result;
result = static_cast<int>(write(fd, data, static_cast<size_t>(len)));
return TRUE;
}
因为不熟悉C/C++,所以就参考网上相关源代码,依葫芦画瓢实现了一个基于C++的Android串口通讯库,并对相关串口控制做了优化,详细见gayhub,地址:https://github.com/freyskill/SerialPortHelper ,欢迎star。
通过该库,完美解决串口数据读取延迟的问题。
阻塞与非阻塞
在项目初期使用google官方的串口demo代码调试设备串口是否能正常通信的时候,遇到在串口读数据的线程中会卡死在inputStream.read(buffer);
这个时候就让人疑惑了,不知道问题是出在硬件还是在串口读取上,在没有了解串口相关知识前,希望的场景是读数据的线程能够不阻塞,一直轮询读取数据。
出现读取数据线程卡死的情况是因为在 fd = open(path_utf, O_RDWR | flags);
设置相关参数,读取默认为阻塞模式,若在open操作中设置O_NONBLOCK则是非阻塞模式。在阻塞模式中,read没有读到数据会阻塞住,直到收到数据;非阻塞模式read没有读到数据会返回-1不会阻塞。
修改open方法:
fd = open(path_utf, O_RDWR | flags | O_NONBLOCK | O_NOCTTY | O_NDELAY);
读取线程就不会再出现卡死了,这个时候仍然接收不到串口设备反馈的数据,就可以断定是串口设备的问题了。
关于串口文件打开方式,可采用下面的文件打开模式,具体说明如下:
O_RDONLY:以只读方式打开文件
O_WRONLY:以只写方式打开文件
O_RDWR:以读写方式打开文件
O_APPEND:写入数据时添加到文件末尾
O_CREATE:如果文件不存在则产生该文件,使用该标志需要设置访问权限位mode_t
O_EXCL:指定该标志,并且指定了O_CREATE标志,如果打开的文件存在则会产生一个错误
O_TRUNC:如果文件存在并且成功以写或者只写方式打开,则清除文件所有内容,使得文件长度变为0
O_NOCTTY:如果打开的是一个终端设备,这个程序不会成为对应这个端口的控制终端,如果没有该标志,任何一个输入,例如键盘中止信号等,都将影响进程。
O_NONBLOCK:该标志与早期使用的O_NDELAY标志作用差不多。程序不关心DCD信号线的状态,如果指定该标志,进程将一直在休眠状态,直到DCD信号线为0。
实际应用中,都会选择阻塞模式,这样更节省资源。但是如果希望在一个线程中同时进行读写操作,没数据反馈时,线程就会阻塞等待,就无法进行写数据了。
串口数据校验方式
一般情况下串口通讯协议都会在数据帧或者说命令格式里定义一个校验方式,常用的有异或校验、和校验、CRC校验和LRC校验。
**注意:**这里说的校验和上面说的校验位是不同的,校验位针对的是单个字节,校验类型针对的是单个数据帧。
校验方式一般放在命令最后,可以是一个byte,也可以是两个byte或者其他,具体看协议设计。
比如命令格式如下,采用和校验:
addr | command | data_length | data1 | data2 | datan | checksum |
---|---|---|---|---|---|---|
0x01 | 0x52 | 0x05 | 0x11 | 0xBA | … | 8E |
其中,获取校验码(checksum)就是将命令中的数据进行相加生成,Checksum=256-(data1+data2+datan)算出校验码为:8E。具体计算方式就是通过将十六进制进行相加算出校验码的十进制字符,详细代码如下:
/**
* 获取校验码(计算方式如下:cs= 256-(data1+data2+data3+data4+datan))
*/
public static String getCheckSum(String data){
Integer in = Integer.valueOf(makeChecksum(data),16);
String st = Integer.toHexString(256 -in).toUpperCase()