去年年底做一台老旧控制器的数据接入改造,设备还是armv7架构,内存总共64MB,原来跑着一套采集程序用的是轮询加阻塞队列,CPU常年占用30%以上,温度稍微高点系统就卡成幻灯片。换了好几个开源采集框架,要么交叉编译就卡死在依赖上,要么跑起来内存直接吃掉大半。最后实在没办法,我把采集逻辑全部重写,就做了一个叫colibri的轻量级组件——只保留采集、缓冲、分发这三件事,用事件驱动替代轮询,用无锁环形缓冲替代阻塞队列,整体二进制压到1.2MB,内存占用不到10MB,在64MB的板子上跑得干干净净。这篇文章就把colibri从架构设计、编译部署到实测数据、踩坑记录完整讲一遍,给正在纠结嵌入式场景数据采集方案的同行一个参考。
1. 为什么我在嵌入式场景里弃用轮询,转向事件驱动
1.1 几个真实痛点
先说那次改造里最直观的问题。原来的采集程序是这么写的:主线程用一个while循环,每隔10毫秒去读一次传感器寄存器,读到数据就塞进一个队列,再由另一个线程从队列里取出来通过网络发走。
while (1) { usleep(10 * 1000); for (int i = 0; i < channel_count; i++) { int value = read_sensor(i); queue_push(&q, value); } }这段代码乍看没什么毛病,但在嵌入式设备上它会带来三个很实际的问题。第一个是CPU空转严重,usleep并不精确,而且每次循环无论有没有新数据都要跑一遍读取逻辑,低频采集时纯属浪费;第二个是高频采集下反而会丢数,当传感器数据产生速度大于队列消费速度时,队列会越堆越长,内存占用随之上涨,最终触发丢帧逻辑;第三个是延迟不稳定,在多线程调度压力大的时候,消费线程可能长时间得不到执行,数据在队列里滞留几百毫秒,这对控制类场景完全不可接受。
我还碰到过一个很隐蔽的现象:在64MB内存的设备上,队列里积压个两万条数据,加上系统本身的开销,内存就逼近上限了。这时候Linux内核开始回收page cache,整个系统的IO延迟跟着抖一下,数据采集的延迟又进一步恶化。这就陷入了一个恶性循环。
1.2 colibri的核心设计取舍
colibri的设计思路是用事件驱动替代轮询。它的采集循环不再主动去读数据,而是监听文件描述符的可读事件——传感器驱动、串口、spidev、网络socket,这些都是Linux下的文件描述符,统一由epoll来管理。
struct epoll_event events[MAX_EVENTS]; int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { handle_readable(events[i].data.fd); } }这个改动带来的好处是本质性的:没有数据来的时候,epoll_wait会让CPU进入睡眠状态,也就是零空转;数据来的时候,内核会唤醒等待线程去处理。CPU占用从30%直接降到个位数,这种差距在资源紧张的设备上感受非常明显。
我还做了一个更贴合工程的取舍:colibri没有采用多线程加锁的模型,而是用了单线程事件循环加一个轻量工作线程池。单线程负责所有IO事件的处理,天然避免了锁竞争问题;只有在协议转换、数据序列化这类CPU密集型的操作上,才把任务分发给线程池。这样做的原因是嵌入式场景下IO事件频率通常没有高到必须多线程,而多线程引入的锁开销和调度不确定性反而会让延迟抖动变得不可控。
用个生活化的类比:轮询模式像是你每隔十分钟就去敲一次邻居的门问快递到了没有,不在乎门口有没有人,反正就是敲;事件驱动模式则是你给快递员留了电话,包裹一到他打给你,你再去取。前者浪费体力还容易错过人,后者虽然要装个电话机,但效率高得多,也不漏件。对嵌入式设备来说,这个电话机的成本也就是一个epoll实例的事,非常划算。
2. colibri的整体架构与关键技术决策
2.1 三层架构:采集层、缓冲层、分发层
colibri的整体架构被我拆成了三个边界清晰的层次。采集层负责对接各种数据源,包括SPI设备、I2C传感器、串口、GPIO、网络UDP包;缓冲层负责在采集层和分发层之间提供一个稳定、低延迟的数据暂存区;分发层负责把数据通过不同协议发出去,目前内置了MQTT、WebSocket、本地文件、标准输出这几种输出通道。
三层之间的数据流向是单向的:采集层把原始数据包写入缓冲层,分发层从缓冲层读走数据进行处理。每一层都只依赖下一层的接口,不跨层调用。比如采集层的代码完全不知道数据最终是发去MQTT还是写成文件,它只负责把一个数据包成功写入缓冲区就算完成任务。
2.2 为什么用环形缓冲区而不是队列
这是colibri设计里我认为最关键的一个决定。最初原型阶段我用的是动态数组加互斥锁的队列,测试下来发现两个问题:一是每次push/pop都要加锁,高频率下锁竞争的开销非常扎眼;二是频繁地分配和释放内存,会产生碎片,长期运行后系统可用内存越来越碎,可能导致大块内存分配失败。
环形缓冲区彻底规避了这两个问题。它的本质是一块固定大小的共享内存,通过两个指针分别管理写入位置和读取位置,数据写满后可以选择覆盖旧数据(overwrite模式)或者丢弃新数据(discard模式)。由于读写操作都是简单的指针移动,在单生产者单消费者的场景下,可以做到完全无锁。
typedef struct { uint8_t *buffer; size_t capacity; size_t head; // 写入位置 size_t tail; // 读取位置 } ring_buffer_t; bool ring_buffer_push(ring_buffer_t *rb, const uint8_t *data, size_t len) { if (rb->head - rb->tail >= rb->capacity) { return false; // 缓冲区已满 } size_t offset = rb->head % rb->capacity; size_t chunk = min(len, rb->capacity - offset); memcpy(rb->buffer + offset, data, chunk); memcpy(rb->buffer, data + chunk, len - chunk); rb->head += len; return true; }这段代码里有个容易被忽略的细节:环形缓冲区的内存访问涉及两次memcpy,因为数据可能跨越缓冲区末尾的边界。很多第一次实现环形缓冲区的人会漏掉这个绕回逻辑,导致写入越界。colibri里对这块做了严格的边界检查,并且在生产代码里加了assert,一旦发现越界直接abort,不让错误静默传递。
2.3 配置体系:用TOML而不是YAML或JSON
另一个让我觉得选对了的方案是配置格式。colibri的配置文件采用了TOML格式而非YAML或JSON。理由有三:第一,TOML的语法足够简单,不需要像YAML那样担心缩进问题导致解析歧义;第二,TOML原生支持注释,可以在配置文件里写上详细的说明文字;第三,解析器的代码量和内存占用比YAML解析器小得多,在嵌入式环境里设置合理。
实际部署的时候配置文件长这样:
[capture] driver = "spidev" device = "/dev/spidev0.0" sample_rate = 1000 channels = [0, 1, 2, 3] [buffer] capacity = 4096 mode = "discard" [distribute] protocol = "mqtt" topic = "sensors/data" host = "192.168.1.55" port = 1883配置文件的加载逻辑也做了严格保护:解析出错时colibri不会静默退回到默认配置,而是打印清晰的错误信息并直接退出。这样做的原因是嵌入式设备往往无人值守,如果配置写错了程序还在跑,很可能采集了半天的错误数据,等发现问题已经晚了。
3. 编译部署与典型接入流程
3.1 交叉编译要点
colibri的代码是用C语言写的,依赖只有libev(或者纯epoll接口,取决于编译宏)和一个极简的TOML解析器,没有其他外部库。这也意味着交叉编译非常简单,不需要去处理一堆递归依赖。
在ARM设备上交叉编译的步骤大概是这样的:
# 设置交叉编译工具链 export CC=arm-linux-gnueabihf-gcc export CFLAGS="-Os -march=armv7-a -ffunction-sections -fdata-sections" export LDFLAGS="-Wl,--gc-sections" # 编译 make clean make -j4 # 安装到临时目录,后续打包拷贝到设备 make DESTDIR=$PWD/_install install这里有几个细节值得说。-Os是优化二进制体积的编译选项,配合-ffunction-sections和-fdata-sections,可以让链接器通过--gc-sections把未被引用的函数和数据段全部丢弃。我实测过,打开这些选项后二进制体积从2.7MB降到了1.2MB,效果非常明显。
3.2 最小配置示例
部署到设备上的过程比我预想的还要顺。我先在树莓派上验证了完整功能,然后交叉编译出ARM版本,用scp拷贝到控制器,直接运行二进制,日志窗口就打印出了传感器数据。冷启动到开始输出数据的时间不到500毫秒,这个启动速度在产线重启场景下很关键。
一份能够立刻运行的配置不需要很复杂:
[capture] driver = "gpio" device = "/dev/gpiochip0" sample_rate = 10 [distribute] protocol = "stdout"capture驱动设为gpio,通过libgpiod读取几个按键或开关状态;distribute协议设为stdout,直接在终端打印。采集频率10Hz,每秒输出10行数据,非常适合用来验证整条链路是否通畅。
3.3 验证工具集
我还为colibri配了几个辅助工具,实际排查问题时帮了大忙。
- colibri_ctl:一个命令行控制程序,通过本地Unix socket向运行中的colibri发送控制指令,比如暂停采集、调整采样率、查询统计信息。
- colibri_bench:一个性能测试工具,模拟高频数据源,用来在没有真实传感器的情况下压测缓冲区分发层的吞吐能力。
- colibri_dump:直接把环形缓冲区里的原始数据以hex格式打印到终端,适合在采集链路出现异常时离线分析数据格式。
这些工具都遵循同一个原则:在设备上占用的资源极小,不会干扰主进程的运行。
4. 性能实测:资源占用、延迟与丢帧
4.1 测试环境
我搭建了一个相对规范的测试环境:树莓派3B+(四核A53,1GB内存,32GB存储)作为被测试设备,另有一台x86服务器作为MQTT broker和WebSocket客户端。树莓派的SD卡用的是普通Class 10,没有做特殊优化,这更贴近真实嵌入式设备的水平。
测试场景分成三组:GPIO按键采集(低频、数字量)、SPI ADC采集(中频、模拟量)、UDP数据包监听(高频、网络数据)。每组都同时开启MQTT输出和文件记录两种分发通道。
4.2 内存与CPU实测数据
整理一下实测数据:
| 采集场景 | 采样频率 | CPU占用 | 内存占用 | 延迟P99 | 丢帧率 |
|---|---|---|---|---|---|
| GPIO按键 | 10 Hz | 0.4% | 6.8 MB | 0.8 ms | 0% |
| SPI ADC | 1 kHz | 2.4% | 8.6 MB | 3.2 ms | <0.01% |
| SPI ADC | 5 kHz | 9.8% | 10.1 MB | 7.5 ms | <0.1% |
| UDP监听 | 10 kHz | 12.3% | 12.4 MB | 11.2 ms | <0.1% |
CPU占用通过top -d 1持续采样1小时取平均值,内存占用通过读取/proc/self/status中的VmRSS获得。这个数据在1kHz采样率下非常能说明问题:CPU占用只有2.4%,内存不到9MB,剩下的计算能力完全可以用来跑业务逻辑。
4.3 高并发输出下的表现
高频采集配合多路输出,是排查缓冲区瓶颈的有效手段。我把采样率调到了5kHz,同时开启MQTT、WebSocket和文件三路输出,观察内存曲线和延迟变化。
实测下来,内存占用稳定在10.1MB左右,没有明显增长趋势。延迟P99从3.2ms升高到7.5ms,但依然在可控范围内。文件输出在SD卡上偶尔会出现几十毫秒的写延迟,不过因为分发层是并发执行的,这个延迟没有回传到采集层,采集本身没有受到影响。由于缓冲区配了discard模式,极端情况下的处理策略就是丢弃新数据保住旧数据,并在统计信息里记录丢弃次数,方便事后判断是否要扩大缓冲区容量。
5. 我踩过的几个坑与排查思路
5.1 SPI设备读取时的内核缓冲区溢出
在实际跑SPI ADC采集的时候,我遇到了一个奇怪的现象:系统运行一段时间后,采集到的数据会出现周期性的大段空白,空白时长大约几毫秒。用colibri_dump查看环形缓冲区里的数据,发现在空白段之前的最后一个值是0xFFFF,像是硬件满量程输出。
排查链路是这样的:先怀疑ADC本身问题,用示波器看了SPI时钟线和数据线,波形正常;再怀疑spidev驱动,查看内核日志,出现了spi_master spi0: I/O error的报错。继续查,发现是因为我的采集程序处理数据的速度跟不上SPI外设产生数据的速度,导致spidev的内核缓冲区溢出,读出来的数据就丢了。
解决方法是双管齐下:一方面降低SPI时钟频率,让数据产生速率降下来;另一方面在用户态调整读取策略,每次read尽量多读数据,减少read次数。比如原来1000Hz采样率下每次读4字节,改为每次读64字节,把多次读合并成一次批量读,大幅降低系统调用开销。
5.2 环形缓冲区与共享内存的对齐问题
另外一个坑涉及到多核CPU下的性能问题。我把环形缓冲区放在共享内存里,便于多个进程之间交换数据。结果发现一旦采集线程和分发线程被调度到不同的CPU核心上,传输速率会莫名下降30%以上。
这个问题的根因是CPU缓存行的伪共享(false sharing)。两个线程操作同一个环形缓冲区对象,但一个读写head指针,一个读写tail指针,这两个指针如果落在同一个缓存行(64字节)内,每次修改都会导致对端CPU的缓存行失效,不得不从主存重新加载,性能自然下降。
解决办法很简单也很经典:在结构体定义里加上对齐宏,让head和tail分布在不同缓存行中。
typedef struct { uint8_t *buffer; size_t capacity; size_t head __attribute__((aligned(64))); size_t tail __attribute__((aligned(64))); } ring_buffer_t;改完以后在多核场景下的性能立刻恢复了。这是一个很容易被忽视的问题,尤其是单核设备上跑得好好的,一迁移到多核设备就出问题。
5.3 网络异常时的背压机制
第三个坑是MQTT broker挂掉之后带来的连锁反应。网络断开时,MQTT客户端会不断尝试重连,同时重连间隔呈指数退避,最长退避到30秒。但在每次重连失败之前,数据包会堆积在发送队列里,而发送队列的堆积又会通过分发层传导到环形缓冲区,最终把缓冲区填满。
colibri的解决思路是引入背压感知机制:在分发层内部维护一个发送窗口,当发送队列积压超过窗口阈值时,主动丢弃低优先级的数据,同时通过colibri_ctl上报丢帧统计。这样做的本质是承认在弱网环境下数据无法百分百送达,不如优先保证最新数据的实时性。
这个机制让我想起一个交通拥堵的例子:高速公路上堵车的时候,与其让所有的车都在匝道口排队等,不如在源头就限制一部分车辆进入,保证已经在主路上跑的车能畅通。对数据采集来说,旧数据的价值往往低于新数据,所以在网络受限时丢旧保新是合理策略。
6. 把colibri改造成适合自己业务的几点思路
6.1 插件化输出
colibri内置的输出协议可能不够用,尤其是接一些私有平台的时候。我给它设计了一个简单的插件机制:输出层定义好接口,业务方把新的协议实现编译成动态库,运行时在配置里指定库路径即可加载。
typedef struct distributor_ops { int (*init)(distributor_t *self); int (*dispatch)(distributor_t *self, const void *data, size_t len); void (*destroy)(distributor_t *self); } distributor_ops_t;只要实现这三个函数,就能注册一个新的分发插件。我实际接过的协议包括MQTT、WebSocket、CoAP、InfluxDB line protocol,每个插件的代码量都在150行左右,工作量不大,而且不用改动主进程。
6.2 时序数据库直写
对数据落库需求,我最推荐的方案是让colibri直接输出InfluxDB的line protocol格式,这样数据接收端可以直连InfluxDB或VictoriaMetrics写入序列数据,中间不需要再经过一个转换网关。
line protocol格式其实非常简单:
sensor_temp,device=controller_01,channel=0 value=23.56 1712563200000000000 sensor_temp,device=controller_01,channel=1 value=23.61 1712563200000000000tags放在逗号后,field放在空格后,时间戳单位是纳秒。colibri在采集时将原始字节流解析成结构化数据,再格式化成这个协议,通过UDP发到数据库端口。整条链路省掉了一个中间代理,延迟能降低到2毫秒以内。
6.3 边缘端的告警触发
最后一个拓宽思路的方向是在采集端直接做告警计算。很多人习惯把数据全部传到云端再判断阈值,但如果网络抖动或中断,告警就会被推迟甚至漏报。colibri可以在分发前内置一个简单的表达式引擎,每秒对缓冲区里的最新数据做一次阈值判断,触发条件满足时直接通过本地继电器或短信模块发送告警。
比如温度超过80度持续10秒,这个判断逻辑在采集端完成,优先级高于任何网络传输。我在实际部署中给一套传动设备加了温度告警功能,效果非常直接:不需要等云端返回结果,现场就能第一时间收到报警,配合断网模式也能照常工作。
写在最后的一点个人体会
把colibri从一个原型变成稳定运行的工具,我最大的感受是:嵌入式场景里的数据采集问题,核心往往不在算法也不在硬件,而是在系统工程的取舍。该用事件驱动的地方绝不用轮询,该丢弃旧数据的时候绝不恋战,该加对齐的时候不能图省事——这些细节单独拿出来都不难,难的是在一开始就按照正确的方向设计。如果你也在为内存紧张的老设备发愁,不妨先从小而精的采集组件入手,少即是多,轻即是快,这几条原则在资源受限的环境里永远不会过时。