跳转到内容
新建笔记

采集数据管道:容量、线程与延迟验证

采集程序的实时性要落实到明确的截止时间和可测量的数据完整性。缓存能吸收短时抖动,却不能补救长期处理速度不足;提高调度优先级也不能替代驱动、线程和队列设计。

原笔记针对 balance_app 提出了 Qt 双缓冲、Linux FIFO 调度和 STM32/Linux 硬件抽象。本页保留这些设计目标,修正原缓冲区重放旧数据的问题,并提供有明确容量、溢出和退出语义的示例。示例不代表当前项目已部署的采集链路;现有工程实现见AD7768 XDMA 采集 SDK。

先明确采集、处理和显示的期限

跳转到“先明确采集、处理和显示的期限”

假设单通道采样率为 fsf_s,每个数据块含 NN 个样本,则连续块到达间隔为:

Tb=Nfs.T_b=\frac{N}{f_s}.

若处理一块平均需要 TpT_p,长期稳定运行至少要求平均处理能力跟上输入;当 Tp>TbT_p>T_b 持续成立时,任何有限缓存最终都会耗尽。即使平均值足够,偶发的调度延迟、锁等待和写盘停顿仍会造成积压。

深度为 KK 的空队列最多暂存 KK 块,在暂时完全不能消费时,对应约 KTbKT_b 的输入时间。这个估算没有包含正在采集和处理中的缓冲,也不是最坏延迟保证。例如 fs=25.6 kHzf_s=25.6\,\mathrm{kHz}、N=256N=256 时,块周期为 10 ms;8 块队列只提供约 80 ms 的暂存空间。这里的数字用于说明计算方法,并非项目配置。

采样时钟由硬件产生,GUI 刷新按交互需求限频,算法与落盘分别记录耗时。不要把界面每秒刷新次数当成采样连续性证据。

原逻辑对 A 追加新数据,读时交换 A/B,然后按值返回 B,但从未清空回收缓冲区。下面的调用顺序即可复现:

步骤写入读到读完后 A 中残留
第一次11空
第二次221
第三次31, 32

因此,仅加互斥锁不会修复数据生命周期问题。原程序在 Qt 6.10.2 上按此顺序运行,确认第三次读回了已消费的 1。

临时修复可以采用“锁内将待处理数据交换到新的局部容器并返回”的方式,让共享待处理容器回到空状态。但如果生产方不断追加,仍存在无限增长、内存分配和消费者处理不及的问题。进一步设计应明确每个数据块的所有者及队列满时的行为。

下面的 C++17 示例采用固定大小块和 8 个槽。块进入队列后,其内容由队列保存一份;消费者取出后获得自己的副本,不会引用已经复用的槽。

tryPush() 在容量满时返回 full,不会覆盖已接受的数据。它只是不等待空槽,仍会等待互斥锁,所以不是无锁、无等待或硬实时实现。waitPop() 在空队列上等待,关闭后排空已经接受的块,最终返回 false。

#pragma once
#include <array>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <mutex>
struct SampleBlock {
std::uint64_t sequence = 0;
std::size_t count = 0;
std::array<double, 256> samples{};
};
enum class PushResult { accepted, full, closed, invalid };
class BlockQueue {
public:
static constexpr std::size_t capacity = 8;
PushResult tryPush(const SampleBlock& block) {
if (block.count == 0 || block.count > block.samples.size())
return PushResult::invalid;
std::unique_lock<std::mutex> lock(mutex_);
if (closed_) return PushResult::closed;
if (size_ == capacity) {
++fullRejections_;
return PushResult::full;
}
blocks_[write_] = block;
write_ = (write_ + 1) % capacity;
++size_;
lock.unlock();
available_.notify_one();
return PushResult::accepted;
}
// Returns false only after close() and draining all accepted blocks.
bool waitPop(SampleBlock& block) {
std::unique_lock<std::mutex> lock(mutex_);
available_.wait(lock, [this] { return closed_ || size_ != 0; });
if (size_ == 0) return false;
block = blocks_[read_];
read_ = (read_ + 1) % capacity;
--size_;
return true;
}
void close() {
{
std::lock_guard<std::mutex> lock(mutex_);
closed_ = true;
}
available_.notify_all();
}
std::uint64_t fullRejections() const {
std::lock_guard<std::mutex> lock(mutex_);
return fullRejections_;
}
private:
std::array<SampleBlock, capacity> blocks_{};
std::size_t read_ = 0, write_ = 0, size_ = 0;
std::uint64_t fullRejections_ = 0;
bool closed_ = false;
mutable std::mutex mutex_;
std::condition_variable available_;
};

生产方必须处理 accepted/full/closed/invalid 四种结果。fullRejections() 统计因容量满而被拒的调用次数;如果调用者重试同一块,它就不能直接作为“丢失样本数”。真正的数据丢失要通过设备序号、样本计数或协议中的丢块标记识别。

可选的满队列策略要按数据用途确定:

用途可选策略必须明确的代价
完整记录、定量分析停止本次采集并报告溢出,或采用设备确实支持的背压不能静默拼接缺样数据
实时预览丢弃过时显示块,保留较新快照显示数据与原始记录必须分开
可以暂停的生产源等待空间或降低生产速率真实 ADC 往往不能任意暂停;硬件 FIFO 也有容量

示例选择“拒绝新块”,但没有替应用决定是否重试、停止或丢弃。处理线程应在队列锁之外执行 FFT、滤波、写盘和日志。退出时先停止或取消硬件读取,保证生产线程结束,再关闭队列,等待消费者排空并退出,最后销毁对象;不能在还有线程等待时析构队列。

让采集或处理线程生成适合显示的结果快照,再通过排队连接交给 GUI 线程。QWidget 只能在主线程操作;QTimer、网络对象等还受所属线程和事件循环约束。不要把 QThread 对象所在的线程误认为 run() 执行的工作线程。Qt 线程与 QObject 文档

GUI 可以按例如 20~50 ms 的显示周期取最新快照,具体数值由交互需求测定。避免为每个样本发送一个排队信号:即使前面的队列有容量限制,后面的事件队列仍可能积压。采集线程发送的是拥有明确生命周期的数据,不是很快就会被 DMA 或下一次读取覆盖的裸指针。

调度优先级的作用与局限

跳转到“调度优先级的作用与局限”

旧记录中的 sudo chrt -f 99 ./balance_app 表示按 SCHED_FIFO 策略、优先级 99 启动程序,不是实时性验收方案。FIFO 线程没有普通轮转时间片;高优先级线程持续可运行时,可能阻碍较低优先级线程推进。Linux 调度策略实际作用于线程,还要检查权限、资源限制、内核配置与服务环境。Linux sched(7)、chrt(1)

应先使用普通调度测出瓶颈。确需实时调度时,只对有确定工作量、会正常阻塞等待的采集线程设定经过测试的优先级,避免把整个 GUI、磁盘写入和日志链路一起提升到最高级。线程绑核、锁页和实时内核是否必要,也应由延迟预算与实际测量决定。

Linux 还可能对实时线程组施加运行预算;检查目标系统的配置,不要为追求速度直接取消预算限制。相关设置与内核版本、cgroup 使用方式有关。内核实时组调度说明

跨平台硬件抽象的边界

跳转到“跨平台硬件抽象的边界”

原接口返回 QVector<double>,这适合使用 Qt 的应用层,不等于普通 STM32 裸机工程也能直接实现同一个接口。应先确定要复用的是算法、设备协议还是应用服务,再选择依赖层级。

跨平台边界可以使用固定容量的调用方缓冲、明确的读取状态和实际返回长度。例如:

#include <cstddef>
#include <cstdint>
enum class ReadStatus { data, timeout, stopped, error };
class SensorSource {
public:
virtual ~SensorSource() = default;
// count 必须在所有返回路径赋值;data 时 0 < count <= capacity。
// timeoutMs 的时基、取消方法和并发调用约束由具体实现定义。
virtual ReadStatus read(double* output, std::size_t capacity,
std::size_t& count, std::uint32_t timeoutMs) = 0;
virtual bool setOutput(std::uint8_t pin, bool state) = 0;
};

这只是接口契约,不是 STM32/Linux 驱动实现。MCU 上还要处理 DMA 缓冲、缓存一致性、中断与任务通知;Linux 端要处理设备文件、协议和取消阻塞读取。平台构建选择不同实现,公共算法不依赖 Qt 容器;Qt 适配器再把结果转换成界面所需数据。若两个目标并不共享 C++ 运行环境,使用 C 接口或消息协议可能更合适。

本次在主机环境编译并运行了示例:验证容量边界、拒绝溢出、先进先出、关闭后拒绝写入并排空,以及两个线程连续交换 10000 块时的序号和全部样本内容。另用 Qt 6.10.2 复现了原缓存重放问题。这些检查说明示例的队列语义,不代表设备实时性已经通过验收。

实机记录应至少包含:输入采样率与块大小、设备序号连续性、队列最大占用、溢出次数、每阶段时间戳、处理耗时分布和最大值、CPU/内存/磁盘负载,以及开始、停止、重连时的状态。对声明的最坏负载做长时间测试,观察到的最大延迟仍只对该测试条件负责;不能从一次平均耗时推出硬实时保证。