这一段时间做的项目自动售货机和无线终端设备的通讯,都是通过串口进行对接和通讯。在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一致,代码如下:
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();
st = String.format("%2s",st);
return st.replaceAll(" ","0");
}
十六进制进行相加代码:
/**
* 生成校验码,十六进制相加
* @param data
* @return
*/
public static String makeChecksum(String data) {
if (data == null || data.equals("")) {
return "00";
}
int iTotal = 0;
int iLen = data.length();
int iNum = 0;
while (iNum < iLen){
String s = data.substring(iNum, iNum + 2);
System.out.println(s);
iTotal += Integer.parseInt(s, 16);
iNum = iNum + 2;
}
/**
* 用256求余最大是255,即16进制的FF
*/
int iMod = iTotal % 256;
String sHex = Integer.toHexString(iMod);
iLen = sHex.length();
//如果不够校验位的长度,补0,这里用的是两位校验
if (iLen < 2){
sHex = "0" + sHex;
}
return sHex;
}
再比如使用CRC校验(有CRC8,CRC16,CRC32),关于CRC校验的原理可以参考:https://blog.csdn.net/u011854789/article/details/80206676
/**
* 获取CRC检验
* @param command 命令集
* @param len 命令长度
* @return
*/
public static int CalCrc(byte[] command,int len){
long MSBInfo;
int i,j ;
int nCRCData;
nCRCData = 0xffff;
for(i = 0; i < len ;i++) {
int temp = (int)(command[i]&0xff);
nCRCData = nCRCData ^ temp ;
for(j= 0 ; j < 8 ;j ++){
MSBInfo = nCRCData & 0x0001;
nCRCData = nCRCData >> 1;
if(MSBInfo != 0 )
nCRCData = nCRCData ^ 0xa001;
}
}
return nCRCData;
}
串口设备问题排查
在对接串口设备的过程中,负责硬件的同事说在PC上通过串口助手收发数据没有问题,然鹅我在Android设备上,通过串口就是无法接收到数据,于是乎双方僵持,对方就差说:“如果我硬件有问题我吃xiang...” 坚称是Android板子串口问题或者是我读写数据的代码有问题。在没有示波器的情况下,如何定位问题呢?各方打听尝试了如下方式:
-
直接短路Tx 与 Rx 两条线
不接设备,先确定Android设备(开发板)上的串口是否可通,检查方式:直接短路板子上的Tx和Rx两个针脚,然后通过Android的串口demo或者相关串口助手进行命令发送,看串口是否能够接收响应。也就是检查板子串口是否可以自发自收。
-
直接与PC对接
操作方式是将Android板子上的串口通过USB转接头直接插入PC,然后在PC和Android设备上同时打开串口助手,波特率等参数保持一致。对接之后打开串口,PC发命令看Android端是否能接收到,反之Android端发看PC端是否能接收到。
在尝试了上面方法之后,发现Android端的串口是通的,那原因就只能出在要使用串口的设备(无线通讯模块)上了,又是一段时间僵持之后,我说这东西是不是要接电才行?结果一试,果然是没有接电的原因,崩溃。为什么PC上不需要接电能通,然道是因为USB已经带电?不得而知。
以上,只是提供一种在没有示波器情况下,检查串口是否正常的方式,仅做参考。
数据转换工具类
串口开发中比较常见进制与进制,进制与字节间的转换,比如:十六进制转十进制,字节数组转十六进制字符串等。
相关代码如下:
package top.keepempty.serialdemo;
/**
* 数据转换工具类
* @author frey
*/
public class DataConversion {
/**
* 判断奇数或偶数,位运算,最后一位是1则为奇数,为0是偶数
* @param num
* @return
*/
public static int isOdd(int num) {
return num & 0x1;
}
/**
* 将int转成byte
* @param number
* @return
*/
public static byte intToByte(int number){
return hexToByte(intToHex(number));
}
/**
* 将int转成hex字符串
* @param number
* @return
*/
public static String intToHex(int number){
String st = Integer.toHexString(number).toUpperCase();
return String.format("%2s",st).replaceAll(" ","0");
}
/**
* 字节转十进制
* @param b
* @return
*/
public static int byteToDec(byte b){
String s = byteToHex(b);
return (int) hexToDec(s);
}
/**
* 字节数组转十进制
* @param bytes
* @return
*/
public static int bytesToDec(byte[] bytes){
String s = encodeHexString(bytes);
return (int) hexToDec(s);
}
/**
* Hex字符串转int
*
* @param inHex
* @return
*/
public static int hexToInt(String inHex) {
return Integer.parseInt(inHex, 16);
}
/**
* 字节转十六进制字符串
* @param num
* @return
*/
public static String byteToHex(byte num) {
char[] hexDigits = new char[2];
hexDigits[0] = Character.forDigit((num >> 4) & 0xF, 16);
hexDigits[1] = Character.forDigit((num & 0xF), 16);
return new String(hexDigits).toUpperCase();
}
/**
* 十六进制转byte字节
* @param hexString
* @return
*/
public static byte hexToByte(String hexString) {
int firstDigit = toDigit(hexString.charAt(0));
int secondDigit = toDigit(hexString.charAt(1));
return (byte) ((firstDigit << 4) + secondDigit);
}
private static int toDigit(char hexChar) {
int digit = Character.digit(hexChar, 16);
if(digit == -1) {
throw new IllegalArgumentException(
"Invalid Hexadecimal Character: "+ hexChar);
}
return digit;
}
/**
* 字节数组转十六进制
* @param byteArray
* @return
*/
public static String encodeHexString(byte[] byteArray) {
StringBuffer hexStringBuffer = new StringBuffer();
for (int i = 0; i < byteArray.length; i++) {
hexStringBuffer.append(byteToHex(byteArray[i]));
}
return hexStringBuffer.toString().toUpperCase();
}
/**
* 十六进制转字节数组
* @param hexString
* @return
*/
public static byte[] decodeHexString(String hexString) {
if (hexString.length() % 2 == 1) {
throw new IllegalArgumentException(
"Invalid hexadecimal String supplied.");
}
byte[] bytes = new byte[hexString.length() / 2];
for (int i = 0; i < hexString.length(); i += 2) {
bytes[i / 2] = hexToByte(hexString.substring(i, i + 2));
}
return bytes;
}
/**
* 十进制转十六进制
* @param dec
* @return
*/
public static String decToHex(int dec){
String hex = Integer.toHexString(dec);
if (hex.length() == 1) {
hex = '0' + hex;
}
return hex.toLowerCase();
}
/**
* 十六进制转十进制
* @param hex
* @return
*/
public static long hexToDec(String hex){
return Long.parseLong(hex, 16);
}
/**
* 十六进制转十进制,并对卡号补位
*/
public static String setCardNum(String cardNun){
String cardNo1= cardNun;
String cardNo=null;
if(cardNo1!=null){
Long cardNo2=Long.parseLong(cardNo1,16);
//cardNo=String.format("%015d", cardNo2);
cardNo = String.valueOf(cardNo2);
}
return cardNo;
}
}
其他
串口中相关引脚说明如下表,一般在开发板子上可以看到Tx,Rx这两个针脚,分别标识串口的发送和接收。
序号 | 信号名称 | 符号 | 流向 | 功能 |
---|---|---|---|---|
2 | 发送数据 | TXD | DTE→DCE | DTE 发送串行数据 |
3 | 接收数据 | RXD | DTE←DCE | DTE 接收串行数据 |
4 | 请求发送 | RTS | DTE→DCE | DTE 请求 DCE 将线路切换到发送方式 |
5 | 允许发送 | CTS | DTE←DCE | DCE 告诉 DTE 线路已接通可以发送数据 |
6 | 数据设备准备好 | DSR | DTE←DCE | DCE 准备好 |
7 | 信号地 | 信号公共地 | ||
8 | 载波检测 | DCD | DTE←DCE | 表示 DCE 接收到远程载波 |
20 | 数据终端准备好 | DTR | DTE→DCE | DTE 准备好 |
22 | 振铃指示 | RI | DTE←DCE | 表示 DCE 与线路接通,出现振铃 |
关于串口的相关知识可以参考这篇文章
参考:
https://www.cnblogs.com/hackfun/p/7612617.html
https://blog.csdn.net/tianruxishui/article/details/37592903
以上,只是个人学习整理,欢迎学习交流,如有纰漏欢迎指出,大神略过。