0%

UART应用编程

1.TTY体系的发展

参考:

Linux 终端(TTY)

解密 TTY 设备

1.1 终端机

从历史上看,终端刚开始就是终端机,配有打印机,键盘,带有一个串口,通过串口传送数据到主机端,然后主机处理完交给终端打印出来。电传打字机(teletype)可以被看作是这类设备的统称,因此终端也被简称为 TTY(teletype 的缩写):

image-20231006145708741

1.1.1 TTY设备

UART 驱动、行规范和 TTY 驱动都位于内核中,它们的一端是终端设备,另一端是用户进程。一开始,UART 驱动、行规范和 TTY 驱动三个模块统称为TTY设备,也是TTY开始的样子。

(1) UART driver

UART(Universal Asynchronous Receiver-Transmitter),通用异步收发器驱动程序用于管理字节流的物理传输。

(2) Line discipline

行规范的主要功能是处理终端上输入的字符流,并根据特定的规则解释和处理这些字符。行规范在终端的输入处理过程中起到了重要的作用,它可以执行各种任务,如输入缓冲、字符编辑、特殊字符处理等。

以下文字引用自参考资料解密TTY

大多数用户都会在输入时犯错,所以退格键会很有用。这当然可以由应用程序本身来实现,但是根据UNIX设计哲学,应用程序应尽可能保持简单。为了方便起见,操作系统提供了一个编辑缓冲区和一些基本的编辑命令(退格,清除单个单词,清除行,重新打印),这些命令在行规范(line discipline)内默认启用。高级应用程序可以通过将行规范设置为原始模式(raw mode)而不是默认的成熟或准则模式(cooked and canonical)来禁用这些功能。大多数交互程序(编辑器,邮件客户端,shell,及所有依赖curses或readline的程序)均以原始模式运行,并自行处理所有的行编辑命令。行规范还包含字符回显(控制是否将字符上报给TTY驱动程序)和回车换行(译者注:\r\n 和 \n)间自动转换的选项。如果你喜欢,可以把它看作是一个原始的内核级sed(1)。

另外,内核提供了几种不同的行规范。一次只能将其中一个连接到给定的串行设备。行规范的默认规则称为N_TTY(drivers/char/n_tty.c,如果你想继续探索的话)。其他的规则被用于其他目的,例如管理数据包交换(ppp,IrDA,串行鼠标),但这不在本文的讨论范围之内。

(3) TTY driver

TTY驱动程序是计算机操作系统中的一个模块,负责与终端设备进行通信,包括键盘、鼠标、打印机等外部设备。

TTY驱动程序的主要功能是通过提供会话管理和终端设备的输入/输出处理,实现用户与计算机系统之间的交互。它允许用户通过终端设备与计算机进行通信,输入命令、数据或请求,并接收来自系统的输出结果。

1.2 桌面计算机系统

image-20231006153242686

典型的桌面计算机系统,不再需要UART设备或TTY设备了。同时,这个阶段还出现了虚拟终端,即在单个设备上同时支持多个用户会话的机制。

1.2.1 虚拟终端

通过使用虚拟终端,用户可以在单个计算机上同时登录多个会话,并在这些会话之间切换。虚拟终端通过在内核中引入了多个逻辑终端设备(如/dev/tty1、/dev/tty2等)来实现。用户可以使用快捷键Ctrl+Alt+Fx来在不同的虚拟终端之间进行切换

虚拟终端的出现是为了解决多用户环境下的多任务处理和资源共享的问题,允许在一个物理终端上同时支持多个用户会话;允许用户在不同的虚拟终端之间切换,从而提供多任务处理的能力;允许多个用户通过虚拟终端共享计算机的资源。

1.3 伪终端

image-20231006161827882

伪终端(Pseudo Terminal)是一种特殊的设备,它充当了终端的角色,但实际上并没有与终端硬件直接连接。它被广泛用于在计算机系统中模拟终端环境,如远程登录、终端仿真和虚拟机管理等,并提供和终端类似的交互功能

1.3.1 伪终端的实现原理

伪终端的实现原理涉及到以下几个关键的组成部分:

  1. 主终端(Master Terminal):主终端是伪终端设备的前端,作为客户端进程(xterm process)与伪终端设备之间的联系桥梁。主终端提供了类似于真实终端的接口,包括输入/输出流以及控制字符的交互。
  2. 从终端(Slave Terminal):从终端是伪终端设备的服务端,它是一个虚拟终端设备,通常表示为/dev/pty/X,其中X是一个字符。从终端通过I/O流与主终端进行数据交换。

伪终端的实现原理如下:

  1. 当客户端进程启动时,它会创建一个伪终端设备。客户端将伪终端的主设备文件描述符传递给服务端进程。
  2. 服务端进程收到客户端传递的伪终端主设备文件描述符后,它会打开该设备,并将其作为自己的标准输入和标准输出。这样,服务端进程就可以通过读取和写入该设备来与客户端进行通信。
  3. 如此一来,客户端和服务器进程之间就建立了一个管道,通过这个管道双方可以交换数据。客户端进程读取用户输入,并将其写入伪终端设备,而服务端进程则从伪终端设备中读取数据,并将结果写回到伪终端。
  4. 对于客户端来说,它认为自己正在与一个终端进行交互,而对于服务端进程来说,它认为自己正在与一个用户进行交互。因此,伪终端的实现使得两个进程之间的通信就像是一个真实的终端会话一样。

以SSH远程登陆为例:

image-20231006165804068

2.理解Linux的不同设备节点

设备节点 含义
/dev/ttyS0、/dev/ttySAC0 串口端口字符设备
/dev/tty1、/dev/tty2、/dev/tty3、…… 虚拟终端设备节点
/dev/tty0 前台终端
/dev/tty 当前shell对应的终端
/dev/console 控制台,特指printk输出的设备,通过内核启动参数”console=ttySCA0”就把console映射到串口0

image-20231006172508437

  • /dev/ttyN(N=1,2,3...):表示某个shell程序使用的虚拟终端,通过快捷键Ctrl+Alt+Fx来切换不同终端
  • /dev/tty0:代表前台shell程序的虚拟终端
  • /dev/tty:表示当前shell程序的终端,可能是虚拟终端也可能是串口终端
  • /dev/console:控制台,可以理解为权限更大,能查看更多信息的终端,比如可以在控制台上看到内核得到打印信息
    • 可以通过内核的cmdline来指定哪一个终端作为控制台,例如console=tty,如果不设置则默认第一个tty设备为控制台

3. UART应用编程

参考资料:

Serial Programming Guide for POSIX Operating Systems

代码示例:Linux串口编程

termios结构体详细信息:Linux串口-struct termios结构体

3.1 串口API

在Linux上操作设备的统一接口:open/ioctl/read/write;对于UART,又在ioctl上封装了用于设置行规程的函数。

  • 行规程的参数使用结构体termios来表示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    typedef unsigned char	cc_t;
    typedef unsigned int speed_t;
    typedef unsigned int tcflag_t;
    struct termios
    {
    tcflag_t c_iflag; /* input mode flags */
    tcflag_t c_oflag; /* output mode flags */
    tcflag_t c_cflag; /* control mode flags */
    tcflag_t c_lflag; /* local mode flags */
    cc_t c_line; /* line discipline */
    cc_t c_cc[NCCS]; /* control characters */
    speed_t c_ispeed; /* input speed */
    speed_t c_ospeed; /* output speed */
    };
  • 行规程的配置函数在名称上有一些惯例:

    • tc:terminal contorl
    • cf:control flag
    函数名 作用
    tcgetattr get terminal attributes,获得终端的属性
    tcsetattr set terminal attributes,修改终端参数
    tcflush 清空终端未完成的输入/输出请求及数据
    cfsetispeed sets the input baud rate,设置输入波特率
    cfsetospeed sets the output baud rate,设置输出波特率
    cfsetspeed 同时设置输入、输出波特率

3.2 打开串口和配置行规程

termios结构体的配置还是相当复杂的,Linux串口编程这篇文章中给了封装好的配置函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/**
* @description: 设置串口参数
* @param {int} fd 串口设备文件
* @param {int} nSpeed 波特率(2400 4800 9600 115200)
* @param {int} nBits 数据位(7 or 8)
* @param {char} nEvent 校验方式(O/E/N:奇校验/偶校验/无校验)
* @param {int} nStop 停止位(1 or 2)
* @param {int} minByte 最小接收字节
* @param {int} minTime 首字节最小等待时间
* @return {配置结果}
*/
int set_opt(int fd, int nSpeed, int nBits, char nEvent, int nStop, int minByte, int minTime)
{
struct termios newtio, oldtio;

/*保存测试现有串口参数设置,在这里如果串口号等出错,会有相关的出错信息*/
if (tcgetattr(fd, &oldtio) != 0)
{
perror("SetupSerial 1");
return -1;
}

bzero(&newtio, sizeof(newtio));
/* 设置字符大小*/
newtio.c_cflag |= CLOCAL | CREAD;
newtio.c_cflag &= ~CSIZE;

/* 设置行规程为原始模式(Raw Mode)方式(禁用规范模式、回显和信号处理,以及禁用输出处理) */
newtio.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); /*Input*/
newtio.c_oflag &= ~OPOST; /*Output*/

/*设置字元位数*/
switch (nBits)
{
case 7:
newtio.c_cflag |= CS7;
break;
case 8:
newtio.c_cflag |= CS8;
break;
}

/*设置奇偶校验位*/
switch (nEvent)
{
case 'O': // 奇校验
newtio.c_cflag |= PARENB;
newtio.c_cflag |= PARODD;
newtio.c_iflag |= (INPCK | ISTRIP);
break;
case 'E': // 偶校验
newtio.c_iflag |= (INPCK | ISTRIP);
newtio.c_cflag |= PARENB;
newtio.c_cflag &= ~PARODD;
break;
case 'N': // 无校验位
newtio.c_cflag &= ~PARENB;
break;
}
/*设置波特率*/
switch (nSpeed)
{
case 2400:
cfsetispeed(&newtio, B2400);
cfsetospeed(&newtio, B2400);
break;
case 4800:
cfsetispeed(&newtio, B4800);
cfsetospeed(&newtio, B4800);
break;
case 9600:
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
case 115200:
cfsetispeed(&newtio, B115200);
cfsetospeed(&newtio, B115200);
break;
default:
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
}

/*设置停止位*/
if (nStop == 1)
newtio.c_cflag &= ~CSTOPB;
else if (nStop == 2)
newtio.c_cflag |= CSTOPB;

/*设置等待时间和最小接收字符*/
newtio.c_cc[VMIN] = minByte; /* 读数据时的最小字节数: 没读到这些数据我就不返回! */
newtio.c_cc[VTIME] = minTime; /* 等待第1个数据的时间:
* 比如VMIN设为10表示至少读到10个数据才返回,
* 但是没有数据总不能一直等吧? 可以设置VTIME(单位是10秒)
* 假设VTIME=1,表示:
* 10秒内一个数据都没有的话就返回
* 如果10秒内至少读到了1个字节,那就继续等待,完全读到VMIN个数据再返回
*/
/*处理未接收字符*/
tcflush(fd, TCIFLUSH);
/*激活新配置*/
if ((tcsetattr(fd, TCSANOW, &newtio)) != 0)
{
perror("com set error");
return -1;
}
return 0;
}

/**
* @description: 打开串口设备
* @param {char} *com 设备文件路径
* @return {int} fd 串口设备文件描述符
*/
int open_port(char *com)
{
int fd;

/* 可读可写,不作为终端 */
fd = open(com, O_RDWR | O_NOCTTY);
if (-1 == fd)
{
return (-1);
}
return fd;
}

3.2.1 一个小问题

使用上述代码打开串口设备文件时,按理来说是以阻塞方式打开的设备文件,如果没有写入数据,那么调用read()读取时应该会阻塞。对于其他大部分设备文件确实会阻塞,但是对于串口设备文件却没有阻塞,直接返回0,表示读取到0字节的数据。

应该和行规程有关,对于串口设备,可以通过设置行规程中的参数来控制是否阻塞。而其他设备文件都不会经过行规程。不过具体是什么原因,还得通过阅读源码才能知道。

3.3 使用串口(与GPS模块通信)

3.3.1 民用GPS常用数据格式

NVMEA0183 格式主要针对民用定位导航。使用串口接收数据,收到的数据头包括:

数据头 表示的内容
$GPGGA GPS 定位数据
$GPGLL 地理定位信息
$GPGSA 当前卫星信息
$GPGSV 可见卫星状态信息
$GPRMC 推荐最小定位信息
$GPVTG 地面速度信息

以$GPGGA为例,其标准格式如下:

1
$XXGGA,<1>,<2>,<3>,<4>,<5>,<6>,<7>,<8>,<9>,M,<10>,M,<11>,<12>*hh<CR><LF>

例如:

1
$GPGGA,074529.82,2429.6717,N,11804.6973,E,1,8,1.098,42.110,,, M,, *76

$XXGGA中XX的取值及对应类型如下:

  • GPGGA:单 GPS
  • BDGGA:单北斗
  • GLGGA:单 GLONASS
  • GNGGA:多星联合定位

其余各个字段的含义及取值范围如下:

image-20231011204725792

hh表示校验和,即是对除去”$”和”*”之外的所有字符的ASCII码进行按位异或(XOR)操作,并将得到的结果作为校验和,通常以十进制进行表示。

经纬度的度分制表示

度分制是一种用于表示经纬度的格式化方式,在度分制中,一个地理位置的纬度和经度被表示为度数(整数)和分钟数的组合,度数部分可能代表具体位置所在的纬度带,而分钟数则可以表示具体位置在该纬度带内的精确偏移量。以纬度格式ddmm.mmmm为例,其中dd表示度数,mm.mmmm表示分钟数。度数范围从0到90度。分钟数的范围从0到59.9999分钟,例如“37度24.5684分钟”可以写作37°24.5684

3.3.2 代码

参考上面打开串口和配置行规程的代码进行配置之后,使用以下函数读取原始数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int read_gps_raw_data(int fd, char *buf)
{
int iRet, i = 0, start = 0;
char c;

while (1)
{
iRet = read(fd, &c, 1);
if (iRet == 1)
{
if (c == '$') start = 1;
if (start) buf[i++] = c;
if (c == '\n' || c == '\r')
return 0;
}
else
return -1;
}
}

该函数使用休眠-唤醒方式(行规程设置读取1字节后返回)读取GPS模块发送的原始数据。

当读取完原始数据之后,可以通过sscanf()对原始数据进行解析:

1
2
3
4
5
6
double time, lat, lng;  
char tmp[10];
char ns, ew;
char tmp[10], time[100], lat[100], lng[100]; //数据头标识 时间 纬度 经度
char ns, ew; // 南北半球 东西半球
sscanf(buf, "%[^,],%[^,],%[^,],%c,%[^,],%c", tmp, time, lat, &ns, lng, &ew);

%[^,]表示除了,之外的所有字符。

当读取到度分制的经纬度之后,可以通过如下代码将度分制转化为度数浮点数:

1
2
3
4
/* 纬度格式: ddmm.mmmm */
sscanf(Lat + 2, "%f", &fLat);
fLat = fLat / 60;
fLat += (Lat[0] - '0') * 10 + (Lat[1] - '0');

至此,完成GPS模块的数据的接收以及解析。