单片机里的通信协议其实蛮多的,IIC;SPI;MQTT;CAN;包括串口也是一种通信协议。而串口通信虽然实现了全双工,但需要至少三根线,为了节省这一根线的成本,于是IIC诞生了。
目录
一.IIC协议
IIC协议其实就是一种标准外设协议,其实所谓协议,本质上就是各种时序电路的组合。IIC也不例外,它的最大特点就是特别的轻量级。
1.IIC的结构
IIC的简便和轻量级就在于它只有两条线,一条是时钟线SCL,一条是数据线SDA。说白了,就是在SCL的控制下在SDA上传输命令/数据。
在这张图上可以看到同时有很多设备连接在这两条线上,它们之间的关系一般是一主多从。
2.IIC的特点
1.IIC是半双工通信,因为SCL不负责传输数据,只有一条SDA数据线,无法发的同时接收信息。
2.如果你使用的是STM32芯片,那么如果没有配置上拉电阻的话GPIO口必须配置为开漏输出。因为假设时序出现错误两个设备一个讲SDA拉低一个将SDA拉高,那么将导致短路。
3.同步传输:数据的传输是严格按照时钟线来进行的。
3.IIC的通信时序
IIC的通信主要依靠四个信号:起始信号,应答信号,停止信号;读写信号;
START:
STOP:
ASK:
读写:
其中ASK就是在在吧SCL拉高的期间读取SDA的的电平。读取完成后立即拉低。读取到的SDA假设为1则表示有应答,若为0则表示没有应答。
读写信号则是在起始信号后将SCL拉低的期间向SDA上放或者读取数据。
START和STOP比较简单不多赘述。
4.具体配置(32HAL库版)
头文件:
#ifndef __IIC_H__
#define __IIC_H__
#include "sys.h"
#define SDA_PULL_UP() HAL_GPIO_WritePin(GPIO_POTT, SDA, GPIO_PIN_SET)
#define SDA_PULL_DOWN() HAL_GPIO_WritePin(GPIO_POTT, SDA, GPIO_PIN_RESET)
#define SCL_PULL_UP() HAL_GPIO_WritePin(GPIO_POTT, SCL, GPIO_PIN_SET)
#define SCL_PULL_DOWN() HAL_GPIO_WritePin(GPIO_POTT, SCL, GPIO_PIN_RESET)
void iic_Stop(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL);
void iic_Start(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL);
void iic_Ask(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL);
void iic_Sendbyte(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL,uint8_t DATA);
#endif
首先我这里用了大量的宏定义了很多宏函数来方便控制对应线的电平高低。另外如果你看的细致的话会发现我这里宏函数中的参数也是宏,然而你却在这儿找不到这个宏。其实它并不是宏,我用了一种稍微怪的方式来简便我在IIC定义里的工作量,但加大了一点调用的工作量。
源文件:
#include "IIC.h"
void iic_Stop(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL){
SCL_PULL_UP();
SDA_PULL_DOWN(); //先拉高SCL再拉高SDA
SDA_PULL_UP();
}
void iic_Start(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL){
SCL_PULL_UP(); //SCL高电平期间SDA下降沿
SDA_PULL_UP();
SDA_PULL_DOWN();
SCL_PULL_DOWN();
}
void iic_Ask(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL){
SCL_PULL_UP();
SCL_PULL_DOWN();
}
void iic_Sendbyte(GPIO_TypeDef *GPIO_POTT,uint16_t SDA,uint16_t SCL,uint8_t DATA){
uint8_t i,tmp = DATA;
//主机在SCL低电平期间在SDA上放数据
for(i = 0; i<8; i++){
if((tmp & 0x80) == 0x80) //DATA &= 1000 0000
SDA_PULL_UP();
else
SDA_PULL_DOWN();
SCL_PULL_UP(); //从机在SCL高电平期间读取这一位
SCL_PULL_DOWN();
tmp = tmp << 1;
}
}
想必如果你由上面的疑问看了源文件也就理解 ,我定义的宏函数里的参数并不是宏而是调用它的函数里的参数。所以我增加的调用方的工作量就是传参很多,IIC这里就非常简介易懂了。
另外一个点就是数据传输函数中是一位一位放的,不断地放不断地左移知道8次轮回完成一字节的传输则结束。
二.SPI协议
SPI的全称是Serial Peripheral Interface。有基础的人估计一眼就看见Serial(串口)了。没错,其实这东西它使用非常像串口。不仅如此,配置起来也非常像串口。
1.SPI的结构
SPI一共由四条线组成:SCK;MISO;MOSI;NSS(CS)
其中,MISO和MOSI分别是:Master Input Slave Output
这里某些同学不要想歪了哈,Master这里就是主机,Slave就是从机,所以这两根线就很好理解了,简单说就是RX和TX一样。
然而:虽然这里也是两条线也是全双工,但是SPI的传输数据方式却是非常的特殊的。
图中画圈的地方都是SPI的重点,其中特殊就在于它的位移寄存器 。
2.SPI的特点
1.SPI最大的特点就来自于它的位移寄存器,它每发送一字节的数据就必须收一字节的数据,同样的,它要收一字节的数据就必须发出一字节数据。
2.看了特点一你会认为SPI是强制全双工的,但其实不是,它完全可以配置为半双工或者只有一条数据线。
3.SPI的工作模式比较特别,它的工作模式取决于时钟极性和时钟相位
第三点展开来说:
时钟极性控制SPI数据线上没有数据时SCL的电平状态:
CPOL为0则空闲时为低电平,反之则为高电平。
CPHA为0则每一个奇数边缘采样数据(第一个),反之则为偶数边缘采样。
你要是看不懂也没关系,总之就是CPOL和CPHA排列组合一共有四种工作模式。
3.具体配置
SPI的配置一般是依靠板子上确定的外设的,因为它不像IIC那样的轻量级。它有很多的东西需要配,比如:分频数;时钟启动;工作模式;是否半双工等等。
#include "SPI.h"
SPI_HandleTypeDef spi_handle = {0};
void SPI_INIT(void){
spi_handle.Instance = SPI1;
spi_handle.Init.Mode = SPI_MODE_MASTER; //配置主从模式
spi_handle.Init.Direction = SPI_DIRECTION_2LINES; //半双工全双工选择
spi_handle.Init.DataSize = SPI_DATASIZE_8BIT;
spi_handle.Init.CLKPolarity = SPI_POLARITY_LOW; //低电平有效
spi_handle.Init.CLKPhase = SPI_PHASE_1EDGE; //奇数取值
spi_handle.Init.NSS = SPI_NSS_SOFT; //软件调控NSS
spi_handle.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_256; //分频数选择
spi_handle.Init.FirstBit = SPI_FIRSTBIT_MSB;
spi_handle.Init.TIMode = SPI_TIMODE_DISABLE;
spi_handle.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
spi_handle.Init.CRCPolynomial = 7;
HAL_SPI_Init(&spi_handle);
}
void HAL_SPI_MspInit(SPI_HandleTypeDef *spi_handle){
if(spi_handle->Instance == SPI1)
{
GPIO_InitTypeDef gpio_initstruct;
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SPI1_CLK_ENABLE();
gpio_initstruct.Pin = GPIO_PIN_4;
gpio_initstruct.Mode = GPIO_MODE_OUTPUT_PP;
gpio_initstruct.Pull = GPIO_PULLUP;
gpio_initstruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &gpio_initstruct);
gpio_initstruct.Pin = GPIO_PIN_5 | GPIO_PIN_7;
gpio_initstruct.Mode = GPIO_MODE_AF_PP;
HAL_GPIO_Init(GPIOA, &gpio_initstruct);
gpio_initstruct.Pin = GPIO_PIN_6;
gpio_initstruct.Mode = GPIO_MODE_INPUT;
HAL_GPIO_Init(GPIOA, &gpio_initstruct);
}
}
uint8_t SPI_swap_byte(uint8_t data){
uint8_t rev_data = 0;
HAL_SPI_TransmitReceive(&spi_handle,&data,&rev_data,1,1000);
return rev_data;
}
这里对于Instance的配置还是挺繁琐的,但是也只是繁琐并没有难度。剩下的就是时钟;GPIO配置;然后可以看到在发送的部分它的函数叫HAL_SPI_TransmitReceive,对应上了我们说的接收的同时必须发送。
三:联合配置
两个协议都学会了,如何应用呢?IIC的入门外设还是非常经典的--OLED屏幕。那SPI呢?可以采用一块存储块进行配合读写。我这里就用W25Q128的存储模块。
1.OLED屏幕的配置
Oled的配置核心其实就是显示东西嘛,所以其实总结成一句话就是:
告诉屏幕在哪里显示什么东西。
OLED是只可以选择亮或者灭的,所以显示什么东西其实最终说白了是各种点阵,这个东西其实没啥技术含量。另外OLED模块的初始化也是不需要学的,你就照着把一堆命令直接复制过来用就行了。所以主要的配置重点在于:如何告诉他。
那么这个时候我们就需要读一下OLED的手册了。
OLED的数据格式
以上为手册的原图,可以看到它的发送全部都是 Control byte + Data byte。那么这是什么意思呢?
说白了,就是Control byte 用来让Oled判断接下来所接收的数据到底是命令还是显示数据。
可以看到,整个数据帧的格式就是:
Start信号--写入数据模式--ASK--Control byte--Data byte--ASK--Stop信号
那么此时的问题就变成了写入模式是什么?以及Control byte是什么?
Control byte看右下角,D/C的后一位写0则表示接下来的数据是Command,写1则表示接下来的数据是Data。
其实自己看手册可绝望了,不信? 给:
OLED的写入模式
一共有四种:
总而言之呢,其实你只记得住第一个就行,因为我们用哪一个都ok,第一个在不配置的情况下是被默认选定的。只需要知道它在写完后会自动向右偏移一位。当写道最右边后会返回来这一行的最左边。那么这里就需要稍微知道一下屏幕的大小了:
整个屏幕是128*64的,每一个字节的八位是竖着排列的,逻辑为1的就亮为0就灭。
64 / 8 = 8,这也是为什么是8个page。
Oled的命令
Oled其实内置的是一块芯片,所以它的命令其实还是蛮多的。但是可以看到这里我给你截下来的命令都是关于位置设定和写入模式的设定的。
其实这里主要的就是Page和Column 的设定,Page的很简单B0~B7分别表示Page0到Page7.
主要就是Column需要给两次,因为有 128位嘛,所以需要两个字节。这就在编程的方面稍微有点小麻烦。
代码:
#include "oled.h"
#include "delay.h"
#include "front.h"
void OLED_INIT(){
GPIO_INIT();
delay_ms(100);
Oled_Write_Cmd(0xAE);
Oled_Write_Cmd(0xD5);
Oled_Write_Cmd(0x80);
Oled_Write_Cmd(0xA8);
Oled_Write_Cmd(0x3F);
Oled_Write_Cmd(0xD3);
Oled_Write_Cmd(0x00);
Oled_Write_Cmd(0x40);
Oled_Write_Cmd(0xA1);
Oled_Write_Cmd(0xC8);
Oled_Write_Cmd(0xDA);
Oled_Write_Cmd(0x12);
Oled_Write_Cmd(0x81);
Oled_Write_Cmd(0xCF);
Oled_Write_Cmd(0xD9);
Oled_Write_Cmd(0xF1);
Oled_Write_Cmd(0xDB);
Oled_Write_Cmd(0x30);
Oled_Write_Cmd(0xA4);
Oled_Write_Cmd(0xA6);
Oled_Write_Cmd(0x8D);
Oled_Write_Cmd(0x14);
Oled_Write_Cmd(0xAF);
}
void GPIO_INIT(){
GPIO_InitTypeDef gpio_init;
gpio_init.Mode = GPIO_MODE_OUTPUT_PP;
gpio_init.Pin = SDA_PIN|SCL_PIN;
gpio_init.Pull = GPIO_PULLUP;
gpio_init.Speed = GPIO_SPEED_FREQ_HIGH;
__HAL_RCC_GPIOB_CLK_ENABLE();
HAL_GPIO_Init(GPIOB, &gpio_init);
}
void Oled_Write_Cmd(uint8_t cmd){
iic_Start(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,0x78);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,0x00);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,cmd);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Stop(SDA_PORT,SDA_PIN,SCL_PIN);
}
void Oled_Write_Data(uint8_t data){
iic_Start(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,0x78);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,0x40);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Sendbyte(SDA_PORT,SDA_PIN,SCL_PIN,data);
iic_Ask(SDA_PORT,SDA_PIN,SCL_PIN);
iic_Stop(SDA_PORT,SDA_PIN,SCL_PIN);
}
void Oled_Set_Position(uint8_t page,uint8_t column){
Oled_Write_Cmd(0xB0 + page); //选择页数
Oled_Write_Cmd(column & 0x0F); //低四位
Oled_Write_Cmd(((column & 0xF0) >> 4) | 0x10); //高四位
}
void Oled_clear(void){
uint8_t i,j;
for(i = 0;i < 8;i++){
Oled_Set_Position(i,0);
for(j = 0;j<128;j++){
Oled_Write_Data(0);
}
}
}
void Oled_show_picture(uint8_t hight,uint8_t wide,uint8_t *picture){
uint8_t page,i,j;
page = hight / 8;
for(i = 0; i<page; i++){
Oled_Set_Position(i,0);
for(j = 0; j<wide; j++){
Oled_Write_Data(picture[wide * i + j]);
}
}
}
void Oled_show_char(uint8_t Ocolumn, uint8_t Opage, uint8_t num, uint8_t size)
{
uint8_t i, j, page;
num = num - ' ';
page = size / 8;
if(size % 8)
page++;
for(j = 0; j < page; j++)
{
Oled_Set_Position(Opage + j,Ocolumn);
for(i = size / 2 * j; i < size /2 * (j + 1); i++)
{
if(size == 12)
Oled_Write_Data(ascii_6X12[num][i]);
else if(size == 16)
Oled_Write_Data(ascii_8X16[num][i]);
else if(size == 24)
Oled_Write_Data(ascii_12X24[num][i]);
}
}
}
void Oled_show_string(uint8_t column, uint8_t page, char *p, uint8_t size)
{
while(*p != '\0')
{
Oled_show_char(column, page, *p, size);
column += size/2;
p++;
}
}
W25Q128配置
对于它其实主要就是配置 读;写;等待空闲;这就是一个FLASH储存器。
FLASH的唯一特性就是:它只能写0不能写1.
并且该模块不需要初始化,命令同样很多但是我们用的很少。
W25Q128的命令
0x06 | 写使能 | 写入数据/擦除之前,必须先发送该指令 |
0x05 | 读 SR1 | 判定 FLASH 是否处于空闲状态,擦除用 |
0x03 | 读数据 | 读取数据 |
0x02 | 页写 | 写入数据,最多写256字节 |
0x20 | 扇区擦除 | 扇区擦除指令,最小擦除单位 |
这里主要讲一些要点:
1.在做任何通讯的操作之前,必须要拉低CS,也就是拉低片选。这也是它和IIC不同的地方,IIC是进行寻址,而它通过拉低片选。操作完成后必须再拉高。
2.在进行读/写操作是需要发送地址,这个地址是三字节的,而发送时每次发送的是一个字节,所以就需要位操作。
void send_addr(uint32_t address){
SPI_swap_byte((uint8_t)address >> 16); //右移的同时进行强转,强转保留低位,所以这里是发送高位
SPI_swap_byte((uint8_t)address >> 8); //中间8位
SPI_swap_byte((uint8_t)address); //低8位
}
3.任何和写相关的操作比如:马上要写,刚刚写完;必须进行等待空闲。
W25Q128的寄存器
这里就介绍这个一个,就是为了等待空闲操作使用。
在编程过程中,步骤也很简单:
拉低片选----向芯片发送读取Busy的命令----发送的同时接收----while来阻塞知道接收到BusyFlag为0----拉高片选。
void wait_busy(void){
W25Q128_CS(0);
SPI_swap_byte(FLASH_ReadStatusReg1); //读取状态寄存器指令
SPI_swap_byte(FLASH_DummyByte); //接收状态寄存器flag
while((SPI_swap_byte(0xFF) & 0x01) == 1); //等待知道寄存器busy位变为0
W25Q128_CS(1);
}
代码:
#include "w25q128.h"
uint16_t w25q128_config(void){
uint16_t device_id = 0;
W25Q128_CS(0);
SPI_swap_byte(FLASH_ManufactDeviceID);
SPI_swap_byte(0x00);
SPI_swap_byte(0x00);
SPI_swap_byte(0x00);
device_id = SPI_swap_byte(FLASH_DummyByte) << 8;
device_id |= SPI_swap_byte(FLASH_DummyByte);
W25Q128_CS(1);
return device_id;
}
void wait_busy(void){
W25Q128_CS(0);
SPI_swap_byte(FLASH_ReadStatusReg1); //读取状态寄存器指令
SPI_swap_byte(FLASH_DummyByte); //接收状态寄存器flag
while((SPI_swap_byte(0xFF) & 0x01) == 1); //等待知道寄存器busy位变为0
W25Q128_CS(1);
}
void send_addr(uint32_t address){
SPI_swap_byte((uint8_t)address >> 16); //右移的同时进行强转,强转保留低位,所以这里是发送高位
SPI_swap_byte((uint8_t)address >> 8); //中间8位
SPI_swap_byte((uint8_t)address); //低8位
}
void w25q128_writ_enable(void)
{
W25Q128_CS(0);
SPI_swap_byte(FLASH_WriteEnable);
W25Q128_CS(1);
}
void read_data(uint32_t address, uint8_t *rev_data, uint8_t size){
W25Q128_CS(0);
SPI_swap_byte(FLASH_ReadData);
send_addr(address);
uint8_t i = 0;
for(i = 0; i < size; i++){
rev_data[i] = SPI_swap_byte(0xFF); //直接根据指针写进去了所以不需要返回值
}
W25Q128_CS(1);
}
void write_page_data(uint32_t address, uint8_t *write_data, uint8_t size){
w25q128_writ_enable();
wait_busy();
W25Q128_CS(0);
SPI_swap_byte(FLASH_PageProgram);
send_addr(address);
uint16_t i = 0;
for(i = 0; i< size; i++){
SPI_swap_byte(write_data[i]);
}
W25Q128_CS(1);
wait_busy();
}
void erase_page(uint32_t address){
w25q128_writ_enable();
wait_busy();
W25Q128_CS(0);
SPI_swap_byte(FLASH_SectorErase);
send_addr(address);
W25Q128_CS(1);
wait_busy();
}
最终配置:
把文件合并,接线合并,初始化后在Main函数更改写法让读写操作显示在屏幕上:
#include "sys.h"
#include "uart1.h"
#include "delay.h"
#include "led.h"
#include "SPI.h"
#include "w25q128.h"
#include "oled.h"
#include "IIC.h"
#include "string.h"
uint8_t data_write[4] = {0xAA, 0xBB, 0xCC, 0xDD};
uint8_t data_read[4] = {0};
int main(void)
{
HAL_Init();
stm32_clock_init(RCC_PLL_MUL9);
SPI_INIT();
uart1_init(115200);
OLED_INIT();
Oled_clear();
Oled_show_string(1,1,"HELLO WKX",24);
delay_ms(1000);
Oled_clear();
char show[8];
uint16_t number = w25q128_config();
sprintf(show,"device id :%X",number);
Oled_show_string(1,1,"Loading...",24);
delay_ms(1000);
Oled_clear();
Oled_show_string(1,1,show,16);
delay_ms(1000);
Oled_clear();
write_page_data(0x000000, data_write, 4);
Oled_show_string(1,1,"Writed succes",16);
delay_ms(500);
Oled_clear();
Oled_show_string(1,1,"Reading data",16);
delay_ms(500);
Oled_clear();
read_data(0x000000, data_read, 4);
memset(show,0,8);
sprintf(show,"%X",data_read[0]);
Oled_show_string(1,1,show,16);
sprintf(show,"%X",data_read[1]);
Oled_show_string(40,1,show,16);
sprintf(show,"%X",data_read[2]);
Oled_show_string(80,1,show,16);
while(1)
{
}
}
好了给看看效果:
OLED&W25&Q128