?
驅動開發 -串口和串行總線
基本知識
一般情況下,設備間的通信方式可以劃分為串行通行方式和并行通信方式兩種。在linux字符設備、塊設備、網絡設備分類方式下,該外設分類劃分于字符設備當中。本章節主要指導基于LINUX驅動完成串口驅動開發并調用串口與USB接口與外設完成有效通信。
串行通信的分類
按照數據傳輸方向
按照數據傳輸的方向可以劃分為 單工,半雙工和全雙工。單工通信允許數據在同一方向上進行傳輸,半雙工則允許數據雙向傳輸但是在同一時刻僅允許一個方向的數據傳輸嗎,不需要獨立的接收端和放松端,兩者可以合并使用相同端口。全雙工通信則包含兩個方向上的同時傳輸,全雙工通信是兩個半雙工的通信方式的拼接,從而完成的獨立接收端和發送端。
按照通信方式
而按照通信方式的不同,可以劃分為同步通信和異步通信兩種,同步通信是需要帶時鐘信號進行互相時鐘同步從而解析電平信號的,如SPI,IIC,而異步通信是無需時鐘同步信號的,如UART等。
在同步通訊中,收發設備的上方會使用一根信號線傳輸信號,在時鐘信號的驅動下雙方進行數據的同步,通常會在收發兩端規定在時鐘信號的上升沿和下降沿對數據線進行采樣。
在異步通訊中,不適用時鐘信號進行數據同步,直接在數據信號中穿插一些用于數據同步的信號位,或通過指定數據協議進行數據打包,以數據幀的方式傳輸數據,通訊中需要約束傳輸速率波特率,常見波特率有 4800 9600 115200等。
UART連接方式
存在兩個引腳:
- RX接收引腳
- TX發送引腳
在連接時如圖,兩個芯片的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 <linux/module.h>
#include <linux/init.h>
#include <linux/serial_core.h>
#include <linux/serial.h>
#include <linux/tty.h>
#include <linux/tty_flip.h>
#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 drivern");
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 <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#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] = '';
printf("Received: %s", buf);
}
}
// 關閉設備文件
close(fd);
return 0;
對于剛剛開發的驅動程序可以通過以上程序進行簡單測試和驗證。
實戰案例
接下來展示一種通過UnionPi Tiger開發板進行串口數據收發的方案,基本思路是通過兩個線程分別控制串口的收發任務,將收到的數據進行處理后再發送結果。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>
//宏定義
#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 failn");
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 failn");
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 errorn");
exit(0);
}
recv[i] = buf; // 保存讀取到的字節
}
// 處理接收到的數據
ret = data_proce(recv);
if (ret == ERR) {
perror("data process errorn");
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 failn");
return ERR;
}
if (fd1 == ERR) {
perror("open file failn");
return ERR;
}
// 初始化串口
ret1 = uart_init(fd1, 9600L);
ret2 = uart_init(fd2,9600L);
if (ret1 == ERR) {
perror("uart init errorn");
return ERR;
}
if (ret2 == ERR) {
perror("uart_t init errorn");
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內核編程知識,硬件配置,驅動框架的選擇,設備樹的配置,內核模塊的開發,都是其中的重要任務,需要每一個步驟都充分了解仔細設計,才能得到最終的有效結果。
在串口操作中需要進行復雜配置,而對于大部分的設備開發而言,有不同類型的接口,接口又有著不同的型號和數據協議,給開發以及使用帶來了非常多的不便捷性,開源鴻蒙以及鴻蒙操作系統帶來的可能性之一是分布式軟總線,在之后的設備中只需要部署分布式軟總線子系統,只需要專注于本地算法和設備驅動的開發,對于多個數據接口的適配不需要那么關注,這對于硬件和設備開發是一大變革。我們都將對此拭目以待,對鴻蒙系統的研究是十分值得的。從長遠來看,分布式軟總線將進一步促進設備開發的進步和發展。未來,隨著物聯網和智能制造等領域的不斷發展,越來越多的設備將需要互相連接和通信,分布式軟總線將成為設備之間通信的主要方式之一。甚至期待有一天可以取代傳統的串口開發等工作,只需要適配分布式軟總線子系統即可。