
简介CANUAV 是一套基于 C 语言实现 UAVCAN 协议的源码工程面向使用 STM32F407 构建无人机、电机控制器及自动化设备通信节点的嵌入式开发者重点解决轻量级协议栈在 MCU 上的移植、收发与节点管理问题。压缩包共 36 个文件约 103KB以 8 个 h 头文件、6 个 c 源文件和 6 个 cpp 文件为主另含 7 个 md 文档、2 个 txt 说明及脚本、配置等辅助内容覆盖 Canard 核心库、STM32 驱动、示例程序与测试代码。已有 949 人学习下载适合具备一定 CAN 与嵌入式基础的中高级读者参考。源码中对 UAVCAN 消息到 CAN 帧的映射、心跳与节点状态监控、错误处理等环节均有体现读者可据此梳理协议栈目录结构、编译链接方式和移植要点并借助测试用例验证数据收发与功能稳定性快速把 UAVCAN 通信能力集成到自己的 STM32 项目中。1. 四轴在台架上好好的装机后一推油门转速就开始漂台架上一切正常装到机架上一推油门四个电机的转速差就开始飘。查到最后往往不是 PID 的问题是四路 PWM 在长线束上被电调开关噪声咬了一口占空比抖掉两三个百分点。十几公斤以上的机型线束一长这个问题就躲不掉——也正是在这个阶段很多团队会把机内控制总线从 PWM/PPM 换到 CAN。CANUAV 就是这类工程里常被拿来参照的一套 C 源码实现飞控主控、电调节点、电池管理节点挂在同一条 CAN 总线上用固定报文 ID 交换姿态、油门、电压和告警整套代码按 git 源码组织、纯 C 编写可以交叉编译到常见 MCU 上。这篇笔记面向已经能跑飞控、准备把机内总线换掉的人。2. 把 CANUAV 的 C 源码在本地跑通目录分层、交叉编译与虚拟总线上冒烟拿到一份飞控类 C 源码最怕的是上来就改 register编译过了、烧进去、灯不亮然后不知道从哪查。靠谱的顺序是先看清楚哪层是通用的、哪层必须动再把交叉编译链跑通最后在没有硬件的情况下用虚拟 CAN 接口把报文收发链路验一遍。这三步做完你手上就有了一个可以逐层替换的骨架。2.1 源码树里每个目录对应飞控的哪一层这类按 git 源码组织的飞控工程目录划分基本遵循同一套思路应用层与硬件解耦协议层与业务解耦硬件差异全部收敛到一个 hal 目录里。你打开源码树后对照下表看一遍就知道自己这块板子需要动哪些文件。目录职责移植时是否要动app/姿态解算、控制律、任务调度基本不动protocol/报文结构定义、打包解包、CRC 校验不动drivers/MCU 外设驱动封装CAN 控制器、定时器、串口必须替换hal/弱函数接口层向上暴露统一 API必须实现config/机型参数、节点 ID 表、任务周期表需要改tools/日志解析、报文回放、上位机脚本不动test/单元测试与总线回环测试建议补充判断一份源码质量高不高的一个土办法看protocol/里有没有#include任何 MCU 厂商头文件。如果有说明协议层被硬件污染了你在换平台时会连带改一堆业务代码这种工程要谨慎。config/目录是最容易出问题的地方。节点 ID 表、报文周期、波特率这些参数通常集中在一个头文件里用宏定义。改之前先备份改完在编译产物里用arm-none-eabi-objdump确认常量真的进了固件别出现改了宏但没重新预处理的情况。2.2 交叉编译从工具链文件到第一个 elf这类工程绝大多数用 CMake 组织仓库里通常带一份toolchain/目录里面有 cortex-m4 之类的工具链文件。如果你手上没有现成的照着下面这个写一份即可注意几个关键项CMAKE_TRY_COMPILE_TARGET_TYPE必须设成STATIC_LIBRARY否则 CMake 在配置阶段会尝试链接一个可执行文件裸机上必然失败。# toolchain/arm-none-eabi.cmake set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) # 浮点单元与指令集要和目标芯片严格一致写错会静默跑飞 set(CMAKE_C_FLAGS_INIT -mcpucortex-m4 -mthumb -mfpufpv4-sp-d16 -mfloat-abihard -ffunction-sections -fdata-sections -Wall -Wextra) # 链接脚本路径、nano 库、段回收 set(CMAKE_EXE_LINKER_FLAGS_INIT -T${CMAKE_SOURCE_DIR}/ld/canuav_f4.ld -Wl,--gc-sections -Wl,-Mapcanuav.map -specsnano.specs) # 关键告诉 CMake 不要尝试链接可执行文件来探测编译器 set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)写完工具链文件后用两条命令完成配置和构建。-DCANUAV_TARGET这类变量是这类工程常见的板级选择开关用来在多个板卡配置之间切换具体名字以你那份源码的 CMakeLists 为准。# 配置阶段指定工具链与目标板 cmake -S . -B build \ -DCMAKE_TOOLCHAIN_FILEtoolchain/arm-none-eabi.cmake \ -DCANUAV_TARGETboard_f4 \ -DCMAKE_BUILD_TYPERelease # 构建并查看尺寸确认没有超过 flash 与 RAM cmake --build build -j8 arm-none-eabi-size build/canuav.elfarm-none-eabi-size输出的三列分别是 text、data、bss。text 和 data 之和不能超过 flash 容量data 加 bss 不能超过 RAM这是最基础的检查。曾经见过有人把一块做缓冲的静态数组从 64 字节改成 4KB编译警告一条没有烧进去之后栈直接踩到堆上现象是偶尔失控——这种问题用调试器很难复现只能靠尺寸表提前发现。所以每次改完config/里的参数都跑一遍 size把这个习惯固定下来。如果构建时报undefined reference to hal_can_send之类的错误别急着去改 HAL先确认你的板级目录里有没有提供对应的实现文件、CMakeLists 里有没有把它加进源文件列表。弱函数只在有强符号覆盖时才会生效没编进去就是没编进去。2.3 没有硬件也能验用虚拟 CAN 加 socket 做冒烟测试飞控板还没焊回来或者焊回来了但 CAN 收发器没贴这时候最容易干的事就是干等。其实 Linux 内核自带虚拟 CAN 接口可以在完全无硬件的情况下把协议层的收发链路跑通一遍。前提是你那边的开发机内核编译了vcan模块。# 加载虚拟 CAN 模块并创建接口 sudo modprobe vcan sudo ip link add dev vcan0 type vcan sudo ip link set up vcan0 # 确认接口状态为 UPMTU 为 16CAN 帧载荷 8 字节 头 ip -details link show vcan0 # 开两个终端一边收一边发 candump vcan0 cansend vcan0 012#0102030405060708cansend的格式是ID#数据标准帧 ID 三位十六进制、扩展帧八位。012#...里的012就是 11 位 ID正好对应后面第三章要讲的位域划分。用这个命令手工灌几帧进去可以快速验证你的解包函数有没有把字段读对位置。要在代码里做自动化冒烟写一个几十行的 socket 程序就够了。这类工程通常有一个tools/目录把下面这段丢进去编译成主机可执行文件。// tools/vcan_probe.c #include stdio.h #include string.h #include unistd.h #include net/if.h #include sys/ioctl.h #include sys/socket.h #include linux/can.h #include linux/can/raw.h int main(void) { int fd socket(PF_CAN, SOCK_RAW, CAN_RAW); if (fd 0) { perror(socket); return 1; } struct ifreq ifr; memset(ifr, 0, sizeof(ifr)); strncpy(ifr.ifr_name, vcan0, IFNAMSIZ - 1); if (ioctl(fd, SIOCGIFINDEX, ifr) 0) { perror(ioctl); return 1; } struct sockaddr_can addr; memset(addr, 0, sizeof(addr)); addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; if (bind(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } struct can_frame f; memset(f, 0, sizeof(f)); // 优先级 0、类别 0x1姿态、节点 0x3 f.can_id (0u 8) | (0x1u 4) | 0x3u; f.can_dlc 8; for (int i 0; i 8; i) f.data[i] (uint8_t)i; if (write(fd, f, sizeof(f)) ! sizeof(f)) { perror(write); return 1; } printf(sent id0x%03X dlc%d\n, f.can_id, f.can_dlc); close(fd); return 0; }这段代码的逻辑很直白创建原始 CAN socket绑定到vcan0组一帧写进去。参数上要注意两点——can_id里如果最高位没有置CAN_EFF_FLAG内核就按标准帧发can_dlc必须小于等于 8经典 CAN 不允许超过 8 字节想传更长的数据只能在协议层自己拆包。编译和运行gcc -O2 -Wall -o tools/vcan_probe tools/vcan_probe.c ./tools/vcan_probe candump vcan0 # 另一个终端应能收到 013 [8] 00 01 02 03 04 05 06 07把源码里protocol/的打包解包函数抽出来和这个探针程序一起编译喂随机数据跑几万次检查解出来的值域是否合理。这一步做完你在硬件回来之前就已经把协议层的问题清掉了大半。3. CAN 帧怎么承载飞控数据11 位 ID 位域划分与打包解包CAN 总线本身只保证「一帧数据可靠送达」它不理解姿态、油门、电压这些概念。所以协议层的第一件事就是设计一套 ID 与载荷的映射规则。飞控场景的特点是报文种类不多、周期固定、实时性要求分层这决定了 ID 不适合做「流水号」而应该做成「可被硬件滤波器识别的分类标签」。3.1 把 11 位 ID 切成优先级、类别、节点三段标准帧 11 位 ID 在仲裁阶段逐位比较ID 数值越小优先级越高。这个物理特性直接决定了位域设计把最需要抢总线的报文放在高位。常见的切法是三段——高 3 位优先级、中间 4 位报文类别、低 4 位源节点地址。位域宽度取值范围用途bit10..8307优先级0 最高数值小者先赢仲裁bit7..44015报文类别区分姿态、遥控、电调反馈等bit3..04015源节点地址最多 16 个节点这套划分一共能表达 8×16×16 2048 个 ID对一架中型无人机来说绰绰有余。类别码在工程里一般按下面的方式分配类别码名称方向典型周期载荷说明0x1ATT 姿态飞控→总线4 ms横滚、俯仰、偏航、状态位0x2RC 遥控遥控器→飞控20 ms四通道摇杆、开关位0x3ESC 电调反馈电调→飞控10 ms转速、电流、温度、故障码0x4BMS 电池电池管理→飞控100 ms总压、单节压差、剩余电量0x5ALM 告警任意→飞控事件触发故障码与来源节点优先级分配是最容易踩坑的一环。姿态帧周期 4 ms遥控帧 20 ms看起来姿态更急所以很多人会把姿态设成最高优先级 0。但真正应该最高的是告警帧——电池欠压、电调过温这类事件是突发的如果被周期性姿态帧一直挤在后面最坏情况下要等几个姿态周期才能发出去。常见的做法是把告警放优先级 1、姿态放 2、电调反馈放 3、遥控放 4。protocol/里通常有一组内联函数负责这三个字段的拼装和拆解几十行值得背下来// protocol/canuav_id.h #include stdint.h #define CANUAV_ID(prio, cls, node) \ ((uint32_t)(((prio) 0x7u) 8) | ((uint32_t)((cls) 0xFu) 4) | ((uint32_t)((node) 0xFu))) static inline uint8_t canuav_id_prio(uint32_t id) { return (uint8_t)((id 8) 0x7u); } static inline uint8_t canuav_id_cls (uint32_t id) { return (uint8_t)((id 4) 0xFu); } static inline uint8_t canuav_id_node(uint32_t id) { return (uint8_t)(id 0xFu); }宏里每个字段都做了掩码这一点看着啰嗦但不能省。曾经有一版代码直接写了prio 8 | cls 4 | node某个节点的 ID 配成了 0x1F结果低 4 位溢出进到类别字段整个总线的报文分类全乱现象是飞控偶尔把电调反馈当成姿态数据处理姿态曲线出现尖刺。加掩码能在编译期就避免这类低级错误。3.2 姿态帧与油门帧的打包代码结构体对齐比字节序更容易翻车载荷设计有两个原则一是固定长度、固定字段位置二是所有浮点数量化成定点整数。CAN 一帧只有 8 字节不到位宽压缩光一个横滚角就要吃掉 4 字节剩下的啥都放不下。姿态帧的常见布局是横滚 2 字节、俯仰 2 字节、偏航 2 字节、状态位 2 字节。分辨率取 0.01 度int16 能覆盖 ±327.67 度够用。字节偏移字段类型缩放说明0..1rollint16×0.01 度横滚角2..3pitchint16×0.01 度俯仰角4..5yawint16×0.1 度偏航角量程更大6..7statusuint16位域bit0 解锁、bit1 定高、bit2 故障打包函数很多人会直接memcpy(dst, struct, 8)这在大端机上是灾难。结构体里 int16 和 uint16 的排列顺序在内存中的字节序取决于 CPU而总线上两端可能是不同架构。稳妥的写法是逐字段按显式字节序写// protocol/canuav_att.c #include string.h #include canuav_id.h typedef struct { int16_t roll_cdeg; // 0.01 度 int16_t pitch_cdeg; int16_t yaw_ddeg; // 0.1 度 uint16_t status; } canuav_att_t; // 大端写入CAN 帧统一定义为高字节在前 static inline void put_u16_be(uint8_t *p, uint16_t v) { p[0] (uint8_t)(v 8); p[1] (uint8_t)(v 0xFF); } void canuav_pack_att(uint32_t node, const canuav_att_t *a, uint8_t out[8]) { put_u16_be(out[0], (uint16_t)a-roll_cdeg); put_u16_be(out[2], (uint16_t)a-pitch_cdeg); put_u16_be(out[4], (uint16_t)a-yaw_ddeg); put_u16_be(out[6], a-status); } static inline uint16_t get_u16_be(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] 8) | p[1]); } void canuav_unpack_att(const uint8_t in[8], canuav_att_t *a) { a-roll_cdeg (int16_t)get_u16_be(in[0]); a-pitch_cdeg (int16_t)get_u16_be(in[2]); a-yaw_ddeg (int16_t)get_u16_be(in[4]); a-status get_u16_be(in[6]); }这套写法在逻辑上多了一次显式移位但有三个实际好处不依赖编译器对齐行为、不依赖 CPU 字节序、解包函数在主机上可以直接用同一份代码跑测试。代价是每次打包多几条指令对 4 ms 周期的任务来说完全无所谓。有一个必查项确认你的__attribute__((packed))没有加在协议结构体上。有人为了「省事」给canuav_att_t加上 packed然后对着结构体指针做类型转换直接读写这在 Cortex-M0 这类不支持非对齐访问的核上会直接触发 HardFault。用上面这种逐字段读写的写法结构体保持自然对齐反而不容易出事。另外浮点到定点的转换要注意饱和。(int16_t)(roll * 100.0f)在 roll 超过 327.67 度时会静默回绕成负数飞控看到的是姿态突然翻转。这类工程一般会有一个clamp_i16辅助函数转换前先限幅。3.3 波特率、采样点和终端电阻三个必须先定的物理层参数协议层写得再漂亮物理层参数不对一样通不了。这三个参数在联调初期就该定死后面不要随意改因为一改就要所有节点同步重新烧录。参数常见取值影响检查方式波特率500 kbps / 1 Mbps决定总线最大负载与线束长度容忍度示波器测位宽或用回环自测采样点75%87.5%采样点太靠前长线反射会误采计算 tq 分配后核对终端电阻总线两端各 120 Ω缺失会反射重复会加重负载断电后万用表量总线阻值约 60 Ω波特率和采样点是一起算的。以 1 Mbps、CAN 时钟 8 MHz 为例一个位时间 16 个 tq常见分配是同步段 1 tq、传播段加相位段 1 共 12 tq、相位段 2 为 3 tq采样点落在 (112)/16 81.25%。这个值对绝大多数线束长度友好。如果把采样点压到 68% 以下长线束上的反射还没稳定就采样误码率会明显上升表现出来就是「偶尔丢一帧」非常难查。终端电阻的坑更隐蔽。规范要求总线两个物理末端各一个 120 Ω中间节点不能接。但很多电调板子出厂就板载了 120 Ω四五个电调挂上去等效阻值掉到二三十欧收发器驱动电流不够波形幅度被压扁。断电后用万用表量 CANH 与 CANL 之间的直流阻值接近 60 Ω 是对的明显偏低就说明有多余的终端电阻需要把中间节点的拆掉。提示量阻值必须断电且总线两端设备都要断开电源否则测出来的是收发器内部电路并联后的结果没有参考意义。4. 把 CANUAV 移植到自己的板子HAL 弱函数裁剪与任务节拍协议层验通之后下一步是把它接到你自己的 MCU 上。这类工程的移植面通常被刻意收窄了——核心思路是把所有与硬件相关的操作收敛到少数几个弱函数里你只需要实现这几个函数上层代码一行不改。听起来很美好但实际移植时真正花时间的往往不是函数实现本身而是滤波器配置和任务节拍。4.1 三个必须由你实现的 HAL 接口接口层一般长这样发送、接收、微秒时间戳。三个函数都用__attribute__((weak))声明默认实现返回错误码或零你的板级文件里给出强符号覆盖。这样设计的好处是如果漏了某个实现链接不会失败但运行时初始化检查能立刻发现。// hal/hal_can.h #include stdint.h typedef struct { uint32_t id; // 11 位标准帧 ID uint8_t dlc; // 数据长度 0..8 uint8_t data[8]; uint32_t ts_us; // 接收时间戳微秒 } canuav_frame_t; // 以下三个函数由板级实现覆盖 int hal_can_send(uint32_t id, const uint8_t *data, uint8_t dlc); int hal_can_recv(canuav_frame_t *frame, uint32_t timeout_ms); uint32_t hal_micros(void); // 默认桩放在 hal/hal_can.c __attribute__((weak)) int hal_can_send(uint32_t id, const uint8_t *data, uint8_t dlc) { (void)id; (void)data; (void)dlc; return -1; // 未实现 } __attribute__((weak)) uint32_t hal_micros(void) { return 0; }三个函数里hal_can_send和hal_can_recv的实现难度不高真正需要想清楚的是时间戳。hal_micros必须是一个单调递增的微秒计数最好由硬件定时器自由运行产生不要用系统节拍乘以 1000 去凑。用节拍凑出来的时间戳分辨率是 1 ms在 4 ms 的控制周期里误差能到 25%算抖动的时候完全没有参考价值。时间戳的另一个问题是回绕。32 位微秒计数在 4294 秒、也就是 71 分半的时候会归零。飞控一次起飞可能就十几分钟看似碰不上但地面调试挂机一小时以上是常事一旦回绕任何用if (now last)判断超时的代码都会立刻失效。稳妥的写法是用无符号差值// 计算两个时间戳的间隔自动处理 32 位回绕 static inline uint32_t ts_diff(uint32_t now, uint32_t last) { return now - last; // 无符号减法回绕时结果仍然正确 }这个写法依赖无符号整数的模运算性质只要两次采样的真实间隔不超过 2^31 微秒约 35 分钟就永远正确。控制周期都是毫秒级远在这个范围内。hal_can_recv建议做成阻塞带超时的语义超时参数传 0 表示非阻塞轮询。上层任务用一个统一的接收线程去收收到后按类别分发到不同队列不要在接收路径上做浮点运算或者动态内存分配。4.2 接收滤波器与 FIFO别让飞控收下整条总线的垃圾滤波器配置是移植阶段最容易忽略、又最容易在联调时反咬一口的部分。CAN 控制器的硬件滤波器可以在不占用 CPU 的情况下丢弃不关心的报文。默认配置通常全通过也就是总线上每一帧都会触发接收中断。如果你的飞控和电调、电池、图传模块挂在同一条总线上一秒钟几百帧里可能只有几十帧是飞控要的剩下的全在消耗中断和队列。按第三章的位域设计滤波器应该只保留类别在关心范围内的报文。以常见的 bxCAN 32 位掩码模式为例类别字段位于 ID 的 bit7..4对应的掩码是 0x0F0// drivers/can_filter.c #include main.h // 只接收指定类别、任意节点、任意优先级的报文 int canuav_filter_accept_class(CAN_HandleTypeDef *hcan, uint32_t bank, uint8_t cls) { CAN_FilterTypeDef f {0}; uint32_t id ((uint32_t)(cls 0xFu) 4); f.FilterBank bank; f.FilterMode CAN_FILTERMODE_IDMASK; f.FilterScale CAN_FILTERSCALE_32BIT; f.FilterIdHigh (uint16_t)((id 0x7FFu) 5); f.FilterMaskIdHigh (uint16_t)((0x0F0u 0x7FFu) 5); // 只比较 bit7..4 f.FilterIdLow 0; f.FilterMaskIdLow 0; f.FilterFIFOAssignment CAN_FILTER_FIFO0; f.FilterActivation ENABLE; return (HAL_CAN_ConfigFilter(hcan, f) HAL_OK) ? 0 : -1; }这里有两个参数需要解释。FilterIdHigh左移 5 位是因为 bxCAN 的 32 位寄存器里标准 ID 存在 bit15..5不是从 bit0 开始对齐的这个偏移写错是滤波器不生效的头号原因。FilterMaskIdHigh用 0x0F0 而不是 0x7FF意味着只有类别字段参与比较优先级和节点地址随意——这正是我们想要的飞控要收所有节点上报的电调反馈。如果 MCU 支持多组滤波器可以给每个类别分一个 bank全部指向 FIFO0这样接收路径只需要读一个 FIFO。某些控制器有两个 FIFO可以把高频的姿态相关报文放 FIFO0、低频的电池报文放 FIFO1配合不同的中断优先级避免低频报文把高频路径堵住。还有一个隐藏问题滤波器数量是有限的超了会静默失败HAL_CAN_ConfigFilter返回 OK 但实际没配上。配置完成后建议读一次寄存器的CAN_FMR和CAN_FA1R确认使能位确实置上了这是那种「不看就不知道」的黑匣子。4.3 任务周期与优先级怎么排CAN 收发本身不复杂复杂的是它和控制律、姿态解算抢 CPU。这类工程一般跑在 RTOS 上任务划分有比较固定的套路。原则只有一条CAN 中断和收发任务只做搬运不做计算。任务周期优先级职责CAN 接收中断触发最高从中断搬运到环形队列不做解析姿态解算4 ms高读 IMU、融合、写共享姿态控制律4 ms高读姿态与遥控算出力矩写入发送队列CAN 发送1 ms中从发送队列取帧调用 hal_can_send状态上报50 ms低电压、温度、错误计数姿态解算和控制律放在同一个 4 ms 节拍里中间用静态变量传递不要用队列。这个数据每 4 ms 就刷新一次队列只会引入延迟和内存开销。CAN 发送单独一个任务是因为hal_can_send在邮箱满的时候可能阻塞把阻塞点隔离在发送任务里控制律就不会被拖住。发送队列的长度要算一下。1 Mbps 下一帧标准帧最坏约 130 位也就是 130 微秒。4 ms 周期里最多能发 30 帧队列深度取 8 到 16 足够覆盖突发。队列满了的策略应该是丢最旧的还是丢最新的取决于业务——姿态帧丢旧的告警帧丢新的反而更合理这个要在代码里写清楚别用默认行为。注意不要在 CAN 接收中断里调用任何带锁的函数也不要在中断里做协议解析。中断服务程序里只应该有一次 FIFO 读取加一次入队超过 20 行就该重新审视。5. 联调时最容易翻车的四个地方一份 CANUAV 排查清单前面几章的配置都做完总线能通、报文能看到、姿态也能解出来这时候最容易松懈。实际情况是从「能通」到「能飞」之间还有一段距离这段距离上的问题往往表现为间歇性故障抓不到现场就只能靠猜。下面四条是我自己踩过、也见过别人反复踩的按现象、原因、解决三段写。5.1 现象地面一切正常一推油门姿态就发散原因通常不是控制参数而是姿态帧的时间戳对不上。执行机构一上电母线电流突变CAN 收发器附近的电源纹波变大某些节点的报文会延迟几毫秒才发出来。如果接收端不检查时间戳把这些迟到帧当成当前姿态用控制律拿到的就是几毫秒前的旧数据相位滞后一叠加增益稍高就会震荡。解决办法是在姿态帧里塞一个 16 位的递增序列号接收端维护一个「最近 5 帧的到达间隔中位数」某帧的间隔超过中位数的 3 倍就标记为可疑交给控制律时做一阶预测补偿或者直接丢弃。这个补偿不需要很精确能挡住几毫秒的抖动就够了。另一个必查项是确认打包和解包用的字节序一致——两端如果一端大端一端小端地面小角度时数值都接近零看不出来一推油门角度变大解出来的值立刻离谱。5.2 现象总线负载不到 30%却偶发丢帧负载率不高还丢帧八成是接收路径堵了。最常见的是在 CAN 接收中断里直接做浮点解算或者调用了带互斥锁的函数。中断执行时间一长FIFO 里积压的帧超过 3 个硬件就溢出丢帧。另一种情况是发送侧在控制任务里同步等待邮箱空位HAL_CAN_AddTxMessage返回 HAL_BUSY 后原地重试把 4 ms 的控制周期撑成了 6 ms。处理方式是把收发彻底分离中断只做一次 FIFO 读取把 8 字节数据和时间戳塞进环形缓冲区立刻退出解析在低优先级任务里做发送走独立队列。改完之后用 GPIO 翻转配合示波器量一下中断服务程序的执行时间控制在 10 微秒以内算合格。5.3 现象某些机器上电就通某些机器死活不通同一批板子有的上电就能通信有的完全没反应换了板子又好了。这种和硬件强相关的问题先查终端电阻。断电后用万用表量 CANH 与 CANL 之间的阻值接近 60 Ω 正常量出来 40 Ω 说明有节点板载了多余的 120 Ω量出来 120 Ω 说明只有一端接了量出来是开路说明线束或者接插件有问题。再查共地。CAN 是差分信号理论上对共模不敏感但收发器的共模范围是有限的一般 -2V 到 7V。如果飞控和电调由不同电池供电两边地电位差超过这个范围收发器就会进入保护状态表现就是完全没波形。解决办法是用一根足够粗的地线把所有节点连起来或者使用带隔离的收发器。隔离收发器贵一点但在多电源系统里能省掉大量玄学排查时间。5.4 现象挂机超过一小时后控制周期突然变大这就是前面提到的 32 位微秒计数回绕。挂机 71 分半hal_micros归零任何写成if (now last period)的超时判断都会失效任务调度器认为时间永远没到控制周期从 4 ms 变成一个不确定的大值。飞机在天上飞的时候碰不到但地面长时间测试一定会遇到而且现象很吓人——舵机突然停住或者全速偏转。改法就是把所有时间比较换成无符号差值uint32_t dt now - last;然后判断dt period。这个改动很小但要全局搜一遍包括那些第三方库里抄来的延时函数。搜索关键词是和配合时间变量名人工过一遍比什么都可靠。还有一条容易漏的如果代码里用了int32_t存时间戳差值回绕时会变成负数加法变减法。时间相关的变量一律用无符号这条规矩值得写进团队的编码规范里。6. 上机之前用总线负载率和周期抖动判断这套 C 源码够不够稳前面所有步骤做完总线能通、报文正确、任务节拍也排好了剩下的问题只有一个这套东西能不能上机。我的判断标准只有两个数字——总线负载率和周期抖动。负载率决定还有多少余量应对突发抖动决定控制律拿到的时间基准有多可信。负载率不能靠感觉估得算。经典 CAN 标准帧的最坏位长度包含帧间隔8 字节载荷大约 130 位考虑到位填充最坏情况再加 10% 左右。写个小函数放在工具目录里把配置表读进来算一遍// tools/bus_load.c #include stdio.h // 估算标准帧最坏位长度帧头帧尾约 47 位 8*N 位载荷 位填充余量 static float frame_bits(int dlc) { int raw 47 8 * dlc; return raw * 1.1f; } int main(void) { // 按 4.3 节的任务表姿态 250 帧/秒、电调 400 帧/秒、遥控 50 帧/秒、电池 10 帧/秒 struct { const char *name; int fps; int dlc; } tbl[] { { ATT, 250, 8 }, { ESC, 400, 8 }, { RC, 50, 8 }, { BMS, 10, 8 }, }; float total 0; for (unsigned i 0; i sizeof(tbl) / sizeof(tbl[0]); i) { float bits frame_bits(tbl[i].dlc) * tbl[i].fps; total bits; printf(%-4s %4d fps %8.0f bit/s\n, tbl[i].name, tbl[i].fps, bits); } printf(total %.0f bit/s, load 1Mbps %.1f%%\n, total, total / 1e6f * 100.0f); return 0; }按这张表跑出来1 Mbps 下的负载率大约在 40% 出头。行业里普遍的经验阈值是稳态不超过 50%峰值不超过 70%。超过 50% 之后低优先级报文的排队延迟会明显变长遥控帧从 20 ms 周期变成 25 ms 你都察觉不到但飞手手感会有差别。如果预算超了第一件事是把电调反馈的周期从 10 ms 放到 20 ms它对控制的影响远小于姿态。抖动指标更好测。在接收端记录每一帧的ts_us算相邻帧的间隔跑一分钟统计最大值和标准差指标合格线超标时的动作平均周期偏差 5% 标称周期检查任务节拍源最大间隔 3× 标称周期查中断延迟与队列深度间隔标准差 10% 标称周期查发送任务是否被阻塞帧丢失率 0.01%查总线负载与终端电阻我自己的一条血泪经验是地面测试时所有指标都漂亮装机首飞前忘了重跑一遍。机架上的线束长度、走线位置、电调安装角度全变了电磁环境完全是另一回事。后来养成习惯每次装机完成、螺旋桨装上之前都要架起来空转一分钟把抖动数据抓下来看一眼。有没有后悔药取决于你在这五分钟里做了什么。这套 C 源码值不值得投入我的判断是如果你的机型起飞重量超过十公斤、节点数超过四个、线束长度超过半米CAN 带来的确定性和可诊断性远超迁移成本如果只是个小四轴串口和 PWM 完全够用不必为了架构好看去折腾。希望帮到你。本文还有配套的精品资源点击获取