好久没上来写写了,突然间手痒了整点有意思的东西以便日后回顾。C语言框架设计主要就是以面向对象的思想来进行底层的设计,参考Linux的内核和驱动层设计,设计完成后后续迭代只需在应用层进行添加修改即可,可极大的提高程序的可移植性、可扩展性、多人开发效率等等,对于需要长时间迭代,多人开发的大型项目工程尤为重要。
以AGV小车主控制器MCU程序外部设备管理为例,车上包含众多外设需通过CAN总线同MCU通讯,包含多个电机、编码器、IO扩展模块、pgv设备等。重构前的程序在CAN中断中接收所有数据并依照can id来解析数据,这种方法确实是最直白简单,容易理解的,但可想而知当外设数量多且种类繁杂时,会收到的大量的不同can id需要进行解析,代码中即会看到一大堆的 "case id:",如下示例所示:
switch (rx_message.StdId) {
case 0x181:
motor_1.over_time_counter = 0;
if (emMotorDriveType == BUKE) {
encoder1_buke = (int32_t)(rx_message.Data[0] | (rx_message.Data[1] << 8) |
(rx_message.Data[2] << 16) | (rx_message.Data[3] << 24));
motor1_status = (uint16_t)(rx_message.Data[4] | (rx_message.Data[5] << 8));
motor1_current = (uint16_t)(rx_message.Data[6] | (rx_message.Data[7] << 8));
}
break;
case 0x182:
motor_2.over_time_counter = 0;
if (emMotorDriveType == BUKE) {
encoder2_buke = (int32_t)(rx_message.Data[0] | (rx_message.Data[1] << 8) |
(rx_message.Data[2] << 16) | (rx_message.Data[3] << 24));
motor2_status = (uint16_t)(rx_message.Data[4] | (rx_message.Data[5] << 8));
motor2_current = (uint16_t)(rx_message.Data[6] | (rx_message.Data[7] << 8));
}
break;
case 0x183:
......
此写法将所有设备的can id混合在一起解析,重复性代码极多且不方便开发维护,每个设备都会发出多个属于自己的id消息,无法快速的将id与各个设备之间对应起来,耦合性高,当此类代码越写越多且包含多个开发人员时,写到最后便会发现许多令人头痛的地方:
(1) 当有新需求或需求变动时不能快速调整代码,往往一个小功能就要在各个c文件里折腾半天。
(2) 对于已有的代码更是不敢改不想改,牵一发而动全身。
(3) 代码不能复用,移植非常麻烦。
(4) 调试bug时更头疼。
以此MCU需和多个外部设备通讯的场景为例思考如何进行优化设计,首先便是需要抽象出所有外设的共有特性,创建一个设备管理的结构体将其特性全部包揽进去。
typedef int32_t (*p_hb_handler)(device_id_t dev, uint8_t *data, uint16_t len);
typedef void (*p_device_rx_cb)(device_id_t dev, device_msg_type_t *type, uint8_t* data);
typedef int32_t (*p_device_msg_map)(uint16_t can_id, device_msg_type_t *type);
struct device_manage {
device_id_t device_id; //设备号
uint32_t status; //设备状态
uint32_t error_code; //设备错误代码
uint16_t hb_time; //设备心跳时间,mcu主动发送
p_hb_handler hb_handler; //心跳时间到来时的回调函数
p_device_rx_cb device_rx_cb; //接收到设备数据时的回调函数
p_msg_map device_msg_map; //消息类型映射回调函数
struct device_manager *pre; //指向上一个设备
struct device_manager *next; //指向下一个设备
};
各个设备的主要共有特性大致如上,若有新的特性也可继续扩展添加,如后续可能会使用到同个设备多个厂商时可添加设备厂商名以区分。设备数据结构体设定完后首先便需提供设备添加函数,将新设备添加到设备链表当中,可在应用层中调用此函数初始化各个设备的独自特性。
int32_t device_add(device_id_t id, p_hb_handler hb_handler, p_device_rx_cb rx_cb, p_device_msg_map msg_map)
{
int32_t ret = -1;
struct device_manager *dev_new = NULL;
struct device_manager *dev_cur = NULL;
if (!id) return -1;
if (xSemaphoreTake(semaphore_device, portMAX_DELAY) == pdTRUE){
dev_new = pvPortMalloc(sizeof(struct device_manager));
if (dev_new) {
dev_new->device_id = id;
dev_new->status = 0;
dev_new->error_code = 0;
dev_new->hb_time = 50; //心跳时间50ms
dev_new->hb_handler = hb_handler; //心跳处理回调函数
dev_new->device_rx_cb = rx_cb; //数据接收回调函数
dev_new->device_msg_map = msg_map; //消息映射回调函数
dev_new->pre = NULL;
dev_new->next = NULL;
if (!device_manager_head) {
device_manager_head = dev_new;
} else {
dev_cur = device_manager_head;
while (dev_cur->next) {
dev_cur = dev_cur->next;
}
dev_cur->next = dev_new;
dev_new->pre = dev_cur;
}
ret = 0;
}
xSemaphoreGive(semaphore_device);
}
return ret;
}
其中device_id设备号用于标识出各个设备,定义出一个device_id_t的设备枚举类型:
typedef enum {
/* 运动控制电机 */
DEV_MOTOR_MC_1 = 11,
DEV_MOTOR_MC_2 = 12,
/* 动作控制电机 */
DEV_MOTOR_AC_1 = 21,
DEV_MOTOR_AC_2 = 22,
/* 编码器 */
DEV_ENCODER_WALK_1 = 31, // 行走
DEV_ENCODER_ROTATE_1 = 34, // 旋转
DEV_ENCODER_PULL_ROPE_1 = 36, // 拉绳
DEV_ENCODER_PULL_SWAY_1 = 38, //横移
/* IO扩展模块 */
DEV_IO100_1 = 41,
/* PGV模块 */
DEV_PGV_UP1 = 51,
DEV_PGV_DOWN1 = 52,
} device_id_t;
查找设备时以此设备号为条件可找出各个设备对应的链表节点:
void device_find(struct device_manager **dev, device_id_t id)
{
static struct device_manager *dev_cur = NULL;
//高频调用,使用信号量使其成为可重入函数
if (xSemaphoreTake(semaphore_device, portMAX_DELAY) == pdTRUE ){
dev_cur = device_manager_head; //首先指向链表头
while (dev_cur) { //遍历整个链表寻找指定的节点
if (dev_cur->device_id == id){
break;
} else {
dev_cur = dev_cur->next;
}
}
*dev = dev_cur; //返回找到的链表节点
xSemaphoreGive(semaphore_device);
}
}
设备结构体里的其它数据类型直接字面理解即可,重点是设备的消息映射回调函数,需要将每类设备发出的不同can id数据通过某种方法组织起来以方便管理。以步科电机为例,一台车上会用到多个电机,虽然发出的数据can id各不相同,但消息解析的地方有相同之处,如1号电机发出的0x181的can id和2号电机发出的0x182的can id里面的8个字节数据解析方法都一样,以此为基础即可通过二维数组将电机类设备的can id全部组织起来。
static uint16_t kinco_motor_message_map[][5] =
{
//每列数据的解析方法一样
{0x181, 0x281, 0x381, 0x481, 0x701}, //电机1发出的所有can id数据
{0x182, 0x282, 0x382, 0x482, 0x702}, //电机2发出的所有can id数据
{0x183, 0x283, 0x383, 0x483, 0x703}, //电机3发出的所有can id数据
{0x184, 0x284, 0x384, 0x484, 0x704}, //电机4发出的所有can id数据
};
其中每一行的can id代表某个设备发出的所有报文,每一列的can id代表所有此类设备的数据解析方法全一样,以此来实现消息映射的回调函数,找出的某一列就代表同一种消息类型:
//消息映射枚举
typedef enum {
DEVICE_MSG_NULL = 0x00, /* 空 */
MOTOR_MSG_PSC, /* 位置/状态/电流 */
MOTOR_MSG_VE, /* 速度/错误码 */
MOTOR_MSG_CIT, /* 控制字/IO/扭矩 */
MOTOR_MSG_WM, /* 工作模式 */
MOTOR_MSG_HEART, /* 心跳 */
} device_msg_type_t;
//消息映射
int32_t kinco_map(uint16_t can_id, device_msg_type_t *type)
{
uint16_t index = 0;
uint8_t col = 0;
while (index < kinco_row){ //遍历每一行
for (col = 0; col < kinco_col; col++){
if (can_id == kinco_motor_message_map[index][col]){ //找出此can_id对应的列数
*type = (device_msg_type_t)(col + 1); //返回列数对应的消息类型
return 0;
}
}
index++;
}
*type = MOTOR_MSG_NULL;
return -1;
}
消息映射完成后便到了消息解析的部分,同样以步科电机的消息解析为例,在应用程序中写好消息解析的回调函数并调用 device_add() 注册到设备中,应用层的解析函数只需按照指定的消息类型进行解析即可,不需要再管那些繁多的can id。
void kinco_rx_motor_1(device_msg_type_t type, uint8_t* data)
{
// 直接case消息类型即可
if(type == MOTOR_MSG_PSC) { // 位置/状态/电流
motor_1.encoder = (data[0] | (data[1]<<8) | (data[2]<<16) | (data[3]<<24));
motor_1.status = (data[4] | (data[5]<<8));
motor_1.current= (data[6] | (data[7]<<8));
} else if(type == MOTOR_MSG_VEE) { // 速度/错误码
motor_1.speed = (data[0] | (data[1]<<8) | (data[2]<<16) | (data[3]<<24));
motor_1.actual_speed = motor_1.speed*1875.0f/10000.0f/512.0f; //转为rpm的转速。
motor_1.error_code = (data[4] | (data[5]<<8) | (data[6]<<16) | (data[7]<<24));
}
}
消息映射及消息解析的回调函数都已注册好,还差一个设备心跳的回调函数参考上面注册即可,底层检测到心跳时间到来后便会自动调用。至此应用层的回调函数都已注册好,接下来便需要完成底层设备管理部分,以此统一调用应用层注册好的回调函数。
int32_t device_handle(void)
{
struct device_manager *dev_handle = NULL; //设备链表
device_id_t cur_dev = DEV_NULL; //设备名
device_msg_type_t type = DEVICE_MSG_NULL; //消息类型
CanRxMsg rx_msg; //接收到的can总线数据
memset(&rx_msg, 0 , sizeof(rx_msg));
//从队列中获取所有接收到的can数据
if(xQueueReceive(can_rx_que, (void *)&rx_msg, 0) != pdPASS){
return ERROR;
}
canid_map(rx_msg.StdId, &cur_dev); //依照接收到的can id来映射出设备名
device_find(&dev_handle, NULL, cur_dev); //依照设备名来找出设备链表中的此设备节点
if (dev_handle){ //成功找到设备节点
//调用此设备节点的消息映射回调函数
dev_handle->device_msg_map(rx_msg.StdId, &type);
//调用此设备节点的消息解析回调函数
dev_handle->device_rx_cb(cur_dev, &type, rx_msg.Data);
return OK;
}
return ERROR;
}
此函数放入循环中运行,当can中断接收到数据入队后此处便能立马通过出队获取接收到的can数据,接下来依照can id来映射出设备名,有了设备名后通过device_find()找到此设备的链表节点,找到设备节点后再调用各个应用层注册好的回调函数。
至此一套设备管理的框架大体已设定完成,对应用层而言只需关注自己想要注册的设备,不需要管底层的数据究竟是如何对应的,如某些情况下需要注册四个电机设备某些情况下需要两个电机和一个编码器设备,此时只需在应用层写好对应设备的数据处理回调函数并调用设备注册函数添加到设备链表中即可,不需要在多个地方窜来窜去的修改。
以上只是大致描述,其中还有许多许多细节要做,需要考虑的面面俱到才能确保框架运行起来不会出现莫名其妙的问题,项目初期肯定会花费大量时间来进行规划设计,不过一旦设计完成后便能在后期的持续迭代中成倍的将初期花费的时间节省回来,非常适用于需要不断开发维护的持续性产品。