一次惨绝人寰的编译(下)

接着上一篇,来说说这次在 Linux 上编译时踩的各种坑。

因为之前已经在 Windows 和 Mac 上搞过一轮,加上有 AI 老师傅指点,我以为这次只是跑两个脚本那么简单。没想到一开始就翻了车。

首先是确定使用哪个 Linux 版本。按照 GPT 老师的建议,最好用 Electron 1.8.0 发布时的官方环境 —— Ubuntu 16.04。可惜我手头那台 Linux 机器早几年就升到了 20.04,不太想为了这个重装系统。而且 Electron 编译起来非常庞大,放 VM 里跑也不现实。

这时 GPT 老师突然灵光一闪:为什么不用 Docker?
我以前没怎么碰过 Docker,以为它只是 Linux 的进程隔离工具。结果发现连 Mac 上也能跑,于是就装了一下。很顺利地创建了一个 Ubuntu 16.04 的环境——这算是 VM 吗?还是进程隔离?搞不清楚,但总之新买的 M2 机器终于有了用武之地。

接下来运行 scripts/bootstrap.py,结果立刻报错:文件下载失败。查了一圈才知道,Electron 1.8.0 已经是上古版本,当年开发者把一些关键文件放在 Amazon S3 上。可惜这些 S3 容器现在都挂了。

换句话说,当年 Electron 相较于 nw.js 的一个“先进设计” —— 不需要自己编译 libchromiumcontent,而是用官方共享的预编译版本来加快流程 —— 现在彻底失效了。

没办法,我只能亲自编译这个庞然大物的 libchromiumcontent。先是在 Docker 里尝试拉取指定版本的代码,然后添加 Electron 的 patch,准备编译。可惜这只是我的美好想象,第一步拉取代码就失败了。

原因是 Google 的代码实在太大,他们还特地设计了一个工具来同时sync几十个仓库。Docker 给的内存又太小,每次clone到 80% 左右就崩了。

找到了问题,就把 Docker 的内存和硬盘配额都拉大。总算把所有代码clone下来了。好在 Google 还算厚道,老版本的代码都还在,接下来的编译过程还算顺利。

编译完成后,把生成的 lib 文件重命名为指定的 commit id,丢回 Electron 的目录,bootstrap 就能跳过失败的下载步骤了。

当然,问题还没完。很快又出现了依赖包下载失败的问题,这次是某个 sysroots 压缩包。问了 GPT,才知道 Linux 编译为了避免头文件版本不一致,引入了类似 Windows SDK 的概念,把一整套头文件、库文件打包起来统一管理。

好在这些文件还能在 GitHub 上搜到,我重新创建了一份,填上去就行了。

终于,bootstrap 跑通了,可以开始编译 Electron 本体了。

意外的是,编译和链接过程都异常顺利,顺利得让我有点害怕。果不其然,最后生成的 binary 文件不太对劲,有整整 2G —— 显然还没 strip。Electron 提供了一个 create-dist.py 脚本来处理发布包,但它默认还要执行一些文档生成的流程。只好小改一下脚本,去掉这些额外步骤,然后就得到了大小正常的发布包。

下一步是实机测试,我拿到 Ubuntu 20.04 环境中一测,果然又报错,提示缺少各种动态链接库。

于是我开始一一排查,把缺失的库都摘出来,统一放到发布目录的 lib 文件夹中,然后套一层启动脚本,把 LD_LIBRARY_PATH 指向自己的 lib 目录。终于,程序能正常运行了。

然后是重头戏:部署到 Steam Deck 上看看吧。果然还是报动态库缺失的错误。继续手动补,直到 Electron 成功打开窗口,游戏主界面也能正常显示。

成功了吗?我正想叉会儿腰,结果一进游戏场景直接报错……

这才发现,Steam Deck 的桌面模式和 Steam 启动环境之间还有些差异。折腾半天,终于意识到可能是编码的问题。错误日志提示是某个汉字命名的文件找不到。

我打印了两种环境下的变量,果然,在 Steam 的启动环境中,LC_ALLLANG 被设为了中文 GBK,而我整个程序用的都是 UTF-8。

知道原因就好办了:在启动脚本中强制把 LANGLC_ALL 设成 en_US.UTF-8

最后检查了一遍工作路径、存档目录这些,没再出啥问题。

Linux 版本,终于搞定!


总结:

  1. Docker 非常好用,但拉取大仓库还是得调高内存和硬盘。
  2. Linux 的编译环境必须固定头文件版本,打包发布时还得附带所需动态链接库。可以用 Steam Deck 进行真实环境测试,Steam 启动器自带部分常用库,只需补少数缺失项。
  3. 一定要完整测试整套环境。Linux 环境变化太多,Steam 与桌面环境也可能差异极大。

一次惨绝人寰的编译(上)

为了让一个老游戏兼容 Mac 和 Steam Deck,我前阵子动手做了个新版本的构建。游戏是用 JavaScript 写的,跑在 Electron 上。既然用了“跨平台”的技术栈,那它至少应该在这种时候派上点用场吧?

任务目标很明确:编译 Electron 1.8.0 的 macOS 和 Linux 版本。

一开始我以为构建 macOS 版本需要用 Apple Silicon 的设备,于是入手了一台最低配的 Mac Mini M2,8GB 内存、256GB SSD,能省就省。但上手之后才发现,macOS 提供了一个叫 Rosetta 的 Intel 兼容层,大多数老程序在 M2 上运行完全没问题。换句话说,我这台新电脑根本没派上用场。

于是我翻出了多年前的老设备——Mac Mini 2014,发现当年我确实在这台机器上编译过 Electron 1.8.0。稍微调整下配置,理论上是可以继续用的。信心满满地开工。

回顾旧文档、准备构建环境、跑脚本,一切按部就班。但第一轮构建很快就失败了,脚本一运行就报错。问题出在 Python:Electron v1.8.0当年依赖的是 Python 2.7,而我在 2023 年升级了 macOS,系统默认已经切换到了 Python 3。很多老脚本因此不再兼容。

于是退回 Python 2.7,再次运行 build.py,这次终于顺利进入构建流程。前期编译过程很快,毕竟历史中间文件还在。可没想到,等到了 link 阶段,机器突然开始变卡,几分钟后系统直接重启了。

我试了几次,都是在 link 阶段死机。最初还怀疑是老机器硬件出问题了。但联想到死机总发生在同一阶段,我猜可能是资源耗尽。打开任务管理器观察,果然在崩溃前有异常磁盘写入,某个系统进程(kernel什么的)在短时间内写了三十多个 G数据。但编译目录并没有生成相应的大文件,看起来不是 link 阶段输出的问题。

我接着观察内存使用情况,发现一个叫 dsymutil 的进程在这个阶段疯狂占用内存,远超物理内存限制,很可能触发了大量 swap 操作,最终导致系统不堪重负直接重启。macOS 在内存耗尽时没有进行优雅处理,反而选择硬重启,这倒是出乎意料。

dsymutil 是什么我并不清楚,于是请教了 ChatGPT。它告诉我,这个工具是 LLVM 工具链的一部分,用于生成调试符号(debug symbols)。考虑到 Electron 本身体积庞大,符号信息数量可能非常可观。显然,dsymutil 没有能力处理这种规模的符号,最终把系统拖垮。

既然问题出在生成调试信息,而 link 阶段其实并不依赖它,我想到一个简单粗暴但有效的办法:制作一个同名空脚本,替代系统中的 dsymutil,让它直接跳过这一步。

具体做法很简单:写一个只包含 exit 0 的 shell 脚本,命名为 dsymutil,赋予可执行权限,放进 $PATH 的前列。这一改动之后,构建流程终于顺利完成,link 阶段不再崩溃。

剩下的收尾工作就相对平淡:为了发布到 Steam,还需要处理 macOS 应用包中的 symbolic links,把它们转换成实际文件,避免打包时出现兼容性问题。另外也踩了 macOS 的路径处理一个坑:JavaScript 获取到的是真实路径而非 symbolic link,对路径逻辑略作调整后问题解决。

Mac版本处理完之后,我打算着手构建 Linux 版本,以支持 Steam Deck。听起来似乎没什么难度?嗯嗯,我得先写到这儿了,牵扯到electron这坨***肯定简单不了,Linux的事情且听下回分解吧。

想不明白

我越来越不理解现在的软件开发了。

举个例子,多年前我用Unity做项目,随便干点啥就要重新导入。导来导去我的硬盘和精神都有点受不了,研究了半天,装它那个什么鬼cache-server。装这个东西就得装nodejs,然后还得搞成service要不然就非得挤占我的c盘空间,有一种按下葫芦浮起瓢的感觉。

总之就是浪费了不少时间,总算导来导去也不怕了。然而我就想不明白,最应该解决问题难道不是“资源总在导来导去”吗?假如一个人吃不下饭是因为拉肚子,难道解决方案不是治疗腹泻吗?怎么搞得好像“打造豪华厕所让人一边腹泻一边吃饭”变成了解决方案……

但是我也能部分理解,就是大厂开发的KPI很重要,不搞点事情出来,直接把问题完美解决了,怎么体现自己的impact?没办法,寄人篱下我也只能凑合用,就这样一路从Unity2018用到了6000……

最近开始我总觉得不太对劲,似乎“缓慢的导来导去”的症状逐渐增多了。我的硬盘已经从SATA接口一路升级到NVME,速度早就翻了好几番,导入的时间没有明显增长,但是总觉得不应该这么慢。于是我再次开始看这个CacheServer是不是出了问题。

一看就发现不对:这玩意儿早就不干活了,后台Service常年停止状态。以为是Windows的锅,我尝试重新启动服务结果不成功。仔细研究了一下,原来这个鬼unity-cache-server只能跑在特定的Nodejs-12.x版本下。我不知道啥时候升级了Nodejs之后,它就静静的嗝屁了……

行吧,我有nvm,专门给这位大人装个指定版本的Nodejs,然后一顿调试。最后终于成功启动,再打开Unity6000看看,还是连不上。我灵机一动翻出当年装它的时候用的Uinty2018,没想到这个旧版本的Unity一连就成功了。

这下搞笑了,网上一查原来整个unity-cache-server都已经deprecated。这是前一波人被开掉了还是财富自由跑路了啊。总之,现在要装的是Unity Accelerator!还好这东西是独立的程序,下载之后点点安装就好了。

原来这么些年我以为是cache-server在帮我加速,其实不然。加速的来源是我的钱包,换成NVME才是越来越快的根本原因。软件工程师每天就研究怎么重新发明轮子提升impact,而硬件的发展才是实打实的。作为码农,我泪流满面。

PS:这个故事到这里就应该结束了,但还有个插曲。Unity Accelerator做了个漂亮的dashboard,直接占用了本地的80端口。这怎么能忍!还好它可以让我修改,我就改到了6000……没想到这个平淡无奇的端口居然是x11的保留端口,换了之后所有的浏览器都不让我访问了。这下想改回来都没辙了,最后靠神秘的Chrome命令行参数允许了这个端口,再次通过dashboard改成了别的。我们软件工程师在“搬起石头砸自己的脚”方面果然是无敌的。

好了,故事终于结束了。下一次break将在什么时候到来?让我们拭目以待吧!

烧坏一个喇叭之后

很久以前买了个液晶驱动板来驱动废旧笔记本上拆下的屏幕。这个驱动板有个问题,它只有4针的喇叭输出口,没有3.5mm耳机输出口。买的时候我看着有个耳机口就没在意,收到货发现居然是音频输入口,用来和VGA联动的。这可误会大了,我总不能一开机就功放吧。当时我灵机一动,装模做样的看了看喇叭输出的电路板,找到了里面的功放IC。

既然功放IC输出的是喇叭的声音,它的输入自然就是小功率的音频信号了,我直接拿来给耳机输出声音,岂不妙哉

于是就各种飞线,把INL和INR再加随便焊接的一根地线引出。还好当时没有直接连到我昂贵的耳机上,而是先用喇叭测了测响度。居然出声了,我一阵得意,很快就闻到了焦糊味。眼看着小喇叭上出现了一个烧红的圆圈,振膜开始冒烟,吓得我赶紧断电,自此不敢乱改造音频设备了。

最近想起这事儿,又仔细研究了一番,才发现我真的已经把电路知识还给老师了。我测了INL和INR,它们确实是左右声道的小功率模拟信号,但是它们和GND之间有一个固定的电势差。也就是说,我把GND和INL/INR接到喇叭上,不仅给它提供了交流电,还带了一部分直流电。

喇叭本身就是线圈+磁铁,内阻非常小,通交流的话产生磁场引起震动做功。通直流的话……基本就是个电热棒,所以很快就烧掉了。要是直接连到耳机上,我估计会特别心疼。所以音频信号输入的地方会接一个电容,隔绝直流部分,还是很有必要的。

既然不能直接用,我也试了下用电容隔绝直流信号。可能是电容的容量不对或者别的什么原因,过滤之后的输出已经不足以驱动耳机了。我试着把这个输出连入下一个功放IC,还是可以听到响的,输入没问题。但功放输出还是喇叭,属于白瞎。

既然从直接拿IC的输入不行,我干脆把喇叭的输出传给耳机,岂不妙哉

我研究了一下电路,大概耳机是16欧/32欧的阻抗,而喇叭上写着8欧。初中水平即可算出,串联一个30多欧的电阻分压,然后再并联一个10欧电阻分流,保证耳机这边不会分到超过25%的电压,差不多功率就是几十毫瓦,应该没问题了。

接下来,我把喇叭的4根线分成3组:R+/L+接耳机的R/L,L-/R-都接耳机的GND,大功告成上电!居然又响了,虽然底噪很大但总归是响了,我又得意了起来。接下来我眼看着手里的线材自己把上面的热缩管烫小了,什么情况啊!我一模好烫,赶紧断电!还好耳机没事儿,全靠串联的电阻,它承受了太多……

可是为什么电阻这么烫?难道还有直流在里面?我掏出万用表测了半天,终于发现了端倪。R+/R-之间,L+/L-直接确实没有直流电压。但是L+和R-之间居然有4.9v的电压,换句话说R-和L-并不在同一个电平,二者是不共地的。

天哪这么基础的事情我居然没想到。喇叭输出4根线是有原因的,因为功放IC并没有保证R/L是共地的,这和3根线的耳机完全不一样。如果我把4根线变3根,那必然有一边的耳机回路里跑着直流电。还好有电阻分压,否则先烧起来的就是耳机了。

后来整理了线路,发现4.9v的直流消失了,可能是哪里接错了。但R-/L-确实不共地,因为放大电路用的是BTL功率放大器,而不是OTL输出的共地信号。Google上有一篇很老的专利文章就讲的是类似的需求:https://patents.google.com/patent/CN2759091Y/zh

这下我就没辙了,彻底暴露了自己电路常识全忘光的情况。我查了查,如果不共地的喇叭转3线耳机,需要更为复杂的电路。可能要音频耦合变压器来传递交流隔绝直流,做到R/L共地。否则就得接4线耳机,但是这样一来和喇叭区别不大,手头耳机全都用不了。

先就此作罢,总结一下:

1、喇叭和耳机的输出不能带直流,否则就变电热棒。

2、喇叭4线输出的负极不一定是共地的,和3线耳机有很大区别。

3、上课的时候好好听讲就不会烧掉喇叭了。

Typc-C充电线中的电阻

最近发现手头的充电线都磨秃噜皮了,打算更新一波。正好去年PD 3.1这个新规范的发布,早就被amazon推荐了很多充电头,就研究了一番。可能是因为5年没有更新手机了,一直没有关注这个领域。没想到USB Type-C接口这几年进化出许多花头,不了解的话很容易花冤枉钱买到不能用的线材,甚至有可能损坏设备。

废话不多说,记录一下我了解到的关于线材的一些知识。不说数据传输速率,只说充电时候线材和接口上的电阻相关的事情。

TL;DR

  1. A2C线没有芯片,大致分带电阻和不带2类。带电阻有3种,10k和22k可能有烧电源风险,不合规。只有带56k欧上拉电阻的线材是正品,防止C口设备烧坏老电源。
  2. C2C线种类非常复杂:没有芯片的线材支持3A电流,兼容性好。带e-marker芯片的线材,支持5A电流,但它自带下拉电阻,和不规范的C口设备不兼容(初代树莓派4)。
  3. 在标准里C口线材(包括A2C)至少支持3A电流。所以标称60w或者3A线的C口线材是最普通的,应该很便宜即可买到,快速充电并不需要昂贵的线材。
  4. 只有用合规的C2C线连接的设备和电源,才可以根据PD充电协议协商,最多输出240w的功率。如果中间经过A口B口甚至pin不全的C口转接头,都会导致无法协商。目前60w以上的充电需求比较罕见,140w是家用电子产品最高的功率要求(苹果笔记本)。

首先是接口类型

最常见的USB Type-A口就是那个方口。其中2.0是4个pin,3.x是9个pin。但是充电也用不到那么多,所以在充电的场景中,可以认为2.0和3.x的A口是等价的,都只有2个pin生效,也就是电源正负极。

接下来是USB Type-C接口,这个就复杂了,它有24个pin。由于c口正反两面都可以接,在充电的场景中只有一半的pin在起作用。特例是两面各有一根的CC引脚,充电时候这两根会同时使用用来查询方向和电压等。

其他USB类型的口就不说了,市面上带C口的线基本就这两种:两头都Type-C的C2C线,或者一头A一头C简称A2C的线

先说说A2C的线

正如名字所写,它是有方向的线。必须从A到C,用来把C口出现之前的USB A口电源(包括充电器,电池,或者电脑等),接到带有C口的设备(例如手机,树莓派)上。在前些年C口刚出现的过渡时代,很多充电器都会送这种线。也有许多使用C口充电的小家电会送这种线,最著名的应该就是任天堂Switch游戏机了。

A2C的线内部结构很简单,就是A口到C口的映射。由于C口有24pin,充电只需要正负极,映射2根电源线绰绰有余。所以最直白的线材就是两根铜丝直接连过来

但是根据USB IF定义的C口规范,这样做是不够的。因为带有C口的设备可能需要各种各样的电压和电流,它需要了解电源能否提供这些不同的电压电流。设备怎样才能询问电源呢?一般来说,协商是通过C口新增加的CC信号线和各种芯片来实现。这就引出了两个问题:

1、CC信号线要怎么接?
2、这些芯片在哪里?

针对问题1,A2C的线材,在A口那一端是没有CC信号线的。问题2的芯片,是为了应付更复杂的情况,只有C2C的线材才可能有,A2C的线材中肯定没有芯片。啥都没有的话,设备如何通过A2C的线材询问电源规格呢?答案是使用上拉电阻

直白的说,就是在A2C线材的C口部分,把它的CC引脚和VBus引脚通过一个电阻连起来。加了这个电阻之后,A2C线材才符合USB IF的规范。因为电阻是从CC信号线接到VBus电源线的,因此被叫做上拉电阻。由于我的电子电路知识都还给老师了,简单认为接到电源的电阻叫上拉即可。

为什么一根A2C的供电线里面会有电阻呢?

之前没想明白的我发出了这样的疑问

答案也很简单,本来在C2C口的情况下,上拉电阻并不在线材里,而是做在C口电源的母座里。旧的A口电源当然不会有这个上拉电阻,更何况A口没有CC引脚,也无处可接。A2C线是为了让C口设备使用A口电源,所以它为了模拟“电源通过上拉电阻报告自己供电能力”的行为,把这个电阻做到了线材里。

当C口线材接到设备的C口之后,设备会给CC和VBus加电压,然后通过上面电流的大小来判断电用的供电能力。理论上A2C线材的上拉电阻应该是56k欧(规范强制)。因为56k的电阻在C口供电规范里表示默认功率,USB2.0的时候是5v*0.5A也就是2.5w功率,或者USB3.x的默认5v*0.9A也就是4.5w功率。最差的A口电源也可以输出这个功率,这就保证了双方和谐共处。

C口供电规范里,阻值还可以是22k欧,对应5v*1.5A也就是7.5w的功率。或者10k欧姆,对应5v*3A也就是15w的功率。无论哪种阻值,采用这种协商方式的电源电压都是5v,只是输出电流有变化。

怎样才是安全的

那A2C把阻值设成10k欧来模拟15w的电源可以吗?要知道这是线材在模拟电源的行为,A口那边的电源对此一无所知。而设备查询到电源的不同能力之后,可能会向电源请求更大的功率输出。确实一些A2C的线材会接10k欧的电阻(违反规范),一步到位模拟5v*3A的15w电源。但是15w电源在A口时代非常罕见,如果设备真的请求了这么大的电流,可能会烧坏电源(我猜不会烧坏设备,毕竟是它自己请求的,但谁知道呢……)

正因为如此,任天堂在官方的指南中写着请使用带有56k电阻的线材。可是它没有明确说这是A2C的线材的需求,让许多玩家非常困惑。贩售线材的商家也不一定知道这么细节的规定,毕竟焊接的电阻存在于A2C的C口内部,肉也无法分辨。所以我们甚至能看到有些C2C的线号称自己带56k上拉电阻,这就完全是扯淡了。

56k电阻的A2C线材虽然安全,但是充电速度就慢了。强调下不是因为电阻大哦,要是供电线路里加了这么大56k的电阻,电线还能通电吗?这个电阻是在线材C口那端,把自己的CC引脚和VBus引脚连接了电阻,并没有放在供电引脚上,所以不会参加供电的过程,只是用来模拟握手协议。

我手头的大部分A口电源都能输出2A,10k电阻肯定是过头了有风险,理论上22k或许也能用?然而想要随便一根A2C线,搭配任何A口充电器,还是得56k电阻的线材。USB规范上写了,需要更高的电流可以通过其他协议再商量:

“The value of Rp shall indicate an advertisement of Default USB Power (See Table 4-24), even though the cable itself can carry 3 A. This is because the cable has no knowledge of the capabilities of the power source, and any higher current is negotiated via USB BC 1.2.”

(正常的A口电源应该只有5V的规格,所以A2C线也不需要模拟其他规格的电源电压输出,上拉电阻就够了。那种什么电阻都没加的直连线材,是不符合规范的。我猜设备遇到这样的线材,应该只会请求最低的功率。那它是否等价于56k电阻的线材我就不清楚了,也许有的设备检测到cc没有上拉电阻,压根就不工作。这就是为什么某些A2C线有时候无法给C口设备充电?此部分纯瞎猜的)

但无论如何,A2C的供电部分双方都是直连的。具体会请求多少电力完全看设备了。比如有些带C口供电的台灯,我猜里面没有什么协议啊握手之类的芯片,放什么电阻都一样。单纯就是要5V电压,功率全看灯泡的瓦数了。

Google工程师烧了笔记本?

我在收集信息的时候看到有人提到了错误电阻规格的线会烧设备,证据就是这个谷歌工程师烧掉笔记本的故事,说得有眉有眼。实际上这也是个myth,Google工程师烧笔记本确有其事,但和上拉电阻规格无关。

这位工程师因为此事出名,后来一直在review各种C口的线材。可惜他发的东西都在Google+上面,已经随着Google+关站一起灰飞烟灭了。具体到烧笔记本的事件,可以看看这两个连接:PCMag的新闻报道,以及他当时发的贴子的快照。

很多报道都没提到细节,这是一根USB A2C的线,只有PCMag的编辑详细引用了这段话,表明这线根本就是搭错了引脚,A口的地线接到了C口的电源,而A口的电源接到了C口的地线,完全反了过来。估计是电源反接导致设备某些器件被击穿,和上拉电阻没有任何关系。

“I directly analyzed the Surjtech cable using a Type-C breakout board and a multimeter, and it appears that they completely miswired the cable. The GND pin on the Type-A plug is tied to the Vbus pins on the Type-C plug. The Vbus pin on the Type-A plug is tied to GND on the Type-C plug,” Leung described.

反接5v会导致的后果ChatGPT有不错的描述

USB电源线的5V电源如果反接,即正负极颠倒连接到设备上,理论上确实有可能导致设备损坏。USB接口设计有标准的针脚布局,其中包括为设备提供电源的Vbus(正电压)和GND(地线)针脚。如果这些电源线被错误地接反,就可能在设备内部产生不正确的电流流向,导致以下几种情况:

即刻损坏:电源反接可能会立即损坏设备内部的电子组件,因为这些组件设计时预期电流将以特定方向流动。反向电流可能会导致电子元件过热、短路或永久损坏。

保护电路触发:一些设计良好的设备和系统可能包含保护电路,如反向极性保护、过流保护和短路保护等,用于防止因错误连接而引起的损坏。如果存在这样的保护机制,设备可能会在电源反接时关闭或断开电源,从而避免损坏。

无反应:在某些情况下,如果设备具有非常强大的保护电路,可能会在没有任何损坏的情况下简单地不响应反接的电源。

绝大多数现代电子设备都设计有一定程度的保护措施来防止因电源反接而导致的损坏,但这并不意味着所有设备都能在电源反接的情况下安然无恙。依赖设备内部的保护措施并非一个好的做法,因为不是所有设备都能够在这种情况下保护自己。

最佳做法是始终确保正确连接USB电源和设备,遵循设备制造商的指南和推荐,以避免因错误连接而导致的损坏。如果不确定,最好是检查设备文档或咨询制造商,以确认设备是否具有防止反接导致损坏的保护功能。

然而不合规格的上拉电阻确实很不安全,根据充电头网的这篇文章

“由于转接线和充电头可能不是配套的。如果充电头端的USB Type-A Receptacle只能提供5V@900mA,那么即使转接线Type-C Plug端Rp声明为 5V@3A,也没法给手机最大提供5V@3A的充电功率,还有可能损坏充电头。”

根据这个日本评测网站的说法,也确实有部分A2C线材会真的连接10k或22k的电阻,只能希望电源和设备都给点力,保护好自己了。

总结一下

https://www.chromium.org/chromium-os/cable-and-adapter-tips-and-tricks/

从C口诞生开始就有非常多的错误信息和myth流传在互联网上。甚至Chromium都有一个专门的页面来澄清,但是格式错乱了。我把它翻成中文留在这里。名词定义:

Resistor Rp:(上拉电阻Rp,接在VBus和CC引脚之间,就是接着电源的电阻)

Resistor Rd:(下拉电阻Rd,接在GND和CC引脚之间,就是接着地的电阻)

ID 引脚 :(只有micro-B口里面有,用于OTG连接)

Plug:公头,就是凸出来的那种,很多地方也称为male,插头。

Receptacle:母座,就是凹进去的插槽,很多地方也叫female,母头。

Open:就是开路,表示这个引脚悬空什么也没连接。

以下表格描述的都是一边C口一头其他口的Legacy Cable and Connector,其C口内部的电阻情况。

线材类型C口电阻ID引脚USB-C规范
C公头 <-> USB 3.x A公头(9pin)56 kΩ RpN/ASection 3.5.1
C公头 <-> USB 2.0 A公头(4pin)56 kΩ Rp N/ASection 3.5.2
C公头 <-> USB 3.x A 母座(9pin)5.1k Ω RdN/ASection 3.6.1
C母座 <-> 各种A/B公头规格不允许Section 2.2
C公头 <-> micro-B 公头 5.1k Ω RdOpenSection 3.5.7
C公头 <-> micro-B 母座56 kΩ RpOpenSection 3.6.2

虽然不合规,实际上C母座转A或者B公头也可以买到。使用上要注意风险,因为接入A/B母座转出来的C母座并不是真的C母座(想也不可能啊,pin数量都差好多)。我买来只用于转接5v充电的A2C,或者USB 3.x的5Gbps数据传输的C2C。相当于把A2C线变成了的A2B线,或者把C2C线变成了A2C线,算是线材降级?

Type-C之前的时代,USB线只要接上总是可以用的,线材上没有花活儿只有电缆,复杂的东西都在设备上。这个时代已经过去了,使用带Type-C口的线,或者接入C口的母座都得注意。

得空再写C2C线的各种情况。

TBC……

MAUI还没入门就放弃

之前听说.NET终于把名字统一了,可喜可贺,我终于不用再纠结.NET Core和.NET Framework哪个新哪个旧了。之后MS在统一的路上继续狂奔,决定把UI也统一了,说是出了个叫做MAUI的框架。

论UI开发框架,MS的开发部门真是研发了好几辈子。之前有WinFrom还有WPF,古早的MFC之类的玩意儿,都是长江后浪推前浪,一代不如一代浪。现在谁不用javascript+html写UI谁就是boomer,手机呼机商务通都可以用的东西各家都在搞,MS家的东西已经不耐看了。但MS野心显然不止于此,大概是因为移动平台的地位失去了,想找回场子,就弄了这么个框架。老调重弹,还是号称一次写码到处编译(亦读作修锅),架构图也是老套路,顶层一大块MAUI包圆了,下面各种模块支撑,叠了三四层最后是各种平台。

我简单理解了下,它是把以前的Xamarin收编了,然后在各个平台上实现了.NET的这套runtime,就能把App跑在各种地方了。布局和MVVM这类东西,还是原来WPF的那套,写起来也主要靠XAML(写到这里,我的小拇指已经因为不停的按Shift书写各种全大写的缩写名称搞得开始酸痛了)。

这听起来没什么新意嘛,可能是我已经老了或者没搞懂?之前也知道Xamarin,但这个名字我一直不喜欢,主要是不知道该怎么念。.NET就已经够奇葩了,Xamarin又是怎么起出来的?无论如何,新的MAUI来了,该怎么念呢?猫艾?Whatever了……

打开tutorial,过了一遍,总之先装VS2022,再装MAUI开发工具集。不管我愿不愿意,20多个G就没了,Android和iOS的一大坨东西被塞进了c盘。要知道我本地已经装了好几个Android SDK,它就不能先别装让我选个本地的版本吗?还有iOS又是什么鬼?已经能在Win上面交叉编译iOS的程序了?我不懂,但我大受震憾。

好在装起来虽然慢但也简单,等进度条跑完,打开IDE创建一个HelloWorld项目,我也来尝尝鲜!MAUI,启动…………哎不对报错了。再启动………………哎呀说要先打开Windows10的开发者模式。什么我的PC还支持这种模式?我平时各种写代码debug爬数据跑ai注入dll原来都不算开发者,只有MAUI才让我明白自己一直用的是门外汉模式。

行吧,开了之后咱重新来过。就假装我的3900x+4090RTX是个平板电脑好了:开发者模式MAUI,启动!!!!

然后就继续报错了,这次报错连推荐解决方案都没有,出现了微软的传统艺能:0x8adsaf123DSAF123文件找不到错误,总之就是看不明白,中英文夹杂的报错信息显得异常结棍。我不服又能怎样?ChatGPT根本不懂这新玩意儿,还得Google来帮忙。

原来报错是因为我的项目放在subst虚拟出来的盘符上,可能deploy的过程需要管理员权限,管理员看不到我的个人用户subst出来的东西,就挂了。行吧可以理解(理解个屁啊!编译EXE也要用到高权限了吗?但无论如何,换到普通盘符下面打开项目,终于可以MAUI启动了。

一番折腾,出现了蓝紫色的画面,除了启动过程略长以外,中规中矩的平板App风格。我跑到build目录一看,好家伙,各个平台每种一个目录。打开Windows的目录,里三层外三层,居然生产了250多个文件将近300m的各种不明就里的dll和exe。

我的天哪!!!

我的天哪!!!

我的天哪!!!

比electron这坨abomination还要吓人呢。我真的找不出合适的中文词汇形容我的感觉了,只有abomination这个长且发音简单的词汇可以表达我内心的震撼。我第一次听到还是十多年前玩龙腾世纪的时候,每次出现一大坨肉队友就会大喊abomination。恶臭,臃肿,2023年了怎么会有这样的东西!

然后我找了下编译出来的exe,大概几百k。双击一下这个文件,居然无法启动我的程序。果然,dotnet core熟悉的感觉出来了,程序编出来不知道怎么启动!明明在visual studio里面可以启动的呀。我索性再编了个release版,文件数没少但是容量减少到了100m,算是小号的abomination吧。

还是无法双击exe启动,满头问号的我再次Google,果然许多人在Github上询问这个事情。在翻了好多微软公司人废话连篇云里雾里的兜圈子回答之后,我终于明白了,这个MAUI开发出来的应用需要publish,变成特殊的MSIX格式,然后安装到PC上才可以脱离Visual Studio启动。

什么?这套流程不是xcode开发mac程序时候的恶臭步骤吗?还得先打包成Archive,再签名,再搞来搞去,最后安装。有种“我自己写的代码不是我自己的,我自己的电脑也不是我自己的,我自己为什么要做这种自我怀疑的事情”的感觉。

试了一下果然,publish过程中问要不要签名,要不要这要不要那,最后产出一坨MSIX。等等,微软又发明新的安装包了?不是MSI是MSIX?怎么现在这些有钱公司的开发者都喜欢X啊……X来X去还是100多M,然后点击安装,然后我的程序终于可以直接跑了。

经过MAUI这么一套组合拳,我感觉自己被赋能,整个人都是MAUI的形状了。硬要我评价的话,就是主打一个“干”字。我想对开发这些东西的人说:还我的1个小时和20G硬盘空间好不好啊?MAUI你们自己留着,我滚回去随便用什么框架了,哪怕electron我也不敢再骂了。

谢谢微软,谢谢MAUI,我手头的其他框架看起来都变得美好了。

可能这就是生命的意义吧。

尝试了一下现代技术

感受了JS前端+全套解决方案开发手机App的效率。

我选择了Tailwind CSS做布局,很容易就能写出现代风格的软件界面。然后React作为逻辑层,数据直接用各种封装好的key-value数据库。然后各种异步直接在js里面写await/async,根本就没有脑力负担。

从web变成手机App的话,就转为React Native,配合Expo的环境,连自己编译都不用搞了,纯粹的写写网页就能变成全平台的App,打包发布一应俱全,这个门槛不能再低了。

实际在开发的时候,各种状态管理都有现成的解决方案,我全是跟ChatGPT问出来的。不会的API或者typescript语法就跟它现学,说得非常清楚,都很容易掌握。比如atom+immer用来做全局的状态储存,swr做异步的数据获取和更新,然后各种hook用于各种不同的场景。

总而言之,就是App的开发生态完全和几年前不一样。那会儿React刚出来的时候,难用得不行,想做稍微复杂的东西就得搞什么redux这种我看着就难受,完全不想搞明白的玩意儿。

当然我相信这不是最优的解决方案,只是对于我这种熟悉WPF和JS还有React的人来说,真是愉悦的开发体验,尤其是不用搭建build环境,告别了和傻缺build engineer脑波同步的过程,幸福生活从云打包开始。

不过前端社区进化太快,我玩的这套也是“过时”技术栈。好在我也不是专业开发,就是花了2周从0开始到做完一个可用的App体验了一把。我不喜欢追新潮的技术,我就喜欢这种“成熟”或者说过时的技术,所有的坑都有人踩过,行不行一眼就能看到,不用自己琢磨太多,反正开发App又不是什么高大上的活儿,自己做着玩刚刚好。

顺便鄙视google出的什么Flutter+Dart,现成的语言不用非要搞全新的东西,拜拜了您的,没心情和任何Google的技术栈产生过深的交集,离丫一段距离比较安全。

Unity的Canvas坐标系统

Unity在游戏中为Scene和UI提供了不同的渲染组件。Scene的Mesh/Sprite的渲染采用了MeshRenderer/SpriteRenderer,而UI的2D组件则通过CanvasRenderer渲染。

我个人不喜欢这套组件的命名方式。本质上所有的Renderer都并非渲染操作的执行者,而是采集渲染数据的容器组件。最终实际的渲染工作由Engine中的渲染代码完成。不过名字只是个代号,知道背后的原理就好。

对于基本的渲染流程来说,被渲染的数据来源都是相同的。Mesh负责提供顶点和颜色数据,Material提供Shader和它的各种参数,再加上用于提供位置信息的Transform Matrix,被渲染物体就算描述清楚了。

在Scene里面所有的物体,无论是2D的Sprite或者3D的Cube,它们在World中的位置都是通过Transform来描述的,Mesh的顶点数据是在Object Local坐标系。只有遇到需要合并批次的情况才会被转换为World坐标系。单独渲染的情况下,在RenderDoc中可以看到Vertex数据和Mesh中的数据完全一致。整个流程就是教科书上的标准流程,Local->World->View->Clip(Projection)->NDC。

在Unity Engine内部会做一个简单的优化。为了减少渲染批次,会把Material相同的Mesh组合成同一个批次。大概过程就是先将这些Mesh的顶点坐标乘以Transform Matrix,转化为个世界坐标系中的位置,然后放在同一个batch中一口气画出来。合批之后,RenderDoc中看到的World Matrix就变成了标准矩阵,因为每个Mesh已经乘上了各自的World Matrix。

通过CanvasRender组织的数据,则稍微有些不同。

Unity里面大部分UI是对各种运行时的UI元件抽象的描述,没有预先制作好的Mesh,这种情况下Mesh会在运行时直接生成。以最简单的RawImage为例,需要通过4个顶点2个三角形组成的矩形Mesh来描述。只要知道UI也是Mesh组成的,想要自定义UI的形状,就可以通过自己创建Mesh来实现,具体可以参考Graphic.cs等UI的代码。

// 自定义的情况下,更新mesh后调用SetVerticesDirty();
// 然后override UnityEngine.UI.Graphic.UpdateGeometry传入自定义的mesh。
protected override void UpdateGeometry()
{
    canvasRenderer.SetMesh(_custom_mesh); 
}

// 或者在Dirty之后,通过OnPopulateMesh()中直接生成顶点数据
// 基类会把这些顶点放进Mesh里,省去自己创建mesh对象。
protected override void OnPopulateMesh(VertexHelper vh)
{
  vh.AddVert(v0);
  vh.AddVert(v1);
  vh.AddVert(v2);
  vh.AddTriangle(0, 1, 2);
  ...
}

如何生成UI Mesh的顶点,就需要牵扯到RectTransform。它是Transfrom在UI组件中的特化,这个概念我不太喜欢。一般情况下,Transform只应该负责“位置和变换”相关的信息,但RectTransform同时定义了“形状”,因此它包含Width/Height属性。更加混乱的是,它还囊括了Anchors和Pivot的概念。Anchor会和UI组件上层的UI组件产生关联,以此修改自身的位置和形状。而Pivot则定义了Mesh中顶点的Local坐标系原点。我之所以讨厌这个组件,就是因为它把太多的东西杂糅到了一起,牵一发而动全身。

以RawImage为例,本来可以简单的定义中心作为原点,通过Width/Height即可生成Mesh需要的4个顶点在Local坐标系的位置。但是在Unity中,UI对象的Local坐标原点是通过Pivot来定义的。因此Pivot改变之后,Mesh的顶点数据也需要重新计算。

知道Pivot和Width/Height,生成了Mesh之后还不算完,这些数据并不会直接提交到渲染的流水线中。可能Unity假定UI基本上拥有相同的Material,所以UIMesh都会被强制做合并批次处理。即使只有1个UI组件,它也会被合并批次。

因此在RenderDoc中看到的顶点数据,都不是UI的Mesh中储存的原始数据。这些顶点会被转化到“Canvas Local坐标系”中,可以说UI的World就是包含它的Canvas。这也是为什么Unity需要把UI对象要放到Canvas的树型结构中,才能将其绘制出来。

对于UI组件来说,它的World Matrix是“自身的RectTransform”和“它所有Parent的RectTransform”相乘的结果:

// 合并批次之后的顶点数据:
// mul( GetCanvasTransform(this.RectTransform) , Mesh顶点 )
Matrix4x4 GetCanvasTransform(Transform t)
{
	Matrix4x4 current = Matrix4x4.identity;
	while (true)
	{
		var canvas = t.gameObject.GetComponent<canvas>();
		if (canvas != null &amp;&amp; canvas.isRootCanvas)
			return current;

		var m = Matrix4x4.TRS(t.localPosition, t.localRotation, t.localScale) * current;
		if (t.parent == null)
			return m;

		current = m;
		t = t.parent;
	}
}

UI的顶点变换相当于(Local->CanvasLocal)->CanvasWorld->View->Clip(Projection)->NDC,前两步对应UI Mesh合并批次的结果。

对于World,View和Projection三个变换矩阵,Scene里面的对象很直接,World是对象各自的Transform,View和Projection通过Camera上的设置生成。UI和Scene并没有本质区别,因为在shader中数据的处理方式都是相同的,主要区别在于送入渲染管线之前如何得到这些矩阵。

具体计算方法和Canvas的Render Mode相关,总共3种模式:

1、Screen Space – Overlay模式:此模式将在屏幕最上方直接渲染UI,忽略任何Camera。在Editor中这个模式的表现很奇怪,编辑器中的UI不能和当前场景的对象对齐,可能强制设定了一套坐标,我一般避免使用它,但官方范例爱用这个模式,即使游戏中只有一个Camera。

Canvas Local坐标系相当于View坐标系的数据。在RenderDoc中可以看到,World和View矩阵都被强制设为单位矩阵,而Projection为全屏范围的正交投影,和任何Camera都无关。在这个模式下,UI会在所有Camera渲染完毕之后,单独被放在在“UGUI.Rendering.RenderOverlays”批次中渲染。

如果设置了自动缩放的CanvasScaler,只有Projection矩阵会根据缩放比例产生变化,World/View依然是单位矩阵。因为CanvasScaler的代码只修改Canvas.scaleFactor,这个属性只会影响Projection矩阵。

2、Screen Space – Camera模式:此模式将在Camera正对的平面上渲染UI,Canvas的范围是该Camera的Viewport,相当于在billboards上渲染UI。在编辑器中可以看到,Canvas会跟着Camera旋转,始终正对Camera。

World矩阵用于把Canvas缩放旋转之后摆在World里正对Camera的位置。接下来View和Projection矩阵和Scene中其他物体的情况相同,按照同样的计算方式,最终得到Clip坐标系中的顶点。

值得注意的是,RenderDoc中可能看不到View矩阵的正确值。unity_MatrixV始终显示为单位矩阵,但unity_MatrixVP的结果是Projection*View,和渲染Scene中其他物体时候的MatrixVP保持一致。

3、World Space – Camera模式:此模式把Canvas当作World中的一个平面来渲染。在编辑器中可以看到Canvas的位置不会随着Camera转动,因此可以渲染出带有3D空间感觉的UI。

和前一个模式唯一的区别在于,World矩阵始终为单位矩阵。因为Canvas Loca坐标系被当作World坐标系来对待,所以Canvas的Width/Height不再是Pixel Size,而是World坐标系的单位。

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

上述3种模式中,前两种模式中Canvas的RectTransform的各项数值由Canvas自己设定,只有第3种可以自己设定。RectTransform再次承担了特殊功能。我猜测引擎内部根据模式和RectTransform的数值,计算View Matrix,以及UIMesh合批的时候转换到Canvas Local坐标系的计算。

总结一下就是:

1、无论何种模式,传入渲染管线的定点数据坐标系没有变化,均为Canvas Local坐标系。这也是Canvas渲染模式切换不影响material的缘故。

2、根据Canvas渲染模式的不同,View和Projection会被分别设置。

3、除了World Space – Camera模式,所有Canvas树形结构中的对象,其Transform均不受Canvas层以上的父节点影响。这和普通的Scene对象有区别。

4、如果希望在shader中获取UI Mesh的Local坐标,可以通过传入GetCanvasTransform()计算结果的逆矩阵,然后和顶点坐标相乘来获得。

// material.SetMatrix("canvas_transform_inverse", GetCanvasTransform(this.rectTransform).inverse);
float2 local = mul( canvas_transform_inverse, float4(vertex, 0.0, 1.0) );

以上均为个人对Canvas坐标系统的理解,如果以后发现错误,再来订正。

针对RectTransform的吐槽就不再重复了,最后想说得是,Unity完全没有必要设计这些东西,单独为UI开一条渲染路径。所有的UI都应该并入Scene中,和其他对象一样进行渲染。或者反过来说,针对UI的这些“优化”和“坐标转换”,都应该允许场景中其他对象参与。否则Graphic相关的实现,都得单独 给UI做一套,例如particle,effect,animation。这又是何苦呢。

Xbox One X硬盘更换

之前想给X1X换个SSD,一直没有找到机会下手。趁着最近正在整理硬盘文件,腾出一张256G的SSD,就想给X1X换上,还能拿到1T的旧盘用来当移动硬盘。

本来以为X1X更换硬盘和PS4差不多,找到硬盘插上去然后重新执行升级文件,完事儿!网上查了下确实也有X1X的升级文件,于是就按照教程把硬盘拆下来。此处省略1000字的吐槽,X1X拆硬盘的难度比PS4高很多,主要是需要把机器翻个底朝天才能拿到最里面的盘。这时候我就有了不详的预感,果然装好SSD之后,升级文件点了就报错E101。

接下来上网翻帖子,才发现X1X的硬盘更换是很麻烦的。坑非常多,但好处是可以直接硬盘对拷,不用像PS4一样备份后再导入。前提是知道怎么操作,网上专门搞这个的家伙是个废话特别多的主儿,youtube视频又臭又长,平均40分钟起。

其实说白了就是:X1X硬盘只有500G/1T/2T三种标准容量。根据当前你机器的硬盘型号,如果直接换同样大小的硬盘,1:1对拷之后就可以解决问题。否则就需要分成两种情况:

1、新的硬盘也是标准容量的情况下,如果不想保留旧数据,插上去直接用离线升级文件OSU1就可以安装系统,不需要分区,新硬盘直接可装。

2、如果要保留旧数据,或者新硬盘不是标准容量(例如我的情况256G,或者1.5T什么的),不能直接对拷也不能使用OSU1离线安装系统。

在第二种情况中,需要手动把硬盘分区。按照X1X的规格,硬盘需要四个NTFS分区。其中3个是固定大小的系统分区,1个是用户文件分区,可以根据自己的硬盘容量把剩余的空间都分进去。分区之后,就可以直接把旧硬盘对应分区的数据,拷贝到新硬盘。

最重要的一步,也是最后一步,要把这些分区的GUID修改成指定的值。具体修改成哪个GUID,是按照机器旧的硬盘的容量来的。500G/1T/2T各有一套GUID,如果GUID不对硬盘放进去也启动不了。虽然可以用OSU1升级,但是校验之后就会报E101的错误。

所以我的情况,是1T的X1X,更换为256G的SSD。需要确认旧的硬盘可以正常进入系统,然后断电关机,把新的SSD分区,再拷贝数据,再修改为1T对应的那套GUID。这样的顺序操作才能让新的SSD直接在X1X中启动。

分区和GUID的修改可以用任何软件进行,拷贝也可以直接用文件管理器。但是要操作很多步骤,手动做很麻烦。网上那位XFiX小哥已经做好了一套脚本,本质上是一堆分区工具+Bat批处理/shell脚本,可以在windows或者linux里,直接自动分区和修改GUID。在Windows上操作唯一要注意的就是,不能同时挂载具有相同GUID的硬盘。新旧硬盘需要修改为相同的GUID,所以要先拷贝数据,拔下旧硬盘之后再修改GUID。

脚本可以在这里下载:https://gbatemp.net/download/xbox-one-windows-and-linux-internal-hard-drive-partitioning-script.34239/

有兴趣研究的也可以看小哥充满各种废话的教程视频:https://www.youtube.com/watch?v=gPJ-sBAWeL0

当然,吐槽归吐槽,前人栽树后人乘凉,小哥的脚本还是很好用的。要怪就怪X1X的硬盘升级步骤太繁琐了。毕竟人家是安全系数极高的console,从来没有被攻破,可不是得高点事情嘛~

最后吐槽:X1X不能安装大于2T的硬盘,装上去也用不了,可能是里面虚拟内核或者加密问题?还有就是离线安装文件居然叫OSU1,而且还存在OSU2和OSU3,但是OSU1才是最新的升级包,不知道起名字的人是什么脑回路,可能是给X1X和XSX以及XSS起名的那位大神……