简介面向嵌入式开发者的 UAVCAN 协议栈 C 源码包聚焦在 STM32F407 平台上构建可靠、实时的 CAN 总线节点。内容以轻量级 Canard 实现为核心涵盖消息映射到 CAN 帧、数据收发、心跳与节点管理等关键机制并保留 STM32、AVR、NuttX、SocketCAN 等多平台适配线索适合已具备一定 C 语言与单片机基础、希望快速集成 UAVCAN 通信的开发者参考。压缩包共 36 个文件约 103KB主要由 h 头文件、c/cpp 源文件、md 说明文档及少量工程配置与测试脚本构成便于按模块阅读协议实现与移植要点。已有 949 人学习下载可作为理解 UAVCAN 帧格式、节点状态监控和故障检测的实现样例也可为扩展服务类型、联调飞控/电机控制器/GPS 接收机等设备提供代码基础。1. CANUAV 是什么一条 29 位扩展帧 ID 上的无人机总线协议CANUAV 这个标题背后指的是一套跑在无人机上的 CAN 节点协议实现语言是 C分发形态是 git 源码。它解决的不是“CAN 怎么收发”这种底层问题——片上 CAN 外设的驱动各家芯片厂商都给了——而是“十几个节点挂在同一对差分线上谁先发、发给谁、一条消息拆成几帧、丢了怎么发现”这一层。协议把 29 位扩展帧 ID 切成优先级、服务标志、端口号、源节点号几个位段把多帧传输的起始、结束、翻转位和传输 ID 压进负载的最后一个字节剩下的活交给一份可移植的 C 代码对象池管内存订阅表管分发移植层只暴露几个函数给你对接硬件。适合谁适合已经在飞控或测试台上跑通裸机 CAN 收发、下一步要把节点数量从两三个扩到十个以上、并且不想自己拍脑袋定一套私有协议的团队。git 拉下来能编、能跑但真要上板移植层和内存策略这两处必须自己动手。2. 把 git 仓库读薄CANUAV 源码的三层结构与 29 位 ID 拆解2.1 仓库目录哪三个目录决定可移植性从 git 拉下来的第一件事不是编译是git ls-files | head -50看目录分布。这类协议实现的仓库无论作者怎么组织实质上都分三层认清这三层比读十个源文件有用。第一层是协议层一般落在src/protocol.c、src/transport.c这类文件里负责 ID 的打包解包、帧类型判定、CRC 计算、多帧重组。这一层的代码不该出现任何寄存器名、任何#include 芯片厂商头文件一旦出现说明作者把移植代码写进了业务逻辑你后面换平台会很痛苦。第二层是对象与资源层通常是src/node.c、src/mempool.c、src/subscription.c。这一层解决的是“收到的帧放哪、发给谁、发不出去怎么办”。它决定了协议栈在长时间运行下的稳定性也是最容易藏内存泄漏的地方。第三层是移植层一般在port/目录下可能同时存在port_posix.cPC 上用 SocketCAN 仿真和port_mcu.c片上外设。这是唯一允许出现硬件细节的地方。判断一份嵌入式协议栈值不值得用先看它的port/目录是不是干净的如果移植层只暴露 5 到 7 个函数说明抽象做得住如果暴露了二十个说明协议层已经和硬件耦合换平台等于重写。顺带一个 git 上的实操习惯先看git log --oneline -20感受提交密度再看git tag -l看有没有发过版本。这类项目常见的情况是主干一直在动、tag 很少那就不要跟着main走找一个提交记录相对稳定、CI 能过的提交打个本地分支钉住git switch -c vendor/canuav commit后面所有移植改动都在这个分支上做。把源码 vendor 进自己工程时我更倾向直接拷目录 在自家仓库里记一条来源提交哈希而不是长期挂 submodule——submodule 在 CI 里忘记--recursive导致编译失败的翻车我见过太多次。2.2 29 位扩展帧 ID 的位段切分与 C 宏实现CANUAV 的编址全部压在 29 位扩展 ID 上位段划分大致是下面这张表。这个划分不是唯一解但它是这类无人机总线协议里最常见、也最经得起推敲的一种把优先级放在最高位纯软件排序时可以直接按整数比较 CAN ID 大小省掉一次查表。位段宽度含义取值范围bit 28..245优先级0 最高0 ~ 31bit 231服务标志0 广播消息 / 1 服务调用0 / 1bit 221请求/响应标志仅服务有效0 / 1bit 21..913端口号消息主题端口服务服务端口0 ~ 8191bit 8..27源节点 ID0 ~ 127bit 1..02保留填 00服务帧的目标节点 ID 不在 ID 里而是放在负载首字节这是为了给 7 位节点号留出完整空间。广播消息不需要目标地址天然省一个字节。/* include/canuav/protocol.h —— ID 的拆与拼全部用宏和 inline不带函数调用开销 */ #ifndef CANUAV_PROTOCOL_H #define CANUAV_PROTOCOL_H #include stdint.h #include stdbool.h #define CU_ID_PRIORITY(id) (((id) 24) 0x1FU) #define CU_ID_IS_SERVICE(id) (((id) 23) 0x01U) #define CU_ID_IS_REQUEST(id) (((id) 22) 0x01U) #define CU_ID_PORT(id) (((id) 9) 0x1FFFU) #define CU_ID_SRC_NODE(id) (((id) 2) 0x7FU) static inline uint32_t cu_make_id(uint8_t priority, bool service, bool request, uint16_t port_id, uint8_t src_node) { /* 越界夹紧而不是断言中断上下文里断言等于主动重启整机 总线上多一帧烂帧比掉一次电的代价小得多 */ if (priority 31U) priority 31U; if (port_id 8191U) port_id 8191U; if (src_node 127U) src_node 127U; return ((uint32_t)priority 24) | ((uint32_t)(service ? 1U : 0U) 23) | ((uint32_t)(request ? 1U : 0U) 22) | ((uint32_t)port_id 9) | ((uint32_t)src_node 2); } #endif逻辑说明cu_make_id的入参顺序刻意与位段从高到低一致写调用点时不容易把 port 和 node 搞反——这两个参数都是 7~13 位的整数反了编译器不会报错只会让你在总线上抓到一堆无人订阅的帧这种坑排查起来极其耗神。参数说明priority建议只用到 0~7 这几个值剩下的留作扩展因为优先级过高会让低优先级的周期帧长期抢不到总线port_id是全局唯一的端口编号需要在项目里维护一张表不要在不同模块里各自拍数字src_node必须保证总线内唯一后面第 5 章会讲它为什么经常冲突。反向解码在调试时用得更多抓到一个 ID 想立刻知道是谁发的/* 抓包时打印一行比对着十六进制脑补位段快十倍 */ static void cu_dump_id(uint32_t id) { printf(id0x%08lX prio%u svc%u req%u port%u src%u\n, (unsigned long)id, CU_ID_PRIORITY(id), CU_ID_IS_SERVICE(id), CU_ID_IS_REQUEST(id), CU_ID_PORT(id), CU_ID_SRC_NODE(id)); }2.3 第一次构建三个 CMake 开关和一段可复制的命令这类项目通常同时提供 CMake 和手写 Makefile 两套构建CMake 那套更适合做交叉编译。下面这段命令可以直接抄把占位符换成你自己的路径。git clone 你的仓库地址 canuav cd canuav # 依赖内联在 third_party/ 的分支不需要这行依赖走 submodule 的必须有 git submodule update --init --recursive cmake -S . -B build \ -DCU_PLATFORMposix \ -DCU_MTU8 \ -DCU_MAX_NODES16 \ -DCU_MAX_TRANSFER_LEN256 \ -DCMAKE_BUILD_TYPERelWithDebInfo cmake --build build -j$(nproc) ctest --test-dir build --output-on-failure三个开关最值得先确认开关作用什么时候改CU_PLATFORM选移植层实现PC 仿真用posix上板改mcuCU_MTU单帧负载字节数经典 CAN 填 8CAN FD 填 64改这个会连带改传输层分片逻辑CU_MAX_NODES节点表/订阅表容量按总线上实际节点数 2 留余量不要图省事填 128CU_MAX_TRANSFER_LEN单条消息最大长度由最长的数据结构决定填过大会把 RAM 吃掉CU_MAX_TRANSFER_LEN是最容易踩的一刀。这个值乘上接收槽数量就是静态 RAM 占用填 1024 看着不心疼等到链接脚本报.bss溢出才发现问题。我一般先按最长的一条状态消息比如电池详情估一个 128 或 256跑起来再压。CMAKE_BUILD_TYPE用RelWithDebInfo而不是Release是因为协议栈的 bug 基本只在优化后出现纯Debug编出来的行为参考价值有限。构建通过之后先跑仓库自带的自环测试。这类测试通常在一个进程里起两个实例用内存队列模拟总线覆盖单帧收发、多帧重组、CRC 错误丢弃几条路径。它挂了说明你的工具链或者某个开关配错了别急着上板——上板之后同一类问题会表现成 HardFault 或者总线错误排查成本翻五倍。3. 让 CANUAV 跑在你的板子上移植层四个函数与两套时钟基准3.1 移植层接口清单四个必须实现、三个建议实现移植层的设计目标是让协议层完全不知道底下是片上外设还是 Linux 的 SocketCAN。常见做法是把它压到 7 个符号其中 4 个必须实现、3 个可选。/* include/canuav/port.h —— 协议栈认的全部硬件入口 */ #ifndef CANUAV_PORT_H #define CANUAV_PORT_H #include canuav/protocol.h /* 必须实现 */ bool cu_port_send(const cu_frame_t *f, uint32_t timeout_ms); uint16_t cu_port_recv(cu_frame_t *out, uint16_t max_frames); uint64_t cu_port_clock_us(void); void cu_port_critical_enter(void); void cu_port_critical_exit(void); /* 建议实现留空也能编过 */ void cu_port_on_error(uint8_t bus_id, uint32_t err_flags); void cu_port_idle(void); #endif四个必须实现里最容易被敷衍的是cu_port_clock_us。协议栈里所有超时、重传、槽位回收都基于它一旦这个函数返回的时间戳会跳变比如直接在 16 位定时器上做减法后强转 64 位多帧重组会莫名其妙地提前超时节点心跳也会被误判成离线。正确写法是维护一个 64 位的软件累加器在定时器溢出中断里加一次读的时候进临界区拿快照。/* port/port_mcu.c —— 32 位定时器 溢出计数拼出 64 位微秒时间戳 */ static volatile uint32_t g_overflow_cnt; static const uint32_t TIMER_HZ 1000000U; /* 定时器跑 1 MHz1 tick 1 us */ void tim_isr(void) /* 定时器溢出中断1 秒一次 */ { g_overflow_cnt; } uint64_t cu_port_clock_us(void) { uint32_t hi, lo; cu_port_critical_enter(); hi g_overflow_cnt; lo timer_read_counter(); /* 读硬件计数器 */ cu_port_critical_exit(); /* 32 位计数器在 1 MHz 下约 71.6 分钟溢出一次先移位再相加不会溢出 64 位 */ return ((uint64_t)hi 32) | lo; }逻辑说明临界区覆盖“读溢出计数”和“读计数器”两步是为了避免在两步之间刚好发生溢出中断导致拿到的hi是旧的、lo是新周期的小值时间戳瞬间倒退一个周期。参数说明TIMER_HZ选 1 MHz 是为了让 1 tick 等于 1 微秒省掉乘法如果你用的是 1 kHz 的 SysTick就别硬凑把返回值的单位改成毫秒同时在protocol.h里把超时常量的单位也改掉——两处单位不一致是这类移植最典型的翻车点。cu_port_critical_enter/exit在 Cortex-M 上用关中断实现注意要保存和恢复PRIMASK而不是无脑开中断否则在中断里调协议栈函数时会把外层的中断提前打开。这个细节在其他地方不值一提但在协议栈里必然出现“中断里调发送”的场景。3.2 片上 CAN 外设的最小发送/接收实现片上 CAN 外设的寄存器名各家不同但模型是一致的若干发送邮箱、两个接收 FIFO、一组错误计数器。下面用一组抽象的can_hw_*接口示意你在实际移植时把它们替换成厂商库函数即可。/* port/port_mcu.c —— 发送侧抢邮箱、填帧、触发失败立刻返回让上层重排 */ bool cu_port_send(const cu_frame_t *f, uint32_t timeout_ms) { uint8_t mb 0; /* 1. 抢空闲发送邮箱。抢不到说明总线被高优先级流量占满 这里绝不死等死等会让周期任务的抖动直接传导到控制环 */ if (!can_hw_acquire_tx_mailbox(mb)) { return false; } /* 2. 经典 CAN 单帧最多 8 字节超长的说明上层分片逻辑有问题直接丢弃并上报 */ if (f-len CU_MTU || f-len 8U) { can_hw_release_tx_mailbox(mb); return false; } can_hw_set_id(mb, f-can_id); /* 扩展帧29 位务必置 IDE 位 */ can_hw_set_dlc(mb, f-len); can_hw_write_data(mb, f-payload, f-len); can_hw_set_priority(mb, CU_ID_PRIORITY(f-can_id)); /* 邮箱仲裁优先级 */ can_hw_trigger(mb); return true; } /* 接收侧从硬件 FIFO 搬到协议栈的帧结构一次搬空避免高频中断 */ uint16_t cu_port_recv(cu_frame_t *out, uint16_t max_frames) { uint16_t n 0; while (n max_frames can_hw_rx_pending()) { out[n].can_id can_hw_read_id(); out[n].len can_hw_read_dlc(); out[n].timestamp_us cu_port_clock_us(); /* 时间戳在硬件层打别拖到上层 */ can_hw_read_data(out[n].payload, out[n].len); can_hw_rx_release(); n; } return n; }逻辑说明发送函数返回bool而不是阻塞是这套协议栈的设计前提——上层拿到false后可以把帧塞回优先级队列下一个周期再试而不是让任务卡在邮箱上。参数说明timeout_ms在这个实现里没被用到它是留给cu_port_idle()的钩子某些平台需要在等邮箱时喂狗可以把喂狗动作塞在can_hw_acquire_tx_mailbox的重试循环里。CU_MTU和硬编码的 8 是两个独立约束前者是协议层配置后者是硬件限制两个都要判。接收侧的关键决策是“一次搬空”。有人喜欢在中断里只搬一帧然后立刻返回把剩下的留给主循环理由是缩短中断时间。但 CAN 的接收 FIFO 通常只有 2~3 级深度主机在 1 Mbps 下满负载时两帧间隔不到 200 微秒主循环稍微被别的任务占一下就会溢出丢帧。我的做法是中断里置标志主循环里一次把 FIFO 搬空再统一走协议层中断时间反而更短。3.3 用 SocketCAN 在 PC 上跑同一份源码做回环同一份src/不改一行只换port/实现就能在 PC 上跑起来——这是判断协议栈抽象做得好不好的最直接标准。Linux 下用 AF_CAN 原始套接字不依赖任何第三方库。/* port/port_posix.c —— PC 仿真用 SocketCAN与固件共用同一份协议层 */ #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 #include canuav/port.h static int g_fd -1; bool cu_port_open(const char *ifname) { struct sockaddr_can addr; struct ifreq ifr; memset(addr, 0, sizeof(addr)); memset(ifr, 0, sizeof(ifr)); g_fd socket(PF_CAN, SOCK_RAW, CAN_RAW); if (g_fd 0) return false; strncpy(ifr.ifr_name, ifname, IFNAMSIZ - 1); if (ioctl(g_fd, SIOCGIFINDEX, ifr) 0) return false; addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; return bind(g_fd, (struct sockaddr *)addr, sizeof(addr)) 0; } bool cu_port_send(const cu_frame_t *f, uint32_t timeout_ms) { struct can_frame cf; memset(cf, 0, sizeof(cf)); cf.can_id f-can_id | CAN_EFF_FLAG; /* 必须带扩展帧标志否则内核按标准帧发 */ cf.can_dlc f-len; memcpy(cf.data, f-payload, f-len); return write(g_fd, cf, sizeof(cf)) (ssize_t)sizeof(cf); }逻辑说明CAN_EFF_FLAG这一位漏掉是最常见的错误现象是你在解密端看到的 ID 只有低 11 位高位的优先级和端口号全丢了于是所有帧都被当成端口 0 的消息。参数说明ifname传虚拟 CAN 接口名回环测试时建一对虚拟接口互相连通即可如果要测 CAN FDstruct can_frame要换成struct canfd_frame并打开CAN_RAW_FD_FRAMES套接字选项负载长度上限也随之从 8 变成 64。两个实例对跑的最小验证方式是在一个进程里创建两个cu_instance_t各自绑定不同的src_nodeA 发一条多帧消息到某个端口B 订阅同一个端口断言 B 的回调被调用且收到的字节完全一致。这条用例覆盖了 ID 打包、分片、CRC、重组、订阅分发整条链路跑通它再去碰硬件。4. 收发主链路拆解对象池、订阅表与多帧重组的可复现代码4.1 接收中断到回调定长块对象池与零拷贝嵌入式协议栈里最忌讳的就是在接收路径上调用malloc。堆碎片在实验室里跑一天看不出来装机飞两小时之后表现为“某个节点突然不响应”属于典型的玄学问题。CANUAV 这类实现通常自带一个定长块对象池初始化时从.bss里切一块出来运行期只做链表摘挂。/* include/canuav/mempool.h */ #define CU_BLOCK_SIZE 64U #define CU_BLOCK_NUM 48U typedef struct cu_block { struct cu_block *next; /* 只在空闲态有效分配出去后被负载覆盖 */ uint8_t data[CU_BLOCK_SIZE]; } cu_block_t; typedef struct { cu_block_t pool[CU_BLOCK_NUM]; cu_block_t *free_list; uint16_t used; /* 仅统计用排错时打印看泄漏 */ uint16_t peak; } cu_mempool_t; void cu_mempool_init(cu_mempool_t *mp) { mp-free_list NULL; mp-used 0; mp-peak 0; /* 倒序串接让第一次分配落在 pool[0]访问局部性略好一点 */ for (int i (int)CU_BLOCK_NUM - 1; i 0; --i) { mp-pool[i].next mp-free_list; mp-free_list mp-pool[i]; } } cu_block_t *cu_mempool_alloc(cu_mempool_t *mp) { cu_block_t *b mp-free_list; if (b NULL) return NULL; /* 返回 NULL 让调用方决定丢帧还是背压 */ mp-free_list b-next; b-next NULL; if (mp-used mp-peak) mp-peak mp-used; return b; } void cu_mempool_free(cu_mempool_t *mp, cu_block_t *b) { if (b NULL) return; b-next mp-free_list; mp-free_list b; mp-used--; }逻辑说明块大小 64 字节是折中值——经典 CAN 单帧 8 字节时浪费严重但 CAN FD 或重组缓冲需要更长两个用途共用一个池能显著降低 RAM 占用。参数说明CU_BLOCK_NUM是硬上限它的合理值约等于“同时在途的接收槽数量 × 2 发送队列深度”我一般先给 48跑通之后打开统计打印看peak按峰值 × 1.5 收敛。peak这个字段看起来可有可无实际上它是唯一能告诉你池子够不够的证据别省。需要强调的是零拷贝的边界帧从硬件 FIFO 搬到cu_frame_t那次拷贝是省不掉的硬件缓冲必须尽快释放但重组时不应该再拷一次。正确做法是把cu_frame_t直接指向池块重组槽只记录块指针和长度最后回调时把块指针交给用户由用户在回调返回前丢弃。这一改动能把接收路径的内存拷贝次数从 3 次降到 1 次。4.2 订阅表端口 ID 的二分查找与 O(1) 的热路径分发逻辑的核心是一张订阅表端口号映射到回调函数。表在初始化阶段构建完毕运行期只读因此不需要任何锁——这一点很重要它意味着你可以在中断里直接查表。typedef struct { uint16_t port_id; bool is_service; /* 与端口号一起构成排序键 */ void (*on_transfer)(void *ctx, const cu_transfer_t *t); void *user_ctx; } cu_subscription_t; /* 表按 (is_service, port_id) 升序排列运行期只读查表无锁 */ static const cu_subscription_t *cu_find_sub(const cu_instance_t *ins, uint16_t port, bool svc) { uint16_t lo 0, hi ins-sub_count; uint32_t key_q ((uint32_t)(svc ? 1U : 0U) 16) | port; while (lo hi) { uint16_t mid (uint16_t)(lo (hi - lo) / 2U); const cu_subscription_t *s ins-subs[mid]; uint32_t key_s ((uint32_t)(s-is_service ? 1U : 0U) 16) | s-port_id; if (key_s key_q) lo (uint16_t)(mid 1U); else hi mid; } if (lo ins-sub_count ins-subs[lo].port_id port ins-subs[lo].is_service svc) { return ins-subs[lo]; } return NULL; /* 无人订阅静默丢弃不要打日志 */ }逻辑说明把is_service拼进排序键的高位可以让消息和服务共用一张表而不会互相撞端口号——两者端口号空间各自独立很多实现把它们分成两张表结果是注册接口要写两遍。参数说明sub_count通常不超过 64二分查找最多 6 次比较在 168 MHz 的 M4 上大约 1 微秒可以接受。如果端口号能收敛到 32 个以内换成一个 32 项的静态数组直接索引会更快但要注意数组索引越界检查port 32时直接返回 NULL。“无人订阅就静默丢弃”这条策略值得单独说一句。总线是共享的别人的帧会流经你的节点日志打多了会把串口堵死进而拖慢整个主循环。真正需要观测的是“我订阅了但没收到”和“我收到了但 CRC 错”不是“我收到了不想收的”。4.3 多帧重组toggle 位、CRC-16 与 1 秒超时槽一条超过 8 字节的消息要拆成多帧协议把全部传输状态压进每帧负载的最后一个字节也就是尾字节位名称含义bit 7SOT传输起始帧标志bit 6EOT传输结束帧标志bit 5TOGGLE翻转位每帧取反用于发现丢帧bit 4..0TID传输 ID0~31 循环单帧消息同时置 SOT 和 EOT不需要占用重组槽这是最热的一条路径必须走快分支。#define CU_TAIL_SOT 0x80U #define CU_TAIL_EOT 0x40U #define CU_TAIL_TOGGLE 0x20U #define CU_TAIL_TID(t) ((uint8_t)((t) 0x1FU)) typedef struct { bool busy; uint8_t src_node; uint16_t port_id; uint8_t tid; uint8_t next_toggle; /* 期望的下一帧 toggle 值 */ uint16_t len; uint32_t last_ms; uint8_t buf[CU_MAX_TRANSFER_LEN]; } cu_rx_slot_t; static uint16_t cu_crc16(const uint8_t *p, uint16_t n) { uint16_t crc 0xFFFFU; /* CCITT-FALSE 初值 */ while (n--) { crc ^ (uint16_t)((uint16_t)(*p) 8); for (uint8_t i 0; i 8U; i) { crc (crc 0x8000U) ? (uint16_t)((crc 1) ^ 0x1021U) : (uint16_t)(crc 1); } } return crc; } void cu_rx_frame(cu_instance_t *ins, const cu_frame_t *f) { if (f-len 1U) return; /* 连尾字节都没有直接丢 */ uint8_t tail f-payload[f-len - 1U]; uint8_t data_len (uint8_t)(f-len - 1U); uint8_t src (uint8_t)CU_ID_SRC_NODE(f-can_id); uint16_t port (uint16_t)CU_ID_PORT(f-can_id); uint8_t tid CU_TAIL_TID(tail); /* 快分支单帧消息不进槽表 */ if ((tail CU_TAIL_SOT) (tail CU_TAIL_EOT)) { cu_dispatch(ins, port, CU_ID_IS_SERVICE(f-can_id), src, f-payload, data_len); return; } /* 慢分支按 (src, port, tid) 三键找槽三者任一不同就是另一次传输 */ cu_rx_slot_t *s cu_slot_lookup(ins, src, port, tid); if (tail CU_TAIL_SOT) { if (s NULL) s cu_slot_acquire(ins); /* 槽满返回 NULL整条传输丢弃 */ if (s NULL) return; s-busy true; s-src_node src; s-port_id port; s-tid tid; s-len 0; s-next_toggle (uint8_t)((tail CU_TAIL_TOGGLE) ? 0U : CU_TAIL_TOGGLE); } if (s NULL) return; /* 中途帧找不到起始丢弃 */ /* toggle 校验上一帧丢了这里的 toggle 必然对不上 */ if ((tail CU_TAIL_TOGGLE) ! s-next_toggle) { cu_slot_release(s); /* 整条传输作废等重传 */ return; } s-next_toggle ^ CU_TAIL_TOGGLE; if ((uint16_t)(s-len data_len) CU_MAX_TRANSFER_LEN) { cu_slot_release(s); /* 溢出绝不部分交付 */ return; } memcpy(s-buf[s-len], f-payload, data_len); s-len (uint16_t)(s-len data_len); s-last_ms (uint32_t)(cu_port_clock_us() / 1000U); if (tail CU_TAIL_EOT) { cu_dispatch(ins, port, CU_ID_IS_SERVICE(f-can_id), src, s-buf, s-len); cu_slot_release(s); } }逻辑说明cu_slot_lookup必须用三元组做键不能只用(src, tid)。TID 只有 5 位同一个源节点在同一时刻可能并发两条不同端口的传输只用 TID 会互相踩。参数说明槽位数量建议等于“同时活跃的源节点数 2”10 个节点的总线配 12 个槽足够CU_MAX_TRANSFER_LEN决定每个槽的 RAM 占用12 个槽 × 256 字节已经是 3 KB超过这个量级就要考虑把缓冲区也改成池块按需分配。超时回收必须放在主循环里做不能在接收路径上做——接收路径只在有帧到来时被调用而丢帧恰恰意味着“没有帧到来”。一个 100 毫秒周期的清扫函数扫到now - last_ms 1000的槽直接释放。1 秒这个值跟波特率有关500 kbps 下一条 256 字节的消息约 60 帧正常耗时不到 20 毫秒1 秒足够宽松又不会让槽位长期被占。4.4 发送侧优先级队列与每周期带宽预算发送侧的设计目标是让周期任务不抖动。做法是把待发帧塞进一个按 CAN ID 排序的队列每个主循环周期只发 N 帧N 由带宽预算算出来。参数建议值说明队列深度32约等于一个控制周期内所有待发帧的数量单周期发送上限81 Mbps 下 8 帧约 1.2 ms占周期不到 15%重试退避100 ms发送失败后下一周期不重试避免打爆总线丢弃策略丢最旧的低优先级帧周期状态帧过期就没价值丢比堵好排序键就是can_id本身因为优先级在高位整数比较即优先级比较。这条设计的好处是插入和取队首都是链表操作没有额外的比较函数指针。要注意的是同一优先级的帧按 FIFO 出队否则周期帧会因为排序不稳定而在某个周期被连续发两次。如果队列满了我的处理是丢弃队尾优先级最低的那一帧把新帧插进去。反过来的做法——拒绝新帧——会让高频事件比如故障上报永远挤不进去因为队列里全是周期状态帧。5. CANUAV 落地避坑五条从总线波形和内存池里捞出来的经验5.1 现象连续运行两小时后偶发 Bus-Off现象台架上跑挂测前两小时正常之后总线错误计数器缓慢上涨最终进入 Bus-Off节点自己摘线上位机看到的是“某个节点突然消失”重启后又好。原因发送路径在邮箱满时不加限制地重试。经典 CAN 的发送错误计数器对“发送失败”非常敏感当总线上有更高优先级流量长期占线节点不停地尝试发一个发不出去的帧错误计数一路涨到 255 就自动离线。这不是协议栈的 bug是发送策略的问题——但表现极具迷惑性因为它和运行时长强相关。解决发送函数返回false时不要立刻重试把帧放回队列并记录一个退避时间戳100 毫秒内不再尝试同一端口。同时在cu_port_on_error()里读硬件错误计数器一旦超过 96 就主动把非关键的周期帧降频一半等计数器回落到阈值以下再恢复。这套做法上线之后同样场景下的挂测从两小时延长到 72 小时无异常。5.2 现象多帧消息偶尔拼出乱码现象一条 200 字节的状态消息绝大部分时候正常偶尔解析出来某个字段是天文数字或者负值而且同一条消息重发一次就好了。原因两个不同源节点用了相同的 5 位 TID且刚好在同一时间向同一端口发多帧消息重组槽只按(src, tid)匹配导致两条传输的中间帧被拼进同一个槽。另一种可能是 toggle 位没校验前一帧在总线上被别的节点干扰丢弃后协议栈仍然把后面的帧拼进去拼出来的长度对但内容错位。解决槽的键改成三元素(src_node, port_id, tid)并在哈希或线性查找时三者都比对。同时严格校验 toggle一旦对不上立刻释放整条传输而不是跳过这一帧——跳过会让错位继续累积。加一条断言每次 EOT 时打印src/port/tid/len抓包日志里比对一下就能确认是不是这个问题。5.3 现象对象池在第 2 小时开始返回 NULL现象池子统计打印里used本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?