跳到主要内容

19. 用 CANopenNode 读取电机

学习形式编程实验
硬件要求完整实验平台
配套 Demolab_19_canopennode_sdo_client
本课动作只读电机对象

第 11 课已经用手写 SDO 客户端读过电机。这一课读取相同类型的标准对象,但通信状态交给 CANopenNode 的 SDO Client。

这样可以直接比较:总线报文没有变,应用代码的职责变少了。

当前验证状态:工程已在 ESP-IDF v5.5.4 下重新编译、烧录并读取真实电机,官方组件版本为 0.1.0,修复后固件大小 208000 字节。上板时发现并修复了 CO_config_t 生命周期错误;最终读到 0x1000=0x000401920x1001=00x6041=0x06370x6061=10x6064=9626。记录见第 19 课上板记录

本课不把所有代码塞进 app_main.c。先看三个文件怎样合作:

第 19 课三个应用文件与 CANopenNode、电机的关系

app_main.c 决定“读哪些对象”;course_sdo_client.c 只负责“怎样完成一次读取”;头文件把两部分可以共同使用的名称公开出来。

建立本课工程和文件

先创建新的标准工程:

# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
mkdir -p "$HOME/esp/can-course-work"
idf.py create-project \
-p "$HOME/esp/can-course-work/lab_19_canopennode_sdo_client" \
lab_19_canopennode_sdo_client
cd "$HOME/esp/can-course-work/lab_19_canopennode_sdo_client"
idf.py set-target esp32c3
mv main/lab_19_canopennode_sdo_client.c main/app_main.c

把第 16 课已经使用过的 OD.cOD.hidf_component.yml 放入 main/,然后新建两个空文件:

# 先创建模块文件,后面再分别填写公开接口和实现。
touch main/course_sdo_client.c main/course_sdo_client.h

main/CMakeLists.txt 写成:

# app_main.c 负责节点生命周期,course_sdo_client.c 封装一次远程读取,OD.c 提供本地对象字典。
# SDO Client 仍复用同一个 CANopenNode 和 TWAI 节点,不再创建第 11 课的独立 CAN 驱动。
idf_component_register(
SRCS "app_main.c" "course_sdo_client.c" "OD.c"
INCLUDE_DIRS "."
REQUIRES espressif__canopennode esp_driver_twai esp_timer
)

先让三个 C 文件只包含最小合法内容并执行一次 idf.py build。这一步用于确认文件名和 CMake;不要等 SDO 代码全部写完才发现某个文件根本没有参与编译。

Client 和 Server 在这里是谁

本次读取中:

角色设备Node-ID
SDO ClientESP32-C30x20
SDO ServerIG35EC020 电机0x01

“Client”表示主动发起访问的一方,不等于它必须是 PC。“Server”表示持有被访问对象字典的一方。

电机的第一个默认 SDO Server 使用:

方向计算本项目 CAN-ID
Client → Server0x600 + Node-ID0x601
Server → Client0x580 + Node-ID0x581

这里说“第一个默认 SDO Server”,是因为 CANopen 允许设备另外配置更多 SDO 通道;初学和大多数默认设备访问先使用这一组标准通道。

一次读取经过哪些 API

CANopenNode SDO Client 的一次读取

完整过程分五步:

  1. CO_SDOclient_setup() 选择远程 Node-ID 和两条 CAN-ID;
  2. CO_SDOclientUploadInitiate() 提交对象地址和超时;
  3. 周期调用 CO_process()CO_SDOclientUpload()
  4. 成功时从协议栈缓冲区取数据,失败时读取 Abort Code;
  5. CO_SDOclientClose() 结束这次会话。

这里的 Upload 是 CANopen 站在 Server 角度的名称:数据从 Server 上传到 Client,也就是 ESP32 读取电机。

开启 SDO Client

sdkconfig.defaults 中配置:

CONFIG_CO_MULTIPLE_OD=y
CONFIG_CO_SDO_CLIENT=y
CONFIG_CO_PDO=n

本实验不需要 PDO,因此明确关闭。CONFIG_CO_SDO_CLIENT 则让 CO_t 中创建 SDO Client 实例。

保存后运行:

# 编译当前工程,先处理出现的第一条错误。
idf.py reconfigure
idf.py build

这一轮只检查 SDO Client 功能是否真正进入构建配置。若 co->SDOclientCO_SDOclient_setup() 不存在,先检查 Kconfig,不要自己声明一个同名函数。

先写头文件:约定模块能做什么

打开 main/course_sdo_client.h,先写防重复包含、基础类型和 CANopenNode 头文件:

// 头文件只公开“发起一次 SDO 上传后能得到什么”,不暴露内部轮询和计时过程。
// CANopen.h 提供 CO_t、SDO 返回值和标准 Abort Code 类型,调用者无需自行复制声明。
#pragma once

#include <stddef.h>
#include <stdint.h>

#include "CANopen.h"

接着加入结果结构和四个函数声明:

// expedited SDO 最多携带 4 个数据字节;size 说明实际长度,abort_code 说明失败原因。
// 不能只返回一个 bool,否则调用者无法区分“对象不存在”和“通信超时”。
typedef struct {
// data 只是原始字节;有无符号和最终数值要按对象字典中的类型解释。
uint8_t data[4];
size_t size;
CO_SDO_abortCode_t abort_code;
} course_sdo_result_t;

bool course_sdo_upload(CO_t *co, uint8_t server_node_id,
uint16_t index, uint8_t sub_index,
course_sdo_result_t *result);

uint16_t course_sdo_le_u16(const uint8_t data[2]);
uint32_t course_sdo_le_u32(const uint8_t data[4]);
const char *course_sdo_abort_name(CO_SDO_abortCode_t code);

头文件只说“别人可以怎样调用”,不在这里写循环和日志。保存后让 app_main.c 包含它,再编译一次,先解决类型名和函数声明问题。

为什么结果结构有三项

// 同一个结构同时承载成功数据和失败信息:成功时看 data/size,失败时看 abort_code。
typedef struct {
uint8_t data[4];
size_t size;
CO_SDO_abortCode_t abort_code;
} course_sdo_result_t;

为什么同时保存三项:

字段原因
data保存本课最多 4 字节的 expedited 数据
size不能假设响应一定符合预期长度
abort_code失败时需要知道电机拒绝或超时的原因

再写实现文件:先处理小端序和错误名称

打开 main/course_sdo_client.c,先写依赖和两个时间参数:

// 本模块直接使用 CANopenNode 的 SDO Client 状态机。
// FreeRTOS 延时只负责给其他任务运行机会,SDO 是否完成仍由状态机返回值决定。
#include "course_sdo_client.h"

#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

enum {
// 一次上传最多等待 1 秒;每 10 ms 推进一次状态机并让出 CPU。
SDO_TIMEOUT_MS = 1000,
SDO_STEP_US = 10000,
};

static const char *TAG = "course_sdo";

然后写两个纯数据函数:

// CANopen 多字节对象采用小端序。调用这些函数前要先按对象类型核对 result.size,
// 否则短响应也会被当作 16/32 位数据访问。
uint16_t course_sdo_le_u16(const uint8_t data[2])
{
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}

uint32_t course_sdo_le_u32(const uint8_t data[4])
{
// 先把每个 uint8_t 扩展为 32 位再左移,避免移位过程中丢失高位。
return (uint32_t)data[0] | ((uint32_t)data[1] << 8)
| ((uint32_t)data[2] << 16)
| ((uint32_t)data[3] << 24);
}

这两个函数还没有访问 CAN。先编译,可以把“普通 C 位运算错误”和“SDO 状态机错误”分开。course_sdo_abort_name() 再使用 switch 把常见 Abort Code 转成可读文字,完整分支可对照本课源码。

选择电机的 SDO 通道

现在开始写 course_sdo_upload()。第一步先拒绝空指针并清空旧结果:

// co、第一条 SDO Client 通道和输出缓冲区缺一不可;空指针不能进入协议栈。
if ((co == NULL) || (co->SDOclient == NULL) || (result == NULL)) {
return false;
}

// 清掉上一笔访问留下的数据和 Abort Code,防止本次失败后调用者误读旧结果。
memset(result, 0, sizeof(*result));

// [0] 是本地对象字典配置的第一条 SDO Client 通道,不是远程电机 Node-ID。
CO_SDOclient_t *client = &co->SDOclient[0];

[0] 表示使用本地节点配置的第一条 SDO Client 通道,不是电机 Node-ID。远程电机是谁,由下一步 setup() 的参数决定。

// 默认 SDO 通道的请求/响应 CAN-ID 分别是 0x600+Node-ID、0x580+Node-ID。
// setup() 在每次读取前把本地 Client 重新指向当前远程 Server。
CO_SDO_return_t ret = CO_SDOclient_setup(
client,
CO_CAN_ID_SDO_CLI + server_node_id,
CO_CAN_ID_SDO_SRV + server_node_id,
server_node_id);

传入 server_node_id = 0x01 后,组件内部使用 0x6010x581。我们不再手工填写一帧 0x601,但抓包时仍会看到它。

发起读取并持续推进状态机

// Initiate 只启动访问,并不表示电机已经响应;ret>0 时必须继续调用 Upload。
ret = CO_SDOclientUploadInitiate(client, index, sub_index,
1000, false);

do {
// 等待 SDO 时仍要推进整个 CANopenNode,否则接收、NMT 和超时处理都会停住。
(void)CO_process(co, false, 10000, NULL);
// 10000 的单位是微秒,与下面 10 ms 的任务延时对应;返回值决定是否继续循环。
ret = CO_SDOclientUpload(client, 10000, false,
&result->abort_code,
NULL, NULL, NULL);
if (ret > 0) {
// 只在状态机仍忙时让出 CPU;成功或失败后立即退出,不额外猜测等待时间。
vTaskDelay(pdMS_TO_TICKS(10));
}
} while (ret > 0);

返回值的判断方式是:

返回值含义
大于 0通信还在进行,稍后继续调用
等于 0本次通信成功结束
小于 0通信失败,查看 abort_code

SDO API 是非阻塞状态机。示例函数在自己的循环里等待,是为了让初学者先看清一次完整访问;等待期间仍调用 CO_process(),没有用固定延时假装收到了响应。

取出并解释数据

// 只有状态机成功结束后才能读取上传缓冲区;返回值是实际复制的字节数。
result->size = CO_SDOclientUploadBufRead(
client, result->data, sizeof(result->data));
// 关闭本次会话,下一次读取才能安全地重新 setup 同一条 Client 通道。
CO_SDOclientClose(client);

无论成功还是失败,最后都要 CO_SDOclientClose()。函数最终返回:

// 只有 communicationEnd 表示完整成功;Abort、超时和本地参数错误都返回 false。
// 失败的标准 Abort Code 已经保存在 result->abort_code,供上一层日志使用。
return ret == CO_SDO_RT_ok_communicationEnd;

到这里,先在 app_main() 中只读一个对象 0x1000:00 并编译。单对象成功后再加入对象数组,调试范围会小很多。

0x6064:00 为例,它是 INTEGER32。四个字节使用小端序组合:

// 0x6064 是 INTEGER32:先无符号组合原始位模式,再转为 int32_t 解释补码符号。
// 若对象是 UNSIGNED32,就不能执行最后这次有符号转换。
uint32_t raw = (uint32_t)data[0]
| ((uint32_t)data[1] << 8)
| ((uint32_t)data[2] << 16)
| ((uint32_t)data[3] << 24);
int32_t position = (int32_t)raw;

先组合无符号位模式,再转换为 int32_t,负位置才能正确解释。

在 app_main 中长期保存 CANopen 配置

本课上板时实际发现过一个只靠编译无法发现的问题:如果在临时初始化函数中创建 CO_config_t,函数返回后该变量失效,而 CANopenNode 仍保存着它的地址。

正确关系如下:

CO_config_t 为什么必须覆盖 CANopenNode 的整个运行期

因此在 app_main() 中这样写:

// 三个变量都放在 app_main() 栈帧中,并且 app_main() 在协议运行期间不会返回。
// 尤其 co_config 的地址会被 CO_new() 保存,生命周期必须覆盖后续所有 CO_process()。
twai_node_handle_t twai = NULL;
CO_t *co = NULL;
CO_config_t co_config = {0};

if (!init_canopen(&twai, &co, &co_config)) {
ESP_LOGE(TAG, "CANopen initialization failed");
return;
}

初始化函数接收地址,不再创建自己的临时配置:

// 初始化函数接收调用者提供的 config 地址,只填写它,不创建会在函数返回时失效的副本。
static bool init_canopen(twai_node_handle_t *twai_out, CO_t **co_out,
CO_config_t *config)
{
OD_INIT_CONFIG((*config));
// CO_t 会长期引用 config;函数返回后 config 仍由 app_main() 持有。
CO_t *co = CO_new(config, NULL);
/* 后面继续执行 CO_CANmodule_disable、CO_CANinit 和 CO_CANopenInit */
}

这不是为了“代码看起来标准”,而是指针生命周期的硬要求。本课第一次固件曾在 CO_process() 中触发 Load access fault,修改后才完成真实 SDO 读取。

最后写对象清单和读取循环

先定义本课真正需要的几种数据类型:

// 对象表明确记录类型,log_value() 才能先核对长度,再选择有符号或无符号解释。
// 不把类型写进表里,同样的 4 字节既可能是 UINT32,也可能是负的 INT32。
typedef enum {
VALUE_U8,
VALUE_U16,
VALUE_U32,
VALUE_I8,
VALUE_I32,
} value_type_t;

typedef struct {
// index/sub_index 决定访问地址,type 决定解码方式,name 只用于本地日志。
uint16_t index;
uint8_t sub_index;
value_type_t type;
const char *name;
} object_to_read_t;

对象数组把“地址、类型、显示名称”放在一起。主循环不需要为每个对象复制一遍 SDO 代码:

// 每个对象独立完成一次 SDO 会话;某一项 Abort 不会阻止后续对象继续读取。
for (size_t i = 0; i < sizeof(OBJECTS) / sizeof(OBJECTS[0]); i++) {
course_sdo_result_t result;
const object_to_read_t *object = &OBJECTS[i];

// 上传函数只返回原始数据;log_value 再按 object->type 核对长度并解释数值。
if (course_sdo_upload(co, MOTOR_NODE_ID, object->index,
object->sub_index, &result)) {
log_value(object, &result);
} else {
ESP_LOGW(TAG, "0x%04X:%02X read failed: abort=0x%08" PRIX32,
object->index, object->sub_index,
(uint32_t)result.abort_code);
}
// 50 ms 只是两次独立请求之间的调度间隔,不参与单次 SDO 的响应判断或超时。
vTaskDelay(pdMS_TO_TICKS(50));
}

这里的 50 ms 只是两次独立查询之间留出一点调度时间。真正的响应等待和超时都在 course_sdo_upload() 内部完成。

本课读取清单

对象类型用途
0x1000:00UNSIGNED32Device Type
0x1001:00UNSIGNED8Error Register
0x6041:00UNSIGNED16Statusword
0x6061:00INTEGER8Mode display
0x6064:00INTEGER32Position actual

读取一个对象失败时,程序打印 Abort Code并继续读取下一个。对象不存在不等于程序必须崩溃。

完整工程

工程位于 examples/lab_19_canopennode_sdo_client/

  • examples/lab_19_canopennode_sdo_client/main/app_main.c(入口和对象清单)
  • examples/lab_19_canopennode_sdo_client/main/course_sdo_client.c(SDO Client 实现)
  • examples/lab_19_canopennode_sdo_client/main/course_sdo_client.h(公开接口)
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
cd "$HOME/esp/can-canopen-course/examples/lab_19_canopennode_sdo_client"
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py build
idf.py flash monitor

安全和验收

本实验没有 download 接口,不写 0x60400x60600x607A 或速度对象。

上板后至少要确认:

  • 总线上出现 ESP32 发出的 0x601
  • 电机返回匹配的 0x581
  • 程序成功打印至少一个标准对象;
  • 0x6064 日志同时显示十六进制位模式和有符号十进制位置;
  • 运行期间没有崩溃或非预期复位。