简介:面向数字信号处理器嵌入式开发者的实时操作系统与工业总线应用资料包,以TI TMS320F28335为平台,完整演示uC/OS-II移植流程以及基于CANOpen协议的Copley驱动器控制方法。压缩包共包含264个文件,整体约1.64MB,文件类型涵盖C源码、头文件、汇编格式的启动及中断例程、CCS工程文件、链接命令与配置文件,便于在CCS中直接加载并分模块研读。目前已有969人学习下载。资料围绕移植中的初始化设置、任务调度、内存管理、中断服务与系统调用等关键环节展开,同时针对CANOpen梳理对象字典、SDO、PDO、NMT等要点,并给出CAN控制器波特率与滤波器配置、节点代码编写、PDO报文收发、Copley驱动器通信测试及错误处理思路。对于需要上手DSP与RTOS结合的工业控制项目,这套资料提供了可复用的代码与完整排错参考,能有效缩短学习曲线。
1. 为什么要在 DSP28335 上同时做 UCOSII 移植和 CANopen 应用
一个带多从站的分布式伺服项目,裸机中断里堆 CAN 报文解析,主循环里还要算电流环,稍微加点功能就开始抖。把这套工程代码拿到手之后你会发现,作者把 UCOSII 内核完整移植到了 DSP28335 上,再让 CANopen 协议栈作为独立任务跑在内核之上,控制逻辑和总线通信从此不再互相抢占。适合的人群很明确:做 C2000 系列 DSP 运动控制、伺服驱动、分布式 IO 采集的嵌入式开发者,手里有 28335 板子又想上操作系统和现场总线协议的,这份工程就是现成的起点。UCOSII28335 的移植资料网上不少,但能跟 CANopen 应用层串成完整链路的工程代码并不常见,下面我从目录结构开始拆。
2. 工程全景:三类源码包如何组合成一个可运行的软总线系统
2.1 工程目录:从外设库到应用层的代码分层
打开 CCS 工程后,第一感觉是目录划分很干净,没有把所有源码堆在同一个文件夹里。顶层结构大致是这样的:
project_root/ ├── DSP2833x_headers/ # 外设寄存器结构体与位定义 │ ├── DSP2833x_GlobalVariableDefs.c │ └── DSP2833x_EPwm_defines.h ├── DSP2833x_common/ # TI 官方外设驱动库 │ ├── source/ # ADC、PWM、CAN、Timer 等驱动源文件 │ └── cmd/ # 28335_RAM_lnk.cmd / 28335_FLASH_lnk.cmd ├── APP/ # 应用层任务 │ ├── motor_task.c # 电机控制任务 │ ├── display_task.c # 状态显示任务 │ └── canopen_task.c # CANopen 协议任务 ├── UCOSII/ # 内核源码 │ ├── ucos_ii.h │ ├── os_core.c │ ├── os_task.c │ ├── os_time.c │ └── ports/ # 移植文件 │ ├── os_cpu.h │ ├── os_cpu_c.c │ └── os_cpu_a.asm └── CANopen/ # CANopen 协议栈源码 ├── od.c / od.h # 对象字典 ├── nmt.c / nmt.h # 网络管理 ├── pdo.c / pdo.h # 过程数据对象 ├── sdo.c / sdo.h # 服务数据对象 └── can_drv.c # 28335 eCAN 底层驱动这个分层思路值得直接抄。DSP2833x_headers 和 DSP2833x_common 是 TI 的标准外设库,基本不用动;UCOSII 目录里只有 ports 下的三个文件与 CPU 相关;CANopen 协议栈则是纯 C 实现,不涉及具体芯片。做二次开发时,绝大多数改动只落在 APP 和 CANopen/od.c 两个位置。我一般在拿到工程后先确认一件事:CANopen 协议栈是否通过函数指针或宏定义与底层 CAN 驱动解耦。如果协议栈里直接出现了 ECanaRegs 这种寄存器操作,那后期换芯片会非常痛苦,好在当前这份代码在 can_drv.c 里做了隔离。
2.2 内存划分与 CMD 链接脚本:任务栈到底放在哪里
28335 片上共有 34K 字的 SARAM,分成了 M0/M1、L0-L7 等多个独立块。UCOSII 的任务栈属于高频读写区域,放外部扩展 RAM 会引入总线等待周期,影响上下文切换的确定性,所以合理做法是把所有任务栈都放在内部 SARAM 的 L0-L7 段里。CMD 文件中的内存划分决定了这些段能不能放得下:
MEMORY { PAGE 0: RAML0 : origin = 0x008000, length = 0x001000 RAML1 : origin = 0x009000, length = 0x001000 ZONE7 : origin = 0x3F0000, length = 0x00FF00 PAGE 1: RAMM0 : origin = 0x000000, length = 0x000400 RAMM1 : origin = 0x000400, length = 0x000400 RAML2 : origin = 0x00A000, length = 0x001000 RAML3 : origin = 0x00B000, length = 0x001000 RAML4 : origin = 0x00C000, length = 0x001000 RAML5 : origin = 0x00D000, length = 0x001000 RAML6 : origin = 0x00E000, length = 0x001000 RAML7 : origin = 0x00F000, length = 0x001000 }SECTIONS 部分把任务栈段 task_stack 分配到 RAML2 到 RAML7,每块 4K 字,正好放 6 个任务栈。这里有个容易被忽略的点:UCOSII 在 C2000 平台上的 OS_STK 被定义成 32 位宽度,而 28335 的 SARAM 是按 16 位字寻址的,所以一个 1024 字的数组在链接后占用的其实是 2K 字的物理空间。如果 CMD 里只按数组长度分配,链接不会报错,但栈底会悄悄越过 RAM 边界,等任务第一次压栈时直接写进邻近的外设寄存器区,这种问题最难排查。我的做法是每个任务栈数组单独放在一个段里,CMD 里逐段分配,栈大小宁多勿少,调试阶段任务栈直接翻倍。
2.3 任务规划表:把 CANopen 接收从中断里解放出来
整份工程最核心的设计决策,是把 CANopen 的报文处理从 CAN 接收中断里拿出来,放到独立任务里跑。CAN 接收中断只做两件事:读取邮箱数据、释放二值信号量。协议解析和 SDO 回包全部在 canopen_task 中完成。这么设计的原因很现实:SDO 分段传输状态下,一帧请求可能触发多个 OD 访问和回包组帧,整个过程耗时不确定,放在中断里轻则导致其他任务节拍抖动,重则直接撑爆中断嵌套栈。
任务规划按 UCOSII 优先级数值越小越高的规则排布如下:
| 任务名 | 优先级 | 触发方式 | 典型周期 |
|---|---|---|---|
| canopen_task | 8 | 信号量 + 10ms 节拍 | 事件驱动 |
| motor_task | 10 | 定时唤醒 | 1ms 周期 |
| adc_task | 12 | 定时唤醒 | 100us 周期 |
| display_task | 14 | 定时唤醒 | 100ms 周期 |
| heart_task | 15 | 定时唤醒 | 200ms 周期 |
协议任务优先级高于控制任务,但它是信号量驱动的,没有 CAN 报文时完全不占 CPU;motor_task 做 1ms 周期控制,100us 的 ADC 采样放在更高优先级。这比裸机状态机清晰得多:新增一个总线报文类型,不需要改动中断入口,只要在 canopen_task 的协议分发里加一个 case。
3. UCOSII 移植拆解:C2000 特有的任务栈、中断与时钟节拍
3.1 移植三件套与 C2000 编译器的数据类型陷阱
UCOSII 移植标准三件套是 os_cpu.h、os_cpu_c.c、os_cpu_a.asm。os_cpu.h 定义数据类型和临界区宏,os_cpu_c.c 实现任务栈初始化,os_cpu_a.asm 写实际切换代码。拿 C2000 平台来说,第一个大坑就是数据类型:TI 的 C2000 编译器为了保持和 DSP 字长一致,char 是 16 位,int 也是 16 位,只有 long 才是 32 位。UCOSII 源码在小内存模型下大量使用 int 做任务状态和事件标志,这些通常没问题,但 OS_STK 必须显式定义为 32 位,否则任务栈指针做自增自减时一次只移动 16 位,栈帧完全错乱。
typedef unsigned long OS_STK; // 关键:C2000 上必须是 32 位 typedef unsigned long OS_STK_SIZE; // 栈大小计数同样用 32 位 #define OS_CRITICAL_METHOD 3 // 使用第三类临界区保护 #define OS_STK_GROWTH 1 // 栈向下增长 #define OS_ENTER_CRITICAL() {cpu_sr = 0x0000; \ asm(" PUSH ST0"); \ asm(" POP %0" : "=&r"(cpu_sr)); \ asm(" DINT");} #define OS_EXIT_CRITICAL() {asm(" PUSH %0" :: "r"(cpu_sr)); \ asm(" POP ST0");}这段代码的关键在于临界区保护方式。方法 3 先把 ST0 压栈保存,然后执行 DINT 关中断,退出时恢复 ST0 而不是简单执行 EINT,这样如果进入临界区前中断本来就是关闭的,退出后依然保持关闭状态,避免了嵌套临界区提前开中断的问题。cpu_sr 变量由 C 编译器的寄存器约束语法直接从栈顶弹出,没有额外内存开销。我建议在移植初期把所有 OS_ENTER_CRITICAL 替换成带日志的版本,记录临界区最大时长,这能帮你判断 CAN 报文是否在临界区期间丢失。
UCOSII 版本里 OS_TICKS_PER_SEC 我习惯设成 100,也就是 10ms 一个节拍。控制任务不走内核节拍,自己用 CPU Timer1 做 1ms 高精度定时,避免内核节拍频率太高导致任务切换开销占比过大。协议任务和显示任务挂在内核节拍下够用。
3.2 任务栈初始化:OSTaskStkInit 如何预置现场
OSTaskStkInit 是 os_cpu_c.c 里最核心的函数。UCOSII 创建任务时只是把任务入口地址、参数和一堆寄存器初始值按特定顺序压入栈中,任务第一次获得 CPU 时通过一次中断返回直接跳进任务函数。C2000 的寄存器文件比 ARM 多,ACC、P、XT、DP、ST0、ST1、IER 都要安排位置,而且栈向下增长,需要从栈底往上倒着填。
OS_STK *OSTaskStkInit(OS_TASK_PTR p_task, OS_TASK_PTR p_arg, OS_STK *p_stk_base, OS_STK *p_stk_limit, OS_STK_SIZE stk_size, OS_OPT opt) { OS_STK *p_stk; p_stk = &p_stk_base[stk_size]; // 指向栈顶(高地址) p_stk = (OS_STK *)((unsigned long)p_stk & ~0x03UL); // 32 位对齐 *--p_stk = (OS_STK)0x00000000; // ACC 累加器 *--p_stk = (OS_STK)0x00000000; // XT 乘法扩展寄存器 *--p_stk = (OS_STK)0x00000000; // P 乘法结果寄存器 *--p_stk = (OS_STK)0x00000000; // DP 数据页指针 *--p_stk = (OS_STK)0x00000000; // ST0 *--p_stk = (OS_STK)0x00000000; // ST1(INTM/BITM 等标志) *--p_stk = (OS_STK)0x00000000; // IER 中断使能寄存器 *--p_stk = (OS_STK)p_arg; // 任务参数 *--p_stk = (OS_STK)p_task; // 任务入口地址 *--p_stk = (OS_STK)0x00000000; // 假返回地址,正常不会返回到这里 return (p_stk); }注意最后压入的返回地址是 0,这是一个安全网。如果任务函数意外 return,DSP 会跳转到地址 0 执行,CCS 反汇编里能看到它停在非法指令处,比跳飞到一个随机地址好排查得多。栈顶做了 32 位对齐,因为 long 类型的存取要求偶地址,而 28335 的 SP 是 16 位字寻址,不强制对齐的话后续弹出两个 long 时会错位。初始化时把 ST1 清成 0,等于默认关闭中断位,确保任务第一个执行的瞬间不被中断打扰。
3.3 任务切换与中断级切换的寄存器保存细节
任务级切换的实现思路是借助软中断指令。UCOSII 的 OS_TASK_SW() 宏在 C2000 上展开成一条 TRAP 指令,触发一个软中断,中断服务程序里完成当前任务现场保存和新任务现场恢复。直接用函数调用做切换是不行的,C 编译器会自动压入返回地址和部分寄存器,导致栈帧结构不可控。
_OSCtxSw: ; 关闭中断保护现场 DINT ; 保存当前任务 SP 到 TCB MOVW DP, #_OSTCBCur MOVL ACC, @_OSTCBCur MOV *+XAR4[0], SP ; 调用调度函数选择最高优先级任务 LCR _OSIntCtxSw这段汇编的逻辑是:先把 SP 存到当前任务 TCB 的 OSTCBStkPtr 字段,然后调用 OSIntCtxSw 继续执行。OSIntCtxSw 内部从就绪表中找到最高优先级任务的 TCB,取出新 SP,再依次弹出之前压入的现场,最后 RET 跳到任务入口。这里有一个 C2000 特有的细节:切换前必须 DINT,因为上下文保存过程中如果插入一个中断,中断现场会覆盖当前任务的部分寄存器,等中断返回后当前任务现场已经不完整了。
中断级切换同样走 OSIntCtxSw,不过它的入口是从 OSTickISR 里调用的,CPU 已经把大部分现场压栈了,不需要再次保存。写移植代码时最容易翻车的地方是把中断级切换和任务级切换的入口混用,导致栈指针错位。我调试时习惯在 OSTaskStkInit 返回的栈顶地址上打一个硬断点,任务第一次切换进来时单步看栈内容是否符合预期布局。
3.4 时钟节拍:用 CPU Timer0 驱动 UCOSII 心跳
时钟节拍在 C2000 上没有现成的 SysTick,最稳妥的做法是用 CPU Timer0,配合 PIE 向量表把中断入口指向 UCOSII 的 OSTickISR。
// 初始化 PIE 向量表:把 Timer0 中断指到 UCOSII 节拍入口 EALLOW; PieVectTable.TINT0 = &OSTickISR; EDIS; // 配置 Timer0:150MHz 时钟,10ms 周期节拍 ConfigCpuTimer(&CpuTimer0, 150, 10); // 使能 PIE 第 1 组第 7 个中断(TINT0) IER |= M_INT1; PieCtrlRegs.PIEIER1.bit.INTx7 = 1; // 启动定时器 CpuTimer0Regs.TCR.bit.TSS = 0; OSTimeTickInit();OSTickISR 内部的固定写法是:先调用 OSIntEnter 通知内核进入中断嵌套,再调用 OSTimeTick 遍历任务延时链表,最后调用 OSIntExit,如果发现有更高优先级任务就绪,OSIntExit 内部直接触发切换。注意 Timer0 的周期寄存器是 32 位的,在 150MHz 下 10ms 对应的计数值是 150 万,完全在范围内。有些移植代码把节拍频率设成 1000Hz,这会让时间片轮转粒度更细,但 UCOSII 的开关临界区次数也同步翻十倍,对 CAN 报文处理的实时性反而有害,10ms 是一个工程上更平衡的选择。
4. CANopen 应用落地:对象字典驱动一条完整的报文收发链路
4.1 协议栈裁剪:从完整实现到 28335 够用的子集
CANopen 全协议栈重量不轻,完整实现包含 SDO 客户端/服务器、PDO 四种传输类型、SYNC、TIME、EMCY、LSS、SDO 分段传输等一堆功能。这份工程代码做了针对性裁剪,协议栈只保留了从站必需的四个部分:NMT 从站状态机、心跳报文、SDO 服务器、PDO 收发。LSS 和 SDO 客户端被拿掉了,因为从站场景里节点 ID 直接通过拨码开关读取,不需要动态配置。裁剪后整个协议栈代码量压缩到不到 3000 行,ROM 占用大约 12K 字,对 28335 的 256K Flash 来说很宽裕。
裁剪的原则是:通信报文处理必须非阻塞。SDO 分段传输如果分多个周期发送,在这份代码里采用了最简单可靠的方式——每收到一个 SDO 请求,在协议任务里同步组帧回包,超过 4 字节的数据对象直接返回 SDO 中止码,而不是做分段续传。对大多数运动控制场景,对象字典里的参数都是 16 位或 32 位标量,一次 expedited 传输足够,省掉分段逻辑换来的是协议栈确定性显著提高。
4.2 对象字典的数据结构与访问接口
对象字典是 CANopen 的核心数据结构。这份代码实现得很简洁:一张静态表,每条记录包含索引、子索引、数据指针、访问属性和数据类型。运行时读写全部走同一组接口,上层任务和 SDO 服务器都通过它访问。结构体定义和应用实例如下:
typedef struct { uint16_t index; // 对象字典索引,如 0x2000 uint8_t subindex; // 子索引,通常为 0 uint8_t access; // READ_ONLY / READ_WRITE uint8_t data_type; // OD_TYPE_U8 / OD_TYPE_U16 / OD_TYPE_U32 uint8_t length; // 数据字节数 void *data_ptr; // 指向协议任务或控制任务共享的变量 } OD_ENTRY; const OD_ENTRY od_table[] = { { 0x1000, 0, READ_ONLY, OD_TYPE_U32, 4, &dev_type }, { 0x1005, 0, READ_WRITE, OD_TYPE_U32, 4, &sync_cob_id }, { 0x1017, 0, READ_WRITE, OD_TYPE_U16, 2, &heartbeat_time }, { 0x2000, 0, READ_WRITE, OD_TYPE_U16, 2, &motor_target_speed }, { 0x2002, 0, READ_ONLY, OD_TYPE_U16, 2, &motor_actual_speed }, { 0x2100, 0, READ_WRITE, OD_TYPE_U16, 2, &ain1_raw_value }, { 0, 0, 0, 0, 0, 0 } // 结束标记 };OD 访问接口统一通过索引查表,找到对应条目后按 data_type 做类型转换。SDO 服务器收到请求时,会先查索引是否存在,再判读写权限,最后按长度拷贝数据。这个结构最大的好处是新增字典条目只需要在表里加一行,协议栈代码一行不用改。motor_task 和 CANopen 协议任务之间通过 od_table 里的 data_ptr 共享变量,天然形成了跨任务数据交换通道,省掉了显式同步机制,因为 CANopen 协议任务是唯一写方时用不上锁,多任务共写时才需要加临界区保护。
4.3 NMT 状态机与协议任务的主循环
NMT 从站状态机在 CANopen 里承担节点生命周期管理,节点必须在主站发出相应 NMT 命令后进入 Operational 状态才能收发 PDO。状态切换逻辑放在 canopen_task 的主循环里,由信号量唤醒:
void canopen_task(void *p_arg) { OS_ERR err; CanMsg msg; while (1) { OSSemPend(can_rx_sem, 50, &err); // 50ms 超时兜底 if (can_receive(&msg) == OK) { switch (msg.cob_id >> 7) { // 高 7 位是 COB-ID case 0x00: // NMT 管理报文,目标是本节点 handle_nmt(msg.data[0]); break; case 0x04: // SDO 请求发给本节点 handle_sdo_request(&msg); break; case 0x02: // RPDO1,目标索引由映射决定 handle_rpdo1(&msg); break; default: break; } } if (nmt_state == NMT_OPERATIONAL) { handle_tpdo(); // 周期发送 TPDO1/2 } send_heartbeat_if_due(); // 心跳周期到则上送 } }COB-ID 的分类逻辑是把帧 ID 右移 7 位:NMT 是 0x000,SDO 请求是 0x600 加上节点 ID 再右移,PDO1 的接收 COB-ID 是 0x200 + 节点 ID。主站发来的 NMT 命令包含目标节点 ID,只有当命令中的节点 ID 等于本节点 ID 或者是 0(广播)时才响应。handle_nmt 内部维护一个枚举状态机,收到 0x01 进入 Operational,0x02 进入 Stopped,0x80 回到 Pre-operational。协议任务主循环里 OSSemPend 的超时参数是 50ms,这是防呆设计:若 CAN 总线静默,任务能定期执行 NMT 状态判断和 TPDO 周期发送,而不是永久挂起。
4.4 PDO 收发链路:邮箱中断、信号量与 TPDO 触发
28335 的 eCAN 模块有 32 个邮箱,每个邮箱可独立配置为发送或接收。工程里把邮箱 0 配置为 NMT/SDO 接收,邮箱 1 配置为 RPDO1 接收,邮箱 2 到 5 配置为 TPDO1 到 TPDO4 发送。CAN 接收中断里做的事情非常轻:
interrupt void CAN_RX_ISR(void) { // 确认中断来自接收邮箱 uint16_t pending = ECanaRegs.CANRMP.all & RX_MAILBOX_MASK; if (pending) { // 读取报文放入环形缓冲区 can_read_mbox(pending, &rx_buf); // 清除接收挂起位,这一步不能漏 ECanaRegs.CANRMP.all = pending; // 通知协议任务 OSSemPost(can_rx_sem, OS_OPT_POST_1, &err); } // 写 PIE ACK,允许同一组下一个中断进入 PieCtrlRegs.PIEACK.all = PIEACK_GROUP1; }接收中断只负责搬数据和释放信号量,处理逻辑在优先级 8 的 canopen_task 里执行。ISR 里用了两级保护:先写清除 CANRMP 挂起位,再置位 PIEACK。漏掉任何一个都会导致该邮箱收不到后续帧,这是 28335 eCAN 中断里最常见的挂死原因。发送侧则完全通过邮箱方式,TPDO 组帧后写入邮箱、置位 TRS 请求发送即可,轮询发送确认位再决定是否继续发下一帧。
TPDO 触发条件有两个:一是协议任务主循环里每 10ms 的定时检查,二是电机控制任务主动调用 TPDO 更新接口。前者适用于速度、位置等周期遥测数据,后者适用于报警类事件,事件型触发时需要配合禁止时间参数,避免反复触发导致总线被刷爆。PDO 映射在这里采用固定映射表,RPDO1 的数据布局直接对应对象字典中的控制字、目标速度、目标位置三个条目,解析时按偏移量取值,省掉动态映射带来的复杂度。
5. 移植与联调避坑:五个常见问题的现象、原因与处理
5.1 现象:任务一多就跑飞,复位后 PC 停在非法地址
任务跑飞是 RTOS 移植初期最费时间的故障。现场表现为程序运行几秒到几分钟不等,然后突然跳到 CCS 反汇编窗口的某个非法地址,目标板看门狗复位。原因是任务栈溢出,或者 OSTaskStkInit 的栈帧预置与 OSCtxSw 的恢复代码不对称。C2000 编译器 int 为 16 位的情况下,如果 OS_STK 被定义成 unsigned int,每次入栈出栈只移动 16 位,任务函数入口地址高位被截断,运行几个任务切换后栈指针就错位了。处理方式是核对 os_cpu.h 中 OS_STK 是否为 32 位,同时把每个任务栈数组后面加一条填充哨兵,跑飞前检查栈水位。从那以后我每次新建任务都先量栈剩余量,绝不做裸跑式的估算。
5.2 现象:CAN 只有发送没有接收,邮箱中断永远不触发
调试时用 USB-CAN 分析仪能看到总线上一帧帧 NMT 报文在发,但板子就是收不到主站下发的报文,协议任务一直停在那。检查邮箱配置后接收使能位也正常,问题出在 PIE 中断分组和 eCAN 中断映射的配合上。28335 的 eCAN 中断默认映射到 PIE 第 9 组,但必须在 CANMC 寄存器里使能全局中断且把对应邮箱的 MIMC 位配置为中断模式,仅配置 PIE 侧是不够的,两级中断屏蔽任何一级没打开都进不了 ISR。另外接收邮箱的挂起位 CANRMP 如果不及时写 1 清除,后续帧会直接被硬件丢弃,表现同样是收不到新报文。处理办法是 ISR 入口先用 pending 值回写 CANRMP,把「读回”和“清挂起”合成一步,避免清标志和读数据之间又插进一个新中断。
5.3 现象:在中断服务里直接回 SDO,协议栈随机死机
这个错误很典型。起初想着 SDO 回包就几个字节,放在 CAN 接收中断里做掉算了,结果一跑起来发现,只要主站连续读取几个对象字典条目,程序就随机死机或协议任务丢帧。原因是 SDO 服务器回包需要查 OD 表、组帧、再调用 CAN 发送函数,查 OD 表的过程可能触发临界区保护,而临界区里调用 OSSemPost 又可能唤醒高优先级任务并尝试做任务切换,中断上下文里做任务切换直接导致栈混乱。这就是在中断里做了阻塞或调度相关操作的典型症状。解决方法是中断里只放数据搬运和信号量释放,SDO 整条处理链全部挪进 canopen_task;主站的连续读取请求加一个 10ms 间隔,给协议任务留足处理时间。CAN 接收中断里除了 OSSemPost 之外不调用任何内核服务,这条铁律后续在所有外设中断里都适用。
5.4 现象:链接报错,RAML0 空间溢出但片上还有很多 RAM
CCS 链接时报「RAML0 溢出」但明明检查存储器分配还有好几块空的 SARAM,这是 CMD 文件配置问题。UCOSII 源码默认把系统堆栈段放到 RAML0,而 OSStartHighRdy 之前的内核初始化、启动代码也占用了 RAML0,两边一叠加就超限。另一块容易踩的地是 TI 库函数中部分重定位段默认落在 RAML0,比如浮点库的 FPU 例程段。解决方法是修改链接命令文件,把 .text、.cinit 等可重定位段显式分散到 RAML0-L7 多块区域,包括系统的 .stack 段也单独划一块。还要注意 28335 的 boot ROM 会占用 0x3F0000 以上地址空间,如果链接脚本把 ZONE7 的地址范围算错,代码段也可能静默覆盖 boot ROM,导致上电后程序不运行却没有任何报错。
5.5 现象:上位机报心跳超时,但程序明明在正常运行
板子工作正常,指示灯闪烁正常,控制输出正常,主站却周期性报「心跳超时」并让节点进入 Stopped。起初怀疑是 28335 的 eCAN 发送邮箱优先级问题导致心跳帧被 TPDO 挤掉,后来用分析仪抓包发现心跳帧实际没有发出。原因是心跳报文发送间隔通过 OD 0x1017 配置为 100ms,但维护心跳计数的逻辑放在协议任务里,而协议任务被更高优先级的控制任务持续占用,得不到调度导致计数在任务级上就超时了。协议任务做的是非周期工作,不应该靠周期唤醒保证心跳精度。解决方式是把心跳发送独立成一个 10ms 周期的高优先级任务,或者把心跳发送放进 CAN 发送完成中断里定时触发。从那以后我再配置任何 CANopen 从站,都先确认心跳和 EMCY 这类时间敏感报文是否挂在独立的定时路径上,而不是挂在某个可能被延迟的任务循环里。
6. 二次开发实战:把一个模拟量采集服务挂进现有任务树
以新增一路 ADC 采集为例,完整走一遍改造流程。先在 APP 层新建 adc_task.c,采样结果通过对象字典对外暴露。对象字典表里已经预留了 0x2100 这个索引,数据指针指向 adc_task 的全局变量:
// adc_task.c uint16_t ain1_raw_value = 0; void adc_task(void *p_arg) { OS_ERR err; while (1) { // 触发 ADC 采样,等待转换完成 AdcRegs.ADCTRL2.bit.SOC_SEQ1 = 1; while (AdcRegs.ADCST.bit.INT_SEQ1 == 0); ain1_raw_value = AdcRegs.ADCRESULT0 >> 4; // 12 位结果右对齐 AdcRegs.ADCST.bit.INT_SEQ1_CLR = 1; // 每 10ms 采样一次 OSTimeDlyHMSM(0, 0, 0, 10, OS_OPT_TIME_HMSM_STRICT, &err); } }创建任务时把优先级设在 motor_task 和 display_task 之间,栈大小 256 字足够:
OSTaskCreate(adc_task, NULL, &adc_task_stk[255], 11, &err);然后打开 od.c 确认 0x2100 条目的 access 是 READ_ONLY,data_ptr 指向 ain1_raw_value。这样主站通过 SDO 就能实时读取电压值。如果想让采集值主动上报,把 0x2100 加到 TPDO2 的映射表,在 pdo_map 数组里补一个映射条目,TPDO 的发送逻辑会自动把该变量的最新值带出去。整个过程不需要改协议栈内部代码,只在 APP 和 OD 表两个位置动手。这个工程的改造边界设计得比较舒服:新增任务、新增字典、新增 PDO 映射互不影响。我第一次给某个模拟项目X加总线扩展时,还犯过一个低级错误:直接把任务栈定义成局部数组放在任务函数里,结果栈空间落在另一个任务的地盘上,跑三天后随机死机。从那以后我每次新建任务都强制走一遍「全局栈数组 → 检查优先级 → 查 OD 表是否已有对应索引 → 上分析仪确认帧内容」的流程,再没翻过车。希望这份拆解能帮你少走几步弯路。
本文还有配套的精品资源,点击获取