在最近的一个项目上,用到STM32F103C8T6(MCU)进行双路模拟采样。来自电压接入点有两路0~10V模拟电压,当上位机查询时,MCU获取当前的采样值,然后封装在串口报文中发送到上位机。硬件电路上使用了10V到1.5V的降压电路,降压后接入到一片双路运放LM358,采用电压跟随器的方式输入到MCU的PA0和PA1。MCU进行轮询采样,当接收到上位机的查询指令后,获取当前的采样值,然后编制应答报文,通过串口发送给上位机。
使用STM32F103C8T6进行串口通信和模拟量采样是“白菜”应用了。我的这个设计,有以下几个新奇的特点,现在分享出来,供各位品尝。
- STM32F103C8T6采用了最小系统(最小,是的,最小最小——三遍啦 ),除了3.3V供电和复位电路以外(复位电路在生产版本上也要空贴!),只连接了PA0/1(模拟量的输入管脚),PA9/10 (用作UART1)和PA13/14(用作SWD的DIO和CLK,烧录代码用的),没有外部8MHz时钟,没有32.768KHz的RTC。真真的最小系统!
- 使用STM32CubeIDE开发环境而不是Keil uVersion。Keil MDK的参考样例遍地都是,但使用CubeIDE的还不多,并且这两种开发环境的文件组织形式也有不少差别。
- 实时性要求挺高,要支持每秒读取10次以上,然后取带有遗忘因子的平均值。
这些要求引起了我的兴趣。以往做STM32F103都是相当豪华的配置,现在要用STM32CubeIDE在最最小系统实现这些功能,还是第一次。
使用STM32CubeIDE配置MCU
STM32CubeIDE V1.14.0
使用的STMCubeIDE的版本是Version 1.14.0,这是最新的版本。1.14.0和1.13.x比较起来,使用的差异不大,最大的区别就是1.14.0中把所有的初始化代码都放在了main.c中,而1.13.x版本则将adc,uart,it等分别生成各自的.h和.c。这两种方式各有千秋,适应了也就好了。
Pinout
根据硬件设计要求,在proj_xxx.ioc中对MCU进行如下图的配置。启用ADC1中的Channel0 (PA0)和Channel1(PA1);启用USART1(PA9-TX,PA10-RX);PA13和PA14是SWD接口;PC13作为调试用,生产版本中用不到。没有HSE,也没有LSE。
关闭外部时钟
PC13作为调试LED管脚
SWD使用SysTick
ADC1双通道,DMA方式采样
使用中断的UART1
进行上述配置后,CTRL+S保存,CudeIDE自动生成初始化代码。
Clock Configuration
因为没有HSE和LSE,因此在PLLMul处设置了可用的最大倍频x9,再大的话APB2会超限。
使用串口中断接收多字节的查询指令
重定向printf
因为要用到printf和字符串函数,所以在 /* USER CODE BEGIN Includes */ 一节中增加 stdio.h 和string.h 两个头文件。
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
在 /* USER CODE BEGIN PD */ 一节中完成对 printf 重定向到 UART1。
/* USER CODE BEGIN PD */
/* Redirect printf to UART1 */
#ifdef __GNUC__
/* With GCC, small printf (option LD Linker->Libraries->Small printf
set to 'Yes') calls __io_putchar() */
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#endif /* __GNUC__ */
PUTCHAR_PROTOTYPE {
HAL_UART_Transmit(&huart1, (uint8_t*) &ch, 1, 0xFFFF);
return ch;
}
/* USER CODE END PD */
准备好UART1的接收和ADC采样
在 /* USER CODE BEGIN 2 */ 一节中加入如下代码。其中
- HAL_UARTEx_ReceiveToIdle_IT(&huart1, cmdBuf, 12);
命令 MCU 开始以中断的方式接收指令。根据应用需求,来自上位机的查询指令是12字节,因此函数的最后一个参数是12。这里采用了 HAL_UARTEx_ReceiveToIdle_IT 函数而没有使用HAL_UART_Receive_IT 函数是为了避免使用后者会因为接收多字节指令时频繁中断可能会造成死机。
- HAL_ADCEx_Calibration_Start(&hadc1);
命令是对ADC1采样电路进行校准。如果不进行校准,采样精度是无法满足要求的。
- HAL_ADC_Start_DMA(&hadc1, (uint32_t*) adValue, 2);
命令开始使用DMA的采样,采样所用的ADC是ADC1,adValue的定义是
uint32_t adValue[2] = { 0, 0 };
adValue的定义放在 /* USER CODE BEGIN PV */ 一节中。
/* USER CODE BEGIN 2 */
HAL_UARTEx_ReceiveToIdle_IT(&huart1, cmdBuf, 12);
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*) adValue, 2);
printf("STM32 is ready.\r\n");
/* USER CODE END 2 */
UART1 的中断回调函数
在 /* USER CODE BEGIN 4 */ 一节中重定义 HAL_UARTEx_RxEventCallback 函数,如下所示。
/* USER CODE BEGIN 4 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance != USART1)
return;
cmdPkt = (mnAnaPacket_t*) cmdBuf;
#if(0)
printf("Received byte sequence: ");
for (int i = 0; i < sizeof(cmdBuf); i++) {
printf("%02X ", cmdBuf[i]);
}
printf("\r\n");
printf("Received packet.\r\n");
printf(" Preamble: %08lX\r\n", cmdPkt->preamble);
printf(" Len: %02X\r\n", cmdPkt->len);
printf(" Code: %02X\r\n", cmdPkt->code);
printf(" CRC: %04X\r\n", cmdPkt->crc);
printf(" Terminator: %08lX\r\n", cmdPkt->terminator);
#endif
do {
if (cmdPkt->preamble != 0xF0F0F0F0)
break;
if (cmdPkt->terminator != 0xFFFFFFFF)
break;
if (cmdPkt->len != 0x07)
break;
if (cmdPkt->code != 0x01)
break;
if (cmdPkt->crc != 0x00C8)
break;
#if(0)
printf("Channel 1: %u, Channel 2: %u\r\n", (uint16_t) adValue[0],
(uint16_t) adValue[1]);
#endif
#if(0)
HAL_ADC_PollForConversion(&hadc1, 0xFFFF);
while (!(HAL_IS_BIT_SET(HAL_ADC_GetState(&hadc1), HAL_ADC_STATE_REG_EOC)))
;
for (int i = 0; i < 2; i++) {
adValue[i] = HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);
printf("Channel 1: %u, Channel 2: %u\r\n", (uint16_t) adValue[0],
(uint16_t) adValue[1]);
#endif
indication.preamble = 0xF0F0F0F0;
indication.len = 0x0F;
indication.code = 0x02;
indication.adch0 = adValue[0];
indication.adch1 = adValue[1];
indication.terminator = 0xFFFFFFFF;
indication.crc = 0;
indication.crc += (indication.preamble & 0x000000FF);
indication.crc += (indication.preamble & 0x0000FF00) >> 8;
indication.crc += (indication.preamble & 0x00FF0000) >> 16;
indication.crc += (indication.preamble & 0xFF000000) >> 24;
indication.crc += indication.len;
indication.crc += indication.code;
indication.crc += (indication.adch0 & 0x000000FF);
indication.crc += (indication.adch0 & 0x0000FF00) >> 8;
indication.crc += (indication.adch0 & 0x00FF0000) >> 16;
indication.crc += (indication.adch0 & 0xFF000000) >> 24;
indication.crc += (indication.adch1 & 0x000000FF);
indication.crc += (indication.adch1 & 0x0000FF00) >> 8;
indication.crc += (indication.adch1 & 0x00FF0000) >> 16;
indication.crc += (indication.adch1 & 0xFF000000) >> 24;
indication.crc &= 0x00FF;
#if(0)
uint8_t txBuf[20] = { 0 };
uint8_t *p8 = NULL;
uint16_t *p16 = NULL;
uint32_t *p32 = NULL;
p32 = (uint32_t*) txBuf;
*(p32++) = indication.preamble;
p8 = (uint8_t*) p32;
*(p8++) = indication.len;
*(p8++) = indication.code;
p32 = (uint32_t*) p8;
*(p32++) = indication.adch0;
*(p32++) = indication.adch1;
p16 = (uint16_t*) p32;
*(p16++) = indication.crc;
p32 = (uint32_t*) p16;
*p32 = indication.terminator;
HAL_UART_Transmit(&huart1, txBuf, sizeof(txBuf), 10);
printf("\r\n");
for(int i=0; i<20; i++){
printf("%02X ", txBuf[i]);
}
printf("\r\n");
#endif
printf("%08lX%02X%02X%08lX%08lX%04X%08lX\r\n", indication.preamble,
indication.len, indication.code, indication.adch0,
indication.adch1, indication.crc, indication.terminator);
} while (0);
memset(cmdBuf, 0, sizeof(cmdBuf));
HAL_UARTEx_ReceiveToIdle_IT(&huart1, cmdBuf, 12);
}
/* USER CODE END 4 */
HAL_UARTEx_RxEventCallback 在自动生成的代码中是 weak 类型的,需要在应用中重新定义。代码中,将接收到的 cmdBuf (字节流)进行强制类型转换,得到指令报文 cmdPkt,其它是应用的逻辑处理,可以不用关心。
在业务处理完成后,重新开启 HAL_UARTEx_ReceiveToIdle_IT 。
需要注意的是,在 HAL_UARTEx_RxEventCallback 回调函数中并不需要调用 HAL_ADC_PollForConversion 和 HAL_ADC_GetState 去获取采用值,只需要直接向应答结构体(indication)中直接填充 adch0 和 adch1 即可。而这两个值的更新是由 ADC 自动由 DMA 完成的。
使用DMA的多路轮询式ADC
使用 DMA 的 ADC 后的编码量非常小,只需要在系统初始化之后调用一次
HAL_ADC_Start_DMA(&hadc1, (uint32_t*) adValue, 2);
函数即可。注意到在用 CubeIDE 的 Pinout & Configuration -> ADC1 -> Parameter Settings 中,把 Scan Conversion Mode 和 Continuous Conversion Mode 都要设置成 Enabled。在这种配置下,ADC 会自动地、连续地进行采样,并通过 DMA 直接将采样结果更新。
在 ADC 的 DMA 设置中,Data Width 均设置为 Word,因此 adValue[2] 定义为uint32_t。采样结果更新时,返回两个 uint32_t 的数,第一个是 Rank 0 的采样值,第二是 Rank 1 的采样值。
ADC1 的采样时间(Sampling Time)应设置得大一些(本例中使用了 13.5 cycles),采样时间越长精度越高,但更新较慢;采样时间越短,精度越低,但更新较快。应通过尝试确定一个合适的采样时间。使用 DMA 传输,即使在较慢的总线上,也不会对 CPU 造成占用。
采用上述配置,可以在需要使用时随时获取符合精度要求的采样值,然后经过换算,就可以使用了。
本次开发获得的几个体会
- 使用 STM32CubeIDE,可以自动生成系统初始化代码。这一点比 Keil uVersion 要强大很多。
- STM32CubeIDE 不能烧写仿制芯片,但是 Keil uVersion 可以。这个限制挺严格的。对于非STM32芯片是否可以通过导入芯片 pack 用得起来,还有待验证。
- 即使不使用 HSE 和 LSE,UART 逼近 115200 的波特率是没问题的。代码最终将 UART1 的波特率设定为 9600,辅助测试中使用 115200 的波特率也可以长时间正常交换128字节长的报文。
- UART 使用中断接收多字节的字节流时,要使用 HAL_UARTEx_ReceiveToIdle_IT 而不要使用 HA_Receive_IT 函数。使用 HAL_UARTEx_ReceiveToIdle_IT 时,在 HAL_UARTEx_RxEventCallback 回调函数中进行接收数据的业务逻辑处理。需要注意的是,HAL_UARTEx_RxEventCallback 函数是中断服务的一部分,应避免使用可能导致中断服务程序挂起的操作,例如 HAL_Delay() 函数(老话说得好:带有 xxDelay 的程序不是好程序)。
- 除非业务逻辑必须,应尽量避免使用 printf() 函数。printf() 函数本质上是一种阻塞式串口操作,对于小资源的 MCU 而言,是比较耗费CPU资源的。
- 使用 HAL_ADC_Start_DMA() 进行连续的轮询式的 ADC 是一种有效的实时采样策略,对 CPU 的耗费几乎不可觉察,编码量极小。
- 经过自动校准后,采用13.5 cycle 采样时间的 ADC,在较慢的 APB2 Prescaler 之后(本例中 ADC1/2 的采样时钟只有 6MHz),采样误差可保持在 +/-0.5% 以内。
- 小资源的 MCU 编码,一定要注意代码的节俭。开发过程中遇到的死机和跑飞现象,都是由代码重复引起的内存不足引起的。需要多使用全局变量,以便在中断处理程序—各个业务逻辑程序中使用,采用过多的局部变量会增大对“栈”的需求,一不小心就会出现栈溢出,导致程序死机。