libc/crt0: возврат из main завершает программу по-настоящему

Два бага одного пути завершения, оба видны только на железе.

1. ЗВУК ПРОДОЛЖАЛСЯ ПОСЛЕ ВЫХОДА.  cbl_close() закрывает СЕССИЮ, но не
гасит железо: bit7 порта 0x004E держит gfx_wait_vsync ради бита луча,
поэтому порт оставался включённым ("немой" режим), и CBL крутил свои 256
слотов уже под шеллом — тихо ровно до первой чужой записи в порт данных,
а дальше она зацикливалась.  Новый cbl_shutdown() гасит bit7 независимо
от держателей и центрует ЦАП обычного COVOX; pop_shutdown зовёт его
последним действием, а _cbl_open_raw регистрирует в atexit его, а не
cbl_close.

2. ЦЕПОЧКА atexit НЕ ВЫПОЛНЯЛАСЬ ПРИ ВОЗВРАТЕ ИЗ main.  crt0 уходил прямо
в ESTEX EXIT, то есть нарушал контракт C (возврат из main = exit(status)).
Молча терялись не только гашение звука и снятие vsync-ссылки, но и
_fclosall: буферизованная запись в файлы пропадала, если программа не
звала exit() явно.  Теперь crt0 после main дёргает _atexit_hook.

Косвенность обязательна: прямая ссылка crt0 на разматыватель притащила бы
его и стек хендлеров в КАЖДУЮ программу.  Указатель живёт в отдельном
data-модуле (два байта _DATA, ни байта кода), ставит его сам atexit() при
первой регистрации — нет регистраций, нет и кода.  Тот же приём, что у
_irq_cbl_hook.

Цена замерена: +14 Б всем программам (блок в crt0) и +64 Б тем
одиннадцати, что реально регистрируют хендлеры (CBL, файловые через
_fclosall, irqtest, gfx_dbuf, solidt) — у них раньше эти хендлеры были
мёртвым кодом.  Эталоны обновлены (кроме atlas: его +434 Б не отсюда,
замерен тот же и без этих правок).

Проверено в MAME: старт и звук как были, выход по F10 возвращает в шелл
чисто (текстовый режим восстановлен, зависания нет), повторный запуск
работает.  Пункт 1 проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
This commit is contained in:
2026-09-02 14:15:48 +03:00
parent 31d0075090
commit 8490288d79
12 changed files with 203 additions and 58 deletions
+11
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */ #include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */ #include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */ #include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */ #include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */ #include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */ #include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
@@ -1490,4 +1491,14 @@ void pop_shutdown(void) __banked
pop_guard_free(); pop_guard_free();
pop_kid_data_free(); pop_kid_data_free();
pop_level_free(); pop_level_free();
/* ЖЕЛЕЗО ЗВУКА ГАСИМ ПОСЛЕДНИМ ДЕЙСТВИЕМ, и pop_sfx_close выше для этого
* НЕ ДОСТАТОЧНО: он закрывает сессию, а bit7 порта CBL остаётся включён,
* пока его держит gfx_wait_vsync ради бита луча. Дальше main просто
* возвращается, а crt0 при возврате уходит прямо в ESTEX EXIT, минуя
* цепочку atexit (runtime/crt0_banked.s) — то есть vsync-ссылку снять
* уже некому. В результате мы уходили в шелл с включённым CBL, и он
* крутил свои 256 слотов: тихо до первой чужой записи в порт данных, а
* дальше она зацикливалась (поймано пользователем на железе 2026-09-02;
* в MAME не воспроизводится). */
cbl_shutdown();
} }
+45 -45
View File
@@ -1,67 +1,67 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3783 accfill 3797
accop 7067 accop 7067
argv 3431 argv 3445
assrtest 3847 assrtest 3861
atlas 9193 atlas 9193
attrprob 4087 attrprob 4101
banked 1056 banked 1070
bankedbg 1067 bankedbg 1081
banklocl 4683 banklocl 4697
banktest 3767 banktest 3781
bgi_img 8677 bgi_img 8691
bgitest 3748 bgitest 3762
bios_text 4461 bios_text 4475
blitperf 5860 blitperf 5860
blitw 4939 blitw 4939
cat 913 cat 927
cblstream 5862 cblstream 5944
cbltest 6008 cbltest 6090
cblwav 6101 cblwav 6183
conio 4605 conio 4619
conio2 3929 conio2 3943
convbench 3508 convbench 3508
dec_test 860 dec_test 874
errno 5966 errno 5980
fbench 8085 fbench 8149
fdmax 6023 fdmax 6087
filetest 10547 filetest 10611
fpsdiv 4870 fpsdiv 4870
gets 509 gets 523
gfx_dbuf 5142 gfx_dbuf 5207
gfx_demo 4114 gfx_demo 4128
gfxbanks 6044 gfxbanks 6044
hello 4167 hello 4181
hello2 4244 hello2 4258
irqtest 6690 irqtest 6754
kbdpoll 1282 kbdpoll 1282
kbdraw 4911 kbdraw 4911
ls 4835 ls 4849
malloc 4461 malloc 4475
mem_test 4555 mem_test 4569
mouse 4382 mouse 4396
openenv 6124 openenv 6138
pageflip 6334 pageflip 6334
palfile 5406 palfile 5406
ptime 5744 ptime 5758
rt_test 5197 rt_test 5216
scroll 6744 scroll 6744
seek 4188 seek 4202
simple 955 simple 969
solidt 11531 solidt 11595
spranim 9431 spranim 9431
spriteclip 4119 spriteclip 4119
sprites 6791 sprites 6791
stattest 7549 stattest 7563
stdlib 6643 stdlib 6657
stest2 3644 stest2 3658
strtest 1340 strtest 1354
text_palette 5024 text_palette 5038
timedir 5454 timedir 5468
w0page 8831 w0page 8831
w3bgfx 5243 w3bgfx 5243
w3big 3673 w3big 3673
w3huge 3700 w3huge 3700
w3probe 3556 w3probe 3556
w3tiny 3471 w3tiny 3471
winrest 4459 winrest 4473
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
2 accfill
3 accop
4 argv
5 assrtest
6 atlas
7 attrprob
8 banked
9 bankedbg
10 banklocl
11 banktest
12 bgi_img
13 bgitest
14 bios_text
15 blitperf
16 blitw
17 cat
18 cblstream
19 cbltest
20 cblwav
21 conio
22 conio2
23 convbench
24 dec_test
25 errno
26 fbench
27 fdmax
28 filetest
29 fpsdiv
30 gets
31 gfx_dbuf
32 gfx_demo
33 gfxbanks
34 hello
35 hello2
36 irqtest
37 kbdpoll
38 kbdraw
39 ls
40 malloc
41 mem_test
42 mouse
43 openenv
44 pageflip
45 palfile
46 ptime
47 rt_test
48 scroll
49 seek
50 simple
51 solidt
52 spranim
53 spriteclip
54 sprites
55 stattest
56 stdlib
57 stest2
58 strtest
59 text_palette
60 timedir
61 w0page
62 w3bgfx
63 w3big
64 w3huge
65 w3probe
66 w3tiny
67 winrest
+6 -1
View File
@@ -61,7 +61,12 @@ int _cbl_open_raw(uint8_t freq_code, uint8_t fmt, uint8_t pump_mode,
static uint8_t atexit_armed; static uint8_t atexit_armed;
if (!atexit_armed) { if (!atexit_armed) {
atexit_armed = 1; atexit_armed = 1;
atexit(cbl_close); /* CBL обязан умолкнуть до шелла */ /* ИМЕННО shutdown, а не close: close оставляет bit7 включённым,
* пока его держит gfx_wait_vsync, и железо крутит буфер уже под
* шеллом. Подстраховка неполная — цепочку atexit разматывает
* только exit(), а crt0 при возврате из main уходит прямо в ESTEX
* EXIT, — поэтому программа обязана звать cbl_shutdown() сама. */
atexit(cbl_shutdown); /* CBL обязан умолкнуть до шелла */
} }
IRQ_DISABLE(); IRQ_DISABLE();
+38
View File
@@ -0,0 +1,38 @@
/*
* cbl_shutdown — ЖЁСТКО погасить звук перед возвратом в шелл.
*
* ЧЕМ ОТЛИЧАЕТСЯ ОТ cbl_close(). Тот закрывает СЕССИЮ: снимает насос и
* пересчитывает порт по текущим держателям. А держатель обычно есть —
* gfx_wait_vsync() занимает bit7 навсегда ради бита луча (см. _cbl_port.c),
* — и порт остаётся включённым в "немом" режиме. Для работающей программы
* это правильно, но при выходе означает, что железо продолжает крутить свои
* 256 слотов уже под шеллом: тихо ровно до первой чужой записи в порт
* данных, а дальше эта запись зацикливается навсегда.
*
* ПОЧЕМУ НЕЛЬЗЯ ПОЛОЖИТЬСЯ НА atexit. Цепочку atexit разматывает только
* exit() (libc/sys/exit.c), а crt0 при ВОЗВРАТЕ ИЗ main уходит прямо в
* ESTEX EXIT (runtime/crt0_banked.s) — ни один хендлер не выполняется.
* Значит и снятие vsync-ссылки, и cbl_close из atexit при обычном
* `return 0;` из main не происходят. Поэтому выключение должно быть
* ЯВНЫМ действием программы, а не следствием чужой уборки.
*
* Что делает: закрывает сессию (если была), обнуляет счётчик держателей —
* программа уходит, держать порт больше некому, — гасит bit7 и центрует
* ЦАП обычного COVOX (при выключенном CBL записи в порт данных идут прямо
* в него, см. _cbl_prime.c), чтобы вместо щелчка осталась ровная тишина.
*
* Идемпотентно; звать последним действием завершения.
*/
#include "_cbl.h"
#include "../irq/_irq.h" /* IRQ_DISABLE/IRQ_ENABLE */
void cbl_shutdown(void)
{
cbl_close(); /* снять насос и отпустить IM2-таблицу */
IRQ_DISABLE();
_cbl_port_refs = 0; /* держателей больше нет: программа уходит */
_cbl_ctrl(0); /* bit7 = 0 — CBL выключен целиком */
_cbl_prime(0x80); /* ...и ЦАП COVOX в центре, без щелчка */
IRQ_ENABLE();
}
+13 -3
View File
@@ -55,9 +55,14 @@
* cbl_underruns() считает недоливы в ОБОИХ режимах — это диагностика, * cbl_underruns() считает недоливы в ОБОИХ режимах — это диагностика,
* поведение не меняет. * поведение не меняет.
* *
* cbl_close() обязателен (atexit подстрахует) — иначе CBL продолжит * cbl_close() обязателен — иначе CBL продолжит прерывать шелл после
* прерывать шелл после выхода; заодно освобождает буфер тишины, если * выхода. НО ОН НЕ ГАСИТ ЖЕЛЕЗО ПОЛНОСТЬЮ: пока bit7 держит
* он был аллоцирован. * gfx_wait_vsync() ради бита луча, порт остаётся включённым в "немом"
* режиме, и буфер крутится под шеллом. Перед возвратом из main звать
* cbl_shutdown() — он выключает всё и центрует ЦАП.
*
* На atexit тут полагаться НЕЛЬЗЯ: цепочку разматывает только exit(), а
* crt0 при возврате из main уходит прямо в ESTEX EXIT.
* *
* Требования как у <irq.h>: код/данные в W2 (tiny/big). * Требования как у <irq.h>: код/данные в W2 (tiny/big).
*/ */
@@ -67,6 +72,11 @@
#include <stdint.h> #include <stdint.h>
/* Выключить звук ПОЛНОСТЬЮ перед завершением программы: закрыть сессию,
* снять bit7 независимо от держателей и оставить ЦАП в центре. Звать
* последним действием завершения; идемпотентно. */
void cbl_shutdown(void);
/* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума /* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума
* Sprinter Team; коды 0/1 — legacy, не использовать). */ * Sprinter Team; коды 0/1 — legacy, не использовать). */
#define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */ #define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */
+7
View File
@@ -10,4 +10,11 @@
extern void (*_atexit_stack[ATEXIT_MAX])(void); extern void (*_atexit_stack[ATEXIT_MAX])(void);
extern int _atexit_top; extern int _atexit_top;
/* Разматыватель цепочки (LIFO) — общее тело exit() и возврата из main. */
void _atexit_run(void);
/* Указатель на него для crt0; ставит atexit() при первой регистрации.
* Подробности и мотивация косвенности — в шапке _atexit_hook.c. */
extern void (*_atexit_hook)(void);
#endif #endif
+18
View File
@@ -0,0 +1,18 @@
/*
* _atexit_hook — указатель на разматыватель цепочки atexit; его дёргает
* crt0 при ВОЗВРАТЕ ИЗ main.
*
* ЗАЧЕМ КОСВЕННОСТЬ. По стандарту C возврат из main эквивалентен
* exit(status), то есть цепочка atexit обязана размотаться. Но прямая
* ссылка crt0 на разматыватель притащила бы в КАЖДУЮ программу и его, и
* весь стек хендлеров — включая те, что atexit() не зовут вовсе. Поэтому
* crt0 ссылается на этот модуль (два байта _DATA, ни байта кода), а
* заполняет указатель сам atexit() при первой регистрации: нет
* регистраций — указатель нулевой, разматыватель в программу не приезжает,
* crt0 просто уходит в ESTEX EXIT как раньше. Тот же приём, что у
* _irq_cbl_hook в libc/irq.
*/
#include "_atexit.h"
void (*_atexit_hook)(void);
+22
View File
@@ -0,0 +1,22 @@
/*
* _atexit_run — размотать цепочку atexit-хендлеров (LIFO).
*
* Общее тело для двух входов: явного exit() и возврата из main (там его
* зовёт crt0 через _atexit_hook). Отдельным модулем — чтобы программа,
* не зарегистрировавшая ни одного хендлера, не получала этот код вовсе.
*
* Вершина сбрасывается ПЕРЕД вызовом каждого хендлера: если хендлер сам
* позовёт exit(), цепочка не пойдёт по второму кругу.
*/
#include "_atexit.h"
void _atexit_run(void)
{
while (_atexit_top > 0) {
void (*fn)(void) = _atexit_stack[--_atexit_top];
if (fn) {
fn();
}
}
}
+8 -3
View File
@@ -3,9 +3,11 @@
* завершении (exit()), максимум 8, порядок LIFO. Возвращает 0 или -1 * завершении (exit()), максимум 8, порядок LIFO. Возвращает 0 или -1
* при переполнении стека хендлеров. * при переполнении стека хендлеров.
* *
* Внимание: возврат из main() без явного exit() уходит в inline RST * ВОЗВРАТ ИЗ main ТОЖЕ РАЗМАТЫВАЕТ ЦЕПОЧКУ (с 2026-09-02), как и требует
* 10h #41 в crt0.s и цепочку atexit НЕ выполняет — программам с * стандарт C. Раньше crt0 уходил из main прямо в ESTEX EXIT, и хендлеры
* хендлерами нужно завершаться через exit(). * не выполнялись: программа теряла и сброс файловых буферов (_fclosall), и
* гашение звука, если не звала exit() явно. Теперь первая регистрация
* ставит _atexit_hook, а crt0 его дёргает — см. _atexit_hook.c.
*/ */
#include <stdlib.h> #include <stdlib.h>
@@ -17,5 +19,8 @@ int atexit(void (*fn)(void))
return -1; return -1;
} }
_atexit_stack[_atexit_top++] = fn; _atexit_stack[_atexit_top++] = fn;
/* Хендлеры появились — значит crt0 обязан размотать цепочку и при
* простом `return` из main. Ставим каждый раз: проверка дороже. */
_atexit_hook = _atexit_run;
return 0; return 0;
} }
+1 -6
View File
@@ -9,11 +9,6 @@
void exit(int code) void exit(int code)
{ {
while (_atexit_top > 0) { _atexit_run();
void (*fn)(void) = _atexit_stack[--_atexit_top];
if (fn) {
fn();
}
}
_exit(code); _exit(code);
} }
+17
View File
@@ -22,6 +22,7 @@
.module crt0 .module crt0
.globl _main .globl _main
.globl __atexit_hook
;; Linker-emitted symbols (resolved at link time). ;; Linker-emitted symbols (resolved at link time).
.globl s__INITIALIZER .globl s__INITIALIZER
@@ -88,6 +89,22 @@ _start::
ld de, (_argv) ld de, (_argv)
call _main call _main
;; Returning from main is exit(status) per the C standard, so the
;; atexit chain must run here too. The call is INDIRECT on purpose:
;; atexit() fills __atexit_hook on its first registration, so programs
;; that never register anything keep it NULL and link neither the
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
push de ; DE = int return of main
ld hl, (__atexit_hook)
ld a, h
or a, l
jr Z, atexit_done
ld de, #atexit_done
push de ; return address: Z80 has no `call (hl)`
jp (hl)
atexit_done:
pop de
;; SDCC's int return → DE. Low byte is the exit code. ;; SDCC's int return → DE. Low byte is the exit code.
ld a, e ld a, e
ld b, a ld b, a
+17
View File
@@ -27,6 +27,7 @@
.module crt0_banked .module crt0_banked
.globl _main .globl _main
.globl __atexit_hook
.globl _n_banks .globl _n_banks
.globl _bank_pages .globl _bank_pages
@@ -218,6 +219,22 @@ skip_bank_load:
ld de, (_argv) ld de, (_argv)
call _main call _main
;; Returning from main is exit(status) per the C standard, so the
;; atexit chain must run here too. The call is INDIRECT on purpose:
;; atexit() fills __atexit_hook on its first registration, so programs
;; that never register anything keep it NULL and link neither the
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
push de ; DE = int return of main
ld hl, (__atexit_hook)
ld a, h
or a, l
jr Z, atexit_done
ld de, #atexit_done
push de ; return address: Z80 has no `call (hl)`
jp (hl)
atexit_done:
pop de
;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1). ;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1).
ld a, e ld a, e
jr exit_with_a jr exit_with_a