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

五、修改记录

2018-07-28 V1.0 初稿

posted @ 2019-02-15 08:56  叶余  阅读(7994)  评论(5编辑  收藏  举报