回头再解释,先 mark 2020年4月28日。
2020年5月1日 今日有空,稍微更新一下,主要在这里备份一下相关的 code 和详细的问题分析和具体解释。
继着上次的 https://www.cnblogs.com/juwan/p/12389600.html 的内容,上次只定位了问题和简单解决了问题,调整了 Python 的 Code ,现在主要是旧 Code 不稳定,下载的文件容易出问题,容易因为二进制文件产生错误。
那么如何解决呢?
我回顾一下我的这 4 天的开发历程,第一天,基于旧接收框架重新整理 Code ,第二天准备到位开始测试,测试崩盘没有任何改造,镇定思痛,第三天重新上状态机架构,第四天大量测试,缝缝补补后符合预期执行,趋于完美。
先上逻辑分析仪捕获数据观察数据流,注意我压缩了很多写的过程,只交待重点,有理有据的代码让人信服,如果有漏洞也是细节的马虎,思路对了大体是不会有太大问题的。
在 HTTP 下载文件的时候,可以看到它接收数据是以 \r\n+IPD 开头的数据,如 '+IPD,3,1452:' or '+IPD,1452:' 两类,经过测试,确实是符合预期的返回指定长度的数据,但会在最后的结束的时候附带 CLOSED\r\n 数据表示传输结束。
这里我们注意到两个问题,以及旧代码为什么不能抵抗高速传输的异常数据所产生的错误,这在上次的分析就已经提到这个数据流的问题,我们先理清问题。
我们知道了传输的规则和传输的形式,例如 AT 指令 和 传输的数据 是混在同一条通道中的,这意味着二进制数据中一旦出现类似 AT 指令的数据必然导致后续的数据错乱,又或者是传输的缓冲区异常导致程序跑飞等等。
这些错误都在旧代码中体现了,这里不再细讲,直接开始编写新代码适用新的接口。
我们知道这个函数是可重入的,所以一开始在剥离基础框架的时候就要最大化的保留重入的接口,例如,接收数据完成后,将数据保留到一个缓冲区中,在下一次的重入的时候,取走足够数量的数据,如果不够,则继续接收数据,如下代码的设计。
// required data already in buf, just return data
size = Buffer_Size(&nic->buffer);
if (size >= out_buff_len) { // 请求的代码长度小于缓冲区的长度,直接返回数据
Buffer_Gets(&nic->buffer, (uint8_t *)out_buff, out_buff_len);
if (data_len) {
*data_len = out_buff_len;
}
size = size > out_buff_len ? out_buff_len : size;
frame_sum -= size; // 总体的缓冲区数据要对应减少,同时判断是否没有更多的数据了,如果为负数则报错。
// printk("Buffer_Size frame_sum %d size %d *data_len %d\n", frame_sum, size, *data_len);
if (frame_sum <= 0) {
Buffer_Clear(&nic->buffer);
if (*peer_closed) { //buffer empty, return EOF
return -2;
}
}
return out_buff_len;
}
围绕这个思路去,这个函数就形成一种非常适合 FSM 状态机的接口,同时必须要有 buffer 去保留上一次的状态与数据。
为此可以将其构建一种基础状态机模型,二话不说先上架构,保持 IDLE, IPD, DATA, EXIT 四种状态,本来有 CLOSED 状态的,后来想了一下还不到时候就移除了。
enum fsm_state { IDLE, IPD, DATA, EXIT } State = IDLE;
static uint8_t tmp_buf[1560 * 2] = {0}; // tmp_buf as queue 1560
uint32_t tmp_bak = 0, tmp_state = 0, tmp_len = 0, tmp_pos = 0;
mp_uint_t interrupt = 0, uart_recv_len = 0, res = 0;
while (State != EXIT) {
// wait any uart data
uart_recv_len = uart_rx_any(nic->uart_obj);
if (uart_recv_len > 0) {
if (tmp_len >= sizeof(tmp_buf)) { // lock across
// printk("lock across frame_len %d tmp_len %d\n", frame_len, tmp_len);
tmp_len = 0;
}
res = uart_stream->read(nic->uart_obj, tmp_buf + tmp_len, 1, &err);
// printk("%s | tmp_buf %s res %d err %d\n", __func__, tmp_buf, res, err);
if (res == 1 && err == 0) {
interrupt = mp_hal_ticks_ms();
// backup tmp_len to tmp_pos (tmp_pos - 1)
tmp_pos = tmp_len, tmp_len += 1; // buffer push
// printk("[%02X]", tmp_buf[tmp_pos]);
if (State == IDLE) {
continue;
}
if (State == IPD) {
continue;
}
if (State == DATA) {
// printk("%s | frame_len %d tmp_len %d tmp_buf[tmp_pos] %02X\n", __func__, frame_len, tmp_len, tmp_buf[tmp_pos]);
continue;
}
} else {
State = EXIT;
}
}
if (mp_hal_ticks_ms() - interrupt > timeout) {
break; // uart no return data to timeout break
}
if (*peer_closed) {
break; // disconnection
}
}
我们看到这个 FSM 的基础保证是,确保访问边界被控制住,防止程序指针异常访问导致跑飞(在我前一篇有所提及)。
if (tmp_len >= sizeof(tmp_buf)) { // lock across
// printk("lock across frame_len %d tmp_len %d\n", frame_len, tmp_len);
tmp_len = 0;
}
其次是超时机制,确保任何情况下都可以离开状态机。
if (mp_hal_ticks_ms() - interrupt > timeout) {
break; // uart no return data to timeout break
}
这样可以保障程序在任何场合下都不会陷入底层无法离开。
接下来就是当缓冲区有数据、数据接收正常、以及每次读取一字节成功的基础上,进行字符串的匹配和状态转移的分类处理。
// wait any uart data
uart_recv_len = uart_rx_any(nic->uart_obj);
if (uart_recv_len > 0) {
if (tmp_len >= sizeof(tmp_buf)) { // lock across
// printk("lock across frame_len %d tmp_len %d\n", frame_len, tmp_len);
tmp_len = 0;
}
res = uart_stream->read(nic->uart_obj, tmp_buf + tmp_len, 1, &err);
// printk("%s | tmp_buf %s res %d err %d\n", __func__, tmp_buf, res, err);
if (res == 1 && err == 0) {
interrupt = mp_hal_ticks_ms();
// backup tmp_len to tmp_pos (tmp_pos - 1)
tmp_pos = tmp_len, tmp_len += 1; // buffer push
// printk("[%02X]", tmp_buf[tmp_pos]);
if (State == IDLE) {
continue;
}
if (State == IPD) {
continue;
}
if (State == DATA) {
// printk("%s | frame_len %d tmp_len %d tmp_buf[tmp_pos] %02X\n", __func__, frame_len, tmp_len, tmp_buf[tmp_pos]);
continue;
}
} else {
State = EXIT;
}
}
这时候最后一个状态是用来离开的标记。
我们开始脑里模拟执行过程和假设,第一种状态就是空转(IDLE),基本上每个状态机都是从这里开始的。
在 IDLE 期间要做的事情就是清理无关的数据,如下代码,直到符合预期的怀疑对象出现为止。
if (State == IDLE) {
if (tmp_buf[tmp_pos] == '+') {
tmp_state = 1, tmp_bak = tmp_pos, State = IPD;
continue;
} else {
// printk("(%02X)", tmp_buf[tmp_pos]);
tmp_len -= 1; // clear don't need data, such as (0D)(0A)
continue;
}
}
如上代码,在空转状态下,遇到 '+' 出现后,会进行假设性的状态转移,如果不符合预期的数据会跌回 ILDE 状态,然后我们看看 IPD 状态的实现。
if (tmp_pos - tmp_bak > 12) { // Over the length of the '+IPD,3,1452:' or '+IPD,1452:'
tmp_state = 0, State = IDLE;
continue;
}
if (0 < tmp_state && tmp_state < 5) {
// printk("(%d, %02X) [%d, %02X]\n", tmp_pos, tmp_buf[tmp_pos], tmp_pos - tmp_bak, ("+IPD,")[tmp_pos - tmp_bak]);
if (tmp_buf[tmp_pos] == ("+IPD,")[tmp_pos - tmp_bak]) {
tmp_state += 1; // tmp_state 1 + "IPD," to tmp_state 5
} else {
tmp_state = 0, State = IDLE;
}
continue;
}
if (tmp_state == 5 && tmp_buf[tmp_pos] == ':')
{
tmp_state = 6, State = IDLE;
tmp_buf[tmp_pos + 1] = '\0'; // lock tmp_buf
// printk("%s | is `IPD` tmp_bak %d tmp_len %d command %s\n", __func__, tmp_bak, tmp_len, tmp_buf + tmp_bak);
char *index = strstr((char *)tmp_buf + tmp_bak + 5 /* 5 > '+IPD,' and `+IPD,325:` in tmp_buf */, ",");
int ret = 0, len = 0;
if (index) { // '+IPD,3,1452:'
ret = sscanf((char *)tmp_buf + tmp_bak, "+IPD,%hhd,%d:", &mux_id, &len);
if (ret != 2 || mux_id < 0 || mux_id > 4 || len <= 0) {
; // Misjudge or fail, return, or clean up later
} else {
tmp_len = tmp_bak, tmp_bak = 0; // Clean up the commands in the buffer and roll back the data
frame_len = len, State = DATA;
}
} else { // '+IPD,1452:'
ret = sscanf((char *)tmp_buf + tmp_bak, "+IPD,%d:", &len);
if (ret != 1 || len <= 0) {
; // Misjudge or fail, return, or clean up later
} else {
tmp_len = tmp_bak, tmp_bak = 0; // Clean up the commands in the buffer and roll back the data
frame_len = len, State = DATA;
}
}
continue;
}
由于这是第三次重构的代码,所以里面加了很多保护措施,但实际上这些措施不一定会遇到,它会随着各种高频传输调试中触发,代码要能够抵抗这些异常数据。
我们看到 if (tmp_buf[tmp_pos] == ("+IPD,")[tmp_pos - tmp_bak]) { 的循环表示的就是 strstr 的匹配,但这里的匹配我稍微偷懒了一下简写了代码。
if (0 < tmp_state && tmp_state < 5) {
// printk("(%d, %02X) [%d, %02X]\n", tmp_pos, tmp_buf[tmp_pos], tmp_pos - tmp_bak, ("+IPD,")[tmp_pos - tmp_bak]);
if (tmp_buf[tmp_pos] == ("+IPD,")[tmp_pos - tmp_bak]) {
tmp_state += 1; // tmp_state 1 + "IPD," to tmp_state 5
} else {
tmp_state = 0, State = IDLE;
}
continue;
}
因为整个环境没有基础容器的支持,例如队列,所以我只能通过代码中的索引去模拟队列执行,保障整个执行的有序状态,当匹配完成后,会从 IPD 进入 DATA 态,此时意味着之后的数据都被视为数据,长度以 IPD 指示的为准。
tmp_len = tmp_bak, tmp_bak = 0; // Clean up the commands in the buffer and roll back the data
frame_len = len, State = DATA;
进入 DATA 态的时候就要思考终止条件的状态了,有两种可能,一种是标识的长度数据走完了,这表示应该要转移状态到 ILDE 了,但期望从这个状态中发现终止条件。
if (frame_len < 0) {
if (frame_len == -1 && tmp_buf[tmp_pos] == 'C') { // wait "CLOSED\r\n"
frame_bak = frame_len;
continue;
}
if (tmp_state == 6 && tmp_buf[tmp_pos] == '\r') {
tmp_state = 7;
continue;
}
if (tmp_state == 7 && tmp_buf[tmp_pos] == '\n') {
if (frame_len == -2) { // match +IPD EOF (\r\n)
tmp_state = 0, State = IDLE;
// After receive complete, confirm the data is enough
size = Buffer_Size(&nic->buffer);
// printk("%s | size %d out_buff_len %d\n", __func__, size, out_buff_len);
if (size >= out_buff_len) { // data enough
// printk("%s | recv out_buff_len overflow\n", __func__);
State = EXIT;
}
} else if (frame_len == -8 && frame_len == -1) {
// Get "CLOSED\r\n"
peer_just_closed = *peer_closed = true;
frame_bak = 0, tmp_state = 0, State = EXIT;
} else {
tmp_state = 6;
}
continue;
}
// 存在异常,没有得到 \r\n 的匹配,并排除 CLOSED\r\n 的指令触发的可能性,意味着传输可能越界出错了 \r\n ,则立即回到空闲状态。
if (frame_len <= -1 && frame_bak != -1) {
// printk("%s | tmp_state %d frame_len %d tmp %02X\n", __func__, tmp_state, frame_len, tmp_buf[tmp_pos]);
State = IDLE;
continue;
}
}
如我所假设的两种状态 \r\n 和 CLOSED\r\n 这两类,但还有最终保障措施,假设无法符合我的预期的结尾,要尽快回归 ILDE 重新进入匹配,否则数据将一片混乱,这个只能保障 1 、 2 字节的误差,对于缓冲区溢出复写的情况无法抵抗。
到这里我们还要注意到很多地方可能会出错和溢出,比如有如下考虑:
上层 Python 接收数据的变量溢出,无法继续 read 更多,积压数据未处理。
原始串口缓冲区溢出后,循环缓冲复写。
内部状态机解析的数据缓冲长度(已经被保护了,但出错了会导致其中的一帧丢失)
状态机解析后的数据缓冲变量写入可能会失败,夹在 C 与 Python 之间交互的一层缓冲区,主要用来供应 socket 层的数据。
因此这里面的任何一个环境出错,都可能导致 HTTP 的原始下载数据出错,但所辛都可以完整下好不会出现指令与二进制数据混淆的情况了。
基于这个接收框架,还可以进一步的拓展出 多 socket 的通信 和 其他 AT 指令的通信接口,匹配状态和解析后分类到各自对应的容器即可。
旧接口函数抵抗异常的设计不够多,所以之后测试完整了后,可以基于新的接口继续发展新功能。
我的测试结果如下:
在串口波特率 921600(1M)约 92 kb/s 的下载速度下载 384kb kmodel 模型文件的过程。
若是不写入 SD 卡的基础上,完美接收完毕,耗时 8s 。
若是写入我的 256M 的路边野卡,完整下载时 10s,无任何损坏,但受到网络波动则会出现下载的文件中会存在失败的帧,下载过程的好坏参半。
在串口波特率 576000(5M)约 57 kb/s 的下载速度下载 5M kmodel 模型文件的过程。
写入SD卡的过程,下载 5M 文件无错且完整,较为稳定,少有失败的情况。
从这个测试的结果来看,将 SD 卡写入数据是一个影响很大的变数,要么将其读写分离加缓冲在其中,减少 SD 卡带来的负面影响,优先把 HTTP 的数据带回内存之中。
这和串口缓冲区有关,如现在假定的 10k 字节,而 921600 (92kb/s),意味着 read 数据要在 10k/x:100kb/s, x = 0.1s 内取走全部数据,否则 IO 不匹配,缓冲区会溢出,之后的数据都会被放弃从而形成接收数据的断层。
python 的 code 执行模型如下
while True:
tmp = wifi.read()
file.write(tmp)
所以再下一次的 wifi.read() 因符合下述读写分离模型,这只是伪代码。不用太计较。
# thread 0
while True:
tmp = wifi.read()
sleep(0.1)
# thread 1
while True:
file.write(tmp)
sleep(x)
这次我们必须在硬件的内存和 SD 卡的写入数据中做一个选择,至少保证 SD 写入速度应大于 100kb/s 从而保持 IO 的速率基本一致,减少下载文件的异常。
其他可能存在的问题,就比如有时候发送数据会请求失败,还不知道是哪个死角没考虑到,也许之后就会有人发现了吧