Tag Archives:系统开发
库代码中是否应该检查 malloc 的返回值?
现在网上有很多似是而非的观点,比如 malloc 失败不用处理,直接退出,这样的经验看似很聪明,实际却很局限,比如:
1)嵌入式设备:不是所有设备都能有一个强大的全功能的操作系统,也不是所有设备都能有虚拟内存功能。
2)长时间运行的程序,内存虽然够,但由于碎片,会导致没有连续足够大的线性地址进而分配失败,32 位程序特别明显。
3)管理员对某类进程设置过内存限制,比如某类要起很多个的进程,那么设置一下内存限制是一个很正常的操作。
4)程序通过容器运行,事先给定了最大内存用量。
5)操作系统 overcommit 选项被管理员关闭。
所以说 malloc 失败不用检测,大多数是明确知道自己运行环境的某一类程序,比如上层业务,比如 CRUD,比如你就是做个增删改查,知道自己运行于一台标准的 linux 服务器,那么确实无需多处理,崩了也就崩了;或者你的程序严重依赖 malloc 三行代码一次分配,那么即使恢复出来估计你也很难往下走,不如乘早崩溃,免得祸害他人。
上面这些情况确实可以简单粗暴处理,但如果你开发一些基础库,你没办法决定自己是会运行在一台标准服务器上还是某个小设备里,那么上面的经验就完全失效了。
比如你开发一个类似 libjpeg 的图象编解码库,你没法假定运行环境,那么 malloc 失败时,你是该自作主张直接 assert 强退掉呢?还是该先把分配了一半的各种资源释放干净,然后向上层返回错误码,由上层决定怎么处理比较好呢?比如上层可选择:1)报错退出;2)释放部分缓存后再运行;3)降级运行,比如图象编码使用 low profile 再运行一遍。
你作为图象编码库需要经常分配一些比较大块的内存,你这里失败了,不代表上层无法继续分配内存处理后续任务对不对?上层内存里有很多图片 cache 用于加速图片显示,你这里失败了,上层感知到,直接从 cache 里回收一波,内存就又有了,对不对?图象视频编码根据资源消耗高低都有 high profile, low profile 的运行模式,你 high profile 内存不够了报告上层,上层视情况还可以选择再用 low profile 跑一遍对不对?
就是上层什么都不做,只在检测到你的返回值时在日志里记录一行内存不够再退出,你也得给上层一个选择的权利,而不是不管不顾,直接退掉;所以,你一个库碰到这类问题还是要自己处理干净把错误返回给上层,让上层来判断该如何处理更恰当一些。
再一种是需要精细控制内存的应用层业务,比如自己主动管理多个内存池,栈上的池不够了就应该到堆上的堆上池分配,堆上池分配不了就应该回收,回收不了就向操作系统要新的,这种情况也不能一概而论。
那么是不是说所有的 malloc 都要去处理 NULL 返回?当然不是,这里强调的是你既然选择用 C 语言了,脑袋里要有根弦,知道这个问题需要分情况处理,区分得了哪些代码重要,哪些代码不重要;哪些代码要什么力道去写,写到多深,这样即使某些业务模块懒得处理了,不处理 NULL 或者写个 assert 也没问题,但如果头脑里没这跟弦,不知道不同层次的代码需要不同的力道去写,一概无脑的不处理或者 assert,那么这样的代码写多了,只会越写越笨。

C语言如何编译出一个不需要操作系统的程序
来个更短的,没有其他乱七八糟的东西,只有一个简短的 C文件,不需要 linux 环境:
miniboot.c
asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))");unsigned char *videobuf = (unsigned char*)0xb8000;const char *str = "Hello, World !! ";int start_entry(void){ int i; for (i = 0; str[i]; i++) { videobuf[i * 2 + 0] = str[i]; videobuf[i * 2 + 1] = 0x17; } for (; i < 80 * 25; i++) { videobuf[i * 2 + 0] = ' '; videobuf[i * 2 + 1] = 0x17; } while (1) { } return 0;}编译:
gcc -c -fno-builtin -ffreestanding -nostdlib -m32 miniboot.c -o miniboot.old -e start_entry -m elf_i386 -Ttext-seg=0x100000 miniboot.o -o miniboot.elf运行:
qemu-system-i386 -kernel miniboot.elf结果:

满足条件:
- 只用纯 C 开发,可以使用 gcc 编译
- 编译出来的东西真的可以运行
- 不需要依赖操作系统
- 不需要包含系统调用的 glibc
- 连 libgcc 都不需要
解释一下:

如何实现一个真正高性能的spin_lock?
应用层用spinlock的最大问题是不能跟kernel一样的关中断(cli/sti),假设并发稍微多点,线程1在lock之后unlock之前发生了时钟中断,一段时间后才会被切回来调用unlock,那么这段时间中另一个调用lock的线程不就得空跑while了?这才是最浪费cpu时间的地方。所以不能关中断就只能sleep了,怎么着都存在巨大的冲突代价。
尤其是多核的时候,假设 Kernel 中任务1跑在 cpu1上,任务 2跑在 cpu2上,任务1进入lock之前就把中断关闭了,不会被切走,调用unlock的时候,不会花费多少时间,cpu2上的任务2在那循环也只会空跑几个指令周期。
看看 Kernel 的 spinlock:
#define _spin_lock_irq(lock) \do { \ local_irq_disable(); \ preempt_disable(); \ _raw_spin_lock(lock); \ __acquire(lock); \} while (0)看到里面的 local_irq_disable() 了么?实现如下:
#define local_irq_disable() \ __asm__ __volatile__("cli": : :"memory")倘若不关闭中断,任务1在进入临界区的时候被切换走了,50ms以后才能被切换回来,即使原来临界区的代码只需要0.001ms就跑完了,可cpu2上的任务2还会在while那里干耗50ms,所以不能禁止中断的话只能用 sleep来避免空跑while浪费性能。
所以不能关闭中断的应用层 spinlock 是残废的,nop都没大用。
不要觉得mutex有多慢,现在的 mutex实现,都带 CAS,首先会在应用层检测冲突,没冲突的话根本不会不会切换到内核态,直接用户态就搞定了,即时有冲突也会先尝试spinlock一样的 try 几次(有限次数),不行再进入休眠队列。比傻傻 while 下去强多了。

游戏机模拟器的具体原理是什么?
“游戏机模拟器” 注重的是 “严格模拟硬件”,要精确,可以对照 MAME代码,所有问题都能在里面找到对应答案:
第一:模拟 CPU
MAME里实现了各种 68000, z80,mips, sparc, arm,pic16c5x,nec, alpha,等 100 多款你见过的或者没见过的主从协处理器的模拟,虽然都是 switch case opcode,但是不像 lua虚拟机。MAME的 CPU模拟重点在 “精确实现硬件”,除了指令集实现外,还有各种软硬终端/trap/异常处理/IO实现。举个简单例子,一个游戏主机需要 4MHz 的 z80芯片,你就得给我真的按照 4Mhz来跑,每条指令计算周期,不能多也不能少,你要把 4Mhz跑成 8Mhz,游戏玩起来节奏就不一样了。比如以前老游戏机上敌人一多,就会慢下来,你实现模拟器,也得把这种慢下来给实现了。另外很多街机是双处理器,比如一块 68000 + z80,你不能复原老主机的运行速度,一些写的粗糙的游戏 ROM可能会出错。
模拟 CPU重点是 “精细”,比如浮点数误差最好一致,比如中断优先级你得模拟出来,模拟器由于按照 interval 来运行,更容易产生同时多个硬件中断被触发,比如 “手柄按键” ,多核通信之类各种东西加在一起,某个核满负荷运行的情况下,优先级低的可能永远得不到处理,弄错了可能游戏就没法玩了。
第二:模拟总线
总线也有好多规格需要实现,不同基板的总线链接不同cpu 和外设的方式都不一样,还是需要 “精确模拟”,比如 ROM /RAM / IO 地址映射,一些大容量游戏需要 ROM 的 BANK 切换,还有一些游戏会在卡带上带有扩展内存,除此之外还要正确模拟各种异常,比如某些 RAM,读写奇数地址会出错,要给对应 CPU发送异常信号,某些老点的 RAM只能读写 16bit的 WORD,不能读写 DWORD或者 BYTE,否则都无效。这些你都得模拟到位了,有些有 BUG的游戏,错误的写了内存,在真实主机上,写操作直接被硬件忽略掉了,没有损伤,但软件模拟不注意执行了那条指令结果就不一致了。
第三:外设模拟
音频芯片,I/O,图形加速芯片,随机数发生器,音视频输出,摇杆,时钟等。有些外设是有bug的,你也要把这些历史上的硬件bug给模拟进去,不然游戏可能行为不一致。
第四:调试系统
提供终端和接口可以内存 DUMP,反汇编,修改指令和数据,保存现场之类的。
有史以来出现过的游戏机硬件数不胜数,但是他们用到的芯片或者硬件是有限的,比如z80和 68000这类流行的芯片,具体每台主机其实就是一份配置文件,包含使用那种总线,哪些cpu,分别按照什么速度来运行,内存I/O布局,关联哪些外设,BIOS和启动加载等信息。
总之是个辛苦活,你需要一本硬件手册,然后边查边弄。
如果你嫌 MAME太复杂庞大,再推荐一个 gens 的代码,只针对世嘉16位机的 Windows实现,条理很清晰,很多比世嘉简单的 FC模拟器写的都没有 gens那么结构清晰,简单易读。它就不像MAME那么大而全,很多步骤实现的很直接不需要配置那么多,代码量也不大。
现在新进的模拟器很多,没机会逐一查看他们的实现细节,只记得有几款比较新的模拟器都是直接裁剪 MAME的部分代码来弄的,因为 MAME里面几乎实现了所有游戏能用的芯片了,拿出来改改参数加点指令集就可以用,比如 MAME里面模拟了 mips,我们裁剪出来实现 PSP模拟器,个别指令有些区别需要改一下,然后我们着重自己实现 PSP里面 MAME没有的硬件部分。

做编译器或操作系统哪个更有趣味?
同范畴类似的东西中,虚拟机开发比操作系统和编译器都有意思:
操作系统能玩的好玩的不多,做完进程管理和内存管理,其他就是脏活累活了,要得到一个成型可用的东西,没有一个团队弄不了,个人玩不转。以前调侃过这个话题:
关于LMOS自主操作系统的发展,大家有什么建议? – 韦易笑的回答
而编译器方面,优化和代码生成有llvm,用不着自己开发,其他部分基本上是一个固定套路,照着文档照着书,按固定套路来即可。
以 Lua为例,最精巧的部分就是 Lua虚拟机的实现,整个 Lua-5.3 代码中,编译部分只占2000行,剩下两万行全在实现虚拟机。大家津津乐道的各种 lua 奇技淫巧全都在它的虚拟机实现部分中。
Ruby更是如此,整个ruby代码除去库实现外,基本在实现ruby的虚拟机,编译器部分作者都懒得怎么写,直接一个的 .y文件搞定,精力都在ruby虚拟机实现上。
虚拟机难是难在开头的设计,怎样最精巧,怎样没有逻辑漏洞,别写着写着发现前后逻辑矛盾了,设计好以后就要开始架构了,先从最简单的 switch case opcode实现起来,很快你就能得到反馈看到自己的工作成果,接下来给虚拟机实现符号表,object,实现 gc,实现各种容器,优化性能,在内存中翻译成本地指令码,然后实现多线程,再给你的虚拟机实现一门汇编语言,每走一步都很有意思。且虚拟机相关的技术目前还在日新月异的发展着,光一个gc,每年都能看到很多新方法出现,大有需要继续改进迭代的地方。
实现虚拟机需要覆盖很多知识面,虚拟机设计的好,还可以嫁接很多其他语言,让这些新语言来这个虚拟机上运行。
大家夸 v8 都是说它虚拟机运行速度快,内存少,没人说 v8 的编译部分如何如何,以至于很多新语言都以v8虚拟机为运行环境。
同样,每年都有很多人尝试重新实现 lua vm,引入更灵巧的机制或者使用更新的 JIT方法。来pk官方runtime, 以及 luajit,好多人或多或少在某些方面都能并发出很多奇思妙想。
相反,从来没人碰 lua 的编译部分,为啥啊?就那样了,还碰它干嘛,没意思了啊。

如何设计一个内存分配器?
通常工程里不推荐自己写内存分配器,因为你费力写一个出来99%可能性没有内置的好,且内存出bug难调试
不过看书之余,你也可以动手自己试试,当个玩具写写玩玩:
1. 实现教科书上的内存分配器:
做一个链表指向空闲内存,分配就是取出一块来,改写链表,返回,释放就是放回到链表里面,并做好归并。注意做好标记和保护,避免二次释放,还可以花点力气在如何查找最适合大小的内存快的搜索上,减少内存碎片,有空你了还可以把链表换成伙伴算法,写着玩嘛。
2. 实现固定内存分配器:
即实现一个 FreeList,每个 FreeList 用于分配固定大小的内存块,比如用于分配 32字节对象的固定内存分配器,之类的。每个固定内存分配器里面有两个链表,OpenList 用于存储未分配的空闲对象,CloseList用于存储已分配的内存对象,那么所谓的分配就是从 OpenList 中取出一个对象放到 CloseList 里并且返回给用户,释放又是从 CloseList 移回到
OpenList。分配时如果不够,那么就需要增长 OpenList:申请一个大一点的内存块,切割成比如 64 个相同大小的对象添加到 OpenList中。这个固定内存分配器回收的时候,统一把先前向系统申请的内存块全部还给系统。

