想了解更多关于开源的内容,请访问:
51CTO 开源基础软件社区
https://ost.51cto.com
驱动开发 -串口和串行总线
基本知识
一般情况下,设备间的通信方式可以划分为串行通行方式和并行通信方式两种。在Linux字符设备、块设备、网络设备分类方式下,该外设分类划分于字符设备当中。本章节主要指导基于LINUX驱动完成串口驱动开发并调用串口与USB接口与外设完成有效通信。
串行通信的分类
按照数据传输方向
按照数据传输的方向可以划分为 单工,半双工和全双工。单工通信允许数据在同一方向上进行传输,半双工则允许数据双向传输但是在同一时刻仅允许一个方向的数据传输吗,不需要独立的接收端和放松端,两者可以合并使用相同端口。全双工通信则包含两个方向上的同时传输,全双工通信是两个半双工的通信方式的拼接,从而完成的独立接收端和发送端。
按照通信方式
而按照通信方式的不同,可以划分为同步通信和异步通信两种,同步通信是需要带时钟信号进行互相时钟同步从而解析电平信号的,如SPI,IIC,而异步通信是无需时钟同步信号的,如UART等。
在同步通讯中,收发设备的上方会使用一根信号线传输信号,在时钟信号的驱动下双方进行数据的同步,通常会在收发两端规定在时钟信号的上升沿和下降沿对数据线进行采样。
在异步通讯中,不适用时钟信号进行数据同步,直接在数据信号中穿插一些用于数据同步的信号位,或通过指定数据协议进行数据打包,以数据帧的方式传输数据,通讯中需要约束传输速率波特率,常见波特率有 4800 9600 115200等。
UART连接方式
存在两个引脚:
在连接时如图,两个芯片的GND引脚共地。
按照电平标准
在嵌入式开发领域通常描述串口按照电平标准划分由USB设备,RS485,RS-422,D-USB接口为主流的差分电平信号,双端电平信号包括LVDS,LVPECL等。另外一类是单片机上使用为主的单端信号,其传输电平标准为TTL,RS-232,CMOS等。普通单端信号无法连接差分信号,如上文中描述的Tx,Rx 传输的TTL电平信号无法连接LVDS信号,在使用时需要使用到转换模块。
本文中将会以讲解USB接口在Linux驱动中的使用,以及一些单端信号的使用为主。
在标准系统使用的开发板上包括了RS-485和USB2.0,USB3.0接口。
单端信号 UART
单端UART全称 通用异步收发传输器,是一种串行异步收发协议。UART的工作原理是将数据的二进制格式数据帧一位一位进行传输,在UART中使用TTL电平为主,在阈值电平以上规定为高电平1,阈值电平以下规定为低电平0.
关于串口传输速率: bps就是比特每秒,115200bps就是每秒传输115200比特(115200bit),1kb=1024bit。注意,大写的B表示字节,1[Byte]=8bit。或者说1B=8b.所以115200bps=每秒112.5kb=每秒14.0625kB。
USB接口
USB,是英文Universal Serial Bus(通用串行总线)的缩写,是一个外部总线标准,用于规范电脑与的连接和通讯。是应用在[PC]领域的接口技术。
USB的电源线是5V,为USB设备提供最大500mA的电流,它与数据线上的电平无关,数据线是差分信号,通常D+和D-在+400mV~-400mV间变化,在传统的单端(Single-ended)通信中,一条线路来传输一个比特位。高电平表示1,低电平表示0。倘若在数据传输过程中受到干扰,高低电平信号完全可能因此产生突破临界值的大幅度扰动,一旦高电平或低电平信号超出临界值,信号就会出错。在差分传输电路中,输出电平为正电压时表示逻辑“1”,输出负电压时表示逻辑“0”,而输出“0”电压是没有意义的,它既不代表“1”,也不代表“0”。而差分通信中,干扰信号会同时进入相邻的两条信号线中,在信号接收端,两个相同的干扰信号分别进入差分放大器的两个反相输入端后,输出电压为0。所以说,差分信号技术对干扰信号具有很强的免疫力。对于串行传输来说,LVDS能够低于外来干扰;而对于并行传输来说,LVDS可以不仅能够抵御外来干扰,还能够抵御数据传输线之间的串扰。因为上述原因,实际电路中只要使用低压差分信号(Low Voltage Differential Signal,LVDS),350mV左右的振幅便能满足近距离传输的要求。假定负载电阻为100Ω,采用LVDS方式传输数据时,如果双绞线长度为10m,传输速率可达400 Mbps;当电缆长度增加到20m时,速率降为100 Mbps;而当电缆长度为100m时,速率只能达到10 Mbps左右。
串口驱动程序开发
基本串口驱动程序实现思路从底层机制大体有两种一种是通过轮训机制,不断访问串口从而实现数据的收发,但是会导致cpu占用过高,第二种是使用中断或者DMA等技术实现串口的非实时读取,但是可以保证cpu占用率低并且保证数据有效。
在上层应用层开发过程中有串口通信协议,需要进行校验位,数据位等需要进行规定。
总体上开发过程分为四步:
- 制定设备间串口协议,波特率、数据位、停止位和校验位等。在开发驱动之前,需要确认设备和设备之间所使用的串口通信协议,以便能够正确地配置和初始化串口。
- 确认串口的硬件信息,保证串口硬件相同,底层物理特性一致,如不一致需要通过CP2102等芯片进行数据转换。同时还需要确认单台设备串口的物理接口、I/O地址、中断号等。
- 编写串口驱动程序,根据操作系统根据操作系统的要求,编写对应的驱动程序。驱动程序需要包括串口的初始化、数据传输、中断处理等功能。
- 测试和调试,完成驱动程序后完成驱动程序的编写后,需要进行测试和调试。首先完成常规调用代码的实现,然后可以使用串口调试工具等工具对驱动程序进行测试,确认串口通信是否正常,数据是否正确传输等。
通常使用数据协议表格可以简单表示如下表
数据帧内容 | 长度 | 功能 |
起始位 | 1位 | 标志帧的起始 |
数据位 | 8位 (有时描述为9位) | 传输数据 |
校验位 | 无校验(1位奇校验/偶校验) | 校验本帧数据正确性和完整性 |
停止位 | 1 (0.5 、1、 1.5、 2) | 标志帧的结束 |
除了上述数据协议在通信双方需要完全一致外,还需要保证数据的传输速率一致,即波特率一致,波特率(Baud rate)是一种衡量数字通信中数据传输速率的单位,通常以每秒钟传输的比特数(bit per second,bps)为单位。它指的是在数字通信中每秒钟传输的符号数,每个符号可以携带多个比特的信息。
在串行通信中,波特率是指在传输数据时,串行线路上数据变化的速率。例如,一个波特率为9600 bps的串行通信系统,可以在一秒钟内传输9600个符号,每个符号可以携带多个比特的信息。波特率是通过调整串行通信系统中时钟信号的频率来实现的。因此,波特率也可以理解为时钟频率的一种体现。和时钟周期成倒数关系,总线时钟周期越短,单位时间传输的码元越多,串口波特率越高。
需要注意的是,波特率并不等同于数据传输速率(data rate),因为每个符号可以携带多个比特的信息。例如,一个波特率为9600 bps的串行通信系统,每个符号可以携带8个比特的信息,因此其数据传输速率为9600 bps × 8 = 76800 bps。
常见的有 115200,38400,9600,4800等。
使用外部中断实现的基本思路和逻辑
常见的中断在前面的讲解中提到过包括定时器中断,外部硬件中断,系统异常中断,系统调用中断,信号中断,NMI中断,虚拟中断等,本节讨论的串口收发会涉及到的中断类型包括接收中断和空闲中断。在大类上归属于外部硬件中断。
使用LINUX依据空闲中断和接收中断实现串口收发的基本逻辑如下
打开串口操作会返回一个文件描述符,之后我们需要使用该文件描述符对串口进行读写操作。配置串口参数的步骤会设置串口的输入输出波特率、数据位、停止位和校验位等参数,以保证通信的正确性和稳定性。
接下来,串口硬件将接收到的数据存储在接收缓冲区中,并向内核发出中断信号。中断处理函数根据中断类型(接收中断或空闲中断)选择相应的处理方式。接收中断处理函数会将数据从接收缓冲区中读取并存储到tty缓冲区中,然后向应用程序发送SIGIO信号通知有数据可读。应用程序监听SIGIO信号并从tty缓冲区中读取数据进行处理。空闲中断处理函数类似,不同之处在于它不需要从接收缓冲区中读取数据,而是在空闲状态下触发中断并向应用程序发送SIGIO信号。
如果对比于STM32单片机实现的逻辑可能更易于理解。
中断处理函数的名称不同:Linux使用的是irq函数,而STM32使用的是HAL_UART_IRQHandler函数。STM32的中断处理函数包含了发送中断和接收中断,需要在处理函数内部进行区分,而Linux中的发送和接收分别有对应的中断处理函数。在Linux中,可以通过tty设备文件直接访问串口,而STM32需要使用串口API进行访问和操作。STM32需要手动开启和关闭中断,而Linux的中断处理函数会在内核中自动启动和停止。Linux中,数据的接收和发送是由tty设备驱动完成的,而STM32需要在中断处理函数内部实现数据的接收和发送。两者关键差异是LINUX使用内核管理中断函数的启停。
以下给出一种示例程序可以根据需要进行修改编译合入内核实现串口驱动。
#include
#include
#include
#include
#include
#include
#define DRIVER_NAME "my_serial_driver"
static struct uart_driver my_uart_driver = {
.owner = THIS_MODULE,
.driver_name = DRIVER_NAME,
.dev_name = "ttyMY", // 设备文件名,例如 /dev/ttyMY0
.major = 0, // 自动分配主设备号
.minor = 0, // 自动分配从设备号
.nr = 1, // 支持的最大串口数量
};
// 串口 probe 函数,用于初始化串口参数和注册串口设备
static int my_serial_probe(struct uart_port *port)
{
// 设置串口参数
port->ops = &my_uart_driver.ops;
port->type = PORT_16550A;
port->iotype = UPIO_MEM;
port->ioport = 0x3f8; // 串口的 I/O 端口地址
port->irq = 4; // 串口的中断号
port->flags = UPF_BOOT_AUTOCONF;
return uart_add_one_port(&my_uart_driver, port); // 注册串口设备
}
// 串口 remove 函数,用于注销串口设备
static void my_serial_remove(struct uart_port *port)
{
uart_remove_one_port(&my_uart_driver, port); // 注销串口设备
}
// 串口操作函数表,这里只需要实现 probe 和 remove 函数
static struct uart_ops my_uart_ops = {
.tx_empty = NULL,
.set_mctrl = NULL,
.get_mctrl = NULL,
.stop_tx = NULL,
.start_tx = NULL,
.send_xchar = NULL,
.stop_rx = NULL,
.enable_ms = NULL,
.break_ctl = NULL,
.startup = NULL,
.shutdown = NULL,
.flush_buffer = NULL,
.set_termios = NULL,
.type = NULL,
.release_port = NULL,
.request_port = NULL,
.config_port = NULL,
.verify_port = NULL,
.ioctl = NULL,
.send_xchar_locked = NULL,
};
// 模块初始化函数,在这里注册串口驱动
static int my_serial_init(void)
{
int ret = 0;
// 注册串口驱动
ret = uart_register_driver(&my_uart_driver);
if (ret) {
printk(KERN_ERR "Failed to register UART driver\n");
return ret;
}
// 设置串口操作函数表中的 probe 和 remove 函数
my_uart_ops.probe = my_serial_probe;
my_uart_ops.remove = my_serial_remove;
my_uart_driver.ops = my_uart_ops;
return ret;
}
// 模块卸载函数,在这里注销串口驱动
static void my_serial_exit(void)
{
uart_unregister_driver(&my_uart_driver);
}
module_init(my_serial_init);
module_exit(my_serial_exit);
MODULE_LICENSE("GPL");
驱动可以通过makefile编译为.ko文件后通过insmod合入内核。
常规驱动的调用方式
串口驱动程序在新的板卡上通常由厂家进行设备树适配和驱动开发,在实际使用案例当中需要熟练掌握通过文件描述符合tty层调用串口驱动即可。以下展示串口驱动的调用方式
#include
#include
#include
#include
#include
#define DEVICE "/dev/ttyMY0"
int main()
{
int fd = 0;
struct termios tio;
char buf[256];
// 打开设备文件
fd = open(DEVICE, O_RDWR | O_NOCTTY | O_NDELAY);
if (fd < 0) {
perror("open");
return -1;
}
// 设置串口参数
tcgetattr(fd, &tio);
tio.c_iflag = IGNBRK | IGNPAR;
tio.c_oflag = 0;
tio.c_cflag = CS8 | CREAD | CLOCAL;
tio.c_lflag = 0;
tio.c_cc[VTIME] = 0;
tio.c_cc[VMIN] = 1;
cfsetispeed(&tio, B9600);
cfsetospeed(&tio, B9600);
tcsetattr(fd, TCSANOW, &tio);
// 读取串口数据
printf("Reading from serial port...\n");
while (1) {
int n = read(fd, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0';
printf("Received: %s", buf);
}
}
// 关闭设备文件
close(fd);
return 0;
对于刚刚开发的驱动程序可以通过以上程序进行简单测试和验证。
实战案例
接下来展示一种通过UnionPi Tiger开发板进行串口数据收发的方案,基本思路是通过两个线程分别控制串口的收发任务,将收到的数据进行处理后再发送结果。
#include
#include
#include
#include
#include
#include
#include
#include
//宏定义
#define OK 0
#define ERR (-1)
//静态变量
static int fd1; // 串口设备文件描述符
static int fd2;
static int send_data; // 传输的数据
// 从串口读的线程
// 转换波特率
speed_t conver_baudrate(int baudrate)
{
switch (baudrate) {
case 9600L:
return B9600;
case 19200L:
return B19200;
case 38400L:
return B38400;
case 115200L:
return B115200;
case 1152000L:
return B1152000;
default:
return 1152000L;
}
}
void set_baud(int fd, int baud)
{
int ret = ERR;
struct termios opt;
tcgetattr(fd, &opt); // tcgetattr用来获取终端参数,将从终端获得的信息fd,保存到opt结构体中
tcflush(fd, TCIOFLUSH); // 刷清缓冲区
cfsetispeed(&opt, baud);
cfsetospeed(&opt, baud);
ret = tcsetattr(fd, TCSANOW, &opt); // 设置终端参数到opt中,使之立即生效
if (ret == ERR) {
perror("tcsetattr fd");
exit(0);
}
tcflush(fd, TCIOFLUSH); // 刷清缓冲区
}
// 设置数据位
int setup_data_bits(int setup_databits, struct termios *options_databits)
{
if (options_databits == NULL) {
perror("setup_data_bits error");
return ERR;
}
switch (setup_databits) {
case 5L:
options_databits->c_cflag |= CS5;
break;
case 6L:
options_databits->c_cflag |= CS6;
break;
case 7L:
options_databits->c_cflag |= CS7;
break;
case 8L:
options_databits->c_cflag |= CS8;
break;
default:
return ERR;
}
return OK;
}
// 设置校验位
int set_params_parity(int setup_parity, struct termios *options_parity)
{
switch (setup_parity) {
case 'n':
case 'N': // 无奇偶校验位
options_parity->c_cflag &= ~PARENB; // Clear parity enable/
options_parity->c_iflag &= ~INPCK; // disable input parity checking/
break;
case 'o':
case 'O': // 设置为奇校验
options_parity->c_cflag |= (PARODD | PARENB); // odd parity checking
options_parity->c_iflag |= INPCK; // enable parity checking
break;
case 'e':
case 'E': // 设置为偶校验
options_parity->c_cflag |= PARENB; // Enable parity /
options_parity->c_cflag &= ~PARODD; // even parity/
options_parity->c_iflag |= INPCK; // enable parity checking /
break;
case 'M':
case 'm': // 标记奇偶校验
options_parity->c_cflag |= PARENB | CMSPAR | PARODD;
options_parity->c_iflag |= INPCK; // enable parity checking /
break;
case 'S':
case 's': // 设置为空格
options_parity->c_cflag |= PARENB | CMSPAR;
options_parity->c_cflag &= ~PARODD;
options_parity->c_iflag |= INPCK; // enable parity checking /
break;
default:
return ERR;
}
return OK;
}
// 设置校验位
int set_params(int fd, int databits, int stopbits, int parity)
{
struct termios options;
int ret = ERR;
if (tcgetattr(fd, &options) != 0) {
perror("tcgetattr fail\n");
return ERR;
}
options.c_iflag = 0;
options.c_oflag = 0;
// setup data bits
options.c_cflag &= ~CSIZE;
ret = setup_data_bits(databits, &options);
if (ret == ERR) {
return ERR;
}
// parity
ret = set_params_parity(parity, &options);
if (ret == ERR) {
return ERR;
}
// stop bits/
switch (stopbits) {
case 1:
options.c_cflag &= ~CSTOPB;
break;
case 2L:
options.c_cflag |= CSTOPB;
break;
default:
return ERR;
}
// 请求发送和清除发送
options.c_cflag &= ~CRTSCTS;
options.c_lflag = 0;
options.c_cc[VTIME] = 10L;
options.c_cc[VMIN] = 1;
tcflush(fd, TCIFLUSH);
if (tcsetattr(fd, TCSANOW, &options) != 0) {
return ERR;
}
return OK;
}
// 设置波特率
int uart_init(int fd, int uartBaud)
{
set_baud(fd, conver_baudrate(uartBaud));
// uart param /
if (set_params(fd, 8L, 1, 'n')) {
perror("set uart parameters fail\n");
return ERR;
}
return OK;
}
int data_proce(recv){
if(recv=="hello_world"){
send_data=1;
return 1;
}
else{
send_data =0;
return 0;
}
}
void *_serial_output_task(void){
pthread_detach(pthread_self());
int ret;
ret=write(fd2,(unsigned char *) send_data,1);
if(ret>0)
printf("send success");
else {
printf("send error");
}
usleep(10000);
}
void *_serial_input_task(void)
{
int i = 0;
int ret = ERR; // 函数返回值
int buf = 0; // 用于保存读取到的字节
int recv[FRAME_LEN] = {0}; // 用于保存接收到的数据
while (1) {
// 读取一帧数据
for (i = 0; i < FRAME_LEN; i++) {
ret = read(fd1, &buf, 1); // 读取一个字节
if (ret == ERR) {
perror("read error\n");
exit(0);
}
recv[i] = buf; // 保存读取到的字节
}
// 处理接收到的数据
ret = data_proce(recv);
if (ret == ERR) {
perror("data process error\n");
exit(0);
}
}
}
int main(int argc, char **argv)
{
char *uart_dev ="ttyUSB1"; // 串口设备文件路径
char *uart_dev_t = "ttyUSB2"; // 串口设备文件路径
int ret1 = ERR; // 函数返回值
// 打开串口设备文件
fd1 = open(uart_dev, O_RDWR);
fd2= open(uart_dev_t,O_RDWR);
if (fd2== ERR) {
perror("open file fail\n");
return ERR;
}
if (fd1 == ERR) {
perror("open file fail\n");
return ERR;
}
// 初始化串口
ret1 = uart_init(fd1, 9600L);
ret2 = uart_init(fd2,9600L);
if (ret1 == ERR) {
perror("uart init error\n");
return ERR;
}
if (ret2 == ERR) {
perror("uart_t init error\n");
return ERR;
}
// 创建线程,一直执行读串口的操作
pthread_t pid_t;
pthread_create(&pid_t, NULL, (void *)_serial_input_task, 0);
pthread_create(&pid_t, NULL, (void *)_serial_output_task, 0);
while (1) {
sleep(10L); // 主线程等待
}
close(fd1); // 关闭串口设备文件
return 0;
}
在上述代码中实现了接收端对于发送端发送信息的校验,主要流程为通过接受线程收取到来自ttyUSB1的数据后进入recv_proc()函数进行判断,如果收到的数据是“helloworld"则将需要发出的值send_data 设置未1,若不是则设置为0,最后通过发送线程发送出去。
在整个流程中核心操作为对文件操作符fd的操作。
总结和一些思考
串口驱动开发是嵌入式系统开发中的一个基本任务,需要掌握底层硬件编程和Linux内核编程知识,硬件配置,驱动框架的选择,设备树的配置,内核模块的开发,都是其中的重要任务,需要每一个步骤都充分了解仔细设计,才能得到最终的有效结果。
在串口操作中需要进行复杂配置,而对于大部分的设备开发而言,有不同类型的接口,接口又有着不同的型号和数据协议,给开发以及使用带来了非常多的不便捷性,开源鸿蒙以及鸿蒙操作系统带来的可能性之一是分布式软总线,在之后的设备中只需要部署分布式软总线子系统,只需要专注于本地算法和设备驱动的开发,对于多个数据接口的适配不需要那么关注,这对于硬件和设备开发是一大变革。我们都将对此拭目以待,对鸿蒙系统的研究是十分值得的。从长远来看,分布式软总线将进一步促进设备开发的进步和发展。未来,随着物联网和智能制造等领域的不断发展,越来越多的设备将需要互相连接和通信,分布式软总线将成为设备之间通信的主要方式之一。甚至期待有一天可以取代传统的串口开发等工作,只需要适配分布式软总线子系统即可。
想了解更多关于开源的内容,请访问:
51CTO 开源基础软件社区
https://ost.51cto.com
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341