AVR单片机教程——UART进阶

本文隶属于AVR单片机教程系列。

?

在第一期中,我们已经开始使用UART来实现单片机开发板与计算机之间的通信,但只是简单地讲了讲一些概念和库函数的使用。在这一篇教程中,我们将从硬件与软件等各方面更深入地了解UART。

USART组件

一直在讲的UART其实是USART组件的一部分,USART比UART多了同步的一部分,但这一部分用得太少(我从来没用过),而且缺乏实例,所以就略过了。然而,单片机的设计者很机智地把这个鸡肋功能升华了一下,USART组件可以支持SPI模式。SPI是一种同步串行总线,可以支持很高的传输速率。这个功能使得ATmega324PA支持最多3个SPI通道,其中一个是纯SPI,另两个就是SPI模式下的USART。我们将在下一讲中揭开SPI的神秘面纱。

回到UART模式下的USART组件。开发板引出的RXTX引脚是属于USART0组件的,因此使用时以下n都用0代替。

UART共有5个寄存器:

  • UDRn是收发数据寄存器,收(RXB)和发(TXB)使用不同的寄存器,但都通过UDRn来访问。向TXB写入一个字节,UART就开始发送;RXB保存接收到的数据,带有额外一个字节的缓冲(如同下一节要讲的缓冲区)。
  • UCSRnA包含UART状态位,如三个中断对应的标志,以及一些不常用的设置位。
  • UCSRnB主要用于使能,包括收发器与三个中断的使能位,以及9位帧格式相关的位。
  • UCSRnC是最主要的控制寄存器,可以配置USART的模式与格式。
  • UBRRnLUBRRnH(可以通过UBRRn来访问这个16位寄存器)用于设定波特率,在异步模式下,\(BAUD = \frac {f_{CPU}} {16(UBRRn + 1)}\)。

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;
}

TXUDRE中断允许程序在总线发送数据同时执行其他代码。比如,在打印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和I2C组件都没有这个特性。至于它到底带来多少好处,只有在码的过程中体会了。

缓冲区

如果你较真一点,就会觉得上面这个程序很烂:

  1. 把硬件驱动(UART配置与中断)与业务逻辑(要输出的内容)紧紧地连接在一起(专业点讲,叫“紧耦合”),不符合可复用性等一系列设计原则;
  2. ASCII表是十分有规律的,而大多数程序的输出则不然,需要UDRE中断以外的代码来决定要输出什么字符串,仅中断并不能解放常规的输出。

    其实我们还遇到过其他问题:

  3. 相比25MHz的CPU频率,UART的38400波特率是很慢的,传输一个字节的时间可以让CPU执行几千条指令,但uart_print_string等函数的策略都是等待UART把数据发送完成才返回,是阻塞的;
  4. uart_scan_string等函数要求程序乖乖地等待总线上的数据到来,不能错过,这使程序不能在等待的同时做其他事;
  5. 以上两点相结合更让人尴尬——在发送的同时接收到的数据会被错过,怎么还能叫全双工总线呢?

这输入和输出两方面的问题可以用一种高度对称的手段来解决,它就是缓冲区。缓冲区是这样一种结构,它存放着一串字符,来自于程序的输出或UART的接收,并可以按顺序取出,用于UART的发送或程序的输入。显然,这需要用到中断:在RX中断中,向缓冲区中放入接收到的数据;在UDRE中断中,如果缓冲区中有数据,则取出并发送之。

于是,当程序需要输入时,可以从缓冲区中取一些字符,并解析成整数等类型,如果缓冲区为空,则等待输入,与C语言标准输入scanf很类似;当程序需要输出时,可以直接把字符串写到缓冲区中,让中断来逐字节发送,而主程序可以无需等待,直接继续工作,这种输出是异步的。这个“异步”与UART总线的“异步”是不同的概念。关于阻塞、异步等概念,可参考:怎样理解阻塞非阻塞与同步异步的区别?

但是现在“缓冲区”还只是一个抽象概念,我们要把它落实成代码。如何实现一个缓冲区呢?

我们先把缓冲区想象成一个管道,有头和尾两端,我们需要从尾部放入球,从头部取出。这种数据结构称为队列

队列可以用链表来实现,好处是队列的长度没有限制,除非内存耗尽。但是在我们的应用场景中,链表节点中有效的数据是一个字节,却还需要两个字节来存放一个指针,不太划算。并且,malloc函数是比较耗时的,应避免频繁调用。

我们使用一种叫作“循环队列”的实现。循环队列是一个数组,保存两个下标,分别指向头和尾(由于我主要写C++,我习惯用尾后)。循环体现在,假如队列的大小是64,那么下标为63的元素的后一个就是下标为0的元素。如果把普通数组想象成一个矩形,那么循环队列就是一个圆环。

初始时,头和尾下标相同。向尾部放入一个字节,就是在尾下标处写数据,并让尾下标指向下一个元素;取出一个字节,就是读取头下标处的数据,并让头下标指向下一个元素。当两个下标相等时,队列为空;当尾的后一个等于头时,队列满——可是明明这时只放了63个元素,为什么不再放一个呢?因为会与队列空的情况冲突,无法分辨,为了省事,还是浪费一个字节吧。

下面这段代码需要你认真阅读并理解,但是请先忽略volatileATOMIC_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)这一句为例,这句语句通常由主程序执行。

  1. 假设执行到这一句前时uart_tx_head41uart_tx_tail42,即缓冲区中还有1字节没有发送。
  2. 程序读取uart_tx_head,其值为41
  3. 在读取uart_tx_tail之前,USART0_UDRE_vect中断触发了,在中断中最后一个字节被发送,uart_tx_head被修改为42UDRIE0被写0,关掉了这个中断,随后中断退出。
  4. 程序读取uart_tx_tail,其值为42,两者不相等,UDRIE0不会被写1,中断保持关闭状态。
  5. 缓冲区中被写了一个字节,uart_tx_tail变为43。缓冲区明明非空,UDRE中断却没有开,这个字节无法发送。

这样分析很累,我写的时候并没有认真分析不加原子操作可能带来的问题,而是遵循这样的原则:对于非中断与中断的代码共享的数据,在非中断代码中一定要加原子,在中断代码中,如果在使用这些数据时全局中断可能处于打开状态,则也需要加原子。

现在我们实现了串口输出缓冲区,输入缓冲区的原理类似,留作作业。我们还需要关注几个问题:

  1. 串口输出是连续的字符流。“连续”是指不存在发送几个字节,停顿一下,再继续发送的情况;“字符流”是指发送的数据都是字符。在字符流的假设下,如果需要可以断开的输出,可以通过用\0标记断点来实现。但是对于字节流,即数据本身就可能包含\0的情形下,如何标记断点呢?作业4在缓冲区的基础之上增加了这样的需求。
  2. 以上代码对于在缓冲区满时插入字符的策略是等待直到缓冲区有空位,虽然一定能等到,保证数据被发送,但可能需要等待很长时间。比如,在缓冲区满时发送一个较长的字符串,插入每一字节时都需要等待一个字节被发送的时间,总体上与同步发送无异。这里提供几种方案:用一种结构来标记是否发生了错误,以及发生何种错误;给发送函数添加返回值,指示是否发送成功;使用动态缓冲区,当缓冲区满时新开辟一块空间存放。不过,还是要根据应用选择最合适的。

多路发送

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\)

这样我们就可以画出原理图:

左边两个是或非门,右边是与非门,分别位于开发板左上角标注NORNAND处。每个门有两个输入和一个输出,对应ABO三个引脚,AB是可以对调的。

然后就要根据原理图搭建电路。也许你对这张并不复杂的电路图毫无头绪。的确,面包板上看似简单的电路也可能很复杂,不过还是有规则可以遵循的:

  1. 每一条杜邦线有两端,是黑色胶壳加上一根针或者没有针。有针的称为“公”,没有的称为“母”(我真没开车)。
  2. 板上的排针连接母头,面包板连接公头。
  3. 杜邦线有3种:公对公、公对母、母对母。
  4. 面包板上,一行5个孔是连接起来的。
  5. 各个引脚可以划分为若干不相交集合,相同集合内的引脚有导线连接,不同集合内的引脚没有引脚连接。每个集合称为一个net。
  6. 对于只有一个引脚的net,不管它。
  7. 对于有两个引脚的net,选用合适的杜邦线把两个引脚直接连接。
  8. 对于有至少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转串口工具上,分别可以看到奇数和偶数的输出。

作业

  1. 为什么通常使用UDRE而不是TX?什么时候不能使用UDRE而只能选择TX?
  2. 使用中断与缓冲区改写“串口接收”一讲中的例程。
  3. 如何使用74HC138来扩展UART输出?
  4. 实现一个两个发送通道共用的缓冲区(注意第1题)。

原文地址:https://www.cnblogs.com/jerry-fuyi/p/12222738.html

时间: 2024-08-02 20:01:50

AVR单片机教程——UART进阶的相关文章

AVR单片机教程——定时器中断

本文隶属于AVR单片机教程系列. ? 中断,是单片机的精华. 中断基础 当一个事件发生时,CPU会停止当前执行的代码,转而处理这个事件,这就是一个中断.触发中断的事件成为中断源,处理事件的函数称为中断服务程序(ISR). 中断在单片机开发中有着举足轻重的地位--没有中断,很多功能就无法实现.比如,在程序干别的事时接受UART总线上的输入,而uart_scan_char等函数只会接收调用该函数后的输入,先前的则会被忽略.利用中断,我们可以在每次接受到一个字节输入时把数据存放到缓冲区中,程序可以从缓

AVR单片机教程——EasyElectronics Library v2.0手册

本文隶属于AVR单片机教程系列. ? adc.h bit.h button.h buzzer.h dac.h delay.h ee.h exin.h exout.h lcd.h ldr.h led.h pin.h pot.h print.h pwm.h rgbw.h rotary.h segment.h switch.h timer.h tone.h uart.h wave.h ? 主要更新: 由于修改了一些接口,与之前版本不完全兼容,主版本号更新为2: 正式支持中断,初步使用回调: UART支

AVR单片机教程——矩阵键盘

本文隶属于AVR单片机教程系列. ? 开发板上有4个按键,我们可以把每一个按键连接到一个单片机引脚上,来实现按键状态的检测.但是常见的键盘有104键,是每一个键分别连接到一个引脚上的吗?我没有考证过,但我们确实有节省引脚的方法. 矩阵键盘 这是一个4*4的矩阵键盘,共有16个按键只需要8个引脚就可以驱动.我们先来看看它的原理. 每个按键有两个引脚,当按键按下时接通.每一行的一个引脚接在一起,分别连接到左边4个端口,称为"行引脚":每一列的另一个引脚接在一起,分别连接到右边的4个端口,称

AVR单片机教程——数字输入

我们已经学习了如何使用按键和拨动开关,不知你有没有好奇 button_down 和 switch_status 等函数是如何实现的.本篇教程带你一探究竟,让我们从按键的原理开始. 在原理图中,按键的符号如下图所示: 符号很简单,就是两个触点上方有一个动片,当按下时与两个触点接触.实际上按键内部的机械结构大体上就是这样,实现的功能是,没有按下时两端断路,按下时两端短路. 还有一种画法是这样的,即电键: 就按键内部的机械结构来说,第一种更加真实,但从电路角度来看,两者没什么区别. 但是我们的开发板上

AVR单片机教程——串口发送

到目前为止,我们的开发板只能处理很小量的数据:读取几个引脚电平,输出几个LED,顶多用数码管显示一个两位数字.至于输入一个指令.输出一条调试信息,甚至用scanf和printf来输入输出,在已经接触过的这些器件上是难以想象的.而本讲"串口发送"与下一讲"串口接收",将打开这一扇大门. 硬件 本讲的主题是UART(Universal Asynchronous Receiver-Transmitter,通用异步收发器),俗称串口.实际上串口是串行接口的统称,在单片机领域

AVR单片机教程——PWM调光

PWM 两位数码管的驱动方式是动态扫描,每一位都只有50%的时间是亮的,我们称这个数值为其占空比.让引脚输出高电平点亮LED,占空比就是100%. 在驱动数码管时,我们迫不得已使占空比为50%,因为不能让两位真正同时地显示不同的数字.但是,我们也可以有意地让LED的占空比不到100%,以降低其亮度. 占空比是可以用程序来调节的.下面的程序允许用户用按键调整蓝色LED的占空比,并通过数码管来显示. #include <ee1/ee.h> #define DUTY_MAX 9 int main()

AVR单片机教程——串口接收

上一讲中,我们实现了单片机开发板向电脑传输数据.在这一讲中,我们将通过电脑向单片机发送指令,让单片机根据指令控制LED.这一次,两端的TX与RX需要交叉连接,单片机TX连接串口工具RX也是需要的,因为程序会根据指令反馈信息. 为了简单起见,我们的程序只需要控制4个板载LED.指令包含两个字节:第一个字节为r.y.g.b中的一个,分别表示红.黄.绿.蓝灯:第二个字节为0或1,表示灯不亮或亮. 然而,a2这样的指令是没有意义的,却是可能出现的.即使用户已经熟悉了这8条指令,也可能会不小心打错.我们应

AVR单片机教程——闪烁LED

上次我们把LED点亮了.你可能已经试过把 LED_RED 换成其他灯,也可能已经用 led_on() 把所有LED一起点亮了.但是LED点亮以后,程序就退出了,之后LED一直没有暗,直到没有供电.这一次,我们用程序来控制LED的亮和暗. 新建一个C executable项目,选择ATmega324PA单片机,在项目属性中添加库libee1,将配置改为Release.这是本教程现阶段中每一次新建项目都要做的.我刚才尝试用project template简化,然而设置无法导入. 默认生成的main.

AVR单片机教程——数码管

先解答之前一个思考题:如果不把引脚配置为输出而写高电平,连接LED会怎样? 实验结果是,LED会亮,但相比于输出高电平的情况,亮度很低.这是为什么呢? 通过上一篇教程我们知道,引脚输入输出模式是由寄存器DDRx中DDxn位控制的,可以推断出 pin_mode 函数会改变一个引脚对应的DDxn值,输入为0,输出为1,而其复位后的值为0,即输入,因此如果不把引脚配置为输出,它的模式就是输入.类似地 pin_write 函数会改变PORTxn,其值为函数的第二个参数. 所以不配置输出而写高电平的结果就