项目简介
该项目制作了一个无线控制的电动滑板,载人工况下最大时速可达 40km/h,续航 25km 左右。该项目开源资料中包括滑板的硬件电路设计、软件代码设计、机械结构工程。

整个系统主要分为两个部分:滑板、遥控器。滑板在使用时,通过遥控器控制滑板的动力,转向通过人在滑板上的重心转移实现,与普通滑板原理一样。遥控器上有显示屏可以查看当前滑板状态、电池电压、温度、速度、累计里程与运行时间。通过摇杆前后推移实现加速和刹车的控制,两个按钮靠近摇杆的一个可以切换动力模式,另一个预留。

滑板的主控板为电调板。电调板实现了滑板的全部控制,包括:无刷电机驱动、转速采集、遥控通信、电流电压温度采样等。在滑板中有一块分电板,用于便捷的更换保险丝、断电、充电。电调板支持 24V-42V 宽电压输入范围,仅需在代码中修改低电量判断参数即可,最大稳定驱动电流 12A。

滑板和遥控器的软件功能较多。主要实现的了滑板与遥控之间的可靠双向通信,同时代码结构便于自行增加新功能。代码均未使用 rtos,嵌入式软件基础较差的同学也能够理解代码逻辑。

滑板机械结构主要使用碳纤维板 cnc 加工后作为滑板主要框架,并作为防水防尘的外壳。内部电气设备使用 3D 打印件进行固定。遥控器使用 3D 打印外壳进行简单固定。


一、硬件
滑板的硬件分为 3 块 PCB,电调板(滑板的主控板)、遥控板(用于控制滑板运行)、分电板(固定滑板电源总开关、充电接口、保险丝)。
硬件部分内容不在此处介绍,大家可以移步开源文档或立创开源硬件平台。

二、软件
电调板与遥控板的软件功能如下,重点以电调板为例介绍功能,遥控板具有的功能基本均由电调板代码直接移植得到,基本不需要单独介绍。
两块板均使用常见的 stm32f103c8t6 芯片编写代码。没有使用 freertos,通过手动的时间管理与 DMA 等硬件配置,实现多任务的执行不阻塞。
电机控制
电机控制的核心是使用三个半桥芯片控制六个 mos 管实现六步换向法控制三相无刷电机。其中当电机处于滑行状态时,需要使用弱场控制方法防止电机发电产生制动力。
六步换向法控制电机旋转
滑板使用有感三相无刷电机,需要逆变器输出三相交流电控制电机旋转。现在使用三个 IR2104 半桥芯片控制六个 MOS 管实现逆变。IR2104 芯片只需要一路 PWM 输入即可自动控制上下桥臂导通与关断,并自动实现死区防止上下桥臂同时导通,方便软件控制。软件通过读取电机的霍尔信号判断当前转子角度,然后控制 PWM 输出实现电机旋转到下一个角度,并在此时可以控制旋转方向。
目前代码中没有写倒车代码,仅支持向前行驶不支持向后行驶。如需切换行驶方向,请自行增加该功能。
电机动力输入控制
电机的转速通过一个 -100~100 的变量控制。控制变量从心跳包中得到,然后在滤波器函数前映射到当前定时器的 ARR 范围。
// 一阶低通滤波器 // 用于速度控制
#define FILTER_ALPHA 0.0001f
// 一阶低通滤波系数
float Motor_filter_prev = 0;
uint8_t isCoasting = 0; // 当前正在滑行标志位
static float setSpeed_filter(float input) {
input = input * ((MOTOR_ARR + 1) / 100);
// 检测符号变化(正变负或负变正)
static float last_input = 0.0f;
int sign_change = 0;
// 判断是否发生符号变化
if ((last_input >= 0.0f && input < 0.0f) || (last_input <= 0.0f && input > 0.0f)) {
sign_change = 1;
}
last_input = input;
// 如果检测到符号变化,重置滤波器状态
if (sign_change) {
Motor_filter_prev = 0.0f;
}
float output = 0;
// 如果收油门 立刻终止加速
if (!sign_change && input < Motor_filter_prev && output > input) {
// 如果油门为正 当前输入小于上次一输出 (杆量在下降) 本次输出大于当前杆量 则加大滤波系数使输出尽快达到杆量
output = (FILTER_ALPHA * 100) * input + (1 - (FILTER_ALPHA * 100)) * Motor_filter_prev;
} else {
output = FILTER_ALPHA * input + (1 - FILTER_ALPHA) * Motor_filter_prev;
}
// 如果当前正在滑行 则加大滤波系数使输出尽快达到杆量 防止滑行时增加杆量不跟手问题
if (isCoasting == 1) {
output = (FILTER_ALPHA * 100) * input + (1 - (FILTER_ALPHA * 100)) * Motor_filter_prev;
}
// 当杆量为 0 时 杆量快速归零 用于滑行
if (input == 0) {
output = (FILTER_ALPHA * 10) * input + (1 - (FILTER_ALPHA * 10)) * Motor_filter_prev;
}
Motor_filter_prev = output;
return output;
}
需要注意的是,低通滤波器在基础滤波功能的基础上,增加了一个当输入变量正负值改变时,重置 filter_prev 以达到在刹车和加速之间及时切换。收油门时,尽快停止加速,防止由于滤波器的存在,导致从加速到滑行的过程不跟手,当杆量减小时仍在加速。在滑行时,让杆量变化加快,以便在滑行切换到继续加速的过程更加跟手,此时使用弱场控制方法滑行。当杆量为 0 时,进入 mos 全关断滑行状态,节省能源。
在遥控板上,速度控制是一个 0-200 的数,小于 100 即减速,大于 100 加速。所以遥控板的速度值需要转换后再给到电调板使用,在电调板的解包函数中进行了转换。另外,如果需要限制动力,建议在遥控板上实现,这样可以方便的切换各个不同动力模式,无需再专门使用一个协议控制电调板。
弱场控制
当电机滑行时,由于电机发电,会有较大的刹车加速度,会导致当油门减小时,突然不平顺的减速。具体原因是由于当前 mos 管有输入较小的占空比,导致电机滑行发电时,电流会形成回路,从而在电机绕组中产生电流,形成制动力。为了解决这个问题,于是引入弱场控制。
需要注意,如果直接将所有 mos 管关断,确实能通过阻断发电电流从而解决滑行发电刹车的问题,但是在全关断与输出动力的切换时,电机输出动力不平顺且有异响。直接关断所有 mos 还有一个问题,当油门重新开始加大,但是又还没有到当前速度的油门大小时,也会有较大的发电刹车,所以必须使用弱场控制来实现电机滑行。
弱场控制是通过检测当前电机处于滑行状态时,主动施加动力,目的是输出电平去抵消滑行发电产生的电动势,从而解决滑行发电的制动力问题。
计算方法如下:
// 计算 Ke 弱场控制 计算值为:0.147333
#define K_rpm_to_rads (2.0f * 3.1415926535f) / 60.0f;
float Motor_calculate_Ke(uint8_t input_Duty) {
float Duty_min_hold = (float) input_Duty / 100.0f;
float V_bus = adc_calculate_battery_voltage();
float RPM_min_hold = Hall_rollSpeed * 60;
float omega_mech = RPM_min_hold * K_rpm_to_rads;
// 计算机械角速度(rad/s)
float Ke = (V_bus * Duty_min_hold) / omega_mech;
return Ke; // 单位 V·s/rad
}
// 计算最小滑行占空比 弱场控制
#define KE 0.130f
#define Delta_V 0.1f
uint8_t calculate_min_slip_duty(void) {
float RPM = Hall_rollSpeed * 60;
float V_bus = adc_calculate_battery_voltage();
float BEMF = KE * RPM * K_rpm_to_rads;
float Duty_min_slip = (BEMF + Delta_V) / V_bus;
// 限制范围
if (Duty_min_slip < 0.0f) Duty_min_slip = 0.0f;
if (Duty_min_slip > 1.0f) Duty_min_slip = 1.0f;
float ccr_value = Duty_min_slip * 100.0f;
// 四舍五入并限制在 0-99 范围内
uint8_t ccr = (uint8_t)(ccr_value + 0.5f); // 四舍五入
if (ccr > 99) ccr = 99;
return ccr;
}
注意 Ke 和 Delta_V 需要根据实际情况调整。这两个函数的使用方法为:先通过串口获取 Motor_calculate_Ke 的返回值,然后当电机以不同速度空载匀速旋转时,记录 Ke,然后取多个值进行平均计算。然后在滑行时,使用 calculate_min_slip_duty 这个函数得到的占空比,然后观察电机现象,如果当滑行时,电机继续加速,那么就需要减小 Ke,反之增大。至于 Delta_V,这个值较小,对实际效果的影响不太大,可以先从 0.1 开始缓慢增大观察现象。如果在滑行时发现电机越转越快,可以减小 Delta_V。具体情况需要具体测试。
建议当杆量不为 0 的滑行状态使用弱场控制,当杆量为 0 时切换为 mos 全关断滑行。如果杆量不为 0 时也使用全关断滑行,可能会导致系统频繁在全关断与弱场控制之间切换,导致电机不平顺且有异响。当杆量为 0 时,基本代表长时间滑行,此时使用全关断可以节省能源,同时降低电机噪音。杆量在 0 和非 0 直接切换时,有滤波器进行平滑处理,不存在平凡在 0 和非 0 直接切换。
制动控制
实现刹车有两种方法:在电机旋转时施加反向的动力实现减速;利用滑行发电的制动力进行制动。我已经尝试了两种方法,利用滑行发电制动的效果较好,从加速到减速的过程较为平缓,且制动力完全足够,也能节省能源。
如果采用施加反向动力实现减速的方法,需要注意防止当电机成功停下后,由于继续输出反向动力,导致电机反转。需要判断如果电机已经停止或者旋转方向变化,需要切断刹车动力。
利用滑行发电的制动力进行制动效果较好且实现方法较简单,不需要考虑电机反向转动的问题。当进行刹车时,只需要将输出占空比从 calculate_min_slip_duty 开始减小即可。
目前只有正向行驶的刹车效果是正确的,如果滑板正在反向滑行,此时刹车力度会很大。因为刹车计算方式是按照正转进行计算的,在倒车时刹车力度计算输出超过安全范围,会被限制在最大值。所以倒车时刹车力度无法控制且刹车力度是最大值。在使用时需要小心。
霍尔信号处理
需要注意,霍尔信号线布线时应当尽量远离电机功率网络,防止信号被干扰产生大量杂波,然后使得软件中不断发起无意义的外部中断,速度计算出现错误。由于电机转速控制也需要根据速度信号得到,所以可能会导致电机速度控制不符合预期,可能会产生危险。
霍尔信号采集使用外部中断的方式,防止不断轮询导致时间浪费。该中断优先级应该尽量高,因为控制电机旋转必须霍尔信号,如果信号错误会导致电机运行错误,非常危险。
实测电机转动一圈发起 60 次霍尔中断,可以据此计算转速,也可以得到行驶里程。建议使用中断 counter 进行速度与里程计算,以确保里程的精度。在保存累计里程时,由于 flash 中存储的数据只包含整数不包含小数,但是如果每次都直接将小数丢弃,会导致误差不断累积,所以现在每次结算后,将多余的小数保存,累加到下一次结算里程中。
#define Wheel_diameter 90.0f //轮子直径 单位 mm
#define Speed_Time 0.1f //秒
// 转速计算
void Get_rollSpeed(void) {
float Speed = 0;
float count = Hall_Count;
Speed = (count / 60.0f) / Speed_Time; //转一圈 Hall_RotationCount 增加 60 次。(圈/秒)
if (Hall_Direction != 0) {
Speed = Speed * Hall_Direction;
}
Hall_rollSpeed = RPM_filter(Speed); // 更新里程累计
static uint16_t mileage_count = 0;
mileage_count += count;
if (mileage_count >= 213) // 轮子周长 = 0.282743m, 一个 count = 0.004712m, 212.2 个 count 走 1m
{
static float decimal = 0; // 累计每次计算留下的小数 在下次结算时加上 提高里程累计精度
float meter = 0.004712f * mileage_count + decimal;
uint8_t add_meter = (uint8_t) meter;
decimal = meter - add_meter;
Store_Mileage(add_meter);
mileage_count = 0;
}
}
遥控模块配置
目前的工程在上电时初始化遥控模块参数,但是可能由于遥控模块上电启动速度比 MCU 慢,导致模块参数初始化有可能失败,非常不稳定,故使用一个特殊固件专门用于配置模块参数。新的模块只需要将特殊固件下载后,复位 MCU,等待代码运行 10s 左右即可,然后再下载正常运行固件即可正常使用。
在正常固件中,不执行遥控模块配置的操作,如果发现遥控模块无法通信,需要下载特殊固件重新配置遥控模块。遥控模块使用前需要配置信道与空中速率等,在上述提到的特殊固件代码中可以对照遥控模块使用手册详细查看具体配置信息,也可以使用厂家的上位机进行配置。收发功率可以尽可能大,空中速率可以在装机后实测信号大小,如果信号强度不够总是丢包,可以降低空中速率提高信号质量。
使用通信模块时需要注意信道占用问题。需要合理控制发包频率,如果一个模块不断高速发包,会导致信道长时间被占用,另一个模块就无法发送数据。
更详细的遥控模块使用手册请联系供应商获取。遥控模块型号:火蝠无线 JC24B。
通信协议
通信协议数据包结构为:
typedef struct __attribute__((packed)){
//取消字节填充 解决字节对齐问题
uint8_t start_flag; //包头
uint32_t pack_number; //包序号
uint32_t timestamp; //发包时间
uint8_t cmd_id; //协议号
uint8_t data[]; //
// uint8_t crc8; //crc 校验
uint8_t end_flag; //包尾
} RC_pack_t;
在收到遥控模块传输的数据包后,将会进行解包操作,根据数据包中第一个 0x5B 作为包头,0x5D 且其后第二位为 0x00 作为包尾,0x5D 的后一位作为 CRC8 校验位。然后将对应协议号提取出来后,执行对应协议号的函数。
该解包与发包方法支持不定长数据,直接调用已有的函数进行发包即可,收到数据包后会自动开始解包,只需要注册对应协议号与执行函数即可。这种通信方式能够方便地管理多个命令,实现复杂的功能。同时不定长的数据包也可以节省遥控信道的带宽,防止由于空中速率不够导致丢包。
在数据包中,有包序号和发包时间这两个字段,可以通过这两个数据计算当前遥控通信的丢包率与延迟。计划在 RC_unpack 函数中进行计算,但是由于开发时间有限且丢包可以通过软件看门狗超时实现处理,所以目前并没有使用这两个字段。
解包函数与封包函数如下:
int8_t RC_unpack(const uint8_t *byteArray) {
// 判断包头
if (byteArray[0] != 0x5B) {
return -1; // 无效包或包头错误
}
//查找包尾
size_t packet_size;
uint8_t end_flag = 0;
for (packet_size = 1; packet_size < SERIAL_3_RX_BUFFER_SIZE - 2; ++packet_size) {
if (byteArray[packet_size] == 0x5D && byteArray[packet_size + 2] == 0x00) {
packet_size += 2;
end_flag = 1;
break;
}
}
if (!end_flag) {
return -1;
}
// 提取数据包
uint8_t pack[SERIAL_3_RX_BUFFER_SIZE];
memcpy(pack, byteArray, packet_size);
// crc 校验
uint8_t crc8 = pack[packet_size - 1];
if (crc8 != Calculate_CRC8(pack, packet_size - 1)) {
return -1; // CRC 校验失败
}
// 统计延迟与丢包率
// crc 校验成功,进行解包操作
typedef union {
//使用联合体提取数据包 避免动态分配内存
uint8_t raw[SERIAL_3_RX_BUFFER_SIZE];
RC_pack_t pack;
} PackConverter;
PackConverter converter;
memcpy(converter.raw, pack, packet_size);
// 判断当前 NTP 是否成功对时
uint32_t time = converter.pack.timestamp;
// 误差小于 100 判断为成功对时
if (((int64_t)NTP_time - (int64_t)time) < 100 && ((int64_t)NTP_time - (int64_t)time) > -100) {
NTP_status = normal;
} else {
NTP_status = error;
}
switch (converter.pack.cmd_id) {
case CMD_SPEED_CONTROL: {
CMD_speed_control(converter.pack.data);
return 0;
}
case CMD_SPEED_REPORT: {
CMD_speed_report();
return 0;
}
case CMD_TIME_SYNCHRONOUS: {
CMD_timeSynchronous(converter.pack.data);
return 0;
}
case CMD_HEARTBEAT: {
CMD_heartbeat(converter.pack.data);
return 0;
}
default: return -2;
}
}
void RC_send(const uint8_t *data, uint8_t cmd_id, size_t data_size) {
// 计算总包长度 = 包头 (1) + pack_number(4) + timestamp(4) + cmd_id(1) + 数据 (data_size) + 包尾 (1) + CRC(1)
const size_t header_size = sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint8_t);
const size_t total_size = header_size + data_size + 2; // +2 表示包尾和 CRC
// 创建发送缓冲区
uint8_t send_buf[SERIAL_3_TX_BUFFER_SIZE];
if(total_size > sizeof(send_buf)) {
// 处理错误:数据过长
return;
}
// 填充包头部分
RC_pack_t *pack = (RC_pack_t *)send_buf;
pack->start_flag = 0x5B;
pack->pack_number = 0; // 需要实现包序号递增逻辑
pack->timestamp = APP_time;
pack->cmd_id = cmd_id;
// 填充数据部分
memcpy(send_buf + header_size, data, data_size);
// 添加包尾
size_t end_pos = header_size + data_size;
send_buf[end_pos] = 0x5D;
// 计算 CRC(包头 + 数据 + 包尾)
uint8_t crc = Calculate_CRC8(send_buf, end_pos + 1);
send_buf[end_pos + 1] = crc;
// 发送完整数据包
Serial_3_SendArray(send_buf, total_size);
}
在使用时虽然系统可以根据不同协议号执行对应操作,但是也需要确保如果执行指定协议的数据包丢包要如何进行保护。假设发送一个 0x01 协议的数据,但是该数据包被丢失,导致实际并没有执行该指令。那么这种情况的保护措施需要自行编写,在我的开源代码中没有实现该功能。
NTP 时间同步
遥控与滑板首次连接或丢失连接再次连接时,会进行 ntp 时间同步,只有当时间已同步且延迟与丢包率正常时,才会开始正常控制功能,否则将一直执行时间同步操作直到信号良好。
注意 NTP 对时成功应当在解包前使用心跳包中的时间戳判断,因为如果使用一个专门协议判断,可能会导致该协议包丢包,导致系统错过该对时成功的信号。
NTP 原理介绍
在 NTP 对时过程中,有 4 个时间戳:
T1:客户端发送请求的时间(以客户端时钟为基准)。
T2:服务器收到请求的时间(以服务器时钟为基准)。
T3:服务器回复响应的时间(以服务器时钟为基准)。
T4:客户端收到响应的时间(以客户端时钟为基准)。
这四个时间戳构成了计算的基础。
计算往返延迟:延迟 = (T4 - T1) - (T3 - T2)
计算时间偏移(即客户端需要调整的量):时间偏移 = [(T2 - T1) + (T3 - T4)] / 2
最终,客户端会将自己的时间调整 时间偏移 量,从而与服务器同步。
代码实现
// NTP 时间同步
if (NTP_status == error) {
//softWatchDog_feed();
Log_add(LOG_NTP_ERROR, "NTP_status error");
Motor_Control(0);
while (NTP_status == error) {
softWatchDog_loopCheck();
LED_Control(LED_ERROR, flicker);
NTP_req = 1;
if (Serial_3_RxFlag == 1) {
Serial_3_RxFlag = 0;
RC_unpack((uint8_t *)Serial_3_RxPacket);
}
if (NTP_req != 1) {
// NTP 对时 请求发送
NTPtime_t sendPack;
sendPack.t1 = NTP_time;
sendPack.t2 = 0;
sendPack.t3 = 0;
sendPack.t4 = 0;
RC_send((uint8_t *)&sendPack, CMD_TIME_SYNCHRONOUS, sizeof(NTPtime_t));
NTP_req = 1;
}
writeFlash_loopcheck();
}
LED_Control(LED_ERROR, off);
uint8_t timestamp[4] = {0};
*((uint32_t*)timestamp) = NTP_time;
RC_send(timestamp, CMD_TIME_MOTOR_RPO, sizeof(uint32_t));
Log_add(LOG_NTP_SUCCESS, "NTP_status success");
}
void CMD_timeSynchronous(uint8_t *pack) {
if (NTP_status != normal) {
NTPtime_t *NTPpack = (NTPtime_t*) pack;
const int64_t t1 = (int64_t)NTPpack->t1;
const int64_t t2 = (int64_t)NTPpack->t2;
const int64_t t3 = (int64_t)NTPpack->t3;
const int64_t t4 = (int64_t)NTP_time;
const int32_t delay = (t4 - t1) - (t3 - t2);
const int32_t offset = ((t2 - t1) + (t3 - t4)) / 2;
NTPpack->t1 = NTP_time;
RC_send((uint8_t *)NTPpack, CMD_TIME_SYNCHRONOUS, sizeof(NTPtime_t));
// 延迟在 100ms 以下才接受数据
if (delay < 150 && delay > 0) {
static int64_t tempoffset[3];
static uint8_t tempcount = 0;
if ((NTP_time + offset) > 0) {
if (tempcount == 0 || offset != tempoffset[tempcount - 1]) {
tempoffset[tempcount ++] = offset;
}
}
if (tempcount >= 3) {
// 找最大值和最小值
int64_t max_val = tempoffset[0];
int64_t min_val = tempoffset[0];
for (int i = 1; i < 3; ++i) {
if (tempoffset[i] > max_val) max_val = tempoffset[i];
if (tempoffset[i] < min_val) min_val = tempoffset[i];
}
// 判断差值是否小于 50 满足则更新 NTP_time
if ((max_val - min_val) < 50) {
// 判断时间偏移量是否在合法范围
if (((tempoffset[2] + NTP_time) >= 0 || (uint32_t)(-tempoffset[2]) <= NTP_time) && NTP_status == error) {
// 误差小于 100 判断为成功对时
if ((((int64_t)NTP_time + tempoffset[2]) - (int64_t)NTPpack->t3) < 100 && (((int64_t)NTP_time + tempoffset[2]) - (int64_t)NTPpack->t3) > -50) {
__disable_irq();
NTP_time += tempoffset[2];
__enable_irq();
NTP_status = normal;
}
}
}
tempcount = 0;
memset(tempoffset, 0, sizeof(tempoffset));
}
}
}
softWatchDog_feed();
}
系统时间
使用了一个 1ms 的定时器进行计时,实现系统时间的计算。很多需要定时执行的操作均在这里通过标志位的方式统一管理,实现定时器资源的节省,并且使用起来也更加方便。类似于 hal 库中的 HAL_GetTick() 函数管理时间以及执行频率的方法。
需要注意的是,在软件定时器中,必须使用标志位的方式执行相关内容,否则可能阻塞定时器导致软件时间错误,影响整个系统的功能与稳定。
注意区分 APP_time 与 NTP_time 之间的区别。APP_time 自系统启动就开始走时,NTP_time 是根据遥控的时间对时的联网时间。
软件看门狗
在系统时间中,实现了一个软件看门狗的功能。这个软件看门狗主要负责检查是否按时收到了心跳包,以便判断当前模块是否离线。
每次收到心跳包均会进行一次喂狗,收到其他包不喂狗,这是为了确保控制信号在被其他数据包阻塞后,可以正确判断为离线。
心跳包
遥控板与电调板均会以特定频率发送心跳包,当一段时间无法收到心跳则判断为离线,执行离线的操作,比如切断动力。滑板的动力控制以及采样数据回传均使用心跳包传输。
心跳包的结构如下:
typedef struct {
float speed; // 速度
uint8_t status; // 状态
float battery; // 电量
float temperature; // 温度
} RC_heartbeat_t;
其中遥控下发的包中,speed 为设定的速度。电调板上发的 speed 为当前电机的时速。
状态参数使用了一个 uint8 变量的每一位,表示各个系统的报错状态。具体每一位的作用可以看遥控板的心跳包解包函数与 OLED 更新函数中的内容。
ADC 采样
在电调板中,使用了多个 ADC 通道,分别用于电池电压采样、NTC 温度采样、电机电流采样、预留接口外接。其中电机电流采样数据并未使用。
ADC 采用了 DMA 进行自动数据传输,无需浪费采样完成中断的时间。同时采样得到数据均会经过滤波器后使用,避免电机运行时电压波动以及信号串扰导致采样噪声过大。
Flash 数据存储
使用 flash 存储模块运行数据。电调板存储了行驶里程数据、累计开机时间。遥控模块存储了累计开机时间。
当距离上次写入 flash 时间超过 5min,系统进入准备写入 flash 模式,此时如果持续 20s 系统闲置,就会写入一次 flash。系统闲置的判断方式为当前电机实际速度与遥控控制速度均为 0 并保持一段时间没有变化。
需要注意写入 flash 频率不应过高,因为 flash 寿命有限,而且在写入 flash 前需要擦除整页,而擦除整页花费的时间较多,所以会严重阻塞代码运行。基本上每次写入 flash 时遥控信号均会短暂断连,这也是一定要等待系统闲置后再写入 flash 的原因。
//Flash 写入里程数据 使用数组区间:1-11
void Store_Mileage(uint16_t Plus_Meter) {
//uint16_t 最大 65535,uint32_t 最大 4,294,967,295
uint16_t Meter = 0;
uint16_t Kilometer = 0;
uint16_t KKilometer = 0;
// 存储 "Mi" -> 0x4D69
Store_Data[1] = ('M' << 8) | 'i';
// 存储 "le" -> 0x6C65
Store_Data[2] = ('l' << 8) | 'e';
// 存储 "ag" -> 0x6167
Store_Data[3] = ('a' << 8) | 'g';
// 存储 "e:" -> 0x653A
Store_Data[4] = ('e' << 8) | ':';
Meter = Store_Data[5];
Store_Data[6] = ('m' << 8) | '\0';
Kilometer = Store_Data[7];
Store_Data[8] = ('k' << 8) | 'm';
KKilometer = Store_Data[9];
Store_Data[10] = ('k' << 8) | 'k';
Store_Data[11] = ('m' << 8) | '\0';
// --- 进位逻辑 ---
uint32_t total_meters = (uint32_t)KKilometer * 1000000 + (uint32_t)Kilometer * 1000 + Meter;
total_meters += Plus_Meter;
// 重新计算并拆分
KKilometer = total_meters / 1000000;
uint32_t remaining = total_meters % 1000000;
Kilometer = remaining / 1000;
Meter = remaining % 1000;
Store_Data[5] = Meter;
Store_Data[7] = Kilometer;
Store_Data[9] = KKilometer;
}
void Store_Mileage_0(void) //清零总里程
{
Store_Data[5] = 0;
Store_Data[7] = 0;
Store_Data[9] = 0;
}
//写入运行时间 使用数组区间:12-21
void Store_runTime(uint16_t Plus_Minutes) {
Store_Data[12] = ('R' << 8) | 'u';
Store_Data[13] = ('n' << 8) | 'T';
Store_Data[14] = ('i' << 8) | 'm';
Store_Data[15] = ('e' << 8) | '\0';
// --- 读取旧的时间 ---
uint16_t current_thousand_hours = Store_Data[16];
uint16_t current_hours = Store_Data[17];
Store_Data[18] = ('h' << 8) | '\0';
uint16_t current_minutes = Store_Data[19];
Store_Data[20] = ('m' << 8) | 'i';
Store_Data[21] = ('n' << 8) | '\0';
if (Plus_Minutes != 0) {
// --- 将所有时间统一转换为'分钟'进行计算 ---
// 1. 计算小时部分对应的总分钟数
uint32_t current_total_minutes = ((uint32_t)current_thousand_hours * 1000 + current_hours) * 60;
// 2. 加上之前存储的分钟数
current_total_minutes += current_minutes;
// --- 累加新的分钟数 ---
uint32_t new_total_minutes = current_total_minutes + Plus_Minutes;
// --- 将总分钟数转换回'千小时,小时,分钟'的格式 ---
uint16_t new_thousand_hours = new_total_minutes / (1000 * 60);
uint16_t remaining_minutes_after_thousand = new_total_minutes % (1000 * 60);
uint16_t new_hours = remaining_minutes_after_thousand / 60;
uint16_t new_minutes = remaining_minutes_after_thousand % 60;
// --- 将新时间存回数组 ---
Store_Data[16] = new_thousand_hours;
Store_Data[17] = new_hours;
Store_Data[19] = new_minutes;
// 存储的是 0-59 的分钟数
}
}
串口通信
在系统中使用了两个串口,且均使用 DMA 自动收发数据,可以极大减少程序阻塞的风险。因为电机转速较高,需要高频处理霍尔产生的外部中断并控制电机。如果在串口与遥控模块通信过程中消耗过多时间,会导致电机运行速度变高时出现丢步的现象。
串口的收发均使用了队列实现异步传输。防止由于在某一时间点突然大量串口发送请求导致程序阻塞的问题。接收队列不是必须的,但是发送队列必须实现。可以通过在.h 文件中修改宏定义便捷的修改队列大小。由于我在代码中人为控制了串口发送频率,所以发送队列仅给了 10 位,且并未添加队列溢出标志位。如果在使用中发现串口数据丢失严重,可以尝试添加队列溢出标志位查看是否队列溢出导致的。
#ifndef __SERIAL_H_
#define __SERIAL_H_
#include <stdio.h>
#include <stdarg.h>
#define SERIAL_TX_BUFFER_SIZE 200
#define SERIAL_RX_BUFFER_SIZE 200
#define SERIAL_TX_QUEUE_SIZE 10 // 发送队列大小 最多缓存 10 个数据
extern char Serial_RxPacket[];
extern uint8_t Serial_RxFlag;
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SendString(char*String);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format, ...);
#endif
另外遥控模块串口波特率不能设为最高的 38400,只能设为 19200,因为若波特率太高,DMA 中断时,会将前几位数据丢失,数据包不完整,所以波特率不能过高。该问题理论上可以通过在中断标志位切换时增加 us 级 delay 解决,但是目前该问题并不会影响功能,故不解决。
需要注意的是,遥控模块有一个 AUX 引脚是用于判断模块是否空闲的,本来准备在串口发送时判断遥控模块是否空闲,若被占用,发送的数据也进入队列。但是经过实验,这样判断遥控模块是否空闲会使丢包更加严重,怀疑是遥控模块接收数据与发送数据时,均会在占用状态,但是其实模块接收数据时,是可以通过串口发送数据的,只是数据在遥控模块中会进入等待发送队列,这样就可以提高 MCU 串口传输效率,不需要在 MCU 的串口队列中等待,所以现在不使用 AUX 引脚判断遥控模块空闲,直接向模块发送数据。
连接电脑的串口功能可以自行修改,用于输出调试信息等。大家可以自行查看该串口的代码并自行修改。
log 系统
在软件中实现了一个 log 报错的记录以便调试,该 log 系统最多可以记录 100 条数据,更多的数据将会覆盖最早数据。log 包括添加记录的时间、错误码、错误信息。其中错误码是为了方便判断错误类型,错误信息最长 50 字节。
如果在调试过程中遇到难以复现的错误,可以使用该 log 系统在程序关键位置保存信息,然后使用串口在电脑上将信息打印出来查看故障原因。这样就不需要长时间连接电脑运行,便于调试。
static log_t Log[MAX_LOG_COUNT] = {0};
static uint16_t log_index = 0; // 当前日志索引
static uint16_t log_count = 0; // 当前日志数量
void Log_add(uint8_t error_number, const char *message) {
// 确保 message 不为 NULL
if (message == NULL) {
;
}
// 添加日志
Log[log_index].app_time = APP_time;
Log[log_index].num = error_number;
// 安全拷贝字符串,防止溢出
strncpy(Log[log_index].message, message, MAX_LOG_MESSAGE - 1);
Log[log_index].message[MAX_LOG_MESSAGE - 1] = '\0';
// 确保字符串终止
// 更新索引和计数
log_index = (log_index + 1) % MAX_LOG_COUNT;
if (log_count < MAX_LOG_COUNT) {
log_count++;
}
}
void Log_print(void) {
uint16_t start_index;
uint16_t i;
if (log_count == 0) {
Serial_Printf("No logs available.\r\n");
return;
}
// 计算起始索引(如果是循环缓冲区)
if (log_count < MAX_LOG_COUNT) {
start_index = 0;
} else {
start_index = log_index;
}
// 打印所有日志
for (i = 0; i < log_count; i++) {
uint16_t idx = (start_index + i) % MAX_LOG_COUNT;
Serial_Printf("[%u] Time: %u, Error: 0x%02X, Message: %s\r\n", i+1, Log[idx].app_time, Log[idx].num, Log[idx].message);
Delay_ms(10);
}
}
void Log_reset(void) {
Serial_Printf("Logs reset. Current saved logs:\r\n");
Log_print();
memset(Log, 0, sizeof(Log));
log_index = 0;
log_count = 0;
}
开机自检
当电调板上电时,会自动进行自检。通过 ADC 采样以及读取霍尔信号的方式,判断当前动力电池、电机霍尔信号、温度信号、电流信号是否正常,确保在系统启动正常。在自检通过后,会进行 NTP 对时,对时成功后才会进入正常控制模式。
// 开机自检
// 自检项目开关
#define SELFCHECK_TEMPERATURE 1
#define SELFCHECK_BATTERY 1
#define SELFCHECK_HALL 1
#define SELFCHECK_CURRENT 1
// return 0 = 自检成功 1 = 自检错误
uint8_t selfCheck(void) {
uint8_t check_count = 0;
if (SELFCHECK_TEMPERATURE) check_count++;
if (SELFCHECK_BATTERY) check_count++;
if (SELFCHECK_HALL) check_count++;
if (SELFCHECK_CURRENT) check_count++;
while (1) {
LED_Control(LED_ERROR, on);
LED_Control(LED_RED, on);
Serial_Rx_loopCheck();
uint8_t success_count = 0;
// 温度检测
if (SELFCHECK_TEMPERATURE) {
uint8_t temperatureSuccess_count = 0;
for (int8_t i = 10; i > 0; i--) {
uint16_t temperature = AD_Value[3];
if (temperature > TEMPERATURE_HIGH && temperature < TEMPERATURE_ERROR) {
temperatureSuccess_count ++;
} else {
temperatureSuccess_count = 0;
}
if (temperatureSuccess_count >= 5) {
success_count ++;
break;
}
}
}
// 电池电量
if (SELFCHECK_BATTERY) {
uint8_t batterySuccess_count = 0;
for (int8_t i = 10; i > 0; i--) {
float battery = adc_calculate_battery_voltage();
if (battery > 30.0f && battery < 45.0f) {
batterySuccess_count ++;
} else {
batterySuccess_count = 0;
}
if (batterySuccess_count >= 5) {
success_count ++;
break;
}
}
}
// 霍尔信号
if (SELFCHECK_HALL) {
if (HallSensor.Position == 1 || HallSensor.Position == 2 || HallSensor.Position == 3 || HallSensor.Position == 4 || HallSensor.Position == 5 || HallSensor.Position == 6) {
success_count ++;
}
}
// 电流采样
if (SELFCHECK_CURRENT) {
uint16_t current = AD_Value[0];
if (current != 0 && current < 5) {
success_count ++;
}
}
// 结算自检结果
if (success_count == check_count) {
LED_Control(LED_RED, off);
Log_add(LOG_NORMAL, "selfCheck Success.");
return 0;
}
static uint8_t logwriteflag_selfcheck = 0;
if (logwriteflag_selfcheck == 0) {
logwriteflag_selfcheck = 1;
Log_add(LOG_NORMAL, "selfCheck Error.");
}
}
}
OLED
在遥控板上实现了 OLED 显示相关代码。该代码使用了通用 OLED 驱动代码,在此基础上进行了简单修改。在使用时需要注意严格控制 OLED 更新频率,因为该 OLED 代码的 I2C 通信没有使用硬件外设,导致该通信时序会严重占用 CPU 时间,可以通过降低 OLED 更新频率来降低通信时序对 CPU 的时间占用。
三、机械结构
由于本人并非机械设计相关专业,所以该滑板结构可能并非最优,建议在此基础上进行一定的改进。
在设计时,需要注意滑板在使用时长期震动,所以内部电气部分一定要牢固固定,防止颠簸导致内部线材或器件互相摩擦损坏,尤其动力电池如果损坏非常危险。电调板附近要留有开口,方便调试以及利于遥控信号接收,因为碳板有微弱导电性,会明显阻隔信号传播。
转向架
转向架安装使用市售带有电机的滑板转向架。安装时需要注意转向架需要区分正反,否则转向会与实际相反。下方 M5 螺母可以使用一个套筒方便拧紧。
在市售转向架电机接口与电调板接口不同,需要自行焊接 mr30 公头连接至电调板。
框架
目前设计使用全碳板与直角连接件配合组装。可以在当前设计的基础上,增加各板材之间的开槽,实现简单的榫卯配合,在安装直角连接件时更加方便。
若需要改进,面板和侧板的厚度不建议减小。实测使用全碳板强度足够,估计使用亚克力板强度也能够满足体重较轻的人使用。

在框架全部组装完成后,才能支持人站在上面,因为单一块面板无法承受一个人的重量,在我的设计中,使用了面板下方的两个侧板增加面板的强度。当侧板没有安装牢固时,人站在滑板上极有可能导致面板折断。
装配
在装配时,由于直角连接件的公差一般较大,所以所有螺丝必须先简单挂上后,再统一一起拧紧,否则可能出现螺丝孔无法对齐的问题。
先将下方的电器盒部分组装完毕,然后再将其固定到面板下,否则部分螺丝无法拧紧。

滑板内部的 3D 打印件设计有些许不合理,电机线需要在分电板安装之前先穿过预留孔位,否则会由于插头大小大于预留孔导致电机线无法穿入。建议用户自行重新设计滑板内部的各部件限位。
滑板所有装配螺丝必须使用防松螺母或者使用螺丝胶。因为滑板在使用时不可避免的会有长时间的高频振动,螺丝极易松脱。
内部活动器件用胶固定,线材可以包裹毛毡胶布防止震动异响。固定用胶水不建议使用热熔胶,建议使用 704 硅橡胶,因为其有一定缓震效果且不易老化。
电气与防水
电调板与分电板要尤其注意需要用螺丝孔将 PCB 固定并架空,因为这两块板上均有动力电池的 42V 供电,PCB 背面的焊点如果接触到导体短路会产生危险。另外需要注意碳板并非完全绝缘,所以在使用时不能将碳板作为绝缘体而将 PCB 直接放置在碳板上通电。一定要使用螺丝孔将 PCB 悬空固定。

PCB 板用螺丝固定的方法:打印件中预留孔位,然后使用滚花螺母热镶嵌进打印件,然后 PCB 就可以方便的使用螺丝固定了。注意热镶嵌的时候需要确保滚花螺母垂直于平面并位于预留孔位中心。具体设计尺寸可见工程文件。
动力电池需要注意不能直接放置在滑板内,需要用打印件固定防止其移动,因为滑板内有螺丝,在使用过程中极有可能刮坏电池,产生严重的危险。

先将所有螺丝装配完毕确保能够正确组装后,可以使用 704 硅橡胶将所有板材缝隙填满,实现简单的防水防尘。在长期使用前必须做好防水,一方面是防止液体进入导致电器短路,另一方面是防止灰尘进入影响电调板的工作。所以即使不在积水路面使用也请做好防水措施。
目前工程中电调板和开关开口的盖板并没有具体设计,仅简单封口通过打胶实现密封。建议自行设计电调板的盖板以及开关的盖板。
四、测试
电调板首次上电
首次上电建议使用可调电源供电,监控供电电流是否正常,在有问题时也可以立刻切断电源。电调板支持 24V 供电,无需专门寻找 36V 输出的电源。
首次上电后,正常状态应该是 3.3V 电源指示 LED 正常点亮,用万用表测了 5V 和 12V 供电接口电压正常。注意 3.3V 电源指示灯使用了 36k 的限流电阻,该 LED 在正常时亮度较低,该现象是正常的。
首次上电成功后,下载遥控特殊固件代码测试。
遥控模块
使用特殊固件初始化遥控模块参数(详见遥控模块配置部分)。在下载特殊固件后,进入 debug 查看串口接收数据,如果收到了 5B 开头的一长串 hex 数据,说明遥控模块配置成功。
然后下载正常固件,观察电调板与遥控板是否能够建立连接,如果 PC13 的红灯一直闪烁,说明无法进行 NTP 对时,即无法建立连接。如果红灯常亮,重启系统再次尝试。遥控就绪状态应当是没有 LED 闪烁,且遥控板的 OLED 能够显示电调板的信息,同时可以控制电机旋转。
注意测试时要将滑板电机架空,防止由于遥控板硬件存在问题,导致采样得到的摇杆数据错误,在遥控连接成功后电机疯转。
电机驱动
使用功率计测量实时电压电流。当电流小于 1A 时,板子发热大概在 20-35 度左右。如果温度过高说明硬件需要排查故障。检测 PCB 发热建议使用热成像,最少也需要使用万用表的温度探头,将探头紧贴 MOS 管粘贴测量。同时也可以测试 NTC 热敏电阻运行是否正常。测试完成后,建议在 PCB 背面粘贴散热块,能够显著增加爬坡时的散热能力。MOS 管在长时间工作时,温度不能超过 80 度。
我使用了带风扇的 M2 固态硬盘散热器。自行选择散热块时注意尺寸即可。若尺寸较大,需要修改固定 PCB 的 3D 打印件结构,并考虑走线空间。
五、工程文件与 BOM 物料
工程文件部分内容不在此处介绍,大家可以移步开源文档链接或立创开源硬件平台。


