0%

最近又用到了iic,但是原理又有些忘记了,刚好就趁这个机会整理一下一些常用的通信协议,主要是针对协议层和寄存器的整理,均以32为例,32有关内容大部分来自《STM32中文参考手册V10》,方便日后查阅温习。

串口协议

因为也不是初学,同步异步和各种标准就不赘述了,主要讲异步

协议层内容

串口通讯的数据包由发送设备通过自身的 TXD 接口传输到接收设备的 RXD 接口。在串口通讯 的协议层中,规定了数据包的内容,它由启始位、主体数据、校验位以及停止位组成,通讯双方的数据包格式要约定一致才能正常收发数据

以32为例,每一帧数据由:

  • 起始位,为1位逻辑0
  • 数据内容,8或9位
  • 校验位,奇偶校验,准确来说包含在数据内容里,如果需要使用校验,则其为数据内容的最后1位
  • 停止位,常为1or2位逻辑1

组成,通信的双方对数据帧格式约定一致才可正常收发数据

奇偶校验

奇偶校验位分为奇校验和偶校验两种,是一种简单的数据误码校验方法。奇校验是指每帧数据中,包括数据位和奇偶校验位的全部位中1的个数必须为奇数;偶校验是指每帧数据中,包括数据位和奇偶校验位的全部位中1的个数必须为偶数。

波特率

波特率指数据信号对载波的调制速率,它用单位时间内载波调制状态改变次数来表示,单位为波特。而比特率指单位时间内传输的比特数,单位 bit/s(bps)。对于 USART 波特率与比特率相等,波特率越大,传输速率越快。

而STM32的USART的发送器和接收器使用相同的波特率,计算公式为:

其中:

  • fck为USART的时钟,而不同的USART挂载在不同的时钟总线上,例如USART1就是挂载在APB2总线上
  • USARTDIV是一个存放在波特率寄存器(USART_BRR)中的一个无符号定点数

​ 其中, DIV_Mantissa[15:4] 位定义 USARTDIV 的整数部分,DIV_Fraction[3:0] 位定义 USARTDIV 的小数部分

​ 对于小数部分,寄存器中存储的值为实际小数*16,若不为整数则取近似值;整数部分则直接取16进制

​ 例如,假设USARTDIV值为39.0625,则:DIV_Mantissa=39=0x27,DIV_Fraction=0.062516=1=0x01=0x1,最终USART_BRR存储的值为:0x0271,*此结果是当fck为72MHz时比特率为115200bps的配置值。

通信的起始和停止

  • 空闲帧:在双方没有发送或接收数据时,此时数据帧可以被视作1个完全由1组成的完整数据帧
  • 断开帧:被视作全为0的帧

相关寄存器

一些比较常用的就不写了

功能引脚

  • SW_RX:只用于单线和智能卡模式,属于内部引脚,没有具体外部引脚

  • nRTS:请求以发送 (Request To Send),n 表示低电平有效。如果使能 RTS 流控制,当 USART 接 收器准备好接收新数据时就会将 nRTS 变成低电平;当接收寄存器已满时,nRTS 将被设置为高 电平。该引脚只适用于硬件流控制

  • nCTS:清除以发送 (Clear To Send),n 表示低电平有效。如果使能 CTS 流控制,发送器在发送下 一帧数据之前会检测 nCTS 引脚,如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送(也就是下一帧不会被发送)。该引脚只适用于硬件流控制

    | STM32F10x | USART1 | USART2 | USART3 |
    | :———-: | :——: | :——: | :——: |
    | nCTS | PA11 | PA0 | PB13 |
    | nRTS | PA12 | PA1 | PB14 |

数据寄存器DR

USART 数据寄存器 (USART_DR) 只有低 9 位有效,并且第 9 位数据是否有效要取决于 USART 控制寄存器 1(USART_CR1) 的 M 位设置,当 M 位为 0 时表示 8 位数据字长,当 M 位为 1 表示 9 位数据字长,我们一般使用 8 位数据字长。

USART_DR 包含了已发送的数据或者接收到的数据。USART_DR 实际是包含了两个寄存器,一 个专门用于发送的可写 TDR,一个专门用于接收的可读 RDR。当进行发送操作时,往 USART_DR 写入数据会自动存储在 TDR 内;当进行读取操作时,向 USART_DR 读取数据会自动提取 RDR 数据。

TDR 和 RDR 都是介于系统总线和移位寄存器之间。串行通信是一个位一个位传输的,发送时把 TDR 内容转移到发送移位寄存器,然后把移位寄存器数据每一位发送出去,接收时把接收到的每一位顺序保存在接收移位寄存器内然后才转移到 RDR

控制器CR、SR

发送器

USART_CR1分为两部分,一部分为上面一排,用于配置串口的有唤醒单元、中断控制等等,另一部分为图中下面一排,用于控制USART的发送器

  • UE:首先,若要激活该USART,需要先将USART_CR1寄存器上的UE位置位
  • M:M用于定义字长,8or9
  • STOP[1:0] :若要控制帧的停止位长度,可以通过 USART 控制寄存器 2(USART_CR2) 的 STOP[1:0] 位控制
  • TE:当USART_CR1发送使能位 TE 置 1 之后(发送使能),发送器开始会先发送一个空闲帧 (一个数据帧长度的高电平),接下 来就可以往 USART_DR 寄存器写入要发送的数据。
  • TC:在写入最后一个数据后,需要等待 USART 状态寄存器 (USART_SR) 的 TC 位为 1,表示数据传输完成
  • TCIE:如果 USART_CR1 寄存器的 TCIE 位置 1,TC置位后将产生中断
  • TXE:由硬件设置,置位则表明:数据已经从TDR移送到移位寄存器,且TDR已清空,数据发送已经开始,下一个数据可以被写进TDR。
  • TXEIE:如果被设置,TXE的置位会产生一个中断

其他的位不太常用,需要的话再下来查

接收器

  • RE:USART接收使能,使能后开始等待接收数据帧起始位,对于起始位的侦测有一套严格的侦测方法,但是我没怎么看懂/吹口哨,STM32中文参考手册中有详细讲,感兴趣可以去看下
  • RXNE:与接收器的TXE相似,表示当前数据已被读取(包括有关的错误标志),数据已经进入RDR,随时可以被读取
  • RXNEIE:RXNE对应中断

有关一些错误标志我也不整理了,感觉基本比较少遇到

校验控制

将 USART_CR1 寄存器的 PCE 位置 1 就可以启动奇偶校验控制,奇偶校验由硬件自动完成。启 动了奇偶校验控制之后,在发送数据帧时会自动添加校验位,接收数据时自动验证校验位。接收 数据时如果出现奇偶校验位验证失败,会见 USART_SR 寄存器的 PE 位置 1,并可以产生奇偶校验中断。

偶校验PS位为0,奇校验为1

中断事件

中断事件 事件标志位(该位引起中断) 中断使能控制位
发送数据寄存器TDR为空 TXE IXEIE
CTS 标志 CTS CTSIE
发送完成 TC TCIE
准备好读取接收到的数据(接收移位寄存器为空) RXNE RXNEIE
检测到上溢错误 ORE RXNEIE
测到空闲线路 IDLE IDLEIE
奇偶校验错误 PE PEIE
断路标志 LBD LBDIE
多缓冲通信中的噪声标志、上溢错误和帧错误 NF/ORE/FE EIE

IIC协议

I2C 通讯协议 (Inter - Integrated Circuit) 是由 Phiilps 公司开发的,由于它引脚少,硬件实现简单, 可扩展性强,不需要 USART、CAN 等通讯协议的外部收发设备,现在被广泛地使用在系统内多 个集成电路 (IC) 间的通讯

物理层

物理层不是本篇重点,就简单讲一下。

IIC是一个支持多设备的总线,在一个IIC通讯总线中,可以连接多个通讯设备,它们都可以是主机或从机(主机可以简单理解为发送数据的一端,从机则为接收一端)

一个 IIC总线只使用两条总线线路,一条双向串行数据线 (SDA) ,一条串行时钟线 (SCL)。 数据线即用来表示数据,时钟线用于数据收发同步,且每个连接到总线的设备都有一个独立的地址,主机可以利用这个地址进行不同设备之间的访问。

协议层

初始化

在IIC总线空闲(初始)时,SCL和SDA都为高电平

特殊信号

I2C 总线在传送数据过程中共有三种类型信号, 它们分别是:开始信号、结束信号和应答信号

  • 起始信号:SCL 为高电平时,SDA 由高电平向低电平跳变,开始传送数据。

  • 停止信号:SCL 为高电平时,SDA 由低电平向高电平跳变,结束传送数据。

  • 应答信号:接收数据的设备在接收到8位数据后向发送数据的设备发出特定的低电平脉冲,表示已收到数据。应答信号的出现次数与数据长度有关,每接收8位数据后接收端都会向发送端发出一个应答信号

    • 有效应答位:SCL为高电平时,SDA为低电平
    • 非应答位:~,SDA为高电平

    当一个字节按数据位从高位到低位的顺序传输完后,紧接着从设备将拉低SDA线,回传给主设备一个应答位ACK, 此时才认为一个数据帧真正地被传输完成

为确保稳定性,每次电平转换的时间要>4.7us

数据有效性

为了不将刚刚讲到的起始、停止信号误识别为数据内容,IIC协议规定了在数据传输过程中,当SCL=1高电平时,数据线SDA必须保持稳定状态,不允许有电平跳变,而起始、停止信号则刚好是在SCL=1高电平时在SDA上发生电平跳变,这样起始、停止信号就和数据传输很好的区分开了。并且当SCL=1时,数据线SDA的任何电平变换会看做是总线的起始信号或者停止信号而不是数据内容。只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化,因为SCL为低时,SDA的数据是无效的,不读取的。

这样就可以理解为,当SCL=1高电平时为读取SDA也就是数据的过程,每一次SCL的高电平读取1位SDA数据,在SCL=0低电平时,SDA可以改变,为下一次SCL高电平的读取做准备

地址

多数IIC设备的地址为7位或10位,通常位7位。在地址最后还要加上1位的读/写地址,所以设备地址为8位

对于读/写地址位:

  • 0表示主设备向从设备写数据
  • 1表示主设备从从设备读数据

例如,对于手势识别模块PAJ7620,当我们要向模块发送数据时,就要先向该模块的地址发送指定数据内容将其读/写地址位设置为0.次数该设备才可以接收主设备写来的数据

整体流程

  • 主机先产生起始信号
  • 紧接着发送一个设备地址,该设备地址由7位从机地址+1位数据方向位组成,此时方向位为0
  • 主机将地址发送到总线上的过程中,总线上的其他设备会同时将地址逐位与自己的地址相比较,当7位从机地址匹配成功后,根据第八位数据方向位将该设备确定为发送/接收器。
  • 由于地址数据也算作一帧数据,所以在设备匹配成功后,从机将会发送1个应答信号
  • 对于PAJ7620,此时需要对其寄存器进行初始化,初始化成功后从机会返回1个应答信号
  • 此后主机、从机之间便可正常通讯
  • 若要停止该通信,需要主机发送1个停止信号
  • 若要改变数据传输方向,在从机初始化完成后,主机需要再重新产生1个起始信号;之后发送设备地址,此时方向位应为1;最后主机将自身SDA的IO口设置为输入模式;

STM32的IIC硬件

STM32 的 I2C 外设可用作通讯的主机及从机,支持 100Kbit/s 和 400Kbit/s 的速率,支持 7 位、10 位设备地址,支持 DMA 数据传输,并具有数据校验功能

STM32F10x IIC1 IIC2
SCL PB6/PB8(重映射) PB10
SDA PB7/PB9(重映射) PB11

时钟逻辑

STM32 的I2C外设都挂载在APB1总线上,使用APB1的时钟源 PCLK1,具体的SCL时钟计算不细讲了

数据控制逻辑

I2C 的 SDA 信号主要连接到数据移位寄存器上,数据移位寄存器的数据来源及目标是数据寄存器 (DR)、地址寄存器 (OAR)、PEC 寄存器以及 SDA 数据线。

数据寄存器DR

当向外发送数据的时候,数据移位寄存器以DR为数据源,把数据一位一位地通过 SDA 信号线发送出去;当从外部接收数据的时候,数据移位寄存器把 SDA 信号线采样到的数据一位一位地存储到DR中。

帧错误校验计算PEC

若使能了数据校验,接收到的数据会经过 PCE 计算器运算,运算结果存储在“PEC 寄存器”中。

比较器

当 STM32 的 I2C 工作在从机模式的时候,接收到设备地址信号时,数据移位寄存器会把接收到 的地址与 STM32 的自身的“I2C 地址寄存器”的值作比较,以便响应主机的寻址。STM32 的自 身 I2C 地址可通过修改“自身地址寄存器”修改,支持同时使用两个 I2C 设备地址,两个地址分别存储在 OAR1 和 OAR2 中

整体控制逻辑

整体控制逻辑负责协调整个 I2C 外设,控制逻辑的工作模式根据我们配置的“控制寄存器 (CR1/CR2)”的参数而改变。在外设工作时,控制逻辑会根据外设的工作状态修改“状态寄存器 (SR1 和 SR2)”,我们只要读取这些寄存器相关的寄存器位,就可以了解 I2C 的工作状态。除此之外,控制逻辑还根据要求,负责控制产生 I2C 中断信号、DMA 请求及各种 I2C 的通讯信号 (起始、停止、响应信号等)。

硬件通讯过程

发送器

发送流程:

  • 当发生起始信号后,它产生事件“EV5”,并会对 SR1 寄存器的“SB” 位置 1,表示起始信号已经发送
  • 紧接着发送设备地址并等待应答信号,若有从机应答,则产生事件“EV6”及“EV8”,这 时 SR1 寄存器的“ADDR”位及“TXE”位被置 1,ADDR 为 1 表示地址已经发送,TXE 为 1 表示数据寄存器为空
  • 以上步骤正常执行并对 ADDR 位清零后,我们往 I2C 的“数据寄存器 DR”写入要发送的数据,这时 TXE 位会被重置 0,表示数据寄存器非空,I2C 外设通过 SDA 信号线一位位把数据发送 出去后,又会产生“EV8”事件,即 TXE 位被置 1,重复这个过程,就可以发送多个字节数据了
  • 发送数据完成后,控制 I2C 设备产生一个停止信号 (P),这个时候会产生 EV8_2 事件,SR1 的 TXE 位及 BTF 位都被置 1,表示通讯结束

接收器

可以参考发送器,应该可以推测出来

软件IIC和硬件IIC的选择

由硬件处理IIC可以减轻CPU的负担,但是配置和使用起来都较为复杂;软件模拟IIC由于是由CPU控制整个通讯时序和引脚状态,CPU占用会更多,但是相对的使用起来会比较方便,且便于移植。

SPI协议

SPI 协议是由摩托罗拉公司提出的通讯协议 (Serial Peripheral Interface),即串行外围设备接口,是 一种高速全双工的通信总线。它被广泛地使用在 ADC、LCD 等设备与 MCU 间,要求通讯速率 较高的场合。

物理层

SPI使用3条总线和片选线,3 条总线分别为 SCK、MOSI、MISO;片选线(也称为 NSS、CS)用于主机片选设备:

  • 片选信号线:当有多个 SPI 从设备与 SPI 主机相连时,设备的其它信号线 SCK、MOSI 及 MIS O 同时并联到相同的 SPI 总线上,即无论有多少个从设备,都共同只使用这 3 条总线;而每个从设备都有独立的这一条 NSS 信号线,本信号线独占主机的一个引脚,即有多少个从设备,就 有多少条片选信号线。

    当主机要选择从设备时, 把该从设备的 NSS 信号线设置为低电平,该从设备即被选中,即片选有效,接着主机开始与被选中的从设备进行 SPI 通讯。所以 SPI 通讯以 NSS 线置低电平为开始信号,以 NSS 线被拉高作为结束信号。

  • SCK时钟线:用于通讯数据同步。它由通讯主机产生,决定了通讯的速率

  • MOSI:主设备输出/从设备输入引脚。

  • MISO:主设备输入/从设备输出引脚。

需要注意的是,不同于串口中,主机的TX对应从机的RX,SPI中主机和从机的MOSI和MISO是一一对应的

协议层

时钟极性与相位

对于STM32,SPI_CR寄存器中有CPOL和CPHA位,其中CPOL位控制时钟极性,CPHA控制时钟相位。

  • CPOL时钟极性:
    • 置0:SCK在空闲时为低电平
    • 置1:SCK在空闲时为高电平
  • CPHA时钟相位:
    • 置0:将SCK时钟第一边沿用作数据位采样
    • 置1:将SCK时钟第二边沿用作数据采样

因此,基于不同配置的CPOL和CPHA,SPI一共有四种通讯时序:

CPOL \ CPHA 0 1
0 检测上升沿 检测下降沿
1 检测下降沿 检测上升沿

起始信号和停止信号

通讯的开始与结束由片选信号线控制,当主机要选择从设备时, 把该从设备的 NSS 信号线设置为低电平,该从设备即被选中,即片选有效,接着主机开始与被选中的从设备进行 SPI 通讯。所以 SPI 通讯以 NSS 线置低电平为开始信号,以 NSS 线被拉高作为结束信号。

数据位切换

由上图可以观察出,数据位根据时钟配置不同,采样点也不同

  • 当CPHA时钟相位为0时,SCK第一个信号边沿用于采集数据,第二个信号边沿用于切换数据位,为下一次采样做准备
  • 当CPHA时钟相位为1时,SCK第一个信号边沿用于数据位切换,第二个信号边沿用于采集数据

同时,每个数据帧根据寄存器SPI_CR1寄存器的DFF的配置可以配置为8位或16位

STM32的SPI硬件

具体的寄存器每一位的功能请参考STM32SPI协议通信详解_rivencode的博客-CSDN博客_stm32spi通信,讲的很详细,这里我主要讲一下主机模式下的发送流程:

  • NSS片选线产生信号,选择设备

  • 第一次发送时,发送缓冲区为空,因此表示其为空的TXE标志在最开始会一直为高电平

  • 第一次发送时,第一帧数据0xF1在时钟信号产生的同时被存入发送缓冲区中,等待被发出,此时TXE为0,表示发送缓冲区非空

  • 1个时钟周期之后,0xF1从发送缓冲区被移入移位寄存器,同时把第0位数据从移位寄存器发出,此时发送寄存器被清空,TXE硬件置1

    与此同时,从机端也发送了1位数据至接收缓冲区中

  • 在这之后,第二帧数据(不是第二位!)在1个时钟周期后被存入发送缓冲区中等待下一次的转移。发送缓冲区非空,TXE置0

  • 与此同时,每过1个时钟周期,主机端移位寄存器发送出1位数据,接收缓冲区存入1位数据,1帧数据在8个时钟周期后被完全发送出。同时主机也收到1帧完整数据,此时RXNE标志置位,此时第一帧数据正式发送完毕

  • 此后数据从上述第四点开始循环

假如我们使能了 TXE 或 RXNE 中断,TXE 或 RXNE 置 1 时会产生 SPI 中断信号,进入同一个中断服务函数,到 SPI 中断服务程序后,可通过检查寄存器位来了解是哪一个事件,再分别进行处理。也可以使用 DMA 方式来收发接收缓冲区中的数据。

内存相关

博客截止到目前的C++部分都只使用了静态内存和栈内存。

静态内存用于保存局部static对象、类的static数据成员和任何定义在函数之外的变量。

栈内存用于保存定义在函数内的非静态变量。

分配在静态内存和栈内存中的对象都由编译器控制它们的创建和销毁:C++中静态对象在该对象被首次用到时分配内存(C中是在编译期间初始化);栈内存在其定义的程序块运行时分配内存。

除了以上两个内存区,每个程序还拥有一个内存池,这部分被称作自由空间或。堆用于存储动态分配的对象,即由程序本身来控制生存期的对象。

动态内存与智能指针

由于动态内存对象是由程序控制,因此其内存的使用很容易出问题。为了更安全、方便地使用动态内存,标准库提供了两种智能指针,该指针与常规指针的重要区别是智能指针负责自动释放所指向的对象,并且智能指针是模板。两种智能制造的区别在于管理底层指针的方式:

  • shared_ptr类型允许多个指针指向同一个对象
  • unique_ptr则只允许存在一个指针指向一个对象

它们被定义在头文件memory中

1、shared_ptr类

智能指针也是一个模板,类似于vector等类型,对智能指针的初始化也要提供指针指向的类型这一信息。

1
2
shared_ptr<string> p1;
shared_ptr<vector<int>> p2;

智能指针的操作基本于常规指针相同,当然其作为一个模板也有自己特有的操作:

shared_ptr和unique_ptr均支持的操作

p 可用作一个条件判断,若p指向了一个对象则返回true
*p ~
p.get() 返回p中保存的指针
swap(p1,p2)/p1.swap(q) 交换p和q中的指针

make_shared函数

make_shared函数被定义在标准库中,该函数可以在动态内存中分配一个对象并初始化,并返回指向该对象的shared_ptr。

1
shared_ptr<string> sp=make_shared<string>("dhk");

因为该函数会返回一个对应指向类型的shared_prt,因此也可以直接使用auto来保存函数make_shared的结果:

1
auto sp=make_shared<string>("dhk");

另外,make_shared函数支持参数用作构造,其用法于顺序容器的成员emplace相似,参数顺序等需严格遵守构造函数内容,详见C++-顺序容器 | 小董的BLOG (gitee.io)

1
auto sp=make_shared<string>(10,'d');//使用构造

shared_ptr的拷贝和赋值

当在进行拷贝和赋值时,每个shared_ptr都会实时地记录当前有多少个shared_ptr指向相同的对象,称之为引用计数,一但一个shared_ptr的计数器变为0,则会自动释放自己所管理的对象。

当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁这个对象。该操作通过类的一个成员函数——析构函数完成。

如果该shared_ptr是作为局部变量导致被自动销毁,析构函数同样会对引用计数进行递减操作

2、直接管理内存

C++定义了两个运算符来直接分配和释放内存:new用于分配内存,delete用于释放内存

使用new分配动态内存

在堆中的分配的内存是无名的,因此无论是make_shared还是new都无法为分配空间的对象命名,取而代之的是返回一个指向该内存的指针。

几种初始化方式:

1
2
3
4
5
int *p= new int;
auto p= new int;//不定义值
int *p= new int();//进行值默认初始化
int *p= new int(100);
auto p= new vector<int>{1,2,3};

也可以使用new分配const对象:

1
auto p = new const int(100);

需要注意的是,new和delete都是运算符,而不是关键字!因此const new int()是不合法的

如果堆内存耗尽,无法再分配新的内存,则new表达式会失败,抛出一个bad_alloc,如果不想其抛出异常,可以使用定位new的方式阻止其抛出异常:

1
auto p = new(nothrow) int;//此时如果分配失败,则只会返回一个空指针

使用定位new需要包含头文件new

使用delete释放内存

通过delete表达式可以将指定的动态内存释放,使用时需注意:

  • delete的对象必须是一个指针
  • 该指针只能指向一块动态内存或为空,不能指向静态内存或栈内存
  • 动态内存中的const对象可以被正常释放
  • 不能多次释放同一块内存

内置指针的缺陷

上一节中讲到,对于shared_ptr管理的内存,是会根据引用计数自动释放内存的。而通过常规指针保存动态内存的方式无法像智能指针一样自动释放,也就是通过new表达式创建的动态内存空间必须显式地手动释放

因此,使用者必须记得在不使用时删除这块内存,如果指向该内存的常规指针指向改变,这块内存就无法被释放了(因为已经找不到了),所以最好的办法是将该指针定义为常量指针,虽然这样也仅仅能避免地址丢失的发生

混合使用shared_ptr和new

可以使用一个指向动态内存的内置指针来初始化一个智能指针:

1
shared_ptr<int> p1(new int(100));

使用时需注意:

  • 用于初始化智能指针的参数必须是一个指向动态内存的指针

  • 必须用直接初始化的方式初始化智能指针,而不是赋值初始化,因为接受指针为参数的构造函数是explicit的

    1
    shared_ptr<int> p1= new int(100);//错误!必须直接初始化
  • 内置指针不能实现隐式转化为智能指针,这也是不能通过赋值初始化的原因👆

    1
    2
    3
    4
    5
    shared_ptr<int> test(int a)
    {
    return (new int(a));//错误,不能隐式转化
    return shared_ptr<int> (new int(a));//正确
    }
  • 通过这个方法构造的shared_ptr默认使用delete来释放内存,而不是之前讲到的析构函数。因此如果要将该shared_ptr指向其他的动态内存,但是这样做就必须提供自己的操作来代替delete,这个操作后面会讲到。

顺序容器概述

容器:一些特定类型对象的集合。顺序容器提供了控制元素存储和访问顺序的能力,且该顺序与元素的内容无关,而与元素加入容器时的位置相关

标准库中的顺序容器

C++中提供了多种多种顺序容器,这些容器根据下两方面性能的选择都有不同的折中:

  • 向容器中不同位置添加、删除元素的代价
  • 非顺序访问容器中的元素的代价
vector 可变大小数组;支持快速任意元素随机访问;在尾部之外插入元素代价较大
deque 双端队列;支持快速任意元素随机访问;在头、尾部之外插入元素代价较大
list 双向链表;只支持双向顺序访问,不支持随机访问;任何位置插入元素速度都快
forward_list 单向链表,只支持单向顺序访问,其余同双向链表
array 固定大小数组;支持快速任意元素随机访问;不能添加、删除元素
string 字符串,与vector相似,专门用于保存字符;在尾部之外插入元素代价较大

array是C++新标准添加的类型,比内置的数组更加安全、更容易使用

选择顺序容器的原则

  • 通常,使用vector是最好的选择

  • 如果需存放的元素很多、元素很小,并且空间的开销很重要,则不要选择list或forward_list

  • 其余原则根据:

    • 程序是否要随机访问元素
    • 程序是否要在中间、头部、尾部插入元素

    进行选择

  • 如果程序只在读取输入时才需要在中间位置插入元素,随后只需要随机访问元素:

    可以在输入阶段使用list,输入完成后将元素拷贝至一个vector

如果目前不确定使用哪种容器,可以先选定一个容器类型,在程序中只使用vector和list的公共操作:使用迭代器,而不是使用下标。这样后续改变容器类型会很方便

容器通用操作

一般来说,每个容器都定义在对应的头文件中,且文件名与类型名相同

类型别名

每个容器都定义了多个类型,我们可以通过这些类型的类型别名来定义一个变量以供使用,例如:

1
2
vector<int> a{1};
auto i=a.begin();

通常我们使用auto来创建一个容器内的类型对象,因为通常这样更方便,但是同时可以通过类型别名具体地定义这些变量的类型,例如这里的i类型实际上是:vector<int>::iterator这里的iterator就是一个类型别名,所以这里还可以:

1
2
vector<int> a{1};
vector<int>::iterator i=a.begin();

像这样的类型别名还有:

iterator 此容器的迭代器类型
const_iterator 只读迭代器类型
size_type 无符号整数类型,用于保存容器长度,可用于索引
difference_type 带符号整数类型,用于保存两迭代器之间的距离
value_type 容器的元素类型
reference 元素的左值类型
const_reference 只读左值类型

容器定义和初始化

为方便理解,我们先像这样定义容器元素的类型:

1
2
3
4
5
6
7
8
9
struct person
{
person() =default;
person(string s) {name=s;}
person(string s1, string s2) {name=s1;addr=s2;}

string name;
string addr;
};

可以看到,该类有三个默认构造函数,接下来对容器的定义和初始化的元素均是该类型对象。

容器的定义与初始化有以下方式(均已vector为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<person> men;
vector<person> men1(10);
vector<person> men2(men1);

string a("dhk"),b("aca");
vector<person> men3(10,a);
vector<person> men3(10,a);
vector<person> men4(10,{a,b});

vector<person> men5{a,b,a,b};
vector<person> men6{{a,b},{b,a}};

vector<person>::iterator i=men1.begin(), e=men1.end();
vector<person> men7(i,e);

写在前面:有关元素类对象的构造函数生成元素的方法后面还会讲到更好用的办法,因此不建议在初始化容器时就添加元素

  • men为经过默认容器构造函数定义的容器对象,每个容器类型都定义了一个默认的构造函数,除了array容器之外,其他容器的默认构造函数都会创建一个指定类型的空容器。而对于array来说,用C中数组思维理解,由于在创建时长度已经给定,因此array中每个元素都会被初始化

  • men1为创建一个含有10个person类元素的vector容器,这些元素都已经经过初始化,并且全部相同。

  • men2为创建一个vector容器,其含有的元素未men1的拷贝,这就要求men1和men2必须是相同的类型,且元素也为相同的类型。对于array容器而言,长度还必须相等。

  • men3为创建一个含有10个person类元素的vector容器,而这些元素通过person的第二个构造函数进行了初始化,而不是像men1一样进行默认初始化。也就是说,现在men3内有10个name为”dhk”,addr为默认初始化值的相同元素。

  • men4为创建一个含有10个person类元素的vector容器,而这些元素经过person的第三个构造函数进行了初始化。也就是说,现在men3内有10个name为”dhk”,addr为”aca”的相同元素。

    到此可以看出,当使用小括号进行容器初始化时,且当第一个值为一个整数n时,代表初始化n个相同的元素,而第二个参数可以为元素的构造函数参数,但这个参数有且只能有1个!!!,因为这n个元素是相同的,不能在这里对其中两个元素使用不同的构造函数参数,因此,vector<person> men3(10,a,b);是错误的写法!

    而如果当元素的构造函数参数有多个时,只需要像men4一样将这些参数通过大括号括起来即可。

  • men5为创建一个含有4个person类元素的vector容器,这些元素通过person的第二个构造函数进行初始化,分别是name为”dhk”,”aca”,”dhk”,”aca”;addr为默认初始化值的person对象。

  • men6为创建一个含有2个person类元素的vector容器,而这些元素经过person的第三个构造函数进行了初始化,分别是name为”dhk”,addr为”aca”name为”dhk”,addr为”aca”两个元素

  • men7为迭代器i和e指定范围内中元素的拷贝,这些元素类型也需和men7中的元素类型相同

array类型的初始化

array类型由于需指定大小,初始化方式稍微有一点不一样:

1
array<person,10> men={a,b,a};

并且初始化时不能像men6一样使用大括号实现多个构造函数参数初始化

string容器初始化

C++字符串、向量和数组 | 小董的BLOG (gitee.io)

赋值操作

除了一般初始化列表拷贝(不支持array),插入函数之外,这里将介绍assign函数和swap函数

assign

顺序容器中定义了assign成员,该函数允许从一个不同但可以兼容的类型元素赋值(不支持array),例如:

1
2
3
4
list<string> names;
vector<const char*> oldstyle;
names=oldstyle;//错误的,不可以直接将类型不同的容器赋值
names=assign( oldstyle.begin(),oldstyle.end() );

assign会直接替换掉旧的元素,并且assign中使用的迭代器不能指向调用assign的容器

assign也支持输入一个整数和一个元素值:

1
2
list<string> names(1);
name.assign(10,"dhk");//替换为10个相同的指定元素

swap

swap函数的作用是交换两个相同类型容器的元素,但是swap的速度要快于一般的值拷贝,因为其本质的操作并没有改变这些元素的地址,而是改变了两个容器的数据结构,我个人的理解是就好像基于FreeRTOS的列表学习 | 小董的BLOG (gitee.io)中列表与列表项之间的关系,swap只是改变了首尾列表项与对应列表的指针关系;也就是说容器内元素的地址未发生变化,只是用于存储它们所属容器信息的某个变量(应该只有首尾元素)发生了变化。因此,swap操作是在一个常数时间内完成的,因为它跟元素的个数无关

因此,在swap前使用的迭代器、引用、指针在swap后都不会失效,因为元素本身的地址是没有改变的,这点就不同于assign的值拷贝。也就是说我们在使用swap后仍可以使用之前的迭代器、引用和指针,它们依旧指向(绑定)swap前的那个元素,只是这个元素不再属于之前的容器了

需要注意的是:

  • 对于string,调用swap会导致之前的迭代器、指针和引用失效
  • 对于array,两容器长度必须相等。swap会真正地交换元素值,但元素地址还是没有改变的,也就是swap前的迭代器、指针和引用仍然可以使用,只是它指向(绑定)的元素的元素内容发生了变化

关系运算符

所有容器都支持使用关系运算符,但运算对象类型必须一致,运算原则:

  • 长度相等且元素两两对应相等则两个容器相等
  • 如果长度不等,但元素相等(较小容器的所有元素都等于较大容器的元素),则小容器<大容器
  • 如果长度不等且元素不等,则关系取决于第一个不不相等元素的大小关系

需要注意的是,只有当容器元素的类型也定义了相应的运算符时才可以使用关系运算符比较两个容器

顺序容器操作

添加元素

以下函数皆为类成员函数

函数 作用 返回值
push_back(t) 在容器尾部添加一个t元素 void
push_front(t) 在容器头部添加一个t元素 void
insert(p,t) 在迭代器p指向的元素之前添加一个t元素 新元素的迭代器
insert(p,n,t) 插入n个t元素 第一个新元素的迭代器
insert(p,b,e) 在迭代器p之前添加迭代器b到e范围内的元素 第一个新元素的迭代器
insert(p,il) 插入一个il列表,例如{1,2,3} 第一个新元素的迭代器

当容器元素需要通过构造函数创建时,可以使用以下函数直接在容器内存中构造元素:

函数 作用 返回值
emplace_back(arg1,arg2,…) 直接添加一个由元素类构造函数构建的新元素到容器尾部 void
emplace_front(arg1,arg2,…) 直接添加一个由元素类构造函数构建的新元素到容器头部 void
emplace(p,arg1,arg2,…) 直接添加一个由元素类构造函数构建的元素到迭代器p前 新元素的迭代器

从arg1开始的参数要严格符合元素的构造函数参数要求

使用这些函数有几个需要注意的点:

  • array不能使用这些操作
  • forward_list有专用函数,不能使用这些函数
  • forward_list不支持push_back和emplace_back
  • vector和string不支持push_front和emplace_front
  • 向一个vector、string或deque插入元素会使原来的迭代器、指针、引用失效
  • 将元素插入到vector、deque和string中是合法的,但是消耗资源较大

访问元素

以下皆为类成员函数

函数 作用
begin() 返回首元素迭代器
end() 返回尾元素之后位置的迭代器
front() 返回首元素的引用
back() 返回尾元素的引用
c[n] 返回容器c中下标n元素的引用,下标越界会导致运行出错
at(n) 同上,但如果下标越界,会抛出一个out_of_range异常
  • 对一个空容器调用这些函数都属于越界访问
  • at和下标操作只适用于vector、string、deque和array
  • 如果容器是const的,则返回元素的引用也是const的,不可改变

删除元素

函数 作用 返回值
pop_back() 删除尾部元素,若容器为空则函数未定义 void
pop_front() 删除头部元素 void
erase(p) 删除迭代器p处元素,若p为尾后迭代器,则函数未定义 被删元素之后的元素迭代器
erase(b,e) 删除迭代器b和e范围内的元素 最后一个被删元素之后的迭代器
clear() 删除所有元素 void
  • array不能使用这些操作
  • forward_list不支持pop_back()
  • vector和string不支持pop_front()

forward_list的特殊操作

前面讲到,forward_list为单向链表,当我们使用前面的这些函数操作某个元素时,需要找到该元素的前驱,因为无论是添加、删除元素都需要改变前驱的链接。但是单向链表无法通过简单的办法找到一个节点的上一个节点,因此单向链表的实现方式有所不同:通过要操作的元素的上一个元素的迭代器来找到待操作元素

函数 作用 返回值
before_begin() 返回首元素之前的迭代器,该迭代器不可解引用 ~
insert_after(p,t) 在迭代器p插入t 插入的元素的迭代器
insert_after(p,n,t) 在迭代器p插入n个t 最后一个插入的元素的迭代器
insert_after(p,b,e) 在p插入迭代器b和e之间的元素 最后一个插入的元素的迭代器
insert_after(p,il) 在p插入一个花括号列表 最后一个插入的元素的迭代器
emplace_after(p,args1,args2,…) 在p后插入一个由构造函数构造的元素 新元素的迭代器
erase_after(p) 删除p指向的元素之后的那一个元素 被删元素之后的迭代器
erase_after(b,e) 删除从b之后到e之间的元素,即(b,e] 被删元素之后的迭代器

改变容器大小

对应除array之外的容器,其容量都是随时可变的,可以通过添加、删除元素实时改变容器的大小,这里提供了resize函数来可视化管理容器的大小:

函数 作用
resize(n) 将容器大小调整为n
resize(n,t) 任何新添加的元素都初始化为t

对于调整值n,若n小于容器本身大小,则多出的元素被丢弃;若n大于容器大小,新的元素都将进行初始化

改变容器大小可能会因为删除元素导致迭代器失效

管理容器的内存

对于vector和string来说,为了支持快速随机访问,其元素在内存上是连续存储的。

因此,当容器需要扩充容量时,因为元素必须连续纯粹的关系,每次添加扩容的新元素时,容器都会重新分配内存空间以保证所有的元素在内存上都连续存储(此时原来的空间内存就会被释放),这种操作会严重影响性能。

为了减轻这种代价,标准库实现者在容器不得不获取新的内存空间时,会分配比新的空间需求更大的内存空间,多出的空间作为预留备用,可以一定程度避免多次重新分配内存空间。而在实际中,这种策略也确实大大提高了性能

为了方便用户管理这些内存,容器类型中也提供了相关的成员函数:

函数 作用
shrink_to_fit() 删除预留的空间,使用后内存空间与size()一致
capacity() 容器目前在不重新分配空间的前提下最多可以保存多少元素
reserve(n) 为容器分配一个至少能容纳n个元素的空间

根据我的使用经验,当reserve的值超过目前容器实际容量一定值之后,reserve的值会等于capacity。并且,reserve与resize函数最大的区别就是:reserve仅分配内存空间,而resize会将元素进行初始化,但它们都是以元素的个数为单位,而不是字节之类的

  • capacity和reserve只适用于vector和string

额外的string操作

构造string的其他方法

函数 作用
string s(cp,n) s是cp指向的数组前n个字符的拷贝
string s(s2,pos2) s是string变量s2从下标pos2开始的字符的拷贝
string s(s2,pos2,len2) 同上,len的长度可以超过s2长度,此时至多拷贝size-pos2个字符

子字符串操作

函数 作用
substr(pos,n) 返回一个string,返回值同第二个构造函数

关联容器

关联容器和顺序容器有根本的区别,简单来说,关联容器通过关键字来保存、访问元素;而顺序容器是通过元素在容器中的位置来操作元素的。

标准库提供了8个关联容器:

类型 功能
map 关联数组:关键字与值一一对应
set 关键字即值,只保存关键字的容器
multimap 关键字可重复出现,允许多个元素对应一个关键字的map
multiset 关键字可重复出现的set
下方为无序容器 ~
unordered_map 用哈希函数组织的map
unordered_set 用哈希函数组织的set
unordered_multimap ~
unordered_multiset ~

使用它们需包含对应的头文件

关联容器概述

关联容器支持上面讲的容器通用操作,不支持顺序容器操作,且关联容器的迭代器都是双向的

关联容器定义

没什么需要特别讲的,基本可以看明白

  • map:

    1
    map<string, int> test={{"dhk",1},{"aca",2}};
  • set:

    1
    set<int> test={1,2,3,4};

关键字类型要求

对于关键字的类型必须是可以比较的。

关联容器操作

关联容器特有类型别名

  • key_type:容器的关键字类型
  • mapped_type:关键字对应值的类型,只适用于map
  • value_type:对于set,于key_type相同;对于map,为pair<const key_type,mapped_type>

(pair可以简单理解为一个类似小容器的类型,每个对象只允许有两个成员)

关联容器迭代器

对于顺序容器,迭代器的解引用为元素的值;而对于关联容器:

  • set的迭代器解引用对象为key_type

  • map的迭代器解引用对象为一个pair类型,因此:

    1
    2
    3
    4
    map<string,int> test({"dhk",1},{"aca",2});
    auto i=test.begin();
    string a=(*i).first;
    int b=i->second;

    需要注意的是,关联容器的关键字是const的,不可修改

    同时,对于关联容器,begin函数返回的不一定是初始化时的第一个元素,而是经过关键字比较过后最小的元素

插入元素

对于map,以下几种方式都可以

1
2
3
4
test.insert({"dhk",1});
test.insert(makepair("dhk",1));//makepair为现场构造一个pair
test.insert(pair<string,int>("dhk",1));
test.insert(map<string,int>::value_type("dhk",1));

同时也可以使用emplace函数,同顺序容器

删除元素

使用erase函数,参数可以为关键字或迭代器

map的下标操作

1
2
3
map<string,int> test({"dhk",1});
map["dhk"]=2;
map["aca"]=2;

map的下标接受一个关键字,会获取该关键字关联的值

如果容器中未存在该关键字,则会创建一个新的元素插入容器中,因此下标操作只能对非const容器使用

通用访问操作

函数 功能 返回值
find(k) 访问容器中第一次出现的关键字为k的元素 该元素迭代器
count(k) 关键字为k的元素数量 数量
lower_bound(k) 访问第一次出现的关键字≤k的元素 该元素迭代器
upper_bound(k) 访问第一次出现的关键字>k的元素 该元素迭代器
equal_range(k) 关键字为k的所有元素的范围 一个pair,两个成员均为迭代器

对于允许关键字重复的容器,关键字相同的元素会在容器中相邻存储,也就是可以通过迭代器递增的方式依次访问

这些操作与下标操作不同的是不能创建新元素

单词转换程序

根据书上的思路写了一个简化版的拼音转汉字程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include<iostream>
#include<fstream>
#include<sstream>
#include<string>
#include<vector>
#include<array>
#include<unordered_map>
#include<map>
using namespace std;

/*将文件流绑定的文件数据导入到本程序的map中,文件数据格式如下*/
/*拼音 对应汉字*/
map<string,string> bulidmap(ifstream &map_file)//返回值为一个map<string,string>
{
map<string,string> the_map;
string key,value;
while(map_file>>key && getline(map_file,value))
//将拼音存入key变量中,该行剩下的内容存入value中
{
//如果value有值,则删除开头的空格
if(value.size()>1) the_map[key]=value.substr(1);
}
return the_map;
}


int main()
{
ifstream mapfile;//创建一个读取文件流用于读取文件
stringstream str("cy shi wo die");//字符串流用于程序输入
mapfile.open("c++\\ass_container\\map.txt");
auto trans_map=bulidmap(mapfile);//将文件流内容导入新创建的map类型变量中
auto end=trans_map.end();//指向map末端之后的一个迭代器
string s,s1;
while(str>>s)
{
auto i= trans_map.find(s);//将字符串流中的每一个单词取出,在map中寻找有无对应的key
if (i !=end)//如果未寻找到,迭代器i会等于end
{
s=i->second;//用寻找到的key对应的value(拼音对应汉字)覆盖s中原有的拼音
}
s1=s1+s;//s1用于存储全部的转换结果
}
cout<<s1<<endl;
system("pause");
return 0;
}
  • 21行:字符串删除函数,可以删除字符串中指定数量的字符
  • 38行:find函数如果未找到对应关键字则会返回队尾之后的迭代器

无序容器

无序容器也属于关联容器的一种,但无序容器不是使用比较关键字的方式来组织元素顺序,而是通过一个哈希函数算法进行组织元素,简单地来说,该算法就是用比普通关联容器组织方式代价更小的方式进行元素组织,通常用于关键字没有明显的序关系等情况下使用,也许可以节省性能的占用

类型 功能
unordered_map 用哈希函数组织的map
unordered_set 用哈希函数组织的set
unordered_multimap ~
unordered_multiset ~

无序容器的管理措施

无序容器在存储上组织为一组“桶”,哈希函数会计算每个元素的哈希值,具有相同哈希值的元素会被存入一个桶中,对于桶的维护,标准库也给出了一系列函数,但是我觉得用处不大,就先不写在这里了

无序容器对关键字类型的要求

无序容器中有一个hash类型用于存储哈希值,内置类型和string等标准库类型均定义了hash类型,均可以作为关键字。不能作为无序容器关键字的是另一个自定义的无序容器,原因与模板有关。