
简介基于STM32的无人船/无人车下位机程序配合树莓派、Jetson Nano等上位机端使用面向嵌入式、自动化、电子信息等专业学生及开发者适用于毕设、课设、竞赛或项目初期验证。系统采用类操作系统式任务框架支持不同频率任务调度仅需修改宏定义即可切换编译方案兼顾灵活性与低耦合。包内共154个文件涵盖53个头文件、51个C源文件、32个汇编文件另有Keil工程配置、Hex固件、说明文档与原理图等压缩包仅856KB轻量易部署。当前已有504人学习下载。代码均经过运行验证平均答辩评分96分并附README及使用说明遇到问题可私聊咨询甚至远程教学。整体目录结构规范既适合小白从零上手也便于进阶开发按模块裁剪复用可作为无人系统控制链路搭建的参考样板。1. 无人船/无人车为什么要拆成 STM32 下位机和树莓派/Jetson 上位机真正决定一台无人船能不能在 3 级风浪里把航向稳住 5 秒的往往不是那块算力最强的英伟达 Jetson Nano而是它旁边那颗看起来不起眼的 STM32。Linux 系统负责认路、看摄像头、跑导航算法但没法保证“每 10ms 必须发一次 PWM 波”这种硬实时任务STM32 恰好相反几百字节的 C 状态机就能做到确定性输出。这个标题里的工程就是要把实时控制和安全逻辑放到 ARM Cortex-M 端把环境感知和决策放到树莓派或 Jetson Nano 端两端用串口/CAN 连接最终形成一套可维护的四轮或双差速无人平台。适合正在做毕业设计、竞赛或小型产品原型的人也适合刚接触上下位机协作开发、想知道 C 工程怎么组织的团队。2. STM32 下位机 C 源码先搭对时钟、PWM 和串口收包2.1 从 CubeMX 模板开始不要手写启动文件STM32 下位机端的源码骨架一般由 STM32CubeMX 生成这样时钟树、引脚复用和中断分组不会出错。工程选型时我会优先选引脚多、有硬件 UART 且支持浮点单元的型号比如 STM32F103C8T6 适合入门STM32F407VET6 适合同时带 4 路电机 PWM、2 路串口和一个 CAN 的无人车。需要特别注意的是时钟配置外部晶振频率不一致会导致串口波特率漂移PWM 频率算不准这是在“使用说明”里必须写清楚的第一个默认项。生成工程时关键配置是这样几步先把 RCC 设为 HSE 外部晶振SYS 调试口选 Serial Wire然后根据外设引脚分配串口和定时器。官方固件包要和 CubeMX 版本匹配否则生成 C 源码时会出现 HAL 库函数签名对不上。常见的报错“no stm32 target found”发生在这一步之后通常是 ST-LINK 连接问题和代码本身无关后面第 5 章会专门讲。# 用 STM32CubeMX 生成工程后源码目录通常是 Project/ Core/Src/main.c Core/Src/stm32f4xx_hal_msp.c Drivers/STM32F4xx_HAL_Driver/ Makefile生成后不要着急写业务逻辑先编译一次空工程确认工具链能跑通。直接在 STM32CubeIDE 里点编译或者命令行执行make都可以。之后把生成的 Makefile 固化到 git团队其他人在 Linux 上也能靠make复现。这一步的意义在于你后面的电机控制、串口收发都要在一个可回退的模板上迭代而不是改一行代码就冲一次下载器。2.2 PWM 波输出电机和舵机的底层动作无人车转向一般由数字舵机完成推力靠直流有刷电机或电调。两者的共同点是都需要特定频率的 PWM 波。舵机是 50Hz脉冲宽度 1ms~2ms 对应 -45 度到 45 度电调一般是 50Hz 或 400Hz脉宽 1ms~2ms 对应油门最小到最大。这里最常见的错误是把“频率”和“占空比”混着调导致舵机抖舵、电机不转。我用 TIM1 的 CH1 和 CH2 来生成两路 PWM配置如下/* TIM1 挂接在 APB2时钟 168MHz */ htim1.Instance TIM1; htim1.Init.Prescaler 168 - 1; /* 168MHz / 168 1MHz 计数频率 */ htim1.Init.Period 20000 - 1; /* 计数 20000 次20ms 周期50Hz */ HAL_TIM_PWM_Init(htim1); /* CH1 舵机1.5ms 对应 0 度 */ TIM1-CCR1 1500; /* CH2 电调1ms 油门 */ TIM1-CCR2 1000; HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_2);Prescaler 和 Period 决定 PWM 周期CCR 决定脉宽。如果电机噪声大可以把 Prescaler 调低让 PWM 频率改成 8kHz同时诚实地把 Period 改成1000000/8000 - 1。要注意 TIM1 是 16 位计数器Period 最大值不能超过 65535否则溢出造成频率跳变。舵机和电调的地线必须和 STM32 共地否则 PWM 波参考点漂移下位机程序看起来“没死”电机就是不动。2.3 串口中断收帧不能阻塞在 while下位机要处理的是来自上位机的速度、转向、模式指令这些指令通常以固定长度帧到达一帧 8 字节左右。如果只在主循环里查询 UART 数据寄存器高并发时很容易丢字节。我一般用 HAL 库的HAL_UART_Receive_IT开启单字节接收中断每收到一个字节就放在一个小缓存里做状态机。#define FRAME_HEAD 0xAA #define FRAME_LEN 8 volatile uint8_t rx_buf[FRAME_LEN]; volatile uint8_t rx_cnt 0; volatile uint8_t frame_ready 0; static uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { /* 按字节累积第 0 个字必须等于帧头 0xAA否则整体清空 */ if (rx_cnt 0 rx_byte ! FRAME_HEAD) { return; /* 丢弃乱码 */ } rx_buf[rx_cnt] rx_byte; if (rx_cnt FRAME_LEN) { frame_ready 1; rx_cnt 0; } /* 重新使能单字节接收 */ HAL_UART_Receive_IT(huart, rx_byte, 1); } }这里我用一个frame_ready位告诉主循环“有一整帧到了”主循环把缓存里的数据拷贝到本地再清标志位。必须在回调里判断帧头否则噪声数据会把帧边界打乱。这个方案没有用 DMA适合低速控制帧如果未来要回传图像或者高频率遥控数据再加 DMA 或改用环形缓冲区。之后在主循环里调用HAL_UART_Receive_IT(huart1, rx_byte, 1)启动接收并且保证每个周期重新进一次接收。外设配置项推荐值说明USART1Baud Rate115200默认通信速率两端口径必须一致TIM1 CH1PWM Frequence50Hz舵机转向TIM1 CH2PWM Frequency50Hz~8kHz电调/直流电机GPIO方向引脚PC13/PC14控制正反转或刹车继电器3. 树莓派/Jetson 上位机 C 程序跨平台串口收发与控制仲裁3.1 在板卡上原生编译还是 PC 交叉编译树莓派 4B 和 Jetson Nano 官方镜像都自带 Linux 工具链如果源码规模不超过几万行 C我倾向于直接在目标板上编译省去交叉编译链的版本匹配问题。Jetson Nano 是 aarch64 架构树莓派 4B 如果烧的是 32 位系统则用 armhf64 位系统用 aarch64。项目说明文档里标题既然写了“C 源码”那就必须给出这两套编译命令否则用户按照习惯在 PC 上编译会出现“执行格式错误”。# 树莓派 4B 32 位系统下直接编译 gcc -o boat_control src/main.c src/serial.c -Isrc -Wall -pthread # 在 PC 上交叉编译 Jetson Nano 程序 aarch64-linux-gnu-gcc -o boat_control src/main.c src/serial.c -Isrc -Wall -pthread-pthread必须加上因为上位机程序至少两个线程一个负责定时发控制帧一个负责读下位机回传状态。交叉编译时如果板卡上串口设备名不同要把宏定义或者配置文件剥离出来不要写死在代码里。树莓派的硬件串口在/dev/ttyAMA0或/dev/serial0Jetson Nano 上一般是/dev/ttyTHS1这些内容要在使用说明里用一张表列清楚。3.2 用 termios 配置串口别用 system 调 stty写 Linux 串口程序第一步是打开设备第二步就是用tcgetattr和tcsetattr设置原始模式。新手常犯的错误是调用system(stty -F /dev/ttyUSB0 115200)这会让后续 C 程序拿到的串口配置带着换行转换和回显收帧时多出\r或丢字节。这里给出一个稳定的串口配置int open_serial(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) return -1; struct termios opt; tcgetattr(fd, opt); cfsetispeed(opt, baud); cfsetospeed(opt, baud); opt.c_cflag | (CLOCAL | CREAD); /* 忽略调制解调器控制允许读取 */ opt.c_cflag ~PARENB; /* 无校验位 */ opt.c_cflag ~CSTOPB; /* 1 位停止位 */ opt.c_cflag ~CSIZE; opt.c_cflag | CS8; /* 8 位数据位 */ opt.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); opt.c_iflag ~(IXON | IXOFF | IXANY); opt.c_oflag ~OPOST; tcsetattr(fd, TCSANOW, opt); tcflush(fd, TCIOFLUSH); return fd; }配置项里的~ICANON很关键因为标准模式会以换行为单位切数据控制帧里没有换行收多了会一直在内核缓冲区里待着。~ECHO防止串口回显污染数据。波特率可以直接传入B115200或B460800不要再手动除波特率。打开串口后如果返回-1先执行ls -l /dev/serial*确认设备节点存在然后把自己的用户加入dialout组sudo usermod -a -G dialout $USER3.3 控制指令仲裁与心跳机制上位机从远程指令、航线任务或遥控手柄拿到控制目标之后不能直接把这个值塞进串口。我给上位机设计一个“指令仲裁”函数它会根据当前安全状态决定三个输出允许前进速度、允许转向角、是否直行。比如收到“急停”指令无论其他模块怎么要求仲裁器强制目标速度和转向角归零并置位紧急停车标志。control_cmd_t arbitrate_commands(void) { control_cmd_t cmd {0}; if (remote_estop_active) { cmd.speed 0; cmd.steer 0; cmd.mode MODE_STOP; trace_log(E-STOP triggered from remote); return cmd; } /* 无有效帧超过 300ms下位机失联 */ if (get_tick_ms() - last_rx_tick 300) { cmd.mode MODE_HOLD; cmd.speed 0; cmd.steer last_valid_steer; return cmd; } cmd.speed target_speed; cmd.steer target_steer; cmd.mode MODE_AUTO; return cmd; }真实无人船项目中还要在这里叠加低电量保护和水深/障碍物提示。仲裁结果由发送线程以 10Hz 频率封装成控制帧写入串口在下位机收到有效帧时会回传一个心跳 ACK上位机收到后更新last_rx_tick。这样只要上位机进程卡死或者串口断开下位机就能自主停车不会因为失去主人而继续前进。4. 上下位机通信协议设计与联调4.1 帧结构固定长度、小端、 CRC 放在最后上下位机之间我习惯用固定长度帧协议越简单越不容易出问题。一帧 8 字节定义如下第 0 字节固定0xAA第 1 字节长度0x08第 2 字节命令字0x01控制、0x02参数回读第 3~4 字节是有符号 16 位速度第 5~6 字节转向第 7 字节是前 6 字节的 CRC8 校验。所有字节按小端序这样直接强转结构体访问。偏移字段类型示例值含义0HEADuint8_t0xAA帧头1LENuint8_t0x08整帧长度2CMDuint8_t0x01控制指令3~4SPEEDint16_t100归一化速度范围 -200~2005~6STEERint16_t-45转向角度 x107CRC8uint8_t0x5C前 6 字节校验CRC8 算法选择 poly0x31网上有很多标准实现但要保证上下位机复用同一个查表程序。最容易踩的坑是 C 语言结构体对齐下位机用指针强转收到的字节数组时编译器默认对齐会让字段偏移错位所以这里必须用#pragma pack(push, 1)把结构体紧缩为 1 字节对齐#pragma pack(push, 1) typedef struct { uint8_t head; uint8_t len; uint8_t cmd; int16_t speed; int16_t steer; uint8_t crc; } control_frame_t; #pragma pack(pop)在 STM32 上这类结构体访问频繁如果不开__attribute__((packed))直接访问结构体的speed字段可能触发硬件错误因为地址没有自然对齐。用memcpy拷贝是更安全的做法但在资源受限的 MCU 上成本略高。联调第一件事就是验证下位机打印出来的帧长度是 8而不是 10 或 12。4.2 上下位机 CRC 校验不一致的排错下位机解析代码里我一般会在收到完整帧后先算一次 CRC再和帧末字节比较。CRC 校验通过后才允许进入执行分支。如果联调时下位机能收到字节但一直返回NACK八成是上位机发送时最后一位 CRC 算错或字节序反了。此时不要怀疑下位机引脚先用一个简单的 Python 或 C 测试程序生成常量帧# 输出一帧手动构造的串口测试数据到设备 printf \xAA\x08\x01\x64\x00\xD3\xFF\x5C /dev/ttyTHS1如果下位机用示波器抓到的波形里能看到0xAA并且收到后执行动作说明串口通路没问题。如果电机动作但转向相反请检查 STEER 的正负映射而不是改代码。我给这种工作方式起了个名字叫“帧级联调”用固定帧代替真实上位机可以在极短时间里把下位机的问题隔离出来。4.3 联调时的三个关键指标联调时看着上位机日志不能只看“有没有收到包”要看丢包率、帧间隔抖动和响应延迟。上位机发送线程我固定用 10Hz那么下位机每 100ms 会收到一帧实际表现中偶尔丢一帧可以接受但连续丢 3 帧以上就必须停车。因此协议里要约定下位机连续 50 帧没有有效接收窗口时自动把电机 PWM 置为最小。另一个容易被忽略的是串口缓冲区vmin和vtime。用read()读串口时默认会阻塞如果下位机 200ms 才回一帧上位机会一直卡住。建议把实际读取放到独立线程并且超时设成 100ms。下位机解析出错的次数同样要上报到上位机日志对判断电磁干扰导致的字节错位非常有帮助。5. 部署验证与排错技巧从“能编译”到“敢上水”5.1 使用说明文档里的三个必备章节这个项目标题里特意包含了“文档说明使用说明”我在组织这类源码包时一定会在 docs 目录下放三份东西01_hardware.md接线表和引脚对应02_build.md编译步骤和依赖03_usage.md操作步骤。硬件接线表里必须画清 STM32 和树莓派/Jetson 的地线连接以及串口 TX/RX 交叉关系。编译文档里写死固件版本号例如 STM32CubeF4 1.27.1不要写“最新版”这种会过时的词。使用说明里还要有上电顺序先开下位机再启动上位机程序不然下位机可能因误收乱码进入保护状态。5.2 启动脚本和异常退出后恢复一套可靠的启动流程可以用一个 shell 脚本完成记录进程号和日志路径方便持续检查进程状态#!/bin/bash # 启动上位机程序输出到日志保留 pid 便于 kill sudo ./boat_control -d /dev/ttyTHS1 -b B115200 -l /var/log/boat.log 21 echo $! /run/boat.pid sleep 2 kill -0 $(cat /run/boat.pid) echo boat_control started-d、-b、-l是自研程序支持的命令行参数Jetson 和树莓派上的默认串口名不一致这种通过参数注入的方式比改代码方便得多。如果程序 3 秒内退出去日志文件里找串口打开失败或权限拒绝。很多“源码没问题但跑不起来”最后都卡在dialout权限或设备名错误。5.3 高频报错与排查速查表现象可能原因检查顺序上位机启动报Permission denied当前用户不在 dialout 组groups添加组后注销重登ST-LINK 报no stm32 target found连接松动、目标板供电不足先接 USB 供电按住复位再点连接Windows 下串口叹号STM32 VCP 驱动未安装换一根数据线重新安装官方 VCP 驱动下位机能收帧但电机不动舵机/电调共地不足或 PWM 周期不对示波器量 PWM 引脚检查 Prescaler树莓派 LED 红灯常亮绿灯不亮电源电流不够换 5V/3AUSB 外设逐个断开这个表基本覆盖了“基于 STM32 树莓派/Jetson 无人船无人车”项目在部署阶段 70% 的问题。每一条的背后都是真实调试中会反复遇到的硬问题。5.4 最后一条建议串口联调通过后最好在船/车底盘悬空的情况下先做 5 分钟怠速测试观察 STM32 温度和保护动作。确认热电和通信都稳定后再装螺旋桨或实车上路并且第一步不给全油门只用遥控慢速挡测试 30 秒。整套代码的可靠性从来不是靠编译通过来保证的而是靠上下位机都具备明确的失联降级策略。所以如果你要改这个源码最优先动的地方不是控制 PID而是把超时阈值和错误计数打出来。本文还有配套的精品资源点击获取