Micropython@ESP32 将静态资源直接放在flash使用的方法
m24h2024/04/16软件综合 IP:上海
Abstract
Mircropython
Esp32
Flash

代码管在XXXXXXXXXXXXXXXXXX/m24h/esp32flash_mpy

最近在操作一个ESP32C3的开发板 才知道什么叫做憨牛充栋 还有什么叫做捉襟见肘

ESP32C3大抵不是为了Micropython设计的 或许使用IDF 它的内存足够一般应用了

t.png

但是如果先塞进去一个Micropython 情况就不一样了 一个原本具备数百k大小内存的MCU 到最后 我必须1k 1k地抠字节 才能勉强启动基本支撑框架 包括蓝牙 无线 OLED显示 网页服务等等 我曾经提了个bug给micropython 只申请2个20k内存 然后激活蓝牙 系统就崩溃了 

再加上Micropython本身又内存碎片的问题 动态申请不同大小的内存不可避免 现在不崩溃 以后也得崩溃 除非留出足够的内存余量 这就得在螺蛳壳里做道场了

于是我遇到了一个问题 就是汉字字库 光是用于二分搜索法的汉字索引 就有20k大小 在IDF中 基本不用考虑 嵌入固件中就是 但是这里 就是一个不能接受的内存大户

因为Micropython需要将用到的外部资源 都加载到RAM中 我寻找网上的解决方案 目前没有一个可用的方法 能够直接将资源放在flash中使用 除非自己编译固件 如果是那样 我还不如直接用IDF 毕竟它更容易从github递归克隆 而且 编译Micropython也需要递归克隆 我感觉就跟偷运两只大象一样困难

使用文件来访问是一种方法 但是对于随机读取数个字 作为块设备的文件 其实不合适的 也不是不能 但是效率可能会令人难以接受 

但是花了一天时间 总算找到了将资源放到flash 直接使用的方法

首先 先得从flash上划出地盘来 这就需要在载入Micropython 没有运行前就进行 就是修改分区表 划分出单独一个分区 这个需要在首次运行前进行的原因 是因为Micropython会在首次运行时寻找第一个数据区进行格式化

原本的分区表 给数据划分了2M空间 我从中拿出1M用来存放静态资源  就变成

data, nvs, 0x9000, 0x6000, nvs, 
data, phy, 0xf000, 0x1000, phy_init, 
app, factory, 0x10000, 0x1f0000, factory, 
data, fat, 0x200000, 0x100000, vfs, 
data, undefined, 0x300000, 0x100000, bin,

网上的分区表工具太繁琐  我自己写了一个 将以上CSV文件转换成可用的可载入的二进制码

import sys
from hashlib import md5
from struct import pack

if len(sys.argv)<3:
	print (f'''\
Create binary parition table from .csv file. 
Usage:
	python {sys.argv[0]} <.csv filename> <.bin filename>
''')
	exit(1)

types={
	'app':0,
	'data':1
}
subtypes={
	0:{
		'factory':0,
		'test':0x20,
	},
	1:{
		'ota':0,
		'phy':1,
		'nvs':2,
		'coredump':3,
		'nvs_keys':4,
		'efuse':5,
		'undefined':6,
		'esphttpd':0x80,
		'fat':0x81,
		'spiffs':0x82,
	},
}
flags={
  'encrypted':0,
}	

bin=bytearray()
with open(sys.argv[1], 'r') as f:
	while line:=f.readline():
		line=line.strip('\r\n \t')
		if not line or line.startswith('#'):
			continue
		csv=line.strip('\r\n \t').split(',')
		bin.extend(b'\xAA\x50')
		bin.extend(pack('B', type:=types[csv[0].strip(' \t').lower()]))
		bin.extend(pack('B', subtypes[type][csv[1].strip(' \t').lower()]))
		bin.extend(pack('<L', eval(csv[2].strip(' \t'))))
		bin.extend(pack('<L', eval(csv[3].strip(' \t'))))
		bin.extend(pack('16s', csv[4].strip(' \t').encode('utf-8')))
		flag=0
		if len(csv)>5:
			for t in csv[5].split(':'):
				if t:=t.strip(' \t'):
					flag=flag+(1<<flags[t])
		bin.extend(pack('<L', flag))
bin.extend(b'\xEB\xEB'+b'\0'*14+md5(bin).digest())
bin.extend(b'\xFF'*(0xc00-len(bin)))
with open(sys.argv[2], 'wb') as f:
	f.write(bin)

然后用"XXXXXXXXXX -p <端口> write_flash 0x8000 <生成的二进制码分区表文件>" 上载即可

下面就是关键了 怎么样像使用内存一样直接访问flash呢 Micropython有machine.mem32等访问内存的方法 但是对ESP32是不能用的 因为ESP32的内存管理有点复杂 它的内存映射和STM32不一样 不是固定的 MCU上的内存地址要转换成FLASH上的地址 有一个叫内存管理单元(MMU)的东西进行管理 我观察了Micropython的大部分代码 都完全没有涉及这块 显然全由bootloader根据app分区运行需要 给它进行内存映射 就这 还不是线性的

解决方法就是 将尚未映射的物理地址 全部都映射上去 幸好ESP的内存映射不像计算机的CPU那么复杂 否则我就可以直接放弃了 还幸好MMU映射表 可以用machine.mem32访问 下面这个模块就是使用非线性映射方式 可以直接访问flash的每个32 16 8位数 ... 嗯 Micropython应用涉及到内存管理核心 我估计也找不到其他的了

需要注意 这个代码只适合ESP32C3 对于其他ESP32 参数需要调整 目前我还没试其他的

# module flash.py
import sys
import machine
from array import array
from struct import unpack

if 'esp32c3' in sys.implementation._machine.lower():
    import esp
    import esp32
    page=4096
    size=esp.flash_size()
    from esp import flash_read  as readinto
    from esp import flash_write as write
    from esp import flash_erase as erase
    # make all flash MMU mapped, for ESP32C3, there are 128 entries, each enter means 64k,
    # and virtual address is from 0x3c000000, MMU table is from 0x600C5000
    _mmu=array('L', (0,)*128)
    free=array('L')
    for i in range(128):
        t=machine.mem32[0x600C5000+(i<<2)]
        if t&0x100:
            free.append(i)
        elif not t&~0x7F:
            _mmu[t]=0x3c000000+(i<<16)
    t=0
    for i in range(size>>16):
        if not _mmu[i]:
            if t>=len(free):
                raise MemoryError('No available MMU entry')
            machine.mem32[0x600C5000+(free[t]<<2)]=i
            _mmu[i]=0x3c000000+(free[t]<<16)
            t=t+1
    def vaddr(faddr):
        return _mmu[faddr>>16]+(faddr&0xffff)
    # find a partition which has a label 'bin'
    if t:=esp32.Partition.find(type=esp32.Partition.TYPE_DATA, label='bin'):
        t=t[0].info()
        bin_addr=t[2]
        bin_size=t[3]
    else:
        bin_addr=0
        bin_size=0
    del t, i, free
else:
    raise NotImplementedError('Not a supported MCU')

def mem32(pos):
    return machine.mem32[vaddr(pos)]

def mem16(pos):
    return machine.mem16[vaddr(pos)]

def mem8(pos):
    return machine.mem8[vaddr(pos)]

class Bin:
    def __init__(self, addr, size):
        self.addr=addr
        self.size=size
    
    def mem32(self, pos):
        return machine.mem32[vaddr(pos+self.addr)]
    
    def mem16(self, pos):
        return machine.mem16[vaddr(pos+self.addr)]
    
    def mem8(self, pos):
        return machine.mem8[vaddr(pos+self.addr)]
    
    def readinto(self, offset, barray):
        readinto(offset+self.addr, barray)
    
    # encoded label must be no longer than 8 bytes
    def findsub(self, label):
        idx=0
        b=bytearray(16)
        label=label.encode('utf-8')
        while idx+16<=self.size:
            readinto(self.addr+idx, b)
            idx=idx+16
            addr, size, name=unpack('<LL8s', b)
            if addr==0xFFFFFFFF or addr==0:
                break
            if name.strip(b' \0\xFF')==label:
                return Bin(self.addr+addr, size)
        return None

bin=Bin(bin_addr, bin_size) if bin_size else None

这里我还做了一个Bin类 将分区中的数据 模仿出一个可以分目录的类文件系统 但是名字只有8字节 而且可以随机访问任何32 16 8位数 另外 将现有资源文件打包的脚本也写了

import sys
from struct import pack

if len(sys.argv)<2:
	print (f'''\
Pack files into a binaray data for using raw ESP32 partition by my flash.py module.
This script will use the file name as data label, which must not be longer than 8 bytes.
Usage: 
	python {sys.argv[0]} <packed filename> [files to be packed] ...
''')
	exit(1)

addr=16*(len(sys.argv)-1)
bin=bytearray(b'\xFF'*addr)
pos=0
for fname in sys.argv[2:]:
	with open(fname, 'rb') as f:
		b=f.read()
	bin.extend(b)
	t=len(b)
	bin[pos:pos+16]=pack('<LL8s', addr, t, fname.encode('utf-8'))
	pos=pos+16
	addr=addr+t
	# align to 16 bytes
	t=(16-(addr%16))%16
	bin.extend(b'\xFF'*t)
	addr=addr+t

with open(sys.argv[1], 'wb') as f:
	f.write(bin)

使用的步骤是 先将最分枝的文件打包 然后层层往上汇聚打包 (其实我根本就不分第二层了 太累) 然后使用"XXXXXXXXXX -p <端口号> 0x300000 <打成最终的包>"来上载到开始地址为3M的第二数据分区

然后使用之前提供的Bin类就可以直接访问上面的字节了 一个代码例子如

class FontBin(Font):
    def __init__(self, b):
        self.cnt=b.mem32(4)
        super().__init__(b.mem32(8), b.mem32(12)) 
        self.bin=b
...
_font_eng5X8=FontBin(flash.bin.findsub('eng5X8'))

这大概是目前唯一在Micropython中直接访问flash字节的方法 虽然Micropython本身有flash读取块的方法 但是远不如做好内存映射 然后直接读取内存地址来得高效

[修改于 11天22时前 - 2024/04/18 09:26:18]

+3  科创币    warmonkey    2024/04/16 真能折腾啊,效果不错
来自:计算机科学 / 软件综合
9
6
已屏蔽 原因:{{ notice.reason }}已屏蔽
{{notice.noticeContent}}
~~空空如也
Ma3.02的守望
13天14时前 IP:四川
931299

Micropython居然要把const常量全读进内存吗

sticker

image.png

看来用ESP-IDF还是有点好处的😂

引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
rb-sama
12天14时前 IP:湖北
931327

一位ESP32的Arduino和IDF用户路过

我想说的是SPIFFS用mkspiffs工具很容易把一些网页静态资源 htm css 或者jpg之类的文件放在文件系统里面

littleFS现在支持性也挺不错了,外挂4M的flash一般可以留1.3M给你放东西,

调用起来有专门的官方websever库可以用,server.on 注册一个handle 并send文件就行了,IDF同理

Python是一种解释性语言,esp32本质上只是稍微强一些的mcu,很多例化的方法都不够高效

Arduino和idf的库都是开源而且可修改性强,可能会更适合你的应用

引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
m24h作者
11天23时前 IP:上海
931348
引用rb-sama发表于2楼的内容
一位ESP32的Arduino和IDF用户路过我想说的是SPIFFS用mkspiffs工具很容易把一...

访问block设备的文件 和随机访问大数据是两回事. IDF和Micropython又是两回事.

你考虑一个有个文件 比RAM还大 没有编在固件里 没有给编译器RO段信息 即使在IDF里面 运行时才想去随机访问它的某个32位数 也是不可能的 除了用文件打开 不停seek

但是做了内存映射 就可以直接使用指针 地址之类 直接访问了 效率不一样

引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
rb-sama
7天21时前 IP:湖北
931455
引用m24h发表于3楼的内容
访问block设备的文件 和随机访问大数据是两回事. IDF和Micropython又是两回事.你考...

对于挂载在外部flash的文件,其实从底层角度上看,是通过SPI或者QSPI的形式对数据进行读取的

也就是是说,在编译器的内存映射,最后调用的也是flash_wr_block/page/sector or Bytes()

所以即使是随机访问,也可以很轻松越过文件系统,不需要通过fs.on 后seek的方法

而是直接调用SPI外设,采用addr+x 相对偏移地址轻松访问储存在flash中的某一个字节

MicroPyhton里用指针地址访问,还是调用了这个方式,所以在IDF或者Arduino中依然可以用我说的这种方法随机访问

访问特定的块对QSPI 给flash发送24bits地址后,送N个edge的时钟,就能获得N*Bytes的数据块(这是最底层时序)

如果用DMA搬运的方式,理论上块获取效率可以等效于QSPI的时钟速度 Tsummary=Nbofbytes*Tclk


引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
m24h作者
7天18时前 IP:上海
931460
引用rb-sama发表于4楼的内容
对于挂载在外部flash的文件,其实从底层角度上看,是通过SPI或者QSPI的形式对数据进行读取的也...

你这样岂不是更复杂 而且操作flash的SPI 可能会和ROM里面程序 甚至和MCU的功能进行冲突 造成不可预见的麻烦

Micropython的访问 仅仅是调用IDF提供的接口 而指针地址访问 全在虚拟地址上

虽然我想说许多 但是最终还是说 你想简单了 不然实现看看 给个代码或者伪码 或者哪个IDF或者Arduino库函数或者功能 能让你直接访问Flash


引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
rb-sama
7天15时前 修改于 7天15时前 IP:香港
931462
引用m24h发表于5楼的内容
你这样岂不是更复杂 而且操作flash的SPI 可能会和ROM里面程序 甚至和MCU的功能进行冲突 ...

无意冒犯,不要上火,技术讨论,还是回到技术上把🤣

任何编程工具都有其存在的价值,Micropython也不例外,我们仅在此讨论效率efficiency

我首先声明没有任何贬低您使用Micropython这种工具的意思。

-

针对您说的:“不然实现看看 给个代码或者伪码 或者哪个IDF或者Arduino库函数或者功能 能让你直接访问Flash”

👌好的,让我们首先打开乐鑫官网-进入ESP-IDF编程指南,输入SPI flash API

SPI flash API - ESP32 - — ESP-IDF 编程指南 latest 文档 (XXXXXXXXXXXXX)

链接如上,我不截图了,里面有一个tips我复制出来:

关于更多高层次的用于访问分区(分区表定义于 分区表)的 API 函数,参见 分区 API 。

备注

访问主 flash 芯片时,建议使用上述 esp_partition_* API 函数,而非低层级的 esp_flash_* API 函数。分区表 API 函数根据存储在分区表中的数据,进行边界检查并计算在 flash 中的正确偏移量。不过,仍支持使用 esp_flash_* 函数直接访问外部(额外)的 SPI flash 芯片。

分区 API - ESP32 - — ESP-IDF 编程指南 latest 文档 (XXXXXXXXXXXXX)

进入上文描述的esp_partition_*API函数介绍页面,这种函数可以完美高效率执行flash数据块的读写

在esp-idf的体系里,有一种叫做“分区表”的struct概念,它提供了flash分区,为读写主flash数据提供支撑。

以上两个功能,提供了NVS

非易失性存储库 - ESP32 - — ESP-IDF 编程指南 latest 文档 (XXXXXXXXXXXXX)

所以你如果向在主Flash中访问任意一个数据块你可以:

 1:清楚知道你的数据被放在Flash中的哪一个分区,手动分时使用基础API访问数据块,最底层实现

 2:利用esp_partition API实现安全访问Flash非程序分区,这是安全与高效的折衷

 3:利用以上功能构建的非易失性存储库(NVS),它可以提供耐磨和哈希验证更安全一些

鉴于您在主帖中做的工作,相当于在MicroPython环境中实现了类似以上idf库中的轮子。。。

高效又简洁,c代码实现最底层flash读取

所以我说要实现这种随机读取某个偏移地址的字节实现汉字字库这类的应用,在idf上很简单是完全成立的

-

至于在arduino上怎么实现,您参考一下这个pgmspace.h的文档吧

arduino-esp32/cores/esp32/pgmspace.h at master · espressif/arduino-esp32 · GitHub

pgm_read_byte访问起来只需要一句,更是简单到没有朋友😂

您要伪代码,最后送您一个链接,里面有实例代码

Arduino - 利用PROGMEM将数据写到闪存(程序存储空间)_串口 f()宏-CSDN博客

这些都是可以用搜索引擎找到的资料,而且都不存在您所说的那样造成与主程序运行冲突的的情况

希望idf和arduino的以上两个例子,能对您的项目进展带来帮助吧。

-

最后说说为什么我比您熟悉这些,因为我最近就在做调取esp32主flash中数据块显示网页静态资源相关的项目,

Micropython和idf以及arduino我都尝试构建过,看到您帖子就分享下

从我的开发经验来看,Micropython更适合实现实现一些创意类验证的项目,商业化我建议您最好考虑idf



引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
m24h作者
7天10时前 修改于 7天8时前 IP:上海
931470
引用rb-sama发表于6楼的内容
无意冒犯,不要上火,技术讨论,还是回到技术上把🤣任何编程工具都有其存在的价值,Micropytho...

你压根就没有理解 我做的东西 具体实现了什么能力

首先 无论是esp_flash_xx()还是esp_partition_xx micropython都早搬过去了 如果够用 我又何必操作MMU 这些都是块操作 而不是直接地址访问 

pgmspace.h的功能 与micropython的machine.memXX() 一样 都是访问虚地址 而不是真实地址 对于AVR或者STM32是可用的 对于esp32是不行的 你连esp32的flash 在什么指针地址都不知道 更不可能知道它是不连续的 是分段操作的

你既然在做调取esp32主flash数据块什么的 你不妨考虑一下 你能否单独读取flash的某个字节  高效地 就好像用指针一样访问 而不是为了某个字节  就调用一次什么函数读取一大块 (或者最多把读取字节数设为1 但是这样就算高效吗) 如果你发现你做不到 你才明白要学什么

正因为我知道你不知道 我又怎么可能上火  或者觉得冒犯呢 看了你的东西 我只是感到白期待了 毕竟我也想知道 有什么我不知道的库函数能够不需要自己操作MMU表 但遗憾的是 看来乐鑫并未提供出来

如果你想深入点esp32  其实这个文章的知识点是很少有人提起 更应该是没有具体其他实现案例的 连说明文档都很难找到的 你根本就不会在官方公布的东西里找到细节 包括MMU表的地址等等 (我也是翻IDF源代码才找到一些痕迹) 可惜了 明珠暗投

深入 请深入 不要停留在肤浅的随便一观上


引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
rb-sama
6天21时前 IP:湖北
931481
引用m24h发表于7楼的内容
你压根就没有理解 我做的东西 具体实现了什么能力首先 无论是esp_flash_xx()还是esp_...

一大早起来,看见您又把几个非常有侵略性的词汇赫然放在在您的回复中 sticker

“你压根就没理解”

“你连xx都不知道”“你才明白要学什么”

“你根本就不会在xx里找到细节”“有什么我不知道的xxx能xxx”

我就知道糟了,昨天我的安慰和叠的buf没有起到作用,您又一次的应激了,

您是不是误解了我的意思?

我从来没有说你懂的少,而您一直在证明您自己比任何人都懂得ESP32。这就是问题的关键。

首先我在上一楼给您提供了三个方案,

1、2、3

分别针对的是裸机SPI访问FLASH中数据块儿,封装一层的裸访和封装成NVS的访问。

最后提供了一个pgmspace.h给您,结果您抓着pgmspace中访问的是虚地址的这事儿又开始证明我不懂了

这个事儿整体上就比较诡异了,合着我发的裸机SPI访问FLASH您是一点儿也没看见? sticker

鉴于您对我做有罪推定,我也可以合理的对您的语文理解能力提出质疑,阅读理解在您学生时代一定是个弱项吧。

-

我早就向您强调了,idf下可以实现单字节读取,甚至是QSPI多字节读取

因为基于W25Qxx的特性:

通过03h读取指令,写入24字节的地址,master发送多个clk可以实现对单字节或者多字节的读取

这个耗时的时间就是四个clk*8+Ndata*8,8*(4+1)*1/80M=0.5us,QSPI 4字节之内也一样。

既然您知道mpy是采用“esp_flash_xx()还是esp_partition_xx micropython”封装

哪一种方式的效率和裸机访问能媲美?您自己抓着非用mpy不放,纠结虚拟地址和实地址,

不如您先做到4字节0.5us效率?

我再强调一遍,可以裸机访问可以裸机访问可以裸机访问,我不介意再贴一遍方法

SPI flash API - ESP32 - — ESP-IDF 编程指南 latest 文档 (XXXXXXXXXXXXX)

本着友好交流原则,您可以再多看几遍,再回复我。

否则我是否可以质疑您根本不关心或者完全不懂硬件是如何实现FLASH读写这个过程的?

-

我从来没说过您做的工作没有价值,也没有说您做的工作无法表示您比谁都懂ESP32

我的回帖中,永远针对的核心是您说的实现网页静态资源的存取应用,或者单字节/数据块的调用读取

乐鑫官方给出的API无论是文件系统还是裸机读取都是可以满足您说的该种要求。

鉴于您在帖子中展现出的“卓越”阅读能力,

我打个比方:那就是您说到7楼必须走楼梯,我告诉您也可以坐电梯,能明白吗。

就像我告诉您裸机读取单/多字节或者文件系统可以稳健、高效实现您说的字库、静态资源读取。

抱歉我真的无法从以上观点中,解释出您怎么推断出我不懂MMU和esp32地址分配的。

-

最后我再次肯定您在主楼中分享的CODE工作,这为mpy上实现直接访问flash提供了轮子。

但我同样也对您“良好”态度和对其他技术路径的不包容表示遗憾,

我无意也没资格教书育人,但我还是想说,一个项目的实施有多种方法,保持一个开放的态度

对他人提出的方法好好的思考、理解,

而不要一贯的认为自己做出的工作是最好的,也不要觉得自己比其他人都懂,这样会错过一些好的建议

《三体》中,有一句很多人都耳熟能详的名句“弱小和无知不是生存的障碍,傲慢才是”

在这里,送给您品鉴。







引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论
m24h作者
6天18时前 修改于 6天18时前 IP:上海
931484
引用rb-sama发表于8楼的内容
一大早起来,看见您又把几个非常有侵略性的词汇赫然放在在您的回复中“你压根就没理解”“你连xx都不知道...

没办法 我说话直 而且敏感点在知识上 不在戏剧用语上 也不在外交上 所以 懒得多说 你爱咋地咋地


引用
评论
加载评论中,请稍候...
200字以内,仅用于支线交流,主线讨论请采用回复功能。
折叠评论

想参与大家的讨论?现在就 登录 或者 注册

所属专业
上级专业
同级专业
m24h
进士 学者 机友
文章
48
回复
804
学术分
1
2020/01/22注册,8时9分前活动

无聊地过着没有意义的日子 偶尔期待一点意思

主体类型:个人
所属领域:无
认证方式:手机号
IP归属地:上海
文件下载
加载中...
{{errorInfo}}
{{downloadWarning}}
你在 {{downloadTime}} 下载过当前文件。
文件名称:{{resource.defaultFile.name}}
下载次数:{{resource.hits}}
上传用户:{{uploader.username}}
所需积分:{{costScores}},{{holdScores}}下载当前附件免费{{description}}
积分不足,去充值
文件已丢失

当前账号的附件下载数量限制如下:
时段 个数
{{f.startingTime}}点 - {{f.endTime}}点 {{f.fileCount}}
视频暂不能访问,请登录试试
仅供内部学术交流或培训使用,请先保存到本地。本内容不代表科创观点,未经原作者同意,请勿转载。
音频暂不能访问,请登录试试
支持的图片格式:jpg, jpeg, png
插入公式
评论控制
加载中...
文号:{{pid}}
投诉或举报
加载中...
{{tip}}
请选择违规类型:
{{reason.type}}

空空如也

加载中...
详情
详情
推送到专栏从专栏移除
设为匿名取消匿名
查看作者
回复
只看作者
加入收藏取消收藏
收藏
取消收藏
折叠回复
置顶取消置顶
评学术分
鼓励
设为精选取消精选
管理提醒
编辑
通过审核
评论控制
退修或删除
历史版本
违规记录
投诉或举报
加入黑名单移除黑名单
查看IP
{{format('YYYY/MM/DD HH:mm:ss', toc)}}