Linux-485收發切換延遲的解決方法


【前言】

  1. 本文引用各種資料甚多,而引用出處標明並不詳細,若有侵權,請聯系刪除。
  2. 轉載請注明出處:https://www.cnblogs.com/leisure_chn/p/10381616.html

一、問題描述

RS-485(亦稱TIA-485, EIA-485)作為一種半雙工總線,其收發過程不能同時進行。
RS-485通信的具體硬件原理可查閱其他資料,此處不詳述。本文僅描述其控制方法及相關問題。

通常由CPU引出三根管腳:兩個UART管腳(記作PIN_RX、PIN_TX)和一個485收發方向控制管腳(記作PIN_DIR)。
這三根管腳會接在板上的485芯片上,485芯片再向板外引出“D+、D-”兩根差分信號總線(差分信號具有搞干擾、傳輸距離遠的優勢)。

應用程序編寫時,在原來的普通串口通信基礎上,加上485收發方向控制即可。
具體說來,UART發送過程中,將PIN_DIR腳拉高,發送完畢再將PIN_DIR腳拉低,使485總線可以接收數據。
對於無操作系統的裸機程序來說,485通信非常簡單。
但在Linux應用程序編寫中,這個方向切換存在延遲問題。

Linux應用層485控制接口偽代碼如下:

// 初始化串口  
fd = open("/dev/ttyS1", O_RDWR | O_NOCTTY);
init_serial(fd, 9600, 8, 1, 'N');
set_485_dir(LOW);	// 默認為接收狀態  

// 發送數據
set_485_dir(HIGH);
write(fd, buf, sizeof(buf));
tcdrain(fd);    	// 此句判斷時刻不准,延時約10-20ms  
set_485_dir(LOW);

// 接收數據  
read();

經測試,set_485_dir()改變PIN_DIR腳的延遲很小,可忽略不計。tcdrain()卻總是存在10-20ms的延遲。
tcdrain()是等待fd所引用的串口設備數據傳輸完畢。在物理上數據已傳輸完畢時,因為操作系統延遲原因,導致tcdrain()多停留了10-20ms,從而導致數據發送完后,PIN_DIR不能及時切換。
如果對接的485設備,接收和應答的延遲小於20ms,那方向切換不及時將導致數據接收丟失。這就是問題所在。

二、解決方法

1. 解決思路

關於收發方向延遲問題,解決思路有如下幾種:

  • 由硬件自動控制收發方向的切換,這種方式不需要軟件參與,硬件實現也很簡單,推薦使用
  • 嘗試將操作系統HZ由默認的100改為1000,經測,tcdrain()延遲降為幾個ms,實際仍然不能滿足要求,而且比較影響系統性能
  • 應用層控制方向切換,應用程序里使用ioctl()方法,利用Linux串口驅動里自帶的485功能。此方法需要全串口里的RTS管腳作為方向腳。時間所限,此方法未研究明白
  • 驅動層控制方向切換,修改串口驅動使支持485方向切換,此方法驗證可行

最后一種方法就是本文要描述的方法。

2. 知識儲備

解決此問題,需要有如下知識儲備:

  • 了解485通信原理
  • 了解Linux終端設備驅動架構,搞清楚板上串口對應的實際驅動源文件
  • 掌握Linux設備驅動中的中斷處理機制:頂半部、底半部(tasklet、工作隊列、軟中斷)

3. 實現方法

本應用中對應的串口設備驅動文件為linux/drivers/tty/serial/8250/8250_core.c

3.1 由應用程序控制是否打開串口設備的485功能

在串口驅動里切換485方向對性能有一些影響。
而某些應用可能只需要標准串口,不需要支持485模式。
因此最好由應用程序來控制,是使用標准串口還是支持485模式的串口。
這主要利用ioctl()實現。

應用程序在初始化打開串口時,禁用/使能串口的485模式

fd = open(...);
init_serial(fd, ...);

struct serial_rs485 rs485conf;
rs485conf.flags |= SER_RS485_ENABLED;	// 使能本串口485模式,默認禁用
ioctl(fd, TIOCSRS485, &rs485conf);

驅動程序中對使能了485模式的串口作特殊處理。
利用struct uart_8250_port結構體中的struct serial_rs485 rs485成員判斷串口是否支持485模式。
在serial_8250.h中有定義rs485數據成員,以及設置此數據成員的成員函數rs485_config

// noted by xx@xx: in serial_8250.h
/*
 * This should be used by drivers which want to register
 * their own 8250 ports without registering their own
 * platform device.  Using these will make your driver
 * dependent on the 8250 driver.
 */

struct uart_8250_port {
	struct uart_port	port;
	struct timer_list	timer;		/* "no irq" timer */
	struct list_head	list;		/* ports on this IRQ */
	unsigned short		capabilities;	/* port capabilities */
	unsigned short		bugs;		/* port bugs */
	bool			fifo_bug;	/* min RX trigger if enabled */
	unsigned int		tx_loadsz;	/* transmit fifo load size */
	unsigned char		acr;
	unsigned char		fcr;
	unsigned char		ier;
	unsigned char		lcr;
	unsigned char		mcr;
	unsigned char		mcr_mask;	/* mask of user bits */
	unsigned char		mcr_force;	/* mask of forced bits */
	unsigned char		cur_iotype;	/* Running I/O type */
	unsigned int		rpm_tx_active;

	/*
	 * Some bits in registers are cleared on a read, so they must
	 * be saved whenever the register is read but the bits will not
	 * be immediately processed.
	 */
#define LSR_SAVE_FLAGS UART_LSR_BRK_ERROR_BITS
	unsigned char		lsr_saved_flags;
#define MSR_SAVE_FLAGS UART_MSR_ANY_DELTA
	unsigned char		msr_saved_flags;

	struct uart_8250_dma	*dma;
	struct serial_rs485     rs485;

	/* 8250 specific callbacks */
	int			(*dl_read)(struct uart_8250_port *);
	void			(*dl_write)(struct uart_8250_port *, int);
	***int			(*rs485_config)(struct uart_8250_port *,
						struct serial_rs485 *rs485);***
};

但serial_8250.c中默認並未實現rs485_config函數,那我們自己實現,如下:
1) 驅動層編寫485配置函數

// add by xx@xx begin
static int serial8250_rs485_config(struct uart_8250_port *up,
				   struct serial_rs485 *rs485)
{
	if (rs485->flags & SER_RS485_ENABLED) {
		printk(KERN_INFO "uart %d set 485 on\n", up->port.line);
		gpio_485_set_direction(true);
		gpio_485_set_value(false);
		tasklet_init(&s485_tasklet, serial8250_485_do_tasklet, (unsigned long)&up->port);
	}
	else {
		printk(KERN_INFO "uart %d set 485 off\n", up->port.line);
	}

	memcpy(&up->rs485, rs485, sizeof(*rs485));

	return 0;
}
// add by xx@xx end

此函數在應用層調用ioctl()函數時,會被驅動層調用執行,此函數作了兩件事:
a. 將第二個參數rs485保存在第一個參數up里,第一個參數關聯具體的某個串口設備(關聯應用層里的ioctl(fd)中的fd)
b. 判斷參數是否使能了485模式,若使能了,則將485方向設置為接收,並注冊中斷底半部tasklet處理函數serial8250_485_do_tasklet

2) 驅動層注冊485配置函數

int serial8250_register_8250_port(struct uart_8250_port *up)
{
	struct uart_8250_port *uart;
	int ret = -ENOSPC;

	if (up->port.uartclk == 0)
		return -EINVAL;

	mutex_lock(&serial_mutex);

	// add by xx@xx begin
	memset((void *)&up->rs485, 0, sizeof(up->rs485));
	up->rs485_config = serial8250_rs485_config;
	// add by xx@xx end 

	......
}

3)應用層open()打開串口時,驅動層調用鏈

serial8250_probe()->
serial8250_register_8250_port()->
up->rs485_config = serial8250_rs485_config;

4) 應用層ioctl()使能串口485模式時,ioctl()在驅動底層的調用代碼

// 下列代碼為系統自帶代碼,無任何改動
static int serial8250_ioctl(struct uart_port *port, unsigned int cmd,
			   unsigned long arg)
{
	struct uart_8250_port *up =
		container_of(port, struct uart_8250_port, port);
	int ret;
	struct serial_rs485 rs485_config;

	if (!up->rs485_config)
		return -ENOIOCTLCMD;

	switch (cmd) {
	case TIOCSRS485:	// 設置
		if (copy_from_user(&rs485_config, (void __user *)arg,
				   sizeof(rs485_config)))
			return -EFAULT;

		ret = up->rs485_config(up, &rs485_config);
		if (ret)
			return ret;

		memcpy(&up->rs485, &rs485_config, sizeof(rs485_config));

		return 0;
	case TIOCGRS485:	// 獲取
		if (copy_to_user((void __user *)arg, &up->rs485,
				 sizeof(up->rs485)))
			return -EFAULT;
		return 0;
	default:
		break;
	}

	return -ENOIOCTLCMD;
}

調用鏈:

serial8250_ioctl()->
up->rs485_config(up, &rs485_config)->
serial8250_rs485_config()	// 自己實現的函數

serial8250_rs485_config()說明參上

3.2 在發送過程的起始時刻拉高PIN_DIR

在串口發送的起始時刻,即串口產生傳輸起始位的時刻,會調用serial8250_start_tx(),在此函數中將PIN_DIR拉高

static void serial8250_start_tx(struct uart_port *port)
{
	struct uart_8250_port *up = up_to_u8250p(port);
	
	// add by xx@xx begin
	if (up->rs485.flags & SER_RS485_ENABLED) {
		gpio_485_set_value(true);
	}
	// add by xx@xx end

	......
}

3.3 在發送過程的結束時間拉低PIN_DIR

按照推理,以為在串口傳輸結束位的時候,會調用serial8250_stop_tx(),那在此函數中將PIN_DIR拉低,任務就完成了。
但是,加打印發現,實際此函數從未被調用。
縷一下代碼,找到串口發送的結束時刻:8250串口的收發數據是通過中斷方式實現的,串口的結束時刻在中斷處理程序中判斷,
1) 中斷處理函數的注冊

serial8250_init()->
serial8250_isa_init_ports()->
set_io_from_upio()->
p->handle_irq = serial8250_default_handle_irq;

2) 中斷處理函數的調用

serial8250_default_handle_irq()->
serial8250_handle_irq()->
serial8250_tx_chars()->

3) 找到位置了,就在serial8250_tx_chars()中調用底半部機制tasklet

void serial8250_tx_chars(struct uart_8250_port *up)
{
	struct uart_port *port = &up->port;
	struct circ_buf *xmit = &port->state->xmit;
	int count;

	if (port->x_char) {
		serial_out(up, UART_TX, port->x_char);
		port->icount.tx++;
		port->x_char = 0;
		return;
	}
	if (uart_tx_stopped(port)) {
		serial8250_stop_tx(port);
		return;
	}
	if (uart_circ_empty(xmit)) {
		__stop_tx(up);
		return;
	}

	count = up->tx_loadsz;
	do {
		serial_out(up, UART_TX, xmit->buf[xmit->tail]);
		xmit->tail = (xmit->tail + 1) & (UART_XMIT_SIZE - 1);
		port->icount.tx++;
		if (uart_circ_empty(xmit))
			break;
		if (up->capabilities & UART_CAP_HFIFO) {
			if ((serial_port_in(port, UART_LSR) & BOTH_EMPTY) !=
			    BOTH_EMPTY)
				break;
		}
	} while (--count > 0);

	if (uart_circ_chars_pending(xmit) < WAKEUP_CHARS)
		uart_write_wakeup(port);

	DEBUG_INTR("THRE...");

	/*
	 * With RPM enabled, we have to wait once the FIFO is empty before the
	 * HW can go idle. So we get here once again with empty FIFO and disable
	 * the interrupt and RPM in __stop_tx()
	 */
	if (uart_circ_empty(xmit) && !(up->capabilities & UART_CAP_RPM))
	{
		__stop_tx(up);
		// add by xx@xx begin
		if (up->rs485.flags & SER_RS485_ENABLED)
			tasklet_hi_schedule(&s485_tasklet);
		// add by xx@xx end
	}
}

注:tasklet_hi_schedule()和tasklet_schedule()的區別:

void tasklet_schedule(struct tasklet_struct *t);
調度tasklet執行,如果tasklet在運行中被調度,它在完成后會再次運行;這保證了在其他事件被處理當中發生的事件受到應有的注意。這個做法也允許一個tasklet重新調度它自己。

void tasklet_hi_schedule(struct tasklet_struct *t);
和tasklet_schedule()類似,只是在更高優先級執行。當軟中斷處理運行時, 將在其他軟中斷之前tasklet_hi_schedule(),只有具有低響應周期要求的驅動才應使用這個函數, 可避免其他軟件中斷處理引入的附加周期。

void tasklet_hi_schedule_first(struct tasklet_struct *t);
此函數的主要作用是將參數t代表的軟中斷添加到向量tasklet_hi_vec的頭部,並觸發一個軟中斷。而tasklet_hi_schedule()則是將參數t代表的軟中斷
添加到向量tasklet_hi_vec的尾部,因此tasklet_hi_schedule_first()添加的tasklet比tasklet_hi_schedule()的優先級更高。

tasklet_schedule使用TASKLET_SOFTIRQ軟中斷索引號,tasklet_hi_schedule和tasklet_hi_schedule_first()使用HI_SOFTIRQ軟中斷索引號。
在Linux支持的多種軟中斷中,HI_SOFTIRQ具有最高的優先級。

4) tasklet處理函數的實現

// TODO: custom a new macro to avoid warnings
#define my_container_of(ptr, type, member) ((type *)((char *)(ptr) - offsetof(type, member)))

static struct tasklet_struct s485_tasklet;
void serial8250_485_do_tasklet(unsigned long);

void serial8250_485_do_tasklet(unsigned long param)
{
	struct uart_port *port;
	struct uart_state *state;
	struct tty_struct *tty;
	struct ktermios termios;
	unsigned int baud;
	int bit_width;

	port = (struct uart_port *)param;

	#if 0
	struct circ_buf *xmit = &port->state->xmit;
	unsigned long flags;
	unsigned int lsr;

	while (1)
	{
		spin_lock_irqsave(&port->lock, flags);
		lsr = serial_port_in(port, UART_LSR);
		spin_unlock_irqrestore(&port->lock, flags);

		if (uart_circ_empty(xmit) && ((lsr & BOTH_EMPTY) == BOTH_EMPTY))
		{
			break;
		}
	}
	#else
	while (port->ops->tx_empty(port) != TIOCSER_TEMT)
	{
		;
	}
	#endif
	
	state = my_container_of(port, struct uart_state, uart_port);
	tty = my_container_of(state, struct tty_struct, driver_data);
	termios = tty->termios;
	baud = uart_get_baud_rate(port, &termios, NULL, 1200, 115200); 
	bit_width = (baud > 0) ? 1000000/baud : 0;
	bit_width = (bit_width > 50) ? (bit_width-50) : 0;	// Measured delay value is 50 us

	udelay(bit_width);	// a stop bit

	gpio_485_set_value(false);
}

注意:上述代碼中udelay(bit_width)是為了延遲一個stop bit的時間
用示波器測一下,485收發方向切換非常准時,微秒級別的延遲,可以接受

3.4 幾種中斷底半部機制的對比

  • tasklet
    tasklet執行於軟中斷上下文,執行時機通常是在頂半部返回的時候。tasklet處理函數中不可睡眠。
  • 工作隊列
    工作隊列執行於進程上下文(內核線程)。工作隊列處理函數中可以睡眠。
  • 軟中斷(softirq)
    tasklet是基於軟中斷(softirq)實現的。softirq通常在內核中使用,驅動程序不宜直接使用softirq。

總體來說,中斷優先級高於軟中斷,軟中斷優先級高於各線程。

在本例中,曾嘗試使用工作隊列,測得延遲仍有幾毫秒至十幾二十毫秒(記不清楚了),無法解決問題。
而使用tasklet則能將延遲控制得非常精確。從這一點也反映了進程上下文和軟中斷上下文的不同之處。

三、遺留問題

  1. tasklet處理函數中調用了自旋鎖,忙等判斷發送結束時刻,操作系統將串口緩沖區數據全部扔給串口芯片到串口線上一包數據傳輸完成,這個過程存在一個時間段,在這個時間段內,處於忙等狀態,這會影響系統性能。優化方向是:研究是否能利用moderm的線控狀態,在傳輸線上數據傳輸完成的時刻,觸發一個中斷,在此中斷處理中將485切換為接收狀態。
  2. 應用程序串口接收的read()函數一直處於阻塞狀態,直到數據在信號線中傳輸完畢驅動層中有數據可讀。優化方向是:由驅動層接收在接收起始時刻和結束時刻分別向應用層發一個信號,結束時刻定在串口接收超時中斷時刻,這樣應用程序可以獲知串口線何時處於接收忙碌狀態。這樣會使對485的支持機制更加完善,應用層有更多的控制空間。

四、參考資料

[1] https://zh.wikipedia.org/wiki/EIA-485
[2] https://blog.csdn.net/u012351051/article/details/69223326
[3] http://kuafu80.blog.163.com/blog/static/122647180201431625820150/
[4] http://blog.chinaunix.net/uid-20768928-id-5077401.html
[5] https://blog.csdn.net/u013304850/article/details/77165265
[6] http://guojing.me/linux-kernel-architecture/posts/soft-irq/
[7] https://blog.csdn.net/ezimu/article/details/54851148


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM