Lời gọi chương trình con trong thế giới cổ đại: trước khi máy tính có stack hoặc heap
(devblogs.microsoft.com)- Các máy tính thời kỳ đầu phải triển khai lời gọi hàm ngay cả khi không có stack và heap, và trình biên dịch quản lý trạng thái lời gọi bằng các biến toàn cục ẩn tương ứng với tham số, địa chỉ trả về và biến cục bộ
- Bên gọi lưu đối số, đặt vị trí quay lại vào biến địa chỉ trả về, rồi nhảy đến điểm bắt đầu của hàm; sau khi tính toán, hàm nhảy trở lại địa chỉ đã lưu
- Các biến cục bộ về mặt logic thực tế cũng dùng vùng lưu trữ toàn cục, nên dù nhìn bề ngoài giống hàm, cơ chế bên trong gần với bộ nhớ cố định và
gotohơn - Một số ABI và bộ xử lý tối ưu hóa việc truyền đối số và xử lý địa chỉ quay lại bằng thanh ghi hoặc
branch with link, nhưng các ràng buộc cơ bản vẫn còn nguyên - Vì địa chỉ trả về của cùng một hàm bị lời gọi mới ghi đè, không thể gọi đệ quy; các ngôn ngữ thời đó ứng phó bằng cách cấm đệ quy hoặc chỉ cho phép khi khai báo rõ ràng
Cách cấu thành lời gọi hàm khi không có stack
- Trong môi trường máy tính thời kỳ đầu, không có stack hay heap như ngày nay chúng ta mặc nhiên coi là có
- Cấp phát bộ nhớ động khi không có heap có thể được thay thế bằng bộ đệm kích thước cố định
- Ngay cả khi xử lý dữ liệu có kích thước biến đổi, người ta cũng đặt trước một bộ đệm cố định đủ lớn
- Nếu dữ liệu yêu cầu vượt quá dung lượng bộ đệm, chương trình kết thúc bằng lỗi nghiêm trọng
- Cách triển khai thân thiện hơn cho phép thiết lập dung lượng tối đa khi biên dịch
- Cách triển khai tinh vi hơn đặt một bộ cấp phát tùy chỉnh trên bộ đệm cố định để có thể dùng như
allocatevàfree
Quy ước gọi dựa trên biến toàn cục ẩn
- Để triển khai lời gọi hàm không dùng stack, trình biên dịch định nghĩa nhiều biến toàn cục ẩn cho mỗi hàm
- Biến toàn cục cho từng tham số đầu vào
- Biến toàn cục chứa địa chỉ trả về của hàm
- Biến toàn cục tương ứng với các biến cục bộ
- Mã gọi được thực thi theo trình tự sau
- Lưu giá trị tham số vào biến toàn cục ẩn tương ứng
- Ghi vị trí sẽ quay lại vào biến địa chỉ trả về của hàm
- Nhảy bằng
gotođến vị trí bắt đầu của hàm
- Hàm đọc và ghi cả tham số lẫn biến cục bộ từ các biến toàn cục ẩn
- Khi thực thi xong, hàm đặt giá trị trả về vào thanh ghi giá trị trả về, rồi nhảy đến địa chỉ được lưu trong biến địa chỉ trả về của hàm
Ví dụ mã giống C được chuyển thành mã dựa trên goto
- Hàm ví dụ
add_two_values(int a, int b)có thể được chuyển đổi thành các vùng lưu trữ sau khi không có stacka2v_a,a2v_blà các biến toàn cục dùng để lưu đối sốa2v_clà biến toàn cục tương ứng với biến cục bộca2v_retaddrlà biến toàn cục dùng để lưu địa chỉ quay lại
- Bên gọi
sample()lưu lần lượt31415và2718vào các biến toàn cục đối số - Sau đó đặt vị trí
resumevàoa2v_retaddrvà nhảy đếnadd_two_values add_two_valueslưu kết quả tính toán vàoreturn_value_register, rồi quay lại quaa2v_retaddr- Bên gọi sau khi quay lại vị trí
resumesẽ lưu giá trị trong thanh ghi giá trị trả về vàosample_x
Tối ưu hóa bằng thanh ghi và branch with link
- Cùng cấu trúc này có thể được làm nhanh hơn ở cấp ABI bằng truyền qua thanh ghi
- Nhiều bộ xử lý cung cấp một
link registerđặc biệt và lệnhbranch with linkbranch with linktự động lưu địa chỉ của lệnh ngay sau lệnh rẽ nhánh vào link register- Bên gọi có thể đặt hai đối số đầu tiên vào
argument_register_1,argument_register_2 - Hàm được gọi có thể chuyển các giá trị thanh ghi này vào biến toàn cục ẩn của chính nó để sử dụng
- Địa chỉ trả về cũng có thể được lưu từ
link_registervào biến địa chỉ trả về của hàm - Tối ưu hóa này vẫn giữ cấu trúc cơ bản cho phép gọi và trả về mà không cần stack
Vì sao đệ quy bị chặn
- Ràng buộc cốt lõi của cách gọi này là không thể gọi đệ quy
- Khi xảy ra lời gọi đệ quy, biến địa chỉ trả về của cùng một hàm bị ghi đè bằng địa chỉ trả về của lời gọi mới
- Khi lời gọi bên ngoài kết thúc, vị trí ban đầu cần quay lại đã biến mất, khiến chương trình nhảy đến vị trí sai
- Các ngôn ngữ lập trình thời đó tránh vấn đề này bằng cách không hỗ trợ đệ quy
- Ban đầu FORTRAN thậm chí không hỗ trợ chương trình con; chương trình con được bổ sung vào năm 1958
- Việc hỗ trợ đệ quy trong FORTRAN trở thành chuẩn vào năm 1991, và ngay cả khi đó cũng phải khai báo chương trình con là
RECURSIVE
Mã tự sửa và lệnh chương trình con của các bộ xử lý thời kỳ đầu
- Một số trình biên dịch dùng cách tinh vi hơn là mã tự sửa
- Trường địa chỉ bên trong lệnh nhảy ở cuối hàm về thực chất đóng vai trò như biến địa chỉ trả về
- Cách này không chỉ là một mẹo đơn giản mà có thể là nhu cầu thực tế
- Một số bộ xử lý có thể không hỗ trợ nhảy gián tiếp
- Sau khi tính hữu dụng của chương trình con được công nhận, nhiều bộ xử lý đã bổ sung lệnh gọi chuyên dụng
- Lưu địa chỉ trả về vào word đầu tiên của chương trình con
- Việc thực thi thực sự bắt đầu từ word thứ hai
- Khi trả về, thực hiện nhảy gián tiếp qua nhãn bắt đầu của chương trình con
- Trong ví dụ assembly,
bsr add_two_valueslưu địa chỉ trả về vào word đầu tiên củaadd_two_values, rồi bắt đầu thực thi từ lệnh thực sự saunopdùng để hy sinh
1 bình luận
Các ý kiến trên Hacker News
Về chủ đề này, The Art of Computer Programming thật sự rất hay
Nhìn bề ngoài có vẻ cũ kỹ, nhưng có rất nhiều thuật toán xử lý mảng hoặc cấu trúc dữ liệu thay đổi động từ thời trước khi có heap hay stack
Cuốn sách dẫn dắt từng bước đến cả garbage collection và cách triển khai danh sách Lisp, và chứa đúng kiểu kiến thức bách khoa mà bạn kỳ vọng ở Knuth
Ví dụ tôi đặc biệt thích là cách hai mảng chia sẻ động cùng một vùng không gian. Nếu để một mảng tăng về phía trước từ
location#0, còn mảng thứ hai tăng lùi từlocation#End, thì có thể chia sẻ hiệu quả vùng không gian được cấp phát tĩnhCũng có thể mở rộng ra số lượng mảng tùy ý, nhưng đến mức đó thì dùng
MallocvàRealloccó lẽ tốt hơn, và bản thân kỹ thuật ấy cũng khá gần với một routine kiểu mallocChèn và dán thì không cần đẩy dữ liệu đi, nhưng khi duyệt thì cần. Dù vậy nó vẫn hoạt động tốt
Nếu không đủ như vậy thì nó nhận ít hơn mức mong muốn, còn nếu không lấy được cả mức tối thiểu thì chạy thất bại
Tôi nhớ là hệ thống đặt heap và thư viện ở phía dưới của mảnh RAM vật lý đó, còn stack ở phía trên
Khoảng System 8, một lớp ảo hóa được thêm vào khiến cách tiếp cận này bớt cần thiết hơn, và đến thời MacOS X thì giống các hệ thống khác, dùng bộ nhớ phân trang nên những trò khéo như vậy không còn cần nữa
Dù vậy, nghĩ lại thời mà “một mẹo lạ” kiểu này trong Art of Computer Programming từng là cách cấp phát RAM cho nhiều app chạy đồng thời thì vẫn thấy thú vị
Một cái tăng lên trên, cái kia tăng xuống dưới. Đó là một cấu trúc mê hoặc, nhưng cuối cùng không đạt được hiệu năng đã hứa hẹn
Trong một page kích thước cố định, mảng offset tăng về phía trước, còn mảng giá trị hàng có độ dài biến đổi thì tăng lùi từ cuối. Theo tôi hiểu, khi xóa hàng, mảng phía sau có thể xuất hiện lỗ trống
Tài liệu có trích dẫn TAOCP về chính cấu trúc B-tree, nên nếu đây là nguồn cảm hứng trực tiếp thì cũng không có gì đáng ngạc nhiên
Việc đưa hàm đệ quy vào ALGOL từng gây khá nhiều tranh cãi, và vẫn là một câu chuyện thú vị: https://vanemden.wordpress.com/2014/06/18/how-recursion-got-...
How recursion got into programming: intrigue, betrayal, and advanced semantics - https://news.ycombinator.com/item?id=33123916 - tháng 10 năm 2022, 8 bình luận
How Recursion Got into Programming (2014) - https://news.ycombinator.com/item?id=23061881 - tháng 5 năm 2020, 47 bình luận
How recursion got into Algol 60: a comedy of errors - https://news.ycombinator.com/item?id=10131664 - tháng 8 năm 2015, 124 bình luận
How recursion got into programming: a comedy of errors - https://news.ycombinator.com/item?id=8073361 - tháng 7 năm 2014, 108 bình luận
Trình thông dịch Forth cho máy SUBLEQ (https://github.com/howerj/subleq) và trình thông dịch cho máy bit-serial (https://github.com/howerj/bit-serial) đã được viết, nhưng cả hai đều không có ngăn xếp gọi hàm cần thiết cho Forth
SUBLEQ thậm chí không cho phép load/store gián tiếp, nên để làm bất cứ việc gì hơi phức tạp đều cần mã tự sửa đổi
Cách tiếp cận là tạo một máy ảo có thể thực hiện các chức năng đó trên cả hai máy, rồi đưa cả cooperative multithreading vào
Nếu cần heap thì viết bằng Forth, và cả tập word dấu phẩy động cũng viết bằng Forth. Nhiều MCU vẫn không có lệnh dấu phẩy động, và có thể xử lý bằng các lời gọi hàm phần mềm hiện thực chúng
Các trình biên dịch khác không được nhắc đến, nhưng có lẽ cũng dùng cách tương tự. Một số trình thông dịch BASIC cũng hiện thực VM rồi nhắm tới nó, và P-Code cũng tương tự
Phần lớn bộ nhớ hệ thống cơ bản là video RAM, và phải truy cập bằng một quy trình khá rườm rà là
poke/peekcác thanh ghi của chip videoChip video duy trì một con trỏ bộ nhớ hiện tại tự tăng, nên khi đọc hoặc ghi liên tiếp thì con trỏ tăng thêm 1, nhưng bản thân việc phần lớn bộ nhớ hệ thống chỉ có thể truy cập theo cách này đã khiến việc viết chương trình lớn trở nên khó khăn
Vì vậy TI đã tạo ra một máy trừu tượng tên là GPL để làm cho việc truy cập video RAM này tự nhiên hơn. Tuy nhiên, vì nó được thông dịch chạy trên TMS9900 nên chậm hơn mã native, và CPU cũng chỉ có thể truy cập RAM của chip video vào những thời điểm chip không scan-out màn hình, chẳng hạn trong khoảng hồi ngang/dọc, nên còn chậm hơn nữa
Mã BASIC và biến cũng đều nằm trong bộ nhớ video này, nên việc trình thông dịch BASIC của TI-99/4A được viết bằng gì cũng khá rõ. Nó hoàn toàn không nhanh
Điểm thú vị là TMS9900 không có thanh ghi đa dụng thực sự. Các thanh ghi workspace WR0~WR15 nằm đâu đó trong bộ nhớ, và thanh ghi con trỏ workspace WP trỏ tới chúng
Các thanh ghi vật lý của CPU chỉ có ba cái: PC, WP và thanh ghi trạng thái. Kết quả là có thể thực hiện một dạng register windowing rất sơ khai; khi rẽ nhánh bằng lệnh
BLWP, một tập “thanh ghi” mới ở vị trí khác trong bộ nhớ được kích hoạt, và địa chỉ trả về được lưu trong workspace mớiDạo này tôi hay nói về TI-99/4A vì đang làm một dự án cá nhân là viết assembler cho dòng máy này
Nói rằng một số bộ xử lý lưu địa chỉ trả về vào word ngay trước lệnh đầu tiên của subroutine là đúng, và PDP-8 đã làm như vậy
Sự tiến hóa của PDP-8 cũng có thể xem như hành trình hỗ trợ phần cứng cho đệ quy
Ban đầu, lệnh
JMSnhét địa chỉ trả về vào word đầu tiên của hàm. Cũng thường gặp trường hợp caller đặt tham số sau lệnhJMS, còn callee đọc tham số theo offset so với lệnh trả về, đồng thời tăng dần mỗi lần để địa chỉ trả về lại trỏ về vị trí mãVề sau, cách dùng một trong các vị trí tự tăng để tạo một ngăn xếp đơn giản trở nên khá phổ biến. PDP-8 có 8 vị trí bộ nhớ tự tăng mỗi khi được dùng làm con trỏ, và prologue/epilogue của hàm trực tiếp quản lý ngăn xếp này, cho phép đệ quy hoàn chỉnh
Muộn hơn nữa, các hiện thực vi xử lý như Harris 6120 được bổ sung stack phần cứng, cải thiện hiệu năng
R, tức lệnh lưu địa chỉ trả vềLệnh này lưu
PC+1đã được tăng sẵn vào phần địa chỉ lệnh của vị trí đích, và theo quy ước, đích đó là lệnh rẽ nhánh vô điều kiện ngay trước điểm bắt đầu subroutineSau lệnh
Rlà lệnh rẽ nhánh vô điều kiệnUđể đi tới subroutine đóSubroutine trả về bằng cách rẽ nhánh tới địa chỉ ngay trước nó, nơi chứa một lệnh rẽ nhánh vô điều kiện quay lại ngay sau điểm gọi
Trừ khi dùng một quy ước gọi nâng cao hơn, đệ quy là không thể. Và mọi mã lệnh trong assembly language đều chỉ là một chữ cái
Trong các chương trình viết cho AVR-8, dùng quy ước gọi C đôi khi cảm giác như điên rồ
Nếu viết assembly, bạn có thể giữ các biến vòng lặp nội bộ trong file thanh ghi lớn, hoặc dùng các cách được mô tả trong bài
Cách “tô màu” hàm trong những ứng dụng như vậy cũng hay. Nếu biết hàm đỏ và hàm xanh không bao giờ hoạt động đồng thời, bạn có thể tái sử dụng biến cục bộ hoặc tham số của chúng
Trong một dự án codebase vi điều khiển mà tôi từng tham gia, nhiều lập trình viên đã mất vài tuần truy vết các lỗi khó bắt ở nhiều subsystem
Khi di chuyển mã, lỗi cũng di chuyển theo. Sau khi truy vết một chút và đặt bẫy, có thể tìm ra những vị trí mã mà call stack trở nên quá sâu và ghi đè lên các cấu trúc dữ liệu khác
Khi mới học lập trình, tôi đã bị buộc phải lập trình đúng theo kiểu này. Không phải thập niên 1970, mà là năm 2001
Bởi trải nghiệm lập trình đầu tiên của tôi là “ngôn ngữ” scripting bán đồ họa do công cụ phát triển game RPG Maker 2000 cung cấp
Nếu chưa từng thấy scripting của RM2K, hãy tưởng tượng một thứ pha trộn giữa Scratch và chế độ Emacs Paredit. Ví dụ: https://forums.rpgmakerweb.com/data/attachments/21/21958-f89...
Trông giống văn bản, nhưng không thể chỉnh sửa như văn bản; chỉ có thể chỉnh sửa dưới dạng các khối kèm hộp thoại thuộc tính
Dĩ nhiên ngôn ngữ scripting của RPG Maker cũng chẳng có thứ xịn sò như stack. Nếu cần một subroutine có thể tái sử dụng, bạn phải cấp phát các biến toàn cục bí mật để làm tham số, và không có tính reentrant
Nhìn lại thì nếu đủ cố chấp, có lẽ đã có thể triển khai cả register lẫn runtime stack bên trong RPG Maker 2000
Ban đầu nghe có vẻ dễ. Có thể tạo các “register” giả kiểu zero page của 6502, và cũng có thể tạo stack bằng truy cập biến gián tiếp (https://rpgmaker.net/tutorials/523/)
Vấn đề là RM2K có tính đồng thời dưới dạng script “parallel process”. Nếu các parallel process dùng những trừu tượng này, các “thread” khác nhau sẽ ghi đè trạng thái của nhau lung tung
Vì vậy cần nhiều zero page và stack cho mỗi “lõi ảo”, rồi phải cấp phát/gắn/schedule lõi ảo cho từng script song song. Nói cách khác, phải bằng cách nào đó khiến mỗi script có một stack pointer mà chỉ nó biết
Muốn ổn định trước race condition thì thường cần thứ gì đó như mutex
Nghĩ đến độ dai dẳng của các nhà phát triển game RPG Maker, tôi đoán hẳn đã có ai đó tìm ra cách đánh lừa một tính năng runtime nào đó để nó hoạt động như mutex, nhưng thật lòng tôi sợ đến mức không muốn biết họ thực sự đã làm gì
Tôi nhớ từng tải một game trên rpgmaker.net có triển khai custom battle system. Đó là một bản triển khai thay thế toàn bộ hệ thống chiến đấu tích hợp bằng những kỹ thuật giống như bạn mô tả
Khi mở nó trong editor để xem cách hoạt động, tôi hoàn toàn choáng ngợp. Có hàng trăm “biến”, và nếu nhớ không nhầm thì chỉ cho phép i64, cùng hàng trăm “switch” nữa. Switch là boolean
Khi đó tôi hoàn toàn chưa có khái niệm gì về stack, heap hay lời gọi hàm
Tôi không thể tưởng tượng nổi đã phải bỏ ra bao nhiêu năng lượng để tạo ra rồi bảo trì/debug thứ đó
Nếu tôi nhớ đúng, khi viết chương trình BASIC trên ZX81, tôi đã viết theo kiểu gần như “không có stack”
1 GOTO 3010 LET C = A + B20 RETURN30 LET A = 140 LET B = 250 GOSUB 1060 LET A = C70 LET B = 380 GOSUB 1090 PRINT CRUN6Nói cách khác, tôi đang tự làm công việc mà bài viết nói là compiler làm. Số dòng là địa chỉ bộ nhớ, còn các biến ẩn thì không hề ẩn với tôi. Vì chính tôi là compiler
Việc duy nhất interpreter làm cho tôi là lưu địa chỉ trả về của
GOSUBTuy vậy, đoạn code có thể sai cú pháp hoặc trí nhớ của tôi có thể bị méo mó. 40 năm là một quãng thời gian dài, nhưng ý tưởng tổng quát thì đúng
Ngoài ra, bộ xử lý Z80 trong máy có chức năng quản lý stack. BASIC interpreter thật sự rất đơn giản, nhưng cũng có lý do bào chữa: chỉ có 1KB RAM và 8KB ROM chứa OS, interpreter, mọi thứ
GOSUBlưu số dòng hoặc một tham chiếu khác đểRETURNtham chiếu tới, và nếu lồng các lời gọiGOSUBthì phải nhớ nhiều điểm trả về, nên cần một dạng stack nào đóTuy nhiên trong một số BASIC, thay vì stack tổng quát, chỉ có một mảng cố định các con trỏ trả về và một chỉ số vị trí hiện tại, chẳng hạn độ sâu lời gọi bị cố định là 7. Với lập trình viên thì nó hoạt động như call stack
Tất nhiên đó không phải là stack “đúng nghĩa” có biến cục bộ/tham số như người ta thường kỳ vọng khi nói đến stack
Trong môi trường mặc định của BBC BASIC, có thể làm một demo thú vị cho thấy chuyện gì xảy ra khi gọi lồng nhau, kể cả đệ quy. Nếu đặt vị trí stack ở đỉnh bộ nhớ hiển thị và không vẽ gì ở đó, bạn có thể thấy stack lớn dần khi chương trình chạy
Vì độ phân giải màn hình thấp, địa chỉ trả về 2 byte hiện thành 8 pixel to ở chế độ màn hình 1 hoặc 5. Ở chế độ 2 thì là 4 pixel nhưng có màu nhấp nháy nên kém hay hơn; còn ở chế độ 0, 3, 4, 6 thì là 16 pixel, nhưng nhìn ở mức bit khó nhận ra hơn so với mẫu lặp 8 màu
Trước khi có heap có thể mở rộng tùy ý, lập trình viên ít nhất cũng phải dùng một chút phán đoán kỹ thuật
Vì họ phải xét đến phân bố xác suất của đầu vào và định cỡ phù hợp cho mọi vùng lưu trữ trung gian
Vì thế mới có mục “BUGS AND LIMITATIONS”
Vì vậy mọi thứ được cấp phát tĩnh tại thời điểm biên dịch, và cần biết đầu vào sẽ tiêu thụ bao nhiêu bộ nhớ
Nhưng việc biết cận trên của mức tiêu thụ bộ nhớ từng là điều bình thường ngay cả với lập trình viên ứng dụng. Vì chẳng ai muốn hết bộ nhớ cả
Có vẻ ngày nay người ta cứ để mức dùng bộ nhớ theo kiểu YOLO
Chẳng hạn đó là một cải thiện lớn so với các hạn chế kiểu độ dài lệnh tối đa của sed là hữu hạn và ngắn
Tôi đã lập trình hàm quá lâu đến mức thật sự khó hình dung phải viết mã thế nào mà không dùng đệ quy
Về mặt kỹ thuật, tôi biết cách chuyển thuật toán đệ quy thành thuật toán lặp, và cũng từng làm việc đó ở những nơi có ràng buộc tài nguyên lớn, nhưng tôi không thích
Thường thì cách đệ quy đẹp hơn, và tôi cho rằng trong 99% trường hợp là đủ nhanh. Nếu trình biên dịch hỗ trợ đệ quy đuôi thì gần như 100%, nhưng với phần lớn các tác vụ thú vị hơn, dù sao cũng phải tự duy trì stack
Thỉnh thoảng tôi cố ý làm những việc như vậy để học xem trước khi tôi ra đời người ta đã làm thế nào. Tôi thỉnh thoảng mày mò game Commodore 64, và cảm nhận rất rõ rằng việc giờ đây quen với phần cứng nhanh, rẻ và dễ dùng là một sự xa xỉ đến mức nào
Muốn dùng đệ quy trên những cỗ máy cũ như vậy thì phải tự tạo cơ chế stack, mà ngay cả thế vẫn còn vấn đề phải xử lý vì về cơ bản không có cách nào dùng được ngoài lưu trữ toàn cục
Tôi đã sống qua thời đó, nhưng không muốn khuyên ai trải nghiệm cả
Trong tính năng
@letcủa Enhanced GNU Awk, các khối@letbên ngoài hàm, chẳng hạn trong khốiBEGINhoặcEND, được để cho trình biên dịch cấp phát biến toàn cục bí mậtCác biến này được tái sử dụng giữa các khối nhiều nhất có thể
$ ./gawk --dump-variables 'BEGIN { @let (a, b, c = 1) { } }'$ cat awkvars.out$let0001: untyped variable$let0002: untyped variable$let0003: 1ARGC: 1ARGIND: 0ARGV: array, 1 elementsBINMODE: 0[ .. snip many ]https://www.kylheku.com/cgit/egawk/about/
pingkhông được, mànc -z 104.37.63.7 443cũng không đượcCập nhật: có vẻ hạ tầng bảo mật bị hỏng. Tôi cũng không biết đó là gì và cũng không dùng Twitter. Kiểm tra AS thì là Google Fiber
Và tôi mong mọi người đừng doxx tôi