本文隶属于AVR单片机教程系列。html
在第一期中,咱们已经开始使用UART来实现单片机开发板与计算机之间的通讯,但只是简单地讲了讲一些概念和库函数的使用。在这一篇教程中,咱们将从硬件与软件等各方面更深刻地了解UART。数组
一直在讲的UART实际上是USART组件的一部分,USART比UART多了同步的一部分,但这一部分用得太少(我历来没用过),并且缺少实例,因此就略过了。然而,单片机的设计者很机智地把这个鸡肋功能升华了一下,USART组件能够支持SPI模式。SPI是一种同步串行总线,能够支持很高的传输速率。这个功能使得ATmega324PA支持最多3个SPI通道,其中一个是纯SPI,另两个就是SPI模式下的USART。咱们将在下一讲中揭开SPI的神秘面纱。数据结构
回到UART模式下的USART组件。开发板引出的RX
和TX
引脚是属于USART0组件的,所以使用时如下n
都用0
代替。并发
UART共有5个寄存器:异步
UDRn
是收发数据寄存器,收(RXB
)和发(TXB
)使用不一样的寄存器,但都经过UDRn
来访问。向TXB
写入一个字节,UART就开始发送;RXB
保存接收到的数据,带有额外一个字节的缓冲(如同下一节要讲的缓冲区)。async
UCSRnA
包含UART状态位,如三个中断对应的标志,以及一些不经常使用的设置位。函数
UCSRnB
主要用于使能,包括收发器与三个中断的使能位,以及9位帧格式相关的位。工具
UCSRnC
是最主要的控制寄存器,能够配置USART的模式与格式。测试
UBRRnL
和UBRRnH
(能够经过UBRRn
来访问这个16位寄存器)用于设定波特率,在异步模式下,\(BAUD = \frac {f_{CPU}} {16(UBRRn + 1)}\)。ui
UART支持三个中断,分别是接收完成(RX
)、数据寄存器空(UDRE
)、发送完成(TX
)。第一个用于接收,后两个用于发送,通常使用UDRE
。
RX
中断容许程序在任什么时候刻及时地接收并处理总线上发来的数据。沿用“串口接收”一讲中的例子:
#include <avr/io.h> #include <avr/interrupt.h> #include <ee1/led.h> int main(void) { led_init(); PORTD |= 1 << 0; // RXD0 pull-up UCSR0B = 1 << RXCIE0 // RX interrupt | 1 << RXEN0 // RX enabled | 1 << TXEN0; // TX enabled UCSR0C = 0b00 << UMSEL00 // asynchronous USART | 0b10 << UPM00 // even parity | 0 << USBS0 // 1 stop bit | 0b11 << UCSZ00; // 8-bit UBRR0L = 40; // 38400bps sei(); while (1) ; } ISR(USART0_RX_vect) { static const char led_char[4] = {'r', 'y', 'g', 'b'}; static uint8_t which = 4; uint8_t byte = UDR0; bool matched = false; for (uint8_t i = 0; i != 4; ++i) if (byte == led_char[i]) { matched = true; which = i; break; } if (!matched && (byte == '0' || byte == '1')) { matched = true; if (which < 4) led_set(which, byte - '0'); which = 4; } if (!matched) which = 4; }
TX
与UDRE
中断容许程序在总线发送数据同时执行其余代码。好比,在打印ASCII表的同时控制LED闪烁。
#include <avr/io.h> #include <avr/interrupt.h> #include <ee1/led.h> #include <ee1/delay.h> int main(void) { led_init(); UCSR0B = 1 << UDRIE0 // UDRE interrupt | 1 << TXEN0; // TX only UCSR0C = 0b00 << UMSEL00 // asynchronous USART | 0b10 << UPM00 // even parity | 0 << USBS0 // 1 stop bit | 0b11 << UCSZ00; // 8-bit UBRR0L = 40; // 38400bps sei(); while (1) { led_on(); delay(500); led_off(); delay(500); } } ISR(USART0_UDRE_vect) { static char c = 0x21; UDR0 = c; if (++c == 0x7F) c = 0x21; }
你看,不用定时器,只需总线中断与老套的main
结合便可。
值得一提的是UDRE
中断的设计特别人性化——UDREn
的复位值是1
,程序能够把全部数据都放在中断中,控制部分只需开关中断——而SPI和I²C组件都没有这个特性。至于它到底带来多少好处,只有在码的过程当中体会了。
若是你较真一点,就会以为上面这个程序很烂:
把硬件驱动(UART配置与中断)与业务逻辑(要输出的内容)牢牢地链接在一块儿(专业点讲,叫“紧耦合”),不符合可复用性等一系列设计原则;
ASCII表是十分有规律的,而大多数程序的输出则否则,须要UDRE
中断之外的代码来决定要输出什么字符串,仅中断并不能解放常规的输出。
其实咱们还遇到过其余问题:
相比25MHz的CPU频率,UART的38400波特率是很慢的,传输一个字节的时间可让CPU执行几千条指令,但uart_print_string
等函数的策略都是等待UART把数据发送完成才返回,是阻塞的;
uart_scan_string
等函数要求程序乖乖地等待总线上的数据到来,不能错过,这使程序不能在等待的同时作其余事;
以上两点相结合更让人尴尬——在发送的同时接收到的数据会被错过,怎么还能叫全双工总线呢?
这输入和输出两方面的问题能够用一种高度对称的手段来解决,它就是缓冲区。缓冲区是这样一种结构,它存放着一串字符,来自于程序的输出或UART的接收,并能够按顺序取出,用于UART的发送或程序的输入。显然,这须要用到中断:在RX
中断中,向缓冲区中放入接收到的数据;在UDRE
中断中,若是缓冲区中有数据,则取出并发送之。
因而,当程序须要输入时,能够从缓冲区中取一些字符,并解析成整数等类型,若是缓冲区为空,则等待输入,与C语言标准输入scanf
很相似;当程序须要输出时,能够直接把字符串写到缓冲区中,让中断来逐字节发送,而主程序能够无需等待,直接继续工做,这种输出是异步的。这个“异步”与UART总线的“异步”是不一样的概念。关于阻塞、异步等概念,可参考:怎样理解阻塞非阻塞与同步异步的区别?
可是如今“缓冲区”还只是一个抽象概念,咱们要把它落实成代码。如何实现一个缓冲区呢?
咱们先把缓冲区想象成一个管道,有头和尾两端,咱们须要从尾部放入球,从头部取出。这种数据结构称为队列。
队列能够用链表来实现,好处是队列的长度没有限制,除非内存耗尽。可是在咱们的应用场景中,链表节点中有效的数据是一个字节,却还须要两个字节来存放一个指针,不太划算。而且,malloc
函数是比较耗时的,应避免频繁调用。
咱们使用一种叫做“循环队列”的实现。循环队列是一个数组,保存两个下标,分别指向头和尾(因为我主要写C++,我习惯用尾后)。循环体如今,假如队列的大小是64,那么下标为63的元素的后一个就是下标为0的元素。若是把普通数组想象成一个矩形,那么循环队列就是一个圆环。
初始时,头和尾下标相同。向尾部放入一个字节,就是在尾下标处写数据,并让尾下标指向下一个元素;取出一个字节,就是读取头下标处的数据,并让头下标指向下一个元素。当两个下标相等时,队列为空;当尾的后一个等于头时,队列满——但是明明这时只放了63个元素,为何再也不放一个呢?由于会与队列空的状况冲突,没法分辨,为了省事,仍是浪费一个字节吧。
下面这段代码须要你认真阅读并理解,可是请先忽略volatile
和ATOMIC_BLOCK(ATOMIC_FORCEON)
,当它们不存在就能够了。你也能够参考一些循环队列相关的资料来更好地理解这种结构(原本我想写的,但这篇已经很长了)。
#include <stdint.h> #include <stdbool.h> #include <avr/io.h> #include <avr/interrupt.h> #include <util/atomic.h> #define UART_TX_BUFFER_SIZE 64 #define UART_TX_BUFFER_MASK (UART_TX_BUFFER_SIZE - 1) volatile char uart_tx_buffer[UART_TX_BUFFER_SIZE]; volatile uint8_t uart_tx_head = 0; volatile uint8_t uart_tx_tail = 0; void uart_init_buffered() { UCSR0B = 0 << UDRIE0 // UDRE interrupt disabled | 1 << TXEN0; // TX only UCSR0C = 0b00 << UMSEL00 // asynchronous USART | 0b10 << UPM00 // even parity | 0 << USBS0 // 1 stop bit | 0b11 << UCSZ00; // 8-bit UBRR0L = 40; // 38400bps } void uart_print_char_buffered(char c) { bool full = true; while (1) { ATOMIC_BLOCK(ATOMIC_FORCEON) { if (((uart_tx_tail + 1) & UART_TX_BUFFER_MASK) // 0->1, ..., 63->0 != uart_tx_head) full = false; } if (!full) break; // if full, wait until buffer is not full } ATOMIC_BLOCK(ATOMIC_FORCEON) { if (uart_tx_head == uart_tx_tail) UCSR0B |= 1 << UDRIE0; uart_tx_buffer[uart_tx_tail] = c; uart_tx_tail = (uart_tx_tail + 1) & UART_TX_BUFFER_MASK; } } ISR(USART0_UDRE_vect) { UDR0 = uart_tx_buffer[uart_tx_head]; uart_tx_head = (uart_tx_head + 1) & UART_TX_BUFFER_MASK; if (uart_tx_head == uart_tx_tail) UCSR0B &= ~(1 << UDRIE0); }
看到这里我默认你已经理解了循环数组,下面来看这些被忽略的语句。声明为volatile
的变量必定会被放在内存中而不是通用寄存器中;ATOMIC_BLOCK
的功能是,后面的大括号中的语句是原子的,在执行时不会被中断;ATOMIC_FORCEON
会在执行完后把全局中断打开。
相信你必定对这种代码感到不适,为何须要这么麻烦呢?以if (uart_tx_head == uart_tx_tail)
这一句为例,这句语句一般由主程序执行。
假设执行到这一句前时uart_tx_head
为41
,uart_tx_tail
为42
,即缓冲区中还有1
字节没有发送。
程序读取uart_tx_head
,其值为41
。
在读取uart_tx_tail
以前,USART0_UDRE_vect
中断触发了,在中断中最后一个字节被发送,uart_tx_head
被修改成42
,UDRIE0
被写0
,关掉了这个中断,随后中断退出。
程序读取uart_tx_tail
,其值为42
,二者不相等,UDRIE0
不会被写1
,中断保持关闭状态。
缓冲区中被写了一个字节,uart_tx_tail
变为43
。缓冲区明明非空,UDRE
中断却没有开,这个字节没法发送。
这样分析很累,我写的时候并无认真分析不加原子操做可能带来的问题,而是遵循这样的原则:对于非中断与中断的代码共享的数据,在非中断代码中必定要加原子,在中断代码中,若是在使用这些数据时全局中断可能处于打开状态,则也须要加原子。
如今咱们实现了串口输出缓冲区,输入缓冲区的原理相似,留做做业。咱们还须要关注几个问题:
串口输出是连续的字符流。“连续”是指不存在发送几个字节,停顿一下,再继续发送的状况;“字符流”是指发送的数据都是字符。在字符流的假设下,若是须要能够断开的输出,能够经过用\0
标记断点来实现。可是对于字节流,即数据自己就可能包含\0
的情形下,如何标记断点呢?做业4在缓冲区的基础之上增长了这样的需求。
以上代码对于在缓冲区满时插入字符的策略是等待直到缓冲区有空位,虽然必定能等到,保证数据被发送,但可能须要等待很长时间。好比,在缓冲区满时发送一个较长的字符串,插入每一字节时都须要等待一个字节被发送的时间,整体上与同步发送无异。这里提供几种方案:用一种结构来标记是否发生了错误,以及发生何种错误;给发送函数添加返回值,指示是否发送成功;使用动态缓冲区,当缓冲区满时新开辟一块空间存放。不过,仍是要根据应用选择最合适的。
UART是稀缺资源,单片机一共有两个,我设计的时候用掉一个,要是再加个串口调试,就用完了。可是,利用一个额外的GPIO和开发板左上角的逻辑门资源,咱们能够把一个UART发送通道扩展成两个。
这个组合电路有两个输入:单片机UART输出(UART
,简写为U
)和信号选择(SEL
,简写为S
);两个输出:当SEL
为低电平时有效的通道A(OUTA
,简写为A
)和当SEL
为高电平时有效的通道B(OUTB
,简写为B
,以上名字都是随便起的)。这样,尽管不能在两个通道同时发送,但至少SEL
能够控制每一个字节的流向。
回顾UART的帧格式,当信号线上没有信号的时候,它是保持高电平的,所以对于A通道,当SEL
为高时,OUTA
老是为高;当SEL
为低时,OUTA
电平与UART
相同,能够获得\(A = U + S\),+
号表示逻辑或。同理,\(B = U + \overline {S}\),上划线表示逻辑非。另外,·
号表示逻辑与。
可是开发板上并无或门和非门,只有与非门(|
,C语言中|
表示什么?)和或非门(↓
),咱们须要把这两个式子变形一下:
\(A = U + S = \overline { \overline{U} \cdot \overline{S} } = (U \downarrow 0) | (S \downarrow 0)\)
\(B = U + \overline {S} = \overline { \overline{U} \cdot S } = (U \downarrow 0) | S\)
这样咱们就能够画出原理图:
左边两个是或非门,右边是与非门,分别位于开发板左上角标注NOR
和NAND
处。每一个门有两个输入和一个输出,对应A
、B
、O
三个引脚,A
和B
是能够对调的。
而后就要根据原理图搭建电路。也许你对这张并不复杂的电路图毫无头绪。的确,面包板上看似简单的电路也可能很复杂,不过仍是有规则能够遵循的:
每一条杜邦线有两端,是黑色胶壳加上一根针或者没有针。有针的称为“公”,没有的称为“母”(我真没开车)。
板上的排针链接母头,面包板链接公头。
杜邦线有3种:公对公、公对母、母对母。
面包板上,一行5个孔是链接起来的。
各个引脚能够划分为若干不相交集合,相同集合内的引脚有导线链接,不一样集合内的引脚没有引脚链接。每一个集合称为一个net。
对于只有一个引脚的net,无论它。
对于有两个引脚的net,选用合适的杜邦线把两个引脚直接链接。
对于有至少3个引脚的net,一般须要借助面包板,选用合适的杜邦线把每一个引脚与面包板上同一行链接。
这张图里只有SEL
和第一个或非门的输出这两个net有3个引脚,所以面包板上只会有6根线,像这样:
最后简单地测试一下,PIN_D
用做SEL
。
#include <ee1/pin.h> #include <ee1/uart.h> #include <ee1/delay.h> int main(void) { pin_mode(PIN_D, OUTPUT); uart_init(UART_TX); for (int16_t i = 0; ; ++i) { pin_write(PIN_D, i & 1); uart_print_int(i); uart_print_line(); delay(500); } }
把两个通道链接到USB转串口工具上,分别能够看到奇数和偶数的输出。
为何一般使用UDRE而不是TX?何时不能使用UDRE而只能选择TX?
使用中断与缓冲区改写“串口接收”一讲中的例程。
如何使用74HC138来扩展UART输出?
实现一个两个发送通道共用的缓冲区(注意第1题)。