简介:这是一套基于STM32F103C8T6的嵌入式雷达系统实战项目资源,面向嵌入式初学者与课程设计学生,解决超声波测距、舵机协同控制及TFT-LCD图形化显示等典型综合开发问题。资源包共1024个文件,涵盖563个C源码(含驱动与算法实现)、250个头文件(定义外设配置与接口)、51个汇编启动文件及28个IAR工程配置文件,另有PDF原理图与Keil/IAR双平台可运行工程(.uvprojx/.icf),总大小23.84MB。已有270人学习下载,体现其在教学实践与毕业设计中的实用热度。用户可直接获取完整雷达扫描逻辑:包括定时器PWM精准驱动SG90舵机180°往复扫描、HC-SR04超声波测距(Timer1触发+Timer2捕获)、SPI驱动2.4寸TFT-LCD实时绘制极坐标雷达图,并在屏幕上动态标定物体角度与距离;同时集成ARM CMSIS-DSP数学库相关模块(如arm_rfft_init_f32、arm_dct4_init_q15等),便于后续扩展信号处理功能。
1. 项目缘起与核心目标
最近在整理工作室的旧项目时,翻出了一个基于STM32F103C8T6的雷达扫描原型。这个项目最初是为了验证一个简单的避障或区域扫描想法,核心就是用最便宜、最常见的硬件——SG90舵机、HC-SR04超声波模块和一块2.4寸TFT屏,来实时绘制一个极坐标下的“雷达图”。当时用CubeMX快速搭建了框架,但代码写得比较随意。这次我决定把它重新梳理一遍,做成一个结构清晰、可复现的完整项目。很多朋友在入门嵌入式图形界面或传感器融合时,都会觉得无从下手,这个项目恰好涵盖了定时器、PWM、外部中断、SPI通信和基础图形算法,是一个非常好的综合练习。它不追求复杂的算法和精美的UI,而是聚焦于如何将几个简单模块的数据流有机结合起来,在屏幕上直观呈现,这对于理解嵌入式系统的“输入-处理-输出”闭环非常有帮助。
整个系统的逻辑很直观:STM32F103C8T6作为大脑,驱动SG90舵机进行0-180度的周期性扫描。在每一个角度上,单片机触发HC-SR04超声波模块测距,获取前方障碍物的距离信息。随后,单片机需要将这对极坐标数据(角度、距离)转换为能在320x240像素的TFT-LCD屏幕上显示的直角坐标点,并实时更新,形成动态的扫描线和新旧点的更替,最终呈现出一个类似雷达扫描的扇形画面。这里面有几个关键点需要解决:如何精确控制舵机角度并与测距时序同步?如何高效地在TFT屏上画点、画线并管理图形刷新以避免闪烁?如何组织代码使得逻辑清晰且易于调试?接下来,我就结合CubeMX配置和HAL库编程,一步步拆解实现过程。
2. 硬件选型与CubeMX基础工程搭建
2.1 核心控制器与开发环境选择
主控芯片选择了经典的STM32F103C8T6,也就是常说的“蓝莓派”或最小系统板核心型号。它拥有72MHz的Cortex-M3内核、64KB Flash和20KB RAM,对于本项目来说性能绰绰有余。其丰富的外设,特别是多个定时器和通信接口,是本项目顺利实施的关键。开发环境方面,STM32CubeMX是必不可少的起点。它极大地简化了引脚分配、时钟树配置和外设初始化的工作,能生成兼容性良好的HAL库代码框架,让我们可以专注于应用逻辑。
首先,在CubeMX中新建工程,选择STM32F103C8T6。关键的系统配置是时钟树。我们需要让系统运行在最高性能,通常将HSE(外部高速时钟)设为8MHz,经过PLL倍频到72MHz,作为系统时钟(SYSCLK)。APB1总线时钟(定时器2、3、4等所在)最高36MHz,APB2总线时钟(高级定时器1、SPI1等所在)就是72MHz。正确的时钟配置是后续PWM频率计算、SPI通信速率设定的基础。
2.2 外设引脚分配与功能规划
根据硬件连接,我们需要在CubeMX中为每个外设分配具体的GPIO引脚。以下是核心外设的配置思路:
SG90舵机 (PWM控制):SG90舵机的控制信号线需要接在一个能输出PWM的引脚上。我们使用定时器(TIM)的PWM输出功能。通常选择TIM1、TIM2、TIM3、TIM4等通用或高级定时器。例如,我将舵机信号线接在PA8引脚上,这个引脚对应TIM1的通道1(CH1)。在CubeMX中,找到TIM1,将Channel1模式设为“PWM Generation CH1”。舵机的控制频率通常是50Hz(周期20ms),我们需要计算产生这个频率和对应脉宽的参数。
HC-SR04超声波模块 (GPIO与定时器):HC-SR04需要两个GPIO,一个用于触发(Trig),一个用于回响(Echo)。Trig配置为推挽输出模式即可。Echo信号则需要测量高电平的持续时间,这个时间对应了距离。最精准的方法是使用输入捕获功能。我们可以将一个定时器的输入捕获通道连接到Echo引脚。例如,将Echo接在PA0(TIM2的通道1)或PA6(TIM3的通道1)。在CubeMX中,将对应TIM的Channel设为“Input Capture direct mode”。同时,还需要一个基本的定时器(如TIM6)来提供超声波测量的超时判断,防止因未收到回波而卡死。
2.4寸 TFT-LCD屏 (SPI通信):市面上常见的2.4寸TFT屏(如ILI9341驱动芯片)大多采用SPI接口通信。我们需要配置一个SPI外设(如SPI1或SPI2)用于发送命令和数据。通常需要以下引脚:SPI_SCK(时钟)、SPI_MOSI(主机输出)、SPI_CS(片选,GPIO模拟)、DC(数据/命令选择,GPIO模拟)、RESET(复位,GPIO模拟)。背光控制(BL)也可以用另一个GPIO控制。在CubeMX中,使能SPI1,模式设为“Full-Duplex Master”,硬件NSS(片选)设为“Disable”,因为我们用软件控制CS。数据大小8位或16位取决于屏驱动芯片,ILI9341通常用8位。然后将SCK(PA5)、MOSI(PA7)等引脚自动配置好。其他的控制引脚(CS, DC, RESET, BL)配置为普通的GPIO输出模式。
调试与下载 (USART):强烈建议预留一个USART(如USART1)用于串口打印调试信息,这将在开发过程中帮你大忙。配置为异步模式,波特率115200。
完成所有引脚分配后,在Project Manager页面设置好工程名称、路径、IDE(MDK-ARM或STM32CubeIDE等),以及生成初始化代码的选项。关键点是在“Code Generator”标签页,勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这会让每个外设的代码更独立,方便管理。
3. 舵机角度控制与扫描逻辑实现
3.1 SG90舵机PWM参数计算与配置
SG90舵机的控制信号是一个周期为20ms(50Hz),脉宽在0.5ms到2.5ms之间的PWM波。脉宽对应舵机转动的角度,通常0.5ms对应0度,2.5ms对应180度,这是一个线性关系。
在CubeMX配置的TIM1中,我们需要设置预分频器(PSC)和自动重载值(ARR)来产生20ms的周期。假设系统时钟(APB2)为72MHz,定时器时钟也是72MHz(如果APB预分频系数不为1,定时器时钟会倍频,这里假设为72MHz)。
- 目标频率:50Hz -> 周期 T = 1/50 = 0.02s = 20,000 us。
- 定时器计数一次的时间:t = 1 / 72MHz ≈ 0.0139 us。
- 需要计数的次数:ARR_Counts = T / t = 20,000 us / 0.0139 us ≈ 1,440,000。这个值远大于16位定时器的最大值65535,因此我们需要使用预分频器来降低定时器的计数频率。
我们可以先设定一个合适的ARR值,比如20000(这样每个计数代表1us,方便计算脉宽)。那么,需要的定时器时钟频率应为:F_timer = ARR / T = 20000 / 0.02s = 1,000,000 Hz = 1MHz。
- 预分频系数 PSC = 72MHz / 1MHz - 1 = 71。
所以在CubeMX的TIM1配置中,我们将Prescaler(PSC)设为71,Counter Period(ARR)设为20000-1=19999(因为从0开始计数)。在PWM生成通道的设置里,将Pulse(初始脉宽)设为对应0度角的值,比如1500(1.5ms,中间位置)。
生成代码后,在main.c的初始化部分,我们需要启动PWM输出:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);3.2 角度扫描算法与定时器中断驱动
我们需要让舵机在0到180度之间匀速来回扫描。一种简单的方法是使用一个定时器中断(如TIM2),每隔一个固定的时间(比如20ms),更新一次舵机的目标角度。
首先,定义一个全局变量servo_angle,范围0-180。在定时器中断回调函数中(例如HAL_TIM_PeriodElapsedCallback),修改这个角度值,并计算对应的PWM脉宽。
脉宽计算函数如下:
// 将角度(0-180)转换为PWM比较值(对应0.5ms-2.5ms) // ARR=19999, 周期20000对应20ms, 1us对应1个计数 // 0.5ms -> 500计数, 2.5ms -> 2500计数 uint32_t angle_to_compare(uint16_t angle) { // 线性映射: angle(0~180) -> compare(500~2500) return 500 + (angle * (2500 - 500)) / 180; }在定时器中断中:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2.Instance) { // 假设TIM2用于产生扫描中断 static int8_t direction = 1; // 扫描方向,1为增,-1为减 static uint16_t current_angle = 0; current_angle += direction; if (current_angle >= 180 || current_angle <= 0) { direction = -direction; } uint32_t pulse = angle_to_compare(current_angle); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pulse); // 更新PWM脉宽 // 记录当前角度,用于后续与超声波测距同步 global_scan_angle = current_angle; } }这里的关键是,舵机转动需要时间(SG90大约0.1秒/60度),所以中断周期不能太短,否则舵机跟不上指令。20ms的更新周期是一个比较平衡的选择,既能平滑运动,又不会太慢。同时,这个global_scan_angle变量,就是当前雷达扫描线所在的角度,是后续进行测距和绘图的“角度基准”。
4. 超声波测距模块的精准时序捕获
4.1 HC-SR04工作原理与硬件连接
HC-SR04模块有四个引脚:VCC、GND、Trig(触发)、Echo(回响)。工作流程是:单片机给Trig引脚一个至少10us的高电平脉冲,模块会自动发出8个40kHz的超声波脉冲,并检测回波。当检测到回波后,Echo引脚会输出一个高电平,其持续时间与距离成正比。距离计算公式为:距离 = (高电平时间 * 声速) / 2。声速在常温下可取340m/s或343m/s。
硬件连接上,VCC接5V或3.3V(注意有些模块Echo输出也是5V电平,可能需要电平转换,但STM32F103的很多IO容忍5V,具体看芯片手册),GND接地。Trig接一个普通的GPIO输出口(如PA1)。Echo接配置为输入捕获模式的定时器通道引脚(如PA0对应TIM2_CH1)。
4.2 使用输入捕获模式测量高电平时间
在CubeMX中,我们已将Echo对应的定时器通道(例如TIM2的Channel1)配置为输入捕获模式。生成代码后,需要使能输入捕获中断,并编写回调函数。
首先,在main.c的初始化部分,启动输入捕获:
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); // 启动TIM2通道1的输入捕获,并使能中断然后,我们需要处理捕获中断。HAL库提供了HAL_TIM_IC_CaptureCallback回调函数。思路是:在上升沿捕获时,记录时间戳T1;在下降沿捕获时,记录时间戳T2。差值(T2-T1)就是高电平时间。定时器的计数频率需要知道,我们之前设置TIM2可能用于扫描,这里我们可以用另一个定时器(如TIM3)专门做输入捕获,或者复用TIM2但用不同的通道。为了简化,假设我们用TIM3的通道1做输入捕获。
在回调函数中:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3.Instance && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { static uint32_t capture_start = 0; static uint32_t capture_end = 0; if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC1) != RESET) { // 检查CC1中断标志 __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC1); if (__HAL_TIM_GET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1) == TIM_INPUTCHANNELPOLARITY_RISING) { // 上升沿捕获 capture_start = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 将捕获极性切换为下降沿 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else { // 下降沿捕获 capture_end = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 计算时间差,需要考虑计数器溢出的情况 uint32_t pulse_width; if (capture_end >= capture_start) { pulse_width = capture_end - capture_start; } else { // 计数器溢出 pulse_width = (htim->Instance->ARR - capture_start + 1) + capture_end; } // 计算距离(单位:厘米) // 定时器时钟假设为1MHz,则1个计数=1us // 距离 = (时间us * 声速cm/us) / 2 = (pulse_width * 0.0343) / 2 global_current_distance = (pulse_width * 343) / 20000; // 简化计算: 0.0343/2 * pulse_width // 一次测量完成,切换回上升沿捕获,准备下一次 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); } } } }4.3 触发测距与数据同步
我们需要在舵机转动到每个特定角度时,触发一次超声波测距。最简单的同步方法是在舵机角度更新的定时器中断里,在更新完PWM脉宽后,触发一次HC-SR04。
在舵机扫描中断函数中增加触发逻辑:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2.Instance) { // ... 之前的舵机角度更新和PWM设置代码 ... // 触发一次超声波测距 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(12); // 延时至少10us,这里用12us HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); // 此时,输入捕获中断会异步地测量Echo高电平时间,并更新 global_current_distance // 我们需要将当前角度和距离“打包”,供显示线程使用 if (global_current_distance > 0 && global_current_distance < MAX_DETECT_RANGE_CM) { // 将数据存入一个缓冲区或直接用于绘图 radar_update_point(global_scan_angle, global_current_distance); } // 清空当前距离,等待下一次测量 global_current_distance = 0; } }这里有一个重要的细节:超声波模块发出波束后,需要一段时间才能收到回波(飞行时间)。在近距离时这个时间很短,但在远距离(比如4米)时,可能需要超过23ms(340m/s下,4米往返约23.5ms)。而我们的舵机扫描中断周期是20ms,如果每次中断都等待测距完成,会严重拖慢扫描速度,导致动画卡顿。因此,必须采用异步非阻塞的方式。上面的代码正是如此:触发后立即返回,测距结果由输入捕获中断异步更新。我们使用一个全局变量global_current_distance来传递结果。但要注意数据竞争问题,如果显示线程和中断同时读写这个变量,可能会出错。一个简单的办法是,在舵机中断中触发测距后,复制角度和距离到一个受保护的结构体中(例如使用临界区保护),或者使用一个小的循环队列。
注意:HC-SR04有两个测量盲区,大约2cm和450cm左右。在代码中需要加入有效范围判断,例如只处理2cm到400cm之间的数据。另外,声速受温度影响,如果要求精度高,可以加入温度传感器进行补偿。
5. TFT-LCD驱动与雷达界面绘制
5.1 SPI通信与屏驱初始化
我们使用的2.4寸屏通常由ILI9341、ST7789等芯片驱动。这里以ILI9341为例。首先需要编写底层的SPI发送函数。HAL库提供了HAL_SPI_Transmit,但为了效率,我们通常直接操作寄存器或使用DMA。对于初始化阶段,用HAL库函数即可。
我们需要封装几个基本函数:
LCD_WriteCommand(uint8_t cmd): 发送命令(DC引脚拉低)。LCD_WriteData(uint8_t data): 发送数据(DC引脚拉高)。LCD_WriteData16(uint16_t data): 发送16位数据(颜色数据常用)。
初始化流程就是按照ILI9341数据手册,依次发送一系列初始化命令和参数,来设置伽马值、颜色模式、显示方向、打开显示等。这部分代码通常比较固定,可以从厂商例程或开源库中找到。一个关键设置是显示方向(Rotation)。我们需要将屏幕设置为横屏模式,并且确定原点(0,0)的位置。对于雷达图,我们通常希望屏幕中心作为雷达扫描的原点(极坐标极点)。假设屏幕是320x240,我们可以将原点设在(160, 120)。设置显示方向时,要确保X和Y轴的增长方向符合我们的绘图习惯。
5.2 极坐标转换与画点函数
雷达扫描得到的是极坐标数据:角度(θ)和距离(r)。我们需要将其转换为屏幕上的直角坐标(x, y)。转换公式为:
- x = r * cos(θ) + center_x
- y = r * sin(θ) + center_y
这里有一个细节:数学上的角度通常以X轴正方向为0度,逆时针旋转为正。而我们的舵机扫描,0度可能对应物理上的最左端。我们需要建立一个映射关系。假设舵机0度对应雷达图的-90度(左侧),180度对应+90度(右侧),那么扫描角度servo_angle(0-180)对应的数学角度theta= (servo_angle - 90) * π / 180。
在嵌入式系统中,直接计算浮点数的sin/cos比较耗时。我们可以采用查表法。预先计算好0到180度(或者-90到+90度)每个整数角度对应的sin和cos值,放大一定倍数(如乘以256)后存入数组,使用时进行整数运算和移位,可以大幅提高速度。
// 预计算sin表,角度范围0-180度,对应实际角度-90到+90度 #define SIN_SCALE 256 int16_t sin_table[181]; void init_trig_table(void) { for (int i = 0; i <= 180; i++) { float theta = (i - 90) * 3.1415926f / 180.0f; sin_table[i] = (int16_t)(sinf(theta) * SIN_SCALE); // cos(i) = sin(i+90),可以复用sin表 } } // 极坐标转直角坐标(简化版,假设r以像素为单位) void polar_to_cartesian(uint16_t angle, uint16_t distance_cm, uint16_t *x, uint16_t *y) { // 将距离(厘米)转换为屏幕像素半径。需要根据实际探测范围和屏幕大小设定比例尺。 // 例如,最大探测距离400cm,在屏幕上用200像素半径表示,则 scale = 200/400 = 0.5 像素/厘米 float scale = 0.5f; uint16_t r_pixels = (uint16_t)(distance_cm * scale); // 查表获取sin值,cos通过sin表偏移90度获取 int16_t sin_val = sin_table[angle]; int16_t cos_val = sin_table[(angle + 90) % 181]; // 注意边界 // 计算坐标(使用整数运算提高速度) int32_t x_tmp = (int32_t)r_pixels * cos_val / SIN_SCALE; int32_t y_tmp = (int32_t)r_pixels * sin_val / SIN_SCALE; *x = (uint16_t)(160 + x_tmp); // 屏幕中心x=160 *y = (uint16_t)(120 - y_tmp); // 屏幕中心y=120,注意屏幕y轴向下为正,所以减去y_tmp }有了坐标,就可以写画点函数了。TFT画点通常需要先设置一个窗口(即坐标范围),然后连续写入颜色数据。对于单个点,我们可以封装一个函数:
void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { if (x >= 320 || y >= 240) return; // 边界检查 LCD_SetWindow(x, y, x, y); // 设置窗口为单个像素 LCD_WriteData16(color); // 写入该像素的颜色(16位RGB565) }5.3 雷达扫描动态效果的实现
雷达图的动态效果主要包括两部分:一条从中心向外旋转的扫描线,以及历史探测点的显示与消失(模拟余晖效果)。
扫描线的绘制:扫描线就是一条从中心点指向当前角度、长度为当前测距值的线段。我们可以用画线算法(如Bresenham算法)来绘制。但为了简单和速度,也可以选择只画线段上的若干个点,或者直接调用屏驱可能提供的画线函数(如果底层驱动有)。更简单的方法是,在每次角度更新时,擦除上一条扫描线,绘制新的扫描线。擦除可以用背景色(如黑色)重画旧线。
余晖效果:为了让探测点不会立即消失,我们可以维护一个点列表(数组),存储最近N次有效的探测点(包含角度和距离,或直接存储屏幕坐标)。每次刷新屏幕时,先清屏或绘制静态背景(如刻度线、网格),然后遍历这个列表,用较暗的颜色绘制所有历史点,最后用高亮颜色绘制最新的点。列表可以设定一个最大长度,超过后覆盖最旧的点。另一种更节省内存的方法是“双缓冲绘图”:在内存中维护一个代表屏幕的二维数组(颜色索引或直接颜色值),每次更新时,将旧点的颜色衰减(例如变暗),绘制新点,然后将整个内存数组刷新到屏幕上。但对于320x240分辨率,全屏16位颜色需要150KB内存,STM32F103C8T6的20KB RAM无法承受。因此,维护一个有限的历史点列表是更可行的方案。
一个简单的实现框架:
#define MAX_POINTS 100 typedef struct { uint16_t x; uint16_t y; uint32_t timestamp; // 可以用系统滴答计时器 uint8_t intensity; // 亮度,用于实现衰减 } RadarPoint_t; RadarPoint_t point_list[MAX_POINTS]; uint16_t point_index = 0; void radar_update_point(uint16_t angle, uint16_t distance) { uint16_t x, y; polar_to_cartesian(angle, distance, &x, &y); // 将新点存入列表 point_list[point_index].x = x; point_list[point_index].y = y; point_list[point_index].timestamp = HAL_GetTick(); point_list[point_index].intensity = 255; // 最高亮度 point_index = (point_index + 1) % MAX_POINTS; // 循环覆盖 } // 在定时器中断或主循环中调用此函数来刷新显示 void radar_draw_frame(void) { // 1. 清屏或绘制静态背景(网格、刻度、文字) LCD_Clear(BLACK); // 2. 绘制所有历史点(根据时间或强度衰减颜色) uint32_t current_time = HAL_GetTick(); for (int i = 0; i < MAX_POINTS; i++) { if (point_list[i].intensity > 0) { // 简单的线性衰减:每过100ms,强度减50 uint32_t age = current_time - point_list[i].timestamp; uint8_t decay = (age / 100) * 50; if (decay >= point_list[i].intensity) { point_list[i].intensity = 0; continue; } uint8_t current_intensity = point_list[i].intensity - decay; // 将强度映射到颜色,例如从绿色(0,255,0)衰减到深绿(0,64,0) uint16_t color = RGB565(0, current_intensity, 0); LCD_DrawPoint(point_list[i].x, point_list[i].y, color); } } // 3. 绘制当前扫描线(从中心到最新点) // 假设 latest_x, latest_y 是最近一次有效探测的坐标 if (latest_distance < MAX_RANGE) { LCD_DrawLine(160, 120, latest_x, latest_y, GREEN); // 需要实现画线函数 } }6. 系统整合、优化与调试心得
6.1 主循环与任务调度
将上述所有模块整合起来,main函数的结构通常如下:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM1_Init(); // PWM for servo MX_TIM2_Init(); // Scan timer interrupt MX_TIM3_Init(); // Input capture for ultrasonic MX_SPI1_Init(); // For TFT-LCD MX_USART1_UART_Init(); // For debug init_trig_table(); LCD_Init(); LCD_Clear(BLACK); // 启动定时器中断 HAL_TIM_Base_Start_IT(&htim2); // 舵机扫描定时器 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 舵机PWM HAL_TIM_IC_Start_IT(&htim3, TIM_CHANNEL_1); // 超声波输入捕获 while (1) { // 主循环负责非实时性的图形刷新 radar_draw_frame(); // 可以加入一个延时或基于系统滴答的帧率控制,比如每秒刷新10-20帧 HAL_Delay(50); // 20 FPS } }这里采用了典型的前后台系统架构:实时性要求高的任务(舵机控制、超声波触发与捕获)放在定时器中断中完成;而图形刷新这种计算量相对较大、实时性要求稍低的任务,放在主循环中。通过调整主循环中的延时,可以控制雷达图的刷新频率。
6.2 性能优化与常见问题排查
屏幕闪烁:如果
radar_draw_frame中先清屏再画所有内容,会出现明显的闪烁。优化方法是使用局部刷新。只擦除需要改变的区域(如上一条扫描线),而不是全屏清空。或者,采用更复杂的“脏矩形”算法,只更新发生变化的部分。测量数据抖动:超声波测距本身有一定误差,加上舵机机械振动,可能导致同一个位置测出的距离值跳动。可以在软件中加入滤波算法。最简单的是一阶低通滤波:
filtered_distance = alpha * new_distance + (1 - alpha) * filtered_distance,其中alpha是一个介于0和1之间的系数,值越小越平滑但响应变慢。也可以使用中值滤波,取最近几次测量的中值。舵机与测距不同步:由于超声波测距是异步的,可能出现“角度A触发测距,但结果返回时舵机已经转到角度B”的情况。解决方法是给每个测距请求打上“角度标签”。在触发测距时,将当前的
global_scan_angle存入一个变量measurement_angle。当输入捕获完成得到距离后,将(measurement_angle, distance)这个数据对存入点列表,而不是使用实时的global_scan_angle。SPI通信速度瓶颈:刷屏是主要的耗时操作。优化措施包括:
- 使用DMA传输颜色数据块。
- 减少全屏刷新次数,采用局部更新。
- 使用屏驱的“写RAM”命令,一次性发送多个像素数据,而不是每个像素都设置一次窗口。
内存不足:STM32F103C8T6的20KB RAM很紧张。要避免大数组。历史点列表不宜过大(如100个点)。如果使用显存缓冲区,可以考虑使用8位颜色索引(调色板)而非16位真彩色,但这会增加代码复杂度。
6.3 调试技巧与心得
串口打印是王道:在关键步骤(如触发测距、捕获到边沿、计算完距离、坐标转换后)通过串口打印出变量值,是定位问题最快的方法。例如,打印出
pulse_width和计算出的distance,可以验证超声波测量是否正常。利用LED和GPIO:如果没有串口,可以用一个GPIO引脚来产生脉冲信号,用示波器或逻辑分析仪观察时序。例如,在触发超声波时拉高一个引脚,在收到回波时拉高另一个引脚,可以直观看到整个测距周期和时序关系。
分模块测试:不要试图一次性把所有功能都调通。先单独测试舵机,让它能平滑转动;再单独测试超声波,固定不动测量距离并在串口显示;然后单独测试TFT屏,画一些静态图形。最后再把它们组合起来。
注意电源噪声:舵机在转动时电流较大,可能会引起电源电压波动,影响超声波模块甚至单片机本身的稳定性。确保电源有足够的容量和良好的退耦(在舵机和单片机电源引脚附近加足够大的电解电容和小的陶瓷电容)。
这个项目虽然硬件简单,但软件上涵盖了中断、定时器、PWM、输入捕获、SPI通信、图形算法等多个嵌入式核心概念。把它一步步调试成功的过程,本身就是对STM32和HAL库一次非常好的深入学习。最终看到屏幕上那个绿色的扇形扫描线一点点描绘出前方的障碍物轮廓时,那种成就感正是嵌入式开发的乐趣所在。
本文还有配套的精品资源,点击获取