Cuộc sống đầy những sự lặp lại. Chúng ta đếm ngày trôi nhờ nhịp điệu tự nhiên quay quanh trục của trái đất, mặt trăng quay quanh trái đất, và của trái đất quay quanh mặt trời. Mỗi ngày mỗi khác nhưng cuộc sống thường được định hình bởi những thói quen đều đặn ngày qua ngày.
Ở một khía cạnh nào đó, lặp lại cũng là bản chất của máy tính. Chẳng ai dùng máy tính chỉ để cộng hai số với nhau. (Mong là thế!) Nhưng cộng một nghìn hay một triệu số thì sao? Đó đích thị là công việc của máy tính.
Mối liên hệ giữa máy tính và sự lặp lại đã rõ ràng từ sớm. Trong bài luận nổi tiếng năm 1843 bàn về Analytical Engine của Charles Babbage, Ada Lovelace đã viết:
Để cho ngắn gọn và rõ ràng, một nhóm lặp lại được gọi là một chu trình. Một chu trình các phép toán, do đó, phải được hiểu là bất kỳ tập hợp các phép toán nào được lặp lại nhiều hơn một lần. Dù nó chỉ được lặp lại hai lần, hay một số lần vô hạn, nó vẫn là một chu trình; bởi chính việc có sự lặp lại đã định hình nên nó. Trong nhiều trường hợp phân tích, sẽ có một nhóm lặp lại của một hay nhiều chu trình; tức là, một chu trình của một chu trình, hoặc một chu trình của các chu trình.
Trong ngôn ngữ hiện đại, những chu trình này thường được gọi là vòng lặp (loop). Thứ mà bà gọi là chu trình của một chu trình ngày nay được gọi là vòng lặp lồng nhau (nested loop).
CPU mà tôi đã và đang lắp ráp trong vài chương qua có vẻ hơi "phế" ở điểm này. Ở cuối chương trước, tôi đã cho bạn xem một chương trình nhỏ dùng để cộng 5 byte được lưu trong bộ nhớ bắt đầu từ địa chỉ 1000h:
Cặp thanh ghi HL được dùng để định địa chỉ bộ nhớ. Byte đầu tiên được đọc từ bộ nhớ vào bộ tích lũy bằng lệnh MOV, và sau đó các lệnh ADD liên tiếp sẽ cộng 4 byte còn lại vào bộ tích lũy. Sau khi mỗi byte được đọc từ bộ nhớ, giá trị trong cặp thanh ghi HL sẽ được tăng lên bằng lệnh INX. Cuối cùng, lệnh STA sẽ lưu kết quả trở lại bộ nhớ.
Chương trình này có thể cải tiến như thế nào để cộng một trăm hay một ngàn byte lại? Chẳng lẽ bạn cứ cắm đầu viết thêm các lệnh INX và ADD cho vừa đúng với số lượng byte cần cộng? Cách đó nghe chừng không ổn lắm. Nó không phải là giải pháp xài được cho các nhu cầu khác. Nói cách khác, nó không phải là một giải pháp tổng quát.
Thay vào đó, sẽ tiện lợi hơn nếu có một lệnh mới cho phép lặp lại một chuỗi lệnh nhất định, trong trường hợp này là các lệnh INX và ADD. Nhưng nó trông ra sao?
Thoạt đầu, một lệnh như vậy có vẻ quá dị biệt so với các lệnh hiện có đến mức bạn có thể lo sợ nó có khi phải đại tu toàn bộ CPU. Nhưng đừng vội tuyệt vọng.
Thông thường, bộ đếm chương trình sẽ tăng lên sau khi CPU lấy từng byte lệnh. Đây là cách CPU đi từ lệnh này sang lệnh tiếp theo. Một lệnh thực hiện vòng lặp cũng phải thay đổi bộ đếm chương trình theo một cách nào đó, nhưng theo một cách khác.
Bạn đã thấy các lệnh như LDA và STA có 2 byte theo sau hợp thành một địa chỉ bộ nhớ 16-bit. Thử tưởng tượng một lệnh cũng được theo sau bởi 2 byte rất giống với LDA và STA, nhưng 2 byte đó không được dùng để định địa chỉ truy cập bộ nhớ. Thay vì vậy, chúng được chốt thẳng vào bộ đếm chương trình. Một lệnh như vậy sẽ bẻ lái lộ trình thực thi bình thường bởi vì nó khiến bộ đếm chương trình nhảy tới một địa chỉ khác.
Hãy gọi lệnh này là JMP, viết tắt của "jump" (nhảy). Đó là cách nó được gọi trong bộ vi xử lý Intel 8080. (Motorola 6809 gọi lệnh tương tự là BRA, viết tắt của "branch" - rẽ nhánh.)
Lệnh JMP có 2 byte theo sau tạo thành một địa chỉ 16-bit. Trong ví dụ sau, địa chỉ này là 0005h. Nó trông như này:
Mỗi khi lệnh INX và ADD thực thi, lệnh JMP sẽ tiếp tục quá trình thực thi tại địa chỉ 0005h cho một vòng khác của các lệnh INX và ADD. Đó chính là vòng lặp.
Thêm lệnh JMP vào CPU dễ đến bất ngờ. Nhưng hãy tạm gác chuyện đó lại một lát để nhìn nhận một vấn đề: Chương trình nhỏ với lệnh JMP này sẽ chạy mãi mãi. Không có cách nào để dừng lại, và vì lý do đó, nó được gọi là một vòng lặp vô hạn(infinite loop). Giá trị của HL sẽ tiếp tục được tăng lên, và byte tại địa chỉ đó sẽ tiếp tục được cộng vào tổng trong bộ tích lũy. Cuối cùng, HL sẽ chạm ngưỡng FFFFh ở tận cùng bộ nhớ. Sau khi được tăng lên lần nữa, nó sẽ quay vòng về 0000h và bắt đầu lôi chính các byte lệnh ra để cộng vào bộ tích lũy!
Vòng lặp cực kỳ quan trọng trong lập trình, nhưng quan trọng không kém là lặp vài lần chứ không phải luôn luôn.
Liệu có sẵn thứ gì trong CPU có khả năng điều khiển bước nhảy xảy ra hay không?
Có đấy. Bạn còn nhớ Bộ logic số học (ALU) chế tạo ở Chương 21 đã lưu vài cờ trong chốt chứ. Đó là Cờ nhớ, Cờ Zero, và Cờ Dấu, và chúng lần lượt cho biết liệu phép toán ALU có sinh nhớ, kết quả có bằng 0, và bit cao nhất của kết quả có phải là 1 hay không (báo cho một số bù hai âm).
Chúng ta có thể mường tượng ra một lệnh chỉ thực hiện bước nhảy nếu Cờ Zero được bật, hoặc nếu Cờ Zero không được bật. Thực tế, chúng ta có thể định nghĩa hẳn một bộ sưu tập nhỏ các lệnh nhảy:
Tôi không hề bịa ra mấy lệnh này đâu nhé! Đây là các lệnh được triển khai bởi bộ vi xử lý Intel 8080 mà tôi đang dùng làm kim chỉ nam xây dựng tập con của CPU đó. Chữ addr trong cột đầu tiên là một địa chỉ bộ nhớ 2-byte theo sau mã thao tác.
Lệnh JMP được biết đến như một bước nhảy vô điều kiện (unconditional jump). Nó buộc CPU phải thay đổi lộ trình thực thi bình thường bất kể các cờ ALU đang được thiết lập ra sao. Phần còn lại được gọi là các bước nhảy có điều kiện (conditional jumps). Những lệnh này chỉ làm thay đổi bộ đếm chương trình nếu các cờ nhất định được bật hoặc không được bật trong ALU. (CPU 8080 còn hỗ trợ thêm hai lệnh nhảy có điều kiện nữa dựa trên cờ Chẵn Lẻ (Parity flag). Tôi đã nhắc đến cờ đó ở Chương 21, nhưng CPU của tôi không cần nó.)
Hãy xem các lệnh nhảy có điều kiện này hoạt động ra sao trong một chương trình. Giả sử bạn muốn cộng 200 byte được lưu trong bộ nhớ bắt đầu từ địa chỉ 1000h.
Mẹo ở đây là dùng một trong các thanh ghi để lưu một giá trị gọi là bộ đếm (counter). Bộ đếm bắt đầu từ giá trị 200, chính là số lượng byte cần cộng. Mỗi khi một byte được truy cập và cộng vào, bộ đếm này sẽ bị trừ đi 1. Tại bất kỳ thời điểm nào, giá trị của bộ đếm sẽ cho biết còn bao nhiêu byte nữa phải cộng. Khi nó chạm mốc 0, công việc hoàn tất.
Điều này nghĩa là chương trình phải tung hứng hai phép toán số học cùng lúc. Nó cần duy trì một tổng cộng dồn của các byte đang được cộng, và cần giảm bộ đếm mỗi khi cộng thêm một byte mới.
Có chút rắc rối nảy sinh: Như bạn còn nhớ, mọi phép toán số học và logic đều xài bộ tích lũy, điều đó có nghĩa là chương trình phải dời các byte từ các thanh ghi vào bộ tích lũy để tính toán; sau đó nó lại phải đẩy các byte mới ngược về các thanh ghi.
Hãy quyết định lưu tổng cộng dồn của các byte vào thanh ghi B, và bộ đếm vào thanh ghi C. Những giá trị này phải được dời sang bộ tích lũy đối với bất kỳ phép toán số học nào và sau đó lại dời về B và C cho lần lặp tiếp theo của lệnh.
Vì chương trình này hơi dài hơn mấy cái bạn từng thấy, tôi đã chia nó thành ba phần.
Phần đầu tiên của một chương trình máy tính thường được gọi là phần khởi tạo(initialization):
Phần này thiết lập giá trị tổng hợp 16-bit của cặp thanh ghi HL thành 1000h, đây là vị trí của các số cần cộng. Thanh ghi C được đặt thành 200 thập phân (C8h trong hệ thập lục phân), đây là số lượng các số cần cộng. Cuối cùng, thanh ghi B được đặt thành số đầu tiên trong danh sách đó.
Phần thứ hai của chương trình này chứa các lệnh lặp lại:
Phần này bắt đầu bằng cách sao chép giá trị của bộ đếm vào bộ tích lũy. Lệnh SUI trừ đi 1 từ số đó. Trong lần lặp đầu tiên, giá trị 200 biến thành 199. Nếu giá trị đó bằng 0 (mà rõ ràng là chưa phải lúc này), lệnh JZ sẽ nhảy đến địa chỉ 0015h, tức là địa chỉ tiếp theo sau khối lệnh. Loại lệnh như thế được gọi là thoát khỏi(breaking out) vòng lặp.
Nếu không, giá trị trong bộ tích lũy (lúc này đang là 199 ở vòng lặp đầu tiên) được chuyển trở lại thanh ghi C. Bây giờ HL có thể tăng lên bằng lệnh INX. Tổng cộng dồn (lưu ở thanh ghi B) được chuyển sang A. Giá trị tại địa chỉ bộ nhớ HL được cộng vào đó, và rồi tổng mới lại được chép về thanh ghi B. Sau đó một lệnh JMP vô điều kiện sẽ nhảy ngược lên đầu để bắt đầu vòng lặp mới.
Mỗi lần đi qua đoạn mã này thường được gọi là một lần lặp(iteration). Cuối cùng, giá trị trong thanh ghi C sẽ là 1, và khi lấy nó trừ đi 1, kết quả sẽ bằng 0, lúc đó lệnh JZ nhảy đến địa chỉ 0015h:
Thanh ghi B chứa tổng cuối cùng của tất cả 200 số. Nó được chuyển vào bộ tích lũy để chuẩn bị cho lệnh STA, lệnh này sẽ cất giá trị đó vào bộ nhớ. Sau đó chương trình dừng lại.
Hãy để ý rằng chương trình này rất dễ chỉnh sửa nếu các số cần cộng nằm ở một vị trí bộ nhớ khác hoặc nếu số lượng nhiều hay ít hơn 200. Tất cả những thông tin đó đều được cài đặt ở ngay đầu chương trình và có thể thay đổi dễ dàng. Sẽ rất tốt nếu viết chương trình máy tính mà nghĩ tới tương lai nó sẽ thay đổi thế nào.
Rất hiếm khi các chương trình máy tính chỉ có thể được viết theo chỉ một cách. Có một cách hơi khác để viết chương trình này mà chỉ dùng duy nhất một lệnh nhảy. Phiên bản này mở đầu gần giống phiên bản trước:
Sau khi giá trị tiếp theo được cộng vào, giá trị đếm trong thanh ghi C được chuyển sang A, giảm đi 1, và giá trị mới lại được chuyển trở lại thanh ghi C. Sau đó lệnh JNZ sẽ nhảy về đầu vòng lặp nếu kết quả của lệnh SUIkhông bằng 0.
Nếu kết quả của lệnh SUIbằng 0, thì chương trình tiếp tục với lệnh nằm ngay sau JNZ. Đây là phần kết thúc của chương trình, tiến hành lưu tổng tích lũy vào bộ nhớ và dừng lại:
Bằng cách loại bỏ một trong các lệnh nhảy, chương trình đã ngắn đi 3 byte, nhưng trông nó có vẻ hơi phức tạp hơn một chút. Bạn có thấy tại sao thanh ghi C cần được đặt thành 199 thay vì 200 không? Là vì giá trị đó đang bị điều chỉnh và kiểm tra sau khi một giá trị từ bộ nhớ đã được cộng. Nếu danh sách chỉ có hai con số cần cộng, cả hai số đó sẽ được truy cập trước khi diễn ra lần lặp đầu tiên của lệnh JNZ. Do đó, C phải được khởi tạo thành 1 thay vì 2. Chương trình này sẽ hoàn toàn tắt điện nếu danh sách chỉ có đúng một byte. Bạn hiểu lý do rồi chứ?
Mọi người rất hay mắc sai lầm khi xác định xem một vòng lặp phải chạy bao nhiêu lần. Những rắc rối kiểu này phổ biến trong lập trình đến mức chúng được ưu ái đặt cho hẳn một cái tên. Chúng được gọi là lỗi off-by-one.
Có thể bạn không biết cần phải cộng bao nhiêu số, nhưng bạn biết chắc số cuối cùng trong danh sách là 00h. Giá trị 00h này báo hiệu cho chương trình là danh sách đã kết thúc. Giá trị như vậy đôi khi được gọi là lính canh(sentinel). Trong trường hợp này, bạn sẽ muốn dùng một lệnh Compare để so sánh giá trị từ bộ nhớ với 00h nhằm xác định khi nào thì thoát khỏi vòng lặp.
Bắt đầu từ chương trình thay thế dùng sentinal này, tôi sẽ ngừng hiển thị các giá trị trong bộ nhớ, và chỉ cho bạn xem các câu lệnh thôi. Thay vì hiển thị các địa chỉ bộ nhớ, tôi sẽ dùng các từ được gọi là nhãn (label). Trông chúng như một từ ngữ bình thường, nhưng chúng vẫn đại diện cho các vị trí trong bộ nhớ. Các nhãn này luôn có dấu hai chấm đi kèm theo sau:
Start: MVI L,00h
MVI H,10h
MVI B,00h
Loop: MOV A,M
CPI 00h
JZ End
ADD B
MOV B,A
INX HL
JMP Loop
End: MOV A,B
STA Result
HLT
Result:
Sau khi giá trị tiếp theo trong bộ nhớ đã được tải vào bộ tích lũy bằng lệnh MOV A,M, lệnh CPI sẽ so sánh nó với 00h. Nếu A bằng 00h, Cờ Zero sẽ được bật, và lệnh JZ sẽ nhảy đến điểm kết thúc. Nếu không, giá trị này sẽ được cộng vào tổng cộng dồn trong B, và HL được tăng lên để chuẩn bị cho lần lặp tiếp theo.
Sử dụng nhãn giúp chúng ta tránh phải tính toán địa chỉ bộ nhớ của các lệnh, nhưng việc tự tính ra vị trí bộ nhớ của các nhãn này vẫn luôn khả thi. Nếu chương trình bắt đầu tại vị trí bộ nhớ 0000h, thì ba lệnh đầu tiên mỗi lệnh ngốn 2 byte, vậy nên nhãn Loop sẽ đại diện cho địa chỉ bộ nhớ 0006h. Bảy lệnh tiếp theo chiếm tổng cộng 12 byte, do đó nhãn End chính là địa chỉ bộ nhớ 0012h, và Result là 0017h.
Nếu bạn chưa đoán ra thì bước nhảy có điều kiện là một tính năng cực kỳ quan trọng của CPU, có khi còn quan trọng hơn nhiều so với những gì bạn tưởng. Để tôi nói cho bạn nghe tại sao.
Năm 1936, một thanh niên 24 tuổi vừa tốt nghiệp Đại học Cambridge tên là Alan Turing bắt tay vào giải quyết một bài toán logic toán học do nhà toán học người Đức David Hilbert đưa ra, được gọi là Entscheidungsproblem, hay vấn đề quyết định: Liệu có tồn tại một quy trình nào có thể xác định xem một mệnh đề tùy ý trong logic toán học là có thể giải quyết được không—nghĩa là, liệu có thể xác định được mệnh đề đó đúng hay sai không?
Trong quá trình đi tìm lời giải cho câu hỏi này, Alan Turing đã chọn một cách tiếp cận vô cùng độc đáo. Ông đưa ra giả thuyết về sự tồn tại của một cỗ máy tính toán đơn giản hoạt động theo những quy tắc đơn giản. Ông không thực sự chế tạo cỗ máy này. Thay vào đó, nó là một cỗ máy trong óc. Nhưng bên cạnh việc chứng minh Entscheidungsproblem là sai, ông đã thiết lập một số khái niệm cơ bản về máy tính kỹ thuật số, mang lại những tác động vượt xa bài toán logic toán học này.
Cỗ máy tính tưởng tượng mà Turing phát minh ra ngày nay được biết đến với tên gọi là máy Turing (Turing machine), và xét về khả năng tính toán, nó tương đương về mặt chức năng với tất cả các máy tính kỹ thuật số từng được chế tạo từ đó đến nay. (Nếu bạn tò mò muốn khám phá bài báo gốc của Turing mô tả cỗ máy tưởng tượng của ông, cuốn sách The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine của tôi có thể sẽ hữu ích đấy.)
Các máy tính kỹ thuật số khác nhau chạy ở các tốc độ khác nhau; chúng có thể truy cập dung lượng bộ nhớ và lưu trữ khác nhau; chúng có các loại phần cứng khác nhau được gắn vào. Nhưng xét về sức mạnh xử lý, chúng đều tương đương nhau về mặt chức năng. Chúng đều có thể làm những loại công việc giống nhau bởi vì tất cả đều sở hữu một tính năng rất đặc biệt: một bước nhảy có điều kiện dựa trên kết quả của một phép toán số học.
Tất cả các ngôn ngữ lập trình hỗ trợ bước nhảy có điều kiện (hoặc một thứ gì đó tương đương) về cơ bản là tương đương nhau. Những ngôn ngữ lập trình này được gọi là Turing complete. Gần như mọi ngôn ngữ lập trình đều thỏa mãn điều kiện này, nhưng các ngôn ngữ markup—chẳng hạn như HyperText Markup Language (HTML) được dùng cho các trang web—thì không phải là Turing complete.
Bên cạnh các lệnh nhảy mà tôi đã liệt kê ở đầu chương này, còn một lệnh khác cũng rất hữu dụng để thực hiện các bước nhảy. Lệnh này dựa trên giá trị trong HL:
Lệnh | Mô tả | Opcode
-----+-------------------------------------+-------
PCHL | Sao chép HL vào bộ đếm chương trình | E9h
Bảy lệnh nhảy và PCHL khá dễ tích hợp vào mạch định thời đã chỉ ra ở chương trước. Trong mạch điện trên trang 366 ở Chương 23, hãy nhớ lại rằng ba bộ giải mã có các đầu vào tương ứng với 8 bit của mã thao tác:
3 bộ giải mã
Nhiều tổ hợp khác nhau của các đầu ra từ những bộ giải mã này sau đó được dùng để tạo ra tín hiệu khi opcode tương ứng với một lệnh nhảy.
Tất cả các lệnh nhảy ngoại trừ PCHL có thể được gom lại thành một nhóm với một cổng OR bảy-đầu-vào:
Cổng OR 7-đầu-vào
Mạch này có thể được dùng để tích hợp các lệnh nhảy vào mạch trên trang 367 ở Chương 23, mạch chuyên quyết định xem phải lấy bao nhiêu byte lệnh từ bộ nhớ và cần bao nhiêu chu kỳ để thực thi mỗi lệnh. Tín hiệu Jump Group báo hiệu rằng phải lấy 3 byte từ bộ nhớ: mã thao tác và một địa chỉ 2-byte. Lệnh PCHL chỉ dài 1 byte. Tất cả các lệnh này chỉ yêu cầu một chu kỳ thực thi, và chỉ liên quan đến bus địa chỉ.
Để thực thi các lệnh nhảy, hãy tạo một tín hiệu cho biết liệu một bước nhảy có điều kiện có nên xảy ra hay không. Các tín hiệu cho byte lệnh đã được giải mã phải được kết hợp với các cờ từ ALU ở Chương 21:
Mạch nhảy điều kiện
Sau đó, thật đơn giản để tích hợp các tín hiệu này vào ma trận diode ROM được hiển thị trên trang 374 ở Chương 23:
Tích hợp vào ma trận diode ROM
Lệnh JMP và các lệnh nhảy có điều kiện kích hoạt Chốt Lệnh 2 và 3 lên bus địa chỉ, trong khi lệnh PCHL kích hoạt HL lên bus địa chỉ. Trong mọi trường hợp, địa chỉ đó được lưu vào bộ đếm chương trình.
Một phiên bản tương tác của CPU nâng cấp này đã có sẵn trên trang web CodeHiddenLanguage.com.
Hầu hết mọi việc to tát cần làm trong một chương trình máy tính đều dính dáng tới sự lặp lại và trở thành ứng cử viên sáng giá cho một vòng lặp. Phép nhân là một ví dụ điển hình. Trở lại Chương 21, tôi đã hứa sẽ cho bạn xem cách để thuyết phục CPU này làm phép nhân, và giờ là lúc xem nó được thực hiện như thế nào.
Hãy xem trường hợp đơn giản nhất, đó là phép nhân 2 byte—ví dụ, 132 nhân 209, hay ở hệ thập lục phân là 84h nhân D1h. Hai số này được gọi là số nhân (multiplier) và số bị nhân (multiplicand), và kết quả là tích (product).
Nói chung, nhân một byte với một byte khác sẽ tạo ra một tích cỡ 2 byte. Đối với ví dụ của tôi, thật dễ dàng để nhẩm ra tích là 27.588 hoặc 6BC4h, nhưng hãy để CPU làm.
Trước đây tôi đã dùng các thanh ghi H và L cho một địa chỉ bộ nhớ 16-bit, nhưng bạn cũng có thể dùng H và L như các thanh ghi 8-bit bình thường, hoặc bạn có thể dùng cặp thanh ghi HL để lưu một giá trị 2-byte. Trong ví dụ này, tôi sẽ dùng HL để lưu tích. Mã để nhân hai byte bắt đầu bằng việc đặt thanh ghi B thành số bị nhân và C thành số nhân, và các thanh ghi H và L về 0:
Start: MVI B,D1h ; Đặt B thành số bị nhân
MVI C,84h ; Đặt C thành số nhân
MVI H,00h ; Khởi tạo HL về 0
MVI L,00h
Tôi đã thêm những mô tả ngắn gọn cho các lệnh ở bên phải sau dấu chấm phẩy. Đây được gọi là các chú thích(comment), và việc dùng dấu chấm phẩy để bắt đầu một chú thích có thể được tìm thấy trong tài liệu gốc của Intel về CPU 8080.
Đầu tiên tôi sẽ cho bạn xem cách nhân hai số đơn giản, về cơ bản chỉ là phép cộng lặp lại. Tôi sẽ cộng số nhân vào các thanh ghi HL với số lần bằng số bị nhân.
Bước đầu tiên là kiểm tra xem số bị nhân (lưu trong thanh ghi B) có bằng 0 hay không. Nếu bằng 0, phép nhân coi như xong:
Loop: MOV A,B ; Kiểm tra xem B có bằng 0 không
CPI 00h
JZ Done ; Nếu có thì xong
Nếu không, số nhân (lưu trong thanh ghi C) sẽ được cộng vào nội dung của các thanh ghi H và L. Hãy lưu ý rằng về cơ bản đây là một phép cộng 16-bit: C được cộng vào L bằng cách trước tiên chuyển nội dung của L vào bộ tích lũy, và sau đó 0 được cộng vào H cùng với nhớ (nếu có) phát sinh từ phép cộng đầu tiên:
MOV A,L ; Cộng C vào HL
ADD C
MOV L,A
MOV A,H
ACI 00h
MOV H,A
Giờ thì số bị nhân trong thanh ghi B sẽ bị giảm đi 1, báo hiệu rằng còn ít đi một con số cần được cộng vào HL, và chương trình nhảy ngược lên nhãn Loop để thực hiện một lần lặp nữa:
MOV A,B ; Giảm B
SBI 01h
MOV B,A
JMP Loop ; Lặp lại tính toán
Khi bước nhảy đến nhãn Done xảy ra trước đó, phép nhân đã hoàn tất, và các thanh ghi HL đang chứa tích:
Done: HLT ; HL chứa kết quả
Đây không phải là cách tốt nhất để nhân hai byte, nhưng nó có ưu điểm là dễ hiểu. Những cách giải quyết kiểu này đôi khi được gọi là các phương pháp vét cạn (brute-force). Ở đây không hề có chút mảy may bận tâm nào đến việc thực hiện phép nhân càng nhanh càng tốt. Mã code thậm chí còn chẳng buồn so sánh hai số để lấy số nhỏ hơn làm vòng lặp. Chỉ cần thêm một tí code vào chương trình là đã cho phép thực hiện 132 phép cộng số 209 thay vì phải cộng 209 lần số 132.
Có cách nào xịn hơn để thực hiện phép nhân này không? Hãy nghĩ xem bạn làm phép nhân thập phân trên giấy thế nào nhé:
132
× 209
-----
1188
264
-----
27588
Hai số nằm dưới đường gạch ngang đầu tiên là 132 nhân 9, và sau đó là 132 nhân 2 được dịch sang trái hai khoảng trắng, nói trắng ra là 132 nhân 200. Chú ý là bạn thậm chí còn chẳng thèm viết xuống 132 nhân 0, vì nó chỉ bằng không thôi. Thay vì phải nai lưng làm 209 phép cộng hoặc 132 phép cộng, chỉ có đúng hai con số cần được cộng lại!
Phép nhân này trông thế nào ở hệ nhị phân? Số nhân (132 trong hệ thập phân) là 10000100 trong hệ nhị phân, và số bị nhân (209 thập phân) là 11010001 trong hệ nhị phân:
Với mỗi bit trong số bị nhân (11010001) bắt đầu từ bên phải, hãy nhân bit đó với số nhân (10000100). Nếu bit đó là 1, thì kết quả là số nhân, được dịch sang trái cho mỗi bit. Nếu bit là 0, kết quả là 0 nên ta có thể phớt lờ nó đi.
Dưới đường gạch ngang đầu tiên chỉ có đúng bốn phiên bản của số nhân (10000100). Chỉ có bốn cái là vì chỉ có đúng bốn số 1 trong số bị nhân (11010001).
Cách tiếp cận này vắt kiệt số lượng các phép cộng xuống mức tối thiểu. Biểu diễn phép cộng như này thậm chí còn trọng yếu hơn nữa nếu bạn nhân số 16-bit hay 32-bit.
Tuy nhiên, nó có vẻ hơi phức tạp ở một số khía cạnh. Chúng ta sẽ cần phải kiểm tra xem bit nào của số bị nhân là 1 và bit nào là 0.
Việc kiểm tra các bit này sử dụng lệnh ANA (AND với thanh ghi tích lũy) của 8080. Lệnh này thực hiện phép toán AND theo bit (bitwise AND) giữa hai byte. Gọi là AND theo bit vì đối với mỗi bit, kết quả là 1 nếu các bit tương ứng của hai byte đều là 1, và là 0 cho mọi trường hợp khác.
Hãy đặt số bị nhân vào thanh ghi D. Trong ví dụ này, đó là byte D1h:
MVI D,D1h
Làm sao bạn biết bit ít quan trọng nhất của thanh ghi D có phải là 1 hay không? Hãy thực hiện phép toán ANA giữa D và giá trị 01h. Đầu tiên bạn có thể đặt thanh ghi E thành giá trị này:
MVI E,01h
Vì ALU chỉ làm việc với bộ tích lũy, bạn sẽ cần chuyển một trong các số vào thanh ghi tích lũy trước:
MOV A,D
ANA E
Kết quả của phép toán AND này là 1 nếu bit ngoài cùng bên phải của D (bit ít quan trọng nhất) là 1 và là 0 nếu ngược lại. Điều này nghĩa là Cờ Zero sẽ được bật nếu bit ngoài cùng bên phải của D là 0. Cờ đó cho phép một bước nhảy có điều kiện được thực hiện.
Với bit tiếp theo, bạn sẽ cần thực hiện phép toán AND không phải với 01h mà là 02h, và với các bit còn lại, bạn sẽ thực hiện các phép toán AND với 04h, 08h, 10h, 20h, 40h, và 80h. Nhìn vào chuỗi này một lúc, bạn có thể sẽ nhận ra rằng mỗi giá trị gấp đôi giá trị trước đó: 01h gấp đôi là 02h, và gấp đôi nữa là 04h, và gấp đôi lần nữa là 08h, v.v. Đây là một thông tin cực kỳ hữu ích!
Thanh ghi E bắt đầu ở mức 01h. Bạn có thể nhân đôi nó bằng cách cộng với chính nó:
MOV A,E
ADD E
MOV E,A
Giờ thì giá trị trong E bằng 02h. Nếu thi hành lại ba lệnh đó, nó sẽ bằng 04h, rồi 08h, v.v. Cái thao tác đơn giản này về cơ bản đang dịch chuyển tịnh tiến một bit từ vị trí ít quan trọng nhất đến vị trí quan trọng nhất, từ 01h đến 80h.
Cũng cần phải dịch số nhân để cộng vào kết quả. Điều này đồng nghĩa với việc số nhân sẽ không còn nằm gọn trong một thanh ghi 8-bit nữa, và nó phải bằng cách nào đó được xử lý như một giá trị 16-bit. Vì lý do đó, số nhân trước tiên được lưu trong thanh ghi C, nhưng thanh ghi B lại được đặt thành 0. Bạn có thể coi các thanh ghi B và C như một cặp lưu trữ số nhân 16-bit này, và nó có thể được dịch chuyển cho các phép cộng 16-bit. Tổ hợp các thanh ghi B và C có thể được gọi tắt là BC.
Đây là cách các thanh ghi được khởi tạo cho bộ số nhân (multiplier) phiên bản nâng cấp này:
Start: MVI D,D1h ; Số bị nhân
MVI C,84h ; Lưu số nhân vào BC
MVI B,00h
MVI E,01h ; Thử bit
MVI H,00h ; Dùng HL cho kết quả 2-byte
MVI L,00h
Phần lặp Loop mở màn bằng cách test xem một bit trong số bị nhân đang là 1 hay 0:
Loop: MOV A,D
ANA E ; Kiểm tra xem bit là 0 hay 1
JZ Skip
Nếu bit đó là 1, kết quả sẽ khác 0, và đoạn code tiếp theo sẽ được thực thi để cộng giá trị của cặp thanh ghi BC vào cặp thanh ghi HL. Tuy nhiên, các thanh ghi 8-bit cần phải được xử lý riêng rẽ. Để ý rằng ADD được dùng cho các byte thấp, trong khi ADC được xài cho byte cao để gom thêm nhớ:
MOV A,L ; Cộng BC vào HL
ADD C
MOV L,A
MOV A,H
ADC B
MOV H,A
Nếu bạn đang xài một con Intel 8080 hàng "real" chứ không phải bản fake tập con mà tôi vừa dựng, bạn có thể thay thế sáu lệnh đó bằng DAD BC, lệnh này cộng ngon ơ BC vào HL. DAD là một trong số vài lệnh của 8080 chuyên trị các giá trị 16-bit.
Nhiệm vụ tiếp theo là nhân đôi giá trị của BC, về cơ bản là dịch nó sang trái để dọn đường cho phép cộng tiếp theo. Đoạn mã này được thực thi bất kể BC đã được cộng vào HL hay chưa:
Skip: MOV A,C ; Nhân đôi BC, số nhân
ADD C
MOV C,A
MOV A,B
ADC B
MOV B,A
Bước tiếp theo là nhân đôi giá trị của thanh ghi E, gã "thử bit" của chúng ta. Nếu giá trị đó khác 0, thì chương trình sẽ nhảy ngược lên nhãn Loop để làm một vòng nữa.
MOV A,E ; Nhân đôi E, thử bit
ADD E
MOV E,A
JNZ Loop
Đoạn mã sau nhãn Loop được thực thi chính xác tám lần. Sau khi E đã được nhân đôi tám lần, thanh ghi 8-bit sẽ bị tràn, và E bây giờ sẽ bằng 0. Phép nhân đã hoàn tất:
Done: HLT ; HL chứa kết quả
Nếu bạn cần nhân hai giá trị 16-bit hay hai giá trị 32-bit, công việc hiển nhiên sẽ rắc rối hơn, và bạn sẽ cần nhiều thanh ghi hơn. Khi bạn cạn kiệt thanh ghi để lưu trữ các giá trị trung gian, bạn có thể xài tạm một góc của bộ nhớ để lưu trữ. Một vùng nhỏ bộ nhớ được dùng theo cách này thường được gọi là bộ nhớ nháp (scratchpad memory).
Mục đích của bài tập này không phải để dọa bạn. Mục đích cũng không phải để cản bước bạn dấn thân vào con đường lập trình máy tính. Nó là để chứng minh cho bạn thấy rằng một tập hợp các cổng logic biết "nghe lời" các mã lưu trong bộ nhớ thực sự có thể móc nối những thao tác vô cùng đơn giản lại với nhau để xử đẹp những tác vụ phức tạp.
Trong lập trình máy tính thực tế, phép nhân dễ thở hơn rất nhiều nhờ sử dụng các ngôn ngữ bậc cao(high-level languages), thứ mà tôi sẽ bàn ở Chương 27. Sự kỳ diệu của phần mềm nằm ở chỗ những người khác đã nai lưng ra làm phần việc khó nhằn để bạn không phải làm nữa.
Phép nhân trong mã máy đòi hỏi phải dịch các bit, việc này hồi nãy đã được làm bằng cách lấy một giá trị cộng với chính nó. Nếu bạn đang xài một con vi xử lý Intel 8080 thật, bạn sẽ có bài xịn hơn để dịch bit. Intel 8080 chứa bốn lệnh thực hiện việc dịch bit mà không cần sự rườm rà của việc lấy các thanh ghi cộng với chính chúng. Chúng được gọi là các lệnh quay(rotate):
Lệnh | Mô tả | Opcode
-----+-------------------+-------
RLC | Quay trái | 07h
-----+-------------------+-------
RRC | Quay phải | 0Fh
-----+-------------------+-------
RAL. | Quay trái qua nhớ | 17h
-----+-------------------+-------
RAR. | Quay phải qua nhớ | 1Fh
Các lệnh này luôn luôn biểu diễn trên giá trị nằm trong bộ tích lũy, và chúng có ảnh hưởng tới Cờ nhớ.
Lệnh RLC dịch các bit của bộ tích lũy sang bên trái. Tuy nhiên, bit quan trọng nhất sẽ được dùng để thiết lập cho cả Cờ nhớ và bit ít quan trọng nhất:
Lệnh RRC cũng từa tựa vậy ngoại trừ việc nó dịch các bit sang bên phải. Bit ít quan trọng nhất được dùng để thiết lập cho cả Cờ nhớ và bit quan trọng nhất:
Lệnh RAL na ná với việc nhân đôi bộ tích lũy, ngoại trừ việc Cờ nhớ hiện tại được dùng để thiết lập bit ít quan trọng nhất. Cái này rất có ích khi dịch một giá trị đa byte:
Mặc dù các lệnh quay này chắc chắn rất hữu dụng trong vài hoàn cảnh, nhưng chúng không thực sự thiết yếu, và tôi sẽ không thêm chúng vào CPU đang lắp ráp.
Bạn đã thấy cách có thể dùng các bước nhảy và vòng lặp để thực thi một nhóm lệnh lặp đi lặp lại. Nhưng cũng sẽ có lúc bạn thèm khát một cách uyển chuyển hơn để thực thi một nhóm lệnh. Có thể bạn đã viết một nhóm lệnh mà bạn cần thực thi từ nhiều phần khác nhau của một chương trình máy tính. (Có thể một phép nhân đa năng là một trong số đó.) Những nhóm lệnh này thường được gọi là các hàm (functions) hoặc thủ tục (procedures) hoặc chương trình con (subroutines), hay đơn giản là các thủ tục (routines).
CPU Intel 8080 triển khai các chương trình con bằng một lệnh mang tên CALL. Cú pháp của lệnh CALL trông rất giống với JMP ở chỗ nó được theo sau bởi một địa chỉ bộ nhớ:
CALL addr
Giống lệnh JMP, lệnh CALL nhảy đến địa chỉ đó để tiếp tục thực thi. Nhưng CALL khác JMP ở chỗ nó lưu lại một "lời nhắc" về nơi nó vừa nhảy đi—cụ thể là địa chỉ của lệnh theo sau lệnh CALL. Như bạn sẽ thấy, địa chỉ này được lưu ở một nơi cực kỳ đặc biệt.
Một lệnh khác, gọi là RET (nghĩa là "return" - quay về), cũng na ná JMP, nhưng địa chỉ mà nó nhảy tới chính là địa chỉ đã được lệnh CALL cất giữ. Các chương trình con thường sẽ kết thúc bằng một câu lệnh RET.
Đây là các lệnh 8080 cho CALL và RET:
Lệnh | Mô tả | Opcode
-----+---------------------------------+-------
CALL | Nhảy đến một chương trình con | CDh
-----+---------------------------------+-------
RET | Quay về từ một chương trình con | C9h
Intel 8080 cũng hỗ trợ các lệnh gọi có điều kiện và quay về có điều kiện, nhưng chúng ít được xài hơn nhiều so với CALL và RET.
Hãy xem một ví dụ thực tế. Giả sử bạn đang viết một chương trình cần hiển thị giá trị của một byte—tỉ dụ như byte 5Bh. Bạn đã thấy ở Chương 13 cách dùng ASCII để hiển thị chữ cái, chữ số, và ký hiệu. Nhưng bạn không thể hiển thị giá trị của byte 5Bh bằng cách xài mã ASCII 5Bh. Đó là mã ASCII của ký tự ngoặc vuông mở! Thay vào đó, một byte như 5Bh sẽ cần được chuyển đổi thành hai mã ASCII:
35h, là mã ASCII cho ký tự 5
42h, là mã ASCII cho ký tự B
Cách chuyển đổi này hiển thị giá trị của byte theo một cách mà con người có thể hiểu được (hoặc ít nhất là những người rành hệ thập lục phân).
Chiến thuật ở đây là trước tiên "chẻ" byte đó thành hai phần: 4 bit trên và 4 bit dưới, đôi khi được gọi là các nibbles (nửa byte). Trong ví dụ này, byte 5Bh được chẻ thành 05h và 0Bh.
Sau đó, mỗi giá trị 4-bit này sẽ được biến hóa thành ASCII. Với các giá trị từ 0h đến 9h, các mã ASCII là từ 30h đến 39h cho các ký tự từ 0 đến 9. (Hãy liếc qua các bảng trên trang 153 và 154 ở Chương 13 nếu bạn cần "ôn bài" về ASCII.) Với các giá trị từ Ah đến Fh, các mã ASCII là từ 41h đến 46h cho các ký tự.
Đây là một chương trình con chuyển đổi một giá trị 4-bit trong bộ tích lũy thành ASCII:
Tôi lại phải lôi vị trí bộ nhớ ra vì chúng rất quan trọng để chứng minh chuyện gì đang diễn ra ở đây. Chương trình con này tình cờ bắt đầu ở vị trí bộ nhớ 1532h, nhưng chẳng có gì to tát về điều đó cả. Chẳng qua là tôi thích đưa chương trình con này vào đó thôi.
Chương trình con mặc định rằng bộ tích lũy chứa giá trị cần đổi. Một giá trị mặc định như vậy thường được gọi là một đối số (argument) hoặc tham số (parameter) của chương trình con.
Chương trình con bắt đầu bằng một lệnh Compare Immediate, lệnh này thiết lập các cờ ALU y như thể nó vừa làm một phép trừ. Nếu bộ tích lũy đang đựng 05h (ví dụ thế), thì việc trừ 0Ah khỏi số đó cần mượn, nên lệnh này sẽ bật Cờ nhớ. Bởi vì Cờ nhớ được bật, lệnh JC sẽ nhảy đến lệnh tại nhãn Number, lệnh này sẽ cộng 30h vào bộ tích lũy, biến nó thành 35h, tức là mã ASCII cho số 5.
Nếu thanh ghi tích lũy chứa thứ gì đó như giá trị 0Bh, thì việc trừ đi 0Ah không cần mượn gì cả. Lệnh CPI không bật Cờ nhớ, nên chả có bước nhảy nào xảy ra. Đầu tiên, 07h được cộng vào bộ tích lũy (biến nó thành 0Bh cộng 07h, hay 12h, trong ví dụ này), và rồi lệnh ADI thứ hai sẽ cộng thêm 30h, biến nó thành 42h, tức mã ASCII cho chữ B. Việc cộng hai giá trị là một mánh lới nhỏ để tái sử dụng lệnh ADI thứ hai cho cả chữ cái lẫn chữ số.
Trong cả hai trường hợp, lệnh cuối cùng là RET, lệnh này sẽ kết thúc chương trình con.
Tôi đã nói là chúng ta sẽ viết một chương trình con chuyển đổi nguyên một byte thành hai mã ASCII. Chương trình con thứ hai này có hai lệnh CALL gọi tới Digit, một lần với nibble thấp và một lần với nibble cao. Ở đầu chương trình con, byte cần chuyển đổi sẽ nằm trong bộ tích lũy, và kết quả được lưu vào các thanh ghi H và L. Chương trình con này, mang tên ToAscii, tình cờ tọa lạc ở địa chỉ bộ nhớ bắt đầu từ 14F8h:
Chương trình con này trước tiên lưu byte gốc vào B, và sau đó lệnh ANI (AND Immediate) thực hiện phép toán AND theo bit với 0Fh để chỉ giữ lại 4 bit thấp. Sau đó nó thi hành một lệnh CALL tới chương trình con Digit, tại địa chỉ 1532h. Kết quả đó được chốt vào L. Byte gốc được lôi ra từ thanh ghi B, và rồi bốn lệnh RRC sẽ dịch nibble cao xuống chỗ của 4 bit thấp. Sau một lệnh ANI nữa là một call khác tới Digit. Kết quả đó được lưu vào thanh ghi H, và chương trình con kết bằng lệnh RET.
Hãy xem nó chạy sao. Đâu đó có thể sẽ có một mẩu mã chứa một lệnh CALL gọi tới chương trình con ToAscii, tại 14F8h:
Khi chương trình tiếp tục tại địa chỉ 0628h, các giá trị của H và L sẽ chứa mã ASCII cho hai chữ số của 5Bh.
Thế CALL và RET hoạt động ra sao?
Tôi đã nói lúc nãy rằng khi một lệnh CALL được thực thi, một địa chỉ sẽ được lưu vào một nơi cực kỳ đặc biệt để cho phép mã tiếp tục chạy sau khi chương trình con đã hoàn tất. Cái nơi cực kỳ đặc biệt này được gọi là stack. Nó là một khu vực trong bộ nhớ nằm cách xa mọi thứ khác nhất có thể. Trong một CPU 8-bit như Intel 8080, stack nằm ở tận cùng bộ nhớ.
Intel 8080 chứa một thanh ghi 16-bit gọi là stack pointer. Khi 8080 được reset, stack pointer được khởi tạo ở giá trị 0000h. Tuy nhiên, một chương trình có thể thay đổi địa chỉ đó bằng các lệnh SPHL (đặt stack pointer từ HL) hoặc LXI SP (tải địa chỉ tức thời vào stack pointer). Nhưng hãy cứ để nó ở giá trị mặc định là 0000h.
Khi Intel 8080 thực thi lệnh CALL ToAscii, vài chuyện sẽ xảy ra tuần tự:
Stack pointer bị giảm đi. Vì nó đã được khởi tạo từ giá trị 0000h, việc giảm đi khiến nó biến thành FFFFh, tức là giá trị 16-bit lớn nhất, và trỏ thẳng vào byte cuối cùng của bộ nhớ 16-bit.
Byte cao của địa chỉ nằm ngay sau lệnh CALL (tức là địa chỉ 0628h, và cũng là giá trị hiện tại của bộ đếm chương trình) được lưu vào bộ nhớ tại vị trí được stack pointer trỏ tới. Byte đó là 06h.
Stack pointer tiếp tục bị giảm đi, lúc này mang giá trị FFFEh.
Byte thấp của địa chỉ nằm ngay sau lệnh CALL được cất vào bộ nhớ tại vị trí được stack pointer trỏ tới. Byte đó là 28h.
Địa chỉ nằm trong lệnh CALL (14F8h) được nhét vào bộ đếm chương trình, về cơ bản là nhảy bổ tới địa chỉ đó. Đây là địa chỉ của chương trình con ToAscii.
Khu vực trên cùng của RAM giờ trông như sau:
+-------+
| |
+-------+
0623h: | 28h | Trả địa chỉ sau khi gọi ToAscii
+-------+
| 06h |
+-------+
Lệnh CALL về cơ bản đã rải vụn bánh mì làm dấu cho đường về nhà.
Chương trình con ToAscii giờ đang được thực thi. Nó cũng xài một lệnh CALL gọi tới chương trình con Digit. Vị trí bộ nhớ trong chương trình con ToAscii nằm ngay sau lệnh đó là 14FEh, thế nên khi lệnh CALL diễn ra, địa chỉ sẽ được lưu vào stack, và trông như vầy:
+-------+
| |
+-------+
FFFCh: | FEh | Trả địa chỉ sau khi gọi Digit
+-------+
| 14h |
+-------+
| 28h | Trả địa chỉ sau khi gọi ToAscii
+-------+
| 06h |
+-------+
Giá trị của stack pointer hiện tại là FFFCh, và chương trình con Digit đang chạy. Khi lệnh RET trong chương trình con Digit được thực thi, đây là những gì sẽ xảy ra:
Byte tại vị trí bộ nhớ được stack pointer trỏ tới sẽ được truy xuất. Byte đó là FEh.
Stack pointer được tăng lên.
Byte tại vị trí bộ nhớ được stack pointer trỏ tới sẽ được lôi ra. Byte đó là 14h.
Stack pointer lại được tăng lên.
Hai byte đó sẽ được nạp vào bộ đếm chương trình, và nó sẽ nhảy đến vị trí bộ nhớ 14FEh trong chương trình con ToAscii, trở về với chương trình đã gọi Digit.
Stack giờ đây đã quay lại trạng thái trước khi gọi lần đầu tới Digit:
+-------+
| |
+-------+
FFFEh: | 28h | Trả địa chỉ sau khi gọi ToAscii
+-------+
| 06h |
+-------+
Stack pointer giờ là FFFEh. Địa chỉ 14FEh vẫn còn nằm trong bộ nhớ, nhưng nó chẳng còn ý nghĩa gì nữa. Lần gọi tiếp theo tới Digit sẽ lại đưa một địa chỉ quay về mới lên stack:
+-------+
| |
+-------+
FFFCh: | 09h | Trả địa chỉ sau khi gọi Digit
+-------+
| 15h |
+-------+
| 28h | Trả địa chỉ sau khi gọi ToAscii
+-------+
| 06h |
+-------+
Đó là địa chỉ nằm sau lần gọi thứ hai tới Digit trong chương trình con ToAscii. Khi Digit thực thi lệnh RET một lần nữa, nó sẽ nhảy về địa chỉ 1509h trong chương trình con ToAscii. Stack bây giờ lại trông như này:
+-------+
| |
+-------+
FFFEh: | 28h | Trả địa chỉ sau khi gọi ToAscii
+-------+
| 06h |
+-------+
Đến lúc này thì lệnh RET trong chương trình con ToAscii có thể được thi hành. Nó sẽ lấy địa chỉ 0628h từ stack và rẽ nhánh tới địa chỉ đó, cũng chính là địa chỉ theo sau lần gọi tới ToAscii.
Và đó là cách stack hoạt động.
Về mặt kỹ thuật, stack được phân loại là một dạng lưu trữ Vào-Sau-Ra-Trước (Last-In-First-Out, hay LIFO). Giá trị được thêm vào stack gần đây nhất sẽ là giá trị đầu tiên ra khỏi stack. Stack thường được ví von như một chồng đĩa ăn ở căn tin được đỡ bởi một cái lò xo đàn hồi. Người ta có thể nhét thêm đĩa vào chồng và sau đó lấy ra theo hướng ngược lại.
Khi một thứ gì đó được thêm vào stack, ta gọi đó là "push" (đẩy vào), và khi nó bị lấy ra, ta gọi là "pop" (lấy ra). Intel 8080 cũng hỗ trợ một vài lệnh PUSH và POP để cất giữ các thanh ghi lên stack và lôi chúng ra sau này:
Lệnh | Mô tả | Opcode
---------+-----------------------------------+-------
PUSH BC | Lưu thanh ghi B và C vào stack | C5h
---------+-----------------------------------+-------
PUSH DE | Lưu thanh ghi D và E vào stack | D5h
---------+-----------------------------------+-------
PUSH HL | Lưu thanh ghi H và L vào stack | E5h
---------+-----------------------------------+-------
PUSH PSW | Cất Program Status Word vào stack | F5h
---------+-----------------------------------+-------
POP BC | Lấy thanh ghi B và C từ stack | C1h
---------+-----------------------------------+-------
POP DE | Lấy thanh ghi D và E từ stack | D1h
---------+-----------------------------------+-------
POP HL | Lấy thanh ghi H và L từ stack | E1h
---------+-----------------------------------+-------
POP PSW | Lấy Program Status Word từ stack | F1h
Chữ viết tắt PSW cho Program Status Word (Từ trạng thái chương trình), và nó chẳng có gì mới cả. Nó chỉ là bộ tích lũy ở một byte và các cờ ALU ở byte kia.
Các lệnh PUSH và POP là một mánh lới tiện lợi để cất giữ nội dung của các thanh ghi khi xài tới các chương trình con. Đôi khi, một đoạn mã trước khi gọi một chương trình con sẽ push nội dung của các thanh ghi trước khi dùng lệnh CALL và pop chúng ra sau đó. Trò này cho phép chương trình con xài các thanh ghi thả ga mà khỏi phải lo xem nó có phá đoạn mã đã gọi nó hay không. Hoặc bản thân một chương trình con sẽ tự push các thanh ghi ở phần đầu và pop chúng ra ngay trước lệnh RET.
Các lệnh PUSH và POP phải cân bằng nhau, hệt như CALL và RET. Nếu một chương trình con gọi PUSH tới hai lần mà chỉ gọi POP một lần rồi RET, thì đoạn mã sẽ nhảy tới xó xỉnh nào đó mà bạn không muốn đến đâu!
Những đoạn mã lang thang có thể pop stack quá nhiều lần, làm cho stack pointer bắt đầu trỏ lộn ngược về phần đầu của bộ nhớ thay vì phần cuối! Sự cố này được gọi là stack underflow, và nó có thể khiến nội dung của stack viết đè mã. Một sự cố anh em của nó là khi có quá nhiều thứ bị push lên stack khiến nó phình to ra, và cũng lại ghi đè mã. Sự cố này được gọi là stack overflow, một tình trạng được ưu ái đặt tên cho một diễn đàn internet cực kỳ nổi tiếng dành cho dân lập trình lên tìm kiếm câu trả lời cho những bế tắc kỹ thuật.
Các lệnh CALL và RET không phải là yếu tố bắt buộc để một CPU được coi là Turing complete, nhưng trong thực tế chúng lại cực kỳ tiện lợi, và có người thậm chí còn cho rằng chúng là thứ không thể thiếu. Các chương trình con là các phần tử nền tảng của chương trình hợp ngữ, và chúng cũng sắm vai trò cốt cán trong rất nhiều loại ngôn ngữ lập trình khác.
E là tôi sẽ không thêm CALL, RET, PUSH, và POP vào CPU đang thiết kế trong vài chương qua. Tôi thấy rất áy náy về điều đó, nhưng chúng đòi hỏi một thiết kế biến hóa đa đoan hơn nhiều so với cái tôi đã bày cho bạn xem. Dù vậy, tôi cá là bạn có thể dễ dàng tưởng tượng ra cách người ta triển khai chúng: Cứ đắp thêm một chốt 16-bit mới toanh gọi là stack pointer vào bus địa chỉ. Nó sẽ trông giống y chốt lưu giữ bộ đếm chương trình. Đấy mới là phần dễ. Nhưng nó cũng sẽ bắt buộc phải push bộ đếm chương trình lên stack khi xài lệnh CALL và pop nó ra khỏi stack khi xài lệnh RET, và việc này sẽ ép buộc 2 byte của bộ đếm chương trình cũng phải trên bus dữ liệu. Không có trong thiết kế hiện tại.
Dù không thêm các lệnh liên quan stack vào CPU đang ráp, tôi đã tạo ra một trình giả lập 8080 hoàn chỉnh trên trang web CodeHiddenLanguage.com.
Trong vài chương vừa qua, bạn đã thấy cách bộ vi xử lý 8-bit như Intel 8080 thực thi các mã lệnh. Intel đã trình làng 8080 vào năm 1974, và với các hậu bối thì giờ đây nó bị xem là "đồ cổ". Khi các CPU tăng kích cỡ để xử lý 16-bit, 32-bit, và thậm chí là 64-bit, chúng cũng trở nên rất phức tạp.
Ấy vậy mà, tất cả các CPU đều vận hành dựa trên cùng một nguyên lý cốt lõi: Chúng thực thi các lệnh để lôi các byte từ bộ nhớ, thực thi các phép toán số học và logic lên chúng, rồi lại lưu chúng vào bộ nhớ.
Đã đến lúc khám phá xem cần thêm những gì để tạo ra một chiếc máy tính đích thực.