23. 使能电机但不运动
上一课只读 0x6041。这一课第一次写电机,但只写 0x6040 Controlword,让 CiA 402 状态机按标准路径进入 Operation enabled。
程序不写运行模式、不写目标位置、不写目标速度,也不发送运动 PDO。
当前验证状态:独立工程已重新编译并上板验证,修复后固件大小 210192 字节。电机依次到达 Ready to switch on、Switched on 和 Operation enabled,最终
0x6041=0x0637;本课未写运行模式、目标位置或目标速度。记录见第 23 课上板记录。
本课每一步都遵守同一条规则:写入、收到 SDO 成功响应、重新读取状态、再决定是否继续。

从第 19 课工程开始写
先新建 lab_23_cia402_enable 标准工程,把自己在第 19 课已经验证过的这些文件复制到新工程 main/:
OD.c和OD.h;idf_component.yml;course_sdo_client.c和course_sdo_client.h。
不要复制 build/ 和 managed_components/。先保持第 19 课的只读程序,执行一次:
# 先选择 ESP32-C3,再编译工程;这一步不会烧录开发板。
idf.py set-target esp32c3
idf.py build
这次仍然只能读取电机。确认基础工程没有因为复制路径或 CMake 出错后,再加入 download 接口。这样一旦写功能出错,能够确认问题来自本课新增代码。
Controlword 和 Statusword 的关系
可以把它们看成一问一答:
| 对象 | 方向 | 作用 |
|---|---|---|
0x6040 Controlword | 控制端写给电机 | 请求状态机执行下一步 |
0x6041 Statusword | 控制端从电机读取 | 确认电机实际到达哪里 |
写入成功只代表 SDO Server 接受了对象写入。必须再读 0x6041,才能证明状态转换成功。
本课使用的三步路径

| 步骤 | 写 0x6040 | 随后期望的状态 |
|---|---|---|
| Shutdown | 0x0006 | Ready to switch on |
| Switch on | 0x0007 | Switched on |
| Enable operation | 0x000F | Operation enabled |
这些名称描述 CiA 402 状态转换,不是操作系统关机或 ESP32 重启。
在 SDO 模块中加入 16 位写入
第 19 课只有 upload。现在加入 download,但接口故意只允许一个 uint16_t:
// 接口把写入值限定为 uint16_t,专门覆盖 0x6040 Controlword 这类 16 位对象。
// 目标 Node-ID、Index、Sub-index 和 Abort Code 都由调用者显式提供,失败原因不会丢失。
bool course_sdo_download_u16(CO_t *co, uint8_t server_node_id,
uint16_t index, uint8_t sub_index,
uint16_t value,
CO_SDO_abortCode_t *abort_code);
将 uint16_t 按小端序放入两字节缓冲区:
// CANopen 使用小端序:低 8 位先放入 data[0],高 8 位再放入 data[1]。
// 例如 Controlword 0x0006 在线上传输为 06 00,而不是 00 06。
uint8_t data[2] = {
(uint8_t)(value & 0xFFU),
(uint8_t)((value >> 8) & 0xFFU),
};
例如 0x0006 发送的数据字节顺序是 06 00。
先在函数开头检查参数并清空上一次错误:
// 协议栈、SDO Client 通道和 Abort 输出指针缺一不可,空指针时绝不能继续写电机。
if ((co == NULL) || (co->SDOclient == NULL) || (abort_code == NULL)) {
return false;
}
// 清除上一笔访问的 Abort Code,确保本次失败日志不会误用旧错误。
*abort_code = CO_SDO_AB_NONE;
接着复用第 19 课的通道选择代码:
// [0] 是本地第一条 SDO Client 通道;远程电机由 server_node_id 决定。
CO_SDOclient_t *client = &co->SDOclient[0];
// 默认 SDO 请求/响应 CAN-ID 分别是 0x600+Node-ID、0x580+Node-ID。
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);
接着调用 CANopenNode:
// Initiate 声明对象地址、长度和超时,BufWrite 再把刚才准备的 2 字节交给状态机。
// 两步都完成仍不代表电机已经接受,必须继续运行下面的 Download 循环。
CO_SDOclientDownloadInitiate(client, index, sub_index,
sizeof(data), 1000, false);
CO_SDOclientDownloadBufWrite(client, data, sizeof(data));
再周期调用 CO_SDOclientDownload(),直到成功、Abort 或超时。完整实现见:
examples/lab_23_cia402_enable/main/course_sdo_client.c
状态机循环应由你自己补成下面这样:
// ret>0 表示下载仍在进行;每轮同时推进整个 CANopenNode 和当前 SDO 状态机。
do {
(void)CO_process(co, false, SDO_STEP_US, NULL);
ret = CO_SDOclientDownload(client, SDO_STEP_US, false, false,
abort_code, NULL, NULL);
if (ret > 0) {
// 只有仍在等待时才让出 CPU;成功或 Abort 后立即退出,不靠固定延时猜结果。
vTaskDelay(pdMS_TO_TICKS(SDO_STEP_US / 1000));
}
} while (ret > 0);
// 成功、Abort 或超时都要关闭会话,下一次写入才能重新 setup 同一通道。
CO_SDOclientClose(client);
return ret == CO_SDO_RT_ok_communicationEnd;
写完 download 函数后先执行 idf.py build,但先不要烧录。编译只能确认 API 和类型正确;写电机前还要完成下面的状态检查代码。
把三步写成数据表
不要在 app_main() 中散落三个神秘数字。先定义每一步包含什么:
// 每一行把“要写的 Controlword”和“写后应读回的状态”绑定在一起。
// 程序按表顺序执行,避免把 0x0006/0x0007/0x000F 变成散落且难核对的数字。
typedef struct {
uint16_t controlword;
uint16_t expected_state;
const char *name;
} enable_step_t;
static const enable_step_t ENABLE_STEPS[] = {
{0x0006, 0x0021, "Shutdown"},
{0x0007, 0x0023, "Switch on"},
{0x000F, 0x0027, "Enable operation"},
};
controlword 是准备写入 0x6040 的值;expected_state 是随后从 0x6041 取掩码后应该得到的状态。名称只用于日志,不是电机收到的字符串。
接着写 cia402_state_name(),先计算:
// 完整 Statusword 还包含 Remote、Target reached 等附加位,不能直接与状态常量比较。
// 0x006F 只留下 CiA 402 状态机判断所需的 bit。
uint16_t state = statusword & 0x006FU;
再用 switch 把 0x0040、0x0021、0x0023、0x0027 等结果转换成文字。这里复用了第 22 课已经解释过的状态掩码,不重新发明判断规则。
先读状态再决定是否继续
// 任何控制字写入前先确认通信可读且 Fault bit 为 0。
// 读取失败和电机故障都立即返回,绝不“先写一下看看”。
uint16_t statusword = 0;
if (!read_statusword(co, &statusword)) {
return;
}
if ((statusword & 0x0008U) != 0U) {
ESP_LOGE(TAG, "Motor is in Fault");
return;
}
这两次 return 都是安全行为:通信不明或电机故障时,不继续写控制字。
每一步都读回
// 三个步骤逐项执行:写 0x6040、等待设备状态稳定、重新读 0x6041 验证。
// 任一步 SDO 失败或状态不符都停止,不能跳过后继续发送下一控制字。
for (size_t i = 0; i < sizeof(ENABLE_STEPS) / sizeof(ENABLE_STEPS[0]); i++) {
CO_SDO_abortCode_t abort_code = CO_SDO_AB_NONE;
if (!course_sdo_download_u16(co, MOTOR_NODE_ID, 0x6040, 0x00,
ENABLE_STEPS[i].controlword,
&abort_code)) {
return;
}
// SDO 成功只说明 Controlword 已被接受;留出 200 ms 后再读取实际状态机结果。
vTaskDelay(pdMS_TO_TICKS(200));
if (!read_statusword(co, &statusword)) {
return;
}
// 屏蔽附加状态位后再与本步骤 expected_state 比较。
uint16_t state = statusword & 0x006FU;
if (state != ENABLE_STEPS[i].expected_state) {
ESP_LOGE(TAG, "State transition was not accepted");
return;
}
}
这里的 200 ms 不是“假装协议响应”。SDO 下载函数内部已经等待并确认 0x581 成功响应;这段短暂等待给驱动状态机完成物理状态转换,然后程序通过新的 SDO 读取验证结果。
参考工程还允许电机已经提前处于更后面的安全状态,例如第一步读回 Switched on 或 Operation enabled。自己第一次实现时可以先使用严格判断,理解日志后再对照配套 Demo 中 examples/lab_23_cia402_enable/main/app_main.c 的完整状态判断。不要为了让日志显示 PASS 而无条件忽略状态不匹配。
为什么使能后电机不应主动转动
本课没有改变:
0x6060Modes of operation;0x607ATarget position;0x60FFTarget velocity;- Profile Position 的 new set-point 位;
- 任何运动 RPDO。
因此状态进入 Operation enabled 只表示驱动准备接受控制。若本课代码导致明显持续运动,应立即断电并检查设备先前保存的模式、目标和厂商行为,不能把它当成正常现象继续实验。
构建和上板
工程位于 examples/lab_23_cia402_enable/:
examples/lab_23_cia402_enable/main/app_main.c(入口和状态判断)examples/lab_23_cia402_enable/main/course_sdo_client.c(SDO 读写模块)
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
cd "$HOME/esp/can-canopen-course/examples/lab_23_cia402_enable"
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py build
idf.py flash monitor
上板前清空机构周围空间,并准备直接切断 12 V 电机电源。
本课过关条件
真实日志和总线报文应共同证明:
- 初始
0x6041可读取且 Fault 位为 0; - 三次
0x6040写入均收到 SDO 下载成功响应; - 每次写后都重新读取
0x6041; - 最终
statusword & 0x006F等于0x0027; 0x6064在允许的静止误差内没有出现命令性位置变化;- 电机没有非预期运动、报警或复位。