Jetson Nano+STM32视觉识别控制舵机实战:从训练到部署全流程解析
简介本资源是一个面向嵌入式AI初学者与课程设计者的端侧智能控制实战项目聚焦Jetson Nano与STM32协同完成图像识别→指令下发→舵机精准响应的完整闭环。适用于毕业设计、大创竞赛、嵌入式实训及深度学习部署练手尤其适合缺乏PCB设计经验但希望快速验证算法与硬件联动效果的学习者。压缩包共111个文件142.82MB涵盖STM32标准外设库C/H源码如usart.c、tim.c、rcc.c等支撑串口通信与PWM舵机驱动、Jetson Nano端PyTorch模型导出的Paddle Lite可部署文件model、params、pdiparams等、Keil工程配置uvprojx/uvoptx、自动化脚本bat/py及说明文档md和演示视频mp4。已有1339人学习下载提供可直接烧录运行的完整工程、清晰引脚连线指导、模型转换与部署关键步骤注释以及作者全程技术答疑支持显著降低嵌入式深度学习落地门槛。 我从一个有点“贪心”的想法开始讲这个项目想做一个能识别物体、并根据识别结果自动控制机械臂/舵机转动的小系统。最开始只打算在电脑上跑跑检测后来发现真正有意思的是把模型放到边缘设备上推理再通过单片机去驱动舵机——这几乎把“AI算法”和“嵌入式控制”这两条线串了个遍。于是就有了这个标题里的完整链路准备数据集 → 训练模型 → Jetson Nano部署 → 和STM32通信 → 控制舵机转动。这个过程中踩的坑比想象中多得多尤其是Jetson Nano的推理环境和STM32串口通信之间的配合。如果你也在做类似的“视觉控制”入门项目或者正想搞清楚Jetson Nano和STM32之间到底怎么协作这篇文章应该能帮你省下两三个星期的折腾时间。1. 项目整体设计与链路拆解1.1 核心需求与系统架构思路整个项目的需求看起来很简单让摄像头识别出目标类别然后把这个类别映射成舵机角度驱动舵机转到指定位置。但是细拆下来它其实分成了三个相对独立的子系统边缘AI推理子系统Jetson Nano 摄像头、通信链路Jetson Nano与STM32之间的数据交换、舵机执行子系统STM32 舵机 PWM驱动。系统架构上我选择的是“主从结构”Jetson Nano作为上位机负责图像采集、模型推理并将推理结果按照自定义协议打包通过UART串口发送给STM32STM32作为下位机负责解析串口数据、校验、执行PWM输出从而控制舵机角度。这个架构的好处是边界清晰。AI推理部分和实时控制部分彻底解耦各自开发各自的最后联调时只要对好通信协议就行。如果你把AI推理和舵机控制都塞到一个板子上调试时会非常痛苦图像处理延迟会直接影响舵机控制周期舵机负载波动又可能反过来拖慢推理流程。1.2 为什么选Jetson Nano和STM32而不是其他组合一开始我也考虑过树莓派或者直接用STM32跑轻量级模型。最后选了Jetson Nano STM32的组合有几个非常现实的原因第一Jetson Nano的优势是完整支持CUDA和TensorRT生态。树莓派虽然也能跑模型但基本依赖CPU推理或者外围NPU部署效率和推理速度都和Nano不在一个水平线。Jetson Nano原生的JetPack SDK自带TensorRT可以把训练好的模型转换成高度优化的推理引擎跑YOLOv5这类轻量目标检测网络能轻松到20~30 FPS。第二STM32的优势是实时性和PWM控制的可靠性。舵机控制需要精确的50Hz周期PWM信号而STM32的硬件定时器输出PWM非常稳定不占用CPU资源。相比之下Jetson Nano直接输出PWM不仅不稳定Linux非实时系统的调度延迟还会导致舵机抖动。所以“AI负责看MCU负责动”是我认为合理且稳健的分工。第三从学习价值的角度看这个组合覆盖了Linux部署、深度学习模型转换、串口协议设计、嵌入式外设驱动四大块做完之后你对“AI到底怎么落地到真实设备”会有非常直观的理解。1.3 软件链路总体流程整个软件链路可以概括为一条流水线数据准备阶段用摄像头采集目标物体的图片标注或分类整理成数据集。模型训练阶段在PC或云端训练目标检测/分类模型导出为ONNX格式。模型转换阶段在Jetson Nano上把ONNX转为TensorRT推理引擎.engine文件。推理部署阶段Jetson Nano循环读取摄像头帧执行推理得到识别结果。命令生成阶段根据识别结果查表映射成舵机角度封装成通信帧。串口发送阶段通过UART向STM32发送数据帧。STM32解析阶段校验数据帧、解析类别/角度指令。舵机控制阶段STM32产生PWM信号控制舵机转到指定角度。这个流水线里每一步都有各自的坑后面慢慢展开。2. 数据集准备与模型训练要点2.1 自己拍数据集看似无聊却最关键的一步我最初想偷懒直接用网上公开的数据集训练直接和想要的场景对不上。因为我的目标是让摄像头识别桌面上的几种物品比如红球、蓝方块、手机、水杯而公开数据集里的物体类别和视角跟我真实场景差距很大。模型在公开数据集上表现再好换到我的摄像头视角下也会“翻车”。所以最终我选择自己采集数据。把USB摄像头固定在一个三脚架上模拟实际运行时的视角然后对每个物体采集不同光照、不同角度、不同背景下的图片。这里有几个关键经验数量不需要太多但分布要均匀。我每个类别拍了150~200张左右一共5类总计约900张。比动不动上万张的数据集少得多但针对单一场景完全够用。尽量模拟推理时的真实环境。如果你的摄像头是俯视的训练图片就不要用平视视角如果你的桌面是木纹的训练图片里就要出现木纹背景。否则模型会学到错误特征。加入一些负样本。我额外拍了一些“空桌面”和“手部入镜”的图片减少误检和乱动的情况。这一点在实机测试中特别有用不然模型很容易把背景或者其他杂物识别成目标。采集完图片后用LabelImg做目标检测标注或者直接按文件夹整理成分类数据集两种方案都可以。我的项目是检测后接控制用了YOLOv5s模型做目标检测所以做的是边界框标注。提示数据集的图片分辨率不需要太高统一缩放到640x640再训练否则训练时间成倍增加部署时的预处理也会更麻烦。2.2 模型选择与训练细节如果只是控制一个舵机检测和目标分类都能完成功能。分类模型更加轻量检测模型更加鲁棒。我之所以选目标检测是因为它可以同时输出物体位置和类别以后想升级成“追踪物体并让舵机跟随”也比较容易。模型方面我选了YOLOv5s。虽然YOLOv5已经被更新的YOLOv8、YOLO11超越但它仍然胜在生态成熟、资料多、转TensorRT的教程遍地都是非常适合作为入门和落地项目。真正跑起来之后YOLOv5s在Jetson Nano上转成TensorRT后大约能跑25~30 FPS完全够用。训练过程里几个值得注意的点我用了预训练权重YOLOv5s.pt做迁移学习迭代100个epoch左右batch size设为16输入尺寸640x640。训练平台直接用PC上的NVIDIA显卡。如果没有显卡可以用云GPU但数据上传下载会比较繁琐。训练集、验证集按8:2划分确认训练时没有数据泄漏。最终训练出来的模型在验证集上mAP能达到0.95左右这个精度对一个玩具级项目来说已经足够了。2.3 模型转换为ONNX和TensorRT引擎这是Jetson Nano部署里最容易卡壳的一步。YOLOv5官方仓库里的export.py脚本可以直接导出ONNX但导出的ONNX不能直接用于TensorRT部署还需要进一步转换。我的转换流程是在PC上用python export.py --weights best.pt --include onnx --opset 11导出ONNX文件。把ONNX文件拷贝到Jetson Nano上。使用TensorRT自带的trtexec命令或者写Python脚本调用TensorRT Python API将ONNX转换为.engine文件。关键的转换命令Jetson Nano上执行/usr/src/tensorrt/bin/trtexec \ --onnxbest.onnx \ --saveEnginebest.engine \ --fp16 \ --workspace1024这里用--fp16开启半精度推理速度能翻倍。注意Jetson Nano的GPU是Maxwell架构对FP16的支持虽然存在但效率不如后来的Volta/Turing架构不过仍然比FP32快不少。注意事项TensorRT版本和PyTorch导出的ONNX算子兼容性是个大坑。如果你的PyTorch版本太新导出的ONNX里可能出现TensorRT不支持的算子。我最终固定了PyTorch 1.8 TensorRT 8.2的组合才稳定转换成功。换版本后记得先跑一个小模型验证环境是否正常。3. Jetson Nano端环境配置与推理部署实操3.1 Jetson Nano刷机与环境配置Jetson Nano的刷机需要另外一台Ubuntu主机或者虚拟机使用NVIDIA官方提供的SDK Manager进行刷机。这里有一个不算坑但容易让人困惑的点Jetson Nano的SD卡版本和桌面版刷机方式不一样。我用的是SD卡烧录方式直接下载JetPack 4.6.1的镜像用balenaEtcher写入128GB的SD卡然后插到Jetson Nano上开机。在开机引导里设置好用户名密码后系统就带好了CUDA、cuDNN、TensorRT、OpenCV等开发环境不需要单独安装CUDA这一点比PC上装深度学习环境省心不少。环境配置的检查命令# 查看JetPack版本 cat /etc/nv_tegra_release # 查看TensorRT版本 dpkg -l | grep nvinfer # 确认CUDA可用 nvcc --version如果你用的是Jetson Orin Nano刷机流程和JetPack版本会不一样但整体思路是一样的。Orin Nano的算力比经典Nano强很多尤其是支持更高效的FP16和INT8推理如果预算允许可以直接选Orin Nano起步。3.2 TensorRT推理代码的完整骨架部署阶段的代码核心就是把图片预处理、推理、后处理这三个环节串起来。这里给出一个简化但可用的TensorRT推理骨架Pythonimport tensorrt as trt import pycuda.driver as cuda import pycuda.autoinit import numpy as np import cv2 class TRTInference: def __init__(self, engine_path): self.logger trt.Logger(trt.Logger.WARNING) with open(engine_path, rb) as f: runtime trt.Runtime(self.logger) self.engine runtime.deserialize_cuda_engine(f.read()) self.context self.engine.create_execution_context() self._allocate_buffers() def _allocate_buffers(self): self.inputs [] self.outputs [] for binding in self.engine: size trt.volume(self.engine.get_binding_shape(binding)) * 4 dtype trt.nptype(self.engine.get_binding_dtype(binding)) if self.engine.binding_is_input(binding): self.inputs.append(cuda.mem_alloc(size)) self.input_shape self.engine.get_binding_shape(binding) else: self.outputs.append(cuda.mem_alloc(size)) self.output_shape self.engine.get_binding_shape(binding) def infer(self, img): # 图像预处理resize到640x640归一化到0-1并转换为CHW img cv2.resize(img, (640, 640)) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1))[None] # 拷贝输入并执行推理 cuda.memcpy_htod(self.inputs[0], np.ascontiguousarray(img)) self.context.execute_v2(bindingsself.inputs self.outputs) # 取回输出 output np.empty(self.output_shape, dtypenp.float32) cuda.memcpy_dtoh(output, self.outputs[0]) return output后处理部分需要根据YOLOv5的输出格式做解析。主要是三个部分解析边界框坐标、做NMS非极大值抑制、把检测到的类别与置信度映射出来。这里踩过最大的坑是输出张量的shape与Python广播不匹配因为YOLOv5的ONNX输出是1x25200x851个批次、640x640下共有25200个候选框、85维向量是4个坐标1个置信度80个类别概率。在解析时一定注意先用reshape改变维度再操作。3.3 从推理结果到控制命令的映射推理结果只是一组坐标和类别ID要变成舵机控制指令还需要加一层逻辑。我的做法比较简单直接检测到不同类别时舵机转向预设角度。映射表大致长这样物体类别对应角度用途红球0°舵机左极限位蓝方块45°中偏左手机90°中间位置水杯135°中偏右无目标恢复90°回到中间这些角度不是随便定的而是根据实际机械结构校准出来的。比如舵机安装角度是水平0°但机械臂的物理限位可能只能转0°~180°具体映射值需要在联调时手动调整。确定角度后把它封装成一帧通信数据通过串口发送给STM32。这里就引出了下一个关键环节Jetson Nano和STM32之间的通信协议设计。4. STM32端通信协议与舵机控制实现4.1 通信方案选型为什么选UARTJetson Nano和STM32之间的通信方式有几种选择UART串口、I2C、SPI、USB虚拟串口甚至网络TCP/UDP。我最终选了UART串口理由是实现简单硬件连接只需要TX、RX、GND三根线。能满足带宽需求。一帧指令大约8~10个字节即使115200波特率下也只需要不到1ms的传输时间。调试方便串口数据可以直接用逻辑分析仪或USB-TTL模块抓取分析。后续如果想加无线通信串口转蓝牙/串口转WiFi模块都是现成的。SPI虽然通信速率更高但需要更多引脚且从机端解析要更复杂I2C速率低且地址冲突等问题会让人头疼。USB虚拟串口则需要装驱动不能即插即用。UART是性价比最高的选择。4.2 自定义通信帧格式设计通信协议最重要的是“可靠”和“易解析”。我设计了一帧8字节的简单协议字节序号内容说明00xAA帧头10x55帧头校验2data_len数据长度本帧固定为43cmd命令号0x01表示角度控制指令4target_id舵机编号多舵机时用5angle_high角度高字节6angle_low角度低字节7checksum前面7个字节的累加和取低8位这里两次帧头是为了降低误判概率。data_len字段用于支持不定长数据扩展checksum用于简单的错误检测。角度用两个字节16位表示范围为0~1800对应0.0°~180.0°精度0.1°。这样设计的好处是直接发送整数避免串口传输浮点数带来的解析烦恼。4.3 STM32串口接收与解析实现STM32端的核心任务是把这8个字节可靠地接收下来并解析。我用的是STM32F103C8T6Blue Pill板HAL库开发。串口接收采用了“空闲中断 DMA”组合。这是一个值得多讲几句的配置因为很多初学者只会用阻塞式接收HAL_UART_Receive效果非常差。空闲中断IDLE是指串口在一段时间内没有新数据进来时触发的中断。利用它可以一次性把一帧完整数据接收完。搭配DMA的话CPU不需要逐字节处理接收过程由DMA自动搬运到内存缓冲区完成帧接收后触发空闲中断。关键代码如下#define RX_BUF_SIZE 128 uint8_t rx_buffer[RX_BUF_SIZE]; uint8_t rx_frame[8]; // 解析后的数据帧 void MX_USART1_UART_Init(void) { // 默认115200 8N1 huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; HAL_UART_Init(huart1); // 开启DMA接收和空闲中断 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); }中断服务函数里处理帧接收逻辑void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算本次接收长度 uint32_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 如果长度足够复制到rx_frame并解析 if (len 8) { memcpy(rx_frame, rx_buffer, 8); parse_frame(rx_frame); } // 重新开启DMA接收 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUF_SIZE); } }这里的关键点是DMA接收是循环模式所以每次空闲中断产生时要立刻取出当前数据长度然后复位DMA接收。否则下一帧数据会覆盖上一帧内容。4.4 舵机PWM控制的核心参数与实现舵机控制的原理并不复杂核心是产生一个周期为20ms50Hz的PWM信号通过改变高电平脉宽通常0.5ms~2.5ms来控制舵机角度。对于市面上最常见的SG90舵机脉宽和角度的关系大致是0.5ms脉宽 → 0°1.5ms脉宽 → 90°2.5ms脉宽 → 180°STM32上使用定时器的PWM输出模式。比如用TIM2的CH1产生PWM配置为// 假设TIM2时钟72MHz预分频72-1 - 1MHz计数频率自动重载20000-1 - 50Hz TIM_HandleTypeDef htim2; htim2.Init.Prescaler 72 - 1; htim2.Init.Period 20000 - 1; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1;角度转PWM比较值的公式// pulse_width单位是usPWM周期20000对应20ms // 角度angle范围0~180 uint32_t pulse_width 500 angle * 2000 / 180; // 0.5ms (angle/180)*2ms uint32_t compare pulse_width * 20; // 因为计数频率是1MHz周期20000对应20ms所以1us20次计数 __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, compare);这个公式里最容易错的是单位换算。计数器频率是1MHz所以1us对应1000个计数上面公式里写“1us20次计数”是错误的实际应该是如果预分频后计数频率是1MHz周期20000计数对应20ms脉宽0.5ms对应500us也就是500个计数。我当时在这里卡了很久把预分频配成了72-1后计数频率是1MHz所以正确的比较值是pulse_width乘以1即500~2500之间。修正后的代码// 计数频率1MHz周期20000对应20ms uint32_t pulse_us 500 angle * 2000 / 180; __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, pulse_us);也就是比较值直接等于脉宽微秒数。如果你的预分频不同需要重新换算。注意SG90这类舵机对供电很敏感。用STM32的3.3V引脚直接给舵机供电是不可行的舵机一转动就会把电压拉低导致STM32复位。正确做法是舵机电源单独用5V电源比如USB的5V或DC-DC降压模块且舵机电源地和STM32的地必须共地否则PWM信号无参考电平舵机会乱抖。5. 联调实录与问题排查5.1 第一个坑推理延迟导致舵机跟不上第一次联调时我让Jetson Nano每帧都推理并发送指令结果发现舵机动起来像“抽风”角度跳来跳去根本无法稳定对准。排查后发现原因有两个模型推理预处理后处理的总耗时大约是50~80msFP16引擎也就是每秒大约12~20帧。但舵机机械响应本身需要约300ms才能完成转动如果每帧都发指令舵机永远在执行“上一个指令”的中途。检测结果的置信度波动会导致类别误判。某一帧检测到手机下一帧漏检再下一帧又检测到角度就变成90°→恢复中间→90°这样抖动。解决方案是加入“检测结果平滑”和“指令频率控制”# 只在置信度高于阈值且连续N帧为同一目标时才发送指令 if score 0.5: if cls last_cls and frame_count 3: send_servo_command(cls_to_angle[cls]) else: frame_count 1 else: detect_count 0简单来说连续5帧都识别出同一个类别才会发指令这样指令频率降到大约2~3Hz舵机动起来就平稳多了。5.2 第二个坑串口数据丢帧与粘包Jetson Nano用Python的pyserial发送数据时如果发送频率太快STM32端偶尔会出现解析错误。后来我用逻辑分析仪抓了波形发现UART数据本身没丢但如果Jetson Nano把两帧数据连续发出STM32的DMA缓冲会被一次性填满空闲中断只在最后一帧结束后触发一次结果一帧数据被当成了包含两帧内容的“粘包”。解决办法是在STM32解析时增加状态机。每收到一个字节就尝试匹配帧头0xAA 0x55只有匹配成功后才开始填充数据填满8字节后校验checksum校验通过才执行控制动作。关键状态机伪代码void parse_rx_byte(uint8_t byte) { switch (state) { case STATE_WAIT_HEADER1: if (byte 0xAA) state STATE_WAIT_HEADER2; break; case STATE_WAIT_HEADER2: if (byte 0x55) state STATE_RECEIVE_DATA; else state STATE_WAIT_HEADER1; break; case STATE_RECEIVE_DATA: rx_frame[rx_index] byte; if (rx_index 6) { // data_len cmd target_id angle checksum if (check_checksum(rx_frame) 0) { execute_servo_command(); } state STATE_WAIT_HEADER1; rx_index 0; } break; } }加入状态机之后无论是粘包还是半包都能正确处理。5.3 第三个坑舵机抖动和供电不足实机测试中常见的现象是舵机在不该动的时候轻微抖动或者转动到目标角度后一直发出嗡嗡声。这个问题的根源基本在供电。SG90的工作电流在空载时大约是100mA堵转时能到500mA以上。USB供电口或者STM32板上稳压芯片根本无法稳定提供这么大电流电压跌落导致舵机控制信号抖动。解决办法用一节18650电池3.7V升压到5V或者直接用一个5V 2A的独立电源模块给舵机供电。同时把舵机电源地、STM32电源地、Jetson Nano的USB转TTL模块地全部接在一起确保参考电平一致。5.4 常见问题速查表问题可能原因排查方法Jetson Nano推理速度很慢没有用TensorRT或未启用FP16确认加载的是.engine文件并检查是否使用--fp16转换舵机完全不转PWM频率不对或供电不足用逻辑分析仪查看PWM波形确认周期20ms、脉宽0.5~2.5ms串口收不到数据波特率不一致确认两端都是1152008N1舵机乱抖供电电压跌落换独立5V电源共地STM32偶尔复位舵机启动电流过大加大电源电容100uF以上或单独给舵机供电模型识别不稳定训练数据场景与实际不一致增加真实场景数据降低置信度阈值多次连续检测再判定6. 常见进阶扩展方向与经验总结6.1 后续可以往哪些方向扩展这个项目的框架一旦跑通扩展空间其实是很大的。我自己做完之后发现至少有三个方向很值得继续做下去第一把单舵机扩展成多舵机机械臂。Jetson Nano通过扩展协议里的target_id字段可以控制多个舵机甚至实现简单的“视觉引导抓取”。只要协议设计时预留了舵机编号代码改动并不大。第二把目标检测升级为视觉伺服控制。现在只是“识别类别→映射固定角度”属于开环控制。如果想做闭环可以检测目标在图像中的坐标然后根据坐标偏差实时调整舵机角度让摄像头“跟随”目标移动。这个玩法对实时性和控制算法要求更高但效果非常惊艳。第三换用Jetson Orin Nano或Orin Nano Super开发板。经典Jetson Nano的算力现在确实有些紧张Orin Nano能跑更大的模型、更高的帧率。如果你的项目需要同时处理多路摄像头或者更复杂的模型性能升级带来的体验差异是巨大的。6.2 个人实操过程中的几个体会回到这个项目本身我想说几点实际的体会。硬件联调一定给“电源”留够预算。这个项目里80%的诡异问题都出在供电上舵机抖动是供电问题、STM32复位是供电问题、Jetson Nano偶发死机也可能是供电问题。Jetson Nano官方推荐5V 4A电源但很多人包括我刚开始用充电宝或者普通手机充电器顶替最后发现推理时电压跌落会造成各种莫名问题。这里建议直接买一个5V 4A的电源适配器能省去很多烦恼。通信协议一定要设计成“可扩展”的。我最早设计的协议只有帧头角度校验后来想加舵机编号时发现不得不推倒重来。如果一开始就预留了cmd和target_id字段后面扩展就轻松很多。调试时尽量发挥逻辑分析仪的作用。STM32的串口数据、PWM波形都可以通过逻辑分析仪抓出来看比肉眼看代码推断快得多。我那时候用一根便宜的8通道逻辑分析仪很多丢帧问题都是靠它定位的。如果你也在做类似的项目可以先从“最小可用系统”入手先让STM32单独控制舵机转动再让Jetson Nano单独跑通推理然后把两者用串口连起来。每一步都确认稳妥后再继续下一步整个项目就不会陷入“到处都是问题、不知道从哪修起”的泥潭。本文还有配套的精品资源点击获取