# 1. ADC ## 1.1. ADC基本原理 什么是ADC(Analog-to-Digital Converter)?模拟信号转换为数字信号通过测量模拟信号的电压,把电压值转换成数字进行后续的操作。 ADC的本质是通过测量电压值来获得被测信号的电压幅值,并保存成一个数值。通过这种机制,不仅可以测电压,还可以测电阻、电容、电感、压力、温度等,只要这些被测量通过一个传感器可以和电压之间产生某种函数关系。 对电压进行测量后保存到的有效数据的位数被叫做精度,例如8位精度可以有256个值,10位精度的有1024个值;该MCU的ADC精度是12位的,有4096个值; 另外,ADC中还有个重要的概念叫做参考电压,也就是测量电压的基准。在该芯片中,内部基准电压叫做 Vrefint,经查阅资料应该是2.048V,也就是说ADC最高能检测的电压是2048V。如果按照12位精度采样,那得到的分辨率是0.0005V。 > 问题:如果电压高于2.048V应该如何进行检测? ![image-20241013171356277](./img/image-20241013171356277.png) 以上是ADC的内部框图,我们关注主要的是Vref、GPIO、ADC中断、DMA请求、ADCCLK。目前我们使用内部基准,通过GPIO采集电压,然后使用轮询模式读取电压值。其他方式还包括中断方式和DMA方式。 开发板内部有两个ADC的测试: ![image-20241013171711301](./img/image-20241013171711301.png) 一个是温度热电偶,另外一个是电位器。具体使用我们在后面说明。 扩展阅读: [ADC原理](https://www.bilibili.com/video/BV1BV4y1V7nE/?spm_id_from=333.337.search-card.all.click&vd_source=3c8e333d6657680a469ddf0238f01d6a) ## 1.2. PCM PCM是一种通过波形幅值测量和对信号进行数字化的一种方案: ![image-20241013172118023](./img/image-20241013172118023.png) [PCM视频讲解](https://www.bilibili.com/video/BV1yb4y1M7a2/?spm_id_from=333.337.search-card.all.click&vd_source=3c8e333d6657680a469ddf0238f01d6a) 模拟信号通过间隔时间采样得到每个点的幅值,然后进行保存和处理。计算机中常见的wav格式就是PCM采样后的文件。衡量PCM的指标有两个:采样率和精度(ADC精度)。一般采样率越高越好,信号的失真越小,但是存储的空间越大。一般来说,采样率不能低于最高频率的两倍,因此以前常见的音频采样率是44k(声音最高20k),目前还有96k以上的高保真。 ## 1.3. 实例NTC温度测试 任务描述:通过NTC测量环境温度,并通过串口打印出来。 NTC 又叫热敏电阻,温度的变化会改变其电阻值。通过这一特性,结合ADC就可以测量温度。 开发版的NTC是如下电路: ![image-20241013183636425](./img/image-20241013183636425.png) 是接在PA4上面的一个电阻,通过10K的R6形成分压,等效电路如下: ![alt text](img/ntc.drawio.png) 当NTC的电阻值变化的时候,PA4这个分压点检测到的电压会随之变化。当对这个电压进行ADC转换后,可以反向计算NTC的电阻值,然后根据NTC的电阻和温度的关系可以计算出温度。 NTC是附件,与开发板这样连接: ![ad78bbf910c2747701476e350990a89](./img/ad78bbf910c2747701476e350990a89.jpg) ### 1.3.1. 配置 在引脚配置中,选择PA4,然后选择ADC1_IN4; ![image-20241013185308417](./img/image-20241013185308417.png) 设置ADC参数: ![image-20241013191944257](./img/image-20241013191944257.png) Continuous Conversion Mode设为Enable,使ADC转换持续进行,不需要每次获取之前手动触发转换;ADC_Regular_ConversionMode -> Rank -> Sampling Time设为239.5 Cycles,最长采样时间,可以获得更稳定的转换结果。 打开串口2 在菜单:project Properties -> C/C++ Build -> Settings -> Tool Settings -> MCU Settings,勾选Use float with printf ... ![image-20241013192823116](./img/image-20241013192823116.png) 这样printf就可以打印小数。 ### 1.3.2. 代码编写 在 inc 目录新建一个头文件 NTC.h,因为后面还会使用到NTC的一些功能; ```h #ifndef INC_NTC_H_ #define INC_NTC_H_ #include "main.h" #include "math.h" extern float NTC_Temperature; float ADC2Resistance(uint32_t adc_value); float resistance2Temperature(float R1); #endif /* INC_NTC_H_ */ ``` 在 src 目录新建 NTC.c 文件,这个文件是NTC转换成温度的一些函数。 ```c /** * @brief 通过ADC值计算NTC电阻 * * @param adc_value ADC原始值 [0, 4095] * @retval 返回NTC电阻值,浮点数类型,单位Ω */ #include "NTC.h" float NTC_Temperature; float ADC2Resistance(uint32_t adc_value) { return (adc_value / (4096.0f - adc_value)) * 10000.0f; } /** * @brief 通过NTC电阻反推温度 * * @param R1 NTC电阻值 * @retval 返回温度,float类型,单位摄氏度 */ float resistance2Temperature(float R1) { float B = 3950.0f; float R2 = 10000.0f; float T2 = 25.0f; return (1.0 / ((1.0 / B) * log(R1 / R2) + (1.0 / (T2 + 273.15))) - 273.15); } ``` 以下是 main.c文件的内容: 在 USER CODE BEGIN Includes 代码段引入头文件 ```c /* USER CODE BEGIN Includes */ #include #include #include "NTC.h" /* USER CODE END Includes */ ``` 在 USER CODE BEGIN 2 代码段加入如下内容: ```c /* USER CODE BEGIN 2 */ char send_buf[50] = { 0 }; uint16_t adc_result = 0; float ntc_resistance = 0.0f; float temperature = 0.0f; HAL_ADC_Start(&hadc1); // 开始连续ADC转换 HAL_Delay(500); // 等待ADC稳定 /* USER CODE END 2 */ ``` 完善主函数的循环: ```c /* USER CODE BEGIN WHILE */ while (1) { adc_result = HAL_ADC_GetValue(&hadc1); // 获取ADC值 ntc_resistance = ADC2Resistance(adc_result); // 获取ADC值 temperature = resistance2Temperature(ntc_resistance); // 获取ADC值 sprintf(send_buf, "阻值%.1f Ω,温度: %.2f ℃\r\n", ntc_resistance, temperature); // 将变量打印为字符串 HAL_UART_Transmit(&huart2, (uint8_t*) send_buf, strlen(send_buf), 10); // 通过串口2发送 HAL_Delay(1000); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */ ``` ### 1.3.3. 代码分析 # 2. I2C [I2C 温湿度传感器](https://www.bilibili.com/video/BV1QN411D7ak/?spm_id_from=333.788&vd_source=3c8e333d6657680a469ddf0238f01d6a) ## 2.1. 设置 1. 先新建一个工程,取名为 humidity; 2. 设置调试模式为 Serial Wire; 3. 打开串口2; 4. 查看开发板19号外设,使用PB6/PB7; ![image-20241015151124859](./img/image-20241015151124859.png) 在引脚设置中点击PB6,看到使用的是I2C1; ![image-20241015151052757](./img/image-20241015151052757.png) 5. 在Connectivity中找到I2C1,设置I2C模式为 I2C; ![image-20241015151355494](./img/image-20241015151355494.png) 6. 在Project Manager中设置为每个外设使用独立的 c/.h 文件: ![image-20241015151501806](./img/image-20241015151501806.png) 7. 按照上个例子设置printf使用浮点; ## 2.2. 编码 inc目录建立一个新文件aht20.h ```h #ifndef __DHT20_H__ #define __DHT20_H__ #include "i2c.h" // 初始化AHT20 void AHT20_Init(void); // 获取温度和湿度 void AHT20_Read(); #endif ``` src 目录建立一个新文件 aht20.c ```c #include #define AHT20_ADDRESS 0x70 /** * 查看状态字是否校准 */ void AHT20_Init(void) { uint8_t read_buf = 0; // 等待DHT20上电稳定 HAL_Delay(40); HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &read_buf, 1, 10); if ((read_buf & 0x08) != 0x08) { // 状态字没有校准,发送出初始化命令 uint8_t send_buf[3] = { 0xBE, 0x08, 0x00 }; HAL_I2C_Master_Transmit_DMA(&hi2c1, AHT20_ADDRESS, send_buf, 3); HAL_Delay(100); } } /** * */ void AHT20_Read(float *temp, float *hum) { uint8_t sendBuffer[3] = { 0xAC, 0x33, 0x00 }; uint8_t readBuffer[6]; HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY); HAL_Delay(75); HAL_I2C_Master_Receive(& hi2c1, AHT20_ADDRESS, readBuffer, 6, HAL_MAX_DELAY); if ((readBuffer[0] & 0x80) == 0x00) { uint32_t data = 0; data = ((uint32_t) readBuffer[3] >> 4) + ((uint32_t) readBuffer[2] << 4) + ((uint32_t) readBuffer[1] << 12); *hum = data * 100.0f / (1 << 20); data = (((uint32_t) readBuffer[3] & 0x0F) << 16) + (((uint32_t) readBuffer[4]) << 8) + (uint32_t) readBuffer[5]; *temp = data * 200.0f / (1 << 20) - 50; } } ``` main.c 文件的 USER CODE BEGIN Includes 区域添加头文件: ```c /* USER CODE BEGIN Includes */ #include "aht20.h" #include #include /* USER CODE END Includes */ ``` main函数的 USER CODE BEGIN 2 区域添加内容 ```c /* USER CODE BEGIN 2 */ AHT20_Init(); float temp, hum; char message[50]; /* USER CODE END 2 */ ``` 完善 main 函数的 while 循环: ```c while (1) { AHT20_Read(&temp, &hum); sprintf(message, "温度:%.1f 湿度:%.1f \n", temp, hum); HAL_UART_Transmit(&huart2, (uint8_t*) message, strlen(message), 100); HAL_Delay(2000); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } ``` ## 2.3. 代码分析 ### 2.3.1. I2C的地址 在启动传输后,随后传输的I2C首字节包括7位的I2C设备地址 0x38和一个SDA方向位 x(读R:‘1’,写W:‘0’)。 ![image-20241015145204114](./img/image-20241015145204114.png) 因此,如果是读:实际地址是 0x71;如果是写,实际地址是0x70;注意代码中关于I2C的读写命令; HAL_I2C_Master_Transmit和HAL_I2C_Master_Receive 的地址都使用0x70,HAL_I2C_Master_Transmit会自动设置最后一位是0(仍然是0x70);HAL_I2C_Master_Receive会自动设置最后一位是1(就是0x71) ### 2.3.2. 温湿度数据的重组 按照AHT20的说明(p8),返回六个字节的顺序: ![image-20241015162601769](./img/image-20241015162601769.png) ![image-20241015162613515](./img/image-20241015162613515.png) ![alt text](img/aht20_data.drawio.png) ## 2.4. I2C的中断和DMA [中断模式和DMA](https://www.bilibili.com/video/BV1AN41127VL?spm_id_from=333.788.videopod.sections&vd_source=3c8e333d6657680a469ddf0238f01d6a) # 3. OLED显示 [视频演示](https://www.bilibili.com/video/BV19u4y197df?spm_id_from=333.788.videopod.sections&vd_source=3c8e333d6657680a469ddf0238f01d6a) 开发板上的OLED显示器是单色的显示,分辨率为 128 * 64。 ![image-20241021082624862](./img/image-20241021082624862.png) 相当于有128 * 64个小灯,只需要点亮相应的小灯就可以组成图案。如果使用IO进行操作,需要 128 * 64 个IO口(8192个),显然是不合适的。如何解决? ![alt text](img/显示驱动.drawio.png) 其实显示器的矩阵可以看作是一段连续的内存,每一个灯就是一个bit(单色模式,如果是彩色显示器,每个灯可能是一个byte或者是多个byte)。和内存一样,显示器内部也有一个存储区域(寄存器),和点阵是对应的,我们只需要对显示器的控制芯片寄存器的相应位置写入相应的数据,显示器对应点就会点亮或者熄灭。这种方式就如同写寄存器一样。 因此,显示驱动芯片其实有很多IO与显示器进行连接,然后驱动芯片与外部有个通信接口(这里是I2C);通过接口,MCU告诉显示器需要如何显示就好了。这样避免了MCU使用很多IO直接控制显示器。另外这里的通信是I2C,但是还有其他的方式,例如串口、SPI、VGA、HDMI等。 ## 3.1. 显示原理 开发板使用SSD1306作为驱动,[显示器资料](../../配套模块资料/OLED显示屏/1.3-ZJY130-2864KSWLG01.pdf),[CH1116芯片资料](<../../配套模块资料/OLED显示屏/CH1116 V0.2.pdf>) ![image-20241021083759719](./img/image-20241021083759719.png) 分成了8个Page(大行),每个Page中有8个小行,一共就是64行,刚好是纵向的分辨率;然后,每行有128个寄存器,表示列,这样就得到128*64的一组寄存器。这个和内存中的分页技术是一致的。 ### 3.1.1. 通信方式 通信分为两种,指令和数据,指令包含两个字节,数据没有限制。指令表示要从哪个位置开始写数据;数据代表写入什么数据。指令包含页的设置和列的设置两个部分。 每个指令都包含两个字节,第一个字节0x00;第二个字节表示页/列的设置。指令选择页和选择列,但后续只有一个字节了,这个后续的一个字节需要携带页还是列?具体是哪个页和列;因此第二字节可以拆分成为高4位和低4位进行表示,其中高4位表示是什么(页、列),低4位表示具体的参数(哪个页、哪个列)。 #### 3.1.1.1. 指令 ![image-20241021090542877](./img/image-20241021090542877.png) 1. 页地址:B0~B7;其实这里的B就代表设置页,地4位的0~7表示哪个页,一共只有8页; 2. 列地址,把列分为两个字节发送,分为高4位和低四位。低位先发,高位后发(有点像小端模式);高4为分别为0和1,表示表示列的低4位和高4位;因为一共128列,因此设置列需要发送两次指令; 例如选择页,使用Bx的方式,高4位B表示设置页的命令,低四位X可以是0~7表示具体是哪一页。这样一个完整的设置页的命令就是0x00,0xb0:选择第0页;0x00,0xb1:选择第1页;因为一共只有8页,4位的取值范围是0~15,完全覆盖8页的取值范围,这样是没有问题的。但是如果选择列,列的取值是0~127,这样使用一条命令是不够的。 在设置列的时候,参数字节的高4位是0表示设置列的低四位;参数字节的高四位是1表示设置列的高四位;00-00:设置低四位00-10:设置高四位这样列就是00列。注意:驱动的第0页其实是从02开始的,因此需要发送(0x0002,0x0010)来选择第0列。 #### 3.1.1.2. 数据 ![image-20241021090849503](./img/image-20241021090849503.png) 数据是 0x40 后携带N个字节,表示从指令设置的页和列开始连续的设置相应的列。 ## 3.2. 显示驱动 在内存中建立一个数组(存储空间)与显存的空间进行对应。当要画图的时候,先在内存中写数据,然后把内存中的数据全部写入到显示器(显存)。这样做有很多好处方便,操作内存比操作显存更方便。但是这样做也有缺陷: 问题:什么缺陷? OLED_NewFrame 函数用于把内存中一个虚拟屏幕清空,这样很快。OLED_ShowFram用于将内存中的一个虚拟屏幕的内容发送到显示器的显存当中,这样就可以显示了。 视频演示有个地方错了,关于画点的函数(仔细看看)。 ### 3.2.1. 如何显示文字和图片 ![image-20241021091927109](./img/image-20241021091927109.png) 显示器和图片都是以点来存放图片或者文字的。如果把上面的文字转换成点阵就可以在点阵的显示器上显示了。如何做到? 任何文字或者图片都可以使用点阵进行描述: ![image-20241021091942446](./img/image-20241021091942446.png) ### 3.2.2. 如何快速的进行显示 1. 抽象基本的功能:画点,划线、画圆。。。 2. 字库:通过ASCII码(其实是一个整数)对应到一个数组(点阵),然后使用画点的方式画出文字。英文处理相对简单,中文处理需要更大的存储空间。 [取模工具](https://led.baud-dance.com/)