Step to memory 005 延迟和流程的演变

原文在 https://www.bit-tech.net/reviews/tech/memory/the_secrets_of_pc_memory_part_1/8/

延迟

DDR 的每一代发展都提供了更快的数据速率和更高的容量,但其代价是稳定性和信号准确性的下降。因此,每一代 DDR 都需要适应更高的频率和更高的信号延迟。

延迟可以理解为为时间上的暂停或延迟。当 DDR 信号命令发生变化时,内存子系统需要在不同的内存命令之间暂停。这就像一列火车驶入车站,然后停下来让乘客上下车。

延迟有很多种类型。CAS 延迟通常被认为是最重要的延迟之一,然而随着 DDR 内存的更新换代,它本身的重要性逐渐降低,而多个延迟值的组合则更为重要。许多内存模块将 CAS 延迟表示为“CL”或简称为“C”。例如,CAS 延迟为 3 个时钟周期的内存模块通常被标记为CL3 或 C3。相关知识将在后续文章中讨论。

演化过程 在更高数据吞吐量需求的推动下,DDR 相关的各种技术经历了一系列的演进,同时尽可能保持经济性。

提高内存数据传输频率面临的两​​大挑战是信号噪声水平和时序精度。这通常被称为有效数据窗口 (Valid Data Window , DVW)。有效数据窗口有时也称为数据有效窗口(Data-Valid Window,DVW)或简称为“眼图”。它是决定信号可靠性主要因素。

为了降低信号反射,。例如:DDR1 采用主板上添加终端电阻方法,DDR2 则采用片上终端电阻 (On-Die Termination,ODT) 方法。DDR3 进一步扩展了 ODT 技术,允许根据情况动态调整 ODT 值。结合各种信号校准技术,可以合理地管理数据完整性,从而实现更快的传输速率。所有内存模块和主板都需要在设计和后期生产过程中进行信号准确性测试和验证,以确保各种内置校准方案正常运行。

这里提到的信号反射指的是高速信号中的反射问题【参考1】https://blog.csdn.net/hs977986979/article/details/142762703

在硬件电路中,高频信号的反射是一个非常重要的现象。当电磁波在传输线上传播时,如果遇到阻抗不连续点(如传输线的末端、拐角、过孔、元件引脚、线宽变化等),就会发生反射。反射波的大小和方向取决于入射波的幅度、相位以及阻抗不连续点的性质。

反射现象会导致信号轮廓失真,产生过冲、欠冲和振荡等问题。这些问题会影响电路的性能和稳定性。为了减小反射,通常需要在传输线的末端添加适当的终端匹配电阻,以确保信号的完整传输。
为了实现更高的效率和更低的散热,需要降低内存电压。数据中心运营商、台式机和笔记本电脑消费者对低功耗计算机越来越感兴趣,原因包括更环保、更长的使用时间以及总体运营成本的降低。

现代数据中心使用大量空调来维持运行。例如,卢卡斯影业 (LucasFilm) 的数据中心在 32 台空调机组中使用了 25 吨冷却剂来维持其系统的运行。功率效率通常以每瓦性能来计算,因此在保持相同性能的情况下,瓦数的降低对行业来说都是利好消息。

计算机内存系统如果没有多年的预先规划和全行业协商,就不会发生重大的范式转变。成员们会将一次又一次的修订提交给像 JEDEC 这样的设计管理机构,然后由一个合作伙伴委员会监督整个设计和批准过程。

制造过程中涉及的制造和测试设备成本高昂,这极大地阻碍了变革,因为自动测试设备 (Automatic Test Equipment,ATE) 价格极其昂贵,通常每台设备的成本高达数百万美元。MOSAID Systems 的 Brad Snoulten 认为,主要挑战有两个:

  • 内存制造商无力购买或更换价值数百万美元的测试仪来满足不断增长的生产或工程测试需求。

  • 内存ATE供应商面临着设计经济实惠且能有效抵御长期淘汰的解决方案的挑战。内存裕度的不断下降和设备复杂性的不断增加加剧了这些问题。

进步是一个渐进的演变过程,而不会出现跳跃式的进步。

第九章 GDDR,QDR 和 XDR

如果您在过去三年内购买组装一个台式机,它很可能至少有一个 PCI Express 图形扩展接口。这些显卡拥有一种专用的 DDR,即图形 DDR (GDDR) 内存,容量从最小的几十兆字节到超过上千兆字节甚至更多。

GDDR通常用于对带宽要求极高的高性能显卡。需要主意它的架构与 DDR 截然不同, 在JEDEC 的规范中GDDR 与 DDR 标准分属不同规范。

最新的 GDDR 技术已发展到第五代,简称为 GDDR5。不同代之间的主要区别在于性能和带宽。与 DDR 相比,GDDR 具有更高的性能,但在制造成本和功耗方面也明显更高。GDDR3的工作电压为 2.0V,而 DDR3 的工作电压为 1.5V,额外的电压有助于 GDDR 更快地运行,但也使其更容易泄漏电流,从而产生更多热量。这与大型图形运算核心组合意味着显卡产生的热量通常比主板中的 CPU 和内存加起来还要多。

Nvidia 8800GT 上的奇梦达 GDDR3

GDDR 具有较宽的数据接口和更大的帧缓冲区。与 DDR 设备相比,显卡等 GDDR 设备的单位容量较低;这会导致功耗更高且价格更高。增加设备的 GDDR 显存容量会相应增加功耗和设备价格。

QDR和XDR

四倍数据速率 (QDR) 内存系统始于 1999 年,由Cypress 导体公司、IDT 和 NEC 共同开发。此后,包括美光科技、瑞萨电子、三星电子和日立在内的多家公司都以某种形式参与其中。

我们预计, QDR 内存取代 DDR 的可能性不高,最重要的原因是QDR无法实现低成本的量产。其次,此举相当于内存技术的一次重大转向,出于经济性和实用性的考虑,很多制造商对此非常反对。

虽然 QDR 的数据频率和有效数据窗口显著提高,并具有超低延迟,但其内存容量相对于 DDR 而言相对较低。当 DDR3 达到每模块 8GB 时,QDR3 内存容量标准仍然以 MB 为单位。QDR 架构是特地为高性能通信应用而设计。

Lattice半导体公司表示,与 QDR 相比,DDR 技术存在以下缺点:

  • 写入和读取共享一条双向数据总线,因此总带宽与 QDR 架构相比减少了一半。这在写入与读取比例接近 1:1 的情况下意义重大。
  • 刷新需要中断数据传输。
  • 访问延迟相对较高。
  • 需要在上电后进行初始化,并在访问之前/之后激活/预充电行(内存接口简化了这一点)。

值得注意的是,目前市场上已经有了QDR内存甚至ODR内存(Octal-Data Rate)。索尼 PlayStation 3 采用 Rambus 的 XDR(极限数据速率)设计,该设计能够在每个时钟周期发送 8 位数据,而 DDR 只能在每个时钟周期发送 2 位数据。三星、奇梦达、尔必达、IBM、东芝、AMD 等公司目前都采用 XDR 设计。这些内存系统价格极其昂贵,而且不像 DDR 那样普及。

有一种罕见的 DDR2 类型,称为 DDR2+(或增强型 DDR2),其核心频率可达 333MHz,而 DDR2 标准频率为 200MHz。DDR2+ 也是由 QDR 联盟联合开发的。

下次我们将研究移动 DDR 和底层 DDR 技术,如温度补偿自刷新、部分阵列自刷新、深度断电和时钟停止模式以及 DRAM 封装和堆叠技术。

======================================================

讲个好玩的事情,三星内存的“反周期策略”:

三星充分利用了存储器行业的强周期特点,在价格下跌、生产过剩、其他企业削减投资的时候,逆势疯狂扩产,通过大规模生产进一步下杀产品价格,从而逼竞争对手退出市场甚至直接破产,世人称之为“反周期定律”。

在存储器这个领域,三星一共祭出过三次“反周期定律”,前两次分别发生在80年代中期和90年代初,让三星从零开始,做到了存储器老大的位置。但三星显然觉得玩的还不够大,于是在2008年金融危机前后,第三次举起了“反周期”屠刀。2007 年初,微软推出了狂吃内存的Vista操作系统,DRAM厂商判断内存需求会大增,于是纷纷上产能,结果Vista 销量不及预期,DRAM 供过于求价格狂跌,加上08 年金融危机的雪上加霜,DRAM 颗粒价格从2.25 美金雪崩至0.31 美金。

就在此时,三星做出令人瞠目结舌的动作:将2007 年三星电子总利润的118%投入DRAM 扩张业务,故意加剧行业亏损,给艰难度日的对手们,加上最后一根稻草。效果是显著的。DRAM价格一路飞流直下,08年中跌破了现金成本,08年底更是跌破了材料成本。2009年初,第三名德系厂商奇梦达首先撑不住,宣布破产,欧洲大陆的内存玩家就此消失。2012年初,第五名尔必达宣布破产,曾经占据DRAM市场50%以上份额的日本,也输掉了最后一张牌。在尔必达宣布破产当晚,京畿道的三星总部彻夜通明,次日股价大涨,全世界都知道韩国人这次又赢了。至此,DRAM领域最终只剩三个玩家:三星、海力士和镁光。尔必达破产后的烂摊子,在2013年被换了新CEO的镁光以20多亿美金的价格打包收走。20亿美金实在是个跳楼价,5年之后,镁光市值从不到100亿美元涨到460亿,20亿美元差不多是它市值一天的振幅。

上述来自雪球 作者:潇潇小鱼 链接:https://xueqiu.com/2691707350/179978325

EasyX 用点画圆

圆形的参数方程如下:

根据这个,编写 EasyX 代码如下:

#include <graphics.h>  // EasyX图形库头文件
#include <conio.h>     // 用于_getch()
#include <math.h>    

#define RAD 100
int main()
{
    int x,y;
    // 初始化640x480像素的图形窗口
    initgraph(640, 480);

    for (int i = 0; i < 360; i++) {
        x = 320 + RAD * sin(i * 3.1415 / 180); // sin 用弧度做参数
        y=  240+ RAD * cos(i * 3.1415 / 180); // cos 用弧度做参数
        putpixel(x, y, RED);  
    }

    // 保持窗口显示
    _getch();

    // 关闭图形窗口
    closegraph();
    return 0;
}

运行结果:

此外,这里再给出一个奇怪/低效的算法。基于如下限制:所有的点在八个方向上只存在两个点与之相近(换句话,一个像素的线绘制出来的圆形),基于此限制,设计算法如下:

  1. 给出起始点坐标(Xorg,Yorg);
  2. 生成8个方向的坐标,计算这些点位和圆心的距离,然后选择最接近半径的作为下一个点的坐标;
  3. 重复上述动作计算出所有的点(不过我还没有想明白怎么通过公式计算出点的数量)
#include <graphics.h>  // EasyX图形库头文件
#include <conio.h>     // 用于_getch()
#include <math.h>    
//#include <stdio.h>  

#define Xcenter 320
#define Ycenter 240
#define RAD 100

// 计算 (x,y) 到圆心的距离
double CalculateDistance(int x, int y)
{
    return (sqrt((x - Xcenter) * (x - Xcenter) + (y - Ycenter) * (y - Ycenter)));
}

// 找到下一个点位
// 输入当前点位坐标 (Xcurrent,Ycurrent)
// 前一个点位坐标 (Xlast,Ylast
void FindNextPoint(int Xcurrent, int Ycurrent, int Xlast, int Ylast, int *Xnext, int *Ynext)
{
    double gap= 1000000000.0;
    double tmp;

    // 左
    if ((Xcurrent - 1 != Xlast) || (Ycurrent != Ylast)) {
        tmp = CalculateDistance(Xcurrent - 1, Ycurrent);
        tmp = fabs(tmp - RAD);
        if (gap > tmp) {
            gap = fabs(CalculateDistance(Xcurrent - 1, Ycurrent) - RAD);
            *Xnext = Xcurrent - 1;
            *Ynext = Ycurrent;
        }
    }
    // 左上
    if ((Xcurrent-1 != Xlast) || (Ycurrent-1!= Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent - 1, Ycurrent-1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent - 1, Ycurrent-1) - RAD);
            *Xnext = Xcurrent - 1;
            *Ynext = Ycurrent-1; 
        }
    }
    // 上
    if ((Xcurrent!= Xlast) || (Ycurrent - 1 != Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent, Ycurrent - 1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent, Ycurrent - 1) - RAD);
            *Xnext = Xcurrent;
            *Ynext = Ycurrent - 1;
        }
    }
    // 右上
    if ((Xcurrent+1 != Xlast) || (Ycurrent - 1 != Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent+1, Ycurrent - 1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent+1, Ycurrent - 1) - RAD);
            *Xnext = Xcurrent+1;
            *Ynext = Ycurrent - 1;
        }
    }
    // 右
    if ((Xcurrent + 1 != Xlast) || (Ycurrent != Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent + 1, Ycurrent) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent + 1, Ycurrent) - RAD);
            *Xnext = Xcurrent + 1;
            *Ynext = Ycurrent;
        }
    }
    // 右下
    if ((Xcurrent + 1 != Xlast) || (Ycurrent+1 != Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent + 1, Ycurrent+1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent + 1, Ycurrent+1) - RAD);
            *Xnext = Xcurrent + 1;
            *Ynext = Ycurrent + 1;
        }
    }
    // 下
    if ((Xcurrent != Xlast) || (Ycurrent +1!= Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent, Ycurrent+1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent, Ycurrent+1) - RAD);
            *Xnext = Xcurrent;
            *Ynext = Ycurrent+1;
        }
    }
    // 左下
    if ((Xcurrent-1 != Xlast) || (Ycurrent + 1 != Ylast)) {
        if (gap > fabs(CalculateDistance(Xcurrent-1, Ycurrent + 1) - RAD)) {
            gap = fabs(CalculateDistance(Xcurrent-1, Ycurrent + 1) - RAD);
            *Xnext = Xcurrent-1;
            *Ynext = Ycurrent + 1;
        }
    }

}
int main()
{
    int x=0, y= 0;
    int Xlast = Xcenter - RAD, Ylast = Ycenter;
    int Xcurrent = Xcenter - RAD, Ycurrent = Ycenter;

    // 初始化640x480像素的图形窗口
    initgraph(640, 480);


    for (int i = 0; i < 800; i++) {
        FindNextPoint(Xcurrent, Ycurrent,Xlast,Ylast,&x,&y);

        //printf("%d %d\n", x, y);
        putpixel(Xcurrent, Ycurrent, RED);
        Xlast = Xcurrent;
        Ylast = Ycurrent;
        Xcurrent = x;
        Ycurrent = y;
    }

    // 保持窗口显示
    _getch();

    // 关闭图形窗口
    closegraph();
    return 0;
}

Step to memory 004 单通道和双通道

继续 《PC 内存的秘密 第一部分》原文在 https://www.bit-tech.net/reviews/tech/memory/the_secrets_of_pc_memory_part_1/6/

有几个独特但重要的速度概念需要牢记。它们是 DRAM 核心频率、输入输出 (IO) 缓冲区频率、内存总线频率和数据频率。它们用于描述内存系统不同部件的性能水平。

所有计算机内存模块都以数据频率为标称值。例如,DDR2-800 中的“800”数字描述了模块以 800MHz频率搬运数据的能力。另一方面,IO 缓冲区和总线频率将以 400MHz 运行,而 DRAM 核心频率仅以 200MHz 运行。

频率 (MHz) 和数据吞吐量 (Mbps) 之间的关系很简单。一个信号是 1 位数据:0 或 1。下图中的圆形黑色“操作点”。800MHz 数据频率意味着它将以每秒 8 亿次的速度发送 1 位数据,因此两者的乘积等于每秒 800 兆比特 (Mbps)。 (注:请记住,位和字节是不同的:8 位 = 1 字节,因此之前的 800Mbps 仅相当于 100MBps 或每秒兆字节)。

在给定数据频率的情况下,实际或有效数据吞吐量低于预期。例如,800MHz 的 DDR2 不会将该频率全部用于数据流。相反,命令和控制信号会占用部分数据。这有点像购买 160GB 硬盘,格式化后,只有 149GB 可用于数据。这是因为驱动器的一部分被系统级信息(如主引导记录 MBR 和分区表)占用。

对于一些爱好者和完美主义者来说,这是内存超频很好的理由,以使数据吞吐量更接近或超过 800MHz 数据流。使用指定为 1066MHz 的 DDR2 模块可以更好地实现这一点。

双通道和单通道模式

DDR 的另一项重大创新是能够使用双通道,而不是传统的单通道内存总线。这种设计极大地提高了内存性能。
在对称双通道芯片组上,将两个内存模块放在相同颜色的插槽中将自动为用户提供双通道性能,但是在 4 插槽主板上使用 3 个内存模块将使主板切换回单通道模式。非对称双通道芯片组能够使用 3 个 DIMM 在双通道模式下运行,从而始终有效地为用户提供 128 位内存性能。

当前基于台式机的 DDR 内存技术无法支持每通道超过 2 个 DIMM,但基于服务器和工作站的 FB-DIMM(全缓冲 DIMM)内存控制器(用于英特尔 CPU)设计时考虑了每通道 8 个 DIMM。这些高端计算机通常配备通道配置,需要至少四个 DIMM 才能使用它。

每个标准台式机内存模块支持 64 位数据总线,而基于服务器的内存模块每通道使用 72 位数据总线:额外的 8 位用于纠错码 (ECC)。由于增加了额外的性能和纠错特性的复杂性,R-DIMM 和 FB-DIMM 比台式机使用的内存贵得多。

CPU 和带宽增长

内存系统与 CPU 的演进有着直接的关系。随着更强大的处理器进入市场,需要更多的内存带宽来跟上 CPU 的处理速度。较慢的内存系统无法向快速的 CPU 提供足够的数据,这会导致处理器花费更多的时间在等待更多数据。内存系统的目标是在尽可能短的时间内为不同的任务存储和检索大量数据。如果没有同样快速的内存系统,CPU 将无法充分发挥效能导致效率低下。

下图说明了这一现象。摩尔定律描述 CPU 的处理能力每 18 个月翻一番。英特尔核心团队负责人 David “Dadi” Perlmutter 将其归类为“经济性和技术进步的简单关系”。计算机的效率在很大程度上依赖于内存系统来跟上 CPU 方面的改进。

单核 CPU 过去只是通过提高处理频率来提升性能。2004-2005 年,一种新的台式机 CPU 设计将处理器改进的动态从原始速度的单一因素转变为两个因素:处理速度以及核心数量。

此后的 CPU 性能提升依赖于核心频率的改进、处理器封装内的核心数量、具有更先进的预测和预取优化的缓存技术以及改进的总线效率以避免瓶颈。用于内存访问的数据预测和预取算法对处理器效率有重大影响。其他因素包括 L1、L2(和 L3)缓存的使用方式及其相关算法和访问分配。

第七章 EMI和内存控制器

由于内存速度从未真正能够跟上 CPU 性能,内存系统设计人员一直在使用多种手段改进性能。出于成本考虑原因,设计的主要目标一直是尽可能降低制造成本。

创新的设计人员没有选择提升内存的原始速度,而是通过并行访问的方法来提升整体速度。这些创造性的技术包括:双倍数据速率技术(DDR)、双通道或四通道模式(Dual or Quad Channel Modes)以及预取技术(Pre-fetching )等等。

需要特别主意的是,DDR DRAM 在技术工程角度存在成本限制,核心频率限制在 200MHz时整体成本最优。提升芯片外部总线传输频率相较于提升 DRAM 核心频率成本上要划算的多。
而为了达到上述目标,主板和内存模块必须在精确的信号管理和电磁干扰(EMI)控制机制方面取得设计和制造突破。因此,内存改进不仅仅是 DRAM 设计人员考虑的问题,也是从 CPU 架构师、芯片组和 DRAM 设计人员到主板和内存模块制造商的全行业协调努力的结果。

内存控制器

内存控制器的职责是对内存模块发送命令、管理内存模块和路由信号到内存模块。每个 DRAM 通常有四个或八个内存组,访问方式与 Microsoft Excel 工作表类似:有行和列。每当 CPU 需要读取或存储数据时,它都会通过内存控制器将信息发送到 RAM。

英特尔今年已经以 P35 和 X38 芯片组的形式发布了“Bearlake”3 系列家族下的第一代 DDR3 主板。Nvidia 和后来的 AMD 为其自己的 CPU 推出的未来芯片组也将在 2008 年支持 DDR3。当前的 DDR2/DDR3“组合”主板能够支持现有的 DDR2-800 或新的 DDR3-1066/1333 内存模块,但不能同时支持两个标准。DDR2 和 DDR3 使用不同的内存插槽,因为它们具有不同的模块键槽位置。

Intel 从Nehalem架构(45nm)开始整合了内存控制器(Integrated  Memory Controller,IMC)。现在市面上的Intel处理器都已经整合了IMC。

将内存集成到 CPU 封装中的直接经济效益是降低该特定平台的主板制造成本。技术效益是减短CPU 对内存传输数据路径。集成内存控制器消除了对物理前端总线 (FSB) 的需求。无需独立的内存控制器芯片,主板制造商的设计和测试过程变得更简单 : EMI 问题更少,整个系统本耗设更低。理论上,这种设计消除了前端总线(FSB)带来的带宽瓶颈,可以显著提高内存性能。

集成内存控制器的缺点是它占用了处理器芯片上的空间,而这些空间原本可以用于更大的 L1、L2 和 L3 缓存。此外,任何直接内存访问 (DMA) 都必须经由 CPU 才能进入内存,这会为需要快速访内存的其他组件(如显卡)带来额外的延迟。

讲一个好玩的,我入行的时候是在昆山的微兴做台式机主板,偶然发现 Intel 的内存兼容性要比 AMD 好多了。发现的原因是当前公司研发有一批DQA 人员负责测试。有时候项目到了后期,实在没有什么好测试的他们就会拿出来内存“扫”一圈。Intel 通常没啥问题,比如,上一次是两三个型号有问题,这一次仍然是这些有问题。但是 AMD 就麻烦了,这次扫出来的和上一次有问题的型号往往不同,让人无比头大。

后来稍微有点经验,对于有问题的内存通过 SPD 信息判断,然后升高电压来解决。万幸的是这种方法对于大部分问题都有效果。

不使用第三方库实现 IIS 麦克风转HTTP

这段代码展示了不使用第三方库实现在一个 HTTP Server, 然后电脑可以通过浏览器打开 http://ip/stream 即可听到 IIS 麦克风拾取到的声音。实验使用MSM261S4030H0的 IIS 音频传感器。

#include <WiFi.h>
#include <WebServer.h>
#include <driver/i2s.h>

// WiFi配置
const char* ssid = "YOURSSID";
const char* password = "YOURPASSWORD";

// I2S配置
#define I2S_MIC_WS  37
#define I2S_MIC_SD  48
#define I2S_MIC_SCK 45
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 32
#define BUFFER_SIZE 1024

WebServer server(80);
int32_t audioBuffer[BUFFER_SIZE];

void setup() {
  Serial.begin(2000000);
  
  // 初始化I2S
  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = BUFFER_SIZE,
    .use_apll = false
  };
  
  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_MIC_SCK,
    .ws_io_num = I2S_MIC_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_MIC_SD
  };
  
  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pin_config);

  // 连接WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
  Serial.println("IP address: " + WiFi.localIP().toString());

  // 设置HTTP路由
  server.on("/stream", HTTP_GET, handleAudioStream);
  server.begin();
}

void loop() {
  server.handleClient();
  
  // 持续读取音频数据
  size_t bytesRead;
  i2s_read(I2S_NUM_0, &audioBuffer, BUFFER_SIZE * sizeof(int32_t), &bytesRead, portMAX_DELAY);
}

void handleAudioStream() {
  // 生成WAV文件头
  uint8_t wavHeader[44];
  generateWavHeader(wavHeader, SAMPLE_RATE, 32);
  
  // 发送HTTP头
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "audio/wav", "");
  
  // 先发送WAV头
  server.sendContent((const char*)wavHeader, sizeof(wavHeader));
  
  // 持续发送音频数据
  while(server.client().connected()) {
    size_t bytesRead;
    i2s_read(I2S_NUM_0, &audioBuffer, BUFFER_SIZE*4, &bytesRead, portMAX_DELAY);
    server.sendContent((const char*)audioBuffer, bytesRead);
  }
}


void generateWavHeader(uint8_t* header, uint32_t sampleRate, uint32_t bitDepth) {
    // RIFF块标识
    header[0] = 'R'; header[1] = 'I'; header[2] = 'F'; header[3] = 'F';
    
    // 文件总大小占位(后续更新)
    header[4] = 0; header[5] = 0; header[6] = 0; header[7] = 0;
    
    // WAVE格式标识
    header[8] = 'W'; header[9] = 'A'; header[10] = 'V'; header[11] = 'E';
    
    // fmt子块标识
    header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' ';
    
    // fmt块大小(16字节)
    header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0;
    
    // 音频格式(1=PCM)
    header[20] = 1; header[21] = 0;
    
    // 声道数(1=单声道)
    header[22] = 1; header[23] = 0;
    
    // 采样率
    header[24] = sampleRate & 0xFF;
    header[25] = (sampleRate >> 8) & 0xFF;
    header[26] = (sampleRate >> 16) & 0xFF;
    header[27] = (sampleRate >> 24) & 0xFF;
    
    // 字节率 = 采样率 * 声道数 * 位深度/8
    uint32_t byteRate = sampleRate * (bitDepth/8);
    header[28] = byteRate & 0xFF;
    header[29] = (byteRate >> 8) & 0xFF;
    header[30] = (byteRate >> 16) & 0xFF;
    header[31] = (byteRate >> 24) & 0xFF;
    
    // 块对齐 = 声道数 * 位深度/8
    uint16_t blockAlign = (bitDepth/8);
    header[32] = blockAlign & 0xFF;
    header[33] = (blockAlign >> 8) & 0xFF;
    
    // 位深度
    header[34] = bitDepth & 0xFF;
    header[35] = (bitDepth >> 8) & 0xFF;
    
    // data子块标识
    header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a';
    
    // data块大小占位(后续更新)
    header[40] = 0; header[41] = 0; header[42] = 0; header[43] = 0;
}

类似的,有时候传感器送出 32Bits 数据,我们需要转为 16Bits输出,编写代码如下:

#include <WiFi.h>
#include <WebServer.h>
#include <driver/i2s.h>

// WiFi配置
const char* ssid = "CMCC-TSR6739";
const char* password = "!!1783az";

// I2S配置
#define I2S_MIC_WS  37
#define I2S_MIC_SD  48
#define I2S_MIC_SCK 45
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 32
#define BUFFER_SIZE 1024

WebServer server(80);
int32_t audioBuffer[BUFFER_SIZE];
int16_t webaudioBuffer[BUFFER_SIZE];

void setup() {
  Serial.begin(2000000);
  
  // 初始化I2S
  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = BUFFER_SIZE,
    .use_apll = false
  };
  
  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_MIC_SCK,
    .ws_io_num = I2S_MIC_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_MIC_SD
  };
  
  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pin_config);

  // 连接WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
  Serial.println("IP address: " + WiFi.localIP().toString());

  // 设置HTTP路由
  server.on("/stream", HTTP_GET, handleAudioStream);
  server.begin();
}

void loop() {
  server.handleClient();
  
  // 持续读取音频数据
  size_t bytesRead;
  i2s_read(I2S_NUM_0, &audioBuffer, BUFFER_SIZE * sizeof(int32_t), &bytesRead, portMAX_DELAY);
}

void handleAudioStream() {
  // 生成WAV文件头
  uint8_t wavHeader[44];
  generateWavHeader(wavHeader, SAMPLE_RATE, 16);
  
  // 发送HTTP头
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "audio/wav", "");
  
  // 先发送WAV头
  server.sendContent((const char*)wavHeader, sizeof(wavHeader));
  
  // 持续发送音频数据
  while(server.client().connected()) {
    size_t bytesRead;
    i2s_read(I2S_NUM_0, &audioBuffer, BUFFER_SIZE*4, &bytesRead, portMAX_DELAY);
    for (int i=0;i<BUFFER_SIZE;i++) {
       int32_t audio_24bit = audioBuffer[i] >> 8;       // 提取高 24 位
       webaudioBuffer[i]=(int16_t)((audio_24bit + 128) >> 8);            // 四舍五入到 16 位
  
    }
    server.sendContent((const char*)webaudioBuffer, bytesRead/2);
  }
}


void generateWavHeader(uint8_t* header, uint32_t sampleRate, uint32_t bitDepth) {
    // RIFF块标识
    header[0] = 'R'; header[1] = 'I'; header[2] = 'F'; header[3] = 'F';
    
    // 文件总大小占位(后续更新)
    header[4] = 0; header[5] = 0; header[6] = 0; header[7] = 0;
    
    // WAVE格式标识
    header[8] = 'W'; header[9] = 'A'; header[10] = 'V'; header[11] = 'E';
    
    // fmt子块标识
    header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' ';
    
    // fmt块大小(16字节)
    header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0;
    
    // 音频格式(1=PCM)
    header[20] = 1; header[21] = 0;
    
    // 声道数(1=单声道)
    header[22] = 1; header[23] = 0;
    
    // 采样率
    header[24] = sampleRate & 0xFF;
    header[25] = (sampleRate >> 8) & 0xFF;
    header[26] = (sampleRate >> 16) & 0xFF;
    header[27] = (sampleRate >> 24) & 0xFF;
    
    // 字节率 = 采样率 * 声道数 * 位深度/8
    uint32_t byteRate = sampleRate * (bitDepth/8);
    header[28] = byteRate & 0xFF;
    header[29] = (byteRate >> 8) & 0xFF;
    header[30] = (byteRate >> 16) & 0xFF;
    header[31] = (byteRate >> 24) & 0xFF;
    
    // 块对齐 = 声道数 * 位深度/8
    uint16_t blockAlign = (bitDepth/8);
    header[32] = blockAlign & 0xFF;
    header[33] = (blockAlign >> 8) & 0xFF;
    
    // 位深度
    header[34] = bitDepth & 0xFF;
    header[35] = (bitDepth >> 8) & 0xFF;
    
    // data子块标识
    header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a';
    
    // data块大小占位(后续更新)
    header[40] = 0; header[41] = 0; header[42] = 0; header[43] = 0;
}

Easy X用点画阿基米德螺旋线

#include <graphics.h>  // EasyX图形库头文件
#include <conio.h>     // 用于_getch()
#include <math.h>    

#define a 0 // 从原点开始
#define b 60*3.1415/180 //半径增加的速率

int main()
{
    int x,y;
    // 初始化640x480像素的图形窗口
    initgraph(640, 480);

    for (int i = 0; i < 360*30; i++) {
        x = 320 + (a+  b * (i * 3.1415 / 180)) * cos(i * 3.1415 / 180);
        y=  240+  (a + b * (i * 3.1415 / 180)) * sin(i * 3.1415 / 180);
        putpixel(x, y, YELLOW);  
    }

    // 保持窗口显示
    _getch();

    // 关闭图形窗口
    closegraph();
    return 0;
}

运行结果:

一键USB通断器

在一些特别情况下,我们会碰到 USB 兼容性问题。比如,我正在使用的 DELL C2722DE 显示器,通过 TypeC 连接主机,除了能够显示之外,还内置了 USB Hub ,但是它和我的微软鼠标存在兼容性问题,如果插着显示器休眠,或者开机的话,进入 Windows之后鼠标是无法直接使用,必须插拔一次才能正常工作。

针对这种情况,这次设计了一个 USB 插拔装置,遇到问题的时候无需做插拔动作,而是通过按键一次自行完成一个插拔的模拟,这样可以方便使用。USB插拔动作可以分解为2步,插入时是先接通5V供电,然后接通D+/D-信号。如果仔细观察USB接头,可以发现USB D+/D- 引脚会比5V和GND 短一点,这样就能保证插入的时候是先接通供电,然后再接入信号的。这样可以避免插入时信号先于供电接通,电流直接倒灌到芯片中。类似的,拔出时,是先断开信号,然后再切断供电。

硬件方面使用了3个芯片:CH554 /CH442/SY6280AAC。CH554是一个单片机,这里作用是获得按钮状态,然后根据状态控制USB信号的切换和USB母头上的5V输出; CH442进行信号切换, SY6280AAC 是功率电子开关芯片,这里我们用用它控制USB供电输出。

CH442是一款低阻宽带双向模拟开关芯片,包含2路单刀双掷二选一开关。这里用作 USB 信号二选一。

CH442

PCB 设计如下:

焊接后的成品,刚好能装在外壳中:

CH554 外围只需要1个10K电阻,2个0.1uf 电容即可让它工作起来。然后通过P1 Header 4 下载数据非常方便。此外,CH_IN 适用于控制CH442信号切换,CTRL1用于控制SY6280AAC。

代码部分非常简单:

#define CH_IN 14
#define LED   15
#define CTRL1 16
#define KEY   17
void setup() {
  pinMode(CH_IN,OUTPUT);
  pinMode(LED,OUTPUT);
  pinMode(CTRL1,OUTPUT);
  pinMode(KEY,INPUT_PULLUP);
    
  digitalWrite(CH_IN,LOW); // Switch to 1
  digitalWrite(LED,LOW); // 不亮
  digitalWrite(CTRL1,HIGH); // 供电    
}
unsigned long int Elsp=0;

void loop() {
  if (digitalRead(KEY)==LOW) {
      digitalWrite(LED,HIGH); // 点亮 LED
      digitalWrite(CH_IN,HIGH); // 断开数据线
      delay(100);
      digitalWrite(CTRL1,LOW); // 断开USB供电
      Elsp=millis();
  } else {
      // Key 抬起
      if ((Elsp!=0)&&(millis()-Elsp>500)) {
          Elsp=0;
          digitalWrite(LED,LOW); // 关闭 LED          
          digitalWrite(CTRL1,HIGH); // 开始USB供电
          delay(100);
          digitalWrite(CH_IN,LOW); // 接通数据线
        }
    }
}

适用范围:如果你遇到的问题通过插拔一次可以解决,并且出现问题的时候USB端口能够正常供电,那么可以尝试本次的设计。运气好的话,因为引入了切换元件使得USB信号发生变化,每次都可以直接用;稍微差一些的话,出现问题的时候按下按钮即可模拟插拔让设备工作起来。

ImageMagick VC 代码的一些细节

大多数时候命令行足够用了,但是对于一些无法写入同一条命令行的组合操作,如果直接使用 VC 编程可以大大提升效率。

        // 读取文件
        image.read(filename);

        // 改变大小
        image.resize(Geometry(image.columns() * resize/100, image.rows() *resize / 100));
        // 设置背景颜色用于填充
        image.backgroundColor(Color("Yellow"));
        //旋转
        image.rotate(angle);
        // 特别主意,如果没有下面这个 repage 会导致计算坐标有问题
        image.repage();
        //以中心为原点裁剪图片
        image.crop(Geometry(1920, 1080, (image.columns()/2 - 1920/2), (image.rows()/2 - 1080/2)));

        // 保存图片
        image.write(Output);

参考:

1.https://zhuanlan.zhihu.com/p/606327058 (亲测可用)

C# 编写的UEFI迷宫游戏

这次介绍的迷宫游戏是C#编写的3D游戏。基于前面介绍的bflat编译完成。项目地址在 https://github.com/MichalStrehovsky/uefimaze/tree/master

下载好代码之后就可以进行编译(特别需要注意的是,需要在所在的 src目录下进行编译。如果你没有将 bflat放入path中,那么需要给出它的绝对路径):

bflat build --os:uefi --stdlib:zero -o:bootx64.efi  

上下左右四个按键移动视角。编译之后生成bootx64.efi。下面是在 VirtualBox上测试的视频:

源代码:

编译后的代码:

在 Arduino Serial Plotter 上绘制正弦波的例子


void setup() {
  Serial.begin(9600);  // 初始化串口通信
}

void loop() {
  static float angle = 0;  // 角度变量
  static const float step = 0.1;  // 角度增量
  
  // 计算正弦值(范围0-1)
  float sinValue = (sin(angle) + 1) / 2;  // 调整到0-1范围
  
  // 输出到串口绘图仪
  Serial.println(sinValue * 100);  // 放大到0-100范围
  
  angle += step;  // 增加角度
  if(angle >= 2*PI) angle = 0;  // 重置角度
  
  delay(50);  // 控制波形刷新速度
}

运行结果

我的故乡:大庆市

当我写下标题的时候,忽然有一种30年前坐在教室里完成老师作文题目的感觉。相比散装的江苏省,东北一直是“整装”的。这句话的意思是,对于江苏人来说他们永远都会介绍自己是苏州人或者南京人,很少会碰见自称江苏人。但是对于东北人来说,无论黑龙江吉林或者辽宁人,通常都会自称:“东北人”。

我在大庆生活到了18岁,然后离开了这个城市去成都求学读书。相当于从祖国的最东走到大西南。大学四年一直是火车来回,当时并没有直达列车需要在北京中转。从大庆到北京,最快18小时;然后再坐27小时50分的车从北京到成都。好在路上总是有同学相伴,加之年轻并不会觉得无聊。三天的路程也只是弹指一挥间。

大庆是一座在荒原上平地而起的城市,它名称的来源是 1959年恰逢建国十周年,发现的大庆油田。等到了1979年,更名为大庆市。也因为它的“年轻”,以至于很多大庆子弟离开之后到其他地方都会觉得为什么当地的楼房那么旧。

我住的地方叫做“让胡路区”,关于这个名称的来源有一些说法:

1954年建滨洲铁路时设让胡路站,区以站名;又说因附近水泡子形状像芽葫芦(轧葫芦、亚葫芦)得名;轧葫芦泡西北端跑县城百里,南北斜长约十数里,两端水阔中间狭细如轧葫芦,故名;相传早年有一家山东人迁移到此,因水泡子其开头很象关里家的轧葫芦,故名轧葫芦泡;1954面简化为让胡路。【参考1】

大庆市的出名是因为“大庆油田”,从历史上来说,大庆市是先有的企业(油田),然后才有的市政府。在我小时候,那个只有“座机”的时代,这样的割裂非常明显。那时候大庆有2种座机号码,5开头的管理局号码(当年我家就是管理局号,现在我还记得很清楚“553455”)以及6开头的市政号码。两种号码经常无法互通,需要拨号很多次才能侥幸打通。如果想成功直播,那就只能在前面加上区号:0459。只是这样做的话,是按照长途收费的。

这是一座名副其实的油城,根据baidu百科:“截至2022年10月,中国陆上最大油田大庆油田被发现63年来,已累计生产原油逾24亿吨”。相当于全国人民2吨油。我小时候经常听到的说法是一顿原油的价格比不上一吨的酱油。所有的物资都是在国家的统一调配下进行的。

对于当年日本侵华战争时为什么没有发现大庆油田,大约有两种说法:

说法一:日本勘探技术的问题以及抗日武装的功劳

当时,日本有一个机构,叫满洲炭矿株式会社,早在1926年,它就专门跑到中国东北来找石油。到了1928年,日本人在中国东北寻找石油的心情愈加迫切。时任满铁地质调查所参事的日本地质专家新带国太郎就曾在1929年春天带着人,沿东清铁路到牡丹江上游的森林地带寻找石油,但经过两个多月的探察却一无所获。不死心的他在1930年4月又在满洲里的扎赉诺尔煤矿进行过第二次勘探,再次失败。不止这一路人马。为了在东北找到石油,日本当时投入了大量人力物力,也用尽了当时他们所掌握的一切勘探和采矿设备,但始终没有发现一滴油。直到1941年“太平洋战争”爆发,所有日本在中国勘探石油的队伍都被征调到了东南亚,参与那里的油田恢复和开采(英军和荷军在撤离时摧毁了油田),“在中国东北找到石油”这个目标最终被放弃。

是日本人勘探能力和技术水平不行吗?也不完全是,现在回过头来看,其实当时还是很凶险的。根据旅日学者徐静波的回忆,2010年,他和一位日本客人拜访时任大庆市副市长的栾莹,栾莹曾告诉他,当时日本人打的其中一口井,离后来喷油的“松基三井”只有2公里远——当时日本人打的井深度是1000米,而“松基三井”是打到1300米深时喷油的。就差300米。而根据黑龙江省安达市档案局局长、安达市志办主任李生的回忆,大庆油田的主力产油区有一口井叫“萨一井”,这口井当年下钻到680米的时候就遇到了油层。而这口井与当年日本人打的井距离更近——相距仅1公里。如果当时日本人把打井的方位再多挪一公里,或者多打几口井,那么就很有可能率先发现大庆油田。日本人当初之所以没发现大庆油田,除了所谓的“运气”和“技术”之外,按照李生的说法,还和当年共产党领导的东北抗联和地下抗日武装有很大关系。

在打出“萨一井”的那个杏树岗,当年就是当地抗日游击队的大本营。在频繁的骚扰和袭击下,日本人其实很难大张旗鼓地拉开架势打井钻油,换句话说,其实当年日本人也并非像我们想象的那样,能大摇大摆地在中国东北随意勘探。【参考2】

说法二:毗邻大庆市的安达市是当时的日本细菌战试验场【参考3】,日本鬼子自然不愿意认真进行勘探。

相比之下,第一种说法更令人信服。所谓的“国运”更是一代又一代人奋力托举出来的结果。

从我有记事起,大庆的生活条件并不好,主要是物资短缺。因为冬季的寒冷,能够吃到的蔬菜品种非常少,主要就是萝卜白菜土豆这样耐寒的贮存菜。秋天的时候,单位会“分菜”。通常就是企业(比如,我父亲的单位是勘探开发设计院),从地方购买白菜,然后按照人头分下去。早些年住在平房,有些家庭会有菜窖。这是高寒地区人民群众多年经验总结出来的经验。在地上挖出来一个坑,然后留个门,秋天的时候将白菜萝卜贮藏在其中,这样即便外面零下三十度,地窖种仍然能够维持一定温度使得蔬菜不至于被冻坏。有需要的时候打开门,顺着梯子下去取出来就可以吃到。此外,还有酸菜,是秋天的时候在家中准备巨大的缸,然后一层层的码上白菜最上面放上一块大石头放置在家中。温度不能太高也不能太低,在事宜的温度中,白菜会发酵,然后成为整个冬季的食物。告诉你这些的人不会告诉你在发酵的过程中白菜会发出异味,也不会告诉你,在这个过程中夜深人静的时候缸中微生物会产生气体,因此,这个庞然大物对于一个少年是恐怖的存在。当然,酸菜炒粉条和酸菜炒肉是非常下饭的存在,有机会的朋友可以尝试一下。

为了让我接收白菜/酸菜,我母亲告诉我白菜清热解毒,人一百天不吃白菜就会中毒,这种说法让我深信不疑。现在虽然我不在家乡,有机会还是很爱吃白菜蘸酱的。除了白菜,给我留下悲伤记忆的更多的就是萝卜。印象中我的幼儿园时光就是充斥着萝卜的味道。当地的一种做法是:将萝卜过水,让它变得松软,然后挤出水分,蘸着大酱吃。味道尚可接受,唯独萝卜过水产生的味道让人难忘…….

至于土豆,倒是没有多少悲伤的感觉,可能是这种东西做出来的土豆泥还有土豆炖粉条多多少少会有肉的存在,味道并不差吧。东北的土豆大约是沙地的缘故,吃起来有着香甜的感觉。土豆炖粉条配上辣椒油,让人瞬间有着温暖的感觉。

从我小时候起,一直听到的“菜篮子工程”(搜了一下,英文名“shopping basket program” 是农业部从1988年开始一直坚持进行的项目【参考4】,至今已经快四十年),随着这个项目的进行,冬天可以选择的蔬菜品种也越来越多,西红柿韭菜大头菜等等。

冬天的水果品种也非常少,印象中只有桔子苹果。我年少时,这些也主要靠单位“分”,市面上买不到。比较有名的苹果有“国光”“红富士”,我比较偏向口感清爽类型的,因此不太喜欢“黄元帅”这种面甜的。当时印象深刻的还有有时候单位会分品相很差的“爱国苹果”。应该是一些地方受灾,苹果没有卖相,然后国有企业承担社会责任,购买之后作为福利下发而来。

大庆本地没有苹果,本地的水果是被成为“沙果”的水果,个头较小,吃起来酸甜口。我不太爱吃,因为太小,没吃几口就没了。

偶然单位能发一些香蕉,这对于我们来说就是少见的热带水果。只是每次拿到的都是真正的“蕉绿”。等我走出家乡,再也没有看到过如此之绿的香蕉。大约是因为贮藏不易的原因,因此发下来的都是翠绿颜色的。到家之后,父亲会含住白酒喷洒在上面,然后用塑料袋仔细的包装起来,放在床下,然后全家人静待成熟,而我总是迫不及待的每隔几天就拉出来看看。

若干年后,我偶然间看到关于北京一所中学的故事,说这家中学以前生源一直是达官显贵,后来被人批判不得不向平民开放。为了筛选,学校搞了一个骚操作,选拔的时候只是面试,拿出热带水果的画册,比如:芒果,然后让学生辨认…….普通人哪怕有看报纸的机会看到的也都是黑白照片,自然不认识热带水果,而能认得此物的通常也都是富贵人家的子弟了。由此,最终招上来的仍然是家世显赫的子弟,

参考:

1.http://www.tcmap.com.cn/heilongjiang/ranghuluqu.html

2.https://zhuanlan.zhihu.com/p/138814640

3.https://baike.baidu.com/item/%E4%BE%B5%E5%8D%8E%E6%97%A5%E5%86%9B%E7%AC%AC%E4%B8%83%E4%B8%89%E4%B8%80%E7%BB%86%E8%8F%8C%E9%83%A8%E9%98%9F%E5%AE%89%E8%BE%BE%E7%89%B9%E5%88%AB%E5%AE%9E%E9%AA%8C%E5%9C%BA%E9%81%97%E5%9D%80/61965399

4.https://baike.baidu.com/item/%E8%8F%9C%E7%AF%AE%E5%AD%90%E5%B7%A5%E7%A8%8B/7105237