zoukankan      html  css  js  c++  java
  • (stm32学习总结)—SPI-FLASH 实验

    SPI总线

    SPI 简介

    SPI 的全称是"Serial Peripheral Interface",意为串行外围接口,是Motorola 首先在其 MC68HCXX 系列处理器上定义的。SPI 接口主要应用在 EEPROM、
    FLASH、实时时钟、AD 转换器,还有数字信号处理器和数字信号解码器之间。SPI是一种高速的,全双工,同步的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚,同时为 PCB 的布局上节省空间,提供方便,正是出于这种简单易用的特性,如今越来越多的芯片集成了这种通信协议,比如 STM32 系列芯片。下面我们看下 SPI 内部结构简易图,如图 39.1.1.1 所示:

    SPI 物理层

    SPI 通讯设备之间的常用连接方式见图 25-1。
      SPI 通讯使用 3 条总线及片选线,3 条总线分别为 SCK、MOSI、MISO,片选线为SS,它们的作用介绍如下:
    (1) SS( Slave Select):从设备选择信号线,常称为片选信号线,也称为 NSS、CS,以下用 NSS 表示。当有多个 SPI 从设备与 SPI 主机相连时,设备的其它信号线 SCK、MOSI 及 MISO 同时并联到相同的 SPI 总线上,即无论有多少个从设备,都共同只使用这 3 条总线;而每个从设备都有独立的这一条 NSS 信号线,本信号线独占主机的一个引脚,即有多少个从设备,就有多少条片选信号线。I2C 协议中通过设备地址来寻址、选中总线上的某个设备并与其进行通讯;而 SPI 协议中没有设备地址,它使用 NSS 信号线来寻址,当主机要选择从设备时,把该从设备的 NSS 信号线设置为低电平,该从设备即被选中,即片选有效,接着主机开始与被选中的从设备进行 SPI 通讯。所以SPI 通讯以 NSS 线置低电平为开始信号,以 NSS 线被拉高作为结束信号。 
    (2) SCK (Serial Clock):时钟信号线,用于通讯数据同步。它由通讯主机产生,决定了通讯的速率,不同的设备支持的最高时钟频率不一样,如 STM32 的 SPI 时钟频率最大为fpclk/2,两个设备之间通讯时,通讯速率受限于低速设备。 
    (3) MOSI (Master Output, Slave Input):主设备输出/从设备输入引脚。主机的数据从这条信号线输出,从机由这条信号线读入主机发送的数据,即这条线上数据的方向为主机到从机。
    (4) MISO(Master Input,,Slave Output):主设备输入/从设备输出引脚。主机从这条信号线
    读入数据,从机的数据由这条信号线输出到主机,即在这条线上数据的方向为从机到主机。

    协议层

    与 I2C 的类似,SPI 协议定义了通讯的起始和停止信号、数据有效性、时钟同步等环节。
    1. SPI 基本通讯过程
      先看看 SPI 通讯的通讯时序,见图 25-2。
    这是一个主机的通讯时序。NSS、SCK、MOSI 信号都由主机控制产生,而 MISO 的信号由从机产生,主机通过该信号线读取从机的数据。MOSI 与 MISO 的信号只在 NSS 为低电平的时候才有效,在 SCK 的每个时钟周期 MOSI 和 MISO 传输一位数据。以上通讯流程中包含的各个信号分解如下: 
    2. 通讯的起始和停止信号
      在图 25-2 中的标号1处,NSS 信号线由高变低,是 SPI 通讯的起始信号。NSS 是每个从机各自独占的信号线,当从机在自己的 NSS 线检测到起始信号后,就知道自己被主机选中了,开始准备与主机通讯。在图中的标号6处,NSS 信号由低变高,是 SPI 通讯的停止信号,表示本次通讯结束,从机的选中状态被取消。 
    3. 数据有效性
      SPI 使用 MOSI 及 MISO 信号线来传输数据,使用 SCK 信号线进行数据同步。MOSI及 MISO 数据线在 SCK 的每个时钟周期传输一位数据,且数据输入输出是同时进行的。数据传输时,MSB 先行或 LSB 先行并没有作硬性规定,但要保证两个 SPI 通讯设备之间使用同样的协定,一般都会采用图 25-2 中的 MSB 先行模式。观察图中的2、3、4、5标号处,MOSI 及 MISO 的数据在 SCK 的上升沿期间变化输出,在 SCK 的下降沿时被采样即在 SCK 的下降沿时刻,MOSI 及 MISO 的数据有效,高电平时表示数据“1”,为低电平时表示数据“0”。在其它时刻,数据无效,MOSI 及 MISO为下一次表示数据做准备。SPI 每次数据传输可以 8 位或 16 位为单位,每次传输的单位数不受限制。 
    4. CPOL/CPHA 及通讯模式
      上面讲述的图 25-2 中的时序只是 SPI 中的其中一种通讯模式,SPI 一共有四种通讯模式,它们的主要区别是总线空闲时 SCK 的时钟状态以及数据采样时刻。为方便说明,在此引入“时钟极性 CPOL”和“时钟相位 CPHA”的概念。时钟极性 CPOL 是指 SPI 通讯设备处于空闲状态时,SCK 信号线的电平信号(即 SPI 通讯开始前、 NSS 线为高电平时 SCK 的状态)。CPOL=0 时, SCK 在空闲状态时为低电平,CPOL=1 时,则相反。时钟相位 CPHA 是指数据的采样的时刻,当 CPHA=0 时,MOSI 或 MISO 数据线上的信号将会在 SCK 时钟线的“奇数边沿”被采样。当 CPHA=1 时,数据线在 SCK 的“偶数边沿”采样。见图 25-3 及图 25-4。
      我们来分析这个 CPHA=0 的时序图。首先,根据 SCK 在空闲状态时的电平,分为两种情况。SCK 信号线在空闲状态为低电平时,CPOL=0;空闲状态为高电平时,CPOL=1。无论 CPOL=0 还是=1,因为我们配置的时钟相位 CPHA=0,在图中可以看到,采样时刻都是在 SCK 的奇数边沿。注意当 CPOL=0 的时候,时钟的奇数边沿是上升沿,而CPOL=1 的时候,时钟的奇数边沿是下降沿。所以 SPI 的采样时刻不是由上升/下降沿决定的。MOSI 和 MISO 数据线的有效信号在 SCK 的奇数边沿保持不变,数据信号将在 SCK 奇数边沿时被采样,在非采样时刻,MOSI 和 MISO 的有效信号才发生切换。 
     
      由 CPOL 及 CPHA 的不同状态,SPI 分成了四种模式,见表 25-1,主机与从机需要工作在相同的模式下才可以正常通讯,实际中采用较多的是“模式 0”与“模式 3”。
    STM32 的 SPI 特性及架构
      与 I2C 外设一样,STM32 芯片也集成了专门用于 SPI 协议通讯的外设。 
    STM32 的 SPI 外设简介
      STM32 的 SPI 外设可用作通讯的主机及从机,支持最高的 SCK 时钟频率为 fpclk/2(STM32F103 型号的芯片默认 fpclk1为 72MHz,fpclk2为 36MHz),完全支持 SPI 协议的 4 种模式,数据帧长度可设置为 8 位或 16 位,可设置数据 MSB 先行或 LSB 先行。它还支持双线全双工(前面小节说明的都是这种模式)、双线单向以及单线模式。其中双线单向模式可以同时使用 MOSI 及 MISO 数据线向一个方向传输数据,可以加快一倍的传输速度。而单线模式则可以减少硬件接线,当然这样速率会受到影响。我们只讲解双线全双工模式。 
    STM32 的 SPI 架构剖析
    1.通讯引脚
      SPI 的所有硬件架构都从图 25-5 中左侧 MOSI、MISO、SCK 及 NSS 线展开的。STM32 芯片有多个 SPI 外设,它们的 SPI 通讯信号引出到不同的 GPIO 引脚上,使用时必须配置到这些指定的引脚,见表 25-2。
      其中 SPI1 是 APB2 上的设备,最高通信速率达 36Mbtis/s,SPI2、SPI3 是 APB1 上的设备,最高通信速率为 18Mbits/s。除了通讯速率,在其它功能上没有差异。其中 SPI3 用到了下载接口的引脚,这几个引脚默认功能是下载,第二功能才是 IO 口,如果想使用 SPI3接口,则程序上必须先禁用掉这几个 IO 口的下载功能。一般在资源不是十分紧张的情况下,这几个 IO 口是专门用于下载和调试程序,不会复用为 SPI3。 
    2. 时钟控制逻辑
      SCK 线的时钟信号,由波特率发生器根据“控制寄存器 CR1”中的 BR[0:2]位控制,该位是对 fpclk时钟的分频因子,对 fpclk的分频结果就是 SCK 引脚的输出时钟频率,计算方法见表 25-3。
      其中的 fpclk频率是指 SPI 所在的 APB 总线频率,APB1 为 fpclk1,APB2 为 fpckl2。通过配置“控制寄存器 CR”的“CPOL 位”及“CPHA”位可以把 SPI 设置成前面分析的 4 种 SPI 模式。
    3. 数据控制逻辑
      SPI 的 MOSI 及 MISO 都连接到数据移位寄存器上,数据移位寄存器的数据来源及目标接收、发送缓冲区以及 MISO、MOSI 线。
    当向外发送数据的时候,数据移位寄存器以“发送缓冲区”为数据源,把数据一位一位地通过数据线发送出去;
    当从外部接收数据的时候,数据移位寄存器把数据线采样到的数据一位一位地存储到“接收缓冲区”中。
    通过写 SPI的“数据寄存器 DR”把数据填充到发送 F 缓冲区中,通讯读“数据寄存器 DR”,可以获取接收缓冲区中的内容。其中数据帧长度可以通过“控制寄存器 CR1”的“DFF 位”配置成 8 位及 16 位模式;配置“LSBFIRST 位”可选择 MSB 先行还是 LSB 先行。
    4. 整体控制逻辑
      整体控制逻辑负责协调整个 SPI 外设,控制逻辑的工作模式根据我们配置的“控制寄存器(CR1/CR2)”的参数而改变,基本的控制参数包括前面提到的 SPI 模式、波特率、LSB先行、主从模式、单双向模式等等。
      在外设工作时,控制逻辑会根据外设的工作状态修改“状态寄存器(SR)”,我们只要读取状态寄存器相关的寄存器位,就可以了解 SPI 的工作状态了。除此之外,控制逻辑还根据要求,负责控制产生 SPI 中断信号、DMA 请求及控制NSS 信号线。
      实际应用中,我们一般不使用 STM32 SPI 外设的标准 NSS 信号线,而是更简单地使用普通的 GPIO,软件控制它的电平输出,从而产生通讯起始和停止信号。

    通讯过程

    STM32 使用 SPI 外设通讯时,在通讯的不同阶段它会对“状态寄存器 SR”的不同数据位写入参数,我们通过读取这些寄存器标志来了解通讯状态。 
    图 25-6 中的是“主模式”流程,即 STM32 作为 SPI 通讯的主机端时的数据收发过程。
    主模式收发流程及事件说明如下:
    (1) 控制 NSS 信号线,产生起始信号(图中没有画出);
    (2) 把要发送的数据写入到“数据寄存器 DR”中,该数据会被存储到发送缓冲区;
    (3) 通讯开始,SCK 时钟开始运行。MOSI 把发送缓冲区中的数据一位一位地传输出去;MISO 则把数据一位一位地存储进接收缓冲区中;
    (4) 当发送完一帧数据的时候,“状态寄存器 SR”中的“TXE 标志位”会被置 1,表示传输完一帧,发送缓冲区已空;类似地,当接收完一帧数据的时候,“RXNE标志位”会被置 1,表示传输完一帧,接收缓冲区非空;
    (5) 等待到“TXE 标志位”为 1 时,若还要继续发送数据,则再次往“数据寄存器DR”写入数据即可;等待到“RXNE 标志位”为 1 时,通过读取“数据寄存器DR”可以获取接收缓冲区中的内容。
    假如我们使能了 TXE 或 RXNE 中断,TXE 或 RXNE 置 1 时会产生 SPI 中断信号,进入同一个中断服务函数,到 SPI 中断服务程序后,可通过检查寄存器位来了解是哪一个事件,再分别进行处理。也可以使用 DMA 方式来收发“数据寄存器 DR”中的数据。 

    SPI 初始化结构体详解

    跟其它外设一样,STM32 标准库提供了 SPI 初始化结构体及初始化函数来配置 SPI 外设。初始化结构体及函数定义在库文件“stm32f4xx_spi.h”及“stm32f4xx_spi.c”中,编程时我们可以结合这两个文件内的注释使用或参考库帮助文档。了解初始化结构体后我们就能对 SPI 外设运用自如了,代码如下。
    这些结构体成员说明如下,其中括号内的文字是对应参数在 STM32 标准库中定义的宏:
    (1) SPI_Direction
    本成员设置 SPI 的通讯方向,可设置为双线全双工(SPI_Direction_2Lines_FullDuplex),双线只接收(SPI_Direction_2Lines_RxOnly),单线只接收(SPI_Direction_1Line_Rx)、单线只发送模式(SPI_Direction_1Line_Tx)。
    (2) SPI_Mode
    本成员设置 SPI 工作在主机模式(SPI_Mode_Master)或从机模式(SPI_Mode_Slave ),这两个模式的最大区别为 SPI 的 SCK 信号线的时序,SCK 的时序是由通讯中的主机产生的。若被配置为从机模式,STM32 的 SPI 外设将接受外来的 SCK 信号。
    (3) SPI_DataSize
    本成员可以选择 SPI 通讯的数据帧大小是为 8 位(SPI_DataSize_8b)还是 16 位(SPI_DataSize_16b)。
    (4) SPI_CPOL 和 SPI_CPHA
    这两个成员配置 SPI 的时钟极性 CPOL 和时钟相位 CPHA,这两个配置影响到 SPI 的通讯模式,关于 CPOL 和 CPHA 的说明参考前面“通讯模式”小节。
    时钟极性 CPOL 成员,可设置为高电平(SPI_CPOL_High)或低电平(SPI_CPOL_Low )。
    时钟相位 CPHA 则可以设置为 SPI_CPHA_1Edge(在 SCK 的奇数边沿采集数据) 或SPI_CPHA_2Edge (在 SCK 的偶数边沿采集数据) 。 
    (5) SPI_NSS
    本成员配置 NSS 引脚的使用模式,可以选择为硬件模式(SPI_NSS_Hard )与软件模式(SPI_NSS_Soft ),在硬件模式中的 SPI 片选信号由 SPI 硬件自动产生,而软件模式则需要我们亲自把相应的 GPIO 端口拉高或置低产生非片选和片选信号。实际中软件模式应用比较多。
    (6) SPI_BaudRatePrescaler
    本成员设置波特率分频因子,分频后的时钟即为 SPI 的 SCK 信号线的时钟频率。这个成员参数可设置为 fpclk 的 2、4、6、8、16、32、64、128、256 分频。
    (7) SPI_FirstBit
    所有串行的通讯协议都会有 MSB 先行(高位数据在前)还是 LSB 先行(低位数据在前)的问题,而 STM32 的 SPI 模块可以通过这个结构体成员,对这个特性编程控制。
    (8) SPI_CRCPolynomial
    这是 SPI 的 CRC 校验中的多项式,若我们使用 CRC 校验时,就使用这个成员的参数(多项式),来计算 CRC 的值。
      配置完这些结构体成员后,我们要调用 SPI_Init 函数把这些参数写入到寄存器中,实现 SPI 的初始化,然后调用 SPI_Cmd 来使能 SPI 外设。

    SPI 配置步骤 

    (1)使能 SPI 及对应 GPIO 端口时钟并配置引脚的复用功能
      要使用 SPI 就必须使能它的时钟,前面介绍框图时,我们知道 SPI1 是挂接在 APB2 总线上,而 SPI2 和 SPI3 挂接在 APB1 总线上。而且 SPI 总线接口对应不同的 STM32 引脚,所以还需使能对应引脚的端口时钟,同时配置为复用功能。
    (2)初始化 SPI,包括数据帧长度、传输模式、MSB 和 LSB 顺序等
    (3)使能(开启)SPI
    (4)SPI 数据传输
      通过上面几个步骤的配置,SPI 已经可以开始通信了,在通信的过程中肯定会有数据的发送和接收,固件库也提供了 SPI 的发送和接收函数。
    SPI 发送数据函数原型为:
      void SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data);这个函数很好理解,往 SPIx 数据寄存器写入数据 Data,从而实现发送。
    SPI 接收数据函数原型为:
      uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx);此函数非常简单,从 SPIx 数据寄存器中读取接收到的数据。
    (5)查看 SPI 传输状态
      在 SPI 传输过程中,我们经常要判断数据是否传输完成,发送区是否为空等状态,这是通过函数 SPI_I2S_GetFlagStatus 实现的,此函数原型为:
    FlagStatus SPI_I2S_GetFlagStatus(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);
    此函数非常简单,第二个参数是用来选择 SPI 传输过程中判断的标志,对应的标志可在 stm32f10x_spi.h 文件中查找到,使用较多的是发送完成标志(SPI_I2S_FLAG_TXE)和接收完成标志(SPI_I2S_FLAG_RXNE)。
    判断发送是否完成的方法是:
    SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE);
    将以上几步配置好后,我们就可以使用 STM32F1 的 SPI 和外部 FLASH(EN25QXX)通信了。
     

    spi配置代码(只配置了spi1)

     1 #ifndef _spi_H
     2 #define _spi_H
     3 
     4 #include "system.h"
     5 
     6 void SPI1_Init(void);             //初始化SPI1口
     7 void SPI1_SetSpeed(u8 SpeedSet); //设置SPI1速度   
     8 u8 SPI1_ReadWriteByte(u8 TxData);//SPI1总线读写一个字节
     9 
    10 //void SPI2_Init(void);             //初始化SPI2口
    11 //void SPI2_SetSpeed(u8 SpeedSet); //设置SPI2速度   
    12 //u8 SPI2_ReadWriteByte(u8 TxData);//SPI2总线读写一个字节
    13 
    14 #endif

     

     1 #include "spi.h"
     2 
     3 //以下是SPI模块的初始化代码,配置成主机模式                           
     4 //SPI口初始化
     5 //这里针是对SPI1的初始化
     6 void SPI1_Init(void)
     7 {
     8     GPIO_InitTypeDef  GPIO_InitStructure;
     9     SPI_InitTypeDef  SPI_InitStructure;
    10     
    11     /* SPI的IO口和SPI外设打开时钟 */
    12     RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    13     RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
    14     
    15     /* SPI的IO口设置 */
    16     GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7;
    17     GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;   //复用推挽输出
    18     GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    19     GPIO_Init(GPIOA, &GPIO_InitStructure);
    20 
    21     SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;  //设置SPI单向或者双向的数据模式:SPI设置为双线双向全双工
    22     SPI_InitStructure.SPI_Mode = SPI_Mode_Master;        //设置SPI工作模式:设置为主SPI
    23     SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;        //设置SPI的数据大小:SPI发送接收8位帧结构
    24     SPI_InitStructure.SPI_CPOL = SPI_CPOL_High;        //串行同步时钟的空闲状态为高电平
    25     SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge;    //串行同步时钟的第二个跳变沿(上升或下降)数据被采样
    26     SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;        //NSS信号由硬件(NSS管脚)还是软件(使用SSI位)管理:内部NSS信号有SSI位控制
    27     SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_256;        //定义波特率预分频的值:波特率预分频值为256
    28     SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;    //指定数据传输从MSB位还是LSB位开始:数据传输从MSB位开始
    29     SPI_InitStructure.SPI_CRCPolynomial = 7;    //CRC值计算的多项式
    30     SPI_Init(SPI1, &SPI_InitStructure);  //根据SPI_InitStruct中指定的参数初始化外设SPIx寄存器
    31     
    32     SPI_Cmd(SPI1, ENABLE); //使能SPI外设
    33     
    34     SPI1_ReadWriteByte(0xff);//启动传输    
    35 }
    36 
    37 //SPI1速度设置函数
    38 //SPI速度=fAPB2/分频系数
    39 //@ref SPI_BaudRate_Prescaler:SPI_BaudRatePrescaler_2~SPI_BaudRatePrescaler_256  
    40 //fAPB2时钟一般为84Mhz:
    41 void SPI1_SetSpeed(u8 SPI_BaudRatePrescaler)
    42 {
    43     SPI1->CR1&=0XFFC7;//位3-5清零,用来设置波特率
    44     SPI1->CR1|=SPI_BaudRatePrescaler;    //设置SPI1速度 
    45     SPI_Cmd(SPI1,ENABLE); //使能SPI1
    46 } 
    47 
    48 //SPI1 读写一个字节
    49 //TxData:要写入的字节
    50 //返回值:读取到的字节
    51 u8 SPI1_ReadWriteByte(u8 TxData)
    52 {                      
    53  
    54     while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);//等待发送区空  
    55     
    56     SPI_I2S_SendData(SPI1, TxData); //通过外设SPIx发送一个byte  数据
    57         
    58     while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); //等待接收完一个byte  
    59  
    60     return SPI_I2S_ReceiveData(SPI1); //返回通过SPIx最近接收的数据    
    61              
    62 }

    上述程序中的一个奇怪的地方

    在复用SPI总线时,必须先设置总线端口。读取其他ARM芯片(如NXP)一般很容易看出芯片的设置是否正确。不过对于STM32就容易让人迷惑了。就像上述程序中,我们在使用SPI总线进行通信时,可以这样设置:

      GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7 
      GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
      GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;     // 复用的推挽输出

         其他端口如时钟端口以及MOSI端口都是stm32向外输出,引脚设置成推挽输出没问题, 但是大家对MISO端口的设置就会产生疑惑了,MISO不是应该设置成为输入端口(GPIO_Mode_IN_FLOATING)才行的吗?

          答题是肯定的,对于STM32的这一类管脚来说(如USART_RX)即可以设置成为输入模式,也可以设置成为复用的推挽输出。其工作都是正常的,不过建议大家还是设置成为输入端口的好,容易理解。

          具体产生这一问题的原因是:从功能上来说,MISO应该配置为输入模式才对,但为什么也可以配置为GPIO_Mode_AF_PP?请看下面的GPIO复用功能配置框图。当一个GPIO端口配置为GPIO_Mode_AF_PP是,这个端口的内部结构框图如下:

      图中可以看到,片上外设的复用功能输出信号会连接到输出控制电路,然后在端口上产生输出信号。但是在芯片内部,MISO是SPI模块的输入引脚,而不是输出引脚,也就是说图中的"复用功能输出信号"根本不存在(MISO不会产生输出信号),因此"输出控制电路"不能对外产生输出信号。

      而另一方面看,即使在GPIO_Mode_AF_PP模式下,复用功能输入信号却与外部引脚之间相互连接,既MISO得到了外部信号的电平,实现了输入的功能(可以4-5-6-7路线输入数据,复用的情况下就是4-5-复用功能路线)。

    FLASH介绍

    控制 FLASH 的指令

      搞定 SPI 的基本收发单元后,还需要了解如何对 FLASH 芯片进行读写。FLASH 芯片自定义了很多指令,我们通过控制 STM32 利用 SPI 总线向 FLASH 芯片发送指令,FLASH芯片收到后就会执行相应的操作。而这些指令,对主机端(STM32)来说,只是它遵守最基本的 SPI 通讯协议发送出的数据,但在设备端(FLASH 芯片)把这些数据解释成不同的意义,所以才成为指令。查看FLASH 芯片的数据手册《W25Q64》,可了解各种它定义的各种指令的功能及指令格式,见表 25-4。 
     
      该表中的第一列为指令名,第二列为指令编码,第三至第 N 列的具体内容根据指令的不同而有不同的含义。其中带括号的字节参数,方向为 FLASH 向主机传输,即命令响应,不带括号的则为主机向 FLASH 传输。表中“A0~A23”指 FLASH 芯片内部存储器组织的地址;“M0~M7”为厂商号(MANUFACTURER ID);“ID0-ID15”为 FLASH 芯片的ID;“dummy”指该处可为任意数据;“D0~D7”为 FLASH 内部存储矩阵的内容。 
      在 FLSAH 芯片内部,存储有固定的厂商编号(M7-M0)和不同类型 FLASH 芯片独有的编号(ID15-ID0),见表 25-5。
      通过指令表中的读 ID 指令“JEDEC ID”可以获取这两个编号,该指令编码为“9Fh”,其中“9F h”是指 16 进制数“9F” (相当于 C 语言中的 0x9F)。紧跟指令编码的三个字节分别为 FLASH 芯片输出的“(M7-M0)”、“(ID15-ID8)”及“(ID7-ID0)” 。此处我们以该指令为例,配合其指令时序图进行讲解,见图 25-8。 

     

      主机首先通过 MOSI 线向 FLASH 芯片发送第一个字节数据为“9F h”,当 FLASH 芯片收到该数据后,它会解读成主机向它发送了“JEDEC 指令”,然后它就作出该命令的响应:通过 MISO 线把它的厂商 ID(M7-M0)及芯片类型(ID15-0)发送给主机,主机接收到指令响应后可进行校验。常见的应用是主机端通过读取设备 ID 来测试硬件是否连接正常,或用于识别设备。对于 FLASH 芯片的其它指令,都是类似的,只是有的指令包含多个字节,或者响应包含更多的数据。
      实际上,编写设备驱动都是有一定的规律可循的。首先我们要确定设备使用的是什么通讯协议。如上一章的 EEPROM 使用的是 I2C,本章的 FLASH 使用的是 SPI。那么我们就先根据它的通讯协议,选择好 STM32 的硬件模块,并进行相应的 I2C 或 SPI 模块初始化。接着,我们要了解目标设备的相关指令,因为不同的设备,都会有相应的不同的指令。如EEPROM 中会把第一个数据解释为内部存储矩阵的地址(实质就是指令)。而 FLASH 则定义了更多的指令,有写指令,读指令,读 ID 指令等等。最后,我们根据这些指令的格式要求,使用通讯协议向设备发送指令,达到控制设备的目标

    定义 FLASH 指令编码表

    为了方便使用,我们把 FLASH 芯片的常用指令编码使用宏来封装起来,后面需要发送指令编码的时候我们直接使用这些宏即可。
     1 //指令表
     2 #define EN25X_WriteEnable        0x06 
     3 #define EN25X_WriteDisable        0x04 
     4 #define EN25X_ReadStatusReg        0x05 
     5 #define EN25X_WriteStatusReg    0x01 
     6 #define EN25X_ReadData            0x03 
     7 #define EN25X_FastReadData        0x0B 
     8 #define EN25X_FastReadDual        0x3B 
     9 #define EN25X_PageProgram        0x02 
    10 #define EN25X_BlockErase        0xD8 
    11 #define EN25X_SectorErase        0x20 
    12 #define EN25X_ChipErase            0xC7 
    13 #define EN25X_PowerDown            0xB9 
    14 #define EN25X_ReleasePowerDown    0xAB 
    15 #define EN25X_DeviceID            0xAB 
    16 #define EN25X_ManufactDeviceID    0x90 
    17 #define EN25X_JedecDeviceID        0x9F 

    读取 FLASH 芯片 ID

    根据“JEDEC”指令的时序,我们把读取 FLASH ID 的过程编写成一个函数。
     1 //读取芯片ID
     2 //返回值如下:                   
     3 //0XEF13,表示芯片型号为EN25Q80  
     4 //0XEF14,表示芯片型号为EN25Q16    
     5 //0XEF15,表示芯片型号为EN25Q32  
     6 //0XEF16,表示芯片型号为EN25Q64 
     7 //0XEF17,表示芯片型号为EN25Q128       
     8 u16 EN25QXX_ReadID(void)
     9 {
    10     u16 Temp = 0;      
    11     EN25QXX_CS=0;                    
    12     SPI2_ReadWriteByte(0x9F);//发送读取ID命令        
    13     SPI2_ReadWriteByte(0x00);         
    14     SPI2_ReadWriteByte(0x00);         
    15     SPI2_ReadWriteByte(0x00);                     
    16     Temp|=SPI2_ReadWriteByte(0xFF)<<8;  
    17     Temp|=SPI2_ReadWriteByte(0xFF);     
    18     //EN25QXX_CS=1;                    
    19     return Temp;
    20 }  
      这段代码利用控制 CS 引脚电平的宏“SPI_FLASH_CS_LOW/HIGH”以及前面编写的单字节收发函数 SPI_FLASH_SendByte,很清晰地实现了“JEDEC ID”指令的时序:发送一个字节的指令编码“W25X_JedecDeviceID”,然后读取 3 个字节,获取 FLASH 芯片对该指令的响应,最后把读取到的这 3 个数据合并到一个变量 Temp 中,然后作为函数返回值,把该返回值与我们定义的宏“sFLASH_ID”对比,即可知道 FLASH 芯片是否正常。 
     

    FLASH 写使能以及读取当前状态

    在向 FLASH 芯片存储矩阵写入数据前,首先要使能写操作,通过“Write Enable”命令即可写使能。
    1 //EN25QXX写使能    
    2 //将WEL置位   
    3 void EN25QXX_Write_Enable(void)   
    4 {
    5     EN25QXX_CS=0;                            //使能器件   
    6     SPI2_ReadWriteByte(EN25X_WriteEnable);      //发送写使能  
    7     EN25QXX_CS=1;                            //取消片选               
    8 } 
      
      与 EEPROM 一样,由于 FLASH 芯片向内部存储矩阵写入数据需要消耗一定的时间,并不是在总线通讯结束的一瞬间完成的,所以在写操作后需要确认 FLASH 芯片“空闲”时才能进行再次写入。为了表示自己的工作状态,FLASH 芯片定义了一个状态寄存器,见图 25-9

    我们只关注这个状态寄存器的第 0 位“BUSY”,当这个位为“1”时,表明 FLASH芯片处于忙碌状态,它可能正在对内部的存储矩阵进行“擦除”或“数据写入”的操作。利用指令表中的“Read Status Register”指令可以获取 FLASH 芯片状态寄存器的内容,其时序见图 25-10。 

     

      只要向 FLASH 芯片发送了读状态寄存器的指令,FLASH 芯片就会持续向主机返回最新的状态寄存器内容,直到收到 SPI 通讯的停止信号。据此我们编写了具有等待 FLASH 芯片写入结束功能的函数,见下面代码。 
     1 //读取EN25QXX的状态寄存器
     2 //BIT7  6   5   4   3   2   1   0
     3 //SPR   RV  TB BP2 BP1 BP0 WEL BUSY
     4 //SPR:默认0,状态寄存器保护位,配合WP使用
     5 //TB,BP2,BP1,BP0:FLASH区域写保护设置
     6 //WEL:写使能锁定
     7 //BUSY:忙标记位(1,忙;0,空闲)
     8 //默认:0x00
     9 u8 EN25QXX_ReadSR(void)   
    10 {  
    11     u8 byte=0;   
    12     EN25QXX_CS=0;                            //使能器件   
    13     SPI2_ReadWriteByte(EN25X_ReadStatusReg);    //发送读取状态寄存器命令    
    14     byte=SPI2_ReadWriteByte(0Xff);             //读取一个字节  
    15     EN25QXX_CS=1;                            //取消片选     
    16     return byte;   
    17 } 
    1 //等待空闲
    2 void EN25QXX_Wait_Busy(void)   
    3 {   
    4     while((EN25QXX_ReadSR()&0x01)==0x01);   // 等待BUSY位清空
    5 }

    FLASH 扇区擦除

    由于 FLASH 存储器的特性决定了它只能把原来为“1”的数据位改写成“0”,而原来为“0”的数据位不能直接改写为“1”。所以这里涉及到数据“擦除”的概念,在写入前,必须要对目标存储矩阵进行擦除操作,把矩阵中的数据位擦除为“1”,在数据写入的时候,如果要存储数据“1”,那就不修改存储矩阵 ,在要存储数据“0”时,才更改该位。通常,对存储矩阵擦除的基本操作单位都是多个字节进行,如本例子中的 FLASH 芯
    片支持“扇区擦除”、“块擦除”以及“整片擦除”,见表 25-6。 
    FLASH 芯片的最小擦除单位为扇区(Sector),而一个块(Block)包含 16 个扇区,其内部存储矩阵分布见图 25-11。
    使用扇区擦除指令“Sector Erase”可控制 FLASH 芯片开始擦写,其指令时序见图25-14。 
    扇区擦除指令的第一个字节为指令编码,紧接着发送的 3 个字节用于表示要擦除的存储矩阵地址。要注意的是在扇区擦除指令前,还需要先发送“写使能”指令,发送扇区擦除指令后,通过读取寄存器状态等待扇区擦除操作完毕,代码如下。
     1 //擦除一个扇区
     2 //Dst_Addr:扇区地址 根据实际容量设置
     3 //擦除一个山区的最少时间:150ms
     4 void EN25QXX_Erase_Sector(u32 Dst_Addr)   
     5 {  
     6     //监视falsh擦除情况,测试用   
     7      printf("fe:%x
    ",Dst_Addr);      
     8      Dst_Addr*=4096;
     9     EN25QXX_Write_Enable();                  //SET WEL      
    10     EN25QXX_Wait_Busy();   
    11       EN25QXX_CS=0;                            //使能器件   
    12     SPI2_ReadWriteByte(EN25X_SectorErase);      //发送扇区擦除指令 
    13     SPI2_ReadWriteByte((u8)((Dst_Addr)>>16));  //发送24bit地址    
    14     SPI2_ReadWriteByte((u8)((Dst_Addr)>>8));   
    15     SPI2_ReadWriteByte((u8)Dst_Addr);  
    16     EN25QXX_CS=1;                            //取消片选               
    17     EN25QXX_Wait_Busy();                      //等待擦除完成
    18 }
    这段代码调用的函数在前面都已讲解,只要注意发送擦除地址时高位在前即可。调用扇区擦除指令时注意输入的地址要对齐到 4KB。
     

    FLASH 的页写入

    目标扇区被擦除完毕后,就可以向它写入数据了。与 EEPROM 类似,FLASH 芯片也有页写入命令,使用页写入命令最多可以一次向 FLASH 传输 256 个字节的数据,我们把这个单位为页大小。FLASH 页写入的时序见图 25-13。 
      从时序图可知,第 1 个字节为“页写入指令”编码,2-4 字节为要写入的“地址 A”,接着的是要写入的内容,最多个可以发送 256 字节数据,这些数据将会从“地址 A”开始,按顺序写入到 FLASH 的存储矩阵。若发送的数据超出 256 个,则会覆盖前面发送的数据。与擦除指令不一样,页写入指令的地址并不要求按 256 字节对齐,只要确认目标存储单元是擦除状态即可(即被擦除后没有被写入过)。所以,若对“地址 x”执行页写入指令后,发送了 200 个字节数据后终止通讯,下一次再执行页写入指令,从“地址(x+200)”开始写入 200 个字节也是没有问题的(小于 256 均可)。 只是在实际应用中由于基本擦除单元是4KB,一般都以扇区为单位进行读写,想深入了解,可学习我们的“FLASH 文件系统”相关的例子。把页写入时序封装成函数,其实现见下列代码。 
     1 //写SPI FLASH  
     2 //在指定地址开始写入指定长度的数据
     3 //该函数带擦除操作!
     4 //pBuffer:数据存储区
     5 //WriteAddr:开始写入的地址(24bit)                        
     6 //NumByteToWrite:要写入的字节数(最大65535)   
     7 u8 EN25QXX_BUFFER[4096];         
     8 void EN25QXX_Write(u8* pBuffer,u32 WriteAddr,u16 NumByteToWrite)   
     9 { 
    10     u32 secpos;
    11     u16 secoff;
    12     u16 secremain;       
    13      u16 i;    
    14     u8 * EN25QXX_BUF;      
    15        EN25QXX_BUF=EN25QXX_BUFFER;         
    16      secpos=WriteAddr/4096;//扇区地址  
    17     secoff=WriteAddr%4096;//在扇区内的偏移
    18     secremain=4096-secoff;//扇区剩余空间大小   
    19      //printf("ad:%X,nb:%X
    ",WriteAddr,NumByteToWrite);//测试用
    20      if(NumByteToWrite<=secremain)secremain=NumByteToWrite;//不大于4096个字节
    21     while(1) 
    22     {    
    23         EN25QXX_Read(EN25QXX_BUF,secpos*4096,4096);//读出整个扇区的内容
    24         for(i=0;i<secremain;i++)//校验数据
    25         {
    26             if(EN25QXX_BUF[secoff+i]!=0XFF)break;//需要擦除        
    27         }
    28         if(i<secremain)//需要擦除
    29         {
    30             EN25QXX_Erase_Sector(secpos);//擦除这个扇区
    31             for(i=0;i<secremain;i++)       //复制
    32             {
    33                 EN25QXX_BUF[i+secoff]=pBuffer[i];      
    34             }
    35             EN25QXX_Write_NoCheck(EN25QXX_BUF,secpos*4096,4096);//写入整个扇区  
    36 
    37         }else EN25QXX_Write_NoCheck(pBuffer,WriteAddr,secremain);//写已经擦除了的,直接写入扇区剩余区间.                    
    38         if(NumByteToWrite==secremain)break;//写入结束了
    39         else//写入未结束
    40         {
    41             secpos++;//扇区地址增1
    42             secoff=0;//偏移位置为0      
    43 
    44                pBuffer+=secremain;  //指针偏移
    45             WriteAddr+=secremain;//写地址偏移       
    46                NumByteToWrite-=secremain;                //字节数递减
    47             if(NumByteToWrite>4096)secremain=4096;    //下一个扇区还是写不完
    48             else secremain=NumByteToWrite;            //下一个扇区可以写完了
    49         }     
    50     }     
    51 }
      这段代码的内容为:先发送“写使能”命令,接着才开始页写入时序,然后发送指令编码、地址,再把要写入的数据一个接一个地发送出去,发送完后结束通讯,检查 FLASH状态寄存器,等待 FLASH 内部写入结束。
     

    从 FLASH 读取数据

    相对于写入,FLASH 芯片的数据读取要简单得多,使用读取指令“Read Data”即可,其指令时序见图 25-14。
     
    发送了指令编码及要读的起始地址后,FLASH 芯片就会按地址递增的方式返回存储矩阵的内容,读取的数据量没有限制,只要没有停止通讯,FLASH 芯片就会一直返回数据。代码如下。
     1 //读取SPI FLASH  
     2 //在指定地址开始读取指定长度的数据
     3 //pBuffer:数据存储区
     4 //ReadAddr:开始读取的地址(24bit)
     5 //NumByteToRead:要读取的字节数(最大65535)
     6 void EN25QXX_Read(u8* pBuffer,u32 ReadAddr,u16 NumByteToRead)   
     7 { 
     8      u16 i;                                               
     9     EN25QXX_CS=0;                            //使能器件   
    10     SPI2_ReadWriteByte(EN25X_ReadData);         //发送读取命令   
    11     SPI2_ReadWriteByte((u8)((ReadAddr)>>16));  //发送24bit地址    
    12     SPI2_ReadWriteByte((u8)((ReadAddr)>>8));   
    13     SPI2_ReadWriteByte((u8)ReadAddr);   
    14     for(i=0;i<NumByteToRead;i++)
    15     { 
    16         pBuffer[i]=SPI2_ReadWriteByte(0XFF);   //循环读数  
    17     }
    18     EN25QXX_CS=1;                                
    19 }  
    由于读取的数据量没有限制,所以发送读命令后一直接收 NumByteToRead 个数据到结束即可。 

    3. main 函数  

    最后我们来编写 main 函数,进行 FLASH 芯片读写校验,代码如下。
     1 #include "system.h"
     2 #include "SysTick.h"
     3 #include "led.h"
     4 #include "usart.h"
     5 #include "tftlcd.h"
     6 #include "key.h"
     7 #include "spi.h"
     8 #include "flash.h"
     9 
    10 
    11 //要写入到25Q64的字符串数组
    12 const u8 text_buf[]="www.prechin.net";
    13 #define TEXT_LEN sizeof(text_buf)
    14 //u16 key3;
    15 
    16 int main()
    17 {
    18     u8 i=0;
    19     u8 key;
    20     u8 buf[30];
    21     
    22     SysTick_Init(72);
    23     NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);  //中断优先级分组 分2组
    24     LED_Init();
    25     USART1_Init(9600);
    26     TFTLCD_Init();            //LCD初始化
    27     KEY_Init();
    28     EN25QXX_Init();
    29     
    30     FRONT_COLOR=BLACK;
    31     LCD_ShowString(10,10,tftlcd_data.width,tftlcd_data.height,16,"PRECHIN STM32F1");
    32     LCD_ShowString(10,30,tftlcd_data.width,tftlcd_data.height,16,"www.prechin.net");
    33     LCD_ShowString(10,50,tftlcd_data.width,tftlcd_data.height,16,"FLASH-SPI Test");
    34     LCD_ShowString(10,70,tftlcd_data.width,tftlcd_data.height,16,"K_UP:Write   K_DOWN:Read");
    35     FRONT_COLOR=RED;
    36     
    37     while(EN25QXX_ReadID()!=EN25Q64)            //检测不到EN25Q64
    38     //while(1)
    39     {
    40         //key3 = EN25QXX_ReadID();
    41         printf("EN25Q64 Check Failed! 
    ");
    42         LCD_ShowString(10,150,tftlcd_data.width,tftlcd_data.height,16,"EN25Q64 Check Failed!  ");
    43     }
    44     printf("EN25Q64 Check Success!
    ");
    45     LCD_ShowString(10,150,tftlcd_data.width,tftlcd_data.height,16,"EN25Q64 Check Success!");
    46         
    47     LCD_ShowString(10,170,tftlcd_data.width,tftlcd_data.height,16,"Write Data:");
    48     LCD_ShowString(10,190,tftlcd_data.width,tftlcd_data.height,16,"Read Data :");
    49     
    50     while(1)
    51     {
    52         key=KEY_Scan(0);
    53         if(key==KEY_UP)
    54         {
    55             EN25QXX_Write((u8 *)text_buf,0,TEXT_LEN);
    56             printf("发送的数据:%s
    ",text_buf);
    57             LCD_ShowString(10+11*8,170,tftlcd_data.width,tftlcd_data.height,16,"www.prechin.net");
    58         }
    59         if(key==KEY_DOWN)
    60         {
    61             EN25QXX_Read(buf,0,TEXT_LEN);
    62             printf("接收的数据:%s
    ",buf);
    63             LCD_ShowString(10+11*8,190,tftlcd_data.width,tftlcd_data.height,16,buf);
    64         }
    65         
    66         i++;
    67         if(i%20==0)
    68         {
    69             led1=!led1;
    70         }
    71         
    72         delay_ms(10);
    73             
    74     }
    75 }
    注意:
    由于实验板上的 FLASH 芯片默认已经存储了特定用途的数据,如擦除了这些数据会影响到某些程序的运行。所以我们预留了 FLASH 芯片的“第 0 扇区(0-4096 地址)”专用于本实验,如非必要,请勿擦除其它地址的内容。如已擦除,可在配套资料里找到“刷外部 FLASH 内容”程序,根据其说明给 FLASH 重新写入出厂内容。
     
     
     
     
     1 //无检验写SPI FLASH 
     2 //必须确保所写的地址范围内的数据全部为0XFF,否则在非0XFF处写入的数据将失败!
     3 //具有自动换页功能 
     4 //在指定地址开始写入指定长度的数据,但是要确保地址不越界!
     5 //pBuffer:数据存储区
     6 //WriteAddr:开始写入的地址(24bit)
     7 //NumByteToWrite:要写入的字节数(最大65535)
     8 //CHECK OK
     9 void EN25QXX_Write_NoCheck(u8* pBuffer,u32 WriteAddr,u16 NumByteToWrite)   
    10 {                       
    11     u16 pageremain;       
    12     pageremain=256-WriteAddr%256; //单页剩余的字节数                 
    13     if(NumByteToWrite<=pageremain)pageremain=NumByteToWrite;//不大于256个字节
    14     while(1)
    15     {       
    16         EN25QXX_Write_Page(pBuffer,WriteAddr,pageremain);
    17         if(NumByteToWrite==pageremain)break;//写入结束了
    18          else //NumByteToWrite>pageremain
    19         {
    20             pBuffer+=pageremain;
    21             WriteAddr+=pageremain;    
    22 
    23             NumByteToWrite-=pageremain;              //减去已经写入了的字节数
    24             if(NumByteToWrite>256)pageremain=256; //一次可以写入256个字节
    25             else pageremain=NumByteToWrite;       //不够256个字节了
    26         }
    27     }        
    28 } 
     
     1 #ifndef _flash_H
     2 #define _flash_H
     3 
     4 #include "system.h"
     5 
     6 
     7 //EN25X系列/Q系列芯片列表       
     8 //EN25Q80  ID  0XEF13
     9 //EN25Q16  ID  0XEF14
    10 //EN25Q32  ID  0XEF15
    11 //EN25Q64  ID  0XEF16    
    12 //EN25Q128 ID  0XEF17    
    13 #define EN25Q80     0XEF13     
    14 #define EN25Q16     0XEF14
    15 #define EN25Q32     0XEF15
    16 //#define EN25Q64     0XEF16
    17 //#define EN25Q128    0XEF17
    18 //#define EN25Q64     0XC816
    19 //#define EN25Q64     0X1C16        //GD25QXX
    20 //#define EN25Q64     0X2016        //XM25QHXX
    21 #define EN25Q64     0Xb16        //MXIC C216
    22 #define EN25Q128    0XC817
    23 
    24 extern u16 EN25QXX_TYPE;                    //定义EN25QXX芯片型号           
    25 
    26 #define    EN25QXX_CS         PGout(13)          //EN25QXX的片选信号
    27 
    28 
    29 //指令表
    30 #define EN25X_WriteEnable         0x06 
    31 #define EN25X_WriteDisable        0x04 
    32 #define EN25X_ReadStatusReg       0x05 
    33 #define EN25X_WriteStatusReg      0x01 
    34 #define EN25X_ReadData            0x03 
    35 #define EN25X_FastReadData        0x0B 
    36 #define EN25X_FastReadDual        0x3B 
    37 #define EN25X_PageProgram         0x02 
    38 #define EN25X_BlockErase          0xD8 
    39 #define EN25X_SectorErase         0x20 
    40 #define EN25X_ChipErase           0xC7 
    41 #define EN25X_PowerDown           0xB9 
    42 #define EN25X_ReleasePowerDown    0xAB 
    43 #define EN25X_DeviceID            0xAB 
    44 #define EN25X_ManufactDeviceID    0x90 
    45 #define EN25X_JedecDeviceID       0x9F 
    46 
    47 void EN25QXX_Init(void);
    48 u16  EN25QXX_ReadID(void);                  //读取FLASH ID
    49 u8     EN25QXX_ReadSR(void);                //读取状态寄存器 
    50 void EN25QXX_Write_SR(u8 sr);              //写状态寄存器
    51 void EN25QXX_Write_Enable(void);          //写使能 
    52 void EN25QXX_Write_Disable(void);        //写保护
    53 void EN25QXX_Write_NoCheck(u8* pBuffer,u32 WriteAddr,u16 NumByteToWrite);
    54 void EN25QXX_Read(u8* pBuffer,u32 ReadAddr,u16 NumByteToRead);   //读取flash
    55 void EN25QXX_Write(u8* pBuffer,u32 WriteAddr,u16 NumByteToWrite);//写入flash
    56 void EN25QXX_Erase_Chip(void);              //整片擦除
    57 void EN25QXX_Erase_Sector(u32 Dst_Addr);    //扇区擦除
    58 void EN25QXX_Wait_Busy(void);               //等待空闲
    59 void EN25QXX_PowerDown(void);            //进入掉电模式
    60 void EN25QXX_WAKEUP(void);                //唤醒
    61 
    62 
    63 #endif
     
     
     
     
     
  • 相关阅读:
    Asp.Net细节性问题技巧精萃
    存储过程(Stored Procedure)及应用
    合并datagrid中内容相同的单元格
    .net 2.0 下发送邮件的方式
    ADO.NET2.0的十大新特性
    sql server 中各个系统表的作用
    DataGrid一些文章的索引,方便查找
    ASP.NET中 WebControls 命名规则
    SQL Server应用程序中的高级SQL注入[转]
    数据操作例子
  • 原文地址:https://www.cnblogs.com/zhj868/p/12787043.html
Copyright © 2011-2022 走看看