cFS与SB学习记录(三):从TransmitMsg追一条消息怎样发出去

管道和订阅准备好以后,发送端只需要调用一次 CFE_SB_TransmitMsg()。接口看起来短,里面却同时处理了消息校验、内存池分配、路由查找和多个队列的投递。

我读这条路径时最先想弄清两件事:两个订阅者是否各复制一份消息,以及函数返回成功究竟能说明什么。把这两件事放在前面,再看 TransmitTxn_* 这组函数,调用链就比较容易串起来。

系列目录 · 上一篇:管道与订阅

先画数据流,再看函数名

沿用 TEMP 消息和两个接收管道的例子,普通发送的大致过程是:

1
2
3
4
5
6
应用自己的消息内存
│ 一次内容复制
▼
SB 内存池中的 BufferD + 消息内容
├── 描述符指针 → 接收管道 A 的 OSAL 队列
└── 描述符指针 → 接收管道 B 的 OSAL 队列

TransmitMsg() 把用户消息复制进 SB 内部缓冲区。后面的广播没有按订阅者数量重复复制整包,而是让不同队列引用同一个缓冲区。接收者因此要把消息当只读对象使用。

这里说“一次复制”,指应用消息内存到 SB 消息缓冲区的那次内容复制。操作系统队列仍会复制队列条目中的指针值,不能把它扩展成“整个系统没有任何复制”。

事务对象把一次发送的临时状态放在一起

公共入口的主线可以压缩成下面几步。省略了逐步状态检查,代码只用于对应控制流:

1
2
3
4
5
6
7
CFE_SB_TransmitMsg
→ TransmitTxn_Init
→ TransmitTxn_SetupFromMsg
→ AllocateMessageBuffer
→ memcpy
→ TransmitTxn_Execute
→ MessageTxn_ReportEvents

Txn 是 transaction 的缩写。这不是数据库事务,也不意味着向两个管道投递具有“要么全成功、要么全回滚”的原子性。它记录这一次调用的消息身份、长度、状态、候选管道及待上报事件,让多个阶段能围绕同一个临时对象工作。

有了这个对象,SB 可以在锁内选好目的地和更新共享计数,解锁后调用 OSAL 队列接口,再在失败时修正相应计数。否则长函数里会混着消息、管道和错误处理的许多临时变量。

先从消息头取得身份和长度

CFE_SB_TransmitTxn_SetupFromMsg() 通过 MSG API 取得 MsgId 和消息大小,并把它们放进事务对象。这里有两层责任:

一层是 SB 的检查,例如 MsgId 是否有效、长度是否超过任务配置的最大消息大小。另一层是调用者必须保证,消息头声明的长度确实在自己提供的内存范围内。一个指针和头里的数字,不能让 SB 凭空知道调用者实际分配了多少字节。

因此构造消息时要把结构体、初始化大小和发送指针配成同一件事。下面片段取自 Linux 示例所用的 STATS 类型;借用现成遥测只是为了观察数据经过 SB,不表示重新定义了业务协议:

1
2
3
4
5
6
7
8
CFE_SB_StatsTlm_t msg;

CFE_MSG_Init(CFE_MSG_PTR(msg.TelemetryHeader),
CFE_SB_ValueToMsgId(CFE_SB_STATS_TLM_MID),
sizeof(msg));
msg.Payload.MaxMsgIdsAllowed = 0x1234u; /* 实验标记 */

CFE_SB_TransmitMsg(CFE_MSG_PTR(msg.TelemetryHeader), true);

实际代码还应检查初始化和发送状态。CFE_MSG_PTR 取得消息头入口,sizeof(msg) 是这份 native 消息对象的大小;这一条同进程 SB 路径没有在这里执行跨主机线包 Pack。

申请的内存还包含描述符

SB 内存池申请的范围包含描述符和消息内容。描述符中记录实际内容大小、分配大小、路由身份、归属以及 UseCount。新缓冲区的引用计数从 1 开始,表示此时发送方持有它。

普通发送随后执行一次内容复制:

1
2
memcpy(&BufPtr->Msg, MsgPtr,
CFE_SB_MessageTxn_GetContentSize(Txn));

这一行解释了为什么调用结束后,普通发送方仍可以复用自己原来的 msg:进入总线的是内部副本,而非继续借用原来那块栈内存。但复制发生前,源对象必须保持有效;与别的任务同时修改源对象仍然有竞争问题。

另一条 AllocateMessageBuffer() → TransmitBuffer() 路径允许应用直接填 SB 缓冲区,从而省掉这次内容复制。它的所有权规则也不同,下一篇一起讲。

查到订阅,不代表每个目的地都能投

CFE_SB_TransmitTxn_FindDestinations() 在共享锁内用路由 MsgId 查 SBR,然后遍历目的地链表。对每个目的地还要检查:

  • 订阅关系是否激活;
  • PipeId 是否仍对应有效管道;
  • IGNOREMINE 是否排除当前发送应用;
  • 这个 MsgId 在此管道里是否达到限额。

IGNOREMINE 比较的是应用身份。在一个应用里放了两个任务,不意味着它们能绕过这个选项;两个任务仍可能属于同一 AppId。

目的地允许接收时,SB 先为它增加缓冲区引用,再增加该订阅的 BuffCount 和管道当前排队深度,并把 OSAL 队列 ID 放进本次事务的目的地集合。限额已满的目的地会被记下事件后跳过,其他目的地继续处理。

这种“先记账、再投递”的顺序很重要。接收任务可能在发送端返回前就被调度运行,引用必须先建立,不能先把指针放进队列,等接收方开始读了才补引用。

true 表示这是消息的产生点

TransmitMsg() 的第二个参数 IsOrigination,表示当前调用是否是消息的产生点。新构造的消息通常传 true;转发已有消息时则要考虑传 false,避免无意覆盖原来的源头信息。

在本文版本中,选到有效路由并作为产生点发送时,会更新路由维护的序列计数,写入内部消息头;随后调用 MSG 的 OriginationAction()。这个 hook 让具体 MSG 实现决定时间戳、校验等附加处理。当前 EDSMSG 实现会尝试设置遥测时间和命令校验,不能据此断言所有 MSG 实现都做相同事情。

普通发送更新的是 SB 内部副本。若在原来的栈对象中检查序列号,可能看不到内部最终投递的值。判断数据时,应观察接收方消息或者内部缓冲区。

真正入队时传的是什么

目的地筛选完成后,SB 在相应阶段调用 OSAL:

1
2
3
4
OS_QueuePut(ContextPtr->SysQueueId,
&BufDscPtr,
sizeof(BufDscPtr),
CFE_SB_MessageTxn_GetOsTimeout(TxnPtr));

BufDscPtr 是描述符地址,&BufDscPtr 则是保存这个地址的变量的地址。队列复制的是这个变量中那一个指针值。少看一个 &,就容易误以为这里在复制描述符或消息内容。

当前普通发送路径使用非等待的队列检查语义。单个 OSAL 入队失败后,SB 重新加锁,回滚此前为这条管道预记的深度、订阅排队数和缓冲区引用,并记录管道溢出或队列写错误。广播仍然继续处理其他候选管道。

因此它不是可靠多播协议。一个慢接收者可能丢掉这次投递,另一个正常接收者照常收到;如果业务要求确认、重试和持久保存,需要应用层另行设计。

给两个订阅者手算一次引用计数

只看正常路径,可以按下面的顺序理解:

时刻 UseCount 谁还持有这块内存
刚申请 1 发送调用
为 A 预留投递引用 2 发送调用、A
为 B 预留投递引用 3 发送调用、A、B
广播收尾,放下发送方引用 2 A、B

若 B 入队失败,就撤掉 B 的那一个引用。A 成功接收后也不会立刻让缓冲区释放;A 的管道会继续通过 LastBuffer 保持它,直到后续接收或清理。这是接收篇要补完的后半本账。

返回成功,不能解释成“业务处理完成”

这个区别在读源码后非常明显。查不到有效路由时,代码增加 NoSubscribersCounter、产生相应事件,发送状态仍为成功;个别目的地失败也会记录在目的地事件和统计中,并不把整次广播变成一个端到端处理确认。路由存在但目的地已被清空,是另一种状态,路由篇会再说明。

CFE_SUCCESS 需要和该 API 的语义一起理解。它不是接收任务已经出队、不是应用处理已经完成,也不是遥测已经到达地面。

我现在排查“发了但收不到”时,会先沿下面这条线对照:

1
2
3
4
5
头里的 MsgId/长度
→ 路由与目的地是否存在
→ Active / IGNOREMINE / 单消息限额
→ OSAL 队列投递与深度
→ 接收任务是否真正运行并处理

发送路径的主要源码在 cfe_sb_api.c、cfe_sb_priv.c和 cfe_sb_buf.c。下一篇:接收、借用缓冲区与零拷贝。

cFS与SB学习记录(三):从TransmitMsg追一条消息怎样发出去

https://goko-son626.github.io/post/cfs-sb-02-transmit.html

作者

GoKo Mell

发布于

2026-10-23

更新于

2026-10-23

许可协议

评论