架构特别篇三:DEVICES 层 --- "协议解析,别管别的"
这是煜坤架构中最"克制"的一层。只有一条规则:解析协议、发布数据。不创建任务、不写业务逻辑。为什么这么设计?因为"单一职责"在嵌入式里比在桌面软件里更重要------你永远不知道这个驱动下个月会接到什么传感器。
1. DEVICES 层只做一件事
c
// m10_parser.h --- M10 LiDAR 协议解析器
// 注意: 没有 FreeRTOS, 没有任务创建, 没有全局变量操作
// 输入: 原始字节流
bool M10_ParseFrame(const uint8_t *data, uint16_t len, M10_Frame_t *frame);
// 输出: 结构化的距离数组
void M10_GetRanges(const M10_Frame_t *frame, float *ranges, uint16_t *count,
float *angle_min, float *angle_inc);
这一层没有的:
- ❌ FreeRTOS 任务(任务创建是 CONTROL 层的事)
- ❌ 全局变量(数据发布通过 CONTROL 层提供的指针)
- ❌ 业务逻辑("这个障碍威胁大吗" → CONTROL 层判断)
- ❌ HAL 调用(UART 初始化是 SYSTEM 层的事)
这一层有的:
- ✅ 字节流 → 结构化数据的翻译
- ✅ 数据校验(CRC/帧头帧尾/长度)
- ✅ 协议常量和数据结构定义
2. 为什么不让 DEVICES 层创建任务?
假设我们把任务创建放在 DEVICES 层:
c
// 坏设计:
// m10_parser.c 里创建 FreeRTOS 任务
void M10_StartTask(void) {
xTaskCreate(m10_task, "M10", 512, NULL, 4, &handle);
}
问题来了:
- 这个任务优先级多少?→ DEVICES 层不应该决定优先级(那是 CONTROL 的事)
- 栈多大?→ 取决于 CONTROL 层的调用链(路径规划嵌套多深)
- 数据推给谁?→ DEVICES 层不知道 CONTROL 层有哪些消费者
正确做法 :CONTROL 层写 lidar_task.c------创建任务、决定频率、调用 M10_ParseFrame、把结果推给 path_planner。
DEVICES 层: m10_parser.c (纯解析, 可被任何任务调用)
CONTROL 层: lidar_task.c (创建任务, 决定频率, 连接消费者)
3. 实际案例:MR72 和 M10 两种完全不同的驱动
MR72 毫米波雷达(33Hz, 14 字节帧/目标, 最多 8 个目标):
c
// mr72_parser.h --- 数据结构
typedef struct {
uint8_t target_id;
float distance; // m
float azimuth; // deg
float velocity; // m/s (多普勒, 接近为负)
float threat_level; // 0~1
bool is_valid;
} RadarTarget_t;
typedef struct {
RadarTarget_t targets[8];
uint8_t target_count;
bool is_valid;
uint32_t timestamp;
} RadarFrame_t;
// API --- 只有三个函数
void MR72_InitConfig(RadarConfig_t *config);
bool MR72_ParseFrame(const uint8_t *data, uint16_t len,
RadarFrame_t *frame, const RadarConfig_t *config);
void MR72_GetValidTargets(const RadarFrame_t *frame,
RadarTarget_t *out, uint8_t *count);
M10 LiDAR(240Hz 帧率, 92 字节/帧, 42 点/15°扇区):
c
// m10_parser.h --- 数据结构
typedef struct {
M10_Point_t points[42];
uint8_t valid_count;
float start_angle; // deg
float speed_rpm;
bool is_valid;
} M10_Frame_t;
// API --- 也是三个函数
void M10_ResetFrame(M10_Frame_t *frame);
bool M10_ParseFrame(const uint8_t *data, uint16_t len, M10_Frame_t *frame);
void M10_GetRanges(const M10_Frame_t *frame, float *ranges, uint16_t *count,
float *angle_min, float *angle_inc);
共同模式:Parse → Get → 发布。两个驱动完全不同(一个是目标列表、一个是点云),但 API 风格一致。新人接一个新传感器,照着 MR72 或 M10 的模板写就行。
4. 数据怎么流向 CONTROL 层?
CONTROL 层的任务调用 DEVICES 层解析器,拿到结构体数据,写入全局变量或直接消费:
c
// lidar_task.c (CONTROL 层)
static float s_lidar_ranges[42]; // 全局缓冲
while (1) {
// ① 读 UART (SYSTEM 层)
while (comGetBuf(COM6, rx_chunk, 512) > 0) {
// ② 帧状态机 → 拼帧
// ③ 解析 (DEVICES 层)
if (M10_ParseFrame(frame_buf, 92, &m10_frame)) {
// ④ 提取距离 (DEVICES 层)
M10_GetRanges(&m10_frame, s_lidar_ranges, &count,
&angle_min, &angle_inc);
// ⑤ 推入路径规划 (CONTROL 层)
path_planner_update_sensors(&g_path_planner,
NULL, s_lidar_ranges, count, angle_min, angle_inc);
}
}
vTaskDelayUntil(&wake, pdMS_TO_TICKS(10));
}
DEVICES 层不知道"谁是消费者"。它只是把字节变成结构体。CONTROL 层决定"这个数据给谁"。
5. devices_list.h 和 devices_utils.h --- 聚合和工具
devices_list.h:把所有 DEVICES 模块的头文件聚合在一起。
c
// devices_list.h --- 一次 include 引入所有设备驱动
#include "radar/mr72_parser.h"
#include "radar/m10_parser.h"
#include "mpu/mpu6050.h"
#include "rtkAntenna/wtrtk.h"
// ...
CONTROL 层只需要 #include "control_list.h"(它依赖 devices_list.h)。加新驱动 → 在 devices_list.h 加一行 → 全局可用。
devices_utils.h:跨设备共享的工具函数。
c
// 大端字节序读取 (MR72 和 M10 都用)
static inline uint16_t byte_read_u16_be(const uint8_t *data, uint8_t start) {
return ((uint16_t)data[start] << 8) | data[start + 1];
}
// CRC8 查表 (MR72 用)
static inline uint8_t crc8_calc(const uint8_t *p, uint8_t len) { ... }
// NMEA 字符串解析 (RTK 用)
static inline int32_t str_to_int_dec(const char *buf, uint8_t *decimals) { ... }
全部 static inline → 零链接开销 → 编译器自动内联到调用处。
6. DEVICES 层的"反模式"
这些是在开发过程中学会的"不该这么做":
反模式 1:在驱动里直接操作全局变量
c
// 坏: 驱动写全局变量, 消费者不知道数据什么时候变的
bool M10_ParseFrame(...) {
g_lidar_data.ranges[0] = ... // 不行
}
正确做法:返回结构体,调用者决定数据放哪。
反模式 2:驱动里加"一点"业务逻辑
c
// 坏: 驱动里判断威胁等级
bool MR72_ParseFrame(...) {
if (target.distance < 3.0f) threat = HIGH; // 驱动不该知道"3m是危险"
}
威胁判断属于 CONTROL 层(sensor_fusion/obstacle_avoidance)。
反模式 3:驱动依赖 FreeRTOS
c
// 坏: 驱动里调 vTaskDelay
bool MR72_ParseFrame(...) {
vTaskDelay(10); // 驱动不知道调用者在哪个任务里
}
驱动应该是"纯函数"------给定输入,产生输出,无副作用。
7. 总结:DEVICES 层的核心价值
makefile
其他人: 传感器驱动 = 任务 + 协议解析 + 数据处理 → 揉在一起
煜坤: 传感器驱动 = 协议解析 → 纯函数, 无副作用
好处:
① 一个驱动可以被多个任务复用 (同一解析器,M10 和回放模式共用)
② 换传感器只需改 DEVICES 层, CONTROL 层不动
③ 单元测试极简单: 给字节数组 → 检查输出结构体 → 不需要硬件
本文是《从零搭建无人船控制系统》架构特别篇。项目地址:煜坤 YuKun(开源准备中)。