整定之前,先给固件长出人机界面【第7期】
第6期把控制算法框架跑通之后,我以为接下来就是纯粹的整定工作了,结果一开调就傻了眼。Kp、Ki、Kd这几个参数全躺在代码里,每改一次都要走一遍"改宏定义 → 编译 → 烧录 → 看串口打印 → 再改"的循环,状态变量还只能靠一串printf自己脑补波形。好在一晚上重复烧了三十多次固件之后,我决定止损——先把人机界面给固件长出来,再谈整定这件事。
这套界面不是指一定要有屏幕,而是让固件具备"可交互的状态入口":能让你随时看到内部变量、在线改参数、保存配置,甚至把实时波形导出去分析。对于一个正在调参的嵌入式系统来说,这个入口比什么都重要。这篇就把我在这套固件上完整设计和实现人机界面的过程、代码骨架和踩过的坑全部写出来,同时覆盖了刷固件、固件烧录、串口交互这类容易被忽略的底层细节,希望能帮到正在跟参数搏斗的人。
1. 先回答一个灵魂拷问:整定之前搞界面,是不是不务正业
1.1 调参最痛的不是算法,而是状态不可见
做控制类固件的人都懂,整定过程里最磨人的不是PID那三个系数怎么算,而是你看不见系统内部到底在干什么。传感器采回来的值、中间运算量、控制输出、误差变化率,这些用printf打在串口调试助手上,只能看到一行行数字滚动,滚完就没了。你想分析一下超调量、响应时间、稳态误差,对不起,只能靠眼睛盯屏幕,再手动复制几组数据到Excel里画图。
我第一次整定这个电机位置环的时候,Kp从50调到80都没什么大问题,但一超过85,系统就开始振荡。那种"参数微调一点,系统状态完全不一样"的感觉,没有可视化界面支撑,你根本不知道振荡是算法积分饱和、执行器饱和还是纯参数临界。为了看内部状态,我不得不在代码里临时插入一堆打印语句,编译烧录完反而把时序搞乱了——printf本身也是耗时操作,插在主循环里直接影响了控制周期。
1.2 刷固件式调参的恶性循环
改一个参数、烧一次固件、确认一轮现象,这个循环的代价远比想象中高。在我这个平台上,编译要十几秒,烧录要几秒,单片机复位、初始化外设、控制函数跑起来又得一阵子。表面上一次只要半分钟,但一次调参往往需要来回十几次甚至几十次,一整晚就耗在"改代码、等编译、等烧录"这三件事上,真正用来观察波形和分析数据的时间还没三分之一。
更关键的是,烧录式调参切断了调试的连续性。参数改了、程序重启了,你上一次调好的状态全部清零,所有中间过程都要重新走一遍。这个问题在状态依赖强的系统上特别致命,比如带积分项的控制回路,每次重启都要重新经历一段启动暂态。后来我忍无可忍,决定把"能够在不重刷固件的情况下完成任务"当成一个硬性需求:必须能在线看状态、必须能在线改参数、必须能保存配置。
1.3 重新定义"人机界面"这个词
很多人一听到"人机界面"就想到触摸屏、LCD屏幕、安卓系统,其实那是HMI的狭义定义。在固件工程里,人机界面是一个更宽泛的概念:它提供人与机器之间的交互通道,让操作者能读取系统状态、修改系统行为。串口命令行是界面,Web页面是界面,甚至一组带按键的OLED菜单也是界面。
我在这篇文章里采用的思路是:界面形态可以按平台灵活选择,但最核心的是先建立一个"结构化交互能力"——包括命令解析、参数映射、实时数据流、配置持久化这四个能力。这套骨架搭好以后,在单片机上跑串口CLI,在带网卡的平台上加Web面板,在有屏幕的板子上加菜单,都只是往框架里填业务而已。反过来,如果一上来就直接写屏幕驱动和菜单,后面换平台、扩功能都够你喝一壶的。
2. 形态选型:串口CLI、Web面板、OLED小屏怎么选
2.1 三种方案的横向对比
我用过的调试界面形态主要就三种:串口命令行、Web面板、OLED小屏菜单。它们各有各的适用场景,我也踩过"选错方案被折腾半天"的坑。先把对比列出来:
| 方案 | 硬件依赖 | 开发成本 | 实时可视化 | 在线改参 | 适用场景 |
|---|---|---|---|---|---|
| 串口CLI | 仅需UART,几乎全覆盖 | 低,纯C代码 | 中,文本波形 | 支持 | 所有单片机,调试基线 |
| Web面板 | 需网络模块,ESP32等 | 中高,需协议栈 | 高,图表/曲线 | 支持 | 带网络能力的固件,远程调试 |
| OLED小屏 | 需SPI/I2C屏幕 | 中,需驱动 | 中,数字/简易曲线 | 有限 | 现场无电脑,需要便携查看 |
从表格能看出来,串口CLI几乎零成本,是所有平台都能跑的底线方案。Web面板和OLED屏的优势场景各不相同,但都不能离开CLI这套核心框架独立存在——因为它们都是在处理同一个"交互需求",只是表现形式不同。
2.2 为什么我以串口CLI为主干
我的原则是:串口CLI作为固件人机界面的主干框架,其他形态按需叠加。原因有三个,都很实际:
第一,串口是单片机时代的万能接口。无论是什么型号的单片机,只要接一个USB转串口模块,就能获得完整的双向通信能力。不像屏幕要考虑驱动芯片、初始化时序、中文字库这些东西,串口在工程上几乎不占额外开销。
第二,命令行的表达力被严重低估。有人觉得命令行是上个时代的东西,但恰恰是这种"字符进、字符出"的模式在嵌入式环境里最好裁剪、最好调试、最好扩展。一个完整的CLI可能只有几百行C代码,就能支持"查询所有参数、修改单个参数、保存配置、导出数据"这些功能,这效率不低。
第三,脚本化和自动化特别方便。整定过程中,我经常需要连续改多次参数观察规律,CLI方式可以直接用上位机脚本批量下发指令。Web面板和OLED你能做到吗?要么改代码,要么手动点击,效率完全不在一个维度。
2.3 各平台的增强方案
CLI作为主干,不意味着拒绝增强。如果板子上有ESP32这种带WiFi的芯片,我会加一个轻量级Web Server,用浏览器实时看波形曲线,调试体验确实独一档。如果你用的是带OLED的板子,就做一个三层菜单:第一层选功能,第二层选参数,第三层调数值。菜单的实现不复杂,核心逻辑还是会回调到CLI那套参数表。
特别要提醒一句:Web面板和OLED菜单看起来"+1"就完成了,实际上还是要处理不少边界问题。像Web面板要处理TCP连接管理、HTTP解析、资源占用,OLED菜单要处理按键消抖、菜单状态机、屏幕刷新,都会挤占一定的主循环时间。所以如果你只是一次性调完参数就跑,串口CLI完全够用,别为了炫技把工程复杂度搭上去。
2.4 选型的原则是看使用场景,别被工具绑架
我把选型逻辑总结成一句话:人机界面的价值不在形式,而在路径。你要想清楚,自己调参的时候人坐在哪里、手边有什么设备、需要看哪些量,然后选择最短的那条路径。项目在实验室里调,电脑就在手边,串口CLI就是最优解;设备安装在现场,没有电脑只有手机,那WiFi Web面板才值得投入;连上位机都没法连接的场合,OLED屏就是唯一选择。
这个道理我是在做另一个项目时彻底想明白的。当时给一个手持设备固件加界面,本想上串口CLI,结果现场根本没有电脑,只能被迫写了一个小OLED菜单系统。虽然多画了几天时间,但用起来的顺畅程度远超预期。所以说,先别问"用什么界面",先问"你手上有什么"和"你要在哪里进行交互"。
3. 固件端CLI核心骨架:从串口字节到命令树
3.1 串口中断+环形缓冲区:别在中断里做业务
CLI的底层第一步,是把串口收到的字节可靠地存下来。很多新手会写这样的代码:在主循环里死等接收标志位,来了一个字节就处理一个字节。这在调试阶段可能看不出问题,但一旦控制算法跑起来、主循环被其他中断频繁打断,丢数据就是家常便饭。
我的做法是:串口用中断接收,把数据丢进一个环形缓冲区;主循环扫描这个缓冲区,按行取出完整命令再解析执行。中断里只做"把字节放入缓冲区"这一件事,任何字符串解析、逻辑判断、命令调用都不允许出现在中断上下文里。
#define RING_BUF_SIZE 256 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; ring_buf_t rx_ring; // 串口中断服务函数 void UART_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t data = USART_ReceiveData(USART1); uint16_t next = (rx_ring.head + 1) % RING_BUF_SIZE; // 缓冲区满时丢弃新数据,并保留旧数据 if (next != rx_ring.tail) { rx_ring.buf[rx_ring.head] = data; rx_ring.head = next; } } }环形缓冲区的核心逻辑就是head和tail两个指针。head表示下一个写入位置,tail表示下一个读取位置;当head追到tail时说明缓冲区满了,可以选择丢弃新数据或覆盖旧数据。我默认选择丢弃新数据,因为调试时序中,旧数据往往比新数据更有参考价值——这一点后面翻车部分会具体讲。
3.2 命令解析用状态机,别用scanf
很多做上位机的人习惯直接用scanf来解析命令,但在单片机固件里这绝对是个坑。scanf依赖堆和格式化解析,占用Flash和RAM不少,而且它对格式错误时的容错处理非常不好,命令错误一两次可能就直接进hardfault。我建议自己写一个轻量的命令状态机,或者用最朴素的strcmp逐一匹配。
我的解析思路分三层:先把从串口缓冲区里取出的字节累积成一个以换行符结尾的字符串;然后按空格拆分成参数数组;最后拿argv[0]和命令注册表里的命令名字符串逐个比对。整个过程不需要动态内存,也不需要堆,RAM占用非常稳定。
// 逐字节喂给行解析器,返回1表示收到完整一行 uint8_t line_buf[128]; uint8_t line_len = 0; uint8_t feed_char_to_line(uint8_t c) { if (c == '\n' || c == '\r') { if (line_len > 0) { line_buf[line_len] = '\0'; return 1; } return 0; } if (line_len < sizeof(line_buf) - 1) { line_buf[line_len++] = c; } return 0; } // 简单拆分:把字符串按空格拆成 argc/argv void split_args(char *s, int *argc, char *argv[], int max_args) { int n = 0; char *p = s; while (*p && n < max_args) { while (*p == ' ') p++; if (*p == '\0') break; argv[n++] = p; while (*p != ' ' && *p != '\0') p++; if (*p == ' ') { *p = '\0'; p++; } } *argc = n; }自己写解析器的好处是可控——你想支持多少种命令格式、什么错误提示,自己说了算。另外一定要用__attribute__((packed))之类的结构体对齐控制,避免在32位单片机上因为对齐问题踩到暗坑。
3.3 命令注册表:结构体数组+函数指针
命令解析的核心是一个"命令注册表":每一条命令就是结构体数组里的一个元素,包含命令名、帮助文本和对应的处理函数。新增命令的时候,往数组里加一个元素就行,不需要改解析逻辑。这就是把代码和数据分离的经典做法。
typedef struct { const char *name; const char *help; int (*handler)(int argc, char *argv[]); } cmd_entry_t; // 命令处理函数前向声明 static int cmd_help(int argc, char *argv[]); static int cmd_get(int argc, char *argv[]); static int cmd_set(int argc, char *argv[]); static int cmd_save(int argc, char *argv[]); static int cmd_feed(int argc, char *argv[]); static int cmd_ver(int argc, char *argv[]); const cmd_entry_t cmd_table[] = { {"help", "print command list", cmd_help}, {"get", "get param value, usage: get kp", cmd_get}, {"set", "set param value, usage: set kp 100", cmd_set}, {"save", "save params to flash", cmd_save}, {"feed", "start/stop realtime data, usage: feed on/off", cmd_feed}, {"ver", "show firmware version", cmd_ver}, }; const int cmd_count = sizeof(cmd_table) / sizeof(cmd_table[0]);在主循环的处理逻辑里,遍历这个表格即可。匹配到命令名就调用对应的handler函数,没匹配到就打印"unknown command"并提示help。这种注册表模式让我后期加功能变得非常舒服,增加一个命令几乎不会影响已有代码。
3.4 一个直接影响调试效率的细节:vcom和转义
串口CLI还有一个很容易被忽略的体验性问题:回改和退格。如果不做任何处理,命令行打错一个字符就只能回车重来。这个在大批量输入时非常折磨人。我做的方案是在收到0x08或者0x7F(退格/删除键)时,擦除行内最后一个字符,同时在串口上输出\b \b让终端光标回退并清掉字符。
另外,建议在CLI里也支持Tab补全。稍微大一点的公司做整定时,命令多到一定程度,Tab补全能极大节省输入时间。实现也不复杂:遍历命令注册表,找到所有以当前输入为前缀的命令,输出候选列表,用户回车确认。走完这个细节之后,我在调参时的输入速度几乎翻了一倍。
4. 整定最需要的是"看到波形",不是"看到数字"
4.1 波形数据强于数字文本的根本原因
当你在做电机位置环整定的时候,最想看到的是位置曲线和目标阶跃之间的关系:上升时间多少、超调多大、有没有振荡收敛、稳态误差是多少。这些东西如果只靠printf输出的数字文本去脑补,等于让一个画家闭着眼睛画画。哪怕你实时打印频率很高,比如每毫秒打印一组,文本的可读性也远低于一条曲线。
更直观的是,控制界的老工程师看一眼波形就能判断参数大概该往哪个方向调:响应太慢加Kp,稳态抖动加Kd,残余误差靠Ki。但如果数据只是滚动文本,这些特征很难被肉眼捕捉。所以在CLI能力之上,我还给固件加了一条"实时数据流"通道,专门用来推送波形数据给上位机绘图。
4.2 文本打印 vs 二进制帧:别让格式吃掉带宽
刚开始我打算用文本方式把数据推出来,比如一行输出"t=1000 target=1000 current=950 output=320",思路简单,上位机也容易解析。但问题是这个字符串太长了,每个采样点要占三四十字节,115200波特率下每毫秒一个点,带宽直接被吃穿,主循环还天天被打断。
后来我改成二进制帧:设计一个简单协议,把数据打包成固定结构的字节流,用帧头、类型、长度、数据和校验来封装。以这个项目为例,每个波形采样点只需要一个时间戳加上三四个浮点数,紧凑打包后不到20字节,同样波特率下能推送一倍多的采样率,而且数据对齐没有文本乱七八糟的问题。
// 二进制波形帧格式定义 // Byte 0: frame head 0xAA // Byte 1: frame head 0x55 // Byte 2: frame type, 0x01 = wave data // Byte 3: data length (N) // Byte 4 ... : payload // Byte 4+N: checksum (XOR of bytes 0~3+N-1) typedef struct { uint32_t timestamp_ms; float target; float current; float output; } wave_sample_t; // 在主循环中以固定周期调用 void wave_push_sample(wave_sample_t *s) { uint8_t payload[64]; uint8_t n = 0; payload[n++] = 0xAA; payload[n++] = 0x55; payload[n++] = 0x01; payload[n++] = sizeof(wave_sample_t); memcpy(&payload[n], s, sizeof(wave_sample_t)); n += sizeof(wave_sample_t); uint8_t xor_checksum = 0; for (uint8_t i = 0; i < n; i++) { xor_checksum ^= payload[i]; } payload[n++] = xor_checksum; // 假设有串口发送函数 uart_send_buffer(payload, n); }注意一点,浮点数据的结构体对齐在PLC和单片机上可能有差异,如果上位机是PC和单片机通信用,最好把结构体的字节对齐规则固定下来(比如#pragma pack(1)),或者干脆把浮点拆成4字节裸数据再打包,两边约定好大小端。我在STM32上发出来的是小端数据,上位机正好也是小端机器,但换平台就得重新考虑。
4.3 周期推送数据:别把波形输出塞进主循环
波形推送的调度一定不能放在控制中断里,也不能放在主循环正中间,最好的方式是在主循环的一段"空闲处理"调用,或者在定时器里设置一个独立的轻量级标志位。我的做法是把波形推送放在一个优先级很低的轮询里:每一轮主循环检测一次,如果距上次推送已经超过设定周期(默认10ms),就读取当前控制环的快照并推送一帧。
10毫秒看起来不密,但对于观察响应曲线已经足够了。如果采样太密,一则是串口带宽紧张,二则是上位机绘图会卡顿。整定过程里,上位机用一款开源的串口示波器工具直接解析这个二进制协议,实时画出位置响应曲线——这比自己在电脑上写一整套上位机要省太多精力。
做这个功能的时候有一个关键细节:波形推送不要用全局静态缓冲,而要在采样点产生时就拷贝一份快照,否则控制核心在采样瞬间正在写这个变量,推送线程读到一半的数据就会错乱。对于浮点数来说还好,如果你将来推结构体,一定要做快照拷贝,否则某些字段是新值某些字段是旧值,波形就会偶尔出现毛刺。
4.4 上位机侧的配合与"活数据"的价值
上位机串口示波器可以直接用,用法很简单:配置好COM口号和波特率,告诉它帧格式是自定义二进制,就能在电脑上看到类似示波器的实时曲线。这个能力给整定带来的直接变化是,我可以一边在CLI里输入命令修改Kp值,一边盯着波形看响应变化,整个过程完全不中断控制回路。
"活数据"的价值在整定过程中体现得淋漓尽致。原来烧录式调参每次重启,波形都是从零开始累积,状态还没稳下来又要重启。现在调参动作变成"数字进去、曲线动起来"的实时反馈,同一条时间轴上能看到多个参数变化带来的响应差异,很多参数规律一眼就能看穿。比如Kp从60加到70,曲线超调从10%涨到18%,这个过程15秒内就能完成验证,而不需要重新编译烧录。
5. 在线调参与掉电保存:整定不再需要重新编译
5.1 从"改代码"到"改参数":参数表驱动设计
有了CLI和波形,下一步最核心的突破就是:把代码里的变量变成可以通过CLI访问和修改的"命名参数"。
最直接的做法是为每一个参数写一个case分支,比如输入set kp 100就去改一个全局变量g_pid_kp。这种做法在参数少的时候也能用,但当你攒下来20个参数时,用例分支会越堆越长。而且get和set逻辑重复,还要处理单位、范围检查、类型转换,等于写两遍。
我采用了参数表驱动设计:把每个参数的信息(名字、数据类型、内存地址、上下限、是否需要保存)集中放到一张表里,get/set命令根据参数名查表,得到内存地址和约束条件后统一处理。这样再新增一个参数,只需要往表里加一行,命令侧零改动。
typedef enum { PARAM_KP = 0, PARAM_KI, PARAM_KD, PARAM_FEED_PERIOD_MS, PARAM_MAX_SPEED, PARAM_COUNT } param_id_t; typedef enum { PARAM_TYPE_INT32, PARAM_TYPE_FLOAT, } param_type_t; typedef struct { const char *name; param_type_t type; void *addr; float min; float max; uint8_t need_save; } param_entry_t; // 全局控制参数 float g_pid_kp = 50.0f; float g_pid_ki = 0.1f; float g_pid_kd = 0.5f; int32_t g_feed_period_ms = 10; int32_t g_max_speed = 2000; const param_entry_t param_table[PARAM_COUNT] = { {"kp", PARAM_TYPE_FLOAT, &g_pid_kp, 0.0f, 500.0f, 1}, {"ki", PARAM_TYPE_FLOAT, &g_pid_ki, 0.0f, 100.0f, 1}, {"kd", PARAM_TYPE_FLOAT, &g_pid_kd, 0.0f, 100.0f, 1}, {"feed_period_ms", PARAM_TYPE_INT32, &g_feed_period_ms, 1, 1000, 1}, {"max_speed", PARAM_TYPE_INT32, &g_max_speed, 100, 3000, 1}, };5.2 get和set的统一实现
有了参数表,get和set命令的实现逻辑就非常清晰了。两者都要先在参数表里查名字,找到索引之后根据type字段走对应的类型转换路径。set命令在写入前还要做范围和边界检查,一旦超限直接打印错误提示,不像以前那样凭手感改代码。
static int cmd_set(int argc, char *argv[]) { if (argc != 3) { printf("usage: set <param> <value>\r\n"); return -1; } const char *name = argv[1]; int id = param_find_id(name); if (id < 0) { printf("unknown param: %s\r\n", name); return -1; } float val; int ret = parse_float(argv[2], &val); if (ret != 0) { printf("invalid value\r\n"); return -1; } const param_entry_t *pe = ¶m_table[id]; if (val < pe->min || val > pe->max) { printf("value out of range [%.1f, %.1f]\r\n", pe->min, pe->max); return -1; } if (pe->type == PARAM_TYPE_FLOAT) { *(float *)(pe->addr) = val; } else { *(int32_t *)(pe->addr) = (int32_t)val; } printf("set %s = ", name); if (pe->type == PARAM_TYPE_FLOAT) { printf("%.3f\r\n", *(float *)(pe->addr)); } else { printf("%d\r\n", *(int32_t *)(pe->addr)); } return 0; }这里有个工程细节:参数类型如果是float,用int32类型的存储和比较在某些编译环境下会出现意外的类型转换,所以一定要严格按type字段走分支,别图省事统一按浮点处理又按整数写回——我因为这个吃过一次亏,后面翻车部分会说。
在线改参数能力对整定效率的提升是颠覆性的。以前我调Kp要编译烧录一次,现在敲一行set kp 75回车,看波形变化,再敲一行set kp 68回车,十五秒内能完成三轮比较。整定从"批处理模式"变成了"交互式探索模式",调试体验完全上了一个台阶——我甚至有时会故意设置一个明显过大的Kp,让系统振荡两秒,观察振荡频率和收敛速度,再即时调回来。
5.3 掉电保存:Flash擦写寿命与磨损均衡
参数调好之后,最怕的是断电重启又回到出厂默认值。如果不想每次上电都重新敲一遍参数,就必须把参数保存到非易失存储里。
单片机上最常见的非易失区是Flash,但Flash有两个硬约束:第一,擦除和写入必须按块/页进行,不能像RAM一样任意改;第二,Flash擦写寿命有限,ST的芯片写寿命一般是1万到10万次。这意味着不能一改参数就写Flash,也不能每次启动都从头擦一遍。
我的做法是:CLI里提供一个专门的save命令,用户确认参数调好之后再手动触发保存。初始化时读取参数区内容,校验正确就加载进RAM;如果校验失败或者标志无效,就用代码里的默认参数。这既避免了频繁写Flash,也让"保存"这个动作有了明确的语义。
5.4 双备份存储与掉电安全的兜底方案
Flash写入过程中发生掉电,是很隐蔽的灾难。你无法保证擦除或写入动作在断电瞬间全部完成——结果就是参数区可能变成"半新半旧"或者干脆全是0xFF。如果只有一份副本,系统以后每次启动都找不到有效参数,只能回退默认值,等于你之前调好的参数全部白费。
为了兜底,我设计了一套双备份存储方案:参数区划分为A区和B区,每次保存只写当前"空闲"的区,写完更新头部的序列号。启动时对比两个区的序列号,取大者加载。如果某个区校验失败(掉电导致半写),就自动降级到另一个区加载,并打印提示"param backup restored"。这个方案在掉电安全场景下非常实用,虽然代码量多了一点点,但换来的是"任何意外掉电都不会丢失参数"的信心。
双备份还有一个好处:你可以在运行过程中故意"破坏"一次参数区,验证固件能不能正确降级恢复。我自己就专门测试过在Flash写入瞬间拉掉电源,重启之后固件仍然能正常工作,只是日志里多了一条恢复提示。这个测试做完以后,我对参数保存这块才算真正放心。
6. 实测中的翻车现场与最终体会
6.1 翻车1:环形缓冲区溢出,关键时刻丢波形数据
第一个翻车发生在刚做好波形推送的时候。我把串口中断里的环形缓冲区大小设为128字节,结果在波形推送高负载时,上位机曲线经常出现缺口,每次正好在波形变化最剧烈的阶段丢数据。排查了很久才发现,UART中断的优先级不够高,当控制定时器中断来临时,UART中断会被抢占,如果此时串口还在持续收数据,128字节的缓冲区很快就满了,后来的数据就只能直接丢弃。
这个问题的本质是:环形缓冲区只是"延迟处理"的缓冲,不是"无限吸收"的缓冲。当数据生产速度快于消费速度时,缓冲区终究会满。我的解决方案有三个动作:把环形缓冲区从128字节扩大到了512字节;把UART中断优先级提上去一级;同时把波形推送周期从5ms放宽到10ms,降低串口数据吞吐压力。三者叠加以后,丢包率直接降到了可忽略的程度。
6.2 翻车2:printf重定向没搞对,整条数据流都是乱码
CLI刚开始调试的那天晚上,我输入help命令,串口输出全是一堆乱码和莫名奇妙的字符。第一反应是波特率不对,换了9600、57600、115200都不行,最后才发现是printf底层重定向没有正确处理浮点支持。我的编译工具链在默认配置下不支持float格式的printf输出,导致printf("%.3f\r\n", value)这种代码输出的是垃圾数据。
这个问题很典型。在GCC ARM工具链下,printf默认版本可能不带浮点支持,你需要使用-u _printf_float链接选项,或者换成更轻量的printf实现。如果你的CLI依赖浮点格式化输出,选型时最好先确认工具链的printf是否支持%f,否则等到调试现场再查这问题,特别费时间。
6.3 翻车3:Flash写入时掉电,参数表彻底废了
前面提到的双备份存储方案,并不是一开始就有的。第一版只用了单区存储,结果有一次我正在调参数,电脑断电同时把USB串口和板子也断掉了,重新上电后发现所有参数恢复成了默认值。打开调试日志才发现,参数区的标志位变成了一个非法值,整个参数区被视为无效,固件自动用默认参数启动了。
这次事故直接推动我做了双备份方案。后来我在测试中验证,双备份在意外掉电场景下确实能恢复出上一次完整参数。如果你在做的是量产设备而不是开发板调试,这个坑很可能让你在客户现场被逼疯,所以从一开始最好就设计成双备份,别等事故发生了再补。
6.4 翻车4:参数表索引越界,一查就死机
还有一次特别诡异的bug:用get命令查询某个参数时,单片机会不定期hardfault。定位了半天,发现是参数表的数组边界没有处理好——输入get abc这种不存在的参数名时,参数查找函数返回了-1,但我后续代码没有判断负数就往param_table[-1]那里取数据,自然就访问到了非法地址。
这个问题的根因是"防御式编程"不够充分。我的建议是,凡是数组索引和查找结果,都要在取值之前做边界判断。一次访问越界导致hardfault,在调试台上可能半小时才定位到,但在一行代码上提前预防只需几秒钟。现在我的命令行里就算敲了一堆乱码,也不会再死机了。
6.5 关于"整定之前先做界面"的最终体会
整套界面系统搭完之后,我再回头整定电机参数,效率和体验完全是两个世界。这个项目让我真正理解了一句话:先做好工具,再去做事情。人机界面不是"整定的周边配套",它就是整定本身的效率倍增器。
我复盘了一遍选择,给准备入坑的同行提三条建议:第一,别一开始追求大而全的图形界面,先把串口CLI和二进制波形数据流跑通,这套能力已经能覆盖大部分调参场景;第二,参数表驱动设计一定要在一开始就定好,不然后面补会牵扯太多代码;第三,掉电保存一定要用双备份加校验,这几乎是量产级嵌入式软件的底线要求。
如果你正在为自己的固件做类似的事情,祝你整定顺利。这期暂时先聊到这里,后面有机会再分享CLI之上如何用Web面板把整定曲线做得更直观,以及命令历史、日志系统这类提升体验的小设计。