Skip to content

排版归排版,送信归送信 ​

上一节咱们把失败装进了类型。我们的任何一个错误呢,都能让咱们打成一个词了。可您要让人看见这个词,内核迟早得输出:也许是页出错的地址,也许是中断的编号,也许就一句"我还活着"。这些话在寄存器里呢,咱们读到的是一串位,到了串口线上呢,却得是一个一个的字符。中间咱们得有人把位翻译成字符——这半的活儿,咱们叫排版。再把字符一个个送出去——那半的活儿,咱们叫送信。

最顺手的写法,咱们都想得到:一个函数把两件事都包圆。

C++
void log_to_serial(const char* format, ...);   // 格式化,顺便写到串口

你瞧,还是挺自然的,对吧!可问题就在这里了。您试着给这个函数排一个出生顺序,马上就卡住了:咱们想验"格式化对不对",得看它吐出来的字。字走的是串口,串口驱动得已经立在那儿等着咱们。可串口驱动怎么调呢?靠输出。输出走的,正是这个还没被验过的格式化函数。咱们想验格式化,您手里就得攥着一个串口。轮到咱们调串口呢,又得靠这个还没验过的格式化。好家伙、原来是死锁了!咱们转了一圈,愣是挑不出能第一个动工的。

就算咱们硬让串口驱动打头,还是躲不开一个更日常的麻烦:改代码的节奏。

引擎本来就是拿来反复调的,您改完就想立刻看一眼输出。可它住在咱们的内核底下,每看一眼咱们都得开一轮虚拟机——一轮就是几十秒,就为了咱们瞄上一眼。同一份逻辑要是能直接在咱们开发的电脑上编译、运行,咱们改完一按回车,新结果就出来了。

所以咱们就一个办法:排版归排版、送信归送信,两件事分开。排版的部分,咱们让它从出生起就不认识串口、也不认识虚拟机,全部工作就是"给我格式串和参数,我把字符一个个吐出来"。吐给谁呢,咱们交给调用方自己决定。这么一分呢,排版立刻自由:正常电脑上的测试想怎么驱动它都行,引擎连内核的门都还没进,已经被咱们跑了几十遍。

snprintf 就摆在那 ​

排版要咱们自己动手,您头一个想问的多半是:库里摆着 snprintf,咱们包一层不行吗?

语义这边呢,咱们就得较真。截断从哪截、结尾的空字符保不保,snprintf 都有一套现成的答案,而且是 C 标准白纸黑字定死的,谁来用都是这一套呢。可语义是别人定的,咱们改不动。而内核里的缓冲动不动就只剩几格,这两件事咱们躲都躲不开。

<cstdio> 也不在咱们允许清单上,snprintf 同样不被独立环境承诺——咱们在七个头,五面旗里立下的清单,在这儿也拦了咱们一道。

再往它身后看呢,snprintf 还拖着 locale、浮点转换一整套咱们管不着的机器。您就算只用 %d,它也是整备着这套家当一起进的。内核世界嘛,咱们带不动这一整串。

真要挑新一点的选项,std::format 也一样:咱们在清单上找不到它,独立环境那边呢,咱们同样讨不到承诺。您看它"往一个出口产字符"的思路,和咱们下面要做的,倒想到一块去了。

一个函数指针当出口 ​

两件事分开以后呢,咱们手里的引擎形状反而简单了:它剩下的活儿只有一件,咱们递给它格式串和参数,它把字符一个个还给咱们。咱们引擎产出来的字符去哪?内存缓冲、串口、屏幕——它一概不知情、也不想打听。送哪儿去呢,咱们让调用方自己定,办法是咱们给引擎留一个回调:把"拿到一个字符之后干什么"包成函数、连同自己的上下文一起交给引擎——每产出一个字符,引擎就替咱们调一次这个函数。

C++
using EmitFn = void (*)(char character, void* context);

struct Sink {
    EmitFn emit;
    void*  context;

    void put(char character) const {
        emit(character, context);
    }
};

咱们再看 void* context,它是老式回调的标准搭配:函数指针只有一张,用的人却是千家万户,往缓冲写的、往串口送的,各带各的数据。咱们让引擎原样递过去——看都不看一眼,连问都懒得问咱们一句。

那为什么咱们拿的是普通函数指针,不是模板呢?换成模板当然也OK的!

咱们编译期就把"送到哪"定死、还省一次间接调用。可模板呢,咱们一旦用起来就有麻烦:一般就得把实现摊进头文件里、调用方才拿得到。咱们选函数指针就没有这个负担:咱们让引擎整体住进两个 .cpp,解析的活儿在 entry.cpp,数字转换的活儿在 radix.cpp。公共头 base/include/cinux/format.hpp 摊开的,就咱们一句声明:

C++
void VformatToBuf(char* out_buffer, size_t buffer_size, const char* format, ...);

咱们格式化进缓冲,全功能的入口就它一个。

两个小盒子 ​

咱们在引擎内部传数据,用的是一对小值类型:

C++
struct CharBuffer {              // 可写窗口
    char* data;
    int   capacity;
};

struct TextView {                // 只读视图
    const char* data;
    int         length;
};

头一个呢是可写窗口,咱们靠它记两件事——写到哪了、还能写多少。另一个呢是只读视图,咱们拿它说明白——读的是哪一段、一共有多长。搁在以前呢,咱们都是拿"指针加长度"两个散着的裸参数,配不配套全凭咱们自觉。现在咱们让边界跟着数据走:窗口呢,把容量揣在了自己身上,写的人写到头自然停。视图自己报个数呢,读的人照着数读就行,内容又不是它的,咱们连结尾是不是空字符都不用关心。

格式符,一次配齐 ​

支持面呢,咱们一步到位、不搞最小子集:

  • 转换符:%% %c %s %d %u %o %x %X %p
  • 长度修饰:l / ll / z
  • 宽度:%Nd(右对齐空格补)、%0Nd(零补)、%-Nd / %-Ns(左对齐)

为什么不肯省?咱们这引擎一立起来,往后每一站咱们都得靠它过日子。%X 咱们今天偷懒不做,将来您在内核里对着一个寄存器值,想看十六进制大写的时候,就得回头给已经跑在内核里的引擎动手术。到那时候再补——可不是顺手的事了。

而且配齐并不贵。%d %u %o %x %X 咱们五个转换符,走的全是同一个"除基取余"函数:换进制就是换除数,换大小写呢,就是给咱们换一张数字表,%o 和 %X 搭的还是同一个循环、增量就几行。那几行代码您一会儿就能看到,贵不到哪去呢。浮点是不做的:内核里没这需求,咱们也不为它拖机器。

拿 %09lld 走一遍 ​

咱们把一条格式指令交给引擎,它呢,就拿光标从左到右给咱们扫过去——一共数出五步:

  1. 吃掉 %,光标后移。
  2. 读标志:- 记左对齐,0 记零填充,读到不是标志的字符为止。
  3. 读宽度:一串十进制数字。
  4. 读长度修饰:l、ll 或 z。
  5. 光标停在转换符上,按它取参数、转换、按宽度补位。

这五步适合让光标真的走一遍。播放或单步推进 %09lld,每一拍只决定一类信息,最后才消费参数:

0.0s / 35.2s
STEP 01开场

五步就是咱们的地图,咱们拿 %09lld 真排一遍。咱们吃掉 %,让光标落在 0 上——咱们认出这是标志、记下零填充、后移。咱们接着读到 9——不当标志读,咱们按宽度记下 9、后移。咱们再往后读到两个连着的 l——记成"64 位"、后移。最后咱们让光标停在 d 上——转换符,咱们一条指令走完:按有符号 64 位取参数、转成十进制,9 格不满呢,咱们拿零补齐,给咱们吐出 000000042。完事了。

咱们走到"取参数"这一步得停一停。printf 这一类函数,... 里的可变参数有个天生的缺陷:咱们传了什么类型,这个信息没跟着参数走呢。运行时咱们手里只有一串等着被取的字节,咱们要取几个、怎么解释——这事咱们说了不算、全看被调函数自己怎么声明。引擎怎么取呢,就看长度修饰和转换符搭伙报的口径:%lld 说"按 long long 取",咱们就得真的传 long long。

口径对不上呢,典型的是两种错法。传窄取宽:您传的是 int,格式串却给咱们写 %lld,引擎按 8 字节给咱们取,而您只保证过其中 4 字节是对的。咱们手头的机器会把前几个整数参数放进寄存器。高 32 位里放的是什么,咱们谁都没拿到承诺。碰上零咱们看到的输出一切正常,碰上别的值呢,给咱们打出来一个天文数字。传宽取窄:您传的是 long long 呢,格式串给咱们写 %d,引擎呢,只认低 4 字节:咱们传进去 0x10000002A、拿到手的只有 42,高 32 位给咱们整段丢掉。两种错法咱们试下来都不崩、不报——连个警告都不给咱们。

咱们拿长度修饰决定取参的宽度,实现里只给咱们分了两档:

长度修饰取参宽度
(无)int / unsigned int
l / ll / z一律 64 位:long long / unsigned long long

咱们把 l 单独一档、严格按 long 读,您看着似乎更"标准"。可咱们面对的是台 64 位机器——long 和 long long 同宽。真拆成两档咱们也跑不出任何行为差异,索性就不拆了。

还有两处行为是咱们人为定下的。零填充遇上负数的时候呢,咱们把补位排在负号前面:%08d 打 -42,出来的是 00000-42——负号本来就在数字串里,补的 0 加在整串前头。内核场景咱们不用负数零填充,行为是咱们自己定死的,定成什么样咱们都能预期,这就够了。%z 系呢,咱们一律按 64 位读,跟的是 Linux 内核那套路子:内核按 size_t 的宽度取参,而咱们这 64 位机器上的 size_t 恰好就是 64 位。独立环境里咱们找不到 ssize_t 这些 POSIX 名目,宽度咱们不猜、直接定死 64 位。

%c、%s 和 %p ​

%c、%s、%p 三个转换符不碰数字呢,咱们挨个过。

咱们让 %c 按 int 取参、取出来再窄化成 char。为什么宽着取?可变参数有个"默认实参提升"的讲究:咱们传进来的类型要是比 int 窄,上车前呢,咱们看着它提升成 int 再上车。您传的是 char,车上放的是 int,咱们让引擎按 int 取,您就取对了。您要真按 char 取,反而踩进未定义行为。

%s 的讲究在兜底:传进来的指针是空的时候,咱们打一个 (null) 顶上去——不当场崩。不少 C 库对 %s 撞空指针也是这个习惯呢,咱们跟着走。可理由呢,不在习惯、在咱们自己的场景——内核跑到一半,一处手滑的空指针 %s,把咱们整个调试现场当场炸没了,这买卖咱们亏不起。咱们顺手把一笔待办记在这儿:算 %s 的长度,引擎调的是 strlen——七个头,五面旗里记下的待办,就有它一份:眼下它在正常电脑上好好的,等咱们进内核那天,得靠咱们亲手写。

%p 统一输出带 0x 前缀的小写十六进制,并且咱们连零填充都直接禁掉。霸道是有理由的:您看 0x0000abcd,零补出来的那一串,长得和地址本身一模一样,读日志的人分不清哪些 0 是地址、哪些 0 是补的。

有一个数,取负就翻车 ​

数字转换这一段呢,有一个数,咱们怕是绕不过去的,所以呢,笔者得专门把它请出来见您。

64 位补码能表示的整数, 范围上咱们写出来是 -2^63 到 2^63-1。欸!我相信您注意到了两头不一样宽:**负数比正数多一个。**多出来的这个,咱们叫它 INT64_MIN。咱们把通用的算法"取绝对值、再逐位转"拿到它身上——第一步就翻车。咱们把这一步排开看:

  • 想算 -INT64_MIN,要的是 +2^63,写成十进制呢,是 9223372036854775808。
  • 可正数那头的天花板是 2^63-1,即 9223372036854775807,刚好小 1。
  • 64 位装不下 +2^63,高位切掉,剩下的位型正好还是 0x8000000000000000,原来的它自己。

更要命的是,有符号溢出在语言层面是未定义行为:今天机器碰巧还给您同一个数,不代表明天哪个编译器不替您"优化"出别的花样来。所以这一步咱们压根不能迈。

解法看着有点耍赖:需要特判的反正就它一个,咱们索性不走取负的路子——把字面量一个字符一个字符抄进去。

C++
if (value == INT64_MIN) {
    const char* const kMinText = "-9223372036854775808";
    int         written  = 0;
    while (kMinText[written] != '\0' && written < out.capacity - 1) {
        out.data[written] = kMinText[written];
        ++written;
    }
    out.data[written] = '\0';
    return {.data = out.data, .length = written};
}

耍赖不丢人!专门还有一条用例替咱们守着它,后面您会见到。

除基取余 ​

剩下的进制转换呢,咱们写公共实现只用了八行:

C++
char reversed[24];
int  reversed_length = 0;

do {
    reversed[reversed_length] = kDigits[value % base];
    ++reversed_length;
    value /= base;
} while (value > 0 && reversed_length < 24);

循环为什么是 do-while 不是 while?值等于 0 的时候,咱们照样得给人一个 '0' 站在那儿。while 这边呢,咱们得看了条件才动手,碰上 0 就一遍都不给咱们跑。do-while 那边呢,咱们动了手才看条件,保底会给咱们跑一次。咱们就图这个保底。

余数是从低位算起的。咱们拿 42 除 10,余出来的是个 2,所以咱们得倒着攒进 reversed、再倒着抄出去、两头一倒,顺序就正了。

缓冲咱们给 24 字节,是这么定的:uint64 顶格的八进制也就 22 位,咱们再放点余量。数组定长、待在栈上、不碰堆——引擎里排数字的缓冲,清一色是栈上的定长数组。

负数的十进制,咱们让它单走一条路:数字按无符号转出来,咱们再整串往右挪一格,腾出来的首位放负号。

C++
for (int i = kDigitsWritten; i > 0; --i) {
    out.data[i] = out.data[i - 1];
}
out.data[0] = '-';

您要是手痒,想加个 %b 来打二进制呢——那个 base 参数也接得住,咱们喂它 2 到 16 都吃得下。咱们在 switch 里添一个 case、在 RadixFor 里认一个字符,就完事了。留给您咯。

二十一行说明书 ​

引擎怎么验?咱们直接用 VformatToBuf:它把结果排进缓冲、结尾补空字符,咱们测试就拿整串出来和期望值逐字比对。这个入口是引擎自带的,咱们测试侧一根手指头都不用多动。咱们把构建目标单拎一个出来,跑给您看:

展开代码收起代码共 29 行
console
$ cmake --build build --target test_format
$ ./build/test/test_format

=== Cinux Test Runner ===
Running 21 case(s)...

[PASS] format: plain text passes through
[PASS] format: percent escape
[PASS] format: char
[PASS] format: string
[PASS] format: null string is guarded
[PASS] format: signed decimal
[PASS] format: unsigned decimal
[PASS] format: octal
[PASS] format: hex lower and upper
[PASS] format: pointer gets 0x prefix
[PASS] format: long long modifiers hit 64-bit extremes
[PASS] format: z modifier reads 64-bit
[PASS] format: width right-align space-pad
[PASS] format: width zero-pad
[PASS] format: width left-align number
[PASS] format: width left-align string
[PASS] format: width right-align string
[PASS] format: INT64_MIN does not overflow
[PASS] format: buffer truncation keeps NUL
[PASS] format: oversized output survives small buffer
[PASS] format: unknown specifier echoes verbatim

=== Results: 21 passed, 0 failed ===

您顺着用例名读下来,单子本身就是引擎答应过的全部:咱们给每个转换符都配了对口的用例,三种宽度对齐各有各的专测,INT64_MIN、UINT64_MAX 这些 64 位边界呢,十进制、十六进制咱们各来了一遍,咱们连截断、空指针、陌生格式符也一个没落下。谁哪天怀疑引擎的某个行为,咱们回头读一遍测试就有数。咱们真要改哪个行为,这儿的用例也得跟着一起改。

测试文件里咱们还有个小助手,替咱们把"格式化进缓冲、整串比对"固定成了一行:

C++
EXPECT_FMT("000000042", 64, "%09d", 42);
EXPECT_FMT("(null)", 64, "%s", static_cast<const char*>(nullptr));

一句用例读起来就是一句话:咱们把 42 按 %09d 排版,期望的就是 000000042。咱们把期望值打头、肉眼比对两边——对没对呢,咱们一眼就定下来。咱们现在只看它们用起来的样子——TEST、ASSERT_STREQ 这些零件是测试框架的,框架呢,咱们得等它过两节才出厂,到时候咱们再拆。

咱们把截断从整套用例里单拆出来讲讲。判界的地方在引擎肚子里:VformatToBuf 干的活,就是把调用方给的缓冲包成一个往里写的后端、再接给引擎。这个后端每写一个字符之前呢,都会替咱们判一道:

C++
void buffer_emit(char character, void* context) {
    auto* sink = static_cast<BufferSink*>(context);
    if (sink->position + 1 < sink->capacity) {
        sink->data[sink->position] = character;
        ++sink->position;
    }
}

咱们写的判据是严格小于:position + 1 < capacity,结尾的空字符永远有一个座。于是咱们截断的语义立住了:咱们允许内容短,字符串必须给咱们完整收尾。

C++
TEST("format: buffer truncation keeps NUL") {
    char format_buffer[5];
    VformatToBuf(format_buffer, sizeof(format_buffer), "%s", "abcdefghijklmn");
    ASSERT_STREQ(format_buffer, "abcd");
}

咱们拿 5 格的缓冲,装一段 14 个字符的长串。咱们看到的结局:留下 abcd,空字符给咱们坐在最后一格,十个字符全给咱们关在门外。您再想想,把那道判断改成"小于等于"会怎样:内容字符呢,倒是给咱们一个不落全留在界内,可缓冲也就此写满了,收尾的空字符被顶出 capacity 一格、落到界外去了。差一格的越界,搁在内核里偏偏是最难抓的一种:十有八九不崩,只是悄悄改掉隔壁的一个字节,等那个字节的主人出事,咱们才知道疼。

清单里呢,咱们还能看到一行 unknown specifier echoes verbatim,说的就是陌生的格式符。咱们拿 %q 试:引擎不咽下去、也不装没看见,原样吐还您一个 %q。您在输出里看见 %q,就知道是咱们自己把格式串写错了。

咱们这套东西能全在正常电脑上立起来,靠的还是开头那一拆。等将来内核要往外说话呢,咱们就给引擎配一个"字符进串口"的后端:

C++
const Sink serial_sink{.emit = SerialPutChar, .context = nullptr};   // 名字占位,串口驱动还得过几站

不过眼下呢,咱们还把 Sink 关在 entry.cpp 的匿名命名空间里,公共头里咱们也只摊了 VformatToBuf 一句。等真接串口那天呢,咱们还得在引擎这边开一个公开入口、专门接这样的后端,串口驱动嘛,离咱们也还有几站。咱们把"引擎本体一行不动"的好日子留到进了内核之后的那几站。接下来呢,下一节咱们去造第三件武器:断言。

r05_long_mode-2-gfc5569b · fc5569b · 2026-10-01