使用STM32搭建蓝牙图片显示终端和温湿度计

最近正在入门stm32开发,进行一个简单的蓝牙图片显示终端和温湿度计搭建,在这期间碰到了不少有意思的事情,遂分享之。(进入大学之后,可支配的时间比高中多了不少,我也有更多的精力可以放在自己的爱好上了。)

1 软硬件概况

  • 主控:STM32f103c8t6, 72MHz, ARM-Cortex M3内核,使用一块32.768的晶振作为HSE
  • 片上系统:FreeRTOS v11
  • 液晶显示屏:128 * 128 TFT屏,由st7735s驱动,走SPI协议
  • 温湿度传感器:AHT20,走I2C协议
  • 蓝牙模块:DX-BT24,UART串口
  • 开发环境:HAL库,Keil v5,cmsis接口

2 湿度传感器模块原理和代码

AHT20通过探测聚合物的水合程度来测量相对湿度(即绝对湿度与当前环境温度下水蒸气达到饱和的绝对湿度之比)。

传感器总线I2C通信协议时序:

ATH20的主机与从机通讯命令格式:

响应数据的第7字节为CRC校验数据,在不追求数据准确性的情况下可以直接忽略。Data0到Data2为湿度数据,Data2到Data4为温度数据,解析出来的数据值均为uint32_t注意Data2的高4位为湿度数据的最低4位,Data2的低4位为湿度数据的最高4位,中间进行截断,这一点在AHT20的产品规格书上没有明确指出。

还需注意的是,传感器操作需要发出I2C指令并等待从设备应答,指令的发出和响应必须严格对应,因此这类操作并不是线程安全的,应该加锁。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include "stm32f1xx_hal.h"
#include "i2c.h"
#include <stdio.h>
#include <stdint.h>
#include "gyconsole.h"
#include "cmsis_os.h"
#include "AHT20.h"

#define AHT20_I2C_PORT hi2c1
#define AHT20_ADDRESS 0x71

static osSemaphoreId_t SemaphoreHandle = NULL;

/*
* 初始化模块
*/
void AHT20_Init()
{
osDelay(50); // 等待初始化和校准

uint8_t initCmdBuf[1] = {0x71};
uint8_t initResponseBuf[1];
// i2c发送初始化指令,获取状态字,确认状态
HAL_I2C_Master_Transmit(&AHT20_I2C_PORT, AHT20_ADDRESS, initCmdBuf, 1, HAL_MAX_DELAY);
osDelay(50);
HAL_I2C_Master_Receive(&AHT20_I2C_PORT, AHT20_ADDRESS, initResponseBuf, 1, HAL_MAX_DELAY);

// 初始化失败
if ((initResponseBuf[0] & 0x18) != 0x18)
{
gylog("init failed %x", initResponseBuf[0]);
}

// 初始化信号量,确保API的互斥访问
const osSemaphoreAttr_t SemaphoreAttr = {
.name = "AHT20",
.cb_mem = NULL,
.cb_size = 0,
};
SemaphoreHandle = osSemaphoreNew(1, 1, &SemaphoreAttr);

osDelay(10);
}

/*
* 测量温湿度数据
*/
void AHT20_MeasureData(float *temperature, float *humidity)
{
// 等待信号量释放
osSemaphoreAcquire(SemaphoreHandle, osWaitForever);

uint8_t measureCmdBuf[3] = {0xac, 0x33, 0x00};
uint8_t measureResBuf[8] = {0, 0, 0, 0, 0, 0, 0, 0};

HAL_I2C_Master_Transmit(&AHT20_I2C_PORT, AHT20_ADDRESS, measureCmdBuf, 3, HAL_MAX_DELAY);
osDelay(80);
HAL_I2C_Master_Receive(&AHT20_I2C_PORT, AHT20_ADDRESS, measureResBuf, 8, HAL_MAX_DELAY);
// 状态字最低位为0,则读取完成
while (measureResBuf[0] & 0x01 == 0x01)
{
osDelay(10);
HAL_I2C_Master_Receive(&AHT20_I2C_PORT, AHT20_ADDRESS, measureResBuf, 8, HAL_MAX_DELAY);
}

// 读取buffer的5字节数据,单个有效长度20字节
// 第三个字节通过按位与进行截断
// 最后一个字节用于CRC校验
uint32_t humidity_raw = 0;
humidity_raw |= (uint32_t)measureResBuf[1] << 12;
humidity_raw |= (uint32_t)measureResBuf[2] << 4;
humidity_raw |= (uint32_t)((measureResBuf[3] & 0xf0) >> 4);
uint32_t temperature_raw = 0;
temperature_raw |= ((uint32_t)(measureResBuf[3] & 0x0f) << 16);
temperature_raw |= (uint32_t)measureResBuf[4] << 8;
temperature_raw |= (uint32_t)measureResBuf[5];

*humidity = (float)humidity_raw / (1 << 20);
*temperature = (float)temperature_raw / (1 << 20) * 200.0f - 50.0f;

// 释放信号量
osSemaphoreRelease(SemaphoreHandle);
}


3 遇到的问题

3.1 可变参数函数sprintf的二次封装

我的这个项目包含了一个128*128的液晶屏作为console(日志系统的一部分),对外输出可供阅读的调试信息。我希望对sprintf进行二次封装,将st7735s的LCD驱动逻辑放入封装内,来实现在液晶屏幕上显示格式化之后的字符串。但是sprintf与常规函数有很大不同, 它的形参数量是可变的,无法使用常规的函数定义方式实现。目前能够使用的解决方案有如下两种:

一种方案是采用C99中的预定义宏##__VA_ARGS__,来在预处理阶段实现参数列表的可变。这里的##是连接符,用来处理参数个数为零的边界情况,当参数个数为零的时候删除其前面一个逗号(即token为空时,不进行连接),确保最终文本符合C语法。

1
2
3
#define gylog(format, ...) fprintf(stdout, format, ##__VA_ARGS__)
// 或者
#define gylog(format, args...) fprintf (stdout, format, args)

第二种方案用到了stdarg库中的va_listva_list本质上是一个指针,va_start会把指针指向参数栈顶部。这种方案下要使用vsprintf代替sprintf来接收va_list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void gylog(const char *format, ...)
{
osSemaphoreAcquire(SemaphoreHandle, osWaitForever);
char result[18];

// 可变参数的处理逻辑,这里使用vaList实现
va_list vaList;
va_start(vaList, format);
vsprintf(result, format, vaList);
va_end(vaList);

ST7735_WriteString(logCurrentX, logCurrentY, result, Font_7x10, rgbToInt(0, 0, 0), rgbToInt(255, 172, 7));
// ...
// ...
osSemaphoreRelease(SemaphoreHandle);
}

3.2 vTaskList显示问题

FreeRTOS附带了一个vTaskList()函数来方便开发者查看当前所有任务(线程)的状态,提供的信息包括任务优先级、栈剩余(历史最低值)等。这个函数的输出字符串中包含ASCII制表符,但是我的日志系统不支持使用制表符进行排版,于是出现下图所示的乱码,影响美观:

解决方法很简单,把相应制表符替换成空格即可,此处省略不表。


3.3 链接期间出错问题

1
2
Playground\Playground.axf:
Error: L6200E: Symbol SysTick_Handler multiply defined (by port.o and cmsis_os2.o).

经过仔细排查,应该是STM32CubeMX的bug,只好修改其生成的freertos文件,删除重定义,苟一下。


3.4 莫名其妙的汇编语言错误

1
2
..\Middlewares\Third_Party\FreeRTOS\Source\portable\RVDS\ARM_CM3\port.c(392):
error: A1586E: Bad operand types (UnDefOT, Constant) for operator (

定位到出错的地方后,发现这个函数来头不小,它负责PendSV中断服务,管理同级task之间的上下文切换。PendSV可被优先级更高的ISR打断,确保系统的实时性,由#configMAX_SYSCALL_INTERRUPT_PRIORITY指定。

mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY(代表4U)稍作修改:

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
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;

PRESERVE8

mrs r0, psp
isb

ldr r3, =pxCurrentTCB /* Get the location of the current TCB. */
ldr r2, [r3]

stmdb r0!, {r4-r11} /* Save the remaining registers. */
str r0, [r2] /* Save the new top of stack into the first member of the TCB. */

stmdb sp!, {r3, r14}
mov r0, #4
msr basepri, r0
dsb
isb
bl vTaskSwitchContext
mov r0, #0
msr basepri, r0
ldmia sp!, {r3, r14}

ldr r1, [r3]
ldr r0, [r1] /* The first item in pxCurrentTCB is the task top of stack. */
ldmia r0!, {r4-r11} /* Pop the registers and the critical nesting count. */
msr psp, r0
isb
bx r14
nop
}

4.蓝牙图传

4.1 效果图

蓝牙图传的核心实现由两台设备构成:发送端和接收端。这里的发送端是PC,接收端是STM32。


4.2 浏览器端的实现

在了解到浏览器端也有蓝牙api之后,决定上位机使用h5开发。用户选择一张图片,系统通过canvas对图片进行采样,得到128*128的RGBA矩阵,之后进行色彩空间转换,将32位的RGBA制式转换为RGB565制式。随后通过Bluetooth API发送到STM32。

下面代码展示了蓝牙初始化、连接设备、建立GATT、监听characteristic等过程。需要注意requestDevice必须由用户操作触发。

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
const initBluetooth = function () {
let type = {
service: 0xffe0,
writeCharacteristic: 0xffe1,
notifyCharacteristic: 0xffe1,
}
let isBleSupported = navigator && "bluetooth" in navigator
if (isBleSupported) {
navigator.bluetooth.getAvailability().then(async () => {
// 显示蓝牙列表
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [type.service] }],
});
if (device) {
// 开始连接设备
const server = await device.gatt.connect();
// 监听characteristic
const service = await server.getPrimaryService(type.service);
const characteristic = await service.getCharacteristic(
type.notifyCharacteristic
);
await characteristic.startNotifications();
writeCharacteristic = await service.getCharacteristic(
type.writeCharacteristic
);
} else {
alert("连接到设备失败")
}
})
} else {
alert("设备或浏览器不支持蓝牙")
}
}

下面代码展示了图片的发送过程。一张以RGB565格式编码的128*128位图,一共占用32KB的存储空间,由于BT-24模块的MTU只有253字节,所以我对图片采取了分块发送的策略,把单个数据包的大小控制在了160字节以内。发送出去的数据遵循一个简单且固定的数据包格式。0x23是一个初始化指令,单片机收到该指令后会停止所有绘制任务,等待接收0xf1绘制指令。0xf1指令有两个参数,分别是图块左上角的x坐标和y坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const sendImg = async function () {
await utils.sendHex(Uint8Array.from([0x23, 0]))

for (let rowNum = 0; rowNum < config.LCD_HEIGHT; rowNum++) {
let bufferSemi1 = [];
let bufferSemi2 = [];
for (let i = rowNum * config.LCD_WIDTH; i < rowNum * config.LCD_WIDTH + config.LCD_WIDTH; i++) {
let uint16Data = utils.rgbToInt(global.rgbList[i][0], global.rgbList[i][1], global.rgbList[i][2])
if (i < rowNum * config.LCD_WIDTH + config.LCD_WIDTH / 2) {
bufferSemi1 = [...bufferSemi1, uint16Data >> 8, uint16Data & 0xff]
} else {
bufferSemi2 = [...bufferSemi2, uint16Data >> 8, uint16Data & 0xff]
}
}
await utils.sendHex(Uint8Array.from([0xf1, 0, 0, rowNum, ...bufferSemi1]))
await utils.sendHex(Uint8Array.from([0xf1, 0, config.LCD_WIDTH / 2, rowNum, ...bufferSemi2]))
await utils.sleep(config.sleepMs)
}
}

兼容性问题:Web Bluetooth API目前并不是W3C标准,目前只有Chromium系的浏览器支持得较好,Firefox和Safari更是全线不支持。


4.3 接收端的实现

借助freertos的消息队列API,在系统上电并完成初始化后,初始化一个128*4的消息队列。在接收到串口数据后,往消息队列中插入数据(ISR中完成)。使用osMessageQueuePut实现外设中断与操作系统的沟通。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 注册回调函数
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &BT24_UART_PORT)
{
// 如果参数超时设置为0,则可以从ISR调用
osMessageQueuePut(BT24MsgQueueHandle, receiveDataBuf, NULL, 0);
// 开启串口不定长数据接收
HAL_UARTEx_ReceiveToIdle_DMA(&BT24_UART_PORT, receiveDataBuf, BT24_SINGLE_MSG_SIZE);
// 关闭指定中断
__HAL_DMA_DISABLE_IT(&BT24_DMA_RX, DMA_IT_HT);
}
}