note/avr

AVR

放假了,我可以好好整理一下之前自学的内容了。

AVR 是 Atmel 生产的一种 RISC 架构的 8 位的微控制器,型号的命名方法为 {ATmega,ATtiny} + 数字 + (P) + 封装类型 -{PU,AU,MU}

Atmel 公司后来被(制造 PIC 的)Microchip 公司收购了,于是很多 AVR 的资源的链接都改变了,请留意。

AVR 各个型号的寄存器的名称与功能有较大的不同。我学习 AVR 时主要采用的芯片为 ATmega48P/ATmega88P/ATmega168P/ATmega328P,这几款芯片十分类似,能在 ATmega48P 上跑的程序基本上都能在其他几款上跑。他们的差别基本上只体现在闪存(flash)容量上。由于他们太过类似,下文提及他们时我将简称为 atm4n8

其中 ATmega328P 是 Arduino UNO/Nano/Pro Mini(不是 Pro Micro)的主控制器,如果手上没有这几块芯片的话可以用这些 Arduino 代替。

资源

ATmega328P 产品主页: https://www.microchip.com/wwwproducts/en/ATMEGA328P

atm4n8 Data Sheet: http://ww1.microchip.com/downloads/en/DeviceDoc/ATmega48A-PA-88A-PA-168A-PA-328-P-DS-DS40002061A.pdf

AVR Libc 参考手册: https://www.microchip.com/webdoc/AVRLibcReferenceManual/

我当时写的库: https://github.com/chuangzhu/avr (代码水平较差请见谅)

硬件

AVR 的内设十分丰富。以 atm4n8 为例,片内自带震荡电路,不需要任何外置电路,接上电源就可以使用。不过为了时钟的精准我们常常还是会接上一个外置的晶振,晶振两端通过两个大小为 12pF~22pF 的电容分别接地。

AVR 使用 ISP 作为程序下载接口,ISP 和 SPI 是同一组引脚(MOSI MISO SCK)。除了下载程序,通过 ISP 接口还可以设置熔丝位。芯片内有几组叫熔丝位(fuse)的东西,用来配置使用内部振荡器还是外部晶振,并指定晶振频率的范围,是否启用编程接口,是否锁死 AVR 的 flash、禁止读写等属性。熔丝位配置错误可能会锁死 AVR,配置之前要弄清楚每个位的含义。

AVR 工作的电压区间在 1.8V~5.5V 内。5V 下 AVR 可以使用最大 16MHz 的时钟频率;3.3V 下可使用最大 8MHz。芯片内置一个 8MHz 的振荡器和一个 128kHz 的振荡器,默认使用的是 8MHz 的振荡器。

开发环境

Linux 或 macOS

安装 avr-binutils(也可能叫 binutils-avr),avr-gcc(也可能叫 gcc-avr),avr-gdb(也可能叫 gdb-avr),avr-libcavrdudemake

一个标准的 Makefile 大概是这样的:https://gist.github.com/chuangzhu/9a25cdfbf59a5f9abe13e41b81ef7771

假设我们要将 lib.c lib.h main.c 编译为一个 program.hex 文件并烧录,以下列出这些在 shell 中具体是如何操作的,如果需要请自行转写为 Makefile,供参考。

编译

因为有些参数下面的命令都要用,我们先在 shell 里面定义一个数组:

CFLAGS=(-Wall -Os -DF_CPU=<时钟频率> -mmcu=<芯片型号>)

首先把 C 语言编译为汇编:

avr-gcc ${CFLAGS[@]} -S main.c -o main.S
avr-gcc ${CFLAGS[@]} -S lib.c -o lib.S

然后汇编为对象文件:

avr-gcc ${CFLAGS[@]} -c main.S -o main.o
avr-gcc ${CFLAGS[@]} -c lib.S -o lib.o

再把对象文件们链接起来,形成一个 .elf 文件:

avr-gcc ${CFLAGS[@]} main.o lib.o -o program.elf

最后把二进制的 .elf 转换为十六进制的 Intel hex 文件(其实这个文件还是以二进制储存的啦,只是说这个文件用十六进制数的 ASCII 码将程序表示出来而已),这样就可以用来烧录了。

avr-objcopy -j .text -j .data -O ihex program.elf program.hex

烧录

这里只举我用的下载器,AVRASP,的例子。AVRDUDE 还支持很多下载器,可用 avrdude -c \? 查看。

AVRDUDE=(avrdude -c avrasp -p <芯片型号>)
sudo ${AVRDUDE[@]} -e  # 擦除
sudo ${AVRDUDE[@]} program.hex  # 写入

或者:

sudo ${AVRDUDE[@]} -U flash:w:main.hex:i

如果他提示什么 signature 有问题,那大概是你 -p 后面的芯片型号指定错了。

烧录的时候要使用 root 权限。

Windows

编译:

  • AVRStudio + WinAVR

    AVRStudio 为早期的 AVR IDE,只支持汇编。WinAVR 为第三方的 Windows 上的 avr-gccavr-libc 移植,安装后可使 AVRStudio 支持 C 语言。

  • Atmel Studio

    后来 Atmel 搞了个 Atmel Studio,不仅用于 AVR 开发,也用于 Atmel 32 位的 ARM 开发。本质上是给 avr-gccavr-libc 套了个 Visual Studio 的外壳。在 Build 文件夹中你甚至可以看见 Atmel Studio 生成的 Makefile。并且添加了很多多余的内容以确保编译速度缓慢。

烧录:可使用 AVRDUDESS 或 PROGISP。

C 语言

AVR GCC 的 C 语言和 GCC 的 C 语言完全一致,区别只在库上。这里要提的是在普通环境中常常被忽视,但是在嵌入式环境中比较重要的几个概念。

常量表达式

C 语言的常数可以用八进制、十进制、十六进制表示(无法用二进制表示)。十进制数直接表示,十六进制数以 0x 开头,八进制数以 0 开头。

十六进制二进制对应表
十六进制二进制
00000
10001
20010
30011
40100
50101
60110
70111
81000
91001
A1010
B1011
C1100
D1101
E1110
F1111

因为一位十六进制数正好对应四位二进制数,这样要表示任意的二进制数只需将每四位二进制数对应的十六进制数拼在一起。如 0010 1101 就等于 0x2D

各种类型在内存中是如何储存的?

变量声明时,类型前可以加修饰词 signedunsigned 来指定这个变量可不可以是负数。没有声明时,CPU 会在运行时判断这个变量是有符号的还是没符号的。这在普通环境下体会不出来差别,但嵌入式环境下 CPU 运算能力较弱,写程序时最好要加上这个修饰词。—— 书上是这么说的,不过我对这点还有些疑问,到时候再研究研究吧。

char 即 character,意思是字符。它占 8 位,可取的值有 256 个。而 ASCII 码一共 128 个字符,一个 ASCII 码表中的字符可以用一个 char 来表示,这就是 char 名称的来源。无符号的 char 取值范围为 0~255,有符号的 char 取值范围为 -128~127。

int 即 interger,意思是整型。在 32 位机器上运行 32 位程序时它占 32 位内存、在 64 位机器上运行 64 位程序时它占 64 位内存。但在 AVR GCC 中是个例外,AVR 虽然是 8 位机器,但是 int 占 16 位,可取的值有 65536 个。无符号的 int 取值范围为 0~65535,有符号的 int 取值范围为 -32768~32767。

有符号的变量使用最高的那位表示符号。以 char 来例,当符号为负数时,最高位为 1,剩下 7 位为它的相反数取反加一。或者说,一个数的相反数运算 a = -a 实质是这个数取反加一 a = ~a + 1

指针 / 地址

指针不仅可以指向内存,还可以指向寄存器。换句话说,寄存器也有自己的地址。如果你查看 avr/iom328p.h 头文件,你会发现 PORTB 的宏定义是这么定义的:

#define __SFR_OFFSET 0x20
#define _MMIO_BYTE(mem_addr) (*(volatile uint8_t *)(mem_addr))
#define _SFR_IO8(io_addr) _MMIO_BYTE((io_addr) + __SFR_OFFSET)
#define PORTB _SFR_IO8(0x05)

根据这个页面,ATMega328P 为 avr5,__AVR_ARCH__ = 5)即:

#define PORTB (*(volatile uint8_t *)(0x25))

也就是说,PORTB = 0xFF 其实是:

*(volatile uint8_t *)(0x25) = 0xFF

这里我们注意到这个操作是取了一个常量地址 0x25 的值,然后把 0xFF 写到这个值里。在标准 C 语言里你不会见到这种对常量地址取值的操作,这个操作是不安全的、会被操作系统屏蔽。你只能见到取一个变量的地址赋值给一个指针、然后对指针指向的变量赋值的操作,这个过程不涉及常量。而微控制器只运行这一个程序,没有操作系统,因此我们可以这样做。

GPIO

首先 include IO 的头文件:

#include <avr/io.h>

AVR 的 I/O 引脚每 8 个为一组,每组以 ABCD 标上,命名为 PA0...PA7、 PB0...PB7 这样。

每组都由三个寄存器控制,以 PA0...PA7 为例:

  • 一个叫 DDRA,用于控制 PA 的每个引脚是输入还是输出。某位为 1 时则该位对应的引脚则为输出,反之则为输入。

  • 另一个叫 PORTA

    当某个引脚在 DDRA 中设置为输出时,在 PORTA 中设置该位为 1 则该引脚为高电平,反之为低电平。

    当某个引脚在 DDRA 中设置为输入时,可以通过设置 PORTA 使引脚成为高阻态或内部上拉(具体怎么做我再翻翻 Data Sheet ......)

  • 还有一个叫 PINA,这是一个只读寄存器。某个引脚在 DDRA 中设置为输入时你可以从这个引脚所在的寄存器里读出它是高电平还是低电平。

寄存器

这里再讲讲寄存器是什么。在 C 语言中你这样写:

#include <avr/io.h>
int main() {
    DDRB = 0xFF;
    PORTB = 0xEE
}

打开你的计算器,选择程序员模式。选择十六进制(hex),输入 FF,再选择二进制(bin),你会看见计算器显示 1111 1111,设置 DDRB 为这个值也就是所有 PB 引脚都设置为输出。像刚才那样,这次输入 EE,你会在二进制界面中看到:

1110 1110

这代表了把 PB0、4 设置为低电平,把 PB1、2、3、5、6、7 设置为高电平。

将上面的程序编译后写入微控制器,在 PB0...7 上接入一个个 LED,你是不是看到了这样子的图案?

(下面 LED 的标号错了,请不要在意)

位操作

设置寄存器涉及大量的位操作,这里把常见的位操作列出来:

置位:

#define set(reg, bit) reg |= (1 << bit)

清零:

#define clr(reg, bit) reg &= ~(1 << bit)

取反:

#define not(reg, bit) reg ^= (1 << bit)

模数转换器(ADC)

#include <avr/io.h>

AVR 内置了 10 位精度的模数转换器,也就是 Arduino 中所谓的 analogRead()

我们来看看 Data Sheet 中的这张框图(版权属于 Microchip Technology Inc.):

ADCSRA 最高位 ADEN 用于控制是否启用 ADC;还有几位叫 ADPS0...2,用于控制 ADC 时钟分频数,我忘记为什么要分频了,总之我以前的程序里设置的是 64 分频。

atm4n8 的 PC0...PC7 都可以作为 ADC 引脚使用。八个引脚接在一个 ADC 上,通过 ADMUX 低三位控制,每次只能读取一个引脚的电压。除此之外 ADMUX 还控制参考电压以及数据在 ADCLADCH 中的对齐方式(一般设置为右对齐就好了)。

最后读取的值有 10 位,也就是取值范围 0~1023,低 8 位在 ADCL 中,高 2 位在 ADCH 中。

计时器

atm4n8 有 3 个计时器。两个 8 位计时器 TIMER0 TIMER2 和一个 16 位计时器 TIMER1。计时器不直接使用系统时钟,而是分频后使用。分频器是用来降低频率、增大周期的,每个时钟的分频器都是独立的。

计时器的原理是每隔一个分频后的周期将计时寄存器 TCNTn 中的数值加一,TCNTn 增到最大后将引发 TIMERn_OVF 中断。在中断函数中你可以进行一些操作,从而起到计时的作用。

我们用公式来捋一下思路:

中断周期=1系统时钟频率×分频数×(TCNTn最大值TCNTn初始值)\text{中断周期} = \frac{1}{\text{系统时钟频率}} \times \text{分频数} \times (\text{TCNTn最大值} - \text{TCNTn初始值})

TIMER0 和 TIMER2 的计数寄存器能计最多 256 个数,假设晶振频率是 8MHz,即使使用了最大的分频数最多也只能计 32.768 毫秒。而 TIMER1 的计数寄存器最多可计 65536 个数, 8MHz 下最长可计时 8.388608 秒。

TCCRnB 寄存器的 CSn2...0 位指定了计时器的分频数,可设置为 1, 8, 64, 256, 1024 分频。如果晶振频率是 8MHz,分 8 频,则 TCNTn 自增 1 的频率就是 1MHz,也就是每 1μs 自增 1

TIMSKn 为 TIMERn 的中断屏蔽寄存器,置位 TIMSKnTOIEn 位来开启 TIMERn 的中断。

PWM

微控制器只能输出数字信号,GPIO 输出的电平不是低电平就是高电平。但是我们的世界不是非黑即白的,我们有时候需要控制 LED 的亮度、电机的转速。为了使输出具有这样的 “灰度”,人们创造了 PWM。将一盏灯一半时间开、一半时间关,如果我们开关的频率很高,由于荧光粉能维持一段时间继续发光以及大脑的视觉暂留效应,我们看到的灯就在 50% 的亮度。如果我们能够精确地控制占空比,那么我们就能用数字信号模拟出模拟信号。

AVR 内部封装了 PWM,只要设置一些寄存器即可开启 PWM。不用手动写中断函数控制 GPIO。Data sheet 引脚定义页面中标有 OCnX (n 为一个数字,X 为一个字母)的引脚即为支持 PWM 的引脚。

AVR 内置的 PWM 有好几种模式,CTC、相位修正 PWM 和快速 PWM 模式。对于要求不是很严格的应用,使用快速 PWM 模式就可以了。

快速 PWM 的原理是 TCNTn 随计时器不断自增,达到 OCRnX 的值时则将 OCnX 输出低电平,达到最大值时即将 OCnX 输出高电平(当然这说的是正常模式,反转模式则相反)。

应该设置这几个寄存器:

  • DDRD 这是当然的了,PWM 引脚必须是输出。

  • TCCRnA

    76543210
    COMnA1COMnA0COMnB1COMnB0--WGMn1WGMn0

    COMnX1..0 快速 PWM 模式中,10 为正常模式,11 为反转模式;00 为不使能 PWM;01 是一个不太常用的功能,不要用。

    WGMn1..0 指定 PWM 模式,11 为快速 PWM 模式。

  • TCCRnB 指定分频数。我的代码中为 256 分频,CSn2..0 为 100

  • OCRnX 即为用于比较的值。正常模式下 OCRnX 越大,OCnX 的占空比就越大,LED 就越亮。反转模式则相反。

协议们

USART / UART / 串口

USART 通讯协议使用两根线进行通讯,TXDRXDTXD 用于发送信息,RXD 用于接受信息。TXDRXD 是对于自身而言的,微控制器 A 的 TXD 要接在微控制器 B 的 RXD 上,RXD 要接在另一个的 TXD 上。对于 atm4n8 来说,RXDPD0TXDPD1

还有一个同步和异步问题。这里的同步和异步不是指 JavaScript 中的那种同步和异步,而是指交流中的时钟信号是用一台机产生的还是两台机各自产生一个时钟信号。同步方式下还需要第三根线,将一台机产生的时钟信号与另一台机相连。我只研究了异步方式。

USART 不仅可以在微控制器之间通讯,还可以连上计算机的串口与计算机通讯。计算机的串口也就是那个矩形的 VGA 接口。需要注意的是,计算机串口的电平和 USART 的电平是不同的,需要一个电平转换器(如 MAX232)转换一下;现在很多计算机已经不带串口了,但是 USART 仍然是最常用的微控制器与计算机的通讯方法,可以使用 USBTTL 将 USB 接口转换为串口。

USART 传输的内容是数。多数监听串口的软件支持两种显示串口数据的方式:用将串口上的数据每 8 位看作一个 byte,显示每 byte 在 ASCII 码中对应的字符;用 16 进制将串口上的数显示出来。

有的 AVR 有好几组 USART 引脚,所以寄存器名称中带了 0, 1, 2, 3 来区分。请将下面出现的小写 n 换成对应的序数。atm4n8 只有一组 USART 引脚,下面的 n 换成 0 就可以了。

波特率(BAUD):传输的速度,即每秒传输的二进制位数(bps)。发送和接收双方的波特率要一致。波特率可以通过寄存器 UBRRnHUBRRnL 联合设定。不同的型号的 UBRR 计算公式不同,请翻 Data Sheet;就拿 atm4n8 来说,在异步模式下,它的计算公式如下:

UBRRn=fOSC16×BAUD1UBRRn = \frac{f_{OSC}}{16 \times BAUD} - 1

(fOSC 就是晶振的频率)那么我们的程序就可以这么写:

UBBR0H = (F_CPU / 16 / BAUD - 1) / 0xFF  // 取高 8 位
UBBR0L = (F_CPU / 16 / BAUD - 1) % 0xFF  // 取低 8 位

UCSRnB 控制使能发送 TXENn /接收 RXEN,使能接收中断 RXCIEn 等。

UCSRnC 控制一次发送几位,要发送字符串的话设置成 8 位就好了。

发送

AVR 片内对 USART 的封装层次较高。前面几个寄存器设置好后,放在寄存器 UDRn 中的字节会自动通过 TXD 发送出去。不过需要注意的是,USART 作为一种 I/O,其发送的速度一定是比不上 CPU 的处理速度的,你得让 CPU 等这个字节发送完后再往寄存器里放下一个字节。这可以通过读取寄存器 UCSRnAUDREn 位得知。

void usartByte(unsigned char x) {
    while (!(UCSR0A & (1 << UDRE0)));
    UDR0 = x;
}
#include <string.h>
void usartString(const char* data) {
    unsigned int length = strlen(data)
    while (length --) {
        usartByte(*data);
        data ++;
    }
}

TWI / I2C / IIC

I2C 可以同时连接多个设备,非常适合传感器使用,许多传感器都使用 I2C 通讯。I2C 有两根线,SDA 用于通讯,SCL 用于时钟。

I2C 可以同时连接多个设备,通讯中分为主机和从机,一个时间内只能有一个机器作为主机。为了区分多个从机,每个从机都有一个地址,这个地址可以在传感器的 Data Sheet 里找到。每个从机内可以提供多个值,每个值也有个地址,称为寄存器地址。有两种交流方式,一个称为 write,一个称为 read。

Write: 主机在总线上声明要写入的从机地址和寄存器地址,然后说出数据内容。对应的从机听到了,把数据写入对应的寄存器里。

Read: 主机在总线上声明要读取的从机地址和寄存器地址。对应的从机听到了,将对应寄存器中的数据说出来。

其中还有几个信号是需要注意的,START, STOP 信号;从机告诉主机自己已接收数据的 ACK 信号;多字节读取时主机每读取一个字节都要回应一个 ACK 信号,主机表示读取完毕时回复的 NACK 信号。

主机

#include <util/twi.h>

这里我不想讲各个寄存器的各位是什么意思了,我直接将发送、接收各个信号的代码贴出来:

  • 设置 TWI 比特率:

    计算公式:

    TWBR=fCPU2×fSCL×PrescalerValue16TWBR = \frac{f_{CPU}}{2 \times f_{SCL} \times PrescalerValue} - 16

    Prescaler 的值可通过 TWSR 设置。

    TWBR = 115;
  • 发送 START 信号:

    void twiStart(void) {
        PORTC |= (1<<4) | (1<<5);  // SDA & SCL 高电平
        TWCR = (1<<TWINT) | (1<<TWSTA) | (1<<TWEN);
        twiWaitAck();
    }
  • 发送 STOP 信号:

    void twiStop() {
        TWCR = (1<<TWINT) | (1<<TWSTO) | (1<<TWEN);
    }
  • 等待 ACK 信号或等待 START/STOP 信号发送完成:

    void twiWaitAck() {
        while (!(TWCR & (1<<TWINT)));
    }
  • 发送 ACK 信号:

    void twiSendAck() {
        TWCR |= 1 << TWEA;
    }
  • 发送 NACK 信号:

    void twiSendNack() {
        TWCR &= ~(1<<TWEA);
    }
  • 发送一个字节:

    void twiSendByte(unsigned char data) {
        TWDR = data;
        TWCR = (1<<TWINT) | (1<<TWEN);
    }
  • 接收一个字节:

    void twiRecvByte() {
        TWCR = (1<<TWINT) | (1<<TWEA) | (1<<TWEN);
        twiWaitAck();
        return TWDR;
    }
  • 指定从机地址:

    twiSendByte(slaveAddr|TW_WRITE);  // 方向 write
    twiSendByte(slaveAddr|TW_READ);  // 方向 read

将这些信号按上面的时序图组合一下就可以了。

SPI

(Work In Progress)

中断

About Me