主题
字号
CHAPTER 04 ≈ 25 MIN READ

跟踪一条命令:从电脑到控制板的完整旅程

🔄 上一章回顾

上一章我们了解了固件的整体架构:FreeRTOS 怎样管理多个任务、app_task 怎样在系统启动后开始工作、每个模块各自负责什么。现在 app_task 已经准备就绪,正在循环等待新的命令——那么,一条命令到底长什么样、经过了哪些环节、最终怎么变成电机的转动?本章就来完整追踪这个过程。

🎯 本章你会学到

  • 串口中断(ISR)是怎样在硬件收到字节时立刻响应的
  • 环形缓冲区如何在"快速接收"和"慢速处理"之间充当缓冲垫
  • 协议帧的格式——为什么裸字节需要"包装"才能成为命令
  • 状态机怎样一步步把字节流拼成完整的帧
  • CRC 校验如何保护数据不被损坏
  • 命令分发器怎样把命令交给正确的处理函数
  • ACK 回复怎样告诉上位机"命令已收到并处理"

第一个字节抵达 PA10

故事从 app_task 的一次等待开始。

此时的下位机正在 FreeRTOS 中正常运行,app_task 已经完成了初始化,正循环等待新的命令帧。与此同时,上位机——可能是电脑上的调试助手,也可能是 RoboGame 主控程序——决定让机器人动起来。它把一条 START_MOTION 命令写入串口。

串口通信的本质是逐位发送的高低电平。电脑端的串口控制器按照约定的节奏把二进制数据转换成电信号,电信号沿着一根细细的导线(在开发板上通常就是 USB 转串口线)来到 STM32 的 PA10 引脚。PA10 是 STM32F407 上 USART1 的默认 RX 引脚,也就是串口接收端。

UART1 在本工程中的配置是 115200 波特率、8 位数据位、1 个停止位、无奇偶校验,简称 "8-N-1"。波特率 115200 表示每秒最多传输 115200 个比特位,实际每个字节大约需要 1 / 11520 ≈ 87 微秒。串口硬件会按照这个节奏对 PA10 上的电平进行采样,把连续的高低变化恢复成一个一个字节。

硬件怎样通知 CPU:"有新数据到了"

当串口收到一个字节时,硬件会自动通知 CPU——不需要软件一直去轮询"有没有新数据"。具体来说,当一个新的字节被硬件接收并放入接收数据寄存器时,USART1RXNE 标志位就会置位。RXNE 的全称是 Receive Data Register Not Empty,翻译过来就是"接收数据寄存器不是空的"。你可以简单理解为硬件在说:"有新数据到了,快来取走!"

这时,STM32 会立即跳转到一个特殊的函数去处理这件事。这个函数就是中断服务函数(Interrupt Service Routine,简称 ISR)——硬件事件发生时,CPU 会立刻放下手头工作去执行的一小段程序。

💡 零基础小课堂

你可以把 ISR 想象成家里的门铃:门铃一响,不管主人正在做饭还是看电视,都会先跑去开门看一眼。在这里,"门铃"就是 RXNE 标志(有新数据到了),"开门的人"就是中断服务函数。门铃响了你必须马上去应门,不然快递员可能就走了——同理,如果不及时读走数据,下一个字节来时旧数据可能会被覆盖丢失。

// USART1 的中断服务函数——每收到一个字节,硬件会自动调用这里
int USART1_IRQHandler(void)
{
    // 检查是否真的收到了新字节(RXNE 标志是否被置位)
    if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
        // 从硬件寄存器读出字节,并立刻交给 transport 层处理
        transport_uart_rx_from_isr(
            (uint8_t)USART_ReceiveData(USART1));
    }
    return 0; // 中断函数返回,CPU 回去继续之前的工作
}

这段中断函数非常短,只干一件关键的事情:

  1. 检查 USART_IT_RXNE 中断标志是否置位。
  2. 如果确实收到了新字节,就通过 USART_ReceiveData(USART1) 把字节从硬件寄存器中读出来。
  3. 立刻调用 transport_uart_rx_from_isr(),把字节交给 transport 层处理。

注意这里没有任何协议解析、没有任何电机控制,甚至连 CRC 计算都没有。中断只做"取字节、转交"这两件事。为什么要这么设计?因为中断的优先级很高,它执行期间会打断普通任务。如果中断里塞入太多逻辑,后面新的字节来的时候可能就来不及接收,导致丢数据。把中断做得尽可能短、尽可能快,是嵌入式串口接收的常用做法。

💡 零基础小课堂

为什么一定要在中断里读数据? 因为 RXNE 标志被置位后,如果不及时读走 USART_ReceiveData(),下一个字节再来时可能会触发 ORE(Overrun Error,溢出错误),新到的字节会被硬件丢掉。所以"收到即读"是串口中断的基本原则。读走之后,字节交给缓冲区,后续慢慢解析即可。

256 格的环形等候区

中断收到的字节不会立刻被 app_task 取走,而是先进入一个长度为 256 的数组 s_rx_buffer。这个数组和两个下标一起,共同构成一个环形缓冲区(ring buffer),也常被称为"循环队列"。

你可以把它想象成一圈首尾相接的候车座位:有新的乘客(字节)来时,head 指向当前可以入座的位置;有乘客离开时,tail 指向下一个该离开的位置。座位一共有 256 个,从 0 编号到 255。当 head 走到 255 之后,下一个位置不是 256,而是绕回到 0

graph LR subgraph "环形缓冲区(想象成一圈座位)" direction LR A["[0]"] --> B["[1]"] --> C["[2]"] --> D["..."] --> E["[254]"] --> F["[255]"] --> A end T["tail(取数据的位置)"] -.-> B H["head(写数据的位置)"] -.-> E
// head:中断负责移动,表示下一个可以写入的位置
static volatile uint16_t s_rx_head = 0U;
// tail:app_task 负责移动,表示下一个该读取的位置
static volatile uint16_t s_rx_tail = 0U;
// 存放接收到的原始字节的数组,共 256 个格子
static uint8_t s_rx_buffer[256];

💡 零基础小课堂

注意变量前面的 volatile 关键字。它的意思是告诉编译器:这个值可能随时被别人改掉,每次都要重新去读。为什么需要它?因为 s_rx_head 会被中断修改,s_rx_tail 会被普通任务修改——如果编译器不知道这一点,它可能会"聪明地"把值缓存起来,结果读到的就是旧数据。加上 volatile 就是在告诉编译器:"别偷懒,每次都要去内存里重新拿最新的值。"

这里有两个关键点:

两边各处理一端,互不干扰,这就把"硬件要求快速响应"和"软件希望按节奏处理"之间的矛盾隔离开了。中断只管"接",任务只管"取",中间的缓冲区负责吸收两边的速度差。

具体写入时,代码会先计算 head 的下一个位置。如果下一个位置不等于 tail,说明缓冲区还有空位,当前字节可以安全写入;写入后 head 再真正前移。如果下一个位置等于 tail,说明缓冲区已经满了,当前实现会选择保留已有字节、放弃新到的字节

// 计算 head 的下一个位置(到 255 之后绕回 0)
uint16_t next = next_index(s_rx_head);
// 只要下一个位置不等于 tail,说明还有空位
if (next != s_rx_tail) {
    s_rx_buffer[s_rx_head] = byte;  // 把字节写进当前位置
    s_rx_head = next;               // head 向前移动一格
}
// 如果 next == s_rx_tail,说明满了,这个字节会被丢弃

💡 零基础小课堂

为什么缓冲区长度是 256,却只能存 255 个字节? 这是一个经典的设计细节。环形缓冲区通常用一个元素位置来区分"空"和"满"。如果 head == tail 表示空,那么当缓冲区真正写满时,也必然是 head == tail,这就和"空"无法区分了。所以工程上常见的做法就是:缓冲区满的判断条件是 next(head) == tail,也就是再写一个就会撞上 tail。这样虽然浪费了 1 个格子,但换来了"空"和"满"判断的简单可靠。256 格的数组最多同时保存 255 个未读字节。

如果你在实际联调时发现串口有丢数据的情况,可以在满的时候增加一个溢出计数器,上位机或日志就能知道是否因为缓冲区太小而丢失了字节。

应用任务从另一端取数据时,代码同样很简单:

// 如果 tail 不等于 head,说明缓冲区里有未读字节
if (s_rx_tail != s_rx_head) {
    *out_byte = s_rx_buffer[s_rx_tail];       // 取出当前字节
    s_rx_tail = next_index(s_rx_tail);         // tail 向前移动一格
    return true;                               // 告诉调用者:成功取到一个字节
}

如果 s_rx_tail == s_rx_head,说明缓冲区暂时为空。transport_uart_receive_byte() 会进入阻塞等待,每隔一个 FreeRTOS 节拍检查一次。app_task 传入的是 portMAX_DELAY,表示会无限期等下去,直到新字节出现。

新字节出现后,任务会按照"先进先出"的顺序逐个取走——先进缓冲区的字节先被解析,这保证了命令帧的字节顺序不会乱。

小结一下:UART 中断像门口收快递的人,收到一个包裹(字节)就往环形缓冲区里放;app_task 像仓库管理员,按顺序从缓冲区取包裹去拆。缓冲区就是两者之间的小货架,解决"收件太快"和"拆件太慢"的节奏差异。

一封带封条的数字信件

如果没有协议会怎样?

想象一下:你收到一条长长的消息,全是数字和字母,但没有标点符号、没有段落分隔,也没有标题和签名。你完全分不清哪几个字组成一句话、哪些话构成一条完整的指令。

串口也面临同样的困境。串口本身只负责传送字节流——它并不知道"一条命令从哪里开始、到哪里结束"。如果上位机连续发送三条命令,在串口看来就是一串连续的字节,不知道该怎么分割。

因此,通信双方必须约定一种格式,把字节流切分成一帧一帧的消息。这种格式就叫协议帧(protocol frame)

信件的比喻

帧可以比喻成一封格式固定的信件:最外面有醒目的封套标记,中间有寄件信息和正文,末尾还有用于检查信件是否在运输中被损坏的封条。只有当一帧完整收到、封条核对无误,接收方才认为这是一条有效的命令。

正文里真正的命令数据,在通信领域有一个专门的名字,叫做 payload(载荷)。你可以把它理解为信封里装的信纸——信封上的收件人、地址、邮编等信息是协议头部,而信纸上写的内容才是 payload。

当前工程中的帧格式由 LOWER_CONTROLLER/PROTOCOL/firmware/comm_protocol.h 定义,结构如下:

部分 长度 含义
SOF 2 字节 固定为 AA 55,标记一帧开始
version 1 字节 当前协议版本为 01
frame_type 1 字节 command、ACK、telemetry 或 heartbeat
seq 2 字节 这一帧的序号,小端序
payload_len 2 字节 payload 长度,小端序
payload 0~256 字节 命令或数据正文
CRC 2 字节 对 header 与 payload 的校验结果,小端序

各部分的作用

帧头固定为 6 字节,也就是 version(1 字节)+ frame_type(1 字节)+ seq(2 字节)+ payload_len(2 字节)。

💡 零基础小课堂

这里有两个"256",别搞混了。 一个是 UART transport 的接收数组 s_rx_buffer[256],它是用来暂存串口收到的原始字节的。另一个是协议允许单帧 payload 最大为 256 字节。完整最大帧还要加上 2 字节 SOF、6 字节 header 和 2 字节 CRC,总长可达 2 + 6 + 256 + 2 = 266 字节。

这两个数字没有必然联系,只是恰好都是 256。缓冲区负责吸收"字节流"和"帧解析"之间的速度差,payload_len 负责描述单帧消息正文的大小。

状态机怎样拼出完整帧

先理解什么是状态机

在讲协议解析之前,我们先用一个生活中的例子来理解状态机(state machine) 这个概念。

想象一台自动售货机的工作流程:

  1. 等待投币 → 你投入硬币
  2. 等待选择 → 你按下商品按钮
  3. 出货 → 机器把商品推出来
  4. 找零 → 机器退回多余的钱,然后回到"等待投币"

售货机在每一个阶段只关注一件事:等投币的时候不理会按钮,出货的时候不接受新硬币。它在不同的"状态"之间按顺序切换,每个状态对应明确的行为规则——这就是状态机的核心思想。

协议解析的状态机

字节流是连续不断的,解析器必须按照约定好的顺序一步步识别:AA55 → 6 字节 header → payload → 2 字节 CRC。这和自动售货机的流程如出一辙——就像门卫手里的收件流程卡:

  1. 先看信封第一个标识;
  2. 再看第二个标识;
  3. 然后收固定长度的表头;
  4. 接着按表头写明的长度收正文;
  5. 最后核对封口处的校验印记。

只有每一步都走对了,才认为一封信件完整有效;任何一步出错,就回到最开始重新寻找下一个信件。

本工程的解析器共有五个阶段:

stateDiagram-v2 [*] --> 等待AA 等待AA --> 等待55: 收到 AA 等待55 --> 读取头部: 收到 55 等待55 --> 等待55: 再次收到 AA 等待55 --> 等待AA: 收到其他字节 读取头部 --> 读取正文: payload_len 大于 0 读取头部 --> 读取CRC: payload_len 等于 0 读取正文 --> 读取CRC: 正文收齐 读取CRC --> 等待AA: 校验结束

逐个阶段解释

小端序:数字的"倒着写"

6 字节头部收齐以后,程序需要把多字节字段按**小端序(little-endian)**还原。

想象你写数字 123,但约定从个位开始写:321。看起来像"倒着写"了,但双方知道规则就能正确还原。小端序就是这样的约定——数值的低位字节放在前面,高位字节放在后面。这是嵌入式和 x86 平台上非常常见的字节排列方式。

例如:

解析器随后会做两个基本检查:

  1. 协议版本是否为 01
  2. payload_len 是否超过 256 字节。

只要有一个检查不通过,解析器就会回到"等待 AA"状态,重新在字节流中寻找下一帧。

CRC:数据的"防伪封条"

正文按照 payload_len 收齐后,解析器再读两个 CRC 字节。CRC 的全称是 Cyclic Redundancy Check,中文叫循环冗余校验。它本质上是一种根据数据内容算出来的"指纹":发送端根据 header + payload 算出一个 16 位数值,接收端用同样的算法也算一次,两个"指纹"一致就认为数据没有损坏。

你可以把 CRC 想象成信封上的封条:封条完好,说明信件在运输过程中没有被拆过或损坏;封条破了,你就知道信件可能有问题。

💡 零基础小课堂

CRC 能纠错吗? 不能。CRC 只能检测数据是否在传输中发生了变化,它不能自动修复错误。如果 CRC 校验失败,接收端通常的做法是丢弃这一帧,等待上位机重发或发送错误响应。封条破了,你知道信件可能有问题,但封条本身不会帮你把信粘好。

版本错误、payload 过大、CRC 错误都会让解析器回到等待 AA 的状态。校验成功时,完整帧的内容被复制到 comm_frame_t 结构体中,ready 标志被设为 true。直到此刻,app_task 才会把这一帧交给命令分发器。

小结一下:状态机就像拼乐高,必须严格按照说明书一步一步来。少一块、错一块,整盒就推倒重来。这种设计让解析器即使遇到噪声、丢字节、乱序等情况,也能尽快回到安全状态,重新寻找下一帧。

把 START_MOTION 拆成真实字节

前面讲的是通用帧格式。现在我们要把 START_MOTION 这条具体命令拆成真实字节,看看它长什么样。

命令帧的 frame_type 固定为 01。它的 payload 还有一层更细的包装:

payload 位置 含义
第 1 字节 命令 ID
第 2 字节 参数长度
后续字节 参数列表

也就是说,payload 不是直接把参数塞进去,而是先说明"我是谁(命令 ID)"、"我有几个参数",然后再放参数。

START_MOTION 的命令 ID 为 04,参数长度固定为 2。两个参数分别是:

方向编码:

编码 含义
01 向前
02 向后
03 向左
04 向右

档位编码:

编码 含义
01 微速
02 慢速
03 快速

假设上位机发送序号为 1,要求机器人以"慢速"开始"向前"运动,那么完整帧可以写成:

AA 55 | 01 01 01 00 04 00 | 04 02 01 02 | CF 93
  SOF |       header       |   payload   |  CRC

🔍 动手试试看

在往下看答案之前,试着自己对照上面的帧格式表,从左到右读一遍这 14 个字节,猜猜每个字节分别是什么意思。不用担心猜错——动手试过一次,印象会比只看答案深刻得多!

让我们从左到右逐字节读懂这 14 个字节:

真实项目中,上位机不会手动掰这些字节,而是使用双方共享的协议生成代码来编码消息。但在这里把字节展开,是为了让你看清:代码里的每一个字段,最终都会落实在串口线上的一串 0 和 1 上。所谓"协议",不过就是双方对这些 0 和 1 的约定。

分发器像一座接线台

完整帧经过状态机和 CRC 校验后,最终被送到 command_dispatcher_handle_frame()。这个函数就像一座旧式电话接线台:它先读出呼叫号码(命令 ID),再把线路接到对应的业务接口。

你也可以把这个过程想象成银行验钞:每一层都要检查"这张钞票是真的吗",即使上一个柜员已经看过了。这种每一层都不信任上一层、自己再检查一遍的做法,在嵌入式编程中叫做防御式编程。为什么要这么做?因为一个坏命令如果没被拦住,可能会让机器人做出危险动作。

函数处理流程如下:

  1. 先查看 frame_type。当前应用只把 frame_type == 01 的 command 帧交给命令处理函数。
  2. 调用 comm_decode_command_view(),检查 payload 至少有两个字节(命令 ID + 参数长度),并确认"2 加参数长度"恰好等于整个 payload 长度。这一步防止 payload 长度和命令自称的参数长度不一致。
  3. 读取 cmd_id,进入 switch 分支。

START_MOTION 为例:

case COMM_CMD_START_MOTION: {  // 匹配到 START_MOTION 命令
    comm_start_motion_args_t args;  // 准备一个结构体来存放解析出的参数

    // 尝试解包参数:检查命令 ID 是否为 04、参数是否恰好 2 字节
    if (!comm_unpack_start_motion_args(frame, &args)) {
        // 解包失败——参数格式有误,回复"参数错误"的 ACK
        response_send_ack(frame->seq, command.cmd_id,
                          RESPONSE_STATUS_BAD_PAYLOAD);
        break;  // 不执行任何运动,直接结束
    }

    // 解包成功,调用运动接口让机器人动起来
    // args.direction = 运动方向,args.gear = 运动档位
    status = motion_start_motion(args.direction, args.gear) ?
        RESPONSE_STATUS_OK : RESPONSE_STATUS_BAD_PAYLOAD;
    // 无论成功还是失败,都回复 ACK 告诉上位机结果
    response_send_ack(frame->seq, command.cmd_id, status);
    break;
}

这里又做了一层针对性核对:comm_unpack_start_motion_args() 会确认命令 ID 必须是 04,参数必须恰好有 2 字节。通过以后,data[0] 成为 directiondata[1] 成为 gear

核对通过后,motion_start_motion(args.direction, args.gear) 被调用。当 direction = 01(向前)时,函数选择向前分支,把四个逻辑轮位都设为正的固定 PWM。当前固定值为 3000,档位参数虽然传入,但现阶段统一输出固定值,不区分快慢。运动层随后应用每个轮位的方向校正和 A、D、B、C 通道映射,再写入方向脚与 PWM 寄存器。

预告:四个麦轮怎样靠不同的正负号组合实现前后、横移与旋转,会在下一章继续展开。现在你只需要知道:命令已经走到了"让电机转起来"的最后一层。

ACK 沿原路返回

运动接口返回 true 时,分发器会发送一条状态为 OKACK(acknowledgement,确认响应)。ACK 就像快递签收单,告诉上位机"你发的这条命令我已经处理完了"。

ACK 帧的 payload 固定为 4 字节:

字节 含义
第 1~2 字节 被回应命令的序号(小端序)
第 3 字节 被回应命令的 ID
第 4 字节 处理状态

响应帧自己也有发送序号。在 response_sender.c 中,当前实现从 100 开始,每发送一帧就递增。这样上位机也可以区分下位机发出的不同响应。

如果前面的 START_MOTION 示例是控制板启动后处理的第一条响应,ACK 帧会是:

AA 55 | 01 02 64 00 04 00 | 01 00 04 00 | 1F 76
  SOF |       header       |   payload   |  CRC

逐字节解读:

response_send_ack() 编码完成后,会调用 transport_uart_send_bytes();transport 再通过 uart1_send() 逐字节写入 USART1,并等待每个字节发送完成。于是 ACK 沿着和命令相反的方向,从 STM32 回到电脑。

⚠️ 重要提醒:ACK ≠ 轮子真的在转

ACK 只能说明"帧校验通过、参数解析成功、命令分发成功、运动接口调用成功"这一整条软件链路走通了。它不保证机器人轮子一定在实物上按预期转动了。轮子的实际表现还会受到电源电压、电机驱动板、接线、安装方向、机械阻力、地面摩擦等因素影响。因此联调时一定要同时观察返回的 ACK 帧和机器人的真实动作。

命令已经走到车轮面前

现在我们把整条旅程串起来回看:

  1. 电脑发送 14 个普通字节。
  2. USART1_IRQHandler()PA10 上迅速接住每一个字节。
  3. transport_uart_rx_from_isr() 把字节放入 256 格环形缓冲区。
  4. app_task 按节奏从缓冲区取字节,交给协议状态机。
  5. 状态机在字节流中找到 AA 55,收齐 header、payload 和 CRC,核对无误后拼出一帧。
  6. command_dispatcher_handle_frame() 读出 cmd_id = 04,进入 START_MOTION 分支。
  7. comm_unpack_start_motion_args() 拆出 direction = 01gear = 02
  8. motion_start_motion() 收到"向前、慢速",把固定 PWM 写入四个轮子。
  9. 响应模块把 ACK 帧送回电脑,上位机看到"序号 1 的命令已 OK"。

整个过程从一串冷冰冰的字节开始,最终变成电机驱动板上的 PWM 波形。

目前协议共定义了 17 条命令:010F 覆盖连通测试、底盘、航向和机构动作,FE 用于查询 payload 版本,FF 用于测试回环。其中 PING、版本查询、测试命令、持续平移、持续旋转和停止已经具有具体行为,其余命令已经能进入格式校验与状态响应,后续会逐步接上运动、传感器和执行机构。

这条 START_MOTION 还带着一个重要的使用约定:它是一个持续运动命令,必须由后续的 STOP_MOTION 来结束。当前软件链已经能够执行这两个入口,但命令超时保护、看门狗、使能状态检查和统一安全停止机制仍需继续接入。

理解了这个边界之后,我们便可以把目光移向车底,看看四个带斜滚子的麦轮,如何把四组正负 PWM 变成向前、横移和旋转。下一章见。

本章新词

术语 英文 一句话解释
中断服务函数 ISR (Interrupt Service Routine) 硬件事件发生时 CPU 自动执行的一小段程序,就像门铃响了去开门
RXNE Receive Data Register Not Empty 串口硬件的标志位,表示"有新数据到了,快来取走"
环形缓冲区 Ring Buffer 首尾相接的数组,用来在快速生产者和慢速消费者之间缓冲数据
volatile volatile 告诉编译器"这个值可能随时被别人改,每次都要重新去读"
协议帧 Protocol Frame 按照约定格式包装好的一段完整消息,像一封格式固定的信件
SOF Start of Frame 帧起始标记,用来在字节流中定位一帧的开头
payload Payload 消息里真正的内容部分,就像信封里装的信纸
小端序 Little-Endian 数值的低位字节放在前面、高位放后面的排列方式
CRC Cyclic Redundancy Check 循环冗余校验,通过计算数据"指纹"来检测传输是否出错
状态机 State Machine 按阶段处理输入的方法,每个状态有明确的规则和转移条件
防御式编程 Defensive Programming 每一层都假设上一层可能犯错,自己再检查一遍才放心
ACK Acknowledgement 确认回复,告诉发送方"你的命令我已经收到并处理了"
telemetry Telemetry 机器人主动上报给电脑的状态数据,如速度、电压等
heartbeat Heartbeat 心跳包,定期发送的"我还活着"信号,用于检测通信是否断开
ORE Overrun Error 溢出错误,旧数据没读走就有新数据到达时发生的错误