【实战指南】轻松自研嵌入式日志框架,6大功能亮点一文读懂
[TOC]
引言
日志系统虽非项目直接功能,却是开发者背后的强大辅助。优秀的日志设计如同给程序安装了北斗定位,让问题排查变得直观快捷,极大提升开发效率与项目维护体验。本文旨在深入探讨并详细记载自主研发日志框架的具体技术和实施策略。
概述
日志框架作为一种普遍应用于软件开发领域的关键工具,已发展出众多成熟案例,诸如Android平台的logcat
、glog
、Log4cpp
等。汲取这些成熟的框架的经验,本篇主要从需求分析、设计方案、实现细节和难点、测试、总结部分记录自研嵌入式框架的细节。
需求分析
在使用者的角度,对于日志功能的需求主要概括如下:
- 日志分级管理
实现包括DEBUG
、INFO
、WARNING
、ERROR
在内的多级别日志输出接口,并允许用户灵活配置和动态切换日志输出级别阈值。 - 异步处理与并发安全性
系统应具备异步日志记录能力,确保在多线程或并发环境下,日志信息记录的完整性及正确性,且内部实现需保证线程安全。 - 详尽上下文信息记录
- 日志条目应包含精确的时间戳、进程ID、代码行号以及模块标识等关键上下文信息,以便于进行问题定位和性能诊断。
- 各模块在使用日志接口时,传入对应的模块标识,方便调试过滤关键日志。
- 滚动日志归档策略
- 实时存储与文件滚动:日志应即时被写入到本地文件,确保信息的完整留存和持久化。
- 文件大小限制与自动分卷:单个日志文件具有预设的最大容量限制,当达到上限时,系统自动创建新文件进行存储。
- 有序归档命名规则:采用“文件名.log.序号”形式命名历史日志文件,如
sparrow.log.1
、sparrow.log.2
等;当前活动日志文件统一命名为sparrow.log
,不带序号,以此保持最新日志的访问便捷性。
- 高效资源利用
设计上注重轻量化,确保日志系统占用较低的内存和CPU资源,在不影响系统性能的前提下完成日志记录工作。 - 便捷API接口
提供一套简洁明了、易于集成的API接口,使得开发人员能够轻松地在代码中添加和使用日志功能。
设计方案
基于上述日志功能需求分析,以下是设计方案的概要框架:
- 日志分级管理设计
- 日志级别定义:
设计一组枚举类型表示不同日志级别(DEBUG
、INFO
、WARNING
、ERROR
),并构建相应的日志输出接口。
配置模块可读取环境变量或配置文件,动态设置日志级别阈值,低于此阈值的日志将不会被记录。 - 日志过滤器
在日志输出前增加过滤逻辑,根据当前设置的日志级别决定是否需要输出指定级别的日志消息。
- 异步处理与并发安全性设计
- 构建日志共享缓存区
映射一片共享内存,通过环形buffer的形式管理,用于实时缓存产生的日志。模块使用时,只会实时的将日志丢到共享内存中,而不会参与日志存储业务,提升日志的写入效率。 - 创建独立的日志管理进程
LogManager
此进程负责实时读取缓存区的日志,并写入到本地文件中。同时,此进程负责实现日志文件的管理等功能。 - 通过信号量,实现并发同步
多进程在写入共享内存时,通过信号量实现进程间同步,避免日志写入错乱。
- 详尽上下文信息记录设计
- 日志输出格式封装
将时间戳、进程ID、代码行号、模块名等信息封装到日志中,伴随每一条日志消息一起输出。
- 滚动日志归档策略设计
为确保日志文件的有效管理和存储,设计了一套文件滚动机制。当当前日志文件sparrow.log的大小超出预设阈值时,系统将自动执行回滚操作:
- 容量监测阶段
实施动态监控sparrow.log文件大小,一旦触及预先设定的容量上限,即刻触发回滚流程。 - 文件迁移过程
将现有的sparrow.log文件通过原子操作进行重命名,新增后缀".1"变为sparrow.log.1,以保存历史日志内容。 - 新文件初始化
立即创建一个新的sparrow.log文件,用于接收接下来产生的实时日志信息。 - 滚动策略连续执行
当sparrow.log
文件再度达到阈值时,延续相同逻辑,依次将sparrow.log更新为sparrow.log.n+1(n为历史文件序号),并创建新的sparrow.log。 - 历史日志管理
设定保留的历史日志文件数量上限,超出限额的最老日志文件将被适时清理,确保存储空间的有效利用。 - 日志文件压缩设计
可选功能。若文件存储阈值定义较大,产生历史文件时,将其压缩存储。
- 资源效率优化设计
- 共享环形缓冲区管理
1)将实时日志先存入共享内存中,避免大量的文件读写操作。
2)引入环形缓冲区管理共享内存,实现共享内存的循环使用,避免创建大容量共享内存。
3) 控制缓存大小,环形设计避免共享内存写入溢出。
- 便捷API接口设计
将日志接口封装成宏函数,方便各模块调用。实现细节
- 日志框架组成
基于上述设计方案的概括,日志框架主要划分为三个相互协作的部分,在实际环境运行示意图如下:
注:箭头指示日志数据的传输路径及其流向
- 对外接口(API)
此组件专为应用开发者设计,嵌入于使用者进程内部,承担着将产生的日志实时推送至共享缓存区的责任,通过简洁高效的API接口,确保开发者能够轻松、灵活地在不同模块中记录不同级别的日志。 - 日志核心管理(LogManagerSrv)
作为独立运行的日志管理进程,核心组件专注于日志的存储和文件管理任务。它负责从共享缓存区读取日志信息,执行日志文件的滚动归档策略,以及对文件大小、历史日志数量进行有效控制,确保日志数据的持久化存储和系统资源的高效利用。 - 调试输出(终端Debug)
调试输出进程主要用于开发和调试场景。在终端手动执行后,它实时监听共享缓存区中的日志数据,并将它们立即显示在终端界面,便于开发人员实时观测和分析程序运行状况。
- 对外接口(API)
这部分代码相对简洁,其核心在于对日志使用接口进行了一层逻辑封装,并进一步通过宏定义的形式转化为易于使用的宏接口,旨在为开发者提供更为便捷的日志调用方式。
#define LOGD(tag, fmt, args...) SprLog::GetInstance()->d(tag, "%4d " fmt, __LINE__, ##args)
#define LOGI(tag, fmt, args...) SprLog::GetInstance()->i(tag, "%4d " fmt, __LINE__, ##args)
#define LOGW(tag, fmt, args...) SprLog::GetInstance()->w(tag, "%4d " fmt, __LINE__, ##args)
#define LOGE(tag, fmt, args...) SprLog::GetInstance()->e(tag, "%4d " fmt, __LINE__, ##args)
- 日志核心管理(Core)
此核心模块承载了日志功能的核心实现逻辑,面对的主要挑战集中在如下几个关键技术环节:
实时存储至本地
负责实现日志数据从内存到磁盘文件的实时高效写入,确保日志信息的完整性和可靠性。int LogManager::MainLoop() { while (mRunning) { if (pLogMCacheMem->AvailData() <= 0) { usleep(10000); continue; } int32_t len = 0; int ret = pLogMCacheMem->read(&len, sizeof(int32_t)); if (ret != 0 || len < 0) { SPR_LOGE("read memory failed! len = %d, ret = %d\n", len, ret); usleep(10000); continue; } std::string value; value.resize(len); char* data = const_cast<char*>(value.c_str()); ret = pLogMCacheMem->read(data, len); if (ret != 0) { SPR_LOGE("read failed! len = %d\n", len); } RotateLogsIfNecessary(len); WriteToLogFile(value); } return 0; }
在
MainLoop
中,不停读取环形共享内存数据,并写入本地文件中。在写入过程中,发现长度超过文件阈值,则触发日志文件回滚策略。回滚策略业务在RotateLogsIfNecessary
实现。日志文件滚动
设计并执行一套有效的日志文件回滚机制,当单个日志文件达到预设大小时,需自动创建新的日志文件并迁移日志,同时维护好历史日志文件的有序命名和存储。// E.g: sparrow.log sparrow.log.1 sparrow.log.2 ... int LogManager::RotateLogsIfNecessary(uint32_t logDataSize) { uint32_t curFileSize = static_cast<uint32_t>(mLogFileStream.tellp()); if (curFileSize + logDataSize > mMaxFileSize) { mLogFileStream.close(); UpdateSuffixOfAllFiles(); mLogFileStream.open(mLogsDirPath + '/' + mCurrentLogFile, std::ios_base::app | std::ios_base::out); if (!mLogFileStream.is_open()) { SPR_LOGE("Open %s failed!", mCurrentLogFile.c_str()); } } return 0; }
在回滚过程中,涉及到历史日志文件迁移,在
UpdateSuffixOfAllFiles
实现,篇幅有限暂不列举。文末获取代码方法,需要自取。共享环形缓存区管理
此部分实现了一个基于共享内存的环形缓冲区数据结构,其核心功能与常规环形缓冲区保持一致,但特别之处在于它位于可供多个进程共同访问的共享内存区域中。该模块提供的接口服务于日志数据的高效暂存和交换,确保在多进程环境下,日志信息能安全、顺畅地从生成者进程传递至消费者进程(如日志管理进程),并在此过程中有效地避免数据冲突和丢失。
```C++
class SharedRingBuffer
{
public:
/**- @brief Constructs a master Shared Ring Buffer object.
- @param path The path to the shared memory.
- @param capacity The buffer's capacity.
* Intended for use in master mode with shared memory refreshing.
*/
SharedRingBuffer(std::string path, uint32_t capacity);/**
- @brief Constructs a slave Shared Ring Buffer object
- @param path The path to the shared memory.
* - This constructor creates an instance of a slave Shared Ring Buffer, typically used by client applications.
It facilitates access and utilization of the shared buffer by referencing it through the specified path.
*/
SharedRingBuffer(std::string path);
~SharedRingBuffer();bool IsReadable() const noexcept;
bool IsWriteable() const noexcept;
int write(const void data, int32_t len);
int read(void data, int32_t len);
int DumpBuffer(void* data, int32_t len) const noexcept;int32_t AvailSpace() const noexcept;
int32_t AvailData() const noexcept;
private:
void AdjustPosIfOverflow(uint32_t pos, int32_t size) const noexcept;
void SetRWStatus(ECmdType type) const noexcept;
void DumpMemory(const char pAddr, uint32_t size);
void DumpErrorInfo();
private:
Root mRoot;
void mData;
uint32_t mCapacity;
std::mutex mMutex;
std::string mShmPath;
};
4. **调试输出(Debug)**
  这部分实现主要是将存储在缓存区的日志实时显示在终端上,便于调试时,观察实时打印。考虑到终端通过执行```tail -f sparrow.log``` 能达到同样效果,故暂不实现。
## 测试
1. **测试终端实时日志打印**
本地触发10ms一次的定时器,观察终端输出的日志间隔是否为10ms
```shell
$ tail -f /tmp/sprlog/sparrow.log
04-12 23:53:26.048 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
04-12 23:53:26.048 51958 TimerM D: 76 [0x0 -> 0x0] msg.GetMsgId() = SIG_ID_TIMER_START_SYSTEM_TIMER
04-12 23:53:26.057 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
04-12 23:53:26.058 51958 TimerM D: 76 [0x0 -> 0x0] msg.GetMsgId() = SIG_ID_TIMER_START_SYSTEM_TIMER
04-12 23:53:26.068 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
04-12 23:53:26.068 51958 TimerM D: 76 [0x0 -> 0x0] msg.GetMsgId() = SIG_ID_TIMER_START_SYSTEM_TIMER
04-12 23:53:26.078 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
04-12 23:53:26.078 51958 TimerM D: 76 [0x0 -> 0x0] msg.GetMsgId() = SIG_ID_TIMER_START_SYSTEM_TIMER
04-12 23:53:26.088 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
04-12 23:53:26.088 51958 TimerM D: 76 [0x0 -> 0x0] msg.GetMsgId() = SIG_ID_TIMER_START_SYSTEM_TIMER
04-12 23:53:26.098 51958 TimerM D: 76 [0x0 -> 0x5] msg.GetMsgId() = SIG_ID_SYSTEM_TIMER_NOTIFY
通过观测,证实了每隔10毫秒的时间间隔均能得到预期反馈,确认该间隔时间设置准确无误。
- 测试日志回滚
为了方便验证,日志文件阈值暂设置为1M。
观察结果显示,日志回滚机制正在正常运作,表现为sparrow.log文件大小随着新日志的实时写入而动态更新,始终保持存储最新内容。$ ls /tmp/sprlog/ -lh total 10M -rw-r--r-- 1 dx dx 995K Apr 12 23:56 sparrow.log -rw-r--r-- 1 dx dx 1.0M Apr 12 23:55 sparrow.log.1 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:54 sparrow.log.2 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:54 sparrow.log.3 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:53 sparrow.log.4 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:52 sparrow.log.5 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:51 sparrow.log.6 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:50 sparrow.log.7 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:50 sparrow.log.8 -rw-r--r-- 1 dx dx 1.0M Apr 12 23:49 sparrow.log.9
总结
- 对于项目来说,移植并使用像Android logcat这样的成熟日志框架至关重要,本文的实现正是以此为参考依据。
- 虽然日志系统看似与实际功能模块无直接关联,但其重要性不可忽视;真正动手实现一套完善的日志系统,对个人技术水平的成长有着极大的提升。
- 在实现日志系统框架时,发现了共享内存应用的一种独特方式:通过与特定数据结构的有机结合,能实现数据在意外断电情况下的记忆保留,这种方式颇具趣味性和实用性。