简介:CANUAV是一套基于C语言实现的UAVCAN协议栈源码,面向在STM32F407平台上开发无人机及自动化设备通信节点的嵌入式工程师。它依托轻量级Canard库完成UAVCAN核心功能,将传感器数据、控制指令、状态报告等消息映射到CAN帧,并实现心跳与节点管理,帮助开发者快速构建可靠的分布式通信网络。资源包共36个文件,约103KB,以h头文件与c、cpp源文件为主体,辅以md说明文档、txt配置、py脚本及yml持续集成配置,涵盖canard核心、stm32与socketcan驱动、测试用例等模块,目录结构清晰,便于按平台裁剪移植。目前已有949人学习下载。读者可从中获得完整的协议栈实现、消息编解码与内存分配参考、CRC及浮点编码测试样例,以及STM32 CAN控制器配置与Canard库编译链接的实践思路,适合具备一定嵌入式与CAN总线基础的中高级开发者参考复用。
1. 从一份 CANUAV 的 C 源码说起:为什么飞控通信层值得单独拆一遍
如果你正在做无人机、机器人或者任何多节点嵌入式系统,大概率绕不开 CAN 总线。而 UAVCAN(现在官方叫 Cyphal,但老工程师还是习惯叫 UAVCAN)就是架在 CAN 之上的一套高层协议,解决的是“多个节点怎么自动分配 ID、怎么交换遥测、怎么做固件升级”这类问题。我手里这份 CANUAV 的 C 源码,就是一个把 UAVCAN 协议栈用纯 C 实现的 git 仓库,没有 RTOS 绑定,没有 HAL 依赖,核心就是协议编解码、传输层状态机和节点管理。它适合谁?适合那些不想直接啃 libcanard 源码、想先拿一个能编译能跑的最小实现来理解 UAVCAN 帧格式和状态流转的人。我第一次拿到它的时候,最直接的感受是:终于不用在几千行的协议栈里找“心跳包到底怎么发”了。
2. 拆开 CANUAV 的 C 源码:目录结构、编译链路与最小可跑节点
2.1 仓库里到底放了什么:从 include 到 examples 的职责划分
这份源码的目录结构很典型,没有花哨的构建系统,就是 Makefile 加几个文件夹。我拿到手先跑了一遍tree,核心目录大概是这样的:
# 假设你已经把仓库克隆到本地 cd canuav-c tree -L 2 -I '.git'输出里你会看到几个关键目录:include/放的是协议头文件,比如uavcan_protocol.h、can_frame.h;src/是协议栈实现,包括can_driver.c(抽象驱动层)、transfer.c(传输层)、node.c(节点状态机);examples/下面通常有一个simple_node.c和一个heartbeat_demo.c。我一般会先看examples/里的 Makefile,因为它决定了你需不需要改编译器选项。
# examples/Makefile 片段(我根据常见实现补全的典型写法) CC ?= gcc CFLAGS += -I../include -I../src -Wall -Wextra -std=c99 LDFLAGS += -lm TARGET = simple_node SRCS = simple_node.c ../src/can_driver.c ../src/transfer.c ../src/node.c $(TARGET): $(SRCS) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) clean: rm -f $(TARGET)这段 Makefile 的逻辑很直白:把src/下的协议实现和示例代码一起编译,不依赖外部库。参数上要注意-std=c99,因为源码里用了不少stdint.h的定长类型和复合字面量,用 gnu89 可能会报错。-lm是给 CRC 校验里可能用到的数学函数留的,虽然大多数情况用不上,但加上不亏。
2.2 编译前必须确认的三件事:CAN 驱动、时钟与内存对齐
在make之前,我血泪经验是先把这三件事确认一遍,否则编译过了也跑不起来。
第一,CAN 驱动抽象层。can_driver.c里通常定义了一组函数指针,比如can_send()、can_recv()、can_filter_set()。源码里给的是空实现或者 Linux SocketCAN 的示例。如果你用的是 STM32 的 bxCAN 或者 MCP2515,需要自己填这三个函数。我一般会先写一个回环测试,不接真实总线,只让can_send把数据打印出来。
// 一个最小的回环驱动实现,用于验证协议栈逻辑 static int loopback_send(const CanFrame *frame) { // 参数说明:frame->id 是 29 位扩展 ID,frame->data 是 8 字节负载 printf("TX ID=0x%08X DLC=%d DATA=", frame->id, frame->dlc); for (int i = 0; i < frame->dlc; i++) { printf("%02X ", frame->data[i]); } printf("\n"); return 0; // 返回 0 表示发送成功 }第二,系统时钟。UAVCAN 的传输层依赖时间戳做超时重传和状态切换。源码里通常有一个get_monotonic_ms()的弱符号或者宏,默认返回 0。如果你不实现它,节点会一直认为时间没走,心跳包永远不发。常见做法是用SysTick或者clock_gettime(CLOCK_MONOTONIC)填上。
第三,内存对齐。CAN 帧结构体里如果有uint8_t data[8]和uint32_t id,在 32 位 MCU 上默认对齐是 4 字节,但 CAN 控制器 DMA 可能要求 1 字节对齐。我踩过的坑是:结构体大小从 12 字节变成 16 字节,DMA 搬运时多读了 4 个字节的垃圾。解决办法是用__attribute__((packed))或者手动序列化。
2.3 跑通第一个心跳包:从 main 到总线上的实际波形
编译通过后,先别急着接电机。我一般会跑heartbeat_demo,因为它只做一件事:让节点以 1Hz 频率往总线上发心跳。代码大概长这样:
// heartbeat_demo.c 的核心逻辑 #include "uavcan_protocol.h" #include "node.h" int main(void) { // 初始化节点,参数:节点 ID、优先级、回调表 UavcanNode node; uavcan_node_init(&node, 42, 16, NULL); // 42 是临时节点 ID // 注册心跳发送任务,周期 1000ms uavcan_node_set_heartbeat_interval(&node, 1000); while (1) { // 驱动协议栈状态机,内部会处理时间戳和发送 uavcan_node_spin_once(&node); // 模拟 1ms 延时,实际项目里用 RTOS 延时或定时器 delay_ms(1); } }逻辑说明:uavcan_node_init的第三个参数是回调表,心跳 demo 不需要接收,所以传 NULL。uavcan_node_spin_once是核心,它检查当前时间是否超过下一次心跳的截止时间,如果是就调用can_send发一帧。参数上,节点 ID 42 是随意选的,但注意 UAVCAN 规定 1~127 是有效动态 ID 范围,0 是匿名,128 以上保留。如果你接上真实 CAN 分析仪,应该能看到 ID 为0x1F0A2A00之类的扩展帧,具体取决于节点 ID 和消息类型。
提示:如果你用 Linux SocketCAN 跑,记得先
ip link set can0 up type can bitrate 1000000,UAVCAN 默认 1Mbps,但很多工控现场用 500k,改can_driver.c里的波特率配置就行。
3. 传输层状态机与消息编解码:把 CAN 帧拼成完整报文
3.1 单帧与多帧传输:什么时候用 TA 什么时候用 TS
UAVCAN 的传输层有两种模式:单帧传输(Single Frame)和多帧传输(Multi Frame)。单帧就是所有数据塞进一个 CAN 帧的 8 字节里,适合心跳、状态这种小消息。多帧需要拆分,用首帧(Start of Transfer)和后续帧(Toggle)拼起来。源码里transfer.c的核心是一个状态机,我把它简化成下面这张表:
| 传输类型 | CAN ID 标志位 | 数据长度 | 典型消息 |
|---|---|---|---|
| 单帧 | bit 7 = 0 | ≤ 7 字节 | 心跳、节点状态 |
| 多帧首帧 | bit 7 = 1, bit 6 = 1 | 8 字节 | 固件升级、日志 |
| 多帧后续帧 | bit 7 = 1, bit 6 = 0 | 8 字节 | 同上 |
| 匿名帧 | 特殊 ID | 8 字节 | 动态节点 ID 分配 |
我一开始没注意 bit 7 和 bit 6 的区别,直接把所有帧都当单帧处理,结果发固件升级包的时候,接收端只收到第一个字节。后来翻transfer.c才发现,多帧的 CRC 校验和序列号都在首帧里,后续帧只带数据。常见做法是:发送前先算好消息长度,如果超过 7 字节就自动切多帧模式,不要手动拼。
3.2 编解码实战:用 C 结构体映射 UAVCAN 消息
UAVCAN 的消息定义是 DSL 描述的,但 C 源码里通常用结构体加位域来映射。比如一个NodeStatus消息,源码里可能是这样:
// 根据 UAVCAN 规范补全的典型结构体 typedef struct { uint32_t uptime_sec; // 运行时间,秒 uint8_t health; // 健康状态:0=OK, 1=Warning, 2=Error, 3=Critical uint8_t mode; // 模式:0=Operational, 1=Initialization, 2=Maintenance uint16_t vendor_specific; // 厂商自定义状态码 } NodeStatus; // 编码函数:把结构体序列化成 CAN 负载 int encode_node_status(const NodeStatus *msg, uint8_t *out_buf) { // 参数说明:out_buf 至少 7 字节,返回实际写入长度 out_buf[0] = msg->uptime_sec & 0xFF; out_buf[1] = (msg->uptime_sec >> 8) & 0xFF; out_buf[2] = (msg->uptime_sec >> 16) & 0xFF; out_buf[3] = (msg->uptime_sec >> 24) & 0xFF; out_buf[4] = msg->health & 0x03; out_buf[5] = msg->mode & 0x07; out_buf[6] = msg->vendor_specific & 0xFF; out_buf[7] = (msg->vendor_specific >> 8) & 0xFF; return 8; // 实际 UAVCAN 里 NodeStatus 是 7 字节,这里演示用 8 }逻辑说明:UAVCAN 是小端字节序,所以uptime_sec先放低字节。health和mode是位域,只占几个 bit,但为了简单,我直接按字节存。参数上要注意:out_buf的长度必须至少等于消息的序列化长度,否则会越界。我一般会在函数开头加assert(out_buf != NULL),但嵌入式里 assert 可能被关掉,所以更稳妥的是返回错误码。
3.3 动态节点 ID 分配:匿名帧与冲突解决
UAVCAN 最实用的功能之一是动态节点 ID 分配。新节点上电后没有 ID,它会发一个匿名帧,里面带一个 128 位的唯一 ID(通常是 MCU 的 UID)。分配器收到后,回一个包含分配结果的帧。源码里node.c的uavcan_node_allocate_id()就是干这个的。我实测时发现,如果两个节点同时上电,匿名帧会冲突,这时候协议栈会随机退避重试。常见做法是:在can_driver.c里开启接收过滤,只收匿名帧和广播帧,减少 CPU 占用。
// 动态 ID 分配的回调示例 void on_id_allocated(uint8_t new_id, void *user_data) { UavcanNode *node = (UavcanNode *)user_data; node->node_id = new_id; printf("Node ID allocated: %d\n", new_id); // 参数说明:new_id 范围 1~127,0 表示失败 if (new_id == 0) { // 分配失败,通常是因为总线太忙或 UID 冲突 // 常见做法是延时 1 秒后重新发起分配 } }注意:动态分配需要总线上的分配器节点在线,如果你只有一个节点,直接写死 ID 更省事。我见过有人为了动态分配专门跑一个树莓派当分配器,结果树莓派一挂,整个总线都瘫了。
4. 避坑与排查:CANUAV 源码在真实硬件上的五个翻车现场
4.1 现象:编译通过但总线无波形;原因:驱动函数没注册;解决:检查函数指针表
我第一次把源码移植到某款 Cortex-M4 开发板时,make成功,烧录后示波器上一点波形都没有。查了半天发现can_driver.c里有一个static const CanDriverOps ops = { .send = NULL, .recv = NULL };,我忘了在main里调用can_driver_register(&ops)。源码默认是空实现,不会报错,但也不会发数据。解决办法:在初始化节点之前,先注册驱动,并确保send函数返回 0。
4.2 现象:心跳包周期忽快忽慢;原因:时间戳溢出;解决:用 64 位单调时钟
UAVCAN 的心跳周期依赖get_monotonic_ms()。我一开始用HAL_GetTick(),它返回 32 位毫秒,大概 49 天溢出。溢出后时间戳回绕,协议栈会认为超时,疯狂发心跳。现象就是总线负载突然飙升。解决办法:把时间戳改成 64 位,或者用SysTick的周期计数自己累加。源码里如果用的是uint32_t,记得在比较时处理回绕,比如(now - last) > interval这种写法。
4.3 现象:多帧传输丢包;原因:接收缓冲区太小;解决:调整CAN_RX_BUFFER_SIZE
transfer.c里通常有一个接收缓冲区,默认可能是 8 或者 16 帧。如果你发一个 256 字节的固件包,需要 32 个后续帧,缓冲区不够就会丢。我踩过的坑是:发送端说发完了,接收端只收到一半。解决办法:在uavcan_protocol.h里把CAN_RX_BUFFER_SIZE改成 64 或者更大,同时确保你的 CAN 控制器接收 FIFO 也够深。常见做法是:接收中断里只做搬运,协议栈解析放到主循环。
4.4 现象:节点 ID 冲突导致总线仲裁失败;原因:两个节点用了同一个 ID;解决:开启动态分配或加冲突检测
有一次实验室里两个节点都写死了 ID 10,上电后总线一直报错。CAN 的仲裁机制会让 ID 小的优先,但两个相同 ID 的节点同时发数据,就会互相干扰。现象是:接收端收到一堆错误帧,或者数据错乱。解决办法:要么用动态分配,要么在代码里加一个“ID 占用检测”——上电后先监听总线,如果发现自己的 ID 已经在用,就换一个。源码里node.c有uavcan_node_check_id_conflict()的弱实现,需要自己填。
4.5 现象:CRC 校验总是失败;原因:字节序搞反了;解决:统一小端并检查位域打包
UAVCAN 的 CRC 是 CRC-16-CCITT,计算范围包括消息头和负载。我一开始把uint16_t直接 memcpy 到缓冲区,结果在大端 MCU 上 CRC 对不上。现象是:接收端能收到帧,但校验失败,直接丢弃。解决办法:所有多字节字段手动按小端写入,不要依赖编译器。另外,位域结构体在不同编译器下布局可能不同,我一般用#pragma pack(1)或者手动移位。
5. 进阶技巧:用 CANUAV 源码做协议模糊测试与总线负载估算
5.1 把源码改造成模糊测试目标:随机帧注入与状态机覆盖
这份源码的另一个价值是:它足够小,可以拿来做模糊测试。我一般会把can_driver.c的recv函数替换成一个随机帧生成器,然后跑几万次,看协议栈会不会崩溃或者进入死循环。具体做法是:写一个fuzz_driver.c,在recv里返回随机 ID 和随机数据,然后编译一个带-fsanitize=address的版本。
// fuzz_driver.c 片段:随机注入 CAN 帧 static int fuzz_recv(CanFrame *frame) { static uint32_t seed = 12345; seed = seed * 1103515245 + 12345; frame->id = (seed >> 16) & 0x1FFFFFFF; // 29 位扩展 ID frame->dlc = (seed >> 8) & 0x0F; if (frame->dlc > 8) frame->dlc = 8; for (int i = 0; i < frame->dlc; i++) { frame->data[i] = (seed >> (i * 4)) & 0xFF; } return 0; }逻辑说明:这个函数每次被调用都生成一个伪随机帧,ID 和 DLC 都在合法范围内。参数上,seed用线性同余,保证可复现。跑的时候用while(1) uavcan_node_spin_once(&node);,如果协议栈有数组越界或者空指针,AddressSanitizer 会直接报出来。我实测跑过 10 万次,发现了一个transfer.c里对dlc > 8没做检查的 bug,后来在recv入口加了if (frame->dlc > 8) return -1;。
5.2 总线负载估算:用源码里的计数器算理论占用率
UAVCAN 总线负载率是无人机现场调试的常见指标。源码里通常有一个uavcan_node_get_stats()函数,返回发送和接收的帧数。我一般会在spin_once里加一个计数器,每秒统计一次,然后按下面的公式算:
| 参数 | 含义 | 典型值 |
|---|---|---|
frames_per_sec | 每秒帧数 | 心跳 1 + 状态 10 + 日志 5 = 16 |
bits_per_frame | 每帧位数 | 扩展帧约 128 位(含填充) |
bus_bitrate | 总线波特率 | 1000000 bps |
load | 负载率 | frames_per_sec * bits_per_frame / bus_bitrate |
按上表算,16 帧/秒 × 128 位 = 2048 位/秒,除以 1M 就是 0.2%。但如果加上固件升级,负载会飙到 50% 以上。我习惯在代码里加一个软限制:如果负载超过 70%,就降低日志发送频率。源码里没有现成的限流逻辑,需要自己在spin_once里加。
5.3 从源码到产品:我一般会保留的三个调试钩子
最后说一个习惯:不管项目多赶,我都会在 CANUAV 源码里保留三个钩子。第一个是can_driver.c里的can_send入口加一行if (debug_mask & DEBUG_TX) printf(...),方便看实际发出的帧。第二个是transfer.c里的 CRC 校验失败处加计数器,超过阈值就报警。第三个是node.c里的状态切换处加日志,记录节点从 Initialization 到 Operational 的耗时。这三个钩子占不了多少 Flash,但能省下大量示波器和 CAN 分析仪的时间。从那以后我每次移植新的 CAN 协议栈,都强制走一遍回环测试、动态 ID 分配、多帧传输和负载统计,缺一个都不上真实总线。希望帮到你。
本文还有配套的精品资源,点击获取