串口

串口全称叫做串行接口,串行接口指的是数据一个一个的按顺序传输,通信线路简单。使用两条线即可实现双向通信,一条用于发送,一条用于接收。串口通信距离远,但是速度相对会低,串口是一种很常用的工业接口。

终端

⚫本地终端: 例如对于我们的个人 PC 机来说, PC 机连接了显示器、键盘以及鼠标等设备,这样的一个显示器/键盘组合就是一个本地终端;同样对于开发板来说也是如此,开发板也可以连接一个LCD 显示器、键盘和鼠标等,同样可以构成本地终端。
⚫ 用串口连接的远程终端: 对于嵌入式 Linux 开发来说,这是最常见的终端—串口终端。譬如我们的开发板通过串口线连接到一个带有显示器和键盘的 PC 机,在 PC 机通过运行一个终端模拟程序,
譬如 Windows 超级终端、 putty、 MobaXterm、 SecureCRT 等来获取并显示开发板通过串口发出的数据、同样还可以通过这些终端模拟程序将用户数据通过串口发送给开发板 Linux 系统,系统接收到数据之后便会进行相应的处理、譬如执行某个命令,这就是一种人机交互!
⚫ 基于网络的远程终端: 譬如我们可以通过 ssh、 Telnet 这些协议登录到一个远程主机。

终端对应的设备节点

在 Linux 当中,一切皆是文件。当然,终端也不例外,每一个终端在/dev 目录下都有一个对应的设备节点。
/dev/ttyX(X 是一个数字编号,譬如 0、 1、 2、 3 等) 设备节点: ttyX(teletype 的简称)是最令人熟悉的了,在 Linux 中, /dev/ttyX 代表的都是上述提到的本地终端, 包括/dev/tty1~/dev/tty63 一共63 个本地终端, 也就是连接到本机的键盘显示器可以操作的终端。

/dev/pts/X(X 是一个数字编号,譬如 0、 1、 2、 3 等)设备节点:这类设备节点是伪终端对应的设备节点,也就是说,伪终端对应的设备节点都在/dev/pts 目录下、以数字编号命令。 譬如我们通过ssh 或 Telnet 这些远程登录协议登录到开发板主机,那么开发板 Linux 系统会在/dev/pts 目录下生成一个设备节点,这个设备节点便对应伪终端,

其实串口的应用编程也很简单,无非就是通过 ioctl()对串口进行配置,调用 read()读取串口的数据、调用 write()向串口写入数据,inux 为上层用户做了一层封装,将这些 ioctl()操作封装成了一套标准的 API,我们就直接使用这一套标准 API 编写自己的串口应用程序即可!

串口编程重点为配置和传输读写两个部分。

配置部分

用结构体struct termios对其进行表示终端的配置信息。

1
2
3
4
5
6
7
8
9
10
11
struct termios
{
tcflag_t c_iflag;//输入模式
tcflag_t c_oflag;//输出模式
tcflag_t c_cflag;//控制模式
fcflag_t c_lflag;//本地模式
cc_t c_line; //线程规划
cc_t c_cc[NCCS]; //特殊控制字符
speed_t c_ispeed;//输入速率
speed_t c_ospeed;//输出速率
}

输入模式

输入模式控制输入数据(终端驱动程序从串口或键盘接收到的字符数据)在被传递给应用程序之前的处理方式。 c_iflag成员标志进行控制,这个标志定义为宏定义。

可用于 c_iflag 成员的宏如下所示:

IGNBRK 忽略输入终止条件
BRKINT 当检测到输入终止条件时发送 SIGINT 信号
IGNPAR 忽略帧错误和奇偶校验错误
PARMRK 对奇偶校验错误做出标记
INPCK 对接收到的数据执行奇偶校验
ISTRIP 将所有接收到的数据裁剪为 7 比特位、也就是去除第八位
INLCR 将接收到的 NL(换行符)转换为 CR(回车符)
IGNCR 忽略接收到的 CR(回车符)
ICRNL 将接收到的 CR(回车符)转换为 NL(换行符)
IUCLC 将接收到的大写字符映射为小写字符
IXON 启动输出软件流控
IXOFF 启动输入软件流控

输出模式

输出模式控制输出字符的处理方式,即由应用程序发送出去的字符数据在传递到串口或屏幕之前是如何处理的 。

OPOST 启用输出处理功能,如果不设置该标志则其他标志都被忽略
OLCUC 将输出字符中的大写字符转换成小写字符
ONLCR 将输出中的换行符(NL ‘\n’)转换成回车符(CR ‘\r’)
OCRNL 将输出中的回车符(CR ‘\r’)转换成换行符(NL ‘\n’)
ONOCR 在第 0 列不输出回车符(CR)
ONLRET 不输出回车符
OFILL 发送填充字符以提供延时
OFDEL 如果设置该标志,则表示填充字符为 DEL 字符,否则为 NULL 字符

控制模式

控制模式就是一般我们进行的设置,比如串口的波特率、数据位、校检位、停止位等硬件特性。

CBAUD 波特率的位掩码
B0 波特率为 0
…… ……
B1200 1200 波特率
B1800 1800 波特率
B2400 2400 波特率
B4800 4800 波特率
B9600 9600 波特率
B19200 19200 波特率
B38400 38400 波特率
B57600 57600 波特率
B115200 115200 波特率
B230400 230400 波特率
B460800 460800 波特率
B500000 500000 波特率
B576000 576000 波特率
B921600 921600 波特率
B1000000 1000000 波特率
B1152000 1152000 波特率
B1500000 1500000 波特率
B2000000 2000000 波特率
B2500000 2500000 波特率
B3000000 3000000 波特率
…… ……
CSIZE 数据位的位掩码
CS5 5 个数据位
CS6 6 个数据位
CS7 7 个数据位
CS8 8 个数据位
CSTOPB 2 个停止位,如果不设置该标志则默认是一个停止位
CREAD 接收使能
PARENB 使能奇偶校验
PARODD 使用奇校验、而不是偶校验
HUPCL 关闭时挂断调制解调器
CLOCAL 忽略调制解调器控制线
CRTSCTS 使能硬件流控

本地模式

本地模式用于控制终端的本地数据处理和工作模式。 通过设置 struct termios 结构体中 c_lflag 成员的标志对本地模式进行配置。

ISIG 若收到信号字符(INTR、 QUIT 等),则会产生相应的信号
ICANON 启用规范模式
ECHO 启用输入字符的本地回显功能。当我们在终端输入字符的时候,字符 会显示出来,这就是回显功能
ECHOE 若设置 ICANON,则允许退格操作
ECHOK 若设置 ICANON,则 KILL 字符会删除当前行
ECHONL 若设置 ICANON,则允许回显换行符
ECHOCTL 若设置 ECHO,则控制字符(制表符、换行符等)会显示成“^X”, 其中 X 的 ASCII 码等于给相应控制字符的 ASCII 码加上 0x40。例如, 退格字符(0x08)会显示为“^H”(’H’的 ASCII 码为 0x48)
ECHOPRT 若设置 ICANON 和 IECHO,则删除字符(退格符等)和被删除的字 符都会被显示
ECHOKE 若设置 ICANON,则允许回显在 ECHOE 和 ECHOPRT 中设定的 KILL 字符
NOFLSH 在通常情况下,当接收到 INTR、 QUIT 和 SUSP 控制字符时,会清空 输入和输出队列。如果设置该标志,则所有的队列不会被清空
TOSTOP 若一个后台进程试图向它的控制终端进行写操作,则系统向该后台进 程的进程组发送 SIGTTOU 信号。该信号通常终止进程的执行
IEXTEN 启用输入处理功能

特殊控制字符

特殊控制字符是一些字符组合,如 Ctrl+C、 Ctrl+Z 等, 当用户键入这样的组合键,终端会采取特殊处理方式。 struct termios 结构体中 c_cc 数组将各种特殊字符映射到对应的支持函数。每个字符位置(数组下标)由对应的宏定义的,如下所示
⚫ VEOF:文件结尾符 EOF,对应键为 Ctrl+D; 该字符使终端驱动程序将输入行中的全部字符传递给正在读取输入的应用程序。如果文件结尾符是该行的第一个字符,则用户程序中的 read 返回 0,表示文件结束。
⚫ VEOL: 附加行结尾符 EOL,对应键为 Carriage return(CR) ; 作用类似于行结束符。
⚫ VEOL2: 第二行结尾符 EOL2,对应键为 Line feed(LF) ;
⚫ VERASE: 删除操作符 ERASE,对应键为 Backspace(BS) ; 该字符使终端驱动程序删除输入行中的最后一个字符;
⚫ VINTR: 中断控制字符 INTR,对应键为 Ctrl+C; 该字符使终端驱动程序向与终端相连的进程发送SIGINT 信号;
⚫ VKILL: 删除行符 KILL,对应键为 Ctrl+U, 该字符使终端驱动程序删除整个输入行;
⚫ VMIN:在非规范模式下,指定最少读取的字符数 MIN
⚫ VQUIT: 退出操作符 QUIT,对应键为 Ctrl+Z; 该字符使终端驱动程序向与终端相连的进程发送SIGQUIT 信号。
⚫ VSTART:开始字符 START,对应键为 Ctrl+Q; 重新启动被 STOP 暂停的输出。
⚫ VSTOP:停止字符 STOP,对应键为 Ctrl+S; 字符作用“截流”,即阻止向终端的进一步输出。用于支持 XON/XOFF 流控。
⚫ VSUSP:挂起字符 SUSP,对应键为 Ctrl+Z; 该字符使终端驱动程序向与终端相连的进程发送SIGSUSP 信号,用于挂起当前应用程序。
⚫ VTIME:非规范模式下, 指定读取的每个字符之间的超时时间(以分秒为单位) TIME

终端有3种工作模式,分别为规范模式(canonical mode)、非规范模式(non-canonical mode)和原始模式(raw mode)。通过在termios结构的c_lflag中设置ICANNON标志来定义终端是以规范模式(设置ICANNON标志)还是以非规范模式(清除ICANNON标志)工作,默认情况为规范模式。

在规范模式下,所有的输入是基于行进行处理。在用户输入一个行结束符(回车符、EOF等)之前,系统调用read()函数读不到用户输入的任何字符。除了EOF之外的行结束符(回车符等)与普通字符一样会被read()函数读取到缓冲区之中。在规范模式中,行编辑是可行的,而且一次调用read()函数最多只能读取一行数据。如果在read()函数中被请求读取的数据字节数小于当前行可读取的字节数,则read()函数只会读取被请求的字节数,剩下的字节下次再被读取。

在非规范模式下,所有的输入是即时有效的,不需要用户另外输入行结束符,而且不可进行行编辑。在非规范模式下,对参数字MIN(c_cc[VMIN])和TIME(c_cc[VTIME])的设置决定read()函数的调用方式。设置可以有4种不同的情况。

MIN = 0 和 TIME = 0: 在这种情况下, read()调用总是会立即返回。若有可读数据,则读取数据并返回被读取的字节数; 否则读取不到任何数据并返回 0。
MIN > 0 和 TIME = 0:在这种情况下, read()函数会被阻塞, 直到有 MIN 个字符可以读取时才返回,返回值是读取的字符数量。到达文件尾时返回 0。
MIN = 0 和 TIME > 0:在这种情况下, 只要有数据可读或者经过 TIME 个十分之一秒的时间, read()函数则立即返回,返回值为被读取的字节数。如果超时并且未读到数据,则 read()函数返回 0。
MIN > 0 和 TIME > 0:在这种情况下, 当有 MIN 个字节可读或者两个输入字符之间的时间间隔超过 TIME 个十分之一秒时, read()函数才返回。因为在输入第一个字符后系统才会启动定时器,所以,在这种情况下, read()函数至少读取一个字节后才返回。

使用了串口方式与主机端进行数据交互时候使用原始模式,在这个模式下,终端是不可回显的, 并且禁用终端输入和输出字符的所有特殊处理。 在我们的应用程序中,可以通过调用 cfmakeraw()函数将终端设置为原始模式。

打开终端

1
2
3
4
5
6
int fd;
fd = open("/dev/ttymxc2", O_RDWR | O_NOCTTY);
if (0 > fd) {
perror("open error");
return -1;
}

O_NOTTY:该参数不会使打开的文件成为该进程的控制终端。如果没有指定这个标志,那么任何一个 输入都将会影响用户的进程。

获取终端的配置参数

获取配置参数进行保存在struct termios结构体的对象中,方便备份数据。获取配置参数使用tcgetattr函数来获取串口终端配置参数。

1
2
3
#include <termios.h>
#include <unistd.h>
int tcgetattr(int fd, struct termios *termios_p);

tcgetattr()调用成功后,会将终端当前的配置参数保存到 termios_p 指针所指的对象中。函数调用成功返回 0;失败将返回-1,并且会设置 errno 以告知错误原因。

1
2
3
4
5
struct termios old_cfg;
if (0 > tcgetattr(fd, &old_cfg)) {
/* 出错处理 */
do_something();
}

对串口终端进行配置

配置串口终端为原始模式

1
2
3
4
struct termios new_cfg;
memset(&new_cfg, 0x0, sizeof(struct termios));
//配置为原始模式
cfmakeraw(&new_cfg);

接收使能

给c_cflag添加CREAD标志

1
new_cfg.c_cflag|=CREAD;	//接收使能

设置串口的波特率

设置波特率的函数为cfsetispeed()和cfsetospeed()两个函数

1
2
cfsetispeed(&new_cfg, B115200);
cfsetospeed(&new_cfg, B115200);

设置数据位大小

设置数据位通过操作位的置换来设置数据的大小,将c_cflag成员中CSIZE位清零,再设置

1
2
new_cfg.c_cflag &=~CSIZE;
new_cfg.c_cflag |= CS8;

设置校检位(奇偶)

1
2
3
4
5
6
7
8
9
10
/奇校验使能
new_cfg.c_cflag |= (PARODD | PARENB);
new_cfg.c_iflag |= INPCK;
//偶校验使能
new_cfg.c_cflag |= PARENB;
new_cfg.c_cflag &= ~PARODD; /* 清除 PARODD 标志,配置为偶校验 */
new_cfg.c_iflag |= INPCK;
//无校验
new_cfg.c_cflag &= ~PARENB;
new_cfg.c_iflag &= ~INPCK;

设置停止位

停止位则是通过设置 c_cflag 成员的 CSTOPB 标志而实现的。若停止位为一个比特, 则清除 CSTOPB 标志;若停止位为两个,则添加 CSTOPB 标志即可。

1
2
3
4
// 将停止位设置为一个比特
new_cfg.c_cflag &= ~CSTOPB;
// 将停止位设置为 2 个比特
new_cfg.c_cflag |= CSTOPB;

设置 MIN 和 TIME 的值

在对接收字符和等待时间没有特别要求的情况下,可以将 MIN 和 TIME 设置为 0, 这样则在任何情况下 read()调用都会立即返回,此时对串口的 read 操作会设置为非阻塞方式,

1
2
new_cfg.c_cc[VTIME] = 0;
new_cfg.c_cc[VMIN] = 0;

缓冲区的处理

处理缓冲区的数据防止对后续进行干扰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <termios.h>
#include <unistd.h>
int tcdrain(int fd); // tcdrain()函数后会使得应用程序阻塞, 直到串口输出缓冲区中的数据全部发送完毕为止
int tcflush(int fd, int queue_selector);/*函数会清空输入/输出缓冲区中的数据,具体情况取决于参数
queue_selector,参数 queue_selector 可取值如下:
TCIFLUSH: 对接收到而未被读取的数据进行清空处理;
TCOFLUSH: 对尚未传输成功的输出数据进行清空处理;
TCIOFLUSH: 包括前两种功能,即对尚未处理的输入/输出数据进行清空处理*/
int tcflow(int fd, int action);/*tcflow()函数会暂停串口上的数据传输或接收工作,具体情况取决于参数 action,参数 action 可取
值如下:
TCOOFF:暂停数据输出(输出传输);
TCOON: 重新启动暂停的输出;
TCIOFF: 发送 STOP 字符,停止终端设备向系统发送数据;
TCION: 发送一个 START 字符,启动终端设备向系统发送数据;*/

写入配置、使配置生效

前面已经完成了对 struct termios 结构体各个成员进行配置,但是配置还未生效,我们需要将配置参数写入到终端设备(串口硬件),使其生效。通过 tcsetattr()函数将配置参数写入到硬件设备

1
2
3
4
#include <termios.h>
#include <unistd.h>
int tcsetattr(int fd, int optional_actions, const struct termios *termios_p);
//将参数 termios_p 所指 struct termios 对象中的配置参数写入到终端设备中

而参数 optional_actions 可以指定更改何时生效,其取值如下:
⚫ TCSANOW:配置立即生效。
⚫ TCSADRAIN: 配置在所有写入 fd 的输出都传输完毕之后生效。
⚫ TCSAFLUSH: 所有已接收但未读取的输入都将在配置生效之前被丢弃。

为了处理同时收发数据,可以使用多线程或异步I/O来实现同时收发数据。

异步IO

异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。 之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程。 (异步 I/O 通常也称为信号驱动 I/O )

要使用异步 I/O,程序需要按照如下步骤来执行:
通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
通过指定 O_ASYNC 标志使能异步 I/O(O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件 )。
设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程。
为内核发送的通知信号注册一个信号处理函数。默认情况下, 异步 I/O 的通知信号是 SIGIO,所以内核会给进程发送信号 SIGIO。在 8.2 小节中简单地提到过该信号。
以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进行 I/O 操作

1
2
3
4
5
6
7
8
int flag;
flag = fcntl(0 ,F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步 I/O 的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 为 SIGIO 信号注册信号处理函数 */
signal(SIGIO, sigio_handler);

默认的异步 I/O 通知信号 SIGIO 是非排队信号。 SIGIO 信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制, 譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。 无法得知文件描述符发生了什么事件。

所以使用制定实时信号,来作为异步IO通知信号,

1
fcntl(fd, F_SETSIG, SIGRTMIN);//

当文件描述符 fd 可执行 I/O 操作时,内核会发送实时信号 SIGRTMIN 给调用进程。

使用 sigaction()函数注册信号处理函数

在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO, 表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。

1
2
3
4
5
6
7
struct sigaction {
void (*sa_handler)(int); // 信号处理函数的地址
void (*sa_sigaction)(int, siginfo_t *, void *); // 替代 sa_handler,支持更多参数
sigset_t sa_mask; // 设置信号掩码,控制信号处理时的阻塞
int sa_flags; // 标志,控制信号处理行为
void (*sa_restorer)(void); // 仅在一些特定平台上存在,一般不需要设置
};

sa_handler:指定了用于处理信号的函数的地址,通常是一个函数指针。当信号发生时,系统会调用指定的函数来处理信号。例如,SIGINT 信号的处理函数可以是 SIG_DFL(默认操作)、SIG_IGN(忽略信号)或你自己定义的处理函数。
sa_sigaction:这个成员是一个更通用的信号处理函数指针,支持接收更多的参数,包括有关信号源的额外信息。通常,你可以选择使用 sa_handler 或 sa_sigaction 中的一个,而不是两者同时使用。
sa_mask:这个成员用于设置信号掩码,即在信号处理函数执行期间将阻塞的信号。这是一个 sigset_t 类型的位掩码,允许你指定哪些信号应在信号处理期间阻塞。这有助于防止嵌套信号处理。
sa_flags:这个成员用于指定一些标志,以控制信号处理的行为。例如,SA_RESTART 标志可以用于在系统调用被中断后自动重启它。
sa_restorer:这个成员在一些特定平台上存在,通常不需要设置。

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <termios.h>
#include <aio.h>
#include <signal.h>
#include <unistd.h>

int serial_port;
struct aiocb aio;

void aio_handler(int signo, siginfo_t *info, void *context) {
if (aio_error(&aio) == 0) {
char *data = (char *)aio.aio_buf;
printf("接收到的数据: %s\n", data);
} else {
perror("异步I/O读取数据时发生错误");
}
}

int main() {
// 打开串口设备
serial_port = open("/dev/ttyS0", O_RDWR);
if (serial_port == -1) {
perror("无法打开串口设备");
return 1;
}
// 配置串口参数(与前一个示例相同)
// 配置异步I/O参数
char buffer[256];
memset(buffer, 0, sizeof(buffer));
aio.aio_fildes = serial_port;
aio.aio_buf = buffer;
aio.aio_nbytes = sizeof(buffer);
aio.aio_offset = 0;
// 设置异步I/O完成时的信号处理函数
struct sigaction sa;
sa.sa_sigaction = aio_handler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGIO, &sa, NULL);
// 启用文件描述符的FASYNC标志,以允许异步I/O
fcntl(serial_port, F_SETOWN, getpid());
fcntl(serial_port, F_SETFL, FASYNC);
// 启动异步I/O读取
aio_read(&aio);
// 主线程中发送数据
char message[] = "Hello, Serial!";
write(serial_port, message, sizeof(message));
// 主线程等待异步I/O完成
while (aio_error(&aio) == EINPROGRESS) {
usleep(1000);
}
// 关闭串口
close(serial_port);
return 0;
}